Background poller intervals increased.
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
Reference in New Issue
Block a user