Compare commits

...

30 Commits

Author SHA1 Message Date
serversdwn
bf5f222511 Add:
- db cache dump on diagnostics request.
- individual device logs, db and files.
-Device logs api endpoints and diagnostics UI.

Fix:
- slmm standalone now uses local TZ (was UTC only before)
- fixed measurement start time logic.
2026-01-29 18:50:47 +00:00
serversdwn
eb39a9d1d0 add: device communication lock, Now to send a tcp command, slmm must establish a connection lock to prevent flooding unit.
fixed: Background poller intervals increased.
2026-01-29 07:54:49 +00:00
serversdwn
67d63b4173 Merge branch 'main' of ssh://10.0.0.2:2222/serversdown/slmm 2026-01-23 08:29:27 +00:00
serversdwn
25cf9528d0 docs: update to 0.2.1 2026-01-23 08:26:23 +00:00
738ad7878e doc update 2026-01-22 15:30:06 -05:00
serversdwn
152377d608 feat: terra-view scheduler implementation added. Start_cylce and stop_cycle functions added. 2026-01-22 20:25:47 +00:00
serversdwn
4868381053 Enhance FTP logging with detailed phases for connection, authentication, and data transfer 2026-01-21 08:05:38 +00:00
serversdwn
b4bbfd2b01 chore:fixed api.md to confirm FTP/TCP interactions are working. 2026-01-17 08:13:19 +00:00
serversdwn
82651f71b5 Add roster management interface and related API endpoints
- Implemented a new `/roster` endpoint to retrieve and manage device configurations.
- Added HTML template for the roster page with a table to display device status and actions.
- Introduced functionality to add, edit, and delete devices via the roster interface.
- Enhanced `ConfigPayload` model to include polling options.
- Updated the main application to serve the new roster page and link to it from the index.
- Added validation for polling interval in the configuration payload.
- Created detailed documentation for the roster management features and API endpoints.
2026-01-17 08:00:05 +00:00
serversdwn
182920809d chore: docs and scripts organized. clutter cleared. 2026-01-16 19:06:38 +00:00
serversdwn
2a3589ca5c Add endpoint to delete device configuration and associated status data 2026-01-16 07:39:26 +00:00
serversdwn
d43ef7427f v0.2.0: async status polling added. 2026-01-16 06:24:13 +00:00
serversdwn
d2b47156d8 Simple diagnostics heartbeat test program added, for debugging. 2026-01-15 20:52:08 +00:00
serversdwn
5b31c2e567 Add endpoint to sync measurement start time from FTP folder timestamp 2026-01-14 21:58:45 +00:00
serversdwn
b74360b6bb Implement automatic sleep mode disable for NL43/NL53 during config updates and measurements 2026-01-14 19:58:22 +00:00
serversdwn
3d445daf1f fixed FTP port support to NL43 configuration and client 2026-01-14 01:44:53 +00:00
serversdwn
2cb96a7a1c Add configurable timezone support with environment variables 2026-01-12 16:31:33 +00:00
serversdwn
6b363b0788 Added: Ability to change store name and overwrite protection 2026-01-08 19:16:59 +00:00
serversdwn
1fb786c262 Fix NL43 DRD field mapping to match official specification
Corrected the parsing of NL43 DRD (Dynamic Range Data) and DOD (Data On Demand)
responses according to the NL43 Communications Guide. The previous implementation
incorrectly mapped d0 (counter field) as a measurement.

Changes:
- Updated DRD/DOD parsing to skip d0 (counter: 1-600)
- Correctly map d1-d5 to lp/leq/lmax/lmin/lpeak measurements
- Added inline documentation referencing DRD format specification
- Included database migration script to revert incorrect field names

