Update config.py

Add volume max percentage and config for volume sync.
This commit is contained in:
imonlinux
2026-01-06 13:36:28 -08:00
committed by GitHub
parent 7891997392
commit 6866358dfe
+62 -43
View File
@@ -1,17 +1,29 @@
"""Configuration models for the application.""" """Configuration models for the application."""
from __future__ import annotations
import json import json
import logging
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional from typing import List, Optional
import logging
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Configuration Dataclasses # 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 @dataclass
class AudioConfig: class AudioConfig:
"""Settings for audio input and output.""" """Settings for audio input and output."""
@@ -19,6 +31,21 @@ class AudioConfig:
input_block_size: int = 1024 input_block_size: int = 1024
output_device: Optional[str] = None 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 @dataclass
class WakeWordConfig: class WakeWordConfig:
@@ -39,20 +66,22 @@ class ESPHomeConfig:
@dataclass @dataclass
class LedConfig: class LedConfig:
"""Settings for the LED strip. """Settings for LEDs."""
Fields:
enabled -> master on/off for hardware LEDs. If False, LedController
still subscribes to events and publishes MQTT state, but
never touches hardware.
led_type -> "dotstar", "neopixel", or "xvf3800"
interface -> "spi", "gpio", "usb", or "i2c" (future)
"""
enabled: bool = True 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" led_type: str = "dotstar"
# For dotstar/neopixel: "spi" or "gpio"
# For xvf3800: "usb"
interface: str = "spi" interface: str = "spi"
# GPIO pin numbers used when interface="gpio"
clock_pin: int = 13 clock_pin: int = 13
data_pin: int = 12 data_pin: int = 12
# Note: overridden by 'preferences.json' if it exists # Note: overridden by 'preferences.json' if it exists
num_leds: int = 3 num_leds: int = 3
@@ -69,39 +98,26 @@ class MqttConfig:
@dataclass @dataclass
class ButtonConfig: class ButtonConfig:
""" """Settings for a hardware momentary button."""
Settings for a hardware momentary button.
mode: # Overall enable/disable (default: off)
- "gpio" -> legacy GPIO button (e.g. ReSpeaker 2-Mic HAT)
- "xvf3800"-> USB-based mute integration for the ReSpeaker XVF3800
For mode="gpio":
- 'pin' and 'long_press_seconds' are used (short vs long press).
For mode="xvf3800":
- The XVF3800ButtonController uses the built-in mute button purely
as a mute toggle; 'pin' and 'long_press_seconds' are ignored.
"""
enabled: bool = False enabled: bool = False
mode: str = "gpio" # "gpio" or "xvf3800"
# 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). # BCM GPIO pin number for the button input (gpio mode only).
pin: int = 17 pin: int = 17
# Press duration (in seconds) to be considered a "long press" (gpio mode). # Press duration (in seconds) to be considered a "long press" (gpio mode).
long_press_seconds: float = 1.0 long_press_seconds: float = 1.0
# Poll interval used by both GPIO and XVF3800 controllers. # Poll interval used by both GPIO and XVF3800 controllers.
poll_interval_seconds: float = 0.01 poll_interval_seconds: float = 0.01
@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 @dataclass
class Config: class Config:
"""Main configuration object.""" """Main configuration object."""
@@ -120,22 +136,18 @@ class Config:
def load_config_from_json(config_path: Path) -> Config: def load_config_from_json(config_path: Path) -> Config:
"""Loads configuration from a JSON file and populates dataclasses.""" """Loads configuration from a JSON file and populates dataclasses."""
# --- Step 1: Load raw JSON data ---
try: try:
with open(config_path, "r", encoding="utf-8") as f: with open(config_path, "r", encoding="utf-8") as f:
raw_data = json.load(f) raw_data = json.load(f)
except FileNotFoundError: except FileNotFoundError:
_LOGGER.critical(f"Configuration file not found at: {config_path}") _LOGGER.critical("Configuration file not found at: %s", config_path)
raise raise
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
_LOGGER.critical(f"Error parsing configuration file: {e}") _LOGGER.critical("Error parsing configuration file: %s", e)
raise raise
# --- Step 2: Create config objects from raw data ---
if "app" not in raw_data: if "app" not in raw_data:
raise ValueError( raise ValueError("Configuration file must contain an 'app' section with a 'name'.")
"Configuration file must contain an 'app' section with a 'name'."
)
app_config = AppConfig(**raw_data.get("app", {})) app_config = AppConfig(**raw_data.get("app", {}))
audio_config = AudioConfig(**raw_data.get("audio", {})) audio_config = AudioConfig(**raw_data.get("audio", {}))
@@ -145,11 +157,18 @@ def load_config_from_json(config_path: Path) -> Config:
mqtt_config = MqttConfig(**raw_data.get("mqtt", {})) mqtt_config = MqttConfig(**raw_data.get("mqtt", {}))
button_config = ButtonConfig(**raw_data.get("button", {})) button_config = ButtonConfig(**raw_data.get("button", {}))
# --- Step 3: Set MQTT 'enabled' flag --- # 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: if mqtt_config.host:
mqtt_config.enabled = True mqtt_config.enabled = True
# --- Step 4: Return the main Config object ---
return Config( return Config(
app=app_config, app=app_config,
audio=audio_config, audio=audio_config,