Coverage for src/webui.py: 71%
65 statements
« prev ^ index » next coverage.py v7.9.1, created at 2025-06-28 12:29 +0000
« 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 です。
5Usage:
6 webapp.py [-c CONFIG] [-s CONTROL_HOST] [-p PUB_PORT] [-a ACTUATOR_HOST] [-n COUNT] [-D] [-d]
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"""
18import atexit
19import logging
20import multiprocessing
21import os
22import pathlib
23import signal
24import threading
25import time
27import flask
28import flask_cors
29import my_lib.proc_util
31SCHEMA_CONFIG = "config.schema"
33# グローバル変数でワーカースレッドを管理
34worker_thread = None
37def term():
38 # ワーカーの終了フラグを設定
39 import unit_cooler.webui.worker
41 unit_cooler.webui.worker.term()
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")
50 # 子プロセスを終了
51 my_lib.proc_util.kill_child()
53 # プロセス終了
54 logging.info("Graceful shutdown completed")
55 os._exit(0)
58def signal_handler(signum, _frame):
59 """シグナルハンドラー: CTRL-Cや終了シグナルを受け取った際の処理"""
60 logging.info("Received signal %d, shutting down gracefully...", signum)
62 term()
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 }
75 setting.update(arg)
77 logging.info("Using ZMQ server of %s:%d", setting["control_host"], setting["pub_port"])
79 # NOTE: テストのため、環境変数 DUMMY_MODE をセットしてからロードしたいのでこの位置
80 import my_lib.webapp.config
82 my_lib.webapp.config.URL_PREFIX = "/unit_cooler"
83 my_lib.webapp.config.init(config["webui"])
85 import my_lib.webapp.base
86 import my_lib.webapp.proxy
87 import my_lib.webapp.util
89 import unit_cooler.webui.api.cooler_stat
90 import unit_cooler.webui.worker
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()
107 # NOTE: アクセスログは無効にする
108 logging.getLogger("werkzeug").setLevel(logging.ERROR)
110 app = flask.Flask("unit-cooler-webui")
112 # NOTE: アクセスログは無効にする
113 logging.getLogger("werkzeug").setLevel(logging.ERROR)
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
123 def notify_terminate(): # pragma: no cover
124 term()
125 my_lib.webapp.log.info("🏃 アプリを再起動します。")
126 my_lib.webapp.log.term()
128 atexit.register(notify_terminate)
129 else: # pragma: no cover
130 pass
132 flask_cors.CORS(app)
134 app.config["CONFIG"] = config
135 app.config["MESSAGE_QUEUE"] = message_queue
137 app.json.compat = True
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)
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)
151 my_lib.webapp.config.show_handler_list(app)
153 unit_cooler.webui.api.cooler_stat.init(api_base_url)
155 # app.debug = True
157 return app
160if __name__ == "__main__":
161 import docopt
162 import my_lib.config
163 import my_lib.logger
165 args = docopt.docopt(__doc__)
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"]
175 my_lib.logger.init("hems.unit_cooler", level=logging.DEBUG if debug_mode else logging.INFO)
177 config = my_lib.config.load(config_file, pathlib.Path(SCHEMA_CONFIG))
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 )
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()