mirror of
https://github.com/rightup/pyMC_Repeater.git
synced 2026-04-30 10:42:16 +02:00
472 lines
15 KiB
Python
472 lines
15 KiB
Python
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")
|