Compare commits
2 Commits
d2b47156d8
...
2a3589ca5c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a3589ca5c | ||
|
|
d43ef7427f |
139
CHANGELOG.md
Normal file
139
CHANGELOG.md
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
# 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.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.0** (2026-01-15) - Background Polling System
|
||||||
|
- **v0.1.0** (2025-12-XX) - Initial Release
|
||||||
95
README.md
95
README.md
@@ -1,5 +1,7 @@
|
|||||||
# SLMM - Sound Level Meter Manager
|
# SLMM - Sound Level Meter Manager
|
||||||
|
|
||||||
|
**Version 0.2.0**
|
||||||
|
|
||||||
Backend API service for controlling and monitoring Rion NL-43/NL-53 Sound Level Meters via TCP and FTP protocols.
|
Backend API service for controlling and monitoring Rion NL-43/NL-53 Sound Level Meters via TCP and FTP protocols.
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
@@ -10,6 +12,8 @@ SLMM is a standalone backend module that provides REST API routing and command t
|
|||||||
|
|
||||||
## Features
|
## 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
|
- **Device Management**: Configure and manage multiple NL43/NL53 devices
|
||||||
- **Real-time Monitoring**: Stream live measurement data via WebSocket
|
- **Real-time Monitoring**: Stream live measurement data via WebSocket
|
||||||
- **Measurement Control**: Start, stop, pause, resume, and reset measurements
|
- **Measurement Control**: Start, stop, pause, resume, and reset measurements
|
||||||
@@ -22,18 +26,33 @@ SLMM is a standalone backend module that provides REST API routing and command t
|
|||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────┐ ┌──────────────┐ ┌─────────────────┐
|
┌─────────────────┐ ┌──────────────────────────────┐ ┌─────────────────┐
|
||||||
│ Terra-View UI │◄───────►│ SLMM API │◄───────►│ NL43/NL53 │
|
│ Terra-View UI │◄───────►│ SLMM API │◄───────►│ NL43/NL53 │
|
||||||
│ (Frontend) │ HTTP │ (Backend) │ TCP │ Sound Meters │
|
│ (Frontend) │ HTTP │ • REST Endpoints │ TCP │ Sound Meters │
|
||||||
└─────────────────┘ └──────────────┘ └─────────────────┘
|
└─────────────────┘ │ • WebSocket Streaming │ └─────────────────┘
|
||||||
│
|
│ • Background Poller ⭐ NEW │ ▲
|
||||||
▼
|
└──────────────────────────────┘ │
|
||||||
┌──────────────┐
|
│ Continuous
|
||||||
│ SQLite DB │
|
▼ Polling
|
||||||
│ (Cache) │
|
┌──────────────┐ │
|
||||||
|
│ SQLite DB │◄─────────────────────┘
|
||||||
|
│ • Config │
|
||||||
|
│ • Status │
|
||||||
└──────────────┘
|
└──────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Background Polling (v0.2.0)
|
||||||
|
|
||||||
|
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
|
## Quick Start
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
@@ -103,10 +122,18 @@ Logs are written to:
|
|||||||
|
|
||||||
| Method | Endpoint | Description |
|
| Method | Endpoint | Description |
|
||||||
|--------|----------|-------------|
|
|--------|----------|-------------|
|
||||||
| GET | `/api/nl43/{unit_id}/status` | Get cached measurement snapshot |
|
| 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 |
|
| 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 |
|
| 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
|
### Measurement Control
|
||||||
|
|
||||||
| Method | Endpoint | Description |
|
| Method | Endpoint | Description |
|
||||||
@@ -167,6 +194,7 @@ slmm/
|
|||||||
│ ├── routers.py # API route definitions
|
│ ├── routers.py # API route definitions
|
||||||
│ ├── models.py # SQLAlchemy database models
|
│ ├── models.py # SQLAlchemy database models
|
||||||
│ ├── services.py # NL43Client and business logic
|
│ ├── services.py # NL43Client and business logic
|
||||||
|
│ ├── background_poller.py # Background polling service ⭐ NEW
|
||||||
│ └── database.py # Database configuration
|
│ └── database.py # Database configuration
|
||||||
├── data/
|
├── data/
|
||||||
│ ├── slmm.db # SQLite database (auto-created)
|
│ ├── slmm.db # SQLite database (auto-created)
|
||||||
@@ -175,9 +203,12 @@ slmm/
|
|||||||
├── templates/
|
├── templates/
|
||||||
│ └── index.html # Simple web interface (optional)
|
│ └── index.html # Simple web interface (optional)
|
||||||
├── manuals/ # Device documentation
|
├── 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
|
├── API.md # Detailed API documentation
|
||||||
├── COMMUNICATION_GUIDE.md # NL43 protocol documentation
|
├── COMMUNICATION_GUIDE.md # NL43 protocol documentation
|
||||||
├── NL43_COMMANDS.md # Command reference
|
├── NL43_COMMANDS.md # Command reference
|
||||||
|
├── CHANGELOG.md # Version history ⭐ NEW
|
||||||
├── requirements.txt # Python dependencies
|
├── requirements.txt # Python dependencies
|
||||||
└── README.md # This file
|
└── README.md # This file
|
||||||
```
|
```
|
||||||
@@ -194,12 +225,16 @@ Stores device connection configuration:
|
|||||||
- `ftp_username`: FTP authentication username
|
- `ftp_username`: FTP authentication username
|
||||||
- `ftp_password`: FTP authentication password
|
- `ftp_password`: FTP authentication password
|
||||||
- `web_enabled`: Enable/disable web interface access
|
- `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
|
### NL43Status Table
|
||||||
Caches latest measurement snapshot:
|
Caches latest measurement snapshot:
|
||||||
- `unit_id` (PK): Unique device identifier
|
- `unit_id` (PK): Unique device identifier
|
||||||
- `last_seen`: Timestamp of last update
|
- `last_seen`: Timestamp of last update
|
||||||
- `measurement_state`: Current state (Measure/Stop)
|
- `measurement_state`: Current state (Measure/Stop)
|
||||||
|
- `measurement_start_time`: When measurement started (UTC)
|
||||||
|
- `counter`: Measurement interval counter (1-600)
|
||||||
- `lp`: Instantaneous sound pressure level
|
- `lp`: Instantaneous sound pressure level
|
||||||
- `leq`: Equivalent continuous sound level
|
- `leq`: Equivalent continuous sound level
|
||||||
- `lmax`: Maximum sound level
|
- `lmax`: Maximum sound level
|
||||||
@@ -210,6 +245,11 @@ Caches latest measurement snapshot:
|
|||||||
- `sd_remaining_mb`: Free SD card space (MB)
|
- `sd_remaining_mb`: Free SD card space (MB)
|
||||||
- `sd_free_ratio`: SD card free space ratio
|
- `sd_free_ratio`: SD card free space ratio
|
||||||
- `raw_payload`: Raw device response data
|
- `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
|
## Protocol Details
|
||||||
|
|
||||||
@@ -253,11 +293,33 @@ curl -X PUT http://localhost:8100/api/nl43/meter-001/config \
|
|||||||
curl -X POST http://localhost:8100/api/nl43/meter-001/start
|
curl -X POST http://localhost:8100/api/nl43/meter-001/start
|
||||||
```
|
```
|
||||||
|
|
||||||
### Get Live Status
|
### Get Cached Status (Fast - from background poller)
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8100/api/nl43/meter-001/status
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get Live Status (Bypasses cache)
|
||||||
```bash
|
```bash
|
||||||
curl http://localhost:8100/api/nl43/meter-001/live
|
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
|
### Verify Device Settings
|
||||||
```bash
|
```bash
|
||||||
curl http://localhost:8100/api/nl43/meter-001/settings
|
curl http://localhost:8100/api/nl43/meter-001/settings
|
||||||
@@ -356,13 +418,22 @@ pytest
|
|||||||
|
|
||||||
### Database Migrations
|
### Database Migrations
|
||||||
```bash
|
```bash
|
||||||
# Migrate existing database to add FTP credentials
|
# 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
|
python migrate_add_ftp_credentials.py
|
||||||
|
|
||||||
# Set FTP credentials for a device
|
# Set FTP credentials for a device
|
||||||
python set_ftp_credentials.py <unit_id> <username> <password>
|
python set_ftp_credentials.py <unit_id> <username> <password>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Testing Background Polling
|
||||||
|
```bash
|
||||||
|
# Run comprehensive polling tests
|
||||||
|
./test_polling.sh [unit_id]
|
||||||
|
```
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
This is a standalone module kept separate from the SFM/Terra-View codebase. When contributing:
|
This is a standalone module kept separate from the SFM/Terra-View codebase. When contributing:
|
||||||
|
|||||||
264
app/background_poller.py
Normal file
264
app/background_poller.py
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class BackgroundPoller:
|
||||||
|
"""
|
||||||
|
Background task that continuously polls NL43 devices and updates status cache.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- Per-device configurable poll intervals (10-3600 seconds)
|
||||||
|
- 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
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
# 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}")
|
||||||
|
|
||||||
|
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}"
|
||||||
|
)
|
||||||
|
status.is_reachable = False
|
||||||
|
else:
|
||||||
|
self._logger.warning(
|
||||||
|
f"Poll failed for {unit_id} (attempt {status.consecutive_failures}/3): {error_msg}"
|
||||||
|
)
|
||||||
|
|
||||||
|
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 10 seconds (prevents tight loops)
|
||||||
|
- Maximum 30 seconds (ensures responsiveness)
|
||||||
|
- 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 30 # 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 10-30 seconds
|
||||||
|
sleep_time = max(10, min(30, min_interval // 2))
|
||||||
|
|
||||||
|
return sleep_time
|
||||||
|
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
# Global singleton instance
|
||||||
|
poller = BackgroundPoller()
|
||||||
24
app/main.py
24
app/main.py
@@ -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
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ class NL43Config(Base):
|
|||||||
ftp_password = Column(String, nullable=True) # FTP login password
|
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):
|
||||||
"""
|
"""
|
||||||
@@ -42,3 +46,10 @@ class NL43Status(Base):
|
|||||||
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)
|
||||||
|
|||||||
168
app/routers.py
168
app/routers.py
@@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisco
|
|||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pydantic import BaseModel, field_validator
|
from pydantic import BaseModel, field_validator, Field
|
||||||
import logging
|
import logging
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import json
|
import json
|
||||||
@@ -77,6 +77,64 @@ class ConfigPayload(BaseModel):
|
|||||||
return v
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class PollingConfigPayload(BaseModel):
|
||||||
|
"""Payload for updating device polling configuration."""
|
||||||
|
poll_interval_seconds: int | None = Field(None, ge=10, le=3600, description="Polling interval in seconds (10-3600)")
|
||||||
|
poll_enabled: bool | None = Field(None, description="Enable or disable background polling for this device")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# GLOBAL POLLING STATUS ENDPOINT (must be before /{unit_id} routes)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@router.get("/_polling/status")
|
||||||
|
def get_global_polling_status(db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Get global background polling status for all devices.
|
||||||
|
|
||||||
|
Returns information about which devices are being polled, their
|
||||||
|
reachability status, failure counts, and last poll times.
|
||||||
|
|
||||||
|
Useful for monitoring the health of the background polling system.
|
||||||
|
|
||||||
|
Note: Must be defined before /{unit_id} routes to avoid routing conflicts.
|
||||||
|
"""
|
||||||
|
from app.background_poller import poller
|
||||||
|
|
||||||
|
configs = db.query(NL43Config).filter_by(
|
||||||
|
tcp_enabled=True,
|
||||||
|
poll_enabled=True
|
||||||
|
).all()
|
||||||
|
|
||||||
|
device_statuses = []
|
||||||
|
for cfg in configs:
|
||||||
|
status = db.query(NL43Status).filter_by(unit_id=cfg.unit_id).first()
|
||||||
|
|
||||||
|
device_statuses.append({
|
||||||
|
"unit_id": cfg.unit_id,
|
||||||
|
"poll_interval_seconds": cfg.poll_interval_seconds,
|
||||||
|
"poll_enabled": cfg.poll_enabled,
|
||||||
|
"is_reachable": status.is_reachable if status else None,
|
||||||
|
"consecutive_failures": status.consecutive_failures if status else 0,
|
||||||
|
"last_poll_attempt": status.last_poll_attempt.isoformat() if status and status.last_poll_attempt else None,
|
||||||
|
"last_success": status.last_success.isoformat() if status and status.last_success else None,
|
||||||
|
"last_error": status.last_error if status else None
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"data": {
|
||||||
|
"poller_running": poller._running,
|
||||||
|
"total_devices": len(configs),
|
||||||
|
"devices": device_statuses
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# DEVICE-SPECIFIC ENDPOINTS
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
@router.get("/{unit_id}/config")
|
@router.get("/{unit_id}/config")
|
||||||
def get_config(unit_id: str, db: Session = Depends(get_db)):
|
def get_config(unit_id: str, db: Session = Depends(get_db)):
|
||||||
cfg = db.query(NL43Config).filter_by(unit_id=unit_id).first()
|
cfg = db.query(NL43Config).filter_by(unit_id=unit_id).first()
|
||||||
@@ -98,6 +156,34 @@ def get_config(unit_id: str, db: Session = Depends(get_db)):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{unit_id}/config")
|
||||||
|
def delete_config(unit_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Delete device configuration and associated status data.
|
||||||
|
|
||||||
|
Used by Terra-View to remove devices from SLMM when deleted from roster.
|
||||||
|
"""
|
||||||
|
cfg = db.query(NL43Config).filter_by(unit_id=unit_id).first()
|
||||||
|
if not cfg:
|
||||||
|
raise HTTPException(status_code=404, detail="NL43 config not found")
|
||||||
|
|
||||||
|
# Also delete associated status record
|
||||||
|
status = db.query(NL43Status).filter_by(unit_id=unit_id).first()
|
||||||
|
if status:
|
||||||
|
db.delete(status)
|
||||||
|
logger.info(f"Deleted status record for {unit_id}")
|
||||||
|
|
||||||
|
db.delete(cfg)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info(f"Deleted device config for {unit_id}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"message": f"Deleted device {unit_id}"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{unit_id}/config")
|
@router.put("/{unit_id}/config")
|
||||||
async def upsert_config(unit_id: str, payload: ConfigPayload, db: Session = Depends(get_db)):
|
async def upsert_config(unit_id: str, payload: ConfigPayload, db: Session = Depends(get_db)):
|
||||||
cfg = db.query(NL43Config).filter_by(unit_id=unit_id).first()
|
cfg = db.query(NL43Config).filter_by(unit_id=unit_id).first()
|
||||||
@@ -167,6 +253,12 @@ def get_status(unit_id: str, db: Session = Depends(get_db)):
|
|||||||
"sd_remaining_mb": status.sd_remaining_mb,
|
"sd_remaining_mb": status.sd_remaining_mb,
|
||||||
"sd_free_ratio": status.sd_free_ratio,
|
"sd_free_ratio": status.sd_free_ratio,
|
||||||
"raw_payload": status.raw_payload,
|
"raw_payload": status.raw_payload,
|
||||||
|
# Background polling status
|
||||||
|
"is_reachable": status.is_reachable,
|
||||||
|
"consecutive_failures": status.consecutive_failures,
|
||||||
|
"last_poll_attempt": status.last_poll_attempt.isoformat() if status.last_poll_attempt else None,
|
||||||
|
"last_success": status.last_success.isoformat() if status.last_success else None,
|
||||||
|
"last_error": status.last_error,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1480,3 +1572,77 @@ async def run_diagnostics(unit_id: str, db: Session = Depends(get_db)):
|
|||||||
# All tests passed
|
# All tests passed
|
||||||
diagnostics["overall_status"] = "pass"
|
diagnostics["overall_status"] = "pass"
|
||||||
return diagnostics
|
return diagnostics
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# BACKGROUND POLLING CONFIGURATION ENDPOINTS
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@router.get("/{unit_id}/polling/config")
|
||||||
|
def get_polling_config(unit_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Get background polling configuration for a device.
|
||||||
|
|
||||||
|
Returns the current polling interval and enabled status for automatic
|
||||||
|
background status polling.
|
||||||
|
"""
|
||||||
|
cfg = db.query(NL43Config).filter_by(unit_id=unit_id).first()
|
||||||
|
if not cfg:
|
||||||
|
raise HTTPException(status_code=404, detail="Device configuration not found")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"data": {
|
||||||
|
"unit_id": unit_id,
|
||||||
|
"poll_interval_seconds": cfg.poll_interval_seconds,
|
||||||
|
"poll_enabled": cfg.poll_enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{unit_id}/polling/config")
|
||||||
|
def update_polling_config(
|
||||||
|
unit_id: str,
|
||||||
|
payload: PollingConfigPayload,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Update background polling configuration for a device.
|
||||||
|
|
||||||
|
Allows configuring the polling interval (10-3600 seconds) and
|
||||||
|
enabling/disabling automatic background polling per device.
|
||||||
|
|
||||||
|
Changes take effect on the next polling cycle.
|
||||||
|
"""
|
||||||
|
cfg = db.query(NL43Config).filter_by(unit_id=unit_id).first()
|
||||||
|
if not cfg:
|
||||||
|
raise HTTPException(status_code=404, detail="Device configuration not found")
|
||||||
|
|
||||||
|
# Update interval if provided
|
||||||
|
if payload.poll_interval_seconds is not None:
|
||||||
|
if payload.poll_interval_seconds < 10:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Polling interval must be at least 10 seconds"
|
||||||
|
)
|
||||||
|
cfg.poll_interval_seconds = payload.poll_interval_seconds
|
||||||
|
|
||||||
|
# Update enabled status if provided
|
||||||
|
if payload.poll_enabled is not None:
|
||||||
|
cfg.poll_enabled = payload.poll_enabled
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Updated polling config for {unit_id}: "
|
||||||
|
f"interval={cfg.poll_interval_seconds}s, enabled={cfg.poll_enabled}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"data": {
|
||||||
|
"unit_id": unit_id,
|
||||||
|
"poll_interval_seconds": cfg.poll_interval_seconds,
|
||||||
|
"poll_enabled": cfg.poll_enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
136
migrate_add_polling_fields.py
Normal file
136
migrate_add_polling_fields.py
Normal 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)
|
||||||
167
test_polling.sh
Executable file
167
test_polling.sh
Executable 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"
|
||||||
Reference in New Issue
Block a user