From 1531708338822312f665cf27949f97159ec1dae5 Mon Sep 17 00:00:00 2001 From: Nicolas Duhamel Date: Thu, 18 Mar 2021 13:58:50 +0100 Subject: [PATCH] Initial commit, first working version --- .gitignore | 8 ++ README.md | 18 +++++ pyproject.toml | 4 + setup.cfg | 13 ++++ setup.py | 10 +++ src/citadel/scene/__init__.py | 140 ++++++++++++++++++++++++++++++++++ src/citadel/scene/__main__.py | 44 +++++++++++ 7 files changed, 237 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 setup.cfg create mode 100644 setup.py create mode 100644 src/citadel/scene/__init__.py create mode 100644 src/citadel/scene/__main__.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c10d448 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +# Source for the following rules: https://raw.githubusercontent.com/github/gitignore/master/Python.gitignore +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + + +*.egg-info/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..f43f6ab --- /dev/null +++ b/README.md @@ -0,0 +1,18 @@ +Citadel Scene +============= + +A scene is a particular state of a part (or all) of your devices. +All is managed by mqtt. + +Starting a scene is playing o bunch of mqtt payload. +Then watch for some specific payload that deactive the scene + +A scene have a state ON/OFF + + +Topic +----- + +stat/scene/id/state ON|OFF + +cmnd/scene/id/activate ON|OFF diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1870a2e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,4 @@ +[build-system] +requires = ["setuptools", "wheel"] +build-backend = "setuptools.build_meta" + diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..e00d836 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,13 @@ +[metadata] +name = citadel.scene +version = 0.0.1 + +[options] +package_dir = + =src +packages = find_namespace: +# install_requires = + +[options.packages.find] +where = src + diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..5c23724 --- /dev/null +++ b/setup.py @@ -0,0 +1,10 @@ +import setuptools + +setuptools.setup( + install_requires = [ + 'citadel.mqtt @ git+https://git.quimbo.fr/citadel/mqtt.git@master#egg=citadel.mqtt', + 'systemd-python', + 'typer==0.3.2' + ] +) + diff --git a/src/citadel/scene/__init__.py b/src/citadel/scene/__init__.py new file mode 100644 index 0000000..81304c1 --- /dev/null +++ b/src/citadel/scene/__init__.py @@ -0,0 +1,140 @@ +import threading +import logging + +import paho.mqtt.client as mqtt + +from citadel.mqtt import Configuration + +class Scene(threading.Thread, mqtt.Client): + """ A scene run as a thread, has his own mqtt session + + Can be activated by topic: + + cmnd/scene/{mqtt_name}/activate ON|OFF|null + + And return status on topic: + + stat/scene/{mqtt_name}/state ON|OFF + + Special class attributes: + + mqtt_name: define topic name, default class name in lower case + + """ + TOPIC_CMND = "cmnd/scene/{mqtt_name}/activate" + TOPIC_STAT = "stat/scene/{mqtt_name}/activate" + + def __init__(self, config: Configuration, logger: logging.Logger=None): + threading.Thread.__init__(self) + mqtt.Client.__init__(self) + + self.configuration = config + self._scene_state = False + + if not hasattr(self, 'mqtt_name'): + self.mqtt_name = self.__class__.__name__.lower() + + if not logger: + logger = logging.getLogger('scene.%s' % self.mqtt_name) + self.logger = logger + + @property + def scene_state(self): + return self._scene_state + + def run(self): + self.username_pw_set(self.configuration.user, + self.configuration.password) + self.connect(self.configuration.host, + self.configuration.port) + + self.subscribe(self.TOPIC_CMND.format(mqtt_name=self.mqtt_name), 2) + + for watch in self.WATCH: + self.subscribe(watch[0], 1) + + run = True + while run: + self.loop() + + def on_message(self, mqttc, obj, msg): + payload = msg.payload.decode().lower() + topic = msg.topic + + # on CMND + if topic == self.TOPIC_CMND.format(mqtt_name=self.mqtt_name): + if payload == 'on': + self.logger.info('CMND activate ON') + self.__activate() + elif payload == 'off': + self.logger.info('CMND activate OFF') + self.__deactivate() + elif payload == '': + self.logger.info('CMND activate state') + self.__send_state() + else: + self.logger.warn('CMND activate invalide') + + elif self.scene_state and self.__check_watched(topic, payload): + self.logger.info('Watched topic %s with invalid payload %s deactivating', topic, payload) + self.__deactivate() + + def __check_watched(self, topic, payload): + """ return True if is a watched topic with an invalid payload """ + for watch in self.WATCH: + if topic == watch[0]: + if payload != watch[1].lower(): + return True + return False + return False + + def __activate(self): + #activate scene + for action in self.ACTIVATE: + self.publish(action[0], payload=action[1], qos=2, retain=False) + + self._scene_state = True + self.publish(self.TOPIC_STAT.format(mqtt_name=self.mqtt_name), + payload='ON', + qos=2, + retain=False) + + def __deactivate(self): + #deactivate scene + self._scene_state = False + self.publish(self.TOPIC_STAT.format(mqtt_name=self.mqtt_name), + payload='OFF', + qos=2, + retain=False) + + def __send_state(self): + print(self._scene_state) + self.publish(self.TOPIC_STAT.format(mqtt_name=self.mqtt_name), + payload='ON' if self.scene_state else 'OFF', + qos=2, + retain=False) + +class Sleep(Scene): + + ACTIVATE = [ + # Close all Shutter + ('cmnd/tasmota/shutter/Bedroom1x1/ShutterClose', ''), + ('cmnd/tasmota/shutter/Bedroom1x2/ShutterClose', ''), + ('cmnd/tasmota/shutter/Bathroom/ShutterClose', ''), + ('cmnd/tasmota/shutter/Staicase1/ShutterClose', ''), + + # Shutdown all light + + # Set thermostat to sleepmode + + # Close screen + ('cmnd/tasmota/screen/Screen/POWER', 'OFF') + ] + + WATCH = [ + ('stat/tasmota/screen/Screen/POWER', 'OFF') + # ('cmnd/tasmota/shutter/Bedroom1x1/ShutterClose', ''), + # ('cmnd/tasmota/shutter/Bedroom1x2/ShutterClose', ''), + # ('cmnd/tasmota/shutter/Bathroom/ShutterClose', ''), + # ('cmnd/tasmota/shutter/Staicase1/ShutterClose', ''), + ] diff --git a/src/citadel/scene/__main__.py b/src/citadel/scene/__main__.py new file mode 100644 index 0000000..cff99ac --- /dev/null +++ b/src/citadel/scene/__main__.py @@ -0,0 +1,44 @@ +import logging +import typer + +import systemd.journal + +from citadel.mqtt import Configuration +from . import Sleep + +def main(\ + mqtt_user: str = typer.Option(... , envvar="DEVICES_MQTT_USER"),\ + mqtt_pwd: str = typer.Option(... , envvar="DEVICES_MQTT_PWD"),\ + mqtt_host: str = typer.Option(... , envvar="DEVICES_MQTT_HOST"),\ + mqtt_port: int = typer.Option(... , envvar="DEVICES_MQTT_PORT"),\ + is_systemd: bool = typer.Option(False, help="Is running as systemd unit", envvar="LAUNCHED_BY_SYSTEMD")\ + ): + + if is_systemd: + logger = logging.getLogger() + logger.setLevel(logging.DEBUG) + logger.addHandler(systemd.journal.JournalHandler()) + else: + logger = logging.getLogger() + logger.setLevel(logging.DEBUG) + # create console handler and set level to debug + ch = logging.StreamHandler() + ch.setLevel(logging.DEBUG) + # create formatter + formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') + # add formatter to ch + ch.setFormatter(formatter) + # add ch to logger + logger.addHandler(ch) + + class Config: + user = mqtt_user + password = mqtt_pwd + host = mqtt_host + port = mqtt_port + + scene = Sleep(Config) + scene.run() + +if __name__ == "__main__": + typer.run(main)