emhass 0.11.4__py3-none-any.whl → 0.15.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
emhass/web_server.py CHANGED
@@ -1,26 +1,28 @@
1
1
  #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
2
 
4
3
  import argparse
5
- import json
4
+ import asyncio
6
5
  import logging
7
6
  import os
8
7
  import pickle
9
8
  import re
10
9
  import threading
11
- from distutils.util import strtobool
12
10
  from importlib.metadata import PackageNotFoundError, version
13
11
  from pathlib import Path
14
12
 
13
+ import aiofiles
14
+ import jinja2
15
+ import orjson
16
+ import uvicorn
15
17
  import yaml
16
- from flask import Flask, make_response, request
17
- from flask import logging as log
18
- from jinja2 import Environment, PackageLoader
19
- from waitress import serve
18
+ from markupsafe import Markup
19
+ from quart import Quart, make_response, request
20
+ from quart import logging as log
20
21
 
21
22
  from emhass.command_line import (
22
23
  continual_publish,
23
24
  dayahead_forecast_optim,
25
+ export_influxdb_to_csv,
24
26
  forecast_model_fit,
25
27
  forecast_model_predict,
26
28
  forecast_model_tune,
@@ -32,6 +34,7 @@ from emhass.command_line import (
32
34
  set_input_data_dict,
33
35
  weather_forecast_cache,
34
36
  )
37
+ from emhass.connection_manager import close_global_connection, get_websocket_client, is_connected
35
38
  from emhass.utils import (
36
39
  build_config,
37
40
  build_legacy_config_params,
@@ -43,273 +46,317 @@ from emhass.utils import (
43
46
  param_to_config,
44
47
  )
45
48
 
46
- # Define the Flask instance
47
- app = Flask(__name__)
48
- emhass_conf = {}
49
+ app = Quart(__name__)
49
50
 
51
+ emhass_conf: dict[str, Path] = {}
52
+ entity_path: Path = Path()
53
+ params_secrets: dict[str, str | float] = {}
54
+ continual_publish_thread: list = []
55
+ injection_dict: dict = {}
50
56
 
51
- def checkFileLog(refString=None) -> bool:
57
+ templates = jinja2.Environment(
58
+ autoescape=True,
59
+ loader=jinja2.PackageLoader("emhass", "templates"),
60
+ )
61
+
62
+ action_log_str = "action_logs.txt"
63
+ injection_dict_file = "injection_dict.pkl"
64
+ params_file = "params.pkl"
65
+ error_msg_associations_file = "Unable to obtain associations file"
66
+
67
+
68
+ # Add custom filter for trusted HTML content
69
+ def mark_safe(value):
70
+ """Mark pre-rendered HTML plots as safe (use only for trusted content)"""
71
+ if value is None:
72
+ return ""
73
+ return Markup(value)
74
+
75
+
76
+ templates.filters["mark_safe"] = mark_safe
77
+
78
+
79
+ # Register async startup and shutdown handlers
80
+ @app.before_serving
81
+ async def before_serving():
82
+ """Initialize EMHASS before starting to serve requests."""
83
+ # Initialize the application
84
+ try:
85
+ await initialize()
86
+ app.logger.info("Full initialization completed")
87
+ except Exception as e:
88
+ app.logger.warning(f"Full initialization failed (this is normal in test environments): {e}")
89
+ app.logger.info("Continuing without WebSocket connection...")
90
+ # The initialize() function already sets up all necessary components except WebSocket
91
+ # So we can continue serving requests even if WebSocket connection fails
92
+
93
+
94
+ @app.after_serving
95
+ async def after_serving():
96
+ """Clean up resources after serving."""
97
+ try:
98
+ # Only close WebSocket connection if it was established
99
+ if is_connected():
100
+ await close_global_connection()
101
+ app.logger.info("WebSocket connection closed")
102
+ else:
103
+ app.logger.info("No WebSocket connection to close")
104
+ except Exception as e:
105
+ app.logger.warning(f"WebSocket shutdown failed: {e}")
106
+ app.logger.info("Quart shutdown complete")
107
+
108
+
109
+ async def check_file_log(ref_string: str | None = None) -> bool:
52
110
  """
53
111
  Check logfile for error, anything after string match if provided.
54
112
 
55
- :param refString: String to reduce log area to check for errors. Use to reduce log to check anything after string match (ie. an action).
56
- :type refString: str
113
+ :param ref_string: String to reduce log area to check for errors. Use to reduce log to check anything after string match (ie. an action).
114
+ :type ref_string: str
57
115
  :return: Boolean return if error was found in logs
58
116
  :rtype: bool
59
117
 
60
118
  """
61
- if refString is not None:
62
- logArray = grabLog(
63
- refString
119
+ log_array: list[str] = []
120
+
121
+ if ref_string is not None:
122
+ log_array = await grab_log(
123
+ ref_string
64
124
  ) # grab reduced log array (everything after string match)
65
125
  else:
66
- if (emhass_conf["data_path"] / "actionLogs.txt").exists():
67
- with open(str(emhass_conf["data_path"] / "actionLogs.txt"), "r") as fp:
68
- logArray = fp.readlines()
126
+ if (emhass_conf["data_path"] / action_log_str).exists():
127
+ async with aiofiles.open(str(emhass_conf["data_path"] / action_log_str)) as fp:
128
+ content = await fp.read()
129
+ log_array = content.splitlines()
69
130
  else:
70
- app.logger.debug("Unable to obtain actionLogs.txt")
71
- for logString in logArray:
72
- if logString.split(" ", 1)[0] == "ERROR":
131
+ app.logger.debug("Unable to obtain {action_log_str}")
132
+ return False
133
+
134
+ for log_string in log_array:
135
+ if log_string.split(" ", 1)[0] == "ERROR":
73
136
  return True
74
137
  return False
75
138
 
76
139
 
77
- def grabLog(refString) -> list:
140
+ async def grab_log(ref_string: str | None = None) -> list[str]:
78
141
  """
79
142
  Find string in logs, append all lines after into list to return.
80
143
 
81
- :param refString: String used to string match log.
82
- :type refString: str
144
+ :param ref_string: String used to string match log.
145
+ :type ref_string: str
83
146
  :return: List of lines in log after string match.
84
147
  :rtype: list
85
148
 
86
149
  """
87
- isFound = []
150
+ is_found = []
88
151
  output = []
89
- if (emhass_conf["data_path"] / "actionLogs.txt").exists():
90
- with open(str(emhass_conf["data_path"] / "actionLogs.txt"), "r") as fp:
91
- logArray = fp.readlines()
92
- # Find all string matches, log key (line Number) in isFound
93
- for x in range(len(logArray) - 1):
94
- if re.search(refString, logArray[x]):
95
- isFound.append(x)
96
- if len(isFound) != 0:
97
- # Use last item in isFound to extract action logs
98
- for x in range(isFound[-1], len(logArray)):
99
- output.append(logArray[x])
152
+ if (emhass_conf["data_path"] / action_log_str).exists():
153
+ async with aiofiles.open(str(emhass_conf["data_path"] / action_log_str)) as fp:
154
+ content = await fp.read()
155
+ log_array = content.splitlines()
156
+ # Find all string matches, log key (line Number) in is_found
157
+ for x in range(len(log_array) - 1):
158
+ if re.search(ref_string, log_array[x]):
159
+ is_found.append(x)
160
+ if len(is_found) != 0:
161
+ # Use last item in is_found to extract action logs
162
+ for x in range(is_found[-1], len(log_array)):
163
+ output.append(log_array[x])
100
164
  return output
101
165
 
102
166
 
103
167
  # Clear the log file
104
- def clearFileLog():
168
+ async def clear_file_log():
105
169
  """
106
- Clear the contents of the log file (actionLogs.txt)
170
+ Clear the contents of the log file
107
171
 
108
172
  """
109
- if (emhass_conf["data_path"] / "actionLogs.txt").exists():
110
- with open(str(emhass_conf["data_path"] / "actionLogs.txt"), "w") as fp:
111
- fp.truncate()
173
+ if (emhass_conf["data_path"] / action_log_str).exists():
174
+ async with aiofiles.open(str(emhass_conf["data_path"] / action_log_str), "w") as fp:
175
+ await fp.write("")
112
176
 
113
177
 
114
178
  @app.route("/")
115
179
  @app.route("/index")
116
- def index():
180
+ async def index():
117
181
  """
118
182
  Render initial index page and serve to web server.
119
183
  Appends plot tables saved from previous optimization into index.html, then serves.
120
-
121
184
  """
122
185
  app.logger.info("EMHASS server online, serving index.html...")
123
- # Load HTML template
124
- file_loader = PackageLoader("emhass", "templates")
125
- env = Environment(loader=file_loader)
126
- # check if index.html exists
127
- if "index.html" not in env.list_templates():
128
- app.logger.error("Unable to find index.html in emhass module")
129
- return make_response(["ERROR: unable to find index.html in emhass module"], 404)
130
- template = env.get_template("index.html")
186
+
131
187
  # Load cached dict (if exists), to present generated plot tables
132
- if (emhass_conf["data_path"] / "injection_dict.pkl").exists():
133
- with open(str(emhass_conf["data_path"] / "injection_dict.pkl"), "rb") as fid:
134
- injection_dict = pickle.load(fid)
188
+ if (emhass_conf["data_path"] / injection_dict_file).exists():
189
+ async with aiofiles.open(str(emhass_conf["data_path"] / injection_dict_file), "rb") as fid:
190
+ content = await fid.read()
191
+ injection_dict = pickle.loads(content)
135
192
  else:
136
193
  app.logger.info(
137
194
  "The data container dictionary is empty... Please launch an optimization task"
138
195
  )
139
196
  injection_dict = {}
140
197
 
141
- # replace {{basename}} in html template html with path root
142
- # basename = request.headers.get("X-Ingress-Path", "")
143
- # return make_response(template.render(injection_dict=injection_dict, basename=basename))
144
-
145
- return make_response(template.render(injection_dict=injection_dict))
198
+ template = templates.get_template("index.html")
199
+ return await make_response(template.render(injection_dict=injection_dict))
146
200
 
147
201
 
148
202
  @app.route("/configuration")
149
- def configuration():
203
+ async def configuration():
150
204
  """
151
205
  Configuration page actions:
152
206
  Render and serve configuration page html
153
-
154
207
  """
155
208
  app.logger.info("serving configuration.html...")
156
- # Load HTML template
157
- file_loader = PackageLoader("emhass", "templates")
158
- env = Environment(loader=file_loader)
159
- # check if configuration.html exists
160
- if "configuration.html" not in env.list_templates():
161
- app.logger.error("Unable to find configuration.html in emhass module")
162
- return make_response(
163
- ["ERROR: unable to find configuration.html in emhass module"], 404
164
- )
165
- template = env.get_template("configuration.html")
166
- return make_response(template.render(config=params))
209
+
210
+ # get params
211
+ if (emhass_conf["data_path"] / params_file).exists():
212
+ async with aiofiles.open(str(emhass_conf["data_path"] / params_file), "rb") as fid:
213
+ content = await fid.read()
214
+ emhass_conf["config_path"], params = pickle.loads(content)
215
+ else:
216
+ # Safe fallback if params.pkl doesn't exist
217
+ params = {}
218
+
219
+ template = templates.get_template("configuration.html")
220
+ return await make_response(template.render(config=params))
167
221
 
168
222
 
169
223
  @app.route("/template", methods=["GET"])
170
- def template_action():
224
+ async def template_action():
171
225
  """
172
226
  template page actions:
173
227
  Render and serve template html
174
-
175
228
  """
176
- app.logger.info(" >> Sending rendered template table data")
177
- file_loader = PackageLoader("emhass", "templates")
178
- env = Environment(loader=file_loader)
179
- # Check if template.html exists
180
- if "template.html" not in env.list_templates():
181
- app.logger.error("Unable to find template.html in emhass module")
182
- return make_response(
183
- ["WARNING: unable to find template.html in emhass module"], 404
184
- )
185
- template = env.get_template("template.html")
186
- if (emhass_conf["data_path"] / "injection_dict.pkl").exists():
187
- with open(str(emhass_conf["data_path"] / "injection_dict.pkl"), "rb") as fid:
188
- injection_dict = pickle.load(fid)
229
+ app.logger.info(" >> Sending rendered template data")
230
+
231
+ if (emhass_conf["data_path"] / injection_dict_file).exists():
232
+ async with aiofiles.open(str(emhass_conf["data_path"] / injection_dict_file), "rb") as fid:
233
+ content = await fid.read()
234
+ injection_dict = pickle.loads(content)
189
235
  else:
190
- app.logger.warning("Unable to obtain plot data from injection_dict.pkl")
236
+ app.logger.warning("Unable to obtain plot data from {injection_dict_file}")
191
237
  app.logger.warning("Try running an launch an optimization task")
192
238
  injection_dict = {}
193
- return make_response(template.render(injection_dict=injection_dict))
239
+
240
+ template = templates.get_template("template.html")
241
+ return await make_response(template.render(injection_dict=injection_dict))
194
242
 
195
243
 
196
244
  @app.route("/get-config", methods=["GET"])
197
- def parameter_get():
245
+ async def parameter_get():
198
246
  """
199
247
  Get request action that builds, formats and sends config as json (config.json format)
200
248
 
201
249
  """
202
250
  app.logger.debug("Obtaining current saved parameters as config")
203
251
  # Build config from all possible sources (inc. legacy yaml config)
204
- config = build_config(
252
+ config = await build_config(
205
253
  emhass_conf,
206
254
  app.logger,
207
- emhass_conf["defaults_path"],
208
- emhass_conf["config_path"],
209
- emhass_conf["legacy_config_path"],
255
+ str(emhass_conf["defaults_path"]),
256
+ str(emhass_conf["config_path"]),
257
+ str(emhass_conf["legacy_config_path"]),
210
258
  )
211
259
  if type(config) is bool and not config:
212
- return make_response(["failed to retrieve default config file"], 500)
260
+ return await make_response(["failed to retrieve default config file"], 500)
213
261
  # Format parameters in config with params (converting legacy json parameters from options.json if any)
214
- params = build_params(emhass_conf, {}, config, app.logger)
262
+ params = await build_params(emhass_conf, {}, config, app.logger)
215
263
  if type(params) is bool and not params:
216
- return make_response(["Unable to obtain associations file"], 500)
264
+ return await make_response([error_msg_associations_file], 500)
217
265
  # Covert formatted parameters from params back into config.json format
218
266
  return_config = param_to_config(params, app.logger)
219
267
  # Send config
220
- return make_response(return_config, 201)
268
+ return await make_response(return_config, 201)
221
269
 
222
270
 
223
271
  # Get default Config
224
272
  @app.route("/get-config/defaults", methods=["GET"])
225
- def config_get():
273
+ async def config_get():
226
274
  """
227
275
  Get request action, retrieves and sends default configuration
228
276
 
229
277
  """
230
278
  app.logger.debug("Obtaining default parameters")
231
279
  # Build config, passing only default file
232
- config = build_config(emhass_conf, app.logger, emhass_conf["defaults_path"])
280
+ config = await build_config(emhass_conf, app.logger, str(emhass_conf["defaults_path"]))
233
281
  if type(config) is bool and not config:
234
- return make_response(["failed to retrieve default config file"], 500)
282
+ return await make_response(["failed to retrieve default config file"], 500)
235
283
  # Format parameters in config with params
236
- params = build_params(emhass_conf, {}, config, app.logger)
284
+ params = await build_params(emhass_conf, {}, config, app.logger)
237
285
  if type(params) is bool and not params:
238
- return make_response(["Unable to obtain associations file"], 500)
286
+ return await make_response([error_msg_associations_file], 500)
239
287
  # Covert formatted parameters from params back into config.json format
240
288
  return_config = param_to_config(params, app.logger)
241
289
  # Send params
242
- return make_response(return_config, 201)
290
+ return await make_response(return_config, 201)
243
291
 
244
292
 
245
293
  # Get YAML-to-JSON config
246
294
  @app.route("/get-json", methods=["POST"])
247
- def json_convert():
295
+ async def json_convert():
248
296
  """
249
297
  Post request action, receives yaml config (config_emhass.yaml or EMHASS-Add-on config page) and converts to config json format.
250
298
 
251
299
  """
252
300
  app.logger.info("Attempting to convert YAML to JSON")
253
- data = request.get_data()
301
+ data = await request.get_data()
254
302
  yaml_config = yaml.safe_load(data)
255
303
 
256
304
  # If filed to Parse YAML
257
305
  if yaml_config is None:
258
- return make_response(["failed to Parse YAML from data"], 400)
306
+ return await make_response(["failed to Parse YAML from data"], 400)
259
307
  # Test YAML is legacy config format (from config_emhass.yaml)
260
- test_legacy_config = build_legacy_config_params(
261
- emhass_conf, yaml_config, app.logger
262
- )
308
+ test_legacy_config = await build_legacy_config_params(emhass_conf, yaml_config, app.logger)
263
309
  if test_legacy_config:
264
310
  yaml_config = test_legacy_config
265
311
  # Format YAML to params (format params. check if params match legacy option.json format)
266
- params = build_params(emhass_conf, {}, yaml_config, app.logger)
312
+ params = await build_params(emhass_conf, {}, yaml_config, app.logger)
267
313
  if type(params) is bool and not params:
268
- return make_response(["Unable to obtain associations file"], 500)
314
+ return await make_response([error_msg_associations_file], 500)
269
315
  # Covert formatted parameters from params back into config.json format
270
316
  config = param_to_config(params, app.logger)
271
317
  # convert json to str
272
- config = json.dumps(config)
318
+ config = orjson.dumps(config).decode()
273
319
 
274
320
  # Send params
275
- return make_response(config, 201)
321
+ return await make_response(config, 201)
276
322
 
277
323
 
278
324
  @app.route("/set-config", methods=["POST"])
279
- def parameter_set():
325
+ async def parameter_set():
280
326
  """
281
327
  Receive JSON config, and save config to file (config.json and param.pkl)
282
328
 
283
329
  """
284
330
  config = {}
285
331
  if not emhass_conf["defaults_path"]:
286
- return make_response(["Unable to Obtain defaults_path from emhass_conf"], 500)
332
+ return await make_response(["Unable to Obtain defaults_path from emhass_conf"], 500)
287
333
  if not emhass_conf["config_path"]:
288
- return make_response(["Unable to Obtain config_path from emhass_conf"], 500)
334
+ return await make_response(["Unable to Obtain config_path from emhass_conf"], 500)
289
335
 
290
336
  # Load defaults as a reference point (for sorting) and a base to override
291
337
  if (
292
338
  os.path.exists(emhass_conf["defaults_path"])
293
339
  and Path(emhass_conf["defaults_path"]).is_file()
294
340
  ):
295
- with emhass_conf["defaults_path"].open("r") as data:
296
- config = json.load(data)
341
+ async with aiofiles.open(str(emhass_conf["defaults_path"])) as data:
342
+ content = await data.read()
343
+ config = orjson.loads(content)
297
344
  else:
298
345
  app.logger.warning(
299
346
  "Unable to obtain default config. only parameters passed from request will be saved to config.json"
300
347
  )
301
348
 
302
349
  # Retrieve sent config json
303
- request_data = request.get_json(force=True)
350
+ request_data = await request.get_json(force=True)
304
351
 
305
352
  # check if data is empty
306
353
  if len(request_data) == 0:
307
- return make_response(["failed to retrieve config json"], 400)
354
+ return await make_response(["failed to retrieve config json"], 400)
308
355
 
309
356
  # Format config by converting to params (format params. check if params match legacy option.json format. If so format)
310
- params = build_params(emhass_conf, params_secrets, request_data, app.logger)
357
+ params = await build_params(emhass_conf, params_secrets, request_data, app.logger)
311
358
  if type(params) is bool and not params:
312
- return make_response(["Unable to obtain associations file"], 500)
359
+ return await make_response([error_msg_associations_file], 500)
313
360
 
314
361
  # Covert formatted parameters from params back into config.json format.
315
362
  # Overwrite existing default parameters in config
@@ -317,256 +364,265 @@ def parameter_set():
317
364
 
318
365
  # Save config to config.json
319
366
  if os.path.exists(emhass_conf["config_path"].parent):
320
- with emhass_conf["config_path"].open("w") as f:
321
- json.dump(config, f, indent=4)
367
+ async with aiofiles.open(str(emhass_conf["config_path"]), "w") as f:
368
+ await f.write(orjson.dumps(config, option=orjson.OPT_INDENT_2).decode())
322
369
  else:
323
- return make_response(["Unable to save config file"], 500)
324
- request_data
370
+ return await make_response(["Unable to save config file"], 500)
325
371
 
326
372
  # Save params with updated config
327
373
  if os.path.exists(emhass_conf["data_path"]):
328
- with open(str(emhass_conf["data_path"] / "params.pkl"), "wb") as fid:
329
- pickle.dump(
374
+ async with aiofiles.open(str(emhass_conf["data_path"] / params_file), "wb") as fid:
375
+ content = pickle.dumps(
330
376
  (
331
- config_path,
332
- build_params(emhass_conf, params_secrets, config, app.logger),
333
- ),
334
- fid,
377
+ emhass_conf["config_path"],
378
+ await build_params(emhass_conf, params_secrets, config, app.logger),
379
+ )
335
380
  )
381
+ await fid.write(content)
336
382
  else:
337
- return make_response(["Unable to save params file, missing data_path"], 500)
383
+ return await make_response(["Unable to save params file, missing data_path"], 500)
338
384
 
339
385
  app.logger.info("Saved parameters from webserver")
340
- return make_response({}, 201)
386
+ return await make_response({}, 201)
341
387
 
342
388
 
343
- @app.route("/action/<action_name>", methods=["POST"])
344
- def action_call(action_name):
389
+ async def _load_params_and_runtime(request, emhass_conf, logger):
390
+ """
391
+ Loads configuration parameters from pickle and runtime parameters from the request.
392
+ Returns a tuple (params, costfun, runtimeparams) or raises an exception/returns None on failure.
345
393
  """
346
- Receive Post action, run action according to passed slug(action_name) (e.g. /action/publish-data)
394
+ action_str = " >> Obtaining params: "
395
+ logger.info(action_str)
347
396
 
348
- :param action_name: Slug/Action string corresponding to which action to take
349
- :type action_name: String
397
+ # Load params.pkl
398
+ params = None
399
+ costfun = "profit"
400
+ params_path = emhass_conf["data_path"] / params_file
350
401
 
351
- """
352
- # Setting up parameters
353
- # Params
354
- ActionStr = " >> Obtaining params: "
355
- app.logger.info(ActionStr)
356
- if (emhass_conf["data_path"] / "params.pkl").exists():
357
- with open(str(emhass_conf["data_path"] / "params.pkl"), "rb") as fid:
358
- emhass_conf["config_path"], params = pickle.load(fid)
402
+ if params_path.exists():
403
+ async with aiofiles.open(str(params_path), "rb") as fid:
404
+ content = await fid.read()
405
+ emhass_conf["config_path"], params = pickle.loads(content)
359
406
  # Set local costfun variable
360
- if params.get("optim_conf", None) is not None:
407
+ if params.get("optim_conf") is not None:
361
408
  costfun = params["optim_conf"].get("costfun", "profit")
362
- params = json.dumps(params)
409
+ params = orjson.dumps(params).decode()
363
410
  else:
364
- app.logger.error("Unable to find params.pkl file")
365
- return make_response(grabLog(ActionStr), 400)
366
- # Runtime
367
- runtimeparams = request.get_json(force=True, silent=True)
368
- if runtimeparams is not None:
369
- if runtimeparams != "{}":
370
- app.logger.info("Passed runtime parameters: " + str(runtimeparams))
371
- else:
372
- app.logger.warning("Unable to parse runtime parameters")
411
+ logger.error("Unable to find params.pkl file")
412
+ return None, None, None
413
+
414
+ # Load runtime params
415
+ try:
416
+ runtimeparams = await request.get_json(force=True)
417
+ if runtimeparams:
418
+ logger.info("Passed runtime parameters: " + str(runtimeparams))
419
+ else:
420
+ runtimeparams = {}
421
+ except Exception as e:
422
+ logger.error(f"Error parsing runtime params JSON: {e}")
423
+ logger.error("Check your payload for syntax errors (e.g., use 'false' instead of 'False')")
373
424
  runtimeparams = {}
374
- runtimeparams = json.dumps(runtimeparams)
375
425
 
376
- # weather-forecast-cache (check before set_input_data_dict)
377
- if action_name == "weather-forecast-cache":
378
- ActionStr = " >> Performing weather forecast, try to caching result"
379
- app.logger.info(ActionStr)
380
- weather_forecast_cache(emhass_conf, params, runtimeparams, app.logger)
381
- msg = "EMHASS >> Weather Forecast has run and results possibly cached... \n"
382
- if not checkFileLog(ActionStr):
383
- return make_response(msg, 201)
384
- return make_response(grabLog(ActionStr), 400)
385
-
386
- ActionStr = " >> Setting input data dict"
387
- app.logger.info(ActionStr)
388
- input_data_dict = set_input_data_dict(
389
- emhass_conf, costfun, params, runtimeparams, action_name, app.logger
390
- )
391
- if not input_data_dict:
392
- return make_response(grabLog(ActionStr), 400)
426
+ runtimeparams = orjson.dumps(runtimeparams).decode()
427
+
428
+ return params, costfun, runtimeparams
393
429
 
394
- # If continual_publish is True, start thread with loop function
395
- if len(continual_publish_thread) == 0 and input_data_dict["retrieve_hass_conf"].get(
396
- "continual_publish", False
397
- ):
398
- # Start Thread
399
- continualLoop = threading.Thread(
400
- name="continual_publish",
401
- target=continual_publish,
402
- args=[input_data_dict, entity_path, app.logger],
403
- )
404
- continualLoop.start()
405
- continual_publish_thread.append(continualLoop)
406
430
 
407
- # Run action based on POST request
408
- # If error in log when running action, return actions log (list) as response. (Using ActionStr as a reference of the action start in the log)
409
- # publish-data
431
+ async def _handle_action_dispatch(
432
+ action_name, input_data_dict, emhass_conf, params, runtimeparams, logger
433
+ ):
434
+ """
435
+ Dispatches the specific logic based on the action_name.
436
+ Returns (response_msg, status_code).
437
+ """
438
+ # Actions that don't require input_data_dict or have specific flows
439
+ if action_name == "weather-forecast-cache":
440
+ action_str = " >> Performing weather forecast, try to caching result"
441
+ logger.info(action_str)
442
+ await weather_forecast_cache(emhass_conf, params, runtimeparams, logger)
443
+ return "EMHASS >> Weather Forecast has run and results possibly cached... \n", 201
444
+
445
+ if action_name == "export-influxdb-to-csv":
446
+ action_str = " >> Exporting InfluxDB data to CSV..."
447
+ logger.info(action_str)
448
+ success = await export_influxdb_to_csv(None, logger, emhass_conf, params, runtimeparams)
449
+ if success:
450
+ return "EMHASS >> Action export-influxdb-to-csv executed successfully... \n", 201
451
+ return await grab_log(action_str), 400
452
+
453
+ # Actions requiring input_data_dict
410
454
  if action_name == "publish-data":
411
- ActionStr = " >> Publishing data..."
412
- app.logger.info(ActionStr)
413
- _ = publish_data(input_data_dict, app.logger)
414
- msg = "EMHASS >> Action publish-data executed... \n"
415
- if not checkFileLog(ActionStr):
416
- return make_response(msg, 201)
417
- return make_response(grabLog(ActionStr), 400)
418
- # perfect-optim
419
- elif action_name == "perfect-optim":
420
- ActionStr = " >> Performing perfect optimization..."
421
- app.logger.info(ActionStr)
422
- opt_res = perfect_forecast_optim(input_data_dict, app.logger)
423
- injection_dict = get_injection_dict(opt_res)
424
- with open(str(emhass_conf["data_path"] / "injection_dict.pkl"), "wb") as fid:
425
- pickle.dump(injection_dict, fid)
426
- msg = "EMHASS >> Action perfect-optim executed... \n"
427
- if not checkFileLog(ActionStr):
428
- return make_response(msg, 201)
429
- return make_response(grabLog(ActionStr), 400)
430
- # dayahead-optim
431
- elif action_name == "dayahead-optim":
432
- ActionStr = " >> Performing dayahead optimization..."
433
- app.logger.info(ActionStr)
434
- opt_res = dayahead_forecast_optim(input_data_dict, app.logger)
455
+ action_str = " >> Publishing data..."
456
+ logger.info(action_str)
457
+ _ = await publish_data(input_data_dict, logger)
458
+ return "EMHASS >> Action publish-data executed... \n", 201
459
+
460
+ # Mapping for optimization actions to their functions
461
+ optim_actions = {
462
+ "perfect-optim": perfect_forecast_optim,
463
+ "dayahead-optim": dayahead_forecast_optim,
464
+ "naive-mpc-optim": naive_mpc_optim,
465
+ }
466
+
467
+ if action_name in optim_actions:
468
+ action_str = f" >> Performing {action_name}..."
469
+ logger.info(action_str)
470
+ opt_res = await optim_actions[action_name](input_data_dict, logger)
435
471
  injection_dict = get_injection_dict(opt_res)
436
- with open(str(emhass_conf["data_path"] / "injection_dict.pkl"), "wb") as fid:
437
- pickle.dump(injection_dict, fid)
438
- msg = "EMHASS >> Action dayahead-optim executed... \n"
439
- if not checkFileLog(ActionStr):
440
- return make_response(msg, 201)
441
- return make_response(grabLog(ActionStr), 400)
442
- # naive-mpc-optim
443
- elif action_name == "naive-mpc-optim":
444
- ActionStr = " >> Performing naive MPC optimization..."
445
- app.logger.info(ActionStr)
446
- opt_res = naive_mpc_optim(input_data_dict, app.logger)
447
- injection_dict = get_injection_dict(opt_res)
448
- with open(str(emhass_conf["data_path"] / "injection_dict.pkl"), "wb") as fid:
449
- pickle.dump(injection_dict, fid)
450
- msg = "EMHASS >> Action naive-mpc-optim executed... \n"
451
- if not checkFileLog(ActionStr):
452
- return make_response(msg, 201)
453
- return make_response(grabLog(ActionStr), 400)
472
+ await _save_injection_dict(injection_dict, emhass_conf["data_path"])
473
+ return f"EMHASS >> Action {action_name} executed... \n", 201
474
+
475
+ # Delegate Machine Learning actions to helper
476
+ ml_response = await _handle_ml_actions(action_name, input_data_dict, emhass_conf, logger)
477
+ if ml_response:
478
+ return ml_response
479
+
480
+ # Fallback for invalid action
481
+ logger.error("ERROR: passed action is not valid")
482
+ return "EMHASS >> ERROR: Passed action is not valid... \n", 400
483
+
484
+
485
+ async def _handle_ml_actions(action_name, input_data_dict, emhass_conf, logger):
486
+ """
487
+ Helper function to handle Machine Learning specific actions.
488
+ Returns (msg, status) if action is handled, otherwise None.
489
+ """
454
490
  # forecast-model-fit
455
- elif action_name == "forecast-model-fit":
456
- ActionStr = " >> Performing a machine learning forecast model fit..."
457
- app.logger.info(ActionStr)
458
- df_fit_pred, _, mlf = forecast_model_fit(input_data_dict, app.logger)
491
+ if action_name == "forecast-model-fit":
492
+ action_str = " >> Performing a machine learning forecast model fit..."
493
+ logger.info(action_str)
494
+ df_fit_pred, _, mlf = await forecast_model_fit(input_data_dict, logger)
459
495
  injection_dict = get_injection_dict_forecast_model_fit(df_fit_pred, mlf)
460
- with open(str(emhass_conf["data_path"] / "injection_dict.pkl"), "wb") as fid:
461
- pickle.dump(injection_dict, fid)
462
- msg = "EMHASS >> Action forecast-model-fit executed... \n"
463
- if not checkFileLog(ActionStr):
464
- return make_response(msg, 201)
465
- return make_response(grabLog(ActionStr), 400)
496
+ await _save_injection_dict(injection_dict, emhass_conf["data_path"])
497
+ return "EMHASS >> Action forecast-model-fit executed... \n", 201
498
+
466
499
  # forecast-model-predict
467
- elif action_name == "forecast-model-predict":
468
- ActionStr = " >> Performing a machine learning forecast model predict..."
469
- app.logger.info(ActionStr)
470
- df_pred = forecast_model_predict(input_data_dict, app.logger)
500
+ if action_name == "forecast-model-predict":
501
+ action_str = " >> Performing a machine learning forecast model predict..."
502
+ logger.info(action_str)
503
+ df_pred = await forecast_model_predict(input_data_dict, logger)
471
504
  if df_pred is None:
472
- return make_response(grabLog(ActionStr), 400)
505
+ return await grab_log(action_str), 400
506
+
473
507
  table1 = df_pred.reset_index().to_html(classes="mystyle", index=False)
474
- injection_dict = {}
475
- injection_dict["title"] = (
476
- "<h2>Custom machine learning forecast model predict</h2>"
477
- )
478
- injection_dict["subsubtitle0"] = (
479
- "<h4>Performed a prediction using a pre-trained model</h4>"
480
- )
481
- injection_dict["table1"] = table1
482
- with open(str(emhass_conf["data_path"] / "injection_dict.pkl"), "wb") as fid:
483
- pickle.dump(injection_dict, fid)
484
- msg = "EMHASS >> Action forecast-model-predict executed... \n"
485
- if not checkFileLog(ActionStr):
486
- return make_response(msg, 201)
487
- return make_response(grabLog(ActionStr), 400)
508
+ injection_dict = {
509
+ "title": "<h2>Custom machine learning forecast model predict</h2>",
510
+ "subsubtitle0": "<h4>Performed a prediction using a pre-trained model</h4>",
511
+ "table1": table1,
512
+ }
513
+ await _save_injection_dict(injection_dict, emhass_conf["data_path"])
514
+ return "EMHASS >> Action forecast-model-predict executed... \n", 201
515
+
488
516
  # forecast-model-tune
489
- elif action_name == "forecast-model-tune":
490
- ActionStr = " >> Performing a machine learning forecast model tune..."
491
- app.logger.info(ActionStr)
492
- df_pred_optim, mlf = forecast_model_tune(input_data_dict, app.logger)
517
+ if action_name == "forecast-model-tune":
518
+ action_str = " >> Performing a machine learning forecast model tune..."
519
+ logger.info(action_str)
520
+ df_pred_optim, mlf = await forecast_model_tune(input_data_dict, logger)
493
521
  if df_pred_optim is None or mlf is None:
494
- return make_response(grabLog(ActionStr), 400)
522
+ return await grab_log(action_str), 400
523
+
495
524
  injection_dict = get_injection_dict_forecast_model_tune(df_pred_optim, mlf)
496
- with open(str(emhass_conf["data_path"] / "injection_dict.pkl"), "wb") as fid:
497
- pickle.dump(injection_dict, fid)
498
- msg = "EMHASS >> Action forecast-model-tune executed... \n"
499
- if not checkFileLog(ActionStr):
500
- return make_response(msg, 201)
501
- return make_response(grabLog(ActionStr), 400)
525
+ await _save_injection_dict(injection_dict, emhass_conf["data_path"])
526
+ return "EMHASS >> Action forecast-model-tune executed... \n", 201
527
+
502
528
  # regressor-model-fit
503
- elif action_name == "regressor-model-fit":
504
- ActionStr = " >> Performing a machine learning regressor fit..."
505
- app.logger.info(ActionStr)
506
- regressor_model_fit(input_data_dict, app.logger)
507
- msg = "EMHASS >> Action regressor-model-fit executed... \n"
508
- if not checkFileLog(ActionStr):
509
- return make_response(msg, 201)
510
- return make_response(grabLog(ActionStr), 400)
529
+ if action_name == "regressor-model-fit":
530
+ action_str = " >> Performing a machine learning regressor fit..."
531
+ logger.info(action_str)
532
+ await regressor_model_fit(input_data_dict, logger)
533
+ return "EMHASS >> Action regressor-model-fit executed... \n", 201
534
+
511
535
  # regressor-model-predict
512
- elif action_name == "regressor-model-predict":
513
- ActionStr = " >> Performing a machine learning regressor predict..."
514
- app.logger.info(ActionStr)
515
- regressor_model_predict(input_data_dict, app.logger)
516
- msg = "EMHASS >> Action regressor-model-predict executed... \n"
517
- if not checkFileLog(ActionStr):
518
- return make_response(msg, 201)
519
- return make_response(grabLog(ActionStr), 400)
520
- # Else return error
521
- else:
522
- app.logger.error("ERROR: passed action is not valid")
523
- msg = "EMHASS >> ERROR: Passed action is not valid... \n"
524
- return make_response(msg, 400)
536
+ if action_name == "regressor-model-predict":
537
+ action_str = " >> Performing a machine learning regressor predict..."
538
+ logger.info(action_str)
539
+ await regressor_model_predict(input_data_dict, logger)
540
+ return "EMHASS >> Action regressor-model-predict executed... \n", 201
525
541
 
542
+ return None
526
543
 
527
- if __name__ == "__main__":
528
- # Parsing arguments
529
- parser = argparse.ArgumentParser()
530
- parser.add_argument(
531
- "--url",
532
- type=str,
533
- help="The URL to your Home Assistant instance, ex the external_url in your hass configuration",
544
+
545
+ async def _save_injection_dict(injection_dict, data_path):
546
+ """Helper to save injection dict to pickle."""
547
+ async with aiofiles.open(str(data_path / injection_dict_file), "wb") as fid:
548
+ content = pickle.dumps(injection_dict)
549
+ await fid.write(content)
550
+
551
+
552
+ @app.route("/action/<action_name>", methods=["POST"])
553
+ async def action_call(action_name: str):
554
+ """
555
+ Receive Post action, run action according to passed slug(action_name)
556
+ """
557
+ global continual_publish_thread
558
+ global injection_dict
559
+
560
+ # Load Parameters
561
+ params, costfun, runtimeparams = await _load_params_and_runtime(
562
+ request, emhass_conf, app.logger
534
563
  )
535
- parser.add_argument(
536
- "--key",
537
- type=str,
538
- help="Your access key. If using EMHASS in standalone this should be a Long-Lived Access Token",
564
+ if params is None:
565
+ return await make_response(await grab_log(" >> Obtaining params: "), 400)
566
+
567
+ # Check for actions that do not need input_data_dict
568
+ if action_name in ["weather-forecast-cache", "export-influxdb-to-csv"]:
569
+ msg, status = await _handle_action_dispatch(
570
+ action_name, None, emhass_conf, params, runtimeparams, app.logger
571
+ )
572
+ if status == 400:
573
+ return await make_response(msg, status)
574
+
575
+ # Check logs for these specific actions
576
+ action_str = f" >> Performing {action_name}..."
577
+ if not await check_file_log(action_str):
578
+ return await make_response(msg, status)
579
+ return await make_response(await grab_log(action_str), 400)
580
+
581
+ # Set Input Data Dict (Common for all other actions)
582
+ action_str = " >> Setting input data dict"
583
+ app.logger.info(action_str)
584
+ input_data_dict = await set_input_data_dict(
585
+ emhass_conf, costfun, params, runtimeparams, action_name, app.logger
539
586
  )
540
- parser.add_argument(
541
- "--no_response",
542
- type=strtobool,
543
- default="False",
544
- help="This is set if json response errors occur",
587
+
588
+ if not input_data_dict:
589
+ return await make_response(await grab_log(action_str), 400)
590
+
591
+ # Handle Continual Publish Threading
592
+ if len(continual_publish_thread) == 0 and input_data_dict["retrieve_hass_conf"].get(
593
+ "continual_publish", False
594
+ ):
595
+ continual_loop = threading.Thread(
596
+ name="continual_publish",
597
+ target=lambda: asyncio.run(continual_publish(input_data_dict, entity_path, app.logger)),
598
+ )
599
+ continual_loop.start()
600
+ continual_publish_thread.append(continual_loop)
601
+
602
+ # Execute Action
603
+ msg, status = await _handle_action_dispatch(
604
+ action_name, input_data_dict, emhass_conf, params, runtimeparams, app.logger
545
605
  )
546
- args = parser.parse_args()
547
606
 
548
- # Pre formatted config parameters
549
- config = {}
550
- # Secrets
551
- params_secrets = {}
552
- # Built parameters (formatted config + secrets)
553
- params = None
607
+ # Final Log Check & Response
608
+ if status == 201:
609
+ if not await check_file_log(" >> "):
610
+ return await make_response(msg, 201)
611
+ return await make_response(await grab_log(" >> "), 400)
612
+
613
+ return await make_response(msg, status)
554
614
 
615
+
616
+ async def _setup_paths() -> tuple[Path, Path, Path, Path, Path, Path]:
617
+ """Helper to set up environment paths and update emhass_conf."""
555
618
  # Find env's, not not set defaults
556
- DATA_PATH = os.getenv("DATA_PATH", default="/app/data/")
619
+ DATA_PATH = os.getenv("DATA_PATH", default="/data/")
557
620
  ROOT_PATH = os.getenv("ROOT_PATH", default=str(Path(__file__).parent))
558
621
  CONFIG_PATH = os.getenv("CONFIG_PATH", default="/share/config.json")
559
622
  OPTIONS_PATH = os.getenv("OPTIONS_PATH", default="/data/options.json")
560
- DEFAULTS_PATH = os.getenv(
561
- "DEFAULTS_PATH", default=ROOT_PATH + "/data/config_defaults.json"
562
- )
563
- ASSOCIATIONS_PATH = os.getenv(
564
- "ASSOCIATIONS_PATH", default=ROOT_PATH + "/data/associations.csv"
565
- )
566
- LEGACY_CONFIG_PATH = os.getenv(
567
- "LEGACY_CONFIG_PATH", default="/app/config_emhass.yaml"
568
- )
569
-
623
+ DEFAULTS_PATH = os.getenv("DEFAULTS_PATH", default=ROOT_PATH + "/data/config_defaults.json")
624
+ ASSOCIATIONS_PATH = os.getenv("ASSOCIATIONS_PATH", default=ROOT_PATH + "/data/associations.csv")
625
+ LEGACY_CONFIG_PATH = os.getenv("LEGACY_CONFIG_PATH", default="/app/config_emhass.yaml")
570
626
  # Define the paths
571
627
  config_path = Path(CONFIG_PATH)
572
628
  options_path = Path(OPTIONS_PATH)
@@ -583,122 +639,221 @@ if __name__ == "__main__":
583
639
  emhass_conf["legacy_config_path"] = legacy_config_path
584
640
  emhass_conf["data_path"] = data_path
585
641
  emhass_conf["root_path"] = root_path
642
+ return (
643
+ config_path,
644
+ options_path,
645
+ defaults_path,
646
+ associations_path,
647
+ legacy_config_path,
648
+ root_path,
649
+ )
650
+
586
651
 
652
+ async def _build_configuration(
653
+ config_path: Path, legacy_config_path: Path, defaults_path: Path
654
+ ) -> tuple[dict, str, str]:
655
+ """Helper to build configuration and local variables."""
656
+ config = {}
587
657
  # Combine parameters from configuration sources (if exists)
588
658
  config.update(
589
- build_config(
590
- emhass_conf, app.logger, defaults_path, config_path, legacy_config_path
659
+ await build_config(
660
+ emhass_conf,
661
+ app.logger,
662
+ str(defaults_path),
663
+ str(config_path) if config_path.exists() else None,
664
+ str(legacy_config_path) if legacy_config_path.exists() else None,
591
665
  )
592
666
  )
593
667
  if type(config) is bool and not config:
594
668
  raise Exception("Failed to find default config")
595
-
596
669
  # Set local variables
597
670
  costfun = os.getenv("LOCAL_COSTFUN", config.get("costfun", "profit"))
598
671
  logging_level = os.getenv("LOGGING_LEVEL", config.get("logging_level", "INFO"))
599
672
  # Temporary set logging level if debug
600
673
  if logging_level == "DEBUG":
601
674
  app.logger.setLevel(logging.DEBUG)
675
+ return config, costfun, logging_level
602
676
 
677
+
678
+ async def _setup_secrets(args: dict | None, options_path: Path) -> str:
679
+ """Helper to parse arguments and build secrets."""
603
680
  ## Secrets
681
+ # Argument
604
682
  argument = {}
605
- if args.url:
606
- argument["url"] = args.url
607
- if args.key:
608
- argument["key"] = args.key
683
+ no_response = False
684
+ if args is not None:
685
+ if args.get("url", None):
686
+ argument["url"] = args["url"]
687
+ if args.get("key", None):
688
+ argument["key"] = args["key"]
689
+ if args.get("no_response", None):
690
+ no_response = args["no_response"]
609
691
  # Combine secrets from ENV, Arguments/ARG, Secrets file (secrets_emhass.yaml), options (options.json from addon configuration file) and/or Home Assistant Standalone API (if exist)
610
- emhass_conf, secrets = build_secrets(
692
+ global emhass_conf
693
+ emhass_conf, secrets = await build_secrets(
611
694
  emhass_conf,
612
695
  app.logger,
613
- argument,
614
- options_path,
615
- os.getenv("SECRETS_PATH", default="/app/secrets_emhass.yaml"),
616
- bool(args.no_response),
696
+ secrets_path=os.getenv("SECRETS_PATH", default="/app/secrets_emhass.yaml"),
697
+ options_path=str(options_path),
698
+ argument=argument,
699
+ no_response=bool(no_response),
617
700
  )
618
701
  params_secrets.update(secrets)
702
+ return params_secrets.get("server_ip", "0.0.0.0")
619
703
 
620
- server_ip = params_secrets.get("server_ip", "0.0.0.0")
621
704
 
705
+ def _validate_data_path(root_path: Path) -> None:
706
+ """Helper to validate and create the data path if necessary."""
622
707
  # Check if data path exists
623
708
  if not os.path.isdir(emhass_conf["data_path"]):
624
709
  app.logger.warning("Unable to find data_path: " + str(emhass_conf["data_path"]))
625
- if os.path.isdir(Path("/app/data/")):
626
- emhass_conf["data_path"] = Path("/app/data/")
710
+ if os.path.isdir(Path("/data/")):
711
+ emhass_conf["data_path"] = Path("/data/")
627
712
  else:
628
713
  Path(root_path / "data/").mkdir(parents=True, exist_ok=True)
629
714
  emhass_conf["data_path"] = root_path / "data/"
630
715
  app.logger.info("data_path has been set to " + str(emhass_conf["data_path"]))
631
716
 
717
+
718
+ async def _load_injection_dict() -> dict | None:
719
+ """Helper to load the injection dictionary."""
632
720
  # Initialize this global dict
633
- if (emhass_conf["data_path"] / "injection_dict.pkl").exists():
634
- with open(str(emhass_conf["data_path"] / "injection_dict.pkl"), "rb") as fid:
635
- injection_dict = pickle.load(fid)
721
+ if (emhass_conf["data_path"] / injection_dict_file).exists():
722
+ async with aiofiles.open(str(emhass_conf["data_path"] / injection_dict_file), "rb") as fid:
723
+ content = await fid.read()
724
+ return pickle.loads(content)
636
725
  else:
637
- injection_dict = None
726
+ return None
727
+
638
728
 
729
+ async def _build_and_save_params(
730
+ config: dict, costfun: str, logging_level: str, config_path: Path
731
+ ) -> dict:
732
+ """Helper to build parameters and save them to a pickle file."""
639
733
  # Build params from config and param_secrets (migrate params to correct config catagories), save result to params.pkl
640
- params = build_params(emhass_conf, params_secrets, config, app.logger)
734
+ params = await build_params(emhass_conf, params_secrets, config, app.logger)
641
735
  if type(params) is bool:
642
736
  raise Exception("A error has occurred while building params")
643
737
  # Update params with local variables
644
738
  params["optim_conf"]["costfun"] = costfun
645
739
  params["optim_conf"]["logging_level"] = logging_level
646
-
647
740
  # Save params to file for later reference
648
741
  if os.path.exists(str(emhass_conf["data_path"])):
649
- with open(str(emhass_conf["data_path"] / "params.pkl"), "wb") as fid:
650
- pickle.dump((config_path, params), fid)
742
+ async with aiofiles.open(str(emhass_conf["data_path"] / params_file), "wb") as fid:
743
+ content = pickle.dumps((config_path, params))
744
+ await fid.write(content)
651
745
  else:
652
746
  raise Exception("missing: " + str(emhass_conf["data_path"]))
747
+ return params
748
+
653
749
 
750
+ async def _configure_logging(logging_level: str) -> None:
751
+ """Helper to configure logging handlers and levels."""
654
752
  # Define loggers
655
- formatter = logging.Formatter(
656
- "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
657
- )
753
+ formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
658
754
  log.default_handler.setFormatter(formatter)
659
755
  # Action file logger
660
- fileLogger = logging.FileHandler(str(emhass_conf["data_path"] / "actionLogs.txt"))
756
+ file_logger = logging.FileHandler(str(emhass_conf["data_path"] / action_log_str))
661
757
  formatter = logging.Formatter("%(levelname)s - %(name)s - %(message)s")
662
- fileLogger.setFormatter(formatter) # add format to Handler
758
+ file_logger.setFormatter(formatter) # add format to Handler
663
759
  if logging_level == "DEBUG":
664
760
  app.logger.setLevel(logging.DEBUG)
665
- fileLogger.setLevel(logging.DEBUG)
761
+ file_logger.setLevel(logging.DEBUG)
666
762
  elif logging_level == "INFO":
667
763
  app.logger.setLevel(logging.INFO)
668
- fileLogger.setLevel(logging.INFO)
764
+ file_logger.setLevel(logging.INFO)
669
765
  elif logging_level == "WARNING":
670
766
  app.logger.setLevel(logging.WARNING)
671
- fileLogger.setLevel(logging.WARNING)
767
+ file_logger.setLevel(logging.WARNING)
672
768
  elif logging_level == "ERROR":
673
769
  app.logger.setLevel(logging.ERROR)
674
- fileLogger.setLevel(logging.ERROR)
770
+ file_logger.setLevel(logging.ERROR)
675
771
  else:
676
772
  app.logger.setLevel(logging.DEBUG)
677
- fileLogger.setLevel(logging.DEBUG)
773
+ file_logger.setLevel(logging.DEBUG)
678
774
  app.logger.propagate = False
679
- app.logger.addHandler(fileLogger)
775
+ app.logger.addHandler(file_logger)
680
776
  # Clear Action File logger file, ready for new instance
681
- clearFileLog()
777
+ await clear_file_log()
778
+
682
779
 
780
+ def _cleanup_entities() -> Path:
781
+ """Helper to remove entity/metadata files."""
683
782
  # If entity_path exists, remove any entity/metadata files
684
- entity_path = emhass_conf["data_path"] / "entities"
685
- if os.path.exists(entity_path):
686
- entity_pathContents = os.listdir(entity_path)
687
- if len(entity_pathContents) > 0:
688
- for entity in entity_pathContents:
689
- os.remove(entity_path / entity)
783
+ ent_path = emhass_conf["data_path"] / "entities"
784
+ if os.path.exists(ent_path):
785
+ entity_path_contents = os.listdir(ent_path)
786
+ if len(entity_path_contents) > 0:
787
+ for entity in entity_path_contents:
788
+ os.remove(ent_path / entity)
789
+ return ent_path
790
+
791
+
792
+ async def _initialize_connections(params: dict) -> None:
793
+ """Helper to initialize WebSocket or InfluxDB connections."""
794
+ # Initialize persistent WebSocket connection only if use_websocket is enabled
795
+ use_websocket = params.get("retrieve_hass_conf", {}).get("use_websocket", False)
796
+ use_influxdb = params.get("retrieve_hass_conf", {}).get("use_influxdb", False)
797
+ # Initialize persistent WebSocket connection if enabled
798
+ if use_websocket:
799
+ app.logger.info("WebSocket mode enabled - initializing connection...")
800
+ try:
801
+ await get_websocket_client(
802
+ hass_url=params_secrets["hass_url"],
803
+ token=params_secrets["long_lived_token"],
804
+ logger=app.logger,
805
+ )
806
+ app.logger.info("WebSocket connection established")
807
+ # WebSocket shutdown is already handled by @app.after_serving
808
+ except Exception as ws_error:
809
+ app.logger.warning(f"WebSocket connection failed: {ws_error}")
810
+ app.logger.info("Continuing without WebSocket connection...")
811
+ # Re-raise the exception so before_serving can handle it
812
+ raise
813
+ # Log InfluxDB mode if enabled (No persistent connection init required here)
814
+ elif use_influxdb:
815
+ app.logger.info("InfluxDB mode enabled - using InfluxDB for data retrieval")
816
+ # Default to REST API if neither is enabled
817
+ else:
818
+ app.logger.info("WebSocket and InfluxDB modes disabled - using REST API for data retrieval")
690
819
 
820
+
821
+ async def initialize(args: dict | None = None):
822
+ global emhass_conf, params_secrets, continual_publish_thread, injection_dict, entity_path
823
+ # Setup paths
824
+ (
825
+ config_path,
826
+ options_path,
827
+ defaults_path,
828
+ _,
829
+ legacy_config_path,
830
+ root_path,
831
+ ) = await _setup_paths()
832
+ # Build configuration
833
+ config, costfun, logging_level = await _build_configuration(
834
+ config_path, legacy_config_path, defaults_path
835
+ )
836
+ # Setup Secrets
837
+ server_ip = await _setup_secrets(args, options_path)
838
+ # Validate Data Path
839
+ _validate_data_path(root_path)
840
+ # Load Injection Dict
841
+ injection_dict = await _load_injection_dict()
842
+ # Build and Save Params
843
+ params = await _build_and_save_params(config, costfun, logging_level, config_path)
844
+ # Configure Logging
845
+ await _configure_logging(logging_level)
846
+ # Cleanup Entities
847
+ entity_path = _cleanup_entities()
848
+ # Initialize Continual Publish Thread
691
849
  # Initialise continual publish thread list
692
850
  continual_publish_thread = []
693
-
694
- # Launch server
851
+ # Log Startup Info
852
+ # Logging
695
853
  port = int(os.environ.get("PORT", 5000))
854
+ app.logger.info("Launching the emhass webserver at: http://" + server_ip + ":" + str(port))
696
855
  app.logger.info(
697
- "Launching the emhass webserver at: http://" + server_ip + ":" + str(port)
698
- )
699
- app.logger.info(
700
- "Home Assistant data fetch will be performed using url: "
701
- + params_secrets["hass_url"]
856
+ "Home Assistant data fetch will be performed using url: " + params_secrets["hass_url"]
702
857
  )
703
858
  app.logger.info("The data path is: " + str(emhass_conf["data_path"]))
704
859
  app.logger.info("The logging is: " + str(logging_level))
@@ -706,4 +861,35 @@ if __name__ == "__main__":
706
861
  app.logger.info("Using core emhass version: " + version("emhass"))
707
862
  except PackageNotFoundError:
708
863
  app.logger.info("Using development emhass version")
709
- serve(app, host=server_ip, port=port, threads=8)
864
+ # Initialize Connections (WebSocket/InfluxDB)
865
+ await _initialize_connections(params)
866
+ app.logger.info("Initialization complete")
867
+
868
+
869
+ async def main() -> None:
870
+ """
871
+ Main function to handle command line arguments.
872
+
873
+ Note: In production, the app should be run via gunicorn with uvicorn workers:
874
+ gunicorn emhass.web_server:app -c gunicorn.conf.py -k uvicorn.workers.UvicornWorker
875
+ """
876
+ parser = argparse.ArgumentParser()
877
+ parser.add_argument("--url", type=str, help="HA URL")
878
+ parser.add_argument("--key", type=str, help="HA long‑lived token")
879
+ parser.add_argument("--no_response", action="store_true")
880
+ args = parser.parse_args()
881
+ args_dict = {k: v for k, v in vars(args).items() if v is not None}
882
+ # Initialize the app before starting server
883
+ await initialize(args_dict)
884
+ # For direct execution (development/testing), use uvicorn programmatically
885
+ host = params_secrets.get("server_ip", "0.0.0.0")
886
+ port = int(os.getenv("PORT", 5000))
887
+ app.logger.info(f"Starting server directly on {host}:{port}")
888
+ # Use uvicorn.Server to run within existing event loop
889
+ config = uvicorn.Config(app, host=host, port=port, log_level="warning")
890
+ server = uvicorn.Server(config)
891
+ await server.serve()
892
+
893
+
894
+ if __name__ == "__main__":
895
+ asyncio.run(main())