Coverage for src/webui.py: 71%

65 statements  

« prev     ^ index     » next       coverage.py v7.9.1, created at 2025-06-28 12:29 +0000

1#!/usr/bin/env python3 

2""" 

3エアコン室外機冷却システムの Web UI です。 

4 

5Usage: 

6 webapp.py [-c CONFIG] [-s CONTROL_HOST] [-p PUB_PORT] [-a ACTUATOR_HOST] [-n COUNT] [-D] [-d] 

7 

8Options: 

9 -c CONFIG : CONFIG を設定ファイルとして読み込んで実行します。 [default: config.yaml] 

10 -s CONTROL_HOST : コントローラのホスト名を指定します。 [default: localhost] 

11 -p PUB_PORT : ZeroMQ の Pub サーバーを動作させるポートを指定します。 [default: 2222] 

12 -a ACTUATOR_HOST : アクチュエータのホスト名を指定します。 [default: localhost] 

13 -n COUNT : n 回制御メッセージを受信したら終了します。0 は制限なし。 [default: 0] 

14 -d : ダミーモードで実行します。 

15 -D : デバッグモードで動作します。 

16""" 

17 

18import atexit 

19import logging 

20import multiprocessing 

21import os 

22import pathlib 

23import signal 

24import threading 

25import time 

26 

27import flask 

28import flask_cors 

29import my_lib.proc_util 

30 

31SCHEMA_CONFIG = "config.schema" 

32 

33# グローバル変数でワーカースレッドを管理 

34worker_thread = None 

35 

36 

37def term(): 

38 # ワーカーの終了フラグを設定 

39 import unit_cooler.webui.worker 

40 

41 unit_cooler.webui.worker.term() 

42 

43 # ワーカースレッドの終了を待つ 

44 if worker_thread and worker_thread.is_alive(): 

45 logging.info("Waiting for worker thread to finish...") 

46 worker_thread.join(timeout=5) 

47 if worker_thread.is_alive(): 

48 logging.warning("Worker thread did not finish in time") 

49 

50 # 子プロセスを終了 

51 my_lib.proc_util.kill_child() 

52 

53 # プロセス終了 

54 logging.info("Graceful shutdown completed") 

55 os._exit(0) 

56 

57 

58def signal_handler(signum, _frame): 

59 """シグナルハンドラー: CTRL-Cや終了シグナルを受け取った際の処理""" 

60 logging.info("Received signal %d, shutting down gracefully...", signum) 

61 

62 term() 

63 

64 

65def create_app(config, arg): 

66 setting = { 

67 "control_host": "localhost", 

68 "pub_port": 2222, 

69 "actuator_host": "localhost", 

70 "log_port": 5001, 

71 "dummy_mode": False, 

72 "msg_count": 0, 

73 } 

74 

75 setting.update(arg) 

76 

77 logging.info("Using ZMQ server of %s:%d", setting["control_host"], setting["pub_port"]) 

78 

79 # NOTE: テストのため、環境変数 DUMMY_MODE をセットしてからロードしたいのでこの位置 

80 import my_lib.webapp.config 

81 

82 my_lib.webapp.config.URL_PREFIX = "/unit_cooler" 

83 my_lib.webapp.config.init(config["webui"]) 

84 

85 import my_lib.webapp.base 

86 import my_lib.webapp.proxy 

87 import my_lib.webapp.util 

88 

89 import unit_cooler.webui.api.cooler_stat 

90 import unit_cooler.webui.worker 

91 

92 message_queue = multiprocessing.Manager().Queue(10) 

93 global worker_thread # noqa: PLW0603 

94 worker_thread = threading.Thread( 

95 target=unit_cooler.webui.worker.subscribe_worker, 

96 args=( 

97 config, 

98 setting["control_host"], 

99 setting["pub_port"], 

100 message_queue, 

101 pathlib.Path(config["webui"]["subscribe"]["liveness"]["file"]), 

102 setting["msg_count"], 

103 ), 

104 ) 

105 worker_thread.start() 

