72 lines
9.9 KiB
Markdown
72 lines
9.9 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 throttle in `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) and `operator_id`. Older builds also wrote `uid_count` + `uid_<n>` (a set of seen UIDs that never affected anything); those keys are left in place and ignored. These survive reboots; a `Reset` card or the reset topic clears them.
|
|
|
|
### Card handling (`loop()`)
|
|
|
|
- `read_card_fields()` reads two MIFARE Classic blocks from the card: block 10 (`CARD_TYPE_BLOCK`, the type as text) and block 12 (`OPERATOR_ID_BLOCK`). Blocks 4-9 once held sku / colour / size / article / remarks and are no longer read; each `RFIDUnit.read(block)` authenticates its own sector, so the reads are independent. The values are written into the card by the desk writer that the UiMetrix "Register Card Type" page drives - the box never consults the database.
|
|
- `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.
|
|
- `tap_handled` edge-detects a tap: set when a card is acted on (all three types, Reset included since 2026-09-15), cleared only once no card is present, so one tap publishes once however long the card rests on the reader.
|
|
- 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.
|
|
- Every scan carries the tapped card's own UID. Until 2026-09-15 an `Operator` message carried the PREVIOUS card's UID (`-` after boot), so the server's "is this card registered" check looked at the wrong card - the first operator tap after a reboot was always refused with "Card not found", and the operator scan log linked operators to product cards. If an operator card is refused with "Card not found" now, it is genuinely missing from the Card Type List.
|
|
|
|
### 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 only when the scan being answered was a `Product` tap (`pending_scan_type`; an accepted Operator or Reset scan is not a piece) and shows `output` in the MSG bar; `ack == 0` only shows `output`. A reply arriving after the 10 s timeout is still applied.
|
|
- `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()` or heartbeat-publish 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` dict under plain names (`operator`, `count`, `msg`, `uid`, `serial`, `ip`, `battery`, `version`, the icons); change text through `label_set(name, text)` / `set_msg(text)`, which tolerate a widget that has not been created yet (that is what lets `setup()` load NVS before drawing). 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_needs_init` makes the next loop pass rebuild the bus and reader (`init_rfid()`). 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 one alternative each left commented out (`UI-Matrix`, `scada.utopia.pk`); switching means commenting/uncommenting those lines. Personal hotspots that used to be listed were removed on 2026-09-15 - this is a public repo, do not add them back. 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.
|