Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ jobs:
run: pip install -U platformio
- name: Build firmware
run: pio run
- name: Build OLED expression demo
run: pio run -e expression-demo
- name: Host Unity tests
run: pio test -e native

Expand All @@ -43,6 +45,10 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Check OLED expression assets
run: |
node scripts/expressions/generate.js --check
node scripts/expressions/test-assets.js
- name: Cursor package tests
run: npm test --prefix packages/tiny-engineer-cursor
- name: Antigravity package tests
Expand Down
15 changes: 14 additions & 1 deletion docs/testing.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Tests

Host checks. Neither flashes the board. GitHub Actions on `main` and PRs runs these same commands. On-device hardware: [hardware/testing.md](hardware/testing.md).
Host checks. These commands do not flash the board. GitHub Actions on `main` and PRs runs these same commands. On-device hardware: [hardware/testing.md](hardware/testing.md).

## Firmware

Expand All @@ -12,6 +12,19 @@ pio test -e native

`pio run` builds firmware. Native tests are **`pio test -e native`**, not `pio run -e native`.

## OLED expressions

The optional [expression library](../lib/TinyEngineerExpressions/README.md) has deterministic asset checks and native renderer tests. These checks require Node 18+ and PlatformIO; they do not access hardware.

```bash
node scripts/expressions/generate.js --check
node scripts/expressions/test-assets.js
pio test -e native
pio run -e expression-demo
```