DRD format per NL43 spec:
- 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)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-07 03:42:26 +00:00
serversdwn
50c9370b8e Containerized for TV deployment 2026-01-07 01:32:25 +00:00
serversdwn
a297e6c5fe cleanup time 2026-01-02 21:19:57 +00:00
serversdwn
6ac60eb380 api command reference doc added 2025-12-27 08:01:08 +00:00
serversdwn
b1267f47b2 Remove data/ and cache files from git tracking, update .gitignore, cause im a noob 2025-12-25 04:03:34 +00:00
serversdwn
8f93c345fe Remove data/ folder from git tracking 2025-12-25 04:00:29 +00:00
serversdwn
92b6173f5f all settings api 2025-12-25 03:58:32 +00:00
serversdwn
f9139d6aa3 feat: Add comprehensive NL-43/NL-53 Communication Guide and command references
- Introduced a new communication guide detailing protocol basics, transport modes, and a quick startup checklist.
- Added a detailed list of commands with their functions and usage for NL-43/NL-53 devices.
- Created a verified quick reference for command formats to prevent common mistakes.
- Implemented an improvements document outlining critical fixes, security enhancements, reliability upgrades, and code quality improvements for the SLMM project.
- Enhanced the frontend with a new button to retrieve all device settings, along with corresponding JavaScript functionality.
- Added a test script for the new settings retrieval API endpoint to demonstrate its usage and validate functionality.
2025-12-25 00:36:46 +00:00
serversdwn
c90544a712 readme expanded 2025-12-24 07:21:04 +00:00
serversdwn
12d512a515 sleep mode debug, proper command ref doc added 2025-12-24 07:16:33 +00:00
serversdwn
60c95e825d API built for most common commands 2025-12-24 06:18:42 +00:00
serversdwn
316cfa84f8 Add FTP credentials management and UI enhancements
- Implement migration script to add ftp_username and ftp_password columns to nl43_config table.
- Create set_ftp_credentials.py script for updating FTP credentials in the database.
- Update requirements.txt to include aioftp for FTP functionality.
- Enhance index.html with FTP controls including enable, disable, check status, and list files features.
- Add JavaScript functions for handling FTP operations and displaying file lists.
2025-12-24 02:03:03 +00:00
50 changed files with 9902 additions and 155 deletions

18
.env.example Normal file
View File

@@ -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=*

13
.gitignore vendored
View File

@@ -1 +1,14 @@
/manuals/ /manuals/
/data/
# Python cache
__pycache__/
*.pyc
*.pyo
*.pyd
.Python
*.so
*.egg
*.egg-info/
dist/
build/

151
CHANGELOG.md Normal file
View File

@@ -0,0 +1,151 @@
# Changelog
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.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.2.1] - 2026-01-23
### Added
- **Roster management**: UI and API endpoints for managing device rosters.
- **Delete config endpoint**: Remove device configuration alongside cached status data.
- **Scheduler hooks**: `start_cycle` and `stop_cycle` helpers for Terra-View scheduling integration.
### Changed
- **FTP logging**: Connection, authentication, and transfer phases now log explicitly.
- **Documentation**: Reorganized docs/scripts and updated API notes for FTP/TCP verification.
## [0.2.0] - 2026-01-15
### Added
#### Background Polling System
- **Continuous automatic device polling** - Background service that continuously polls configured devices
- **Per-device configurable intervals** - Each device can have custom polling interval (10-3600 seconds, default 60)
- **Automatic offline detection** - Devices automatically marked unreachable after 3 consecutive failures
- **Reachability tracking** - Database fields track device health with failure counters and error messages
- **Dynamic sleep scheduling** - Polling service adjusts sleep intervals based on device configurations
- **Graceful lifecycle management** - Background poller starts on application startup and stops cleanly on shutdown
#### 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
- **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)
- **Application startup** - Added lifespan context manager in `app/main.py` to manage background poller lifecycle
- **Performance improvement** - Terra-View requests now return cached data instantly (<100ms) instead of waiting for device queries (1-2 seconds)
### Technical Details
#### Architecture
- Background poller runs as async task using `asyncio.create_task()`
- Uses existing `NL43Client` and `persist_snapshot()` functions - no code duplication
- 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
- Initial release
- REST API for NL43/NL53 sound level meter control
- 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
### Database Schema (v0.1.0)
- **NL43Config table** - Device connection configuration
- **NL43Status table** - Measurement snapshot cache
---
## Version History Summary
- **v0.2.1** (2026-01-23) - Roster management, scheduler hooks, FTP logging, doc cleanup
- **v0.2.0** (2026-01-15) - Background Polling System
- **v0.1.0** (2025-12-XX) - Initial Release

