Skip to content

v0.10.0 (Remotes Have Been Buffed) - #103

Merged
DAB-LABS merged 15 commits into
mainfrom
trigger-styling
Aug 18, 2026
Merged

v0.10.0 (Remotes Have Been Buffed)#103
DAB-LABS merged 15 commits into
mainfrom
trigger-styling

Conversation

@DAB-LABS

Copy link
Copy Markdown
Owner

Release 0.10.0, Remotes Have Been Buffed. Branch trigger-styling onto main, squash merge.

What is in it: Devices and Remotes as two sections; USE on every set of codes to make a Device or a Remote; Make a Remote from a Device and Make a Device from a Remote; each Remote is its own Home Assistant device with device triggers; pinning a Remote to Devices with echo defense, loop breaker and coalescing; air-conditioner Remotes with the STATE MATRIX card in listen mode, LAST HEARD, state triggers and the "State heard" device trigger; the climate preset star; the add tile with file drop; the Mirror emitter filter; settings and header alignment; ten languages.

Behind it: one identity form for wig cells (wire form, backfilled); a persisted cell index; receiver-tolerant matching for file-sourced codes (normalized fingerprint as a lowest tier for cells, wig, Clipper and Plucker triggers, and wig-adopted commands; echo ticket carries it too); trigger dedup anchored at 300 ms keyed on trigger id with allow and suppress log lines; Clipper and Plucker triggers stamped clip and plucked and treated as file-sourced, with a load-time backfill.

Docs: README, CHANGELOG 0.10.0 entry, info.md, llms.txt, nine translated README stamps, manifest and panel footer 0.10.0 with the bundle rebuilt.

Verification: full suites green (3.12: 2998 passed, 34 skipped; 3.14: 3061 passed, 1 skipped), ruff clean; a real-air regression bench on the release candidate with an ESPHome transmitter and an Athom receiver, thirty presses through a pinned pair with zero echo leaks, three restarts, two receivers one press, zero HAIR warnings; eight-hour soak running.

No community issue closed by this release. PR #80 (Spanish README) is unrelated and stays open.

…ngs rework, pin storage, trigger dialog aims at any Remote

Signpost 3 replaces the old ADOPT flow with a USE fork across all four
surfaces (Sniffer, Clipper, Plucker, Mirror): picking a signal now
offers USE (bind it straight to an existing device or trigger) beside
the prior create-new path, with ghost tiles standing in for the old
Add buttons and supporting file-drop creation on both Add dialogs.
Per-kind source tabs (Manual/Closet/Sniffer/Clipper/Plucker/Remotes)
are wired live end to end on both the Add Device and Add Trigger
Remote dialogs, so every source surfaces its own picker instead of a
generic list.

The device settings dialog was reworked (header collapse-to-active
chips, Emitters:/Receivers: header chips, duplicate dialog's footer
receiver-chip picker, the mirror-door mint buttons wired live with a
backend-issued pin prompt on first mint) alongside eager HA device
registration for named remotes as soon as they're created, USE-as-
Remote creation from every source, and combined/enabled/disabled
trigger counts on remote cards. Pin storage lands gated behind
PINNING_UI_ENABLED -- built and tested dark, no user-facing surface
turned on yet.

The trigger create dialog now aims at any Remote: a REMOTE picker
(HAIR Triggers drawer or any named TriggerRemote, "+ New Remote" last)
replaces the old drawer-only assumption, edit mode shows the owning
remote as a fixed label, and the receiver picker only renders when the
effective target is the drawer -- a named remote's own receiver_scope
covers it instead. The trigger popover now names each row's owning
remote.

