-
Notifications
You must be signed in to change notification settings - Fork 0
/
lightmachine.py
209 lines (171 loc) · 7.83 KB
/
lightmachine.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
"""
This is the main program that does the work.
"""
from apscheduler.schedulers.blocking import BlockingScheduler
from transitions import Machine
from datetime import datetime, timedelta
import ephem
import enum
import logging
import logging.handlers
import secrets
import os, sys
import yaml
import numpy as np
from utils import set_log_level, load_conf, info_jobs, synth_off_time, synth_sched_time
from switchmate import SwitchMate
# XXX maybe what I want is a frozen bidict
# or better SwitchMate.switchon() should return these instead of a str
class Light(enum.Enum):
ON = 1
OFF = 0
BAT = 2
UNKN = 3
VERIFY_TABLE = {'on': Light.ON,
'off': Light.OFF,
False: Light.UNKN}
class SwitchScheduler():
""" this is a scheduler that controls scheduling of an underlying switch """
def __init__(self, point=None):
self.point = point
def calc_time_from_loc_and_schedule(self, schedule):
point = self.conf['home']
obs = ephem.Observer()
obs.lat = point['lat']
obs.lon = point['lon']
obs.elev = point['elev']
today = datetime.now()
obs.date = today.replace(hour=12, minute=0, second=0)
obs.horizon = str(schedule['horizon'])
if schedule['when'] == "morning":
utc_time = obs.previous_rising(ephem.Sun())
elif schedule['when'] == "evening":
utc_time = obs.next_setting(ephem.Sun())
else:
raise NameError("no idea when you want to calculate")
time = ephem.localtime(utc_time)
return time
def rand_off_time(self, off_time_str):
h,m,s = off_time_str.split(':')
rand_minute = self._random_minute()
now = datetime.now()
return now.replace(hour=int(h), minute=rand_minute, second=int(s))
def _random_minute():
""" use numpy and generate a minute from a random normal distribution
numbers should be bounded 0 < num < 60
"""
mu, sigma, n = 20, 14, 1
s = np.random.normal(mu, sigma, n)
val = int(round(s[0]))
if val < 0 or val > 60:
val = _random_minute()
return val
def _add_times_to_schedule(self, schedule):
for event, val in schedule.items():
if event == 'off_time':
schedule[event]['time'] = self.rand_off_time(val['off_hour'])
else:
schedule[event]['time'] = self.calc_time_from_loc_and_schedule(val)
return schedule
def scheduler(self, sched):
set_log_level(logging.INFO)
logging.info(f"scheduler() {self.batterystatus()}")
timez = self._add_times_to_schedule(self.conf['schedule'])
now = datetime.now()
for run in timez.keys():
if timez[run]['time'] > now:
if timez[run]['state']:
state = self.switchon
else:
state = self.switchoff
sched.add_job(state, 'date', run_date=timez[run]['time'])
logging.info(f"{timez[run]['emoji']:2} {run:10}: {timez[run]['time']}")
logging.info(info_jobs(sched.get_jobs()))
class LightMachine(Machine, SwitchScheduler, SwitchMate):
""" main object that does all the work
it's doing all the __init__ work """
def __init__(self, conf):
self.conf = conf
self.mystery_state = Light.UNKN
states = [Light.ON, Light.OFF, Light.BAT]
# XXX this causes a weirdo hole and indeterminite startup if run in the off_hour hour I guess, I don't super care as verify_state will catch things
t = self._add_times_to_schedule(self.conf['schedule'])
#logging.info(f"__init__ t: {t}") # to debug that weirdo hole
now = datetime.now()
if (t['morn_twil']['time'] < now and now < t['post_sunl']['time']) or (t['aft_twil']['time'] < now and now < t['off_time']['time']):
initial_state = Light.ON
else:
initial_state = Light.OFF
Machine.__init__(self, states=states, initial=initial_state)
SwitchScheduler.__init__(self, point=self.conf['home'])
SwitchMate.__init__(self, self.conf)
self.add_transition('on', Light.OFF, Light.ON, before='on_state', after='check_state')
self.add_transition('off', Light.ON, Light.OFF, before='off_state', after='check_state')
def on_state(self):
set_log_level(logging.INFO)
self.switchon()
def off_state(self):
set_log_level(logging.INFO)
self.switchoff()
def check_state(self):
self.mystery_state = Light.UNKN
logging.info(f"check_state: {self.state}")
two_mins = datetime.now() + timedelta(minutes=2)
sched.add_job(self.verify_state, 'date', run_date=two_mins)
def verify_state(self):
logging.debug("verify_state()")
if self.mystery_state == Light.UNKN:
status = self.status()
# XXX wouldn't need VERIFY_TABLE if status & self.state were the same type to verify against
if VERIFY_TABLE[status] == self.state:
logging.info(f"status is ✅ {status} 💡")
self.mystery_state = VERIFY_TABLE[status]
elif VERIFY_TABLE[status] == Light.UNKN:
logging.info("status is ❓")
self.mystery_state = Light.UNKN
elif VERIFY_TABLE[status] != self.state:
logging.info("status is 🚫 toggling 💡")
self.toggle()
self.mystery_state = Light.UNKN
else:
logging.info("✅ status correct. quieting log messages")
set_log_level(logging.WARNING) # XXX this might not be working ... just suck it up and accept the log messages ... could do this via apscheduler.job.pause() but that would require a dance
if __name__ == "__main__":
dir_path = os.path.dirname(os.path.realpath(__file__))
conf = load_conf(f"{dir_path}/conf.yaml")
# create a logging handler that rotates at 3MB
handler = logging.handlers.RotatingFileHandler(conf['logfile'],
backupCount=3,
maxBytes=3*1000*1000)
logging.basicConfig(level=logging.INFO,
handlers=[handler],
format='%(asctime)s %(name)-12s %(levelname)-8s %(message)s',
datefmt='%m-%d %H:%M')
logging.info(f"making LightMachine()")
lm = LightMachine(conf)
logging.info(f"making BlockingScheduler()")
sched = BlockingScheduler({
'apscheduler.executors.default': {
'class': 'apscheduler.executors.pool:ThreadPoolExecutor',
'max_workers': conf['max_workers']
},
'apscheduler.job_defaults.coalesce': 'false',
'apscheduler.job_defaults.max_instances': conf['job_max_instances'],
}, daemon=True)
logging.info(f"adding scheduler cron 🕙")
hour, minute = conf['sched_time'].split(':')
sched.add_job(lm.scheduler, 'cron', hour=hour, minute=minute, args=[sched])
del hour, minute
logging.info(f"add verify_state cron")
sched.add_job(lm.verify_state, 'cron', minute=conf['verify_cron'])
# determine if a one off scheduler job is needed
now = datetime.now()
off_time = synth_off_time(conf['schedule']['off_time']['off_hour'])
sched_time = synth_sched_time(conf['sched_time'])
delay = conf['scheduler_delay']
if (off_time < sched_time) and (sched_time < now or now < off_time):
logging.info("📆 sched_time < now() < off_time adding scheduler in 🕑 2m")
sched.add_job(lm.scheduler, 'date', run_date=(now+timedelta(minutes=delay)), args=[sched])
del now, off_time, sched_time, delay
logging.info(f"start BlockingScheduler()")
sched.start()