# Flight Panel R1 software

This release controls **two rows of seven moving characters**, using fourteen **38-position** drums and five MCP23017 boards. Every row position moves, including the hyphen in `ORD-LAX`. The alphabet, in drum order, is:

```text
 ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-
```

The first character is a space. The original 37-position drum is **not compatible** with this release. The new drum, Hall timing and printed leaf boundaries still need physical validation. The software builds and native tests pass; no real controller has been flashed or tested for this release.

## Build and explicit USB flash

From `/Users/john/splitflap-display`:

```bash
./scripts/build.sh
python3 -m unittest discover -s tests -v
```

The build creates an isolated `.venv` if needed, installs pinned PlatformIO requirements, builds the browser controls, compiles ESP32-C3 firmware and generates LittleFS. It does **not** upload. Node 20+ and Python 3.10+ are needed. Dependencies are pinned in `platformio.ini` and `package-lock.json`. Pioarduino 53.03.11 supplies Arduino-ESP32 3.1.1 and native Apple Silicon tools. On Apple Silicon a checksum-pinned native `mklittlefs` is used; no Rosetta is installed.

Outputs are under `firmware/.pio/build/esp32_c3/`: `firmware.bin`, `littlefs.bin`, `bootloader.bin`, `partitions.bin`. The included 4 MB partition map has 1.5 MiB application slots and an 896 KiB filesystem. `scripts/verify_artifacts.py` checks actual binary sizes and partition overlap. Use the complete matching build; the application binary alone is not a complete image to write at address zero.

With motor power disconnected, inspect the actual port and explicitly select it:

```bash
.venv/bin/pio device list
./scripts/flash.sh /dev/cu.usbmodemYOUR_BOARD
.venv/bin/pio device monitor --port /dev/cu.usbmodemYOUR_BOARD --baud 460800
```

Replace the example port. The script requires an existing serial character device and never chooses one automatically. It uploads firmware and filesystem. Flashing/rebooting attempts homing; keep motors unpowered until the wiring is checked. Do not connect computer USB and external board 5 V simultaneously unless the exact board's power isolation is verified.

## First connection and one-module commissioning

1. Build the new 38-position mechanism, with its magnet and Hall sensor. Wire **module 0 to MCP 0x20 slot 0**, using the electrical guide. The default is one active module,6 RPM ceiling, nominal 2048 full steps/revolution. The three shared-expander slots are not three separate I²C addresses.
2. On USB serial, type `?` to print the access token and the randomly generated setup hotspot password. Join **SplitFlap-Setup**, open `http://192.168.4.1`, paste the token and select Connect. The token stays in that browser tab. The setup hotspot uses a password.
3. Save home Wi-Fi credentials using the page. Once connected, the hotspot closes; open `http://splitflap.local` from the same network, or use the serial-printed IP. If no configured network connects within 20 seconds, the setup hotspot opens again. Credentials stay in device NVS, not the repository.
4. Inspect power, pin order, sensor behavior and free drum rotation. Reconnect motor power, then select **Recover and home**. Initial power without motor supply can produce a Hall timeout; recovery is explicit after correction.
5. Test blank, A, Z,0,9 and hyphen. Then test all 38 positions through repeated wraps. Tune the common Hall reference and individual module offset. The reference730 is inherited only as a **starting estimate**, not a measured calibration for the new drum. Increasing an offset changes the logical position assigned to the trailing Hall edge; determine its physical sign in small increments.
6. Validate repeated homing and power cycles, loaded torque, current and temperatures before duplicating the mechanism.

For terminal control, store the serial token in a local file outside the repository with permissions600, then use `--token-file /absolute/path/to/token`. Alternatively set `SPLITFLAP_TOKEN` in the local shell. Never commit it.

```bash
python3 scripts/splitflap.py --dry-run configure --modules 1
python3 scripts/splitflap.py --token-file /path/to/token configure --modules 1
python3 scripts/splitflap.py --token-file /path/to/token status
python3 scripts/splitflap.py --token-file /path/to/token test 0 A
python3 scripts/splitflap.py --token-file /path/to/token test 0 '-'
python3 scripts/splitflap.py --token-file /path/to/token test 0 ' '
```

