Compare commits
1 Commits
main
...
5d50401d40
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d50401d40 |
1
.copier/.copier-answers.yml.jinja
Normal file
@@ -0,0 +1 @@
|
||||
{{ _copier_answers|to_json -}}
|
||||
26
.copier/update_dotenv.py
Normal file
@@ -0,0 +1,26 @@
|
||||
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
@@ -13,8 +13,8 @@ FRONTEND_HOST=http://localhost:5173
|
||||
# Environment: local, staging, production
|
||||
ENVIRONMENT=local
|
||||
|
||||
PROJECT_NAME="SpatialHub"
|
||||
STACK_NAME=spatialhub
|
||||
PROJECT_NAME="Full Stack FastAPI Project"
|
||||
STACK_NAME=full-stack-fastapi-project
|
||||
|
||||
# Backend
|
||||
BACKEND_CORS_ORIGINS="http://localhost,http://localhost:5173,https://localhost,https://localhost:5173,http://localhost.tiangolo.com"
|
||||
@@ -43,10 +43,3 @@ 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
|
||||
|
||||
@@ -12,6 +12,7 @@ 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"
|
||||
@@ -41,18 +42,6 @@ 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
|
||||
|
||||
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- bt
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
@@ -22,6 +23,7 @@ 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 }}
|
||||
@@ -39,16 +41,6 @@ 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
|
||||
@@ -56,16 +48,6 @@ 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
|
||||
@@ -102,22 +84,5 @@ 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
|
||||
|
||||
98
.gitea/workflows/playwright.yml
Normal file
@@ -0,0 +1,98 @@
|
||||
name: Playwright Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- bt
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test-playwright:
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git clone "http://172.17.0.1:3000/${{ github.repository }}.git" .
|
||||
git checkout "${{ github.sha }}"
|
||||
|
||||
- name: Detect relevant file changes
|
||||
id: changes
|
||||
run: |
|
||||
set -euo pipefail
|
||||
CHANGED=false
|
||||
|
||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||
BASE="${{ github.base_ref }}"
|
||||
git fetch origin "+refs/heads/${BASE}:refs/remotes/origin/${BASE}" --depth=200 2>/dev/null \
|
||||
|| git fetch origin --depth=200
|
||||
if git rev-parse "origin/${BASE}" >/dev/null 2>&1; then
|
||||
FILES=$(git diff --name-only "origin/${BASE}...HEAD" || true)
|
||||
else
|
||||
FILES=$(git show --name-only --pretty=format: HEAD)
|
||||
fi
|
||||
else
|
||||
BEFORE="${{ github.event.before }}"
|
||||
if [ -z "${BEFORE}" ] || [ "${BEFORE}" = "0000000000000000000000000000000000000000" ]; then
|
||||
FILES=$(git show --name-only --pretty=format: HEAD)
|
||||
else
|
||||
FILES=$(git diff --name-only "${BEFORE}" "${{ github.sha }}" || true)
|
||||
fi
|
||||
fi
|
||||
|
||||
while IFS= read -r line; do
|
||||
[ -z "${line}" ] && continue
|
||||
case "${line}" in
|
||||
backend/*|frontend/*|.env|compose*|.gitea/workflows/playwright.yml|.github/workflows/playwright.yml)
|
||||
CHANGED=true
|
||||
break
|
||||
;;
|
||||
esac
|
||||
done <<< "${FILES}"
|
||||
|
||||
echo "changed=${CHANGED}" >> "${GITHUB_OUTPUT}"
|
||||
echo "Relevant changes for Playwright: ${CHANGED}"
|
||||
|
||||
- name: Skip Playwright (no relevant changes)
|
||||
if: steps.changes.outputs.changed != 'true'
|
||||
run: echo "Skipping Playwright — no changes under backend/, frontend/, compose, .env, or playwright workflows."
|
||||
|
||||
- name: Install uv and Bun
|
||||
if: steps.changes.outputs.changed == 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export PATH="${HOME}/.local/bin:${HOME}/.bun/bin:${PATH}"
|
||||
export UV_PYTHON_DOWNLOADS=automatic
|
||||
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y python3 python3-pip python3-venv curl unzip ca-certificates
|
||||
|
||||
python3 -m pip install --user --upgrade pip
|
||||
python3 -m pip install --user "uv>=0.4"
|
||||
|
||||
# Official Bun installer (not GitHub Actions)
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
|
||||
- name: Generate client, build images, run Playwright
|
||||
if: steps.changes.outputs.changed == 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export PATH="${HOME}/.local/bin:${HOME}/.bun/bin:${PATH}"
|
||||
export BUN_INSTALL="${HOME}/.bun"
|
||||
|
||||
(cd backend && uv sync)
|
||||
bun ci
|
||||
bash scripts/generate-client.sh
|
||||
|
||||
docker compose build
|
||||
docker compose down -v --remove-orphans || true
|
||||
docker compose run --rm playwright bunx playwright test \
|
||||
--fail-on-flaky-tests \
|
||||
--trace=retain-on-failure
|
||||
docker compose down -v --remove-orphans || true
|
||||
44
.gitea/workflows/test-backend.yml
Normal file
@@ -0,0 +1,44 @@
|
||||
name: Test Backend
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- bt
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
|
||||
jobs:
|
||||
test-backend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout and run backend tests
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export PATH="${HOME}/.local/bin:${PATH}"
|
||||
export UV_PYTHON_DOWNLOADS=automatic
|
||||
|
||||
git clone "http://172.17.0.1:3000/${{ github.repository }}.git" .
|
||||
git checkout "${{ github.sha }}"
|
||||
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y python3 python3-pip python3-venv ca-certificates
|
||||
|
||||
python3 -m pip install --user --upgrade pip
|
||||
python3 -m pip install --user "uv>=0.4"
|
||||
|
||||
docker compose down -v --remove-orphans || true
|
||||
docker compose up -d db mailcatcher
|
||||
|
||||
(cd backend && uv run bash scripts/prestart.sh)
|
||||
(cd backend && uv run bash scripts/tests-start.sh "Coverage for ${{ github.sha }}")
|
||||
|
||||
docker compose down -v --remove-orphans || true
|
||||
|
||||
if [ -d backend/htmlcov ]; then
|
||||
echo "Coverage HTML generated under backend/htmlcov (view on runner if needed)."
|
||||
fi
|
||||
|
||||
(cd backend && uv run coverage report --fail-under=90)
|
||||
31
.gitea/workflows/test-docker-compose.yml
Normal file
@@ -0,0 +1,31 @@
|
||||
name: Test Docker Compose
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- bt
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
|
||||
jobs:
|
||||
test-docker-compose:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout and verify compose stack
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
git clone "http://172.17.0.1:3000/${{ github.repository }}.git" .
|
||||
git checkout "${{ github.sha }}"
|
||||
|
||||
docker compose build
|
||||
docker compose down -v --remove-orphans || true
|
||||
docker compose up -d --wait backend frontend adminer
|
||||
|
||||
curl -fsS http://localhost:8000/api/v1/utils/health-check
|
||||
curl -fsS http://localhost:5173
|
||||
|
||||
docker compose down -v --remove-orphans || true
|
||||
2
.gitignore
vendored
@@ -8,5 +8,3 @@ node_modules/
|
||||
.DS_Store
|
||||
.env.production
|
||||
.venv/
|
||||
.playwright-cli/
|
||||
.worktrees/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Contributing
|
||||
|
||||
Thank you for your interest in contributing to the SpatialHub! 🙇
|
||||
Thank you for your interest in contributing to the Full Stack FastAPI Template! 🙇
|
||||
|
||||
## Discussions First
|
||||
|
||||
|
||||
69
README.md
@@ -1,4 +1,4 @@
|
||||
# SpatialHub
|
||||
# Full Stack FastAPI Template
|
||||
|
||||
<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,7 +22,8 @@
|
||||
- 📫 Email based password recovery.
|
||||
- 📬 [Mailcatcher](https://mailcatcher.me) for local email testing during development.
|
||||
- ✅ Tests with [Pytest](https://pytest.org).
|
||||
- 🚢 Deployment instructions using Docker Compose.
|
||||
- 📞 [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.
|
||||
- 🏭 CI (continuous integration) and CD (continuous deployment) based on GitHub Actions.
|
||||
|
||||
### Dashboard Login
|
||||
@@ -145,6 +146,66 @@ 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).
|
||||
@@ -161,7 +222,7 @@ Deployment docs: [deployment.md](./deployment.md).
|
||||
|
||||
General development docs: [development.md](./development.md).
|
||||
|
||||
This includes using Docker Compose and `.env` configurations.
|
||||
This includes using Docker Compose, custom local domains, `.env` configurations, etc.
|
||||
|
||||
## Release Notes
|
||||
|
||||
@@ -169,4 +230,4 @@ Check the file [release-notes.md](./release-notes.md).
|
||||
|
||||
## License
|
||||
|
||||
The SpatialHub is licensed under the terms of the MIT license.
|
||||
The Full Stack FastAPI Template is licensed under the terms of the MIT license.
|
||||
|
||||
@@ -170,40 +170,3 @@ 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
|
||||
```
|
||||
|
||||
@@ -1,223 +0,0 @@
|
||||
"""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")
|
||||
@@ -1,108 +0,0 @@
|
||||
"""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")
|
||||
@@ -1,60 +0,0 @@
|
||||
"""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,
|
||||
)
|
||||
@@ -27,7 +27,7 @@ SessionDep = Annotated[Session, Depends(get_db)]
|
||||
TokenDep = Annotated[str, Depends(reusable_oauth2)]
|
||||
|
||||
|
||||
def resolve_user_from_token(*, session: Session, token: str) -> User:
|
||||
def get_current_user(session: SessionDep, token: TokenDep) -> User:
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token, settings.SECRET_KEY, algorithms=[security.ALGORITHM]
|
||||
@@ -46,10 +46,6 @@ def resolve_user_from_token(*, session: Session, token: str) -> 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)]
|
||||
|
||||
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.routes import (
|
||||
locations,
|
||||
login,
|
||||
mqtt_raw,
|
||||
private,
|
||||
telemetry,
|
||||
users,
|
||||
utils,
|
||||
)
|
||||
from app.api.routes import locations, login, private, users, utils
|
||||
from app.core.config import settings
|
||||
|
||||
api_router = APIRouter()
|
||||
@@ -16,9 +8,8 @@ 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)
|
||||
|
||||
|
||||
@@ -1,300 +0,0 @@
|
||||
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",
|
||||
)
|
||||
@@ -1,63 +0,0 @@
|
||||
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
|
||||
@@ -56,17 +56,6 @@ 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:
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import uuid
|
||||
from datetime import date, datetime, timezone
|
||||
from typing import Any
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from pydantic import EmailStr
|
||||
from sqlalchemy import JSON, DateTime, LargeBinary
|
||||
from sqlalchemy import DateTime
|
||||
from sqlmodel import Field, Relationship, SQLModel
|
||||
|
||||
|
||||
@@ -54,9 +53,7 @@ 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
|
||||
@@ -111,98 +108,11 @@ 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
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
"""MQTT ingestion pipeline package."""
|
||||
@@ -1,119 +0,0 @@
|
||||
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()
|
||||
@@ -1,93 +0,0 @@
|
||||
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()
|
||||
@@ -1,60 +0,0 @@
|
||||
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()
|
||||
@@ -1,4 +0,0 @@
|
||||
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"]
|
||||
@@ -1,44 +0,0 @@
|
||||
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
|
||||
@@ -1,192 +0,0 @@
|
||||
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
|
||||
@@ -1,237 +0,0 @@
|
||||
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)
|
||||
@@ -1,158 +0,0 @@
|
||||
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}
|
||||
@@ -19,7 +19,6 @@ 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]
|
||||
|
||||
@@ -1,223 +0,0 @@
|
||||
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
|
||||
@@ -1,127 +0,0 @@
|
||||
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
|
||||
@@ -1,448 +0,0 @@
|
||||
$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
|
||||
@@ -1,54 +0,0 @@
|
||||
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 == []
|
||||
@@ -1,48 +0,0 @@
|
||||
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,
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
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
|
||||
@@ -1,149 +0,0 @@
|
||||
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
|
||||
@@ -1,12 +0,0 @@
|
||||
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")
|
||||
31
bun.lock
@@ -19,7 +19,6 @@
|
||||
"@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",
|
||||
@@ -29,13 +28,10 @@
|
||||
"@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,8 +39,6 @@
|
||||
"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",
|
||||
@@ -56,7 +50,6 @@
|
||||
"@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",
|
||||
@@ -257,8 +250,6 @@
|
||||
|
||||
"@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=="],
|
||||
@@ -287,8 +278,6 @@
|
||||
|
||||
"@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=="],
|
||||
@@ -437,14 +426,8 @@
|
||||
|
||||
"@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=="],
|
||||
@@ -627,10 +610,6 @@
|
||||
|
||||
"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=="],
|
||||
@@ -731,10 +710,6 @@
|
||||
|
||||
"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=="],
|
||||
@@ -873,10 +848,6 @@
|
||||
|
||||
"@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=="],
|
||||
@@ -957,8 +928,6 @@
|
||||
|
||||
"@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=="],
|
||||
|
||||
@@ -1,5 +1,50 @@
|
||||
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:
|
||||
@@ -42,20 +87,6 @@ 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:
|
||||
@@ -69,31 +100,9 @@ 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:
|
||||
@@ -113,9 +122,14 @@ services:
|
||||
- MAILCATCHER_HOST=http://mailcatcher:1080
|
||||
# For the reports when run locally
|
||||
- PLAYWRIGHT_HTML_HOST=0.0.0.0
|
||||
- CI=${CI:-false}
|
||||
- CI=${CI}
|
||||
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
|
||||
|
||||
@@ -70,77 +70,6 @@ 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
|
||||
@@ -158,6 +87,3 @@ services:
|
||||
networks:
|
||||
1panel-network:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
mqtt-archive-data:
|
||||
|
||||
77
compose.traefik.yml
Normal file
@@ -0,0 +1,77 @@
|
||||
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
|
||||
137
compose.yml
@@ -22,16 +22,34 @@ 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
|
||||
@@ -61,6 +79,9 @@ services:
|
||||
backend:
|
||||
image: '${DOCKER_IMAGE_BACKEND?Variable not set}:${TAG-latest}'
|
||||
restart: always
|
||||
networks:
|
||||
- traefik-public
|
||||
- default
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
@@ -97,93 +118,57 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: backend/Dockerfile
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.docker.network=traefik-public
|
||||
- traefik.constraint-label=traefik-public
|
||||
|
||||
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.services.${STACK_NAME?Variable not set}-backend.loadbalancer.server.port=8000
|
||||
|
||||
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
|
||||
- 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
|
||||
|
||||
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:
|
||||
mqtt-archive-data:
|
||||
|
||||
networks:
|
||||
traefik-public:
|
||||
# Allow setting it to false for testing
|
||||
external: true
|
||||
|
||||
100
copier.yml
Normal file
@@ -0,0 +1,100 @@
|
||||
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]
|
||||
@@ -33,7 +33,7 @@ OpenResty (1Panel 管理, SSL, 端口 80/443)
|
||||
**关键设计决策:**
|
||||
- 后端和前端容器只绑定 `127.0.0.1`,不对外暴露,由 OpenResty 统一反代
|
||||
- 所有容器加入 `1panel-network`,可直接通过 `postgresql` 主机名访问已有数据库
|
||||
- 使用 1Panel 自带的 OpenResty 作为反向代理
|
||||
- 不使用 Traefik(用 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. 创建新仓库,例如 `spatialhub`
|
||||
2. 创建新仓库,例如 `full-stack-fastapi`
|
||||
3. 在本地开发机上添加 Gitea 远程仓库:
|
||||
|
||||
```bash
|
||||
cd /Users/weifeng/Workspace/full-stack-fastapi-template
|
||||
|
||||
# 添加 Gitea 远程
|
||||
git remote add gitea http://your-server-ip:3000/your-username/spatialhub.git
|
||||
git remote add gitea http://your-server-ip:3000/your-username/full-stack-fastapi.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/spatialhub.git .
|
||||
git clone http://localhost:3000/your-username/full-stack-fastapi.git .
|
||||
# 或者如果已经有代码
|
||||
git pull origin main
|
||||
```
|
||||
@@ -323,7 +323,8 @@ sudo systemctl status gitea-runner
|
||||
|-------------|-----|
|
||||
| `DOMAIN` | `makefire.fun` |
|
||||
| `FRONTEND_HOST` | `https://makefire.fun` |
|
||||
| `PROJECT_NAME` | `SpatialHub` |
|
||||
| `PROJECT_NAME` | `Full Stack FastAPI Project` |
|
||||
| `STACK_NAME` | `full-stack-fastapi-project` |
|
||||
| `BACKEND_CORS_ORIGINS` | `https://makefire.fun,https://api.makefire.fun` |
|
||||
| `SECRET_KEY` | *(用 `openssl rand -hex 32` 生成)* |
|
||||
| `FIRST_SUPERUSER` | `admin@makefire.fun` |
|
||||
|
||||
387
deployment.md
@@ -1,87 +1,344 @@
|
||||
# FastAPI Project - Deployment
|
||||
|
||||
You can deploy this project with Docker Compose and put it behind an external reverse proxy (for example OpenResty/Nginx).
|
||||
You can deploy the project using Docker Compose to a remote server.
|
||||
|
||||
This repository uses an external reverse proxy setup and keeps Compose deployment focused on app services.
|
||||
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. 🤓
|
||||
|
||||
## Preparation
|
||||
|
||||
* 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
|
||||
* 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.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Create `.env.production` based on `.env.production.example` and fill in real values.
|
||||
You need to set some environment variables first.
|
||||
|
||||
Minimum required values:
|
||||
### Generate secret keys
|
||||
|
||||
* `DOMAIN`
|
||||
* `FRONTEND_HOST`
|
||||
* `ENVIRONMENT=production`
|
||||
* `SECRET_KEY`
|
||||
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`
|
||||
* `FIRST_SUPERUSER`
|
||||
* `FIRST_SUPERUSER_PASSWORD`
|
||||
* `POSTGRES_SERVER`
|
||||
* `POSTGRES_PORT`
|
||||
* `POSTGRES_DB`
|
||||
* `POSTGRES_USER`
|
||||
* `POSTGRES_PASSWORD`
|
||||
* `SECRET_KEY`
|
||||
* `LATEST_CHANGES`
|
||||
* `SMOKESHOW_AUTH_KEY`
|
||||
|
||||
Optional values:
|
||||
## GitHub Action Deployment Workflows
|
||||
|
||||
* `SMTP_*` and `EMAILS_FROM_EMAIL`
|
||||
* `SENTRY_DSN`
|
||||
* `MQTT_*` pipeline settings
|
||||
* `DOCKER_IMAGE_BACKEND`
|
||||
* `DOCKER_IMAGE_FRONTEND`
|
||||
There are GitHub Action workflows in the `.github/workflows` directory already configured for deploying to the environments (GitHub Actions runners with the labels):
|
||||
|
||||
## Deploy with Docker Compose
|
||||
* `staging`: after pushing (or merging) to the branch `master`.
|
||||
* `production`: after publishing a release.
|
||||
|
||||
```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.
|
||||
If you need to add extra environments you could use those as a starting point.
|
||||
|
||||
## URLs
|
||||
|
||||
Replace with your real domains configured in OpenResty:
|
||||
Replace `fastapi-project.example.com` with your domain.
|
||||
|
||||
* Frontend: `https://<your-frontend-domain>`
|
||||
* Backend API docs: `https://<your-api-domain>/docs`
|
||||
* Backend API base URL: `https://<your-api-domain>`
|
||||
### 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`
|
||||
|
||||
@@ -18,6 +18,8 @@ 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):
|
||||
@@ -77,6 +79,34 @@ 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`.
|
||||
@@ -168,4 +198,24 @@ 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>
|
||||
|
||||
@@ -1,581 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,252 +0,0 @@
|
||||
# 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 选型结论
|
||||
|
||||
采用 **方案 2:DB 队列双阶段**。
|
||||
|
||||
理由:
|
||||
|
||||
- 避免 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:连接状态、入站速率、入库失败
|
||||
- parser:pending 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`,内容无附加字段)
|
||||
@@ -1,9 +1,10 @@
|
||||
# Shared Bun stage for frontend dependencies and sources
|
||||
FROM oven/bun:1 AS bun-base
|
||||
# Stage 0, "build-stage", based on Bun, to build and compile the frontend
|
||||
FROM oven/bun:1 AS build-stage
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json bun.lock /app/
|
||||
|
||||
COPY frontend/package.json /app/frontend/
|
||||
|
||||
WORKDIR /app/frontend
|
||||
@@ -13,22 +14,10 @@ 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
|
||||
|
||||
|
||||
# Production stage serving compiled assets with Nginx
|
||||
# Stage 1, based on Nginx, to have only the compiled app, ready for production with Nginx
|
||||
FROM nginx:1
|
||||
|
||||
COPY --from=build-stage /app/frontend/dist/ /usr/share/nginx/html
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
<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>SpatialHub</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/images/spatialhub-icon.svg" />
|
||||
<title>Full Stack FastAPI Project</title>
|
||||
<link rel="icon" type="image/x-icon" href="/assets/images/favicon.png" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
"@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",
|
||||
@@ -33,13 +32,10 @@
|
||||
"@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",
|
||||
@@ -47,8 +43,6 @@
|
||||
"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",
|
||||
@@ -60,7 +54,6 @@
|
||||
"@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",
|
||||
|
||||
77
frontend/public/assets/images/fastapi-icon-light.svg
Normal file
@@ -0,0 +1,77 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
77
frontend/public/assets/images/fastapi-icon.svg
Normal file
@@ -0,0 +1,77 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
83
frontend/public/assets/images/fastapi-logo-light.svg
Normal file
@@ -0,0 +1,83 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 7.0 KiB |
91
frontend/public/assets/images/fastapi-logo.svg
Normal file
@@ -0,0 +1,91 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 7.1 KiB |
BIN
frontend/public/assets/images/favicon.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
@@ -1,8 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 599 B |
@@ -1,8 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 599 B |
@@ -1,17 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 824 B |
@@ -1,17 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 824 B |
@@ -57,22 +57,6 @@ 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: {
|
||||
@@ -87,6 +71,131 @@ 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: {
|
||||
@@ -224,66 +333,6 @@ 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: {
|
||||
|
||||
@@ -3,7 +3,118 @@
|
||||
import type { CancelablePromise } from './core/CancelablePromise';
|
||||
import { OpenAPI } from './core/OpenAPI';
|
||||
import { request as __request } from './core/request';
|
||||
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';
|
||||
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'
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class LocationsService {
|
||||
/**
|
||||
@@ -213,94 +324,6 @@ 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
|
||||
@@ -323,31 +346,6 @@ 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
|
||||
|
||||
@@ -9,15 +9,33 @@ 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);
|
||||
@@ -45,18 +63,6 @@ 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;
|
||||
@@ -130,6 +136,38 @@ 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;
|
||||
@@ -188,48 +226,12 @@ 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;
|
||||
|
||||
@@ -4,13 +4,13 @@ import { FaXTwitter } from "react-icons/fa6"
|
||||
const socialLinks = [
|
||||
{
|
||||
icon: FaGithub,
|
||||
href: "https://makefire.fun",
|
||||
href: "https://github.com/fastapi/fastapi",
|
||||
label: "GitHub",
|
||||
},
|
||||
{ icon: FaXTwitter, href: "https://makefire.fun", label: "X" },
|
||||
{ icon: FaXTwitter, href: "https://x.com/fastapi", label: "X" },
|
||||
{
|
||||
icon: FaLinkedinIn,
|
||||
href: "https://makefire.fun",
|
||||
href: "https://linkedin.com/company/fastapi",
|
||||
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">
|
||||
SpatialHub - {currentYear}
|
||||
Full Stack FastAPI Template - {currentYear}
|
||||
</p>
|
||||
<div className="flex items-center gap-4">
|
||||
{socialLinks.map(({ icon: Icon, href, label }) => (
|
||||
|
||||
@@ -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/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"
|
||||
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"
|
||||
|
||||
interface LogoProps {
|
||||
variant?: "full" | "icon" | "responsive"
|
||||
@@ -29,7 +29,7 @@ export function Logo({
|
||||
<>
|
||||
<img
|
||||
src={fullLogo}
|
||||
alt="SpatialHub"
|
||||
alt="FastAPI"
|
||||
className={cn(
|
||||
"h-6 w-auto group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
@@ -37,7 +37,7 @@ export function Logo({
|
||||
/>
|
||||
<img
|
||||
src={iconLogo}
|
||||
alt="SpatialHub"
|
||||
alt="FastAPI"
|
||||
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="SpatialHub"
|
||||
alt="FastAPI"
|
||||
className={cn(variant === "full" ? "h-6 w-auto" : "size-5", className)}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FileDown, Home, MapPin, Satellite, Users } from "lucide-react"
|
||||
import { Home, MapPin, Users } from "lucide-react"
|
||||
|
||||
import { SidebarAppearance } from "@/components/Common/Appearance"
|
||||
import { Logo } from "@/components/Common/Logo"
|
||||
@@ -15,8 +15,6 @@ 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() {
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
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 }
|
||||
@@ -1,26 +0,0 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
/**
|
||||
* 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]
|
||||
}
|
||||
@@ -11,10 +11,6 @@ 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
|
||||
@@ -22,21 +18,10 @@ 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)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (shouldHandleAuthError(error.status, error.body)) {
|
||||
clearAuthAndRedirectToLogin()
|
||||
if (error instanceof ApiError && [401, 403].includes(error.status)) {
|
||||
localStorage.removeItem("access_token")
|
||||
window.location.href = "/login"
|
||||
}
|
||||
}
|
||||
const queryClient = new QueryClient({
|
||||
|
||||
@@ -16,9 +16,7 @@ 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({
|
||||
@@ -55,21 +53,11 @@ 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',
|
||||
@@ -83,9 +71,7 @@ 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 {
|
||||
@@ -94,9 +80,7 @@ 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
|
||||
}
|
||||
@@ -108,9 +92,7 @@ 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
|
||||
}
|
||||
@@ -123,9 +105,7 @@ export interface FileRouteTypes {
|
||||
| '/reset-password'
|
||||
| '/signup'
|
||||
| '/admin'
|
||||
| '/gnss-monitor'
|
||||
| '/locations'
|
||||
| '/mqtt-raw'
|
||||
| '/settings'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
@@ -134,9 +114,7 @@ export interface FileRouteTypes {
|
||||
| '/reset-password'
|
||||
| '/signup'
|
||||
| '/admin'
|
||||
| '/gnss-monitor'
|
||||
| '/locations'
|
||||
| '/mqtt-raw'
|
||||
| '/settings'
|
||||
| '/'
|
||||
id:
|
||||
@@ -147,9 +125,7 @@ export interface FileRouteTypes {
|
||||
| '/reset-password'
|
||||
| '/signup'
|
||||
| '/_layout/admin'
|
||||
| '/_layout/gnss-monitor'
|
||||
| '/_layout/locations'
|
||||
| '/_layout/mqtt-raw'
|
||||
| '/_layout/settings'
|
||||
| '/_layout/'
|
||||
fileRoutesById: FileRoutesById
|
||||
@@ -213,13 +189,6 @@ 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'
|
||||
@@ -227,13 +196,6 @@ 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'
|
||||
@@ -246,18 +208,14 @@ 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,
|
||||
}
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
createFileRoute,
|
||||
Outlet,
|
||||
redirect,
|
||||
useRouterState,
|
||||
} from "@tanstack/react-router"
|
||||
import { createFileRoute, Outlet, redirect } from "@tanstack/react-router"
|
||||
|
||||
import { Footer } from "@/components/Common/Footer"
|
||||
import AppSidebar from "@/components/Sidebar/AppSidebar"
|
||||
@@ -26,28 +21,19 @@ 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 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">
|
||||
<SidebarInset>
|
||||
<header className="sticky top-0 z-10 flex h-16 shrink-0 items-center gap-2 border-b px-4">
|
||||
<SidebarTrigger className="-ml-1 text-muted-foreground" />
|
||||
</header>
|
||||
<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"}`}
|
||||
>
|
||||
<main className="flex-1 p-6 md:p-8">
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
{!isFullScreenPage && <Footer />}
|
||||
<Footer />
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
)
|
||||
|
||||
@@ -1,826 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,779 +1,30 @@
|
||||
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 { 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"
|
||||
import useAuth from "@/hooks/useAuth"
|
||||
|
||||
export const Route = createFileRoute("/_layout/")({
|
||||
component: Dashboard,
|
||||
head: () => ({
|
||||
meta: [
|
||||
{
|
||||
title: "Device Map - SpatialHub",
|
||||
title: "Dashboard - FastAPI Template",
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
/* ---------- 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: "© 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: "© 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: "© 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 (
|
||||
<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
|
||||
const { user: currentUser } = useAuth()
|
||||
|
||||
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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -36,12 +36,8 @@ 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>
|
||||
)
|
||||
}
|
||||
@@ -63,9 +59,7 @@ 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>
|
||||
|
||||
@@ -1,411 +0,0 @@
|
||||
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
@@ -69,7 +69,6 @@ dependencies = [
|
||||
{ name = "fastapi", extra = ["standard"] },
|
||||
{ name = "httpx" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "paho-mqtt" },
|
||||
{ name = "psycopg", extra = ["binary"] },
|
||||
{ name = "pwdlib", extra = ["argon2", "bcrypt"] },
|
||||
{ name = "pydantic" },
|
||||
@@ -98,7 +97,6 @@ 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" },
|
||||
@@ -813,6 +811,7 @@ 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" },
|
||||
@@ -820,6 +819,7 @@ 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,6 +827,7 @@ 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" },
|
||||
@@ -834,6 +835,7 @@ 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" },
|
||||
@@ -841,6 +843,7 @@ 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" },
|
||||
@@ -848,6 +851,7 @@ 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" },
|
||||
@@ -1351,15 +1355,6 @@ 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"
|
||||
|
||||