first commit
This commit is contained in:
commit
4886983f96
18 changed files with 964 additions and 0 deletions
10
README.md
Normal file
10
README.md
Normal file
|
@ -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
|
8
requirements.txt
Normal file
8
requirements.txt
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
smbus2
|
||||||
|
vl53l1x
|
||||||
|
|
||||||
|
# For Philips Hue Counter
|
||||||
|
phue
|
||||||
|
|
||||||
|
# For statistics
|
||||||
|
matplotlib
|
18
src/console_counter.py
Normal file
18
src/console_counter.py
Normal file
|
@ -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()
|
44
src/home_assistant_counter.py
Normal file
44
src/home_assistant_counter.py
Normal file
|
@ -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()
|
283
src/philips_hue_counter.py
Normal file
283
src/philips_hue_counter.py
Normal file
|
@ -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()
|
2
src/sensors/__init__.py
Normal file
2
src/sensors/__init__.py
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
from sensors.tof_sensor import ToFSensor, Directions
|
||||||
|
from sensors.people_counter import PeopleCounter
|
189
src/sensors/people_counter.py
Normal file
189
src/sensors/people_counter.py
Normal file
|
@ -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
|
33
src/sensors/tof_sensor.py
Normal file
33
src/sensors/tof_sensor.py
Normal file
|
@ -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()
|
64
src/sensors/vl53l1x_sensor.py
Normal file
64
src/sensors/vl53l1x_sensor.py
Normal file
|
@ -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()
|
BIN
src/services/__pycache__/philips_hue.cpython-310.pyc
Normal file
BIN
src/services/__pycache__/philips_hue.cpython-310.pyc
Normal file
Binary file not shown.
87
src/services/philips_hue.py
Normal file
87
src/services/philips_hue.py
Normal file
|
@ -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()
|
96
src/statistics/statistics.py
Normal file
96
src/statistics/statistics.py
Normal file
|
@ -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, "%")
|
21
src/timeloop/LICENSE
Normal file
21
src/timeloop/LICENSE
Normal file
|
@ -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.
|
1
src/timeloop/__init__.py
Normal file
1
src/timeloop/__init__.py
Normal file
|
@ -0,0 +1 @@
|
||||||
|
from timeloop.app import Timeloop
|
72
src/timeloop/app.py
Normal file
72
src/timeloop/app.py
Normal file
|
@ -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()
|
6
src/timeloop/exceptions.py
Normal file
6
src/timeloop/exceptions.py
Normal file
|
@ -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
|
5
src/timeloop/helpers.py
Normal file
5
src/timeloop/helpers.py
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
from timeloop.exceptions import ServiceExit
|
||||||
|
|
||||||
|
|
||||||
|
def service_shutdown(signum, frame):
|
||||||
|
raise ServiceExit
|
25
src/timeloop/job.py
Normal file
25
src/timeloop/job.py
Normal file
|
@ -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)
|
Loading…
Reference in a new issue