# lith v1 · hardware reference

For anyone knapping their lith by hand in an IDE (Arduino IDE, PlatformIO, etc.)
instead of through Oldowan. This is the same ground truth Oldowan itself
builds from, paste it into your IDE's project notes, or straight into an AI
coding assistant if you're using one alongside your IDE.

## Board

- **ESP32-S3-Zero** (supermini form factor), 4MB flash QIO, native USB, CDC on boot
- Arduino core: `esp32` 3.x
- FQBN: `esp32:esp32:esp32s3:CDCOnBoot=cdc`
- Flash offsets: bootloader `0x0` · partitions `0x8000` · boot_app0 `0xe000` · app `0x10000`

## Pin map

| Signal      | Pin |
|-------------|-----|
| TFT SCLK    | 12  |
| TFT MOSI    | 11  |
| TFT DC      | 13  |
| TFT CS      | 10  |
| TFT RST     | 9   |
| TFT BLK     | 8   |
| SW1         | 1   |
| SW2         | 2   |
| ENC_A       | 4   |
| ENC_B       | 5   |
| MOTOR       | 6   |

## Display

ST7789V2, 170×320, driven with **LovyanGFX**. After `tft.setRotation(1)` it's
320×170 landscape. Reuse this init verbatim: the panel needs exact offsets
that aren't guessable from a datasheet:

```cpp
#define LGFX_USE_V1
#include <LovyanGFX.hpp>
class LGFX : public lgfx::LGFX_Device {
  lgfx::Panel_ST7789 _panel; lgfx::Bus_SPI _bus; lgfx::Light_PWM _light;
public:
  LGFX() {
    { auto cfg = _bus.config(); cfg.spi_host = SPI2_HOST; cfg.spi_mode = 0;
      cfg.freq_write = 50000000; cfg.freq_read = 16000000; cfg.spi_3wire = false;
      cfg.use_lock = true; cfg.dma_channel = SPI_DMA_CH_AUTO;
      cfg.pin_sclk = 12; cfg.pin_mosi = 11; cfg.pin_miso = -1; cfg.pin_dc = 13;
      _bus.config(cfg); _panel.setBus(&_bus); }
    { auto cfg = _panel.config(); cfg.pin_cs = 10; cfg.pin_rst = 9; cfg.pin_busy = -1;
      cfg.panel_width = 170; cfg.panel_height = 320; cfg.offset_x = 35; cfg.offset_y = 0;
      cfg.offset_rotation = 2; cfg.readable = false; cfg.invert = true;
      cfg.rgb_order = false; cfg.dlen_16bit = false; cfg.bus_shared = false;
      _panel.config(cfg); }
    { auto cfg = _light.config(); cfg.pin_bl = 8; cfg.invert = false;
      cfg.freq = 44100; cfg.pwm_channel = 7; _light.config(cfg); _panel.setLight(&_light); }
    setPanel(&_panel);
  }
};
// usage: LGFX tft; tft.init(); tft.setRotation(1); tft.setBrightness(180);
```

Draw into a 16-bit 320×170 `LGFX_Sprite` and `pushSprite()` it once per frame
rather than drawing primitives straight to the panel, avoids flicker/tearing.

## Switches

Two MX-style mechanical keyswitches, SW1 and SW2, wired **active low**. Use
`INPUT_PULLUP`. Debounce in software (~25 ms) and distinguish a short tap from
a long press (~800 ms hold); don't block waiting for either, compare against
`millis()` each loop pass.

## Encoder

The orange side scroll wheel: quadrature on ENC_A/ENC_B, `INPUT_PULLUP`, 4
quadrature steps per detent. **Keep this interrupt-driven, never polled in
`loop()`.** Attach a `CHANGE` interrupt on both pins into a small ISR that
only updates a `volatile` raw counter via a quadrature lookup table (no
Serial/heap/drawing inside the ISR). `loop()` reads that counter with
interrupts briefly disabled, divides by 4 to get detents, and only reacts to
the delta since the last detent; reacting per raw edge (4x too often, plus
contact bounce) makes scrolling feel jittery instead of matching the wheel's
physical click. This is also what decouples scroll responsiveness from
whatever else `loop()` is doing that tick (rendering, motor, etc.); the
single most common way a homebrew lith sketch ends up feeling laggy is
polling the encoder pins inside the render/update cadence instead of off an
interrupt.

## Vibration motor

Small motor behind an NPN low-side switch on the MOTOR pin. Drive with LEDC
PWM at 20 kHz, 8-bit resolution. Duty ~190 for a gentle buzz, 255 briefly for
a crisp tap. Treat it as fire-and-forget: set a `millis()`-based deadline and
let a small `motorService()` called every loop turn the PWM on/off
accordingly; never block waiting for a pulse to finish, and always let duty
fall back to 0 when idle.

## Libraries available on the build server

If you're compiling locally in an IDE you aren't limited to this list, but
anything you want to eventually publish through the knappery has to build on
these:

- LovyanGFX (display, reuse the init snippet above)
- QRCode by ricmoo (`qrcode.h`)
- Preferences (ESP32 NVS key-value storage)
- everything in the ESP32 Arduino core 3.x (WiFi, BLE, LEDC, etc.)

## Engineering heuristics

Distilled from the hardware bring-up sketch (`firmware/sketches/bringup.cpp`
in the [lith firmware repo](https://github.com/altofctrl/lith)). These are
what keep a lith sketch feeling responsive instead of laggy:

- Never call `delay()` in `loop()` or anywhere on the hot path. Every
  subsystem (buttons, motor, animation) should be driven off
  `millis()`-based timestamps so one slow feature can't stall input handling
  for the others.
- The encoder stays interrupt-driven, converted to detents before you act on
  it (see above); this is the single biggest lever on scroll feel.
- Debounce switches (~25 ms) and split short vs. long press (~800 ms)
  non-blocking.
- Throttle screen redraws to a fixed cadence (~25-40 fps) instead of drawing
  every loop iteration; draw into an off-screen sprite and push it once per
  frame.
- Motor/haptic output is fire-and-forget via a deadline timestamp, never a
  blocking wait.
- Drain all available Serial input each loop pass (`while (Serial.available())`),
  not one byte per pass.
- Use fixed-size `char` buffers with `snprintf` for logging/formatting, not
  the `String` class, to avoid heap fragmentation in a sketch that runs
  indefinitely.
- Keep `loop()` a short dispatch of small per-subsystem functions, each
  guarded by its own timing check, rather than inlining state machines
  directly in `loop()`.
- Net effect: any new feature must never come at the cost of encoder/button
  responsiveness. If something needs to block, block a state machine's
  transition, not the loop.
- There is no visible LED on the enclosure. Don't write status blinks,
  `digitalWrite` on the onboard LED pin, or any LED-based feedback into a
  sketch; the screen and motor are the only user-visible outputs.

## Stock firmware

The shipped firmware is a pomodoro timer: encoder sets the length shown when
idle; SW1 short press starts/pauses/resumes, SW1 long press resets the phase;
SW2 short press skips to the next phase, SW2 long press cycles which setting
the encoder edits; every fourth break is long. It also shows a QR splash to
`lith.vidalion.co/onboarding` for the first 10 boots after each new flash.
Full source: `firmware/src/main.cpp` in the
[lith firmware repo](https://github.com/altofctrl/lith).
