Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 30 additions & 11 deletions proto/cleaned/quilt_hds.proto
Original file line number Diff line number Diff line change
Expand Up @@ -57,16 +57,34 @@ enum LocalCommsHealthStatus {
LOCAL_COMMS_HEALTH_STATUS_STARTING_UP = 4;
}

// Local communications health status for a QSM or Controller mesh node.
// Diagnostic reason accompanying LocalCommsHealthStatus.
// APK-confirmed (com.quilt.android 1.0.29, enum class m62). The local mesh is
// Eclipse Zenoh-based (see ZENOH_SESSION_DOWN), not NATS.
enum LocalCommsHealthReason {
LOCAL_COMMS_HEALTH_REASON_UNSPECIFIED = 0;
LOCAL_COMMS_HEALTH_REASON_HEALTHY = 1;
LOCAL_COMMS_HEALTH_REASON_BOOT_OR_WARMUP = 2;
LOCAL_COMMS_HEALTH_REASON_ZENOH_SESSION_DOWN = 3;
LOCAL_COMMS_HEALTH_REASON_NO_PEERS_VISIBLE = 4;
LOCAL_COMMS_HEALTH_REASON_PARTIAL_VISIBILITY = 5;
LOCAL_COMMS_HEALTH_REASON_NO_MESSAGES_RECEIVED = 6;
LOCAL_COMMS_HEALTH_REASON_SESSION_INSTABILITY = 7;
LOCAL_COMMS_HEALTH_REASON_NO_ALIVE_PEERS = 8;
LOCAL_COMMS_HEALTH_REASON_NO_PEERS_EXPECTED = 9;
LOCAL_COMMS_HEALTH_REASON_CREDENTIALS_NOT_READY = 10;
LOCAL_COMMS_HEALTH_REASON_CREDENTIALS_EXPIRED = 11;
}

// Local communications status for a QSM or Controller mesh node.
// Carried as field 8 on QuiltSmartModule and field 9 on Controller.
// The `health` subfield (2) is the primary value; subfields 3, 4, 6 semantics TBD.
// Field numbers/semantics APK-confirmed (1.0.29, message class l62).
message LocalCommsStatus {
google.protobuf.Timestamp updated_ts = 1; // most-recent server update timestamp
LocalCommsHealthStatus health = 2; // current health
int32 link_state = 3; // observed: HEALTHY=9, DEGRADED=8; enum TBD
int32 version = 4; // observed: always 9
google.protobuf.Timestamp health_changed_ts = 5; // when health last changed
int32 connection_state = 6; // observed: HEALTHY=1, DEGRADED=5; enum TBD
google.protobuf.Timestamp updated_ts = 1; // most-recent server update timestamp
LocalCommsHealthStatus status = 2; // current health status
int32 visible_devices_count = 3; // mesh peers currently visible
int32 expected_devices_count = 4; // mesh peers expected on this system
google.protobuf.Timestamp last_session_change_ts = 5; // when the mesh session last changed
LocalCommsHealthReason reason = 6; // diagnostic reason for current status
}