21
Dockerfile Normal file
View File

@@ -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"]

480
README.md
View File

@@ -1,17 +1,471 @@
# slmm # SLMM - Sound Level Meter Manager
Standalone NL43 addon module (keep separate from the SFM/terra-view codebase).
Run the addon API: **Version 0.2.1**
```bash
pip install -r requirements.txt Backend API service for controlling and monitoring Rion NL-43/NL-53 Sound Level Meters via TCP and FTP protocols.
uvicorn app.main:app --reload --port 8100
## Overview
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.
## Features
- **Background Polling** ⭐ NEW: Continuous automatic polling of devices with configurable intervals
- **Offline Detection** ⭐ NEW: Automatic device reachability tracking with failure counters
- **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
```
┌─────────────────┐ ┌──────────────────────────────┐ ┌─────────────────┐
│ │◄───────►│ SLMM API │◄───────►│ NL43/NL53 │
│ (Frontend) │ HTTP │ • REST Endpoints │ TCP │ Sound Meters │
└─────────────────┘ │ • WebSocket Streaming │ └─────────────────┘
│ • Background Poller ⭐ NEW │ ▲
└──────────────────────────────┘ │
│ Continuous
▼ Polling
┌──────────────┐ │
│ SQLite DB │◄─────────────────────┘
│ • Config │
│ • Status │
└──────────────┘
``` ```
Endpoints: ### Background Polling (v0.2.0)
- `GET /health`
- `GET /api/nl43/{unit_id}/config`
- `PUT /api/nl43/{unit_id}/config`
- `GET /api/nl43/{unit_id}/status`
- `POST /api/nl43/{unit_id}/status`
Use `app/services.py` to wire in the TCP connector and call `persist_snapshot` with parsed DOD/DRD data. 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
### 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
```
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
```
## Database Schema
### 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
### NL43Status Table
Caches latest measurement snapshot:
- `unit_id` (PK): Unique device identifier
- `last_seen`: Timestamp of last update
- `measurement_state`: Current state (Measure/Stop)
- `measurement_start_time`: When measurement started (UTC)
- `counter`: Measurement interval counter (1-600)
- `lp`: Instantaneous sound pressure level
- `leq`: Equivalent continuous sound level
- `lmax`: Maximum sound level
- `lmin`: Minimum sound level
- `lpeak`: Peak sound level
- `battery_level`: Battery percentage
- `power_source`: Current power source
- `sd_remaining_mb`: Free SD card space (MB)
- `sd_free_ratio`: SD card free space ratio
- `raw_payload`: Raw device response data
- `is_reachable`: Device reachability status (Boolean) ⭐ NEW
- `consecutive_failures`: Count of consecutive poll failures ⭐ NEW
- `last_poll_attempt`: Last time background poller attempted to poll ⭐ NEW
- `last_success`: Last successful poll timestamp ⭐ NEW
- `last_error`: Last error message (truncated to 500 chars) ⭐ NEW
## Protocol Details
### 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)
### 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`
### Data Formats
**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
## Example Usage
### Configure a Device
```bash
curl -X PUT http://localhost:8100/api/nl43/meter-001/config \
-H "Content-Type: application/json" \
-d '{
"host": "192.168.1.100",
"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
```
### Get Cached Status (Fast - from background poller)
```bash
curl http://localhost:8100/api/nl43/meter-001/status
```
### Get Live Status (Bypasses cache)
```bash
curl http://localhost:8100/api/nl43/meter-001/live
```
### Configure Background Polling ⭐ NEW
```bash
# Set polling interval to 30 seconds
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
}'
# Get polling configuration
curl http://localhost:8100/api/nl43/meter-001/polling/config
# Check global polling status
curl http://localhost:8100/api/nl43/_polling/status
```
### Verify Device Settings
```bash
curl http://localhost:8100/api/nl43/meter-001/settings
```
This returns all current device configuration:
```json
{
"status": "ok",
"unit_id": "meter-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"
}
}
```
### Stream Real-time Data (JavaScript)
```javascript
const ws = new WebSocket('ws://localhost:8100/api/nl43/meter-001/stream');
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log('Live measurement:', data);
};
```
### 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
### 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
### 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
### 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
### WebSocket Disconnects
- WebSocket streams maintain persistent connection
- Limit concurrent streams to avoid device overload
- Connection will auto-close if device stops responding
## Development
### Running Tests
```bash
# Add test commands when implemented
pytest
```
### Database Migrations
```bash
# Migrate to v0.2.0 (add background polling fields)
python3 migrate_add_polling_fields.py
# 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>
```
### 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

