mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-08-09 10:23:03 +02:00
Merge pull request #1 from ipnet-mesh/claude/start-project-development-01H6a5BA6hagqsB7JvCBjcdn
Initial Project Setup
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
# MeshCore Hub - Environment Configuration Example
|
||||
# Copy this file to .env and customize values
|
||||
|
||||
# ===================
|
||||
# Common Settings
|
||||
# ===================
|
||||
|
||||
# Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# MQTT Broker Settings
|
||||
MQTT_HOST=localhost
|
||||
MQTT_PORT=1883
|
||||
MQTT_USERNAME=
|
||||
MQTT_PASSWORD=
|
||||
MQTT_PREFIX=meshcore
|
||||
|
||||
# ===================
|
||||
# Interface Settings
|
||||
# ===================
|
||||
|
||||
# Mode of operation (RECEIVER or SENDER)
|
||||
INTERFACE_MODE=RECEIVER
|
||||
|
||||
# Serial port for MeshCore device
|
||||
SERIAL_PORT=/dev/ttyUSB0
|
||||
SERIAL_BAUD=115200
|
||||
|
||||
# Use mock device for testing (true/false)
|
||||
MOCK_DEVICE=false
|
||||
|
||||
# ===================
|
||||
# Collector Settings
|
||||
# ===================
|
||||
|
||||
# Database connection URL
|
||||
# SQLite: sqlite:///./meshcore.db
|
||||
# PostgreSQL: postgresql://user:password@localhost/meshcore
|
||||
DATABASE_URL=sqlite:///./meshcore.db
|
||||
|
||||
# ===================
|
||||
# API Settings
|
||||
# ===================
|
||||
|
||||
# API Server binding
|
||||
API_HOST=0.0.0.0
|
||||
API_PORT=8000
|
||||
|
||||
# API Keys for authentication
|
||||
# Generate secure keys for production!
|
||||
API_READ_KEY=
|
||||
API_ADMIN_KEY=
|
||||
|
||||
# ===================
|
||||
# Web Dashboard Settings
|
||||
# ===================
|
||||
|
||||
# Web Server binding
|
||||
WEB_HOST=0.0.0.0
|
||||
WEB_PORT=8080
|
||||
|
||||
# API connection for web dashboard
|
||||
API_BASE_URL=http://localhost:8000
|
||||
API_KEY=
|
||||
|
||||
# Network Information (displayed on web dashboard)
|
||||
NETWORK_DOMAIN=
|
||||
NETWORK_NAME=MeshCore Network
|
||||
NETWORK_CITY=
|
||||
NETWORK_COUNTRY=
|
||||
NETWORK_LOCATION=
|
||||
NETWORK_RADIO_CONFIG=
|
||||
NETWORK_CONTACT_EMAIL=
|
||||
NETWORK_CONTACT_DISCORD=
|
||||
|
||||
# Path to members JSON file
|
||||
MEMBERS_FILE=members.json
|
||||
@@ -0,0 +1,16 @@
|
||||
[flake8]
|
||||
max-line-length = 88
|
||||
extend-ignore = E203, E501, W503
|
||||
exclude =
|
||||
.git,
|
||||
__pycache__,
|
||||
.venv,
|
||||
venv,
|
||||
build,
|
||||
dist,
|
||||
*.egg-info,
|
||||
alembic/versions,
|
||||
.mypy_cache,
|
||||
.pytest_cache
|
||||
per-file-ignores =
|
||||
__init__.py: F401
|
||||
@@ -205,3 +205,8 @@ cython_debug/
|
||||
marimo/_static/
|
||||
marimo/_lsp/
|
||||
__marimo__/
|
||||
|
||||
# MeshCore Hub specific
|
||||
*.db
|
||||
meshcore.db
|
||||
members.json
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.5.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
- id: end-of-file-fixer
|
||||
- id: check-yaml
|
||||
- id: check-added-large-files
|
||||
- id: check-merge-conflict
|
||||
- id: check-toml
|
||||
- id: debug-statements
|
||||
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 24.3.0
|
||||
hooks:
|
||||
- id: black
|
||||
language_version: python3.11
|
||||
args: ["--line-length=88"]
|
||||
|
||||
- repo: https://github.com/pycqa/flake8
|
||||
rev: 7.0.0
|
||||
hooks:
|
||||
- id: flake8
|
||||
additional_dependencies:
|
||||
- flake8-bugbear
|
||||
- flake8-comprehensions
|
||||
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: v1.9.0
|
||||
hooks:
|
||||
- id: mypy
|
||||
additional_dependencies:
|
||||
- pydantic>=2.0.0
|
||||
- pydantic-settings>=2.0.0
|
||||
- sqlalchemy>=2.0.0
|
||||
- fastapi>=0.100.0
|
||||
- types-paho-mqtt>=1.6.0
|
||||
args: ["--ignore-missing-imports"]
|
||||
@@ -31,7 +31,7 @@ MeshCore Hub is a Python 3.11+ monorepo for managing and orchestrating MeshCore
|
||||
| Migrations | Alembic |
|
||||
| REST API | FastAPI |
|
||||
| MQTT Client | paho-mqtt |
|
||||
| MeshCore Interface | meshcore-py |
|
||||
| MeshCore Interface | meshcore |
|
||||
| Templates | Jinja2 |
|
||||
| CSS Framework | Tailwind CSS + DaisyUI |
|
||||
| Testing | pytest, pytest-asyncio |
|
||||
@@ -432,9 +432,60 @@ logging.basicConfig(level=logging.DEBUG)
|
||||
export LOG_LEVEL=DEBUG
|
||||
```
|
||||
|
||||
## MeshCore Library Integration
|
||||
|
||||
The interface component uses the `meshcore` Python library to communicate with MeshCore devices. Key patterns:
|
||||
|
||||
### Device Commands
|
||||
|
||||
Commands are accessed via `mc.commands.*` on the MeshCore instance:
|
||||
|
||||
```python
|
||||
# Set device time
|
||||
await mc.commands.set_time(unix_timestamp)
|
||||
|
||||
# Send advertisement
|
||||
await mc.commands.send_advert(flood=False)
|
||||
|
||||
# Send messages
|
||||
await mc.commands.send_msg(destination, text)
|
||||
await mc.commands.send_chan_msg(channel_idx, text)
|
||||
|
||||
# Request data
|
||||
await mc.commands.send_statusreq(target)
|
||||
await mc.commands.send_telemetry_req(target)
|
||||
```
|
||||
|
||||
### Event Subscription
|
||||
|
||||
Events are received via the subscription system. The `Event` object has:
|
||||
- `event.type` - The event type enum
|
||||
- `event.payload` - Full event data (dict with all fields like `text`, `pubkey_prefix`, etc.)
|
||||
- `event.attributes` - Subset of fields for filtering
|
||||
|
||||
**Important**: Use `event.payload` (not `event.attributes`) to get full message data.
|
||||
|
||||
### Auto Message Fetching
|
||||
|
||||
The library requires explicit message fetching. Call `start_auto_message_fetching()` to:
|
||||
1. Subscribe to `MESSAGES_WAITING` events
|
||||
2. Automatically call `get_msg()` to fetch pending messages
|
||||
3. Immediately fetch any queued messages on startup
|
||||
|
||||
```python
|
||||
await mc.start_auto_message_fetching()
|
||||
```
|
||||
|
||||
### Receiver Initialization
|
||||
|
||||
On startup, the receiver performs these initialization steps:
|
||||
1. Set device clock to current Unix timestamp
|
||||
2. Send a local (non-flood) advertisement
|
||||
3. Start automatic message fetching
|
||||
|
||||
## References
|
||||
|
||||
- [meshcore_py Documentation](https://github.com/meshcore-dev/meshcore_py)
|
||||
- [meshcore Documentation](https://github.com/fdlamotte/meshcore)
|
||||
- [FastAPI Documentation](https://fastapi.tiangolo.com/)
|
||||
- [SQLAlchemy 2.0 Documentation](https://docs.sqlalchemy.org/en/20/)
|
||||
- [Pydantic Documentation](https://docs.pydantic.dev/)
|
||||
|
||||
@@ -316,4 +316,4 @@ See [LICENSE](LICENSE) for details.
|
||||
## Acknowledgments
|
||||
|
||||
- [MeshCore](https://meshcore.dev/) - The mesh networking protocol
|
||||
- [meshcore_py](https://github.com/meshcore-dev/meshcore_py) - Python library for MeshCore devices
|
||||
- [meshcore](https://github.com/fdlamotte/meshcore) - Python library for MeshCore devices
|
||||
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
# A generic, single database configuration for Alembic.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts
|
||||
script_location = alembic
|
||||
|
||||
# template used to generate migration file names
|
||||
file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
prepend_sys_path = src
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
timezone = UTC
|
||||
|
||||
# max length of characters to apply to the "slug" field
|
||||
truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during the 'revision' command
|
||||
revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without a source .py file
|
||||
# to be detected as revisions in the versions/ directory
|
||||
sourceless = false
|
||||
|
||||
# version location specification; This defaults to alembic/versions.
|
||||
version_locations = %(here)s/alembic/versions
|
||||
|
||||
# version path separator
|
||||
version_path_separator = os
|
||||
|
||||
# set to 'true' to search source files recursively
|
||||
recursive_version_locations = false
|
||||
|
||||
# the output encoding used when revision files are written from script.py.mako
|
||||
output_encoding = utf-8
|
||||
|
||||
# Database URL - can be overridden by environment variable
|
||||
sqlalchemy.url = sqlite:///./meshcore.db
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
# on newly generated revision scripts.
|
||||
|
||||
# format using "black" - only if black is installed
|
||||
hooks = black
|
||||
black.type = console_scripts
|
||||
black.entrypoint = black
|
||||
black.options = -q
|
||||
|
||||
|
||||
# Logging configuration
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Alembic environment configuration."""
|
||||
|
||||
import os
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
from meshcore_hub.common.models import Base
|
||||
|
||||
# this is the Alembic Config object
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# Model's MetaData object for 'autogenerate' support
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def get_database_url() -> str:
|
||||
"""Get database URL from environment or config."""
|
||||
# First try environment variable
|
||||
url = os.environ.get("DATABASE_URL")
|
||||
if url:
|
||||
return url
|
||||
# Fall back to alembic.ini
|
||||
return config.get_main_option("sqlalchemy.url", "sqlite:///./meshcore.db")
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
This configures the context with just a URL
|
||||
and not an Engine, though an Engine is acceptable
|
||||
here as well. By skipping the Engine creation
|
||||
we don't even need a DBAPI to be available.
|
||||
|
||||
Calls to context.execute() here emit the given string to the
|
||||
script output.
|
||||
"""
|
||||
url = get_database_url()
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
render_as_batch=True, # SQLite batch mode for ALTER TABLE
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode.
|
||||
|
||||
In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
"""
|
||||
configuration = config.get_section(config.config_ini_section, {})
|
||||
configuration["sqlalchemy.url"] = get_database_url()
|
||||
|
||||
connectable = engine_from_config(
|
||||
configuration,
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
render_as_batch=True, # SQLite batch mode for ALTER TABLE
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,26 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,244 @@
|
||||
"""Initial database schema
|
||||
|
||||
Revision ID: 001
|
||||
Revises:
|
||||
Create Date: 2024-12-02
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "001"
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Create nodes table
|
||||
op.create_table(
|
||||
"nodes",
|
||||
sa.Column("id", sa.String(), nullable=False),
|
||||
sa.Column("public_key", sa.String(64), nullable=False),
|
||||
sa.Column("name", sa.String(255), nullable=True),
|
||||
sa.Column("adv_type", sa.String(20), nullable=True),
|
||||
sa.Column("flags", sa.Integer(), nullable=True),
|
||||
sa.Column("first_seen", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("last_seen", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("public_key"),
|
||||
)
|
||||
op.create_index("ix_nodes_public_key", "nodes", ["public_key"])
|
||||
op.create_index("ix_nodes_last_seen", "nodes", ["last_seen"])
|
||||
op.create_index("ix_nodes_adv_type", "nodes", ["adv_type"])
|
||||
|
||||
# Create node_tags table
|
||||
op.create_table(
|
||||
"node_tags",
|
||||
sa.Column("id", sa.String(), nullable=False),
|
||||
sa.Column("node_id", sa.String(), nullable=False),
|
||||
sa.Column("key", sa.String(100), nullable=False),
|
||||
sa.Column("value", sa.Text(), nullable=True),
|
||||
sa.Column("value_type", sa.String(20), nullable=False, server_default="string"),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.ForeignKeyConstraint(["node_id"], ["nodes.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("node_id", "key", name="uq_node_tags_node_key"),
|
||||
)
|
||||
op.create_index("ix_node_tags_node_id", "node_tags", ["node_id"])
|
||||
op.create_index("ix_node_tags_key", "node_tags", ["key"])
|
||||
|
||||
# Create messages table
|
||||
op.create_table(
|
||||
"messages",
|
||||
sa.Column("id", sa.String(), nullable=False),
|
||||
sa.Column("receiver_node_id", sa.String(), nullable=True),
|
||||
sa.Column("message_type", sa.String(20), nullable=False),
|
||||
sa.Column("pubkey_prefix", sa.String(12), nullable=True),
|
||||
sa.Column("channel_idx", sa.Integer(), nullable=True),
|
||||
sa.Column("text", sa.Text(), nullable=False),
|
||||
sa.Column("path_len", sa.Integer(), nullable=True),
|
||||
sa.Column("txt_type", sa.Integer(), nullable=True),
|
||||
sa.Column("signature", sa.String(8), nullable=True),
|
||||
sa.Column("snr", sa.Float(), nullable=True),
|
||||
sa.Column("sender_timestamp", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("received_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
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"])
|
||||
op.create_index("ix_messages_message_type", "messages", ["message_type"])
|
||||
op.create_index("ix_messages_pubkey_prefix", "messages", ["pubkey_prefix"])
|
||||
op.create_index("ix_messages_channel_idx", "messages", ["channel_idx"])
|
||||
op.create_index("ix_messages_received_at", "messages", ["received_at"])
|
||||
|
||||
# Create advertisements table
|
||||
op.create_table(
|
||||
"advertisements",
|
||||
sa.Column("id", sa.String(), nullable=False),
|
||||
sa.Column("receiver_node_id", sa.String(), nullable=True),
|
||||
sa.Column("node_id", sa.String(), nullable=True),
|
||||
sa.Column("public_key", sa.String(64), nullable=False),
|
||||
sa.Column("name", sa.String(255), nullable=True),
|
||||
sa.Column("adv_type", sa.String(20), nullable=True),
|
||||
sa.Column("flags", sa.Integer(), nullable=True),
|
||||
sa.Column("received_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
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_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"])
|
||||
|
||||
# Create trace_paths table
|
||||
op.create_table(
|
||||
"trace_paths",
|
||||
sa.Column("id", sa.String(), nullable=False),
|
||||
sa.Column("receiver_node_id", sa.String(), nullable=True),
|
||||
sa.Column("initiator_tag", sa.BigInteger(), nullable=False),
|
||||
sa.Column("path_len", sa.Integer(), nullable=True),
|
||||
sa.Column("flags", sa.Integer(), nullable=True),
|
||||
sa.Column("auth", sa.Integer(), nullable=True),
|
||||
sa.Column("path_hashes", sa.JSON(), nullable=True),
|
||||
sa.Column("snr_values", sa.JSON(), nullable=True),
|
||||
sa.Column("hop_count", sa.Integer(), nullable=True),
|
||||
sa.Column("received_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
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_initiator_tag", "trace_paths", ["initiator_tag"])
|
||||
op.create_index("ix_trace_paths_received_at", "trace_paths", ["received_at"])
|
||||
|
||||
# Create telemetry table
|
||||
op.create_table(
|
||||
"telemetry",
|
||||
sa.Column("id", sa.String(), nullable=False),
|
||||
sa.Column("receiver_node_id", sa.String(), nullable=True),
|
||||
sa.Column("node_id", sa.String(), nullable=True),
|
||||
sa.Column("node_public_key", sa.String(64), nullable=False),
|
||||
sa.Column("lpp_data", sa.LargeBinary(), nullable=True),
|
||||
sa.Column("parsed_data", sa.JSON(), nullable=True),
|
||||
sa.Column("received_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
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_telemetry_receiver_node_id", "telemetry", ["receiver_node_id"])
|
||||
op.create_index("ix_telemetry_node_id", "telemetry", ["node_id"])
|
||||
op.create_index("ix_telemetry_node_public_key", "telemetry", ["node_public_key"])
|
||||
op.create_index("ix_telemetry_received_at", "telemetry", ["received_at"])
|
||||
|
||||
# Create events_log table
|
||||
op.create_table(
|
||||
"events_log",
|
||||
sa.Column("id", sa.String(), nullable=False),
|
||||
sa.Column("receiver_node_id", sa.String(), nullable=True),
|
||||
sa.Column("event_type", sa.String(50), nullable=False),
|
||||
sa.Column("payload", sa.JSON(), nullable=True),
|
||||
sa.Column("received_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
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_event_type", "events_log", ["event_type"])
|
||||
op.create_index("ix_events_log_received_at", "events_log", ["received_at"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("events_log")
|
||||
op.drop_table("telemetry")
|
||||
op.drop_table("trace_paths")
|
||||
op.drop_table("advertisements")
|
||||
op.drop_table("messages")
|
||||
op.drop_table("node_tags")
|
||||
op.drop_table("nodes")
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "meshcore-hub"
|
||||
version = "0.1.0"
|
||||
description = "Python monorepo for managing and orchestrating MeshCore mesh networks"
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
requires-python = ">=3.11"
|
||||
authors = [
|
||||
{name = "MeshCore Hub Contributors"}
|
||||
]
|
||||
classifiers = [
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Topic :: Communications",
|
||||
"Topic :: System :: Networking",
|
||||
]
|
||||
keywords = ["meshcore", "mesh", "network", "mqtt", "lora"]
|
||||
dependencies = [
|
||||
"click>=8.1.0",
|
||||
"pydantic>=2.0.0",
|
||||
"pydantic-settings>=2.0.0",
|
||||
"python-dotenv>=1.0.0",
|
||||
"sqlalchemy>=2.0.0",
|
||||
"alembic>=1.12.0",
|
||||
"fastapi>=0.100.0",
|
||||
"uvicorn[standard]>=0.23.0",
|
||||
"paho-mqtt>=2.0.0",
|
||||
"jinja2>=3.1.0",
|
||||
"python-multipart>=0.0.6",
|
||||
"httpx>=0.25.0",
|
||||
"aiosqlite>=0.19.0",
|
||||
"meshcore>=2.2.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=7.4.0",
|
||||
"pytest-asyncio>=0.21.0",
|
||||
"pytest-cov>=4.1.0",
|
||||
"black>=23.0.0",
|
||||
"flake8>=6.1.0",
|
||||
"mypy>=1.5.0",
|
||||
"pre-commit>=3.4.0",
|
||||
"types-paho-mqtt>=1.6.0",
|
||||
]
|
||||
postgres = [
|
||||
"asyncpg>=0.28.0",
|
||||
"psycopg2-binary>=2.9.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
meshcore-hub = "meshcore_hub.__main__:main"
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/meshcore-dev/meshcore-hub"
|
||||
Documentation = "https://github.com/meshcore-dev/meshcore-hub#readme"
|
||||
Repository = "https://github.com/meshcore-dev/meshcore-hub"
|
||||
Issues = "https://github.com/meshcore-dev/meshcore-hub/issues"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
meshcore_hub = ["py.typed", "templates/**/*", "static/**/*"]
|
||||
|
||||
[tool.black]
|
||||
line-length = 88
|
||||
target-version = ["py311"]
|
||||
include = '\.pyi?$'
|
||||
extend-exclude = '''
|
||||
/(
|
||||
\.eggs
|
||||
| \.git
|
||||
| \.hg
|
||||
| \.mypy_cache
|
||||
| \.tox
|
||||
| \.venv
|
||||
| _build
|
||||
| buck-out
|
||||
| build
|
||||
| dist
|
||||
| alembic/versions
|
||||
)/
|
||||
'''
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.11"
|
||||
warn_return_any = true
|
||||
warn_unused_ignores = true
|
||||
disallow_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
check_untyped_defs = true
|
||||
strict_optional = true
|
||||
plugins = ["pydantic.mypy"]
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = [
|
||||
"paho.*",
|
||||
"uvicorn.*",
|
||||
]
|
||||
ignore_missing_imports = true
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
minversion = "7.0"
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
python_files = ["test_*.py"]
|
||||
python_classes = ["Test*"]
|
||||
python_functions = ["test_*"]
|
||||
addopts = [
|
||||
"-ra",
|
||||
"-q",
|
||||
"--strict-markers",
|
||||
"--cov=meshcore_hub",
|
||||
"--cov-report=term-missing",
|
||||
]
|
||||
filterwarnings = [
|
||||
"ignore::DeprecationWarning",
|
||||
]
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["src/meshcore_hub"]
|
||||
branch = true
|
||||
omit = [
|
||||
"*/tests/*",
|
||||
"*/__pycache__/*",
|
||||
]
|
||||
|
||||
[tool.coverage.report]
|
||||
exclude_lines = [
|
||||
"pragma: no cover",
|
||||
"def __repr__",
|
||||
"raise NotImplementedError",
|
||||
"if TYPE_CHECKING:",
|
||||
"if __name__ == .__main__.:",
|
||||
]
|
||||
@@ -0,0 +1,3 @@
|
||||
"""MeshCore Hub - Python monorepo for managing MeshCore mesh networks."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,180 @@
|
||||
"""MeshCore Hub CLI entry point."""
|
||||
|
||||
import click
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from meshcore_hub import __version__
|
||||
from meshcore_hub.common.config import LogLevel
|
||||
from meshcore_hub.common.logging import configure_logging
|
||||
|
||||
# Load .env file early so Click's envvar parameter picks up values
|
||||
load_dotenv()
|
||||
|
||||
|
||||
@click.group()
|
||||
@click.version_option(version=__version__, prog_name="meshcore-hub")
|
||||
@click.option(
|
||||
"--log-level",
|
||||
type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]),
|
||||
default="INFO",
|
||||
envvar="LOG_LEVEL",
|
||||
help="Set logging level",
|
||||
)
|
||||
@click.pass_context
|
||||
def cli(ctx: click.Context, log_level: str) -> None:
|
||||
"""MeshCore Hub - Mesh network management and orchestration.
|
||||
|
||||
A Python monorepo for managing and orchestrating MeshCore mesh networks.
|
||||
Provides components for interfacing with devices, collecting data,
|
||||
REST API access, and web dashboard visualization.
|
||||
"""
|
||||
ctx.ensure_object(dict)
|
||||
ctx.obj["log_level"] = LogLevel(log_level)
|
||||
configure_logging(level=ctx.obj["log_level"])
|
||||
|
||||
|
||||
# Import and register component CLIs
|
||||
from meshcore_hub.interface.cli import interface
|
||||
from meshcore_hub.collector.cli import collector
|
||||
from meshcore_hub.api.cli import api
|
||||
from meshcore_hub.web.cli import web
|
||||
|
||||
cli.add_command(interface)
|
||||
cli.add_command(collector)
|
||||
cli.add_command(api)
|
||||
cli.add_command(web)
|
||||
|
||||
|
||||
@cli.group()
|
||||
def db() -> None:
|
||||
"""Database migration commands.
|
||||
|
||||
Manage database schema migrations using Alembic.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
@db.command("upgrade")
|
||||
@click.option(
|
||||
"--revision",
|
||||
type=str,
|
||||
default="head",
|
||||
help="Target revision (default: head)",
|
||||
)
|
||||
@click.option(
|
||||
"--database-url",
|
||||
type=str,
|
||||
default=None,
|
||||
envvar="DATABASE_URL",
|
||||
help="Database connection URL",
|
||||
)
|
||||
def db_upgrade(revision: str, database_url: str | None) -> None:
|
||||
"""Upgrade database to a later version."""
|
||||
import os
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
|
||||
click.echo(f"Upgrading database to revision: {revision}")
|
||||
|
||||
alembic_cfg = Config("alembic.ini")
|
||||
if database_url:
|
||||
os.environ["DATABASE_URL"] = database_url
|
||||
|
||||
command.upgrade(alembic_cfg, revision)
|
||||
click.echo("Database upgrade complete.")
|
||||
|
||||
|
||||
@db.command("downgrade")
|
||||
@click.option(
|
||||
"--revision",
|
||||
type=str,
|
||||
required=True,
|
||||
help="Target revision",
|
||||
)
|
||||
@click.option(
|
||||
"--database-url",
|
||||
type=str,
|
||||
default=None,
|
||||
envvar="DATABASE_URL",
|
||||
help="Database connection URL",
|
||||
)
|
||||
def db_downgrade(revision: str, database_url: str | None) -> None:
|
||||
"""Revert database to a previous version."""
|
||||
import os
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
|
||||
click.echo(f"Downgrading database to revision: {revision}")
|
||||
|
||||
alembic_cfg = Config("alembic.ini")
|
||||
if database_url:
|
||||
os.environ["DATABASE_URL"] = database_url
|
||||
|
||||
command.downgrade(alembic_cfg, revision)
|
||||
click.echo("Database downgrade complete.")
|
||||
|
||||
|
||||
@db.command("revision")
|
||||
@click.option(
|
||||
"-m",
|
||||
"--message",
|
||||
type=str,
|
||||
required=True,
|
||||
help="Revision message",
|
||||
)
|
||||
@click.option(
|
||||
"--autogenerate",
|
||||
is_flag=True,
|
||||
default=True,
|
||||
help="Autogenerate migration from models",
|
||||
)
|
||||
def db_revision(message: str, autogenerate: bool) -> None:
|
||||
"""Create a new database migration."""
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
|
||||
click.echo(f"Creating new revision: {message}")
|
||||
|
||||
alembic_cfg = Config("alembic.ini")
|
||||
command.revision(alembic_cfg, message=message, autogenerate=autogenerate)
|
||||
click.echo("Revision created.")
|
||||
|
||||
|
||||
@db.command("current")
|
||||
@click.option(
|
||||
"--database-url",
|
||||
type=str,
|
||||
default=None,
|
||||
envvar="DATABASE_URL",
|
||||
help="Database connection URL",
|
||||
)
|
||||
def db_current(database_url: str | None) -> None:
|
||||
"""Show current database revision."""
|
||||
import os
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
|
||||
alembic_cfg = Config("alembic.ini")
|
||||
if database_url:
|
||||
os.environ["DATABASE_URL"] = database_url
|
||||
|
||||
command.current(alembic_cfg)
|
||||
|
||||
|
||||
@db.command("history")
|
||||
def db_history() -> None:
|
||||
"""Show database migration history."""
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
|
||||
alembic_cfg = Config("alembic.ini")
|
||||
command.history(alembic_cfg)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Main entry point."""
|
||||
cli()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1 @@
|
||||
"""REST API component for querying data and sending commands."""
|
||||
@@ -0,0 +1,123 @@
|
||||
"""FastAPI application for MeshCore Hub API."""
|
||||
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from meshcore_hub import __version__
|
||||
from meshcore_hub.common.database import DatabaseManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Global database manager (set during startup)
|
||||
_db_manager: DatabaseManager | None = None
|
||||
|
||||
|
||||
def get_db_manager() -> DatabaseManager:
|
||||
"""Get the global database manager."""
|
||||
if _db_manager is None:
|
||||
raise RuntimeError("Database not initialized")
|
||||
return _db_manager
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
"""Application lifespan handler."""
|
||||
global _db_manager
|
||||
|
||||
# Get database URL from app state
|
||||
database_url = getattr(app.state, "database_url", "sqlite:///./meshcore.db")
|
||||
|
||||
# Initialize database
|
||||
logger.info(f"Initializing database: {database_url}")
|
||||
_db_manager = DatabaseManager(database_url)
|
||||
_db_manager.create_tables()
|
||||
|
||||
yield
|
||||
|
||||
# Cleanup
|
||||
if _db_manager:
|
||||
_db_manager.dispose()
|
||||
_db_manager = None
|
||||
logger.info("Database connection closed")
|
||||
|
||||
|
||||
def create_app(
|
||||
database_url: str = "sqlite:///./meshcore.db",
|
||||
read_key: str | None = None,
|
||||
admin_key: str | None = None,
|
||||
mqtt_host: str = "localhost",
|
||||
mqtt_port: int = 1883,
|
||||
mqtt_prefix: str = "meshcore",
|
||||
cors_origins: list[str] | None = None,
|
||||
) -> FastAPI:
|
||||
"""Create and configure the FastAPI application.
|
||||
|
||||
Args:
|
||||
database_url: Database connection URL
|
||||
read_key: Read-only API key
|
||||
admin_key: Admin API key
|
||||
mqtt_host: MQTT broker host
|
||||
mqtt_port: MQTT broker port
|
||||
mqtt_prefix: MQTT topic prefix
|
||||
cors_origins: Allowed CORS origins
|
||||
|
||||
Returns:
|
||||
Configured FastAPI application
|
||||
"""
|
||||
app = FastAPI(
|
||||
title="MeshCore Hub API",
|
||||
description="REST API for querying MeshCore network data and sending commands",
|
||||
version=__version__,
|
||||
lifespan=lifespan,
|
||||
docs_url="/api/docs",
|
||||
redoc_url="/api/redoc",
|
||||
openapi_url="/api/openapi.json",
|
||||
)
|
||||
|
||||
# Store configuration in app state
|
||||
app.state.database_url = database_url
|
||||
app.state.read_key = read_key
|
||||
app.state.admin_key = admin_key
|
||||
app.state.mqtt_host = mqtt_host
|
||||
app.state.mqtt_port = mqtt_port
|
||||
app.state.mqtt_prefix = mqtt_prefix
|
||||
|
||||
# Configure CORS
|
||||
if cors_origins is None:
|
||||
cors_origins = ["*"]
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Include routers
|
||||
from meshcore_hub.api.routes import api_router
|
||||
|
||||
app.include_router(api_router, prefix="/api/v1")
|
||||
|
||||
# Health check endpoints
|
||||
@app.get("/health", tags=["Health"])
|
||||
async def health() -> dict:
|
||||
"""Basic health check."""
|
||||
return {"status": "healthy", "version": __version__}
|
||||
|
||||
@app.get("/health/ready", tags=["Health"])
|
||||
async def health_ready() -> dict:
|
||||
"""Readiness check including database."""
|
||||
try:
|
||||
db = get_db_manager()
|
||||
with db.session_scope() as session:
|
||||
session.execute("SELECT 1")
|
||||
return {"status": "ready", "database": "connected"}
|
||||
except Exception as e:
|
||||
return {"status": "not_ready", "database": str(e)}
|
||||
|
||||
return app
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Authentication middleware for the API."""
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Security scheme
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
def get_api_keys(request: Request) -> tuple[str | None, str | None]:
|
||||
"""Get API keys from app state.
|
||||
|
||||
Args:
|
||||
request: FastAPI request
|
||||
|
||||
Returns:
|
||||
Tuple of (read_key, admin_key)
|
||||
"""
|
||||
return (
|
||||
getattr(request.app.state, "read_key", None),
|
||||
getattr(request.app.state, "admin_key", None),
|
||||
)
|
||||
|
||||
|
||||
async def get_current_token(
|
||||
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)],
|
||||
) -> str | None:
|
||||
"""Extract bearer token from request.
|
||||
|
||||
Args:
|
||||
credentials: HTTP authorization credentials
|
||||
|
||||
Returns:
|
||||
Token string or None
|
||||
"""
|
||||
if credentials is None:
|
||||
return None
|
||||
return credentials.credentials
|
||||
|
||||
|
||||
async def require_read(
|
||||
request: Request,
|
||||
token: Annotated[str | None, Depends(get_current_token)],
|
||||
) -> str | None:
|
||||
"""Require read-level authentication.
|
||||
|
||||
Allows access if:
|
||||
- No API keys are configured (open access)
|
||||
- Token matches read key
|
||||
- Token matches admin key
|
||||
|
||||
Args:
|
||||
request: FastAPI request
|
||||
token: Bearer token
|
||||
|
||||
Returns:
|
||||
Token string
|
||||
|
||||
Raises:
|
||||
HTTPException: If authentication fails
|
||||
"""
|
||||
read_key, admin_key = get_api_keys(request)
|
||||
|
||||
# If no keys configured, allow access
|
||||
if not read_key and not admin_key:
|
||||
return token
|
||||
|
||||
# Require token if keys are configured
|
||||
if not token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Authentication required",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# Check if token matches any key
|
||||
if token == read_key or token == admin_key:
|
||||
return token
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Invalid API key",
|
||||
)
|
||||
|
||||
|
||||
async def require_admin(
|
||||
request: Request,
|
||||
token: Annotated[str | None, Depends(get_current_token)],
|
||||
) -> str:
|
||||
"""Require admin-level authentication.
|
||||
|
||||
Allows access if:
|
||||
- No admin key is configured (open access)
|
||||
- Token matches admin key
|
||||
|
||||
Args:
|
||||
request: FastAPI request
|
||||
token: Bearer token
|
||||
|
||||
Returns:
|
||||
Token string
|
||||
|
||||
Raises:
|
||||
HTTPException: If authentication fails
|
||||
"""
|
||||
read_key, admin_key = get_api_keys(request)
|
||||
|
||||
# If no admin key configured, allow access
|
||||
if not admin_key:
|
||||
return token or ""
|
||||
|
||||
# Require token
|
||||
if not token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Authentication required",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# Check if token matches admin key
|
||||
if token == admin_key:
|
||||
return token
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin access required",
|
||||
)
|
||||
|
||||
|
||||
# Dependency types for use in routes
|
||||
RequireRead = Annotated[str | None, Depends(require_read)]
|
||||
RequireAdmin = Annotated[str, Depends(require_admin)]
|
||||
@@ -0,0 +1,157 @@
|
||||
"""API CLI commands."""
|
||||
|
||||
import click
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--host",
|
||||
type=str,
|
||||
default="0.0.0.0",
|
||||
envvar="API_HOST",
|
||||
help="API server host",
|
||||
)
|
||||
@click.option(
|
||||
"--port",
|
||||
type=int,
|
||||
default=8000,
|
||||
envvar="API_PORT",
|
||||
help="API server port",
|
||||
)
|
||||
@click.option(
|
||||
"--database-url",
|
||||
type=str,
|
||||
default="sqlite:///./meshcore.db",
|
||||
envvar="DATABASE_URL",
|
||||
help="Database connection URL",
|
||||
)
|
||||
@click.option(
|
||||
"--read-key",
|
||||
type=str,
|
||||
default=None,
|
||||
envvar="API_READ_KEY",
|
||||
help="Read-only API key (optional, enables read-level auth)",
|
||||
)
|
||||
@click.option(
|
||||
"--admin-key",
|
||||
type=str,
|
||||
default=None,
|
||||
envvar="API_ADMIN_KEY",
|
||||
help="Admin API key (optional, enables admin-level auth)",
|
||||
)
|
||||
@click.option(
|
||||
"--mqtt-host",
|
||||
type=str,
|
||||
default="localhost",
|
||||
envvar="MQTT_HOST",
|
||||
help="MQTT broker host for commands",
|
||||
)
|
||||
@click.option(
|
||||
"--mqtt-port",
|
||||
type=int,
|
||||
default=1883,
|
||||
envvar="MQTT_PORT",
|
||||
help="MQTT broker port",
|
||||
)
|
||||
@click.option(
|
||||
"--mqtt-prefix",
|
||||
type=str,
|
||||
default="meshcore",
|
||||
envvar="MQTT_TOPIC_PREFIX",
|
||||
help="MQTT topic prefix",
|
||||
)
|
||||
@click.option(
|
||||
"--cors-origins",
|
||||
type=str,
|
||||
default=None,
|
||||
envvar="CORS_ORIGINS",
|
||||
help="Comma-separated list of allowed CORS origins",
|
||||
)
|
||||
@click.option(
|
||||
"--reload",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Enable auto-reload for development",
|
||||
)
|
||||
@click.pass_context
|
||||
def api(
|
||||
ctx: click.Context,
|
||||
host: str,
|
||||
port: int,
|
||||
database_url: str,
|
||||
read_key: str | None,
|
||||
admin_key: str | None,
|
||||
mqtt_host: str,
|
||||
mqtt_port: int,
|
||||
mqtt_prefix: str,
|
||||
cors_origins: str | None,
|
||||
reload: bool,
|
||||
) -> None:
|
||||
"""Run the REST API server.
|
||||
|
||||
Provides REST API endpoints for querying mesh network data and sending
|
||||
commands to devices via MQTT.
|
||||
|
||||
Examples:
|
||||
|
||||
# Run with defaults (no auth)
|
||||
meshcore-hub api
|
||||
|
||||
# Run with authentication
|
||||
meshcore-hub api --read-key secret --admin-key supersecret
|
||||
|
||||
# Run with CORS for web frontend
|
||||
meshcore-hub api --cors-origins "http://localhost:8080,http://localhost:3000"
|
||||
|
||||
# Development mode with auto-reload
|
||||
meshcore-hub api --reload
|
||||
"""
|
||||
import uvicorn
|
||||
|
||||
from meshcore_hub.api.app import create_app
|
||||
|
||||
click.echo("=" * 50)
|
||||
click.echo("MeshCore Hub API Server")
|
||||
click.echo("=" * 50)
|
||||
click.echo(f"Host: {host}")
|
||||
click.echo(f"Port: {port}")
|
||||
click.echo(f"Database: {database_url}")
|
||||
click.echo(f"MQTT: {mqtt_host}:{mqtt_port} (prefix: {mqtt_prefix})")
|
||||
click.echo(f"Read key configured: {read_key is not None}")
|
||||
click.echo(f"Admin key configured: {admin_key is not None}")
|
||||
click.echo(f"CORS origins: {cors_origins or 'none'}")
|
||||
click.echo(f"Reload mode: {reload}")
|
||||
click.echo("=" * 50)
|
||||
|
||||
# Parse CORS origins
|
||||
origins_list: list[str] | None = None
|
||||
if cors_origins:
|
||||
origins_list = [o.strip() for o in cors_origins.split(",")]
|
||||
|
||||
if reload:
|
||||
# For development, use uvicorn's reload feature
|
||||
# We need to pass app as string for reload to work
|
||||
click.echo("\nStarting in development mode with auto-reload...")
|
||||
click.echo("Note: Using default settings for reload mode.")
|
||||
|
||||
uvicorn.run(
|
||||
"meshcore_hub.api.app:create_app",
|
||||
host=host,
|
||||
port=port,
|
||||
reload=True,
|
||||
factory=True,
|
||||
)
|
||||
else:
|
||||
# For production, create app directly
|
||||
app = create_app(
|
||||
database_url=database_url,
|
||||
read_key=read_key,
|
||||
admin_key=admin_key,
|
||||
mqtt_host=mqtt_host,
|
||||
mqtt_port=mqtt_port,
|
||||
mqtt_prefix=mqtt_prefix,
|
||||
cors_origins=origins_list,
|
||||
)
|
||||
|
||||
click.echo("\nStarting API server...")
|
||||
uvicorn.run(app, host=host, port=port)
|
||||
@@ -0,0 +1,73 @@
|
||||
"""FastAPI dependencies for the API."""
|
||||
|
||||
import logging
|
||||
from typing import Annotated, Generator
|
||||
|
||||
from fastapi import Depends, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from meshcore_hub.common.database import DatabaseManager
|
||||
from meshcore_hub.common.mqtt import MQTTClient, MQTTConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_db_manager(request: Request) -> DatabaseManager:
|
||||
"""Get database manager from app.
|
||||
|
||||
Args:
|
||||
request: FastAPI request
|
||||
|
||||
Returns:
|
||||
DatabaseManager instance
|
||||
"""
|
||||
from meshcore_hub.api.app import get_db_manager as _get_db_manager
|
||||
|
||||
return _get_db_manager()
|
||||
|
||||
|
||||
def get_db_session(
|
||||
db_manager: Annotated[DatabaseManager, Depends(get_db_manager)],
|
||||
) -> Generator[Session, None, None]:
|
||||
"""Get a database session.
|
||||
|
||||
Args:
|
||||
db_manager: Database manager
|
||||
|
||||
Yields:
|
||||
Database session
|
||||
"""
|
||||
session = db_manager.get_session()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def get_mqtt_client(request: Request) -> MQTTClient:
|
||||
"""Get an MQTT client for publishing commands.
|
||||
|
||||
Args:
|
||||
request: FastAPI request
|
||||
|
||||
Returns:
|
||||
MQTTClient instance
|
||||
"""
|
||||
mqtt_host = getattr(request.app.state, "mqtt_host", "localhost")
|
||||
mqtt_port = getattr(request.app.state, "mqtt_port", 1883)
|
||||
mqtt_prefix = getattr(request.app.state, "mqtt_prefix", "meshcore")
|
||||
|
||||
config = MQTTConfig(
|
||||
host=mqtt_host,
|
||||
port=mqtt_port,
|
||||
prefix=mqtt_prefix,
|
||||
client_id="meshcore-api",
|
||||
)
|
||||
|
||||
client = MQTTClient(config)
|
||||
return client
|
||||
|
||||
|
||||
# Dependency types for use in routes
|
||||
DbSession = Annotated[Session, Depends(get_db_session)]
|
||||
MqttClient = Annotated[MQTTClient, Depends(get_mqtt_client)]
|
||||
@@ -0,0 +1,28 @@
|
||||
"""API route handlers."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from meshcore_hub.api.routes.nodes import router as nodes_router
|
||||
from meshcore_hub.api.routes.node_tags import router as node_tags_router
|
||||
from meshcore_hub.api.routes.messages import router as messages_router
|
||||
from meshcore_hub.api.routes.advertisements import router as advertisements_router
|
||||
from meshcore_hub.api.routes.trace_paths import router as trace_paths_router
|
||||
from meshcore_hub.api.routes.telemetry import router as telemetry_router
|
||||
from meshcore_hub.api.routes.commands import router as commands_router
|
||||
from meshcore_hub.api.routes.dashboard import router as dashboard_router
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
# Include all routers
|
||||
api_router.include_router(nodes_router, prefix="/nodes", tags=["Nodes"])
|
||||
api_router.include_router(node_tags_router, tags=["Node Tags"])
|
||||
api_router.include_router(messages_router, prefix="/messages", tags=["Messages"])
|
||||
api_router.include_router(
|
||||
advertisements_router, prefix="/advertisements", tags=["Advertisements"]
|
||||
)
|
||||
api_router.include_router(
|
||||
trace_paths_router, prefix="/trace-paths", tags=["Trace Paths"]
|
||||
)
|
||||
api_router.include_router(telemetry_router, prefix="/telemetry", tags=["Telemetry"])
|
||||
api_router.include_router(commands_router, prefix="/commands", tags=["Commands"])
|
||||
api_router.include_router(dashboard_router, tags=["Dashboard"])
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Advertisement API routes."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from meshcore_hub.api.auth import RequireRead
|
||||
from meshcore_hub.api.dependencies import DbSession
|
||||
from meshcore_hub.common.models import Advertisement
|
||||
from meshcore_hub.common.schemas.messages import AdvertisementList, AdvertisementRead
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=AdvertisementList)
|
||||
async def list_advertisements(
|
||||
_: RequireRead,
|
||||
session: DbSession,
|
||||
public_key: Optional[str] = Query(None, description="Filter by public key"),
|
||||
since: Optional[datetime] = Query(None, description="Start timestamp"),
|
||||
until: Optional[datetime] = Query(None, description="End timestamp"),
|
||||
limit: int = Query(50, ge=1, le=100, description="Page size"),
|
||||
offset: int = Query(0, ge=0, description="Page offset"),
|
||||
) -> AdvertisementList:
|
||||
"""List advertisements with filtering and pagination."""
|
||||
# Build query
|
||||
query = select(Advertisement)
|
||||
|
||||
if public_key:
|
||||
query = query.where(Advertisement.public_key == public_key)
|
||||
|
||||
if since:
|
||||
query = query.where(Advertisement.received_at >= since)
|
||||
|
||||
if until:
|
||||
query = query.where(Advertisement.received_at <= until)
|
||||
|
||||
# Get total count
|
||||
count_query = select(func.count()).select_from(query.subquery())
|
||||
total = session.execute(count_query).scalar() or 0
|
||||
|
||||
# Apply pagination
|
||||
query = query.order_by(Advertisement.received_at.desc()).offset(offset).limit(limit)
|
||||
|
||||
# Execute
|
||||
advertisements = session.execute(query).scalars().all()
|
||||
|
||||
return AdvertisementList(
|
||||
items=[AdvertisementRead.model_validate(a) for a in advertisements],
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{advertisement_id}", response_model=AdvertisementRead)
|
||||
async def get_advertisement(
|
||||
_: RequireRead,
|
||||
session: DbSession,
|
||||
advertisement_id: str,
|
||||
) -> AdvertisementRead:
|
||||
"""Get a single advertisement by ID."""
|
||||
query = select(Advertisement).where(Advertisement.id == advertisement_id)
|
||||
advertisement = session.execute(query).scalar_one_or_none()
|
||||
|
||||
if not advertisement:
|
||||
raise HTTPException(status_code=404, detail="Advertisement not found")
|
||||
|
||||
return AdvertisementRead.model_validate(advertisement)
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Command API routes for sending messages to the mesh network."""
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from meshcore_hub.api.auth import RequireAdmin
|
||||
from meshcore_hub.api.dependencies import MqttClient
|
||||
from meshcore_hub.common.schemas.commands import (
|
||||
CommandResponse,
|
||||
SendAdvertCommand,
|
||||
SendChannelMessageCommand,
|
||||
SendMessageCommand,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/send-message", response_model=CommandResponse)
|
||||
async def send_message(
|
||||
_: RequireAdmin,
|
||||
mqtt: MqttClient,
|
||||
command: SendMessageCommand,
|
||||
) -> CommandResponse:
|
||||
"""Send a direct message to a node.
|
||||
|
||||
Publishes a send_msg command to MQTT for the sender interface to process.
|
||||
"""
|
||||
try:
|
||||
# Connect to MQTT
|
||||
mqtt.connect()
|
||||
mqtt.start_background()
|
||||
|
||||
# Build payload
|
||||
payload = {
|
||||
"destination": command.destination,
|
||||
"text": command.text,
|
||||
"timestamp": command.timestamp or int(time.time()),
|
||||
}
|
||||
|
||||
# Publish to wildcard topic (any sender can pick it up)
|
||||
mqtt.publish_command("+", "send_msg", payload)
|
||||
|
||||
# Cleanup
|
||||
mqtt.stop()
|
||||
mqtt.disconnect()
|
||||
|
||||
logger.info(f"Published send_msg command to {command.destination[:12]}...")
|
||||
|
||||
return CommandResponse(
|
||||
success=True,
|
||||
message=f"Message queued for {command.destination[:12]}...",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send message: {e}")
|
||||
return CommandResponse(
|
||||
success=False,
|
||||
message=f"Failed to send message: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/send-channel-message", response_model=CommandResponse)
|
||||
async def send_channel_message(
|
||||
_: RequireAdmin,
|
||||
mqtt: MqttClient,
|
||||
command: SendChannelMessageCommand,
|
||||
) -> CommandResponse:
|
||||
"""Send a message to a channel.
|
||||
|
||||
Publishes a send_channel_msg command to MQTT for the sender interface to process.
|
||||
"""
|
||||
try:
|
||||
# Connect to MQTT
|
||||
mqtt.connect()
|
||||
mqtt.start_background()
|
||||
|
||||
# Build payload
|
||||
payload = {
|
||||
"channel_idx": command.channel_idx,
|
||||
"text": command.text,
|
||||
"timestamp": command.timestamp or int(time.time()),
|
||||
}
|
||||
|
||||
# Publish to wildcard topic
|
||||
mqtt.publish_command("+", "send_channel_msg", payload)
|
||||
|
||||
# Cleanup
|
||||
mqtt.stop()
|
||||
mqtt.disconnect()
|
||||
|
||||
logger.info(f"Published send_channel_msg command to channel {command.channel_idx}")
|
||||
|
||||
return CommandResponse(
|
||||
success=True,
|
||||
message=f"Message queued for channel {command.channel_idx}",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send channel message: {e}")
|
||||
return CommandResponse(
|
||||
success=False,
|
||||
message=f"Failed to send channel message: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/send-advertisement", response_model=CommandResponse)
|
||||
async def send_advertisement(
|
||||
_: RequireAdmin,
|
||||
mqtt: MqttClient,
|
||||
command: SendAdvertCommand,
|
||||
) -> CommandResponse:
|
||||
"""Send a node advertisement.
|
||||
|
||||
Publishes a send_advert command to MQTT for the sender interface to process.
|
||||
"""
|
||||
try:
|
||||
# Connect to MQTT
|
||||
mqtt.connect()
|
||||
mqtt.start_background()
|
||||
|
||||
# Build payload
|
||||
payload = {
|
||||
"flood": command.flood,
|
||||
}
|
||||
|
||||
# Publish to wildcard topic
|
||||
mqtt.publish_command("+", "send_advert", payload)
|
||||
|
||||
# Cleanup
|
||||
mqtt.stop()
|
||||
mqtt.disconnect()
|
||||
|
||||
logger.info(f"Published send_advert command (flood={command.flood})")
|
||||
|
||||
return CommandResponse(
|
||||
success=True,
|
||||
message=f"Advertisement queued (flood={command.flood})",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send advertisement: {e}")
|
||||
return CommandResponse(
|
||||
success=False,
|
||||
message=f"Failed to send advertisement: {str(e)}",
|
||||
)
|
||||
@@ -0,0 +1,237 @@
|
||||
"""Dashboard API routes."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from meshcore_hub.api.auth import RequireRead
|
||||
from meshcore_hub.api.dependencies import DbSession
|
||||
from meshcore_hub.common.models import Advertisement, Message, Node
|
||||
from meshcore_hub.common.schemas.messages import DashboardStats
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/stats", response_model=DashboardStats)
|
||||
async def get_stats(
|
||||
_: RequireRead,
|
||||
session: DbSession,
|
||||
) -> DashboardStats:
|
||||
"""Get dashboard statistics."""
|
||||
now = datetime.now(timezone.utc)
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
yesterday = now - timedelta(days=1)
|
||||
|
||||
# Total nodes
|
||||
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
|
||||
|
||||
# Total messages
|
||||
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
|
||||
|
||||
# Total advertisements
|
||||
total_advertisements = session.execute(
|
||||
select(func.count()).select_from(Advertisement)
|
||||
).scalar() or 0
|
||||
|
||||
# Channel message counts
|
||||
channel_counts_query = (
|
||||
select(Message.channel_idx, func.count())
|
||||
.where(Message.message_type == "channel")
|
||||
.where(Message.channel_idx.isnot(None))
|
||||
.group_by(Message.channel_idx)
|
||||
)
|
||||
channel_results = session.execute(channel_counts_query).all()
|
||||
channel_message_counts = {
|
||||
int(channel): int(count) for channel, count in channel_results
|
||||
}
|
||||
|
||||
return DashboardStats(
|
||||
total_nodes=total_nodes,
|
||||
active_nodes=active_nodes,
|
||||
total_messages=total_messages,
|
||||
messages_today=messages_today,
|
||||
total_advertisements=total_advertisements,
|
||||
channel_message_counts=channel_message_counts,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/dashboard", response_class=HTMLResponse)
|
||||
async def dashboard(
|
||||
request: Request,
|
||||
session: DbSession,
|
||||
) -> HTMLResponse:
|
||||
"""Simple HTML dashboard page."""
|
||||
now = datetime.now(timezone.utc)
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
yesterday = now - timedelta(days=1)
|
||||
|
||||
# Get stats
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
# Get recent nodes
|
||||
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()
|
||||
|
||||
# Build HTML
|
||||
html = f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>MeshCore Hub Dashboard</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta http-equiv="refresh" content="30">
|
||||
<style>
|
||||
body {{
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
background: #f5f5f5;
|
||||
color: #333;
|
||||
}}
|
||||
h1 {{ color: #2c3e50; }}
|
||||
.container {{ max-width: 1200px; margin: 0 auto; }}
|
||||
.stats {{
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}}
|
||||
.stat-card {{
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}}
|
||||
.stat-card h3 {{ margin: 0 0 10px 0; color: #666; font-size: 14px; }}
|
||||
.stat-card .value {{ font-size: 32px; font-weight: bold; color: #2c3e50; }}
|
||||
.section {{
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
margin-bottom: 20px;
|
||||
}}
|
||||
table {{ width: 100%; border-collapse: collapse; }}
|
||||
th, td {{ padding: 10px; text-align: left; border-bottom: 1px solid #eee; }}
|
||||
th {{ background: #f8f9fa; font-weight: 600; }}
|
||||
.text-muted {{ color: #666; }}
|
||||
.truncate {{ max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>MeshCore Hub Dashboard</h1>
|
||||
<p class="text-muted">Last updated: {now.strftime('%Y-%m-%d %H:%M:%S UTC')}</p>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat-card">
|
||||
<h3>Total Nodes</h3>
|
||||
<div class="value">{total_nodes}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Active Nodes (24h)</h3>
|
||||
<div class="value">{active_nodes}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Total Messages</h3>
|
||||
<div class="value">{total_messages}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Messages Today</h3>
|
||||
<div class="value">{messages_today}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Recent Nodes</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Public Key</th>
|
||||
<th>Type</th>
|
||||
<th>Last Seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{"".join(f'''
|
||||
<tr>
|
||||
<td>{n.name or '-'}</td>
|
||||
<td class="truncate">{n.public_key[:16]}...</td>
|
||||
<td>{n.adv_type or '-'}</td>
|
||||
<td>{n.last_seen.strftime('%Y-%m-%d %H:%M') if n.last_seen else '-'}</td>
|
||||
</tr>
|
||||
''' for n in recent_nodes)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Recent Messages</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>From/Channel</th>
|
||||
<th>Text</th>
|
||||
<th>Received</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{"".join(f'''
|
||||
<tr>
|
||||
<td>{m.message_type}</td>
|
||||
<td>{m.pubkey_prefix or f'Ch {m.channel_idx}' or '-'}</td>
|
||||
<td class="truncate">{m.text[:50]}{'...' if len(m.text) > 50 else ''}</td>
|
||||
<td>{m.received_at.strftime('%Y-%m-%d %H:%M') if m.received_at else '-'}</td>
|
||||
</tr>
|
||||
''' for m in recent_messages)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
return HTMLResponse(content=html)
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Message API routes."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from meshcore_hub.api.auth import RequireRead
|
||||
from meshcore_hub.api.dependencies import DbSession
|
||||
from meshcore_hub.common.models import Message
|
||||
from meshcore_hub.common.schemas.messages import MessageList, MessageRead
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=MessageList)
|
||||
async def list_messages(
|
||||
_: RequireRead,
|
||||
session: DbSession,
|
||||
type: Optional[str] = Query(None, description="Filter by message type"),
|
||||
pubkey_prefix: Optional[str] = Query(None, description="Filter by sender prefix"),
|
||||
channel_idx: Optional[int] = Query(None, description="Filter by channel"),
|
||||
since: Optional[datetime] = Query(None, description="Start timestamp"),
|
||||
until: Optional[datetime] = Query(None, description="End timestamp"),
|
||||
search: Optional[str] = Query(None, description="Search in message text"),
|
||||
limit: int = Query(50, ge=1, le=100, description="Page size"),
|
||||
offset: int = Query(0, ge=0, description="Page offset"),
|
||||
) -> MessageList:
|
||||
"""List messages with filtering and pagination."""
|
||||
# Build query
|
||||
query = select(Message)
|
||||
|
||||
if type:
|
||||
query = query.where(Message.message_type == type)
|
||||
|
||||
if pubkey_prefix:
|
||||
query = query.where(Message.pubkey_prefix == pubkey_prefix)
|
||||
|
||||
if channel_idx is not None:
|
||||
query = query.where(Message.channel_idx == channel_idx)
|
||||
|
||||
if since:
|
||||
query = query.where(Message.received_at >= since)
|
||||
|
||||
if until:
|
||||
query = query.where(Message.received_at <= until)
|
||||
|
||||
if search:
|
||||
query = query.where(Message.text.ilike(f"%{search}%"))
|
||||
|
||||
# Get total count
|
||||
count_query = select(func.count()).select_from(query.subquery())
|
||||
total = session.execute(count_query).scalar() or 0
|
||||
|
||||
# Apply pagination
|
||||
query = query.order_by(Message.received_at.desc()).offset(offset).limit(limit)
|
||||
|
||||
# Execute
|
||||
messages = session.execute(query).scalars().all()
|
||||
|
||||
return MessageList(
|
||||
items=[MessageRead.model_validate(m) for m in messages],
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{message_id}", response_model=MessageRead)
|
||||
async def get_message(
|
||||
_: RequireRead,
|
||||
session: DbSession,
|
||||
message_id: str,
|
||||
) -> MessageRead:
|
||||
"""Get a single message by ID."""
|
||||
query = select(Message).where(Message.id == message_id)
|
||||
message = session.execute(query).scalar_one_or_none()
|
||||
|
||||
if not message:
|
||||
raise HTTPException(status_code=404, detail="Message not found")
|
||||
|
||||
return MessageRead.model_validate(message)
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Node tag API routes."""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from sqlalchemy import select
|
||||
|
||||
from meshcore_hub.api.auth import RequireAdmin, RequireRead
|
||||
from meshcore_hub.api.dependencies import DbSession
|
||||
from meshcore_hub.common.models import Node, NodeTag
|
||||
from meshcore_hub.common.schemas.nodes import NodeTagCreate, NodeTagRead, NodeTagUpdate
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/nodes/{public_key}/tags", response_model=list[NodeTagRead])
|
||||
async def list_node_tags(
|
||||
_: RequireRead,
|
||||
session: DbSession,
|
||||
public_key: str,
|
||||
) -> list[NodeTagRead]:
|
||||
"""List all tags for a node."""
|
||||
# Find node
|
||||
node_query = select(Node).where(Node.public_key == public_key)
|
||||
node = session.execute(node_query).scalar_one_or_none()
|
||||
|
||||
if not node:
|
||||
raise HTTPException(status_code=404, detail="Node not found")
|
||||
|
||||
return [NodeTagRead.model_validate(t) for t in node.tags]
|
||||
|
||||
|
||||
@router.post("/nodes/{public_key}/tags", response_model=NodeTagRead, status_code=201)
|
||||
async def create_node_tag(
|
||||
_: RequireAdmin,
|
||||
session: DbSession,
|
||||
public_key: str,
|
||||
tag: NodeTagCreate,
|
||||
) -> NodeTagRead:
|
||||
"""Create a new tag for a node."""
|
||||
# Find node
|
||||
node_query = select(Node).where(Node.public_key == public_key)
|
||||
node = session.execute(node_query).scalar_one_or_none()
|
||||
|
||||
if not node:
|
||||
raise HTTPException(status_code=404, detail="Node not found")
|
||||
|
||||
# Check if tag already exists
|
||||
existing_query = select(NodeTag).where(
|
||||
(NodeTag.node_id == node.id) & (NodeTag.key == tag.key)
|
||||
)
|
||||
existing = session.execute(existing_query).scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
raise HTTPException(status_code=409, detail="Tag already exists")
|
||||
|
||||
# Create tag
|
||||
node_tag = NodeTag(
|
||||
node_id=node.id,
|
||||
key=tag.key,
|
||||
value=tag.value,
|
||||
value_type=tag.value_type,
|
||||
)
|
||||
session.add(node_tag)
|
||||
session.commit()
|
||||
session.refresh(node_tag)
|
||||
|
||||
return NodeTagRead.model_validate(node_tag)
|
||||
|
||||
|
||||
@router.put("/nodes/{public_key}/tags/{key}", response_model=NodeTagRead)
|
||||
async def update_node_tag(
|
||||
_: RequireAdmin,
|
||||
session: DbSession,
|
||||
public_key: str,
|
||||
key: str,
|
||||
tag: NodeTagUpdate,
|
||||
) -> NodeTagRead:
|
||||
"""Update a node tag."""
|
||||
# Find node
|
||||
node_query = select(Node).where(Node.public_key == public_key)
|
||||
node = session.execute(node_query).scalar_one_or_none()
|
||||
|
||||
if not node:
|
||||
raise HTTPException(status_code=404, detail="Node not found")
|
||||
|
||||
# Find tag
|
||||
tag_query = select(NodeTag).where(
|
||||
(NodeTag.node_id == node.id) & (NodeTag.key == key)
|
||||
)
|
||||
node_tag = session.execute(tag_query).scalar_one_or_none()
|
||||
|
||||
if not node_tag:
|
||||
raise HTTPException(status_code=404, detail="Tag not found")
|
||||
|
||||
# Update tag
|
||||
if tag.value is not None:
|
||||
node_tag.value = tag.value
|
||||
if tag.value_type is not None:
|
||||
node_tag.value_type = tag.value_type
|
||||
|
||||
session.commit()
|
||||
session.refresh(node_tag)
|
||||
|
||||
return NodeTagRead.model_validate(node_tag)
|
||||
|
||||
|
||||
@router.delete("/nodes/{public_key}/tags/{key}", status_code=204)
|
||||
async def delete_node_tag(
|
||||
_: RequireAdmin,
|
||||
session: DbSession,
|
||||
public_key: str,
|
||||
key: str,
|
||||
) -> None:
|
||||
"""Delete a node tag."""
|
||||
# Find node
|
||||
node_query = select(Node).where(Node.public_key == public_key)
|
||||
node = session.execute(node_query).scalar_one_or_none()
|
||||
|
||||
if not node:
|
||||
raise HTTPException(status_code=404, detail="Node not found")
|
||||
|
||||
# Find and delete tag
|
||||
tag_query = select(NodeTag).where(
|
||||
(NodeTag.node_id == node.id) & (NodeTag.key == key)
|
||||
)
|
||||
node_tag = session.execute(tag_query).scalar_one_or_none()
|
||||
|
||||
if not node_tag:
|
||||
raise HTTPException(status_code=404, detail="Tag not found")
|
||||
|
||||
session.delete(node_tag)
|
||||
session.commit()
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Node API routes."""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from meshcore_hub.api.auth import RequireRead
|
||||
from meshcore_hub.api.dependencies import DbSession
|
||||
from meshcore_hub.common.models import Node
|
||||
from meshcore_hub.common.schemas.nodes import NodeList, NodeRead
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=NodeList)
|
||||
async def list_nodes(
|
||||
_: RequireRead,
|
||||
session: DbSession,
|
||||
search: Optional[str] = Query(None, description="Search in name or public key"),
|
||||
adv_type: Optional[str] = Query(None, description="Filter by advertisement type"),
|
||||
limit: int = Query(50, ge=1, le=100, description="Page size"),
|
||||
offset: int = Query(0, ge=0, description="Page offset"),
|
||||
) -> NodeList:
|
||||
"""List all nodes with pagination and filtering."""
|
||||
# Build query
|
||||
query = select(Node)
|
||||
|
||||
if search:
|
||||
query = query.where(
|
||||
(Node.name.ilike(f"%{search}%")) | (Node.public_key.ilike(f"%{search}%"))
|
||||
)
|
||||
|
||||
if adv_type:
|
||||
query = query.where(Node.adv_type == adv_type)
|
||||
|
||||
# Get total count
|
||||
count_query = select(func.count()).select_from(query.subquery())
|
||||
total = session.execute(count_query).scalar() or 0
|
||||
|
||||
# Apply pagination
|
||||
query = query.order_by(Node.last_seen.desc()).offset(offset).limit(limit)
|
||||
|
||||
# Execute
|
||||
nodes = session.execute(query).scalars().all()
|
||||
|
||||
return NodeList(
|
||||
items=[NodeRead.model_validate(n) for n in nodes],
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{public_key}", response_model=NodeRead)
|
||||
async def get_node(
|
||||
_: RequireRead,
|
||||
session: DbSession,
|
||||
public_key: str,
|
||||
) -> NodeRead:
|
||||
"""Get a single node by public key."""
|
||||
query = select(Node).where(Node.public_key == public_key)
|
||||
node = session.execute(query).scalar_one_or_none()
|
||||
|
||||
if not node:
|
||||
raise HTTPException(status_code=404, detail="Node not found")
|
||||
|
||||
return NodeRead.model_validate(node)
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Telemetry API routes."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from meshcore_hub.api.auth import RequireRead
|
||||
from meshcore_hub.api.dependencies import DbSession
|
||||
from meshcore_hub.common.models import Telemetry
|
||||
from meshcore_hub.common.schemas.messages import TelemetryList, TelemetryRead
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=TelemetryList)
|
||||
async def list_telemetry(
|
||||
_: RequireRead,
|
||||
session: DbSession,
|
||||
node_public_key: Optional[str] = Query(None, description="Filter by node"),
|
||||
since: Optional[datetime] = Query(None, description="Start timestamp"),
|
||||
until: Optional[datetime] = Query(None, description="End timestamp"),
|
||||
limit: int = Query(50, ge=1, le=100, description="Page size"),
|
||||
offset: int = Query(0, ge=0, description="Page offset"),
|
||||
) -> TelemetryList:
|
||||
"""List telemetry records with filtering and pagination."""
|
||||
# Build query
|
||||
query = select(Telemetry)
|
||||
|
||||
if node_public_key:
|
||||
query = query.where(Telemetry.node_public_key == node_public_key)
|
||||
|
||||
if since:
|
||||
query = query.where(Telemetry.received_at >= since)
|
||||
|
||||
if until:
|
||||
query = query.where(Telemetry.received_at <= until)
|
||||
|
||||
# Get total count
|
||||
count_query = select(func.count()).select_from(query.subquery())
|
||||
total = session.execute(count_query).scalar() or 0
|
||||
|
||||
# Apply pagination
|
||||
query = query.order_by(Telemetry.received_at.desc()).offset(offset).limit(limit)
|
||||
|
||||
# Execute
|
||||
records = session.execute(query).scalars().all()
|
||||
|
||||
return TelemetryList(
|
||||
items=[TelemetryRead.model_validate(t) for t in records],
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{telemetry_id}", response_model=TelemetryRead)
|
||||
async def get_telemetry(
|
||||
_: RequireRead,
|
||||
session: DbSession,
|
||||
telemetry_id: str,
|
||||
) -> TelemetryRead:
|
||||
"""Get a single telemetry record by ID."""
|
||||
query = select(Telemetry).where(Telemetry.id == telemetry_id)
|
||||
telemetry = session.execute(query).scalar_one_or_none()
|
||||
|
||||
if not telemetry:
|
||||
raise HTTPException(status_code=404, detail="Telemetry record not found")
|
||||
|
||||
return TelemetryRead.model_validate(telemetry)
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Trace path API routes."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from meshcore_hub.api.auth import RequireRead
|
||||
from meshcore_hub.api.dependencies import DbSession
|
||||
from meshcore_hub.common.models import TracePath
|
||||
from meshcore_hub.common.schemas.messages import TracePathList, TracePathRead
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=TracePathList)
|
||||
async def list_trace_paths(
|
||||
_: RequireRead,
|
||||
session: DbSession,
|
||||
since: Optional[datetime] = Query(None, description="Start timestamp"),
|
||||
until: Optional[datetime] = Query(None, description="End timestamp"),
|
||||
limit: int = Query(50, ge=1, le=100, description="Page size"),
|
||||
offset: int = Query(0, ge=0, description="Page offset"),
|
||||
) -> TracePathList:
|
||||
"""List trace paths with filtering and pagination."""
|
||||
# Build query
|
||||
query = select(TracePath)
|
||||
|
||||
if since:
|
||||
query = query.where(TracePath.received_at >= since)
|
||||
|
||||
if until:
|
||||
query = query.where(TracePath.received_at <= until)
|
||||
|
||||
# Get total count
|
||||
count_query = select(func.count()).select_from(query.subquery())
|
||||
total = session.execute(count_query).scalar() or 0
|
||||
|
||||
# Apply pagination
|
||||
query = query.order_by(TracePath.received_at.desc()).offset(offset).limit(limit)
|
||||
|
||||
# Execute
|
||||
trace_paths = session.execute(query).scalars().all()
|
||||
|
||||
return TracePathList(
|
||||
items=[TracePathRead.model_validate(t) for t in trace_paths],
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{trace_path_id}", response_model=TracePathRead)
|
||||
async def get_trace_path(
|
||||
_: RequireRead,
|
||||
session: DbSession,
|
||||
trace_path_id: str,
|
||||
) -> TracePathRead:
|
||||
"""Get a single trace path by ID."""
|
||||
query = select(TracePath).where(TracePath.id == trace_path_id)
|
||||
trace_path = session.execute(query).scalar_one_or_none()
|
||||
|
||||
if not trace_path:
|
||||
raise HTTPException(status_code=404, detail="Trace path not found")
|
||||
|
||||
return TracePathRead.model_validate(trace_path)
|
||||
@@ -0,0 +1 @@
|
||||
"""Collector component for storing MeshCore events from MQTT."""
|
||||
@@ -0,0 +1,94 @@
|
||||
"""CLI for the Collector component."""
|
||||
|
||||
import click
|
||||
|
||||
from meshcore_hub.common.logging import configure_logging
|
||||
|
||||
|
||||
@click.command("collector")
|
||||
@click.option(
|
||||
"--mqtt-host",
|
||||
type=str,
|
||||
default="localhost",
|
||||
envvar="MQTT_HOST",
|
||||
help="MQTT broker host",
|
||||
)
|
||||
@click.option(
|
||||
"--mqtt-port",
|
||||
type=int,
|
||||
default=1883,
|
||||
envvar="MQTT_PORT",
|
||||
help="MQTT broker port",
|
||||
)
|
||||
@click.option(
|
||||
"--mqtt-username",
|
||||
type=str,
|
||||
default=None,
|
||||
envvar="MQTT_USERNAME",
|
||||
help="MQTT username",
|
||||
)
|
||||
@click.option(
|
||||
"--mqtt-password",
|
||||
type=str,
|
||||
default=None,
|
||||
envvar="MQTT_PASSWORD",
|
||||
help="MQTT password",
|
||||
)
|
||||
@click.option(
|
||||
"--prefix",
|
||||
type=str,
|
||||
default="meshcore",
|
||||
envvar="MQTT_PREFIX",
|
||||
help="MQTT topic prefix",
|
||||
)
|
||||
@click.option(
|
||||
"--database-url",
|
||||
type=str,
|
||||
default="sqlite:///./meshcore.db",
|
||||
envvar="DATABASE_URL",
|
||||
help="Database connection URL",
|
||||
)
|
||||
@click.option(
|
||||
"--log-level",
|
||||
type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]),
|
||||
default="INFO",
|
||||
envvar="LOG_LEVEL",
|
||||
help="Log level",
|
||||
)
|
||||
def collector(
|
||||
mqtt_host: str,
|
||||
mqtt_port: int,
|
||||
mqtt_username: str | None,
|
||||
mqtt_password: str | None,
|
||||
prefix: str,
|
||||
database_url: str,
|
||||
log_level: str,
|
||||
) -> None:
|
||||
"""Run the collector component.
|
||||
|
||||
The collector subscribes to MQTT broker and stores
|
||||
MeshCore events in the database for later retrieval.
|
||||
|
||||
Events stored include:
|
||||
- Node advertisements
|
||||
- Contact and channel messages
|
||||
- Trace path data
|
||||
- Telemetry responses
|
||||
- Informational events (battery, status, etc.)
|
||||
"""
|
||||
configure_logging(level=log_level)
|
||||
|
||||
click.echo("Starting MeshCore Collector")
|
||||
click.echo(f"MQTT: {mqtt_host}:{mqtt_port} (prefix: {prefix})")
|
||||
click.echo(f"Database: {database_url}")
|
||||
|
||||
from meshcore_hub.collector.subscriber import run_collector
|
||||
|
||||
run_collector(
|
||||
mqtt_host=mqtt_host,
|
||||
mqtt_port=mqtt_port,
|
||||
mqtt_username=mqtt_username,
|
||||
mqtt_password=mqtt_password,
|
||||
mqtt_prefix=prefix,
|
||||
database_url=database_url,
|
||||
)
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Event handlers for processing MQTT messages."""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from meshcore_hub.collector.subscriber import Subscriber
|
||||
|
||||
|
||||
def register_all_handlers(subscriber: "Subscriber") -> None:
|
||||
"""Register all event handlers with the subscriber.
|
||||
|
||||
Args:
|
||||
subscriber: Subscriber instance
|
||||
"""
|
||||
from meshcore_hub.collector.handlers.advertisement import handle_advertisement
|
||||
from meshcore_hub.collector.handlers.message import (
|
||||
handle_contact_message,
|
||||
handle_channel_message,
|
||||
)
|
||||
from meshcore_hub.collector.handlers.trace import handle_trace_data
|
||||
from meshcore_hub.collector.handlers.telemetry import handle_telemetry
|
||||
from meshcore_hub.collector.handlers.contacts import handle_contacts
|
||||
from meshcore_hub.collector.handlers.event_log import handle_event_log
|
||||
|
||||
# Persisted events with specific handlers
|
||||
subscriber.register_handler("advertisement", handle_advertisement)
|
||||
subscriber.register_handler("contact_msg_recv", handle_contact_message)
|
||||
subscriber.register_handler("channel_msg_recv", handle_channel_message)
|
||||
subscriber.register_handler("trace_data", handle_trace_data)
|
||||
subscriber.register_handler("telemetry_response", handle_telemetry)
|
||||
subscriber.register_handler("contacts", handle_contacts)
|
||||
|
||||
# Informational events (logged only)
|
||||
subscriber.register_handler("send_confirmed", handle_event_log)
|
||||
subscriber.register_handler("status_response", handle_event_log)
|
||||
subscriber.register_handler("battery", handle_event_log)
|
||||
subscriber.register_handler("path_updated", handle_event_log)
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Handler for advertisement events."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from meshcore_hub.common.database import DatabaseManager
|
||||
from meshcore_hub.common.models import Advertisement, Node
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def handle_advertisement(
|
||||
public_key: str,
|
||||
event_type: str,
|
||||
payload: dict[str, Any],
|
||||
db: DatabaseManager,
|
||||
) -> None:
|
||||
"""Handle an advertisement event.
|
||||
|
||||
1. Upserts the node in the nodes table
|
||||
2. Creates an advertisement record
|
||||
3. Updates node last_seen timestamp
|
||||
|
||||
Args:
|
||||
public_key: Receiver node's public key (from MQTT topic)
|
||||
event_type: Event type name
|
||||
payload: Advertisement payload
|
||||
db: Database manager
|
||||
"""
|
||||
adv_public_key = payload.get("public_key")
|
||||
if not adv_public_key:
|
||||
logger.warning("Advertisement missing public_key")
|
||||
return
|
||||
|
||||
name = payload.get("name")
|
||||
adv_type = payload.get("adv_type")
|
||||
flags = payload.get("flags")
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
with db.session_scope() as session:
|
||||
# Find or create receiver node
|
||||
receiver_node = None
|
||||
if public_key:
|
||||
receiver_query = select(Node).where(Node.public_key == public_key)
|
||||
receiver_node = session.execute(receiver_query).scalar_one_or_none()
|
||||
|
||||
if not receiver_node:
|
||||
receiver_node = Node(
|
||||
public_key=public_key,
|
||||
first_seen=now,
|
||||
last_seen=now,
|
||||
)
|
||||
session.add(receiver_node)
|
||||
session.flush()
|
||||
|
||||
# Find or create advertised node
|
||||
node_query = select(Node).where(Node.public_key == adv_public_key)
|
||||
node = session.execute(node_query).scalar_one_or_none()
|
||||
|
||||
if node:
|
||||
# Update existing node
|
||||
if name:
|
||||
node.name = name
|
||||
if adv_type:
|
||||
node.adv_type = adv_type
|
||||
if flags is not None:
|
||||
node.flags = flags
|
||||
node.last_seen = now
|
||||
else:
|
||||
# Create new node
|
||||
node = Node(
|
||||
public_key=adv_public_key,
|
||||
name=name,
|
||||
adv_type=adv_type,
|
||||
flags=flags,
|
||||
first_seen=now,
|
||||
last_seen=now,
|
||||
)
|
||||
session.add(node)
|
||||
session.flush()
|
||||
|
||||
# Create advertisement record
|
||||
advertisement = Advertisement(
|
||||
receiver_node_id=receiver_node.id if receiver_node else None,
|
||||
node_id=node.id,
|
||||
public_key=adv_public_key,
|
||||
name=name,
|
||||
adv_type=adv_type,
|
||||
flags=flags,
|
||||
received_at=now,
|
||||
)
|
||||
session.add(advertisement)
|
||||
|
||||
logger.info(
|
||||
f"Stored advertisement from {name or adv_public_key[:12]!r} "
|
||||
f"(type={adv_type})"
|
||||
)
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Handler for contacts sync events."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from meshcore_hub.common.database import DatabaseManager
|
||||
from meshcore_hub.common.models import Node
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def handle_contacts(
|
||||
public_key: str,
|
||||
event_type: str,
|
||||
payload: dict[str, Any],
|
||||
db: DatabaseManager,
|
||||
) -> None:
|
||||
"""Handle a contacts sync event.
|
||||
|
||||
Upserts all contacts in the contacts list.
|
||||
|
||||
Args:
|
||||
public_key: Receiver node's public key (from MQTT topic)
|
||||
event_type: Event type name
|
||||
payload: Contacts payload
|
||||
db: Database manager
|
||||
"""
|
||||
contacts = payload.get("contacts", [])
|
||||
if not contacts:
|
||||
logger.debug("Empty contacts list received")
|
||||
return
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
created_count = 0
|
||||
updated_count = 0
|
||||
|
||||
with db.session_scope() as session:
|
||||
for contact in contacts:
|
||||
contact_key = contact.get("public_key")
|
||||
if not contact_key:
|
||||
continue
|
||||
|
||||
name = contact.get("name")
|
||||
node_type = contact.get("node_type")
|
||||
|
||||
# Find or create node
|
||||
node_query = select(Node).where(Node.public_key == contact_key)
|
||||
node = session.execute(node_query).scalar_one_or_none()
|
||||
|
||||
if node:
|
||||
# Update existing node
|
||||
if name and not node.name:
|
||||
node.name = name
|
||||
if node_type and not node.adv_type:
|
||||
node.adv_type = node_type
|
||||
node.last_seen = now
|
||||
updated_count += 1
|
||||
else:
|
||||
# Create new node
|
||||
node = Node(
|
||||
public_key=contact_key,
|
||||
name=name,
|
||||
adv_type=node_type,
|
||||
first_seen=now,
|
||||
last_seen=now,
|
||||
)
|
||||
session.add(node)
|
||||
created_count += 1
|
||||
|
||||
logger.info(
|
||||
f"Processed contacts sync: {created_count} new, {updated_count} updated"
|
||||
)
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Generic event log handler for informational events."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from meshcore_hub.common.database import DatabaseManager
|
||||
from meshcore_hub.common.models import EventLog, Node
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def handle_event_log(
|
||||
public_key: str,
|
||||
event_type: str,
|
||||
payload: dict[str, Any],
|
||||
db: DatabaseManager,
|
||||
) -> None:
|
||||
"""Handle an event by logging it to the events_log table.
|
||||
|
||||
This is used for informational events that don't need
|
||||
specific processing but should be recorded.
|
||||
|
||||
Args:
|
||||
public_key: Receiver node's public key (from MQTT topic)
|
||||
event_type: Event type name
|
||||
payload: Event payload
|
||||
db: Database manager
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
with db.session_scope() as session:
|
||||
# Find receiver node
|
||||
receiver_node = None
|
||||
if public_key:
|
||||
receiver_query = select(Node).where(Node.public_key == public_key)
|
||||
receiver_node = session.execute(receiver_query).scalar_one_or_none()
|
||||
|
||||
if not receiver_node:
|
||||
receiver_node = Node(
|
||||
public_key=public_key,
|
||||
first_seen=now,
|
||||
last_seen=now,
|
||||
)
|
||||
session.add(receiver_node)
|
||||
session.flush()
|
||||
else:
|
||||
receiver_node.last_seen = now
|
||||
|
||||
# Create event log record
|
||||
event_log = EventLog(
|
||||
receiver_node_id=receiver_node.id if receiver_node else None,
|
||||
event_type=event_type,
|
||||
payload=payload,
|
||||
received_at=now,
|
||||
)
|
||||
session.add(event_log)
|
||||
|
||||
logger.debug(f"Logged event: {event_type}")
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Handler for message events."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from meshcore_hub.common.database import DatabaseManager
|
||||
from meshcore_hub.common.models import Message, Node
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def handle_contact_message(
|
||||
public_key: str,
|
||||
event_type: str,
|
||||
payload: dict[str, Any],
|
||||
db: DatabaseManager,
|
||||
) -> None:
|
||||
"""Handle a contact message event.
|
||||
|
||||
Args:
|
||||
public_key: Receiver node's public key (from MQTT topic)
|
||||
event_type: Event type name
|
||||
payload: Message payload
|
||||
db: Database manager
|
||||
"""
|
||||
_handle_message(public_key, "contact", payload, db)
|
||||
|
||||
|
||||
def handle_channel_message(
|
||||
public_key: str,
|
||||
event_type: str,
|
||||
payload: dict[str, Any],
|
||||
db: DatabaseManager,
|
||||
) -> None:
|
||||
"""Handle a channel message event.
|
||||
|
||||
Args:
|
||||
public_key: Receiver node's public key (from MQTT topic)
|
||||
event_type: Event type name
|
||||
payload: Message payload
|
||||
db: Database manager
|
||||
"""
|
||||
_handle_message(public_key, "channel", payload, db)
|
||||
|
||||
|
||||
def _handle_message(
|
||||
public_key: str,
|
||||
message_type: str,
|
||||
payload: dict[str, Any],
|
||||
db: DatabaseManager,
|
||||
) -> None:
|
||||
"""Handle a message event (contact or channel).
|
||||
|
||||
Args:
|
||||
public_key: Receiver node's public key
|
||||
message_type: Message type ('contact' or 'channel')
|
||||
payload: Message payload
|
||||
db: Database manager
|
||||
"""
|
||||
text = payload.get("text")
|
||||
if not text:
|
||||
logger.warning(f"Message missing text content")
|
||||
return
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Extract fields based on message type
|
||||
pubkey_prefix = payload.get("pubkey_prefix") if message_type == "contact" else None
|
||||
channel_idx = payload.get("channel_idx") if message_type == "channel" else None
|
||||
path_len = payload.get("path_len")
|
||||
txt_type = payload.get("txt_type")
|
||||
signature = payload.get("signature")
|
||||
snr = payload.get("SNR") or payload.get("snr")
|
||||
|
||||
# Parse sender timestamp
|
||||
sender_ts = payload.get("sender_timestamp")
|
||||
sender_timestamp = None
|
||||
if sender_ts:
|
||||
try:
|
||||
sender_timestamp = datetime.fromtimestamp(sender_ts, tz=timezone.utc)
|
||||
except (ValueError, OSError):
|
||||
pass
|
||||
|
||||
with db.session_scope() as session:
|
||||
# Find receiver node
|
||||
receiver_node = None
|
||||
if public_key:
|
||||
receiver_query = select(Node).where(Node.public_key == public_key)
|
||||
receiver_node = session.execute(receiver_query).scalar_one_or_none()
|
||||
|
||||
if not receiver_node:
|
||||
receiver_node = Node(
|
||||
public_key=public_key,
|
||||
first_seen=now,
|
||||
last_seen=now,
|
||||
)
|
||||
session.add(receiver_node)
|
||||
session.flush()
|
||||
else:
|
||||
receiver_node.last_seen = now
|
||||
|
||||
# Create message record
|
||||
message = Message(
|
||||
receiver_node_id=receiver_node.id if receiver_node else None,
|
||||
message_type=message_type,
|
||||
pubkey_prefix=pubkey_prefix,
|
||||
channel_idx=channel_idx,
|
||||
text=text,
|
||||
path_len=path_len,
|
||||
txt_type=txt_type,
|
||||
signature=signature,
|
||||
snr=snr,
|
||||
sender_timestamp=sender_timestamp,
|
||||
received_at=now,
|
||||
)
|
||||
session.add(message)
|
||||
|
||||
if message_type == "contact":
|
||||
logger.info(
|
||||
f"Stored contact message from {pubkey_prefix!r}: "
|
||||
f"{text[:30]}{'...' if len(text) > 30 else ''}"
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
f"Stored channel {channel_idx} message: "
|
||||
f"{text[:30]}{'...' if len(text) > 30 else ''}"
|
||||
)
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Handler for telemetry events."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from meshcore_hub.common.database import DatabaseManager
|
||||
from meshcore_hub.common.models import Node, Telemetry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def handle_telemetry(
|
||||
public_key: str,
|
||||
event_type: str,
|
||||
payload: dict[str, Any],
|
||||
db: DatabaseManager,
|
||||
) -> None:
|
||||
"""Handle a telemetry response event.
|
||||
|
||||
Args:
|
||||
public_key: Receiver node's public key (from MQTT topic)
|
||||
event_type: Event type name
|
||||
payload: Telemetry payload
|
||||
db: Database manager
|
||||
"""
|
||||
node_public_key = payload.get("node_public_key")
|
||||
if not node_public_key:
|
||||
logger.warning("Telemetry missing node_public_key")
|
||||
return
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
lpp_data = payload.get("lpp_data")
|
||||
parsed_data = payload.get("parsed_data")
|
||||
|
||||
# Convert lpp_data to bytes if it's a string or list
|
||||
lpp_bytes = None
|
||||
if lpp_data:
|
||||
if isinstance(lpp_data, bytes):
|
||||
lpp_bytes = lpp_data
|
||||
elif isinstance(lpp_data, list):
|
||||
lpp_bytes = bytes(lpp_data)
|
||||
elif isinstance(lpp_data, str):
|
||||
try:
|
||||
lpp_bytes = bytes.fromhex(lpp_data)
|
||||
except ValueError:
|
||||
lpp_bytes = lpp_data.encode()
|
||||
|
||||
with db.session_scope() as session:
|
||||
# Find receiver node
|
||||
receiver_node = None
|
||||
if public_key:
|
||||
receiver_query = select(Node).where(Node.public_key == public_key)
|
||||
receiver_node = session.execute(receiver_query).scalar_one_or_none()
|
||||
|
||||
if not receiver_node:
|
||||
receiver_node = Node(
|
||||
public_key=public_key,
|
||||
first_seen=now,
|
||||
last_seen=now,
|
||||
)
|
||||
session.add(receiver_node)
|
||||
session.flush()
|
||||
else:
|
||||
receiver_node.last_seen = now
|
||||
|
||||
# Find or create reporting node
|
||||
reporting_node = None
|
||||
if node_public_key:
|
||||
node_query = select(Node).where(Node.public_key == node_public_key)
|
||||
reporting_node = session.execute(node_query).scalar_one_or_none()
|
||||
|
||||
if not reporting_node:
|
||||
reporting_node = Node(
|
||||
public_key=node_public_key,
|
||||
first_seen=now,
|
||||
last_seen=now,
|
||||
)
|
||||
session.add(reporting_node)
|
||||
session.flush()
|
||||
else:
|
||||
reporting_node.last_seen = now
|
||||
|
||||
# Create telemetry record
|
||||
telemetry = Telemetry(
|
||||
receiver_node_id=receiver_node.id if receiver_node else None,
|
||||
node_id=reporting_node.id if reporting_node else None,
|
||||
node_public_key=node_public_key,
|
||||
lpp_data=lpp_bytes,
|
||||
parsed_data=parsed_data,
|
||||
received_at=now,
|
||||
)
|
||||
session.add(telemetry)
|
||||
|
||||
# Log telemetry values
|
||||
if parsed_data:
|
||||
values = ", ".join(f"{k}={v}" for k, v in parsed_data.items())
|
||||
logger.info(f"Stored telemetry from {node_public_key[:12]!r}: {values}")
|
||||
else:
|
||||
logger.info(f"Stored telemetry from {node_public_key[:12]!r}")
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Handler for trace data events."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from meshcore_hub.common.database import DatabaseManager
|
||||
from meshcore_hub.common.models import Node, TracePath
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def handle_trace_data(
|
||||
public_key: str,
|
||||
event_type: str,
|
||||
payload: dict[str, Any],
|
||||
db: DatabaseManager,
|
||||
) -> None:
|
||||
"""Handle a trace data event.
|
||||
|
||||
Args:
|
||||
public_key: Receiver node's public key (from MQTT topic)
|
||||
event_type: Event type name
|
||||
payload: Trace data payload
|
||||
db: Database manager
|
||||
"""
|
||||
initiator_tag = payload.get("initiator_tag")
|
||||
if initiator_tag is None:
|
||||
logger.warning("Trace data missing initiator_tag")
|
||||
return
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
path_len = payload.get("path_len")
|
||||
flags = payload.get("flags")
|
||||
auth = payload.get("auth")
|
||||
path_hashes = payload.get("path_hashes")
|
||||
snr_values = payload.get("snr_values")
|
||||
hop_count = payload.get("hop_count")
|
||||
|
||||
with db.session_scope() as session:
|
||||
# Find receiver node
|
||||
receiver_node = None
|
||||
if public_key:
|
||||
receiver_query = select(Node).where(Node.public_key == public_key)
|
||||
receiver_node = session.execute(receiver_query).scalar_one_or_none()
|
||||
|
||||
if not receiver_node:
|
||||
receiver_node = Node(
|
||||
public_key=public_key,
|
||||
first_seen=now,
|
||||
last_seen=now,
|
||||
)
|
||||
session.add(receiver_node)
|
||||
session.flush()
|
||||
else:
|
||||
receiver_node.last_seen = now
|
||||
|
||||
# Create trace path record
|
||||
trace_path = TracePath(
|
||||
receiver_node_id=receiver_node.id if receiver_node else None,
|
||||
initiator_tag=initiator_tag,
|
||||
path_len=path_len,
|
||||
flags=flags,
|
||||
auth=auth,
|
||||
path_hashes=path_hashes,
|
||||
snr_values=snr_values,
|
||||
hop_count=hop_count,
|
||||
received_at=now,
|
||||
)
|
||||
session.add(trace_path)
|
||||
|
||||
logger.info(
|
||||
f"Stored trace data: tag={initiator_tag}, hops={hop_count}"
|
||||
)
|
||||
@@ -0,0 +1,230 @@
|
||||
"""MQTT Subscriber for collecting MeshCore events.
|
||||
|
||||
The subscriber:
|
||||
1. Connects to MQTT broker
|
||||
2. Subscribes to all event topics
|
||||
3. Routes events to appropriate handlers
|
||||
4. Persists data to database
|
||||
"""
|
||||
|
||||
import logging
|
||||
import signal
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from meshcore_hub.common.database import DatabaseManager
|
||||
from meshcore_hub.common.mqtt import MQTTClient, MQTTConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Handler type: receives (public_key, event_type, payload, db_manager)
|
||||
EventHandler = Callable[[str, str, dict[str, Any], DatabaseManager], None]
|
||||
|
||||
|
||||
class Subscriber:
|
||||
"""MQTT Subscriber for collecting and storing MeshCore events."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
mqtt_client: MQTTClient,
|
||||
db_manager: DatabaseManager,
|
||||
):
|
||||
"""Initialize subscriber.
|
||||
|
||||
Args:
|
||||
mqtt_client: MQTT client instance
|
||||
db_manager: Database manager instance
|
||||
"""
|
||||
self.mqtt = mqtt_client
|
||||
self.db = db_manager
|
||||
self._running = False
|
||||
self._shutdown_event = threading.Event()
|
||||
self._handlers: dict[str, EventHandler] = {}
|
||||
|
||||
def register_handler(self, event_type: str, handler: EventHandler) -> None:
|
||||
"""Register a handler for an event type.
|
||||
|
||||
Args:
|
||||
event_type: Event type name (e.g., 'advertisement')
|
||||
handler: Handler function
|
||||
"""
|
||||
self._handlers[event_type] = handler
|
||||
logger.debug(f"Registered handler for {event_type}")
|
||||
|
||||
def _handle_mqtt_message(
|
||||
self,
|
||||
topic: str,
|
||||
pattern: str,
|
||||
payload: dict[str, Any],
|
||||
) -> None:
|
||||
"""Handle incoming MQTT event message.
|
||||
|
||||
Args:
|
||||
topic: MQTT topic
|
||||
pattern: Subscription pattern
|
||||
payload: Message payload
|
||||
"""
|
||||
# Parse event from topic
|
||||
parsed = self.mqtt.topic_builder.parse_event_topic(topic)
|
||||
if not parsed:
|
||||
logger.warning(f"Could not parse event topic: {topic}")
|
||||
return
|
||||
|
||||
public_key, event_type = parsed
|
||||
logger.debug(f"Received event: {event_type} from {public_key[:12]}...")
|
||||
|
||||
# Find and call handler
|
||||
handler = self._handlers.get(event_type)
|
||||
if handler:
|
||||
try:
|
||||
handler(public_key, event_type, payload, self.db)
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling {event_type}: {e}")
|
||||
else:
|
||||
# Use generic event log handler if no specific handler
|
||||
from meshcore_hub.collector.handlers.event_log import handle_event_log
|
||||
|
||||
try:
|
||||
handle_event_log(public_key, event_type, payload, self.db)
|
||||
except Exception as e:
|
||||
logger.error(f"Error logging event {event_type}: {e}")
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start the subscriber."""
|
||||
logger.info("Starting collector subscriber")
|
||||
|
||||
# Create database tables if needed
|
||||
self.db.create_tables()
|
||||
|
||||
# Connect to MQTT broker
|
||||
try:
|
||||
self.mqtt.connect()
|
||||
self.mqtt.start_background()
|
||||
logger.info("Connected to MQTT broker")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to connect to MQTT broker: {e}")
|
||||
raise
|
||||
|
||||
# Subscribe to all event topics
|
||||
event_topic = self.mqtt.topic_builder.all_events_topic()
|
||||
self.mqtt.subscribe(event_topic, self._handle_mqtt_message)
|
||||
logger.info(f"Subscribed to event topic: {event_topic}")
|
||||
|
||||
self._running = True
|
||||
|
||||
def run(self) -> None:
|
||||
"""Run the subscriber event loop (blocking)."""
|
||||
if not self._running:
|
||||
self.start()
|
||||
|
||||
logger.info("Collector running. Press Ctrl+C to stop.")
|
||||
|
||||
try:
|
||||
while self._running and not self._shutdown_event.is_set():
|
||||
time.sleep(0.1)
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Keyboard interrupt received")
|
||||
finally:
|
||||
self.stop()
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the subscriber."""
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
logger.info("Stopping collector subscriber")
|
||||
self._running = False
|
||||
self._shutdown_event.set()
|
||||
|
||||
# Stop MQTT
|
||||
self.mqtt.stop()
|
||||
self.mqtt.disconnect()
|
||||
|
||||
logger.info("Collector subscriber stopped")
|
||||
|
||||
|
||||
def create_subscriber(
|
||||
mqtt_host: str = "localhost",
|
||||
mqtt_port: int = 1883,
|
||||
mqtt_username: Optional[str] = None,
|
||||
mqtt_password: Optional[str] = None,
|
||||
mqtt_prefix: str = "meshcore",
|
||||
database_url: str = "sqlite:///./meshcore.db",
|
||||
) -> Subscriber:
|
||||
"""Create a configured subscriber instance.
|
||||
|
||||
Args:
|
||||
mqtt_host: MQTT broker host
|
||||
mqtt_port: MQTT broker port
|
||||
mqtt_username: MQTT username
|
||||
mqtt_password: MQTT password
|
||||
mqtt_prefix: MQTT topic prefix
|
||||
database_url: Database connection URL
|
||||
|
||||
Returns:
|
||||
Configured Subscriber instance
|
||||
"""
|
||||
# Create MQTT client
|
||||
mqtt_config = MQTTConfig(
|
||||
host=mqtt_host,
|
||||
port=mqtt_port,
|
||||
username=mqtt_username,
|
||||
password=mqtt_password,
|
||||
prefix=mqtt_prefix,
|
||||
client_id="meshcore-collector",
|
||||
)
|
||||
mqtt_client = MQTTClient(mqtt_config)
|
||||
|
||||
# Create database manager
|
||||
db_manager = DatabaseManager(database_url)
|
||||
|
||||
# Create subscriber
|
||||
subscriber = Subscriber(mqtt_client, db_manager)
|
||||
|
||||
# Register handlers
|
||||
from meshcore_hub.collector.handlers import register_all_handlers
|
||||
|
||||
register_all_handlers(subscriber)
|
||||
|
||||
return subscriber
|
||||
|
||||
|
||||
def run_collector(
|
||||
mqtt_host: str = "localhost",
|
||||
mqtt_port: int = 1883,
|
||||
mqtt_username: Optional[str] = None,
|
||||
mqtt_password: Optional[str] = None,
|
||||
mqtt_prefix: str = "meshcore",
|
||||
database_url: str = "sqlite:///./meshcore.db",
|
||||
) -> None:
|
||||
"""Run the collector (blocking).
|
||||
|
||||
Args:
|
||||
mqtt_host: MQTT broker host
|
||||
mqtt_port: MQTT broker port
|
||||
mqtt_username: MQTT username
|
||||
mqtt_password: MQTT password
|
||||
mqtt_prefix: MQTT topic prefix
|
||||
database_url: Database connection URL
|
||||
"""
|
||||
subscriber = create_subscriber(
|
||||
mqtt_host=mqtt_host,
|
||||
mqtt_port=mqtt_port,
|
||||
mqtt_username=mqtt_username,
|
||||
mqtt_password=mqtt_password,
|
||||
mqtt_prefix=mqtt_prefix,
|
||||
database_url=database_url,
|
||||
)
|
||||
|
||||
# Set up signal handlers
|
||||
def signal_handler(signum: int, frame: Any) -> None:
|
||||
logger.info(f"Received signal {signum}")
|
||||
subscriber.stop()
|
||||
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
signal.signal(signal.SIGTERM, signal_handler)
|
||||
|
||||
# Run
|
||||
subscriber.run()
|
||||
@@ -0,0 +1 @@
|
||||
"""Common utilities, models and configurations used by all components."""
|
||||
@@ -0,0 +1,192 @@
|
||||
"""Pydantic Settings for MeshCore Hub configuration."""
|
||||
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import Field, field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class LogLevel(str, Enum):
|
||||
"""Log level enumeration."""
|
||||
|
||||
DEBUG = "DEBUG"
|
||||
INFO = "INFO"
|
||||
WARNING = "WARNING"
|
||||
ERROR = "ERROR"
|
||||
CRITICAL = "CRITICAL"
|
||||
|
||||
|
||||
class InterfaceMode(str, Enum):
|
||||
"""Interface component mode."""
|
||||
|
||||
RECEIVER = "RECEIVER"
|
||||
SENDER = "SENDER"
|
||||
|
||||
|
||||
class CommonSettings(BaseSettings):
|
||||
"""Common settings shared by all components."""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
# Logging
|
||||
log_level: LogLevel = Field(default=LogLevel.INFO, description="Logging level")
|
||||
|
||||
# MQTT Broker
|
||||
mqtt_host: str = Field(default="localhost", description="MQTT broker host")
|
||||
mqtt_port: int = Field(default=1883, description="MQTT broker port")
|
||||
mqtt_username: Optional[str] = Field(
|
||||
default=None, description="MQTT username (optional)"
|
||||
)
|
||||
mqtt_password: Optional[str] = Field(
|
||||
default=None, description="MQTT password (optional)"
|
||||
)
|
||||
mqtt_prefix: str = Field(
|
||||
default="meshcore", description="MQTT topic prefix"
|
||||
)
|
||||
|
||||
|
||||
class InterfaceSettings(CommonSettings):
|
||||
"""Settings for the Interface component."""
|
||||
|
||||
# Mode
|
||||
interface_mode: InterfaceMode = Field(
|
||||
default=InterfaceMode.RECEIVER,
|
||||
description="Interface mode: RECEIVER or SENDER",
|
||||
)
|
||||
|
||||
# Serial connection
|
||||
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"
|
||||
)
|
||||
|
||||
|
||||
class CollectorSettings(CommonSettings):
|
||||
"""Settings for the Collector component."""
|
||||
|
||||
# Database
|
||||
database_url: str = Field(
|
||||
default="sqlite:///./meshcore.db",
|
||||
description="SQLAlchemy database URL",
|
||||
)
|
||||
|
||||
@field_validator("database_url")
|
||||
@classmethod
|
||||
def validate_database_url(cls, v: str) -> str:
|
||||
"""Validate database URL format."""
|
||||
if not v:
|
||||
raise ValueError("Database URL cannot be empty")
|
||||
return v
|
||||
|
||||
|
||||
class APISettings(CommonSettings):
|
||||
"""Settings for the API component."""
|
||||
|
||||
# Server binding
|
||||
api_host: str = Field(default="0.0.0.0", description="API server host")
|
||||
api_port: int = Field(default=8000, description="API server port")
|
||||
|
||||
# Database
|
||||
database_url: str = Field(
|
||||
default="sqlite:///./meshcore.db",
|
||||
description="SQLAlchemy database URL",
|
||||
)
|
||||
|
||||
# Authentication
|
||||
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)"
|
||||
)
|
||||
|
||||
@field_validator("database_url")
|
||||
@classmethod
|
||||
def validate_database_url(cls, v: str) -> str:
|
||||
"""Validate database URL format."""
|
||||
if not v:
|
||||
raise ValueError("Database URL cannot be empty")
|
||||
return v
|
||||
|
||||
|
||||
class WebSettings(CommonSettings):
|
||||
"""Settings for the Web Dashboard component."""
|
||||
|
||||
# Server binding
|
||||
web_host: str = Field(default="0.0.0.0", description="Web server host")
|
||||
web_port: int = Field(default=8080, description="Web server port")
|
||||
|
||||
# API connection
|
||||
api_base_url: str = Field(
|
||||
default="http://localhost:8000",
|
||||
description="API server base URL",
|
||||
)
|
||||
api_key: Optional[str] = Field(
|
||||
default=None, description="API key for queries"
|
||||
)
|
||||
|
||||
# Network information
|
||||
network_domain: Optional[str] = Field(
|
||||
default=None, description="Network domain name"
|
||||
)
|
||||
network_name: str = Field(
|
||||
default="MeshCore Network", description="Network display name"
|
||||
)
|
||||
network_city: Optional[str] = Field(
|
||||
default=None, description="Network city location"
|
||||
)
|
||||
network_country: Optional[str] = Field(
|
||||
default=None, description="Network country (ISO 3166-1 alpha-2)"
|
||||
)
|
||||
network_location: Optional[str] = Field(
|
||||
default=None, description="Network location (lat,lon)"
|
||||
)
|
||||
network_radio_config: Optional[str] = Field(
|
||||
default=None, description="Radio configuration details"
|
||||
)
|
||||
network_contact_email: Optional[str] = Field(
|
||||
default=None, description="Contact email address"
|
||||
)
|
||||
network_contact_discord: Optional[str] = Field(
|
||||
default=None, description="Discord server link"
|
||||
)
|
||||
|
||||
# Members file
|
||||
members_file: str = Field(
|
||||
default="members.json", description="Path to members JSON file"
|
||||
)
|
||||
|
||||
|
||||
def get_common_settings() -> CommonSettings:
|
||||
"""Get common settings instance."""
|
||||
return CommonSettings()
|
||||
|
||||
|
||||
def get_interface_settings() -> InterfaceSettings:
|
||||
"""Get interface settings instance."""
|
||||
return InterfaceSettings()
|
||||
|
||||
|
||||
def get_collector_settings() -> CollectorSettings:
|
||||
"""Get collector settings instance."""
|
||||
return CollectorSettings()
|
||||
|
||||
|
||||
def get_api_settings() -> APISettings:
|
||||
"""Get API settings instance."""
|
||||
return APISettings()
|
||||
|
||||
|
||||
def get_web_settings() -> WebSettings:
|
||||
"""Get web settings instance."""
|
||||
return WebSettings()
|
||||
@@ -0,0 +1,186 @@
|
||||
"""Database connection and session management."""
|
||||
|
||||
from contextlib import contextmanager
|
||||
from typing import Generator
|
||||
|
||||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from meshcore_hub.common.models.base import Base
|
||||
|
||||
|
||||
def create_database_engine(
|
||||
database_url: str,
|
||||
echo: bool = False,
|
||||
) -> Engine:
|
||||
"""Create a SQLAlchemy database engine.
|
||||
|
||||
Args:
|
||||
database_url: SQLAlchemy database URL
|
||||
echo: Enable SQL query logging
|
||||
|
||||
Returns:
|
||||
SQLAlchemy Engine instance
|
||||
"""
|
||||
connect_args = {}
|
||||
|
||||
# SQLite-specific configuration
|
||||
if database_url.startswith("sqlite"):
|
||||
connect_args["check_same_thread"] = False
|
||||
|
||||
engine = create_engine(
|
||||
database_url,
|
||||
echo=echo,
|
||||
connect_args=connect_args,
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
|
||||
# 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()
|
||||
cursor.execute("PRAGMA foreign_keys=ON")
|
||||
cursor.close()
|
||||
|
||||
return engine
|
||||
|
||||
|
||||
def create_session_factory(engine: Engine) -> sessionmaker[Session]:
|
||||
"""Create a session factory for the given engine.
|
||||
|
||||
Args:
|
||||
engine: SQLAlchemy Engine instance
|
||||
|
||||
Returns:
|
||||
Session factory
|
||||
"""
|
||||
return sessionmaker(
|
||||
bind=engine,
|
||||
autocommit=False,
|
||||
autoflush=False,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
|
||||
|
||||
def create_tables(engine: Engine) -> None:
|
||||
"""Create all database tables.
|
||||
|
||||
Args:
|
||||
engine: SQLAlchemy Engine instance
|
||||
"""
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
|
||||
def drop_tables(engine: Engine) -> None:
|
||||
"""Drop all database tables.
|
||||
|
||||
Args:
|
||||
engine: SQLAlchemy Engine instance
|
||||
"""
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
class DatabaseManager:
|
||||
"""Database connection manager.
|
||||
|
||||
Manages database engine and session creation for a component.
|
||||
"""
|
||||
|
||||
def __init__(self, database_url: str, echo: bool = False):
|
||||
"""Initialize the database manager.
|
||||
|
||||
Args:
|
||||
database_url: SQLAlchemy database URL
|
||||
echo: Enable SQL query logging
|
||||
"""
|
||||
self.database_url = database_url
|
||||
self.engine = create_database_engine(database_url, echo=echo)
|
||||
self.session_factory = create_session_factory(self.engine)
|
||||
|
||||
def create_tables(self) -> None:
|
||||
"""Create all database tables."""
|
||||
create_tables(self.engine)
|
||||
|
||||
def drop_tables(self) -> None:
|
||||
"""Drop all database tables."""
|
||||
drop_tables(self.engine)
|
||||
|
||||
def get_session(self) -> Session:
|
||||
"""Get a new database session.
|
||||
|
||||
Returns:
|
||||
New Session instance
|
||||
"""
|
||||
return self.session_factory()
|
||||
|
||||
@contextmanager
|
||||
def session_scope(self) -> Generator[Session, None, None]:
|
||||
"""Provide a transactional scope around a series of operations.
|
||||
|
||||
Yields:
|
||||
Session instance
|
||||
|
||||
Example:
|
||||
with db.session_scope() as session:
|
||||
session.add(node)
|
||||
session.commit()
|
||||
"""
|
||||
session = self.get_session()
|
||||
try:
|
||||
yield session
|
||||
session.commit()
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def dispose(self) -> None:
|
||||
"""Dispose of the database engine and connection pool."""
|
||||
self.engine.dispose()
|
||||
|
||||
|
||||
# Global database manager instance (initialized at runtime)
|
||||
_db_manager: DatabaseManager | None = None
|
||||
|
||||
|
||||
def init_database(database_url: str, echo: bool = False) -> DatabaseManager:
|
||||
"""Initialize the global database manager.
|
||||
|
||||
Args:
|
||||
database_url: SQLAlchemy database URL
|
||||
echo: Enable SQL query logging
|
||||
|
||||
Returns:
|
||||
DatabaseManager instance
|
||||
"""
|
||||
global _db_manager
|
||||
_db_manager = DatabaseManager(database_url, echo=echo)
|
||||
return _db_manager
|
||||
|
||||
|
||||
def get_database() -> DatabaseManager:
|
||||
"""Get the global database manager.
|
||||
|
||||
Returns:
|
||||
DatabaseManager instance
|
||||
|
||||
Raises:
|
||||
RuntimeError: If database not initialized
|
||||
"""
|
||||
if _db_manager is None:
|
||||
raise RuntimeError(
|
||||
"Database not initialized. Call init_database() first."
|
||||
)
|
||||
return _db_manager
|
||||
|
||||
|
||||
def get_session() -> Session:
|
||||
"""Get a database session from the global manager.
|
||||
|
||||
Returns:
|
||||
Session instance
|
||||
"""
|
||||
return get_database().get_session()
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Logging configuration for MeshCore Hub."""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from typing import Optional
|
||||
|
||||
from meshcore_hub.common.config import LogLevel
|
||||
|
||||
|
||||
# Default log format
|
||||
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"
|
||||
)
|
||||
|
||||
|
||||
def configure_logging(
|
||||
level: LogLevel | str = LogLevel.INFO,
|
||||
format_string: Optional[str] = None,
|
||||
structured: bool = False,
|
||||
) -> None:
|
||||
"""Configure logging for the application.
|
||||
|
||||
Args:
|
||||
level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
||||
format_string: Custom log format string (optional)
|
||||
structured: Use structured logging format
|
||||
"""
|
||||
# Convert LogLevel enum to string if necessary
|
||||
if isinstance(level, LogLevel):
|
||||
level_str = level.value
|
||||
else:
|
||||
level_str = level.upper()
|
||||
|
||||
# Get numeric log level
|
||||
numeric_level = getattr(logging, level_str, logging.INFO)
|
||||
|
||||
# Determine format
|
||||
if format_string:
|
||||
log_format = format_string
|
||||
elif structured:
|
||||
log_format = STRUCTURED_FORMAT
|
||||
else:
|
||||
log_format = DEFAULT_FORMAT
|
||||
|
||||
# Configure root logger
|
||||
logging.basicConfig(
|
||||
level=numeric_level,
|
||||
format=log_format,
|
||||
handlers=[
|
||||
logging.StreamHandler(sys.stdout),
|
||||
],
|
||||
)
|
||||
|
||||
# Set levels for noisy third-party loggers
|
||||
logging.getLogger("paho").setLevel(logging.WARNING)
|
||||
logging.getLogger("urllib3").setLevel(logging.WARNING)
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
||||
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
|
||||
|
||||
# Set our loggers to the configured level
|
||||
logging.getLogger("meshcore_hub").setLevel(numeric_level)
|
||||
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
"""Get a logger with the given name.
|
||||
|
||||
Args:
|
||||
name: Logger name (typically __name__)
|
||||
|
||||
Returns:
|
||||
Logger instance
|
||||
"""
|
||||
return logging.getLogger(name)
|
||||
|
||||
|
||||
class ComponentLogger:
|
||||
"""Logger wrapper for a specific component."""
|
||||
|
||||
def __init__(self, component: str):
|
||||
"""Initialize component logger.
|
||||
|
||||
Args:
|
||||
component: Component name (e.g., 'interface', 'collector')
|
||||
"""
|
||||
self.component = component
|
||||
self._logger = logging.getLogger(f"meshcore_hub.{component}")
|
||||
|
||||
def debug(self, message: str, **kwargs: object) -> None:
|
||||
"""Log a debug message."""
|
||||
self._logger.debug(message, extra=kwargs)
|
||||
|
||||
def info(self, message: str, **kwargs: object) -> None:
|
||||
"""Log an info message."""
|
||||
self._logger.info(message, extra=kwargs)
|
||||
|
||||
def warning(self, message: str, **kwargs: object) -> None:
|
||||
"""Log a warning message."""
|
||||
self._logger.warning(message, extra=kwargs)
|
||||
|
||||
def error(self, message: str, **kwargs: object) -> None:
|
||||
"""Log an error message."""
|
||||
self._logger.error(message, extra=kwargs)
|
||||
|
||||
def critical(self, message: str, **kwargs: object) -> None:
|
||||
"""Log a critical message."""
|
||||
self._logger.critical(message, extra=kwargs)
|
||||
|
||||
def exception(self, message: str, **kwargs: object) -> None:
|
||||
"""Log an exception with traceback."""
|
||||
self._logger.exception(message, extra=kwargs)
|
||||
|
||||
|
||||
def get_component_logger(component: str) -> ComponentLogger:
|
||||
"""Get a component-specific logger.
|
||||
|
||||
Args:
|
||||
component: Component name
|
||||
|
||||
Returns:
|
||||
ComponentLogger instance
|
||||
"""
|
||||
return ComponentLogger(component)
|
||||
@@ -0,0 +1,22 @@
|
||||
"""SQLAlchemy database models."""
|
||||
|
||||
from meshcore_hub.common.models.base import Base, TimestampMixin
|
||||
from meshcore_hub.common.models.node import Node
|
||||
from meshcore_hub.common.models.node_tag import NodeTag
|
||||
from meshcore_hub.common.models.message import Message
|
||||
from meshcore_hub.common.models.advertisement import Advertisement
|
||||
from meshcore_hub.common.models.trace_path import TracePath
|
||||
from meshcore_hub.common.models.telemetry import Telemetry
|
||||
from meshcore_hub.common.models.event_log import EventLog
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
"TimestampMixin",
|
||||
"Node",
|
||||
"NodeTag",
|
||||
"Message",
|
||||
"Advertisement",
|
||||
"TracePath",
|
||||
"Telemetry",
|
||||
"EventLog",
|
||||
]
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Advertisement model for storing node advertisements."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from meshcore_hub.common.models.base import Base, TimestampMixin, UUIDMixin, utc_now
|
||||
|
||||
|
||||
class Advertisement(Base, UUIDMixin, TimestampMixin):
|
||||
"""Advertisement model for storing node advertisements.
|
||||
|
||||
Attributes:
|
||||
id: UUID primary key
|
||||
receiver_node_id: FK to nodes (receiving interface)
|
||||
node_id: FK to nodes (advertised node)
|
||||
public_key: Advertised public key
|
||||
name: Advertised name
|
||||
adv_type: Node type (chat, repeater, room, none)
|
||||
flags: Capability flags
|
||||
received_at: When received by interface
|
||||
created_at: Record creation timestamp
|
||||
"""
|
||||
|
||||
__tablename__ = "advertisements"
|
||||
|
||||
receiver_node_id: Mapped[Optional[str]] = mapped_column(
|
||||
ForeignKey("nodes.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
node_id: Mapped[Optional[str]] = mapped_column(
|
||||
ForeignKey("nodes.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
public_key: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
name: Mapped[Optional[str]] = mapped_column(
|
||||
String(255),
|
||||
nullable=True,
|
||||
)
|
||||
adv_type: Mapped[Optional[str]] = mapped_column(
|
||||
String(20),
|
||||
nullable=True,
|
||||
)
|
||||
flags: Mapped[Optional[int]] = mapped_column(
|
||||
Integer,
|
||||
nullable=True,
|
||||
)
|
||||
received_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=utc_now,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
__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})>"
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Base model with common fields and mixins."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import DateTime, func
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
|
||||
def generate_uuid() -> str:
|
||||
"""Generate a new UUID string."""
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
def utc_now() -> datetime:
|
||||
"""Get current UTC datetime."""
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Base class for all SQLAlchemy models."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
"""Mixin that adds created_at and updated_at timestamp columns."""
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=utc_now,
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=utc_now,
|
||||
server_default=func.now(),
|
||||
onupdate=utc_now,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
|
||||
class UUIDMixin:
|
||||
"""Mixin that adds a UUID primary key."""
|
||||
|
||||
id: Mapped[str] = mapped_column(
|
||||
primary_key=True,
|
||||
default=generate_uuid,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
|
||||
def model_to_dict(model: Any) -> dict[str, Any]:
|
||||
"""Convert a SQLAlchemy model instance to a dictionary.
|
||||
|
||||
Args:
|
||||
model: SQLAlchemy model instance
|
||||
|
||||
Returns:
|
||||
Dictionary representation of the model
|
||||
"""
|
||||
result = {}
|
||||
for column in model.__table__.columns:
|
||||
value = getattr(model, column.name)
|
||||
if isinstance(value, datetime):
|
||||
result[column.name] = value.isoformat()
|
||||
else:
|
||||
result[column.name] = value
|
||||
return result
|
||||
@@ -0,0 +1,52 @@
|
||||
"""EventLog model for storing all event payloads."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, String
|
||||
from sqlalchemy.dialects.sqlite import JSON
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from meshcore_hub.common.models.base import Base, TimestampMixin, UUIDMixin, utc_now
|
||||
|
||||
|
||||
class EventLog(Base, UUIDMixin, TimestampMixin):
|
||||
"""EventLog model for storing all event payloads for audit/debugging.
|
||||
|
||||
Attributes:
|
||||
id: UUID primary key
|
||||
receiver_node_id: FK to nodes (receiving interface)
|
||||
event_type: Event type name
|
||||
payload: Full event payload as JSON
|
||||
received_at: When received by interface
|
||||
created_at: Record creation timestamp
|
||||
"""
|
||||
|
||||
__tablename__ = "events_log"
|
||||
|
||||
receiver_node_id: Mapped[Optional[str]] = mapped_column(
|
||||
ForeignKey("nodes.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
event_type: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
nullable=False,
|
||||
)
|
||||
payload: Mapped[Optional[dict[str, Any]]] = mapped_column(
|
||||
JSON,
|
||||
nullable=True,
|
||||
)
|
||||
received_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=utc_now,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_events_log_event_type", "event_type"),
|
||||
Index("ix_events_log_received_at", "received_at"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<EventLog(id={self.id}, event_type={self.event_type})>"
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Message model for storing received messages."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import DateTime, Float, ForeignKey, Index, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from meshcore_hub.common.models.base import Base, TimestampMixin, UUIDMixin, utc_now
|
||||
|
||||
|
||||
class Message(Base, UUIDMixin, TimestampMixin):
|
||||
"""Message model for storing contact and channel messages.
|
||||
|
||||
Attributes:
|
||||
id: UUID primary key
|
||||
receiver_node_id: FK to nodes (receiving interface)
|
||||
message_type: Message type (contact, channel)
|
||||
pubkey_prefix: Sender's public key prefix (12 chars, contact msgs)
|
||||
channel_idx: Channel index (channel msgs)
|
||||
text: Message content
|
||||
path_len: Number of hops
|
||||
txt_type: Message type indicator
|
||||
signature: Message signature (8 hex chars)
|
||||
snr: Signal-to-noise ratio
|
||||
sender_timestamp: Sender's timestamp
|
||||
received_at: When received by interface
|
||||
created_at: Record creation timestamp
|
||||
"""
|
||||
|
||||
__tablename__ = "messages"
|
||||
|
||||
receiver_node_id: Mapped[Optional[str]] = mapped_column(
|
||||
ForeignKey("nodes.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
message_type: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
nullable=False,
|
||||
)
|
||||
pubkey_prefix: Mapped[Optional[str]] = mapped_column(
|
||||
String(12),
|
||||
nullable=True,
|
||||
)
|
||||
channel_idx: Mapped[Optional[int]] = mapped_column(
|
||||
Integer,
|
||||
nullable=True,
|
||||
)
|
||||
text: Mapped[str] = mapped_column(
|
||||
Text,
|
||||
nullable=False,
|
||||
)
|
||||
path_len: Mapped[Optional[int]] = mapped_column(
|
||||
Integer,
|
||||
nullable=True,
|
||||
)
|
||||
txt_type: Mapped[Optional[int]] = mapped_column(
|
||||
Integer,
|
||||
nullable=True,
|
||||
)
|
||||
signature: Mapped[Optional[str]] = mapped_column(
|
||||
String(8),
|
||||
nullable=True,
|
||||
)
|
||||
snr: Mapped[Optional[float]] = mapped_column(
|
||||
Float,
|
||||
nullable=True,
|
||||
)
|
||||
sender_timestamp: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
)
|
||||
received_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=utc_now,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_messages_message_type", "message_type"),
|
||||
Index("ix_messages_pubkey_prefix", "pubkey_prefix"),
|
||||
Index("ix_messages_channel_idx", "channel_idx"),
|
||||
Index("ix_messages_received_at", "received_at"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Message(id={self.id}, type={self.message_type}, text={self.text[:20]}...)>"
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Node model for tracking MeshCore network nodes."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from sqlalchemy import DateTime, Index, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from meshcore_hub.common.models.base import Base, TimestampMixin, UUIDMixin, utc_now
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from meshcore_hub.common.models.node_tag import NodeTag
|
||||
|
||||
|
||||
class Node(Base, UUIDMixin, TimestampMixin):
|
||||
"""Node model representing a MeshCore network node.
|
||||
|
||||
Attributes:
|
||||
id: UUID primary key
|
||||
public_key: Node's 64-character hex public key (unique)
|
||||
name: Node display name
|
||||
adv_type: Advertisement type (chat, repeater, room, none)
|
||||
flags: Capability/status flags bitmask
|
||||
first_seen: Timestamp of first advertisement
|
||||
last_seen: Timestamp of most recent activity
|
||||
created_at: Record creation timestamp
|
||||
updated_at: Record update timestamp
|
||||
"""
|
||||
|
||||
__tablename__ = "nodes"
|
||||
|
||||
public_key: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
name: Mapped[Optional[str]] = mapped_column(
|
||||
String(255),
|
||||
nullable=True,
|
||||
)
|
||||
adv_type: Mapped[Optional[str]] = mapped_column(
|
||||
String(20),
|
||||
nullable=True,
|
||||
)
|
||||
flags: Mapped[Optional[int]] = mapped_column(
|
||||
Integer,
|
||||
nullable=True,
|
||||
)
|
||||
first_seen: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=utc_now,
|
||||
nullable=False,
|
||||
)
|
||||
last_seen: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=utc_now,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# Relationships
|
||||
tags: Mapped[list["NodeTag"]] = relationship(
|
||||
"NodeTag",
|
||||
back_populates="node",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_nodes_last_seen", "last_seen"),
|
||||
Index("ix_nodes_adv_type", "adv_type"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Node(id={self.id}, public_key={self.public_key[:12]}..., name={self.name})>"
|
||||
@@ -0,0 +1,62 @@
|
||||
"""NodeTag model for custom node metadata."""
|
||||
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from sqlalchemy import ForeignKey, Index, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from meshcore_hub.common.models.base import Base, TimestampMixin, UUIDMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from meshcore_hub.common.models.node import Node
|
||||
|
||||
|
||||
class NodeTag(Base, UUIDMixin, TimestampMixin):
|
||||
"""NodeTag model for custom node metadata.
|
||||
|
||||
Allows users to assign arbitrary key-value tags to nodes.
|
||||
|
||||
Attributes:
|
||||
id: UUID primary key
|
||||
node_id: Foreign key to nodes table
|
||||
key: Tag name/key
|
||||
value: Tag value (stored as text, can be JSON for typed values)
|
||||
value_type: Type hint (string, number, boolean, coordinate)
|
||||
created_at: Record creation timestamp
|
||||
updated_at: Record update timestamp
|
||||
"""
|
||||
|
||||
__tablename__ = "node_tags"
|
||||
|
||||
node_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("nodes.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
key: Mapped[str] = mapped_column(
|
||||
String(100),
|
||||
nullable=False,
|
||||
)
|
||||
value: Mapped[Optional[str]] = mapped_column(
|
||||
Text,
|
||||
nullable=True,
|
||||
)
|
||||
value_type: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
default="string",
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# Relationships
|
||||
node: Mapped["Node"] = relationship(
|
||||
"Node",
|
||||
back_populates="tags",
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("node_id", "key", name="uq_node_tags_node_key"),
|
||||
Index("ix_node_tags_key", "key"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<NodeTag(node_id={self.node_id}, key={self.key}, value={self.value})>"
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Telemetry model for storing sensor data."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, LargeBinary, String
|
||||
from sqlalchemy.dialects.sqlite import JSON
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from meshcore_hub.common.models.base import Base, TimestampMixin, UUIDMixin, utc_now
|
||||
|
||||
|
||||
class Telemetry(Base, UUIDMixin, TimestampMixin):
|
||||
"""Telemetry model for storing sensor data from network nodes.
|
||||
|
||||
Attributes:
|
||||
id: UUID primary key
|
||||
receiver_node_id: FK to nodes (receiving interface)
|
||||
node_id: FK to nodes (reporting node)
|
||||
node_public_key: Reporting node's public key
|
||||
lpp_data: Raw LPP-encoded sensor data
|
||||
parsed_data: Decoded sensor readings as JSON
|
||||
received_at: When received by interface
|
||||
created_at: Record creation timestamp
|
||||
"""
|
||||
|
||||
__tablename__ = "telemetry"
|
||||
|
||||
receiver_node_id: Mapped[Optional[str]] = mapped_column(
|
||||
ForeignKey("nodes.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
node_id: Mapped[Optional[str]] = mapped_column(
|
||||
ForeignKey("nodes.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
node_public_key: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
lpp_data: Mapped[Optional[bytes]] = mapped_column(
|
||||
LargeBinary,
|
||||
nullable=True,
|
||||
)
|
||||
parsed_data: Mapped[Optional[dict[str, Any]]] = mapped_column(
|
||||
JSON,
|
||||
nullable=True,
|
||||
)
|
||||
received_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=utc_now,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
__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]}...)>"
|
||||
@@ -0,0 +1,77 @@
|
||||
"""TracePath model for storing network trace data."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import BigInteger, DateTime, ForeignKey, Index, Integer, String
|
||||
from sqlalchemy.dialects.sqlite import JSON
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from meshcore_hub.common.models.base import Base, TimestampMixin, UUIDMixin, utc_now
|
||||
|
||||
|
||||
class TracePath(Base, UUIDMixin, TimestampMixin):
|
||||
"""TracePath model for storing network trace path results.
|
||||
|
||||
Attributes:
|
||||
id: UUID primary key
|
||||
receiver_node_id: FK to nodes (receiving interface)
|
||||
initiator_tag: Unique trace identifier
|
||||
path_len: Path length
|
||||
flags: Trace flags
|
||||
auth: Authentication data
|
||||
path_hashes: JSON array of node hash identifiers
|
||||
snr_values: JSON array of SNR values per hop
|
||||
hop_count: Total number of hops
|
||||
received_at: When received by interface
|
||||
created_at: Record creation timestamp
|
||||
"""
|
||||
|
||||
__tablename__ = "trace_paths"
|
||||
|
||||
receiver_node_id: Mapped[Optional[str]] = mapped_column(
|
||||
ForeignKey("nodes.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
initiator_tag: Mapped[int] = mapped_column(
|
||||
BigInteger,
|
||||
nullable=False,
|
||||
)
|
||||
path_len: Mapped[Optional[int]] = mapped_column(
|
||||
Integer,
|
||||
nullable=True,
|
||||
)
|
||||
flags: Mapped[Optional[int]] = mapped_column(
|
||||
Integer,
|
||||
nullable=True,
|
||||
)
|
||||
auth: Mapped[Optional[int]] = mapped_column(
|
||||
Integer,
|
||||
nullable=True,
|
||||
)
|
||||
path_hashes: Mapped[Optional[list[str]]] = mapped_column(
|
||||
JSON,
|
||||
nullable=True,
|
||||
)
|
||||
snr_values: Mapped[Optional[list[float]]] = mapped_column(
|
||||
JSON,
|
||||
nullable=True,
|
||||
)
|
||||
hop_count: Mapped[Optional[int]] = mapped_column(
|
||||
Integer,
|
||||
nullable=True,
|
||||
)
|
||||
received_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=utc_now,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_trace_paths_initiator_tag", "initiator_tag"),
|
||||
Index("ix_trace_paths_received_at", "received_at"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<TracePath(id={self.id}, initiator_tag={self.initiator_tag}, hop_count={self.hop_count})>"
|
||||
@@ -0,0 +1,365 @@
|
||||
"""MQTT client utilities for MeshCore Hub."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
import paho.mqtt.client as mqtt
|
||||
from paho.mqtt.enums import CallbackAPIVersion
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MQTTConfig:
|
||||
"""MQTT connection configuration."""
|
||||
|
||||
host: str = "localhost"
|
||||
port: int = 1883
|
||||
username: Optional[str] = None
|
||||
password: Optional[str] = None
|
||||
prefix: str = "meshcore"
|
||||
client_id: Optional[str] = None
|
||||
keepalive: int = 60
|
||||
clean_session: bool = True
|
||||
|
||||
|
||||
class TopicBuilder:
|
||||
"""Helper class for building MQTT topics."""
|
||||
|
||||
def __init__(self, prefix: str = "meshcore"):
|
||||
"""Initialize topic builder.
|
||||
|
||||
Args:
|
||||
prefix: MQTT topic prefix
|
||||
"""
|
||||
self.prefix = prefix
|
||||
|
||||
def event_topic(self, public_key: str, event_name: str) -> str:
|
||||
"""Build an event topic.
|
||||
|
||||
Args:
|
||||
public_key: Node's public key
|
||||
event_name: Event name
|
||||
|
||||
Returns:
|
||||
Full MQTT topic string
|
||||
"""
|
||||
return f"{self.prefix}/{public_key}/event/{event_name}"
|
||||
|
||||
def command_topic(self, public_key: str, command_name: str) -> str:
|
||||
"""Build a command topic.
|
||||
|
||||
Args:
|
||||
public_key: Node's public key (or '+' for wildcard)
|
||||
command_name: Command name
|
||||
|
||||
Returns:
|
||||
Full MQTT topic string
|
||||
"""
|
||||
return f"{self.prefix}/{public_key}/command/{command_name}"
|
||||
|
||||
def all_events_topic(self) -> str:
|
||||
"""Build a topic pattern to subscribe to all events.
|
||||
|
||||
Returns:
|
||||
MQTT topic pattern with wildcards
|
||||
"""
|
||||
return f"{self.prefix}/+/event/#"
|
||||
|
||||
def all_commands_topic(self) -> str:
|
||||
"""Build a topic pattern to subscribe to all commands.
|
||||
|
||||
Returns:
|
||||
MQTT topic pattern with wildcards
|
||||
"""
|
||||
return f"{self.prefix}/+/command/#"
|
||||
|
||||
def parse_event_topic(self, topic: str) -> tuple[str, str] | None:
|
||||
"""Parse an event topic to extract public key and event name.
|
||||
|
||||
Args:
|
||||
topic: Full MQTT topic string
|
||||
|
||||
Returns:
|
||||
Tuple of (public_key, event_name) or None if invalid
|
||||
"""
|
||||
parts = topic.split("/")
|
||||
if len(parts) >= 4 and parts[0] == self.prefix and parts[2] == "event":
|
||||
public_key = parts[1]
|
||||
event_name = "/".join(parts[3:])
|
||||
return (public_key, event_name)
|
||||
return None
|
||||
|
||||
def parse_command_topic(self, topic: str) -> tuple[str, str] | None:
|
||||
"""Parse a command topic to extract public key and command name.
|
||||
|
||||
Args:
|
||||
topic: Full MQTT topic string
|
||||
|
||||
Returns:
|
||||
Tuple of (public_key, command_name) or None if invalid
|
||||
"""
|
||||
parts = topic.split("/")
|
||||
if len(parts) >= 4 and parts[0] == self.prefix and parts[2] == "command":
|
||||
public_key = parts[1]
|
||||
command_name = "/".join(parts[3:])
|
||||
return (public_key, command_name)
|
||||
return None
|
||||
|
||||
|
||||
MessageHandler = Callable[[str, str, dict[str, Any]], None]
|
||||
|
||||
|
||||
class MQTTClient:
|
||||
"""Wrapper for paho-mqtt client with helper methods."""
|
||||
|
||||
def __init__(self, config: MQTTConfig):
|
||||
"""Initialize MQTT client.
|
||||
|
||||
Args:
|
||||
config: MQTT configuration
|
||||
"""
|
||||
self.config = config
|
||||
self.topic_builder = TopicBuilder(config.prefix)
|
||||
self._client = mqtt.Client(
|
||||
callback_api_version=CallbackAPIVersion.VERSION2,
|
||||
client_id=config.client_id,
|
||||
clean_session=config.clean_session,
|
||||
)
|
||||
self._connected = False
|
||||
self._message_handlers: dict[str, list[MessageHandler]] = {}
|
||||
|
||||
# Set up authentication if provided
|
||||
if config.username:
|
||||
self._client.username_pw_set(config.username, config.password)
|
||||
|
||||
# Set up callbacks
|
||||
self._client.on_connect = self._on_connect
|
||||
self._client.on_disconnect = self._on_disconnect
|
||||
self._client.on_message = self._on_message
|
||||
|
||||
def _on_connect(
|
||||
self,
|
||||
client: mqtt.Client,
|
||||
userdata: Any,
|
||||
flags: Any,
|
||||
reason_code: Any,
|
||||
properties: Any = None,
|
||||
) -> None:
|
||||
"""Handle connection callback."""
|
||||
if reason_code == 0:
|
||||
self._connected = True
|
||||
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)
|
||||
logger.debug(f"Resubscribed to topic: {topic}")
|
||||
else:
|
||||
logger.error(f"Failed to connect to MQTT broker: {reason_code}")
|
||||
|
||||
def _on_disconnect(
|
||||
self,
|
||||
client: mqtt.Client,
|
||||
userdata: Any,
|
||||
disconnect_flags: Any,
|
||||
reason_code: Any,
|
||||
properties: Any = None,
|
||||
) -> None:
|
||||
"""Handle disconnection callback."""
|
||||
self._connected = False
|
||||
logger.warning(f"Disconnected from MQTT broker: {reason_code}")
|
||||
|
||||
def _on_message(
|
||||
self,
|
||||
client: mqtt.Client,
|
||||
userdata: Any,
|
||||
message: mqtt.MQTTMessage,
|
||||
) -> None:
|
||||
"""Handle incoming message callback."""
|
||||
topic = message.topic
|
||||
try:
|
||||
payload = json.loads(message.payload.decode("utf-8"))
|
||||
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
||||
logger.error(f"Failed to decode message payload: {e}")
|
||||
return
|
||||
|
||||
logger.debug(f"Received message on topic {topic}: {payload}")
|
||||
|
||||
# Call registered handlers
|
||||
for pattern, handlers in self._message_handlers.items():
|
||||
if self._topic_matches(pattern, topic):
|
||||
for handler in handlers:
|
||||
try:
|
||||
handler(topic, pattern, payload)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in message handler: {e}")
|
||||
|
||||
def _topic_matches(self, pattern: str, topic: str) -> bool:
|
||||
"""Check if a topic matches a subscription pattern.
|
||||
|
||||
Args:
|
||||
pattern: MQTT subscription pattern (may contain + and #)
|
||||
topic: Actual topic string
|
||||
|
||||
Returns:
|
||||
True if topic matches pattern
|
||||
"""
|
||||
pattern_parts = pattern.split("/")
|
||||
topic_parts = topic.split("/")
|
||||
|
||||
for i, (p, t) in enumerate(zip(pattern_parts, topic_parts)):
|
||||
if p == "#":
|
||||
return True
|
||||
if p != "+" and p != t:
|
||||
return False
|
||||
|
||||
return len(pattern_parts) == len(topic_parts) or (
|
||||
len(pattern_parts) > 0 and pattern_parts[-1] == "#"
|
||||
)
|
||||
|
||||
def connect(self) -> None:
|
||||
"""Connect to the MQTT broker."""
|
||||
logger.info(f"Connecting to MQTT broker at {self.config.host}:{self.config.port}")
|
||||
self._client.connect(
|
||||
self.config.host,
|
||||
self.config.port,
|
||||
self.config.keepalive,
|
||||
)
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""Disconnect from the MQTT broker."""
|
||||
self._client.disconnect()
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start the MQTT client loop (blocking)."""
|
||||
self._client.loop_forever()
|
||||
|
||||
def start_background(self) -> None:
|
||||
"""Start the MQTT client loop in background thread."""
|
||||
self._client.loop_start()
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the MQTT client loop."""
|
||||
self._client.loop_stop()
|
||||
|
||||
def subscribe(
|
||||
self,
|
||||
topic: str,
|
||||
handler: MessageHandler,
|
||||
qos: int = 1,
|
||||
) -> None:
|
||||
"""Subscribe to a topic with a handler.
|
||||
|
||||
Args:
|
||||
topic: MQTT topic pattern
|
||||
handler: Message handler function
|
||||
qos: Quality of service level
|
||||
"""
|
||||
if topic not in self._message_handlers:
|
||||
self._message_handlers[topic] = []
|
||||
if self._connected:
|
||||
self._client.subscribe(topic, qos)
|
||||
logger.debug(f"Subscribed to topic: {topic}")
|
||||
|
||||
self._message_handlers[topic].append(handler)
|
||||
|
||||
def unsubscribe(self, topic: str) -> None:
|
||||
"""Unsubscribe from a topic.
|
||||
|
||||
Args:
|
||||
topic: MQTT topic pattern
|
||||
"""
|
||||
if topic in self._message_handlers:
|
||||
del self._message_handlers[topic]
|
||||
self._client.unsubscribe(topic)
|
||||
logger.debug(f"Unsubscribed from topic: {topic}")
|
||||
|
||||
def publish(
|
||||
self,
|
||||
topic: str,
|
||||
payload: dict[str, Any],
|
||||
qos: int = 1,
|
||||
retain: bool = False,
|
||||
) -> None:
|
||||
"""Publish a message to a topic.
|
||||
|
||||
Args:
|
||||
topic: MQTT topic
|
||||
payload: Message payload (will be JSON encoded)
|
||||
qos: Quality of service level
|
||||
retain: Whether to retain the message
|
||||
"""
|
||||
message = json.dumps(payload)
|
||||
self._client.publish(topic, message, qos=qos, retain=retain)
|
||||
logger.debug(f"Published message to topic {topic}: {payload}")
|
||||
|
||||
def publish_event(
|
||||
self,
|
||||
public_key: str,
|
||||
event_name: str,
|
||||
payload: dict[str, Any],
|
||||
) -> None:
|
||||
"""Publish an event message.
|
||||
|
||||
Args:
|
||||
public_key: Node's public key
|
||||
event_name: Event name
|
||||
payload: Event payload
|
||||
"""
|
||||
topic = self.topic_builder.event_topic(public_key, event_name)
|
||||
self.publish(topic, payload)
|
||||
|
||||
def publish_command(
|
||||
self,
|
||||
public_key: str,
|
||||
command_name: str,
|
||||
payload: dict[str, Any],
|
||||
) -> None:
|
||||
"""Publish a command message.
|
||||
|
||||
Args:
|
||||
public_key: Target node's public key (or '+' for all)
|
||||
command_name: Command name
|
||||
payload: Command payload
|
||||
"""
|
||||
topic = self.topic_builder.command_topic(public_key, command_name)
|
||||
self.publish(topic, payload)
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
"""Check if client is connected to broker."""
|
||||
return self._connected
|
||||
|
||||
|
||||
def create_mqtt_client(
|
||||
host: str = "localhost",
|
||||
port: int = 1883,
|
||||
username: Optional[str] = None,
|
||||
password: Optional[str] = None,
|
||||
prefix: str = "meshcore",
|
||||
client_id: Optional[str] = None,
|
||||
) -> MQTTClient:
|
||||
"""Create and configure an MQTT client.
|
||||
|
||||
Args:
|
||||
host: MQTT broker host
|
||||
port: MQTT broker port
|
||||
username: MQTT username (optional)
|
||||
password: MQTT password (optional)
|
||||
prefix: Topic prefix
|
||||
client_id: Client identifier (optional)
|
||||
|
||||
Returns:
|
||||
Configured MQTTClient instance
|
||||
"""
|
||||
config = MQTTConfig(
|
||||
host=host,
|
||||
port=port,
|
||||
username=username,
|
||||
password=password,
|
||||
prefix=prefix,
|
||||
client_id=client_id,
|
||||
)
|
||||
return MQTTClient(config)
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Pydantic schemas for API request/response validation."""
|
||||
|
||||
from meshcore_hub.common.schemas.events import (
|
||||
AdvertisementEvent,
|
||||
ContactMessageEvent,
|
||||
ChannelMessageEvent,
|
||||
TraceDataEvent,
|
||||
TelemetryResponseEvent,
|
||||
ContactsEvent,
|
||||
SendConfirmedEvent,
|
||||
StatusResponseEvent,
|
||||
BatteryEvent,
|
||||
PathUpdatedEvent,
|
||||
)
|
||||
from meshcore_hub.common.schemas.nodes import (
|
||||
NodeRead,
|
||||
NodeList,
|
||||
NodeTagCreate,
|
||||
NodeTagUpdate,
|
||||
NodeTagRead,
|
||||
)
|
||||
from meshcore_hub.common.schemas.messages import (
|
||||
MessageRead,
|
||||
MessageList,
|
||||
MessageFilters,
|
||||
)
|
||||
from meshcore_hub.common.schemas.commands import (
|
||||
SendMessageCommand,
|
||||
SendChannelMessageCommand,
|
||||
SendAdvertCommand,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Events
|
||||
"AdvertisementEvent",
|
||||
"ContactMessageEvent",
|
||||
"ChannelMessageEvent",
|
||||
"TraceDataEvent",
|
||||
"TelemetryResponseEvent",
|
||||
"ContactsEvent",
|
||||
"SendConfirmedEvent",
|
||||
"StatusResponseEvent",
|
||||
"BatteryEvent",
|
||||
"PathUpdatedEvent",
|
||||
# Nodes
|
||||
"NodeRead",
|
||||
"NodeList",
|
||||
"NodeTagCreate",
|
||||
"NodeTagUpdate",
|
||||
"NodeTagRead",
|
||||
# Messages
|
||||
"MessageRead",
|
||||
"MessageList",
|
||||
"MessageFilters",
|
||||
# Commands
|
||||
"SendMessageCommand",
|
||||
"SendChannelMessageCommand",
|
||||
"SendAdvertCommand",
|
||||
]
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Pydantic schemas for command API endpoints."""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class SendMessageCommand(BaseModel):
|
||||
"""Schema for sending a direct message."""
|
||||
|
||||
destination: str = Field(
|
||||
...,
|
||||
min_length=12,
|
||||
max_length=64,
|
||||
description="Destination public key or prefix",
|
||||
)
|
||||
text: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
max_length=1000,
|
||||
description="Message content",
|
||||
)
|
||||
timestamp: Optional[int] = Field(
|
||||
default=None,
|
||||
description="Unix timestamp (optional, defaults to current time)",
|
||||
)
|
||||
|
||||
|
||||
class SendChannelMessageCommand(BaseModel):
|
||||
"""Schema for sending a channel message."""
|
||||
|
||||
channel_idx: int = Field(
|
||||
...,
|
||||
ge=0,
|
||||
le=255,
|
||||
description="Channel index (0-255)",
|
||||
)
|
||||
text: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
max_length=1000,
|
||||
description="Message content",
|
||||
)
|
||||
timestamp: Optional[int] = Field(
|
||||
default=None,
|
||||
description="Unix timestamp (optional, defaults to current time)",
|
||||
)
|
||||
|
||||
|
||||
class SendAdvertCommand(BaseModel):
|
||||
"""Schema for sending an advertisement."""
|
||||
|
||||
flood: bool = Field(
|
||||
default=True,
|
||||
description="Whether to flood the advertisement",
|
||||
)
|
||||
|
||||
|
||||
class RequestStatusCommand(BaseModel):
|
||||
"""Schema for requesting node status."""
|
||||
|
||||
target_public_key: Optional[str] = Field(
|
||||
default=None,
|
||||
min_length=64,
|
||||
max_length=64,
|
||||
description="Target node public key (optional)",
|
||||
)
|
||||
|
||||
|
||||
class RequestTelemetryCommand(BaseModel):
|
||||
"""Schema for requesting telemetry data."""
|
||||
|
||||
target_public_key: str = Field(
|
||||
...,
|
||||
min_length=64,
|
||||
max_length=64,
|
||||
description="Target node public key",
|
||||
)
|
||||
|
||||
|
||||
class CommandResponse(BaseModel):
|
||||
"""Schema for command response."""
|
||||
|
||||
success: bool = Field(..., description="Whether command was accepted")
|
||||
message: str = Field(..., description="Response message")
|
||||
command_id: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Command tracking ID (if applicable)",
|
||||
)
|
||||
@@ -0,0 +1,261 @@
|
||||
"""Pydantic schemas for MeshCore events."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class AdvertisementEvent(BaseModel):
|
||||
"""Schema for ADVERTISEMENT / NEW_ADVERT events."""
|
||||
|
||||
public_key: str = Field(
|
||||
...,
|
||||
min_length=64,
|
||||
max_length=64,
|
||||
description="Node's 64-character hex public key",
|
||||
)
|
||||
name: Optional[str] = Field(
|
||||
default=None,
|
||||
max_length=255,
|
||||
description="Node name/alias",
|
||||
)
|
||||
adv_type: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Node type: chat, repeater, room, none",
|
||||
)
|
||||
flags: Optional[int] = Field(
|
||||
default=None,
|
||||
description="Capability/status flags bitmask",
|
||||
)
|
||||
|
||||
|
||||
class ContactMessageEvent(BaseModel):
|
||||
"""Schema for CONTACT_MSG_RECV events."""
|
||||
|
||||
pubkey_prefix: str = Field(
|
||||
...,
|
||||
min_length=12,
|
||||
max_length=12,
|
||||
description="First 12 characters of sender's public key",
|
||||
)
|
||||
text: str = Field(..., description="Message content")
|
||||
path_len: Optional[int] = Field(
|
||||
default=None,
|
||||
description="Number of hops message traveled",
|
||||
)
|
||||
txt_type: Optional[int] = Field(
|
||||
default=None,
|
||||
description="Message type indicator (0=plain, 2=signed, etc.)",
|
||||
)
|
||||
signature: Optional[str] = Field(
|
||||
default=None,
|
||||
max_length=8,
|
||||
description="Message signature (8 hex chars)",
|
||||
)
|
||||
SNR: Optional[float] = Field(
|
||||
default=None,
|
||||
alias="snr",
|
||||
description="Signal-to-Noise Ratio in dB",
|
||||
)
|
||||
sender_timestamp: Optional[int] = Field(
|
||||
default=None,
|
||||
description="Unix timestamp when message was sent",
|
||||
)
|
||||
|
||||
class Config:
|
||||
populate_by_name = True
|
||||
|
||||
|
||||
class ChannelMessageEvent(BaseModel):
|
||||
"""Schema for CHANNEL_MSG_RECV events."""
|
||||
|
||||
channel_idx: int = Field(
|
||||
...,
|
||||
ge=0,
|
||||
le=255,
|
||||
description="Channel number (0-255)",
|
||||
)
|
||||
text: str = Field(..., description="Message content")
|
||||
path_len: Optional[int] = Field(
|
||||
default=None,
|
||||
description="Number of hops message traveled",
|
||||
)
|
||||
txt_type: Optional[int] = Field(
|
||||
default=None,
|
||||
description="Message type indicator",
|
||||
)
|
||||
signature: Optional[str] = Field(
|
||||
default=None,
|
||||
max_length=8,
|
||||
description="Message signature (8 hex chars)",
|
||||
)
|
||||
SNR: Optional[float] = Field(
|
||||
default=None,
|
||||
alias="snr",
|
||||
description="Signal-to-Noise Ratio in dB",
|
||||
)
|
||||
sender_timestamp: Optional[int] = Field(
|
||||
default=None,
|
||||
description="Unix timestamp when message was sent",
|
||||
)
|
||||
|
||||
class Config:
|
||||
populate_by_name = True
|
||||
|
||||
|
||||
class TraceDataEvent(BaseModel):
|
||||
"""Schema for TRACE_DATA events."""
|
||||
|
||||
initiator_tag: int = Field(
|
||||
...,
|
||||
description="Unique trace identifier",
|
||||
)
|
||||
path_len: Optional[int] = Field(
|
||||
default=None,
|
||||
description="Length of the path",
|
||||
)
|
||||
flags: Optional[int] = Field(
|
||||
default=None,
|
||||
description="Trace flags/options",
|
||||
)
|
||||
auth: Optional[int] = Field(
|
||||
default=None,
|
||||
description="Authentication/validation data",
|
||||
)
|
||||
path_hashes: Optional[list[str]] = Field(
|
||||
default=None,
|
||||
description="Array of 2-character node hash identifiers",
|
||||
)
|
||||
snr_values: Optional[list[float]] = Field(
|
||||
default=None,
|
||||
description="Array of SNR values per hop",
|
||||
)
|
||||
hop_count: Optional[int] = Field(
|
||||
default=None,
|
||||
description="Total number of hops",
|
||||
)
|
||||
|
||||
|
||||
class TelemetryResponseEvent(BaseModel):
|
||||
"""Schema for TELEMETRY_RESPONSE events."""
|
||||
|
||||
node_public_key: str = Field(
|
||||
...,
|
||||
min_length=64,
|
||||
max_length=64,
|
||||
description="Full public key of reporting node",
|
||||
)
|
||||
lpp_data: Optional[bytes] = Field(
|
||||
default=None,
|
||||
description="Raw LPP-encoded sensor data",
|
||||
)
|
||||
parsed_data: Optional[dict[str, Any]] = Field(
|
||||
default=None,
|
||||
description="Decoded sensor readings",
|
||||
)
|
||||
|
||||
|
||||
class ContactInfo(BaseModel):
|
||||
"""Schema for a single contact in CONTACTS event."""
|
||||
|
||||
public_key: str = Field(
|
||||
...,
|
||||
min_length=64,
|
||||
max_length=64,
|
||||
description="Node's full public key",
|
||||
)
|
||||
name: Optional[str] = Field(
|
||||
default=None,
|
||||
max_length=255,
|
||||
description="Node name/alias",
|
||||
)
|
||||
node_type: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Node type: chat, repeater, room, none",
|
||||
)
|
||||
|
||||
|
||||
class ContactsEvent(BaseModel):
|
||||
"""Schema for CONTACTS sync events."""
|
||||
|
||||
contacts: list[ContactInfo] = Field(
|
||||
...,
|
||||
description="Array of contact objects",
|
||||
)
|
||||
|
||||
|
||||
class SendConfirmedEvent(BaseModel):
|
||||
"""Schema for SEND_CONFIRMED events."""
|
||||
|
||||
destination_public_key: str = Field(
|
||||
...,
|
||||
min_length=64,
|
||||
max_length=64,
|
||||
description="Recipient's full public key",
|
||||
)
|
||||
round_trip_ms: int = Field(
|
||||
...,
|
||||
description="Round-trip time in milliseconds",
|
||||
)
|
||||
|
||||
|
||||
class StatusResponseEvent(BaseModel):
|
||||
"""Schema for STATUS_RESPONSE events."""
|
||||
|
||||
node_public_key: str = Field(
|
||||
...,
|
||||
min_length=64,
|
||||
max_length=64,
|
||||
description="Node's full public key",
|
||||
)
|
||||
status: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Status description",
|
||||
)
|
||||
uptime: Optional[int] = Field(
|
||||
default=None,
|
||||
description="Uptime in seconds",
|
||||
)
|
||||
message_count: Optional[int] = Field(
|
||||
default=None,
|
||||
description="Total messages processed",
|
||||
)
|
||||
|
||||
|
||||
class BatteryEvent(BaseModel):
|
||||
"""Schema for BATTERY events."""
|
||||
|
||||
battery_voltage: float = Field(
|
||||
...,
|
||||
description="Battery voltage (e.g., 3.7V)",
|
||||
)
|
||||
battery_percentage: int = Field(
|
||||
...,
|
||||
ge=0,
|
||||
le=100,
|
||||
description="Battery level 0-100%",
|
||||
)
|
||||
|
||||
|
||||
class PathUpdatedEvent(BaseModel):
|
||||
"""Schema for PATH_UPDATED events."""
|
||||
|
||||
node_public_key: str = Field(
|
||||
...,
|
||||
min_length=64,
|
||||
max_length=64,
|
||||
description="Target node's full public key",
|
||||
)
|
||||
hop_count: int = Field(
|
||||
...,
|
||||
description="Number of hops in new path",
|
||||
)
|
||||
|
||||
|
||||
class WebhookPayload(BaseModel):
|
||||
"""Schema for webhook payload envelope."""
|
||||
|
||||
event_type: str = Field(..., description="Event type name")
|
||||
timestamp: datetime = Field(..., description="Event timestamp (ISO 8601)")
|
||||
data: dict[str, Any] = Field(..., description="Event-specific payload")
|
||||
@@ -0,0 +1,189 @@
|
||||
"""Pydantic schemas for message API endpoints."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class MessageRead(BaseModel):
|
||||
"""Schema for reading a message."""
|
||||
|
||||
id: str = Field(..., description="Message UUID")
|
||||
receiver_node_id: Optional[str] = Field(
|
||||
default=None, description="Receiving interface node UUID"
|
||||
)
|
||||
message_type: str = Field(..., description="Message type (contact, channel)")
|
||||
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"
|
||||
)
|
||||
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"
|
||||
)
|
||||
sender_timestamp: Optional[datetime] = Field(
|
||||
default=None, description="Sender's timestamp"
|
||||
)
|
||||
received_at: datetime = Field(..., description="When received by interface")
|
||||
created_at: datetime = Field(..., description="Record creation timestamp")
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class MessageList(BaseModel):
|
||||
"""Schema for paginated message list response."""
|
||||
|
||||
items: list[MessageRead] = Field(..., description="List of messages")
|
||||
total: int = Field(..., description="Total number of messages")
|
||||
limit: int = Field(..., description="Page size limit")
|
||||
offset: int = Field(..., description="Page offset")
|
||||
|
||||
|
||||
class MessageFilters(BaseModel):
|
||||
"""Schema for message query filters."""
|
||||
|
||||
type: Optional[Literal["contact", "channel"]] = Field(
|
||||
default=None,
|
||||
description="Filter by message type",
|
||||
)
|
||||
pubkey_prefix: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Filter by sender public key prefix",
|
||||
)
|
||||
channel_idx: Optional[int] = Field(
|
||||
default=None,
|
||||
description="Filter by channel index",
|
||||
)
|
||||
since: Optional[datetime] = Field(
|
||||
default=None,
|
||||
description="Start timestamp filter",
|
||||
)
|
||||
until: Optional[datetime] = Field(
|
||||
default=None,
|
||||
description="End timestamp filter",
|
||||
)
|
||||
search: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Search in message text",
|
||||
)
|
||||
limit: int = Field(default=50, ge=1, le=100, description="Page size limit")
|
||||
offset: int = Field(default=0, ge=0, description="Page offset")
|
||||
|
||||
|
||||
class AdvertisementRead(BaseModel):
|
||||
"""Schema for reading an advertisement."""
|
||||
|
||||
id: str = Field(..., description="Advertisement UUID")
|
||||
receiver_node_id: Optional[str] = Field(
|
||||
default=None, description="Receiving interface 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")
|
||||
flags: Optional[int] = Field(default=None, description="Capability flags")
|
||||
received_at: datetime = Field(..., description="When received")
|
||||
created_at: datetime = Field(..., description="Record creation timestamp")
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class AdvertisementList(BaseModel):
|
||||
"""Schema for paginated advertisement list response."""
|
||||
|
||||
items: list[AdvertisementRead] = Field(..., description="List of advertisements")
|
||||
total: int = Field(..., description="Total number of advertisements")
|
||||
limit: int = Field(..., description="Page size limit")
|
||||
offset: int = Field(..., description="Page offset")
|
||||
|
||||
|
||||
class TracePathRead(BaseModel):
|
||||
"""Schema for reading a trace path."""
|
||||
|
||||
id: str = Field(..., description="Trace path UUID")
|
||||
receiver_node_id: Optional[str] = Field(
|
||||
default=None, description="Receiving interface node UUID"
|
||||
)
|
||||
initiator_tag: int = Field(..., description="Trace identifier")
|
||||
path_len: Optional[int] = Field(default=None, description="Path length")
|
||||
flags: Optional[int] = Field(default=None, description="Trace flags")
|
||||
auth: Optional[int] = Field(default=None, description="Auth data")
|
||||
path_hashes: Optional[list[str]] = Field(
|
||||
default=None, description="Node hash identifiers"
|
||||
)
|
||||
snr_values: Optional[list[float]] = Field(
|
||||
default=None, description="SNR values per hop"
|
||||
)
|
||||
hop_count: Optional[int] = Field(default=None, description="Total hops")
|
||||
received_at: datetime = Field(..., description="When received")
|
||||
created_at: datetime = Field(..., description="Record creation timestamp")
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TracePathList(BaseModel):
|
||||
"""Schema for paginated trace path list response."""
|
||||
|
||||
items: list[TracePathRead] = Field(..., description="List of trace paths")
|
||||
total: int = Field(..., description="Total number of trace paths")
|
||||
limit: int = Field(..., description="Page size limit")
|
||||
offset: int = Field(..., description="Page offset")
|
||||
|
||||
|
||||
class TelemetryRead(BaseModel):
|
||||
"""Schema for reading a telemetry record."""
|
||||
|
||||
id: str = Field(..., description="Telemetry UUID")
|
||||
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_public_key: str = Field(..., description="Reporting node public key")
|
||||
parsed_data: Optional[dict] = Field(
|
||||
default=None, description="Decoded sensor readings"
|
||||
)
|
||||
received_at: datetime = Field(..., description="When received")
|
||||
created_at: datetime = Field(..., description="Record creation timestamp")
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TelemetryList(BaseModel):
|
||||
"""Schema for paginated telemetry list response."""
|
||||
|
||||
items: list[TelemetryRead] = Field(..., description="List of telemetry records")
|
||||
total: int = Field(..., description="Total number of records")
|
||||
limit: int = Field(..., description="Page size limit")
|
||||
offset: int = Field(..., description="Page offset")
|
||||
|
||||
|
||||
class DashboardStats(BaseModel):
|
||||
"""Schema for dashboard statistics."""
|
||||
|
||||
total_nodes: int = Field(..., description="Total number of nodes")
|
||||
active_nodes: int = Field(..., description="Nodes active in last 24h")
|
||||
total_messages: int = Field(..., description="Total number of messages")
|
||||
messages_today: int = Field(..., description="Messages received today")
|
||||
total_advertisements: int = Field(..., description="Total advertisements")
|
||||
channel_message_counts: dict[int, int] = Field(
|
||||
default_factory=dict,
|
||||
description="Message count per channel",
|
||||
)
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Pydantic schemas for node API endpoints."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class NodeTagCreate(BaseModel):
|
||||
"""Schema for creating a node tag."""
|
||||
|
||||
key: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
max_length=100,
|
||||
description="Tag name/key",
|
||||
)
|
||||
value: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Tag value",
|
||||
)
|
||||
value_type: Literal["string", "number", "boolean", "coordinate"] = Field(
|
||||
default="string",
|
||||
description="Value type hint",
|
||||
)
|
||||
|
||||
|
||||
class NodeTagUpdate(BaseModel):
|
||||
"""Schema for updating a node tag."""
|
||||
|
||||
value: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Tag value",
|
||||
)
|
||||
value_type: Optional[Literal["string", "number", "boolean", "coordinate"]] = Field(
|
||||
default=None,
|
||||
description="Value type hint",
|
||||
)
|
||||
|
||||
|
||||
class NodeTagRead(BaseModel):
|
||||
"""Schema for reading a node tag."""
|
||||
|
||||
id: str = Field(..., description="Tag UUID")
|
||||
node_id: str = Field(..., description="Parent node UUID")
|
||||
key: str = Field(..., description="Tag name/key")
|
||||
value: Optional[str] = Field(default=None, description="Tag value")
|
||||
value_type: str = Field(..., description="Value type hint")
|
||||
created_at: datetime = Field(..., description="Creation timestamp")
|
||||
updated_at: datetime = Field(..., description="Last update timestamp")
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class NodeRead(BaseModel):
|
||||
"""Schema for reading a node."""
|
||||
|
||||
id: str = Field(..., description="Node UUID")
|
||||
public_key: str = Field(..., description="Node's 64-character hex public key")
|
||||
name: Optional[str] = Field(default=None, description="Node display name")
|
||||
adv_type: Optional[str] = Field(default=None, description="Advertisement type")
|
||||
flags: Optional[int] = Field(default=None, description="Capability flags")
|
||||
first_seen: datetime = Field(..., description="First advertisement timestamp")
|
||||
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"
|
||||
)
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class NodeList(BaseModel):
|
||||
"""Schema for paginated node list response."""
|
||||
|
||||
items: list[NodeRead] = Field(..., description="List of nodes")
|
||||
total: int = Field(..., description="Total number of nodes")
|
||||
limit: int = Field(..., description="Page size limit")
|
||||
offset: int = Field(..., description="Page offset")
|
||||
|
||||
|
||||
class NodeFilters(BaseModel):
|
||||
"""Schema for node query filters."""
|
||||
|
||||
search: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Search in name or public key",
|
||||
)
|
||||
adv_type: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Filter by advertisement type",
|
||||
)
|
||||
has_tag: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Filter by tag key",
|
||||
)
|
||||
limit: int = Field(default=50, ge=1, le=100, description="Page size limit")
|
||||
offset: int = Field(default=0, ge=0, description="Page offset")
|
||||
@@ -0,0 +1 @@
|
||||
"""Interface component for MeshCore device communication."""
|
||||
@@ -0,0 +1,367 @@
|
||||
"""CLI for the Interface component."""
|
||||
|
||||
import click
|
||||
|
||||
from meshcore_hub.common.config import InterfaceMode
|
||||
from meshcore_hub.common.logging import configure_logging
|
||||
|
||||
|
||||
@click.group()
|
||||
def interface() -> None:
|
||||
"""Interface component for MeshCore device communication.
|
||||
|
||||
Runs in RECEIVER or SENDER mode to bridge between
|
||||
MeshCore devices and MQTT broker.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
@interface.command("run")
|
||||
@click.option(
|
||||
"--mode",
|
||||
type=click.Choice(["RECEIVER", "SENDER"], case_sensitive=False),
|
||||
required=True,
|
||||
envvar="INTERFACE_MODE",
|
||||
help="Interface mode: RECEIVER or SENDER",
|
||||
)
|
||||
@click.option(
|
||||
"--port",
|
||||
type=str,
|
||||
default="/dev/ttyUSB0",
|
||||
envvar="SERIAL_PORT",
|
||||
help="Serial port path",
|
||||
)
|
||||
@click.option(
|
||||
"--baud",
|
||||
type=int,
|
||||
default=115200,
|
||||
envvar="SERIAL_BAUD",
|
||||
help="Serial baud rate",
|
||||
)
|
||||
@click.option(
|
||||
"--mock",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
envvar="MOCK_DEVICE",
|
||||
help="Use mock device for testing",
|
||||
)
|
||||
@click.option(
|
||||
"--node-address",
|
||||
type=str,
|
||||
default=None,
|
||||
envvar="NODE_ADDRESS",
|
||||
help="Override for device public key/address (hex string)",
|
||||
)
|
||||
@click.option(
|
||||
"--mqtt-host",
|
||||
type=str,
|
||||
default="localhost",
|
||||
envvar="MQTT_HOST",
|
||||
help="MQTT broker host",
|
||||
)
|
||||
@click.option(
|
||||
"--mqtt-port",
|
||||
type=int,
|
||||
default=1883,
|
||||
envvar="MQTT_PORT",
|
||||
help="MQTT broker port",
|
||||
)
|
||||
@click.option(
|
||||
"--mqtt-username",
|
||||
type=str,
|
||||
default=None,
|
||||
envvar="MQTT_USERNAME",
|
||||
help="MQTT username",
|
||||
)
|
||||
@click.option(
|
||||
"--mqtt-password",
|
||||
type=str,
|
||||
default=None,
|
||||
envvar="MQTT_PASSWORD",
|
||||
help="MQTT password",
|
||||
)
|
||||
@click.option(
|
||||
"--prefix",
|
||||
type=str,
|
||||
default="meshcore",
|
||||
envvar="MQTT_PREFIX",
|
||||
help="MQTT topic prefix",
|
||||
)
|
||||
@click.option(
|
||||
"--log-level",
|
||||
type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]),
|
||||
default="INFO",
|
||||
envvar="LOG_LEVEL",
|
||||
help="Log level",
|
||||
)
|
||||
def run(
|
||||
mode: str,
|
||||
port: str,
|
||||
baud: int,
|
||||
mock: bool,
|
||||
node_address: str | None,
|
||||
mqtt_host: str,
|
||||
mqtt_port: int,
|
||||
mqtt_username: str | None,
|
||||
mqtt_password: str | None,
|
||||
prefix: str,
|
||||
log_level: str,
|
||||
) -> None:
|
||||
"""Run the interface component.
|
||||
|
||||
The interface bridges MeshCore devices to an MQTT broker.
|
||||
|
||||
In RECEIVER mode:
|
||||
- Connects to a MeshCore device
|
||||
- Subscribes to device events
|
||||
- Publishes events to MQTT
|
||||
|
||||
In SENDER mode:
|
||||
- Connects to a MeshCore device
|
||||
- Subscribes to MQTT command topics
|
||||
- Executes commands on the device
|
||||
"""
|
||||
configure_logging(level=log_level)
|
||||
|
||||
click.echo(f"Starting interface in {mode} mode")
|
||||
click.echo(f"Serial: {port} @ {baud} baud")
|
||||
click.echo(f"MQTT: {mqtt_host}:{mqtt_port} (prefix: {prefix})")
|
||||
click.echo(f"Mock device: {mock}")
|
||||
if node_address:
|
||||
click.echo(f"Node address: {node_address}")
|
||||
|
||||
mode_upper = mode.upper()
|
||||
|
||||
if mode_upper == "RECEIVER":
|
||||
from meshcore_hub.interface.receiver import run_receiver
|
||||
|
||||
run_receiver(
|
||||
port=port,
|
||||
baud=baud,
|
||||
mock=mock,
|
||||
node_address=node_address,
|
||||
mqtt_host=mqtt_host,
|
||||
mqtt_port=mqtt_port,
|
||||
mqtt_username=mqtt_username,
|
||||
mqtt_password=mqtt_password,
|
||||
mqtt_prefix=prefix,
|
||||
)
|
||||
elif mode_upper == "SENDER":
|
||||
from meshcore_hub.interface.sender import run_sender
|
||||
|
||||
run_sender(
|
||||
port=port,
|
||||
baud=baud,
|
||||
mock=mock,
|
||||
node_address=node_address,
|
||||
mqtt_host=mqtt_host,
|
||||
mqtt_port=mqtt_port,
|
||||
mqtt_username=mqtt_username,
|
||||
mqtt_password=mqtt_password,
|
||||
mqtt_prefix=prefix,
|
||||
)
|
||||
else:
|
||||
click.echo(f"Unknown mode: {mode}", err=True)
|
||||
raise click.Abort()
|
||||
|
||||
|
||||
@interface.command("receiver")
|
||||
@click.option(
|
||||
"--port",
|
||||
type=str,
|
||||
default="/dev/ttyUSB0",
|
||||
envvar="SERIAL_PORT",
|
||||
help="Serial port path",
|
||||
)
|
||||
@click.option(
|
||||
"--baud",
|
||||
type=int,
|
||||
default=115200,
|
||||
envvar="SERIAL_BAUD",
|
||||
help="Serial baud rate",
|
||||
)
|
||||
@click.option(
|
||||
"--mock",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
envvar="MOCK_DEVICE",
|
||||
help="Use mock device for testing",
|
||||
)
|
||||
@click.option(
|
||||
"--node-address",
|
||||
type=str,
|
||||
default=None,
|
||||
envvar="NODE_ADDRESS",
|
||||
help="Override for device public key/address (hex string)",
|
||||
)
|
||||
@click.option(
|
||||
"--mqtt-host",
|
||||
type=str,
|
||||
default="localhost",
|
||||
envvar="MQTT_HOST",
|
||||
help="MQTT broker host",
|
||||
)
|
||||
@click.option(
|
||||
"--mqtt-port",
|
||||
type=int,
|
||||
default=1883,
|
||||
envvar="MQTT_PORT",
|
||||
help="MQTT broker port",
|
||||
)
|
||||
@click.option(
|
||||
"--mqtt-username",
|
||||
type=str,
|
||||
default=None,
|
||||
envvar="MQTT_USERNAME",
|
||||
help="MQTT username",
|
||||
)
|
||||
@click.option(
|
||||
"--mqtt-password",
|
||||
type=str,
|
||||
default=None,
|
||||
envvar="MQTT_PASSWORD",
|
||||
help="MQTT password",
|
||||
)
|
||||
@click.option(
|
||||
"--prefix",
|
||||
type=str,
|
||||
default="meshcore",
|
||||
envvar="MQTT_PREFIX",
|
||||
help="MQTT topic prefix",
|
||||
)
|
||||
def receiver(
|
||||
port: str,
|
||||
baud: int,
|
||||
mock: bool,
|
||||
node_address: str | None,
|
||||
mqtt_host: str,
|
||||
mqtt_port: int,
|
||||
mqtt_username: str | None,
|
||||
mqtt_password: str | None,
|
||||
prefix: str,
|
||||
) -> None:
|
||||
"""Run interface in RECEIVER mode.
|
||||
|
||||
Shortcut for: meshcore-hub interface run --mode RECEIVER
|
||||
"""
|
||||
from meshcore_hub.interface.receiver import run_receiver
|
||||
|
||||
click.echo("Starting interface in RECEIVER mode")
|
||||
click.echo(f"Serial: {port} @ {baud} baud")
|
||||
click.echo(f"MQTT: {mqtt_host}:{mqtt_port} (prefix: {prefix})")
|
||||
click.echo(f"Mock device: {mock}")
|
||||
if node_address:
|
||||
click.echo(f"Node address: {node_address}")
|
||||
|
||||
run_receiver(
|
||||
port=port,
|
||||
baud=baud,
|
||||
mock=mock,
|
||||
node_address=node_address,
|
||||
mqtt_host=mqtt_host,
|
||||
mqtt_port=mqtt_port,
|
||||
mqtt_username=mqtt_username,
|
||||
mqtt_password=mqtt_password,
|
||||
mqtt_prefix=prefix,
|
||||
)
|
||||
|
||||
|
||||
@interface.command("sender")
|
||||
@click.option(
|
||||
"--port",
|
||||
type=str,
|
||||
default="/dev/ttyUSB0",
|
||||
envvar="SERIAL_PORT",
|
||||
help="Serial port path",
|
||||
)
|
||||
@click.option(
|
||||
"--baud",
|
||||
type=int,
|
||||
default=115200,
|
||||
envvar="SERIAL_BAUD",
|
||||
help="Serial baud rate",
|
||||
)
|
||||
@click.option(
|
||||
"--mock",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
envvar="MOCK_DEVICE",
|
||||
help="Use mock device for testing",
|
||||
)
|
||||
@click.option(
|
||||
"--node-address",
|
||||
type=str,
|
||||
default=None,
|
||||
envvar="NODE_ADDRESS",
|
||||
help="Override for device public key/address (hex string)",
|
||||
)
|
||||
@click.option(
|
||||
"--mqtt-host",
|
||||
type=str,
|
||||
default="localhost",
|
||||
envvar="MQTT_HOST",
|
||||
help="MQTT broker host",
|
||||
)
|
||||
@click.option(
|
||||
"--mqtt-port",
|
||||
type=int,
|
||||
default=1883,
|
||||
envvar="MQTT_PORT",
|
||||
help="MQTT broker port",
|
||||
)
|
||||
@click.option(
|
||||
"--mqtt-username",
|
||||
type=str,
|
||||
default=None,
|
||||
envvar="MQTT_USERNAME",
|
||||
help="MQTT username",
|
||||
)
|
||||
@click.option(
|
||||
"--mqtt-password",
|
||||
type=str,
|
||||
default=None,
|
||||
envvar="MQTT_PASSWORD",
|
||||
help="MQTT password",
|
||||
)
|
||||
@click.option(
|
||||
"--prefix",
|
||||
type=str,
|
||||
default="meshcore",
|
||||
envvar="MQTT_PREFIX",
|
||||
help="MQTT topic prefix",
|
||||
)
|
||||
def sender(
|
||||
port: str,
|
||||
baud: int,
|
||||
mock: bool,
|
||||
node_address: str | None,
|
||||
mqtt_host: str,
|
||||
mqtt_port: int,
|
||||
mqtt_username: str | None,
|
||||
mqtt_password: str | None,
|
||||
prefix: str,
|
||||
) -> None:
|
||||
"""Run interface in SENDER mode.
|
||||
|
||||
Shortcut for: meshcore-hub interface run --mode SENDER
|
||||
"""
|
||||
from meshcore_hub.interface.sender import run_sender
|
||||
|
||||
click.echo("Starting interface in SENDER mode")
|
||||
click.echo(f"Serial: {port} @ {baud} baud")
|
||||
click.echo(f"MQTT: {mqtt_host}:{mqtt_port} (prefix: {prefix})")
|
||||
click.echo(f"Mock device: {mock}")
|
||||
if node_address:
|
||||
click.echo(f"Node address: {node_address}")
|
||||
|
||||
run_sender(
|
||||
port=port,
|
||||
baud=baud,
|
||||
mock=mock,
|
||||
node_address=node_address,
|
||||
mqtt_host=mqtt_host,
|
||||
mqtt_port=mqtt_port,
|
||||
mqtt_username=mqtt_username,
|
||||
mqtt_password=mqtt_password,
|
||||
mqtt_prefix=prefix,
|
||||
)
|
||||
@@ -0,0 +1,565 @@
|
||||
"""MeshCore device wrapper for serial communication."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EventType(str, Enum):
|
||||
"""MeshCore event types."""
|
||||
|
||||
ADVERTISEMENT = "advertisement"
|
||||
CONTACT_MSG_RECV = "contact_msg_recv"
|
||||
CHANNEL_MSG_RECV = "channel_msg_recv"
|
||||
TRACE_DATA = "trace_data"
|
||||
TELEMETRY_RESPONSE = "telemetry_response"
|
||||
CONTACTS = "contacts"
|
||||
SEND_CONFIRMED = "send_confirmed"
|
||||
STATUS_RESPONSE = "status_response"
|
||||
BATTERY = "battery"
|
||||
PATH_UPDATED = "path_updated"
|
||||
|
||||
|
||||
EventHandler = Callable[[EventType, dict[str, Any]], None]
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeviceConfig:
|
||||
"""Device connection configuration."""
|
||||
|
||||
port: str = "/dev/ttyUSB0"
|
||||
baud: int = 115200
|
||||
timeout: float = 1.0
|
||||
reconnect_delay: float = 5.0
|
||||
max_reconnect_attempts: int = 10
|
||||
node_address: Optional[str] = None # Override for device public key/address
|
||||
|
||||
|
||||
class BaseMeshCoreDevice(ABC):
|
||||
"""Abstract base class for MeshCore device interface."""
|
||||
|
||||
def __init__(self, config: DeviceConfig):
|
||||
"""Initialize device.
|
||||
|
||||
Args:
|
||||
config: Device configuration
|
||||
"""
|
||||
self.config = config
|
||||
self._connected = False
|
||||
self._public_key: Optional[str] = None
|
||||
self._event_handlers: dict[EventType, list[EventHandler]] = {}
|
||||
|
||||
@property
|
||||
def public_key(self) -> Optional[str]:
|
||||
"""Get the device's public key."""
|
||||
return self._public_key
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
"""Check if device is connected."""
|
||||
return self._connected
|
||||
|
||||
@abstractmethod
|
||||
def connect(self) -> bool:
|
||||
"""Connect to the device.
|
||||
|
||||
Returns:
|
||||
True if connection successful
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def disconnect(self) -> None:
|
||||
"""Disconnect from the device."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def send_message(
|
||||
self,
|
||||
destination: str,
|
||||
text: str,
|
||||
timestamp: Optional[int] = None,
|
||||
) -> bool:
|
||||
"""Send a direct message.
|
||||
|
||||
Args:
|
||||
destination: Destination public key or prefix
|
||||
text: Message content
|
||||
timestamp: Optional timestamp (defaults to current time)
|
||||
|
||||
Returns:
|
||||
True if message was queued successfully
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def send_channel_message(
|
||||
self,
|
||||
channel_idx: int,
|
||||
text: str,
|
||||
timestamp: Optional[int] = None,
|
||||
) -> bool:
|
||||
"""Send a channel message.
|
||||
|
||||
Args:
|
||||
channel_idx: Channel index (0-255)
|
||||
text: Message content
|
||||
timestamp: Optional timestamp (defaults to current time)
|
||||
|
||||
Returns:
|
||||
True if message was queued successfully
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def send_advertisement(self, flood: bool = True) -> bool:
|
||||
"""Send a node advertisement.
|
||||
|
||||
Args:
|
||||
flood: Whether to flood the advertisement
|
||||
|
||||
Returns:
|
||||
True if advertisement was queued successfully
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def request_status(self, target: Optional[str] = None) -> bool:
|
||||
"""Request status from a node.
|
||||
|
||||
Args:
|
||||
target: Target node public key (optional)
|
||||
|
||||
Returns:
|
||||
True if request was sent
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def request_telemetry(self, target: str) -> bool:
|
||||
"""Request telemetry from a node.
|
||||
|
||||
Args:
|
||||
target: Target node public key
|
||||
|
||||
Returns:
|
||||
True if request was sent
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def set_time(self, timestamp: int) -> bool:
|
||||
"""Set the device's hardware clock.
|
||||
|
||||
Args:
|
||||
timestamp: Unix timestamp to set
|
||||
|
||||
Returns:
|
||||
True if time was set successfully
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def start_message_fetching(self) -> bool:
|
||||
"""Start automatic message fetching.
|
||||
|
||||
Subscribes to MESSAGES_WAITING events and fetches pending messages.
|
||||
|
||||
Returns:
|
||||
True if started successfully
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def run(self) -> None:
|
||||
"""Run the device event loop (blocking)."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def stop(self) -> None:
|
||||
"""Stop the device event loop."""
|
||||
pass
|
||||
|
||||
def register_handler(
|
||||
self,
|
||||
event_type: EventType,
|
||||
handler: EventHandler,
|
||||
) -> None:
|
||||
"""Register an event handler.
|
||||
|
||||
Args:
|
||||
event_type: Event type to handle
|
||||
handler: Handler function
|
||||
"""
|
||||
if event_type not in self._event_handlers:
|
||||
self._event_handlers[event_type] = []
|
||||
self._event_handlers[event_type].append(handler)
|
||||
logger.debug(f"Registered handler for {event_type.value}")
|
||||
|
||||
def unregister_handler(
|
||||
self,
|
||||
event_type: EventType,
|
||||
handler: EventHandler,
|
||||
) -> None:
|
||||
"""Unregister an event handler.
|
||||
|
||||
Args:
|
||||
event_type: Event type
|
||||
handler: Handler function to remove
|
||||
"""
|
||||
if event_type in self._event_handlers:
|
||||
try:
|
||||
self._event_handlers[event_type].remove(handler)
|
||||
logger.debug(f"Unregistered handler for {event_type.value}")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
def _dispatch_event(self, event_type: EventType, payload: dict[str, Any]) -> None:
|
||||
"""Dispatch an event to registered handlers.
|
||||
|
||||
Args:
|
||||
event_type: Event type
|
||||
payload: Event payload
|
||||
"""
|
||||
handlers = self._event_handlers.get(event_type, [])
|
||||
for handler in handlers:
|
||||
try:
|
||||
handler(event_type, payload)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in event handler for {event_type.value}: {e}")
|
||||
|
||||
|
||||
# Map meshcore library EventType to our EventType
|
||||
MESHCORE_EVENT_MAP = {
|
||||
"advertisement": EventType.ADVERTISEMENT,
|
||||
"contact_message": EventType.CONTACT_MSG_RECV,
|
||||
"channel_message": EventType.CHANNEL_MSG_RECV,
|
||||
"trace_data": EventType.TRACE_DATA,
|
||||
"telemetry_response": EventType.TELEMETRY_RESPONSE,
|
||||
"contacts": EventType.CONTACTS,
|
||||
"message_sent": EventType.SEND_CONFIRMED,
|
||||
"status_response": EventType.STATUS_RESPONSE,
|
||||
"battery_info": EventType.BATTERY,
|
||||
"path_update": EventType.PATH_UPDATED,
|
||||
}
|
||||
|
||||
|
||||
class MeshCoreDevice(BaseMeshCoreDevice):
|
||||
"""Real MeshCore device implementation using meshcore library."""
|
||||
|
||||
def __init__(self, config: DeviceConfig):
|
||||
"""Initialize real device.
|
||||
|
||||
Args:
|
||||
config: Device configuration
|
||||
"""
|
||||
super().__init__(config)
|
||||
self._running = False
|
||||
self._mc = None
|
||||
self._loop = None
|
||||
self._subscriptions = []
|
||||
|
||||
def connect(self) -> bool:
|
||||
"""Connect to the MeshCore device."""
|
||||
try:
|
||||
from meshcore import MeshCore
|
||||
from meshcore.serial_cx import SerialConnection
|
||||
except ImportError:
|
||||
logger.error(
|
||||
"meshcore library not installed. "
|
||||
"Install with: pip install meshcore"
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
logger.info(f"Connecting to MeshCore device on {self.config.port}")
|
||||
|
||||
# Create event loop if needed
|
||||
try:
|
||||
self._loop = asyncio.get_event_loop()
|
||||
except RuntimeError:
|
||||
self._loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(self._loop)
|
||||
|
||||
# Create serial connection and MeshCore instance
|
||||
cx = SerialConnection(
|
||||
self.config.port,
|
||||
baudrate=self.config.baud,
|
||||
)
|
||||
self._mc = MeshCore(cx, auto_reconnect=True)
|
||||
|
||||
# Connect asynchronously
|
||||
self._loop.run_until_complete(self._mc.connect())
|
||||
|
||||
# Get device public key from self_info property
|
||||
# After connect(), the library internally processes SELF_INFO
|
||||
# and stores it in the self_info property
|
||||
if self.config.node_address:
|
||||
# Use configured override
|
||||
self._public_key = self.config.node_address
|
||||
logger.info(f"Using configured node address: {self._public_key}")
|
||||
else:
|
||||
# Get from device self_info
|
||||
self_info = self._mc.self_info
|
||||
if self_info:
|
||||
self._public_key = self_info.get("public_key")
|
||||
if self._public_key:
|
||||
logger.info(f"Retrieved device public key from self_info")
|
||||
else:
|
||||
logger.warning(
|
||||
"Device self_info missing public_key field. "
|
||||
"Use --node-address to configure manually."
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Could not retrieve device self_info. "
|
||||
"Use --node-address to configure manually."
|
||||
)
|
||||
|
||||
self._connected = True
|
||||
logger.info(f"Connected to MeshCore device, public_key: {self._public_key}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to connect to device: {e}")
|
||||
return False
|
||||
|
||||
def _setup_event_subscriptions(self) -> None:
|
||||
"""Set up event subscriptions for the meshcore library."""
|
||||
if not self._mc:
|
||||
return
|
||||
|
||||
from meshcore import EventType as MCEventType
|
||||
|
||||
# Map of meshcore event types to subscribe to
|
||||
event_map = {
|
||||
MCEventType.ADVERTISEMENT: EventType.ADVERTISEMENT,
|
||||
MCEventType.CONTACT_MSG_RECV: EventType.CONTACT_MSG_RECV,
|
||||
MCEventType.CHANNEL_MSG_RECV: EventType.CHANNEL_MSG_RECV,
|
||||
MCEventType.TRACE_DATA: EventType.TRACE_DATA,
|
||||
MCEventType.TELEMETRY_RESPONSE: EventType.TELEMETRY_RESPONSE,
|
||||
MCEventType.CONTACTS: EventType.CONTACTS,
|
||||
MCEventType.MSG_SENT: EventType.SEND_CONFIRMED,
|
||||
MCEventType.STATUS_RESPONSE: EventType.STATUS_RESPONSE,
|
||||
MCEventType.BATTERY: EventType.BATTERY,
|
||||
MCEventType.PATH_UPDATE: EventType.PATH_UPDATED,
|
||||
}
|
||||
|
||||
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 {}
|
||||
self._dispatch_event(et, payload)
|
||||
|
||||
sub = self._mc.subscribe(mc_event_type, callback)
|
||||
self._subscriptions.append(sub)
|
||||
logger.debug(f"Subscribed to {mc_event_type.name}")
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""Disconnect from the device."""
|
||||
if self._mc:
|
||||
try:
|
||||
# Unsubscribe from events
|
||||
for sub in self._subscriptions:
|
||||
self._mc.unsubscribe(sub)
|
||||
self._subscriptions.clear()
|
||||
|
||||
# Disconnect
|
||||
if self._loop:
|
||||
self._loop.run_until_complete(self._mc.disconnect())
|
||||
except Exception as e:
|
||||
logger.error(f"Error disconnecting: {e}")
|
||||
|
||||
self._connected = False
|
||||
self._mc = None
|
||||
logger.info("Disconnected from MeshCore device")
|
||||
|
||||
def send_message(
|
||||
self,
|
||||
destination: str,
|
||||
text: str,
|
||||
timestamp: Optional[int] = None,
|
||||
) -> bool:
|
||||
"""Send a direct message."""
|
||||
if not self._connected or not self._mc:
|
||||
logger.error("Cannot send message: not connected")
|
||||
return False
|
||||
|
||||
try:
|
||||
async def _send():
|
||||
await self._mc.commands.send_msg(destination, text)
|
||||
|
||||
self._loop.run_until_complete(_send())
|
||||
logger.info(f"Sent message to {destination[:12]}...")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send message: {e}")
|
||||
return False
|
||||
|
||||
def send_channel_message(
|
||||
self,
|
||||
channel_idx: int,
|
||||
text: str,
|
||||
timestamp: Optional[int] = None,
|
||||
) -> bool:
|
||||
"""Send a channel message."""
|
||||
if not self._connected or not self._mc:
|
||||
logger.error("Cannot send channel message: not connected")
|
||||
return False
|
||||
|
||||
try:
|
||||
async def _send():
|
||||
await self._mc.commands.send_chan_msg(channel_idx, text)
|
||||
|
||||
self._loop.run_until_complete(_send())
|
||||
logger.info(f"Sent message to channel {channel_idx}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send channel message: {e}")
|
||||
return False
|
||||
|
||||
def send_advertisement(self, flood: bool = True) -> bool:
|
||||
"""Send a node advertisement."""
|
||||
if not self._connected or not self._mc:
|
||||
logger.error("Cannot send advertisement: not connected")
|
||||
return False
|
||||
|
||||
try:
|
||||
async def _send():
|
||||
await self._mc.commands.send_advert(flood=flood)
|
||||
|
||||
self._loop.run_until_complete(_send())
|
||||
logger.info(f"Sent advertisement (flood={flood})")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send advertisement: {e}")
|
||||
return False
|
||||
|
||||
def request_status(self, target: Optional[str] = None) -> bool:
|
||||
"""Request status from a node."""
|
||||
if not self._connected or not self._mc:
|
||||
logger.error("Cannot request status: not connected")
|
||||
return False
|
||||
|
||||
try:
|
||||
async def _request():
|
||||
await self._mc.commands.send_statusreq(target)
|
||||
|
||||
self._loop.run_until_complete(_request())
|
||||
logger.info(f"Requested status from {target or 'self'}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to request status: {e}")
|
||||
return False
|
||||
|
||||
def request_telemetry(self, target: str) -> bool:
|
||||
"""Request telemetry from a node."""
|
||||
if not self._connected or not self._mc:
|
||||
logger.error("Cannot request telemetry: not connected")
|
||||
return False
|
||||
|
||||
try:
|
||||
async def _request():
|
||||
await self._mc.commands.send_telemetry_req(target)
|
||||
|
||||
self._loop.run_until_complete(_request())
|
||||
logger.info(f"Requested telemetry from {target[:12]}...")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to request telemetry: {e}")
|
||||
return False
|
||||
|
||||
def set_time(self, timestamp: int) -> bool:
|
||||
"""Set the device's hardware clock."""
|
||||
if not self._connected or not self._mc:
|
||||
logger.error("Cannot set time: not connected")
|
||||
return False
|
||||
|
||||
try:
|
||||
async def _set_time():
|
||||
await self._mc.commands.set_time(timestamp)
|
||||
|
||||
self._loop.run_until_complete(_set_time())
|
||||
logger.info(f"Set device time to {timestamp}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to set device time: {e}")
|
||||
return False
|
||||
|
||||
def start_message_fetching(self) -> bool:
|
||||
"""Start automatic message fetching."""
|
||||
if not self._connected or not self._mc:
|
||||
logger.error("Cannot start message fetching: not connected")
|
||||
return False
|
||||
|
||||
try:
|
||||
async def _start_fetching():
|
||||
await self._mc.start_auto_message_fetching()
|
||||
|
||||
self._loop.run_until_complete(_start_fetching())
|
||||
logger.info("Started automatic message fetching")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start message fetching: {e}")
|
||||
return False
|
||||
|
||||
def run(self) -> None:
|
||||
"""Run the device event loop."""
|
||||
self._running = True
|
||||
logger.info("Starting device event loop")
|
||||
|
||||
# Set up event subscriptions
|
||||
self._setup_event_subscriptions()
|
||||
|
||||
# Run the async event loop
|
||||
async def _run_loop():
|
||||
while self._running and self._connected:
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
try:
|
||||
self._loop.run_until_complete(_run_loop())
|
||||
except Exception as e:
|
||||
logger.error(f"Error in event loop: {e}")
|
||||
|
||||
logger.info("Device event loop stopped")
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the device event loop."""
|
||||
self._running = False
|
||||
if self._mc:
|
||||
self._mc.stop()
|
||||
logger.info("Stopping device event loop")
|
||||
|
||||
|
||||
def create_device(
|
||||
port: str = "/dev/ttyUSB0",
|
||||
baud: int = 115200,
|
||||
mock: bool = False,
|
||||
node_address: Optional[str] = None,
|
||||
) -> BaseMeshCoreDevice:
|
||||
"""Create a MeshCore device instance.
|
||||
|
||||
Args:
|
||||
port: Serial port path
|
||||
baud: Baud rate
|
||||
mock: Use mock device for testing
|
||||
node_address: Optional override for device public key/address
|
||||
|
||||
Returns:
|
||||
Device instance
|
||||
"""
|
||||
config = DeviceConfig(port=port, baud=baud, node_address=node_address)
|
||||
|
||||
if mock:
|
||||
from meshcore_hub.interface.mock_device import MockMeshCoreDevice
|
||||
return MockMeshCoreDevice(config)
|
||||
|
||||
return MeshCoreDevice(config)
|
||||
@@ -0,0 +1,424 @@
|
||||
"""Mock MeshCore device for testing without hardware."""
|
||||
|
||||
import logging
|
||||
import random
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
from meshcore_hub.interface.device import (
|
||||
BaseMeshCoreDevice,
|
||||
DeviceConfig,
|
||||
EventType,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MockNodeConfig:
|
||||
"""Configuration for a simulated node."""
|
||||
|
||||
public_key: str
|
||||
name: str
|
||||
adv_type: str = "chat"
|
||||
flags: int = 218
|
||||
|
||||
|
||||
@dataclass
|
||||
class MockDeviceConfig:
|
||||
"""Configuration for mock device behavior."""
|
||||
|
||||
# Device identity
|
||||
public_key: Optional[str] = None
|
||||
name: str = "MockNode"
|
||||
|
||||
# Simulated network nodes
|
||||
nodes: list[MockNodeConfig] = field(default_factory=list)
|
||||
|
||||
# Event generation intervals (seconds)
|
||||
advertisement_interval: float = 30.0
|
||||
message_interval: float = 10.0
|
||||
telemetry_interval: float = 60.0
|
||||
|
||||
# Simulation parameters
|
||||
enable_auto_events: bool = True
|
||||
message_delay_min: float = 0.1
|
||||
message_delay_max: float = 1.0
|
||||
error_rate: float = 0.0 # Probability of simulated errors
|
||||
|
||||
|
||||
def generate_random_public_key() -> str:
|
||||
"""Generate a random 64-character hex public key."""
|
||||
return secrets.token_hex(32)
|
||||
|
||||
|
||||
class MockMeshCoreDevice(BaseMeshCoreDevice):
|
||||
"""Mock MeshCore device for testing.
|
||||
|
||||
Simulates a MeshCore device for unit and integration testing
|
||||
without requiring physical hardware.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: DeviceConfig,
|
||||
mock_config: Optional[MockDeviceConfig] = None,
|
||||
):
|
||||
"""Initialize mock device.
|
||||
|
||||
Args:
|
||||
config: Device configuration (port/baud are ignored)
|
||||
mock_config: Mock-specific configuration
|
||||
"""
|
||||
super().__init__(config)
|
||||
self.mock_config = mock_config or MockDeviceConfig()
|
||||
|
||||
# Generate public key if not provided
|
||||
if self.mock_config.public_key:
|
||||
self._public_key = self.mock_config.public_key
|
||||
else:
|
||||
self._public_key = generate_random_public_key()
|
||||
|
||||
# Initialize default simulated nodes if none provided
|
||||
if not self.mock_config.nodes:
|
||||
self.mock_config.nodes = self._create_default_nodes()
|
||||
|
||||
self._running = False
|
||||
self._event_thread: Optional[threading.Thread] = None
|
||||
self._lock = threading.Lock()
|
||||
|
||||
logger.info(f"Initialized mock device with public key: {self._public_key}")
|
||||
|
||||
def _create_default_nodes(self) -> list[MockNodeConfig]:
|
||||
"""Create default simulated network nodes."""
|
||||
return [
|
||||
MockNodeConfig(
|
||||
public_key=generate_random_public_key(),
|
||||
name="Alice",
|
||||
adv_type="chat",
|
||||
),
|
||||
MockNodeConfig(
|
||||
public_key=generate_random_public_key(),
|
||||
name="Bob",
|
||||
adv_type="chat",
|
||||
),
|
||||
MockNodeConfig(
|
||||
public_key=generate_random_public_key(),
|
||||
name="Repeater-01",
|
||||
adv_type="repeater",
|
||||
flags=128,
|
||||
),
|
||||
MockNodeConfig(
|
||||
public_key=generate_random_public_key(),
|
||||
name="ChatRoom",
|
||||
adv_type="room",
|
||||
),
|
||||
]
|
||||
|
||||
def connect(self) -> bool:
|
||||
"""Connect to the mock device."""
|
||||
logger.info("Connecting to mock MeshCore device")
|
||||
self._connected = True
|
||||
|
||||
# Simulate initial AppStart event
|
||||
self._dispatch_event(
|
||||
EventType.STATUS_RESPONSE,
|
||||
{
|
||||
"node_public_key": self._public_key,
|
||||
"status": "connected",
|
||||
"uptime": 0,
|
||||
"message_count": 0,
|
||||
},
|
||||
)
|
||||
|
||||
logger.info(f"Mock device connected: {self._public_key}")
|
||||
return True
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""Disconnect from the mock device."""
|
||||
self._connected = False
|
||||
self.stop()
|
||||
logger.info("Mock device disconnected")
|
||||
|
||||
def send_message(
|
||||
self,
|
||||
destination: str,
|
||||
text: str,
|
||||
timestamp: Optional[int] = None,
|
||||
) -> bool:
|
||||
"""Send a simulated direct message."""
|
||||
if not self._connected:
|
||||
logger.error("Cannot send message: not connected")
|
||||
return False
|
||||
|
||||
if self._should_fail():
|
||||
logger.warning("Simulated send failure")
|
||||
return False
|
||||
|
||||
ts = timestamp or int(time.time())
|
||||
logger.info(f"Mock: Sending message to {destination[:12]}...: {text[:20]}...")
|
||||
|
||||
# Simulate send confirmation after delay
|
||||
delay = random.uniform(
|
||||
self.mock_config.message_delay_min,
|
||||
self.mock_config.message_delay_max,
|
||||
)
|
||||
|
||||
def send_confirmation() -> None:
|
||||
time.sleep(delay)
|
||||
self._dispatch_event(
|
||||
EventType.SEND_CONFIRMED,
|
||||
{
|
||||
"destination_public_key": destination
|
||||
if len(destination) == 64
|
||||
else destination + "0" * (64 - len(destination)),
|
||||
"round_trip_ms": int(delay * 1000),
|
||||
},
|
||||
)
|
||||
|
||||
threading.Thread(target=send_confirmation, daemon=True).start()
|
||||
return True
|
||||
|
||||
def send_channel_message(
|
||||
self,
|
||||
channel_idx: int,
|
||||
text: str,
|
||||
timestamp: Optional[int] = None,
|
||||
) -> bool:
|
||||
"""Send a simulated channel message."""
|
||||
if not self._connected:
|
||||
logger.error("Cannot send channel message: not connected")
|
||||
return False
|
||||
|
||||
if self._should_fail():
|
||||
logger.warning("Simulated send failure")
|
||||
return False
|
||||
|
||||
ts = timestamp or int(time.time())
|
||||
logger.info(f"Mock: Sending message to channel {channel_idx}: {text[:20]}...")
|
||||
|
||||
return True
|
||||
|
||||
def send_advertisement(self, flood: bool = True) -> bool:
|
||||
"""Send a simulated advertisement."""
|
||||
if not self._connected:
|
||||
logger.error("Cannot send advertisement: not connected")
|
||||
return False
|
||||
|
||||
logger.info(f"Mock: Sending advertisement (flood={flood})")
|
||||
return True
|
||||
|
||||
def request_status(self, target: Optional[str] = None) -> bool:
|
||||
"""Request status from mock device."""
|
||||
if not self._connected:
|
||||
logger.error("Cannot request status: not connected")
|
||||
return False
|
||||
|
||||
logger.info(f"Mock: Requesting status from {target or 'self'}")
|
||||
|
||||
# Generate status response
|
||||
def send_status() -> None:
|
||||
time.sleep(0.2)
|
||||
self._dispatch_event(
|
||||
EventType.STATUS_RESPONSE,
|
||||
{
|
||||
"node_public_key": target or self._public_key,
|
||||
"status": "operational",
|
||||
"uptime": random.randint(0, 86400),
|
||||
"message_count": random.randint(0, 10000),
|
||||
},
|
||||
)
|
||||
|
||||
threading.Thread(target=send_status, daemon=True).start()
|
||||
return True
|
||||
|
||||
def request_telemetry(self, target: str) -> bool:
|
||||
"""Request telemetry from mock device."""
|
||||
if not self._connected:
|
||||
logger.error("Cannot request telemetry: not connected")
|
||||
return False
|
||||
|
||||
logger.info(f"Mock: Requesting telemetry from {target[:12]}...")
|
||||
|
||||
# Generate telemetry response
|
||||
def send_telemetry() -> None:
|
||||
time.sleep(0.3)
|
||||
self._dispatch_event(
|
||||
EventType.TELEMETRY_RESPONSE,
|
||||
{
|
||||
"node_public_key": target,
|
||||
"parsed_data": {
|
||||
"temperature": round(random.uniform(15.0, 35.0), 1),
|
||||
"humidity": random.randint(30, 90),
|
||||
"battery": round(random.uniform(3.2, 4.2), 2),
|
||||
"pressure": round(random.uniform(980.0, 1040.0), 2),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
threading.Thread(target=send_telemetry, daemon=True).start()
|
||||
return True
|
||||
|
||||
def set_time(self, timestamp: int) -> bool:
|
||||
"""Set the mock device's hardware clock."""
|
||||
if not self._connected:
|
||||
logger.error("Cannot set time: not connected")
|
||||
return False
|
||||
|
||||
logger.info(f"Mock: Set device time to {timestamp}")
|
||||
return True
|
||||
|
||||
def start_message_fetching(self) -> bool:
|
||||
"""Start automatic message fetching (mock)."""
|
||||
if not self._connected:
|
||||
logger.error("Cannot start message fetching: not connected")
|
||||
return False
|
||||
|
||||
logger.info("Mock: Started automatic message fetching")
|
||||
return True
|
||||
|
||||
def run(self) -> None:
|
||||
"""Run the mock device event loop."""
|
||||
self._running = True
|
||||
logger.info("Starting mock device event loop")
|
||||
|
||||
# Start auto event generation thread if enabled
|
||||
if self.mock_config.enable_auto_events:
|
||||
self._event_thread = threading.Thread(
|
||||
target=self._auto_event_generator,
|
||||
daemon=True,
|
||||
)
|
||||
self._event_thread.start()
|
||||
|
||||
while self._running and self._connected:
|
||||
time.sleep(0.1)
|
||||
|
||||
logger.info("Mock device event loop stopped")
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the mock device event loop."""
|
||||
self._running = False
|
||||
if self._event_thread and self._event_thread.is_alive():
|
||||
self._event_thread.join(timeout=1.0)
|
||||
logger.info("Mock device stopped")
|
||||
|
||||
def _should_fail(self) -> bool:
|
||||
"""Check if operation should fail based on error rate."""
|
||||
return random.random() < self.mock_config.error_rate
|
||||
|
||||
def _auto_event_generator(self) -> None:
|
||||
"""Generate automatic events for simulation."""
|
||||
last_adv = time.time()
|
||||
last_msg = time.time()
|
||||
last_telemetry = time.time()
|
||||
|
||||
while self._running:
|
||||
now = time.time()
|
||||
|
||||
# Generate advertisements
|
||||
if now - last_adv >= self.mock_config.advertisement_interval:
|
||||
self._generate_advertisement()
|
||||
last_adv = now
|
||||
|
||||
# Generate messages
|
||||
if now - last_msg >= self.mock_config.message_interval:
|
||||
self._generate_message()
|
||||
last_msg = now
|
||||
|
||||
# Generate telemetry
|
||||
if now - last_telemetry >= self.mock_config.telemetry_interval:
|
||||
self._generate_telemetry()
|
||||
last_telemetry = now
|
||||
|
||||
time.sleep(1.0)
|
||||
|
||||
def _generate_advertisement(self) -> None:
|
||||
"""Generate a random advertisement event."""
|
||||
node = random.choice(self.mock_config.nodes)
|
||||
self._dispatch_event(
|
||||
EventType.ADVERTISEMENT,
|
||||
{
|
||||
"public_key": node.public_key,
|
||||
"name": node.name,
|
||||
"adv_type": node.adv_type,
|
||||
"flags": node.flags,
|
||||
},
|
||||
)
|
||||
logger.debug(f"Generated advertisement from {node.name}")
|
||||
|
||||
def _generate_message(self) -> None:
|
||||
"""Generate a random message event."""
|
||||
node = random.choice(self.mock_config.nodes)
|
||||
|
||||
# Decide between contact and channel message
|
||||
if random.random() < 0.5:
|
||||
# Contact message
|
||||
sample_messages = [
|
||||
"Hello!",
|
||||
"How's the signal?",
|
||||
"Testing 1, 2, 3",
|
||||
"Great weather today!",
|
||||
"Anyone copy?",
|
||||
"Loud and clear!",
|
||||
]
|
||||
self._dispatch_event(
|
||||
EventType.CONTACT_MSG_RECV,
|
||||
{
|
||||
"pubkey_prefix": node.public_key[:12],
|
||||
"text": random.choice(sample_messages),
|
||||
"path_len": random.randint(1, 10),
|
||||
"txt_type": 0,
|
||||
"SNR": round(random.uniform(-5.0, 25.0), 1),
|
||||
"sender_timestamp": int(time.time()),
|
||||
},
|
||||
)
|
||||
logger.debug(f"Generated contact message from {node.name}")
|
||||
else:
|
||||
# Channel message
|
||||
channel_messages = [
|
||||
"Hello everyone!",
|
||||
"Network check",
|
||||
"CQ CQ CQ",
|
||||
"Mesh is working great!",
|
||||
"Any repeaters online?",
|
||||
]
|
||||
self._dispatch_event(
|
||||
EventType.CHANNEL_MSG_RECV,
|
||||
{
|
||||
"channel_idx": random.choice([0, 1, 4, 7]),
|
||||
"text": random.choice(channel_messages),
|
||||
"path_len": random.randint(1, 15),
|
||||
"txt_type": 0,
|
||||
"SNR": round(random.uniform(-5.0, 25.0), 1),
|
||||
"sender_timestamp": int(time.time()),
|
||||
},
|
||||
)
|
||||
logger.debug("Generated channel message")
|
||||
|
||||
def _generate_telemetry(self) -> None:
|
||||
"""Generate a random telemetry event."""
|
||||
node = random.choice(self.mock_config.nodes)
|
||||
self._dispatch_event(
|
||||
EventType.TELEMETRY_RESPONSE,
|
||||
{
|
||||
"node_public_key": node.public_key,
|
||||
"parsed_data": {
|
||||
"temperature": round(random.uniform(15.0, 35.0), 1),
|
||||
"humidity": random.randint(30, 90),
|
||||
"battery": round(random.uniform(3.2, 4.2), 2),
|
||||
},
|
||||
},
|
||||
)
|
||||
logger.debug(f"Generated telemetry from {node.name}")
|
||||
|
||||
def inject_event(self, event_type: EventType, payload: dict) -> None:
|
||||
"""Inject a custom event for testing.
|
||||
|
||||
Args:
|
||||
event_type: Event type
|
||||
payload: Event payload
|
||||
"""
|
||||
self._dispatch_event(event_type, payload)
|
||||
@@ -0,0 +1,257 @@
|
||||
"""RECEIVER mode implementation for MeshCore Interface.
|
||||
|
||||
In RECEIVER mode, the interface:
|
||||
1. Connects to a MeshCore device
|
||||
2. Subscribes to all device events
|
||||
3. Publishes events to MQTT broker
|
||||
"""
|
||||
|
||||
import logging
|
||||
import signal
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
|
||||
from meshcore_hub.common.mqtt import MQTTClient, MQTTConfig
|
||||
from meshcore_hub.interface.device import (
|
||||
BaseMeshCoreDevice,
|
||||
DeviceConfig,
|
||||
EventType,
|
||||
create_device,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Receiver:
|
||||
"""RECEIVER mode implementation.
|
||||
|
||||
Bridges MeshCore device events to MQTT broker.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device: BaseMeshCoreDevice,
|
||||
mqtt_client: MQTTClient,
|
||||
):
|
||||
"""Initialize receiver.
|
||||
|
||||
Args:
|
||||
device: MeshCore device instance
|
||||
mqtt_client: MQTT client instance
|
||||
"""
|
||||
self.device = device
|
||||
self.mqtt = mqtt_client
|
||||
self._running = False
|
||||
self._shutdown_event = threading.Event()
|
||||
|
||||
def _initialize_device(self) -> None:
|
||||
"""Initialize device after connection.
|
||||
|
||||
Sets the hardware clock, sends a local advertisement, and starts message fetching.
|
||||
"""
|
||||
# Set device time to current Unix timestamp
|
||||
current_time = int(time.time())
|
||||
if self.device.set_time(current_time):
|
||||
logger.info(f"Synchronized device clock to {current_time}")
|
||||
else:
|
||||
logger.warning("Failed to synchronize device clock")
|
||||
|
||||
# Send a local (non-flood) advertisement to announce presence
|
||||
if self.device.send_advertisement(flood=False):
|
||||
logger.info("Sent local advertisement")
|
||||
else:
|
||||
logger.warning("Failed to send local advertisement")
|
||||
|
||||
# Start automatic message fetching
|
||||
if self.device.start_message_fetching():
|
||||
logger.info("Started automatic message fetching")
|
||||
else:
|
||||
logger.warning("Failed to start automatic message fetching")
|
||||
|
||||
def _handle_event(self, event_type: EventType, payload: dict[str, Any]) -> None:
|
||||
"""Handle device event and publish to MQTT.
|
||||
|
||||
Args:
|
||||
event_type: Event type
|
||||
payload: Event payload
|
||||
"""
|
||||
if not self.device.public_key:
|
||||
logger.warning("Cannot publish event: device public key not available")
|
||||
return
|
||||
|
||||
try:
|
||||
# Convert event type to MQTT topic name
|
||||
event_name = event_type.value
|
||||
|
||||
# Publish to MQTT
|
||||
self.mqtt.publish_event(
|
||||
self.device.public_key,
|
||||
event_name,
|
||||
payload,
|
||||
)
|
||||
|
||||
logger.debug(f"Published {event_name} event to MQTT")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to publish event to MQTT: {e}")
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start the receiver."""
|
||||
logger.info("Starting RECEIVER mode")
|
||||
|
||||
# Register event handlers for all event types
|
||||
for event_type in EventType:
|
||||
self.device.register_handler(event_type, self._handle_event)
|
||||
logger.debug(f"Registered handler for {event_type.value}")
|
||||
|
||||
# Connect to MQTT broker
|
||||
try:
|
||||
self.mqtt.connect()
|
||||
self.mqtt.start_background()
|
||||
logger.info("Connected to MQTT broker")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to connect to MQTT broker: {e}")
|
||||
raise
|
||||
|
||||
# Connect to device
|
||||
if not self.device.connect():
|
||||
logger.error("Failed to connect to MeshCore device")
|
||||
self.mqtt.stop()
|
||||
self.mqtt.disconnect()
|
||||
raise RuntimeError("Failed to connect to MeshCore device")
|
||||
|
||||
logger.info(f"Connected to MeshCore device: {self.device.public_key}")
|
||||
|
||||
# Initialize device: set time and send local advertisement
|
||||
self._initialize_device()
|
||||
|
||||
self._running = True
|
||||
|
||||
def run(self) -> None:
|
||||
"""Run the receiver event loop (blocking)."""
|
||||
if not self._running:
|
||||
self.start()
|
||||
|
||||
logger.info("Receiver running. Press Ctrl+C to stop.")
|
||||
|
||||
try:
|
||||
# Run device event loop
|
||||
self.device.run()
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Keyboard interrupt received")
|
||||
finally:
|
||||
self.stop()
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the receiver."""
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
logger.info("Stopping receiver")
|
||||
self._running = False
|
||||
self._shutdown_event.set()
|
||||
|
||||
# Stop device
|
||||
self.device.stop()
|
||||
self.device.disconnect()
|
||||
|
||||
# Stop MQTT
|
||||
self.mqtt.stop()
|
||||
self.mqtt.disconnect()
|
||||
|
||||
logger.info("Receiver stopped")
|
||||
|
||||
|
||||
def create_receiver(
|
||||
port: str = "/dev/ttyUSB0",
|
||||
baud: int = 115200,
|
||||
mock: bool = False,
|
||||
node_address: Optional[str] = None,
|
||||
mqtt_host: str = "localhost",
|
||||
mqtt_port: int = 1883,
|
||||
mqtt_username: Optional[str] = None,
|
||||
mqtt_password: Optional[str] = None,
|
||||
mqtt_prefix: str = "meshcore",
|
||||
) -> Receiver:
|
||||
"""Create a configured receiver instance.
|
||||
|
||||
Args:
|
||||
port: Serial port path
|
||||
baud: Baud rate
|
||||
mock: Use mock device
|
||||
node_address: Optional override for device public key/address
|
||||
mqtt_host: MQTT broker host
|
||||
mqtt_port: MQTT broker port
|
||||
mqtt_username: MQTT username
|
||||
mqtt_password: MQTT password
|
||||
mqtt_prefix: MQTT topic prefix
|
||||
|
||||
Returns:
|
||||
Configured Receiver instance
|
||||
"""
|
||||
# Create device
|
||||
device = create_device(port=port, baud=baud, mock=mock, node_address=node_address)
|
||||
|
||||
# Create MQTT client
|
||||
mqtt_config = MQTTConfig(
|
||||
host=mqtt_host,
|
||||
port=mqtt_port,
|
||||
username=mqtt_username,
|
||||
password=mqtt_password,
|
||||
prefix=mqtt_prefix,
|
||||
client_id=f"meshcore-receiver-{device.public_key[:8] if device.public_key else 'unknown'}",
|
||||
)
|
||||
mqtt_client = MQTTClient(mqtt_config)
|
||||
|
||||
return Receiver(device, mqtt_client)
|
||||
|
||||
|
||||
def run_receiver(
|
||||
port: str = "/dev/ttyUSB0",
|
||||
baud: int = 115200,
|
||||
mock: bool = False,
|
||||
node_address: Optional[str] = None,
|
||||
mqtt_host: str = "localhost",
|
||||
mqtt_port: int = 1883,
|
||||
mqtt_username: Optional[str] = None,
|
||||
mqtt_password: Optional[str] = None,
|
||||
mqtt_prefix: str = "meshcore",
|
||||
) -> None:
|
||||
"""Run the receiver (blocking).
|
||||
|
||||
This is the main entry point for running the receiver component.
|
||||
|
||||
Args:
|
||||
port: Serial port path
|
||||
baud: Baud rate
|
||||
mock: Use mock device
|
||||
node_address: Optional override for device public key/address
|
||||
mqtt_host: MQTT broker host
|
||||
mqtt_port: MQTT broker port
|
||||
mqtt_username: MQTT username
|
||||
mqtt_password: MQTT password
|
||||
mqtt_prefix: MQTT topic prefix
|
||||
"""
|
||||
receiver = create_receiver(
|
||||
port=port,
|
||||
baud=baud,
|
||||
mock=mock,
|
||||
node_address=node_address,
|
||||
mqtt_host=mqtt_host,
|
||||
mqtt_port=mqtt_port,
|
||||
mqtt_username=mqtt_username,
|
||||
mqtt_password=mqtt_password,
|
||||
mqtt_prefix=mqtt_prefix,
|
||||
)
|
||||
|
||||
# Set up signal handlers
|
||||
def signal_handler(signum: int, frame: Any) -> None:
|
||||
logger.info(f"Received signal {signum}")
|
||||
receiver.stop()
|
||||
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
signal.signal(signal.SIGTERM, signal_handler)
|
||||
|
||||
# Run
|
||||
receiver.run()
|
||||
@@ -0,0 +1,329 @@
|
||||
"""SENDER mode implementation for MeshCore Interface.
|
||||
|
||||
In SENDER mode, the interface:
|
||||
1. Connects to a MeshCore device
|
||||
2. Subscribes to command topics on MQTT broker
|
||||
3. Executes received commands on the device
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import signal
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
|
||||
from meshcore_hub.common.mqtt import MQTTClient, MQTTConfig
|
||||
from meshcore_hub.interface.device import (
|
||||
BaseMeshCoreDevice,
|
||||
DeviceConfig,
|
||||
create_device,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Sender:
|
||||
"""SENDER mode implementation.
|
||||
|
||||
Bridges MQTT commands to MeshCore device.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device: BaseMeshCoreDevice,
|
||||
mqtt_client: MQTTClient,
|
||||
):
|
||||
"""Initialize sender.
|
||||
|
||||
Args:
|
||||
device: MeshCore device instance
|
||||
mqtt_client: MQTT client instance
|
||||
"""
|
||||
self.device = device
|
||||
self.mqtt = mqtt_client
|
||||
self._running = False
|
||||
self._shutdown_event = threading.Event()
|
||||
|
||||
def _handle_mqtt_message(
|
||||
self,
|
||||
topic: str,
|
||||
pattern: str,
|
||||
payload: dict[str, Any],
|
||||
) -> None:
|
||||
"""Handle incoming MQTT command message.
|
||||
|
||||
Args:
|
||||
topic: MQTT topic
|
||||
pattern: Subscription pattern
|
||||
payload: Message payload
|
||||
"""
|
||||
# Parse command from topic
|
||||
parsed = self.mqtt.topic_builder.parse_command_topic(topic)
|
||||
if not parsed:
|
||||
logger.warning(f"Could not parse command topic: {topic}")
|
||||
return
|
||||
|
||||
target_key, command_name = parsed
|
||||
logger.info(f"Received command: {command_name} for {target_key[:12]}...")
|
||||
|
||||
# Dispatch command
|
||||
try:
|
||||
if command_name == "send_msg":
|
||||
self._handle_send_msg(payload)
|
||||
elif command_name == "send_channel_msg":
|
||||
self._handle_send_channel_msg(payload)
|
||||
elif command_name == "send_advert":
|
||||
self._handle_send_advert(payload)
|
||||
elif command_name == "request_status":
|
||||
self._handle_request_status(payload)
|
||||
elif command_name == "request_telemetry":
|
||||
self._handle_request_telemetry(payload)
|
||||
else:
|
||||
logger.warning(f"Unknown command: {command_name}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling command {command_name}: {e}")
|
||||
|
||||
def _handle_send_msg(self, payload: dict[str, Any]) -> None:
|
||||
"""Handle send_msg command.
|
||||
|
||||
Args:
|
||||
payload: Command payload with destination, text, timestamp
|
||||
"""
|
||||
destination = payload.get("destination")
|
||||
text = payload.get("text")
|
||||
timestamp = payload.get("timestamp")
|
||||
|
||||
if not destination or not text:
|
||||
logger.error("send_msg: missing destination or text")
|
||||
return
|
||||
|
||||
success = self.device.send_message(destination, text, timestamp)
|
||||
if success:
|
||||
logger.info(f"Message sent to {destination[:12]}...")
|
||||
else:
|
||||
logger.error(f"Failed to send message to {destination[:12]}...")
|
||||
|
||||
def _handle_send_channel_msg(self, payload: dict[str, Any]) -> None:
|
||||
"""Handle send_channel_msg command.
|
||||
|
||||
Args:
|
||||
payload: Command payload with channel_idx, text, timestamp
|
||||
"""
|
||||
channel_idx = payload.get("channel_idx")
|
||||
text = payload.get("text")
|
||||
timestamp = payload.get("timestamp")
|
||||
|
||||
if channel_idx is None or not text:
|
||||
logger.error("send_channel_msg: missing channel_idx or text")
|
||||
return
|
||||
|
||||
success = self.device.send_channel_message(channel_idx, text, timestamp)
|
||||
if success:
|
||||
logger.info(f"Channel message sent to channel {channel_idx}")
|
||||
else:
|
||||
logger.error(f"Failed to send message to channel {channel_idx}")
|
||||
|
||||
def _handle_send_advert(self, payload: dict[str, Any]) -> None:
|
||||
"""Handle send_advert command.
|
||||
|
||||
Args:
|
||||
payload: Command payload with flood flag
|
||||
"""
|
||||
flood = payload.get("flood", True)
|
||||
|
||||
success = self.device.send_advertisement(flood)
|
||||
if success:
|
||||
logger.info(f"Advertisement sent (flood={flood})")
|
||||
else:
|
||||
logger.error("Failed to send advertisement")
|
||||
|
||||
def _handle_request_status(self, payload: dict[str, Any]) -> None:
|
||||
"""Handle request_status command.
|
||||
|
||||
Args:
|
||||
payload: Command payload with optional target
|
||||
"""
|
||||
target = payload.get("target_public_key")
|
||||
|
||||
success = self.device.request_status(target)
|
||||
if success:
|
||||
logger.info(f"Status requested from {target or 'self'}")
|
||||
else:
|
||||
logger.error("Failed to request status")
|
||||
|
||||
def _handle_request_telemetry(self, payload: dict[str, Any]) -> None:
|
||||
"""Handle request_telemetry command.
|
||||
|
||||
Args:
|
||||
payload: Command payload with target
|
||||
"""
|
||||
target = payload.get("target_public_key")
|
||||
|
||||
if not target:
|
||||
logger.error("request_telemetry: missing target_public_key")
|
||||
return
|
||||
|
||||
success = self.device.request_telemetry(target)
|
||||
if success:
|
||||
logger.info(f"Telemetry requested from {target[:12]}...")
|
||||
else:
|
||||
logger.error("Failed to request telemetry")
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start the sender."""
|
||||
logger.info("Starting SENDER mode")
|
||||
|
||||
# Connect to device first
|
||||
if not self.device.connect():
|
||||
logger.error("Failed to connect to MeshCore device")
|
||||
raise RuntimeError("Failed to connect to MeshCore device")
|
||||
|
||||
logger.info(f"Connected to MeshCore device: {self.device.public_key}")
|
||||
|
||||
# Connect to MQTT broker
|
||||
try:
|
||||
self.mqtt.connect()
|
||||
self.mqtt.start_background()
|
||||
logger.info("Connected to MQTT broker")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to connect to MQTT broker: {e}")
|
||||
self.device.disconnect()
|
||||
raise
|
||||
|
||||
# Subscribe to command topics
|
||||
# Using wildcard to receive commands for any node
|
||||
command_topic = self.mqtt.topic_builder.all_commands_topic()
|
||||
self.mqtt.subscribe(command_topic, self._handle_mqtt_message)
|
||||
logger.info(f"Subscribed to command topic: {command_topic}")
|
||||
|
||||
self._running = True
|
||||
|
||||
def run(self) -> None:
|
||||
"""Run the sender event loop (blocking)."""
|
||||
if not self._running:
|
||||
self.start()
|
||||
|
||||
logger.info("Sender running. Press Ctrl+C to stop.")
|
||||
|
||||
try:
|
||||
while self._running and not self._shutdown_event.is_set():
|
||||
time.sleep(0.1)
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Keyboard interrupt received")
|
||||
finally:
|
||||
self.stop()
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the sender."""
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
logger.info("Stopping sender")
|
||||
self._running = False
|
||||
self._shutdown_event.set()
|
||||
|
||||
# Stop MQTT
|
||||
self.mqtt.stop()
|
||||
self.mqtt.disconnect()
|
||||
|
||||
# Stop device
|
||||
self.device.stop()
|
||||
self.device.disconnect()
|
||||
|
||||
logger.info("Sender stopped")
|
||||
|
||||
|
||||
def create_sender(
|
||||
port: str = "/dev/ttyUSB0",
|
||||
baud: int = 115200,
|
||||
mock: bool = False,
|
||||
node_address: Optional[str] = None,
|
||||
mqtt_host: str = "localhost",
|
||||
mqtt_port: int = 1883,
|
||||
mqtt_username: Optional[str] = None,
|
||||
mqtt_password: Optional[str] = None,
|
||||
mqtt_prefix: str = "meshcore",
|
||||
) -> Sender:
|
||||
"""Create a configured sender instance.
|
||||
|
||||
Args:
|
||||
port: Serial port path
|
||||
baud: Baud rate
|
||||
mock: Use mock device
|
||||
node_address: Optional override for device public key/address
|
||||
mqtt_host: MQTT broker host
|
||||
mqtt_port: MQTT broker port
|
||||
mqtt_username: MQTT username
|
||||
mqtt_password: MQTT password
|
||||
mqtt_prefix: MQTT topic prefix
|
||||
|
||||
Returns:
|
||||
Configured Sender instance
|
||||
"""
|
||||
# Create device
|
||||
device = create_device(port=port, baud=baud, mock=mock, node_address=node_address)
|
||||
|
||||
# Create MQTT client
|
||||
mqtt_config = MQTTConfig(
|
||||
host=mqtt_host,
|
||||
port=mqtt_port,
|
||||
username=mqtt_username,
|
||||
password=mqtt_password,
|
||||
prefix=mqtt_prefix,
|
||||
client_id=f"meshcore-sender-{device.public_key[:8] if device.public_key else 'unknown'}",
|
||||
)
|
||||
mqtt_client = MQTTClient(mqtt_config)
|
||||
|
||||
return Sender(device, mqtt_client)
|
||||
|
||||
|
||||
def run_sender(
|
||||
port: str = "/dev/ttyUSB0",
|
||||
baud: int = 115200,
|
||||
mock: bool = False,
|
||||
node_address: Optional[str] = None,
|
||||
mqtt_host: str = "localhost",
|
||||
mqtt_port: int = 1883,
|
||||
mqtt_username: Optional[str] = None,
|
||||
mqtt_password: Optional[str] = None,
|
||||
mqtt_prefix: str = "meshcore",
|
||||
) -> None:
|
||||
"""Run the sender (blocking).
|
||||
|
||||
This is the main entry point for running the sender component.
|
||||
|
||||
Args:
|
||||
port: Serial port path
|
||||
baud: Baud rate
|
||||
mock: Use mock device
|
||||
node_address: Optional override for device public key/address
|
||||
mqtt_host: MQTT broker host
|
||||
mqtt_port: MQTT broker port
|
||||
mqtt_username: MQTT username
|
||||
mqtt_password: MQTT password
|
||||
mqtt_prefix: MQTT topic prefix
|
||||
"""
|
||||
sender = create_sender(
|
||||
port=port,
|
||||
baud=baud,
|
||||
mock=mock,
|
||||
node_address=node_address,
|
||||
mqtt_host=mqtt_host,
|
||||
mqtt_port=mqtt_port,
|
||||
mqtt_username=mqtt_username,
|
||||
mqtt_password=mqtt_password,
|
||||
mqtt_prefix=mqtt_prefix,
|
||||
)
|
||||
|
||||
# Set up signal handlers
|
||||
def signal_handler(signum: int, frame: Any) -> None:
|
||||
logger.info(f"Received signal {signum}")
|
||||
sender.stop()
|
||||
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
signal.signal(signal.SIGTERM, signal_handler)
|
||||
|
||||
# Run
|
||||
sender.run()
|
||||
@@ -0,0 +1 @@
|
||||
"""Web dashboard component for visualizing MeshCore network."""
|
||||
@@ -0,0 +1,149 @@
|
||||
"""FastAPI application for MeshCore Hub Web Dashboard."""
|
||||
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import AsyncGenerator
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from meshcore_hub import __version__
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Directory paths
|
||||
PACKAGE_DIR = Path(__file__).parent
|
||||
TEMPLATES_DIR = PACKAGE_DIR / "templates"
|
||||
STATIC_DIR = PACKAGE_DIR / "static"
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
"""Application lifespan handler."""
|
||||
# Create HTTP client for API calls
|
||||
api_url = getattr(app.state, "api_url", "http://localhost:8000")
|
||||
api_key = getattr(app.state, "api_key", None)
|
||||
|
||||
headers = {}
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
|
||||
app.state.http_client = httpx.AsyncClient(
|
||||
base_url=api_url,
|
||||
headers=headers,
|
||||
timeout=30.0,
|
||||
)
|
||||
|
||||
logger.info(f"Web dashboard started, API URL: {api_url}")
|
||||
|
||||
yield
|
||||
|
||||
# Cleanup
|
||||
await app.state.http_client.aclose()
|
||||
logger.info("Web dashboard stopped")
|
||||
|
||||
|
||||
def create_app(
|
||||
api_url: str = "http://localhost:8000",
|
||||
api_key: str | None = None,
|
||||
network_name: str = "MeshCore Network",
|
||||
network_city: str | None = None,
|
||||
network_country: str | None = None,
|
||||
network_location: tuple[float, float] | None = None,
|
||||
network_radio_config: str | None = None,
|
||||
network_contact_email: str | None = None,
|
||||
network_contact_discord: str | None = None,
|
||||
members_file: str | None = None,
|
||||
) -> FastAPI:
|
||||
"""Create and configure the web dashboard application.
|
||||
|
||||
Args:
|
||||
api_url: Base URL of the MeshCore Hub API
|
||||
api_key: API key for authentication
|
||||
network_name: Display name for the network
|
||||
network_city: City where the network is located
|
||||
network_country: Country where the network is located
|
||||
network_location: (lat, lon) tuple for map centering
|
||||
network_radio_config: Radio configuration description
|
||||
network_contact_email: Contact email address
|
||||
network_contact_discord: Discord invite/server info
|
||||
members_file: Path to members JSON file
|
||||
|
||||
Returns:
|
||||
Configured FastAPI application
|
||||
"""
|
||||
app = FastAPI(
|
||||
title="MeshCore Hub Dashboard",
|
||||
description="Web dashboard for MeshCore network visualization",
|
||||
version=__version__,
|
||||
lifespan=lifespan,
|
||||
docs_url=None, # Disable docs for web app
|
||||
redoc_url=None,
|
||||
)
|
||||
|
||||
# Store configuration in app state
|
||||
app.state.api_url = api_url
|
||||
app.state.api_key = api_key
|
||||
app.state.network_name = network_name
|
||||
app.state.network_city = network_city
|
||||
app.state.network_country = network_country
|
||||
app.state.network_location = network_location or (0.0, 0.0)
|
||||
app.state.network_radio_config = network_radio_config
|
||||
app.state.network_contact_email = network_contact_email
|
||||
app.state.network_contact_discord = network_contact_discord
|
||||
app.state.members_file = members_file
|
||||
|
||||
# Set up templates
|
||||
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
||||
app.state.templates = templates
|
||||
|
||||
# Mount static files
|
||||
if STATIC_DIR.exists():
|
||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||
|
||||
# Include routers
|
||||
from meshcore_hub.web.routes import web_router
|
||||
|
||||
app.include_router(web_router)
|
||||
|
||||
# Health check endpoint
|
||||
@app.get("/health", tags=["Health"])
|
||||
async def health() -> dict:
|
||||
"""Basic health check."""
|
||||
return {"status": "healthy", "version": __version__}
|
||||
|
||||
@app.get("/health/ready", tags=["Health"])
|
||||
async def health_ready(request: Request) -> dict:
|
||||
"""Readiness check including API connectivity."""
|
||||
try:
|
||||
response = await request.app.state.http_client.get("/health")
|
||||
if response.status_code == 200:
|
||||
return {"status": "ready", "api": "connected"}
|
||||
return {"status": "not_ready", "api": f"status {response.status_code}"}
|
||||
except Exception as e:
|
||||
return {"status": "not_ready", "api": str(e)}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def get_templates(request: Request) -> Jinja2Templates:
|
||||
"""Get templates from app state."""
|
||||
return request.app.state.templates
|
||||
|
||||
|
||||
def get_network_context(request: Request) -> dict:
|
||||
"""Get network configuration context for templates."""
|
||||
return {
|
||||
"network_name": request.app.state.network_name,
|
||||
"network_city": request.app.state.network_city,
|
||||
"network_country": request.app.state.network_country,
|
||||
"network_location": request.app.state.network_location,
|
||||
"network_radio_config": request.app.state.network_radio_config,
|
||||
"network_contact_email": request.app.state.network_contact_email,
|
||||
"network_contact_discord": request.app.state.network_contact_discord,
|
||||
"version": __version__,
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
"""Web dashboard CLI commands."""
|
||||
|
||||
import click
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--host",
|
||||
type=str,
|
||||
default="0.0.0.0",
|
||||
envvar="WEB_HOST",
|
||||
help="Web server host",
|
||||
)
|
||||
@click.option(
|
||||
"--port",
|
||||
type=int,
|
||||
default=8080,
|
||||
envvar="WEB_PORT",
|
||||
help="Web server port",
|
||||
)
|
||||
@click.option(
|
||||
"--api-url",
|
||||
type=str,
|
||||
default="http://localhost:8000",
|
||||
envvar="API_BASE_URL",
|
||||
help="API server base URL",
|
||||
)
|
||||
@click.option(
|
||||
"--api-key",
|
||||
type=str,
|
||||
default=None,
|
||||
envvar="API_KEY",
|
||||
help="API key for queries",
|
||||
)
|
||||
@click.option(
|
||||
"--network-name",
|
||||
type=str,
|
||||
default="MeshCore Network",
|
||||
envvar="NETWORK_NAME",
|
||||
help="Network display name",
|
||||
)
|
||||
@click.option(
|
||||
"--network-city",
|
||||
type=str,
|
||||
default=None,
|
||||
envvar="NETWORK_CITY",
|
||||
help="Network city location",
|
||||
)
|
||||
@click.option(
|
||||
"--network-country",
|
||||
type=str,
|
||||
default=None,
|
||||
envvar="NETWORK_COUNTRY",
|
||||
help="Network country",
|
||||
)
|
||||
@click.option(
|
||||
"--network-lat",
|
||||
type=float,
|
||||
default=0.0,
|
||||
envvar="NETWORK_LAT",
|
||||
help="Network center latitude",
|
||||
)
|
||||
@click.option(
|
||||
"--network-lon",
|
||||
type=float,
|
||||
default=0.0,
|
||||
envvar="NETWORK_LON",
|
||||
help="Network center longitude",
|
||||
)
|
||||
@click.option(
|
||||
"--network-radio-config",
|
||||
type=str,
|
||||
default=None,
|
||||
envvar="NETWORK_RADIO_CONFIG",
|
||||
help="Radio configuration description",
|
||||
)
|
||||
@click.option(
|
||||
"--network-contact-email",
|
||||
type=str,
|
||||
default=None,
|
||||
envvar="NETWORK_CONTACT_EMAIL",
|
||||
help="Contact email address",
|
||||
)
|
||||
@click.option(
|
||||
"--network-contact-discord",
|
||||
type=str,
|
||||
default=None,
|
||||
envvar="NETWORK_CONTACT_DISCORD",
|
||||
help="Discord server info",
|
||||
)
|
||||
@click.option(
|
||||
"--members-file",
|
||||
type=str,
|
||||
default=None,
|
||||
envvar="MEMBERS_FILE",
|
||||
help="Path to members JSON file",
|
||||
)
|
||||
@click.option(
|
||||
"--reload",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Enable auto-reload for development",
|
||||
)
|
||||
@click.pass_context
|
||||
def web(
|
||||
ctx: click.Context,
|
||||
host: str,
|
||||
port: int,
|
||||
api_url: str,
|
||||
api_key: str | None,
|
||||
network_name: str,
|
||||
network_city: str | None,
|
||||
network_country: str | None,
|
||||
network_lat: float,
|
||||
network_lon: float,
|
||||
network_radio_config: str | None,
|
||||
network_contact_email: str | None,
|
||||
network_contact_discord: str | None,
|
||||
members_file: str | None,
|
||||
reload: bool,
|
||||
) -> None:
|
||||
"""Run the web dashboard.
|
||||
|
||||
Provides a web interface for visualizing network status, browsing nodes,
|
||||
viewing messages, and displaying a node map.
|
||||
|
||||
Examples:
|
||||
|
||||
# Run with defaults
|
||||
meshcore-hub web
|
||||
|
||||
# Run with custom network name and location
|
||||
meshcore-hub web --network-name "My Mesh" --network-city "New York" --network-country "USA"
|
||||
|
||||
# Run with API authentication
|
||||
meshcore-hub web --api-url http://api.example.com --api-key secret
|
||||
|
||||
# Run with members file
|
||||
meshcore-hub web --members-file /path/to/members.json
|
||||
|
||||
# Development mode with auto-reload
|
||||
meshcore-hub web --reload
|
||||
"""
|
||||
import uvicorn
|
||||
|
||||
from meshcore_hub.web.app import create_app
|
||||
|
||||
click.echo("=" * 50)
|
||||
click.echo("MeshCore Hub Web Dashboard")
|
||||
click.echo("=" * 50)
|
||||
click.echo(f"Host: {host}")
|
||||
click.echo(f"Port: {port}")
|
||||
click.echo(f"API URL: {api_url}")
|
||||
click.echo(f"API key configured: {api_key is not None}")
|
||||
click.echo(f"Network: {network_name}")
|
||||
if network_city and network_country:
|
||||
click.echo(f"Location: {network_city}, {network_country}")
|
||||
if network_lat != 0.0 or network_lon != 0.0:
|
||||
click.echo(f"Map center: {network_lat}, {network_lon}")
|
||||
if members_file:
|
||||
click.echo(f"Members file: {members_file}")
|
||||
click.echo(f"Reload mode: {reload}")
|
||||
click.echo("=" * 50)
|
||||
|
||||
network_location = (network_lat, network_lon)
|
||||
|
||||
if reload:
|
||||
# For development, use uvicorn's reload feature
|
||||
click.echo("\nStarting in development mode with auto-reload...")
|
||||
click.echo("Note: Using default settings for reload mode.")
|
||||
|
||||
uvicorn.run(
|
||||
"meshcore_hub.web.app:create_app",
|
||||
host=host,
|
||||
port=port,
|
||||
reload=True,
|
||||
factory=True,
|
||||
)
|
||||
else:
|
||||
# For production, create app directly
|
||||
app = create_app(
|
||||
api_url=api_url,
|
||||
api_key=api_key,
|
||||
network_name=network_name,
|
||||
network_city=network_city,
|
||||
network_country=network_country,
|
||||
network_location=network_location,
|
||||
network_radio_config=network_radio_config,
|
||||
network_contact_email=network_contact_email,
|
||||
network_contact_discord=network_contact_discord,
|
||||
members_file=members_file,
|
||||
)
|
||||
|
||||
click.echo("\nStarting web dashboard...")
|
||||
uvicorn.run(app, host=host, port=port)
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Web routes for MeshCore Hub Dashboard."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from meshcore_hub.web.routes.home import router as home_router
|
||||
from meshcore_hub.web.routes.network import router as network_router
|
||||
from meshcore_hub.web.routes.nodes import router as nodes_router
|
||||
from meshcore_hub.web.routes.messages import router as messages_router
|
||||
from meshcore_hub.web.routes.map import router as map_router
|
||||
from meshcore_hub.web.routes.members import router as members_router
|
||||
|
||||
# Create main web router
|
||||
web_router = APIRouter()
|
||||
|
||||
# Include all sub-routers
|
||||
web_router.include_router(home_router)
|
||||
web_router.include_router(network_router)
|
||||
web_router.include_router(nodes_router)
|
||||
web_router.include_router(messages_router)
|
||||
web_router.include_router(map_router)
|
||||
web_router.include_router(members_router)
|
||||
|
||||
__all__ = ["web_router"]
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Home page route."""
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from meshcore_hub.web.app import get_network_context, get_templates
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def home(request: Request) -> HTMLResponse:
|
||||
"""Render the home page."""
|
||||
templates = get_templates(request)
|
||||
context = get_network_context(request)
|
||||
context["request"] = request
|
||||
|
||||
return templates.TemplateResponse("home.html", context)
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Map page route."""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
|
||||
from meshcore_hub.web.app import get_network_context, get_templates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/map", response_class=HTMLResponse)
|
||||
async def map_page(request: Request) -> HTMLResponse:
|
||||
"""Render the map page."""
|
||||
templates = get_templates(request)
|
||||
context = get_network_context(request)
|
||||
context["request"] = request
|
||||
|
||||
return templates.TemplateResponse("map.html", context)
|
||||
|
||||
|
||||
@router.get("/map/data")
|
||||
async def map_data(request: Request) -> JSONResponse:
|
||||
"""Return node location data as JSON for the map."""
|
||||
nodes_with_location = []
|
||||
|
||||
try:
|
||||
# Fetch all nodes from API
|
||||
response = await request.app.state.http_client.get(
|
||||
"/api/v1/nodes", params={"limit": 500}
|
||||
)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
nodes = data.get("items", [])
|
||||
|
||||
# Filter nodes with location tags
|
||||
for node in nodes:
|
||||
tags = node.get("tags", [])
|
||||
lat = None
|
||||
lon = None
|
||||
for tag in tags:
|
||||
if tag.get("key") == "lat":
|
||||
try:
|
||||
lat = float(tag.get("value"))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
elif tag.get("key") == "lon":
|
||||
try:
|
||||
lon = float(tag.get("value"))
|
||||
except (ValueError, TypeError):
|
||||
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"),
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch nodes for map: {e}")
|
||||
|
||||
# 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],
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Members page route."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from meshcore_hub.web.app import get_network_context, get_templates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def load_members(members_file: str | None) -> list[dict]:
|
||||
"""Load members from JSON file.
|
||||
|
||||
Args:
|
||||
members_file: Path to members JSON file
|
||||
|
||||
Returns:
|
||||
List of member dictionaries
|
||||
"""
|
||||
if not members_file:
|
||||
return []
|
||||
|
||||
try:
|
||||
path = Path(members_file)
|
||||
if path.exists():
|
||||
with open(path, "r") as f:
|
||||
data = json.load(f)
|
||||
# Handle both list and dict with "members" key
|
||||
if isinstance(data, list):
|
||||
return data
|
||||
elif isinstance(data, dict) and "members" in data:
|
||||
return data["members"]
|
||||
else:
|
||||
logger.warning(f"Members file not found: {members_file}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load members file: {e}")
|
||||
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/members", response_class=HTMLResponse)
|
||||
async def members_page(request: Request) -> HTMLResponse:
|
||||
"""Render the members page."""
|
||||
templates = get_templates(request)
|
||||
context = get_network_context(request)
|
||||
context["request"] = request
|
||||
|
||||
# Load members from file
|
||||
members_file = request.app.state.members_file
|
||||
members = load_members(members_file)
|
||||
|
||||
context["members"] = members
|
||||
|
||||
return templates.TemplateResponse("members.html", context)
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Messages page route."""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Query, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from meshcore_hub.web.app import get_network_context, get_templates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/messages", response_class=HTMLResponse)
|
||||
async def messages_list(
|
||||
request: Request,
|
||||
message_type: str | None = Query(None, description="Filter by message type"),
|
||||
channel_idx: int | None = Query(None, description="Filter by channel"),
|
||||
search: str | None = Query(None, description="Search in message text"),
|
||||
page: int = Query(1, ge=1, description="Page number"),
|
||||
limit: int = Query(50, ge=1, le=100, description="Items per page"),
|
||||
) -> HTMLResponse:
|
||||
"""Render the messages list page."""
|
||||
templates = get_templates(request)
|
||||
context = get_network_context(request)
|
||||
context["request"] = request
|
||||
|
||||
# Calculate offset
|
||||
offset = (page - 1) * limit
|
||||
|
||||
# Build query params
|
||||
params = {"limit": limit, "offset": offset}
|
||||
if message_type:
|
||||
params["message_type"] = message_type
|
||||
if channel_idx is not None:
|
||||
params["channel_idx"] = channel_idx
|
||||
|
||||
# Fetch messages from API
|
||||
messages = []
|
||||
total = 0
|
||||
|
||||
try:
|
||||
response = await request.app.state.http_client.get(
|
||||
"/api/v1/messages", params=params
|
||||
)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
messages = data.get("items", [])
|
||||
total = data.get("total", 0)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch messages from API: {e}")
|
||||
context["api_error"] = str(e)
|
||||
|
||||
# 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 "",
|
||||
})
|
||||
|
||||
return templates.TemplateResponse("messages.html", context)
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Network overview page route."""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from meshcore_hub.web.app import get_network_context, get_templates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/network", response_class=HTMLResponse)
|
||||
async def network_overview(request: Request) -> HTMLResponse:
|
||||
"""Render the network overview page."""
|
||||
templates = get_templates(request)
|
||||
context = get_network_context(request)
|
||||
context["request"] = request
|
||||
|
||||
# Fetch stats from API
|
||||
stats = {
|
||||
"total_nodes": 0,
|
||||
"active_nodes": 0,
|
||||
"total_messages": 0,
|
||||
"messages_today": 0,
|
||||
"total_advertisements": 0,
|
||||
"channel_message_counts": {},
|
||||
}
|
||||
|
||||
try:
|
||||
response = await request.app.state.http_client.get("/api/v1/dashboard/stats")
|
||||
if response.status_code == 200:
|
||||
stats = response.json()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch stats from API: {e}")
|
||||
context["api_error"] = str(e)
|
||||
|
||||
context["stats"] = stats
|
||||
|
||||
return templates.TemplateResponse("network.html", context)
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Nodes page routes."""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Query, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from meshcore_hub.web.app import get_network_context, get_templates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/nodes", response_class=HTMLResponse)
|
||||
async def nodes_list(
|
||||
request: Request,
|
||||
search: str | None = Query(None, description="Search term"),
|
||||
adv_type: str | None = Query(None, description="Filter by node type"),
|
||||
page: int = Query(1, ge=1, description="Page number"),
|
||||
limit: int = Query(20, ge=1, le=100, description="Items per page"),
|
||||
) -> HTMLResponse:
|
||||
"""Render the nodes list page."""
|
||||
templates = get_templates(request)
|
||||
context = get_network_context(request)
|
||||
context["request"] = request
|
||||
|
||||
# Calculate offset
|
||||
offset = (page - 1) * limit
|
||||
|
||||
# Build query params
|
||||
params = {"limit": limit, "offset": offset}
|
||||
if search:
|
||||
params["search"] = search
|
||||
if adv_type:
|
||||
params["adv_type"] = adv_type
|
||||
|
||||
# Fetch nodes from API
|
||||
nodes = []
|
||||
total = 0
|
||||
|
||||
try:
|
||||
response = await request.app.state.http_client.get(
|
||||
"/api/v1/nodes", params=params
|
||||
)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
nodes = data.get("items", [])
|
||||
total = data.get("total", 0)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch nodes from API: {e}")
|
||||
context["api_error"] = str(e)
|
||||
|
||||
# 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 "",
|
||||
})
|
||||
|
||||
return templates.TemplateResponse("nodes.html", context)
|
||||
|
||||
|
||||
@router.get("/nodes/{public_key}", response_class=HTMLResponse)
|
||||
async def node_detail(request: Request, public_key: str) -> HTMLResponse:
|
||||
"""Render the node detail page."""
|
||||
templates = get_templates(request)
|
||||
context = get_network_context(request)
|
||||
context["request"] = request
|
||||
|
||||
node = None
|
||||
advertisements = []
|
||||
telemetry = []
|
||||
|
||||
try:
|
||||
# Fetch node details
|
||||
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}
|
||||
)
|
||||
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}
|
||||
)
|
||||
if response.status_code == 200:
|
||||
telemetry = response.json().get("items", [])
|
||||
|
||||
except Exception as e:
|
||||
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,
|
||||
})
|
||||
|
||||
return templates.TemplateResponse("node_detail.html", context)
|
||||
@@ -0,0 +1,120 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}{{ network_name }}{% endblock %}</title>
|
||||
|
||||
<!-- Tailwind CSS with DaisyUI -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.4.19/dist/full.min.css" rel="stylesheet" type="text/css" />
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
|
||||
<!-- Leaflet CSS for maps -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
|
||||
<style>
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: oklch(var(--b2));
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: oklch(var(--bc) / 0.3);
|
||||
border-radius: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: oklch(var(--bc) / 0.5);
|
||||
}
|
||||
|
||||
/* Table styling */
|
||||
.table-compact td, .table-compact th {
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
/* Truncate text in table cells */
|
||||
.truncate-cell {
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
{% block extra_head %}{% endblock %}
|
||||
</head>
|
||||
<body class="min-h-screen bg-base-200">
|
||||
<!-- Navbar -->
|
||||
<div class="navbar bg-base-100 shadow-lg">
|
||||
<div class="navbar-start">
|
||||
<div class="dropdown">
|
||||
<div tabindex="0" role="button" class="btn btn-ghost lg:hidden">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h8m-8 6h16" />
|
||||
</svg>
|
||||
</div>
|
||||
<ul tabindex="0" class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow bg-base-100 rounded-box w-52">
|
||||
<li><a href="/" class="{% if request.url.path == '/' %}active{% endif %}">Home</a></li>
|
||||
<li><a href="/network" class="{% if request.url.path == '/network' %}active{% endif %}">Network</a></li>
|
||||
<li><a href="/nodes" class="{% if '/nodes' in request.url.path %}active{% endif %}">Nodes</a></li>
|
||||
<li><a href="/messages" class="{% if request.url.path == '/messages' %}active{% endif %}">Messages</a></li>
|
||||
<li><a href="/map" class="{% if request.url.path == '/map' %}active{% endif %}">Map</a></li>
|
||||
<li><a href="/members" class="{% if request.url.path == '/members' %}active{% endif %}">Members</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<a href="/" class="btn btn-ghost text-xl">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.14 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0" />
|
||||
</svg>
|
||||
{{ network_name }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="navbar-center hidden lg:flex">
|
||||
<ul class="menu menu-horizontal px-1">
|
||||
<li><a href="/" class="{% if request.url.path == '/' %}active{% endif %}">Home</a></li>
|
||||
<li><a href="/network" class="{% if request.url.path == '/network' %}active{% endif %}">Network</a></li>
|
||||
<li><a href="/nodes" class="{% if '/nodes' in request.url.path %}active{% endif %}">Nodes</a></li>
|
||||
<li><a href="/messages" class="{% if request.url.path == '/messages' %}active{% endif %}">Messages</a></li>
|
||||
<li><a href="/map" class="{% if request.url.path == '/map' %}active{% endif %}">Map</a></li>
|
||||
<li><a href="/members" class="{% if request.url.path == '/members' %}active{% endif %}">Members</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="navbar-end">
|
||||
<div class="badge badge-outline badge-sm">v{{ version }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="container mx-auto px-4 py-6">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="footer footer-center p-4 bg-base-100 text-base-content mt-auto">
|
||||
<aside>
|
||||
<p>
|
||||
{{ network_name }}
|
||||
{% if network_city and network_country %}
|
||||
- {{ network_city }}, {{ network_country }}
|
||||
{% endif %}
|
||||
</p>
|
||||
<p class="text-sm opacity-70">
|
||||
{% if network_contact_email %}
|
||||
<a href="mailto:{{ network_contact_email }}" class="link link-hover">{{ network_contact_email }}</a>
|
||||
{% endif %}
|
||||
{% if network_contact_email and network_contact_discord %} | {% endif %}
|
||||
{% if network_contact_discord %}
|
||||
<span>Discord: {{ network_contact_discord }}</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
<p class="text-xs opacity-50 mt-2">Powered by MeshCore Hub v{{ version }}</p>
|
||||
</aside>
|
||||
</footer>
|
||||
|
||||
<!-- Leaflet JS for maps -->
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
|
||||
{% block extra_scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,118 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ network_name }} - Home{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="hero min-h-[50vh] bg-base-100 rounded-box">
|
||||
<div class="hero-content text-center">
|
||||
<div class="max-w-2xl">
|
||||
<h1 class="text-5xl font-bold">{{ network_name }}</h1>
|
||||
{% if network_city and network_country %}
|
||||
<p class="py-2 text-lg opacity-70">{{ network_city }}, {{ network_country }}</p>
|
||||
{% endif %}
|
||||
<p class="py-6">
|
||||
Welcome to the {{ network_name }} mesh network dashboard.
|
||||
Monitor network activity, view connected nodes, and explore message history.
|
||||
</p>
|
||||
<div class="flex gap-4 justify-center flex-wrap">
|
||||
<a href="/network" class="btn btn-primary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
View Network Stats
|
||||
</a>
|
||||
<a href="/nodes" class="btn btn-secondary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
Browse Nodes
|
||||
</a>
|
||||
<a href="/map" class="btn btn-accent">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
|
||||
</svg>
|
||||
View Map
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mt-8">
|
||||
<!-- Network Info Card -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Network Info
|
||||
</h2>
|
||||
<div class="space-y-2">
|
||||
{% if network_radio_config %}
|
||||
<div class="flex justify-between">
|
||||
<span class="opacity-70">Radio Config:</span>
|
||||
<span class="font-mono">{{ network_radio_config }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if network_location and network_location != (0.0, 0.0) %}
|
||||
<div class="flex justify-between">
|
||||
<span class="opacity-70">Location:</span>
|
||||
<span class="font-mono">{{ "%.4f"|format(network_location[0]) }}, {{ "%.4f"|format(network_location[1]) }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Links Card -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||
</svg>
|
||||
Quick Links
|
||||
</h2>
|
||||
<ul class="menu bg-base-200 rounded-box">
|
||||
<li><a href="/messages">Recent Messages</a></li>
|
||||
<li><a href="/nodes">All Nodes</a></li>
|
||||
<li><a href="/members">Network Members</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contact Card -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
Contact
|
||||
</h2>
|
||||
<div class="space-y-2">
|
||||
{% if network_contact_email %}
|
||||
<a href="mailto:{{ network_contact_email }}" class="btn btn-outline btn-sm btn-block">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 12a4 4 0 10-8 0 4 4 0 008 0zm0 0v1.5a2.5 2.5 0 005 0V12a9 9 0 10-9 9m4.5-1.206a8.959 8.959 0 01-4.5 1.207" />
|
||||
</svg>
|
||||
{{ network_contact_email }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if network_contact_discord %}
|
||||
<div class="btn btn-outline btn-sm btn-block">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z"/>
|
||||
</svg>
|
||||
{{ network_contact_discord }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if not network_contact_email and not network_contact_discord %}
|
||||
<p class="text-sm opacity-70">No contact information configured.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,103 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ network_name }} - Node Map{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<style>
|
||||
#map {
|
||||
height: calc(100vh - 250px);
|
||||
min-height: 400px;
|
||||
border-radius: var(--rounded-box);
|
||||
}
|
||||
.leaflet-popup-content-wrapper {
|
||||
background: oklch(var(--b1));
|
||||
color: oklch(var(--bc));
|
||||
}
|
||||
.leaflet-popup-tip {
|
||||
background: oklch(var(--b1));
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-3xl font-bold">Node Map</h1>
|
||||
<span id="node-count" class="badge badge-lg">Loading...</span>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body p-2">
|
||||
<div id="map"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 text-sm opacity-70">
|
||||
<p>Nodes are placed on the map based on their <code>lat</code> and <code>lon</code> tags.</p>
|
||||
<p>To add a node to the map, set its location tags via the API.</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
// Initialize map
|
||||
const map = L.map('map').setView([{{ network_location[0] }}, {{ network_location[1] }}], 10);
|
||||
|
||||
// Add tile layer
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
}).addTo(map);
|
||||
|
||||
// Custom marker icon
|
||||
const nodeIcon = L.divIcon({
|
||||
className: 'custom-div-icon',
|
||||
html: `<div style="background-color: oklch(var(--p)); width: 12px; height: 12px; border-radius: 50%; border: 2px solid white; box-shadow: 0 2px 4px rgba(0,0,0,0.3);"></div>`,
|
||||
iconSize: [12, 12],
|
||||
iconAnchor: [6, 6]
|
||||
});
|
||||
|
||||
// Fetch and display nodes
|
||||
fetch('/map/data')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const nodes = data.nodes;
|
||||
const center = data.center;
|
||||
|
||||
// Update node count
|
||||
document.getElementById('node-count').textContent = `${nodes.length} nodes on map`;
|
||||
|
||||
// Add markers for each node
|
||||
nodes.forEach(node => {
|
||||
const marker = L.marker([node.lat, node.lon], { icon: nodeIcon }).addTo(map);
|
||||
|
||||
// Create popup content
|
||||
const popupContent = `
|
||||
<div class="p-2">
|
||||
<h3 class="font-bold text-lg mb-2">${node.name}</h3>
|
||||
<div class="space-y-1 text-sm">
|
||||
<p><span class="opacity-70">Type:</span> ${node.adv_type || 'Unknown'}</p>
|
||||
<p><span class="opacity-70">Key:</span> <code class="text-xs">${node.public_key.substring(0, 16)}...</code></p>
|
||||
<p><span class="opacity-70">Location:</span> ${node.lat.toFixed(4)}, ${node.lon.toFixed(4)}</p>
|
||||
${node.last_seen ? `<p><span class="opacity-70">Last seen:</span> ${node.last_seen.substring(0, 19).replace('T', ' ')}</p>` : ''}
|
||||
</div>
|
||||
<a href="/nodes/${node.public_key}" class="btn btn-primary btn-xs mt-3">View Details</a>
|
||||
</div>
|
||||
`;
|
||||
|
||||
marker.bindPopup(popupContent);
|
||||
});
|
||||
|
||||
// Fit bounds if we have nodes
|
||||
if (nodes.length > 0) {
|
||||
const bounds = L.latLngBounds(nodes.map(n => [n.lat, n.lon]));
|
||||
map.fitBounds(bounds, { padding: [50, 50] });
|
||||
} else if (center.lat !== 0 || center.lon !== 0) {
|
||||
// Use network center if no nodes
|
||||
map.setView([center.lat, center.lon], 10);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading map data:', error);
|
||||
document.getElementById('node-count').textContent = 'Error loading data';
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,90 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ network_name }} - Members{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-3xl font-bold">Network Members</h1>
|
||||
<span class="badge badge-lg">{{ members|length }} members</span>
|
||||
</div>
|
||||
|
||||
{% if members %}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{% for member in members %}
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
{{ member.name }}
|
||||
{% if member.callsign %}
|
||||
<span class="badge badge-secondary">{{ member.callsign }}</span>
|
||||
{% endif %}
|
||||
</h2>
|
||||
|
||||
{% if member.role %}
|
||||
<p class="text-sm opacity-70">{{ member.role }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if member.description %}
|
||||
<p class="mt-2">{{ member.description }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if member.email or member.discord or member.website %}
|
||||
<div class="card-actions justify-start mt-4">
|
||||
{% if member.email %}
|
||||
<a href="mailto:{{ member.email }}" class="btn btn-ghost btn-xs">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
Email
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if member.website %}
|
||||
<a href="{{ member.website }}" target="_blank" class="btn btn-ghost btn-xs">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||
</svg>
|
||||
Website
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-info">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div>
|
||||
<h3 class="font-bold">No members configured</h3>
|
||||
<p class="text-sm">To display network members, provide a members JSON file using the <code>--members-file</code> option.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Members File Format</h2>
|
||||
<p class="mb-4">Create a JSON file with the following structure:</p>
|
||||
<pre class="bg-base-200 p-4 rounded-box text-sm overflow-x-auto"><code>{
|
||||
"members": [
|
||||
{
|
||||
"name": "John Doe",
|
||||
"callsign": "AB1CD",
|
||||
"role": "Network Admin",
|
||||
"description": "Manages the main repeater node.",
|
||||
"email": "john@example.com",
|
||||
"website": "https://example.com"
|
||||
},
|
||||
{
|
||||
"name": "Jane Smith",
|
||||
"role": "Member",
|
||||
"description": "Regular user in the downtown area."
|
||||
}
|
||||
]
|
||||
}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,139 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ network_name }} - Messages{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-3xl font-bold">Messages</h1>
|
||||
<span class="badge badge-lg">{{ total }} total</span>
|
||||
</div>
|
||||
|
||||
{% if api_error %}
|
||||
<div class="alert alert-warning mb-6">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<span>Could not fetch data from API: {{ api_error }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="card bg-base-100 shadow mb-6">
|
||||
<div class="card-body py-4">
|
||||
<form method="GET" action="/messages" class="flex gap-4 flex-wrap items-end">
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text">Type</span>
|
||||
</label>
|
||||
<select name="message_type" class="select select-bordered select-sm">
|
||||
<option value="">All Types</option>
|
||||
<option value="direct" {% if message_type == 'direct' %}selected{% endif %}>Direct</option>
|
||||
<option value="channel" {% if message_type == 'channel' %}selected{% endif %}>Channel</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text">Channel</span>
|
||||
</label>
|
||||
<select name="channel_idx" class="select select-bordered select-sm">
|
||||
<option value="">All Channels</option>
|
||||
{% for i in range(8) %}
|
||||
<option value="{{ i }}" {% if channel_idx == i %}selected{% endif %}>Channel {{ i }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Filter</button>
|
||||
<a href="/messages" class="btn btn-ghost btn-sm">Clear</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Messages Table -->
|
||||
<div class="overflow-x-auto bg-base-100 rounded-box shadow">
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Type</th>
|
||||
<th>From/Channel</th>
|
||||
<th>Message</th>
|
||||
<th>SNR</th>
|
||||
<th>Hops</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for msg in messages %}
|
||||
<tr class="hover">
|
||||
<td class="text-xs whitespace-nowrap">
|
||||
{{ msg.received_at[:19].replace('T', ' ') if msg.received_at else '-' }}
|
||||
</td>
|
||||
<td>
|
||||
{% if msg.message_type == 'channel' %}
|
||||
<span class="badge badge-info badge-sm">Channel</span>
|
||||
{% else %}
|
||||
<span class="badge badge-success badge-sm">Direct</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="font-mono text-xs">
|
||||
{% if msg.message_type == 'channel' %}
|
||||
CH{{ msg.channel_idx }}
|
||||
{% else %}
|
||||
{{ (msg.pubkey_prefix or '-')[:12] }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="truncate-cell" title="{{ msg.text }}">
|
||||
{{ msg.text or '-' }}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
{% if msg.snr is not none %}
|
||||
<span class="badge badge-ghost badge-sm">{{ "%.1f"|format(msg.snr) }}</span>
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
{% if msg.hops is not none %}
|
||||
<span class="badge badge-ghost badge-sm">{{ msg.hops }}</span>
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="6" class="text-center py-8 opacity-70">No messages found.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if total_pages > 1 %}
|
||||
<div class="flex justify-center mt-6">
|
||||
<div class="join">
|
||||
{% if page > 1 %}
|
||||
<a href="?page={{ page - 1 }}&message_type={{ message_type }}&channel_idx={{ channel_idx or '' }}&limit={{ limit }}" class="join-item btn btn-sm">Previous</a>
|
||||
{% else %}
|
||||
<button class="join-item btn btn-sm btn-disabled">Previous</button>
|
||||
{% endif %}
|
||||
|
||||
{% for p in range(1, total_pages + 1) %}
|
||||
{% if p == page %}
|
||||
<button class="join-item btn btn-sm btn-active">{{ p }}</button>
|
||||
{% elif p == 1 or p == total_pages or (p >= page - 2 and p <= page + 2) %}
|
||||
<a href="?page={{ p }}&message_type={{ message_type }}&channel_idx={{ channel_idx or '' }}&limit={{ limit }}" class="join-item btn btn-sm">{{ p }}</a>
|
||||
{% elif p == 2 or p == total_pages - 1 %}
|
||||
<button class="join-item btn btn-sm btn-disabled">...</button>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if page < total_pages %}
|
||||
<a href="?page={{ page + 1 }}&message_type={{ message_type }}&channel_idx={{ channel_idx or '' }}&limit={{ limit }}" class="join-item btn btn-sm">Next</a>
|
||||
{% else %}
|
||||
<button class="join-item btn btn-sm btn-disabled">Next</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,148 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ network_name }} - Network Overview{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-3xl font-bold">Network Overview</h1>
|
||||
<button onclick="location.reload()" class="btn btn-ghost btn-sm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{% if api_error %}
|
||||
<div class="alert alert-warning mb-6">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<span>Could not fetch data from API: {{ api_error }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<!-- Total Nodes -->
|
||||
<div class="stat bg-base-100 rounded-box shadow">
|
||||
<div class="stat-figure text-primary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-title">Total Nodes</div>
|
||||
<div class="stat-value text-primary">{{ stats.total_nodes }}</div>
|
||||
<div class="stat-desc">All discovered nodes</div>
|
||||
</div>
|
||||
|
||||
<!-- Active Nodes -->
|
||||
<div class="stat bg-base-100 rounded-box shadow">
|
||||
<div class="stat-figure text-secondary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5.636 18.364a9 9 0 010-12.728m12.728 0a9 9 0 010 12.728m-9.9-2.829a5 5 0 010-7.07m7.072 0a5 5 0 010 7.07M13 12a1 1 0 11-2 0 1 1 0 012 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-title">Active Nodes</div>
|
||||
<div class="stat-value text-secondary">{{ stats.active_nodes }}</div>
|
||||
<div class="stat-desc">Active in last 24 hours</div>
|
||||
</div>
|
||||
|
||||
<!-- Total Messages -->
|
||||
<div class="stat bg-base-100 rounded-box shadow">
|
||||
<div class="stat-figure text-accent">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-title">Total Messages</div>
|
||||
<div class="stat-value text-accent">{{ stats.total_messages }}</div>
|
||||
<div class="stat-desc">All time</div>
|
||||
</div>
|
||||
|
||||
<!-- Messages Today -->
|
||||
<div class="stat bg-base-100 rounded-box shadow">
|
||||
<div class="stat-figure text-info">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-title">Messages Today</div>
|
||||
<div class="stat-value text-info">{{ stats.messages_today }}</div>
|
||||
<div class="stat-desc">Last 24 hours</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Additional Stats -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- Advertisements -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z" />
|
||||
</svg>
|
||||
Advertisements
|
||||
</h2>
|
||||
<div class="stat-value">{{ stats.total_advertisements }}</div>
|
||||
<p class="text-sm opacity-70">Total advertisements received</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Channel Stats -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14" />
|
||||
</svg>
|
||||
Channel Messages
|
||||
</h2>
|
||||
{% if stats.channel_message_counts %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-compact w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Channel</th>
|
||||
<th class="text-right">Count</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for channel, count in stats.channel_message_counts.items() %}
|
||||
<tr>
|
||||
<td>Channel {{ channel }}</td>
|
||||
<td class="text-right font-mono">{{ count }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-sm opacity-70">No channel messages recorded yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="flex gap-4 mt-8 flex-wrap">
|
||||
<a href="/nodes" class="btn btn-primary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
Browse Nodes
|
||||
</a>
|
||||
<a href="/messages" class="btn btn-secondary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
|
||||
</svg>
|
||||
View Messages
|
||||
</a>
|
||||
<a href="/map" class="btn btn-accent">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
|
||||
</svg>
|
||||
View Map
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,154 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ network_name }} - Node Details{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="breadcrumbs text-sm mb-4">
|
||||
<ul>
|
||||
<li><a href="/">Home</a></li>
|
||||
<li><a href="/nodes">Nodes</a></li>
|
||||
<li>{{ node.name or public_key[:12] + '...' if node else 'Not Found' }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{% if api_error %}
|
||||
<div class="alert alert-warning mb-6">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<span>Could not fetch data from API: {{ api_error }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if node %}
|
||||
<!-- Node Info Card -->
|
||||
<div class="card bg-base-100 shadow-xl mb-6">
|
||||
<div class="card-body">
|
||||
<h1 class="card-title text-2xl">
|
||||
{{ node.name or 'Unnamed Node' }}
|
||||
{% if node.adv_type %}
|
||||
<span class="badge badge-secondary">{{ node.adv_type }}</span>
|
||||
{% endif %}
|
||||
</h1>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
|
||||
<div>
|
||||
<h3 class="font-semibold opacity-70 mb-2">Public Key</h3>
|
||||
<code class="text-sm bg-base-200 p-2 rounded block break-all">{{ node.public_key }}</code>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold opacity-70 mb-2">Activity</h3>
|
||||
<div class="space-y-1 text-sm">
|
||||
<p><span class="opacity-70">First seen:</span> {{ node.first_seen[:19].replace('T', ' ') if node.first_seen else '-' }}</p>
|
||||
<p><span class="opacity-70">Last seen:</span> {{ node.last_seen[:19].replace('T', ' ') if node.last_seen else '-' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
{% if node.tags %}
|
||||
<div class="mt-6">
|
||||
<h3 class="font-semibold opacity-70 mb-2">Tags</h3>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-compact w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Key</th>
|
||||
<th>Value</th>
|
||||
<th>Type</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for tag in node.tags %}
|
||||
<tr>
|
||||
<td class="font-mono">{{ tag.key }}</td>
|
||||
<td>{{ tag.value }}</td>
|
||||
<td class="opacity-70">{{ tag.value_type or 'string' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Recent Advertisements -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Recent Advertisements</h2>
|
||||
{% if advertisements %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-compact w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Type</th>
|
||||
<th>Name</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for adv in advertisements %}
|
||||
<tr>
|
||||
<td class="text-xs">{{ adv.received_at[:19].replace('T', ' ') if adv.received_at else '-' }}</td>
|
||||
<td>{{ adv.adv_type or '-' }}</td>
|
||||
<td>{{ adv.name or '-' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="opacity-70">No advertisements recorded.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Telemetry -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Recent Telemetry</h2>
|
||||
{% if telemetry %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-compact w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Data</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for tel in telemetry %}
|
||||
<tr>
|
||||
<td class="text-xs">{{ tel.received_at[:19].replace('T', ' ') if tel.received_at else '-' }}</td>
|
||||
<td class="text-xs font-mono">
|
||||
{% if tel.parsed_data %}
|
||||
{{ tel.parsed_data | tojson }}
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="opacity-70">No telemetry recorded.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<div class="alert alert-error">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>Node not found: {{ public_key }}</span>
|
||||
</div>
|
||||
<a href="/nodes" class="btn btn-primary mt-4">Back to Nodes</a>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,138 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ network_name }} - Nodes{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-3xl font-bold">Nodes</h1>
|
||||
<span class="badge badge-lg">{{ total }} total</span>
|
||||
</div>
|
||||
|
||||
{% if api_error %}
|
||||
<div class="alert alert-warning mb-6">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<span>Could not fetch data from API: {{ api_error }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="card bg-base-100 shadow mb-6">
|
||||
<div class="card-body py-4">
|
||||
<form method="GET" action="/nodes" class="flex gap-4 flex-wrap items-end">
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text">Search</span>
|
||||
</label>
|
||||
<input type="text" name="search" value="{{ search }}" placeholder="Name or public key..." class="input input-bordered input-sm w-64" />
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text">Type</span>
|
||||
</label>
|
||||
<select name="adv_type" class="select select-bordered select-sm">
|
||||
<option value="">All Types</option>
|
||||
<option value="chat" {% if adv_type == 'chat' %}selected{% endif %}>Chat</option>
|
||||
<option value="repeater" {% if adv_type == 'repeater' %}selected{% endif %}>Repeater</option>
|
||||
<option value="room" {% if adv_type == 'room' %}selected{% endif %}>Room</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Filter</button>
|
||||
<a href="/nodes" class="btn btn-ghost btn-sm">Clear</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nodes Table -->
|
||||
<div class="overflow-x-auto bg-base-100 rounded-box shadow">
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Public Key</th>
|
||||
<th>Type</th>
|
||||
<th>Last Seen</th>
|
||||
<th>Tags</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for node in nodes %}
|
||||
<tr class="hover">
|
||||
<td class="font-medium">{{ node.name or '-' }}</td>
|
||||
<td class="font-mono text-xs truncate-cell" title="{{ node.public_key }}">
|
||||
{{ node.public_key[:16] }}...
|
||||
</td>
|
||||
<td>
|
||||
{% if node.adv_type %}
|
||||
<span class="badge badge-outline badge-sm">{{ node.adv_type }}</span>
|
||||
{% else %}
|
||||
<span class="opacity-50">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-sm">
|
||||
{% if node.last_seen %}
|
||||
{{ node.last_seen[:19].replace('T', ' ') }}
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if node.tags %}
|
||||
<div class="flex gap-1 flex-wrap">
|
||||
{% for tag in node.tags[:3] %}
|
||||
<span class="badge badge-ghost badge-xs">{{ tag.key }}</span>
|
||||
{% endfor %}
|
||||
{% if node.tags|length > 3 %}
|
||||
<span class="badge badge-ghost badge-xs">+{{ node.tags|length - 3 }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="opacity-50">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<a href="/nodes/{{ node.public_key }}" class="btn btn-ghost btn-xs">
|
||||
View
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="6" class="text-center py-8 opacity-70">No nodes found.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if total_pages > 1 %}
|
||||
<div class="flex justify-center mt-6">
|
||||
<div class="join">
|
||||
{% if page > 1 %}
|
||||
<a href="?page={{ page - 1 }}&search={{ search }}&adv_type={{ adv_type }}&limit={{ limit }}" class="join-item btn btn-sm">Previous</a>
|
||||
{% else %}
|
||||
<button class="join-item btn btn-sm btn-disabled">Previous</button>
|
||||
{% endif %}
|
||||
|
||||
{% for p in range(1, total_pages + 1) %}
|
||||
{% if p == page %}
|
||||
<button class="join-item btn btn-sm btn-active">{{ p }}</button>
|
||||
{% elif p == 1 or p == total_pages or (p >= page - 2 and p <= page + 2) %}
|
||||
<a href="?page={{ p }}&search={{ search }}&adv_type={{ adv_type }}&limit={{ limit }}" class="join-item btn btn-sm">{{ p }}</a>
|
||||
{% elif p == 2 or p == total_pages - 1 %}
|
||||
<button class="join-item btn btn-sm btn-disabled">...</button>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if page < total_pages %}
|
||||
<a href="?page={{ page + 1 }}&search={{ search }}&adv_type={{ adv_type }}&limit={{ limit }}" class="join-item btn btn-sm">Next</a>
|
||||
{% else %}
|
||||
<button class="join-item btn btn-sm btn-disabled">Next</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1 @@
|
||||
"""MeshCore Hub test suite."""
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Shared pytest fixtures for all tests."""
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from meshcore_hub.common.models import Base
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_engine():
|
||||
"""Create an in-memory SQLite database engine for testing."""
|
||||
engine = create_engine(
|
||||
"sqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
Base.metadata.create_all(engine)
|
||||
yield engine
|
||||
Base.metadata.drop_all(engine)
|
||||
engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_session(db_engine):
|
||||
"""Create a database session for testing."""
|
||||
Session = sessionmaker(bind=db_engine)
|
||||
session = Session()
|
||||
yield session
|
||||
session.close()
|
||||
@@ -0,0 +1 @@
|
||||
"""API component tests."""
|
||||
@@ -0,0 +1,262 @@
|
||||
"""API test fixtures."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
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.common.database import DatabaseManager
|
||||
from meshcore_hub.common.models import (
|
||||
Advertisement,
|
||||
Base,
|
||||
Message,
|
||||
Node,
|
||||
NodeTag,
|
||||
Telemetry,
|
||||
TracePath,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_db_path():
|
||||
"""Create a temporary database file path."""
|
||||
fd, path = tempfile.mkstemp(suffix=".db")
|
||||
os.close(fd)
|
||||
yield path
|
||||
# Cleanup
|
||||
if os.path.exists(path):
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api_db_engine(test_db_path):
|
||||
"""Create a SQLite database engine for API testing."""
|
||||
db_url = f"sqlite:///{test_db_path}"
|
||||
engine = create_engine(
|
||||
db_url,
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
Base.metadata.create_all(engine)
|
||||
yield engine
|
||||
Base.metadata.drop_all(engine)
|
||||
engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api_db_session(api_db_engine):
|
||||
"""Create a database session for API testing."""
|
||||
Session = sessionmaker(bind=api_db_engine)
|
||||
session = Session()
|
||||
yield session
|
||||
session.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_mqtt():
|
||||
"""Create a mock MQTT client."""
|
||||
mock = MagicMock()
|
||||
mock.connect.return_value = None
|
||||
mock.start_background.return_value = None
|
||||
mock.stop.return_value = None
|
||||
mock.disconnect.return_value = None
|
||||
mock.publish_command.return_value = None
|
||||
return mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db_manager(api_db_engine):
|
||||
"""Create a mock database manager using the test engine."""
|
||||
manager = MagicMock(spec=DatabaseManager)
|
||||
Session = sessionmaker(bind=api_db_engine)
|
||||
manager.get_session = lambda: Session()
|
||||
return manager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app_no_auth(test_db_path, api_db_engine, mock_mqtt, mock_db_manager):
|
||||
"""Create a FastAPI app with no authentication required."""
|
||||
db_url = f"sqlite:///{test_db_path}"
|
||||
|
||||
# Patch the global db_manager to avoid lifespan issues
|
||||
with patch("meshcore_hub.api.app._db_manager", mock_db_manager):
|
||||
app = create_app(
|
||||
database_url=db_url,
|
||||
read_key=None,
|
||||
admin_key=None,
|
||||
)
|
||||
|
||||
# Create session maker for this test engine
|
||||
Session = sessionmaker(bind=api_db_engine)
|
||||
|
||||
def override_get_db_manager(request=None):
|
||||
return mock_db_manager
|
||||
|
||||
def override_get_db_session():
|
||||
session = Session()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def override_get_mqtt_client(request=None):
|
||||
return mock_mqtt
|
||||
|
||||
app.dependency_overrides[get_db_manager] = override_get_db_manager
|
||||
app.dependency_overrides[get_db_session] = override_get_db_session
|
||||
app.dependency_overrides[get_mqtt_client] = override_get_mqtt_client
|
||||
|
||||
yield app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app_with_auth(test_db_path, api_db_engine, mock_mqtt, mock_db_manager):
|
||||
"""Create a FastAPI app with authentication enabled."""
|
||||
db_url = f"sqlite:///{test_db_path}"
|
||||
|
||||
with patch("meshcore_hub.api.app._db_manager", mock_db_manager):
|
||||
app = create_app(
|
||||
database_url=db_url,
|
||||
read_key="test-read-key",
|
||||
admin_key="test-admin-key",
|
||||
)
|
||||
|
||||
Session = sessionmaker(bind=api_db_engine)
|
||||
|
||||
def override_get_db_manager(request=None):
|
||||
return mock_db_manager
|
||||
|
||||
def override_get_db_session():
|
||||
session = Session()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def override_get_mqtt_client(request=None):
|
||||
return mock_mqtt
|
||||
|
||||
app.dependency_overrides[get_db_manager] = override_get_db_manager
|
||||
app.dependency_overrides[get_db_session] = override_get_db_session
|
||||
app.dependency_overrides[get_mqtt_client] = override_get_mqtt_client
|
||||
|
||||
yield app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client_no_auth(app_no_auth, mock_db_manager):
|
||||
"""Create a test client with no authentication.
|
||||
|
||||
Uses raise_server_exceptions=False to skip lifespan events.
|
||||
"""
|
||||
# Don't use context manager to skip lifespan
|
||||
client = TestClient(app_no_auth, raise_server_exceptions=True)
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client_with_auth(app_with_auth, mock_db_manager):
|
||||
"""Create a test client with authentication enabled.
|
||||
|
||||
Uses raise_server_exceptions=False to skip lifespan events.
|
||||
"""
|
||||
client = TestClient(app_with_auth, raise_server_exceptions=True)
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_node(api_db_session):
|
||||
"""Create a sample node in the database."""
|
||||
node = Node(
|
||||
public_key="abc123def456abc123def456abc123de",
|
||||
name="Test Node",
|
||||
adv_type="REPEATER",
|
||||
first_seen=datetime.now(timezone.utc),
|
||||
last_seen=datetime.now(timezone.utc),
|
||||
)
|
||||
api_db_session.add(node)
|
||||
api_db_session.commit()
|
||||
api_db_session.refresh(node)
|
||||
return node
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_node_tag(api_db_session, sample_node):
|
||||
"""Create a sample node tag in the database."""
|
||||
tag = NodeTag(
|
||||
node_id=sample_node.id,
|
||||
key="environment",
|
||||
value="production",
|
||||
)
|
||||
api_db_session.add(tag)
|
||||
api_db_session.commit()
|
||||
api_db_session.refresh(tag)
|
||||
return tag
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_message(api_db_session):
|
||||
"""Create a sample message in the database."""
|
||||
message = Message(
|
||||
message_type="direct",
|
||||
pubkey_prefix="abc123",
|
||||
text="Hello World",
|
||||
received_at=datetime.now(timezone.utc),
|
||||
)
|
||||
api_db_session.add(message)
|
||||
api_db_session.commit()
|
||||
api_db_session.refresh(message)
|
||||
return message
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_advertisement(api_db_session):
|
||||
"""Create a sample advertisement in the database."""
|
||||
advert = Advertisement(
|
||||
public_key="abc123def456abc123def456abc123de",
|
||||
name="TestNode",
|
||||
adv_type="REPEATER",
|
||||
received_at=datetime.now(timezone.utc),
|
||||
)
|
||||
api_db_session.add(advert)
|
||||
api_db_session.commit()
|
||||
api_db_session.refresh(advert)
|
||||
return advert
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_telemetry(api_db_session):
|
||||
"""Create a sample telemetry record in the database."""
|
||||
telemetry = Telemetry(
|
||||
node_public_key="abc123def456abc123def456abc123de",
|
||||
parsed_data={
|
||||
"battery_level": 85.5,
|
||||
"temperature": 25.3,
|
||||
},
|
||||
received_at=datetime.now(timezone.utc),
|
||||
)
|
||||
api_db_session.add(telemetry)
|
||||
api_db_session.commit()
|
||||
api_db_session.refresh(telemetry)
|
||||
return telemetry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_trace_path(api_db_session):
|
||||
"""Create a sample trace path in the database."""
|
||||
trace = TracePath(
|
||||
initiator_tag=12345,
|
||||
path_hashes=["abc123", "def456", "ghi789"],
|
||||
hop_count=3,
|
||||
received_at=datetime.now(timezone.utc),
|
||||
)
|
||||
api_db_session.add(trace)
|
||||
api_db_session.commit()
|
||||
api_db_session.refresh(trace)
|
||||
return trace
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Tests for advertisement API routes."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestListAdvertisements:
|
||||
"""Tests for GET /advertisements endpoint."""
|
||||
|
||||
def test_list_advertisements_empty(self, client_no_auth):
|
||||
"""Test listing advertisements when database is empty."""
|
||||
response = client_no_auth.get("/api/v1/advertisements")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["items"] == []
|
||||
assert data["total"] == 0
|
||||
|
||||
def test_list_advertisements_with_data(self, client_no_auth, sample_advertisement):
|
||||
"""Test listing advertisements with data in database."""
|
||||
response = client_no_auth.get("/api/v1/advertisements")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
assert data["total"] == 1
|
||||
assert data["items"][0]["public_key"] == sample_advertisement.public_key
|
||||
assert data["items"][0]["adv_type"] == sample_advertisement.adv_type
|
||||
|
||||
def test_list_advertisements_filter_by_public_key(
|
||||
self, client_no_auth, sample_advertisement
|
||||
):
|
||||
"""Test filtering advertisements by public key."""
|
||||
response = client_no_auth.get(
|
||||
f"/api/v1/advertisements?public_key={sample_advertisement.public_key}"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
|
||||
response = client_no_auth.get(
|
||||
"/api/v1/advertisements?public_key=nonexistent"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 0
|
||||
|
||||
|
||||
class TestGetAdvertisement:
|
||||
"""Tests for GET /advertisements/{id} endpoint."""
|
||||
|
||||
def test_get_advertisement_success(self, client_no_auth, sample_advertisement):
|
||||
"""Test getting a specific advertisement."""
|
||||
response = client_no_auth.get(
|
||||
f"/api/v1/advertisements/{sample_advertisement.id}"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["id"] == sample_advertisement.id
|
||||
assert data["public_key"] == sample_advertisement.public_key
|
||||
|
||||
def test_get_advertisement_not_found(self, client_no_auth):
|
||||
"""Test getting a non-existent advertisement."""
|
||||
response = client_no_auth.get("/api/v1/advertisements/nonexistent-id")
|
||||
assert response.status_code == 404
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Tests for API authentication."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestAuthenticationFlow:
|
||||
"""Tests for authentication behavior."""
|
||||
|
||||
def test_no_auth_when_keys_not_configured(self, client_no_auth):
|
||||
"""Test that no auth is required when keys are not configured."""
|
||||
# All endpoints should work without auth
|
||||
response = client_no_auth.get("/api/v1/nodes")
|
||||
assert response.status_code == 200
|
||||
|
||||
response = client_no_auth.get("/api/v1/messages")
|
||||
assert response.status_code == 200
|
||||
|
||||
response = client_no_auth.post(
|
||||
"/api/v1/commands/send-message",
|
||||
json={
|
||||
"destination": "abc123def456abc123def456abc123de",
|
||||
"text": "Test",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_read_endpoints_accept_read_key(self, client_with_auth):
|
||||
"""Test that read endpoints accept read key."""
|
||||
response = client_with_auth.get(
|
||||
"/api/v1/nodes",
|
||||
headers={"Authorization": "Bearer test-read-key"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_read_endpoints_accept_admin_key(self, client_with_auth):
|
||||
"""Test that read endpoints accept admin key."""
|
||||
response = client_with_auth.get(
|
||||
"/api/v1/nodes",
|
||||
headers={"Authorization": "Bearer test-admin-key"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_admin_endpoints_reject_read_key(self, client_with_auth):
|
||||
"""Test that admin endpoints reject read key."""
|
||||
response = client_with_auth.post(
|
||||
"/api/v1/commands/send-message",
|
||||
json={
|
||||
"destination": "abc123def456abc123def456abc123de",
|
||||
"text": "Test",
|
||||
},
|
||||
headers={"Authorization": "Bearer test-read-key"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_admin_endpoints_accept_admin_key(self, client_with_auth):
|
||||
"""Test that admin endpoints accept admin key."""
|
||||
response = client_with_auth.post(
|
||||
"/api/v1/commands/send-message",
|
||||
json={
|
||||
"destination": "abc123def456abc123def456abc123de",
|
||||
"text": "Test",
|
||||
},
|
||||
headers={"Authorization": "Bearer test-admin-key"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_invalid_key_rejected(self, client_with_auth):
|
||||
"""Test that invalid keys are rejected."""
|
||||
response = client_with_auth.get(
|
||||
"/api/v1/nodes",
|
||||
headers={"Authorization": "Bearer invalid-key"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_missing_bearer_prefix_rejected(self, client_with_auth):
|
||||
"""Test that tokens without Bearer prefix are rejected."""
|
||||
response = client_with_auth.get(
|
||||
"/api/v1/nodes",
|
||||
headers={"Authorization": "test-read-key"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_empty_auth_header_rejected(self, client_with_auth):
|
||||
"""Test that empty auth headers are rejected."""
|
||||
response = client_with_auth.get(
|
||||
"/api/v1/nodes",
|
||||
headers={"Authorization": ""},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
class TestHealthEndpoint:
|
||||
"""Tests for health check endpoint."""
|
||||
|
||||
def test_health_no_auth(self, client_no_auth):
|
||||
"""Test health endpoint without auth."""
|
||||
response = client_no_auth.get("/health")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "healthy"
|
||||
|
||||
def test_health_with_auth_configured(self, client_with_auth):
|
||||
"""Test health endpoint works even when auth is configured."""
|
||||
# Health endpoint should always be accessible
|
||||
response = client_with_auth.get("/health")
|
||||
assert response.status_code == 200
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Tests for command API routes."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestSendMessage:
|
||||
"""Tests for POST /commands/send-message endpoint."""
|
||||
|
||||
def test_send_message_success(self, client_no_auth, mock_mqtt):
|
||||
"""Test sending a direct message."""
|
||||
response = client_no_auth.post(
|
||||
"/api/v1/commands/send-message",
|
||||
json={
|
||||
"destination": "abc123def456abc123def456abc123de",
|
||||
"text": "Hello World",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert "queued" in data["message"].lower()
|
||||
|
||||
def test_send_message_requires_admin(self, client_with_auth):
|
||||
"""Test sending message requires admin authentication."""
|
||||
# Without auth
|
||||
response = client_with_auth.post(
|
||||
"/api/v1/commands/send-message",
|
||||
json={
|
||||
"destination": "abc123def456abc123def456abc123de",
|
||||
"text": "Hello",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
# With read key (not admin)
|
||||
response = client_with_auth.post(
|
||||
"/api/v1/commands/send-message",
|
||||
json={
|
||||
"destination": "abc123def456abc123def456abc123de",
|
||||
"text": "Hello",
|
||||
},
|
||||
headers={"Authorization": "Bearer test-read-key"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
# With admin key
|
||||
response = client_with_auth.post(
|
||||
"/api/v1/commands/send-message",
|
||||
json={
|
||||
"destination": "abc123def456abc123def456abc123de",
|
||||
"text": "Hello",
|
||||
},
|
||||
headers={"Authorization": "Bearer test-admin-key"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class TestSendChannelMessage:
|
||||
"""Tests for POST /commands/send-channel-message endpoint."""
|
||||
|
||||
def test_send_channel_message_success(self, client_no_auth, mock_mqtt):
|
||||
"""Test sending a channel message."""
|
||||
response = client_no_auth.post(
|
||||
"/api/v1/commands/send-channel-message",
|
||||
json={
|
||||
"channel_idx": 1,
|
||||
"text": "Hello Channel",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert "channel 1" in data["message"].lower()
|
||||
|
||||
def test_send_channel_message_requires_admin(self, client_with_auth):
|
||||
"""Test sending channel message requires admin authentication."""
|
||||
response = client_with_auth.post(
|
||||
"/api/v1/commands/send-channel-message",
|
||||
json={
|
||||
"channel_idx": 1,
|
||||
"text": "Hello",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
class TestSendAdvertisement:
|
||||
"""Tests for POST /commands/send-advertisement endpoint."""
|
||||
|
||||
def test_send_advertisement_success(self, client_no_auth, mock_mqtt):
|
||||
"""Test sending an advertisement."""
|
||||
response = client_no_auth.post(
|
||||
"/api/v1/commands/send-advertisement",
|
||||
json={"flood": False},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert "advertisement" in data["message"].lower()
|
||||
|
||||
def test_send_advertisement_with_flood(self, client_no_auth, mock_mqtt):
|
||||
"""Test sending an advertisement with flood enabled."""
|
||||
response = client_no_auth.post(
|
||||
"/api/v1/commands/send-advertisement",
|
||||
json={"flood": True},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert "flood=True" in data["message"]
|
||||
|
||||
def test_send_advertisement_requires_admin(self, client_with_auth):
|
||||
"""Test sending advertisement requires admin authentication."""
|
||||
response = client_with_auth.post(
|
||||
"/api/v1/commands/send-advertisement",
|
||||
json={"flood": False},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Tests for dashboard API routes."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestDashboardStats:
|
||||
"""Tests for GET /dashboard/stats endpoint."""
|
||||
|
||||
def test_get_stats_empty(self, client_no_auth):
|
||||
"""Test getting stats with empty database."""
|
||||
response = client_no_auth.get("/api/v1/dashboard/stats")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total_nodes"] == 0
|
||||
assert data["active_nodes"] == 0
|
||||
assert data["total_messages"] == 0
|
||||
assert data["messages_today"] == 0
|
||||
assert data["total_advertisements"] == 0
|
||||
assert data["channel_message_counts"] == {}
|
||||
|
||||
def test_get_stats_with_data(
|
||||
self, client_no_auth, sample_node, sample_message, sample_advertisement
|
||||
):
|
||||
"""Test getting stats with data in database."""
|
||||
response = client_no_auth.get("/api/v1/dashboard/stats")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total_nodes"] == 1
|
||||
assert data["active_nodes"] == 1 # Node was just created
|
||||
assert data["total_messages"] == 1
|
||||
assert data["total_advertisements"] == 1
|
||||
|
||||
|
||||
class TestDashboardHtml:
|
||||
"""Tests for GET /dashboard/dashboard endpoint."""
|
||||
|
||||
def test_dashboard_html_response(self, client_no_auth):
|
||||
"""Test dashboard returns HTML."""
|
||||
response = client_no_auth.get("/api/v1/dashboard/dashboard")
|
||||
assert response.status_code == 200
|
||||
assert "text/html" in response.headers["content-type"]
|
||||
assert "<!DOCTYPE html>" in response.text
|
||||
assert "MeshCore Hub Dashboard" in response.text
|
||||
|
||||
def test_dashboard_contains_stats(
|
||||
self, client_no_auth, sample_node, sample_message
|
||||
):
|
||||
"""Test dashboard HTML contains stat values."""
|
||||
response = client_no_auth.get("/api/v1/dashboard/dashboard")
|
||||
assert response.status_code == 200
|
||||
# Check that stats are present
|
||||
assert "Total Nodes" in response.text
|
||||
assert "Active Nodes" in response.text
|
||||
assert "Total Messages" in response.text
|
||||
|
||||
def test_dashboard_contains_recent_data(self, client_no_auth, sample_node):
|
||||
"""Test dashboard HTML contains recent nodes."""
|
||||
response = client_no_auth.get("/api/v1/dashboard/dashboard")
|
||||
assert response.status_code == 200
|
||||
assert "Recent Nodes" in response.text
|
||||
# The node name should appear in the table
|
||||
assert sample_node.name in response.text
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Tests for message API routes."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestListMessages:
|
||||
"""Tests for GET /messages endpoint."""
|
||||
|
||||
def test_list_messages_empty(self, client_no_auth):
|
||||
"""Test listing messages when database is empty."""
|
||||
response = client_no_auth.get("/api/v1/messages")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["items"] == []
|
||||
assert data["total"] == 0
|
||||
|
||||
def test_list_messages_with_data(self, client_no_auth, sample_message):
|
||||
"""Test listing messages with data in database."""
|
||||
response = client_no_auth.get("/api/v1/messages")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
assert data["total"] == 1
|
||||
assert data["items"][0]["text"] == sample_message.text
|
||||
assert data["items"][0]["message_type"] == sample_message.message_type
|
||||
|
||||
def test_list_messages_filter_by_type(self, client_no_auth, sample_message):
|
||||
"""Test filtering messages by type."""
|
||||
response = client_no_auth.get("/api/v1/messages?message_type=direct")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
|
||||
response = client_no_auth.get("/api/v1/messages?message_type=channel")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 0
|
||||
|
||||
def test_list_messages_pagination(self, client_no_auth):
|
||||
"""Test message list pagination parameters."""
|
||||
response = client_no_auth.get("/api/v1/messages?limit=25&offset=10")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["limit"] == 25
|
||||
assert data["offset"] == 10
|
||||
|
||||
|
||||
class TestGetMessage:
|
||||
"""Tests for GET /messages/{id} endpoint."""
|
||||
|
||||
def test_get_message_success(self, client_no_auth, sample_message):
|
||||
"""Test getting a specific message."""
|
||||
response = client_no_auth.get(f"/api/v1/messages/{sample_message.id}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["id"] == sample_message.id
|
||||
assert data["text"] == sample_message.text
|
||||
|
||||
def test_get_message_not_found(self, client_no_auth):
|
||||
"""Test getting a non-existent message."""
|
||||
response = client_no_auth.get("/api/v1/messages/nonexistent-id")
|
||||
assert response.status_code == 404
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Tests for node API routes."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestListNodes:
|
||||
"""Tests for GET /nodes endpoint."""
|
||||
|
||||
def test_list_nodes_empty(self, client_no_auth):
|
||||
"""Test listing nodes when database is empty."""
|
||||
response = client_no_auth.get("/api/v1/nodes")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["items"] == []
|
||||
assert data["total"] == 0
|
||||
|
||||
def test_list_nodes_with_data(self, client_no_auth, sample_node):
|
||||
"""Test listing nodes with data in database."""
|
||||
response = client_no_auth.get("/api/v1/nodes")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
assert data["total"] == 1
|
||||
assert data["items"][0]["public_key"] == sample_node.public_key
|
||||
assert data["items"][0]["name"] == sample_node.name
|
||||
|
||||
def test_list_nodes_pagination(self, client_no_auth, sample_node):
|
||||
"""Test node list pagination parameters."""
|
||||
response = client_no_auth.get("/api/v1/nodes?limit=10&offset=0")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["limit"] == 10
|
||||
assert data["offset"] == 0
|
||||
|
||||
def test_list_nodes_with_auth_required(self, client_with_auth):
|
||||
"""Test listing nodes requires auth when configured."""
|
||||
# Without auth header
|
||||
response = client_with_auth.get("/api/v1/nodes")
|
||||
assert response.status_code == 401
|
||||
|
||||
# With read key
|
||||
response = client_with_auth.get(
|
||||
"/api/v1/nodes",
|
||||
headers={"Authorization": "Bearer test-read-key"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class TestGetNode:
|
||||
"""Tests for GET /nodes/{public_key} endpoint."""
|
||||
|
||||
def test_get_node_success(self, client_no_auth, sample_node):
|
||||
"""Test getting a specific node."""
|
||||
response = client_no_auth.get(f"/api/v1/nodes/{sample_node.public_key}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["public_key"] == sample_node.public_key
|
||||
assert data["name"] == sample_node.name
|
||||
|
||||
def test_get_node_not_found(self, client_no_auth):
|
||||
"""Test getting a non-existent node."""
|
||||
response = client_no_auth.get("/api/v1/nodes/nonexistent123")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestNodeTags:
|
||||
"""Tests for node tag endpoints."""
|
||||
|
||||
def test_create_node_tag(self, client_no_auth, sample_node):
|
||||
"""Test creating a node tag."""
|
||||
response = client_no_auth.post(
|
||||
f"/api/v1/nodes/{sample_node.public_key}/tags",
|
||||
json={"key": "location", "value": "building-a"},
|
||||
)
|
||||
assert response.status_code == 201 # Created
|
||||
data = response.json()
|
||||
assert data["key"] == "location"
|
||||
assert data["value"] == "building-a"
|
||||
|
||||
def test_get_node_tag(self, client_no_auth, sample_node, sample_node_tag):
|
||||
"""Test getting a specific node tag."""
|
||||
response = client_no_auth.get(
|
||||
f"/api/v1/nodes/{sample_node.public_key}/tags/{sample_node_tag.key}"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["key"] == sample_node_tag.key
|
||||
assert data["value"] == sample_node_tag.value
|
||||
|
||||
def test_update_node_tag(self, client_no_auth, sample_node, sample_node_tag):
|
||||
"""Test updating a node tag."""
|
||||
response = client_no_auth.put(
|
||||
f"/api/v1/nodes/{sample_node.public_key}/tags/{sample_node_tag.key}",
|
||||
json={"value": "staging"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["value"] == "staging"
|
||||
|
||||
def test_delete_node_tag(self, client_no_auth, sample_node, sample_node_tag):
|
||||
"""Test deleting a node tag."""
|
||||
response = client_no_auth.delete(
|
||||
f"/api/v1/nodes/{sample_node.public_key}/tags/{sample_node_tag.key}"
|
||||
)
|
||||
assert response.status_code == 204 # No Content
|
||||
|
||||
# Verify it's deleted
|
||||
response = client_no_auth.get(
|
||||
f"/api/v1/nodes/{sample_node.public_key}/tags/{sample_node_tag.key}"
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_tag_crud_requires_admin(self, client_with_auth, sample_node):
|
||||
"""Test that tag CRUD operations require admin auth."""
|
||||
# Without auth
|
||||
response = client_with_auth.post(
|
||||
f"/api/v1/nodes/{sample_node.public_key}/tags",
|
||||
json={"key": "test", "value": "test"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
# With read key (not admin)
|
||||
response = client_with_auth.post(
|
||||
f"/api/v1/nodes/{sample_node.public_key}/tags",
|
||||
json={"key": "test", "value": "test"},
|
||||
headers={"Authorization": "Bearer test-read-key"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
# With admin key
|
||||
response = client_with_auth.post(
|
||||
f"/api/v1/nodes/{sample_node.public_key}/tags",
|
||||
json={"key": "test", "value": "test"},
|
||||
headers={"Authorization": "Bearer test-admin-key"},
|
||||
)
|
||||
assert response.status_code == 201 # Created
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Tests for telemetry API routes."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestListTelemetry:
|
||||
"""Tests for GET /telemetry endpoint."""
|
||||
|
||||
def test_list_telemetry_empty(self, client_no_auth):
|
||||
"""Test listing telemetry when database is empty."""
|
||||
response = client_no_auth.get("/api/v1/telemetry")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["items"] == []
|
||||
assert data["total"] == 0
|
||||
|
||||
def test_list_telemetry_with_data(self, client_no_auth, sample_telemetry):
|
||||
"""Test listing telemetry with data in database."""
|
||||
response = client_no_auth.get("/api/v1/telemetry")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
assert data["total"] == 1
|
||||
assert data["items"][0]["node_public_key"] == sample_telemetry.node_public_key
|
||||
assert data["items"][0]["parsed_data"] == sample_telemetry.parsed_data
|
||||
|
||||
def test_list_telemetry_filter_by_node(self, client_no_auth, sample_telemetry):
|
||||
"""Test filtering telemetry by node public key."""
|
||||
response = client_no_auth.get(
|
||||
f"/api/v1/telemetry?node_public_key={sample_telemetry.node_public_key}"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
|
||||
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
|
||||
|
||||
|
||||
class TestGetTelemetry:
|
||||
"""Tests for GET /telemetry/{id} endpoint."""
|
||||
|
||||
def test_get_telemetry_success(self, client_no_auth, sample_telemetry):
|
||||
"""Test getting a specific telemetry record."""
|
||||
response = client_no_auth.get(f"/api/v1/telemetry/{sample_telemetry.id}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["id"] == sample_telemetry.id
|
||||
assert data["node_public_key"] == sample_telemetry.node_public_key
|
||||
|
||||
def test_get_telemetry_not_found(self, client_no_auth):
|
||||
"""Test getting a non-existent telemetry record."""
|
||||
response = client_no_auth.get("/api/v1/telemetry/nonexistent-id")
|
||||
assert response.status_code == 404
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Tests for trace path API routes."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestListTracePaths:
|
||||
"""Tests for GET /trace-paths endpoint."""
|
||||
|
||||
def test_list_trace_paths_empty(self, client_no_auth):
|
||||
"""Test listing trace paths when database is empty."""
|
||||
response = client_no_auth.get("/api/v1/trace-paths")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["items"] == []
|
||||
assert data["total"] == 0
|
||||
|
||||
def test_list_trace_paths_with_data(self, client_no_auth, sample_trace_path):
|
||||
"""Test listing trace paths with data in database."""
|
||||
response = client_no_auth.get("/api/v1/trace-paths")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
assert data["total"] == 1
|
||||
assert data["items"][0]["path_hashes"] == sample_trace_path.path_hashes
|
||||
assert data["items"][0]["hop_count"] == sample_trace_path.hop_count
|
||||
|
||||
|
||||
class TestGetTracePath:
|
||||
"""Tests for GET /trace-paths/{id} endpoint."""
|
||||
|
||||
def test_get_trace_path_success(self, client_no_auth, sample_trace_path):
|
||||
"""Test getting a specific trace path."""
|
||||
response = client_no_auth.get(f"/api/v1/trace-paths/{sample_trace_path.id}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["id"] == sample_trace_path.id
|
||||
assert data["path_hashes"] == sample_trace_path.path_hashes
|
||||
|
||||
def test_get_trace_path_not_found(self, client_no_auth):
|
||||
"""Test getting a non-existent trace path."""
|
||||
response = client_no_auth.get("/api/v1/trace-paths/nonexistent-id")
|
||||
assert response.status_code == 404
|
||||
@@ -0,0 +1 @@
|
||||
"""Collector component tests."""
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Fixtures for collector component tests."""
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from meshcore_hub.common.database import DatabaseManager
|
||||
from meshcore_hub.common.models import Base
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_manager():
|
||||
"""Create an in-memory database manager for testing."""
|
||||
manager = DatabaseManager("sqlite:///:memory:")
|
||||
manager.create_tables()
|
||||
yield manager
|
||||
manager.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_session(db_manager):
|
||||
"""Create a database session for testing."""
|
||||
session = db_manager.get_session()
|
||||
yield session
|
||||
session.close()
|
||||
@@ -0,0 +1 @@
|
||||
"""Event handler tests."""
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Tests for advertisement handler."""
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from meshcore_hub.common.models import Advertisement, Node
|
||||
from meshcore_hub.collector.handlers.advertisement import handle_advertisement
|
||||
|
||||
|
||||
class TestHandleAdvertisement:
|
||||
"""Tests for handle_advertisement."""
|
||||
|
||||
def test_creates_new_node(self, db_manager, db_session):
|
||||
"""Test that new nodes are created."""
|
||||
payload = {
|
||||
"public_key": "a" * 64,
|
||||
"name": "TestNode",
|
||||
"adv_type": "chat",
|
||||
"flags": 218,
|
||||
}
|
||||
|
||||
handle_advertisement("b" * 64, "advertisement", payload, db_manager)
|
||||
|
||||
# Check node was created
|
||||
node = db_session.execute(
|
||||
select(Node).where(Node.public_key == "a" * 64)
|
||||
).scalar_one_or_none()
|
||||
|
||||
assert node is not None
|
||||
assert node.name == "TestNode"
|
||||
assert node.adv_type == "chat"
|
||||
assert node.flags == 218
|
||||
|
||||
def test_updates_existing_node(self, db_manager, db_session):
|
||||
"""Test that existing nodes are updated."""
|
||||
# Create initial node
|
||||
node = Node(public_key="a" * 64, name="OldName", adv_type="repeater")
|
||||
db_session.add(node)
|
||||
db_session.commit()
|
||||
|
||||
# Handle advertisement with new data
|
||||
payload = {
|
||||
"public_key": "a" * 64,
|
||||
"name": "NewName",
|
||||
"adv_type": "chat",
|
||||
"flags": 100,
|
||||
}
|
||||
|
||||
handle_advertisement("b" * 64, "advertisement", payload, db_manager)
|
||||
|
||||
# Refresh node
|
||||
db_session.refresh(node)
|
||||
|
||||
assert node.name == "NewName"
|
||||
assert node.adv_type == "chat"
|
||||
assert node.flags == 100
|
||||
|
||||
def test_creates_advertisement_record(self, db_manager, db_session):
|
||||
"""Test that advertisement records are created."""
|
||||
payload = {
|
||||
"public_key": "a" * 64,
|
||||
"name": "TestNode",
|
||||
"adv_type": "chat",
|
||||
}
|
||||
|
||||
handle_advertisement("b" * 64, "advertisement", payload, db_manager)
|
||||
|
||||
# Check advertisement was created
|
||||
ad = db_session.execute(select(Advertisement)).scalar_one_or_none()
|
||||
|
||||
assert ad is not None
|
||||
assert ad.public_key == "a" * 64
|
||||
assert ad.name == "TestNode"
|
||||
|
||||
def test_handles_missing_public_key(self, db_manager, db_session):
|
||||
"""Test that missing public_key is handled gracefully."""
|
||||
payload = {
|
||||
"name": "TestNode",
|
||||
"adv_type": "chat",
|
||||
}
|
||||
|
||||
# Should not raise
|
||||
handle_advertisement("b" * 64, "advertisement", payload, db_manager)
|
||||
|
||||
# No advertisement should be created
|
||||
ads = db_session.execute(select(Advertisement)).scalars().all()
|
||||
assert len(ads) == 0
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Tests for message handlers."""
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from meshcore_hub.common.models import Message, Node
|
||||
from meshcore_hub.collector.handlers.message import (
|
||||
handle_contact_message,
|
||||
handle_channel_message,
|
||||
)
|
||||
|
||||
|
||||
class TestHandleContactMessage:
|
||||
"""Tests for handle_contact_message."""
|
||||
|
||||
def test_creates_contact_message(self, db_manager, db_session):
|
||||
"""Test that contact messages are stored."""
|
||||
payload = {
|
||||
"pubkey_prefix": "01ab2186c4d5",
|
||||
"text": "Hello World!",
|
||||
"path_len": 3,
|
||||
"SNR": 15.5,
|
||||
}
|
||||
|
||||
handle_contact_message("a" * 64, "contact_msg_recv", payload, db_manager)
|
||||
|
||||
# Check message was created
|
||||
msg = db_session.execute(select(Message)).scalar_one_or_none()
|
||||
|
||||
assert msg is not None
|
||||
assert msg.message_type == "contact"
|
||||
assert msg.pubkey_prefix == "01ab2186c4d5"
|
||||
assert msg.text == "Hello World!"
|
||||
assert msg.path_len == 3
|
||||
assert msg.snr == 15.5
|
||||
|
||||
def test_handles_missing_text(self, db_manager, db_session):
|
||||
"""Test that missing text is handled gracefully."""
|
||||
payload = {
|
||||
"pubkey_prefix": "01ab2186c4d5",
|
||||
"path_len": 3,
|
||||
}
|
||||
|
||||
handle_contact_message("a" * 64, "contact_msg_recv", payload, db_manager)
|
||||
|
||||
# No message should be created
|
||||
msgs = db_session.execute(select(Message)).scalars().all()
|
||||
assert len(msgs) == 0
|
||||
|
||||
|
||||
class TestHandleChannelMessage:
|
||||
"""Tests for handle_channel_message."""
|
||||
|
||||
def test_creates_channel_message(self, db_manager, db_session):
|
||||
"""Test that channel messages are stored."""
|
||||
payload = {
|
||||
"channel_idx": 4,
|
||||
"text": "Channel broadcast",
|
||||
"path_len": 10,
|
||||
"SNR": 8.5,
|
||||
}
|
||||
|
||||
handle_channel_message("a" * 64, "channel_msg_recv", payload, db_manager)
|
||||
|
||||
# Check message was created
|
||||
msg = db_session.execute(select(Message)).scalar_one_or_none()
|
||||
|
||||
assert msg is not None
|
||||
assert msg.message_type == "channel"
|
||||
assert msg.channel_idx == 4
|
||||
assert msg.text == "Channel broadcast"
|
||||
assert msg.path_len == 10
|
||||
assert msg.snr == 8.5
|
||||
|
||||
def test_creates_receiver_node_if_needed(self, db_manager, db_session):
|
||||
"""Test that receiver node is created if it doesn't exist."""
|
||||
payload = {
|
||||
"channel_idx": 4,
|
||||
"text": "Test message",
|
||||
}
|
||||
|
||||
handle_channel_message("a" * 64, "channel_msg_recv", payload, db_manager)
|
||||
|
||||
# Check receiver node was created
|
||||
node = db_session.execute(
|
||||
select(Node).where(Node.public_key == "a" * 64)
|
||||
).scalar_one_or_none()
|
||||
|
||||
assert node is not None
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Tests for telemetry handler."""
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from meshcore_hub.common.models import Node, Telemetry
|
||||
from meshcore_hub.collector.handlers.telemetry import handle_telemetry
|
||||
|
||||
|
||||
class TestHandleTelemetry:
|
||||
"""Tests for handle_telemetry."""
|
||||
|
||||
def test_creates_telemetry_record(self, db_manager, db_session):
|
||||
"""Test that telemetry records are stored."""
|
||||
payload = {
|
||||
"node_public_key": "b" * 64,
|
||||
"parsed_data": {
|
||||
"temperature": 22.5,
|
||||
"humidity": 65,
|
||||
"battery": 3.8,
|
||||
},
|
||||
}
|
||||
|
||||
handle_telemetry("a" * 64, "telemetry_response", payload, db_manager)
|
||||
|
||||
# Check telemetry was created
|
||||
telemetry = db_session.execute(select(Telemetry)).scalar_one_or_none()
|
||||
|
||||
assert telemetry is not None
|
||||
assert telemetry.node_public_key == "b" * 64
|
||||
assert telemetry.parsed_data["temperature"] == 22.5
|
||||
assert telemetry.parsed_data["humidity"] == 65
|
||||
assert telemetry.parsed_data["battery"] == 3.8
|
||||
|
||||
def test_creates_reporting_node(self, db_manager, db_session):
|
||||
"""Test that reporting node is created if needed."""
|
||||
payload = {
|
||||
"node_public_key": "b" * 64,
|
||||
"parsed_data": {"temperature": 20.0},
|
||||
}
|
||||
|
||||
handle_telemetry("a" * 64, "telemetry_response", payload, db_manager)
|
||||
|
||||
# Check node was created
|
||||
node = db_session.execute(
|
||||
select(Node).where(Node.public_key == "b" * 64)
|
||||
).scalar_one_or_none()
|
||||
|
||||
assert node is not None
|
||||
|
||||
def test_handles_missing_node_public_key(self, db_manager, db_session):
|
||||
"""Test that missing node_public_key is handled gracefully."""
|
||||
payload = {
|
||||
"parsed_data": {"temperature": 20.0},
|
||||
}
|
||||
|
||||
handle_telemetry("a" * 64, "telemetry_response", payload, db_manager)
|
||||
|
||||
# No telemetry should be created
|
||||
records = db_session.execute(select(Telemetry)).scalars().all()
|
||||
assert len(records) == 0
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user