Massive update - API and other modules added
This commit is contained in:
15
usda_vision_system/core/__init__.py
Normal file
15
usda_vision_system/core/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""
|
||||
USDA Vision Camera System - Core Module
|
||||
|
||||
This module contains the core functionality for the USDA vision camera system,
|
||||
including configuration management, state management, and event handling.
|
||||
"""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
__author__ = "USDA Vision Team"
|
||||
|
||||
from .config import Config
|
||||
from .state_manager import StateManager
|
||||
from .events import EventSystem
|
||||
|
||||
__all__ = ["Config", "StateManager", "EventSystem"]
|
||||
BIN
usda_vision_system/core/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
usda_vision_system/core/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
usda_vision_system/core/__pycache__/config.cpython-311.pyc
Normal file
BIN
usda_vision_system/core/__pycache__/config.cpython-311.pyc
Normal file
Binary file not shown.
BIN
usda_vision_system/core/__pycache__/events.cpython-311.pyc
Normal file
BIN
usda_vision_system/core/__pycache__/events.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
207
usda_vision_system/core/config.py
Normal file
207
usda_vision_system/core/config.py
Normal file
@@ -0,0 +1,207 @@
|
||||
"""
|
||||
Configuration management for the USDA Vision Camera System.
|
||||
|
||||
This module handles all configuration settings including MQTT broker settings,
|
||||
camera configurations, storage paths, and system parameters.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, List, Optional, Any
|
||||
from dataclasses import dataclass, asdict
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass
|
||||
class MQTTConfig:
|
||||
"""MQTT broker configuration"""
|
||||
broker_host: str = "192.168.1.110"
|
||||
broker_port: int = 1883
|
||||
username: Optional[str] = None
|
||||
password: Optional[str] = None
|
||||
topics: Dict[str, str] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.topics is None:
|
||||
self.topics = {
|
||||
"vibratory_conveyor": "vision/vibratory_conveyor/state",
|
||||
"blower_separator": "vision/blower_separator/state"
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class CameraConfig:
|
||||
"""Individual camera configuration"""
|
||||
name: str
|
||||
machine_topic: str # Which MQTT topic triggers this camera
|
||||
storage_path: str
|
||||
exposure_ms: float = 1.0
|
||||
gain: float = 3.5
|
||||
target_fps: float = 3.0
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class StorageConfig:
|
||||
"""Storage configuration"""
|
||||
base_path: str = "/storage"
|
||||
max_file_size_mb: int = 1000 # Max size per video file
|
||||
max_recording_duration_minutes: int = 60 # Max recording duration
|
||||
cleanup_older_than_days: int = 30 # Auto cleanup old files
|
||||
|
||||
|
||||
@dataclass
|
||||
class SystemConfig:
|
||||
"""System-wide configuration"""
|
||||
camera_check_interval_seconds: int = 2
|
||||
log_level: str = "INFO"
|
||||
log_file: str = "usda_vision_system.log"
|
||||
api_host: str = "0.0.0.0"
|
||||
api_port: int = 8000
|
||||
enable_api: bool = True
|
||||
timezone: str = "America/New_York" # Atlanta, Georgia timezone
|
||||
|
||||
|
||||
class Config:
|
||||
"""Main configuration manager"""
|
||||
|
||||
def __init__(self, config_file: Optional[str] = None):
|
||||
self.config_file = config_file or "config.json"
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
# Default configurations
|
||||
self.mqtt = MQTTConfig()
|
||||
self.storage = StorageConfig()
|
||||
self.system = SystemConfig()
|
||||
|
||||
# Camera configurations - will be populated from config file or defaults
|
||||
self.cameras: List[CameraConfig] = []
|
||||
|
||||
# Load configuration
|
||||
self.load_config()
|
||||
|
||||
# Ensure storage directories exist
|
||||
self._ensure_storage_directories()
|
||||
|
||||
def load_config(self) -> None:
|
||||
"""Load configuration from file"""
|
||||
config_path = Path(self.config_file)
|
||||
|
||||
if config_path.exists():
|
||||
try:
|
||||
with open(config_path, 'r') as f:
|
||||
config_data = json.load(f)
|
||||
|
||||
# Load MQTT config
|
||||
if 'mqtt' in config_data:
|
||||
mqtt_data = config_data['mqtt']
|
||||
self.mqtt = MQTTConfig(**mqtt_data)
|
||||
|
||||
# Load storage config
|
||||
if 'storage' in config_data:
|
||||
storage_data = config_data['storage']
|
||||
self.storage = StorageConfig(**storage_data)
|
||||
|
||||
# Load system config
|
||||
if 'system' in config_data:
|
||||
system_data = config_data['system']
|
||||
self.system = SystemConfig(**system_data)
|
||||
|
||||
# Load camera configs
|
||||
if 'cameras' in config_data:
|
||||
self.cameras = [
|
||||
CameraConfig(**cam_data)
|
||||
for cam_data in config_data['cameras']
|
||||
]
|
||||
else:
|
||||
self._create_default_camera_configs()
|
||||
|
||||
self.logger.info(f"Configuration loaded from {config_path}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error loading config from {config_path}: {e}")
|
||||
self._create_default_camera_configs()
|
||||
else:
|
||||
self.logger.info(f"Config file {config_path} not found, using defaults")
|
||||
self._create_default_camera_configs()
|
||||
self.save_config() # Save default config
|
||||
|
||||
def _create_default_camera_configs(self) -> None:
|
||||
"""Create default camera configurations"""
|
||||
self.cameras = [
|
||||
CameraConfig(
|
||||
name="camera1",
|
||||
machine_topic="vibratory_conveyor",
|
||||
storage_path=os.path.join(self.storage.base_path, "camera1")
|
||||
),
|
||||
CameraConfig(
|
||||
name="camera2",
|
||||
machine_topic="blower_separator",
|
||||
storage_path=os.path.join(self.storage.base_path, "camera2")
|
||||
)
|
||||
]
|
||||
|
||||
def save_config(self) -> None:
|
||||
"""Save current configuration to file"""
|
||||
config_data = {
|
||||
'mqtt': asdict(self.mqtt),
|
||||
'storage': asdict(self.storage),
|
||||
'system': asdict(self.system),
|
||||
'cameras': [asdict(cam) for cam in self.cameras]
|
||||
}
|
||||
|
||||
try:
|
||||
with open(self.config_file, 'w') as f:
|
||||
json.dump(config_data, f, indent=2)
|
||||
self.logger.info(f"Configuration saved to {self.config_file}")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error saving config to {self.config_file}: {e}")
|
||||
|
||||
def _ensure_storage_directories(self) -> None:
|
||||
"""Ensure all storage directories exist"""
|
||||
try:
|
||||
# Create base storage directory
|
||||
Path(self.storage.base_path).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create camera-specific directories
|
||||
for camera in self.cameras:
|
||||
Path(camera.storage_path).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self.logger.info("Storage directories verified/created")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error creating storage directories: {e}")
|
||||
|
||||
def get_camera_by_topic(self, topic: str) -> Optional[CameraConfig]:
|
||||
"""Get camera configuration by MQTT topic"""
|
||||
for camera in self.cameras:
|
||||
if camera.machine_topic == topic:
|
||||
return camera
|
||||
return None
|
||||
|
||||
def get_camera_by_name(self, name: str) -> Optional[CameraConfig]:
|
||||
"""Get camera configuration by name"""
|
||||
for camera in self.cameras:
|
||||
if camera.name == name:
|
||||
return camera
|
||||
return None
|
||||
|
||||
def update_camera_config(self, name: str, **kwargs) -> bool:
|
||||
"""Update camera configuration"""
|
||||
camera = self.get_camera_by_name(name)
|
||||
if camera:
|
||||
for key, value in kwargs.items():
|
||||
if hasattr(camera, key):
|
||||
setattr(camera, key, value)
|
||||
self.save_config()
|
||||
return True
|
||||
return False
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert configuration to dictionary"""
|
||||
return {
|
||||
'mqtt': asdict(self.mqtt),
|
||||
'storage': asdict(self.storage),
|
||||
'system': asdict(self.system),
|
||||
'cameras': [asdict(cam) for cam in self.cameras]
|
||||
}
|
||||
195
usda_vision_system/core/events.py
Normal file
195
usda_vision_system/core/events.py
Normal file
@@ -0,0 +1,195 @@
|
||||
"""
|
||||
Event system for the USDA Vision Camera System.
|
||||
|
||||
This module provides a thread-safe event system for communication between
|
||||
different components of the system (MQTT, cameras, recording, etc.).
|
||||
"""
|
||||
|
||||
import threading
|
||||
import logging
|
||||
from typing import Dict, List, Callable, Any, Optional
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class EventType(Enum):
|
||||
"""Event types for the system"""
|
||||
MACHINE_STATE_CHANGED = "machine_state_changed"
|
||||
CAMERA_STATUS_CHANGED = "camera_status_changed"
|
||||
RECORDING_STARTED = "recording_started"
|
||||
RECORDING_STOPPED = "recording_stopped"
|
||||
RECORDING_ERROR = "recording_error"
|
||||
MQTT_CONNECTED = "mqtt_connected"
|
||||
MQTT_DISCONNECTED = "mqtt_disconnected"
|
||||
SYSTEM_SHUTDOWN = "system_shutdown"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Event:
|
||||
"""Event data structure"""
|
||||
event_type: EventType
|
||||
source: str
|
||||
data: Dict[str, Any]
|
||||
timestamp: datetime
|
||||
|
||||
def __post_init__(self):
|
||||
if not isinstance(self.timestamp, datetime):
|
||||
self.timestamp = datetime.now()
|
||||
|
||||
|
||||
class EventSystem:
|
||||
"""Thread-safe event system for inter-component communication"""
|
||||
|
||||
def __init__(self):
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self._subscribers: Dict[EventType, List[Callable]] = {}
|
||||
self._lock = threading.RLock()
|
||||
self._event_history: List[Event] = []
|
||||
self._max_history = 1000 # Keep last 1000 events
|
||||
|
||||
def subscribe(self, event_type: EventType, callback: Callable[[Event], None]) -> None:
|
||||
"""Subscribe to an event type"""
|
||||
with self._lock:
|
||||
if event_type not in self._subscribers:
|
||||
self._subscribers[event_type] = []
|
||||
|
||||
if callback not in self._subscribers[event_type]:
|
||||
self._subscribers[event_type].append(callback)
|
||||
self.logger.debug(f"Subscribed to {event_type.value}")
|
||||
|
||||
def unsubscribe(self, event_type: EventType, callback: Callable[[Event], None]) -> None:
|
||||
"""Unsubscribe from an event type"""
|
||||
with self._lock:
|
||||
if event_type in self._subscribers:
|
||||
try:
|
||||
self._subscribers[event_type].remove(callback)
|
||||
self.logger.debug(f"Unsubscribed from {event_type.value}")
|
||||
except ValueError:
|
||||
pass # Callback wasn't subscribed
|
||||
|
||||
def publish(self, event_type: EventType, source: str, data: Optional[Dict[str, Any]] = None) -> None:
|
||||
"""Publish an event"""
|
||||
if data is None:
|
||||
data = {}
|
||||
|
||||
event = Event(
|
||||
event_type=event_type,
|
||||
source=source,
|
||||
data=data,
|
||||
timestamp=datetime.now()
|
||||
)
|
||||
|
||||
# Add to history
|
||||
with self._lock:
|
||||
self._event_history.append(event)
|
||||
if len(self._event_history) > self._max_history:
|
||||
self._event_history.pop(0)
|
||||
|
||||
# Notify subscribers
|
||||
self._notify_subscribers(event)
|
||||
|
||||
def _notify_subscribers(self, event: Event) -> None:
|
||||
"""Notify all subscribers of an event"""
|
||||
with self._lock:
|
||||
subscribers = self._subscribers.get(event.event_type, []).copy()
|
||||
|
||||
for callback in subscribers:
|
||||
try:
|
||||
callback(event)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error in event callback for {event.event_type.value}: {e}")
|
||||
|
||||
def get_recent_events(self, event_type: Optional[EventType] = None, limit: int = 100) -> List[Event]:
|
||||
"""Get recent events, optionally filtered by type"""
|
||||
with self._lock:
|
||||
events = self._event_history.copy()
|
||||
|
||||
if event_type:
|
||||
events = [e for e in events if e.event_type == event_type]
|
||||
|
||||
return events[-limit:] if limit else events
|
||||
|
||||
def clear_history(self) -> None:
|
||||
"""Clear event history"""
|
||||
with self._lock:
|
||||
self._event_history.clear()
|
||||
self.logger.info("Event history cleared")
|
||||
|
||||
def get_subscriber_count(self, event_type: EventType) -> int:
|
||||
"""Get number of subscribers for an event type"""
|
||||
with self._lock:
|
||||
return len(self._subscribers.get(event_type, []))
|
||||
|
||||
def get_all_event_types(self) -> List[EventType]:
|
||||
"""Get all event types that have subscribers"""
|
||||
with self._lock:
|
||||
return list(self._subscribers.keys())
|
||||
|
||||
|
||||
# Global event system instance
|
||||
event_system = EventSystem()
|
||||
|
||||
|
||||
# Convenience functions for common events
|
||||
def publish_machine_state_changed(machine_name: str, state: str, source: str = "mqtt") -> None:
|
||||
"""Publish machine state change event"""
|
||||
event_system.publish(
|
||||
EventType.MACHINE_STATE_CHANGED,
|
||||
source,
|
||||
{
|
||||
"machine_name": machine_name,
|
||||
"state": state,
|
||||
"previous_state": None # Could be enhanced to track previous state
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def publish_camera_status_changed(camera_name: str, status: str, details: str = "", source: str = "camera_monitor") -> None:
|
||||
"""Publish camera status change event"""
|
||||
event_system.publish(
|
||||
EventType.CAMERA_STATUS_CHANGED,
|
||||
source,
|
||||
{
|
||||
"camera_name": camera_name,
|
||||
"status": status,
|
||||
"details": details
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def publish_recording_started(camera_name: str, filename: str, source: str = "recorder") -> None:
|
||||
"""Publish recording started event"""
|
||||
event_system.publish(
|
||||
EventType.RECORDING_STARTED,
|
||||
source,
|
||||
{
|
||||
"camera_name": camera_name,
|
||||
"filename": filename
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def publish_recording_stopped(camera_name: str, filename: str, duration_seconds: float, source: str = "recorder") -> None:
|
||||
"""Publish recording stopped event"""
|
||||
event_system.publish(
|
||||
EventType.RECORDING_STOPPED,
|
||||
source,
|
||||
{
|
||||
"camera_name": camera_name,
|
||||
"filename": filename,
|
||||
"duration_seconds": duration_seconds
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def publish_recording_error(camera_name: str, error_message: str, source: str = "recorder") -> None:
|
||||
"""Publish recording error event"""
|
||||
event_system.publish(
|
||||
EventType.RECORDING_ERROR,
|
||||
source,
|
||||
{
|
||||
"camera_name": camera_name,
|
||||
"error_message": error_message
|
||||
}
|
||||
)
|
||||
260
usda_vision_system/core/logging_config.py
Normal file
260
usda_vision_system/core/logging_config.py
Normal file
@@ -0,0 +1,260 @@
|
||||
"""
|
||||
Logging configuration for the USDA Vision Camera System.
|
||||
|
||||
This module provides comprehensive logging setup with rotation, formatting,
|
||||
and different log levels for different components.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import logging.handlers
|
||||
import os
|
||||
import sys
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class ColoredFormatter(logging.Formatter):
|
||||
"""Colored formatter for console output"""
|
||||
|
||||
# ANSI color codes
|
||||
COLORS = {
|
||||
'DEBUG': '\033[36m', # Cyan
|
||||
'INFO': '\033[32m', # Green
|
||||
'WARNING': '\033[33m', # Yellow
|
||||
'ERROR': '\033[31m', # Red
|
||||
'CRITICAL': '\033[35m', # Magenta
|
||||
'RESET': '\033[0m' # Reset
|
||||
}
|
||||
|
||||
def format(self, record):
|
||||
# Add color to levelname
|
||||
if record.levelname in self.COLORS:
|
||||
record.levelname = f"{self.COLORS[record.levelname]}{record.levelname}{self.COLORS['RESET']}"
|
||||
|
||||
return super().format(record)
|
||||
|
||||
|
||||
class USDAVisionLogger:
|
||||
"""Custom logger setup for the USDA Vision Camera System"""
|
||||
|
||||
def __init__(self, log_level: str = "INFO", log_file: Optional[str] = None,
|
||||
enable_console: bool = True, enable_rotation: bool = True):
|
||||
self.log_level = log_level.upper()
|
||||
self.log_file = log_file
|
||||
self.enable_console = enable_console
|
||||
self.enable_rotation = enable_rotation
|
||||
|
||||
# Setup logging
|
||||
self._setup_logging()
|
||||
|
||||
def _setup_logging(self) -> None:
|
||||
"""Setup comprehensive logging configuration"""
|
||||
|
||||
# Get root logger
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.setLevel(getattr(logging, self.log_level))
|
||||
|
||||
# Clear existing handlers
|
||||
root_logger.handlers.clear()
|
||||
|
||||
# Create formatters
|
||||
detailed_formatter = logging.Formatter(
|
||||
'%(asctime)s - %(name)s - %(levelname)s - %(funcName)s:%(lineno)d - %(message)s'
|
||||
)
|
||||
|
||||
simple_formatter = logging.Formatter(
|
||||
'%(asctime)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
|
||||
colored_formatter = ColoredFormatter(
|
||||
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
|
||||
# Console handler
|
||||
if self.enable_console:
|
||||
console_handler = logging.StreamHandler(sys.stdout)
|
||||
console_handler.setLevel(getattr(logging, self.log_level))
|
||||
console_handler.setFormatter(colored_formatter)
|
||||
root_logger.addHandler(console_handler)
|
||||
|
||||
# File handler
|
||||
if self.log_file:
|
||||
try:
|
||||
# Create log directory if it doesn't exist
|
||||
log_dir = os.path.dirname(self.log_file)
|
||||
if log_dir and not os.path.exists(log_dir):
|
||||
os.makedirs(log_dir)
|
||||
|
||||
if self.enable_rotation:
|
||||
# Rotating file handler (10MB max, keep 5 backups)
|
||||
file_handler = logging.handlers.RotatingFileHandler(
|
||||
self.log_file,
|
||||
maxBytes=10*1024*1024, # 10MB
|
||||
backupCount=5
|
||||
)
|
||||
else:
|
||||
file_handler = logging.FileHandler(self.log_file)
|
||||
|
||||
file_handler.setLevel(logging.DEBUG) # File gets all messages
|
||||
file_handler.setFormatter(detailed_formatter)
|
||||
root_logger.addHandler(file_handler)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not setup file logging: {e}")
|
||||
|
||||
# Setup specific logger levels for different components
|
||||
self._setup_component_loggers()
|
||||
|
||||
# Log the logging setup
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(f"Logging initialized - Level: {self.log_level}, File: {self.log_file}")
|
||||
|
||||
def _setup_component_loggers(self) -> None:
|
||||
"""Setup specific log levels for different components"""
|
||||
|
||||
# MQTT client - can be verbose
|
||||
mqtt_logger = logging.getLogger('usda_vision_system.mqtt')
|
||||
if self.log_level == 'DEBUG':
|
||||
mqtt_logger.setLevel(logging.DEBUG)
|
||||
else:
|
||||
mqtt_logger.setLevel(logging.INFO)
|
||||
|
||||
# Camera components - important for debugging
|
||||
camera_logger = logging.getLogger('usda_vision_system.camera')
|
||||
camera_logger.setLevel(logging.INFO)
|
||||
|
||||
# API server - can be noisy
|
||||
api_logger = logging.getLogger('usda_vision_system.api')
|
||||
if self.log_level == 'DEBUG':
|
||||
api_logger.setLevel(logging.DEBUG)
|
||||
else:
|
||||
api_logger.setLevel(logging.INFO)
|
||||
|
||||
# Uvicorn - reduce noise unless debugging
|
||||
uvicorn_logger = logging.getLogger('uvicorn')
|
||||
if self.log_level == 'DEBUG':
|
||||
uvicorn_logger.setLevel(logging.INFO)
|
||||
else:
|
||||
uvicorn_logger.setLevel(logging.WARNING)
|
||||
|
||||
# FastAPI - reduce noise
|
||||
fastapi_logger = logging.getLogger('fastapi')
|
||||
fastapi_logger.setLevel(logging.WARNING)
|
||||
|
||||
@staticmethod
|
||||
def setup_exception_logging():
|
||||
"""Setup logging for uncaught exceptions"""
|
||||
|
||||
def handle_exception(exc_type, exc_value, exc_traceback):
|
||||
if issubclass(exc_type, KeyboardInterrupt):
|
||||
# Don't log keyboard interrupts
|
||||
sys.__excepthook__(exc_type, exc_value, exc_traceback)
|
||||
return
|
||||
|
||||
logger = logging.getLogger("uncaught_exception")
|
||||
logger.critical(
|
||||
"Uncaught exception",
|
||||
exc_info=(exc_type, exc_value, exc_traceback)
|
||||
)
|
||||
|
||||
sys.excepthook = handle_exception
|
||||
|
||||
|
||||
class PerformanceLogger:
|
||||
"""Logger for performance monitoring"""
|
||||
|
||||
def __init__(self, name: str):
|
||||
self.logger = logging.getLogger(f"performance.{name}")
|
||||
self.start_time: Optional[float] = None
|
||||
|
||||
def start_timer(self, operation: str) -> None:
|
||||
"""Start timing an operation"""
|
||||
import time
|
||||
self.start_time = time.time()
|
||||
self.logger.debug(f"Started: {operation}")
|
||||
|
||||
def end_timer(self, operation: str) -> float:
|
||||
"""End timing an operation and log duration"""
|
||||
import time
|
||||
if self.start_time is None:
|
||||
self.logger.warning(f"Timer not started for: {operation}")
|
||||
return 0.0
|
||||
|
||||
duration = time.time() - self.start_time
|
||||
self.logger.info(f"Completed: {operation} in {duration:.3f}s")
|
||||
self.start_time = None
|
||||
return duration
|
||||
|
||||
def log_metric(self, metric_name: str, value: float, unit: str = "") -> None:
|
||||
"""Log a performance metric"""
|
||||
self.logger.info(f"Metric: {metric_name} = {value} {unit}")
|
||||
|
||||
|
||||
class ErrorTracker:
|
||||
"""Track and log errors with context"""
|
||||
|
||||
def __init__(self, component_name: str):
|
||||
self.component_name = component_name
|
||||
self.logger = logging.getLogger(f"errors.{component_name}")
|
||||
self.error_count = 0
|
||||
self.last_error_time: Optional[datetime] = None
|
||||
|
||||
def log_error(self, error: Exception, context: str = "",
|
||||
additional_data: Optional[dict] = None) -> None:
|
||||
"""Log an error with context and tracking"""
|
||||
self.error_count += 1
|
||||
self.last_error_time = datetime.now()
|
||||
|
||||
error_msg = f"Error in {self.component_name}"
|
||||
if context:
|
||||
error_msg += f" ({context})"
|
||||
error_msg += f": {str(error)}"
|
||||
|
||||
if additional_data:
|
||||
error_msg += f" | Data: {additional_data}"
|
||||
|
||||
self.logger.error(error_msg, exc_info=True)
|
||||
|
||||
def log_warning(self, message: str, context: str = "") -> None:
|
||||
"""Log a warning with context"""
|
||||
warning_msg = f"Warning in {self.component_name}"
|
||||
if context:
|
||||
warning_msg += f" ({context})"
|
||||
warning_msg += f": {message}"
|
||||
|
||||
self.logger.warning(warning_msg)
|
||||
|
||||
def get_error_stats(self) -> dict:
|
||||
"""Get error statistics"""
|
||||
return {
|
||||
"component": self.component_name,
|
||||
"error_count": self.error_count,
|
||||
"last_error_time": self.last_error_time.isoformat() if self.last_error_time else None
|
||||
}
|
||||
|
||||
|
||||
def setup_logging(log_level: str = "INFO", log_file: Optional[str] = None) -> USDAVisionLogger:
|
||||
"""Setup logging for the entire application"""
|
||||
|
||||
# Setup main logging
|
||||
logger_setup = USDAVisionLogger(
|
||||
log_level=log_level,
|
||||
log_file=log_file,
|
||||
enable_console=True,
|
||||
enable_rotation=True
|
||||
)
|
||||
|
||||
# Setup exception logging
|
||||
USDAVisionLogger.setup_exception_logging()
|
||||
|
||||
return logger_setup
|
||||
|
||||
|
||||
def get_performance_logger(component_name: str) -> PerformanceLogger:
|
||||
"""Get a performance logger for a component"""
|
||||
return PerformanceLogger(component_name)
|
||||
|
||||
|
||||
def get_error_tracker(component_name: str) -> ErrorTracker:
|
||||
"""Get an error tracker for a component"""
|
||||
return ErrorTracker(component_name)
|
||||
328
usda_vision_system/core/state_manager.py
Normal file
328
usda_vision_system/core/state_manager.py
Normal file
@@ -0,0 +1,328 @@
|
||||
"""
|
||||
State management for the USDA Vision Camera System.
|
||||
|
||||
This module manages the current state of machines, cameras, and recordings
|
||||
in a thread-safe manner.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import logging
|
||||
from typing import Dict, Optional, List, Any
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class MachineState(Enum):
|
||||
"""Machine states"""
|
||||
UNKNOWN = "unknown"
|
||||
ON = "on"
|
||||
OFF = "off"
|
||||
ERROR = "error"
|
||||
|
||||
|
||||
class CameraStatus(Enum):
|
||||
"""Camera status"""
|
||||
UNKNOWN = "unknown"
|
||||
AVAILABLE = "available"
|
||||
BUSY = "busy"
|
||||
ERROR = "error"
|
||||
DISCONNECTED = "disconnected"
|
||||
|
||||
|
||||
class RecordingState(Enum):
|
||||
"""Recording states"""
|
||||
IDLE = "idle"
|
||||
RECORDING = "recording"
|
||||
STOPPING = "stopping"
|
||||
ERROR = "error"
|
||||
|
||||
|
||||
@dataclass
|
||||
class MachineInfo:
|
||||
"""Machine state information"""
|
||||
name: str
|
||||
state: MachineState = MachineState.UNKNOWN
|
||||
last_updated: datetime = field(default_factory=datetime.now)
|
||||
last_message: Optional[str] = None
|
||||
mqtt_topic: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class CameraInfo:
|
||||
"""Camera state information"""
|
||||
name: str
|
||||
status: CameraStatus = CameraStatus.UNKNOWN
|
||||
last_checked: datetime = field(default_factory=datetime.now)
|
||||
last_error: Optional[str] = None
|
||||
device_info: Optional[Dict[str, Any]] = None
|
||||
is_recording: bool = False
|
||||
current_recording_file: Optional[str] = None
|
||||
recording_start_time: Optional[datetime] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RecordingInfo:
|
||||
"""Recording session information"""
|
||||
camera_name: str
|
||||
filename: str
|
||||
start_time: datetime
|
||||
state: RecordingState = RecordingState.RECORDING
|
||||
end_time: Optional[datetime] = None
|
||||
file_size_bytes: Optional[int] = None
|
||||
frame_count: Optional[int] = None
|
||||
error_message: Optional[str] = None
|
||||
|
||||
|
||||
class StateManager:
|
||||
"""Thread-safe state manager for the entire system"""
|
||||
|
||||
def __init__(self):
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self._lock = threading.RLock()
|
||||
|
||||
# State dictionaries
|
||||
self._machines: Dict[str, MachineInfo] = {}
|
||||
self._cameras: Dict[str, CameraInfo] = {}
|
||||
self._recordings: Dict[str, RecordingInfo] = {} # Key: recording_id (filename)
|
||||
|
||||
# System state
|
||||
self._mqtt_connected = False
|
||||
self._system_started = False
|
||||
self._last_mqtt_message_time: Optional[datetime] = None
|
||||
|
||||
# Machine state management
|
||||
def update_machine_state(self, name: str, state: str, message: Optional[str] = None, topic: Optional[str] = None) -> bool:
|
||||
"""Update machine state"""
|
||||
try:
|
||||
machine_state = MachineState(state.lower())
|
||||
except ValueError:
|
||||
self.logger.warning(f"Invalid machine state: {state}")
|
||||
machine_state = MachineState.UNKNOWN
|
||||
|
||||
with self._lock:
|
||||
if name not in self._machines:
|
||||
self._machines[name] = MachineInfo(name=name, mqtt_topic=topic)
|
||||
|
||||
machine = self._machines[name]
|
||||
old_state = machine.state
|
||||
machine.state = machine_state
|
||||
machine.last_updated = datetime.now()
|
||||
machine.last_message = message
|
||||
if topic:
|
||||
machine.mqtt_topic = topic
|
||||
|
||||
self.logger.info(f"Machine {name} state: {old_state.value} -> {machine_state.value}")
|
||||
return old_state != machine_state
|
||||
|
||||
def get_machine_state(self, name: str) -> Optional[MachineInfo]:
|
||||
"""Get machine state"""
|
||||
with self._lock:
|
||||
return self._machines.get(name)
|
||||
|
||||
def get_all_machines(self) -> Dict[str, MachineInfo]:
|
||||
"""Get all machine states"""
|
||||
with self._lock:
|
||||
return self._machines.copy()
|
||||
|
||||
# Camera state management
|
||||
def update_camera_status(self, name: str, status: str, error: Optional[str] = None, device_info: Optional[Dict] = None) -> bool:
|
||||
"""Update camera status"""
|
||||
try:
|
||||
camera_status = CameraStatus(status.lower())
|
||||
except ValueError:
|
||||
self.logger.warning(f"Invalid camera status: {status}")
|
||||
camera_status = CameraStatus.UNKNOWN
|
||||
|
||||
with self._lock:
|
||||
if name not in self._cameras:
|
||||
self._cameras[name] = CameraInfo(name=name)
|
||||
|
||||
camera = self._cameras[name]
|
||||
old_status = camera.status
|
||||
camera.status = camera_status
|
||||
camera.last_checked = datetime.now()
|
||||
camera.last_error = error
|
||||
if device_info:
|
||||
camera.device_info = device_info
|
||||
|
||||
if old_status != camera_status:
|
||||
self.logger.info(f"Camera {name} status: {old_status.value} -> {camera_status.value}")
|
||||
return True
|
||||
return False
|
||||
|
||||
def set_camera_recording(self, name: str, recording: bool, filename: Optional[str] = None) -> None:
|
||||
"""Set camera recording state"""
|
||||
with self._lock:
|
||||
if name not in self._cameras:
|
||||
self._cameras[name] = CameraInfo(name=name)
|
||||
|
||||
camera = self._cameras[name]
|
||||
camera.is_recording = recording
|
||||
camera.current_recording_file = filename
|
||||
|
||||
if recording and filename:
|
||||
camera.recording_start_time = datetime.now()
|
||||
self.logger.info(f"Camera {name} started recording: {filename}")
|
||||
elif not recording:
|
||||
camera.recording_start_time = None
|
||||
self.logger.info(f"Camera {name} stopped recording")
|
||||
|
||||
def get_camera_status(self, name: str) -> Optional[CameraInfo]:
|
||||
"""Get camera status"""
|
||||
with self._lock:
|
||||
return self._cameras.get(name)
|
||||
|
||||
def get_all_cameras(self) -> Dict[str, CameraInfo]:
|
||||
"""Get all camera statuses"""
|
||||
with self._lock:
|
||||
return self._cameras.copy()
|
||||
|
||||
# Recording management
|
||||
def start_recording(self, camera_name: str, filename: str) -> str:
|
||||
"""Start a new recording session"""
|
||||
recording_id = filename # Use filename as recording ID
|
||||
|
||||
with self._lock:
|
||||
recording = RecordingInfo(
|
||||
camera_name=camera_name,
|
||||
filename=filename,
|
||||
start_time=datetime.now()
|
||||
)
|
||||
self._recordings[recording_id] = recording
|
||||
|
||||
# Update camera state
|
||||
self.set_camera_recording(camera_name, True, filename)
|
||||
|
||||
self.logger.info(f"Started recording session: {recording_id}")
|
||||
return recording_id
|
||||
|
||||
def stop_recording(self, recording_id: str, file_size: Optional[int] = None, frame_count: Optional[int] = None) -> bool:
|
||||
"""Stop a recording session"""
|
||||
with self._lock:
|
||||
if recording_id not in self._recordings:
|
||||
self.logger.warning(f"Recording session not found: {recording_id}")
|
||||
return False
|
||||
|
||||
recording = self._recordings[recording_id]
|
||||
recording.state = RecordingState.IDLE
|
||||
recording.end_time = datetime.now()
|
||||
recording.file_size_bytes = file_size
|
||||
recording.frame_count = frame_count
|
||||
|
||||
# Update camera state
|
||||
self.set_camera_recording(recording.camera_name, False)
|
||||
|
||||
duration = (recording.end_time - recording.start_time).total_seconds()
|
||||
self.logger.info(f"Stopped recording session: {recording_id} (duration: {duration:.1f}s)")
|
||||
return True
|
||||
|
||||
def set_recording_error(self, recording_id: str, error_message: str) -> bool:
|
||||
"""Set recording error state"""
|
||||
with self._lock:
|
||||
if recording_id not in self._recordings:
|
||||
return False
|
||||
|
||||
recording = self._recordings[recording_id]
|
||||
recording.state = RecordingState.ERROR
|
||||
recording.error_message = error_message
|
||||
recording.end_time = datetime.now()
|
||||
|
||||
# Update camera state
|
||||
self.set_camera_recording(recording.camera_name, False)
|
||||
|
||||
self.logger.error(f"Recording error for {recording_id}: {error_message}")
|
||||
return True
|
||||
|
||||
def get_recording(self, recording_id: str) -> Optional[RecordingInfo]:
|
||||
"""Get recording information"""
|
||||
with self._lock:
|
||||
return self._recordings.get(recording_id)
|
||||
|
||||
def get_all_recordings(self) -> Dict[str, RecordingInfo]:
|
||||
"""Get all recording sessions"""
|
||||
with self._lock:
|
||||
return self._recordings.copy()
|
||||
|
||||
def get_active_recordings(self) -> Dict[str, RecordingInfo]:
|
||||
"""Get currently active recordings"""
|
||||
with self._lock:
|
||||
return {
|
||||
rid: recording for rid, recording in self._recordings.items()
|
||||
if recording.state == RecordingState.RECORDING
|
||||
}
|
||||
|
||||
# System state management
|
||||
def set_mqtt_connected(self, connected: bool) -> None:
|
||||
"""Set MQTT connection state"""
|
||||
with self._lock:
|
||||
old_state = self._mqtt_connected
|
||||
self._mqtt_connected = connected
|
||||
if connected:
|
||||
self._last_mqtt_message_time = datetime.now()
|
||||
|
||||
if old_state != connected:
|
||||
self.logger.info(f"MQTT connection: {'connected' if connected else 'disconnected'}")
|
||||
|
||||
def is_mqtt_connected(self) -> bool:
|
||||
"""Check if MQTT is connected"""
|
||||
with self._lock:
|
||||
return self._mqtt_connected
|
||||
|
||||
def update_mqtt_activity(self) -> None:
|
||||
"""Update last MQTT message time"""
|
||||
with self._lock:
|
||||
self._last_mqtt_message_time = datetime.now()
|
||||
|
||||
def set_system_started(self, started: bool) -> None:
|
||||
"""Set system started state"""
|
||||
with self._lock:
|
||||
self._system_started = started
|
||||
self.logger.info(f"System {'started' if started else 'stopped'}")
|
||||
|
||||
def is_system_started(self) -> bool:
|
||||
"""Check if system is started"""
|
||||
with self._lock:
|
||||
return self._system_started
|
||||
|
||||
# Utility methods
|
||||
def get_system_summary(self) -> Dict[str, Any]:
|
||||
"""Get a summary of the entire system state"""
|
||||
with self._lock:
|
||||
return {
|
||||
"system_started": self._system_started,
|
||||
"mqtt_connected": self._mqtt_connected,
|
||||
"last_mqtt_message": self._last_mqtt_message_time.isoformat() if self._last_mqtt_message_time else None,
|
||||
"machines": {name: {
|
||||
"state": machine.state.value,
|
||||
"last_updated": machine.last_updated.isoformat()
|
||||
} for name, machine in self._machines.items()},
|
||||
"cameras": {name: {
|
||||
"status": camera.status.value,
|
||||
"is_recording": camera.is_recording,
|
||||
"last_checked": camera.last_checked.isoformat()
|
||||
} for name, camera in self._cameras.items()},
|
||||
"active_recordings": len(self.get_active_recordings()),
|
||||
"total_recordings": len(self._recordings)
|
||||
}
|
||||
|
||||
def cleanup_old_recordings(self, max_age_hours: int = 24) -> int:
|
||||
"""Clean up old recording entries from memory"""
|
||||
cutoff_time = datetime.now() - datetime.timedelta(hours=max_age_hours)
|
||||
removed_count = 0
|
||||
|
||||
with self._lock:
|
||||
to_remove = []
|
||||
for recording_id, recording in self._recordings.items():
|
||||
if (recording.state != RecordingState.RECORDING and
|
||||
recording.end_time and recording.end_time < cutoff_time):
|
||||
to_remove.append(recording_id)
|
||||
|
||||
for recording_id in to_remove:
|
||||
del self._recordings[recording_id]
|
||||
removed_count += 1
|
||||
|
||||
if removed_count > 0:
|
||||
self.logger.info(f"Cleaned up {removed_count} old recording entries")
|
||||
|
||||
return removed_count
|
||||
225
usda_vision_system/core/timezone_utils.py
Normal file
225
usda_vision_system/core/timezone_utils.py
Normal file
@@ -0,0 +1,225 @@
|
||||
"""
|
||||
Timezone utilities for the USDA Vision Camera System.
|
||||
|
||||
This module provides timezone-aware datetime handling for Atlanta, Georgia.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import pytz
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class TimezoneManager:
|
||||
"""Manages timezone-aware datetime operations"""
|
||||
|
||||
def __init__(self, timezone_name: str = "America/New_York"):
|
||||
self.timezone_name = timezone_name
|
||||
self.timezone = pytz.timezone(timezone_name)
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
# Log timezone information
|
||||
self.logger.info(f"Timezone manager initialized for {timezone_name}")
|
||||
self._log_timezone_info()
|
||||
|
||||
def _log_timezone_info(self) -> None:
|
||||
"""Log current timezone information"""
|
||||
now = self.now()
|
||||
self.logger.info(f"Current local time: {now}")
|
||||
self.logger.info(f"Current UTC time: {self.to_utc(now)}")
|
||||
self.logger.info(f"Timezone: {now.tzname()} (UTC{now.strftime('%z')})")
|
||||
|
||||
def now(self) -> datetime.datetime:
|
||||
"""Get current time in the configured timezone"""
|
||||
return datetime.datetime.now(self.timezone)
|
||||
|
||||
def utc_now(self) -> datetime.datetime:
|
||||
"""Get current UTC time"""
|
||||
return datetime.datetime.now(pytz.UTC)
|
||||
|
||||
def to_local(self, dt: datetime.datetime) -> datetime.datetime:
|
||||
"""Convert datetime to local timezone"""
|
||||
if dt.tzinfo is None:
|
||||
# Assume UTC if no timezone info
|
||||
dt = pytz.UTC.localize(dt)
|
||||
return dt.astimezone(self.timezone)
|
||||
|
||||
def to_utc(self, dt: datetime.datetime) -> datetime.datetime:
|
||||
"""Convert datetime to UTC"""
|
||||
if dt.tzinfo is None:
|
||||
# Assume local timezone if no timezone info
|
||||
dt = self.timezone.localize(dt)
|
||||
return dt.astimezone(pytz.UTC)
|
||||
|
||||
def localize(self, dt: datetime.datetime) -> datetime.datetime:
|
||||
"""Add timezone info to naive datetime (assumes local timezone)"""
|
||||
if dt.tzinfo is not None:
|
||||
return dt
|
||||
return self.timezone.localize(dt)
|
||||
|
||||
def format_timestamp(self, dt: Optional[datetime.datetime] = None,
|
||||
include_timezone: bool = True) -> str:
|
||||
"""Format datetime as timestamp string"""
|
||||
if dt is None:
|
||||
dt = self.now()
|
||||
|
||||
if dt.tzinfo is None:
|
||||
dt = self.localize(dt)
|
||||
|
||||
if include_timezone:
|
||||
return dt.strftime("%Y-%m-%d %H:%M:%S %Z")
|
||||
else:
|
||||
return dt.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
def format_filename_timestamp(self, dt: Optional[datetime.datetime] = None) -> str:
|
||||
"""Format datetime for use in filenames (no special characters)"""
|
||||
if dt is None:
|
||||
dt = self.now()
|
||||
|
||||
if dt.tzinfo is None:
|
||||
dt = self.localize(dt)
|
||||
|
||||
return dt.strftime("%Y%m%d_%H%M%S")
|
||||
|
||||
def parse_timestamp(self, timestamp_str: str) -> datetime.datetime:
|
||||
"""Parse timestamp string to datetime"""
|
||||
try:
|
||||
# Try parsing with timezone info
|
||||
return datetime.datetime.fromisoformat(timestamp_str)
|
||||
except ValueError:
|
||||
try:
|
||||
# Try parsing without timezone (assume local)
|
||||
dt = datetime.datetime.strptime(timestamp_str, "%Y-%m-%d %H:%M:%S")
|
||||
return self.localize(dt)
|
||||
except ValueError:
|
||||
try:
|
||||
# Try parsing filename format
|
||||
dt = datetime.datetime.strptime(timestamp_str, "%Y%m%d_%H%M%S")
|
||||
return self.localize(dt)
|
||||
except ValueError:
|
||||
raise ValueError(f"Unable to parse timestamp: {timestamp_str}")
|
||||
|
||||
def is_dst(self, dt: Optional[datetime.datetime] = None) -> bool:
|
||||
"""Check if datetime is during daylight saving time"""
|
||||
if dt is None:
|
||||
dt = self.now()
|
||||
|
||||
if dt.tzinfo is None:
|
||||
dt = self.localize(dt)
|
||||
|
||||
return bool(dt.dst())
|
||||
|
||||
def get_timezone_offset(self, dt: Optional[datetime.datetime] = None) -> str:
|
||||
"""Get timezone offset string (e.g., '-0500' or '-0400')"""
|
||||
if dt is None:
|
||||
dt = self.now()
|
||||
|
||||
if dt.tzinfo is None:
|
||||
dt = self.localize(dt)
|
||||
|
||||
return dt.strftime('%z')
|
||||
|
||||
def get_timezone_name(self, dt: Optional[datetime.datetime] = None) -> str:
|
||||
"""Get timezone name (e.g., 'EST' or 'EDT')"""
|
||||
if dt is None:
|
||||
dt = self.now()
|
||||
|
||||
if dt.tzinfo is None:
|
||||
dt = self.localize(dt)
|
||||
|
||||
return dt.tzname()
|
||||
|
||||
|
||||
# Global timezone manager instance for Atlanta, Georgia
|
||||
atlanta_tz = TimezoneManager("America/New_York")
|
||||
|
||||
|
||||
# Convenience functions
|
||||
def now_atlanta() -> datetime.datetime:
|
||||
"""Get current Atlanta time"""
|
||||
return atlanta_tz.now()
|
||||
|
||||
|
||||
def format_atlanta_timestamp(dt: Optional[datetime.datetime] = None) -> str:
|
||||
"""Format timestamp in Atlanta timezone"""
|
||||
return atlanta_tz.format_timestamp(dt)
|
||||
|
||||
|
||||
def format_filename_timestamp(dt: Optional[datetime.datetime] = None) -> str:
|
||||
"""Format timestamp for filenames"""
|
||||
return atlanta_tz.format_filename_timestamp(dt)
|
||||
|
||||
|
||||
def to_atlanta_time(dt: datetime.datetime) -> datetime.datetime:
|
||||
"""Convert any datetime to Atlanta time"""
|
||||
return atlanta_tz.to_local(dt)
|
||||
|
||||
|
||||
def check_time_sync() -> dict:
|
||||
"""Check if system time appears to be synchronized"""
|
||||
import requests
|
||||
|
||||
result = {
|
||||
"system_time": now_atlanta(),
|
||||
"timezone": atlanta_tz.get_timezone_name(),
|
||||
"offset": atlanta_tz.get_timezone_offset(),
|
||||
"dst": atlanta_tz.is_dst(),
|
||||
"sync_status": "unknown",
|
||||
"time_diff_seconds": None,
|
||||
"error": None
|
||||
}
|
||||
|
||||
try:
|
||||
# Check against world time API
|
||||
response = requests.get(
|
||||
"http://worldtimeapi.org/api/timezone/America/New_York",
|
||||
timeout=5
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
api_time = datetime.datetime.fromisoformat(data['datetime'])
|
||||
|
||||
# Convert to same timezone for comparison
|
||||
system_time = atlanta_tz.now()
|
||||
time_diff = abs((system_time.replace(tzinfo=None) -
|
||||
api_time.replace(tzinfo=None)).total_seconds())
|
||||
|
||||
result["api_time"] = api_time
|
||||
result["time_diff_seconds"] = time_diff
|
||||
|
||||
if time_diff < 5:
|
||||
result["sync_status"] = "synchronized"
|
||||
elif time_diff < 30:
|
||||
result["sync_status"] = "minor_drift"
|
||||
else:
|
||||
result["sync_status"] = "out_of_sync"
|
||||
else:
|
||||
result["error"] = f"API returned status {response.status_code}"
|
||||
|
||||
except Exception as e:
|
||||
result["error"] = str(e)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def log_time_info(logger: Optional[logging.Logger] = None) -> None:
|
||||
"""Log comprehensive time information"""
|
||||
if logger is None:
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
sync_info = check_time_sync()
|
||||
|
||||
logger.info("=== TIME SYNCHRONIZATION STATUS ===")
|
||||
logger.info(f"System time: {sync_info['system_time']}")
|
||||
logger.info(f"Timezone: {sync_info['timezone']} ({sync_info['offset']})")
|
||||
logger.info(f"Daylight Saving: {'Yes' if sync_info['dst'] else 'No'}")
|
||||
logger.info(f"Sync status: {sync_info['sync_status']}")
|
||||
|
||||
if sync_info.get('time_diff_seconds') is not None:
|
||||
logger.info(f"Time difference: {sync_info['time_diff_seconds']:.2f} seconds")
|
||||
|
||||
if sync_info.get('error'):
|
||||
logger.warning(f"Time sync check error: {sync_info['error']}")
|
||||
|
||||
logger.info("=====================================")
|
||||
Reference in New Issue
Block a user