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/command_line.py +1481 -811
- emhass/connection_manager.py +108 -0
- emhass/data/associations.csv +37 -2
- emhass/data/cec_inverters.pbz2 +0 -0
- emhass/data/cec_modules.pbz2 +0 -0
- emhass/data/config_defaults.json +53 -49
- emhass/forecast.py +1264 -731
- emhass/img/emhass_icon.png +0 -0
- emhass/machine_learning_forecaster.py +534 -281
- emhass/machine_learning_regressor.py +141 -125
- emhass/optimization.py +1173 -585
- emhass/retrieve_hass.py +958 -263
- emhass/static/advanced.html +7 -0
- emhass/static/configuration_list.html +5 -1
- emhass/static/configuration_script.js +146 -62
- emhass/static/data/param_definitions.json +215 -48
- emhass/static/script.js +58 -26
- emhass/static/style.css +6 -8
- emhass/templates/configuration.html +5 -3
- emhass/templates/index.html +8 -6
- emhass/templates/template.html +4 -5
- emhass/utils.py +1152 -403
- emhass/web_server.py +565 -379
- emhass/websocket_client.py +224 -0
- emhass-0.15.5.dist-info/METADATA +164 -0
- emhass-0.15.5.dist-info/RECORD +34 -0
- {emhass-0.11.4.dist-info → emhass-0.15.5.dist-info}/WHEEL +1 -2
- emhass-0.15.5.dist-info/entry_points.txt +2 -0
- emhass-0.11.4.dist-info/METADATA +0 -666
- emhass-0.11.4.dist-info/RECORD +0 -32
- emhass-0.11.4.dist-info/entry_points.txt +0 -2
- emhass-0.11.4.dist-info/top_level.txt +0 -1
- {emhass-0.11.4.dist-info → emhass-0.15.5.dist-info/licenses}/LICENSE +0 -0
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
|
|
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
|
|
17
|
-
from
|
|
18
|
-
from
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
56
|
-
:type
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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"] /
|
|
67
|
-
with open(str(emhass_conf["data_path"] /
|
|
68
|
-
|
|
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
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
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
|
|
82
|
-
:type
|
|
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
|
-
|
|
150
|
+
is_found = []
|
|
88
151
|
output = []
|
|
89
|
-
if (emhass_conf["data_path"] /
|
|
90
|
-
with open(str(emhass_conf["data_path"] /
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
168
|
+
async def clear_file_log():
|
|
105
169
|
"""
|
|
106
|
-
Clear the contents of the log file
|
|
170
|
+
Clear the contents of the log file
|
|
107
171
|
|
|
108
172
|
"""
|
|
109
|
-
if (emhass_conf["data_path"] /
|
|
110
|
-
with open(str(emhass_conf["data_path"] /
|
|
111
|
-
fp.
|
|
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
|
-
|
|
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"] /
|
|
133
|
-
with open(str(emhass_conf["data_path"] /
|
|
134
|
-
|
|
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
|
-
|
|
142
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
|
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
|
-
|
|
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([
|
|
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([
|
|
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([
|
|
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 =
|
|
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"]
|
|
296
|
-
|
|
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([
|
|
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"]
|
|
321
|
-
|
|
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"] /
|
|
329
|
-
pickle.
|
|
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
|
-
|
|
344
|
-
|
|
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
|
-
|
|
394
|
+
action_str = " >> Obtaining params: "
|
|
395
|
+
logger.info(action_str)
|
|
347
396
|
|
|
348
|
-
|
|
349
|
-
|
|
397
|
+
# Load params.pkl
|
|
398
|
+
params = None
|
|
399
|
+
costfun = "profit"
|
|
400
|
+
params_path = emhass_conf["data_path"] / params_file
|
|
350
401
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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"
|
|
407
|
+
if params.get("optim_conf") is not None:
|
|
361
408
|
costfun = params["optim_conf"].get("costfun", "profit")
|
|
362
|
-
params =
|
|
409
|
+
params = orjson.dumps(params).decode()
|
|
363
410
|
else:
|
|
364
|
-
|
|
365
|
-
return
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
-
|
|
412
|
-
|
|
413
|
-
_ = publish_data(input_data_dict,
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
df_fit_pred, _, mlf = forecast_model_fit(input_data_dict,
|
|
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
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
df_pred = forecast_model_predict(input_data_dict,
|
|
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
|
|
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
|
-
|
|
476
|
-
"<
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
df_pred_optim, mlf = forecast_model_tune(input_data_dict,
|
|
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
|
|
522
|
+
return await grab_log(action_str), 400
|
|
523
|
+
|
|
495
524
|
injection_dict = get_injection_dict_forecast_model_tune(df_pred_optim, mlf)
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
regressor_model_fit(input_data_dict,
|
|
507
|
-
|
|
508
|
-
|
|
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
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
regressor_model_predict(input_data_dict,
|
|
516
|
-
|
|
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
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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
|
-
|
|
536
|
-
"
|
|
537
|
-
|
|
538
|
-
|
|
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
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
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
|
-
#
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
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="/
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
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
|
|
692
|
+
global emhass_conf
|
|
693
|
+
emhass_conf, secrets = await build_secrets(
|
|
611
694
|
emhass_conf,
|
|
612
695
|
app.logger,
|
|
613
|
-
|
|
614
|
-
options_path,
|
|
615
|
-
|
|
616
|
-
bool(
|
|
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("/
|
|
626
|
-
emhass_conf["data_path"] = Path("/
|
|
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"] /
|
|
634
|
-
with open(str(emhass_conf["data_path"] /
|
|
635
|
-
|
|
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
|
-
|
|
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"] /
|
|
650
|
-
pickle.
|
|
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
|
-
|
|
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
|
-
|
|
758
|
+
file_logger.setFormatter(formatter) # add format to Handler
|
|
663
759
|
if logging_level == "DEBUG":
|
|
664
760
|
app.logger.setLevel(logging.DEBUG)
|
|
665
|
-
|
|
761
|
+
file_logger.setLevel(logging.DEBUG)
|
|
666
762
|
elif logging_level == "INFO":
|
|
667
763
|
app.logger.setLevel(logging.INFO)
|
|
668
|
-
|
|
764
|
+
file_logger.setLevel(logging.INFO)
|
|
669
765
|
elif logging_level == "WARNING":
|
|
670
766
|
app.logger.setLevel(logging.WARNING)
|
|
671
|
-
|
|
767
|
+
file_logger.setLevel(logging.WARNING)
|
|
672
768
|
elif logging_level == "ERROR":
|
|
673
769
|
app.logger.setLevel(logging.ERROR)
|
|
674
|
-
|
|
770
|
+
file_logger.setLevel(logging.ERROR)
|
|
675
771
|
else:
|
|
676
772
|
app.logger.setLevel(logging.DEBUG)
|
|
677
|
-
|
|
773
|
+
file_logger.setLevel(logging.DEBUG)
|
|
678
774
|
app.logger.propagate = False
|
|
679
|
-
app.logger.addHandler(
|
|
775
|
+
app.logger.addHandler(file_logger)
|
|
680
776
|
# Clear Action File logger file, ready for new instance
|
|
681
|
-
|
|
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
|
-
|
|
685
|
-
if os.path.exists(
|
|
686
|
-
|
|
687
|
-
if len(
|
|
688
|
-
for entity in
|
|
689
|
-
os.remove(
|
|
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
|
-
#
|
|
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
|
-
"
|
|
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
|
-
|
|
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())
|