Skip to content

Pr/ac_common - #122

Open
3735943886 wants to merge 10 commits into
anszom:masterfrom
3735943886:pr/ac-common
Open

Pr/ac_common#122
3735943886 wants to merge 10 commits into
anszom:masterfrom
3735943886:pr/ac-common

Conversation

@3735943886

@3735943886 3735943886 commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

This is the ac_common base class that came out from the review on #101.

CST_570004_WW (ceiling cassette, deviceType 401) is the only unit confirmed on real
hardware — I own these, and every wire value was driven and read back.

Everything else — RAC, POT, WIN, and PAC — is existing code moved onto
ac_common. The tests pass, but none of it is verified on hardware; I have none of those
units.
PAC's values come from @kkqq9320's captures in #105.

Corrections and "this does nothing on my unit" reports are very welcome, and I am happy to
rework or split this however you prefer.

Closes #105

3735943886 and others added 10 commits August 3, 2026 09:50
The framing check accepts only byte 6 = 0x87, but some device families
(ceiling cassettes, portable ACs) send 0xa7 there instead. POT_056905_WW
worked around this by copying the whole first branch of processData() into
an override just to widen that one comparison.

Turn the comparison into an overridable isHeaderByte6() and drop the copy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 3a3978a)
RAC_056905_WW is not one model's handler. It is the DualCool TLV scheme - 0x1f7
power, 0x1f9 mode, 0x1fa fan, 0x1fd/0x1fe temperatures, the 0x2cc / 0x2cd / 0x2d3
capability bitmaps, the pipe and ODU diagnostics, the basic-filter priv-command -
with one model's particulars mixed into it. Anything else on the same scheme could
either copy the file or be translated into RAC's expectations, and both have been
tried in this project.

So the protocol moves to ac_common and RAC keeps what is actually its own: its
0x1f9 / 0x1fa scales, its setpoint range, the +50 bias on its power reading, and the
fact that it carries a basic-filter counter. Nothing here is tuned to a unit; where
models are known to differ, the difference is stated by the subclass as an override.

Behaviour-preserving, including the two fixes RAC took last week, which move across
rather than being lost in the shuffle:

  - the filter reset queries the counter before clearing it (02cf15c), so a reset
    does not record a usage figure up to 24 hours short of what the filter ran
  - a hook already on modeChangeHooks is not also registered on powerChangeHooks
    (2641ccd), since power reads as a mode change too and the appliance was getting
    the same write twice on every power-up

Both are now covered by RAC tests that fail without them: one counts the jet writes
a power-up produces, the other reads the priv-command subcommands and asserts the
query goes out before the reset.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The bitmap tests read `!!(caps & 0x02)` at each use, so what a bit meant lived only
in the comment beside it and had to be right in every copy. The tags and the bits
are named once at the top of the file and tested through one helper, which also
answers the "unit reports no bitmap at all" case in one place instead of four: it
has none of the features, which is the useful answer, because the entities behind
them would have nothing to show.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both speak the same DualCool TLV scheme as RAC and CST - the same 0x1f7 /
0x1f9 / 0x1fa / 0x1fd / 0x1fe, the same 0x2da marking a capability response,
and in POT's case the same 0xa7 header byte as CST. They were standalone
TLVDevices only because ac_common did not exist when they were written: each
built its whole discovery config in its constructor, before a single packet
arrived, so nothing either unit reports had any say in it.

POT's capability response turns out to carry most of what was written out by
hand: 0x2c1 = 7 is exactly its cool/dry/fan_only, 0x2c2 = 0x54 sets exactly
the fan wire values 2/4/6, and 0x2e1 / 0x2e2 = 32 / 60 is the 16 - 30 C the
handler clamped to. Those are now read rather than assumed. Gating on what
the unit reports also gives it the entities it was always describing and
nobody was showing: error, nominal capacity, EEV opening, the two ODU
temperatures, instantaneous power from 0x2b3, the turn-on / turn-off timers
that 0x2d3 bit 2 advertises (bit 0 is clear, so no sleep timer), and a
climate action from 0x6c. The setpoint range is now published to HA instead
of only being enforced on write.

Two things POT keeps as they were, because the capability data does not
settle them:
  - 0x2cd = 16 leaves every swing bit clear although the unit plainly swings,
    so the axis is stated explicitly rather than derived.
  - that axis stays on 0x322, which is the tag this handler has always
    written, even though the captured values response carries 0x321 and not
    0x322 - so the state is never read back. The existing test already noted
    this. Which tag the unit acts on needs checking against hardware.

