Skip to content

fix: handle MQTT status-change-broadcast and full-status-broadcast payloads#90

Open
KieraDOG wants to merge 5 commits into
kclif9:mainfrom
KieraDOG:fix/mqtt-status-change-broadcast
Open

fix: handle MQTT status-change-broadcast and full-status-broadcast payloads#90
KieraDOG wants to merge 5 commits into
kclif9:mainfrom
KieraDOG:fix/mqtt-status-change-broadcast

Conversation

@KieraDOG
Copy link
Copy Markdown

@KieraDOG KieraDOG commented Jun 3, 2026

Summary

Fixes stale zone state when using MQTT realtime push on Neo systems.

Root causes

1. status-change-broadcast ignored (actron.py)
The Neo broker sends realtime state deltas inside event["event"] with type == "status-change-broadcast". Each key uses flat dot-notation + array-index format (e.g. UserAirconSettings.EnabledZones[6], RemoteZoneInfo[1].ZonePosition). The old code listed "event" as a metadata-only key, so every delta fell through to an HTTP refresh of the stale cloud-cached lastKnownState — zone updates from the device were silently discarded.

2. full-status-broadcast not parsed (actron.py)
The Neo broker wraps the complete device state inside payload["event"] (type "full-status-broadcast") rather than the expected {"lastKnownState": {...}} shape. mqtt_client.py tried to parse this directly as ActronAirStatus and produced an all-zero default object.

3. IP literal TLS verification failure (mqtt_client.py)
When the MQTT endpoint is a raw IP address (e.g. 4.237.217.248), setting only check_hostname = False was not enough — verify_mode also needs to be set to CERT_NONE, otherwise CERTIFICATE_VERIFY_FAILED is raised.

4. No accurate initial state on connect
The Neo broker does not retain MQTT messages, so no state is pushed on subscription. Relying on the HTTP lastKnownState endpoint returned a stale cloud snapshot. The iOS app works around this by sending a getAll command on connect, which triggers an immediate full-status-broadcast from the device.

Changes

  • _mqtt_status_change_contains_state — detects event.type == "status-change-broadcast" payloads as state-bearing
  • _merge_mqtt_status_change — extracts flat indexed keys from inside the event dict and writes them into lastKnownState via two new helpers: _parse_flat_key_path and _apply_flat_path_to_dict
  • _parse_full_status_broadcast — new method that extracts the lastKnownState-compatible keys from a full-status-broadcast payload
  • _coerce_realtime_status — always routes full-status topic through _parse_full_status_broadcast (takes precedence over the broken domain_model)
  • _request_initial_full_status — new method called from start_push that sends getAll and waits up to 5 s for the full-status-broadcast response, ensuring accurate state before start_push returns
  • mqtt_client._ensure_tls_context — adds verify_mode = ssl.CERT_NONE for IP literal endpoints

Test plan

  • All 522 existing unit tests pass
  • Tested against a live Neo system: MQTT status-change-broadcast zone state correctly reflected without HTTP fallback
  • Tested getAll triggers immediate full-status-broadcast with accurate zone data on connect

🤖 Generated with Claude Code

KieraDOG and others added 5 commits June 3, 2026 21:44
Neo status-change messages wrap real-time state deltas inside an
event dict with type "status-change-broadcast". Each key in that
dict uses flat dot-notation + array-index format (e.g.
"UserAirconSettings.EnabledZones[6]") rather than a nested object.

The previous code listed "event" as a metadata-only key, so every
status-change-broadcast fell through to an HTTP refresh that returns
a stale cloud-cached snapshot — zone state updates from the device
were silently discarded.

Changes:
- _mqtt_status_change_contains_state now detects broadcast payloads
- _merge_mqtt_status_change extracts flat keys from inside the event
  dict and writes them into lastKnownState via two new helpers:
  _parse_flat_key_path and _apply_flat_path_to_dict
- _ensure_tls_context: IP literal endpoints also need
  verify_mode = ssl.CERT_NONE (check_hostname = False alone caused
  CERTIFICATE_VERIFY_FAILED against the Azure IP broker)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The Neo broker pushes full device state on the full-status topic with
the payload wrapped inside event["event"] (type == "full-status-broadcast").
The previous code passed this to ActronAirStatus.model_validate which
failed because the structure doesn't match the expected HTTP shape.

Add _parse_full_status_broadcast to extract the lastKnownState-compatible
keys from the event dict (skipping the serial diagnostic block and "type"),
so that full-status pushes — triggered by iOS app getAll commands or
periodic device reports — are correctly parsed and applied to state_manager.

This provides accurate initial state without any HTTP polling.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
After connecting MQTT, send a "getAll" command (same as iOS app) to
trigger an immediate full-status-broadcast from the device. Wait up
to 5 seconds for the broadcast to arrive so state_manager holds
accurate device state before start_push returns to the caller.

This eliminates reliance on the stale HTTP lastKnownState snapshot
for initial state. The Neo broker does not retain messages and has
no other mechanism to request current state.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
mqtt_client.py tries to parse the full-status payload as ActronAirStatus
but the payload has structure {"event": {...}} not {"lastKnownState": {...}},
so domain_model ends up as an empty default status (all zeros). The
previous check `if status is None` meant _parse_full_status_broadcast
was never reached.

Now full-status topics always go through _parse_full_status_broadcast
first; falling back to the domain_model only if that returns None.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings June 3, 2026 11:57
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

This PR improves realtime MQTT state accuracy by requesting an immediate full-state snapshot on connect and adding support for parsing/applying Neo “full-status-broadcast” and flattened “status-change-broadcast” payloads. It also changes TLS behavior for IP-literal MQTT endpoints.

Changes:

  • Disable TLS certificate verification when connecting to IP-literal endpoints.
  • Request an initial getAll on realtime connect and wait briefly for full-status broadcasts.
  • Add parsing of /mwc/full-status events and apply flattened delta keys into lastKnownState.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.

File Description
src/actron_neo_api/rt/mqtt_client.py Adjusts TLS context behavior for IP-literal endpoints.
src/actron_neo_api/actron.py Adds initial getAll request, parses full-status broadcasts, and applies flattened status-change deltas.

tls_context = await asyncio.to_thread(ssl.create_default_context)
if self._is_ip_literal_endpoint():
tls_context.check_hostname = False
tls_context.verify_mode = ssl.CERT_NONE
Comment on lines +890 to +897
# Keys that are NOT part of lastKnownState
_SKIP = {"type"}

last_known_state = {
k: v
for k, v in event.items()
if k not in _SKIP and not (isinstance(k, str) and k.startswith("<"))
}
'RemoteZoneInfo[1].ZonePosition' → ['RemoteZoneInfo', 1, 'ZonePosition']
"""
path: list[str | int] = []
for match in ActronAirAPI._FLAT_KEY_SEGMENT_RE.finditer(key):
Comment on lines +1040 to +1052
current: Any = d
for i, segment in enumerate(path[:-1]):
next_segment = path[i + 1]
if isinstance(segment, int):
while len(current) <= segment:
current.append(None)
if not isinstance(current[segment], (dict, list)):
current[segment] = [] if isinstance(next_segment, int) else {}
current = current[segment]
else:
if not isinstance(current.get(segment), (dict, list)):
current[segment] = [] if isinstance(next_segment, int) else {}
current = current[segment]
@KieraDOG
Copy link
Copy Markdown
Author

KieraDOG commented Jun 3, 2026

@kclif9 I will look into those copilot comments later, but could you please advise if this is a way to fix the issue?

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.

2 participants