From the review of 7723711:
- The assembler is locked: a client closed for a full queue is closed
by the task that was sending, loop(), whose disconnect dropped the
buffer while async_tcp could be copying into it. A whole message is
taken out of it, so a disconnect can't free it while it is read.
- loop() expires a message stalled for 10 s, freeing its buffer and
telling its client, instead of holding it until another client came.
A binary message is refused before it can touch another's buffer.
- Each edit takes only the fields its route reads; one it would ignore
is refused by name. linkRemote's rolling code goes in under the
route's name, so applyModelEdit is unchanged again.
- An edit refused for an id that doesn't exist replies not found
(edit_result_t::missing). A reply that doesn't fit its buffer goes
as an internal error, so the op still gets one.
- The TYPE table says what becomes of each op (proto_op_t), which the
socket switches on. The reply address is one proto_reply_to_t. The
routes and the ops start and queue an edit the same way
(newModelEdit, submitModelEdit). The full-list texts and the offered
remote address rule live in ModelEditor.
- e2e: the sort order test skips a list that it would renumber.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
12 KiB
Socket protocol 2
Why the protocol looks like this: ADR 0005. Terms: GLOSSARY.md.
The table of types, fields, error codes and feeds is defined once, in src/ProtoSchema.h. The device sends it to every client as the schema, and test/fixtures/proto2/schema.json is a copy recorded by the e2e suite (pytest test/e2e --record). This page describes how the protocol works, not every field.
Connection
ws://host/wson port 80. Under Secured the upgrade needs?apikey=<token>from/login; without a valid one it is refused with 401 (ADR 0006).- Optional
?schema=<hash>: the client already has the schema with that hash and doesn't need it again. - At most 5 clients. A sixth is closed as soon as it connects.
- Text messages of at most 4 KB. A message may be sent as several frames, and a frame larger than one TCP segment (about 1.4 KB) reaches the device in pieces anyway; the device puts it back together. It does that for one message at a time across all clients: while it is, another client's long message is answered with busy, and the client sends it again. A message that hasn't arrived in full within 10 s is given up, and its client is told with a bad request. These refusals have no
id, since the message couldn't be read. A binary encoding, if one comes, would be asked for at the upgrade (for example?enc=bin) and use binary frames.
Envelope
Every frame, in both directions, is one JSON object:
{"v":2,"t":129,"id":17,"d":{"s":3,"p":40}}
| Key | Meaning |
|---|---|
v |
protocol version, 2 |
t |
message type, a number from the schema |
id |
only on a client message that wants a reply, and on that reply |
d |
the payload, with keys from the schema's field dictionary |
Within d:
- keys are one or two characters, from one dictionary shared by every type;
- booleans are
1and0; - a field at its default is left out: a client fills it in from the schema;
- lists and objects use the same dictionary for the keys inside them.
The keys of hello (fw, sh) and of the schema itself (T, F, E, Q) never change, since a client needs them before it has a schema.
Message types
| Range | Sender | Types |
|---|---|---|
| 1–15 | control | hello 1, reply 2, next 3, sync 4, syncEnd 5, subscribe 6, unsubscribe 7 |
| 16–127 | client ops | commands: shadeCommand 16, groupCommand 17, tiltCommand 18, repeatCommand 19, setPositions 20, setSensor 21, setMyPosition 22, unpairShade 23, setRollingCode 24, sendRemoteCommand 25; edits: 26–44 (addRoom … groupSortOrder); reads: getNextRoom 45, getNextShade 46, getNextGroup 47, groupOptions 48; settings follow |
| 128–255 | device events | shadeState, shade, shadeRemoved, group, groupState, groupRemoved, room, roomRemoved, roomOrder, shadeOrder, groupOrder, shadeCommanded, memory, wifi, ethernet, frame, frequencyScan, repeaters |
A number is never reused. A retired type stays reserved.
Connect sequence
- The device sends
hello:{"fw":"<firmware>","sh":"<schema hash>"}. - The device sends the first
syncframe straight away. Unless the client passed the matching?schema=, the sync starts with the schema:T: types, as[number, name]F: fields, as[key, name, kind, default], kind beingbbool,iinteger,nnumber,sstring,llist,oobjectE: error codes, as[number, name]Q: feeds, as[number, name]
- Then the device:
header(limits, starting address, what a new shade gets, server id, hostname, model, chip, firmware, connection type),rooms,shades,groups,repeatersandstatus(memory, and WiFi or Ethernet). A frame is at most 4 KB, an item is never split across frames, and several sections can share one. - The client sends
nextafter eachsyncframe, and the device answers with the following one. With nonextfor 30 s the device closes the socket. syncEndfollows the lastsyncframe.
The client can send sync at any time to get the shade model and status again (without the schema).
Events go out to every client from the moment it connects, so they can arrive during the sync, and even before the schema. A client keeps any frame it can't decode yet until it has the schema. Each sync frame is formatted from the live model, so an item in a later frame already holds every change before it. A client treats sync items and events alike: insert or update by id, and ignore removing an id it doesn't have.
Events
| Event | When | Payload |
|---|---|---|
shadeState |
a shade moved or its state changed | id, position, target, direction, favorite, tilt, flags, rolling code |
shade |
a shade was added or a setting changed (edit, pairing, linked remote, group, its room deleted) | the whole shade, as in the sync |
group, groupState |
the same for a group: its settings or linked shades, or its flags and direction | |
room |
a room was added or saved | the whole room |
*Removed |
a shade, group or room was deleted | its id |
roomOrder, shadeOrder, groupOrder |
the sort order changed | is: the ids in order |
repeaters |
a repeater was linked or unlinked | the list |
shadeCommanded |
a remote or the device commanded a shade | id, address, command, source, rolling code, source address |
memory, wifi, ethernet |
every 30 s, WiFi only when the signal moved |
Commands
Every op carries an id and gets one reply. The ops do what the HTTP routes of the same name do, with these arguments:
| Op | Payload | Does |
|---|---|---|
shadeCommand |
shadeId; command or target; optional repeat, stepSize |
sends the command, or moves the shade to the position 0–100 |
tiltCommand |
shadeId; command or target |
the same for the tilt |
groupCommand |
groupId, command; optional repeat, stepSize |
sends the command as the group |
repeatCommand |
shadeId or groupId, command; optional repeat, stepSize |
repeats the last frame when it was this command, or sends it |
setPositions |
shadeId; position, tiltPosition or both |
sets where the shade is thought to be; nothing goes on air |
setSensor |
shadeId or groupId; sunny, windy or both; optional repeat |
sends a sensor frame |
setMyPosition |
shadeId; myPos, myTiltPos or both |
sets the favorite |
unpairShade |
shadeId |
sends Prog and clears paired |
setRollingCode |
shadeId, lastRollingCode |
sets the next rolling code; nothing goes on air |
sendRemoteCommand |
remoteAddress, command, lastRollingCode; optional encKey, repeats |
sends one frame as a remote that isn't one of the shades |
- An op reads only the fields that were sent. Unlike what the device sends, a field left out of an op is not its schema default: it is not given, so
setPositionswith onlypositionleaves the tilt as it is. A client sends every field it means, defaults included. commandis a command's name, in any case:My,Up,Down,MyUp,MyDown,UpDown,MyUpDown,Prog,SunFlag,Flag,StepUp,StepDown,Toggle,Sensor,Favorite,Stop, or the spellingshadeCommandedreports (Step Up,My+Up).repeatleft out means the shade's or the group's own repeats.- Where they differ from the routes:
- The keys come from the dictionary:
setMyPositiontakesmyPosandmyTiltPos(the route:pos,tilt);setRollingCodeandsendRemoteCommandtakelastRollingCode(the routes:rollingCode,rcode);sendRemoteCommandtakesremoteAddress(the route:address). groupCommand,repeatCommandandsendRemoteCommandneed acommand, where the routes send My without one.setMyPositiontakes a tilt on its own and keeps the lift favorite, where the route needs a lift position.
- The keys come from the dictionary:
- A field of the wrong kind, a value out of range, or both of two alternatives, is a bad request, and
descnames the field. - The reply comes once the command has gone to the radio, or was refused: not found (no shade or group with that id), radio unavailable (the radio is paused, as during a frequency scan, or didn't take it), busy (the device's command queue is full, or
setMyPositionon a shade that is moving) or bad request (setPositionswith only a tilt, for a shade without one). It carries no state: the shade'sshadeStateevents follow as it moves. sendRemoteCommandis answered as soon as the radio has taken the frame, not after the ops queued before it, and with radio unavailable when the radio is full rather than waiting for room.
Edits
Each edit op is the edit route of the same name: addRoom, addShade, addGroup, saveRoom, saveShade, saveGroup, deleteRoom, deleteShade, deleteGroup, linkRemote, unlinkRemote, linkToGroup, unlinkFromGroup, linkRepeater, unlinkRepeater, setPaired, roomSortOrder, shadeSortOrder, groupSortOrder.
- The payload holds what the route's request does, under the dictionary's keys: a room's, shade's or group's fields as in the sync, and its id to save, delete or link it.
linkRemotetakesremoteAddressandlastRollingCode;linkRepeatertakesremoteAddress. A sort order takesids, the ids in their new order. - As with the commands, only the fields that were sent are applied. A key that isn't in the dictionary, a field the edit doesn't set (a save doesn't set
pairedorlinkedRemotes, for one), or a value of the wrong kind, is a bad request before anything is queued. - One edit runs at a time, whichever client or route sent it: while one is waiting, another gets busy.
- The reply comes once the edit is applied and saved. An add's reply carries the new item's id (
roomId,shadeIdorgroupId). A refused edit has the route's reason indesc: not found for an id that doesn't exist, otherwise a bad request, with the reason's number invalidationfor a shade whose settings don't add up. - The edit's events follow: the whole item for an add or save,
*Removedfor a delete, the new order for a sort order,repeatersfor a repeater.
Reads
What an edit form needs before it is sent, worked out by the device as the edit would be:
| Op | Payload | Reply |
|---|---|---|
getNextRoom |
the roomId a new room gets |
|
getNextShade, getNextGroup |
the shadeId or groupId a new one gets, a remoteAddress no shade or group uses, and the radio's bitLength and proto |
|
groupOptions |
groupId |
the group's id, and in ids the shades it doesn't have yet |
When every slot is taken the reply is a bad request; groupOptions for a group that doesn't exist is not found.
Replies
A client message with an id gets exactly one reply with that id, to that client only, whether it is an op or a control message such as next: ok 1 on success, or an error code and a desc for people. Error codes are in the schema: bad request, unknown op, unauthorized, not found, busy, radio unavailable, internal.
A malformed message gets a bad request, with its id when it had one. A type a client may not send gets an unknown op.
Feeds
subscribe and unsubscribe take a feed number in fe: frames (received remote frames, with their pulses) or frequencyScan. Their events go only to clients subscribed to them.
Inside the device
async_tcp reads client frames and answers with fixed frames that hold nothing of the shade model: hello, errors and ok. Everything formatted from the shade model goes out from loop(), which is the only task that reads and writes it (ADR 0002). Sync frames and events share the socket event buffer, so protocol 2 needs no more static memory.