WIN gains the same treatment, and with it a fix: selecting "off" in HA did
nothing at all. The handler called setProperty('power', ...) where the field
is registered as 'climate-power', so that call only logged a warning, and the
mode write that followed returned undefined for 'off' - which setProperty
treats as "do not send". No frame left the bridge. The unit could not be
turned off. For the same reason a mode selected while the unit was off never
powered it on: the 0x1f7 attached to the write just re-sent whatever was last
seen. powerOnWithModeWrite states that intent properly. Also dropped is a
dry -> 8 write with no matching entry in the read table and no 'dry' in the
advertised mode list, and the device name moves from the discovery root, where
it did nothing, into the HA device where the other handlers put it.

Neither unit is verified against hardware - there is neither one here.

For POT what was done instead is a differential check: the captures already in
its test, replayed through the old handler and the new one, diffing the
discovery config, every field, every transform and the frames emitted for ten
HA commands. That says the migration changes nothing; it does not say the
original was right. The only intended wire change is that powering on now
attaches fan and setpoint, as it does on RAC.

WIN has less than that. No capture from an LW1823HRSM exists here, so its test
uses synthetic frames and deliberately sets no capability bitmap: it pins the
climate core and the writes, and can say nothing about which optional entities
real hardware would get. Other hardware is known to report this same ThinQ
model id with a different feature set. A capability dump from either is the
missing piece, and the "off" fix above is reasoned from the code rather than
watched working. One further wire change is unverified: a mode write no longer
re-sends 0x322 alongside, matching every other model here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 2f0849f)
Three things the previous pass left inconsistent.

The mode and fan scales were declared three times per model - a wire-indexed
table, a label-keyed record, and the list offered to HA - all carrying the
same fact and all having to be kept in step by hand. The file already had the
answer for this: WireLevels plus wireMaps(), one [label, wire] list from which
the option list and both directions are derived, which the swing axes and
addValueSelect were already using. The two most important mappings in the
class were the ones not using it. Each model now writes modeLevels and
fanLevels, in the order HA should offer them, and the six declarations become
two. The maps are built on first use, not in a field initialiser, because a
base-class field is initialised during super() - before the subclass has
assigned the list it would be built from.

Which implementation of a feature a unit has was asked two different ways per
feature: a capability bit for auto dry's binary form but a plain flag for its
select form, a method reading 0x2f1 for the priv-command filter but a plain
flag for the value-tag one. Two selectors each, of different shapes, for one
question - and nothing stopping a model answering yes twice, so the order this
class happened to test them in silently decided the outcome. They become
autoDryStyle() -> 'none' | 'binary' | 'select' and filterStyle() -> 'none' |
'priv' | 'valueTags'. One question, one answer.

initMakeSetConfig() was 459 lines, a third of the file, and a linear script
whose seams were already marked by blank lines and comments. It is now 16
lines calling one method per concern - climate core, diagnostics, capability
features, auto dry, action, filter, power - each of which says in its own
comment what decides whether it does anything.

Also: ClimateComponent gains `modes`, which it was missing although HA's MQTT
climate accepts it next to fan_modes and swing_modes; it had been slipped past
the type with a conditional spread.

Verified as before, by replaying the RAC, CST, POT and WIN frames through the
handlers and diffing the discovery config, every registered field, each
transform across raw 0-255 and every label any model offers, the published
properties, and the bytes emitted for ten HA commands. One difference, in
RAC's config only: it now states its mode list explicitly where it used to
omit it and let HA fall back to a default. HA's default for a climate
component is the same six modes, so what HA is told is unchanged; RAC was the
only model relying on that, and deriving the list from modeLevels makes it
uniform with the other three.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 176e9c9)
…s' shapes

