Firmware integration · Phase 1 backend · live

Scooter Firmware Bring-Up

The backend is running in production and a simulated scooter already talks to it end to end. This guide takes the real firmware the same way, from the first TLS handshake to a confirmed unlock. Do the stages in order: each one ends with a check against the live system, and every later stage assumes the earlier checks pass.

Broker
mqtt.xzcode.me:8883
Transport
TLS 1.2/1.3 · MQTT 3.1.1
Identity
scooter-{id}, one per unit
Byte order
little endian
Integrity
CRC-16/CCITT-FALSE
Report every
10 s (default)
Before you start

How a reading and a command travel

The scooter only ever talks to the MQTT broker. It has no HTTP client and never learns whether the backend accepted a frame. Everything after the broker belongs to the backend team, but knowing the path tells you where to look when something goes missing.

Scooter ATmega328 + 4G client scooter-1 EMQX broker auth · ACL · TLS mount c/${clientid}/ Ingestion CRC → validate → dedup Redis + Postgres live state · history API api.xzcode.me Dashboard rider app sc/1/tel, ack, evt, status sc/1/cmd arrives as c/scooter-1/… writes reads publish c/scooter-1/sc/1/cmd live state and command progress (socket.io) POST /api/v1/scooters/1/commands
Black is the telemetry path, amber is the command path. The broker, not the device, adds the c/scooter-1/ prefix, using the identity the scooter logged in with. That prefix is how the backend catches one unit publishing under another unit's id.
  • No application-level receipt for telemetry. MQTT QoS 1 PUBACK means the broker has it, nothing more. Rejected frames are stored server-side with the raw bytes and a reason.
  • A command succeeds only when your ACK arrives. The backend never treats "published to MQTT" as done. No matching ACK within 15 s means TIMEOUT.
  • The wire contract lives in one place: packages/protocol/src/. The server and the simulator both import it, so when this guide and that code disagree, the code wins. Tell the backend team.
Stage 01

Get an identity for each unit

Every scooter has its own credential. A shared fleet key is forbidden: one extracted key would let anyone impersonate every scooter and receive every scooter's commands. The backend team provisions each unit and gives you four values.

ValueExampleWhere it goes
Scooter id1The topic path: sc/1/…. Decimal, no leading zeros.
Usernamescooter-1MQTT CONNECT username
Client idscooter-1MQTT CONNECT client identifier. It must match the provisioned value exactly.
Password32 random charsMQTT CONNECT password. Shown once at provisioning; the server keeps only a bcrypt hash.
  • Store the credential in EEPROM or flash, per unit. Never compile one into a shared image.
  • Never print the password on a debug UART. A lost password means reprovisioning, not recovery.
  • Two units with the same client id will repeatedly kick each other off the broker. Only one session per client id can exist at a time.
Pass when

Before touching firmware, prove the credential works from a laptop. Stop any simulator or unit using the same client id first, or this connection will take over its session.

mosquitto_sub -h mqtt.xzcode.me -p 8883 -V mqttv311 \
  --cafile isrg-root-x1.pem \
  -i scooter-1 -u scooter-1 -P '<password>' \
  -t 'sc/1/cmd' -q 1 -d

You see CONNACK (0) and SUBACK with granted QoS 1. CONNACK (5) means the credential is wrong or disabled.

Stage 02

Connect over TLS

SettingValueWhy
Host / portmqtt.xzcode.me : 8883The only listener devices can reach. Port 1883 is internal to the server.
ProtocolMQTT 3.1.1 (level 4)What the backend is tested against for devices.
Clean sessiontrueThe device re-subscribes on every connect (below).
Keepalive60 s suggestedKeep it well under the carrier's NAT idle timeout. A dead link is detected after about 1.5× keepalive.
Max packet1 KBThe broker drops anything larger. The largest frame is 20 bytes.
Client certificatenot required yetPassword auth today. mTLS depends on your module. See Still to agree.

Trusting the server

