mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-08-02 23:12:31 +02:00
Updates
This commit is contained in:
+2
-2
@@ -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/
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
3.11
|
||||
@@ -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:
|
||||
|
||||
@@ -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) |
|
||||
|
||||
|
||||
@@ -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"])
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"<Advertisement(id={self.id}, public_key={self.public_key[:12]}..., name={self.name})>"
|
||||
|
||||
@@ -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"<Telemetry(id={self.id}, node_public_key={self.node_public_key[:12]}...)>"
|
||||
return (
|
||||
f"<Telemetry(id={self.id}, node_public_key={self.node_public_key[:12]}...)>"
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user