Also folds in the signpost's punch-list pass: dropped noise-filtering
of non-sniffed unknown devices, fixed two stray "adopt" locale
strings, remote-card reactivity and the redundant trigger-count line
(overview cards and the HAIR Triggers drawer's own), the panel header
wordmark removal, a locale first-paint race on language switch, the
Add-dialog form-clarity rework (SOURCE/DEVICE/EMITTERS sections, live
Name-required treatment, static Create button, top-anchored geometry),
and the double-scrollbar fix in the SOURCE picker that followed it.
Derivation, retransmit, echo defense (anchored single-use ticket,
post-send guard, loop breaker, coalescing), pin surfaces, header pin
layout, punch items 9-13.
A matrix Remote is the hear side of the lattice a matrix Device already
sends from. It carries its own copy of the wig's climate matrix, watches
the air for those states, and reports what it heard: a bloom on the cell
as the frame lands, a gold rest ring that stays on it, a slim readout on
the card, and a LAST HEARD row underneath. All four read from one stored
fact on the remote, so a reload comes back already filled instead of
blank until the next press.

Hearing and browsing stay separate on purpose. Browsing is what the user
clicked and fills blue; hearing is what came off the handset and rings
gold. Neither disturbs the other, so the card can show what just
happened while the user reads somewhere else in the lattice. Mode, fan
and swing rings are dimension level; the temperature ring is scoped to
its branch, because the same tile is a different command under a
different combination.

Any cell can become a trigger, through three doors that share one
dialog: the LAST HEARD row, the action bar on a cell that was heard, and
the action bar on a cell that never has been. The third is only possible
because the card renders live from the first paint, so a brand new
Remote can be given its first trigger straight off the lattice. The
minted row wears the cold blue STATE chip, and fires, pins, renames and
deletes like any other trigger.

Pinning meets the matrix at the coordinates. When a matrix Remote hears
a cell and is pinned to a matrix Device, the same coordinates resolve in
that device's own lattice and go out through the existing retransmit
dispatcher, with its breaker, its coalescing and its echo ticket
unchanged. Flat triggers on the same remote keep the path they always
had.

ONE IDENTITY FORM, and it is the wire form. Everything that matches a
frame now hashes the canonical Pronto: the listener's cell index, the
triggers minted off a cell, and the commands adopted from a wig. A state
heard off the air and the same state read out of a file resolve to the
same identity, which is what lets a wig adopted device recognise its own
undecoded commands.

State heard is its own event and its own device trigger row, one per
matrix Remote, filtered on the remote id so renaming never strands an
automation. Automations template on the decoded dimensions directly.

The card itself was extracted rather than copied: one component, two
moods, consumed by the device page in send mode and the remote page in
hear mode. The trigger row's anatomy moved to shared styles for the same
reason, since the LAST HEARD row wears it too.

Punch list items 14 through 23 ride along. The ghost tile becomes
legible and then steps back into a faded grey with the full format list
restored. The Mirror's filter header becomes four fixed pills whatever
the size of the house, with the emitter list behind a dropdown. The
settings dialogs get a seam at every section boundary on every device
kind and one footer shape, Delete apart from the constructive pair. The
trigger dialog's Create button joins every other Create in green. A
mirror door pin now shows on both objects without a refresh. The two
detail headers align their action glyphs by giving both buttons the same
square box. And a seeded branch no longer pretends to be a selection.

Ten locales for every new string. Green in both venvs, ruff, tsc, build,
and locale parity.
A code HAIR knows only from a file does not match its own capture over a
real air path. Twenty presses of one Mitsubishi cell through a
microsecond-accurate ESP32 transmitter produced twenty distinct byte
hashes and not one of them was the code that was sent; short flat codes
hash stably but on a transmitter-specific wrong value, and ESPHome and
Broadlink each land on their own. The bench_rx injector reproduces the
file identity exactly, which is the control: the miss is the air path,
not the identity code. Marks come back short and spaces come back long
(mean mark ratio 0.83 to 0.95), the photodiode AGC signature, with
per-edge excursions from 0.71 to 1.27 -- more than enough to flip an
individual short/long decision.

THE TIER RULE. Every capture and every file-sourced record gets a
NORMALIZED FINGERPRINT: canonical-strip the trailing gap, reduce to the
first frame, divide marks by the code's own median mark and spaces by
its own median space, split each into two levels at their widest
relative gap (1.20x or more, so a run with no real separation stays one
level), and hash the level sequence with the edge count. It is the
LOWEST of four tiers, consulted only after decoded, (fingerprint, byte
hash) and byte hash have all missed, and only when the capture decoded
as nothing. A value claimed by two genuinely different waveforms is
dropped rather than won by whichever record was indexed last (13 of
2,354 across the bench closet), because naming the wrong state is worse
than naming none.

The levels are FOUND, not thresholded: applying the existing short/long
threshold to the normalized values collapses all sixteen ACER RC-17DE0
codes onto one value, where splitting at each code's own widest gap
gives 16 of 16 and 34 of 34 on a 64-cell Mitsubishi lattice.

ONE STRIP, SHARED. canonical_edges drops trailing zeros then a trailing
space, leaving every edge list ending on a mark. The same code was
measured presenting as 67 edges from the file path and 68 from the
capture path, which is enough on its own to break any identity that
counts edges. canonical_pronto routes through it too.

WHOSE BYTES NEVER CAME THROUGH A RECEIVER. Receiver-learned records
never gain the tier: their existing tiers already work, and handing them
this one would re-collapse the sibling buttons the byte hash exists to
separate (measured: 53 buttons of one Sony wig share a single normalized
value, and every one of them decodes).

  Triggers, by the origin each door actually writes:
    closet   ws_wig_make_remote                     file-sourced
    matrix   the panel, for Track M's "+ Trigger"   file-sourced
    device   ws_device_make_remote                  ask the source command
    remote   catalog promotes, sniffed or pasted    no
    manual   the drawer's own dialog                no
  Plus: any undecoded row on a Remote that is a lattice, came from the
  closet, or carries a wig id -- which is what covers a trigger saved
  from a matrix card, since that door stamps the dialog's origin rather
  than the lattice's.

  Commands: source in {database, imported, matrix}, a plucked command
  name, or a porthole row; OR, as the upgrade path for data written
  before the doors stamped anything, the owning device carries
  source_wig_id / source_file / origin "closet" AND the command has no
  decoded identity. That last guard matters: a decoded row is already
  answered by tier 1 and has no use for this one.

  Doors fixed to stamp CommandSource.IMPORTED: Adopt Device and the
  supersession top-up it shares, the Remote-to-Device mint when the
  source trigger is itself file-sourced, and Clipper/Plucker assigns.
  IMPORTED rather than a new enum member: it already means "came from a
  file rather than off the air" and nothing renders it. One trigger door
  was also lying -- duplicating a Remote stamped every copied trigger
  "manual" -- and now copies the source trigger's origin.

  Known gap: a Clipper paste or a Plucker pull promoted to a Remote
  arrives as origin "remote", indistinguishable from a Sniffer promote.
  Widening that vocabulary is frontend work and was declined for this
  track, so those two miss the tier.

WHERE THE TIER IS CONSULTED. The matrix listener's CellIndex (fourth
map; the stored index goes to hair-cell-index/2 so every lattice
rebuilds once), storage.get_triggers_for_signal, storage.match_command,
and pin_bindings.DeviceCommandIndex. Captures compute the value once, in
signal_monitor.normalize, beside the byte hash. One DEBUG line names the
tier that answered.

