diff --git a/CHANGELOG.md b/CHANGELOG.md index bc3b9d26..cdecbcd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,8 @@ compatibility changes and a short test procedure. These changes are not yet a ta - Forecast continuation and AUTO/FIXED valuation of energy left in the battery, separate from battery wear costs. Continuation helps choose the plan; it does not add commands beyond the configured control horizon. +- Optional `ems.notify_url`: EOS sends an HTTP POST with a small JSON event after every + completed optimization, so a client can fetch the new plan right away instead of polling. - Configuration-driven `POST /v1/optimize` and a PDF report of the stored GENETIC result. - Measurement sample, energy, household-balance and battery-capacity APIs with data-quality and coverage information. A capacity estimate does not overwrite configured capacity. diff --git a/docs/_generated/configems.md b/docs/_generated/configems.md index 10343912..0e6eb52d 100644 --- a/docs/_generated/configems.md +++ b/docs/_generated/configems.md @@ -10,6 +10,7 @@ | interval | `EOS_EMS__INTERVAL` | `float` | `rw` | `300.0` | Intervall between EOS energy management runs [seconds]. | | mode | `EOS_EMS__MODE` | `` | `rw` | `required` | Energy management mode [DISABLED | PREDICTION | OPTIMIZATION]. Defaults to DISABLED. | | modes | | `list[str]` | `ro` | `N/A` | Available energy management modes. | +| notify_url | `EOS_EMS__NOTIFY_URL` | `Optional[str]` | `rw` | `None` | URL that receives an HTTP POST with a small JSON event after every completed optimization, so a client can fetch the new solution right away instead of polling for it. None = off. | | startup_delay | `EOS_EMS__STARTUP_DELAY` | `float` | `rw` | `5` | Startup delay in seconds for EOS energy management runs. | ::: @@ -24,7 +25,8 @@ "ems": { "startup_delay": 5.0, "interval": 300.0, - "mode": "OPTIMIZATION" + "mode": "OPTIMIZATION", + "notify_url": null } } ``` @@ -41,6 +43,7 @@ "startup_delay": 5.0, "interval": 300.0, "mode": "OPTIMIZATION", + "notify_url": null, "modes": [ "DISABLED", "PREDICTION", diff --git a/docs/_generated/configexample.md b/docs/_generated/configexample.md index a4d96d69..8da18eed 100644 --- a/docs/_generated/configexample.md +++ b/docs/_generated/configexample.md @@ -180,7 +180,8 @@ "ems": { "startup_delay": 5.0, "interval": 300.0, - "mode": "OPTIMIZATION" + "mode": "OPTIMIZATION", + "notify_url": null }, "feedintariff": { "direct_marketing_enabled": false, diff --git a/openapi.json b/openapi.json index 416adb21..09e64a4f 100644 --- a/openapi.json +++ b/openapi.json @@ -5947,6 +5947,22 @@ "examples": [ "OPTIMIZATION" ] + }, + "notify_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Notify Url", + "description": "URL that receives an HTTP POST with a small JSON event after every completed optimization, so a client can fetch the new solution right away instead of polling for it. None = off.", + "examples": [ + null, + "http://127.0.0.1:8080/api/eos/solution-ready" + ] } }, "type": "object", @@ -5979,6 +5995,22 @@ "OPTIMIZATION" ] }, + "notify_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Notify Url", + "description": "URL that receives an HTTP POST with a small JSON event after every completed optimization, so a client can fetch the new solution right away instead of polling for it. None = off.", + "examples": [ + null, + "http://127.0.0.1:8080/api/eos/solution-ready" + ] + }, "modes": { "items": { "type": "string" diff --git a/src/akkudoktoreos/core/ems.py b/src/akkudoktoreos/core/ems.py index 2bff5e2f..c84c139b 100644 --- a/src/akkudoktoreos/core/ems.py +++ b/src/akkudoktoreos/core/ems.py @@ -1,4 +1,7 @@ +import json +import threading import traceback +import urllib.request from asyncio import Lock, get_running_loop from concurrent.futures import ThreadPoolExecutor from enum import StrEnum @@ -56,6 +59,48 @@ async def ems_manage_energy() -> None: await EnergyManagement().run() +NOTIFY_TIMEOUT_SECONDS = 5.0 +_notify_failing = False + + +def _post_notification(url: str, event: dict) -> None: + """POST one event; log a failure once per failure streak, not every run.""" + global _notify_failing + # The scheme is restricted to http(s) by the notify_url setting. + request = urllib.request.Request( # noqa: S310 + url, + data=json.dumps(event).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=NOTIFY_TIMEOUT_SECONDS) as response: # noqa: S310 + response.read() + except Exception as exc: + if not _notify_failing: + logger.warning("Optimization notification to {} failed: {}", url, exc) + _notify_failing = True + return + if _notify_failing: + logger.info("Optimization notification to {} works again.", url) + _notify_failing = False + + +def notify_optimization_completed(url: Optional[str], event: dict) -> Optional[threading.Thread]: + """Tell ``ems.notify_url`` that a new solution is available. + + Runs in a background thread so a slow or unreachable receiver never delays + the energy management run. + """ + if not url: + return None + thread = threading.Thread( + target=_post_notification, args=(url, event), name="eos-notify", daemon=True + ) + thread.start() + return thread + + class EnergyManagement( SingletonMixin, ConfigMixin, PredictionMixin, AdapterMixin, PydanticBaseModel ): @@ -492,6 +537,15 @@ class EnergyManagement( logger.debug("{}: Energy management plan:\n{}", algorithm, EnergyManagement._plan) logger.info("{}: Energy management run done (optimization updated)", algorithm) + notify_optimization_completed( + getattr(self.config.ems, "notify_url", None), + { + "event": "optimization_completed", + "algorithm": str(algorithm), + "completed_at": to_datetime().to_iso8601_string(), + "duration_s": round(optimization_duration.total_seconds(), 1), + }, + ) # --- Dispatch control by adapters --- EnergyManagement._stage = EnergyManagementStage.CONTROL_DISPATCH diff --git a/src/akkudoktoreos/core/emsettings.py b/src/akkudoktoreos/core/emsettings.py index 93dcb846..e0fa036e 100644 --- a/src/akkudoktoreos/core/emsettings.py +++ b/src/akkudoktoreos/core/emsettings.py @@ -4,8 +4,9 @@ Kept in an extra module to avoid cyclic dependencies on package import. """ from enum import StrEnum +from typing import Optional -from pydantic import Field, computed_field +from pydantic import Field, computed_field, field_validator from akkudoktoreos.config.configabc import SettingsBaseModel, is_home_assistant_addon @@ -60,6 +61,28 @@ class EnergyManagementCommonSettings(SettingsBaseModel): }, ) + notify_url: Optional[str] = Field( + default=None, + json_schema_extra={ + "description": ( + "URL that receives an HTTP POST with a small JSON event after every " + "completed optimization, so a client can fetch the new solution right " + "away instead of polling for it. None = off." + ), + "examples": [None, "http://127.0.0.1:8080/api/eos/solution-ready"], + }, + ) + + @field_validator("notify_url") + @classmethod + def _validate_notify_url(cls, value: Optional[str]) -> Optional[str]: + if value is None or value.strip() == "": + return None + value = value.strip() + if not value.startswith(("http://", "https://")): + raise ValueError("notify_url must be an http:// or https:// URL") + return value + @computed_field # type: ignore[prop-decorator] @property def modes(self) -> list[str]: diff --git a/tests/test_ems_notify.py b/tests/test_ems_notify.py new file mode 100644 index 00000000..c1bfc223 --- /dev/null +++ b/tests/test_ems_notify.py @@ -0,0 +1,79 @@ +"""EOS notifies ``ems.notify_url`` after a completed optimization.""" + +import json +import threading +from http.server import BaseHTTPRequestHandler, HTTPServer + +import pytest +from pydantic import ValidationError + +from akkudoktoreos.core import ems as ems_module +from akkudoktoreos.core.emsettings import EnergyManagementCommonSettings + + +def _receiver(): + received: list[dict] = [] + + class Handler(BaseHTTPRequestHandler): + def do_POST(self): # noqa: N802 + length = int(self.headers.get("Content-Length", 0)) + received.append( + { + "path": self.path, + "type": self.headers.get("Content-Type"), + "body": json.loads(self.rfile.read(length)), + } + ) + self.send_response(204) + self.end_headers() + + def log_message(self, *args): + pass + + server = HTTPServer(("127.0.0.1", 0), Handler) + threading.Thread(target=server.serve_forever, daemon=True).start() + return server, received + + +def test_completed_optimization_is_posted(): + server, received = _receiver() + try: + url = f"http://127.0.0.1:{server.server_port}/api/eos/solution-ready" + event = {"event": "optimization_completed", "duration_s": 12.5} + thread = ems_module.notify_optimization_completed(url, event) + assert thread is not None + thread.join(timeout=10) + assert received == [ + {"path": "/api/eos/solution-ready", "type": "application/json", "body": event} + ] + finally: + server.shutdown() + + +def test_no_url_no_notification(): + assert ems_module.notify_optimization_completed(None, {"event": "x"}) is None + assert ems_module.notify_optimization_completed("", {"event": "x"}) is None + + +def test_unreachable_receiver_is_logged_once(monkeypatch): + warnings: list[str] = [] + monkeypatch.setattr(ems_module.logger, "warning", lambda *a, **k: warnings.append(a[0])) + monkeypatch.setattr(ems_module, "_notify_failing", False) + server, _ = _receiver() + port = server.server_port + server.shutdown() + server.server_close() + for _ in range(3): + ems_module._post_notification(f"http://127.0.0.1:{port}/x", {"event": "x"}) + assert len(warnings) == 1 + + +def test_notify_url_setting(): + assert EnergyManagementCommonSettings().notify_url is None + assert EnergyManagementCommonSettings(notify_url=" ").notify_url is None + assert ( + EnergyManagementCommonSettings(notify_url=" http://127.0.0.1:8080/a ").notify_url + == "http://127.0.0.1:8080/a" + ) + with pytest.raises(ValidationError): + EnergyManagementCommonSettings(notify_url="file:///etc/passwd")