mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-09 23:05:10 +02:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7528e4121f | |||
| 291bd85c78 | |||
| 4bc87b4a0f | |||
| 6d0434d59e | |||
| f22184c166 | |||
| d10de8abf7 | |||
| 5f78294cd1 | |||
| 6b81dd3082 | |||
| cc2b16e53f | |||
| 330007e120 | |||
| f5a2a21f11 | |||
| a3e62885d4 | |||
| dbdd722c48 | |||
| b8683e57d8 |
@@ -0,0 +1,10 @@
|
||||
name: "RemoteTerm CodeQL config"
|
||||
|
||||
# Exclude rules that flag intentional design decisions:
|
||||
# - AES-ECB is required by the MeshCore radio protocol wire format
|
||||
# - Repeater/room passwords are not meaningfully sensitive secrets
|
||||
query-filters:
|
||||
- exclude:
|
||||
id: py/weak-cryptographic-algorithm
|
||||
- exclude:
|
||||
id: js/clear-text-storage-of-sensitive-data
|
||||
@@ -4,6 +4,9 @@ on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
backend-checks:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
name: CodeQL
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: "0 6 * * 1"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [javascript-typescript, python]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
config-file: .github/codeql/codeql-config.yml
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v3
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
@@ -25,6 +25,9 @@ concurrency:
|
||||
group: publish-aur
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
publish-aur:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
## [3.12.1] - 2026-04-19
|
||||
|
||||
* Feature: Auto-evict/circular-buffer contact load mode (solves potential T-Beam issues)
|
||||
* Feature: Channel mute
|
||||
* Misc: HA Documentation improvements
|
||||
* Misc: Bump deps & update tests
|
||||
* Misc: Improve warnings around web push in untrusted contexts
|
||||
|
||||
## [3.12.0] - 2026-04-17
|
||||
|
||||
* Feature: Web Push -- get your mesh notifications on a locked phone or when your browser is closed!
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# RemoteTerm for MeshCore
|
||||
|
||||
Backend server + browser interface for MeshCore mesh radio networks. Connect your radio over Serial, TCP, or BLE, and then you can:
|
||||
Backend server + browser interface for MeshCore mesh radio networks, providing a rich, web-based power-user management and messaging system through a companion radio.
|
||||
|
||||
Connect your radio over Serial, TCP, or BLE, and then you can:
|
||||
|
||||
* Send and receive DMs and channel messages
|
||||
* Cache all received packets, decrypting as you gain keys
|
||||
@@ -8,8 +10,8 @@ Backend server + browser interface for MeshCore mesh radio networks. Connect you
|
||||
* Monitor unlimited contacts and channels (radio limits don't apply -- packets are decrypted server-side)
|
||||
* Access your radio remotely over your network or VPN
|
||||
* Search for hashtag channel names for channels you don't have keys for yet
|
||||
* Forward packets to MQTT, LetsMesh, MeshRank, SQS, Apprise, etc.
|
||||
* Use the more recent 1.14 firmwares which support multibyte pathing
|
||||
* Forward packets, messages, and automatic repeater telemetry to MQTT, Home Assistant, LetsMesh, MeshRank, SQS, Apprise, etc.
|
||||
* Use the more recent 1.14+ firmwares which support multibyte pathing
|
||||
* Visualize the mesh as a map or node set, view repeater stats, and more!
|
||||
|
||||
For advanced setup and troubleshooting see [README_ADVANCED.md](README_ADVANCED.md). If you plan to contribute, read [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
@@ -26,6 +26,7 @@ class Settings(BaseSettings):
|
||||
default=False,
|
||||
validation_alias="__CLOWNTOWN_DO_CLOCK_WRAPAROUND",
|
||||
)
|
||||
enable_local_private_key_export: bool = False
|
||||
load_with_autoevict: bool = False
|
||||
skip_post_connect_sync: bool = False
|
||||
basic_auth_username: str = ""
|
||||
|
||||
+19
@@ -180,6 +180,25 @@ async def radio_disconnected_handler(request: Request, exc: RadioDisconnectedErr
|
||||
return JSONResponse(status_code=503, content={"detail": "Radio not connected"})
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def log_server_errors(request: Request, call_next):
|
||||
"""Capture 5xx errors and unhandled exceptions into the log ring buffer.
|
||||
|
||||
Starlette writes unhandled-exception tracebacks to stderr, bypassing
|
||||
Python logging, so they never reach the debug dump. This middleware
|
||||
catches them and logs via ``logger.exception()`` so the full traceback
|
||||
is preserved in the ring buffer for the ``GET /api/debug`` snapshot.
|
||||
"""
|
||||
try:
|
||||
response = await call_next(request)
|
||||
except Exception:
|
||||
logger.exception("Unhandled exception on %s %s", request.method, request.url.path)
|
||||
raise
|
||||
if response.status_code >= 500:
|
||||
logger.error("HTTP %d on %s %s", response.status_code, request.method, request.url.path)
|
||||
return response
|
||||
|
||||
|
||||
# API routes - all prefixed with /api for production compatibility
|
||||
app.include_router(health.router, prefix="/api")
|
||||
app.include_router(debug.router, prefix="/api")
|
||||
|
||||
+34
-5
@@ -64,7 +64,6 @@ class DebugRuntimeInfo(BaseModel):
|
||||
path_hash_mode_supported: bool
|
||||
channel_slot_reuse_enabled: bool
|
||||
channel_send_cache_capacity: int
|
||||
remediation_flags: dict[str, bool]
|
||||
|
||||
|
||||
class DebugContactAudit(BaseModel):
|
||||
@@ -110,6 +109,21 @@ class DebugHealthSummary(BaseModel):
|
||||
basic_auth_enabled: bool = False
|
||||
|
||||
|
||||
class DebugEnvironment(BaseModel):
|
||||
connection_type: str
|
||||
serial_port: str
|
||||
serial_baudrate: int
|
||||
tcp_host: str
|
||||
tcp_port: int
|
||||
ble_address: str
|
||||
log_level: str
|
||||
database_path: str
|
||||
disable_bots: bool
|
||||
enable_message_poll_fallback: bool
|
||||
force_channel_slot_reconfigure: bool
|
||||
load_with_autoevict: bool
|
||||
|
||||
|
||||
class DebugAppSettings(BaseModel):
|
||||
max_radio_contacts: int
|
||||
auto_decrypt_dm_on_advert: bool
|
||||
@@ -123,6 +137,7 @@ class DebugSnapshotResponse(BaseModel):
|
||||
captured_at: str
|
||||
system: DebugSystemInfo
|
||||
application: DebugApplicationInfo
|
||||
environment: DebugEnvironment
|
||||
health: DebugHealthSummary
|
||||
settings: DebugAppSettings
|
||||
runtime: DebugRuntimeInfo
|
||||
@@ -203,6 +218,23 @@ def _coerce_live_max_channels(device_info: dict[str, Any] | None) -> int | None:
|
||||
return None
|
||||
|
||||
|
||||
def _build_environment() -> DebugEnvironment:
|
||||
return DebugEnvironment(
|
||||
connection_type=settings.connection_type,
|
||||
serial_port=settings.serial_port,
|
||||
serial_baudrate=settings.serial_baudrate,
|
||||
tcp_host=settings.tcp_host,
|
||||
tcp_port=settings.tcp_port,
|
||||
ble_address=settings.ble_address,
|
||||
log_level=settings.log_level,
|
||||
database_path=settings.database_path,
|
||||
disable_bots=settings.disable_bots,
|
||||
enable_message_poll_fallback=settings.enable_message_poll_fallback,
|
||||
force_channel_slot_reconfigure=settings.force_channel_slot_reconfigure,
|
||||
load_with_autoevict=settings.load_with_autoevict,
|
||||
)
|
||||
|
||||
|
||||
def _build_debug_app_settings(app_settings: AppSettings) -> DebugAppSettings:
|
||||
return DebugAppSettings(
|
||||
max_radio_contacts=app_settings.max_radio_contacts,
|
||||
@@ -393,6 +425,7 @@ async def debug_support_snapshot() -> DebugSnapshotResponse:
|
||||
captured_at=datetime.now(UTC).isoformat(),
|
||||
system=_build_system_info(),
|
||||
application=_build_application_info(),
|
||||
environment=_build_environment(),
|
||||
health=_build_debug_health_summary(health_data, radio_state=radio_state),
|
||||
settings=_build_debug_app_settings(app_settings),
|
||||
runtime=DebugRuntimeInfo(
|
||||
@@ -404,10 +437,6 @@ async def debug_support_snapshot() -> DebugSnapshotResponse:
|
||||
path_hash_mode_supported=radio_runtime.path_hash_mode_supported,
|
||||
channel_slot_reuse_enabled=radio_runtime.channel_slot_reuse_enabled(),
|
||||
channel_send_cache_capacity=radio_runtime.get_channel_send_cache_capacity(),
|
||||
remediation_flags={
|
||||
"enable_message_poll_fallback": settings.enable_message_poll_fallback,
|
||||
"force_channel_slot_reconfigure": settings.force_channel_slot_reconfigure,
|
||||
},
|
||||
),
|
||||
database=DebugDatabaseInfo(
|
||||
total_dms=message_totals["total_dms"],
|
||||
|
||||
@@ -40,6 +40,8 @@ class RadioStatsSnapshot(BaseModel):
|
||||
# Core stats
|
||||
battery_mv: int | None = None
|
||||
uptime_secs: int | None = None
|
||||
queue_len: int | None = None
|
||||
errors: int | None = None
|
||||
# Radio stats
|
||||
noise_floor: int | None = None
|
||||
last_rssi: int | None = None
|
||||
@@ -155,6 +157,8 @@ async def build_health_data(radio_connected: bool, connection_info: str | None)
|
||||
"timestamp": raw_stats.get("timestamp"),
|
||||
"battery_mv": raw_stats.get("battery_mv"),
|
||||
"uptime_secs": raw_stats.get("uptime_secs"),
|
||||
"queue_len": raw_stats.get("queue_len"),
|
||||
"errors": raw_stats.get("errors"),
|
||||
"noise_floor": raw_stats.get("noise_floor"),
|
||||
"last_rssi": raw_stats.get("last_rssi"),
|
||||
"last_snr": raw_stats.get("last_snr"),
|
||||
|
||||
@@ -385,6 +385,30 @@ async def update_radio_config(update: RadioConfigUpdate) -> RadioConfigResponse:
|
||||
return await get_radio_config()
|
||||
|
||||
|
||||
@router.get("/private-key")
|
||||
async def get_private_key() -> dict:
|
||||
"""Return the in-memory private key (exported from radio on startup).
|
||||
|
||||
Gated behind MESHCORE_ENABLE_LOCAL_PRIVATE_KEY_EXPORT=true.
|
||||
"""
|
||||
from app.config import settings
|
||||
from app.keystore import get_private_key as ks_get
|
||||
|
||||
if not settings.enable_local_private_key_export:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Private key export is disabled (set MESHCORE_ENABLE_LOCAL_PRIVATE_KEY_EXPORT=true)",
|
||||
)
|
||||
|
||||
key = ks_get()
|
||||
if key is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Private key not available (not exported from radio)",
|
||||
)
|
||||
return {"private_key": key.hex()}
|
||||
|
||||
|
||||
@router.put("/private-key")
|
||||
async def set_private_key(update: PrivateKeyUpdate) -> dict:
|
||||
"""Set the radio's private key. This is write-only."""
|
||||
|
||||
@@ -258,6 +258,12 @@ async def send_channel_message_with_effective_scope(
|
||||
)
|
||||
raise HTTPException(status_code=504, detail=NO_RADIO_RESPONSE_AFTER_SEND_DETAIL)
|
||||
if send_result.type == EventType.ERROR:
|
||||
logger.error(
|
||||
"Radio returned error during %s for channel %s: %s",
|
||||
action_label,
|
||||
channel.name,
|
||||
send_result.payload,
|
||||
)
|
||||
radio_manager.invalidate_cached_channel_slot(channel_key)
|
||||
else:
|
||||
radio_manager.note_channel_slot_used(channel_key)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "remoteterm-meshcore-frontend",
|
||||
"private": true,
|
||||
"version": "3.12.0",
|
||||
"version": "3.12.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -96,6 +96,7 @@ export const api = {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(config),
|
||||
}),
|
||||
getPrivateKey: () => fetchJson<{ private_key: string }>('/radio/private-key'),
|
||||
setPrivateKey: (privateKey: string) =>
|
||||
fetchJson<{ status: string }>('/radio/private-key', {
|
||||
method: 'PUT',
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { MapPinned } from 'lucide-react';
|
||||
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { ChevronDown, Download, MapPinned, Upload } from 'lucide-react';
|
||||
import { Input } from '../ui/input';
|
||||
import { Label } from '../ui/label';
|
||||
import { Button } from '../ui/button';
|
||||
import { Separator } from '../ui/separator';
|
||||
import { toast } from '../ui/sonner';
|
||||
import { Checkbox } from '../ui/checkbox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '../ui/dialog';
|
||||
import { api } from '../../api';
|
||||
import { RADIO_PRESETS } from '../../utils/radioPresets';
|
||||
import { stripRegionScopePrefix } from '../../utils/regionScope';
|
||||
import type {
|
||||
@@ -17,8 +26,116 @@ import type {
|
||||
RadioConfigUpdate,
|
||||
RadioDiscoveryResponse,
|
||||
RadioDiscoveryTarget,
|
||||
RadioStatsSnapshot,
|
||||
} from '../../types';
|
||||
|
||||
function formatUptime(secs: number): string {
|
||||
const days = Math.floor(secs / 86400);
|
||||
const hours = Math.floor((secs % 86400) / 3600);
|
||||
const minutes = Math.floor((secs % 3600) / 60);
|
||||
if (days > 0) return `${days}d ${hours}h ${minutes}m`;
|
||||
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
function formatAirtime(secs: number): string {
|
||||
if (secs < 60) return `${secs}s`;
|
||||
const hours = Math.floor(secs / 3600);
|
||||
const minutes = Math.floor((secs % 3600) / 60);
|
||||
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
function StatRow({ label, value, warn }: { label: string; value: string; warn?: boolean }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 py-0.5">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<span
|
||||
className={`text-xs font-mono tabular-nums ${warn ? 'text-warning font-semibold' : ''}`}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RadioDetailsCollapsible({ stats }: { stats: RadioStatsSnapshot }) {
|
||||
const age = stats.timestamp ? Math.max(0, Math.floor(Date.now() / 1000) - stats.timestamp) : null;
|
||||
const packets = {
|
||||
recv: stats.packets_recv,
|
||||
sent: stats.packets_sent,
|
||||
flood_tx: stats.flood_tx,
|
||||
direct_tx: stats.direct_tx,
|
||||
flood_rx: stats.flood_rx,
|
||||
direct_rx: stats.direct_rx,
|
||||
};
|
||||
|
||||
return (
|
||||
<details className="group">
|
||||
<summary className="text-sm font-medium text-foreground cursor-pointer select-none flex items-center gap-1">
|
||||
<ChevronDown className="h-3 w-3 transition-transform group-open:rotate-0 -rotate-90" />
|
||||
Radio Details
|
||||
</summary>
|
||||
<div className="mt-2 space-y-2 rounded-md border border-input bg-muted/20 p-3">
|
||||
{age !== null && (
|
||||
<p className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
Updated {age < 5 ? 'just now' : `${age}s ago`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Core */}
|
||||
{stats.uptime_secs != null && (
|
||||
<StatRow label="Uptime" value={formatUptime(stats.uptime_secs)} />
|
||||
)}
|
||||
{stats.battery_mv != null && stats.battery_mv > 0 && (
|
||||
<StatRow label="Battery" value={`${(stats.battery_mv / 1000).toFixed(2)}V`} />
|
||||
)}
|
||||
{stats.queue_len != null && (
|
||||
<StatRow
|
||||
label="TX Queue"
|
||||
value={`${stats.queue_len} / 16`}
|
||||
warn={stats.queue_len >= 14}
|
||||
/>
|
||||
)}
|
||||
{stats.errors != null && (
|
||||
<StatRow label="Errors" value={String(stats.errors)} warn={stats.errors > 0} />
|
||||
)}
|
||||
|
||||
{/* RF */}
|
||||
{stats.noise_floor != null && (
|
||||
<StatRow label="Noise Floor" value={`${stats.noise_floor} dBm`} />
|
||||
)}
|
||||
{stats.last_rssi != null && <StatRow label="Last RSSI" value={`${stats.last_rssi} dBm`} />}
|
||||
{stats.last_snr != null && <StatRow label="Last SNR" value={`${stats.last_snr} dB`} />}
|
||||
|
||||
{/* Airtime */}
|
||||
{(stats.tx_air_secs != null || stats.rx_air_secs != null) && (
|
||||
<>
|
||||
{stats.tx_air_secs != null && (
|
||||
<StatRow label="TX Airtime" value={formatAirtime(stats.tx_air_secs)} />
|
||||
)}
|
||||
{stats.rx_air_secs != null && (
|
||||
<StatRow label="RX Airtime" value={formatAirtime(stats.rx_air_secs)} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Packets */}
|
||||
{packets.recv != null && <StatRow label="Packets Received" value={String(packets.recv)} />}
|
||||
{packets.sent != null && <StatRow label="Packets Sent" value={String(packets.sent)} />}
|
||||
{packets.flood_tx != null && <StatRow label="Flood TX" value={String(packets.flood_tx)} />}
|
||||
{packets.flood_rx != null && <StatRow label="Flood RX" value={String(packets.flood_rx)} />}
|
||||
{packets.direct_tx != null && (
|
||||
<StatRow label="Direct TX" value={String(packets.direct_tx)} />
|
||||
)}
|
||||
{packets.direct_rx != null && (
|
||||
<StatRow label="Direct RX" value={String(packets.direct_rx)} />
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsRadioSection({
|
||||
config,
|
||||
health,
|
||||
@@ -320,6 +437,169 @@ export function SettingsRadioSection({
|
||||
}
|
||||
};
|
||||
|
||||
const importInputRef = useRef<HTMLInputElement>(null);
|
||||
const [keyImportDialogOpen, setKeyImportDialogOpen] = useState(false);
|
||||
const pendingImportRef = useRef<Record<string, unknown> | null>(null);
|
||||
|
||||
const buildConfigProfile = () => ({
|
||||
version: 1,
|
||||
exported_at: new Date().toISOString(),
|
||||
name: config.name,
|
||||
lat: config.lat,
|
||||
lon: config.lon,
|
||||
tx_power: config.tx_power,
|
||||
radio: { ...config.radio },
|
||||
path_hash_mode: config.path_hash_mode,
|
||||
advert_location_source: config.advert_location_source ?? 'current',
|
||||
multi_acks_enabled: config.multi_acks_enabled ?? false,
|
||||
});
|
||||
|
||||
const downloadJson = (profile: object, suffix: string) => {
|
||||
const blob = new Blob([JSON.stringify(profile, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
const safeName = (config.name || 'radio').replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||
const timestamp = new Date()
|
||||
.toLocaleString(undefined, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
})
|
||||
.replace(/[/:, ]+/g, '-');
|
||||
a.download = `${safeName}-${suffix}-${timestamp}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleExportConfig = async () => {
|
||||
const profile = buildConfigProfile();
|
||||
try {
|
||||
const { private_key } = await api.getPrivateKey();
|
||||
downloadJson({ ...profile, private_key }, 'config');
|
||||
toast.success('Export generated with private key');
|
||||
} catch {
|
||||
downloadJson(profile, 'config');
|
||||
toast.info('Export generated without private key', {
|
||||
description: 'See README_ADVANCED.md for private key export enable',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const validateImportData = (
|
||||
data: unknown
|
||||
): data is {
|
||||
name: string;
|
||||
radio: { freq: number; bw: number; sf: number; cr: number };
|
||||
[k: string]: unknown;
|
||||
} =>
|
||||
typeof data === 'object' &&
|
||||
data !== null &&
|
||||
'name' in data &&
|
||||
typeof (data as Record<string, unknown>).name === 'string' &&
|
||||
'radio' in data &&
|
||||
typeof (data as Record<string, unknown>).radio === 'object' &&
|
||||
(data as Record<string, unknown>).radio !== null &&
|
||||
typeof (data as Record<string, Record<string, unknown>>).radio.freq === 'number' &&
|
||||
typeof (data as Record<string, Record<string, unknown>>).radio.bw === 'number' &&
|
||||
typeof (data as Record<string, Record<string, unknown>>).radio.sf === 'number' &&
|
||||
typeof (data as Record<string, Record<string, unknown>>).radio.cr === 'number';
|
||||
|
||||
const populateFormFromImport = (data: Record<string, unknown>) => {
|
||||
const radio = data.radio as { freq: number; bw: number; sf: number; cr: number };
|
||||
setName(data.name as string);
|
||||
if (typeof data.lat === 'number') setLat(String(data.lat));
|
||||
if (typeof data.lon === 'number') setLon(String(data.lon));
|
||||
if (typeof data.tx_power === 'number') setTxPower(String(data.tx_power));
|
||||
setFreq(String(radio.freq));
|
||||
setBw(String(radio.bw));
|
||||
setSf(String(radio.sf));
|
||||
setCr(String(radio.cr));
|
||||
if (typeof data.path_hash_mode === 'number') setPathHashMode(String(data.path_hash_mode));
|
||||
if (data.advert_location_source === 'off' || data.advert_location_source === 'current')
|
||||
setAdvertLocationSource(data.advert_location_source);
|
||||
if (typeof data.multi_acks_enabled === 'boolean') setMultiAcksEnabled(data.multi_acks_enabled);
|
||||
};
|
||||
|
||||
const buildUpdateFromImport = (data: Record<string, unknown>): RadioConfigUpdate => {
|
||||
const radio = data.radio as { freq: number; bw: number; sf: number; cr: number };
|
||||
const update: RadioConfigUpdate = {
|
||||
name: data.name as string,
|
||||
lat: typeof data.lat === 'number' ? data.lat : config.lat,
|
||||
lon: typeof data.lon === 'number' ? data.lon : config.lon,
|
||||
tx_power: typeof data.tx_power === 'number' ? (data.tx_power as number) : config.tx_power,
|
||||
radio,
|
||||
};
|
||||
if (data.advert_location_source === 'off' || data.advert_location_source === 'current')
|
||||
update.advert_location_source = data.advert_location_source;
|
||||
if (typeof data.multi_acks_enabled === 'boolean')
|
||||
update.multi_acks_enabled = data.multi_acks_enabled;
|
||||
if (config.path_hash_mode_supported && typeof data.path_hash_mode === 'number')
|
||||
update.path_hash_mode = data.path_hash_mode as number;
|
||||
return update;
|
||||
};
|
||||
|
||||
const applyImport = async (data: Record<string, unknown>) => {
|
||||
populateFormFromImport(data);
|
||||
const update = buildUpdateFromImport(data);
|
||||
|
||||
setBusy(true);
|
||||
setRebooting(true);
|
||||
try {
|
||||
if (typeof data.private_key === 'string' && data.private_key) {
|
||||
await onSetPrivateKey(data.private_key);
|
||||
toast.success('Config + private key imported, saving & rebooting...');
|
||||
} else {
|
||||
toast.success('Config imported, saving & rebooting...');
|
||||
}
|
||||
await onSave(update);
|
||||
await onReboot();
|
||||
if (!pageMode) onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to import config');
|
||||
} finally {
|
||||
setRebooting(false);
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportConfig = async (file: File) => {
|
||||
try {
|
||||
const text = await file.text();
|
||||
const data = JSON.parse(text);
|
||||
|
||||
if (!validateImportData(data)) {
|
||||
toast.error('Invalid config file', {
|
||||
description: 'File must contain name and radio parameters (freq, bw, sf, cr)',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof data.private_key === 'string' && data.private_key) {
|
||||
// Private key present — show warning dialog before applying
|
||||
pendingImportRef.current = data;
|
||||
setKeyImportDialogOpen(true);
|
||||
} else {
|
||||
await applyImport(data);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to import config');
|
||||
} finally {
|
||||
if (importInputRef.current) importInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmKeyImport = async () => {
|
||||
setKeyImportDialogOpen(false);
|
||||
const data = pendingImportRef.current;
|
||||
pendingImportRef.current = null;
|
||||
if (data) await applyImport(data);
|
||||
};
|
||||
|
||||
const radioState =
|
||||
health?.radio_state ?? (health?.radio_initializing ? 'initializing' : 'disconnected');
|
||||
const connectionActionLabel =
|
||||
@@ -414,6 +694,9 @@ export function SettingsRadioSection({
|
||||
</span>
|
||||
</div>
|
||||
{deviceInfoLabel && <p className="text-sm text-muted-foreground">{deviceInfoLabel}</p>}
|
||||
|
||||
{health?.radio_stats && <RadioDetailsCollapsible stats={health.radio_stats} />}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -678,6 +961,37 @@ export function SettingsRadioSection({
|
||||
Some settings may require a reboot to take effect on some radios.
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleExportConfig} className="flex-1">
|
||||
<Download className="mr-1.5 h-4 w-4" aria-hidden="true" />
|
||||
Export Config
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => importInputRef.current?.click()}
|
||||
disabled={busy || rebooting}
|
||||
className="flex-1"
|
||||
>
|
||||
<Upload className="mr-1.5 h-4 w-4" aria-hidden="true" />
|
||||
Import & Reboot
|
||||
</Button>
|
||||
<input
|
||||
ref={importInputRef}
|
||||
type="file"
|
||||
accept=".json"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleImportConfig(file);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
Export saves the current server config to a JSON file. Import loads a config file, applies
|
||||
it, and reboots the radio.
|
||||
</p>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* ── Messaging ── */}
|
||||
@@ -733,9 +1047,9 @@ export function SettingsRadioSection({
|
||||
placeholder="MyRegion"
|
||||
/>
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
Tag outgoing flood messages with a region name (e.g. MyRegion). Repeaters configured for
|
||||
that region can forward the traffic, while repeaters configured to deny other regions may
|
||||
drop it. Leave empty to disable.
|
||||
Tag outgoing messages with a region name (e.g. MyRegion). Repeaters configured for that
|
||||
region can forward the traffic, while repeaters configured to deny other regions may drop
|
||||
it. Leave empty to disable.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -907,6 +1221,44 @@ export function SettingsRadioSection({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Private Key Import Warning ── */}
|
||||
<Dialog
|
||||
open={keyImportDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setKeyImportDialogOpen(open);
|
||||
if (!open) pendingImportRef.current = null;
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Import includes Private Key</DialogTitle>
|
||||
<DialogDescription>
|
||||
This config file contains a private key. Importing it will change your radio's
|
||||
identity — your radio will have a new public key and other nodes will see it as
|
||||
a different device. This cannot be undone without the original key.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setKeyImportDialogOpen(false);
|
||||
pendingImportRef.current = null;
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirmKeyImport}
|
||||
className="border-destructive/50 text-destructive hover:bg-destructive/10"
|
||||
variant="outline"
|
||||
>
|
||||
Import Config & Key
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,6 +37,33 @@ function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
||||
return arr;
|
||||
}
|
||||
|
||||
/** Race a promise against a timeout; rejects with a descriptive error on expiry. */
|
||||
function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(
|
||||
() =>
|
||||
reject(
|
||||
new Error(
|
||||
`${label} timed out — the service worker may have failed to install. ` +
|
||||
'Mobile browsers require a trusted TLS certificate for service workers, ' +
|
||||
'even if the page itself loads with a self-signed cert.'
|
||||
)
|
||||
),
|
||||
ms
|
||||
);
|
||||
promise.then(
|
||||
(v) => {
|
||||
clearTimeout(timer);
|
||||
resolve(v);
|
||||
},
|
||||
(e) => {
|
||||
clearTimeout(timer);
|
||||
reject(e);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function uint8ArraysEqual(a: Uint8Array | null, b: Uint8Array): boolean {
|
||||
if (!a || a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
@@ -109,8 +136,9 @@ export function usePushSubscription(): PushSubscriptionState {
|
||||
const subsPromise = api.getPushSubscriptions().catch(() => [] as PushSubscriptionInfo[]);
|
||||
|
||||
// Check if THIS browser has an active push subscription and match it
|
||||
// to a backend record.
|
||||
navigator.serviceWorker.ready
|
||||
// to a backend record. Use a timeout so we don't hang forever when the
|
||||
// service worker failed to install (e.g. mobile + self-signed cert).
|
||||
withTimeout(navigator.serviceWorker.ready, 1_000, 'Service worker activation')
|
||||
.then((reg) => reg.pushManager.getSubscription())
|
||||
.then(async (sub) => {
|
||||
const existing = await subsPromise;
|
||||
@@ -129,7 +157,11 @@ export function usePushSubscription(): PushSubscriptionState {
|
||||
const refreshSubscriptions = useCallback(async () => {
|
||||
try {
|
||||
const subs = await api.getPushSubscriptions();
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
const reg = await withTimeout(
|
||||
navigator.serviceWorker.ready,
|
||||
10_000,
|
||||
'Service worker activation'
|
||||
);
|
||||
const sub = await reg.pushManager.getSubscription();
|
||||
reconcileCurrentSubscription(subs, sub?.endpoint ?? null);
|
||||
return subs;
|
||||
@@ -155,7 +187,11 @@ export function usePushSubscription(): PushSubscriptionState {
|
||||
vapidKeyRef.current = resp.public_key;
|
||||
const vapidKeyBytes = urlBase64ToUint8Array(resp.public_key);
|
||||
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
const reg = await withTimeout(
|
||||
navigator.serviceWorker.ready,
|
||||
3_000,
|
||||
'Service worker activation'
|
||||
);
|
||||
let pushSub = await reg.pushManager.getSubscription();
|
||||
const existingKeyBytes = getApplicationServerKeyBytes(pushSub?.options?.applicationServerKey);
|
||||
const requiresRecreate =
|
||||
@@ -188,6 +224,7 @@ export function usePushSubscription(): PushSubscriptionState {
|
||||
console.error('Push subscribe failed:', err);
|
||||
toast.error('Failed to enable push notifications', {
|
||||
description: err instanceof Error ? err.message : 'Check that notifications are allowed',
|
||||
duration: 8_000,
|
||||
});
|
||||
return null;
|
||||
} finally {
|
||||
|
||||
@@ -24,5 +24,7 @@ createRoot(document.getElementById('root')!).render(
|
||||
|
||||
// Register service worker for Web Push (requires secure context)
|
||||
if ('serviceWorker' in navigator && window.isSecureContext) {
|
||||
navigator.serviceWorker.register('./sw.js').catch(() => {});
|
||||
navigator.serviceWorker.register('./sw.js').catch((err) => {
|
||||
console.warn('Service worker registration failed:', err);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -150,6 +150,35 @@ describe('usePushSubscription', () => {
|
||||
expect(result.current.allSubscriptions).toEqual([]);
|
||||
});
|
||||
|
||||
it('times out and shows a toast when service worker never activates', async () => {
|
||||
// Replace serviceWorker.ready with a promise that never resolves
|
||||
Object.defineProperty(navigator, 'serviceWorker', {
|
||||
configurable: true,
|
||||
value: {
|
||||
ready: new Promise(() => {}),
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => usePushSubscription());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSupported).toBe(true);
|
||||
});
|
||||
|
||||
// subscribe() will hang on serviceWorker.ready, then the 1s timeout fires
|
||||
await act(async () => {
|
||||
await result.current.subscribe();
|
||||
});
|
||||
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(mocks.toast.error).toHaveBeenCalledWith(
|
||||
'Failed to enable push notifications',
|
||||
expect.objectContaining({
|
||||
description: expect.stringContaining('trusted TLS certificate for service workers'),
|
||||
})
|
||||
);
|
||||
}, 5_000);
|
||||
|
||||
it('recreates a stale browser subscription when the server VAPID key changed', async () => {
|
||||
const oldSubscription = activeSubscription;
|
||||
mocks.api.getPushSubscriptions
|
||||
|
||||
@@ -66,6 +66,8 @@ export interface RadioStatsSnapshot {
|
||||
timestamp: number | null;
|
||||
battery_mv: number | null;
|
||||
uptime_secs: number | null;
|
||||
queue_len: number | null;
|
||||
errors: number | null;
|
||||
noise_floor: number | null;
|
||||
last_rssi: number | null;
|
||||
last_snr: number | null;
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "remoteterm-meshcore"
|
||||
version = "3.12.0"
|
||||
version = "3.12.1"
|
||||
description = "RemoteTerm - Web interface for MeshCore radio mesh networks"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
@@ -61,7 +61,7 @@ reportMissingTypeStubs = false
|
||||
dev = [
|
||||
"httpx>=0.28.1",
|
||||
"pip-licenses>=5.0.0",
|
||||
"pytest>=9.0.2",
|
||||
"pytest>=9.0.3",
|
||||
"pytest-asyncio>=1.3.0",
|
||||
"pytest-xdist>=3.0",
|
||||
"ruff>=0.8.0",
|
||||
|
||||
@@ -52,6 +52,12 @@ test.describe('Favorites persistence', () => {
|
||||
return channels.some((c) => c.key === channelKey && c.favorite);
|
||||
})
|
||||
.toBe(false);
|
||||
await expect(page.getByText('Favorites')).not.toBeVisible();
|
||||
// The test channel should no longer appear under the Favorites header —
|
||||
// but the Favorites section itself may remain if radio-synced contacts are favorited.
|
||||
const channelsSectionHeader = page.getByText('Channels');
|
||||
await expect(channelsSectionHeader).toBeVisible();
|
||||
// Verify the channel now appears in the non-favorites Channels section
|
||||
const channelEntry = page.getByText(channelName, { exact: true }).first();
|
||||
await expect(channelEntry).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -203,6 +203,30 @@ class TestHealthEndpoint:
|
||||
class TestDebugEndpoint:
|
||||
"""Test the debug support snapshot endpoint."""
|
||||
|
||||
def test_build_environment_exposes_env_settings(self):
|
||||
"""_build_environment should expose env config without secrets."""
|
||||
from app.config import Settings
|
||||
from app.routers.debug import _build_environment
|
||||
|
||||
with patch(
|
||||
"app.routers.debug.settings",
|
||||
Settings(
|
||||
serial_port="/dev/ttyUSB0",
|
||||
serial_baudrate=115200,
|
||||
log_level="DEBUG",
|
||||
database_path="data/test.db",
|
||||
),
|
||||
):
|
||||
env = _build_environment()
|
||||
|
||||
assert env.connection_type == "serial"
|
||||
assert env.serial_port == "/dev/ttyUSB0"
|
||||
assert env.log_level == "DEBUG"
|
||||
assert env.database_path == "data/test.db"
|
||||
assert not hasattr(env, "ble_pin")
|
||||
assert not hasattr(env, "basic_auth_password")
|
||||
assert not hasattr(env, "basic_auth_username")
|
||||
|
||||
def test_support_snapshot_sanitizes_radio_probe_location_fields(self):
|
||||
"""Debug radio probe should redact advertised lat/lon from self_info."""
|
||||
from app.routers.debug import _sanitize_radio_probe_self_info
|
||||
@@ -300,6 +324,8 @@ class TestDebugEndpoint:
|
||||
assert "multi_acks_enabled" not in payload["radio_probe"]
|
||||
assert "max_channels" not in payload["runtime"]
|
||||
assert "path_hash_mode" not in payload["runtime"]
|
||||
assert "environment" in payload
|
||||
assert payload["environment"]["connection_type"] in ("serial", "tcp", "ble")
|
||||
assert payload["runtime"]["channels_with_incoming_messages"] == 0
|
||||
assert payload["database"]["total_dms"] == 0
|
||||
assert payload["database"]["total_channel_messages"] == 0
|
||||
|
||||
@@ -20,6 +20,7 @@ from app.routers.radio import (
|
||||
RadioSettings,
|
||||
disconnect_radio,
|
||||
discover_mesh,
|
||||
get_private_key,
|
||||
get_radio_config,
|
||||
reboot_radio,
|
||||
reconnect_radio,
|
||||
@@ -283,6 +284,38 @@ class TestUpdateRadioConfig:
|
||||
mc.commands.send_appstart.assert_not_awaited()
|
||||
|
||||
|
||||
class TestPrivateKeyExport:
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_403_when_export_disabled(self):
|
||||
with patch("app.config.settings") as mock_settings:
|
||||
mock_settings.enable_local_private_key_export = False
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await get_private_key()
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_404_when_no_key_available(self):
|
||||
with (
|
||||
patch("app.config.settings") as mock_settings,
|
||||
patch("app.keystore.get_private_key", return_value=None),
|
||||
):
|
||||
mock_settings.enable_local_private_key_export = True
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await get_private_key()
|
||||
assert exc.value.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_key_hex_when_enabled_and_available(self):
|
||||
key_bytes = bytes.fromhex("ab" * 64)
|
||||
with (
|
||||
patch("app.config.settings") as mock_settings,
|
||||
patch("app.keystore.get_private_key", return_value=key_bytes),
|
||||
):
|
||||
mock_settings.enable_local_private_key_export = True
|
||||
result = await get_private_key()
|
||||
assert result == {"private_key": "ab" * 64}
|
||||
|
||||
|
||||
class TestPrivateKeyImport:
|
||||
@pytest.mark.asyncio
|
||||
async def test_rejects_invalid_hex(self):
|
||||
|
||||
@@ -1399,7 +1399,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.2"
|
||||
version = "9.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
@@ -1408,9 +1408,9 @@ dependencies = [
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1533,7 +1533,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "remoteterm-meshcore"
|
||||
version = "3.12.0"
|
||||
version = "3.12.1"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "aiomqtt" },
|
||||
@@ -1582,7 +1582,7 @@ dev = [
|
||||
{ name = "httpx", specifier = ">=0.28.1" },
|
||||
{ name = "pip-licenses", specifier = ">=5.0.0" },
|
||||
{ name = "pyright", specifier = ">=1.1.390" },
|
||||
{ name = "pytest", specifier = ">=9.0.2" },
|
||||
{ name = "pytest", specifier = ">=9.0.3" },
|
||||
{ name = "pytest-asyncio", specifier = ">=1.3.0" },
|
||||
{ name = "pytest-xdist", specifier = ">=3.0" },
|
||||
{ name = "ruff", specifier = ">=0.8.0" },
|
||||
@@ -1590,7 +1590,7 @@ dev = [
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.32.5"
|
||||
version = "2.33.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
@@ -1598,9 +1598,9 @@ dependencies = [
|
||||
{ name = "idna" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738 },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user