THE ECHO TICKET. The send expectation claimed on decoded identity and
the short/long fingerprint, both computed from the file, so a clean echo
of a file-sourced code matched neither: on the bench, setting a pinned
matrix Device's dial put three frames back through the receiver, the
fuzzy garble guard swallowed two, and the third was heard as a handset
press and fired the state trigger, twice reproducibly. The ticket now
carries norm_fp as one more fallback, armed and spent like the others
and offered only to a capture nothing could decode. This supersedes the
plan's section 4.5.

ONE PRESS IS ONE EVENT. An AC state code is two complete frames, which a
real receiver delivers 103 to 148 ms apart, so the 100 ms window fired
hair_state_heard twice per press and saved the store twice; the state
trigger fired twice for the same reason. MATRIX_STATE_DEDUP_WINDOW_S is
400 ms, keyed on (remote, cell) so a deliberate change of state inside
the window is still two events, and the trigger dedup takes the same
number only for rows that are file-sourced AND multi-frame -- a Sony
keypad's 45 ms repeat frames and single-frame wig rows keep the 100 ms
window they were sized for.

FIXTURES, in tests/fixtures/air-path/, gzipped because they ship to
every install: 51 real captures of four codes from two transmitters with
the identity HAIR stored for each, the four code lists themselves, the
34 distinct codes of the Mitsubishi lattice, and the 16 signals of the
ACER wig. The tests assert the run's own numbers -- 34 of 35 air
captures land on the value computed from the file, the one miss being a
Broadlink send of C2, named rather than hidden.

