Add compressor status, outdoor temp, etc, using direct writes - #77
Conversation
There was a problem hiding this comment.
Pull request overview
This PR adds low-level CN105 (ITP) raw frame transport over the Kumo adapter’s WiFi API and introduces decoding helpers + new telemetry getters (outdoor temp, compressor runtime, operating flag, compressor frequency, sub-mode/stage/auto-sub-mode) on PyKumo.
Changes:
- Added
PyKumomethods to send/read/transceive raw CN105 frames and to query/decode new status/telemetry fields. - Introduced a new
pykumo.cn105module containing frame construction, checksum/validation, and decode helpers for info codes0x03,0x06, and0x09. - Re-exported CN105 helpers from
pykumo.__init__for public use.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| pykumo/py_kumo.py | Adds raw CN105 R/W + higher-level telemetry/status getters built on transceive polling. |
| pykumo/cn105.py | New module implementing CN105 frame helpers and decode routines for multiple info codes. |
| pykumo/init.py | Exposes CN105 helper functions at the package top level. |
Comments suppressed due to low confidence (3)
pykumo/cn105.py:95
- valid_cn105_reply() only validates the leading 0xFC byte and checksum. That can treat non-CN105 data (wrong subheader) or concatenated/partial frames (length byte not matching actual length) as “valid”, which increases the chance transceive_cn105_frame() accepts stale/garbled readback data. Consider also validating the fixed subheader and that len(frame) matches the length byte.
def valid_cn105_reply(frame: bytes) -> bool:
"""True if ``frame`` is a well-formed CN105 frame with a valid checksum."""
if not frame or len(frame) < 6:
return False
if frame[0] != PACKET_HEADER:
pykumo/cn105.py:137
- valid_reply_for_code() checks the echoed info code at byte[5], but it doesn’t validate that the frame is actually an info response (type 0x62). Without that, decode* helpers can return values for non-response frames that happen to have the right checksum/length and code byte.
def _valid_reply_for_code(frame, code: int, min_len: int) -> bool:
"""True if ``frame`` is a valid reply for info ``code`` and long enough.
Checks the frame is well-formed (:func:`valid_cn105_reply`), that the
echoed info code at raw byte index 5 matches ``code``, and that the frame
pykumo/cn105.py:71
- build_cn105_frame() unconditionally right-pads the payload to PAYLOAD_SIZE (16) and sets the length byte to 16. That makes the helper unsuitable for building arbitrary CN105 frames (and conflicts with the PR description that this assembles header+payload+checksum). Consider making padding optional and having build_info_request() opt into the 16-byte padding.
def build_cn105_frame(type_byte: int, payload: bytes) -> bytes:
"""Build a complete CN105 frame: header + payload + trailing checksum.
``payload`` is right-padded with zeros to :data:`PAYLOAD_SIZE` bytes, which
matches how info requests appear on the wire.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if not self.send_raw_cn105_frame(frame, id_byte): | ||
| return None | ||
| poll_count = max(1, int(timeout / RAW_REPLY_POLL_INTERVAL_SECONDS)) | ||
| for _ in range(poll_count): | ||
| time.sleep(RAW_REPLY_POLL_INTERVAL_SECONDS) | ||
| reply = self.read_raw_cn105_frame() | ||
| if not valid_cn105_reply(reply): | ||
| continue | ||
| if expect_type is not None and reply[1] != expect_type: | ||
| continue | ||
| if expect_code is not None and reply[5] != expect_code: | ||
| continue | ||
| return reply | ||
| _LOGGER.debug("%s: no valid CN105 reply within %.1fs", self._name, timeout) | ||
| return None |
| def cn105_checksum(data: bytes) -> int: | ||
| """Return the CN105 frame checksum for ``data`` (all preceding bytes).""" | ||
| return (0xFC - sum(data)) & 0xFF |
|
This still needs some work like implementing some type of async comm manager to prevent the packets from stepping on each other. Would also need a way to poll periodically and cache the states, but maybe that can go into the hass kumo repo instead. I added some initial work for an unsupported 0x06, but this needs to be made more robust. Maybe it needs to be gated behind a setting or something in the frontend to select whether to use 0x06 or the fallback. If you send the 0x06 to an unsupported unit, it just hangs for a long time and no other cn105 command would receive a response. A lot of the coex packet issue is probably because we're not handling the id_byte correctly since it's currently always using 1. Maybe there's a way to send using other id values and it can asynchronously wait for a response with the matching id or something. Haven't looked into this yet. |
|
Certainly caching & polling state can go in hass-kumo, that's the pattern used for other state currently. In fact it polls everything at once so a simplification could be a single function that gathers, assembles, and returns a dictionary of all the CN105 data in a safe manner. If we could detect the indoor unit model we could have an allowlist for 0x06. But, astonishingly, I think it's not available to the adapter. Maybe you've seen it in your exploration. But barring that, yeah, the safe thing would be an opt-in UI option and a list of known working models in the docs. |
e2f5848 to
7659a4e
Compare
dlarrick
left a comment
There was a problem hiding this comment.
At a glance this looks great, just a few comments. Discovering the raw CN105 is a great achievement that should give users the best of both worlds.
I am out of town this coming weekend (hello summer) and the day job consumes the weekdays, but I intend to play with this code in isolation prior to approving & committing.
If you'd like to press forward with hass-kumo changes feel free, otherwise I can take a stab at it. I have no great love for the current config flow (works but could use modernization) so even sweeping changes there are welcome.
| Byte indices in this module are into the full wire frame, so ``frame[5]`` is the | ||
| info code and "byte N" is ``payload[N - 5]``. Every ``decode_*`` function returns | ||
| ``None`` when the frame is not a valid reply for its info code, is too short to | ||
| carry the field, or the unit reports the field as unavailable; unlike ESPHome |
There was a problem hiding this comment.
"unlike ESPHome" comment is likely leftover Claude commentary, not useful to someone not present for the AI converastion. You stripped some of this from other locations.
| checksum is validated. Returns the reply frame bytes, or None if no | ||
| matching reply arrives within ``timeout``. | ||
|
|
||
| Holds the per-unit CN105 lock across the whole send/poll window, so |
| Pass ``runtime_minutes`` to reuse a counter value already read (e.g. by | ||
| :meth:`get_cn105_status`) instead of paying another round trip. | ||
|
|
||
| Prefer :meth:`get_operating` (0x06) where available, or :meth:`get_stage` |
| @@ -0,0 +1,648 @@ | |||
| """Tests for raw CN105/ITP frame send+receive and outdoor temperature read.""" | |||
There was a problem hiding this comment.
Nice, thank you for adding some tests for this code that would otherwise be hard to validate.
c1397f9 to
2f623dc
Compare
The Kumo API does not report outdoor temperature, compressor runtime, or sub mode. Those are all available over the CN105 serial protocol, which the adapter tunnels through its indoorUnit.settings.rawITPFrame node. Decoding is ported from echavet/MitsubishiCN105ESPHome. cn105.py builds and decodes frames, with no I/O and no state. cn105_bus.py owns the transport: the adapter has one reply buffer, so each send and the read that collects it happen under a lock, inside a single request_cycle() so the whole wait uses one connection. PyKumo gets update_cn105_telemetry() plus cached getters, matching how update_status() already works. Info code 0x06 is opt-in, because a unit that does not implement it takes about 30 seconds to give up and usually takes the codes after it in the same refresh down too; without it, is_compressor_running() infers from the runtime counter. Any code that never answers is dropped after one miss so it cannot keep disturbing the bus. The runtime counter is timestamped when it is read rather than when the refresh ends, and the estimator is only fed a genuinely fresh reading. Otherwise a slow code makes a reading taken seconds ago look far enough from the previous one to compare against it, which reports the compressor as stopped while it is running. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2f623dc to
8edd9f4
Compare
This adds raw CN105 frame read/write over the adapter's WiFi API, and uses it to expose things the Kumo API does not give you: outdoor temperature, compressor runtime, sub mode, and whether the compressor is actually running. The decoding is ported from echavet/MitsubishiCN105ESPHome.
In principle any frame that works with MitsubishiCN105ESPHome or the other CN105 projects can be sent this way. In practice the adapter's reply buffer is buggy, so some responses are not readable even though the write itself went through. Writes go straight to the indoor unit, so use this at your own risk: not every unit supports every feature, and you should back up your existing config (function codes, temperature source, and so on) before writing anything. Everything listed below is a read I have validated on my own units, but your mileage may vary.
Usage
Reads and refreshes are separate, the same way
update_status()and the existing getters work. One refresh gets every field for the codes you ask for, and the getters after it are cache reads:Info code
0x06is opt-in, because units that do not implement it stop answering CN105 entirely until the adapter reboots. Pass it explicitly to get every field:operatingis always in the snapshot: the real0x06flag when you asked for that code, otherwise the same inferenceis_compressor_running()makes.Or ask for one code at a time if you only want part of it:
If a code that has never answered misses, the bus stops sending it, so one bad request cannot keep jamming every poll. After a reboot, clear that:
You can send anything the methods below do not cover:
PyKumomethodsupdate_cn105_telemetryboolget_cn105_telemetrydictoperatingget_cn105_telemetry_agefloatNoneif neverget_outdoor_temperaturefloatget_raw_room_temperaturefloatget_compressor_runtime_minutesintis_compressor_runningbool0x06flag if you asked for it, otherwise inferred from the counterget_cn105_busCn105Busupdate_cn105_telemetry(codes=None, timeout=20.0)never raises. A code that fails leaves its own fieldsNoneand the others alone, and all the keys for a requested code are always present, so callers can index without guarding.codesdefaults to0x03and0x09.get_cn105_telemetry()adds one key the decoders do not produce:operating, which mirrorsis_compressor_running(). Every other key comes straight from a decoded reply.Info codes
InfoCodeTEMPERATURES0x03room_temperature,outdoor_temperature,compressor_runtime_minutesCOMPRESSOR0x06operating,compressor_frequencySUB_MODE0x09sub_mode,stage,auto_sub_modesub_modeis NORMAL/WARMUP/DEFROST/PREHEAT/STANDBY/OFF,stageis the indoor fan (IDLE/LOW/GENTLE/MEDIUM/MODERATE/HIGH/DIFFUSE), andauto_sub_modecovers both the older 4-state units and the newer MFZ bitfield ones.Frames and decoding (
cn105.py)cn105_checksumint(0xFC - sum) & 0xFFbuild_cn105_framebytespad_tofor payloads that are not 16 bytesbuild_info_requestbytes0x42info request for a codevalid_cn105_replyboolis_info_replybool0x62reply to a request for a specific codedecode_info_replydictTELEMETRY_KEYSdictDEFAULT_INFO_CODEStuple0x03and0x09CompressorActivityEstimatordecode_info_reply(frame, code)never raises and holds no state. You get every field for the code, and a field isNoneif the unit says it has no value, the byte is unrecognized, or the frame is missing, broken, or too short.Transport (
cn105_bus.py)Cn105BusmethodsendboolrawITPFramereadbytestransceivebytesNoneon timeoutread_infobytesunsupported_codesfrozensetforget_unsupportedNoneThe adapter has one buffer for replies, so a send and the read that collects it happen under a single lock. That covers threads in this process only; another program on the network can still overwrite the buffer mid-exchange. The whole wait also runs inside one
request_cycle(), so a 20 second window uses one connection instead of reconnecting on every check.