Compare commits

20 Commits

Author SHA1 Message Date
Louis King
92b0b883e6 More website improvements 2025-12-08 17:07:39 +00:00
Louis King
9e621c0029 Fixed test 2025-12-08 16:42:13 +00:00
Louis King
a251f3a09f Added map to node detail page, made title consistent with emoji 2025-12-08 16:37:53 +00:00
Louis King
0fdedfe5ba Tidied Advert/Node search 2025-12-08 16:22:08 +00:00
Louis King
243a3e8521 Added truncate CLI command 2025-12-08 15:54:32 +00:00
JingleManSweep
b24a6f0894 Merge pull request #54 from ipnet-mesh/feature/more-filters
Fixed Member model
2025-12-08 15:15:04 +00:00
Louis King
57f51c741c Fixed Member model 2025-12-08 15:13:24 +00:00
Louis King
65b8418af4 Fixed last seen issue 2025-12-08 00:15:25 +00:00
JingleManSweep
89ceee8741 Merge pull request #51 from ipnet-mesh/feat/sync-receiver-contacts-on-advert
Receiver nodes now sync contacts to MQTT on every advert received
2025-12-07 23:36:11 +00:00
Louis King
64ec1a7135 Receiver nodes now sync contacts to MQTT on every advert received 2025-12-07 23:34:33 +00:00
JingleManSweep
3d632a94b1 Merge pull request #50 from ipnet-mesh/feat/remove-friendly-name
Removed friendly name support and tidied tags
2025-12-07 23:03:39 +00:00
Louis King
fbd29ff78e Removed friendly name support and tidied tags 2025-12-07 23:02:19 +00:00
Louis King
86bff07f7d Removed contrib 2025-12-07 22:22:32 +00:00
Louis King
3abd5ce3ea Updates 2025-12-07 22:18:16 +00:00
Louis King
0bf2086f16 Added screenshot 2025-12-07 22:05:34 +00:00
Louis King
40dc6647e9 Updates 2025-12-07 22:02:42 +00:00
Louis King
f4e95a254e Fixes 2025-12-07 22:00:46 +00:00
Louis King
ba43be9e62 Fixes 2025-12-07 21:58:42 +00:00
JingleManSweep
5b22ab29cf Merge pull request #49 from ipnet-mesh/fix/version-display
Fixed version display
2025-12-07 21:56:26 +00:00
Louis King
278d102064 Fixed version display 2025-12-07 21:55:10 +00:00
51 changed files with 1504 additions and 967 deletions

View File

@@ -2,9 +2,9 @@ name: CI
on:
push:
branches: [main, master]
branches: [main]
pull_request:
branches: [main, master]
branches: [main]
jobs:
lint:

View File

