diff --git a/amniotic/audio.py b/amniotic/audio.py index 110a38a..dd3be54 100644 --- a/amniotic/audio.py +++ b/amniotic/audio.py @@ -2,6 +2,7 @@ import logging import vlc from cachetools.func import ttl_cache +from copy import deepcopy from datetime import datetime from itertools import cycle from numbers import Number @@ -97,7 +98,7 @@ class Amniotic: VOLUME_DEFAULT = 50 THEME_NAME_DEFAULT = 'Default Theme' - def __init__(self, path: Union[Path, str], device_names: Optional[dict[str, str]] = None): + def __init__(self, path: Union[Path, str], device_names: Optional[dict[str, str]] = None, presets: Dict = None): """ Read audio directories and instantiate Theme objects @@ -132,6 +133,9 @@ def __init__(self, path: Union[Path, str], device_names: Optional[dict[str, str] self.volume = 0 self.set_volume(self.VOLUME_DEFAULT) + self.presets = deepcopy(presets) or {} # Ensure changes to Presets don't also affect config.presets + self.preset_current = None + @property def devices(self) -> dict[str, str]: """ @@ -245,7 +249,19 @@ def status_text(self) -> str: status_text = ', '.join(statuses_themes) or None return status_text - def get_preset(self) -> Dict: + def get_preset(self) -> Optional[str]: + """ + + Get the currently applied preset name, if it still matches the current settings + + """ + preset_data_current = self.get_preset_data() + preset_data = self.presets.get(self.preset_current) + if preset_data != preset_data_current: + self.preset_current = None + return self.preset_current + + def get_preset_data(self) -> Dict: """ Get Preset representing current settings, namely, Master and Theme volumes @@ -260,7 +276,20 @@ def get_preset(self) -> Dict: } return preset - def apply_preset(self, preset: Dict): + def apply_preset(self, name: str): + """ + + Apply the Preset matching the specified name, if it exists + + """ + if name not in self.presets: + msg = f'Preset "{name}" does not exist' + logging.warning(msg) + return + self.preset_current = name + self.apply_preset_data(self.presets[name]) + + def apply_preset_data(self, preset: Dict): """ Apply a preset. Themes that appear in the Preset are implicitly enabled. Non-existent Themes need to be @@ -291,6 +320,15 @@ def apply_preset(self, preset: Dict): theme = self.themes[name] theme.enabled = False + def add_preset(self, name: str): + """ + + Add a new Preset, under the specified name, consisting of the current state + + """ + self.presets[name] = self.get_preset_data() + self.apply_preset(name) + def close(self): """ @@ -425,6 +463,7 @@ def set_device(self, device: Optional[str]): Set the output audio device from its ID. Also handle when that device had been unplugged, etc. """ + device_old = device devices = self.devices if device not in devices: @@ -433,7 +472,7 @@ def set_device(self, device: Optional[str]): if device not in devices: self.enabled = False device = next(iter(devices or {None})) - msg = f'Current device "{self.device}" no longer available for theme "{self.name}". ' \ + msg = f'Current device "{device_old}" no longer available for theme "{self.name}". ' \ f'Defaulting to "{device}". Theme will be disabled.' logging.warning(msg) @@ -484,7 +523,6 @@ def stop(self): When Theme is stopped, unload its players """ - for player in self.players or []: unload_player(player) msg = f'Theme "{self.name}" unloaded player: {player}' @@ -557,6 +595,31 @@ def set_volume_scaled(self, volume_scaled): if self.enabled: self.player.audio_set_volume(volume_scaled) + def get_preset(self) -> Dict: + """ + + Get preset data + + """ + return {'volume': self.volume, 'device': self.device} + + def apply_preset(self, preset: Dict): + """ + + Apply preset data. Only enabled Themes are mentioned in Presets, so always enable + + """ + + logging.info(f'Theme "{self.name}" applying preset: {repr(preset)}') + if (volume := preset.get('volume')) is not None: + self.set_volume(volume) + + device = preset.get('device') + self.set_device(device) + + if device in self.devices: + self.enabled = True + @property def status(self): """ @@ -609,23 +672,4 @@ def status_text(self): """ return f'{self.name} @ {self.volume}%' - def get_preset(self) -> Dict: - """ - - Get preset data - - """ - return {'volume': self.volume} - - def apply_preset(self, preset: Dict): - """ - - Apply preset data. Only enabled Themes are mentioned in Presets, so always enable - - """ - - logging.info(f'Theme "{self.name}" applying preset: {repr(preset)}') - if (volume := preset.get('volume')) is not None: - self.set_volume(volume) - self.enabled = True diff --git a/amniotic/config.py b/amniotic/config.py index ace5f2f..3c96917 100644 --- a/amniotic/config.py +++ b/amniotic/config.py @@ -1,7 +1,6 @@ from os import getenv import logging -import tempfile import yaml from _socket import gethostname from appdirs import AppDirs @@ -18,9 +17,7 @@ MAC_ADDRESS = getmac.get_mac_address().replace(':', '') HOSTNAME = gethostname() IS_ADDON = bool(strtobool(getenv(f'{NAME}_IS_ADDON'.upper(), 'false'))) -PATH_TEMP_DIR = Path(tempfile.gettempdir()) -PATH_LAST_PRESET = PATH_TEMP_DIR / 'amniotic_last_preset.json' - +PRESET_LAST_KEY = '.LAST' @dataclass class Config: @@ -36,8 +33,10 @@ class Config: device_names: dict = None logging: str = None tele_period: int = 300 + presets: dict = field(default_factory=dict) config_raw: dict = field(default_factory=dict) + def __post_init__(self): path_audio = Path(self.path_audio).absolute() if not path_audio.exists(): @@ -104,4 +103,6 @@ def write(self): """ config_str = yaml.dump(self.config_raw) path = self.get_path_config() + logging.info(f'Wrote out config file to: {path}') + return path.write_text(config_str) diff --git a/amniotic/mqtt/control.py b/amniotic/mqtt/control.py index 92469f8..b2290b1 100644 --- a/amniotic/mqtt/control.py +++ b/amniotic/mqtt/control.py @@ -28,6 +28,7 @@ class Entity: HA_PLATFORM = None NAME = None ICON_SUFFIX = None + NA_VALUE = '-' value = None TEST_ALWAYS_UPDATE = False @@ -671,8 +672,8 @@ def update_sensor(self): Get the sensor for displaying update messages """ - from amniotic.mqtt.sensor import DownloaderStatus - update_status = self.loop.entities[DownloaderStatus] + from amniotic.mqtt.sensor import DownloadStatus + update_status = self.loop.entities[DownloadStatus] return update_status def progress_callback(self, stream: Stream, chunk: bytes, bytes_remaining: int): @@ -747,14 +748,14 @@ def handle_incoming(self, value: Any): threading.Thread(target=self.do_download, args=[value]).start() -class Preset(TextInput): +class PresetData(TextInput): """ - Home Assistant text input box for io of Presets + Home Assistant text input box for I/O of Presets """ ICON_SUFFIX = 'cog-play' - NAME = 'Preset' + NAME = 'Preset Data' def update_sensor(self): pass @@ -762,10 +763,10 @@ def update_sensor(self): def set_value(self, value) -> Any: """ - Apply preset + Apply preset data """ - self.amniotic.apply_preset(value) + self.amniotic.apply_preset_data(value) def get_value(self) -> str: """ @@ -773,6 +774,90 @@ def get_value(self) -> str: Current settings as Preset data """ - preset = self.amniotic.get_preset() + preset = self.amniotic.get_preset_data() preset_json = json.dumps(preset) return preset_json + + +class SavePreset(TextInput): + """ + + Home Assistant text input box for naming/saving a new Preset + + """ + ICON_SUFFIX = 'content-save-plus' + NAME = 'Save Preset As' + PATTERN = '^[^\.].*$' + + def update_sensor(self): + pass + + @property + def data(self): + """ + + Home Assistant announce data for the entity. Don't allow Preset names to start with a '.' because these will be + used internally as special cases (e.g. last-used Preset) + + """ + data = super().data | { + # 'pattern': self.PATTERN TODO: Fix this + } + return data + + def set_value(self, value): + """ + + Add a new Preset + + """ + self.amniotic.add_preset(value) + config = self.loop.config + config.config_raw['presets'] = self.amniotic.presets + config.write() + + def get_value(self) -> Optional[str]: + """ + + We can leave the text as the last value the user entered, so no need to return anything here + + """ + return None + + +class Preset(Select): + """ + + Home Assistant Presets selector. + + """ + ICON_SUFFIX = 'window-shutter-cog' + NAME = 'Preset' + NA_VALUE = "" + + def get_value(self) -> str: + """ + + Get the currently selected Preset, if there is one + + """ + return self.amniotic.get_preset() or self.NA_VALUE + + def set_value(self, value) -> str: + """ + + If selector isn't empty, apply the selected Preset + + """ + if value == self.NA_VALUE: + return + self.amniotic.apply_preset(value) + + def get_options(self, amniotic: Amniotic) -> list[str]: + """ + + Get state of the entity, i.e. the list of options and the currently selected option. Also allow no entry to + be selected + + """ + return [self.NA_VALUE] + sorted(amniotic.presets.keys()) diff --git a/amniotic/mqtt/loop.py b/amniotic/mqtt/loop.py index 71116fe..443229f 100644 --- a/amniotic/mqtt/loop.py +++ b/amniotic/mqtt/loop.py @@ -2,13 +2,14 @@ import json import logging +from copy import deepcopy from functools import cached_property from paho.mqtt import client as mqtt from time import sleep from typing import Type from amniotic.audio import Amniotic -from amniotic.config import Config, IS_ADDON, PATH_LAST_PRESET +from amniotic.config import Config, IS_ADDON, PRESET_LAST_KEY from amniotic.mqtt.device import Device from amniotic.mqtt.tools import Message from amniotic.version import __version__ @@ -33,6 +34,46 @@ class Loop: DELAY = 0.5 DELAY_FIRST = 3 + def __init__(self, config: Config, device: Device, amniotic: Amniotic): + """ + + Setup and connect MQTT Client. + + """ + + self.config = config + self.device = device + + self.exit_reason = False + + self.entities = { + entity_class: entity_class(self) + for entity_class in self.entity_classes + } + self.callback_map = { + entity.topic_command: entity.handle_incoming + for entity in self.entities.values() + } + + self.queue = [] + self.force_announce_period = self.config.tele_period * 10 + self.has_reconnected = True + self.topic_lwt = self.device.topic_lwt + + self.amniotic = amniotic + self.client = mqtt.Client() + + self.client.on_connect = self.on_connect + self.client.on_message = self.on_message + self.client.on_connect_fail = self.on_connect_fail + self.client.user_data_set(amniotic) + self.client.will_set(self.topic_lwt, payload='Offline', qos=1, retain=False, properties=None) + + if config.mqtt_username is not None and config.mqtt_password is not None: + self.client.username_pw_set(username=config.mqtt_username, password=config.mqtt_password) + + self.client.connect(host=config.mqtt_host, port=config.mqtt_port) + def on_message(self, client: mqtt.Client, amniotic: Amniotic, mqtt_message: mqtt.MQTTMessage): """ @@ -74,47 +115,6 @@ def on_connect(self, client: mqtt.Client, amniotic: Amniotic, flags: dict, code: self.has_reconnected = True - def __init__(self, host, port, device: Device, amniotic: Amniotic, username: str = None, password: str = None, - tele_period: int = 300): - """ - - Setup and connect MQTT Client. - - """ - - self.device = device - - self.exit_reason = False - - self.entities = { - entity_class: entity_class(self) - for entity_class in self.entity_classes - } - self.callback_map = { - entity.topic_command: entity.handle_incoming - for entity in self.entities.values() - } - - self.queue = [] - self.tele_period = tele_period - self.force_announce_period = self.tele_period * 10 - self.has_reconnected = True - self.topic_lwt = self.device.topic_lwt - - self.amniotic = amniotic - self.client = mqtt.Client() - - self.client.on_connect = self.on_connect - self.client.on_message = self.on_message - self.client.on_connect_fail = self.on_connect_fail - self.client.user_data_set(amniotic) - self.client.will_set(self.topic_lwt, payload='Offline', qos=1, retain=False, properties=None) - - if username is not None and password is not None: - self.client.username_pw_set(username=username, password=password) - - self.client.connect(host=host, port=port) - @cached_property def entity_classes(self) -> list[Type]: """ @@ -130,7 +130,7 @@ def entity_classes(self) -> list[Type]: control.ButtonVolumeDownMaster, control.ButtonVolumeUpMaster, control.ButtonDisableAllThemes, - control.Preset, + control.ButtonRestart, control.SelectTheme, @@ -140,6 +140,10 @@ def entity_classes(self) -> list[Type]: control.ButtonVolumeDownTheme, control.ButtonVolumeUpTheme, + control.PresetData, + control.Preset, + control.SavePreset, + control.Downloader, control.NewTheme, @@ -152,7 +156,7 @@ def entity_classes(self) -> list[Type]: ] sensors = [ - sensor.ThemesStatus, + sensor.Overview, sensor.Title, sensor.Album, sensor.TrackCount, @@ -160,7 +164,7 @@ def entity_classes(self) -> list[Type]: sensor.By, sensor.Duration, # sensor.Elapsed, - sensor.DownloaderStatus, + sensor.DownloadStatus, ] if not IS_ADDON: @@ -192,7 +196,6 @@ def do_telemetry(self): logging.debug(f'Status: {status}') # self.client.publish(TOPIC_STATUS, status) self.client.publish(self.topic_lwt, "Online", qos=1) - self def loop_start(self): """ @@ -219,7 +222,7 @@ def loop_start(self): sleep(self.LOOP_PERIOD) continue - is_telem_loop = loop_count % self.tele_period == 0 + is_telem_loop = loop_count % self.config.tele_period == 0 is_force_announce_loop = loop_count % self.force_announce_period == 0 self.handle_outgoing(force_announce=self.has_reconnected or is_force_announce_loop) @@ -234,14 +237,24 @@ def loop_start(self): sleep(self.LOOP_PERIOD) loop_count += 1 + self.close() + + def close(self): + """ + + Close Amniotic gracefully, save current config, etc. + + """ msg = f'Event loop exiting gracefully for the following reason: {self.exit_reason}' logging.info(msg) - msg = f'Writing current preset to {PATH_LAST_PRESET}' + msg = f'Adding current preset to config' logging.info(msg) - preset = self.amniotic.get_preset() - preset_json = json.dumps(preset) - PATH_LAST_PRESET.write_text(preset_json) + + presets = deepcopy(self.amniotic.presets) + presets[PRESET_LAST_KEY] = self.amniotic.get_preset_data() + self.config.config_raw['presets'] = presets + self.config.write() msg = f'Amniotic {__version__} closing...' logging.info(msg) @@ -262,28 +275,20 @@ def start(): force=True ) - amniotic = Amniotic(path=config.path_audio, device_names=config.device_names) + preset_last = config.presets.pop(PRESET_LAST_KEY, None) + amniotic = Amniotic(path=config.path_audio, device_names=config.device_names, presets=config.presets) + if preset_last: + amniotic.apply_preset_data(preset_last) + msg = f'Amniotic {__version__} has started.' logging.info(msg) - - if PATH_LAST_PRESET.exists(): - preset_json = PATH_LAST_PRESET.read_text() - preset = json.loads(preset_json) - msg = f'Amniotic {__version__} is applying last run preset.' - logging.info(msg) - amniotic.apply_preset(preset) - msg = f'Amniotic {__version__} starting MQTT...' logging.info(msg) loop = Loop( + config, device=Device(location=config.location), amniotic=amniotic, - host=config.mqtt_host, - port=config.mqtt_port, - username=config.mqtt_username, - password=config.mqtt_password, - tele_period=config.tele_period ) loop.loop_start() diff --git a/amniotic/mqtt/sensor.py b/amniotic/mqtt/sensor.py index 949e992..3df2977 100644 --- a/amniotic/mqtt/sensor.py +++ b/amniotic/mqtt/sensor.py @@ -12,7 +12,6 @@ class Sensor(control.Entity): """ HA_PLATFORM = 'sensor' - NA_VALUE = '-' META_KEY = None IS_SOURCE_META = True UOM = None @@ -51,14 +50,14 @@ def get_value(self, key=None) -> Union[str, int, float]: meta_value = self.NA_VALUE return meta_value -class ThemesStatus(Sensor): +class Overview(Sensor): """ - Home Assistant Themes status sensor, showing overview of which Themes are enabled + Home Assistant sensor showing overview of which Themes are enabled, etc. """ META_KEY = None - NAME = 'Themes Status' + NAME = 'Overview' IS_SOURCE_META = False ICON_SUFFIX = 'list-status' NA_VALUE = 'None enabled' @@ -182,13 +181,13 @@ class UpdateStatus(StaticMessageSensor): message = 'Never checked' -class DownloaderStatus(StaticMessageSensor): +class DownloadStatus(StaticMessageSensor): """ - Home Assistant downloader status sensor. Messages set by downloader input etc. + Home Assistant download status sensor. Messages set by downloader input etc. """ - NAME = 'Downloader Status' + NAME = 'Download Status' ICON_SUFFIX = 'cloud-sync-outline' message = 'Idle'