Merge branch 'main' into bt to align Alembic with production DB
Some checks failed
Deploy to Production / deploy (push) Successful in 1m45s
Playwright Tests / test-playwright (push) Failing after 18s
Test Backend / test-backend (push) Failing after 12s
Test Docker Compose / test-docker-compose (push) Has been cancelled

This commit is contained in:
魏风
2026-05-11 10:47:08 +08:00
77 changed files with 6653 additions and 1441 deletions

View File

@@ -1 +0,0 @@
{{ _copier_answers|to_json -}}

View File

@@ -1,26 +0,0 @@
from pathlib import Path
import json
# Update the .env file with the answers from the .copier-answers.yml file
# without using Jinja2 templates in the .env file, this way the code works as is
# without needing Copier, but if Copier is used, the .env file will be updated
root_path = Path(__file__).parent.parent
answers_path = Path(__file__).parent / ".copier-answers.yml"
answers = json.loads(answers_path.read_text())
env_path = root_path / ".env"
env_content = env_path.read_text()
lines = []
for line in env_content.splitlines():
for key, value in answers.items():
upper_key = key.upper()
if line.startswith(f"{upper_key}="):
if " " in value:
content = f"{upper_key}={value!r}"
else:
content = f"{upper_key}={value}"
new_line = line.replace(line, content)
lines.append(new_line)
break
else:
lines.append(line)
env_path.write_text("\n".join(lines))

11
.env
View File

@@ -13,8 +13,8 @@ FRONTEND_HOST=http://localhost:5173
# Environment: local, staging, production
ENVIRONMENT=local
PROJECT_NAME="Full Stack FastAPI Project"
STACK_NAME=full-stack-fastapi-project
PROJECT_NAME="SpatialHub"
STACK_NAME=spatialhub
# Backend
BACKEND_CORS_ORIGINS="http://localhost,http://localhost:5173,https://localhost,https://localhost:5173,http://localhost.tiangolo.com"
@@ -43,3 +43,10 @@ SENTRY_DSN=
# Configure these with your own Docker registry images
DOCKER_IMAGE_BACKEND=backend
DOCKER_IMAGE_FRONTEND=frontend
# MQTT Config
MQTT_HOST=101.35.119.226
MQTT_PORT=1883
MQTT_USERNAME=weefnn
MQTT_PASSWORD=123456
MQTT_CLIENT_ID=spatialhub-local-dev-weifeng

View File

@@ -12,7 +12,6 @@ FRONTEND_HOST=https://makefire.fun
ENVIRONMENT=production
PROJECT_NAME="Full Stack FastAPI Project"
STACK_NAME=full-stack-fastapi-project
# Backend
BACKEND_CORS_ORIGINS="https://makefire.fun,https://api.makefire.fun"
@@ -42,6 +41,18 @@ POSTGRES_PASSWORD=password_CYmsGt
SENTRY_DSN=
# MQTT ingestion pipeline
MQTT_HOST=101.35.119.226
MQTT_PORT=1883
MQTT_USERNAME=
MQTT_PASSWORD=
MQTT_TOPIC_PATTERN=terminal/+/raw
MQTT_CLIENT_ID=spatialhub-mqtt-ingestor
RAW_HOT_RETENTION_DAYS=90
RAW_ARCHIVE_BASE_DIR=/data/archive/gnss
PARSER_BATCH_SIZE=200
PARSER_POLL_INTERVAL_MS=300
# Docker images (本地构建,不需要远程 registry)
DOCKER_IMAGE_BACKEND=backend
DOCKER_IMAGE_FRONTEND=frontend

View File

@@ -23,7 +23,6 @@ jobs:
FRONTEND_HOST=${{ secrets.FRONTEND_HOST }}
ENVIRONMENT=production
PROJECT_NAME=${{ secrets.PROJECT_NAME }}
STACK_NAME=${{ secrets.STACK_NAME }}
BACKEND_CORS_ORIGINS=${{ secrets.BACKEND_CORS_ORIGINS }}
SECRET_KEY=${{ secrets.SECRET_KEY }}
FIRST_SUPERUSER=${{ secrets.FIRST_SUPERUSER }}
@@ -41,6 +40,16 @@ jobs:
POSTGRES_USER=${{ secrets.POSTGRES_USER }}
POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }}
SENTRY_DSN=${{ secrets.SENTRY_DSN }}
MQTT_HOST=${{ secrets.MQTT_HOST }}
MQTT_PORT=${{ secrets.MQTT_PORT }}
MQTT_USERNAME=${{ secrets.MQTT_USERNAME }}
MQTT_PASSWORD=${{ secrets.MQTT_PASSWORD }}
MQTT_TOPIC_PATTERN=${{ secrets.MQTT_TOPIC_PATTERN }}
MQTT_CLIENT_ID=${{ secrets.MQTT_CLIENT_ID }}
RAW_HOT_RETENTION_DAYS=${{ secrets.RAW_HOT_RETENTION_DAYS }}
RAW_ARCHIVE_BASE_DIR=${{ secrets.RAW_ARCHIVE_BASE_DIR }}
PARSER_BATCH_SIZE=${{ secrets.PARSER_BATCH_SIZE }}
PARSER_POLL_INTERVAL_MS=${{ secrets.PARSER_POLL_INTERVAL_MS }}
DOCKER_IMAGE_BACKEND=${{ secrets.DOCKER_IMAGE_BACKEND }}
DOCKER_IMAGE_FRONTEND=${{ secrets.DOCKER_IMAGE_FRONTEND }}
ENVEOF
@@ -48,6 +57,16 @@ jobs:
# Fallback defaults if image name secrets are empty
sed -i 's/^DOCKER_IMAGE_BACKEND=$/DOCKER_IMAGE_BACKEND=backend/' .env.production
sed -i 's/^DOCKER_IMAGE_FRONTEND=$/DOCKER_IMAGE_FRONTEND=frontend/' .env.production
sed -i 's/^MQTT_HOST=$/MQTT_HOST=1Panel-emqx-Rniy/' .env.production
sed -i 's/^MQTT_PORT=$/MQTT_PORT=1883/' .env.production
sed -i 's/^MQTT_USERNAME=$/MQTT_USERNAME=weefnn/' .env.production
sed -i 's/^MQTT_PASSWORD=$/MQTT_PASSWORD=123456/' .env.production
sed -i 's%^MQTT_TOPIC_PATTERN=$%MQTT_TOPIC_PATTERN=terminal/+/raw%' .env.production
sed -i 's/^MQTT_CLIENT_ID=$/MQTT_CLIENT_ID=spatialhub-mqtt-ingestor/' .env.production
sed -i 's/^RAW_HOT_RETENTION_DAYS=$/RAW_HOT_RETENTION_DAYS=90/' .env.production
sed -i 's%^RAW_ARCHIVE_BASE_DIR=$%RAW_ARCHIVE_BASE_DIR=/data/archive/gnss%' .env.production
sed -i 's/^PARSER_BATCH_SIZE=$/PARSER_BATCH_SIZE=200/' .env.production
sed -i 's/^PARSER_POLL_INTERVAL_MS=$/PARSER_POLL_INTERVAL_MS=300/' .env.production
- name: Build Docker images
run: docker compose --env-file .env.production -f compose.prod.yml build
@@ -84,5 +103,22 @@ jobs:
exit 1
fi
- name: Verify mqtt workers
run: |
if docker compose --env-file .env.production -f compose.prod.yml ps mqtt-ingestor --status running | grep -q mqtt-ingestor; then
echo "✅ mqtt-ingestor is running!"
else
echo "❌ mqtt-ingestor is not running"
docker compose --env-file .env.production -f compose.prod.yml logs mqtt-ingestor
exit 1
fi
if docker compose --env-file .env.production -f compose.prod.yml ps mqtt-parser-worker --status running | grep -q mqtt-parser-worker; then
echo "✅ mqtt-parser-worker is running!"
else
echo "❌ mqtt-parser-worker is not running"
docker compose --env-file .env.production -f compose.prod.yml logs mqtt-parser-worker
exit 1
fi
- name: Cleanup old Docker images
run: docker image prune -f || true

2
.gitignore vendored
View File

@@ -8,3 +8,5 @@ node_modules/
.DS_Store
.env.production
.venv/
.playwright-cli/
.worktrees/

View File

@@ -1,6 +1,6 @@
# Contributing
Thank you for your interest in contributing to the Full Stack FastAPI Template! 🙇
Thank you for your interest in contributing to the SpatialHub! 🙇
## Discussions First

View File

@@ -1,4 +1,4 @@
# Full Stack FastAPI Template
# SpatialHub
<a href="https://github.com/fastapi/full-stack-fastapi-template/actions?query=workflow%3A%22Test+Docker+Compose%22" target="_blank"><img src="https://github.com/fastapi/full-stack-fastapi-template/workflows/Test%20Docker%20Compose/badge.svg" alt="Test Docker Compose"></a>
<a href="https://github.com/fastapi/full-stack-fastapi-template/actions?query=workflow%3A%22Test+Backend%22" target="_blank"><img src="https://github.com/fastapi/full-stack-fastapi-template/workflows/Test%20Backend/badge.svg" alt="Test Backend"></a>
@@ -22,8 +22,7 @@
- 📫 Email based password recovery.
- 📬 [Mailcatcher](https://mailcatcher.me) for local email testing during development.
- ✅ Tests with [Pytest](https://pytest.org).
- 📞 [Traefik](https://traefik.io) as a reverse proxy / load balancer.
- 🚢 Deployment instructions using Docker Compose, including how to set up a frontend Traefik proxy to handle automatic HTTPS certificates.
- 🚢 Deployment instructions using Docker Compose.
- 🏭 CI (continuous integration) and CD (continuous deployment) based on GitHub Actions.
### Dashboard Login
@@ -146,66 +145,6 @@ python -c "import secrets; print(secrets.token_urlsafe(32))"
Copy the content and use that as password / secret key. And run that again to generate another secure key.
## How To Use It - Alternative With Copier
This repository also supports generating a new project using [Copier](https://copier.readthedocs.io).
It will copy all the files, ask you configuration questions, and update the `.env` files with your answers.
### Install Copier
You can install Copier with:
```bash
pip install copier
```
Or better, if you have [`pipx`](https://pipx.pypa.io/), you can run it with:
```bash
pipx install copier
```
**Note**: If you have `pipx`, installing copier is optional, you could run it directly.
### Generate a Project With Copier
Decide a name for your new project's directory, you will use it below. For example, `my-awesome-project`.
Go to the directory that will be the parent of your project, and run the command with your project's name:
```bash
copier copy https://github.com/fastapi/full-stack-fastapi-template my-awesome-project --trust
```
If you have `pipx` and you didn't install `copier`, you can run it directly:
```bash
pipx run copier copy https://github.com/fastapi/full-stack-fastapi-template my-awesome-project --trust
```
**Note** the `--trust` option is necessary to be able to execute a [post-creation script](https://github.com/fastapi/full-stack-fastapi-template/blob/master/.copier/update_dotenv.py) that updates your `.env` files.
### Input Variables
Copier will ask you for some data, you might want to have at hand before generating the project.
But don't worry, you can just update any of that in the `.env` files afterwards.
The input variables, with their default values (some auto generated) are:
- `project_name`: (default: `"FastAPI Project"`) The name of the project, shown to API users (in .env).
- `stack_name`: (default: `"fastapi-project"`) The name of the stack used for Docker Compose labels and project name (no spaces, no periods) (in .env).
- `secret_key`: (default: `"changethis"`) The secret key for the project, used for security, stored in .env, you can generate one with the method above.
- `first_superuser`: (default: `"admin@example.com"`) The email of the first superuser (in .env).
- `first_superuser_password`: (default: `"changethis"`) The password of the first superuser (in .env).
- `smtp_host`: (default: "") The SMTP server host to send emails, you can set it later in .env.
- `smtp_user`: (default: "") The SMTP server user to send emails, you can set it later in .env.
- `smtp_password`: (default: "") The SMTP server password to send emails, you can set it later in .env.
- `emails_from_email`: (default: `"info@example.com"`) The email account to send emails from, you can set it later in .env.
- `postgres_password`: (default: `"changethis"`) The password for the PostgreSQL database, stored in .env, you can generate one with the method above.
- `sentry_dsn`: (default: "") The DSN for Sentry, if you are using it, you can set it later in .env.
## Backend Development
Backend docs: [backend/README.md](./backend/README.md).
@@ -222,7 +161,7 @@ Deployment docs: [deployment.md](./deployment.md).
General development docs: [development.md](./development.md).
This includes using Docker Compose, custom local domains, `.env` configurations, etc.
This includes using Docker Compose and `.env` configurations.
## Release Notes
@@ -230,4 +169,4 @@ Check the file [release-notes.md](./release-notes.md).
## License
The Full Stack FastAPI Template is licensed under the terms of the MIT license.
The SpatialHub is licensed under the terms of the MIT license.

View File

@@ -170,3 +170,40 @@ The email templates are in `./backend/app/email-templates/`. Here, there are two
Before continuing, ensure you have the [MJML extension](https://github.com/mjmlio/vscode-mjml) installed in your VS Code.
Once you have the MJML extension installed, you can create a new email template in the `src` directory. After creating the new email template and with the `.mjml` file open in your editor, open the command palette with `Ctrl+Shift+P` and search for `MJML: Export to HTML`. This will convert the `.mjml` file to a `.html` file and now you can save it in the build directory.
## MQTT Ingestion Pipeline
This project includes a two-stage MQTT pipeline:
- `mqtt-ingestor` subscribes to `terminal/{device_id}/raw` and stores payload bytes losslessly in `terminal_message_raw`.
- `mqtt-parser-worker` consumes unparsed rows (`parsed_at IS NULL` and `parse_error IS NULL`) and updates `terminal_device_state`.
- Parse is single-pass by design in the current implementation: failed rows keep `parse_error` and are not auto-retried with backoff.
### Run locally with Docker Compose
```console
$ docker compose up -d db backend mqtt-ingestor mqtt-parser-worker
```
### Unit tests for MQTT modules
The existing `tests/conftest.py` expects a PostgreSQL connection. For parser/repository unit tests that run with in-memory SQLite fixtures, use:
```console
$ cd backend
$ PYTHONPATH=. ../.venv/bin/pytest tests/mqtt --confcutdir=tests/mqtt -q
```
### Verify end-to-end ingestion quickly
1. Start services (`db`, `backend`, `mqtt-ingestor`, `mqtt-parser-worker`).
2. Publish a sample message to `terminal/device-001/raw`.
3. Query PostgreSQL tables:
- `terminal_message_raw`
- `terminal_device_state`
### Archive run (manual trigger)
```console
$ docker compose run --rm mqtt-parser-worker python -m app.mqtt.archive_job
```

View File

@@ -0,0 +1,223 @@
"""add mqtt pipeline tables
Revision ID: a1b2c3d4e5f6
Revises: 0b117fab4208
Create Date: 2026-03-17 12:30:00.000000
"""
from alembic import op
import sqlalchemy as sa
import sqlmodel.sql.sqltypes
# revision identifiers, used by Alembic.
revision = "a1b2c3d4e5f6"
down_revision = "0b117fab4208"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"terminal_message_raw",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column(
"device_id", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False
),
sa.Column("topic", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False),
sa.Column("received_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("payload_bytes", sa.LargeBinary(), nullable=False),
sa.Column("payload_size", sa.Integer(), nullable=False),
sa.Column(
"payload_sha256", sqlmodel.sql.sqltypes.AutoString(length=64), nullable=False
),
sa.Column("qos", sa.Integer(), nullable=False),
sa.Column("retain", sa.Boolean(), nullable=False),
sa.Column(
"parse_status", sqlmodel.sql.sqltypes.AutoString(length=32), nullable=False
),
sa.Column("parse_attempts", sa.Integer(), nullable=False),
sa.Column("next_retry_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"parse_error", sqlmodel.sql.sqltypes.AutoString(length=1024), nullable=True
),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_terminal_message_raw_device_id"),
"terminal_message_raw",
["device_id"],
unique=False,
)
op.create_index(
op.f("ix_terminal_message_raw_next_retry_at"),
"terminal_message_raw",
["next_retry_at"],
unique=False,
)
op.create_index(
op.f("ix_terminal_message_raw_parse_status"),
"terminal_message_raw",
["parse_status"],
unique=False,
)
op.create_index(
op.f("ix_terminal_message_raw_payload_sha256"),
"terminal_message_raw",
["payload_sha256"],
unique=False,
)
op.create_index(
op.f("ix_terminal_message_raw_received_at"),
"terminal_message_raw",
["received_at"],
unique=False,
)
op.create_table(
"terminal_observation_event",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("raw_id", sa.Uuid(), nullable=False),
sa.Column(
"device_id", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False
),
sa.Column("event_time", sa.DateTime(timezone=True), nullable=False),
sa.Column(
"event_type", sqlmodel.sql.sqltypes.AutoString(length=64), nullable=False
),
sa.Column("payload_json", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["raw_id"], ["terminal_message_raw.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_terminal_observation_event_device_id"),
"terminal_observation_event",
["device_id"],
unique=False,
)
op.create_index(
op.f("ix_terminal_observation_event_event_time"),
"terminal_observation_event",
["event_time"],
unique=False,
)
op.create_index(
op.f("ix_terminal_observation_event_event_type"),
"terminal_observation_event",
["event_type"],
unique=False,
)
op.create_index(
op.f("ix_terminal_observation_event_raw_id"),
"terminal_observation_event",
["raw_id"],
unique=False,
)
op.create_table(
"terminal_device_state",
sa.Column(
"device_id", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False
),
sa.Column("last_event_time", sa.DateTime(timezone=True), nullable=True),
sa.Column("fix_status", sqlmodel.sql.sqltypes.AutoString(length=64), nullable=True),
sa.Column("lat", sa.Float(), nullable=True),
sa.Column("lon", sa.Float(), nullable=True),
sa.Column("alt_m", sa.Float(), nullable=True),
sa.Column("satellites_used", sa.Integer(), nullable=True),
sa.Column("satellite_signal_summary", sa.JSON(), nullable=True),
sa.Column("rtcm_type_summary", sa.JSON(), nullable=True),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("device_id"),
)
op.create_table(
"terminal_raw_archive_manifest",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column(
"device_id", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False
),
sa.Column("range_start", sa.DateTime(timezone=True), nullable=False),
sa.Column("range_end", sa.DateTime(timezone=True), nullable=False),
sa.Column(
"file_path", sqlmodel.sql.sqltypes.AutoString(length=1024), nullable=False
),
sa.Column("file_size", sa.Integer(), nullable=False),
sa.Column("sha256", sqlmodel.sql.sqltypes.AutoString(length=64), nullable=False),
sa.Column("record_count", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("file_path"),
)
op.create_index(
op.f("ix_terminal_raw_archive_manifest_device_id"),
"terminal_raw_archive_manifest",
["device_id"],
unique=False,
)
op.create_index(
op.f("ix_terminal_raw_archive_manifest_range_end"),
"terminal_raw_archive_manifest",
["range_end"],
unique=False,
)
op.create_index(
op.f("ix_terminal_raw_archive_manifest_range_start"),
"terminal_raw_archive_manifest",
["range_start"],
unique=False,
)
def downgrade() -> None:
op.drop_index(
op.f("ix_terminal_raw_archive_manifest_range_start"),
table_name="terminal_raw_archive_manifest",
)
op.drop_index(
op.f("ix_terminal_raw_archive_manifest_range_end"),
table_name="terminal_raw_archive_manifest",
)
op.drop_index(
op.f("ix_terminal_raw_archive_manifest_device_id"),
table_name="terminal_raw_archive_manifest",
)
op.drop_table("terminal_raw_archive_manifest")
op.drop_table("terminal_device_state")
op.drop_index(
op.f("ix_terminal_observation_event_raw_id"),
table_name="terminal_observation_event",
)
op.drop_index(
op.f("ix_terminal_observation_event_event_type"),
table_name="terminal_observation_event",
)
op.drop_index(
op.f("ix_terminal_observation_event_event_time"),
table_name="terminal_observation_event",
)
op.drop_index(
op.f("ix_terminal_observation_event_device_id"),
table_name="terminal_observation_event",
)
op.drop_table("terminal_observation_event")
op.drop_index(
op.f("ix_terminal_message_raw_received_at"), table_name="terminal_message_raw"
)
op.drop_index(
op.f("ix_terminal_message_raw_payload_sha256"),
table_name="terminal_message_raw",
)
op.drop_index(
op.f("ix_terminal_message_raw_parse_status"), table_name="terminal_message_raw"
)
op.drop_index(
op.f("ix_terminal_message_raw_next_retry_at"), table_name="terminal_message_raw"
)
op.drop_index(
op.f("ix_terminal_message_raw_device_id"), table_name="terminal_message_raw"
)
op.drop_table("terminal_message_raw")

View File

@@ -0,0 +1,108 @@
"""simplify mqtt parse tracking
Revision ID: b4c5d6e7f809
Revises: a1b2c3d4e5f6
Create Date: 2026-03-19 17:30:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "b4c5d6e7f809"
down_revision = "a1b2c3d4e5f6"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"terminal_message_raw",
sa.Column("parsed_at", sa.DateTime(timezone=True), nullable=True),
)
op.create_index(
op.f("ix_terminal_message_raw_parsed_at"),
"terminal_message_raw",
["parsed_at"],
unique=False,
)
# Preserve historical parse outcomes before removing state-machine columns.
op.execute(
sa.text(
"""
UPDATE terminal_message_raw
SET parsed_at = COALESCE(received_at, created_at)
WHERE parse_status <> 'pending'
"""
)
)
op.drop_index(
op.f("ix_terminal_message_raw_next_retry_at"),
table_name="terminal_message_raw",
)
op.drop_index(
op.f("ix_terminal_message_raw_parse_status"),
table_name="terminal_message_raw",
)
op.drop_column("terminal_message_raw", "next_retry_at")
op.drop_column("terminal_message_raw", "parse_attempts")
op.drop_column("terminal_message_raw", "parse_status")
def downgrade() -> None:
op.add_column(
"terminal_message_raw",
sa.Column(
"parse_status",
sa.String(length=32),
nullable=False,
server_default="pending",
),
)
op.add_column(
"terminal_message_raw",
sa.Column("parse_attempts", sa.Integer(), nullable=False, server_default="0"),
)
op.add_column(
"terminal_message_raw",
sa.Column("next_retry_at", sa.DateTime(timezone=True), nullable=True),
)
op.create_index(
op.f("ix_terminal_message_raw_parse_status"),
"terminal_message_raw",
["parse_status"],
unique=False,
)
op.create_index(
op.f("ix_terminal_message_raw_next_retry_at"),
"terminal_message_raw",
["next_retry_at"],
unique=False,
)
op.execute(
sa.text(
"""
UPDATE terminal_message_raw
SET parse_status = CASE
WHEN parsed_at IS NULL THEN 'pending'
WHEN parse_error IS NULL THEN 'succeeded'
ELSE 'failed'
END,
parse_attempts = CASE
WHEN parsed_at IS NULL THEN 0
ELSE 1
END
"""
)
)
op.drop_index(
op.f("ix_terminal_message_raw_parsed_at"),
table_name="terminal_message_raw",
)
op.drop_column("terminal_message_raw", "parsed_at")

View File

@@ -0,0 +1,60 @@
"""drop terminal observation event table
Revision ID: c6d7e8f90123
Revises: b4c5d6e7f809
Create Date: 2026-03-19 18:20:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "c6d7e8f90123"
down_revision = "b4c5d6e7f809"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.drop_table("terminal_observation_event")
def downgrade() -> None:
op.create_table(
"terminal_observation_event",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("raw_id", sa.Uuid(), nullable=False),
sa.Column("device_id", sa.String(length=255), nullable=False),
sa.Column("event_time", sa.DateTime(timezone=True), nullable=False),
sa.Column("event_type", sa.String(length=64), nullable=False),
sa.Column("payload_json", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["raw_id"], ["terminal_message_raw.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_terminal_observation_event_device_id"),
"terminal_observation_event",
["device_id"],
unique=False,
)
op.create_index(
op.f("ix_terminal_observation_event_event_time"),
"terminal_observation_event",
["event_time"],
unique=False,
)
op.create_index(
op.f("ix_terminal_observation_event_event_type"),
"terminal_observation_event",
["event_type"],
unique=False,
)
op.create_index(
op.f("ix_terminal_observation_event_raw_id"),
"terminal_observation_event",
["raw_id"],
unique=False,
)

View File

@@ -27,7 +27,7 @@ SessionDep = Annotated[Session, Depends(get_db)]
TokenDep = Annotated[str, Depends(reusable_oauth2)]
def get_current_user(session: SessionDep, token: TokenDep) -> User:
def resolve_user_from_token(*, session: Session, token: str) -> User:
try:
payload = jwt.decode(
token, settings.SECRET_KEY, algorithms=[security.ALGORITHM]
@@ -46,6 +46,10 @@ def get_current_user(session: SessionDep, token: TokenDep) -> User:
return user
def get_current_user(session: SessionDep, token: TokenDep) -> User:
return resolve_user_from_token(session=session, token=token)
CurrentUser = Annotated[User, Depends(get_current_user)]

View File

@@ -1,6 +1,14 @@
from fastapi import APIRouter
from app.api.routes import locations, login, private, users, utils
from app.api.routes import (
locations,
login,
mqtt_raw,
private,
telemetry,
users,
utils,
)
from app.core.config import settings
api_router = APIRouter()
@@ -8,8 +16,9 @@ api_router.include_router(login.router)
api_router.include_router(users.router)
api_router.include_router(utils.router)
api_router.include_router(locations.router)
api_router.include_router(telemetry.router)
api_router.include_router(mqtt_raw.router)
if settings.ENVIRONMENT == "local":
api_router.include_router(private.router)

View File

@@ -0,0 +1,300 @@
from __future__ import annotations
from datetime import date, datetime, time, timedelta, timezone
from pathlib import Path
from typing import Any
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import Response
from sqlmodel import col, delete, func, select
from app.api.deps import CurrentUser, SessionDep
from app.models import (
DeleteResult,
MqttRawDaysPublic,
MqttRawDaySummary,
TerminalMessageRaw,
TerminalRawArchiveManifest,
)
router = APIRouter(prefix="/mqtt-raw", tags=["mqtt_raw"])
UTC_PLUS_8 = timezone(timedelta(hours=8))
def _normalize_day(value: Any) -> date:
if isinstance(value, date) and not isinstance(value, datetime):
return value
if isinstance(value, datetime):
return value.date()
if isinstance(value, str):
return date.fromisoformat(value)
raise ValueError(f"Unsupported day value: {value!r}")
def _day_bounds(day: date) -> tuple[datetime, datetime]:
start_local = datetime.combine(day, time.min, tzinfo=UTC_PLUS_8)
end_local = start_local + timedelta(days=1)
return start_local.astimezone(timezone.utc), end_local.astimezone(timezone.utc)
def _local_day_expr(session: SessionDep, field: Any) -> Any:
bind = session.get_bind()
dialect_name = bind.dialect.name if bind is not None else ""
if dialect_name == "sqlite":
return func.date(func.datetime(field, "+8 hours"))
if dialect_name in {"postgresql", "postgres"}:
return func.date(func.timezone("Asia/Shanghai", field))
return func.date(field)
def _build_window(
*, day: date, start_seconds: int | None, end_seconds: int | None
) -> tuple[datetime, datetime, int, int]:
start_of_day_local = datetime.combine(day, time.min, tzinfo=UTC_PLUS_8)
start_sec = 0 if start_seconds is None else start_seconds
end_sec = 24 * 3600 if end_seconds is None else end_seconds
if not 0 <= start_sec <= 24 * 3600:
raise HTTPException(status_code=400, detail="start_seconds must be 0..86400")
if not 0 <= end_sec <= 24 * 3600:
raise HTTPException(status_code=400, detail="end_seconds must be 0..86400")
if start_sec >= end_sec:
raise HTTPException(
status_code=400, detail="start_seconds must be smaller than end_seconds"
)
start = (start_of_day_local + timedelta(seconds=start_sec)).astimezone(timezone.utc)
end = (start_of_day_local + timedelta(seconds=end_sec)).astimezone(timezone.utc)
return start, end, start_sec, end_sec
def _unlink_file(path_str: str) -> None:
path = Path(path_str)
try:
path.unlink(missing_ok=True)
except OSError:
return
@router.get(
"/days",
response_model=MqttRawDaysPublic,
operation_id="mqtt_raw_list_days",
)
def list_mqtt_raw_days(
session: SessionDep,
_current_user: CurrentUser,
skip: int = Query(default=0, ge=0),
limit: int = Query(default=30, ge=1, le=200),
) -> MqttRawDaysPublic:
days: dict[date, MqttRawDaySummary] = {}
raw_day_expr = _local_day_expr(session, TerminalMessageRaw.received_at)
raw_statement = (
select(
raw_day_expr,
func.count(),
func.min(TerminalMessageRaw.received_at),
func.max(TerminalMessageRaw.received_at),
)
.group_by(raw_day_expr)
.order_by(raw_day_expr.desc())
)
for day_value, message_count, first_at, last_at in session.exec(
raw_statement
).all():
day_key = _normalize_day(day_value)
days[day_key] = MqttRawDaySummary(
day=day_key,
message_count=int(message_count or 0),
first_received_at=first_at,
last_received_at=last_at,
)
archive_day_expr = _local_day_expr(session, TerminalRawArchiveManifest.range_start)
archive_statement = (
select(
archive_day_expr,
func.sum(TerminalRawArchiveManifest.record_count),
func.min(TerminalRawArchiveManifest.range_start),
func.max(TerminalRawArchiveManifest.range_end),
)
.group_by(archive_day_expr)
.order_by(archive_day_expr.desc())
)
for day_value, message_count, first_at, last_at in session.exec(
archive_statement
).all():
day_key = _normalize_day(day_value)
existing = days.get(day_key)
archive_count = int(message_count or 0)
if existing is None:
days[day_key] = MqttRawDaySummary(
day=day_key,
message_count=archive_count,
first_received_at=first_at,
last_received_at=last_at,
)
continue
existing.message_count += archive_count
if first_at and (
existing.first_received_at is None or first_at < existing.first_received_at
):
existing.first_received_at = first_at
if last_at and (
existing.last_received_at is None or last_at > existing.last_received_at
):
existing.last_received_at = last_at
ordered = sorted(days.values(), key=lambda item: item.day, reverse=True)
paged = ordered[skip : skip + limit]
return MqttRawDaysPublic(data=paged, count=len(ordered))
@router.get(
"/download",
operation_id="mqtt_raw_download",
responses={
200: {
"content": {
"application/octet-stream": {
"schema": {"type": "string", "format": "binary"}
}
}
}
},
)
def download_mqtt_raw(
session: SessionDep,
_current_user: CurrentUser,
day: date,
start_seconds: int | None = Query(default=None),
end_seconds: int | None = Query(default=None),
) -> Response:
range_start, range_end, start_sec, end_sec = _build_window(
day=day,
start_seconds=start_seconds,
end_seconds=end_seconds,
)
chunks: list[tuple[datetime, bytes]] = []
raw_statement = (
select(TerminalMessageRaw)
.where(TerminalMessageRaw.received_at >= range_start)
.where(TerminalMessageRaw.received_at < range_end)
.order_by(col(TerminalMessageRaw.received_at), col(TerminalMessageRaw.id))
)
raw_rows = session.exec(raw_statement).all()
for row in raw_rows:
chunks.append((row.received_at, row.payload_bytes))
manifest_statement = (
select(TerminalRawArchiveManifest)
.where(TerminalRawArchiveManifest.range_start >= range_start)
.where(TerminalRawArchiveManifest.range_end <= range_end)
.order_by(
col(TerminalRawArchiveManifest.range_start),
col(TerminalRawArchiveManifest.range_end),
)
)
manifests = session.exec(manifest_statement).all()
for manifest in manifests:
path = Path(manifest.file_path)
if not path.exists() or not path.is_file():
continue
chunks.append((manifest.range_start, path.read_bytes()))
chunks.sort(key=lambda item: item[0])
payload = b"".join(chunk for _, chunk in chunks)
name_suffix = (
day.isoformat()
if start_sec == 0 and end_sec == 24 * 3600
else f"{day.isoformat()}_{start_sec}_{end_sec}"
)
filename = f"mqtt_raw_{name_suffix}.txt"
return Response(
content=payload,
media_type="application/octet-stream",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.delete(
"/day/{day}",
response_model=DeleteResult,
operation_id="mqtt_raw_delete_day",
)
def delete_mqtt_raw_by_day(
session: SessionDep,
_current_user: CurrentUser,
day: date,
) -> DeleteResult:
range_start, range_end = _day_bounds(day)
raw_statement = (
select(TerminalMessageRaw.id)
.where(TerminalMessageRaw.received_at >= range_start)
.where(TerminalMessageRaw.received_at < range_end)
)
raw_ids = list(session.exec(raw_statement).all())
if raw_ids:
session.exec(
delete(TerminalMessageRaw).where(col(TerminalMessageRaw.id).in_(raw_ids))
)
manifest_statement = (
select(TerminalRawArchiveManifest)
.where(TerminalRawArchiveManifest.range_start >= range_start)
.where(TerminalRawArchiveManifest.range_end <= range_end)
)
manifests = session.exec(manifest_statement).all()
for manifest in manifests:
_unlink_file(manifest.file_path)
session.delete(manifest)
session.commit()
deleted_count = len(raw_ids) + len(manifests)
return DeleteResult(
deleted_count=deleted_count,
message=f"Deleted {deleted_count} record(s) for {day.isoformat()}",
)
@router.delete(
"/all",
response_model=DeleteResult,
operation_id="mqtt_raw_delete_all",
)
def delete_all_mqtt_raw(
session: SessionDep,
_current_user: CurrentUser,
confirm: bool = Query(default=False),
) -> DeleteResult:
if not confirm:
raise HTTPException(
status_code=400,
detail="Set confirm=true to delete all saved MQTT raw data",
)
raw_ids = list(session.exec(select(TerminalMessageRaw.id)).all())
if raw_ids:
session.exec(
delete(TerminalMessageRaw).where(col(TerminalMessageRaw.id).in_(raw_ids))
)
manifests = session.exec(select(TerminalRawArchiveManifest)).all()
for manifest in manifests:
_unlink_file(manifest.file_path)
session.delete(manifest)
session.commit()
deleted_count = len(raw_ids) + len(manifests)
return DeleteResult(
deleted_count=deleted_count,
message=f"Deleted {deleted_count} record(s) from MQTT raw storage",
)

View File

@@ -0,0 +1,63 @@
from __future__ import annotations
import asyncio
from typing import Any
from fastapi import APIRouter, HTTPException, Query, WebSocket, status
from fastapi.encoders import jsonable_encoder
from fastapi.websockets import WebSocketDisconnect
from sqlmodel import Session
from app.api.deps import CurrentUser, SessionDep, resolve_user_from_token
from app.core.db import engine
from app.mqtt.telemetry import build_telemetry_snapshot
router = APIRouter(prefix="/telemetry", tags=["telemetry"])
STREAM_INTERVAL_SECONDS = 1.0
@router.get("/latest")
def read_telemetry_latest(
session: SessionDep,
_current_user: CurrentUser,
device_id: str | None = None,
limit: int = Query(default=50, ge=1, le=200),
) -> dict[str, Any]:
"""
Return the latest parsed GNSS telemetry snapshot.
"""
return build_telemetry_snapshot(session=session, device_id=device_id, limit=limit)
@router.websocket("/ws")
async def telemetry_websocket(
websocket: WebSocket,
token: str | None = Query(default=None),
device_id: str | None = Query(default=None),
limit: int = Query(default=50, ge=1, le=200),
) -> None:
if not token:
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
return
with Session(engine) as session:
try:
resolve_user_from_token(session=session, token=token)
except HTTPException:
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
return
await websocket.accept()
try:
while True:
with Session(engine) as session:
payload = build_telemetry_snapshot(
session=session,
device_id=device_id,
limit=limit,
)
await websocket.send_json(jsonable_encoder(payload))
await asyncio.sleep(STREAM_INTERVAL_SECONDS)
except (WebSocketDisconnect, RuntimeError):
return

View File

@@ -56,6 +56,17 @@ class Settings(BaseSettings):
POSTGRES_PASSWORD: str = ""
POSTGRES_DB: str = ""
MQTT_HOST: str = "101.35.119.226"
MQTT_PORT: int = 1883
MQTT_USERNAME: str = ""
MQTT_PASSWORD: str = ""
MQTT_TOPIC_PATTERN: str = "terminal/+/raw"
MQTT_CLIENT_ID: str = "spatialhub-mqtt-ingestor"
RAW_HOT_RETENTION_DAYS: int = 90
RAW_ARCHIVE_BASE_DIR: str = "/data/archive/gnss"
PARSER_BATCH_SIZE: int = 200
PARSER_POLL_INTERVAL_MS: int = 300
@computed_field # type: ignore[prop-decorator]
@property
def SQLALCHEMY_DATABASE_URI(self) -> PostgresDsn:

View File

@@ -1,8 +1,9 @@
import uuid
from datetime import datetime, timezone
from datetime import date, datetime, timezone
from typing import Any
from pydantic import EmailStr
from sqlalchemy import DateTime
from sqlalchemy import JSON, DateTime, LargeBinary
from sqlmodel import Field, Relationship, SQLModel
@@ -53,7 +54,9 @@ class User(UserBase, table=True):
default_factory=get_datetime_utc,
sa_type=DateTime(timezone=True), # type: ignore
)
locations: list["Location"] = Relationship(back_populates="owner", cascade_delete=True)
locations: list["Location"] = Relationship(
back_populates="owner", cascade_delete=True
)
# Properties to return via API, id is always required
@@ -108,11 +111,98 @@ class LocationsPublic(SQLModel):
count: int
class MqttRawDaySummary(SQLModel):
day: date
message_count: int
first_received_at: datetime | None = None
last_received_at: datetime | None = None
class MqttRawDaysPublic(SQLModel):
data: list[MqttRawDaySummary]
count: int
class TerminalMessageRaw(SQLModel, table=True):
__tablename__ = "terminal_message_raw"
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
device_id: str = Field(max_length=255, index=True)
topic: str = Field(max_length=255)
received_at: datetime = Field(
default_factory=get_datetime_utc,
sa_type=DateTime(timezone=True), # type: ignore
)
payload_bytes: bytes = Field(sa_type=LargeBinary)
payload_size: int = 0
payload_sha256: str = Field(default="", max_length=64, index=True)
qos: int = 0
retain: bool = False
parsed_at: datetime | None = Field(
default=None,
sa_type=DateTime(timezone=True), # type: ignore
index=True,
)
parse_error: str | None = Field(default=None, max_length=1024)
created_at: datetime = Field(
default_factory=get_datetime_utc,
sa_type=DateTime(timezone=True), # type: ignore
)
class TerminalDeviceState(SQLModel, table=True):
__tablename__ = "terminal_device_state"
device_id: str = Field(max_length=255, primary_key=True)
last_event_time: datetime | None = Field(
default=None, sa_type=DateTime(timezone=True)
) # type: ignore[call-overload]
fix_status: str | None = Field(default=None, max_length=64)
lat: float | None = None
lon: float | None = None
alt_m: float | None = None
satellites_used: int | None = None
satellite_signal_summary: dict[str, Any] | None = Field(default=None, sa_type=JSON)
rtcm_type_summary: dict[str, Any] | None = Field(default=None, sa_type=JSON)
updated_at: datetime = Field(
default_factory=get_datetime_utc,
sa_type=DateTime(timezone=True), # type: ignore
)
class TerminalRawArchiveManifest(SQLModel, table=True):
__tablename__ = "terminal_raw_archive_manifest"
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
device_id: str = Field(max_length=255, index=True)
range_start: datetime = Field(
sa_type=DateTime(timezone=True), # type: ignore
index=True,
)
range_end: datetime = Field(
sa_type=DateTime(timezone=True), # type: ignore
index=True,
)
file_path: str = Field(max_length=1024, unique=True)
file_size: int = 0
sha256: str = Field(max_length=64)
record_count: int = 0
created_at: datetime = Field(
default_factory=get_datetime_utc,
sa_type=DateTime(timezone=True), # type: ignore
)
# Generic message
class Message(SQLModel):
message: str
class DeleteResult(SQLModel):
deleted_count: int
message: str
# JSON payload containing access token
class Token(SQLModel):
access_token: str

View File

@@ -0,0 +1 @@
"""MQTT ingestion pipeline package."""

View File

@@ -0,0 +1,119 @@
from __future__ import annotations
import hashlib
import logging
from datetime import datetime, timedelta
from pathlib import Path
from sqlmodel import Session, col, select
from app.core.config import settings
from app.core.db import engine
from app.models import TerminalMessageRaw, TerminalRawArchiveManifest, get_datetime_utc
logger = logging.getLogger(__name__)
def _format_timestamp(value: datetime) -> str:
return value.strftime("%Y-%m-%dT%H-%M-%S")
def _build_archive_path(
*,
archive_base_dir: Path,
device_id: str,
range_start: datetime,
range_end: datetime,
) -> Path:
day = range_start.strftime("%Y-%m-%d")
output_dir = archive_base_dir / device_id / day
output_dir.mkdir(parents=True, exist_ok=True)
filename = (
f"device_{device_id}_{_format_timestamp(range_start)}"
f"_{_format_timestamp(range_end)}.txt"
)
return output_dir / filename
def run_archive_once(
*,
session: Session,
archive_base_dir: Path | str,
retention_days: int,
now: datetime | None = None,
) -> list[TerminalRawArchiveManifest]:
archive_root = Path(archive_base_dir)
ts_now = now or get_datetime_utc()
cutoff = ts_now - timedelta(days=retention_days)
statement = (
select(TerminalMessageRaw)
.where(TerminalMessageRaw.received_at < cutoff)
.order_by(
col(TerminalMessageRaw.device_id), col(TerminalMessageRaw.received_at)
)
)
rows = session.exec(statement).all()
if not rows:
return []
grouped: dict[tuple[str, str], list[TerminalMessageRaw]] = {}
for row in rows:
key = (row.device_id, row.received_at.strftime("%Y-%m-%d"))
grouped.setdefault(key, []).append(row)
manifests: list[TerminalRawArchiveManifest] = []
for (_, _), group_rows in grouped.items():
range_start = group_rows[0].received_at
range_end = group_rows[-1].received_at
output_path = _build_archive_path(
archive_base_dir=archive_root,
device_id=group_rows[0].device_id,
range_start=range_start,
range_end=range_end,
)
digest = hashlib.sha256()
record_count = 0
with output_path.open("wb") as out:
for row in group_rows:
out.write(row.payload_bytes)
digest.update(row.payload_bytes)
record_count += 1
manifest = TerminalRawArchiveManifest(
device_id=group_rows[0].device_id,
range_start=range_start,
range_end=range_end,
file_path=str(output_path),
file_size=output_path.stat().st_size,
sha256=digest.hexdigest(),
record_count=record_count,
)
session.add(manifest)
manifests.append(manifest)
for row in group_rows:
session.delete(row)
session.commit()
for manifest in manifests:
session.refresh(manifest)
return manifests
def run() -> None:
logging.basicConfig(level=logging.INFO)
with Session(engine) as session:
manifests = run_archive_once(
session=session,
archive_base_dir=settings.RAW_ARCHIVE_BASE_DIR,
retention_days=settings.RAW_HOT_RETENTION_DAYS,
)
logger.info("Archived %s file(s)", len(manifests))
if __name__ == "__main__":
run()

View File

@@ -0,0 +1,93 @@
from __future__ import annotations
import logging
import re
from typing import Any
from sqlmodel import Session
from app.core.config import settings
from app.core.db import engine
from app.mqtt.repository import insert_raw_message
logger = logging.getLogger(__name__)
TOPIC_PATTERN = re.compile(r"^terminal/(?P<device_id>[^/]+)/raw$")
def extract_device_id(topic: str) -> str:
match = TOPIC_PATTERN.match(topic)
if match is None:
raise ValueError("topic must match terminal/{device_id}/raw")
return match.group("device_id")
def _on_connect(
client: Any,
_userdata: object,
_flags: dict[str, int],
reason_code: int,
_properties: object | None = None,
) -> None:
if reason_code != 0:
logger.error("MQTT connect failed: rc=%s", reason_code)
return
logger.info(
"Connected to MQTT broker %s:%s", settings.MQTT_HOST, settings.MQTT_PORT
)
client.subscribe(settings.MQTT_TOPIC_PATTERN, qos=1)
def _on_message(
_client: Any,
_userdata: object,
message: Any,
) -> None:
try:
device_id = extract_device_id(message.topic)
except ValueError:
logger.warning("Skipping unexpected topic: %s", message.topic)
return
with Session(engine) as session:
insert_raw_message(
session=session,
device_id=device_id,
topic=message.topic,
payload=bytes(message.payload),
qos=message.qos,
retain=message.retain,
)
def create_client() -> Any:
try:
import paho.mqtt.client as mqtt # type: ignore[import-not-found,import-untyped]
except ModuleNotFoundError as exc: # pragma: no cover - runtime guard
raise RuntimeError(
"paho-mqtt is required to run mqtt-ingestor. Install backend dependencies first."
) from exc
client = mqtt.Client(
callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
client_id=settings.MQTT_CLIENT_ID,
clean_session=False,
)
if settings.MQTT_USERNAME:
client.username_pw_set(settings.MQTT_USERNAME, settings.MQTT_PASSWORD)
client.on_connect = _on_connect
client.on_message = _on_message
client.reconnect_delay_set(min_delay=1, max_delay=30)
return client
def run() -> None:
logging.basicConfig(level=logging.INFO)
client = create_client()
client.connect(settings.MQTT_HOST, settings.MQTT_PORT, keepalive=60)
client.loop_forever()
if __name__ == "__main__":
run()

View File

@@ -0,0 +1,60 @@
from __future__ import annotations
import logging
import time
from sqlmodel import Session
from app.core.config import settings
from app.core.db import engine
from app.models import TerminalMessageRaw
from app.mqtt.parsers.mixed import parse_mixed_payload
from app.mqtt.repository import (
fetch_pending_rows_for_update,
mark_failed,
mark_succeeded,
update_device_state_from_events,
)
logger = logging.getLogger(__name__)
def _process_one_row(*, session: Session, row: TerminalMessageRaw) -> None:
try:
events = parse_mixed_payload(row.payload_bytes)
update_device_state_from_events(session=session, row=row, events=events)
mark_succeeded(session=session, row=row)
except Exception as exc: # pragma: no cover - safety net path
mark_failed(
session=session,
row=row,
parse_error=str(exc),
)
session.commit()
def process_pending_batch(*, session: Session, batch_size: int) -> int:
rows = fetch_pending_rows_for_update(session=session, batch_size=batch_size)
for row in rows:
_process_one_row(session=session, row=row)
return len(rows)
def run() -> None:
logging.basicConfig(level=logging.INFO)
sleep_seconds = max(settings.PARSER_POLL_INTERVAL_MS, 50) / 1000.0
while True:
with Session(engine) as session:
processed = process_pending_batch(
session=session, batch_size=settings.PARSER_BATCH_SIZE
)
if processed == 0:
time.sleep(sleep_seconds)
else:
logger.info("Processed %s pending MQTT raw messages", processed)
if __name__ == "__main__":
run()

View File

@@ -0,0 +1,4 @@
from app.mqtt.parsers.mixed import parse_mixed_payload, summarize_rtcm_types
from app.mqtt.parsers.nmea import parse_nmea_sentences
__all__ = ["parse_mixed_payload", "parse_nmea_sentences", "summarize_rtcm_types"]

View File

@@ -0,0 +1,44 @@
from __future__ import annotations
from typing import Any
from app.mqtt.parsers.nmea import parse_nmea_sentences
def summarize_rtcm_types(payload: bytes) -> dict[int, int]:
counts: dict[int, int] = {}
i = 0
while i + 6 <= len(payload):
if payload[i] != 0xD3:
i += 1
continue
length = ((payload[i + 1] & 0x03) << 8) | payload[i + 2]
frame_end = i + 3 + length + 3
if frame_end > len(payload):
break
message_type = ((payload[i + 3] << 4) | (payload[i + 4] >> 4)) & 0x0FFF
counts[message_type] = counts.get(message_type, 0) + 1
i = frame_end
return counts
def parse_mixed_payload(payload: bytes) -> list[dict[str, Any]]:
events = parse_nmea_sentences(payload)
rtcm_types = summarize_rtcm_types(payload)
if rtcm_types:
events.append(
{
"event_type": "rtcm_summary",
"payload": {
"rtcm_message_count": sum(rtcm_types.values()),
"rtcm_types": rtcm_types,
},
}
)
return events

View File

@@ -0,0 +1,192 @@
from __future__ import annotations
from typing import Any
SUPPORTED_NMEA_TYPES = {"GGA", "RMC", "GSV", "GSA", "GST"}
TALKER_TO_CONSTELLATION = {
"GP": "GPS",
"GL": "GLONASS",
"GA": "Galileo",
"GB": "BeiDou",
"BD": "BeiDou",
"GQ": "QZSS",
"GI": "NavIC",
"GN": "Mixed",
}
def _to_float(value: str) -> float | None:
if not value:
return None
try:
return float(value)
except ValueError:
return None
def _to_int(value: str) -> int | None:
if not value:
return None
try:
return int(value)
except ValueError:
return None
def _parse_lat_lon(value: str, hemisphere: str) -> float | None:
if not value or not hemisphere:
return None
try:
degree_len = 2 if hemisphere in {"N", "S"} else 3
degrees = float(value[:degree_len])
minutes = float(value[degree_len:])
except ValueError:
return None
decimal = degrees + (minutes / 60.0)
if hemisphere in {"S", "W"}:
decimal *= -1
return decimal
def _extract_sentence_meta(sentence: str) -> tuple[str, str] | None:
if not sentence.startswith("$"):
return None
if len(sentence) < 6:
return None
talker = sentence[1:3].upper()
nmea_type = sentence[3:6].upper()
if nmea_type not in SUPPORTED_NMEA_TYPES:
return None
return talker, nmea_type
def _parse_satellite_blocks(fields: list[str]) -> list[dict[str, Any]]:
satellites: list[dict[str, Any]] = []
for i in range(4, len(fields), 4):
if i + 3 >= len(fields):
continue
satellite_id = fields[i]
if not satellite_id:
continue
satellites.append(
{
"satellite_id": satellite_id,
"elevation_deg": _to_int(fields[i + 1]),
"azimuth_deg": _to_int(fields[i + 2]),
"snr_dbhz": _to_int(fields[i + 3]),
}
)
return satellites
def _parse_nmea_fields(
*, talker: str, nmea_type: str, fields: list[str]
) -> dict[str, Any]:
payload: dict[str, Any] = {
"sentence_type": nmea_type,
"talker": talker,
"constellation": TALKER_TO_CONSTELLATION.get(talker, "Unknown"),
"raw_fields": fields,
}
if nmea_type == "GGA":
payload.update(
{
"utc_time": fields[1] if len(fields) > 1 else None,
"lat": _parse_lat_lon(
fields[2] if len(fields) > 2 else "",
fields[3] if len(fields) > 3 else "",
),
"lon": _parse_lat_lon(
fields[4] if len(fields) > 4 else "",
fields[5] if len(fields) > 5 else "",
),
"fix_quality": _to_int(fields[6] if len(fields) > 6 else ""),
"satellites_used": _to_int(fields[7] if len(fields) > 7 else ""),
"hdop": _to_float(fields[8] if len(fields) > 8 else ""),
"altitude_m": _to_float(fields[9] if len(fields) > 9 else ""),
}
)
elif nmea_type == "RMC":
payload.update(
{
"utc_time": fields[1] if len(fields) > 1 else None,
"status": fields[2] if len(fields) > 2 else None,
"lat": _parse_lat_lon(
fields[3] if len(fields) > 3 else "",
fields[4] if len(fields) > 4 else "",
),
"lon": _parse_lat_lon(
fields[5] if len(fields) > 5 else "",
fields[6] if len(fields) > 6 else "",
),
"speed_knots": _to_float(fields[7] if len(fields) > 7 else ""),
"course_deg": _to_float(fields[8] if len(fields) > 8 else ""),
"date": fields[9] if len(fields) > 9 else None,
}
)
elif nmea_type == "GSV":
satellites = _parse_satellite_blocks(fields)
payload.update(
{
"total_messages": _to_int(fields[1] if len(fields) > 1 else ""),
"message_index": _to_int(fields[2] if len(fields) > 2 else ""),
"satellites_in_view": _to_int(fields[3] if len(fields) > 3 else ""),
"satellites": satellites,
"snr_values": [
satellite["snr_dbhz"]
for satellite in satellites
if satellite["snr_dbhz"] is not None
],
}
)
elif nmea_type == "GSA":
used_satellite_ids = [sat_id for sat_id in fields[3:15] if sat_id]
payload.update(
{
"mode": fields[1] if len(fields) > 1 else None,
"fix_type": _to_int(fields[2] if len(fields) > 2 else ""),
"used_satellite_ids": used_satellite_ids,
"pdop": _to_float(fields[15] if len(fields) > 15 else ""),
"hdop": _to_float(fields[16] if len(fields) > 16 else ""),
"vdop": _to_float(fields[17] if len(fields) > 17 else ""),
}
)
elif nmea_type == "GST":
payload.update(
{
"utc_time": fields[1] if len(fields) > 1 else None,
"rms": _to_float(fields[2] if len(fields) > 2 else ""),
"std_major": _to_float(fields[3] if len(fields) > 3 else ""),
"std_minor": _to_float(fields[4] if len(fields) > 4 else ""),
}
)
return payload
def parse_nmea_sentences(payload: bytes) -> list[dict[str, Any]]:
events: list[dict[str, Any]] = []
for raw_line in payload.splitlines():
if not raw_line.startswith(b"$"):
continue
sentence = raw_line.decode("ascii", errors="ignore").strip()
sentence_meta = _extract_sentence_meta(sentence)
if sentence_meta is None:
continue
talker, nmea_type = sentence_meta
main_part = sentence.split("*", 1)[0]
fields = main_part.split(",")
parsed_payload = _parse_nmea_fields(
talker=talker, nmea_type=nmea_type, fields=fields
)
events.append(
{"event_type": f"nmea_{nmea_type.lower()}", "payload": parsed_payload}
)
return events

View File

@@ -0,0 +1,237 @@
from __future__ import annotations
import hashlib
from datetime import datetime
from typing import Any
from sqlmodel import Session, col, select
from app.models import (
TerminalDeviceState,
TerminalMessageRaw,
get_datetime_utc,
)
def insert_raw_message(
*,
session: Session,
device_id: str,
topic: str,
payload: bytes,
qos: int,
retain: bool,
received_at: datetime | None = None,
) -> TerminalMessageRaw:
ts = received_at or get_datetime_utc()
row = TerminalMessageRaw(
device_id=device_id,
topic=topic,
received_at=ts,
payload_bytes=payload,
payload_size=len(payload),
payload_sha256=hashlib.sha256(payload).hexdigest(),
qos=qos,
retain=retain,
created_at=ts,
)
session.add(row)
session.commit()
session.refresh(row)
return row
def fetch_pending_rows_for_update(
*, session: Session, batch_size: int
) -> list[TerminalMessageRaw]:
statement = (
select(TerminalMessageRaw)
.where(col(TerminalMessageRaw.parsed_at).is_(None))
.where(col(TerminalMessageRaw.parse_error).is_(None))
.order_by(col(TerminalMessageRaw.received_at))
.limit(batch_size)
)
bind = session.get_bind()
if bind is not None and bind.dialect.name != "sqlite":
statement = statement.with_for_update(skip_locked=True)
rows = session.exec(statement).all()
return list(rows)
def mark_succeeded(*, session: Session, row: TerminalMessageRaw) -> None:
row.parsed_at = get_datetime_utc()
row.parse_error = None
session.add(row)
def mark_failed(
*,
session: Session,
row: TerminalMessageRaw,
parse_error: str,
) -> None:
row.parsed_at = get_datetime_utc()
row.parse_error = parse_error[:1024]
session.add(row)
def _build_satellite_signal_summary(
*,
gsv_satellites: list[dict[str, Any]],
satellites_in_view: int | None,
used_satellite_ids: set[str],
) -> dict[str, Any]:
deduped: dict[tuple[str, str], dict[str, Any]] = {}
for satellite in gsv_satellites:
constellation = str(satellite.get("constellation", "Unknown"))
satellite_id = str(satellite.get("satellite_id", "")).strip()
if not satellite_id:
continue
key = (constellation, satellite_id)
existing = deduped.get(key)
if existing is None:
deduped[key] = satellite
continue
incoming_snr = satellite.get("snr_dbhz")
existing_snr = existing.get("snr_dbhz")
if isinstance(incoming_snr, int) and (
not isinstance(existing_snr, int) or incoming_snr > existing_snr
):
deduped[key] = satellite
satellites = list(deduped.values())
satellites.sort(
key=lambda sat: (str(sat["constellation"]), str(sat["satellite_id"]))
)
snr_values: list[int] = []
cn0_values: list[int] = []
constellation_summary: dict[str, dict[str, int]] = {}
for satellite in gsv_satellites:
cn0_value = satellite.get("snr_dbhz")
if isinstance(cn0_value, int):
cn0_values.append(cn0_value)
for satellite in satellites:
constellation = str(satellite["constellation"])
sat_id = str(satellite["satellite_id"])
used_in_navigation = sat_id in used_satellite_ids
satellite["used_in_navigation"] = used_in_navigation
constellation_item = constellation_summary.setdefault(
constellation,
{"count": 0, "with_signal": 0, "used_in_navigation": 0},
)
constellation_item["count"] += 1
if used_in_navigation:
constellation_item["used_in_navigation"] += 1
snr_dbhz = satellite.get("snr_dbhz")
if isinstance(snr_dbhz, int):
snr_values.append(snr_dbhz)
constellation_item["with_signal"] += 1
return {
"satellites_in_view": satellites_in_view or len(satellites),
"snr_values": snr_values,
"cn0_values": cn0_values,
"used_satellite_ids": sorted(used_satellite_ids),
"constellations": constellation_summary,
"satellites": satellites,
}
def update_device_state_from_events(
*,
session: Session,
row: TerminalMessageRaw,
events: list[dict[str, Any]],
) -> None:
if not events:
state = session.get(TerminalDeviceState, row.device_id)
if state is None:
state = TerminalDeviceState(device_id=row.device_id)
state.last_event_time = row.received_at
state.updated_at = get_datetime_utc()
session.add(state)
return
state = session.get(TerminalDeviceState, row.device_id)
if state is None:
state = TerminalDeviceState(device_id=row.device_id)
gsv_satellites: list[dict[str, Any]] = []
satellites_in_view: int | None = None
used_satellite_ids: set[str] = set()
for event in events:
payload = event.get("payload", {})
event_type = str(event.get("event_type", "unknown"))
if event_type == "nmea_gga":
state.lat = payload.get("lat")
state.lon = payload.get("lon")
state.alt_m = payload.get("altitude_m")
state.satellites_used = payload.get("satellites_used")
fix_quality = payload.get("fix_quality")
if fix_quality is not None:
state.fix_status = str(fix_quality)
elif event_type == "nmea_rmc":
if payload.get("lat") is not None:
state.lat = payload.get("lat")
if payload.get("lon") is not None:
state.lon = payload.get("lon")
status = payload.get("status")
if status is not None and state.fix_status is None:
state.fix_status = str(status)
elif event_type == "nmea_gsv":
payload_satellites = payload.get("satellites", [])
if isinstance(payload_satellites, list):
for sat in payload_satellites:
if not isinstance(sat, dict):
continue
gsv_satellites.append(
{
"satellite_id": str(sat.get("satellite_id", "")),
"elevation_deg": sat.get("elevation_deg"),
"azimuth_deg": sat.get("azimuth_deg"),
"snr_dbhz": sat.get("snr_dbhz"),
"constellation": payload.get("constellation", "Unknown"),
}
)
satellites_count = payload.get("satellites_in_view")
if isinstance(satellites_count, int):
satellites_in_view = max(satellites_in_view or 0, satellites_count)
elif event_type == "nmea_gsa":
for satellite_id in payload.get("used_satellite_ids", []):
if satellite_id is None:
continue
used_satellite_ids.add(str(satellite_id))
elif event_type == "rtcm_summary":
state.rtcm_type_summary = payload.get("rtcm_types", {})
if gsv_satellites:
state.satellite_signal_summary = _build_satellite_signal_summary(
gsv_satellites=gsv_satellites,
satellites_in_view=satellites_in_view,
used_satellite_ids=used_satellite_ids,
)
elif used_satellite_ids and state.satellite_signal_summary:
current_summary = dict(state.satellite_signal_summary)
current_summary["used_satellite_ids"] = sorted(used_satellite_ids)
satellites = current_summary.get("satellites", [])
if isinstance(satellites, list):
for satellite in satellites:
if not isinstance(satellite, dict):
continue
sat_id = str(satellite.get("satellite_id", ""))
satellite["used_in_navigation"] = sat_id in used_satellite_ids
state.satellite_signal_summary = current_summary
state.last_event_time = row.received_at
state.updated_at = get_datetime_utc()
session.add(state)

View File

@@ -0,0 +1,158 @@
from __future__ import annotations
from typing import Any
from sqlmodel import Session, col, select
from app.models import TerminalDeviceState, get_datetime_utc
def _to_int(value: Any) -> int | None:
if isinstance(value, bool):
return None
if isinstance(value, int):
return value
if isinstance(value, float):
return int(value)
if isinstance(value, str):
stripped = value.strip()
if stripped.isdigit():
return int(stripped)
return None
def _normalize_satellite_summary(summary: Any) -> dict[str, Any]:
if not isinstance(summary, dict):
return {
"satellites_in_view": None,
"snr_values": [],
"cn0_values": [],
"constellations": {},
"used_satellite_ids": [],
"satellites": [],
}
raw_satellites = summary.get("satellites", [])
satellites: list[dict[str, Any]] = []
if isinstance(raw_satellites, list):
for satellite in raw_satellites:
if not isinstance(satellite, dict):
continue
satellite_id = str(satellite.get("satellite_id", "")).strip()
if not satellite_id:
continue
satellites.append(
{
"satellite_id": satellite_id,
"constellation": str(satellite.get("constellation", "Unknown")),
"azimuth_deg": _to_int(satellite.get("azimuth_deg")),
"elevation_deg": _to_int(satellite.get("elevation_deg")),
"snr_dbhz": _to_int(satellite.get("snr_dbhz")),
"used_in_navigation": bool(
satellite.get("used_in_navigation", False)
),
}
)
snr_values: list[int] = []
raw_snr_values = summary.get("snr_values", [])
if isinstance(raw_snr_values, list):
for value in raw_snr_values:
converted = _to_int(value)
if converted is not None:
snr_values.append(converted)
cn0_values: list[int] = []
raw_cn0_values = summary.get("cn0_values", [])
if isinstance(raw_cn0_values, list):
for value in raw_cn0_values:
converted = _to_int(value)
if converted is not None:
cn0_values.append(converted)
if not cn0_values:
cn0_values = list(snr_values)
satellites_in_view = _to_int(summary.get("satellites_in_view"))
if satellites_in_view is None:
satellites_in_view = len(satellites)
constellations = summary.get("constellations", {})
if not isinstance(constellations, dict):
constellations = {}
used_satellite_ids = summary.get("used_satellite_ids", [])
if not isinstance(used_satellite_ids, list):
used_satellite_ids = []
return {
"satellites_in_view": satellites_in_view,
"snr_values": snr_values,
"cn0_values": cn0_values,
"constellations": constellations,
"used_satellite_ids": used_satellite_ids,
"satellites": satellites,
}
def _build_signal_health(snr_values: list[int]) -> dict[str, Any]:
strong_signal_count = sum(1 for value in snr_values if value >= 40)
average_snr = (sum(snr_values) / len(snr_values)) if snr_values else None
best_snr = max(snr_values) if snr_values else None
if strong_signal_count >= 20:
level = "good"
message = "At least 20 satellites have CN0 >= 40"
elif strong_signal_count >= 10:
level = "fair"
message = "At least 10 satellites have CN0 >= 40"
else:
level = "poor"
message = "Less than 10 satellites have CN0 >= 40"
score = min(strong_signal_count * 5, 100)
return {
"level": level,
"score": score,
"strong_signal_count": strong_signal_count,
"average_snr_dbhz": round(average_snr, 1) if average_snr is not None else None,
"best_snr_dbhz": best_snr,
"message": message,
}
def _serialize_device_state(state: TerminalDeviceState) -> dict[str, Any]:
signal_summary = _normalize_satellite_summary(state.satellite_signal_summary)
signal_health = _build_signal_health(signal_summary["snr_values"])
return {
"device_id": state.device_id,
"last_event_time": state.last_event_time,
"updated_at": state.updated_at,
"fix_status": state.fix_status,
"lat": state.lat,
"lon": state.lon,
"alt_m": state.alt_m,
"satellites_used": state.satellites_used,
"satellite_signal_summary": signal_summary,
"signal_health": signal_health,
}
def build_telemetry_snapshot(
*,
session: Session,
device_id: str | None = None,
limit: int = 50,
) -> dict[str, Any]:
statement = select(TerminalDeviceState).order_by(
col(TerminalDeviceState.updated_at).desc()
)
if device_id:
statement = statement.where(TerminalDeviceState.device_id == device_id)
statement = statement.limit(limit)
states = session.exec(statement).all()
devices = [_serialize_device_state(state) for state in states]
return {"generated_at": get_datetime_utc(), "devices": devices}

View File

@@ -19,6 +19,7 @@ dependencies = [
"sentry-sdk[fastapi]>=2.0.0,<3.0.0",
"pyjwt<3.0.0,>=2.8.0",
"pwdlib[argon2,bcrypt]>=0.3.0",
"paho-mqtt<3.0.0,>=2.1.0",
]
[dependency-groups]

View File

@@ -0,0 +1,223 @@
from datetime import date, datetime, timezone
from fastapi.testclient import TestClient
from sqlmodel import Session, delete, func, select
from app.core.config import settings
from app.models import TerminalMessageRaw
from app.mqtt.repository import insert_raw_message
def _reset_raw_data(db: Session) -> None:
db.execute(delete(TerminalMessageRaw))
db.commit()
def _count_raw_rows(db: Session) -> int:
statement = select(func.count()).select_from(TerminalMessageRaw)
return int(db.exec(statement).one())
def test_list_mqtt_raw_days(
client: TestClient, superuser_token_headers: dict[str, str], db: Session
) -> None:
_reset_raw_data(db)
insert_raw_message(
session=db,
device_id="device-a",
topic="terminal/device-a/raw",
payload=b"A1",
qos=1,
retain=False,
received_at=datetime(2026, 3, 10, 8, 0, tzinfo=timezone.utc),
)
insert_raw_message(
session=db,
device_id="device-a",
topic="terminal/device-a/raw",
payload=b"A2",
qos=1,
retain=False,
received_at=datetime(2026, 3, 10, 21, 0, tzinfo=timezone.utc),
)
insert_raw_message(
session=db,
device_id="device-b",
topic="terminal/device-b/raw",
payload=b"B1",
qos=1,
retain=False,
received_at=datetime(2026, 3, 11, 9, 0, tzinfo=timezone.utc),
)
response = client.get(
f"{settings.API_V1_STR}/mqtt-raw/days?skip=0&limit=10",
headers=superuser_token_headers,
)
assert response.status_code == 200
content = response.json()
assert content["count"] == 2
assert content["data"][0]["day"] == "2026-03-11"
assert content["data"][0]["message_count"] == 2
assert content["data"][1]["day"] == "2026-03-10"
assert content["data"][1]["message_count"] == 1
def test_download_mqtt_raw_by_day_or_time_range(
client: TestClient, superuser_token_headers: dict[str, str], db: Session
) -> None:
_reset_raw_data(db)
target_day = date(2026, 3, 12)
insert_raw_message(
session=db,
device_id="device-a",
topic="terminal/device-a/raw",
payload=b"EARLY\n",
qos=1,
retain=False,
received_at=datetime(2026, 3, 11, 20, 0, tzinfo=timezone.utc),
)
insert_raw_message(
session=db,
device_id="device-a",
topic="terminal/device-a/raw",
payload=b"MID\n",
qos=1,
retain=False,
received_at=datetime(2026, 3, 12, 3, 0, tzinfo=timezone.utc),
)
insert_raw_message(
session=db,
device_id="device-a",
topic="terminal/device-a/raw",
payload=b"LATE\n",
qos=1,
retain=False,
received_at=datetime(2026, 3, 12, 12, 0, tzinfo=timezone.utc),
)
insert_raw_message(
session=db,
device_id="device-a",
topic="terminal/device-a/raw",
payload=b"NEXT_DAY\n",
qos=1,
retain=False,
received_at=datetime(2026, 3, 12, 18, 0, tzinfo=timezone.utc),
)
full_day_response = client.get(
f"{settings.API_V1_STR}/mqtt-raw/download",
headers=superuser_token_headers,
params={"day": target_day.isoformat()},
)
assert full_day_response.status_code == 200
assert full_day_response.content == b"EARLY\nMID\nLATE\n"
range_response = client.get(
f"{settings.API_V1_STR}/mqtt-raw/download",
headers=superuser_token_headers,
params={
"day": target_day.isoformat(),
"start_seconds": 10 * 3600,
"end_seconds": 14 * 3600,
},
)
assert range_response.status_code == 200
assert range_response.content == b"MID\n"
def test_delete_mqtt_raw_by_day(
client: TestClient, superuser_token_headers: dict[str, str], db: Session
) -> None:
_reset_raw_data(db)
insert_raw_message(
session=db,
device_id="device-x",
topic="terminal/device-x/raw",
payload=b"X",
qos=1,
retain=False,
received_at=datetime(2026, 3, 14, 20, 30, tzinfo=timezone.utc),
)
insert_raw_message(
session=db,
device_id="device-a",
topic="terminal/device-a/raw",
payload=b"A",
qos=1,
retain=False,
received_at=datetime(2026, 3, 15, 1, 0, tzinfo=timezone.utc),
)
insert_raw_message(
session=db,
device_id="device-b",
topic="terminal/device-b/raw",
payload=b"B",
qos=1,
retain=False,
received_at=datetime(2026, 3, 15, 12, 0, tzinfo=timezone.utc),
)
insert_raw_message(
session=db,
device_id="device-c",
topic="terminal/device-c/raw",
payload=b"C",
qos=1,
retain=False,
received_at=datetime(2026, 3, 15, 16, 30, tzinfo=timezone.utc),
)
response = client.delete(
f"{settings.API_V1_STR}/mqtt-raw/day/2026-03-15",
headers=superuser_token_headers,
)
assert response.status_code == 200
content = response.json()
assert content["deleted_count"] == 3
assert _count_raw_rows(db) == 1
def test_delete_all_mqtt_raw_requires_confirmation(
client: TestClient, superuser_token_headers: dict[str, str], db: Session
) -> None:
_reset_raw_data(db)
insert_raw_message(
session=db,
device_id="device-a",
topic="terminal/device-a/raw",
payload=b"A",
qos=1,
retain=False,
received_at=datetime(2026, 3, 15, 1, 0, tzinfo=timezone.utc),
)
insert_raw_message(
session=db,
device_id="device-b",
topic="terminal/device-b/raw",
payload=b"B",
qos=1,
retain=False,
received_at=datetime(2026, 3, 16, 1, 0, tzinfo=timezone.utc),
)
reject_response = client.delete(
f"{settings.API_V1_STR}/mqtt-raw/all",
headers=superuser_token_headers,
)
assert reject_response.status_code == 400
assert _count_raw_rows(db) == 2
confirmed_response = client.delete(
f"{settings.API_V1_STR}/mqtt-raw/all?confirm=true",
headers=superuser_token_headers,
)
assert confirmed_response.status_code == 200
content = confirmed_response.json()
assert content["deleted_count"] == 2
assert _count_raw_rows(db) == 0

View File

@@ -0,0 +1,127 @@
from datetime import datetime, timezone
from fastapi.testclient import TestClient
from sqlmodel import Session
from app.core.config import settings
from app.models import TerminalDeviceState
def _seed_device_state(
db: Session, device_id: str, snr_values: list[int] | None = None
) -> None:
state = db.get(TerminalDeviceState, device_id)
if state is None:
state = TerminalDeviceState(device_id=device_id)
signal_values = snr_values if snr_values is not None else [28, 31, 36, 41, 44]
state.fix_status = "5"
state.lat = 31.359591
state.lon = 120.714489
state.alt_m = 52.1
state.satellites_used = 17
state.satellite_signal_summary = {
"satellites_in_view": 22,
"snr_values": signal_values,
"constellations": {
"GPS": {"count": 8, "used_in_navigation": 6},
"BeiDou": {"count": 10, "used_in_navigation": 8},
"Galileo": {"count": 4, "used_in_navigation": 3},
},
"satellites": [
{
"satellite_id": "G05",
"constellation": "GPS",
"azimuth_deg": 121,
"elevation_deg": 29,
"snr_dbhz": 37,
"used_in_navigation": True,
}
],
}
state.last_event_time = datetime.now(timezone.utc)
db.add(state)
db.commit()
def test_read_telemetry_latest(
client: TestClient, superuser_token_headers: dict[str, str], db: Session
) -> None:
device_id = "device-api-001"
_seed_device_state(db, device_id)
response = client.get(
f"{settings.API_V1_STR}/telemetry/latest",
headers=superuser_token_headers,
)
assert response.status_code == 200
payload = response.json()
assert "generated_at" in payload
assert len(payload["devices"]) >= 1
device = next(item for item in payload["devices"] if item["device_id"] == device_id)
assert device["fix_status"] == "5"
assert device["signal_health"]["level"] == "poor"
def test_signal_health_levels_based_on_count_of_40_plus_signals(
client: TestClient, superuser_token_headers: dict[str, str], db: Session
) -> None:
_seed_device_state(db, "device-health-good", [40] * 20 + [15] * 3)
_seed_device_state(db, "device-health-fair", [40] * 10 + [20] * 3)
_seed_device_state(db, "device-health-poor", [40] * 9 + [39])
response = client.get(
f"{settings.API_V1_STR}/telemetry/latest",
headers=superuser_token_headers,
params={"limit": 200},
)
assert response.status_code == 200
devices = {item["device_id"]: item for item in response.json()["devices"]}
assert devices["device-health-good"]["signal_health"]["level"] == "good"
assert devices["device-health-fair"]["signal_health"]["level"] == "fair"
assert devices["device-health-poor"]["signal_health"]["level"] == "poor"
def test_signal_health_uses_satellite_level_values_for_ucenter_view(
client: TestClient, superuser_token_headers: dict[str, str], db: Session
) -> None:
state = db.get(TerminalDeviceState, "device-health-ucenter")
if state is None:
state = TerminalDeviceState(device_id="device-health-ucenter")
state.fix_status = "5"
state.satellite_signal_summary = {
# u-center signal bars are per-satellite bars, not per-frequency points
"snr_values": [41] * 9 + [30] * 4,
# keep raw CN0 points for diagnostics, but health should follow bar view
"cn0_values": [41] * 20 + [25] * 5,
}
db.add(state)
db.commit()
response = client.get(
f"{settings.API_V1_STR}/telemetry/latest",
headers=superuser_token_headers,
params={"device_id": "device-health-ucenter"},
)
assert response.status_code == 200
device = response.json()["devices"][0]
assert device["signal_health"]["strong_signal_count"] == 9
assert device["signal_health"]["level"] == "poor"
def test_telemetry_websocket_stream(
client: TestClient, superuser_token_headers: dict[str, str], db: Session
) -> None:
_seed_device_state(db, "device-api-002")
token = superuser_token_headers["Authorization"].split(" ", 1)[1]
with client.websocket_connect(
f"{settings.API_V1_STR}/telemetry/ws?token={token}"
) as websocket:
payload = websocket.receive_json()
assert "generated_at" in payload
assert len(payload["devices"]) >= 1

448
backend/tests/mqtt/nmea.txt Normal file
View File

@@ -0,0 +1,448 @@
$GNRMC,015903.000,A,3121.575445,N,12042.869341,E,0.015,51.12,080126,,E,A,V*48
$GNGGA,015903.000,3121.575445,N,12042.869341,E,1,17,1.09,51.800,M,0.000,M,,*79
$GPGSA,A,3,04,26,28,29,31,32,,,,,,,1.54,1.09,1.08,1*1C
$GBGSA,A,3,06,24,26,35,39,41,,,,,,,1.54,1.09,1.08,4*04
$GAGSA,A,3,09,,,,,,,,,,,,1.54,1.09,1.08,3*04
$GLGSA,A,3,67,75,76,85,,,,,,,,,1.54,1.09,1.08,2*0E
$GPGSV,3,1,9,03,24,284,00,04,16,319,38,16,47,239,00,26,74,310,29,1*55
$GPGSV,3,2,9,27,11,189,00,28,50,056,42,29,23,053,44,31,59,357,39,1*5A
$GPGSV,3,3,9,32,34,146,20,1*6A
$GPGSV,3,1,9,03,24,284,00,04,16,319,36,16,47,239,00,26,74,310,31,8*5B
$GPGSV,3,2,9,27,11,189,00,28,50,056,00,29,23,053,00,31,59,357,00,8*5F
$GPGSV,3,3,9,32,34,146,00,8*61
$GBGSV,8,1,29,01,44,140,00,02,38,237,00,03,52,199,00,04,32,124,00,1*78
$GBGSV,8,2,29,05,16,257,00,06,59,332,33,07,31,184,00,08,25,209,00,1*71
$GBGSV,8,3,29,09,50,309,00,10,20,194,00,12,17,131,00,13,30,222,00,1*7A
$GBGSV,8,4,29,14,50,202,00,24,59,014,39,25,37,278,00,26,22,058,40,1*77
$GBGSV,8,5,29,33,58,251,00,34,12,157,00,35,11,059,40,38,17,196,00,1*71
$GBGSV,8,6,29,39,62,002,35,40,29,169,00,41,28,312,37,42,29,177,00,1*7F
$GBGSV,8,7,29,44,23,105,00,50,09,164,00,59,48,145,00,60,33,239,00,1*76
$GBGSV,8,8,29,61,51,199,00,1*4F
$GBGSV,4,1,13,24,59,014,38,25,37,278,00,26,22,058,34,33,58,251,00,4*7B
$GBGSV,4,2,13,34,12,157,00,35,11,059,37,38,17,196,00,39,62,002,37,4*75
$GBGSV,4,3,13,40,29,169,00,41,28,312,29,42,29,177,00,44,23,105,00,4*7A
$GBGSV,4,4,13,50,09,164,00,4*4E
$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,37,19,13,135,00,7*4B
$GAGSV,2,2,5,31,78,255,00,7*79
$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,40,19,13,135,00,1*4D
$GAGSV,2,2,5,31,78,255,00,1*7F
$GLGSV,2,1,8,65,39,176,00,66,72,275,00,67,21,329,36,75,13,044,43,1*47
$GLGSV,2,2,8,76,53,352,43,77,39,277,00,85,09,055,41,86,08,112,00,1*49
$GNGST,015903.000,0.86,8.38,1.39,81.42,32.311,7.315,13.683*43
$GNSVD,0,0,0,118,22,22*40
$GNRMC,015904.000,A,3121.575704,N,12042.869303,E,0.050,334.82,080126,,E,A,V*77
$GNGGA,015904.000,3121.575704,N,12042.869303,E,1,17,1.09,51.838,M,0.000,M,,*75
$GPGSA,A,3,04,26,28,29,31,32,,,,,,,1.54,1.09,1.08,1*1C
$GBGSA,A,3,06,24,26,35,39,41,,,,,,,1.54,1.09,1.08,4*04
$GAGSA,A,3,09,,,,,,,,,,,,1.54,1.09,1.08,3*04
$GLGSA,A,3,67,75,76,85,,,,,,,,,1.54,1.09,1.08,2*0E
$GPGSV,3,1,9,03,24,284,00,04,16,319,38,16,47,239,00,26,74,310,29,1*55
$GPGSV,3,2,9,27,11,189,00,28,50,056,42,29,23,053,44,31,59,357,39,1*5A
$GPGSV,3,3,9,32,34,146,19,1*60
$GPGSV,3,1,9,03,24,284,00,04,16,319,36,16,47,239,00,26,74,310,30,8*5A
$GPGSV,3,2,9,27,11,189,00,28,50,056,00,29,23,053,00,31,59,357,00,8*5F
$GPGSV,3,3,9,32,34,146,00,8*61
$GBGSV,8,1,29,01,44,140,00,02,38,237,00,03,52,199,00,04,32,124,00,1*78
$GBGSV,8,2,29,05,16,257,00,06,59,332,33,07,31,184,00,08,25,209,00,1*71
$GBGSV,8,3,29,09,50,309,00,10,20,194,00,12,17,131,00,13,30,222,00,1*7A
$GBGSV,8,4,29,14,50,202,00,24,59,014,39,25,37,278,00,26,22,058,41,1*76
$GBGSV,8,5,29,33,58,251,00,34,12,157,00,35,11,059,40,38,17,196,00,1*71
$GBGSV,8,6,29,39,62,002,35,40,29,169,00,41,28,312,37,42,29,177,00,1*7F
$GBGSV,8,7,29,44,23,105,00,50,09,164,00,59,48,145,00,60,33,239,00,1*76
$GBGSV,8,8,29,61,51,199,00,1*4F
$GBGSV,4,1,13,24,59,014,38,25,37,278,00,26,22,058,34,33,58,251,00,4*7B
$GBGSV,4,2,13,34,12,157,00,35,11,059,37,38,17,196,00,39,62,002,37,4*75
$GBGSV,4,3,13,40,29,169,00,41,28,312,29,42,29,177,00,44,23,105,00,4*7A
$GBGSV,4,4,13,50,09,164,00,4*4E
$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,37,19,13,135,00,7*4B
$GAGSV,2,2,5,31,78,255,00,7*79
$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,40,19,13,135,00,1*4D
$GAGSV,2,2,5,31,78,255,00,1*7F
$GLGSV,2,1,8,65,39,176,00,66,72,275,00,67,21,329,35,75,13,044,43,1*44
$GLGSV,2,2,8,76,53,352,42,77,39,277,00,85,09,055,41,86,08,112,00,1*48
$GNGST,015904.000,0.93,8.38,1.38,81.41,32.302,7.294,13.653*45
$GNSVD,-1,2,0,350,68,71*68
$GNRMC,015905.000,A,3121.575859,N,12042.869263,E,0.039,345.46,080126,,E,A,V*77
$GNGGA,015905.000,3121.575859,N,12042.869263,E,1,17,1.09,51.796,M,0.000,M,,*7F
$GPGSA,A,3,04,26,28,29,31,32,,,,,,,1.54,1.09,1.08,1*1C
$GBGSA,A,3,06,24,26,35,39,41,,,,,,,1.54,1.09,1.08,4*04
$GAGSA,A,3,09,,,,,,,,,,,,1.54,1.09,1.08,3*04
$GLGSA,A,3,67,75,76,85,,,,,,,,,1.54,1.09,1.08,2*0E
$GPGSV,3,1,9,03,24,284,00,04,16,319,38,16,47,239,00,26,74,310,29,1*55
$GPGSV,3,2,9,27,11,189,00,28,50,056,42,29,23,053,44,31,59,357,39,1*5A
$GPGSV,3,3,9,32,34,146,19,1*60
$GPGSV,3,1,9,03,24,284,00,04,16,319,36,16,47,239,00,26,74,310,31,8*5B
$GPGSV,3,2,9,27,11,189,00,28,50,056,00,29,23,053,00,31,59,357,00,8*5F
$GPGSV,3,3,9,32,34,146,00,8*61
$GBGSV,8,1,29,01,44,140,00,02,38,237,00,03,52,199,00,04,32,124,00,1*78
$GBGSV,8,2,29,05,16,257,00,06,59,332,33,07,31,184,00,08,25,209,00,1*71
$GBGSV,8,3,29,09,50,309,00,10,20,194,00,12,17,131,00,13,30,222,00,1*7A
$GBGSV,8,4,29,14,50,202,00,24,59,014,39,25,37,278,00,26,22,058,40,1*77
$GBGSV,8,5,29,33,58,250,00,34,12,157,00,35,11,059,40,38,17,196,00,1*70
$GBGSV,8,6,29,39,62,002,35,40,29,169,00,41,28,312,37,42,29,177,00,1*7F
$GBGSV,8,7,29,44,23,105,00,50,09,164,00,59,48,145,00,60,33,239,00,1*76
$GBGSV,8,8,29,61,51,199,00,1*4F
$GBGSV,4,1,13,24,59,014,39,25,37,278,00,26,22,058,34,33,58,250,00,4*7B
$GBGSV,4,2,13,34,12,157,00,35,11,059,37,38,17,196,00,39,62,002,37,4*75
$GBGSV,4,3,13,40,29,169,00,41,28,312,29,42,29,177,00,44,23,105,00,4*7A
$GBGSV,4,4,13,50,09,164,00,4*4E
$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,37,19,13,135,00,7*4B
$GAGSV,2,2,5,31,78,255,00,7*79
$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,40,19,13,135,00,1*4D
$GAGSV,2,2,5,31,78,255,00,1*7F
$GLGSV,2,1,8,65,39,176,00,66,72,275,00,67,21,329,35,75,13,044,43,1*44
$GLGSV,2,2,8,76,53,352,43,77,39,277,00,85,09,055,41,86,08,112,00,1*49
$GNGST,015905.000,0.68,8.41,1.37,81.41,32.384,7.264,13.612*45
$GNSVD,0,1,0,409,76,76*44
$GNRMC,015906.000,A,3121.576069,N,12042.869216,E,0.042,336.34,080126,,E,A,V*73
$GNGGA,015906.000,3121.576069,N,12042.869216,E,1,17,1.09,51.776,M,0.000,M,,*78
$GPGSA,A,3,04,26,28,29,31,32,,,,,,,1.54,1.09,1.08,1*1C
$GBGSA,A,3,06,24,26,35,39,41,,,,,,,1.54,1.09,1.08,4*04
$GAGSA,A,3,09,,,,,,,,,,,,1.54,1.09,1.08,3*04
$GLGSA,A,3,67,75,76,85,,,,,,,,,1.54,1.09,1.08,2*0E
$GPGSV,3,1,9,03,24,284,00,04,16,319,38,16,47,239,00,26,74,310,29,1*55
$GPGSV,3,2,9,27,11,189,00,28,50,056,43,29,23,053,45,31,59,357,39,1*5A
$GPGSV,3,3,9,32,34,146,20,1*6A
$GPGSV,3,1,9,03,24,284,00,04,16,319,36,16,47,239,00,26,74,310,31,8*5B
$GPGSV,3,2,9,27,11,189,00,28,50,056,00,29,23,053,00,31,59,357,00,8*5F
$GPGSV,3,3,9,32,34,146,00,8*61
$GBGSV,8,1,29,01,44,140,00,02,38,237,00,03,52,199,00,04,32,124,00,1*78
$GBGSV,8,2,29,05,16,257,00,06,59,332,33,07,31,184,00,08,25,209,00,1*71
$GBGSV,8,3,29,09,50,309,00,10,20,194,00,12,17,131,00,13,30,222,00,1*7A
$GBGSV,8,4,29,14,50,202,00,24,59,014,39,25,37,278,00,26,22,058,41,1*76
$GBGSV,8,5,29,33,58,250,00,34,12,157,00,35,11,059,40,38,17,196,00,1*70
$GBGSV,8,6,29,39,62,002,35,40,29,169,00,41,28,312,37,42,29,177,00,1*7F
$GBGSV,8,7,29,44,23,105,00,50,09,164,00,59,48,145,00,60,33,239,00,1*76
$GBGSV,8,8,29,61,51,199,00,1*4F
$GBGSV,4,1,13,24,59,014,39,25,37,278,00,26,22,058,34,33,58,250,00,4*7B
$GBGSV,4,2,13,34,12,157,00,35,11,059,37,38,17,196,00,39,62,002,37,4*75
$GBGSV,4,3,13,40,29,169,00,41,28,312,29,42,29,177,00,44,23,105,00,4*7A
$GBGSV,4,4,13,50,09,164,00,4*4E
$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,38,19,13,135,00,7*44
$GAGSV,2,2,5,31,78,255,00,7*79
$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,40,19,13,135,00,1*4D
$GAGSV,2,2,5,31,78,255,00,1*7F
$GLGSV,2,1,8,65,39,176,00,66,72,275,00,67,21,329,35,75,13,044,43,1*44
$GLGSV,2,2,8,76,53,352,43,77,39,277,00,85,09,055,41,86,08,112,00,1*49
$GNGST,015906.000,0.55,8.42,1.36,81.36,32.417,7.258,13.592*43
$GNSVD,0,2,0,407,84,79*4B
$GNRMC,015907.000,A,3121.576167,N,12042.869218,E,0.058,344.47,080126,,E,A,V*79
$GNGGA,015907.000,3121.576167,N,12042.869218,E,1,17,1.09,51.827,M,0.000,M,,*73
$GPGSA,A,3,04,26,28,29,31,32,,,,,,,1.54,1.09,1.08,1*1C
$GBGSA,A,3,06,24,26,35,39,41,,,,,,,1.54,1.09,1.08,4*04
$GAGSA,A,3,09,,,,,,,,,,,,1.54,1.09,1.08,3*04
$GLGSA,A,3,67,75,76,85,,,,,,,,,1.54,1.09,1.08,2*0E
$GPGSV,3,1,9,03,24,284,00,04,16,319,38,16,47,239,00,26,74,311,29,1*54
$GPGSV,3,2,9,27,11,189,00,28,50,056,43,29,23,053,45,31,59,358,39,1*55
$GPGSV,3,3,9,32,34,146,20,1*6A
$GPGSV,3,1,9,03,24,284,00,04,16,319,36,16,47,239,00,26,74,311,31,8*5A
$GPGSV,3,2,9,27,11,189,00,28,50,056,00,29,23,053,00,31,59,358,00,8*50
$GPGSV,3,3,9,32,34,146,00,8*61
$GBGSV,8,1,29,01,44,140,00,02,38,237,00,03,52,199,00,04,32,124,00,1*78
$GBGSV,8,2,29,05,16,257,00,06,59,332,33,07,31,184,00,08,25,209,00,1*71
$GBGSV,8,3,29,09,50,309,00,10,20,194,00,12,17,131,00,13,30,222,00,1*7A
$GBGSV,8,4,29,14,50,202,00,24,59,014,39,25,37,278,00,26,22,058,40,1*77
$GBGSV,8,5,29,33,58,250,00,34,12,157,00,35,11,059,40,38,17,196,00,1*70
$GBGSV,8,6,29,39,62,002,35,40,29,169,00,41,28,312,37,42,29,177,00,1*7F
$GBGSV,8,7,29,44,23,105,00,50,09,164,00,59,48,145,00,60,33,239,00,1*76
$GBGSV,8,8,29,61,51,199,00,1*4F
$GBGSV,4,1,13,24,59,014,39,25,37,278,00,26,22,058,34,33,58,250,00,4*7B
$GBGSV,4,2,13,34,12,157,00,35,11,059,36,38,17,196,00,39,62,002,37,4*74
$GBGSV,4,3,13,40,29,169,00,41,28,312,29,42,29,177,00,44,23,105,00,4*7A
$GBGSV,4,4,13,50,09,164,00,4*4E
$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,38,19,13,135,00,7*44
$GAGSV,2,2,5,31,78,255,00,7*79
$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,40,19,13,135,00,1*4D
$GAGSV,2,2,5,31,78,255,00,1*7F
$GLGSV,2,1,8,65,39,176,00,66,72,275,00,67,21,329,35,75,13,044,44,1*43
$GLGSV,2,2,8,76,53,352,43,77,39,277,00,85,09,055,41,86,08,112,00,1*49
$GNGST,015907.000,0.74,8.46,1.36,81.39,32.556,7.267,13.652*4D
$GNSVD,0,2,1,351,70,80*43
$GNRMC,015908.000,A,3121.576274,N,12042.869205,E,0.037,270.24,080126,,E,A,V*71
$GNGGA,015908.000,3121.576274,N,12042.869205,E,1,17,1.09,51.877,M,0.000,M,,*74
$GPGSA,A,3,04,26,28,29,31,32,,,,,,,1.54,1.09,1.08,1*1C
$GBGSA,A,3,06,24,26,35,39,41,,,,,,,1.54,1.09,1.08,4*04
$GAGSA,A,3,09,,,,,,,,,,,,1.54,1.09,1.08,3*04
$GLGSA,A,3,67,75,76,85,,,,,,,,,1.54,1.09,1.08,2*0E
$GPGSV,3,1,9,03,24,284,00,04,16,319,38,16,47,239,00,26,74,311,29,1*54
$GPGSV,3,2,9,27,11,189,00,28,50,056,43,29,23,053,44,31,59,358,39,1*54
$GPGSV,3,3,9,32,34,146,20,1*6A
$GPGSV,3,1,9,03,24,284,00,04,16,319,36,16,47,239,00,26,74,311,31,8*5A
$GPGSV,3,2,9,27,11,189,00,28,50,056,00,29,23,053,00,31,59,358,00,8*50
$GPGSV,3,3,9,32,34,146,00,8*61
$GBGSV,8,1,29,01,44,140,00,02,38,237,00,03,52,199,00,04,32,124,00,1*78
$GBGSV,8,2,29,05,16,257,00,06,59,332,33,07,31,184,00,08,25,209,00,1*71
$GBGSV,8,3,29,09,50,309,00,10,20,194,00,12,17,131,00,13,30,222,00,1*7A
$GBGSV,8,4,29,14,50,202,00,24,59,014,39,25,37,278,00,26,22,058,40,1*77
$GBGSV,8,5,29,33,58,250,00,34,12,157,00,35,11,059,40,38,17,196,00,1*70
$GBGSV,8,6,29,39,62,002,35,40,29,169,00,41,28,312,37,42,29,177,00,1*7F
$GBGSV,8,7,29,44,23,105,00,50,09,164,00,59,48,145,00,60,33,239,00,1*76
$GBGSV,8,8,29,61,51,199,00,1*4F
$GBGSV,4,1,13,24,59,014,38,25,37,278,00,26,22,058,34,33,58,250,00,4*7A
$GBGSV,4,2,13,34,12,157,00,35,11,059,36,38,17,196,00,39,62,002,37,4*74
$GBGSV,4,3,13,40,29,169,00,41,28,312,29,42,29,177,00,44,23,105,00,4*7A
$GBGSV,4,4,13,50,09,164,00,4*4E
$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,38,19,13,135,00,7*44
$GAGSV,2,2,5,31,78,255,00,7*79
$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,39,19,13,135,00,1*43
$GAGSV,2,2,5,31,78,255,00,1*7F
$GLGSV,2,1,8,65,39,176,00,66,72,275,00,67,21,329,35,75,13,044,43,1*44
$GLGSV,2,2,8,76,53,352,43,77,39,277,00,85,09,055,41,86,08,112,00,1*49
$GNGST,015908.000,0.73,8.42,1.35,81.41,32.460,7.222,13.595*40
$GNSVD,-1,0,8,345,70,74*6A
$GNRMC,015909.000,A,3121.576447,N,12042.869168,E,0.038,223.29,080126,,E,A,V*7A
$GNGGA,015909.000,3121.576447,N,12042.869168,E,1,17,1.09,51.981,M,0.000,M,,*73
$GPGSA,A,3,04,26,28,29,31,32,,,,,,,1.54,1.09,1.08,1*1C
$GBGSA,A,3,06,24,26,35,39,41,,,,,,,1.54,1.09,1.08,4*04
$GAGSA,A,3,09,,,,,,,,,,,,1.54,1.09,1.08,3*04
$GLGSA,A,3,67,75,76,85,,,,,,,,,1.54,1.09,1.08,2*0E
$GPGSV,3,1,9,03,24,284,00,04,16,319,39,16,47,239,00,26,74,311,30,1*5D
$GPGSV,3,2,9,27,11,189,00,28,50,056,43,29,23,053,44,31,59,358,39,1*54
$GPGSV,3,3,9,32,34,146,20,1*6A
$GPGSV,3,1,9,03,24,284,00,04,16,319,36,16,47,239,00,26,74,311,31,8*5A
$GPGSV,3,2,9,27,11,189,00,28,50,056,00,29,23,053,00,31,59,358,00,8*50
$GPGSV,3,3,9,32,34,146,00,8*61
$GBGSV,8,1,29,01,44,140,00,02,38,237,00,03,52,199,00,04,32,124,00,1*78
$GBGSV,8,2,29,05,16,257,00,06,59,332,33,07,31,184,00,08,25,209,00,1*71
$GBGSV,8,3,29,09,50,309,00,10,20,194,00,12,17,131,00,13,30,222,00,1*7A
$GBGSV,8,4,29,14,50,202,00,24,59,014,39,25,37,278,00,26,22,058,40,1*77
$GBGSV,8,5,29,33,58,250,00,34,12,157,00,35,11,059,40,38,17,196,00,1*70
$GBGSV,8,6,29,39,62,002,35,40,29,169,00,41,28,312,37,42,29,177,00,1*7F
$GBGSV,8,7,29,44,23,105,00,50,09,164,00,59,48,145,00,60,33,239,00,1*76
$GBGSV,8,8,29,61,51,199,00,1*4F
$GBGSV,4,1,13,24,59,014,39,25,37,278,00,26,22,058,34,33,58,250,00,4*7B
$GBGSV,4,2,13,34,12,157,00,35,11,059,36,38,17,196,00,39,62,002,37,4*74
$GBGSV,4,3,13,40,29,169,00,41,28,312,29,42,29,177,00,44,23,105,00,4*7A
$GBGSV,4,4,13,50,09,164,00,4*4E
$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,38,19,13,135,00,7*44
$GAGSV,2,2,5,31,78,255,00,7*79
$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,40,19,13,135,00,1*4D
$GAGSV,2,2,5,31,78,255,00,1*7F
$GLGSV,2,1,8,65,39,176,00,66,72,275,00,67,21,329,35,75,13,044,44,1*43
$GLGSV,2,2,8,76,53,352,43,77,39,277,00,85,09,055,41,86,08,112,00,1*49
$GNGST,015909.000,0.79,8.42,1.35,81.34,32.445,7.223,13.571*45
$GNSVD,-1,-1,10,379,80,76*7D
$GNRMC,015910.000,A,3121.576443,N,12042.869180,E,0.053,156.87,080126,,E,A,V*78
$GNGGA,015910.000,3121.576443,N,12042.869180,E,1,17,1.09,52.067,M,0.000,M,,*7B
$GPGSA,A,3,04,26,28,29,31,32,,,,,,,1.54,1.09,1.08,1*1C
$GBGSA,A,3,06,24,26,35,39,41,,,,,,,1.54,1.09,1.08,4*04
$GAGSA,A,3,09,,,,,,,,,,,,1.54,1.09,1.08,3*04
$GLGSA,A,3,67,75,76,85,,,,,,,,,1.54,1.09,1.08,2*0E
$GPGSV,3,1,9,03,24,284,00,04,16,319,39,16,47,239,00,26,74,311,30,1*5D
$GPGSV,3,2,9,27,11,189,00,28,50,056,43,29,23,053,44,31,59,358,39,1*54
$GPGSV,3,3,9,32,34,146,19,1*60
$GPGSV,3,1,9,03,24,284,00,04,16,319,37,16,47,239,00,26,74,311,31,8*5B
$GPGSV,3,2,9,27,11,189,00,28,50,056,00,29,23,053,00,31,59,358,00,8*50
$GPGSV,3,3,9,32,34,146,00,8*61
$GBGSV,8,1,29,01,44,140,00,02,38,237,00,03,52,199,00,04,32,124,00,1*78
$GBGSV,8,2,29,05,16,257,00,06,59,332,33,07,31,184,00,08,25,209,00,1*71
$GBGSV,8,3,29,09,50,309,00,10,20,194,00,12,17,131,00,13,30,222,00,1*7A
$GBGSV,8,4,29,14,50,202,00,24,59,014,38,25,37,278,00,26,22,059,40,1*77
$GBGSV,8,5,29,33,58,250,00,34,12,157,00,35,11,059,40,38,17,196,00,1*70
$GBGSV,8,6,29,39,62,002,35,40,29,169,00,41,28,312,37,42,29,177,00,1*7F
$GBGSV,8,7,29,44,23,105,00,50,09,164,00,59,48,145,00,60,33,239,00,1*76
$GBGSV,8,8,29,61,51,199,00,1*4F
$GBGSV,4,1,13,24,59,014,39,25,37,278,00,26,22,059,34,33,58,250,00,4*7A
$GBGSV,4,2,13,34,12,157,00,35,11,059,37,38,17,196,00,39,62,002,37,4*75
$GBGSV,4,3,13,40,29,169,00,41,28,312,29,42,29,177,00,44,23,105,00,4*7A
$GBGSV,4,4,13,50,09,164,00,4*4E
$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,38,19,13,135,00,7*44
$GAGSV,2,2,5,31,78,255,00,7*79
$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,40,19,13,135,00,1*4D
$GAGSV,2,2,5,31,78,255,00,1*7F
$GLGSV,2,1,8,65,39,176,00,66,72,275,00,67,21,329,35,75,13,044,44,1*43
$GLGSV,2,2,8,76,53,352,42,77,39,277,00,85,09,055,41,86,08,112,00,1*48
$GNGST,015910.000,0.96,8.50,1.36,81.36,32.672,7.253,13.641*4F
$GNSVD,1,-2,8,400,77,78*65
$GNRMC,015911.000,A,3121.576500,N,12042.869206,E,0.064,148.17,080126,,E,A,V*70
$GNGGA,015911.000,3121.576500,N,12042.869206,E,1,17,1.09,52.146,M,0.000,M,,*73
$GPGSA,A,3,04,26,28,29,31,32,,,,,,,1.54,1.09,1.08,1*1C
$GBGSA,A,3,06,24,26,35,39,41,,,,,,,1.54,1.09,1.08,4*04
$GAGSA,A,3,09,,,,,,,,,,,,1.54,1.09,1.08,3*04
$GLGSA,A,3,67,75,76,85,,,,,,,,,1.54,1.09,1.08,2*0E
$GPGSV,3,1,9,03,24,284,00,04,16,319,39,16,47,239,00,26,74,311,30,1*5D
$GPGSV,3,2,9,27,11,189,00,28,50,056,43,29,23,053,45,31,59,358,40,1*5B
$GPGSV,3,3,9,32,34,146,19,1*60
$GPGSV,3,1,9,03,24,284,00,04,16,319,37,16,47,239,00,26,74,311,31,8*5B
$GPGSV,3,2,9,27,11,189,00,28,50,056,00,29,23,053,00,31,59,358,00,8*50
$GPGSV,3,3,9,32,34,146,00,8*61
$GBGSV,8,1,29,01,44,140,00,02,38,237,00,03,52,199,00,04,32,124,00,1*78
$GBGSV,8,2,29,05,16,257,00,06,59,332,33,07,31,184,00,08,25,209,00,1*71
$GBGSV,8,3,29,09,50,309,00,10,20,194,00,12,17,131,00,13,30,222,00,1*7A
$GBGSV,8,4,29,14,50,202,00,24,59,014,38,25,37,278,00,26,22,059,40,1*77
$GBGSV,8,5,29,33,58,250,00,34,12,157,00,35,11,059,40,38,17,196,00,1*70
$GBGSV,8,6,29,39,62,003,35,40,29,169,00,41,28,312,37,42,29,177,00,1*7E
$GBGSV,8,7,29,44,23,105,00,50,09,164,00,59,48,145,00,60,33,239,00,1*76
$GBGSV,8,8,29,61,51,199,00,1*4F
$GBGSV,4,1,13,24,59,014,39,25,37,278,00,26,22,059,34,33,58,250,00,4*7A
$GBGSV,4,2,13,34,12,157,00,35,11,059,37,38,17,196,00,39,62,003,37,4*74
$GBGSV,4,3,13,40,29,169,00,41,28,312,29,42,29,177,00,44,23,105,00,4*7A
$GBGSV,4,4,13,50,09,164,00,4*4E
$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,38,19,13,135,00,7*44
$GAGSV,2,2,5,31,78,255,00,7*79
$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,40,19,13,135,00,1*4D
$GAGSV,2,2,5,31,78,255,00,1*7F
$GLGSV,2,1,8,65,39,176,00,66,72,275,00,67,21,329,34,75,13,044,43,1*45
$GLGSV,2,2,8,76,53,352,43,77,39,277,00,85,09,055,41,86,08,112,00,1*49
$GNGST,015911.000,0.75,8.51,1.34,81.40,32.724,7.216,13.607*40
$GNSVD,1,-2,6,394,71,77*68
$GNRMC,015912.000,A,3121.576672,N,12042.869211,E,0.050,221.67,080126,,E,A,V*7F
$GNGGA,015912.000,3121.576672,N,12042.869211,E,1,17,1.09,52.182,M,0.000,M,,*78
$GPGSA,A,3,04,26,28,29,31,32,,,,,,,1.54,1.09,1.08,1*1C
$GBGSA,A,3,06,24,26,35,39,41,,,,,,,1.54,1.09,1.08,4*04
$GAGSA,A,3,09,,,,,,,,,,,,1.54,1.09,1.08,3*04
$GLGSA,A,3,67,75,76,85,,,,,,,,,1.54,1.09,1.08,2*0E
$GPGSV,3,1,9,03,24,284,00,04,16,319,39,16,47,239,00,26,74,311,30,1*5D
$GPGSV,3,2,9,27,11,189,00,28,50,056,43,29,23,053,44,31,59,358,40,1*5A
$GPGSV,3,3,9,32,34,146,20,1*6A
$GPGSV,3,1,9,03,24,284,00,04,16,319,37,16,47,239,00,26,74,311,31,8*5B
$GPGSV,3,2,9,27,11,189,00,28,50,056,00,29,23,053,00,31,59,358,00,8*50
$GPGSV,3,3,9,32,34,146,00,8*61
$GBGSV,8,1,29,01,44,140,00,02,38,237,00,03,52,199,00,04,32,124,00,1*78
$GBGSV,8,2,29,05,16,257,00,06,59,332,33,07,31,184,00,08,25,209,00,1*71
$GBGSV,8,3,29,09,50,309,00,10,20,194,00,12,17,131,00,13,30,222,00,1*7A
$GBGSV,8,4,29,14,50,202,00,24,59,014,38,25,37,278,00,26,22,059,40,1*77
$GBGSV,8,5,29,33,58,250,00,34,12,157,00,35,11,058,40,38,17,196,00,1*71
$GBGSV,8,6,29,39,62,003,35,40,29,169,00,41,28,312,37,42,29,177,00,1*7E
$GBGSV,8,7,29,44,23,105,00,50,09,164,00,59,48,145,00,60,33,239,00,1*76
$GBGSV,8,8,29,61,51,199,00,1*4F
$GBGSV,4,1,13,24,59,014,38,25,37,278,00,26,22,059,34,33,58,250,00,4*7B
$GBGSV,4,2,13,34,12,157,00,35,11,058,37,38,17,196,00,39,62,003,37,4*75
$GBGSV,4,3,13,40,29,169,00,41,28,312,29,42,29,177,00,44,23,105,00,4*7A
$GBGSV,4,4,13,50,09,164,00,4*4E
$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,38,19,13,135,00,7*44
$GAGSV,2,2,5,31,78,255,00,7*79
$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,40,19,13,135,00,1*4D
$GAGSV,2,2,5,31,78,255,00,1*7F
$GLGSV,2,1,8,65,39,176,00,66,72,275,00,67,21,329,34,75,13,044,43,1*45
$GLGSV,2,2,8,76,53,352,43,77,39,277,00,85,09,055,41,86,08,112,00,1*49
$GNGST,015912.000,0.81,8.56,1.33,81.41,32.863,7.193,13.598*4E
$GNSVD,-1,-1,2,463,81,80*4A
$GNRMC,015913.000,A,3121.576893,N,12042.869268,E,0.038,116.71,080126,,E,A,V*7F
$GNGGA,015913.000,3121.576893,N,12042.869268,E,1,17,1.09,52.385,M,0.000,M,,*73
$GPGSA,A,3,04,26,28,29,31,32,,,,,,,1.54,1.09,1.08,1*1C
$GBGSA,A,3,06,24,26,35,39,41,,,,,,,1.54,1.09,1.08,4*04
$GAGSA,A,3,09,,,,,,,,,,,,1.54,1.09,1.08,3*04
$GLGSA,A,3,67,75,76,85,,,,,,,,,1.54,1.09,1.08,2*0E
$GPGSV,3,1,9,03,24,284,00,04,16,319,39,16,47,239,00,26,74,311,30,1*5D
$GPGSV,3,2,9,27,11,189,00,28,50,056,43,29,23,053,45,31,59,358,39,1*55
$GPGSV,3,3,9,32,34,146,21,1*6B
$GPGSV,3,1,9,03,24,284,00,04,16,319,37,16,47,239,00,26,74,311,31,8*5B
$GPGSV,3,2,9,27,11,189,00,28,50,056,00,29,23,053,00,31,59,358,00,8*50
$GPGSV,3,3,9,32,34,146,00,8*61
$GBGSV,8,1,29,01,44,140,00,02,38,237,00,03,52,199,00,04,32,124,00,1*78
$GBGSV,8,2,29,05,16,257,00,06,59,332,33,07,31,184,00,08,25,209,00,1*71
$GBGSV,8,3,29,09,50,309,00,10,20,194,00,12,17,131,00,13,30,222,00,1*7A
$GBGSV,8,4,29,14,50,202,00,24,59,014,38,25,37,278,00,26,22,059,40,1*77
$GBGSV,8,5,29,33,58,250,00,34,12,157,00,35,11,058,40,38,17,196,00,1*71
$GBGSV,8,6,29,39,62,003,35,40,29,169,00,41,28,312,37,42,29,177,00,1*7E
$GBGSV,8,7,29,44,23,105,00,50,09,164,00,59,48,145,00,60,33,239,00,1*76
$GBGSV,8,8,29,61,51,199,00,1*4F
$GBGSV,4,1,13,24,59,014,39,25,37,278,00,26,22,059,34,33,58,250,00,4*7A
$GBGSV,4,2,13,34,12,157,00,35,11,058,37,38,17,196,00,39,62,003,37,4*75
$GBGSV,4,3,13,40,29,169,00,41,28,312,29,42,29,177,00,44,23,105,00,4*7A
$GBGSV,4,4,13,50,09,164,00,4*4E
$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,38,19,13,135,00,7*44
$GAGSV,2,2,5,31,78,255,00,7*79
$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,40,19,13,135,00,1*4D
$GAGSV,2,2,5,31,78,255,00,1*7F
$GLGSV,2,1,8,65,39,176,00,66,72,275,00,67,21,329,34,75,13,044,43,1*45
$GLGSV,2,2,8,76,53,352,43,77,39,277,00,85,09,055,41,86,08,112,00,1*49
$GNGST,015913.000,0.85,8.57,1.32,81.45,32.915,7.164,13.564*44
$GNSVD,1,0,3,396,70,75*43
$GNRMC,015914.000,A,3121.576888,N,12042.869261,E,0.042,124.95,080126,,E,A,V*7D
$GNGGA,015914.000,3121.576888,N,12042.869261,E,1,17,1.09,52.400,M,0.000,M,,*7D
$GPGSA,A,3,04,26,28,29,31,32,,,,,,,1.54,1.09,1.08,1*1C
$GBGSA,A,3,06,24,26,35,39,41,,,,,,,1.54,1.09,1.08,4*04
$GAGSA,A,3,09,,,,,,,,,,,,1.54,1.09,1.08,3*04
$GLGSA,A,3,67,75,76,85,,,,,,,,,1.54,1.09,1.08,2*0E
$GPGSV,3,1,9,03,24,284,00,04,16,319,39,16,47,239,00,26,74,311,29,1*55
$GPGSV,3,2,9,27,11,189,00,28,50,056,43,29,23,053,45,31,59,358,40,1*5B
$GPGSV,3,3,9,32,34,146,22,1*68
$GPGSV,3,1,9,03,24,284,00,04,16,319,37,16,47,239,00,26,74,311,31,8*5B
$GPGSV,3,2,9,27,11,189,00,28,50,056,00,29,23,053,00,31,59,358,00,8*50
$GPGSV,3,3,9,32,34,146,00,8*61
$GBGSV,8,1,29,01,44,140,00,02,38,237,00,03,52,199,00,04,32,124,00,1*78
$GBGSV,8,2,29,05,16,257,00,06,59,332,33,07,31,184,00,08,25,209,00,1*71
$GBGSV,8,3,29,09,50,309,00,10,20,194,00,12,17,131,00,13,30,222,00,1*7A
$GBGSV,8,4,29,14,50,202,00,24,59,014,38,25,37,278,00,26,22,059,40,1*77
$GBGSV,8,5,29,33,58,250,00,34,12,157,00,35,11,058,40,38,17,196,00,1*71
$GBGSV,8,6,29,39,62,003,35,40,29,169,00,41,28,312,37,42,29,177,00,1*7E
$GBGSV,8,7,29,44,23,105,00,50,09,164,00,59,48,145,00,60,33,239,00,1*76
$GBGSV,8,8,29,61,51,199,00,1*4F
$GBGSV,4,1,13,24,59,014,39,25,37,278,00,26,22,059,34,33,58,250,00,4*7A
$GBGSV,4,2,13,34,12,157,00,35,11,058,37,38,17,196,00,39,62,003,37,4*75
$GBGSV,4,3,13,40,29,169,00,41,28,312,29,42,29,177,00,44,23,105,00,4*7A
$GBGSV,4,4,13,50,09,164,00,4*4E
$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,38,19,13,135,00,7*44
$GAGSV,2,2,5,31,78,255,00,7*79
$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,39,19,13,135,00,1*43
$GAGSV,2,2,5,31,78,255,00,1*7F
$GLGSV,2,1,8,65,39,176,00,66,72,275,00,67,21,329,35,75,13,044,43,1*44
$GLGSV,2,2,8,76,53,352,43,77,39,277,00,85,09,055,41,86,08,112,00,1*49
$GNGST,015914.000,1.04,8.64,1.32,81.48,33.129,7.180,13.627*4E
$GNSVD,1,-1,2,115,20,22*60
$GNRMC,015915.000,A,3121.576930,N,12042.869269,E,0.082,170.97,080126,,E,A,V*79
$GNGGA,015915.000,3121.576930,N,12042.869269,E,1,17,1.09,52.478,M,0.000,M,,*79
$GPGSA,A,3,04,26,28,29,31,32,,,,,,,1.54,1.09,1.08,1*1C
$GBGSA,A,3,06,24,26,35,39,41,,,,,,,1.54,1.09,1.08,4*04
$GAGSA,A,3,09,,,,,,,,,,,,1.54,1.09,1.08,3*04
$GLGSA,A,3,67,75,76,85,,,,,,,,,1.54,1.09,1.08,2*0E
$GPGSV,3,1,9,03,24,284,00,04,16,319,39,16,47,239,00,26,74,311,30,1*5D
$GPGSV,3,2,9,27,11,189,00,28,50,056,43,29,23,053,45,31,59,358,40,1*5B
$GPGSV,3,3,9,32,34,146,23,1*69
$GPGSV,3,1,9,03,24,284,00,04,16,319,37,16,47,239,00,26,74,311,31,8*5B
$GPGSV,3,2,9,27,11,189,00,28,50,056,00,29,23,053,00,31,59,358,00,8*50
$GPGSV,3,3,9,32,34,146,00,8*61
$GBGSV,8,1,29,01,44,140,00,02,38,237,00,03,52,199,00,04,32,124,00,1*78
$GBGSV,8,2,29,05,16,257,00,06,59,332,33,07,31,184,00,08,25,209,00,1*71
$GBGSV,8,3,29,09,50,309,00,10,20,194,00,12,17,131,00,13,30,222,00,1*7A
$GBGSV,8,4,29,14,50,202,00,24,59,014,38,25,37,278,00,26,22,059,40,1*77
$GBGSV,8,5,29,33,58,250,00,34,12,157,00,35,11,058,40,38,17,196,00,1*71
$GBGSV,8,6,29,39,62,003,35,40,29,169,00,41,28,312,37,42,29,177,00,1*7E
$GBGSV,8,7,29,44,23,105,00,50,09,164,00,59,48,145,00,60,33,239,00,1*76
$GBGSV,8,8,29,61,51,199,00,1*4F
$GBGSV,4,1,13,24,59,014,38,25,37,278,00,26,22,059,34,33,58,250,00,4*7B
$GBGSV,4,2,13,34,12,157,00,35,11,058,37,38,17,196,00,39,62,003,37,4*75
$GBGSV,4,3,13,40,29,169,00,41,28,312,29,42,29,177,00,44,23,105,00,4*7A
$GBGSV,4,4,13,50,09,164,00,4*4E
$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,38,19,13,135,00,7*44
$GAGSV,2,2,5,31,78,255,00,7*79
$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,39,19,13,135,00,1*43
$GAGSV,2,2,5,31,78,255,00,1*7F
$GLGSV,2,1,8,65,39,176,00,66,72,275,00,67,21,329,35,75,13,044,43,1*44
$GLGSV,2,2,8,76,53,352,43,77,39,277,00,85,09,055,41,86,08,112,00,1*49
$GNGST,015915.000,1.04,8.66,1.32,81.53,33.219,7.158,13.611*47
$GNSVD,0,-4,0,372,67,69*69
$GNRMC,015916.000,A,3121.576981,N,12042.869285,E,0.093,164.37,080126,,E,A,V*7D
$GNGGA,015916.000,3121.576981,N,12042.869285,E,1,17,1.09,52.469,M,0.000,M,,*72
$GPGSA,A,3,04,26,28,29,31,32,,,,,,,1.54,1.09,1.08,1*1C
$GBGSA,A,3,06,24,26,35,39,41,,,,,,,1.54,1.09,1.08,4*04
$GAGSA,A,3,09,,,,,,,,,,,,1.54,1.09,1.08,3*04
$GLGSA,A,3,67,75,76,85,,,,,,,,,1.54,1.09,1.08,2*0E
$GPGSV,3,1,9,03,24,284,00,04,16,319,38,16,47,239,00,26,74,311,29,1*54
$GPGSV,3,2,9,27,11,189,00,28,50,056,42,29,23,053,45,31,59,358,39,1*54
$GPGSV,3,3,9,32,34,146,23,1*69
$GPGSV,3,1,9,03,24,284,00,04,16,319,37,16,47,239,00,26,74,311,31,8*5B
$GPGSV,3,2,9,27,11,189,00,28,50,056,00,29,23,053,00,31,59,358,00,8*50
$GPGSV,3,3,9,32,34,146,00,8*61
$GBGSV,8,1,29,01,44,140,00,02,38,237,00,03,52,199,00,04,32,124,00,1*78
$GBGSV,8,2,29,05,16,257,00,06,59,332,33,07,31,184,00,08,25,209,00,1*71
$GBGSV,8,3,29,09,50,309,00,10,20,194,00,12,17,131,00,13,30,222,00,1*7A
$GBGSV,8,4,29,14,50,202,00,24,59,014,38,25,37,278,00,26,22,059,40,1*77
$GBGSV,8,5,29,33,58,250,00,34,12,157,00,35,11,058,40,38,17,196,00,1*71
$GBGSV,8,6,29,39,62,003,35,40,29,169,00,41,28,312,37,42,29,177,00,1*7E
$GBGSV,8,7,29,44,23,104,00,50,09,164,00,59,48,145,00,60,33,239,00,1*77
$GBGSV,8,8,29,61,51,199,00,1*4F
$GBGSV,4,1,13,24,59,014,39,25,37,278,00,26,22,059,34,33,58,250,00,4*7A
$GBGSV,4,2,13,34,12,157,00,35,11,058,36,38,17,196,00,39,62,003,37,4*74
$GBGSV,4,3,13,40,29,169,00,41,28,312,29,42,29,177,00,44,23,104,00,4*7B
$GBGSV,4,4,13,50,09,164,00,4*4E
$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,38,19,13,135,00,7*44
$GAGSV,2,2,5,31,78,255,00,7*79
$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,39,19,13,135,00,1*43
$GAGSV,2,2,5,31,78,255,00,1*7F
$GLGSV,2,1,8,65,39,176,00,66,72,274,00,67,21,329,35,75,13,044,43,1*45
$GLGSV,2,2,8,76,53,352,43,77,39,277,00,85,09,055,41,86,08,112,00,1*49
$GNGST,015916.000,1.05,8.75,1.31,81.60,33.492,7.151,13.627*4D
$GNSVD,1,-4,-1,478,76,80*4E

View File

@@ -0,0 +1,54 @@
from datetime import timedelta
from pathlib import Path
from sqlmodel import Session, SQLModel, create_engine, select
from app.models import TerminalMessageRaw, get_datetime_utc
from app.mqtt.archive_job import run_archive_once
from app.mqtt.repository import insert_raw_message
def _session() -> Session:
engine = create_engine("sqlite://")
SQLModel.metadata.create_all(engine)
return Session(engine)
def test_archive_writes_raw_bytes_and_manifest(tmp_path: Path) -> None:
with _session() as session:
current = get_datetime_utc()
old_ts = current - timedelta(days=91)
insert_raw_message(
session=session,
device_id="device-001",
topic="terminal/device-001/raw",
payload=b"abc",
qos=1,
retain=False,
received_at=old_ts,
)
insert_raw_message(
session=session,
device_id="device-001",
topic="terminal/device-001/raw",
payload=b"\x01\x02",
qos=1,
retain=False,
received_at=old_ts,
)
manifests = run_archive_once(
session=session,
archive_base_dir=tmp_path,
retention_days=90,
now=current,
)
assert len(manifests) == 1
archived_file = Path(manifests[0].file_path)
assert archived_file.exists()
assert archived_file.read_bytes() == b"abc\x01\x02"
remaining = session.exec(select(TerminalMessageRaw)).all()
assert remaining == []

View File

@@ -0,0 +1,48 @@
from app.mqtt.parsers.mixed import parse_mixed_payload
from app.mqtt.parsers.nmea import parse_nmea_sentences
def test_parse_nmea_sentences_extracts_gga_and_rmc() -> None:
payload = (
b"$GPGGA,123519,3723.2475,N,12158.3416,W,1,12,0.8,10.0,M,-25.0,M,,*65\r\n"
b"$GPRMC,123520,A,3723.2475,N,12158.3416,W,0.13,309.62,120598,,,A*74\r\n"
)
events = parse_nmea_sentences(payload)
assert {e["event_type"] for e in events} >= {"nmea_gga", "nmea_rmc"}
def test_parse_mixed_payload_returns_rtcm_summary() -> None:
payload = (
b"$GPGSV,1,1,04,02,17,213,40,05,29,121,37,12,66,314,44,25,14,048,39*71\r\n"
+ b"\xd3\x00\x04"
+ b"\x43\x50\x00\x00"
+ b"\x00\x00\x00"
)
events = parse_mixed_payload(payload)
event_types = {event["event_type"] for event in events}
assert "rtcm_summary" in event_types
def test_parse_nmea_sentences_extracts_satellite_details_and_used_ids() -> None:
payload = (
b"$GPGSA,A,3,02,05,12,,,,,,,,,,1.50,0.90,1.20*00\r\n"
b"$GPGSV,1,1,04,02,17,213,40,05,29,121,37,12,66,314,44,25,14,048,39*71\r\n"
)
events = parse_nmea_sentences(payload)
gsa_event = next(event for event in events if event["event_type"] == "nmea_gsa")
gsv_event = next(event for event in events if event["event_type"] == "nmea_gsv")
assert gsa_event["payload"]["used_satellite_ids"] == ["02", "05", "12"]
assert gsv_event["payload"]["constellation"] == "GPS"
assert len(gsv_event["payload"]["satellites"]) == 4
assert gsv_event["payload"]["satellites"][0] == {
"satellite_id": "02",
"elevation_deg": 17,
"azimuth_deg": 213,
"snr_dbhz": 40,
}

View File

@@ -0,0 +1,22 @@
from sqlmodel import Session, SQLModel, create_engine
from app.models import TerminalMessageRaw
def test_terminal_message_raw_model_can_persist() -> None:
engine = create_engine("sqlite://")
SQLModel.metadata.create_all(engine)
with Session(engine) as session:
row = TerminalMessageRaw(
device_id="device-001",
topic="terminal/device-001/raw",
payload_bytes=b"$GPGGA,123519,3723.2475,N,12158.3416,W,1,12,0.8,10.0,M,-25.0,M,,*65\r\n",
)
session.add(row)
session.commit()
session.refresh(row)
assert row.id is not None
assert row.parsed_at is None
assert row.parse_error is None

View File

@@ -0,0 +1,149 @@
from sqlmodel import Session, SQLModel, create_engine
from app.models import TerminalDeviceState, TerminalMessageRaw
from app.mqtt.parser_worker import process_pending_batch
from app.mqtt.repository import insert_raw_message
def _session() -> Session:
engine = create_engine("sqlite://")
SQLModel.metadata.create_all(engine)
return Session(engine)
def test_insert_raw_message_sets_unparsed_defaults() -> None:
with _session() as session:
row = insert_raw_message(
session=session,
device_id="device-001",
topic="terminal/device-001/raw",
payload=b"$GPRMC,123520,A,3723.2475,N,12158.3416,W,0.13,309.62,120598,,,A*74\r\n",
qos=1,
retain=False,
)
assert row.parsed_at is None
assert row.parse_error is None
assert row.payload_size > 0
assert len(row.payload_sha256) == 64
def test_worker_marks_success_and_updates_state() -> None:
with _session() as session:
raw = insert_raw_message(
session=session,
device_id="device-001",
topic="terminal/device-001/raw",
payload=b"$GPGGA,123519,3723.2475,N,12158.3416,W,1,12,0.8,10.0,M,-25.0,M,,*65\r\n",
qos=1,
retain=False,
)
processed = process_pending_batch(session=session, batch_size=10)
assert processed == 1
refreshed = session.get(TerminalMessageRaw, raw.id)
assert refreshed is not None
assert refreshed.parsed_at is not None
assert refreshed.parse_error is None
state = session.get(TerminalDeviceState, "device-001")
assert state is not None
assert state.lat is not None
assert state.lon is not None
def test_worker_marks_failed_without_retry_schedule(monkeypatch) -> None:
def _raise_parse_error(_payload: bytes) -> list[dict[str, object]]:
raise ValueError("boom")
monkeypatch.setattr(
"app.mqtt.parser_worker.parse_mixed_payload", _raise_parse_error
)
with _session() as session:
raw = insert_raw_message(
session=session,
device_id="device-err",
topic="terminal/device-err/raw",
payload=b"$GPGGA,broken*00\r\n",
qos=1,
retain=False,
)
processed = process_pending_batch(session=session, batch_size=10)
assert processed == 1
refreshed = session.get(TerminalMessageRaw, raw.id)
assert refreshed is not None
assert refreshed.parsed_at is not None
assert refreshed.parse_error == "boom"
processed_again = process_pending_batch(session=session, batch_size=10)
assert processed_again == 0
def test_schema_excludes_terminal_observation_event_table() -> None:
assert "terminal_observation_event" not in SQLModel.metadata.tables
def test_ingest_and_parse_real_nmea_sample() -> None:
from pathlib import Path
sample_path = Path(__file__).parent / "nmea.txt"
payload = sample_path.read_bytes()
with _session() as session:
raw = insert_raw_message(
session=session,
device_id="device-real-sample",
topic="terminal/device-real-sample/raw",
payload=payload,
qos=1,
retain=False,
)
processed = process_pending_batch(session=session, batch_size=10)
assert processed == 1
refreshed = session.get(TerminalMessageRaw, raw.id)
assert refreshed is not None
assert refreshed.parsed_at is not None
assert refreshed.parse_error is None
state = session.get(TerminalDeviceState, "device-real-sample")
assert state is not None
assert state.lat is not None
assert state.lon is not None
def test_worker_builds_satellite_distribution_summary() -> None:
payload = (
b"$GNGGA,123519,3723.2475,N,12158.3416,W,4,12,0.8,10.0,M,-25.0,M,,*65\r\n"
b"$GPGSA,A,3,02,05,12,,,,,,,,,,1.50,0.90,1.20*00\r\n"
b"$GPGSV,1,1,04,02,17,213,40,05,29,121,37,12,66,314,44,25,14,048,39*71\r\n"
)
with _session() as session:
insert_raw_message(
session=session,
device_id="device-gsv",
topic="terminal/device-gsv/raw",
payload=payload,
qos=1,
retain=False,
)
processed = process_pending_batch(session=session, batch_size=10)
assert processed == 1
state = session.get(TerminalDeviceState, "device-gsv")
assert state is not None
assert state.fix_status == "4"
summary = state.satellite_signal_summary
assert summary is not None
assert summary["satellites_in_view"] == 4
assert summary["snr_values"] == [40, 37, 44, 39]
assert summary["used_satellite_ids"] == ["02", "05", "12"]
assert summary["constellations"]["GPS"]["count"] == 4
assert summary["constellations"]["GPS"]["used_in_navigation"] == 3

View File

@@ -0,0 +1,12 @@
import pytest
from app.mqtt.ingestor import extract_device_id
def test_extract_device_id_success() -> None:
assert extract_device_id("terminal/device-001/raw") == "device-001"
def test_extract_device_id_rejects_invalid_topic() -> None:
with pytest.raises(ValueError, match="terminal/\\{device_id\\}/raw"):
extract_device_id("terminal/device-001/nmea")

View File

@@ -19,6 +19,7 @@
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
@@ -28,10 +29,13 @@
"@tanstack/react-router": "^1.163.3",
"@tanstack/react-router-devtools": "^1.163.3",
"@tanstack/react-table": "^8.21.3",
"@types/leaflet.markercluster": "^1.5.6",
"axios": "1.13.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"form-data": "4.0.5",
"leaflet": "^1.9.4",
"leaflet.markercluster": "^1.5.3",
"lucide-react": "^0.563.0",
"next-themes": "^0.4.6",
"react": "^19.1.1",
@@ -39,6 +43,8 @@
"react-error-boundary": "^6.0.0",
"react-hook-form": "^7.68.0",
"react-icons": "^5.5.0",
"react-leaflet": "^5.0.0",
"react-leaflet-cluster": "^4.0.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.2.0",
@@ -50,6 +56,7 @@
"@playwright/test": "1.58.2",
"@tanstack/router-devtools": "^1.159.10",
"@tanstack/router-plugin": "^1.140.0",
"@types/leaflet": "^1.9.21",
"@types/node": "^25.3.2",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
@@ -250,6 +257,8 @@
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g=="],
"@radix-ui/react-slider": ["@radix-ui/react-slider@1.3.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw=="],
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="],
@@ -278,6 +287,8 @@
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
"@react-leaflet/core": ["@react-leaflet/core@3.0.0", "", { "peerDependencies": { "leaflet": "^1.9.0", "react": "^19.0.0", "react-dom": "^19.0.0" } }, "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.2", "", {}, "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.55.2", "", { "os": "android", "cpu": "arm" }, "sha512-21J6xzayjy3O6NdnlO6aXi/urvSRjm6nCI6+nF6ra2YofKruGixN9kfT+dt55HVNwfDmpDHJcaS3JuP/boNnlA=="],
@@ -426,8 +437,14 @@
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/leaflet": ["@types/leaflet@1.9.21", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w=="],
"@types/leaflet.markercluster": ["@types/leaflet.markercluster@1.5.6", "", { "dependencies": { "@types/leaflet": "^1.9" } }, "sha512-I7hZjO2+isVXGYWzKxBp8PsCzAYCJBc29qBdFpquOCkS7zFDqUsUvkEOyQHedsk/Cy5tocQzf+Ndorm5W9YKTQ=="],
"@types/node": ["@types/node@25.3.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q=="],
"@types/react": ["@types/react@19.2.9", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA=="],
@@ -610,6 +627,10 @@
"json5": ["json5@2.2.3", "", { "bin": "lib/cli.js" }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"leaflet": ["leaflet@1.9.4", "", {}, "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA=="],
"leaflet.markercluster": ["leaflet.markercluster@1.5.3", "", { "peerDependencies": { "leaflet": "^1.3.1" } }, "sha512-vPTw/Bndq7eQHjLBVlWpnGeLa3t+3zGiuM7fJwCkiMFq+nmRuG3RI3f7f4N4TDX7T4NpbAXpR2+NTRSEGfCSeA=="],
"lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="],
@@ -710,6 +731,10 @@
"react-icons": ["react-icons@5.5.0", "", { "peerDependencies": { "react": "*" } }, "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw=="],
"react-leaflet": ["react-leaflet@5.0.0", "", { "dependencies": { "@react-leaflet/core": "^3.0.0" }, "peerDependencies": { "leaflet": "^1.9.0", "react": "^19.0.0", "react-dom": "^19.0.0" } }, "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw=="],
"react-leaflet-cluster": ["react-leaflet-cluster@4.0.0", "", { "dependencies": { "leaflet.markercluster": "^1.5.3" }, "peerDependencies": { "@react-leaflet/core": "^3.0.0", "leaflet": "^1.9.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-leaflet": "^5.0.0" } }, "sha512-Lu75+KOu2ruGyAx8LoCQvlHuw+3CLLJQGEoSk01ymsDN/YnCiRV6ChkpsvaruVyYBPzUHwiskFw4Jo7WHj5qNw=="],
"react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
@@ -848,6 +873,10 @@
"@radix-ui/react-select/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-slider/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-slider/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-tabs/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-tabs/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
@@ -928,6 +957,8 @@
"@radix-ui/react-scroll-area/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-slider/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-tabs/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-visually-hidden/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],

View File

@@ -1,50 +1,5 @@
services:
# Local services are available on their ports, but also available on:
# http://api.localhost.tiangolo.com: backend
# http://dashboard.localhost.tiangolo.com: frontend
# etc. To enable it, update .env, set:
# DOMAIN=localhost.tiangolo.com
proxy:
image: traefik:3.6
volumes:
- /var/run/docker.sock:/var/run/docker.sock
ports:
- "80:80"
- "8090:8080"
# Duplicate the command from compose.yml to add --api.insecure=true
command:
# Enable Docker in Traefik, so that it reads labels from Docker services
- --providers.docker
# Add a constraint to only use services with the label for this stack
- --providers.docker.constraints=Label(`traefik.constraint-label`, `traefik-public`)
# Do not expose all Docker services, only the ones explicitly exposed
- --providers.docker.exposedbydefault=false
# Create an entrypoint "http" listening on port 80
- --entrypoints.http.address=:80
# Create an entrypoint "https" listening on port 443
- --entrypoints.https.address=:443
# Enable the access log, with HTTP requests
- --accesslog
# Enable the Traefik log, for configurations and errors
- --log
# Enable debug logging for local development
- --log.level=DEBUG
# Enable the Dashboard and API
- --api
# Enable the Dashboard and API in insecure mode for local development
- --api.insecure=true
labels:
# Enable Traefik for this service, to make it available in the public network
- traefik.enable=true
- traefik.constraint-label=traefik-public
# Dummy https-redirect middleware that doesn't really redirect, only to
# allow running it locally
- traefik.http.middlewares.https-redirect.contenttype.autodetect=false
networks:
- traefik-public
- default
db:
restart: "no"
ports:
@@ -87,6 +42,20 @@ services:
SMTP_TLS: "false"
EMAILS_FROM_EMAIL: "noreply@example.com"
mqtt-ingestor:
restart: "no"
command:
- python
- -m
- app.mqtt.ingestor
mqtt-parser-worker:
restart: "no"
command:
- python
- -m
- app.mqtt.parser_worker
mailcatcher:
image: schickling/mailcatcher
ports:
@@ -100,9 +69,31 @@ services:
build:
context: .
dockerfile: frontend/Dockerfile
target: dev-stage
args:
- VITE_API_URL=http://localhost:8000
- NODE_ENV=development
environment:
- VITE_API_URL=http://localhost:8000
- CHOKIDAR_USEPOLLING=true
- CHOKIDAR_INTERVAL=150
develop:
watch:
- path: ./frontend
action: sync
target: /app/frontend
ignore:
- node_modules/
- dist/
- blob-report/
- test-results/
- package.json
- path: ./frontend/package.json
action: rebuild
- path: ./package.json
action: rebuild
- path: ./bun.lock
action: rebuild
playwright:
build:
@@ -122,14 +113,9 @@ services:
- MAILCATCHER_HOST=http://mailcatcher:1080
# For the reports when run locally
- PLAYWRIGHT_HTML_HOST=0.0.0.0
- CI=${CI}
- CI=${CI:-false}
volumes:
- ./frontend/blob-report:/app/frontend/blob-report
- ./frontend/test-results:/app/frontend/test-results
ports:
- 9323:9323
networks:
traefik-public:
# For local dev, don't expect an external Traefik network
external: false

View File

@@ -70,6 +70,77 @@ services:
context: .
dockerfile: backend/Dockerfile
mqtt-ingestor:
image: '${DOCKER_IMAGE_BACKEND?Variable not set}:${TAG-latest}'
restart: always
networks:
- 1panel-network
depends_on:
prestart:
condition: service_completed_successfully
env_file:
- .env.production
environment:
- ENVIRONMENT=${ENVIRONMENT}
- SECRET_KEY=${SECRET_KEY?Variable not set}
- FIRST_SUPERUSER=${FIRST_SUPERUSER?Variable not set}
- FIRST_SUPERUSER_PASSWORD=${FIRST_SUPERUSER_PASSWORD?Variable not set}
- POSTGRES_SERVER=${POSTGRES_SERVER}
- POSTGRES_PORT=${POSTGRES_PORT}
- POSTGRES_DB=${POSTGRES_DB}
- POSTGRES_USER=${POSTGRES_USER?Variable not set}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD?Variable not set}
- MQTT_HOST=${MQTT_HOST-1Panel-emqx-Rniy}
- MQTT_PORT=${MQTT_PORT-1883}
- MQTT_USERNAME=${MQTT_USERNAME}
- MQTT_PASSWORD=${MQTT_PASSWORD}
- MQTT_TOPIC_PATTERN=${MQTT_TOPIC_PATTERN-terminal/+/raw}
- MQTT_CLIENT_ID=${MQTT_CLIENT_ID-spatialhub-mqtt-ingestor}
- RAW_HOT_RETENTION_DAYS=${RAW_HOT_RETENTION_DAYS-90}
- RAW_ARCHIVE_BASE_DIR=${RAW_ARCHIVE_BASE_DIR-/data/archive/gnss}
- PARSER_BATCH_SIZE=${PARSER_BATCH_SIZE-200}
- PARSER_POLL_INTERVAL_MS=${PARSER_POLL_INTERVAL_MS-300}
command:
- python
- -m
- app.mqtt.ingestor
build:
context: .
dockerfile: backend/Dockerfile
volumes:
- mqtt-archive-data:/data/archive/gnss
mqtt-parser-worker:
image: '${DOCKER_IMAGE_BACKEND?Variable not set}:${TAG-latest}'
restart: always
networks:
- 1panel-network
depends_on:
prestart:
condition: service_completed_successfully
env_file:
- .env.production
environment:
- ENVIRONMENT=${ENVIRONMENT}
- SECRET_KEY=${SECRET_KEY?Variable not set}
- FIRST_SUPERUSER=${FIRST_SUPERUSER?Variable not set}
- FIRST_SUPERUSER_PASSWORD=${FIRST_SUPERUSER_PASSWORD?Variable not set}
- POSTGRES_SERVER=${POSTGRES_SERVER}
- POSTGRES_PORT=${POSTGRES_PORT}
- POSTGRES_DB=${POSTGRES_DB}
- POSTGRES_USER=${POSTGRES_USER?Variable not set}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD?Variable not set}
- RAW_HOT_RETENTION_DAYS=${RAW_HOT_RETENTION_DAYS-90}
- PARSER_BATCH_SIZE=${PARSER_BATCH_SIZE-200}
- PARSER_POLL_INTERVAL_MS=${PARSER_POLL_INTERVAL_MS-300}
command:
- python
- -m
- app.mqtt.parser_worker
build:
context: .
dockerfile: backend/Dockerfile
frontend:
image: '${DOCKER_IMAGE_FRONTEND?Variable not set}:${TAG-latest}'
restart: always
@@ -87,3 +158,6 @@ services:
networks:
1panel-network:
external: true
volumes:
mqtt-archive-data:

View File

@@ -1,77 +0,0 @@
services:
traefik:
image: traefik:3.6
ports:
# Listen on port 80, default for HTTP, necessary to redirect to HTTPS
- 80:80
# Listen on port 443, default for HTTPS
- 443:443
restart: always
labels:
# Enable Traefik for this service, to make it available in the public network
- traefik.enable=true
# Use the traefik-public network (declared below)
- traefik.docker.network=traefik-public
# Define the port inside of the Docker service to use
- traefik.http.services.traefik-dashboard.loadbalancer.server.port=8080
# Make Traefik use this domain (from an environment variable) in HTTP
- traefik.http.routers.traefik-dashboard-http.entrypoints=http
- traefik.http.routers.traefik-dashboard-http.rule=Host(`traefik.${DOMAIN?Variable not set}`)
# traefik-https the actual router using HTTPS
- traefik.http.routers.traefik-dashboard-https.entrypoints=https
- traefik.http.routers.traefik-dashboard-https.rule=Host(`traefik.${DOMAIN?Variable not set}`)
- traefik.http.routers.traefik-dashboard-https.tls=true
# Use the "le" (Let's Encrypt) resolver created below
- traefik.http.routers.traefik-dashboard-https.tls.certresolver=le
# Use the special Traefik service api@internal with the web UI/Dashboard
- traefik.http.routers.traefik-dashboard-https.service=api@internal
# https-redirect middleware to redirect HTTP to HTTPS
- traefik.http.middlewares.https-redirect.redirectscheme.scheme=https
- traefik.http.middlewares.https-redirect.redirectscheme.permanent=true
# traefik-http set up only to use the middleware to redirect to https
- traefik.http.routers.traefik-dashboard-http.middlewares=https-redirect
# admin-auth middleware with HTTP Basic auth
# Using the environment variables USERNAME and HASHED_PASSWORD
- traefik.http.middlewares.admin-auth.basicauth.users=${USERNAME?Variable not set}:${HASHED_PASSWORD?Variable not set}
# Enable HTTP Basic auth, using the middleware created above
- traefik.http.routers.traefik-dashboard-https.middlewares=admin-auth
volumes:
# Add Docker as a mounted volume, so that Traefik can read the labels of other services
- /var/run/docker.sock:/var/run/docker.sock:ro
# Mount the volume to store the certificates
- traefik-public-certificates:/certificates
command:
# Enable Docker in Traefik, so that it reads labels from Docker services
- --providers.docker
# Do not expose all Docker services, only the ones explicitly exposed
- --providers.docker.exposedbydefault=false
# Create an entrypoint "http" listening on port 80
- --entrypoints.http.address=:80
# Create an entrypoint "https" listening on port 443
- --entrypoints.https.address=:443
# Create the certificate resolver "le" for Let's Encrypt, uses the environment variable EMAIL
- --certificatesresolvers.le.acme.email=${EMAIL?Variable not set}
# Store the Let's Encrypt certificates in the mounted volume
- --certificatesresolvers.le.acme.storage=/certificates/acme.json
# Use the TLS Challenge for Let's Encrypt
- --certificatesresolvers.le.acme.tlschallenge=true
# Enable the access log, with HTTP requests
- --accesslog
# Enable the Traefik log, for configurations and errors
- --log
# Enable the Dashboard and API
- --api
networks:
# Use the public network created to be shared between Traefik and
# any other service that needs to be publicly available with HTTPS
- traefik-public
volumes:
# Create a volume to store the certificates, even if the container is recreated
traefik-public-certificates:
networks:
# Use the previously created public network "traefik-public", shared with other
# services that need to be publicly available via this Traefik
traefik-public:
external: true

View File

@@ -22,34 +22,16 @@ services:
adminer:
image: adminer
restart: always
networks:
- traefik-public
- default
depends_on:
- db
environment:
- ADMINER_DESIGN=pepa-linha-dark
labels:
- traefik.enable=true
- traefik.docker.network=traefik-public
- traefik.constraint-label=traefik-public
- traefik.http.routers.${STACK_NAME?Variable not set}-adminer-http.rule=Host(`adminer.${DOMAIN?Variable not set}`)
- traefik.http.routers.${STACK_NAME?Variable not set}-adminer-http.entrypoints=http
- traefik.http.routers.${STACK_NAME?Variable not set}-adminer-http.middlewares=https-redirect
- traefik.http.routers.${STACK_NAME?Variable not set}-adminer-https.rule=Host(`adminer.${DOMAIN?Variable not set}`)
- traefik.http.routers.${STACK_NAME?Variable not set}-adminer-https.entrypoints=https
- traefik.http.routers.${STACK_NAME?Variable not set}-adminer-https.tls=true
- traefik.http.routers.${STACK_NAME?Variable not set}-adminer-https.tls.certresolver=le
- traefik.http.services.${STACK_NAME?Variable not set}-adminer.loadbalancer.server.port=8080
prestart:
image: '${DOCKER_IMAGE_BACKEND?Variable not set}:${TAG-latest}'
build:
context: .
dockerfile: backend/Dockerfile
networks:
- traefik-public
- default
depends_on:
db:
condition: service_healthy
@@ -79,9 +61,6 @@ services:
backend:
image: '${DOCKER_IMAGE_BACKEND?Variable not set}:${TAG-latest}'
restart: always
networks:
- traefik-public
- default
depends_on:
db:
condition: service_healthy
@@ -118,57 +97,93 @@ services:
build:
context: .
dockerfile: backend/Dockerfile
labels:
- traefik.enable=true
- traefik.docker.network=traefik-public
- traefik.constraint-label=traefik-public
- traefik.http.services.${STACK_NAME?Variable not set}-backend.loadbalancer.server.port=8000
mqtt-ingestor:
image: '${DOCKER_IMAGE_BACKEND?Variable not set}:${TAG-latest}'
restart: always
networks:
- default
depends_on:
db:
condition: service_healthy
restart: true
prestart:
condition: service_completed_successfully
command:
- python
- -m
- app.mqtt.ingestor
env_file:
- .env
environment:
- ENVIRONMENT=${ENVIRONMENT}
- SECRET_KEY=${SECRET_KEY?Variable not set}
- FIRST_SUPERUSER=${FIRST_SUPERUSER?Variable not set}
- FIRST_SUPERUSER_PASSWORD=${FIRST_SUPERUSER_PASSWORD?Variable not set}
- POSTGRES_SERVER=db
- POSTGRES_PORT=${POSTGRES_PORT}
- POSTGRES_DB=${POSTGRES_DB}
- POSTGRES_USER=${POSTGRES_USER?Variable not set}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD?Variable not set}
- MQTT_HOST=${MQTT_HOST-101.35.119.226}
- MQTT_PORT=${MQTT_PORT-1883}
- MQTT_USERNAME=${MQTT_USERNAME}
- MQTT_PASSWORD=${MQTT_PASSWORD}
- MQTT_TOPIC_PATTERN=${MQTT_TOPIC_PATTERN-terminal/+/raw}
- MQTT_CLIENT_ID=${MQTT_CLIENT_ID-spatialhub-mqtt-ingestor}
- RAW_HOT_RETENTION_DAYS=${RAW_HOT_RETENTION_DAYS-90}
- RAW_ARCHIVE_BASE_DIR=${RAW_ARCHIVE_BASE_DIR-/data/archive/gnss}
- PARSER_BATCH_SIZE=${PARSER_BATCH_SIZE-200}
- PARSER_POLL_INTERVAL_MS=${PARSER_POLL_INTERVAL_MS-300}
build:
context: .
dockerfile: backend/Dockerfile
volumes:
- mqtt-archive-data:/data/archive/gnss
- traefik.http.routers.${STACK_NAME?Variable not set}-backend-http.rule=Host(`api.${DOMAIN?Variable not set}`)
- traefik.http.routers.${STACK_NAME?Variable not set}-backend-http.entrypoints=http
- traefik.http.routers.${STACK_NAME?Variable not set}-backend-https.rule=Host(`api.${DOMAIN?Variable not set}`)
- traefik.http.routers.${STACK_NAME?Variable not set}-backend-https.entrypoints=https
- traefik.http.routers.${STACK_NAME?Variable not set}-backend-https.tls=true
- traefik.http.routers.${STACK_NAME?Variable not set}-backend-https.tls.certresolver=le
# Enable redirection for HTTP and HTTPS
- traefik.http.routers.${STACK_NAME?Variable not set}-backend-http.middlewares=https-redirect
mqtt-parser-worker:
image: '${DOCKER_IMAGE_BACKEND?Variable not set}:${TAG-latest}'
restart: always
networks:
- default
depends_on:
db:
condition: service_healthy
restart: true
prestart:
condition: service_completed_successfully
command:
- python
- -m
- app.mqtt.parser_worker
env_file:
- .env
environment:
- ENVIRONMENT=${ENVIRONMENT}
- SECRET_KEY=${SECRET_KEY?Variable not set}
- FIRST_SUPERUSER=${FIRST_SUPERUSER?Variable not set}
- FIRST_SUPERUSER_PASSWORD=${FIRST_SUPERUSER_PASSWORD?Variable not set}
- POSTGRES_SERVER=db
- POSTGRES_PORT=${POSTGRES_PORT}
- POSTGRES_DB=${POSTGRES_DB}
- POSTGRES_USER=${POSTGRES_USER?Variable not set}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD?Variable not set}
- RAW_HOT_RETENTION_DAYS=${RAW_HOT_RETENTION_DAYS-90}
- PARSER_BATCH_SIZE=${PARSER_BATCH_SIZE-200}
- PARSER_POLL_INTERVAL_MS=${PARSER_POLL_INTERVAL_MS-300}
build:
context: .
dockerfile: backend/Dockerfile
frontend:
image: '${DOCKER_IMAGE_FRONTEND?Variable not set}:${TAG-latest}'
restart: always
networks:
- traefik-public
- default
build:
context: .
dockerfile: frontend/Dockerfile
args:
- VITE_API_URL=https://api.${DOMAIN?Variable not set}
- NODE_ENV=production
labels:
- traefik.enable=true
- traefik.docker.network=traefik-public
- traefik.constraint-label=traefik-public
- traefik.http.services.${STACK_NAME?Variable not set}-frontend.loadbalancer.server.port=80
- traefik.http.routers.${STACK_NAME?Variable not set}-frontend-http.rule=Host(`dashboard.${DOMAIN?Variable not set}`)
- traefik.http.routers.${STACK_NAME?Variable not set}-frontend-http.entrypoints=http
- traefik.http.routers.${STACK_NAME?Variable not set}-frontend-https.rule=Host(`dashboard.${DOMAIN?Variable not set}`)
- traefik.http.routers.${STACK_NAME?Variable not set}-frontend-https.entrypoints=https
- traefik.http.routers.${STACK_NAME?Variable not set}-frontend-https.tls=true
- traefik.http.routers.${STACK_NAME?Variable not set}-frontend-https.tls.certresolver=le
# Enable redirection for HTTP and HTTPS
- traefik.http.routers.${STACK_NAME?Variable not set}-frontend-http.middlewares=https-redirect
volumes:
app-db-data:
networks:
traefik-public:
# Allow setting it to false for testing
external: true
mqtt-archive-data:

View File

@@ -1,100 +0,0 @@
project_name:
type: str
help: The name of the project, shown to API users (in .env)
default: FastAPI Project
stack_name:
type: str
help: The name of the stack used for Docker Compose labels (no spaces) (in .env)
default: fastapi-project
secret_key:
type: str
help: |
'The secret key for the project, used for security,
stored in .env, you can generate one with:
python -c "import secrets; print(secrets.token_urlsafe(32))"'
default: changethis
first_superuser:
type: str
help: The email of the first superuser (in .env)
default: admin@example.com
first_superuser_password:
type: str
help: The password of the first superuser (in .env)
default: changethis
smtp_host:
type: str
help: The SMTP server host to send emails, you can set it later in .env
default: ""
smtp_user:
type: str
help: The SMTP server user to send emails, you can set it later in .env
default: ""
smtp_password:
type: str
help: The SMTP server password to send emails, you can set it later in .env
default: ""
emails_from_email:
type: str
help: The email account to send emails from, you can set it later in .env
default: info@example.com
postgres_password:
type: str
help: |
'The password for the PostgreSQL database, stored in .env,
you can generate one with:
python -c "import secrets; print(secrets.token_urlsafe(32))"'
default: changethis
sentry_dsn:
type: str
help: The DSN for Sentry, if you are using it, you can set it later in .env
default: ""
_exclude:
# Global
- .vscode
- .mypy_cache
# Python
- __pycache__
- app.egg-info
- "*.pyc"
- .mypy_cache
- .coverage
- htmlcov
- .cache
- .venv
# Frontend
# Logs
- logs
- "*.log"
- npm-debug.log*
- yarn-debug.log*
- yarn-error.log*
- pnpm-debug.log*
- lerna-debug.log*
- node_modules
- dist
- dist-ssr
- "*.local"
# Editor directories and files
- .idea
- .DS_Store
- "*.suo"
- "*.ntvs*"
- "*.njsproj"
- "*.sln"
- "*.sw?"
_answers_file: .copier/.copier-answers.yml
_tasks:
- ["{{ _copier_python }}", .copier/update_dotenv.py]

View File

@@ -33,7 +33,7 @@ OpenResty (1Panel 管理, SSL, 端口 80/443)
**关键设计决策:**
- 后端和前端容器只绑定 `127.0.0.1`,不对外暴露,由 OpenResty 统一反代
- 所有容器加入 `1panel-network`,可直接通过 `postgresql` 主机名访问已有数据库
- 不使用 Traefik用 1Panel 自带的 OpenResty 替代)
- 使用 1Panel 自带的 OpenResty 作为反向代理
- 不启动独立 PostgreSQL 容器(复用已有的)
---
@@ -51,14 +51,14 @@ sudo chown $USER:$USER /opt/fastapi-app
### 2.2 初始化 Git 仓库(在 Gitea 上)
1. 登录 Gitea`http://your-server-ip:3000`
2. 创建新仓库,例如 `full-stack-fastapi`
2. 创建新仓库,例如 `spatialhub`
3. 在本地开发机上添加 Gitea 远程仓库:
```bash
cd /Users/weifeng/Workspace/full-stack-fastapi-template
# 添加 Gitea 远程
git remote add gitea http://your-server-ip:3000/your-username/full-stack-fastapi.git
git remote add gitea http://your-server-ip:3000/your-username/spatialhub.git
# 推送代码
git push gitea main
@@ -130,7 +130,7 @@ ALTER DATABASE app OWNER TO fastapi_user;
```bash
cd /opt/fastapi-app
git clone http://localhost:3000/your-username/full-stack-fastapi.git .
git clone http://localhost:3000/your-username/spatialhub.git .
# 或者如果已经有代码
git pull origin main
```
@@ -323,8 +323,7 @@ sudo systemctl status gitea-runner
|-------------|-----|
| `DOMAIN` | `makefire.fun` |
| `FRONTEND_HOST` | `https://makefire.fun` |
| `PROJECT_NAME` | `Full Stack FastAPI Project` |
| `STACK_NAME` | `full-stack-fastapi-project` |
| `PROJECT_NAME` | `SpatialHub` |
| `BACKEND_CORS_ORIGINS` | `https://makefire.fun,https://api.makefire.fun` |
| `SECRET_KEY` | *(用 `openssl rand -hex 32` 生成)* |
| `FIRST_SUPERUSER` | `admin@makefire.fun` |

View File

@@ -1,344 +1,87 @@
# FastAPI Project - Deployment
You can deploy the project using Docker Compose to a remote server.
You can deploy this project with Docker Compose and put it behind an external reverse proxy (for example OpenResty/Nginx).
This project expects you to have a Traefik proxy handling communication to the outside world and HTTPS certificates.
You can use CI/CD (continuous integration and continuous deployment) systems to deploy automatically, there are already configurations to do it with GitHub Actions.
But you have to configure a couple things first. 🤓
This repository uses an external reverse proxy setup and keeps Compose deployment focused on app services.
## Preparation
* Have a remote server ready and available.
* Configure the DNS records of your domain to point to the IP of the server you just created.
* Configure a wildcard subdomain for your domain, so that you can have multiple subdomains for different services, e.g. `*.fastapi-project.example.com`. This will be useful for accessing different components, like `dashboard.fastapi-project.example.com`, `api.fastapi-project.example.com`, `traefik.fastapi-project.example.com`, `adminer.fastapi-project.example.com`, etc. And also for `staging`, like `dashboard.staging.fastapi-project.example.com`, `adminer.staging.fastapi-project.example.com`, etc.
* Install and configure [Docker](https://docs.docker.com/engine/install/) on the remote server (Docker Engine, not Docker Desktop).
## Public Traefik
We need a Traefik proxy to handle incoming connections and HTTPS certificates.
You need to do these next steps only once.
### Traefik Docker Compose
* Create a remote directory to store your Traefik Docker Compose file:
```bash
mkdir -p /root/code/traefik-public/
```
Copy the Traefik Docker Compose file to your server. You could do it by running the command `rsync` in your local terminal:
```bash
rsync -a compose.traefik.yml root@your-server.example.com:/root/code/traefik-public/
```
### Traefik Public Network
This Traefik will expect a Docker "public network" named `traefik-public` to communicate with your stack(s).
This way, there will be a single public Traefik proxy that handles the communication (HTTP and HTTPS) with the outside world, and then behind that, you could have one or more stacks with different domains, even if they are on the same single server.
To create a Docker "public network" named `traefik-public` run the following command in your remote server:
```bash
docker network create traefik-public
```
### Traefik Environment Variables
The Traefik Docker Compose file expects some environment variables to be set in your terminal before starting it. You can do it by running the following commands in your remote server.
* Create the username for HTTP Basic Auth, e.g.:
```bash
export USERNAME=admin
```
* Create an environment variable with the password for HTTP Basic Auth, e.g.:
```bash
export PASSWORD=changethis
```
* Use openssl to generate the "hashed" version of the password for HTTP Basic Auth and store it in an environment variable:
```bash
export HASHED_PASSWORD=$(openssl passwd -apr1 $PASSWORD)
```
To verify that the hashed password is correct, you can print it:
```bash
echo $HASHED_PASSWORD
```
* Create an environment variable with the domain name for your server, e.g.:
```bash
export DOMAIN=fastapi-project.example.com
```
* Create an environment variable with the email for Let's Encrypt, e.g.:
```bash
export EMAIL=admin@example.com
```
**Note**: you need to set a different email, an email `@example.com` won't work.
### Start the Traefik Docker Compose
Go to the directory where you copied the Traefik Docker Compose file in your remote server:
```bash
cd /root/code/traefik-public/
```
Now with the environment variables set and the `compose.traefik.yml` in place, you can start the Traefik Docker Compose running the following command:
```bash
docker compose -f compose.traefik.yml up -d
```
## Deploy the FastAPI Project
Now that you have Traefik in place you can deploy your FastAPI project with Docker Compose.
**Note**: You might want to jump ahead to the section about Continuous Deployment with GitHub Actions.
## Copy the Code
```bash
rsync -av --filter=":- .gitignore" ./ root@your-server.example.com:/root/code/app/
```
Note: `--filter=":- .gitignore"` tells `rsync` to use the same rules as git, ignore files ignored by git, like the Python virtual environment.
* Have a remote server ready.
* Configure DNS records to point your domain to that server.
* Install Docker Engine on the server.
* Configure OpenResty (or your reverse proxy) to forward traffic:
* frontend domain/path -> frontend container port
* API domain/path -> backend container port
## Environment Variables
You need to set some environment variables first.
Create `.env.production` based on `.env.production.example` and fill in real values.
### Generate secret keys
Minimum required values:
Some environment variables in the `.env` file have a default value of `changethis`.
You have to change them with a secret key, to generate secret keys you can run the following command:
```bash
python -c "import secrets; print(secrets.token_urlsafe(32))"
```
Copy the content and use that as password / secret key. And run that again to generate another secure key.
### Required Environment Variables
Set the `ENVIRONMENT`, by default `local` (for development), but when deploying to a server you would put something like `staging` or `production`:
```bash
export ENVIRONMENT=production
```
Set the `DOMAIN`, by default `localhost` (for development), but when deploying you would use your own domain, for example:
```bash
export DOMAIN=fastapi-project.example.com
```
Set the `POSTGRES_PASSWORD` to something different than `changethis`:
```bash
export POSTGRES_PASSWORD="changethis"
```
Set the `SECRET_KEY`, used to sign tokens:
```bash
export SECRET_KEY="changethis"
```
Note: you can use the Python command above to generate a secure secret key.
Set the `FIRST_SUPER_USER_PASSWORD` to something different than `changethis`:
```bash
export FIRST_SUPERUSER_PASSWORD="changethis"
```
Set the `BACKEND_CORS_ORIGINS` to include your domain:
```bash
export BACKEND_CORS_ORIGINS="https://dashboard.${DOMAIN?Variable not set},https://api.${DOMAIN?Variable not set}"
```
You can set several other environment variables:
* `PROJECT_NAME`: The name of the project, used in the API for the docs and emails.
* `STACK_NAME`: The name of the stack used for Docker Compose labels and project name, this should be different for `staging`, `production`, etc. You could use the same domain replacing dots with dashes, e.g. `fastapi-project-example-com` and `staging-fastapi-project-example-com`.
* `BACKEND_CORS_ORIGINS`: A list of allowed CORS origins separated by commas.
* `FIRST_SUPERUSER`: The email of the first superuser, this superuser will be the one that can create new users.
* `SMTP_HOST`: The SMTP server host to send emails, this would come from your email provider (E.g. Mailgun, Sparkpost, Sendgrid, etc).
* `SMTP_USER`: The SMTP server user to send emails.
* `SMTP_PASSWORD`: The SMTP server password to send emails.
* `EMAILS_FROM_EMAIL`: The email account to send emails from.
* `POSTGRES_SERVER`: The hostname of the PostgreSQL server. You can leave the default of `db`, provided by the same Docker Compose. You normally wouldn't need to change this unless you are using a third-party provider.
* `POSTGRES_PORT`: The port of the PostgreSQL server. You can leave the default. You normally wouldn't need to change this unless you are using a third-party provider.
* `POSTGRES_USER`: The Postgres user, you can leave the default.
* `POSTGRES_DB`: The database name to use for this application. You can leave the default of `app`.
* `SENTRY_DSN`: The DSN for Sentry, if you are using it.
## GitHub Actions Environment Variables
There are some environment variables only used by GitHub Actions that you can configure:
* `LATEST_CHANGES`: Used by the GitHub Action [latest-changes](https://github.com/tiangolo/latest-changes) to automatically add release notes based on the PRs merged. It's a personal access token, read the docs for details.
* `SMOKESHOW_AUTH_KEY`: Used to handle and publish the code coverage using [Smokeshow](https://github.com/samuelcolvin/smokeshow), follow their instructions to create a (free) Smokeshow key.
### Deploy with Docker Compose
With the environment variables in place, you can deploy with Docker Compose:
```bash
cd /root/code/app/
docker compose -f compose.yml build
docker compose -f compose.yml up -d
```
For production you wouldn't want to have the overrides in `compose.override.yml`, that's why we explicitly specify `compose.yml` as the file to use.
## Continuous Deployment (CD)
You can use GitHub Actions to deploy your project automatically. 😎
You can have multiple environment deployments.
There are already two environments configured, `staging` and `production`. 🚀
### Install GitHub Actions Runner
* On your remote server, create a user for your GitHub Actions:
```bash
sudo adduser github
```
* Add Docker permissions to the `github` user:
```bash
sudo usermod -aG docker github
```
* Temporarily switch to the `github` user:
```bash
sudo su - github
```
* Go to the `github` user's home directory:
```bash
cd
```
* [Install a GitHub Action self-hosted runner following the official guide](https://docs.github.com/en/actions/hosting-your-own-runners/managing-self-hosted-runners/adding-self-hosted-runners#adding-a-self-hosted-runner-to-a-repository).
* When asked about labels, add a label for the environment, e.g. `production`. You can also add labels later.
After installing, the guide would tell you to run a command to start the runner. Nevertheless, it would stop once you terminate that process or if your local connection to your server is lost.
To make sure it runs on startup and continues running, you can install it as a service. To do that, exit the `github` user and go back to the `root` user:
```bash
exit
```
After you do it, you will be on the previous user again. And you will be on the previous directory, belonging to that user.
Before being able to go the `github` user directory, you need to become the `root` user (you might already be):
```bash
sudo su
```
* As the `root` user, go to the `actions-runner` directory inside of the `github` user's home directory:
```bash
cd /home/github/actions-runner
```
* Install the self-hosted runner as a service with the user `github`:
```bash
./svc.sh install github
```
* Start the service:
```bash
./svc.sh start
```
* Check the status of the service:
```bash
./svc.sh status
```
You can read more about it in the official guide: [Configuring the self-hosted runner application as a service](https://docs.github.com/en/actions/hosting-your-own-runners/managing-self-hosted-runners/configuring-the-self-hosted-runner-application-as-a-service).
### Set Secrets
On your repository, configure secrets for the environment variables you need, the same ones described above, including `SECRET_KEY`, etc. Follow the [official GitHub guide for setting repository secrets](https://docs.github.com/en/actions/security-guides/using-secrets-in-github-actions#creating-secrets-for-a-repository).
The current Github Actions workflows expect these secrets:
* `DOMAIN_PRODUCTION`
* `DOMAIN_STAGING`
* `STACK_NAME_PRODUCTION`
* `STACK_NAME_STAGING`
* `EMAILS_FROM_EMAIL`
* `DOMAIN`
* `FRONTEND_HOST`
* `ENVIRONMENT=production`
* `SECRET_KEY`
* `FIRST_SUPERUSER`
* `FIRST_SUPERUSER_PASSWORD`
* `POSTGRES_SERVER`
* `POSTGRES_PORT`
* `POSTGRES_DB`
* `POSTGRES_USER`
* `POSTGRES_PASSWORD`
* `SECRET_KEY`
* `LATEST_CHANGES`
* `SMOKESHOW_AUTH_KEY`
## GitHub Action Deployment Workflows
Optional values:
There are GitHub Action workflows in the `.github/workflows` directory already configured for deploying to the environments (GitHub Actions runners with the labels):
* `SMTP_*` and `EMAILS_FROM_EMAIL`
* `SENTRY_DSN`
* `MQTT_*` pipeline settings
* `DOCKER_IMAGE_BACKEND`
* `DOCKER_IMAGE_FRONTEND`
* `staging`: after pushing (or merging) to the branch `master`.
* `production`: after publishing a release.
## Deploy with Docker Compose
If you need to add extra environments you could use those as a starting point.
```bash
cd /path/to/project
docker compose --env-file .env.production -f compose.prod.yml build
docker compose --env-file .env.production -f compose.prod.yml up -d
```
To update after code changes:
```bash
docker compose --env-file .env.production -f compose.prod.yml down --remove-orphans
docker compose --env-file .env.production -f compose.prod.yml up -d --build
```
## Verify
```bash
docker compose --env-file .env.production -f compose.prod.yml ps
docker compose --env-file .env.production -f compose.prod.yml logs backend --tail 100
docker compose --env-file .env.production -f compose.prod.yml logs frontend --tail 100
```
Backend health check endpoint:
* `http://<backend-host>:18000/api/v1/utils/health-check/` (or your internal mapped port)
## CI/CD
The Gitea workflow at `.gitea/workflows/deploy.yml` deploys using:
```bash
docker compose --env-file .env.production -f compose.prod.yml ...
```
So production deploys are isolated from local development overrides.
## URLs
Replace `fastapi-project.example.com` with your domain.
Replace with your real domains configured in OpenResty:
### Main Traefik Dashboard
Traefik UI: `https://traefik.fastapi-project.example.com`
### Production
Frontend: `https://dashboard.fastapi-project.example.com`
Backend API docs: `https://api.fastapi-project.example.com/docs`
Backend API base URL: `https://api.fastapi-project.example.com`
Adminer: `https://adminer.fastapi-project.example.com`
### Staging
Frontend: `https://dashboard.staging.fastapi-project.example.com`
Backend API docs: `https://api.staging.fastapi-project.example.com/docs`
Backend API base URL: `https://api.staging.fastapi-project.example.com`
Adminer: `https://adminer.staging.fastapi-project.example.com`
* Frontend: `https://<your-frontend-domain>`
* Backend API docs: `https://<your-api-domain>/docs`
* Backend API base URL: `https://<your-api-domain>`

View File

@@ -18,8 +18,6 @@ Automatic interactive documentation with Swagger UI (from the OpenAPI backend):
Adminer, database web administration: <http://localhost:8080>
Traefik UI, to see how the routes are being handled by the proxy: <http://localhost:8090>
**Note**: The first time you start your stack, it might take a minute for it to be ready. While the backend waits for the database to be ready and configures everything. You can check the logs to monitor it.
To check the logs, run (in another terminal):
@@ -79,34 +77,6 @@ cd backend
fastapi dev app/main.py
```
## Docker Compose in `localhost.tiangolo.com`
When you start the Docker Compose stack, it uses `localhost` by default, with different ports for each service (backend, frontend, adminer, etc).
When you deploy it to production (or staging), it will deploy each service in a different subdomain, like `api.example.com` for the backend and `dashboard.example.com` for the frontend.
In the guide about [deployment](deployment.md) you can read about Traefik, the configured proxy. That's the component in charge of transmitting traffic to each service based on the subdomain.
If you want to test that it's all working locally, you can edit the local `.env` file, and change:
```dotenv
DOMAIN=localhost.tiangolo.com
```
That will be used by the Docker Compose files to configure the base domain for the services.
Traefik will use this to transmit traffic at `api.localhost.tiangolo.com` to the backend, and traffic at `dashboard.localhost.tiangolo.com` to the frontend.
The domain `localhost.tiangolo.com` is a special domain that is configured (with all its subdomains) to point to `127.0.0.1`. This way you can use that for your local development.
After you update it, run again:
```bash
docker compose watch
```
When deploying, for example in production, the main Traefik is configured outside of the Docker Compose files. For local development, there's an included Traefik in `compose.override.yml`, just to let you test that the domains work as expected, for example with `api.localhost.tiangolo.com` and `dashboard.localhost.tiangolo.com`.
## Docker Compose files and env vars
There is a main `compose.yml` file with all the configurations that apply to the whole stack, it is used automatically by `docker compose`.
@@ -198,24 +168,4 @@ Automatic Alternative Docs (ReDoc): <http://localhost:8000/redoc>
Adminer: <http://localhost:8080>
Traefik UI: <http://localhost:8090>
MailCatcher: <http://localhost:1080>
### Development URLs with `localhost.tiangolo.com` Configured
Development URLs, for local development.
Frontend: <http://dashboard.localhost.tiangolo.com>
Backend: <http://api.localhost.tiangolo.com>
Automatic Interactive Docs (Swagger UI): <http://api.localhost.tiangolo.com/docs>
Automatic Alternative Docs (ReDoc): <http://api.localhost.tiangolo.com/redoc>
Adminer: <http://localhost.tiangolo.com:8080>
Traefik UI: <http://localhost.tiangolo.com:8090>
MailCatcher: <http://localhost.tiangolo.com:1080>

View File

@@ -0,0 +1,581 @@
# MQTT Raw Ingestion Pipeline Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build a production-safe MQTT ingestion pipeline that subscribes to `terminal/{device_id}/raw`, stores payload bytes losslessly, parses NMEA + RTCM 3.2 type summaries asynchronously, and supports 90-day hot data plus local cold archive.
**Architecture:** Use a DB-queue two-stage pipeline: `mqtt-ingestor` writes raw payloads to PostgreSQL immediately, and `parser-worker` asynchronously consumes pending rows to produce event/state tables. A separate archive job writes local cold files and manifest records, then prunes hot data beyond retention.
**Tech Stack:** FastAPI/SQLModel/Alembic/PostgreSQL, paho-mqtt, pytest, Docker Compose.
---
## Scope Check
This plan covers one cohesive subsystem (MQTT ingest + parse + archive pipeline). It intentionally excludes frontend UI and realtime push implementation, but produces backend data structures required for both.
## Execution Skills
- `@superpowers/test-driven-development`
- `@superpowers/systematic-debugging`
- `@superpowers/verification-before-completion`
## File Structure Map
### Create
- `backend/app/mqtt/__init__.py` — MQTT package marker.
- `backend/app/mqtt/topic.py` — topic parsing and validation (`device_id` extraction).
- `backend/app/mqtt/repository.py` — DB helpers for raw insert, pending fetch, status update, event/state write, archive manifest.
- `backend/app/mqtt/parsers/__init__.py` — parser package exports.
- `backend/app/mqtt/parsers/nmea.py` — NMEA sentence extraction + structured fields.
- `backend/app/mqtt/parsers/rtcm.py` — RTCM 3.x frame scan + type summary.
- `backend/app/mqtt/parsers/mixed.py` — mixed payload scanner orchestrating NMEA + RTCM parsers.
- `backend/app/mqtt/ingestor.py` — MQTT client callbacks + buffered DB flush.
- `backend/app/mqtt/parser_worker.py` — pending job polling loop (`FOR UPDATE SKIP LOCKED`).
- `backend/app/mqtt/archive_job.py` — local archive writer and retention cleanup.
- `backend/app/alembic/versions/a1b2c3d4e5f6_add_mqtt_pipeline_tables.py` — migration for raw/event/state/archive tables.
- `backend/tests/mqtt/test_topic.py` — unit tests for topic parser.
- `backend/tests/mqtt/test_mixed_parser.py` — unit tests for mixed payload parse behavior.
- `backend/tests/mqtt/test_parser_worker.py` — DB-backed tests for pending->parsed transitions.
- `backend/tests/mqtt/test_archive_job.py` — archive/manifest/cleanup tests.
### Modify
- `backend/pyproject.toml` — add MQTT dependency (`paho-mqtt`).
- `backend/app/core/config.py` — add MQTT and pipeline configuration env vars.
- `backend/app/models.py` — add `TerminalMessageRaw`, `TerminalObservationEvent`, `TerminalDeviceState`, `TerminalRawArchiveManifest` models.
- `compose.yml` — add production `mqtt-ingestor` and `mqtt-parser-worker` services.
- `compose.override.yml` — add local dev services and optional archive runner profile.
- `.env.production.example` — add MQTT and archive env examples.
- `backend/README.md` — operational docs for running ingestor/worker/archive and verification commands.
### Optional (if command wiring is needed)
- `backend/pyproject.toml` entry points for CLI commands (`mqtt-ingestor`, `mqtt-parser-worker`, `mqtt-archive`).
---
### Task 1: Configuration + Topic Parsing Foundation
**Files:**
- Create: `backend/app/mqtt/__init__.py`
- Create: `backend/app/mqtt/topic.py`
- Test: `backend/tests/mqtt/test_topic.py`
- Modify: `backend/app/core/config.py`
- Modify: `backend/pyproject.toml`
- [ ] **Step 1: Write failing tests for topic parsing**
```python
# backend/tests/mqtt/test_topic.py
import pytest
from app.mqtt.topic import extract_device_id
def test_extract_device_id_success() -> None:
assert extract_device_id("terminal/device-001/raw") == "device-001"
def test_extract_device_id_rejects_invalid_topic() -> None:
with pytest.raises(ValueError, match="terminal/.+/raw"):
extract_device_id("terminal/device-001/nmea")
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd backend && ../.venv/bin/pytest tests/mqtt/test_topic.py -v`
Expected: FAIL with `ModuleNotFoundError: No module named 'app.mqtt.topic'`
- [ ] **Step 3: Implement minimal topic parser + config fields**
```python
# backend/app/mqtt/topic.py
import re
TOPIC_RE = re.compile(r"^terminal/(?P<device_id>[^/]+)/raw$")
def extract_device_id(topic: str) -> str:
match = TOPIC_RE.match(topic)
if not match:
raise ValueError("topic must match terminal/{device_id}/raw")
return match.group("device_id")
```
```python
# backend/app/core/config.py (additions)
MQTT_HOST: str = "101.35.119.226"
MQTT_PORT: int = 1883
MQTT_USERNAME: str = ""
MQTT_PASSWORD: str = ""
MQTT_TOPIC_PATTERN: str = "terminal/+/raw"
MQTT_CLIENT_ID: str = "spatialhub-mqtt-ingestor"
RAW_HOT_RETENTION_DAYS: int = 90
RAW_ARCHIVE_BASE_DIR: str = "/data/archive/gnss"
PARSER_BATCH_SIZE: int = 200
PARSER_POLL_INTERVAL_MS: int = 300
```
- [ ] **Step 4: Run tests to verify pass**
Run: `cd backend && ../.venv/bin/pytest tests/mqtt/test_topic.py -v`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add backend/app/mqtt/__init__.py backend/app/mqtt/topic.py backend/tests/mqtt/test_topic.py backend/app/core/config.py backend/pyproject.toml
git commit -m "feat: add MQTT topic parsing and pipeline config"
```
### Task 2: Add DB Schema for Raw/Event/State/Archive
**Files:**
- Modify: `backend/app/models.py`
- Create: `backend/app/alembic/versions/a1b2c3d4e5f6_add_mqtt_pipeline_tables.py`
- Test: `backend/tests/mqtt/test_parser_worker.py`
- [ ] **Step 1: Write failing DB test for raw row persistence**
```python
# backend/tests/mqtt/test_parser_worker.py
from sqlmodel import Session
from app.models import TerminalMessageRaw
def test_terminal_message_raw_model_can_persist(db: Session) -> None:
row = TerminalMessageRaw(
device_id="device-001",
topic="terminal/device-001/raw",
payload_bytes=b"$GPGGA,123519,3723.2475,N,12158.3416,W,1,12,0.8,10.0,M,-25.0,M,,*65\r\n",
)
db.add(row)
db.commit()
db.refresh(row)
assert row.id is not None
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd backend && ../.venv/bin/pytest tests/mqtt/test_parser_worker.py::test_terminal_message_raw_model_can_persist -v`
Expected: FAIL with `ImportError`/`AttributeError` for missing `TerminalMessageRaw`
- [ ] **Step 3: Implement models and migration**
```python
# backend/app/models.py (new table skeleton example)
class TerminalMessageRaw(SQLModel, table=True):
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
device_id: str = Field(index=True)
topic: str
received_at: datetime = Field(default_factory=get_datetime_utc, sa_type=DateTime(timezone=True))
payload_bytes: bytes
payload_size: int
payload_sha256: str = Field(max_length=64, index=True)
parse_status: str = Field(default="pending", index=True)
```
```python
# alembic upgrade should create:
# terminal_message_raw, terminal_observation_event, terminal_device_state, terminal_raw_archive_manifest
```
```bash
# if creating from CLI
cd backend && ../.venv/bin/alembic revision --autogenerate -m "add mqtt pipeline tables" --rev-id a1b2c3d4e5f6
```
- [ ] **Step 4: Run migration + targeted test**
Run: `cd backend && ../.venv/bin/alembic upgrade head`
Expected: migration applies without errors
Run: `cd backend && ../.venv/bin/pytest tests/mqtt/test_parser_worker.py::test_terminal_message_raw_model_can_persist -v`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add backend/app/models.py backend/app/alembic/versions/*.py backend/tests/mqtt/test_parser_worker.py
git commit -m "feat: add MQTT pipeline database schema"
```
### Task 3: Implement Raw Ingestion Repository + MQTT Ingestor
**Files:**
- Create: `backend/app/mqtt/repository.py`
- Create: `backend/app/mqtt/ingestor.py`
- Test: `backend/tests/mqtt/test_parser_worker.py`
- [ ] **Step 1: Write failing test for raw insert helper**
```python
def test_insert_raw_message_sets_pending_status(db: Session) -> None:
row = insert_raw_message(
session=db,
device_id="device-001",
topic="terminal/device-001/raw",
payload=b"$GPRMC,123520,A,3723.2475,N,12158.3416,W,0.13,309.62,120598,,,A*74\r\n",
qos=1,
retain=False,
)
assert row.parse_status == "pending"
assert row.payload_size > 0
```
- [ ] **Step 2: Run test to verify failure**
Run: `cd backend && ../.venv/bin/pytest tests/mqtt/test_parser_worker.py::test_insert_raw_message_sets_pending_status -v`
Expected: FAIL with `NameError`/missing repository function
- [ ] **Step 3: Implement repository + ingestor callback path**
```python
# backend/app/mqtt/repository.py
def insert_raw_message(*, session: Session, device_id: str, topic: str, payload: bytes, qos: int, retain: bool) -> TerminalMessageRaw:
row = TerminalMessageRaw(
device_id=device_id,
topic=topic,
payload_bytes=payload,
payload_size=len(payload),
payload_sha256=hashlib.sha256(payload).hexdigest(),
qos=qos,
retain=retain,
parse_status="pending",
)
session.add(row)
session.commit()
session.refresh(row)
return row
```
```python
# backend/app/mqtt/ingestor.py (callback shape)
def on_message(client: Client, userdata: object, message: MQTTMessage) -> None:
device_id = extract_device_id(message.topic)
with Session(engine) as session:
insert_raw_message(
session=session,
device_id=device_id,
topic=message.topic,
payload=message.payload,
qos=message.qos,
retain=message.retain,
)
```
- [ ] **Step 4: Run tests for repository path**
Run: `cd backend && ../.venv/bin/pytest tests/mqtt/test_parser_worker.py -k insert_raw_message -v`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add backend/app/mqtt/repository.py backend/app/mqtt/ingestor.py backend/tests/mqtt/test_parser_worker.py
git commit -m "feat: persist MQTT raw messages via ingestor repository"
```
### Task 4: Build NMEA Parser for GGA/RMC/GSV/GSA/GST
**Files:**
- Create: `backend/app/mqtt/parsers/__init__.py`
- Create: `backend/app/mqtt/parsers/nmea.py`
- Test: `backend/tests/mqtt/test_mixed_parser.py`
- [ ] **Step 1: Write failing tests for NMEA extraction**
```python
def test_parse_nmea_sentences_extracts_gga_and_rmc() -> None:
payload = b"$GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*47\r\n$GPRMC,123520,A,4807.038,N,01131.000,E,0.13,309.62,120598,,,A*10\r\n"
events = parse_nmea_sentences(payload)
assert {e["event_type"] for e in events} >= {"nmea_gga", "nmea_rmc"}
```
- [ ] **Step 2: Run test to verify failure**
Run: `cd backend && ../.venv/bin/pytest tests/mqtt/test_mixed_parser.py -k nmea -v`
Expected: FAIL with missing parser function
- [ ] **Step 3: Implement minimal NMEA parser**
```python
# backend/app/mqtt/parsers/nmea.py
def parse_nmea_sentences(payload: bytes) -> list[dict[str, Any]]:
events: list[dict[str, Any]] = []
for raw_line in payload.splitlines():
if not raw_line.startswith(b"$"):
continue
line = raw_line.decode("ascii", errors="ignore")
talker_and_type = line[3:6].lower()
if talker_and_type in {"gga", "rmc", "gsv", "gsa", "gst"}:
events.append({"event_type": f"nmea_{talker_and_type}", "raw": line})
return events
```
- [ ] **Step 4: Run NMEA tests**
Run: `cd backend && ../.venv/bin/pytest tests/mqtt/test_mixed_parser.py -k nmea -v`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add backend/app/mqtt/parsers/__init__.py backend/app/mqtt/parsers/nmea.py backend/tests/mqtt/test_mixed_parser.py
git commit -m "feat: parse NMEA sentences into observation events"
```
### Task 5: Build RTCM Summary + Mixed Payload Scanner
**Files:**
- Create: `backend/app/mqtt/parsers/rtcm.py`
- Create: `backend/app/mqtt/parsers/mixed.py`
- Test: `backend/tests/mqtt/test_mixed_parser.py`
- [ ] **Step 1: Write failing test for RTCM type summary in mixed payload**
```python
def test_parse_mixed_payload_returns_rtcm_summary() -> None:
payload = (
b"$GPGSV,1,1,04,02,17,213,40,05,29,121,37,12,66,314,44,25,14,048,39*71\r\n"
+ b"\xd3\x00\x13"
+ b"\x43\x50\x00"
+ b"\x00" * 16
)
result = parse_mixed_payload(payload)
assert "rtcm_summary" in {e["event_type"] for e in result}
```
- [ ] **Step 2: Run test to verify failure**
Run: `cd backend && ../.venv/bin/pytest tests/mqtt/test_mixed_parser.py -k rtcm -v`
Expected: FAIL with missing mixed parser
- [ ] **Step 3: Implement RTCM scanner and orchestrator**
```python
# backend/app/mqtt/parsers/rtcm.py
def summarize_rtcm_types(payload: bytes) -> dict[int, int]:
counts: dict[int, int] = {}
i = 0
while i + 5 < len(payload):
if payload[i] != 0xD3:
i += 1
continue
length = ((payload[i + 1] & 0x03) << 8) | payload[i + 2]
end = i + 3 + length + 3
if end > len(payload):
break
msg_type = ((payload[i + 3] << 4) | (payload[i + 4] >> 4)) & 0x0FFF
counts[msg_type] = counts.get(msg_type, 0) + 1
i = end
return counts
```
```python
# backend/app/mqtt/parsers/mixed.py
def parse_mixed_payload(payload: bytes) -> list[dict[str, Any]]:
events = parse_nmea_sentences(payload)
rtcm_counts = summarize_rtcm_types(payload)
if rtcm_counts:
events.append({"event_type": "rtcm_summary", "rtcm_types": rtcm_counts})
return events
```
- [ ] **Step 4: Run mixed parser tests**
Run: `cd backend && ../.venv/bin/pytest tests/mqtt/test_mixed_parser.py -v`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add backend/app/mqtt/parsers/rtcm.py backend/app/mqtt/parsers/mixed.py backend/tests/mqtt/test_mixed_parser.py
git commit -m "feat: add RTCM type summary and mixed payload parser"
```
### Task 6: Implement Parser Worker (Pending Queue -> Event/State)
**Files:**
- Create: `backend/app/mqtt/parser_worker.py`
- Modify: `backend/app/mqtt/repository.py`
- Test: `backend/tests/mqtt/test_parser_worker.py`
- [ ] **Step 1: Write failing worker test for status transitions and event writes**
```python
def test_worker_marks_success_and_writes_events(db: Session) -> None:
raw = insert_raw_message(
session=db,
device_id="device-001",
topic="terminal/device-001/raw",
payload=b"$GPGGA,123519,3723.2475,N,12158.3416,W,1,12,0.8,10.0,M,-25.0,M,,*65\r\n",
qos=1,
retain=False,
)
processed = process_pending_batch(session=db, batch_size=10)
assert processed == 1
db.refresh(raw)
assert raw.parse_status == "succeeded"
```
- [ ] **Step 2: Run test to verify failure**
Run: `cd backend && ../.venv/bin/pytest tests/mqtt/test_parser_worker.py -k worker -v`
Expected: FAIL with missing `process_pending_batch`
- [ ] **Step 3: Implement pending fetch with row locking and worker loop**
```python
# repository fetch query shape
select(TerminalMessageRaw).where(
TerminalMessageRaw.parse_status == "pending"
).order_by(TerminalMessageRaw.received_at).with_for_update(
skip_locked=True
).limit(batch_size)
```
```python
# parser_worker.py
def process_pending_batch(*, session: Session, batch_size: int) -> int:
rows = fetch_pending_rows_for_update(session=session, batch_size=batch_size)
for row in rows:
mark_processing(session=session, row=row)
events = parse_mixed_payload(row.payload_bytes)
write_events_and_update_state(session=session, row=row, events=events)
mark_succeeded(session=session, row=row)
return len(rows)
```
- [ ] **Step 4: Run parser worker tests**
Run: `cd backend && ../.venv/bin/pytest tests/mqtt/test_parser_worker.py -v`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add backend/app/mqtt/parser_worker.py backend/app/mqtt/repository.py backend/tests/mqtt/test_parser_worker.py
git commit -m "feat: add async parser worker for pending raw messages"
```
### Task 7: Implement Local Archive Job + 90-Day Hot Retention Cleanup
**Files:**
- Create: `backend/app/mqtt/archive_job.py`
- Test: `backend/tests/mqtt/test_archive_job.py`
- Modify: `backend/app/mqtt/repository.py`
- [ ] **Step 1: Write failing archive test for byte-accurate output and manifest**
```python
def test_archive_writes_raw_bytes_and_manifest(tmp_path: Path, db: Session) -> None:
seed_old_raw_rows(db, days_ago=91, payloads=[b"abc", b"\x01\x02"])
output = run_archive_once(session=db, archive_base_dir=tmp_path)
archived_file = Path(output[0].file_path)
assert archived_file.read_bytes() == b"abc\x01\x02"
```
- [ ] **Step 2: Run test to verify failure**
Run: `cd backend && ../.venv/bin/pytest tests/mqtt/test_archive_job.py -v`
Expected: FAIL with missing archive job implementation
- [ ] **Step 3: Implement archive writer and retention cleanup**
```python
# backend/app/mqtt/archive_job.py
def archive_day(*, session: Session, device_id: str, day_start: datetime, output_path: Path) -> None:
with output_path.open("wb") as f:
rows = fetch_rows_for_archive(session=session, device_id=device_id, day_start=day_start)
for row in rows:
f.write(row.payload_bytes)
write_manifest(session=session, device_id=device_id, day_start=day_start, output_path=output_path, rows=rows)
delete_archived_hot_rows(session=session, archived_rows=rows)
```
- [ ] **Step 4: Run archive tests**
Run: `cd backend && ../.venv/bin/pytest tests/mqtt/test_archive_job.py -v`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add backend/app/mqtt/archive_job.py backend/app/mqtt/repository.py backend/tests/mqtt/test_archive_job.py
git commit -m "feat: add local archive job with hot retention cleanup"
```
### Task 8: Runtime Wiring, Compose Services, and Operational Docs
**Files:**
- Modify: `compose.yml`
- Modify: `compose.override.yml`
- Modify: `.env.production.example`
- Modify: `backend/README.md`
- Modify: `backend/pyproject.toml` (CLI scripts if used)
- [ ] **Step 1: Write failing smoke checklist (documented commands) for ingestor/worker startup**
```text
# backend/README.md verification block should include:
1) start db/backend/mqtt services
2) publish sample payload to terminal/device-001/raw
3) verify raw/event/state rows in postgres
```
- [ ] **Step 2: Run local startup command and capture expected failure before wiring**
Run: `docker compose up -d mqtt-ingestor mqtt-parser-worker`
Expected: FAIL with `no such service` before compose changes
- [ ] **Step 3: Add compose services + env vars + runbook commands**
```yaml
# compose service shape
mqtt-ingestor:
command: ["python", "-m", "app.mqtt.ingestor"]
mqtt-parser-worker:
command: ["python", "-m", "app.mqtt.parser_worker"]
```
- [ ] **Step 4: Run end-to-end verification commands**
Run:
- `cd backend && ../.venv/bin/pytest tests/mqtt -v`
- `cd backend && ../.venv/bin/pytest tests/api/routes -v`
- `cd backend && ../.venv/bin/mypy app`
- `cd backend && ../.venv/bin/ruff check app`
Expected: all PASS
- [ ] **Step 5: Commit**
```bash
git add compose.yml compose.override.yml .env.production.example backend/README.md backend/pyproject.toml
git commit -m "chore: wire mqtt ingestor and parser worker runtime configuration"
```
## Final Verification Gate
- [ ] Run: `cd backend && ../.venv/bin/pytest tests/mqtt tests/api/routes -v`
- [ ] Run: `cd backend && ../.venv/bin/mypy app`
- [ ] Run: `cd backend && ../.venv/bin/ruff check app && ../.venv/bin/ruff format app --check`
- [ ] Run a manual MQTT publish and verify DB rows (`raw`, `event`, `state`) update.
- [ ] Confirm archive output filename format: `device_<id>_<start>_<end>.txt` and content is byte-identical concatenation.
## Implementation Notes
- Keep ingestor lightweight; no heavy parsing inside MQTT callbacks.
- Any parse failure must retain raw row and set retry metadata.
- Do not add metadata into archive download file bytes; only payload concatenation is allowed.
- Use UTC timestamps everywhere for consistent retention and download range slicing.

View File

@@ -0,0 +1,252 @@
# MQTT 原始数据接收与保存设计(双阶段)
## 1. 背景与目标
在现有 `full-stack-fastapi-template` 工程基础上,新增 MQTT 采集能力,用于接收终端上报的 GNSS 数据并可靠保存,支持后续:
- 前端秒级实时展示(设备看板 + 逐条观测流)
- 按天/时间段下载原始数据供外部软件分析
本设计阶段仅实现“接收 + 保存 + 可解析落库链路”,不实现前端页面。
## 2. 已确认约束
- 部署环境:腾讯云,同机已有 EMQX
- Broker 地址:`101.35.119.226:1883`
- 鉴权方式:用户名/密码
- Topic`terminal/{device_id}/raw`
- `device_id`:直接从 topic 中提取
- 上报形态:每 1~10 秒一条消息,每条消息为“完整帧/完整语句集合”
- 负载内容:可能为 NMEA only、RTCM3.2 only 或二者混合
- NMEA 类型GGA / RMC / GSV / GSA / GST
- RTCM本期仅做“消息类型级别摘要”
- 存储策略90 天热数据 + 本地冷归档
- 下载要求:下载内容必须是原始 MQTT payload 字节流,不附加元信息
- 下载文件后缀:`.txt`(内容可包含二进制字节)
## 3. 架构方案与结论
### 3.1 备选方案
1. FastAPI 内嵌 MQTT 客户端
2. DB 队列双阶段(独立 ingestor + parser worker
3. 外部消息队列三阶段
### 3.2 选型结论
采用 **方案 2DB 队列双阶段**
理由:
- 避免 FastAPI 多 worker 下重复订阅/重复入库风险
- 接收链路与解析链路解耦,稳定性更高
- 原始数据先落库,可重试、可回放、可追溯
- 为后续实时展示和下载提供稳定基础
## 4. 组件职责
### 4.1 mqtt-ingestor采集层
- 常驻独立进程,连接 EMQX 并订阅 `terminal/+/raw`
- 回调中仅执行轻量逻辑:
- 提取 `device_id`
- 记录 `topic/qos/retain/received_at`
- 原样保存 `payload_bytes`
- 计算 `payload_sha256``payload_size`
- 插入 `terminal_message_raw``parse_status='pending'`
- 不执行 NMEA/RTCM 深解析
### 4.2 parser-worker解析层
- 轮询 `terminal_message_raw` 中待解析记录
- 解析 NMEA + RTCM3.2 摘要
- 写入事件表 `terminal_observation_event`
- Upsert 最新状态表 `terminal_device_state`
- 失败可重试,错误可观测,不丢原始数据
### 4.3 backend API服务层
- 本期不新增前端页面
- 后续基于 `event + state + archive manifest` 增加秒级展示与下载接口
## 5. 数据模型设计
### 5.1 terminal_message_raw热数据原始表
用途:保存原始消息,作为解析队列与下载来源。
建议字段:
- `id` (uuid, pk)
- `device_id` (text, index)
- `topic` (text)
- `received_at` (timestamptz, index)
- `payload_bytes` (bytea)
- `payload_size` (int)
- `payload_sha256` (char(64), index)
- `qos` (smallint)
- `retain` (bool)
- `parse_status` (`pending|processing|succeeded|failed`, index)
- `parse_attempts` (int, default 0)
- `next_retry_at` (timestamptz, nullable, index)
- `parse_error` (text, nullable)
- `created_at` (timestamptz)
### 5.2 terminal_observation_event解析事件流
用途:支撑逐条观测流展示与历史分析。
建议字段:
- `id` (uuid, pk)
- `raw_id` (uuid, fk -> terminal_message_raw.id, index)
- `device_id` (text, index)
- `event_time` (timestamptz, index)
- `event_type` (text)
示例:`nmea_gga` / `nmea_rmc` / `nmea_gsv` / `nmea_gsa` / `nmea_gst` / `rtcm_summary`
- `payload_json` (jsonb)
- `created_at` (timestamptz)
### 5.3 terminal_device_state设备最新状态
用途:支撑前端设备看板秒级刷新。
建议字段:
- `device_id` (text, pk)
- `last_event_time` (timestamptz)
- `fix_status` (text)
- `lat` / `lon` (double precision)
- `alt_m` (double precision)
- `satellites_used` (int)
- `satellite_signal_summary` (jsonb)
- `rtcm_type_summary` (jsonb)
- `updated_at` (timestamptz)
### 5.4 terminal_raw_archive_manifest冷归档索引
用途:记录本地归档文件与时间范围映射。
建议字段:
- `id` (uuid, pk)
- `device_id` (text, index)
- `range_start` / `range_end` (timestamptz, index)
- `file_path` (text, unique)
- `file_size` (bigint)
- `sha256` (char(64))
- `record_count` (bigint)
- `created_at` (timestamptz)
## 6. 双阶段流水线细节
### 6.1 入站ingestor
- MQTT 建议参数:`clean_session=False`、固定 `client_id`、QoS=1
- 批量入库建议:每 100 条或 200ms flush 一次事务
- 目标:尽快持久化原始字节,避免解析阻塞接收
### 6.2 解析parser-worker
批处理取数:
- 条件:`parse_status='pending'``next_retry_at` 到期
- 顺序:`ORDER BY received_at`
- 锁:`FOR UPDATE SKIP LOCKED`
- 批量:`LIMIT 100~500`
处理规则:
- 成功:`parse_status='succeeded'`
- 失败:`parse_status='failed'` + `parse_attempts+1` + `parse_error`
- 重试:退避后设置 `next_retry_at`
幂等建议:
- 事件表增加防重策略(如 `raw_id + event_type + event_time` 约束或哈希去重)
- `terminal_device_state` 用 upsert仅更新更晚 `event_time`
## 7. 解析策略(本期)
### 7.1 NMEA
- 解析 GGA/RMC/GSV/GSA/GST
- 每条语句产出一条 `terminal_observation_event`
### 7.2 RTCM3.2
- 仅提取消息类型摘要(如 1005/1077/1087
- 每条 raw payload 产出一条 `rtcm_summary` 事件,包含类型计数与最近出现时间
### 7.3 混合负载
- 单 payload 中可同时包含 NMEA 与 RTCM 片段
- 解析器按字节扫描拆分,分别产出事件
- 部分片段解析失败不影响其余片段;原始 payload 永远保留
## 8. 热数据与本地冷归档
### 8.1 热数据
- `terminal_message_raw` 保留 90 天
### 8.2 本地冷归档
- 定时任务离峰执行
- 归档粒度:`device_id + 日`(必要时可按小时分片)
- 路径示例:`/data/archive/gnss/<device_id>/YYYY-MM-DD/`
- 文件内容:仅原始 payload 字节流拼接(无额外元信息)
- 文件名示例:`device_<id>_<start>_<end>.txt`
### 8.3 校验与清理
- 写入 `manifest`时间范围、条数、sha256、文件路径
- 校验通过后,清理超过 90 天热数据
## 9. 下载行为约束(后续接口实现遵循)
- 下载内容必须与原始 MQTT payload 字节一致
- 近 90 天:从热表按时间顺序拼接输出
- 超过 90 天:从本地归档读取输出
- 跨热/冷边界:按时间顺序拼接两段数据
## 10. 运维与可观测性
必须监控:
- ingestor连接状态、入站速率、入库失败
- parserpending backlog、解析成功率、平均延迟、失败重试
- archive每日归档成功/失败、磁盘余量
建议告警:
- `pending` 队列持续增长
- parser 失败率超阈值
- 归档目录磁盘不足
## 11. 配置项建议(.env
- `MQTT_HOST=101.35.119.226`
- `MQTT_PORT=1883`
- `MQTT_USERNAME=...`
- `MQTT_PASSWORD=...`
- `MQTT_TOPIC_PATTERN=terminal/+/raw`
- `MQTT_CLIENT_ID=spatialhub-mqtt-ingestor`
- `RAW_HOT_RETENTION_DAYS=90`
- `RAW_ARCHIVE_BASE_DIR=/data/archive/gnss`
- `PARSER_BATCH_SIZE=200`
- `PARSER_POLL_INTERVAL_MS=300`
## 12. 风险与后续演进
- 使用 `.txt` 扩展名存放二进制流可能被文本工具误打开;但不影响下游软件按字节读取
- 当设备量增长时,可水平扩展 parser-worker依赖 `SKIP LOCKED`
- 后续实时能力:新增 SSE/WebSocket 推送层,数据源直接复用 `event/state`
## 13. 验收标准(本期)
- 能稳定接收 `terminal/{device_id}/raw` 消息并原样入库
- 混合 payload 可提取 NMEA 事件和 RTCM 类型摘要
- 设备最新状态可持续更新
- 支持 90 天热数据保留与本地冷归档落盘
- 能按时间段导出原始数据(文件后缀 `.txt`,内容无附加字段)

View File

@@ -1,10 +1,9 @@
# Stage 0, "build-stage", based on Bun, to build and compile the frontend
FROM oven/bun:1 AS build-stage
# Shared Bun stage for frontend dependencies and sources
FROM oven/bun:1 AS bun-base
WORKDIR /app
COPY package.json bun.lock /app/
COPY frontend/package.json /app/frontend/
WORKDIR /app/frontend
@@ -14,10 +13,22 @@ RUN bun install
COPY ./frontend /app/frontend
ARG VITE_API_URL
# Development stage for docker compose watch + Vite HMR
FROM bun-base AS dev-stage
EXPOSE 80
CMD ["bun", "run", "dev", "--host", "0.0.0.0", "--port", "80"]
# Build stage for production assets
FROM bun-base AS build-stage
RUN bun run build
# Stage 1, based on Nginx, to have only the compiled app, ready for production with Nginx
# Production stage serving compiled assets with Nginx
FROM nginx:1
COPY --from=build-stage /app/frontend/dist/ /usr/share/nginx/html

View File

@@ -2,10 +2,9 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Full Stack FastAPI Project</title>
<link rel="icon" type="image/x-icon" href="/assets/images/favicon.png" />
<title>SpatialHub</title>
<link rel="icon" type="image/svg+xml" href="/assets/images/spatialhub-icon.svg" />
</head>
<body>
<div id="root"></div>

View File

@@ -23,6 +23,7 @@
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
@@ -32,10 +33,13 @@
"@tanstack/react-router": "^1.163.3",
"@tanstack/react-router-devtools": "^1.163.3",
"@tanstack/react-table": "^8.21.3",
"@types/leaflet.markercluster": "^1.5.6",
"axios": "1.13.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"form-data": "4.0.5",
"leaflet": "^1.9.4",
"leaflet.markercluster": "^1.5.3",
"lucide-react": "^0.563.0",
"next-themes": "^0.4.6",
"react": "^19.1.1",
@@ -43,6 +47,8 @@
"react-error-boundary": "^6.0.0",
"react-hook-form": "^7.68.0",
"react-icons": "^5.5.0",
"react-leaflet": "^5.0.0",
"react-leaflet-cluster": "^4.0.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.2.0",
@@ -54,6 +60,7 @@
"@playwright/test": "1.58.2",
"@tanstack/router-devtools": "^1.159.10",
"@tanstack/router-plugin": "^1.140.0",
"@types/leaflet": "^1.9.21",
"@types/node": "^25.3.2",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",

View File

@@ -1,77 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="500"
height="500"
viewBox="0 0 132.29167 132.29166"
version="1.1"
id="svg8"
sodipodi:docname="icon-white-nomargin-transparent.svg"
inkscape:version="1.3.2 (091e20e, 2023-11-25)"
inkscape:export-filename="icon-teal-nomargin-transparent-500.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1720"
inkscape:window-height="1371"
id="namedview10"
showgrid="false"
inkscape:zoom="0.73657628"
inkscape:cx="448.01877"
inkscape:cy="-91.640203"
inkscape:window-x="1720"
inkscape:window-y="32"
inkscape:window-maximized="0"
inkscape:current-layer="g1"
inkscape:showpageshadow="2"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm" />
<defs
id="defs2" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<g
id="g2149">
<g
id="g2141">
<g
id="g1">
<path
id="path875-5-9-7-3-2-3-9-9-8-0-0-5-87-7"
style="fill:#ffffff;fill-opacity:0.980392;stroke:none;stroke-width:0.28098;stop-color:#000000"
d="M 66.145833 0 A 66.145836 65.931923 0 0 0 0 65.931893 A 66.145836 65.931923 0 0 0 66.145833 131.86379 A 66.145836 65.931923 0 0 0 132.29167 65.931893 A 66.145836 65.931923 0 0 0 66.145833 0 z M 61.581771 29.853992 L 103.20042 29.853992 L 61.410205 59.222742 L 89.976937 59.222742 L 29.091248 102.00979 L 42.315763 72.641044 L 48.358289 59.222742 L 61.581771 29.853992 z " />
</g>
</g>
</g>
<rect
y="-49.422424"
x="-51.908718"
height="162.82199"
width="451.52316"
id="rect824"
style="opacity:0.98;fill:none;fill-opacity:1;stroke-width:0.311037" />
</svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -1,77 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="500"
height="500"
viewBox="0 0 132.29167 132.29166"
version="1.1"
id="svg8"
sodipodi:docname="icon-teal-nomargin-transparent.svg"
inkscape:version="1.3.2 (091e20e, 2023-11-25)"
inkscape:export-filename="icon-teal-nomargin-192.png"
inkscape:export-xdpi="36.863998"
inkscape:export-ydpi="36.863998"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1720"
inkscape:window-height="1371"
id="namedview10"
showgrid="false"
inkscape:zoom="0.73657628"
inkscape:cx="448.01877"
inkscape:cy="-91.640203"
inkscape:window-x="1720"
inkscape:window-y="32"
inkscape:window-maximized="0"
inkscape:current-layer="g1"
inkscape:showpageshadow="2"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm" />
<defs
id="defs2" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<g
id="g2149">
<g
id="g2141">
<g
id="g1">
<path
id="path875-5-9-7-3-2-3-9-9-8-0-0-5-87-7"
style="fill:#009688;fill-opacity:0.980392;stroke:none;stroke-width:0.28098;stop-color:#000000"
d="M 66.145833 0 A 66.145836 65.931923 0 0 0 0 65.931893 A 66.145836 65.931923 0 0 0 66.145833 131.86379 A 66.145836 65.931923 0 0 0 132.29167 65.931893 A 66.145836 65.931923 0 0 0 66.145833 0 z M 61.581771 29.853992 L 103.20042 29.853992 L 61.410205 59.222742 L 89.976937 59.222742 L 29.091248 102.00979 L 42.315763 72.641044 L 48.358289 59.222742 L 61.581771 29.853992 z " />
</g>
</g>
</g>
<rect
y="-49.422424"
x="-51.908718"
height="162.82199"
width="451.52316"
id="rect824"
style="opacity:0.98;fill:none;fill-opacity:1;stroke-width:0.311037" />
</svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -1,83 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="341.26324mm"
height="63.977489mm"
viewBox="0 0 341.26324 63.977485"
version="1.1"
id="svg8"
sodipodi:docname="logo-white-nomargin-vector.svg"
inkscape:version="1.3.2 (091e20e, 2023-11-25)"
inkscape:export-filename="logo-white-margin-vector.png"
inkscape:export-xdpi="57.604134"
inkscape:export-ydpi="57.604134"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1720"
inkscape:window-height="1371"
id="namedview10"
showgrid="false"
inkscape:zoom="0.73657628"
inkscape:cx="644.8755"
inkscape:cy="95.713101"
inkscape:window-x="1720"
inkscape:window-y="32"
inkscape:window-maximized="0"
inkscape:current-layer="g2141"
inkscape:showpageshadow="2"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm" />
<defs
id="defs2" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<g
id="g2149"
transform="translate(2.5752783e-4,1.1668595e-4)">
<g
id="g2141">
<g
id="g1">
<path
id="path875-5-9-7-3-2-3-9-9-8-0-0-5-87-7"
style="fill:#ffffff;fill-opacity:0.980392;stroke:none;stroke-width:0.136325;stop-color:#000000"
d="M 32.092357,-1.166849e-4 A 32.092354,31.988569 0 0 0 -2.5752734e-4,31.988628 32.092354,31.988569 0 0 0 32.092357,63.977374 32.092354,31.988569 0 0 0 64.184455,31.988628 32.092354,31.988569 0 0 0 32.092357,-1.166849e-4 Z M 29.878022,14.484271 H 50.070588 L 29.794823,28.73353 H 43.654442 L 14.114126,49.49247 20.530789,35.243727 23.462393,28.73353 Z" />
<path
style="font-size:79.7151px;line-height:1.25;font-family:Ubuntu;-inkscape-font-specification:Ubuntu;letter-spacing:0px;word-spacing:0px;fill:#ffffff;stroke-width:1.99288"
d="M 89.762163,59.410606 V 4.1680399 H 121.88735 V 9.5089518 H 95.979941 V 28.082571 h 22.957949 v 5.340912 H 95.979941 v 25.987123 z m 51.017587,0.876867 q -4.46405,0 -7.97152,-1.275442 -3.50746,-1.275442 -5.50034,-4.145185 -1.99288,-2.869744 -1.99288,-7.572935 0,-4.543761 2.23203,-7.33379 2.23202,-2.869743 6.13806,-4.145185 3.98576,-1.275442 8.92809,-1.275442 2.23203,0 4.70319,0.398576 2.47117,0.398575 3.10889,0.717436 v -2.391453 q 0,-2.710314 -0.71743,-5.181482 -0.63772,-2.550883 -2.71032,-4.145186 -2.07259,-1.594302 -6.29749,-1.594302 -4.38433,0 -6.69607,0.637721 -2.31174,0.637721 -3.42775,1.036297 l -0.79715,-5.101767 q 1.43487,-0.637721 4.38433,-1.195727 2.94946,-0.558005 6.93522,-0.558005 5.65977,0 8.92809,1.992877 3.34803,1.992878 4.7829,5.500342 1.51459,3.42775 1.51459,7.891796 v 25.907408 q -1.67402,0.398576 -5.97863,1.116012 -4.30462,0.717436 -9.56581,0.717436 z m 0.87686,-5.101767 q 2.79003,0 5.02205,-0.15943 2.23203,-0.239146 3.74661,-0.558006 V 40.597842 q -0.79715,-0.398575 -2.71031,-0.797151 -1.91316,-0.398576 -4.94234,-0.398576 -2.55088,0 -5.18148,0.558006 -2.6306,0.558006 -4.46404,2.232023 -1.75374,1.674017 -1.75374,5.022052 0,4.464045 2.79003,6.217778 2.79003,1.753732 7.49322,1.753732 z m 36.4298,5.181482 q -5.34092,0 -8.37009,-0.956582 -2.94946,-0.876866 -3.98575,-1.355156 l 1.43487,-5.261197 q 0.87686,0.31886 3.58718,1.355157 2.71031,1.036296 7.33379,1.036296 4.38433,0 7.09464,-1.355157 2.71031,-1.355157 2.71031,-4.703191 0,-2.152308 -0.95658,-3.427749 -0.95658,-1.355157 -3.10889,-2.471169 -2.07259,-1.116011 -5.73948,-2.550883 -3.10889,-1.275442 -5.73949,-2.710313 -2.6306,-1.434872 -4.30462,-3.666895 -1.5943,-2.232023 -1.5943,-5.739488 0,-3.427749 1.75373,-5.978632 1.75374,-2.550884 4.94234,-3.985755 3.26832,-1.434872 7.73237,-1.434872 4.14518,0 7.01492,0.717436 2.86975,0.717436 4.06547,1.275441 l -1.35515,5.181482 q -1.0363,-0.558006 -3.34804,-1.275442 -2.31173,-0.797151 -6.53663,-0.797151 -3.34804,0 -5.81921,1.434872 -2.47117,1.355157 -2.47117,4.384331 0,2.152308 1.0363,3.507464 1.0363,1.275442 3.10889,2.311738 2.07259,1.036297 5.10177,2.232023 3.42775,1.355157 6.13806,2.869744 2.79003,1.434872 4.46405,3.74661 1.67401,2.311738 1.67401,6.138063 0,3.74661 -1.91316,6.377208 -1.91316,2.550883 -5.50034,3.826325 -3.50747,1.275442 -8.4498,1.275442 z m 39.21967,-0.07972 q -5.2612,0 -8.29037,-1.833448 -3.02917,-1.833447 -4.30461,-5.500342 -1.19573,-3.666895 -1.19573,-9.167237 V 6.1609175 L 209.494,5.1246211 V 18.118183 h 16.18217 v 5.022051 H 209.494 v 21.124503 q 0,4.38433 0.95658,6.696068 1.0363,2.311738 2.86975,3.188605 1.91316,0.797151 4.46404,0.797151 3.02918,0 5.02205,-0.717436 2.0726,-0.717436 3.26832,-1.275442 l 1.27544,4.862621 q -1.19572,0.717436 -3.98575,1.594302 -2.71031,0.876867 -6.05835,0.876867 z m 11.95723,-0.876867 q 4.30462,-11.55869 7.8918,-21.044787 3.58718,-9.486097 7.01493,-17.776468 3.50746,-8.370086 7.4135,-16.4213111 h 5.58006 q 2.86974,6.0583481 5.50034,12.2761261 2.71032,6.138063 5.34091,12.754416 2.6306,6.616354 5.42063,14.109574 2.86975,7.413504 6.05835,16.10245 h -6.77578 q -1.43488,-3.985755 -2.79003,-7.572934 -1.35516,-3.666895 -2.55089,-7.17436 h -26.30598 q -1.27544,3.507465 -2.6306,7.17436 -1.35516,3.587179 -2.71031,7.572934 z M 242.8946,39.402115 h 22.63909 q -1.51459,-3.985755 -2.94946,-7.732365 -1.43487,-3.746609 -2.86975,-7.254074 -1.35515,-3.507464 -2.79002,-6.775784 -1.35516,-3.348034 -2.79003,-6.536638 -1.35516,3.188604 -2.79003,6.536638 -1.35516,3.26832 -2.79003,6.775784 -1.35516,3.507465 -2.79003,7.254074 -1.43487,3.74661 -2.86974,7.732365 z m 44.481,20.008491 V 5.2043362 q 3.02917,-0.797151 6.93521,-1.1160114 3.98576,-0.3985755 7.33379,-0.3985755 11.71812,0 17.53732,4.5437609 5.89892,4.4640458 5.89892,12.7544168 0,6.297493 -2.94946,10.123818 -2.86974,3.826325 -8.37008,5.580057 -5.42063,1.674017 -13.07328,1.674017 h -7.09465 v 21.044787 z m 6.21777,-26.385699 h 6.53664 q 5.65978,0 9.80496,-0.956581 4.14519,-1.036296 6.37721,-3.666895 2.31174,-2.630598 2.31174,-7.493219 0,-4.703192 -2.39146,-7.254075 -2.39145,-2.550883 -6.21777,-3.587179 -3.82633,-1.0362968 -8.13094,-1.0362968 -2.79003,0 -4.86263,0.2391453 -1.99287,0.1594302 -3.42775,0.3188604 z M 335.0452,59.410606 V 4.1680399 h 6.21778 V 59.410606 Z"
id="text979-3"
aria-label="FastAPI" />
</g>
</g>
</g>
<rect
y="-49.422306"
x="-51.908459"
height="162.82199"
width="451.52316"
id="rect824"
style="opacity:0.98;fill:none;fill-opacity:1;stroke-width:0.311037" />
</svg>

Before

Width:  |  Height:  |  Size: 7.0 KiB

View File

@@ -1,91 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="341.26297mm"
height="63.977139mm"
viewBox="0 0 341.26297 63.977134"
version="1.1"
id="svg8"
sodipodi:docname="logo-teal-nomargin-vector.svg"
inkscape:version="1.3.2 (091e20e, 2023-11-25)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1720"
inkscape:window-height="1371"
id="namedview10"
showgrid="false"
inkscape:zoom="0.73657628"
inkscape:cx="448.01877"
inkscape:cy="-91.640203"
inkscape:window-x="1720"
inkscape:window-y="32"
inkscape:window-maximized="0"
inkscape:current-layer="g1"
inkscape:showpageshadow="2"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm" />
<defs
id="defs2" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<g
id="g2149">
<g
id="g2141">
<g
id="g1">
<g
id="g2106"
transform="matrix(0.96564264,0,0,0.96251987,-899.3295,194.86874)">
<circle
style="fill:#009688;fill-opacity:0.980392;stroke:none;stroke-width:0.141404;stop-color:#000000"
id="path875-5-9-7-3-2-3-9-9-8-0-0-5-87-7"
cx="964.56165"
cy="-169.22266"
r="33.234192"
inkscape:export-xdpi="1543.8315"
inkscape:export-ydpi="1543.8315" />
<path
id="rect1249-6-3-4-4-3-6-6-1-2"
style="fill:#ffffff;fill-opacity:0.980392;stroke:none;stroke-width:0.146895;stop-color:#000000"
d="m 962.2685,-187.40837 -6.64403,14.80375 -3.03599,6.76393 -6.64456,14.80375 30.59142,-21.56768 h -14.35312 l 20.99715,-14.80375 z" />
</g>
<path
style="font-size:79.7151px;line-height:1.25;font-family:Ubuntu;-inkscape-font-specification:Ubuntu;letter-spacing:0px;word-spacing:0px;fill:#009688;stroke-width:1.99288"
d="M 89.762163,59.410606 V 4.1680399 H 121.88735 V 9.5089518 H 95.979941 V 28.082571 h 22.957949 v 5.340912 H 95.979941 v 25.987123 z m 51.017587,0.876867 q -4.46405,0 -7.97152,-1.275442 -3.50746,-1.275442 -5.50034,-4.145185 -1.99288,-2.869744 -1.99288,-7.572935 0,-4.543761 2.23203,-7.33379 2.23202,-2.869743 6.13806,-4.145185 3.98576,-1.275442 8.92809,-1.275442 2.23203,0 4.70319,0.398576 2.47117,0.398575 3.10889,0.717436 v -2.391453 q 0,-2.710314 -0.71743,-5.181482 -0.63772,-2.550883 -2.71032,-4.145186 -2.07259,-1.594302 -6.29749,-1.594302 -4.38433,0 -6.69607,0.637721 -2.31174,0.637721 -3.42775,1.036297 l -0.79715,-5.101767 q 1.43487,-0.637721 4.38433,-1.195727 2.94946,-0.558005 6.93522,-0.558005 5.65977,0 8.92809,1.992877 3.34803,1.992878 4.7829,5.500342 1.51459,3.42775 1.51459,7.891796 v 25.907408 q -1.67402,0.398576 -5.97863,1.116012 -4.30462,0.717436 -9.56581,0.717436 z m 0.87686,-5.101767 q 2.79003,0 5.02205,-0.15943 2.23203,-0.239146 3.74661,-0.558006 V 40.597842 q -0.79715,-0.398575 -2.71031,-0.797151 -1.91316,-0.398576 -4.94234,-0.398576 -2.55088,0 -5.18148,0.558006 -2.6306,0.558006 -4.46404,2.232023 -1.75374,1.674017 -1.75374,5.022052 0,4.464045 2.79003,6.217778 2.79003,1.753732 7.49322,1.753732 z m 36.4298,5.181482 q -5.34092,0 -8.37009,-0.956582 -2.94946,-0.876866 -3.98575,-1.355156 l 1.43487,-5.261197 q 0.87686,0.31886 3.58718,1.355157 2.71031,1.036296 7.33379,1.036296 4.38433,0 7.09464,-1.355157 2.71031,-1.355157 2.71031,-4.703191 0,-2.152308 -0.95658,-3.427749 -0.95658,-1.355157 -3.10889,-2.471169 -2.07259,-1.116011 -5.73948,-2.550883 -3.10889,-1.275442 -5.73949,-2.710313 -2.6306,-1.434872 -4.30462,-3.666895 -1.5943,-2.232023 -1.5943,-5.739488 0,-3.427749 1.75373,-5.978632 1.75374,-2.550884 4.94234,-3.985755 3.26832,-1.434872 7.73237,-1.434872 4.14518,0 7.01492,0.717436 2.86975,0.717436 4.06547,1.275441 l -1.35515,5.181482 q -1.0363,-0.558006 -3.34804,-1.275442 -2.31173,-0.797151 -6.53663,-0.797151 -3.34804,0 -5.81921,1.434872 -2.47117,1.355157 -2.47117,4.384331 0,2.152308 1.0363,3.507464 1.0363,1.275442 3.10889,2.311738 2.07259,1.036297 5.10177,2.232023 3.42775,1.355157 6.13806,2.869744 2.79003,1.434872 4.46405,3.74661 1.67401,2.311738 1.67401,6.138063 0,3.74661 -1.91316,6.377208 -1.91316,2.550883 -5.50034,3.826325 -3.50747,1.275442 -8.4498,1.275442 z m 39.21967,-0.07972 q -5.2612,0 -8.29037,-1.833448 -3.02917,-1.833447 -4.30461,-5.500342 -1.19573,-3.666895 -1.19573,-9.167237 V 6.1609175 L 209.494,5.1246211 V 18.118183 h 16.18217 v 5.022051 H 209.494 v 21.124503 q 0,4.38433 0.95658,6.696068 1.0363,2.311738 2.86975,3.188605 1.91316,0.797151 4.46404,0.797151 3.02918,0 5.02205,-0.717436 2.0726,-0.717436 3.26832,-1.275442 l 1.27544,4.862621 q -1.19572,0.717436 -3.98575,1.594302 -2.71031,0.876867 -6.05835,0.876867 z m 11.95723,-0.876867 q 4.30462,-11.55869 7.8918,-21.044787 3.58718,-9.486097 7.01493,-17.776468 3.50746,-8.370086 7.4135,-16.4213111 h 5.58006 q 2.86974,6.0583481 5.50034,12.2761261 2.71032,6.138063 5.34091,12.754416 2.6306,6.616354 5.42063,14.109574 2.86975,7.413504 6.05835,16.10245 h -6.77578 q -1.43488,-3.985755 -2.79003,-7.572934 -1.35516,-3.666895 -2.55089,-7.17436 h -26.30598 q -1.27544,3.507465 -2.6306,7.17436 -1.35516,3.587179 -2.71031,7.572934 z M 242.8946,39.402115 h 22.63909 q -1.51459,-3.985755 -2.94946,-7.732365 -1.43487,-3.746609 -2.86975,-7.254074 -1.35515,-3.507464 -2.79002,-6.775784 -1.35516,-3.348034 -2.79003,-6.536638 -1.35516,3.188604 -2.79003,6.536638 -1.35516,3.26832 -2.79003,6.775784 -1.35516,3.507465 -2.79003,7.254074 -1.43487,3.74661 -2.86974,7.732365 z m 44.481,20.008491 V 5.2043362 q 3.02917,-0.797151 6.93521,-1.1160114 3.98576,-0.3985755 7.33379,-0.3985755 11.71812,0 17.53732,4.5437609 5.89892,4.4640458 5.89892,12.7544168 0,6.297493 -2.94946,10.123818 -2.86974,3.826325 -8.37008,5.580057 -5.42063,1.674017 -13.07328,1.674017 h -7.09465 v 21.044787 z m 6.21777,-26.385699 h 6.53664 q 5.65978,0 9.80496,-0.956581 4.14519,-1.036296 6.37721,-3.666895 2.31174,-2.630598 2.31174,-7.493219 0,-4.703192 -2.39146,-7.254075 -2.39145,-2.550883 -6.21777,-3.587179 -3.82633,-1.0362968 -8.13094,-1.0362968 -2.79003,0 -4.86263,0.2391453 -1.99287,0.1594302 -3.42775,0.3188604 z M 335.0452,59.410606 V 4.1680399 h 6.21778 V 59.410606 Z"
id="text979-3"
aria-label="FastAPI" />
</g>
</g>
</g>
<rect
y="-49.422424"
x="-51.908718"
height="162.82199"
width="451.52316"
id="rect824"
style="opacity:0.98;fill:none;fill-opacity:1;stroke-width:0.311037" />
</svg>

Before

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

View File

@@ -0,0 +1,8 @@
<svg viewBox="0 0 80 80" xmlns="http://www.w3.org/2000/svg">
<circle cx="40" cy="40" r="35" fill="#009688" />
<g fill="#ffffff">
<path fill-rule="evenodd" clip-rule="evenodd" d="M 40 18 C 30 18 22 26 22 36 C 22 48 40 63 40 63 C 40 63 58 48 58 36 C 58 26 50 18 40 18 Z M 40 43 C 36.134 43 33 39.866 33 36 C 33 32.134 36.134 29 40 29 C 43.866 29 47 32.134 47 36 C 47 39.866 43.866 43 40 43 Z" />
<ellipse cx="40" cy="40" rx="27" ry="10" fill="none" stroke="#ffffff" stroke-width="2.5" transform="rotate(-25 40 40)" />
<circle cx="15.5" cy="51.5" r="3.5" fill="#ffffff" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 599 B

View File

@@ -0,0 +1,8 @@
<svg viewBox="0 0 80 80" xmlns="http://www.w3.org/2000/svg">
<circle cx="40" cy="40" r="35" fill="#009688" />
<g fill="#ffffff">
<path fill-rule="evenodd" clip-rule="evenodd" d="M 40 18 C 30 18 22 26 22 36 C 22 48 40 63 40 63 C 40 63 58 48 58 36 C 58 26 50 18 40 18 Z M 40 43 C 36.134 43 33 39.866 33 36 C 33 32.134 36.134 29 40 29 C 43.866 29 47 32.134 47 36 C 47 39.866 43.866 43 40 43 Z" />
<ellipse cx="40" cy="40" rx="27" ry="10" fill="none" stroke="#ffffff" stroke-width="2.5" transform="rotate(-25 40 40)" />
<circle cx="15.5" cy="51.5" r="3.5" fill="#ffffff" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 599 B

View File

@@ -0,0 +1,17 @@
<svg viewBox="0 0 450 80" xmlns="http://www.w3.org/2000/svg">
<style>
.text {
font-family: 'Ubuntu', 'Segoe UI', system-ui, sans-serif;
font-size: 58px;
font-weight: 600;
fill: #ffffff;
}
</style>
<circle cx="40" cy="40" r="35" fill="#009688" />
<g fill="#ffffff">
<path fill-rule="evenodd" clip-rule="evenodd" d="M 40 18 C 30 18 22 26 22 36 C 22 48 40 63 40 63 C 40 63 58 48 58 36 C 58 26 50 18 40 18 Z M 40 43 C 36.134 43 33 39.866 33 36 C 33 32.134 36.134 29 40 29 C 43.866 29 47 32.134 47 36 C 47 39.866 43.866 43 40 43 Z" />
<ellipse cx="40" cy="40" rx="27" ry="10" fill="none" stroke="#ffffff" stroke-width="2.5" transform="rotate(-25 40 40)" />
<circle cx="15.5" cy="51.5" r="3.5" fill="#ffffff" />
</g>
<text x="85" y="59" class="text">SpatialHub</text>
</svg>

After

Width:  |  Height:  |  Size: 824 B

View File

@@ -0,0 +1,17 @@
<svg viewBox="0 0 450 80" xmlns="http://www.w3.org/2000/svg">
<style>
.text {
font-family: 'Ubuntu', 'Segoe UI', system-ui, sans-serif;
font-size: 58px;
font-weight: 600;
fill: #009688;
}
</style>
<circle cx="40" cy="40" r="35" fill="#009688" />
<g fill="#ffffff">
<path fill-rule="evenodd" clip-rule="evenodd" d="M 40 18 C 30 18 22 26 22 36 C 22 48 40 63 40 63 C 40 63 58 48 58 36 C 58 26 50 18 40 18 Z M 40 43 C 36.134 43 33 39.866 33 36 C 33 32.134 36.134 29 40 29 C 43.866 29 47 32.134 47 36 C 47 39.866 43.866 43 40 43 Z" />
<ellipse cx="40" cy="40" rx="27" ry="10" fill="none" stroke="#ffffff" stroke-width="2.5" transform="rotate(-25 40 40)" />
<circle cx="15.5" cy="51.5" r="3.5" fill="#ffffff" />
</g>
<text x="85" y="59" class="text">SpatialHub</text>
</svg>

After

Width:  |  Height:  |  Size: 824 B

View File

@@ -57,6 +57,22 @@ export const Body_login_login_access_tokenSchema = {
title: 'Body_login-login_access_token'
} as const;
export const DeleteResultSchema = {
properties: {
deleted_count: {
type: 'integer',
title: 'Deleted Count'
},
message: {
type: 'string',
title: 'Message'
}
},
type: 'object',
required: ['deleted_count', 'message'],
title: 'DeleteResult'
} as const;
export const HTTPValidationErrorSchema = {
properties: {
detail: {
@@ -71,131 +87,6 @@ export const HTTPValidationErrorSchema = {
title: 'HTTPValidationError'
} as const;
export const ItemCreateSchema = {
properties: {
title: {
type: 'string',
maxLength: 255,
minLength: 1,
title: 'Title'
},
description: {
anyOf: [
{
type: 'string',
maxLength: 255
},
{
type: 'null'
}
],
title: 'Description'
}
},
type: 'object',
required: ['title'],
title: 'ItemCreate'
} as const;
export const ItemPublicSchema = {
properties: {
title: {
type: 'string',
maxLength: 255,
minLength: 1,
title: 'Title'
},
description: {
anyOf: [
{
type: 'string',
maxLength: 255
},
{
type: 'null'
}
],
title: 'Description'
},
id: {
type: 'string',
format: 'uuid',
title: 'Id'
},
owner_id: {
type: 'string',
format: 'uuid',
title: 'Owner Id'
},
created_at: {
anyOf: [
{
type: 'string',
format: 'date-time'
},
{
type: 'null'
}
],
title: 'Created At'
}
},
type: 'object',
required: ['title', 'id', 'owner_id'],
title: 'ItemPublic'
} as const;
export const ItemUpdateSchema = {
properties: {
title: {
anyOf: [
{
type: 'string',
maxLength: 255,
minLength: 1
},
{
type: 'null'
}
],
title: 'Title'
},
description: {
anyOf: [
{
type: 'string',
maxLength: 255
},
{
type: 'null'
}
],
title: 'Description'
}
},
type: 'object',
title: 'ItemUpdate'
} as const;
export const ItemsPublicSchema = {
properties: {
data: {
items: {
'$ref': '#/components/schemas/ItemPublic'
},
type: 'array',
title: 'Data'
},
count: {
type: 'integer',
title: 'Count'
}
},
type: 'object',
required: ['data', 'count'],
title: 'ItemsPublic'
} as const;
export const LocationCreateSchema = {
properties: {
title: {
@@ -333,6 +224,66 @@ export const MessageSchema = {
title: 'Message'
} as const;
export const MqttRawDaySummarySchema = {
properties: {
day: {
type: 'string',
format: 'date',
title: 'Day'
},
message_count: {
type: 'integer',
title: 'Message Count'
},
first_received_at: {
anyOf: [
{
type: 'string',
format: 'date-time'
},
{
type: 'null'
}
],
title: 'First Received At'
},
last_received_at: {
anyOf: [
{
type: 'string',
format: 'date-time'
},
{
type: 'null'
}
],
title: 'Last Received At'
}
},
type: 'object',
required: ['day', 'message_count'],
title: 'MqttRawDaySummary'
} as const;
export const MqttRawDaysPublicSchema = {
properties: {
data: {
items: {
'$ref': '#/components/schemas/MqttRawDaySummary'
},
type: 'array',
title: 'Data'
},
count: {
type: 'integer',
title: 'Count'
}
},
type: 'object',
required: ['data', 'count'],
title: 'MqttRawDaysPublic'
} as const;
export const NewPasswordSchema = {
properties: {
token: {

View File

@@ -3,118 +3,7 @@
import type { CancelablePromise } from './core/CancelablePromise';
import { OpenAPI } from './core/OpenAPI';
import { request as __request } from './core/request';
import type { ItemsReadItemsData, ItemsReadItemsResponse, ItemsCreateItemData, ItemsCreateItemResponse, ItemsReadItemData, ItemsReadItemResponse, ItemsUpdateItemData, ItemsUpdateItemResponse, ItemsDeleteItemData, ItemsDeleteItemResponse, LocationsReadLocationsData, LocationsReadLocationsResponse, LocationsCreateLocationData, LocationsCreateLocationResponse, LocationsReadLocationData, LocationsReadLocationResponse, LocationsUpdateLocationData, LocationsUpdateLocationResponse, LocationsDeleteLocationData, LocationsDeleteLocationResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, PrivateCreateUserData, PrivateCreateUserResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse } from './types.gen';
export class ItemsService {
/**
* Read Items
* Retrieve items.
* @param data The data for the request.
* @param data.skip
* @param data.limit
* @returns ItemsPublic Successful Response
* @throws ApiError
*/
public static readItems(data: ItemsReadItemsData = {}): CancelablePromise<ItemsReadItemsResponse> {
return __request(OpenAPI, {
method: 'GET',
url: '/api/v1/items/',
query: {
skip: data.skip,
limit: data.limit
},
errors: {
422: 'Validation Error'
}
});
}
/**
* Create Item
* Create new item.
* @param data The data for the request.
* @param data.requestBody
* @returns ItemPublic Successful Response
* @throws ApiError
*/
public static createItem(data: ItemsCreateItemData): CancelablePromise<ItemsCreateItemResponse> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/v1/items/',
body: data.requestBody,
mediaType: 'application/json',
errors: {
422: 'Validation Error'
}
});
}
/**
* Read Item
* Get item by ID.
* @param data The data for the request.
* @param data.id
* @returns ItemPublic Successful Response
* @throws ApiError
*/
public static readItem(data: ItemsReadItemData): CancelablePromise<ItemsReadItemResponse> {
return __request(OpenAPI, {
method: 'GET',
url: '/api/v1/items/{id}',
path: {
id: data.id
},
errors: {
422: 'Validation Error'
}
});
}
/**
* Update Item
* Update an item.
* @param data The data for the request.
* @param data.id
* @param data.requestBody
* @returns ItemPublic Successful Response
* @throws ApiError
*/
public static updateItem(data: ItemsUpdateItemData): CancelablePromise<ItemsUpdateItemResponse> {
return __request(OpenAPI, {
method: 'PUT',
url: '/api/v1/items/{id}',
path: {
id: data.id
},
body: data.requestBody,
mediaType: 'application/json',
errors: {
422: 'Validation Error'
}
});
}
/**
* Delete Item
* Delete an item.
* @param data The data for the request.
* @param data.id
* @returns Message Successful Response
* @throws ApiError
*/
public static deleteItem(data: ItemsDeleteItemData): CancelablePromise<ItemsDeleteItemResponse> {
return __request(OpenAPI, {
method: 'DELETE',
url: '/api/v1/items/{id}',
path: {
id: data.id
},
errors: {
422: 'Validation Error'
}
});
}
}
import type { LocationsReadLocationsData, LocationsReadLocationsResponse, LocationsCreateLocationData, LocationsCreateLocationResponse, LocationsReadLocationData, LocationsReadLocationResponse, LocationsUpdateLocationData, LocationsUpdateLocationResponse, LocationsDeleteLocationData, LocationsDeleteLocationResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, MqttRawListDaysData, MqttRawListDaysResponse, MqttRawDownloadData, MqttRawDownloadResponse, MqttRawDeleteDayData, MqttRawDeleteDayResponse, MqttRawDeleteAllData, MqttRawDeleteAllResponse, PrivateCreateUserData, PrivateCreateUserResponse, TelemetryReadTelemetryLatestData, TelemetryReadTelemetryLatestResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse } from './types.gen';
export class LocationsService {
/**
@@ -324,6 +213,94 @@ export class LoginService {
}
}
export class MqttRawService {
/**
* List Mqtt Raw Days
* @param data The data for the request.
* @param data.skip
* @param data.limit
* @returns MqttRawDaysPublic Successful Response
* @throws ApiError
*/
public static listDays(data: MqttRawListDaysData = {}): CancelablePromise<MqttRawListDaysResponse> {
return __request(OpenAPI, {
method: 'GET',
url: '/api/v1/mqtt-raw/days',
query: {
skip: data.skip,
limit: data.limit
},
errors: {
422: 'Validation Error'
}
});
}
/**
* Download Mqtt Raw
* @param data The data for the request.
* @param data.day
* @param data.startSeconds
* @param data.endSeconds
* @returns unknown Successful Response
* @throws ApiError
*/
public static download(data: MqttRawDownloadData): CancelablePromise<MqttRawDownloadResponse> {
return __request(OpenAPI, {
method: 'GET',
url: '/api/v1/mqtt-raw/download',
query: {
day: data.day,
start_seconds: data.startSeconds,
end_seconds: data.endSeconds
},
errors: {
422: 'Validation Error'
}
});
}
/**
* Delete Mqtt Raw By Day
* @param data The data for the request.
* @param data.day
* @returns DeleteResult Successful Response
* @throws ApiError
*/
public static deleteDay(data: MqttRawDeleteDayData): CancelablePromise<MqttRawDeleteDayResponse> {
return __request(OpenAPI, {
method: 'DELETE',
url: '/api/v1/mqtt-raw/day/{day}',
path: {
day: data.day
},
errors: {
422: 'Validation Error'
}
});
}
/**
* Delete All Mqtt Raw
* @param data The data for the request.
* @param data.confirm
* @returns DeleteResult Successful Response
* @throws ApiError
*/
public static deleteAll(data: MqttRawDeleteAllData = {}): CancelablePromise<MqttRawDeleteAllResponse> {
return __request(OpenAPI, {
method: 'DELETE',
url: '/api/v1/mqtt-raw/all',
query: {
confirm: data.confirm
},
errors: {
422: 'Validation Error'
}
});
}
}
export class PrivateService {
/**
* Create User
@@ -346,6 +323,31 @@ export class PrivateService {
}
}
export class TelemetryService {
/**
* Read Telemetry Latest
* Return the latest parsed GNSS telemetry snapshot.
* @param data The data for the request.
* @param data.deviceId
* @param data.limit
* @returns unknown Successful Response
* @throws ApiError
*/
public static readTelemetryLatest(data: TelemetryReadTelemetryLatestData = {}): CancelablePromise<TelemetryReadTelemetryLatestResponse> {
return __request(OpenAPI, {
method: 'GET',
url: '/api/v1/telemetry/latest',
query: {
device_id: data.deviceId,
limit: data.limit
},
errors: {
422: 'Validation Error'
}
});
}
}
export class UsersService {
/**
* Read Users

View File

@@ -9,33 +9,15 @@ export type Body_login_login_access_token = {
client_secret?: (string | null);
};
export type DeleteResult = {
deleted_count: number;
message: string;
};
export type HTTPValidationError = {
detail?: Array<ValidationError>;
};
export type ItemCreate = {
title: string;
description?: (string | null);
};
export type ItemPublic = {
title: string;
description?: (string | null);
id: string;
owner_id: string;
created_at?: (string | null);
};
export type ItemsPublic = {
data: Array<ItemPublic>;
count: number;
};
export type ItemUpdate = {
title?: (string | null);
description?: (string | null);
};
export type LocationCreate = {
title: string;
description?: (string | null);
@@ -63,6 +45,18 @@ export type Message = {
message: string;
};
export type MqttRawDaysPublic = {
data: Array<MqttRawDaySummary>;
count: number;
};
export type MqttRawDaySummary = {
day: string;
message_count: number;
first_received_at?: (string | null);
last_received_at?: (string | null);
};
export type NewPassword = {
token: string;
new_password: string;
@@ -136,38 +130,6 @@ export type ValidationError = {
};
};
export type ItemsReadItemsData = {
limit?: number;
skip?: number;
};
export type ItemsReadItemsResponse = (ItemsPublic);
export type ItemsCreateItemData = {
requestBody: ItemCreate;
};
export type ItemsCreateItemResponse = (ItemPublic);
export type ItemsReadItemData = {
id: string;
};
export type ItemsReadItemResponse = (ItemPublic);
export type ItemsUpdateItemData = {
id: string;
requestBody: ItemUpdate;
};
export type ItemsUpdateItemResponse = (ItemPublic);
export type ItemsDeleteItemData = {
id: string;
};
export type ItemsDeleteItemResponse = (Message);
export type LocationsReadLocationsData = {
limit?: number;
skip?: number;
@@ -226,12 +188,48 @@ export type LoginRecoverPasswordHtmlContentData = {
export type LoginRecoverPasswordHtmlContentResponse = (string);
export type MqttRawListDaysData = {
limit?: number;
skip?: number;
};
export type MqttRawListDaysResponse = (MqttRawDaysPublic);
export type MqttRawDownloadData = {
day: string;
endSeconds?: (number | null);
startSeconds?: (number | null);
};
export type MqttRawDownloadResponse = (unknown);
export type MqttRawDeleteDayData = {
day: string;
};
export type MqttRawDeleteDayResponse = (DeleteResult);
export type MqttRawDeleteAllData = {
confirm?: boolean;
};
export type MqttRawDeleteAllResponse = (DeleteResult);
export type PrivateCreateUserData = {
requestBody: PrivateUserCreate;
};
export type PrivateCreateUserResponse = (UserPublic);
export type TelemetryReadTelemetryLatestData = {
deviceId?: (string | null);
limit?: number;
};
export type TelemetryReadTelemetryLatestResponse = ({
[key: string]: unknown;
});
export type UsersReadUsersData = {
limit?: number;
skip?: number;

View File

@@ -4,13 +4,13 @@ import { FaXTwitter } from "react-icons/fa6"
const socialLinks = [
{
icon: FaGithub,
href: "https://github.com/fastapi/fastapi",
href: "https://makefire.fun",
label: "GitHub",
},
{ icon: FaXTwitter, href: "https://x.com/fastapi", label: "X" },
{ icon: FaXTwitter, href: "https://makefire.fun", label: "X" },
{
icon: FaLinkedinIn,
href: "https://linkedin.com/company/fastapi",
href: "https://makefire.fun",
label: "LinkedIn",
},
]
@@ -22,7 +22,7 @@ export function Footer() {
<footer className="border-t py-4 px-6">
<div className="flex flex-col items-center justify-between gap-4 sm:flex-row">
<p className="text-muted-foreground text-sm">
Full Stack FastAPI Template - {currentYear}
SpatialHub - {currentYear}
</p>
<div className="flex items-center gap-4">
{socialLinks.map(({ icon: Icon, href, label }) => (

View File

@@ -2,10 +2,10 @@ import { Link } from "@tanstack/react-router"
import { useTheme } from "@/components/theme-provider"
import { cn } from "@/lib/utils"
import icon from "/assets/images/fastapi-icon.svg"
import iconLight from "/assets/images/fastapi-icon-light.svg"
import logo from "/assets/images/fastapi-logo.svg"
import logoLight from "/assets/images/fastapi-logo-light.svg"
import icon from "/assets/images/spatialhub-icon.svg"
import iconLight from "/assets/images/spatialhub-icon-light.svg"
import logo from "/assets/images/spatialhub-logo.svg"
import logoLight from "/assets/images/spatialhub-logo-light.svg"
interface LogoProps {
variant?: "full" | "icon" | "responsive"
@@ -29,7 +29,7 @@ export function Logo({
<>
<img
src={fullLogo}
alt="FastAPI"
alt="SpatialHub"
className={cn(
"h-6 w-auto group-data-[collapsible=icon]:hidden",
className,
@@ -37,7 +37,7 @@ export function Logo({
/>
<img
src={iconLogo}
alt="FastAPI"
alt="SpatialHub"
className={cn(
"size-5 hidden group-data-[collapsible=icon]:block",
className,
@@ -47,7 +47,7 @@ export function Logo({
) : (
<img
src={variant === "full" ? fullLogo : iconLogo}
alt="FastAPI"
alt="SpatialHub"
className={cn(variant === "full" ? "h-6 w-auto" : "size-5", className)}
/>
)

View File

@@ -66,8 +66,8 @@ const DeleteLocation = ({ id, onSuccess }: DeleteLocationProps) => {
<DialogHeader>
<DialogTitle>Delete Location</DialogTitle>
<DialogDescription>
This location will be permanently deleted. Are you sure? You will not
be able to undo this action.
This location will be permanently deleted. Are you sure? You will
not be able to undo this action.
</DialogDescription>
</DialogHeader>

View File

@@ -1,4 +1,4 @@
import { Home, MapPin, Users } from "lucide-react"
import { FileDown, Home, MapPin, Satellite, Users } from "lucide-react"
import { SidebarAppearance } from "@/components/Common/Appearance"
import { Logo } from "@/components/Common/Logo"
@@ -15,6 +15,8 @@ import { User } from "./User"
const baseItems: Item[] = [
{ icon: Home, title: "Dashboard", path: "/" },
{ icon: MapPin, title: "Locations", path: "/locations" },
{ icon: Satellite, title: "GNSS Monitor", path: "/gnss-monitor" },
{ icon: FileDown, title: "MQTT Raw", path: "/mqtt-raw" },
]
export function AppSidebar() {

View File

@@ -0,0 +1,31 @@
import * as SliderPrimitive from "@radix-ui/react-slider"
import * as React from "react"
import { cn } from "@/lib/utils"
function Slider({
className,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
return (
<SliderPrimitive.Root
className={cn(
"relative flex w-full touch-none select-none items-center",
className,
)}
{...props}
>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-muted">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
{props.value?.map((_, index) => (
<SliderPrimitive.Thumb
className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
key={index}
/>
))}
</SliderPrimitive.Root>
)
}
export { Slider }

View File

@@ -0,0 +1,26 @@
const AUTH_EXPIRED_STATUSES = new Set([401, 403])
const USER_NOT_FOUND_DETAIL = "User not found"
const extractDetail = (body: unknown): unknown => {
if (typeof body === "object" && body !== null && "detail" in body) {
return (body as { detail?: unknown }).detail
}
return undefined
}
export const shouldHandleAuthError = (
status: number,
body: unknown,
): boolean => {
if (AUTH_EXPIRED_STATUSES.has(status)) {
return true
}
return status === 404 && extractDetail(body) === USER_NOT_FOUND_DETAIL
}
export const clearAuthAndRedirectToLogin = (): void => {
localStorage.removeItem("access_token")
if (window.location.pathname !== "/login") {
window.location.href = "/login"
}
}

View File

@@ -0,0 +1,76 @@
/**
* WGS-84 ↔ GCJ-02 coordinate transformation.
*
* GCJ-02 (国测局坐标系 / "Mars coordinates") is the mandatory coordinate system
* used by Chinese mapping services including Gaode (AutoNavi / 高德地图).
*
* GPS devices output WGS-84 coordinates. When overlaying markers on a GCJ-02
* base map the coordinates must be transformed, otherwise markers will appear
* shifted by tens to hundreds of metres in mainland China.
*/
const PI = Math.PI
const SEMI_MAJOR = 6378245.0 // Semi-major axis of Krasovsky ellipsoid
const EE = 0.006693421622965943 // Eccentricity squared
function outOfChina(lat: number, lon: number): boolean {
return lon < 72.004 || lon > 137.8347 || lat < 0.8293 || lat > 55.8271
}
function transformLat(x: number, y: number): number {
let ret =
-100.0 +
2.0 * x +
3.0 * y +
0.2 * y * y +
0.1 * x * y +
0.2 * Math.sqrt(Math.abs(x))
ret +=
((20.0 * Math.sin(6.0 * x * PI) + 20.0 * Math.sin(2.0 * x * PI)) * 2.0) /
3.0
ret +=
((20.0 * Math.sin(y * PI) + 40.0 * Math.sin((y / 3.0) * PI)) * 2.0) / 3.0
ret +=
((160.0 * Math.sin((y / 12.0) * PI) + 320 * Math.sin((y * PI) / 30.0)) *
2.0) /
3.0
return ret
}
function transformLon(x: number, y: number): number {
let ret =
300.0 +
x +
2.0 * y +
0.1 * x * x +
0.1 * x * y +
0.1 * Math.sqrt(Math.abs(x))
ret +=
((20.0 * Math.sin(6.0 * x * PI) + 20.0 * Math.sin(2.0 * x * PI)) * 2.0) /
3.0
ret +=
((20.0 * Math.sin(x * PI) + 40.0 * Math.sin((x / 3.0) * PI)) * 2.0) / 3.0
ret +=
((150.0 * Math.sin((x / 12.0) * PI) + 300.0 * Math.sin((x / 30.0) * PI)) *
2.0) /
3.0
return ret
}
/** Convert WGS-84 → GCJ-02 */
export function wgs84ToGcj02(wgsLat: number, wgsLon: number): [number, number] {
if (outOfChina(wgsLat, wgsLon)) {
return [wgsLat, wgsLon]
}
let dLat = transformLat(wgsLon - 105.0, wgsLat - 35.0)
let dLon = transformLon(wgsLon - 105.0, wgsLat - 35.0)
const radLat = (wgsLat / 180.0) * PI
let magic = Math.sin(radLat)
magic = 1 - EE * magic * magic
const sqrtMagic = Math.sqrt(magic)
dLat = (dLat * 180.0) / (((SEMI_MAJOR * (1 - EE)) / (magic * sqrtMagic)) * PI)
dLon = (dLon * 180.0) / ((SEMI_MAJOR / sqrtMagic) * Math.cos(radLat) * PI)
return [wgsLat + dLat, wgsLon + dLon]
}

View File

@@ -11,6 +11,10 @@ import { ApiError, OpenAPI } from "./client"
import { ThemeProvider } from "./components/theme-provider"
import { Toaster } from "./components/ui/sonner"
import "./index.css"
import {
clearAuthAndRedirectToLogin,
shouldHandleAuthError,
} from "./lib/auth-error"
import { routeTree } from "./routeTree.gen"
OpenAPI.BASE = import.meta.env.VITE_API_URL
@@ -18,10 +22,21 @@ OpenAPI.TOKEN = async () => {
return localStorage.getItem("access_token") || ""
}
OpenAPI.interceptors.response.use((response) => {
if (shouldHandleAuthError(response.status, response.data)) {
clearAuthAndRedirectToLogin()
}
return response
})
const handleApiError = (error: Error) => {
if (error instanceof ApiError && [401, 403].includes(error.status)) {
localStorage.removeItem("access_token")
window.location.href = "/login"
if (!(error instanceof ApiError)) {
return
}
if (shouldHandleAuthError(error.status, error.body)) {
clearAuthAndRedirectToLogin()
}
}
const queryClient = new QueryClient({

View File

@@ -16,7 +16,9 @@ import { Route as LoginRouteImport } from './routes/login'
import { Route as LayoutRouteImport } from './routes/_layout'
import { Route as LayoutIndexRouteImport } from './routes/_layout/index'
import { Route as LayoutSettingsRouteImport } from './routes/_layout/settings'
import { Route as LayoutMqttRawRouteImport } from './routes/_layout/mqtt-raw'
import { Route as LayoutLocationsRouteImport } from './routes/_layout/locations'
import { Route as LayoutGnssMonitorRouteImport } from './routes/_layout/gnss-monitor'
import { Route as LayoutAdminRouteImport } from './routes/_layout/admin'
const SignupRoute = SignupRouteImport.update({
@@ -53,11 +55,21 @@ const LayoutSettingsRoute = LayoutSettingsRouteImport.update({
path: '/settings',
getParentRoute: () => LayoutRoute,
} as any)
const LayoutMqttRawRoute = LayoutMqttRawRouteImport.update({
id: '/mqtt-raw',
path: '/mqtt-raw',
getParentRoute: () => LayoutRoute,
} as any)
const LayoutLocationsRoute = LayoutLocationsRouteImport.update({
id: '/locations',
path: '/locations',
getParentRoute: () => LayoutRoute,
} as any)
const LayoutGnssMonitorRoute = LayoutGnssMonitorRouteImport.update({
id: '/gnss-monitor',
path: '/gnss-monitor',
getParentRoute: () => LayoutRoute,
} as any)
const LayoutAdminRoute = LayoutAdminRouteImport.update({
id: '/admin',
path: '/admin',
@@ -71,7 +83,9 @@ export interface FileRoutesByFullPath {
'/reset-password': typeof ResetPasswordRoute
'/signup': typeof SignupRoute
'/admin': typeof LayoutAdminRoute
'/gnss-monitor': typeof LayoutGnssMonitorRoute
'/locations': typeof LayoutLocationsRoute
'/mqtt-raw': typeof LayoutMqttRawRoute
'/settings': typeof LayoutSettingsRoute
}
export interface FileRoutesByTo {
@@ -80,7 +94,9 @@ export interface FileRoutesByTo {
'/reset-password': typeof ResetPasswordRoute
'/signup': typeof SignupRoute
'/admin': typeof LayoutAdminRoute
'/gnss-monitor': typeof LayoutGnssMonitorRoute
'/locations': typeof LayoutLocationsRoute
'/mqtt-raw': typeof LayoutMqttRawRoute
'/settings': typeof LayoutSettingsRoute
'/': typeof LayoutIndexRoute
}
@@ -92,7 +108,9 @@ export interface FileRoutesById {
'/reset-password': typeof ResetPasswordRoute
'/signup': typeof SignupRoute
'/_layout/admin': typeof LayoutAdminRoute
'/_layout/gnss-monitor': typeof LayoutGnssMonitorRoute
'/_layout/locations': typeof LayoutLocationsRoute
'/_layout/mqtt-raw': typeof LayoutMqttRawRoute
'/_layout/settings': typeof LayoutSettingsRoute
'/_layout/': typeof LayoutIndexRoute
}
@@ -105,7 +123,9 @@ export interface FileRouteTypes {
| '/reset-password'
| '/signup'
| '/admin'
| '/gnss-monitor'
| '/locations'
| '/mqtt-raw'
| '/settings'
fileRoutesByTo: FileRoutesByTo
to:
@@ -114,7 +134,9 @@ export interface FileRouteTypes {
| '/reset-password'
| '/signup'
| '/admin'
| '/gnss-monitor'
| '/locations'
| '/mqtt-raw'
| '/settings'
| '/'
id:
@@ -125,7 +147,9 @@ export interface FileRouteTypes {
| '/reset-password'
| '/signup'
| '/_layout/admin'
| '/_layout/gnss-monitor'
| '/_layout/locations'
| '/_layout/mqtt-raw'
| '/_layout/settings'
| '/_layout/'
fileRoutesById: FileRoutesById
@@ -189,6 +213,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof LayoutSettingsRouteImport
parentRoute: typeof LayoutRoute
}
'/_layout/mqtt-raw': {
id: '/_layout/mqtt-raw'
path: '/mqtt-raw'
fullPath: '/mqtt-raw'
preLoaderRoute: typeof LayoutMqttRawRouteImport
parentRoute: typeof LayoutRoute
}
'/_layout/locations': {
id: '/_layout/locations'
path: '/locations'
@@ -196,6 +227,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof LayoutLocationsRouteImport
parentRoute: typeof LayoutRoute
}
'/_layout/gnss-monitor': {
id: '/_layout/gnss-monitor'
path: '/gnss-monitor'
fullPath: '/gnss-monitor'
preLoaderRoute: typeof LayoutGnssMonitorRouteImport
parentRoute: typeof LayoutRoute
}
'/_layout/admin': {
id: '/_layout/admin'
path: '/admin'
@@ -208,14 +246,18 @@ declare module '@tanstack/react-router' {
interface LayoutRouteChildren {
LayoutAdminRoute: typeof LayoutAdminRoute
LayoutGnssMonitorRoute: typeof LayoutGnssMonitorRoute
LayoutLocationsRoute: typeof LayoutLocationsRoute
LayoutMqttRawRoute: typeof LayoutMqttRawRoute
LayoutSettingsRoute: typeof LayoutSettingsRoute
LayoutIndexRoute: typeof LayoutIndexRoute
}
const LayoutRouteChildren: LayoutRouteChildren = {
LayoutAdminRoute: LayoutAdminRoute,
LayoutGnssMonitorRoute: LayoutGnssMonitorRoute,
LayoutLocationsRoute: LayoutLocationsRoute,
LayoutMqttRawRoute: LayoutMqttRawRoute,
LayoutSettingsRoute: LayoutSettingsRoute,
LayoutIndexRoute: LayoutIndexRoute,
}

View File

@@ -1,4 +1,9 @@
import { createFileRoute, Outlet, redirect } from "@tanstack/react-router"
import {
createFileRoute,
Outlet,
redirect,
useRouterState,
} from "@tanstack/react-router"
import { Footer } from "@/components/Common/Footer"
import AppSidebar from "@/components/Sidebar/AppSidebar"
@@ -21,19 +26,28 @@ export const Route = createFileRoute("/_layout")({
})
function Layout() {
const pathname = useRouterState({
select: (state) => state.location.pathname,
})
const isFullScreenPage = pathname === "/gnss-monitor" || pathname === "/"
return (
<SidebarProvider>
<AppSidebar />
<SidebarInset>
<header className="sticky top-0 z-10 flex h-16 shrink-0 items-center gap-2 border-b px-4">
<SidebarInset className="flex flex-col">
<header className="sticky top-0 z-10 flex h-16 shrink-0 items-center gap-2 border-b px-4 bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60">
<SidebarTrigger className="-ml-1 text-muted-foreground" />
</header>
<main className="flex-1 p-6 md:p-8">
<div className="mx-auto max-w-7xl">
<main
className={`flex-1 flex flex-col ${isFullScreenPage ? "p-0" : "p-6 md:p-8"}`}
>
<div
className={`flex-1 flex flex-col mx-auto w-full ${isFullScreenPage ? "max-w-none" : "max-w-7xl"}`}
>
<Outlet />
</div>
</main>
<Footer />
{!isFullScreenPage && <Footer />}
</SidebarInset>
</SidebarProvider>
)

View File

@@ -0,0 +1,826 @@
import { createFileRoute } from "@tanstack/react-router"
import { Compass, Gauge, MapPin, Radio, Satellite, Signal } from "lucide-react"
import { useEffect, useMemo, useState } from "react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
type SignalHealthLevel = "good" | "fair" | "poor" | "unknown"
type SignalHealth = {
level: SignalHealthLevel
score: number
strong_signal_count?: number
average_snr_dbhz: number | null
best_snr_dbhz: number | null
message: string
}
type SatellitePoint = {
satellite_id: string
constellation: string
azimuth_deg: number | null
elevation_deg: number | null
snr_dbhz: number | null
used_in_navigation: boolean
}
type SatelliteSummary = {
satellites_in_view: number | null
snr_values: number[]
constellations: Record<
string,
{ count?: number; with_signal?: number; used_in_navigation?: number }
>
used_satellite_ids: string[]
satellites: SatellitePoint[]
}
type TelemetryDevice = {
device_id: string
last_event_time: string | null
updated_at: string
fix_status: string | null
lat: number | null
lon: number | null
alt_m: number | null
satellites_used: number | null
satellite_signal_summary: SatelliteSummary
signal_health: SignalHealth
}
type TelemetrySnapshot = {
generated_at: string
devices: TelemetryDevice[]
}
const CONSTELLATION_COLORS: Record<string, string> = {
GPS: "#f59e0b",
SBAS: "#9ca3af",
BeiDou: "#8b5cf6",
Galileo: "#84cc16",
GLONASS: "#06b6d4",
QZSS: "#f97316",
NavIC: "#ec4899",
Mixed: "#64748b",
Unknown: "#94a3b8",
}
const CONSTELLATION_ORDER = [
"GPS",
"SBAS",
"Galileo",
"BeiDou",
"GLONASS",
"QZSS",
"NavIC",
"Mixed",
"Unknown",
] as const
const CONSTELLATION_PREFIX: Record<string, string> = {
GPS: "G",
SBAS: "S",
Galileo: "E",
BeiDou: "B",
GLONASS: "R",
QZSS: "Q",
NavIC: "I",
Mixed: "M",
Unknown: "U",
}
const SIGNAL_STATUS_CLASS: Record<SignalHealthLevel, string> = {
good: "text-emerald-500 bg-emerald-500/10 border-emerald-500/20",
fair: "text-amber-500 bg-amber-500/10 border-amber-500/20",
poor: "text-red-500 bg-red-500/10 border-red-500/20",
unknown: "text-muted-foreground bg-muted border-border",
}
const FIX_STATUS_META: Record<
string,
{ label: string; className: string; description: string }
> = {
"0": {
label: "No Fix",
className: "text-red-500 bg-red-500/10 border-red-500/20",
description: "No position solution",
},
"1": {
label: "Single",
className: "text-amber-500 bg-amber-500/10 border-amber-500/20",
description: "Standalone GNSS fix",
},
"2": {
label: "DGPS",
className: "text-sky-500 bg-sky-500/10 border-sky-500/20",
description: "Differential correction active",
},
"4": {
label: "RTK Fixed",
className: "text-emerald-500 bg-emerald-500/10 border-emerald-500/20",
description: "Carrier fixed solution",
},
"5": {
label: "RTK Float",
className: "text-orange-500 bg-orange-500/10 border-orange-500/20",
description: "Carrier float solution",
},
A: {
label: "Valid",
className: "text-sky-500 bg-sky-500/10 border-sky-500/20",
description: "Valid navigation status",
},
V: {
label: "Invalid",
className: "text-red-500 bg-red-500/10 border-red-500/20",
description: "Invalid navigation status",
},
}
export const Route = createFileRoute("/_layout/gnss-monitor")({
component: GnssMonitor,
head: () => ({
meta: [
{
title: "GNSS Monitor - FastAPI Template",
},
],
}),
})
function buildTelemetryWsUrl(token: string): string {
const apiBase = (
import.meta.env.VITE_API_URL || window.location.origin
).replace(/\/$/, "")
const wsBase = apiBase.startsWith("https://")
? `wss://${apiBase.slice("https://".length)}`
: apiBase.startsWith("http://")
? `ws://${apiBase.slice("http://".length)}`
: apiBase
return `${wsBase}/api/v1/telemetry/ws?token=${encodeURIComponent(token)}`
}
function formatCoord(value: number | null): string {
if (value === null) {
return "-"
}
return value.toFixed(6)
}
function formatTime(value: string | null): string {
if (!value) {
return "-"
}
return new Date(value).toLocaleTimeString()
}
function formatAge(value: string | null): string {
if (!value) {
return "-"
}
const diffSeconds = Math.max(
0,
Math.floor((Date.now() - new Date(value).getTime()) / 1000),
)
if (diffSeconds < 60) {
return `${diffSeconds}s`
}
const minutes = Math.floor(diffSeconds / 60)
const seconds = diffSeconds % 60
return `${minutes}m ${seconds}s`
}
function getFixMeta(fixStatus: string | null) {
if (!fixStatus) {
return {
label: "Unknown",
className: "text-muted-foreground bg-muted border-border",
description: "No fix state received yet",
}
}
return (
FIX_STATUS_META[fixStatus] ?? {
label: `Fix ${fixStatus}`,
className: "text-muted-foreground bg-muted border-border",
description: "Unrecognized fix code",
}
)
}
function satColor(constellation: string): string {
return CONSTELLATION_COLORS[constellation] ?? CONSTELLATION_COLORS.Unknown
}
function satPrefix(constellation: string): string {
return CONSTELLATION_PREFIX[constellation] ?? CONSTELLATION_PREFIX.Unknown
}
function satelliteSortValue(satelliteId: string): number {
const digits = satelliteId.replace(/\D+/g, "")
return digits ? Number(digits) : Number.POSITIVE_INFINITY
}
function satelliteName(satellite: SatellitePoint): string {
const satId = satellite.satellite_id.trim()
if (/^[A-Za-z]/.test(satId)) {
return satId
}
return `${satPrefix(satellite.constellation)}${satId}`
}
function SignalBars({ satellites }: { satellites: SatellitePoint[] }) {
const withSnr = satellites.filter(
(sat) => sat.snr_dbhz !== null && sat.snr_dbhz > 0,
)
if (withSnr.length === 0) {
return (
<div className="h-40 flex items-center justify-center text-sm text-muted-foreground border rounded-lg">
No SNR data yet
</div>
)
}
const grouped = CONSTELLATION_ORDER.map((name) => ({
name,
satellites: withSnr
.filter((sat) => sat.constellation === name)
.sort(
(a, b) =>
satelliteSortValue(a.satellite_id) -
satelliteSortValue(b.satellite_id),
),
})).filter((group) => group.satellites.length > 0)
const unknownGroup = withSnr
.filter(
(sat) =>
!CONSTELLATION_ORDER.includes(
sat.constellation as (typeof CONSTELLATION_ORDER)[number],
),
)
.sort(
(a, b) =>
satelliteSortValue(a.satellite_id) - satelliteSortValue(b.satellite_id),
)
if (unknownGroup.length > 0) {
grouped.push({ name: "Unknown", satellites: unknownGroup })
}
const bars = grouped.flatMap((group, groupIndex) =>
group.satellites.map((satellite, index) => ({
groupName: group.name,
groupStart: groupIndex > 0 && index === 0,
satellite,
})),
)
const columnStyle = {
gridTemplateColumns: `repeat(${bars.length}, minmax(0, 1fr))`,
} as const
return (
<div className="space-y-3">
<div className="flex flex-wrap gap-2 text-xs">
{grouped.map((group) => (
<div
className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5"
key={group.name}
>
<span
className="size-2 rounded-full"
style={{ backgroundColor: satColor(group.name) }}
/>
<span>
{group.name} ({satPrefix(group.name)})
</span>
</div>
))}
<div className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5">
<span className="size-2 rounded-full border-2 border-blue-500" />
<span>Not used in navigation</span>
</div>
</div>
<div className="flex gap-2 min-w-0">
<div className="w-10 h-[200px] text-[11px] text-muted-foreground shrink-0 flex flex-col justify-between">
{[50, 40, 30, 20, 10, 0].map((tick) => (
<span className="leading-none text-right" key={tick}>
{tick}
</span>
))}
</div>
<div className="relative h-[250px] flex-1 min-w-0">
<div className="absolute inset-0 pointer-events-none">
{[0, 10, 20, 30, 40, 50].map((tick) => (
<div
className="absolute left-0 right-0 border-t border-dashed border-border/70"
key={tick}
style={{ bottom: `${(tick / 50) * 200}px` }}
/>
))}
</div>
<div
className="relative h-full grid gap-x-1 min-w-0"
style={columnStyle}
>
{bars.map((entry) => {
const snr = entry.satellite.snr_dbhz ?? 0
const color = satColor(entry.groupName)
const height = Math.max(Math.round((snr / 50) * 200), 4)
return (
<div
className={`min-w-0 ${entry.groupStart ? "border-l border-border/80 pl-1" : ""}`}
key={`${entry.groupName}-${entry.satellite.satellite_id}`}
>
<div className="h-[200px] flex items-end justify-center">
<div
className="w-full max-w-4 rounded-sm border"
style={{
height: `${height}px`,
backgroundColor: entry.satellite.used_in_navigation
? color
: `${color}33`,
borderColor: entry.satellite.used_in_navigation
? color
: "#2563eb",
borderWidth: entry.satellite.used_in_navigation
? "1px"
: "2px",
}}
title={`${satelliteName(entry.satellite)}${entry.groupName}${snr} dB-Hz`}
/>
</div>
<div className="mt-1 flex flex-col items-center leading-none gap-1">
<span className="text-[10px] text-center whitespace-nowrap">
{satelliteName(entry.satellite)}
</span>
<span className="text-[10px] text-muted-foreground text-center">
{snr}
</span>
</div>
</div>
)
})}
</div>
</div>
</div>
</div>
)
}
function SkyPlot({ satellites }: { satellites: SatellitePoint[] }) {
const points = satellites.filter(
(sat) =>
sat.azimuth_deg !== null &&
sat.elevation_deg !== null &&
sat.elevation_deg >= 0 &&
sat.elevation_deg <= 90,
)
return (
<div className="relative w-full max-w-[420px] mx-auto aspect-square rounded-full border bg-muted/20">
<div className="absolute inset-[16%] rounded-full border border-dashed border-border" />
<div className="absolute inset-[33%] rounded-full border border-dashed border-border" />
<div className="absolute inset-[50%] rounded-full border border-dashed border-border" />
<div className="absolute left-1/2 top-0 -translate-x-1/2 text-xs text-muted-foreground">
N
</div>
<div className="absolute left-1/2 bottom-0 -translate-x-1/2 text-xs text-muted-foreground">
S
</div>
<div className="absolute left-1 top-1/2 -translate-y-1/2 text-xs text-muted-foreground">
W
</div>
<div className="absolute right-1 top-1/2 -translate-y-1/2 text-xs text-muted-foreground">
E
</div>
{points.map((satellite) => {
const azimuth = satellite.azimuth_deg ?? 0
const elevation = satellite.elevation_deg ?? 0
const radialPercent = ((90 - elevation) / 90) * 45
const angle = ((azimuth - 90) * Math.PI) / 180
const x = Math.cos(angle) * radialPercent
const y = Math.sin(angle) * radialPercent
const color = satColor(satellite.constellation)
return (
<div
className="absolute size-4 rounded-full border-2"
key={`${satellite.constellation}-${satellite.satellite_id}-sky`}
style={{
left: `${50 + x}%`,
top: `${50 + y}%`,
transform: "translate(-50%, -50%)",
backgroundColor: color,
borderColor: satellite.used_in_navigation
? color
: "var(--border)",
opacity: satellite.used_in_navigation ? 1 : 0.5,
}}
title={`${satellite.constellation}-${satellite.satellite_id} (${satellite.snr_dbhz ?? "-"} dB-Hz)`}
/>
)
})}
</div>
)
}
function ConnectionBadge({
status,
}: {
status: "connecting" | "live" | "retrying"
}) {
const statusText =
status === "live"
? "Live"
: status === "retrying"
? "Reconnecting"
: "Connecting"
const statusClass =
status === "live"
? "text-emerald-500 bg-emerald-500/10 border-emerald-500/20"
: "text-amber-500 bg-amber-500/10 border-amber-500/20"
return (
<Badge variant="outline" className={statusClass}>
<Radio className="size-3" />
{statusText}
</Badge>
)
}
function GnssMonitor() {
const [snapshot, setSnapshot] = useState<TelemetrySnapshot | null>(null)
const [status, setStatus] = useState<"connecting" | "live" | "retrying">(
"connecting",
)
const [selectedDeviceId, setSelectedDeviceId] = useState<string>("")
useEffect(() => {
const token = localStorage.getItem("access_token")
if (!token) {
return
}
let reconnectTimer: number | undefined
let socket: WebSocket | undefined
let shouldReconnect = true
const connect = () => {
setStatus((prev) => (prev === "live" ? prev : "connecting"))
socket = new WebSocket(buildTelemetryWsUrl(token))
socket.onopen = () => {
setStatus("live")
}
socket.onmessage = (event) => {
try {
const payload = JSON.parse(event.data) as TelemetrySnapshot
setSnapshot(payload)
setStatus("live")
} catch {
setStatus("retrying")
}
}
socket.onclose = () => {
if (!shouldReconnect) {
return
}
setStatus("retrying")
reconnectTimer = window.setTimeout(connect, 2000)
}
socket.onerror = () => {
socket?.close()
}
}
connect()
return () => {
shouldReconnect = false
if (reconnectTimer) {
window.clearTimeout(reconnectTimer)
}
socket?.close()
}
}, [])
useEffect(() => {
if (!snapshot?.devices.length) {
setSelectedDeviceId("")
return
}
const exists = snapshot.devices.some(
(device) => device.device_id === selectedDeviceId,
)
if (!exists) {
setSelectedDeviceId(snapshot.devices[0].device_id)
}
}, [snapshot, selectedDeviceId])
const currentDevice = useMemo(() => {
if (!snapshot?.devices.length) {
return null
}
return (
snapshot.devices.find(
(device) => device.device_id === selectedDeviceId,
) ?? snapshot.devices[0]
)
}, [selectedDeviceId, snapshot])
const fixMeta = getFixMeta(currentDevice?.fix_status ?? null)
const satellites = currentDevice?.satellite_signal_summary.satellites ?? []
const constellations =
currentDevice?.satellite_signal_summary.constellations ?? {}
const signalHealth = currentDevice?.signal_health
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">GNSS Monitor</h1>
<p className="text-muted-foreground">
Real-time positioning and satellite signal watch
</p>
</div>
<div className="flex items-center gap-3">
<ConnectionBadge status={status} />
<Badge variant="outline">
{snapshot?.generated_at
? `Updated ${formatTime(snapshot.generated_at)}`
: "Waiting for stream"}
</Badge>
</div>
</div>
<Card>
<CardHeader className="gap-3">
<CardTitle>Device</CardTitle>
<CardDescription>
Choose a device to inspect live fix and satellite conditions
</CardDescription>
</CardHeader>
<CardContent>
<select
className="h-10 rounded-md border bg-background px-3 text-sm min-w-[280px]"
onChange={(event) => setSelectedDeviceId(event.target.value)}
value={selectedDeviceId}
>
{snapshot?.devices.map((device) => (
<option key={device.device_id} value={device.device_id}>
{device.device_id}
</option>
))}
</select>
</CardContent>
</Card>
{!currentDevice ? (
<Card>
<CardContent className="py-10 text-center text-muted-foreground">
Waiting for MQTT parsed telemetry...
</CardContent>
</Card>
) : (
<>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Gauge className="size-4" />
Fix Status
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<Badge variant="outline" className={fixMeta.className}>
{fixMeta.label}
</Badge>
<p className="text-sm text-muted-foreground">
{fixMeta.description}
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Signal className="size-4" />
Signal Health
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<Badge
variant="outline"
className={
SIGNAL_STATUS_CLASS[signalHealth?.level ?? "unknown"]
}
>
{(signalHealth?.level ?? "unknown").toUpperCase()}
</Badge>
<div className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span>Score</span>
<span className="font-medium">
{signalHealth?.score ?? 0}
</span>
</div>
<div className="h-2 rounded-full bg-muted overflow-hidden">
<div
className="h-full bg-primary transition-all duration-300"
style={{ width: `${signalHealth?.score ?? 0}%` }}
/>
</div>
</div>
<p className="text-xs text-muted-foreground">
{signalHealth?.message ?? "No signal data"}
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Satellite className="size-4" />
Satellites
</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Used in fix</span>
<span>{currentDevice.satellites_used ?? "-"}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">In view</span>
<span>
{currentDevice.satellite_signal_summary
.satellites_in_view ?? "-"}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Best SNR</span>
<span>{signalHealth?.best_snr_dbhz ?? "-"} dB-Hz</span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<MapPin className="size-4" />
Position
</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Latitude</span>
<span>{formatCoord(currentDevice.lat)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Longitude</span>
<span>{formatCoord(currentDevice.lon)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Altitude</span>
<span>
{currentDevice.alt_m === null
? "-"
: `${currentDevice.alt_m.toFixed(2)} m`}
</span>
</div>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Signal className="size-4" />
Satellite Signal View
</CardTitle>
<CardDescription>
Grouped by constellation, with per-satellite CN0 bars and clear
non-navigation marking.
</CardDescription>
</CardHeader>
<CardContent>
<SignalBars satellites={satellites} />
</CardContent>
</Card>
<div className="grid gap-6 xl:grid-cols-[460px_1fr]">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Compass className="size-4" />
Satellite Position View
</CardTitle>
<CardDescription>
Sky plot by azimuth/elevation for quick distribution checks.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<SkyPlot satellites={satellites} />
<div className="grid grid-cols-2 gap-2 text-xs">
{Object.entries(constellations).map(([name, detail]) => (
<div
className="rounded-md border px-2 py-1 flex items-center justify-between"
key={name}
>
<span className="flex items-center gap-1">
<span
className="size-2 rounded-full"
style={{ backgroundColor: satColor(name) }}
/>
{name}
</span>
<span className="text-muted-foreground">
{detail.used_in_navigation ?? 0}/{detail.count ?? 0}
</span>
</div>
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Data View</CardTitle>
<CardDescription>
Lightweight runtime details for monitoring, without offline
analysis.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 text-sm md:grid-cols-2 xl:grid-cols-3">
<div>
<p className="text-muted-foreground">Device ID</p>
<p className="font-medium break-all">
{currentDevice.device_id}
</p>
</div>
<div>
<p className="text-muted-foreground">Last Event Time</p>
<p className="font-medium">
{formatTime(currentDevice.last_event_time)}
</p>
</div>
<div>
<p className="text-muted-foreground">Data Age</p>
<p className="font-medium">
{formatAge(currentDevice.updated_at)}
</p>
</div>
<div>
<p className="text-muted-foreground">Average SNR</p>
<p className="font-medium">
{signalHealth?.average_snr_dbhz ?? "-"} dB-Hz
</p>
</div>
<div>
<p className="text-muted-foreground">{"Strong CN0 (>=40)"}</p>
<p className="font-medium">
{signalHealth?.strong_signal_count ?? 0}
</p>
</div>
<div className="md:col-span-2 xl:col-span-3">
<p className="text-muted-foreground mb-2">Map Jump</p>
{currentDevice.lat === null || currentDevice.lon === null ? (
<p className="text-sm text-muted-foreground">
Waiting for valid coordinates
</p>
) : (
<Button asChild size="sm" variant="outline">
<a
href={`https://www.openstreetmap.org/?mlat=${currentDevice.lat}&mlon=${currentDevice.lon}&zoom=16`}
rel="noreferrer"
target="_blank"
>
Open in OpenStreetMap
</a>
</Button>
)}
</div>
</CardContent>
</Card>
</div>
</>
)}
</div>
)
}

View File

@@ -1,30 +1,779 @@
import { createFileRoute } from "@tanstack/react-router"
import L from "leaflet"
import {
ChevronDown,
ChevronUp,
Clock,
Layers,
Maximize,
Mountain,
Radio,
Satellite,
Signal,
} from "lucide-react"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { MapContainer, Marker, Popup, TileLayer, useMap } from "react-leaflet"
import "leaflet/dist/leaflet.css"
import useAuth from "@/hooks/useAuth"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { wgs84ToGcj02 } from "@/lib/coord-transform"
export const Route = createFileRoute("/_layout/")({
component: Dashboard,
head: () => ({
meta: [
{
title: "Dashboard - FastAPI Template",
title: "Device Map - SpatialHub",
},
],
}),
})
function Dashboard() {
const { user: currentUser } = useAuth()
/* ---------- types ---------- */
type SignalHealth = {
level: string
score: number
strong_signal_count: number
average_snr_dbhz: number | null
best_snr_dbhz: number | null
message: string
}
type TelemetryDevice = {
device_id: string
last_event_time: string | null
updated_at: string
fix_status: string | null
lat: number | null
lon: number | null
alt_m: number | null
satellites_used: number | null
signal_health: SignalHealth | null
}
type TelemetrySnapshot = {
generated_at: string
devices: TelemetryDevice[]
}
type ConnectionState = "connecting" | "live" | "retrying"
type BaseMapType = "osm" | "amap" | "amap-satellite"
type MapControlBridge = {
__mapFitAll?: () => void
__mapFocusDevice?: (coordinate: [number, number]) => void
}
/* ---------- constants ---------- */
const BASE_MAPS: Record<
BaseMapType,
{
label: string
url: string
attribution: string
subdomains: string
gcj02: boolean
}
> = {
osm: {
label: "OpenStreetMap",
url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
attribution: "&copy; OpenStreetMap contributors",
subdomains: "abc",
gcj02: false,
},
amap: {
label: "高德矢量",
url: "https://webrd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x={x}&y={y}&z={z}",
attribution: "&copy; AutoNavi",
subdomains: "1234",
gcj02: true,
},
"amap-satellite": {
label: "高德卫星",
url: "https://webst0{s}.is.autonavi.com/appmaptile?style=6&x={x}&y={y}&z={z}",
attribution: "&copy; AutoNavi",
subdomains: "1234",
gcj02: true,
},
}
const DEFAULT_CENTER: [number, number] = [31.2304, 121.4737]
const DEFAULT_ZOOM = 5
const FIX_STATUS_LABEL: Record<string, string> = {
"0": "No Fix",
"1": "Single",
"2": "DGPS",
"4": "RTK Fixed",
"5": "RTK Float",
A: "Valid",
V: "Invalid",
}
/* ---------- custom marker icons ---------- */
/**
* Build an SVG DivIcon for a device marker.
* - Online devices: pin shape with colored gradient + subtle pulse ring
* - Offline devices: muted grey, no pulse
* - Selected device: larger + bright border ring
*/
function buildDeviceIcon(
fillColor: string,
isSelected: boolean,
isOffline: boolean,
): L.DivIcon {
const size = isSelected ? 36 : 28
const half = size / 2
const dotR = isSelected ? 7 : 5.5
const ringR = isSelected ? 12 : 9
// Pulse ring only for online + selected-or-not
const pulseRing = isOffline
? ""
: `<circle cx="${half}" cy="${half}" r="${ringR}"
fill="none" stroke="${fillColor}" stroke-width="2" opacity="0.5">
<animate attributeName="r" from="${ringR}" to="${half - 1}" dur="1.8s" repeatCount="indefinite"/>
<animate attributeName="opacity" from="0.5" to="0" dur="1.8s" repeatCount="indefinite"/>
</circle>`
const borderStroke = isSelected
? `stroke="${fillColor}" stroke-width="2.5"`
: `stroke="rgba(255,255,255,0.85)" stroke-width="1.5"`
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
${pulseRing}
<circle cx="${half}" cy="${half}" r="${dotR}" fill="${fillColor}" ${borderStroke}
style="filter: drop-shadow(0 1px 3px rgba(0,0,0,0.35));" />
</svg>`
return L.divIcon({
html: svg,
className: "", // remove default leaflet-div-icon styling
iconSize: [size, size],
iconAnchor: [half, half],
popupAnchor: [0, -half],
})
}
/* ---------- helpers ---------- */
function buildTelemetryWsUrl(token: string): string {
const apiBase = (
import.meta.env.VITE_API_URL || window.location.origin
).replace(/\/$/, "")
const wsBase = apiBase.startsWith("https://")
? `wss://${apiBase.slice("https://".length)}`
: apiBase.startsWith("http://")
? `ws://${apiBase.slice("http://".length)}`
: apiBase
return `${wsBase}/api/v1/telemetry/ws?token=${encodeURIComponent(token)}`
}
function formatCoord(value: number | null): string {
if (value === null) return "-"
return value.toFixed(6)
}
function getFixStatusLabel(value: string | null): string {
if (!value) return "Unknown"
return FIX_STATUS_LABEL[value] ?? `Fix ${value}`
}
function getFixStatusColor(value: string | null): string {
if (value === "4") return "text-emerald-500"
if (value === "5") return "text-orange-400"
if (value === "2" || value === "A") return "text-sky-500"
if (value === "1") return "text-amber-500"
if (value === "0" || value === "V") return "text-red-500"
return "text-muted-foreground"
}
function deviceMarkerColor(
fixStatus: string | null,
isOffline: boolean,
): string {
if (isOffline) return "#94a3b8"
if (fixStatus === "4") return "#10b981"
if (fixStatus === "5") return "#fb923c"
if (fixStatus === "2" || fixStatus === "A") return "#0ea5e9"
if (fixStatus === "1") return "#f59e0b"
if (fixStatus === "0" || fixStatus === "V") return "#ef4444"
return "#64748b"
}
const OFFLINE_THRESHOLD_MS = 5 * 60 * 1000
function checkIsOffline(lastEventTime: string | null): boolean {
if (!lastEventTime) return true
return Date.now() - new Date(lastEventTime).getTime() > OFFLINE_THRESHOLD_MS
}
function timeAgo(isoString: string | null): string {
if (!isoString) return "Unknown"
const diffMs = Date.now() - new Date(isoString).getTime()
if (diffMs < 0) return "just now"
const seconds = Math.floor(diffMs / 1000)
if (seconds < 60) return `${seconds}s ago`
const minutes = Math.floor(seconds / 60)
if (minutes < 60) return `${minutes}m ago`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h ${minutes % 60}m ago`
return new Date(isoString).toLocaleString()
}
function signalHealthBadge(health: SignalHealth | null) {
if (!health) return null
const colors: Record<string, string> = {
good: "text-emerald-500 bg-emerald-500/10 border-emerald-500/20",
fair: "text-amber-500 bg-amber-500/10 border-amber-500/20",
poor: "text-red-500 bg-red-500/10 border-red-500/20",
}
return (
<div>
<div>
<h1 className="text-2xl truncate max-w-sm">
Hi, {currentUser?.full_name || currentUser?.email} 👋
</h1>
<p className="text-muted-foreground">
Welcome back, nice to see you again!!!
</p>
<Badge variant="outline" className={colors[health.level] ?? ""}>
<Signal className="size-3" />
{health.level === "good"
? "Good"
: health.level === "fair"
? "Fair"
: "Poor"}{" "}
({health.score}%)
</Badge>
)
}
function projectCoord(
lat: number,
lon: number,
useGcj02: boolean,
): [number, number] {
if (!useGcj02) return [lat, lon]
return wgs84ToGcj02(lat, lon)
}
/* ---------- map sub-components ---------- */
function FitBoundsToDevices({
coordinates,
}: {
coordinates: Array<[number, number]>
}) {
const map = useMap()
const hasFitted = useRef(false)
useEffect(() => {
if (coordinates.length === 0 || hasFitted.current) return
hasFitted.current = true
if (coordinates.length === 1) {
map.setView(coordinates[0], 14, { animate: true })
return
}
map.fitBounds(coordinates, { padding: [40, 40] })
}, [coordinates, map])
return null
}
/** Badges-only overlay — lives inside MapContainer, no dropdown needed */
function MapStatusBadges({
isLive,
status,
deviceCount,
disconnectedDuration,
}: {
isLive: boolean
status: ConnectionState
deviceCount: number
disconnectedDuration: number
}) {
return (
<div
className="leaflet-bottom leaflet-left ml-3 mb-5"
style={{ pointerEvents: "auto", zIndex: 800 }}
>
<div className="leaflet-control flex items-center gap-2">
<Badge
variant="outline"
className={`backdrop-blur-md bg-background/80 shadow-sm ${
isLive
? "text-emerald-500 border-emerald-500/30"
: "text-amber-500 border-amber-500/30"
}`}
>
{isLive && (
<span className="relative mr-1.5 flex size-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
<span className="relative inline-flex size-2 rounded-full bg-emerald-500" />
</span>
)}
{!isLive && <Radio className="size-3" />}
{isLive
? "Live"
: status === "retrying"
? `Reconnecting${disconnectedDuration > 0 ? ` (${disconnectedDuration}s)` : ""}`
: "Connecting"}
</Badge>
<Badge
variant="outline"
className="backdrop-blur-md bg-background/80 shadow-sm"
>
Devices: {deviceCount}
</Badge>
</div>
</div>
)
}
/** FitBounds helper — must live inside MapContainer to call useMap() */
function FitBoundsControl({
coordinates,
}: {
coordinates: Array<[number, number]>
}) {
const map = useMap()
const fitAll = useCallback(() => {
if (!coordinates.length) return
if (coordinates.length === 1) {
map.setView(coordinates[0], 14, { animate: true })
} else {
map.fitBounds(coordinates, { padding: [40, 40] })
}
}, [coordinates, map])
const focusDevice = useCallback(
(coordinate: [number, number]) => {
const maxZoom = map.getMaxZoom()
const targetZoom = Number.isFinite(maxZoom) ? maxZoom : 18
map.setView(coordinate, targetZoom, { animate: true })
},
[map],
)
useEffect(() => {
const ref = window as unknown as MapControlBridge
ref.__mapFitAll = fitAll
ref.__mapFocusDevice = focusDevice
return () => {
if (ref.__mapFitAll === fitAll) {
delete ref.__mapFitAll
}
if (ref.__mapFocusDevice === focusDevice) {
delete ref.__mapFocusDevice
}
}
}, [fitAll, focusDevice])
return null
}
/* ---------- WebSocket hook ---------- */
function useTelemetryWS() {
const [snapshot, setSnapshot] = useState<TelemetrySnapshot | null>(null)
const [status, setStatus] = useState<ConnectionState>("connecting")
const [disconnectedAt, setDisconnectedAt] = useState<number | null>(null)
const retryCount = useRef(0)
const lastMessageAt = useRef<number>(0)
useEffect(() => {
const token = localStorage.getItem("access_token")
if (!token) {
setStatus("retrying")
return
}
let reconnectTimer: number | undefined
let staleTimer: number | undefined
let socket: WebSocket | undefined
let shouldReconnect = true
const STALE_TIMEOUT_MS = 10_000
const resetStaleTimer = () => {
if (staleTimer) window.clearTimeout(staleTimer)
staleTimer = window.setTimeout(() => {
socket?.close()
}, STALE_TIMEOUT_MS)
}
const connect = () => {
setStatus((prev) => (prev === "live" ? prev : "connecting"))
socket = new WebSocket(buildTelemetryWsUrl(token))
socket.onopen = () => {
setStatus("live")
setDisconnectedAt(null)
retryCount.current = 0
lastMessageAt.current = Date.now()
resetStaleTimer()
}
socket.onmessage = (event) => {
try {
const payload = JSON.parse(event.data) as TelemetrySnapshot
setSnapshot(payload)
setStatus("live")
setDisconnectedAt(null)
lastMessageAt.current = Date.now()
resetStaleTimer()
} catch {
// ignore malformed messages
}
}
socket.onclose = () => {
if (staleTimer) window.clearTimeout(staleTimer)
if (!shouldReconnect) return
setStatus("retrying")
setDisconnectedAt((prev) => prev ?? Date.now())
const backoffMs = Math.min(2000 * 2 ** retryCount.current, 30000)
retryCount.current++
reconnectTimer = window.setTimeout(connect, backoffMs)
}
socket.onerror = () => {
socket?.close()
}
}
connect()
return () => {
shouldReconnect = false
if (reconnectTimer) window.clearTimeout(reconnectTimer)
if (staleTimer) window.clearTimeout(staleTimer)
if (socket) socket.close()
}
}, [])
return { snapshot, status, disconnectedAt }
}
/* ---------- Dashboard ---------- */
function Dashboard() {
const { snapshot, status, disconnectedAt } = useTelemetryWS()
const [baseMap, setBaseMap] = useState<BaseMapType>("amap")
const [activeDeviceId, setActiveDeviceId] = useState<string>("")
const [isPanelExpanded, setIsPanelExpanded] = useState(true)
const activeDeviceIdRef = useRef(activeDeviceId)
activeDeviceIdRef.current = activeDeviceId
const isGcj02 = BASE_MAPS[baseMap].gcj02
const devicesWithCoords = useMemo(
() =>
(snapshot?.devices ?? []).filter(
(device) =>
device.lat !== null &&
device.lon !== null &&
device.lat >= -90 &&
device.lat <= 90 &&
device.lon >= -180 &&
device.lon <= 180,
),
[snapshot],
)
useEffect(() => {
if (!devicesWithCoords.length) {
if (activeDeviceIdRef.current) setActiveDeviceId("")
return
}
const exists = devicesWithCoords.some(
(d) => d.device_id === activeDeviceIdRef.current,
)
if (!exists && !activeDeviceIdRef.current) {
setActiveDeviceId(devicesWithCoords[0].device_id)
}
}, [devicesWithCoords])
const activeDevice = useMemo(
() =>
devicesWithCoords.find((d) => d.device_id === activeDeviceId) ??
devicesWithCoords[0] ??
null,
[devicesWithCoords, activeDeviceId],
)
const mapPoints = useMemo(
() =>
devicesWithCoords.map((d) =>
projectCoord(d.lat as number, d.lon as number, isGcj02),
),
[devicesWithCoords, isGcj02],
)
const handleDeviceClick = useCallback(
(deviceId: string, coordinate: [number, number]) => {
setActiveDeviceId(deviceId)
const mapBridge = window as unknown as MapControlBridge
mapBridge.__mapFocusDevice?.(coordinate)
},
[],
)
const isLive = status === "live"
// Disconnected time ticker
const [, forceUpdate] = useState(0)
useEffect(() => {
if (!disconnectedAt) return
const interval = window.setInterval(() => forceUpdate((n) => n + 1), 1000)
return () => window.clearInterval(interval)
}, [disconnectedAt])
const disconnectedDuration = disconnectedAt
? Math.floor((Date.now() - disconnectedAt) / 1000)
: 0
return (
<div className="flex flex-col h-full bg-background overflow-hidden">
{/* Map area — fills available space */}
<div className="relative flex-1 min-h-0">
{/* Controls live OUTSIDE MapContainer to avoid Leaflet z-index stacking issues */}
<div className="absolute top-3 right-3 z-9999 flex flex-col gap-2 pointer-events-auto">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="secondary"
size="icon"
className="h-8 w-8 shadow-md backdrop-blur-md bg-background/80"
>
<Layers className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="z-10001">
{(
Object.entries(BASE_MAPS) as Array<
[BaseMapType, (typeof BASE_MAPS)[BaseMapType]]
>
).map(([key, provider]) => (
<DropdownMenuItem key={key} onClick={() => setBaseMap(key)}>
{provider.label} {baseMap === key && "✓"}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="secondary"
size="icon"
className="h-8 w-8 shadow-md backdrop-blur-md bg-background/80"
title="Fit all devices"
onClick={() =>
(window as unknown as Record<string, () => void>).__mapFitAll?.()
}
>
<Maximize className="size-4" />
</Button>
</div>
<div className="h-full w-full">
<MapContainer
center={DEFAULT_CENTER}
className="h-full w-full"
scrollWheelZoom
zoom={DEFAULT_ZOOM}
>
<TileLayer
key={baseMap}
attribution={BASE_MAPS[baseMap].attribution}
subdomains={BASE_MAPS[baseMap].subdomains}
url={BASE_MAPS[baseMap].url}
/>
<FitBoundsToDevices coordinates={mapPoints} />
<FitBoundsControl coordinates={mapPoints} />
<MapStatusBadges
isLive={isLive}
status={status}
deviceCount={devicesWithCoords.length}
disconnectedDuration={disconnectedDuration}
/>
{devicesWithCoords.map((device) => {
const isOffline = checkIsOffline(device.last_event_time)
const isSelected = activeDeviceId === device.device_id
const projectedCoordinate = projectCoord(
device.lat as number,
device.lon as number,
isGcj02,
)
const [projLat, projLon] = projectedCoordinate
const color = deviceMarkerColor(device.fix_status, isOffline)
const icon = buildDeviceIcon(color, isSelected, isOffline)
return (
<Marker
position={[projLat, projLon]}
icon={icon}
eventHandlers={{
click: () =>
handleDeviceClick(device.device_id, projectedCoordinate),
}}
key={device.device_id}
>
<Popup>
<div className="space-y-1 text-sm font-sans min-w-[200px]">
<div className="font-semibold text-base mb-2 border-b pb-1">
{device.device_id}
{isOffline && (
<span className="ml-2 text-xs text-rose-500 font-medium tracking-wide">
(Offline)
</span>
)}
</div>
<div className="grid grid-cols-[80px_1fr] gap-x-2 gap-y-0.5">
<span className="text-muted-foreground">Lat:</span>
<span>{formatCoord(device.lat)}</span>
<span className="text-muted-foreground">Lon:</span>
<span>{formatCoord(device.lon)}</span>
<span className="text-muted-foreground">Alt:</span>
<span>
{device.alt_m !== null
? `${device.alt_m.toFixed(1)} m`
: "-"}
</span>
<span className="text-muted-foreground">Fix:</span>
<span>{getFixStatusLabel(device.fix_status)}</span>
<span className="text-muted-foreground">Sats:</span>
<span>{device.satellites_used ?? "-"}</span>
</div>
<div className="text-xs text-muted-foreground pt-2 mt-2 border-t">
Last seen:{" "}
{device.last_event_time
? new Date(device.last_event_time).toLocaleString()
: "Unknown"}
</div>
</div>
</Popup>
</Marker>
)
})}
</MapContainer>
</div>
{/* Selected Device Panel Overlay */}
<div
className={`absolute bottom-6 right-6 z-9999 w-80 max-w-[calc(100%-3rem)] transition-all duration-300 ${isPanelExpanded ? "opacity-80" : "opacity-100"}`}
>
<Card className="shadow-lg border bg-background/50 backdrop-blur-md overflow-hidden py-0 gap-0">
<CardHeader
className={`flex flex-row items-center justify-between space-y-0 cursor-pointer select-none transition-all duration-300 ${
isPanelExpanded ? "p-3 min-h-11" : "px-3 py-0 min-h-0"
}`}
onClick={() => setIsPanelExpanded(!isPanelExpanded)}
>
<CardTitle className="font-semibold flex items-center gap-2 text-sm">
Selected Device
{activeDevice?.signal_health &&
!isPanelExpanded &&
signalHealthBadge(activeDevice.signal_health)}
</CardTitle>
<div className="flex items-center gap-1">
{activeDevice?.signal_health &&
isPanelExpanded &&
signalHealthBadge(activeDevice.signal_health)}
<Button
variant="ghost"
size="icon"
className="h-6 w-6 hover:bg-transparent"
>
{isPanelExpanded ? (
<ChevronDown className="size-4" />
) : (
<ChevronUp className="size-4" />
)}
</Button>
</div>
</CardHeader>
{isPanelExpanded && (
<CardContent className="p-3 pt-0 grid grid-cols-2 gap-x-4 gap-y-3 text-[11px]">
<div className="col-span-2">
<p className="text-muted-foreground mb-0.5">Device ID</p>
<p className="font-medium break-all text-xs">
{activeDevice?.device_id ?? "-"}
</p>
</div>
<div>
<p className="text-muted-foreground mb-0.5">Latitude</p>
<p className="font-medium font-mono">
{formatCoord(activeDevice?.lat ?? null)}
</p>
</div>
<div>
<p className="text-muted-foreground mb-0.5">Longitude</p>
<p className="font-medium font-mono">
{formatCoord(activeDevice?.lon ?? null)}
</p>
</div>
<div>
<p className="text-muted-foreground mb-0.5 flex items-center gap-1">
<Mountain className="size-3" />
Altitude
</p>
<p className="font-medium font-mono">
{activeDevice?.alt_m !== null &&
activeDevice?.alt_m !== undefined
? `${activeDevice.alt_m.toFixed(1)} m`
: "-"}
</p>
</div>
<div>
<p className="text-muted-foreground mb-0.5">Fix Status</p>
<p
className={`font-medium ${getFixStatusColor(activeDevice?.fix_status ?? null)}`}
>
{getFixStatusLabel(activeDevice?.fix_status ?? null)}
</p>
</div>
<div>
<p className="text-muted-foreground mb-0.5 flex items-center gap-1">
<Satellite className="size-3" />
Satellites
</p>
<p className="font-medium font-mono">
{activeDevice?.satellites_used ?? "-"}
</p>
</div>
<div>
<p className="text-muted-foreground mb-0.5 flex items-center gap-1">
<Signal className="size-3" />
SNR
</p>
<p className="font-medium font-mono">
{activeDevice?.signal_health
? `${activeDevice.signal_health.score}%`
: "-"}
</p>
</div>
<div className="col-span-2">
<p className="text-muted-foreground mb-0.5 flex items-center gap-1">
<Clock className="size-3" />
Last Seen
</p>
<p className="font-medium">
{timeAgo(activeDevice?.last_event_time ?? null)}
</p>
</div>
</CardContent>
)}
</Card>
</div>
</div>
</div>
)

View File

@@ -36,8 +36,12 @@ function LocationsTableContent() {
<div className="rounded-full bg-muted p-4 mb-4">
<Search className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="text-lg font-semibold">You don't have any locations yet</h3>
<p className="text-muted-foreground">Add a new location to get started</p>
<h3 className="text-lg font-semibold">
You don't have any locations yet
</h3>
<p className="text-muted-foreground">
Add a new location to get started
</p>
</div>
)
}
@@ -59,7 +63,9 @@ function Locations() {
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Locations</h1>
<p className="text-muted-foreground">Create and manage your locations</p>
<p className="text-muted-foreground">
Create and manage your locations
</p>
</div>
<AddLocation />
</div>

View File

@@ -0,0 +1,411 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { createFileRoute } from "@tanstack/react-router"
import type { ColumnDef } from "@tanstack/react-table"
import { Search } from "lucide-react"
import { useMemo, useState } from "react"
import { type MqttRawDaySummary, MqttRawService } from "@/client"
import { DataTable } from "@/components/Common/DataTable"
import PendingSkeleton from "@/components/Pending/PendingSkeleton"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { LoadingButton } from "@/components/ui/loading-button"
import { Slider } from "@/components/ui/slider"
import useCustomToast from "@/hooks/useCustomToast"
const MINUTE_STEPS_PER_DAY = 24 * 60
export const Route = createFileRoute("/_layout/mqtt-raw")({
component: MqttRawPage,
head: () => ({
meta: [
{
title: "MQTT Raw Data - FastAPI Template",
},
],
}),
})
function toUtc8Date(value: Date): Date {
const utcMillis = value.getTime() + value.getTimezoneOffset() * 60 * 1000
return new Date(utcMillis + 8 * 60 * 60 * 1000)
}
function todayIsoDateUtc8(): string {
const now = new Date()
return toUtc8Date(now).toISOString().slice(0, 10)
}
function formatDateTime(value: string | null | undefined): string {
if (!value) {
return "-"
}
const utc8 = toUtc8Date(new Date(value))
return `${utc8.toISOString().slice(0, 19).replace("T", " ")} (UTC+8)`
}
function formatMinuteLabel(minute: number): string {
const clamped = Math.max(0, Math.min(minute, MINUTE_STEPS_PER_DAY))
const hours = Math.floor(clamped / 60)
const minutes = clamped % 60
return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`
}
function triggerFileDownload(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob)
const link = document.createElement("a")
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
function toDownloadBlob(value: unknown): Blob {
if (value instanceof Blob) {
return value
}
if (value instanceof ArrayBuffer) {
return new Blob([value], { type: "application/octet-stream" })
}
if (typeof value === "string") {
return new Blob([value], { type: "application/octet-stream" })
}
return new Blob([JSON.stringify(value)], { type: "application/octet-stream" })
}
function MqttRawPage() {
const queryClient = useQueryClient()
const { showSuccessToast, showErrorToast } = useCustomToast()
const [selectedDay, setSelectedDay] = useState(todayIsoDateUtc8())
const [rangeStartMinute, setRangeStartMinute] = useState(0)
const [rangeEndMinute, setRangeEndMinute] = useState(MINUTE_STEPS_PER_DAY)
const [deleteDayOpen, setDeleteDayOpen] = useState(false)
const [deleteAllOpen, setDeleteAllOpen] = useState(false)
const [deleteAllConfirmText, setDeleteAllConfirmText] = useState("")
const {
data: daysResponse,
isPending,
isError,
error,
} = useQuery({
queryFn: () => MqttRawService.listDays({ limit: 200, skip: 0 }),
queryKey: ["mqtt-raw-days"],
})
const days = daysResponse?.data ?? []
const isFullDayRange =
rangeStartMinute === 0 && rangeEndMinute === MINUTE_STEPS_PER_DAY
const startSeconds = rangeStartMinute * 60
const endSeconds = rangeEndMinute * 60
const downloadMutation = useMutation({
mutationFn: async () => {
const data = await MqttRawService.download({
day: selectedDay,
startSeconds: isFullDayRange ? undefined : startSeconds,
endSeconds: isFullDayRange ? undefined : endSeconds,
})
const filename = isFullDayRange
? `mqtt_raw_${selectedDay}.txt`
: `mqtt_raw_${selectedDay}_${startSeconds}_${endSeconds}.txt`
return {
blob: toDownloadBlob(data),
filename,
}
},
onSuccess: ({ blob, filename }) => {
triggerFileDownload(blob, filename)
showSuccessToast("Raw MQTT data downloaded")
},
onError: (downloadError) => {
const message =
downloadError instanceof Error
? downloadError.message
: "Download failed"
showErrorToast(message)
},
})
const deleteDayMutation = useMutation({
mutationFn: () => MqttRawService.deleteDay({ day: selectedDay }),
onSuccess: (result) => {
showSuccessToast(result.message)
setDeleteDayOpen(false)
queryClient.invalidateQueries({ queryKey: ["mqtt-raw-days"] })
},
onError: (deleteError) => {
const message =
deleteError instanceof Error ? deleteError.message : "Delete failed"
showErrorToast(message)
},
})
const deleteAllMutation = useMutation({
mutationFn: () => MqttRawService.deleteAll({ confirm: true }),
onSuccess: (result) => {
showSuccessToast(result.message)
setDeleteAllConfirmText("")
setDeleteAllOpen(false)
queryClient.invalidateQueries({ queryKey: ["mqtt-raw-days"] })
},
onError: (deleteError) => {
const message =
deleteError instanceof Error ? deleteError.message : "Delete failed"
showErrorToast(message)
},
})
const columns = useMemo<ColumnDef<MqttRawDaySummary>[]>(
() => [
{
accessorKey: "day",
header: "Day (UTC+8)",
cell: ({ row }) => (
<Button
className="font-mono px-0"
onClick={() => setSelectedDay(row.original.day)}
variant="link"
>
{row.original.day}
</Button>
),
},
{
accessorKey: "message_count",
header: "Messages",
cell: ({ row }) => (
<span className="font-medium">{row.original.message_count}</span>
),
},
{
accessorKey: "first_received_at",
header: "First Message",
cell: ({ row }) => formatDateTime(row.original.first_received_at),
},
{
accessorKey: "last_received_at",
header: "Last Message",
cell: ({ row }) => formatDateTime(row.original.last_received_at),
},
],
[],
)
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">MQTT Raw Data</h1>
<p className="text-muted-foreground">
Download saved payload bytes by UTC+8 day/time range and clean old
data.
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Download</CardTitle>
<CardDescription>
Pick a UTC+8 day, then download the full day or a custom time range.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<p className="text-sm font-medium">Date (UTC+8)</p>
<Input
onChange={(event) => setSelectedDay(event.target.value)}
type="date"
value={selectedDay}
/>
</div>
<div className="flex items-end gap-2">
<Button
onClick={() => {
setRangeStartMinute(0)
setRangeEndMinute(MINUTE_STEPS_PER_DAY)
}}
type="button"
variant="outline"
>
Full Day
</Button>
<LoadingButton
loading={downloadMutation.isPending}
onClick={() => downloadMutation.mutate()}
type="button"
>
Download
</LoadingButton>
</div>
</div>
<div className="space-y-3 rounded-lg border p-4">
<div className="flex items-center justify-between text-sm">
<span className="font-medium">
Start: {formatMinuteLabel(rangeStartMinute)}
</span>
<span className="font-medium">
End: {formatMinuteLabel(rangeEndMinute)}
</span>
</div>
<Slider
max={MINUTE_STEPS_PER_DAY}
min={0}
minStepsBetweenThumbs={1}
onValueChange={(values) => {
if (values.length !== 2) {
return
}
setRangeStartMinute(values[0])
setRangeEndMinute(values[1])
}}
step={1}
value={[rangeStartMinute, rangeEndMinute]}
/>
<p className="text-xs text-muted-foreground">
{isFullDayRange
? "Current selection: full day (00:00-24:00, UTC+8)."
: `Current selection: ${formatMinuteLabel(rangeStartMinute)}-${formatMinuteLabel(rangeEndMinute)}.`}
</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Delete</CardTitle>
<CardDescription>
Delete data for one UTC+8 day or delete all saved MQTT raw data.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap gap-3">
<Button
onClick={() => setDeleteDayOpen(true)}
type="button"
variant="destructive"
>
Delete {selectedDay}
</Button>
<Button
onClick={() => setDeleteAllOpen(true)}
type="button"
variant="destructive"
>
Delete All Data
</Button>
</CardContent>
</Card>
<div>
<h2 className="text-lg font-semibold mb-2">Saved Days (UTC+8)</h2>
<p className="text-sm text-muted-foreground mb-4">
Click a day to load it into the controls above.
</p>
{isPending ? (
<PendingSkeleton />
) : isError ? (
<div className="text-sm text-destructive">
{error instanceof Error
? error.message
: "Failed to load data days."}
</div>
) : days.length === 0 ? (
<div className="flex flex-col items-center justify-center text-center py-12 rounded-lg border">
<div className="rounded-full bg-muted p-4 mb-4">
<Search className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="text-lg font-semibold">No raw data saved yet</h3>
<p className="text-muted-foreground">
MQTT payloads will appear here after ingestion.
</p>
</div>
) : (
<DataTable columns={columns} data={days} />
)}
</div>
<Dialog onOpenChange={setDeleteDayOpen} open={deleteDayOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Delete Day Data</DialogTitle>
<DialogDescription>
Delete all MQTT raw data for {selectedDay} (UTC+8). This action
cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button disabled={deleteDayMutation.isPending} variant="outline">
Cancel
</Button>
</DialogClose>
<LoadingButton
loading={deleteDayMutation.isPending}
onClick={() => deleteDayMutation.mutate()}
type="button"
variant="destructive"
>
Delete Day
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog onOpenChange={setDeleteAllOpen} open={deleteAllOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Delete All MQTT Raw Data</DialogTitle>
<DialogDescription>
This will delete all saved raw MQTT payloads. To confirm, type
<span className="font-mono font-semibold"> DELETE ALL</span>.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Input
onChange={(event) => setDeleteAllConfirmText(event.target.value)}
placeholder="DELETE ALL"
value={deleteAllConfirmText}
/>
</div>
<DialogFooter>
<DialogClose asChild>
<Button disabled={deleteAllMutation.isPending} variant="outline">
Cancel
</Button>
</DialogClose>
<LoadingButton
disabled={deleteAllConfirmText !== "DELETE ALL"}
loading={deleteAllMutation.isPending}
onClick={() => deleteAllMutation.mutate()}
type="button"
variant="destructive"
>
Delete Everything
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

17
uv.lock generated
View File

@@ -69,6 +69,7 @@ dependencies = [
{ name = "fastapi", extra = ["standard"] },
{ name = "httpx" },
{ name = "jinja2" },
{ name = "paho-mqtt" },
{ name = "psycopg", extra = ["binary"] },
{ name = "pwdlib", extra = ["argon2", "bcrypt"] },
{ name = "pydantic" },
@@ -97,6 +98,7 @@ requires-dist = [
{ name = "fastapi", extras = ["standard"], specifier = ">=0.114.2,<1.0.0" },
{ name = "httpx", specifier = ">=0.25.1,<1.0.0" },
{ name = "jinja2", specifier = ">=3.1.4,<4.0.0" },
{ name = "paho-mqtt", specifier = ">=2.1.0,<3.0.0" },
{ name = "psycopg", extras = ["binary"], specifier = ">=3.1.13,<4.0.0" },
{ name = "pwdlib", extras = ["argon2", "bcrypt"], specifier = ">=0.3.0" },
{ name = "pydantic", specifier = ">2.0" },
@@ -811,7 +813,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/32/6a/33d1702184d94106d3cdd7bfb788e19723206fce152e303473ca3b946c7b/greenlet-3.3.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:6f8496d434d5cb2dce025773ba5597f71f5410ae499d5dd9533e0653258cdb3d", size = 273658, upload-time = "2025-12-04T14:23:37.494Z" },
{ url = "https://files.pythonhosted.org/packages/d6/b7/2b5805bbf1907c26e434f4e448cd8b696a0b71725204fa21a211ff0c04a7/greenlet-3.3.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b96dc7eef78fd404e022e165ec55327f935b9b52ff355b067eb4a0267fc1cffb", size = 574810, upload-time = "2025-12-04T14:50:04.154Z" },
{ url = "https://files.pythonhosted.org/packages/94/38/343242ec12eddf3d8458c73f555c084359883d4ddc674240d9e61ec51fd6/greenlet-3.3.0-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73631cd5cccbcfe63e3f9492aaa664d278fda0ce5c3d43aeda8e77317e38efbd", size = 586248, upload-time = "2025-12-04T14:57:39.35Z" },
{ url = "https://files.pythonhosted.org/packages/f0/d0/0ae86792fb212e4384041e0ef8e7bc66f59a54912ce407d26a966ed2914d/greenlet-3.3.0-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b299a0cb979f5d7197442dccc3aee67fce53500cd88951b7e6c35575701c980b", size = 597403, upload-time = "2025-12-04T15:07:10.831Z" },
{ url = "https://files.pythonhosted.org/packages/b6/a8/15d0aa26c0036a15d2659175af00954aaaa5d0d66ba538345bd88013b4d7/greenlet-3.3.0-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7dee147740789a4632cace364816046e43310b59ff8fb79833ab043aefa72fd5", size = 586910, upload-time = "2025-12-04T14:25:59.705Z" },
{ url = "https://files.pythonhosted.org/packages/e1/9b/68d5e3b7ccaba3907e5532cf8b9bf16f9ef5056a008f195a367db0ff32db/greenlet-3.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:39b28e339fc3c348427560494e28d8a6f3561c8d2bcf7d706e1c624ed8d822b9", size = 1547206, upload-time = "2025-12-04T15:04:21.027Z" },
{ url = "https://files.pythonhosted.org/packages/66/bd/e3086ccedc61e49f91e2cfb5ffad9d8d62e5dc85e512a6200f096875b60c/greenlet-3.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b3c374782c2935cc63b2a27ba8708471de4ad1abaa862ffdb1ef45a643ddbb7d", size = 1613359, upload-time = "2025-12-04T14:27:26.548Z" },
@@ -819,7 +820,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1f/cb/48e964c452ca2b92175a9b2dca037a553036cb053ba69e284650ce755f13/greenlet-3.3.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:e29f3018580e8412d6aaf5641bb7745d38c85228dacf51a73bd4e26ddf2a6a8e", size = 274908, upload-time = "2025-12-04T14:23:26.435Z" },
{ url = "https://files.pythonhosted.org/packages/28/da/38d7bff4d0277b594ec557f479d65272a893f1f2a716cad91efeb8680953/greenlet-3.3.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a687205fb22794e838f947e2194c0566d3812966b41c78709554aa883183fb62", size = 577113, upload-time = "2025-12-04T14:50:05.493Z" },
{ url = "https://files.pythonhosted.org/packages/3c/f2/89c5eb0faddc3ff014f1c04467d67dee0d1d334ab81fadbf3744847f8a8a/greenlet-3.3.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4243050a88ba61842186cb9e63c7dfa677ec146160b0efd73b855a3d9c7fcf32", size = 590338, upload-time = "2025-12-04T14:57:41.136Z" },
{ url = "https://files.pythonhosted.org/packages/80/d7/db0a5085035d05134f8c089643da2b44cc9b80647c39e93129c5ef170d8f/greenlet-3.3.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:670d0f94cd302d81796e37299bcd04b95d62403883b24225c6b5271466612f45", size = 601098, upload-time = "2025-12-04T15:07:11.898Z" },
{ url = "https://files.pythonhosted.org/packages/dc/a6/e959a127b630a58e23529972dbc868c107f9d583b5a9f878fb858c46bc1a/greenlet-3.3.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cb3a8ec3db4a3b0eb8a3c25436c2d49e3505821802074969db017b87bc6a948", size = 590206, upload-time = "2025-12-04T14:26:01.254Z" },
{ url = "https://files.pythonhosted.org/packages/48/60/29035719feb91798693023608447283b266b12efc576ed013dd9442364bb/greenlet-3.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2de5a0b09eab81fc6a382791b995b1ccf2b172a9fec934747a7a23d2ff291794", size = 1550668, upload-time = "2025-12-04T15:04:22.439Z" },
{ url = "https://files.pythonhosted.org/packages/0a/5f/783a23754b691bfa86bd72c3033aa107490deac9b2ef190837b860996c9f/greenlet-3.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4449a736606bd30f27f8e1ff4678ee193bc47f6ca810d705981cfffd6ce0d8c5", size = 1615483, upload-time = "2025-12-04T14:27:28.083Z" },
@@ -827,7 +827,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f8/0a/a3871375c7b9727edaeeea994bfff7c63ff7804c9829c19309ba2e058807/greenlet-3.3.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:b01548f6e0b9e9784a2c99c5651e5dc89ffcbe870bc5fb2e5ef864e9cc6b5dcb", size = 276379, upload-time = "2025-12-04T14:23:30.498Z" },
{ url = "https://files.pythonhosted.org/packages/43/ab/7ebfe34dce8b87be0d11dae91acbf76f7b8246bf9d6b319c741f99fa59c6/greenlet-3.3.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349345b770dc88f81506c6861d22a6ccd422207829d2c854ae2af8025af303e3", size = 597294, upload-time = "2025-12-04T14:50:06.847Z" },
{ url = "https://files.pythonhosted.org/packages/a4/39/f1c8da50024feecd0793dbd5e08f526809b8ab5609224a2da40aad3a7641/greenlet-3.3.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e8e18ed6995e9e2c0b4ed264d2cf89260ab3ac7e13555b8032b25a74c6d18655", size = 607742, upload-time = "2025-12-04T14:57:42.349Z" },
{ url = "https://files.pythonhosted.org/packages/77/cb/43692bcd5f7a0da6ec0ec6d58ee7cddb606d055ce94a62ac9b1aa481e969/greenlet-3.3.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c024b1e5696626890038e34f76140ed1daf858e37496d33f2af57f06189e70d7", size = 622297, upload-time = "2025-12-04T15:07:13.552Z" },
{ url = "https://files.pythonhosted.org/packages/75/b0/6bde0b1011a60782108c01de5913c588cf51a839174538d266de15e4bf4d/greenlet-3.3.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:047ab3df20ede6a57c35c14bf5200fcf04039d50f908270d3f9a7a82064f543b", size = 609885, upload-time = "2025-12-04T14:26:02.368Z" },
{ url = "https://files.pythonhosted.org/packages/49/0e/49b46ac39f931f59f987b7cd9f34bfec8ef81d2a1e6e00682f55be5de9f4/greenlet-3.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2d9ad37fc657b1102ec880e637cccf20191581f75c64087a549e66c57e1ceb53", size = 1567424, upload-time = "2025-12-04T15:04:23.757Z" },
{ url = "https://files.pythonhosted.org/packages/05/f5/49a9ac2dff7f10091935def9165c90236d8f175afb27cbed38fb1d61ab6b/greenlet-3.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83cd0e36932e0e7f36a64b732a6f60c2fc2df28c351bae79fbaf4f8092fe7614", size = 1636017, upload-time = "2025-12-04T14:27:29.688Z" },
@@ -835,7 +834,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/02/2f/28592176381b9ab2cafa12829ba7b472d177f3acc35d8fbcf3673d966fff/greenlet-3.3.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a1e41a81c7e2825822f4e068c48cb2196002362619e2d70b148f20a831c00739", size = 275140, upload-time = "2025-12-04T14:23:01.282Z" },
{ url = "https://files.pythonhosted.org/packages/2c/80/fbe937bf81e9fca98c981fe499e59a3f45df2a04da0baa5c2be0dca0d329/greenlet-3.3.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f515a47d02da4d30caaa85b69474cec77b7929b2e936ff7fb853d42f4bf8808", size = 599219, upload-time = "2025-12-04T14:50:08.309Z" },
{ url = "https://files.pythonhosted.org/packages/c2/ff/7c985128f0514271b8268476af89aee6866df5eec04ac17dcfbc676213df/greenlet-3.3.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d2d9fd66bfadf230b385fdc90426fcd6eb64db54b40c495b72ac0feb5766c54", size = 610211, upload-time = "2025-12-04T14:57:43.968Z" },
{ url = "https://files.pythonhosted.org/packages/79/07/c47a82d881319ec18a4510bb30463ed6891f2ad2c1901ed5ec23d3de351f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30a6e28487a790417d036088b3bcb3f3ac7d8babaa7d0139edbaddebf3af9492", size = 624311, upload-time = "2025-12-04T15:07:14.697Z" },
{ url = "https://files.pythonhosted.org/packages/fd/8e/424b8c6e78bd9837d14ff7df01a9829fc883ba2ab4ea787d4f848435f23f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:087ea5e004437321508a8d6f20efc4cfec5e3c30118e1417ea96ed1d93950527", size = 612833, upload-time = "2025-12-04T14:26:03.669Z" },
{ url = "https://files.pythonhosted.org/packages/b5/ba/56699ff9b7c76ca12f1cdc27a886d0f81f2189c3455ff9f65246780f713d/greenlet-3.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab97cf74045343f6c60a39913fa59710e4bd26a536ce7ab2397adf8b27e67c39", size = 1567256, upload-time = "2025-12-04T15:04:25.276Z" },
{ url = "https://files.pythonhosted.org/packages/1e/37/f31136132967982d698c71a281a8901daf1a8fbab935dce7c0cf15f942cc/greenlet-3.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5375d2e23184629112ca1ea89a53389dddbffcf417dad40125713d88eb5f96e8", size = 1636483, upload-time = "2025-12-04T14:27:30.804Z" },
@@ -843,7 +841,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d7/7c/f0a6d0ede2c7bf092d00bc83ad5bafb7e6ec9b4aab2fbdfa6f134dc73327/greenlet-3.3.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:60c2ef0f578afb3c8d92ea07ad327f9a062547137afe91f38408f08aacab667f", size = 275671, upload-time = "2025-12-04T14:23:05.267Z" },
{ url = "https://files.pythonhosted.org/packages/44/06/dac639ae1a50f5969d82d2e3dd9767d30d6dbdbab0e1a54010c8fe90263c/greenlet-3.3.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a5d554d0712ba1de0a6c94c640f7aeba3f85b3a6e1f2899c11c2c0428da9365", size = 646360, upload-time = "2025-12-04T14:50:10.026Z" },
{ url = "https://files.pythonhosted.org/packages/e0/94/0fb76fe6c5369fba9bf98529ada6f4c3a1adf19e406a47332245ef0eb357/greenlet-3.3.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3a898b1e9c5f7307ebbde4102908e6cbfcb9ea16284a3abe15cab996bee8b9b3", size = 658160, upload-time = "2025-12-04T14:57:45.41Z" },
{ url = "https://files.pythonhosted.org/packages/93/79/d2c70cae6e823fac36c3bbc9077962105052b7ef81db2f01ec3b9bf17e2b/greenlet-3.3.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dcd2bdbd444ff340e8d6bdf54d2f206ccddbb3ccfdcd3c25bf4afaa7b8f0cf45", size = 671388, upload-time = "2025-12-04T15:07:15.789Z" },
{ url = "https://files.pythonhosted.org/packages/b8/14/bab308fc2c1b5228c3224ec2bf928ce2e4d21d8046c161e44a2012b5203e/greenlet-3.3.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5773edda4dc00e173820722711d043799d3adb4f01731f40619e07ea2750b955", size = 660166, upload-time = "2025-12-04T14:26:05.099Z" },
{ url = "https://files.pythonhosted.org/packages/4b/d2/91465d39164eaa0085177f61983d80ffe746c5a1860f009811d498e7259c/greenlet-3.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ac0549373982b36d5fd5d30beb8a7a33ee541ff98d2b502714a09f1169f31b55", size = 1615193, upload-time = "2025-12-04T15:04:27.041Z" },
{ url = "https://files.pythonhosted.org/packages/42/1b/83d110a37044b92423084d52d5d5a3b3a73cafb51b547e6d7366ff62eff1/greenlet-3.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d198d2d977460358c3b3a4dc844f875d1adb33817f0613f663a656f463764ccc", size = 1683653, upload-time = "2025-12-04T14:27:32.366Z" },
@@ -851,7 +848,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/66/bd6317bc5932accf351fc19f177ffba53712a202f9df10587da8df257c7e/greenlet-3.3.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d6ed6f85fae6cdfdb9ce04c9bf7a08d666cfcfb914e7d006f44f840b46741931", size = 282638, upload-time = "2025-12-04T14:25:20.941Z" },
{ url = "https://files.pythonhosted.org/packages/30/cf/cc81cb030b40e738d6e69502ccbd0dd1bced0588e958f9e757945de24404/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9125050fcf24554e69c4cacb086b87b3b55dc395a8b3ebe6487b045b2614388", size = 651145, upload-time = "2025-12-04T14:50:11.039Z" },
{ url = "https://files.pythonhosted.org/packages/9c/ea/1020037b5ecfe95ca7df8d8549959baceb8186031da83d5ecceff8b08cd2/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:87e63ccfa13c0a0f6234ed0add552af24cc67dd886731f2261e46e241608bee3", size = 654236, upload-time = "2025-12-04T14:57:47.007Z" },
{ url = "https://files.pythonhosted.org/packages/69/cc/1e4bae2e45ca2fa55299f4e85854606a78ecc37fead20d69322f96000504/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2662433acbca297c9153a4023fe2161c8dcfdcc91f10433171cf7e7d94ba2221", size = 662506, upload-time = "2025-12-04T15:07:16.906Z" },
{ url = "https://files.pythonhosted.org/packages/57/b9/f8025d71a6085c441a7eaff0fd928bbb275a6633773667023d19179fe815/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c6e9b9c1527a78520357de498b0e709fb9e2f49c3a513afd5a249007261911b", size = 653783, upload-time = "2025-12-04T14:26:06.225Z" },
{ url = "https://files.pythonhosted.org/packages/f6/c7/876a8c7a7485d5d6b5c6821201d542ef28be645aa024cfe1145b35c120c1/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:286d093f95ec98fdd92fcb955003b8a3d054b4e2cab3e2707a5039e7b50520fd", size = 1614857, upload-time = "2025-12-04T15:04:28.484Z" },
{ url = "https://files.pythonhosted.org/packages/4f/dc/041be1dff9f23dac5f48a43323cd0789cb798342011c19a248d9c9335536/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c10513330af5b8ae16f023e8ddbfb486ab355d04467c4679c5cfe4659975dd9", size = 1676034, upload-time = "2025-12-04T14:27:33.531Z" },
@@ -1355,6 +1351,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
]
[[package]]
name = "paho-mqtt"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/39/15/0a6214e76d4d32e7f663b109cf71fb22561c2be0f701d67f93950cd40542/paho_mqtt-2.1.0.tar.gz", hash = "sha256:12d6e7511d4137555a3f6ea167ae846af2c7357b10bc6fa4f7c3968fc1723834", size = 148848, upload-time = "2024-04-29T19:52:55.591Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c4/cb/00451c3cf31790287768bb12c6bec834f5d292eaf3022afc88e14b8afc94/paho_mqtt-2.1.0-py3-none-any.whl", hash = "sha256:6db9ba9b34ed5bc6b6e3812718c7e06e2fd7444540df2455d2c51bd58808feee", size = 67219, upload-time = "2024-04-29T19:52:48.345Z" },
]
[[package]]
name = "pathspec"
version = "1.0.3"