Two reviewers went over this while a stand-unit handler was being written against it (issue anszom#105),
and between them they found that the shared base decides several things it has no business
deciding, and that two of its capability bits are the wrong bits.

The mode and fan lists are now narrowed by the 0x2c1 and 0x2c2 bitmaps. One modelId covers units
that differ: the wall units' list declares heat and auto, and a cooling-only unit under the same
modelId answers 7 - cool, dry, fan only - so Home Assistant has been offering two modes that the
appliance silently ignores when they are picked. The list stays the vocabulary, since only the
model knows which wire value carries which name; the bitmap is this unit's answer about which of
them it has. A bitmap that is absent, zero, or describes none of the declared levels leaves the
list alone: these bitmaps carry bits above the level cluster whose meaning is not established, and
one that is not recognised is a reason to publish the model's list unchanged rather than to publish
nothing. CST opts out of the fan half, where the bitmap offers wire 0 and the list derived from
driving the appliance says 1.

Two capability bits were tested at the wrong position. The model description names both bitmaps -
0x2cd is support.racSubMode and 0x2d3 is support.reserve - and labels each bit, writing bit N as
key N+1. The turn-on / turn-off timer pair is @TIMEBS_ONOFF at bit 4, not bit 2; horizontal swing
has @AC_MAIN_WIND_DIRECTION_SWING_LEFT_RIGHT_W at bit 5. The keys for the bits tested here do not
appear in either enum, and of the three capability replies on file - a cassette read live, and the
wall and stand units reported in the issue - all three set bit 4 for the timers and none sets bit 2,
so no unit on hand has ever been given those entities. Both positions are accepted rather than
swapped: the fixtures asserting the old bits are synthetic, and accepting both can only add an
entity to a unit that advertises the documented bit, never take one away from an install relying on
the other.

The rest is shape the base could not express, each of which forced a stand-unit handler to
reimplement something around it:

  - a wire level may now carry a repeated label. The stand units report fan speed 8 while drying
    and 7 while jet runs, both meaning "the appliance is driving the fan itself"; the first is what
    a write sends, both read back, and HA is offered the label once.
  - switches take a wiring description. Not every switch tag is 0/1: an AI dry enable is 0/255, a
    display or beep tag stores 1 for "off", and a cleaning cycle is started with one value and
    reports another while it runs. The read is an exact comparison, since truthiness is the wrong
    question for an inverted tag.
  - a switch may sit outside Configuration. HA files an entity by entity_category, and there is no
    string meaning Controls - the key has to be absent - so an everyday control had no way to be
    one.
  - autoDryStyle() gains 'switchLevel': an enable tag plus a separate strength select, which is
    neither of the two forms already here.
  - filterStyle() gains 'valueTagsReset'. The value-tag pair was assumed read-only; on the stand
    units the official app resets the counter by writing 0 to 0x355 and the appliance answers with
    a full life. The button is wired through fields_by_ha rather than addField, which would claim
    fields_by_id[0x355] from the read hook that derives the sensors, and it sends its own frame so
    that a reset the appliance ignores leaves HA still telling the truth. The rated life is
    published too, having been read and thrown away.

Also documented on isHeaderByte6() that an override must widen the accepted set rather than replace
it. Byte 6 is not a per-device constant - on the stand units the async state frames are 0xa7 while
the private-channel acknowledgements stay 0x87 - and a handler that narrowed the hook would lose
them the day the private branches are routed through it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The reserve bitmap advertises a turn-on / turn-off pair on 0x21c / 0x21b and these units do set the
bit, so honouring the bitmap gave every one of them two more entities. They are not worth having: a
schedule that lives inside the appliance is one Home Assistant cannot see the effect of until it
fires, and anyone who wants the unit on at seven already has a better way to arrange it.

The sleep timer stays. That one is a live countdown the appliance acts on by itself, so reporting it
says something the user cannot otherwise see.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…appened to set

support.racSubMode labels bit N as key N+1, and it gives the vertical axis two keys, 3 and 5. Only
bit 2 was accepted here, so a unit advertising the axis on bit 4 was quietly denied the entity. No
capture could have found that: the bit is missing from every capability reply on file, which is
exactly the case where reading the model beats staring at bytes.

Horizontal keeps bit 3 alongside bit 5 even though no enum defines its key 4. These tables are
per-model and do not share a key space - the PAC stand unit has no support.racSubMode at all, and
numbers the sleep timer 8 in support.reserve where the CST cassette numbers it 1 - so a key absent
from one model's table says nothing about another's, and a RAC wall unit on file does set bit 3.
Widening an axis can only add it to a unit that advertises it; narrowing one takes it away from a
unit that works today.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Same DualCool TLV scheme as the residential units, so the protocol comes from
ac_common and this file is only the ways a cassette differs from a wall unit. It is
what the base class was split out for.

Mapping the modelId straight onto RAC_056905_WW was tried first, and does not work.
Five separate reasons, each measured on the units here:

  - the cassette frames its async and query TLV with header byte 6 = 0xa7, so RAC's
    parser drops every state frame. That is what isHeaderByte6 is for.
  - it reports no 0x2cc at all; its feature bitmap is 0x2cb, and read through RAC's
    accessor every feature behind it silently switches off
  - 0x2cd is not the jet/positional-swing bitmap here. Its value has many unrelated
    bits set on a unit that has neither jet nor positional swing, so honouring it
    invents entities the appliance does not have
  - the fan scale differs: six steps, and auto is wire 8 where RAC uses 3
  - no basic-filter priv-command. Filter life is on the value tags 0x355 / 0x356,
    read-only, and RAC's counter reads as absent

The mode list is likewise the cassette's own: 0x2c1 advertises {0,1,2,3} and the
unit reports 0x1f9=3 for auto, where the wall units use 6 - and a cassette on a
cooling-only outdoor unit has no heat hardware at all.

The one place a capability bitmap is deliberately not honoured is the fan list. 0x2c2
reports 469, i.e. wire 0, 2, 4, 6, 7, 8, which agrees with the driven list on five of
six steps and offers 0 where this has 1. All six were confirmed by driving the
appliance, so the list wins over an unexplained disagreement about the slowest step,
and fanCaps() says so rather than silently dropping "very low".

Decode vectors from a live unit; entities verified against the panel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The third shape on the DualCool scheme, and the one that decided how two of
ac_common's per-model questions are asked.

Auto dry is neither of the forms the base class had. It is not the diagnostic on/off
the 0x2cc bit advertises, and not the writable duration the cassette has: 0x20e is a
plain enable that reads 0 or 255 and nothing else, the strength is its own 1..5
select on a second tag, and 0x225 counts the running cycle down in minutes. Offering
it as the duration variant's "10 min / 30 min / 60 min" would be inventing options
the appliance does not have, so autoDryStyle() gained a third answer instead.

The filter is on the value tags like the cassette, but on this unit they are not
read-only: the official app resets the counter with a plain TLV write of 0 to 0x355
and the appliance answers by reporting 0x355 = 0x356, a full life again. That is not
something to probe for - a wrong guess writes a lifetime that contradicts the
appliance's own display - so it is a separate filterStyle rather than a behaviour
every value-tag unit inherits.

The unit is also an argument against trusting the capability bitmaps: 0x2cc is absent
entirely and 0x2cd reads 0, so ac_common's derivations answer "no" to every feature
it demonstrably has. The per-model hooks exist for that, and it states each of them
itself rather than being made to lie.

NOT VERIFIED AGAINST HARDWARE. There is no such appliance here. Every value comes
from kkqq9320's captures - 557 frames over four sessions of operating the unit by
hand, reported in issue anszom#105 - and the file cites which frames each value came from;
where something is inferred rather than seen, it says so. The tests replay those
frames: the capability reply, a 94-tag values dump, the mode, fan and switch
notifications, and both a cooling and a stopped 0xa8 record. What a capture cannot
show is whether a write lands, so somebody with the unit should confirm those.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
kkqq9320 pushed a commit to kkqq9320/rethink that referenced this pull request Aug 4, 2026
0x225 has been published as '%' since this handler was written - inherited from upstream, which
still declares it, and never measured here: all six earlier observations read 0 because no cycle
ever ran while recording. It was briefly changed to 'min' on the sibling units' evidence and
reverted deliberately, with a test pinning '%' so the next change would have to be a decision.

The cycle has now been run (wall-autodry-20260804.jsonl). 0x225 started at 8 while the official
app showed 8 minutes, and stepped 8 -> 7 in 59 s before the stop button took it to 0. The app's
display and the decrement rate are independent of one another, and a percentage of an
unknown-length cycle does not begin at 8.

Same tag and same raw value as upstream - neither side applies a read_xform - so this is a label
being corrected, not a decode. Worth noting for upstream: anszom#122's ac_common keeps '%' for the
'binary' auto-dry style, which is the style this unit's 0x2cc bit selects, and switches to 'min'
only for 'select' and 'switchLevel'. That split is what the measurement contradicts, on this
unit; other batches of the same modelId are not claimed.

device_class 'duration' comes along to match PAC_910604_WW, whose comment cross-referencing RAC's
'%' is updated here too.
@anszom

anszom commented Aug 20, 2026

Copy link
Copy Markdown
Owner

Thank you for the PR. I've just returned from vacation and I'm slowly working through the PR & issue backlog. I'd appreciate your patience :)

BluSyn pushed a commit to BluSyn/rethink-rs that referenced this pull request Aug 21, 2026
Port from upstream Node PR #122
(anszom/rethink#122). TLV climate on 0xA7 using
TlvDeviceCore; modes 0/1/5 and fans 2-6 plus 7/8-as-auto from issue #105
captures. Power is tenths of a watt (0x2b3); filter is the 0x355/0x356
value-tag pair; 307-byte 0xA8 records supply compressor running for
hvac_action.
BluSyn pushed a commit to BluSyn/rethink-rs that referenced this pull request Aug 21, 2026
Stop aliasing the ceiling cassette to RAC_056905_WW. Upstream PR #122
(anszom/rethink#122) documents a different
mode/fan/swing map (auto=3 not 6; six fan steps; on/off vanes on
0x205/0x206), humidity as 0x336/10, and cassette-only wind-mode /
autodry-select entities. Capture fixtures from that PR drive the
shipped decoder.
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.

Plan: common AC logic into ac_common

2 participants