Skip to content

Add compressor status, outdoor temp, etc, using direct writes - #77

Draft
ok2sh wants to merge 1 commit into
dlarrick:masterfrom
ok2sh:feat/direct_writes
Draft

Add compressor status, outdoor temp, etc, using direct writes#77
ok2sh wants to merge 1 commit into
dlarrick:masterfrom
ok2sh:feat/direct_writes

Conversation

@ok2sh

@ok2sh ok2sh commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

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:

unit.update_cn105_telemetry()          # blocks a few seconds, call from an executor
unit.get_outdoor_temperature()         # 27.0
unit.is_compressor_running()           # True, False, or None if not known yet
unit.get_cn105_telemetry()
# {'room_temperature': 22.0, 'outdoor_temperature': 27.0,
#  'compressor_runtime_minutes': 137918, 'sub_mode': 'NORMAL',
#  'stage': 'GENTLE', 'auto_sub_mode': 'AUTO_INACTIVE',
#  'operating': True}

Info code 0x06 is opt-in, because units that do not implement it stop answering CN105 entirely until the adapter reboots. Pass it explicitly to get every field:

from pykumo import InfoCode

unit.update_cn105_telemetry(codes=list(InfoCode))   # 0x03, 0x06, 0x09
unit.get_cn105_telemetry()
# {'room_temperature': 22.0, 'outdoor_temperature': 27.0,
#  'compressor_runtime_minutes': 137918,
#  'operating': True, 'compressor_frequency': 42,
#  'sub_mode': 'NORMAL', 'stage': 'GENTLE', 'auto_sub_mode': 'AUTO_ACTIVE'}

operating is always in the snapshot: the real 0x06 flag when you asked for that code, otherwise the same inference is_compressor_running() makes.

Or ask for one code at a time if you only want part of it:

unit.update_cn105_telemetry(codes=[InfoCode.COMPRESSOR])
unit.get_cn105_telemetry()["compressor_frequency"]   # 42

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:

bus = unit.get_cn105_bus()
bus.unsupported_codes        # frozenset({<InfoCode.COMPRESSOR: 6>})
bus.forget_unsupported()

You can send anything the methods below do not cover:

from pykumo.cn105 import build_info_request, decode_info_reply

bus = unit.get_cn105_bus()
reply = bus.read_info(0x09)
decode_info_reply(reply, 0x09)             # {'sub_mode': 'NORMAL', ...}
bus.transceive(build_info_request(0x02))   # raw frame in, raw frame out

PyKumo methods

Method Blocks Returns Notes
update_cn105_telemetry yes bool Refresh. One request per code, in sequence. True if any code answered
get_cn105_telemetry no dict Everything from the last refresh, plus operating
get_cn105_telemetry_age no float Seconds since the last answer, None if never
get_outdoor_temperature no float The adapter reports this as null in its own status
get_raw_room_temperature no float What the unit measures, not the sensor the adapter was told to use
get_compressor_runtime_minutes no int 24-bit counter, only moves while the compressor runs
is_compressor_running no bool 0x06 flag if you asked for it, otherwise inferred from the counter
get_cn105_bus no Cn105Bus Raw frame access

update_cn105_telemetry(codes=None, timeout=20.0) never raises. A code that fails leaves its own fields None and the others alone, and all the keys for a requested code are always present, so callers can index without guarding. codes defaults to 0x03 and 0x09.

get_cn105_telemetry() adds one key the decoders do not produce: operating, which mirrors is_compressor_running(). Every other key comes straight from a decoded reply.

Info codes

InfoCode Value Fields
TEMPERATURES 0x03 room_temperature, outdoor_temperature, compressor_runtime_minutes
COMPRESSOR 0x06 operating, compressor_frequency
SUB_MODE 0x09 sub_mode, stage, auto_sub_mode

sub_mode is NORMAL/WARMUP/DEFROST/PREHEAT/STANDBY/OFF, stage is the indoor fan (IDLE/LOW/GENTLE/MEDIUM/MODERATE/HIGH/DIFFUSE), and auto_sub_mode covers both the older 4-state units and the newer MFZ bitfield ones.

Frames and decoding (cn105.py)

Function Returns Notes
cn105_checksum int (0xFC - sum) & 0xFF
build_cn105_frame bytes Header, payload, checksum. pad_to for payloads that are not 16 bytes
build_info_request bytes A 0x42 info request for a code
valid_cn105_reply bool Checks header, sub-header, declared length, checksum
is_info_reply bool Valid 0x62 reply to a request for a specific code
decode_info_reply dict Every field for the code you ask about
TELEMETRY_KEYS dict Which fields each code reports
DEFAULT_INFO_CODES tuple 0x03 and 0x09
CompressorActivityEstimator class Infers compressor activity from the runtime counter. No I/O, and the caller passes the time in

decode_info_reply(frame, code) never raises and holds no state. You get every field for the code, and a field is None if the unit says it has no value, the byte is unrecognized, or the frame is missing, broken, or too short.

Transport (cn105_bus.py)

Cn105Bus method Returns Notes
send bool Write a full frame to rawITPFrame
read bytes Read back whatever reply the adapter is holding
transceive bytes Send once, wait for a matching reply, None on timeout
read_info bytes An info request, skipping codes we have given up on
unsupported_codes frozenset Codes that never answered
forget_unsupported None Try them again, e.g. after an adapter reboot

The 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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 PyKumo methods to send/read/transceive raw CN105 frames and to query/decode new status/telemetry fields.
  • Introduced a new pykumo.cn105 module containing frame construction, checksum/validation, and decode helpers for info codes 0x03, 0x06, and 0x09.
  • 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.

Comment thread pykumo/py_kumo.py Outdated
Comment thread pykumo/py_kumo.py Outdated
Comment on lines +833 to +847
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
Comment thread pykumo/cn105.py
Comment on lines +62 to +64
def cn105_checksum(data: bytes) -> int:
"""Return the CN105 frame checksum for ``data`` (all preceding bytes)."""
return (0xFC - sum(data)) & 0xFF
@ok2sh
ok2sh marked this pull request as draft July 26, 2026 20:16
@ok2sh

ok2sh commented Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

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.

@dlarrick

Copy link
Copy Markdown
Owner

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.

@ok2sh
ok2sh force-pushed the feat/direct_writes branch 2 times, most recently from e2f5848 to 7659a4e Compare July 27, 2026 22:18

@dlarrick dlarrick left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread pykumo/cn105.py Outdated
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

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"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.

Comment thread pykumo/py_kumo.py Outdated
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

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

Comment thread pykumo/py_kumo.py Outdated
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`

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Callers should prefer"

Comment thread tests/test_raw_cn105.py Outdated
@@ -0,0 +1,648 @@
"""Tests for raw CN105/ITP frame send+receive and outdoor temperature read."""

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice, thank you for adding some tests for this code that would otherwise be hard to validate.

@ok2sh
ok2sh force-pushed the feat/direct_writes branch 3 times, most recently from c1397f9 to 2f623dc Compare July 28, 2026 02:04
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>
@ok2sh
ok2sh force-pushed the feat/direct_writes branch from 2f623dc to 8edd9f4 Compare July 28, 2026 02:42
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.

3 participants