Skip to content

Commit 1a14783

Browse files
committed
backend: Add support for scheduled door
1 parent 6ae6536 commit 1a14783

File tree

5 files changed

+179
-15
lines changed

5 files changed

+179
-15
lines changed

Diff for: README.md

+2-1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ Supports:
1111
- controlling IR illuminator relay for night vision
1212
- reading input button, if you want to open/close door manually on-site without
1313
a smart phone
14+
- support for scheduled door opening and closing on sunrise and sunset
1415
- stores the current state into config file so the door remain in position
1516
in case of power shortage
1617

@@ -31,7 +32,7 @@ Install dependencies:
3132

3233
```sh
3334
sudo apt install python3-libgpiod # also installs libgpiod2
34-
pip install adafruit-circuitpython-dht
35+
pip install adafruit-circuitpython-dht astral
3536
```
3637

3738
Run backend with

Diff for: backend/chickencoop_backend.py

+46-11
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
#!/usr/bin/python
22

3-
import json
43
import dataclasses
4+
from datetime import datetime, timezone
55
from http.server import BaseHTTPRequestHandler, HTTPServer
6+
import json
67
import logging
78
import RPi.GPIO as GPIO
9+
from urllib.parse import parse_qs
810

9-
from datetime import datetime, timezone
1011
import config
1112
from status import Status
1213
import manual_door
14+
import schedule
1315
import temperature
1416

1517
HOSTNAME = ''
@@ -40,14 +42,10 @@ def do_GET(s):
4042
status.temperature = avgT
4143
status.current_datetime = datetime.now(timezone.utc)
4244
s.wfile.write(json.dumps(dataclasses.asdict(status), default=str).encode())
43-
elif s.path.startswith("/door_down"):
44-
status.door = False
45-
config.save_cfg_from_status(status)
46-
update_gpio()
47-
elif s.path.startswith("/door_up"):
48-
status.door = True
49-
config.save_cfg_from_status(status)
50-
update_gpio()
45+
elif s.path.startswith("/door_open"):
46+
door_open()
47+
elif s.path.startswith("/door_close"):
48+
door_close()
5149
elif s.path.startswith("/light_on"):
5250
status.light = True
5351
config.save_cfg_from_status(status)
@@ -59,6 +57,25 @@ def do_GET(s):
5957
else:
6058
s.send_response(404)
6159

60+
def do_POST(s):
61+
content_length = int(s.headers['Content-Length'])
62+
post_raw = s.rfile.read(content_length)
63+
post = parse_qs(post_raw.decode(), strict_parsing=True)
64+
print(post_raw)
65+
print(post)
66+
if "action" in post and len(post["action"])==1:
67+
if post["action"][0] == "apply_schedule":
68+
status.schedule_city = post["schedule_city"][0]
69+
status.schedule_door_open = ("schedule_door_open" in post)
70+
status.schedule_door_close = ("schedule_door_close" in post)
71+
status.schedule_door_open_offset = int(post["schedule_door_open_offset"][0])
72+
status.schedule_door_close_offset = int(post["schedule_door_close_offset"][0])
73+
config.save_cfg_from_status(status)
74+
schedule.reset_today_state()
75+
76+
s.send_response(200)
77+
s.send_header("Content-type", "application/json")
78+
s.end_headers()
6279

6380
def init_gpio():
6481
GPIO.cleanup()
@@ -78,6 +95,20 @@ def update_gpio():
7895
GPIO.output(config.MASTER_SWITCH_PIN, status.master)
7996

8097

98+
def door_open():
99+
global status
100+
status.door = True
101+
config.save_cfg_from_status(status)
102+
update_gpio()
103+
104+
105+
def door_close():
106+
global status
107+
status.door = False
108+
config.save_cfg_from_status(status)
109+
update_gpio()
110+
111+
81112
def switch_door():
82113
global status
83114

@@ -89,12 +120,14 @@ def switch_door():
89120

90121