Bench, on the deployed build: 50 presses through the ESPHome transmitter
gave 10 of 10 for C2 and OFF, 11 events for 10 C1 presses (one press
split its frames by 330 ms, which is what took the window to 400), 20
trigger fires for 10 ACER Power presses across the two Remotes that
carry that code, and nothing at all for a code held out of the lattice.
Thirty pinned retransmits, thirty echo suppressions, zero leaks. Every
match read "on the normalized tier"; nothing matched on any tier above.

No frontend.
One tap is one fire, including on a handset that repeats the whole frame
while the button is down.

The regression bench against 0164e53 measured a Samsung32 handset
repeating its full frame every 102 to 119 ms on an ordinary tap. The
trigger path was borrowing MULTI_RECEIVER_DEDUP_WINDOW_S, which is 100 ms
and was sized against Sony SIRC repeats at 45 ms. A full-frame repeater
therefore landed just outside the window and a single tap fired two to
four times; which it was came down to about ten milliseconds of jitter,
so the fault was intermittent. Volume Up settled it inside one press:
three captures at 65 ms and 106 ms spacing, the 65 ms gap suppressed and
the 106 ms gap not.

This is not a regression from any recent track. The constant is unchanged
since v0.5.8 and was identical in all four builds of the bisect, which is
why all four doubled.

What changed

- New TRIGGER_FIRE_DEDUP_WINDOW_S in const.py, 250 ms, with the bench
  measurements and the sizing argument in the comment. Above the widest
  full-frame repeat measured (119 ms) with room for receiver jitter,
  below a deliberate release-and-re-press.
- The window is ANCHORED at the last capture allowed through, not slid
  forward by suppressed ones. Sliding made a held button invisible: while
  the frames kept arriving the window never expired, so a two-second hold
  was one fire and nothing more. Anchored, it expires on schedule and the
  next repeat re-fires -- against a 108 ms repeat that is a fire on
  roughly every third frame, about three a second, the cadence the
  appliance itself repeats at.
- MULTI_RECEIVER_DEDUP_WINDOW_S is left alone at 100 ms and keeps its
  remaining job, grouping observations of one press for the diagnostic
  log line. The trigger path stops borrowing it.
- _maybe_prune_recent_fires now sizes its cutoff on the widest window a
  row can be given rather than the narrowest, so an entry cannot be
  pruned while its window is still live.

How the two windows compose

One mechanism, the window chosen per row, the wider one winning where it
applies. A row that is both file-sourced and multi-frame keeps
MATRIX_STATE_DEDUP_WINDOW_S at 400 ms: the two frames of an AC state code
arrive 103 to 148 ms apart through a real receiver and such a code has no
repeat behaviour to preserve. Every other row gets the new 250 ms. The
matrix listener already stamped on emit only, so anchoring the trigger
path also brings the two into line.

The dedup key is unchanged: trigger id alone, as it has been since
v0.5.8. The module docstring still described it as (trigger, fingerprint),
which went stale when the key changed; corrected here.

Behaviour change worth naming: two taps closer together than 250 ms now
count as one press. That is the deliberate cost of covering full-frame
repeaters, and the affected test moved from 150 ms to 300 ms to say so.

Tests

- one tap of a full-frame repeater (0 / 108 / 216 ms) is one fire
- a held button re-fires on the anchored ramp (0 / 108 / 216 / 324 / 432
  gives two)
- two taps 300 ms apart are two fires
- Sony's five frames 45 ms apart are still one fire
- the multi-receiver fold still holds, here and in the harder
  differing-byte-hash case in test_bytehash_identity
- the wider file-sourced multi-frame window still wins where it applies
- the tolerant track's two-frame press test is untouched and passes

Both CI-parity venvs green: 3.12 2976 passed / 34 skipped, 3.14 3039
passed / 1 skipped. ruff clean. No frontend.
The regression bench proved this one over air. Three ESPHome sends of an
Arris Power Pronto were heard cleanly by the Athom and fired nothing:

  trigger Power | origin remote
  file_sourced_trigger -> False
  air : fp 49b80b5e33c2cec8  bh 188018037eea2072  norm_fp cec7ced5e9fb2fba
  file: fp 49b80b5e33c2cec8  bh 4457e79c2bdf1eca  norm_fp cec7ced5e9fb2fba
  air, no norm_fp   -> []
  air, with norm_fp -> []
  file identity     -> [Power]