Binary file not shown.

343
app/background_poller.py Normal file
View File

@@ -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()

277
app/device_logger.py Normal file
View File

@@ -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()

View File

@@ -1,5 +1,6 @@
import os import os
import logging import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
@@ -7,6 +8,7 @@ from fastapi.templating import Jinja2Templates
from app.database import Base, engine from app.database import Base, engine
from app import routers from app import routers
from app.background_poller import poller
# Configure logging # Configure logging
logging.basicConfig( logging.basicConfig(
@@ -23,10 +25,28 @@ logger = logging.getLogger(__name__)
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
logger.info("Database tables initialized") 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( app = FastAPI(
title="SLMM NL43 Addon", title="SLMM NL43 Addon",
description="Standalone module for NL43 configuration and status APIs", description="Standalone module for NL43 configuration and status APIs with background polling",
version="0.1.0", version="0.2.0",
lifespan=lifespan,
) )
# CORS configuration - use environment variable for allowed origins # CORS configuration - use environment variable for allowed origins
@@ -52,6 +72,11 @@ def index(request: Request):
return templates.TemplateResponse("index.html", {"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") @app.get("/health")
async def health(): async def health():
"""Basic health check endpoint.""" """Basic health check endpoint."""
@@ -73,7 +98,7 @@ async def health_devices():
configs = db.query(NL43Config).filter_by(tcp_enabled=True).all() configs = db.query(NL43Config).filter_by(tcp_enabled=True).all()
for cfg in configs: for cfg in configs:
client = NL43Client(cfg.host, cfg.tcp_port, timeout=2.0) client = NL43Client(cfg.host, cfg.tcp_port, timeout=2.0, ftp_username=cfg.ftp_username, ftp_password=cfg.ftp_password)
status = { status = {
"unit_id": cfg.unit_id, "unit_id": cfg.unit_id,
"host": cfg.host, "host": cfg.host,

View File

@@ -11,11 +11,18 @@ class NL43Config(Base):
unit_id = Column(String, primary_key=True, index=True) unit_id = Column(String, primary_key=True, index=True)
host = Column(String, default="127.0.0.1") host = Column(String, default="127.0.0.1")
tcp_port = Column(Integer, default=80) # NL43 TCP control port (via RX55) tcp_port = Column(Integer, default=2255) # NL43 TCP control port (standard: 2255)
tcp_enabled = Column(Boolean, default=True) tcp_enabled = Column(Boolean, default=True)
ftp_enabled = Column(Boolean, default=False) 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) 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): class NL43Status(Base):
""" """
@@ -27,13 +34,41 @@ class NL43Status(Base):
unit_id = Column(String, primary_key=True, index=True) unit_id = Column(String, primary_key=True, index=True)
last_seen = Column(DateTime, default=func.now()) last_seen = Column(DateTime, default=func.now())
measurement_state = Column(String, default="unknown") # Measure/Stop measurement_state = Column(String, default="unknown") # Measure/Stop
lp = Column(String, nullable=True) measurement_start_time = Column(DateTime, nullable=True) # When measurement started (UTC)
leq = Column(String, nullable=True) counter = Column(String, nullable=True) # d0: Measurement interval counter (1-600)
lmax = Column(String, nullable=True) lp = Column(String, nullable=True) # Instantaneous sound pressure level
lmin = Column(String, nullable=True) leq = Column(String, nullable=True) # Equivalent continuous sound level
lpeak = Column(String, nullable=True) 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) battery_level = Column(String, nullable=True)
power_source = Column(String, nullable=True) power_source = Column(String, nullable=True)
sd_remaining_mb = Column(String, nullable=True) sd_remaining_mb = Column(String, nullable=True)
sd_free_ratio = Column(String, nullable=True) sd_free_ratio = Column(String, nullable=True)
raw_payload = Column(Text, 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)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

67
archive/README.md Normal file
View File

@@ -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

View File

@@ -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()

View File

@@ -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)

View File

@@ -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()

View File

@@ -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))

