mirror of
https://github.com/pyMC-dev/pyMC_Repeater.git
synced 2026-08-06 08:53:46 +02:00
feat: add glass to repeater.
This commit is contained in:
@@ -0,0 +1,471 @@
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
|
||||
_MODULE_PATH = Path(__file__).resolve().parents[1] / "repeater" / "data_acquisition" / "glass_handler.py"
|
||||
_SPEC = importlib.util.spec_from_file_location("repeater_glass_handler", _MODULE_PATH)
|
||||
_MODULE = importlib.util.module_from_spec(_SPEC)
|
||||
assert _SPEC and _SPEC.loader
|
||||
_SPEC.loader.exec_module(_MODULE)
|
||||
GlassHandler = _MODULE.GlassHandler
|
||||
|
||||
|
||||
class _DummyIdentity:
|
||||
@staticmethod
|
||||
def get_public_key():
|
||||
return bytes.fromhex("ab" * 32)
|
||||
|
||||
|
||||
class _DummyConfigManager:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def update_and_save(self, updates, live_update=True, live_update_sections=None):
|
||||
self.calls.append(
|
||||
{
|
||||
"updates": updates,
|
||||
"live_update": live_update,
|
||||
"live_update_sections": live_update_sections,
|
||||
}
|
||||
)
|
||||
return {"success": True, "saved": True, "live_updated": True}
|
||||
|
||||
@staticmethod
|
||||
def save_to_file():
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def live_update_daemon(_sections):
|
||||
return True
|
||||
|
||||
|
||||
class _DummyDaemon:
|
||||
def __init__(self):
|
||||
self.local_identity = _DummyIdentity()
|
||||
self.repeater_handler = type("RH", (), {"start_time": time.time() - 60})()
|
||||
|
||||
@staticmethod
|
||||
def get_stats():
|
||||
return {
|
||||
"rx_count": 11,
|
||||
"forwarded_count": 7,
|
||||
"dropped_count": 2,
|
||||
"flood_dup_count": 3,
|
||||
"direct_dup_count": 1,
|
||||
"sent_flood_count": 5,
|
||||
"sent_direct_count": 2,
|
||||
"utilization_percent": 4.2,
|
||||
"noise_floor_dbm": -111.5,
|
||||
"uptime_seconds": 60,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def send_advert():
|
||||
return True
|
||||
|
||||
|
||||
class _DummyMqttClient:
|
||||
def __init__(self):
|
||||
self.published = []
|
||||
|
||||
def publish(self, topic, message, qos=0, retain=False):
|
||||
self.published.append(
|
||||
{
|
||||
"topic": topic,
|
||||
"message": message,
|
||||
"qos": qos,
|
||||
"retain": retain,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class _DummyPahoClient:
|
||||
def __init__(self):
|
||||
self.username = None
|
||||
self.password = None
|
||||
self.tls_set_kwargs = None
|
||||
self.tls_insecure = None
|
||||
self.connected = None
|
||||
self.loop_started = False
|
||||
self.loop_stopped = False
|
||||
self.disconnected = False
|
||||
self.on_connect = None
|
||||
self.on_disconnect = None
|
||||
|
||||
def username_pw_set(self, username, password):
|
||||
self.username = username
|
||||
self.password = password
|
||||
|
||||
def tls_set(self, **kwargs):
|
||||
self.tls_set_kwargs = kwargs
|
||||
|
||||
def tls_insecure_set(self, value):
|
||||
self.tls_insecure = value
|
||||
|
||||
def connect_async(self, host, port, keepalive):
|
||||
self.connected = (host, port, keepalive)
|
||||
|
||||
def loop_start(self):
|
||||
self.loop_started = True
|
||||
|
||||
def loop_stop(self):
|
||||
self.loop_stopped = True
|
||||
|
||||
def disconnect(self):
|
||||
self.disconnected = True
|
||||
|
||||
|
||||
class _DummyPahoModule:
|
||||
def __init__(self, client):
|
||||
self._client = client
|
||||
|
||||
def Client(self):
|
||||
return self._client
|
||||
|
||||
|
||||
class _DummyHttpResponse:
|
||||
def __init__(self, payload):
|
||||
self._payload = json.dumps(payload).encode("utf-8")
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def read(self):
|
||||
return self._payload
|
||||
|
||||
|
||||
class _DummySslContext:
|
||||
def __init__(self):
|
||||
self.cert_chain = None
|
||||
|
||||
def load_cert_chain(self, certfile, keyfile):
|
||||
self.cert_chain = (certfile, keyfile)
|
||||
|
||||
|
||||
def _make_config():
|
||||
return {
|
||||
"repeater": {
|
||||
"node_name": "mesh-repeater-01",
|
||||
"mode": "forward",
|
||||
"location": "51.5074,-0.1278",
|
||||
"identity_key": "PRIVATE-KEY",
|
||||
},
|
||||
"radio": {
|
||||
"frequency": 869618000,
|
||||
"spreading_factor": 8,
|
||||
"bandwidth": 62500,
|
||||
"tx_power": 14,
|
||||
},
|
||||
"glass": {
|
||||
"enabled": False,
|
||||
"base_url": "http://localhost:8080",
|
||||
"inform_interval_seconds": 30,
|
||||
"request_timeout_seconds": 10,
|
||||
"verify_tls": True,
|
||||
"api_token": "",
|
||||
"cert_store_dir": "/tmp/pymc-glass-test",
|
||||
"mqtt_password": "super-secret",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_compute_config_hash_has_expected_format():
|
||||
config = _make_config()
|
||||
config["repeater"]["identity_key"] = b"\x01" * 32
|
||||
digest = GlassHandler._compute_config_hash(config)
|
||||
assert digest.startswith("sha256:")
|
||||
assert len(digest) == 71
|
||||
|
||||
|
||||
def test_build_inform_payload_contains_expected_fields():
|
||||
config = _make_config()
|
||||
daemon = _DummyDaemon()
|
||||
manager = _DummyConfigManager()
|
||||
handler = GlassHandler(config=config, daemon_instance=daemon, config_manager=manager)
|
||||
|
||||
asyncio.run(handler._queue_command_result("cmd-1", "success", "ok"))
|
||||
payload = asyncio.run(handler._build_inform_payload())
|
||||
|
||||
assert payload["type"] == "inform"
|
||||
assert payload["version"] == 1
|
||||
assert payload["node_name"] == "mesh-repeater-01"
|
||||
assert payload["pubkey"].startswith("0x")
|
||||
assert payload["config_hash"].startswith("sha256:")
|
||||
assert payload["location"] == "51.907400,-0.157800"
|
||||
assert payload["radio"]["frequency"] == 869618000
|
||||
assert payload["counters"]["duplicates"] == 4
|
||||
assert payload["settings"]["repeater"]["location"] == "51.9074,-0.1570"
|
||||
assert payload["settings"]["repeater"]["identity_key"] == "<redacted>"
|
||||
assert payload["settings"]["glass"]["mqtt_password"] == "<redacted>"
|
||||
assert payload["command_results"][0]["command_id"] == "cmd-1"
|
||||
|
||||
|
||||
def test_execute_set_mode_command_updates_config():
|
||||
config = _make_config()
|
||||
daemon = _DummyDaemon()
|
||||
manager = _DummyConfigManager()
|
||||
handler = GlassHandler(config=config, daemon_instance=daemon, config_manager=manager)
|
||||
|
||||
ok, message = asyncio.run(handler._execute_command_action("set_mode", {"mode": "monitor"}))
|
||||
assert ok is True
|
||||
assert "Config patched" in message
|
||||
assert manager.calls
|
||||
assert manager.calls[-1]["updates"]["repeater"]["mode"] == "monitor"
|
||||
|
||||
|
||||
def test_handle_command_response_queues_result():
|
||||
config = _make_config()
|
||||
daemon = _DummyDaemon()
|
||||
manager = _DummyConfigManager()
|
||||
handler = GlassHandler(config=config, daemon_instance=daemon, config_manager=manager)
|
||||
|
||||
asyncio.run(
|
||||
handler._handle_command_response(
|
||||
{
|
||||
"type": "command",
|
||||
"command_id": "cmd-42",
|
||||
"action": "run_diagnostic",
|
||||
"params": {},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
assert handler._pending_command_results
|
||||
queued = handler._pending_command_results[-1]
|
||||
assert queued["command_id"] == "cmd-42"
|
||||
assert queued["status"] == "success"
|
||||
|
||||
|
||||
def test_publish_telemetry_packet_envelope_to_expected_topic():
|
||||
config = _make_config()
|
||||
config["glass"]["enabled"] = True
|
||||
daemon = _DummyDaemon()
|
||||
manager = _DummyConfigManager()
|
||||
handler = GlassHandler(config=config, daemon_instance=daemon, config_manager=manager)
|
||||
handler.mqtt_enabled = True
|
||||
handler._mqtt_ready = True
|
||||
handler._mqtt_client = _DummyMqttClient()
|
||||
|
||||
handler.publish_telemetry(
|
||||
"packet",
|
||||
{
|
||||
"timestamp": 1760000000,
|
||||
"packet_hash": "ABCDEF123456",
|
||||
"rssi": -80.5,
|
||||
},
|
||||
)
|
||||
|
||||
assert len(handler._mqtt_client.published) == 1
|
||||
published = handler._mqtt_client.published[0]
|
||||
assert published["topic"] == "glass/mesh-repeater-01/packet"
|
||||
payload = json.loads(published["message"])
|
||||
assert payload["version"] == 1
|
||||
assert payload["type"] == "packet"
|
||||
assert payload["node_name"] == "mesh-repeater-01"
|
||||
assert payload["topic"] == "glass/mesh-repeater-01/packet"
|
||||
assert payload["payload"]["packet_hash"] == "ABCDEF123456"
|
||||
assert published["qos"] == 0
|
||||
assert published["retain"] is False
|
||||
|
||||
|
||||
def test_publish_telemetry_event_uses_event_topic_suffix():
|
||||
config = _make_config()
|
||||
config["glass"]["enabled"] = True
|
||||
daemon = _DummyDaemon()
|
||||
manager = _DummyConfigManager()
|
||||
handler = GlassHandler(config=config, daemon_instance=daemon, config_manager=manager)
|
||||
handler.mqtt_enabled = True
|
||||
handler._mqtt_ready = True
|
||||
handler._mqtt_client = _DummyMqttClient()
|
||||
|
||||
handler.publish_telemetry(
|
||||
"noise_floor",
|
||||
{
|
||||
"timestamp": "2026-04-15T12:30:45Z",
|
||||
"noise_floor_dbm": -112.3,
|
||||
},
|
||||
)
|
||||
|
||||
assert len(handler._mqtt_client.published) == 1
|
||||
published = handler._mqtt_client.published[0]
|
||||
assert published["topic"] == "glass/mesh-repeater-01/event/noise_floor"
|
||||
payload = json.loads(published["message"])
|
||||
assert payload["type"] == "event"
|
||||
assert payload["event_name"] == "noise_floor"
|
||||
assert payload["topic"] == "glass/mesh-repeater-01/event/noise_floor"
|
||||
|
||||
|
||||
def test_apply_config_update_glass_managed_updates_runtime_and_file(tmp_path):
|
||||
config = _make_config()
|
||||
config["glass"]["enabled"] = True
|
||||
config["glass"]["cert_store_dir"] = str(tmp_path)
|
||||
daemon = _DummyDaemon()
|
||||
manager = _DummyConfigManager()
|
||||
handler = GlassHandler(config=config, daemon_instance=daemon, config_manager=manager)
|
||||
handler._sync_mqtt_publisher = lambda: None
|
||||
|
||||
ok, message = handler._apply_config_update(
|
||||
{
|
||||
"glass_managed": {
|
||||
"mqtt_enabled": True,
|
||||
"mqtt_broker_host": "emqx",
|
||||
"mqtt_broker_port": 1883,
|
||||
"mqtt_base_topic": "glass/fleet",
|
||||
"mqtt_tls_enabled": True,
|
||||
}
|
||||
},
|
||||
merge_mode="patch",
|
||||
)
|
||||
|
||||
assert ok is True
|
||||
assert "Managed settings updated" in message
|
||||
managed_path = tmp_path / "managed.json"
|
||||
assert managed_path.exists()
|
||||
managed = json.loads(managed_path.read_text(encoding="utf-8"))
|
||||
assert managed["mqtt_enabled"] is True
|
||||
assert managed["mqtt_broker_host"] == "emqx"
|
||||
assert managed["mqtt_broker_port"] == 1883
|
||||
assert managed["mqtt_base_topic"] == "glass/fleet"
|
||||
assert managed["mqtt_tls_enabled"] is True
|
||||
assert handler.mqtt_enabled is True
|
||||
assert handler.mqtt_broker_host == "emqx"
|
||||
assert handler.mqtt_broker_port == 1883
|
||||
assert handler.mqtt_base_topic == "glass/fleet"
|
||||
assert handler.mqtt_tls_enabled is True
|
||||
|
||||
|
||||
def test_sync_mqtt_publisher_restarts_when_signature_changes(monkeypatch):
|
||||
config = _make_config()
|
||||
config["glass"]["enabled"] = True
|
||||
daemon = _DummyDaemon()
|
||||
manager = _DummyConfigManager()
|
||||
handler = GlassHandler(config=config, daemon_instance=daemon, config_manager=manager)
|
||||
handler.enabled = True
|
||||
handler.mqtt_enabled = True
|
||||
handler._mqtt_client = object()
|
||||
handler._mqtt_runtime_signature = ("old-host", 1883, "glass", None, None)
|
||||
|
||||
calls = []
|
||||
|
||||
def _fake_close():
|
||||
calls.append("close")
|
||||
handler._mqtt_client = None
|
||||
handler._mqtt_runtime_signature = None
|
||||
|
||||
def _fake_init():
|
||||
calls.append("init")
|
||||
|
||||
monkeypatch.setattr(_MODULE, "mqtt", object())
|
||||
monkeypatch.setattr(handler, "_close_mqtt_publisher", _fake_close)
|
||||
monkeypatch.setattr(handler, "_init_mqtt_publisher", _fake_init)
|
||||
|
||||
handler.mqtt_broker_host = "new-host"
|
||||
handler._sync_mqtt_publisher()
|
||||
|
||||
assert calls == ["close", "init"]
|
||||
|
||||
|
||||
def test_init_mqtt_publisher_uses_mtls_cert_material(tmp_path, monkeypatch):
|
||||
cert_path = tmp_path / "glass-client.crt"
|
||||
key_path = tmp_path / "glass-client.key"
|
||||
ca_path = tmp_path / "glass-ca.crt"
|
||||
cert_path.write_text("CERT", encoding="utf-8")
|
||||
key_path.write_text("KEY", encoding="utf-8")
|
||||
ca_path.write_text("CA", encoding="utf-8")
|
||||
|
||||
config = _make_config()
|
||||
config["glass"]["enabled"] = True
|
||||
config["glass"]["verify_tls"] = True
|
||||
config["glass"]["client_cert_path"] = str(cert_path)
|
||||
config["glass"]["client_key_path"] = str(key_path)
|
||||
config["glass"]["ca_cert_path"] = str(ca_path)
|
||||
daemon = _DummyDaemon()
|
||||
manager = _DummyConfigManager()
|
||||
handler = GlassHandler(config=config, daemon_instance=daemon, config_manager=manager)
|
||||
handler.enabled = True
|
||||
handler.mqtt_enabled = True
|
||||
handler.mqtt_tls_enabled = True
|
||||
handler.mqtt_broker_host = "emqx"
|
||||
handler.mqtt_broker_port = 8883
|
||||
handler.mqtt_base_topic = "glass"
|
||||
|
||||
fake_client = _DummyPahoClient()
|
||||
monkeypatch.setattr(_MODULE, "mqtt", _DummyPahoModule(fake_client))
|
||||
|
||||
handler._init_mqtt_publisher()
|
||||
|
||||
assert fake_client.tls_set_kwargs is not None
|
||||
assert fake_client.tls_set_kwargs["ca_certs"] == str(ca_path)
|
||||
assert fake_client.tls_set_kwargs["certfile"] == str(cert_path)
|
||||
assert fake_client.tls_set_kwargs["keyfile"] == str(key_path)
|
||||
assert fake_client.tls_set_kwargs["cert_reqs"] == _MODULE.ssl.CERT_REQUIRED
|
||||
assert fake_client.connected == ("emqx", 8883, 60)
|
||||
assert fake_client.loop_started is True
|
||||
assert handler._mqtt_client is fake_client
|
||||
assert handler._mqtt_runtime_signature == handler._current_mqtt_signature()
|
||||
|
||||
|
||||
def test_post_inform_sync_uses_configured_ca_and_client_cert_chain(tmp_path, monkeypatch):
|
||||
cert_path = tmp_path / "glass-client.crt"
|
||||
key_path = tmp_path / "glass-client.key"
|
||||
ca_path = tmp_path / "glass-ca.crt"
|
||||
cert_path.write_text("CERT", encoding="utf-8")
|
||||
key_path.write_text("KEY", encoding="utf-8")
|
||||
ca_path.write_text("CA", encoding="utf-8")
|
||||
|
||||
config = _make_config()
|
||||
config["glass"]["enabled"] = True
|
||||
config["glass"]["base_url"] = "https://glass.example"
|
||||
config["glass"]["verify_tls"] = True
|
||||
config["glass"]["client_cert_path"] = str(cert_path)
|
||||
config["glass"]["client_key_path"] = str(key_path)
|
||||
config["glass"]["ca_cert_path"] = str(ca_path)
|
||||
daemon = _DummyDaemon()
|
||||
manager = _DummyConfigManager()
|
||||
handler = GlassHandler(config=config, daemon_instance=daemon, config_manager=manager)
|
||||
|
||||
calls = {}
|
||||
fake_context = _DummySslContext()
|
||||
|
||||
def _fake_create_default_context(cafile=None):
|
||||
calls["cafile"] = cafile
|
||||
return fake_context
|
||||
|
||||
def _fake_urlopen(req, timeout=None, context=None):
|
||||
calls["timeout"] = timeout
|
||||
calls["context"] = context
|
||||
assert req.full_url == "https://glass.example/inform"
|
||||
return _DummyHttpResponse({"type": "noop", "interval": 30})
|
||||
|
||||
monkeypatch.setattr(_MODULE.ssl, "create_default_context", _fake_create_default_context)
|
||||
monkeypatch.setattr(_MODULE.request, "urlopen", _fake_urlopen)
|
||||
|
||||
response = handler._post_inform_sync({"type": "inform"})
|
||||
|
||||
assert response["type"] == "noop"
|
||||
assert calls["cafile"] == str(ca_path)
|
||||
assert calls["context"] is fake_context
|
||||
assert fake_context.cert_chain == (str(cert_path), str(key_path))
|
||||
|
||||
|
||||
def test_build_ssl_context_raises_when_client_key_missing(tmp_path):
|
||||
cert_path = tmp_path / "glass-client.crt"
|
||||
cert_path.write_text("CERT", encoding="utf-8")
|
||||
|
||||
config = _make_config()
|
||||
config["glass"]["enabled"] = True
|
||||
config["glass"]["base_url"] = "https://glass.example"
|
||||
config["glass"]["verify_tls"] = False
|
||||
config["glass"]["client_cert_path"] = str(cert_path)
|
||||
daemon = _DummyDaemon()
|
||||
manager = _DummyConfigManager()
|
||||
handler = GlassHandler(config=config, daemon_instance=daemon, config_manager=manager)
|
||||
|
||||
with pytest.raises(RuntimeError, match="client_key_path"):
|
||||
handler._build_ssl_context("https://glass.example/inform")
|
||||
Reference in New Issue
Block a user