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.
@@ -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)