Files
meshcore-hub/src/meshcore_hub/common/config.py
T
Claude 127cd7adf6 Add i18n support for web dashboard
Implement lightweight i18n infrastructure with shared JSON translation
files used by both server-side Jinja2 templates and client-side SPA.

- Add custom i18n module (Python + JS, ~80 lines total, zero deps)
- Create en.json with ~200 translation keys covering all web strings
- Add WEB_LOCALE config setting (default: 'en', with localStorage override)
- Translate all navigation labels, page titles, and footer in spa.html
- Translate all 13 SPA page modules (home, dashboard, nodes, etc.)
- Translate shared components (pagination, relative time, charts)
- Translate all 3 admin pages (index, members, node-tags)
- Fix Adverts/Advertisements inconsistency (standardize to Advertisements)
- Add i18n unit tests with 100% coverage

https://claude.ai/code/session_01FbnUnwYAwPrsQmAh5EuSkF
2026-02-13 18:49:06 +00:00

414 lines
13 KiB
Python

"""Pydantic Settings for MeshCore Hub configuration."""
from enum import Enum
from typing import Optional
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class LogLevel(str, Enum):
"""Log level enumeration."""
DEBUG = "DEBUG"
INFO = "INFO"
WARNING = "WARNING"
ERROR = "ERROR"
CRITICAL = "CRITICAL"
class InterfaceMode(str, Enum):
"""Interface component mode."""
RECEIVER = "RECEIVER"
SENDER = "SENDER"
class CommonSettings(BaseSettings):
"""Common settings shared by all components."""
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
# Data home directory (base for all service data directories)
data_home: str = Field(
default="./data",
description="Base directory for service data (e.g., ./data or /data)",
)
# Logging
log_level: LogLevel = Field(default=LogLevel.INFO, description="Logging level")
# MQTT Broker
mqtt_host: str = Field(default="localhost", description="MQTT broker host")
mqtt_port: int = Field(default=1883, description="MQTT broker port")
mqtt_username: Optional[str] = Field(
default=None, description="MQTT username (optional)"
)
mqtt_password: Optional[str] = Field(
default=None, description="MQTT password (optional)"
)
mqtt_prefix: str = Field(default="meshcore", description="MQTT topic prefix")
mqtt_tls: bool = Field(
default=False, description="Enable TLS/SSL for MQTT connection"
)
class InterfaceSettings(CommonSettings):
"""Settings for the Interface component."""
# Mode
interface_mode: InterfaceMode = Field(
default=InterfaceMode.RECEIVER,
description="Interface mode: RECEIVER or SENDER",
)
# Serial connection
serial_port: str = Field(default="/dev/ttyUSB0", description="Serial port path")
serial_baud: int = Field(default=115200, description="Serial baud rate")
# Mock device
mock_device: bool = Field(default=False, description="Use mock device for testing")
# Device name
meshcore_device_name: Optional[str] = Field(
default=None, description="Device/node name (optional)"
)
# Contact cleanup settings
contact_cleanup_enabled: bool = Field(
default=True,
description="Enable automatic removal of stale contacts from companion node",
)
contact_cleanup_days: int = Field(
default=7,
description="Remove contacts not advertised for this many days",
ge=1,
)
class CollectorSettings(CommonSettings):
"""Settings for the Collector component."""
# Database - default uses data_home/collector/meshcore.db
database_url: Optional[str] = Field(
default=None,
description="SQLAlchemy database URL (default: sqlite:///{data_home}/collector/meshcore.db)",
)
# Seed home directory - contains initial data files (node_tags.yaml, members.yaml)
seed_home: str = Field(
default="./seed",
description="Directory containing seed data files (default: ./seed)",
)
# Webhook URLs (empty = disabled)
webhook_advertisement_url: Optional[str] = Field(
default=None, description="Webhook URL for advertisement events"
)
webhook_advertisement_secret: Optional[str] = Field(
default=None, description="Secret/API key for advertisement webhook"
)
webhook_message_url: Optional[str] = Field(
default=None, description="Webhook URL for all message events"
)
webhook_message_secret: Optional[str] = Field(
default=None, description="Secret/API key for message webhook"
)
webhook_channel_message_url: Optional[str] = Field(
default=None,
description="Webhook URL for channel messages (overrides message_url)",
)
webhook_channel_message_secret: Optional[str] = Field(
default=None, description="Secret for channel message webhook"
)
webhook_direct_message_url: Optional[str] = Field(
default=None,
description="Webhook URL for direct messages (overrides message_url)",
)
webhook_direct_message_secret: Optional[str] = Field(
default=None, description="Secret for direct message webhook"
)
# Global webhook settings
webhook_timeout: float = Field(default=10.0, description="Webhook request timeout")
webhook_max_retries: int = Field(default=3, description="Max retry attempts")
webhook_retry_backoff: float = Field(
default=2.0, description="Retry backoff multiplier"
)
# Data retention / cleanup settings
data_retention_enabled: bool = Field(
default=True, description="Enable automatic event data cleanup"
)
data_retention_days: int = Field(
default=30, description="Number of days to retain event data", ge=1
)
data_retention_interval_hours: int = Field(
default=24,
description="Hours between automatic cleanup runs (applies to both events and nodes)",
ge=1,
)
# Node cleanup settings
node_cleanup_enabled: bool = Field(
default=True, description="Enable automatic cleanup of inactive nodes"
)
node_cleanup_days: int = Field(
default=7,
description="Remove nodes not seen for this many days (last_seen)",
ge=1,
)
@property
def collector_data_dir(self) -> str:
"""Get the collector data directory path."""
from pathlib import Path
return str(Path(self.data_home) / "collector")
@property
def effective_database_url(self) -> str:
"""Get the effective database URL, using default if not set."""
if self.database_url:
return self.database_url
from pathlib import Path
db_path = Path(self.data_home) / "collector" / "meshcore.db"
return f"sqlite:///{db_path}"
@property
def effective_seed_home(self) -> str:
"""Get the effective seed home directory."""
from pathlib import Path
return str(Path(self.seed_home))
@property
def node_tags_file(self) -> str:
"""Get the path to node_tags.yaml in seed_home."""
from pathlib import Path
return str(Path(self.effective_seed_home) / "node_tags.yaml")
@property
def members_file(self) -> str:
"""Get the path to members.yaml in seed_home."""
from pathlib import Path
return str(Path(self.effective_seed_home) / "members.yaml")
@field_validator("database_url")
@classmethod
def validate_database_url(cls, v: Optional[str]) -> Optional[str]:
"""Validate database URL format."""
# None is allowed - will use default
return v
class APISettings(CommonSettings):
"""Settings for the API component."""
# Server binding
api_host: str = Field(default="0.0.0.0", description="API server host")
api_port: int = Field(default=8000, description="API server port")
# Database - default uses data_home/collector/meshcore.db (same as collector)
database_url: Optional[str] = Field(
default=None,
description="SQLAlchemy database URL (default: sqlite:///{data_home}/collector/meshcore.db)",
)
# Authentication
api_read_key: Optional[str] = Field(default=None, description="Read-only API key")
api_admin_key: Optional[str] = Field(
default=None, description="Admin API key (full access)"
)
@property
def effective_database_url(self) -> str:
"""Get the effective database URL, using default if not set."""
if self.database_url:
return self.database_url
from pathlib import Path
db_path = Path(self.data_home) / "collector" / "meshcore.db"
return f"sqlite:///{db_path}"
@field_validator("database_url")
@classmethod
def validate_database_url(cls, v: Optional[str]) -> Optional[str]:
"""Validate database URL format."""
# None is allowed - will use default
return v
class WebSettings(CommonSettings):
"""Settings for the Web Dashboard component."""
# Server binding
web_host: str = Field(default="0.0.0.0", description="Web server host")
web_port: int = Field(default=8080, description="Web server port")
# Timezone for date/time display (uses standard TZ environment variable)
tz: str = Field(default="UTC", description="Timezone for displaying dates/times")
# Theme (dark or light, default dark)
web_theme: str = Field(
default="dark",
description="Default theme for the web dashboard (dark or light)",
)
# Locale / language (default: English)
web_locale: str = Field(
default="en",
description="Locale/language for the web dashboard (e.g. 'en')",
)
# Admin interface (disabled by default for security)
web_admin_enabled: bool = Field(
default=False,
description="Enable admin interface at /a/ (requires OAuth2Proxy in front)",
)
# API connection
api_base_url: str = Field(
default="http://localhost:8000",
description="API server base URL",
)
api_key: Optional[str] = Field(default=None, description="API key for queries")
# Network information
network_domain: Optional[str] = Field(
default=None, description="Network domain name"
)
network_name: str = Field(
default="MeshCore Network", description="Network display name"
)
network_city: Optional[str] = Field(
default=None, description="Network city location"
)
network_country: Optional[str] = Field(
default=None, description="Network country (ISO 3166-1 alpha-2)"
)
network_radio_config: Optional[str] = Field(
default=None, description="Radio configuration details"
)
network_contact_email: Optional[str] = Field(
default=None, description="Contact email address"
)
network_contact_discord: Optional[str] = Field(
default=None, description="Discord server link"
)
network_contact_github: Optional[str] = Field(
default=None, description="GitHub repository URL"
)
network_contact_youtube: Optional[str] = Field(
default=None, description="YouTube channel URL"
)
network_welcome_text: Optional[str] = Field(
default=None, description="Welcome text for homepage"
)
# Feature flags (control which pages are visible in the web dashboard)
feature_dashboard: bool = Field(
default=True, description="Enable the /dashboard page"
)
feature_nodes: bool = Field(default=True, description="Enable the /nodes pages")
feature_advertisements: bool = Field(
default=True, description="Enable the /advertisements page"
)
feature_messages: bool = Field(
default=True, description="Enable the /messages page"
)
feature_map: bool = Field(
default=True, description="Enable the /map page and /map/data endpoint"
)
feature_members: bool = Field(default=True, description="Enable the /members page")
feature_pages: bool = Field(
default=True, description="Enable custom markdown pages"
)
# Content directory (contains pages/ and media/ subdirectories)
content_home: Optional[str] = Field(
default=None,
description="Directory containing custom content (pages/, media/) (default: ./content)",
)
@property
def features(self) -> dict[str, bool]:
"""Get feature flags as a dictionary.
Automatic dependencies:
- Dashboard requires at least one of nodes/advertisements/messages.
- Map requires nodes (map displays node locations).
"""
has_dashboard_content = (
self.feature_nodes or self.feature_advertisements or self.feature_messages
)
return {
"dashboard": self.feature_dashboard and has_dashboard_content,
"nodes": self.feature_nodes,
"advertisements": self.feature_advertisements,
"messages": self.feature_messages,
"map": self.feature_map and self.feature_nodes,
"members": self.feature_members,
"pages": self.feature_pages,
}
@property
def effective_content_home(self) -> str:
"""Get the effective content home directory."""
from pathlib import Path
return str(Path(self.content_home or "./content"))
@property
def effective_pages_home(self) -> str:
"""Get the effective pages directory (content_home/pages)."""
from pathlib import Path
return str(Path(self.effective_content_home) / "pages")
@property
def effective_media_home(self) -> str:
"""Get the effective media directory (content_home/media)."""
from pathlib import Path
return str(Path(self.effective_content_home) / "media")
@property
def web_data_dir(self) -> str:
"""Get the web data directory path."""
from pathlib import Path
return str(Path(self.data_home) / "web")
def get_common_settings() -> CommonSettings:
"""Get common settings instance."""
return CommonSettings()
def get_interface_settings() -> InterfaceSettings:
"""Get interface settings instance."""
return InterfaceSettings()
def get_collector_settings() -> CollectorSettings:
"""Get collector settings instance."""
return CollectorSettings()
def get_api_settings() -> APISettings:
"""Get API settings instance."""
return APISettings()
def get_web_settings() -> WebSettings:
"""Get web settings instance."""
return WebSettings()