feat(regions): DeviceManager wrappers for flood-scope commands

Second slice of the per-channel region-scope feature — firmware plumbing.
No UI, routes, or send-flow integration yet; those land in PR #3 / #4.

- _send_lock: threading.Lock added to __init__ (consumed in PR #4 to
  serialize the set-scope + send-channel-message pair across Flask
  threads; introduced here to keep the init diff small).
- set_flood_scope_key(key_hex): thin wrapper over the existing
  meshcore-py `set_flood_scope(bytes)` path (CMD 54). None/empty clears
  the volatile scope. Used on the channel-send hot path in PR #4.
- set_default_flood_scope(name, key_hex): hand-rolled CMD 63 frame
  (opcode + 31-byte NUL-padded name + 16-byte key = 48 bytes) via the
  lib's generic send() with [OK, ERROR] wait. Installed meshcore-py
  (<=2.2.15) has no wrapper for this opcode; frame format matches
  MyMesh.cpp lines 1893-1909.
- Deliberately NOT implementing CMD 64 (GET_DEFAULT_FLOOD_SCOPE): the
  library's reader drops RESP_CODE 28 as "unhandled" (reader.py:919-921),
  so there is no Event we can wait for. Until upstream adds support,
  mc-webui treats its own regions.is_default row as the source of truth
  and pushes one-way via CMD 63. Comment in code documents the reason.
This commit is contained in:
MarekWo
2026-04-24 07:20:30 +02:00
parent 8e353407d3
commit 0e38e0ce8c

View File

@@ -142,6 +142,7 @@ class DeviceManager:
self._max_channels = 8 # updated from device_info at connect
self._pending_echo = None # {'timestamp': float, 'channel_idx': int, 'msg_id': int, 'pkt_payload': str|None}
self._echo_lock = threading.Lock()
self._send_lock = threading.Lock() # serialize set-scope + send-channel-message pair (used in PR #4)
self._pending_acks = {} # {ack_code_hex: dm_id} — maps retry acks to DM
self._retry_tasks = {} # {dm_id: asyncio.Task} — active retry coroutines
self._retry_context = {} # {dm_id: {attempt, max_attempts, path}} — for _on_ack
@@ -2930,6 +2931,71 @@ class DeviceManager:
logger.error(f"set_flood_scope failed: {e}")
return {'success': False, 'error': str(e)}
def set_flood_scope_key(self, key_hex: Optional[str]) -> Dict:
"""Set the volatile per-send flood scope by raw 16-byte key (CMD_SET_FLOOD_SCOPE_KEY = 54).
Passing None or empty hex clears the scope (firmware falls back to its default).
Used on the channel-send hot path in PR #4.
"""
if not self.is_connected:
return {'success': False, 'error': 'Device not connected'}
try:
if not key_hex:
key_bytes = b'\x00' * 16
else:
key_bytes = bytes.fromhex(key_hex)
if len(key_bytes) != 16:
return {'success': False, 'error': 'Scope key must be 16 bytes (32 hex chars)'}
self.execute(self.mc.commands.set_flood_scope(key_bytes), timeout=5)
return {'success': True}
except Exception as e:
logger.error(f"set_flood_scope_key failed: {e}")
return {'success': False, 'error': str(e)}
def set_default_flood_scope(self, name: str, key_hex: str) -> Dict:
"""Set the firmware's persistent default flood scope (CMD_SET_DEFAULT_FLOOD_SCOPE = 63).
Passing empty name+key (or name='' / key_hex='') clears the firmware default.
Frame format: [0x3F][name: 31 bytes NUL-padded][key: 16 bytes] = 48 bytes.
Hand-rolled because the installed meshcore-py (<=2.2.15) has no wrapper.
"""
if not self.is_connected:
return {'success': False, 'error': 'Device not connected'}
try:
from meshcore.events import EventType
CMD_SET_DEFAULT_FLOOD_SCOPE = 63
if not name or not key_hex:
# Send just the opcode — firmware clears both name and key.
payload = bytes([CMD_SET_DEFAULT_FLOOD_SCOPE])
else:
name_bytes = name.encode('utf-8')
if len(name_bytes) >= 31:
return {'success': False, 'error': 'Name too long (max 30 bytes)'}
key_bytes = bytes.fromhex(key_hex)
if len(key_bytes) != 16:
return {'success': False, 'error': 'Scope key must be 16 bytes (32 hex chars)'}
payload = bytes([CMD_SET_DEFAULT_FLOOD_SCOPE]) + name_bytes.ljust(31, b'\x00') + key_bytes
event = self.execute(
self.mc.commands.send(payload, [EventType.OK, EventType.ERROR]),
timeout=5,
)
if event and getattr(event, 'type', None) == EventType.ERROR:
reason = (getattr(event, 'payload', {}) or {}).get('reason', 'unknown')
return {'success': False, 'error': f'Firmware error: {reason}'}
return {'success': True, 'message': f'Default scope set to: {name or "(cleared)"}'}
except Exception as e:
logger.error(f"set_default_flood_scope failed: {e}")
return {'success': False, 'error': str(e)}
# NOTE: CMD_GET_DEFAULT_FLOOD_SCOPE (64) / RESP_CODE_DEFAULT_FLOOD_SCOPE (28)
# is intentionally NOT implemented here. The installed meshcore-py reader has
# no handler for opcode 28 (reader.py:919-921 silently drops unknown opcodes
# with a debug log), so we can't reliably wait for its response. Until the
# upstream library adds support, mc-webui treats its own `regions.is_default`
# row as the source of truth and pushes it one-way to the firmware via CMD 63.
def get_self_telemetry(self) -> Dict:
"""Get own telemetry data."""
if not self.is_connected: