python-mobius 0.1.4__py3-none-any.whl → 0.2.0__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.
- mobius/__init__.py +21 -5
- mobius/cli.py +269 -1
- mobius/coap.py +253 -0
- mobius/constants.py +17 -0
- mobius/device.py +265 -7
- mobius/device_status.py +45 -0
- mobius/discovery.py +68 -0
- mobius/mesh_address.py +116 -0
- mobius/relay.py +289 -0
- {python_mobius-0.1.4.dist-info → python_mobius-0.2.0.dist-info}/METADATA +1 -1
- python_mobius-0.2.0.dist-info/RECORD +21 -0
- python_mobius-0.1.4.dist-info/RECORD +0 -18
- {python_mobius-0.1.4.dist-info → python_mobius-0.2.0.dist-info}/WHEEL +0 -0
- {python_mobius-0.1.4.dist-info → python_mobius-0.2.0.dist-info}/entry_points.txt +0 -0
- {python_mobius-0.1.4.dist-info → python_mobius-0.2.0.dist-info}/licenses/LICENSE +0 -0
mobius/__init__.py
CHANGED
|
@@ -41,17 +41,27 @@ from .modifiers import (
|
|
|
41
41
|
)
|
|
42
42
|
from .power import ChannelPowerInfo, channel_percent_value
|
|
43
43
|
from .pump_status import PumpFlowRange, BatteryBackupInfo, BoostedBatteryInfo
|
|
44
|
-
from .device_status import GroupInfo, CalibrationInfo, MaintenanceInfo
|
|
44
|
+
from .device_status import GroupInfo, CalibrationInfo, MaintenanceInfo, DeviceTimeInfo, MeshPeer
|
|
45
|
+
from .coap import (
|
|
46
|
+
CoapRequestType, CoapMethod, CoapResponseCode, CoapResponse,
|
|
47
|
+
encode_coap_request, decode_coap_response, COAP_OPCODE, COAP_INDICATION_OPCODE,
|
|
48
|
+
next_coap_token,
|
|
49
|
+
)
|
|
50
|
+
from .mesh_address import (
|
|
51
|
+
mesh_local_prefix_from_own_address, is_valid_mesh_address,
|
|
52
|
+
build_rloc_address, extract_short_address, is_short_address_derived,
|
|
53
|
+
)
|
|
54
|
+
from .relay import RelayedMobiusDevice
|
|
45
55
|
from .device import (
|
|
46
56
|
MobiusDevice, MobiusPump, LightIntensityResult,
|
|
47
57
|
SERVICE_GENERAL, CHAR_RX_DATA, CHAR_RX_FINAL, CHAR_TX_DATA, CHAR_TX_FINAL,
|
|
48
58
|
)
|
|
49
59
|
from .discovery import (
|
|
50
60
|
scan_for_mobius_devices, scan_for_mobius_devices_with_info, group_by_pan_id,
|
|
51
|
-
dedupe_by_serial, find_device_by_serial,
|
|
61
|
+
dedupe_by_serial, find_device_by_serial, discover_mesh_peers_via_direct_connect,
|
|
52
62
|
)
|
|
53
63
|
|
|
54
|
-
__version__ = "0.
|
|
64
|
+
__version__ = "0.2.0"
|
|
55
65
|
|
|
56
66
|
__all__ = [
|
|
57
67
|
"__version__",
|
|
@@ -87,11 +97,17 @@ __all__ = [
|
|
|
87
97
|
# pump_status
|
|
88
98
|
"PumpFlowRange", "BatteryBackupInfo", "BoostedBatteryInfo",
|
|
89
99
|
# device_status
|
|
90
|
-
"GroupInfo", "CalibrationInfo", "MaintenanceInfo",
|
|
100
|
+
"GroupInfo", "CalibrationInfo", "MaintenanceInfo", "DeviceTimeInfo", "MeshPeer",
|
|
101
|
+
"CoapRequestType", "CoapMethod", "CoapResponseCode", "CoapResponse",
|
|
102
|
+
"encode_coap_request", "decode_coap_response", "COAP_OPCODE", "COAP_INDICATION_OPCODE",
|
|
103
|
+
"next_coap_token",
|
|
104
|
+
"mesh_local_prefix_from_own_address", "is_valid_mesh_address",
|
|
105
|
+
"build_rloc_address", "extract_short_address", "is_short_address_derived",
|
|
106
|
+
"RelayedMobiusDevice",
|
|
91
107
|
# device
|
|
92
108
|
"MobiusDevice", "MobiusPump", "LightIntensityResult",
|
|
93
109
|
"SERVICE_GENERAL", "CHAR_RX_DATA", "CHAR_RX_FINAL", "CHAR_TX_DATA", "CHAR_TX_FINAL",
|
|
94
110
|
# discovery
|
|
95
111
|
"scan_for_mobius_devices", "scan_for_mobius_devices_with_info", "group_by_pan_id",
|
|
96
|
-
"dedupe_by_serial", "find_device_by_serial",
|
|
112
|
+
"dedupe_by_serial", "find_device_by_serial", "discover_mesh_peers_via_direct_connect",
|
|
97
113
|
]
|
mobius/cli.py
CHANGED
|
@@ -11,10 +11,150 @@ import argparse
|
|
|
11
11
|
import asyncio
|
|
12
12
|
|
|
13
13
|
from .device import MobiusDevice
|
|
14
|
+
from .relay import RelayedMobiusDevice
|
|
15
|
+
from .device_status import MeshPeer
|
|
16
|
+
from .mesh_address import extract_short_address
|
|
14
17
|
from .discovery import (
|
|
15
18
|
scan_for_mobius_devices_with_info, group_by_pan_id, dedupe_by_serial,
|
|
16
|
-
find_device_by_serial,
|
|
19
|
+
find_device_by_serial, discover_mesh_peers_via_direct_connect,
|
|
17
20
|
)
|
|
21
|
+
from .schedule import SchedulePoint
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _format_flags(point) -> str:
|
|
25
|
+
names = []
|
|
26
|
+
if point.has(SchedulePoint.FLAG_NIGHT):
|
|
27
|
+
names.append("NIGHT")
|
|
28
|
+
if point.has(SchedulePoint.FLAG_SUNRISE):
|
|
29
|
+
names.append("SUNRISE")
|
|
30
|
+
if point.has(SchedulePoint.FLAG_SUNSET):
|
|
31
|
+
names.append("SUNSET")
|
|
32
|
+
return f" [{', '.join(names)}]" if names else ""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _format_time(minutes: int) -> str:
|
|
36
|
+
return f"{minutes // 60:02d}:{minutes % 60:02d}"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def _dump_schedule(device: MobiusDevice, support: str) -> None:
|
|
40
|
+
"""
|
|
41
|
+
Prints the FULL raw programmed schedule (every point, its time, its
|
|
42
|
+
flags, and its per-channel/mode values) -- not the interpolated
|
|
43
|
+
"current" value get_device_summary() already shows. Added specifically
|
|
44
|
+
so a schedule's actual point structure (needed to verify
|
|
45
|
+
is_night_segment()'s bracket-finding against real data) can be
|
|
46
|
+
inspected directly, instead of transcribed by hand from the app.
|
|
47
|
+
"""
|
|
48
|
+
print(" --- full schedule ---")
|
|
49
|
+
if support == "light":
|
|
50
|
+
points = await device.get_light_schedule(which=1)
|
|
51
|
+
for point in sorted(points, key=lambda p: p.time_minutes):
|
|
52
|
+
channels = ", ".join(
|
|
53
|
+
f"{vid.name}={val}"
|
|
54
|
+
for vid, val in sorted(point.light.channels.items(), key=lambda x: x[0].name)
|
|
55
|
+
)
|
|
56
|
+
print(f" {_format_time(point.time_minutes)}{_format_flags(point)}: {channels}")
|
|
57
|
+
|
|
58
|
+
lunar_enabled = await device.get_lunar_enabled()
|
|
59
|
+
insolation_active = await device.get_insolation_enabled()
|
|
60
|
+
schedule_intensity = await device.get_schedule_intensity()
|
|
61
|
+
acclimation = await device.get_acclimation_info()
|
|
62
|
+
print(f" lunar_enabled: {lunar_enabled}")
|
|
63
|
+
print(f" insolation_active: {insolation_active}")
|
|
64
|
+
print(f" schedule_intensity: {schedule_intensity}")
|
|
65
|
+
print(f" acclimation: {acclimation}")
|
|
66
|
+
|
|
67
|
+
elif support.startswith("pump"):
|
|
68
|
+
points = await device.get_pump_schedule(which=1)
|
|
69
|
+
for point in sorted(points, key=lambda p: p.time_minutes):
|
|
70
|
+
params = ", ".join(
|
|
71
|
+
f"{p.name}={v.hex() if isinstance(v, bytes) else (v.name if hasattr(v, 'name') else v)}"
|
|
72
|
+
for p, v in point.pump.params.items()
|
|
73
|
+
)
|
|
74
|
+
print(f" {_format_time(point.time_minutes)}{_format_flags(point)}: "
|
|
75
|
+
f"mode={point.pump.mode.name} {params}")
|
|
76
|
+
else:
|
|
77
|
+
print(f" (schedule dumping not supported for support={support!r})")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def _debug_mesh_discovery(device) -> list:
|
|
81
|
+
"""
|
|
82
|
+
Verbose, step-by-step version of discover_mesh_peers() for CLI
|
|
83
|
+
debugging. discover_mesh_peers() deliberately fails soft to an empty
|
|
84
|
+
list on ANY error -- the right behavior for production use (e.g.
|
|
85
|
+
ha-mobius shouldn't crash because a device doesn't support this), but
|
|
86
|
+
that makes it useless for diagnosing WHY no peers were found. This
|
|
87
|
+
surfaces each step's raw result or exception instead, and returns
|
|
88
|
+
whatever peers it manages to construct (same shape as
|
|
89
|
+
discover_mesh_peers()'s return value) so callers needing the actual
|
|
90
|
+
peer list (e.g. --relay-target) don't need a second, separate fetch.
|
|
91
|
+
"""
|
|
92
|
+
from .constants import C2Attribute, Model
|
|
93
|
+
from .mesh_address import is_valid_mesh_address, mesh_local_prefix_from_own_address, build_rloc_address
|
|
94
|
+
import struct
|
|
95
|
+
|
|
96
|
+
print(" Step 1: MeshLocalAddresses (1005)...")
|
|
97
|
+
try:
|
|
98
|
+
own_address_raw = await device.get_attribute(C2Attribute.MeshLocalAddresses)
|
|
99
|
+
except Exception as e:
|
|
100
|
+
print(f" FAILED: {e!r}")
|
|
101
|
+
print(" This attribute isn't supported at all by this device -- strong "
|
|
102
|
+
"evidence it doesn't run Thread (or at least doesn't expose this "
|
|
103
|
+
"attribute), which would mean relay isn't available on this hardware, "
|
|
104
|
+
"independent of anything about this implementation.")
|
|
105
|
+
return []
|
|
106
|
+
if not own_address_raw:
|
|
107
|
+
print(" Returned an EMPTY response (not an error, but no data either).")
|
|
108
|
+
return []
|
|
109
|
+
raw_addr = own_address_raw[0]
|
|
110
|
+
print(f" Raw response: {raw_addr.hex()} ({len(raw_addr)} bytes)")
|
|
111
|
+
if not is_valid_mesh_address(raw_addr):
|
|
112
|
+
print(" This is all-zero -- the attribute IS supported, but this device "
|
|
113
|
+
"isn't currently part of an active Thread network. Different from the "
|
|
114
|
+
"case above: this suggests Thread support exists but isn't active right "
|
|
115
|
+
"now, rather than not existing at all.")
|
|
116
|
+
return []
|
|
117
|
+
prefix = mesh_local_prefix_from_own_address(raw_addr)
|
|
118
|
+
print(f" Valid. Derived mesh-local prefix: {prefix.hex()}")
|
|
119
|
+
|
|
120
|
+
arrays = {}
|
|
121
|
+
for name, attr in [
|
|
122
|
+
("SerialNumberArray", C2Attribute.SerialNumberArray),
|
|
123
|
+
("DeviceModelArray", C2Attribute.DeviceModelArray),
|
|
124
|
+
("ShortAddressArray", C2Attribute.ShortAddressArray),
|
|
125
|
+
]:
|
|
126
|
+
print(f" Step 2: {name} ({attr.value})...")
|
|
127
|
+
try:
|
|
128
|
+
values = await device.get_attribute(attr, index=0, count=0xFFFF)
|
|
129
|
+
except Exception as e:
|
|
130
|
+
print(f" FAILED: {e!r}")
|
|
131
|
+
arrays[name] = []
|
|
132
|
+
continue
|
|
133
|
+
print(f" Returned {len(values)} element(s): {[v.hex() for v in values]}")
|
|
134
|
+
arrays[name] = values
|
|
135
|
+
|
|
136
|
+
peers = []
|
|
137
|
+
for serial_bytes, model_bytes, short_bytes in zip(
|
|
138
|
+
arrays.get("SerialNumberArray", []), arrays.get("DeviceModelArray", []),
|
|
139
|
+
arrays.get("ShortAddressArray", []),
|
|
140
|
+
):
|
|
141
|
+
try:
|
|
142
|
+
serial = serial_bytes.decode("ascii").rstrip("\x00")
|
|
143
|
+
except (UnicodeDecodeError, AttributeError):
|
|
144
|
+
continue
|
|
145
|
+
if not serial or serial == "00000000000000" or len(model_bytes) < 2 or len(short_bytes) < 2:
|
|
146
|
+
continue
|
|
147
|
+
model_raw = struct.unpack("<h", model_bytes[:2])[0]
|
|
148
|
+
try:
|
|
149
|
+
model = Model(model_raw)
|
|
150
|
+
except ValueError:
|
|
151
|
+
model = None
|
|
152
|
+
short_address = struct.unpack("<H", short_bytes[:2])[0]
|
|
153
|
+
peers.append(MeshPeer(
|
|
154
|
+
serial=serial, model_raw=model_raw, model=model,
|
|
155
|
+
short_address=short_address, address=build_rloc_address(prefix, short_address),
|
|
156
|
+
))
|
|
157
|
+
return peers
|
|
18
158
|
|
|
19
159
|
|
|
20
160
|
async def _run(args) -> None:
|
|
@@ -36,6 +176,70 @@ async def _run(args) -> None:
|
|
|
36
176
|
summary = await mdevice.get_device_summary()
|
|
37
177
|
for k, v in summary.items():
|
|
38
178
|
print(f" {k}: {v}")
|
|
179
|
+
if args.dump_schedule:
|
|
180
|
+
await _dump_schedule(mdevice, summary.get("support", ""))
|
|
181
|
+
|
|
182
|
+
if args.dump_mesh_peers or args.relay_target:
|
|
183
|
+
print("\n=== Thread mesh peer discovery ===")
|
|
184
|
+
peers = await _debug_mesh_discovery(mdevice)
|
|
185
|
+
if not peers:
|
|
186
|
+
print(" No peers found -- see the step-by-step output above for "
|
|
187
|
+
"exactly which stage this failed at, rather than just that it "
|
|
188
|
+
"failed. See documentation/09-thread-coap-relay.md.")
|
|
189
|
+
else:
|
|
190
|
+
print()
|
|
191
|
+
for peer in peers:
|
|
192
|
+
model_name = peer.model.name if peer.model else f"unknown({peer.model_raw})"
|
|
193
|
+
print(f" {peer.serial} model={model_name} "
|
|
194
|
+
f"short_address={peer.short_address:#06x} address={peer.address.hex()}")
|
|
195
|
+
|
|
196
|
+
if args.relay_target:
|
|
197
|
+
print(f"\n=== Relaying to {args.relay_target!r} through {args.by_serial!r} ===")
|
|
198
|
+
target_peer = next((p for p in peers if p.serial == args.relay_target), None)
|
|
199
|
+
if target_peer is None:
|
|
200
|
+
print(f" Not found via {args.by_serial!r}'s own peer-tracking (expected "
|
|
201
|
+
f"without a Cowboy hub) -- trying a brief direct connection to "
|
|
202
|
+
f"{args.relay_target!r} instead to learn its address...")
|
|
203
|
+
found_target = await find_device_by_serial(
|
|
204
|
+
args.relay_target, timeout=args.timeout, adapter=args.adapter,
|
|
205
|
+
)
|
|
206
|
+
if found_target is None:
|
|
207
|
+
print(f" No device currently advertising serial "
|
|
208
|
+
f"{args.relay_target!r} found.")
|
|
209
|
+
else:
|
|
210
|
+
target_device, target_info = found_target
|
|
211
|
+
target_address = None
|
|
212
|
+
try:
|
|
213
|
+
async with MobiusDevice(
|
|
214
|
+
target_device, connect_timeout=args.connect_timeout,
|
|
215
|
+
) as tdevice:
|
|
216
|
+
target_address = await tdevice.get_own_mesh_address()
|
|
217
|
+
except Exception as e:
|
|
218
|
+
print(f" Failed to connect directly to "
|
|
219
|
+
f"{args.relay_target!r}: {e}")
|
|
220
|
+
if target_address is None:
|
|
221
|
+
print(f" {args.relay_target!r} didn't report a valid mesh "
|
|
222
|
+
f"address -- cannot relay to it.")
|
|
223
|
+
else:
|
|
224
|
+
target_peer = MeshPeer(
|
|
225
|
+
serial=args.relay_target,
|
|
226
|
+
model_raw=target_info.model_raw if target_info else 0,
|
|
227
|
+
model=target_info.model if target_info else None,
|
|
228
|
+
short_address=extract_short_address(target_address),
|
|
229
|
+
address=target_address,
|
|
230
|
+
)
|
|
231
|
+
print(f" Learned address directly: {target_address.hex()}")
|
|
232
|
+
|
|
233
|
+
if target_peer is not None:
|
|
234
|
+
relayed = RelayedMobiusDevice(mdevice, target_peer, debug=args.debug_relay)
|
|
235
|
+
try:
|
|
236
|
+
relayed_summary = await relayed.get_device_summary()
|
|
237
|
+
for k, v in relayed_summary.items():
|
|
238
|
+
print(f" {k}: {v}")
|
|
239
|
+
print(f"\n Relay succeeded. Compare this against a direct "
|
|
240
|
+
f"`--by-serial {args.relay_target}` run to confirm they match.")
|
|
241
|
+
except Exception as e:
|
|
242
|
+
print(f" relay failed: {e}")
|
|
39
243
|
except Exception as e:
|
|
40
244
|
print(" failed to connect/read:", e)
|
|
41
245
|
return
|
|
@@ -64,6 +268,22 @@ async def _run(args) -> None:
|
|
|
64
268
|
if args.scan_only:
|
|
65
269
|
return
|
|
66
270
|
|
|
271
|
+
if args.build_peer_map:
|
|
272
|
+
print("\n=== Building mesh peer map via direct connect (no Cowboy hub needed) ===")
|
|
273
|
+
peers = await discover_mesh_peers_via_direct_connect(
|
|
274
|
+
found, adapter=args.adapter, connect_timeout=args.connect_timeout,
|
|
275
|
+
)
|
|
276
|
+
if not peers:
|
|
277
|
+
print(" No peers found -- none of the scanned devices reported a valid "
|
|
278
|
+
"mesh address when connected to directly. See documentation/"
|
|
279
|
+
"09-thread-coap-relay.md.")
|
|
280
|
+
else:
|
|
281
|
+
for peer in peers:
|
|
282
|
+
model_name = peer.model.name if peer.model else f"unknown({peer.model_raw})"
|
|
283
|
+
print(f" {peer.serial} model={model_name} "
|
|
284
|
+
f"short_address={peer.short_address:#06x} address={peer.address.hex()}")
|
|
285
|
+
return
|
|
286
|
+
|
|
67
287
|
print("\n=== Full device summaries (connects to each) ===")
|
|
68
288
|
for device, info in found:
|
|
69
289
|
print(f"--- {device.address} ({device.name}) ---")
|
|
@@ -94,6 +314,8 @@ async def _run(args) -> None:
|
|
|
94
314
|
summary = await mdevice.get_device_summary()
|
|
95
315
|
for k, v in summary.items():
|
|
96
316
|
print(f" {k}: {v}")
|
|
317
|
+
if args.dump_schedule:
|
|
318
|
+
await _dump_schedule(mdevice, summary.get("support", ""))
|
|
97
319
|
except Exception as e:
|
|
98
320
|
print(" failed to connect/read:", e)
|
|
99
321
|
|
|
@@ -117,6 +339,52 @@ def main() -> None:
|
|
|
117
339
|
"to whichever device is CURRENTLY advertising this serial number, "
|
|
118
340
|
"regardless of its BLE address. Useful for testing serial-based "
|
|
119
341
|
"reconnection against real hardware whose address may have changed.")
|
|
342
|
+
parser.add_argument("--dump-schedule", action="store_true",
|
|
343
|
+
help="Also print each connected device's FULL raw programmed schedule "
|
|
344
|
+
"(every point's time, flags, and per-channel/mode values) -- not "
|
|
345
|
+
"just the interpolated 'current' value get_device_summary() shows. "
|
|
346
|
+
"For lights, also prints lunar/insolation/schedule-intensity/"
|
|
347
|
+
"acclimation state. Useful for diagnosing a mismatch against the "
|
|
348
|
+
"app's own displayed value.")
|
|
349
|
+
parser.add_argument("--dump-mesh-peers", action="store_true",
|
|
350
|
+
help="Only usable with --by-serial. After connecting, calls "
|
|
351
|
+
"discover_mesh_peers() on it and prints every other device it "
|
|
352
|
+
"knows about over the Thread mesh (serial/model/short address/"
|
|
353
|
+
"IPv6) -- an empty result here is itself informative (this device "
|
|
354
|
+
"may not support Thread mesh at all, or isn't currently part of "
|
|
355
|
+
"an active one). See documentation/09-thread-coap-relay.md.")
|
|
356
|
+
parser.add_argument("--relay-target", default=None, metavar="SERIAL",
|
|
357
|
+
help="Only usable with --by-serial (the device you connect to becomes "
|
|
358
|
+
"the relay gateway). Tries to find this serial among the "
|
|
359
|
+
"gateway's own reported mesh peers first (requires a Cowboy hub); "
|
|
360
|
+
"if not found there, falls back to a brief separate direct "
|
|
361
|
+
"connection to the target itself to learn its address (no "
|
|
362
|
+
"Cowboy hub needed for this path -- confirmed via real hardware "
|
|
363
|
+
"testing to be necessary, since the peer-tracking attributes "
|
|
364
|
+
"are Cowboy-hub-specific). Either way, once an address is known, "
|
|
365
|
+
"relays a full get_device_summary() read to it via CoAP through "
|
|
366
|
+
"the gateway and prints the result -- run a separate direct "
|
|
367
|
+
"`--by-serial <this serial>` command to confirm the two match. "
|
|
368
|
+
"This is the actual validation this whole relay implementation "
|
|
369
|
+
"needs against real hardware.")
|
|
370
|
+
parser.add_argument("--debug-relay", action="store_true",
|
|
371
|
+
help="Only usable with --relay-target. Prints raw bytes at every "
|
|
372
|
+
"layer of each relayed request/response (outer frame, CoAP "
|
|
373
|
+
"envelope fields, inner frame, decoded attribute status/values), "
|
|
374
|
+
"plus every frame the gateway receives during the wait (both the "
|
|
375
|
+
"confirmed-meaningless opcode-25 ack and the real opcode-26 "
|
|
376
|
+
"indication, if it arrives). Useful for diagnosing a relay that "
|
|
377
|
+
"times out or returns unexpected data.")
|
|
378
|
+
parser.add_argument("--build-peer-map", action="store_true",
|
|
379
|
+
help="Instead of full device summaries, scan for devices then "
|
|
380
|
+
"briefly connect to EACH one in turn to build a mesh peer map "
|
|
381
|
+
"(serial/model/short address/IPv6) -- the alternative to "
|
|
382
|
+
"--dump-mesh-peers when there's no dedicated Cowboy hub device "
|
|
383
|
+
"(confirmed via real hardware testing to be required for that "
|
|
384
|
+
"attribute-array-based approach). This is the practical way to "
|
|
385
|
+
"learn every device's mesh address once, so you can later keep "
|
|
386
|
+
"only one connection open and relay to the rest. See "
|
|
387
|
+
"documentation/09-thread-coap-relay.md.")
|
|
120
388
|
args = parser.parse_args()
|
|
121
389
|
asyncio.run(_run(args))
|
|
122
390
|
|
mobius/coap.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CoAP (RFC 7252) protocol primitives, ported from com.c2.comm.Coap and
|
|
3
|
+
com.c2.comm.requests.coap.{CoapRequest,CoapFsciRequest} /
|
|
4
|
+
com.c2.comm.responses.coap.CoapResponse in the decompiled app.
|
|
5
|
+
|
|
6
|
+
## Why this exists
|
|
7
|
+
|
|
8
|
+
The official app doesn't maintain a direct BLE connection to every device
|
|
9
|
+
in a tank. When it needs to talk to a device it isn't directly connected
|
|
10
|
+
to, it wraps the request in CoAP and sends it through whichever device it
|
|
11
|
+
*is* connected to (the "gateway"), which forwards it over the underlying
|
|
12
|
+
Thread mesh network and relays the response back. Confirmed directly in
|
|
13
|
+
the decompiled source (`Comm.java`), not inferred:
|
|
14
|
+
|
|
15
|
+
if (commDevice.equals(request.getTarget())) {
|
|
16
|
+
// direct: send over the existing BLE connection as-is
|
|
17
|
+
} else if (iPv6 != null) {
|
|
18
|
+
// relay: wrap in CoAP, address it to the target's mesh IPv6,
|
|
19
|
+
// send through commDevice (the connected gateway)
|
|
20
|
+
} else {
|
|
21
|
+
// no known address for the target -- fail
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
## Wire format
|
|
25
|
+
|
|
26
|
+
A new C2CI opcode (confirmed literal: **25**), alongside the `0x17`/`0x18`
|
|
27
|
+
Get/Set-attribute opcodes this library already uses. Carried inside the
|
|
28
|
+
same outer FSCI frame (see mobius.frame) as everything else -- this is
|
|
29
|
+
not a separate transport, just a different C2CI opcode.
|
|
30
|
+
|
|
31
|
+
**Request** (`encode_coap_request()`): confirmed via `CoapRequest`'s first
|
|
32
|
+
constructor --
|
|
33
|
+
|
|
34
|
+
IPv6(16 bytes, REVERSED) + token(4) + method(2) + payload_length(2)
|
|
35
|
+
+ request_type(1) + payload
|
|
36
|
+
|
|
37
|
+
**Response** (`decode_coap_response()`): confirmed via `CoapResponse`'s
|
|
38
|
+
constructor --
|
|
39
|
+
|
|
40
|
+
IPv6(16 bytes, NOT reversed) + token(4) + response_code(2)
|
|
41
|
+
+ payload_length(2) + request_type(1) + payload
|
|
42
|
+
|
|
43
|
+
The asymmetric byte-reversal (request reverses the address, response
|
|
44
|
+
doesn't) is easy to get backwards -- confirmed directly from the
|
|
45
|
+
decompiled source, not a guess, so implemented exactly as traced.
|
|
46
|
+
|
|
47
|
+
**The payload is just an existing FSCI request/response's raw bytes.**
|
|
48
|
+
`CoapFsciRequest` in the decompile literally wraps `request.getData(true)`
|
|
49
|
+
-- meaning none of this library's existing Get/Set attribute encoding
|
|
50
|
+
needs reimplementing. This module only handles the CoAP envelope; the
|
|
51
|
+
payload is built with mobius.frame's existing encode_get_attribute() /
|
|
52
|
+
encode_set_attribute() / decode_attribute_response(), same as a direct
|
|
53
|
+
(non-relayed) request.
|
|
54
|
+
|
|
55
|
+
## Confidence notes on enum values
|
|
56
|
+
|
|
57
|
+
Some `Coap.ResponseCode` values in the decompile are JADX symbolic
|
|
58
|
+
substitutions (obfuscated numeric constants resolved back to unrelated
|
|
59
|
+
Android/Google library field names, e.g. `TypedValues.PositionType.
|
|
60
|
+
TYPE_SIZE_PERCENT`) rather than plain literals. Where a value was already
|
|
61
|
+
independently confirmed elsewhere in this project (for a completely
|
|
62
|
+
different attribute, but the same underlying Java constant), that
|
|
63
|
+
confirmed value is used here too -- `InternalServerError` (500, via
|
|
64
|
+
`ServiceStarter.ERROR_UNKNOWN`) and `BadGateway`/`ProxyingNotSupported`
|
|
65
|
+
(502/512, via `TypedValues.PositionType.TYPE_DRAWPATH`/`TYPE_SIZE_PERCENT`).
|
|
66
|
+
Note `ProxyingNotSupported=512` does NOT fit the otherwise-clean RFC 7252
|
|
67
|
+
`class*100+detail` pattern every other value in this enum follows (5.05
|
|
68
|
+
would predict 505) -- confirmed via cross-reference, not just pattern
|
|
69
|
+
completion, but flagged as numerically surprising and worth treating with
|
|
70
|
+
some caution until seen on real hardware.
|
|
71
|
+
|
|
72
|
+
The remaining symbolic values (`Content`, `Unauthorized`, `BadOption`,
|
|
73
|
+
`Forbidden`, `NotImplemented`, `ServiceUnavailable`, `GatewayTimeout`)
|
|
74
|
+
could NOT be independently confirmed (third-party library classes not
|
|
75
|
+
present in this project's decompile, and not findable via public source
|
|
76
|
+
lookups for these specific obscure constants). Rather than guess, this
|
|
77
|
+
module only defines the CONFIRMED response codes (all literal, or
|
|
78
|
+
independently cross-referenced) in CoapResponseCode -- an unrecognized
|
|
79
|
+
code decodes to a plain int, not a guessed enum member, matching the real
|
|
80
|
+
app's own graceful-fallback behavior (`getResponseCode()` returns
|
|
81
|
+
`coap_Empty` for anything it doesn't recognize, rather than crashing).
|
|
82
|
+
`Content` (matching the real "2.05 Content" success response to a GET,
|
|
83
|
+
which we'll see constantly in practice) is the one value here confirmed
|
|
84
|
+
only by strong pattern inference, not direct source confirmation -- flagged
|
|
85
|
+
explicitly below, and worth confirming against real hardware traffic.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
from __future__ import annotations
|
|
89
|
+
|
|
90
|
+
import struct
|
|
91
|
+
from dataclasses import dataclass
|
|
92
|
+
from enum import IntEnum
|
|
93
|
+
from typing import Optional
|
|
94
|
+
|
|
95
|
+
# Confirmed literal: the C2CI opcode used for CoAP-wrapped requests/responses,
|
|
96
|
+
# alongside the existing Get(0x17)/Set(0x18) attribute opcodes.
|
|
97
|
+
COAP_OPCODE = 25
|
|
98
|
+
|
|
99
|
+
# Confirmed from BaseConnection.smali's handleIndication() (a method JADX
|
|
100
|
+
# couldn't decompile to Java -- traced from raw bytecode instead): the
|
|
101
|
+
# REAL CoAP relay response arrives as a completely separate message on
|
|
102
|
+
# THIS opcode, not COAP_OPCODE (25). Opcode 25's confirm is a bare 1-byte
|
|
103
|
+
# ack the app itself explicitly discards (see mobius.relay's module
|
|
104
|
+
# docstring for the full trace) -- opcode 26 is a genuinely distinct
|
|
105
|
+
# "indication," and matching it back to the request that triggered it is
|
|
106
|
+
# done purely by CoAP token (lambda$getSentRequest$6:
|
|
107
|
+
# coapRequest.getToken() == coapResponse.getToken()), never by the outer
|
|
108
|
+
# FSCI message ID.
|
|
109
|
+
COAP_INDICATION_OPCODE = 26
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# Matches Coap.currentToken's confirmed starting value in the decompile
|
|
113
|
+
# (a simple static int starting at 100) -- the exact starting value
|
|
114
|
+
# doesn't matter functionally (any value unique per outstanding request
|
|
115
|
+
# works), matched here purely for closest fidelity to the real app.
|
|
116
|
+
_next_coap_token = 100
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def next_coap_token() -> int:
|
|
120
|
+
"""Simple monotonically-increasing CoAP token generator (module-global),
|
|
121
|
+
matching the pattern already used for FSCI message IDs
|
|
122
|
+
(mobius.frame.next_message_id)."""
|
|
123
|
+
global _next_coap_token
|
|
124
|
+
_next_coap_token += 1
|
|
125
|
+
return _next_coap_token
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class CoapRequestType(IntEnum):
|
|
129
|
+
"""Confirmed literal values from Coap.RequestType."""
|
|
130
|
+
Con = 0 # Confirmable
|
|
131
|
+
Non = 1 # Non-confirmable
|
|
132
|
+
Ack = 2 # Acknowledgement
|
|
133
|
+
Rst = 3 # Reset
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class CoapMethod(IntEnum):
|
|
137
|
+
"""Confirmed literal values from Request.Method (plain literals in the
|
|
138
|
+
decompile, no symbolic substitution)."""
|
|
139
|
+
GET = 1
|
|
140
|
+
POST = 2
|
|
141
|
+
PUT = 3
|
|
142
|
+
DELETE = 4
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class CoapResponseCode(IntEnum):
|
|
146
|
+
"""
|
|
147
|
+
Confirmed response codes only -- see this module's docstring for which
|
|
148
|
+
values are plain literals, which are independently cross-referenced
|
|
149
|
+
from elsewhere in this project, and which (Content) is confirmed only
|
|
150
|
+
by strong pattern inference rather than direct source confirmation.
|
|
151
|
+
Deliberately does NOT include every RFC 7252 code -- unrecognized
|
|
152
|
+
codes decode to a plain int via decode_coap_response(), not a guessed
|
|
153
|
+
enum member.
|
|
154
|
+
"""
|
|
155
|
+
Empty = 0 # literal
|
|
156
|
+
Created = 201 # literal
|
|
157
|
+
Deleted = 202 # literal
|
|
158
|
+
Valid = 203 # literal
|
|
159
|
+
Changed = 204 # literal
|
|
160
|
+
Content = 205 # INFERRED (pattern match only, not source-confirmed -- see module docstring)
|
|
161
|
+
BadRequest = 400 # literal
|
|
162
|
+
NotFound = 404 # literal
|
|
163
|
+
MethodNotAllowed = 405 # literal
|
|
164
|
+
NotAcceptable = 406 # literal
|
|
165
|
+
PreconditionFailed = 412 # literal
|
|
166
|
+
RequestEntityTooLarge = 413 # literal
|
|
167
|
+
UnsupportedContentFormat = 415 # literal
|
|
168
|
+
InternalServerError = 500 # CONFIRMED (cross-referenced via ServiceStarter.ERROR_UNKNOWN)
|
|
169
|
+
BadGateway = 502 # CONFIRMED (cross-referenced via TypedValues.PositionType.TYPE_DRAWPATH)
|
|
170
|
+
ProxyingNotSupported = 512 # CONFIRMED (cross-referenced via TypedValues.PositionType.TYPE_SIZE_PERCENT) -- numerically surprising, see module docstring
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@dataclass
|
|
174
|
+
class CoapResponse:
|
|
175
|
+
"""A decoded CoAP-wrapped response, as parsed by decode_coap_response()."""
|
|
176
|
+
address: bytes # 16-byte IPv6, as received (NOT reversed -- see module docstring)
|
|
177
|
+
token: int
|
|
178
|
+
response_code: "CoapResponseCode | int" # int if the code isn't a recognized CoapResponseCode member
|
|
179
|
+
request_type: CoapRequestType
|
|
180
|
+
payload: bytes # the inner FSCI response bytes -- decode with mobius.frame as usual
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def encode_coap_request(
|
|
184
|
+
target_address: bytes,
|
|
185
|
+
token: int,
|
|
186
|
+
method: CoapMethod,
|
|
187
|
+
payload: bytes,
|
|
188
|
+
request_type: CoapRequestType = CoapRequestType.Con,
|
|
189
|
+
) -> bytes:
|
|
190
|
+
"""
|
|
191
|
+
Builds the C2CI-opcode-25 payload for a CoAP-wrapped request, to be
|
|
192
|
+
sent through a connected gateway device addressed to a device
|
|
193
|
+
elsewhere in the mesh. `payload` is the raw bytes of an ordinary
|
|
194
|
+
(non-CoAP) FSCI request -- e.g. the output of
|
|
195
|
+
mobius.frame.encode_get_attribute() -- exactly as it would be sent
|
|
196
|
+
directly, just wrapped in this envelope instead of sent as-is.
|
|
197
|
+
|
|
198
|
+
`target_address` must be exactly 16 bytes (a full IPv6 address -- see
|
|
199
|
+
mesh_local_address() for how to construct one for a specific device).
|
|
200
|
+
|
|
201
|
+
Confirmed wire format (CoapRequest's first constructor):
|
|
202
|
+
IPv6(16, REVERSED) + token(4) + method(2) + payload_length(2)
|
|
203
|
+
+ request_type(1) + payload.
|
|
204
|
+
"""
|
|
205
|
+
if len(target_address) != 16:
|
|
206
|
+
raise ValueError(f"target_address must be exactly 16 bytes, got {len(target_address)}")
|
|
207
|
+
reversed_address = target_address[::-1]
|
|
208
|
+
return (
|
|
209
|
+
reversed_address
|
|
210
|
+
+ struct.pack("<i", token)
|
|
211
|
+
+ struct.pack("<h", method.value)
|
|
212
|
+
+ struct.pack("<h", len(payload))
|
|
213
|
+
+ bytes([request_type.value])
|
|
214
|
+
+ payload
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def decode_coap_response(data: bytes) -> Optional[CoapResponse]:
|
|
219
|
+
"""
|
|
220
|
+
Parses a C2CI-opcode-25 response payload. Returns None if the data is
|
|
221
|
+
too short to be a valid CoAP response (confirmed minimum: 25 bytes
|
|
222
|
+
before any payload, matching CoapResponse.validate()'s own check).
|
|
223
|
+
|
|
224
|
+
Confirmed wire format (CoapResponse's constructor):
|
|
225
|
+
IPv6(16, NOT reversed) + token(4) + response_code(2) + payload_length(2)
|
|
226
|
+
+ request_type(1) + payload.
|
|
227
|
+
"""
|
|
228
|
+
if len(data) < 25:
|
|
229
|
+
return None
|
|
230
|
+
|
|
231
|
+
address = data[0:16]
|
|
232
|
+
token = struct.unpack("<i", data[16:20])[0]
|
|
233
|
+
code_raw = struct.unpack("<h", data[20:22])[0]
|
|
234
|
+
try:
|
|
235
|
+
response_code: "CoapResponseCode | int" = CoapResponseCode(code_raw)
|
|
236
|
+
except ValueError:
|
|
237
|
+
response_code = code_raw # unrecognized code -- matches the app's own graceful fallback
|
|
238
|
+
|
|
239
|
+
payload_length = struct.unpack("<h", data[22:24])[0]
|
|
240
|
+
try:
|
|
241
|
+
request_type = CoapRequestType(data[24])
|
|
242
|
+
except ValueError:
|
|
243
|
+
request_type = CoapRequestType.Con # matches Coap.RequestType.getRequestType()'s own fallback
|
|
244
|
+
|
|
245
|
+
payload = data[25:25 + payload_length]
|
|
246
|
+
|
|
247
|
+
return CoapResponse(
|
|
248
|
+
address=address,
|
|
249
|
+
token=token,
|
|
250
|
+
response_code=response_code,
|
|
251
|
+
request_type=request_type,
|
|
252
|
+
payload=payload,
|
|
253
|
+
)
|
mobius/constants.py
CHANGED
|
@@ -271,6 +271,23 @@ class C2Attribute(IntEnum):
|
|
|
271
271
|
AcclimationStartIntensity = 904
|
|
272
272
|
AcclimationStartTime = 905
|
|
273
273
|
LunarPhasesEnabled = 907
|
|
274
|
+
Epoch = 201
|
|
275
|
+
RTCTime = 219
|
|
276
|
+
# Thread mesh peer discovery -- see documentation/09-thread-coap-relay.md
|
|
277
|
+
# for the full protocol trace. MeshLocalAddresses confirmed via
|
|
278
|
+
# PeripheralConnection.java; the three parallel arrays confirmed via
|
|
279
|
+
# CowboyNetworkProcess.java (index-matched: serialArray[i]/
|
|
280
|
+
# modelArray[i]/shortArray[i] all refer to the same peer).
|
|
281
|
+
MeshLocalAddresses = 1005
|
|
282
|
+
ShortAddressArray = 3700
|
|
283
|
+
SerialNumberArray = 3701
|
|
284
|
+
DeviceModelArray = 3702
|
|
285
|
+
# A fourth parallel array giving each peer's real BLE MAC address --
|
|
286
|
+
# confirmed present (CowboyNetworkProcess.java references it
|
|
287
|
+
# alongside the three above) but not used by discover_mesh_peers()
|
|
288
|
+
# (not needed for CoAP relay, which addresses peers by Thread IPv6,
|
|
289
|
+
# not BLE address). Included for completeness/future use.
|
|
290
|
+
BladeBleAddressArray = 3714
|
|
274
291
|
InsolationEnabled = 912
|
|
275
292
|
MaxPower = 1504
|
|
276
293
|
Ramp = 1505
|