Files
imonlinux 6866358dfe Update config.py
Add volume max percentage and config for volume sync.
2026-01-06 13:36:28 -08:00

181 lines
5.6 KiB
Python

"""Configuration models for the application."""
from __future__ import annotations
import json
import logging
from dataclasses import dataclass, field
from pathlib import Path
from typing import List, Optional
_LOGGER = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
# Configuration Dataclasses
# -----------------------------------------------------------------------------
@dataclass
class AppConfig:
"""General application settings."""
name: str
wakeup_sound: str = "sounds/wake_word_triggered.flac"
timer_finished_sound: str = "sounds/timer_finished.flac"
preferences_file: str = "preferences.json"
debug: bool = False
@dataclass
class AudioConfig:
"""Settings for audio input and output."""
input_device: Optional[str] = None
input_block_size: int = 1024
output_device: Optional[str] = None
# If True, LVA will attempt to set the OS sink volume to match the persisted
# volume in preferences.json on startup (PipeWire/PulseAudio/ALSA best-effort).
# Default is False to avoid surprising changes on systems where users manage
# volume externally.
volume_sync: bool = False
# Maximum sink volume to map to LVA's 100% volume.
#
# Example: if max_volume_percent=150 then when LVA's media player reports
# 100% (preferences.volume_level == 1.0), the underlying sink will be set
# to 150% (or 1.5 for backends that use scalar volume).
#
# This is useful for devices that need >100% gain on PipeWire/Pulse/ALSA.
max_volume_percent: int = 100
@dataclass
class WakeWordConfig:
"""Settings for wake word detection."""
directories: List[str] = field(default_factory=list)
model: str = "okay_nabu"
stop_model: str = "stop"
refractory_seconds: float = 2.0
download_dir: str = "local"
@dataclass
class ESPHomeConfig:
"""Settings for the ESPHome API server."""
host: str = "0.0.0.0"
port: int = 6053
@dataclass
class LedConfig:
"""Settings for LEDs."""
enabled: bool = True
# Supported values include:
# - "dotstar" / "neopixel" for Pi-attached LED strips
# - "xvf3800" for the ReSpeaker XVF3800 USB LED ring backend
led_type: str = "dotstar"
# For dotstar/neopixel: "spi" or "gpio"
# For xvf3800: "usb"
interface: str = "spi"
# GPIO pin numbers used when interface="gpio"
clock_pin: int = 13
data_pin: int = 12
# Note: overridden by 'preferences.json' if it exists
num_leds: int = 3
@dataclass
class MqttConfig:
"""Settings for the MQTT client."""
enabled: bool = False
host: Optional[str] = None
port: int = 1883
username: Optional[str] = None
password: Optional[str] = None
@dataclass
class ButtonConfig:
"""Settings for a hardware momentary button."""
# Overall enable/disable (default: off)
enabled: bool = False
# mode:
# - "gpio" -> legacy GPIO button (e.g. ReSpeaker 2-Mic HAT)
# - "xvf3800"-> USB-based mute integration for the ReSpeaker XVF3800
mode: str = "gpio"
# BCM GPIO pin number for the button input (gpio mode only).
pin: int = 17
# Press duration (in seconds) to be considered a "long press" (gpio mode).
long_press_seconds: float = 1.0
# Poll interval used by both GPIO and XVF3800 controllers.
poll_interval_seconds: float = 0.01
@dataclass
class Config:
"""Main configuration object."""
app: AppConfig
audio: AudioConfig = field(default_factory=AudioConfig)
wake_word: WakeWordConfig = field(default_factory=WakeWordConfig)
esphome: ESPHomeConfig = field(default_factory=ESPHomeConfig)
led: LedConfig = field(default_factory=LedConfig)
mqtt: MqttConfig = field(default_factory=MqttConfig)
button: ButtonConfig = field(default_factory=ButtonConfig)
# -----------------------------------------------------------------------------
# Helper Function
# -----------------------------------------------------------------------------
def load_config_from_json(config_path: Path) -> Config:
"""Loads configuration from a JSON file and populates dataclasses."""
try:
with open(config_path, "r", encoding="utf-8") as f:
raw_data = json.load(f)
except FileNotFoundError:
_LOGGER.critical("Configuration file not found at: %s", config_path)
raise
except json.JSONDecodeError as e:
_LOGGER.critical("Error parsing configuration file: %s", e)
raise
if "app" not in raw_data:
raise ValueError("Configuration file must contain an 'app' section with a 'name'.")
app_config = AppConfig(**raw_data.get("app", {}))
audio_config = AudioConfig(**raw_data.get("audio", {}))
wake_word_config = WakeWordConfig(**raw_data.get("wake_word", {}))
esphome_config = ESPHomeConfig(**raw_data.get("esphome", {}))
led_config = LedConfig(**raw_data.get("led", {}))
mqtt_config = MqttConfig(**raw_data.get("mqtt", {}))
button_config = ButtonConfig(**raw_data.get("button", {}))
# Back-compat: allow top-level "volume_sync" (preferred location is audio.volume_sync)
if "volume_sync" in raw_data and "volume_sync" not in raw_data.get("audio", {}):
try:
audio_config.volume_sync = bool(raw_data.get("volume_sync"))
except Exception:
# If it's something strange, just leave default.
pass
# Set MQTT 'enabled' flag
if mqtt_config.host:
mqtt_config.enabled = True
return Config(
app=app_config,
audio=audio_config,
wake_word=wake_word_config,
esphome=esphome_config,
led=led_config,
mqtt=mqtt_config,
button=button_config,
)