From 9416ab4ab77ede9405b488a51f75f5c8f413edde Mon Sep 17 00:00:00 2001 From: Maximilian Giller Date: Mon, 17 Oct 2022 16:25:36 +0200 Subject: [PATCH 01/14] Created gitignore --- .gitignore | 160 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..68bc17f --- /dev/null +++ b/.gitignore @@ -0,0 +1,160 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ From bfbd3a2833b49dc1897cfc797d49de1310f35d2f Mon Sep 17 00:00:00 2001 From: Maximilian Giller Date: Wed, 19 Oct 2022 03:43:58 +0200 Subject: [PATCH 02/14] Defined some basic models and thought about a config file --- mash.yaml | 11 +++++++ requirements.txt | 6 ++++ src/models/devices.py | 39 ++++++++++++++++++++++++ src/models/groups.py | 71 +++++++++++++++++++++++++++++++++++++++++++ src/models/helper.py | 10 ++++++ src/models/light.py | 24 +++++++++++++++ 6 files changed, 161 insertions(+) create mode 100644 mash.yaml create mode 100644 src/models/devices.py create mode 100644 src/models/groups.py create mode 100644 src/models/helper.py create mode 100644 src/models/light.py diff --git a/mash.yaml b/mash.yaml new file mode 100644 index 0000000..b8a389c --- /dev/null +++ b/mash.yaml @@ -0,0 +1,11 @@ +# An example config file for MaSH + +services: + philips-hue: + ip: 192.168.178.42 + +rooms: + - name: Office + - name: Hallway + adjacent: + - Office diff --git a/requirements.txt b/requirements.txt index 27dcd81..01ecf54 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,12 @@ smbus2 vl53l1x +# To parse the config file +pyyaml + +# To hot reload the config file +watchdog + # For Philips Hue Counter phue diff --git a/src/models/devices.py b/src/models/devices.py new file mode 100644 index 0000000..041af03 --- /dev/null +++ b/src/models/devices.py @@ -0,0 +1,39 @@ +import abc + +from models.light import LightColor + +class GenericDevice: + """A generic device.""" + def __init__(self, name: str): + self.name = name + + +class SwitchDevice(GenericDevice): + """Abstract device that can be turned on and off.""" + + def toggle(self): + self.is_on = not self.is_on + + @property + @abc.abstractmethod + def is_on(self) -> bool: + raise NotImplementedError + + @is_on.setter + @abc.abstractmethod + def is_on(self, is_on: bool): + raise NotImplementedError + + +class LightDevice(SwitchDevice): + """Abstract device that can be turned on and off and has a color.""" + + @property + @abc.abstractmethod + def color(self) -> LightColor: + raise NotImplementedError + + @color.setter + @abc.abstractmethod + def color(self, color: LightColor): + raise NotImplementedError diff --git a/src/models/groups.py b/src/models/groups.py new file mode 100644 index 0000000..e0f0e2d --- /dev/null +++ b/src/models/groups.py @@ -0,0 +1,71 @@ +from models.helper import filter_devices +from models.devices import GenericDevice, LightDevice, SwitchDevice +from models.light import LightColor, LightScene + + +class DeviceGroup(LightDevice): + """A group of devices that allows group operations.""" + + def __init__(self, devices: list[GenericDevice]): + self._devices = devices + + @property + def devices(self) -> list[GenericDevice]: + return self._devices + + @property + def switches(self) -> list[SwitchDevice]: + return filter_devices(self.devices, SwitchDevice) + + @property + def lights(self) -> list[LightDevice]: + return filter_devices(self.devices, LightDevice) + + @property + def is_on(self) -> bool: + """Returns true if any device is on.""" + return any(device.is_on for device in self.switches) + + @is_on.setter + def is_on(self, is_on: bool): + """Sets all devices to the same state.""" + for device in self.switches: + device.is_on = is_on + + @property + def color(self) -> LightColor: + """Returns the color of the first light in the group.""" + return self.lights[0].color + + @color.setter + def color(self, color: LightColor): + """Sets all lights in the group to the same color.""" + for device in self.lights: + device.color = color + + @property + def scene(self) -> LightScene: + """Returns the current scene of the group.""" + return LightScene(self.name, {device: device.color for device in self.lights}) + + +class Room(DeviceGroup): + """A group of devices that has additional properties a can be seen as a room.""" + + def __init__(self, name: str, devices: list[GenericDevice], people_count: int = 0): + """ + Args: + name (str): Name of the room. + devices (list[GenericDevice]): Devices in this room. + people_count (int, optional): Number of people in this room. Defaults to 0. + """ + super().__init__(devices) + self.name = name + self._people_count = people_count + + @property + def people_count(self) -> int: + return self._people_count + + def __str__(self): + return self.name diff --git a/src/models/helper.py b/src/models/helper.py new file mode 100644 index 0000000..52fe107 --- /dev/null +++ b/src/models/helper.py @@ -0,0 +1,10 @@ +from typing import TypeVar +from models.devices import GenericDevice, LightDevice, SwitchDevice + + +DEVICE_TYPE = TypeVar("DEVICE_TYPE", type(GenericDevice), type(SwitchDevice), type(LightDevice)) + + +def filter_devices(devices: list[GenericDevice], type: DEVICE_TYPE) -> list[DEVICE_TYPE]: + """Filters out devices that are not of a specific type.""" + return [device for device in devices if isinstance(device, DEVICE_TYPE)] diff --git a/src/models/light.py b/src/models/light.py new file mode 100644 index 0000000..0dd0e2e --- /dev/null +++ b/src/models/light.py @@ -0,0 +1,24 @@ +from models.devices import LightDevice + + +class LightColor: + """The color of a light source.""" + + def __init__(self, red: int, green: int, blue: int, is_on: bool = True): + self.red = red + self.green = green + self.blue = blue + self.is_on = is_on + + +class LightScene: + """A scene describing a state of a collection of light sources.""" + + def __init__(self, name: str, device_colors: dict[LightDevice, LightColor]): + self.name = name + self.device_colors = device_colors + + def set_scene(self): + """Sets the scene on all devices.""" + for device, color in self.device_colors.items(): + device.color = color From c2f4765c091fb93a8fd03f3a8ffdc5580bf3d314 Mon Sep 17 00:00:00 2001 From: Maximilian Giller Date: Fri, 28 Oct 2022 09:30:24 +0200 Subject: [PATCH 03/14] Refined config template --- mash.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/mash.yaml b/mash.yaml index b8a389c..fb2c2c2 100644 --- a/mash.yaml +++ b/mash.yaml @@ -4,8 +4,16 @@ services: philips-hue: ip: 192.168.178.42 +lights: + - name: Hallway Ceiling + philips-hue: Hallway Ceiling + - name: Desk + philips-hue: Desk Plug + rooms: - name: Office + philips-hue: Work Room - name: Hallway + philips-hue: Hallways adjacent: - Office From f95f8c7d1959bddda38db68f41446aaad5e759ec Mon Sep 17 00:00:00 2001 From: Maximilian Giller Date: Fri, 28 Oct 2022 09:30:40 +0200 Subject: [PATCH 04/14] Hot reloader for config structure --- src/config/hotreloader.py | 41 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 src/config/hotreloader.py diff --git a/src/config/hotreloader.py b/src/config/hotreloader.py new file mode 100644 index 0000000..3971239 --- /dev/null +++ b/src/config/hotreloader.py @@ -0,0 +1,41 @@ +import logging + +DEPENDENCY_INSTALLED = None + +try: + from watchdog.observers import Observer + from watchdog.events import ( + FileSystemEventHandler, + FileCreatedEvent, + FileModifiedEvent, + ) + + DEPENDENCY_INSTALLED = True + logging.debug("Watchdog imported successfully. Hot reloading available.") +except ImportError: + DEPENDENCY_INSTALLED = False + logging.info("Watchdog is not installed. Hot reloading unavailable.") + + +class HotReloader(FileSystemEventHandler): + def __init__(self, path): + if not DEPENDENCY_INSTALLED: + return + + self.path = path + self.observer = Observer() + self.observer.schedule(self, path, recursive=True) + self.observer.start() + + def __del__(self): + if not DEPENDENCY_INSTALLED: + return + + self.observer.stop() + self.observer.join() + + def on_modified(self, event: FileModifiedEvent): + logging.info("Config file modified. Triggering hot reload.") + + def on_created(self, event: FileCreatedEvent): + logging.info("New config file created. Triggering hot reload.") From 457f4484f8b6d5c36f2a2ad2581989dd95b7f27f Mon Sep 17 00:00:00 2001 From: Maximilian Giller Date: Wed, 2 Nov 2022 12:54:41 +0100 Subject: [PATCH 05/14] Better separation of concepts for transition between devices and groups --- src/models/devices.py | 5 +++++ src/models/groups.py | 6 +++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/models/devices.py b/src/models/devices.py index 041af03..d8333f4 100644 --- a/src/models/devices.py +++ b/src/models/devices.py @@ -37,3 +37,8 @@ class LightDevice(SwitchDevice): @abc.abstractmethod def color(self, color: LightColor): raise NotImplementedError + + +class AllDevice(LightDevice): + """Abstract device class that offers all device operations.""" + pass diff --git a/src/models/groups.py b/src/models/groups.py index e0f0e2d..104f8a4 100644 --- a/src/models/groups.py +++ b/src/models/groups.py @@ -1,10 +1,10 @@ from models.helper import filter_devices -from models.devices import GenericDevice, LightDevice, SwitchDevice +from models.devices import GenericDevice, LightDevice, SwitchDevice, AllDevice from models.light import LightColor, LightScene -class DeviceGroup(LightDevice): - """A group of devices that allows group operations.""" +class DeviceGroup(AllDevice): + """A group of devices that allows group operations. Inherits from a complex device to offer group operations for all devices.""" def __init__(self, devices: list[GenericDevice]): self._devices = devices From 0b247755d329cf54d30b88a5175b2b7a79c30e20 Mon Sep 17 00:00:00 2001 From: Maximilian Giller Date: Wed, 2 Nov 2022 12:54:59 +0100 Subject: [PATCH 06/14] Basic concept of home --- src/models/helper.py | 3 ++- src/models/home.py | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 src/models/home.py diff --git a/src/models/helper.py b/src/models/helper.py index 52fe107..8acc8dd 100644 --- a/src/models/helper.py +++ b/src/models/helper.py @@ -1,8 +1,9 @@ from typing import TypeVar from models.devices import GenericDevice, LightDevice, SwitchDevice +from models.groups import DeviceGroup, Room -DEVICE_TYPE = TypeVar("DEVICE_TYPE", type(GenericDevice), type(SwitchDevice), type(LightDevice)) +DEVICE_TYPE = TypeVar("DEVICE_TYPE", type(GenericDevice), type(SwitchDevice), type(LightDevice), type(Room), type(DeviceGroup)) def filter_devices(devices: list[GenericDevice], type: DEVICE_TYPE) -> list[DEVICE_TYPE]: diff --git a/src/models/home.py b/src/models/home.py new file mode 100644 index 0000000..9eb92a6 --- /dev/null +++ b/src/models/home.py @@ -0,0 +1,19 @@ +from models.groups import DeviceGroup, Room +from models.helper import filter_devices + + +class Home(Room): + """Combines all elements of a smart home.""" + + location: str + """Physical location of the home, useful for sunset and sunrise times.""" + + @property + def rooms(self) -> list[Room]: + """Returns all rooms in the home.""" + return filter_devices(self.devices, Room) + + @property + def groups(self) -> list[DeviceGroup]: + """Returns all groups in the home.""" + return filter_devices(self.devices, DeviceGroup) From 069ee75bf32c4f48d3e866a22459dce8dae9aae6 Mon Sep 17 00:00:00 2001 From: Maximilian Giller Date: Fri, 4 Nov 2022 09:03:18 +0100 Subject: [PATCH 07/14] Adds exception when no device is found --- src/models/exceptions.py | 2 ++ src/models/helper.py | 8 +++++++- 2 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 src/models/exceptions.py diff --git a/src/models/exceptions.py b/src/models/exceptions.py new file mode 100644 index 0000000..57ff946 --- /dev/null +++ b/src/models/exceptions.py @@ -0,0 +1,2 @@ +class NoDeviceFoundError(Exception): + pass \ No newline at end of file diff --git a/src/models/helper.py b/src/models/helper.py index 8acc8dd..8e4f877 100644 --- a/src/models/helper.py +++ b/src/models/helper.py @@ -1,5 +1,6 @@ from typing import TypeVar from models.devices import GenericDevice, LightDevice, SwitchDevice +from models.exceptions import NoDeviceFoundError from models.groups import DeviceGroup, Room @@ -8,4 +9,9 @@ DEVICE_TYPE = TypeVar("DEVICE_TYPE", type(GenericDevice), type(SwitchDevice), ty def filter_devices(devices: list[GenericDevice], type: DEVICE_TYPE) -> list[DEVICE_TYPE]: """Filters out devices that are not of a specific type.""" - return [device for device in devices if isinstance(device, DEVICE_TYPE)] + filtered_devices: list[type] = [device for device in devices if isinstance(device, type)] + + if len(filtered_devices) == 0: + raise NoDeviceFoundError(f"No devices of type {type} found.") + + return filtered_devices \ No newline at end of file From f8cdd0f94d251a513fb7815b3c7d1bb8c796e82a Mon Sep 17 00:00:00 2001 From: Maximilian Giller Date: Fri, 4 Nov 2022 09:03:53 +0100 Subject: [PATCH 08/14] Formatting --- src/config/hotreloader.py | 4 ++-- src/models/devices.py | 3 +++ src/models/exceptions.py | 2 +- src/models/helper.py | 23 +++++++++++++++++------ 4 files changed, 23 insertions(+), 9 deletions(-) diff --git a/src/config/hotreloader.py b/src/config/hotreloader.py index 3971239..a6377c4 100644 --- a/src/config/hotreloader.py +++ b/src/config/hotreloader.py @@ -21,7 +21,7 @@ class HotReloader(FileSystemEventHandler): def __init__(self, path): if not DEPENDENCY_INSTALLED: return - + self.path = path self.observer = Observer() self.observer.schedule(self, path, recursive=True) @@ -30,7 +30,7 @@ class HotReloader(FileSystemEventHandler): def __del__(self): if not DEPENDENCY_INSTALLED: return - + self.observer.stop() self.observer.join() diff --git a/src/models/devices.py b/src/models/devices.py index d8333f4..4016d66 100644 --- a/src/models/devices.py +++ b/src/models/devices.py @@ -2,8 +2,10 @@ import abc from models.light import LightColor + class GenericDevice: """A generic device.""" + def __init__(self, name: str): self.name = name @@ -41,4 +43,5 @@ class LightDevice(SwitchDevice): class AllDevice(LightDevice): """Abstract device class that offers all device operations.""" + pass diff --git a/src/models/exceptions.py b/src/models/exceptions.py index 57ff946..afc2cbd 100644 --- a/src/models/exceptions.py +++ b/src/models/exceptions.py @@ -1,2 +1,2 @@ class NoDeviceFoundError(Exception): - pass \ No newline at end of file + pass diff --git a/src/models/helper.py b/src/models/helper.py index 8e4f877..78c41c1 100644 --- a/src/models/helper.py +++ b/src/models/helper.py @@ -4,14 +4,25 @@ from models.exceptions import NoDeviceFoundError from models.groups import DeviceGroup, Room -DEVICE_TYPE = TypeVar("DEVICE_TYPE", type(GenericDevice), type(SwitchDevice), type(LightDevice), type(Room), type(DeviceGroup)) +DEVICE_TYPE = TypeVar( + "DEVICE_TYPE", + type(GenericDevice), + type(SwitchDevice), + type(LightDevice), + type(Room), + type(DeviceGroup), +) -def filter_devices(devices: list[GenericDevice], type: DEVICE_TYPE) -> list[DEVICE_TYPE]: +def filter_devices( + devices: list[GenericDevice], type: DEVICE_TYPE +) -> list[DEVICE_TYPE]: """Filters out devices that are not of a specific type.""" - filtered_devices: list[type] = [device for device in devices if isinstance(device, type)] - + filtered_devices: list[type] = [ + device for device in devices if isinstance(device, type) + ] + if len(filtered_devices) == 0: raise NoDeviceFoundError(f"No devices of type {type} found.") - - return filtered_devices \ No newline at end of file + + return filtered_devices From 9ec399a714c6605117a726d77ecdb7b40ab7c1cf Mon Sep 17 00:00:00 2001 From: Maximilian Giller Date: Fri, 4 Nov 2022 09:07:17 +0100 Subject: [PATCH 09/14] Updates gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 68bc17f..40def25 100644 --- a/.gitignore +++ b/.gitignore @@ -158,3 +158,4 @@ cython_debug/ # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ +.vscode/settings.json From 9483b98e344f6e213f88b9ef94e2d4a9278750ff Mon Sep 17 00:00:00 2001 From: Maximilian Giller Date: Fri, 4 Nov 2022 09:07:30 +0100 Subject: [PATCH 10/14] Fixes typing --- src/models/helper.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/models/helper.py b/src/models/helper.py index 78c41c1..e167049 100644 --- a/src/models/helper.py +++ b/src/models/helper.py @@ -18,7 +18,7 @@ def filter_devices( devices: list[GenericDevice], type: DEVICE_TYPE ) -> list[DEVICE_TYPE]: """Filters out devices that are not of a specific type.""" - filtered_devices: list[type] = [ + filtered_devices: list[DEVICE_TYPE] = [ device for device in devices if isinstance(device, type) ] From f83c6d0570936b328c596ee8fea4d6ea955624ac Mon Sep 17 00:00:00 2001 From: Maximilian Giller Date: Fri, 4 Nov 2022 09:19:18 +0100 Subject: [PATCH 11/14] Fixes unbound import of dependency --- src/config/hotreloader.py | 49 +++++++++++++++++---------------------- 1 file changed, 21 insertions(+), 28 deletions(-) diff --git a/src/config/hotreloader.py b/src/config/hotreloader.py index a6377c4..0ca4010 100644 --- a/src/config/hotreloader.py +++ b/src/config/hotreloader.py @@ -1,7 +1,5 @@ import logging -DEPENDENCY_INSTALLED = None - try: from watchdog.observers import Observer from watchdog.events import ( @@ -10,32 +8,27 @@ try: FileModifiedEvent, ) - DEPENDENCY_INSTALLED = True + class HotReloader(FileSystemEventHandler): + """Might be unbound, if watchdog is not installed. Check if equal to None before use.""" + + def __init__(self, path): + self.path = path + self.observer = Observer() + self.observer.schedule(self, path, recursive=True) + self.observer.start() + + def __del__(self): + self.observer.stop() + self.observer.join() + + def on_modified(self, event: FileModifiedEvent): + logging.info("Config file modified. Triggering hot reload.") + + def on_created(self, event: FileCreatedEvent): + logging.info("New config file created. Triggering hot reload.") + logging.debug("Watchdog imported successfully. Hot reloading available.") + + except ImportError: - DEPENDENCY_INSTALLED = False logging.info("Watchdog is not installed. Hot reloading unavailable.") - - -class HotReloader(FileSystemEventHandler): - def __init__(self, path): - if not DEPENDENCY_INSTALLED: - return - - self.path = path - self.observer = Observer() - self.observer.schedule(self, path, recursive=True) - self.observer.start() - - def __del__(self): - if not DEPENDENCY_INSTALLED: - return - - self.observer.stop() - self.observer.join() - - def on_modified(self, event: FileModifiedEvent): - logging.info("Config file modified. Triggering hot reload.") - - def on_created(self, event: FileCreatedEvent): - logging.info("New config file created. Triggering hot reload.") From 6ee324095e32b147fde09bcc6fb94d7299b395e8 Mon Sep 17 00:00:00 2001 From: Maximilian Giller Date: Fri, 4 Nov 2022 14:34:56 +0100 Subject: [PATCH 12/14] Separates old files --- src/{services => old}/philips_hue.py | 0 src/{ => old}/philips_hue_counter.py | 2 +- src/{ => old}/sensors/__init__.py | 0 src/{ => old}/sensors/people_counter.py | 0 src/{ => old}/sensors/tof_sensor.py | 0 src/{ => old}/sensors/vl53l1x_sensor.py | 0 src/{ => old}/statistics/statistics.py | 32 ++++++++++++------------- src/{ => old}/timeloop/LICENSE | 0 src/{ => old}/timeloop/__init__.py | 0 src/{ => old}/timeloop/app.py | 0 src/{ => old}/timeloop/exceptions.py | 0 src/{ => old}/timeloop/helpers.py | 0 src/{ => old}/timeloop/job.py | 0 13 files changed, 16 insertions(+), 18 deletions(-) rename src/{services => old}/philips_hue.py (100%) rename src/{ => old}/philips_hue_counter.py (99%) rename src/{ => old}/sensors/__init__.py (100%) rename src/{ => old}/sensors/people_counter.py (100%) rename src/{ => old}/sensors/tof_sensor.py (100%) rename src/{ => old}/sensors/vl53l1x_sensor.py (100%) rename src/{ => old}/statistics/statistics.py (80%) rename src/{ => old}/timeloop/LICENSE (100%) rename src/{ => old}/timeloop/__init__.py (100%) rename src/{ => old}/timeloop/app.py (100%) rename src/{ => old}/timeloop/exceptions.py (100%) rename src/{ => old}/timeloop/helpers.py (100%) rename src/{ => old}/timeloop/job.py (100%) diff --git a/src/services/philips_hue.py b/src/old/philips_hue.py similarity index 100% rename from src/services/philips_hue.py rename to src/old/philips_hue.py diff --git a/src/philips_hue_counter.py b/src/old/philips_hue_counter.py similarity index 99% rename from src/philips_hue_counter.py rename to src/old/philips_hue_counter.py index a63064c..db30d0e 100644 --- a/src/philips_hue_counter.py +++ b/src/old/philips_hue_counter.py @@ -1,6 +1,6 @@ from datetime import datetime, time, timedelta from typing import Dict -from services.philips_hue import PhilipsHue +from old.philips_hue import PhilipsHue from sensors import PeopleCounter, Directions, VL53L1XSensor import logging import json diff --git a/src/sensors/__init__.py b/src/old/sensors/__init__.py similarity index 100% rename from src/sensors/__init__.py rename to src/old/sensors/__init__.py diff --git a/src/sensors/people_counter.py b/src/old/sensors/people_counter.py similarity index 100% rename from src/sensors/people_counter.py rename to src/old/sensors/people_counter.py diff --git a/src/sensors/tof_sensor.py b/src/old/sensors/tof_sensor.py similarity index 100% rename from src/sensors/tof_sensor.py rename to src/old/sensors/tof_sensor.py diff --git a/src/sensors/vl53l1x_sensor.py b/src/old/sensors/vl53l1x_sensor.py similarity index 100% rename from src/sensors/vl53l1x_sensor.py rename to src/old/sensors/vl53l1x_sensor.py diff --git a/src/statistics/statistics.py b/src/old/statistics/statistics.py similarity index 80% rename from src/statistics/statistics.py rename to src/old/statistics/statistics.py index eac2a96..9ba75bd 100644 --- a/src/statistics/statistics.py +++ b/src/old/statistics/statistics.py @@ -15,13 +15,14 @@ with open(FILE_PATH, "r") as file: content = file.readlines() -def parse_log_entry(entry: Dict) -> Dict: +def parse_log_entry(entry: Dict) -> Dict | bool: # Only keep last record of a sequence if not is_last_in_sequence(entry): return False entry["dateTime"] = datetime.strptime( - str(entry["dateTime"])[:19], "%Y-%m-%d %H:%M:%S") + str(entry["dateTime"])[:19], "%Y-%m-%d %H:%M:%S" + ) if entry["dateTime"] < datetime(2022, 1, 1): return False @@ -31,15 +32,15 @@ def parse_log_entry(entry: Dict) -> Dict: def is_last_in_sequence(entry: Dict) -> Boolean: indoor = entry["directionState"]["indoor"] outdoor = entry["directionState"]["outdoor"] - + if len(indoor) <= 0 or len(outdoor) <= 0: return False - + end_key = "end_distance" # Check version if end_key not in indoor[-1]: end_key = "end" - + if indoor[-1][end_key] is None or outdoor[-1][end_key] is None: return False @@ -47,20 +48,20 @@ def is_last_in_sequence(entry: Dict) -> Boolean: # Collect -log = [json.loads(line.strip("\x00")) for line in content] +log: list[Dict] = [json.loads(line.strip("\x00")) for line in content] print("Number of total entries:", len(log)) # Parse & Filter -log = [parse_log_entry(entry) for entry in log if parse_log_entry(entry)] +log = [parse_log_entry(entry) for entry in log if parse_log_entry(entry)] # type: ignore print("Number of filtered entries:", len(log)) # Render fig, ax = plt.subplots() # Create a figure containing a single axes. times: list[datetime] = [entry["dateTime"] for entry in log] counts: list[int] = [entry["previousPeopleCount"] for entry in log] -ax.step(times, counts, where="pre") +ax.step(times, counts, where="pre") # type: ignore plt.show() -print("-"*20) +print("-" * 20) # Print stats @@ -70,12 +71,11 @@ walk_unders = [entry for entry in log if entry["countChange"] == 0] print("Number of walk-ins:", len(walk_ins)) print("Number of walk-outs:", len(walk_outs)) print("Number of walk-unders:", len(walk_unders)) -print("-"*20) +print("-" * 20) # Calculate faults for c, n in zip(list(range(len(log))), list(range(len(log)))[1:]): - estimated_count: int = log[c]["previousPeopleCount"] + \ - log[c]["countChange"] + estimated_count: int = log[c]["previousPeopleCount"] + log[c]["countChange"] faulty: bool = estimated_count != log[n]["previousPeopleCount"] log[c]["faulty"] = faulty log[c]["faultyCount"] = log[c]["previousPeopleCount"] if faulty else None @@ -85,11 +85,9 @@ fault_count = sum(1 for entry in log if entry["faulty"]) print("Number of faults:", fault_count) print("Percentage of faults:", fault_count / len(log) * 100, "%") -print("-"*20) -faulty_off = [entry for entry in log if entry["faulty"] - and entry["faultyCount"] == 0] -faulty_on = [entry for entry in log if entry["faulty"] - and entry["faultyCount"] != 0] +print("-" * 20) +faulty_off = [entry for entry in log if entry["faulty"] and entry["faultyCount"] == 0] +faulty_on = [entry for entry in log if entry["faulty"] and entry["faultyCount"] != 0] print("Number of false-0:", len(faulty_off)) print("Number of false-1:", len(faulty_on)) print("Percentage of false-0:", len(faulty_off) / fault_count * 100, "%") diff --git a/src/timeloop/LICENSE b/src/old/timeloop/LICENSE similarity index 100% rename from src/timeloop/LICENSE rename to src/old/timeloop/LICENSE diff --git a/src/timeloop/__init__.py b/src/old/timeloop/__init__.py similarity index 100% rename from src/timeloop/__init__.py rename to src/old/timeloop/__init__.py diff --git a/src/timeloop/app.py b/src/old/timeloop/app.py similarity index 100% rename from src/timeloop/app.py rename to src/old/timeloop/app.py diff --git a/src/timeloop/exceptions.py b/src/old/timeloop/exceptions.py similarity index 100% rename from src/timeloop/exceptions.py rename to src/old/timeloop/exceptions.py diff --git a/src/timeloop/helpers.py b/src/old/timeloop/helpers.py similarity index 100% rename from src/timeloop/helpers.py rename to src/old/timeloop/helpers.py diff --git a/src/timeloop/job.py b/src/old/timeloop/job.py similarity index 100% rename from src/timeloop/job.py rename to src/old/timeloop/job.py From 54eb509d4fc4fc6ca7e0e435e382cbc47956f1df Mon Sep 17 00:00:00 2001 From: Maximilian Giller Date: Fri, 4 Nov 2022 14:35:31 +0100 Subject: [PATCH 13/14] Refines config parser and gives basic structure for overall framework --- .../{hotreloader.py => hotdogtrigger.py} | 18 ++++--- src/config/parser.py | 49 +++++++++++++++++++ src/config/reloadtrigger.py | 26 ++++++++++ src/mash.py | 48 ++++++++++++++++++ 4 files changed, 133 insertions(+), 8 deletions(-) rename src/config/{hotreloader.py => hotdogtrigger.py} (53%) create mode 100644 src/config/parser.py create mode 100644 src/config/reloadtrigger.py create mode 100644 src/mash.py diff --git a/src/config/hotreloader.py b/src/config/hotdogtrigger.py similarity index 53% rename from src/config/hotreloader.py rename to src/config/hotdogtrigger.py index 0ca4010..86f8c80 100644 --- a/src/config/hotreloader.py +++ b/src/config/hotdogtrigger.py @@ -1,5 +1,7 @@ import logging +from config.reloadtrigger import ReloadTrigger + try: from watchdog.observers import Observer from watchdog.events import ( @@ -8,11 +10,11 @@ try: FileModifiedEvent, ) - class HotReloader(FileSystemEventHandler): - """Might be unbound, if watchdog is not installed. Check if equal to None before use.""" + class HotDogTrigger(ReloadTrigger, FileSystemEventHandler): + """Trigger for config files. Might be unbound, if watchdog is not installed. Check if equal to None before use.""" - def __init__(self, path): - self.path = path + def __init__(self, path: str): + super().__init__(path) self.observer = Observer() self.observer.schedule(self, path, recursive=True) self.observer.start() @@ -22,13 +24,13 @@ try: self.observer.join() def on_modified(self, event: FileModifiedEvent): - logging.info("Config file modified. Triggering hot reload.") + self.__trigger_reload() def on_created(self, event: FileCreatedEvent): - logging.info("New config file created. Triggering hot reload.") + self.__trigger_reload() - logging.debug("Watchdog imported successfully. Hot reloading available.") + logging.debug("Watchdog imported successfully. HotDogTrigger available.") except ImportError: - logging.info("Watchdog is not installed. Hot reloading unavailable.") + logging.info("Watchdog is not installed. HotDogTrigger unavailable.") diff --git a/src/config/parser.py b/src/config/parser.py new file mode 100644 index 0000000..5bef0ea --- /dev/null +++ b/src/config/parser.py @@ -0,0 +1,49 @@ +import abc +from typing import Any + +from models.home import Home + + +class ConfigParser: + def get_config(self) -> Home: # TODO: Check type + """Load and parse the config.""" + return self.__parse_config(self.__load_config()) + + @abc.abstractmethod + def __load_config(self) -> Any: + """Load the config.""" + raise NotImplementedError() + + @abc.abstractmethod + def __parse_config(self, config) -> Home: + """Parse the config.""" + raise NotImplementedError() + + +class FileConfigParser(ConfigParser): + """Scaffoling for parsers based on files.""" + + path: str + """Path to the config file.""" + + def __init__(self, path: str): + self.path = path + + +class YAMLConfig(FileConfigParser): + """Loads and parses config from a YAML file.""" + + def __init__(self, path: str): + super().__init__(path) + + def __load_config(self) -> dict: + """Loads config from a YAML file.""" + import yaml + + with open(self.path, "r") as file: + return yaml.safe_load(file) + + def __parse_config(self, config: dict) -> Home: + """Parses config in dict format. Usually based on JSON or YAML.""" + # TODO: Implement + raise NotImplementedError() diff --git a/src/config/reloadtrigger.py b/src/config/reloadtrigger.py new file mode 100644 index 0000000..c0129a0 --- /dev/null +++ b/src/config/reloadtrigger.py @@ -0,0 +1,26 @@ +class ReloadTrigger(object): + """Abstract interface for config hot reloading.""" + + path: str + """Path to the config file.""" + + callbacks: list + """List of functions to callback on reload of config.""" + + def __init__(self, path: str): + """Initialize the trigger. + + Args: + path (str): Path to the config file. + """ + self.path = path + self.callbacks = [] + + def on_reload(self, callback): + """Register a callback to be called when the config is reloaded.""" + self.callbacks.append(callback) + + def __trigger_reload(self): + """Trigger a reload of the config.""" + for callback in self.callbacks: + callback() diff --git a/src/mash.py b/src/mash.py new file mode 100644 index 0000000..1a7f995 --- /dev/null +++ b/src/mash.py @@ -0,0 +1,48 @@ +from config.parser import ConfigParser +from config.reloadtrigger import ReloadTrigger +from models.home import Home + + +class MaSH: + """Max' Smart Home. Smart home automation framework.""" + + config_parser: ConfigParser + """Parser for the config file.""" + + config_trigger: list[ReloadTrigger] + """Active triggers for reloading the config.""" + + home: Home + """Home to control and make smart.""" + + def __init__(self, config_parser: ConfigParser) -> None: + """Initialize the framework. + + Args: + config_parser (ConfigParser): Parser for the config file. + """ + self.config_parser = config_parser + self.config_trigger = [] + + self.reload_config() + + def reload_config(self) -> None: + """Reload the config.""" + self.home = self.config_parser.get_config() # TODO: Check type + + def register_config_trigger(self, trigger: ReloadTrigger) -> bool: + """Register a trigger for hot-reloading the config. + + Args: + trigger (ReloadTrigger): Trigger for hot-reloading the config based on ReloadTrigger. + + Returns: + bool: True if the trigger was registered, False if not. + """ + if trigger is None or trigger in self.config_trigger: + return False + + trigger.on_reload(self.reload_config) + self.config_trigger.append(trigger) + + return True From 8a5b86f7f355728d9dedc6778a2eaba2683307cb Mon Sep 17 00:00:00 2001 From: Maximilian Giller Date: Wed, 9 Nov 2022 20:08:41 +0100 Subject: [PATCH 14/14] Restructures devices, and breaks group implementation --- src/models/colors.py | 23 ++++++++++++++ src/models/devices.py | 47 ---------------------------- src/models/devices/__init__.py | 9 ++++++ src/models/devices/colordevices.py | 25 +++++++++++++++ src/models/devices/genericdevices.py | 5 +++ src/models/devices/switchdevices.py | 43 +++++++++++++++++++++++++ src/models/groups.py | 4 +-- src/models/helper.py | 2 +- src/models/light.py | 24 -------------- 9 files changed, 108 insertions(+), 74 deletions(-) create mode 100644 src/models/colors.py delete mode 100644 src/models/devices.py create mode 100644 src/models/devices/__init__.py create mode 100644 src/models/devices/colordevices.py create mode 100644 src/models/devices/genericdevices.py create mode 100644 src/models/devices/switchdevices.py delete mode 100644 src/models/light.py diff --git a/src/models/colors.py b/src/models/colors.py new file mode 100644 index 0000000..34a7d8a --- /dev/null +++ b/src/models/colors.py @@ -0,0 +1,23 @@ +class RGBColor: + """Represents a color in the RGB color space.""" + + r: int + """Red value of the color.""" + + g: int + """Green value of the color.""" + + b: int + """Blue value of the color.""" + + def __init__(self, r: int, g: int, b: int): + """Initialize the color. + + Args: + r (int): Red value of the color. + g (int): Green value of the color. + b (int): Blue value of the color. + """ + self.r = r + self.g = g + self.b = b diff --git a/src/models/devices.py b/src/models/devices.py deleted file mode 100644 index 4016d66..0000000 --- a/src/models/devices.py +++ /dev/null @@ -1,47 +0,0 @@ -import abc - -from models.light import LightColor - - -class GenericDevice: - """A generic device.""" - - def __init__(self, name: str): - self.name = name - - -class SwitchDevice(GenericDevice): - """Abstract device that can be turned on and off.""" - - def toggle(self): - self.is_on = not self.is_on - - @property - @abc.abstractmethod - def is_on(self) -> bool: - raise NotImplementedError - - @is_on.setter - @abc.abstractmethod - def is_on(self, is_on: bool): - raise NotImplementedError - - -class LightDevice(SwitchDevice): - """Abstract device that can be turned on and off and has a color.""" - - @property - @abc.abstractmethod - def color(self) -> LightColor: - raise NotImplementedError - - @color.setter - @abc.abstractmethod - def color(self, color: LightColor): - raise NotImplementedError - - -class AllDevice(LightDevice): - """Abstract device class that offers all device operations.""" - - pass diff --git a/src/models/devices/__init__.py b/src/models/devices/__init__.py new file mode 100644 index 0000000..2ab87ac --- /dev/null +++ b/src/models/devices/__init__.py @@ -0,0 +1,9 @@ +from models.devices.colordevices import * +from models.devices.switchdevices import * +from models.devices.genericdevices import * + + +class AllDevice(SetSwitchDevice, SetColorDevice): + """Inherits all intefaces from all devices.""" + + pass diff --git a/src/models/devices/colordevices.py b/src/models/devices/colordevices.py new file mode 100644 index 0000000..dd79539 --- /dev/null +++ b/src/models/devices/colordevices.py @@ -0,0 +1,25 @@ +from models.colors import RGBColor +from models.devices.genericdevices import GenericDevice + + +class ColorDevice(GenericDevice): + """Abstract device that has a color.""" + + _color: RGBColor + """Color of device.""" + + +class GetColorDevice(ColorDevice): + """Implement color getter for a color device.""" + + @property + def color(self) -> RGBColor: + return self._color + + +class SetColorDevice(GetColorDevice): + """Implements color setter for a color device.""" + + @GetColorDevice.color.setter + def color(self, color: RGBColor): + self._color = color diff --git a/src/models/devices/genericdevices.py b/src/models/devices/genericdevices.py new file mode 100644 index 0000000..a8eca85 --- /dev/null +++ b/src/models/devices/genericdevices.py @@ -0,0 +1,5 @@ +class GenericDevice: + """A generic device.""" + + def __init__(self, name: str): + self.name = name diff --git a/src/models/devices/switchdevices.py b/src/models/devices/switchdevices.py new file mode 100644 index 0000000..73b25df --- /dev/null +++ b/src/models/devices/switchdevices.py @@ -0,0 +1,43 @@ +from models.devices.genericdevices import GenericDevice + + +class SwitchDevice(GenericDevice): + """Abstract device that can be turned on and off.""" + + _is_on: bool + """Current state of the device.""" + + +class ToggleSwitchDevice(SwitchDevice): + """Implements toggle functionality for a switch device.""" + + def toggle(self): + self._is_on = not self._is_on + + +class TurnOffSwitchDevice(SwitchDevice): + """Implements turn off functionality for a switch device.""" + + def turn_off(self): + self._is_on = False + + +class TurnOnSwitchDevice(SwitchDevice): + """Implements turn on functionality for a switch device.""" + + def turn_on(self): + self._is_on = True + + +class GetSwitchDevice(SwitchDevice): + """Implements is_on state getter for a switch device.""" + + @property + def is_on(self) -> bool: + return self._is_on + + +class SetSwitchDevice(TurnOffSwitchDevice, TurnOnSwitchDevice, ToggleSwitchDevice): + @GetSwitchDevice.is_on.setter + def is_on(self, set_on: bool): + self._is_on = set_on diff --git a/src/models/groups.py b/src/models/groups.py index 104f8a4..3439793 100644 --- a/src/models/groups.py +++ b/src/models/groups.py @@ -1,6 +1,6 @@ from models.helper import filter_devices -from models.devices import GenericDevice, LightDevice, SwitchDevice, AllDevice -from models.light import LightColor, LightScene +from models.devices import AllDevice +from models.colors import RGBColor class DeviceGroup(AllDevice): diff --git a/src/models/helper.py b/src/models/helper.py index e167049..9ccbafe 100644 --- a/src/models/helper.py +++ b/src/models/helper.py @@ -1,5 +1,5 @@ from typing import TypeVar -from models.devices import GenericDevice, LightDevice, SwitchDevice +from models.devices.genericdevices import GenericDevice, LightDevice, SwitchDevice from models.exceptions import NoDeviceFoundError from models.groups import DeviceGroup, Room diff --git a/src/models/light.py b/src/models/light.py deleted file mode 100644 index 0dd0e2e..0000000 --- a/src/models/light.py +++ /dev/null @@ -1,24 +0,0 @@ -from models.devices import LightDevice - - -class LightColor: - """The color of a light source.""" - - def __init__(self, red: int, green: int, blue: int, is_on: bool = True): - self.red = red - self.green = green - self.blue = blue - self.is_on = is_on - - -class LightScene: - """A scene describing a state of a collection of light sources.""" - - def __init__(self, name: str, device_colors: dict[LightDevice, LightColor]): - self.name = name - self.device_colors = device_colors - - def set_scene(self): - """Sets the scene on all devices.""" - for device, color in self.device_colors.items(): - device.color = color