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 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.1.4"
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