mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-12 20:36:05 +02:00
Compare commits
12 Commits
3.9.0
...
aur-packaging
| Author | SHA1 | Date | |
|---|---|---|---|
| 33a7c027c2 | |||
| 416dffaca0 | |||
| fbae6e2215 | |||
| 68439f2ad9 | |||
| 88ab088673 | |||
| bb5af5ba82 | |||
| 159df1ec5b | |||
| 8e2e039985 | |||
| 01c86a486e | |||
| 7d5cfdec26 | |||
| 5fe0ac0ad4 | |||
| b98102ccac |
@@ -0,0 +1,71 @@
|
|||||||
|
name: Publish AUR package
|
||||||
|
|
||||||
|
# Pushes the contents of pkg/aur/ to the remoteterm-meshcore AUR repository
|
||||||
|
# whenever a GitHub release is published. Can also be triggered manually for
|
||||||
|
# testing or out-of-band republishes.
|
||||||
|
#
|
||||||
|
# Required secrets:
|
||||||
|
# AUR_SSH_PRIVATE_KEY Private SSH key registered with the AUR maintainer
|
||||||
|
# account that owns the remoteterm-meshcore package.
|
||||||
|
# AUR_COMMIT_EMAIL Email used for the AUR git commit identity.
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: 'Version to publish (no v prefix, e.g. 3.9.1)'
|
||||||
|
required: true
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
# Serialize publishes so a fast back-to-back release sequence cannot race
|
||||||
|
# two pushes against the AUR repo. The later one wins by virtue of being
|
||||||
|
# the final state.
|
||||||
|
group: publish-aur
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish-aur:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Resolve version from event
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||||
|
VERSION="${{ inputs.version }}"
|
||||||
|
else
|
||||||
|
VERSION="${{ github.event.release.tag_name }}"
|
||||||
|
fi
|
||||||
|
VERSION="${VERSION#v}"
|
||||||
|
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Publishing AUR package for version $VERSION"
|
||||||
|
|
||||||
|
- name: Stamp pkgver into PKGBUILD
|
||||||
|
run: |
|
||||||
|
sed -i "s/^pkgver=.*/pkgver=${{ steps.version.outputs.version }}/" pkg/aur/PKGBUILD
|
||||||
|
sed -i "s/^pkgrel=.*/pkgrel=1/" pkg/aur/PKGBUILD
|
||||||
|
|
||||||
|
- name: Publish to AUR
|
||||||
|
uses: KSXGitHub/github-actions-deploy-aur@v4.1.2
|
||||||
|
with:
|
||||||
|
pkgname: remoteterm-meshcore
|
||||||
|
pkgbuild: pkg/aur/PKGBUILD
|
||||||
|
assets: |
|
||||||
|
pkg/aur/remoteterm-meshcore.install
|
||||||
|
pkg/aur/remoteterm-meshcore.service
|
||||||
|
pkg/aur/remoteterm.env
|
||||||
|
commit_username: jkingsman
|
||||||
|
commit_email: ${{ secrets.AUR_COMMIT_EMAIL }}
|
||||||
|
ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
|
||||||
|
commit_message: "Update to ${{ steps.version.outputs.version }}"
|
||||||
|
# Recompute sha256sums from the live release tarball + the bundled
|
||||||
|
# service/env files. The committed PKGBUILD has SKIP placeholders.
|
||||||
|
updpkgsums: true
|
||||||
|
# Validate the PKGBUILD parses and sources download, but skip the
|
||||||
|
# actual build (which would run uv sync + npm install for several
|
||||||
|
# minutes of CI time on every release).
|
||||||
|
test: true
|
||||||
|
test_flags: --clean --cleanbuild --nodeps --nobuild
|
||||||
@@ -161,6 +161,29 @@ To stop:
|
|||||||
sudo docker compose down
|
sudo docker compose down
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Install Path 3: Arch Linux (AUR)
|
||||||
|
|
||||||
|
A [`remoteterm-meshcore`](https://aur.archlinux.org/packages/remoteterm-meshcore) package is available in the AUR. Install it with an AUR helper or build it manually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# with an AUR helper
|
||||||
|
yay -S remoteterm-meshcore
|
||||||
|
|
||||||
|
# or manually
|
||||||
|
git clone https://aur.archlinux.org/remoteterm-meshcore.git
|
||||||
|
cd remoteterm-meshcore
|
||||||
|
makepkg -si
|
||||||
|
```
|
||||||
|
|
||||||
|
Configure your radio connection, then start the service:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo vi /etc/remoteterm-meshcore/remoteterm.env
|
||||||
|
sudo systemctl enable --now remoteterm-meshcore
|
||||||
|
```
|
||||||
|
|
||||||
|
Access the app at http://localhost:8000.
|
||||||
|
|
||||||
## Standard Environment Variables
|
## Standard Environment Variables
|
||||||
|
|
||||||
Only one transport may be active at a time. If multiple are set, the server will refuse to start.
|
Only one transport may be active at a time. If multiple are set, the server will refuse to start.
|
||||||
@@ -199,6 +222,15 @@ $env:MESHCORE_SERIAL_PORT="COM8" # or your COM port
|
|||||||
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> **Windows + MQTT fanout:** Python's default Windows event loop (ProactorEventLoop) is not compatible with the MQTT libraries used by RemoteTerm. If you configure any MQTT integration, add `--loop none` to your uvicorn command:
|
||||||
|
>
|
||||||
|
> ```powershell
|
||||||
|
> uv run uvicorn app.main:app --host 0.0.0.0 --port 8000 --loop none
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> If you forget, the app will start normally but MQTT connections will fail and you'll see a toast in the UI with this same guidance.
|
||||||
|
|
||||||
If you enable Basic Auth, protect the app with HTTPS. HTTP Basic credentials are not safe on plain HTTP. Also note that the app's permissive CORS policy is a deliberate trusted-network tradeoff, so cross-origin browser JavaScript is not a reliable way to use that Basic Auth gate.
|
If you enable Basic Auth, protect the app with HTTPS. HTTP Basic credentials are not safe on plain HTTP. Also note that the app's permissive CORS policy is a deliberate trusted-network tradeoff, so cross-origin browser JavaScript is not a reliable way to use that Basic Auth gate.
|
||||||
|
|
||||||
## Where To Go Next
|
## Where To Go Next
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import sys
|
||||||
import time
|
import time
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -252,6 +253,34 @@ class BaseMqttPublisher(ABC):
|
|||||||
self._client = None
|
self._client = None
|
||||||
self._last_error = _format_error_detail(e)
|
self._last_error = _format_error_detail(e)
|
||||||
|
|
||||||
|
# Windows ProactorEventLoop does not implement add_reader /
|
||||||
|
# add_writer, which paho-mqtt requires. The failure can
|
||||||
|
# surface as a direct NotImplementedError (add_writer in
|
||||||
|
# __aenter__) or as a generic timeout (add_reader fails
|
||||||
|
# inside an event-loop callback, so paho never hears back).
|
||||||
|
# Either way, if we're on Windows with Proactor the root
|
||||||
|
# cause is the same and retrying won't help.
|
||||||
|
_on_proactor = (
|
||||||
|
sys.platform == "win32"
|
||||||
|
and type(asyncio.get_event_loop()).__name__ == "ProactorEventLoop"
|
||||||
|
)
|
||||||
|
if _on_proactor:
|
||||||
|
broadcast_error(
|
||||||
|
"MQTT unavailable — Windows event loop incompatible",
|
||||||
|
"The default Windows event loop (ProactorEventLoop) does "
|
||||||
|
"not support MQTT. Add --loop none to your uvicorn "
|
||||||
|
"command and restart. See README.md for details.",
|
||||||
|
)
|
||||||
|
_broadcast_health()
|
||||||
|
logger.error(
|
||||||
|
"%s cannot run: Windows ProactorEventLoop does not "
|
||||||
|
"implement add_reader/add_writer required by paho-mqtt. "
|
||||||
|
"Restart uvicorn with '--loop none' to use "
|
||||||
|
"SelectorEventLoop instead. Giving up (will not retry).",
|
||||||
|
self._integration_label(),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
title, detail = self._on_error()
|
title, detail = self._on_error()
|
||||||
broadcast_error(title, detail)
|
broadcast_error(title, detail)
|
||||||
_broadcast_health()
|
_broadcast_health()
|
||||||
|
|||||||
+37
-1
@@ -1,5 +1,41 @@
|
|||||||
import asyncio
|
|
||||||
import logging
|
import logging
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Windows event-loop advisory for MQTT fanout
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# On Windows, uvicorn's default event loop (ProactorEventLoop) does not
|
||||||
|
# implement add_reader()/add_writer(), which paho-mqtt (via aiomqtt) requires.
|
||||||
|
# We cannot fix this from inside the app — the loop is already created by the
|
||||||
|
# time this module is imported. Log a prominent warning so Windows operators
|
||||||
|
# who want MQTT know to add ``--loop none`` to their uvicorn command.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
if sys.platform == "win32":
|
||||||
|
import asyncio as _asyncio
|
||||||
|
|
||||||
|
_loop = _asyncio.get_event_loop()
|
||||||
|
_is_proactor = type(_loop).__name__ == "ProactorEventLoop"
|
||||||
|
if _is_proactor:
|
||||||
|
print(
|
||||||
|
"\n" + "!" * 78 + "\n"
|
||||||
|
" NOTE FOR WINDOWS USERS\n" + "!" * 78 + "\n"
|
||||||
|
"\n"
|
||||||
|
" The running event loop is ProactorEventLoop, which is not\n"
|
||||||
|
" compatible with MQTT fanout (aiomqtt / paho-mqtt).\n"
|
||||||
|
"\n"
|
||||||
|
" If you use MQTT integrations, restart with --loop none:\n"
|
||||||
|
"\n"
|
||||||
|
" uv run uvicorn app.main:app \033[1m--loop none\033[0m"
|
||||||
|
" [... other options ...]\n"
|
||||||
|
"\n"
|
||||||
|
" Everything else works fine as-is.\n"
|
||||||
|
"\n" + "!" * 78 + "\n",
|
||||||
|
file=sys.stderr,
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
del _loop, _is_proactor
|
||||||
|
|
||||||
|
import asyncio
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|||||||
@@ -884,6 +884,11 @@ class NoiseFloorHistoryStats(BaseModel):
|
|||||||
samples: list[NoiseFloorSample] = Field(default_factory=list)
|
samples: list[NoiseFloorSample] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class PacketsPerHourBucket(BaseModel):
|
||||||
|
timestamp: int = Field(description="Unix timestamp at the start of the hour")
|
||||||
|
count: int = Field(description="Number of packets received in that hour")
|
||||||
|
|
||||||
|
|
||||||
class StatisticsResponse(BaseModel):
|
class StatisticsResponse(BaseModel):
|
||||||
busiest_channels_24h: list[BusyChannel]
|
busiest_channels_24h: list[BusyChannel]
|
||||||
contact_count: int
|
contact_count: int
|
||||||
@@ -899,6 +904,7 @@ class StatisticsResponse(BaseModel):
|
|||||||
repeaters_heard: ContactActivityCounts
|
repeaters_heard: ContactActivityCounts
|
||||||
known_channels_active: ContactActivityCounts
|
known_channels_active: ContactActivityCounts
|
||||||
path_hash_width_24h: PathHashWidthStats
|
path_hash_width_24h: PathHashWidthStats
|
||||||
|
packets_per_hour_72h: list[PacketsPerHourBucket]
|
||||||
noise_floor_24h: NoiseFloorHistoryStats
|
noise_floor_24h: NoiseFloorHistoryStats
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -692,9 +692,18 @@ class ContactAdvertPathRepository:
|
|||||||
cursor = await db.conn.execute(
|
cursor = await db.conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT public_key, path_hex, path_len, first_seen, last_seen, heard_count
|
SELECT public_key, path_hex, path_len, first_seen, last_seen, heard_count
|
||||||
FROM contact_advert_paths
|
FROM (
|
||||||
|
SELECT *,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY public_key
|
||||||
|
ORDER BY last_seen DESC, heard_count DESC, path_len ASC, path_hex ASC
|
||||||
|
) AS rn
|
||||||
|
FROM contact_advert_paths
|
||||||
|
)
|
||||||
|
WHERE rn <= ?
|
||||||
ORDER BY public_key ASC, last_seen DESC, heard_count DESC, path_len ASC, path_hex ASC
|
ORDER BY public_key ASC, last_seen DESC, heard_count DESC, path_len ASC, path_hex ASC
|
||||||
"""
|
""",
|
||||||
|
(limit_per_contact,),
|
||||||
)
|
)
|
||||||
rows = await cursor.fetchall()
|
rows = await cursor.fetchall()
|
||||||
|
|
||||||
@@ -705,8 +714,6 @@ class ContactAdvertPathRepository:
|
|||||||
if paths is None:
|
if paths is None:
|
||||||
paths = []
|
paths = []
|
||||||
grouped[key] = paths
|
grouped[key] = paths
|
||||||
if len(paths) >= limit_per_contact:
|
|
||||||
continue
|
|
||||||
paths.append(ContactAdvertPathRepository._row_to_path(row))
|
paths.append(ContactAdvertPathRepository._row_to_path(row))
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
SECONDS_1H = 3600
|
SECONDS_1H = 3600
|
||||||
SECONDS_24H = 86400
|
SECONDS_24H = 86400
|
||||||
|
SECONDS_72H = 259200
|
||||||
SECONDS_7D = 604800
|
SECONDS_7D = 604800
|
||||||
RAW_PACKET_STATS_BATCH_SIZE = 500
|
RAW_PACKET_STATS_BATCH_SIZE = 500
|
||||||
|
|
||||||
@@ -274,6 +275,25 @@ class StatisticsRepository:
|
|||||||
"last_week": row["last_week"] or 0,
|
"last_week": row["last_week"] or 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _packets_per_hour_72h() -> list[dict[str, int]]:
|
||||||
|
"""Return packet counts bucketed by hour for the last 72 hours."""
|
||||||
|
now = int(time.time())
|
||||||
|
cutoff = now - SECONDS_72H
|
||||||
|
# Bucket timestamps to the start of each hour
|
||||||
|
cursor = await db.conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT (timestamp / 3600) * 3600 AS hour_ts, COUNT(*) AS count
|
||||||
|
FROM raw_packets
|
||||||
|
WHERE timestamp >= ?
|
||||||
|
GROUP BY hour_ts
|
||||||
|
ORDER BY hour_ts
|
||||||
|
""",
|
||||||
|
(cutoff,),
|
||||||
|
)
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
return [{"timestamp": row["hour_ts"], "count": row["count"]} for row in rows]
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def _path_hash_width_24h() -> dict[str, int | float]:
|
async def _path_hash_width_24h() -> dict[str, int | float]:
|
||||||
"""Count parsed raw packets from the last 24h by hop hash width."""
|
"""Count parsed raw packets from the last 24h by hop hash width."""
|
||||||
@@ -350,6 +370,7 @@ class StatisticsRepository:
|
|||||||
repeaters_heard = await StatisticsRepository._activity_counts(contact_type=2)
|
repeaters_heard = await StatisticsRepository._activity_counts(contact_type=2)
|
||||||
known_channels_active = await StatisticsRepository._known_channels_active()
|
known_channels_active = await StatisticsRepository._known_channels_active()
|
||||||
path_hash_width_24h = await StatisticsRepository._path_hash_width_24h()
|
path_hash_width_24h = await StatisticsRepository._path_hash_width_24h()
|
||||||
|
packets_per_hour_72h = await StatisticsRepository._packets_per_hour_72h()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"busiest_channels_24h": busiest_channels_24h,
|
"busiest_channels_24h": busiest_channels_24h,
|
||||||
@@ -366,4 +387,5 @@ class StatisticsRepository:
|
|||||||
"repeaters_heard": repeaters_heard,
|
"repeaters_heard": repeaters_heard,
|
||||||
"known_channels_active": known_channels_active,
|
"known_channels_active": known_channels_active,
|
||||||
"path_hash_width_24h": path_hash_width_24h,
|
"path_hash_width_24h": path_hash_width_24h,
|
||||||
|
"packets_per_hour_72h": packets_per_hour_72h,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,11 +11,14 @@ import {
|
|||||||
Cell,
|
Cell,
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
|
|
||||||
|
import { MeshCoreDecoder, Utils } from '@michaelhart/meshcore-decoder';
|
||||||
|
|
||||||
import { RawPacketList } from './RawPacketList';
|
import { RawPacketList } from './RawPacketList';
|
||||||
import { RawPacketInspectorDialog } from './RawPacketDetailModal';
|
import { RawPacketInspectorDialog } from './RawPacketDetailModal';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import type { Channel, Contact, RawPacket } from '../types';
|
import type { Channel, Contact, RawPacket } from '../types';
|
||||||
import {
|
import {
|
||||||
|
KNOWN_PAYLOAD_TYPES,
|
||||||
RAW_PACKET_STATS_WINDOWS,
|
RAW_PACKET_STATS_WINDOWS,
|
||||||
buildRawPacketStatsSnapshot,
|
buildRawPacketStatsSnapshot,
|
||||||
type NeighborStat,
|
type NeighborStat,
|
||||||
@@ -24,9 +27,26 @@ import {
|
|||||||
type RawPacketStatsSessionState,
|
type RawPacketStatsSessionState,
|
||||||
type RawPacketStatsWindow,
|
type RawPacketStatsWindow,
|
||||||
} from '../utils/rawPacketStats';
|
} from '../utils/rawPacketStats';
|
||||||
|
import { createDecoderOptions } from '../utils/rawPacketInspector';
|
||||||
import { getContactDisplayName } from '../utils/pubkey';
|
import { getContactDisplayName } from '../utils/pubkey';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const KNOWN_PAYLOAD_TYPE_SET = new Set<string>(KNOWN_PAYLOAD_TYPES);
|
||||||
|
|
||||||
|
function getPacketTypeName(
|
||||||
|
packet: RawPacket,
|
||||||
|
decoderOptions?: ReturnType<typeof createDecoderOptions>
|
||||||
|
): string {
|
||||||
|
try {
|
||||||
|
const decoded = MeshCoreDecoder.decode(packet.data, decoderOptions);
|
||||||
|
if (!decoded.isValid) return 'Unknown';
|
||||||
|
const name = Utils.getPayloadTypeName(decoded.payloadType);
|
||||||
|
return KNOWN_PAYLOAD_TYPE_SET.has(name) ? name : 'Unknown';
|
||||||
|
} catch {
|
||||||
|
return 'Unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface RawPacketFeedViewProps {
|
interface RawPacketFeedViewProps {
|
||||||
packets: RawPacket[];
|
packets: RawPacket[];
|
||||||
rawPacketStatsSession: RawPacketStatsSessionState;
|
rawPacketStatsSession: RawPacketStatsSessionState;
|
||||||
@@ -428,6 +448,48 @@ export function RawPacketFeedView({
|
|||||||
const [nowSec, setNowSec] = useState(() => Math.floor(Date.now() / 1000));
|
const [nowSec, setNowSec] = useState(() => Math.floor(Date.now() / 1000));
|
||||||
const [selectedPacket, setSelectedPacket] = useState<RawPacket | null>(null);
|
const [selectedPacket, setSelectedPacket] = useState<RawPacket | null>(null);
|
||||||
const [analyzeModalOpen, setAnalyzeModalOpen] = useState(false);
|
const [analyzeModalOpen, setAnalyzeModalOpen] = useState(false);
|
||||||
|
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
|
||||||
|
const [enabledTypes, setEnabledTypes] = useState<Set<string>>(() => new Set(KNOWN_PAYLOAD_TYPES));
|
||||||
|
|
||||||
|
const decoderOptions = useMemo(() => createDecoderOptions(channels), [channels]);
|
||||||
|
|
||||||
|
const packetsWithTypes = useMemo(
|
||||||
|
() =>
|
||||||
|
packets.map((packet) => ({
|
||||||
|
packet,
|
||||||
|
payloadType: getPacketTypeName(packet, decoderOptions),
|
||||||
|
})),
|
||||||
|
[packets, decoderOptions]
|
||||||
|
);
|
||||||
|
|
||||||
|
const allTypesEnabled = enabledTypes.size === KNOWN_PAYLOAD_TYPES.length;
|
||||||
|
|
||||||
|
const filteredPackets = useMemo(() => {
|
||||||
|
if (allTypesEnabled) return packets;
|
||||||
|
return packetsWithTypes
|
||||||
|
.filter(({ payloadType }) => enabledTypes.has(payloadType))
|
||||||
|
.map(({ packet }) => packet);
|
||||||
|
}, [packetsWithTypes, enabledTypes, packets, allTypesEnabled]);
|
||||||
|
|
||||||
|
const handleToggleAll = () => {
|
||||||
|
setEnabledTypes(allTypesEnabled ? new Set() : new Set(KNOWN_PAYLOAD_TYPES));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleType = (type: string) => {
|
||||||
|
setEnabledTypes((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(type)) {
|
||||||
|
next.delete(type);
|
||||||
|
} else {
|
||||||
|
next.add(type);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOnly = (type: string) => {
|
||||||
|
setEnabledTypes(new Set([type]));
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const interval = window.setInterval(() => {
|
const interval = window.setInterval(() => {
|
||||||
@@ -468,38 +530,129 @@ export function RawPacketFeedView({
|
|||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-between gap-3 border-b border-border px-4 py-2.5">
|
<div className="border-b border-border px-4 py-2.5">
|
||||||
<div>
|
<div className="flex items-center justify-between gap-3">
|
||||||
<h2 className="font-semibold text-base text-foreground">Raw Packet Feed</h2>
|
<div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<h2 className="font-semibold text-base text-foreground">Raw Packet Feed</h2>
|
||||||
Collecting stats since {formatTimestamp(rawPacketStatsSession.sessionStartedAt)}
|
<p className="hidden md:block text-xs text-muted-foreground">
|
||||||
</p>
|
Collecting stats since {formatTimestamp(rawPacketStatsSession.sessionStartedAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setAnalyzeModalOpen(true)}
|
||||||
|
>
|
||||||
|
Analyze Packet
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setStatsOpen((current) => !current)}
|
||||||
|
aria-expanded={statsOpen}
|
||||||
|
>
|
||||||
|
{statsOpen ? (
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{statsOpen ? 'Hide Stats' : 'Show Stats'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<p className="md:hidden text-xs text-muted-foreground">
|
||||||
<Button
|
Collecting stats since {formatTimestamp(rawPacketStatsSession.sessionStartedAt)}
|
||||||
type="button"
|
{!mobileFiltersOpen && (
|
||||||
variant="outline"
|
<>
|
||||||
size="sm"
|
{' · '}
|
||||||
onClick={() => setAnalyzeModalOpen(true)}
|
<button
|
||||||
>
|
type="button"
|
||||||
Analyze Packet
|
className="text-primary hover:text-primary/80 transition-colors"
|
||||||
</Button>
|
onClick={() => setMobileFiltersOpen(true)}
|
||||||
<Button
|
>
|
||||||
type="button"
|
Show Filters
|
||||||
variant="outline"
|
</button>
|
||||||
size="sm"
|
</>
|
||||||
onClick={() => setStatsOpen((current) => !current)}
|
)}
|
||||||
aria-expanded={statsOpen}
|
</p>
|
||||||
>
|
|
||||||
{statsOpen ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
|
{mobileFiltersOpen && (
|
||||||
{statsOpen ? 'Hide Stats' : 'Show Stats'}
|
<div className="mt-1.5 md:hidden flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||||
</Button>
|
<label className="flex items-center gap-1 text-xs text-muted-foreground cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={allTypesEnabled}
|
||||||
|
onChange={handleToggleAll}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
All
|
||||||
|
</label>
|
||||||
|
{KNOWN_PAYLOAD_TYPES.map((type) => (
|
||||||
|
<span key={type} className="inline-flex items-center gap-1 text-xs">
|
||||||
|
<label className="flex items-center gap-1 text-foreground cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={enabledTypes.has(type)}
|
||||||
|
onChange={() => handleToggleType(type)}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
{type}
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-[0.625rem] text-muted-foreground hover:text-primary transition-colors"
|
||||||
|
onClick={() => handleOnly(type)}
|
||||||
|
>
|
||||||
|
(only)
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-1.5 hidden md:flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||||
|
<label className="flex items-center gap-1 text-xs text-muted-foreground cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={allTypesEnabled}
|
||||||
|
onChange={handleToggleAll}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
All
|
||||||
|
</label>
|
||||||
|
{KNOWN_PAYLOAD_TYPES.map((type) => (
|
||||||
|
<span key={type} className="inline-flex items-center gap-1 text-xs">
|
||||||
|
<label className="flex items-center gap-1 text-foreground cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={enabledTypes.has(type)}
|
||||||
|
onChange={() => handleToggleType(type)}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
{type}
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-[0.625rem] text-muted-foreground hover:text-primary transition-colors"
|
||||||
|
onClick={() => handleOnly(type)}
|
||||||
|
>
|
||||||
|
(only)
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex min-h-0 flex-1 flex-col md:flex-row">
|
<div className="flex min-h-0 flex-1 flex-col md:flex-row">
|
||||||
<div className={cn('min-h-0 min-w-0 flex-1', statsOpen && 'md:border-r md:border-border')}>
|
<div className={cn('min-h-0 min-w-0 flex-1', statsOpen && 'md:border-r md:border-border')}>
|
||||||
<RawPacketList packets={packets} channels={channels} onPacketClick={setSelectedPacket} />
|
<RawPacketList
|
||||||
|
packets={filteredPackets}
|
||||||
|
channels={channels}
|
||||||
|
onPacketClick={setSelectedPacket}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside
|
<aside
|
||||||
|
|||||||
@@ -1666,7 +1666,8 @@ function AppriseConfigEditor({
|
|||||||
rows={4}
|
rows={4}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
One URL per line. All URLs receive every matched notification.
|
One URL per line. All URLs receive every matched notification. For Matrix room version 12
|
||||||
|
(servername-less room IDs), append <code>?hsreq=no</code> to the URL.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,87 @@ function formatTime(ts: number): string {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDateTime(ts: number): string {
|
||||||
|
const d = new Date(ts * 1000);
|
||||||
|
return (
|
||||||
|
d.toLocaleDateString([], { month: 'short', day: 'numeric' }) +
|
||||||
|
' ' +
|
||||||
|
d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PacketsPerHourChart({ buckets }: { buckets: { timestamp: number; count: number }[] }) {
|
||||||
|
// Fill gaps so hours with zero packets still appear on the chart
|
||||||
|
const filled: { timestamp: number; count: number }[] = [];
|
||||||
|
if (buckets.length > 0) {
|
||||||
|
const first = buckets[0].timestamp;
|
||||||
|
const last = buckets[buckets.length - 1].timestamp;
|
||||||
|
const byTs = new Map(buckets.map((b) => [b.timestamp, b.count]));
|
||||||
|
for (let ts = first; ts <= last; ts += 3600) {
|
||||||
|
filled.push({ timestamp: ts, count: byTs.get(ts) ?? 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = filled.map((b, i) => ({
|
||||||
|
idx: i,
|
||||||
|
label: formatDateTime(b.timestamp),
|
||||||
|
count: b.count,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Show ~6 evenly-spaced tick labels
|
||||||
|
const tickCount = Math.min(6, data.length);
|
||||||
|
const tickIndices: number[] = [];
|
||||||
|
if (data.length > 1) {
|
||||||
|
for (let i = 0; i < tickCount; i++) {
|
||||||
|
tickIndices.push(Math.round((i / (tickCount - 1)) * (data.length - 1)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={140}>
|
||||||
|
<AreaChart data={data} margin={{ top: 4, right: 4, bottom: 0, left: -8 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="idx"
|
||||||
|
type="number"
|
||||||
|
domain={[0, data.length - 1]}
|
||||||
|
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
ticks={tickIndices}
|
||||||
|
tickFormatter={(idx) => data[idx]?.label ?? ''}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
allowDecimals={false}
|
||||||
|
/>
|
||||||
|
<RechartsTooltip
|
||||||
|
{...TOOLTIP_STYLE}
|
||||||
|
cursor={{
|
||||||
|
stroke: 'hsl(var(--muted-foreground))',
|
||||||
|
strokeWidth: 1,
|
||||||
|
strokeDasharray: '3 3',
|
||||||
|
}}
|
||||||
|
labelFormatter={(idx) => data[Number(idx)]?.label ?? ''}
|
||||||
|
formatter={(value) => [`${Number(value).toLocaleString()} packets`, 'Count']}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="count"
|
||||||
|
stroke="#0ea5e9"
|
||||||
|
fill="#0ea5e9"
|
||||||
|
fillOpacity={0.15}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
dot={false}
|
||||||
|
activeDot={{ r: 4, fill: '#0ea5e9', strokeWidth: 2, stroke: 'hsl(var(--popover))' }}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function NoiseFloorChart({
|
function NoiseFloorChart({
|
||||||
samples,
|
samples,
|
||||||
}: {
|
}: {
|
||||||
@@ -241,6 +322,17 @@ export function SettingsStatisticsSection({ className }: { className?: string })
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Packets per Hour (72h) */}
|
||||||
|
{stats.packets_per_hour_72h?.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium mb-2">Packets per Hour (72h)</h4>
|
||||||
|
<PacketsPerHourChart buckets={stats.packets_per_hour_72h} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
{/* Path Hash Width */}
|
{/* Path Hash Width */}
|
||||||
|
|||||||
@@ -652,6 +652,10 @@ describe('SettingsModal', () => {
|
|||||||
double_byte_pct: 30,
|
double_byte_pct: 30,
|
||||||
triple_byte_pct: 20,
|
triple_byte_pct: 20,
|
||||||
},
|
},
|
||||||
|
packets_per_hour_72h: [
|
||||||
|
{ timestamp: 1711792800, count: 12 },
|
||||||
|
{ timestamp: 1711796400, count: 8 },
|
||||||
|
],
|
||||||
noise_floor_24h: {
|
noise_floor_24h: {
|
||||||
sample_interval_seconds: 300,
|
sample_interval_seconds: 300,
|
||||||
coverage_seconds: 3600,
|
coverage_seconds: 3600,
|
||||||
@@ -722,6 +726,7 @@ describe('SettingsModal', () => {
|
|||||||
double_byte_pct: 30,
|
double_byte_pct: 30,
|
||||||
triple_byte_pct: 20,
|
triple_byte_pct: 20,
|
||||||
},
|
},
|
||||||
|
packets_per_hour_72h: [],
|
||||||
noise_floor_24h: {
|
noise_floor_24h: {
|
||||||
sample_interval_seconds: 300,
|
sample_interval_seconds: 300,
|
||||||
coverage_seconds: 0,
|
coverage_seconds: 0,
|
||||||
|
|||||||
@@ -544,6 +544,11 @@ export interface NoiseFloorHistoryStats {
|
|||||||
samples: NoiseFloorSample[];
|
samples: NoiseFloorSample[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PacketsPerHourBucket {
|
||||||
|
timestamp: number;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface StatisticsResponse {
|
export interface StatisticsResponse {
|
||||||
busiest_channels_24h: BusyChannel[];
|
busiest_channels_24h: BusyChannel[];
|
||||||
contact_count: number;
|
contact_count: number;
|
||||||
@@ -567,5 +572,6 @@ export interface StatisticsResponse {
|
|||||||
double_byte_pct: number;
|
double_byte_pct: number;
|
||||||
triple_byte_pct: number;
|
triple_byte_pct: number;
|
||||||
};
|
};
|
||||||
|
packets_per_hour_72h: PacketsPerHourBucket[];
|
||||||
noise_floor_24h: NoiseFloorHistoryStats;
|
noise_floor_24h: NoiseFloorHistoryStats;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const RAW_PACKET_STATS_WINDOW_SECONDS: Record<Exclude<RawPacketStatsWindow, 'ses
|
|||||||
|
|
||||||
export const MAX_RAW_PACKET_STATS_OBSERVATIONS = 20000;
|
export const MAX_RAW_PACKET_STATS_OBSERVATIONS = 20000;
|
||||||
|
|
||||||
const KNOWN_PAYLOAD_TYPES = [
|
export const KNOWN_PAYLOAD_TYPES = [
|
||||||
'Advert',
|
'Advert',
|
||||||
'GroupText',
|
'GroupText',
|
||||||
'TextMessage',
|
'TextMessage',
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# Maintainer: Jack Kingsman <jack.kingsman@gmail.com>
|
||||||
|
|
||||||
|
pkgname=remoteterm-meshcore
|
||||||
|
# pkgver is rewritten by .github/workflows/publish-aur.yml on each release.
|
||||||
|
pkgver=3.9.0
|
||||||
|
pkgrel=1
|
||||||
|
pkgdesc='Web interface for MeshCore mesh radio networks'
|
||||||
|
arch=(x86_64 aarch64)
|
||||||
|
url='https://github.com/jkingsman/Remote-Terminal-for-MeshCore'
|
||||||
|
license=('MIT')
|
||||||
|
# No system python dependency — we bundle a standalone interpreter via
|
||||||
|
# python-build-standalone so the package is immune to Arch python ABI bumps.
|
||||||
|
depends=(glibc)
|
||||||
|
makedepends=(uv nodejs npm)
|
||||||
|
optdepends=('bluez: BLE transport support')
|
||||||
|
backup=(etc/remoteterm-meshcore/remoteterm.env)
|
||||||
|
# The bundled python-build-standalone binary ships pre-stripped. makepkg's
|
||||||
|
# default strip pass corrupts its unusual ELF layout (.dynstr not in segment),
|
||||||
|
# so we disable stripping for the whole package.
|
||||||
|
options=(!strip)
|
||||||
|
install=remoteterm-meshcore.install
|
||||||
|
source=(
|
||||||
|
"$pkgname-$pkgver.tar.gz::https://github.com/jkingsman/Remote-Terminal-for-MeshCore/archive/refs/tags/$pkgver.tar.gz"
|
||||||
|
"remoteterm-meshcore.service"
|
||||||
|
"remoteterm.env"
|
||||||
|
)
|
||||||
|
# sha256sums are recomputed by `updpkgsums` in the publish workflow before
|
||||||
|
# the PKGBUILD is pushed to AUR. The committed values are intentionally SKIP
|
||||||
|
# so the file is honest about not tracking real hashes in this repo.
|
||||||
|
sha256sums=('SKIP'
|
||||||
|
'SKIP'
|
||||||
|
'SKIP')
|
||||||
|
|
||||||
|
# python-build-standalone: stripped install_only builds (~30 MB each).
|
||||||
|
# Bump _pyver and _pybuilddate when updating the bundled interpreter.
|
||||||
|
_pyver=3.13.13
|
||||||
|
_pybuilddate=20260408
|
||||||
|
|
||||||
|
source_x86_64=("python-${_pyver}-x86_64.tar.gz::https://github.com/astral-sh/python-build-standalone/releases/download/${_pybuilddate}/cpython-${_pyver}+${_pybuilddate}-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz")
|
||||||
|
sha256sums_x86_64=('SKIP')
|
||||||
|
|
||||||
|
source_aarch64=("python-${_pyver}-aarch64.tar.gz::https://github.com/astral-sh/python-build-standalone/releases/download/${_pybuilddate}/cpython-${_pyver}+${_pybuilddate}-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz")
|
||||||
|
sha256sums_aarch64=('SKIP')
|
||||||
|
|
||||||
|
_srcname="Remote-Terminal-for-MeshCore-$pkgver"
|
||||||
|
|
||||||
|
build() {
|
||||||
|
cd "$_srcname"
|
||||||
|
|
||||||
|
# Build frontend
|
||||||
|
cd frontend
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
# Create venv using the bundled standalone Python interpreter, then install
|
||||||
|
# Python dependencies into it. This produces a fully self-contained venv
|
||||||
|
# that does not reference the system Python at all.
|
||||||
|
uv venv --python "$srcdir/python/bin/python3" .venv
|
||||||
|
uv sync --no-dev --frozen
|
||||||
|
}
|
||||||
|
|
||||||
|
package() {
|
||||||
|
cd "$_srcname"
|
||||||
|
|
||||||
|
local _optdir=/opt/remoteterm-meshcore
|
||||||
|
local _instdir="$pkgdir$_optdir"
|
||||||
|
|
||||||
|
# App source
|
||||||
|
install -d "$_instdir"
|
||||||
|
cp -r app "$_instdir/"
|
||||||
|
cp pyproject.toml uv.lock "$_instdir/"
|
||||||
|
|
||||||
|
# Frontend build
|
||||||
|
install -d "$_instdir/frontend"
|
||||||
|
cp -r frontend/dist "$_instdir/frontend/"
|
||||||
|
|
||||||
|
# Bundled Python interpreter
|
||||||
|
cp -a "$srcdir/python" "$_instdir/python"
|
||||||
|
|
||||||
|
# Python venv
|
||||||
|
cp -a .venv "$_instdir/"
|
||||||
|
|
||||||
|
# Fix shebangs and venv config: replace build-time paths with final
|
||||||
|
# install paths so the venv works from /opt after installation.
|
||||||
|
# sed only operates on regular file contents, so symlinks need separate
|
||||||
|
# fixup below.
|
||||||
|
find "$_instdir/.venv/bin" -type f -exec \
|
||||||
|
sed -i "s|$srcdir/$_srcname/.venv|$_optdir/.venv|g" {} +
|
||||||
|
find "$_instdir/.venv/bin" -type f -exec \
|
||||||
|
sed -i "s|$srcdir/python|$_optdir/python|g" {} +
|
||||||
|
sed -i \
|
||||||
|
-e "s|$srcdir/$_srcname/.venv|$_optdir/.venv|g" \
|
||||||
|
-e "s|$srcdir/python|$_optdir/python|g" \
|
||||||
|
"$_instdir/.venv/pyvenv.cfg" 2>/dev/null || true
|
||||||
|
|
||||||
|
# Recreate the venv interpreter symlinks — these are symlinks (not files),
|
||||||
|
# so sed cannot fix them. Point them at the bundled Python.
|
||||||
|
ln -sf "$_optdir/python/bin/python3" "$_instdir/.venv/bin/python"
|
||||||
|
ln -sf python "$_instdir/.venv/bin/python3"
|
||||||
|
ln -sf python "$_instdir/.venv/bin/python3.13"
|
||||||
|
|
||||||
|
# Data directory symlink
|
||||||
|
ln -s /var/lib/remoteterm-meshcore "$_instdir/data"
|
||||||
|
|
||||||
|
# Systemd service
|
||||||
|
install -Dm644 "$srcdir/remoteterm-meshcore.service" \
|
||||||
|
"$pkgdir/usr/lib/systemd/system/remoteterm-meshcore.service"
|
||||||
|
|
||||||
|
# Environment file
|
||||||
|
install -Dm640 "$srcdir/remoteterm.env" \
|
||||||
|
"$pkgdir/etc/remoteterm-meshcore/remoteterm.env"
|
||||||
|
|
||||||
|
# License
|
||||||
|
install -Dm644 LICENSE.md \
|
||||||
|
"$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
pre_install() {
|
||||||
|
getent group remoteterm > /dev/null || groupadd -r remoteterm
|
||||||
|
getent passwd remoteterm > /dev/null || \
|
||||||
|
useradd -r -g remoteterm -d /var/lib/remoteterm-meshcore -s /sbin/nologin \
|
||||||
|
-c "RemoteTerm for MeshCore" remoteterm
|
||||||
|
}
|
||||||
|
|
||||||
|
pre_upgrade() {
|
||||||
|
pre_install
|
||||||
|
}
|
||||||
|
|
||||||
|
post_install() {
|
||||||
|
echo "==> Set your radio connection (serial, TCP, or BLE) in"
|
||||||
|
echo "==> /etc/remoteterm-meshcore/remoteterm.env"
|
||||||
|
echo "==> Start the service with: systemctl enable --now remoteterm-meshcore"
|
||||||
|
echo "==> The web UI will be at http://localhost:8000"
|
||||||
|
}
|
||||||
|
|
||||||
|
post_upgrade() {
|
||||||
|
# Clean orphaned __pycache__ dirs left by the previous Python version
|
||||||
|
find /opt/remoteterm-meshcore -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|
||||||
|
|
||||||
|
if command -v systemctl &>/dev/null && systemctl --version &>/dev/null 2>&1; then
|
||||||
|
systemctl daemon-reload
|
||||||
|
if systemctl is-active --quiet remoteterm-meshcore; then
|
||||||
|
systemctl restart remoteterm-meshcore
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
pre_remove() {
|
||||||
|
if command -v systemctl &>/dev/null && systemctl --version &>/dev/null 2>&1; then
|
||||||
|
systemctl disable --now remoteterm-meshcore 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
post_remove() {
|
||||||
|
if command -v systemctl &>/dev/null && systemctl --version &>/dev/null 2>&1; then
|
||||||
|
systemctl daemon-reload
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Database and config remain in /var/lib/remoteterm-meshcore/, remoteterm user retained."
|
||||||
|
echo "==> To fully clean up: sudo rm -rf /var/lib/remoteterm-meshcore"
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=RemoteTerm for MeshCore
|
||||||
|
Documentation=https://github.com/jkingsman/Remote-Terminal-for-MeshCore
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=remoteterm
|
||||||
|
Group=remoteterm
|
||||||
|
WorkingDirectory=/opt/remoteterm-meshcore
|
||||||
|
EnvironmentFile=/etc/remoteterm-meshcore/remoteterm.env
|
||||||
|
ExecStart=/opt/remoteterm-meshcore/.venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5s
|
||||||
|
|
||||||
|
StateDirectory=remoteterm-meshcore
|
||||||
|
|
||||||
|
# Hardening
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=yes
|
||||||
|
PrivateTmp=yes
|
||||||
|
NoNewPrivileges=yes
|
||||||
|
|
||||||
|
# Serial port access (uucp group on Arch)
|
||||||
|
SupplementaryGroups=uucp
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# RemoteTerm for MeshCore configuration
|
||||||
|
# https://github.com/jkingsman/Remote-Terminal-for-MeshCore
|
||||||
|
|
||||||
|
# Transport: uncomment ONE section below
|
||||||
|
|
||||||
|
# Serial auto-detect (default — no config needed)
|
||||||
|
|
||||||
|
# Serial manual port
|
||||||
|
#MESHCORE_SERIAL_PORT=/dev/ttyUSB0
|
||||||
|
|
||||||
|
# TCP
|
||||||
|
#MESHCORE_TCP_HOST=192.168.1.100
|
||||||
|
#MESHCORE_TCP_PORT=4000
|
||||||
|
|
||||||
|
# BLE (also requires the optional `bluez` package)
|
||||||
|
#MESHCORE_BLE_ADDRESS=AA:BB:CC:DD:EE:FF
|
||||||
|
#MESHCORE_BLE_PIN=123456
|
||||||
|
|
||||||
|
# Database
|
||||||
|
MESHCORE_DATABASE_PATH=/var/lib/remoteterm-meshcore/meshcore.db
|
||||||
|
|
||||||
|
# Bots can run arbitrary Python on the server. Leave this set to 'true' unless
|
||||||
|
# you trust everyone on your network.
|
||||||
|
MESHCORE_DISABLE_BOTS=true
|
||||||
|
|
||||||
|
# HTTP Basic Auth (recommended when bots are enabled)
|
||||||
|
#MESHCORE_BASIC_AUTH_USERNAME=
|
||||||
|
#MESHCORE_BASIC_AUTH_PASSWORD=
|
||||||
+1
-1
@@ -14,7 +14,7 @@ dependencies = [
|
|||||||
"pynacl>=1.5.0",
|
"pynacl>=1.5.0",
|
||||||
"meshcore==2.3.2",
|
"meshcore==2.3.2",
|
||||||
"aiomqtt>=2.0",
|
"aiomqtt>=2.0",
|
||||||
"apprise>=1.9.7",
|
"apprise>=1.9.8",
|
||||||
"boto3>=1.38.0",
|
"boto3>=1.38.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# test_aur_package.sh — Build the AUR package in one Arch container, then
|
||||||
|
# install and run it in a clean Arch container with port 8000 exposed.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./scripts/quality/test_aur_package.sh [--port PORT]
|
||||||
|
#
|
||||||
|
# The script streams application logs until you Ctrl-C.
|
||||||
|
|
||||||
|
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||||
|
PORT=8000
|
||||||
|
if [ "${1:-}" = "--port" ]; then PORT="${2:-8000}"; fi
|
||||||
|
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
CYAN='\033[0;36m'
|
||||||
|
BOLD='\033[1m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
ARTIFACT_DIR="$(mktemp -d)"
|
||||||
|
INSTALL_CONTAINER="remoteterm-aur-test-$$"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
echo
|
||||||
|
echo -e "${YELLOW}Cleaning up...${NC}"
|
||||||
|
docker rm -f "$INSTALL_CONTAINER" 2>/dev/null || true
|
||||||
|
rm -rf "$ARTIFACT_DIR"
|
||||||
|
echo -e "${GREEN}Done.${NC}"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
# ── Phase 1: Build ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
echo -e "${BOLD}=== Phase 1: Build AUR package ===${NC}"
|
||||||
|
|
||||||
|
docker run --rm \
|
||||||
|
-v "$REPO_ROOT/pkg/aur:/pkg:ro" \
|
||||||
|
-v "$ARTIFACT_DIR:/out" \
|
||||||
|
archlinux:latest bash -c '
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
pacman -Syu --noconfirm base-devel git curl >/dev/null 2>&1
|
||||||
|
curl -LsSf https://astral.sh/uv/install.sh | sh >/dev/null 2>&1
|
||||||
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
|
pacman -S --noconfirm nodejs npm >/dev/null 2>&1
|
||||||
|
|
||||||
|
useradd -m builder
|
||||||
|
echo "builder ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers
|
||||||
|
|
||||||
|
BUILD_DIR=/home/builder/build
|
||||||
|
mkdir -p "$BUILD_DIR"
|
||||||
|
cp /pkg/PKGBUILD /pkg/remoteterm-meshcore.install \
|
||||||
|
/pkg/remoteterm-meshcore.service /pkg/remoteterm.env "$BUILD_DIR/"
|
||||||
|
chown -R builder:builder "$BUILD_DIR"
|
||||||
|
|
||||||
|
echo "Building package..."
|
||||||
|
su builder -c "export PATH=\"$HOME/.local/bin:\$PATH\" && cd $BUILD_DIR && makepkg -sf --noconfirm" 2>&1
|
||||||
|
|
||||||
|
cp "$BUILD_DIR"/remoteterm-meshcore-*.pkg.tar.zst /out/
|
||||||
|
echo "Package artifact copied to /out/"
|
||||||
|
ls -lh /out/*.pkg.tar.zst
|
||||||
|
'
|
||||||
|
|
||||||
|
PKG_FILE="$(ls "$ARTIFACT_DIR"/*.pkg.tar.zst 2>/dev/null | head -1)"
|
||||||
|
if [ -z "$PKG_FILE" ]; then
|
||||||
|
echo -e "${RED}Build failed — no .pkg.tar.zst produced${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${GREEN}Built: $(basename "$PKG_FILE") ($(du -h "$PKG_FILE" | cut -f1))${NC}"
|
||||||
|
echo
|
||||||
|
|
||||||
|
# ── Phase 2: Install and run ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
echo -e "${BOLD}=== Phase 2: Install and run ===${NC}"
|
||||||
|
|
||||||
|
docker run -d \
|
||||||
|
--name "$INSTALL_CONTAINER" \
|
||||||
|
-p "$PORT:8000" \
|
||||||
|
-v "$ARTIFACT_DIR:/pkg:ro" \
|
||||||
|
archlinux:latest bash -c '
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Install the package (triggers pre_install which creates the remoteterm user)
|
||||||
|
pacman -Syu --noconfirm >/dev/null 2>&1
|
||||||
|
pacman -U --noconfirm /pkg/*.pkg.tar.zst
|
||||||
|
|
||||||
|
# Create the state directory (systemd StateDirectory= would do this on a real host)
|
||||||
|
mkdir -p /var/lib/remoteterm-meshcore
|
||||||
|
chown remoteterm:remoteterm /var/lib/remoteterm-meshcore
|
||||||
|
|
||||||
|
echo "============================================"
|
||||||
|
echo " RemoteTerm installed — starting server"
|
||||||
|
echo "============================================"
|
||||||
|
|
||||||
|
# Run as the remoteterm service user, matching the systemd unit
|
||||||
|
exec su -s /bin/bash remoteterm -c "cd /opt/remoteterm-meshcore && exec .venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000"
|
||||||
|
' >/dev/null
|
||||||
|
|
||||||
|
echo -e "${CYAN}Container:${NC} $INSTALL_CONTAINER"
|
||||||
|
echo -e "${CYAN}Listening:${NC} http://localhost:$PORT"
|
||||||
|
echo -e "${CYAN}Health: ${NC} http://localhost:$PORT/api/health"
|
||||||
|
echo
|
||||||
|
echo -e "${YELLOW}Streaming logs (Ctrl-C to stop and clean up)...${NC}"
|
||||||
|
echo
|
||||||
|
|
||||||
|
docker logs -f "$INSTALL_CONTAINER"
|
||||||
@@ -43,6 +43,7 @@ class TestStatisticsEmpty:
|
|||||||
"double_byte_pct": 0.0,
|
"double_byte_pct": 0.0,
|
||||||
"triple_byte_pct": 0.0,
|
"triple_byte_pct": 0.0,
|
||||||
}
|
}
|
||||||
|
assert result["packets_per_hour_72h"] == []
|
||||||
|
|
||||||
|
|
||||||
class TestStatisticsCounts:
|
class TestStatisticsCounts:
|
||||||
@@ -397,6 +398,54 @@ class TestPathHashWidthStats:
|
|||||||
assert breakdown["triple_byte"] == 1
|
assert breakdown["triple_byte"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestPacketsPerHour:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_buckets_packets_by_hour(self, test_db):
|
||||||
|
"""Packets within 72h are bucketed by hour."""
|
||||||
|
now = int(time.time())
|
||||||
|
hour_start = (now // 3600) * 3600
|
||||||
|
conn = test_db.conn
|
||||||
|
|
||||||
|
# 3 packets in the current hour, 1 in the previous hour
|
||||||
|
for i in range(3):
|
||||||
|
await conn.execute(
|
||||||
|
"INSERT INTO raw_packets (timestamp, data, payload_hash) VALUES (?, ?, ?)",
|
||||||
|
(hour_start + i, b"\x01", bytes([i]) * 32),
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
"INSERT INTO raw_packets (timestamp, data, payload_hash) VALUES (?, ?, ?)",
|
||||||
|
(hour_start - 1800, b"\x02", b"\xaa" * 32),
|
||||||
|
)
|
||||||
|
# 1 packet outside the 72h window — should be excluded
|
||||||
|
await conn.execute(
|
||||||
|
"INSERT INTO raw_packets (timestamp, data, payload_hash) VALUES (?, ?, ?)",
|
||||||
|
(now - 260000, b"\x03", b"\xbb" * 32),
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
|
||||||
|
result = await StatisticsRepository.get_all()
|
||||||
|
buckets = result["packets_per_hour_72h"]
|
||||||
|
|
||||||
|
assert len(buckets) == 2
|
||||||
|
by_ts = {b["timestamp"]: b["count"] for b in buckets}
|
||||||
|
assert by_ts[hour_start] == 3
|
||||||
|
assert by_ts[hour_start - 3600] == 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_empty_when_no_recent_packets(self, test_db):
|
||||||
|
"""Returns empty list when all packets are older than 72h."""
|
||||||
|
now = int(time.time())
|
||||||
|
conn = test_db.conn
|
||||||
|
await conn.execute(
|
||||||
|
"INSERT INTO raw_packets (timestamp, data, payload_hash) VALUES (?, ?, ?)",
|
||||||
|
(now - 300000, b"\x01", b"\x01" * 32),
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
|
||||||
|
result = await StatisticsRepository.get_all()
|
||||||
|
assert result["packets_per_hour_72h"] == []
|
||||||
|
|
||||||
|
|
||||||
class TestStatisticsEndpoint:
|
class TestStatisticsEndpoint:
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_statistics_endpoint_includes_noise_floor_history(self, test_db, client):
|
async def test_statistics_endpoint_includes_noise_floor_history(self, test_db, client):
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "apprise"
|
name = "apprise"
|
||||||
version = "1.9.7"
|
version = "1.9.9"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "certifi" },
|
{ name = "certifi" },
|
||||||
@@ -67,9 +67,9 @@ dependencies = [
|
|||||||
{ name = "requests-oauthlib" },
|
{ name = "requests-oauthlib" },
|
||||||
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/bc/f5/97dc06b3401bb67abcef6e8bef7155f192b75795c2a2aa4d59eb5aa7fa66/apprise-1.9.7.tar.gz", hash = "sha256:2f73cc1e0264fb119fdb9b7cde82e8fde40a0f531ac885d8c6f0cf0f6e13aec2", size = 1937173 }
|
sdist = { url = "https://files.pythonhosted.org/packages/20/f4/be5c7e39b83a2285ab62ae7c19bb10704836f59c0a5b4c471730f54c9f98/apprise-1.9.9.tar.gz", hash = "sha256:fd622c0df16bdc79ed385539735573488cafe2405d25747e87eebd6b09b26012", size = 2032822 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/fb/6b/cfa80a13437896eb8f4504ddac6dfa4ef7f1d2b2261057aa4a30003b8de6/apprise-1.9.7-py3-none-any.whl", hash = "sha256:c7640a81a1097685de66e0508e3da89f49235d566cb44bbead1dd98419bf5ee3", size = 1459879 },
|
{ url = "https://files.pythonhosted.org/packages/e6/2f/54d068d7e011a8b4e0aae3e93b09a30b33bcf780829fe70c6e8876aeb0e0/apprise-1.9.9-py3-none-any.whl", hash = "sha256:55ceb8827a1c783d683881c9f77fa42eb43b3fc91b854419c452d557101c7068", size = 1519940 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1022,7 +1022,7 @@ dev = [
|
|||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "aiomqtt", specifier = ">=2.0" },
|
{ name = "aiomqtt", specifier = ">=2.0" },
|
||||||
{ name = "aiosqlite", specifier = ">=0.19.0" },
|
{ name = "aiosqlite", specifier = ">=0.19.0" },
|
||||||
{ name = "apprise", specifier = ">=1.9.7" },
|
{ name = "apprise", specifier = ">=1.9.8" },
|
||||||
{ name = "boto3", specifier = ">=1.38.0" },
|
{ name = "boto3", specifier = ">=1.38.0" },
|
||||||
{ name = "fastapi", specifier = ">=0.115.0" },
|
{ name = "fastapi", specifier = ">=0.115.0" },
|
||||||
{ name = "httpx", specifier = ">=0.28.1" },
|
{ name = "httpx", specifier = ">=0.28.1" },
|
||||||
|
|||||||
Reference in New Issue
Block a user