Coverage for flask/src/rasp_shutter/scheduler.py: 98%
255 statements
« prev ^ index » next coverage.py v7.9.1, created at 2025-06-28 13:33 +0900
« prev ^ index » next coverage.py v7.9.1, created at 2025-06-28 13:33 +0900
1#!/usr/bin/env python3
2import datetime
3import enum
4import logging
5import os
6import pathlib
7import re
8import threading
9import time
10import traceback
12import my_lib.footprint
13import my_lib.serializer
14import my_lib.webapp.config
15import my_lib.webapp.log
16import rasp_shutter.config
17import rasp_shutter.webapp_control
18import rasp_shutter.webapp_sensor
19import schedule
22class BRIGHTNESS_STATE(enum.IntEnum): # noqa: N801
23 DARK = 0
24 BRIGHT = 1
25 UNKNOWN = 2
28RETRY_COUNT = 3
30schedule_lock = None
31schedule_data = None
32should_terminate = threading.Event()
34# Worker-specific scheduler instance for pytest-xdist parallel execution
35_scheduler_instances = {}
38def get_scheduler():
39 """Get worker-specific scheduler instance for pytest-xdist parallel execution"""
40 worker_id = os.environ.get("PYTEST_XDIST_WORKER", "main")
42 if worker_id not in _scheduler_instances:
43 # Create a new scheduler instance for this worker
44 _scheduler_instances[worker_id] = schedule.Scheduler()
46 return _scheduler_instances[worker_id]
49def init():
50 global schedule_lock # noqa: PLW0603
51 global should_terminate
53 schedule_lock = threading.Lock()
54 should_terminate.clear()
57def term():
58 global should_terminate
60 should_terminate.set()
63def brightness_text(sense_data, cur_schedule_data):
64 text = [
65 "{sensor}: current {current:.1f} {cmp} threshold {threshold:.1f}".format(
66 sensor=sensor,
67 current=sense_data[sensor]["value"],
68 threshold=cur_schedule_data[sensor],
69 cmp=">"
70 if sense_data[sensor]["value"] > cur_schedule_data[sensor]
71 else ("<" if sense_data[sensor]["value"] < cur_schedule_data[sensor] else "="),
72 )
73 for sensor in ["solar_rad", "lux", "altitude"]
74 ]
76 return ", ".join(text)
79def check_brightness(sense_data, action):
80 if (not sense_data["lux"]["valid"]) or (not sense_data["solar_rad"]["valid"]):
81 return BRIGHTNESS_STATE.UNKNOWN
83 if action == "close":
84 if (
85 (sense_data["lux"]["value"] < schedule_data[action]["lux"])
86 and (sense_data["solar_rad"]["value"] < schedule_data[action]["solar_rad"])
87 ) or (sense_data["altitude"]["value"] < schedule_data[action]["altitude"]):
88 logging.info("Getting darker %s", brightness_text(sense_data, schedule_data[action]))
89 return BRIGHTNESS_STATE.DARK
90 else:
91 return BRIGHTNESS_STATE.BRIGHT
92 else: # noqa: PLR5501
93 if (
94 (sense_data["lux"]["value"] > schedule_data[action]["lux"])
95 or (sense_data["solar_rad"]["value"] > schedule_data[action]["solar_rad"])
96 ) and (sense_data["altitude"]["value"] > schedule_data[action]["altitude"]):
97 logging.info("Getting brighter %s", brightness_text(sense_data, schedule_data[action]))
98 return BRIGHTNESS_STATE.BRIGHT
99 else:
100 return BRIGHTNESS_STATE.DARK
103def exec_shutter_control_impl(config, state, mode, sense_data, user):
104 try:
105 # NOTE: Web 経由だと認証つけた場合に困るので、直接関数を呼ぶ
106 rasp_shutter.webapp_control.set_shutter_state(
107 config, list(range(len(config["shutter"]))), state, mode, sense_data, user
108 )
109 return True
110 except Exception as e:
111 logging.warning(e)
112 logging.warning(traceback.format_exc())
114 return False
117def exec_shutter_control(config, state, mode, sense_data, user):
118 logging.debug("Execute shutter control")
120 for _ in range(RETRY_COUNT):
121 if exec_shutter_control_impl(config, state, mode, sense_data, user):
122 return True
123 logging.debug("Retry")
125 my_lib.webapp.log.info("😵 シャッターの制御に失敗しました。")
126 return False
129def shutter_auto_open(config):
130 logging.debug("try auto open")
132 if not schedule_data["open"]["is_active"]:
133 logging.debug("inactive")
134 return
136 elapsed_pendiing_open = my_lib.footprint.elapsed(rasp_shutter.config.STAT_PENDING_OPEN)
137 if elapsed_pendiing_open > 6 * 60 * 60:
138 # NOTE: 暗くて開けるのを延期されている場合以外は処理を行わない。
139 logging.debug("NOT pending")
140 return
141 else:
142 logging.debug("Elapsed time since pending open: %s", elapsed_pendiing_open)
144 if (
145 my_lib.footprint.elapsed(rasp_shutter.config.STAT_AUTO_CLOSE)
146 < rasp_shutter.config.EXEC_INTERVAL_AUTO_MIN * 60
147 ):
148 # NOTE: 自動で閉めてから時間が経っていない場合は、処理を行わない。
149 logging.debug("just closed before %d", my_lib.footprint.elapsed(rasp_shutter.config.STAT_AUTO_CLOSE))
150 return
152 sense_data = rasp_shutter.webapp_sensor.get_sensor_data(config)
153 if check_brightness(sense_data, "open") == BRIGHTNESS_STATE.BRIGHT:
154 sensor_text = rasp_shutter.webapp_control.sensor_text(sense_data)
155 my_lib.webapp.log.info(f"📝 暗くて延期されていましたが、明るくなってきたので開けます。{sensor_text}")
157 exec_shutter_control(
158 config,
159 "open",
160 rasp_shutter.webapp_control.CONTROL_MODE.AUTO,
161 sense_data,
162 "sensor",
163 )
164 my_lib.footprint.clear(rasp_shutter.config.STAT_PENDING_OPEN)
165 my_lib.footprint.clear(rasp_shutter.config.STAT_AUTO_CLOSE)
166 else:
167 logging.debug(
168 "Skip pendding open (solar_rad: %.1f W/m^2, lux: %.1f LUX)",
169 sense_data["solar_rad"]["value"],
170 sense_data["lux"]["value"],
171 )
174def conv_schedule_time_to_datetime(schedule_time):
175 return (
176 datetime.datetime.strptime(
177 my_lib.time.now().strftime("%Y/%m/%d ") + schedule_time,
178 "%Y/%m/%d %H:%M",
179 )
180 ).replace(
181 tzinfo=my_lib.time.get_zoneinfo(),
182 day=my_lib.time.now().day,
183 )
186def shutter_auto_close(config):
187 logging.debug("try auto close")
189 if not schedule_data["close"]["is_active"]:
190 logging.debug("inactive")
191 return
192 elif abs(
193 my_lib.time.now() - conv_schedule_time_to_datetime(schedule_data["open"]["time"])
194 ) < datetime.timedelta(minutes=1):
195 # NOTE: 開ける時刻付近の場合は処理しない
196 logging.debug("near open time")
197 return
198 elif (
199 my_lib.time.now() <= conv_schedule_time_to_datetime(schedule_data["open"]["time"])
200 ) or my_lib.footprint.exists(rasp_shutter.config.STAT_PENDING_OPEN):
201 # NOTE: 開ける時刻よりも早い場合は処理しない
202 logging.debug("before open time")
203 return
204 elif conv_schedule_time_to_datetime(schedule_data["close"]["time"]) <= my_lib.time.now():
205 # NOTE: スケジュールで閉めていた場合は処理しない
206 logging.debug("after close time")
207 return
208 elif my_lib.footprint.elapsed(rasp_shutter.config.STAT_AUTO_CLOSE) <= 12 * 60 * 60:
209 # NOTE: 12時間以内に自動で閉めていた場合は処理しない
210 logging.debug("already close")
211 return
213 for index in range(len(config["shutter"])):
214 if (
215 my_lib.footprint.elapsed(rasp_shutter.webapp_control.exec_stat_file("open", index))
216 < rasp_shutter.config.EXEC_INTERVAL_AUTO_MIN * 60
217 ):
218 # NOTE: 自動で開けてから時間が経っていない場合は、処理を行わない。
219 logging.debug(
220 "just opened before %d sec (%d)",
221 my_lib.footprint.elapsed(rasp_shutter.webapp_control.exec_stat_file("open", index)),
222 index,
223 )
224 return
226 sense_data = rasp_shutter.webapp_sensor.get_sensor_data(config)
227 if check_brightness(sense_data, "close") == BRIGHTNESS_STATE.DARK:
228 sensor_text = rasp_shutter.webapp_control.sensor_text(sense_data)
229 my_lib.webapp.log.info(
230 f"📝 予定より早いですが、暗くなってきたので閉めます。{sensor_text}",
231 )
233 exec_shutter_control(
234 config,
235 "close",
236 rasp_shutter.webapp_control.CONTROL_MODE.AUTO,
237 sense_data,
238 "sensor",
239 )
240 logging.info("Set Auto CLOSE")
241 my_lib.footprint.update(rasp_shutter.config.STAT_AUTO_CLOSE)
243 # NOTE: まだ明るくなる可能性がある時間帯の場合、再度自動的に開けるようにする
244 hour = my_lib.time.now().hour
245 if (hour > 5) and (hour < 13):
246 logging.info("Set Pending OPEN")
247 my_lib.footprint.update(rasp_shutter.config.STAT_PENDING_OPEN)
249 else: # pragma: no cover
250 # NOTE: pending close の制御は無いのでここには来ない。
251 logging.debug(
252 "Skip pendding close (solar_rad: %.1f W/m^2, lux: %.1f LUX)",
253 sense_data["solar_rad"]["value"] if sense_data["solar_rad"]["valid"] else -1,
254 sense_data["lux"]["value"] if sense_data["solar_rad"]["valid"] else -1,
255 )
258def shutter_auto_control(config):
259 hour = my_lib.time.now().hour
261 # NOTE: 時間帯によって自動制御の内容を分ける
262 if (hour > 5) and (hour < 12):
263 shutter_auto_open(config)
265 if (hour > 5) and (hour < 20): 265 ↛ exitline 265 didn't return from function 'shutter_auto_control' because the condition on line 265 was always true
266 shutter_auto_close(config)
269def shutter_schedule_control(config, state):
270 logging.info("Execute schedule control")
272 sense_data = rasp_shutter.webapp_sensor.get_sensor_data(config)
274 if check_brightness(sense_data, state) == BRIGHTNESS_STATE.UNKNOWN:
275 error_sensor = []
277 if not sense_data["solar_rad"]["valid"]:
278 error_sensor.append("日射センサ")
279 if not sense_data["lux"]["valid"]:
280 error_sensor.append("照度センサ")
282 my_lib.webapp.log.error(
283 "😵 {error_sensor}の値が不明なので{state}るのを見合わせました。".format(
284 error_sensor="と".join(error_sensor),
285 state="開け" if state == "open" else "閉め",
286 )
287 )
288 return
290 if state == "open":
291 if check_brightness(sense_data, state) == BRIGHTNESS_STATE.DARK:
292 sensor_text = rasp_shutter.webapp_control.sensor_text(sense_data)
293 my_lib.webapp.log.info(f"📝 まだ暗いので開けるのを見合わせました。{sensor_text}")
295 rasp_shutter.webapp_control.cmd_hist_push(
296 {
297 "cmd": "pending",
298 "state": state,
299 }
300 )
302 # NOTE: 暗いので開けれなかったことを通知
303 logging.info("Set Pending OPEN")
304 my_lib.footprint.update(rasp_shutter.config.STAT_PENDING_OPEN)
305 else:
306 # NOTE: ここにきたときのみ、スケジュールに従って開ける
307 exec_shutter_control(
308 config,
309 state,
310 rasp_shutter.webapp_control.CONTROL_MODE.SCHEDULE,
311 sense_data,
312 "scheduler",
313 )
314 else:
315 my_lib.footprint.clear(rasp_shutter.config.STAT_PENDING_OPEN)
316 exec_shutter_control(
317 config,
318 state,
319 rasp_shutter.webapp_control.CONTROL_MODE.SCHEDULE,
320 sense_data,
321 "scheduler",
322 )
325def schedule_validate(schedule_data): # noqa: C901, PLR0911
326 if len(schedule_data) != 2:
327 logging.warning("Count of entry is Invalid: %d", len(schedule_data))
328 return False
330 for entry in schedule_data.values():
331 for key in ["is_active", "time", "wday", "solar_rad", "lux", "altitude"]:
332 if key not in entry:
333 logging.warning("Does not contain %s", key)
334 return False
335 if type(entry["is_active"]) is not bool:
336 logging.warning("Type of is_active is invalid: %s", type(entry["is_active"]))
337 return False
338 if type(entry["lux"]) is not int:
339 logging.warning("Type of lux is invalid: %s", type(entry["lux"]))
340 return False
341 if type(entry["altitude"]) is not int: 341 ↛ 342line 341 didn't jump to line 342 because the condition on line 341 was never true
342 logging.warning("Type of altitude is invalid: %s", type(entry["altitude"]))
343 return False
344 if type(entry["solar_rad"]) is not int:
345 logging.warning("Type of solar_rad is invalid: %s", type(entry["solar_rad"]))
346 return False
347 if not re.compile(r"\d{2}:\d{2}").search(entry["time"]):
348 logging.warning("Format of time is invalid: %s", entry["time"])
349 return False
350 if len(entry["wday"]) != 7:
351 logging.warning("Count of wday is Invalid: %d", len(entry["wday"]))
352 return False
353 for i, wday_flag in enumerate(entry["wday"]):
354 if type(wday_flag) is not bool:
355 logging.warning("Type of wday[%d] is Invalid: %s", i, type(entry["wday"][i]))
356 return False
357 return True
360def schedule_store(schedule_data):
361 global schedule_lock
362 try:
363 with schedule_lock:
364 my_lib.serializer.store(my_lib.webapp.config.SCHEDULE_FILE_PATH, schedule_data)
365 except Exception:
366 logging.exception("Failed to save schedule settings.")
367 my_lib.webapp.log.error("😵 スケジュール設定の保存に失敗しました。")
370def gen_schedule_default():
371 schedule_data = {
372 "is_active": False,
373 "time": "00:00",
374 "solar_rad": 0,
375 "lux": 0,
376 "altitude": 0,
377 "wday": [True] * 7,
378 }
380 return {
381 "open": schedule_data | {"time": "08:00", "solar_rad": 150, "lux": 1000},
382 "close": schedule_data | {"time": "17:00", "solar_rad": 80, "lux": 1200},
383 }
386def schedule_load():
387 global schedule_lock
389 schedule_default = gen_schedule_default()
391 try:
392 with schedule_lock:
393 schedule_data = my_lib.serializer.load(my_lib.webapp.config.SCHEDULE_FILE_PATH, schedule_default)
394 if schedule_validate(schedule_data):
395 return schedule_data
396 except Exception:
397 logging.exception("Failed to load schedule settings.")
398 my_lib.webapp.log.error("😵 スケジュール設定の読み出しに失敗しました。")
400 return schedule_default
403def set_schedule(config, schedule_data): # noqa: C901
404 scheduler = get_scheduler()
405 scheduler.clear()
407 for state, entry in schedule_data.items():
408 if not entry["is_active"]:
409 continue
411 if entry["wday"][0]:
412 scheduler.every().sunday.at(entry["time"], my_lib.time.get_pytz()).do(
413 shutter_schedule_control, config, state
414 )
415 if entry["wday"][1]:
416 scheduler.every().monday.at(entry["time"], my_lib.time.get_pytz()).do(
417 shutter_schedule_control, config, state
418 )
419 if entry["wday"][2]:
420 scheduler.every().tuesday.at(entry["time"], my_lib.time.get_pytz()).do(
421 shutter_schedule_control, config, state
422 )
423 if entry["wday"][3]:
424 scheduler.every().wednesday.at(entry["time"], my_lib.time.get_pytz()).do(
425 shutter_schedule_control, config, state
426 )
427 if entry["wday"][4]:
428 scheduler.every().thursday.at(entry["time"], my_lib.time.get_pytz()).do(
429 shutter_schedule_control, config, state
430 )
431 if entry["wday"][5]:
432 scheduler.every().friday.at(entry["time"], my_lib.time.get_pytz()).do(
433 shutter_schedule_control, config, state
434 )
435 if entry["wday"][6]:
436 scheduler.every().saturday.at(entry["time"], my_lib.time.get_pytz()).do(
437 shutter_schedule_control, config, state
438 )
440 for job in scheduler.get_jobs():
441 logging.info("Next run: %s", job.next_run)
443 idle_sec = scheduler.idle_seconds
444 if idle_sec is not None:
445 hours, remainder = divmod(idle_sec, 3600)
446 minutes, seconds = divmod(remainder, 60)
448 logging.info(
449 "Now is %s, time to next jobs is %d hour(s) %d minute(s) %d second(s)",
450 my_lib.time.now().strftime("%Y-%m-%d %H:%M"),
451 hours,
452 minutes,
453 seconds,
454 )
456 scheduler.every(10).seconds.do(shutter_auto_control, config)
459def schedule_worker(config, queue):
460 global should_terminate
461 global schedule_data # noqa: PLW0603
463 sleep_sec = 0.5
464 scheduler = get_scheduler()
466 liveness_file = pathlib.Path(config["liveness"]["file"]["scheduler"])
468 logging.info("Load schedule")
469 schedule_data = schedule_load()
471 set_schedule(config, schedule_data)
473 logging.info("Start schedule worker")
475 i = 0
476 while True:
477 if should_terminate.is_set():
478 scheduler.clear()
479 break
481 try:
482 if not queue.empty():
483 schedule_data = queue.get()
484 set_schedule(config, schedule_data)
485 schedule_store(schedule_data)
487 scheduler.run_pending()
489 logging.debug("Sleep %.1f sec...", sleep_sec)
490 time.sleep(sleep_sec)
491 except OverflowError: # pragma: no cover
492 # NOTE: テストする際、freezer 使って日付をいじるとこの例外が発生する
493 logging.debug(traceback.format_exc())
495 if i % (10 / sleep_sec) == 0:
496 my_lib.footprint.update(liveness_file)
498 i += 1
500 logging.info("Terminate schedule worker")
503if __name__ == "__main__":
504 import multiprocessing
505 import multiprocessing.pool
507 import my_lib.config
508 import my_lib.logger
510 my_lib.logger.init("test", level=logging.DEBUG)
512 def test_func():
513 logging.info("TEST")
515 should_terminate.set()
517 config = my_lib.config.load()
518 queue = multiprocessing.Queue()
520 init()
522 pool = multiprocessing.pool.ThreadPool(processes=1)
523 result = pool.apply_async(schedule_worker, (config, queue))
525 exec_time = my_lib.time.now() + datetime.timedelta(seconds=5)
526 queue.put(
527 {
528 "open": {
529 "time": exec_time.strftime("%H:%M"),
530 "is_active": True,
531 "wday": [True] * 7,
532 "solar_rad": 0,
533 "lux": 0,
534 "altitude": 0,
535 "func": test_func,
536 }
537 }
538 )
540 # NOTE: 終了するのを待つ
541 result.get()