@@ -2,11 +2,9 @@ name: Docker
on:
push:
branches: [main, master]
branches: [main]
tags:
- "v*"
pull_request:
branches: [main, master]
env:
REGISTRY: ghcr.io
@@ -59,13 +57,13 @@ jobs:
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
SETUPTOOLS_SCM_PRETEND_VERSION=${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || format('0.0.0.dev0+g{0}', github.sha) }}
BUILD_VERSION=${{ github.ref_name }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Test Docker image
if: github.event_name == 'pull_request'
run: |
docker build -t meshcore-hub-test --build-arg SETUPTOOLS_SCM_PRETEND_VERSION=0.0.0.dev0+g${{ github.sha }} -f Dockerfile .
docker build -t meshcore-hub-test --build-arg BUILD_VERSION=${{ github.ref_name }} -f Dockerfile .
docker run --rm meshcore-hub-test --version
docker run --rm meshcore-hub-test --help

1
.gitignore vendored
View File

@@ -218,4 +218,3 @@ __marimo__/
# MeshCore Hub specific
*.db
meshcore.db
src/meshcore_hub/_version.py

View File

@@ -624,6 +624,20 @@ 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
4. Sync the device's contact database
### Contact Sync Behavior
The receiver syncs the device's contact database in two scenarios:
1. **Startup**: Initial sync when receiver starts
2. **Advertisement Events**: Automatic sync triggered whenever an advertisement is received from the mesh
Since advertisements are typically received every ~20 minutes, contact sync happens automatically without manual intervention. Each contact from the device is published individually to MQTT:
- Topic: `{prefix}/{device_public_key}/event/contact`
- Payload: `{public_key, adv_name, type}`
This ensures the collector's database stays current with all nodes discovered on the mesh network.
## References

View File

@@ -21,9 +21,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# Build argument for version (set via CI or manually)
ARG SETUPTOOLS_SCM_PRETEND_VERSION=0.0.0+docker
# Copy project files
WORKDIR /app
COPY pyproject.toml README.md ./
@@ -31,9 +28,13 @@ COPY src/ ./src/
COPY alembic/ ./alembic/
COPY alembic.ini ./
# Install the package with version from build arg
RUN pip install --upgrade pip && \
SETUPTOOLS_SCM_PRETEND_VERSION=${SETUPTOOLS_SCM_PRETEND_VERSION} pip install .
# Build argument for version (set via CI or manually)
ARG BUILD_VERSION=dev
# Set version in _version.py and install the package
RUN sed -i "s|__version__ = \"dev\"|__version__ = \"${BUILD_VERSION}\"|" src/meshcore_hub/_version.py && \
pip install --upgrade pip && \
pip install .
# =============================================================================
# Stage 2: Runtime - Final production image

View File

@@ -2,6 +2,8 @@
Python 3.11+ platform for managing and orchestrating MeshCore mesh networks.
![MeshCore Hub Web Dashboard](docs/images/web.png)
## Overview
MeshCore Hub provides a complete solution for monitoring, collecting, and interacting with MeshCore mesh networks. It consists of multiple components that work together:
@@ -318,7 +320,7 @@ All components are configured via environment variables. Create a `.env` file or
#### Webhook Configuration
The collector can forward events to external HTTP endpoints:
The collector can forward certain events to external HTTP endpoints:
| Variable | Default | Description |
|----------|---------|-------------|

View File

@@ -0,0 +1,39 @@
"""Make Node.last_seen nullable
Revision ID: 0b944542ccd8
Revises: 005
Create Date: 2025-12-08 00:07:49.891245+00:00
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "0b944542ccd8"
down_revision: Union[str, None] = "005"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
# Make Node.last_seen nullable since nodes from contact sync
# haven't actually been "seen" on the mesh yet
with op.batch_alter_table("nodes", schema=None) as batch_op:
batch_op.alter_column("last_seen", existing_type=sa.DATETIME(), nullable=True)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
# Revert Node.last_seen to non-nullable
# Note: This will fail if there are NULL values in last_seen
with op.batch_alter_table("nodes", schema=None) as batch_op:
batch_op.alter_column("last_seen", existing_type=sa.DATETIME(), nullable=False)
# ### end Alembic commands ###

View File

@@ -0,0 +1,111 @@
"""Add member_id field to members table
Revision ID: 03b9b2451bd9
Revises: 0b944542ccd8
Create Date: 2025-12-08 14:34:30.337799+00:00
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "03b9b2451bd9"
down_revision: Union[str, None] = "0b944542ccd8"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("advertisements", schema=None) as batch_op:
batch_op.drop_index(batch_op.f("ix_advertisements_event_hash_unique"))
batch_op.create_unique_constraint(
"uq_advertisements_event_hash", ["event_hash"]
)
with op.batch_alter_table("members", schema=None) as batch_op:
# Add member_id as nullable first to handle existing data
batch_op.add_column(
sa.Column("member_id", sa.String(length=100), nullable=True)
)
# Generate member_id for existing members based on their name
# Convert name to lowercase and replace spaces with underscores
connection = op.get_bind()
connection.execute(
sa.text(
"UPDATE members SET member_id = LOWER(REPLACE(name, ' ', '_')) WHERE member_id IS NULL"
)
)
with op.batch_alter_table("members", schema=None) as batch_op:
# Now make it non-nullable and add unique index
batch_op.alter_column("member_id", nullable=False)
batch_op.drop_index(batch_op.f("ix_members_name"))
batch_op.create_index(
batch_op.f("ix_members_member_id"), ["member_id"], unique=True
)
with op.batch_alter_table("messages", schema=None) as batch_op:
batch_op.drop_index(batch_op.f("ix_messages_event_hash_unique"))
batch_op.create_unique_constraint("uq_messages_event_hash", ["event_hash"])
with op.batch_alter_table("nodes", schema=None) as batch_op:
batch_op.drop_index(batch_op.f("ix_nodes_public_key"))
batch_op.create_index(
batch_op.f("ix_nodes_public_key"), ["public_key"], unique=True
)
with op.batch_alter_table("telemetry", schema=None) as batch_op:
batch_op.drop_index(batch_op.f("ix_telemetry_event_hash_unique"))
batch_op.create_unique_constraint("uq_telemetry_event_hash", ["event_hash"])
with op.batch_alter_table("trace_paths", schema=None) as batch_op:
batch_op.drop_index(batch_op.f("ix_trace_paths_event_hash_unique"))
batch_op.create_unique_constraint("uq_trace_paths_event_hash", ["event_hash"])
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("trace_paths", schema=None) as batch_op:
batch_op.drop_constraint("uq_trace_paths_event_hash", type_="unique")
batch_op.create_index(
batch_op.f("ix_trace_paths_event_hash_unique"), ["event_hash"], unique=1
)
with op.batch_alter_table("telemetry", schema=None) as batch_op:
batch_op.drop_constraint("uq_telemetry_event_hash", type_="unique")
batch_op.create_index(
batch_op.f("ix_telemetry_event_hash_unique"), ["event_hash"], unique=1
)
with op.batch_alter_table("nodes", schema=None) as batch_op:
batch_op.drop_index(batch_op.f("ix_nodes_public_key"))
batch_op.create_index(
batch_op.f("ix_nodes_public_key"), ["public_key"], unique=False
)
with op.batch_alter_table("messages", schema=None) as batch_op:
batch_op.drop_constraint("uq_messages_event_hash", type_="unique")
batch_op.create_index(
batch_op.f("ix_messages_event_hash_unique"), ["event_hash"], unique=1
)
with op.batch_alter_table("members", schema=None) as batch_op:
batch_op.drop_index(batch_op.f("ix_members_member_id"))
batch_op.create_index(batch_op.f("ix_members_name"), ["name"], unique=False)
batch_op.drop_column("member_id")
with op.batch_alter_table("advertisements", schema=None) as batch_op:
batch_op.drop_constraint("uq_advertisements_event_hash", type_="unique")
batch_op.create_index(
batch_op.f("ix_advertisements_event_hash_unique"), ["event_hash"], unique=1
)
# ### end Alembic commands ###

View File

@@ -0,0 +1,57 @@
"""Remove member_nodes table
Revision ID: aa1162502616
Revises: 03b9b2451bd9
Create Date: 2025-12-08 15:04:37.260923+00:00
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "aa1162502616"
down_revision: Union[str, None] = "03b9b2451bd9"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Drop the member_nodes table
# Nodes are now associated with members via a 'member_id' tag on the node
op.drop_table("member_nodes")
def downgrade() -> None:
# Recreate the member_nodes table if needed for rollback
op.create_table(
"member_nodes",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("member_id", sa.String(length=36), nullable=False),
sa.Column("public_key", sa.String(length=64), nullable=False),
sa.Column("node_role", sa.String(length=50), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["member_id"],
["members.id"],
name=op.f("fk_member_nodes_member_id_members"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_member_nodes")),
)
op.create_index(
op.f("ix_member_nodes_member_id"), "member_nodes", ["member_id"], unique=False
)
op.create_index(
op.f("ix_member_nodes_public_key"), "member_nodes", ["public_key"], unique=False
)
op.create_index(
"ix_member_nodes_member_public_key",
"member_nodes",
["member_id", "public_key"],
unique=False,
)

View File

@@ -1,70 +0,0 @@
# IPNet Network Members
members:
- name: Louis
callsign: Louis
role: admin
description: IPNet Founder
nodes:
# ip2-rep01
- public_key: 2337484665ced7e210007e9fd9db98ced0a24a6eab8b4cbe3a06b3a1cea33ca1
node_role: repeater
# ip2-rep02
- public_key: 8cb01fff1afc099055af418ce5fc5e60384df9ff763c25dd7e6a5e0922e8df90
node_role: repeater
# ip2-rep03
- public_key: 5b565df747913358e24d890b2227de9c35d09763746b6ec326c15ebbf9b8be3b
node_role: repeater
# ip2-sol01
- public_key: 87eb9487a1a4351e986e55627b2d09c4da61f94d080eaf4d7129caef89886e25
node_role: repeater
# personal chat node
- public_key: c6e0d85528b4b5d7f53aa7dded2b7e0b9c8f8a5c00acfaad47476ef5f3c7dc47
node_role: chat
- name: Mark
callsign: Mark
role: member
description: IPNet Member
nodes:
- public_key: 22309435fbd9dd1f14870a1895dc854779f6b2af72b08542f6105d264a493ebe
node_role: repeater
- public_key: 9135986b83815ada92883358435cc6528c7db60cb647f9b6547739a1ce5eb1c8
node_role: repeater
- public_key: 2a4f89e766dfa1758e35a69962c1f6d352b206a5e3562a589155a3ebfe7fc2bb
node_role: repeater
- public_key: e790b73b2d6e377dd0f575c847f3ef42232f610eb9a19af57083fc4f647309ac
node_role: repeater
- public_key: d3c20d962f7384c111fbafad6fbc1c1dc0e5c3ce802fb3ee11020e8d8207ed3a
node_role: repeater
- public_key: cd4497d3c2fa2a1df565ae9d1cb8bf87aeaded34059421b63abeaec203f9eda8
node_role: repeater
- public_key: b00ce9d218203e96d8557a4d59e06f5de59bbc4dcc4df9c870079d2cb8b5bd80
node_role: repeater
- public_key: 69fb8431e7ab307513797544fab99ce53ce24c46ec2d3a11767fe70f2ca37b23
node_role: repeater
- name: CCZ
callsign: CCZ
role: member
nodes:
- public_key: e334ec5475789d542ed9e692fbeef7444a371fcc05adcbda1f47ba6a3191b459
node_role: repeater
- public_key: cc15fb33e98f2e098a543f516f770dc3061a1a6b30f79b84780663bf68ae6b53
node_role: repeater
- public_key: 20ed75ffc0f9777951716bb3d308d7f041fd2ad32fe2e998e600d0361e1fe2ac
node_role: repeater
description: IPNet Member
- name: Walshie
callsign: Walshie86
role: member
description: IPNet Member
nodes:
- public_key: bd7b5ac75f660675b39f368e1dbb6d1dbcefd8bd7a170e21a942954f67c8bf52
node_role: repeater
- public_key: 9cf300c40112ea34d0a59858270948b27ab6cd87e840de338f3ca782c17537b2
node_role: repeater
- name: Craig
callsign: M7XCN
role: member
description: IPNet Member
nodes:
- public_key: 8accb6d0189ccaffb745ba54793e7fe3edd515edb45554325d957e48c1b9f3b3
node_role: repeater

View File

@@ -1,247 +0,0 @@
# IPNet Network Node Tags
# Uses YAML primitives: numbers, booleans, and strings are auto-detected
# IP2 Area Nodes
2337484665ced7e210007e9fd9db98ced0a24a6eab8b4cbe3a06b3a1cea33ca1:
friendly_name: IP2 Repeater 1
node_id: ip2-rep01.ipnt.uk
member_id: louis
area: IP2
lat: 52.0357627
lon: 1.132079
location_description: Fountains Road
hardware: Heltec V3
antenna: Paradar 8.5dBi Omni
elevation: 31
role: infra
8cb01fff1afc099055af418ce5fc5e60384df9ff763c25dd7e6a5e0922e8df90:
friendly_name: IP2 Repeater 2
node_id: ip2-rep02.ipnt.uk
member_id: louis
area: IP2
lat: 52.0390682
lon: 1.1304141
location_description: Belstead Road
hardware: Heltec V3
antenna: McGill 6dBi Omni
elevation: 44
role: infra
5b565df747913358e24d890b2227de9c35d09763746b6ec326c15ebbf9b8be3b:
friendly_name: IP2 Repeater 3
node_id: ip2-rep03.ipnt.uk
member_id: louis
area: IP2
lat: 52.046356
lon: 1.134661
location_description: Birkfield Drive
hardware: Heltec V3
antenna: Paradar 8.5dBi Omni
elevation: 52
role: infra
780d0939f90b22d3bd7cbedcaf4e8d468a12c01886ab24b8cfa11eab2f5516c5:
friendly_name: IP2 Integration 1
node_id: ip2-int01.ipnt.uk
member_id: louis
area: IP2
lat: 52.0354539
lon: 1.1295338
location_description: Fountains Road
hardware: Heltec V3
antenna: Generic 5dBi Whip
elevation: 25
role: infra
30121dc60362c633c457ffa18f49b3e1d6823402c33709f32d7df70612250b96:
friendly_name: MeshBot
node_id: bot.ipnt.uk
member_id: louis
area: IP2
lat: 52.0354539
lon: 1.1295338
location_description: Fountains Road
hardware: Heltec V3
antenna: Generic 5dBi Whip
elevation: 25
role: infra
# IP3 Area Nodes
9135986b83815ada92883358435cc6528c7db60cb647f9b6547739a1ce5eb1c8:
friendly_name: IP3 Repeater 1
node_id: ip3-rep01.ipnt.uk
member_id: markab
area: IP3
lat: 52.045803
lon: 1.204416
location_description: Brokehall
hardware: Heltec V3
antenna: Paradar 8.5dBi Omni
elevation: 42
role: infra
e334ec5475789d542ed9e692fbeef7444a371fcc05adcbda1f47ba6a3191b459:
friendly_name: IP3 Repeater 2
node_id: ip3-rep02.ipnt.uk
member_id: ccz
area: IP3
lat: 52.03297
lon: 1.17543
location_description: Morland Road Allotments
hardware: Heltec T114
antenna: Unknown
elevation: 39
role: infra
cc15fb33e98f2e098a543f516f770dc3061a1a6b30f79b84780663bf68ae6b53:
friendly_name: IP3 Repeater 3
node_id: ip3-rep03.ipnt.uk
member_id: ccz
area: IP3
lat: 52.04499
lon: 1.18149
location_description: Hatfield Road
hardware: Heltec V3
antenna: Unknown
elevation: 39
role: infra
22309435fbd9dd1f14870a1895dc854779f6b2af72b08542f6105d264a493ebe:
friendly_name: IP3 Integration 1
node_id: ip3-int01.ipnt.uk
member_id: markab
area: IP3
lat: 52.045773
lon: 1.212808
location_description: Brokehall
hardware: Heltec V3
antenna: Generic 3dBi Whip
elevation: 37
role: infra
2a4f89e766dfa1758e35a69962c1f6d352b206a5e3562a589155a3ebfe7fc2bb:
friendly_name: IP3 Repeater 4
node_id: ip3-rep04.ipnt.uk
member_id: markab
area: IP3
lat: 52.046383
lon: 1.174542
location_description: Holywells
hardware: Sensecap Solar
antenna: Paradar 6.5dbi Omni
elevation: 21
role: infra
e790b73b2d6e377dd0f575c847f3ef42232f610eb9a19af57083fc4f647309ac:
friendly_name: IP3 Repeater 5
node_id: ip3-rep05.ipnt.uk
member_id: markab
area: IP3
lat: 52.05252
lon: 1.17034
location_description: Back Hamlet
hardware: Heltec T114
antenna: Paradar 6.5dBi Omni
elevation: 38
role: infra
20ed75ffc0f9777951716bb3d308d7f041fd2ad32fe2e998e600d0361e1fe2ac:
friendly_name: IP3 Repeater 6
node_id: ip3-rep06.ipnt.uk
member_id: ccz
area: IP3
lat: 52.04893
lon: 1.18965
location_description: Dover Road
hardware: Unknown
antenna: Generic 5dBi Whip
elevation: 38
role: infra
69fb8431e7ab307513797544fab99ce53ce24c46ec2d3a11767fe70f2ca37b23:
friendly_name: IP3 Test Repeater 1
node_id: ip3-tst01.ipnt.uk
member_id: markab
area: IP3
lat: 52.041869
lon: 1.204789
location_description: Brokehall
hardware: Station G2
antenna: McGill 10dBi Panel
elevation: 37
role: infra
ebb16e6c328b3f2fa5bc46a8c3efc8e9ad1960ee49a76dfa85abddbf7911e2ca:
friendly_name: IP3 Integration 1
node_id: ip3-int01.ipnt.uk
member_id: markab
area: IP3
location_description: Morland Road Allotments
role: infra
# IP4 Area Nodes
c464e725906e956b0cc113f4eb3ae320db66209d0b7cf1924e258b0f86147cae:
friendly_name: IP4 Repeater 1
node_id: ip4-rep01.ipnt.uk
member_id: markab
area: IP4
lat: 52.052445
lon: 1.156882
location_description: Wine Rack
hardware: Heltec T114
antenna: Generic 5dbi Whip
elevation: 50
role: infra
cd4497d3c2fa2a1df565ae9d1cb8bf87aeaded34059421b63abeaec203f9eda8:
friendly_name: IP4 Repeater 2
node_id: ip4-rep02.ipnt.uk
member_id: markab
area: IP4
lat: 52.06217
lon: 1.18332
location_description: Rushmere Road
hardware: Heltec V3
antenna: Paradar 5dbi Whip
elevation: 35
role: infra
8accb6d0189ccaffb745ba54793e7fe3edd515edb45554325d957e48c1b9f3b3:
friendly_name: IP4 Repeater 3
node_id: ip4-rep03.ipnt.uk
member_id: craig
area: IP4
lat: 52.058
lon: 1.165
location_description: IP4 Area
hardware: Heltec v3
antenna: Generic Whip
elevation: 30
role: infra
# IP8 Area Nodes
bd7b5ac75f660675b39f368e1dbb6d1dbcefd8bd7a170e21a942954f67c8bf52:
friendly_name: IP8 Repeater 1
node_id: rep01.ip8.ipnt.uk
member_id: walshie86
area: IP8
lat: 52.033684
lon: 1.118384
location_description: Grove Hill
hardware: Heltec V3
antenna: McGill 3dBi Omni
elevation: 13
role: infra
9cf300c40112ea34d0a59858270948b27ab6cd87e840de338f3ca782c17537b2:
friendly_name: IP8 Repeater 2
node_id: rep02.ip8.ipnt.uk
member_id: walshie86
area: IP8
lat: 52.035648
lon: 1.073271
location_description: Washbrook
hardware: Sensecap Solar
elevation: 13
role: infra

View File

@@ -138,6 +138,9 @@ services:
- all
- core
restart: unless-stopped
depends_on:
seed:
condition: service_completed_successfully
volumes:
- ${DATA_HOME:-./data}:/data
- ${SEED_HOME:-./seed}:/seed
@@ -193,6 +196,8 @@ services:
- core
restart: unless-stopped
depends_on:
seed:
condition: service_completed_successfully
collector:
condition: service_started
ports:
@@ -274,7 +279,9 @@ services:
container_name: meshcore-db-migrate
profiles:
- all
- core
- migrate
restart: "no"
volumes:
# Mount data directory (uses collector/meshcore.db)
- ${DATA_HOME:-./data}:/data
@@ -295,7 +302,12 @@ services:
container_name: meshcore-seed
profiles:
- all
- core
- seed
restart: "no"
depends_on:
db-migrate:
condition: service_completed_successfully
volumes:
# Mount data directory for database (read-write)
- ${DATA_HOME:-./data}:/data

BIN
docs/images/web.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

View File

@@ -1,16 +1,14 @@
# Example members seed file
# Each member can have multiple nodes with different roles (chat, repeater, etc.)
# Note: Nodes are associated with members via a 'member_id' tag on the node.
# Use node_tags.yaml to set member_id tags on nodes.
members:
- name: Example Member
- member_id: example_member
name: Example Member
callsign: N0CALL
role: Network Operator
description: Example member entry with multiple nodes
nodes:
- public_key: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
node_role: chat
- public_key: fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210
node_role: repeater
- name: Simple Member
description: Example network operator member
- member_id: simple_member
name: Simple Member
callsign: N0CALL2
role: Observer
description: Member without any nodes
description: Example observer member

View File

@@ -1,10 +1,10 @@
[build-system]
requires = ["setuptools>=68.0", "wheel", "setuptools-scm>=8.0"]
requires = ["setuptools>=68.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "meshcore-hub"
dynamic = ["version"]
version = "0.0.0"
description = "Python monorepo for managing and orchestrating MeshCore mesh networks"
readme = "README.md"
license = {text = "GPL-3.0-or-later"}
@@ -68,10 +68,6 @@ Documentation = "https://github.com/ipnet-mesh/meshcore-hub#readme"
Repository = "https://github.com/ipnet-mesh/meshcore-hub"
Issues = "https://github.com/ipnet-mesh/meshcore-hub/issues"
[tool.setuptools_scm]
version_file = "src/meshcore_hub/_version.py"
fallback_version = "0.0.0+unknown"
[tool.setuptools.packages.find]
where = ["src"]

View File

@@ -1,5 +1,5 @@
"""MeshCore Hub - Python monorepo for managing MeshCore mesh networks."""
from meshcore_hub._version import __version__, __version_tuple__
from meshcore_hub._version import __version__
__all__ = ["__version__", "__version_tuple__"]
__all__ = ["__version__"]

View File

@@ -0,0 +1,8 @@
"""MeshCore Hub version information.
This file contains the version string for the package.
It can be overridden at build time by setting BUILD_VERSION environment variable.
"""
__version__ = "dev"
__all__ = ["__version__"]

View File

@@ -4,7 +4,7 @@ from datetime import datetime
from typing import Optional
from fastapi import APIRouter, HTTPException, Query
from sqlalchemy import func, select
from sqlalchemy import func, or_, select
from sqlalchemy.orm import aliased, selectinload
from meshcore_hub.api.auth import RequireRead
@@ -19,12 +19,12 @@ from meshcore_hub.common.schemas.messages import (
router = APIRouter()
def _get_friendly_name(node: Optional[Node]) -> Optional[str]:
"""Extract friendly_name tag from a node's tags."""
def _get_tag_name(node: Optional[Node]) -> Optional[str]:
"""Extract name tag from a node's tags."""
if not node or not node.tags:
return None
for tag in node.tags:
if tag.key == "friendly_name":
if tag.key == "name":
return tag.value
return None
@@ -57,15 +57,15 @@ def _fetch_receivers_for_events(
receivers_by_hash: dict[str, list[ReceiverInfo]] = {}
node_ids = [r.node_id for r in results]
friendly_names: dict[str, str] = {}
tag_names: dict[str, str] = {}
if node_ids:
fn_query = (
tag_query = (
select(NodeTag.node_id, NodeTag.value)
.where(NodeTag.node_id.in_(node_ids))
.where(NodeTag.key == "friendly_name")
.where(NodeTag.key == "name")
)
for node_id, value in session.execute(fn_query).all():
friendly_names[node_id] = value
for node_id, value in session.execute(tag_query).all():
tag_names[node_id] = value
for row in results:
if row.event_hash not in receivers_by_hash:
@@ -76,7 +76,7 @@ def _fetch_receivers_for_events(
node_id=row.node_id,
public_key=row.public_key,
name=row.name,
friendly_name=friendly_names.get(row.node_id),
tag_name=tag_names.get(row.node_id),
snr=row.snr,
received_at=row.received_at,
)
@@ -89,6 +89,9 @@ def _fetch_receivers_for_events(
async def list_advertisements(
_: RequireRead,
session: DbSession,
search: Optional[str] = Query(
None, description="Search in name tag, node name, or public key"
),
public_key: Optional[str] = Query(None, description="Filter by public key"),
received_by: Optional[str] = Query(
None, description="Filter by receiver node public key"
@@ -118,6 +121,22 @@ async def list_advertisements(
.outerjoin(SourceNode, Advertisement.node_id == SourceNode.id)
)
if search:
# Search in public key, advertisement name, node name, or name tag
search_pattern = f"%{search}%"
query = query.where(
or_(
Advertisement.public_key.ilike(search_pattern),
Advertisement.name.ilike(search_pattern),
SourceNode.name.ilike(search_pattern),
SourceNode.id.in_(
select(NodeTag.node_id).where(
NodeTag.key == "name", NodeTag.value.ilike(search_pattern)
)
),
)
)
if public_key:
query = query.where(Advertisement.public_key == public_key)
@@ -173,11 +192,11 @@ async def list_advertisements(
data = {
"received_by": row.receiver_pk,
"receiver_name": row.receiver_name,
"receiver_friendly_name": _get_friendly_name(receiver_node),
"receiver_tag_name": _get_tag_name(receiver_node),
"public_key": adv.public_key,
"name": adv.name,
"node_name": row.source_name,
"node_friendly_name": _get_friendly_name(source_node),
"node_tag_name": _get_tag_name(source_node),
"adv_type": adv.adv_type or row.source_adv_type,
"flags": adv.flags,
"received_at": adv.received_at,
@@ -255,11 +274,11 @@ async def get_advertisement(
data = {
"received_by": result.receiver_pk,
"receiver_name": result.receiver_name,
"receiver_friendly_name": _get_friendly_name(receiver_node),
"receiver_tag_name": _get_tag_name(receiver_node),
"public_key": adv.public_key,
"name": adv.name,
"node_name": result.source_name,
"node_friendly_name": _get_friendly_name(source_node),
"node_tag_name": _get_tag_name(source_node),
"adv_type": adv.adv_type or result.source_adv_type,
"flags": adv.flags,
"received_at": adv.received_at,

View File

@@ -31,6 +31,7 @@ async def get_stats(
now = datetime.now(timezone.utc)
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
yesterday = now - timedelta(days=1)
seven_days_ago = now - timedelta(days=7)
# Total nodes
total_nodes = session.execute(select(func.count()).select_from(Node)).scalar() or 0
@@ -73,6 +74,26 @@ async def get_stats(
or 0
)
# Advertisements in last 7 days
advertisements_7d = (
session.execute(
select(func.count())
.select_from(Advertisement)
.where(Advertisement.received_at >= seven_days_ago)
).scalar()
or 0
)
# Messages in last 7 days
messages_7d = (
session.execute(
select(func.count())
.select_from(Message)
.where(Message.received_at >= seven_days_ago)
).scalar()
or 0
)
# Recent advertisements (last 10)
recent_ads = (
session.execute(
@@ -82,11 +103,11 @@ async def get_stats(
.all()
)
# Get node names, adv_types, and friendly_name tags for the advertised nodes
# Get node names, adv_types, and name tags for the advertised nodes
ad_public_keys = [ad.public_key for ad in recent_ads]
node_names: dict[str, str] = {}
node_adv_types: dict[str, str] = {}
friendly_names: dict[str, str] = {}
tag_names: dict[str, str] = {}
if ad_public_keys:
# Get node names and adv_types from Node table
node_query = select(Node.public_key, Node.name, Node.adv_type).where(
@@ -98,21 +119,21 @@ async def get_stats(
if adv_type:
node_adv_types[public_key] = adv_type
# Get friendly_name tags
friendly_name_query = (
# Get name tags
tag_name_query = (
select(Node.public_key, NodeTag.value)
.join(NodeTag, Node.id == NodeTag.node_id)
.where(Node.public_key.in_(ad_public_keys))
.where(NodeTag.key == "friendly_name")
.where(NodeTag.key == "name")
)
for public_key, value in session.execute(friendly_name_query).all():
friendly_names[public_key] = value
for public_key, value in session.execute(tag_name_query).all():
tag_names[public_key] = value
recent_advertisements = [
RecentAdvertisement(
public_key=ad.public_key,
name=ad.name or node_names.get(ad.public_key),
friendly_name=friendly_names.get(ad.public_key),
tag_name=tag_names.get(ad.public_key),
adv_type=ad.adv_type or node_adv_types.get(ad.public_key),
received_at=ad.received_at,
)
@@ -146,7 +167,7 @@ async def get_stats(
# Look up sender names for these messages
msg_prefixes = [m.pubkey_prefix for m in channel_msgs if m.pubkey_prefix]
msg_sender_names: dict[str, str] = {}
msg_friendly_names: dict[str, str] = {}
msg_tag_names: dict[str, str] = {}
if msg_prefixes:
for prefix in set(msg_prefixes):
sender_node_query = select(Node.public_key, Node.name).where(
@@ -156,14 +177,14 @@ async def get_stats(
if name:
msg_sender_names[public_key[:12]] = name
sender_friendly_query = (
sender_tag_query = (
select(Node.public_key, NodeTag.value)
.join(NodeTag, Node.id == NodeTag.node_id)
.where(Node.public_key.startswith(prefix))
.where(NodeTag.key == "friendly_name")
.where(NodeTag.key == "name")
)
for public_key, value in session.execute(sender_friendly_query).all():
msg_friendly_names[public_key[:12]] = value
for public_key, value in session.execute(sender_tag_query).all():
msg_tag_names[public_key[:12]] = value
channel_messages[int(channel_idx)] = [
ChannelMessage(
@@ -171,8 +192,8 @@ async def get_stats(
sender_name=(
msg_sender_names.get(m.pubkey_prefix) if m.pubkey_prefix else None
),
sender_friendly_name=(
msg_friendly_names.get(m.pubkey_prefix) if m.pubkey_prefix else None
sender_tag_name=(
msg_tag_names.get(m.pubkey_prefix) if m.pubkey_prefix else None
),
pubkey_prefix=m.pubkey_prefix,
received_at=m.received_at,
@@ -185,8 +206,10 @@ async def get_stats(
active_nodes=active_nodes,
total_messages=total_messages,
messages_today=messages_today,
messages_7d=messages_7d,
total_advertisements=total_advertisements,
advertisements_24h=advertisements_24h,
advertisements_7d=advertisements_7d,
recent_advertisements=recent_advertisements,
channel_message_counts=channel_message_counts,
channel_messages=channel_messages,

View File

@@ -2,15 +2,13 @@
from fastapi import APIRouter, HTTPException, Query
from sqlalchemy import func, select
from sqlalchemy.orm import selectinload
from meshcore_hub.api.auth import RequireAdmin, RequireRead
from meshcore_hub.api.dependencies import DbSession
from meshcore_hub.common.models import Member, MemberNode, Node
from meshcore_hub.common.models import Member
from meshcore_hub.common.schemas.members import (
MemberCreate,
MemberList,
MemberNodeRead,
MemberRead,
MemberUpdate,
)
@@ -18,50 +16,6 @@ from meshcore_hub.common.schemas.members import (
router = APIRouter()
def _enrich_member_nodes(
member: Member, node_info: dict[str, dict]
) -> list[MemberNodeRead]:
"""Enrich member nodes with node details from the database.
Args:
member: The member with nodes to enrich
node_info: Dict mapping public_key to node details
Returns:
List of MemberNodeRead with node details populated
"""
enriched_nodes = []
for mn in member.nodes:
info = node_info.get(mn.public_key, {})
enriched_nodes.append(
MemberNodeRead(
public_key=mn.public_key,
node_role=mn.node_role,
created_at=mn.created_at,
updated_at=mn.updated_at,
node_name=info.get("name"),
node_adv_type=info.get("adv_type"),
friendly_name=info.get("friendly_name"),
)
)
return enriched_nodes
def _member_to_read(member: Member, node_info: dict[str, dict]) -> MemberRead:
"""Convert a Member model to MemberRead with enriched node data."""
return MemberRead(
id=member.id,
name=member.name,
callsign=member.callsign,
role=member.role,
description=member.description,
contact=member.contact,
nodes=_enrich_member_nodes(member, node_info),
created_at=member.created_at,
updated_at=member.updated_at,
)
@router.get("", response_model=MemberList)
async def list_members(
_: RequireRead,
@@ -74,45 +28,12 @@ async def list_members(
count_query = select(func.count()).select_from(Member)
total = session.execute(count_query).scalar() or 0
# Get members with nodes eagerly loaded
query = (
select(Member)
.options(selectinload(Member.nodes))
.order_by(Member.name)
.limit(limit)
.offset(offset)
)
# Get members ordered by name
query = select(Member).order_by(Member.name).limit(limit).offset(offset)
members = list(session.execute(query).scalars().all())
# Collect all public keys from member nodes
all_public_keys = set()
for m in members:
for mn in m.nodes:
all_public_keys.add(mn.public_key)
# Fetch node info for all public keys in one query
node_info: dict[str, dict] = {}
if all_public_keys:
node_query = (
select(Node)
.options(selectinload(Node.tags))
.where(Node.public_key.in_(all_public_keys))
)
nodes = session.execute(node_query).scalars().all()
for node in nodes:
friendly_name = None
for tag in node.tags:
if tag.key == "friendly_name":
friendly_name = tag.value
break
node_info[node.public_key] = {
"name": node.name,
"adv_type": node.adv_type,
"friendly_name": friendly_name,
}
return MemberList(
items=[_member_to_read(m, node_info) for m in members],
items=[MemberRead.model_validate(m) for m in members],
total=total,
limit=limit,
offset=offset,
@@ -126,37 +47,13 @@ async def get_member(
member_id: str,
) -> MemberRead:
"""Get a specific member by ID."""
query = (
select(Member).options(selectinload(Member.nodes)).where(Member.id == member_id)
)
query = select(Member).where(Member.id == member_id)
member = session.execute(query).scalar_one_or_none()
if not member:
raise HTTPException(status_code=404, detail="Member not found")
# Fetch node info for member's nodes
node_info: dict[str, dict] = {}
public_keys = [mn.public_key for mn in member.nodes]
if public_keys:
node_query = (
select(Node)
.options(selectinload(Node.tags))
.where(Node.public_key.in_(public_keys))
)
nodes = session.execute(node_query).scalars().all()
for node in nodes:
friendly_name = None
for tag in node.tags:
if tag.key == "friendly_name":
friendly_name = tag.value
break
node_info[node.public_key] = {
"name": node.name,
"adv_type": node.adv_type,
"friendly_name": friendly_name,
}
return _member_to_read(member, node_info)
return MemberRead.model_validate(member)
@router.post("", response_model=MemberRead, status_code=201)
@@ -166,8 +63,18 @@ async def create_member(
member: MemberCreate,
) -> MemberRead:
"""Create a new member."""
# Check if member_id already exists
query = select(Member).where(Member.member_id == member.member_id)
existing = session.execute(query).scalar_one_or_none()
if existing:
raise HTTPException(
status_code=400,
detail=f"Member with member_id '{member.member_id}' already exists",
)
# Create member
new_member = Member(
member_id=member.member_id,
name=member.name,
callsign=member.callsign,
role=member.role,
@@ -175,18 +82,6 @@ async def create_member(
contact=member.contact,
)
session.add(new_member)
session.flush() # Get the ID for the member
# Add nodes if provided
if member.nodes:
for node_data in member.nodes:
node = MemberNode(
member_id=new_member.id,
public_key=node_data.public_key.lower(),
node_role=node_data.node_role,
)
session.add(node)
session.commit()
session.refresh(new_member)
@@ -201,15 +96,25 @@ async def update_member(
member: MemberUpdate,
) -> MemberRead:
"""Update a member."""
query = (
select(Member).options(selectinload(Member.nodes)).where(Member.id == member_id)
)
query = select(Member).where(Member.id == member_id)
existing = session.execute(query).scalar_one_or_none()
if not existing:
raise HTTPException(status_code=404, detail="Member not found")
# Update fields
if member.member_id is not None:
# Check if new member_id is already taken by another member
check_query = select(Member).where(
Member.member_id == member.member_id, Member.id != member_id
)
collision = session.execute(check_query).scalar_one_or_none()
if collision:
raise HTTPException(
status_code=400,
detail=f"Member with member_id '{member.member_id}' already exists",
)
existing.member_id = member.member_id
if member.name is not None:
existing.name = member.name
if member.callsign is not None:
@@ -221,20 +126,6 @@ async def update_member(
if member.contact is not None:
existing.contact = member.contact
# Update nodes if provided (replaces existing nodes)
if member.nodes is not None:
# Clear existing nodes
existing.nodes.clear()
# Add new nodes
for node_data in member.nodes:
node = MemberNode(
member_id=existing.id,
public_key=node_data.public_key.lower(),
node_role=node_data.node_role,
)
existing.nodes.append(node)
session.commit()
session.refresh(existing)

View File

@@ -15,12 +15,12 @@ from meshcore_hub.common.schemas.messages import MessageList, MessageRead, Recei
router = APIRouter()
def _get_friendly_name(node: Optional[Node]) -> Optional[str]:
"""Extract friendly_name tag from a node's tags."""
def _get_tag_name(node: Optional[Node]) -> Optional[str]:
"""Extract name tag from a node's tags."""
if not node or not node.tags:
return None
for tag in node.tags:
if tag.key == "friendly_name":
if tag.key == "name":
return tag.value
return None
@@ -64,17 +64,17 @@ def _fetch_receivers_for_events(
# Group by event_hash
receivers_by_hash: dict[str, list[ReceiverInfo]] = {}
# Get friendly names for receiver nodes
# Get tag names for receiver nodes
node_ids = [r.node_id for r in results]
friendly_names: dict[str, str] = {}
tag_names: dict[str, str] = {}
if node_ids:
fn_query = (
tag_query = (
select(NodeTag.node_id, NodeTag.value)
.where(NodeTag.node_id.in_(node_ids))
.where(NodeTag.key == "friendly_name")
.where(NodeTag.key == "name")
)
for node_id, value in session.execute(fn_query).all():
friendly_names[node_id] = value
for node_id, value in session.execute(tag_query).all():
tag_names[node_id] = value
for row in results:
if row.event_hash not in receivers_by_hash:
@@ -85,7 +85,7 @@ def _fetch_receivers_for_events(
node_id=row.node_id,
public_key=row.public_key,
name=row.name,
friendly_name=friendly_names.get(row.node_id),
tag_name=tag_names.get(row.node_id),
snr=row.snr,
received_at=row.received_at,
)
@@ -153,10 +153,10 @@ async def list_messages(
# Execute
results = session.execute(query).all()
# Look up sender names and friendly_names for senders with pubkey_prefix
# Look up sender names and tag names for senders with pubkey_prefix
pubkey_prefixes = [r[0].pubkey_prefix for r in results if r[0].pubkey_prefix]
sender_names: dict[str, str] = {}
friendly_names: dict[str, str] = {}
sender_tag_names: dict[str, str] = {}
if pubkey_prefixes:
# Find nodes whose public_key starts with any of these prefixes
for prefix in set(pubkey_prefixes):
@@ -168,15 +168,15 @@ async def list_messages(
if name:
sender_names[public_key[:12]] = name
# Get friendly_name tag
friendly_name_query = (
# Get name tag
tag_name_query = (
select(Node.public_key, NodeTag.value)
.join(NodeTag, Node.id == NodeTag.node_id)
.where(Node.public_key.startswith(prefix))
.where(NodeTag.key == "friendly_name")
.where(NodeTag.key == "name")
)
for public_key, value in session.execute(friendly_name_query).all():
friendly_names[public_key[:12]] = value
for public_key, value in session.execute(tag_name_query).all():
sender_tag_names[public_key[:12]] = value
# Collect receiver node IDs to fetch tags
receiver_ids = set()
@@ -214,14 +214,14 @@ async def list_messages(
"receiver_node_id": m.receiver_node_id,
"received_by": receiver_pk,
"receiver_name": receiver_name,
"receiver_friendly_name": _get_friendly_name(receiver_node),
"receiver_tag_name": _get_tag_name(receiver_node),
"message_type": m.message_type,
"pubkey_prefix": m.pubkey_prefix,
"sender_name": (
sender_names.get(m.pubkey_prefix) if m.pubkey_prefix else None
),
"sender_friendly_name": (
friendly_names.get(m.pubkey_prefix) if m.pubkey_prefix else None
"sender_tag_name": (
sender_tag_names.get(m.pubkey_prefix) if m.pubkey_prefix else None
),
"channel_idx": m.channel_idx,
"text": m.text,

View File

@@ -3,11 +3,12 @@
from typing import Optional
from fastapi import APIRouter, HTTPException, Query
from sqlalchemy import func, select
from sqlalchemy import func, or_, select
from sqlalchemy.orm import selectinload
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.models import Node, NodeTag
from meshcore_hub.common.schemas.nodes import NodeList, NodeRead
router = APIRouter()
@@ -17,18 +18,31 @@ router = APIRouter()
async def list_nodes(
_: RequireRead,
session: DbSession,
search: Optional[str] = Query(None, description="Search in name or public key"),
search: Optional[str] = Query(
None, description="Search in name tag, node name, or public key"
),
adv_type: Optional[str] = Query(None, description="Filter by advertisement type"),
limit: int = Query(50, ge=1, le=500, 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)
# Build base query with tags loaded
query = select(Node).options(selectinload(Node.tags))
if search:
# Search in public key, node name, or name tag
# For name tag search, we need to join with NodeTag
search_pattern = f"%{search}%"
query = query.where(
(Node.name.ilike(f"%{search}%")) | (Node.public_key.ilike(f"%{search}%"))
or_(
Node.public_key.ilike(search_pattern),
Node.name.ilike(search_pattern),
Node.id.in_(
select(NodeTag.node_id).where(
NodeTag.key == "name", NodeTag.value.ilike(search_pattern)
)
),
)
)
if adv_type:
@@ -38,7 +52,7 @@ async def list_nodes(
count_query = select(func.count()).select_from(query.subquery())
total = session.execute(count_query).scalar() or 0
# Apply pagination
# Apply pagination and ordering
query = query.order_by(Node.last_seen.desc()).offset(offset).limit(limit)
# Execute

View File

@@ -170,8 +170,8 @@ def _run_collector_service(
) -> None:
"""Run the collector service.
On startup, automatically seeds the database from YAML files in seed_home
if they exist.
Note: Seed data import should be done using the 'meshcore-hub collector seed'
command or the dedicated seed container before starting the collector service.
Webhooks can be configured via environment variables:
- WEBHOOK_ADVERTISEMENT_URL: Webhook for advertisement events
@@ -193,31 +193,6 @@ def _run_collector_service(
click.echo(f"MQTT: {mqtt_host}:{mqtt_port} (prefix: {prefix})")
click.echo(f"Database: {database_url}")
# Initialize database (schema managed by Alembic migrations)
from meshcore_hub.common.database import DatabaseManager
db = DatabaseManager(database_url)
# Auto-seed from seed files on startup
click.echo("")
click.echo("Checking for seed files...")
seed_home_path = Path(seed_home)
node_tags_exists = (seed_home_path / "node_tags.yaml").exists()
members_exists = (seed_home_path / "members.yaml").exists()
if node_tags_exists or members_exists:
click.echo("Running seed import...")
_run_seed_import(
seed_home=seed_home,
db=db,
create_nodes=True,
verbose=True,
)
else:
click.echo(f"No seed files found in {seed_home}")
db.dispose()
# Load webhook configuration from settings
from meshcore_hub.collector.webhook import (
WebhookDispatcher,
@@ -383,8 +358,11 @@ def _run_seed_import(
file_path=str(node_tags_file),
db=db,
create_nodes=create_nodes,
clear_existing=True,
)
if verbose:
if stats["deleted"]:
click.echo(f" Deleted {stats['deleted']} existing tags")
click.echo(
f" Tags: {stats['created']} created, {stats['updated']} updated"
)
@@ -428,16 +406,24 @@ def _run_seed_import(
default=False,
help="Skip tags for nodes that don't exist (default: create nodes)",
)
@click.option(
"--clear-existing",
is_flag=True,
default=False,
help="Delete all existing tags before importing",
)
@click.pass_context
def import_tags_cmd(
ctx: click.Context,
file: str | None,
no_create_nodes: bool,
clear_existing: bool,
) -> None:
"""Import node tags from a YAML file.
Reads a YAML file containing tag definitions and upserts them
into the database. Existing tags are updated, new tags are created.
into the database. By default, existing tags are updated and new tags are created.
Use --clear-existing to delete all tags before importing.
FILE is the path to the YAML file containing tags.
If not provided, defaults to {SEED_HOME}/node_tags.yaml.
@@ -492,11 +478,14 @@ def import_tags_cmd(
file_path=tags_file,
db=db,
create_nodes=not no_create_nodes,
clear_existing=clear_existing,
)
# Report results
click.echo("")
click.echo("Import complete:")
if stats["deleted"]:
click.echo(f" Tags deleted: {stats['deleted']}")
click.echo(f" Total tags in file: {stats['total']}")
click.echo(f" Tags created: {stats['created']}")
click.echo(f" Tags updated: {stats['updated']}")
@@ -674,3 +663,212 @@ def cleanup_cmd(
db.dispose()
click.echo("")
click.echo("Cleanup complete." if not dry_run else "Dry run complete.")
@collector.command("truncate")
@click.option(
"--members",
is_flag=True,
default=False,
help="Truncate members table",
)
@click.option(
"--nodes",
is_flag=True,
default=False,
help="Truncate nodes table (also clears tags, advertisements, messages, telemetry, trace paths)",
)
@click.option(
"--messages",
is_flag=True,
default=False,
help="Truncate messages table",
)
@click.option(
"--advertisements",
is_flag=True,
default=False,
help="Truncate advertisements table",
)
@click.option(
"--telemetry",
is_flag=True,
default=False,
help="Truncate telemetry table",
)
@click.option(
"--trace-paths",
is_flag=True,
default=False,
help="Truncate trace_paths table",
)
@click.option(
"--event-logs",
is_flag=True,
default=False,
help="Truncate event_logs table",
)
@click.option(
"--all",
"truncate_all",
is_flag=True,
default=False,
help="Truncate ALL tables (use with caution!)",
)
@click.option(
"--yes",
is_flag=True,
default=False,
help="Skip confirmation prompt",
)
@click.pass_context
def truncate_cmd(
ctx: click.Context,
members: bool,
nodes: bool,
messages: bool,
advertisements: bool,
telemetry: bool,
trace_paths: bool,
event_logs: bool,
truncate_all: bool,
yes: bool,
) -> None:
"""Truncate (clear) data tables.
WARNING: This permanently deletes data! Use with caution.
Examples:
# Clear members table
meshcore-hub collector truncate --members
# Clear messages and advertisements
meshcore-hub collector truncate --messages --advertisements
# Clear everything (requires confirmation)
meshcore-hub collector truncate --all
Note: Clearing nodes also clears all related data (tags, advertisements,
messages, telemetry, trace paths) due to foreign key constraints.
"""
configure_logging(level=ctx.obj["log_level"])
# Determine what to truncate
if truncate_all:
tables_to_clear = {
"members": True,
"nodes": True,
"messages": True,
"advertisements": True,
"telemetry": True,
"trace_paths": True,
"event_logs": True,
}
else:
tables_to_clear = {
"members": members,
"nodes": nodes,
"messages": messages,
"advertisements": advertisements,
"telemetry": telemetry,
"trace_paths": trace_paths,
"event_logs": event_logs,
}
# Check if any tables selected
if not any(tables_to_clear.values()):
click.echo("No tables specified. Use --help to see available options.")
return
# Show what will be cleared
click.echo("Database: " + ctx.obj["database_url"])
click.echo("")
click.echo("The following tables will be PERMANENTLY CLEARED:")
for table, should_clear in tables_to_clear.items():
if should_clear:
click.echo(f" - {table}")
if tables_to_clear.get("nodes"):
click.echo("")
click.echo(
"WARNING: Clearing nodes will also clear all related data due to foreign keys:"
)
click.echo(" - node_tags")
click.echo(" - advertisements")
click.echo(" - messages")
click.echo(" - telemetry")
click.echo(" - trace_paths")
click.echo("")
# Confirm
if not yes:
if not click.confirm(
"Are you sure you want to permanently delete this data?", default=False
):
click.echo("Aborted.")
return
from meshcore_hub.common.database import DatabaseManager
from meshcore_hub.common.models import (
Advertisement,
EventLog,
Member,
Message,
Node,
NodeTag,
Telemetry,
TracePath,
)
from sqlalchemy import delete
from sqlalchemy.engine import CursorResult
db = DatabaseManager(ctx.obj["database_url"])
with db.session_scope() as session:
# Truncate in correct order to respect foreign keys
cleared: list[str] = []
# Clear members (no dependencies)
if tables_to_clear.get("members"):
result: CursorResult = session.execute(delete(Member)) # type: ignore
cleared.append(f"members: {result.rowcount} rows")
# Clear event-specific tables first (they depend on nodes)
if tables_to_clear.get("messages"):
result = session.execute(delete(Message)) # type: ignore
cleared.append(f"messages: {result.rowcount} rows")
if tables_to_clear.get("advertisements"):
result = session.execute(delete(Advertisement)) # type: ignore
cleared.append(f"advertisements: {result.rowcount} rows")
if tables_to_clear.get("telemetry"):
result = session.execute(delete(Telemetry)) # type: ignore
cleared.append(f"telemetry: {result.rowcount} rows")
if tables_to_clear.get("trace_paths"):
result = session.execute(delete(TracePath)) # type: ignore
cleared.append(f"trace_paths: {result.rowcount} rows")
if tables_to_clear.get("event_logs"):
result = session.execute(delete(EventLog)) # type: ignore
cleared.append(f"event_logs: {result.rowcount} rows")
# Clear nodes last (this will cascade delete tags and any remaining events)
if tables_to_clear.get("nodes"):
# Delete tags first (they depend on nodes)
tag_result: CursorResult = session.execute(delete(NodeTag)) # type: ignore
cleared.append(f"node_tags: {tag_result.rowcount} rows (cascade)")
# Delete nodes (will cascade to remaining related tables)
node_result: CursorResult = session.execute(delete(Node)) # type: ignore
cleared.append(f"nodes: {node_result.rowcount} rows")
db.dispose()
click.echo("")
click.echo("Truncate complete. Cleared:")
for item in cleared:
click.echo(f" - {item}")
click.echo("")

View File

@@ -73,15 +73,17 @@ def handle_contact(
node.name = name
if node_type and not node.adv_type:
node.adv_type = node_type
node.last_seen = now
# Do NOT update last_seen for contact sync - only advertisement events
# should update last_seen since that's when the node was actually seen
else:
# Create new node
# Create new node from contact database
# Set last_seen=None since we haven't actually seen this node advertise yet
node = Node(
public_key=contact_key,
name=name,
adv_type=node_type,
first_seen=now,
last_seen=now,
last_seen=None, # Will be set when we receive an advertisement
)
session.add(node)
logger.info(f"Created node from contact: {contact_key[:12]}... ({name})")

View File

@@ -5,41 +5,28 @@ from pathlib import Path
from typing import Any, Optional
import yaml
from pydantic import BaseModel, Field, field_validator
from pydantic import BaseModel, Field
from sqlalchemy import select
from meshcore_hub.common.database import DatabaseManager
from meshcore_hub.common.models import Member, MemberNode
from meshcore_hub.common.models import Member
logger = logging.getLogger(__name__)
class NodeData(BaseModel):
"""Schema for a node entry in the member import file."""
public_key: str = Field(..., min_length=64, max_length=64)
node_role: Optional[str] = Field(default=None, max_length=50)
@field_validator("public_key")
@classmethod
def validate_public_key(cls, v: str) -> str:
"""Validate and normalize public key."""
if len(v) != 64:
raise ValueError(f"public_key must be 64 characters, got {len(v)}")
if not all(c in "0123456789abcdefABCDEF" for c in v):
raise ValueError("public_key must be a valid hex string")
return v.lower()
class MemberData(BaseModel):
"""Schema for a member entry in the import file."""
"""Schema for a member entry in the import file.
Note: Nodes are associated with members via a 'member_id' tag on the node,
not through this schema.
"""
member_id: str = Field(..., min_length=1, max_length=100)
name: str = Field(..., min_length=1, max_length=255)
callsign: Optional[str] = Field(default=None, max_length=20)
role: Optional[str] = Field(default=None, max_length=100)
description: Optional[str] = Field(default=None)
contact: Optional[str] = Field(default=None, max_length=255)
nodes: Optional[list[NodeData]] = Field(default=None)
def load_members_file(file_path: str | Path) -> list[dict[str, Any]]:
@@ -48,20 +35,16 @@ def load_members_file(file_path: str | Path) -> list[dict[str, Any]]:
Supports two formats:
1. List of member objects:
- name: Member 1
- member_id: member1
name: Member 1
callsign: M1
nodes:
- public_key: abc123...
node_role: chat
2. Object with "members" key:
members:
- name: Member 1
- member_id: member1
name: Member 1
callsign: M1
nodes:
- public_key: abc123...
node_role: chat
Args:
file_path: Path to the members YAML file
@@ -96,6 +79,8 @@ def load_members_file(file_path: str | Path) -> list[dict[str, Any]]:
for i, member in enumerate(members_list):
if not isinstance(member, dict):
raise ValueError(f"Member at index {i} must be an object")
if "member_id" not in member:
raise ValueError(f"Member at index {i} must have a 'member_id' field")
if "name" not in member:
raise ValueError(f"Member at index {i} must have a 'name' field")
@@ -115,9 +100,11 @@ def import_members(
) -> dict[str, Any]:
"""Import members from a YAML file into the database.
Performs upsert operations based on name - existing members are updated,
new members are created. Nodes are synced (existing nodes removed and
replaced with new ones from the file).
Performs upsert operations based on member_id - existing members are updated,
new members are created.
Note: Nodes are associated with members via a 'member_id' tag on the node.
This import does not manage node associations.
Args:
file_path: Path to the members YAML file
@@ -149,14 +136,17 @@ def import_members(
with db.session_scope() as session:
for member_data in members_data:
try:
member_id = member_data["member_id"]
name = member_data["name"]
# Find existing member by name
query = select(Member).where(Member.name == name)
# Find existing member by member_id
query = select(Member).where(Member.member_id == member_id)
existing = session.execute(query).scalar_one_or_none()
if existing:
# Update existing member
if member_data.get("name") is not None:
existing.name = member_data["name"]
if member_data.get("callsign") is not None:
existing.callsign = member_data["callsign"]
if member_data.get("role") is not None:
@@ -166,25 +156,12 @@ def import_members(
if member_data.get("contact") is not None:
existing.contact = member_data["contact"]
# Sync nodes if provided
if member_data.get("nodes") is not None:
# Remove existing nodes
existing.nodes.clear()
# Add new nodes
for node_data in member_data["nodes"]:
node = MemberNode(
member_id=existing.id,
public_key=node_data["public_key"],
node_role=node_data.get("node_role"),
)
existing.nodes.append(node)
stats["updated"] += 1
logger.debug(f"Updated member: {name}")
logger.debug(f"Updated member: {member_id} ({name})")
else:
# Create new member
new_member = Member(
member_id=member_id,
name=name,
callsign=member_data.get("callsign"),
role=member_data.get("role"),
@@ -192,23 +169,12 @@ def import_members(
contact=member_data.get("contact"),
)
session.add(new_member)
session.flush() # Get the ID for the member
# Add nodes if provided
if member_data.get("nodes"):
for node_data in member_data["nodes"]:
node = MemberNode(
member_id=new_member.id,
public_key=node_data["public_key"],
node_role=node_data.get("node_role"),
)
session.add(node)
stats["created"] += 1
logger.debug(f"Created member: {name}")
logger.debug(f"Created member: {member_id} ({name})")
except Exception as e:
error_msg = f"Error processing member '{member_data.get('name', 'unknown')}': {e}"
error_msg = f"Error processing member '{member_data.get('member_id', 'unknown')}' ({member_data.get('name', 'unknown')}): {e}"
stats["errors"].append(error_msg)
logger.error(error_msg)

View File

@@ -7,7 +7,7 @@ from typing import Any
import yaml
from pydantic import BaseModel, Field, model_validator
from sqlalchemy import select
from sqlalchemy import delete, func, select
from meshcore_hub.common.database import DatabaseManager
from meshcore_hub.common.models import Node, NodeTag
@@ -151,16 +151,19 @@ def import_tags(
file_path: str | Path,
db: DatabaseManager,
create_nodes: bool = True,
clear_existing: bool = False,
) -> dict[str, Any]:
"""Import tags from a YAML file into the database.
Performs upsert operations - existing tags are updated, new tags are created.
Optionally clears all existing tags before import.
Args:
file_path: Path to the tags YAML file
db: Database manager instance
create_nodes: If True, create nodes that don't exist. If False, skip tags
for non-existent nodes.
clear_existing: If True, delete all existing tags before importing.
Returns:
Dictionary with import statistics:
@@ -169,6 +172,7 @@ def import_tags(
- updated: Number of existing tags updated
- skipped: Number of tags skipped (node not found and create_nodes=False)
- nodes_created: Number of new nodes created
- deleted: Number of existing tags deleted (if clear_existing=True)
- errors: List of error messages
"""
stats: dict[str, Any] = {
@@ -177,6 +181,7 @@ def import_tags(
"updated": 0,
"skipped": 0,
"nodes_created": 0,
"deleted": 0,
"errors": [],
}
@@ -194,6 +199,15 @@ def import_tags(
now = datetime.now(timezone.utc)
with db.session_scope() as session:
# Clear all existing tags if requested
if clear_existing:
delete_count = (
session.execute(select(func.count()).select_from(NodeTag)).scalar() or 0
)
session.execute(delete(NodeTag))
stats["deleted"] = delete_count
logger.info(f"Deleted {delete_count} existing tags")
# Cache nodes by public_key to reduce queries
node_cache: dict[str, Node] = {}
@@ -232,24 +246,8 @@ def import_tags(
tag_value = tag_data.get("value")
tag_type = tag_data.get("type", "string")
# Find or create tag
tag_query = select(NodeTag).where(
NodeTag.node_id == node.id,
NodeTag.key == tag_key,
)
existing_tag = session.execute(tag_query).scalar_one_or_none()
if existing_tag:
# Update existing tag
existing_tag.value = tag_value
existing_tag.value_type = tag_type
stats["updated"] += 1
logger.debug(
f"Updated tag {tag_key}={tag_value} "
f"for {public_key[:12]}..."
)
else:
# Create new tag
if clear_existing:
# When clearing, always create new tags
new_tag = NodeTag(
node_id=node.id,
key=tag_key,
@@ -262,6 +260,39 @@ def import_tags(
f"Created tag {tag_key}={tag_value} "
f"for {public_key[:12]}..."
)
else:
# Find or create tag
tag_query = select(NodeTag).where(
NodeTag.node_id == node.id,
NodeTag.key == tag_key,
)
existing_tag = session.execute(
tag_query
).scalar_one_or_none()
if existing_tag:
# Update existing tag
existing_tag.value = tag_value
existing_tag.value_type = tag_type
stats["updated"] += 1
logger.debug(
f"Updated tag {tag_key}={tag_value} "
f"for {public_key[:12]}..."
)
else:
# Create new tag
new_tag = NodeTag(
node_id=node.id,
key=tag_key,
value=tag_value,
value_type=tag_type,
)
session.add(new_tag)
stats["created"] += 1
logger.debug(
f"Created tag {tag_key}={tag_value} "
f"for {public_key[:12]}..."
)
except Exception as e:
error_msg = f"Error processing tag {tag_key} for {public_key[:12]}...: {e}"

View File

@@ -9,7 +9,6 @@ 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
from meshcore_hub.common.models.member import Member
from meshcore_hub.common.models.member_node import MemberNode
from meshcore_hub.common.models.event_receiver import EventReceiver, add_event_receiver
__all__ = [
@@ -23,7 +22,6 @@ __all__ = [
"Telemetry",
"EventLog",
"Member",
"MemberNode",
"EventReceiver",
"add_event_receiver",
]

View File

@@ -1,36 +1,39 @@
"""Member model for network member information."""
from typing import TYPE_CHECKING, Optional
from typing import Optional
from sqlalchemy import String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.orm import Mapped, mapped_column
from meshcore_hub.common.models.base import Base, TimestampMixin, UUIDMixin
if TYPE_CHECKING:
from meshcore_hub.common.models.member_node import MemberNode
class Member(Base, UUIDMixin, TimestampMixin):
"""Member model for network member information.
Stores information about network members/operators.
Members can have multiple associated nodes (chat, repeater, etc.).
Nodes are associated with members via a 'member_id' tag on the node.
Attributes:
id: UUID primary key
member_id: Unique member identifier (e.g., 'walshie86')
name: Member's display name
callsign: Amateur radio callsign (optional)
role: Member's role in the network (optional)
description: Additional description (optional)
contact: Contact information (optional)
nodes: List of associated MemberNode records
created_at: Record creation timestamp
updated_at: Record update timestamp
"""
__tablename__ = "members"
member_id: Mapped[str] = mapped_column(
String(100),
nullable=False,
unique=True,
index=True,
)
name: Mapped[str] = mapped_column(
String(255),
nullable=False,
@@ -52,11 +55,5 @@ class Member(Base, UUIDMixin, TimestampMixin):
nullable=True,
)
# Relationship to member nodes
nodes: Mapped[list["MemberNode"]] = relationship(
back_populates="member",
cascade="all, delete-orphan",
)
def __repr__(self) -> str:
return f"<Member(id={self.id}, name={self.name}, callsign={self.callsign})>"
return f"<Member(id={self.id}, member_id={self.member_id}, name={self.name}, callsign={self.callsign})>"

View File

@@ -1,56 +0,0 @@
"""MemberNode model for associating nodes with members."""
from typing import TYPE_CHECKING, Optional
from sqlalchemy import ForeignKey, String, Index
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.member import Member
class MemberNode(Base, UUIDMixin, TimestampMixin):
"""Association model linking members to their nodes.
A member can have multiple nodes (e.g., chat node, repeater).
Each node is identified by its public_key and has a role.
Attributes:
id: UUID primary key
member_id: Foreign key to the member
public_key: Node's public key (64-char hex)
node_role: Role of the node (e.g., 'chat', 'repeater')
created_at: Record creation timestamp
updated_at: Record update timestamp
"""
__tablename__ = "member_nodes"
member_id: Mapped[str] = mapped_column(
String(36),
ForeignKey("members.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
public_key: Mapped[str] = mapped_column(
String(64),
nullable=False,
index=True,
)
node_role: Mapped[Optional[str]] = mapped_column(
String(50),
nullable=True,
)
# Relationship back to member
member: Mapped["Member"] = relationship(back_populates="nodes")
# Composite index for efficient lookups
__table_args__ = (
Index("ix_member_nodes_member_public_key", "member_id", "public_key"),
)
def __repr__(self) -> str:
return f"<MemberNode(member_id={self.member_id}, public_key={self.public_key[:8]}..., role={self.node_role})>"

View File

@@ -52,10 +52,10 @@ class Node(Base, UUIDMixin, TimestampMixin):
default=utc_now,
nullable=False,
)
last_seen: Mapped[datetime] = mapped_column(
last_seen: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True),
default=utc_now,
nullable=False,
default=None,
nullable=True,
)
# Relationships

View File

@@ -6,46 +6,19 @@ from typing import Optional
from pydantic import BaseModel, Field
class MemberNodeCreate(BaseModel):
"""Schema for creating a member node association."""
public_key: str = Field(
...,
min_length=64,
max_length=64,
pattern=r"^[0-9a-fA-F]{64}$",
description="Node's public key (64-char hex)",
)
node_role: Optional[str] = Field(
default=None,
max_length=50,
description="Role of the node (e.g., 'chat', 'repeater')",
)
class MemberNodeRead(BaseModel):
"""Schema for reading a member node association."""
public_key: str = Field(..., description="Node's public key")
node_role: Optional[str] = Field(default=None, description="Role of the node")
created_at: datetime = Field(..., description="Creation timestamp")
updated_at: datetime = Field(..., description="Last update timestamp")
# Node details (populated from nodes table if available)
node_name: Optional[str] = Field(default=None, description="Node's name from DB")
node_adv_type: Optional[str] = Field(
default=None, description="Node's advertisement type"
)
friendly_name: Optional[str] = Field(
default=None, description="Node's friendly name tag"
)
class Config:
from_attributes = True
class MemberCreate(BaseModel):
"""Schema for creating a member."""
"""Schema for creating a member.
Note: Nodes are associated with members via a 'member_id' tag on the node,
not through this schema.
"""
member_id: str = Field(
...,
min_length=1,
max_length=100,
description="Unique member identifier (e.g., 'walshie86')",
)
name: str = Field(
...,
min_length=1,
@@ -71,15 +44,21 @@ class MemberCreate(BaseModel):
max_length=255,
description="Contact information",
)
nodes: Optional[list[MemberNodeCreate]] = Field(
default=None,
description="List of associated nodes",
)
class MemberUpdate(BaseModel):
"""Schema for updating a member."""
"""Schema for updating a member.
Note: Nodes are associated with members via a 'member_id' tag on the node,
not through this schema.
"""
member_id: Optional[str] = Field(
default=None,
min_length=1,
max_length=100,
description="Unique member identifier (e.g., 'walshie86')",
)
name: Optional[str] = Field(
default=None,
min_length=1,
@@ -105,22 +84,22 @@ class MemberUpdate(BaseModel):
max_length=255,
description="Contact information",
)
nodes: Optional[list[MemberNodeCreate]] = Field(
default=None,
description="List of associated nodes (replaces existing nodes)",
)
class MemberRead(BaseModel):
"""Schema for reading a member."""
"""Schema for reading a member.
Note: Nodes are associated with members via a 'member_id' tag on the node.
To find nodes for a member, query nodes with a 'member_id' tag matching this member.
"""
id: str = Field(..., description="Member UUID")
member_id: str = Field(..., description="Unique member identifier")
name: str = Field(..., description="Member's display name")
callsign: Optional[str] = Field(default=None, description="Amateur radio callsign")
role: Optional[str] = Field(default=None, description="Member's role")
description: Optional[str] = Field(default=None, description="Description")
contact: Optional[str] = Field(default=None, description="Contact information")
nodes: list[MemberNodeRead] = Field(default=[], description="Associated nodes")
created_at: datetime = Field(..., description="Creation timestamp")
updated_at: datetime = Field(..., description="Last update timestamp")

View File

@@ -12,9 +12,7 @@ class ReceiverInfo(BaseModel):
node_id: str = Field(..., description="Receiver node UUID")
public_key: str = Field(..., description="Receiver node public key")
name: Optional[str] = Field(default=None, description="Receiver node name")
friendly_name: Optional[str] = Field(
default=None, description="Receiver friendly name from tags"
)
tag_name: Optional[str] = Field(default=None, description="Receiver name from tags")
snr: Optional[float] = Field(
default=None, description="Signal-to-noise ratio at this receiver"
)
@@ -31,8 +29,8 @@ class MessageRead(BaseModel):
default=None, description="Receiving interface node public key"
)
receiver_name: Optional[str] = Field(default=None, description="Receiver node name")
receiver_friendly_name: Optional[str] = Field(
default=None, description="Receiver friendly name from tags"
receiver_tag_name: Optional[str] = Field(
default=None, description="Receiver name from tags"
)
message_type: str = Field(..., description="Message type (contact, channel)")
pubkey_prefix: Optional[str] = Field(
@@ -41,8 +39,8 @@ class MessageRead(BaseModel):
sender_name: Optional[str] = Field(
default=None, description="Sender's advertised node name"
)
sender_friendly_name: Optional[str] = Field(
default=None, description="Sender's friendly name from node tags"
sender_tag_name: Optional[str] = Field(
default=None, description="Sender's name from node tags"
)
channel_idx: Optional[int] = Field(default=None, description="Channel index")
text: str = Field(..., description="Message content")
@@ -110,16 +108,16 @@ class AdvertisementRead(BaseModel):
default=None, description="Receiving interface node public key"
)
receiver_name: Optional[str] = Field(default=None, description="Receiver node name")
receiver_friendly_name: Optional[str] = Field(
default=None, description="Receiver friendly name from tags"
receiver_tag_name: Optional[str] = Field(
default=None, description="Receiver name from tags"
)
public_key: str = Field(..., description="Advertised public key")
name: Optional[str] = Field(default=None, description="Advertised name")
node_name: Optional[str] = Field(
default=None, description="Node name from nodes table"
)
node_friendly_name: Optional[str] = Field(
default=None, description="Node friendly name from tags"
node_tag_name: Optional[str] = Field(
default=None, description="Node name from tags"
)
adv_type: Optional[str] = Field(default=None, description="Node type")
flags: Optional[int] = Field(default=None, description="Capability flags")
@@ -215,7 +213,7 @@ class RecentAdvertisement(BaseModel):
public_key: str = Field(..., description="Node public key")
name: Optional[str] = Field(default=None, description="Node name")
friendly_name: Optional[str] = Field(default=None, description="Friendly name tag")
tag_name: Optional[str] = Field(default=None, description="Name tag")
adv_type: Optional[str] = Field(default=None, description="Node type")
received_at: datetime = Field(..., description="When received")
@@ -225,8 +223,8 @@ class ChannelMessage(BaseModel):
text: str = Field(..., description="Message text")
sender_name: Optional[str] = Field(default=None, description="Sender name")
sender_friendly_name: Optional[str] = Field(
default=None, description="Sender friendly name"
sender_tag_name: Optional[str] = Field(
default=None, description="Sender name from tags"
)
pubkey_prefix: Optional[str] = Field(
default=None, description="Sender public key prefix"
@@ -241,10 +239,14 @@ class DashboardStats(BaseModel):
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")
messages_7d: int = Field(default=0, description="Messages received in last 7 days")
total_advertisements: int = Field(..., description="Total advertisements")
advertisements_24h: int = Field(
default=0, description="Advertisements received in last 24h"
)
advertisements_7d: int = Field(
default=0, description="Advertisements received in last 7 days"
)
recent_advertisements: list[RecentAdvertisement] = Field(
default_factory=list, description="Last 10 advertisements"
)

View File

@@ -59,7 +59,9 @@ class NodeRead(BaseModel):
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")
last_seen: Optional[datetime] = Field(
default=None, 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")
@@ -82,7 +84,7 @@ class NodeFilters(BaseModel):
search: Optional[str] = Field(
default=None,
description="Search in name or public key",
description="Search in name tag, node name, or public key",
)
adv_type: Optional[str] = Field(
default=None,

View File

@@ -193,11 +193,24 @@ class BaseMeshCoreDevice(ABC):
Triggers a CONTACTS event with all stored contacts from the device.
Note: This should only be called before the event loop is running.
Returns:
True if request was sent successfully
"""
pass
@abstractmethod
def schedule_get_contacts(self) -> bool:
"""Schedule a get_contacts request on the event loop.
This is safe to call from event handlers while the event loop is running.
Returns:
True if request was scheduled successfully
"""
pass
@abstractmethod
def run(self) -> None:
"""Run the device event loop (blocking)."""
@@ -567,7 +580,12 @@ class MeshCoreDevice(BaseMeshCoreDevice):
return False
def get_contacts(self) -> bool:
"""Fetch contacts from device contact database."""
"""Fetch contacts from device contact database.
Note: This method should only be called before the event loop is running
(e.g., during initialization). For calling during event processing,
use schedule_get_contacts() instead.
"""
if not self._connected or not self._mc:
logger.error("Cannot get contacts: not connected")
return False
@@ -584,6 +602,31 @@ class MeshCoreDevice(BaseMeshCoreDevice):
logger.error(f"Failed to get contacts: {e}")
return False
def schedule_get_contacts(self) -> bool:
"""Schedule a get_contacts request on the event loop.
This is safe to call from event handlers while the event loop is running.
The request is scheduled as a task on the event loop.
Returns:
True if request was scheduled, False if device not connected
"""
if not self._connected or not self._mc:
logger.error("Cannot get contacts: not connected")
return False
try:
async def _get_contacts() -> None:
await self._mc.commands.get_contacts()
asyncio.run_coroutine_threadsafe(_get_contacts(), self._loop)
logger.info("Scheduled contact sync request")
return True
except Exception as e:
logger.error(f"Failed to schedule get contacts: {e}")
return False
def run(self) -> None:
"""Run the device event loop."""
self._running = True

View File

@@ -292,7 +292,10 @@ class MockMeshCoreDevice(BaseMeshCoreDevice):
return True
def get_contacts(self) -> bool:
"""Fetch contacts from mock device contact database."""
"""Fetch contacts from mock device contact database.
Note: This should only be called before the event loop is running.
"""
if not self._connected:
logger.error("Cannot get contacts: not connected")
return False
@@ -318,6 +321,14 @@ class MockMeshCoreDevice(BaseMeshCoreDevice):
threading.Thread(target=send_contacts, daemon=True).start()
return True
def schedule_get_contacts(self) -> bool:
"""Schedule a get_contacts request.
For the mock device, this is the same as get_contacts() since we
don't have a real async event loop. The contacts are sent via a thread.
"""
return self.get_contacts()
def run(self) -> None:
"""Run the mock device event loop."""
self._running = True

View File

@@ -144,9 +144,24 @@ class Receiver:
logger.debug(f"Published {event_name} event to MQTT")
# Trigger contact sync on advertisements
if event_type == EventType.ADVERTISEMENT:
self._sync_contacts()
except Exception as e:
logger.error(f"Failed to publish event to MQTT: {e}")
def _sync_contacts(self) -> None:
"""Request contact sync from device.
Called when advertisements are received to ensure contact database
stays current with all nodes on the mesh.
"""
logger.info("Advertisement received, triggering contact sync")
success = self.device.schedule_get_contacts()
if not success:
logger.warning("Contact sync request failed")
def _publish_contacts(self, payload: dict[str, Any]) -> None:
"""Publish each contact as a separate MQTT message.

View File

@@ -14,7 +14,7 @@ router = APIRouter()
@router.get("/advertisements", response_class=HTMLResponse)
async def advertisements_list(
request: Request,
public_key: str | None = Query(None, description="Filter by public key"),
search: str | None = Query(None, description="Search term"),
page: int = Query(1, ge=1, description="Page number"),
limit: int = Query(50, ge=1, le=100, description="Items per page"),
) -> HTMLResponse:
@@ -28,8 +28,8 @@ async def advertisements_list(
# Build query params
params: dict[str, int | str] = {"limit": limit, "offset": offset}
if public_key:
params["public_key"] = public_key
if search:
params["search"] = search
# Fetch advertisements from API
advertisements = []
@@ -57,7 +57,7 @@ async def advertisements_list(
"page": page,
"limit": limit,
"total_pages": total_pages,
"public_key": public_key or "",
"search": search or "",
}
)

View File

@@ -23,24 +23,72 @@ async def members_page(request: Request) -> HTMLResponse:
def node_sort_key(node: dict) -> int:
"""Sort nodes: repeater first, then chat, then others."""
role = (node.get("node_role") or "").lower()
if role == "repeater":
adv_type = (node.get("adv_type") or "").lower()
if adv_type == "repeater":
return 0
if role == "chat":
if adv_type == "chat":
return 1
return 2
try:
# Fetch all members
response = await request.app.state.http_client.get(
"/api/v1/members", params={"limit": 100}
)
if response.status_code == 200:
data = response.json()
members = data.get("items", [])
# Sort nodes within each member (repeater first, then chat)
# Fetch all nodes with member_id tags in one query
nodes_response = await request.app.state.http_client.get(
"/api/v1/nodes", params={"has_tag": "member_id", "limit": 500}
)
# Build a map of member_id -> nodes
member_nodes_map: dict[str, list] = {}
if nodes_response.status_code == 200:
nodes_data = nodes_response.json()
all_nodes = nodes_data.get("items", [])
for node in all_nodes:
# Find member_id tag
for tag in node.get("tags", []):
if tag.get("key") == "member_id":
member_id_value = tag.get("value")
if member_id_value:
if member_id_value not in member_nodes_map:
member_nodes_map[member_id_value] = []
member_nodes_map[member_id_value].append(node)
break
# Assign nodes to members and sort
for member in members:
if member.get("nodes"):
member["nodes"] = sorted(member["nodes"], key=node_sort_key)
member_id = member.get("member_id")
if member_id and member_id in member_nodes_map:
# Sort nodes (repeater first, then chat, then by name tag)
nodes = member_nodes_map[member_id]
# Sort by advertisement type first, then by name
def full_sort_key(node: dict) -> tuple:
adv_type = (node.get("adv_type") or "").lower()
type_priority = (
0
if adv_type == "repeater"
else (1 if adv_type == "chat" else 2)
)
# Get name from tags
node_name = node.get("name") or ""
for tag in node.get("tags", []):
if tag.get("key") == "name":
node_name = tag.get("value") or node_name
break
return (type_priority, node_name.lower())
member["nodes"] = sorted(nodes, key=full_sort_key)
else:
member["nodes"] = []
except Exception as e:
logger.warning(f"Failed to fetch members from API: {e}")
context["api_error"] = str(e)

View File

@@ -23,11 +23,11 @@
<form method="GET" action="/advertisements" class="flex gap-4 flex-wrap items-end">
<div class="form-control">
<label class="label py-1">
<span class="label-text">Public Key</span>
<span class="label-text">Search</span>
</label>
<input type="text" name="public_key" value="{{ public_key }}" placeholder="Filter by public key..." class="input input-bordered input-sm w-80" />
<input type="text" name="search" value="{{ search }}" placeholder="Search by name, ID, or public key..." class="input input-bordered input-sm w-80" />
</div>
<button type="submit" class="btn btn-primary btn-sm">Filter</button>
<button type="submit" class="btn btn-primary btn-sm">Search</button>
<a href="/advertisements" class="btn btn-ghost btn-sm">Clear</a>
</form>
</div>
@@ -49,8 +49,8 @@
<tr class="hover">
<td>
<a href="/nodes/{{ ad.public_key }}" class="link link-hover">
{% if ad.node_friendly_name or ad.node_name or ad.name %}
<div class="font-medium">{{ ad.node_friendly_name or ad.node_name or ad.name }}</div>
{% if ad.node_tag_name or ad.node_name or ad.name %}
<div class="font-medium">{{ ad.node_tag_name or ad.node_name or ad.name }}</div>
<div class="text-xs font-mono opacity-70">{{ ad.public_key[:16] }}...</div>
{% else %}
<span class="font-mono text-sm">{{ ad.public_key[:16] }}...</span>
@@ -80,7 +80,7 @@
{% for recv in ad.receivers %}
<li>
<a href="/nodes/{{ recv.public_key }}" class="text-sm">
{{ recv.friendly_name or recv.name or recv.public_key[:12] + '...' }}
{{ recv.tag_name or recv.name or recv.public_key[:12] + '...' }}
</a>
</li>
{% endfor %}
@@ -88,8 +88,8 @@
</div>
{% elif ad.receivers and ad.receivers|length == 1 %}
<a href="/nodes/{{ ad.receivers[0].public_key }}" class="link link-hover">
{% if ad.receivers[0].friendly_name or ad.receivers[0].name %}
<div class="font-medium">{{ ad.receivers[0].friendly_name or ad.receivers[0].name }}</div>
{% if ad.receivers[0].tag_name or ad.receivers[0].name %}
<div class="font-medium">{{ ad.receivers[0].tag_name or ad.receivers[0].name }}</div>
<div class="text-xs font-mono opacity-70">{{ ad.receivers[0].public_key[:16] }}...</div>
{% else %}
<span class="font-mono text-sm">{{ ad.receivers[0].public_key[:16] }}...</span>
@@ -97,8 +97,8 @@
</a>
{% elif ad.received_by %}
<a href="/nodes/{{ ad.received_by }}" class="link link-hover">
{% if ad.receiver_friendly_name or ad.receiver_name %}
<div class="font-medium">{{ ad.receiver_friendly_name or ad.receiver_name }}</div>
{% if ad.receiver_tag_name or ad.receiver_name %}
<div class="font-medium">{{ ad.receiver_tag_name or ad.receiver_name }}</div>
<div class="text-xs font-mono opacity-70">{{ ad.received_by[:16] }}...</div>
{% else %}
<span class="font-mono text-sm">{{ ad.received_by[:16] }}...</span>
@@ -126,7 +126,7 @@
<div class="flex justify-center mt-6">
<div class="join">
{% if page > 1 %}
<a href="?page={{ page - 1 }}&public_key={{ public_key }}&limit={{ limit }}" class="join-item btn btn-sm">Previous</a>
<a href="?page={{ page - 1 }}&search={{ search }}&limit={{ limit }}" class="join-item btn btn-sm">Previous</a>
{% else %}
<button class="join-item btn btn-sm btn-disabled">Previous</button>
{% endif %}
@@ -135,14 +135,14 @@
{% 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 }}&public_key={{ public_key }}&limit={{ limit }}" class="join-item btn btn-sm">{{ p }}</a>
<a href="?page={{ p }}&search={{ search }}&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 }}&public_key={{ public_key }}&limit={{ limit }}" class="join-item btn btn-sm">Next</a>
<a href="?page={{ page + 1 }}&search={{ search }}&limit={{ limit }}" class="join-item btn btn-sm">Next</a>
{% else %}
<button class="join-item btn btn-sm btn-disabled">Next</button>
{% endif %}

View File

@@ -83,7 +83,7 @@
</ul>
</div>
<div class="navbar-end">
<div class="badge badge-outline badge-sm">v{{ version }}</div>
<div class="badge badge-outline badge-sm">{{ version }}</div>
</div>
</div>
@@ -114,7 +114,7 @@
<a href="{{ network_contact_github }}" target="_blank" rel="noopener noreferrer" class="link link-hover">GitHub</a>
{% endif %}
</p>
<p class="text-xs opacity-50 mt-2">Powered by <a href="https://github.com/ipnet-mesh/meshcore-hub" target="_blank" rel="noopener noreferrer" class="link link-hover">MeshCore Hub</a> v{{ version }}</p>
<p class="text-xs opacity-50 mt-2">Powered by <a href="https://github.com/ipnet-mesh/meshcore-hub" target="_blank" rel="noopener noreferrer" class="link link-hover">MeshCore Hub</a> {{ version }}</p>
</aside>
</footer>

View File

@@ -19,25 +19,25 @@
</p>
{% endif %}
<div class="flex gap-4 justify-center flex-wrap">
<a href="/network" class="btn btn-primary">
<a href="/network" class="btn btn-neutral">
<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>
Dashboard
</a>
<a href="/nodes" class="btn btn-secondary">
<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>
Nodes
</a>
<a href="/advertisements" class="btn btn-accent">
<a href="/advertisements" 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="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
</a>
<a href="/messages" class="btn btn-info">
<a href="/messages" 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="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>
@@ -49,7 +49,7 @@
</div>
<!-- Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mt-6">
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mt-6">
<!-- Total Nodes -->
<div class="stat bg-base-100 rounded-box shadow">
<div class="stat-figure text-primary">
@@ -62,7 +62,7 @@
<div class="stat-desc">All discovered nodes</div>
</div>
<!-- Advertisements (24h) -->
<!-- Advertisements (7 days) -->
<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">
@@ -70,32 +70,20 @@
</svg>
</div>
<div class="stat-title">Advertisements</div>
<div class="stat-value text-secondary">{{ stats.advertisements_24h }}</div>
<div class="stat-desc">Received in last 24 hours</div>
<div class="stat-value text-secondary">{{ stats.advertisements_7d }}</div>
<div class="stat-desc">Last 7 days</div>
</div>
<!-- Total Messages -->
<!-- Messages (7 days) -->
<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 class="stat-title">Messages</div>
<div class="stat-value text-accent">{{ stats.messages_7d }}</div>
<div class="stat-desc">Last 7 days</div>
</div>
</div>
@@ -239,8 +227,8 @@
datasets: [{
label: 'Advertisements',
data: counts,
borderColor: 'oklch(0.7 0.15 250)',
backgroundColor: 'oklch(0.7 0.15 250 / 0.1)',
borderColor: 'oklch(0.7 0.17 330)',
backgroundColor: 'oklch(0.7 0.17 330 / 0.1)',
fill: true,
tension: 0.3,
pointRadius: 2,

View File

@@ -33,7 +33,9 @@
{% if member.nodes %}
<div class="mt-4 space-y-2">
{% for node in member.nodes %}
{% set adv_type = node.node_adv_type or node.node_role %}
{% set adv_type = node.adv_type %}
{% set node_tag_name = node.tags|selectattr('key', 'equalto', 'name')|map(attribute='value')|first %}
{% set display_name = node_tag_name or node.name %}
<a href="/nodes/{{ node.public_key }}" class="flex items-center gap-3 p-2 bg-base-200 rounded-lg hover:bg-base-300 transition-colors">
<span class="text-lg" title="{{ adv_type or 'Unknown' }}">
{% if adv_type and adv_type|lower == 'chat' %}
@@ -49,8 +51,8 @@
{% endif %}
</span>
<div>
{% if node.friendly_name or node.node_name %}
<div class="font-medium text-sm">{{ node.friendly_name or node.node_name }}</div>
{% if display_name %}
<div class="font-medium text-sm">{{ display_name }}</div>
<div class="font-mono text-xs opacity-60">{{ node.public_key[:12] }}...</div>
{% else %}
<div class="font-mono text-sm">{{ node.public_key[:12] }}...</div>
@@ -80,18 +82,20 @@
<h2 class="card-title">Members File Format</h2>
<p class="mb-4">Create a YAML file at <code>$SEED_HOME/members.yaml</code> with the following structure:</p>
<pre class="bg-base-200 p-4 rounded-box text-sm overflow-x-auto"><code>members:
- name: John Doe
- member_id: johndoe
name: John Doe
callsign: AB1CD
role: Network Admin
description: Manages the main repeater node.
contact: john@example.com
nodes:
- public_key: abc123def456... # 64-char hex
node_role: repeater
- name: Jane Smith
- member_id: janesmith
name: Jane Smith
role: Member
description: Regular user in the downtown area.</code></pre>
<p class="mt-4 text-sm opacity-70">Run <code>meshcore-hub collector seed</code> to import members, or they will be imported automatically on collector startup.</p>
<p class="mt-4 text-sm opacity-70">
Run <code>meshcore-hub collector seed</code> to import members.<br/>
To associate nodes with members, add a <code>member_id</code> tag to nodes in <code>node_tags.yaml</code>.
</p>
</div>
</div>
{% endif %}

View File

@@ -78,8 +78,8 @@
{% if msg.message_type == 'channel' %}
<span class="font-mono">CH{{ msg.channel_idx }}</span>
{% else %}
{% if msg.sender_friendly_name or msg.sender_name %}
<span class="font-medium">{{ msg.sender_friendly_name or msg.sender_name }}</span>
{% if msg.sender_tag_name or msg.sender_name %}
<span class="font-medium">{{ msg.sender_tag_name or msg.sender_name }}</span>
{% else %}
<span class="font-mono text-xs">{{ (msg.pubkey_prefix or '-')[:12] }}</span>
{% endif %}
@@ -96,7 +96,7 @@
{% for recv in msg.receivers %}
<li>
<a href="/nodes/{{ recv.public_key }}" class="text-sm">
<span class="flex-1">{{ recv.friendly_name or recv.name or recv.public_key[:12] + '...' }}</span>
<span class="flex-1">{{ recv.tag_name or recv.name or recv.public_key[:12] + '...' }}</span>
{% if recv.snr is not none %}
<span class="badge badge-ghost badge-xs">{{ "%.1f"|format(recv.snr) }}</span>
{% endif %}
@@ -107,8 +107,8 @@
</div>
{% elif msg.receivers and msg.receivers|length == 1 %}
<a href="/nodes/{{ msg.receivers[0].public_key }}" class="link link-hover">
{% if msg.receivers[0].friendly_name or msg.receivers[0].name %}
<div class="font-medium">{{ msg.receivers[0].friendly_name or msg.receivers[0].name }}</div>
{% if msg.receivers[0].tag_name or msg.receivers[0].name %}
<div class="font-medium">{{ msg.receivers[0].tag_name or msg.receivers[0].name }}</div>
<div class="text-xs font-mono opacity-70">{{ msg.receivers[0].public_key[:16] }}...</div>
{% else %}
<span class="font-mono text-sm">{{ msg.receivers[0].public_key[:16] }}...</span>
@@ -116,8 +116,8 @@
</a>
{% elif msg.received_by %}
<a href="/nodes/{{ msg.received_by }}" class="link link-hover">
{% if msg.receiver_friendly_name or msg.receiver_name %}
<div class="font-medium">{{ msg.receiver_friendly_name or msg.receiver_name }}</div>
{% if msg.receiver_tag_name or msg.receiver_name %}
<div class="font-medium">{{ msg.receiver_tag_name or msg.receiver_name }}</div>
<div class="text-xs font-mono opacity-70">{{ msg.received_by[:16] }}...</div>
{% else %}
<span class="font-mono text-sm">{{ msg.received_by[:16] }}...</span>

View File

@@ -22,8 +22,63 @@
</div>
{% endif %}
<!-- Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
<!-- 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>
<!-- Advertisements (7 days) -->
<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="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>
</div>
<div class="stat-title">Advertisements</div>
<div class="stat-value text-secondary">{{ stats.advertisements_7d }}</div>
<div class="stat-desc">Last 7 days</div>
</div>
<!-- Messages (7 days) -->
<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">Messages</div>
<div class="stat-value text-accent">{{ stats.messages_7d }}</div>
<div class="stat-desc">Last 7 days</div>
</div>
</div>
<!-- Activity Charts -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<!-- Node Count Chart -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title text-base">
<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="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>
Total Nodes
</h2>
<p class="text-xs opacity-70">Over time (last 7 days)</p>
<div class="h-32">
<canvas id="nodeChart"></canvas>
</div>
</div>
</div>
<!-- Advertisements Chart -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
@@ -55,22 +110,6 @@
</div>
</div>
</div>
<!-- Node Count Chart -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title text-base">
<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="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>
Total Nodes
</h2>
<p class="text-xs opacity-70">Over time (last 7 days)</p>
<div class="h-32">
<canvas id="nodeChart"></canvas>
</div>
</div>
</div>
</div>
<!-- Additional Stats -->
@@ -163,27 +202,6 @@
{% endif %}
</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 %}
{% block extra_scripts %}
@@ -232,7 +250,7 @@
});
}
// Advertisements chart
// Advertisements chart (secondary color - pink/magenta)
const advertCtx = document.getElementById('advertChart');
if (advertCtx && advertData.data && advertData.data.length > 0) {
new Chart(advertCtx, {
@@ -242,8 +260,8 @@
datasets: [{
label: 'Advertisements',
data: advertData.data.map(d => d.count),
borderColor: 'oklch(0.7 0.15 250)',
backgroundColor: 'oklch(0.7 0.15 250 / 0.1)',
borderColor: 'oklch(0.7 0.17 330)',
backgroundColor: 'oklch(0.7 0.17 330 / 0.1)',
fill: true,
tension: 0.3,
pointRadius: 2,
@@ -254,7 +272,7 @@
});
}
// Messages chart
// Messages chart (accent color - teal/cyan)
const messageCtx = document.getElementById('messageChart');
if (messageCtx && messageData.data && messageData.data.length > 0) {
new Chart(messageCtx, {
@@ -264,8 +282,8 @@
datasets: [{
label: 'Messages',
data: messageData.data.map(d => d.count),
borderColor: 'oklch(0.7 0.15 160)',
backgroundColor: 'oklch(0.7 0.15 160 / 0.1)',
borderColor: 'oklch(0.75 0.18 180)',
backgroundColor: 'oklch(0.75 0.18 180 / 0.1)',
fill: true,
tension: 0.3,
pointRadius: 2,
@@ -276,7 +294,7 @@
});
}
// Node count chart
// Node count chart (primary color - purple/blue)
const nodeCtx = document.getElementById('nodeChart');
if (nodeCtx && nodeData.data && nodeData.data.length > 0) {
new Chart(nodeCtx, {
@@ -286,8 +304,8 @@
datasets: [{
label: 'Total Nodes',
data: nodeData.data.map(d => d.count),
borderColor: 'oklch(0.7 0.15 30)',
backgroundColor: 'oklch(0.7 0.15 30 / 0.1)',
borderColor: 'oklch(0.65 0.24 265)',
backgroundColor: 'oklch(0.65 0.24 265 / 0.1)',
fill: true,
tension: 0.3,
pointRadius: 2,

View File

@@ -2,19 +2,35 @@
{% block title %}{{ network_name }} - Node Details{% endblock %}
{% block extra_head %}
<style>
#node-map {
height: 300px;
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="breadcrumbs text-sm mb-4">
<ul>
<li><a href="/">Home</a></li>
<li><a href="/nodes">Nodes</a></li>
{% if node %}
{% set ns = namespace(friendly_name=none) %}
{% set ns = namespace(tag_name=none) %}
{% for tag in node.tags or [] %}
{% if tag.key == 'friendly_name' %}
{% set ns.friendly_name = tag.value %}
{% if tag.key == 'name' %}
{% set ns.tag_name = tag.value %}
{% endif %}
{% endfor %}
<li>{{ ns.friendly_name or node.name or public_key[:12] + '...' }}</li>
<li>{{ ns.tag_name or node.name or public_key[:12] + '...' }}</li>
{% else %}
<li>Not Found</li>
{% endif %}
@@ -31,20 +47,28 @@
{% endif %}
{% if node %}
{% set ns = namespace(friendly_name=none) %}
{% set ns = namespace(tag_name=none) %}
{% for tag in node.tags or [] %}
{% if tag.key == 'friendly_name' %}
{% set ns.friendly_name = tag.value %}
{% if tag.key == 'name' %}
{% set ns.tag_name = tag.value %}
{% endif %}
{% endfor %}
<!-- Node Info Card -->
<div class="card bg-base-100 shadow-xl mb-6">
<div class="card-body">
<h1 class="card-title text-2xl">
{{ ns.friendly_name or node.name or 'Unnamed Node' }}
{% if node.adv_type %}
<span class="badge badge-secondary">{{ node.adv_type }}</span>
{% if node.adv_type|lower == 'chat' %}
<span title="Chat">💬</span>
{% elif node.adv_type|lower == 'repeater' %}
<span title="Repeater">📡</span>
{% elif node.adv_type|lower == 'room' %}
<span title="Room">🪧</span>
{% else %}
<span title="{{ node.adv_type }}">📍</span>
{% endif %}
{% endif %}
{{ ns.tag_name or node.name or 'Unnamed Node' }}
</h1>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
@@ -61,32 +85,55 @@
</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>
<!-- Tags and Map Grid -->
{% set ns_map = namespace(lat=none, lon=none) %}
{% for tag in node.tags or [] %}
{% if tag.key == 'lat' %}
{% set ns_map.lat = tag.value %}
{% elif tag.key == 'lon' %}
{% set ns_map.lon = tag.value %}
{% endif %}
{% endfor %}
<div class="grid grid-cols-1 {% if ns_map.lat and ns_map.lon %}lg:grid-cols-2{% endif %} gap-6 mt-6">
<!-- Tags -->
{% if node.tags %}
<div>
<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 %}
<!-- Location Map -->
{% if ns_map.lat and ns_map.lon %}
<div>
<h3 class="font-semibold opacity-70 mb-2">Location</h3>
<div id="node-map" class="mb-2"></div>
<div class="text-sm opacity-70">
<p>Coordinates: {{ ns_map.lat }}, {{ ns_map.lon }}</p>
</div>
</div>
{% endif %}
</div>
{% endif %}
</div>
</div>
@@ -125,8 +172,8 @@
<td>
{% if adv.received_by %}
<a href="/nodes/{{ adv.received_by }}" class="link link-hover">
{% if adv.receiver_friendly_name or adv.receiver_name %}
<div class="font-medium text-sm">{{ adv.receiver_friendly_name or adv.receiver_name }}</div>
{% if adv.receiver_tag_name or adv.receiver_name %}
<div class="font-medium text-sm">{{ adv.receiver_tag_name or adv.receiver_name }}</div>
<div class="text-xs font-mono opacity-70">{{ adv.received_by[:16] }}...</div>
{% else %}
<span class="font-mono text-xs">{{ adv.received_by[:16] }}...</span>
@@ -175,8 +222,8 @@
<td>
{% if tel.received_by %}
<a href="/nodes/{{ tel.received_by }}" class="link link-hover">
{% if tel.receiver_friendly_name or tel.receiver_name %}
<div class="font-medium text-sm">{{ tel.receiver_friendly_name or tel.receiver_name }}</div>
{% if tel.receiver_tag_name or tel.receiver_name %}
<div class="font-medium text-sm">{{ tel.receiver_tag_name or tel.receiver_name }}</div>
<div class="text-xs font-mono opacity-70">{{ tel.received_by[:16] }}...</div>
{% else %}
<span class="font-mono text-xs">{{ tel.received_by[:16] }}...</span>
@@ -208,3 +255,67 @@
<a href="/nodes" class="btn btn-primary mt-4">Back to Nodes</a>
{% endif %}
{% endblock %}
{% block extra_scripts %}
{% if node %}
{% set ns_map = namespace(lat=none, lon=none, name=none) %}
{% for tag in node.tags or [] %}
{% if tag.key == 'lat' %}
{% set ns_map.lat = tag.value %}
{% elif tag.key == 'lon' %}
{% set ns_map.lon = tag.value %}
{% elif tag.key == 'name' %}
{% set ns_map.name = tag.value %}
{% endif %}
{% endfor %}
{% if ns_map.lat and ns_map.lon %}
<script>
// Initialize map centered on the node's location
const nodeLat = {{ ns_map.lat }};
const nodeLon = {{ ns_map.lon }};
const nodeName = {{ (ns_map.name or node.name or 'Unnamed Node') | tojson }};
const nodeType = {{ (node.adv_type or '') | tojson }};
const publicKey = {{ node.public_key | tojson }};
const map = L.map('node-map').setView([nodeLat, nodeLon], 15);
// 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);
// Get emoji marker based on node type
function getNodeEmoji(type) {
const normalizedType = type ? type.toLowerCase() : null;
if (normalizedType === 'chat') return '💬';
if (normalizedType === 'repeater') return '📡';
if (normalizedType === 'room') return '🪧';
return '📍';
}
// Create marker icon (just the emoji, no label)
const emoji = getNodeEmoji(nodeType);
const icon = L.divIcon({
className: 'custom-div-icon',
html: `<span style="font-size: 32px; text-shadow: 0 0 3px #1a237e, 0 0 6px #1a237e, 0 1px 2px rgba(0,0,0,0.7);">${emoji}</span>`,
iconSize: [32, 32],
iconAnchor: [16, 16]
});
// Add marker
const marker = L.marker([nodeLat, nodeLon], { icon: icon }).addTo(map);
// Add popup (shown on click, not by default)
marker.bindPopup(`
<div class="p-2">
<h3 class="font-bold text-lg mb-2">${emoji} ${nodeName}</h3>
<div class="space-y-1 text-sm">
${nodeType ? `<p><span class="opacity-70">Type:</span> ${nodeType}</p>` : ''}
<p><span class="opacity-70">Coordinates:</span> ${nodeLat.toFixed(4)}, ${nodeLon.toFixed(4)}</p>
</div>
</div>
`);
</script>
{% endif %}
{% endif %}
{% endblock %}

View File

@@ -25,7 +25,7 @@
<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" />
<input type="text" name="search" value="{{ search }}" placeholder="Search by name, ID, or public key..." class="input input-bordered input-sm w-80" />
</div>
<div class="form-control">
<label class="label py-1">
@@ -57,17 +57,17 @@
</thead>
<tbody>
{% for node in nodes %}
{% set ns = namespace(friendly_name=none) %}
{% set ns = namespace(tag_name=none) %}
{% for tag in node.tags or [] %}
{% if tag.key == 'friendly_name' %}
{% set ns.friendly_name = tag.value %}
{% if tag.key == 'name' %}
{% set ns.tag_name = tag.value %}
{% endif %}
{% endfor %}
<tr class="hover">
<td>
<a href="/nodes/{{ node.public_key }}" class="link link-hover">
{% if ns.friendly_name or node.name %}
<div class="font-medium">{{ ns.friendly_name or node.name }}</div>
{% if ns.tag_name or node.name %}
<div class="font-medium">{{ ns.tag_name or node.name }}</div>
<div class="text-xs font-mono opacity-70">{{ node.public_key[:16] }}...</div>
{% else %}
<span class="font-mono text-sm">{{ node.public_key[:16] }}...</span>

View File

@@ -0,0 +1,172 @@
"""Tests for contact handler."""
import pytest
from contextlib import contextmanager
from datetime import datetime, timezone
from unittest.mock import MagicMock
from meshcore_hub.collector.handlers.contacts import handle_contact
from meshcore_hub.common.models import Node
@pytest.fixture
def mock_db_manager(db_session):
"""Create a mock database manager that uses the test session."""
mock_db = MagicMock()
@contextmanager
def session_scope():
try:
yield db_session
db_session.commit()
except Exception:
db_session.rollback()
raise
mock_db.session_scope = session_scope
return mock_db
def test_handle_contact_creates_new_node(db_session, mock_db_manager):
"""Test that contact handler creates new node with last_seen=None."""
payload = {
"public_key": "a" * 64,
"adv_name": "TestNode",
"type": 1, # chat
}
handle_contact("receiver123", "contact", payload, mock_db_manager)
# Verify node was created
node = db_session.query(Node).filter_by(public_key="a" * 64).first()
assert node is not None
assert node.name == "TestNode"
assert node.adv_type == "chat"
assert node.first_seen is not None
assert node.last_seen is None # Should NOT be set by contact sync
def test_handle_contact_updates_existing_node_name(db_session, mock_db_manager):
"""Test that contact handler updates name but NOT last_seen."""
# Create existing node with a last_seen timestamp
last_seen_time = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
node = Node(
public_key="b" * 64,
name="OldName",
adv_type="chat",
first_seen=datetime.now(timezone.utc),
last_seen=last_seen_time,
)
db_session.add(node)
db_session.commit()
# Process contact with new name
payload = {
"public_key": "b" * 64,
"adv_name": "NewName",
"type": 1,
}
handle_contact("receiver123", "contact", payload, mock_db_manager)
# Verify name was updated but last_seen was NOT
db_session.expire_all()
node = db_session.query(Node).filter_by(public_key="b" * 64).first()
assert node.name == "NewName"
# Compare timestamps without timezone (SQLite strips timezone info)
assert node.last_seen is not None
assert node.last_seen.replace(tzinfo=None) == last_seen_time.replace(tzinfo=None)
def test_handle_contact_preserves_existing_adv_type(db_session, mock_db_manager):
"""Test that contact handler doesn't overwrite existing adv_type."""
# Create existing node with adv_type
node = Node(
public_key="c" * 64,
name="TestNode",
adv_type="repeater",
first_seen=datetime.now(timezone.utc),
last_seen=None,
)
db_session.add(node)
db_session.commit()
# Process contact with different type
payload = {
"public_key": "c" * 64,
"adv_name": "TestNode",
"type": 1, # chat
}
handle_contact("receiver123", "contact", payload, mock_db_manager)
# Verify adv_type was NOT changed
db_session.expire_all()
node = db_session.query(Node).filter_by(public_key="c" * 64).first()
assert node.adv_type == "repeater" # Should preserve existing
def test_handle_contact_sets_adv_type_if_missing(db_session, mock_db_manager):
"""Test that contact handler sets adv_type if node doesn't have one."""
# Create existing node without adv_type
node = Node(
public_key="d" * 64,
name="TestNode",
adv_type=None,
first_seen=datetime.now(timezone.utc),
last_seen=None,
)
db_session.add(node)
db_session.commit()
# Process contact with type
payload = {
"public_key": "d" * 64,
"adv_name": "TestNode",
"type": 2, # repeater
}
handle_contact("receiver123", "contact", payload, mock_db_manager)
# Verify adv_type was set
db_session.expire_all()
node = db_session.query(Node).filter_by(public_key="d" * 64).first()
assert node.adv_type == "repeater"
def test_handle_contact_ignores_missing_public_key(db_session, mock_db_manager, caplog):
"""Test that contact handler handles missing public_key gracefully."""
payload = {
"adv_name": "TestNode",
"type": 1,
}
handle_contact("receiver123", "contact", payload, mock_db_manager)
# Verify warning was logged and no node created
assert "missing public_key" in caplog.text
count = db_session.query(Node).count()
assert count == 0
def test_handle_contact_node_type_mapping(db_session, mock_db_manager):
"""Test that numeric node types are correctly mapped to strings."""
test_cases = [
(0, "none"),
(1, "chat"),
(2, "repeater"),
(3, "room"),
]
for numeric_type, expected_string in test_cases:
public_key = str(numeric_type) * 64
payload = {
"public_key": public_key,
"adv_name": f"Node{numeric_type}",
"type": numeric_type,
}
handle_contact("receiver123", "contact", payload, mock_db_manager)
node = db_session.query(Node).filter_by(public_key=public_key).first()
assert node.adv_type == expected_string

View File

@@ -390,3 +390,64 @@ class TestImportTags:
assert tag_dict["is_disabled"].value_type == "boolean"
Path(f.name).unlink()
def test_import_with_clear_existing(self, db_manager):
"""Test that clear_existing deletes all tags before importing."""
# Create initial tags
initial_data = {
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef": {
"old_tag": "old_value",
"shared_tag": "old_value",
},
"1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef": {
"another_old_tag": "value",
},
}
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
yaml.dump(initial_data, f)
f.flush()
initial_file = f.name
stats1 = import_tags(initial_file, db_manager, create_nodes=True)
assert stats1["created"] == 3
assert stats1["deleted"] == 0
# Verify initial tags exist
with db_manager.session_scope() as session:
tags = session.execute(select(NodeTag)).scalars().all()
assert len(tags) == 3
# Import new tags with clear_existing=True
new_data = {
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef": {
"new_tag": "new_value",
"shared_tag": "new_value",
}
}
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
yaml.dump(new_data, f)
f.flush()
new_file = f.name
stats2 = import_tags(
new_file, db_manager, create_nodes=True, clear_existing=True
)
assert stats2["deleted"] == 3 # All 3 old tags deleted
assert stats2["created"] == 2 # 2 new tags created
assert stats2["updated"] == 0 # No updates when clearing
# Verify only new tags exist
with db_manager.session_scope() as session:
tags = session.execute(select(NodeTag)).scalars().all()
tag_dict = {t.key: t for t in tags}
assert len(tags) == 2
assert "new_tag" in tag_dict
assert "shared_tag" in tag_dict
assert tag_dict["shared_tag"].value == "new_value"
assert "old_tag" not in tag_dict
assert "another_old_tag" not in tag_dict
Path(initial_file).unlink()
Path(new_file).unlink()

View File

@@ -62,6 +62,56 @@ class TestReceiver:
# Verify MQTT publish was called
mock_mqtt_client.publish_event.assert_called()
def test_receiver_syncs_contacts_on_advertisement(
self, receiver, mock_device, mock_mqtt_client
):
"""Test that receiver syncs contacts when advertisement is received."""
import time
from unittest.mock import patch
receiver.start()
# Patch schedule_get_contacts to track calls
with patch.object(
mock_device, "schedule_get_contacts", return_value=True
) as mock_get:
# Inject an advertisement event
mock_device.inject_event(
EventType.ADVERTISEMENT,
{"pubkey_prefix": "b" * 64, "adv_name": "TestNode", "type": 1},
)
# Allow time for event processing
time.sleep(0.1)
# Verify schedule_get_contacts was called
mock_get.assert_called()
def test_receiver_handles_contact_sync_failure(
self, receiver, mock_device, mock_mqtt_client
):
"""Test that receiver handles contact sync failures gracefully."""
import time
from unittest.mock import patch
receiver.start()
# Patch schedule_get_contacts to return False (failure)
with patch.object(
mock_device, "schedule_get_contacts", return_value=False
) as mock_get:
# Should not raise exception even if sync fails
mock_device.inject_event(
EventType.ADVERTISEMENT,
{"pubkey_prefix": "c" * 64, "adv_name": "FailNode", "type": 1},
)
# Allow time for event processing
time.sleep(0.1)
# Verify it was attempted
mock_get.assert_called()
class TestCreateReceiver:
"""Tests for create_receiver factory function."""

View File

@@ -302,6 +302,7 @@ def client(web_app: Any, mock_http_client: MockHttpClient) -> TestClient:
def mock_http_client_with_members() -> MockHttpClient:
"""Create a mock HTTP client with members data."""
client = MockHttpClient()
# Mock the members API response (no nodes in the response anymore)
client.set_response(
"GET",
"/api/v1/members",
@@ -310,33 +311,23 @@ def mock_http_client_with_members() -> MockHttpClient:
"items": [
{
"id": "member-1",
"member_id": "alice",
"name": "Alice",
"callsign": "W1ABC",
"role": "Admin",
"description": None,
"contact": "alice@example.com",
"nodes": [
{
"public_key": "abc123def456abc123def456abc123def456abc123def456abc123def456abc1",
"node_role": "chat",
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z",
"node_name": "Alice's Node",
"node_adv_type": "chat",
"friendly_name": "Alice Chat",
}
],
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z",
},
{
"id": "member-2",
"member_id": "bob",
"name": "Bob",
"callsign": "W2XYZ",
"role": "Member",
"description": None,
"contact": None,
"nodes": [],
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z",
},
@@ -346,6 +337,46 @@ def mock_http_client_with_members() -> MockHttpClient:
"offset": 0,
},
)
# Mock the nodes API response with has_tag filter
# This will be called to get nodes with member_id tags
client.set_response(
"GET",
"/api/v1/nodes",
200,
{
"items": [
{
"public_key": "abc123def456abc123def456abc123def456abc123def456abc123def456abc1",
"name": "Alice's Node",
"adv_type": "chat",
"flags": None,
"first_seen": "2024-01-01T00:00:00Z",
"last_seen": "2024-01-01T00:00:00Z",
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z",
"tags": [
{
"key": "member_id",
"value": "alice",
"value_type": "string",
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z",
},
{
"key": "name",
"value": "Alice Chat",
"value_type": "string",
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z",
},
],
}
],
"total": 1,
"limit": 500,
"offset": 0,
},
)
return client

View File

@@ -91,7 +91,8 @@ class TestNodeDetailPage:
assert response.status_code == 200
# Should display node details
assert "Node One" in response.text
assert "REPEATER" in response.text
# Node type is shown as emoji with title attribute
assert 'title="Repeater"' in response.text
def test_node_detail_displays_public_key(
self, client: TestClient, mock_http_client: MockHttpClient