From 4886983f962f8f4ed09ced86ad6bd08b97b03f4f Mon Sep 17 00:00:00 2001 From: Max Date: Fri, 5 Aug 2022 23:45:25 +0200 Subject: [PATCH] first commit --- README.md | 10 + requirements.txt | 8 + src/console_counter.py | 18 ++ src/home_assistant_counter.py | 44 +++ src/philips_hue_counter.py | 283 ++++++++++++++++++ src/sensors/__init__.py | 2 + src/sensors/people_counter.py | 189 ++++++++++++ src/sensors/tof_sensor.py | 33 ++ src/sensors/vl53l1x_sensor.py | 64 ++++ .../__pycache__/philips_hue.cpython-310.pyc | Bin 0 -> 4135 bytes src/services/philips_hue.py | 87 ++++++ src/statistics/statistics.py | 96 ++++++ src/timeloop/LICENSE | 21 ++ src/timeloop/__init__.py | 1 + src/timeloop/app.py | 72 +++++ src/timeloop/exceptions.py | 6 + src/timeloop/helpers.py | 5 + src/timeloop/job.py | 25 ++ 18 files changed, 964 insertions(+) create mode 100644 README.md create mode 100644 requirements.txt create mode 100644 src/console_counter.py create mode 100644 src/home_assistant_counter.py create mode 100644 src/philips_hue_counter.py create mode 100644 src/sensors/__init__.py create mode 100644 src/sensors/people_counter.py create mode 100644 src/sensors/tof_sensor.py create mode 100644 src/sensors/vl53l1x_sensor.py create mode 100644 src/services/__pycache__/philips_hue.cpython-310.pyc create mode 100644 src/services/philips_hue.py create mode 100644 src/statistics/statistics.py create mode 100644 src/timeloop/LICENSE create mode 100644 src/timeloop/__init__.py create mode 100644 src/timeloop/app.py create mode 100644 src/timeloop/exceptions.py create mode 100644 src/timeloop/helpers.py create mode 100644 src/timeloop/job.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..965b684 --- /dev/null +++ b/README.md @@ -0,0 +1,10 @@ +# Max's Smart Home - MaSH + +Should be a very simple implementation of what is required in Max's smart home. Trying not to overcomplicate things and thereby ruin motivation to work on this. + +## ToDo + +- Energy-saving/Off mode (Only one light slighty on to deal with the state) (How should power plugs be handled?) +- Daylight Adjustment (E.g. No ceiling lights during daytime) +- Save scene when turning off, to reapply same scene when turning on +- Detect fast flickering of light state, indicating an issue, and disable the system for a few minutes diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..27dcd81 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +smbus2 +vl53l1x + +# For Philips Hue Counter +phue + +# For statistics +matplotlib diff --git a/src/console_counter.py b/src/console_counter.py new file mode 100644 index 0000000..f60c0cf --- /dev/null +++ b/src/console_counter.py @@ -0,0 +1,18 @@ +from sensor.people_counter import PeopleCounter +from sensor.vl53l1x_sensor import VL53L1XSensor +import logging + +counter = PeopleCounter(VL53L1XSensor()) +peopleCount = 0 + +logging.getLogger().setLevel(logging.INFO) + + +def countChange(change: int) -> None: + global peopleCount + peopleCount += change + logging.info(f'People count change to: {peopleCount}') + + +counter.hookCounting(countChange) +counter.run() diff --git a/src/home_assistant_counter.py b/src/home_assistant_counter.py new file mode 100644 index 0000000..eeba02d --- /dev/null +++ b/src/home_assistant_counter.py @@ -0,0 +1,44 @@ +from sensor.people_counter import PeopleCounter +from sensor.vl53l1x_sensor import VL53L1XSensor +import paho.mqtt.client as mqtt +from HaMqtt.MQTTSensor import MQTTSensor +from HaMqtt.MQTTUtil import HaDeviceClass +import logging + + +HA_URL = "" +HA_PORT = 1883 +HA_SENSOR_NAME = "" +HA_SENSOR_ID = "" +HA_SENSOR_DEVICE_CLASS = HaDeviceClass.NONE +SENSOR_UNIT = "" + + +# Setup connection to HA +mqttClient = mqtt.Client() +mqttClient.connect(HA_URL, HA_PORT) +mqttClient.loop_start() # Keep conneciton alive + +# Setup mqtt binding +sensor = MQTTSensor(HA_SENSOR_NAME, HA_SENSOR_ID, mqttClient, SENSOR_UNIT, HA_SENSOR_DEVICE_CLASS) +logging.debug(f'Connected to topic {sensor.state_topic}') + + +def countChange(change: int) -> None: + """Called when people count change is detected. + Sends update to the initialized HA instance. + + Args: + change (int): Number of people leaving (<0) or entering (>0) a room. + """ + # Send update to HA + global sensor + sensor.publish_state(change) + + logging.debug(f'People count changed by {change}') + + +# Setup people count sensor +counter = PeopleCounter(VL53L1XSensor()) +counter.hookCounting(countChange) +counter.run() diff --git a/src/philips_hue_counter.py b/src/philips_hue_counter.py new file mode 100644 index 0000000..0955ca4 --- /dev/null +++ b/src/philips_hue_counter.py @@ -0,0 +1,283 @@ +from datetime import datetime, time, timedelta +from typing import Dict +from interface.philips_hue import PhilipsHue +from sensor.people_counter import PeopleCounter +from sensor.tof_sensor import Directions +from sensor.vl53l1x_sensor import VL53L1XSensor +import logging +import json +from timeloop import Timeloop + + +# Should lights already turn on where there is any kind of motion in the sensor +ENABLE_MOTION_TRIGGERED_LIGHT = True + +# Should lights change when a certain time in the schedule is reached +ENABLE_SCHEDULE_TRIGGERS = False # Not working correctly at the moment, so turned off by default + +# Schedule (Key is time after scene should be used. Value is scene name to be used.) +# Needs to be sorted chronologically +SCHEDULE = {} + + +LOG_FILE_PATH = "log.txt" # Path for logs +hue_conf = { + 'bridge_ip': '', + 'transition_time': 10, # seconds + 'light_group': '', + # If file exists, application is considered 'registered' at the bridge + 'registered_file': 'smart_light_registered.bridge' +} # Custom configuration for philips hue + + +hue: PhilipsHue = PhilipsHue(hue_conf) # Light interface +counter: PeopleCounter = PeopleCounter(VL53L1XSensor()) # Sensor object +peopleCount: int = 0 # Global count of people on the inside +motion_triggered_lights = False # Is light on because of any detected motion +timeloop: Timeloop = Timeloop() # Used for time triggered schedule + +logging.getLogger().setLevel(logging.INFO) + + +def time_minus_time(time_a: time, time_b: time) -> timedelta: + """Implementes a basic timedelta function for time objects. + + Args: + time_a (time): Time to subtract from. + time_b (time): Time to be subtracted. + + Returns: + timedelta: Delta between the two time objects. + """ + today = datetime.today() + dt_a = datetime.combine(today, time_a) + dt_b = datetime.combine(today, time_b) + + return dt_a - dt_b + + +def get_scene_for_time(time: time) -> str: + """Determines the correct scene to activate for a given time. + + Args: + time (time): Time to find scene for. + + Returns: + string: Scene name that should be active. None, if schedule is empty. + """ + global SCHEDULE + + if SCHEDULE is None or len(SCHEDULE) <= 0: + return None + + previous_scene = None + for start_time, scene in SCHEDULE.items(): + # If current time is still after schedule time, just keep going + if start_time <= time: + previous_scene = scene + continue + + # Schedule timef is now after current time, which is too late + # So if exists, take previous scene, since it was the last before the current time + if previous_scene: + return previous_scene + else: + break + + # Only breaks if it could not find a valid scene, so use lates scene as fallback + return list(SCHEDULE.values())[-1] + + +def change_cb(countChange: int, directionState: Dict): + """Handles basic logging of event data for later analysis. + + Args: + countChange (int): The change in the number of people. Usually on of [-1, 0, 1]. + directionState (Dict): Object describing the internal state of the sensor. + """ + data = { + 'version': 'v0.0', + 'previousPeopleCount': peopleCount, + 'countChange': countChange, + 'directionState': directionState, + 'dateTime': datetime.now(), + 'motionTriggeredLights': motion_triggered_lights + } + + try: + with open(LOG_FILE_PATH, 'a') as f: + f.write(json.dumps(data, default=str) + "\n") + except Exception as ex: + logging.exception(f'Unable to write log file. {ex}') + + +def count_change(change: int) -> None: + """Handles light state when people count changes + + Args: + change (int): The change in the number of people. Usually on of [-1, 0, 1]. + """ + global hue + global peopleCount + global motion_triggered_lights + + # Are lights on at the moment? + previous_lights_state = get_light_state() + + # Apply correction + if peopleCount <= 0 and previous_lights_state and not motion_triggered_lights: + # Count was 0, but lights were on (not because of motion triggers) => people count was not actually 0 + peopleCount = 1 + logging.debug(f'People count corrected to {peopleCount}') + elif peopleCount > 0 and not previous_lights_state: + # Count was >0, but lights were off => people count was actually 0 + peopleCount = 0 + logging.debug(f'People count corrected to {peopleCount}') + + peopleCount += change + if peopleCount < 0: + peopleCount = 0 + logging.debug(f'People count changed by {change}') + + # Handle light + target_light_state = peopleCount > 0 + + # Return, if there is no change + if previous_lights_state == target_light_state: + if previous_lights_state: + # Signaling that the people count is taking control over the light now + motion_triggered_lights = False + return + + set_light_state(target_light_state) + + +def trigger_change(triggerState: Dict): + """Handles motion triggered light state. + + Args: + triggerState (Dict): Describing in what directions the sensor is triggerd. + """ + global hue + global motion_triggered_lights + + target_light_state = None + + # Is someone walking close to the door? + motion_detected = triggerState[Directions.INSIDE] or triggerState[Directions.OUTSIDE] + target_light_state = motion_detected + + # Does motion triggered light need to do anything? + if peopleCount > 0: + # State is successfully handled by the count + motion_triggered_lights = False + return + + # Only look at changing situations + if target_light_state == motion_triggered_lights: + return + + set_light_state(target_light_state) + + # Save state + motion_triggered_lights = target_light_state + + +def set_light_scene(target_scene: str) -> bool: + """Sets the lights to the given scene, but only, if lights are already on. Does not correct count if lights are in an unexpected state. + + Args: + target_scene (string): Name of the scene to activate. + """ + # Is valid scene? + if target_scene is None: + return + + # Are lights on at the moment? Only based on people count for simplicity + if peopleCount <= 0: + # Lights are probably off, not doing anything + return + + # Set lights to scene + hue.set_group_scene(hue_conf['light_group'], target_scene) + logging.debug( + f'Light scene set to {target_scene}') + + +def set_light_state(target_light_state: bool) -> bool: + """Sets the lights to the given state. + + Args: + target_light_state (bool): Should lights on the inside be on or off. + + Returns: + bool: Previous light state. + """ + # Are lights on at the moment? + previous_lights_state = get_light_state() + if target_light_state == previous_lights_state: + return previous_lights_state + + # Adjust light as necessary + target_scene = get_scene_for_time(datetime.now().time()) + if target_light_state and target_scene: + # Set to specific scene if exists + hue.set_group_scene(hue_conf['light_group'], target_scene) + logging.debug( + f'Light state changed to {target_light_state} with scene {target_scene}') + else: + hue.set_group(hue_conf['light_group'], {'on': target_light_state}) + logging.debug(f'Light state changed to {target_light_state}') + + return previous_lights_state + + +def get_light_state() -> bool: + """ + Returns: + bool: Current light state. + """ + return hue.get_group(hue_conf['light_group'])['state']['any_on'] + + +def update_scene(): + """Called by time trigger to update light scene if lights are on. + """ + scene = get_scene_for_time(datetime.now().time()) + + if scene is None: + return + + set_light_scene(scene) + logging.debug(f'Updated scene at {datetime.now().time()} to {scene}.') + + +def register_time_triggers(): + """Registeres time triggered callbacks based on the schedule, to adjust the current scene, if lights are on. + """ + global SCHEDULE + if SCHEDULE is None or len(SCHEDULE) <= 0: + return + + for time in SCHEDULE.keys(): + delta = time_minus_time(time, datetime.now().time()) + if delta < timedelta(0): + delta += timedelta(1) + + timeloop._add_job(update_scene, interval=timedelta(1), offset=delta) + + timeloop.start(block=False) + + logging.info("Registered time triggers.") + + +if __name__ == "__main__": + if ENABLE_SCHEDULE_TRIGGERS: + register_time_triggers() + + # Represents callback trigger order + counter.hookChange(change_cb) + counter.hookCounting(count_change) + counter.hookTrigger(trigger_change) + + counter.run() diff --git a/src/sensors/__init__.py b/src/sensors/__init__.py new file mode 100644 index 0000000..54354ab --- /dev/null +++ b/src/sensors/__init__.py @@ -0,0 +1,2 @@ +from sensors.tof_sensor import ToFSensor, Directions +from sensors.people_counter import PeopleCounter diff --git a/src/sensors/people_counter.py b/src/sensors/people_counter.py new file mode 100644 index 0000000..9c3b75d --- /dev/null +++ b/src/sensors/people_counter.py @@ -0,0 +1,189 @@ +from typing import Dict +from sensors import ToFSensor, Directions +from datetime import datetime +import threading + + +COUNTING_CB = "counting" +TRIGGER_CB = "trigger" +CHANGE_CB = "changes" +START_TIME = "start_time" +END_TIME = "end_time" +TRIGGER_DISTANCES = "trigger_distances" +END_DISTANCE = "end_distance" + + +class PeopleCounter (): + def __init__(self, sensor: ToFSensor) -> None: + self.sensor = sensor + self.callbacks = {COUNTING_CB: [], TRIGGER_CB: [], CHANGE_CB: []} + self.maxTriggerDistance = 120 # In cm + + def hookCounting(self, cb) -> None: + self.callbacks[COUNTING_CB].append(cb) + + def unhookCounting(self, cb) -> None: + self.callbacks[COUNTING_CB].remove(cb) + + def hookTrigger(self, cb) -> None: + self.callbacks[TRIGGER_CB].append(cb) + + def unhookTrigger(self, cb) -> None: + self.callbacks[TRIGGER_CB].remove(cb) + + def hookChange(self, cb) -> None: + self.callbacks[CHANGE_CB].append(cb) + + def unhookChange(self, cb) -> None: + self.callbacks[CHANGE_CB].remove(cb) + + def getInitialDirectionState(self) -> Dict: + return { + Directions.INSIDE: [], + Directions.OUTSIDE: [] + } + + def run(self) -> None: + self.keepRunning = True + direction = Directions.INSIDE + self.directionState = self.getInitialDirectionState() + + self.sensor.open() + while self.keepRunning: + # Switch to other direction + direction: Directions = Directions.other(direction) + + self.sensor.setDirection(direction) + + distance: float = self.sensor.getDistance() + changed: bool = self.updateState(direction, distance) + + if changed: + countChange: int = self.getCountChange(self.directionState) + + # Hooks + th = threading.Thread(target=self.handleCallbacks, args=(countChange,)) + th.start() + + # Reset state if state is finalised + if not self.isDirectionTriggered(Directions.INSIDE) and not self.isDirectionTriggered(Directions.OUTSIDE): + self.directionState = self.getInitialDirectionState() + + self.sensor.close() + + def getCountChange(self, directionState) -> int: + # Is valid? + for direction in Directions: + # Is there at least one record for every direction? + if len(directionState[direction]) <= 0: + return 0 + + # Did every record start and end? + if directionState[direction][0][START_TIME] is None or directionState[direction][-1][END_TIME] is None: + return 0 # Return no change if not valid + + # Get times into variables + insideStart = directionState[Directions.INSIDE][0][START_TIME] + insideEnd = directionState[Directions.INSIDE][-1][END_TIME] + outsideStart = directionState[Directions.OUTSIDE][0][START_TIME] + outsideEnd = directionState[Directions.OUTSIDE][-1][END_TIME] + + # In what direction is the doorframe entered and left? + # Entering doorframe in the inside direction + enteringInside: bool = outsideStart < insideStart + # Leaving dooframe in the inside direction + leavingInside: bool = outsideEnd < insideEnd + + # They have to be the same, otherwise they switch directions in between + if enteringInside != leavingInside: + # Someone did not go all the way + # Either + # Inside -######- + # Outside ---##--- + # or + # Inside ---##--- + # Outside -######- + return 0 + + # Are those times overlapping or disjunct? + if insideEnd < outsideStart or outsideEnd < insideStart: + # They are disjunct + # Either + # Inside -##----- + # Outside -----##- + # or + # Inside -----##- + # Outside -##----- + return 0 + + # What direction is the person taking? + if enteringInside: + # Entering the inside + # Inside ---####- + # Outside -####--- + return 1 + else: + # Leaving the inside + # Inside -####--- + # Outside ---####- + return -1 + + def isTriggerDistance(self, distance: float) -> bool: + #! TODO: Should be based on the distance from the ground, not from the sensor + return distance <= self.maxTriggerDistance + + def handleCallbacks(self, countChange: int): + self.handleChangeCallbacks(countChange) + self.handleCountingCallbacks(countChange) + self.handleTriggerCallbacks() + + def handleCountingCallbacks(self, countChange: int) -> None: + # Only notify counting on actual count change + if countChange == 0: + return + + for cb in self.callbacks[COUNTING_CB]: + cb(countChange) + + def handleTriggerCallbacks(self) -> None: + triggerState = { + Directions.INSIDE: self.isDirectionTriggered(Directions.INSIDE), + Directions.OUTSIDE: self.isDirectionTriggered(Directions.OUTSIDE) + } + + for cb in self.callbacks[TRIGGER_CB]: + cb(triggerState) + + def handleChangeCallbacks(self, countChange: int) -> None: + for cb in self.callbacks[CHANGE_CB]: + cb(countChange, self.directionState) + + def isDirectionTriggered(self, direction: Directions) -> bool: + return len(self.directionState[direction]) > 0 and self.directionState[direction][-1][END_TIME] is None + + def updateState(self, direction: Directions, distance: float) -> bool: + triggered: bool = self.isTriggerDistance(distance) + + previouslyTriggered = False + if len(self.directionState[direction]) > 0: + previouslyTriggered = self.directionState[direction][-1][END_TIME] is None + + if triggered and not previouslyTriggered: + # Set as new beginning for this direction + self.directionState[direction].append({ + START_TIME: datetime.now(), + END_TIME: None, + TRIGGER_DISTANCES: [distance], + END_DISTANCE: None + }) + return True + elif not triggered and previouslyTriggered: + # Set as end for this direction + self.directionState[direction][-1][END_TIME] = datetime.now() + self.directionState[direction][-1][END_DISTANCE] = distance + return True + elif previouslyTriggered: + # Add distance at least + self.directionState[direction][-1][TRIGGER_DISTANCES].append(distance) + + return False diff --git a/src/sensors/tof_sensor.py b/src/sensors/tof_sensor.py new file mode 100644 index 0000000..def4206 --- /dev/null +++ b/src/sensors/tof_sensor.py @@ -0,0 +1,33 @@ +from enum import Enum + + +class Directions(str, Enum): + INSIDE = "indoor" + OUTSIDE = "outdoor" + + def other(direction: 'Direction') -> 'Direction': + if direction is Directions.INSIDE: + return Directions.OUTSIDE + else: + return Directions.INSIDE + + def __iter__(): + return [Directions.INSIDE, Directions.OUTSIDE] + + +class ToFSensor: + def open(self) -> None: + raise NotImplementedError() + + def setDirection(self, direction: Directions) -> None: + """Configure sensor to pick up the distance in a specific direction. + """ + raise NotImplementedError() + + def getDistance(self) -> float: + """Returns new distance in cm. + """ + raise NotImplementedError() + + def close(self) -> None: + raise NotImplementedError() diff --git a/src/sensors/vl53l1x_sensor.py b/src/sensors/vl53l1x_sensor.py new file mode 100644 index 0000000..9cd01d0 --- /dev/null +++ b/src/sensors/vl53l1x_sensor.py @@ -0,0 +1,64 @@ +from sensor.tof_sensor import Directions, ToFSensor +import VL53L1X + +# Reference: https://github.com/pimoroni/vl53l1x-python +# +# Left, right, top and bottom are relative to the SPAD matrix coordinates, +# which will be mirrored in real scene coordinates. +# (or even rotated, depending on the VM53L1X element alignment on the board and on the board position) +# +# ROI in SPAD matrix coords: +# +# 15 top-left +# | X____ +# | | | +# | |____X +# | bottom-right +# 0__________15 +# + + +class VL53L1XSensor (ToFSensor): + def __init__(self) -> None: + super().__init__() + + def open(self) -> None: + self.sensor = VL53L1X.VL53L1X(i2c_bus=1, i2c_address=0x29) + self.sensor.open() + + # Optionally set an explicit timing budget + # These values are measurement time in microseconds, + # and inter-measurement time in milliseconds. + # If you uncomment the line below to set a budget you + # should use `tof.start_ranging(0)` + # tof.set_timing(66000, 70) + self.ranging = 2 + # 0 = Unchanged + # 1 = Short Range + # 2 = Medium Range + # 3 = Long Range + + def setDirection(self, direction: Directions) -> None: + """Configure sensor to pick up the distance in a specific direction. + """ + direction_roi = { + Directions.INSIDE: VL53L1X.VL53L1xUserRoi(6, 3, 9, 0), + Directions.OUTSIDE: VL53L1X.VL53L1xUserRoi(6, 15, 9, 12) + } + + roi = direction_roi[direction] + + self.sensor.stop_ranging() + self.sensor.set_user_roi(roi) + self.sensor.start_ranging(self.ranging) + + def getDistance(self) -> float: + """Returns new distance in cm. + """ + distance = self.sensor.get_distance() + + return distance / 10 + + def close(self) -> None: + self.sensor.stop_ranging() + self.sensor.close() diff --git a/src/services/__pycache__/philips_hue.cpython-310.pyc b/src/services/__pycache__/philips_hue.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..305d7269c8065d060a1c760b59649ef01a2c223f GIT binary patch literal 4135 zcmb_fTW{P%6rLG-ZSQ82v}v2BEtf^&w$O(Ay{f7P3QrL@y+EuD z0#d2rUnCMQ;U`(VAtCY18~1b0?8Vz8Jh91)XU22RoZFo9Ih~(xF!+7??T`L1bBz6r zlj_fa$#rPiP3R<(JZ3%aa*ksWi=N>cG&W+hS95DLHe<`R;Jq2!uKhlfHEG>v(%KcS zBh3xgwtod)tj$a7hKgi2gi$Sv!>|tn^Fu%1S`TnT^*0ft533<_xn!=8T#Eb5HKZX; zc$)ekw^8<#)*kFfzy)8;B zNRv*~Ep2!vVUWX;nT2sj*+6v26nf$J7jM12nWvpA{V?sv;gujAB>85hg3Tz&L)Gzv zaI>%F@U{lwYJabxv0W!lz|mP>2;zwyjiTQX-`B+x0R01yx0a`a zV5@B^j0v|fRou0+K@fylR`QSnPIqwa8F8A2LG~haOWfoR7renwL38*5^tjT%r)d7| zd?WsFes3(oidg36U&T@--bQAc2{#NqvOc9(6r~ zBp9lD>@&Jm8%$X+Z3~63T)P|Qp5Kp1mqSiUPxUIDtFhm^EB$LvBOhegV8zoFagjXB z{XAT~8mECDXV+k$Ne+2A=Xv2*VKB%;&r>Tvty$9u?N-(&^iPcsSmMRm;|@5{B5D~( z|4V;$9#5`nt&a`DWaP-HXynZc$Pj5RLRUQVurx@Q9o1J|LXs(~sg@1WlE|LLJ&z&V zbH}osNsk!L*eK1s4g*Mx?10?(F8gF@6H?5MF*Gpk3^}BQl_4YnDXy}iAs~Hju+O26 zP;8WD;`hQ*L=qhTIB+U!Q6BcPQJAY1E*ba2Z73yEvtZmP^3&;w2HxFm4|zQg@3JOz zXCY%c81H|ZM)AmTN{vYZjNkDO$CH>w5AF$!=?DCQ=zvE571wAIit7a;!?@n#d+b2W z#&%iHAUNZwyOrzcHn4p3dVV5HiCO|1V{rO}pMl@9tNuanZHMN7we{4Esj zh~5)6C+I!)H^xiF-{@hj_e(H(6K6;Cp0NI3^~NhY)mxDRCO4QId=;fRqUeO~1Vt6r zG#x5ar@jhn?;NY#gx!hCWlsVjUiYCmAk6eVhZ&z-ej$xfQ8U*ShE)J<=7*>cGe9;@ z9DB$QcT72d7-fUZMd{3SDDQcZJnm*($Fs2d zEwkcsCB;n~=@Io&1H$Q8*wiD?jRWQl7`qL99A;8&TDp9AU&_UYxL$)r!lnxxvc|=e zkDsX>C@tpLHKfLZBl(bH_4tCTd4iwUxgrJn>UYkO@(vqCfwbAZBUXhx`6 zd=2=U@H33CZ~}sc5g}-j!kWCTzJr`xytS4NV%bX4yfp$mt|*1N41p8CEF)<@^*tRl&`F7NFN2)Uy>1@b%9n3;N$? zd|HKiRD>4s^9lpp^U8+j^-?*AaoqI0&j)_2-zbC-TG&T%gxEx18A3`}JLvu@OgM_t zDb*5`x1z&T5yTV@v<$JE!vL9>&WdBepWT2Sn`1U>3iq|^uGxpL3jI}JqCNFDfZd1B k=s3EoKPb??ijo}?9d|0=WUbycgz5x~nh^II7%dt90yqPCk^lez literal 0 HcmV?d00001 diff --git a/src/services/philips_hue.py b/src/services/philips_hue.py new file mode 100644 index 0000000..0d09c4d --- /dev/null +++ b/src/services/philips_hue.py @@ -0,0 +1,87 @@ +from phue import Bridge +from time import sleep +from pathlib import Path +import logging +import socket + + +class PhilipsHue (): + def __init__(self, config): + self.config = config + self.connect() + + def connect(self): + registered = Path(self.config['registered_file']).is_file() + success = False + while success == False: + try: + logging.info("Connecting to hue bridge") + self.bridge = Bridge(self.config['bridge_ip']) + self.bridge.connect() + success = True + except Exception as e: + logging.info("Failed to connect to bridge") + success = False + if registered == False: + logging.info("Trying again in 5 seconds..") + sleep(5) + else: + raise e + + logging.info("Connected to hue bridge") + if registered == False: + # register + logging.info("Saving registration") + Path(self.config['registered_file']).touch() + + def get_state(self): + return self.__execute__(lambda: self.bridge.get_api()) + + def get_scenes(self): + return self.__execute__(lambda: self.bridge.get_scene()) + + def get_scene_by_name(self, name): + for key, scene in self.get_scenes().items(): + if scene['name'] == name: + scene['id'] = key + return scene + return None + + def set_light(self, lights, command): + return self.__execute__(lambda: self.bridge.set_light(lights, command)) + + def get_light(self, id, command=None): + return self.__execute__(lambda: self.bridge.get_light(id, command)) + + def set_group(self, groups, command): + return self.__execute__(lambda: self.bridge.set_group(groups, command)) + + def get_group(self, id, command=None): + return self.__execute__(lambda: self.bridge.get_group(id, command)) + + def set_group_scene(self, group_name, scene_name): + scene_id = self.get_scene_by_name(scene_name)['id'] + return self.__execute__(lambda: self.set_group(group_name, self.create_conf({'scene': scene_id}))) + + def create_conf(self, conf): + if 'transitiontime' not in conf.keys(): + conf['transitiontime'] = self.config['transition_time'] + return conf + + def __execute__(self, function): + try: + return function() + except socket.timeout as e: + # Try to reconnect + logging.exception( + "Could not execute function. Trying to reconnect to bridge") + logging.exception(str(e)) + try: + self.connect() + except Exception as e: + logging.exception( + "Reconnect did not succeed, skipping execution") + logging.exception(str(e)) + return + # Now try again + return function() diff --git a/src/statistics/statistics.py b/src/statistics/statistics.py new file mode 100644 index 0000000..eac2a96 --- /dev/null +++ b/src/statistics/statistics.py @@ -0,0 +1,96 @@ +from datetime import datetime +import json +from typing import Dict +from xmlrpc.client import Boolean + +import matplotlib.pyplot as plt + +# Config +FILE_PATH = "log.txt" + + +# Read file +content = None +with open(FILE_PATH, "r") as file: + content = file.readlines() + + +def parse_log_entry(entry: Dict) -> Dict: + # 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") + if entry["dateTime"] < datetime(2022, 1, 1): + return False + + return entry + + +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 + + return True + + +# Collect +log = [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)] +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") +plt.show() +print("-"*20) + + +# Print stats +walk_ins = [entry for entry in log if entry["countChange"] > 0] +walk_outs = [entry for entry in log if entry["countChange"] < 0] +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) + +# 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"] + faulty: bool = estimated_count != log[n]["previousPeopleCount"] + log[c]["faulty"] = faulty + log[c]["faultyCount"] = log[c]["previousPeopleCount"] if faulty else None + +log = log[:-1] +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("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, "%") +print("Percentage of false-1:", len(faulty_on) / fault_count * 100, "%") diff --git a/src/timeloop/LICENSE b/src/timeloop/LICENSE new file mode 100644 index 0000000..30302f4 --- /dev/null +++ b/src/timeloop/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 sankalpjonn + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/timeloop/__init__.py b/src/timeloop/__init__.py new file mode 100644 index 0000000..8d95c4d --- /dev/null +++ b/src/timeloop/__init__.py @@ -0,0 +1 @@ +from timeloop.app import Timeloop diff --git a/src/timeloop/app.py b/src/timeloop/app.py new file mode 100644 index 0000000..4575d69 --- /dev/null +++ b/src/timeloop/app.py @@ -0,0 +1,72 @@ +from datetime import datetime, timedelta +import logging +import sys +import signal +import time + +from timeloop.exceptions import ServiceExit +from timeloop.job import Job +from timeloop.helpers import service_shutdown + + +class Timeloop(): + def __init__(self): + self.jobs = [] + logger = logging.getLogger('timeloop') + ch = logging.StreamHandler(sys.stdout) + ch.setLevel(logging.INFO) + formatter = logging.Formatter('[%(asctime)s] [%(name)s] [%(levelname)s] %(message)s') + ch.setFormatter(formatter) + logger.addHandler(ch) + logger.setLevel(logging.INFO) + self.logger = logger + + def _add_job(self, func, interval: timedelta, offset: timedelta=None, *args, **kwargs): + j = Job(interval, func, offset=offset, *args, **kwargs) + self.jobs.append(j) + + def _block_main_thread(self): + signal.signal(signal.SIGTERM, service_shutdown) + signal.signal(signal.SIGINT, service_shutdown) + + while True: + try: + time.sleep(1) + except ServiceExit: + self.stop() + break + + def _start_jobs(self, block): + for j in self.jobs: + j.daemon = not block + j.start() + self.logger.info("Registered job {}".format(j.execute)) + + def _stop_jobs(self): + for j in self.jobs: + self.logger.info("Stopping job {}".format(j.execute)) + j.stop() + + def job(self, interval: timedelta, offset: timedelta=None): + """Decorator to define a timeloop for the decorated function. + + Args: + interval (timedelta): How long to wait after every execution until the next one. + offset (timedelta, optional): Positive offset until the first execution of the function. If None, will wait with first execution until the first interval passed. If timedelta with length 0 (or smaller) will execute immediately. Defaults to None. + """ + def decorator(f): + self._add_job(f, interval, offset=offset) + return f + return decorator + + def stop(self): + self._stop_jobs() + self.logger.info("Timeloop exited.") + + def start(self, block=False): + self.logger.info("Starting Timeloop..") + self._start_jobs(block=block) + + self.logger.info("Timeloop now started. Jobs will run based on the interval set") + if block: + self._block_main_thread() diff --git a/src/timeloop/exceptions.py b/src/timeloop/exceptions.py new file mode 100644 index 0000000..4585852 --- /dev/null +++ b/src/timeloop/exceptions.py @@ -0,0 +1,6 @@ +class ServiceExit(Exception): + """ + Custom exception which is used to trigger the clean exit + of all running threads and the main program. + """ + pass diff --git a/src/timeloop/helpers.py b/src/timeloop/helpers.py new file mode 100644 index 0000000..0a1c2fe --- /dev/null +++ b/src/timeloop/helpers.py @@ -0,0 +1,5 @@ +from timeloop.exceptions import ServiceExit + + +def service_shutdown(signum, frame): + raise ServiceExit diff --git a/src/timeloop/job.py b/src/timeloop/job.py new file mode 100644 index 0000000..5c32fdd --- /dev/null +++ b/src/timeloop/job.py @@ -0,0 +1,25 @@ +from threading import Thread, Event +from datetime import timedelta +from time import sleep + +class Job(Thread): + def __init__(self, interval: timedelta, execute, offset: timedelta=None, *args, **kwargs): + Thread.__init__(self) + self.stopped = Event() + self.interval: timedelta = interval + self.execute = execute + self.offset: timedelta = offset + self.args = args + self.kwargs = kwargs + + def stop(self): + self.stopped.set() + self.join() + + def run(self): + if self.offset: + sleep(self.offset.total_seconds()) + self.execute(*self.args, **self.kwargs) + + while not self.stopped.wait(self.interval.total_seconds()): + self.execute(*self.args, **self.kwargs)