diff --git a/.gitignore b/.gitignore index 72aeb22..a10fdfb 100644 --- a/.gitignore +++ b/.gitignore @@ -182,9 +182,9 @@ cython_debug/ .abstra/ # Visual Studio Code -# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore -# and can be added to the global gitignore or merged into this file. However, if you prefer, +# and can be added to the global gitignore or merged into this file. However, if you prefer, # you could uncomment the following to ignore the entire vscode folder # .vscode/ diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..2c07333 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.11 diff --git a/AGENTS.md b/AGENTS.md index cf7b328..f640ce2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,20 @@ This document provides context and guidelines for AI coding assistants working on the MeshCore Hub project. +## Agent Rules + +* You MUST use Python (version in `.python-version` file) +* You MUST activate a Python virtual environment in the `venv` directory or create one if it does not exist: + - `ls ./venv` to check if it exists + - `python -m venv .venv` to create it +* You MUST always activate the virtual environment before running any commands + - `source .venv/bin/activate` +* You MUST install all project dependencies using `pip install -e ".[dev]"` command` +* You MUST install `pre-commit` for quality checks +* Before commiting: + - Run tests with `pytest` to ensure recent changes haven't broken anything + - Run `pre-commit run --all-files` to perform all quality checks + ## Project Overview MeshCore Hub is a Python 3.11+ monorepo for managing and orchestrating MeshCore mesh networks. It consists of five main components: diff --git a/TASKS.md b/TASKS.md index d310c3b..1f2d93d 100644 --- a/TASKS.md +++ b/TASKS.md @@ -786,4 +786,3 @@ This document tracks implementation progress for the MeshCore Hub project. Each | 2025-12-03 | 3 | Phase 3: Collector | MQTT subscriber, event handlers, CLI, tests (webhook pending) | | 2025-12-03 | 4 | Phase 4: API | FastAPI app, auth, all routes, CLI, tests (108 passed, 9 pre-existing failures) | | 2025-12-03 | 5 | Phase 5: Web Dashboard | FastAPI + Jinja2, Tailwind/DaisyUI, Leaflet map, all pages, CLI (tests pending) | - diff --git a/alembic/versions/20241202_0001_001_initial_schema.py b/alembic/versions/20241202_0001_001_initial_schema.py index d69a640..760771d 100644 --- a/alembic/versions/20241202_0001_001_initial_schema.py +++ b/alembic/versions/20241202_0001_001_initial_schema.py @@ -5,6 +5,7 @@ Revises: Create Date: 2024-12-02 """ + from typing import Sequence, Union from alembic import op @@ -102,7 +103,9 @@ def upgrade() -> None: server_default=sa.func.now(), nullable=False, ), - sa.ForeignKeyConstraint(["receiver_node_id"], ["nodes.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint( + ["receiver_node_id"], ["nodes.id"], ondelete="SET NULL" + ), sa.PrimaryKeyConstraint("id"), ) op.create_index("ix_messages_receiver_node_id", "messages", ["receiver_node_id"]) @@ -134,11 +137,15 @@ def upgrade() -> None: server_default=sa.func.now(), nullable=False, ), - sa.ForeignKeyConstraint(["receiver_node_id"], ["nodes.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint( + ["receiver_node_id"], ["nodes.id"], ondelete="SET NULL" + ), sa.ForeignKeyConstraint(["node_id"], ["nodes.id"], ondelete="SET NULL"), sa.PrimaryKeyConstraint("id"), ) - op.create_index("ix_advertisements_receiver_node_id", "advertisements", ["receiver_node_id"]) + op.create_index( + "ix_advertisements_receiver_node_id", "advertisements", ["receiver_node_id"] + ) op.create_index("ix_advertisements_node_id", "advertisements", ["node_id"]) op.create_index("ix_advertisements_public_key", "advertisements", ["public_key"]) op.create_index("ix_advertisements_received_at", "advertisements", ["received_at"]) @@ -168,10 +175,14 @@ def upgrade() -> None: server_default=sa.func.now(), nullable=False, ), - sa.ForeignKeyConstraint(["receiver_node_id"], ["nodes.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint( + ["receiver_node_id"], ["nodes.id"], ondelete="SET NULL" + ), sa.PrimaryKeyConstraint("id"), ) - op.create_index("ix_trace_paths_receiver_node_id", "trace_paths", ["receiver_node_id"]) + op.create_index( + "ix_trace_paths_receiver_node_id", "trace_paths", ["receiver_node_id"] + ) op.create_index("ix_trace_paths_initiator_tag", "trace_paths", ["initiator_tag"]) op.create_index("ix_trace_paths_received_at", "trace_paths", ["received_at"]) @@ -197,7 +208,9 @@ def upgrade() -> None: server_default=sa.func.now(), nullable=False, ), - sa.ForeignKeyConstraint(["receiver_node_id"], ["nodes.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint( + ["receiver_node_id"], ["nodes.id"], ondelete="SET NULL" + ), sa.ForeignKeyConstraint(["node_id"], ["nodes.id"], ondelete="SET NULL"), sa.PrimaryKeyConstraint("id"), ) @@ -226,10 +239,14 @@ def upgrade() -> None: server_default=sa.func.now(), nullable=False, ), - sa.ForeignKeyConstraint(["receiver_node_id"], ["nodes.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint( + ["receiver_node_id"], ["nodes.id"], ondelete="SET NULL" + ), sa.PrimaryKeyConstraint("id"), ) - op.create_index("ix_events_log_receiver_node_id", "events_log", ["receiver_node_id"]) + op.create_index( + "ix_events_log_receiver_node_id", "events_log", ["receiver_node_id"] + ) op.create_index("ix_events_log_event_type", "events_log", ["event_type"]) op.create_index("ix_events_log_received_at", "events_log", ["received_at"]) diff --git a/src/meshcore_hub/api/routes/commands.py b/src/meshcore_hub/api/routes/commands.py index 3c62f4d..2608f53 100644 --- a/src/meshcore_hub/api/routes/commands.py +++ b/src/meshcore_hub/api/routes/commands.py @@ -92,7 +92,9 @@ async def send_channel_message( mqtt.stop() mqtt.disconnect() - logger.info(f"Published send_channel_msg command to channel {command.channel_idx}") + logger.info( + f"Published send_channel_msg command to channel {command.channel_idx}" + ) return CommandResponse( success=True, diff --git a/src/meshcore_hub/api/routes/dashboard.py b/src/meshcore_hub/api/routes/dashboard.py index 679c56f..24262f3 100644 --- a/src/meshcore_hub/api/routes/dashboard.py +++ b/src/meshcore_hub/api/routes/dashboard.py @@ -25,31 +25,35 @@ async def get_stats( yesterday = now - timedelta(days=1) # Total nodes - total_nodes = session.execute( - select(func.count()).select_from(Node) - ).scalar() or 0 + total_nodes = session.execute(select(func.count()).select_from(Node)).scalar() or 0 # Active nodes (last 24h) - active_nodes = session.execute( - select(func.count()).select_from(Node).where(Node.last_seen >= yesterday) - ).scalar() or 0 + active_nodes = ( + session.execute( + select(func.count()).select_from(Node).where(Node.last_seen >= yesterday) + ).scalar() + or 0 + ) # Total messages - total_messages = session.execute( - select(func.count()).select_from(Message) - ).scalar() or 0 + total_messages = ( + session.execute(select(func.count()).select_from(Message)).scalar() or 0 + ) # Messages today - messages_today = session.execute( - select(func.count()) - .select_from(Message) - .where(Message.received_at >= today_start) - ).scalar() or 0 + messages_today = ( + session.execute( + select(func.count()) + .select_from(Message) + .where(Message.received_at >= today_start) + ).scalar() + or 0 + ) # Total advertisements - total_advertisements = session.execute( - select(func.count()).select_from(Advertisement) - ).scalar() or 0 + total_advertisements = ( + session.execute(select(func.count()).select_from(Advertisement)).scalar() or 0 + ) # Channel message counts channel_counts_query = ( @@ -84,33 +88,41 @@ async def dashboard( yesterday = now - timedelta(days=1) # Get stats - total_nodes = session.execute( - select(func.count()).select_from(Node) - ).scalar() or 0 + total_nodes = session.execute(select(func.count()).select_from(Node)).scalar() or 0 - active_nodes = session.execute( - select(func.count()).select_from(Node).where(Node.last_seen >= yesterday) - ).scalar() or 0 + active_nodes = ( + session.execute( + select(func.count()).select_from(Node).where(Node.last_seen >= yesterday) + ).scalar() + or 0 + ) - total_messages = session.execute( - select(func.count()).select_from(Message) - ).scalar() or 0 + total_messages = ( + session.execute(select(func.count()).select_from(Message)).scalar() or 0 + ) - messages_today = session.execute( - select(func.count()) - .select_from(Message) - .where(Message.received_at >= today_start) - ).scalar() or 0 + messages_today = ( + session.execute( + select(func.count()) + .select_from(Message) + .where(Message.received_at >= today_start) + ).scalar() + or 0 + ) # Get recent nodes - recent_nodes = session.execute( - select(Node).order_by(Node.last_seen.desc()).limit(10) - ).scalars().all() + recent_nodes = ( + session.execute(select(Node).order_by(Node.last_seen.desc()).limit(10)) + .scalars() + .all() + ) # Get recent messages - recent_messages = session.execute( - select(Message).order_by(Message.received_at.desc()).limit(10) - ).scalars().all() + recent_messages = ( + session.execute(select(Message).order_by(Message.received_at.desc()).limit(10)) + .scalars() + .all() + ) # Build HTML html = f""" diff --git a/src/meshcore_hub/collector/handlers/trace.py b/src/meshcore_hub/collector/handlers/trace.py index 13e42b4..646860d 100644 --- a/src/meshcore_hub/collector/handlers/trace.py +++ b/src/meshcore_hub/collector/handlers/trace.py @@ -72,6 +72,4 @@ def handle_trace_data( ) session.add(trace_path) - logger.info( - f"Stored trace data: tag={initiator_tag}, hops={hop_count}" - ) + logger.info(f"Stored trace data: tag={initiator_tag}, hops={hop_count}") diff --git a/src/meshcore_hub/common/config.py b/src/meshcore_hub/common/config.py index 8b04806..569d67e 100644 --- a/src/meshcore_hub/common/config.py +++ b/src/meshcore_hub/common/config.py @@ -45,9 +45,7 @@ class CommonSettings(BaseSettings): mqtt_password: Optional[str] = Field( default=None, description="MQTT password (optional)" ) - mqtt_prefix: str = Field( - default="meshcore", description="MQTT topic prefix" - ) + mqtt_prefix: str = Field(default="meshcore", description="MQTT topic prefix") class InterfaceSettings(CommonSettings): @@ -60,15 +58,11 @@ class InterfaceSettings(CommonSettings): ) # Serial connection - serial_port: str = Field( - default="/dev/ttyUSB0", description="Serial port path" - ) + serial_port: str = Field(default="/dev/ttyUSB0", description="Serial port path") serial_baud: int = Field(default=115200, description="Serial baud rate") # Mock device - mock_device: bool = Field( - default=False, description="Use mock device for testing" - ) + mock_device: bool = Field(default=False, description="Use mock device for testing") class CollectorSettings(CommonSettings): @@ -103,9 +97,7 @@ class APISettings(CommonSettings): ) # Authentication - api_read_key: Optional[str] = Field( - default=None, description="Read-only API key" - ) + api_read_key: Optional[str] = Field(default=None, description="Read-only API key") api_admin_key: Optional[str] = Field( default=None, description="Admin API key (full access)" ) @@ -131,9 +123,7 @@ class WebSettings(CommonSettings): default="http://localhost:8000", description="API server base URL", ) - api_key: Optional[str] = Field( - default=None, description="API key for queries" - ) + api_key: Optional[str] = Field(default=None, description="API key for queries") # Network information network_domain: Optional[str] = Field( diff --git a/src/meshcore_hub/common/database.py b/src/meshcore_hub/common/database.py index 10671b5..13b2503 100644 --- a/src/meshcore_hub/common/database.py +++ b/src/meshcore_hub/common/database.py @@ -38,6 +38,7 @@ def create_database_engine( # Enable foreign keys for SQLite if database_url.startswith("sqlite"): + @event.listens_for(engine, "connect") def set_sqlite_pragma(dbapi_connection, connection_record): # type: ignore cursor = dbapi_connection.cursor() @@ -171,9 +172,7 @@ def get_database() -> DatabaseManager: RuntimeError: If database not initialized """ if _db_manager is None: - raise RuntimeError( - "Database not initialized. Call init_database() first." - ) + raise RuntimeError("Database not initialized. Call init_database() first.") return _db_manager diff --git a/src/meshcore_hub/common/logging.py b/src/meshcore_hub/common/logging.py index edb13f8..b358ec9 100644 --- a/src/meshcore_hub/common/logging.py +++ b/src/meshcore_hub/common/logging.py @@ -11,9 +11,7 @@ from meshcore_hub.common.config import LogLevel DEFAULT_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" # Structured log format (more suitable for production/parsing) -STRUCTURED_FORMAT = ( - "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s" -) +STRUCTURED_FORMAT = "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s" def configure_logging( diff --git a/src/meshcore_hub/common/models/advertisement.py b/src/meshcore_hub/common/models/advertisement.py index dbd9994..192cd94 100644 --- a/src/meshcore_hub/common/models/advertisement.py +++ b/src/meshcore_hub/common/models/advertisement.py @@ -59,9 +59,7 @@ class Advertisement(Base, UUIDMixin, TimestampMixin): nullable=False, ) - __table_args__ = ( - Index("ix_advertisements_received_at", "received_at"), - ) + __table_args__ = (Index("ix_advertisements_received_at", "received_at"),) def __repr__(self) -> str: return f"" diff --git a/src/meshcore_hub/common/models/telemetry.py b/src/meshcore_hub/common/models/telemetry.py index 7bf1341..77084ac 100644 --- a/src/meshcore_hub/common/models/telemetry.py +++ b/src/meshcore_hub/common/models/telemetry.py @@ -55,9 +55,9 @@ class Telemetry(Base, UUIDMixin, TimestampMixin): nullable=False, ) - __table_args__ = ( - Index("ix_telemetry_received_at", "received_at"), - ) + __table_args__ = (Index("ix_telemetry_received_at", "received_at"),) def __repr__(self) -> str: - return f"" + return ( + f"" + ) diff --git a/src/meshcore_hub/common/mqtt.py b/src/meshcore_hub/common/mqtt.py index 9ae7752..03bdb6b 100644 --- a/src/meshcore_hub/common/mqtt.py +++ b/src/meshcore_hub/common/mqtt.py @@ -151,7 +151,9 @@ class MQTTClient: """Handle connection callback.""" if reason_code == 0: self._connected = True - logger.info(f"Connected to MQTT broker at {self.config.host}:{self.config.port}") + logger.info( + f"Connected to MQTT broker at {self.config.host}:{self.config.port}" + ) # Resubscribe to topics on reconnect for topic in self._message_handlers.keys(): self._client.subscribe(topic) @@ -221,7 +223,9 @@ class MQTTClient: def connect(self) -> None: """Connect to the MQTT broker.""" - logger.info(f"Connecting to MQTT broker at {self.config.host}:{self.config.port}") + logger.info( + f"Connecting to MQTT broker at {self.config.host}:{self.config.port}" + ) self._client.connect( self.config.host, self.config.port, diff --git a/src/meshcore_hub/common/schemas/messages.py b/src/meshcore_hub/common/schemas/messages.py index 2df842e..d5329cf 100644 --- a/src/meshcore_hub/common/schemas/messages.py +++ b/src/meshcore_hub/common/schemas/messages.py @@ -17,20 +17,12 @@ class MessageRead(BaseModel): pubkey_prefix: Optional[str] = Field( default=None, description="Sender's public key prefix (12 chars)" ) - channel_idx: Optional[int] = Field( - default=None, description="Channel index" - ) + channel_idx: Optional[int] = Field(default=None, description="Channel index") text: str = Field(..., description="Message content") path_len: Optional[int] = Field(default=None, description="Number of hops") - txt_type: Optional[int] = Field( - default=None, description="Message type indicator" - ) - signature: Optional[str] = Field( - default=None, description="Message signature" - ) - snr: Optional[float] = Field( - default=None, description="Signal-to-noise ratio" - ) + txt_type: Optional[int] = Field(default=None, description="Message type indicator") + signature: Optional[str] = Field(default=None, description="Message signature") + snr: Optional[float] = Field(default=None, description="Signal-to-noise ratio") sender_timestamp: Optional[datetime] = Field( default=None, description="Sender's timestamp" ) @@ -88,9 +80,7 @@ class AdvertisementRead(BaseModel): receiver_node_id: Optional[str] = Field( default=None, description="Receiving interface node UUID" ) - node_id: Optional[str] = Field( - default=None, description="Advertised node UUID" - ) + node_id: Optional[str] = Field(default=None, description="Advertised node UUID") public_key: str = Field(..., description="Advertised public key") name: Optional[str] = Field(default=None, description="Advertised name") adv_type: Optional[str] = Field(default=None, description="Node type") @@ -152,9 +142,7 @@ class TelemetryRead(BaseModel): receiver_node_id: Optional[str] = Field( default=None, description="Receiving interface node UUID" ) - node_id: Optional[str] = Field( - default=None, description="Reporting node UUID" - ) + node_id: Optional[str] = Field(default=None, description="Reporting node UUID") node_public_key: str = Field(..., description="Reporting node public key") parsed_data: Optional[dict] = Field( default=None, description="Decoded sensor readings" diff --git a/src/meshcore_hub/common/schemas/nodes.py b/src/meshcore_hub/common/schemas/nodes.py index f696a13..e4ebdf9 100644 --- a/src/meshcore_hub/common/schemas/nodes.py +++ b/src/meshcore_hub/common/schemas/nodes.py @@ -65,9 +65,7 @@ class NodeRead(BaseModel): last_seen: datetime = Field(..., description="Last activity timestamp") created_at: datetime = Field(..., description="Record creation timestamp") updated_at: datetime = Field(..., description="Record update timestamp") - tags: list[NodeTagRead] = Field( - default_factory=list, description="Node tags" - ) + tags: list[NodeTagRead] = Field(default_factory=list, description="Node tags") class Config: from_attributes = True diff --git a/src/meshcore_hub/interface/device.py b/src/meshcore_hub/interface/device.py index 4efc58d..2843d6c 100644 --- a/src/meshcore_hub/interface/device.py +++ b/src/meshcore_hub/interface/device.py @@ -272,8 +272,7 @@ class MeshCoreDevice(BaseMeshCoreDevice): from meshcore.serial_cx import SerialConnection except ImportError: logger.error( - "meshcore library not installed. " - "Install with: pip install meshcore" + "meshcore library not installed. " "Install with: pip install meshcore" ) return False @@ -352,11 +351,16 @@ class MeshCoreDevice(BaseMeshCoreDevice): } for mc_event_type, our_event_type in event_map.items(): + async def callback(event, et=our_event_type): # Convert event to dict and dispatch # Use event.payload for the full data (text, etc.) # event.attributes only contains filtering fields - payload = dict(event.payload) if hasattr(event, 'payload') and isinstance(event.payload, dict) else {} + payload = ( + dict(event.payload) + if hasattr(event, "payload") and isinstance(event.payload, dict) + else {} + ) self._dispatch_event(et, payload) sub = self._mc.subscribe(mc_event_type, callback) @@ -394,6 +398,7 @@ class MeshCoreDevice(BaseMeshCoreDevice): return False try: + async def _send(): await self._mc.commands.send_msg(destination, text) @@ -416,6 +421,7 @@ class MeshCoreDevice(BaseMeshCoreDevice): return False try: + async def _send(): await self._mc.commands.send_chan_msg(channel_idx, text) @@ -433,6 +439,7 @@ class MeshCoreDevice(BaseMeshCoreDevice): return False try: + async def _send(): await self._mc.commands.send_advert(flood=flood) @@ -450,6 +457,7 @@ class MeshCoreDevice(BaseMeshCoreDevice): return False try: + async def _request(): await self._mc.commands.send_statusreq(target) @@ -467,6 +475,7 @@ class MeshCoreDevice(BaseMeshCoreDevice): return False try: + async def _request(): await self._mc.commands.send_telemetry_req(target) @@ -484,6 +493,7 @@ class MeshCoreDevice(BaseMeshCoreDevice): return False try: + async def _set_time(): await self._mc.commands.set_time(timestamp) @@ -501,6 +511,7 @@ class MeshCoreDevice(BaseMeshCoreDevice): return False try: + async def _start_fetching(): await self._mc.start_auto_message_fetching() @@ -560,6 +571,7 @@ def create_device( if mock: from meshcore_hub.interface.mock_device import MockMeshCoreDevice + return MockMeshCoreDevice(config) return MeshCoreDevice(config) diff --git a/src/meshcore_hub/interface/mock_device.py b/src/meshcore_hub/interface/mock_device.py index cb1c46d..89b85f6 100644 --- a/src/meshcore_hub/interface/mock_device.py +++ b/src/meshcore_hub/interface/mock_device.py @@ -172,9 +172,11 @@ class MockMeshCoreDevice(BaseMeshCoreDevice): self._dispatch_event( EventType.SEND_CONFIRMED, { - "destination_public_key": destination - if len(destination) == 64 - else destination + "0" * (64 - len(destination)), + "destination_public_key": ( + destination + if len(destination) == 64 + else destination + "0" * (64 - len(destination)) + ), "round_trip_ms": int(delay * 1000), }, ) diff --git a/src/meshcore_hub/web/routes/map.py b/src/meshcore_hub/web/routes/map.py index f3af646..dbccb32 100644 --- a/src/meshcore_hub/web/routes/map.py +++ b/src/meshcore_hub/web/routes/map.py @@ -53,14 +53,16 @@ async def map_data(request: Request) -> JSONResponse: pass if lat is not None and lon is not None: - nodes_with_location.append({ - "public_key": node.get("public_key"), - "name": node.get("name") or node.get("public_key", "")[:12], - "adv_type": node.get("adv_type"), - "lat": lat, - "lon": lon, - "last_seen": node.get("last_seen"), - }) + nodes_with_location.append( + { + "public_key": node.get("public_key"), + "name": node.get("name") or node.get("public_key", "")[:12], + "adv_type": node.get("adv_type"), + "lat": lat, + "lon": lon, + "last_seen": node.get("last_seen"), + } + ) except Exception as e: logger.warning(f"Failed to fetch nodes for map: {e}") @@ -68,10 +70,12 @@ async def map_data(request: Request) -> JSONResponse: # Get network center location network_location = request.app.state.network_location - return JSONResponse({ - "nodes": nodes_with_location, - "center": { - "lat": network_location[0], - "lon": network_location[1], - }, - }) + return JSONResponse( + { + "nodes": nodes_with_location, + "center": { + "lat": network_location[0], + "lon": network_location[1], + }, + } + ) diff --git a/src/meshcore_hub/web/routes/messages.py b/src/meshcore_hub/web/routes/messages.py index 8befb89..a0ade5e 100644 --- a/src/meshcore_hub/web/routes/messages.py +++ b/src/meshcore_hub/web/routes/messages.py @@ -54,15 +54,17 @@ async def messages_list( # Calculate pagination total_pages = (total + limit - 1) // limit if total > 0 else 1 - context.update({ - "messages": messages, - "total": total, - "page": page, - "limit": limit, - "total_pages": total_pages, - "message_type": message_type or "", - "channel_idx": channel_idx, - "search": search or "", - }) + context.update( + { + "messages": messages, + "total": total, + "page": page, + "limit": limit, + "total_pages": total_pages, + "message_type": message_type or "", + "channel_idx": channel_idx, + "search": search or "", + } + ) return templates.TemplateResponse("messages.html", context) diff --git a/src/meshcore_hub/web/routes/nodes.py b/src/meshcore_hub/web/routes/nodes.py index e85870d..e9f7bbb 100644 --- a/src/meshcore_hub/web/routes/nodes.py +++ b/src/meshcore_hub/web/routes/nodes.py @@ -53,15 +53,17 @@ async def nodes_list( # Calculate pagination total_pages = (total + limit - 1) // limit if total > 0 else 1 - context.update({ - "nodes": nodes, - "total": total, - "page": page, - "limit": limit, - "total_pages": total_pages, - "search": search or "", - "adv_type": adv_type or "", - }) + context.update( + { + "nodes": nodes, + "total": total, + "page": page, + "limit": limit, + "total_pages": total_pages, + "search": search or "", + "adv_type": adv_type or "", + } + ) return templates.TemplateResponse("nodes.html", context) @@ -79,22 +81,22 @@ async def node_detail(request: Request, public_key: str) -> HTMLResponse: try: # Fetch node details - response = await request.app.state.http_client.get(f"/api/v1/nodes/{public_key}") + response = await request.app.state.http_client.get( + f"/api/v1/nodes/{public_key}" + ) if response.status_code == 200: node = response.json() # Fetch recent advertisements for this node response = await request.app.state.http_client.get( - "/api/v1/advertisements", - params={"public_key": public_key, "limit": 10} + "/api/v1/advertisements", params={"public_key": public_key, "limit": 10} ) if response.status_code == 200: advertisements = response.json().get("items", []) # Fetch recent telemetry for this node response = await request.app.state.http_client.get( - "/api/v1/telemetry", - params={"node_public_key": public_key, "limit": 10} + "/api/v1/telemetry", params={"node_public_key": public_key, "limit": 10} ) if response.status_code == 200: telemetry = response.json().get("items", []) @@ -103,11 +105,13 @@ async def node_detail(request: Request, public_key: str) -> HTMLResponse: logger.warning(f"Failed to fetch node details from API: {e}") context["api_error"] = str(e) - context.update({ - "node": node, - "advertisements": advertisements, - "telemetry": telemetry, - "public_key": public_key, - }) + context.update( + { + "node": node, + "advertisements": advertisements, + "telemetry": telemetry, + "public_key": public_key, + } + ) return templates.TemplateResponse("node_detail.html", context) diff --git a/tests/test_api/conftest.py b/tests/test_api/conftest.py index 55e5ccf..0e6483a 100644 --- a/tests/test_api/conftest.py +++ b/tests/test_api/conftest.py @@ -11,7 +11,11 @@ from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from meshcore_hub.api.app import create_app -from meshcore_hub.api.dependencies import get_db_session, get_mqtt_client, get_db_manager +from meshcore_hub.api.dependencies import ( + get_db_session, + get_mqtt_client, + get_db_manager, +) from meshcore_hub.common.database import DatabaseManager from meshcore_hub.common.models import ( Advertisement, diff --git a/tests/test_api/test_advertisements.py b/tests/test_api/test_advertisements.py index 803a13f..e5c2387 100644 --- a/tests/test_api/test_advertisements.py +++ b/tests/test_api/test_advertisements.py @@ -35,9 +35,7 @@ class TestListAdvertisements: data = response.json() assert len(data["items"]) == 1 - response = client_no_auth.get( - "/api/v1/advertisements?public_key=nonexistent" - ) + response = client_no_auth.get("/api/v1/advertisements?public_key=nonexistent") assert response.status_code == 200 data = response.json() assert len(data["items"]) == 0 diff --git a/tests/test_api/test_telemetry.py b/tests/test_api/test_telemetry.py index 115e7ad..b303024 100644 --- a/tests/test_api/test_telemetry.py +++ b/tests/test_api/test_telemetry.py @@ -33,9 +33,7 @@ class TestListTelemetry: data = response.json() assert len(data["items"]) == 1 - response = client_no_auth.get( - "/api/v1/telemetry?node_public_key=nonexistent" - ) + response = client_no_auth.get("/api/v1/telemetry?node_public_key=nonexistent") assert response.status_code == 200 data = response.json() assert len(data["items"]) == 0 diff --git a/tests/test_collector/test_subscriber.py b/tests/test_collector/test_subscriber.py index 722161e..439bba0 100644 --- a/tests/test_collector/test_subscriber.py +++ b/tests/test_collector/test_subscriber.py @@ -15,7 +15,10 @@ class TestSubscriber: client = MagicMock() client.topic_builder = MagicMock() client.topic_builder.all_events_topic.return_value = "meshcore/+/event/#" - client.topic_builder.parse_event_topic.return_value = ("a" * 64, "advertisement") + client.topic_builder.parse_event_topic.return_value = ( + "a" * 64, + "advertisement", + ) return client @pytest.fixture @@ -47,7 +50,9 @@ class TestSubscriber: mock_mqtt_client.stop.assert_called_once() mock_mqtt_client.disconnect.assert_called_once() - def test_handle_mqtt_message_calls_handler(self, subscriber, mock_mqtt_client, db_manager): + def test_handle_mqtt_message_calls_handler( + self, subscriber, mock_mqtt_client, db_manager + ): """Test that MQTT messages are routed to handlers.""" handler = MagicMock() subscriber.register_handler("advertisement", handler) diff --git a/tests/test_interface/test_receiver.py b/tests/test_interface/test_receiver.py index 1ca2fb4..fc8e023 100644 --- a/tests/test_interface/test_receiver.py +++ b/tests/test_interface/test_receiver.py @@ -24,7 +24,9 @@ class TestReceiver: """Create a receiver instance.""" return Receiver(mock_device, mock_mqtt_client) - def test_start_connects_device_and_mqtt(self, receiver, mock_device, mock_mqtt_client): + def test_start_connects_device_and_mqtt( + self, receiver, mock_device, mock_mqtt_client + ): """Test that start connects to device and MQTT.""" receiver.start() @@ -32,7 +34,9 @@ class TestReceiver: mock_mqtt_client.connect.assert_called_once() mock_mqtt_client.start_background.assert_called_once() - def test_stop_disconnects_device_and_mqtt(self, receiver, mock_device, mock_mqtt_client): + def test_stop_disconnects_device_and_mqtt( + self, receiver, mock_device, mock_mqtt_client + ): """Test that stop disconnects device and MQTT.""" receiver.start() receiver.stop() @@ -53,6 +57,7 @@ class TestReceiver: # Allow time for event processing import time + time.sleep(0.1) # Verify MQTT publish was called diff --git a/tests/test_interface/test_sender.py b/tests/test_interface/test_sender.py index 8ed614f..7d86d74 100644 --- a/tests/test_interface/test_sender.py +++ b/tests/test_interface/test_sender.py @@ -25,7 +25,9 @@ class TestSender: """Create a sender instance.""" return Sender(mock_device, mock_mqtt_client) - def test_start_connects_device_and_mqtt(self, sender, mock_device, mock_mqtt_client): + def test_start_connects_device_and_mqtt( + self, sender, mock_device, mock_mqtt_client + ): """Test that start connects to device and MQTT.""" sender.start() @@ -34,7 +36,9 @@ class TestSender: mock_mqtt_client.start_background.assert_called_once() mock_mqtt_client.subscribe.assert_called_once() - def test_stop_disconnects_device_and_mqtt(self, sender, mock_device, mock_mqtt_client): + def test_stop_disconnects_device_and_mqtt( + self, sender, mock_device, mock_mqtt_client + ): """Test that stop disconnects device and MQTT.""" sender.start() sender.stop() @@ -60,7 +64,9 @@ class TestSender: # Verify message was sent (device is mocked, so just check no error) assert mock_device.is_connected - def test_handle_send_channel_msg_command(self, sender, mock_device, mock_mqtt_client): + def test_handle_send_channel_msg_command( + self, sender, mock_device, mock_mqtt_client + ): """Test handling send_channel_msg command.""" mock_mqtt_client.topic_builder.parse_command_topic.return_value = ( "abc123",