From 18dcb8de6770aec5febd44191300e325acb8dfc2 Mon Sep 17 00:00:00 2001 From: JarbasAI <33701864+JarbasAl@users.noreply.github.com> Date: Wed, 18 Sep 2024 05:51:01 +0100 Subject: [PATCH] bug/fix everything around brightness/nightmode... (#31) * bug/get_rid_of_duplicate_configs closes https://github.com/OpenVoiceOS/ovos-gui-plugin-shell-companion/issues/26 * clean up some more, did this thing ever work right? * reformat some more * Update ovos_gui_plugin_shell_companion/__init__.py Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * drop hardcoded sleeps, use events, standardize brightness handlers names to follow conventions * further simplify dimming logic * night mode tweaks * refactor periodic nightmode check, schedule events directly for sunset/sunrise times * typo * readme * safety checks around brightness manager, default_brightness setting, change brightness during nightmode * safety checks and error logs * low_brightness and auto_dim_seconds from config * autodim during night mode * fix value range * ensure ints not floats * remove redundant checks * simplify further * fix sunrise/sunset calculation * fix sunrise/sunset typo * Update README.md Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update README.md Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update README.md * improve responsiveness * streamline sunset calculations, no need for a 12h timer... * avoid multiple events firing * simplify suntimes calc * DRY * less log spam * syntax error * Update README.md * configurable sunset/sunrise times * refactor/split discover into helper methods * check if modes are enabled on launch, not only on GUI settings change use the ovos-shell native brightness control WHY did this code even exist * option to make default * better nightmode autodim * better nightmode autodim * more logs * error handling * fix night time check * minimize writes --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .github/workflows/unit_tests.yml | 45 -- README.md | 56 +- ovos_gui_plugin_shell_companion/__init__.py | 38 +- ovos_gui_plugin_shell_companion/brightness.py | 591 +++++++++++------- ovos_gui_plugin_shell_companion/config.py | 18 - .../{cui.py => helpers.py} | 14 + test/__init__.py | 0 test/requirements.txt | 8 - test/unittests/__init__.py | 0 test/unittests/mocks.py | 56 -- test/unittests/test_smartspeaker_extension.py | 52 -- 11 files changed, 438 insertions(+), 440 deletions(-) delete mode 100644 .github/workflows/unit_tests.yml delete mode 100644 ovos_gui_plugin_shell_companion/config.py rename ovos_gui_plugin_shell_companion/{cui.py => helpers.py} (95%) delete mode 100644 test/__init__.py delete mode 100644 test/requirements.txt delete mode 100644 test/unittests/__init__.py delete mode 100644 test/unittests/mocks.py delete mode 100644 test/unittests/test_smartspeaker_extension.py diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml deleted file mode 100644 index af14577..0000000 --- a/.github/workflows/unit_tests.yml +++ /dev/null @@ -1,45 +0,0 @@ -name: Unit Tests -on: - push: - workflow_dispatch: - -jobs: - py_build_tests: - uses: neongeckocom/.github/.github/workflows/python_build_tests.yml@master - with: - python_version: "3.8" - unit_tests: - strategy: - matrix: - python-version: [ 3.7, 3.8, 3.9 ] - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - name: Set up python ${{ matrix.python-version }} - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} - - name: Install System Dependencies - run: | - sudo apt-get update - sudo apt install python3-dev swig - python -m pip install build wheel - - name: Install repo - run: | - pip install -e . - - name: Install test dependencies - run: | - pip install -r test/requirements.txt - - name: Run unittests - run: | - pytest --cov=ovos_gui_plugin_shell_companion --cov-report xml test/unittests - # NOTE: additional pytest invocations should also add the --cov-append flag - # or they will overwrite previous invocations' coverage reports - # (for an example, see OVOS Skill Manager's workflow) - - name: Upload coverage - if: "${{ matrix.python-version == '3.9' }}" - uses: codecov/codecov-action@v3 - with: - token: ${{secrets.CODECOV_TOKEN}} - files: coverage.xml - verbose: true \ No newline at end of file diff --git a/README.md b/README.md index b0ad071..b013481 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # OVOS Shell companion -provides various bus APIs that integrate with ovos-shell +provides various bus APIs that integrate with [ovos-shell](https://github.com/OpenVoiceOS/ovos-shell) - color scheme manager - notifications widgets @@ -8,12 +8,54 @@ provides various bus APIs that integrate with ovos-shell - brightness control (night mode etc) -these used to be several individual plugins but they all are ovos-shell specific integrations and ovos-shell requires all of them, so they have been unified +## Features +```json +{ + "gui": { + "ovos-gui-plugin-shell-companion": { + "sunrise_time": "auto", + "sunset_time": "auto", + "default_brightness": 100, + "night_default_brightness": 70, + "low_brightness": 20, + "auto_dim_seconds": 60, + "auto_dim": false, + "auto_nightmode": false + } + } +} +``` + + +### Night mode + +night mode will perform actions based on sunset/sunrise times + +- the homescreen changes to a simple clock with white text on a black background. +- default brightness is reduced. +- auto-dim is enabled + +`sunrise_time` and `sunset_time` will be automatically calculated based on location if set to `"auto"`, specific times can be explicitly set with the format `"HH:MM"`, eg. if you are an early riser you may want `"sunrise_time": "05:30"` + +brightness level during nighttime can be set via `"night_default_brightness"` + +### Auto Dim + +auto-dim will lower the screen brightness after 60 seconds of inactivity, until the user interacts with the GUI or talks to the OVOS device + +brightness level when idle can be set via `"low_brightness"` + +auto-dim can be enabled at all times by setting `"auto_dim": true` in your config + + +## DEPRECATION WARNING + +> in **ovos-core version 0.0.7** the bus apis provided by this repo used to be several individual PHAL plugins the following packages have been deprecated in favor of this repo: -- https://github.com/OpenVoiceOS/ovos-PHAL-plugin-dashboard <- DEPRECATED, community maintained, no official replacement, [removed from ovos-shell](https://github.com/OpenVoiceOS/ovos-gui/pull/10) -- https://github.com/OpenVoiceOS/ovos-PHAL-plugin-configuration-provider -- https://github.com/OpenVoiceOS/ovos-PHAL-plugin-notification-widgets -- https://github.com/OpenVoiceOS/ovos-PHAL-plugin-brightness-control-rpi -- https://github.com/OpenVoiceOS/ovos-PHAL-plugin-color-scheme-manager +- [ovos-PHAL-plugin-dashboard](https://github.com/OpenVoiceOS/ovos-PHAL-plugin-dashboard) <- DEPRECATED, community maintained, no official replacement, [removed from ovos-shell](https://github.com/OpenVoiceOS/ovos-gui/pull/10) +- [ovos-PHAL-plugin-configuration-provider](https://github.com/OpenVoiceOS/ovos-PHAL-plugin-configuration-provider) <- now part of this repo +- [ovos-PHAL-plugin-notification-widgets](https://github.com/OpenVoiceOS/ovos-PHAL-plugin-notification-widgets) <- now part of this repo +- [ovos-PHAL-plugin-brightness-control-rpi](https://github.com/OpenVoiceOS/ovos-PHAL-plugin-brightness-control-rpi) <- now part of this repo +- [ovos-PHAL-plugin-color-scheme-manager](https://github.com/OpenVoiceOS/ovos-PHAL-plugin-color-scheme-manager) <- now part of this repo diff --git a/ovos_gui_plugin_shell_companion/__init__.py b/ovos_gui_plugin_shell_companion/__init__.py index f30b7eb..d0e1c68 100644 --- a/ovos_gui_plugin_shell_companion/__init__.py +++ b/ovos_gui_plugin_shell_companion/__init__.py @@ -1,19 +1,18 @@ import platform - from os.path import join, dirname -from ovos_bus_client.client import MessageBusClient + from ovos_bus_client import Message -from ovos_utils import network_utils from ovos_bus_client.apis.gui import GUIInterface -from ovos_utils.log import LOG +from ovos_bus_client.client import MessageBusClient from ovos_config.config import Configuration +from ovos_plugin_manager.templates.gui import GUIExtension +from ovos_utils import network_utils +from ovos_utils.log import LOG from ovos_gui_plugin_shell_companion.brightness import BrightnessManager from ovos_gui_plugin_shell_companion.color_manager import ColorManager -from ovos_gui_plugin_shell_companion.config import get_ovos_shell_config -from ovos_gui_plugin_shell_companion.cui import ConfigUIManager +from ovos_gui_plugin_shell_companion.helpers import ConfigUIManager from ovos_gui_plugin_shell_companion.wigets import WidgetManager -from ovos_plugin_manager.templates.gui import GUIExtension class OVOSShellCompanionExtension(GUIExtension): @@ -44,13 +43,12 @@ def __init__(self, config: dict, bus: MessageBusClient = None, LOG.info("OVOS Shell: Initializing") super().__init__(config=config, bus=bus, gui=gui, preload_gui=preload_gui, permanent=permanent) - self.local_display_config = get_ovos_shell_config() self.about_page_data = [] self.build_initial_about_page_data() self.color_manager = ColorManager(self.bus) self.widgets = WidgetManager(self.bus) - self.bright = BrightnessManager(self.bus) + self.bright = BrightnessManager(self.bus, self.config) self.cui = ConfigUIManager(self.bus) def register_bus_events(self): @@ -81,7 +79,7 @@ def register_bus_events(self): self.handle_display_auto_nightmode_config_set) def handle_remove_namespace(self, message): - LOG.info("Got Clear Namespace Event In Skill") + LOG.debug("Clearing namespace (mycroft.gui.screen.close)") get_skill_namespace = message.data.get("skill_id", "") if get_skill_namespace: self.bus.emit(Message("gui.clear.namespace", @@ -139,11 +137,11 @@ def handle_device_display_factory(self, message): self.gui.show_page("SYSTEM_AdditionalSettings.qml", override_idle=True) def handle_device_display_settings(self, message): - LOG.debug(f"Display settings: {self.local_display_config}") + LOG.debug(f"Display settings: {self.config}") self.gui['state'] = 'settings/display_settings' # wallpaper_rotation data is determined via Messagebus in Qt directly - self.gui['display_auto_dim'] = self.local_display_config.get("auto_dim", False) - self.gui['display_auto_nightmode'] = self.local_display_config.get("auto_nightmode", False) + self.gui['display_auto_dim'] = self.config.get("auto_dim", False) + self.gui['display_auto_nightmode'] = self.config.get("auto_nightmode", False) self.gui.show_page("SYSTEM_AdditionalSettings.qml", override_idle=True) def handle_device_about_page(self, message): @@ -156,15 +154,17 @@ def handle_device_about_page(self, message): def handle_display_auto_dim_config_set(self, message): auto_dim = message.data.get("auto_dim", False) - self.local_display_config["auto_dim"] = auto_dim - self.local_display_config.store() - self.bus.emit(Message("speaker.extension.display.auto.dim.changed")) + if auto_dim: + self.bright.start_auto_dim() + else: + self.bright.stop_auto_dim() def handle_display_auto_nightmode_config_set(self, message): auto_nightmode = message.data.get("auto_nightmode", False) - self.local_display_config["auto_nightmode"] = auto_nightmode - self.local_display_config.store() - self.bus.emit(Message("speaker.extension.display.auto.nightmode.changed")) + if auto_nightmode: + self.bright.start_auto_night_mode() + else: + self.bright.stop_auto_night_mode() def display_advanced_config_for_group(self, message=None): group_meta = message.data.get("settingsMetaData") diff --git a/ovos_gui_plugin_shell_companion/brightness.py b/ovos_gui_plugin_shell_companion/brightness.py index 6a54223..b4675fd 100644 --- a/ovos_gui_plugin_shell_companion/brightness.py +++ b/ovos_gui_plugin_shell_companion/brightness.py @@ -1,9 +1,8 @@ import datetime -import subprocess import threading -import time -import shutil from datetime import timedelta +from typing import Optional, Tuple + from astral import LocationInfo from astral.sun import sun from ovos_bus_client import Message @@ -11,257 +10,379 @@ from ovos_utils.events import EventSchedulerInterface from ovos_utils.log import LOG from ovos_utils.time import now_local -from ovos_gui_plugin_shell_companion.config import get_ovos_shell_config + +from ovos_gui_plugin_shell_companion.helpers import update_config class BrightnessManager: - def __init__(self, bus): + """ovos-shell has a fake brightness setting, it will dim the QML itself, not control the screen + + ovos-shell slider: https://github.com/OpenVoiceOS/ovos-shell/blob/master/application/qml/panel/quicksettings/BrightnessSlider.qml + - emitted bus event from shell slider: "phal.brightness.control.set", {"brightness": fixedValue} + - to update slider externally: "phal.brightness.control.auto.dim.update"/"phal.brightness.control.get.response", {"brightness": fixedValue} + """ + + def __init__(self, bus, config: dict): + """ + Initialize the BrightnessManager. + + Args: + bus: Message bus for inter-process communication. + config: Configuration dictionary for brightness settings. + """ + self._lock = threading.RLock() self.bus = bus + self.config = config self.event_scheduler = EventSchedulerInterface() self.event_scheduler.set_id("ovos-shell") self.event_scheduler.set_bus(self.bus) - self.device_interface = "DSI" - self.vcgencmd = shutil.which("vcgencmd") - self.ddcutil = shutil.which("ddcutil") - self.ddcutil_detected_bus = None - self.ddcutil_brightness_code = None - self.auto_dim_enabled = False - self.auto_night_mode_enabled = False - self.timer_thread = None # TODO - use event scheduler too - - self.sunset_time = None - self.sunrise_time = None - self.get_sunset_time() - - self.is_auto_dim_enabled() - self.is_auto_night_mode_enabled() - - self.discover() - - self.bus.on("phal.brightness.control.get", self.query_current_brightness) - self.bus.on("phal.brightness.control.set", self.set_brightness_from_bus) - self.bus.on("speaker.extension.display.auto.dim.changed", self.is_auto_dim_enabled) - self.bus.on("speaker.extension.display.auto.nightmode.changed", self.is_auto_night_mode_enabled) - self.bus.on("gui.page_interaction", self.undim_display) - self.bus.on("gui.page_gained_focus", self.undim_display) - self.bus.on("recognizer_loop:wakeword", self.undim_display) - self.bus.on("recognizer_loop:record_begin", self.undim_display) - - #### brightness manager - TODO generic non rpi support - # Check if the auto dim is enabled - def is_auto_dim_enabled(self, message=None): - LOG.debug("Checking if auto dim is enabled") - display_config = get_ovos_shell_config() - self.auto_dim_enabled = display_config.get("auto_dim", False) + + self.fake_brightness = not self.config.get("external_plugin", False) # allow delegating to external PHAL plugin + self.default_brightness = self.config.get("default_brightness", 100) + self._brightness_level: int = self.default_brightness + self.sunrise_time, self.sunset_time = None, None + + self.bus.on("phal.brightness.control.get", self.handle_get_brightness) + self.bus.on("phal.brightness.control.set", self.handle_sync_brightness) # from external PHAL plugin + self.bus.on("phal.brightness.control.sync", self.handle_sync_brightness) # from GUI slider + + self.bus.on("gui.page_interaction", self.handle_undim_screen) + self.bus.on("gui.page_gained_focus", self.handle_undim_screen) + self.bus.on("recognizer_loop:wakeword", self.handle_undim_screen) + self.bus.on("recognizer_loop:record_begin", self.handle_undim_screen) + + self.start() + + def start(self): + LOG.info(f"auto dim enabled: {self.auto_dim_enabled}") + LOG.info(f"auto night mode enabled: {self.auto_night_mode_enabled}") + if self.auto_night_mode_enabled: + LOG.debug("Starting auto night mode on launch") + sunrise = self.config.get("sunrise_time", "auto") + sunset = self.config.get("sunset_time", "auto") + LOG.debug(f"sunrise set by user - {sunrise}") + LOG.debug(f"sunset set by user - {sunset}") + self.start_auto_night_mode() if self.auto_dim_enabled: + LOG.debug("Starting auto dim on launch") self.start_auto_dim() - else: - self.stop_auto_dim() - # Discover the brightness control device interface (HDMI / DSI) on the Raspberry PI - def discover(self): - try: - LOG.info("Discovering brightness control device interface") - proc = subprocess.Popen([self.vcgencmd, - "get_config", "display_default_lcd"], stdout=subprocess.PIPE) - if proc.stdout.read().decode("utf-8").strip() in ('1', 'display_default_lcd=1'): - self.device_interface = "DSI" - else: - self.device_interface = "HDMI" - LOG.info("Brightness control device interface is {}".format( - self.device_interface)) - - if self.device_interface == "HDMI": - proc_detect = subprocess.Popen( - [self.ddcutil, "detect"], stdout=subprocess.PIPE) - - ddcutil_detected_output = proc_detect.stdout.read().decode("utf-8") - if "I2C bus:" in ddcutil_detected_output: - bus_code = ddcutil_detected_output.split( - "I2C bus: ")[1].strip().split("\n")[0] - self.ddcutil_detected_bus = bus_code.split("-")[1].strip() - else: - ddcutil_detected_bus = None - LOG.error("Display is not detected by DDCUTIL") - - if self.ddcutil_detected_bus: - proc_fetch_vcp = subprocess.Popen( - [self.ddcutil, "getvcp", "known", "--bus", self.ddcutil_detected_bus], - stdout=subprocess.PIPE) - # check the vcp output for the Brightness string and get its VCP code - for line in proc_fetch_vcp.stdout: - if "Brightness" in line.decode("utf-8"): - self.ddcutil_brightness_code = line.decode( - "utf-8").split(" ")[2].strip() - except Exception as e: - LOG.error(e) - LOG.info("Falling back to DSI interface") - self.device_interface = "DSI" - - # Get the current brightness level - - def get_brightness(self): - LOG.info("Getting current brightness level") - if self.device_interface == "HDMI": - proc_fetch_vcp = subprocess.Popen( - [self.ddcutil, "getvcp", self.ddcutil_brightness_code, "--bus", self.ddcutil_detected_bus], - stdout=subprocess.PIPE) - for line in proc_fetch_vcp.stdout: - if "current value" in line.decode("utf-8"): - brightness_level = line.decode( - "utf-8").split("current value = ")[1].split(",")[0].strip() - return int(brightness_level) - - if self.device_interface == "DSI": - proc_fetch_vcp = subprocess.Popen( - ["cat", "/sys/class/backlight/rpi_backlight/actual_brightness"], stdout=subprocess.PIPE) - for line in proc_fetch_vcp.stdout: - brightness_level = line.decode("utf-8").strip() - return int(brightness_level) - - def query_current_brightness(self, message): - current_brightness = self.get_brightness() - if self.device_interface == "HDMI": - self.bus.emit(message.response( - data={"brightness": current_brightness})) - elif self.device_interface == "DSI": - brightness_percentage = int((current_brightness / 255) * 100) - self.bus.emit(message.response( - data={"brightness": brightness_percentage})) - - # Set the brightness level - - def set_brightness(self, level): - LOG.debug("Setting brightness level") - if self.device_interface == "HDMI": - subprocess.Popen([self.ddcutil, "setvcp", self.ddcutil_brightness_code, - "--bus", self.ddcutil_detected_bus, str(level)]) - elif self.device_interface == "DSI": - subprocess.call( - f"echo {level} > /sys/class/backlight/rpi_backlight/brightness", shell=True) - - LOG.info("Brightness level set to {}".format(level)) - - def set_brightness_from_bus(self, message): - LOG.debug("Setting brightness level from bus") - level = message.data.get("brightness", "") - - if self.device_interface == "HDMI": - percent_level = 100 * float(level) - if float(level) < 0: - apply_level = 0 - elif float(level) > 100: - apply_level = 100 - else: - apply_level = round(percent_level / 10) * 10 - - self.set_brightness(apply_level) - - if self.device_interface == "DSI": - percent_level = 255 * float(level) - if float(level) < 0: - apply_level = 0 - elif float(level) > 255: - apply_level = 255 - else: - apply_level = round(percent_level / 10) * 10 - - self.set_brightness(apply_level) + ############################################## + # brightness manager + # TODO - allow dynamic brightness based on camera, reacting live to brightness, + def set_brightness(self, level: int): + """ + Set the brightness level. + + Args: + level: Brightness level to set. + """ + with self._lock: # use a lock so this doesnt fire multiple times + level = int(level) + if level == self._brightness_level: + return # avoid log spam + LOG.info(f"Brightness level set to {level}") + self._brightness_level = level + + if self.fake_brightness: + LOG.debug("delegating brightness change to ovos-shell fake brightness") + # ovos-shell will apply fake brightness + self.bus.emit(Message("phal.brightness.control.auto.dim.update", + {"brightness": level})) + else: # will NOT update ovos-shell slider + LOG.debug("delegating brightness change to external plugin") + self.bus.emit(Message("phal.brightness.control.set", + {"brightness": level})) + # sync GUI slider by reporting new value + self.bus.emit(Message("phal.brightness.control.get.response", + {"brightness": level})) + + def handle_get_brightness(self, message: Message): + """ + Handle the 'get brightness' event from the message bus. + + Args: + message: The message received from the bus. + """ + if not self.fake_brightness: + # let external PHAL plugin handle it + return + self.bus.emit(message.response(data={"brightness": self._brightness_level})) + + def handle_sync_brightness(self, message: Message): + """ + Handle the 'set brightness' event from the message bus. + + Args: + message: The message received from the bus. + """ + level = message.data.get("brightness", 100) + LOG.debug(f"brightness level update: {level}") + self._brightness_level = int(level) + if message.data.get("make_default") and level != self.default_brightness: + self.default_brightness = level + LOG.info(f"new brightness default level: {level}") + + @property + def auto_dim_enabled(self) -> bool: + """ + Check if auto-dim is enabled. + + Returns: + bool: True if auto-dim is enabled, False otherwise. + """ + return self.config.get("auto_dim", False) or (self.auto_night_mode_enabled and self.is_night) def start_auto_dim(self): - LOG.debug("Starting auto dim") - self.timer_thread = threading.Thread(target=self.auto_dim_timer) - self.timer_thread.start() - - def auto_dim_timer(self): - while self.auto_dim_enabled: - time.sleep(60) - LOG.debug("Adjusting brightness automatically") - if self.device_interface == "HDMI": - current_brightness = 100 - if self.device_interface == "DSI": - current_brightness = 255 - - self.bus.emit( - Message("phal.brightness.control.auto.dim.update", {"brightness": 20})) - self.set_brightness(20) + """ + Start the auto-dim functionality. + """ + if not self.config.get("auto_dim"): + LOG.info("Nightmode: Auto Dim enabled until sunrise") + else: + LOG.info("Enabling Auto Dim") + self.config["auto_dim"] = True + update_config("auto_dim", True) + + # cancel any previous autodim event + self._cancel_next_dim() + # dim screen in 60 seconds + seconds = self.config.get("auto_dim_seconds", 60) + self.event_scheduler.schedule_event(self.handle_dim_screen, + when=now_local() + timedelta(seconds=seconds), + name="ovos-shell.autodim") + + def handle_dim_screen(self, message: Optional[Message] = None): + """ + Handle the dimming of the screen. + + Args: + message: Optional message received from the bus. + """ + if self.auto_dim_enabled: + lowb = self.config.get("low_brightness", 20) + if self._brightness_level != lowb: + LOG.debug("Auto-dim: Lowering brightness") + self.set_brightness(lowb) + if self.auto_night_mode_enabled and self.is_night: + # show night clock in homescreen + LOG.debug("triggering night face clock") + # TODO - allow other actions, new bus event to trigger night mode + # dont hardcode homescreen night clock face + self.bus.emit(Message("phal.brightness.control.auto.night.mode.enabled")) + + def _restore(self): + """ + Restore the brightness level if auto-dim had reduced it. + """ + if self._brightness_level < self.default_brightness: + LOG.debug("Auto-dim: Restoring brightness") + self.set_brightness(self.default_brightness) def stop_auto_dim(self): + """ + Stop the auto-dim functionality. + """ LOG.debug("Stopping Auto Dim") - self.auto_dim_enabled = False - if self.timer_thread: - self.timer_thread.join() - - def restart_auto_dim(self): - LOG.debug("Restarting Auto Dim") - self.stop_auto_dim() - self.auto_dim_enabled = True - self.start_auto_dim() - - def undim_display(self, message=None): + self._cancel_next_dim() + self._restore() + if self.config.get("auto_dim"): + self.config["auto_dim"] = False + update_config("auto_dim", False) + + def _cancel_next_dim(self): + # cancel the next unfired dim event + try: + time_left = self.event_scheduler.get_scheduled_event_status("ovos-shell.autodim") + self.event_scheduler.cancel_scheduled_event("ovos-shell.autodim") + except: + pass # throws exception if event not registered + + def handle_undim_screen(self, message: Optional[Message] = None): + """ + Handle the undimming of the screen upon user interaction. + + Args: + message: Optional message received from the bus. + """ if self.auto_dim_enabled: - LOG.debug("Undimming display on interaction") - if self.device_interface == "HDMI": - self.set_brightness(100) - if self.device_interface == "DSI": - self.set_brightness(255) - self.bus.emit( - Message("phal.brightness.control.auto.dim.update", {"brightness": "100"})) - self.restart_auto_dim() + self._restore() + self._cancel_next_dim() + # schedule next auto-dim + seconds = self.config.get("auto_dim_seconds", 60) + self.event_scheduler.schedule_event(self.handle_dim_screen, + when=now_local() + timedelta(seconds=seconds), + name="ovos-shell.autodim") + + ################################## + # AUTO NIGHT MODE HANDLING + # TODO - allow to do it based on camera, reacting live to brightness, + # instead of depending on sunset times + @property + def auto_night_mode_enabled(self) -> bool: + """ + Check if auto night mode is enabled. + + Returns: + bool: True if auto night mode is enabled, False otherwise. + """ + return self.config.get("auto_nightmode", False) + + @property + def is_night(self) -> bool: + # next events, guaranteed to be both in **the future** + self.sunrise_time, self.sunset_time = self.get_suntimes() + + # it is daytime if the sun sets today and rises tomorrow + # n = now_local() + # is_day = self.sunset_time.day == n.day and self.sunrise_time.day > n.day + + # is_day = self.sunset_time.day != self.sunrise_time.day + # return not is_day + + # before midnight -> both are next day + # after midnight -> both are current day + return self.sunset_time.day == self.sunrise_time.day + + def start_auto_night_mode(self): + """ + Start the auto night mode functionality. + """ + LOG.debug("Starting auto night mode") + if not self.config.get("auto_nightmode"): + self.config["auto_nightmode"] = True + update_config("auto_nightmode", True) + + if self.is_night: + self.handle_sunset() else: - pass + self.handle_sunrise() - ##### AUTO NIGHT MODE HANDLING ##### + def handle_sunrise(self, message: Optional[Message] = None): + """ + Handle the sunrise event for auto night mode. - def get_sunset_time(self, message=None): - LOG.debug("Getting sunset time") - now_time = now_local() - try: - location = Configuration()["location"] - lat = location["coordinate"]["latitude"] - lon = location["coordinate"]["longitude"] - tz = location["timezone"]["code"] - city = LocationInfo("Some city", "Some location", tz, lat, lon) - s = sun(city.observer, date=now_time) - self.sunset_time = s["sunset"] - self.sunrise_time = s["sunrise"] - except Exception as e: - LOG.exception(f"Using default times for sunrise/sunset: {e}") - self.sunset_time = datetime.datetime(year=now_time.year, - month=now_time.month, - day=now_time.day, hour=22) - self.sunrise_time = self.sunset_time + timedelta(hours=8) - - # check sunset times again in 24 hours - self.event_scheduler.schedule_event(self.get_sunset_time, - when=now_time + timedelta(hours=24), - name="ovos-shell.suntimes.check") - - def start_auto_night_mode(self, message=None): + Args: + message: Optional message received from the bus. + """ + self.sunrise_time, self.sunset_time = self.get_suntimes() # sync if self.auto_night_mode_enabled: - date = now_local() - self.event_scheduler.schedule_event(self.start_auto_night_mode, - when=date + timedelta(hours=1), - name="ovos-shell.night.mode.check") - if self.sunset_time < date < self.sunrise_time: - LOG.debug("It is night time") - self.bus.emit(Message("phal.brightness.control.auto.night.mode.enabled")) - else: - LOG.debug("It is day time") - # TODO - implement this message in shell / check if it exists - # i just made it up without checking - self.bus.emit(Message("phal.brightness.control.auto.night.mode.disabled")) + LOG.debug("It is daytime") + self.default_brightness = self.config.get("default_brightness", 100) + # reset homescreen to day mode + self.bus.emit(Message("ovos.homescreen.main_view.current_index.set", + {"current_index": 1})) + + self.event_scheduler.schedule_event(self.handle_sunset, + when=self.sunset_time, + name="ovos-shell.sunset") + + if not self.auto_dim_enabled: + # cancel the next unfired dim event + self._cancel_next_dim() + self._restore() + + def handle_sunset(self, message: Optional[Message] = None): + """ + Handle the sunset event for auto night mode. + + Args: + message: Optional message received from the bus. + """ + self.sunrise_time, self.sunset_time = self.get_suntimes() # sync + if self.auto_night_mode_enabled: + LOG.debug("It is nighttime") + self.default_brightness = self.config.get("night_default_brightness", 70) + self.set_brightness(self.default_brightness) + # show night clock in homescreen + self.bus.emit(Message("phal.brightness.control.auto.night.mode.enabled")) + # equivalent to + # self.bus.emit(Message("ovos.homescreen.main_view.current_index.set", {"current_index": 0})) + + self.event_scheduler.schedule_event(self.handle_sunrise, + when=self.sunrise_time, + name="ovos-shell.sunrise") + if not self.config.get("auto_dim"): + self.start_auto_dim() def stop_auto_night_mode(self): - LOG.debug("Stopping auto night mode") - self.auto_night_mode_enabled = False - self.event_scheduler.cancel_scheduled_event("ovos-shell.night.mode.check") - - def is_auto_night_mode_enabled(self): - display_config = get_ovos_shell_config() - self.auto_night_mode_enabled = display_config.get("auto_nightmode", False) - - if self.auto_night_mode_enabled: - self.start_auto_night_mode() - else: - self.stop_auto_night_mode() + """ + Stop the auto night mode functionality. + """ + if self.config.get("auto_nightmode"): + LOG.debug("Stopping auto night mode") + self.config["auto_nightmode"] = False + update_config("auto_nightmode", False) + + def get_suntimes(self) -> Tuple[datetime.datetime, datetime.datetime]: + sunrise = self.config.get("sunrise_time", "auto") + sunset = self.config.get("sunset_time", "auto") + sunset_time = None + sunrise_time = None + + reference = now_local() # now_local() is tz aware + + # check if sunrise has been explicitly configured by user + if ":" in sunrise: + hours, mins = sunrise.split(":") + sunrise_time = datetime.datetime(hour=int(hours), + minute=int(mins), + day=reference.day, + month=reference.month, + year=reference.year, + tzinfo=reference.tzinfo) + if reference > sunrise_time: + sunrise_time += timedelta(days=1) + + # check if sunset has been explicitly configured by user + if ":" in sunset: + hours, mins = sunset.split(":") + sunset_time = datetime.datetime(hour=int(hours), + minute=int(mins), + day=reference.day, + month=reference.month, + year=reference.year, + tzinfo=reference.tzinfo) + if reference > sunset_time: + sunset_time += timedelta(days=1) + + # auto determine sunrise/sunset + if sunrise_time is None or sunset_time is None: + LOG.debug("Determining sunset/sunrise times") + try: + location = Configuration()["location"] + lat = location["coordinate"]["latitude"] + lon = location["coordinate"]["longitude"] + tz = location["timezone"]["code"] + city = LocationInfo("Some city", "Some location", tz, lat, lon) + + s = sun(city.observer, date=reference) + s2 = sun(city.observer, date=reference + timedelta(days=1)) + if not sunset_time: + sunset_time = s["sunset"] + if reference > sunset_time: # get next sunset, today's already happened + sunset_time = s2["sunset"] + if not sunrise_time: + sunrise_time = s["sunrise"] + if reference > sunrise_time: # get next sunrise, today's already happened + sunrise_time = s2["sunrise"] + except: + LOG.exception("Failed to calculate suntimes! defaulting to 06:30 and 20:30") + if reference.hour > 7: + self.sunrise_time = datetime.datetime(hour=6, minute=30, month=reference.month, + day=reference.day + 1, year=reference.year) + else: + self.sunrise_time = datetime.datetime(hour=6, minute=30, month=reference.month, + day=reference.day, year=reference.year) + if reference.hour < 21: + self.sunset_time = datetime.datetime(hour=22, minute=30, month=reference.month, + day=reference.day, year=reference.year) + else: + self.sunset_time = datetime.datetime(hour=22, minute=30, month=reference.month, + day=reference.day + 1, year=reference.year) + # info logs + if self.sunrise_time is None or self.sunrise_time != sunrise_time: + LOG.info(f"Sunrise time: {sunrise_time}") + if self.sunset_time is None or self.sunset_time != sunset_time: + LOG.info(f"Sunset time: {sunset_time}") + return sunrise_time, sunset_time diff --git a/ovos_gui_plugin_shell_companion/config.py b/ovos_gui_plugin_shell_companion/config.py deleted file mode 100644 index 827328e..0000000 --- a/ovos_gui_plugin_shell_companion/config.py +++ /dev/null @@ -1,18 +0,0 @@ - -from os.path import isfile, join -import json -from ovos_utils.xdg_utils import xdg_config_home -from json_database import JsonStorage - - -def get_ovos_shell_config(): - # Paths to find the local display config - display_config_path_local = join(xdg_config_home(), "OvosDisplay.conf") - display_config_path_system = "/etc/xdg/OvosDisplay.conf" - local_display_config = JsonStorage(display_config_path_local) - if isfile(display_config_path_system): - with open(display_config_path_system) as f: - d = json.load(f) - local_display_config.merge(d) - return local_display_config - diff --git a/ovos_gui_plugin_shell_companion/cui.py b/ovos_gui_plugin_shell_companion/helpers.py similarity index 95% rename from ovos_gui_plugin_shell_companion/cui.py rename to ovos_gui_plugin_shell_companion/helpers.py index ab94a2b..5e114f3 100644 --- a/ovos_gui_plugin_shell_companion/cui.py +++ b/ovos_gui_plugin_shell_companion/helpers.py @@ -3,12 +3,26 @@ from ovos_bus_client import Message from ovos_config import Configuration +from ovos_config import LocalConf, USER_CONFIG from ovos_config.config import update_mycroft_config from ovos_utils.log import LOG +def update_config(k, v): + """helper to update config permanently (on mycroft.conf)""" + cfg = LocalConf(USER_CONFIG) + if "gui" not in cfg: + cfg["gui"] = {} + if "ovos-gui-plugin-shell-companion" not in cfg["gui"]: + cfg["gui"]["ovos-gui-plugin-shell-companion"] = {} + if cfg["gui"]["ovos-gui-plugin-shell-companion"].get(k) != v: + cfg["gui"]["ovos-gui-plugin-shell-companion"][k] = v + cfg.store() + + class ConfigUIManager: """ handle UI for developer settings dropdown menu in ovos-shell """ + def __init__(self, bus): self.bus = bus self.settings_meta = {} diff --git a/test/__init__.py b/test/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/test/requirements.txt b/test/requirements.txt deleted file mode 100644 index 05206bb..0000000 --- a/test/requirements.txt +++ /dev/null @@ -1,8 +0,0 @@ -coveralls==1.8.2 -flake8==3.7.9 -pytest==5.2.4 -pytest-cov==2.8.1 -cov-core==1.15.0 -sphinx==2.2.1 -sphinx-rtd-theme==0.4.3 -ovos_plugin_manager>=0.0.23a9 \ No newline at end of file diff --git a/test/unittests/__init__.py b/test/unittests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/test/unittests/mocks.py b/test/unittests/mocks.py deleted file mode 100644 index b9c7507..0000000 --- a/test/unittests/mocks.py +++ /dev/null @@ -1,56 +0,0 @@ -# Copyright 2019 Mycroft AI Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -from copy import deepcopy -from unittest.mock import Mock - -from ovos_config.config import LocalConf -from ovos_config.locations import DEFAULT_CONFIG - -__CONFIG = LocalConf(DEFAULT_CONFIG) - - -class AnyCallable: - """Class matching any callable. - - Useful for assert_called_with arguments. - """ - def __eq__(self, other): - return callable(other) - - -def base_config(): - """Base config used when mocking. - - Preload to skip hitting the disk each creation time but make a copy - so modifications don't mutate it. - - Returns: - (dict) Mycroft default configuration - """ - return deepcopy(__CONFIG) - - -def mock_config(temp_dir): - """Supply a reliable return value for the Configuration.get() method.""" - get_config_mock = Mock() - config = base_config() - config['skills']['priority_skills'] = ['foobar'] - config['data_dir'] = str(temp_dir) - config['server']['metrics'] = False - config['enclosure'] = {} - - get_config_mock.return_value = config - return get_config_mock - diff --git a/test/unittests/test_smartspeaker_extension.py b/test/unittests/test_smartspeaker_extension.py deleted file mode 100644 index 7d77c18..0000000 --- a/test/unittests/test_smartspeaker_extension.py +++ /dev/null @@ -1,52 +0,0 @@ - -from ovos_bus_client import Message - -from unittest import mock -from unittest.mock import patch - -from ovos_config import Configuration -from ovos_utils.gui import GUIInterface -from ovos_utils.messagebus import FakeBus -from ovos_gui_plugin_shell_companion import OVOSShellCompanionExtension as SmartSpeakerExtension -from .mocks import base_config - -PATCH_MODULE = "ovos_gui.extensions" - - -# Add Unit Tests For SmartSpeakerExtension - -class TestSmartSpeakerExtension: - @patch.object(Configuration, 'get') - def test_smartspeaker_set_backend_type(self, mock_get): - config = base_config() - config.merge( - { - 'gui': { - 'extension': 'smartspeaker' - } - }) - mock_get.return_value = config - smartSpeaker = SmartSpeakerExtension(config, FakeBus(), - GUIInterface("test", FakeBus())) - smartSpeaker.set_backend_type = mock.Mock() - message_data = Message("ovos.pairing.set.backend", - {'backend': 'unknown'}) - smartSpeaker.set_backend_type(message_data) - smartSpeaker.set_backend_type.assert_any_call(message_data) - - @patch.object(Configuration, 'get') - def test_smartspeaker_start_homescreen_process(self, mock_get): - config = base_config() - config.merge( - { - 'gui': { - 'extension': 'smartspeaker' - } - }) - mock_get.return_value = config - smartSpeaker = SmartSpeakerExtension(config, FakeBus(), - GUIInterface("test", FakeBus())) - smartSpeaker.start_homescreen_process = mock.Mock() - message_data = Message("ovos.pairing.process.completed", {}) - smartSpeaker.start_homescreen_process(message_data) - smartSpeaker.start_homescreen_process.assert_any_call(message_data)