sofapython 0.0.1rc1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sofapython/__init__.py +136 -0
- sofapython/ack.py +79 -0
- sofapython/aio.py +552 -0
- sofapython/backup_export.py +507 -0
- sofapython/blob_decoders.py +806 -0
- sofapython/cli.py +447 -0
- sofapython/commands.py +1273 -0
- sofapython/deframer.py +73 -0
- sofapython/device_create.py +1174 -0
- sofapython/devices.py +534 -0
- sofapython/discovery.py +315 -0
- sofapython/frame_handlers.py +131 -0
- sofapython/hub_listener.py +242 -0
- sofapython/hub_logging.py +152 -0
- sofapython/hub_versions.py +112 -0
- sofapython/inputs.py +501 -0
- sofapython/macros.py +669 -0
- sofapython/notify_demuxer.py +434 -0
- sofapython/opcode_handlers.py +1655 -0
- sofapython/protocol_const.py +633 -0
- sofapython/proxy_ack_waiters.py +660 -0
- sofapython/proxy_activity_ops.py +943 -0
- sofapython/proxy_backup.py +504 -0
- sofapython/proxy_backup_export.py +486 -0
- sofapython/proxy_catalog.py +915 -0
- sofapython/proxy_frame_decode.py +227 -0
- sofapython/proxy_ir_blob.py +676 -0
- sofapython/proxy_restore.py +2004 -0
- sofapython/proxy_wifi_device.py +1101 -0
- sofapython/state_helpers.py +713 -0
- sofapython/transport_bridge.py +876 -0
- sofapython/version.py +4 -0
- sofapython/wire_schema.py +164 -0
- sofapython/x1_proxy.py +1833 -0
- sofapython-0.0.1rc1.dist-info/METADATA +162 -0
- sofapython-0.0.1rc1.dist-info/RECORD +39 -0
- sofapython-0.0.1rc1.dist-info/WHEEL +4 -0
- sofapython-0.0.1rc1.dist-info/entry_points.txt +2 -0
- sofapython-0.0.1rc1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# hub_logging.py — per-hub log prefixing and canonical subsystem tags.
|
|
2
|
+
# Library-side source of truth; the Home Assistant integration's
|
|
3
|
+
# logging_utils.py re-exports these names. Nothing here may import
|
|
4
|
+
# outside the library package.
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import re
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
_HUB_LOG_PREFIX_PATTERN = re.compile(r"^\[(?P<entry_id>[^\[\]]+)\]\s+")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class LogTag:
|
|
15
|
+
"""Canonical subsystem tags for log-message prefixes.
|
|
16
|
+
|
|
17
|
+
Single source of truth for the ``[TAG]`` token that follows the per-hub
|
|
18
|
+
``[entry_id]`` prefix added by :class:`HubLogger`. Every value is an
|
|
19
|
+
UPPERCASE, bracket-wrapped string so call sites can write the prefix
|
|
20
|
+
directly, e.g. ``log.debug("%s connected", LogTag.TRANSPORT)`` or
|
|
21
|
+
``log.debug(f"{LogTag.FRAME} ...")``.
|
|
22
|
+
|
|
23
|
+
Tags are deliberately UPPERCASE: :func:`extract_hub_log_entry_id` rejects
|
|
24
|
+
an all-caps leading bracket, so a tag is never mistaken for a hub entry id
|
|
25
|
+
when a line carries no ``[entry_id]`` prefix (such lines are treated as
|
|
26
|
+
globally relevant and shown in every hub's view).
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
# Transport / wire
|
|
30
|
+
TRANSPORT = "[TRANSPORT]" # socket bridge connect/disconnect/io (TCP/UDP)
|
|
31
|
+
SEND = "[SEND]" # outbound frame dispatch to the hub
|
|
32
|
+
WIRE = "[WIRE]" # full hex frame dumps (gated by the hex-logging switch)
|
|
33
|
+
FRAME = "[FRAME]" # decoded inbound/outbound frame summaries (family/role/paging)
|
|
34
|
+
PARSE = "[PARSE]" # frame-decode errors
|
|
35
|
+
|
|
36
|
+
# Lifecycle / identity
|
|
37
|
+
PROXY = "[PROXY]" # proxy lifecycle: enable/disable/stop/hex toggle
|
|
38
|
+
MDNS = "[MDNS]" # mDNS discovery / advertisement
|
|
39
|
+
HUB = "[HUB]" # hub identity / name
|
|
40
|
+
BANNER = "[BANNER]" # connect banner parsing
|
|
41
|
+
STATE = "[STATE]" # current-activity / connection state changes
|
|
42
|
+
|
|
43
|
+
# Command dispatch & acks
|
|
44
|
+
CMD = "[CMD]" # command queue / dispatch
|
|
45
|
+
ACK = "[ACK]" # ack waiters / STATUS_ACK classification
|
|
46
|
+
|
|
47
|
+
# Catalog read paths
|
|
48
|
+
CATALOG = "[CATALOG]" # device/activity/button/command catalog requests & parsing
|
|
49
|
+
MACRO = "[MACRO]" # macro page decode
|
|
50
|
+
REMOTE = "[REMOTE]" # remote sync/find, idle/device-control queries
|
|
51
|
+
|
|
52
|
+
# Write / mutation flows
|
|
53
|
+
CREATE = "[CREATE]" # device/activity create flow
|
|
54
|
+
RESTORE = "[RESTORE]" # restore flow
|
|
55
|
+
BACKUP = "[BACKUP]" # backup serialization / erase
|
|
56
|
+
WIFI = "[WIFI]" # wifi / virtual-ip device flow & per-step acks
|
|
57
|
+
IR = "[IR]" # IR blob play / persist
|
|
58
|
+
ACTIVITY = "[ACTIVITY]" # activity-assign, favorites, keymap-write ops
|
|
59
|
+
|
|
60
|
+
# Subsystems
|
|
61
|
+
DEMUX = "[DEMUX]" # CALL_ME notify demuxer
|
|
62
|
+
ROKU = "[ROKU]" # Roku ECP listener
|
|
63
|
+
HINT = "[HINT]" # diagnostic hints
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def all(cls) -> tuple[str, ...]:
|
|
67
|
+
"""Return every registered tag value."""
|
|
68
|
+
|
|
69
|
+
return tuple(
|
|
70
|
+
value
|
|
71
|
+
for name, value in vars(cls).items()
|
|
72
|
+
if not name.startswith("_") and isinstance(value, str)
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def extract_hub_log_entry_id(message: str) -> str | None:
|
|
77
|
+
"""Return the canonical hub entry id from the start of a log message."""
|
|
78
|
+
|
|
79
|
+
match = _HUB_LOG_PREFIX_PATTERN.match(str(message or ""))
|
|
80
|
+
if not match:
|
|
81
|
+
return None
|
|
82
|
+
candidate = str(match.group("entry_id") or "").strip()
|
|
83
|
+
if re.fullmatch(r"[A-Z_]+", candidate):
|
|
84
|
+
return None
|
|
85
|
+
return candidate or None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def format_hub_log_message(entry_id: str, message: str) -> str:
|
|
89
|
+
"""Prepend the canonical hub prefix unless it is already present."""
|
|
90
|
+
|
|
91
|
+
text = str(message or "")
|
|
92
|
+
normalized_entry_id = str(entry_id or "").strip()
|
|
93
|
+
if not normalized_entry_id:
|
|
94
|
+
return text
|
|
95
|
+
|
|
96
|
+
existing_entry_id = extract_hub_log_entry_id(text)
|
|
97
|
+
if existing_entry_id == normalized_entry_id:
|
|
98
|
+
return text
|
|
99
|
+
|
|
100
|
+
return f"[{normalized_entry_id}] {text}"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class HubLogger:
|
|
104
|
+
"""Prefix log lines with the canonical hub entry id."""
|
|
105
|
+
|
|
106
|
+
def __init__(self, logger: logging.Logger, entry_id: str) -> None:
|
|
107
|
+
self._logger = logger
|
|
108
|
+
self._entry_id = str(entry_id or "").strip()
|
|
109
|
+
|
|
110
|
+
def isEnabledFor(self, level: int) -> bool:
|
|
111
|
+
return self._logger.isEnabledFor(level)
|
|
112
|
+
|
|
113
|
+
def log(self, level: int, message: str, *args: Any, **kwargs: Any) -> None:
|
|
114
|
+
normalized_message = str(message or "")
|
|
115
|
+
normalized_args = args
|
|
116
|
+
|
|
117
|
+
# Preserve existing call sites that still use "[%s] ..." with the
|
|
118
|
+
# entry id as the first formatting argument.
|
|
119
|
+
if (
|
|
120
|
+
normalized_message.startswith("[%s] ")
|
|
121
|
+
and normalized_args
|
|
122
|
+
and str(normalized_args[0] or "").strip() == self._entry_id
|
|
123
|
+
):
|
|
124
|
+
normalized_message = normalized_message[5:]
|
|
125
|
+
normalized_args = normalized_args[1:]
|
|
126
|
+
|
|
127
|
+
self._logger.log(
|
|
128
|
+
level,
|
|
129
|
+
format_hub_log_message(self._entry_id, normalized_message),
|
|
130
|
+
*normalized_args,
|
|
131
|
+
**kwargs,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
def debug(self, message: str, *args: Any, **kwargs: Any) -> None:
|
|
135
|
+
self.log(logging.DEBUG, message, *args, **kwargs)
|
|
136
|
+
|
|
137
|
+
def info(self, message: str, *args: Any, **kwargs: Any) -> None:
|
|
138
|
+
self.log(logging.INFO, message, *args, **kwargs)
|
|
139
|
+
|
|
140
|
+
def warning(self, message: str, *args: Any, **kwargs: Any) -> None:
|
|
141
|
+
self.log(logging.WARNING, message, *args, **kwargs)
|
|
142
|
+
|
|
143
|
+
def error(self, message: str, *args: Any, **kwargs: Any) -> None:
|
|
144
|
+
self.log(logging.ERROR, message, *args, **kwargs)
|
|
145
|
+
|
|
146
|
+
def exception(self, message: str, *args: Any, **kwargs: Any) -> None:
|
|
147
|
+
kwargs.setdefault("exc_info", True)
|
|
148
|
+
self.log(logging.ERROR, message, *args, **kwargs)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def get_hub_logger(logger: logging.Logger, entry_id: str) -> HubLogger:
|
|
152
|
+
return HubLogger(logger, entry_id)
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# hub_versions.py — hub-variant classification and shared protocol-level
|
|
2
|
+
# constants. This module is the library-side source of truth; the Home
|
|
3
|
+
# Assistant integration's const.py re-exports these names for its own
|
|
4
|
+
# call sites. Nothing here may import outside the library package.
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
MDNS_SERVICE_TYPES: tuple[str, ...] = (
|
|
8
|
+
"_x1hub._udp.local.",
|
|
9
|
+
"_sofabaton_hub._udp.local.",
|
|
10
|
+
)
|
|
11
|
+
MDNS_SERVICE_TYPE_X1 = MDNS_SERVICE_TYPES[0]
|
|
12
|
+
MDNS_SERVICE_TYPE_X2 = MDNS_SERVICE_TYPES[1]
|
|
13
|
+
|
|
14
|
+
# Hub version classification
|
|
15
|
+
HVER_X1 = "1"
|
|
16
|
+
HVER_X1S = "2"
|
|
17
|
+
HVER_X2 = "3"
|
|
18
|
+
|
|
19
|
+
HUB_VERSION_X1 = "X1"
|
|
20
|
+
HUB_VERSION_X1S = "X1S"
|
|
21
|
+
HUB_VERSION_X2 = "X2"
|
|
22
|
+
|
|
23
|
+
# Backup-format schema versions. These gate restore: a payload whose
|
|
24
|
+
# schema_version does not match is rejected so the slim, hand-editable
|
|
25
|
+
# format stays an exact contract (no silent reads of a stale verbose
|
|
26
|
+
# dump). Bump the matching constant whenever the corresponding export
|
|
27
|
+
# shape changes, and update the restore gate + fixtures together.
|
|
28
|
+
DEVICE_BACKUP_SCHEMA_VERSION = 4
|
|
29
|
+
ACTIVITY_BACKUP_SCHEMA_VERSION = 4
|
|
30
|
+
HUB_BUNDLE_SCHEMA_VERSION = 5
|
|
31
|
+
|
|
32
|
+
HUB_VERSION_BY_HVER = {
|
|
33
|
+
HVER_X1: HUB_VERSION_X1,
|
|
34
|
+
HVER_X1S: HUB_VERSION_X1S,
|
|
35
|
+
HVER_X2: HUB_VERSION_X2,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
HVER_BY_HUB_VERSION = {
|
|
39
|
+
HUB_VERSION_X1: HVER_X1,
|
|
40
|
+
HUB_VERSION_X1S: HVER_X1S,
|
|
41
|
+
HUB_VERSION_X2: HVER_X2,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
MDNS_SERVICE_TYPE_BY_VERSION = {
|
|
45
|
+
HUB_VERSION_X1: MDNS_SERVICE_TYPE_X1,
|
|
46
|
+
HUB_VERSION_X1S: MDNS_SERVICE_TYPE_X1,
|
|
47
|
+
# X2 hubs continue to use the legacy _x1hub._udp.local. advertisement for compatibility
|
|
48
|
+
HUB_VERSION_X2: MDNS_SERVICE_TYPE_X1,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
DEFAULT_PROXY_UDP_PORT = 8102
|
|
52
|
+
DEFAULT_HUB_LISTEN_BASE = 8200
|
|
53
|
+
|
|
54
|
+
# TXT record marker carried by proxy advertisements so discovery can
|
|
55
|
+
# tell a proxy apart from a physical hub. The key spells "HA_PROXY" for
|
|
56
|
+
# historical reasons (the Home Assistant integration shipped it first);
|
|
57
|
+
# renaming would orphan existing installs, so it stays vendor-named.
|
|
58
|
+
PROXY_TXT_KEY = "HA_PROXY"
|
|
59
|
+
PROXY_TXT_VALUE = "1"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def is_proxy_advertisement(props: dict[str, str]) -> bool:
|
|
63
|
+
"""True when TXT properties mark the advertiser as one of our proxies."""
|
|
64
|
+
|
|
65
|
+
return props.get(PROXY_TXT_KEY) == PROXY_TXT_VALUE
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def classify_hub_version(props: dict[str, str]) -> str:
|
|
69
|
+
"""Determine hub version from advertised mDNS / banner properties.
|
|
70
|
+
|
|
71
|
+
Raises :class:`ValueError` when ``props`` carries no ``HVER`` key
|
|
72
|
+
or its value does not map to a known hub line. The integration
|
|
73
|
+
deliberately refuses to default to a previously-known variant in
|
|
74
|
+
that case: a missing or unfamiliar advertisement signals either an
|
|
75
|
+
upstream firmware change or a misconfigured manual entry, and
|
|
76
|
+
silently inheriting the X1 layout would corrupt every write to
|
|
77
|
+
that hub. Callers that cannot guarantee an ``HVER`` (e.g. fully
|
|
78
|
+
manual entry before first connect) must pick a known variant
|
|
79
|
+
explicitly rather than relying on this helper.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
hver = props.get("HVER")
|
|
83
|
+
if hver is None:
|
|
84
|
+
raise ValueError(
|
|
85
|
+
"classify_hub_version: advertisement is missing HVER; "
|
|
86
|
+
"cannot identify hub variant."
|
|
87
|
+
)
|
|
88
|
+
version = HUB_VERSION_BY_HVER.get(str(hver).strip())
|
|
89
|
+
if not version:
|
|
90
|
+
known = ", ".join(sorted(HUB_VERSION_BY_HVER))
|
|
91
|
+
raise ValueError(
|
|
92
|
+
f"classify_hub_version: unknown HVER={hver!r}; "
|
|
93
|
+
f"expected one of {known}."
|
|
94
|
+
)
|
|
95
|
+
return version
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def mdns_service_type_for_props(props: dict[str, str]) -> str:
|
|
99
|
+
"""Map hub properties to the correct mDNS service type.
|
|
100
|
+
|
|
101
|
+
The integration advertises the same service type for every known
|
|
102
|
+
variant, so an unclassifiable advertisement falls back to the
|
|
103
|
+
shared narrow-line type rather than refusing to advertise -- the
|
|
104
|
+
transport envelope is byte-compatible across the family and the
|
|
105
|
+
connect banner reclassifies authoritatively.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
version = classify_hub_version(props)
|
|
110
|
+
except ValueError:
|
|
111
|
+
return MDNS_SERVICE_TYPE_X1
|
|
112
|
+
return MDNS_SERVICE_TYPE_BY_VERSION.get(version, MDNS_SERVICE_TYPE_X1)
|