Protocol number registry
KM43 / numeric authority
Every message type, field, outcome, capability, metric, and event gets one permanent number here before an implementation is allowed to use it.
- Source
protocol.tomland generated tables- Allocation rule
- Reserve the number before writing code
- Lifecycle
- Live, reserved, withdrawn, or retired
- Vendor range
0xF000–0xFFFFfor metric and event kinds
Allocation rules
A number is allocated here before it appears in any implementation. Two people
picking 0x0503 independently produce two firmwares that disagree about what a
generator is doing, and the disagreement is invisible until one of them is at a
site. The collision must surface in the pull request that allocates the number,
not on the wire.
Retired numbers stay in the table, marked retired, with the date and the reason. Deleting the row is how a number gets reused by somebody who never knew.
Reserved ranges. 0xF000–0xFFFF is reserved for vendor and experimental
use in the metric-kind and event-kind spaces, and will never be allocated
here. A device may emit one, and a client shows that channel or that record as
unrecognised and keeps the rest of the message.
Skip what you cannot name; reject what you cannot act on. Those two spaces
are labels on data — not knowing a metric kind costs one row on a screen. Every
other number in this file is a value something decides on, and there the rule is
P-014 in PROTOCOL.md: an unrecognised discriminant is error 1,
with no vendor range and no 0 = unknown to fall back to. P-019 is the carve-out
by space and it names these two and no others; P-125 is the carve-out by
position, and it is the one place a discriminant is not in a discriminant
field — the three metric kinds below whose key 3 is an answer rather than a
measurement.
| Space | An unrecognised value is |
|---|---|
| Metric kinds, event kinds | skipped — that one Value or Event is surfaced as unrecognised, the rest of the message stands |
| Quality | rejected — a reading whose trustworthiness cannot be named is not a reading |
| Generator state, generator selector, boot reason | skipped for that one channel — P-125, and it is the second carve-out: an answer nobody can name is not rendered, and the Snapshot around it stands |
| Command, SetConfig, Pair, Firmware and Time outcomes | rejected — a client that cannot tell accepted from inhibited has learned nothing |
| Client kinds, time sources, config sections, error codes | rejected |
What separates the first row from the rest is the difference between the label on
a quantity and the answer to a question. Generator state is the label, and a
client that does not hold metric 0x0501 can say there is a channel here I
cannot name and be honest.
The generator is in state 6 is the answer, and a client that renders an answer
it cannot read is the room card showing 0.0 °C all over again.
Status column, and the four are genuinely different things:
| Status | The number is | May it be emitted? |
|---|---|---|
| live | allocated and fully specified | Yes |
| reserved | allocated; its body or argument schema is deferred, see DEFERRED.md | Yes, where a normative rule requires it — but a receiver must not depend on the body’s contents, because the body is what is deferred |
| withdrawn | allocated; the condition it named is answered somewhere else now | No. Nothing emits it, and the number is not free |
| retired | was used, and is gone forever | No |
reserved is not withdrawn, and conflating them was a real bug. An earlier
revision defined reserved as “nothing may emit it”, which quietly forbade three
things the specification requires: the records dropped event that explains a
hole in seq, the time set event that records which source moved the clock,
and the comms-processor lifecycle events LINK.md leans on. All three have
allocated kinds whose bodies are deferred — the number is emittable, the schema
is not yet written. A status that forbids a MUST is a status that is wrong.
Message types — u8
Section titled “Message types — u8”The high bit means response to a request. Event is the only unsolicited
message. 0x60–0x7E is the link-local range from LINK.md, whose
responses are 0xE0–0xFE — the same high-bit rule, so nothing special has to
be remembered. 0x7F is deliberately left out of the range, because 0x7F with
the high bit set is 0xFF, which is Error: 31 requests against 31 responses is
a rule that stays true, and a 32nd request whose response is somebody else’s
opcode is a trap for whoever allocates last. A link-local type arriving on a
client-facing transport is error 257, not error 2: it is a routing bug in the
comms processor, not a client sending nonsense, and the two want different
investigations.
The two auth columns are one column in disguise, and splitting them is the
point. A request and its response are not authenticated the same way — a
Snapshot request is wrq and its response is rsp, under different preimages
— and a single column saying “session” covered both while agreeing with P-052
only by luck. These are the labels P-052 defines, and nothing else is a legal
value.
| Request | Response | Name | Auth (request) | Auth (response) | Since | Status |
|---|---|---|---|---|---|---|
0x00 | 0x80 | Discover | none | none | 1.0 | live |
0x01 | 0x81 | Hello | proof | rsp | 1.0 | live |
0x02 | 0x82 | Snapshot | wrq | rsp | 1.0 | live |
0x03 | 0x83 | Subscribe | wrq | rsp | 1.0 | live |
| — | 0x04 | Event | — | evt | 1.0 | live |
0x05 | 0x85 | ReadLog | wrq | rsp | 1.0 | live |
0x06 | 0x86 | GetConfig | wrq | rsp | 1.0 | reserved |
0x07 | 0x87 | SetConfig | signed | rsp | 1.0 | reserved |
0x08 | 0x88 | Command | signed | rsp | 1.0 | reserved |
0x09 | 0x89 | Firmware | signed | rsp | 1.0 | reserved |
0x0A | 0x8A | Time | signed | rsp | 1.0 | live |
0x0B | 0x8B | Pair | pair_key | pair_key | 1.0 | live |
0x0C | 0x8C | Goodbye | wrq | rsp | 1.0 | live |
0x60–0x7E | 0xE0–0xFE | link-local, see LINK.md | — | — | 1.0 | live |
| — | 0xFF | Error | — | rsp_or_bare | 1.0 | live |
GetConfig and SetConfig are reserved rather than live because their
envelopes are settled and their body is not: no section has a field list yet,
so two implementations can exchange a config read and disagree about every byte
inside it. The numbers are allocated; the schemas are deferred — see
DEFERRED.md.
Pair authenticates under pair_key, which P-088 derives from printed_secret
with the label km43/v1/pair-key. The printed secret is never an HMAC key
itself: it can never be rotated, and the comms processor watches every pairing
exchange, so keying it directly would hand the one component this protocol calls
hostile an oracle under the one secret the device depends on.
Goodbye exists because a session table with no way to empty it locks every
client out after eight reconnections inside the expiry window. It is not
politeness; it is the difference between a browser refresh working and not.
Section titled “Goodbye exists because a session table with no way to empty it locks every
client out after eight reconnections inside the expiry window. It is not
politeness; it is the difference between a browser refresh working and not.”Error codes — u16
Section titled “Error codes — u16”| Code | Meaning | MAC’d? | Status |
|---|---|---|---|
| 1 | Malformed frame | no | live |
| 2 | Unknown message type | no | live |
| 3 | Protocol major mismatch | no | live |
| 4 | Hello required first | no | live |
| 5 | Payload too large | no | live |
| 6 | Unknown section | yes | live |
| 7 | Busy — retry | yes | live |
| 8 | Session table full | no | live |
| 9 | Session expired | no | live |
| 10 | Bad MAC | no | live |
| 11 | Counter not fresh | yes | live |
| 12 | Unknown client | no | live |
| 13 | Pairing window closed | no | withdrawn |
| 14 | Stale challenge — reconnect and retry | no | live |
| 15 | Snapshot exceeds channel cap | yes | withdrawn |
| 16 | Not permitted on this transport | no | withdrawn |
| 17 | Clock not set | yes | withdrawn |
| 256–511 | link-local, see LINK.md | no | live |
13, 15, 16 and 17 are withdrawn, not live, because nothing produces them.
13 because P-066 answers a Pair arriving with no window open with Pair 0x8B outcome 2 window_closed, MAC’d under the printed secret. That is P-141
doing its job: a refusal the comms processor can forge is a refusal that sends
somebody back to the panel to press a button that was never needed.
15 because P-090 refuses an over-cap configuration at write time with
SetConfigAck outcome 5 exceeds_cap. Nothing builds an over-cap Snapshot to
refuse — the failure lands on the person editing config rather than on a client
at 2 a.m.
16 because there is no per-transport permission policy yet — that is DEFERRED.md entry 4 — and the only signal a controller has about which transport a client arrived on is a field the comms processor writes, which is the one party the boundary exists to distrust.
17 because no rule in PROTOCOL.md or LINK.md
raises it. A controller whose clock has never been set does not refuse anything:
P-093 omits at, and P-092’s stale simply stops being expressible until the
first Time write. There is nothing left for a refusal to say.
All four numbers are held so nobody else takes them. They are retired-adjacent rather than free — each one had a rule pointing at it once, and reusing a number somebody’s early implementation may already have emitted is the collision this file exists to stop. Each stays unemitted until a rule says what refuses what.
This table allocates a meaning. The rule that produces a code lives in PROTOCOL.md or LINK.md, and a condition those documents describe without naming a code here is a bug in them: two implementations will pick different numbers for the same refusal and neither is wrong.
The MAC’d column is the receiver’s rule, not the sender’s. It says which
codes a receiver refuses to read out of a bare Error body — a bare error 6 or
11 is discarded, not acted on. What shape a sender emits is P-142’s question and
is answered by whether that sender holds a session, never by looking a code up
here. One column, one meaning, or the two tests disagree in exactly the case that
matters: a session the controller has already dropped.
Link-local codes start at 256 so a client can tell the link failed from
your request failed. Three of them reach a client on purpose: 257 (it sent
a link-local type), 258 (it connected before the two firmwares had exchanged
LinkUp) and 259 (its handle is gone, which is what a controller reboot
looks like from the outside). All three mean reconnect, and none is MAC’d, so
the rule below applies to them like any other unauthenticated error. The rest —
256 and 260–264 — never leave the UART, and one of those in a
client-facing frame is wrong on sight.
An unauthenticated error is a hint, never a fact. The comms processor can
forge every no row above. A client may retry or reconnect on one; it must never
render one as a statement about the site, and it must never conclude from error
9 that its own writes did not land — that is what the log is for.
Metric kinds — u16
Section titled “Metric kinds — u16”Every metric carries a unit and a scale, and the value on the wire is an integer. There is no FPU on the target, and a scaled integer cannot disagree with itself the way a float rounded by two languages can.
value = wire_integer × 10^scale, in the stated unit.
A metric kind and an event kind can be the same number, and fourteen of the
twenty event kinds are. 0x0604 is log ring utilisation here and time set
in the event table; 0x0301 is ambient temperature here and behaviour
decision there. Nothing on the wire is ambiguous — a metric kind appears only in
Value.kind, an event kind only in Event.kind and LogEntry.kind, and no field
takes both — so this is a trap for a person rather than for a decoder: somebody
greps 0x0604, finds one row, and writes it into the wrong table. That is the
exact mistake this file exists to catch, sitting inside the file itself.
The two spaces are not renumbered onto disjoint prefixes. It would buy a
decoder nothing, cost half of both spaces, and retire twenty numbers that four
documents and the vector file already name in prose — and a renumber applied here
and not in LINK.md is precisely the collision the allocation rule
exists to stop. What is required instead is a habit with teeth: prose naming one
of these numbers says which space it is in — metric 0x0604, event
0x0604 — and a bare number in a sentence about kinds is a bug in that sentence.
P-019’s vendor range 0xF000–0xFFFF spans both spaces on purpose. It is the
same rule in each — surface that one carrier as unrecognised, keep the rest of the
message — so it needs one range and not two.
0x01xx — DC electrical
Section titled “0x01xx — DC electrical”| Kind | Name | Unit | Scale | Status |
|---|---|---|---|---|
0x0101 | battery voltage | V | −3 | live |
0x0102 | battery current (+ charge, − discharge) | A | −3 | live |
0x0103 | battery power | W | −1 | live |
0x0104 | state of charge | % | −1 | live |
0x0105 | battery temperature | °C | −1 | live |
0x0110 | PV array voltage | V | −3 | live |
0x0111 | PV array current | A | −3 | live |
0x0112 | PV array power | W | −1 | live |
0x0120 | load current | A | −3 | live |
0x0121 | load power | W | −1 | live |
0x0130 | start battery voltage | V | −3 | live |
Battery current is signed, and that is the whole point of it. A PZEM-017 reports current as unsigned and therefore cannot fill this slot at all — it cannot tell 40 A into the bank from 40 A out of it. A source that cannot distinguish direction does not implement this measurement, even if it exposes an ampere value.
0x02xx — AC electrical
Section titled “0x02xx — AC electrical”| Kind | Name | Unit | Scale | Status |
|---|---|---|---|---|
0x0201 | AC voltage | V | −1 | live |
0x0202 | AC current | A | −3 | live |
0x0203 | AC power | W | −1 | live |
0x0204 | AC frequency | Hz | −2 | live |
0x0205 | AC energy | Wh | 0 | live |
AC frequency is the discriminator that proves an engine caught. Current into the bank is also what the sun does; 120 V at 60 Hz is not.
0x03xx — temperature
Section titled “0x03xx — temperature”| Kind | Name | Unit | Scale | Status |
|---|---|---|---|---|
0x0301 | ambient temperature | °C | −1 | live |
0x0302 | probe temperature | °C | −1 | live |
0x04xx — level
Section titled “0x04xx — level”| Kind | Name | Unit | Scale | Status |
|---|---|---|---|---|
0x0401 | tank level | % | −1 | live |
0x0402 | tank sender current | A | −6 | live |
0x05xx — generator
Section titled “0x05xx — generator”| Kind | Name | Unit | Scale | Status |
|---|---|---|---|---|
0x0501 | generator state (enum, see below) | — | 0 | live |
0x0502 | generator run hours | h | −2 | live |
0x0503 | generator starts since commissioning | count | 0 | live |
0x0504 | run contact commanded (bool) | — | 0 | live |
0x06xx — controller
Section titled “0x06xx — controller”| Kind | Name | Unit | Scale | Status |
|---|---|---|---|---|
0x0601 | generator selector position (enum, see below) | — | 0 | live |
0x0602 | uptime since boot | s | 0 | live |
0x0603 | last boot reason (enum) | — | 0 | live |
0x0604 | log ring utilisation | % | −1 | live |
The three spaces inside a metric
Section titled “The three spaces inside a metric”0x0501, 0x0601 and 0x0603 carry an answer in a Value’s key 3 where
every other metric carries a measurement. P-125 is the rule that follows from
that, and it is the one place in the protocol where a discriminant is not in a
discriminant field: a value here that a client cannot name is surfaced as
unrecognised for that one channel and never rendered as a state, while the
Snapshot around it stands.
They were three sentences of prose under the tables above until a client had to decide what an unrecognised one means. A number a reader has to parse out of a sentence is a number the bindings cannot be generated from, which is the whole argument for this file.
Generator states — u8
Section titled “Generator states — u8”| Value | Name | Meaning |
|---|---|---|
| 1 | stopped | No AC and our contact open. Autostart may fire |
| 2 | starting | Our contact closed, no AC yet. Gives up after the start window |
| 3 | running | AC present with our contact closed. Ours, and ours to stop |
| 4 | running, not ours | AC present with our contact open — somebody started it with the fob. Not a fault, and never rendered as one |
| 5 | stop not honoured | Our command was withdrawn and the AC persists. Notify; never retry |
| 6 | gave up | Commanded to run and it did not, as many times as the starter is worth. The contact stays open and nothing here will try again until a person clears it — out of fuel, an oil-alert shutdown and a flat start battery are indistinguishable from the controller, and hammering a start line into any of them is wrong |
The state model is two facts: is there AC, and is our contact closed. 4 is the
row that matters: somebody started it with the fob, and closing our contact on
top of that converts the fob owns it into both own it, after which the
human’s stop button does not work.
Generator selector — u8
Section titled “Generator selector — u8”| Value | Name | Meaning |
|---|---|---|
| 1 | auto | Behaviours may command the generator |
| 2 | off | Never command it, for any reason. A behaviour that needs it reports itself inhibited |
| 3 | manual | The operator is driving. The controller monitors and logs only |
Named for what it selects, because SelectorPosition on its own is a client
rendering three positions of something without knowing what they govern. It is
also a different thing from the lockout switch at the genset: that one is a
service lockout — nothing cranks while somebody’s hands are in there — and this
one is an operator override, at the controller.
Boot reasons — u8
Section titled “Boot reasons — u8”| Value | Name |
|---|---|
| 1 | power-on |
| 2 | watchdog |
| 3 | brown-out |
| 4 | software reset |
| 5 | panic |
Five different situations, and a generator that was running through one of them is a sixth — the log record carries that, not this metric.
Quality — u8
Section titled “Quality — u8”| Value | Name | Meaning |
|---|---|---|
| 1 | measured | Read directly from an instrument |
| 2 | counted | Integrated or accumulated, and trustworthy as a total |
| 3 | estimated | Derived from something else, e.g. SoC from terminal voltage |
| 4 | stale | Was measured, is no longer current: older than its channel’s maximum age, or the same reading for so long that the instrument is presumed hung |
| 5 | absent | No reading. The value key is omitted entirely |
A behaviour that needs a counted figure refuses an estimated one. This is
the founding rule of the project and the reason 5 exists rather than a zero.
Event kinds — u16
Section titled “Event kinds — u16”This is a different space from the metric kinds above, and fourteen of the
twenty numbers below are also a live metric kind. 0x0201 is generator state
changed here and AC voltage there. Nothing decodes ambiguously — the two never
meet in one field — but a sentence that says 0x0201 without saying which space
it means is a sentence somebody implements backwards. Say event 0x0201. The
argument for leaving the numbers overlapping is at the head of the metric table.
| Kind | Name | Class | Status |
|---|---|---|---|
0x0101 | value changed | B | reserved |
0x0102 | value quality changed | A | reserved |
0x0201 | generator state changed | A | reserved |
0x0202 | generator command withdrawn | A | reserved |
0x0203 | generator stop not honoured | A | reserved |
0x0301 | behaviour decision (shadow or applied) | A | reserved |
0x0302 | behaviour inhibited | A | reserved |
0x0401 | output changed | A | reserved |
0x0501 | alarm raised | A | reserved |
0x0502 | alarm cleared | A | reserved |
0x0601 | boot | A | reserved |
0x0602 | config changed | A | reserved |
0x0603 | client enrolled | A | reserved |
0x0604 | time set | A | reserved |
0x0701 | records dropped | A | reserved |
0x0702 | record failed CRC | A | reserved |
0x0801 | comms link lost | A | reserved |
0x0802 | comms power cycled | A | reserved |
0x0803 | comms unrecoverable | A | reserved |
0x0804 | sessions shed for backpressure | A | reserved |
Every row is reserved, and the reason is the body map. The kind numbers
and the classes are settled; not one kind says what is inside its body, so two
implementations can agree on framing, on the MAC and on the sequence number and
still disagree about every event they exchange. The numbers are allocated; the
body schemas are deferred — see DEFERRED.md.
Class A and class B
Section titled “Class A and class B”The Class column decides which records survive a controller under pressure,
and the two words are defined here, normatively, rather than left to a design
document. A column with no definition beside it is a column every implementation
fills in for itself, and the two answers it picks between are keep this alarm
and drop it. A controller that ranks alarm raised below value changed loses
the alarm on the busiest day, which is the day it mattered.
Class A is never dropped — not from the log, and not from a queue. When the
implementation’s bounded global write budget is exceeded on class A alone, the
controller raises a diagnostic instead of shedding: a site
producing that many state changes has something genuinely wrong with it, and the
record of what went wrong is the last thing to throw away. When a session’s
MAX_EVENT_QUEUE cannot take a class A event, P-098 in
PROTOCOL.md closes that session and the client reconnects and
catches up with ReadLog. That is worse than a delivered event and far better
than a client sitting on a socket it believes is current while an alarm never
reached it.
Class B is dropped first, under pressure, and every drop is counted. In the
log it is what the write budget sheds; in a session’s outbound queue it goes
oldest first, and the count reaches that session in its own records dropped
(0x0701) record — a log-ring count cannot explain a hole one queue made.
Counted is half the definition and not a detail: a hole in seq with no number
attached is the mystery P-097 forbids a client to render as a complete stream.
Today class B is exactly one kind — value changed — and that is the whole of the split. It is the only kind whose rate follows a sampling loop rather than something happening at the site. Everything else in the table is a state change, a command outcome, an alarm, a boot, or a record about the log itself, and every one of those is a sentence somebody has to be able to find in April.
A new event kind is allocated with its class, or it is not allocated. A row added with the class left blank is a number on the wire before anybody has decided whether it may go missing.
Behaviour decision carries whether the decision was applied or shadowed. While every site runs in shadow, a client that cannot tell a shadowed decision from an applied one cannot audit the thing the shadow deployment exists to prove.
0x08xx is the link to the comms processor. LINK.md L-023 and
L-110 through L-112 require the controller to log every one of these — the heartbeat ladder ends in a rail
switched off — and had no numbers to log them under, which is how a number gets
invented at a bench by whoever hit it first. Comms power cycled carries the
cycle count, because three power cycles in an hour, rail left off is a sentence
somebody has to be able to read at a client: a link that goes quiet leaving no
record behind looks exactly like a site that is fine.
Sessions shed for backpressure is the newest of them, and it is allocated
because LINK.md’s L-023 requires a class A record for the first session shed in an
hour and had no kind to log it under. A comms processor that answers every
heartbeat and drains the UART slowly closes all eight sessions without forging a
byte or dropping a frame, and the shed record is the only trace it leaves — the
link is up and carrying nothing is otherwise a state the untrusted peer can
drive at will with nobody able to name it afterwards. It is class A for the same
reason: it is the evidence, so it is the one record that must not be shed by the
pressure it describes. Comms link lost (0x0801) stays the escalation at three
in an hour; 0x0804 is the first occurrence.
A clock that jumps is not a fourth kind. LINK.md’s L-160 logs a step of more than
five seconds in 0x0604 time set, carrying the old value alongside the new and
the source that set it. One clock change is one record. Splitting it into a
routine kind and a jumped kind gives a client two things to reconcile and gives
whoever is asking did the schedule fire twice on Tuesday two places to look.
Config sections — u16
Section titled “Config sections — u16”| Section | Name | Status |
|---|---|---|
0x0001 | identity and site | reserved |
0x0002 | channels | reserved |
0x0003 | buses and devices | reserved |
0x0010 | generator behaviour | reserved |
0x0011 | frost behaviour | reserved |
0x0012 | schedule behaviour | reserved |
0x0013 | load-shed behaviour | reserved |
0x0020 | network | reserved |
0x0021 | cloud | reserved |
Every row is reserved, and again it is the body. The section numbers are
settled — a client can ask for 0x0011 and be told whether it exists — and no
section has a field list, a key number, a unit or a scale for anything inside
it. The numbers are allocated; the body schemas are deferred — see
DEFERRED.md.
The network and cloud sections hold credentials — the site’s Wi-Fi
passphrase among them, because the controller holds the master copy and
LINK.md says why. One rule about those schemas is settled already and
is not waiting on the field lists: a field marked secret is never returned by
GetConfig, which the controller answers with a body and which every enrolled
client can call, the cloud client included. DEFERRED.md entry 9
carries it. Anybody drafting a section body from this table alone would write the
leak straight back in.
Every behaviour section carries a shadow flag. It is a flag rather than a
subsystem, and it is readable by a client so that “nothing is actuating” is a
thing a person can confirm rather than believe. Its key number arrives with the
section schemas, and it must be the same number in all four behaviour sections:
a shadow that means key 9 for frost and key 4 for the generator is a flag
somebody reads wrong exactly once.
Command kinds — u16
Section titled “Command kinds — u16”Reserved, not specified. No actuation until a controller is granted authority over an output — see DEFERRED.md. The space is allocated now so that nothing squats on it in the meantime.
| Kind | Name | Status |
|---|---|---|
0x0101 | start generator | reserved |
0x0102 | stop generator | reserved |
0x0103 | run exercise cycle now | reserved |
0x0201 | set output | reserved |
0x0202 | clear alarm | reserved |
0x0301 | clear override | reserved |
Those six names are allocations, not specifications: none has an argument schema
and none may be sent by a client yet. 0x8000–0xFFFF is where bench and
experimental kinds go instead — permanently non-normative, never allocated in
this file, and free to abandon. That is the whole of the squatting rule, and
DEFERRED.md entry 8 says when a low number becomes sendable.
There is deliberately no factory-reset command and no unpair command. A compromised cloud that can unpair a controller which starts engines is worth more to an attacker than any single command. Both require the physical button, and that is the only way a full client table is emptied.
Command outcomes — u8
Section titled “Command outcomes — u8”| Value | Name | Meaning |
|---|---|---|
| 1 | accepted | Acted on |
| 2 | rejected | Refused, detail says why |
| 3 | duplicate | Already seen this cmd_id; not acted on twice |
| 4 | inhibited | The selector is at Off, or the generator is not ours |
| 5 | unauthorised | This client may not do this |
| 6 | shadowed | Would have been accepted; nothing was actuated |
4 and 6 are not failures. inhibited is the controller declining with a
reason a person can read; shadowed is every decision a site makes while it runs
in shadow.
5 is what the client capability mask below produces. A client whose mask
does not carry send commands is refused here, inside a MAC’d response, rather
than with an error the comms processor could have forged — P-141, applied.
SetConfig outcomes — u8
Section titled “SetConfig outcomes — u8”| Value | Name |
|---|---|
| 1 | accepted |
| 2 | stale_version |
| 3 | invalid |
| 4 | unauthorised |
| 5 | exceeds_cap |
4 is the capability mask refusing one of two different things: a client that may
not write configuration at all, or a client that may not write this section —
a cloud client editing 0x0020 network. One outcome for both, because from the
client’s side there is one thing to do about either, and the section it named is
already echoed back in key 1.
Pair outcomes — u8
Section titled “Pair outcomes — u8”| Value | Name |
|---|---|
| 1 | enrolled |
| 2 | window_closed |
| 3 | bad_proof |
| 4 | table_full |
| 5 | reclaimed |
5 is what stops the client table being a consumable, and the rule that
produces it is P-078 in PROTOCOL.md, not this file. Which
Pair reuses an occupied row, that the label comparison is over the exact
UTF-8 bytes that entered the pair-proof preimage, that matching runs before
allocation so P-067’s table_full means eight distinct labels rather than eight
pairings, that a reclaim is not a revocation, and why setting the counter back to
0 does not re-open replay — all of it is there, with the arithmetic that says why
eight cumulative re-pairings is a season and not a lifetime. What this file
allocates is the number.
Outcome 5 rather than outcome 1 is the part that belongs here, because it is
the allocation decision: a reclaim silently reported as an enrolment is a row
overwritten with nobody told. The person at the panel needs to read this
replaced the row called kitchen phone, and the log record needs to say the
same — see DEFERRED.md entry 10.
Time outcomes — u8
Section titled “Time outcomes — u8”| Value | Name |
|---|---|
| 1 | accepted |
| 2 | rejected |
| 3 | unauthorised |
| 4 | needs_button |
3 exists because the capability mask can refuse a Time write, and rejected
would have told a client its clock was implausible when the real answer is that it
is not allowed to set one. Moving the clock moves schedule, exercise and
quiet_hours with it, so who may and is this a sane time are two questions and
a client that cannot tell them apart retries forever against the wrong one.
4 splits the same hair one more time. A backward set refused for P-114’s floor
is refused for a reason a person can act on — hold the button at the panel and
send it again — and under 2 it was indistinguishable from a time nobody could
believe. A client that cannot tell those apart puts the controller says that
time is implausible on the screen while the correction somebody drove four hours
to make sits one gesture away.
Time sources — u8
Section titled “Time sources — u8”| Value | Name | Meaning |
|---|---|---|
| 1 | client | A signed Time write from an enrolled client |
| 2 | ntp-via-comms | A TimeOffer the controller accepted from the comms processor |
One space for what gets recorded. The Time operation carries a value from
this table and P-111’s time set event records one, and there is no second
space either of them may draw from. An accepted TimeOffer from
LINK.md is recorded as 2 (L-162). That offer carries a source field of its
own — a link-local enum with one value in it — and its number is never copied
through: 1 there means NTP, 1 here means a client set the clock, which
is the opposite. The point of recording a source at all is to tell a drifted RTC
from a lying uplink, so a number copied straight across inverts the one thing the
field exists to say.
Firmware outcomes — u8
Section titled “Firmware outcomes — u8”Reserved — see DEFERRED.md.
| Value | Name | Status |
|---|---|---|
| 1 | accepted | reserved |
| 2 | bad_offset | reserved |
| 3 | bad_signature | reserved |
| 4 | wrong_target | reserved |
| 5 | too_large | reserved |
| 6 | no_transfer_in_progress | reserved |
| 7 | not_owner | reserved |
| 8 | unauthorised | reserved |
7 and 8 are different refusals and were one word covering both. 7
not_owner is about a transfer: a Firmware operation naming a transfer that
another client began. One image lands at a time, and a second client feeding
chunks into somebody else’s transfer is how a half-and-half image gets written and
then fails its signature after the reboot. 8 unauthorised is about the
client: its capability mask does not carry push firmware, and no transfer of
its own would have been accepted either. A client told not_owner retries when
the other transfer finishes, which is right for one and a loop for the other.
Nothing produces 7 yet, and that is a gap in the message bodies rather than
in this table. No document says how a transfer records which client owns it, so
there is nothing an implementation could compare a second client against. It is
owed by DEFERRED.md entry 6 along with the rest of the Firmware
field list, and it is named there so the number does not sit here looking
implemented.
Client kinds — u8
Section titled “Client kinds — u8”| Value | Name |
|---|---|
| 1 | app |
| 2 | browser |
| 3 | cloud |
| 4 | cli |
Client capability mask — u16
Section titled “Client capability mask — u16”A per-client bitfield, fixed at enrolment from client_kind and stored in FRAM
beside that client’s counter. P-105 in PROTOCOL.md is the
rule — where the mask comes from, why client_kind is a sound input where
transport is not, that no message raises or lowers one, and what each refusal
is answered with. This file allocates the bits and the row each client_kind is
handed.
It is not a wire discriminant — P-014 does not reach it, and an unallocated bit
is a capability nobody has defined yet, not a value to reject. It is allocated
here because two firmwares that disagree about what bit 4 means disagree about
whether the cloud can push firmware, which is the same collision as two people
picking 0x0503 and worse in its consequences.
| Bit | The client may | app | browser | cloud | cli |
|---|---|---|---|---|---|
| 0 | write configuration at all | yes | yes | yes | yes |
| 1 | write the network (0x0020) and cloud (0x0021) sections | yes | yes | no | yes |
| 2 | send Command 0x08 | yes | yes | yes | yes |
| 3 | set the clock with Time 0x0A | yes | yes | no | yes |
| 4 | push firmware with Firmware 0x09 | yes | yes | no | yes |
| 5–15 | unallocated | — | — | — | — |
Link-local error codes
Section titled “Link-local error codes”Allocated in LINK.md, which is where the rule for each one lives; L-180 is the rule for the third column. They are listed here because this file is where a number is handed out, and a space allocated somewhere else is a space two people can allocate from.
Three of them reach a client on purpose and six never do. That distinction is the reason they are a separate space from the client error codes rather than a continuation of them: a client that cannot tell the link failed from your request failed retries against the wrong thing.
| Code | Meaning | Reaches a client? |
|---|---|---|
| 256 | Unknown link-local opcode, or one sent from the wrong side | no |
| 257 | Link-local type on a client transport | yes |
| 258 | Client frame before LinkUp completed | yes |
| 259 | Unknown connection handle | yes |
| 260 | Connection table full | no |
| 261 | Link protocol major mismatch | no |
| 262 | Too many outstanding link-local requests | no |
| 263 | Link-local type with a non-zero session | no |
| 264 | No authorisation matches this image | no |
Bit 1 is only consulted when bit 0 is set: a client that may not write configuration cannot write two particular sections of it either.
Which response answers which denial, because those are outcome numbers and
outcome numbers live here. P-105 is what requires every one of them to ride
inside the MAC’d response rather than inside an Error the comms processor could
have forged:
| Refused | Answer |
|---|---|
SetConfig, bit 0 clear — or bit 1 clear on 0x0020/0x0021 | SetConfigAck 0x87 outcome 4 unauthorised |
Command, bit 2 clear | Ack 0x88 outcome 5 unauthorised |
Time, bit 3 clear | TimeAck 0x8A outcome 3 unauthorised |
Firmware, bit 4 clear | Firmware 0x89 outcome 8 unauthorised |
A client kind added to the table above arrives with its row here in the same commit — P-105 refuses enrolment of a kind with no row, and this is the allocation half of the same rule. A kind with no mask row is a client whose permissions are whatever the implementer’s zero value happens to be, and a default that opens is the one direction this project never defaults.
A cloud client keeps bit 2, and that is deliberate. Telling a generator to
stop from somewhere that is not the site is the whole point of having a cloud at
all, and every command is MAC’d end to end regardless — a compromised relay cannot
forge one, it can only refuse to carry it. What it does not need is the ability to
re-flash the controller, move the clock under a schedule, or read back the
credentials it forwards. Command authority is separately gated in any case: no
kind below 0x8000 is sendable by anybody yet (DEFERRED.md
entry 8).
The failure the cloud column closes is that Command 5 unauthorised,
SetConfig 4 unauthorised and Firmware 8 were a vocabulary of refusals with
nothing behind them — allocated in this file, produced by nothing, and reading as
implemented to everybody who grepped for them. P-105 says what that left the most
exposed client in the product able to do; this table is where it stops.
The mask granted at enrolment belongs in the client enrolled record (event
0x0603) when that body schema is written — DEFERRED.md entry 10.
Somebody was enrolled and somebody was enrolled and may push firmware are
different sentences, and only one of them is an audit record.
Capability bits — u32
Section titled “Capability bits — u32”Returned in the Hello response. A bit set means the controller can do the thing; a client must not assume an unset bit is a failure, only an absence.
| Bit | Meaning | Status |
|---|---|---|
| 0 | event log readable | live |
| 1 | configuration writable | live |
| 2 | commands accepted | reserved |
| 3 | firmware update accepted | reserved |
| 4 | clock settable by client | live |
| 5 | counted state of charge available | live |
| 6 | AC metering on the generator | live |
| 7 | any behaviour is in shadow mode | live |
| 8–31 | unallocated | — |
Bit 7 is deliberately coarse. A client that wants to know which behaviour is shadowed reads the config sections; the bit exists so an app can put a banner up without four round trips.
Bits 1, 2, 3 and 4 are answered per calling session, not per device: the
controller reports its own capability and the calling client’s mask. A cloud
client sees firmware update accepted and clock settable by client clear, and
does not draw a button that can only ever be refused. No new field carries this,
because the Hello response is bound to exactly one client_id by construction —
the field that can say it is already in the right place.
Bit 1 is coarser than the mask and stays that way. A cloud client may write configuration and may not write two sections of it, so it reads bit 1 set and is still refused with outcome 4 on the network section. A bit here is a claim about a message type, not about every section that message can name.
Retired numbers
Section titled “Retired numbers”None yet. When the first one lands it stays here forever, with the date and the reason, because the alternative is somebody reusing it in three years.