Compare commits

...

8 Commits

Author SHA1 Message Date
Jack Kingsman 2030175e05 Fix port numbering 2026-04-10 00:03:50 -07:00
Jack Kingsman bb5af5ba82 Bump apprise to 1.9.9. Closes #173. 2026-04-09 17:20:57 -07:00
Jack Kingsman 159df1ec5b Revert "Add debug lines for fav click"
This reverts commit 8e2e039985.
2026-04-08 16:33:44 -07:00
Jack Kingsman 8e2e039985 Add debug lines for fav click 2026-04-08 16:18:46 -07:00
Jack Kingsman 01c86a486e Add packet feed filters; closes #169. 2026-04-08 14:44:41 -07:00
Jack Kingsman 7d5cfdec26 Add note about startup on windows 2026-04-07 22:07:31 -07:00
Jack Kingsman 5fe0ac0ad4 Be more memory concious on recent contact fetch 2026-04-07 16:41:34 -07:00
Jack Kingsman b98102ccac Add 72hr packet density view 2026-04-07 16:26:01 -07:00
18 changed files with 459 additions and 44 deletions
+9
View File
@@ -199,6 +199,15 @@ $env:MESHCORE_SERIAL_PORT="COM8" # or your COM port
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.
## Where To Go Next
+29
View File
@@ -12,6 +12,7 @@ from __future__ import annotations
import asyncio
import json
import logging
import sys
import time
from abc import ABC, abstractmethod
from typing import Any
@@ -252,6 +253,34 @@ class BaseMqttPublisher(ABC):
self._client = None
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()
broadcast_error(title, detail)
_broadcast_health()
+37 -1
View File
@@ -1,5 +1,41 @@
import asyncio
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 pathlib import Path
+6
View File
@@ -884,6 +884,11 @@ class NoiseFloorHistoryStats(BaseModel):
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):
busiest_channels_24h: list[BusyChannel]
contact_count: int
@@ -899,6 +904,7 @@ class StatisticsResponse(BaseModel):
repeaters_heard: ContactActivityCounts
known_channels_active: ContactActivityCounts
path_hash_width_24h: PathHashWidthStats
packets_per_hour_72h: list[PacketsPerHourBucket]
noise_floor_24h: NoiseFloorHistoryStats
+11 -4
View File
@@ -692,9 +692,18 @@ class ContactAdvertPathRepository:
cursor = await db.conn.execute(
"""
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
"""
""",
(limit_per_contact,),
)
rows = await cursor.fetchall()
@@ -705,8 +714,6 @@ class ContactAdvertPathRepository:
if paths is None:
paths = []
grouped[key] = paths
if len(paths) >= limit_per_contact:
continue
paths.append(ContactAdvertPathRepository._row_to_path(row))
return [
+22
View File
@@ -11,6 +11,7 @@ logger = logging.getLogger(__name__)
SECONDS_1H = 3600
SECONDS_24H = 86400
SECONDS_72H = 259200
SECONDS_7D = 604800
RAW_PACKET_STATS_BATCH_SIZE = 500
@@ -274,6 +275,25 @@ class StatisticsRepository:
"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
async def _path_hash_width_24h() -> dict[str, int | float]:
"""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)
known_channels_active = await StatisticsRepository._known_channels_active()
path_hash_width_24h = await StatisticsRepository._path_hash_width_24h()
packets_per_hour_72h = await StatisticsRepository._packets_per_hour_72h()
return {
"busiest_channels_24h": busiest_channels_24h,
@@ -366,4 +387,5 @@ class StatisticsRepository:
"repeaters_heard": repeaters_heard,
"known_channels_active": known_channels_active,
"path_hash_width_24h": path_hash_width_24h,
"packets_per_hour_72h": packets_per_hour_72h,
}
+1 -1
View File
@@ -31,7 +31,7 @@ services:
# TCP
# MESHCORE_TCP_HOST: 192.168.1.100
# MESHCORE_TCP_PORT: 4000
# MESHCORE_TCP_PORT: 5000
# BLE
# BLE in Docker usually needs additional manual compose changes such as
+179 -26
View File
@@ -11,11 +11,14 @@ import {
Cell,
} from 'recharts';
import { MeshCoreDecoder, Utils } from '@michaelhart/meshcore-decoder';
import { RawPacketList } from './RawPacketList';
import { RawPacketInspectorDialog } from './RawPacketDetailModal';
import { Button } from './ui/button';
import type { Channel, Contact, RawPacket } from '../types';
import {
KNOWN_PAYLOAD_TYPES,
RAW_PACKET_STATS_WINDOWS,
buildRawPacketStatsSnapshot,
type NeighborStat,
@@ -24,9 +27,26 @@ import {
type RawPacketStatsSessionState,
type RawPacketStatsWindow,
} from '../utils/rawPacketStats';
import { createDecoderOptions } from '../utils/rawPacketInspector';
import { getContactDisplayName } from '../utils/pubkey';
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 {
packets: RawPacket[];
rawPacketStatsSession: RawPacketStatsSessionState;
@@ -428,6 +448,48 @@ export function RawPacketFeedView({
const [nowSec, setNowSec] = useState(() => Math.floor(Date.now() / 1000));
const [selectedPacket, setSelectedPacket] = useState<RawPacket | null>(null);
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(() => {
const interval = window.setInterval(() => {
@@ -468,38 +530,129 @@ export function RawPacketFeedView({
);
return (
<>
<div className="flex items-center justify-between gap-3 border-b border-border px-4 py-2.5">
<div>
<h2 className="font-semibold text-base text-foreground">Raw Packet Feed</h2>
<p className="text-xs text-muted-foreground">
Collecting stats since {formatTimestamp(rawPacketStatsSession.sessionStartedAt)}
</p>
<div className="border-b border-border px-4 py-2.5">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="font-semibold text-base text-foreground">Raw Packet Feed</h2>
<p className="hidden md:block text-xs text-muted-foreground">
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 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>
<p className="md:hidden text-xs text-muted-foreground">
Collecting stats since {formatTimestamp(rawPacketStatsSession.sessionStartedAt)}
{!mobileFiltersOpen && (
<>
{' · '}
<button
type="button"
className="text-primary hover:text-primary/80 transition-colors"
onClick={() => setMobileFiltersOpen(true)}
>
Show Filters
</button>
</>
)}
</p>
{mobileFiltersOpen && (
<div className="mt-1.5 md:hidden 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 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 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')}>
<RawPacketList packets={packets} channels={channels} onPacketClick={setSelectedPacket} />
<RawPacketList
packets={filteredPackets}
channels={channels}
onPacketClick={setSelectedPacket}
/>
</div>
<aside
@@ -1666,7 +1666,8 @@ function AppriseConfigEditor({
rows={4}
/>
<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>
</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({
samples,
}: {
@@ -241,6 +322,17 @@ export function SettingsStatisticsSection({ className }: { className?: string })
</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 />
{/* Path Hash Width */}
+5
View File
@@ -652,6 +652,10 @@ describe('SettingsModal', () => {
double_byte_pct: 30,
triple_byte_pct: 20,
},
packets_per_hour_72h: [
{ timestamp: 1711792800, count: 12 },
{ timestamp: 1711796400, count: 8 },
],
noise_floor_24h: {
sample_interval_seconds: 300,
coverage_seconds: 3600,
@@ -722,6 +726,7 @@ describe('SettingsModal', () => {
double_byte_pct: 30,
triple_byte_pct: 20,
},
packets_per_hour_72h: [],
noise_floor_24h: {
sample_interval_seconds: 300,
coverage_seconds: 0,
+6
View File
@@ -544,6 +544,11 @@ export interface NoiseFloorHistoryStats {
samples: NoiseFloorSample[];
}
interface PacketsPerHourBucket {
timestamp: number;
count: number;
}
export interface StatisticsResponse {
busiest_channels_24h: BusyChannel[];
contact_count: number;
@@ -567,5 +572,6 @@ export interface StatisticsResponse {
double_byte_pct: number;
triple_byte_pct: number;
};
packets_per_hour_72h: PacketsPerHourBucket[];
noise_floor_24h: NoiseFloorHistoryStats;
}
+1 -1
View File
@@ -15,7 +15,7 @@ const RAW_PACKET_STATS_WINDOW_SECONDS: Record<Exclude<RawPacketStatsWindow, 'ses
export const MAX_RAW_PACKET_STATS_OBSERVATIONS = 20000;
const KNOWN_PAYLOAD_TYPES = [
export const KNOWN_PAYLOAD_TYPES = [
'Advert',
'GroupText',
'TextMessage',
+1 -1
View File
@@ -14,7 +14,7 @@ dependencies = [
"pynacl>=1.5.0",
"meshcore==2.3.2",
"aiomqtt>=2.0",
"apprise>=1.9.7",
"apprise>=1.9.8",
"boto3>=1.38.0",
]
+3 -3
View File
@@ -35,7 +35,7 @@ SERIAL_HOST_PATH="/dev/ttyACM0"
SERIAL_COMPOSE_HOST_PATH="/dev/ttyACM0"
SERIAL_CONTAINER_PATH="/dev/meshcore-radio"
TCP_HOST=""
TCP_PORT="4000"
TCP_PORT="5000"
BLE_ADDRESS=""
BLE_PIN=""
ENABLE_BOTS="N"
@@ -311,8 +311,8 @@ case "$TRANSPORT_CHOICE" in
echo -e "${RED}TCP host is required.${NC}"
read -r -p "TCP host: " TCP_HOST
done
read -r -p "TCP port (default: 4000): " TCP_PORT
TCP_PORT="${TCP_PORT:-4000}"
read -r -p "TCP port (default: 5000): " TCP_PORT
TCP_PORT="${TCP_PORT:-5000}"
echo -e "${GREEN}TCP: ${TCP_HOST}:${TCP_PORT}${NC}"
;;
3)
+2 -2
View File
@@ -114,8 +114,8 @@ case "$TRANSPORT_CHOICE" in
echo -e "${RED}TCP host is required.${NC}"
read -rp "TCP host: " TCP_HOST
done
read -rp "TCP port (default: 4000): " TCP_PORT
TCP_PORT="${TCP_PORT:-4000}"
read -rp "TCP port (default: 5000): " TCP_PORT
TCP_PORT="${TCP_PORT:-5000}"
echo -e "${GREEN}TCP: ${TCP_HOST}:${TCP_PORT}${NC}"
;;
4)
+49
View File
@@ -43,6 +43,7 @@ class TestStatisticsEmpty:
"double_byte_pct": 0.0,
"triple_byte_pct": 0.0,
}
assert result["packets_per_hour_72h"] == []
class TestStatisticsCounts:
@@ -397,6 +398,54 @@ class TestPathHashWidthStats:
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:
@pytest.mark.asyncio
async def test_statistics_endpoint_includes_noise_floor_history(self, test_db, client):
Generated
+4 -4
View File
@@ -56,7 +56,7 @@ wheels = [
[[package]]
name = "apprise"
version = "1.9.7"
version = "1.9.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
@@ -67,9 +67,9 @@ dependencies = [
{ name = "requests-oauthlib" },
{ 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 = [
{ 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]]
@@ -1022,7 +1022,7 @@ dev = [
requires-dist = [
{ name = "aiomqtt", specifier = ">=2.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 = "fastapi", specifier = ">=0.115.0" },
{ name = "httpx", specifier = ">=0.28.1" },