Background poller intervals increased.

This commit is contained in:
serversdwn
2026-01-29 06:08:55 +00:00
parent 67d63b4173
commit 89fca9d0fe
3 changed files with 143 additions and 107 deletions

View File

@@ -25,7 +25,7 @@ class BackgroundPoller:
Background task that continuously polls NL43 devices and updates status cache. Background task that continuously polls NL43 devices and updates status cache.
Features: Features:
- Per-device configurable poll intervals (10-3600 seconds) - Per-device configurable poll intervals (30 seconds to 6 hours)
- Automatic offline detection (marks unreachable after 3 consecutive failures) - Automatic offline detection (marks unreachable after 3 consecutive failures)
- Dynamic sleep intervals based on device configurations - Dynamic sleep intervals based on device configurations
- Graceful shutdown on application stop - Graceful shutdown on application stop
@@ -230,8 +230,8 @@ class BackgroundPoller:
Calculate the next sleep interval based on all device poll intervals. Calculate the next sleep interval based on all device poll intervals.
Returns a dynamic sleep time that ensures responsive polling: Returns a dynamic sleep time that ensures responsive polling:
- Minimum 10 seconds (prevents tight loops) - Minimum 30 seconds (prevents tight loops)
- Maximum 30 seconds (ensures responsiveness) - Maximum 300 seconds / 5 minutes (ensures reasonable responsiveness for long intervals)
- Generally half the minimum device interval - Generally half the minimum device interval
Returns: Returns:
@@ -245,14 +245,15 @@ class BackgroundPoller:
).all() ).all()
if not configs: if not configs:
return 30 # Default sleep when no devices configured return 60 # Default sleep when no devices configured
# Get all intervals # Get all intervals
intervals = [cfg.poll_interval_seconds or 60 for cfg in configs] intervals = [cfg.poll_interval_seconds or 60 for cfg in configs]
min_interval = min(intervals) min_interval = min(intervals)
# Use half the minimum interval, but cap between 10-30 seconds # Use half the minimum interval, but cap between 30-300 seconds
sleep_time = max(10, min(30, min_interval // 2)) # 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 return sleep_time

View File

@@ -81,14 +81,14 @@ class ConfigPayload(BaseModel):
@field_validator("poll_interval_seconds") @field_validator("poll_interval_seconds")
@classmethod @classmethod
def validate_poll_interval(cls, v): def validate_poll_interval(cls, v):
if v is not None and not (10 <= v <= 3600): if v is not None and not (30 <= v <= 21600):
raise ValueError("Poll interval must be between 10 and 3600 seconds") raise ValueError("Poll interval must be between 30 and 21600 seconds (30s to 6 hours)")
return v return v
class PollingConfigPayload(BaseModel): class PollingConfigPayload(BaseModel):
"""Payload for updating device polling configuration.""" """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_interval_seconds: int | None = Field(None, ge=30, le=21600, description="Polling interval in seconds (30s to 6 hours)")
poll_enabled: bool | None = Field(None, description="Enable or disable background polling for this device") poll_enabled: bool | None = Field(None, description="Enable or disable background polling for this device")
@@ -233,8 +233,8 @@ class RosterCreatePayload(BaseModel):
@field_validator("poll_interval_seconds") @field_validator("poll_interval_seconds")
@classmethod @classmethod
def validate_poll_interval(cls, v): def validate_poll_interval(cls, v):
if v is not None and not (10 <= v <= 3600): if v is not None and not (30 <= v <= 21600):
raise ValueError("Poll interval must be between 10 and 3600 seconds") raise ValueError("Poll interval must be between 30 and 21600 seconds (30s to 6 hours)")
return v return v
@@ -1880,7 +1880,7 @@ def update_polling_config(
""" """
Update background polling configuration for a device. Update background polling configuration for a device.
Allows configuring the polling interval (10-3600 seconds) and Allows configuring the polling interval (30-21600 seconds, i.e. 30s to 6 hours) and
enabling/disabling automatic background polling per device. enabling/disabling automatic background polling per device.
Changes take effect on the next polling cycle. Changes take effect on the next polling cycle.
@@ -1891,10 +1891,15 @@ def update_polling_config(
# Update interval if provided # Update interval if provided
if payload.poll_interval_seconds is not None: if payload.poll_interval_seconds is not None:
if payload.poll_interval_seconds < 10: if payload.poll_interval_seconds < 30:
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail="Polling interval must be at least 10 seconds" detail="Polling interval must be at least 30 seconds"
)
if payload.poll_interval_seconds > 21600:
raise HTTPException(
status_code=400,
detail="Polling interval must be at most 21600 seconds (6 hours)"
) )
cfg.poll_interval_seconds = payload.poll_interval_seconds cfg.poll_interval_seconds = payload.poll_interval_seconds

View File

@@ -14,7 +14,7 @@ import zipfile
import tempfile import tempfile
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from typing import Optional, List from typing import Optional, List, Dict
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ftplib import FTP from ftplib import FTP
from pathlib import Path from pathlib import Path
@@ -105,6 +105,19 @@ def persist_snapshot(s: NL43Snapshot, db: Session):
_last_command_time = {} _last_command_time = {}
_rate_limit_lock = asyncio.Lock() _rate_limit_lock = asyncio.Lock()
# Per-device connection locks: NL43 devices only support one TCP connection at a time
# This prevents concurrent connections from fighting for the device
_device_locks: Dict[str, asyncio.Lock] = {}
_device_locks_lock = asyncio.Lock()
async def _get_device_lock(device_key: str) -> asyncio.Lock:
"""Get or create a lock for a specific device."""
async with _device_locks_lock:
if device_key not in _device_locks:
_device_locks[device_key] = asyncio.Lock()
return _device_locks[device_key]
class NL43Client: class NL43Client:
def __init__(self, host: str, port: int, timeout: float = 5.0, ftp_username: str = None, ftp_password: str = None, ftp_port: int = 21): def __init__(self, host: str, port: int, timeout: float = 5.0, ftp_username: str = None, ftp_password: str = None, ftp_port: int = 21):
@@ -133,7 +146,17 @@ class NL43Client:
NL43 protocol returns two lines for query commands: NL43 protocol returns two lines for query commands:
Line 1: Result code (R+0000 for success, error codes otherwise) Line 1: Result code (R+0000 for success, error codes otherwise)
Line 2: Actual data (for query commands ending with '?') Line 2: Actual data (for query commands ending with '?')
This method acquires a per-device lock to ensure only one TCP connection
is active at a time (NL43 devices only support single connections).
""" """
# Acquire per-device lock to prevent concurrent connections
device_lock = await _get_device_lock(self.device_key)
async with device_lock:
return await self._send_command_unlocked(cmd)
async def _send_command_unlocked(self, cmd: str) -> str:
"""Internal: send command without acquiring device lock (lock must be held by caller)."""
await self._enforce_rate_limit() await self._enforce_rate_limit()
logger.info(f"Sending command to {self.device_key}: {cmd.strip()}") logger.info(f"Sending command to {self.device_key}: {cmd.strip()}")
@@ -429,7 +452,14 @@ class NL43Client:
The stream continues until an exception occurs or the connection is closed. The stream continues until an exception occurs or the connection is closed.
Send SUB character (0x1A) to stop the stream. Send SUB character (0x1A) to stop the stream.
NOTE: This method holds the device lock for the entire duration of streaming,
blocking other commands to this device. This is intentional since NL43 devices
only support one TCP connection at a time.
""" """
# Acquire per-device lock - held for entire streaming session
device_lock = await _get_device_lock(self.device_key)
async with device_lock:
await self._enforce_rate_limit() await self._enforce_rate_limit()
logger.info(f"Starting DRD stream for {self.device_key}") logger.info(f"Starting DRD stream for {self.device_key}")