The broker presents a Let's Encrypt certificate for mqtt.xzcode.me. Let's Encrypt replaces it about every 60–90 days, so load the ISRG Root X1 CA into the modem (and ISRG Root X2, so a change of key type doesn't strand units). Never pin the leaf certificate. A pinned leaf bricks the whole fleet's connectivity on the next renewal.

Certificate validation needs the time. After a cold boot, get network time from the modem (NITZ, or AT+CCLK? on 3GPP modules) before opening TLS. The same clock stamps telemetry, which the backend rejects if it's more than 5 minutes off.

The CONNECT packet

Register a Last Will so the broker announces the scooter offline if the link dies without a clean disconnect:

Will fieldValue
Topicsc/1/status
Payload0x00 (1 byte)
QoS / retain1 / true

On every successful CONNACK, in this order

  1. Publish 0x01 to sc/1/status, QoS 1, retained.
  2. Subscribe to sc/1/cmd at QoS 1. Clean session drops subscriptions, so this happens every time.
  3. Publish a BOOT event if this is the first connect since power-up.
  4. Replay any buffered readings with bit 7 set (stage 07).
  5. Resume live telemetry on the normal interval.

If the connect fails, back off with jitter: for example 2 s, 4 s, 8 s … capped at 5 minutes, plus a random 0–30%. A fleet that reconnects in lockstep after a cell outage floods the broker all at once.

Pass when

Ask the backend team to run this while the unit is powered. It should show connected=true, clean_start=true and subscriptions=1:

docker exec <emqx-container> emqx ctl clients show scooter-1

The scooter's connection badge on the dashboard reads Online once the first live telemetry frame lands (next stage).

Stage 03

Build frames byte for byte

Every frame is a fixed-size binary record: a version byte 0x01, the fields, then a CRC over everything before it. Multi-byte fields are little endian, the AVR's native order, so a memcpy of a uint32_t is already correct. The CRC goes out low byte first too.

CRC-16/CCITT-FALSE

Poly 0x1021, init 0xFFFF, no reflection, no final XOR. Check value: CRC of the ASCII string "123456789" is 0x29B1. Test this before anything else; a wrong CRC is the most common bring-up failure.

/* Bit-wise: no table, fits comfortably on an ATmega328. */
uint16_t crc16_ccitt_false(const uint8_t *data, uint8_t len) {
    uint16_t crc = 0xFFFF;
    while (len--) {
        crc ^= (uint16_t)(*data++) << 8;
        for (uint8_t i = 0; i < 8; i++)
            crc = (crc & 0x8000) ? (crc << 1) ^ 0x1021 : (crc << 1);
    }
    return crc;
}

/* Sealing a 20-byte telemetry frame: CRC over bytes 0..17, stored LE. */
uint16_t crc = crc16_ccitt_false(frame, 18);
frame[18] = crc & 0xFF;
frame[19] = crc >> 8;

Telemetry · 20 bytes · sc/{id}/tel

0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
veru8
msg_idu16
timestampu32 epoch s
latitudei32 deg×10⁷
longitudei32 deg×10⁷
bat%
spdkm/h
bitsu8
crc160..17
01
34
12
04
03
02
01
60
69
E8
11
88
30
9E
12
57
17
1A
9D
46

The bottom row is the golden vector from packages/protocol/test/telemetry.spec.ts: msg_id 4660, latitude 30.0444, longitude 31.2357, battery 87 %, speed 23 km/h, status 0x1A, CRC 0x469D. Its timestamp decodes to a 1970 date, so use it to test byte layout only. Sent live, it would be rejected as CLOCK_SKEW.

status_bits · byte 17

bit 7 · 0x80buffered1 = replayed, not current
bit 6 · 0x40low_bat1 = low
bits 5–4 · 0x30device_status00 avail · 01 ride · 10 maint · 11 reserved
· (2 bits)
bit 3 · 0x08motion1 = moving
bit 2 · 0x04charging1 = charging
bit 1 · 0x02gps_fix1 = valid fix
bit 0 · 0x01lock1 = locked
Coordinate overflow. degrees × 10⁷ in an int32 tops out near ±214.7°. Past that the value wraps to a different coordinate that looks valid. Compute with int32_t arithmetic from fixed-point GPS fields, never through a float that can drift.
Pass when

A host-side unit test of your encoder, fed the golden values above (including timestamp 16909060 and status 0x1A), produces exactly:

01 34 12 04 03 02 01 60 69 E8 11 88 30 9E 12 57 17 1A 9D 46

Run the same source on the target and compare over the UART. A difference there, but not on the host, usually means a struct was padded or an integer promoted.

Stage 04

Send live telemetry

Telemetry is also the heartbeat. There's no separate keepalive frame, so publish every interval even when nothing has changed. Publish at QoS 1, not retained, with bit 7 clear.

seconds since the last live frame · 10 s interval Online Stale Offline last frame × × × missed frames 0 10 15 20 30 40 1.5 × interval 3 × interval
Two missed frames make a scooter Stale; three make it Offline. A Last Will arriving from the broker moves it to Offline at once. The next live frame brings it straight back to Online.

What the backend does with each frame

Checks run in this order. A reject is refused and stored with the raw bytes. A tag is accepted but flagged.

CheckOutcomeRule
BAD_LENGTHrejectPayload not exactly 20 bytes. No padding, no trailing newline.
BAD_VERSIONrejectByte 0 isn't 0x01.
BAD_CRCrejectCRC over bytes 0–17 doesn't match bytes 18–19.
TOPIC_CLIENTID_MISMATCHrejectThe client id you logged in with doesn't own the scooter id in the topic. Also logged as a security incident, and the unit is disconnected.
UNKNOWN_SCOOTERrejectThe scooter id isn't in the fleet.
BAD_BATTERYrejectOutside 0–100.
BAD_SPEEDrejectAbove 40 km/h.
CLOCK_SKEWrejectLive frame more than ±300 s from server time.
no GPS fixtagBit 1 clear: frame accepted, coordinates ignored, battery/lock/speed still used. Send these frames. Don't hold them back.
BAD_LAT / BAD_LNGrejectWith a fix: latitude outside ±90 or longitude outside ±180.
position jumptagMoved faster than 200 m/s since the last frame: accepted, marked suspect_jump.
duplicate msg_idignoredSame msg_id seen within 600 s: processed once, silently. This is what makes QoS 1 redelivery safe.

msg_id rules

  • Increment by one per frame, wrapping at 65 535. At 10 s, a wrap takes about 7.5 days, far outside the 600 s dedup window.
  • Don't reset it on reconnect. Rapid reconnects would reuse ids inside the window, and real readings would be discarded as duplicates.
  • Don't restart it from 0 after a reboot either. A unit that reboots and reconnects within 10 minutes hits the same trap. Save the counter to EEPROM every few hundred frames and skip ahead on boot, or seed it randomly.
  • A QoS 1 retransmission reuses the same bytes and the same msg_id. That's correct.
Pass when

With the unit on the bench, the dashboard or the rider app shows battery, lock and last seen updating every interval, and the badge holds at Online. The backend team confirms telemetry_rejected has no new rows for your scooter id. If it does, each row's reason, detail and raw hex usually pinpoint the bug.

Stage 05

Execute commands and answer with ACKs

Commands arrive on sc/{id}/cmd as 12-byte frames. The backend matches your ACK to the command by command_id alone, so mirror it exactly.

Command · 12 bytes · received on sc/{id}/cmd

0
1
2
3
4
5
6
7
8
9
10
11
veru8
opu8
command_idu32
issued_atu32 epoch s · or interval
crc160..9
01
02
2A
00
00
00
C6
C2
A6
6A
91
04

Example: UNLOCK (0x02), command_id 42, issued at 1789313734, CRC 0x0491.

ACK · 10 bytes · publish to sc/{id}/ack

0
1
2
3
4
5
6
7
8
9
veru8
opmirror
command_idu32 mirror
resultu8
erru8
crc160..7
01
02
2A
00
00
00
00
00
04
A5

The ACK for the command above, result SUCCESS. The same ACK with result ALREADY_DONE is 01 02 2A 00 00 00 02 00 66 C3.

OpcodeCommandWhat the device does
0x01LOCKEngage the lock, confirm with the sensor, ACK, then send one telemetry frame straight away.
0x02UNLOCKSame as LOCK, other direction.
0x03PINGACK SUCCESS. Nothing else.
0x04REBOOTPublish the ACK, wait for its PUBACK, then reset. Reset first and the command times out.
0x05SET_INTERVALissued_at carries the new interval in seconds (1–3600). Apply it, then ACK.
ResultNameBackend recordsSend it when
0x00SUCCESSACKEDDone and confirmed by the sensor.
0x01FAILEDFAILEDTried and failed. Put a device-specific reason in err.
0x02ALREADY_DONEACKEDThis command_id was already executed. It's a retry.
0x03INVALIDFAILEDUnknown opcode or bad parameter.
0x04NO_FEEDBACKFAILEDActuator driven but the confirmation sensor didn't answer. The operator is told the lock state is unverified.
API EMQX Scooter Ingestion 15 s timeout PENDING · timer armed cmd #42 UNLOCK SENT sc/1/cmd · 12 B #42 in recent history? no → actuate lock, read sensor sc/1/ack · #42 SUCCESS sc/1/tel · lock bit 0 c/scooter-1/sc/1/ack, tel command #42 → ACKED · lock state updated No ACK by 15 s → TIMEOUT. An operator retry resends the same #42. If the unit already executed #42, it answers ALREADY_DONE and does not actuate twice.
One UNLOCK, end to end. The telemetry frame right after the ACK isn't required by the protocol, but without it the dashboard keeps showing the old lock state until the next interval.

Device rules for commands

  • Keep the last ~8 executed command_ids in RAM (32 bytes). If a command's id is already there, don't actuate again; answer ALREADY_DONE. Retries always reuse the original id, so this is what prevents a double unlock.
  • Don't send an ACK you can't back up. Silence becomes a clean TIMEOUT the operator can retry; a false SUCCESS leaves a rider standing at a locked scooter.
  • An ACK that arrives after the 15 s timeout is ignored. It doesn't bring the command back.
  • The backend never sends two in-flight commands of the same opcode to one scooter, so you won't see overlapping UNLOCKs.
  • Handle the frame as it arrives: check length, version and CRC first. A command failing those gets no ACK.
Pass when

Send a PING through the API (the rider app's Ping button does the same):

curl -X POST https://api.xzcode.me/api/v1/scooters/1/commands \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"opcode":"PING"}'
# → 202 {"command_id": 42, "state": "PENDING", ...}

curl -H "Authorization: Bearer $TOKEN" https://api.xzcode.me/api/v1/commands/42
# → "state": "ACKED", "result_name": "SUCCESS"   within about a second

Then UNLOCK and LOCK from the rider app: the lock icon flips as soon as the ACK arrives, with no page reload. The token comes from POST /api/v1/auth/login with an operator account.

Stage 06

Events and the status byte

Event · 8 bytes · sc/{id}/evt

0
1
2
3
4
5
6
7
veru8
typeu8
timestampu32 epoch s
crc160..5
TypeEventSend it
0x01LOW_BATTERYOnce, when the battery crosses your low threshold. Not on every reading.
0x02BOOTFirst connect after power-up or reset.
0x03FALL_DETECTEDReserved for a later phase.
0x04TAMPERTamper switch or unexpected motion while locked.
0x05CHARGING_STARTCharger connected.
0x06CHARGING_ENDCharger removed.

Any other type is rejected as UNKNOWN_EVENT_TYPE, not ignored.

Status · 1 byte · sc/{id}/status

Just 0x01 (online) or 0x00 (offline), with no version byte and no CRC. Publish 0x01 retained after every connect. The broker publishes the retained 0x00 for you through the Last Will when the link drops. Before a deliberate power-down, publish 0x00 retained yourself, then send DISCONNECT. A clean disconnect doesn't trigger the Will.

Pass when

Cut the modem's power on the bench. The scooter turns Offline within about 1.5× the keepalive (Last Will) or 30 s (missed telemetry), whichever comes first. Power it back and it returns to Online on the first live frame, and a BOOT event appears in the scooter's event list.

Stage 07

Survive outages with bit 7

This is the behaviour that matters most. While the link is down, keep sampling and store the readings. When the link returns, replay them with bit 7 set. Bit 7 means one thing: this reading is not current.

on the wire backend link down · sample and store bit7 = 0 reconnect bit7 = 1 replay burst bit7 = 0 live map + history Stale → Offline history only live map + history device timestamps kept
Replayed frames fill the gap in the history using the times you recorded. They never move the live map, never count as liveness, and never bring the scooter back Online. Only a live frame does that.
MistakeWhat the operator sees
Bit 7 never set on replayed framesA scooter back from a two-hour outage jumps to two-hour-old positions shown as current. Someone gets sent to where it was.
Bit 7 left set on live framesThe scooter streams happily but stays Offline and never appears on the live map.
  • Replayed frames take new msg_ids from the normal sequence. Don't reuse the ids the readings would have had.
  • Keep each reading's original timestamp. The backend accepts buffered frames up to 7 days old, but still rejects any stamped more than 5 minutes in the future.
  • Replay oldest first, then go back to live frames. If storage fills, drop the oldest readings, not the newest.
  • Pace a long replay (for example 5–10 frames per second). The broker has no trouble with it, but the modem's send buffer may.
Pass when

Disconnect the antenna for two minutes while the scooter moves on the bench GPS simulator, then reconnect. The live map doesn't jump backwards, the history for those two minutes fills in with the device's timestamps, and the badge returns to Online on the first live frame after the replay.

Reference

When it doesn't work

Before blaming firmware, confirm the backend is healthy by running the simulator with a scooter id that isn't your hardware. If the simulated scooter works and yours doesn't, the difference is in the device.

npm run sim -- --count 1 --start-id 2 --interval 10          # known-good peer on the live broker
npm run sim -- --count 1 --start-id 2 --ack-mode silent      # forces the 15 s TIMEOUT path
npm run sim -- --count 1 --start-id 2 --buffered-burst       # outage + bit 7 replay

# every command it receives is printed:
[sim] scooter 2 <- cmd #42 UNLOCK
[sim] scooter 2 -> ack #42 SUCCESS (locked=false)

For a full reference implementation of connect, status, subscribe, telemetry, ACK and replay, read apps/simulator/src/virtual-scooter.ts.

SymptomMost likely cause
CONNACK 5, not authorisedWrong username or password, or the credential was revoked.
TLS handshake failsClock not set yet, or ISRG Root X1 missing from the modem.
Connects, then drops every few secondsAnother unit or a test tool is using the same client id.
Connects, publishes once, gets disconnectedPublished outside the five allowed topics (wrong id, leading zero, typo in the channel).
Connected but nothing on the dashboardCheck in this order: CRC check value, little endian, exact length, clock, topic id. telemetry_rejected gives the reason.
Every frame is TOPIC_CLIENTID_MISMATCHClient id doesn't belong to the scooter id in the topic.
Frames vanish with no rejected rowDuplicate msg_id inside 600 s, usually a counter reset on reconnect or reboot.
Shows Offline while streamingBit 7 set on live frames, or the unit's interval is longer than the one configured for it.
Commands always end TIMEOUTNot subscribed to sc/{id}/cmd after reconnect, ACK published to the wrong topic, or command_id not mirrored byte for byte.
Lock changes only after a delayNo telemetry frame sent right after the ACK. It shows at the next interval.
A command runs twiceThe recent command_id history isn't checked, so a retry wasn't recognised.
Position lands somewhere impossibleint32 overflow or float rounding in the ×10⁷ conversion.
Open

Still to agree between the teams

QuestionBackend todayNeeds from firmware
Mutual TLSServer certificate only; devices log in with a password.Does the 4G module support per-device X.509 client certificates? If yes, the broker switches to verify_peer.
Buffered age limitReplays accepted up to 7 days old; the ±5 min rule applies to live frames only.Confirm 7 days covers the longest outage you'll store, and how many readings fit in storage.
Reporting interval10 s default, changeable per unit with SET_INTERVAL.Confirm 10 s fits the data plan and battery budget.
REBOOT orderingExpects an ACK before the reset.Confirm the module can deliver a PUBACK-confirmed ACK before resetting.