Three things line up there. The S/L fingerprint matched exactly, but the
bare-fingerprint tier is withheld from hash-bearing rows (the deliberate
v0.5.8 anti-false-match rule). The byte hash moved, so both hash tiers
missed. The normalized fingerprint was identical, so the tolerant tier
would have matched -- and was never offered, because the row said
origin=remote.

It said that because the promote door stamped every catalog row the same
way, whichever of the three USE-as-a-Remote tabs made it. The Arris row
came from a Clipper paste (its Sniffer group carries source=manual), and
a pasted code is a file by definition: it never came off a receiver.

What changed

- Two new IRTrigger.origin values, clip and plucked, documented in the
  models.py field comment alongside the existing five.
- identity.FILE_SOURCED_TRIGGER_ORIGINS, one frozenset that is now the
  single home for the vocabulary. file_sourced_trigger reads it.
- signal_monitor.CATALOG_SOURCE_TRIGGER_ORIGIN maps UnknownDevice.source
  to a trigger origin: sniffed -> remote, manual -> clip, plucked ->
  plucked. echo never reaches the door and falls through to the default.
- A load-time backfill, HAIRStore.backfill_catalog_trigger_origins, run
  from async_setup_entry rather than async_load because it is the only
  backfill needing both stores: the trigger names its Remote, and only
  the signal store knows which catalog row that Remote was promoted from
  (UnknownDevice.promoted_to_remote). One save, folded like the others.
  Deliberately narrow -- it rewrites origin=remote and nothing else, and
  only when the linked catalog row is manual or plucked.

The mint doors, and what each writes

- signal_monitor.promote_to_remote -- the USE-as-a-Remote fork for all
  three catalog tabs. The one door this commit changes.
- websocket_api.ws_wig_make_remote -- closet. Unchanged.
- websocket_api.ws_device_make_remote -- device, the source command
  decides. Unchanged.
- websocket_api.ws_duplicate_trigger_remote -- copies the source
  trigger origin, so clip and plucked propagate for free. Unchanged.
- websocket_api.ws_create_trigger -- writes the origin the panel sends
  (matrix for the lattice doors). Unchanged: a trigger created through
  this door on a Clipper-promoted Remote lands as remote and the
  backfill repairs it on the next load. Noted rather than papered over.

No frontend. The panel indexes ORIGIN_COLORS by dialog TAB, not by a
stored trigger origin, and the only consumer of trigger.origin is
ir-trigger-row.ts testing equality against matrix -- so a clip row simply
does not paint a STATE chip, which is correct. The origin colour question
stays declined.

Tests

New test_clip_plucked_origins.py, 18 cases:
- each source maps to the right origin, and an unknown source falls back
  to remote
- the predicate is true for clip and plucked, false for remote, and the
  vocabulary is the ruled one
- the backfill restamps a Clipper and a Plucker promote, leaves a real
  sniffed promote alone, leaves other origins alone, is idempotent, and
  does nothing for an unpromoted row
- the bench fixture, tests/fixtures/air-path/arris-power-clip.json,
  carrying the pasted Pronto and the code the Athom actually handed back:
  the air form moves the byte hash and holds the normalized fingerprint;
  as shipped (origin=remote) it matches nothing; stamped clip it matches
  on the normalized tier; and stripped of norm_fp it still matches
  nothing, so the origin decides whether the tier is offered and the tier
  is what carries it.

test_init.py stubs the new backfill on its mocked store; those three
cases are about setup wiring.

Both CI-parity venvs green: 3.12 2994 passed / 34 skipped, 3.14 3057
passed / 1 skipped. ruff clean.
Owner ruling, second bench pass, measured rather than argued.

At 250 ms the bench ran three deliberate Volume Up taps whose frame
trains measured 219, 213 and 215 ms end to end. All three folded to a
single fire, but with only about 30 ms of margin. A Volume Down tap in
the same session emitted three frames spanning 254 ms and fired twice --
six milliseconds outside the window. 300 ms clears the whole measured
spread of a deliberate tap and still sits far below a release and a
re-press.

