Merge pull request #1 from ipnet-mesh/claude/start-project-development-01H6a5BA6hagqsB7JvCBjcdn

Initial Project Setup
This commit is contained in:
JingleManSweep
2025-12-03 00:48:53 +00:00
committed by GitHub
111 changed files with 11635 additions and 461 deletions
+77
View File
@@ -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
+16
View File
@@ -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
+5
View File
@@ -205,3 +205,8 @@ cython_debug/
marimo/_static/
marimo/_lsp/
__marimo__/
# MeshCore Hub specific
*.db
meshcore.db
members.json
+38
View File
@@ -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"]
+53 -2
View File
@@ -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/)
+1 -1
View File
@@ -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
+460 -458
View File
File diff suppressed because it is too large Load Diff
+87
View File
@@ -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
+85
View File
@@ -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()
+26
View File
@@ -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
View File
@@ -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__.:",
]
+3
View File
@@ -0,0 +1,3 @@
"""MeshCore Hub - Python monorepo for managing MeshCore mesh networks."""
__version__ = "0.1.0"
+180
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
"""REST API component for querying data and sending commands."""
+123
View File
@@ -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
+137
View File
@@ -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)]
+157
View File
@@ -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)
+73
View File
@@ -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)]
+28
View File
@@ -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)
+149
View File
@@ -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)}",
)
+237
View File
@@ -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)
+83
View File
@@ -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)
+131
View File
@@ -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()
+68
View File
@@ -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)
+71
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
"""Collector component for storing MeshCore events from MQTT."""
+94
View File
@@ -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}"
)
+230
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
"""Common utilities, models and configurations used by all components."""
+192
View File
@@ -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()
+186
View File
@@ -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()
+126
View File
@@ -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})>"
+71
View File
@@ -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})>"
+88
View File
@@ -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]}...)>"
+75
View File
@@ -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})>"
+365
View File
@@ -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)",
)
+261
View File
@@ -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")
+189
View File
@@ -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",
)
+101
View File
@@ -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")
+1
View File
@@ -0,0 +1 @@
"""Interface component for MeshCore device communication."""
+367
View File
@@ -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,
)
+565
View File
@@ -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)
+424
View File
@@ -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)
+257
View File
@@ -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()
+329
View File
@@ -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()
View File
+1
View File
@@ -0,0 +1 @@
"""Web dashboard component for visualizing MeshCore network."""
+149
View File
@@ -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__,
}
+195
View File
@@ -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)
+23
View File
@@ -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"]
+18
View File
@@ -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)
+77
View File
@@ -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],
},
})
+59
View File
@@ -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)
+68
View File
@@ -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)
+41
View File
@@ -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)
+113
View File
@@ -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)
+120
View File
@@ -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>
+118
View File
@@ -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 %}
+103
View File
@@ -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: '&copy; <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 %}
+148
View File
@@ -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 %}
+138
View File
@@ -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 %}
+1
View File
@@ -0,0 +1 @@
"""MeshCore Hub test suite."""
+29
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
"""API component tests."""
+262
View File
@@ -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
+62
View File
@@ -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
+106
View File
@@ -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
+118
View File
@@ -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
+62
View File
@@ -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
+62
View File
@@ -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
+136
View File
@@ -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
+58
View File
@@ -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
+42
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
"""Collector component tests."""
+25
View File
@@ -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