`configure` saves the full profile and immediately re-homes. It resets offsets to zero unless `--offsets` supplies all 14 measured values. All 14 offsets exist even while commissioning a subset. Configurations with 37 flaps, unknown settings, out-of-range counts or malformed offsets are rejected. R1 uses a separate `flight38` NVS namespace; old PCF settings are not imported. Reflashing R1 preserves its saved calibration and Wi-Fi settings.

## Enable the full panel

Add hardware in module order0 through 13 and verify each bank before increasing the active count. Exactly five addresses are used:0x20–0x24. `firmware/profiles/commission-one.json` and `flight-panel.json` show the complete configuration shapes.

```bash
python3 scripts/splitflap.py --token-file /path/to/token configure --modules 14
python3 scripts/splitflap.py --token-file /path/to/token flight ORD LAX UA 1899
python3 scripts/splitflap.py --token-file /path/to/token display 'ORD-LAX' 'UA 1899'
```

Preserve calibrated offsets with `--offsets` when changing the count. The CLI uppercases ASCII and pads short generic rows on the right. The device API requires exactly 7 characters per row. Flight numbers are strings so leading zeros are retained. `UA` plus a space plus `1899` fills seven cells; a shorter number is padded on the right. Manual inputs are always labelled **not live**.

## Actual API

All API requests require `Authorization: Bearer <device-token>`. POST bodies are JSON, limited to 2048 bytes. The controller uses HTTP on a trusted local network; do not expose it by port forwarding. There is no cloud account, OTA updater or MQTT service in R1.

| Endpoint | Body or result |
| --- | --- |
| `GET /api/status` | Fault, homing/busy flags, energized count, homed count, active/pending/settled sequence numbers and text; input source and freshness |
| `GET /api/config` | Current nonsecret full38-position configuration and fixed bus topology |
| `POST /api/flight` | `{"origin":"ORD","destination":"LAX","airline":"UA","flight":"1899"}` |
| `POST /api/display` | `{"rows":["ORD-LAX","UA 1899"]}`; alternatively `{"text":"ORD-LAXUA 1899"}` |
| `POST /api/test` | `{"module":0,"character":"-"}`; active commissioning subset allowed |
| `POST /api/config` | Full object from one of the two profile files; always14 offsets and `mechanismFlaps:38` |
| `POST /api/stop` | `{}`; holds RESET and inhibits movement |
| `POST /api/recover` | `{}`; after inspection, resets expanders and re-homes every active module |
| `POST /api/wifi` | `{"ssid":"your-network","password":"your-password"}`; credentials are never returned |

Full flight/display requests need all 14 active modules. Invalid input returns400, unauthorized401, inhibited/incomplete-layout409, full control queue429. A202 response means the command was queued, **not that the flap physically reached its character**. The control task is the sole hardware owner. Network callbacks enqueue commands; they never drive motors.

A frame is one atomic logical target across both rows. Mechanical transitions are staggered and visibly asynchronous. While a frame moves, later requests replace one pending target; the current frame finishes before the newest pending frame begins. There is no unlimited command backlog. `active_sequence`, `pending_sequence`, and `settled_sequence` distinguish these stages. Stop discards queued work. Recovery clears old targets and requires fresh homing.

`input_sequence` identifies which accepted input the metadata describes; it can be newer than the active or settled frame. `input_fresh` describes that input's timestamp, **not** what the physical display currently shows. `settled_text` is a software position estimate after homing; individual characters have no position encoder. `position_verified` remains false. `live` remains false because this firmware does not independently verify a flight provider, including when the host submits external data.

## Bring real flight data without inventing a feed

The host bridge accepts a provider-neutral JSON observation from a file or stdin. Your chosen real provider/integration must supply the fields, source label and actual observation time. No airline-data subscription, API key or imaginary feed is bundled.

```json
{
  "origin": "ORD",
  "destination": "LAX",
  "airline": "UA",
  "flight": "1899",
  "source": "YOUR_REAL_SOURCE",
  "observed_at": 0,
  "max_age_seconds": 120
}
```

This is a **schema example**, not a live observation; timestamp0 is deliberately rejected. Use UTC epoch seconds from the actual observation. The host and device reject observations older than the TTL, more than 30 seconds in the future, or TTLs outside 10–300 seconds. The device requires a synchronized UTC clock; NTP uses `pool.ntp.org` and `time.nist.gov`. Manual control works without Internet/NTP.