The second reason is consistency. 300 ms is SIGNAL_REPEAT_SUPPRESS_MS,
the Sniffer window. The fault this constant exists to fix was the two
paths disagreeing about what one press was -- the Sniffer counted a tap
once while the trigger fired twice on the same frames. They now agree.
Three gates remain (Sniffer 300, trigger 300, matrix 400) and a unified
capture stage is expected to supersede all three; until then two of the
three are the same number for a stated reason.

Cost to a held button: the cadence is whenever the first frame past the
window arrives, so it follows the handset repeat rate. At the measured
100-110 ms that is a fire every 324 to 400 ms, between 2.5 and 3 a
second. The bench measured seven fires across a real two-second hold at
250 ms, 318 ms apart. A held volume button steps a little slower.

Tests

- the ramp test now spells the cadence out: six frames at 108 ms fire on
  frames 1 and 4, and the docstring records that the interval depends on
  the handset rate rather than on the window alone
- the bench worst case is pinned directly: three frames at 0 / 163 / 91
  (the 254 ms tap that failed at 250) is one fire
- the boundary is probed a millisecond either side rather than exactly
  on the window; an exact-boundary assertion tests IEEE rounding on a
  monotonic clock, not the rule
- the window assertion also pins it equal to SIGNAL_REPEAT_SUPPRESS_MS,
  so the two cannot drift apart silently
- every spacing sized against 250 moved: deliberate-double-tap to 400 ms
  in test_bytehash_identity, the location-aware distinct-press gap to
  400 ms, the pin-retransmit min_hits sleep to 400 ms, and the
  file-sourced composition probe to 350 ms (through 300, inside 400)

Both CI-parity venvs green: 3.12 2996 passed / 34 skipped, 3.14 3059
passed / 1 skipped. ruff clean.
The bench saw two fires 3 ms apart on one Volume Up tap (2026-08-18) and
the logs could not say why, because the suppression path was silent: you
could not tell a capture gated out here from one that never reached this
point at all. Investigation first, then the change.

What was found, from the stored evidence

1. Both captures of that pair were on ONE receiver. Every receiver in
   that remote scope was queried for the window 20:20:45.0 to 45.5: only
   infrared.athom_rx recorded anything (45.485, 45.800, 45.803); the
   other six were static, their last states hours old or unknown. So the
   pair is not the multi-receiver fold failing.

2. The stamp is recorded at the moment of the check, before the fire
   path, with no await between: on_signal_captured is synchronous, and
   the check and the record are adjacent statements. Check-and-record
   cannot be interleaved on the event loop, so the race the pair would
   imply does not exist in this method. There IS an await upstream --
   _process_parsed_signal awaits _match_echo before calling in -- which
   can reorder captures relative to their arrival, but it cannot let two
   of them through this gate.

   A comment now says so at the record, because the property is load
   bearing and invisible: making this method async, or moving the record
   below _fire_trigger, would open exactly the window the pair implies.

4. The 45.485 capture left no trace anywhere: no fire, no Sniffer row,
   no Mirror row, no log line at DEBUG. Its Sniffer group (the Samsung
   S/L fingerprint df9d5b3f3fab077d) is on the dismissed list as of
   earlier the same evening, and the dismiss check runs AFTER the
   trigger check, so dismissal explains the missing catalog row but not
   a missing fire. The consistent reading of the surviving evidence is a
   Samsung frame that decoded to something other than Volume Up: no
   trigger matched, and the dismissed group swallowed it. Stated as the
   reading it is; the new log line settles it next time either way.

The 3 ms pair itself is NOT explained. What is established is that it is
not the fold, not a check-and-record race, and not the window logic --
the window was verified directly on the deployed build with the
injector: two injections 50 ms apart gave one fire, 320 ms apart gave
two, and five at 100 ms gave two.

What changed

- A DEBUG line at the suppression continue, naming the trigger, the
  receiver, the measured gap in ms and the window in ms. This is the
  whole point: the next question of this shape is a log read.
- The record site carries the comment about why it must stay where it
  is.