106 

107 # NOTE: アクセスログは無効にする 

108 logging.getLogger("werkzeug").setLevel(logging.ERROR) 

109 

110 app = flask.Flask("unit-cooler-webui") 

111 

112 # NOTE: アクセスログは無効にする 

113 logging.getLogger("werkzeug").setLevel(logging.ERROR) 

114 

115 if os.environ.get("WERKZEUG_RUN_MAIN") == "true": 

116 if setting["dummy_mode"]: 

117 logging.warning("Set dummy mode") 

118 # NOTE: オプションでダミーモードが指定された場合、環境変数もそれに揃えておく 

119 os.environ["DUMMY_MODE"] = "true" 

120 else: # pragma: no cover 

121 pass 

122 

123 def notify_terminate(): # pragma: no cover 

124 term() 

125 my_lib.webapp.log.info("🏃 アプリを再起動します。") 

126 my_lib.webapp.log.term() 

127 

128 atexit.register(notify_terminate) 

129 else: # pragma: no cover 

130 pass 

131 

132 flask_cors.CORS(app) 

133 

134 app.config["CONFIG"] = config 

135 app.config["MESSAGE_QUEUE"] = message_queue 

136 

137 app.json.compat = True 

138 

139 # Initialize proxy before registering blueprint 

140 api_base_url = f"http://{setting['actuator_host']}:{setting['log_port']}/unit_cooler" 

141 # Set error_response to match old log_proxy.py behavior (return 200 with empty data) 

142 error_response = {"data": [], "last_time": time.time()} 

143 my_lib.webapp.proxy.init(api_base_url, error_response) 

144 

145 app.register_blueprint(my_lib.webapp.base.blueprint_default) 

146 app.register_blueprint(my_lib.webapp.base.blueprint) 

147 app.register_blueprint(my_lib.webapp.proxy.blueprint) 

148 app.register_blueprint(my_lib.webapp.util.blueprint) 

149 app.register_blueprint(unit_cooler.webui.api.cooler_stat.blueprint) 

150 

151 my_lib.webapp.config.show_handler_list(app) 

152 

153 unit_cooler.webui.api.cooler_stat.init(api_base_url) 

154 

155 # app.debug = True 

156 

157 return app 

158 

159 

160if __name__ == "__main__": 

161 import docopt 

162 import my_lib.config 

163 import my_lib.logger 

164 

165 args = docopt.docopt(__doc__) 

166 

167 config_file = args["-c"] 

168 control_host = os.environ.get("HEMS_CONTROL_HOST", args["-s"]) 

169 pub_port = int(os.environ.get("HEMS_PUB_PORT", args["-p"])) 

170 actuator_host = os.environ.get("HEMS_ACTUATOR_HOST", args["-a"]) 

171 dummy_mode = args["-d"] 

172 msg_count = int(args["-n"]) 

173 debug_mode = args["-D"] 

174 

175 my_lib.logger.init("hems.unit_cooler", level=logging.DEBUG if debug_mode else logging.INFO) 

176 

177 config = my_lib.config.load(config_file, pathlib.Path(SCHEMA_CONFIG)) 

178 

179 app = create_app( 

180 config, 

181 { 

182 "control_host": control_host, 

183 "pub_port": pub_port, 

184 "actuator_host": actuator_host, 

185 "log_port": config["actuator"]["log_server"]["webapp"]["port"], 

186 "dummy_mode": dummy_mode, 

187 "msg_count": msg_count, 

188 }, 

189 ) 

190 

191 # Flaskアプリケーションを実行 

192 try: 

193 # NOTE: スクリプトの自動リロード停止したい場合は use_reloader=False にする 

194 app.run(host="0.0.0.0", threaded=True, use_reloader=True, port=config["webui"]["webapp"]["port"]) # noqa: S104 

195 except KeyboardInterrupt: 

196 logging.info("Received KeyboardInterrupt, shutting down...") 

197 signal_handler(signal.SIGINT, None) 

198 finally: 

199 term()