```bash
python3 scripts/splitflap.py --token-file /path/to/token bridge --input /path/to/flight.json
python3 scripts/splitflap.py --token-file /path/to/token bridge --input /path/to/flight.json --watch 5
```

Watch mode sends only changed observations and reports file/provider errors. An upstream adapter should replace its file atomically to avoid partial JSON reads. Stopping the bridge does not extend data validity. When the device's observation expires, it replaces the pending target with a blank top row and `NO DATA` below. Already moving mechanisms finish their current frame before that stale-data frame; status marks stale immediately. A complete physical transition can take tens of seconds, so choose TTLs that suit this display's actual measured travel time. HTTP redirects are refused so access tokens are not forwarded to other servers.

## Motion behavior and limits

The compile-time `STEPS=2048` assumes 2048 full steps per output revolution. Offsets correct phase, not gearbox scale. Progressive character drift around a revolution requires the correct 1:64 motor or a measured, revalidated `STEPS` constant and all 38 target positions; offset adjustment alone cannot solve it.

The four-state motor sequence preserves the original IN1–IN4 order:0x3,0x9,0xC,0x6. Each MCP has one 16-bit output shadow; dirty words are written once per tick, preserving sibling phases even where a bank crosses the two rows. GPA7 is only used as an output; GPB7 is held output-low.

No more than four motors are energized, including settling time. Homing starts are separated by at least 250 ms. Homing requires two stable LOW samples followed by two stable HIGH samples; an invalid I²C read cannot become a Hall edge. The trailing edge establishes reference+offset modulo 2048. Missing/stuck Hall during search or excessive travel aborts after 4096 steps. Every read/write failure, including the last step or coil release, latches a fault and asserts the independent hardware RESET line. No automatic retry resumes a possibly mispositioned drum.

Hall polling is nominally every 4 ms. At 100 kHz, transaction time and a 1 ms loop yield reduce achievable speed; **configured RPM is a ceiling, not a guaranteed measured speed**. Four motors across several expanders can run below 6 RPM. Target travel has no catch-up burst after delays. Actual bus rise time, pulse width, Hall debounce, loaded speed, gearbox ratio and offsets need bench measurement. At the ideal 6 RPM a full revolution is 10 seconds; four groups of 14 modules can approach 40 seconds for a worst-case frame, plus settling and bus overhead. Homing can take longer. Design data-refresh expectations around this mechanism, not an LED display.

The reset line handles software-detected I²C faults independently of the bus. It is not a certified emergency stop, current sensor or hardware watchdog. A dead CPU, broken reset wire, driver short, wiring fault or mechanical jam can require disconnecting motor power at the accessible barrel plug. Never rely on a web button to remove power from a stalled prototype.

## Verification and source history

`tests/native/flight_test.cpp` compiles the actual portable `FlightCore.cpp` with a fake transport. It tests safe register ordering, address/slot mapping, shared-bank preservation, reset on read/write errors, recovery, Hall timeout, last-step NACK, bounded concurrency, homing stagger, latest-target coalescing,38-character formatting, every character over two complete cycles with extreme offsets, dash-to-blank wrap and subset commissioning. Python tests cover strict profiles, fields/freshness, dry-run behavior, real local HTTP request shape and redirect refusal. They do not simulate motor torque or prove physical alignment. Optional browser acceptance uses `scripts/test_firmware_web.py` with the pinned `scripts/requirements-ui.txt` and Playwright Chromium installed; its screenshots and API requests are explicitly mocked, not device evidence.

The project began from [jhoff/Split-Flap-Display, pinned commit 1c41968](https://github.com/jhoff/Split-Flap-Display/tree/1c41968ba877170274e0b4905acbe5cf49c354e5), based on Morgan Manly's mechanism and contributions by Jordan Hoff and Thom Koopman. R1 replaces the active PCF/flat-row firmware with the MCP controller above; old source is in Git history. Source archive and tool hashes are recorded in `evidence/firmware-source-lock.json`. The pinned upstream repository has no top-level license file; attribution is retained, and no commercial redistribution license is claimed. Current evidence is `evidence/firmware-r1-*`; earlier logs describe the superseded 37-position implementation.