91122
if __name__ == '__main__':
123+
status = config.load_cfg_to_status()
124+
92125
logging.basicConfig(level=logging.DEBUG)
93126
init_gpio()
94127
manual_door.init_manual_door_service(switch_door)
95128
temperature.init_temperature_service()
129+
schedule.init_schedule_service(door_open, door_close, status)
96130

97-
status = config.load_cfg_to_status()
98131
update_gpio()
99132

100133
httpd = HTTPServer((HOSTNAME, PORT), ChickenCoopHTTPHandler)
@@ -104,7 +137,9 @@ def switch_door():
104137
except:
105138
pass
106139
httpd.server_close()
140+
schedule.stop_schedule_service()
107141
temperature.stop_temperature_service()
108142
manual_door.stop_manual_door_service()
143+
GPIO.output(config.MASTER_SWITCH_PIN, False)
109144
GPIO.cleanup()
110145
logging.info('Server stopped - keyboard interrupt')

Diff for: backend/config.py

+23-3
Original file line numberDiff line numberDiff line change
@@ -25,15 +25,25 @@
2525

2626
@dataclass
2727
class Config:
28-
door: bool # True=up, False=down
29-
light: bool # True=on, False=off
30-
master: bool # True=on, False=off
28+
door: bool # True=door opened, False=door closed
29+
light: bool # True=on, False=off
30+
master: bool # True=on, False=off
31+
schedule_city: str = "" # Closest city to the chicken coop to compute accurate sunrise/sunset times
32+
schedule_door_open: bool = False # Is scheduled door opening enabled
33+
schedule_door_close: bool = False # Is scheduled door closing enabled
34+
schedule_door_open_offset: int = 0 # Offset in opening the door on sunrise in minutes
35+
schedule_door_close_offset: int = 0 # Offset in closing the door on sunset in minutes
3136

3237
def default_cfg() -> Config:
3338
return Config(
3439
door=False,
3540
light=False,
3641
master=True,
42+
schedule_city="",
43+
schedule_door_open=False,
44+
schedule_door_close=False,
45+
schedule_door_open_offset=0,
46+
schedule_door_close_offset=0,
3747
)
3848

3949

@@ -61,6 +71,11 @@ def load_cfg_to_status() -> Status:
6171
humidity=0,
6272
current_datetime=datetime.min,
6373
last_manual_door_datetime=datetime.min,
74+
schedule_city=cfg.schedule_city,
75+
schedule_door_open=cfg.schedule_door_open,
76+
schedule_door_close=cfg.schedule_door_close,
77+
schedule_door_open_offset=cfg.schedule_door_open_offset,
78+
schedule_door_close_offset=cfg.schedule_door_close_offset,
6479
)
6580

6681

@@ -69,6 +84,11 @@ def save_cfg_from_status(status: Status):
6984
door=status.door,
7085
light=status.light,
7186
master=status.master,
87+
schedule_city=status.schedule_city,
88+
schedule_door_open=status.schedule_door_open,
89+
schedule_door_close=status.schedule_door_close,
90+
schedule_door_open_offset=status.schedule_door_open_offset,
91+
schedule_door_close_offset=status.schedule_door_close_offset,
7292
))
7393

7494

Diff for: backend/schedule.py

