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

1#!/usr/bin/env python3 

2import datetime 

3import enum 

4import logging 

5import os 

6import pathlib 

7import re 

8import threading 

9import time 

10import traceback 

11 

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 

20 

21 

22class BRIGHTNESS_STATE(enum.IntEnum): # noqa: N801 

23 DARK = 0 

24 BRIGHT = 1 

25 UNKNOWN = 2 

26 

27 

28RETRY_COUNT = 3 

29 

30schedule_lock = None 

31schedule_data = None 

32should_terminate = threading.Event() 

33 

34# Worker-specific scheduler instance for pytest-xdist parallel execution 

35_scheduler_instances = {} 

36 

37 

38def get_scheduler(): 

39 """Get worker-specific scheduler instance for pytest-xdist parallel execution""" 

40 worker_id = os.environ.get("PYTEST_XDIST_WORKER", "main") 

41 

42 if worker_id not in _scheduler_instances: 

43 # Create a new scheduler instance for this worker 

44 _scheduler_instances[worker_id] = schedule.Scheduler() 

45 

46 return _scheduler_instances[worker_id] 

47 

48 

49def init(): 

50 global schedule_lock # noqa: PLW0603 

51 global should_terminate 

52 

53 schedule_lock = threading.Lock() 

54 should_terminate.clear() 

55 

56 

57def term(): 

58 global should_terminate 

59 

60 should_terminate.set() 

61 

62 

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 ] 

75 

76 return ", ".join(text) 

77 

78 

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 

82 

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 

101 

102 

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()) 

113 

114 return False 

115 

116 

117def exec_shutter_control(config, state, mode, sense_data, user): 

118 logging.debug("Execute shutter control") 

119 

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") 

124 

125 my_lib.webapp.log.info("😵 シャッターの制御に失敗しました。") 

126 return False 

127 

128 

129def shutter_auto_open(config): 

130 logging.debug("try auto open") 

131 

132 if not schedule_data["open"]["is_active"]: 

133 logging.debug("inactive") 

134 return 

135 

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) 

143 

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 

151 

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}") 

156 

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 ) 

172 

173 

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 ) 

184 

185 

186def shutter_auto_close(config): 

187 logging.debug("try auto close") 

188 

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 

212 

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 

225 

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 ) 

232 

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) 

242 

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) 

248 

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 ) 

256 

257 

258def shutter_auto_control(config): 

259 hour = my_lib.time.now().hour 

260 

261 # NOTE: 時間帯によって自動制御の内容を分ける 

262 if (hour > 5) and (hour < 12): 

263 shutter_auto_open(config) 

264 

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) 

267 

268 

269def shutter_schedule_control(config, state): 

270 logging.info("Execute schedule control") 

271 

272 sense_data = rasp_shutter.webapp_sensor.get_sensor_data(config) 

273 

274 if check_brightness(sense_data, state) == BRIGHTNESS_STATE.UNKNOWN: 

275 error_sensor = [] 

276 

277 if not sense_data["solar_rad"]["valid"]: 

278 error_sensor.append("日射センサ") 

279 if not sense_data["lux"]["valid"]: 

280 error_sensor.append("照度センサ") 

281 

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 

289 

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}") 

294 

295 rasp_shutter.webapp_control.cmd_hist_push( 

296 { 

297 "cmd": "pending", 

298 "state": state, 

299 } 

300 ) 

301 

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 ) 

323 

324 

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 

329 

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 

358 

359 

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("😵 スケジュール設定の保存に失敗しました。") 

368 

369 

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 } 

379 

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 } 

384 

385 

386def schedule_load(): 

387 global schedule_lock 

388 

389 schedule_default = gen_schedule_default() 

390 

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("😵 スケジュール設定の読み出しに失敗しました。") 

399 

400 return schedule_default 

401 

402 

403def set_schedule(config, schedule_data): # noqa: C901 

404 scheduler = get_scheduler() 

405 scheduler.clear() 

406 

407 for state, entry in schedule_data.items(): 

408 if not entry["is_active"]: 

409 continue 

410 

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 ) 

439 

440 for job in scheduler.get_jobs(): 

441 logging.info("Next run: %s", job.next_run) 

442 

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) 

447 

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 ) 

455 

456 scheduler.every(10).seconds.do(shutter_auto_control, config) 

457 

458 

459def schedule_worker(config, queue): 

460 global should_terminate 

461 global schedule_data # noqa: PLW0603 

462 

463 sleep_sec = 0.5 

464 scheduler = get_scheduler() 

465 

466 liveness_file = pathlib.Path(config["liveness"]["file"]["scheduler"]) 

467 

468 logging.info("Load schedule") 

469 schedule_data = schedule_load() 

470 

471 set_schedule(config, schedule_data) 

472 

473 logging.info("Start schedule worker") 

474 

475 i = 0 

476 while True: 

477 if should_terminate.is_set(): 

478 scheduler.clear() 

479 break 

480 

481 try: 

482 if not queue.empty(): 

483 schedule_data = queue.get() 

484 set_schedule(config, schedule_data) 

485 schedule_store(schedule_data) 

486 

487 scheduler.run_pending() 

488 

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()) 

494 

495 if i % (10 / sleep_sec) == 0: 

496 my_lib.footprint.update(liveness_file) 

497 

498 i += 1 

499 

500 logging.info("Terminate schedule worker") 

501 

502 

503if __name__ == "__main__": 

504 import multiprocessing 

505 import multiprocessing.pool 

506 

507 import my_lib.config 

508 import my_lib.logger 

509 

510 my_lib.logger.init("test", level=logging.DEBUG) 

511 

512 def test_func(): 

513 logging.info("TEST") 

514 

515 should_terminate.set() 

516 

517 config = my_lib.config.load() 

518 queue = multiprocessing.Queue() 

519 

520 init() 

521 

522 pool = multiprocessing.pool.ThreadPool(processes=1) 

523 result = pool.apply_async(schedule_worker, (config, queue)) 

524 

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 ) 

539 

540 # NOTE: 終了するのを待つ 

541 result.get()