enum OccupancyMode {
Expand All @@ -77,7 +95,7 @@ enum OccupancyMode {

// Freeze-protection setting on SpaceSettings (field 9).
// UNSPECIFIED is treated as ENABLED by the app (freeze protection on by default).
// Field number unconfirmed (assumed f9; added post-initial release).
// APK-confirmed field 9 (com.quilt.android 1.0.29, message class ea2).
enum SafetyHeatingMode {
SAFETY_HEATING_MODE_UNSPECIFIED = 0;
SAFETY_HEATING_MODE_DISABLED = 1;
Expand Down Expand Up @@ -243,7 +261,7 @@ message SpaceRelationships {
// f1=name, f2=description, f3=updated_ts, f4=timezone, f5=OccupancyMode,
// f6=HvacControllerType, f7=occupied_timeout_s(180.0), f8=unoccupied_timeout_s(1200.0)
// Wire-confirmed: fields 1–8.
// f9=SafetyHeatingMode (freeze protection): field number unconfirmed (assumed f9).
// f9=SafetyHeatingMode (freeze protection): APK-confirmed field 9 (1.0.29, class ea2).
message SpaceSettings {
string name = 1; // wire-confirmed: f1
string description = 2; // wire-confirmed: f2
Expand All @@ -253,7 +271,7 @@ message SpaceSettings {
HvacControllerType hvac_controller_type = 6; // wire-confirmed: f6
float occupied_timeout_s = 7; // seconds of presence before "returned"; default 180s (3 min)
float unoccupied_timeout_s = 8; // seconds of no-presence before "away"; default 1200s (20 min)
SafetyHeatingMode safety_heating = 9; // freeze protection; UNSPECIFIED treated as ENABLED; field# unconfirmed
SafetyHeatingMode safety_heating = 9; // freeze protection; UNSPECIFIED treated as ENABLED; APK-confirmed f9
}

// Wire-confirmed from UpdateSpace binary (hex decode):
Expand Down Expand Up @@ -389,6 +407,7 @@ message IndoorUnitConditions {
ConditionState coil_preheat = 10;
ConditionState mode_conflict_avoidance = 11;
ConditionState outdoor_unit_communication_error = 12;
ConditionState compressor_minimum_run_time = 13; // APK-confirmed 1.0.29 (enum class l02)
}

message IndoorUnitPresenceState {
Expand Down
518 changes: 260 additions & 258 deletions src/quilt_hp/_proto/quilt_hds_pb2.py

Large diffs are not rendered by default.

99 changes: 71 additions & 28 deletions src/quilt_hp/_proto/quilt_hds_pb2.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,45 @@ LOCAL_COMMS_HEALTH_STATUS_OFFLINE: LocalCommsHealthStatus.ValueType # 3
LOCAL_COMMS_HEALTH_STATUS_STARTING_UP: LocalCommsHealthStatus.ValueType # 4
Global___LocalCommsHealthStatus: _TypeAlias = LocalCommsHealthStatus # noqa: Y015

class _LocalCommsHealthReason:
ValueType = _typing.NewType("ValueType", _builtins.int)
V: _TypeAlias = ValueType # noqa: Y015

class _LocalCommsHealthReasonEnumTypeWrapper(_enum_type_wrapper._EnumTypeWrapper[_LocalCommsHealthReason.ValueType], _builtins.type):
DESCRIPTOR: _descriptor.EnumDescriptor
LOCAL_COMMS_HEALTH_REASON_UNSPECIFIED: _LocalCommsHealthReason.ValueType # 0
LOCAL_COMMS_HEALTH_REASON_HEALTHY: _LocalCommsHealthReason.ValueType # 1
LOCAL_COMMS_HEALTH_REASON_BOOT_OR_WARMUP: _LocalCommsHealthReason.ValueType # 2
LOCAL_COMMS_HEALTH_REASON_ZENOH_SESSION_DOWN: _LocalCommsHealthReason.ValueType # 3
LOCAL_COMMS_HEALTH_REASON_NO_PEERS_VISIBLE: _LocalCommsHealthReason.ValueType # 4
LOCAL_COMMS_HEALTH_REASON_PARTIAL_VISIBILITY: _LocalCommsHealthReason.ValueType # 5
LOCAL_COMMS_HEALTH_REASON_NO_MESSAGES_RECEIVED: _LocalCommsHealthReason.ValueType # 6
LOCAL_COMMS_HEALTH_REASON_SESSION_INSTABILITY: _LocalCommsHealthReason.ValueType # 7
LOCAL_COMMS_HEALTH_REASON_NO_ALIVE_PEERS: _LocalCommsHealthReason.ValueType # 8
LOCAL_COMMS_HEALTH_REASON_NO_PEERS_EXPECTED: _LocalCommsHealthReason.ValueType # 9
LOCAL_COMMS_HEALTH_REASON_CREDENTIALS_NOT_READY: _LocalCommsHealthReason.ValueType # 10
LOCAL_COMMS_HEALTH_REASON_CREDENTIALS_EXPIRED: _LocalCommsHealthReason.ValueType # 11

class LocalCommsHealthReason(_LocalCommsHealthReason, metaclass=_LocalCommsHealthReasonEnumTypeWrapper):
"""Diagnostic reason accompanying LocalCommsHealthStatus.
APK-confirmed (com.quilt.android 1.0.29, enum class m62). The local mesh is
Eclipse Zenoh-based (see ZENOH_SESSION_DOWN), not NATS.
"""

LOCAL_COMMS_HEALTH_REASON_UNSPECIFIED: LocalCommsHealthReason.ValueType # 0
LOCAL_COMMS_HEALTH_REASON_HEALTHY: LocalCommsHealthReason.ValueType # 1
LOCAL_COMMS_HEALTH_REASON_BOOT_OR_WARMUP: LocalCommsHealthReason.ValueType # 2
LOCAL_COMMS_HEALTH_REASON_ZENOH_SESSION_DOWN: LocalCommsHealthReason.ValueType # 3
LOCAL_COMMS_HEALTH_REASON_NO_PEERS_VISIBLE: LocalCommsHealthReason.ValueType # 4
LOCAL_COMMS_HEALTH_REASON_PARTIAL_VISIBILITY: LocalCommsHealthReason.ValueType # 5
LOCAL_COMMS_HEALTH_REASON_NO_MESSAGES_RECEIVED: LocalCommsHealthReason.ValueType # 6
LOCAL_COMMS_HEALTH_REASON_SESSION_INSTABILITY: LocalCommsHealthReason.ValueType # 7
LOCAL_COMMS_HEALTH_REASON_NO_ALIVE_PEERS: LocalCommsHealthReason.ValueType # 8
LOCAL_COMMS_HEALTH_REASON_NO_PEERS_EXPECTED: LocalCommsHealthReason.ValueType # 9
LOCAL_COMMS_HEALTH_REASON_CREDENTIALS_NOT_READY: LocalCommsHealthReason.ValueType # 10
LOCAL_COMMS_HEALTH_REASON_CREDENTIALS_EXPIRED: LocalCommsHealthReason.ValueType # 11
Global___LocalCommsHealthReason: _TypeAlias = LocalCommsHealthReason # noqa: Y015

class _OccupancyMode:
ValueType = _typing.NewType("ValueType", _builtins.int)
V: _TypeAlias = ValueType # noqa: Y015
Expand Down Expand Up @@ -159,7 +198,7 @@ class _SafetyHeatingModeEnumTypeWrapper(_enum_type_wrapper._EnumTypeWrapper[_Saf
class SafetyHeatingMode(_SafetyHeatingMode, metaclass=_SafetyHeatingModeEnumTypeWrapper):
"""Freeze-protection setting on SpaceSettings (field 9).
UNSPECIFIED is treated as ENABLED by the app (freeze protection on by default).
Field number unconfirmed (assumed f9; added post-initial release).
APK-confirmed field 9 (com.quilt.android 1.0.29, message class ea2).
"""

SAFETY_HEATING_MODE_UNSPECIFIED: SafetyHeatingMode.ValueType # 0
Expand Down Expand Up @@ -507,48 +546,48 @@ Global___WifiConnectionState: _TypeAlias = WifiConnectionState # noqa: Y015

@_typing.final
class LocalCommsStatus(_message.Message):
"""Local communications health status for a QSM or Controller mesh node.
"""Local communications status for a QSM or Controller mesh node.
Carried as field 8 on QuiltSmartModule and field 9 on Controller.
The `health` subfield (2) is the primary value; subfields 3, 4, 6 semantics TBD.
Field numbers/semantics APK-confirmed (1.0.29, message class l62).
"""

DESCRIPTOR: _descriptor.Descriptor

UPDATED_TS_FIELD_NUMBER: _builtins.int
HEALTH_FIELD_NUMBER: _builtins.int
LINK_STATE_FIELD_NUMBER: _builtins.int
VERSION_FIELD_NUMBER: _builtins.int
HEALTH_CHANGED_TS_FIELD_NUMBER: _builtins.int
CONNECTION_STATE_FIELD_NUMBER: _builtins.int
health: Global___LocalCommsHealthStatus.ValueType
"""current health"""
link_state: _builtins.int
"""observed: HEALTHY=9, DEGRADED=8; enum TBD"""
version: _builtins.int
"""observed: always 9"""
connection_state: _builtins.int
"""observed: HEALTHY=1, DEGRADED=5; enum TBD"""
STATUS_FIELD_NUMBER: _builtins.int
VISIBLE_DEVICES_COUNT_FIELD_NUMBER: _builtins.int
EXPECTED_DEVICES_COUNT_FIELD_NUMBER: _builtins.int
LAST_SESSION_CHANGE_TS_FIELD_NUMBER: _builtins.int
REASON_FIELD_NUMBER: _builtins.int
status: Global___LocalCommsHealthStatus.ValueType
"""current health status"""
visible_devices_count: _builtins.int
"""mesh peers currently visible"""
expected_devices_count: _builtins.int
"""mesh peers expected on this system"""
reason: Global___LocalCommsHealthReason.ValueType
"""diagnostic reason for current status"""
@_builtins.property
def updated_ts(self) -> _timestamp_pb2.Timestamp:
"""most-recent server update timestamp"""

@_builtins.property
def health_changed_ts(self) -> _timestamp_pb2.Timestamp:
"""when health last changed"""
def last_session_change_ts(self) -> _timestamp_pb2.Timestamp:
"""when the mesh session last changed"""

def __init__(
self,
*,
updated_ts: _timestamp_pb2.Timestamp | None = ...,
health: Global___LocalCommsHealthStatus.ValueType = ...,
link_state: _builtins.int = ...,
version: _builtins.int = ...,
health_changed_ts: _timestamp_pb2.Timestamp | None = ...,
connection_state: _builtins.int = ...,
status: Global___LocalCommsHealthStatus.ValueType = ...,
visible_devices_count: _builtins.int = ...,
expected_devices_count: _builtins.int = ...,
last_session_change_ts: _timestamp_pb2.Timestamp | None = ...,
reason: Global___LocalCommsHealthReason.ValueType = ...,
) -> None: ...
_HasFieldArgType: _TypeAlias = _typing.Literal["health_changed_ts", b"health_changed_ts", "updated_ts", b"updated_ts"] # noqa: Y015
_HasFieldArgType: _TypeAlias = _typing.Literal["last_session_change_ts", b"last_session_change_ts", "updated_ts", b"updated_ts"] # noqa: Y015
def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ...
_ClearFieldArgType: _TypeAlias = _typing.Literal["connection_state", b"connection_state", "health", b"health", "health_changed_ts", b"health_changed_ts", "link_state", b"link_state", "updated_ts", b"updated_ts", "version", b"version"] # noqa: Y015
_ClearFieldArgType: _TypeAlias = _typing.Literal["expected_devices_count", b"expected_devices_count", "last_session_change_ts", b"last_session_change_ts", "reason", b"reason", "status", b"status", "updated_ts", b"updated_ts", "visible_devices_count", b"visible_devices_count"] # noqa: Y015
def ClearField(self, field_name: _ClearFieldArgType) -> None: ...
def WhichOneof(self, oneof_group: _Never) -> None: ...

Expand Down Expand Up @@ -725,7 +764,7 @@ class SpaceSettings(_message.Message):
f1=name, f2=description, f3=updated_ts, f4=timezone, f5=OccupancyMode,
f6=HvacControllerType, f7=occupied_timeout_s(180.0), f8=unoccupied_timeout_s(1200.0)
Wire-confirmed: fields 1–8.
f9=SafetyHeatingMode (freeze protection): field number unconfirmed (assumed f9).
f9=SafetyHeatingMode (freeze protection): APK-confirmed field 9 (1.0.29, class ea2).
"""

DESCRIPTOR: _descriptor.Descriptor
Expand Down Expand Up @@ -754,7 +793,7 @@ class SpaceSettings(_message.Message):
unoccupied_timeout_s: _builtins.float
"""seconds of no-presence before "away"; default 1200s (20 min)"""
safety_heating: Global___SafetyHeatingMode.ValueType
"""freeze protection; UNSPECIFIED treated as ENABLED; field# unconfirmed"""
"""freeze protection; UNSPECIFIED treated as ENABLED; APK-confirmed f9"""
@_builtins.property
def updated_ts(self) -> _timestamp_pb2.Timestamp:
"""wire-confirmed: f3"""
Expand Down Expand Up @@ -1180,6 +1219,7 @@ class IndoorUnitConditions(_message.Message):
COIL_PREHEAT_FIELD_NUMBER: _builtins.int
MODE_CONFLICT_AVOIDANCE_FIELD_NUMBER: _builtins.int
OUTDOOR_UNIT_COMMUNICATION_ERROR_FIELD_NUMBER: _builtins.int
COMPRESSOR_MINIMUM_RUN_TIME_FIELD_NUMBER: _builtins.int
mode_conflict: Global___ConditionState.ValueType
anti_cold_wind: Global___ConditionState.ValueType
abnormal_outdoor_air_temperature: Global___ConditionState.ValueType
Expand All @@ -1191,6 +1231,8 @@ class IndoorUnitConditions(_message.Message):
coil_preheat: Global___ConditionState.ValueType
mode_conflict_avoidance: Global___ConditionState.ValueType
outdoor_unit_communication_error: Global___ConditionState.ValueType
compressor_minimum_run_time: Global___ConditionState.ValueType
"""APK-confirmed 1.0.29 (enum class l02)"""
@_builtins.property
def updated_ts(self) -> _timestamp_pb2.Timestamp: ...
def __init__(
Expand All @@ -1208,10 +1250,11 @@ class IndoorUnitConditions(_message.Message):
coil_preheat: Global___ConditionState.ValueType = ...,
mode_conflict_avoidance: Global___ConditionState.ValueType = ...,
outdoor_unit_communication_error: Global___ConditionState.ValueType = ...,
compressor_minimum_run_time: Global___ConditionState.ValueType = ...,
) -> None: ...
_HasFieldArgType: _TypeAlias = _typing.Literal["updated_ts", b"updated_ts"] # noqa: Y015
def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ...
_ClearFieldArgType: _TypeAlias = _typing.Literal["abnormal_outdoor_air_temperature", b"abnormal_outdoor_air_temperature", "anti_cold_wind", b"anti_cold_wind", "coil_preheat", b"coil_preheat", "defrost_cycle", b"defrost_cycle", "hvac_mode_switching_delay", b"hvac_mode_switching_delay", "modbus_communication_error", b"modbus_communication_error", "mode_conflict", b"mode_conflict", "mode_conflict_avoidance", b"mode_conflict_avoidance", "oil_return", b"oil_return", "outdoor_unit_communication_error", b"outdoor_unit_communication_error", "safety_heating", b"safety_heating", "updated_ts", b"updated_ts"] # noqa: Y015
_ClearFieldArgType: _TypeAlias = _typing.Literal["abnormal_outdoor_air_temperature", b"abnormal_outdoor_air_temperature", "anti_cold_wind", b"anti_cold_wind", "coil_preheat", b"coil_preheat", "compressor_minimum_run_time", b"compressor_minimum_run_time", "defrost_cycle", b"defrost_cycle", "hvac_mode_switching_delay", b"hvac_mode_switching_delay", "modbus_communication_error", b"modbus_communication_error", "mode_conflict", b"mode_conflict", "mode_conflict_avoidance", b"mode_conflict_avoidance", "oil_return", b"oil_return", "outdoor_unit_communication_error", b"outdoor_unit_communication_error", "safety_heating", b"safety_heating", "updated_ts", b"updated_ts"] # noqa: Y015
def ClearField(self, field_name: _ClearFieldArgType) -> None: ...
def WhichOneof(self, oneof_group: _Never) -> None: ...

Expand Down
2 changes: 2 additions & 0 deletions src/quilt_hp/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
HVACState,
LedAnimation,
LightPreset,
LocalCommsHealthReason,
LocalCommsHealthStatus,
LouverAngle,
LouverMode,
Expand Down Expand Up @@ -64,6 +65,7 @@
"IndoorUnitState",
"LedAnimation",
"LightPreset",
"LocalCommsHealthReason",
"LocalCommsHealthStatus",
"Location",
"LouverAngle",
Expand Down
29 changes: 26 additions & 3 deletions src/quilt_hp/models/_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@
from quilt_hp.const import PROTO_TIMESTAMP_UNSET_SECONDS


def local_comms_last_session_change(local_comms_status: object) -> datetime | None:
"""Return ``LocalCommsStatus.last_session_change_ts`` as an aware datetime.

Returns ``None`` when the status message or timestamp is absent.
"""
ts = getattr(local_comms_status, "last_session_change_ts", None)
seconds = getattr(ts, "seconds", 0) if ts is not None else 0
if not seconds:
return None
return datetime.fromtimestamp(seconds, tz=UTC)


def proto_has_field(proto: object, name: str) -> bool:
"""Return True when a message-typed field is present on the wire.

Expand Down Expand Up @@ -84,9 +96,20 @@ def lookup_hardware(hw_map: dict[str, object], hardware_id: str | None) -> objec
return None


def parse_wifi_state(proto: object) -> tuple[str | None, str | None, int | None]:
"""Extract WiFi fields while preserving explicit zero signal values."""
def parse_wifi_state(
proto: object,
) -> tuple[str | None, str | None, int | None, str | None, int | None]:
"""Extract WiFi fields while preserving explicit zero signal values.

Returns ``(ssid, ip, signal_dbm, bssid, frequency_mhz)``. ``bssid`` and
``frequency_mhz`` are wire-present on the shared ``WifiState`` message
used by both QSM and Controller hosted-wifi state (confirmed via raw
proto capture 2026-07-03) and identify which physical AP radio/band a
device is associated with.
"""
ssid = getattr(proto, "ssid", "") or None
ip = getattr(proto, "ipv4_address", None) or None
signal = getattr(proto, "signal_level_dbm", None)
return ssid, ip, signal if signal is not None else None
bssid = getattr(proto, "bssid", "") or None
frequency_mhz = getattr(proto, "frequency_mhz", None) or None
return ssid, ip, (signal if signal is not None else None), bssid, frequency_mhz
Loading