View File

@@ -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))

100
archive/nl43_dod_poll.py Normal file
View File

@@ -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()

Binary file not shown.

View File

@@ -1,93 +0,0 @@
2025-12-23 19:02:07,047 - app.main - INFO - Database tables initialized
2025-12-23 19:02:07,048 - app.main - INFO - CORS allowed origins: ['*']
2025-12-23 19:02:19,874 - app.main - INFO - Database tables initialized
2025-12-23 19:02:19,874 - app.main - INFO - CORS allowed origins: ['*']
2025-12-23 19:25:54,057 - app.main - INFO - Database tables initialized
2025-12-23 19:25:54,057 - app.main - INFO - CORS allowed origins: ['*']
2025-12-23 19:26:08,782 - app.services - INFO - Sending command to 63.45.161.130:2255: DOD?
2025-12-23 19:26:13,783 - app.services - ERROR - Connection timeout to 63.45.161.130:2255
2025-12-23 19:26:13,783 - app.routers - ERROR - Failed to get live status for nl43-1: Failed to connect to device at 63.45.161.130:2255
2025-12-23 19:32:21,893 - app.routers - INFO - Updated config for unit nl43-1
2025-12-23 19:32:24,189 - app.services - INFO - Sending command to 63.45.161.130:52255: DOD?
2025-12-23 19:32:29,191 - app.services - ERROR - Connection timeout to 63.45.161.130:52255
2025-12-23 19:32:29,191 - app.routers - ERROR - Failed to get live status for nl43-1: Failed to connect to device at 63.45.161.130:52255
2025-12-23 19:33:01,847 - app.services - INFO - Sending command to 63.45.161.130:52255: DOD?
2025-12-23 19:33:06,848 - app.services - ERROR - Connection timeout to 63.45.161.130:52255
2025-12-23 19:33:06,848 - app.routers - ERROR - Failed to get live status for nl43-1: Failed to connect to device at 63.45.161.130:52255
2025-12-23 19:33:07,508 - app.routers - INFO - Updated config for unit nl43-1
2025-12-23 19:33:09,056 - app.services - INFO - Sending command to 63.45.161.130:5225: DOD?
2025-12-23 19:33:14,057 - app.services - ERROR - Connection timeout to 63.45.161.130:5225
2025-12-23 19:33:14,057 - app.routers - ERROR - Failed to get live status for nl43-1: Failed to connect to device at 63.45.161.130:5225
2025-12-23 19:33:22,649 - app.services - INFO - Sending command to 63.45.161.130:5225: $Measure, Start
2025-12-23 19:33:27,650 - app.services - ERROR - Connection timeout to 63.45.161.130:5225
2025-12-23 19:33:27,650 - app.routers - ERROR - Failed to start measurement on nl43-1: Failed to connect to device at 63.45.161.130:5225
2025-12-23 19:39:10,370 - app.routers - INFO - Updated config for unit nl43-1
2025-12-23 19:39:12,948 - app.services - INFO - Sending command to 63.45.161.30:5225: DOD?
2025-12-23 19:39:17,948 - app.services - ERROR - Connection timeout to 63.45.161.30:5225
2025-12-23 19:39:17,948 - app.routers - ERROR - Failed to get live status for nl43-1: Failed to connect to device at 63.45.161.30:5225
2025-12-23 19:39:40,472 - app.services - INFO - Sending command to 63.45.161.30:5225: $Measure, Start
2025-12-23 19:39:45,472 - app.services - ERROR - Connection timeout to 63.45.161.30:5225
2025-12-23 19:39:45,472 - app.routers - ERROR - Failed to start measurement on nl43-1: Failed to connect to device at 63.45.161.30:5225
2025-12-23 19:39:52,929 - app.services - INFO - Sending command to 63.45.161.30:5225: $Measure, Stop
2025-12-23 19:39:57,929 - app.services - ERROR - Connection timeout to 63.45.161.30:5225
2025-12-23 19:39:57,929 - app.routers - ERROR - Failed to stop measurement on nl43-1: Failed to connect to device at 63.45.161.30:5225
2025-12-23 19:40:15,626 - app.services - INFO - Sending command to 63.45.161.30:5225: DOD?
2025-12-23 19:40:20,626 - app.services - ERROR - Connection timeout to 63.45.161.30:5225
2025-12-23 19:40:20,626 - app.routers - ERROR - Failed to get live status for nl43-1: Failed to connect to device at 63.45.161.30:5225
2025-12-23 19:41:48,870 - app.routers - INFO - Updated config for unit nl43-1
2025-12-23 19:41:51,383 - app.services - INFO - Sending command to 63.45.161.30:2255: DOD?
2025-12-23 19:41:51,551 - app.services - ERROR - Malformed DOD response from 63.45.161.30:2255: R+0000
2025-12-23 19:41:51,551 - app.routers - ERROR - Invalid response from device nl43-1: Malformed DOD response: expected comma-separated values, got: R+0000
2025-12-23 19:41:59,639 - app.services - INFO - Sending command to 63.45.161.30:2255: $Measure, Start
2025-12-23 19:41:59,774 - app.routers - INFO - Started measurement on unit nl43-1
2025-12-23 19:42:12,493 - app.services - INFO - Sending command to 63.45.161.30:2255: $Measure, Stop
2025-12-23 19:42:12,654 - app.routers - INFO - Stopped measurement on unit nl43-1
2025-12-23 19:42:21,145 - app.services - INFO - Sending command to 63.45.161.30:2255: DOD?
2025-12-23 19:42:21,304 - app.services - ERROR - Malformed DOD response from 63.45.161.30:2255: R+0000
2025-12-23 19:42:21,304 - app.routers - ERROR - Invalid response from device nl43-1: Malformed DOD response: expected comma-separated values, got: R+0000
2025-12-23 19:43:43,946 - app.services - INFO - Sending command to 63.45.161.30:2255: $Measure, Start
2025-12-23 19:43:44,096 - app.routers - INFO - Started measurement on unit nl43-1
2025-12-23 19:57:13,076 - app.main - INFO - Database tables initialized
2025-12-23 19:57:13,077 - app.main - INFO - CORS allowed origins: ['*']
2025-12-23 19:57:31,950 - app.main - INFO - Database tables initialized
2025-12-23 19:57:31,950 - app.main - INFO - CORS allowed origins: ['*']
2025-12-23 19:58:52,676 - app.services - INFO - Sending command to 63.45.161.30:2255: DOD?
2025-12-23 19:58:52,824 - app.services - INFO - Parsed 64 data points from DOD response
2025-12-23 19:58:52,833 - app.routers - INFO - Retrieved live status for unit nl43-1
2025-12-23 19:59:05,367 - app.services - INFO - Sending command to 63.45.161.30:2255: $Measure, Start
2025-12-23 19:59:05,534 - app.services - ERROR - Communication error with 63.45.161.30:2255: Command error - device did not recognize command
2025-12-23 19:59:05,534 - app.routers - ERROR - Unexpected error starting measurement on nl43-1: Command error - device did not recognize command
2025-12-23 19:59:25,765 - app.services - INFO - Sending command to 63.45.161.30:2255: DOD?
2025-12-23 19:59:26,024 - app.services - INFO - Parsed 64 data points from DOD response
2025-12-23 19:59:26,044 - app.routers - INFO - Retrieved live status for unit nl43-1
2025-12-23 19:59:29,829 - app.services - INFO - Sending command to 63.45.161.30:2255: DOD?
2025-12-23 19:59:29,983 - app.services - INFO - Parsed 64 data points from DOD response
2025-12-23 19:59:29,991 - app.routers - INFO - Retrieved live status for unit nl43-1
2025-12-23 20:01:30,447 - app.services - INFO - Sending command to 63.45.161.30:2255: DOD?
2025-12-23 20:01:30,624 - app.services - INFO - Parsed 64 data points from DOD response
2025-12-23 20:01:30,633 - app.routers - INFO - Retrieved live status for unit nl43-1
2025-12-23 20:01:31,448 - app.services - INFO - Sending command to 63.45.161.30:2255: DOD?
2025-12-23 20:01:31,663 - app.services - INFO - Parsed 64 data points from DOD response
2025-12-23 20:01:31,671 - app.routers - INFO - Retrieved live status for unit nl43-1
2025-12-23 20:13:43,728 - app.main - INFO - Database tables initialized
2025-12-23 20:13:43,728 - app.main - INFO - CORS allowed origins: ['*']
2025-12-23 20:14:33,467 - app.services - INFO - Sending command to 63.45.161.30:2255: DOD?
2025-12-23 20:14:33,625 - app.services - INFO - Parsed 64 data points from DOD response
2025-12-23 20:14:33,635 - app.routers - INFO - Retrieved live status for unit nl43-1
2025-12-23 20:14:39,641 - app.services - INFO - Sending command to 63.45.161.30:2255: $Measure,Start
2025-12-23 20:14:39,935 - app.services - ERROR - Communication error with 63.45.161.30:2255: Command error - device did not recognize command
2025-12-23 20:14:39,935 - app.routers - ERROR - Unexpected error starting measurement on nl43-1: Command error - device did not recognize command
2025-12-23 20:23:03,818 - app.main - INFO - Database tables initialized
2025-12-23 20:23:03,818 - app.main - INFO - CORS allowed origins: ['*']
2025-12-23 20:23:58,949 - app.services - INFO - Sending command to 63.45.161.30:2255: DOD?
2025-12-23 20:23:59,105 - app.services - INFO - Parsed 64 data points from DOD response
2025-12-23 20:23:59,115 - app.routers - INFO - Retrieved live status for unit nl43-1
2025-12-23 20:24:00,869 - app.services - INFO - Sending command to 63.45.161.30:2255: $Measure,1
2025-12-23 20:24:01,185 - app.services - ERROR - Communication error with 63.45.161.30:2255: Command error - device did not recognize command
2025-12-23 20:24:01,185 - app.routers - ERROR - Unexpected error starting measurement on nl43-1: Command error - device did not recognize command
2025-12-23 20:29:41,079 - app.main - INFO - Database tables initialized
2025-12-23 20:29:41,079 - app.main - INFO - CORS allowed origins: ['*']
2025-12-23 20:29:56,999 - app.services - INFO - Sending command to 63.45.161.30:2255: Measure,Start
2025-12-23 20:29:57,135 - app.routers - INFO - Started measurement on unit nl43-1
2025-12-23 20:30:46,229 - app.services - INFO - Sending command to 63.45.161.30:2255: Measure,Stop
2025-12-23 20:30:46,455 - app.routers - INFO - Stopped measurement on unit nl43-1

