v0.73.0
Removed
- Two exports whose comments named a caller that does not exist.
InterfaceClient.ModifiedAt/SetModifiedAtdocumented themselves as
driven by the central's event path; nothing set them and nothing read them,
and the per-interface timestamp they duplicated is kept by the event
coordinator (lastEventStamp).ParameterDecider.IsParameterHiddenand
IsParameterHiddenForCentralwere aliases of theIgnoredpair with no
production caller. Both were found by the pre-release comment-claims sweep.
No behaviour changes: neither path ever ran.
Fixed
-
The reachability analyzer's diagnostics were invisible. Its logger
defaulted toWarn, so every progress line — how many packages loaded, how
many entry points were found, how large the whitelist is — needed
-verboseto appear. That is the numbers that tell a reader whether the run
measured what they think it did.It had already cost something: a diagnostic added at
Infoto report that
package variants disagreed on reachability printed nothing, and the silence
read as "nothing disagreed" when 443 identifiers had. The level isInfo
now,-verbosestill adds the per-file detail, and the messages that became
visible are English rather than German, as is the rest of the tool's output.Output only: regenerating both inventories at the same commit produces
byte-identical content. -
The dead-code analyzer reported 443 live identifiers as dead, and counted
each identifier once per compilation rather than once.go/packagesloads
a package again for every test binary that links it, and each load produces
its own SSA package whose members are distinct objects carrying the same
path and name. The analyzer walked all of them.Two consequences. The counts multiplied, so the ratchet could rise while the
set of dead identifiers fell — which is exactly how it last moved. And RTA
answers per object, so one variant could call a symbol reachable while
another did not: 443 identifiers were listed as dead and counted as
reachable in the same run. Among themaddonupdate.Updater, a field of the
composition root, andaddonupdate.PeriodicChecker, constructed in
addon_update_wiring.go.The classification is folded per identifier now — whitelisted, then
reachable in any variant, then dead — and the run prints how many variants
disagreed, so the next occurrence is visible instead of absorbed. The
ceiling drops from 3033 to 1385, which is the count it was always meant to
be: a newly dead export moves it by exactly one. Measured both ways — the
same probe export moved the old counter by two.
Changed
-
BREAKING (API 11.0.0):
POST /system/firmware/downloadnow downloads the
CCU's own firmware, and ignores theurlfield. The endpoint offered to
fetch a caller-supplied image; no such call exists on a CCU. Its own entry
point resolves the download from the box's/VERSIONand board serial and
writes the image to/tmp/fup.tgz— it takes no parameters, so there was
never anywhere for a URL to go.What changes for a client: the request body is now optional apart from
central, aurlis accepted and ignored rather than validated (a client
written against the old contract keeps working unchanged), and a 202 now
means the CCU reported the download succeeded. A 502 means it reported a
failure — no firmware matches this box's version and serial, or the
transfer failed. Previously every call answered 202, because the old
implementation POSTed an action the maintenance CGI does not define and read
the resulting HTML error page, served under HTTP 200, as success.The audit entry records the central, not the discarded URL: recording a URL
the CCU never fetched made the trail claim something that did not happen.Downloading is still not installing —
POST /system/firmware/update
performs the install, matching the CCU's own two-step flow.SetDownloadFirmwareTransportis renamedSetHTTPTransport. It never
served firmware; it wires the two operations that do reach the CCU over
plain HTTP, the backup download and the group editor.
Fixed
-
reportValueUsagewas classified as a read, and it reconfigures the
device. The CCU documents the method as telling the interface process how
often a value is used so that it can establish or delete the connection to
the component (src/rfd/XmlRpcMethods.cpp:700-723): it persists the
per-channel usage record and adds or removes the direct link peer between
the channel and the central. Its BidCos return value is a transmission
verdict rather than a query result —falsemeans the device was
unreachable, the change is queued, andCONFIG_PENDINGis now set on its
MAINTENANCE channel. The daemon uses it for exactly that, calling it per
channel withrefCounter1 to create a central link and 0 to tear one down.It sat in
readMethods, whose own header said the table holds methods that
return CCU state without mutating it, so it was paced on the read throttle
and reported as a read. It is a write now.TestNoMethodIsBothReadAndWrite
keeps the tables disjoint, andTestReportValueUsageIsAWritepins this one
against what the CCU does rather than against the table.This matters beyond the throttle: the read/write split is what the project's
own procedure for the developer's live CCU treats as free of consequence, so
a run that believed it was only reading could reconfigure device links. -
pkg/hmlogwas not importable from outside this module. It imported
internal/reqctx— in the log factory, to install the request-context
handler, and in the operation logger, to read the span. Go refuses an
internal/path to an importer outside the module, so an external program
that embedded openccu-loom as a library and importedpkg/hmlogfailed to
compile. Nothing in this repository could notice: the rule never applies to
importers inside the module, and every importer here is inside it, so
go build ./...stayed green.That contradicted the promise
pkg/carries, written onpkg/hmapi: the
package sits there so an external program can import it without pulling in
the wholeinternal/tree.internal/reqctxis nowpkg/hmreqctx(packagehmreqctx, matching the
pkg/hm*convention every sibling follows). No behaviour changed and no
call site changed meaning — 31 files follow the rename.
TestPkgDoesNotImportInternalwalkspkg/and fails on anyinternal/
import, with no exception list: one entry forhmlogwould have made it
blind to the case it exists for. ADR 0017 carries an amendment recording
the move and why it was forced. -
The last seven low findings, all cross-package. The
.sbksuffix, the
ping caller-id and the XML-RPC fault collapse each had two spellings in
different packages; the Latin-1 byte loop had two, one over[]byteand
one overstring, easy to confuse with its inverse
(FixXMLRPCEncoding) and now living beside it.pkg/hmenum/ports.go
cited SPECIFICATION §7.2 for its values; that section is the risk
register and the specification states no CCU-side port at all.Two comments were corrected against the firmware rather than tidied. The
get_serialscript claimed it compresses the serial to ten characters —
the script does nothing of the kind, the Go caller does, and the CCU
publishes the value untruncated where it publishes it (get_serial_number
in the UPnP device description feedsSERIAL_NUMBERstraight from the
file). The reduction stays, because clients embed the result as the
central-id slot of their unique_ids, but it is this daemon's convention
and now says so.The aggregate alarm panel's name is resolved the same way on every plane:
MQTT already treated a catalogue key echoed back as "no translation", the
REST/WS wiring did not, so a dropped row would have shown the raw key on
one plane and "Alarm system" on the other. -
Low-severity audit findings, final batch: 24 more, and the last of the
open ones. The remaining set was the cross-package half — findings whose
fix reached into two or three packages at once, which is why they came last.- Backup archives are recognised by one constant. The
.sbksuffix was
spelled nine times across storage, restore and the REST download; a
mismatch would have made a written archive invisible to the list that
finds it. - Ping and pong now share one caller-id grammar. The producer built the
id and the PONG parser took it apart with a separator of its own, and two
comments described that id as<iface>when the wire carries a triple. - A missing hook is reported instead of silently succeeding. Five hub
coordinator methods returned nil when their write hook was never wired,
so an unwired program execution looked exactly like a successful one. - The profile-key grammar has one owner. Five spellings, three of them
acceptingP01and two not. Which form to keep was measured, not chosen:
no zero-padded profile key exists anywhere in the firmware tree or the
descriptor corpus, so the strict two-character form changes no reachable
input.
The
system.listMethodsadvertisement, theschedules.active_profile.set
base, the CCU-timezone wiring seam and the localizable "event received"
health note each needed a decision or a file the fixing agent could not
reach; they are done here.MaxDiscriminatorhad picked up a Matter-spec
section citation on its way out of the config validator — it now cites
matter.js, which is what this project's rule requires. - Backup archives are recognised by one constant. The
-
Low-severity audit findings, second batch: 62 more fixed across the model,
client, alarm, config, pkg and north-bound packages. The same classes as
the first batch, plus three worth naming:- A guard that could not tell the two sides apart. The forced-sensor
identity is spelled in three places; nothing compared them, so the model
and the MQTT discovery payload could name different entities. A new test
drives all three from one device and fails when they diverge. - A hint vocabulary with no cross-language check. Every icon and
state-colour tokenpkg/hmuiemits is now asserted to exist in the SPA's
own registries, so a token added on one side and not the other stops
silently rendering nothing. - A comment that taught a rule the body does not implement. The event
group's usage doc described a membership condition; the body never reads
the member set. The doc now says what the code does, and the rule stays
unimplemented because no consumer defines which answer is right.
Eleven findings were not fixed. Three had their recipe's own text say no
change was sound, two needed a value no source carries, three were
duplicates or optional halves of another finding, and the rest reached
outside the package that owned them. Two more were reported as fixed
without a bite proof because measurement showed the proposed guard cannot
bite — in both cases the fix makes the two sides read one constant, so they
move together and no test can separate them.Along the way
internal/model/device/naming.go's successor in
internal/model/hubwent the same way as the naming family before it: the
SysvarDp*wrapper set had no production caller, no accessor onSysvar
and no constructor call since the initial release, while carrying three
coercion rules that had drifted from the live ones. It is deleted. The
service route forset_valuestopped parsing strings itself — its boolean
table was case-sensitive and knew neither "yes" nor "t", while its numeric
tables usedfmt.Sscanf, which reads "12abc" as 12 and bounds nothing, so
a caller could be refused a value the daemon would write, or have a
malformed one accepted. - A guard that could not tell the two sides apart. The forced-sensor
-
Low-severity audit findings, first batch: 48 fixed across six packages.
Each was re-verified against the current tree before anything was changed —
five of the original set had already been fixed by the high/medium work and
were left alone. The classes that recurred:- A constant that named no wire value. The garage door's
DoorStateUnknownwas"UNKNOWN"; every device declaringDOOR_STATE
declaresCLOSED, OPEN, VENTILATION_POSITION, POSITION_UNKNOWN, so the
comparison matched nothing and an unknown door position was never
recognised as one. - One rule spelled several times. Channel-address parsing and composition
across the adapter now go throughpkg/hmtypes; the boot-time ingest
backoff, the CCU port table, the channel LIKE prefix and the lock
permission vocabulary each had two independent copies with a comment
asserting their equality and nothing enforcing it. - Code that could not run. A duplicate error sanitiser in
internal/clientwith no production caller, two unreachable un-ignore
branches, aPhaseUnreleasedno producer emits, anErrSchemaDriftno
path returns, and twoLinkCoordinatormethods nothing called. - Wire kinds that decoded to nil. The XML-RPC reply converter on the
callback path handled neitherDateTimeValuenorBase64Value; both
arrived as an untyped nil indistinguishable from a genuineNilValue. - Comments describing something the code does not do. The initial-snapshot
header said MASTER values are skipped while the pass publishes them; the
audit trail recorded channel 0 for a device-level paramset write.
Nine findings were deliberately not fixed and are recorded rather than
carried: two had their premise refuted by the firmware on re-reading, three
needed a decision about behaviour rather than a repair, and the rest reached
outside the package that owned them. - A constant that named no wire value. The garage door's
-
Every documented un-ignore pattern was one the parser rejects. The
annotated reference config, the concept document, the config field's own
comment, the store and the REST contract all taught
MODEL:CHANNEL:PARAMETER—"*:*:RSSI_DEVICE",
"HM-CC-RT-DN:4:VALVE_STATE". The parser refuses a colon without an@
outright. The accepted forms are a barePARAMETER, matching every VALUES
paramset on any model and channel, or the fully-qualified
PARAMETER:PARAMSET@MODEL:CHANNEL— which the SPA's own input placeholder
had right all along.An operator who followed the documentation got a silently empty list: a
parse failure is counted and logged, never surfaced as a config error, so
the parameter simply stayed hidden with nothing to say why. Every document
now carries the grammar the parser implements, and
TestDocumentedUnIgnorePatternsParsefeeds each documented example through
that parser. Its extractor accepts any quoted list item under an
un_ignore:key rather than only well-formed ones — an extractor that
matched only valid patterns could never have caught this. -
Un-ignore patterns are documented as fleet-wide, which is what they are.
They are stored per central and the visibility editor edits one central's
list at a time, so the storage shape and the editor both read as if the
effect were scoped to that CCU. It is not: the daemon unions every
central's patterns into one shared visibility decider and re-applies the
marks across every device of every central. Nor could it be otherwise as
written — a pattern names a model, a channel and a parameter, none of which
identify a CCU.No behaviour changed. The config field, the store, the concept document and
the REST contract now say so, the visibility editor carries a line above
the central selector explaining that it picks whose list is edited rather
than where it applies, andTestUnIgnorePatternsApplyAcrossTheFleetpins
the scope so the documentation cannot drift from it again. Making the
effect per-central would mean a per-central decider, and it would silently
narrow every rule an operator has already saved — an architectural change,
not a fix. -
MQTT published
ch3where the operator had named the channel. The
discovery name builder derived its entity name from the custom-DP
classification alone and never looked at the channel's name, so every
channel of a multi-primary device was published under thech<N>marker —
including one the operator had labelled on the CCU. REST, the SPA and the
model itself showed the operator's label; MQTT alone showed the marker.The marker exists to keep two channels apart when neither carries a label:
the CCU reports the bare<device>:<no>form for a channel nobody renamed,
and those slugify onto one entity id in Home Assistant. A named channel
already distinguishes itself. The unnamed shapes are unchanged —ch<N>
for one of several primaries,vch<N>for a secondary mirror, and no name
at all for a device's only primary.The builder now asks the model (
device.Channel.CustomDPDisplayName)
instead of re-deriving, which is where the divergence came from and where
the other two name builders in the same package were already looking. Two
further name builders in that file — press events, and the impulse /
device-error entities — had preferred the operator's label all along, which
is what made this one the outlier rather than the convention.What an existing installation sees: affected entities change their
displayed name. Theirentity_iddoes not move — Home Assistant assigns it
once and only changes it on an explicit rename
(homeassistant/helpers/entity_registry.py:async_get_or_createreuses
the registered entry without passingnew_entity_id, the one branch that
moves an id). Automations keep working; a channel discovered for the first
time after the upgrade gets its id from the new name. -
Two of the three silence-code switches were stored and ignored. The
alarm policies view offered a per-source "require code to silence" gate for
mqtt,keypadandremote. The engine drops that requirement for every
pre-authenticated source, so onlymqttcould ever gate anything: an
operator session carries its own second factor, and a keypad or remote press
is authenticated by its slot or binding match and carries no PIN that could
be typed. An operator who enabled either switch was shown a protection that
was not there — andkeypadnever reaches the silence verb at all, since
the intent router uses it for arming and disarming only.The view now offers
mqttalone. Nothing changes for a stored
configuration: an entry for another source was already inert, and stays
accepted, so no existing zone document is rewritten.TestSilenceGatesAreOfferedOnlyWhereTheyBitederives both halves from the
sources — which source strings reach a silence verb anywhere in the daemon,
and which of those the engine can gate — so the view and the engine cannot
drift apart again. The field's own documentation namedsysvaras gateable;
sysvar arms and disarms and never silences. -
The capture archive's redaction was documented as the opposite of what it
does. Two config comments and the REST contract saidanonymisehashes
"device-address-shaped values". It hashes the operator-identifying attributes
— thesubject,user,username,remoteandremote_addrkeys — while
device addresses, channel addresses, parameter names and interface ids stay
in clear text, which is deliberate: an operator reading their own archive
needs them to follow the trace. Thehmlogside said so correctly all along.
An operator who read the contract had it backwards in both directions. -
A wire-parity reference asserted values no device and neither stack
produces.FixedColorLight__SetColorBehaviour.jsonclaimed aiohomematic
sendsCOLOR_BEHAVIOURas the integers 0 / 2 / 3 and that openccu-loom is
equivalent.COLOR_BEHAVIOURis an ENUM whose MIN is a string on every
device carrying it (HmIP-BSL:MIN 'OFF', thirteen entries withOLD_VALUE
at 11 andDO_NOT_CAREat 12), so both stacks send the label — aiohomematic
throughDpSelect's string branch, openccu-loom through
Select.EnumWireValue. The asserted indices belong to no device's list; the
Go fixture beside the comparison declared a six-entry value list invented so
that exactly those indices would fall out. Reference, fixture and generator
now carry what both stacks actually send. -
Two more single-sourcings. The climate profile carried a byte-identical
copy of the Matter centi-degree encoder — same rounding, same two clamps
(32767 is the NULL sentinel and must never be sent as a reading, -27315 is
absolute zero), same comments. It reads
measurement.CelsiusToInt16now, so a boundary corrected in one place cannot
leave the other emitting the old value.And four device profiles resolved a group-scoped profile field by hand, of
which only the light profile read the group-wide declaration block; cover,
valve and switch looked at the per-channel blocks alone. No shipped profile
declares such a field group-wide, so the four agreed by accident — the first
one to do so would have bound on one device family and silently bound nothing
on the other three.custom.ResolveGroupFieldSlotresolves it once, reading
the group-wide block first. -
A raised rollup lag would have deleted unfolded history, silently. The
retention purge drops raw samples once they age out; the hourly fold only
folds samples older thanrollupHourlyLag.config.HistoryRetentionFloor
exists to keep the purge behind the fold — and its own comment said it
"mirrors" the recorder's lag while stating in the same breath that the
mirroring was unenforced: raising the lag alone left the floor too low, and
the purge then deleted raw rows the fold had never seen. Permanently, and
only for operators whose retention happened to sit between the two values.
The recorder derives its lag from the floor now, so the two move together. -
Three more audited leftovers. The
InterfaceRPCServerTypetable
documented itself as driving the callback servers' dispatcher; it drives
nothing — the XML-RPC server routes by URL path and the BIN-RPC one by the
interface id in the envelope. Its only reader already says so about itself,
so the claim invited a reader to change routing in the wrong place.
hmtypes.ValidateHost/IsHosthad no caller outside their own tests and
are removed, with the one comment that referenced them reworded. And
CcuBackend.GetInboxDevicesnow carries the same warning its sibling
GetServiceMessagesdoes: thenamefield is percent-encoded Latin-1 and
must not be unescaped without the transcode, because the unescape alone
corrupts every non-ASCII value irreversibly. -
Firmware download reported success for a request the CCU never received.
CcuBackend.DownloadFirmwarepostedaction=download_firmwareto
/config/cp_maintenance.cgi. That CGI definesfirmware_upload,
firmware_update_confirm/_go/_cancel/_invalid,createBackup,
put_page, the reboot and shutdown actions — and nodownload_firmware. The
unknown action fell through to an HTML page under HTTP 200, which the method
read as success, soPOST /ccu/firmware/downloadanswered 2xx for a download
that never started. Four unit tests pinned the exchange in place; they passed
against a test server that answered anything with 200, which is how it
survived.The CCU's own entry point is the JSON-RPC method
CCU.downloadFirmware, and
it takes no parameters: it fetches the newest firmware for that box's own
serial from eQ-3's update server. A caller-supplied URL has nowhere to go, so
the call now reportsErrUnsupported— which the REST layer already handles
— with the firmware citation at the site. Wiring it to the JSON-RPC method
would change what the operation means ("install the CCU's own latest
firmware" rather than "fetch this URL") and therefore what the REST field is
for; that is a decision about the published surface, not a repair, and is
left open deliberately. -
The active-profile score was computed twice. Both planes resolve which
link profile is active — the SPA's schema over raw JSON constraints, the
store over decoded ones — and each carried its own copy of the specificity
arithmetic. Two scorers can rank two profiles differently, and then the
operator sees one active profile while everything reading the store sees
another.linkprofile.ProfileSpecificityis the one scorer now; it takes the
constraint types, which is all the score ever read, so neither plane has to
convert its whole constraint shape to ask. -
The OnOff cluster's Matter identity was hand-transcribed in four
packages.internal/model/genericand the switch, light and siren profiles
each declared the cluster id, the revision, the LT-gated attribute block and
the command set independently, with the matter.js citation copied along
rather than the values — while a generated single source of the revision
(schema.ClusterRevisions, produced from the matter.js snapshot) sat unused
by all four. They had already drifted: the siren omittedTogglewhile
advertising the FeatureMap that makes it mandatory.internal/north/matter/cluster/onoffcarries it once now, and the revision
is read from the generated snapshot instead of restated, so a
regeneration moves all four projections at once. Bending the snapshot to
revision 7 now fails every projection's parity test; before, each kept its
own6. -
Two schedule constants left in the adapter after their home was created.
The lock-permission duration pair (base 7, factor 31— the CCU's own
"Dauerhaft") was declared a third time in the adapter beside the two the
domain now single-sources, and the climate day terminator was spelled as a
bare1440at four places in the adapter's weekday expander while
schedule.ClimateEndOfDayMinutesowns it. Both read from the domain now, so
the adapter's expander cannot drift fromweekprofile's about where a day
ends. -
The escalation threshold was rebuilt at five call sites.
health.Tracker
flap-damps deliberately: a probe's first failing sample yields DEGRADED, and
only a second consecutive one escalates. Five callers needed the opposite —
they were not sampling but reporting a condition (a client that failed or
stopped, a breaker that opened, a recovery that gave up, an unbound Matter
listener, a disconnected MQTT client) — and forced it by recording twice, the
second time with an invented(escalated)note. That re-encoded the
tracker's rule at every site and left a sample in the health history
describing something that never happened; two of those phantom notes even had
their own i18n keys.Tracker.RecordUnhealthyis the missing entry point. The five sites report
once now, with their own note, and the phantom notes and their translation
keys are gone.Recordkeeps its damping unchanged, which the guard pins
alongside. -
An RF week-program pointer resolved one profile too low. The
subscription that keeps the active profile current called the parameter-less
SyncProfilePointer, which guesses which parameter a value came from by its
Go type: a string is read asWEEK_PROGRAM_POINTER(declared 0-based),
everything else asACTIVE_PROFILE(declared 1-based). An RF pointer
arriving as an integer — its ordinary shape — was therefore read 1-based, so
a device on its third program reported its second. The subscription passes
the parameter it already has in hand now, and the guessing form, left without
a production caller, is deleted with its tests. -
The WebSocket and MCP planes published countdown kinds no client
declares.AlarmCountdown.kindis constrained toexit_delay/
entry_delaybyassets/openapi.yamland typed as those two in the SPA. The
REST handler filtered to them by restating the two tokens; the WS plane
published whatever the engine had stamped and MCP did the same. A zone in
pre-alarm, in trigger, or waiting to auto-rearm therefore sent one of three
further kinds through a field that admits neither. All three planes ask
engine.IsCountdownTimerKindnow — the engine owns which timer kinds are a
user-facing countdown — and the handler's private copy of the two tokens is
gone. -
An installable access-point firmware update stayed hidden.
IsFirmwareUpdateReadyheld{READY_FOR_UPDATE, DO_UPDATE_PENDING, PERFORMING_UPDATE}. The CCU's own install precondition is
READY_FOR_UPDATE || liveServerUpdateState == NEW_FIRMWARE_AVAILABLE || liveServerUpdateState == DELIVER_FIRMWARE_IMAGE, and the live-server states
reach the legacy XML-RPC wire prefixedLIVE_. Both were missing, so
GatedLatestFirmwarereported the device's current version as the latest one
and the update never surfaced. The two in-flight states were in the set and
are not installable — they answerIsFirmwareUpdateInProgress, which
GatedLatestFirmwarenow asks separately so a running install still shows
the version it is heading for. -
Four dead exported pairs removed.
ConvertCPVToHMLevel/
ConvertCPVToHMIPLevel,Notification.SourceNamesand the JSON-RPC
transport'sDownloadFirmwarehad no production caller — the live download
path builds its own request againstcp_maintenance.cgiand never reached
the transport copy. Each had tests, so each looked maintained; a change to
any of them would have gone green while the live path kept its own answer. -
50 further audited rules corrected. 16 changed behaviour, 34 corrected a
stated reason. The ones that reached a device:- A siren asked to repeat 15 times sounded until someone stopped it.
MaxRepetitionswas 18; the VALUES-paramsetREPETITIONSlist ends at
REPETITIONS_014on every device that carries it, and the silent clamp slid
15..18 onto the last slot — which isINFINITE_REPETITIONS. A finite
repetition count became an unbounded alarm. The ceiling is 14 now, one
literal instead of two, and an index the device does not offer is an error
rather than a clamp. - A sound file the device does not offer was written anyway; the player
repeated its previous file and reported success. The index is checked
against the device's own list now. - The text display's row length was 24 characters where HmIP-WRCD declares
16, and 24 was republished to Home Assistant as the input field's maximum. ScheduleProfileNosanswered a static 6 while the published slots come from
the device; the two now answer through one path.- The RF lock's
STATEpolarity was spelled independently on the read path,
the write path and the discovery payload —payload_lockadvertisedtrue
while the service path wrotefalse.
Every number that went into a comment was measured by reading the files, and
the method is stated beside it. That rule exists because a wave-1 comment
carried counts produced bygrepwithout-a, which skips half the device
XMLs as binary. - A siren asked to repeat 15 times sounded until someone stopped it.
-
75 further audited rules corrected across nine packages. 40 changed
behaviour, 35 corrected a stated reason that the CCU sources refute while the
behaviour stood. Each carries a test written before the fix and a bite proof;
each was then read by an independent reviewer against the original finding.
The ones a user can notice:- A
NUMBERsystem variable was typed float or integer depending on whether
its current value happened to contain a decimal point. The CCU has no
integerNUMBERsysvar at all —getall.tclderives the type from
ValueSubType()alone and every creation path pairsistGenericwith
ivtFloat, so the payload carries no int/float distinction to recover. - A custom data point invoked over MQTT was resolved against an arbitrary
central instead of the one the topic named — the same class as the device
lookup fixed above, on the command path. deriveTargetChannelsminted schedule target keys that no
WEEK_PROGRAM_CHANNEL_LOCKSbit can address; they are withheld now rather
than written.- The runtime
delay_new_device_creationtoggle never reached the CUxD
callback handler, so switching it off left CUxD devices held until restart
— the exact failure the function's own doc claims to fix. setInstallModeand firmware updates were accepted on interfaces that
cannot serve them; they consult the interface capability now.- The backup-restore upload used an
actionverb and a form field the
firmware's CGI does not define. - MQTT schedule entries dropped the colour fields the REST DTO carries from
the same domain struct. - Device-error suppression and the model's event classification disagreed
about which parameters are error events, so a suppressed parameter the
classifier drops reached no plane at all.
Where the CCU sources cannot decide a rule, the comment now says so in those
words rather than asserting one — 27 rules are recorded as Tier-1-silent and
5 as not decidable from any available source. - A
-
The siren's OnOff cluster omitted a mandatory command. matter.js gives
Toggleconformance!OFFONLY(on-off.element.ts:39) — mandatory on any
OnOff cluster that does not advertise the OffOnly feature, and this
projection advertises LT and nothing else. It was left out on the grounds
that a siren has no toggle in its wire surface, but conformance asks what
the cluster must accept, not what the device spells; the three sibling OnOff
projections all carry it. A controller that finds a mandatory command
missing can abort the commissioning.Toggleis accepted now and raises the
alarm when it is silent, silences it when it is sounding. -
Two fault codes were documented as something the CCU does not do.
XMLRPCFaultDutyCyclecited a code stringINSUFFICIENT_DUTYCYCLEthat
appears nowhere in the CCU sources; the fault is raised in exactly one place,
RFDevice::UpdateFirmware(src/rfd/RFDevice.cpp:1492), and never on a
value-write path.XMLRPCFaultTransmissionPendingwas described as the CCU
being busy with a previous command — it means the device is unreachable and
the command has been persisted as pending configuration, cleared when the
device next makes contact rather than by waiting. Both comments now say what
the firmware does, andDEVICE_OUT_OF_RANGEis labelled unverified because
that string is not in the sources either. -
A link profile matched on the SPA and not in the store, or the reverse.
"Does this profile match the channel's current values" was implemented twice
— once on the raw JSON the link schema carries, once on the decoded values
the store holds — and the two had drifted in both directions. One compared
floats with!=where the other uses a relative epsilon, so a level that
survived a wire round-trip (0.1 + 0.2) matched in the store and not on the
schema; and the schema parsed numeric strings the store refuses, so"1"
matched there and not in the store. Same channel, same profile, two answers.linkprofile.ProfileMatchesis the single rule now; the schema converts its
constraints and asks. What stays separate is the input preparation, and the
comment says why: the schema reads values straight off the wire, where a
numeric string is a shape the CCU produces, while the store refuses strings
on purpose because reading one as a number in the wrong place risks a silent
mismatch — and whether a LINK paramset can carry one there is not decidable
from the CCU sources.TestProfileMatchingAgreesWithTheStorecompares the
two planes on the same input rather than pinning either. -
"Supports last known level" was decided by a test no device can satisfy.
The rule readMAX > 1.0, on the claim that a device carrying the feature
reports the extended bound. It does not: the firmware declares these level
parametersmax="1.0"and carries the sentinel as a separate SPECIAL member
(<special_value id="OLD_LEVEL" value="1.005"/>), which the paramset
description exports as its own field. Every dimmer in the descriptor corpus
that offers the feature reportsMAX 1.0withSPECIAL {"OLD_LEVEL": 1.005}.Nothing was missing in practice — the classification grants the feature to
every level parameter regardless, which is why the dead branch went
unnoticed. What was wrong was the stated reason, and with it the only route a
parameter outside that classification could take. The declared SPECIAL is
that route now, and it is deliberately not gated on our percent display: a
parameter declaringOLD_LEVELis a level parameter by the device's own
account, whatever our classification made of its name. -
A siren's
durationmeant two different things depending on which surface
carried it. The invoke plane — REST, WebSocket and the MQTT cdp-invoke
topic — read a bare number as milliseconds; the siren's own service handler,
which the per-service MQTT topic reaches, read the same key as seconds. So
{"duration": 30}wroteDURATION_VALUE=0through one path and30through
the other, on the same device, with no error on either to say so. The invoke
branch also accepted neither the canonicalsecondskey that every other
timed operation takes, nor the shared helper — it had invented a third
vocabulary — and the service handler silently dropped a value it could not
parse, so{"duration": "5s"}worked on one plane and vanished on the other.Both planes read through one function now (
siren.ParseOnDuration). A bare
number is seconds, which is what Home Assistant's MQTT siren sends and what
the service handler always did;secondsandduration_secondsare accepted
alongside it, and an unreadable value fails the command instead of falling
through to the device default.TestSirenDurationMeansTheSameOnBothPlanes
asserts the two planes agree rather than pinning a number. -
A burglar alarm was reported to Matter controllers as a fire.
SMOKE_DETECTOR_ALARM_STATUScarriesINTRUSION_ALARMwhen the installation
drives a smoke detector as a siren for an intrusion alarm — a command the
domain sent, not a detection the device made. The safety classifier and the
derivedSMOKE_ALARMsensor both exclude it, andpkg/hmenumspells out why
in the comment above the list. The Matter projection reversed that: it mapped
the label toSmokeState = CriticalandExpressedState = SmokeAlarm, which
Matter defines as the device's smoke sensor triggering
(smoke-co-alarm.d.ts:150,:566-574). Anyone whose alarm sounded a smoke
detector got a fire notification from Apple Home or Google Home.Which labels mean smoke is no longer decided on the Matter plane at all: it
is read fromhmenum.SmokeDetectorAlarmStatusSmokeLabels, the one place the
domain answers that question, so the two planes cannot drift apart again.
Only the severity of a genuine smoke label stays local.
TestSmokeMatterPlaneAgreesWithTheDomainsSmokeLabelsasserts the agreement
rather than a value, which is what neither side's tests did before — each was
internally consistent, and that is why the reversal survived. -
Eleven more rules grounded against the CCU firmware. Each was carried by
a comment, a bare literal or the Python port; each is now either corrected or
explicitly labelled unverified at the site where a reader will look for it.Behaviour changed where the firmware contradicted us:
- The HM-CC-VG-1 virtual-heating-group patch repaired the wrong thing. The
CCU serves that group'sSET_TEMPERATUREbounds as strings, which is the
real defect; the patch instead overwrote the range with4.5/30.5— a
member thermostat's bounds substituted for the group's declared
5.0/30.0, widening the accepted setpoint at both ends. It now coerces
the type and leaves the CCU's own numbers alone, falling back to the
declared range only when none arrives. - The
HmIP-DRDI3MASTER channel list gained channels 4, 8 and 12, which the
descriptor declares and which therefore never became data points. - The
ENERGY_COUNTERunit patch reached one of the nine models that share
the declaration; it now covers all nine. - An empty string no longer coerces to
falseon the REST write boundary.
The CCU's own value library rejects it in both textual boolean readers, so
accepting it turned an input the device would have refused into a confirmed
switch-off. FixRSSI's bands were widened to the values the two transports actually
produce, and the unit cleanup matches whole units instead of substrings —
m3/Imp.used to be rewritten tom³.- The
HmIP-RGBWSATURATIONoperations patch was removed: it rested on a
claim with no reproducible condition.
Elsewhere the behaviour was right and only the stated reason was false — the
<i4>and<double>write formatting (the CCU emits<i4>itself, and
fault -5 means "Unknown parameter"), the duty-cycle and transmission-pending
retry windows (fault -8 is raised only on the firmware-update path, -10 is
HmIP-only and means the command is already queued), and the motion-reset and
alarm-sensor tables. Those comments now say what is measured and what is not.The paramset cache schema version is bumped, without which an installed
daemon would keep serving the old bounds from SQLite. - The HM-CC-VG-1 virtual-heating-group patch repaired the wrong thing. The
-
An unpaired device kept its measurement history and its recording
overrides forever.MeasurementStore.DeleteDeviceand
RecordingOverrideStore.DeleteDeviceeach carried a doc comment saying they
run on device-remove / unpair, and neither had a production caller: the
removal path wires evictors for the values cache, the MASTER cache, the
paramsets and the channel flags, and none for these two. History across all
three tiers and the operator's per-channel recording decisions survived
unpairing indefinitely — and because the CCU reuses addresses when hardware
is swapped, a replacement paired into the same address inherited the previous
device's series and its recording settings. That is exactly the resurfacing
the multi-tier delete was written to prevent.WireMeasurementEvictionsupplies the missing seam, registered per central
through the same observer the other evictors use so a CCU adopted at runtime
is covered too. A whole-model teardown is excluded, as elsewhere.
TestRemovedDevicePurgesItsMeasurementHistoryasserts the effect in the
database through the real constructor, and both comments now name the seam
instead of claiming a caller. -
A device lookup answered from the wrong CCU. Device addresses are unique
within one CCU and repeat verbatim across them — the virtual-remote roots
(BidCoS-RF,BidCos-Wir,HmIP-RCV-1) and theINT000*group devices
carry the identical address on every one.central.Registry.List()is
name-sorted, so every address-keyed lookup that walked it answered from the
alphabetically first central. On the MQTT plane that published another
installation's model, name, channel type and availability under this
central's topic, and drove the device-lifecycle event the WebSocket plane and
the Matter reachability forward consume. On the write path it delivered a
command to a different CCU's hardware.The event bridge carries the central at every one of those call sites and
simply dropped it; its lookups take it now. The address-keyed REST facade
(DevicesAdapter.Device,DataPointWriterAdapter.SetValue) has no central
to take — its callers do not carry one — so an address several centrals share
is refused instead of resolved to an arbitrary CCU, and the write error names
the candidates. Single-CCU installations are unaffected, as is every address
only one central holds. -
An optical alarm flashed an acknowledgement blink instead of an alarm.
With no pattern configured, the driver took the last entry of the device's
OPTICAL_ALARM_SELECTIONlist. On every HmIP-ASIR variant that entry is
CONFIRMATION_SIGNAL_2— "long short short", the third of three one-shot
acknowledgement blinks. The device's four sustained alarm patterns are the
*_REPEATINGentries; the list order is fixed and identical across ASIR,
ASIR-2 and ASIR-O, so the positional rule resolved deterministically to the
entry furthest from what an alarm needs. Nothing failed: the device accepted
it,OPTICAL_ALARM_ACTIVEwent true and the watchdog was satisfied, so an
optical-only activation or test fire showed a brief blink and nothing else.The pattern now comes from a property the device states rather than from a
position:Siren.AlarmOpticalLabelpicks the first selection the device
itself names as repeating, mirroringDisableAcousticLabelon the acoustic
half — whose own comment already warned against re-deriving the label
positionally. A device offering no repeating pattern gets no optical
selection written at all and keeps the one it holds, which beats sending a
blink that would satisfy the watchdog. -
A schedule could ask for an astro offset five times wider than any device
accepts.astro_offset_minuteswas validated against a constant ±720, and
anything inside that reached the wire. Every model in the descriptor corpus
declaresASTRO_OFFSETasINTEGER MIN -128 MAX 127, and the CCU's own
weekly-program editor carries no constant at all — it reads
ASTRO_OFFSET_MIN/ASTRO_OFFSET_MAXout of the paramset description and
clamps its input to them. An offset between 128 and 720 was written, and the
device either rejected theputParamsetor stored a clipped switching time.The bound is now the channel's declared range, resolved from the live model
the same way the target-channel bits already are. An out-of-range offset is
refused rather than clipped: a clipped offset is a different switching time
from the one the operator asked for. A channel whose descriptor has not been
loaded keeps the previous ±720 bound, which describes nothing about a device
and is documented as such.TestSaveRejectsAnAstroOffsetTheChannelDoesNotDeclare
pins the seam through the production save path rather than the helper. -
The LED flash duration sent four labels the device does not have, and
turned a ten-second flash into a permanent one.ON_TIME_LISTwas mapped
through a fixed table of our own that assumed a regular 100 ms ladder up to
5 s. A device declares 16 entries and skips three of ours — there is no
600MS, no800MS, no900MSand no4S— while continuing past 5 s with
7S,10S,20S,40Sand60S
(GeneralStateParameterFactory#createOnTimeListParameter). Requests in
551-650, 751-850, 851-950 and 3501-4500 ms produced a label the device does
not declare, which the CCU's enum conversion rejects — failing the whole
atomic turn-onput_paramset, not just the duration. And because everything
above 5 s collapsed toPERMANENTLY_ON, an ordinary ten-second flash left
the LED on for good.The entry is chosen from the device's own value list now, by parsing each
declared label, so no table of ours can disagree with the device again; only
a duration longer than the longest declared entry becomesPERMANENTLY_ON. -
Four of the eight fixed colours were reported as the wrong colour.
FixedColorLight.Color()cast the raw COLOR index straight to a
FixedColorordinal. The CCU orders its COLOR value list by the RGB bit
pattern — bit 0 blue, bit 1 green, bit 2 red — giving
BLACK, BLUE, GREEN, TURQUOISE, RED, PURPLE, YELLOW, WHITE, which agrees
with our enumeration on four slots and swaps the other four: what the device
called blue we reported as red, its turquoise as yellow, its red as blue, its
yellow as turquoise. The ordinal is what arrives on the wire, because the
CCU substitutes an ENUM's index for its label
(etc/config_templates/crRFD.conf:47,
Legacy.Parameter.ReplaceEnumValueWithOrdinal=true). Affected HmIP-BSL,
HmIP-MP3P channels 2-5 and HmIPW-WGC, inColorName(), the MQTT
fixed_colorfield, and the optimistic update after a write, which seeded
the store with an index the CCU would never echo.The slot is resolved through the device's own VALUE_LIST now, so the rule no
longer depends on any ordering assumption, and a label outside the eight
known colours — the writable list also carriesRANDOM,OLD_VALUEand
DO_NOT_CARE— reports as unobserved instead of becoming a nonsense
ordinal. The write path was already correct: it writes the label, and its
own comment stated the CCU's order whileColor()contradicted it.Every test fixture in the repository declared the value list in our order
rather than the CCU's, which is why nothing caught this: the tests fed in the
convention they confirmed. The fixtures now carry the device's order, and
TestFixedColorLightReadsSlotByLabelNotByIndexpins each of the eight slots.