9.5 KiB
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.10is older than1.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.pyfirst, and because their NVS may already claim a version they never ran, the nextversion.txtmust 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 at192.168.2.174hardcoded and noV…label on screen), need one manual USB copy of the currentmain.pyto start following Gitea.version.txtwas reset to2.0at the move; nobody knows the highest number ever published on GitHub, so if a box shows a higherV…on its screen, bumpversion.txtabove 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 identicalmain.pyfrom 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 ofmachine.unique_id(). It is the MQTT client id, thedevice_serialin every published message, and what every incoming message is matched against. Shown on screen asSR:.- NVS namespace
rfid_data(load_from_nvs()/save_to_nvs()):count(product counter),color(last lamp colour) andoperator_id. Older builds also wroteuid_count+uid_<n>(a set of seen UIDs that never affected anything); those keys are left in place and ignored. These survive reboots; aResetcard 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; eachRFIDUnit.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.cardtypeselects the behaviour:Operatorsets and persistsOperator_ID,Productpublishes a scan,Resetzeroes the counter and operator and publishes aRESETmessage. Anything else is ignored.tap_handlededge-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_ackfor 10 s: further cards are ignored ("WAITING FOR RESPONSE") until aresponse_data(orlamp_topic) message addressed to this serial arrives, or the deadline passes ("NO RESPONSE RECIEVED").tag_counterincrements 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
Operatorbranch publishes beforelast_uid_stris updated, so an operator message carries the previous card's UID (-after boot); aProductmessage 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 oncard_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 == 1increments the counter and showsoutputin the MSG bar;ack == 0only showsoutput.lamp_topic—{"serial", "color": "red"|"green"|"yellow"|"purple"|"blue"}sets the RGB unit and persists the colour to NVS.reset-topic— payloadresetclears 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 fromlamp_topic. - A publish failure calls
reconnect_mqtt(), which tears the client down and re-runsinit_mqtt(), throttled to once per 5 s. Acheck_msg()or heartbeat-publish error setsmqtt_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.Imageover the old one at fixed coordinates.
Screen and hardware
init_ui()draws everything withM5.Widgetsat absolute 320x240 coordinates and keeps the handles in theuidict under plain names (operator,count,msg,uid,serial,ip,battery,version, the icons); change text throughlabel_set(name, text)/set_msg(text), which tolerate a widget that has not been created yet (that is what letssetup()load NVS before drawing). The battery bar is anm5ui.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_initmakes 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.