The wire format
EEBUS JSON-UTF8 explained: why every sequence is an array of single-key objects, and how the codec encodes it directly through serde in one pass.
SPINE is specified in XML Schema, but on the wire over SHIP it is JSON. The projection between them (SHIP §11.4) is unusual enough to be worth understanding before anything else makes sense.
Arrays of single-key objects
XML sequences are ordered and may repeat an element name; JSON objects are neither. Rather than lose that, EEBUS represents every sequence as an array of single-key objects:
{"datagram":[
{"header":[{"specificationVersion":"1.3.0"},{"msgCounter":7},{"cmdClassifier":"notify"}]},
{"payload":[{"cmd":[[
{"function":"deviceDiagnosisHeartbeatData"},
{"deviceDiagnosisHeartbeatData":[{"heartbeatCounter":12},{"heartbeatTimeout":"PT1M"}]}
]]}]}
]}Note the double bracket after "cmd": cmd is a list, and each command is itself a sequence, so an array of arrays.
Encoded directly
Most implementations serialise to ordinary JSON and rewrite the tree afterwards. This one encodes the format directly through serde in a single streaming pass — no intermediate Value, no allocation for field names. Round trips are byte for byte, which matters because the fixtures are the specification's own example datagrams.
Byte for byte for a schema-valid message. The model is generated from the XSDs, so a field a peer sends where the schema defines none has nowhere to go and is dropped. That is not hypothetical: SPINE's detailed discovery uses a restricted address type carrying only the entity path, and eebus-go puts a device in it anyway. Dropping it is the interoperable answer — the enclosing deviceInformation already names the device — and the device captures in the test suite hold the crate to exactly that one exception.
use eebus::model::{from_json_str, to_json, CmdData, Function};
let datagram = from_json_str(message)?;
let cmd = &datagram.payload.as_ref().unwrap().cmd.as_ref().unwrap()[0];
if let Some(CmdData::DeviceDiagnosisHeartbeatData(hb)) = &cmd.data {
println!("heartbeat #{:?}", hb.heartbeat_counter);
}
assert_eq!(to_json(&datagram)?, message); // byte for byteNumbers that are not floats
SPINE carries physical quantities as a ScaledNumber: an integer number and a power-of-ten scale. 4200 W is {"number": 42, "scale": 2}. There is no floating point on the wire, which is deliberate — a limit is a legal quantity and must round-trip exactly.
These rules follow, and each is easy to get wrong:
scaleis always sent. The SPINE implementation guide §3.2.1 erratum says so, and a peer that assumes a missing scale means zero will be wrong by orders of magnitude.- A partial update merges into the stored value. Send a
scaledNumbercarrying onlynumberand the storedscalemust survive. Replace instead of merge and a 4.2 kW limit becomes 42 MW. See Restricted Function Exchange. - A value that overflows
f64is not a value.scaleis signed 16-bit, so anumberneari64::MAXreaches infinity in one well-formed message.to_f64answersNone, and a use case handed a present-but-unreadable value NACKs the write rather than substituting a number the peer never sent. - A negative scale divides; it does not multiply. No negative power of ten is representable in binary, so
number × 10⁻⁴rounds twice — once building the constant, once in the product — and12345 × 10⁻⁴comes out as1.2345000000000002. Dividing by the exact1e4rounds once. The two spellings of a quantity have to agree, or a limit changes when a peer picks a different scale for it. - And a scale past
10²²is still that scale. Only the first twenty-three powers of ten aref64values, so a bigger scale is applied in steps of the largest exact one rather than through a magnitude built up first — which rounds once per step instead of once per decade, and gets10²³exactly right.scaleis a signed 16-bit integer, so a peer reaches that seam in one schema-valid message. - A value too large for an
i64raises the scale.1e30 as i64isi64::MAX, a different number by a factor of nine;ScaledNumber::from_f64answersnumber = 1, scale = 30instead, which is what the scale is for.
Times
AbsoluteOrRelativeTimeType is defined as xs:union memberTypes="xs:duration xs:dateTime", so one element carries either a span or an instant and nothing in the element says which. AbsoluteOrRelativeTime::parse returns whichever arrived; is_absolute() says which form the peer meant, which stays answerable even when the value cannot be read.
Durations — PT1M, PT2H, P1DT30M — are parsed and emitted without a calendar library, since only the fixed-length part of the grammar can occur in SPINE.
Months and years are refused rather than approximated: LPC's failsafe duration is a safety-relevant timer, and guessing at 30-day months would make it wrong. So are durations with no components at all (P, PT). A duration pointing backwards reads as zero, not as its magnitude — Duration is unsigned, and a span that has elapsed has nothing left to run.
On a measurement a duration is an age: the value was taken that long before the message carrying it, so the instant is arrived − duration. That subtraction is Reading::taken_at_relative_to, against the same monotonic clock the engine is given.
Timestamps are xs:dateTime, which is not RFC 3339 and where reaching for an RFC 3339 parser is a bug that looks like a peer sending nothing. Three differences carry meaning:
- The offset is optional.
2026-09-05T08:15:00is well formed and names a wall-clock reading whose zone the protocol never states, so it fixes no instant at all.DateTimekeeps that distinction:unix_seconds()isNone, andunix_seconds_at(offset)makes the caller name the zone it means to assume rather than defaulting to UTC and landing an hour or two out of place. 24:00:00is legal, and denotes midnight ending that day (XML Schema Part 2 §3.2.7). It is normalised to00:00:00of the next.- The year may be negative and may exceed four digits. Neither reaches a household message; both are cheap to accept and expensive to refuse.
Everything that carries meaning is refused instead: a thirteenth month, a 29 February in a common year, a sixtieth second, an offset past ±14:00, a +0200 without its colon. A timestamp that is wrong is worse than one that is absent — the second is visibly missing and the first is silently believed.
What the crate does not decide is the policy. A household device sets its clock from NTP or from nothing, and how far a peer's clock may be wrong before its timestamps are worth less than the arrival time is not a decision a protocol library can make for a product.
Where a use case is narrower than the schema, the use case wins
The union is what the schema permits, and it is regularly wider than what a given element may actually carry. Two cases here, and both are places an implementer reading only the schema goes wrong in good faith:
- LPC, LPP and COB fix their durations as relative. §3.1.8.2 of each: "Durations used within this Use Case SHALL be presented as relative times. The same holds for the
endTimeElement used for the duration of validity." So a limit'sendTimeis a span, and one that cannot be read as a span makes the write unusable — reading it as absent would mean "no expiry", which is the wrong way for the mistake to fall. SmartEnergyManagementPsrestrictsschedule.startTimetoxs:durationbyxs:restrictionon thePowerSequencestype that uses the union. So a heat-pump compressor's start time is "in this long", never "at this instant", andohpcf::activatetakes aDurationso the wider spelling cannot be built.
Both are enforced rather than documented: the value is refused, and the refusal reaches the peer as a NACK.