72 lines
8.8 KiB
Markdown
72 lines
8.8 KiB
Markdown
# CLAUDE.md
|
|
|
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
|
|
## What this repository is
|
|
|
|
Firmware for the UiMetrix shop-floor RFID tag-reader stations: an M5Stack CoreS3 with an RFID Unit and an RGB light bar, running MicroPython on the UIFlow 2.0 firmware (`M5`, `m5ui`, `unit`, `hardware`, `umqtt.simple`, `esp32.NVS` APIs). The whole program is the single file `main.py`, served to the devices exactly as committed (the repo stores LF; the Windows checkout shows CRLF through `core.autocrlf`). It was `M5-Stack Code for Uimetrix.txt` until 2026-09-15; `git log --follow main.py` crosses the rename.
|
|
|
|
The repository is hosted on the company Gitea at `https://git.utopiadeals.com/UIND/UiMetrix-M5-Firmware` (branch `main`), and **that is what the devices update from** (see "Releasing to devices"): a push to `main` that also bumps `version.txt` goes live on every box at its next boot, so keep unfinished work off `main` or leave `version.txt` alone until it is ready. There is no PC-side toolchain: nothing to build, lint or run here, and there are no tests. Changes can only be verified on a device; debug output is `print()` over the USB serial port.
|
|
|
|
The server that talks to this device is the UiMetrix ASP.NET Core app in `D:\Projects\UiMetrix` (`Services/MqttService.cs`; its own `CLAUDE.md` documents the server side). `D:\Projects\MqttWorkerService` is an older standalone worker that speaks the same protocol, and its `tools/rfid_enrol.py` numbers cards from this device's MQTT messages.
|
|
|
|
## Releasing to devices (OTA)
|
|
|
|
Devices update themselves at boot from this repo's `main` branch on Gitea, over its anonymous raw-file URLs (`.../raw/branch/main/version.txt` and `.../raw/branch/main/main.py`; the host resolves to a public address, so any box with internet reaches it). `check_for_update()` fetches `version.txt`, compares it as a float against the `fw_version` string in NVS namespace `storage` (default `"1.0"`), and if newer downloads `main.py` and hands it to `install_update()`. The download must be HTTP 200, at least 1000 bytes, contain `def setup` and `def loop`, and compile; otherwise it is rejected. Installation writes it to `/flash/main_ota_temp.py`, renames the running program to `/flash/main_prev.py` (a rollback copy for manual recovery over USB), renames the download to `/flash/main.py`, and only then stores the new version in NVS and resets. A rejected download or a failed swap leaves the old program and the old version in place, so the next boot retries. A release is therefore: push the new `main.py` and bump `version.txt`. Things to keep in mind:
|
|
|
|
- Versions compare as floats, so `1.10` is older than `1.9`.
|
|
- Builds before 2026-09-11 downloaded the file but never installed it, while still stamping the new version into NVS. Devices running such a build never pick up an OTA release: they need one manual USB copy of the fixed `main.py` first, and because their NVS may already claim a version they never ran, the next `version.txt` must be higher than any number published so far.
|
|
- Until 2026-09-15 the update location was the personal GitHub repo `husbananjum/m5core-s3-UIMETRIX-Z87`, which now returns 404 for both files, so every box on that build has been failing its boot-time check (harmlessly) since. Those boxes, and any box running a build without OTA code at all (the ones with the broker at `192.168.2.174` hardcoded and no `V…` label on screen), need one manual USB copy of the current `main.py` to start following Gitea. `version.txt` was reset to `2.0` at the move; nobody knows the highest number ever published on GitHub, so if a box shows a higher `V…` on its screen, bump `version.txt` above it.
|
|
- A box freshly copied over USB still carries its old NVS version (or the default `1.0`), so on its first boot it downloads and installs the identical `main.py` from Gitea once and resets; that is expected, not a loop.
|
|
|
|
## How the program works
|
|
|
|
Single-file, cooperative main loop: `setup()` once, then `while True: loop()`. No threads or asyncio; all state is module-level globals (functions declare `global`). Anything added to the loop must stay non-blocking: timers use `utime.ticks_ms()` deadlines (`ack_deadline`, the 5 s throttles in `check_wifi()` / `reconnect_mqtt()`), not sleeps. The exception is `connect_wifi()`, which blocks and retries recursively up to `MAX_RETRIES`; `loop()` falls back to it whenever WiFi is down.
|
|
|
|
### Identity and persistence
|
|
|
|
- `serial` = hex of `machine.unique_id()`. It is the MQTT client id, the `device_serial` in every published message, and what every incoming message is matched against. Shown on screen as `SR:`.
|
|
- NVS namespace `rfid_data` (`load_from_nvs()` / `save_to_nvs()`): `count` (product counter), `color` (last lamp colour), `operator_id`, and `uid_count` + `uid_<n>` (the `seen_uids` set, no longer used for counting). These survive reboots; a `Reset` card or the reset topic clears them.
|
|
|
|
### Card handling (`loop()`)
|
|
|
|
- `read_all_fields()` reads the card's text blocks positionally with lengths `[4, 5, 6, 8, 9, 10, 12]` -> `(sku, color, size, article, remarks, cardtype, operator_id)`. Only `cardtype` and `operator_id` still matter; the first five are read but no longer published.
|
|
- `cardtype` selects the behaviour: `Operator` sets and persists `Operator_ID`, `Product` publishes a scan, `Reset` zeroes the counter and operator and publishes a `RESET` message. Anything else is ignored.
|
|
- The `card` flag edge-detects a tap: set when a card is handled, cleared only once no card is present, so one tap publishes once.
|
|
- After every publish the device enters `waiting_for_ack` for 10 s: further cards are ignored ("WAITING FOR RESPONSE") until a `response_data` (or `lamp_topic`) message addressed to this serial arrives, or the deadline passes ("NO RESPONSE RECIEVED"). `tag_counter` increments only on a positive ACK from the server, never on the scan itself, so the on-screen count is the server's count.
|
|
- Quirk: the `Operator` branch publishes before `last_uid_str` is updated, so an operator message carries the previous card's UID (`-` after boot); a `Product` message carries the current UID.
|
|
|
|
### MQTT contract
|
|
|
|
Broker, port and credentials are constants at the top of the file and must match the broker the UiMetrix server uses. All messages are JSON, QoS 0.
|
|
|
|
Publishes:
|
|
|
|
- `send_receive_data` — `{"card_type", "operator_id", "device_type": "tag_reader", "uid", "device_serial"}`. The server routes on `card_type` / `device_type`.
|
|
- `M5devices/<serial>` — an empty object on every idle loop pass (several times a second), a liveness heartbeat.
|
|
|
|
Subscribes (each handler first checks the serial in the payload; messages for other devices are ignored):
|
|
|
|
- `response_data` — `{"device_serial", "ack": 0|1, "output"}`. `ack == 1` increments the counter and shows `output` in the MSG bar; `ack == 0` only shows `output`.
|
|
- `lamp_topic` — `{"serial", "color": "red"|"green"|"yellow"|"purple"|"blue"}` sets the RGB unit and persists the colour to NVS.
|
|
- `reset-topic` — payload `reset` clears the counter and UIDs (keeps the operator).
|
|
|
|
`mqtt_callback` accepts both the 2-argument and 4-argument umqtt callback signatures.
|
|
|
|
### Connectivity and the light bar
|
|
|
|
- Blue on the RGB unit means "not connected" (boot, WiFi down, MQTT down); once MQTT connects the persisted `lamp_color` (default green) is restored. After that, colours come only from `lamp_topic`.
|
|
- A publish failure calls `reconnect_mqtt()`, which tears the client down and re-runs `init_mqtt()`, throttled to once per 5 s. A `check_msg()` error sets `mqtt_client = None`, which takes the same path on the next loop.
|
|
- Status icons (WiFi ok/error, MQTT cloud/error, charging) are "updated" by drawing a new `Widgets.Image` over the old one at fixed coordinates.
|
|
|
|
### Screen and hardware
|
|
|
|
- `init_ui()` draws everything with `M5.Widgets` at absolute 320x240 coordinates and keeps the handles in the `ui_elements` dict; change text through `safe_label_update()`. `label3` and `label5` are referenced but never created, so updates to them are no-ops. The battery bar is an `m5ui.M5Bar`.
|
|
- Images are loaded from `/flash/res/img/` on the device (`Logo`, `emp`, `mach`, `rfid-tag-log`, `bar_c`, `wifi`, `wifi_error`, `MQTT_Cloud`, `mqtt_error`, `charging`, `not_charging`, all `.jpg`); they are not in this directory.
|
|
- RFID Unit on I2C bus 0 (SCL pin 1, SDA pin 2, 100 kHz); if any RFID call throws, `rfid_re_init` makes the next loop pass rebuild the bus and reader. RGB Unit on pins 8/9 with 10 LEDs.
|
|
|
|
## Conventions
|
|
|
|
- WiFi SSID/password and the MQTT broker/user/password are hardcoded near the top of the file, with previously used networks left commented out; switching networks means commenting/uncommenting those lines. The file already holds live credentials, so don't echo them into other files or chat.
|
|
- The serial console never prints the card UID (only MQTT traffic, NVS saves/loads, connection state and errors), which is why the enrolment tool reads UIDs from MQTT instead of the USB port.
|