+101
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import astral, astral.geocoder, astral.sun
2+
from datetime import datetime, timedelta, timezone
3+
import logging
4+
import RPi.GPIO as GPIO
5+
import threading
6+
from typing import Any, Dict
7+
import time
8+
9+
from status import Status
10+
11+
from config import MANUAL_DOOR_BUTTON_PIN
12+
13+
# Number of seconds between two schedule checks.
14+
SCHEDULE_SLEEP = 60
15+
16+
# Number of seconds after the door up/down event is still considered.
17+
SCHEDULE_THRESHOLD = 300
18+
19+
# Is checking the schedule service main loop running.
20+
SCHEDULE_ENABLED = False
21+
22+
# Configuration.
23+
status: Status
24+
25+
# Main door loop thread.
26+
t: threading.Thread
27+
28+
# Current astral's sun info, updated regularly by the main loop.
29+
sun: Dict[str, Any]
30+
31+
# Status for today.
32+
today_date = datetime.min
33+
today_door_opened = False
34+
today_door_closed = False
35+
36+
37+
def init_schedule_service(door_open_callback, door_close_callback, s):
38+
global SCHEDULE_ENABLED, t, status
39+
status = s
40+
41+
t = threading.Thread(target=check_time, args=[door_open_callback, door_close_callback])
42+
SCHEDULE_ENABLED = True
43+
t.start()
44+
45+
46+
def stop_schedule_service():
47+
global SCHEDULE_ENABLED
48+
SCHEDULE_ENABLED = False
49+
t.join()
50+
51+
52+
def check_time(door_open_callback, door_close_callback):
53+
global status, SCHEDULE_SLEEP, SCHEDULE_THRESHOLD, SCHEDULE_ENABLED, sun, today_date, today_door_opened, today_door_closed
54+
55+
reset_today_state()
56+
57+
while SCHEDULE_ENABLED:
58+
if today_date != datetime.now().date():
59+
reset_today_state()
60+
61+
city = astral.geocoder.lookup("London", astral.geocoder.database())
62+
try:
63+
city = astral.geocoder.lookup("London" if status.schedule_city == "" else status.schedule_city,
64+
astral.geocoder.database())
65+
except KeyError:
66+
67+
68+
sun = astral.sun.sun(city.observer)
69+
status.schedule_sunrise = sun['sunrise']
70+
status.schedule_sunset = sun['sunset']
71+
72+
opening_time = sun['sunrise'] + timedelta(minutes=status.schedule_door_open_offset)
73+
closing_time = sun['sunset'] + timedelta(minutes=status.schedule_door_close_offset)
74+
75+
logging.info(f'schedule: now {datetime.now()}')
76+
logging.info(f'schedule: city: {city}, sun: {sun}')
77+
logging.info(f'schedule: opening_time {opening_time}, closing_time {closing_time}')
78+
logging.info(f'schedule: opening_time + threshold: {opening_time + timedelta(seconds=SCHEDULE_THRESHOLD)}, closing_time + threshold: {closing_time + timedelta(seconds=SCHEDULE_THRESHOLD)}')
79+
logging.info(f'schedule: today_door_opened {today_door_opened}, today_door_closed {today_door_closed}')
80+
logging.info(f'schedule: schedule_door_open {status.schedule_door_open}, schedule_door_close {status.schedule_door_close}')
81+
82+
if not today_door_opened and status.schedule_door_open and opening_time < datetime.now(timezone.utc) < opening_time + timedelta(seconds=SCHEDULE_THRESHOLD):
83+
logging.info("Door open schedule event! Good morning 🌅")
84+
door_open_callback()
85+
today_door_opened = True
86+
87+
if not today_door_closed and status.schedule_door_close and closing_time < datetime.now(timezone.utc) < closing_time + timedelta(seconds=SCHEDULE_THRESHOLD):
88+
logging.info("Door close schedule event! Good night 🌇")
89+
door_close_callback()
90+
today_door_closed = True
91+
92+
time.sleep(SCHEDULE_SLEEP)
93+
94+
95+
def reset_today_state():
96+
global today_date, today_door_opened, today_door_closed
97+
98+
logging.info(f'schedule: resetting today state, old values: {today_date}, {today_door_opened}, {today_door_closed}')
99+
today_date = datetime.now().date()
100+
today_door_opened = False
101+
today_door_closed = False

Diff for: backend/status.py

+7
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,10 @@ class Status:
1111
master: bool
1212
current_datetime: datetime
1313
last_manual_door_datetime: datetime
14+
schedule_city: str
15+
schedule_door_open: bool
16+
schedule_door_close: bool
17+
schedule_door_open_offset: int
18+
schedule_door_close_offset: int
19+
schedule_sunrise: datetime = datetime.min
20+
schedule_sunset: datetime = datetime.min

0 commit comments

Comments
 (0)