- 300 ms stands (owner ruling, third pass). Recorded in the constant
  comment along with why: a later tap whose frames spanned 318 ms fired
  twice, which is the rule working. The boundary moves under the thumb
  wherever it is put, and 400 would collide with
  MATRIX_STATE_DEDUP_WINDOW_S.

Tests

- two receivers, same identity, 3 ms apart, as two asyncio tasks: one
  fire. The guard for the property in finding 2.
- the same receiver twice, 3 ms apart, as two tasks: one fire. This is
  the bench actual shape per finding 1, and the one worth guarding.

Both CI-parity venvs green: 3.12 2998 passed / 34 skipped, 3.14 3061
passed / 1 skipped. ruff clean.
The suppression line added in 0e45b88 could not settle the bench
question it was added for. A capture that PASSED the dedup gate still
left no trace, so "allowed" and "never reached the gate" read
identically in the log, and the two Volume Up fires 3 ms apart at
2026-08-17 20:20:45 stayed ambiguous: either the second capture saw no
prior stamp, or it saw a gap wide enough to clear the window.

The allow line closes that. It names the receiver, reports the gap, and
says whether a stamp existed at all. A missing stamp is reported as
missing rather than printed as a number, because with no prior fire the
stored value is 0.0 and the gap is time since the process started,
which would read as a plausible but meaningless interval.

The fire line now names the capturing receiver too, as the suppression
and allow lines already do. Without it the receiver behind either half
of a double fire had to be reconstructed from recorder history.

Instrumentation only. No behaviour change and no test change: the dedup
key, the window, the anchoring and the fire path are untouched.
Turn signals into a device: Devices vs Remotes, USE, one add dialog, ghost tile, reciprocal Make a Remote/Make a Device.

Capture a remote: USE replaces old assign-only flow, count dot on repeat use.

Quick start: step 3 updated to USE / Make a Device.

Set up an air conditioner: STATE MATRIX card language, preset star, new Listen to the wall remote passage for AC Remotes.

Import codes: USE button, one line on file-sourced Remotes recognizing real handsets.

Set up triggers: trigger belongs to a Remote, Pin a Remote to a Device.

The Devices tab: DEVICES and REMOTES sections.

Use the Mirror: Mirror filter row (Search, Not heard, Emitter dropdown).
…-tile wig filing

Capture a remote: a new remote needs at least three presses before it shows as a card.

Set up an air conditioner: reworded the import-limits paragraph, added the comb-flagged-cell command-row behavior.

Turn signals into a device: dropping onto the ghost tile also files a wig in the Closet.

Import codes: cross-referenced the ghost-tile shortcut.
Restore the two screenshots the 0.10.0 draft dropped (devices overview, add-a-device dialog) alongside the placeholders for the new shots, reword the Import codes good-to-know line, and carry forward the MQTT capture and send notes and the MQTT adopters row for HA 2026.8.
CHANGELOG gains the 0.10.0 entry: the Devices and Remotes split with
the dashed add tile, the one add dialog and USE; pinning a Remote to a
Device; air-conditioner handsets as Remotes with LAST HEARD and the
State heard trigger; thermostat presets by star; codes that came from a
file recognizing the real handset; the Mirror filter row and the
settings, header and finish pass; the tap-counts-once fix; ten
languages. The entries since 0.9.8 are both unrelated hotfixes, so
nothing here repeats them.

info.md gains two lines on the HACS card: Devices and Remotes with
pinning, and air-conditioner handsets as Remotes.

llms.txt gains the architecture: the Devices/Remotes split with each
Remote a real HA device and its device_trigger entries, pinning and the
loop breaker, matrix Remotes in listen mode with hair_state_heard and
the State heard device trigger, climate presets from starred commands,
and the tolerant signal-shape match used only when a stored code is
file-sourced and every exact tier misses.

The nine translated READMEs get the v0.10.0 stamp; the repo carries no
glossary section, so there was none to extend.
manifest.json and the panel's HAIR_VERSION footer constant both move to
0.10.0, and the dist bundle is rebuilt from that source so the footer a
user reads matches the version HACS installed. Source and bundle land
together, which is what test_locales.py's panel-stamp test checks.
@DAB-LABS
DAB-LABS merged commit 7b9643d into main Aug 18, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant