Coverage for src/webui.py: 70%

63 statements  

« prev     ^ index     » next       coverage.py v7.9.1, created at 2025-07-23 14:35 +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] [-l LOG_PORT] [-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 -l LOG_PORT : 動作ログを提供するアクチュエータの WEB サーバーのポートを指定します。 [default: 5001] 

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

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

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

17""" 

18 

19import atexit 

20import logging 

21import multiprocessing 

22import os 

23import pathlib 

24import signal 

25import threading 

26 

27import flask 

28import flask_cors 

29import my_lib.proc_util 

30import my_lib.webapp.base 

31import my_lib.webapp.config 

32import my_lib.webapp.proxy 

33import my_lib.webapp.util 

34 

35import unit_cooler.webui.webapi.cooler_stat 

36import unit_cooler.webui.worker 

37 

38SCHEMA_CONFIG = "config.schema" 

39 

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

41worker_thread = None 

42 

43 

44def term(): 

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

46 import unit_cooler.webui.worker 

47 

48 unit_cooler.webui.worker.term() 

49 

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

51 if worker_thread and worker_thread.is_alive(): 

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

53 worker_thread.join(timeout=5) 

54 if worker_thread.is_alive(): 

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

56 

57 # 子プロセスを終了 

58 my_lib.proc_util.kill_child() 

59 

60 # プロセス終了 

61 logging.info("Graceful shutdown completed") 

62 os._exit(0) 

63 

64 

65def signal_handler(signum, _frame): 

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

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

68 

69 term() 

70 

71 

72def create_app(config, arg): 

73 setting = { 

74 "control_host": "localhost", 

75 "pub_port": 2222, 

76 "actuator_host": "localhost", 

77 "log_port": 5001, 

78 "dummy_mode": False, 

79 "msg_count": 0, 

80 } 

81 

82 setting.update(arg) 

83 

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

85 

86 my_lib.webapp.config.URL_PREFIX = "/unit-cooler" 

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

88 

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

90 global worker_thread # noqa: PLW0603 

91 worker_thread = threading.Thread( 

92 target=unit_cooler.webui.worker.subscribe_worker, 

93 args=( 

94 config, 

95 setting["control_host"], 

96 setting["pub_port"], 

97 message_queue, 

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

99 setting["msg_count"], 

100 ), 

101 ) 

102 worker_thread.start() 

103 

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

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

106 

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

108 

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

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

111 

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

113 if setting["dummy_mode"]: 

114 logging.warning("Set dummy mode") 

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

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

117 else: # pragma: no cover 

118 pass 

119 

120 def notify_terminate(): # pragma: no cover 

121 term() 

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

123 my_lib.webapp.log.term() 

124 

125 atexit.register(notify_terminate) 

126 else: # pragma: no cover 

127 pass 

128 

129 flask_cors.CORS(app) 

130 

131 app.config["CONFIG"] = config 

132 app.config["MESSAGE_QUEUE"] = message_queue 

133 

134 app.json.compat = True 

135 

136 # Initialize proxy before registering blueprint 

137 api_base_url = f"http://{setting['actuator_host']}:{setting['log_port']}/unit-cooler" 

138 my_lib.webapp.proxy.init(api_base_url) 

139 

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

141 app.register_blueprint(my_lib.webapp.base.blueprint, url_prefix=my_lib.webapp.config.URL_PREFIX) 

142 app.register_blueprint(my_lib.webapp.proxy.blueprint, url_prefix=my_lib.webapp.config.URL_PREFIX) 

143 app.register_blueprint(my_lib.webapp.util.blueprint, url_prefix=my_lib.webapp.config.URL_PREFIX) 

144 app.register_blueprint( 

145 unit_cooler.webui.webapi.cooler_stat.blueprint, url_prefix=my_lib.webapp.config.URL_PREFIX 

146 ) 

147 

148 my_lib.webapp.config.show_handler_list(app) 

149 

150 unit_cooler.webui.webapi.cooler_stat.init(api_base_url) 

151 

152 # app.debug = True 

153 

154 return app 

155 

156 

157if __name__ == "__main__": 

158 import docopt 

159 import my_lib.config 

160 import my_lib.logger 

161 

162 args = docopt.docopt(__doc__) 

163 

164 config_file = args["-c"] 

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

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

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

168 log_port = int(os.environ.get("HEMS_LOG_PORT", args["-l"])) 

169 dummy_mode = args["-d"] 

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

171 debug_mode = args["-D"] 

172 

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

174 

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

176 

177 app = create_app( 

178 config, 

179 { 

180 "control_host": control_host, 

181 "pub_port": pub_port, 

182 "actuator_host": actuator_host, 

183 "log_port": log_port, 

184 "dummy_mode": dummy_mode, 

185 "msg_count": msg_count, 

186 }, 

187 ) 

188 

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

190 try: 

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

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

193 except KeyboardInterrupt: 

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

195 signal_handler(signal.SIGINT, None) 

196 finally: 

197 term()