Compare commits
34 Commits
main
..
bf5f222511
| Author | SHA1 | Date | |
|---|---|---|---|
| bf5f222511 | |||
| eb39a9d1d0 | |||
| 67d63b4173 | |||
| 25cf9528d0 | |||
| 738ad7878e | |||
| 152377d608 | |||
| 4868381053 | |||
| b4bbfd2b01 | |||
| 82651f71b5 | |||
| 182920809d | |||
| 2a3589ca5c | |||
| d43ef7427f | |||
| d2b47156d8 | |||
| 5b31c2e567 | |||
| b74360b6bb | |||
| 3d445daf1f | |||
| 2cb96a7a1c | |||
| 6b363b0788 | |||
| 1fb786c262 | |||
| 50c9370b8e | |||
| a297e6c5fe | |||
| 6ac60eb380 | |||
| b1267f47b2 | |||
| 8f93c345fe | |||
| 92b6173f5f | |||
| f9139d6aa3 | |||
| c90544a712 | |||
| 12d512a515 | |||
| 60c95e825d | |||
| 316cfa84f8 | |||
| db6fd56673 | |||
| dac731f912 | |||
| 5c4722267f | |||
| f011e1785d |
@@ -0,0 +1,18 @@
|
|||||||
|
# SLMM Configuration
|
||||||
|
# Copy this file to .env and customize as needed
|
||||||
|
|
||||||
|
# Timezone Configuration
|
||||||
|
# Set the timezone offset from UTC (in hours)
|
||||||
|
# Examples:
|
||||||
|
# -5 = EST (Eastern Standard Time)
|
||||||
|
# -4 = EDT (Eastern Daylight Time)
|
||||||
|
# 0 = UTC
|
||||||
|
# +1 = CET (Central European Time)
|
||||||
|
# -8 = PST (Pacific Standard Time)
|
||||||
|
TIMEZONE_OFFSET=-5
|
||||||
|
|
||||||
|
# Optional: Timezone name for logging (cosmetic only)
|
||||||
|
TIMEZONE_NAME=EST
|
||||||
|
|
||||||
|
# CORS Configuration (comma-separated list of allowed origins)
|
||||||
|
CORS_ORIGINS=*
|
||||||
+8
-51
@@ -1,57 +1,14 @@
|
|||||||
# --------------------------
|
/manuals/
|
||||||
# Thor Watcher local files
|
/data/
|
||||||
# --------------------------
|
|
||||||
config.json
|
|
||||||
/example-data/
|
|
||||||
|
|
||||||
# -------------------------
|
# Python cache
|
||||||
# Python ignores
|
|
||||||
# -------------------------
|
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.pyc
|
||||||
*.pyo
|
*.pyo
|
||||||
*.pyd
|
*.pyd
|
||||||
*.pdb
|
.Python
|
||||||
*.so
|
*.so
|
||||||
*.dll
|
*.egg
|
||||||
|
|
||||||
# Virtual environments
|
|
||||||
venv/
|
|
||||||
env/
|
|
||||||
.venv/
|
|
||||||
|
|
||||||
# Distribution / packaging
|
|
||||||
build/
|
|
||||||
dist/
|
|
||||||
Output/
|
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
*.spec
|
dist/
|
||||||
|
build/
|
||||||
# -------------------------
|
|
||||||
# Logs + runtime artifacts
|
|
||||||
# -------------------------
|
|
||||||
agent_logs/*
|
|
||||||
!agent_logs/.gitkeep
|
|
||||||
|
|
||||||
*.log
|
|
||||||
|
|
||||||
# -------------------------
|
|
||||||
# System / Editor files
|
|
||||||
# -------------------------
|
|
||||||
# Windows
|
|
||||||
Thumbs.db
|
|
||||||
ehthumbs.db
|
|
||||||
Desktop.ini
|
|
||||||
|
|
||||||
# macOS
|
|
||||||
.DS_Store
|
|
||||||
|
|
||||||
# VS Code
|
|
||||||
.vscode/
|
|
||||||
|
|
||||||
# JetBrains
|
|
||||||
.idea/
|
|
||||||
|
|
||||||
# Backup files
|
|
||||||
*.bak
|
|
||||||
*.tmp
|
|
||||||
Vendored
-7
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"workbench.colorCustomizations": {
|
|
||||||
"activityBar.background": "#372C13",
|
|
||||||
"titleBar.activeBackground": "#4D3E1B",
|
|
||||||
"titleBar.activeForeground": "#FCFBF8"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+131
-55
@@ -1,75 +1,151 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to SLMM (Sound Level Meter Manager) will be documented in this file.
|
||||||
|
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
## [0.3.1] - 2026-05-20
|
## [0.2.1] - 2026-01-23
|
||||||
|
|
||||||
### Hot fix
|
|
||||||
- Improved update logging
|
|
||||||
- Improved update settings
|
|
||||||
|
|
||||||
|
|
||||||
## [0.3.0] - 2026-05-19
|
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- `event_forwarder.py` — forwards `.IDFH` (histogram) and `.IDFW` (waveform) event files plus their `TXT/<basename>.txt` sidecars to a seismo-relay SFM server's new `/db/import/idf_file` endpoint
|
- **Roster management**: UI and API endpoints for managing device rosters.
|
||||||
- Sha256-keyed `thor_forwarded.json` state file for idempotency across restarts and re-scans (default path: `<log_dir>/thor_forwarded.json`)
|
- **Delete config endpoint**: Remove device configuration alongside cached status data.
|
||||||
- "SFM Forward" tab in Settings dialog: enable/URL/Test, forward interval, quiescence, missing-report grace, HTTP timeout, max forwards per pass, max event age, state file picker
|
- **Scheduler hooks**: `start_cycle` and `stop_cycle` helpers for Terra-View scheduling integration.
|
||||||
- Forwarder status line in tray menu: `SFM OK | N fwd, M err | last 30s ago`
|
|
||||||
- Tray icon goes amber when the SFM forwarder is failing but the API heartbeat is still healthy
|
|
||||||
- Re-pair logic: events forwarded without their TXT are re-forwarded once the sidecar appears so the relay can refresh DB rows with device-authoritative PPV/ZCFreq/peak values
|
|
||||||
- `event_forwarder.py --seed-state` CLI for skipping historical backfill on a first deploy
|
|
||||||
- Version badge: `Thor Watcher vX.Y.Z` shown at the top of the tray menu and in the Settings dialog title bar — operators no longer have to crack open the .exe properties to tell which version is running
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- Bumped `VERSION` to `0.3.0`
|
- **FTP logging**: Connection, authentication, and transfer phases now log explicitly.
|
||||||
- Settings dialog tab order: Connection / Paths / Scanning / Logging / **SFM Forward** / Updates
|
- **Documentation**: Reorganized docs/scripts and updated API notes for FTP/TCP verification.
|
||||||
|
|
||||||
## [0.2.0] - 2026-03-20
|
## [0.2.0] - 2026-01-15
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- `thor_tray.py` — system tray launcher with status icon (green/amber/red/grey), Settings and Open Log Folder menu items
|
|
||||||
- `thor_settings_dialog.py` — Tkinter settings dialog with first-run wizard; tabs for Connection, Paths, Scanning, Logging, Updates
|
#### Background Polling System
|
||||||
- Hardened auto-updater: three-layer download validation (100 KB floor, 50% relative size floor, MZ magic bytes), safer swap bat with 5-retry cap and `:fail` exit, `.exe.old` backup
|
- **Continuous automatic device polling** - Background service that continuously polls configured devices
|
||||||
- Configurable update source: `update_source` (gitea / url / disabled), `update_url` for custom server
|
- **Per-device configurable intervals** - Each device can have custom polling interval (10-3600 seconds, default 60)
|
||||||
- Remote push support: `update_available` flag in API heartbeat response triggers update regardless of `update_source` setting
|
- **Automatic offline detection** - Devices automatically marked unreachable after 3 consecutive failures
|
||||||
- `build.bat` — PyInstaller build script; outputs versioned exe for Gitea and plain copy for Inno Setup
|
- **Reachability tracking** - Database fields track device health with failure counters and error messages
|
||||||
- `installer.iss` — Inno Setup installer script with startup shortcut
|
- **Dynamic sleep scheduling** - Polling service adjusts sleep intervals based on device configurations
|
||||||
- `_update_log()` helper writes timestamped `[updater]` lines to the watcher log
|
- **Graceful lifecycle management** - Background poller starts on application startup and stops cleanly on shutdown
|
||||||
- `log_tail` included in heartbeat payload (last 25 lines) for terra-view display
|
|
||||||
- `run_watcher(state, stop_event)` pattern in `series4_ingest.py` for background thread use from tray
|
#### New API Endpoints
|
||||||
|
- `GET /api/nl43/{unit_id}/polling/config` - Get device polling configuration
|
||||||
|
- `PUT /api/nl43/{unit_id}/polling/config` - Update polling interval and enable/disable per-device polling
|
||||||
|
- `GET /api/nl43/_polling/status` - Get global polling status for all devices with reachability info
|
||||||
|
|
||||||
|
#### Database Schema Changes
|
||||||
|
- **NL43Config table**:
|
||||||
|
- `poll_interval_seconds` (Integer, default 60) - Polling interval in seconds
|
||||||
|
- `poll_enabled` (Boolean, default true) - Enable/disable background polling per device
|
||||||
|
|
||||||
|
- **NL43Status table**:
|
||||||
|
- `is_reachable` (Boolean, default true) - Current device reachability status
|
||||||
|
- `consecutive_failures` (Integer, default 0) - Count of consecutive poll failures
|
||||||
|
- `last_poll_attempt` (DateTime) - Last time background poller attempted to poll
|
||||||
|
- `last_success` (DateTime) - Last successful poll timestamp
|
||||||
|
- `last_error` (Text) - Last error message (truncated to 500 chars)
|
||||||
|
|
||||||
|
#### New Files
|
||||||
|
- `app/background_poller.py` - Background polling service implementation
|
||||||
|
- `migrate_add_polling_fields.py` - Database migration script for v0.2.0 schema changes
|
||||||
|
- `test_polling.sh` - Comprehensive test script for polling functionality
|
||||||
|
- `CHANGELOG.md` - This changelog file
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- `series4_ingest.py` refactored into tray-friendly background thread module; `main()` retained for standalone use
|
- **Enhanced status endpoint** - `GET /api/nl43/{unit_id}/status` now includes polling-related fields (is_reachable, consecutive_failures, last_poll_attempt, last_success, last_error)
|
||||||
- Config key `sfm_endpoint` renamed to `api_url` for consistency with series3-watcher
|
- **Application startup** - Added lifespan context manager in `app/main.py` to manage background poller lifecycle
|
||||||
- Heartbeat payload now uses `source_id`, `source_type`, `version` fields matching terra-view WatcherAgent model
|
- **Performance improvement** - Terra-View requests now return cached data instantly (<100ms) instead of waiting for device queries (1-2 seconds)
|
||||||
- AppData folder: `ThorWatcher` (was not previously defined)
|
|
||||||
|
|
||||||
## [0.1.1] - 2025-12-08
|
### Technical Details
|
||||||
|
|
||||||
### Changed
|
#### Architecture
|
||||||
- Moved configuration from hard-coded values to external `config.json` file
|
- Background poller runs as async task using `asyncio.create_task()`
|
||||||
- Configuration now loads from JSON file with graceful fallback to defaults
|
- Uses existing `NL43Client` and `persist_snapshot()` functions - no code duplication
|
||||||
- Config file is loaded relative to script location for portability
|
- Respects existing 1-second rate limiting per device
|
||||||
|
- Efficient resource usage - skips work when no devices configured
|
||||||
|
- WebSocket streaming remains unaffected - separate real-time data path
|
||||||
|
|
||||||
|
#### Default Behavior
|
||||||
|
- Existing devices automatically get 60-second polling interval
|
||||||
|
- Existing status records default to `is_reachable=true`
|
||||||
|
- Migration is additive-only - no data loss
|
||||||
|
- Polling can be disabled per-device via `poll_enabled=false`
|
||||||
|
|
||||||
|
#### Recommended Intervals
|
||||||
|
- Critical monitoring: 30 seconds
|
||||||
|
- Normal monitoring: 60 seconds (default)
|
||||||
|
- Battery conservation: 300 seconds (5 minutes)
|
||||||
|
- Development/testing: 10 seconds (minimum allowed)
|
||||||
|
|
||||||
|
### Migration Notes
|
||||||
|
|
||||||
|
To upgrade from v0.1.x to v0.2.0:
|
||||||
|
|
||||||
|
1. **Stop the service** (if running):
|
||||||
|
```bash
|
||||||
|
docker compose down slmm
|
||||||
|
# OR
|
||||||
|
# Stop your uvicorn process
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Update code**:
|
||||||
|
```bash
|
||||||
|
git pull
|
||||||
|
# OR copy new files
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Run migration**:
|
||||||
|
```bash
|
||||||
|
cd slmm
|
||||||
|
python3 migrate_add_polling_fields.py
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Restart service**:
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build slmm
|
||||||
|
# OR
|
||||||
|
uvicorn app.main:app --host 0.0.0.0 --port 8100
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Verify polling is active**:
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8100/api/nl43/_polling/status | jq '.'
|
||||||
|
```
|
||||||
|
|
||||||
|
You should see `"poller_running": true` and all configured devices listed.
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
None. This release is fully backward-compatible with v0.1.x. All existing endpoints and functionality remain unchanged.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.1.0] - 2025-12-XX
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- `config.json` - External configuration file for all application settings
|
- Initial release
|
||||||
- `load_config()` function with error handling and default value fallback
|
- REST API for NL43/NL53 sound level meter control
|
||||||
- Warning messages when config file is missing or malformed
|
- TCP command protocol implementation
|
||||||
|
- FTP file download support
|
||||||
|
- WebSocket streaming for real-time data (DRD)
|
||||||
|
- Device configuration management
|
||||||
|
- Measurement control (start, stop, pause, resume, reset, store)
|
||||||
|
- Device information endpoints (battery, clock, results)
|
||||||
|
- Measurement settings management (frequency/time weighting)
|
||||||
|
- Sleep mode control
|
||||||
|
- Rate limiting (1-second minimum between commands)
|
||||||
|
- SQLite database for device configs and status cache
|
||||||
|
- Health check endpoints
|
||||||
|
- Comprehensive API documentation
|
||||||
|
- NL43 protocol documentation
|
||||||
|
|
||||||
## [0.1.0] - 2025-12-04
|
### Database Schema (v0.1.0)
|
||||||
|
- **NL43Config table** - Device connection configuration
|
||||||
|
- **NL43Status table** - Measurement snapshot cache
|
||||||
|
|
||||||
### Added
|
---
|
||||||
- Initial release of Series 4 Ingest Agent
|
|
||||||
- Micromate (Series 4) heartbeat monitoring for Seismo Fleet Manager
|
## Version History Summary
|
||||||
- THORDATA folder scanner for MLG files
|
|
||||||
- Automatic detection of newest MLG file per unit by timestamp
|
- **v0.2.1** (2026-01-23) - Roster management, scheduler hooks, FTP logging, doc cleanup
|
||||||
- Age calculation and status classification (OK / LATE / STALE)
|
- **v0.2.0** (2026-01-15) - Background Polling System
|
||||||
- Console heartbeat display with formatted output
|
- **v0.1.0** (2025-12-XX) - Initial Release
|
||||||
- Optional HTTP POST to SFM backend endpoint
|
|
||||||
- Configurable age thresholds and scan intervals
|
|
||||||
- Debug logging capability
|
|
||||||
- Graceful error handling for filesystem and network operations
|
|
||||||
|
|||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends curl && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy requirements and install dependencies
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 8100
|
||||||
|
|
||||||
|
# Run the application
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8100"]
|
||||||
@@ -1,178 +1,471 @@
|
|||||||
# Thor Watcher
|
# SLMM - Sound Level Meter Manager
|
||||||
|
|
||||||
**Version:** 0.3.1
|
**Version 0.2.1**
|
||||||
|
|
||||||
Micromate (Series 4) watcher agent for Terra-View fleet management. Runs as a Windows system tray application, scans THORDATA for Micromate unit activity, sends heartbeat data to Terra-View, and (optionally) forwards `.IDFH`/`.IDFW` event files to a seismo-relay SFM server.
|
Backend API service for controlling and monitoring Rion NL-43/NL-53 Sound Level Meters via TCP and FTP protocols.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
Thor Watcher monitors `C:\THORDATA\<Project>\<UM####>\*.MLG` files, determines each unit's last activity from the MLG filename timestamp, and periodically posts a heartbeat payload to the Terra-View backend. It runs silently in the system tray and auto-starts on login.
|
SLMM is a standalone backend module that provides REST API routing and command translation for NL43/NL53 sound level meters. This service acts as a bridge between the hardware devices and frontend applications, handling all device communication, data persistence, and protocol management.
|
||||||
|
|
||||||
---
|
**Note:** This is a backend-only service. Actual user interfacing is done via customized front ends or cli.
|
||||||
|
|
||||||
## Installation
|
## Features
|
||||||
|
|
||||||
1. Run `thor-watcher-setup.exe`
|
- **Background Polling** ⭐ NEW: Continuous automatic polling of devices with configurable intervals
|
||||||
2. On first launch the setup wizard will appear — enter your THORDATA path and Terra-View URL
|
- **Offline Detection** ⭐ NEW: Automatic device reachability tracking with failure counters
|
||||||
3. The app starts in the system tray and launches automatically on login
|
- **Device Management**: Configure and manage multiple NL43/NL53 devices
|
||||||
|
- **Real-time Monitoring**: Stream live measurement data via WebSocket
|
||||||
|
- **Measurement Control**: Start, stop, pause, resume, and reset measurements
|
||||||
|
- **Data Retrieval**: Access current and historical measurement snapshots
|
||||||
|
- **FTP Integration**: Download measurement files directly from devices
|
||||||
|
- **Device Configuration**: Manage frequency/time weighting, clock sync, and more
|
||||||
|
- **Rate Limiting**: Automatic 1-second delay enforcement between device commands
|
||||||
|
- **Persistent Storage**: SQLite database for device configs and measurement cache
|
||||||
|
|
||||||
---
|
## Architecture
|
||||||
|
|
||||||
## Building from Source
|
```
|
||||||
|
┌─────────────────┐ ┌──────────────────────────────┐ ┌─────────────────┐
|
||||||
Requires Python 3.10+ and pip on PATH.
|
│ │◄───────►│ SLMM API │◄───────►│ NL43/NL53 │
|
||||||
|
│ (Frontend) │ HTTP │ • REST Endpoints │ TCP │ Sound Meters │
|
||||||
```bat
|
└─────────────────┘ │ • WebSocket Streaming │ └─────────────────┘
|
||||||
build.bat
|
│ • Background Poller ⭐ NEW │ ▲
|
||||||
|
└──────────────────────────────┘ │
|
||||||
|
│ Continuous
|
||||||
|
▼ Polling
|
||||||
|
┌──────────────┐ │
|
||||||
|
│ SQLite DB │◄─────────────────────┘
|
||||||
|
│ • Config │
|
||||||
|
│ • Status │
|
||||||
|
└──────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
Produces:
|
### Background Polling (v0.2.0)
|
||||||
- `dist\thor-watcher-0.3.1.exe` — upload to Gitea release
|
|
||||||
- `dist\thor-watcher.exe` — use with Inno Setup
|
|
||||||
|
|
||||||
Then run Inno Setup Compiler on `installer.iss` to produce `thor-watcher-setup.exe`.
|
SLMM now includes a background polling service that continuously queries devices and updates the status cache:
|
||||||
|
|
||||||
---
|
- **Automatic Updates**: Devices are polled at configurable intervals (10-3600 seconds)
|
||||||
|
- **Offline Detection**: Devices marked unreachable after 3 consecutive failures
|
||||||
|
- **Per-Device Configuration**: Each device can have a custom polling interval
|
||||||
|
- **Resource Efficient**: Dynamic sleep intervals and smart scheduling
|
||||||
|
- **Graceful Shutdown**: Background task stops cleanly on service shutdown
|
||||||
|
|
||||||
|
This makes Terra-View significantly more responsive - status requests return cached data instantly (<100ms) instead of waiting for device queries (1-2 seconds).
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Python 3.10+
|
||||||
|
- pip package manager
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
1. Clone the repository:
|
||||||
|
```bash
|
||||||
|
git clone <repository-url>
|
||||||
|
cd slmm
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Install dependencies:
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running the Server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Development mode with auto-reload
|
||||||
|
uvicorn app.main:app --reload --port 8100
|
||||||
|
|
||||||
|
# Production mode
|
||||||
|
uvicorn app.main:app --host 0.0.0.0 --port 8100
|
||||||
|
```
|
||||||
|
|
||||||
|
The API will be available at `http://localhost:8100`
|
||||||
|
|
||||||
|
### API Documentation
|
||||||
|
|
||||||
|
Once running, visit:
|
||||||
|
- Swagger UI: `http://localhost:8100/docs`
|
||||||
|
- ReDoc: `http://localhost:8100/redoc`
|
||||||
|
- Health Check: `http://localhost:8100/health`
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Config is stored at:
|
### Environment Variables
|
||||||
|
|
||||||
|
- `PORT`: Server port (default: 8100)
|
||||||
|
- `CORS_ORIGINS`: Comma-separated list of allowed origins (default: "*")
|
||||||
|
|
||||||
|
### Database
|
||||||
|
|
||||||
|
The SQLite database is automatically created at [data/slmm.db](data/slmm.db) on first run.
|
||||||
|
|
||||||
|
### Logging
|
||||||
|
|
||||||
|
Logs are written to:
|
||||||
|
- Console output (stdout)
|
||||||
|
- [data/slmm.log](data/slmm.log) file
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Device Configuration
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| GET | `/api/nl43/{unit_id}/config` | Get device configuration |
|
||||||
|
| PUT | `/api/nl43/{unit_id}/config` | Update device configuration |
|
||||||
|
|
||||||
|
### Device Status
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| GET | `/api/nl43/{unit_id}/status` | Get cached measurement snapshot (updated by background poller) |
|
||||||
|
| GET | `/api/nl43/{unit_id}/live` | Request fresh DOD data from device (bypasses cache) |
|
||||||
|
| WS | `/api/nl43/{unit_id}/stream` | WebSocket stream for real-time DRD data |
|
||||||
|
|
||||||
|
### Background Polling Configuration ⭐ NEW
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| GET | `/api/nl43/{unit_id}/polling/config` | Get device polling configuration |
|
||||||
|
| PUT | `/api/nl43/{unit_id}/polling/config` | Update polling interval and enable/disable polling |
|
||||||
|
| GET | `/api/nl43/_polling/status` | Get global polling status for all devices |
|
||||||
|
|
||||||
|
### Measurement Control
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| POST | `/api/nl43/{unit_id}/start` | Start measurement |
|
||||||
|
| POST | `/api/nl43/{unit_id}/stop` | Stop measurement |
|
||||||
|
| POST | `/api/nl43/{unit_id}/pause` | Pause measurement |
|
||||||
|
| POST | `/api/nl43/{unit_id}/resume` | Resume paused measurement |
|
||||||
|
| POST | `/api/nl43/{unit_id}/reset` | Reset measurement data |
|
||||||
|
| POST | `/api/nl43/{unit_id}/store` | Manually store data to SD card |
|
||||||
|
|
||||||
|
### Device Information
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| GET | `/api/nl43/{unit_id}/battery` | Get battery level |
|
||||||
|
| GET | `/api/nl43/{unit_id}/clock` | Get device clock time |
|
||||||
|
| PUT | `/api/nl43/{unit_id}/clock` | Set device clock time |
|
||||||
|
| GET | `/api/nl43/{unit_id}/results` | Get final calculation results (DLC) |
|
||||||
|
|
||||||
|
### Measurement Settings
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| GET | `/api/nl43/{unit_id}/settings` | Get all current device settings for verification |
|
||||||
|
| GET | `/api/nl43/{unit_id}/frequency-weighting` | Get frequency weighting (A/C/Z) |
|
||||||
|
| PUT | `/api/nl43/{unit_id}/frequency-weighting` | Set frequency weighting |
|
||||||
|
| GET | `/api/nl43/{unit_id}/time-weighting` | Get time weighting (F/S/I) |
|
||||||
|
| PUT | `/api/nl43/{unit_id}/time-weighting` | Set time weighting |
|
||||||
|
|
||||||
|
### Sleep Mode
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| POST | `/api/nl43/{unit_id}/sleep` | Put device into sleep mode |
|
||||||
|
| POST | `/api/nl43/{unit_id}/wake` | Wake device from sleep |
|
||||||
|
| GET | `/api/nl43/{unit_id}/sleep/status` | Get sleep mode status |
|
||||||
|
|
||||||
|
### FTP File Management
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| POST | `/api/nl43/{unit_id}/ftp/enable` | Enable FTP server on device |
|
||||||
|
| POST | `/api/nl43/{unit_id}/ftp/disable` | Disable FTP server on device |
|
||||||
|
| GET | `/api/nl43/{unit_id}/ftp/status` | Get FTP server status |
|
||||||
|
| GET | `/api/nl43/{unit_id}/ftp/files` | List files on device |
|
||||||
|
| POST | `/api/nl43/{unit_id}/ftp/download` | Download file from device |
|
||||||
|
|
||||||
|
For detailed API documentation and examples, see [API.md](API.md).
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
%LOCALAPPDATA%\ThorWatcher\config.json
|
slmm/
|
||||||
|
├── app/
|
||||||
|
│ ├── __init__.py # Package initialization
|
||||||
|
│ ├── main.py # FastAPI application and startup
|
||||||
|
│ ├── routers.py # API route definitions
|
||||||
|
│ ├── models.py # SQLAlchemy database models
|
||||||
|
│ ├── services.py # NL43Client and business logic
|
||||||
|
│ ├── background_poller.py # Background polling service ⭐ NEW
|
||||||
|
│ └── database.py # Database configuration
|
||||||
|
├── data/
|
||||||
|
│ ├── slmm.db # SQLite database (auto-created)
|
||||||
|
│ ├── slmm.log # Application logs
|
||||||
|
│ └── downloads/ # Downloaded files from devices
|
||||||
|
├── templates/
|
||||||
|
│ └── index.html # Simple web interface (optional)
|
||||||
|
├── manuals/ # Device documentation
|
||||||
|
├── migrate_add_polling_fields.py # Database migration for v0.2.0 ⭐ NEW
|
||||||
|
├── test_polling.sh # Polling feature test script ⭐ NEW
|
||||||
|
├── API.md # Detailed API documentation
|
||||||
|
├── COMMUNICATION_GUIDE.md # NL43 protocol documentation
|
||||||
|
├── NL43_COMMANDS.md # Command reference
|
||||||
|
├── CHANGELOG.md # Version history ⭐ NEW
|
||||||
|
├── requirements.txt # Python dependencies
|
||||||
|
└── README.md # This file
|
||||||
```
|
```
|
||||||
|
|
||||||
Managed through the Settings dialog (right-click tray icon → Settings). A `config.example.json` is included as reference.
|
## Database Schema
|
||||||
|
|
||||||
### Config Keys
|
### NL43Config Table
|
||||||
|
Stores device connection configuration:
|
||||||
|
- `unit_id` (PK): Unique device identifier
|
||||||
|
- `host`: Device IP address or hostname
|
||||||
|
- `tcp_port`: TCP control port (default: 80)
|
||||||
|
- `tcp_enabled`: Enable/disable TCP communication
|
||||||
|
- `ftp_enabled`: Enable/disable FTP functionality
|
||||||
|
- `ftp_username`: FTP authentication username
|
||||||
|
- `ftp_password`: FTP authentication password
|
||||||
|
- `web_enabled`: Enable/disable web interface access
|
||||||
|
- `poll_interval_seconds`: Polling interval in seconds (10-3600, default: 60) ⭐ NEW
|
||||||
|
- `poll_enabled`: Enable/disable background polling for this device ⭐ NEW
|
||||||
|
|
||||||
| Key | Type | Default | Description |
|
### NL43Status Table
|
||||||
|-----|------|---------|-------------|
|
Caches latest measurement snapshot:
|
||||||
| `thordata_path` | string | `C:\THORDATA` | Root THORDATA directory |
|
- `unit_id` (PK): Unique device identifier
|
||||||
| `scan_interval` | integer | `60` | Seconds between scans |
|
- `last_seen`: Timestamp of last update
|
||||||
| `api_url` | string | `""` | Terra-View heartbeat URL (e.g. `http://10.0.0.40:8000/api/series4/heartbeat`) |
|
- `measurement_state`: Current state (Measure/Stop)
|
||||||
| `api_timeout` | integer | `5` | HTTP request timeout in seconds |
|
- `measurement_start_time`: When measurement started (UTC)
|
||||||
| `api_interval` | integer | `300` | Seconds between API heartbeat POSTs |
|
- `counter`: Measurement interval counter (1-600)
|
||||||
| `source_id` | string | hostname | Identifier for this machine in Terra-View |
|
- `lp`: Instantaneous sound pressure level
|
||||||
| `source_type` | string | `series4_watcher` | Agent type (do not change) |
|
- `leq`: Equivalent continuous sound level
|
||||||
| `local_timezone` | string | `America/New_York` | Timezone of the field machine — used to convert MLG timestamps to UTC |
|
- `lmax`: Maximum sound level
|
||||||
| `enable_logging` | boolean | `true` | Write log file |
|
- `lmin`: Minimum sound level
|
||||||
| `log_file` | string | `%LOCALAPPDATA%\ThorWatcher\agent_logs\thor_watcher.log` | Log file path |
|
- `lpeak`: Peak sound level
|
||||||
| `log_retention_days` | integer | `30` | Days before log is auto-cleared |
|
- `battery_level`: Battery percentage
|
||||||
| `update_source` | string | `gitea` | Auto-update source: `gitea`, `url`, or `disabled` |
|
- `power_source`: Current power source
|
||||||
| `update_url` | string | `""` | Base URL for `url` mode (e.g. Terra-View server) |
|
- `sd_remaining_mb`: Free SD card space (MB)
|
||||||
| `sfm_forward_enabled` | boolean | `false` | Forward `.IDFH`/`.IDFW` event files to a seismo-relay SFM server |
|
- `sd_free_ratio`: SD card free space ratio
|
||||||
| `sfm_url` | string | `""` | Base URL of the seismo-relay SFM server (e.g. `http://10.0.0.44:8200`) |
|
- `raw_payload`: Raw device response data
|
||||||
| `sfm_forward_interval` | integer | `60` | Seconds between forwarder passes |
|
- `is_reachable`: Device reachability status (Boolean) ⭐ NEW
|
||||||
| `sfm_quiescence_seconds` | integer | `5` | Skip files modified within the last N seconds (avoid in-flight files) |
|
- `consecutive_failures`: Count of consecutive poll failures ⭐ NEW
|
||||||
| `sfm_missing_report_grace_seconds` | integer | `60` | Forward a binary without its `.txt` sidecar if it hasn't appeared after N seconds |
|
- `last_poll_attempt`: Last time background poller attempted to poll ⭐ NEW
|
||||||
| `sfm_http_timeout` | integer | `60` | HTTP timeout per forward POST |
|
- `last_success`: Last successful poll timestamp ⭐ NEW
|
||||||
| `sfm_state_file` | string | `""` | Path to the sha256-keyed state file. Blank → `<log_dir>\thor_forwarded.json` |
|
- `last_error`: Last error message (truncated to 500 chars) ⭐ NEW
|
||||||
| `sfm_max_forwards_per_pass` | integer | `500` | Cap per pass to drip-feed large backfills |
|
|
||||||
| `sfm_max_event_age_days` | integer | `365` | Skip event files older than this many days |
|
|
||||||
|
|
||||||
---
|
## Protocol Details
|
||||||
|
|
||||||
## Event Forwarding
|
### TCP Communication
|
||||||
|
- Uses ASCII command protocol over TCP
|
||||||
|
- Enforces ≥1 second delay between commands to same device
|
||||||
|
- Two-line response format:
|
||||||
|
- Line 1: Result code (R+0000 for success)
|
||||||
|
- Line 2: Data payload (for query commands)
|
||||||
|
|
||||||
When `sfm_forward_enabled` is true and `sfm_url` is set, Thor Watcher walks the THORDATA tree each `sfm_forward_interval` seconds, finds `.IDFH` (histogram) and `.IDFW` (waveform) event binaries plus their `TXT/<basename>.txt` ASCII sidecars, and POSTs them to seismo-relay's `/db/import/idf_file` endpoint.
|
### FTP Communication
|
||||||
|
- Uses active mode FTP (requires device to connect back)
|
||||||
|
- TCP and FTP are mutually exclusive on the device
|
||||||
|
- Credentials configurable per device
|
||||||
|
- **Default NL43 FTP Credentials**: Username: `USER`, Password: `0000`
|
||||||
|
|
||||||
- **Idempotent.** Every forwarded file is recorded by sha256 in `thor_forwarded.json`. Re-scans never re-POST.
|
### Data Formats
|
||||||
- **Default off.** Operators must explicitly enable from the Settings → SFM Forward tab.
|
|
||||||
- **Re-pair logic.** If a binary is forwarded before its TXT sidecar appears (after the grace period), it's flagged `had_report=false` and re-forwarded once the TXT arrives so the SFM database row can be refreshed with device-authoritative PPV/ZCFreq/peak values.
|
|
||||||
- **TXT export must be enabled in Thor.** Thor's TXT sidecars are not produced automatically — operators should enable TXT export so the relay can extract rich metadata. Forwards without a TXT are still useful (binary gets indexed; rich fields stay NULL).
|
|
||||||
- **Backfill seeding.** To skip a large historical archive on first deploy, run `python event_forwarder.py --seed-state --thordata C:\THORDATA --state <state file>` before flipping the switch.
|
|
||||||
|
|
||||||
---
|
**DOD (Data Output Display)**: Snapshot of current display values
|
||||||
|
**DRD (Data Real-time Display)**: Continuous streaming data
|
||||||
|
**DLC (Data Last Calculation)**: Final stored measurement results
|
||||||
|
|
||||||
## Tray Icon Colors
|
## Example Usage
|
||||||
|
|
||||||
| Color | Meaning |
|
### Configure a Device
|
||||||
|-------|---------|
|
```bash
|
||||||
| Green | Running, API reporting OK (and SFM forwarder healthy when enabled) |
|
curl -X PUT http://localhost:8100/api/nl43/meter-001/config \
|
||||||
| Amber | Running, API disabled OR SFM forwarder failing while API is healthy |
|
-H "Content-Type: application/json" \
|
||||||
| Red | Running, API failing |
|
-d '{
|
||||||
| Purple | Error — check logs |
|
"host": "192.168.1.100",
|
||||||
| Grey | Starting up |
|
"tcp_port": 2255,
|
||||||
|
"tcp_enabled": true,
|
||||||
|
"ftp_enabled": true,
|
||||||
|
"ftp_username": "USER",
|
||||||
|
"ftp_password": "0000"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
---
|
### Start Measurement
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8100/api/nl43/meter-001/start
|
||||||
|
```
|
||||||
|
|
||||||
## Auto-Updater
|
### Get Cached Status (Fast - from background poller)
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8100/api/nl43/meter-001/status
|
||||||
|
```
|
||||||
|
|
||||||
Thor Watcher checks for updates every ~5 minutes. When a new release is found it downloads and validates the exe, then relaunches via a swap bat — no manual intervention needed.
|
### Get Live Status (Bypasses cache)
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8100/api/nl43/meter-001/live
|
||||||
|
```
|
||||||
|
|
||||||
**Update sources:**
|
### Configure Background Polling ⭐ NEW
|
||||||
- `gitea` — checks the Gitea release page (default)
|
```bash
|
||||||
- `url` — fetches `version.txt` and `thor-watcher.exe` from a custom server (e.g. Terra-View)
|
# Set polling interval to 30 seconds
|
||||||
- `disabled` — no automatic checks; remote push from Terra-View still works
|
curl -X PUT http://localhost:8100/api/nl43/meter-001/polling/config \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"poll_interval_seconds": 30,
|
||||||
|
"poll_enabled": true
|
||||||
|
}'
|
||||||
|
|
||||||
**Download validation:** 100 KB minimum size, 50% relative size floor vs current exe, MZ magic bytes check.
|
# Get polling configuration
|
||||||
|
curl http://localhost:8100/api/nl43/meter-001/polling/config
|
||||||
|
|
||||||
Remote update push from Terra-View Watcher Manager works regardless of `update_source` setting.
|
# Check global polling status
|
||||||
|
curl http://localhost:8100/api/nl43/_polling/status
|
||||||
|
```
|
||||||
|
|
||||||
---
|
### Verify Device Settings
|
||||||
|
```bash
|
||||||
## Heartbeat Payload
|
curl http://localhost:8100/api/nl43/meter-001/settings
|
||||||
|
```
|
||||||
Posted to `api_url` on each API interval:
|
|
||||||
|
|
||||||
|
This returns all current device configuration:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"source_id": "THOR-PC",
|
"status": "ok",
|
||||||
"source_type": "series4_watcher",
|
"unit_id": "meter-001",
|
||||||
"version": "0.3.1",
|
"settings": {
|
||||||
"generated_at": "2026-03-20T14:30:00Z",
|
"measurement_state": "Stop",
|
||||||
"log_tail": ["...last 25 log lines..."],
|
"frequency_weighting": "A",
|
||||||
"units": [
|
"time_weighting": "F",
|
||||||
{
|
"measurement_time": "00:01:00",
|
||||||
"unit_id": "UM11719",
|
"leq_interval": "1s",
|
||||||
"last_call": "2026-03-20T13:18:00Z",
|
"lp_interval": "125ms",
|
||||||
"age_minutes": 72,
|
"index_number": "0",
|
||||||
"mlg_path": "C:\\THORDATA\\Project A\\UM11719\\UM11719_20260320131800.MLG",
|
"battery_level": "100%",
|
||||||
"project_hint": "Project A"
|
"clock": "2025/12/24,20:45:30",
|
||||||
|
"sleep_mode": "Off",
|
||||||
|
"ftp_status": "On"
|
||||||
}
|
}
|
||||||
]
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
### Stream Real-time Data (JavaScript)
|
||||||
|
```javascript
|
||||||
|
const ws = new WebSocket('ws://localhost:8100/api/nl43/meter-001/stream');
|
||||||
|
|
||||||
## THORDATA Directory Structure
|
ws.onmessage = (event) => {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
```
|
console.log('Live measurement:', data);
|
||||||
C:\THORDATA\
|
};
|
||||||
├── Project A\
|
|
||||||
│ ├── UM11719\
|
|
||||||
│ │ ├── UM11719_20260320131800.MLG
|
|
||||||
│ │ └── UM11719_20260319095430.MLG
|
|
||||||
│ └── UM12345\
|
|
||||||
│ └── UM12345_20260318091530.MLG
|
|
||||||
└── Project B\
|
|
||||||
└── UM98765\
|
|
||||||
└── UM98765_20260301082215.MLG
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
### Download Files via FTP
|
||||||
|
```bash
|
||||||
|
# Enable FTP
|
||||||
|
curl -X POST http://localhost:8100/api/nl43/meter-001/ftp/enable
|
||||||
|
|
||||||
|
# List files
|
||||||
|
curl http://localhost:8100/api/nl43/meter-001/ftp/files?path=/NL43_DATA
|
||||||
|
|
||||||
|
# Download file
|
||||||
|
curl -X POST http://localhost:8100/api/nl43/meter-001/ftp/download \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"remote_path": "/NL43_DATA/measurement.wav"}' \
|
||||||
|
--output measurement.wav
|
||||||
|
|
||||||
|
# Disable FTP
|
||||||
|
curl -X POST http://localhost:8100/api/nl43/meter-001/ftp/disable
|
||||||
|
```
|
||||||
|
|
||||||
|
## Integration with Terra-View
|
||||||
|
|
||||||
|
This backend is designed to be consumed by the Terra-View frontend application. The frontend should:
|
||||||
|
|
||||||
|
1. Use the config endpoints to register and configure devices
|
||||||
|
2. Poll or stream live status for real-time monitoring
|
||||||
|
3. Use control endpoints to manage measurements
|
||||||
|
4. Download files via FTP endpoints for analysis
|
||||||
|
|
||||||
|
See [API.md](API.md) for detailed integration examples.
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
**Tray icon is amber:** API URL is not configured or disabled — open Settings and enter Terra-View URL.
|
### Connection Issues
|
||||||
|
- Verify device IP address and port in configuration
|
||||||
|
- Ensure device is on the same network
|
||||||
|
- Check firewall rules allow TCP/FTP connections
|
||||||
|
- Verify RX55 network adapter is properly configured on device
|
||||||
|
|
||||||
**Tray icon is red:** API is failing — check Terra-View is reachable, URL is correct, and the network is up.
|
### Rate Limiting
|
||||||
|
- API automatically enforces 1-second delay between commands
|
||||||
|
- If experiencing delays, this is normal device behavior
|
||||||
|
- Multiple devices can be controlled in parallel
|
||||||
|
|
||||||
**Units showing wrong time in Terra-View:** Check `local_timezone` in Settings matches the field machine's timezone.
|
### FTP Active Mode
|
||||||
|
- Ensure server can accept incoming connections from device
|
||||||
|
- FTP uses active mode (device connects back to server)
|
||||||
|
- May require firewall configuration for data channel
|
||||||
|
|
||||||
**No units found:** Verify `thordata_path` is correct and MLG files exist following the `UM####_YYYYMMDDHHMMSS.MLG` naming convention.
|
### WebSocket Disconnects
|
||||||
|
- WebSocket streams maintain persistent connection
|
||||||
|
- Limit concurrent streams to avoid device overload
|
||||||
|
- Connection will auto-close if device stops responding
|
||||||
|
|
||||||
**Auto-updater not working:** Check the log file for `[updater]` lines. On first deploy, verify the Gitea release has a `thor-watcher-X.X.X.exe` asset (not a setup exe).
|
## Development
|
||||||
|
|
||||||
---
|
### Running Tests
|
||||||
|
```bash
|
||||||
|
# Add test commands when implemented
|
||||||
|
pytest
|
||||||
|
```
|
||||||
|
|
||||||
## Version History
|
### Database Migrations
|
||||||
|
```bash
|
||||||
|
# Migrate to v0.2.0 (add background polling fields)
|
||||||
|
python3 migrate_add_polling_fields.py
|
||||||
|
|
||||||
See [CHANGELOG.md](CHANGELOG.md) for detailed version history.
|
# Legacy: Migrate to add FTP credentials
|
||||||
|
python migrate_add_ftp_credentials.py
|
||||||
|
|
||||||
---
|
# Set FTP credentials for a device
|
||||||
|
python set_ftp_credentials.py <unit_id> <username> <password>
|
||||||
|
```
|
||||||
|
|
||||||
*Proprietary — Terra-Mechanics Inc. Internal use only.*
|
### Testing Background Polling
|
||||||
|
```bash
|
||||||
|
# Run comprehensive polling tests
|
||||||
|
./test_polling.sh [unit_id]
|
||||||
|
|
||||||
|
# Test settings endpoint
|
||||||
|
python3 test_settings_endpoint.py <unit_id>
|
||||||
|
|
||||||
|
# Test sleep mode auto-disable
|
||||||
|
python3 test_sleep_mode_auto_disable.py <unit_id>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Legacy Scripts
|
||||||
|
Old migration scripts and manual polling tools have been moved to `archive/` for reference. See [archive/README.md](archive/README.md) for details.
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
This is a standalone module kept separate from the SFM/Terra-View codebase. When contributing:
|
||||||
|
|
||||||
|
1. Maintain separation from frontend code
|
||||||
|
2. Follow existing API patterns and error handling
|
||||||
|
3. Update API documentation for new endpoints
|
||||||
|
4. Ensure rate limiting is enforced for device commands
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
[Specify license here]
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
- [API.md](API.md) - Complete API reference with examples
|
||||||
|
- [COMMUNICATION_GUIDE.md](COMMUNICATION_GUIDE.md) - NL43 protocol details
|
||||||
|
- [NL43_COMMANDS.md](NL43_COMMANDS.md) - Device command reference
|
||||||
|
- [manuals/](manuals/) - Device manufacturer documentation
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For issues and questions:
|
||||||
|
- Backend API issues: This repository
|
||||||
|
- Frontend/UI issues: Terra-View repository
|
||||||
|
- Device protocol questions: See COMMUNICATION_GUIDE.md
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
# SLMM addon package for NL43 integration.
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
"""
|
||||||
|
Background polling service for NL43 devices.
|
||||||
|
|
||||||
|
This module provides continuous, automatic polling of configured NL43 devices
|
||||||
|
at configurable intervals. Status snapshots are persisted to the database
|
||||||
|
for fast API access without querying devices on every request.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.database import SessionLocal
|
||||||
|
from app.models import NL43Config, NL43Status
|
||||||
|
from app.services import NL43Client, persist_snapshot, sync_measurement_start_time_from_ftp
|
||||||
|
from app.device_logger import log_device_event, cleanup_old_logs
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class BackgroundPoller:
|
||||||
|
"""
|
||||||
|
Background task that continuously polls NL43 devices and updates status cache.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- Per-device configurable poll intervals (30 seconds to 6 hours)
|
||||||
|
- Automatic offline detection (marks unreachable after 3 consecutive failures)
|
||||||
|
- Dynamic sleep intervals based on device configurations
|
||||||
|
- Graceful shutdown on application stop
|
||||||
|
- Respects existing rate limiting (1-second minimum between commands)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._task: Optional[asyncio.Task] = None
|
||||||
|
self._running = False
|
||||||
|
self._logger = logger
|
||||||
|
self._last_cleanup = None # Track last log cleanup time
|
||||||
|
|
||||||
|
async def start(self):
|
||||||
|
"""Start the background polling task."""
|
||||||
|
if self._running:
|
||||||
|
self._logger.warning("Background poller already running")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._running = True
|
||||||
|
self._task = asyncio.create_task(self._poll_loop())
|
||||||
|
self._logger.info("Background poller task created")
|
||||||
|
|
||||||
|
async def stop(self):
|
||||||
|
"""Gracefully stop the background polling task."""
|
||||||
|
if not self._running:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._logger.info("Stopping background poller...")
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
if self._task:
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(self._task, timeout=5.0)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
self._logger.warning("Background poller task did not stop gracefully, cancelling...")
|
||||||
|
self._task.cancel()
|
||||||
|
try:
|
||||||
|
await self._task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
self._logger.info("Background poller stopped")
|
||||||
|
|
||||||
|
async def _poll_loop(self):
|
||||||
|
"""Main polling loop that runs continuously."""
|
||||||
|
self._logger.info("Background polling loop started")
|
||||||
|
|
||||||
|
while self._running:
|
||||||
|
try:
|
||||||
|
await self._poll_all_devices()
|
||||||
|
except Exception as e:
|
||||||
|
self._logger.error(f"Error in poll loop: {e}", exc_info=True)
|
||||||
|
|
||||||
|
# Run log cleanup once per hour
|
||||||
|
try:
|
||||||
|
now = datetime.utcnow()
|
||||||
|
if self._last_cleanup is None or (now - self._last_cleanup).total_seconds() > 3600:
|
||||||
|
cleanup_old_logs()
|
||||||
|
self._last_cleanup = now
|
||||||
|
except Exception as e:
|
||||||
|
self._logger.warning(f"Log cleanup failed: {e}")
|
||||||
|
|
||||||
|
# Calculate dynamic sleep interval
|
||||||
|
sleep_time = self._calculate_sleep_interval()
|
||||||
|
self._logger.debug(f"Sleeping for {sleep_time} seconds until next poll cycle")
|
||||||
|
|
||||||
|
# Sleep in small intervals to allow graceful shutdown
|
||||||
|
for _ in range(int(sleep_time)):
|
||||||
|
if not self._running:
|
||||||
|
break
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
self._logger.info("Background polling loop exited")
|
||||||
|
|
||||||
|
async def _poll_all_devices(self):
|
||||||
|
"""Poll all configured devices that are due for polling."""
|
||||||
|
db: Session = SessionLocal()
|
||||||
|
try:
|
||||||
|
# Get all devices with TCP and polling enabled
|
||||||
|
configs = db.query(NL43Config).filter_by(
|
||||||
|
tcp_enabled=True,
|
||||||
|
poll_enabled=True
|
||||||
|
).all()
|
||||||
|
|
||||||
|
if not configs:
|
||||||
|
self._logger.debug("No devices configured for polling")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._logger.debug(f"Checking {len(configs)} devices for polling")
|
||||||
|
now = datetime.utcnow()
|
||||||
|
polled_count = 0
|
||||||
|
|
||||||
|
for cfg in configs:
|
||||||
|
if not self._running:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Get current status
|
||||||
|
status = db.query(NL43Status).filter_by(unit_id=cfg.unit_id).first()
|
||||||
|
|
||||||
|
# Check if device should be polled
|
||||||
|
if self._should_poll(cfg, status, now):
|
||||||
|
await self._poll_device(cfg, db)
|
||||||
|
polled_count += 1
|
||||||
|
else:
|
||||||
|
self._logger.debug(f"Skipping {cfg.unit_id} - interval not elapsed")
|
||||||
|
|
||||||
|
if polled_count > 0:
|
||||||
|
self._logger.info(f"Polled {polled_count}/{len(configs)} devices")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
def _should_poll(self, cfg: NL43Config, status: Optional[NL43Status], now: datetime) -> bool:
|
||||||
|
"""
|
||||||
|
Determine if a device should be polled based on interval and last poll time.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cfg: Device configuration
|
||||||
|
status: Current device status (may be None if never polled)
|
||||||
|
now: Current UTC timestamp
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if device should be polled, False otherwise
|
||||||
|
"""
|
||||||
|
# If never polled before, poll now
|
||||||
|
if not status or not status.last_poll_attempt:
|
||||||
|
self._logger.debug(f"Device {cfg.unit_id} never polled, polling now")
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Calculate elapsed time since last poll attempt
|
||||||
|
interval = cfg.poll_interval_seconds or 60
|
||||||
|
elapsed = (now - status.last_poll_attempt).total_seconds()
|
||||||
|
|
||||||
|
should_poll = elapsed >= interval
|
||||||
|
|
||||||
|
if should_poll:
|
||||||
|
self._logger.debug(
|
||||||
|
f"Device {cfg.unit_id} due for polling: {elapsed:.1f}s elapsed, interval={interval}s"
|
||||||
|
)
|
||||||
|
|
||||||
|
return should_poll
|
||||||
|
|
||||||
|
async def _poll_device(self, cfg: NL43Config, db: Session):
|
||||||
|
"""
|
||||||
|
Poll a single device and update its status in the database.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cfg: Device configuration
|
||||||
|
db: Database session
|
||||||
|
"""
|
||||||
|
unit_id = cfg.unit_id
|
||||||
|
self._logger.info(f"Polling device {unit_id} at {cfg.host}:{cfg.tcp_port}")
|
||||||
|
|
||||||
|
# Get or create status record
|
||||||
|
status = db.query(NL43Status).filter_by(unit_id=unit_id).first()
|
||||||
|
if not status:
|
||||||
|
status = NL43Status(unit_id=unit_id)
|
||||||
|
db.add(status)
|
||||||
|
|
||||||
|
# Update last_poll_attempt immediately
|
||||||
|
status.last_poll_attempt = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Create client and attempt to poll
|
||||||
|
client = NL43Client(
|
||||||
|
cfg.host,
|
||||||
|
cfg.tcp_port,
|
||||||
|
timeout=5.0,
|
||||||
|
ftp_username=cfg.ftp_username,
|
||||||
|
ftp_password=cfg.ftp_password,
|
||||||
|
ftp_port=cfg.ftp_port or 21
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Send DOD? command to get device status
|
||||||
|
snap = await client.request_dod()
|
||||||
|
snap.unit_id = unit_id
|
||||||
|
|
||||||
|
# Success - persist snapshot and reset failure counter
|
||||||
|
persist_snapshot(snap, db)
|
||||||
|
|
||||||
|
status.is_reachable = True
|
||||||
|
status.consecutive_failures = 0
|
||||||
|
status.last_success = datetime.utcnow()
|
||||||
|
status.last_error = None
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
self._logger.info(f"✓ Successfully polled {unit_id}")
|
||||||
|
|
||||||
|
# Log to device log
|
||||||
|
log_device_event(
|
||||||
|
unit_id, "INFO", "POLL",
|
||||||
|
f"Poll success: state={snap.measurement_state}, Leq={snap.leq}, Lp={snap.lp}",
|
||||||
|
db
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if device is measuring but has no start time recorded
|
||||||
|
# This happens if measurement was started before SLMM began polling
|
||||||
|
# or after a service restart
|
||||||
|
status = db.query(NL43Status).filter_by(unit_id=unit_id).first()
|
||||||
|
|
||||||
|
# Reset the sync flag when measurement stops (so next measurement can sync)
|
||||||
|
if status and status.measurement_state != "Start":
|
||||||
|
if status.start_time_sync_attempted:
|
||||||
|
status.start_time_sync_attempted = False
|
||||||
|
db.commit()
|
||||||
|
self._logger.debug(f"Reset FTP sync flag for {unit_id} (measurement stopped)")
|
||||||
|
log_device_event(unit_id, "DEBUG", "STATE", "Measurement stopped, reset FTP sync flag", db)
|
||||||
|
|
||||||
|
# Attempt FTP sync if:
|
||||||
|
# - Device is measuring
|
||||||
|
# - No start time recorded
|
||||||
|
# - FTP sync not already attempted for this measurement
|
||||||
|
# - FTP is configured
|
||||||
|
if (status and
|
||||||
|
status.measurement_state == "Start" and
|
||||||
|
status.measurement_start_time is None and
|
||||||
|
not status.start_time_sync_attempted and
|
||||||
|
cfg.ftp_enabled and
|
||||||
|
cfg.ftp_username and
|
||||||
|
cfg.ftp_password):
|
||||||
|
|
||||||
|
self._logger.info(
|
||||||
|
f"Device {unit_id} is measuring but has no start time - "
|
||||||
|
f"attempting FTP sync"
|
||||||
|
)
|
||||||
|
log_device_event(unit_id, "INFO", "SYNC", "Attempting FTP sync for measurement start time", db)
|
||||||
|
|
||||||
|
# Mark that we attempted sync (prevents repeated attempts on failure)
|
||||||
|
status.start_time_sync_attempted = True
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
try:
|
||||||
|
synced = await sync_measurement_start_time_from_ftp(
|
||||||
|
unit_id=unit_id,
|
||||||
|
host=cfg.host,
|
||||||
|
tcp_port=cfg.tcp_port,
|
||||||
|
ftp_port=cfg.ftp_port or 21,
|
||||||
|
ftp_username=cfg.ftp_username,
|
||||||
|
ftp_password=cfg.ftp_password,
|
||||||
|
db=db
|
||||||
|
)
|
||||||
|
if synced:
|
||||||
|
self._logger.info(f"✓ FTP sync succeeded for {unit_id}")
|
||||||
|
log_device_event(unit_id, "INFO", "SYNC", "FTP sync succeeded - measurement start time updated", db)
|
||||||
|
else:
|
||||||
|
self._logger.warning(f"FTP sync returned False for {unit_id}")
|
||||||
|
log_device_event(unit_id, "WARNING", "SYNC", "FTP sync returned False", db)
|
||||||
|
except Exception as sync_err:
|
||||||
|
self._logger.warning(
|
||||||
|
f"FTP sync failed for {unit_id}: {sync_err}"
|
||||||
|
)
|
||||||
|
log_device_event(unit_id, "ERROR", "SYNC", f"FTP sync failed: {sync_err}", db)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Failure - increment counter and potentially mark offline
|
||||||
|
status.consecutive_failures += 1
|
||||||
|
error_msg = str(e)[:500] # Truncate to prevent bloat
|
||||||
|
status.last_error = error_msg
|
||||||
|
|
||||||
|
# Mark unreachable after 3 consecutive failures
|
||||||
|
if status.consecutive_failures >= 3:
|
||||||
|
if status.is_reachable: # Only log transition
|
||||||
|
self._logger.warning(
|
||||||
|
f"Device {unit_id} marked unreachable after {status.consecutive_failures} failures: {error_msg}"
|
||||||
|
)
|
||||||
|
log_device_event(unit_id, "ERROR", "POLL", f"Device marked UNREACHABLE after {status.consecutive_failures} failures: {error_msg}", db)
|
||||||
|
status.is_reachable = False
|
||||||
|
else:
|
||||||
|
self._logger.warning(
|
||||||
|
f"Poll failed for {unit_id} (attempt {status.consecutive_failures}/3): {error_msg}"
|
||||||
|
)
|
||||||
|
log_device_event(unit_id, "WARNING", "POLL", f"Poll failed (attempt {status.consecutive_failures}/3): {error_msg}", db)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
def _calculate_sleep_interval(self) -> int:
|
||||||
|
"""
|
||||||
|
Calculate the next sleep interval based on all device poll intervals.
|
||||||
|
|
||||||
|
Returns a dynamic sleep time that ensures responsive polling:
|
||||||
|
- Minimum 30 seconds (prevents tight loops)
|
||||||
|
- Maximum 300 seconds / 5 minutes (ensures reasonable responsiveness for long intervals)
|
||||||
|
- Generally half the minimum device interval
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Sleep interval in seconds
|
||||||
|
"""
|
||||||
|
db: Session = SessionLocal()
|
||||||
|
try:
|
||||||
|
configs = db.query(NL43Config).filter_by(
|
||||||
|
tcp_enabled=True,
|
||||||
|
poll_enabled=True
|
||||||
|
).all()
|
||||||
|
|
||||||
|
if not configs:
|
||||||
|
return 60 # Default sleep when no devices configured
|
||||||
|
|
||||||
|
# Get all intervals
|
||||||
|
intervals = [cfg.poll_interval_seconds or 60 for cfg in configs]
|
||||||
|
min_interval = min(intervals)
|
||||||
|
|
||||||
|
# Use half the minimum interval, but cap between 30-300 seconds
|
||||||
|
# This allows longer sleep times when polling intervals are long (e.g., hourly)
|
||||||
|
sleep_time = max(30, min(300, min_interval // 2))
|
||||||
|
|
||||||
|
return sleep_time
|
||||||
|
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
# Global singleton instance
|
||||||
|
poller = BackgroundPoller()
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Ensure data directory exists for the SLMM addon
|
||||||
|
os.makedirs("data", exist_ok=True)
|
||||||
|
|
||||||
|
SQLALCHEMY_DATABASE_URL = "sqlite:///./data/slmm.db"
|
||||||
|
|
||||||
|
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False})
|
||||||
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
"""Dependency for database sessions."""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_db_session():
|
||||||
|
"""Get a database session directly (not as a dependency)."""
|
||||||
|
return SessionLocal()
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
"""
|
||||||
|
Per-device logging system.
|
||||||
|
|
||||||
|
Provides dual output: database entries for structured queries and file logs for backup.
|
||||||
|
Each device gets its own log file in data/logs/{unit_id}.log with rotation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from logging.handlers import RotatingFileHandler
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.database import SessionLocal
|
||||||
|
from app.models import DeviceLog
|
||||||
|
|
||||||
|
# Configure base logger
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Log directory (persisted in Docker volume)
|
||||||
|
LOG_DIR = Path(os.path.dirname(os.path.dirname(__file__))) / "data" / "logs"
|
||||||
|
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Per-device file loggers (cached)
|
||||||
|
_device_file_loggers: dict = {}
|
||||||
|
|
||||||
|
# Log retention (days)
|
||||||
|
LOG_RETENTION_DAYS = int(os.getenv("LOG_RETENTION_DAYS", "7"))
|
||||||
|
|
||||||
|
|
||||||
|
def _get_file_logger(unit_id: str) -> logging.Logger:
|
||||||
|
"""Get or create a file logger for a specific device."""
|
||||||
|
if unit_id in _device_file_loggers:
|
||||||
|
return _device_file_loggers[unit_id]
|
||||||
|
|
||||||
|
# Create device-specific logger
|
||||||
|
device_logger = logging.getLogger(f"device.{unit_id}")
|
||||||
|
device_logger.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
|
# Avoid duplicate handlers
|
||||||
|
if not device_logger.handlers:
|
||||||
|
# Create rotating file handler (5 MB max, keep 3 backups)
|
||||||
|
log_file = LOG_DIR / f"{unit_id}.log"
|
||||||
|
handler = RotatingFileHandler(
|
||||||
|
log_file,
|
||||||
|
maxBytes=5 * 1024 * 1024, # 5 MB
|
||||||
|
backupCount=3,
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
handler.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
|
# Format: timestamp [LEVEL] [CATEGORY] message
|
||||||
|
formatter = logging.Formatter(
|
||||||
|
"%(asctime)s [%(levelname)s] [%(category)s] %(message)s",
|
||||||
|
datefmt="%Y-%m-%d %H:%M:%S"
|
||||||
|
)
|
||||||
|
handler.setFormatter(formatter)
|
||||||
|
device_logger.addHandler(handler)
|
||||||
|
|
||||||
|
# Don't propagate to root logger
|
||||||
|
device_logger.propagate = False
|
||||||
|
|
||||||
|
_device_file_loggers[unit_id] = device_logger
|
||||||
|
return device_logger
|
||||||
|
|
||||||
|
|
||||||
|
def log_device_event(
|
||||||
|
unit_id: str,
|
||||||
|
level: str,
|
||||||
|
category: str,
|
||||||
|
message: str,
|
||||||
|
db: Optional[Session] = None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Log an event for a specific device.
|
||||||
|
|
||||||
|
Writes to both:
|
||||||
|
1. Database (DeviceLog table) for structured queries
|
||||||
|
2. File (data/logs/{unit_id}.log) for backup/debugging
|
||||||
|
|
||||||
|
Args:
|
||||||
|
unit_id: Device identifier
|
||||||
|
level: Log level (DEBUG, INFO, WARNING, ERROR)
|
||||||
|
category: Event category (TCP, FTP, POLL, COMMAND, STATE, SYNC)
|
||||||
|
message: Log message
|
||||||
|
db: Optional database session (creates one if not provided)
|
||||||
|
"""
|
||||||
|
timestamp = datetime.utcnow()
|
||||||
|
|
||||||
|
# Write to file log
|
||||||
|
try:
|
||||||
|
file_logger = _get_file_logger(unit_id)
|
||||||
|
log_func = getattr(file_logger, level.lower(), file_logger.info)
|
||||||
|
# Pass category as extra for formatter
|
||||||
|
log_func(message, extra={"category": category})
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to write file log for {unit_id}: {e}")
|
||||||
|
|
||||||
|
# Write to database
|
||||||
|
close_db = False
|
||||||
|
try:
|
||||||
|
if db is None:
|
||||||
|
db = SessionLocal()
|
||||||
|
close_db = True
|
||||||
|
|
||||||
|
log_entry = DeviceLog(
|
||||||
|
unit_id=unit_id,
|
||||||
|
timestamp=timestamp,
|
||||||
|
level=level.upper(),
|
||||||
|
category=category.upper(),
|
||||||
|
message=message
|
||||||
|
)
|
||||||
|
db.add(log_entry)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to write DB log for {unit_id}: {e}")
|
||||||
|
if db:
|
||||||
|
db.rollback()
|
||||||
|
finally:
|
||||||
|
if close_db and db:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_old_logs(retention_days: Optional[int] = None, db: Optional[Session] = None):
|
||||||
|
"""
|
||||||
|
Delete log entries older than retention period.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
retention_days: Days to retain (default: LOG_RETENTION_DAYS env var or 7)
|
||||||
|
db: Optional database session
|
||||||
|
"""
|
||||||
|
if retention_days is None:
|
||||||
|
retention_days = LOG_RETENTION_DAYS
|
||||||
|
|
||||||
|
cutoff = datetime.utcnow() - timedelta(days=retention_days)
|
||||||
|
|
||||||
|
close_db = False
|
||||||
|
try:
|
||||||
|
if db is None:
|
||||||
|
db = SessionLocal()
|
||||||
|
close_db = True
|
||||||
|
|
||||||
|
deleted = db.query(DeviceLog).filter(DeviceLog.timestamp < cutoff).delete()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
if deleted > 0:
|
||||||
|
logger.info(f"Cleaned up {deleted} log entries older than {retention_days} days")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to cleanup old logs: {e}")
|
||||||
|
if db:
|
||||||
|
db.rollback()
|
||||||
|
finally:
|
||||||
|
if close_db and db:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_device_logs(
|
||||||
|
unit_id: str,
|
||||||
|
limit: int = 100,
|
||||||
|
offset: int = 0,
|
||||||
|
level: Optional[str] = None,
|
||||||
|
category: Optional[str] = None,
|
||||||
|
since: Optional[datetime] = None,
|
||||||
|
db: Optional[Session] = None
|
||||||
|
) -> list:
|
||||||
|
"""
|
||||||
|
Query log entries for a specific device.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
unit_id: Device identifier
|
||||||
|
limit: Max entries to return (default: 100)
|
||||||
|
offset: Number of entries to skip (default: 0)
|
||||||
|
level: Filter by level (DEBUG, INFO, WARNING, ERROR)
|
||||||
|
category: Filter by category (TCP, FTP, POLL, COMMAND, STATE, SYNC)
|
||||||
|
since: Filter entries after this timestamp
|
||||||
|
db: Optional database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of log entries as dicts
|
||||||
|
"""
|
||||||
|
close_db = False
|
||||||
|
try:
|
||||||
|
if db is None:
|
||||||
|
db = SessionLocal()
|
||||||
|
close_db = True
|
||||||
|
|
||||||
|
query = db.query(DeviceLog).filter(DeviceLog.unit_id == unit_id)
|
||||||
|
|
||||||
|
if level:
|
||||||
|
query = query.filter(DeviceLog.level == level.upper())
|
||||||
|
if category:
|
||||||
|
query = query.filter(DeviceLog.category == category.upper())
|
||||||
|
if since:
|
||||||
|
query = query.filter(DeviceLog.timestamp >= since)
|
||||||
|
|
||||||
|
# Order by newest first
|
||||||
|
query = query.order_by(DeviceLog.timestamp.desc())
|
||||||
|
|
||||||
|
# Apply pagination
|
||||||
|
entries = query.offset(offset).limit(limit).all()
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": e.id,
|
||||||
|
"timestamp": e.timestamp.isoformat() + "Z",
|
||||||
|
"level": e.level,
|
||||||
|
"category": e.category,
|
||||||
|
"message": e.message
|
||||||
|
}
|
||||||
|
for e in entries
|
||||||
|
]
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if close_db and db:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_log_stats(unit_id: str, db: Optional[Session] = None) -> dict:
|
||||||
|
"""
|
||||||
|
Get log statistics for a device.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with counts by level and category
|
||||||
|
"""
|
||||||
|
close_db = False
|
||||||
|
try:
|
||||||
|
if db is None:
|
||||||
|
db = SessionLocal()
|
||||||
|
close_db = True
|
||||||
|
|
||||||
|
total = db.query(DeviceLog).filter(DeviceLog.unit_id == unit_id).count()
|
||||||
|
|
||||||
|
# Count by level
|
||||||
|
level_counts = {}
|
||||||
|
for level in ["DEBUG", "INFO", "WARNING", "ERROR"]:
|
||||||
|
count = db.query(DeviceLog).filter(
|
||||||
|
DeviceLog.unit_id == unit_id,
|
||||||
|
DeviceLog.level == level
|
||||||
|
).count()
|
||||||
|
if count > 0:
|
||||||
|
level_counts[level] = count
|
||||||
|
|
||||||
|
# Count by category
|
||||||
|
category_counts = {}
|
||||||
|
for category in ["TCP", "FTP", "POLL", "COMMAND", "STATE", "SYNC", "GENERAL"]:
|
||||||
|
count = db.query(DeviceLog).filter(
|
||||||
|
DeviceLog.unit_id == unit_id,
|
||||||
|
DeviceLog.category == category
|
||||||
|
).count()
|
||||||
|
if count > 0:
|
||||||
|
category_counts[category] = count
|
||||||
|
|
||||||
|
# Get oldest and newest
|
||||||
|
oldest = db.query(DeviceLog).filter(
|
||||||
|
DeviceLog.unit_id == unit_id
|
||||||
|
).order_by(DeviceLog.timestamp.asc()).first()
|
||||||
|
|
||||||
|
newest = db.query(DeviceLog).filter(
|
||||||
|
DeviceLog.unit_id == unit_id
|
||||||
|
).order_by(DeviceLog.timestamp.desc()).first()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total": total,
|
||||||
|
"by_level": level_counts,
|
||||||
|
"by_category": category_counts,
|
||||||
|
"oldest": oldest.timestamp.isoformat() + "Z" if oldest else None,
|
||||||
|
"newest": newest.timestamp.isoformat() + "Z" if newest else None
|
||||||
|
}
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if close_db and db:
|
||||||
|
db.close()
|
||||||
+141
@@ -0,0 +1,141 @@
|
|||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
from app.database import Base, engine
|
||||||
|
from app import routers
|
||||||
|
from app.background_poller import poller
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||||
|
handlers=[
|
||||||
|
logging.StreamHandler(),
|
||||||
|
logging.FileHandler("data/slmm.log"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Ensure database tables exist for the addon
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
logger.info("Database tables initialized")
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
"""Manage application lifecycle - startup and shutdown events."""
|
||||||
|
# Startup
|
||||||
|
logger.info("Starting background poller...")
|
||||||
|
await poller.start()
|
||||||
|
logger.info("Background poller started")
|
||||||
|
|
||||||
|
yield # Application runs
|
||||||
|
|
||||||
|
# Shutdown
|
||||||
|
logger.info("Stopping background poller...")
|
||||||
|
await poller.stop()
|
||||||
|
logger.info("Background poller stopped")
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="SLMM NL43 Addon",
|
||||||
|
description="Standalone module for NL43 configuration and status APIs with background polling",
|
||||||
|
version="0.2.0",
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
|
||||||
|
# CORS configuration - use environment variable for allowed origins
|
||||||
|
# Default to "*" for development, but should be restricted in production
|
||||||
|
allowed_origins = os.getenv("CORS_ORIGINS", "*").split(",")
|
||||||
|
logger.info(f"CORS allowed origins: {allowed_origins}")
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=allowed_origins,
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
|
app.include_router(routers.router)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/", response_class=HTMLResponse)
|
||||||
|
def index(request: Request):
|
||||||
|
return templates.TemplateResponse("index.html", {"request": request})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/roster", response_class=HTMLResponse)
|
||||||
|
def roster(request: Request):
|
||||||
|
return templates.TemplateResponse("roster.html", {"request": request})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health():
|
||||||
|
"""Basic health check endpoint."""
|
||||||
|
return {"status": "ok", "service": "slmm-nl43-addon"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health/devices")
|
||||||
|
async def health_devices():
|
||||||
|
"""Enhanced health check that tests device connectivity."""
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from app.database import SessionLocal
|
||||||
|
from app.services import NL43Client
|
||||||
|
from app.models import NL43Config
|
||||||
|
|
||||||
|
db: Session = SessionLocal()
|
||||||
|
device_status = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
configs = db.query(NL43Config).filter_by(tcp_enabled=True).all()
|
||||||
|
|
||||||
|
for cfg in configs:
|
||||||
|
client = NL43Client(cfg.host, cfg.tcp_port, timeout=2.0, ftp_username=cfg.ftp_username, ftp_password=cfg.ftp_password)
|
||||||
|
status = {
|
||||||
|
"unit_id": cfg.unit_id,
|
||||||
|
"host": cfg.host,
|
||||||
|
"port": cfg.tcp_port,
|
||||||
|
"reachable": False,
|
||||||
|
"error": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Try to connect (don't send command to avoid rate limiting issues)
|
||||||
|
import asyncio
|
||||||
|
reader, writer = await asyncio.wait_for(
|
||||||
|
asyncio.open_connection(cfg.host, cfg.tcp_port), timeout=2.0
|
||||||
|
)
|
||||||
|
writer.close()
|
||||||
|
await writer.wait_closed()
|
||||||
|
status["reachable"] = True
|
||||||
|
except Exception as e:
|
||||||
|
status["error"] = str(type(e).__name__)
|
||||||
|
logger.warning(f"Device {cfg.unit_id} health check failed: {e}")
|
||||||
|
|
||||||
|
device_status.append(status)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
all_reachable = all(d["reachable"] for d in device_status) if device_status else True
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok" if all_reachable else "degraded",
|
||||||
|
"devices": device_status,
|
||||||
|
"total_devices": len(device_status),
|
||||||
|
"reachable_devices": sum(1 for d in device_status if d["reachable"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
uvicorn.run("app.main:app", host="0.0.0.0", port=int(os.getenv("PORT", "8100")), reload=True)
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
from sqlalchemy import Column, String, DateTime, Boolean, Integer, Text, func
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class NL43Config(Base):
|
||||||
|
"""
|
||||||
|
NL43 connection/config metadata for the standalone SLMM addon.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "nl43_config"
|
||||||
|
|
||||||
|
unit_id = Column(String, primary_key=True, index=True)
|
||||||
|
host = Column(String, default="127.0.0.1")
|
||||||
|
tcp_port = Column(Integer, default=2255) # NL43 TCP control port (standard: 2255)
|
||||||
|
tcp_enabled = Column(Boolean, default=True)
|
||||||
|
ftp_enabled = Column(Boolean, default=False)
|
||||||
|
ftp_port = Column(Integer, default=21) # FTP port (standard: 21)
|
||||||
|
ftp_username = Column(String, nullable=True) # FTP login username
|
||||||
|
ftp_password = Column(String, nullable=True) # FTP login password
|
||||||
|
web_enabled = Column(Boolean, default=False)
|
||||||
|
|
||||||
|
# Background polling configuration
|
||||||
|
poll_interval_seconds = Column(Integer, nullable=True, default=60) # Polling interval (10-3600 seconds)
|
||||||
|
poll_enabled = Column(Boolean, default=True) # Enable/disable background polling for this device
|
||||||
|
|
||||||
|
|
||||||
|
class NL43Status(Base):
|
||||||
|
"""
|
||||||
|
Latest NL43 status snapshot for quick dashboard/API access.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "nl43_status"
|
||||||
|
|
||||||
|
unit_id = Column(String, primary_key=True, index=True)
|
||||||
|
last_seen = Column(DateTime, default=func.now())
|
||||||
|
measurement_state = Column(String, default="unknown") # Measure/Stop
|
||||||
|
measurement_start_time = Column(DateTime, nullable=True) # When measurement started (UTC)
|
||||||
|
counter = Column(String, nullable=True) # d0: Measurement interval counter (1-600)
|
||||||
|
lp = Column(String, nullable=True) # Instantaneous sound pressure level
|
||||||
|
leq = Column(String, nullable=True) # Equivalent continuous sound level
|
||||||
|
lmax = Column(String, nullable=True) # Maximum level
|
||||||
|
lmin = Column(String, nullable=True) # Minimum level
|
||||||
|
lpeak = Column(String, nullable=True) # Peak level
|
||||||
|
battery_level = Column(String, nullable=True)
|
||||||
|
power_source = Column(String, nullable=True)
|
||||||
|
sd_remaining_mb = Column(String, nullable=True)
|
||||||
|
sd_free_ratio = Column(String, nullable=True)
|
||||||
|
raw_payload = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
# Background polling status
|
||||||
|
is_reachable = Column(Boolean, default=True) # Device reachability status
|
||||||
|
consecutive_failures = Column(Integer, default=0) # Count of consecutive poll failures
|
||||||
|
last_poll_attempt = Column(DateTime, nullable=True) # Last time background poller attempted to poll
|
||||||
|
last_success = Column(DateTime, nullable=True) # Last successful poll timestamp
|
||||||
|
last_error = Column(Text, nullable=True) # Last error message (truncated to 500 chars)
|
||||||
|
|
||||||
|
# FTP start time sync tracking
|
||||||
|
start_time_sync_attempted = Column(Boolean, default=False) # True if FTP sync was attempted for current measurement
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceLog(Base):
|
||||||
|
"""
|
||||||
|
Per-device log entries for debugging and audit trail.
|
||||||
|
Stores events like commands, state changes, errors, and FTP operations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "device_logs"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
unit_id = Column(String, index=True, nullable=False)
|
||||||
|
timestamp = Column(DateTime, default=func.now(), index=True)
|
||||||
|
level = Column(String, default="INFO") # DEBUG, INFO, WARNING, ERROR
|
||||||
|
category = Column(String, default="GENERAL") # TCP, FTP, POLL, COMMAND, STATE, SYNC
|
||||||
|
message = Column(Text, nullable=False)
|
||||||
+2050
File diff suppressed because it is too large
Load Diff
+1423
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,67 @@
|
|||||||
|
# SLMM Archive
|
||||||
|
|
||||||
|
This directory contains legacy scripts that are no longer needed for normal operation but are preserved for reference.
|
||||||
|
|
||||||
|
## Legacy Migrations (`legacy_migrations/`)
|
||||||
|
|
||||||
|
These migration scripts were used during SLMM development (v0.1.x) to incrementally add database fields. They are **no longer needed** because:
|
||||||
|
|
||||||
|
1. **Fresh databases** get the complete schema automatically from `app/models.py`
|
||||||
|
2. **Existing databases** should already have these fields from previous runs
|
||||||
|
3. **Current migration** is `migrate_add_polling_fields.py` (v0.2.0) in the parent directory
|
||||||
|
|
||||||
|
### Archived Migration Files
|
||||||
|
|
||||||
|
- `migrate_add_counter.py` - Added `counter` field to NL43Status
|
||||||
|
- `migrate_add_measurement_start_time.py` - Added `measurement_start_time` field
|
||||||
|
- `migrate_add_ftp_port.py` - Added `ftp_port` field to NL43Config
|
||||||
|
- `migrate_field_names.py` - Renamed fields for consistency (one-time fix)
|
||||||
|
- `migrate_revert_field_names.py` - Rollback for the rename migration
|
||||||
|
|
||||||
|
**Do not delete** - These provide historical context for database schema evolution.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Legacy Tools
|
||||||
|
|
||||||
|
### `nl43_dod_poll.py`
|
||||||
|
|
||||||
|
Manual polling script that queries a single NL-43 device for DOD (Device On-Demand) data.
|
||||||
|
|
||||||
|
**Status**: Replaced by background polling system in v0.2.0
|
||||||
|
|
||||||
|
**Why archived**:
|
||||||
|
- Background poller (`app/background_poller.py`) now handles continuous polling automatically
|
||||||
|
- No need for manual polling scripts
|
||||||
|
- Kept for reference in case manual querying is needed for debugging
|
||||||
|
|
||||||
|
**How to use** (if needed):
|
||||||
|
```bash
|
||||||
|
cd /home/serversdown/tmi/slmm/archive
|
||||||
|
python3 nl43_dod_poll.py <host> <port> <unit_id>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Active Scripts (Still in Parent Directory)
|
||||||
|
|
||||||
|
These scripts are **actively used** and documented in the main README:
|
||||||
|
|
||||||
|
### Migrations
|
||||||
|
- `migrate_add_polling_fields.py` - **v0.2.0 migration** - Adds background polling fields
|
||||||
|
- `migrate_add_ftp_credentials.py` - **Legacy FTP migration** - Adds FTP auth fields
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
- `test_polling.sh` - Comprehensive test suite for background polling features
|
||||||
|
- `test_settings_endpoint.py` - Tests device settings API
|
||||||
|
- `test_sleep_mode_auto_disable.py` - Tests automatic sleep mode handling
|
||||||
|
|
||||||
|
### Utilities
|
||||||
|
- `set_ftp_credentials.py` - Command-line tool to set FTP credentials for a device
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Version History
|
||||||
|
|
||||||
|
- **v0.2.0** (2026-01-15) - Background polling system added, manual polling scripts archived
|
||||||
|
- **v0.1.0** (2025-12-XX) - Initial release with incremental migrations
|
||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Database migration: Add counter field to nl43_status table
|
||||||
|
|
||||||
|
This adds the d0 (measurement interval counter) field to track the device's
|
||||||
|
actual measurement progress for accurate timer synchronization.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
|
||||||
|
DB_PATH = "data/slmm.db"
|
||||||
|
|
||||||
|
def migrate():
|
||||||
|
print(f"Adding counter field to: {DB_PATH}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Check if counter column already exists
|
||||||
|
cursor.execute("PRAGMA table_info(nl43_status)")
|
||||||
|
columns = [row[1] for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
if 'counter' in columns:
|
||||||
|
print("✓ Counter column already exists, no migration needed")
|
||||||
|
conn.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
print("Starting migration...")
|
||||||
|
|
||||||
|
# Add counter column
|
||||||
|
cursor.execute("""
|
||||||
|
ALTER TABLE nl43_status
|
||||||
|
ADD COLUMN counter TEXT
|
||||||
|
""")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
print("✓ Added counter column")
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
cursor.execute("PRAGMA table_info(nl43_status)")
|
||||||
|
columns = [row[1] for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
if 'counter' not in columns:
|
||||||
|
raise Exception("Counter column was not added successfully")
|
||||||
|
|
||||||
|
print("✓ Migration completed successfully")
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ Migration failed: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
migrate()
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Migration script to add ftp_port column to nl43_config table.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python migrate_add_ftp_port.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
def migrate():
|
||||||
|
db_path = Path("data/slmm.db")
|
||||||
|
|
||||||
|
if not db_path.exists():
|
||||||
|
print(f"❌ Database not found at {db_path}")
|
||||||
|
print(" Run this script from the slmm directory")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Check if column already exists
|
||||||
|
cursor.execute("PRAGMA table_info(nl43_config)")
|
||||||
|
columns = [row[1] for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
if "ftp_port" in columns:
|
||||||
|
print("✓ ftp_port column already exists")
|
||||||
|
conn.close()
|
||||||
|
return True
|
||||||
|
|
||||||
|
print("Adding ftp_port column to nl43_config table...")
|
||||||
|
|
||||||
|
# Add the ftp_port column with default value of 21
|
||||||
|
cursor.execute("""
|
||||||
|
ALTER TABLE nl43_config
|
||||||
|
ADD COLUMN ftp_port INTEGER DEFAULT 21
|
||||||
|
""")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
print("✓ Migration completed successfully")
|
||||||
|
print(" Added ftp_port column (default: 21)")
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Migration failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
success = migrate()
|
||||||
|
sys.exit(0 if success else 1)
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Database migration: Add measurement_start_time field to nl43_status table
|
||||||
|
|
||||||
|
This tracks when a measurement session started by detecting the state transition
|
||||||
|
from "Stop" to "Measure", enabling accurate elapsed time display even for
|
||||||
|
manually-started measurements.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
|
||||||
|
DB_PATH = "data/slmm.db"
|
||||||
|
|
||||||
|
def migrate():
|
||||||
|
print(f"Adding measurement_start_time field to: {DB_PATH}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Check if measurement_start_time column already exists
|
||||||
|
cursor.execute("PRAGMA table_info(nl43_status)")
|
||||||
|
columns = [row[1] for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
if 'measurement_start_time' in columns:
|
||||||
|
print("✓ measurement_start_time column already exists, no migration needed")
|
||||||
|
conn.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
print("Starting migration...")
|
||||||
|
|
||||||
|
# Add measurement_start_time column
|
||||||
|
cursor.execute("""
|
||||||
|
ALTER TABLE nl43_status
|
||||||
|
ADD COLUMN measurement_start_time TEXT
|
||||||
|
""")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
print("✓ Added measurement_start_time column")
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
cursor.execute("PRAGMA table_info(nl43_status)")
|
||||||
|
columns = [row[1] for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
if 'measurement_start_time' not in columns:
|
||||||
|
raise Exception("measurement_start_time column was not added successfully")
|
||||||
|
|
||||||
|
print("✓ Migration completed successfully")
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ Migration failed: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
migrate()
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Migration script to rename NL43 measurement field names to match actual device output.
|
||||||
|
|
||||||
|
Changes:
|
||||||
|
- lp -> laeq (A-weighted equivalent continuous sound level)
|
||||||
|
- leq -> lae (A-weighted sound exposure level)
|
||||||
|
- lmax -> lasmax (A-weighted slow maximum)
|
||||||
|
- lmin -> lasmin (A-weighted slow minimum)
|
||||||
|
- lpeak -> lapeak (A-weighted peak)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
def migrate_database(db_path: str):
|
||||||
|
"""Migrate the database schema to use correct field names."""
|
||||||
|
|
||||||
|
print(f"Migrating database: {db_path}")
|
||||||
|
|
||||||
|
# Connect to database
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check if migration is needed
|
||||||
|
cur.execute("PRAGMA table_info(nl43_status)")
|
||||||
|
columns = [row[1] for row in cur.fetchall()]
|
||||||
|
|
||||||
|
if 'laeq' in columns:
|
||||||
|
print("✓ Database already migrated")
|
||||||
|
return
|
||||||
|
|
||||||
|
if 'lp' not in columns:
|
||||||
|
print("✗ Database schema does not match expected format")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print("Starting migration...")
|
||||||
|
|
||||||
|
# SQLite doesn't support column renaming directly, so we need to:
|
||||||
|
# 1. Create new table with correct column names
|
||||||
|
# 2. Copy data from old table
|
||||||
|
# 3. Drop old table
|
||||||
|
# 4. Rename new table
|
||||||
|
|
||||||
|
# Create new table with correct column names
|
||||||
|
cur.execute("""
|
||||||
|
CREATE TABLE nl43_status_new (
|
||||||
|
unit_id VARCHAR PRIMARY KEY,
|
||||||
|
last_seen DATETIME,
|
||||||
|
measurement_state VARCHAR,
|
||||||
|
laeq VARCHAR,
|
||||||
|
lae VARCHAR,
|
||||||
|
lasmax VARCHAR,
|
||||||
|
lasmin VARCHAR,
|
||||||
|
lapeak VARCHAR,
|
||||||
|
battery_level VARCHAR,
|
||||||
|
power_source VARCHAR,
|
||||||
|
sd_remaining_mb VARCHAR,
|
||||||
|
sd_free_ratio VARCHAR,
|
||||||
|
raw_payload TEXT
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
print("✓ Created new table with correct column names")
|
||||||
|
|
||||||
|
# Copy data from old table to new table
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO nl43_status_new
|
||||||
|
(unit_id, last_seen, measurement_state, laeq, lae, lasmax, lasmin, lapeak,
|
||||||
|
battery_level, power_source, sd_remaining_mb, sd_free_ratio, raw_payload)
|
||||||
|
SELECT
|
||||||
|
unit_id, last_seen, measurement_state, lp, leq, lmax, lmin, lpeak,
|
||||||
|
battery_level, power_source, sd_remaining_mb, sd_free_ratio, raw_payload
|
||||||
|
FROM nl43_status
|
||||||
|
""")
|
||||||
|
rows_copied = cur.rowcount
|
||||||
|
print(f"✓ Copied {rows_copied} rows from old table")
|
||||||
|
|
||||||
|
# Drop old table
|
||||||
|
cur.execute("DROP TABLE nl43_status")
|
||||||
|
print("✓ Dropped old table")
|
||||||
|
|
||||||
|
# Rename new table
|
||||||
|
cur.execute("ALTER TABLE nl43_status_new RENAME TO nl43_status")
|
||||||
|
print("✓ Renamed new table to nl43_status")
|
||||||
|
|
||||||
|
# Commit changes
|
||||||
|
conn.commit()
|
||||||
|
print("✓ Migration completed successfully")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
conn.rollback()
|
||||||
|
print(f"✗ Migration failed: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Default database path
|
||||||
|
db_path = Path(__file__).parent / "data" / "slmm.db"
|
||||||
|
|
||||||
|
# Allow custom path as command line argument
|
||||||
|
if len(sys.argv) > 1:
|
||||||
|
db_path = Path(sys.argv[1])
|
||||||
|
|
||||||
|
if not db_path.exists():
|
||||||
|
print(f"✗ Database not found: {db_path}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
migrate_database(str(db_path))
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Migration script to revert NL43 measurement field names back to correct DRD format.
|
||||||
|
|
||||||
|
The previous migration was incorrect. According to NL43 DRD documentation:
|
||||||
|
- d0 = counter (1-600) - NOT a measurement!
|
||||||
|
- d1 = Lp (instantaneous sound pressure level)
|
||||||
|
- d2 = Leq (equivalent continuous sound level)
|
||||||
|
- d3 = Lmax (maximum level)
|
||||||
|
- d4 = Lmin (minimum level)
|
||||||
|
- d5 = Lpeak (peak level)
|
||||||
|
|
||||||
|
Changes:
|
||||||
|
- laeq -> lp (was incorrectly mapped to counter field!)
|
||||||
|
- lae -> leq
|
||||||
|
- lasmax -> lmax
|
||||||
|
- lasmin -> lmin
|
||||||
|
- lapeak -> lpeak
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
def migrate_database(db_path: str):
|
||||||
|
"""Revert database schema to correct DRD field names."""
|
||||||
|
|
||||||
|
print(f"Reverting database migration: {db_path}")
|
||||||
|
|
||||||
|
# Connect to database
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check if migration is needed
|
||||||
|
cur.execute("PRAGMA table_info(nl43_status)")
|
||||||
|
columns = [row[1] for row in cur.fetchall()]
|
||||||
|
|
||||||
|
if 'lp' in columns:
|
||||||
|
print("✓ Database already has correct field names")
|
||||||
|
return
|
||||||
|
|
||||||
|
if 'laeq' not in columns:
|
||||||
|
print("✗ Database schema does not match expected format")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print("Starting revert migration...")
|
||||||
|
|
||||||
|
# Create new table with correct column names
|
||||||
|
cur.execute("""
|
||||||
|
CREATE TABLE nl43_status_new (
|
||||||
|
unit_id VARCHAR PRIMARY KEY,
|
||||||
|
last_seen DATETIME,
|
||||||
|
measurement_state VARCHAR,
|
||||||
|
lp VARCHAR,
|
||||||
|
leq VARCHAR,
|
||||||
|
lmax VARCHAR,
|
||||||
|
lmin VARCHAR,
|
||||||
|
lpeak VARCHAR,
|
||||||
|
battery_level VARCHAR,
|
||||||
|
power_source VARCHAR,
|
||||||
|
sd_remaining_mb VARCHAR,
|
||||||
|
sd_free_ratio VARCHAR,
|
||||||
|
raw_payload TEXT
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
print("✓ Created new table with correct DRD field names")
|
||||||
|
|
||||||
|
# Copy data from old table to new table
|
||||||
|
# Note: laeq was incorrectly mapped to d0 (counter), so we discard it
|
||||||
|
# The actual measurements start from d1
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO nl43_status_new
|
||||||
|
(unit_id, last_seen, measurement_state, lp, leq, lmax, lmin, lpeak,
|
||||||
|
battery_level, power_source, sd_remaining_mb, sd_free_ratio, raw_payload)
|
||||||
|
SELECT
|
||||||
|
unit_id, last_seen, measurement_state, lae, lasmax, lasmin, lapeak, NULL,
|
||||||
|
battery_level, power_source, sd_remaining_mb, sd_free_ratio, raw_payload
|
||||||
|
FROM nl43_status
|
||||||
|
""")
|
||||||
|
rows_copied = cur.rowcount
|
||||||
|
print(f"✓ Copied {rows_copied} rows (note: discarded incorrect 'laeq' counter field)")
|
||||||
|
|
||||||
|
# Drop old table
|
||||||
|
cur.execute("DROP TABLE nl43_status")
|
||||||
|
print("✓ Dropped old table")
|
||||||
|
|
||||||
|
# Rename new table
|
||||||
|
cur.execute("ALTER TABLE nl43_status_new RENAME TO nl43_status")
|
||||||
|
print("✓ Renamed new table to nl43_status")
|
||||||
|
|
||||||
|
# Commit changes
|
||||||
|
conn.commit()
|
||||||
|
print("✓ Revert migration completed successfully")
|
||||||
|
print("\nNote: The 'lp' field will be populated correctly on next device measurement")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
conn.rollback()
|
||||||
|
print(f"✗ Migration failed: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Default database path
|
||||||
|
db_path = Path(__file__).parent / "data" / "slmm.db"
|
||||||
|
|
||||||
|
# Allow custom path as command line argument
|
||||||
|
if len(sys.argv) > 1:
|
||||||
|
db_path = Path(sys.argv[1])
|
||||||
|
|
||||||
|
if not db_path.exists():
|
||||||
|
print(f"✗ Database not found: {db_path}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
migrate_database(str(db_path))
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Diagnostic poller for NL-43 TCP connectivity.
|
||||||
|
|
||||||
|
Every interval, open a TCP connection, send DOD?, read response, and log results.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
import socket
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# ---- Configuration (edit as needed) ----
|
||||||
|
HOST = "192.168.0.10"
|
||||||
|
PORT = 2255
|
||||||
|
INTERVAL_SECONDS = 5 * 60
|
||||||
|
CONNECT_TIMEOUT_SECONDS = 5.0
|
||||||
|
READ_TIMEOUT_SECONDS = 5.0
|
||||||
|
LOG_PATH = Path("nl43_dod_poll.log")
|
||||||
|
# ---------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _timestamp() -> str:
|
||||||
|
return dt.datetime.utcnow().isoformat(timespec="seconds") + "Z"
|
||||||
|
|
||||||
|
|
||||||
|
def _read_line(sock_file) -> str:
|
||||||
|
line = sock_file.readline()
|
||||||
|
if not line:
|
||||||
|
raise ConnectionError("Socket closed before full response")
|
||||||
|
return line.decode("ascii", errors="ignore").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _poll_once() -> tuple[bool, str, str, str, str]:
|
||||||
|
sock = None
|
||||||
|
result_code = ""
|
||||||
|
data_line = ""
|
||||||
|
try:
|
||||||
|
sock = socket.create_connection((HOST, PORT), timeout=CONNECT_TIMEOUT_SECONDS)
|
||||||
|
sock.settimeout(READ_TIMEOUT_SECONDS)
|
||||||
|
|
||||||
|
sock.sendall(b"DOD?\r\n")
|
||||||
|
|
||||||
|
with sock.makefile("rb") as sock_file:
|
||||||
|
result_code = _read_line(sock_file)
|
||||||
|
if result_code.startswith("$"):
|
||||||
|
result_code = result_code[1:].strip()
|
||||||
|
|
||||||
|
if result_code != "R+0000":
|
||||||
|
return False, "other", f"device_result={result_code}", result_code, data_line
|
||||||
|
|
||||||
|
data_line = _read_line(sock_file)
|
||||||
|
if data_line.startswith("$"):
|
||||||
|
data_line = data_line[1:].strip()
|
||||||
|
|
||||||
|
return True, "none", "ok", result_code, data_line
|
||||||
|
except socket.timeout:
|
||||||
|
return False, "timeout", "socket_timeout", result_code, data_line
|
||||||
|
except ConnectionRefusedError:
|
||||||
|
return False, "refused", "connection_refused", result_code, data_line
|
||||||
|
except OSError as exc:
|
||||||
|
return False, "other", f"os_error={exc.__class__.__name__}", result_code, data_line
|
||||||
|
except Exception as exc:
|
||||||
|
return False, "other", f"error={exc.__class__.__name__}", result_code, data_line
|
||||||
|
finally:
|
||||||
|
if sock is not None:
|
||||||
|
try:
|
||||||
|
sock.shutdown(socket.SHUT_RDWR)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
sock.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _log_line(text: str) -> None:
|
||||||
|
print(text, flush=True)
|
||||||
|
with LOG_PATH.open("a", encoding="ascii") as handle:
|
||||||
|
handle.write(text + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
while True:
|
||||||
|
start = time.monotonic()
|
||||||
|
ok, error_type, detail, result_code, data_line = _poll_once()
|
||||||
|
|
||||||
|
status = "success" if ok else "failure"
|
||||||
|
msg = (
|
||||||
|
f"ts={_timestamp()} status={status} error_type={error_type} "
|
||||||
|
f"detail={detail} result_code={result_code} data={data_line}"
|
||||||
|
)
|
||||||
|
_log_line(msg)
|
||||||
|
|
||||||
|
elapsed = time.monotonic() - start
|
||||||
|
sleep_for = max(0.0, INTERVAL_SECONDS - elapsed)
|
||||||
|
time.sleep(sleep_for)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
@echo off
|
|
||||||
echo Building thor-watcher.exe...
|
|
||||||
pip install pyinstaller pystray Pillow
|
|
||||||
|
|
||||||
REM Extract version from series4_ingest.py (looks for: VERSION = "0.2.0")
|
|
||||||
for /f "tokens=3 delims= " %%V in ('findstr /C:"VERSION = " series4_ingest.py') do set RAW_VER=%%V
|
|
||||||
set VERSION=%RAW_VER:"=%
|
|
||||||
set EXE_NAME=thor-watcher-%VERSION%
|
|
||||||
|
|
||||||
echo Version: %VERSION%
|
|
||||||
echo Output: dist\%EXE_NAME%.exe
|
|
||||||
|
|
||||||
REM Check whether icon.ico exists alongside this script.
|
|
||||||
if exist "%~dp0icon.ico" (
|
|
||||||
pyinstaller --onefile --windowed --name "%EXE_NAME%" ^
|
|
||||||
--icon="%~dp0icon.ico" ^
|
|
||||||
--add-data "%~dp0icon.ico;." ^
|
|
||||||
thor_tray.py
|
|
||||||
) else (
|
|
||||||
echo [INFO] icon.ico not found -- building without custom icon.
|
|
||||||
pyinstaller --onefile --windowed --name "%EXE_NAME%" thor_tray.py
|
|
||||||
)
|
|
||||||
|
|
||||||
REM Copy versioned exe to plain name for Inno Setup
|
|
||||||
copy /Y "dist\%EXE_NAME%.exe" "dist\thor-watcher.exe"
|
|
||||||
|
|
||||||
echo.
|
|
||||||
echo Done.
|
|
||||||
echo Gitea upload: dist\%EXE_NAME%.exe
|
|
||||||
echo Inno Setup: dist\thor-watcher.exe (copy of above)
|
|
||||||
pause
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
{
|
|
||||||
"thordata_path": "C:\\THORDATA",
|
|
||||||
"scan_interval": 60,
|
|
||||||
|
|
||||||
"api_url": "",
|
|
||||||
"api_timeout": 5,
|
|
||||||
"api_interval": 300,
|
|
||||||
"source_id": "",
|
|
||||||
"source_type": "series4_watcher",
|
|
||||||
"local_timezone": "America/New_York",
|
|
||||||
|
|
||||||
"enable_logging": true,
|
|
||||||
"log_file": "C:\\Users\\%USERNAME%\\AppData\\Local\\ThorWatcher\\agent_logs\\thor_watcher.log",
|
|
||||||
"log_retention_days": 30,
|
|
||||||
|
|
||||||
"update_source": "gitea",
|
|
||||||
"update_url": "",
|
|
||||||
|
|
||||||
"sfm_forward_enabled": false,
|
|
||||||
"sfm_url": "",
|
|
||||||
"sfm_forward_interval": 60,
|
|
||||||
"sfm_quiescence_seconds": 5,
|
|
||||||
"sfm_missing_report_grace_seconds": 60,
|
|
||||||
"sfm_http_timeout": 60,
|
|
||||||
"sfm_state_file": "",
|
|
||||||
"sfm_max_forwards_per_pass": 500,
|
|
||||||
"sfm_max_event_age_days": 365
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
# Thor File Stucture Guide
|
|
||||||
|
|
||||||
This document is to explain how Thor formatis the file structure for units that call in via thor Autocall home.
|
|
||||||
|
|
||||||
## Main Stucture
|
|
||||||
|
|
||||||
Thor saves its data in a folder located in C:/ called 'THORDATA'. it then Creates folders for each user entered project. When a unit is added to that project, it creates a folder in project with that unit's serial number. Raw events (.IDFH for histogram and .IDFW for waveforms) plus .MLG monitor logs are then saved in this folder. if a unit is not assigned a project, it saves into a default project folder. If it matters, there is also a daily log file that gets created in the folder 'Logs'
|
|
||||||
|
|
||||||
In each unit's folder, there are various formats saved in their own individual folders too. Most cases have CSV, HTML, PDF, TXT, and XML
|
|
||||||
|
|
||||||
Here is the structure illustrated:
|
|
||||||
|
|
||||||
C:/THORDATA
|
|
||||||
├───Mon-Fayette Express Way - Sec 53A1
|
|
||||||
│ └───UM11402
|
|
||||||
│ ├───CSV
|
|
||||||
│ ├───HTML
|
|
||||||
│ ├───PDF
|
|
||||||
│ ├───TXT
|
|
||||||
│ └───XML
|
|
||||||
├───P.J. Dick - 5th and Halket
|
|
||||||
│ ├───UM11719
|
|
||||||
│ │ ├───CSV
|
|
||||||
│ │ ├───HTML
|
|
||||||
│ │ ├───PDF
|
|
||||||
│ │ ├───TXT
|
|
||||||
│ │ └───XML
|
|
||||||
│ UM12420
|
|
||||||
│ ├───CSV
|
|
||||||
│ ├───HTML
|
|
||||||
│ ├───PDF
|
|
||||||
│ ├───TXT
|
|
||||||
│ └───XML
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Here is an expanded project folder with two events in it
|
|
||||||
|
|
||||||
|
|
||||||
└───Clearwater - ECMS 57940
|
|
||||||
└───BE9439
|
|
||||||
│ BE9439_20200713124250.MLG
|
|
||||||
│ BE9439_20200713124251.IDFH
|
|
||||||
│ BE9439_20200713131747.IDFW
|
|
||||||
│ BE9439_20200713131747.IDFW.CDB
|
|
||||||
│
|
|
||||||
├───CSV
|
|
||||||
│ BE9439_20200713124251.IDFH.csv
|
|
||||||
│ BE9439_20200713131747.IDFW.csv
|
|
||||||
│
|
|
||||||
├───PDF
|
|
||||||
│ BE9439_20200713124251.IDFH.pdf
|
|
||||||
│ BE9439_20200713131747.IDFW.pdf
|
|
||||||
│
|
|
||||||
├───TXT
|
|
||||||
│ BE9439_20200713124251.IDFH.txt
|
|
||||||
│ BE9439_20200713131747.IDFW.txt
|
|
||||||
│
|
|
||||||
└───XML
|
|
||||||
BE9439_20200713124251_IDFH_XML.XML
|
|
||||||
BE9439_20200713131747_IDFW_XML.XML
|
|
||||||
|
|
||||||
+672
@@ -0,0 +1,672 @@
|
|||||||
|
# SLMM API Documentation
|
||||||
|
|
||||||
|
REST API for controlling Rion NL-43/NL-53 Sound Level Meters via TCP and FTP.
|
||||||
|
|
||||||
|
Base URL: `http://localhost:8000/api/nl43`
|
||||||
|
|
||||||
|
All endpoints require a `unit_id` parameter identifying the device.
|
||||||
|
|
||||||
|
## Device Configuration
|
||||||
|
|
||||||
|
### Get Device Config
|
||||||
|
```
|
||||||
|
GET /{unit_id}/config
|
||||||
|
```
|
||||||
|
Returns the device configuration including host, port, and enabled protocols.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"data": {
|
||||||
|
"unit_id": "nl43-1",
|
||||||
|
"host": "192.168.1.100",
|
||||||
|
"tcp_port": 2255,
|
||||||
|
"tcp_enabled": true,
|
||||||
|
"ftp_enabled": false,
|
||||||
|
"web_enabled": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Update Device Config
|
||||||
|
```
|
||||||
|
PUT /{unit_id}/config
|
||||||
|
```
|
||||||
|
Update device configuration.
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"host": "192.168.1.100",
|
||||||
|
"tcp_port": 2255,
|
||||||
|
"tcp_enabled": true,
|
||||||
|
"ftp_enabled": false,
|
||||||
|
"ftp_username": "admin",
|
||||||
|
"ftp_password": "password",
|
||||||
|
"web_enabled": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important:** When TCP is enabled and connection details are provided, sleep mode will be automatically disabled on the device. This is necessary because sleep/eco mode turns off TCP communications, which would prevent remote monitoring and control.
|
||||||
|
|
||||||
|
## Device Status
|
||||||
|
|
||||||
|
### Get Cached Status
|
||||||
|
```
|
||||||
|
GET /{unit_id}/status
|
||||||
|
```
|
||||||
|
Returns the last cached measurement snapshot from the database.
|
||||||
|
|
||||||
|
### Get Live Status
|
||||||
|
```
|
||||||
|
GET /{unit_id}/live
|
||||||
|
```
|
||||||
|
Requests fresh DOD (Display On Demand) data from the device and returns current measurements.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"data": {
|
||||||
|
"unit_id": "nl43-1",
|
||||||
|
"measurement_state": "Measure",
|
||||||
|
"lp": "65.2",
|
||||||
|
"leq": "68.4",
|
||||||
|
"lmax": "82.1",
|
||||||
|
"lmin": "42.3",
|
||||||
|
"lpeak": "89.5",
|
||||||
|
"battery_level": "80",
|
||||||
|
"power_source": "Battery",
|
||||||
|
"sd_remaining_mb": "2048",
|
||||||
|
"sd_free_ratio": "50"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stream Live Data (WebSocket)
|
||||||
|
```
|
||||||
|
WS /{unit_id}/live
|
||||||
|
```
|
||||||
|
Opens a WebSocket connection and streams continuous DRD (Display Real-time Data) from the device.
|
||||||
|
|
||||||
|
## Measurement Control
|
||||||
|
|
||||||
|
### Start Measurement
|
||||||
|
```
|
||||||
|
POST /{unit_id}/start
|
||||||
|
```
|
||||||
|
Starts measurement on the device.
|
||||||
|
|
||||||
|
**Important:** Before starting the measurement, sleep mode is automatically disabled to ensure TCP communications remain active throughout the measurement session.
|
||||||
|
|
||||||
|
### Stop Measurement
|
||||||
|
```
|
||||||
|
POST /{unit_id}/stop
|
||||||
|
```
|
||||||
|
Stops measurement on the device.
|
||||||
|
|
||||||
|
### Pause Measurement
|
||||||
|
```
|
||||||
|
POST /{unit_id}/pause
|
||||||
|
```
|
||||||
|
Pauses the current measurement.
|
||||||
|
|
||||||
|
### Resume Measurement
|
||||||
|
```
|
||||||
|
POST /{unit_id}/resume
|
||||||
|
```
|
||||||
|
Resumes a paused measurement.
|
||||||
|
|
||||||
|
### Reset Measurement
|
||||||
|
```
|
||||||
|
POST /{unit_id}/reset
|
||||||
|
```
|
||||||
|
Resets the measurement data.
|
||||||
|
|
||||||
|
### Get Measurement State
|
||||||
|
```
|
||||||
|
GET /{unit_id}/measurement-state
|
||||||
|
```
|
||||||
|
Gets the current measurement state to determine if the device is actively measuring.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"measurement_state": "Start",
|
||||||
|
"is_measuring": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Values:**
|
||||||
|
- `measurement_state`: "Start" (measuring) or "Stop" (not measuring)
|
||||||
|
- `is_measuring`: Boolean convenience field (true if measuring, false otherwise)
|
||||||
|
|
||||||
|
**Note:** Elapsed time and interval counts are displayed on the device OSD but not available via command. Track elapsed time in your application by recording the start time when you call the `/start` endpoint.
|
||||||
|
|
||||||
|
### Manual Store
|
||||||
|
```
|
||||||
|
POST /{unit_id}/store
|
||||||
|
```
|
||||||
|
Manually stores the current measurement data.
|
||||||
|
|
||||||
|
## Device Information
|
||||||
|
|
||||||
|
### Get Battery Level
|
||||||
|
```
|
||||||
|
GET /{unit_id}/battery
|
||||||
|
```
|
||||||
|
Returns the battery level.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"battery_level": "80"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get Clock
|
||||||
|
```
|
||||||
|
GET /{unit_id}/clock
|
||||||
|
```
|
||||||
|
Returns the device clock time.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"clock": "2025/12/24,02:30:15"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Set Clock
|
||||||
|
```
|
||||||
|
PUT /{unit_id}/clock
|
||||||
|
```
|
||||||
|
Sets the device clock time.
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"datetime": "2025/12/24,02:30:15"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Measurement Settings
|
||||||
|
|
||||||
|
### Get Frequency Weighting
|
||||||
|
```
|
||||||
|
GET /{unit_id}/frequency-weighting?channel=Main
|
||||||
|
```
|
||||||
|
Gets the frequency weighting (A, C, or Z) for a channel.
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
- `channel` (optional): Main, Sub1, Sub2, or Sub3 (default: Main)
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"frequency_weighting": "A",
|
||||||
|
"channel": "Main"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Set Frequency Weighting
|
||||||
|
```
|
||||||
|
PUT /{unit_id}/frequency-weighting
|
||||||
|
```
|
||||||
|
Sets the frequency weighting.
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"weighting": "A",
|
||||||
|
"channel": "Main"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get Time Weighting
|
||||||
|
```
|
||||||
|
GET /{unit_id}/time-weighting?channel=Main
|
||||||
|
```
|
||||||
|
Gets the time weighting (F, S, or I) for a channel.
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
- `channel` (optional): Main, Sub1, Sub2, or Sub3 (default: Main)
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"time_weighting": "F",
|
||||||
|
"channel": "Main"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Set Time Weighting
|
||||||
|
```
|
||||||
|
PUT /{unit_id}/time-weighting
|
||||||
|
```
|
||||||
|
Sets the time weighting.
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"weighting": "F",
|
||||||
|
"channel": "Main"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Values:**
|
||||||
|
- `F` - Fast (125ms)
|
||||||
|
- `S` - Slow (1s)
|
||||||
|
- `I` - Impulse (35ms)
|
||||||
|
|
||||||
|
## Timing and Interval Configuration
|
||||||
|
|
||||||
|
### Get Measurement Time
|
||||||
|
```
|
||||||
|
GET /{unit_id}/measurement-time
|
||||||
|
```
|
||||||
|
Gets the current measurement time preset.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"measurement_time": "1h"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Set Measurement Time
|
||||||
|
```
|
||||||
|
PUT /{unit_id}/measurement-time
|
||||||
|
```
|
||||||
|
Sets the measurement time preset.
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"preset": "1h"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Preset Values:**
|
||||||
|
- `10s`, `1m`, `5m`, `10m`, `15m`, `30m`, `1h`, `8h`, `24h`
|
||||||
|
- Custom format: `HH:MM:SS` (e.g., `00:05:30` for 5.5 minutes)
|
||||||
|
|
||||||
|
### Get Leq Calculation Interval
|
||||||
|
```
|
||||||
|
GET /{unit_id}/leq-interval
|
||||||
|
```
|
||||||
|
Gets the current Leq calculation interval.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"leq_interval": "1m"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Set Leq Calculation Interval
|
||||||
|
```
|
||||||
|
PUT /{unit_id}/leq-interval
|
||||||
|
```
|
||||||
|
Sets the Leq calculation interval preset.
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"preset": "1m"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Preset Values:**
|
||||||
|
- `Off`, `10s`, `1m`, `5m`, `10m`, `15m`, `30m`, `1h`, `8h`, `24h`
|
||||||
|
- Custom format: `HH:MM:SS` (e.g., `00:05:30` for 5.5 minutes)
|
||||||
|
|
||||||
|
### Get Lp Store Interval
|
||||||
|
```
|
||||||
|
GET /{unit_id}/lp-interval
|
||||||
|
```
|
||||||
|
Gets the current Lp store interval.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"lp_interval": "1s"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Set Lp Store Interval
|
||||||
|
```
|
||||||
|
PUT /{unit_id}/lp-interval
|
||||||
|
```
|
||||||
|
Sets the Lp store interval.
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"preset": "1s"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Preset Values:**
|
||||||
|
- `Off`, `10ms`, `25ms`, `100ms`, `200ms`, `1s`
|
||||||
|
|
||||||
|
### Get Index Number
|
||||||
|
```
|
||||||
|
GET /{unit_id}/index-number
|
||||||
|
```
|
||||||
|
Gets the current index number for file numbering.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"index_number": "0042"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Set Index Number
|
||||||
|
```
|
||||||
|
PUT /{unit_id}/index-number
|
||||||
|
```
|
||||||
|
Sets the index number for file numbering. This number is incremented with each measurement and used in file names.
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"index": 42
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Valid Range:** 0000 to 9999
|
||||||
|
|
||||||
|
## Device Settings Query
|
||||||
|
|
||||||
|
### Get All Settings
|
||||||
|
```
|
||||||
|
GET /{unit_id}/settings/all
|
||||||
|
```
|
||||||
|
Retrieves all current device settings for verification. This is useful for confirming device configuration before starting measurements.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"settings": {
|
||||||
|
"measurement_state": "Stop",
|
||||||
|
"frequency_weighting": "A",
|
||||||
|
"time_weighting": "F",
|
||||||
|
"measurement_time": "1h",
|
||||||
|
"leq_interval": "1m",
|
||||||
|
"lp_interval": "1s",
|
||||||
|
"index_number": "0042",
|
||||||
|
"battery_level": "80",
|
||||||
|
"clock": "2025/12/24,02:30:15",
|
||||||
|
"sleep_mode": "Off",
|
||||||
|
"ftp_status": "Off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** If any setting query fails, the error message will be included in the response for that setting (e.g., `"frequency_weighting": "Error: Connection timeout"`).
|
||||||
|
|
||||||
|
## Data Retrieval
|
||||||
|
|
||||||
|
### Get Final Results
|
||||||
|
```
|
||||||
|
GET /{unit_id}/results
|
||||||
|
```
|
||||||
|
Retrieves the final calculation results (DLC) from the last completed measurement.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"data": {
|
||||||
|
"leq": "68.4",
|
||||||
|
"lmax": "82.1",
|
||||||
|
"lmin": "42.3",
|
||||||
|
"lpeak": "89.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Power Management
|
||||||
|
|
||||||
|
### Sleep Device
|
||||||
|
```
|
||||||
|
POST /{unit_id}/sleep
|
||||||
|
```
|
||||||
|
Enables Sleep Mode on the device. When enabled, the device will automatically enter sleep mode between Timer Auto measurements.
|
||||||
|
|
||||||
|
**Note:** This is a SETTING, not a command to sleep immediately. Sleep Mode only applies when using Timer Auto measurements.
|
||||||
|
|
||||||
|
**Warning:** Sleep/eco mode turns off TCP communications, which will prevent remote monitoring and control. For this reason, SLMM automatically disables sleep mode when:
|
||||||
|
- Device configuration is created or updated with TCP enabled
|
||||||
|
- Measurements are started
|
||||||
|
|
||||||
|
If you need to enable sleep mode for battery conservation, be aware that TCP connectivity will be lost until the device is physically accessed or wakes for a scheduled measurement.
|
||||||
|
|
||||||
|
### Wake Device
|
||||||
|
```
|
||||||
|
POST /{unit_id}/wake
|
||||||
|
```
|
||||||
|
Disables Sleep Mode on the device.
|
||||||
|
|
||||||
|
### Get Sleep Status
|
||||||
|
```
|
||||||
|
GET /{unit_id}/sleep/status
|
||||||
|
```
|
||||||
|
Gets the current Sleep Mode status.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"sleep_mode": "Off"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## FTP File Management
|
||||||
|
|
||||||
|
### Enable FTP
|
||||||
|
```
|
||||||
|
POST /{unit_id}/ftp/enable
|
||||||
|
```
|
||||||
|
Enables FTP server on the device.
|
||||||
|
|
||||||
|
**Note:** ~~FTP and TCP are mutually exclusive. Enabling FTP will temporarily disable TCP control.~~ As of v0.2.0, FTP and TCP are working fine in tandem. Just dont spam them a bunch.
|
||||||
|
|
||||||
|
### Disable FTP
|
||||||
|
```
|
||||||
|
POST /{unit_id}/ftp/disable
|
||||||
|
```
|
||||||
|
Disables FTP server on the device.
|
||||||
|
|
||||||
|
### Get FTP Status
|
||||||
|
```
|
||||||
|
GET /{unit_id}/ftp/status
|
||||||
|
```
|
||||||
|
Checks if FTP is enabled on the device.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"ftp_status": "On",
|
||||||
|
"ftp_enabled": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### List Files
|
||||||
|
```
|
||||||
|
GET /{unit_id}/ftp/files?path=/
|
||||||
|
```
|
||||||
|
Lists files and directories at the specified path.
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
- `path` (optional): Directory path to list (default: /)
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"path": "/NL43_DATA/",
|
||||||
|
"count": 3,
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"name": "measurement_001.wav",
|
||||||
|
"path": "/NL43_DATA/measurement_001.wav",
|
||||||
|
"size": 102400,
|
||||||
|
"modified": "Dec 24 2025",
|
||||||
|
"is_dir": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "folder1",
|
||||||
|
"path": "/NL43_DATA/folder1",
|
||||||
|
"size": 0,
|
||||||
|
"modified": "Dec 23 2025",
|
||||||
|
"is_dir": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Download File
|
||||||
|
```
|
||||||
|
POST /{unit_id}/ftp/download
|
||||||
|
```
|
||||||
|
Downloads a file from the device via FTP.
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"remote_path": "/NL43_DATA/measurement_001.wav"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
Returns the file as a binary download with appropriate `Content-Disposition` header.
|
||||||
|
|
||||||
|
## Error Responses
|
||||||
|
|
||||||
|
All endpoints return standard HTTP status codes:
|
||||||
|
|
||||||
|
- `200` - Success
|
||||||
|
- `404` - Device config not found
|
||||||
|
- `403` - TCP communication is disabled
|
||||||
|
- `502` - Failed to communicate with device
|
||||||
|
- `504` - Device communication timeout
|
||||||
|
- `500` - Internal server error
|
||||||
|
|
||||||
|
**Error Response Format:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"detail": "Error message"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
### Terra-view Integration Example
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const devices = ['nl43-1', 'nl43-2', 'nl43-3'];
|
||||||
|
|
||||||
|
// Configure all devices before measurement
|
||||||
|
for (const device of devices) {
|
||||||
|
// Set measurement time to 12 hours
|
||||||
|
await fetch(`http://localhost:8000/api/nl43/${device}/measurement-time`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ preset: '12h' })
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set Leq interval to 1 minute
|
||||||
|
await fetch(`http://localhost:8000/api/nl43/${device}/leq-interval`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ preset: '1m' })
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set index number for daily file organization
|
||||||
|
const dayNumber = new Date().getDate();
|
||||||
|
await fetch(`http://localhost:8000/api/nl43/${device}/index-number`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ index: dayNumber })
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify all settings are correct
|
||||||
|
const settings = await fetch(`http://localhost:8000/api/nl43/${device}/settings/all`)
|
||||||
|
.then(r => r.json());
|
||||||
|
console.log(`Device ${device} settings:`, settings.settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start measurement on all devices at 7pm
|
||||||
|
await Promise.all(
|
||||||
|
devices.map(id =>
|
||||||
|
fetch(`http://localhost:8000/api/nl43/${id}/start`, { method: 'POST' })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get live status from all devices
|
||||||
|
const statuses = await Promise.all(
|
||||||
|
devices.map(id =>
|
||||||
|
fetch(`http://localhost:8000/api/nl43/${id}/live`)
|
||||||
|
.then(r => r.json())
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Download files from all devices the next morning
|
||||||
|
for (const device of devices) {
|
||||||
|
// Enable FTP
|
||||||
|
await fetch(`http://localhost:8000/api/nl43/${device}/ftp/enable`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
|
||||||
|
// List files in device data directory
|
||||||
|
const res = await fetch(`http://localhost:8000/api/nl43/${device}/ftp/files?path=/NL43_DATA`);
|
||||||
|
const { files } = await res.json();
|
||||||
|
|
||||||
|
// Download latest measurement file
|
||||||
|
const latestFile = files
|
||||||
|
.filter(f => !f.is_dir)
|
||||||
|
.sort((a, b) => b.modified - a.modified)[0];
|
||||||
|
|
||||||
|
if (latestFile) {
|
||||||
|
const download = await fetch(`http://localhost:8000/api/nl43/${device}/ftp/download`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ remote_path: latestFile.path })
|
||||||
|
});
|
||||||
|
|
||||||
|
const blob = await download.blob();
|
||||||
|
// Save to local storage or process...
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable FTP to re-enable TCP
|
||||||
|
await fetch(`http://localhost:8000/api/nl43/${device}/ftp/disable`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rate Limiting
|
||||||
|
|
||||||
|
The NL43 protocol requires ≥1 second between commands to the same device. The API automatically enforces this rate limit.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- TCP and FTP protocols are mutually exclusive on the device
|
||||||
|
- FTP uses active mode (requires device to connect back to server)
|
||||||
|
- WebSocket streaming keeps a persistent connection - limit concurrent streams
|
||||||
|
- All measurements are stored in the database for quick access via `/status` endpoint
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
# NL-43 / NL-53 Communication Guide (Concise + Full Reference)
|
||||||
|
|
||||||
|
Single-file quick reference for the NL-43/NL-53 Communication Guide (No. 66132, pages ~1–97). Use the official PDF for authoritative details, exact formatting, and option requirements.
|
||||||
|
|
||||||
|
## Protocol Basics
|
||||||
|
- ASCII commands terminated with `CR LF`.
|
||||||
|
- Setting: `$Command,Param[CR][LF]`; Request: `Command?[CR][LF]`.
|
||||||
|
- Wait for `$` prompt/idle before the next command; recommended ≥1 s between commands.
|
||||||
|
- Result codes: `R+0000` success; `0001` command error; `0002` parameter error; `0003` spec/type error; `0004` status error (wrong state).
|
||||||
|
- Control codes: `CR`=0x0D, `LF`=0x0A, `SUB`=0x1A (stop DRD stream).
|
||||||
|
- Timing: responds within ~3 s; characters ≤100 ms apart; DRD streams until `SUB`.
|
||||||
|
|
||||||
|
## Transport Modes
|
||||||
|
- USB CDC: Serial over USB (mutually exclusive with LAN TCP/FTP/web/I/O port comm).
|
||||||
|
- RS-232C: 9600–115200 bps; DRD streaming needs ≥19200 (EX) or ≥57600 (RT).
|
||||||
|
- LAN (NX-43EX): TCP control, FTP, web app (ports 80 & 8000). TCP/FTP/Web are mutually exclusive with each other and with USB comm while active.
|
||||||
|
|
||||||
|
## Quick Startup Checklist (TCP control)
|
||||||
|
1) Install NX-43EX; on device: Ethernet On; DHCP On or set IP/Subnet/Gateway; `TCP, On`; ensure USB comm + web app + I/O port comm are Off.
|
||||||
|
2) Ensure reachability to device IP:TCP port (default 80).
|
||||||
|
3) `Clock,<timestamp>` to sync time.
|
||||||
|
4) Configure mode/intervals, then `Measure, Start`.
|
||||||
|
5) Poll `DOD?` (≥1 s) or stream `DRD?status`; stop with `SUB`.
|
||||||
|
6) Toggle `FTP, On` only when pulling SD files; then back to `TCP, On`.
|
||||||
|
|
||||||
|
## LAN / USB / Web / FTP (NX-43EX)
|
||||||
|
| Command | Purpose | Type | Notes / Params |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Ethernet | LAN on/off | S/R | `Ethernet, On|Off` (p84) |
|
||||||
|
| Ethernet DHCP | DHCP on/off | S/R | `Ethernet DHCP, On|Off` (p84) |
|
||||||
|
| Ethernet IP | Set IP | S/R | `Ethernet IP, a.b.c.d` (p84) |
|
||||||
|
| Ethernet Subnet | Set subnet | S/R | `Ethernet Subnet, a.b.c.d` (p84) |
|
||||||
|
| Ethernet Gateway | Set gateway | S/R | `Ethernet Gateway, a.b.c.d` (p85) |
|
||||||
|
| TCP | TCP control | S/R | `TCP, On|Off` (p86); stops if USB comm/web/I/O port comm enabled |
|
||||||
|
| FTP | FTP transfer | S/R | `FTP, On|Off` (p85); stops if USB mass storage/web enabled |
|
||||||
|
| Web | Web app | S/R | `Web, On|Off` (p85); uses ports 80/8000; disables Timer Auto, Trigger Mode, Delay Time, I/O port comm, USB, LAN TCP/FTP while active |
|
||||||
|
| USB Class | USB comm/mass storage | S/R | `USB Class, Off|CDC|CDC/MSC` (p83); CDC/MSC blocks LAN TCP/FTP |
|
||||||
|
|
||||||
|
## Data Output Commands (fields)
|
||||||
|
- **DOD?**: Snapshot; main/sub channel Lp, Leq, LE, Lmax, Lmin, LN1–LN5, Lpeak, LIeq, Leq,mov, Ltm5, over/under flags. RT variant may include band/POA data; wait ≥1 s between requests.
|
||||||
|
- **DRD?**: Continuous every 100 ms; counter + main/sub Lp/Leq/Lmax/Lmin/Lpeak/LIeq + over/under. Stop with `SUB` (0x1A). Baud constraints on RS-232C only.
|
||||||
|
- **DRD?status**: DRD payload + timestamp, power source (I/E/U), battery level (F/M/L/D/E), SD remaining MB, measurement state (M/S).
|
||||||
|
- **DLC?**: Final calculation set (similar to DOD); RT variant includes band data + over/under flags.
|
||||||
|
|
||||||
|
## Condensed Command List (Section 5.6)
|
||||||
|
| Command | Function | Type | Notes / Page |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Communication Echo | Echo on/off | S/R | p39 |
|
||||||
|
| System Version | Version info | R | p39 |
|
||||||
|
| Type | Model type | R | p39 |
|
||||||
|
| Serial Number | Serial number | R | p39 |
|
||||||
|
| Clock | Current time | S/R | p40 |
|
||||||
|
| Language | Display language | S/R | p40 |
|
||||||
|
| Index Number | Index | S/R | p40 |
|
||||||
|
| Key Lock | Key lock | S/R | p41 |
|
||||||
|
| Backlight | Backlight status | S/R | p41 |
|
||||||
|
| Backlight Auto Off | Auto off | S/R | p41 |
|
||||||
|
| LCD | LCD status | S/R | p41 |
|
||||||
|
| LCD Auto Off | Auto off | S/R | p42 |
|
||||||
|
| Backlight Brightness | Brightness | S/R | p42 |
|
||||||
|
| Battery Type | Battery type | S/R | p42 |
|
||||||
|
| Battery Level | Battery level | S/R | p43 |
|
||||||
|
| SD Card Total Size | SD total | R | p43 |
|
||||||
|
| SD Card Free Size | SD free | R | p43 |
|
||||||
|
| SD Card Percentage | SD free % | R | p43 |
|
||||||
|
| Output Level Range Upper | Bar upper | S/R | p44 |
|
||||||
|
| Output Level Range Lower | Bar lower | S/R | p44 |
|
||||||
|
| Display Leq / LE / Lpeak / Lmax / Lmin | Display flags | S/R | p44–45 |
|
||||||
|
| Display LN1–LN5 | Percentiles display | S/R | p45–46 |
|
||||||
|
| Display LIeq / Ltm5 / Leqmov | Display flags | S/R | p47 |
|
||||||
|
| Time Level Time Scale | Time-level scale | S/R | p47 |
|
||||||
|
| Display Calculate Type (RT) | Calc type | S/R | p48 |
|
||||||
|
| Measure Display Sub Channel 1–3 | Sub displays | S/R | p49 |
|
||||||
|
| Octave Mode (RT) | Analysis mode | S/R | p49 |
|
||||||
|
| Additional Band (RT) | Sub band | S/R | p49 |
|
||||||
|
| Display Partial Over All (RT) | POA on/off | S/R | p50 |
|
||||||
|
| Upper/Lower Limit Frequency (+Offset) (RT) | POA bands | S/R | p50–52 |
|
||||||
|
| Lmax Type / Channel | Lmax/Lmin settings | S/R | p52 |
|
||||||
|
| Frequency Weighting (Main/Sub1–3) | Weighting | S/R | p53 |
|
||||||
|
| Frequency Weighting (Band) (RT) | Band weighting | S/R | p53 |
|
||||||
|
| Time Weighting (Main/Sub1–3) | Time weighting | S/R | p54 |
|
||||||
|
| Time Weighting (Band/Band2) (RT) | Band time weighting | S/R | p55 |
|
||||||
|
| Windscreen / Diffuse Correction | Corrections | S/R | p55–56 |
|
||||||
|
| Ldiff1/Ldiff2 (+Channel/Calc) (RT) | Differential | S/R | p56–57 |
|
||||||
|
| Store Mode / Name / Manual Address | Storage setup | S/R | p58 |
|
||||||
|
| Measure | Start/Stop measure | S/R | p58 |
|
||||||
|
| Pause | Pause | S/R | p59 |
|
||||||
|
| Manual Store | Manual store | S | p59 |
|
||||||
|
| Overwrite | Overwrite check | S/R | p59 |
|
||||||
|
| Measurement Time Preset Manual / Manual (Num/Unit) | Manual timing | S/R | p60 |
|
||||||
|
| Measurement Time Preset Auto / Auto (Num/Unit) (EX) | Auto timing | S/R | p61 |
|
||||||
|
| Lp Store Interval | Lp interval | S/R | p62 |
|
||||||
|
| Leq Calculation Interval Preset / Num / Unit | Leq interval | S/R | p62–63 |
|
||||||
|
| Store Delay Time | Delay | S/R | p63 |
|
||||||
|
| Back Erase | Back erase | S/R | p63 |
|
||||||
|
| Timer Auto Start/Stop Time | Timer | S/R | p64 |
|
||||||
|
| Timer Auto Interval (EX) | Timer interval | S/R | p65 |
|
||||||
|
| Sleep Mode | Sleep | S/R | p65 |
|
||||||
|
| Trigger Mode | Trigger | S/R | p65 |
|
||||||
|
| Level Trigger Channel (EX) | Trigger channel | S/R | p66 |
|
||||||
|
| Level Trigger Band Freq/Offset (RT) | Band trigger | S/R | p66–67 |
|
||||||
|
| Level Trigger Level (EX) | Trigger level | S/R | p67 |
|
||||||
|
| Moving Leq Interval Preset / Num / Unit | Moving Leq | S/R | p67–68 |
|
||||||
|
| TRM | LN mode sampling | S/R | p68 |
|
||||||
|
| Percentile 1–5 | LN percentiles | S/R | p69 |
|
||||||
|
| Lp Mode (RT) | Lp type | S/R | p69 |
|
||||||
|
| Wave Rec Mode / Sampling Freq / Bit Length (WR) | Waveform | S/R | p70 |
|
||||||
|
| Frequency Weighting (Wave) | Wave weighting | S/R | p71 |
|
||||||
|
| Wave Rec Range Upper / State | Wave status | S/R | p71 |
|
||||||
|
| Wave Splitting Interval | Split interval | S/R | p72 |
|
||||||
|
| Wave Manual Rec / Pre-time | Manual rec | S/R | p72 |
|
||||||
|
| Wave Level Rec / Trigger Channel (WR/RT) | Level rec | S/R | p73 |
|
||||||
|
| Wave Level Trigger Band Freq/Offset (RT) | Band trigger | S/R | p73–74 |
|
||||||
|
| Wave Level Trigger Level / Pre-time / Max Time | Thresholds | S/R | p74–75 |
|
||||||
|
| Wave Level Reference Time/Level (1–4) | Time-zone thresholds | S/R | p75–76 |
|
||||||
|
| Wave Interval Rec (Interval/Time) | Interval rec | S/R | p76–77 |
|
||||||
|
| I/O AC OUT | AC output | S/R | p78 |
|
||||||
|
| AC Out Band Freq/Offset (RT) | AC band | S/R | p78–79 |
|
||||||
|
| I/O DC OUT | DC output | S/R | p79 |
|
||||||
|
| DC Out Band Freq/Offset (RT) | DC band | S/R | p80 |
|
||||||
|
| Output Range Upper | Electrical full scale | S/R | p81 |
|
||||||
|
| Reference Signal Output | Reference signal | S/R | p81 |
|
||||||
|
| IO Func | IO port | S/R | p81 |
|
||||||
|
| Baud Rate | RS-232C baud | S/R | p82 |
|
||||||
|
| Comparator Channel | Comparator channel | S/R | p82 |
|
||||||
|
| Comparator Band Freq/Offset (RT) | Comparator band | S/R | p82–83 |
|
||||||
|
| Comparator Level (EX) | Comparator level | S/R | p83 |
|
||||||
|
| USB Class | USB comm | S/R | p83 |
|
||||||
|
| Ethernet / DHCP / IP / Subnet / Gateway (EX) | LAN config | S/R | p84–85 |
|
||||||
|
| Web (EX) | Web app | S/R | p85 |
|
||||||
|
| FTP (EX) | FTP | S/R | p85 |
|
||||||
|
| TCP (EX) | TCP control | S/R | p86 |
|
||||||
|
| DOD / DOD (RT) | Output displayed value | R | p88–89 |
|
||||||
|
| DRD / DRD (RT) | Continuous output | R | p90–91 |
|
||||||
|
| DRD?status / DRD?status (RT) | Continuous + status | R | p92–93 |
|
||||||
|
| DLC / DLC (RT) | Final calculation output | R | p94–95 |
|
||||||
|
|
||||||
|
## Full Command Catalog (pages ~30–95)
|
||||||
|
All commands from section 5.6 listed with purpose. Types: S=Setting, R=Request. Options: (EX)=NX-43EX, (RT)=NX-43RT, (WR)=NX-43WR.
|
||||||
|
|
||||||
|
Communication / System
|
||||||
|
- Communication Echo (S/R): Echo sent command strings (p39)
|
||||||
|
- System Version (R): Version info (p39)
|
||||||
|
- Type (R): Type info (p39)
|
||||||
|
- Serial Number (R): Serial number (p39)
|
||||||
|
- Clock (S/R): Current time (p40)
|
||||||
|
- Language (S/R): Display language (p40)
|
||||||
|
- Index Number (S/R): Index (p40)
|
||||||
|
|
||||||
|
UI / Power / Storage Stats
|
||||||
|
- Key Lock (S/R): Key lock (p41)
|
||||||
|
- Backlight (S/R): Backlight status (p41)
|
||||||
|
- Backlight Auto Off (S/R): Auto off (p41)
|
||||||
|
- LCD (S/R): LCD status (p41)
|
||||||
|
- LCD Auto Off (S/R): Auto off (p42)
|
||||||
|
- Backlight Brightness (S/R): Brightness (p42)
|
||||||
|
- Battery Type (S/R): Battery type (p42)
|
||||||
|
- Battery Level (S/R): Battery level (p43)
|
||||||
|
- SD Card Total Size (R): SD total (p43)
|
||||||
|
- SD Card Free Size (R): SD free (p43)
|
||||||
|
- SD Card Percentage (R): SD free % (p43)
|
||||||
|
|
||||||
|
Display/Measure Flags
|
||||||
|
- Output Level Range Upper/Lower (S/R): Bar graph ranges (p44)
|
||||||
|
- Display Leq/LE/Lpeak/Lmax/Lmin (S/R): Flags (p44–45)
|
||||||
|
- Display LN1–LN5 (S/R): Percentiles (p45–46)
|
||||||
|
- Display LIeq/Ltm5/Leqmov (S/R): Flags (p47)
|
||||||
|
- Time Level Time Scale (S/R): Time-level scale (p47)
|
||||||
|
- Display Calculate Type (RT, S/R): Calc type (p48)
|
||||||
|
- Measure Display Sub Channel 1–3 (S/R): Sub displays (p49)
|
||||||
|
|
||||||
|
Analysis / Bands (RT)
|
||||||
|
- Octave Mode (S/R): Analysis mode (p49)
|
||||||
|
- Additional Band (S/R): Sub band (p49)
|
||||||
|
- Display Partial Over All (S/R): POA (p50)
|
||||||
|
- Upper/Lower Limit Frequency (+Offset) (S/R): POA bands (p50–52)
|
||||||
|
- Lmax Type / Channel (S/R): Lmax/Lmin settings (p52)
|
||||||
|
|
||||||
|
Weighting / Corrections
|
||||||
|
- Frequency Weighting (Main/Sub1–3) (EX, S/R): Weighting (p53)
|
||||||
|
- Frequency Weighting (Band) (RT, S/R): Band weighting (p53)
|
||||||
|
- Time Weighting (Main/Sub1–3) (S/R): Time weighting (p54)
|
||||||
|
- Time Weighting (Band/Band2) (RT, S/R): Band time weighting (p55)
|
||||||
|
- Windscreen Correction (S/R): Windscreen correction (p55)
|
||||||
|
- Diffuse Sound Field Correction (S/R): Diffuse correction (p56)
|
||||||
|
|
||||||
|
Differential (RT)
|
||||||
|
- Ldiff1/Ldiff2 (S/R): Measure differential (p56)
|
||||||
|
- Ldiff1/2 Channel1/2 (S/R): Differential channel (p56)
|
||||||
|
- Ldiff1/2 Calculation1/2 (S/R): Differential calc (p57)
|
||||||
|
|
||||||
|
Store / Measurement Control
|
||||||
|
- Store Mode (S/R): Manual/Auto (p58)
|
||||||
|
- Store Name (S/R): Storage name (p58)
|
||||||
|
- Manual Address (S/R): Manual storage address (p58)
|
||||||
|
- Measure (S/R): Start/Stop measure (p58)
|
||||||
|
- Pause (S/R): Pause (p59)
|
||||||
|
- Manual Store (S): Manual store (p59)
|
||||||
|
- Overwrite (S/R): Overwrite check (p59)
|
||||||
|
- Measurement Time Preset Manual / Manual (Num/Unit) (S/R): Manual timing (p60)
|
||||||
|
- Measurement Time Preset Auto / Auto (Num/Unit) (EX, S/R): Auto timing (p61)
|
||||||
|
- Lp Store Interval (S/R): Lp interval (p62)
|
||||||
|
- Leq Calculation Interval Preset / Num / Unit (S/R): Leq interval (p62–63)
|
||||||
|
- Store Delay Time (S/R): Delay (p63)
|
||||||
|
- Back Erase (S/R): Back erase (p63)
|
||||||
|
- Timer Auto Start/Stop Time (S/R): Timer (p64)
|
||||||
|
- Timer Auto Interval (EX, S/R): Auto interval (p65)
|
||||||
|
- Sleep Mode (S/R): Sleep (p65)
|
||||||
|
- Trigger Mode (S/R): Trigger (p65)
|
||||||
|
- Level Trigger Channel (EX, S/R): Trigger channel (p66)
|
||||||
|
- Level Trigger Band Frequency/Offset (RT, S/R): Band trigger (p66–67)
|
||||||
|
- Level Trigger Level (EX, S/R): Trigger level (p67)
|
||||||
|
- Moving Leq Interval Preset / Num / Unit (S/R): Moving Leq (p67–68)
|
||||||
|
- TRM (S/R): LN mode sampling (p68)
|
||||||
|
- Percentile 1–5 (S/R): LN percentiles (p69)
|
||||||
|
- Lp Mode (RT, S/R): Lp type (p69)
|
||||||
|
|
||||||
|
Waveform Recording (WR/RT)
|
||||||
|
- Wave Rec Mode / Sampling Frequency / Bit Length (S/R): Recording config (p70)
|
||||||
|
- Frequency Weighting (Wave) (S/R): Wave weighting (p71)
|
||||||
|
- Wave Rec Range Upper / State (S/R): Rec range/status (p71)
|
||||||
|
- Wave Splitting Interval (S/R): File split interval (p72)
|
||||||
|
- Wave Manual Rec / Pre-time (S/R): Manual rec (p72)
|
||||||
|
- Wave Level Rec / Trigger Channel (WR/RT, S/R): Level rec (p73)
|
||||||
|
- Wave Level Trigger Band Frequency/Offset (RT, S/R): Band trigger (p73–74)
|
||||||
|
- Wave Level Trigger Level / Pre-time / Maximum Recording Time (S/R): Thresholds/time (p74–75)
|
||||||
|
- Wave Level Reference Time Interval 1–4 (S/R): Time-zone intervals (p75)
|
||||||
|
- Wave Level Reference Time 1–4 (S/R): Time-zone time (p75)
|
||||||
|
- Wave Level Reference Time 1–4 Level (S/R): Time-zone level (p75–76)
|
||||||
|
- Wave Interval Rec Interval / Time (S/R): Interval rec (p76–77)
|
||||||
|
|
||||||
|
I/O and Outputs
|
||||||
|
- I/O AC OUT (S/R): AC output (p78)
|
||||||
|
- AC Out Band Frequency/Offset (RT, S/R): AC band (p78–79)
|
||||||
|
- I/O DC OUT (S/R): DC output (p79)
|
||||||
|
- DC Out Band Frequency/Offset (RT, S/R): DC band (p80)
|
||||||
|
- Output Range Upper (S/R): Electrical full scale (p81)
|
||||||
|
- Reference Signal Output (S/R): Reference signal (p81)
|
||||||
|
- IO Func (S/R): IO port (p81)
|
||||||
|
|
||||||
|
Comparator / Comms
|
||||||
|
- Baud Rate (S/R): RS-232C baud (p82)
|
||||||
|
- Comparator Channel (S/R): Comparator channel (p82)
|
||||||
|
- Comparator Band Frequency/Offset (RT, S/R): Comparator band (p82–83)
|
||||||
|
- Comparator Level (EX, S/R): Comparator level (p83)
|
||||||
|
- USB Class (S/R): USB comm/mass storage (p83)
|
||||||
|
- Ethernet/DHCP/IP/Subnet/Gateway (EX, S/R): LAN config (p84–85)
|
||||||
|
- Web (EX, S/R): Web app (p85)
|
||||||
|
- FTP (EX, S/R): FTP (p85)
|
||||||
|
- TCP (EX, S/R): TCP control (p86)
|
||||||
|
|
||||||
|
Data Output (fields)
|
||||||
|
- DOD / DOD (RT) (R): Snapshot of displayed values; includes Lp/Leq/LE/Lmax/Lmin/LN1–LN5/Lpeak/LIeq/Leq,mov/Ltm5/over-under (p88–89).
|
||||||
|
- DRD / DRD (RT) (R): Continuous every 100 ms; counter + Lp/Leq/Lmax/Lmin/Lpeak/LIeq + over/under (p90–91). Stop with `SUB`.
|
||||||
|
- DRD?status / DRD?status (RT) (R): DRD + timestamp, power source, battery level, SD remaining MB, measurement state (p92–93).
|
||||||
|
- DLC / DLC (RT) (R): Final calculation result set (similar to DOD; RT includes band data) (p94–95).
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
# SLMM Project Improvements
|
||||||
|
|
||||||
|
This document details all the improvements made to the SLMM (NL43 Sound Level Meter Module) project.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The original code generated by Codex was functional and well-structured, but lacked production-ready features. These improvements address security, reliability, error handling, and operational concerns.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Critical Fixes
|
||||||
|
|
||||||
|
### 1. Database Session Management ([services.py](app/services.py))
|
||||||
|
|
||||||
|
**Issue**: `persist_snapshot()` created its own database session outside FastAPI's lifecycle management.
|
||||||
|
|
||||||
|
**Fix**:
|
||||||
|
- Changed function signature to accept `db: Session` parameter
|
||||||
|
- Now uses FastAPI's dependency injection for proper session management
|
||||||
|
- Added explicit rollback on error
|
||||||
|
- Added error logging
|
||||||
|
|
||||||
|
**Impact**: Prevents connection leaks and ensures proper transaction handling.
|
||||||
|
|
||||||
|
### 2. Response Validation & Error Handling ([services.py](app/services.py))
|
||||||
|
|
||||||
|
**Issue**: DOD response parsing had no validation and silently failed on malformed data. Additionally, the code only read the first line (result code) and didn't read the second line containing actual data. Start/Stop commands had incorrect syntax with spaces after commas.
|
||||||
|
|
||||||
|
**Fix**:
|
||||||
|
- Implemented proper two-line protocol handling:
|
||||||
|
- Line 1: Result code (R+0000 for success, or error codes 0001-0004)
|
||||||
|
- Line 2: Actual data (for query commands ending with `?`)
|
||||||
|
- Parse and validate result codes with specific error messages:
|
||||||
|
- R+0001: Command error
|
||||||
|
- R+0002: Parameter error
|
||||||
|
- R+0003: Spec/type error
|
||||||
|
- R+0004: Status error
|
||||||
|
- Fixed command syntax to match NL43 protocol:
|
||||||
|
- Setting commands: `$Command,Param` (NO space after comma)
|
||||||
|
- Changed `$Measure, Start` to `$Measure,Start`
|
||||||
|
- Changed `$Measure, Stop` to `$Measure,Stop`
|
||||||
|
- Validate response is not empty
|
||||||
|
- Check minimum field count (at least 2 data points)
|
||||||
|
- Remove leading `$` prompt if present
|
||||||
|
- Proper exception handling with logging
|
||||||
|
- Raise `ValueError` for invalid responses
|
||||||
|
|
||||||
|
**Impact**: Now correctly receives and parses actual measurement data instead of just the success code. Start/Stop commands now work correctly. Better debugging and prevents silent failures.
|
||||||
|
|
||||||
|
### 3. TCP Enabled Check ([routers.py](app/routers.py))
|
||||||
|
|
||||||
|
**Issue**: Endpoints didn't check if TCP was enabled before attempting communication.
|
||||||
|
|
||||||
|
**Fix**: Added check for `cfg.tcp_enabled` in all TCP operation endpoints:
|
||||||
|
- `/start`
|
||||||
|
- `/stop`
|
||||||
|
- `/live`
|
||||||
|
|
||||||
|
Returns HTTP 403 if TCP is disabled.
|
||||||
|
|
||||||
|
**Impact**: Respects configuration and prevents unnecessary connection attempts.
|
||||||
|
|
||||||
|
### 4. Rate Limiting ([services.py](app/services.py))
|
||||||
|
|
||||||
|
**Issue**: No enforcement of NL43's ≥1 second between commands requirement.
|
||||||
|
|
||||||
|
**Fix**:
|
||||||
|
- Implemented per-device rate limiting using async locks
|
||||||
|
- Tracks last command time per `host:port` key
|
||||||
|
- Automatically waits if commands are too frequent
|
||||||
|
- Logging of rate limit delays
|
||||||
|
|
||||||
|
**Impact**: Prevents overwhelming the device and ensures protocol compliance.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Improvements
|
||||||
|
|
||||||
|
### 5. CORS Configuration ([main.py](app/main.py))
|
||||||
|
|
||||||
|
**Issue**: CORS allowed all origins (`allow_origins=["*"]`).
|
||||||
|
|
||||||
|
**Fix**:
|
||||||
|
- Added `CORS_ORIGINS` environment variable
|
||||||
|
- Comma-separated list of allowed origins
|
||||||
|
- Defaults to `*` for development
|
||||||
|
- Logged on startup
|
||||||
|
|
||||||
|
**Usage**:
|
||||||
|
```bash
|
||||||
|
# Restrict to specific origins
|
||||||
|
export CORS_ORIGINS="http://localhost:3000,https://app.example.com"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Impact**: Prevents unauthorized cross-origin requests when deployed.
|
||||||
|
|
||||||
|
### 6. Error Message Sanitization ([routers.py](app/routers.py))
|
||||||
|
|
||||||
|
**Issue**: Exception details leaked to API responses (e.g., `f"Start failed: {e}"`).
|
||||||
|
|
||||||
|
**Fix**:
|
||||||
|
- Catch specific exception types (`ConnectionError`, `TimeoutError`, `ValueError`)
|
||||||
|
- Log full error details server-side
|
||||||
|
- Return generic messages to clients
|
||||||
|
- Use appropriate HTTP status codes (502, 504, 500)
|
||||||
|
|
||||||
|
**Impact**: Prevents information disclosure while maintaining debuggability.
|
||||||
|
|
||||||
|
### 7. Input Validation ([routers.py](app/routers.py))
|
||||||
|
|
||||||
|
**Issue**: No validation of host/port values.
|
||||||
|
|
||||||
|
**Fix**: Added Pydantic validators:
|
||||||
|
- `host`: Validates IP address or hostname format
|
||||||
|
- `tcp_port`: Ensures 1-65535 range
|
||||||
|
|
||||||
|
**Impact**: Prevents invalid configurations and potential injection attacks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reliability Improvements
|
||||||
|
|
||||||
|
### 8. Connection Error Handling ([services.py](app/services.py))
|
||||||
|
|
||||||
|
**Issue**: Generic exception handling with poor logging.
|
||||||
|
|
||||||
|
**Fix**:
|
||||||
|
- Separate try/except blocks for connection vs. communication
|
||||||
|
- Specific error messages for timeouts vs. connection failures
|
||||||
|
- Comprehensive logging at all stages
|
||||||
|
- Proper cleanup in finally block
|
||||||
|
|
||||||
|
**Impact**: Better diagnostics and more robust error recovery.
|
||||||
|
|
||||||
|
### 9. Logging Framework ([main.py](app/main.py))
|
||||||
|
|
||||||
|
**Issue**: No logging configured.
|
||||||
|
|
||||||
|
**Fix**:
|
||||||
|
- Configured Python's `logging` module
|
||||||
|
- Console output (stdout)
|
||||||
|
- File output (`data/slmm.log`)
|
||||||
|
- Structured format with timestamps
|
||||||
|
- INFO level by default
|
||||||
|
|
||||||
|
**Impact**: Full visibility into system operation and errors.
|
||||||
|
|
||||||
|
### 10. Enhanced Health Check ([main.py](app/main.py))
|
||||||
|
|
||||||
|
**Issue**: `/health` only checked API, not device connectivity.
|
||||||
|
|
||||||
|
**Fix**: Added `/health/devices` endpoint:
|
||||||
|
- Tests TCP connectivity to all enabled devices
|
||||||
|
- 2-second timeout per device
|
||||||
|
- Returns reachable/unreachable status
|
||||||
|
- Overall status: "ok" or "degraded"
|
||||||
|
|
||||||
|
**Response Example**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"devices": [
|
||||||
|
{
|
||||||
|
"unit_id": "nl43-1",
|
||||||
|
"host": "192.168.1.100",
|
||||||
|
"port": 80,
|
||||||
|
"reachable": true,
|
||||||
|
"error": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total_devices": 1,
|
||||||
|
"reachable_devices": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Impact**: Monitoring systems can detect device connectivity issues.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code Quality Improvements
|
||||||
|
|
||||||
|
### 11. Pydantic V2 Compatibility ([routers.py](app/routers.py))
|
||||||
|
|
||||||
|
**Issue**: Used deprecated `.dict()` method.
|
||||||
|
|
||||||
|
**Fix**: Changed to `.model_dump()` (Pydantic V2).
|
||||||
|
|
||||||
|
**Impact**: Future-proof and avoids deprecation warnings.
|
||||||
|
|
||||||
|
### 12. SQLAlchemy Best Practices ([models.py](app/models.py))
|
||||||
|
|
||||||
|
**Issue**: Used `datetime.utcnow` (deprecated).
|
||||||
|
|
||||||
|
**Fix**: Changed to `func.now()` for `last_seen` default.
|
||||||
|
|
||||||
|
**Impact**: Database-level timestamp generation, more reliable.
|
||||||
|
|
||||||
|
### 13. Standardized API Responses ([routers.py](app/routers.py))
|
||||||
|
|
||||||
|
**Issue**: Inconsistent response formats.
|
||||||
|
|
||||||
|
**Fix**: All endpoints now return:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"data": { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Or for simple operations:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"message": "Operation completed"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Impact**: Consistent client-side parsing.
|
||||||
|
|
||||||
|
### 14. Comprehensive Error Logging ([services.py](app/services.py), [routers.py](app/routers.py))
|
||||||
|
|
||||||
|
**Issue**: No logging of operations or errors.
|
||||||
|
|
||||||
|
**Fix**: Added logging at:
|
||||||
|
- Command send/receive (DEBUG)
|
||||||
|
- Rate limiting (DEBUG)
|
||||||
|
- Successful operations (INFO)
|
||||||
|
- Errors (ERROR)
|
||||||
|
- Configuration changes (INFO)
|
||||||
|
|
||||||
|
**Impact**: Full audit trail and debugging capability.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary Statistics
|
||||||
|
|
||||||
|
| Category | Count |
|
||||||
|
|----------|-------|
|
||||||
|
| Critical Fixes | 4 |
|
||||||
|
| Security Improvements | 3 |
|
||||||
|
| Reliability Improvements | 3 |
|
||||||
|
| Code Quality Improvements | 4 |
|
||||||
|
| **Total Improvements** | **14** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
New environment variables for configuration:
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `CORS_ORIGINS` | `*` | Comma-separated list of allowed CORS origins |
|
||||||
|
| `PORT` | `8100` | HTTP server port (existing) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Changes Summary
|
||||||
|
|
||||||
|
| File | Changes |
|
||||||
|
|------|---------|
|
||||||
|
| [app/services.py](app/services.py) | Rate limiting, improved error handling, logging, session management fix |
|
||||||
|
| [app/routers.py](app/routers.py) | Input validation, tcp_enabled checks, error sanitization, standardized responses |
|
||||||
|
| [app/models.py](app/models.py) | Fixed deprecated datetime pattern |
|
||||||
|
| [app/main.py](app/main.py) | Logging configuration, CORS env var, enhanced health check |
|
||||||
|
| [templates/index.html](templates/index.html) | Updated to handle new response format |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Recommendations
|
||||||
|
|
||||||
|
1. **Rate Limiting**: Send rapid commands to same device, verify 1-second spacing
|
||||||
|
2. **Error Handling**: Disconnect device, verify graceful error messages
|
||||||
|
3. **Input Validation**: Try invalid IPs/ports, verify rejection
|
||||||
|
4. **Health Check**: Access `/health/devices`, verify connectivity status
|
||||||
|
5. **Logging**: Check `data/slmm.log` for operation audit trail
|
||||||
|
6. **CORS**: Test from different origins with `CORS_ORIGINS` set
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Upgrade Notes
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
|
||||||
|
1. **`persist_snapshot()` signature changed**:
|
||||||
|
- Old: `persist_snapshot(snap)`
|
||||||
|
- New: `persist_snapshot(snap, db)`
|
||||||
|
|
||||||
|
Existing calls need to pass database session.
|
||||||
|
|
||||||
|
2. **API response format standardized**:
|
||||||
|
- All responses now wrapped in `{"status": "ok", "data": {...}}`
|
||||||
|
- Frontend code may need updates (already fixed in `index.html`)
|
||||||
|
|
||||||
|
### Database Migration
|
||||||
|
|
||||||
|
If you have existing data, SQLAlchemy will handle the schema automatically since only defaults changed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Future Enhancements (Not Implemented)
|
||||||
|
|
||||||
|
These were identified but not implemented as they're architectural changes:
|
||||||
|
|
||||||
|
1. **Connection Pooling**: Reuse TCP connections instead of per-request
|
||||||
|
2. **DRD Streaming**: Continuous 100ms data output mode
|
||||||
|
3. **Authentication**: API access control
|
||||||
|
4. **Battery/SD Status Queries**: Additional device commands
|
||||||
|
5. **Metrics/Prometheus**: Operational metrics export
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
The original Codex-generated code was well-structured and functional. These improvements make it production-ready by adding:
|
||||||
|
- Robust error handling
|
||||||
|
- Security hardening
|
||||||
|
- Operational visibility
|
||||||
|
- Protocol compliance
|
||||||
|
- Input validation
|
||||||
|
|
||||||
|
**Overall Grade After Improvements: A**
|
||||||
|
|
||||||
|
The code is now suitable for production deployment with proper monitoring and configuration.
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
5.6 List of commands
|
||||||
|
S: Setting command (for making the unit settings)
|
||||||
|
R: Request command (for obtaining information on the unit status and measurement results)
|
||||||
|
Commands Function See page
|
||||||
|
Communication Echo Communication echo (S/R) 39
|
||||||
|
System System Version Version information (R) 39
|
||||||
|
Type Type information (R) 39
|
||||||
|
Serial Number Serial number (R) 39
|
||||||
|
Clock Current time (S/R) 40
|
||||||
|
Language Display language (S/R) 40
|
||||||
|
Index Number Index (S/R) 40
|
||||||
|
Key Lock Key lock (S/R) 41
|
||||||
|
Backlight Backlight status (S/R) 41
|
||||||
|
Backlight Auto Off Backlight auto off (S/R) 41
|
||||||
|
LCD LCD status (S/R) 41
|
||||||
|
LCD Auto Off LCD auto off (S/R) 42
|
||||||
|
Backlight Brightness Backlight brightness (S/R) 42
|
||||||
|
Battery Type Battery type (S/R) 42
|
||||||
|
Battery Level Battery level (S/R) 43
|
||||||
|
SD Card Total Size Total SD card capacity (R) 43
|
||||||
|
SD Card Free Size SD card free space (R) 43
|
||||||
|
SD Card Percentage SD card free space ratio (R) 43
|
||||||
|
Display Output Level Range Upper Bar graph upper range (S/R) 44
|
||||||
|
Output Level Range Lower Bar graph lower range (S/R) 44
|
||||||
|
Display Leq Leq display (S/R) 44
|
||||||
|
Display LE LE display (S/R) 44
|
||||||
|
Display Lpeak Lpeak display (S/R) 45
|
||||||
|
Display Lmax Lmax display (S/R) 45
|
||||||
|
Display Lmin Lmin display (S/R) 45
|
||||||
|
Display LN1 L5 display (S/R) 45
|
||||||
|
Display LN2 L10 display (S/R) 46
|
||||||
|
Display LN3 L50 display (S/R) 46
|
||||||
|
Display LN4 L90 display (S/R) 46
|
||||||
|
Display LN5 L95 display (S/R) 46
|
||||||
|
Display LIeq LIeq display (S/R) 47
|
||||||
|
Display Ltm5 Ltm5 display (S/R) 47
|
||||||
|
Display Leqmov Leq,mov display (S/R) 47
|
||||||
|
Time Level Time Scale Time-Level time scale (S/R) 47
|
||||||
|
Display
|
||||||
|
(NX-43RT) Display Calculate Type Display calculate type (S/R) 48
|
||||||
|
Measure Display Sub Channel 1
|
||||||
|
Display Sub Channel 2
|
||||||
|
Display Sub Channel 3
|
||||||
|
Sub channels (S/R) 49
|
||||||
|
34
|
||||||
|
Commands
|
||||||
|
Commands Function See page
|
||||||
|
Measure
|
||||||
|
(NX-43RT)
|
||||||
|
Octave Mode Analysis mode (S/R) 49
|
||||||
|
Additional Band Sub band (S/R) 49
|
||||||
|
Display Partial Over All Partial overall (POA) (S/R) 50
|
||||||
|
Upper Limit Frequency Upper frequency of POA (S/R) 50
|
||||||
|
Upper Limit Frequency Offset Upper frequency offset of POA (S/R) 51
|
||||||
|
Lower Limit Frequency Lower frequency of POA (S/R) 51
|
||||||
|
Lower Limit Frequency Offset Lower frequency offset of POA (S/R) 52
|
||||||
|
Lmax Type Lmax/Lmin type (S/R) 52
|
||||||
|
Lmax Type Channel Lmax/Lmin channel (S/R) 52
|
||||||
|
Measure
|
||||||
|
(NX-43EX)
|
||||||
|
Frequency Weighting
|
||||||
|
Frequency Weighting (Main) Main frequency weighting (S/R) 53
|
||||||
|
Frequency Weighting (Sub1)
|
||||||
|
Frequency Weighting (Sub2)
|
||||||
|
Frequency Weighting (Sub3)
|
||||||
|
Sub frequency weighting (S/R) 53
|
||||||
|
Measure
|
||||||
|
(NX-43RT) Frequency Weighting (Band) Frequency weighting band (S/R) 53
|
||||||
|
Measure Time Weighting
|
||||||
|
Time Weighting (Main) Main time weighting (S/R) 54
|
||||||
|
Time Weighting (Sub1)
|
||||||
|
Time Weighting (Sub2)
|
||||||
|
Time Weighting (Sub3)
|
||||||
|
Sub time weighting (S/R) 54
|
||||||
|
Measure
|
||||||
|
(NX-43RT)
|
||||||
|
Time Weighting (Band) Time weighting band (S/R) 55
|
||||||
|
Time Weighting (Band2) Time weighting sub band (S/R) 55
|
||||||
|
Measure Windscreen Correction Windscreen correction (S/R) 55
|
||||||
|
Diffuse Sound Field Correction Diffuse sound field correction (S/R) 56
|
||||||
|
Measure
|
||||||
|
(NX-43RT)
|
||||||
|
Ldiff1
|
||||||
|
Ldiff2 Measure differential (S/R) 56
|
||||||
|
Ldiff1 Channel1
|
||||||
|
Ldiff1 Channel2
|
||||||
|
Ldiff2 Channel1
|
||||||
|
Ldiff2 Channel2
|
||||||
|
Measure differential, channel (S/R) 56
|
||||||
|
Ldiff1 Calculation1
|
||||||
|
Ldiff1 Calculation2
|
||||||
|
Ldiff2 Calculation1
|
||||||
|
Ldiff2 Calculation2
|
||||||
|
Measure differential, calculation (S/R) 57
|
||||||
|
Store Store Mode Store mode (S/R) 58
|
||||||
|
Store Name Storage name (S/R) 58
|
||||||
|
Manual Address Manual storage address (S/R) 58
|
||||||
|
Measure Measure (S/R) 58
|
||||||
|
Pause Pause (S/R) 59
|
||||||
|
Manual Store Manual storage (S) 59
|
||||||
|
Overwrite Storage overwriting check (S/R) 59
|
||||||
|
Measurement Time Preset Manual Manual storage measurement time (S/R) 60
|
||||||
|
Measurement Time Manual (Num) Manual storage measurement time (user setting: time)
|
||||||
|
(S/R) 60
|
||||||
|
Measurement Time Manual (Unit) Manual storage measurement time (user setting: unit)
|
||||||
|
(S/R) 60
|
||||||
|
35
|
||||||
|
Commands
|
||||||
|
Commands Function See page
|
||||||
|
Store
|
||||||
|
(NX-43EX)
|
||||||
|
Measurement Time Preset Auto Auto storage total measurement time (S/R) 61
|
||||||
|
Measurement Time Auto (Num) Auto storage total measurement time (user setting:
|
||||||
|
time) (S/R) 61
|
||||||
|
Measurement Time Auto (Unit) Auto storage total measurement time (user setting:
|
||||||
|
unit) (S/R) 61
|
||||||
|
Lp Store Interval Lp store interval (S/R) 62
|
||||||
|
Leq Calculation Interval Preset Leq calculation interval (S/R) 62
|
||||||
|
Leq Calculation Interval (Num) Leq calculation interval (user setting: time) (S/R) 62
|
||||||
|
Leq Calculation Interval (Unit) Leq calculation interval (user setting: unit) (S/R) 63
|
||||||
|
Store Delay Time Delay measurement (S/R) 63
|
||||||
|
Back Erase Back erase (S/R) 63
|
||||||
|
Timer Auto Start Time Timer auto start time (S/R) 64
|
||||||
|
Timer Auto Stop Time Timer auto stop time (S/R) 64
|
||||||
|
Store
|
||||||
|
(NX-43EX)
|
||||||
|
Timer Auto Interval Timer auto measurement interval (S/R) 65
|
||||||
|
Sleep Mode Sleep mode (S/R) 65
|
||||||
|
Trigger Mode Trigger mode (S/R) 65
|
||||||
|
Level Trigger Channel Level trigger channel (trigger mode) (S/R) 66
|
||||||
|
Store
|
||||||
|
(NX-43RT)
|
||||||
|
Level Trigger Band Frequency Level trigger band frequency (trigger mode) (S/R) 66
|
||||||
|
Level Trigger Band Offset Level trigger band frequency offset (trigger mode) (S/R) 67
|
||||||
|
Store
|
||||||
|
(NX-43EX)
|
||||||
|
Level Trigger Level Trigger level (trigger mode) (S/R) 67
|
||||||
|
Moving Leq Interval Preset Moving Leq interval (S/R) 67
|
||||||
|
Moving Leq Interval (Num) Moving Leq interval (user setting: time) (S/R) 68
|
||||||
|
Moving Leq Interval (Unit) Moving Leq interval (user setting: unit) (S/R) 68
|
||||||
|
TRM LN mode, sampling data (S/R) 68
|
||||||
|
Percentile 1
|
||||||
|
Percentile 2
|
||||||
|
Percentile 3
|
||||||
|
Percentile 4
|
||||||
|
Percentile 5
|
||||||
|
LN mode, percentile (S/R) 69
|
||||||
|
Store
|
||||||
|
(NX-43RT) Lp Mode Lp type (S/R) 69
|
||||||
|
Waveform recording
|
||||||
|
(NX-43WR)
|
||||||
|
Wave Rec Mode Recording function (S/R) 70
|
||||||
|
Wave Sampling Frequency Sampling frequency (S/R) 70
|
||||||
|
Wave Bit Length Bit length (S/R) 70
|
||||||
|
Frequency Weighting (Wave) Frequency weighting (waveform recording) (S/R) 71
|
||||||
|
Wave Rec Range Upper Recording level range (S/R) 71
|
||||||
|
Wave Rec State Recording status (S/R) 71
|
||||||
|
Wave Splitting Interval File splitting interval (S/R) 72
|
||||||
|
Wave Manual Rec Manual recording (S/R) 72
|
||||||
|
Wave Manual Pre-time Manual recording pre-time (S/R) 72
|
||||||
|
Wave Level Rec Level recording (S/R) 73
|
||||||
|
Wave Level Trigger Channel Trigger channels (level recording) (S/R) 73
|
||||||
|
Waveform recording
|
||||||
|
(NX-43RT)
|
||||||
|
Wave Level Trigger Band Frequency Trigger band frequency (level recording) (S/R) 73
|
||||||
|
Wave Level Trigger Band Offset Trigger band frequency offset (level recording) (S/R) 74
|
||||||
|
36
|
||||||
|
Commands
|
||||||
|
Commands Function See page
|
||||||
|
Waveform recording
|
||||||
|
(NX-43WR)
|
||||||
|
Wave Level Trigger Level Start level (level recording) (S/R) 74
|
||||||
|
Wave Level Pre-time Pre-time (level recording) (S/R) 74
|
||||||
|
Wave Level Maximum Recording Time Maximum recording time (S/R) 75
|
||||||
|
Wave Level Reference Time Interval 1
|
||||||
|
Wave Level Reference Time Interval 2
|
||||||
|
Wave Level Reference Time Interval 3
|
||||||
|
Wave Level Reference Time Interval 4
|
||||||
|
Threshold per time zone (S/R) 75
|
||||||
|
Wave Level Reference Time 1
|
||||||
|
Wave Level Reference Time 2
|
||||||
|
Wave Level Reference Time 3
|
||||||
|
Wave Level Reference Time 4
|
||||||
|
Threshold per time zone time (S/R) 75
|
||||||
|
Wave Level Reference Time 1 Level
|
||||||
|
Wave Level Reference Time 2 Level
|
||||||
|
Wave Level Reference Time 3 Level
|
||||||
|
Wave Level Reference Time 4 Level
|
||||||
|
Threshold per time zone level (S/R) 76
|
||||||
|
Wave Interval Rec Interval recording (S/R) 76
|
||||||
|
Wave Interval Rec Interval Interval recording_Recording interval (S/R) 76
|
||||||
|
Wave Interval Rec Time Interval recording_Recording time (S/R) 77
|
||||||
|
I/O AC OUT AC output (S/R) 78
|
||||||
|
I/O
|
||||||
|
(NX-43RT)
|
||||||
|
AC Out Band Frequency AC output band frequency (S/R) 78
|
||||||
|
AC Out Band Offset AC output band frequency offset (S/R) 79
|
||||||
|
I/O DC OUT DC output (S/R) 79
|
||||||
|
I/O
|
||||||
|
(NX-43RT)
|
||||||
|
DC Out Band Frequency DC output band frequency (S/R) 80
|
||||||
|
DC Out Band Offset DC output band frequency offset (S/R) 80
|
||||||
|
I/O Output Range Upper Electrical output full scale (S/R) 81
|
||||||
|
Reference Signal Output Reference signal output (S/R) 81
|
||||||
|
IO Func IO port (S/R) 81
|
||||||
|
Baud Rate RS-232C communication speed (S/R) 82
|
||||||
|
Comparator Channel Comparator channel (S/R) 82
|
||||||
|
I/O
|
||||||
|
(NX-43RT)
|
||||||
|
Comparator Band Frequency Comparator band frequency (S/R) 82
|
||||||
|
Comparator Band Offset Comparator band frequency offset (S/R) 83
|
||||||
|
I/O Comparator Level Comparator level (S/R) 83
|
||||||
|
USB Class USB communication (S/R) 83
|
||||||
|
I/O
|
||||||
|
(NX-43EX)
|
||||||
|
Ethernet LAN function (S/R) 84
|
||||||
|
Ethernet DHCP IP address automatic setting (S/R) 84
|
||||||
|
Ethernet IP IP address (S/R) 84
|
||||||
|
Ethernet Subnet Subnet mask (S/R) 84
|
||||||
|
Ethernet Gateway Default gateway (S/R) 85
|
||||||
|
Web Web app (S/R) 85
|
||||||
|
FTP File transfer (FTP) (S/R) 85
|
||||||
|
TCP Communication control (TCP) (S/R) 86
|
||||||
|
Data output DOD Output displayed value (R) 88
|
||||||
|
Data output
|
||||||
|
(NX-43RT) DOD Output displayed value (R) 89
|
||||||
|
Data output
|
||||||
|
(NX-43EX)
|
||||||
|
DRD Continuous output (R) 90
|
||||||
|
37
|
||||||
|
Commands
|
||||||
|
Commands Function See page
|
||||||
|
Data output
|
||||||
|
(NX-43RT)
|
||||||
|
DRD Continuous output (R) 91
|
||||||
|
Data output
|
||||||
|
(NX-43EX)
|
||||||
|
DRD?status Continuous output (attaching status information) (R) 92
|
||||||
|
Data output
|
||||||
|
(NX-43RT)
|
||||||
|
DRD?status Continuous output (attaching status information) (R) 93
|
||||||
|
Data output DLC Final calculation result output (R) 94
|
||||||
|
Data output
|
||||||
|
(NX-43RT) DLC Final calculation result output (R) 95
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
|
||||||
|
# ✅ NL-43 / NL-53 Command Format — Verified Quick Reference
|
||||||
|
|
||||||
|
This cheat sheet lists confirmed working command formats for NL-43 and NL-53 devices, based on the official Rion Communication Guide (pages 30–96).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧠 Protocol Basics
|
||||||
|
|
||||||
|
- **Command format:** `Command,Param[CR][LF]` — no space after comma.
|
||||||
|
- **Query format:** `Command?[CR][LF]`
|
||||||
|
- **Line endings:** CR = `\r` (0x0D), LF = `\n` (0x0A), always use both.
|
||||||
|
- **No `$` prefix** unless specifically required (e.g. system control).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Confirmed Commands
|
||||||
|
|
||||||
|
### 📏 Start/Stop Measurement
|
||||||
|
| Action | Command Sent |
|
||||||
|
|--------------|---------------------------|
|
||||||
|
| Start | `Measure,Start\r\n` |
|
||||||
|
| Stop | `Measure,Stop\r\n` |
|
||||||
|
|
||||||
|
> **Important:** These must be exact — no space after comma, param is a capitalized string.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🕒 Set/Query Clock
|
||||||
|
| Action | Command Sent |
|
||||||
|
|--------------|---------------------------------------------|
|
||||||
|
| Set Time | `Clock,2025,12,23 23:45:00\r\n` |
|
||||||
|
| Query Time | `Clock?\r\n` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 📈 One-Shot Readout (DOD)
|
||||||
|
| Action | Command Sent |
|
||||||
|
|--------------|--------------|
|
||||||
|
| Get Snapshot | `DOD?\r\n` |
|
||||||
|
|
||||||
|
Returns: comma-separated line with values like `R+70.2,91.1,88.0,...`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🔁 Streaming Output (DRD)
|
||||||
|
| Action | Command Sent |
|
||||||
|
|--------------------|--------------|
|
||||||
|
| Start DRD Output | `DRD?\r\n` |
|
||||||
|
| Stop DRD Output | `\x1A` (SUB)|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🔧 Echo On/Off
|
||||||
|
| Action | Command Sent |
|
||||||
|
|------------|---------------|
|
||||||
|
| Enable | `Echo,On\r\n` |
|
||||||
|
| Disable | `Echo,Off\r\n` |
|
||||||
|
| Query | `Echo?\r\n` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ Common Mistakes to Avoid
|
||||||
|
- ❌ Don’t include a space after comma: `Measure, Start` → invalid.
|
||||||
|
- ❌ Don’t use numeric params if the spec requires strings.
|
||||||
|
- ❌ Don’t forget `\r\n` line ending — most commands won’t work without it.
|
||||||
|
- ❌ Don’t send multiple commands at once — insert 1s delay between.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
This file is safe for ingestion by agents or UI generators.
|
||||||
+246
@@ -0,0 +1,246 @@
|
|||||||
|
# SLMM Roster Management
|
||||||
|
|
||||||
|
The SLMM standalone application now includes a roster management interface for viewing and configuring all Sound Level Meter devices.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Web Interface
|
||||||
|
|
||||||
|
Access the roster at: **http://localhost:8100/roster**
|
||||||
|
|
||||||
|
The roster page provides:
|
||||||
|
|
||||||
|
- **Device List Table**: View all configured SLMs with their connection details
|
||||||
|
- **Real-time Status**: See device connectivity status (Online/Offline/Stale)
|
||||||
|
- **Add Device**: Create new device configurations with a user-friendly modal form
|
||||||
|
- **Edit Device**: Modify existing device configurations
|
||||||
|
- **Delete Device**: Remove device configurations (does not affect physical devices)
|
||||||
|
- **Test Connection**: Run diagnostics on individual devices
|
||||||
|
|
||||||
|
### Table Columns
|
||||||
|
|
||||||
|
| Column | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| Unit ID | Unique identifier for the device |
|
||||||
|
| Host / IP | Device IP address or hostname |
|
||||||
|
| TCP Port | TCP control port (default: 2255) |
|
||||||
|
| FTP Port | FTP file transfer port (default: 21) |
|
||||||
|
| TCP | Whether TCP control is enabled |
|
||||||
|
| FTP | Whether FTP file transfer is enabled |
|
||||||
|
| Polling | Whether background polling is enabled |
|
||||||
|
| Status | Device connectivity status (Online/Offline/Stale) |
|
||||||
|
| Actions | Test, Edit, Delete buttons |
|
||||||
|
|
||||||
|
### Status Indicators
|
||||||
|
|
||||||
|
- **Online** (green): Device responded within the last 5 minutes
|
||||||
|
- **Stale** (yellow): Device hasn't responded recently but was seen before
|
||||||
|
- **Offline** (red): Device is unreachable or has consecutive failures
|
||||||
|
- **Unknown** (gray): No status data available yet
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### List All Devices
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GET /api/nl43/roster
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns all configured devices with their status information.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"devices": [
|
||||||
|
{
|
||||||
|
"unit_id": "SLM-43-01",
|
||||||
|
"host": "192.168.1.100",
|
||||||
|
"tcp_port": 2255,
|
||||||
|
"ftp_port": 21,
|
||||||
|
"tcp_enabled": true,
|
||||||
|
"ftp_enabled": true,
|
||||||
|
"ftp_username": "USER",
|
||||||
|
"ftp_password": "0000",
|
||||||
|
"web_enabled": false,
|
||||||
|
"poll_enabled": true,
|
||||||
|
"poll_interval_seconds": 60,
|
||||||
|
"status": {
|
||||||
|
"last_seen": "2026-01-16T20:00:00",
|
||||||
|
"measurement_state": "Start",
|
||||||
|
"is_reachable": true,
|
||||||
|
"consecutive_failures": 0,
|
||||||
|
"last_success": "2026-01-16T20:00:00",
|
||||||
|
"last_error": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create New Device
|
||||||
|
|
||||||
|
```bash
|
||||||
|
POST /api/nl43/roster
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"unit_id": "SLM-43-01",
|
||||||
|
"host": "192.168.1.100",
|
||||||
|
"tcp_port": 2255,
|
||||||
|
"ftp_port": 21,
|
||||||
|
"tcp_enabled": true,
|
||||||
|
"ftp_enabled": false,
|
||||||
|
"poll_enabled": true,
|
||||||
|
"poll_interval_seconds": 60
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Required Fields:**
|
||||||
|
- `unit_id`: Unique device identifier
|
||||||
|
- `host`: IP address or hostname
|
||||||
|
|
||||||
|
**Optional Fields:**
|
||||||
|
- `tcp_port`: TCP control port (default: 2255)
|
||||||
|
- `ftp_port`: FTP port (default: 21)
|
||||||
|
- `tcp_enabled`: Enable TCP control (default: true)
|
||||||
|
- `ftp_enabled`: Enable FTP transfers (default: false)
|
||||||
|
- `ftp_username`: FTP username (only if ftp_enabled)
|
||||||
|
- `ftp_password`: FTP password (only if ftp_enabled)
|
||||||
|
- `poll_enabled`: Enable background polling (default: true)
|
||||||
|
- `poll_interval_seconds`: Polling interval 10-3600 seconds (default: 60)
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"message": "Device SLM-43-01 created successfully",
|
||||||
|
"data": {
|
||||||
|
"unit_id": "SLM-43-01",
|
||||||
|
"host": "192.168.1.100",
|
||||||
|
"tcp_port": 2255,
|
||||||
|
"tcp_enabled": true,
|
||||||
|
"ftp_enabled": false,
|
||||||
|
"poll_enabled": true,
|
||||||
|
"poll_interval_seconds": 60
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Update Device
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PUT /api/nl43/{unit_id}/config
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"host": "192.168.1.101",
|
||||||
|
"tcp_port": 2255,
|
||||||
|
"poll_interval_seconds": 120
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
All fields are optional. Only include fields you want to update.
|
||||||
|
|
||||||
|
### Delete Device
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DELETE /api/nl43/{unit_id}/config
|
||||||
|
```
|
||||||
|
|
||||||
|
Removes the device configuration and associated status data. Does not affect the physical device.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"message": "Deleted device SLM-43-01"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
### Via Web Interface
|
||||||
|
|
||||||
|
1. Navigate to http://localhost:8100/roster
|
||||||
|
2. Click "Add Device" to create a new configuration
|
||||||
|
3. Fill in the device details (unit ID, IP address, ports)
|
||||||
|
4. Configure TCP, FTP, and polling settings
|
||||||
|
5. Click "Save Device"
|
||||||
|
6. Use "Test" button to verify connectivity
|
||||||
|
7. Edit or delete devices as needed
|
||||||
|
|
||||||
|
### Via API (curl)
|
||||||
|
|
||||||
|
**Add a new device:**
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8100/api/nl43/roster \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"unit_id": "slm-site-a",
|
||||||
|
"host": "192.168.1.100",
|
||||||
|
"tcp_port": 2255,
|
||||||
|
"tcp_enabled": true,
|
||||||
|
"ftp_enabled": true,
|
||||||
|
"ftp_username": "USER",
|
||||||
|
"ftp_password": "0000",
|
||||||
|
"poll_enabled": true,
|
||||||
|
"poll_interval_seconds": 60
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Update device host:**
|
||||||
|
```bash
|
||||||
|
curl -X PUT http://localhost:8100/api/nl43/slm-site-a/config \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"host": "192.168.1.101"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Delete device:**
|
||||||
|
```bash
|
||||||
|
curl -X DELETE http://localhost:8100/api/nl43/slm-site-a/config
|
||||||
|
```
|
||||||
|
|
||||||
|
**List all devices:**
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8100/api/nl43/roster | python3 -m json.tool
|
||||||
|
```
|
||||||
|
|
||||||
|
## Integration with Terra-View
|
||||||
|
|
||||||
|
When SLMM is used as a module within Terra-View:
|
||||||
|
|
||||||
|
1. Terra-View manages device configurations in its own database
|
||||||
|
2. Terra-View syncs configurations to SLMM via `PUT /api/nl43/{unit_id}/config`
|
||||||
|
3. Terra-View can query device status via `GET /api/nl43/{unit_id}/status`
|
||||||
|
4. SLMM's roster page can be used for standalone testing and diagnostics
|
||||||
|
|
||||||
|
## Background Polling
|
||||||
|
|
||||||
|
Devices with `poll_enabled: true` are automatically polled at their configured interval:
|
||||||
|
|
||||||
|
- Polls device status every `poll_interval_seconds` (10-3600 seconds)
|
||||||
|
- Updates `NL43Status` table with latest measurements
|
||||||
|
- Tracks device reachability and failure counts
|
||||||
|
- Provides real-time status updates in the roster
|
||||||
|
|
||||||
|
**Note**: Polling respects the NL43 protocol's 1-second rate limit between commands.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
The roster system validates:
|
||||||
|
|
||||||
|
- **Unit ID**: Must be unique across all devices
|
||||||
|
- **Host**: Valid IP address or hostname format
|
||||||
|
- **Ports**: Must be between 1-65535
|
||||||
|
- **Poll Interval**: Must be between 10-3600 seconds
|
||||||
|
- **Duplicate Check**: Returns 409 Conflict if unit_id already exists
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Deleting a device from the roster does NOT affect the physical device
|
||||||
|
- Device configurations are stored in the SLMM database (`data/slmm.db`)
|
||||||
|
- Status information is updated by the background polling system
|
||||||
|
- The roster page auto-refreshes status indicators
|
||||||
|
- Test button runs full diagnostics (connectivity, TCP, FTP if enabled)
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
# Feature Summary: Device Settings Verification
|
||||||
|
|
||||||
|
## What Was Added
|
||||||
|
|
||||||
|
A new API endpoint that retrieves all current device settings in a single request, allowing users to quickly verify the NL43/NL53 configuration before starting measurements.
|
||||||
|
|
||||||
|
## New Endpoint
|
||||||
|
|
||||||
|
**`GET /api/nl43/{unit_id}/settings`**
|
||||||
|
|
||||||
|
Returns comprehensive device configuration including:
|
||||||
|
- Measurement state and weighting settings
|
||||||
|
- Timing and interval configuration
|
||||||
|
- Battery level and device clock
|
||||||
|
- Sleep mode and FTP status
|
||||||
|
|
||||||
|
## Files Modified
|
||||||
|
|
||||||
|
### 1. [app/routers.py](app/routers.py)
|
||||||
|
**Lines:** 728-761
|
||||||
|
|
||||||
|
Added new route handler `get_all_settings()` that:
|
||||||
|
- Validates device configuration exists
|
||||||
|
- Checks TCP communication is enabled
|
||||||
|
- Calls `NL43Client.get_all_settings()`
|
||||||
|
- Returns formatted JSON response with all settings
|
||||||
|
- Handles connection errors, timeouts, and exceptions
|
||||||
|
|
||||||
|
### 2. [README.md](README.md)
|
||||||
|
**Updated sections:**
|
||||||
|
- Line 134: Added new endpoint to Measurement Settings table
|
||||||
|
- Lines 259-283: Added usage example showing how to verify device settings
|
||||||
|
|
||||||
|
## Files Created
|
||||||
|
|
||||||
|
### 1. [test_settings_endpoint.py](test_settings_endpoint.py)
|
||||||
|
Test/demonstration script showing:
|
||||||
|
- How to use the `get_all_settings()` method
|
||||||
|
- Example API endpoint usage with curl
|
||||||
|
- Expected response format
|
||||||
|
|
||||||
|
### 2. [SETTINGS_ENDPOINT.md](SETTINGS_ENDPOINT.md)
|
||||||
|
Comprehensive documentation including:
|
||||||
|
- Detailed endpoint description
|
||||||
|
- Complete list of settings retrieved
|
||||||
|
- Usage examples in bash, Python, and JavaScript
|
||||||
|
- Performance considerations
|
||||||
|
- Best practices and troubleshooting
|
||||||
|
|
||||||
|
### 3. [FEATURE_SUMMARY.md](FEATURE_SUMMARY.md)
|
||||||
|
This file - summary of changes for reference
|
||||||
|
|
||||||
|
## Existing Functionality Used
|
||||||
|
|
||||||
|
The implementation leverages the existing `get_all_settings()` method in [app/services.py](app/services.py#L538) which was already implemented but not exposed via the API. This method queries multiple device settings and handles errors gracefully.
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
1. **User makes GET request** to `/api/nl43/{unit_id}/settings`
|
||||||
|
2. **Router validates** device configuration exists and TCP is enabled
|
||||||
|
3. **NL43Client queries device** for each setting sequentially (with 1-second delays)
|
||||||
|
4. **Individual errors** are caught and returned as error strings
|
||||||
|
5. **Response returned** with all settings in JSON format
|
||||||
|
|
||||||
|
## Usage Example
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Quick verification before measurement
|
||||||
|
curl http://localhost:8100/api/nl43/NL43-001/settings
|
||||||
|
|
||||||
|
# Response:
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"unit_id": "NL43-001",
|
||||||
|
"settings": {
|
||||||
|
"measurement_state": "Stop",
|
||||||
|
"frequency_weighting": "A",
|
||||||
|
"time_weighting": "F",
|
||||||
|
"measurement_time": "00:01:00",
|
||||||
|
"leq_interval": "1s",
|
||||||
|
"lp_interval": "125ms",
|
||||||
|
"index_number": "0",
|
||||||
|
"battery_level": "100%",
|
||||||
|
"clock": "2025/12/24,20:45:30",
|
||||||
|
"sleep_mode": "Off",
|
||||||
|
"ftp_status": "On"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
|
||||||
|
1. **Single request** - Get all settings at once instead of multiple API calls
|
||||||
|
2. **Pre-flight checks** - Verify configuration before starting measurements
|
||||||
|
3. **Documentation** - Easy to save configuration snapshots for audit trails
|
||||||
|
4. **Troubleshooting** - Quickly identify misconfigured settings
|
||||||
|
5. **Multi-device** - Compare settings across multiple devices
|
||||||
|
|
||||||
|
## Performance Notes
|
||||||
|
|
||||||
|
- **Query time:** ~10-15 seconds (due to required 1-second delays between commands)
|
||||||
|
- **Rate limiting:** Automatically enforced by NL43Client
|
||||||
|
- **Error handling:** Partial failures don't prevent other settings from being retrieved
|
||||||
|
- **Caching recommended:** Settings don't change frequently, cache for 5-10 minutes
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
To test the new endpoint:
|
||||||
|
|
||||||
|
1. **Start the server:**
|
||||||
|
```bash
|
||||||
|
uvicorn app.main:app --reload --port 8100
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Configure a device** (if not already configured):
|
||||||
|
```bash
|
||||||
|
curl -X PUT http://localhost:8100/api/nl43/test-meter/config \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"host": "192.168.1.100", "tcp_port": 80, "tcp_enabled": true}'
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Query settings:**
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8100/api/nl43/test-meter/settings
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Check Swagger UI:**
|
||||||
|
- Navigate to http://localhost:8100/docs
|
||||||
|
- Find "GET /api/nl43/{unit_id}/settings" endpoint
|
||||||
|
- Click "Try it out" and test interactively
|
||||||
|
|
||||||
|
## Integration Tips
|
||||||
|
|
||||||
|
### Frontend Integration
|
||||||
|
```javascript
|
||||||
|
// React/Vue/Angular example
|
||||||
|
async function verifyDeviceBeforeMeasurement(unitId) {
|
||||||
|
const response = await fetch(`/api/nl43/${unitId}/settings`);
|
||||||
|
const { settings } = await response.json();
|
||||||
|
|
||||||
|
// Verify critical settings
|
||||||
|
if (settings.frequency_weighting !== 'A') {
|
||||||
|
alert('Warning: Frequency weighting not set to A');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check battery
|
||||||
|
const batteryPercent = parseInt(settings.battery_level);
|
||||||
|
if (batteryPercent < 20) {
|
||||||
|
alert('Low battery! Please charge device.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Python Automation
|
||||||
|
```python
|
||||||
|
def ensure_correct_config(unit_id: str, required_config: dict):
|
||||||
|
"""Verify device matches required configuration."""
|
||||||
|
settings = get_device_settings(unit_id)
|
||||||
|
|
||||||
|
mismatches = []
|
||||||
|
for key, expected in required_config.items():
|
||||||
|
actual = settings.get(key)
|
||||||
|
if actual != expected:
|
||||||
|
mismatches.append(f"{key}: expected {expected}, got {actual}")
|
||||||
|
|
||||||
|
if mismatches:
|
||||||
|
raise ValueError(f"Configuration mismatch: {', '.join(mismatches)}")
|
||||||
|
|
||||||
|
return True
|
||||||
|
```
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
Potential improvements for future versions:
|
||||||
|
|
||||||
|
1. **Filtered queries** - Query parameter to select specific settings
|
||||||
|
2. **Diff mode** - Compare current settings to expected values
|
||||||
|
3. **Batch queries** - Get settings from multiple devices in one request
|
||||||
|
4. **Settings profiles** - Save/load common configuration profiles
|
||||||
|
5. **Change detection** - Track when settings were last modified
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For questions or issues with this feature:
|
||||||
|
- See [SETTINGS_ENDPOINT.md](SETTINGS_ENDPOINT.md) for detailed documentation
|
||||||
|
- Check [README.md](README.md) for general API usage
|
||||||
|
- Review [COMMUNICATION_GUIDE.md](COMMUNICATION_GUIDE.md) for protocol details
|
||||||
|
|
||||||
|
## Version Info
|
||||||
|
|
||||||
|
- **Added:** December 24, 2025
|
||||||
|
- **API Version:** Compatible with existing v1 API
|
||||||
|
- **Breaking Changes:** None - purely additive feature
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# SLMM Feature Documentation
|
||||||
|
|
||||||
|
This directory contains detailed documentation for specific SLMM features and enhancements.
|
||||||
|
|
||||||
|
## Feature Documents
|
||||||
|
|
||||||
|
### FEATURE_SUMMARY.md
|
||||||
|
Overview of all major features in SLMM.
|
||||||
|
|
||||||
|
### SETTINGS_ENDPOINT.md
|
||||||
|
Documentation of the device settings endpoint and verification system.
|
||||||
|
|
||||||
|
### TIMEZONE_CONFIGURATION.md
|
||||||
|
Timezone handling and configuration for SLMM timestamps.
|
||||||
|
|
||||||
|
### SLEEP_MODE_AUTO_DISABLE.md
|
||||||
|
Automatic sleep mode wake-up system for background polling.
|
||||||
|
|
||||||
|
### UI_UPDATE.md
|
||||||
|
UI/UX improvements and interface updates.
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
- [../README.md](../../README.md) - Main SLMM documentation
|
||||||
|
- [../CHANGELOG.md](../../CHANGELOG.md) - Version history
|
||||||
|
- [../API.md](../../API.md) - Complete API reference
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
# Device Settings Verification Endpoint
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The new `GET /api/nl43/{unit_id}/settings` endpoint provides a comprehensive view of all current device settings. This allows you to quickly verify the configuration of your NL43/NL53 sound level meter before starting measurements, ensuring the device is configured correctly for your testing requirements.
|
||||||
|
|
||||||
|
## Endpoint Details
|
||||||
|
|
||||||
|
**URL:** `GET /api/nl43/{unit_id}/settings`
|
||||||
|
|
||||||
|
**Description:** Retrieves all queryable settings from the device in a single request.
|
||||||
|
|
||||||
|
**Response Time:** Approximately 10-15 seconds (due to required 1-second delay between device commands)
|
||||||
|
|
||||||
|
## Settings Retrieved
|
||||||
|
|
||||||
|
The endpoint queries the following categories of settings:
|
||||||
|
|
||||||
|
### Measurement Configuration
|
||||||
|
- **measurement_state**: Current state (Measure, Stop, Pause)
|
||||||
|
- **frequency_weighting**: Frequency weighting (A, C, or Z)
|
||||||
|
- **time_weighting**: Time weighting (F=Fast, S=Slow, I=Impulse)
|
||||||
|
|
||||||
|
### Timing and Intervals
|
||||||
|
- **measurement_time**: Total measurement duration setting
|
||||||
|
- **leq_interval**: Leq calculation interval
|
||||||
|
- **lp_interval**: Lp sampling interval
|
||||||
|
- **index_number**: Current index/file number for storage
|
||||||
|
|
||||||
|
### Device Information
|
||||||
|
- **battery_level**: Current battery percentage
|
||||||
|
- **clock**: Device clock time (format: YYYY/MM/DD,HH:MM:SS)
|
||||||
|
|
||||||
|
### Operational Status
|
||||||
|
- **sleep_mode**: Sleep mode status (On/Off)
|
||||||
|
- **ftp_status**: FTP server status (On/Off)
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
### Basic Request
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8100/api/nl43/NL43-001/settings
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response Format
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"unit_id": "NL43-001",
|
||||||
|
"settings": {
|
||||||
|
"measurement_state": "Stop",
|
||||||
|
"frequency_weighting": "A",
|
||||||
|
"time_weighting": "F",
|
||||||
|
"measurement_time": "00:01:00",
|
||||||
|
"leq_interval": "1s",
|
||||||
|
"lp_interval": "125ms",
|
||||||
|
"index_number": "0",
|
||||||
|
"battery_level": "100%",
|
||||||
|
"clock": "2025/12/24,20:45:30",
|
||||||
|
"sleep_mode": "Off",
|
||||||
|
"ftp_status": "On"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
Individual settings that fail to query will show an error message instead of a value:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"unit_id": "NL43-001",
|
||||||
|
"settings": {
|
||||||
|
"measurement_state": "Stop",
|
||||||
|
"frequency_weighting": "A",
|
||||||
|
"time_weighting": "Error: Status error - device is in wrong state for this command",
|
||||||
|
...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This partial error handling ensures you get as much information as possible even if some settings fail to query.
|
||||||
|
|
||||||
|
## Common Use Cases
|
||||||
|
|
||||||
|
### Pre-Measurement Verification
|
||||||
|
|
||||||
|
Before starting a measurement session, verify all critical settings:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Get all settings
|
||||||
|
SETTINGS=$(curl -s http://localhost:8100/api/nl43/meter-001/settings)
|
||||||
|
|
||||||
|
# Extract specific values (using jq)
|
||||||
|
FREQ_WEIGHT=$(echo $SETTINGS | jq -r '.settings.frequency_weighting')
|
||||||
|
TIME_WEIGHT=$(echo $SETTINGS | jq -r '.settings.time_weighting')
|
||||||
|
|
||||||
|
echo "Frequency: $FREQ_WEIGHT, Time: $TIME_WEIGHT"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration Audit
|
||||||
|
|
||||||
|
Document device configuration for quality assurance:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Save settings snapshot
|
||||||
|
curl http://localhost:8100/api/nl43/meter-001/settings > config_snapshot_$(date +%Y%m%d_%H%M%S).json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multi-Device Comparison
|
||||||
|
|
||||||
|
Compare settings across multiple devices:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Compare two devices
|
||||||
|
curl http://localhost:8100/api/nl43/meter-001/settings > device1.json
|
||||||
|
curl http://localhost:8100/api/nl43/meter-002/settings > device2.json
|
||||||
|
diff device1.json device2.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Integration Examples
|
||||||
|
|
||||||
|
### Python
|
||||||
|
|
||||||
|
```python
|
||||||
|
import requests
|
||||||
|
|
||||||
|
def verify_device_settings(unit_id: str) -> dict:
|
||||||
|
"""Retrieve and verify device settings."""
|
||||||
|
response = requests.get(f"http://localhost:8100/api/nl43/{unit_id}/settings")
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
settings = data["settings"]
|
||||||
|
|
||||||
|
# Verify critical settings
|
||||||
|
assert settings["frequency_weighting"] == "A", "Wrong frequency weighting!"
|
||||||
|
assert settings["time_weighting"] == "F", "Wrong time weighting!"
|
||||||
|
|
||||||
|
return settings
|
||||||
|
|
||||||
|
# Usage
|
||||||
|
settings = verify_device_settings("NL43-001")
|
||||||
|
print(f"Battery: {settings['battery_level']}")
|
||||||
|
print(f"Clock: {settings['clock']}")
|
||||||
|
```
|
||||||
|
|
||||||
|
### JavaScript/TypeScript
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface DeviceSettings {
|
||||||
|
measurement_state: string;
|
||||||
|
frequency_weighting: string;
|
||||||
|
time_weighting: string;
|
||||||
|
measurement_time: string;
|
||||||
|
leq_interval: string;
|
||||||
|
lp_interval: string;
|
||||||
|
index_number: string;
|
||||||
|
battery_level: string;
|
||||||
|
clock: string;
|
||||||
|
sleep_mode: string;
|
||||||
|
ftp_status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDeviceSettings(unitId: string): Promise<DeviceSettings> {
|
||||||
|
const response = await fetch(`http://localhost:8100/api/nl43/${unitId}/settings`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.status !== "ok") {
|
||||||
|
throw new Error("Failed to retrieve settings");
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage
|
||||||
|
const settings = await getDeviceSettings("NL43-001");
|
||||||
|
console.log(`Frequency weighting: ${settings.frequency_weighting}`);
|
||||||
|
console.log(`Battery level: ${settings.battery_level}`);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Considerations
|
||||||
|
|
||||||
|
### Query Duration
|
||||||
|
|
||||||
|
The endpoint queries multiple settings sequentially with required 1-second delays between commands. Total query time depends on:
|
||||||
|
- Number of settings queried (~10-12 settings)
|
||||||
|
- Network latency
|
||||||
|
- Device response time
|
||||||
|
|
||||||
|
**Expected duration:** 10-15 seconds
|
||||||
|
|
||||||
|
### Caching Strategy
|
||||||
|
|
||||||
|
For applications that need frequent access to settings:
|
||||||
|
|
||||||
|
1. **Cache results** - Settings don't change frequently unless you modify them
|
||||||
|
2. **Refresh periodically** - Query every 5-10 minutes or on-demand
|
||||||
|
3. **Track changes** - Re-query after sending configuration commands
|
||||||
|
|
||||||
|
### Rate Limiting
|
||||||
|
|
||||||
|
The endpoint respects device rate limiting (1-second delay between commands). Concurrent requests to the same device will be serialized automatically.
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Pre-flight check**: Always verify settings before starting critical measurements
|
||||||
|
2. **Document configuration**: Save settings snapshots for audit trails
|
||||||
|
3. **Monitor battery**: Check battery level to avoid measurement interruption
|
||||||
|
4. **Sync clocks**: Verify device clock is accurate for timestamped data
|
||||||
|
5. **Error handling**: Check for "Error:" prefixes in individual setting values
|
||||||
|
|
||||||
|
## Related Endpoints
|
||||||
|
|
||||||
|
- `GET /api/nl43/{unit_id}/frequency-weighting` - Get single frequency weighting setting
|
||||||
|
- `PUT /api/nl43/{unit_id}/frequency-weighting` - Set frequency weighting
|
||||||
|
- `GET /api/nl43/{unit_id}/time-weighting` - Get single time weighting setting
|
||||||
|
- `PUT /api/nl43/{unit_id}/time-weighting` - Set time weighting
|
||||||
|
- `GET /api/nl43/{unit_id}/battery` - Get battery level only
|
||||||
|
- `GET /api/nl43/{unit_id}/clock` - Get device clock only
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Slow Response
|
||||||
|
|
||||||
|
**Problem:** Endpoint takes longer than expected
|
||||||
|
|
||||||
|
**Solutions:**
|
||||||
|
- Normal behavior due to rate limiting (1 second between commands)
|
||||||
|
- Check network connectivity
|
||||||
|
- Verify device is not in sleep mode
|
||||||
|
|
||||||
|
### Partial Errors
|
||||||
|
|
||||||
|
**Problem:** Some settings show "Error:" messages
|
||||||
|
|
||||||
|
**Solutions:**
|
||||||
|
- Device may be in wrong state for certain queries
|
||||||
|
- Check if measurement is running (some settings require stopped state)
|
||||||
|
- Verify firmware version supports all queried commands
|
||||||
|
|
||||||
|
### Connection Timeout
|
||||||
|
|
||||||
|
**Problem:** 504 Gateway Timeout error
|
||||||
|
|
||||||
|
**Solutions:**
|
||||||
|
- Verify device IP address and port in configuration
|
||||||
|
- Check if device is powered on and connected
|
||||||
|
- Ensure TCP communication is enabled in device config
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- [README.md](README.md) - Main documentation
|
||||||
|
- [API.md](API.md) - Complete API reference
|
||||||
|
- [COMMUNICATION_GUIDE.md](COMMUNICATION_GUIDE.md) - NL43 protocol details
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
# Sleep Mode Auto-Disable Feature
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
NL-43/NL-53 sound level meters have a sleep/eco mode feature that conserves battery power. However, when these devices enter sleep mode, **they turn off TCP communications**, which completely breaks remote monitoring and control capabilities. This makes it impossible to:
|
||||||
|
|
||||||
|
- Query device status remotely
|
||||||
|
- Start/stop measurements
|
||||||
|
- Stream real-time data
|
||||||
|
- Download files via FTP
|
||||||
|
- Perform any remote management tasks
|
||||||
|
|
||||||
|
This is particularly problematic in deployed scenarios where physical access to devices is limited or impossible.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
SLMM now automatically disables sleep mode in two key scenarios:
|
||||||
|
|
||||||
|
### 1. Device Configuration
|
||||||
|
When a device configuration is created or updated with TCP enabled, SLMM automatically:
|
||||||
|
- Checks the current sleep mode status on the device
|
||||||
|
- Disables sleep mode if it's enabled
|
||||||
|
- Logs the operation for visibility
|
||||||
|
|
||||||
|
**Endpoint:** `PUT /api/nl43/{unit_id}/config`
|
||||||
|
|
||||||
|
### 2. Measurement Start
|
||||||
|
Before starting any measurement, SLMM:
|
||||||
|
- Proactively disables sleep mode
|
||||||
|
- Ensures TCP remains active throughout the measurement session
|
||||||
|
- Allows remote monitoring to work reliably
|
||||||
|
|
||||||
|
**Endpoint:** `POST /api/nl43/{unit_id}/start`
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
### Helper Function
|
||||||
|
A new async helper function was added to [app/routers.py](app/routers.py:21-38):
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def ensure_sleep_mode_disabled(client: NL43Client, unit_id: str):
|
||||||
|
"""
|
||||||
|
Helper function to ensure sleep mode is disabled on the device.
|
||||||
|
Sleep/eco mode turns off TCP communications, preventing remote monitoring.
|
||||||
|
This should be called when configuring a device or starting measurements.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
current_status = await client.get_sleep_status()
|
||||||
|
logger.info(f"Current sleep mode status for {unit_id}: {current_status}")
|
||||||
|
|
||||||
|
# If sleep mode is on, disable it
|
||||||
|
if "On" in current_status or "on" in current_status:
|
||||||
|
logger.info(f"Sleep mode is enabled on {unit_id}, disabling it to maintain TCP connectivity")
|
||||||
|
await client.wake()
|
||||||
|
logger.info(f"Successfully disabled sleep mode on {unit_id}")
|
||||||
|
else:
|
||||||
|
logger.info(f"Sleep mode already disabled on {unit_id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not verify/disable sleep mode on {unit_id}: {e}")
|
||||||
|
# Don't raise - we want configuration to succeed even if sleep mode check fails
|
||||||
|
```
|
||||||
|
|
||||||
|
### Non-Blocking Design
|
||||||
|
The sleep mode check is **non-blocking**:
|
||||||
|
- If the device is unreachable, the operation logs a warning but continues
|
||||||
|
- Configuration updates succeed even if sleep mode can't be verified
|
||||||
|
- Measurement starts proceed even if sleep mode check fails
|
||||||
|
- This prevents device communication issues from blocking critical operations
|
||||||
|
|
||||||
|
### Logging
|
||||||
|
All sleep mode operations are logged with appropriate levels:
|
||||||
|
- **INFO**: Successful operations and status checks
|
||||||
|
- **WARNING**: Failed operations (device unreachable, timeout, etc.)
|
||||||
|
|
||||||
|
Example logs:
|
||||||
|
```
|
||||||
|
2026-01-14 18:37:12,889 - app.routers - INFO - TCP enabled for test-nl43-001, ensuring sleep mode is disabled
|
||||||
|
2026-01-14 18:37:12,889 - app.services - INFO - Sending command to 192.168.1.100:2255: Sleep Mode?
|
||||||
|
2026-01-14 18:37:17,890 - app.routers - WARNING - Could not verify/disable sleep mode on test-nl43-001: Failed to connect to device at 192.168.1.100:2255
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
A comprehensive test script is available: [test_sleep_mode_auto_disable.py](test_sleep_mode_auto_disable.py)
|
||||||
|
|
||||||
|
Run it with:
|
||||||
|
```bash
|
||||||
|
python3 test_sleep_mode_auto_disable.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The test verifies:
|
||||||
|
1. Config updates trigger sleep mode check
|
||||||
|
2. Config retrieval works correctly
|
||||||
|
3. Start measurement triggers sleep mode check
|
||||||
|
4. Operations succeed even without a physical device (non-blocking)
|
||||||
|
|
||||||
|
## API Documentation Updates
|
||||||
|
|
||||||
|
The following documentation files were updated to reflect this feature:
|
||||||
|
|
||||||
|
### [docs/API.md](docs/API.md)
|
||||||
|
- Updated config endpoint documentation with sleep mode auto-disable note
|
||||||
|
- Added warning to start measurement endpoint
|
||||||
|
- Enhanced power management section with detailed warnings about sleep mode behavior
|
||||||
|
|
||||||
|
Key additions:
|
||||||
|
- Configuration section now explains that sleep mode is automatically disabled when TCP is enabled
|
||||||
|
- Measurement control section notes that sleep mode is disabled before starting measurements
|
||||||
|
- Power management section includes comprehensive warnings about sleep mode affecting TCP connectivity
|
||||||
|
|
||||||
|
## Usage Notes
|
||||||
|
|
||||||
|
### For Operators
|
||||||
|
- You no longer need to manually disable sleep mode before starting remote monitoring
|
||||||
|
- Sleep mode will be automatically disabled when you configure a device or start measurements
|
||||||
|
- Check logs to verify sleep mode operations if experiencing connectivity issues
|
||||||
|
|
||||||
|
### For Developers
|
||||||
|
- The `ensure_sleep_mode_disabled()` helper can be called from any endpoint that requires reliable TCP connectivity
|
||||||
|
- Always use it before long-running operations that depend on continuous device communication
|
||||||
|
- The function is designed to fail gracefully - don't worry about exception handling
|
||||||
|
|
||||||
|
### Battery Conservation
|
||||||
|
If battery conservation is a concern:
|
||||||
|
- Consider using Timer Auto mode with scheduled measurements
|
||||||
|
- Sleep mode can be manually re-enabled between measurements using `POST /{unit_id}/sleep`
|
||||||
|
- Be aware that TCP connectivity will be lost until the device wakes or is physically accessed
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
The feature is automatically included when building the SLMM container:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/serversdown/tmi/terra-view
|
||||||
|
docker compose build slmm
|
||||||
|
docker compose up -d slmm
|
||||||
|
```
|
||||||
|
|
||||||
|
No configuration changes are required - the feature is active by default.
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
Potential improvements for future versions:
|
||||||
|
- Add a user preference to optionally skip sleep mode disable
|
||||||
|
- Implement smart sleep mode scheduling (enable between measurements, disable during)
|
||||||
|
- Add sleep mode status to device health checks
|
||||||
|
- Create alerts when sleep mode is detected as enabled
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- NL-43 Command Reference: [docs/nl43_Command_ref.md](docs/nl43_Command_ref.md)
|
||||||
|
- Communication Guide: [docs/COMMUNICATION_GUIDE.md](docs/COMMUNICATION_GUIDE.md) (page 65, Sleep Mode)
|
||||||
|
- API Documentation: [docs/API.md](docs/API.md)
|
||||||
|
- SLMM Services: [app/services.py](app/services.py:395-417) (sleep mode commands)
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
# Timezone Configuration for SLMM
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The SLMM system now supports configurable timezone settings. All timestamps are stored internally in UTC for consistency, but the system can interpret FTP timestamps and display times in your local timezone.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
Set the following environment variables to configure your timezone:
|
||||||
|
|
||||||
|
#### `TIMEZONE_OFFSET` (required)
|
||||||
|
The number of hours offset from UTC. Use negative numbers for zones west of UTC, positive for east.
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
- `-5` = EST (Eastern Standard Time, UTC-5)
|
||||||
|
- `-4` = EDT (Eastern Daylight Time, UTC-4)
|
||||||
|
- `0` = UTC (Coordinated Universal Time)
|
||||||
|
- `+1` = CET (Central European Time, UTC+1)
|
||||||
|
- `-8` = PST (Pacific Standard Time, UTC-8)
|
||||||
|
|
||||||
|
**Default:** `-5` (EST)
|
||||||
|
|
||||||
|
#### `TIMEZONE_NAME` (optional)
|
||||||
|
A friendly name for your timezone, used in log messages.
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
- `EST`
|
||||||
|
- `EDT`
|
||||||
|
- `UTC`
|
||||||
|
- `PST`
|
||||||
|
|
||||||
|
**Default:** Auto-generated from offset (e.g., "UTC-5")
|
||||||
|
|
||||||
|
### Setup Instructions
|
||||||
|
|
||||||
|
#### Option 1: Using .env file (Recommended)
|
||||||
|
|
||||||
|
1. Copy the example file:
|
||||||
|
```bash
|
||||||
|
cd /home/serversdown/slmm
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Edit `.env` and set your timezone:
|
||||||
|
```bash
|
||||||
|
TIMEZONE_OFFSET=-5
|
||||||
|
TIMEZONE_NAME=EST
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Make sure your application loads the .env file (you may need to install `python-dotenv`):
|
||||||
|
```bash
|
||||||
|
pip install python-dotenv
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Update `app/main.py` to load the .env file (add at the top):
|
||||||
|
```python
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv()
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Option 2: System Environment Variables
|
||||||
|
|
||||||
|
Set the environment variables in your shell or service configuration:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export TIMEZONE_OFFSET=-5
|
||||||
|
export TIMEZONE_NAME=EST
|
||||||
|
```
|
||||||
|
|
||||||
|
Or add to your systemd service file if running as a service.
|
||||||
|
|
||||||
|
#### Option 3: Docker/Docker Compose
|
||||||
|
|
||||||
|
If using Docker, add to your `docker-compose.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
slmm:
|
||||||
|
environment:
|
||||||
|
- TIMEZONE_OFFSET=-5
|
||||||
|
- TIMEZONE_NAME=EST
|
||||||
|
```
|
||||||
|
|
||||||
|
Or pass via command line:
|
||||||
|
```bash
|
||||||
|
docker run -e TIMEZONE_OFFSET=-5 -e TIMEZONE_NAME=EST ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
|
||||||
|
1. **FTP Timestamps**: When the system reads file timestamps via FTP from the NL43 device, they are assumed to be in your configured timezone
|
||||||
|
2. **Conversion**: Timestamps are immediately converted to UTC for internal storage
|
||||||
|
3. **Database**: All timestamps in the database are stored in UTC
|
||||||
|
4. **API Responses**: Timestamps are sent to the frontend as UTC ISO strings
|
||||||
|
5. **Frontend Display**: The browser automatically converts UTC timestamps to the user's local timezone for display
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
If you're in EST (UTC-5) and the FTP shows a file timestamp of "Jan 11 21:57":
|
||||||
|
|
||||||
|
1. System interprets: `Jan 11 21:57 EST`
|
||||||
|
2. Converts to UTC: `Jan 12 02:57 UTC` (adds 5 hours)
|
||||||
|
3. Stores in database: `2026-01-12T02:57:00`
|
||||||
|
4. Sends to frontend: `2026-01-12T02:57:00` (with 'Z' added = UTC)
|
||||||
|
5. Browser displays: `Jan 11, 9:57 PM EST` (converts back to user's local time)
|
||||||
|
|
||||||
|
### Timer Calculation
|
||||||
|
|
||||||
|
The measurement timer calculates elapsed time correctly because:
|
||||||
|
- `measurement_start_time` is stored in UTC
|
||||||
|
- FTP folder timestamps are converted to UTC
|
||||||
|
- Frontend calculates `Date.now() - startTime` using UTC milliseconds
|
||||||
|
- All timezone offsets cancel out, giving accurate elapsed time
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Timer shows wrong elapsed time
|
||||||
|
|
||||||
|
1. **Check your timezone setting**: Make sure `TIMEZONE_OFFSET` matches your actual timezone
|
||||||
|
```bash
|
||||||
|
# Check current setting in logs when SLMM starts:
|
||||||
|
grep "Using timezone" data/slmm.log
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Verify FTP timestamps**: FTP timestamps from the device should be in your local timezone
|
||||||
|
- If the device is configured for a different timezone, adjust `TIMEZONE_OFFSET` accordingly
|
||||||
|
|
||||||
|
3. **Restart the service**: Changes to environment variables require restarting the SLMM service
|
||||||
|
|
||||||
|
### Logs show unexpected timezone
|
||||||
|
|
||||||
|
Check the startup logs:
|
||||||
|
```bash
|
||||||
|
tail -f data/slmm.log | grep timezone
|
||||||
|
```
|
||||||
|
|
||||||
|
You should see:
|
||||||
|
```
|
||||||
|
Using timezone: EST (UTC-5)
|
||||||
|
```
|
||||||
|
|
||||||
|
If not, the environment variable may not be loaded correctly.
|
||||||
|
|
||||||
|
## Daylight Saving Time (DST)
|
||||||
|
|
||||||
|
**Important:** This configuration uses a fixed offset. If you need to account for Daylight Saving Time:
|
||||||
|
|
||||||
|
- **During DST (summer)**: Set `TIMEZONE_OFFSET=-4` (EDT)
|
||||||
|
- **During standard time (winter)**: Set `TIMEZONE_OFFSET=-5` (EST)
|
||||||
|
- You'll need to manually update the setting when DST changes (typically March and November)
|
||||||
|
|
||||||
|
**Future Enhancement:** Automatic DST handling could be implemented using Python's `zoneinfo` module with named timezones (e.g., "America/New_York").
|
||||||
|
|
||||||
|
## Default Behavior
|
||||||
|
|
||||||
|
If no environment variables are set:
|
||||||
|
- **TIMEZONE_OFFSET**: Defaults to `-5` (EST)
|
||||||
|
- **TIMEZONE_NAME**: Defaults to `UTC-5`
|
||||||
|
|
||||||
|
This means the system will work correctly for EST deployments out of the box.
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
# Standalone UI Update: Get ALL Settings Button
|
||||||
|
|
||||||
|
## What Was Added
|
||||||
|
|
||||||
|
A new **"Get ALL Settings"** button has been added to the standalone web UI at [templates/index.html](templates/index.html).
|
||||||
|
|
||||||
|
## Location
|
||||||
|
|
||||||
|
The button is located in the **Measurement Settings** fieldset, at the top before the individual frequency and time weighting controls.
|
||||||
|
|
||||||
|
## Visual Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ Measurement Settings │
|
||||||
|
├─────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ [Get ALL Settings] ← NEW BUTTON (bold styling) │
|
||||||
|
│ │
|
||||||
|
│ Frequency Weighting: │
|
||||||
|
│ [Get] [Set A] [Set C] [Set Z] │
|
||||||
|
│ │
|
||||||
|
│ Time Weighting: │
|
||||||
|
│ [Get] [Set Fast] [Set Slow] [Set Impulse] │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Functionality
|
||||||
|
|
||||||
|
When clicked, the button:
|
||||||
|
|
||||||
|
1. **Shows loading message**: "Retrieving all device settings (this may take 10-15 seconds)..."
|
||||||
|
|
||||||
|
2. **Calls API**: `GET /api/nl43/{unit_id}/settings`
|
||||||
|
|
||||||
|
3. **Displays results in two places**:
|
||||||
|
- **Status area** (top): Shows formatted JSON with all settings
|
||||||
|
- **Log area** (bottom): Shows each setting on a separate line
|
||||||
|
|
||||||
|
## Example Output
|
||||||
|
|
||||||
|
### Status Area Display
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"measurement_state": "Stop",
|
||||||
|
"frequency_weighting": "A",
|
||||||
|
"time_weighting": "F",
|
||||||
|
"measurement_time": "00:01:00",
|
||||||
|
"leq_interval": "1s",
|
||||||
|
"lp_interval": "125ms",
|
||||||
|
"index_number": "0",
|
||||||
|
"battery_level": "100%",
|
||||||
|
"clock": "2025/12/24,20:45:30",
|
||||||
|
"sleep_mode": "Off",
|
||||||
|
"ftp_status": "On"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Log Area Display
|
||||||
|
```
|
||||||
|
Retrieving all device settings (this may take 10-15 seconds)...
|
||||||
|
=== ALL DEVICE SETTINGS ===
|
||||||
|
measurement_state: Stop
|
||||||
|
frequency_weighting: A
|
||||||
|
time_weighting: F
|
||||||
|
measurement_time: 00:01:00
|
||||||
|
leq_interval: 1s
|
||||||
|
lp_interval: 125ms
|
||||||
|
index_number: 0
|
||||||
|
battery_level: 100%
|
||||||
|
clock: 2025/12/24,20:45:30
|
||||||
|
sleep_mode: Off
|
||||||
|
ftp_status: On
|
||||||
|
===========================
|
||||||
|
```
|
||||||
|
|
||||||
|
## Code Changes
|
||||||
|
|
||||||
|
### HTML Changes (Line 60)
|
||||||
|
```html
|
||||||
|
<button onclick="getAllSettings()" style="margin-bottom: 12px; font-weight: bold;">
|
||||||
|
Get ALL Settings
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
### JavaScript Changes (Lines 284-305)
|
||||||
|
```javascript
|
||||||
|
async function getAllSettings() {
|
||||||
|
const unitId = document.getElementById('unitId').value;
|
||||||
|
log('Retrieving all device settings (this may take 10-15 seconds)...');
|
||||||
|
|
||||||
|
const res = await fetch(`/api/nl43/${unitId}/settings`);
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
log(`Get All Settings failed: ${res.status} - ${data.detail || JSON.stringify(data)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display in status area
|
||||||
|
statusEl.textContent = JSON.stringify(data.settings, null, 2);
|
||||||
|
|
||||||
|
// Log summary
|
||||||
|
log('=== ALL DEVICE SETTINGS ===');
|
||||||
|
Object.entries(data.settings).forEach(([key, value]) => {
|
||||||
|
log(`${key}: ${value}`);
|
||||||
|
});
|
||||||
|
log('===========================');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage Flow
|
||||||
|
|
||||||
|
1. **Configure device** using the "Unit Config" section
|
||||||
|
2. **Click "Get ALL Settings"** to retrieve current configuration
|
||||||
|
3. **Review settings** in the Status and Log areas
|
||||||
|
4. **Verify** critical settings match requirements before starting measurements
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
|
||||||
|
✓ **Quick verification** - One click to see all device settings
|
||||||
|
✓ **Pre-measurement check** - Ensure device is configured correctly
|
||||||
|
✓ **Debugging** - Identify misconfigured settings easily
|
||||||
|
✓ **Documentation** - Copy settings from status area for records
|
||||||
|
✓ **Comparison** - Compare settings across multiple devices
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
If the request fails:
|
||||||
|
- Error message is displayed in the log
|
||||||
|
- Status code and details are shown
|
||||||
|
- Previous status display is preserved
|
||||||
|
|
||||||
|
Example error output:
|
||||||
|
```
|
||||||
|
Retrieving all device settings (this may take 10-15 seconds)...
|
||||||
|
Get All Settings failed: 502 - Failed to communicate with device
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing the Feature
|
||||||
|
|
||||||
|
1. **Start the SLMM server**:
|
||||||
|
```bash
|
||||||
|
cd /home/serversdown/slmm
|
||||||
|
uvicorn app.main:app --reload --port 8100
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Open the standalone UI**:
|
||||||
|
```
|
||||||
|
http://localhost:8100
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Configure a device**:
|
||||||
|
- Enter Unit ID (e.g., "nl43-1")
|
||||||
|
- Enter Host IP (e.g., "192.168.1.100")
|
||||||
|
- Enter Port (e.g., "80")
|
||||||
|
- Click "Save Config"
|
||||||
|
|
||||||
|
4. **Test the new button**:
|
||||||
|
- Click "Get ALL Settings"
|
||||||
|
- Wait 10-15 seconds for results
|
||||||
|
- Review settings in Status and Log areas
|
||||||
|
|
||||||
|
## Related Files
|
||||||
|
|
||||||
|
- [templates/index.html](templates/index.html) - Standalone UI (updated)
|
||||||
|
- [app/routers.py](app/routers.py#L728-L761) - Settings endpoint
|
||||||
|
- [app/services.py](app/services.py#L538-L606) - Client implementation
|
||||||
|
- [README.md](README.md) - Main documentation
|
||||||
|
- [SETTINGS_ENDPOINT.md](SETTINGS_ENDPOINT.md) - API documentation
|
||||||
@@ -0,0 +1,770 @@
|
|||||||
|
# NL-43/NL-53 Sound Level Meter - AI Command Reference
|
||||||
|
## Quick Reference for Programming & Development
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 QUICK START - COMMAND SYNTAX
|
||||||
|
|
||||||
|
### Setting Command (Change device settings)
|
||||||
|
```
|
||||||
|
Format: $Command Name,Parameter[CR][LF]
|
||||||
|
Example: $LCD Auto Off,Short[CR][LF]
|
||||||
|
$Backlight,On[CR][LF]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Critical Rules:**
|
||||||
|
- **Prefix:** `$` (auto-added by device, shows command processing status)
|
||||||
|
- **Separator:** `,` (comma required after command name)
|
||||||
|
- **Spaces:** Must preserve exact spacing in command names
|
||||||
|
- **Terminator:** `[CR][LF]` (0x0D 0x0A)
|
||||||
|
- **Case:** Insensitive (LCD = lcd = Lcd)
|
||||||
|
- **Wait:** Minimum 1 second after receiving `$` response before next command
|
||||||
|
|
||||||
|
### Request Command (Get device status/data)
|
||||||
|
```
|
||||||
|
Format: Command Name?[CR][LF]
|
||||||
|
Example: Battery Level?[CR][LF]
|
||||||
|
System Version?[CR][LF]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Critical Rules:**
|
||||||
|
- **Suffix:** `?` after command name
|
||||||
|
- **No prefix:** No `$` for requests
|
||||||
|
- **Terminator:** `[CR][LF]`
|
||||||
|
- **Case:** Insensitive
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 RESULT CODES
|
||||||
|
|
||||||
|
Every command returns a result code:
|
||||||
|
|
||||||
|
```
|
||||||
|
Format: R+####
|
||||||
|
```
|
||||||
|
|
||||||
|
| Code | Status | Meaning |
|
||||||
|
|------|--------|---------|
|
||||||
|
| `R+0000` | ✅ Success | Command executed successfully |
|
||||||
|
| `R+0001` | ❌ Command Error | Command not recognized |
|
||||||
|
| `R+0002` | ❌ Parameter Error | Wrong parameter format or count |
|
||||||
|
| `R+0003` | ❌ Specification Error | Used set when should request (or vice versa) |
|
||||||
|
| `R+0004` | ❌ Status Error | Device not in correct state for this command |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⏱️ TIMING & CONTROL VALUES
|
||||||
|
|
||||||
|
| Operation | Time | Notes |
|
||||||
|
|-----------|------|-------|
|
||||||
|
| Device response time | < 3 seconds | Max time for device to respond |
|
||||||
|
| Between characters | < 100 ms | When sending multi-char strings |
|
||||||
|
| Device idle time | < 200 ms | After device sends data |
|
||||||
|
| **Recommended wait** | **≥ 1 second** | **After `$` before next command** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 COMMUNICATION CODES
|
||||||
|
|
||||||
|
| Code | Hex | Purpose |
|
||||||
|
|------|-----|---------|
|
||||||
|
| `[CR]` | 0x0D | First terminator (Carriage Return) |
|
||||||
|
| `[LF]` | 0x0A | Second terminator (Line Feed) |
|
||||||
|
| `[SUB]` | 0x1A | Stop request |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 COMMAND CATEGORIES & FULL LIST
|
||||||
|
|
||||||
|
### Communication
|
||||||
|
| Command | Type | Function |
|
||||||
|
|---------|------|----------|
|
||||||
|
| `Echo` | S/R | Communication echo ON/OFF |
|
||||||
|
|
||||||
|
### System
|
||||||
|
| Command | Type | Function |
|
||||||
|
|---------|------|----------|
|
||||||
|
| `System Version` | R | Get firmware version |
|
||||||
|
| `Type` | R | Get device type (NL-43/NL-53) |
|
||||||
|
| `Serial Number` | R | Get device serial number |
|
||||||
|
| `Clock` | S/R | Set/get current time |
|
||||||
|
| `Language` | S/R | Set/get display language |
|
||||||
|
| `Index Number` | S/R | Set/get index number |
|
||||||
|
| `Key Lock` | S/R | Enable/disable key lock |
|
||||||
|
|
||||||
|
### Display
|
||||||
|
| Command | Type | Function |
|
||||||
|
|---------|------|----------|
|
||||||
|
| `Backlight` | S/R | Control backlight ON/OFF |
|
||||||
|
| `Backlight Auto Off` | S/R | Set auto-off time for backlight |
|
||||||
|
| `LCD` | S/R | Control LCD ON/OFF |
|
||||||
|
| `LCD Auto Off` | S/R | Set auto-off time for LCD |
|
||||||
|
| `Backlight Brightness` | S/R | Set backlight brightness |
|
||||||
|
| `Battery Type` | S/R | Set battery type |
|
||||||
|
| `Battery Level` | R | Get current battery level |
|
||||||
|
| `Output Level Range Upper` | S/R | Set upper limit of bar graph |
|
||||||
|
| `Output Level Range Lower` | S/R | Set lower limit of bar graph |
|
||||||
|
| `Display Leq` | S/R | Enable/disable Leq display |
|
||||||
|
| `Display LE` | S/R | Enable/disable LE display |
|
||||||
|
| `Display Lpeak` | S/R | Enable/disable Lpeak display |
|
||||||
|
| `Display Lmax` | S/R | Enable/disable Lmax display |
|
||||||
|
| `Display Lmin` | S/R | Enable/disable Lmin display |
|
||||||
|
| `Display LN1` | S/R | Enable/disable L5 display |
|
||||||
|
| `Display LN2` | S/R | Enable/disable L10 display |
|
||||||
|
| `Display LN3` | S/R | Enable/disable L50 display |
|
||||||
|
| `Display LN4` | S/R | Enable/disable L90 display |
|
||||||
|
| `Display LN5` | S/R | Enable/disable L95 display |
|
||||||
|
| `Time Level Time Scale` | S/R | Set time-level time scale |
|
||||||
|
|
||||||
|
### SD Card
|
||||||
|
| Command | Type | Function |
|
||||||
|
|---------|------|----------|
|
||||||
|
| `SD Card Total Size` | R | Get total SD card capacity |
|
||||||
|
| `SD Card Free Size` | R | Get SD card free space |
|
||||||
|
| `SD Card Percentage` | R | Get SD card free space ratio |
|
||||||
|
|
||||||
|
### Measurement
|
||||||
|
| Command | Type | Function |
|
||||||
|
|---------|------|----------|
|
||||||
|
| `Frequency Weighting (Main)` | S/R | Set main frequency weighting (A/C/Z) |
|
||||||
|
| `Frequency Weighting (Sub1/2/3)` | S/R | Set sub frequency weighting |
|
||||||
|
| `Time Weighting (Main)` | S/R | Set main time weighting (F/S/I) |
|
||||||
|
| `Time Weighting (Sub1/2/3)` | S/R | Set sub time weighting |
|
||||||
|
| `Measure` | S/R | Start/stop measurement |
|
||||||
|
| `Pause` | S/R | Pause measurement |
|
||||||
|
| `Windscreen Correction` | S/R | Enable/disable windscreen correction |
|
||||||
|
|
||||||
|
### Storage
|
||||||
|
| Command | Type | Function |
|
||||||
|
|---------|------|----------|
|
||||||
|
| `Store Mode` | S/R | Set storage mode (Manual/Auto) |
|
||||||
|
| `Store Name` | S/R | Set storage name |
|
||||||
|
| `Manual Address` | S/R | Set manual storage address |
|
||||||
|
| `Manual Store` | S | Execute manual storage |
|
||||||
|
| `Overwrite` | S/R | Enable/disable storage overwriting |
|
||||||
|
| `Measurement Time Preset Manual` | S/R | Set manual measurement time |
|
||||||
|
| `Measurement Time Preset Auto` | S/R | Set auto measurement time |
|
||||||
|
|
||||||
|
### I/O
|
||||||
|
| Command | Type | Function |
|
||||||
|
|---------|------|----------|
|
||||||
|
| `AC OUT` | S/R | Set AC output ON/OFF |
|
||||||
|
| `DC OUT` | S/R | Set DC output ON/OFF |
|
||||||
|
| `Baud Rate` | S/R | Set RS-232C baud rate |
|
||||||
|
| `USB Class` | S/R | Set USB communication mode |
|
||||||
|
| `Ethernet` | S/R | Enable/disable LAN function |
|
||||||
|
| `Ethernet DHCP` | S/R | Enable/disable DHCP |
|
||||||
|
| `Ethernet IP` | S/R | Set IP address |
|
||||||
|
| `Ethernet Subnet` | S/R | Set subnet mask |
|
||||||
|
| `Ethernet Gateway` | S/R | Set default gateway |
|
||||||
|
| `Web` | S/R | Enable/disable web application |
|
||||||
|
| `FTP` | S/R | Enable/disable FTP |
|
||||||
|
| `TCP` | S/R | Enable/disable TCP communication |
|
||||||
|
|
||||||
|
### Data Output
|
||||||
|
| Command | Type | Function |
|
||||||
|
|---------|------|----------|
|
||||||
|
| `DOD` | R | Output current displayed value |
|
||||||
|
| `DRD` | R | Continuous data output |
|
||||||
|
| `DRD?status` | R | Continuous output with status |
|
||||||
|
| `DLC` | R | Output final calculation result |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💡 DETAILED COMMAND SYNTAX
|
||||||
|
|
||||||
|
### Echo (Communication Echo)
|
||||||
|
**Purpose:** Enable/disable echo of sent commands
|
||||||
|
|
||||||
|
**Setting:**
|
||||||
|
```
|
||||||
|
$Echo,Off[CR][LF] # Disable echo
|
||||||
|
$Echo,On[CR][LF] # Enable echo
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```
|
||||||
|
Echo?[CR][LF]
|
||||||
|
Response: Off | On
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### System Version
|
||||||
|
**Purpose:** Get firmware version
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```
|
||||||
|
System Version?[CR][LF]
|
||||||
|
Response: "xx.xx.xxxx" # Example: "01.02.0034"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** No setting command available
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Type
|
||||||
|
**Purpose:** Get device type
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```
|
||||||
|
Type?[CR][LF]
|
||||||
|
Response: "NL-43" | "NL-53"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** No setting command available
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Serial Number
|
||||||
|
**Purpose:** Get device serial number
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```
|
||||||
|
Serial Number?[CR][LF]
|
||||||
|
Response: "xxxxxxxx" # 8-digit number (00000000-99999999)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Clock
|
||||||
|
**Purpose:** Set/get current date and time
|
||||||
|
|
||||||
|
**Setting:**
|
||||||
|
```
|
||||||
|
$Clock,2025/12/24 14:30:00[CR][LF]
|
||||||
|
|
||||||
|
Format: $Clock,YYYY/MM/DD HH:MM:SS[CR][LF]
|
||||||
|
- Year: 2023-2079
|
||||||
|
- Month: 1-12
|
||||||
|
- Day: 1-31
|
||||||
|
- Hour: 0-23
|
||||||
|
- Minute: 0-59
|
||||||
|
- Second: 0-59
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```
|
||||||
|
Clock?[CR][LF]
|
||||||
|
Response: 2025/12/24 14:30:00
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Language
|
||||||
|
**Purpose:** Set/get display language
|
||||||
|
|
||||||
|
**Setting:**
|
||||||
|
```
|
||||||
|
$Language,English[CR][LF]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
- Japanese
|
||||||
|
- English
|
||||||
|
- Germany
|
||||||
|
- Spanish
|
||||||
|
- French
|
||||||
|
- Simplified Chinese
|
||||||
|
- Korean
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```
|
||||||
|
Language?[CR][LF]
|
||||||
|
Response: English
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Index Number
|
||||||
|
**Purpose:** Set/get index number
|
||||||
|
|
||||||
|
**Setting:**
|
||||||
|
```
|
||||||
|
$Index Number,0042[CR][LF]
|
||||||
|
|
||||||
|
Format: 0000-9999 (4 digits)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```
|
||||||
|
Index Number?[CR][LF]
|
||||||
|
Response: 0042
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Key Lock
|
||||||
|
**Purpose:** Enable/disable key lock
|
||||||
|
|
||||||
|
**Setting:**
|
||||||
|
```
|
||||||
|
$Key Lock,Off[CR][LF]
|
||||||
|
$Key Lock,On[CR][LF]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```
|
||||||
|
Key Lock?[CR][LF]
|
||||||
|
Response: Off | On
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Backlight
|
||||||
|
**Purpose:** Control backlight ON/OFF
|
||||||
|
|
||||||
|
**Setting:**
|
||||||
|
```
|
||||||
|
$Backlight,Off[CR][LF]
|
||||||
|
$Backlight,On[CR][LF]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```
|
||||||
|
Backlight?[CR][LF]
|
||||||
|
Response: Off | On
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Backlight Auto Off
|
||||||
|
**Purpose:** Set backlight auto-off timer
|
||||||
|
|
||||||
|
**Setting:**
|
||||||
|
```
|
||||||
|
$Backlight Auto Off,Short[CR][LF]
|
||||||
|
$Backlight Auto Off,Long[CR][LF]
|
||||||
|
$Backlight Auto Off,Off[CR][LF]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
- Short: 30 seconds
|
||||||
|
- Long: 60 seconds
|
||||||
|
- Off: Always on
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```
|
||||||
|
Backlight Auto Off?[CR][LF]
|
||||||
|
Response: Short | Long | Off
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### LCD Auto Off
|
||||||
|
**Purpose:** Set LCD auto-off timer
|
||||||
|
|
||||||
|
**Setting:**
|
||||||
|
```
|
||||||
|
$LCD Auto Off,Short[CR][LF]
|
||||||
|
$LCD Auto Off,Long[CR][LF]
|
||||||
|
$LCD Auto Off,Off[CR][LF]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
- Short: 5 minutes
|
||||||
|
- Long: 10 minutes
|
||||||
|
- Off: Always on
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```
|
||||||
|
LCD Auto Off?[CR][LF]
|
||||||
|
Response: Short | Long | Off
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Backlight Brightness
|
||||||
|
**Purpose:** Set backlight brightness
|
||||||
|
|
||||||
|
**Setting:**
|
||||||
|
```
|
||||||
|
$Backlight Brightness,1[CR][LF]
|
||||||
|
|
||||||
|
Range: 1-4 (1=dimmest, 4=brightest)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```
|
||||||
|
Backlight Brightness?[CR][LF]
|
||||||
|
Response: 1 | 2 | 3 | 4
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Battery Type
|
||||||
|
**Purpose:** Set battery type
|
||||||
|
|
||||||
|
**Setting:**
|
||||||
|
```
|
||||||
|
$Battery Type,Alkaline[CR][LF]
|
||||||
|
$Battery Type,NiMH[CR][LF]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
- Alkaline: Alkaline batteries
|
||||||
|
- NiMH: Nickel-metal hydride batteries
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```
|
||||||
|
Battery Type?[CR][LF]
|
||||||
|
Response: Alkaline | NiMH
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Battery Level
|
||||||
|
**Purpose:** Get current battery level
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```
|
||||||
|
Battery Level?[CR][LF]
|
||||||
|
Response: 0-100 # Percentage (0=empty, 100=full)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** Read-only command
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Measure
|
||||||
|
**Purpose:** Start/stop measurement
|
||||||
|
|
||||||
|
**Setting:**
|
||||||
|
```
|
||||||
|
$Measure,Run[CR][LF] # Start measurement
|
||||||
|
$Measure,Stop[CR][LF] # Stop measurement
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```
|
||||||
|
Measure?[CR][LF]
|
||||||
|
Response: Run | Stop
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Pause
|
||||||
|
**Purpose:** Pause/resume measurement
|
||||||
|
|
||||||
|
**Setting:**
|
||||||
|
```
|
||||||
|
$Pause,On[CR][LF] # Pause measurement
|
||||||
|
$Pause,Off[CR][LF] # Resume measurement
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```
|
||||||
|
Pause?[CR][LF]
|
||||||
|
Response: On | Off
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Frequency Weighting (Main)
|
||||||
|
**Purpose:** Set main frequency weighting
|
||||||
|
|
||||||
|
**Setting:**
|
||||||
|
```
|
||||||
|
$Frequency Weighting,A[CR][LF]
|
||||||
|
$Frequency Weighting,C[CR][LF]
|
||||||
|
$Frequency Weighting,Z[CR][LF]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
- A: A-weighting (most common)
|
||||||
|
- C: C-weighting
|
||||||
|
- Z: Z-weighting (flat response)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```
|
||||||
|
Frequency Weighting?[CR][LF]
|
||||||
|
Response: A | C | Z
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Time Weighting (Main)
|
||||||
|
**Purpose:** Set main time weighting
|
||||||
|
|
||||||
|
**Setting:**
|
||||||
|
```
|
||||||
|
$Time Weighting,F[CR][LF]
|
||||||
|
$Time Weighting,S[CR][LF]
|
||||||
|
$Time Weighting,I[CR][LF]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
- F: Fast (125ms)
|
||||||
|
- S: Slow (1000ms)
|
||||||
|
- I: Impulse
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```
|
||||||
|
Time Weighting?[CR][LF]
|
||||||
|
Response: F | S | I
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Baud Rate
|
||||||
|
**Purpose:** Set RS-232C communication speed
|
||||||
|
|
||||||
|
**Setting:**
|
||||||
|
```
|
||||||
|
$Baud Rate,9600[CR][LF]
|
||||||
|
$Baud Rate,19200[CR][LF]
|
||||||
|
$Baud Rate,38400[CR][LF]
|
||||||
|
$Baud Rate,57600[CR][LF]
|
||||||
|
$Baud Rate,115200[CR][LF]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```
|
||||||
|
Baud Rate?[CR][LF]
|
||||||
|
Response: 9600 | 19200 | 38400 | 57600 | 115200
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** Default is usually 38400
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Ethernet IP
|
||||||
|
**Purpose:** Set IP address for LAN connection
|
||||||
|
|
||||||
|
**Setting:**
|
||||||
|
```
|
||||||
|
$Ethernet IP,192.168.1.100[CR][LF]
|
||||||
|
|
||||||
|
Format: xxx.xxx.xxx.xxx (0-255 for each octet)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```
|
||||||
|
Ethernet IP?[CR][LF]
|
||||||
|
Response: 192.168.1.100
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Ethernet DHCP
|
||||||
|
**Purpose:** Enable/disable automatic IP address assignment
|
||||||
|
|
||||||
|
**Setting:**
|
||||||
|
```
|
||||||
|
$Ethernet DHCP,Off[CR][LF] # Manual IP
|
||||||
|
$Ethernet DHCP,On[CR][LF] # Automatic IP
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```
|
||||||
|
Ethernet DHCP?[CR][LF]
|
||||||
|
Response: Off | On
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### DOD (Display Output Data)
|
||||||
|
**Purpose:** Get current displayed measurement value
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```
|
||||||
|
DOD?[CR][LF]
|
||||||
|
|
||||||
|
Response format:
|
||||||
|
ddd.d,cc.c,aa.a
|
||||||
|
- ddd.d: Main display value
|
||||||
|
- cc.c: Sub display value
|
||||||
|
- aa.a: Additional value
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example Response:**
|
||||||
|
```
|
||||||
|
075.2,072.1,080.5
|
||||||
|
# 75.2 dB main, 72.1 dB sub, 80.5 dB peak
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### DRD (Data Real-time Display)
|
||||||
|
**Purpose:** Continuous output of measurement data
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```
|
||||||
|
DRD?[CR][LF]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Stop continuous output:**
|
||||||
|
```
|
||||||
|
[SUB] (0x1A)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:** Continuous stream of measurement values until stop requested
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### USB Class
|
||||||
|
**Purpose:** Set USB communication mode
|
||||||
|
|
||||||
|
**Setting:**
|
||||||
|
```
|
||||||
|
$USB Class,Serial[CR][LF] # Serial communication mode
|
||||||
|
$USB Class,Mass Storage[CR][LF] # Mass storage mode (file access)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```
|
||||||
|
USB Class?[CR][LF]
|
||||||
|
Response: Serial | Mass Storage
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important:** When in Mass Storage mode, communication commands are blocked
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 COMMON PROGRAMMING PATTERNS
|
||||||
|
|
||||||
|
### Basic Connection Test
|
||||||
|
```python
|
||||||
|
# 1. Check device type
|
||||||
|
send("Type?[CR][LF]")
|
||||||
|
wait_for_response() # Should get "NL-43" or "NL-53"
|
||||||
|
|
||||||
|
# 2. Get version
|
||||||
|
send("System Version?[CR][LF]")
|
||||||
|
wait_for_response() # e.g., "01.02.0034"
|
||||||
|
|
||||||
|
# 3. Enable echo for debugging
|
||||||
|
send("$Echo,On[CR][LF]")
|
||||||
|
wait_for_response() # Should get "R+0000"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Start Basic Measurement
|
||||||
|
```python
|
||||||
|
# 1. Set frequency weighting to A
|
||||||
|
send("$Frequency Weighting,A[CR][LF]")
|
||||||
|
wait_1_second()
|
||||||
|
|
||||||
|
# 2. Set time weighting to Fast
|
||||||
|
send("$Time Weighting,F[CR][LF]")
|
||||||
|
wait_1_second()
|
||||||
|
|
||||||
|
# 3. Start measurement
|
||||||
|
send("$Measure,Run[CR][LF]")
|
||||||
|
wait_1_second()
|
||||||
|
|
||||||
|
# 4. Get current reading
|
||||||
|
send("DOD?[CR][LF]")
|
||||||
|
reading = wait_for_response()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configure Network
|
||||||
|
```python
|
||||||
|
# 1. Disable DHCP for static IP
|
||||||
|
send("$Ethernet DHCP,Off[CR][LF]")
|
||||||
|
wait_1_second()
|
||||||
|
|
||||||
|
# 2. Set IP address
|
||||||
|
send("$Ethernet IP,192.168.1.100[CR][LF]")
|
||||||
|
wait_1_second()
|
||||||
|
|
||||||
|
# 3. Set subnet
|
||||||
|
send("$Ethernet Subnet,255.255.255.0[CR][LF]")
|
||||||
|
wait_1_second()
|
||||||
|
|
||||||
|
# 4. Set gateway
|
||||||
|
send("$Ethernet Gateway,192.168.1.1[CR][LF]")
|
||||||
|
wait_1_second()
|
||||||
|
|
||||||
|
# 5. Enable Ethernet
|
||||||
|
send("$Ethernet,On[CR][LF]")
|
||||||
|
wait_1_second()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Continuous Data Acquisition
|
||||||
|
```python
|
||||||
|
# Start continuous output
|
||||||
|
send("DRD?[CR][LF]")
|
||||||
|
|
||||||
|
# Read data in loop
|
||||||
|
while acquiring:
|
||||||
|
data = read_line()
|
||||||
|
process(data)
|
||||||
|
|
||||||
|
# Stop when done
|
||||||
|
send("[SUB]") # 0x1A
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ COMMON ERRORS & TROUBLESHOOTING
|
||||||
|
|
||||||
|
### R+0001 - Command Not Recognized
|
||||||
|
- Check spelling (spaces matter!)
|
||||||
|
- Verify command exists for your model
|
||||||
|
- Check for extra/missing spaces in command name
|
||||||
|
|
||||||
|
### R+0002 - Parameter Error
|
||||||
|
- Verify parameter format matches spec
|
||||||
|
- Check parameter value is in valid range
|
||||||
|
- Ensure comma separator is present
|
||||||
|
|
||||||
|
### R+0003 - Specification Error
|
||||||
|
- Using `$` prefix on request command
|
||||||
|
- Using `?` on setting command
|
||||||
|
- Command type mismatch
|
||||||
|
|
||||||
|
### R+0004 - Status Error
|
||||||
|
- Device busy (measuring, storing, etc.)
|
||||||
|
- Stop measurement before changing settings
|
||||||
|
- Check device is powered on and ready
|
||||||
|
- May need to wait longer between commands
|
||||||
|
|
||||||
|
### No Response
|
||||||
|
- Check baud rate matches (default 38400)
|
||||||
|
- Verify `[CR][LF]` line endings
|
||||||
|
- Wait minimum 1 second between commands
|
||||||
|
- Check cable connection
|
||||||
|
- Verify USB Class mode is "Serial" not "Mass Storage"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 NOTES FOR AI ASSISTANTS
|
||||||
|
|
||||||
|
**When helping with code:**
|
||||||
|
1. Always include the `$` prefix for setting commands
|
||||||
|
2. Always include `?` suffix for request commands
|
||||||
|
3. Always include `[CR][LF]` terminators
|
||||||
|
4. Always wait ≥1 second between commands
|
||||||
|
5. Check for `R+0000` success code after settings
|
||||||
|
6. Handle error codes appropriately
|
||||||
|
|
||||||
|
**For serial communication:**
|
||||||
|
- Use 8 data bits, no parity, 1 stop bit (8N1)
|
||||||
|
- Default baud: 38400
|
||||||
|
- Hardware flow control: None
|
||||||
|
- Hex codes: CR=0x0D, LF=0x0A, SUB=0x1A
|
||||||
|
|
||||||
|
**For network communication:**
|
||||||
|
- TCP port: Varies by configuration
|
||||||
|
- Ensure device is in correct USB/Ethernet mode
|
||||||
|
- Commands are identical over serial/network
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📖 DOCUMENT SOURCE
|
||||||
|
|
||||||
|
This reference was extracted from:
|
||||||
|
- **Document:** NL-43/NL-53 Communication Guide
|
||||||
|
- **Filename:** NL-43_NL-53_Communication_Guide_66132.pdf
|
||||||
|
- **Manufacturer:** RION Co., Ltd.
|
||||||
|
- **Full manual available at:** https://rion-sv.com/nl-43_53_63/manual/
|
||||||
|
|
||||||
|
**Document Coverage:**
|
||||||
|
- Full communication protocol specification
|
||||||
|
- RS-232C and USB serial connection
|
||||||
|
- Ethernet/LAN configuration
|
||||||
|
- Complete command reference
|
||||||
|
- Data output formats
|
||||||
|
- Timing specifications
|
||||||
|
|
||||||
|
For complete technical details, measurement specifications, and advanced features, refer to the full PDF documentation.
|
||||||
@@ -1,823 +0,0 @@
|
|||||||
"""
|
|
||||||
event_forwarder.py — forward Thor (Micromate Series IV) IDFH/IDFW event
|
|
||||||
files to a seismo-relay SFM server.
|
|
||||||
|
|
||||||
Walks the same `THORDATA_PATH/<Project>/<Unit>/` tree the heartbeat path
|
|
||||||
scans. For each event binary that hasn't been forwarded yet, pairs it
|
|
||||||
with its `<unit>/TXT/<basename>.txt` ASCII report (when available) and
|
|
||||||
POSTs both to seismo-relay's `/db/import/idf_file` endpoint as one
|
|
||||||
multipart request.
|
|
||||||
|
|
||||||
This is a port of `series3-watcher/event_forwarder.py` adapted for the
|
|
||||||
Thor file layout. Key differences from the series3 forwarder:
|
|
||||||
|
|
||||||
- **Filenames are literal `<SERIAL>_<YYYYMMDDHHMMSS>.IDFH|.IDFW`** —
|
|
||||||
no base36 stem; the serial is right there in the prefix.
|
|
||||||
- **TXT sidecars live in a `TXT/` subfolder** next to the binaries
|
|
||||||
(Thor's exporter writes them there, not alongside the binary).
|
|
||||||
- **IDFH and IDFW are forwarded as independent events.** Each has
|
|
||||||
its own sha256-keyed state entry and its own POST. A single
|
|
||||||
timestamp can produce both a histogram (IDFH) and a waveform (IDFW),
|
|
||||||
and the seismo-relay endpoint dedupes on (serial, timestamp, kind),
|
|
||||||
so treating them as separate rows is the right model.
|
|
||||||
|
|
||||||
Design notes
|
|
||||||
────────────
|
|
||||||
- **stdlib only.** Matches the rest of the watcher (`urllib.request`).
|
|
||||||
Multipart encoding is hand-rolled.
|
|
||||||
- **Idempotent across restarts.** Forwarded files are tracked by
|
|
||||||
sha256 in a JSON state file (default: `<log_dir>/thor_forwarded.json`).
|
|
||||||
Re-scanning the watch tree doesn't re-POST anything.
|
|
||||||
- **Default-off.** Callers must enable via config
|
|
||||||
(`sfm_forward_enabled=true` + `sfm_url=...`). Existing 0.2.x
|
|
||||||
deployments that auto-update stay non-forwarding until an operator
|
|
||||||
flips the switch.
|
|
||||||
- **Quiescence guard.** Files modified within the last few seconds
|
|
||||||
are skipped — Thor writes the .txt after the binary, so we wait
|
|
||||||
until both look stable before forwarding.
|
|
||||||
- **Best-effort report pairing.** When the .txt hasn't appeared yet
|
|
||||||
but the binary is older than `missing_report_grace_seconds`, the
|
|
||||||
binary is forwarded alone (seismo-relay accepts that and just skips
|
|
||||||
the rich fields — we'd rather get the binary indexed than block
|
|
||||||
forever waiting for a TXT that may never arrive, e.g. operator
|
|
||||||
hasn't enabled the TXT export in Thor).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import time
|
|
||||||
import urllib.error
|
|
||||||
import urllib.request
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
|
||||||
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Default tuning. All overridable via config.json sfm_* keys.
|
|
||||||
DEFAULT_QUIESCENCE_SECONDS = 5
|
|
||||||
DEFAULT_MISSING_REPORT_GRACE_SECONDS = 60
|
|
||||||
DEFAULT_HTTP_TIMEOUT = 60.0
|
|
||||||
STATE_SCHEMA_VERSION = 1
|
|
||||||
|
|
||||||
|
|
||||||
# ── Filename matching ─────────────────────────────────────────────────────────
|
|
||||||
#
|
|
||||||
# Thor (Micromate Series IV) filename scheme:
|
|
||||||
# <SERIAL>_<YYYYMMDDHHMMSS>.<KIND>
|
|
||||||
# where SERIAL is the literal device serial (e.g. UM11719, BE9439),
|
|
||||||
# and KIND is IDFH (histogram) or IDFW (waveform).
|
|
||||||
#
|
|
||||||
# Examples:
|
|
||||||
# UM11719_20231219163444.IDFH
|
|
||||||
# UM11719_20231219162723.IDFW
|
|
||||||
# BE9439_20200713124251.IDFH
|
|
||||||
_EVENT_FILENAME_RE = re.compile(
|
|
||||||
r"^([A-Z]{2}\d+)_(\d{14})\.(IDFH|IDFW)$",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Filenames we explicitly skip even if they look event-shaped.
|
|
||||||
_NON_EVENT_EXTS = {
|
|
||||||
".mlg", # monitor-log files (separate heartbeat path)
|
|
||||||
".txt", # ASCII reports — paired in, not primary
|
|
||||||
".csv", # operator-facing derivative
|
|
||||||
".html", # operator-facing derivative
|
|
||||||
".pdf", # operator-facing derivative
|
|
||||||
".xml", # operator-facing derivative
|
|
||||||
".cdb", # IDFW.CDB cache-database variant — skip
|
|
||||||
".log",
|
|
||||||
".ini",
|
|
||||||
".json",
|
|
||||||
".sfm.json",
|
|
||||||
".bak",
|
|
||||||
".tmp",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def is_event_binary(path: str) -> bool:
|
|
||||||
"""Return True if `path`'s basename looks like a Thor event binary."""
|
|
||||||
name = os.path.basename(path)
|
|
||||||
lname = name.lower()
|
|
||||||
# Explicit reject for compound extensions like .IDFW.CDB
|
|
||||||
if lname.endswith(".idfw.cdb") or lname.endswith(".idfh.cdb"):
|
|
||||||
return False
|
|
||||||
if not _EVENT_FILENAME_RE.match(name):
|
|
||||||
return False
|
|
||||||
ext = os.path.splitext(name)[1].lower()
|
|
||||||
if ext in _NON_EVENT_EXTS:
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def parse_event_filename(name: str) -> Optional[Tuple[str, datetime, str]]:
|
|
||||||
"""Parse `<SERIAL>_<YYYYMMDDHHMMSS>.<KIND>` -> (serial, timestamp, kind).
|
|
||||||
|
|
||||||
`kind` is the upper-case extension without the dot — "IDFH" or "IDFW".
|
|
||||||
Returns None if the filename doesn't match.
|
|
||||||
"""
|
|
||||||
m = _EVENT_FILENAME_RE.match(name)
|
|
||||||
if not m:
|
|
||||||
return None
|
|
||||||
serial = m.group(1).upper()
|
|
||||||
try:
|
|
||||||
ts = datetime.strptime(m.group(2), "%Y%m%d%H%M%S")
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
kind = m.group(3).upper()
|
|
||||||
return serial, ts, kind
|
|
||||||
|
|
||||||
|
|
||||||
def idf_report_name(binary_name: str) -> str:
|
|
||||||
"""Thor TXT-export convention: append `.txt` to the binary basename.
|
|
||||||
|
|
||||||
UM11719_20231219163444.IDFH → UM11719_20231219163444.IDFH.txt
|
|
||||||
"""
|
|
||||||
return binary_name + ".txt"
|
|
||||||
|
|
||||||
|
|
||||||
def idf_report_path(binary_path: str) -> str:
|
|
||||||
"""Compute the expected TXT sidecar path for a Thor event binary.
|
|
||||||
|
|
||||||
Thor's TXT exporter writes sidecars into a `TXT/` subfolder of the
|
|
||||||
unit directory (verified against captured example-data). The
|
|
||||||
returned path is the *expected* location — caller is responsible
|
|
||||||
for checking that it actually exists.
|
|
||||||
"""
|
|
||||||
unit_dir = os.path.dirname(binary_path)
|
|
||||||
name = os.path.basename(binary_path)
|
|
||||||
return os.path.join(unit_dir, "TXT", idf_report_name(name))
|
|
||||||
|
|
||||||
|
|
||||||
def is_histogram_event(filename: str) -> bool:
|
|
||||||
"""True if this is an .IDFH (histogram) event. Used purely for
|
|
||||||
log clarity — Thor doesn't always export a TXT for histograms, so
|
|
||||||
a missing-report warning is suppressed for them."""
|
|
||||||
name = os.path.basename(filename)
|
|
||||||
return name.lower().endswith(".idfh")
|
|
||||||
|
|
||||||
|
|
||||||
def serial_from_filename(name: str) -> Optional[str]:
|
|
||||||
"""Extract the serial-number prefix from a Thor event filename."""
|
|
||||||
parsed = parse_event_filename(name)
|
|
||||||
if parsed is None:
|
|
||||||
return None
|
|
||||||
return parsed[0]
|
|
||||||
|
|
||||||
|
|
||||||
# ── State file ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
class ForwardState:
|
|
||||||
"""Idempotency record: which event files have we already forwarded?
|
|
||||||
|
|
||||||
State file format (JSON):
|
|
||||||
|
|
||||||
{
|
|
||||||
"version": 1,
|
|
||||||
"forwarded": {
|
|
||||||
"<sha256>": {
|
|
||||||
"filename": "UM11719_20231219163444.IDFW",
|
|
||||||
"size": 8800,
|
|
||||||
"forwarded_at": "2026-05-19T...Z",
|
|
||||||
"had_report": true
|
|
||||||
},
|
|
||||||
...
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Keyed by sha256 (not filename) so identical content is recognised
|
|
||||||
as already-forwarded even if the file moved or got renamed.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, path: str):
|
|
||||||
self.path = path
|
|
||||||
self._data: Dict[str, Any] = {"version": STATE_SCHEMA_VERSION, "forwarded": {}}
|
|
||||||
self._load()
|
|
||||||
|
|
||||||
def _load(self) -> None:
|
|
||||||
try:
|
|
||||||
with open(self.path, "r", encoding="utf-8") as f:
|
|
||||||
d = json.load(f)
|
|
||||||
if not isinstance(d, dict):
|
|
||||||
raise ValueError("state file root is not an object")
|
|
||||||
if d.get("version") != STATE_SCHEMA_VERSION:
|
|
||||||
log.warning(
|
|
||||||
"forward state version mismatch (got %r, want %d) — starting fresh",
|
|
||||||
d.get("version"), STATE_SCHEMA_VERSION,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
forwarded = d.get("forwarded")
|
|
||||||
if isinstance(forwarded, dict):
|
|
||||||
self._data["forwarded"] = forwarded
|
|
||||||
except FileNotFoundError:
|
|
||||||
pass
|
|
||||||
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
||||||
log.warning("failed to load forward state from %s: %s", self.path, exc)
|
|
||||||
|
|
||||||
def _save(self) -> None:
|
|
||||||
tmp = self.path + ".tmp"
|
|
||||||
try:
|
|
||||||
d = os.path.dirname(self.path)
|
|
||||||
if d and not os.path.isdir(d):
|
|
||||||
os.makedirs(d, exist_ok=True)
|
|
||||||
with open(tmp, "w", encoding="utf-8") as f:
|
|
||||||
json.dump(self._data, f, indent=2, sort_keys=True)
|
|
||||||
f.flush()
|
|
||||||
os.fsync(f.fileno())
|
|
||||||
os.replace(tmp, self.path)
|
|
||||||
except OSError as exc:
|
|
||||||
log.warning("failed to save forward state to %s: %s", self.path, exc)
|
|
||||||
|
|
||||||
def is_forwarded(self, sha256: str) -> bool:
|
|
||||||
return sha256 in self._data["forwarded"]
|
|
||||||
|
|
||||||
def status(self, sha256: str) -> Optional[bool]:
|
|
||||||
"""Return forwarding status for *sha256*.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
None — never forwarded. Eligible for a fresh forward.
|
|
||||||
True — forwarded successfully with its paired report.
|
|
||||||
NOT a candidate for re-forward.
|
|
||||||
False — forwarded WITHOUT its paired `.txt`. Eligible for
|
|
||||||
re-forward IF the TXT now exists, so seismo-relay's
|
|
||||||
upsert refreshes the DB row with authoritative
|
|
||||||
device-side values.
|
|
||||||
|
|
||||||
Legacy entries without a `had_report` key default to True so
|
|
||||||
an upgrade doesn't unexpectedly re-forward every entry.
|
|
||||||
"""
|
|
||||||
entry = self._data["forwarded"].get(sha256)
|
|
||||||
if entry is None:
|
|
||||||
return None
|
|
||||||
return bool(entry.get("had_report", True))
|
|
||||||
|
|
||||||
def mark_forwarded(
|
|
||||||
self,
|
|
||||||
sha256: str,
|
|
||||||
filename: str,
|
|
||||||
size: int,
|
|
||||||
had_report: bool = True,
|
|
||||||
) -> None:
|
|
||||||
"""Record a successful forward.
|
|
||||||
|
|
||||||
Set `had_report=False` when the forward shipped the binary
|
|
||||||
without its paired ASCII report. Such entries are re-checked
|
|
||||||
on subsequent scans and re-forwarded once the TXT appears.
|
|
||||||
|
|
||||||
Idempotent: re-marking an existing sha256 with `had_report=True`
|
|
||||||
is the explicit promotion path used when a re-pair succeeds.
|
|
||||||
"""
|
|
||||||
self._data["forwarded"][sha256] = {
|
|
||||||
"filename": filename,
|
|
||||||
"size": size,
|
|
||||||
"forwarded_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
|
||||||
"had_report": had_report,
|
|
||||||
}
|
|
||||||
self._save()
|
|
||||||
|
|
||||||
def count(self) -> int:
|
|
||||||
return len(self._data["forwarded"])
|
|
||||||
|
|
||||||
|
|
||||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def sha256_of_file(path: str) -> str:
|
|
||||||
h = hashlib.sha256()
|
|
||||||
with open(path, "rb") as f:
|
|
||||||
for chunk in iter(lambda: f.read(65536), b""):
|
|
||||||
h.update(chunk)
|
|
||||||
return h.hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def _is_quiescent(path: str, now_ts: float, quiescence_seconds: float) -> bool:
|
|
||||||
"""Return True if the file's mtime is at least `quiescence_seconds`
|
|
||||||
in the past — i.e. no longer being written."""
|
|
||||||
try:
|
|
||||||
mtime = os.path.getmtime(path)
|
|
||||||
except OSError:
|
|
||||||
return False
|
|
||||||
return (now_ts - mtime) >= quiescence_seconds
|
|
||||||
|
|
||||||
|
|
||||||
def _iter_unit_dirs(thordata_root: str):
|
|
||||||
"""Yield (project_name, unit_name, unit_path) for every Thor unit
|
|
||||||
folder beneath `thordata_root`.
|
|
||||||
|
|
||||||
THORDATA layout: <root>/<Project>/<UnitSerial>/
|
|
||||||
"""
|
|
||||||
if not os.path.isdir(thordata_root):
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
projects = os.listdir(thordata_root)
|
|
||||||
except OSError:
|
|
||||||
return
|
|
||||||
for proj in projects:
|
|
||||||
proj_path = os.path.join(thordata_root, proj)
|
|
||||||
if not os.path.isdir(proj_path):
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
units = os.listdir(proj_path)
|
|
||||||
except OSError:
|
|
||||||
continue
|
|
||||||
for unit in units:
|
|
||||||
unit_path = os.path.join(proj_path, unit)
|
|
||||||
if not os.path.isdir(unit_path):
|
|
||||||
continue
|
|
||||||
yield proj, unit, unit_path
|
|
||||||
|
|
||||||
|
|
||||||
def _find_txt_in_unit(unit_path: str, binary_name: str,
|
|
||||||
now_ts: float, quiescence_seconds: float,
|
|
||||||
_txt_cache: Dict[str, Dict[str, str]]) -> Optional[str]:
|
|
||||||
"""Look up the matching `.txt` sidecar for `binary_name` inside
|
|
||||||
`<unit_path>/TXT/`. Case-insensitive on Windows-shaped paths.
|
|
||||||
|
|
||||||
Returns the absolute path if a quiescent sidecar exists, else None.
|
|
||||||
`_txt_cache` is mutated to memoize the lower-case → actual-name
|
|
||||||
map for each unit so repeated lookups in one scan don't restat.
|
|
||||||
"""
|
|
||||||
expected = idf_report_name(binary_name)
|
|
||||||
|
|
||||||
if unit_path not in _txt_cache:
|
|
||||||
txt_dir = os.path.join(unit_path, "TXT")
|
|
||||||
listing: Dict[str, str] = {}
|
|
||||||
if os.path.isdir(txt_dir):
|
|
||||||
try:
|
|
||||||
for n in os.listdir(txt_dir):
|
|
||||||
listing[n.lower()] = n
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
_txt_cache[unit_path] = listing
|
|
||||||
|
|
||||||
listing = _txt_cache[unit_path]
|
|
||||||
actual = listing.get(expected.lower())
|
|
||||||
if not actual:
|
|
||||||
return None
|
|
||||||
candidate = os.path.join(unit_path, "TXT", actual)
|
|
||||||
if _is_quiescent(candidate, now_ts, quiescence_seconds):
|
|
||||||
return candidate
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# ── Scan pass ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def find_pending_events(
|
|
||||||
thordata_root: str,
|
|
||||||
state: ForwardState,
|
|
||||||
*,
|
|
||||||
max_age_days: int,
|
|
||||||
quiescence_seconds: float = DEFAULT_QUIESCENCE_SECONDS,
|
|
||||||
missing_report_grace_seconds: float = DEFAULT_MISSING_REPORT_GRACE_SECONDS,
|
|
||||||
max_per_pass: int = 0,
|
|
||||||
) -> List[Tuple[str, Optional[str]]]:
|
|
||||||
"""
|
|
||||||
Walk `thordata_root` and return the list of (binary_path, txt_path_or_None)
|
|
||||||
pairs that need forwarding.
|
|
||||||
|
|
||||||
Filtering rules:
|
|
||||||
- Filename must match the Thor event filename regex.
|
|
||||||
- File must be quiescent (mtime >= quiescence_seconds in the past).
|
|
||||||
- File must not exceed `max_age_days`.
|
|
||||||
- File's sha256 must NOT already be in the forwarded state
|
|
||||||
(unless it was forwarded without its TXT and the TXT is now
|
|
||||||
present — see ForwardState.status).
|
|
||||||
- If a `<unit>/TXT/<basename>.txt` exists and is quiescent,
|
|
||||||
we pair them. Otherwise, if the binary is older than
|
|
||||||
missing_report_grace_seconds, we forward without the TXT.
|
|
||||||
Younger binaries with a missing TXT are deferred.
|
|
||||||
- When `max_per_pass > 0`, return at most that many pairs.
|
|
||||||
Older files (lower mtime) are forwarded first so backfill
|
|
||||||
proceeds chronologically.
|
|
||||||
"""
|
|
||||||
if not os.path.isdir(thordata_root):
|
|
||||||
log.warning("forward scan: thordata root not found: %s", thordata_root)
|
|
||||||
return []
|
|
||||||
|
|
||||||
now_ts = time.time()
|
|
||||||
max_age_seconds = max(1, int(max_age_days)) * 86400.0
|
|
||||||
|
|
||||||
# First pass: collect every event-binary entry with mtime for sorting.
|
|
||||||
candidates: List[Tuple[float, str, str]] = [] # (mtime, unit_path, binary_path)
|
|
||||||
for _proj, _unit, unit_path in _iter_unit_dirs(thordata_root):
|
|
||||||
try:
|
|
||||||
entries = list(os.scandir(unit_path))
|
|
||||||
except OSError:
|
|
||||||
continue
|
|
||||||
for e in entries:
|
|
||||||
if not e.is_file():
|
|
||||||
continue
|
|
||||||
if not is_event_binary(e.path):
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
mtime = e.stat().st_mtime
|
|
||||||
except OSError:
|
|
||||||
continue
|
|
||||||
if (now_ts - mtime) > max_age_seconds:
|
|
||||||
continue
|
|
||||||
candidates.append((mtime, unit_path, e.path))
|
|
||||||
|
|
||||||
# Sort oldest-first so backfill is chronological.
|
|
||||||
candidates.sort(key=lambda t: t[0])
|
|
||||||
|
|
||||||
pending: List[Tuple[str, Optional[str]]] = []
|
|
||||||
skipped_inflight = 0
|
|
||||||
skipped_already_forwarded = 0
|
|
||||||
txt_cache: Dict[str, Dict[str, str]] = {}
|
|
||||||
|
|
||||||
for mtime, unit_path, binary_path in candidates:
|
|
||||||
if not _is_quiescent(binary_path, now_ts, quiescence_seconds):
|
|
||||||
skipped_inflight += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
digest = sha256_of_file(binary_path)
|
|
||||||
except OSError as exc:
|
|
||||||
log.warning("forward scan: sha256 failed for %s: %s", binary_path, exc)
|
|
||||||
continue
|
|
||||||
fwd_status = state.status(digest)
|
|
||||||
if fwd_status is True:
|
|
||||||
skipped_already_forwarded += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
binary_name = os.path.basename(binary_path)
|
|
||||||
txt_path = _find_txt_in_unit(
|
|
||||||
unit_path, binary_name, now_ts, quiescence_seconds, txt_cache,
|
|
||||||
)
|
|
||||||
|
|
||||||
if fwd_status is False:
|
|
||||||
# Previously forwarded WITHOUT report. Re-forward only
|
|
||||||
# if the TXT is now present so seismo-relay's upsert can
|
|
||||||
# refresh the row with authoritative device values.
|
|
||||||
if txt_path is None:
|
|
||||||
skipped_already_forwarded += 1
|
|
||||||
continue
|
|
||||||
elif txt_path is None:
|
|
||||||
# First-time forward and TXT not yet present. Wait for
|
|
||||||
# the grace period before forwarding alone.
|
|
||||||
if (now_ts - mtime) < missing_report_grace_seconds:
|
|
||||||
skipped_inflight += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
pending.append((binary_path, txt_path))
|
|
||||||
|
|
||||||
if max_per_pass and len(pending) >= max_per_pass:
|
|
||||||
break
|
|
||||||
|
|
||||||
log.debug(
|
|
||||||
"forward scan: %d pending skipped_inflight=%d already_forwarded=%d cap=%d",
|
|
||||||
len(pending), skipped_inflight, skipped_already_forwarded, max_per_pass,
|
|
||||||
)
|
|
||||||
return pending
|
|
||||||
|
|
||||||
|
|
||||||
# ── Multipart upload ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def _encode_multipart(
|
|
||||||
parts: List[Tuple[str, str, str, bytes]],
|
|
||||||
) -> Tuple[bytes, str]:
|
|
||||||
"""Encode a list of (field_name, filename, content_type, data) tuples
|
|
||||||
as a multipart/form-data body. Returns (body_bytes, content_type
|
|
||||||
header value)."""
|
|
||||||
boundary = "----ThorWatcherBoundary" + os.urandom(8).hex()
|
|
||||||
chunks: List[bytes] = []
|
|
||||||
for field_name, filename, content_type, data in parts:
|
|
||||||
chunks.append(("--" + boundary + "\r\n").encode("ascii"))
|
|
||||||
chunks.append(
|
|
||||||
(f'Content-Disposition: form-data; name="{field_name}"; '
|
|
||||||
f'filename="{filename}"\r\n').encode("ascii")
|
|
||||||
)
|
|
||||||
chunks.append((f"Content-Type: {content_type}\r\n\r\n").encode("ascii"))
|
|
||||||
chunks.append(data)
|
|
||||||
chunks.append(b"\r\n")
|
|
||||||
chunks.append(("--" + boundary + "--\r\n").encode("ascii"))
|
|
||||||
body = b"".join(chunks)
|
|
||||||
content_type_hdr = f"multipart/form-data; boundary={boundary}"
|
|
||||||
return body, content_type_hdr
|
|
||||||
|
|
||||||
|
|
||||||
def _import_endpoint(sfm_url: str) -> str:
|
|
||||||
"""Compose the import endpoint URL from a base SFM URL."""
|
|
||||||
return sfm_url.rstrip("/") + "/db/import/idf_file"
|
|
||||||
|
|
||||||
|
|
||||||
def forward_event_pair(
|
|
||||||
sfm_url: str,
|
|
||||||
binary_path: str,
|
|
||||||
txt_path: Optional[str],
|
|
||||||
*,
|
|
||||||
serial_hint: Optional[str] = None,
|
|
||||||
timeout: float = DEFAULT_HTTP_TIMEOUT,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""POST a single event (binary + optional .txt) to the SFM import
|
|
||||||
endpoint.
|
|
||||||
|
|
||||||
Returns a dict mirroring the per-file outcome the server returned
|
|
||||||
on success, or a dict with `status="error"` on transport/HTTP
|
|
||||||
failure.
|
|
||||||
"""
|
|
||||||
binary_name = os.path.basename(binary_path)
|
|
||||||
with open(binary_path, "rb") as f:
|
|
||||||
binary_bytes = f.read()
|
|
||||||
|
|
||||||
parts = [("files", binary_name, "application/octet-stream", binary_bytes)]
|
|
||||||
if txt_path is not None:
|
|
||||||
with open(txt_path, "rb") as f:
|
|
||||||
txt_bytes = f.read()
|
|
||||||
parts.append(("files", os.path.basename(txt_path), "text/plain", txt_bytes))
|
|
||||||
|
|
||||||
body, content_type = _encode_multipart(parts)
|
|
||||||
|
|
||||||
# Auto-derive serial from filename if caller didn't supply one.
|
|
||||||
if not serial_hint:
|
|
||||||
serial_hint = serial_from_filename(binary_name)
|
|
||||||
|
|
||||||
url = _import_endpoint(sfm_url)
|
|
||||||
if serial_hint:
|
|
||||||
sep = "&" if "?" in url else "?"
|
|
||||||
url = f"{url}{sep}serial={serial_hint}"
|
|
||||||
|
|
||||||
req = urllib.request.Request(
|
|
||||||
url, data=body, method="POST",
|
|
||||||
headers={
|
|
||||||
"Content-Type": content_type,
|
|
||||||
"Content-Length": str(len(body)),
|
|
||||||
"User-Agent": "thor-watcher/sfm-forwarder",
|
|
||||||
"Accept": "application/json",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
||||||
raw = resp.read().decode("utf-8", errors="replace")
|
|
||||||
try:
|
|
||||||
payload = json.loads(raw)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
return {
|
|
||||||
"status": "error",
|
|
||||||
"filename": binary_name,
|
|
||||||
"detail": f"server returned non-JSON: {raw[:200]!r}",
|
|
||||||
}
|
|
||||||
for entry in (payload.get("results") or []):
|
|
||||||
if entry.get("filename") == binary_name and entry.get("status") == "ok":
|
|
||||||
return entry
|
|
||||||
for entry in (payload.get("results") or []):
|
|
||||||
if entry.get("filename") == binary_name:
|
|
||||||
return entry
|
|
||||||
return {
|
|
||||||
"status": "error",
|
|
||||||
"filename": binary_name,
|
|
||||||
"detail": f"unexpected server response: {payload!r}",
|
|
||||||
}
|
|
||||||
except urllib.error.HTTPError as exc:
|
|
||||||
try:
|
|
||||||
body_excerpt = exc.read().decode("utf-8", errors="replace")[:300]
|
|
||||||
except Exception:
|
|
||||||
body_excerpt = ""
|
|
||||||
return {
|
|
||||||
"status": "error",
|
|
||||||
"filename": binary_name,
|
|
||||||
"detail": f"HTTP {exc.code}: {exc.reason} body={body_excerpt!r}",
|
|
||||||
}
|
|
||||||
except urllib.error.URLError as exc:
|
|
||||||
return {
|
|
||||||
"status": "error",
|
|
||||||
"filename": binary_name,
|
|
||||||
"detail": f"connection error: {exc.reason}",
|
|
||||||
}
|
|
||||||
except (OSError, TimeoutError) as exc:
|
|
||||||
return {
|
|
||||||
"status": "error",
|
|
||||||
"filename": binary_name,
|
|
||||||
"detail": f"transport error: {exc}",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ── Top-level orchestration ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def forward_pending(
|
|
||||||
thordata_root: str,
|
|
||||||
sfm_url: str,
|
|
||||||
state: ForwardState,
|
|
||||||
*,
|
|
||||||
max_age_days: int,
|
|
||||||
quiescence_seconds: float = DEFAULT_QUIESCENCE_SECONDS,
|
|
||||||
missing_report_grace_seconds: float = DEFAULT_MISSING_REPORT_GRACE_SECONDS,
|
|
||||||
timeout: float = DEFAULT_HTTP_TIMEOUT,
|
|
||||||
max_per_pass: int = 0,
|
|
||||||
logger: Optional[Any] = None,
|
|
||||||
) -> Dict[str, int]:
|
|
||||||
"""
|
|
||||||
Run one full pass: find pending events, POST each one, update state.
|
|
||||||
|
|
||||||
Returns a counts dict suitable for logging:
|
|
||||||
|
|
||||||
{
|
|
||||||
"scanned": <int>, # event binaries selected for forward
|
|
||||||
"forwarded": <int>, # successfully POSTed this pass
|
|
||||||
"errors": <int>, # POST failures (will retry next pass)
|
|
||||||
"with_report":<int>, # of forwarded, how many had a paired TXT
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
def _log(msg: str) -> None:
|
|
||||||
if logger:
|
|
||||||
logger(msg)
|
|
||||||
else:
|
|
||||||
log.info(msg)
|
|
||||||
|
|
||||||
pending = find_pending_events(
|
|
||||||
thordata_root, state,
|
|
||||||
max_age_days=max_age_days,
|
|
||||||
quiescence_seconds=quiescence_seconds,
|
|
||||||
missing_report_grace_seconds=missing_report_grace_seconds,
|
|
||||||
max_per_pass=max_per_pass,
|
|
||||||
)
|
|
||||||
|
|
||||||
counts = {"scanned": len(pending), "forwarded": 0, "errors": 0, "with_report": 0}
|
|
||||||
|
|
||||||
for binary_path, txt_path in pending:
|
|
||||||
result = forward_event_pair(
|
|
||||||
sfm_url, binary_path, txt_path,
|
|
||||||
timeout=timeout,
|
|
||||||
)
|
|
||||||
if result.get("status") == "ok":
|
|
||||||
try:
|
|
||||||
digest = sha256_of_file(binary_path)
|
|
||||||
size = os.path.getsize(binary_path)
|
|
||||||
state.mark_forwarded(
|
|
||||||
digest,
|
|
||||||
os.path.basename(binary_path),
|
|
||||||
size,
|
|
||||||
had_report=(txt_path is not None),
|
|
||||||
)
|
|
||||||
except OSError as exc:
|
|
||||||
_log(f"[forward] post-success state save failed for "
|
|
||||||
f"{os.path.basename(binary_path)}: {exc}")
|
|
||||||
counts["forwarded"] += 1
|
|
||||||
if txt_path:
|
|
||||||
counts["with_report"] += 1
|
|
||||||
|
|
||||||
if txt_path:
|
|
||||||
report_token = "+ {} attached".format(os.path.basename(txt_path))
|
|
||||||
elif is_histogram_event(binary_path):
|
|
||||||
report_token = "(histogram, no report expected)"
|
|
||||||
else:
|
|
||||||
report_token = "no report"
|
|
||||||
|
|
||||||
_log(
|
|
||||||
"[forward] OK {} ({}B, {}, inserted={}, skipped={})".format(
|
|
||||||
os.path.basename(binary_path),
|
|
||||||
result.get("filesize", 0),
|
|
||||||
report_token,
|
|
||||||
result.get("inserted", 0),
|
|
||||||
result.get("skipped", 0),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
counts["errors"] += 1
|
|
||||||
_log(
|
|
||||||
f"[forward] ERR {os.path.basename(binary_path)}: "
|
|
||||||
f"{result.get('detail', 'unknown error')}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return counts
|
|
||||||
|
|
||||||
|
|
||||||
# ── Seed-state mode (skip historical backfill on first deploy) ────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def seed_state_from_folder(
|
|
||||||
thordata_root: str,
|
|
||||||
state: ForwardState,
|
|
||||||
*,
|
|
||||||
max_age_days: int = 365,
|
|
||||||
logger: Optional[Any] = None,
|
|
||||||
) -> Dict[str, int]:
|
|
||||||
"""Walk `thordata_root` and mark every existing event binary as
|
|
||||||
already forwarded — without POSTing anything.
|
|
||||||
|
|
||||||
Run this ONCE before enabling sfm_forward_enabled on a machine
|
|
||||||
with a large historical archive. The watcher then starts
|
|
||||||
forwarding only events that appear AFTER the seed run.
|
|
||||||
|
|
||||||
Returns a counts dict:
|
|
||||||
{"scanned": int, "seeded": int, "already_known": int, "skipped_too_old": int}
|
|
||||||
"""
|
|
||||||
def _log(msg: str) -> None:
|
|
||||||
if logger:
|
|
||||||
logger(msg)
|
|
||||||
else:
|
|
||||||
log.info(msg)
|
|
||||||
|
|
||||||
counts = {"scanned": 0, "seeded": 0, "already_known": 0, "skipped_too_old": 0}
|
|
||||||
|
|
||||||
if not os.path.isdir(thordata_root):
|
|
||||||
_log(f"[seed] thordata root not found: {thordata_root}")
|
|
||||||
return counts
|
|
||||||
|
|
||||||
now_ts = time.time()
|
|
||||||
max_age_seconds = max(1, int(max_age_days)) * 86400.0
|
|
||||||
|
|
||||||
for _proj, _unit, unit_path in _iter_unit_dirs(thordata_root):
|
|
||||||
try:
|
|
||||||
entries = [e for e in os.scandir(unit_path) if e.is_file()]
|
|
||||||
except OSError:
|
|
||||||
continue
|
|
||||||
for e in entries:
|
|
||||||
if not is_event_binary(e.path):
|
|
||||||
continue
|
|
||||||
counts["scanned"] += 1
|
|
||||||
try:
|
|
||||||
mtime = e.stat().st_mtime
|
|
||||||
size = e.stat().st_size
|
|
||||||
except OSError:
|
|
||||||
continue
|
|
||||||
if (now_ts - mtime) > max_age_seconds:
|
|
||||||
counts["skipped_too_old"] += 1
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
digest = sha256_of_file(e.path)
|
|
||||||
except OSError as exc:
|
|
||||||
_log(f"[seed] sha256 failed for {e.path}: {exc}")
|
|
||||||
continue
|
|
||||||
if state.is_forwarded(digest):
|
|
||||||
counts["already_known"] += 1
|
|
||||||
continue
|
|
||||||
state.mark_forwarded(digest, e.name, size)
|
|
||||||
counts["seeded"] += 1
|
|
||||||
if counts["seeded"] % 1000 == 0:
|
|
||||||
_log(f"[seed] progress: {counts['seeded']} seeded so far...")
|
|
||||||
|
|
||||||
_log(
|
|
||||||
f"[seed] done. scanned={counts['scanned']} seeded={counts['seeded']} "
|
|
||||||
f"already_known={counts['already_known']} "
|
|
||||||
f"skipped_too_old={counts['skipped_too_old']}"
|
|
||||||
)
|
|
||||||
return counts
|
|
||||||
|
|
||||||
|
|
||||||
# ── CLI entry point ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def _main() -> int:
|
|
||||||
"""Command-line interface for one-shot operations.
|
|
||||||
|
|
||||||
python event_forwarder.py --seed-state \\
|
|
||||||
--thordata "C:\\THORDATA" \\
|
|
||||||
--state "<path/to/thor_forwarded.json>" \\
|
|
||||||
[--max-age-days 365]
|
|
||||||
"""
|
|
||||||
import argparse
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="Thor Watcher — SFM event forwarder utilities",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--seed-state", action="store_true",
|
|
||||||
help="Mark every event binary in --thordata as already-forwarded "
|
|
||||||
"(without POSTing). Use this BEFORE enabling sfm_forward "
|
|
||||||
"on a machine with a large historical archive.",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--thordata", required=True,
|
|
||||||
help="Path to the THORDATA root folder.",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--state", required=True,
|
|
||||||
help="Path to the JSON state file. Will be created if missing.",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--max-age-days", type=int, default=365,
|
|
||||||
help="Only seed files newer than this many days (default 365).",
|
|
||||||
)
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
if not args.seed_state:
|
|
||||||
parser.error("specify --seed-state (no other modes supported yet)")
|
|
||||||
|
|
||||||
print(f"[seed] thordata = {args.thordata}")
|
|
||||||
print(f"[seed] state = {args.state}")
|
|
||||||
print(f"[seed] max_age = {args.max_age_days} days")
|
|
||||||
|
|
||||||
state = ForwardState(args.state)
|
|
||||||
print(f"[seed] state currently has {state.count()} entries")
|
|
||||||
seed_state_from_folder(
|
|
||||||
args.thordata, state,
|
|
||||||
max_age_days=args.max_age_days,
|
|
||||||
logger=lambda m: print(m),
|
|
||||||
)
|
|
||||||
print(f"[seed] state now has {state.count()} entries")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
import sys
|
|
||||||
sys.exit(_main())
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
; Inno Setup script for Thor Watcher
|
|
||||||
; Run through Inno Setup Compiler after building dist\thor-watcher.exe
|
|
||||||
|
|
||||||
[Setup]
|
|
||||||
AppName=Thor Watcher
|
|
||||||
AppVersion=0.3.1
|
|
||||||
AppPublisher=Terra-Mechanics Inc.
|
|
||||||
DefaultDirName={pf}\ThorWatcher
|
|
||||||
DefaultGroupName=Thor Watcher
|
|
||||||
OutputBaseFilename=thor-watcher-setup
|
|
||||||
Compression=lzma
|
|
||||||
SolidCompression=yes
|
|
||||||
; Require admin rights so we can write to Program Files
|
|
||||||
PrivilegesRequired=admin
|
|
||||||
|
|
||||||
[Tasks]
|
|
||||||
Name: "desktopicon"; Description: "Create a &desktop icon"; GroupDescription: "Additional icons:"; Flags: unchecked
|
|
||||||
|
|
||||||
[Dirs]
|
|
||||||
; Create the agent_logs folder so the watcher can write logs on first run
|
|
||||||
Name: "{app}\agent_logs"
|
|
||||||
|
|
||||||
[Files]
|
|
||||||
; Main executable — built by build.bat / PyInstaller
|
|
||||||
Source: "dist\thor-watcher.exe"; DestDir: "{app}"; Flags: ignoreversion
|
|
||||||
|
|
||||||
[Icons]
|
|
||||||
; Start Menu shortcut
|
|
||||||
Name: "{group}\Thor Watcher"; Filename: "{app}\thor-watcher.exe"
|
|
||||||
; Start Menu uninstall shortcut
|
|
||||||
Name: "{group}\Uninstall Thor Watcher"; Filename: "{uninstallexe}"
|
|
||||||
; Desktop shortcut (optional — controlled by [Tasks] above)
|
|
||||||
Name: "{commondesktop}\Thor Watcher"; Filename: "{app}\thor-watcher.exe"; Tasks: desktopicon
|
|
||||||
; Startup folder shortcut so the tray app launches on login
|
|
||||||
Name: "{userstartup}\Thor Watcher"; Filename: "{app}\thor-watcher.exe"
|
|
||||||
|
|
||||||
[Run]
|
|
||||||
; Offer to launch the app after install (unchecked by default)
|
|
||||||
Filename: "{app}\thor-watcher.exe"; \
|
|
||||||
Description: "Launch Thor Watcher"; \
|
|
||||||
Flags: nowait postinstall skipifsilent unchecked
|
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Database migration: Add device_logs table.
|
||||||
|
|
||||||
|
This table stores per-device log entries for debugging and audit trail.
|
||||||
|
|
||||||
|
Run this once to add the new table.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Path to the SLMM database
|
||||||
|
DB_PATH = os.path.join(os.path.dirname(__file__), "data", "slmm.db")
|
||||||
|
|
||||||
|
|
||||||
|
def migrate():
|
||||||
|
print(f"Adding device_logs table to: {DB_PATH}")
|
||||||
|
|
||||||
|
if not os.path.exists(DB_PATH):
|
||||||
|
print("Database does not exist yet. Table will be created automatically on first run.")
|
||||||
|
return
|
||||||
|
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check if table already exists
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT name FROM sqlite_master
|
||||||
|
WHERE type='table' AND name='device_logs'
|
||||||
|
""")
|
||||||
|
if cursor.fetchone():
|
||||||
|
print("✓ device_logs table already exists, no migration needed")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create the table
|
||||||
|
print("Creating device_logs table...")
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE device_logs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
unit_id VARCHAR NOT NULL,
|
||||||
|
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
level VARCHAR DEFAULT 'INFO',
|
||||||
|
category VARCHAR DEFAULT 'GENERAL',
|
||||||
|
message TEXT NOT NULL
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Create indexes for efficient querying
|
||||||
|
print("Creating indexes...")
|
||||||
|
cursor.execute("CREATE INDEX ix_device_logs_unit_id ON device_logs (unit_id)")
|
||||||
|
cursor.execute("CREATE INDEX ix_device_logs_timestamp ON device_logs (timestamp)")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
print("✓ Created device_logs table with indexes")
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT name FROM sqlite_master
|
||||||
|
WHERE type='table' AND name='device_logs'
|
||||||
|
""")
|
||||||
|
if not cursor.fetchone():
|
||||||
|
raise Exception("device_logs table was not created successfully")
|
||||||
|
|
||||||
|
print("✓ Migration completed successfully")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
migrate()
|
||||||
Executable
+59
@@ -0,0 +1,59 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Migration script to add FTP username and password columns to nl43_config table.
|
||||||
|
Run this once to update existing database schema.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
DB_PATH = Path(__file__).parent / "data" / "slmm.db"
|
||||||
|
|
||||||
|
|
||||||
|
def migrate():
|
||||||
|
"""Add ftp_username and ftp_password columns to nl43_config table."""
|
||||||
|
|
||||||
|
if not DB_PATH.exists():
|
||||||
|
print(f"Database not found at {DB_PATH}")
|
||||||
|
print("No migration needed - database will be created with new schema")
|
||||||
|
return
|
||||||
|
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check if columns already exist
|
||||||
|
cursor.execute("PRAGMA table_info(nl43_config)")
|
||||||
|
columns = [row[1] for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
if "ftp_username" in columns and "ftp_password" in columns:
|
||||||
|
print("✓ FTP credential columns already exist, no migration needed")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Add ftp_username column if it doesn't exist
|
||||||
|
if "ftp_username" not in columns:
|
||||||
|
print("Adding ftp_username column...")
|
||||||
|
cursor.execute("ALTER TABLE nl43_config ADD COLUMN ftp_username TEXT")
|
||||||
|
print("✓ Added ftp_username column")
|
||||||
|
|
||||||
|
# Add ftp_password column if it doesn't exist
|
||||||
|
if "ftp_password" not in columns:
|
||||||
|
print("Adding ftp_password column...")
|
||||||
|
cursor.execute("ALTER TABLE nl43_config ADD COLUMN ftp_password TEXT")
|
||||||
|
print("✓ Added ftp_password column")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
print("\n✓ Migration completed successfully!")
|
||||||
|
print("\nYou can now set FTP credentials via the web UI or database.")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
conn.rollback()
|
||||||
|
print(f"✗ Migration failed: {e}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
migrate()
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Migration script to add polling-related fields to nl43_config and nl43_status tables.
|
||||||
|
|
||||||
|
Adds to nl43_config:
|
||||||
|
- poll_interval_seconds (INTEGER, default 60)
|
||||||
|
- poll_enabled (BOOLEAN, default 1/True)
|
||||||
|
|
||||||
|
Adds to nl43_status:
|
||||||
|
- is_reachable (BOOLEAN, default 1/True)
|
||||||
|
- consecutive_failures (INTEGER, default 0)
|
||||||
|
- last_poll_attempt (DATETIME, nullable)
|
||||||
|
- last_success (DATETIME, nullable)
|
||||||
|
- last_error (TEXT, nullable)
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python migrate_add_polling_fields.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def migrate():
|
||||||
|
db_path = Path("data/slmm.db")
|
||||||
|
|
||||||
|
if not db_path.exists():
|
||||||
|
print(f"❌ Database not found at {db_path}")
|
||||||
|
print(" Run this script from the slmm directory")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Check nl43_config columns
|
||||||
|
cursor.execute("PRAGMA table_info(nl43_config)")
|
||||||
|
config_columns = [row[1] for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
# Check nl43_status columns
|
||||||
|
cursor.execute("PRAGMA table_info(nl43_status)")
|
||||||
|
status_columns = [row[1] for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
changes_made = False
|
||||||
|
|
||||||
|
# Add nl43_config columns
|
||||||
|
if "poll_interval_seconds" not in config_columns:
|
||||||
|
print("Adding poll_interval_seconds to nl43_config...")
|
||||||
|
cursor.execute("""
|
||||||
|
ALTER TABLE nl43_config
|
||||||
|
ADD COLUMN poll_interval_seconds INTEGER DEFAULT 60
|
||||||
|
""")
|
||||||
|
changes_made = True
|
||||||
|
else:
|
||||||
|
print("✓ poll_interval_seconds already exists in nl43_config")
|
||||||
|
|
||||||
|
if "poll_enabled" not in config_columns:
|
||||||
|
print("Adding poll_enabled to nl43_config...")
|
||||||
|
cursor.execute("""
|
||||||
|
ALTER TABLE nl43_config
|
||||||
|
ADD COLUMN poll_enabled BOOLEAN DEFAULT 1
|
||||||
|
""")
|
||||||
|
changes_made = True
|
||||||
|
else:
|
||||||
|
print("✓ poll_enabled already exists in nl43_config")
|
||||||
|
|
||||||
|
# Add nl43_status columns
|
||||||
|
if "is_reachable" not in status_columns:
|
||||||
|
print("Adding is_reachable to nl43_status...")
|
||||||
|
cursor.execute("""
|
||||||
|
ALTER TABLE nl43_status
|
||||||
|
ADD COLUMN is_reachable BOOLEAN DEFAULT 1
|
||||||
|
""")
|
||||||
|
changes_made = True
|
||||||
|
else:
|
||||||
|
print("✓ is_reachable already exists in nl43_status")
|
||||||
|
|
||||||
|
if "consecutive_failures" not in status_columns:
|
||||||
|
print("Adding consecutive_failures to nl43_status...")
|
||||||
|
cursor.execute("""
|
||||||
|
ALTER TABLE nl43_status
|
||||||
|
ADD COLUMN consecutive_failures INTEGER DEFAULT 0
|
||||||
|
""")
|
||||||
|
changes_made = True
|
||||||
|
else:
|
||||||
|
print("✓ consecutive_failures already exists in nl43_status")
|
||||||
|
|
||||||
|
if "last_poll_attempt" not in status_columns:
|
||||||
|
print("Adding last_poll_attempt to nl43_status...")
|
||||||
|
cursor.execute("""
|
||||||
|
ALTER TABLE nl43_status
|
||||||
|
ADD COLUMN last_poll_attempt DATETIME
|
||||||
|
""")
|
||||||
|
changes_made = True
|
||||||
|
else:
|
||||||
|
print("✓ last_poll_attempt already exists in nl43_status")
|
||||||
|
|
||||||
|
if "last_success" not in status_columns:
|
||||||
|
print("Adding last_success to nl43_status...")
|
||||||
|
cursor.execute("""
|
||||||
|
ALTER TABLE nl43_status
|
||||||
|
ADD COLUMN last_success DATETIME
|
||||||
|
""")
|
||||||
|
changes_made = True
|
||||||
|
else:
|
||||||
|
print("✓ last_success already exists in nl43_status")
|
||||||
|
|
||||||
|
if "last_error" not in status_columns:
|
||||||
|
print("Adding last_error to nl43_status...")
|
||||||
|
cursor.execute("""
|
||||||
|
ALTER TABLE nl43_status
|
||||||
|
ADD COLUMN last_error TEXT
|
||||||
|
""")
|
||||||
|
changes_made = True
|
||||||
|
else:
|
||||||
|
print("✓ last_error already exists in nl43_status")
|
||||||
|
|
||||||
|
if changes_made:
|
||||||
|
conn.commit()
|
||||||
|
print("\n✓ Migration completed successfully")
|
||||||
|
print(" Added polling-related fields to nl43_config and nl43_status")
|
||||||
|
else:
|
||||||
|
print("\n✓ All polling fields already exist - no changes needed")
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Migration failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
success = migrate()
|
||||||
|
sys.exit(0 if success else 1)
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Database migration: Add start_time_sync_attempted field to nl43_status table.
|
||||||
|
|
||||||
|
This field tracks whether FTP sync has been attempted for the current measurement,
|
||||||
|
preventing repeated sync attempts when FTP fails.
|
||||||
|
|
||||||
|
Run this once to add the new column.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Path to the SLMM database
|
||||||
|
DB_PATH = os.path.join(os.path.dirname(__file__), "data", "slmm.db")
|
||||||
|
|
||||||
|
|
||||||
|
def migrate():
|
||||||
|
print(f"Adding start_time_sync_attempted field to: {DB_PATH}")
|
||||||
|
|
||||||
|
if not os.path.exists(DB_PATH):
|
||||||
|
print("Database does not exist yet. Column will be created automatically.")
|
||||||
|
return
|
||||||
|
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check if column already exists
|
||||||
|
cursor.execute("PRAGMA table_info(nl43_status)")
|
||||||
|
columns = [col[1] for col in cursor.fetchall()]
|
||||||
|
|
||||||
|
if 'start_time_sync_attempted' in columns:
|
||||||
|
print("✓ start_time_sync_attempted column already exists, no migration needed")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Add the column
|
||||||
|
print("Adding start_time_sync_attempted column...")
|
||||||
|
cursor.execute("""
|
||||||
|
ALTER TABLE nl43_status
|
||||||
|
ADD COLUMN start_time_sync_attempted BOOLEAN DEFAULT 0
|
||||||
|
""")
|
||||||
|
conn.commit()
|
||||||
|
print("✓ Added start_time_sync_attempted column")
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
cursor.execute("PRAGMA table_info(nl43_status)")
|
||||||
|
columns = [col[1] for col in cursor.fetchall()]
|
||||||
|
|
||||||
|
if 'start_time_sync_attempted' not in columns:
|
||||||
|
raise Exception("start_time_sync_attempted column was not added successfully")
|
||||||
|
|
||||||
|
print("✓ Migration completed successfully")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
migrate()
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
fastapi
|
||||||
|
uvicorn
|
||||||
|
sqlalchemy
|
||||||
|
pydantic
|
||||||
|
aioftp
|
||||||
|
jinja2
|
||||||
|
websockets
|
||||||
@@ -1,477 +0,0 @@
|
|||||||
"""
|
|
||||||
Thor Watcher — Series 4 Ingest Agent v0.3.1
|
|
||||||
|
|
||||||
Micromate (Series 4) ingest agent for Terra-View.
|
|
||||||
|
|
||||||
Behavior:
|
|
||||||
- Scans C:\THORDATA\<Project>\<UM####>\*.MLG
|
|
||||||
- For each UM####, finds the newest .MLG by timestamp in the filename
|
|
||||||
- Posts JSON heartbeat payload to Terra-View backend
|
|
||||||
- Forwards .IDFH/.IDFW event files (+ TXT sidecars) to a seismo-relay
|
|
||||||
SFM server when sfm_forward_enabled=true. See event_forwarder.py.
|
|
||||||
- Tray-friendly: run_watcher(state, stop_event) for background thread use
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
import json
|
|
||||||
import threading
|
|
||||||
import urllib.request
|
|
||||||
import urllib.error
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
|
||||||
from socket import gethostname
|
|
||||||
|
|
||||||
import event_forwarder
|
|
||||||
|
|
||||||
|
|
||||||
# ── Version ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
VERSION = "0.3.1"
|
|
||||||
|
|
||||||
|
|
||||||
# ── Config ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def load_config(config_path: str) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Load configuration from config.json.
|
|
||||||
Merges with defaults so any missing key is always present.
|
|
||||||
Raises on file-not-found or malformed JSON (caller handles).
|
|
||||||
"""
|
|
||||||
defaults: Dict[str, Any] = {
|
|
||||||
"thordata_path": r"C:\THORDATA",
|
|
||||||
"scan_interval": 60,
|
|
||||||
"api_url": "",
|
|
||||||
"api_timeout": 5,
|
|
||||||
"api_interval": 300,
|
|
||||||
"source_id": "",
|
|
||||||
"source_type": "series4_watcher",
|
|
||||||
"local_timezone": "America/New_York",
|
|
||||||
"enable_logging": True,
|
|
||||||
"log_file": os.path.join(
|
|
||||||
os.environ.get("LOCALAPPDATA") or os.environ.get("APPDATA") or "C:\\",
|
|
||||||
"ThorWatcher", "agent_logs", "thor_watcher.log"
|
|
||||||
),
|
|
||||||
"log_retention_days": 30,
|
|
||||||
"update_source": "gitea",
|
|
||||||
"update_url": "",
|
|
||||||
"debug": False,
|
|
||||||
|
|
||||||
# SFM event forwarding — default OFF, opt-in via Settings.
|
|
||||||
"sfm_forward_enabled": False,
|
|
||||||
"sfm_url": "", # e.g. "http://10.0.0.44:8200"
|
|
||||||
"sfm_forward_interval": 60, # seconds between forward passes
|
|
||||||
"sfm_quiescence_seconds": 5,
|
|
||||||
"sfm_missing_report_grace_seconds": 60,
|
|
||||||
"sfm_http_timeout": 60,
|
|
||||||
"sfm_state_file": "", # blank → <log_dir>/thor_forwarded.json
|
|
||||||
"sfm_max_forwards_per_pass": 500,
|
|
||||||
"sfm_max_event_age_days": 365,
|
|
||||||
}
|
|
||||||
|
|
||||||
with open(config_path, "r", encoding="utf-8") as f:
|
|
||||||
raw = json.load(f)
|
|
||||||
|
|
||||||
return {**defaults, **raw}
|
|
||||||
|
|
||||||
|
|
||||||
# ── Logging ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def log_message(path: str, enabled: bool, msg: str) -> None:
|
|
||||||
if not enabled:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
d = os.path.dirname(path) or "."
|
|
||||||
if not os.path.exists(d):
|
|
||||||
os.makedirs(d)
|
|
||||||
with open(path, "a", encoding="utf-8") as f:
|
|
||||||
f.write("{} {}\n".format(datetime.now(timezone.utc).isoformat(), msg))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _read_log_tail(log_file: str, n: int = 25) -> Optional[List[str]]:
|
|
||||||
"""Return the last n lines of the log file as a list, or None."""
|
|
||||||
if not log_file:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
with open(log_file, "r", errors="replace") as f:
|
|
||||||
lines = f.readlines()
|
|
||||||
return [line.rstrip("\n") for line in lines[-n:]]
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# ── MLG filename parsing ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
# Matches: UM12345_20251204193042.MLG
|
|
||||||
_MLG_PATTERN = re.compile(r"^(UM\d+)_([0-9]{14})\.MLG$", re.IGNORECASE)
|
|
||||||
|
|
||||||
|
|
||||||
def parse_mlg_filename(name: str) -> Optional[Tuple[str, datetime]]:
|
|
||||||
"""Parse UM####_YYYYMMDDHHMMSS.MLG -> (unit_id, timestamp) or None."""
|
|
||||||
m = _MLG_PATTERN.match(name)
|
|
||||||
if not m:
|
|
||||||
return None
|
|
||||||
unit_id = m.group(1).upper()
|
|
||||||
try:
|
|
||||||
ts = datetime.strptime(m.group(2), "%Y%m%d%H%M%S")
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
return unit_id, ts
|
|
||||||
|
|
||||||
|
|
||||||
# ── THORDATA scanner ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def scan_thordata(root: str) -> Dict[str, Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Scan THORDATA folder structure: <root>/<Project>/<UM####>/*.MLG
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
{ "UM12345": { "unit_id", "project", "last_call" (datetime naive local), "mlg_path" }, ... }
|
|
||||||
"""
|
|
||||||
unit_map: Dict[str, Dict[str, Any]] = {}
|
|
||||||
|
|
||||||
if not os.path.isdir(root):
|
|
||||||
return unit_map
|
|
||||||
|
|
||||||
try:
|
|
||||||
project_names = os.listdir(root)
|
|
||||||
except OSError:
|
|
||||||
return unit_map
|
|
||||||
|
|
||||||
for project_name in project_names:
|
|
||||||
project_path = os.path.join(root, project_name)
|
|
||||||
if not os.path.isdir(project_path):
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
unit_dirs = os.listdir(project_path)
|
|
||||||
except OSError:
|
|
||||||
continue
|
|
||||||
|
|
||||||
for unit_name in unit_dirs:
|
|
||||||
unit_path = os.path.join(project_path, unit_name)
|
|
||||||
if not os.path.isdir(unit_path):
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
files = os.listdir(unit_path)
|
|
||||||
except OSError:
|
|
||||||
continue
|
|
||||||
|
|
||||||
for fname in files:
|
|
||||||
if not fname.upper().endswith(".MLG"):
|
|
||||||
continue
|
|
||||||
parsed = parse_mlg_filename(fname)
|
|
||||||
if not parsed:
|
|
||||||
continue
|
|
||||||
unit_id, ts = parsed
|
|
||||||
full_path = os.path.join(unit_path, fname)
|
|
||||||
current = unit_map.get(unit_id)
|
|
||||||
if current is None or ts > current["last_call"]:
|
|
||||||
unit_map[unit_id] = {
|
|
||||||
"unit_id": unit_id,
|
|
||||||
"project": project_name,
|
|
||||||
"last_call": ts,
|
|
||||||
"mlg_path": full_path,
|
|
||||||
}
|
|
||||||
|
|
||||||
return unit_map
|
|
||||||
|
|
||||||
|
|
||||||
# ── API payload ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def build_api_payload(unit_map: Dict[str, Dict[str, Any]], cfg: Dict[str, Any]) -> dict:
|
|
||||||
"""Build the Terra-View JSON heartbeat payload."""
|
|
||||||
now_utc = datetime.now(timezone.utc)
|
|
||||||
now_local = datetime.now()
|
|
||||||
|
|
||||||
source_id = (cfg.get("source_id") or "").strip() or gethostname()
|
|
||||||
|
|
||||||
# Resolve local timezone for MLG timestamp conversion
|
|
||||||
try:
|
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
local_tz = ZoneInfo(cfg.get("local_timezone") or "America/New_York")
|
|
||||||
except Exception:
|
|
||||||
local_tz = None
|
|
||||||
|
|
||||||
units = []
|
|
||||||
for unit_id, entry in unit_map.items():
|
|
||||||
last_call: datetime = entry["last_call"]
|
|
||||||
age_seconds = max(0.0, (now_local - last_call).total_seconds())
|
|
||||||
age_minutes = int(age_seconds // 60)
|
|
||||||
|
|
||||||
# MLG timestamps are local naive — convert to UTC for transmission
|
|
||||||
try:
|
|
||||||
if local_tz is not None:
|
|
||||||
last_call_utc = last_call.replace(tzinfo=local_tz).astimezone(timezone.utc)
|
|
||||||
last_call_str = last_call_utc.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
||||||
else:
|
|
||||||
# Fallback: send as-is with Z and accept the inaccuracy
|
|
||||||
last_call_str = last_call.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
||||||
except Exception:
|
|
||||||
last_call_str = last_call.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
||||||
|
|
||||||
units.append({
|
|
||||||
"unit_id": unit_id,
|
|
||||||
"last_call": last_call_str,
|
|
||||||
"age_minutes": age_minutes,
|
|
||||||
"mlg_path": entry["mlg_path"],
|
|
||||||
"project_hint": entry["project"],
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
"source_id": source_id,
|
|
||||||
"source_type": cfg.get("source_type", "series4_watcher"),
|
|
||||||
"version": VERSION,
|
|
||||||
"generated_at": now_utc.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
||||||
"units": units,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def send_api_payload(payload: dict, api_url: str, timeout: int) -> Optional[dict]:
|
|
||||||
"""POST payload to Terra-View. Returns parsed JSON response or None on failure."""
|
|
||||||
if not api_url:
|
|
||||||
return None
|
|
||||||
data = json.dumps(payload).encode("utf-8")
|
|
||||||
req = urllib.request.Request(
|
|
||||||
api_url, data=data,
|
|
||||||
headers={"Content-Type": "application/json"},
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
||||||
print("[API] POST success: {}".format(resp.status))
|
|
||||||
try:
|
|
||||||
return json.loads(resp.read().decode("utf-8"))
|
|
||||||
except Exception:
|
|
||||||
return {}
|
|
||||||
except urllib.error.URLError as e:
|
|
||||||
print("[API] POST failed: {}".format(e))
|
|
||||||
return None
|
|
||||||
except Exception as e:
|
|
||||||
print("[API] Unexpected error: {}".format(e))
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# ── Watcher loop (tray-friendly) ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
def run_watcher(state: Dict[str, Any], stop_event: threading.Event) -> None:
|
|
||||||
"""
|
|
||||||
Main watcher loop. Runs in a background thread when launched from the tray.
|
|
||||||
|
|
||||||
state keys written each cycle:
|
|
||||||
state["status"] — "running" | "error" | "starting"
|
|
||||||
state["api_status"] — "ok" | "fail" | "disabled"
|
|
||||||
state["units"] — list of unit dicts for tray display
|
|
||||||
state["last_scan"] — datetime of last successful scan
|
|
||||||
state["last_error"] — last error string or None
|
|
||||||
state["log_dir"] — directory containing the log file
|
|
||||||
state["cfg"] — loaded config dict
|
|
||||||
state["update_available"] — set True when API response signals an update
|
|
||||||
state["sfm_status"] — "ok" | "fail" | "disabled" | "ready"
|
|
||||||
state["last_forward"] — datetime of last forwarder pass (or None)
|
|
||||||
state["last_forward_counts"] — dict from event_forwarder.forward_pending
|
|
||||||
"""
|
|
||||||
# Resolve config path
|
|
||||||
if getattr(sys, "frozen", False):
|
|
||||||
_appdata = os.environ.get("LOCALAPPDATA") or os.environ.get("APPDATA") or ""
|
|
||||||
config_dir = os.path.join(_appdata, "ThorWatcher")
|
|
||||||
else:
|
|
||||||
config_dir = os.path.dirname(os.path.abspath(__file__)) or "."
|
|
||||||
config_path = os.path.join(config_dir, "config.json")
|
|
||||||
|
|
||||||
state["status"] = "starting"
|
|
||||||
state["units"] = []
|
|
||||||
state["last_scan"] = None
|
|
||||||
state["last_error"] = None
|
|
||||||
state["log_dir"] = None
|
|
||||||
state["cfg"] = {}
|
|
||||||
state["update_available"] = False
|
|
||||||
|
|
||||||
try:
|
|
||||||
cfg = load_config(config_path)
|
|
||||||
except Exception as e:
|
|
||||||
state["status"] = "error"
|
|
||||||
state["last_error"] = "Config load failed: {}".format(e)
|
|
||||||
return
|
|
||||||
|
|
||||||
state["cfg"] = cfg
|
|
||||||
log_file = cfg["log_file"]
|
|
||||||
state["log_dir"] = os.path.dirname(log_file) or config_dir
|
|
||||||
|
|
||||||
THORDATA_PATH = cfg["thordata_path"]
|
|
||||||
SCAN_INTERVAL = int(cfg["scan_interval"])
|
|
||||||
API_URL = cfg["api_url"]
|
|
||||||
API_TIMEOUT = int(cfg["api_timeout"])
|
|
||||||
API_INTERVAL = int(cfg["api_interval"])
|
|
||||||
ENABLE_LOGGING = bool(cfg["enable_logging"])
|
|
||||||
|
|
||||||
# SFM forwarder config
|
|
||||||
SFM_FORWARD_ENABLED = bool(cfg.get("sfm_forward_enabled", False))
|
|
||||||
SFM_URL = str(cfg.get("sfm_url", "")).strip()
|
|
||||||
SFM_FORWARD_INTERVAL = int(cfg.get("sfm_forward_interval", 60))
|
|
||||||
SFM_QUIESCENCE = int(cfg.get("sfm_quiescence_seconds", 5))
|
|
||||||
SFM_GRACE = int(cfg.get("sfm_missing_report_grace_seconds", 60))
|
|
||||||
SFM_HTTP_TIMEOUT = int(cfg.get("sfm_http_timeout", 60))
|
|
||||||
SFM_MAX_PER_PASS = int(cfg.get("sfm_max_forwards_per_pass", 500))
|
|
||||||
SFM_MAX_AGE_DAYS = int(cfg.get("sfm_max_event_age_days", 365))
|
|
||||||
sfm_state_path = str(cfg.get("sfm_state_file", "")).strip() or \
|
|
||||||
os.path.join(state["log_dir"], "thor_forwarded.json")
|
|
||||||
|
|
||||||
log_message(log_file, ENABLE_LOGGING,
|
|
||||||
"[cfg] THORDATA_PATH={} SCAN_INTERVAL={}s API_INTERVAL={}s API={} SFM={}".format(
|
|
||||||
THORDATA_PATH, SCAN_INTERVAL, API_INTERVAL, bool(API_URL),
|
|
||||||
bool(SFM_FORWARD_ENABLED and SFM_URL),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
print("[CFG] THORDATA_PATH={} SCAN_INTERVAL={}s API={} SFM={}".format(
|
|
||||||
THORDATA_PATH, SCAN_INTERVAL, bool(API_URL),
|
|
||||||
bool(SFM_FORWARD_ENABLED and SFM_URL),
|
|
||||||
))
|
|
||||||
|
|
||||||
# Initialize SFM forwarder state (if enabled)
|
|
||||||
sfm_state_obj: Optional[event_forwarder.ForwardState] = None
|
|
||||||
if SFM_FORWARD_ENABLED and SFM_URL:
|
|
||||||
try:
|
|
||||||
sfm_state_obj = event_forwarder.ForwardState(sfm_state_path)
|
|
||||||
state["sfm_status"] = "ready"
|
|
||||||
log_message(log_file, ENABLE_LOGGING,
|
|
||||||
"[sfm] forwarder ready url={} state_file={} known={}".format(
|
|
||||||
SFM_URL, sfm_state_path, sfm_state_obj.count(),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
print("[SFM] forwarder ready url={} known={}".format(
|
|
||||||
SFM_URL, sfm_state_obj.count(),
|
|
||||||
))
|
|
||||||
except Exception as exc:
|
|
||||||
state["sfm_status"] = "fail"
|
|
||||||
state["last_error"] = "SFM init failed: {}".format(exc)
|
|
||||||
log_message(log_file, ENABLE_LOGGING,
|
|
||||||
"[sfm] init failed: {}".format(exc))
|
|
||||||
else:
|
|
||||||
state["sfm_status"] = "disabled"
|
|
||||||
|
|
||||||
state["last_forward"] = None
|
|
||||||
state["last_forward_counts"] = None
|
|
||||||
|
|
||||||
last_api_ts = 0.0
|
|
||||||
last_forward_ts = 0.0
|
|
||||||
|
|
||||||
while not stop_event.is_set():
|
|
||||||
try:
|
|
||||||
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
||||||
print("-" * 80)
|
|
||||||
print("Heartbeat @ {}".format(now_str))
|
|
||||||
print("-" * 80)
|
|
||||||
|
|
||||||
unit_map = scan_thordata(THORDATA_PATH)
|
|
||||||
now_local = datetime.now()
|
|
||||||
|
|
||||||
unit_list = []
|
|
||||||
for uid in sorted(unit_map.keys()):
|
|
||||||
entry = unit_map[uid]
|
|
||||||
last_call = entry["last_call"]
|
|
||||||
age_seconds = max(0.0, (now_local - last_call).total_seconds())
|
|
||||||
age_minutes = int(age_seconds // 60)
|
|
||||||
unit_list.append({
|
|
||||||
"uid": uid,
|
|
||||||
"age_minutes": age_minutes,
|
|
||||||
"last_call": last_call.strftime("%Y-%m-%d %H:%M:%S"),
|
|
||||||
"mlg_path": entry["mlg_path"],
|
|
||||||
"project": entry["project"],
|
|
||||||
})
|
|
||||||
line = "{uid:<8} Age: {h}h {m}m Last: {last} Project: {proj}".format(
|
|
||||||
uid=uid,
|
|
||||||
h=age_minutes // 60,
|
|
||||||
m=age_minutes % 60,
|
|
||||||
last=last_call.strftime("%Y-%m-%d %H:%M:%S"),
|
|
||||||
proj=entry["project"],
|
|
||||||
)
|
|
||||||
print(line)
|
|
||||||
log_message(log_file, ENABLE_LOGGING, line)
|
|
||||||
|
|
||||||
if not unit_list:
|
|
||||||
msg = "[info] No Micromate units found in THORDATA"
|
|
||||||
print(msg)
|
|
||||||
log_message(log_file, ENABLE_LOGGING, msg)
|
|
||||||
|
|
||||||
state["status"] = "running"
|
|
||||||
state["units"] = unit_list
|
|
||||||
state["last_scan"] = datetime.now()
|
|
||||||
state["last_error"] = None
|
|
||||||
|
|
||||||
# ── API heartbeat ──────────────────────────────────────────────────
|
|
||||||
if API_URL:
|
|
||||||
now_ts = time.time()
|
|
||||||
if now_ts - last_api_ts >= API_INTERVAL:
|
|
||||||
payload = build_api_payload(unit_map, cfg)
|
|
||||||
payload["log_tail"] = _read_log_tail(log_file, 25)
|
|
||||||
response = send_api_payload(payload, API_URL, API_TIMEOUT)
|
|
||||||
last_api_ts = now_ts
|
|
||||||
if response is not None:
|
|
||||||
state["api_status"] = "ok"
|
|
||||||
if response.get("update_available"):
|
|
||||||
state["update_available"] = True
|
|
||||||
else:
|
|
||||||
state["api_status"] = "fail"
|
|
||||||
else:
|
|
||||||
state["api_status"] = "disabled"
|
|
||||||
|
|
||||||
# ── SFM event forwarding ───────────────────────────────────────────
|
|
||||||
if sfm_state_obj is not None:
|
|
||||||
now_ts = time.time()
|
|
||||||
if now_ts - last_forward_ts >= SFM_FORWARD_INTERVAL:
|
|
||||||
last_forward_ts = now_ts
|
|
||||||
try:
|
|
||||||
counts = event_forwarder.forward_pending(
|
|
||||||
THORDATA_PATH, SFM_URL, sfm_state_obj,
|
|
||||||
max_age_days=SFM_MAX_AGE_DAYS,
|
|
||||||
quiescence_seconds=SFM_QUIESCENCE,
|
|
||||||
missing_report_grace_seconds=SFM_GRACE,
|
|
||||||
timeout=SFM_HTTP_TIMEOUT,
|
|
||||||
max_per_pass=SFM_MAX_PER_PASS,
|
|
||||||
logger=lambda m: log_message(log_file, ENABLE_LOGGING, m),
|
|
||||||
)
|
|
||||||
state["last_forward"] = datetime.now()
|
|
||||||
state["last_forward_counts"] = counts
|
|
||||||
if counts["errors"] > 0:
|
|
||||||
state["sfm_status"] = "fail"
|
|
||||||
else:
|
|
||||||
state["sfm_status"] = "ok"
|
|
||||||
summary = ("[sfm] pass scanned={scanned} forwarded={forwarded} "
|
|
||||||
"errors={errors} with_report={with_report}").format(**counts)
|
|
||||||
print(summary)
|
|
||||||
log_message(log_file, ENABLE_LOGGING, summary)
|
|
||||||
except Exception as exc:
|
|
||||||
state["sfm_status"] = "fail"
|
|
||||||
msg = "[sfm] pass failed: {}".format(exc)
|
|
||||||
print(msg)
|
|
||||||
log_message(log_file, ENABLE_LOGGING, msg)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
err = "[loop-error] {}".format(e)
|
|
||||||
print(err)
|
|
||||||
log_message(log_file, ENABLE_LOGGING, err)
|
|
||||||
state["status"] = "error"
|
|
||||||
state["last_error"] = str(e)
|
|
||||||
|
|
||||||
stop_event.wait(timeout=SCAN_INTERVAL)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Standalone entry point ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
state: Dict[str, Any] = {}
|
|
||||||
stop_event = threading.Event()
|
|
||||||
try:
|
|
||||||
run_watcher(state, stop_event)
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
print("\nStopping...")
|
|
||||||
stop_event.set()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
Executable
+65
@@ -0,0 +1,65 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Helper script to set FTP credentials for a device.
|
||||||
|
Usage: python3 set_ftp_credentials.py <unit_id> <username> <password>
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
DB_PATH = Path(__file__).parent / "data" / "slmm.db"
|
||||||
|
|
||||||
|
|
||||||
|
def set_credentials(unit_id: str, username: str, password: str):
|
||||||
|
"""Set FTP credentials for a device."""
|
||||||
|
|
||||||
|
if not DB_PATH.exists():
|
||||||
|
print(f"Error: Database not found at {DB_PATH}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check if unit exists
|
||||||
|
cursor.execute("SELECT unit_id FROM nl43_config WHERE unit_id = ?", (unit_id,))
|
||||||
|
if not cursor.fetchone():
|
||||||
|
print(f"Error: Unit '{unit_id}' not found in database")
|
||||||
|
print("\nAvailable units:")
|
||||||
|
cursor.execute("SELECT unit_id FROM nl43_config")
|
||||||
|
for row in cursor.fetchall():
|
||||||
|
print(f" - {row[0]}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Update credentials
|
||||||
|
cursor.execute(
|
||||||
|
"UPDATE nl43_config SET ftp_username = ?, ftp_password = ? WHERE unit_id = ?",
|
||||||
|
(username, password, unit_id)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
print(f"✓ FTP credentials updated for unit '{unit_id}'")
|
||||||
|
print(f" Username: {username}")
|
||||||
|
print(f" Password: {'*' * len(password)}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
conn.rollback()
|
||||||
|
print(f"Error: {e}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if len(sys.argv) != 4:
|
||||||
|
print("Usage: python3 set_ftp_credentials.py <unit_id> <username> <password>")
|
||||||
|
print("\nExample:")
|
||||||
|
print(" python3 set_ftp_credentials.py nl43-1 admin mypassword")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
unit_id = sys.argv[1]
|
||||||
|
username = sys.argv[2]
|
||||||
|
password = sys.argv[3]
|
||||||
|
|
||||||
|
set_credentials(unit_id, username, password)
|
||||||
@@ -0,0 +1,980 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>SLMM NL43 Standalone</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: system-ui, -apple-system, sans-serif; margin: 24px; max-width: 900px; }
|
||||||
|
fieldset { margin-bottom: 16px; padding: 12px; }
|
||||||
|
legend { font-weight: 600; }
|
||||||
|
label { display: block; margin-bottom: 6px; font-weight: 600; }
|
||||||
|
input { width: 100%; padding: 8px; margin-bottom: 10px; }
|
||||||
|
button { padding: 8px 12px; margin-right: 8px; }
|
||||||
|
#log { background: #f6f8fa; border: 1px solid #d0d7de; padding: 12px; min-height: 120px; white-space: pre-wrap; }
|
||||||
|
.diagnostic-item { margin: 8px 0; padding: 8px; border-left: 4px solid #888; background: #f6f8fa; }
|
||||||
|
.diagnostic-item.pass { border-left-color: #0a0; }
|
||||||
|
.diagnostic-item.fail { border-left-color: #d00; }
|
||||||
|
.diagnostic-item.warning { border-left-color: #fa0; }
|
||||||
|
.diagnostic-item.skip { border-left-color: #888; }
|
||||||
|
.diagnostic-status { font-weight: 600; margin-right: 8px; text-transform: uppercase; font-size: 0.85em; }
|
||||||
|
.diagnostic-status.pass { color: #0a0; }
|
||||||
|
.diagnostic-status.fail { color: #d00; }
|
||||||
|
.diagnostic-status.warning { color: #fa0; }
|
||||||
|
.diagnostic-status.skip { color: #888; }
|
||||||
|
#diagnosticsSummary { font-size: 1.1em; font-weight: 600; margin-bottom: 12px; padding: 8px; border-radius: 4px; }
|
||||||
|
#diagnosticsSummary.pass { background: #d4edda; color: #155724; }
|
||||||
|
#diagnosticsSummary.fail { background: #f8d7da; color: #721c24; }
|
||||||
|
#diagnosticsSummary.degraded { background: #fff3cd; color: #856404; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>SLMM NL43 Standalone</h1>
|
||||||
|
<p>Configure a unit (host/port), then use controls to Start/Stop and fetch live status.</p>
|
||||||
|
<p style="margin-bottom: 16px;">
|
||||||
|
<a href="/roster" style="color: #0969da; text-decoration: none; font-weight: 600;">📊 View Device Roster</a>
|
||||||
|
<span style="margin: 0 8px; color: #d0d7de;">|</span>
|
||||||
|
<a href="/docs" style="color: #0969da; text-decoration: none;">API Documentation</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>🔍 Connection Diagnostics</legend>
|
||||||
|
<button onclick="runDiagnostics()">Run Diagnostics</button>
|
||||||
|
<button onclick="clearDiagnostics()">Clear</button>
|
||||||
|
<div id="diagnosticsResults" style="margin-top: 12px;"></div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Unit Selection & Config</legend>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 8px; align-items: flex-end; margin-bottom: 12px;">
|
||||||
|
<div style="flex: 1;">
|
||||||
|
<label>Select Device</label>
|
||||||
|
<select id="deviceSelector" onchange="loadSelectedDevice()" style="width: 100%; padding: 8px; margin-bottom: 0;">
|
||||||
|
<option value="">-- Select a device --</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button onclick="refreshDeviceList()" style="padding: 8px 12px;">↻ Refresh</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="padding: 12px; background: #f6f8fa; border: 1px solid #d0d7de; border-radius: 4px; margin-bottom: 12px;">
|
||||||
|
<div style="display: flex; gap: 16px;">
|
||||||
|
<div style="flex: 1;">
|
||||||
|
<label>Unit ID</label>
|
||||||
|
<input id="unitId" value="nl43-1" />
|
||||||
|
</div>
|
||||||
|
<div style="flex: 2;">
|
||||||
|
<label>Host</label>
|
||||||
|
<input id="host" value="127.0.0.1" />
|
||||||
|
</div>
|
||||||
|
<div style="flex: 1;">
|
||||||
|
<label>TCP Port</label>
|
||||||
|
<input id="port" type="number" value="2255" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin: 12px 0;">
|
||||||
|
<label style="display: inline-flex; align-items: center; margin-right: 16px;">
|
||||||
|
<input type="checkbox" id="tcpEnabled" checked style="width: auto; margin-right: 6px;" />
|
||||||
|
TCP Enabled
|
||||||
|
</label>
|
||||||
|
<label style="display: inline-flex; align-items: center;">
|
||||||
|
<input type="checkbox" id="ftpEnabled" style="width: auto; margin-right: 6px;" />
|
||||||
|
FTP Enabled
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="ftpCredentials" style="display: none; margin-top: 12px; padding: 12px; background: #f6f8fa; border: 1px solid #d0d7de; border-radius: 4px;">
|
||||||
|
<label>FTP Username</label>
|
||||||
|
<input id="ftpUsername" value="USER" />
|
||||||
|
<label>FTP Password</label>
|
||||||
|
<input id="ftpPassword" type="password" value="0000" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top: 12px;">
|
||||||
|
<button onclick="saveConfig()">Save Config</button>
|
||||||
|
<button onclick="loadConfig()">Load Config</button>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Measurement Controls</legend>
|
||||||
|
<button onclick="start()">Start</button>
|
||||||
|
<button onclick="stop()">Stop</button>
|
||||||
|
<button onclick="pause()">Pause</button>
|
||||||
|
<button onclick="resume()">Resume</button>
|
||||||
|
<button onclick="reset()">Reset</button>
|
||||||
|
<button onclick="store()">Store Data</button>
|
||||||
|
<button onclick="live()">Fetch Live (DOD)</button>
|
||||||
|
<button onclick="getResults()">Get Results (DLC)</button>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Power Management</legend>
|
||||||
|
<button onclick="sleepDevice()">Sleep</button>
|
||||||
|
<button onclick="wakeDevice()">Wake</button>
|
||||||
|
<button onclick="getSleepStatus()">Check Sleep Status</button>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Device Info</legend>
|
||||||
|
<button onclick="getBattery()">Get Battery</button>
|
||||||
|
<button onclick="getClock()">Get Clock</button>
|
||||||
|
<button onclick="syncClock()">Sync Clock to PC</button>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Measurement Settings</legend>
|
||||||
|
<button onclick="getAllSettings()" style="margin-bottom: 12px; font-weight: bold;">Get ALL Settings</button>
|
||||||
|
<div style="margin-bottom: 8px;">
|
||||||
|
<label style="display: inline; margin-right: 8px;">Frequency Weighting:</label>
|
||||||
|
<button onclick="getFreqWeighting()">Get</button>
|
||||||
|
<button onclick="setFreqWeighting('A')">Set A</button>
|
||||||
|
<button onclick="setFreqWeighting('C')">Set C</button>
|
||||||
|
<button onclick="setFreqWeighting('Z')">Set Z</button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style="display: inline; margin-right: 8px;">Time Weighting:</label>
|
||||||
|
<button onclick="getTimeWeighting()">Get</button>
|
||||||
|
<button onclick="setTimeWeighting('F')">Set Fast</button>
|
||||||
|
<button onclick="setTimeWeighting('S')">Set Slow</button>
|
||||||
|
<button onclick="setTimeWeighting('I')">Set Impulse</button>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Live Stream (DRD)</legend>
|
||||||
|
<button id="streamBtn" onclick="toggleStream()">Start Stream</button>
|
||||||
|
<span id="streamStatus" style="margin-left: 12px; color: #888;">Not connected</span>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>FTP File Download</legend>
|
||||||
|
<button onclick="enableFTP()">Enable FTP</button>
|
||||||
|
<button onclick="disableFTP()">Disable FTP</button>
|
||||||
|
<button onclick="checkFTPStatus()">Check FTP Status</button>
|
||||||
|
<button onclick="listFiles()">List Files</button>
|
||||||
|
<div id="fileList" style="margin-top: 12px; max-height: 200px; overflow-y: auto; background: #f6f8fa; border: 1px solid #d0d7de; padding: 8px;"></div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Status</legend>
|
||||||
|
<pre id="status">No data yet.</pre>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Log</legend>
|
||||||
|
<div id="log"></div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const logEl = document.getElementById('log');
|
||||||
|
const statusEl = document.getElementById('status');
|
||||||
|
const streamBtn = document.getElementById('streamBtn');
|
||||||
|
const streamStatus = document.getElementById('streamStatus');
|
||||||
|
|
||||||
|
let ws = null;
|
||||||
|
let streamUpdateCount = 0;
|
||||||
|
let availableDevices = [];
|
||||||
|
|
||||||
|
function log(msg) {
|
||||||
|
logEl.textContent += msg + "\n";
|
||||||
|
logEl.scrollTop = logEl.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFtpCredentials() {
|
||||||
|
const ftpEnabled = document.getElementById('ftpEnabled').checked;
|
||||||
|
const ftpCredentials = document.getElementById('ftpCredentials');
|
||||||
|
ftpCredentials.style.display = ftpEnabled ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load device list from roster
|
||||||
|
async function refreshDeviceList() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/nl43/roster');
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
log('Failed to load device list');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
availableDevices = data.devices || [];
|
||||||
|
const selector = document.getElementById('deviceSelector');
|
||||||
|
|
||||||
|
// Save current selection
|
||||||
|
const currentSelection = selector.value;
|
||||||
|
|
||||||
|
// Clear and rebuild options
|
||||||
|
selector.innerHTML = '<option value="">-- Select a device --</option>';
|
||||||
|
|
||||||
|
availableDevices.forEach(device => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = device.unit_id;
|
||||||
|
|
||||||
|
// Add status indicator
|
||||||
|
let statusIcon = '⚪';
|
||||||
|
if (device.status) {
|
||||||
|
if (device.status.is_reachable === false) {
|
||||||
|
statusIcon = '🔴';
|
||||||
|
} else if (device.status.last_success) {
|
||||||
|
const lastSeen = new Date(device.status.last_success);
|
||||||
|
const ageMinutes = Math.floor((Date.now() - lastSeen) / 60000);
|
||||||
|
statusIcon = ageMinutes < 5 ? '🟢' : '🟡';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
option.textContent = `${statusIcon} ${device.unit_id} (${device.host})`;
|
||||||
|
selector.appendChild(option);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restore selection if it still exists
|
||||||
|
if (currentSelection && availableDevices.find(d => d.unit_id === currentSelection)) {
|
||||||
|
selector.value = currentSelection;
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`Loaded ${availableDevices.length} device(s) from roster`);
|
||||||
|
} catch (err) {
|
||||||
|
log(`Error loading device list: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load selected device configuration
|
||||||
|
function loadSelectedDevice() {
|
||||||
|
const selector = document.getElementById('deviceSelector');
|
||||||
|
const unitId = selector.value;
|
||||||
|
|
||||||
|
if (!unitId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const device = availableDevices.find(d => d.unit_id === unitId);
|
||||||
|
if (!device) {
|
||||||
|
log(`Device ${unitId} not found in list`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate form fields
|
||||||
|
document.getElementById('unitId').value = device.unit_id;
|
||||||
|
document.getElementById('host').value = device.host;
|
||||||
|
document.getElementById('port').value = device.tcp_port || 2255;
|
||||||
|
document.getElementById('tcpEnabled').checked = device.tcp_enabled || false;
|
||||||
|
document.getElementById('ftpEnabled').checked = device.ftp_enabled || false;
|
||||||
|
|
||||||
|
if (device.ftp_username) {
|
||||||
|
document.getElementById('ftpUsername').value = device.ftp_username;
|
||||||
|
}
|
||||||
|
if (device.ftp_password) {
|
||||||
|
document.getElementById('ftpPassword').value = device.ftp_password;
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleFtpCredentials();
|
||||||
|
|
||||||
|
log(`Loaded configuration for ${device.unit_id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add event listeners
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
document.getElementById('ftpEnabled').addEventListener('change', toggleFtpCredentials);
|
||||||
|
|
||||||
|
// Load device list on page load
|
||||||
|
refreshDeviceList();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function runDiagnostics() {
|
||||||
|
const unitId = document.getElementById('unitId').value;
|
||||||
|
const resultsEl = document.getElementById('diagnosticsResults');
|
||||||
|
|
||||||
|
resultsEl.innerHTML = '<p style="color: #888;">Running diagnostics...</p>';
|
||||||
|
log('Running diagnostics...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/nl43/${unitId}/diagnostics`);
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
resultsEl.innerHTML = `<p style="color: #d00;">Diagnostics failed: ${data.detail || 'Unknown error'}</p>`;
|
||||||
|
log(`Diagnostics failed: ${res.status}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build results HTML
|
||||||
|
let html = '';
|
||||||
|
|
||||||
|
// Overall status summary
|
||||||
|
const statusText = {
|
||||||
|
'pass': '✓ All systems operational',
|
||||||
|
'fail': '✗ Connection failed',
|
||||||
|
'degraded': '⚠ Partial connectivity'
|
||||||
|
};
|
||||||
|
|
||||||
|
html += `<div id="diagnosticsSummary" class="${data.overall_status}">`;
|
||||||
|
html += statusText[data.overall_status] || data.overall_status;
|
||||||
|
html += `</div>`;
|
||||||
|
|
||||||
|
// Individual test results
|
||||||
|
const testNames = {
|
||||||
|
'config_exists': '📋 Configuration',
|
||||||
|
'tcp_enabled': '🔌 TCP Enabled',
|
||||||
|
'modem_reachable': '📡 Modem/Router Reachable',
|
||||||
|
'tcp_connection': '🌐 Device Port Connection',
|
||||||
|
'command_response': '💬 Device Response',
|
||||||
|
'ftp_status': '📁 FTP Status'
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [testKey, testResult] of Object.entries(data.tests)) {
|
||||||
|
const testName = testNames[testKey] || testKey;
|
||||||
|
html += `<div class="diagnostic-item ${testResult.status}">`;
|
||||||
|
html += `<span class="diagnostic-status ${testResult.status}">${testResult.status}</span>`;
|
||||||
|
html += `<strong>${testName}:</strong> ${testResult.message}`;
|
||||||
|
html += `</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `<p style="margin-top: 12px; font-size: 0.9em; color: #666;">Last run: ${new Date(data.timestamp).toLocaleString()}</p>`;
|
||||||
|
|
||||||
|
// Add database dump section if available
|
||||||
|
if (data.database_dump) {
|
||||||
|
html += `<div style="margin-top: 16px; border-top: 1px solid #d0d7de; padding-top: 12px;">`;
|
||||||
|
html += `<h4 style="margin: 0 0 12px 0;">📦 Database Dump</h4>`;
|
||||||
|
|
||||||
|
// Config section
|
||||||
|
if (data.database_dump.config) {
|
||||||
|
const cfg = data.database_dump.config;
|
||||||
|
html += `<div style="background: #f0f4f8; padding: 12px; border-radius: 4px; margin-bottom: 12px;">`;
|
||||||
|
html += `<strong>Configuration (nl43_config)</strong>`;
|
||||||
|
html += `<table style="width: 100%; margin-top: 8px; font-size: 0.9em;">`;
|
||||||
|
html += `<tr><td style="padding: 2px 8px; color: #666;">Host</td><td>${cfg.host}:${cfg.tcp_port}</td></tr>`;
|
||||||
|
html += `<tr><td style="padding: 2px 8px; color: #666;">TCP Enabled</td><td>${cfg.tcp_enabled ? '✓' : '✗'}</td></tr>`;
|
||||||
|
html += `<tr><td style="padding: 2px 8px; color: #666;">FTP Enabled</td><td>${cfg.ftp_enabled ? '✓' : '✗'}${cfg.ftp_enabled ? ` (port ${cfg.ftp_port}, user: ${cfg.ftp_username || 'none'})` : ''}</td></tr>`;
|
||||||
|
html += `<tr><td style="padding: 2px 8px; color: #666;">Background Polling</td><td>${cfg.poll_enabled ? `✓ every ${cfg.poll_interval_seconds}s` : '✗ disabled'}</td></tr>`;
|
||||||
|
html += `</table></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status cache section
|
||||||
|
if (data.database_dump.status_cache) {
|
||||||
|
const cache = data.database_dump.status_cache;
|
||||||
|
html += `<div style="background: #f0f8f4; padding: 12px; border-radius: 4px; margin-bottom: 12px;">`;
|
||||||
|
html += `<strong>Status Cache (nl43_status)</strong>`;
|
||||||
|
html += `<table style="width: 100%; margin-top: 8px; font-size: 0.9em;">`;
|
||||||
|
|
||||||
|
// Measurement state and timing
|
||||||
|
html += `<tr><td style="padding: 2px 8px; color: #666;">Measurement State</td><td><strong>${cache.measurement_state || 'unknown'}</strong></td></tr>`;
|
||||||
|
if (cache.measurement_start_time) {
|
||||||
|
const startTime = new Date(cache.measurement_start_time);
|
||||||
|
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||||||
|
const elapsedStr = elapsed > 3600 ? `${Math.floor(elapsed/3600)}h ${Math.floor((elapsed%3600)/60)}m` : elapsed > 60 ? `${Math.floor(elapsed/60)}m ${elapsed%60}s` : `${elapsed}s`;
|
||||||
|
html += `<tr><td style="padding: 2px 8px; color: #666;">Measurement Started</td><td>${startTime.toLocaleString()} (${elapsedStr} ago)</td></tr>`;
|
||||||
|
}
|
||||||
|
html += `<tr><td style="padding: 2px 8px; color: #666;">Counter (d0)</td><td>${cache.counter || 'N/A'}</td></tr>`;
|
||||||
|
|
||||||
|
// Sound levels
|
||||||
|
html += `<tr><td colspan="2" style="padding: 8px 8px 2px 8px; font-weight: 600; border-top: 1px solid #d0d7de;">Sound Levels (dB)</td></tr>`;
|
||||||
|
html += `<tr><td style="padding: 2px 8px; color: #666;">Lp (Instantaneous)</td><td>${cache.lp || 'N/A'}</td></tr>`;
|
||||||
|
html += `<tr><td style="padding: 2px 8px; color: #666;">Leq (Equivalent)</td><td>${cache.leq || 'N/A'}</td></tr>`;
|
||||||
|
html += `<tr><td style="padding: 2px 8px; color: #666;">Lmax / Lmin</td><td>${cache.lmax || 'N/A'} / ${cache.lmin || 'N/A'}</td></tr>`;
|
||||||
|
html += `<tr><td style="padding: 2px 8px; color: #666;">Lpeak</td><td>${cache.lpeak || 'N/A'}</td></tr>`;
|
||||||
|
|
||||||
|
// Device status
|
||||||
|
html += `<tr><td colspan="2" style="padding: 8px 8px 2px 8px; font-weight: 600; border-top: 1px solid #d0d7de;">Device Status</td></tr>`;
|
||||||
|
html += `<tr><td style="padding: 2px 8px; color: #666;">Battery</td><td>${cache.battery_level || 'N/A'}${cache.power_source ? ` (${cache.power_source})` : ''}</td></tr>`;
|
||||||
|
html += `<tr><td style="padding: 2px 8px; color: #666;">SD Card</td><td>${cache.sd_remaining_mb ? `${cache.sd_remaining_mb} MB` : 'N/A'}${cache.sd_free_ratio ? ` (${cache.sd_free_ratio} free)` : ''}</td></tr>`;
|
||||||
|
|
||||||
|
// Polling status
|
||||||
|
html += `<tr><td colspan="2" style="padding: 8px 8px 2px 8px; font-weight: 600; border-top: 1px solid #d0d7de;">Polling Status</td></tr>`;
|
||||||
|
html += `<tr><td style="padding: 2px 8px; color: #666;">Reachable</td><td>${cache.is_reachable ? '🟢 Yes' : '🔴 No'}</td></tr>`;
|
||||||
|
if (cache.last_seen) {
|
||||||
|
html += `<tr><td style="padding: 2px 8px; color: #666;">Last Seen</td><td>${new Date(cache.last_seen).toLocaleString()}</td></tr>`;
|
||||||
|
}
|
||||||
|
if (cache.last_success) {
|
||||||
|
html += `<tr><td style="padding: 2px 8px; color: #666;">Last Success</td><td>${new Date(cache.last_success).toLocaleString()}</td></tr>`;
|
||||||
|
}
|
||||||
|
if (cache.last_poll_attempt) {
|
||||||
|
html += `<tr><td style="padding: 2px 8px; color: #666;">Last Poll Attempt</td><td>${new Date(cache.last_poll_attempt).toLocaleString()}</td></tr>`;
|
||||||
|
}
|
||||||
|
html += `<tr><td style="padding: 2px 8px; color: #666;">Consecutive Failures</td><td>${cache.consecutive_failures || 0}</td></tr>`;
|
||||||
|
if (cache.last_error) {
|
||||||
|
html += `<tr><td style="padding: 2px 8px; color: #666;">Last Error</td><td style="color: #d00; font-size: 0.85em;">${cache.last_error}</td></tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `</table></div>`;
|
||||||
|
|
||||||
|
// Raw payload (collapsible)
|
||||||
|
if (cache.raw_payload) {
|
||||||
|
html += `<details style="margin-top: 8px;"><summary style="cursor: pointer; color: #666; font-size: 0.9em;">📄 Raw Payload</summary>`;
|
||||||
|
html += `<pre style="background: #f6f8fa; padding: 8px; border-radius: 4px; font-size: 0.8em; overflow-x: auto; margin-top: 8px;">${cache.raw_payload}</pre></details>`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html += `<p style="color: #888; font-style: italic;">No cached status available for this unit.</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch and display device logs
|
||||||
|
try {
|
||||||
|
const logsRes = await fetch(`/api/nl43/${unitId}/logs?limit=50`);
|
||||||
|
if (logsRes.ok) {
|
||||||
|
const logsData = await logsRes.json();
|
||||||
|
if (logsData.logs && logsData.logs.length > 0) {
|
||||||
|
html += `<div style="margin-top: 16px; border-top: 1px solid #d0d7de; padding-top: 12px;">`;
|
||||||
|
html += `<h4 style="margin: 0 0 12px 0;">📋 Device Logs (${logsData.stats.total} total)</h4>`;
|
||||||
|
|
||||||
|
// Stats summary
|
||||||
|
if (logsData.stats.by_level) {
|
||||||
|
html += `<div style="margin-bottom: 8px; font-size: 0.85em; color: #666;">`;
|
||||||
|
const levels = logsData.stats.by_level;
|
||||||
|
const parts = [];
|
||||||
|
if (levels.ERROR) parts.push(`<span style="color: #d00;">${levels.ERROR} errors</span>`);
|
||||||
|
if (levels.WARNING) parts.push(`<span style="color: #fa0;">${levels.WARNING} warnings</span>`);
|
||||||
|
if (levels.INFO) parts.push(`${levels.INFO} info`);
|
||||||
|
html += parts.join(' · ');
|
||||||
|
html += `</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log entries (collapsible)
|
||||||
|
html += `<details open><summary style="cursor: pointer; font-size: 0.9em; margin-bottom: 8px;">Recent entries (${logsData.logs.length})</summary>`;
|
||||||
|
html += `<div style="max-height: 300px; overflow-y: auto; background: #f6f8fa; border: 1px solid #d0d7de; border-radius: 4px; padding: 8px; font-size: 0.8em; font-family: monospace;">`;
|
||||||
|
|
||||||
|
logsData.logs.forEach(entry => {
|
||||||
|
const levelColor = {
|
||||||
|
'ERROR': '#d00',
|
||||||
|
'WARNING': '#b86e00',
|
||||||
|
'INFO': '#0969da',
|
||||||
|
'DEBUG': '#888'
|
||||||
|
}[entry.level] || '#666';
|
||||||
|
|
||||||
|
const time = new Date(entry.timestamp).toLocaleString();
|
||||||
|
html += `<div style="margin-bottom: 4px; border-bottom: 1px solid #eee; padding-bottom: 4px;">`;
|
||||||
|
html += `<span style="color: #888;">${time}</span> `;
|
||||||
|
html += `<span style="color: ${levelColor}; font-weight: 600;">[${entry.level}]</span> `;
|
||||||
|
html += `<span style="color: #666;">[${entry.category}]</span> `;
|
||||||
|
html += `${entry.message}`;
|
||||||
|
html += `</div>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
html += `</div></details>`;
|
||||||
|
html += `</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (logErr) {
|
||||||
|
console.log('Could not fetch device logs:', logErr);
|
||||||
|
}
|
||||||
|
|
||||||
|
resultsEl.innerHTML = html;
|
||||||
|
log(`Diagnostics complete: ${data.overall_status}`);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
resultsEl.innerHTML = `<p style="color: #d00;">Error running diagnostics: ${err.message}</p>`;
|
||||||
|
log(`Diagnostics error: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearDiagnostics() {
|
||||||
|
document.getElementById('diagnosticsResults').innerHTML = '';
|
||||||
|
log('Diagnostics cleared');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveConfig() {
|
||||||
|
const unitId = document.getElementById('unitId').value;
|
||||||
|
const host = document.getElementById('host').value;
|
||||||
|
const port = parseInt(document.getElementById('port').value, 10);
|
||||||
|
const tcpEnabled = document.getElementById('tcpEnabled').checked;
|
||||||
|
const ftpEnabled = document.getElementById('ftpEnabled').checked;
|
||||||
|
const ftpUsername = document.getElementById('ftpUsername').value;
|
||||||
|
const ftpPassword = document.getElementById('ftpPassword').value;
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
host,
|
||||||
|
tcp_port: port,
|
||||||
|
tcp_enabled: tcpEnabled,
|
||||||
|
ftp_enabled: ftpEnabled
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only include FTP credentials if FTP is enabled
|
||||||
|
if (ftpEnabled) {
|
||||||
|
config.ftp_username = ftpUsername;
|
||||||
|
config.ftp_password = ftpPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`/api/nl43/${unitId}/config`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(config)
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
log(`Saved config: ${JSON.stringify(data)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadConfig() {
|
||||||
|
const unitId = document.getElementById('unitId').value;
|
||||||
|
const res = await fetch(`/api/nl43/${unitId}/config`);
|
||||||
|
if (!res.ok) {
|
||||||
|
log(`Load config failed: ${res.status}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const response = await res.json();
|
||||||
|
const data = response.data;
|
||||||
|
document.getElementById('host').value = data.host;
|
||||||
|
document.getElementById('port').value = data.tcp_port;
|
||||||
|
document.getElementById('tcpEnabled').checked = data.tcp_enabled || false;
|
||||||
|
document.getElementById('ftpEnabled').checked = data.ftp_enabled || false;
|
||||||
|
|
||||||
|
// Show/hide FTP credentials based on FTP enabled status
|
||||||
|
toggleFtpCredentials();
|
||||||
|
|
||||||
|
log(`Loaded config: ${JSON.stringify(data)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function start() {
|
||||||
|
const unitId = document.getElementById('unitId').value;
|
||||||
|
const res = await fetch(`/api/nl43/${unitId}/start`, { method: 'POST' });
|
||||||
|
log(`Start: ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stop() {
|
||||||
|
const unitId = document.getElementById('unitId').value;
|
||||||
|
const res = await fetch(`/api/nl43/${unitId}/stop`, { method: 'POST' });
|
||||||
|
log(`Stop: ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function store() {
|
||||||
|
const unitId = document.getElementById('unitId').value;
|
||||||
|
const res = await fetch(`/api/nl43/${unitId}/store`, { method: 'POST' });
|
||||||
|
const data = await res.json();
|
||||||
|
log(`Store: ${res.status} - ${data.message || JSON.stringify(data)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function live() {
|
||||||
|
const unitId = document.getElementById('unitId').value;
|
||||||
|
const res = await fetch(`/api/nl43/${unitId}/live`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
log(`Live failed: ${res.status} ${JSON.stringify(data)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
statusEl.textContent = JSON.stringify(data.data, null, 2);
|
||||||
|
log(`Live: ${JSON.stringify(data.data)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getResults() {
|
||||||
|
const unitId = document.getElementById('unitId').value;
|
||||||
|
const res = await fetch(`/api/nl43/${unitId}/results`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
log(`Get Results failed: ${res.status} ${JSON.stringify(data)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
statusEl.textContent = JSON.stringify(data.data, null, 2);
|
||||||
|
log(`Results (DLC): Retrieved final calculation data`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// New measurement control functions
|
||||||
|
async function pause() {
|
||||||
|
const unitId = document.getElementById('unitId').value;
|
||||||
|
const res = await fetch(`/api/nl43/${unitId}/pause`, { method: 'POST' });
|
||||||
|
const data = await res.json();
|
||||||
|
log(`Pause: ${res.status} - ${data.message || JSON.stringify(data)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resume() {
|
||||||
|
const unitId = document.getElementById('unitId').value;
|
||||||
|
const res = await fetch(`/api/nl43/${unitId}/resume`, { method: 'POST' });
|
||||||
|
const data = await res.json();
|
||||||
|
log(`Resume: ${res.status} - ${data.message || JSON.stringify(data)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reset() {
|
||||||
|
const unitId = document.getElementById('unitId').value;
|
||||||
|
const res = await fetch(`/api/nl43/${unitId}/reset`, { method: 'POST' });
|
||||||
|
const data = await res.json();
|
||||||
|
log(`Reset: ${res.status} - ${data.message || JSON.stringify(data)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Power management functions
|
||||||
|
async function sleepDevice() {
|
||||||
|
const unitId = document.getElementById('unitId').value;
|
||||||
|
const res = await fetch(`/api/nl43/${unitId}/sleep`, { method: 'POST' });
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
log(`Device entering sleep mode`);
|
||||||
|
} else {
|
||||||
|
log(`Sleep failed: ${res.status} - ${data.detail}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function wakeDevice() {
|
||||||
|
const unitId = document.getElementById('unitId').value;
|
||||||
|
const res = await fetch(`/api/nl43/${unitId}/wake`, { method: 'POST' });
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
log(`Device waking from sleep mode`);
|
||||||
|
} else {
|
||||||
|
log(`Wake failed: ${res.status} - ${data.detail}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSleepStatus() {
|
||||||
|
const unitId = document.getElementById('unitId').value;
|
||||||
|
const res = await fetch(`/api/nl43/${unitId}/sleep/status`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
log(`Sleep Status: ${data.sleep_status}`);
|
||||||
|
} else {
|
||||||
|
log(`Get Sleep Status failed: ${res.status} - ${data.detail}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Device info functions
|
||||||
|
async function getBattery() {
|
||||||
|
const unitId = document.getElementById('unitId').value;
|
||||||
|
const res = await fetch(`/api/nl43/${unitId}/battery`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
log(`Battery Level: ${data.battery_level}%`);
|
||||||
|
} else {
|
||||||
|
log(`Get Battery failed: ${res.status} - ${data.detail}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getClock() {
|
||||||
|
const unitId = document.getElementById('unitId').value;
|
||||||
|
const res = await fetch(`/api/nl43/${unitId}/clock`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
log(`Device Clock: ${data.clock}`);
|
||||||
|
} else {
|
||||||
|
log(`Get Clock failed: ${res.status} - ${data.detail}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncClock() {
|
||||||
|
const unitId = document.getElementById('unitId').value;
|
||||||
|
const now = new Date();
|
||||||
|
const datetime = `${now.getFullYear()}/${String(now.getMonth() + 1).padStart(2, '0')}/${String(now.getDate()).padStart(2, '0')},${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}`;
|
||||||
|
|
||||||
|
const res = await fetch(`/api/nl43/${unitId}/clock`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ datetime })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
log(`Clock synced to: ${datetime}`);
|
||||||
|
} else {
|
||||||
|
log(`Sync Clock failed: ${res.status} - ${data.detail}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Measurement settings functions
|
||||||
|
async function getAllSettings() {
|
||||||
|
const unitId = document.getElementById('unitId').value;
|
||||||
|
log('Retrieving all device settings (this may take 10-15 seconds)...');
|
||||||
|
|
||||||
|
const res = await fetch(`/api/nl43/${unitId}/settings`);
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
log(`Get All Settings failed: ${res.status} - ${data.detail || JSON.stringify(data)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display in status area
|
||||||
|
statusEl.textContent = JSON.stringify(data.settings, null, 2);
|
||||||
|
|
||||||
|
// Log summary
|
||||||
|
log('=== ALL DEVICE SETTINGS ===');
|
||||||
|
Object.entries(data.settings).forEach(([key, value]) => {
|
||||||
|
log(`${key}: ${value}`);
|
||||||
|
});
|
||||||
|
log('===========================');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getFreqWeighting() {
|
||||||
|
const unitId = document.getElementById('unitId').value;
|
||||||
|
const res = await fetch(`/api/nl43/${unitId}/frequency-weighting?channel=Main`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
log(`Frequency Weighting (Main): ${data.frequency_weighting}`);
|
||||||
|
} else {
|
||||||
|
log(`Get Freq Weighting failed: ${res.status} - ${data.detail}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setFreqWeighting(weighting) {
|
||||||
|
const unitId = document.getElementById('unitId').value;
|
||||||
|
const res = await fetch(`/api/nl43/${unitId}/frequency-weighting`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ weighting, channel: 'Main' })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
log(`Frequency Weighting set to: ${weighting}`);
|
||||||
|
} else {
|
||||||
|
log(`Set Freq Weighting failed: ${res.status} - ${data.detail}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getTimeWeighting() {
|
||||||
|
const unitId = document.getElementById('unitId').value;
|
||||||
|
const res = await fetch(`/api/nl43/${unitId}/time-weighting?channel=Main`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
log(`Time Weighting (Main): ${data.time_weighting}`);
|
||||||
|
} else {
|
||||||
|
log(`Get Time Weighting failed: ${res.status} - ${data.detail}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setTimeWeighting(weighting) {
|
||||||
|
const unitId = document.getElementById('unitId').value;
|
||||||
|
const res = await fetch(`/api/nl43/${unitId}/time-weighting`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ weighting, channel: 'Main' })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
log(`Time Weighting set to: ${weighting}`);
|
||||||
|
} else {
|
||||||
|
log(`Set Time Weighting failed: ${res.status} - ${data.detail}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleStream() {
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
|
stopStream();
|
||||||
|
} else {
|
||||||
|
startStream();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startStream() {
|
||||||
|
const unitId = document.getElementById('unitId').value;
|
||||||
|
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const wsUrl = `${wsProtocol}//${window.location.host}/api/nl43/${unitId}/stream`;
|
||||||
|
|
||||||
|
log(`Connecting to WebSocket: ${wsUrl}`);
|
||||||
|
streamUpdateCount = 0;
|
||||||
|
|
||||||
|
ws = new WebSocket(wsUrl);
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
log('WebSocket connected - DRD streaming started');
|
||||||
|
streamBtn.textContent = 'Stop Stream';
|
||||||
|
streamStatus.textContent = 'Connected';
|
||||||
|
streamStatus.style.color = '#0a0';
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
log(`Stream error: ${data.error} - ${data.detail || ''}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
streamUpdateCount++;
|
||||||
|
|
||||||
|
// Update status display with live data
|
||||||
|
const displayData = {
|
||||||
|
unit_id: data.unit_id,
|
||||||
|
timestamp: data.timestamp,
|
||||||
|
lp: data.lp,
|
||||||
|
leq: data.leq,
|
||||||
|
lmax: data.lmax,
|
||||||
|
lmin: data.lmin,
|
||||||
|
lpeak: data.lpeak
|
||||||
|
};
|
||||||
|
statusEl.textContent = JSON.stringify(displayData, null, 2);
|
||||||
|
|
||||||
|
// Log every 10th update to avoid spamming
|
||||||
|
if (streamUpdateCount % 10 === 0) {
|
||||||
|
log(`Stream update #${streamUpdateCount}: Lp=${data.lp} Leq=${data.leq} Lpeak=${data.lpeak}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = (error) => {
|
||||||
|
log('WebSocket error occurred');
|
||||||
|
console.error('WebSocket error:', error);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
log(`WebSocket closed (received ${streamUpdateCount} updates)`);
|
||||||
|
streamBtn.textContent = 'Start Stream';
|
||||||
|
streamStatus.textContent = 'Not connected';
|
||||||
|
streamStatus.style.color = '#888';
|
||||||
|
ws = null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopStream() {
|
||||||
|
if (ws) {
|
||||||
|
log('Closing WebSocket...');
|
||||||
|
ws.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup on page unload
|
||||||
|
window.addEventListener('beforeunload', () => {
|
||||||
|
if (ws) ws.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// FTP Functions
|
||||||
|
async function enableFTP() {
|
||||||
|
const unitId = document.getElementById('unitId').value;
|
||||||
|
const res = await fetch(`/api/nl43/${unitId}/ftp/enable`, { method: 'POST' });
|
||||||
|
const data = await res.json();
|
||||||
|
log(`Enable FTP: ${res.status} - ${data.message || JSON.stringify(data)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disableFTP() {
|
||||||
|
const unitId = document.getElementById('unitId').value;
|
||||||
|
const res = await fetch(`/api/nl43/${unitId}/ftp/disable`, { method: 'POST' });
|
||||||
|
const data = await res.json();
|
||||||
|
log(`Disable FTP: ${res.status} - ${data.message || JSON.stringify(data)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkFTPStatus() {
|
||||||
|
const unitId = document.getElementById('unitId').value;
|
||||||
|
const res = await fetch(`/api/nl43/${unitId}/ftp/status`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
log(`FTP Status: ${data.ftp_status} (enabled: ${data.ftp_enabled})`);
|
||||||
|
} else {
|
||||||
|
log(`FTP Status check failed: ${res.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentPath = '/';
|
||||||
|
|
||||||
|
async function listFiles(path = '/') {
|
||||||
|
currentPath = path;
|
||||||
|
const unitId = document.getElementById('unitId').value;
|
||||||
|
const res = await fetch(`/api/nl43/${unitId}/ftp/files?path=${encodeURIComponent(path)}`);
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
log(`List files failed: ${res.status} ${JSON.stringify(data)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileListEl = document.getElementById('fileList');
|
||||||
|
fileListEl.innerHTML = '';
|
||||||
|
|
||||||
|
// Add breadcrumb navigation
|
||||||
|
const breadcrumb = document.createElement('div');
|
||||||
|
breadcrumb.style.marginBottom = '8px';
|
||||||
|
breadcrumb.style.padding = '4px';
|
||||||
|
breadcrumb.style.background = '#e1e4e8';
|
||||||
|
breadcrumb.style.borderRadius = '3px';
|
||||||
|
breadcrumb.innerHTML = '<strong>Path:</strong> ';
|
||||||
|
|
||||||
|
const pathParts = path.split('/').filter(p => p);
|
||||||
|
let builtPath = '/';
|
||||||
|
|
||||||
|
// Root link
|
||||||
|
const rootLink = document.createElement('a');
|
||||||
|
rootLink.href = '#';
|
||||||
|
rootLink.textContent = '/';
|
||||||
|
rootLink.style.marginRight = '4px';
|
||||||
|
rootLink.onclick = (e) => { e.preventDefault(); listFiles('/'); };
|
||||||
|
breadcrumb.appendChild(rootLink);
|
||||||
|
|
||||||
|
// Path component links
|
||||||
|
pathParts.forEach((part, idx) => {
|
||||||
|
builtPath += part + '/';
|
||||||
|
const linkPath = builtPath;
|
||||||
|
|
||||||
|
const separator = document.createElement('span');
|
||||||
|
separator.textContent = ' / ';
|
||||||
|
breadcrumb.appendChild(separator);
|
||||||
|
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = '#';
|
||||||
|
link.textContent = part;
|
||||||
|
link.style.marginRight = '4px';
|
||||||
|
if (idx === pathParts.length - 1) {
|
||||||
|
link.style.fontWeight = 'bold';
|
||||||
|
link.style.color = '#000';
|
||||||
|
}
|
||||||
|
link.onclick = (e) => { e.preventDefault(); listFiles(linkPath); };
|
||||||
|
breadcrumb.appendChild(link);
|
||||||
|
});
|
||||||
|
|
||||||
|
fileListEl.appendChild(breadcrumb);
|
||||||
|
|
||||||
|
if (data.files.length === 0) {
|
||||||
|
const emptyDiv = document.createElement('div');
|
||||||
|
emptyDiv.textContent = 'No files found';
|
||||||
|
emptyDiv.style.padding = '8px';
|
||||||
|
fileListEl.appendChild(emptyDiv);
|
||||||
|
log(`No files found in ${path}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`Found ${data.count} files in ${path}`);
|
||||||
|
|
||||||
|
data.files.forEach(file => {
|
||||||
|
const fileDiv = document.createElement('div');
|
||||||
|
fileDiv.style.marginBottom = '8px';
|
||||||
|
fileDiv.style.padding = '4px';
|
||||||
|
fileDiv.style.borderBottom = '1px solid #ddd';
|
||||||
|
|
||||||
|
const icon = file.is_dir ? '📁' : '📄';
|
||||||
|
const size = file.is_dir ? '' : ` (${(file.size / 1024).toFixed(1)} KB)`;
|
||||||
|
|
||||||
|
if (file.is_dir) {
|
||||||
|
fileDiv.innerHTML = `
|
||||||
|
${icon} <a href="#" onclick="event.preventDefault(); listFiles('${file.path}');" style="font-weight: bold;">${file.name}</a>
|
||||||
|
<br><small style="color: #666;">${file.path}</small>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
fileDiv.innerHTML = `
|
||||||
|
${icon} <strong>${file.name}</strong>${size}
|
||||||
|
<button onclick="downloadFile('${file.path}')" style="margin-left: 8px; padding: 2px 6px; font-size: 0.9em;">Download</button>
|
||||||
|
<br><small style="color: #666;">${file.path}</small>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
fileListEl.appendChild(fileDiv);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadFile(remotePath) {
|
||||||
|
const unitId = document.getElementById('unitId').value;
|
||||||
|
log(`Downloading file: ${remotePath}...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/nl43/${unitId}/ftp/download`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ remote_path: remotePath })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
log(`Download failed: ${res.status} - ${data.detail || JSON.stringify(data)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger browser download
|
||||||
|
const blob = await res.blob();
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = remotePath.split('/').pop();
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
document.body.removeChild(a);
|
||||||
|
|
||||||
|
log(`Downloaded: ${remotePath}`);
|
||||||
|
} catch (error) {
|
||||||
|
log(`Download error: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,624 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>SLMM Roster - Sound Level Meter Configuration</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
padding: 24px;
|
||||||
|
background: #f6f8fa;
|
||||||
|
}
|
||||||
|
.container { max-width: 1400px; margin: 0 auto; }
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
padding: 16px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
h1 { margin: 0; font-size: 24px; }
|
||||||
|
.nav { display: flex; gap: 12px; }
|
||||||
|
.btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: 1px solid #d0d7de;
|
||||||
|
background: white;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
color: #24292f;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.btn:hover { background: #f6f8fa; }
|
||||||
|
.btn-primary {
|
||||||
|
background: #2da44e;
|
||||||
|
color: white;
|
||||||
|
border-color: #2da44e;
|
||||||
|
}
|
||||||
|
.btn-primary:hover { background: #2c974b; }
|
||||||
|
.btn-danger {
|
||||||
|
background: #cf222e;
|
||||||
|
color: white;
|
||||||
|
border-color: #cf222e;
|
||||||
|
}
|
||||||
|
.btn-danger:hover { background: #a40e26; }
|
||||||
|
.btn-small {
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
.table-container {
|
||||||
|
background: white;
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
background: #f6f8fa;
|
||||||
|
padding: 12px;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
border-bottom: 2px solid #d0d7de;
|
||||||
|
font-size: 13px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
td {
|
||||||
|
padding: 12px;
|
||||||
|
border-bottom: 1px solid #d0d7de;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
tr:hover { background: #f6f8fa; }
|
||||||
|
.status-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.status-ok {
|
||||||
|
background: #dafbe1;
|
||||||
|
color: #1a7f37;
|
||||||
|
}
|
||||||
|
.status-unknown {
|
||||||
|
background: #eaeef2;
|
||||||
|
color: #57606a;
|
||||||
|
}
|
||||||
|
.status-error {
|
||||||
|
background: #ffebe9;
|
||||||
|
color: #cf222e;
|
||||||
|
}
|
||||||
|
.checkbox-cell {
|
||||||
|
text-align: center;
|
||||||
|
width: 80px;
|
||||||
|
}
|
||||||
|
.checkbox-cell input[type="checkbox"] {
|
||||||
|
cursor: pointer;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
.actions-cell {
|
||||||
|
white-space: nowrap;
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 48px;
|
||||||
|
color: #57606a;
|
||||||
|
}
|
||||||
|
.empty-state-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0,0,0,0.5);
|
||||||
|
z-index: 1000;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.modal.active { display: flex; }
|
||||||
|
.modal-content {
|
||||||
|
background: white;
|
||||||
|
padding: 24px;
|
||||||
|
border-radius: 6px;
|
||||||
|
max-width: 600px;
|
||||||
|
width: 90%;
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.modal-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
.close-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #57606a;
|
||||||
|
padding: 0;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
.close-btn:hover { color: #24292f; }
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.form-group input[type="text"],
|
||||||
|
.form-group input[type="number"],
|
||||||
|
.form-group input[type="password"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #d0d7de;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.form-group input[type="checkbox"] {
|
||||||
|
width: auto;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
.checkbox-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-weight: normal;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
top: 24px;
|
||||||
|
right: 24px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #24292f;
|
||||||
|
color: white;
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||||
|
z-index: 2000;
|
||||||
|
display: none;
|
||||||
|
min-width: 300px;
|
||||||
|
}
|
||||||
|
.toast.active {
|
||||||
|
display: block;
|
||||||
|
animation: slideIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
@keyframes slideIn {
|
||||||
|
from {
|
||||||
|
transform: translateX(400px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.toast-success { background: #2da44e; }
|
||||||
|
.toast-error { background: #cf222e; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>📊 Sound Level Meter Roster</h1>
|
||||||
|
<div class="nav">
|
||||||
|
<a href="/" class="btn">← Back to Control Panel</a>
|
||||||
|
<button class="btn btn-primary" onclick="openAddModal()">+ Add Device</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-container">
|
||||||
|
<table id="rosterTable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Unit ID</th>
|
||||||
|
<th>Host / IP</th>
|
||||||
|
<th>TCP Port</th>
|
||||||
|
<th>FTP Port</th>
|
||||||
|
<th class="checkbox-cell">TCP</th>
|
||||||
|
<th class="checkbox-cell">FTP</th>
|
||||||
|
<th class="checkbox-cell">Polling</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th class="actions-cell">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="rosterBody">
|
||||||
|
<tr>
|
||||||
|
<td colspan="9" style="text-align: center; padding: 24px;">
|
||||||
|
Loading...
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add/Edit Modal -->
|
||||||
|
<div id="deviceModal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 id="modalTitle">Add Device</h2>
|
||||||
|
<button class="close-btn" onclick="closeModal()">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="deviceForm" onsubmit="saveDevice(event)">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="unitId">Unit ID *</label>
|
||||||
|
<input type="text" id="unitId" required placeholder="e.g., nl43-1, slm-site-a" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="host">Host / IP Address *</label>
|
||||||
|
<input type="text" id="host" required placeholder="e.g., 192.168.1.100" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="tcpPort">TCP Port *</label>
|
||||||
|
<input type="number" id="tcpPort" required value="2255" min="1" max="65535" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="ftpPort">FTP Port</label>
|
||||||
|
<input type="number" id="ftpPort" value="21" min="1" max="65535" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" id="tcpEnabled" checked />
|
||||||
|
TCP Enabled (required for remote control)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" id="ftpEnabled" onchange="toggleFtpCredentials()" />
|
||||||
|
FTP Enabled (for file downloads)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="ftpCredentialsSection" style="display: none; padding: 12px; background: #f6f8fa; border-radius: 6px; margin-bottom: 16px;">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="ftpUsername">FTP Username</label>
|
||||||
|
<input type="text" id="ftpUsername" placeholder="Default: USER" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="ftpPassword">FTP Password</label>
|
||||||
|
<input type="password" id="ftpPassword" placeholder="Default: 0000" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" id="pollEnabled" checked />
|
||||||
|
Enable background polling (status updates)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="pollInterval">Polling Interval (seconds)</label>
|
||||||
|
<input type="number" id="pollInterval" value="60" min="10" max="3600" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" class="btn" onclick="closeModal()">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Save Device</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toast Notification -->
|
||||||
|
<div id="toast" class="toast"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let devices = [];
|
||||||
|
let editingDeviceId = null;
|
||||||
|
|
||||||
|
// Load roster on page load
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
loadRoster();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadRoster() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/nl43/roster');
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
showToast('Failed to load roster', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
devices = data.devices || [];
|
||||||
|
renderRoster();
|
||||||
|
} catch (err) {
|
||||||
|
showToast('Error loading roster: ' + err.message, 'error');
|
||||||
|
console.error('Load roster error:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRoster() {
|
||||||
|
const tbody = document.getElementById('rosterBody');
|
||||||
|
|
||||||
|
if (devices.length === 0) {
|
||||||
|
tbody.innerHTML = `
|
||||||
|
<tr>
|
||||||
|
<td colspan="9" class="empty-state">
|
||||||
|
<div class="empty-state-icon">📭</div>
|
||||||
|
<div><strong>No devices configured</strong></div>
|
||||||
|
<div style="margin-top: 8px; font-size: 14px;">Click "Add Device" to configure your first sound level meter</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody.innerHTML = devices.map(device => `
|
||||||
|
<tr>
|
||||||
|
<td><strong>${escapeHtml(device.unit_id)}</strong></td>
|
||||||
|
<td>${escapeHtml(device.host)}</td>
|
||||||
|
<td>${device.tcp_port}</td>
|
||||||
|
<td>${device.ftp_port || 21}</td>
|
||||||
|
<td class="checkbox-cell">
|
||||||
|
<input type="checkbox" ${device.tcp_enabled ? 'checked' : ''} disabled />
|
||||||
|
</td>
|
||||||
|
<td class="checkbox-cell">
|
||||||
|
<input type="checkbox" ${device.ftp_enabled ? 'checked' : ''} disabled />
|
||||||
|
</td>
|
||||||
|
<td class="checkbox-cell">
|
||||||
|
<input type="checkbox" ${device.poll_enabled ? 'checked' : ''} disabled />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
${getStatusBadge(device)}
|
||||||
|
</td>
|
||||||
|
<td class="actions-cell">
|
||||||
|
<button class="btn btn-small" onclick="testDevice('${escapeHtml(device.unit_id)}')">Test</button>
|
||||||
|
<button class="btn btn-small" onclick="openEditModal('${escapeHtml(device.unit_id)}')">Edit</button>
|
||||||
|
<button class="btn btn-small btn-danger" onclick="deleteDevice('${escapeHtml(device.unit_id)}')">Delete</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusBadge(device) {
|
||||||
|
if (!device.status) {
|
||||||
|
return '<span class="status-badge status-unknown">Unknown</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (device.status.is_reachable === false) {
|
||||||
|
return '<span class="status-badge status-error">Offline</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (device.status.last_success) {
|
||||||
|
const lastSeen = new Date(device.status.last_success);
|
||||||
|
const ago = Math.floor((Date.now() - lastSeen) / 1000);
|
||||||
|
if (ago < 300) { // Less than 5 minutes
|
||||||
|
return '<span class="status-badge status-ok">Online</span>';
|
||||||
|
} else {
|
||||||
|
return `<span class="status-badge status-unknown">Stale (${Math.floor(ago / 60)}m ago)</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '<span class="status-badge status-unknown">Unknown</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const map = {
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
"'": '''
|
||||||
|
};
|
||||||
|
return String(text).replace(/[&<>"']/g, m => map[m]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAddModal() {
|
||||||
|
editingDeviceId = null;
|
||||||
|
document.getElementById('modalTitle').textContent = 'Add Device';
|
||||||
|
document.getElementById('deviceForm').reset();
|
||||||
|
document.getElementById('unitId').disabled = false;
|
||||||
|
document.getElementById('tcpEnabled').checked = true;
|
||||||
|
document.getElementById('ftpEnabled').checked = false;
|
||||||
|
document.getElementById('pollEnabled').checked = true;
|
||||||
|
document.getElementById('tcpPort').value = 2255;
|
||||||
|
document.getElementById('ftpPort').value = 21;
|
||||||
|
document.getElementById('pollInterval').value = 60;
|
||||||
|
toggleFtpCredentials();
|
||||||
|
document.getElementById('deviceModal').classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditModal(unitId) {
|
||||||
|
const device = devices.find(d => d.unit_id === unitId);
|
||||||
|
if (!device) {
|
||||||
|
showToast('Device not found', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
editingDeviceId = unitId;
|
||||||
|
document.getElementById('modalTitle').textContent = 'Edit Device';
|
||||||
|
document.getElementById('unitId').value = device.unit_id;
|
||||||
|
document.getElementById('unitId').disabled = true;
|
||||||
|
document.getElementById('host').value = device.host;
|
||||||
|
document.getElementById('tcpPort').value = device.tcp_port;
|
||||||
|
document.getElementById('ftpPort').value = device.ftp_port || 21;
|
||||||
|
document.getElementById('tcpEnabled').checked = device.tcp_enabled;
|
||||||
|
document.getElementById('ftpEnabled').checked = device.ftp_enabled;
|
||||||
|
document.getElementById('ftpUsername').value = device.ftp_username || '';
|
||||||
|
document.getElementById('ftpPassword').value = device.ftp_password || '';
|
||||||
|
document.getElementById('pollEnabled').checked = device.poll_enabled;
|
||||||
|
document.getElementById('pollInterval').value = device.poll_interval_seconds || 60;
|
||||||
|
toggleFtpCredentials();
|
||||||
|
document.getElementById('deviceModal').classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
document.getElementById('deviceModal').classList.remove('active');
|
||||||
|
editingDeviceId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFtpCredentials() {
|
||||||
|
const ftpEnabled = document.getElementById('ftpEnabled').checked;
|
||||||
|
document.getElementById('ftpCredentialsSection').style.display = ftpEnabled ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveDevice(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const unitId = document.getElementById('unitId').value.trim();
|
||||||
|
const payload = {
|
||||||
|
host: document.getElementById('host').value.trim(),
|
||||||
|
tcp_port: parseInt(document.getElementById('tcpPort').value),
|
||||||
|
ftp_port: parseInt(document.getElementById('ftpPort').value),
|
||||||
|
tcp_enabled: document.getElementById('tcpEnabled').checked,
|
||||||
|
ftp_enabled: document.getElementById('ftpEnabled').checked,
|
||||||
|
poll_enabled: document.getElementById('pollEnabled').checked,
|
||||||
|
poll_interval_seconds: parseInt(document.getElementById('pollInterval').value)
|
||||||
|
};
|
||||||
|
|
||||||
|
if (payload.ftp_enabled) {
|
||||||
|
const username = document.getElementById('ftpUsername').value.trim();
|
||||||
|
const password = document.getElementById('ftpPassword').value.trim();
|
||||||
|
if (username) payload.ftp_username = username;
|
||||||
|
if (password) payload.ftp_password = password;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = editingDeviceId
|
||||||
|
? `/api/nl43/${editingDeviceId}/config`
|
||||||
|
: `/api/nl43/roster`;
|
||||||
|
|
||||||
|
const method = editingDeviceId ? 'PUT' : 'POST';
|
||||||
|
|
||||||
|
const body = editingDeviceId
|
||||||
|
? payload
|
||||||
|
: { unit_id: unitId, ...payload };
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
showToast(data.detail || 'Failed to save device', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast(editingDeviceId ? 'Device updated successfully' : 'Device added successfully', 'success');
|
||||||
|
closeModal();
|
||||||
|
await loadRoster();
|
||||||
|
} catch (err) {
|
||||||
|
showToast('Error saving device: ' + err.message, 'error');
|
||||||
|
console.error('Save device error:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteDevice(unitId) {
|
||||||
|
if (!confirm(`Are you sure you want to delete "${unitId}"?\n\nThis will remove the device configuration but will not affect the physical device.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/nl43/${unitId}/config`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
showToast(data.detail || 'Failed to delete device', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast('Device deleted successfully', 'success');
|
||||||
|
await loadRoster();
|
||||||
|
} catch (err) {
|
||||||
|
showToast('Error deleting device: ' + err.message, 'error');
|
||||||
|
console.error('Delete device error:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testDevice(unitId) {
|
||||||
|
showToast('Testing device connection...', 'success');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/nl43/${unitId}/diagnostics`);
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
showToast('Device test failed', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusText = {
|
||||||
|
'pass': 'All systems operational ✓',
|
||||||
|
'fail': 'Connection failed ✗',
|
||||||
|
'degraded': 'Partial connectivity ⚠'
|
||||||
|
};
|
||||||
|
|
||||||
|
showToast(statusText[data.overall_status] || 'Test complete',
|
||||||
|
data.overall_status === 'pass' ? 'success' : 'error');
|
||||||
|
|
||||||
|
// Reload to update status
|
||||||
|
await loadRoster();
|
||||||
|
} catch (err) {
|
||||||
|
showToast('Error testing device: ' + err.message, 'error');
|
||||||
|
console.error('Test device error:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(message, type = 'success') {
|
||||||
|
const toast = document.getElementById('toast');
|
||||||
|
toast.textContent = message;
|
||||||
|
toast.className = `toast toast-${type} active`;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.classList.remove('active');
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modal when clicking outside
|
||||||
|
document.getElementById('deviceModal').addEventListener('click', (e) => {
|
||||||
|
if (e.target.id === 'deviceModal') {
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,716 +0,0 @@
|
|||||||
"""
|
|
||||||
test_event_forwarder.py — unit tests for Thor Watcher's SFM event forwarder.
|
|
||||||
|
|
||||||
Covers:
|
|
||||||
- is_event_binary() filename matching (positive + negative cases)
|
|
||||||
- parse_event_filename() / serial_from_filename()
|
|
||||||
- idf_report_path() — the TXT/ subfolder convention
|
|
||||||
- ForwardState load/save round-trip + idempotency check
|
|
||||||
- find_pending_events() against the THORDATA/<Project>/<Unit>/ tree,
|
|
||||||
plus quiescence + grace-period + re-pair logic
|
|
||||||
- _encode_multipart() byte-level shape (boundary + headers)
|
|
||||||
- forward_event_pair() end-to-end against a tiny stdlib HTTP server
|
|
||||||
that mimics seismo-relay's POST /db/import/idf_file endpoint
|
|
||||||
- seed_state_from_folder() walks the tree without POSTing
|
|
||||||
|
|
||||||
Stdlib only — runs with `python -m pytest test_event_forwarder.py`
|
|
||||||
on Python 3.8+ (the watcher's compat target).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import http.server
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import tempfile
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
import unittest
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import event_forwarder as ef
|
|
||||||
|
|
||||||
|
|
||||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def _make_thordata(root: Path, project: str, unit: str) -> Path:
|
|
||||||
"""Create a THORDATA/<project>/<unit>/ folder pair; return unit_dir."""
|
|
||||||
unit_dir = root / project / unit
|
|
||||||
unit_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
return unit_dir
|
|
||||||
|
|
||||||
|
|
||||||
def _touch_with_age(p: Path, age_seconds: float, content: bytes = b"x") -> Path:
|
|
||||||
"""Create a file with controlled mtime."""
|
|
||||||
p.write_bytes(content)
|
|
||||||
target = time.time() - age_seconds
|
|
||||||
os.utime(p, (target, target))
|
|
||||||
return p
|
|
||||||
|
|
||||||
|
|
||||||
def _make_event(unit_dir: Path, name: str, age_seconds: float = 100,
|
|
||||||
content: bytes = b"x") -> Path:
|
|
||||||
return _touch_with_age(unit_dir / name, age_seconds, content)
|
|
||||||
|
|
||||||
|
|
||||||
def _make_txt(unit_dir: Path, base_name: str, age_seconds: float = 100,
|
|
||||||
content: bytes = b"r") -> Path:
|
|
||||||
txt_dir = unit_dir / "TXT"
|
|
||||||
txt_dir.mkdir(exist_ok=True)
|
|
||||||
return _touch_with_age(txt_dir / ef.idf_report_name(base_name),
|
|
||||||
age_seconds, content)
|
|
||||||
|
|
||||||
|
|
||||||
# ── is_event_binary() ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
class TestIsEventBinary(unittest.TestCase):
|
|
||||||
|
|
||||||
def test_recognises_typical_thor_filenames(self):
|
|
||||||
for name in [
|
|
||||||
"UM11719_20231219163444.IDFH",
|
|
||||||
"UM11719_20231219162723.IDFW",
|
|
||||||
"BE9439_20200713124251.IDFH",
|
|
||||||
"UM13981_20220808082418.IDFH",
|
|
||||||
# case-insensitive
|
|
||||||
"um11719_20231219163444.idfh",
|
|
||||||
]:
|
|
||||||
self.assertTrue(ef.is_event_binary(name), name)
|
|
||||||
|
|
||||||
def test_rejects_non_event_extensions(self):
|
|
||||||
for name in [
|
|
||||||
"UM11719_20231219163436.MLG", # monitor log
|
|
||||||
"UM11719_20231219163444.IDFH.txt", # report sidecar
|
|
||||||
"UM11719_20231219164135.IDFW.CDB", # cache database variant
|
|
||||||
"UM11719_20231219164135.IDFH.CDB",
|
|
||||||
"agent.log",
|
|
||||||
"config.json",
|
|
||||||
"foo.bak",
|
|
||||||
"bar.tmp",
|
|
||||||
"UM11719_20231219163444.csv",
|
|
||||||
"UM11719_20231219163444.pdf",
|
|
||||||
"UM11719_20231219163444.html",
|
|
||||||
"UM11719_20231219163444.xml",
|
|
||||||
]:
|
|
||||||
self.assertFalse(ef.is_event_binary(name), name)
|
|
||||||
|
|
||||||
def test_rejects_malformed_filenames(self):
|
|
||||||
for name in [
|
|
||||||
"",
|
|
||||||
"no_extension",
|
|
||||||
"UM_20231219163444.IDFH", # missing serial digits
|
|
||||||
"1234_20231219163444.IDFH", # serial must start with letters
|
|
||||||
"UM11719_2023121916.IDFH", # short timestamp
|
|
||||||
"UM11719_20231219163444.IDFX", # wrong kind
|
|
||||||
"UM11719-20231219163444.IDFH", # wrong separator
|
|
||||||
]:
|
|
||||||
self.assertFalse(ef.is_event_binary(name), name)
|
|
||||||
|
|
||||||
def test_parse_event_filename(self):
|
|
||||||
from datetime import datetime
|
|
||||||
parsed = ef.parse_event_filename("UM11719_20231219163444.IDFW")
|
|
||||||
self.assertIsNotNone(parsed)
|
|
||||||
serial, ts, kind = parsed
|
|
||||||
self.assertEqual(serial, "UM11719")
|
|
||||||
self.assertEqual(ts, datetime(2023, 12, 19, 16, 34, 44))
|
|
||||||
self.assertEqual(kind, "IDFW")
|
|
||||||
|
|
||||||
def test_serial_from_filename(self):
|
|
||||||
self.assertEqual(ef.serial_from_filename("UM11719_20231219163444.IDFH"),
|
|
||||||
"UM11719")
|
|
||||||
self.assertEqual(ef.serial_from_filename("BE9439_20200713124251.IDFH"),
|
|
||||||
"BE9439")
|
|
||||||
self.assertIsNone(ef.serial_from_filename("not_an_event.bin"))
|
|
||||||
|
|
||||||
def test_idf_report_path_uses_txt_subfolder(self):
|
|
||||||
binary = "/foo/THORDATA/Project A/UM11719/UM11719_20231219163444.IDFW"
|
|
||||||
self.assertEqual(
|
|
||||||
ef.idf_report_path(binary),
|
|
||||||
os.path.join("/foo/THORDATA/Project A/UM11719",
|
|
||||||
"TXT", "UM11719_20231219163444.IDFW.txt"),
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_is_histogram_event(self):
|
|
||||||
self.assertTrue(ef.is_histogram_event("UM11719_20231219163444.IDFH"))
|
|
||||||
self.assertTrue(ef.is_histogram_event("um11719_20231219163444.idfh"))
|
|
||||||
self.assertFalse(ef.is_histogram_event("UM11719_20231219162723.IDFW"))
|
|
||||||
|
|
||||||
|
|
||||||
# ── ForwardState ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
class TestForwardState(unittest.TestCase):
|
|
||||||
|
|
||||||
def test_round_trip_persists_marked_entries(self):
|
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
|
||||||
path = os.path.join(tmp, "fwd.json")
|
|
||||||
s = ef.ForwardState(path)
|
|
||||||
self.assertFalse(s.is_forwarded("abc123"))
|
|
||||||
s.mark_forwarded("abc123", "UM11719_20231219163444.IDFW", 8800)
|
|
||||||
self.assertTrue(s.is_forwarded("abc123"))
|
|
||||||
|
|
||||||
# Re-load from disk
|
|
||||||
s2 = ef.ForwardState(path)
|
|
||||||
self.assertTrue(s2.is_forwarded("abc123"))
|
|
||||||
self.assertEqual(s2.count(), 1)
|
|
||||||
|
|
||||||
def test_corrupt_state_file_starts_fresh(self):
|
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
|
||||||
path = os.path.join(tmp, "fwd.json")
|
|
||||||
with open(path, "w") as f:
|
|
||||||
f.write("not valid json {{{")
|
|
||||||
s = ef.ForwardState(path)
|
|
||||||
self.assertEqual(s.count(), 0)
|
|
||||||
|
|
||||||
def test_version_mismatch_starts_fresh(self):
|
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
|
||||||
path = os.path.join(tmp, "fwd.json")
|
|
||||||
with open(path, "w") as f:
|
|
||||||
json.dump({"version": 999, "forwarded": {"x": {}}}, f)
|
|
||||||
s = ef.ForwardState(path)
|
|
||||||
self.assertEqual(s.count(), 0)
|
|
||||||
|
|
||||||
def test_legacy_entries_default_to_had_report_true(self):
|
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
|
||||||
path = os.path.join(tmp, "fwd.json")
|
|
||||||
with open(path, "w") as f:
|
|
||||||
json.dump({
|
|
||||||
"version": 1,
|
|
||||||
"forwarded": {
|
|
||||||
"abc123": {
|
|
||||||
"filename": "UM11719_20231219163444.IDFW",
|
|
||||||
"size": 123,
|
|
||||||
"forwarded_at": "2025-01-01T00:00:00Z",
|
|
||||||
# No had_report field — legacy entry
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, f)
|
|
||||||
state = ef.ForwardState(path)
|
|
||||||
self.assertIs(state.status("abc123"), True)
|
|
||||||
|
|
||||||
def test_status_returns_none_for_unknown_sha(self):
|
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
|
||||||
state = ef.ForwardState(str(Path(tmp) / "fwd.json"))
|
|
||||||
self.assertIs(state.status("never-seen"), None)
|
|
||||||
|
|
||||||
def test_mark_with_had_report_false_then_promote(self):
|
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
|
||||||
state = ef.ForwardState(str(Path(tmp) / "fwd.json"))
|
|
||||||
state.mark_forwarded("xyz", "UM11719_20231219163444.IDFW", 100,
|
|
||||||
had_report=False)
|
|
||||||
self.assertIs(state.status("xyz"), False)
|
|
||||||
state.mark_forwarded("xyz", "UM11719_20231219163444.IDFW", 100,
|
|
||||||
had_report=True)
|
|
||||||
self.assertIs(state.status("xyz"), True)
|
|
||||||
|
|
||||||
|
|
||||||
# ── find_pending_events() ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
class TestFindPendingEvents(unittest.TestCase):
|
|
||||||
|
|
||||||
def test_returns_pair_when_both_files_present_and_quiescent(self):
|
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
|
||||||
root = Path(tmp)
|
|
||||||
unit_dir = _make_thordata(root, "Project A", "UM11719")
|
|
||||||
_make_event(unit_dir, "UM11719_20231219163444.IDFW",
|
|
||||||
age_seconds=120, content=b"binary")
|
|
||||||
_make_txt(unit_dir, "UM11719_20231219163444.IDFW",
|
|
||||||
age_seconds=100, content=b"report")
|
|
||||||
|
|
||||||
state = ef.ForwardState(str(root / "fwd.json"))
|
|
||||||
pending = ef.find_pending_events(
|
|
||||||
str(root), state, max_age_days=30,
|
|
||||||
quiescence_seconds=5, missing_report_grace_seconds=60,
|
|
||||||
)
|
|
||||||
self.assertEqual(len(pending), 1)
|
|
||||||
self.assertEqual(os.path.basename(pending[0][0]),
|
|
||||||
"UM11719_20231219163444.IDFW")
|
|
||||||
self.assertEqual(os.path.basename(pending[0][1]),
|
|
||||||
"UM11719_20231219163444.IDFW.txt")
|
|
||||||
|
|
||||||
def test_idfh_and_idfw_are_separate_events(self):
|
|
||||||
"""A single timestamp produces both .IDFH and .IDFW — they
|
|
||||||
forward as two independent events with their own state entries."""
|
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
|
||||||
root = Path(tmp)
|
|
||||||
unit_dir = _make_thordata(root, "Project A", "UM11719")
|
|
||||||
_make_event(unit_dir, "UM11719_20231219163444.IDFH",
|
|
||||||
age_seconds=120, content=b"histogram")
|
|
||||||
_make_event(unit_dir, "UM11719_20231219163444.IDFW",
|
|
||||||
age_seconds=120, content=b"waveform")
|
|
||||||
_make_txt(unit_dir, "UM11719_20231219163444.IDFH",
|
|
||||||
age_seconds=100, content=b"hreport")
|
|
||||||
_make_txt(unit_dir, "UM11719_20231219163444.IDFW",
|
|
||||||
age_seconds=100, content=b"wreport")
|
|
||||||
|
|
||||||
state = ef.ForwardState(str(root / "fwd.json"))
|
|
||||||
pending = ef.find_pending_events(
|
|
||||||
str(root), state, max_age_days=30,
|
|
||||||
quiescence_seconds=5, missing_report_grace_seconds=60,
|
|
||||||
)
|
|
||||||
self.assertEqual(len(pending), 2)
|
|
||||||
names = sorted(os.path.basename(p[0]) for p in pending)
|
|
||||||
self.assertEqual(names, [
|
|
||||||
"UM11719_20231219163444.IDFH",
|
|
||||||
"UM11719_20231219163444.IDFW",
|
|
||||||
])
|
|
||||||
|
|
||||||
def test_pairing_when_txt_is_in_unit_root_does_not_match(self):
|
|
||||||
"""Sidecars MUST live in the TXT/ subfolder. A stray .txt
|
|
||||||
next to the binary is not the canonical location and should
|
|
||||||
not be picked up as a sidecar."""
|
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
|
||||||
root = Path(tmp)
|
|
||||||
unit_dir = _make_thordata(root, "Project A", "UM11719")
|
|
||||||
_make_event(unit_dir, "UM11719_20231219163444.IDFW",
|
|
||||||
age_seconds=200, content=b"bin")
|
|
||||||
# .txt is in the unit dir, not unit/TXT/
|
|
||||||
_touch_with_age(unit_dir / "UM11719_20231219163444.IDFW.txt",
|
|
||||||
age_seconds=100, content=b"misplaced")
|
|
||||||
|
|
||||||
state = ef.ForwardState(str(root / "fwd.json"))
|
|
||||||
pending = ef.find_pending_events(
|
|
||||||
str(root), state, max_age_days=30,
|
|
||||||
quiescence_seconds=5, missing_report_grace_seconds=60,
|
|
||||||
)
|
|
||||||
# Forward proceeds (grace period elapsed), but WITHOUT pairing
|
|
||||||
self.assertEqual(len(pending), 1)
|
|
||||||
self.assertIsNone(pending[0][1])
|
|
||||||
|
|
||||||
def test_skips_if_already_forwarded(self):
|
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
|
||||||
root = Path(tmp)
|
|
||||||
unit_dir = _make_thordata(root, "Project A", "UM11719")
|
|
||||||
bin_p = _make_event(unit_dir, "UM11719_20231219163444.IDFW",
|
|
||||||
age_seconds=120, content=b"binary")
|
|
||||||
_make_txt(unit_dir, "UM11719_20231219163444.IDFW",
|
|
||||||
age_seconds=100, content=b"report")
|
|
||||||
|
|
||||||
state = ef.ForwardState(str(root / "fwd.json"))
|
|
||||||
digest = ef.sha256_of_file(str(bin_p))
|
|
||||||
state.mark_forwarded(digest, "UM11719_20231219163444.IDFW", len(b"binary"))
|
|
||||||
|
|
||||||
pending = ef.find_pending_events(
|
|
||||||
str(root), state, max_age_days=30,
|
|
||||||
quiescence_seconds=5, missing_report_grace_seconds=60,
|
|
||||||
)
|
|
||||||
self.assertEqual(len(pending), 0)
|
|
||||||
|
|
||||||
def test_skips_if_too_fresh_to_be_quiescent(self):
|
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
|
||||||
root = Path(tmp)
|
|
||||||
unit_dir = _make_thordata(root, "Project A", "UM11719")
|
|
||||||
_make_event(unit_dir, "UM11719_20231219163444.IDFW",
|
|
||||||
age_seconds=1, content=b"binary")
|
|
||||||
_make_txt(unit_dir, "UM11719_20231219163444.IDFW",
|
|
||||||
age_seconds=1, content=b"report")
|
|
||||||
|
|
||||||
state = ef.ForwardState(str(root / "fwd.json"))
|
|
||||||
pending = ef.find_pending_events(
|
|
||||||
str(root), state, max_age_days=30,
|
|
||||||
quiescence_seconds=5, missing_report_grace_seconds=60,
|
|
||||||
)
|
|
||||||
self.assertEqual(len(pending), 0)
|
|
||||||
|
|
||||||
def test_forwards_alone_after_grace_when_txt_missing(self):
|
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
|
||||||
root = Path(tmp)
|
|
||||||
unit_dir = _make_thordata(root, "Project A", "UM11719")
|
|
||||||
_make_event(unit_dir, "UM11719_20231219163444.IDFH",
|
|
||||||
age_seconds=200, content=b"binary")
|
|
||||||
state = ef.ForwardState(str(root / "fwd.json"))
|
|
||||||
pending = ef.find_pending_events(
|
|
||||||
str(root), state, max_age_days=30,
|
|
||||||
quiescence_seconds=5, missing_report_grace_seconds=60,
|
|
||||||
)
|
|
||||||
self.assertEqual(len(pending), 1)
|
|
||||||
bin_path, txt_path = pending[0]
|
|
||||||
self.assertEqual(os.path.basename(bin_path),
|
|
||||||
"UM11719_20231219163444.IDFH")
|
|
||||||
self.assertIsNone(txt_path)
|
|
||||||
|
|
||||||
def test_re_pair_after_late_arriving_txt(self):
|
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
|
||||||
root = Path(tmp)
|
|
||||||
unit_dir = _make_thordata(root, "Project A", "UM11719")
|
|
||||||
bin_p = _make_event(unit_dir, "UM11719_20231219163444.IDFW",
|
|
||||||
age_seconds=200, content=b"binary")
|
|
||||||
state = ef.ForwardState(str(root / "fwd.json"))
|
|
||||||
digest = ef.sha256_of_file(str(bin_p))
|
|
||||||
state.mark_forwarded(digest, "UM11719_20231219163444.IDFW",
|
|
||||||
len(b"binary"), had_report=False)
|
|
||||||
|
|
||||||
# First scan: TXT not present → still skipped.
|
|
||||||
pending = ef.find_pending_events(
|
|
||||||
str(root), state, max_age_days=30,
|
|
||||||
quiescence_seconds=5, missing_report_grace_seconds=60,
|
|
||||||
)
|
|
||||||
self.assertEqual(pending, [])
|
|
||||||
|
|
||||||
# TXT finally appears.
|
|
||||||
_make_txt(unit_dir, "UM11719_20231219163444.IDFW",
|
|
||||||
age_seconds=100, content=b"report")
|
|
||||||
pending = ef.find_pending_events(
|
|
||||||
str(root), state, max_age_days=30,
|
|
||||||
quiescence_seconds=5, missing_report_grace_seconds=60,
|
|
||||||
)
|
|
||||||
self.assertEqual(len(pending), 1)
|
|
||||||
self.assertEqual(os.path.basename(pending[0][1]),
|
|
||||||
"UM11719_20231219163444.IDFW.txt")
|
|
||||||
|
|
||||||
def test_defers_when_txt_missing_and_within_grace(self):
|
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
|
||||||
root = Path(tmp)
|
|
||||||
unit_dir = _make_thordata(root, "Project A", "UM11719")
|
|
||||||
_make_event(unit_dir, "UM11719_20231219163444.IDFW",
|
|
||||||
age_seconds=15, content=b"binary")
|
|
||||||
state = ef.ForwardState(str(root / "fwd.json"))
|
|
||||||
pending = ef.find_pending_events(
|
|
||||||
str(root), state, max_age_days=30,
|
|
||||||
quiescence_seconds=5, missing_report_grace_seconds=60,
|
|
||||||
)
|
|
||||||
self.assertEqual(len(pending), 0)
|
|
||||||
|
|
||||||
def test_skips_old_files_beyond_max_age_days(self):
|
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
|
||||||
root = Path(tmp)
|
|
||||||
unit_dir = _make_thordata(root, "Project A", "UM11719")
|
|
||||||
_make_event(unit_dir, "UM11719_20231219163444.IDFW",
|
|
||||||
age_seconds=10 * 86400, content=b"binary")
|
|
||||||
_make_txt(unit_dir, "UM11719_20231219163444.IDFW",
|
|
||||||
age_seconds=10 * 86400, content=b"report")
|
|
||||||
state = ef.ForwardState(str(root / "fwd.json"))
|
|
||||||
pending = ef.find_pending_events(
|
|
||||||
str(root), state, max_age_days=1,
|
|
||||||
quiescence_seconds=5, missing_report_grace_seconds=60,
|
|
||||||
)
|
|
||||||
self.assertEqual(len(pending), 0)
|
|
||||||
|
|
||||||
def test_ignores_mlg_and_other_non_event_files(self):
|
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
|
||||||
root = Path(tmp)
|
|
||||||
unit_dir = _make_thordata(root, "Project A", "UM11719")
|
|
||||||
_make_event(unit_dir, "UM11719_20231219163436.MLG",
|
|
||||||
age_seconds=120, content=b"mlg")
|
|
||||||
_make_event(unit_dir, "UM11719_20231219164135.IDFW.CDB",
|
|
||||||
age_seconds=120, content=b"cache")
|
|
||||||
_touch_with_age(unit_dir / "agent.log", age_seconds=120, content=b"log")
|
|
||||||
state = ef.ForwardState(str(root / "fwd.json"))
|
|
||||||
pending = ef.find_pending_events(
|
|
||||||
str(root), state, max_age_days=30,
|
|
||||||
quiescence_seconds=5, missing_report_grace_seconds=60,
|
|
||||||
)
|
|
||||||
self.assertEqual(len(pending), 0)
|
|
||||||
|
|
||||||
def test_walks_multiple_projects_and_units(self):
|
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
|
||||||
root = Path(tmp)
|
|
||||||
unit_a = _make_thordata(root, "Project A", "UM11719")
|
|
||||||
unit_b = _make_thordata(root, "Project B", "BE9439")
|
|
||||||
_make_event(unit_a, "UM11719_20231219163444.IDFW", age_seconds=200, content=b"a")
|
|
||||||
_make_event(unit_b, "BE9439_20200713131747.IDFW", age_seconds=200, content=b"b")
|
|
||||||
_make_txt(unit_a, "UM11719_20231219163444.IDFW", age_seconds=100, content=b"ar")
|
|
||||||
_make_txt(unit_b, "BE9439_20200713131747.IDFW", age_seconds=100, content=b"br")
|
|
||||||
|
|
||||||
state = ef.ForwardState(str(root / "fwd.json"))
|
|
||||||
pending = ef.find_pending_events(
|
|
||||||
str(root), state, max_age_days=10000, # BE event is from 2020
|
|
||||||
quiescence_seconds=5, missing_report_grace_seconds=60,
|
|
||||||
)
|
|
||||||
self.assertEqual(len(pending), 2)
|
|
||||||
names = sorted(os.path.basename(p[0]) for p in pending)
|
|
||||||
self.assertEqual(names, [
|
|
||||||
"BE9439_20200713131747.IDFW",
|
|
||||||
"UM11719_20231219163444.IDFW",
|
|
||||||
])
|
|
||||||
|
|
||||||
def test_max_per_pass_caps_returned_count(self):
|
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
|
||||||
root = Path(tmp)
|
|
||||||
unit = _make_thordata(root, "Project A", "UM11719")
|
|
||||||
for i in range(5):
|
|
||||||
name = "UM11719_2023121916344{}.IDFW".format(i)
|
|
||||||
_make_event(unit, name, age_seconds=120 + i, content=("bin-" + str(i)).encode())
|
|
||||||
_make_txt(unit, name, age_seconds=110 + i, content=b"report")
|
|
||||||
state = ef.ForwardState(str(root / "fwd.json"))
|
|
||||||
pending = ef.find_pending_events(
|
|
||||||
str(root), state, max_age_days=30,
|
|
||||||
quiescence_seconds=5, missing_report_grace_seconds=60,
|
|
||||||
max_per_pass=2,
|
|
||||||
)
|
|
||||||
self.assertEqual(len(pending), 2)
|
|
||||||
|
|
||||||
def test_max_per_pass_returns_oldest_first(self):
|
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
|
||||||
root = Path(tmp)
|
|
||||||
unit = _make_thordata(root, "Project A", "UM11719")
|
|
||||||
ages = [200, 150, 100, 50]
|
|
||||||
for i, age in enumerate(ages):
|
|
||||||
name = "UM11719_2023121916344{}.IDFW".format(i)
|
|
||||||
_make_event(unit, name, age_seconds=age, content=("c" + str(i)).encode())
|
|
||||||
_make_txt(unit, name, age_seconds=max(1, age - 10), content=b"r")
|
|
||||||
state = ef.ForwardState(str(root / "fwd.json"))
|
|
||||||
pending = ef.find_pending_events(
|
|
||||||
str(root), state, max_age_days=30,
|
|
||||||
quiescence_seconds=5, missing_report_grace_seconds=60,
|
|
||||||
max_per_pass=2,
|
|
||||||
)
|
|
||||||
names = [os.path.basename(p[0]) for p in pending]
|
|
||||||
# Oldest two should be index 0 (200s) and 1 (150s)
|
|
||||||
self.assertEqual(names, [
|
|
||||||
"UM11719_20231219163440.IDFW",
|
|
||||||
"UM11719_20231219163441.IDFW",
|
|
||||||
])
|
|
||||||
|
|
||||||
|
|
||||||
# ── Seed-state mode ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
class TestSeedStateFromFolder(unittest.TestCase):
|
|
||||||
|
|
||||||
def test_seeds_every_in_window_event_without_posting(self):
|
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
|
||||||
root = Path(tmp)
|
|
||||||
unit = _make_thordata(root, "Project A", "UM11719")
|
|
||||||
for i in range(3):
|
|
||||||
_make_event(unit, "UM11719_2023121916344{}.IDFW".format(i),
|
|
||||||
age_seconds=120 + i, content=("e" + str(i)).encode())
|
|
||||||
# Ignored
|
|
||||||
_make_event(unit, "UM11719_20231219163436.MLG", age_seconds=120, content=b"mlg")
|
|
||||||
|
|
||||||
state = ef.ForwardState(str(root / "seed.json"))
|
|
||||||
counts = ef.seed_state_from_folder(str(root), state, max_age_days=30)
|
|
||||||
self.assertEqual(counts["scanned"], 3)
|
|
||||||
self.assertEqual(counts["seeded"], 3)
|
|
||||||
self.assertEqual(counts["already_known"], 0)
|
|
||||||
self.assertEqual(state.count(), 3)
|
|
||||||
|
|
||||||
def test_seeded_files_are_then_skipped_by_normal_scan(self):
|
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
|
||||||
root = Path(tmp)
|
|
||||||
unit = _make_thordata(root, "Project A", "UM11719")
|
|
||||||
_make_event(unit, "UM11719_20231219163444.IDFW", age_seconds=120, content=b"x")
|
|
||||||
_make_txt(unit, "UM11719_20231219163444.IDFW", age_seconds=110, content=b"r")
|
|
||||||
_make_event(unit, "UM11719_20231219163444.IDFH", age_seconds=120, content=b"y")
|
|
||||||
_make_txt(unit, "UM11719_20231219163444.IDFH", age_seconds=110, content=b"r")
|
|
||||||
|
|
||||||
state = ef.ForwardState(str(root / "seed.json"))
|
|
||||||
ef.seed_state_from_folder(str(root), state, max_age_days=30)
|
|
||||||
|
|
||||||
pending = ef.find_pending_events(
|
|
||||||
str(root), state, max_age_days=30,
|
|
||||||
quiescence_seconds=5, missing_report_grace_seconds=60,
|
|
||||||
)
|
|
||||||
self.assertEqual(len(pending), 0)
|
|
||||||
|
|
||||||
def test_seed_is_idempotent(self):
|
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
|
||||||
root = Path(tmp)
|
|
||||||
unit = _make_thordata(root, "Project A", "UM11719")
|
|
||||||
_make_event(unit, "UM11719_20231219163444.IDFW", age_seconds=120, content=b"x")
|
|
||||||
|
|
||||||
state = ef.ForwardState(str(root / "seed.json"))
|
|
||||||
counts1 = ef.seed_state_from_folder(str(root), state, max_age_days=30)
|
|
||||||
counts2 = ef.seed_state_from_folder(str(root), state, max_age_days=30)
|
|
||||||
self.assertEqual(counts1["seeded"], 1)
|
|
||||||
self.assertEqual(counts2["seeded"], 0)
|
|
||||||
self.assertEqual(counts2["already_known"], 1)
|
|
||||||
self.assertEqual(state.count(), 1)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Multipart encoder ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
class TestMultipartEncoder(unittest.TestCase):
|
|
||||||
|
|
||||||
def test_encodes_two_parts_with_proper_boundary(self):
|
|
||||||
body, content_type = ef._encode_multipart([
|
|
||||||
("files", "a.bin", "application/octet-stream", b"\x01\x02"),
|
|
||||||
("files", "a.txt", "text/plain", b"hello"),
|
|
||||||
])
|
|
||||||
self.assertTrue(content_type.startswith("multipart/form-data; boundary="))
|
|
||||||
boundary = content_type.split("boundary=", 1)[1]
|
|
||||||
self.assertIn(boundary.encode("ascii"), body)
|
|
||||||
|
|
||||||
text = body.decode("latin-1")
|
|
||||||
self.assertIn('name="files"; filename="a.bin"', text)
|
|
||||||
self.assertIn('name="files"; filename="a.txt"', text)
|
|
||||||
self.assertIn("Content-Type: application/octet-stream", text)
|
|
||||||
self.assertIn("Content-Type: text/plain", text)
|
|
||||||
self.assertTrue(text.rstrip("\r\n").endswith(f"--{boundary}--"))
|
|
||||||
|
|
||||||
|
|
||||||
# ── End-to-end forward_event_pair against a fake server ──────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
class _FakeImportHandler(http.server.BaseHTTPRequestHandler):
|
|
||||||
"""Mimics seismo-relay's POST /db/import/idf_file response."""
|
|
||||||
received = [] # class-level capture for test inspection
|
|
||||||
|
|
||||||
def do_POST(self):
|
|
||||||
length = int(self.headers.get("Content-Length", "0"))
|
|
||||||
body = self.rfile.read(length)
|
|
||||||
ctype = self.headers.get("Content-Type", "")
|
|
||||||
|
|
||||||
parts = body.split(b"--" + ctype.split("boundary=")[-1].encode())
|
|
||||||
filenames = []
|
|
||||||
for p in parts:
|
|
||||||
for line in p.split(b"\r\n"):
|
|
||||||
if b'filename="' in line:
|
|
||||||
fn = line.split(b'filename="', 1)[1].split(b'"', 1)[0]
|
|
||||||
filenames.append(fn.decode("latin-1"))
|
|
||||||
|
|
||||||
self.__class__.received.append({
|
|
||||||
"path": self.path,
|
|
||||||
"ctype": ctype,
|
|
||||||
"filenames": filenames,
|
|
||||||
})
|
|
||||||
|
|
||||||
results = []
|
|
||||||
binary_fn = next(
|
|
||||||
(fn for fn in filenames if not fn.lower().endswith(".txt")),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
if binary_fn:
|
|
||||||
results.append({
|
|
||||||
"filename": binary_fn,
|
|
||||||
"status": "ok",
|
|
||||||
"stored_filename": binary_fn,
|
|
||||||
"filesize": len(body),
|
|
||||||
"sha256": "00" * 32,
|
|
||||||
"report_attached": any(fn.lower().endswith(".txt") for fn in filenames),
|
|
||||||
"inserted": 1,
|
|
||||||
"skipped": 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
payload = json.dumps({"count": len(results), "results": results}).encode()
|
|
||||||
self.send_response(200)
|
|
||||||
self.send_header("Content-Type", "application/json")
|
|
||||||
self.send_header("Content-Length", str(len(payload)))
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(payload)
|
|
||||||
|
|
||||||
def log_message(self, *_a, **_kw): # silence the test runner
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _start_fake_server():
|
|
||||||
server = http.server.HTTPServer(("127.0.0.1", 0), _FakeImportHandler)
|
|
||||||
threading.Thread(target=server.serve_forever, daemon=True).start()
|
|
||||||
host, port = server.server_address
|
|
||||||
return server, f"http://{host}:{port}"
|
|
||||||
|
|
||||||
|
|
||||||
class TestForwardEventPair(unittest.TestCase):
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
_FakeImportHandler.received = []
|
|
||||||
self.server, self.base_url = _start_fake_server()
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
self.server.shutdown()
|
|
||||||
self.server.server_close()
|
|
||||||
|
|
||||||
def test_post_with_paired_report(self):
|
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
|
||||||
tmp_p = Path(tmp)
|
|
||||||
bin_p = tmp_p / "UM11719_20231219163444.IDFW"
|
|
||||||
txt_p = tmp_p / "UM11719_20231219163444.IDFW.txt"
|
|
||||||
bin_p.write_bytes(b"\x10\x20\x30 binary")
|
|
||||||
txt_p.write_bytes(b'"SerialNumber : UM11719"\n')
|
|
||||||
|
|
||||||
result = ef.forward_event_pair(
|
|
||||||
self.base_url, str(bin_p), str(txt_p), timeout=5.0,
|
|
||||||
)
|
|
||||||
self.assertEqual(result["status"], "ok")
|
|
||||||
self.assertEqual(result["filename"], "UM11719_20231219163444.IDFW")
|
|
||||||
self.assertTrue(result["report_attached"])
|
|
||||||
|
|
||||||
self.assertEqual(len(_FakeImportHandler.received), 1)
|
|
||||||
req = _FakeImportHandler.received[0]
|
|
||||||
# Path includes the serial-hint auto-extracted from the filename
|
|
||||||
self.assertTrue(req["path"].startswith("/db/import/idf_file"))
|
|
||||||
self.assertIn("serial=UM11719", req["path"])
|
|
||||||
self.assertIn("UM11719_20231219163444.IDFW", req["filenames"])
|
|
||||||
self.assertIn("UM11719_20231219163444.IDFW.txt", req["filenames"])
|
|
||||||
|
|
||||||
def test_post_without_report(self):
|
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
|
||||||
bin_p = Path(tmp) / "UM11719_20231219163444.IDFH"
|
|
||||||
bin_p.write_bytes(b"binary only")
|
|
||||||
|
|
||||||
result = ef.forward_event_pair(
|
|
||||||
self.base_url, str(bin_p), None, timeout=5.0,
|
|
||||||
)
|
|
||||||
self.assertEqual(result["status"], "ok")
|
|
||||||
self.assertFalse(result["report_attached"])
|
|
||||||
req = _FakeImportHandler.received[0]
|
|
||||||
self.assertEqual(req["filenames"], ["UM11719_20231219163444.IDFH"])
|
|
||||||
|
|
||||||
def test_explicit_serial_hint_overrides_auto(self):
|
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
|
||||||
bin_p = Path(tmp) / "UM11719_20231219163444.IDFW"
|
|
||||||
bin_p.write_bytes(b"x")
|
|
||||||
ef.forward_event_pair(
|
|
||||||
self.base_url, str(bin_p), None,
|
|
||||||
serial_hint="OVERRIDE99", timeout=5.0,
|
|
||||||
)
|
|
||||||
req = _FakeImportHandler.received[0]
|
|
||||||
self.assertIn("serial=OVERRIDE99", req["path"])
|
|
||||||
|
|
||||||
|
|
||||||
# ── forward_pending() smoke test ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
class TestForwardPending(unittest.TestCase):
|
|
||||||
"""End-to-end: tree → find → POST → state-update → no re-POST."""
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
_FakeImportHandler.received = []
|
|
||||||
self.server, self.base_url = _start_fake_server()
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
self.server.shutdown()
|
|
||||||
self.server.server_close()
|
|
||||||
|
|
||||||
def test_pass_then_re_pass_is_idempotent(self):
|
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
|
||||||
root = Path(tmp)
|
|
||||||
unit = _make_thordata(root, "Project A", "UM11719")
|
|
||||||
_make_event(unit, "UM11719_20231219163444.IDFW",
|
|
||||||
age_seconds=200, content=b"binary")
|
|
||||||
_make_txt(unit, "UM11719_20231219163444.IDFW",
|
|
||||||
age_seconds=100, content=b"report")
|
|
||||||
_make_event(unit, "UM11719_20231219163444.IDFH",
|
|
||||||
age_seconds=200, content=b"histogram")
|
|
||||||
_make_txt(unit, "UM11719_20231219163444.IDFH",
|
|
||||||
age_seconds=100, content=b"hreport")
|
|
||||||
|
|
||||||
state = ef.ForwardState(str(root / "fwd.json"))
|
|
||||||
counts = ef.forward_pending(
|
|
||||||
str(root), self.base_url, state,
|
|
||||||
max_age_days=30, quiescence_seconds=5,
|
|
||||||
missing_report_grace_seconds=60, timeout=5.0,
|
|
||||||
)
|
|
||||||
self.assertEqual(counts["scanned"], 2)
|
|
||||||
self.assertEqual(counts["forwarded"], 2)
|
|
||||||
self.assertEqual(counts["errors"], 0)
|
|
||||||
self.assertEqual(counts["with_report"], 2)
|
|
||||||
self.assertEqual(state.count(), 2)
|
|
||||||
self.assertEqual(len(_FakeImportHandler.received), 2)
|
|
||||||
|
|
||||||
# Re-pass: nothing pending; no new POSTs.
|
|
||||||
counts2 = ef.forward_pending(
|
|
||||||
str(root), self.base_url, state,
|
|
||||||
max_age_days=30, quiescence_seconds=5,
|
|
||||||
missing_report_grace_seconds=60, timeout=5.0,
|
|
||||||
)
|
|
||||||
self.assertEqual(counts2["scanned"], 0)
|
|
||||||
self.assertEqual(counts2["forwarded"], 0)
|
|
||||||
self.assertEqual(len(_FakeImportHandler.received), 2)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
Executable
+167
@@ -0,0 +1,167 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Manual test script for background polling functionality
|
||||||
|
# Usage: ./test_polling.sh [UNIT_ID]
|
||||||
|
|
||||||
|
BASE_URL="http://localhost:8100/api/nl43"
|
||||||
|
UNIT_ID="${1:-NL43-001}"
|
||||||
|
|
||||||
|
echo "=========================================="
|
||||||
|
echo "Background Polling Test Script"
|
||||||
|
echo "=========================================="
|
||||||
|
echo "Testing device: $UNIT_ID"
|
||||||
|
echo "Base URL: $BASE_URL"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Color codes for output
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
RED='\033[0;31m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Function to print test header
|
||||||
|
test_header() {
|
||||||
|
echo ""
|
||||||
|
echo "=========================================="
|
||||||
|
echo "$1"
|
||||||
|
echo "=========================================="
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to print success
|
||||||
|
success() {
|
||||||
|
echo -e "${GREEN}✓${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to print warning
|
||||||
|
warning() {
|
||||||
|
echo -e "${YELLOW}⚠${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to print error
|
||||||
|
error() {
|
||||||
|
echo -e "${RED}✗${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test 1: Get current polling configuration
|
||||||
|
test_header "Test 1: Get Current Polling Configuration"
|
||||||
|
RESPONSE=$(curl -s "$BASE_URL/$UNIT_ID/polling/config")
|
||||||
|
echo "$RESPONSE" | jq '.'
|
||||||
|
|
||||||
|
if echo "$RESPONSE" | jq -e '.status == "ok"' > /dev/null; then
|
||||||
|
success "Successfully retrieved polling configuration"
|
||||||
|
CURRENT_INTERVAL=$(echo "$RESPONSE" | jq -r '.data.poll_interval_seconds')
|
||||||
|
CURRENT_ENABLED=$(echo "$RESPONSE" | jq -r '.data.poll_enabled')
|
||||||
|
echo " Current interval: ${CURRENT_INTERVAL}s"
|
||||||
|
echo " Polling enabled: $CURRENT_ENABLED"
|
||||||
|
else
|
||||||
|
error "Failed to retrieve polling configuration"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Test 2: Update polling interval to 30 seconds
|
||||||
|
test_header "Test 2: Update Polling Interval to 30 Seconds"
|
||||||
|
RESPONSE=$(curl -s -X PUT "$BASE_URL/$UNIT_ID/polling/config" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"poll_interval_seconds": 30}')
|
||||||
|
echo "$RESPONSE" | jq '.'
|
||||||
|
|
||||||
|
if echo "$RESPONSE" | jq -e '.status == "ok"' > /dev/null; then
|
||||||
|
success "Successfully updated polling interval to 30s"
|
||||||
|
else
|
||||||
|
error "Failed to update polling interval"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Test 3: Check global polling status
|
||||||
|
test_header "Test 3: Check Global Polling Status"
|
||||||
|
RESPONSE=$(curl -s "$BASE_URL/_polling/status")
|
||||||
|
echo "$RESPONSE" | jq '.'
|
||||||
|
|
||||||
|
if echo "$RESPONSE" | jq -e '.status == "ok"' > /dev/null; then
|
||||||
|
success "Successfully retrieved global polling status"
|
||||||
|
POLLER_RUNNING=$(echo "$RESPONSE" | jq -r '.data.poller_running')
|
||||||
|
TOTAL_DEVICES=$(echo "$RESPONSE" | jq -r '.data.total_devices')
|
||||||
|
echo " Poller running: $POLLER_RUNNING"
|
||||||
|
echo " Total devices: $TOTAL_DEVICES"
|
||||||
|
else
|
||||||
|
error "Failed to retrieve global polling status"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Test 4: Wait for automatic poll to occur
|
||||||
|
test_header "Test 4: Wait for Automatic Poll (35 seconds)"
|
||||||
|
warning "Waiting 35 seconds for automatic poll to occur..."
|
||||||
|
for i in {35..1}; do
|
||||||
|
echo -ne " ${i}s remaining...\r"
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
success "Wait complete"
|
||||||
|
|
||||||
|
# Test 5: Check if status was updated by background poller
|
||||||
|
test_header "Test 5: Verify Background Poll Occurred"
|
||||||
|
RESPONSE=$(curl -s "$BASE_URL/$UNIT_ID/status")
|
||||||
|
echo "$RESPONSE" | jq '{last_poll_attempt, last_success, is_reachable, consecutive_failures}'
|
||||||
|
|
||||||
|
if echo "$RESPONSE" | jq -e '.status == "ok"' > /dev/null; then
|
||||||
|
LAST_POLL=$(echo "$RESPONSE" | jq -r '.data.last_poll_attempt')
|
||||||
|
IS_REACHABLE=$(echo "$RESPONSE" | jq -r '.data.is_reachable')
|
||||||
|
FAILURES=$(echo "$RESPONSE" | jq -r '.data.consecutive_failures')
|
||||||
|
|
||||||
|
if [ "$LAST_POLL" != "null" ]; then
|
||||||
|
success "Device was polled by background poller"
|
||||||
|
echo " Last poll: $LAST_POLL"
|
||||||
|
echo " Reachable: $IS_REACHABLE"
|
||||||
|
echo " Failures: $FAILURES"
|
||||||
|
else
|
||||||
|
warning "No automatic poll detected yet"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
error "Failed to retrieve device status"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Test 6: Disable polling
|
||||||
|
test_header "Test 6: Disable Background Polling"
|
||||||
|
RESPONSE=$(curl -s -X PUT "$BASE_URL/$UNIT_ID/polling/config" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"poll_enabled": false}')
|
||||||
|
echo "$RESPONSE" | jq '.'
|
||||||
|
|
||||||
|
if echo "$RESPONSE" | jq -e '.status == "ok"' > /dev/null; then
|
||||||
|
success "Successfully disabled background polling"
|
||||||
|
else
|
||||||
|
error "Failed to disable polling"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Test 7: Verify polling is disabled
|
||||||
|
test_header "Test 7: Verify Polling Disabled in Global Status"
|
||||||
|
RESPONSE=$(curl -s "$BASE_URL/_polling/status")
|
||||||
|
DEVICE_ENABLED=$(echo "$RESPONSE" | jq --arg uid "$UNIT_ID" '.data.devices[] | select(.unit_id == $uid) | .poll_enabled')
|
||||||
|
|
||||||
|
if [ "$DEVICE_ENABLED" == "false" ]; then
|
||||||
|
success "Polling correctly shows as disabled for $UNIT_ID"
|
||||||
|
else
|
||||||
|
warning "Device still appears in polling list or shows as enabled"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Test 8: Re-enable polling with original interval
|
||||||
|
test_header "Test 8: Re-enable Polling with Original Interval"
|
||||||
|
RESPONSE=$(curl -s -X PUT "$BASE_URL/$UNIT_ID/polling/config" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"poll_enabled\": true, \"poll_interval_seconds\": $CURRENT_INTERVAL}")
|
||||||
|
echo "$RESPONSE" | jq '.'
|
||||||
|
|
||||||
|
if echo "$RESPONSE" | jq -e '.status == "ok"' > /dev/null; then
|
||||||
|
success "Successfully re-enabled polling with ${CURRENT_INTERVAL}s interval"
|
||||||
|
else
|
||||||
|
error "Failed to re-enable polling"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
test_header "Test Summary"
|
||||||
|
echo "All tests completed!"
|
||||||
|
echo ""
|
||||||
|
echo "Key endpoints tested:"
|
||||||
|
echo " GET $BASE_URL/{unit_id}/polling/config"
|
||||||
|
echo " PUT $BASE_URL/{unit_id}/polling/config"
|
||||||
|
echo " GET $BASE_URL/_polling/status"
|
||||||
|
echo " GET $BASE_URL/{unit_id}/status (with polling fields)"
|
||||||
|
echo ""
|
||||||
|
success "Background polling feature is working correctly"
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Test script to demonstrate the GET /api/nl43/{unit_id}/settings endpoint.
|
||||||
|
|
||||||
|
This endpoint retrieves all current device settings for verification purposes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
async def test_settings_retrieval():
|
||||||
|
"""Test the settings retrieval functionality."""
|
||||||
|
from app.services import NL43Client
|
||||||
|
|
||||||
|
# Example configuration - adjust these to match your actual device
|
||||||
|
host = "192.168.1.100" # Replace with your NL43 device IP
|
||||||
|
port = 80
|
||||||
|
unit_id = "NL43-001"
|
||||||
|
|
||||||
|
print(f"Connecting to NL43 device at {host}:{port}...")
|
||||||
|
print(f"Unit ID: {unit_id}\n")
|
||||||
|
|
||||||
|
client = NL43Client(host, port)
|
||||||
|
|
||||||
|
try:
|
||||||
|
print("Retrieving all device settings...")
|
||||||
|
settings = await client.get_all_settings()
|
||||||
|
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("DEVICE SETTINGS SUMMARY")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
for key, value in settings.items():
|
||||||
|
print(f"{key:.<30} {value}")
|
||||||
|
|
||||||
|
print("="*60)
|
||||||
|
print(f"\nTotal settings retrieved: {len(settings)}")
|
||||||
|
print("\n✓ Settings retrieval successful!")
|
||||||
|
|
||||||
|
except ConnectionError as e:
|
||||||
|
print(f"\n✗ Connection Error: {e}")
|
||||||
|
print("\nTroubleshooting:")
|
||||||
|
print(" 1. Verify the device IP address and port")
|
||||||
|
print(" 2. Ensure the device is powered on and connected to the network")
|
||||||
|
print(" 3. Check firewall settings")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n✗ Error: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_api_endpoint():
|
||||||
|
"""Demonstrate how to call the API endpoint."""
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("API ENDPOINT USAGE")
|
||||||
|
print("="*60)
|
||||||
|
print("\nTo retrieve all settings via the API, use:")
|
||||||
|
print("\n GET /api/nl43/{unit_id}/settings")
|
||||||
|
print("\nExample with curl:")
|
||||||
|
print("\n curl http://localhost:8000/api/nl43/NL43-001/settings")
|
||||||
|
print("\nExample response:")
|
||||||
|
print("""
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"unit_id": "NL43-001",
|
||||||
|
"settings": {
|
||||||
|
"measurement_state": "Stop",
|
||||||
|
"frequency_weighting": "A",
|
||||||
|
"time_weighting": "F",
|
||||||
|
"measurement_time": "00:01:00",
|
||||||
|
"leq_interval": "1s",
|
||||||
|
"lp_interval": "125ms",
|
||||||
|
"index_number": "0",
|
||||||
|
"battery_level": "100%",
|
||||||
|
"clock": "2025/12/24,20:45:30",
|
||||||
|
"sleep_mode": "Off",
|
||||||
|
"ftp_status": "On"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("NL43 Settings Retrieval Test")
|
||||||
|
print("="*60)
|
||||||
|
print("\nThis test demonstrates the new /api/nl43/{unit_id}/settings endpoint")
|
||||||
|
print("which allows you to view all current device settings for verification.\n")
|
||||||
|
|
||||||
|
# Show API usage
|
||||||
|
asyncio.run(test_api_endpoint())
|
||||||
|
|
||||||
|
# Uncomment below to test actual device connection
|
||||||
|
# asyncio.run(test_settings_retrieval())
|
||||||
|
|
||||||
|
print("\n✓ Test completed!")
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Test script to verify that sleep mode is automatically disabled when:
|
||||||
|
1. Device configuration is created/updated with TCP enabled
|
||||||
|
2. Measurements are started
|
||||||
|
|
||||||
|
This script tests the API endpoints, not the actual device communication.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import json
|
||||||
|
|
||||||
|
BASE_URL = "http://localhost:8100/api/nl43"
|
||||||
|
UNIT_ID = "test-nl43-001"
|
||||||
|
|
||||||
|
def test_config_update():
|
||||||
|
"""Test that config update works (actual sleep mode disable requires real device)"""
|
||||||
|
print("\n=== Testing Config Update ===")
|
||||||
|
|
||||||
|
# Create/update a device config
|
||||||
|
config_data = {
|
||||||
|
"host": "192.168.1.100",
|
||||||
|
"tcp_port": 2255,
|
||||||
|
"tcp_enabled": True,
|
||||||
|
"ftp_enabled": False,
|
||||||
|
"ftp_username": "admin",
|
||||||
|
"ftp_password": "password"
|
||||||
|
}
|
||||||
|
|
||||||
|
print(f"Updating config for {UNIT_ID}...")
|
||||||
|
response = requests.put(f"{BASE_URL}/{UNIT_ID}/config", json=config_data)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
print("✓ Config updated successfully")
|
||||||
|
print(f"Response: {json.dumps(response.json(), indent=2)}")
|
||||||
|
print("\nNote: Sleep mode disable was attempted (will succeed if device is reachable)")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print(f"✗ Config update failed: {response.status_code}")
|
||||||
|
print(f"Error: {response.text}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def test_get_config():
|
||||||
|
"""Test retrieving the config"""
|
||||||
|
print("\n=== Testing Get Config ===")
|
||||||
|
|
||||||
|
response = requests.get(f"{BASE_URL}/{UNIT_ID}/config")
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
print("✓ Config retrieved successfully")
|
||||||
|
print(f"Response: {json.dumps(response.json(), indent=2)}")
|
||||||
|
return True
|
||||||
|
elif response.status_code == 404:
|
||||||
|
print("✗ Config not found (create one first)")
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
print(f"✗ Request failed: {response.status_code}")
|
||||||
|
print(f"Error: {response.text}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def test_start_measurement():
|
||||||
|
"""Test that start measurement attempts to disable sleep mode"""
|
||||||
|
print("\n=== Testing Start Measurement ===")
|
||||||
|
|
||||||
|
print(f"Attempting to start measurement on {UNIT_ID}...")
|
||||||
|
response = requests.post(f"{BASE_URL}/{UNIT_ID}/start")
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
print("✓ Start command accepted")
|
||||||
|
print(f"Response: {json.dumps(response.json(), indent=2)}")
|
||||||
|
print("\nNote: Sleep mode was disabled before starting measurement")
|
||||||
|
return True
|
||||||
|
elif response.status_code == 404:
|
||||||
|
print("✗ Device config not found (create config first)")
|
||||||
|
return False
|
||||||
|
elif response.status_code == 502:
|
||||||
|
print("✗ Device not reachable (expected if no physical device)")
|
||||||
|
print(f"Response: {response.text}")
|
||||||
|
print("\nNote: This is expected behavior when testing without a physical device")
|
||||||
|
return True # This is actually success - the endpoint tried to communicate
|
||||||
|
else:
|
||||||
|
print(f"✗ Request failed: {response.status_code}")
|
||||||
|
print(f"Error: {response.text}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 60)
|
||||||
|
print("Sleep Mode Auto-Disable Test")
|
||||||
|
print("=" * 60)
|
||||||
|
print("\nThis test verifies that sleep mode is automatically disabled")
|
||||||
|
print("when device configs are updated or measurements are started.")
|
||||||
|
print("\nNote: Without a physical device, some operations will fail at")
|
||||||
|
print("the device communication level, but the API logic will execute.")
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
results = []
|
||||||
|
|
||||||
|
# Test 1: Update config (should attempt to disable sleep mode)
|
||||||
|
results.append(("Config Update", test_config_update()))
|
||||||
|
|
||||||
|
# Test 2: Get config
|
||||||
|
results.append(("Get Config", test_get_config()))
|
||||||
|
|
||||||
|
# Test 3: Start measurement (should attempt to disable sleep mode)
|
||||||
|
results.append(("Start Measurement", test_start_measurement()))
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("Test Summary")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
for test_name, result in results:
|
||||||
|
status = "✓ PASS" if result else "✗ FAIL"
|
||||||
|
print(f"{status}: {test_name}")
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("Implementation Details:")
|
||||||
|
print("=" * 60)
|
||||||
|
print("1. Config endpoint is now async and calls ensure_sleep_mode_disabled()")
|
||||||
|
print(" when TCP is enabled")
|
||||||
|
print("2. Start measurement endpoint calls ensure_sleep_mode_disabled()")
|
||||||
|
print(" before starting the measurement")
|
||||||
|
print("3. Sleep mode check is non-blocking - config/start will succeed")
|
||||||
|
print(" even if the device is unreachable")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1,678 +0,0 @@
|
|||||||
"""
|
|
||||||
Thor Watcher — Settings Dialog v0.3.1
|
|
||||||
|
|
||||||
Provides a Tkinter settings dialog that doubles as a first-run wizard.
|
|
||||||
|
|
||||||
Public API:
|
|
||||||
show_dialog(config_path, wizard=False) -> bool
|
|
||||||
Returns True if the user saved, False if they cancelled.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import json
|
|
||||||
import tkinter as tk
|
|
||||||
from tkinter import ttk, filedialog, messagebox
|
|
||||||
from socket import gethostname
|
|
||||||
|
|
||||||
import series4_ingest as watcher
|
|
||||||
|
|
||||||
|
|
||||||
# ── Defaults (mirror config.example.json) ────────────────────────────────────
|
|
||||||
|
|
||||||
DEFAULTS = {
|
|
||||||
"thordata_path": r"C:\THORDATA",
|
|
||||||
"scan_interval": 60,
|
|
||||||
"api_url": "",
|
|
||||||
"api_timeout": 5,
|
|
||||||
"api_interval": 300,
|
|
||||||
"source_id": "",
|
|
||||||
"source_type": "series4_watcher",
|
|
||||||
"local_timezone": "America/New_York",
|
|
||||||
"enable_logging": True,
|
|
||||||
"log_file": os.path.join(
|
|
||||||
os.environ.get("LOCALAPPDATA") or os.environ.get("APPDATA") or "C:\\",
|
|
||||||
"ThorWatcher", "agent_logs", "thor_watcher.log"
|
|
||||||
),
|
|
||||||
"log_retention_days": 30,
|
|
||||||
"update_source": "gitea",
|
|
||||||
"update_url": "",
|
|
||||||
|
|
||||||
# SFM forwarder defaults — mirror series4_ingest.load_config
|
|
||||||
"sfm_forward_enabled": False,
|
|
||||||
"sfm_url": "",
|
|
||||||
"sfm_forward_interval": 60,
|
|
||||||
"sfm_quiescence_seconds": 5,
|
|
||||||
"sfm_missing_report_grace_seconds": 60,
|
|
||||||
"sfm_http_timeout": 60,
|
|
||||||
"sfm_state_file": "",
|
|
||||||
"sfm_max_forwards_per_pass": 500,
|
|
||||||
"sfm_max_event_age_days": 365,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ── Config I/O ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _load_config(config_path):
|
|
||||||
"""Load existing config.json, merged with DEFAULTS for any missing key."""
|
|
||||||
values = dict(DEFAULTS)
|
|
||||||
if not os.path.exists(config_path):
|
|
||||||
return values
|
|
||||||
try:
|
|
||||||
with open(config_path, "r", encoding="utf-8") as f:
|
|
||||||
raw = json.load(f)
|
|
||||||
values.update(raw)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return values
|
|
||||||
|
|
||||||
|
|
||||||
def _save_config(config_path, values):
|
|
||||||
"""Write values dict to config_path as JSON."""
|
|
||||||
config_dir = os.path.dirname(config_path)
|
|
||||||
if config_dir and not os.path.exists(config_dir):
|
|
||||||
os.makedirs(config_dir)
|
|
||||||
with open(config_path, "w", encoding="utf-8") as f:
|
|
||||||
json.dump(values, f, indent=2)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Widget helpers ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _make_spinbox(parent, from_, to, width=8):
|
|
||||||
try:
|
|
||||||
sb = ttk.Spinbox(parent, from_=from_, to=to, width=width)
|
|
||||||
except AttributeError:
|
|
||||||
sb = tk.Spinbox(parent, from_=from_, to=to, width=width)
|
|
||||||
return sb
|
|
||||||
|
|
||||||
|
|
||||||
def _add_label_entry(frame, row, label_text, var, hint=None, readonly=False):
|
|
||||||
tk.Label(frame, text=label_text, anchor="w").grid(
|
|
||||||
row=row, column=0, sticky="w", padx=(8, 4), pady=4
|
|
||||||
)
|
|
||||||
state = "readonly" if readonly else "normal"
|
|
||||||
entry = ttk.Entry(frame, textvariable=var, width=42, state=state)
|
|
||||||
entry.grid(row=row, column=1, sticky="ew", padx=(0, 8), pady=4)
|
|
||||||
if hint and not var.get():
|
|
||||||
entry.config(foreground="grey")
|
|
||||||
entry.insert(0, hint)
|
|
||||||
|
|
||||||
def _on_focus_in(event, e=entry, h=hint, v=var):
|
|
||||||
if e.get() == h:
|
|
||||||
e.delete(0, tk.END)
|
|
||||||
e.config(foreground="black")
|
|
||||||
|
|
||||||
def _on_focus_out(event, e=entry, h=hint, v=var):
|
|
||||||
if not e.get():
|
|
||||||
e.config(foreground="grey")
|
|
||||||
e.insert(0, h)
|
|
||||||
v.set("")
|
|
||||||
|
|
||||||
entry.bind("<FocusIn>", _on_focus_in)
|
|
||||||
entry.bind("<FocusOut>", _on_focus_out)
|
|
||||||
return entry
|
|
||||||
|
|
||||||
|
|
||||||
def _add_label_spinbox(frame, row, label_text, var, from_, to):
|
|
||||||
tk.Label(frame, text=label_text, anchor="w").grid(
|
|
||||||
row=row, column=0, sticky="w", padx=(8, 4), pady=4
|
|
||||||
)
|
|
||||||
sb = _make_spinbox(frame, from_=from_, to=to, width=8)
|
|
||||||
sb.grid(row=row, column=1, sticky="w", padx=(0, 8), pady=4)
|
|
||||||
sb.delete(0, tk.END)
|
|
||||||
sb.insert(0, str(var.get()))
|
|
||||||
|
|
||||||
def _on_change(*args):
|
|
||||||
var.set(sb.get())
|
|
||||||
|
|
||||||
sb.config(command=_on_change)
|
|
||||||
sb.bind("<KeyRelease>", _on_change)
|
|
||||||
return sb
|
|
||||||
|
|
||||||
|
|
||||||
def _add_label_check(frame, row, label_text, var):
|
|
||||||
cb = ttk.Checkbutton(frame, text=label_text, variable=var)
|
|
||||||
cb.grid(row=row, column=0, columnspan=2, sticky="w", padx=(8, 8), pady=4)
|
|
||||||
return cb
|
|
||||||
|
|
||||||
|
|
||||||
def _add_label_browse_entry(frame, row, label_text, var, browse_fn):
|
|
||||||
tk.Label(frame, text=label_text, anchor="w").grid(
|
|
||||||
row=row, column=0, sticky="w", padx=(8, 4), pady=4
|
|
||||||
)
|
|
||||||
inner = tk.Frame(frame)
|
|
||||||
inner.grid(row=row, column=1, sticky="ew", padx=(0, 8), pady=4)
|
|
||||||
inner.columnconfigure(0, weight=1)
|
|
||||||
entry = ttk.Entry(inner, textvariable=var, width=36)
|
|
||||||
entry.grid(row=0, column=0, sticky="ew")
|
|
||||||
btn = ttk.Button(inner, text="Browse...", command=browse_fn, width=9)
|
|
||||||
btn.grid(row=0, column=1, padx=(4, 0))
|
|
||||||
return entry
|
|
||||||
|
|
||||||
|
|
||||||
# ── Main dialog class ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class SettingsDialog:
|
|
||||||
def __init__(self, parent, config_path, wizard=False):
|
|
||||||
self.config_path = config_path
|
|
||||||
self.wizard = wizard
|
|
||||||
self.saved = False
|
|
||||||
self.root = parent
|
|
||||||
|
|
||||||
kind = "Setup" if wizard else "Settings"
|
|
||||||
title = "Thor Watcher v{} — {}".format(watcher.VERSION, kind)
|
|
||||||
self.root.title(title)
|
|
||||||
self.root.resizable(False, False)
|
|
||||||
self.root.update_idletasks()
|
|
||||||
|
|
||||||
self._values = _load_config(config_path)
|
|
||||||
self._build_vars()
|
|
||||||
self._build_ui()
|
|
||||||
|
|
||||||
self.root.grab_set()
|
|
||||||
self.root.protocol("WM_DELETE_WINDOW", self._on_cancel)
|
|
||||||
|
|
||||||
# ── Variable setup ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _build_vars(self):
|
|
||||||
v = self._values
|
|
||||||
|
|
||||||
# Connection
|
|
||||||
raw_url = str(v.get("api_url", ""))
|
|
||||||
_suffix = "/api/series4/heartbeat"
|
|
||||||
if raw_url.endswith(_suffix):
|
|
||||||
raw_url = raw_url[:-len(_suffix)]
|
|
||||||
self.var_api_url = tk.StringVar(value=raw_url)
|
|
||||||
self.var_api_interval = tk.StringVar(value=str(v.get("api_interval", 300)))
|
|
||||||
self.var_source_id = tk.StringVar(value=str(v.get("source_id", "")))
|
|
||||||
self.var_source_type = tk.StringVar(value=str(v.get("source_type", "series4_watcher")))
|
|
||||||
|
|
||||||
# Paths
|
|
||||||
self.var_thordata_path = tk.StringVar(value=str(v.get("thordata_path", r"C:\THORDATA")))
|
|
||||||
self.var_log_file = tk.StringVar(value=str(v.get("log_file", DEFAULTS["log_file"])))
|
|
||||||
|
|
||||||
# Scanning
|
|
||||||
self.var_scan_interval = tk.StringVar(value=str(v.get("scan_interval", 60)))
|
|
||||||
|
|
||||||
# Logging
|
|
||||||
en = v.get("enable_logging", True)
|
|
||||||
self.var_enable_logging = tk.BooleanVar(value=bool(en) if isinstance(en, bool) else str(en).lower() in ("true", "1", "yes"))
|
|
||||||
self.var_log_retention_days = tk.StringVar(value=str(v.get("log_retention_days", 30)))
|
|
||||||
|
|
||||||
# Updates
|
|
||||||
src = str(v.get("update_source", "gitea")).lower()
|
|
||||||
if src not in ("gitea", "url", "disabled"):
|
|
||||||
src = "gitea"
|
|
||||||
self.var_local_timezone = tk.StringVar(value=str(v.get("local_timezone", "America/New_York")))
|
|
||||||
self.var_update_source = tk.StringVar(value=src)
|
|
||||||
self.var_update_url = tk.StringVar(value=str(v.get("update_url", "")))
|
|
||||||
|
|
||||||
# SFM Forwarder
|
|
||||||
sfm_en = v.get("sfm_forward_enabled", False)
|
|
||||||
self.var_sfm_enabled = tk.BooleanVar(
|
|
||||||
value=bool(sfm_en) if isinstance(sfm_en, bool) else str(sfm_en).lower() in ("true", "1", "yes")
|
|
||||||
)
|
|
||||||
self.var_sfm_url = tk.StringVar(value=str(v.get("sfm_url", "")))
|
|
||||||
self.var_sfm_forward_interval = tk.StringVar(value=str(v.get("sfm_forward_interval", 60)))
|
|
||||||
self.var_sfm_quiescence = tk.StringVar(value=str(v.get("sfm_quiescence_seconds", 5)))
|
|
||||||
self.var_sfm_grace = tk.StringVar(value=str(v.get("sfm_missing_report_grace_seconds", 60)))
|
|
||||||
self.var_sfm_http_timeout = tk.StringVar(value=str(v.get("sfm_http_timeout", 60)))
|
|
||||||
self.var_sfm_max_per_pass = tk.StringVar(value=str(v.get("sfm_max_forwards_per_pass", 500)))
|
|
||||||
self.var_sfm_max_age_days = tk.StringVar(value=str(v.get("sfm_max_event_age_days", 365)))
|
|
||||||
self.var_sfm_state_file = tk.StringVar(value=str(v.get("sfm_state_file", "")))
|
|
||||||
|
|
||||||
# ── UI construction ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _build_ui(self):
|
|
||||||
outer = tk.Frame(self.root, padx=10, pady=8)
|
|
||||||
outer.pack(fill="both", expand=True)
|
|
||||||
|
|
||||||
if self.wizard:
|
|
||||||
welcome = (
|
|
||||||
"Welcome to Thor Watcher!\n\n"
|
|
||||||
"No configuration file was found. Please review the settings below\n"
|
|
||||||
"and click \"Save & Start\" when you are ready."
|
|
||||||
)
|
|
||||||
tk.Label(
|
|
||||||
outer, text=welcome, justify="left",
|
|
||||||
wraplength=460, fg="#1a5276", font=("TkDefaultFont", 9, "bold"),
|
|
||||||
).pack(fill="x", pady=(0, 8))
|
|
||||||
|
|
||||||
nb = ttk.Notebook(outer)
|
|
||||||
nb.pack(fill="both", expand=True)
|
|
||||||
|
|
||||||
self._build_tab_connection(nb)
|
|
||||||
self._build_tab_paths(nb)
|
|
||||||
self._build_tab_scanning(nb)
|
|
||||||
self._build_tab_logging(nb)
|
|
||||||
self._build_tab_forwarding(nb)
|
|
||||||
self._build_tab_updates(nb)
|
|
||||||
|
|
||||||
btn_frame = tk.Frame(outer)
|
|
||||||
btn_frame.pack(fill="x", pady=(10, 0))
|
|
||||||
|
|
||||||
save_label = "Save & Start" if self.wizard else "Save"
|
|
||||||
ttk.Button(btn_frame, text=save_label, command=self._on_save, width=14).pack(side="right", padx=(4, 0))
|
|
||||||
ttk.Button(btn_frame, text="Cancel", command=self._on_cancel, width=10).pack(side="right")
|
|
||||||
|
|
||||||
def _tab_frame(self, nb, title):
|
|
||||||
outer = tk.Frame(nb, padx=4, pady=4)
|
|
||||||
nb.add(outer, text=title)
|
|
||||||
outer.columnconfigure(1, weight=1)
|
|
||||||
return outer
|
|
||||||
|
|
||||||
def _build_tab_connection(self, nb):
|
|
||||||
f = self._tab_frame(nb, "Connection")
|
|
||||||
|
|
||||||
# URL row with Test button
|
|
||||||
tk.Label(f, text="Terra-View URL", anchor="w").grid(
|
|
||||||
row=0, column=0, sticky="w", padx=(8, 4), pady=4
|
|
||||||
)
|
|
||||||
url_frame = tk.Frame(f)
|
|
||||||
url_frame.grid(row=0, column=1, sticky="ew", padx=(0, 8), pady=4)
|
|
||||||
url_frame.columnconfigure(0, weight=1)
|
|
||||||
|
|
||||||
url_entry = ttk.Entry(url_frame, textvariable=self.var_api_url, width=32)
|
|
||||||
url_entry.grid(row=0, column=0, sticky="ew")
|
|
||||||
|
|
||||||
_hint = "http://192.168.x.x:8000"
|
|
||||||
if not self.var_api_url.get():
|
|
||||||
url_entry.config(foreground="grey")
|
|
||||||
url_entry.insert(0, _hint)
|
|
||||||
def _on_focus_in(e):
|
|
||||||
if url_entry.get() == _hint:
|
|
||||||
url_entry.delete(0, tk.END)
|
|
||||||
url_entry.config(foreground="black")
|
|
||||||
def _on_focus_out(e):
|
|
||||||
if not url_entry.get():
|
|
||||||
url_entry.config(foreground="grey")
|
|
||||||
url_entry.insert(0, _hint)
|
|
||||||
self.var_api_url.set("")
|
|
||||||
url_entry.bind("<FocusIn>", _on_focus_in)
|
|
||||||
url_entry.bind("<FocusOut>", _on_focus_out)
|
|
||||||
|
|
||||||
self._test_btn = ttk.Button(url_frame, text="Test", width=6,
|
|
||||||
command=self._test_connection)
|
|
||||||
self._test_btn.grid(row=0, column=1, padx=(4, 0))
|
|
||||||
|
|
||||||
self._test_status = tk.Label(url_frame, text="", anchor="w", width=20)
|
|
||||||
self._test_status.grid(row=0, column=2, padx=(6, 0))
|
|
||||||
|
|
||||||
_add_label_spinbox(f, 1, "API Interval (sec)", self.var_api_interval, 30, 3600)
|
|
||||||
|
|
||||||
source_id_hint = "Defaults to hostname ({})".format(gethostname())
|
|
||||||
_add_label_entry(f, 2, "Source ID", self.var_source_id, hint=source_id_hint)
|
|
||||||
|
|
||||||
_add_label_entry(f, 3, "Source Type", self.var_source_type, readonly=True)
|
|
||||||
|
|
||||||
_add_label_entry(f, 4, "Local Timezone", self.var_local_timezone,
|
|
||||||
hint="e.g. America/New_York, America/Chicago")
|
|
||||||
tk.Label(
|
|
||||||
f,
|
|
||||||
text="Used to convert MLG file timestamps (local time) to UTC for terra-view.",
|
|
||||||
justify="left", fg="#555555", wraplength=340,
|
|
||||||
).grid(row=5, column=0, columnspan=2, sticky="w", padx=(8, 8), pady=(0, 4))
|
|
||||||
|
|
||||||
def _test_connection(self):
|
|
||||||
import urllib.request
|
|
||||||
import urllib.error
|
|
||||||
|
|
||||||
self._test_status.config(text="Testing...", foreground="grey")
|
|
||||||
self._test_btn.config(state="disabled")
|
|
||||||
self.root.update_idletasks()
|
|
||||||
|
|
||||||
raw = self.var_api_url.get().strip()
|
|
||||||
if not raw or raw == "http://192.168.x.x:8000":
|
|
||||||
self._test_status.config(text="Enter a URL first", foreground="orange")
|
|
||||||
self._test_btn.config(state="normal")
|
|
||||||
return
|
|
||||||
|
|
||||||
url = raw.rstrip("/") + "/health"
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(urllib.request.Request(url), timeout=5) as resp:
|
|
||||||
if resp.status == 200:
|
|
||||||
self._test_status.config(text="Connected!", foreground="green")
|
|
||||||
else:
|
|
||||||
self._test_status.config(text="HTTP {}".format(resp.status), foreground="orange")
|
|
||||||
except urllib.error.URLError as e:
|
|
||||||
reason = str(e.reason) if hasattr(e, "reason") else str(e)
|
|
||||||
self._test_status.config(text="Failed: {}".format(reason[:30]), foreground="red")
|
|
||||||
except Exception as e:
|
|
||||||
self._test_status.config(text="Error: {}".format(str(e)[:30]), foreground="red")
|
|
||||||
finally:
|
|
||||||
self._test_btn.config(state="normal")
|
|
||||||
|
|
||||||
def _build_tab_paths(self, nb):
|
|
||||||
f = self._tab_frame(nb, "Paths")
|
|
||||||
|
|
||||||
def browse_thordata():
|
|
||||||
d = filedialog.askdirectory(
|
|
||||||
title="Select THORDATA Folder",
|
|
||||||
initialdir=self.var_thordata_path.get() or "C:\\",
|
|
||||||
)
|
|
||||||
if d:
|
|
||||||
self.var_thordata_path.set(d.replace("/", "\\"))
|
|
||||||
|
|
||||||
_add_label_browse_entry(f, 0, "THORDATA Path", self.var_thordata_path, browse_thordata)
|
|
||||||
|
|
||||||
def browse_log():
|
|
||||||
p = filedialog.asksaveasfilename(
|
|
||||||
title="Select Log File",
|
|
||||||
defaultextension=".log",
|
|
||||||
filetypes=[("Log files", "*.log"), ("Text files", "*.txt"), ("All files", "*.*")],
|
|
||||||
initialfile=os.path.basename(self.var_log_file.get() or "thor_watcher.log"),
|
|
||||||
initialdir=os.path.dirname(self.var_log_file.get() or "C:\\"),
|
|
||||||
)
|
|
||||||
if p:
|
|
||||||
self.var_log_file.set(p.replace("/", "\\"))
|
|
||||||
|
|
||||||
_add_label_browse_entry(f, 1, "Log File", self.var_log_file, browse_log)
|
|
||||||
|
|
||||||
def _build_tab_scanning(self, nb):
|
|
||||||
f = self._tab_frame(nb, "Scanning")
|
|
||||||
_add_label_spinbox(f, 0, "Scan Interval (sec)", self.var_scan_interval, 10, 3600)
|
|
||||||
|
|
||||||
def _build_tab_logging(self, nb):
|
|
||||||
f = self._tab_frame(nb, "Logging")
|
|
||||||
_add_label_check(f, 0, "Enable Logging", self.var_enable_logging)
|
|
||||||
_add_label_spinbox(f, 1, "Log Retention (days)", self.var_log_retention_days, 1, 365)
|
|
||||||
|
|
||||||
def _build_tab_forwarding(self, nb):
|
|
||||||
f = self._tab_frame(nb, "SFM Forward")
|
|
||||||
|
|
||||||
# Row 0: enable checkbox
|
|
||||||
_add_label_check(f, 0, "Enable SFM Forwarding", self.var_sfm_enabled)
|
|
||||||
|
|
||||||
# Row 1: SFM URL + Test button
|
|
||||||
tk.Label(f, text="SFM URL", anchor="w").grid(
|
|
||||||
row=1, column=0, sticky="w", padx=(8, 4), pady=4
|
|
||||||
)
|
|
||||||
url_frame = tk.Frame(f)
|
|
||||||
url_frame.grid(row=1, column=1, sticky="ew", padx=(0, 8), pady=4)
|
|
||||||
url_frame.columnconfigure(0, weight=1)
|
|
||||||
|
|
||||||
sfm_entry = ttk.Entry(url_frame, textvariable=self.var_sfm_url, width=32)
|
|
||||||
sfm_entry.grid(row=0, column=0, sticky="ew")
|
|
||||||
|
|
||||||
_hint = "http://10.0.0.44:8200"
|
|
||||||
if not self.var_sfm_url.get():
|
|
||||||
sfm_entry.config(foreground="grey")
|
|
||||||
sfm_entry.insert(0, _hint)
|
|
||||||
|
|
||||||
def _on_focus_in(e, ent=sfm_entry, h=_hint):
|
|
||||||
if ent.get() == h:
|
|
||||||
ent.delete(0, tk.END)
|
|
||||||
ent.config(foreground="black")
|
|
||||||
|
|
||||||
def _on_focus_out(e, ent=sfm_entry, h=_hint, v=self.var_sfm_url):
|
|
||||||
if not ent.get():
|
|
||||||
ent.config(foreground="grey")
|
|
||||||
ent.insert(0, h)
|
|
||||||
v.set("")
|
|
||||||
|
|
||||||
sfm_entry.bind("<FocusIn>", _on_focus_in)
|
|
||||||
sfm_entry.bind("<FocusOut>", _on_focus_out)
|
|
||||||
|
|
||||||
self._sfm_test_btn = ttk.Button(url_frame, text="Test", width=6,
|
|
||||||
command=self._test_sfm_connection)
|
|
||||||
self._sfm_test_btn.grid(row=0, column=1, padx=(4, 0))
|
|
||||||
|
|
||||||
self._sfm_test_status = tk.Label(url_frame, text="", anchor="w", width=20)
|
|
||||||
self._sfm_test_status.grid(row=0, column=2, padx=(6, 0))
|
|
||||||
|
|
||||||
# Rows 2-7: timing/limits spinboxes
|
|
||||||
_add_label_spinbox(f, 2, "Forward Interval (sec)", self.var_sfm_forward_interval, 30, 3600)
|
|
||||||
_add_label_spinbox(f, 3, "Quiescence (sec)", self.var_sfm_quiescence, 1, 60)
|
|
||||||
_add_label_spinbox(f, 4, "Missing-Report Grace (sec)", self.var_sfm_grace, 0, 3600)
|
|
||||||
_add_label_spinbox(f, 5, "HTTP Timeout (sec)", self.var_sfm_http_timeout, 5, 300)
|
|
||||||
_add_label_spinbox(f, 6, "Max Forwards Per Pass", self.var_sfm_max_per_pass, 1, 5000)
|
|
||||||
_add_label_spinbox(f, 7, "Max Event Age (days)", self.var_sfm_max_age_days, 1, 3650)
|
|
||||||
|
|
||||||
# Row 8: state file browse
|
|
||||||
def browse_state():
|
|
||||||
p = filedialog.asksaveasfilename(
|
|
||||||
title="Select SFM State File",
|
|
||||||
defaultextension=".json",
|
|
||||||
filetypes=[("JSON files", "*.json"), ("All files", "*.*")],
|
|
||||||
initialfile=os.path.basename(self.var_sfm_state_file.get() or "thor_forwarded.json"),
|
|
||||||
initialdir=os.path.dirname(self.var_sfm_state_file.get() or "C:\\"),
|
|
||||||
)
|
|
||||||
if p:
|
|
||||||
self.var_sfm_state_file.set(p.replace("/", "\\"))
|
|
||||||
|
|
||||||
_add_label_browse_entry(f, 8, "State File", self.var_sfm_state_file, browse_state)
|
|
||||||
|
|
||||||
# Row 9: help text
|
|
||||||
help_text = (
|
|
||||||
"Forwards .IDFH (histogram) and .IDFW (waveform) event files plus their\n"
|
|
||||||
"TXT/<basename>.txt sidecars to a seismo-relay SFM server.\n"
|
|
||||||
"Idempotent: each file is tracked by sha256, so re-scans never re-POST.\n"
|
|
||||||
"If the TXT sidecar appears AFTER the binary was forwarded alone, the\n"
|
|
||||||
"next pass will re-forward so the relay can refresh the DB row with\n"
|
|
||||||
"device-authoritative PPV/ZCFreq/peak values.\n"
|
|
||||||
"State file blank → defaults to <log_dir>\\thor_forwarded.json."
|
|
||||||
)
|
|
||||||
tk.Label(
|
|
||||||
f, text=help_text, justify="left", fg="#555555", wraplength=420,
|
|
||||||
).grid(row=9, column=0, columnspan=2, sticky="w", padx=(8, 8), pady=(8, 4))
|
|
||||||
|
|
||||||
def _test_sfm_connection(self):
|
|
||||||
import urllib.request
|
|
||||||
import urllib.error
|
|
||||||
|
|
||||||
self._sfm_test_status.config(text="Testing...", foreground="grey")
|
|
||||||
self._sfm_test_btn.config(state="disabled")
|
|
||||||
self.root.update_idletasks()
|
|
||||||
|
|
||||||
raw = self.var_sfm_url.get().strip()
|
|
||||||
if not raw or raw == "http://10.0.0.44:8200":
|
|
||||||
self._sfm_test_status.config(text="Enter a URL first", foreground="orange")
|
|
||||||
self._sfm_test_btn.config(state="normal")
|
|
||||||
return
|
|
||||||
|
|
||||||
url = raw.rstrip("/") + "/health"
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(urllib.request.Request(url), timeout=5) as resp:
|
|
||||||
if resp.status == 200:
|
|
||||||
self._sfm_test_status.config(text="Connected!", foreground="green")
|
|
||||||
else:
|
|
||||||
self._sfm_test_status.config(text="HTTP {}".format(resp.status), foreground="orange")
|
|
||||||
except urllib.error.URLError as e:
|
|
||||||
reason = str(e.reason) if hasattr(e, "reason") else str(e)
|
|
||||||
self._sfm_test_status.config(text="Failed: {}".format(reason[:30]), foreground="red")
|
|
||||||
except Exception as e:
|
|
||||||
self._sfm_test_status.config(text="Error: {}".format(str(e)[:30]), foreground="red")
|
|
||||||
finally:
|
|
||||||
self._sfm_test_btn.config(state="normal")
|
|
||||||
|
|
||||||
def _build_tab_updates(self, nb):
|
|
||||||
f = self._tab_frame(nb, "Updates")
|
|
||||||
|
|
||||||
# Current version display
|
|
||||||
tk.Label(f, text="Current Version", anchor="w").grid(
|
|
||||||
row=0, column=0, sticky="w", padx=(8, 4), pady=(8, 2)
|
|
||||||
)
|
|
||||||
tk.Label(
|
|
||||||
f, text="v{}".format(watcher.VERSION), anchor="w",
|
|
||||||
font=("TkDefaultFont", 9, "bold"),
|
|
||||||
).grid(row=0, column=1, sticky="w", padx=(0, 8), pady=(8, 2))
|
|
||||||
|
|
||||||
tk.Label(f, text="Auto-Update Source", anchor="w").grid(
|
|
||||||
row=1, column=0, sticky="w", padx=(8, 4), pady=(8, 2)
|
|
||||||
)
|
|
||||||
|
|
||||||
radio_frame = tk.Frame(f)
|
|
||||||
radio_frame.grid(row=1, column=1, sticky="w", padx=(0, 8), pady=(8, 2))
|
|
||||||
|
|
||||||
ttk.Radiobutton(
|
|
||||||
radio_frame, text="Gitea (default)",
|
|
||||||
variable=self.var_update_source, value="gitea",
|
|
||||||
command=self._on_update_source_change,
|
|
||||||
).grid(row=0, column=0, sticky="w", padx=(0, 12))
|
|
||||||
|
|
||||||
ttk.Radiobutton(
|
|
||||||
radio_frame, text="Custom URL",
|
|
||||||
variable=self.var_update_source, value="url",
|
|
||||||
command=self._on_update_source_change,
|
|
||||||
).grid(row=0, column=1, sticky="w", padx=(0, 12))
|
|
||||||
|
|
||||||
ttk.Radiobutton(
|
|
||||||
radio_frame, text="Disabled",
|
|
||||||
variable=self.var_update_source, value="disabled",
|
|
||||||
command=self._on_update_source_change,
|
|
||||||
).grid(row=0, column=2, sticky="w")
|
|
||||||
|
|
||||||
tk.Label(f, text="Update Server URL", anchor="w").grid(
|
|
||||||
row=2, column=0, sticky="w", padx=(8, 4), pady=4
|
|
||||||
)
|
|
||||||
self._update_url_entry = ttk.Entry(f, textvariable=self.var_update_url, width=42)
|
|
||||||
self._update_url_entry.grid(row=2, column=1, sticky="ew", padx=(0, 8), pady=4)
|
|
||||||
|
|
||||||
tk.Label(
|
|
||||||
f,
|
|
||||||
text=(
|
|
||||||
"Gitea: checks the Gitea release page automatically every 5 minutes.\n"
|
|
||||||
"Custom URL: fetches version.txt and thor-watcher.exe from a web\n"
|
|
||||||
"server — use when Gitea is not reachable (e.g. terra-view URL).\n"
|
|
||||||
"Disabled: no automatic update checks. Remote push from terra-view\n"
|
|
||||||
"still works when disabled."
|
|
||||||
),
|
|
||||||
justify="left", fg="#555555", wraplength=380,
|
|
||||||
).grid(row=3, column=0, columnspan=2, sticky="w", padx=(8, 8), pady=(4, 8))
|
|
||||||
|
|
||||||
self._on_update_source_change()
|
|
||||||
|
|
||||||
def _on_update_source_change(self):
|
|
||||||
if self.var_update_source.get() == "url":
|
|
||||||
self._update_url_entry.config(state="normal")
|
|
||||||
else:
|
|
||||||
self._update_url_entry.config(state="disabled")
|
|
||||||
|
|
||||||
# ── Validation ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _get_int_var(self, var, name, min_val, max_val):
|
|
||||||
raw = str(var.get()).strip()
|
|
||||||
try:
|
|
||||||
val = int(raw)
|
|
||||||
except ValueError:
|
|
||||||
messagebox.showerror("Validation Error",
|
|
||||||
"{} must be an integer (got: {!r}).".format(name, raw))
|
|
||||||
return None
|
|
||||||
if val < min_val or val > max_val:
|
|
||||||
messagebox.showerror("Validation Error",
|
|
||||||
"{} must be between {} and {} (got {}).".format(name, min_val, max_val, val))
|
|
||||||
return None
|
|
||||||
return val
|
|
||||||
|
|
||||||
# ── Save / Cancel ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _on_save(self):
|
|
||||||
checks = [
|
|
||||||
(self.var_api_interval, "API Interval", 30, 3600),
|
|
||||||
(self.var_scan_interval, "Scan Interval", 10, 3600),
|
|
||||||
(self.var_log_retention_days, "Log Retention Days", 1, 365),
|
|
||||||
(self.var_sfm_forward_interval, "Forward Interval", 30, 3600),
|
|
||||||
(self.var_sfm_quiescence, "Quiescence", 1, 60),
|
|
||||||
(self.var_sfm_grace, "Missing-Report Grace", 0, 3600),
|
|
||||||
(self.var_sfm_http_timeout, "HTTP Timeout", 5, 300),
|
|
||||||
(self.var_sfm_max_per_pass, "Max Forwards Per Pass", 1, 5000),
|
|
||||||
(self.var_sfm_max_age_days, "Max Event Age (days)", 1, 3650),
|
|
||||||
]
|
|
||||||
int_values = {}
|
|
||||||
for var, name, mn, mx in checks:
|
|
||||||
result = self._get_int_var(var, name, mn, mx)
|
|
||||||
if result is None:
|
|
||||||
return
|
|
||||||
int_values[name] = result
|
|
||||||
|
|
||||||
source_id = self.var_source_id.get().strip()
|
|
||||||
if source_id.startswith("Defaults to hostname"):
|
|
||||||
source_id = ""
|
|
||||||
|
|
||||||
api_url = self.var_api_url.get().strip()
|
|
||||||
if api_url == "http://192.168.x.x:8000" or not api_url:
|
|
||||||
api_url = ""
|
|
||||||
else:
|
|
||||||
api_url = api_url.rstrip("/") + "/api/series4/heartbeat"
|
|
||||||
|
|
||||||
sfm_url = self.var_sfm_url.get().strip()
|
|
||||||
if sfm_url == "http://10.0.0.44:8200":
|
|
||||||
sfm_url = ""
|
|
||||||
sfm_url = sfm_url.rstrip("/") # event_forwarder adds the endpoint path
|
|
||||||
|
|
||||||
values = {
|
|
||||||
"thordata_path": self.var_thordata_path.get().strip(),
|
|
||||||
"scan_interval": int_values["Scan Interval"],
|
|
||||||
"api_url": api_url,
|
|
||||||
"api_timeout": 5,
|
|
||||||
"api_interval": int_values["API Interval"],
|
|
||||||
"source_id": source_id,
|
|
||||||
"source_type": self.var_source_type.get().strip() or "series4_watcher",
|
|
||||||
"local_timezone": self.var_local_timezone.get().strip() or "America/New_York",
|
|
||||||
"enable_logging": self.var_enable_logging.get(),
|
|
||||||
"log_file": self.var_log_file.get().strip(),
|
|
||||||
"log_retention_days": int_values["Log Retention Days"],
|
|
||||||
"update_source": self.var_update_source.get().strip() or "gitea",
|
|
||||||
"update_url": self.var_update_url.get().strip(),
|
|
||||||
|
|
||||||
"sfm_forward_enabled": self.var_sfm_enabled.get(),
|
|
||||||
"sfm_url": sfm_url,
|
|
||||||
"sfm_forward_interval": int_values["Forward Interval"],
|
|
||||||
"sfm_quiescence_seconds": int_values["Quiescence"],
|
|
||||||
"sfm_missing_report_grace_seconds": int_values["Missing-Report Grace"],
|
|
||||||
"sfm_http_timeout": int_values["HTTP Timeout"],
|
|
||||||
"sfm_max_forwards_per_pass": int_values["Max Forwards Per Pass"],
|
|
||||||
"sfm_max_event_age_days": int_values["Max Event Age (days)"],
|
|
||||||
"sfm_state_file": self.var_sfm_state_file.get().strip(),
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
_save_config(self.config_path, values)
|
|
||||||
except Exception as e:
|
|
||||||
messagebox.showerror("Save Error", "Could not write config.json:\n{}".format(e))
|
|
||||||
return
|
|
||||||
|
|
||||||
self.saved = True
|
|
||||||
self.root.destroy()
|
|
||||||
|
|
||||||
def _on_cancel(self):
|
|
||||||
self.saved = False
|
|
||||||
self.root.destroy()
|
|
||||||
|
|
||||||
|
|
||||||
# ── Public API ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def show_dialog(config_path, wizard=False):
|
|
||||||
"""
|
|
||||||
Open the settings dialog.
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
config_path : str
|
|
||||||
Absolute path to config.json (read if exists, written on Save).
|
|
||||||
wizard : bool
|
|
||||||
If True, shows first-run welcome message and "Save & Start" button.
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
bool
|
|
||||||
True if the user saved, False if they cancelled.
|
|
||||||
"""
|
|
||||||
root = tk.Tk()
|
|
||||||
root.withdraw()
|
|
||||||
|
|
||||||
top = tk.Toplevel(root)
|
|
||||||
top.deiconify()
|
|
||||||
|
|
||||||
dlg = SettingsDialog(top, config_path, wizard=wizard)
|
|
||||||
|
|
||||||
top.update_idletasks()
|
|
||||||
w = top.winfo_reqwidth()
|
|
||||||
h = top.winfo_reqheight()
|
|
||||||
sw = top.winfo_screenwidth()
|
|
||||||
sh = top.winfo_screenheight()
|
|
||||||
top.geometry("{}x{}+{}+{}".format(w, h, (sw - w) // 2, (sh - h) // 2))
|
|
||||||
|
|
||||||
root.wait_window(top)
|
|
||||||
root.destroy()
|
|
||||||
|
|
||||||
return dlg.saved
|
|
||||||
-558
@@ -1,558 +0,0 @@
|
|||||||
"""
|
|
||||||
Thor Watcher — System Tray Launcher v0.3.1
|
|
||||||
Requires: pystray, Pillow, tkinter (stdlib)
|
|
||||||
|
|
||||||
Run with: pythonw thor_tray.py (no console window)
|
|
||||||
or: python thor_tray.py (with console, for debugging)
|
|
||||||
|
|
||||||
Put a shortcut to this in shell:startup for auto-start on login.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import json
|
|
||||||
import subprocess
|
|
||||||
import tempfile
|
|
||||||
import threading
|
|
||||||
import urllib.request
|
|
||||||
import urllib.error
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
import pystray
|
|
||||||
from PIL import Image, ImageDraw
|
|
||||||
|
|
||||||
import series4_ingest as watcher
|
|
||||||
|
|
||||||
|
|
||||||
# ── Auto-updater ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
GITEA_BASE = "https://gitea.serversdown.net"
|
|
||||||
GITEA_USER = "serversdown"
|
|
||||||
GITEA_REPO = "thor-watcher"
|
|
||||||
GITEA_API_URL = "{}/api/v1/repos/{}/{}/releases?limit=1&page=1".format(
|
|
||||||
GITEA_BASE, GITEA_USER, GITEA_REPO
|
|
||||||
)
|
|
||||||
|
|
||||||
_CURRENT_VERSION = getattr(watcher, "VERSION", "0.0.0")
|
|
||||||
|
|
||||||
|
|
||||||
def _version_tuple(v):
|
|
||||||
"""Convert '0.2.0' -> (0, 2, 0) for comparison."""
|
|
||||||
parts = []
|
|
||||||
for p in str(v).lstrip("v").split(".")[:3]:
|
|
||||||
try:
|
|
||||||
parts.append(int(p))
|
|
||||||
except ValueError:
|
|
||||||
parts.append(0)
|
|
||||||
while len(parts) < 3:
|
|
||||||
parts.append(0)
|
|
||||||
return tuple(parts)
|
|
||||||
|
|
||||||
|
|
||||||
def _update_log(msg):
|
|
||||||
"""Append a timestamped [updater] line to the watcher log."""
|
|
||||||
try:
|
|
||||||
log_path = os.path.join(
|
|
||||||
os.environ.get("LOCALAPPDATA") or os.environ.get("APPDATA") or "",
|
|
||||||
"ThorWatcher", "agent_logs", "thor_watcher.log"
|
|
||||||
)
|
|
||||||
os.makedirs(os.path.dirname(log_path), exist_ok=True)
|
|
||||||
with open(log_path, "a") as f:
|
|
||||||
f.write("[{}] [updater] {}\n".format(
|
|
||||||
datetime.now().strftime("%Y-%m-%d %H:%M:%S"), msg
|
|
||||||
))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _check_for_update_gitea():
|
|
||||||
"""Query Gitea API for latest release. Returns (tag, download_url) or (None, None)."""
|
|
||||||
try:
|
|
||||||
req = urllib.request.Request(
|
|
||||||
GITEA_API_URL,
|
|
||||||
headers={"User-Agent": "thor-watcher/{}".format(_CURRENT_VERSION)},
|
|
||||||
)
|
|
||||||
with urllib.request.urlopen(req, timeout=8) as resp:
|
|
||||||
releases = json.loads(resp.read().decode("utf-8"))
|
|
||||||
if not releases:
|
|
||||||
return None, None
|
|
||||||
latest = releases[0]
|
|
||||||
tag = latest.get("tag_name", "")
|
|
||||||
if _version_tuple(tag) <= _version_tuple(_CURRENT_VERSION):
|
|
||||||
return None, None
|
|
||||||
assets = latest.get("assets", [])
|
|
||||||
for asset in assets:
|
|
||||||
name = asset.get("name", "").lower()
|
|
||||||
if name.endswith(".exe") and "setup" not in name:
|
|
||||||
return tag, asset.get("browser_download_url")
|
|
||||||
_update_log("Newer release {} found but no valid .exe asset".format(tag))
|
|
||||||
return tag, None
|
|
||||||
except Exception as e:
|
|
||||||
_update_log("check_for_update (gitea) failed: {}".format(e))
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
|
|
||||||
def _check_for_update_url(base_url):
|
|
||||||
"""Query a custom URL server for latest version. Returns (tag, download_url) or (None, None)."""
|
|
||||||
if not base_url:
|
|
||||||
_update_log("update_source=url but update_url is empty — skipping")
|
|
||||||
return None, None
|
|
||||||
try:
|
|
||||||
ver_url = base_url.rstrip("/") + "/api/updates/thor-watcher/version.txt"
|
|
||||||
req = urllib.request.Request(
|
|
||||||
ver_url,
|
|
||||||
headers={"User-Agent": "thor-watcher/{}".format(_CURRENT_VERSION)},
|
|
||||||
)
|
|
||||||
with urllib.request.urlopen(req, timeout=8) as resp:
|
|
||||||
tag = resp.read().decode("utf-8").strip()
|
|
||||||
if not tag:
|
|
||||||
return None, None
|
|
||||||
if _version_tuple(tag) <= _version_tuple(_CURRENT_VERSION):
|
|
||||||
return None, None
|
|
||||||
exe_url = base_url.rstrip("/") + "/api/updates/thor-watcher/thor-watcher.exe"
|
|
||||||
return tag, exe_url
|
|
||||||
except Exception as e:
|
|
||||||
_update_log("check_for_update (url mode) failed: {}".format(e))
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
|
|
||||||
def check_for_update():
|
|
||||||
"""
|
|
||||||
Check for an update using the configured source (gitea, url, or disabled).
|
|
||||||
Reads update_source and update_url from config.json at check time.
|
|
||||||
Returns (tag, download_url) if an update is available, else (None, None).
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
cfg = _read_config()
|
|
||||||
update_source = str(cfg.get("update_source", "gitea")).strip().lower()
|
|
||||||
update_url = str(cfg.get("update_url", "")).strip()
|
|
||||||
except Exception as exc:
|
|
||||||
_update_log("config read failed in check_for_update: {} — defaulting to gitea".format(exc))
|
|
||||||
update_source = "gitea"
|
|
||||||
update_url = ""
|
|
||||||
|
|
||||||
_update_log("Checking for update (source={}, version={})".format(
|
|
||||||
update_source, _CURRENT_VERSION
|
|
||||||
))
|
|
||||||
|
|
||||||
if update_source == "disabled":
|
|
||||||
return None, None
|
|
||||||
if update_source == "url":
|
|
||||||
return _check_for_update_url(update_url)
|
|
||||||
else:
|
|
||||||
return _check_for_update_gitea()
|
|
||||||
|
|
||||||
|
|
||||||
def apply_update(download_url):
|
|
||||||
"""
|
|
||||||
Download new .exe, validate it, write swap .bat, launch it, exit.
|
|
||||||
Backs up old exe to .exe.old before replacing.
|
|
||||||
"""
|
|
||||||
exe_path = os.path.abspath(sys.executable if getattr(sys, "frozen", False) else sys.argv[0])
|
|
||||||
|
|
||||||
try:
|
|
||||||
tmp_fd, tmp_path = tempfile.mkstemp(suffix=".exe", prefix="tw_update_")
|
|
||||||
os.close(tmp_fd)
|
|
||||||
|
|
||||||
_update_log("Downloading update from: {}".format(download_url))
|
|
||||||
|
|
||||||
req = urllib.request.Request(
|
|
||||||
download_url,
|
|
||||||
headers={"User-Agent": "thor-watcher/{}".format(_CURRENT_VERSION)},
|
|
||||||
)
|
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
|
||||||
with open(tmp_path, "wb") as f:
|
|
||||||
f.write(resp.read())
|
|
||||||
|
|
||||||
# Three-layer validation
|
|
||||||
try:
|
|
||||||
dl_size = os.path.getsize(tmp_path)
|
|
||||||
current_size = os.path.getsize(exe_path)
|
|
||||||
_update_log("Download complete ({} bytes), validating...".format(dl_size))
|
|
||||||
|
|
||||||
if dl_size < 100 * 1024:
|
|
||||||
_update_log("Validation failed: too small ({} bytes) — aborting".format(dl_size))
|
|
||||||
os.remove(tmp_path)
|
|
||||||
return False
|
|
||||||
|
|
||||||
if current_size > 0 and dl_size < current_size * 0.5:
|
|
||||||
_update_log("Validation failed: suspiciously small ({} vs {} bytes) — aborting".format(
|
|
||||||
dl_size, current_size))
|
|
||||||
os.remove(tmp_path)
|
|
||||||
return False
|
|
||||||
|
|
||||||
with open(tmp_path, "rb") as _f:
|
|
||||||
magic = _f.read(2)
|
|
||||||
if magic != b"MZ":
|
|
||||||
_update_log("Validation failed: not a valid Windows exe — aborting")
|
|
||||||
os.remove(tmp_path)
|
|
||||||
return False
|
|
||||||
|
|
||||||
_update_log("Validation passed ({} bytes, MZ ok)".format(dl_size))
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
_update_log("Validation error: {} — aborting".format(e))
|
|
||||||
try:
|
|
||||||
os.remove(tmp_path)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return False
|
|
||||||
|
|
||||||
bat_fd, bat_path = tempfile.mkstemp(suffix=".bat", prefix="tw_swap_")
|
|
||||||
os.close(bat_fd)
|
|
||||||
|
|
||||||
bat_content = (
|
|
||||||
"@echo off\r\n"
|
|
||||||
"ping 127.0.0.1 -n 4 > nul\r\n"
|
|
||||||
"copy /Y \"{exe}\" \"{exe}.old\"\r\n"
|
|
||||||
"set RETRIES=0\r\n"
|
|
||||||
":retry\r\n"
|
|
||||||
"copy /Y \"{new}\" \"{exe}\"\r\n"
|
|
||||||
"if errorlevel 1 (\r\n"
|
|
||||||
" set /a RETRIES+=1\r\n"
|
|
||||||
" if %RETRIES% GEQ 5 goto fail\r\n"
|
|
||||||
" ping 127.0.0.1 -n 3 > nul\r\n"
|
|
||||||
" goto retry\r\n"
|
|
||||||
")\r\n"
|
|
||||||
"start \"\" \"{exe}\"\r\n"
|
|
||||||
"del \"{new}\"\r\n"
|
|
||||||
"del \"%~f0\"\r\n"
|
|
||||||
"exit /b 0\r\n"
|
|
||||||
":fail\r\n"
|
|
||||||
"del \"{new}\"\r\n"
|
|
||||||
"del \"%~f0\"\r\n"
|
|
||||||
"exit /b 1\r\n"
|
|
||||||
).format(new=tmp_path, exe=exe_path)
|
|
||||||
|
|
||||||
with open(bat_path, "w") as f:
|
|
||||||
f.write(bat_content)
|
|
||||||
|
|
||||||
_update_log("Launching swap bat — exiting for update")
|
|
||||||
|
|
||||||
subprocess.Popen(
|
|
||||||
["cmd", "/C", bat_path],
|
|
||||||
creationflags=subprocess.CREATE_NO_WINDOW if hasattr(subprocess, "CREATE_NO_WINDOW") else 0,
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
_update_log("apply_update failed: {}".format(e))
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
# ── Config helpers ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _read_config():
|
|
||||||
"""Read config.json from the appropriate location and return as dict."""
|
|
||||||
if getattr(sys, "frozen", False):
|
|
||||||
_appdata = os.environ.get("LOCALAPPDATA") or os.environ.get("APPDATA") or ""
|
|
||||||
config_dir = os.path.join(_appdata, "ThorWatcher")
|
|
||||||
else:
|
|
||||||
config_dir = os.path.dirname(os.path.abspath(__file__)) or "."
|
|
||||||
config_path = os.path.join(config_dir, "config.json")
|
|
||||||
with open(config_path, "r", encoding="utf-8") as f:
|
|
||||||
return json.load(f)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Paths ─────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
if getattr(sys, "frozen", False):
|
|
||||||
HERE = os.path.dirname(os.path.abspath(sys.executable))
|
|
||||||
else:
|
|
||||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
|
|
||||||
if getattr(sys, "frozen", False):
|
|
||||||
_appdata = os.environ.get("LOCALAPPDATA") or os.environ.get("APPDATA") or HERE
|
|
||||||
CONFIG_DIR = os.path.join(_appdata, "ThorWatcher")
|
|
||||||
os.makedirs(CONFIG_DIR, exist_ok=True)
|
|
||||||
else:
|
|
||||||
CONFIG_DIR = HERE
|
|
||||||
CONFIG_PATH = os.path.join(CONFIG_DIR, "config.json")
|
|
||||||
|
|
||||||
|
|
||||||
# ── Icon drawing ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
COLORS = {
|
|
||||||
"ok": (60, 200, 80), # green
|
|
||||||
"pending": (230, 180, 0), # amber
|
|
||||||
"missing": (210, 40, 40), # red
|
|
||||||
"error": (160, 40, 200), # purple
|
|
||||||
"starting": (120, 120, 120), # grey
|
|
||||||
}
|
|
||||||
|
|
||||||
ICON_SIZE = 64
|
|
||||||
|
|
||||||
|
|
||||||
def make_icon(status):
|
|
||||||
"""Draw a plain colored circle for the system tray."""
|
|
||||||
color = COLORS.get(status, COLORS["starting"])
|
|
||||||
img = Image.new("RGBA", (ICON_SIZE, ICON_SIZE), (0, 0, 0, 0))
|
|
||||||
draw = ImageDraw.Draw(img)
|
|
||||||
margin = 6
|
|
||||||
draw.ellipse(
|
|
||||||
[margin, margin, ICON_SIZE - margin, ICON_SIZE - margin],
|
|
||||||
fill=color,
|
|
||||||
)
|
|
||||||
return img
|
|
||||||
|
|
||||||
|
|
||||||
# ── First-run check ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def ensure_config():
|
|
||||||
"""
|
|
||||||
If config.json is missing, launch the first-run wizard.
|
|
||||||
Returns True if config is ready, False if user cancelled.
|
|
||||||
"""
|
|
||||||
if os.path.exists(CONFIG_PATH):
|
|
||||||
return True
|
|
||||||
|
|
||||||
from thor_settings_dialog import show_dialog
|
|
||||||
saved = show_dialog(CONFIG_PATH, wizard=True)
|
|
||||||
if not saved:
|
|
||||||
_show_cancel_message()
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def _show_cancel_message():
|
|
||||||
try:
|
|
||||||
import tkinter as tk
|
|
||||||
from tkinter import messagebox
|
|
||||||
root = tk.Tk()
|
|
||||||
root.withdraw()
|
|
||||||
messagebox.showwarning(
|
|
||||||
"Thor Watcher",
|
|
||||||
"No configuration was saved.\nThe application will now exit.",
|
|
||||||
)
|
|
||||||
root.destroy()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# ── Tray app ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class WatcherTray:
|
|
||||||
def __init__(self):
|
|
||||||
self.state = {}
|
|
||||||
self.stop_event = threading.Event()
|
|
||||||
self._watcher_thread = None
|
|
||||||
self._icon = None
|
|
||||||
self._menu_lock = threading.Lock()
|
|
||||||
|
|
||||||
# ── Watcher thread management ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _start_watcher(self):
|
|
||||||
self.stop_event.clear()
|
|
||||||
self._watcher_thread = threading.Thread(
|
|
||||||
target=watcher.run_watcher,
|
|
||||||
args=(self.state, self.stop_event),
|
|
||||||
daemon=True,
|
|
||||||
name="watcher",
|
|
||||||
)
|
|
||||||
self._watcher_thread.start()
|
|
||||||
|
|
||||||
def _stop_watcher(self):
|
|
||||||
self.stop_event.set()
|
|
||||||
if self._watcher_thread is not None:
|
|
||||||
self._watcher_thread.join(timeout=10)
|
|
||||||
self._watcher_thread = None
|
|
||||||
|
|
||||||
def _restart_watcher(self):
|
|
||||||
self._stop_watcher()
|
|
||||||
self.stop_event = threading.Event()
|
|
||||||
self.state["status"] = "starting"
|
|
||||||
self.state["units"] = []
|
|
||||||
self.state["last_scan"] = None
|
|
||||||
self.state["last_error"] = None
|
|
||||||
self._start_watcher()
|
|
||||||
|
|
||||||
# ── Menu callbacks ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _open_settings(self, icon, item):
|
|
||||||
def _run():
|
|
||||||
from thor_settings_dialog import show_dialog
|
|
||||||
saved = show_dialog(CONFIG_PATH, wizard=False)
|
|
||||||
if saved:
|
|
||||||
self._restart_watcher()
|
|
||||||
threading.Thread(target=_run, daemon=True, name="settings-dialog").start()
|
|
||||||
|
|
||||||
def _open_logs(self, icon, item):
|
|
||||||
log_dir = self.state.get("log_dir")
|
|
||||||
if not log_dir:
|
|
||||||
log_dir = HERE
|
|
||||||
if os.path.exists(log_dir):
|
|
||||||
subprocess.Popen(["explorer", log_dir])
|
|
||||||
else:
|
|
||||||
parent = os.path.dirname(log_dir)
|
|
||||||
if os.path.exists(parent):
|
|
||||||
subprocess.Popen(["explorer", parent])
|
|
||||||
else:
|
|
||||||
subprocess.Popen(["explorer", HERE])
|
|
||||||
|
|
||||||
def _exit(self, icon, item):
|
|
||||||
self.stop_event.set()
|
|
||||||
icon.stop()
|
|
||||||
|
|
||||||
# ── Dynamic menu text ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _status_text(self):
|
|
||||||
status = self.state.get("status", "starting")
|
|
||||||
last_err = self.state.get("last_error")
|
|
||||||
last_scan = self.state.get("last_scan")
|
|
||||||
api_status = self.state.get("api_status", "disabled")
|
|
||||||
unit_count = len(self.state.get("units", []))
|
|
||||||
|
|
||||||
if status == "error":
|
|
||||||
return "Error — {}".format(last_err or "unknown")
|
|
||||||
if status == "starting":
|
|
||||||
return "Starting..."
|
|
||||||
|
|
||||||
if last_scan is not None:
|
|
||||||
age_secs = int((datetime.now() - last_scan).total_seconds())
|
|
||||||
age_str = "{}s ago".format(age_secs) if age_secs < 60 else "{}m ago".format(age_secs // 60)
|
|
||||||
else:
|
|
||||||
age_str = "never"
|
|
||||||
|
|
||||||
if api_status == "ok":
|
|
||||||
api_str = "API OK"
|
|
||||||
elif api_status == "fail":
|
|
||||||
api_str = "API FAIL"
|
|
||||||
else:
|
|
||||||
api_str = "API off"
|
|
||||||
|
|
||||||
base_line = "Running — {} | {} unit(s) | scan {}".format(api_str, unit_count, age_str)
|
|
||||||
|
|
||||||
sfm_status = self.state.get("sfm_status", "disabled")
|
|
||||||
if sfm_status in ("ok", "fail", "ready"):
|
|
||||||
counts = self.state.get("last_forward_counts") or {}
|
|
||||||
fwd = counts.get("forwarded", 0)
|
|
||||||
errs = counts.get("errors", 0)
|
|
||||||
last_fwd = self.state.get("last_forward")
|
|
||||||
if last_fwd is not None:
|
|
||||||
fwd_age = int((datetime.now() - last_fwd).total_seconds())
|
|
||||||
fwd_age_str = "{}s ago".format(fwd_age) if fwd_age < 60 else "{}m ago".format(fwd_age // 60)
|
|
||||||
else:
|
|
||||||
fwd_age_str = "pending"
|
|
||||||
sfm_line = "SFM {} | {} fwd, {} err | last {}".format(
|
|
||||||
sfm_status.upper(), fwd, errs, fwd_age_str,
|
|
||||||
)
|
|
||||||
return base_line + "\n" + sfm_line
|
|
||||||
|
|
||||||
return base_line
|
|
||||||
|
|
||||||
def _tray_status(self):
|
|
||||||
status = self.state.get("status", "starting")
|
|
||||||
if status == "error":
|
|
||||||
return "error"
|
|
||||||
if status == "starting":
|
|
||||||
return "starting"
|
|
||||||
api_status = self.state.get("api_status", "disabled")
|
|
||||||
if api_status == "fail":
|
|
||||||
return "missing" # red — API failing
|
|
||||||
sfm_status = self.state.get("sfm_status", "disabled")
|
|
||||||
if api_status == "ok" and sfm_status == "fail":
|
|
||||||
return "pending" # amber — heartbeat OK but forwarder is failing
|
|
||||||
if api_status == "disabled":
|
|
||||||
return "pending" # amber — running but not reporting
|
|
||||||
return "ok" # green — running and API good
|
|
||||||
|
|
||||||
def _build_menu(self):
|
|
||||||
return pystray.Menu(
|
|
||||||
pystray.MenuItem("Thor Watcher v{}".format(_CURRENT_VERSION), None, enabled=False),
|
|
||||||
pystray.Menu.SEPARATOR,
|
|
||||||
pystray.MenuItem(lambda item: self._status_text(), None, enabled=False),
|
|
||||||
pystray.Menu.SEPARATOR,
|
|
||||||
pystray.MenuItem("Settings...", self._open_settings),
|
|
||||||
pystray.MenuItem("Open Log Folder", self._open_logs),
|
|
||||||
pystray.Menu.SEPARATOR,
|
|
||||||
pystray.MenuItem("Exit", self._exit),
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── Icon/menu update loop ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _icon_updater(self):
|
|
||||||
"""Periodically refresh the tray icon and check for updates."""
|
|
||||||
_update_log("Updater thread started (version={}, first check in ~30s)".format(_CURRENT_VERSION))
|
|
||||||
last_status = None
|
|
||||||
# Initial first-check fires at counter==3 (~30s) so we get a confirmation
|
|
||||||
# line in the log soon after startup; subsequent checks every ~5 min.
|
|
||||||
update_check_counter = 27
|
|
||||||
|
|
||||||
while not self.stop_event.is_set():
|
|
||||||
icon_status = self._tray_status()
|
|
||||||
|
|
||||||
if self._icon is not None:
|
|
||||||
with self._menu_lock:
|
|
||||||
self._icon.menu = self._build_menu()
|
|
||||||
if icon_status != last_status:
|
|
||||||
self._icon.icon = make_icon(icon_status)
|
|
||||||
self._icon.title = "Thor Watcher — {}".format(self._status_text())
|
|
||||||
last_status = icon_status
|
|
||||||
|
|
||||||
# Terra-View push-triggered update
|
|
||||||
if self.state.get("update_available"):
|
|
||||||
self.state["update_available"] = False
|
|
||||||
self._do_update()
|
|
||||||
return
|
|
||||||
|
|
||||||
# Periodic update check
|
|
||||||
update_check_counter += 1
|
|
||||||
if update_check_counter >= 30:
|
|
||||||
update_check_counter = 0
|
|
||||||
tag, url = check_for_update()
|
|
||||||
if tag and url:
|
|
||||||
self._do_update(url)
|
|
||||||
return
|
|
||||||
|
|
||||||
self.stop_event.wait(timeout=10)
|
|
||||||
|
|
||||||
def _do_update(self, download_url=None):
|
|
||||||
"""Notify tray then apply update. If url is None, fetch it first."""
|
|
||||||
if download_url is None:
|
|
||||||
_, download_url = check_for_update()
|
|
||||||
if not download_url:
|
|
||||||
return
|
|
||||||
|
|
||||||
if self._icon is not None:
|
|
||||||
self._icon.title = "Thor Watcher — Updating..."
|
|
||||||
self._icon.icon = make_icon("starting")
|
|
||||||
|
|
||||||
success = apply_update(download_url)
|
|
||||||
if success:
|
|
||||||
self.stop_event.set()
|
|
||||||
if self._icon is not None:
|
|
||||||
self._icon.stop()
|
|
||||||
|
|
||||||
# ── Entry point ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def run(self):
|
|
||||||
self._start_watcher()
|
|
||||||
|
|
||||||
icon_img = make_icon("starting")
|
|
||||||
self._icon = pystray.Icon(
|
|
||||||
name="thor_watcher",
|
|
||||||
icon=icon_img,
|
|
||||||
title="Thor Watcher — Starting...",
|
|
||||||
menu=self._build_menu(),
|
|
||||||
)
|
|
||||||
|
|
||||||
updater = threading.Thread(
|
|
||||||
target=self._icon_updater, daemon=True, name="icon-updater"
|
|
||||||
)
|
|
||||||
updater.start()
|
|
||||||
|
|
||||||
self._icon.run()
|
|
||||||
|
|
||||||
|
|
||||||
# ── Entry point ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def main():
|
|
||||||
if not ensure_config():
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
app = WatcherTray()
|
|
||||||
app.run()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
Reference in New Issue
Block a user