The `expression-demo` environment builds a separate OLED-only application without changing the default robot build. For a later bench test, follow its [wiring and upload notes](../lib/TinyEngineerExpressions/README.md#standalone-oled-demo), leave the separate servo supply off, and observe all 16 faces through a complete 48-second cycle. Report physical display results separately from host tests and compilation.

## Packages

Node 18+. No robot.
Expand Down
75 changes: 75 additions & 0 deletions examples/expression-demo/main.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
#include <Arduino.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <TinyEngineerExpressions.h>

// Adafruit rotations: 0 = normal, 2 = 180 degrees. Match your mounting.
#ifndef EXPRESSION_DEMO_ROTATION
#define EXPRESSION_DEMO_ROTATION 0
#endif

namespace {
namespace expressions = tiny_engineer::expressions;

constexpr uint8_t kRotation = EXPRESSION_DEMO_ROTATION;
static_assert(kRotation == 0 || kRotation == 2,
"The 128 x 32 demo supports rotation 0 or 2");
constexpr uint8_t kOledAddress = 0x3C;
constexpr int kSda = 0;
constexpr int kScl = 1;

Adafruit_SSD1306 display(expressions::kWidth, expressions::kHeight, &Wire, -1);
uint8_t frame[expressions::kFrameBytes];
uint8_t expressionIndex = 0;
uint32_t expressionStarted = 0;
uint32_t lastRendered = 0;
bool displayReady = false;
}

void setup() {
// This example does not initialize a servo controller. Keep the separate
// servo V+ supply off when testing an OLED on an assembled robot.
Serial.begin(115200);
Wire.begin(kSda, kScl);
Wire.beginTransmission(kOledAddress);
if (Wire.endTransmission() != 0) {
Serial.println("No OLED response at I2C address 0x3C");
return;
}
displayReady = display.begin(SSD1306_SWITCHCAPVCC, kOledAddress, true, false);
if (!displayReady) {
Serial.println("OLED initialization failed");
return;
}
display.setRotation(kRotation);
expressionStarted = millis();
lastRendered = expressionStarted - expressions::kFrameDurationMs;
Serial.println("Expression: idle");
}

void loop() {
if (!displayReady) return;

const uint32_t now = millis();
if (uint32_t(now - lastRendered) < expressions::kFrameDurationMs) return;
lastRendered = now;

uint32_t elapsed = now - expressionStarted;
if (elapsed >= expressions::kLoopDurationMs) {
const uint32_t skipped = elapsed / expressions::kLoopDurationMs;
expressionIndex = (expressionIndex + skipped) % expressions::kExpressionCount;
elapsed %= expressions::kLoopDurationMs;
expressionStarted = now - elapsed;
Serial.print("Expression: ");
Serial.println(expressions::name(static_cast<expressions::Expression>(expressionIndex)));
}

const auto expression = static_cast<expressions::Expression>(expressionIndex);
if (expressions::render(expression, elapsed, frame, sizeof(frame))) {
display.clearDisplay();
display.drawBitmap(0, 0, frame, expressions::kWidth,
expressions::kHeight, SSD1306_WHITE);
display.display();
}
}
98 changes: 98 additions & 0 deletions lib/TinyEngineerExpressions/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# Tiny Engineer Expressions

Sixteen animated, kaomoji-inspired faces for a **128 × 32 monochrome OLED**. The C++ library renders a frame into caller-owned memory; the application decides when and where to show it.

This is an optional display library. It does not replace Tiny Engineer's existing eye modes or map expressions to HTTP commands, AI events, or robot poses. It does not access I²C, initialize a display, change its rotation, or issue servo commands.

## Faces

The display numbers below are one-based; the enum values are zero-based and follow this order.

| No. | `Expression` | Face |
| --- | --- | --- |
| 1 | `Idle` | Relaxed idle |
| 2 | `Happy` | Happy |
| 3 | `Laugh` | Laughing |
| 4 | `Wink` | Wink |
| 5 | `Curious` | Curious |
| 6 | `Thinking` | Thinking |
| 7 | `Surprise` | Surprised |
| 8 | `Smug` | Smug |
| 9 | `Sleepy` | Sleepy |
| 10 | `Sleep` | Sleeping |
| 11 | `Sad` | Sad |
| 12 | `Cry` | Crying |
| 13 | `Angry` | Angry |
| 14 | `Panic` | Panicked |
| 15 | `Shy` | Shy |
| 16 | `Love` | Heart eyes |

Each face has 30 frames, spaced 100 ms apart, for a repeating 3-second animation. Faces are original pixel drawings made from geometric primitives, with no external font or image dependency. Source and generated assets use the repository's [MIT license](../../LICENSE).

## Use from C++

```cpp
#include <TinyEngineerExpressions.h>

namespace faces = tiny_engineer::expressions;
uint8_t frame[faces::kFrameBytes]; // 512 bytes, owned by the caller

bool ready = faces::render(faces::Expression::Happy, elapsedMs,
frame, sizeof(frame));
```

`render()` uses `elapsedMs` since the expression was selected. Start at zero for the first frame; elapsed time wraps within the animation every `kLoopDurationMs` (3000 ms). The function has no clock or playback state and does not delay. The caller controls updates, expression selection, and stopping. For Arduino, unsigned `millis() - startedAt` subtraction handles a timer rollover; reset or advance the start timestamp during long-running playback, as the example does.

`render()` returns `false` for an invalid expression, a null buffer, or a buffer smaller than `kFrameBytes`, leaving the supplied buffer unchanged. `name()` returns a stable lowercase identifier for a valid expression and `nullptr` for an invalid one. There is no dynamic allocation in the renderer.

The result is **row-major, MSB-first**, 16 bytes per row: pixel `(0, 0)` is bit 7 of byte 0. It is compatible with Adafruit GFX `drawBitmap()`. It is **not** the vertical-page layout returned by SSD1306 `getBuffer()`; do not copy it directly there.

For an already initialized Adafruit display:

```cpp
if (faces::render(selectedExpression, elapsedMs, frame, sizeof(frame))) {
display.clearDisplay();
display.drawBitmap(0, 0, frame, faces::kWidth, faces::kHeight,
SSD1306_WHITE);
display.display();
}
```

The application still owns display initialization, clearing, brightness, orientation, and sleep behavior. When integrating into the robot firmware, use the existing display owner and saved rotation setting. Avoid two renderers updating the same display concurrently. Selecting this library must be an explicit application decision, so the existing eye-mode and motion behavior can remain the default.

## Standalone OLED demo

[`examples/expression-demo/main.cpp`](../../examples/expression-demo/main.cpp) cycles through all 16 faces, showing each for three seconds. It schedules frames without `delay()` and uses a static 512-byte decode buffer. Adafruit SSD1306 additionally owns its normal display framebuffer.

The example targets the Waveshare ESP32-C3-Zero with SDA = GPIO 0, SCL = GPIO 1, and OLED address `0x3C`. Rotation defaults to 0; add `-D EXPRESSION_DEMO_ROTATION=2` to the example environment's `build_flags` for a display mounted upside down. This affects only the example, not the robot's saved settings.

Build only:

```bash
pio run -e expression-demo
```

For an intentional bench test, keep the **separate servo V+ rail switched off**, connect the OLED and ESP32 via their normal logic wiring, and power the ESP32 through USB. This example does not send PCA9685 commands, so it does not disable outputs that a powered controller might retain. Uploading the demo replaces the application currently on the ESP32; retain your previous firmware if you want to restore it.

```bash
pio run -e expression-demo -t upload
pio device monitor -e expression-demo
```

Observe all 16 expressions, a complete 48-second cycle, the screen orientation, and smooth frame updates. Hardware behavior must be checked on the target display before calling the library bench-tested.

## Assets and checks

The deterministic generator packs the pixels, deduplicates identical frames, and applies lossless byte-run encoding. The 480 frame references share 87 unique frames; the encoded payload and lookup tables occupy about 18.2 KiB. Decoding requires the caller's 512-byte output buffer rather than a table of uncompressed animation frames.

From the repository root:

```bash
node scripts/expressions/generate.js --check
node scripts/expressions/test-assets.js
pio test -e native
pio run
pio run -e expression-demo
```

After editing the drawing source, regenerate assets with `node scripts/expressions/generate.js` and review the resulting pixel changes. The checks cover generated-file consistency, frame data, and the C++ renderer. Compilation and host checks do not establish I²C reliability, OLED orientation, or on-device animation quality; record those results separately in a PR.
9 changes: 9 additions & 0 deletions lib/TinyEngineerExpressions/library.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "TinyEngineerExpressions",
"version": "0.1.0",
"description": "Portable 128x32 monochrome robot expression frames with caller-owned animation timing.",
"keywords": ["oled", "expressions", "animation", "robot"],
"license": "MIT",
"frameworks": "*",
"platforms": "*"
}
76 changes: 76 additions & 0 deletions lib/TinyEngineerExpressions/src/TinyEngineerExpressions.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
#include "TinyEngineerExpressions.h"
#include "expression_assets.h"