672
docs/API.md Normal file
View File

@@ -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

246
docs/ROSTER.md Normal file
View File

@@ -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)

View File

@@ -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

26
docs/features/README.md Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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.

171
docs/features/UI_UPDATE.md Normal file
View File

@@ -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

770
docs/nl43_Command_ref.md Normal file
View File

@@ -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.

View File

@@ -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()

59
migrate_add_ftp_credentials.py Executable file
View File

@@ -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()

View File

@@ -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)

View File

@@ -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()

View File

@@ -2,3 +2,6 @@ fastapi
uvicorn uvicorn
sqlalchemy sqlalchemy
pydantic pydantic
aioftp
jinja2
websockets

65
set_ftp_credentials.py Executable file
View File

@@ -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)

View File

@@ -7,33 +7,155 @@
<style> <style>
body { font-family: system-ui, -apple-system, sans-serif; margin: 24px; max-width: 900px; } body { font-family: system-ui, -apple-system, sans-serif; margin: 24px; max-width: 900px; }
fieldset { margin-bottom: 16px; padding: 12px; } fieldset { margin-bottom: 16px; padding: 12px; }
legend { font-weight: 600; }
label { display: block; margin-bottom: 6px; font-weight: 600; } label { display: block; margin-bottom: 6px; font-weight: 600; }
input { width: 100%; padding: 8px; margin-bottom: 10px; } input { width: 100%; padding: 8px; margin-bottom: 10px; }
button { padding: 8px 12px; margin-right: 8px; } button { padding: 8px 12px; margin-right: 8px; }
#log { background: #f6f8fa; border: 1px solid #d0d7de; padding: 12px; min-height: 120px; white-space: pre-wrap; } #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> </style>
</head> </head>
<body> <body>
<h1>SLMM NL43 Standalone</h1> <h1>SLMM NL43 Standalone</h1>
<p>Configure a unit (host/port), then use controls to Start/Stop and fetch live status.</p> <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> <fieldset>
<legend>Unit Config</legend> <legend>🔍 Connection Diagnostics</legend>
<label>Unit ID</label> <button onclick="runDiagnostics()">Run Diagnostics</button>
<input id="unitId" value="nl43-1" /> <button onclick="clearDiagnostics()">Clear</button>
<label>Host</label> <div id="diagnosticsResults" style="margin-top: 12px;"></div>
<input id="host" value="127.0.0.1" />
<label>Port</label>
<input id="port" type="number" value="80" />
<button onclick="saveConfig()">Save Config</button>
<button onclick="loadConfig()">Load Config</button>
</fieldset> </fieldset>
<fieldset> <fieldset>
<legend>Controls</legend> <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="start()">Start</button>
<button onclick="stop()">Stop</button> <button onclick="stop()">Stop</button>
<button onclick="live()">Fetch Live (DOD?)</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>
<fieldset> <fieldset>
@@ -49,19 +171,336 @@
<script> <script>
const logEl = document.getElementById('log'); const logEl = document.getElementById('log');
const statusEl = document.getElementById('status'); 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) { function log(msg) {
logEl.textContent += msg + "\n"; 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() { async function saveConfig() {
const unitId = document.getElementById('unitId').value; const unitId = document.getElementById('unitId').value;
const host = document.getElementById('host').value; const host = document.getElementById('host').value;
const port = parseInt(document.getElementById('port').value, 10); 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`, { const res = await fetch(`/api/nl43/${unitId}/config`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ host, tcp_port: port }) body: JSON.stringify(config)
}); });
const data = await res.json(); const data = await res.json();
log(`Saved config: ${JSON.stringify(data)}`); log(`Saved config: ${JSON.stringify(data)}`);
@@ -78,6 +517,12 @@
const data = response.data; const data = response.data;
document.getElementById('host').value = data.host; document.getElementById('host').value = data.host;
document.getElementById('port').value = data.tcp_port; 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)}`); log(`Loaded config: ${JSON.stringify(data)}`);
} }
@@ -93,6 +538,13 @@
log(`Stop: ${res.status}`); 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() { async function live() {
const unitId = document.getElementById('unitId').value; const unitId = document.getElementById('unitId').value;
const res = await fetch(`/api/nl43/${unitId}/live`); const res = await fetch(`/api/nl43/${unitId}/live`);
@@ -104,6 +556,425 @@
statusEl.textContent = JSON.stringify(data.data, null, 2); statusEl.textContent = JSON.stringify(data.data, null, 2);
log(`Live: ${JSON.stringify(data.data)}`); 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> </script>
</body> </body>
</html> </html>

624
templates/roster.html Normal file
View File

@@ -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()">&times;</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 = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
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>

167
test_polling.sh Executable file
View File

@@ -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"

98
test_settings_endpoint.py Normal file
View File

@@ -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!")

View File

@@ -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()