commit 1531708338822312f665cf27949f97159ec1dae5
Author: Nicolas Duhamel <nicolas@jombi.fr>
Date:   Thu Mar 18 13:58:50 2021 +0100

    Initial commit, first working version

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)