fix: handle MQTT status-change-broadcast and full-status-broadcast payloads#90
Open
KieraDOG wants to merge 5 commits into
Open
fix: handle MQTT status-change-broadcast and full-status-broadcast payloads#90KieraDOG wants to merge 5 commits into
KieraDOG wants to merge 5 commits into
Conversation
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>
Contributor
There was a problem hiding this comment.
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
getAllon realtime connect and wait briefly for full-status broadcasts. - Add parsing of
/mwc/full-statusevents and apply flattened delta keys intolastKnownState.
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] |
Author
|
@kclif9 I will look into those copilot comments later, but could you please advise if this is a way to fix the issue? |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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"]withtype == "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-cachedlastKnownState— 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.pytried to parse this directly asActronAirStatusand 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 onlycheck_hostname = Falsewas not enough —verify_modealso needs to be set toCERT_NONE, otherwiseCERTIFICATE_VERIFY_FAILEDis 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
lastKnownStateendpoint returned a stale cloud snapshot. The iOS app works around this by sending agetAllcommand on connect, which triggers an immediatefull-status-broadcastfrom the device.Changes
_mqtt_status_change_contains_state— detectsevent.type == "status-change-broadcast"payloads as state-bearing_merge_mqtt_status_change— extracts flat indexed keys from inside theeventdict and writes them intolastKnownStatevia two new helpers:_parse_flat_key_pathand_apply_flat_path_to_dict_parse_full_status_broadcast— new method that extracts thelastKnownState-compatible keys from afull-status-broadcastpayload_coerce_realtime_status— always routesfull-statustopic through_parse_full_status_broadcast(takes precedence over the broken domain_model)_request_initial_full_status— new method called fromstart_pushthat sendsgetAlland waits up to 5 s for thefull-status-broadcastresponse, ensuring accurate state beforestart_pushreturnsmqtt_client._ensure_tls_context— addsverify_mode = ssl.CERT_NONEfor IP literal endpointsTest plan
status-change-broadcastzone state correctly reflected without HTTP fallbackgetAlltriggers immediatefull-status-broadcastwith accurate zone data on connect🤖 Generated with Claude Code