#include <cstring>

namespace tiny_engineer {
namespace expressions {

namespace {

uint8_t readByte(const uint8_t* address) {
#if defined(ARDUINO)
return pgm_read_byte(address);
#else
return *address;
#endif
}

uint16_t readWord(const uint16_t* address) {
#if defined(ARDUINO)
return pgm_read_word(address);
#else
return *address;
#endif
}

const char* const kNames[kExpressionCount] = {
"idle", "happy", "laugh", "wink", "curious", "thinking", "surprise", "smug",
"sleepy", "sleep", "sad", "cry", "angry", "panic", "shy", "love"};

} // namespace

const char* name(Expression expression) {
const auto index = static_cast<uint8_t>(expression);
return index < kExpressionCount ? kNames[index] : nullptr;
}

bool render(Expression expression, uint32_t elapsedMs,
uint8_t* output, std::size_t outputSize) {
const auto index = static_cast<uint8_t>(expression);
if (index >= kExpressionCount || output == nullptr || outputSize < kFrameBytes) {
return false;
}

const auto frame = (elapsedMs % kLoopDurationMs) / kFrameDurationMs;
const auto uniqueFrame = readByte(&detail::kFrameIndices[index][frame]);
if (uniqueFrame >= detail::kUniqueFrameCount) return false;

const std::size_t begin = readWord(&detail::kFrameOffsets[uniqueFrame]);
const std::size_t end = readWord(&detail::kFrameOffsets[uniqueFrame + 1]);
if (begin > end || end > detail::kRleDataSize || (end - begin) % 2 != 0) {
return false;
}

// Validate the complete frame before writing, so even corrupt assets leave
// the caller's buffer unchanged. Every pair is an unsigned (count, value).
std::size_t decodedSize = 0;
for (std::size_t cursor = begin; cursor < end; cursor += 2) {
const auto count = readByte(&detail::kRleData[cursor]);
if (count == 0 || count > kFrameBytes - decodedSize) return false;
decodedSize += count;
}
if (decodedSize != kFrameBytes) return false;

std::size_t written = 0;
for (std::size_t cursor = begin; cursor < end; cursor += 2) {
const auto count = readByte(&detail::kRleData[cursor]);
const auto value = readByte(&detail::kRleData[cursor + 1]);
std::memset(output + written, value, count);
written += count;
}
return true;
}

} // namespace expressions
} // namespace tiny_engineer
66 changes: 66 additions & 0 deletions lib/TinyEngineerExpressions/src/TinyEngineerExpressions.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
#pragma once

#include <cstddef>
#include <cstdint>

#if defined(ARDUINO)
#if defined(__AVR__)
#include <avr/pgmspace.h>
#else
#include <pgmspace.h>
#endif
#endif

#ifndef TE_EXPR_PROGMEM
#if defined(ARDUINO)
#define TE_EXPR_PROGMEM PROGMEM
#else
#define TE_EXPR_PROGMEM
#endif
#endif

namespace tiny_engineer {
namespace expressions {

enum class Expression : uint8_t {
Idle,
Happy,
Laugh,
Wink,
Curious,
Thinking,
Surprise,
Smug,
Sleepy,
Sleep,
Sad,
Cry,
Angry,
Panic,
Shy,
Love
};

constexpr std::size_t kExpressionCount = 16;
constexpr std::size_t kWidth = 128;
constexpr std::size_t kHeight = 32;
constexpr std::size_t kFrameBytes = 512;
constexpr std::size_t kFramesPerExpression = 30;
constexpr uint32_t kFrameDurationMs = 100;
constexpr uint32_t kLoopDurationMs = 3000;

// Stable lowercase identifiers for the 16 selectable expressions; invalid
// enum values return nullptr.
const char* name(Expression expression);

// Render the frame at elapsedMs since the caller selected this expression.
// The caller owns timing, selection, and display output. Animation loops every
// kLoopDurationMs; this function never reads a clock or accesses a display.
// Output is 128x32, row-major, MSB first, compatible with drawBitmap().
// Invalid arguments or malformed assets return false without changing output.
// A valid call writes exactly kFrameBytes and leaves any remaining bytes intact.
bool render(Expression expression, uint32_t elapsedMs,
uint8_t* output, std::size_t outputSize);

} // namespace expressions
} // namespace tiny_engineer
Loading