From 36115a42d056fd2c21e2ccb9849e1390abbad062 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Mar 2026 18:26:03 +0000 Subject: [PATCH 01/13] Add MCP server implementation plan and todo Comprehensive plan for adding a Model Context Protocol (MCP) server to batcontrol, covering: - Phase 1: Duration-based OverrideManager to replace the single-shot api_overwrite flag (benefits both MCP and existing MQTT API) - Phase 2: MCP server with read tools (status, forecasts, battery info, decision explanation) and write tools (mode override, charge rate) - Phase 3: Decision explainability in the logic engine - Phase 4: Home Assistant addon integration (port exposure, config) - Phase 5: Testing and documentation Architecture: in-process HTTP server (Streamable HTTP transport) sharing the Batcontrol instance, with stdio as secondary transport option. https://claude.ai/code/session_0145EbJrBDema8V6xSTGdL8M --- todo.md | 336 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 336 insertions(+) create mode 100644 todo.md diff --git a/todo.md b/todo.md new file mode 100644 index 00000000..fa9cbde1 --- /dev/null +++ b/todo.md @@ -0,0 +1,336 @@ +# MCP Server for Batcontrol — Implementation Plan + +## Motivation + +Batcontrol is a powerful home battery optimization system, but it lacks an interactive +query interface. Users can only observe behavior via MQTT topics or log files. There is +no way to ask "why did you charge at 3am?" or "what's the forecast for tonight?". + +An MCP (Model Context Protocol) server turns batcontrol into a system you can have a +conversation with — through any MCP-compatible AI client (Claude Desktop, Claude Code, +Home Assistant voice assistants, etc.). + +Additionally, the current override mechanism (`api_overwrite`) is **single-shot**: it +only survives one evaluation cycle (~3 minutes) before the autonomous logic takes back +control. This makes manual overrides essentially meaningless. The MCP server work +includes fixing this with a proper duration-based override system that benefits both +MCP and the existing MQTT API. + +--- + +## Architecture Decision: Transport & Deployment + +### Transport: Streamable HTTP (primary), stdio (secondary) + +**Why Streamable HTTP over stdio:** +- The HA addon runs batcontrol in a Docker container — stdio MCP requires the client + to spawn the server process, which doesn't work across container boundaries +- Streamable HTTP allows the MCP server to run inside the existing batcontrol process + and accept connections over the network (localhost or LAN) +- Home Assistant addons can expose ports — a single HTTP port is simple to configure +- Multiple clients can connect simultaneously (HA dashboard + Claude Desktop) +- Streamable HTTP is the current MCP standard, replacing the deprecated SSE transport + +**stdio as secondary option:** +- Useful for local development and testing +- Can be offered as a CLI flag (`--mcp-stdio`) for direct integration with tools + like Claude Desktop when running batcontrol outside Docker + +### Deployment Model + +``` +┌─────────────────────────────────────────────┐ +│ Batcontrol Process (existing) │ +│ │ +│ ┌──────────┐ ┌───────────┐ ┌──────────┐ │ +│ │ Main │ │ Scheduler │ │ MQTT API │ │ +│ │ Loop │ │ Thread │ │ Thread │ │ +│ └────┬─────┘ └───────────┘ └──────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────────────────────────┐ │ +│ │ Batcontrol Core Instance │ │ +│ │ (forecasts, state, inverter ctrl) │ │ +│ └──────────┬───────────────────────────┘ │ +│ │ │ +│ ┌─────┴─────┐ │ +│ ▼ ▼ │ +│ ┌──────────┐ ┌──────────────┐ │ +│ │ Override │ │ MCP Server │ │ +│ │ Manager │ │ (HTTP/stdio) │ │ +│ └──────────┘ └──────────────┘ │ +│ ▲ │ +│ │ HTTP :8081 │ +└────────────────────┼────────────────────────┘ + │ + MCP Clients (Claude Desktop, + HA voice, custom dashboards) +``` + +The MCP server runs **in-process** as an additional thread, sharing direct access to +the `Batcontrol` instance — same pattern as `MqttApi`. + +--- + +## Phase 1: Duration-Based Override Manager + +**Problem:** `api_overwrite` is a boolean flag reset after one evaluation cycle. + +**Solution:** A standalone `OverrideManager` class that both MCP and MQTT can use. + +### Design + +```python +class OverrideManager: + """Manages time-bounded overrides for batcontrol operation.""" + + def set_override(self, mode: int, duration_minutes: int, + charge_rate: int = None, reason: str = "") -> OverrideState + def clear_override() -> None + def get_override() -> Optional[OverrideState] # None if expired/inactive + def is_active() -> bool + def remaining_minutes() -> float +``` + +### Behavior +- Override has a **mode**, **duration**, optional **charge_rate**, and a **reason** +- `core.py:run()` checks `override_manager.is_active()` instead of `api_overwrite` +- If active: apply the override's mode/rate, skip autonomous logic +- If expired: resume autonomous logic automatically +- Override can be cleared early via `clear_override()` +- MQTT `mode/set` uses this manager with a configurable default duration +- MCP tools specify duration explicitly + +### Integration Points +- `core.py`: Replace `api_overwrite` flag with `OverrideManager` queries +- `mqtt_api.py`: Route `mode/set` through `OverrideManager` (backward compatible) +- New file: `src/batcontrol/override_manager.py` + +### Files to Create/Modify +- [ ] `src/batcontrol/override_manager.py` — New: OverrideManager class +- [ ] `src/batcontrol/core.py` — Modify: integrate OverrideManager +- [ ] `src/batcontrol/mqtt_api.py` — Modify: use OverrideManager for mode/set +- [ ] `tests/batcontrol/test_override_manager.py` — New: unit tests + +--- + +## Phase 2: MCP Server Implementation + +### MCP Tools (Read Operations) + +| Tool | Description | Data Source | +|------|-------------|-------------| +| `get_system_status` | Current mode, SOC, charge rate, override status, last evaluation time | `core.py` state | +| `get_price_forecast` | Hourly/15-min electricity prices for next 24-48h | `dynamictariff` provider | +| `get_solar_forecast` | Expected PV production (W per interval) | `forecastsolar` provider | +| `get_consumption_forecast` | Expected household consumption (W per interval) | `forecastconsumption` provider | +| `get_net_consumption_forecast` | Consumption minus production (grid dependency) | Computed from above | +| `get_battery_info` | SOC, capacity, stored energy, reserved energy, free capacity | Inverter + logic | +| `get_decision_explanation` | Why the system chose the current mode, with price/forecast context | Logic output + state | +| `get_configuration` | Current runtime config (thresholds, limits, offsets) | `core.py` config state | +| `get_override_status` | Active override details: mode, remaining time, reason | `OverrideManager` | + +### MCP Tools (Write Operations) + +| Tool | Description | Safety | +|------|-------------|--------| +| `set_mode_override` | Override mode for N minutes (with reason) | Duration-bounded, auto-expires | +| `clear_mode_override` | Cancel active override, resume autonomous logic | Safe — restores normal operation | +| `set_charge_rate` | Set charge rate (implies force-charge mode) for N minutes | Duration-bounded | +| `set_parameter` | Adjust runtime parameter (discharge limit, price threshold, etc.) | Validated, temporary | + +### MCP Resources (optional, later) + +Resources provide ambient context without explicit tool calls: +- `batcontrol://status` — Live system status summary +- `batcontrol://forecasts` — Current forecast data + +### Implementation + +**New files:** +- `src/batcontrol/mcp_server.py` — MCP server class using the `mcp` Python SDK +- Registers tools, handles requests, bridges to `Batcontrol` instance + +**MCP SDK:** Use the official `mcp` Python package (PyPI: `mcp`), which provides: +- `FastMCP` high-level server class +- Streamable HTTP and stdio transports built-in +- Tool/resource/prompt decorators + +**Thread model:** +- MCP HTTP server runs in its own thread (like MQTT) +- Tool handlers access `Batcontrol` instance (read-mostly, writes via `OverrideManager`) +- Thread safety: Read operations on forecast arrays use existing locks; write + operations go through `OverrideManager` which has its own lock + +### Files to Create/Modify +- [ ] `src/batcontrol/mcp_server.py` — New: MCP server implementation +- [ ] `src/batcontrol/core.py` — Modify: initialize MCP server, expose getters +- [ ] `src/batcontrol/__main__.py` — Modify: add `--mcp-stdio` flag, MCP config +- [ ] `pyproject.toml` — Modify: add `mcp` dependency +- [ ] `config/batcontrol_config_dummy.yaml` — Modify: add MCP config section +- [ ] `tests/batcontrol/test_mcp_server.py` — New: MCP server tests + +--- + +## Phase 3: Decision Explainability + +The `get_decision_explanation` tool is the killer feature. It requires the logic +engine to produce human-readable rationale alongside its control output. + +### Design +- Extend `CalculationOutput` with a `explanation: list[str]` field +- Logic engine appends reasoning steps as it evaluates: + - "Current price (0.15 EUR/kWh) is below average (0.22 EUR/kWh)" + - "Solar forecast shows 3.2 kWh production in next 4 hours" + - "Battery at 45% SOC with 2.1 kWh reserved for evening peak" + - "Decision: Allow discharge — prices are above threshold and battery has surplus" +- MCP tool formats this into a structured response + +### Files to Modify +- [ ] `src/batcontrol/logic/default.py` — Add explanation accumulation +- [ ] `src/batcontrol/logic/logic_interface.py` — Add explanation to output type +- [ ] `src/batcontrol/core.py` — Store and expose last explanation +- [ ] `tests/batcontrol/logic/test_default.py` — Test explanation output + +--- + +## Phase 4: Home Assistant Addon Integration + +The HA addon is maintained in a separate repository (`batcontrol_ha_addon`). The MCP +server integration requires changes in **both** repositories. + +### Changes in This Repository (batcontrol) + +1. **Configuration section** in `batcontrol_config_dummy.yaml`: + ```yaml + mcp: + enabled: false + transport: http # 'http' or 'stdio' + host: 0.0.0.0 # bind address + port: 8081 # HTTP port + # auth_token: "" # optional bearer token for security + ``` + +2. **Entrypoint** (`entrypoint_ha.sh`): No changes needed — config flows through YAML + +### Changes Needed in batcontrol_ha_addon Repository + +1. **Port exposure** in addon `config.yaml` / `manifest.json`: + ```yaml + ports: + "8081/tcp": 8081 + ports_description: + "8081/tcp": "MCP Server (Model Context Protocol)" + ``` + +2. **Options schema** — Add MCP toggle to addon options: + ```json + { + "mcp_enabled": true, + "mcp_port": 8081 + } + ``` + +3. **Ingress support** (optional, future): HA supports addon ingress for web-based + interfaces. The MCP HTTP endpoint could be exposed through HA's ingress proxy, + providing automatic authentication. + +### How MCP Fits the HA Ecosystem + +``` +┌─────────────────────────────────────────────┐ +│ Home Assistant │ +│ │ +│ ┌──────────┐ ┌────────────────────────┐ │ +│ │ HA Core │ │ Batcontrol Addon │ │ +│ │ │ │ │ │ +│ │ MQTT ◄──┼──┤ MQTT API (existing) │ │ +│ │ Broker │ │ │ │ +│ │ │ │ MCP Server :8081 (new) │ │ +│ └──────────┘ └───────────┬────────────┘ │ +│ │ │ +│ ┌─────────────────────────┼──────────────┐ │ +│ │ HA Voice / Assist │ │ │ +│ │ (future MCP client) ▼ │ │ +│ │ MCP over HTTP │ │ +│ └────────────────────────────────────────┘ │ +└─────────────────────────────────────────────┘ + │ + │ MCP over HTTP (LAN) + ▼ + Claude Desktop / other MCP clients +``` + +**Key integration points:** +- MQTT remains the primary HA integration (dashboards, automations, sensors) +- MCP adds a **conversational** layer — ideal for voice assistants and AI agents +- Both share the same `Batcontrol` instance and `OverrideManager` +- HA's future native MCP support could make this seamless (HA is exploring MCP) + +--- + +## Phase 5: Testing & Documentation + +- [ ] Unit tests for `OverrideManager` (expiry, clear, concurrent access) +- [ ] Unit tests for MCP tools (mock `Batcontrol` instance) +- [ ] Integration test: MCP client → server → override → evaluation cycle +- [ ] Update `README.MD` with MCP section +- [ ] Update `config/batcontrol_config_dummy.yaml` with MCP config example +- [ ] Document MCP tools and their parameters + +--- + +## Task Checklist + +### Phase 1: Override Manager +- [ ] Design and implement `OverrideManager` class +- [ ] Write unit tests for `OverrideManager` +- [ ] Integrate `OverrideManager` into `core.py` (replace `api_overwrite`) +- [ ] Update MQTT `mode/set` to use `OverrideManager` +- [ ] Test backward compatibility with existing MQTT overrides + +### Phase 2: MCP Server Core +- [ ] Add `mcp` dependency to `pyproject.toml` +- [ ] Implement `mcp_server.py` with read-only tools +- [ ] Add write tools (`set_mode_override`, `clear_mode_override`, `set_charge_rate`) +- [ ] Add `set_parameter` tool for runtime config changes +- [ ] Initialize MCP server from `core.py` (same pattern as MQTT) +- [ ] Add `--mcp-stdio` CLI flag to `__main__.py` +- [ ] Add MCP config section to config YAML +- [ ] Write MCP server unit tests + +### Phase 3: Decision Explainability +- [ ] Extend logic output with explanation field +- [ ] Add explanation accumulation in `DefaultLogic.calculate()` +- [ ] Expose last explanation via `core.py` getter +- [ ] Implement `get_decision_explanation` MCP tool +- [ ] Test explanation output + +### Phase 4: HA Addon Integration +- [ ] Document required changes for `batcontrol_ha_addon` repository +- [ ] Add MCP port to Dockerfile EXPOSE +- [ ] Test MCP server in Docker container +- [ ] Test MCP connectivity from external client + +### Phase 5: Testing & Documentation +- [ ] End-to-end test: client → MCP → override → evaluation +- [ ] Update README with MCP documentation +- [ ] Update dummy config with MCP section +- [ ] Verify no regressions in existing tests + +--- + +## Dependencies + +- `mcp>=1.0` — Official MCP Python SDK (includes FastMCP, transports) +- No other new dependencies required (HTTP server is built into `mcp` SDK via `uvicorn`/`starlette`) + +## Risk Assessment + +| Risk | Mitigation | +|------|-----------| +| Thread safety between MCP and main loop | OverrideManager uses threading.Lock; read tools access immutable snapshot data | +| MCP server crash affects batcontrol | MCP runs in isolated thread; exceptions are caught and logged | +| Security (unauthenticated access) | Optional auth_token config; bind to localhost by default | +| Resource usage on Raspberry Pi | MCP server is lightweight; HTTP idle connections use minimal memory | +| Breaking existing MQTT behavior | OverrideManager is backward compatible; MQTT mode/set gets default duration | From a8ff9b9ceca7b01ec8485a724a66fab2be0adb05 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Mar 2026 18:39:17 +0000 Subject: [PATCH 02/13] Add MCP server and duration-based override manager Phase 1 - OverrideManager (replaces single-shot api_overwrite): - New OverrideManager class with time-bounded overrides that persist across evaluation cycles and auto-expire - MQTT mode/set now creates 30-minute overrides (backward compatible) - Override status published to MQTT (override_active, remaining_minutes) - HA auto-discovery messages for override sensors Phase 2 - MCP Server: - 9 read tools: get_system_status, get_price_forecast, get_solar_forecast, get_consumption_forecast, get_net_consumption_forecast, get_battery_info, get_decision_explanation, get_configuration, get_override_status - 4 write tools: set_mode_override (with duration), clear_mode_override, set_charge_rate, set_parameter - Streamable HTTP transport (port 8081) for Docker/HA addon - stdio transport via --mcp-stdio flag for local AI tool integration - In-process daemon thread, same pattern as MqttApi Phase 3 - Decision Explainability: - CalculationOutput.explanation field accumulates reasoning steps - DefaultLogic annotates price analysis, energy balance, and decisions - Exposed via get_decision_explanation MCP tool Config & deployment: - New mcp section in batcontrol_config_dummy.yaml - mcp>=1.0 added to pyproject.toml dependencies - EXPOSE 8081 added to Dockerfile - --mcp-stdio CLI flag added to __main__.py Tests: 37 new tests (14 override, 23 MCP), all 361 tests pass. https://claude.ai/code/session_0145EbJrBDema8V6xSTGdL8M --- Dockerfile | 3 + config/batcontrol_config_dummy.yaml | 11 + pyproject.toml | 3 +- src/batcontrol/__main__.py | 19 ++ src/batcontrol/core.py | 127 +++++-- src/batcontrol/logic/default.py | 27 +- src/batcontrol/logic/logic_interface.py | 4 +- src/batcontrol/mcp_server.py | 387 ++++++++++++++++++++++ src/batcontrol/mqtt_api.py | 38 +++ src/batcontrol/override_manager.py | 144 ++++++++ tests/batcontrol/test_core.py | 2 +- tests/batcontrol/test_mcp_server.py | 249 ++++++++++++++ tests/batcontrol/test_override_manager.py | 158 +++++++++ 13 files changed, 1137 insertions(+), 35 deletions(-) create mode 100644 src/batcontrol/mcp_server.py create mode 100644 src/batcontrol/override_manager.py create mode 100644 tests/batcontrol/test_mcp_server.py create mode 100644 tests/batcontrol/test_override_manager.py diff --git a/Dockerfile b/Dockerfile index 447f2b42..8ca6cd74 100644 --- a/Dockerfile +++ b/Dockerfile @@ -52,6 +52,9 @@ COPY config ./config_template # Set the scripts as executable RUN chmod +x entrypoint.sh +# Expose MCP server port (only used when mcp.enabled=true in config) +EXPOSE 8081 + VOLUME ["/app/logs", "/app/config"] CMD ["/bin/sh", "/app/entrypoint.sh"] diff --git a/config/batcontrol_config_dummy.yaml b/config/batcontrol_config_dummy.yaml index 6f32975f..d502a39d 100644 --- a/config/batcontrol_config_dummy.yaml +++ b/config/batcontrol_config_dummy.yaml @@ -82,6 +82,17 @@ utility: # zone_3_hours: 17-20 # optional hours for zone 3 (must not overlap with zone 1 or 2) # apikey: YOUR_API_KEY # MANDATORY for energyforecast and tibber. Uncomment and set if using those providers. +#-------------------------- +# MCP Server (Model Context Protocol) +# Exposes batcontrol as AI-accessible tools for querying status, +# forecasts, and managing overrides via Claude Desktop or other MCP clients. +#-------------------------- +mcp: + enabled: false + transport: http # 'http' for network access (Docker/HA addon), 'stdio' for direct pipe + host: 0.0.0.0 # Bind address (0.0.0.0 for all interfaces) + port: 8081 # HTTP port for MCP server + #-------------------------- # MQTT API # See more Details in: https://github.com/MaStr/batcontrol/wiki/MQTT-API diff --git a/pyproject.toml b/pyproject.toml index cf64fdf1..21626684 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,8 @@ dependencies = [ "cachetools>=5.0", "websockets>=11.0", "schedule>=1.2.0", - "pytz>=2024.2" + "pytz>=2024.2", + "mcp>=1.0" ] # Config for the build system diff --git a/src/batcontrol/__main__.py b/src/batcontrol/__main__.py index 792f18d0..24253225 100644 --- a/src/batcontrol/__main__.py +++ b/src/batcontrol/__main__.py @@ -1,6 +1,7 @@ from .core import Batcontrol from .setup import setup_logging, load_config from .inverter import InverterOutageError +from .mcp_server import BatcontrolMcpServer import argparse import time import datetime @@ -30,6 +31,11 @@ def parse_arguments(): default=CONFIGFILE, help=f'Path to configuration file (default: {CONFIGFILE})' ) + parser.add_argument( + '--mcp-stdio', + action='store_true', + help='Run MCP server with stdio transport (for direct integration with AI tools)' + ) return parser.parse_args() @@ -80,6 +86,19 @@ def main() -> int: bc = Batcontrol(config) + # Handle --mcp-stdio: run MCP server in stdio mode (blocking) + if args.mcp_stdio: + logger.info("Running MCP server in stdio mode") + mcp = BatcontrolMcpServer(bc, config.get('mcp', {})) + try: + mcp.run_stdio() + except KeyboardInterrupt: + pass + finally: + bc.shutdown() + del bc + return 0 + try: while True: logger.info("Starting batcontrol") diff --git a/src/batcontrol/core.py b/src/batcontrol/core.py index 5d6c4768..42c90b5c 100644 --- a/src/batcontrol/core.py +++ b/src/batcontrol/core.py @@ -32,6 +32,8 @@ from .forecastsolar import ForecastSolar as solar_factory from .forecastconsumption import Consumption as consumption_factory +from .override_manager import OverrideManager +from .mcp_server import BatcontrolMcpServer ERROR_IGNORE_TIME = 600 # 10 Minutes EVALUATIONS_EVERY_MINUTES = 3 # Every x minutes on the clock @@ -56,7 +58,7 @@ class Batcontrol: def __init__(self, configdict: dict): # For API - self.api_overwrite = False + self.override_manager = OverrideManager() # -1 = charge from grid , 0 = avoid discharge , 8 = limit battery charge, 10 = discharge allowed self.last_mode = None self.last_charge_rate = 0 @@ -292,6 +294,19 @@ def __init__(self, configdict: dict): self.evcc_api.wait_ready() logger.info('evcc Connection ready') + # Initialize MCP server + self.mcp_server = None + mcp_config = config.get('mcp', {}) + if mcp_config.get('enabled', False): + logger.info('MCP Server enabled') + self.mcp_server = BatcontrolMcpServer(self, mcp_config) + transport = mcp_config.get('transport', 'http') + if transport == 'http': + host = mcp_config.get('host', '0.0.0.0') + port = mcp_config.get('port', 8081) + self.mcp_server.start_http(host=host, port=port) + # stdio transport is handled in __main__.py + # Initialize scheduler thread self.scheduler = SchedulerThread() logger.info('Scheduler thread initialized') @@ -326,6 +341,10 @@ def shutdown(self): self.scheduler.stop() del self.scheduler + # Stop MCP server + if hasattr(self, 'mcp_server') and self.mcp_server is not None: + self.mcp_server.shutdown() + self.inverter.shutdown() del self.inverter if self.evcc_api is not None: @@ -457,14 +476,16 @@ def run(self): # Store data for API self.__save_run_data(production, consumption, net_consumption, prices) - # stop here if api_overwrite is set and reset it - if self.api_overwrite: + # Check if a time-bounded override is active + override = self.override_manager.get_override() + if override is not None: logger.info( - 'API Overwrite active. Skipping control logic. ' - 'Next evaluation in %.0f seconds', - TIME_BETWEEN_EVALUATIONS + 'Override active: mode=%s, %.1f min remaining, reason="%s". ' + 'Skipping control logic.', + override.mode, override.remaining_minutes, override.reason ) - self.api_overwrite = False + # Re-apply the override mode to ensure inverter stays in sync + self._apply_override(override) return # Correction for time that has already passed in the current interval @@ -797,13 +818,46 @@ def refresh_static_values(self) -> None: self.mqtt_api.publish_evaluation_intervall( TIME_BETWEEN_EVALUATIONS) self.mqtt_api.publish_last_evaluation_time(self.last_run_time) + # Publish override status + override = self.override_manager.get_override() + self.mqtt_api.publish_override_active(override is not None) + self.mqtt_api.publish_override_remaining( + override.remaining_minutes if override else 0.0) # self.mqtt_api.publish_discharge_blocked(self.discharge_blocked) # Trigger Inverter self.inverter.refresh_api_values() - def api_set_mode(self, mode: int): - """ Log and change config run mode of inverter(s) from external call """ + def _apply_override(self, override): + """Apply an override's mode/charge_rate to the inverter.""" + mode = override.mode + charge_rate = override.charge_rate + + if mode == MODE_FORCE_CHARGING: + if charge_rate is not None and charge_rate > 0: + self.force_charge(charge_rate) + else: + self.force_charge() + elif mode == MODE_AVOID_DISCHARGING: + self.avoid_discharging() + elif mode == MODE_LIMIT_BATTERY_CHARGE_RATE: + if self._limit_battery_charge_rate < 0: + logger.warning( + 'Override: Mode %d (limit battery charge rate) set but no valid ' + 'limit configured. Falling back to allow-discharging mode.', + mode) + self.limit_battery_charge_rate(self._limit_battery_charge_rate) + elif mode == MODE_ALLOW_DISCHARGING: + self.allow_discharging() + + def api_set_mode(self, mode: int, duration_minutes: float = None): + """ Log and change config run mode of inverter(s) from external call. + + Uses the OverrideManager for time-bounded overrides. + Args: + mode: Inverter mode (-1, 0, 8, 10) + duration_minutes: Override duration. None uses OverrideManager default. + """ # Check if mode is valid if mode not in [ MODE_FORCE_CHARGING, @@ -814,34 +868,33 @@ def api_set_mode(self, mode: int): return logger.info('API: Setting mode to %s', mode) - self.api_overwrite = True + override = self.override_manager.set_override( + mode=mode, + duration_minutes=duration_minutes, + reason="MQTT API mode/set" + ) + self._apply_override(override) - if mode != self.last_mode: - if mode == MODE_FORCE_CHARGING: - self.force_charge() - elif mode == MODE_AVOID_DISCHARGING: - self.avoid_discharging() - elif mode == MODE_LIMIT_BATTERY_CHARGE_RATE: - if self._limit_battery_charge_rate < 0: - logger.warning( - 'API: Mode %d (limit battery charge rate) set but no valid ' - 'limit configured. Set a limit via api_set_limit_battery_charge_rate ' - 'first. Falling back to allow-discharging mode.', - mode) - self.limit_battery_charge_rate(self._limit_battery_charge_rate) - elif mode == MODE_ALLOW_DISCHARGING: - self.allow_discharging() + def api_set_charge_rate(self, charge_rate: int, duration_minutes: float = None): + """ Log and change config charge_rate and activate charging. - def api_set_charge_rate(self, charge_rate: int): - """ Log and change config charge_rate and activate charging.""" + Uses the OverrideManager for time-bounded overrides. + Args: + charge_rate: Charge rate in W + duration_minutes: Override duration. None uses OverrideManager default. + """ if charge_rate < 0: logger.warning( 'API: Invalid charge rate %d W', charge_rate) return logger.info('API: Setting charge rate to %d W', charge_rate) - self.api_overwrite = True - if charge_rate != self.last_charge_rate: - self.force_charge(charge_rate) + override = self.override_manager.set_override( + mode=MODE_FORCE_CHARGING, + charge_rate=charge_rate, + duration_minutes=duration_minutes, + reason="MQTT API charge_rate/set" + ) + self._apply_override(override) def api_set_limit_battery_charge_rate(self, limit: int): """ Set dynamic battery charge rate limit from external call @@ -900,6 +953,20 @@ def api_set_min_price_difference(self, min_price_difference: float): 'API: Setting min price difference to %.3f', min_price_difference) self.min_price_difference = min_price_difference + def api_get_decision_explanation(self) -> list: + """Get the explanation steps from the last logic calculation. + + Returns: + List of human-readable explanation strings, or empty list if no + calculation has been run yet. + """ + if self.last_logic_instance is None: + return [] + calc_output = self.last_logic_instance.get_calculation_output() + if calc_output is None: + return [] + return list(calc_output.explanation) + def api_set_min_price_difference_rel( self, min_price_difference_rel: float): """ Log and change config min_price_difference_rel from external call """ diff --git a/src/batcontrol/logic/default.py b/src/batcontrol/logic/default.py index cd60d303..b32e6681 100644 --- a/src/batcontrol/logic/default.py +++ b/src/batcontrol/logic/default.py @@ -79,6 +79,11 @@ def get_inverter_control_settings(self) -> InverterControlSettings: """ Get the inverter control settings from the last calculation """ return self.inverter_control_settings + def _explain(self, message: str): + """Add an explanation step to the calculation output.""" + if self.calculation_output is not None: + self.calculation_output.explanation.append(message) + def calculate_inverter_mode(self, calc_input: CalculationInput, calc_timestamp: Optional[datetime.datetime] = None) -> InverterControlSettings: """ Main control logic for battery control """ @@ -100,16 +105,22 @@ def calculate_inverter_mode(self, calc_input: CalculationInput, if calc_timestamp is None: calc_timestamp = datetime.datetime.now().astimezone(self.timezone) + self._explain( + "Current price: %.4f, Battery: %.0f Wh stored, %.0f Wh usable, %.0f Wh free capacity" % ( + prices[0], calc_input.stored_energy, + calc_input.stored_usable_energy, calc_input.free_capacity)) + # ensure availability of data max_slot = min(len(net_consumption), len(prices)) if self.__is_discharge_allowed(calc_input, net_consumption, prices, calc_timestamp): inverter_control_settings.allow_discharge = True inverter_control_settings.limit_battery_charge_rate = -1 # no limit - + self._explain("Decision: Allow discharge") return inverter_control_settings else: # discharge not allowed logger.debug('Discharging is NOT allowed') + self._explain("Discharge not allowed — reserving energy for future high-price slots") inverter_control_settings.allow_discharge = False charging_limit_percent = self.calculation_parameters.max_charging_from_grid_limit * 100 charge_limit_capacity = self.common.max_capacity * \ @@ -124,6 +135,7 @@ def calculate_inverter_mode(self, calc_input: CalculationInput, logger.debug('Charging is allowed, because SOC is below %.0f%%', charging_limit_percent ) + self._explain("Grid charging possible (SOC below %.0f%% limit)" % charging_limit_percent) required_recharge_energy = self.__get_required_recharge_energy( calc_input, net_consumption[:max_slot], @@ -133,6 +145,7 @@ def calculate_inverter_mode(self, calc_input: CalculationInput, logger.debug('Charging is NOT allowed, because SOC is above %.0f%%', charging_limit_percent ) + self._explain("Grid charging blocked (SOC above %.0f%% limit)" % charging_limit_percent) if required_recharge_energy > 0: allowed_charging_energy = charge_limit_capacity - calc_input.stored_energy @@ -146,6 +159,7 @@ def calculate_inverter_mode(self, calc_input: CalculationInput, 'Get additional energy via grid: %0.1f Wh', required_recharge_energy ) + self._explain("Need %.0f Wh from grid for upcoming high-price periods" % required_recharge_energy) elif required_recharge_energy == 0 and is_charging_possible: logger.debug( 'No additional energy required or possible price found.') @@ -184,12 +198,13 @@ def calculate_inverter_mode(self, calc_input: CalculationInput, charge_rate = self.common.calculate_charge_rate(charge_rate) - #self.force_charge(charge_rate) inverter_control_settings.charge_from_grid = True inverter_control_settings.charge_rate = charge_rate + self._explain("Decision: Force charge from grid at %d W" % charge_rate) else: # keep current charge level. recharge if solar surplus available inverter_control_settings.allow_discharge = False + self._explain("Decision: Avoid discharge (hold current charge level)") # return inverter_control_settings @@ -211,6 +226,8 @@ def __is_discharge_allowed(self, calc_input: CalculationInput, if self.common.is_discharge_always_allowed_capacity(calc_input.stored_energy): logger.info( "[Rule] Discharge allowed due to always_allow_discharge_limit") + self._explain("Battery above always-allow-discharge limit (%.0f%%) — discharge permitted regardless" % ( + self.common.always_allow_discharge_limit * 100)) return True current_price = prices[0] @@ -315,6 +332,9 @@ def __is_discharge_allowed(self, calc_input: CalculationInput, calc_input.stored_usable_energy, reserved_storage ) + self._explain( + "Usable energy (%.0f Wh) > reserved energy (%.0f Wh) — surplus available for discharge" % ( + calc_input.stored_usable_energy, reserved_storage)) return True # forbid discharging @@ -323,6 +343,9 @@ def __is_discharge_allowed(self, calc_input: CalculationInput, calc_input.stored_usable_energy, reserved_storage ) + self._explain( + "Usable energy (%.0f Wh) <= reserved energy (%.0f Wh) — holding for %d upcoming higher-price slot(s)" % ( + calc_input.stored_usable_energy, reserved_storage, len(higher_price_slots))) return False diff --git a/src/batcontrol/logic/logic_interface.py b/src/batcontrol/logic/logic_interface.py index ff207b26..5eadc743 100644 --- a/src/batcontrol/logic/logic_interface.py +++ b/src/batcontrol/logic/logic_interface.py @@ -1,6 +1,7 @@ from abc import ABC, abstractmethod -from dataclasses import dataclass +from dataclasses import dataclass, field import datetime +from typing import List import numpy as np @dataclass @@ -27,6 +28,7 @@ class CalculationOutput: reserved_energy: float = 0.0 required_recharge_energy: float = 0.0 min_dynamic_price_difference: float = 0.05 + explanation: List[str] = field(default_factory=list) @dataclass class InverterControlSettings: diff --git a/src/batcontrol/mcp_server.py b/src/batcontrol/mcp_server.py new file mode 100644 index 00000000..38813b59 --- /dev/null +++ b/src/batcontrol/mcp_server.py @@ -0,0 +1,387 @@ +"""MCP (Model Context Protocol) Server for Batcontrol + +Provides AI-accessible tools to query system state, forecasts, +battery info, and manage overrides. + +Runs in-process as a thread alongside the main batcontrol evaluation loop. +Supports Streamable HTTP transport (for Docker/HA addon) and stdio transport. +""" +import json +import time +import logging +import threading +from typing import Optional + +from mcp.server.fastmcp import FastMCP + +logger = logging.getLogger(__name__) + +# Mode constants (duplicated to avoid circular imports) +MODE_ALLOW_DISCHARGING = 10 +MODE_LIMIT_BATTERY_CHARGE_RATE = 8 +MODE_AVOID_DISCHARGING = 0 +MODE_FORCE_CHARGING = -1 + +MODE_NAMES = { + MODE_FORCE_CHARGING: "Force Charge from Grid", + MODE_AVOID_DISCHARGING: "Avoid Discharge", + MODE_LIMIT_BATTERY_CHARGE_RATE: "Limit PV Charge Rate", + MODE_ALLOW_DISCHARGING: "Allow Discharge", +} + +VALID_MODES = set(MODE_NAMES.keys()) + + +def _format_forecast_array(arr, run_time: float, interval_minutes: int) -> list: + """Format a numpy array forecast into a list of {time, value} dicts.""" + if arr is None: + return [] + interval_seconds = interval_minutes * 60 + base_time = run_time - (run_time % interval_seconds) + result = [] + for i, val in enumerate(arr): + result.append({ + 'slot': i, + 'time_start': base_time + i * interval_seconds, + 'value': round(float(val), 1), + }) + return result + + +class BatcontrolMcpServer: + """MCP server that exposes batcontrol as AI-accessible tools. + + Initialized with a reference to the live Batcontrol instance. + """ + + def __init__(self, batcontrol_instance, config: dict): + self._bc = batcontrol_instance + self._config = config + self._thread: Optional[threading.Thread] = None + + self.mcp = FastMCP( + "Batcontrol", + instructions=( + "Batcontrol is a home battery optimization system. It automatically " + "charges/discharges PV batteries based on dynamic electricity prices, " + "solar forecasts, and consumption patterns. Use these tools to inspect " + "system state, view forecasts, understand decisions, and manage overrides." + ), + ) + self._register_tools() + + def _register_tools(self): + """Register all MCP tools.""" + + # ---- Read Tools ---- + + @self.mcp.tool() + def get_system_status() -> dict: + """Get current batcontrol system status. + + Returns the current operating mode, battery state of charge, + charge rate, override status, and last evaluation time. + """ + bc = self._bc + override = bc.override_manager.get_override() + return { + 'mode': bc.last_mode, + 'mode_name': MODE_NAMES.get(bc.last_mode, "Unknown"), + 'charge_rate_w': bc.last_charge_rate, + 'soc_percent': bc.last_SOC, + 'stored_energy_wh': bc.last_stored_energy, + 'stored_usable_energy_wh': bc.last_stored_usable_energy, + 'max_capacity_wh': bc.last_max_capacity, + 'free_capacity_wh': bc.last_free_capacity, + 'reserved_energy_wh': bc.last_reserved_energy, + 'discharge_blocked': bc.discharge_blocked, + 'last_evaluation_timestamp': bc.last_run_time, + 'override': override.to_dict() if override else None, + 'time_resolution_minutes': bc.time_resolution, + } + + @self.mcp.tool() + def get_price_forecast() -> dict: + """Get the electricity price forecast for the next 24-48 hours. + + Returns hourly or 15-minute prices depending on system configuration. + Prices are in EUR/kWh. + """ + bc = self._bc + return { + 'interval_minutes': bc.time_resolution, + 'prices': _format_forecast_array( + bc.last_prices, bc.last_run_time, bc.time_resolution), + 'current_price': round(float(bc.last_prices[0]), 4) if bc.last_prices is not None and len(bc.last_prices) > 0 else None, + } + + @self.mcp.tool() + def get_solar_forecast() -> dict: + """Get the solar production forecast. + + Returns expected PV production in Watts per time interval. + """ + bc = self._bc + return { + 'interval_minutes': bc.time_resolution, + 'production_w': _format_forecast_array( + bc.last_production, bc.last_run_time, bc.time_resolution), + 'production_offset_percent': bc.production_offset_percent, + } + + @self.mcp.tool() + def get_consumption_forecast() -> dict: + """Get the household consumption forecast. + + Returns expected consumption in Watts per time interval. + """ + bc = self._bc + return { + 'interval_minutes': bc.time_resolution, + 'consumption_w': _format_forecast_array( + bc.last_consumption, bc.last_run_time, bc.time_resolution), + } + + @self.mcp.tool() + def get_net_consumption_forecast() -> dict: + """Get the net consumption forecast (consumption minus production). + + Positive values mean grid dependency, negative means surplus. + """ + bc = self._bc + return { + 'interval_minutes': bc.time_resolution, + 'net_consumption_w': _format_forecast_array( + bc.last_net_consumption, bc.last_run_time, bc.time_resolution), + } + + @self.mcp.tool() + def get_battery_info() -> dict: + """Get detailed battery information. + + Returns state of charge, capacity, stored energy, reserved energy, + and free capacity. + """ + bc = self._bc + return { + 'soc_percent': bc.last_SOC, + 'max_capacity_wh': bc.last_max_capacity, + 'stored_energy_wh': bc.last_stored_energy, + 'stored_usable_energy_wh': bc.last_stored_usable_energy, + 'free_capacity_wh': bc.last_free_capacity, + 'reserved_energy_wh': bc.last_reserved_energy, + 'always_allow_discharge_limit': bc.get_always_allow_discharge_limit(), + 'max_charging_from_grid_limit': bc.max_charging_from_grid_limit, + } + + @self.mcp.tool() + def get_decision_explanation() -> dict: + """Get an explanation of why the system chose the current operating mode. + + Returns step-by-step reasoning from the last evaluation cycle, + including price analysis, energy balance, and the final decision. + """ + bc = self._bc + override = bc.override_manager.get_override() + explanation = bc.api_get_decision_explanation() + + result = { + 'mode': bc.last_mode, + 'mode_name': MODE_NAMES.get(bc.last_mode, "Unknown"), + 'explanation_steps': explanation, + 'override_active': override is not None, + } + if override: + result['override_info'] = ( + "Manual override active: %s for %.1f more minutes. Reason: %s" % ( + MODE_NAMES.get(override.mode, "Unknown"), + override.remaining_minutes, + override.reason or "not specified" + ) + ) + return result + + @self.mcp.tool() + def get_configuration() -> dict: + """Get current runtime configuration parameters. + + Returns battery control thresholds, price settings, and + operational parameters that can be adjusted. + """ + bc = self._bc + return { + 'always_allow_discharge_limit': bc.get_always_allow_discharge_limit(), + 'max_charging_from_grid_limit': bc.max_charging_from_grid_limit, + 'min_price_difference': bc.min_price_difference, + 'min_price_difference_rel': bc.min_price_difference_rel, + 'production_offset_percent': bc.production_offset_percent, + 'time_resolution_minutes': bc.time_resolution, + 'limit_battery_charge_rate': bc._limit_battery_charge_rate, + } + + @self.mcp.tool() + def get_override_status() -> dict: + """Get the current override status. + + Returns details about any active manual override including + mode, remaining time, and reason. Returns null if no override is active. + """ + override = self._bc.override_manager.get_override() + if override is None: + return {'active': False, 'override': None} + return { + 'active': True, + 'override': override.to_dict(), + 'mode_name': MODE_NAMES.get(override.mode, "Unknown"), + } + + # ---- Write Tools ---- + + @self.mcp.tool() + def set_mode_override(mode: int, duration_minutes: float = 30, + reason: str = "") -> dict: + """Override the battery control mode for a specified duration. + + The override persists across evaluation cycles and auto-expires. + Normal autonomous logic resumes after expiry. + + Args: + mode: Inverter mode. + -1 = Force charge from grid + 0 = Avoid discharge (protect battery) + 8 = Limit PV charge rate + 10 = Allow discharge (normal operation) + duration_minutes: How long the override lasts (default 30 min) + reason: Human-readable reason for the override + """ + if mode not in VALID_MODES: + return {'error': "Invalid mode %s. Valid: %s" % (mode, sorted(VALID_MODES))} + if duration_minutes <= 0 or duration_minutes > 1440: + return {'error': "duration_minutes must be between 0 and 1440 (24h)"} + + bc = self._bc + override = bc.override_manager.set_override( + mode=mode, + duration_minutes=duration_minutes, + reason=reason or "MCP set_mode_override", + ) + bc._apply_override(override) + + return { + 'success': True, + 'override': override.to_dict(), + 'mode_name': MODE_NAMES.get(mode, "Unknown"), + } + + @self.mcp.tool() + def clear_mode_override() -> dict: + """Clear any active override and resume autonomous battery control. + + The next evaluation cycle will recalculate the optimal mode. + """ + bc = self._bc + was_active = bc.override_manager.is_active() + bc.override_manager.clear_override() + return { + 'success': True, + 'was_active': was_active, + 'message': "Override cleared. Autonomous logic will resume at next evaluation." + } + + @self.mcp.tool() + def set_charge_rate(charge_rate_w: int, duration_minutes: float = 30, + reason: str = "") -> dict: + """Force charge the battery at a specific rate for a duration. + + This sets the mode to Force Charge (-1) with the given charge rate. + + Args: + charge_rate_w: Charge rate in Watts (must be > 0) + duration_minutes: How long to charge (default 30 min) + reason: Human-readable reason + """ + if charge_rate_w <= 0: + return {'error': "charge_rate_w must be positive"} + if duration_minutes <= 0 or duration_minutes > 1440: + return {'error': "duration_minutes must be between 0 and 1440 (24h)"} + + bc = self._bc + override = bc.override_manager.set_override( + mode=MODE_FORCE_CHARGING, + charge_rate=charge_rate_w, + duration_minutes=duration_minutes, + reason=reason or "MCP set_charge_rate", + ) + bc._apply_override(override) + + return { + 'success': True, + 'override': override.to_dict(), + 'effective_charge_rate_w': bc.last_charge_rate, + } + + @self.mcp.tool() + def set_parameter(parameter: str, value: float) -> dict: + """Adjust a runtime configuration parameter. + + Changes are temporary and will not be written to the config file. + The next evaluation cycle will use the new value. + + Args: + parameter: Parameter name. One of: + - always_allow_discharge_limit (0.0-1.0) + - max_charging_from_grid_limit (0.0-1.0) + - min_price_difference (>= 0.0, EUR) + - min_price_difference_rel (>= 0.0) + - production_offset (0.0-2.0, multiplier) + value: New value for the parameter + """ + bc = self._bc + handlers = { + 'always_allow_discharge_limit': bc.api_set_always_allow_discharge_limit, + 'max_charging_from_grid_limit': bc.api_set_max_charging_from_grid_limit, + 'min_price_difference': bc.api_set_min_price_difference, + 'min_price_difference_rel': bc.api_set_min_price_difference_rel, + 'production_offset': bc.api_set_production_offset, + } + + if parameter not in handlers: + return { + 'error': "Unknown parameter '%s'. Valid: %s" % ( + parameter, sorted(handlers.keys())) + } + + handlers[parameter](value) + return { + 'success': True, + 'parameter': parameter, + 'new_value': value, + } + + def start_http(self, host: str = "0.0.0.0", port: int = 8081): + """Start the MCP server with Streamable HTTP transport in a background thread.""" + def _run(): + logger.info("Starting MCP server on %s:%d", host, port) + try: + self.mcp.run(transport="streamable-http", host=host, port=port) + except Exception as e: + logger.error("MCP server error: %s", e, exc_info=True) + + self._thread = threading.Thread( + target=_run, + name="MCPServerThread", + daemon=True, + ) + self._thread.start() + logger.info("MCP server thread started") + + def run_stdio(self): + """Run the MCP server with stdio transport (blocking).""" + logger.info("Starting MCP server with stdio transport") + self.mcp.run(transport="stdio") + + def shutdown(self): + """Shutdown the MCP server.""" + logger.info("MCP server shutdown requested") + # The daemon thread will be cleaned up automatically on process exit. + # For stdio mode, the process is already blocking on it. diff --git a/src/batcontrol/mqtt_api.py b/src/batcontrol/mqtt_api.py index 67e7749d..cb2bd055 100644 --- a/src/batcontrol/mqtt_api.py +++ b/src/batcontrol/mqtt_api.py @@ -434,6 +434,24 @@ def publish_discharge_blocked(self, discharge_blocked: bool) -> None: '/discharge_blocked', str(discharge_blocked)) + def publish_override_active(self, active: bool) -> None: + """ Publish override active status to MQTT + /override_active + """ + if self.client.is_connected(): + self.client.publish( + self.base_topic + '/override_active', + str(active)) + + def publish_override_remaining(self, remaining_minutes: float) -> None: + """ Publish override remaining minutes to MQTT + /override_remaining_minutes + """ + if self.client.is_connected(): + self.client.publish( + self.base_topic + '/override_remaining_minutes', + f'{remaining_minutes:.1f}') + def publish_production_offset(self, production_offset: float) -> None: """ Publish the production offset percentage to MQTT /production_offset @@ -587,6 +605,26 @@ def send_mqtt_discovery_messages(self) -> None: self.base_topic + "/min_dynamic_price_difference") + # override + self.publish_mqtt_discovery_message( + "Override Active", + "batcontrol_override_active", + "sensor", + None, + None, + self.base_topic + + "/override_active", + value_template="{% if value | lower == 'true' %}active{% else %}inactive{% endif %}") + + self.publish_mqtt_discovery_message( + "Override Remaining Minutes", + "batcontrol_override_remaining_minutes", + "sensor", + "duration", + "min", + self.base_topic + + "/override_remaining_minutes") + # diagnostic self.publish_mqtt_discovery_message( "Status", diff --git a/src/batcontrol/override_manager.py b/src/batcontrol/override_manager.py new file mode 100644 index 00000000..7c821ddd --- /dev/null +++ b/src/batcontrol/override_manager.py @@ -0,0 +1,144 @@ +"""Override Manager for Batcontrol + +Manages time-bounded overrides for battery control mode. +Replaces the single-shot api_overwrite flag with duration-based overrides +that persist across multiple evaluation cycles. + +Used by both MQTT API and MCP server to provide meaningful manual control. +""" +import time +import threading +import logging +from dataclasses import dataclass, field +from typing import Optional + +logger = logging.getLogger(__name__) + +# Default duration for MQTT overrides (backward compatible) +DEFAULT_OVERRIDE_DURATION_MINUTES = 30 + + +@dataclass +class OverrideState: + """Represents an active override.""" + mode: int + charge_rate: Optional[int] + duration_minutes: float + reason: str + created_at: float = field(default_factory=time.time) + expires_at: float = 0.0 + + def __post_init__(self): + if self.expires_at == 0.0: + self.expires_at = self.created_at + self.duration_minutes * 60 + + @property + def remaining_seconds(self) -> float: + """Seconds remaining before override expires.""" + return max(0.0, self.expires_at - time.time()) + + @property + def remaining_minutes(self) -> float: + """Minutes remaining before override expires.""" + return self.remaining_seconds / 60.0 + + @property + def is_expired(self) -> bool: + """True if the override has expired.""" + return time.time() >= self.expires_at + + def to_dict(self) -> dict: + """Serialize override state to a dictionary.""" + return { + 'mode': self.mode, + 'charge_rate': self.charge_rate, + 'duration_minutes': self.duration_minutes, + 'reason': self.reason, + 'created_at': self.created_at, + 'expires_at': self.expires_at, + 'remaining_minutes': round(self.remaining_minutes, 1), + 'is_active': not self.is_expired, + } + + +class OverrideManager: + """Manages time-bounded overrides for batcontrol operation. + + Thread-safe: all public methods acquire a lock before modifying state. + """ + + def __init__(self, default_duration_minutes: float = DEFAULT_OVERRIDE_DURATION_MINUTES): + self._lock = threading.Lock() + self._override: Optional[OverrideState] = None + self.default_duration_minutes = default_duration_minutes + + def set_override(self, mode: int, duration_minutes: Optional[float] = None, + charge_rate: Optional[int] = None, + reason: str = "") -> OverrideState: + """Set a time-bounded override. + + Args: + mode: Inverter mode (-1, 0, 8, 10) + duration_minutes: How long the override should last. + None uses default_duration_minutes. + charge_rate: Optional charge rate in W (relevant for mode -1) + reason: Human-readable reason for the override + + Returns: + The created OverrideState + """ + if duration_minutes is None: + duration_minutes = self.default_duration_minutes + + if duration_minutes <= 0: + raise ValueError("duration_minutes must be positive") + + with self._lock: + self._override = OverrideState( + mode=mode, + charge_rate=charge_rate, + duration_minutes=duration_minutes, + reason=reason, + ) + logger.info( + 'Override set: mode=%s, duration=%.1f min, charge_rate=%s, reason="%s"', + mode, duration_minutes, charge_rate, reason + ) + return self._override + + def clear_override(self) -> None: + """Clear the active override, resuming autonomous logic.""" + with self._lock: + if self._override is not None: + logger.info('Override cleared (was mode=%s, reason="%s")', + self._override.mode, self._override.reason) + self._override = None + + def get_override(self) -> Optional[OverrideState]: + """Get the active override, or None if no override is active. + + Automatically clears expired overrides. + """ + with self._lock: + if self._override is None: + return None + if self._override.is_expired: + logger.info( + 'Override expired: mode=%s, reason="%s"', + self._override.mode, self._override.reason + ) + self._override = None + return None + return self._override + + def is_active(self) -> bool: + """Check if an override is currently active (not expired).""" + return self.get_override() is not None + + @property + def remaining_minutes(self) -> float: + """Minutes remaining on the current override, or 0 if none.""" + override = self.get_override() + if override is None: + return 0.0 + return override.remaining_minutes diff --git a/tests/batcontrol/test_core.py b/tests/batcontrol/test_core.py index 633566b6..0a3120bd 100644 --- a/tests/batcontrol/test_core.py +++ b/tests/batcontrol/test_core.py @@ -226,7 +226,7 @@ def test_api_set_mode_accepts_mode_8( # Verify mode was set assert bc.last_mode == MODE_LIMIT_BATTERY_CHARGE_RATE - assert bc.api_overwrite is True + assert bc.override_manager.is_active() @patch('batcontrol.core.tariff_factory.create_tarif_provider') @patch('batcontrol.core.inverter_factory.create_inverter') diff --git a/tests/batcontrol/test_mcp_server.py b/tests/batcontrol/test_mcp_server.py new file mode 100644 index 00000000..bd78f00a --- /dev/null +++ b/tests/batcontrol/test_mcp_server.py @@ -0,0 +1,249 @@ +"""Tests for the MCP server implementation""" +import pytest +import numpy as np +from unittest.mock import MagicMock, patch, PropertyMock + +from batcontrol.mcp_server import BatcontrolMcpServer, _format_forecast_array, MODE_NAMES +from batcontrol.override_manager import OverrideManager, OverrideState + + +class TestFormatForecastArray: + """Test the forecast array formatting utility.""" + + def test_none_array(self): + result = _format_forecast_array(None, 1000.0, 60) + assert result == [] + + def test_simple_array(self): + arr = np.array([100.0, 200.0, 300.0]) + result = _format_forecast_array(arr, 3600.0, 60) + assert len(result) == 3 + assert result[0]['slot'] == 0 + assert result[0]['value'] == 100.0 + assert result[1]['value'] == 200.0 + assert result[2]['value'] == 300.0 + + def test_15min_interval(self): + arr = np.array([100.0, 200.0]) + result = _format_forecast_array(arr, 900.0, 15) + assert len(result) == 2 + # 15 min = 900 seconds between slots + assert result[1]['time_start'] - result[0]['time_start'] == 900 + + def test_60min_interval(self): + arr = np.array([100.0, 200.0]) + result = _format_forecast_array(arr, 3600.0, 60) + assert len(result) == 2 + assert result[1]['time_start'] - result[0]['time_start'] == 3600 + + +class TestBatcontrolMcpServer: + """Test MCP server tool registration and execution.""" + + @pytest.fixture + def mock_bc(self): + """Create a mock Batcontrol instance with all necessary attributes.""" + bc = MagicMock() + bc.override_manager = OverrideManager() + bc.last_mode = 10 + bc.last_charge_rate = 0 + bc.last_SOC = 65.0 + bc.last_stored_energy = 5000.0 + bc.last_stored_usable_energy = 4500.0 + bc.last_max_capacity = 10000.0 + bc.last_free_capacity = 5000.0 + bc.last_reserved_energy = 1000.0 + bc.discharge_blocked = False + bc.last_run_time = 1700000000.0 + bc.time_resolution = 60 + bc.last_prices = np.array([0.15, 0.20, 0.25, 0.18]) + bc.last_production = np.array([500.0, 1000.0, 800.0, 200.0]) + bc.last_consumption = np.array([300.0, 400.0, 500.0, 350.0]) + bc.last_net_consumption = np.array([-200.0, -600.0, -300.0, 150.0]) + bc.production_offset_percent = 1.0 + bc.max_charging_from_grid_limit = 0.8 + bc.min_price_difference = 0.05 + bc.min_price_difference_rel = 0.1 + bc._limit_battery_charge_rate = -1 + bc.get_always_allow_discharge_limit.return_value = 0.9 + bc.api_get_decision_explanation.return_value = [ + "Current price: 0.1500, Battery: 5000 Wh stored", + "Decision: Allow discharge" + ] + bc.api_set_always_allow_discharge_limit = MagicMock() + bc.api_set_max_charging_from_grid_limit = MagicMock() + bc.api_set_min_price_difference = MagicMock() + bc.api_set_min_price_difference_rel = MagicMock() + bc.api_set_production_offset = MagicMock() + bc._apply_override = MagicMock() + return bc + + @pytest.fixture + def server(self, mock_bc): + """Create a BatcontrolMcpServer instance.""" + return BatcontrolMcpServer(mock_bc, {}) + + def test_server_creation(self, server): + """Test that server creates without errors.""" + assert server.mcp is not None + assert server._bc is not None + + def test_tools_registered(self, server): + """Test that all expected tools are registered.""" + tools = server.mcp._tool_manager._tools + expected_tools = [ + 'get_system_status', + 'get_price_forecast', + 'get_solar_forecast', + 'get_consumption_forecast', + 'get_net_consumption_forecast', + 'get_battery_info', + 'get_decision_explanation', + 'get_configuration', + 'get_override_status', + 'set_mode_override', + 'clear_mode_override', + 'set_charge_rate', + 'set_parameter', + ] + for tool_name in expected_tools: + assert tool_name in tools, f"Tool '{tool_name}' not registered" + + def test_get_system_status(self, server, mock_bc): + """Test get_system_status tool returns correct data.""" + # Access the tool function directly + tool_fn = server.mcp._tool_manager._tools['get_system_status'].fn + result = tool_fn() + assert result['mode'] == 10 + assert result['mode_name'] == "Allow Discharge" + assert result['soc_percent'] == 65.0 + assert result['override'] is None + + def test_get_price_forecast(self, server, mock_bc): + """Test get_price_forecast tool.""" + tool_fn = server.mcp._tool_manager._tools['get_price_forecast'].fn + result = tool_fn() + assert result['interval_minutes'] == 60 + assert result['current_price'] == 0.15 + assert len(result['prices']) == 4 + + def test_get_solar_forecast(self, server, mock_bc): + """Test get_solar_forecast tool.""" + tool_fn = server.mcp._tool_manager._tools['get_solar_forecast'].fn + result = tool_fn() + assert len(result['production_w']) == 4 + assert result['production_offset_percent'] == 1.0 + + def test_get_battery_info(self, server, mock_bc): + """Test get_battery_info tool.""" + tool_fn = server.mcp._tool_manager._tools['get_battery_info'].fn + result = tool_fn() + assert result['soc_percent'] == 65.0 + assert result['max_capacity_wh'] == 10000.0 + assert result['always_allow_discharge_limit'] == 0.9 + + def test_get_decision_explanation(self, server, mock_bc): + """Test get_decision_explanation tool.""" + tool_fn = server.mcp._tool_manager._tools['get_decision_explanation'].fn + result = tool_fn() + assert result['mode'] == 10 + assert len(result['explanation_steps']) == 2 + assert "Allow discharge" in result['explanation_steps'][1] + assert result['override_active'] is False + + def test_get_configuration(self, server, mock_bc): + """Test get_configuration tool.""" + tool_fn = server.mcp._tool_manager._tools['get_configuration'].fn + result = tool_fn() + assert result['min_price_difference'] == 0.05 + assert result['time_resolution_minutes'] == 60 + + def test_get_override_status_inactive(self, server): + """Test get_override_status when no override is active.""" + tool_fn = server.mcp._tool_manager._tools['get_override_status'].fn + result = tool_fn() + assert result['active'] is False + assert result['override'] is None + + def test_get_override_status_active(self, server, mock_bc): + """Test get_override_status when override is active.""" + mock_bc.override_manager.set_override(mode=-1, duration_minutes=30, reason="test") + tool_fn = server.mcp._tool_manager._tools['get_override_status'].fn + result = tool_fn() + assert result['active'] is True + assert result['override']['mode'] == -1 + assert result['mode_name'] == "Force Charge from Grid" + + def test_set_mode_override(self, server, mock_bc): + """Test set_mode_override tool.""" + tool_fn = server.mcp._tool_manager._tools['set_mode_override'].fn + result = tool_fn(mode=0, duration_minutes=60, reason="test override") + assert result['success'] is True + assert result['mode_name'] == "Avoid Discharge" + assert mock_bc.override_manager.is_active() + mock_bc._apply_override.assert_called_once() + + def test_set_mode_override_invalid_mode(self, server): + """Test set_mode_override with invalid mode.""" + tool_fn = server.mcp._tool_manager._tools['set_mode_override'].fn + result = tool_fn(mode=99, duration_minutes=30) + assert 'error' in result + + def test_set_mode_override_invalid_duration(self, server): + """Test set_mode_override with invalid duration.""" + tool_fn = server.mcp._tool_manager._tools['set_mode_override'].fn + result = tool_fn(mode=0, duration_minutes=0) + assert 'error' in result + + def test_clear_mode_override(self, server, mock_bc): + """Test clear_mode_override tool.""" + mock_bc.override_manager.set_override(mode=-1, duration_minutes=30) + tool_fn = server.mcp._tool_manager._tools['clear_mode_override'].fn + result = tool_fn() + assert result['success'] is True + assert result['was_active'] is True + assert not mock_bc.override_manager.is_active() + + def test_set_charge_rate(self, server, mock_bc): + """Test set_charge_rate tool.""" + tool_fn = server.mcp._tool_manager._tools['set_charge_rate'].fn + result = tool_fn(charge_rate_w=2000, duration_minutes=45, reason="charge test") + assert result['success'] is True + assert mock_bc.override_manager.is_active() + override = mock_bc.override_manager.get_override() + assert override.mode == -1 + assert override.charge_rate == 2000 + mock_bc._apply_override.assert_called_once() + + def test_set_charge_rate_invalid(self, server): + """Test set_charge_rate with invalid rate.""" + tool_fn = server.mcp._tool_manager._tools['set_charge_rate'].fn + result = tool_fn(charge_rate_w=0) + assert 'error' in result + + def test_set_parameter_valid(self, server, mock_bc): + """Test set_parameter with a valid parameter.""" + tool_fn = server.mcp._tool_manager._tools['set_parameter'].fn + result = tool_fn(parameter='min_price_difference', value=0.08) + assert result['success'] is True + mock_bc.api_set_min_price_difference.assert_called_once_with(0.08) + + def test_set_parameter_invalid(self, server): + """Test set_parameter with unknown parameter.""" + tool_fn = server.mcp._tool_manager._tools['set_parameter'].fn + result = tool_fn(parameter='nonexistent', value=1.0) + assert 'error' in result + + def test_set_parameter_all_valid_params(self, server, mock_bc): + """Test that all documented parameters are accepted.""" + tool_fn = server.mcp._tool_manager._tools['set_parameter'].fn + valid_params = [ + ('always_allow_discharge_limit', 0.85), + ('max_charging_from_grid_limit', 0.75), + ('min_price_difference', 0.03), + ('min_price_difference_rel', 0.15), + ('production_offset', 0.9), + ] + for param, value in valid_params: + result = tool_fn(parameter=param, value=value) + assert result['success'] is True, f"Parameter '{param}' should be valid" diff --git a/tests/batcontrol/test_override_manager.py b/tests/batcontrol/test_override_manager.py new file mode 100644 index 00000000..52a008c5 --- /dev/null +++ b/tests/batcontrol/test_override_manager.py @@ -0,0 +1,158 @@ +"""Tests for the OverrideManager""" +import time +import pytest +from unittest.mock import patch + +from batcontrol.override_manager import OverrideManager, OverrideState + + +class TestOverrideState: + """Test OverrideState dataclass behavior""" + + def test_state_creation(self): + """Test basic override state creation""" + state = OverrideState(mode=-1, charge_rate=500, duration_minutes=30, reason="test") + assert state.mode == -1 + assert state.charge_rate == 500 + assert state.duration_minutes == 30 + assert state.reason == "test" + assert state.expires_at > state.created_at + + def test_state_remaining(self): + """Test remaining time calculation""" + state = OverrideState(mode=0, charge_rate=None, duration_minutes=10, reason="test") + assert state.remaining_minutes > 9.9 + assert state.remaining_minutes <= 10.0 + assert not state.is_expired + + def test_state_expired(self): + """Test expired state detection""" + state = OverrideState( + mode=0, charge_rate=None, duration_minutes=1, reason="test", + created_at=time.time() - 120, # 2 minutes ago + expires_at=time.time() - 60 # expired 1 minute ago + ) + assert state.is_expired + assert state.remaining_seconds == 0.0 + assert state.remaining_minutes == 0.0 + + def test_state_to_dict(self): + """Test serialization to dict""" + state = OverrideState(mode=-1, charge_rate=500, duration_minutes=30, reason="test charge") + d = state.to_dict() + assert d['mode'] == -1 + assert d['charge_rate'] == 500 + assert d['duration_minutes'] == 30 + assert d['reason'] == "test charge" + assert d['is_active'] is True + assert 'remaining_minutes' in d + assert 'created_at' in d + assert 'expires_at' in d + + +class TestOverrideManager: + """Test OverrideManager core functionality""" + + def test_no_override_initially(self): + """Manager starts with no active override""" + mgr = OverrideManager() + assert mgr.get_override() is None + assert not mgr.is_active() + assert mgr.remaining_minutes == 0.0 + + def test_set_override(self): + """Test setting an override""" + mgr = OverrideManager() + state = mgr.set_override(mode=-1, duration_minutes=30, charge_rate=500, reason="test") + assert state.mode == -1 + assert state.charge_rate == 500 + assert mgr.is_active() + assert mgr.remaining_minutes > 29.9 + + def test_set_override_default_duration(self): + """Test that default duration is used when not specified""" + mgr = OverrideManager(default_duration_minutes=45) + state = mgr.set_override(mode=0, reason="default duration test") + assert state.duration_minutes == 45 + + def test_clear_override(self): + """Test clearing an override""" + mgr = OverrideManager() + mgr.set_override(mode=-1, duration_minutes=30) + assert mgr.is_active() + + mgr.clear_override() + assert not mgr.is_active() + assert mgr.get_override() is None + + def test_clear_when_no_override(self): + """Clearing when nothing is set should not raise""" + mgr = OverrideManager() + mgr.clear_override() # should not raise + assert not mgr.is_active() + + def test_override_expires(self): + """Test that expired overrides are automatically cleaned up""" + mgr = OverrideManager() + mgr.set_override(mode=0, duration_minutes=1, reason="will expire") + + # Manually set the expiry to the past + mgr._override.expires_at = time.time() - 1 + + assert mgr.get_override() is None + assert not mgr.is_active() + + def test_override_replaces_previous(self): + """Setting a new override replaces the previous one""" + mgr = OverrideManager() + mgr.set_override(mode=-1, duration_minutes=30, reason="first") + mgr.set_override(mode=10, duration_minutes=60, reason="second") + + override = mgr.get_override() + assert override.mode == 10 + assert override.reason == "second" + assert override.duration_minutes == 60 + + def test_invalid_duration_raises(self): + """Zero or negative duration should raise ValueError""" + mgr = OverrideManager() + with pytest.raises(ValueError): + mgr.set_override(mode=0, duration_minutes=0) + with pytest.raises(ValueError): + mgr.set_override(mode=0, duration_minutes=-5) + + def test_override_without_charge_rate(self): + """Test override for non-charging modes""" + mgr = OverrideManager() + state = mgr.set_override(mode=10, duration_minutes=60, reason="allow discharge") + assert state.charge_rate is None + assert state.mode == 10 + + def test_thread_safety(self): + """Basic test that concurrent set/get doesn't crash""" + import threading + mgr = OverrideManager() + errors = [] + + def setter(): + try: + for _ in range(100): + mgr.set_override(mode=-1, duration_minutes=10, reason="thread test") + except Exception as e: + errors.append(e) + + def getter(): + try: + for _ in range(100): + mgr.get_override() + mgr.is_active() + except Exception as e: + errors.append(e) + + threads = [threading.Thread(target=setter), threading.Thread(target=getter)] + for t in threads: + t.start() + for t in threads: + t.join() + + assert len(errors) == 0 From 66341b3359edf8880a9e81829022e51337e9236a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Mar 2026 18:50:03 +0000 Subject: [PATCH 03/13] Add MQTT override_duration/set and clear_override/set endpoints New MQTT endpoints: - override_duration/set: Pre-configure how long the next mode/set or charge_rate/set override will last (1-1440 min, 0 resets to default 30). Works like limit_battery_charge_rate/set: set the parameter first, then it takes effect on the next mode/set call. This avoids changing the existing MQTT message format (backward compatible). - clear_override/set: Cancel any active override immediately, resuming autonomous control at the next evaluation cycle. Published topics: - override_duration: Shows the currently configured duration - HA auto-discovery for override_duration as a number entity (config category) This gives HA users the same override duration control that the MCP server already has via the duration_minutes parameter. https://claude.ai/code/session_0145EbJrBDema8V6xSTGdL8M --- src/batcontrol/core.py | 71 +++++++++++++++++++++++++++++++++++--- src/batcontrol/mqtt_api.py | 30 ++++++++++++++++ 2 files changed, 97 insertions(+), 4 deletions(-) diff --git a/src/batcontrol/core.py b/src/batcontrol/core.py index 42c90b5c..3229eca6 100644 --- a/src/batcontrol/core.py +++ b/src/batcontrol/core.py @@ -59,6 +59,7 @@ class Batcontrol: def __init__(self, configdict: dict): # For API self.override_manager = OverrideManager() + self._mqtt_override_duration = self.override_manager.default_duration_minutes # -1 = charge from grid , 0 = avoid discharge , 8 = limit battery charge, 10 = discharge allowed self.last_mode = None self.last_charge_rate = 0 @@ -272,6 +273,16 @@ def __init__(self, configdict: dict): self.api_set_production_offset, float ) + self.mqtt_api.register_set_callback( + 'override_duration', + self.api_set_override_duration, + float + ) + self.mqtt_api.register_set_callback( + 'clear_override', + self.api_clear_override, + int + ) # Inverter Callbacks self.inverter.activate_mqtt(self.mqtt_api) @@ -823,6 +834,8 @@ def refresh_static_values(self) -> None: self.mqtt_api.publish_override_active(override is not None) self.mqtt_api.publish_override_remaining( override.remaining_minutes if override else 0.0) + self.mqtt_api.publish_override_duration( + self._mqtt_override_duration) # self.mqtt_api.publish_discharge_blocked(self.discharge_blocked) # Trigger Inverter @@ -856,7 +869,9 @@ def api_set_mode(self, mode: int, duration_minutes: float = None): Uses the OverrideManager for time-bounded overrides. Args: mode: Inverter mode (-1, 0, 8, 10) - duration_minutes: Override duration. None uses OverrideManager default. + duration_minutes: Override duration. None uses the MQTT-configured + override_duration (set via override_duration/set), which defaults + to the OverrideManager default (30 min). """ # Check if mode is valid if mode not in [ @@ -867,7 +882,11 @@ def api_set_mode(self, mode: int, duration_minutes: float = None): logger.warning('API: Invalid mode %s', mode) return - logger.info('API: Setting mode to %s', mode) + # Use MQTT-configured duration if no explicit duration given + if duration_minutes is None: + duration_minutes = self._mqtt_override_duration + + logger.info('API: Setting mode to %s for %.1f min', mode, duration_minutes) override = self.override_manager.set_override( mode=mode, duration_minutes=duration_minutes, @@ -881,13 +900,19 @@ def api_set_charge_rate(self, charge_rate: int, duration_minutes: float = None): Uses the OverrideManager for time-bounded overrides. Args: charge_rate: Charge rate in W - duration_minutes: Override duration. None uses OverrideManager default. + duration_minutes: Override duration. None uses the MQTT-configured + override_duration. """ if charge_rate < 0: logger.warning( 'API: Invalid charge rate %d W', charge_rate) return - logger.info('API: Setting charge rate to %d W', charge_rate) + + # Use MQTT-configured duration if no explicit duration given + if duration_minutes is None: + duration_minutes = self._mqtt_override_duration + + logger.info('API: Setting charge rate to %d W for %.1f min', charge_rate, duration_minutes) override = self.override_manager.set_override( mode=MODE_FORCE_CHARGING, charge_rate=charge_rate, @@ -896,6 +921,44 @@ def api_set_charge_rate(self, charge_rate: int, duration_minutes: float = None): ) self._apply_override(override) + def api_set_override_duration(self, duration_minutes: float): + """ Set the duration (in minutes) for subsequent mode/charge_rate overrides via MQTT. + + This acts like a "pre-set" — the next mode/set or charge_rate/set will use + this duration. Analogous to how limit_battery_charge_rate/set works for mode 8. + + Args: + duration_minutes: Duration in minutes (1-1440). 0 resets to default. + """ + if duration_minutes == 0: + duration_minutes = self.override_manager.default_duration_minutes + logger.info('API: Reset override duration to default (%.1f min)', + duration_minutes) + elif duration_minutes < 1 or duration_minutes > 1440: + logger.warning( + 'API: Invalid override duration %.1f min (must be 1-1440, or 0 to reset)', + duration_minutes) + return + else: + logger.info('API: Setting override duration to %.1f min', + duration_minutes) + + self._mqtt_override_duration = duration_minutes + if self.mqtt_api is not None: + self.mqtt_api.publish_override_duration(duration_minutes) + + def api_clear_override(self, _value: int = 0): + """ Clear any active override and resume autonomous control. + + The value parameter is ignored (any value triggers the clear). + This follows the MQTT /set pattern where a message triggers the action. + """ + logger.info('API: Clearing override') + self.override_manager.clear_override() + if self.mqtt_api is not None: + self.mqtt_api.publish_override_active(False) + self.mqtt_api.publish_override_remaining(0.0) + def api_set_limit_battery_charge_rate(self, limit: int): """ Set dynamic battery charge rate limit from external call diff --git a/src/batcontrol/mqtt_api.py b/src/batcontrol/mqtt_api.py index cb2bd055..d4068722 100644 --- a/src/batcontrol/mqtt_api.py +++ b/src/batcontrol/mqtt_api.py @@ -22,6 +22,9 @@ - /min_price_difference : minimum price difference in EUR - /discharge_blocked : bool # Discharge is blocked by other sources - /production_offset: production offset percentage (1.0 = 100%, 0.8 = 80%, etc.) +- /override_active : bool # Override is currently active +- /override_remaining_minutes: float # Minutes remaining on active override +- /override_duration : float # Configured override duration for next mode/set call (minutes) The following statistical arrays are published as JSON arrays: - /FCST/production: forecasted production in W @@ -37,6 +40,8 @@ - /max_charging_from_grid_limit/set: set charge limit in 0-1 - /min_price_difference/set: set minimum price difference in EUR - /production_offset/set: set production offset percentage (0.0-2.0) +- /override_duration/set: set override duration in minutes (1-1440, 0=reset to default 30 min) +- /clear_override/set: clear active override (any value triggers the clear) The module uses the paho-mqtt library for MQTT communication and numpy for handling arrays. """ @@ -452,6 +457,15 @@ def publish_override_remaining(self, remaining_minutes: float) -> None: self.base_topic + '/override_remaining_minutes', f'{remaining_minutes:.1f}') + def publish_override_duration(self, duration_minutes: float) -> None: + """ Publish the configured override duration in minutes to MQTT + /override_duration + """ + if self.client.is_connected(): + self.client.publish( + self.base_topic + '/override_duration', + f'{duration_minutes:.0f}') + def publish_production_offset(self, production_offset: float) -> None: """ Publish the production offset percentage to MQTT /production_offset @@ -625,6 +639,22 @@ def send_mqtt_discovery_messages(self) -> None: self.base_topic + "/override_remaining_minutes") + self.publish_mqtt_discovery_message( + "Override Duration", + "batcontrol_override_duration", + "number", + "duration", + "min", + self.base_topic + + "/override_duration", + self.base_topic + + "/override_duration/set", + entity_category="config", + min_value=0, + max_value=1440, + step_value=5, + initial_value=30) + # diagnostic self.publish_mqtt_discovery_message( "Status", From 67b3f9bc5ac8aeb3942445edcc0895919fb90952 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Mar 2026 18:59:06 +0000 Subject: [PATCH 04/13] Replace todo.md with structured docs for MCP, overrides, explainability Three focused docs under docs/: - mcp-server.md: Architecture, tool reference, config, mode constants - override-manager.md: API, thread safety, MQTT topics, HA discovery, usage flows - decision-explainability.md: Data flow, explanation points, lifecycle, example output These are designed to be AI-consumable (structured tables, code blocks, concrete examples) for onboarding future contributors or AI agents. https://claude.ai/code/session_0145EbJrBDema8V6xSTGdL8M --- docs/decision-explainability.md | 69 +++++++ docs/mcp-server.md | 114 +++++++++++ docs/override-manager.md | 125 ++++++++++++ todo.md | 336 -------------------------------- 4 files changed, 308 insertions(+), 336 deletions(-) create mode 100644 docs/decision-explainability.md create mode 100644 docs/mcp-server.md create mode 100644 docs/override-manager.md delete mode 100644 todo.md diff --git a/docs/decision-explainability.md b/docs/decision-explainability.md new file mode 100644 index 00000000..9f48aa35 --- /dev/null +++ b/docs/decision-explainability.md @@ -0,0 +1,69 @@ +# Decision Explainability + +## Overview + +The logic engine produces human-readable explanations alongside its control output. +These are surfaced via the MCP `get_decision_explanation` tool and stored in +`CalculationOutput.explanation`. + +## Data Flow + +``` +DefaultLogic.calculate() + → self._explain("Current price: 0.1500, Battery: 5000 Wh stored, ...") + → __is_discharge_allowed() + → self._explain("Battery above always-allow-discharge limit ...") + OR + → self._explain("Usable energy (4500 Wh) <= reserved energy (5000 Wh) ...") + → self._explain("Decision: Allow discharge") + → stored in CalculationOutput.explanation: List[str] + +core.py stores last_logic_instance + → api_get_decision_explanation() reads explanation from it + +MCP get_decision_explanation tool + → returns {mode, mode_name, explanation_steps[], override_active} +``` + +## Explanation Points + +The following decision points produce explanation messages: + +| Location | Explanation | +|----------|-------------| +| `calculate_inverter_mode` entry | Current price, battery state summary | +| `is_discharge_always_allowed` | Above/below always-allow-discharge limit | +| Discharge surplus check | Usable energy vs reserved energy comparison | +| Reserved slots | Number of higher-price slots reserved for | +| Grid charging possible | SOC vs charging limit check | +| Required recharge energy | How much grid energy needed | +| Final decision | "Allow discharge" / "Force charge at X W" / "Avoid discharge" | + +## Lifecycle + +- Explanation list is **reset every evaluation cycle** (new `CalculationOutput()`) +- Between cycles, `api_get_decision_explanation()` returns the **last completed** evaluation +- No accumulation across cycles, no staleness + +## Files Modified + +| File | Change | +|------|--------| +| `logic/logic_interface.py` | Added `explanation: List[str]` to `CalculationOutput` | +| `logic/default.py` | Added `_explain()` method, annotation calls throughout logic | +| `core.py` | Added `api_get_decision_explanation()` getter | + +## Example Output + +```json +{ + "mode": 10, + "mode_name": "Allow Discharge", + "explanation_steps": [ + "Current price: 0.1500, Battery: 5000 Wh stored, 4500 Wh usable, 5000 Wh free capacity", + "Usable energy (4500 Wh) > reserved energy (2000 Wh) — surplus available for discharge", + "Decision: Allow discharge" + ], + "override_active": false +} +``` diff --git a/docs/mcp-server.md b/docs/mcp-server.md new file mode 100644 index 00000000..e537a8bd --- /dev/null +++ b/docs/mcp-server.md @@ -0,0 +1,114 @@ +# MCP Server — Architecture & Integration Guide + +## Overview + +Batcontrol exposes an MCP (Model Context Protocol) server that enables AI clients +(Claude Desktop, Claude Code, HA voice assistants) to query system state, inspect +forecasts, understand decisions, and manage battery overrides via natural language. + +The MCP server runs **in-process** as a daemon thread alongside the main evaluation +loop, sharing direct access to the `Batcontrol` instance — same pattern as `MqttApi`. + +## Architecture + +``` +┌─────────────────────────────────────────────┐ +│ Batcontrol Process │ +│ │ +│ ┌──────────┐ ┌───────────┐ ┌──────────┐ │ +│ │ Main │ │ Scheduler │ │ MQTT API │ │ +│ │ Loop │ │ Thread │ │ Thread │ │ +│ └────┬─────┘ └───────────┘ └──────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────────────────────────┐ │ +│ │ Batcontrol Core Instance │ │ +│ │ (forecasts, state, inverter ctrl) │ │ +│ └──────────┬───────────────────────────┘ │ +│ │ │ +│ ┌─────┴─────┐ │ +│ ▼ ▼ │ +│ ┌──────────┐ ┌──────────────┐ │ +│ │ Override │ │ MCP Server │ │ +│ │ Manager │ │ (HTTP/stdio) │ │ +│ └──────────┘ └──────────────┘ │ +│ ▲ │ +│ │ HTTP :8081 │ +└────────────────────┼────────────────────────┘ + │ + MCP Clients (Claude Desktop, + HA voice, custom dashboards) +``` + +## Files + +| File | Role | +|------|------| +| `src/batcontrol/mcp_server.py` | MCP server class, tool definitions, transport setup | +| `src/batcontrol/override_manager.py` | Duration-based override state machine | +| `src/batcontrol/core.py` | Integration: init, shutdown, `_apply_override()`, `api_get_decision_explanation()` | +| `src/batcontrol/__main__.py` | `--mcp-stdio` CLI flag | +| `src/batcontrol/logic/logic_interface.py` | `CalculationOutput.explanation` field | +| `src/batcontrol/logic/default.py` | `_explain()` annotations throughout decision logic | + +## Configuration + +```yaml +# In batcontrol_config.yaml +mcp: + enabled: false + transport: http # 'http' for network, 'stdio' for pipe + host: 0.0.0.0 # Bind address + port: 8081 # HTTP port +``` + +CLI alternative for stdio transport: +```bash +python -m batcontrol --mcp-stdio --config config/batcontrol_config.yaml +``` + +## MCP Tools Reference + +### Read Tools (9) + +| Tool | Returns | Key Fields | +|------|---------|------------| +| `get_system_status` | Current mode, SOC, charge rate, override | `mode`, `soc_percent`, `override` | +| `get_price_forecast` | Electricity prices per interval | `prices[]`, `current_price` | +| `get_solar_forecast` | PV production in W per interval | `production_w[]` | +| `get_consumption_forecast` | Household consumption in W | `consumption_w[]` | +| `get_net_consumption_forecast` | Consumption minus production | `net_consumption_w[]` | +| `get_battery_info` | SOC, capacity, stored/reserved energy | `soc_percent`, `max_capacity_wh` | +| `get_decision_explanation` | Step-by-step reasoning from last eval | `explanation_steps[]` | +| `get_configuration` | Runtime parameters | `min_price_difference`, limits | +| `get_override_status` | Active override details | `active`, `override{}` | + +### Write Tools (4) + +| Tool | Parameters | Effect | +|------|-----------|--------| +| `set_mode_override` | `mode` (-1,0,8,10), `duration_minutes`, `reason` | Time-bounded mode override | +| `clear_mode_override` | — | Cancel override, resume autonomous | +| `set_charge_rate` | `charge_rate_w`, `duration_minutes`, `reason` | Force charge at rate | +| `set_parameter` | `parameter` name, `value` | Adjust runtime config | + +#### `set_parameter` valid parameters: +- `always_allow_discharge_limit` (0.0–1.0) +- `max_charging_from_grid_limit` (0.0–1.0) +- `min_price_difference` (≥ 0.0, EUR) +- `min_price_difference_rel` (≥ 0.0) +- `production_offset` (0.0–2.0) + +## Mode Constants + +| Value | Name | Meaning | +|-------|------|---------| +| `-1` | Force Charge from Grid | Charge battery from grid at configured rate | +| `0` | Avoid Discharge | Hold charge, allow PV charging | +| `8` | Limit PV Charge Rate | Allow discharge, cap PV charge rate | +| `10` | Allow Discharge | Normal operation, discharge when optimal | + +## Dependencies + +- `mcp>=1.0` — Official MCP Python SDK (includes FastMCP, uvicorn, starlette) +- Docker: port 8081 exposed in Dockerfile diff --git a/docs/override-manager.md b/docs/override-manager.md new file mode 100644 index 00000000..8c811f65 --- /dev/null +++ b/docs/override-manager.md @@ -0,0 +1,125 @@ +# Override Manager — Design & API + +## Problem + +The original `api_overwrite` was a boolean flag that reset after one evaluation cycle +(~3 minutes). Manual overrides via MQTT `mode/set` were essentially meaningless because +the autonomous logic immediately regained control. + +## Solution + +`OverrideManager` provides **time-bounded overrides** that persist across multiple +evaluation cycles and auto-expire. + +## File + +`src/batcontrol/override_manager.py` + +## API + +```python +class OverrideManager: + def set_override(mode, duration_minutes=None, charge_rate=None, reason="") -> OverrideState + def clear_override() -> None + def get_override() -> Optional[OverrideState] # None if expired or inactive + def is_active() -> bool + remaining_minutes: float # property, 0 if no override +``` + +```python +@dataclass +class OverrideState: + mode: int # -1, 0, 8, or 10 + charge_rate: Optional[int] # W, only relevant for mode -1 + duration_minutes: float + reason: str + created_at: float # time.time() + expires_at: float # auto-calculated + # Properties: + remaining_seconds: float + remaining_minutes: float + is_expired: bool + def to_dict() -> dict # JSON-serializable snapshot +``` + +## Thread Safety + +All public methods acquire `threading.Lock` before modifying `_override`. +Read tools in the MCP server and the main evaluation loop can safely call +`get_override()` concurrently. + +## Integration in core.py + +### Evaluation loop (`run()`) + +```python +override = self.override_manager.get_override() +if override is not None: + # Re-apply the mode to keep inverter in sync + self._apply_override(override) + return # Skip autonomous logic +# ... normal logic continues +``` + +### MQTT API callbacks + +`api_set_mode()` and `api_set_charge_rate()` create overrides using +`_mqtt_override_duration` (configurable via `override_duration/set`). + +### MCP tools + +`set_mode_override` and `set_charge_rate` pass explicit `duration_minutes`. + +## MQTT Topics + +### Published (output) + +| Topic | Type | Description | +|-------|------|-------------| +| `override_active` | bool | Whether an override is currently active | +| `override_remaining_minutes` | float | Minutes remaining on active override | +| `override_duration` | float | Configured duration for next mode/set call | + +### Subscribable (input) + +| Topic | Type | Description | +|-------|------|-------------| +| `override_duration/set` | float | Set duration in minutes (1–1440, 0=reset to 30) | +| `clear_override/set` | int | Any value clears active override | + +## HA Auto Discovery + +Three entities registered: +- **Override Active** — sensor, shows "active"/"inactive" +- **Override Remaining Minutes** — sensor, unit: min +- **Override Duration** — number (config), 0–1440, step 5, controls next override length + +## Behavioral Contract + +1. **Override persists** across evaluation cycles until expiry or clear +2. **Auto-expiry**: `get_override()` returns `None` once `time.time() >= expires_at` +3. **Latest wins**: setting a new override replaces the previous one +4. **Clear is safe**: clearing when nothing is active is a no-op +5. **Duration validation**: `set_override()` raises `ValueError` if `duration_minutes <= 0` +6. **Default duration**: 30 minutes (configurable per-manager and per-MQTT via `override_duration/set`) + +## Usage Flow: MQTT + +``` +1. Publish 120 to house/batcontrol/override_duration/set +2. Publish -1 to house/batcontrol/mode/set + → Creates a 120-minute force-charge override +3. Override auto-expires after 120 min, OR: + Publish 1 to house/batcontrol/clear_override/set + → Clears immediately, autonomous logic resumes next cycle +``` + +## Usage Flow: MCP + +```json +// Tool call: set_mode_override +{"mode": 0, "duration_minutes": 60, "reason": "Guest staying overnight, preserve charge"} + +// Tool call: clear_mode_override +{} +``` diff --git a/todo.md b/todo.md deleted file mode 100644 index fa9cbde1..00000000 --- a/todo.md +++ /dev/null @@ -1,336 +0,0 @@ -# MCP Server for Batcontrol — Implementation Plan - -## Motivation - -Batcontrol is a powerful home battery optimization system, but it lacks an interactive -query interface. Users can only observe behavior via MQTT topics or log files. There is -no way to ask "why did you charge at 3am?" or "what's the forecast for tonight?". - -An MCP (Model Context Protocol) server turns batcontrol into a system you can have a -conversation with — through any MCP-compatible AI client (Claude Desktop, Claude Code, -Home Assistant voice assistants, etc.). - -Additionally, the current override mechanism (`api_overwrite`) is **single-shot**: it -only survives one evaluation cycle (~3 minutes) before the autonomous logic takes back -control. This makes manual overrides essentially meaningless. The MCP server work -includes fixing this with a proper duration-based override system that benefits both -MCP and the existing MQTT API. - ---- - -## Architecture Decision: Transport & Deployment - -### Transport: Streamable HTTP (primary), stdio (secondary) - -**Why Streamable HTTP over stdio:** -- The HA addon runs batcontrol in a Docker container — stdio MCP requires the client - to spawn the server process, which doesn't work across container boundaries -- Streamable HTTP allows the MCP server to run inside the existing batcontrol process - and accept connections over the network (localhost or LAN) -- Home Assistant addons can expose ports — a single HTTP port is simple to configure -- Multiple clients can connect simultaneously (HA dashboard + Claude Desktop) -- Streamable HTTP is the current MCP standard, replacing the deprecated SSE transport - -**stdio as secondary option:** -- Useful for local development and testing -- Can be offered as a CLI flag (`--mcp-stdio`) for direct integration with tools - like Claude Desktop when running batcontrol outside Docker - -### Deployment Model - -``` -┌─────────────────────────────────────────────┐ -│ Batcontrol Process (existing) │ -│ │ -│ ┌──────────┐ ┌───────────┐ ┌──────────┐ │ -│ │ Main │ │ Scheduler │ │ MQTT API │ │ -│ │ Loop │ │ Thread │ │ Thread │ │ -│ └────┬─────┘ └───────────┘ └──────────┘ │ -│ │ │ -│ ▼ │ -│ ┌──────────────────────────────────────┐ │ -│ │ Batcontrol Core Instance │ │ -│ │ (forecasts, state, inverter ctrl) │ │ -│ └──────────┬───────────────────────────┘ │ -│ │ │ -│ ┌─────┴─────┐ │ -│ ▼ ▼ │ -│ ┌──────────┐ ┌──────────────┐ │ -│ │ Override │ │ MCP Server │ │ -│ │ Manager │ │ (HTTP/stdio) │ │ -│ └──────────┘ └──────────────┘ │ -│ ▲ │ -│ │ HTTP :8081 │ -└────────────────────┼────────────────────────┘ - │ - MCP Clients (Claude Desktop, - HA voice, custom dashboards) -``` - -The MCP server runs **in-process** as an additional thread, sharing direct access to -the `Batcontrol` instance — same pattern as `MqttApi`. - ---- - -## Phase 1: Duration-Based Override Manager - -**Problem:** `api_overwrite` is a boolean flag reset after one evaluation cycle. - -**Solution:** A standalone `OverrideManager` class that both MCP and MQTT can use. - -### Design - -```python -class OverrideManager: - """Manages time-bounded overrides for batcontrol operation.""" - - def set_override(self, mode: int, duration_minutes: int, - charge_rate: int = None, reason: str = "") -> OverrideState - def clear_override() -> None - def get_override() -> Optional[OverrideState] # None if expired/inactive - def is_active() -> bool - def remaining_minutes() -> float -``` - -### Behavior -- Override has a **mode**, **duration**, optional **charge_rate**, and a **reason** -- `core.py:run()` checks `override_manager.is_active()` instead of `api_overwrite` -- If active: apply the override's mode/rate, skip autonomous logic -- If expired: resume autonomous logic automatically -- Override can be cleared early via `clear_override()` -- MQTT `mode/set` uses this manager with a configurable default duration -- MCP tools specify duration explicitly - -### Integration Points -- `core.py`: Replace `api_overwrite` flag with `OverrideManager` queries -- `mqtt_api.py`: Route `mode/set` through `OverrideManager` (backward compatible) -- New file: `src/batcontrol/override_manager.py` - -### Files to Create/Modify -- [ ] `src/batcontrol/override_manager.py` — New: OverrideManager class -- [ ] `src/batcontrol/core.py` — Modify: integrate OverrideManager -- [ ] `src/batcontrol/mqtt_api.py` — Modify: use OverrideManager for mode/set -- [ ] `tests/batcontrol/test_override_manager.py` — New: unit tests - ---- - -## Phase 2: MCP Server Implementation - -### MCP Tools (Read Operations) - -| Tool | Description | Data Source | -|------|-------------|-------------| -| `get_system_status` | Current mode, SOC, charge rate, override status, last evaluation time | `core.py` state | -| `get_price_forecast` | Hourly/15-min electricity prices for next 24-48h | `dynamictariff` provider | -| `get_solar_forecast` | Expected PV production (W per interval) | `forecastsolar` provider | -| `get_consumption_forecast` | Expected household consumption (W per interval) | `forecastconsumption` provider | -| `get_net_consumption_forecast` | Consumption minus production (grid dependency) | Computed from above | -| `get_battery_info` | SOC, capacity, stored energy, reserved energy, free capacity | Inverter + logic | -| `get_decision_explanation` | Why the system chose the current mode, with price/forecast context | Logic output + state | -| `get_configuration` | Current runtime config (thresholds, limits, offsets) | `core.py` config state | -| `get_override_status` | Active override details: mode, remaining time, reason | `OverrideManager` | - -### MCP Tools (Write Operations) - -| Tool | Description | Safety | -|------|-------------|--------| -| `set_mode_override` | Override mode for N minutes (with reason) | Duration-bounded, auto-expires | -| `clear_mode_override` | Cancel active override, resume autonomous logic | Safe — restores normal operation | -| `set_charge_rate` | Set charge rate (implies force-charge mode) for N minutes | Duration-bounded | -| `set_parameter` | Adjust runtime parameter (discharge limit, price threshold, etc.) | Validated, temporary | - -### MCP Resources (optional, later) - -Resources provide ambient context without explicit tool calls: -- `batcontrol://status` — Live system status summary -- `batcontrol://forecasts` — Current forecast data - -### Implementation - -**New files:** -- `src/batcontrol/mcp_server.py` — MCP server class using the `mcp` Python SDK -- Registers tools, handles requests, bridges to `Batcontrol` instance - -**MCP SDK:** Use the official `mcp` Python package (PyPI: `mcp`), which provides: -- `FastMCP` high-level server class -- Streamable HTTP and stdio transports built-in -- Tool/resource/prompt decorators - -**Thread model:** -- MCP HTTP server runs in its own thread (like MQTT) -- Tool handlers access `Batcontrol` instance (read-mostly, writes via `OverrideManager`) -- Thread safety: Read operations on forecast arrays use existing locks; write - operations go through `OverrideManager` which has its own lock - -### Files to Create/Modify -- [ ] `src/batcontrol/mcp_server.py` — New: MCP server implementation -- [ ] `src/batcontrol/core.py` — Modify: initialize MCP server, expose getters -- [ ] `src/batcontrol/__main__.py` — Modify: add `--mcp-stdio` flag, MCP config -- [ ] `pyproject.toml` — Modify: add `mcp` dependency -- [ ] `config/batcontrol_config_dummy.yaml` — Modify: add MCP config section -- [ ] `tests/batcontrol/test_mcp_server.py` — New: MCP server tests - ---- - -## Phase 3: Decision Explainability - -The `get_decision_explanation` tool is the killer feature. It requires the logic -engine to produce human-readable rationale alongside its control output. - -### Design -- Extend `CalculationOutput` with a `explanation: list[str]` field -- Logic engine appends reasoning steps as it evaluates: - - "Current price (0.15 EUR/kWh) is below average (0.22 EUR/kWh)" - - "Solar forecast shows 3.2 kWh production in next 4 hours" - - "Battery at 45% SOC with 2.1 kWh reserved for evening peak" - - "Decision: Allow discharge — prices are above threshold and battery has surplus" -- MCP tool formats this into a structured response - -### Files to Modify -- [ ] `src/batcontrol/logic/default.py` — Add explanation accumulation -- [ ] `src/batcontrol/logic/logic_interface.py` — Add explanation to output type -- [ ] `src/batcontrol/core.py` — Store and expose last explanation -- [ ] `tests/batcontrol/logic/test_default.py` — Test explanation output - ---- - -## Phase 4: Home Assistant Addon Integration - -The HA addon is maintained in a separate repository (`batcontrol_ha_addon`). The MCP -server integration requires changes in **both** repositories. - -### Changes in This Repository (batcontrol) - -1. **Configuration section** in `batcontrol_config_dummy.yaml`: - ```yaml - mcp: - enabled: false - transport: http # 'http' or 'stdio' - host: 0.0.0.0 # bind address - port: 8081 # HTTP port - # auth_token: "" # optional bearer token for security - ``` - -2. **Entrypoint** (`entrypoint_ha.sh`): No changes needed — config flows through YAML - -### Changes Needed in batcontrol_ha_addon Repository - -1. **Port exposure** in addon `config.yaml` / `manifest.json`: - ```yaml - ports: - "8081/tcp": 8081 - ports_description: - "8081/tcp": "MCP Server (Model Context Protocol)" - ``` - -2. **Options schema** — Add MCP toggle to addon options: - ```json - { - "mcp_enabled": true, - "mcp_port": 8081 - } - ``` - -3. **Ingress support** (optional, future): HA supports addon ingress for web-based - interfaces. The MCP HTTP endpoint could be exposed through HA's ingress proxy, - providing automatic authentication. - -### How MCP Fits the HA Ecosystem - -``` -┌─────────────────────────────────────────────┐ -│ Home Assistant │ -│ │ -│ ┌──────────┐ ┌────────────────────────┐ │ -│ │ HA Core │ │ Batcontrol Addon │ │ -│ │ │ │ │ │ -│ │ MQTT ◄──┼──┤ MQTT API (existing) │ │ -│ │ Broker │ │ │ │ -│ │ │ │ MCP Server :8081 (new) │ │ -│ └──────────┘ └───────────┬────────────┘ │ -│ │ │ -│ ┌─────────────────────────┼──────────────┐ │ -│ │ HA Voice / Assist │ │ │ -│ │ (future MCP client) ▼ │ │ -│ │ MCP over HTTP │ │ -│ └────────────────────────────────────────┘ │ -└─────────────────────────────────────────────┘ - │ - │ MCP over HTTP (LAN) - ▼ - Claude Desktop / other MCP clients -``` - -**Key integration points:** -- MQTT remains the primary HA integration (dashboards, automations, sensors) -- MCP adds a **conversational** layer — ideal for voice assistants and AI agents -- Both share the same `Batcontrol` instance and `OverrideManager` -- HA's future native MCP support could make this seamless (HA is exploring MCP) - ---- - -## Phase 5: Testing & Documentation - -- [ ] Unit tests for `OverrideManager` (expiry, clear, concurrent access) -- [ ] Unit tests for MCP tools (mock `Batcontrol` instance) -- [ ] Integration test: MCP client → server → override → evaluation cycle -- [ ] Update `README.MD` with MCP section -- [ ] Update `config/batcontrol_config_dummy.yaml` with MCP config example -- [ ] Document MCP tools and their parameters - ---- - -## Task Checklist - -### Phase 1: Override Manager -- [ ] Design and implement `OverrideManager` class -- [ ] Write unit tests for `OverrideManager` -- [ ] Integrate `OverrideManager` into `core.py` (replace `api_overwrite`) -- [ ] Update MQTT `mode/set` to use `OverrideManager` -- [ ] Test backward compatibility with existing MQTT overrides - -### Phase 2: MCP Server Core -- [ ] Add `mcp` dependency to `pyproject.toml` -- [ ] Implement `mcp_server.py` with read-only tools -- [ ] Add write tools (`set_mode_override`, `clear_mode_override`, `set_charge_rate`) -- [ ] Add `set_parameter` tool for runtime config changes -- [ ] Initialize MCP server from `core.py` (same pattern as MQTT) -- [ ] Add `--mcp-stdio` CLI flag to `__main__.py` -- [ ] Add MCP config section to config YAML -- [ ] Write MCP server unit tests - -### Phase 3: Decision Explainability -- [ ] Extend logic output with explanation field -- [ ] Add explanation accumulation in `DefaultLogic.calculate()` -- [ ] Expose last explanation via `core.py` getter -- [ ] Implement `get_decision_explanation` MCP tool -- [ ] Test explanation output - -### Phase 4: HA Addon Integration -- [ ] Document required changes for `batcontrol_ha_addon` repository -- [ ] Add MCP port to Dockerfile EXPOSE -- [ ] Test MCP server in Docker container -- [ ] Test MCP connectivity from external client - -### Phase 5: Testing & Documentation -- [ ] End-to-end test: client → MCP → override → evaluation -- [ ] Update README with MCP documentation -- [ ] Update dummy config with MCP section -- [ ] Verify no regressions in existing tests - ---- - -## Dependencies - -- `mcp>=1.0` — Official MCP Python SDK (includes FastMCP, transports) -- No other new dependencies required (HTTP server is built into `mcp` SDK via `uvicorn`/`starlette`) - -## Risk Assessment - -| Risk | Mitigation | -|------|-----------| -| Thread safety between MCP and main loop | OverrideManager uses threading.Lock; read tools access immutable snapshot data | -| MCP server crash affects batcontrol | MCP runs in isolated thread; exceptions are caught and logged | -| Security (unauthenticated access) | Optional auth_token config; bind to localhost by default | -| Resource usage on Raspberry Pi | MCP server is lightweight; HTTP idle connections use minimal memory | -| Breaking existing MQTT behavior | OverrideManager is backward compatible; MQTT mode/set gets default duration | From 79f562d5da1707da540892826f010e68e62584dd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Mar 2026 19:04:32 +0000 Subject: [PATCH 05/13] Make MCP an optional dependency for Python <3.10 compatibility The MCP SDK requires Python >=3.10, but batcontrol supports >=3.9 (needed for HA addon environments that may run Python 3.9). Changes: - Move mcp from hard dependency to optional: pip install batcontrol[mcp] - mcp_server.py: try/except import with MCP_AVAILABLE flag and is_available() helper; BatcontrolMcpServer.__init__ raises ImportError if mcp package is missing - core.py: check mcp_module.is_available() before instantiation, log warning if enabled but unavailable - __main__.py: --mcp-stdio exits with error 1 if mcp unavailable - test_mcp_server.py: pytest.skip(allow_module_level=True) when mcp is not installed - Dockerfile: install with [mcp] extra (Python 3.13 supports it) - docs/mcp-server.md: document optional dependency and install command On Python <3.10: batcontrol works normally, MCP features are simply unavailable, a warning is logged if mcp.enabled=true in config. https://claude.ai/code/session_0145EbJrBDema8V6xSTGdL8M --- Dockerfile | 3 ++- docs/mcp-server.md | 6 ++++++ pyproject.toml | 6 ++++-- src/batcontrol/__main__.py | 11 +++++++++-- src/batcontrol/core.py | 27 +++++++++++++++++---------- src/batcontrol/mcp_server.py | 22 +++++++++++++++++++++- tests/batcontrol/test_mcp_server.py | 14 +++++++++++++- 7 files changed, 72 insertions(+), 17 deletions(-) diff --git a/Dockerfile b/Dockerfile index 8ca6cd74..974a0fc7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,7 +28,8 @@ LABEL maintainer="matthias.strubel@aod-rpg.de" COPY --from=builder /wheels /wheels # Update pip and install runtime dependencies -RUN pip install --no-cache-dir --extra-index-url https://piwheels.org/simple --prefer-binary /wheels/*.whl && rm -rf /wheels +# Install the wheel with the optional MCP dependency (Python 3.13 supports it) +RUN pip install --no-cache-dir --extra-index-url https://piwheels.org/simple --prefer-binary "/wheels/*.whl[mcp]" && rm -rf /wheels ENV BATCONTROL_VERSION=${VERSION} ENV BATCONTROL_GIT_SHA=${GIT_SHA} diff --git a/docs/mcp-server.md b/docs/mcp-server.md index e537a8bd..f2c8e403 100644 --- a/docs/mcp-server.md +++ b/docs/mcp-server.md @@ -111,4 +111,10 @@ python -m batcontrol --mcp-stdio --config config/batcontrol_config.yaml ## Dependencies - `mcp>=1.0` — Official MCP Python SDK (includes FastMCP, uvicorn, starlette) +- **Requires Python >=3.10** (MCP SDK constraint) +- `mcp` is an **optional dependency** — batcontrol itself runs on Python >=3.9 +- Install with: `pip install batcontrol[mcp]` +- Docker image (Python 3.13) installs MCP automatically +- On Python <3.10: MCP features are unavailable, a warning is logged if + `mcp.enabled: true` is set in config, and everything else works normally - Docker: port 8081 exposed in Dockerfile diff --git a/pyproject.toml b/pyproject.toml index 21626684..0b31379a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,8 +17,7 @@ dependencies = [ "cachetools>=5.0", "websockets>=11.0", "schedule>=1.2.0", - "pytz>=2024.2", - "mcp>=1.0" + "pytz>=2024.2" ] # Config for the build system @@ -46,6 +45,9 @@ pythonpath = ["src"] # For testing [project.optional-dependencies] +mcp = [ + "mcp>=1.0", +] test = [ "pytest>=7.0.0", "pytest-cov>=4.0.0", diff --git a/src/batcontrol/__main__.py b/src/batcontrol/__main__.py index 24253225..6be7d73e 100644 --- a/src/batcontrol/__main__.py +++ b/src/batcontrol/__main__.py @@ -1,7 +1,7 @@ from .core import Batcontrol from .setup import setup_logging, load_config from .inverter import InverterOutageError -from .mcp_server import BatcontrolMcpServer +from . import mcp_server as mcp_module import argparse import time import datetime @@ -88,8 +88,15 @@ def main() -> int: # Handle --mcp-stdio: run MCP server in stdio mode (blocking) if args.mcp_stdio: + if not mcp_module.is_available(): + logger.error( + 'MCP server requires the "mcp" package (Python >=3.10). ' + 'Install with: pip install batcontrol[mcp]') + bc.shutdown() + del bc + return 1 logger.info("Running MCP server in stdio mode") - mcp = BatcontrolMcpServer(bc, config.get('mcp', {})) + mcp = mcp_module.BatcontrolMcpServer(bc, config.get('mcp', {})) try: mcp.run_stdio() except KeyboardInterrupt: diff --git a/src/batcontrol/core.py b/src/batcontrol/core.py index 3229eca6..5771c4b2 100644 --- a/src/batcontrol/core.py +++ b/src/batcontrol/core.py @@ -33,7 +33,7 @@ from .forecastconsumption import Consumption as consumption_factory from .override_manager import OverrideManager -from .mcp_server import BatcontrolMcpServer +from . import mcp_server as mcp_module ERROR_IGNORE_TIME = 600 # 10 Minutes EVALUATIONS_EVERY_MINUTES = 3 # Every x minutes on the clock @@ -305,18 +305,25 @@ def __init__(self, configdict: dict): self.evcc_api.wait_ready() logger.info('evcc Connection ready') - # Initialize MCP server + # Initialize MCP server (optional, requires Python >=3.10 + mcp package) self.mcp_server = None mcp_config = config.get('mcp', {}) if mcp_config.get('enabled', False): - logger.info('MCP Server enabled') - self.mcp_server = BatcontrolMcpServer(self, mcp_config) - transport = mcp_config.get('transport', 'http') - if transport == 'http': - host = mcp_config.get('host', '0.0.0.0') - port = mcp_config.get('port', 8081) - self.mcp_server.start_http(host=host, port=port) - # stdio transport is handled in __main__.py + if not mcp_module.is_available(): + logger.warning( + 'MCP server is enabled in config but the "mcp" package ' + 'is not installed (requires Python >=3.10). ' + 'Install with: pip install batcontrol[mcp]') + else: + logger.info('MCP Server enabled') + self.mcp_server = mcp_module.BatcontrolMcpServer( + self, mcp_config) + transport = mcp_config.get('transport', 'http') + if transport == 'http': + host = mcp_config.get('host', '0.0.0.0') + port = mcp_config.get('port', 8081) + self.mcp_server.start_http(host=host, port=port) + # stdio transport is handled in __main__.py # Initialize scheduler thread self.scheduler = SchedulerThread() diff --git a/src/batcontrol/mcp_server.py b/src/batcontrol/mcp_server.py index 38813b59..ca84438a 100644 --- a/src/batcontrol/mcp_server.py +++ b/src/batcontrol/mcp_server.py @@ -5,6 +5,11 @@ Runs in-process as a thread alongside the main batcontrol evaluation loop. Supports Streamable HTTP transport (for Docker/HA addon) and stdio transport. + +Requires the optional 'mcp' dependency (Python >=3.10): + pip install batcontrol[mcp] + +Use `is_available()` to check at runtime before instantiation. """ import json import time @@ -12,7 +17,17 @@ import threading from typing import Optional -from mcp.server.fastmcp import FastMCP +try: + from mcp.server.fastmcp import FastMCP + MCP_AVAILABLE = True +except ImportError: + MCP_AVAILABLE = False + FastMCP = None + + +def is_available() -> bool: + """Check whether the MCP SDK is installed and importable.""" + return MCP_AVAILABLE logger = logging.getLogger(__name__) @@ -55,6 +70,11 @@ class BatcontrolMcpServer: """ def __init__(self, batcontrol_instance, config: dict): + if not MCP_AVAILABLE: + raise ImportError( + "MCP server requires the 'mcp' package (Python >=3.10). " + "Install with: pip install batcontrol[mcp]" + ) self._bc = batcontrol_instance self._config = config self._thread: Optional[threading.Thread] = None diff --git a/tests/batcontrol/test_mcp_server.py b/tests/batcontrol/test_mcp_server.py index bd78f00a..c4bd9cda 100644 --- a/tests/batcontrol/test_mcp_server.py +++ b/tests/batcontrol/test_mcp_server.py @@ -1,8 +1,20 @@ -"""Tests for the MCP server implementation""" +"""Tests for the MCP server implementation + +Requires the optional 'mcp' dependency (Python >=3.10). +Tests are skipped automatically when the mcp package is not installed. +""" import pytest import numpy as np from unittest.mock import MagicMock, patch, PropertyMock +from batcontrol.mcp_server import is_available as mcp_is_available + +if not mcp_is_available(): + pytest.skip( + "MCP SDK not installed (requires Python >=3.10)", + allow_module_level=True + ) + from batcontrol.mcp_server import BatcontrolMcpServer, _format_forecast_array, MODE_NAMES from batcontrol.override_manager import OverrideManager, OverrideState From d692e082c8e3cb72e3260eba740080bc65fa05c7 Mon Sep 17 00:00:00 2001 From: Matthias Strubel Date: Tue, 17 Mar 2026 21:16:47 +0100 Subject: [PATCH 06/13] Address PR review feedback for MCP server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mcp_server.py: remove unused json/time imports - mcp_server.py: add digits param to _format_forecast_array; price forecast now uses digits=4 to preserve EUR/kWh precision - mcp_server.py: fix duration error message "between 0" → "between 1" - mcp_server.py: document shutdown() limitation (no FastMCP stop API) - core.py: default MCP host to 127.0.0.1 (safer; 0.0.0.0 requires explicit opt-in in config) - core.py: reject charge_rate <= 0 in api_set_charge_rate (previously charge_rate=0 would silently start charging at default 500W) - __main__.py: disable core HTTP MCP startup when --mcp-stdio is used to prevent two MCP servers running simultaneously - test_mcp_server.py: remove unused patch/PropertyMock/OverrideState - test_override_manager.py: remove unused patch import - config/batcontrol_config_dummy.yaml: update example host to 127.0.0.1 Co-Authored-By: Claude Sonnet 4.6 --- config/batcontrol_config_dummy.yaml | 2 +- src/batcontrol/__main__.py | 5 ++++ src/batcontrol/core.py | 6 ++-- src/batcontrol/mcp_server.py | 35 +++++++++++++++-------- tests/batcontrol/test_mcp_server.py | 4 +-- tests/batcontrol/test_override_manager.py | 1 - 6 files changed, 34 insertions(+), 19 deletions(-) diff --git a/config/batcontrol_config_dummy.yaml b/config/batcontrol_config_dummy.yaml index d502a39d..625f1928 100644 --- a/config/batcontrol_config_dummy.yaml +++ b/config/batcontrol_config_dummy.yaml @@ -90,7 +90,7 @@ utility: mcp: enabled: false transport: http # 'http' for network access (Docker/HA addon), 'stdio' for direct pipe - host: 0.0.0.0 # Bind address (0.0.0.0 for all interfaces) + host: 127.0.0.1 # Bind address (127.0.0.1 = local only; 0.0.0.0 = all interfaces) port: 8081 # HTTP port for MCP server #-------------------------- diff --git a/src/batcontrol/__main__.py b/src/batcontrol/__main__.py index 6be7d73e..7ba10d9d 100644 --- a/src/batcontrol/__main__.py +++ b/src/batcontrol/__main__.py @@ -84,6 +84,11 @@ def main() -> int: logging.getLogger("batcontrol.forecastconsumption.forecast_homeassistant.details").setLevel(logging.INFO) logging.getLogger("batcontrol.forecastconsumption.forecast_homeassistant.communication").setLevel(logging.INFO) + # When using stdio transport, prevent core.py from starting an HTTP MCP server. + # Both would share the same Batcontrol instance and compete for the port. + if args.mcp_stdio: + config.setdefault('mcp', {})['enabled'] = False + bc = Batcontrol(config) # Handle --mcp-stdio: run MCP server in stdio mode (blocking) diff --git a/src/batcontrol/core.py b/src/batcontrol/core.py index 5771c4b2..ba54962a 100644 --- a/src/batcontrol/core.py +++ b/src/batcontrol/core.py @@ -320,7 +320,7 @@ def __init__(self, configdict: dict): self, mcp_config) transport = mcp_config.get('transport', 'http') if transport == 'http': - host = mcp_config.get('host', '0.0.0.0') + host = mcp_config.get('host', '127.0.0.1') port = mcp_config.get('port', 8081) self.mcp_server.start_http(host=host, port=port) # stdio transport is handled in __main__.py @@ -910,9 +910,9 @@ def api_set_charge_rate(self, charge_rate: int, duration_minutes: float = None): duration_minutes: Override duration. None uses the MQTT-configured override_duration. """ - if charge_rate < 0: + if charge_rate <= 0: logger.warning( - 'API: Invalid charge rate %d W', charge_rate) + 'API: Invalid charge rate %d W (must be > 0)', charge_rate) return # Use MQTT-configured duration if no explicit duration given diff --git a/src/batcontrol/mcp_server.py b/src/batcontrol/mcp_server.py index ca84438a..0c10c2ba 100644 --- a/src/batcontrol/mcp_server.py +++ b/src/batcontrol/mcp_server.py @@ -11,8 +11,6 @@ Use `is_available()` to check at runtime before instantiation. """ -import json -import time import logging import threading from typing import Optional @@ -47,8 +45,17 @@ def is_available() -> bool: VALID_MODES = set(MODE_NAMES.keys()) -def _format_forecast_array(arr, run_time: float, interval_minutes: int) -> list: - """Format a numpy array forecast into a list of {time, value} dicts.""" +def _format_forecast_array(arr, run_time: float, interval_minutes: int, + digits: int = 1) -> list: + """Format a numpy array forecast into a list of {time, value} dicts. + + Args: + arr: Numpy array of values. + run_time: Current epoch timestamp. + interval_minutes: Slot width in minutes. + digits: Decimal places to round values to. Use 1 for power/energy (W/Wh), + 4 for prices (EUR/kWh) to preserve meaningful precision. + """ if arr is None: return [] interval_seconds = interval_minutes * 60 @@ -58,7 +65,7 @@ def _format_forecast_array(arr, run_time: float, interval_minutes: int) -> list: result.append({ 'slot': i, 'time_start': base_time + i * interval_seconds, - 'value': round(float(val), 1), + 'value': round(float(val), digits), }) return result @@ -131,7 +138,7 @@ def get_price_forecast() -> dict: return { 'interval_minutes': bc.time_resolution, 'prices': _format_forecast_array( - bc.last_prices, bc.last_run_time, bc.time_resolution), + bc.last_prices, bc.last_run_time, bc.time_resolution, digits=4), 'current_price': round(float(bc.last_prices[0]), 4) if bc.last_prices is not None and len(bc.last_prices) > 0 else None, } @@ -277,7 +284,7 @@ def set_mode_override(mode: int, duration_minutes: float = 30, if mode not in VALID_MODES: return {'error': "Invalid mode %s. Valid: %s" % (mode, sorted(VALID_MODES))} if duration_minutes <= 0 or duration_minutes > 1440: - return {'error': "duration_minutes must be between 0 and 1440 (24h)"} + return {'error': "duration_minutes must be between 1 and 1440 (24h)"} bc = self._bc override = bc.override_manager.set_override( @@ -323,7 +330,7 @@ def set_charge_rate(charge_rate_w: int, duration_minutes: float = 30, if charge_rate_w <= 0: return {'error': "charge_rate_w must be positive"} if duration_minutes <= 0 or duration_minutes > 1440: - return {'error': "duration_minutes must be between 0 and 1440 (24h)"} + return {'error': "duration_minutes must be between 1 and 1440 (24h)"} bc = self._bc override = bc.override_manager.set_override( @@ -401,7 +408,11 @@ def run_stdio(self): self.mcp.run(transport="stdio") def shutdown(self): - """Shutdown the MCP server.""" - logger.info("MCP server shutdown requested") - # The daemon thread will be cleaned up automatically on process exit. - # For stdio mode, the process is already blocking on it. + """Request MCP server shutdown. + + Note: The MCP SDK (FastMCP/uvicorn) does not expose a programmatic + stop API. The HTTP server runs as a daemon thread and will be cleaned + up automatically when the process exits. If you need clean mid-process + shutdown, use a dedicated process/subprocess for the MCP server instead. + """ + logger.info("MCP server shutdown requested (daemon thread will exit with process)") diff --git a/tests/batcontrol/test_mcp_server.py b/tests/batcontrol/test_mcp_server.py index c4bd9cda..6d9f98af 100644 --- a/tests/batcontrol/test_mcp_server.py +++ b/tests/batcontrol/test_mcp_server.py @@ -5,7 +5,7 @@ """ import pytest import numpy as np -from unittest.mock import MagicMock, patch, PropertyMock +from unittest.mock import MagicMock from batcontrol.mcp_server import is_available as mcp_is_available @@ -16,7 +16,7 @@ ) from batcontrol.mcp_server import BatcontrolMcpServer, _format_forecast_array, MODE_NAMES -from batcontrol.override_manager import OverrideManager, OverrideState +from batcontrol.override_manager import OverrideManager class TestFormatForecastArray: diff --git a/tests/batcontrol/test_override_manager.py b/tests/batcontrol/test_override_manager.py index 52a008c5..9554756b 100644 --- a/tests/batcontrol/test_override_manager.py +++ b/tests/batcontrol/test_override_manager.py @@ -1,7 +1,6 @@ """Tests for the OverrideManager""" import time import pytest -from unittest.mock import patch from batcontrol.override_manager import OverrideManager, OverrideState From a53f8988bd6c24d0bca17a775436be1d74fe0833 Mon Sep 17 00:00:00 2001 From: Matthias Strubel Date: Tue, 17 Mar 2026 21:24:59 +0100 Subject: [PATCH 07/13] Address second round of PR review feedback - mcp_server.py: set_parameter now validates ranges before calling the API handler, returning a proper error instead of false success when values are out of range - mcp_server.py: fix _format_forecast_array docstring to say {slot, time_start, value} instead of {time, value} - core.py: downgrade MODE_LIMIT_BATTERY_CHARGE_RATE missing-limit log from warning to debug (emitted every eval cycle, not actionable) - pyproject.toml: add python_version>='3.10' marker to mcp extra so pip install batcontrol[mcp] on Python 3.9 does not attempt to install the incompatible mcp package - Dockerfile: install wheel and mcp>=1.0 as separate pip calls; appending [mcp] to a wheel filename wildcard is not valid pip syntax Co-Authored-By: Claude Sonnet 4.6 --- Dockerfile | 6 ++++-- pyproject.toml | 2 +- src/batcontrol/core.py | 2 +- src/batcontrol/mcp_server.py | 41 +++++++++++++++++++++++++++++------- 4 files changed, 39 insertions(+), 12 deletions(-) diff --git a/Dockerfile b/Dockerfile index 974a0fc7..8dca6d2c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,8 +28,10 @@ LABEL maintainer="matthias.strubel@aod-rpg.de" COPY --from=builder /wheels /wheels # Update pip and install runtime dependencies -# Install the wheel with the optional MCP dependency (Python 3.13 supports it) -RUN pip install --no-cache-dir --extra-index-url https://piwheels.org/simple --prefer-binary "/wheels/*.whl[mcp]" && rm -rf /wheels +# Install the wheel, then add the optional MCP dependency (Python 3.13 supports it) +RUN pip install --no-cache-dir --extra-index-url https://piwheels.org/simple --prefer-binary /wheels/*.whl && \ + pip install --no-cache-dir "mcp>=1.0" && \ + rm -rf /wheels ENV BATCONTROL_VERSION=${VERSION} ENV BATCONTROL_GIT_SHA=${GIT_SHA} diff --git a/pyproject.toml b/pyproject.toml index 0b31379a..86135945 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ pythonpath = ["src"] # For testing [project.optional-dependencies] mcp = [ - "mcp>=1.0", + "mcp>=1.0; python_version>='3.10'", ] test = [ "pytest>=7.0.0", diff --git a/src/batcontrol/core.py b/src/batcontrol/core.py index ba54962a..d1faadfc 100644 --- a/src/batcontrol/core.py +++ b/src/batcontrol/core.py @@ -862,7 +862,7 @@ def _apply_override(self, override): self.avoid_discharging() elif mode == MODE_LIMIT_BATTERY_CHARGE_RATE: if self._limit_battery_charge_rate < 0: - logger.warning( + logger.debug( 'Override: Mode %d (limit battery charge rate) set but no valid ' 'limit configured. Falling back to allow-discharging mode.', mode) diff --git a/src/batcontrol/mcp_server.py b/src/batcontrol/mcp_server.py index 0c10c2ba..0383e243 100644 --- a/src/batcontrol/mcp_server.py +++ b/src/batcontrol/mcp_server.py @@ -47,7 +47,7 @@ def is_available() -> bool: def _format_forecast_array(arr, run_time: float, interval_minutes: int, digits: int = 1) -> list: - """Format a numpy array forecast into a list of {time, value} dicts. + """Format a numpy array forecast into a list of {slot, time_start, value} dicts. Args: arr: Numpy array of values. @@ -363,6 +363,38 @@ def set_parameter(parameter: str, value: float) -> dict: - production_offset (0.0-2.0, multiplier) value: New value for the parameter """ + # Validate ranges before calling the API (mirrors core.py validation) + PARAM_VALIDATORS = { + 'always_allow_discharge_limit': ( + lambda v: 0.0 <= v <= 1.0, + "must be between 0.0 and 1.0"), + 'max_charging_from_grid_limit': ( + lambda v: 0.0 <= v <= 1.0, + "must be between 0.0 and 1.0"), + 'min_price_difference': ( + lambda v: v >= 0.0, + "must be >= 0.0"), + 'min_price_difference_rel': ( + lambda v: v >= 0.0, + "must be >= 0.0"), + 'production_offset': ( + lambda v: 0.0 <= v <= 2.0, + "must be between 0.0 and 2.0"), + } + + if parameter not in PARAM_VALIDATORS: + return { + 'error': "Unknown parameter '%s'. Valid: %s" % ( + parameter, sorted(PARAM_VALIDATORS.keys())) + } + + validator, constraint = PARAM_VALIDATORS[parameter] + if not validator(value): + return { + 'error': "Invalid value %.4g for '%s': %s" % ( + value, parameter, constraint) + } + bc = self._bc handlers = { 'always_allow_discharge_limit': bc.api_set_always_allow_discharge_limit, @@ -371,13 +403,6 @@ def set_parameter(parameter: str, value: float) -> dict: 'min_price_difference_rel': bc.api_set_min_price_difference_rel, 'production_offset': bc.api_set_production_offset, } - - if parameter not in handlers: - return { - 'error': "Unknown parameter '%s'. Valid: %s" % ( - parameter, sorted(handlers.keys())) - } - handlers[parameter](value) return { 'success': True, From 3d0c5fd04421a385ef11e06d1a020543084f08ff Mon Sep 17 00:00:00 2001 From: Matthias Strubel Date: Thu, 2 Apr 2026 15:40:24 +0200 Subject: [PATCH 08/13] Address PR review feedback: lint, API safety, and MCP host/port fix - Fix FastMCP.run() called with invalid host/port kwargs; configure via mcp.settings.host/port before calling run() (real functional bug) - Add api_apply_override() public method to Batcontrol, removing protected-access violations in mcp_server.py - Add duration_minutes validation in api_set_mode() and api_set_charge_rate() to guard against ValueError from override_manager (prevents MQTT callback crashes) - Fix __main__.py import order (stdlib before local), add module and function docstrings, break long lines - Replace % string formatting with f-strings in mcp_server.py - Rename PARAM_VALIDATORS to param_validators (snake_case) - Fix default MCP HTTP bind from 0.0.0.0 to 127.0.0.1 - Use bc.api_get_limit_battery_charge_rate() instead of private _limit_battery_charge_rate access in get_configuration tool - All 361 tests pass; pylint 9.46/10 overall (10.00/10 on new files) Co-Authored-By: Claude Sonnet 4.6 --- src/batcontrol/__main__.py | 28 +++++++++++------ src/batcontrol/core.py | 19 ++++++++++++ src/batcontrol/mcp_server.py | 48 ++++++++++++++++------------- tests/batcontrol/test_mcp_server.py | 6 ++-- 4 files changed, 68 insertions(+), 33 deletions(-) diff --git a/src/batcontrol/__main__.py b/src/batcontrol/__main__.py index 7ba10d9d..83265017 100644 --- a/src/batcontrol/__main__.py +++ b/src/batcontrol/__main__.py @@ -1,13 +1,15 @@ -from .core import Batcontrol -from .setup import setup_logging, load_config -from .inverter import InverterOutageError -from . import mcp_server as mcp_module +"""Batcontrol entry point: parses arguments, sets up logging, and runs the main loop.""" import argparse import time import datetime import sys import logging +from .core import Batcontrol +from .setup import setup_logging, load_config +from .inverter import InverterOutageError +from . import mcp_server as mcp_module + CONFIGFILE = "config/batcontrol_config.yaml" EVALUATIONS_EVERY_MINUTES = 3 # Every x minutes on the clock @@ -39,7 +41,8 @@ def parse_arguments(): return parser.parse_args() -def main() -> int: +def main() -> int: # pylint: disable=too-many-locals,too-many-statements + """Run batcontrol: load config, set up logging, and start the main loop.""" # Parse command line arguments args = parse_arguments() @@ -71,7 +74,11 @@ def main() -> int: } # Setup the logger based on the config - setup_logging(level=loglevel_mapping.get(loglevel, logging.INFO), logfile=logfile, max_logfile_size_kb=max_logfile_size) + setup_logging( + level=loglevel_mapping.get(loglevel, logging.INFO), + logfile=logfile, + max_logfile_size_kb=max_logfile_size, + ) logger = logging.getLogger(__name__) # Reduce the default loglevel for urllib3.connectionpool @@ -81,8 +88,9 @@ def main() -> int: logging.getLogger("asyncio").setLevel(logging.WARNING) logging.getLogger("urllib3.connectionpool").setLevel(logging.WARNING) logging.getLogger("batcontrol.inverter.fronius.auth").setLevel(logging.INFO) - logging.getLogger("batcontrol.forecastconsumption.forecast_homeassistant.details").setLevel(logging.INFO) - logging.getLogger("batcontrol.forecastconsumption.forecast_homeassistant.communication").setLevel(logging.INFO) + ha_base = "batcontrol.forecastconsumption.forecast_homeassistant" + logging.getLogger(f"{ha_base}.details").setLevel(logging.INFO) + logging.getLogger(f"{ha_base}.communication").setLevel(logging.INFO) # When using stdio transport, prevent core.py from starting an HTTP MCP server. # Both would share the same Batcontrol instance and compete for the port. @@ -131,7 +139,9 @@ def main() -> int: # add time increments to trigger next evaluation next_eval += datetime.timedelta(minutes=EVALUATIONS_EVERY_MINUTES) sleeptime = (next_eval - loop_now).total_seconds() - logger.info("Next evaluation at %s. Sleeping for %d seconds", next_eval.strftime('%H:%M:%S'), int(sleeptime)) + logger.info( + "Next evaluation at %s. Sleeping for %d seconds", + next_eval.strftime('%H:%M:%S'), int(sleeptime)) time.sleep(sleeptime) except KeyboardInterrupt: print("Shutting down") diff --git a/src/batcontrol/core.py b/src/batcontrol/core.py index d1faadfc..4c6a0d78 100644 --- a/src/batcontrol/core.py +++ b/src/batcontrol/core.py @@ -848,6 +848,13 @@ def refresh_static_values(self) -> None: # Trigger Inverter self.inverter.refresh_api_values() + def api_apply_override(self, override): + """Public entry point to apply an override's mode/charge_rate to the inverter. + + Used by the MCP server to avoid accessing the private _apply_override method. + """ + self._apply_override(override) + def _apply_override(self, override): """Apply an override's mode/charge_rate to the inverter.""" mode = override.mode @@ -893,6 +900,12 @@ def api_set_mode(self, mode: int, duration_minutes: float = None): if duration_minutes is None: duration_minutes = self._mqtt_override_duration + if duration_minutes <= 0 or duration_minutes > 1440: + logger.warning( + 'API: Invalid duration %.1f min for mode override (must be 1-1440)', + duration_minutes) + return + logger.info('API: Setting mode to %s for %.1f min', mode, duration_minutes) override = self.override_manager.set_override( mode=mode, @@ -919,6 +932,12 @@ def api_set_charge_rate(self, charge_rate: int, duration_minutes: float = None): if duration_minutes is None: duration_minutes = self._mqtt_override_duration + if duration_minutes <= 0 or duration_minutes > 1440: + logger.warning( + 'API: Invalid duration %.1f min for charge rate override (must be 1-1440)', + duration_minutes) + return + logger.info('API: Setting charge rate to %d W for %.1f min', charge_rate, duration_minutes) override = self.override_manager.set_override( mode=MODE_FORCE_CHARGING, diff --git a/src/batcontrol/mcp_server.py b/src/batcontrol/mcp_server.py index 0383e243..24bb3796 100644 --- a/src/batcontrol/mcp_server.py +++ b/src/batcontrol/mcp_server.py @@ -135,11 +135,15 @@ def get_price_forecast() -> dict: Prices are in EUR/kWh. """ bc = self._bc + current = bc.last_prices + current_price = ( + round(float(current[0]), 4) if current is not None and len(current) > 0 else None + ) return { 'interval_minutes': bc.time_resolution, 'prices': _format_forecast_array( bc.last_prices, bc.last_run_time, bc.time_resolution, digits=4), - 'current_price': round(float(bc.last_prices[0]), 4) if bc.last_prices is not None and len(bc.last_prices) > 0 else None, + 'current_price': current_price, } @self.mcp.tool() @@ -219,12 +223,11 @@ def get_decision_explanation() -> dict: 'override_active': override is not None, } if override: + mode_name = MODE_NAMES.get(override.mode, "Unknown") + reason = override.reason or "not specified" result['override_info'] = ( - "Manual override active: %s for %.1f more minutes. Reason: %s" % ( - MODE_NAMES.get(override.mode, "Unknown"), - override.remaining_minutes, - override.reason or "not specified" - ) + f"Manual override active: {mode_name} for " + f"{override.remaining_minutes:.1f} more minutes. Reason: {reason}" ) return result @@ -243,7 +246,7 @@ def get_configuration() -> dict: 'min_price_difference_rel': bc.min_price_difference_rel, 'production_offset_percent': bc.production_offset_percent, 'time_resolution_minutes': bc.time_resolution, - 'limit_battery_charge_rate': bc._limit_battery_charge_rate, + 'limit_battery_charge_rate': bc.api_get_limit_battery_charge_rate(), } @self.mcp.tool() @@ -282,7 +285,7 @@ def set_mode_override(mode: int, duration_minutes: float = 30, reason: Human-readable reason for the override """ if mode not in VALID_MODES: - return {'error': "Invalid mode %s. Valid: %s" % (mode, sorted(VALID_MODES))} + return {'error': f"Invalid mode {mode}. Valid: {sorted(VALID_MODES)}"} if duration_minutes <= 0 or duration_minutes > 1440: return {'error': "duration_minutes must be between 1 and 1440 (24h)"} @@ -292,7 +295,7 @@ def set_mode_override(mode: int, duration_minutes: float = 30, duration_minutes=duration_minutes, reason=reason or "MCP set_mode_override", ) - bc._apply_override(override) + bc.api_apply_override(override) return { 'success': True, @@ -339,7 +342,7 @@ def set_charge_rate(charge_rate_w: int, duration_minutes: float = 30, duration_minutes=duration_minutes, reason=reason or "MCP set_charge_rate", ) - bc._apply_override(override) + bc.api_apply_override(override) return { 'success': True, @@ -364,7 +367,7 @@ def set_parameter(parameter: str, value: float) -> dict: value: New value for the parameter """ # Validate ranges before calling the API (mirrors core.py validation) - PARAM_VALIDATORS = { + param_validators = { 'always_allow_discharge_limit': ( lambda v: 0.0 <= v <= 1.0, "must be between 0.0 and 1.0"), @@ -382,17 +385,16 @@ def set_parameter(parameter: str, value: float) -> dict: "must be between 0.0 and 2.0"), } - if parameter not in PARAM_VALIDATORS: + if parameter not in param_validators: return { - 'error': "Unknown parameter '%s'. Valid: %s" % ( - parameter, sorted(PARAM_VALIDATORS.keys())) + 'error': f"Unknown parameter '{parameter}'. " + f"Valid: {sorted(param_validators.keys())}" } - validator, constraint = PARAM_VALIDATORS[parameter] + validator, constraint = param_validators[parameter] if not validator(value): return { - 'error': "Invalid value %.4g for '%s': %s" % ( - value, parameter, constraint) + 'error': f"Invalid value {value:.4g} for '{parameter}': {constraint}" } bc = self._bc @@ -403,20 +405,24 @@ def set_parameter(parameter: str, value: float) -> dict: 'min_price_difference_rel': bc.api_set_min_price_difference_rel, 'production_offset': bc.api_set_production_offset, } - handlers[parameter](value) + handlers[parameter](value) # pylint: disable=protected-access return { 'success': True, 'parameter': parameter, 'new_value': value, } - def start_http(self, host: str = "0.0.0.0", port: int = 8081): + def start_http(self, host: str = "127.0.0.1", port: int = 8081): """Start the MCP server with Streamable HTTP transport in a background thread.""" + # FastMCP reads host/port from its settings at run time + self.mcp.settings.host = host + self.mcp.settings.port = port + def _run(): logger.info("Starting MCP server on %s:%d", host, port) try: - self.mcp.run(transport="streamable-http", host=host, port=port) - except Exception as e: + self.mcp.run(transport="streamable-http") + except Exception as e: # pylint: disable=broad-exception-caught logger.error("MCP server error: %s", e, exc_info=True) self._thread = threading.Thread( diff --git a/tests/batcontrol/test_mcp_server.py b/tests/batcontrol/test_mcp_server.py index 6d9f98af..2a350f23 100644 --- a/tests/batcontrol/test_mcp_server.py +++ b/tests/batcontrol/test_mcp_server.py @@ -87,7 +87,7 @@ def mock_bc(self): bc.api_set_min_price_difference = MagicMock() bc.api_set_min_price_difference_rel = MagicMock() bc.api_set_production_offset = MagicMock() - bc._apply_override = MagicMock() + bc.api_apply_override = MagicMock() return bc @pytest.fixture @@ -193,7 +193,7 @@ def test_set_mode_override(self, server, mock_bc): assert result['success'] is True assert result['mode_name'] == "Avoid Discharge" assert mock_bc.override_manager.is_active() - mock_bc._apply_override.assert_called_once() + mock_bc.api_apply_override.assert_called_once() def test_set_mode_override_invalid_mode(self, server): """Test set_mode_override with invalid mode.""" @@ -225,7 +225,7 @@ def test_set_charge_rate(self, server, mock_bc): override = mock_bc.override_manager.get_override() assert override.mode == -1 assert override.charge_rate == 2000 - mock_bc._apply_override.assert_called_once() + mock_bc.api_apply_override.assert_called_once() def test_set_charge_rate_invalid(self, server): """Test set_charge_rate with invalid rate.""" From 546d26400b6209abdecc89ae3d630d99ed7c83bc Mon Sep 17 00:00:00 2001 From: Matthias Strubel Date: Thu, 2 Apr 2026 16:03:07 +0200 Subject: [PATCH 09/13] Address fourth Copilot review: transport warning, naming, epoch fix, tests - core.py: log a warning when mcp.transport is not 'http' so users aren't silently left with an unstarted server (fixes silent no-op for transport: stdio in config without --mcp-stdio flag) - mcp_server.py: add 'production_offset' key to get_configuration() so clients can round-trip values with set_parameter() (which uses 'production_offset' as the parameter name, not 'production_offset_percent') - mcp_server.py: fall back to time.time() when last_run_time is 0 (unset before first evaluation) to avoid epoch-anchored timestamps in all four forecast tools - test_mcp_server.py: remove unused MODE_NAMES import - test_core.py: add TestOverrideDurationAndClear with 7 tests covering api_set_override_duration() (valid, reset-to-default, reject negative, reject >1440) and api_clear_override() (removes override, idempotent, publishes MQTT when api present) All 368 tests pass; pylint 9.46/10. Co-Authored-By: Claude Sonnet 4.6 --- src/batcontrol/core.py | 10 +++- src/batcontrol/mcp_server.py | 19 +++++-- tests/batcontrol/test_core.py | 83 +++++++++++++++++++++++++++++ tests/batcontrol/test_mcp_server.py | 2 +- 4 files changed, 108 insertions(+), 6 deletions(-) diff --git a/src/batcontrol/core.py b/src/batcontrol/core.py index 4c6a0d78..4e05ce10 100644 --- a/src/batcontrol/core.py +++ b/src/batcontrol/core.py @@ -323,7 +323,15 @@ def __init__(self, configdict: dict): host = mcp_config.get('host', '127.0.0.1') port = mcp_config.get('port', 8081) self.mcp_server.start_http(host=host, port=port) - # stdio transport is handled in __main__.py + else: + # stdio transport is handled in __main__.py via --mcp-stdio. + # Any other transport value is unsupported here. + logger.warning( + 'MCP transport "%s" is not started by batcontrol.core. ' + 'Only "http" is started automatically. ' + 'For stdio, use the --mcp-stdio command-line option.', + transport, + ) # Initialize scheduler thread self.scheduler = SchedulerThread() diff --git a/src/batcontrol/mcp_server.py b/src/batcontrol/mcp_server.py index 24bb3796..d2b479d9 100644 --- a/src/batcontrol/mcp_server.py +++ b/src/batcontrol/mcp_server.py @@ -13,6 +13,7 @@ """ import logging import threading +import time from typing import Optional try: @@ -97,6 +98,15 @@ def __init__(self, batcontrol_instance, config: dict): ) self._register_tools() + @staticmethod + def _base_time(last_run_time: float) -> float: + """Return a valid epoch base time for forecast formatting. + + Falls back to the current time when last_run_time has not been set yet + (it is initialised to 0 and only updated after the first evaluation cycle). + """ + return last_run_time if last_run_time > 0 else time.time() + def _register_tools(self): """Register all MCP tools.""" @@ -142,7 +152,7 @@ def get_price_forecast() -> dict: return { 'interval_minutes': bc.time_resolution, 'prices': _format_forecast_array( - bc.last_prices, bc.last_run_time, bc.time_resolution, digits=4), + bc.last_prices, self._base_time(bc.last_run_time), bc.time_resolution, digits=4), 'current_price': current_price, } @@ -156,7 +166,7 @@ def get_solar_forecast() -> dict: return { 'interval_minutes': bc.time_resolution, 'production_w': _format_forecast_array( - bc.last_production, bc.last_run_time, bc.time_resolution), + bc.last_production, self._base_time(bc.last_run_time), bc.time_resolution), 'production_offset_percent': bc.production_offset_percent, } @@ -170,7 +180,7 @@ def get_consumption_forecast() -> dict: return { 'interval_minutes': bc.time_resolution, 'consumption_w': _format_forecast_array( - bc.last_consumption, bc.last_run_time, bc.time_resolution), + bc.last_consumption, self._base_time(bc.last_run_time), bc.time_resolution), } @self.mcp.tool() @@ -183,7 +193,7 @@ def get_net_consumption_forecast() -> dict: return { 'interval_minutes': bc.time_resolution, 'net_consumption_w': _format_forecast_array( - bc.last_net_consumption, bc.last_run_time, bc.time_resolution), + bc.last_net_consumption, self._base_time(bc.last_run_time), bc.time_resolution), } @self.mcp.tool() @@ -245,6 +255,7 @@ def get_configuration() -> dict: 'min_price_difference': bc.min_price_difference, 'min_price_difference_rel': bc.min_price_difference_rel, 'production_offset_percent': bc.production_offset_percent, + 'production_offset': bc.production_offset_percent, 'time_resolution_minutes': bc.time_resolution, 'limit_battery_charge_rate': bc.api_get_limit_battery_charge_rate(), } diff --git a/tests/batcontrol/test_core.py b/tests/batcontrol/test_core.py index 0a3120bd..07999f20 100644 --- a/tests/batcontrol/test_core.py +++ b/tests/batcontrol/test_core.py @@ -356,5 +356,88 @@ def test_logic_factory_accepts_string_resolution_as_int(self, resolution_str): assert logic.interval_minutes == int(resolution_str) +class TestOverrideDurationAndClear: + """Tests for api_set_override_duration() and api_clear_override().""" + + @pytest.fixture + def bc(self): + """Minimal Batcontrol instance with all external dependencies mocked.""" + with patch('batcontrol.core.tariff_factory.create_tarif_provider') as mock_tariff, \ + patch('batcontrol.core.inverter_factory.create_inverter') as mock_inv, \ + patch('batcontrol.core.solar_factory.create_solar_provider') as mock_solar, \ + patch('batcontrol.core.consumption_factory.create_consumption') as mock_cons: + mock_inverter = MagicMock() + mock_inverter.max_pv_charge_rate = 3000 + mock_inverter.get_max_capacity = MagicMock(return_value=10000) + mock_inv.return_value = mock_inverter + mock_tariff.return_value = MagicMock() + mock_solar.return_value = MagicMock() + mock_cons.return_value = MagicMock() + config = { + 'timezone': 'Europe/Berlin', + 'time_resolution_minutes': 60, + 'inverter': { + 'type': 'dummy', + 'max_grid_charge_rate': 5000, + 'max_pv_charge_rate': 3000, + 'min_pv_charge_rate': 100, + }, + 'utility': {'type': 'tibber', 'token': 'test_token'}, + 'pvinstallations': [], + 'consumption_forecast': {'type': 'simple', 'value': 500}, + 'battery_control': { + 'max_charging_from_grid_limit': 0.8, + 'min_price_difference': 0.05, + }, + 'mqtt': {'enabled': False}, + } + yield Batcontrol(config) + + def test_set_override_duration_valid(self, bc): + """Valid duration is stored and returned.""" + bc.api_set_override_duration(60.0) + assert bc._mqtt_override_duration == 60.0 + + def test_set_override_duration_resets_to_default_on_zero(self, bc): + """Duration of 0 resets to the manager default.""" + default = bc.override_manager.default_duration_minutes + bc.api_set_override_duration(90.0) + bc.api_set_override_duration(0) + assert bc._mqtt_override_duration == default + + def test_set_override_duration_rejects_negative(self, bc): + """Negative duration is rejected; stored value is unchanged.""" + bc.api_set_override_duration(45.0) + bc.api_set_override_duration(-10.0) + assert bc._mqtt_override_duration == 45.0 + + def test_set_override_duration_rejects_above_1440(self, bc): + """Duration above 1440 minutes is rejected.""" + bc.api_set_override_duration(30.0) + bc.api_set_override_duration(1441.0) + assert bc._mqtt_override_duration == 30.0 + + def test_clear_override_removes_active_override(self, bc): + """api_clear_override() stops an active override.""" + bc.override_manager.set_override(mode=-1, duration_minutes=30, reason="test") + assert bc.override_manager.is_active() + bc.api_clear_override() + assert not bc.override_manager.is_active() + + def test_clear_override_is_idempotent(self, bc): + """Calling api_clear_override() when nothing is active does not raise.""" + assert not bc.override_manager.is_active() + bc.api_clear_override() # should not raise + assert not bc.override_manager.is_active() + + def test_clear_override_publishes_mqtt_when_api_present(self, bc): + """Override clear publishes updated status via MQTT when mqtt_api is set.""" + bc.mqtt_api = MagicMock() + bc.override_manager.set_override(mode=0, duration_minutes=10, reason="test") + bc.api_clear_override() + bc.mqtt_api.publish_override_active.assert_called_once_with(False) + bc.mqtt_api.publish_override_remaining.assert_called_once_with(0.0) + + if __name__ == '__main__': pytest.main([__file__, '-v']) diff --git a/tests/batcontrol/test_mcp_server.py b/tests/batcontrol/test_mcp_server.py index 2a350f23..f7c98413 100644 --- a/tests/batcontrol/test_mcp_server.py +++ b/tests/batcontrol/test_mcp_server.py @@ -15,7 +15,7 @@ allow_module_level=True ) -from batcontrol.mcp_server import BatcontrolMcpServer, _format_forecast_array, MODE_NAMES +from batcontrol.mcp_server import BatcontrolMcpServer, _format_forecast_array from batcontrol.override_manager import OverrideManager From 00e1835756f79dfc61a1481427afcd36cb392c47 Mon Sep 17 00:00:00 2001 From: Matthias Strubel Date: Thu, 2 Apr 2026 16:17:39 +0200 Subject: [PATCH 10/13] Self-review fixes: sentinel, TOCTOU, MQTT publish, validation alignment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit override_manager.py: - Use None sentinel for expires_at instead of 0.0 float equality check (M2: float equality is fragile; Optional[float] is unambiguous) - Snapshot time.time() once in to_dict() so remaining_minutes and is_active are guaranteed internally consistent (M3: TOCTOU fix) mcp_server.py: - clear_mode_override now calls bc.api_clear_override() instead of bc.override_manager.clear_override() directly, so MQTT publishes override_active=False immediately and the API audit log is written (M1+m2: stale MQTT state + missing log line) - Remove duplicate production_offset_percent from get_configuration(); keep production_offset as the single canonical key that matches set_parameter() (m1/n3) - set_charge_rate returns a 'note' field when the requested rate was clamped by inverter limits, so clients see the effective vs requested discrepancy (m4) - Remove spurious pylint: disable=protected-access on public method call (n1) core.py: - Fix misleading _apply_override log for mode 8: message now correctly describes that limit_battery_charge_rate() will switch to allow-discharging, not that this method falls back (C1) - Align duration_minutes validation to < 1 (not <= 0) in api_set_mode and api_set_charge_rate, matching api_set_override_duration (m3) __main__.py: - Copy mcp config section instead of mutating the shared dict stored in bc.config when --mcp-stdio disables HTTP startup (m5) tests: - Wire mock_bc.api_clear_override to the real override_manager in test_mcp_server.py so clear_mode_override test remains valid - Add TestApiSetModeAndChargeDuration: 5 tests for explicit duration, invalid duration rejection, and None→mqtt_default fallback (m7) All 373 tests pass; pylint 9.46/10. Co-Authored-By: Claude Sonnet 4.6 --- src/batcontrol/__main__.py | 3 +- src/batcontrol/core.py | 11 ++--- src/batcontrol/mcp_server.py | 16 ++++--- src/batcontrol/override_manager.py | 10 +++-- tests/batcontrol/test_core.py | 68 +++++++++++++++++++++++++++++ tests/batcontrol/test_mcp_server.py | 2 + 6 files changed, 95 insertions(+), 15 deletions(-) diff --git a/src/batcontrol/__main__.py b/src/batcontrol/__main__.py index 83265017..f44f87e3 100644 --- a/src/batcontrol/__main__.py +++ b/src/batcontrol/__main__.py @@ -94,8 +94,9 @@ def main() -> int: # pylint: disable=too-many-locals,too-many-statements # When using stdio transport, prevent core.py from starting an HTTP MCP server. # Both would share the same Batcontrol instance and compete for the port. + # Copy the mcp section to avoid mutating the dict stored in bc.config later. if args.mcp_stdio: - config.setdefault('mcp', {})['enabled'] = False + config['mcp'] = {**config.get('mcp', {}), 'enabled': False} bc = Batcontrol(config) diff --git a/src/batcontrol/core.py b/src/batcontrol/core.py index 4e05ce10..7c79a103 100644 --- a/src/batcontrol/core.py +++ b/src/batcontrol/core.py @@ -878,9 +878,10 @@ def _apply_override(self, override): elif mode == MODE_LIMIT_BATTERY_CHARGE_RATE: if self._limit_battery_charge_rate < 0: logger.debug( - 'Override: Mode %d (limit battery charge rate) set but no valid ' - 'limit configured. Falling back to allow-discharging mode.', - mode) + 'Override: Mode %d (limit battery charge rate) requested but ' + '_limit_battery_charge_rate=%d; limit_battery_charge_rate() ' + 'will switch to allow-discharging because the limit is negative.', + mode, self._limit_battery_charge_rate) self.limit_battery_charge_rate(self._limit_battery_charge_rate) elif mode == MODE_ALLOW_DISCHARGING: self.allow_discharging() @@ -908,7 +909,7 @@ def api_set_mode(self, mode: int, duration_minutes: float = None): if duration_minutes is None: duration_minutes = self._mqtt_override_duration - if duration_minutes <= 0 or duration_minutes > 1440: + if duration_minutes < 1 or duration_minutes > 1440: logger.warning( 'API: Invalid duration %.1f min for mode override (must be 1-1440)', duration_minutes) @@ -940,7 +941,7 @@ def api_set_charge_rate(self, charge_rate: int, duration_minutes: float = None): if duration_minutes is None: duration_minutes = self._mqtt_override_duration - if duration_minutes <= 0 or duration_minutes > 1440: + if duration_minutes < 1 or duration_minutes > 1440: logger.warning( 'API: Invalid duration %.1f min for charge rate override (must be 1-1440)', duration_minutes) diff --git a/src/batcontrol/mcp_server.py b/src/batcontrol/mcp_server.py index d2b479d9..d8e7771c 100644 --- a/src/batcontrol/mcp_server.py +++ b/src/batcontrol/mcp_server.py @@ -254,7 +254,6 @@ def get_configuration() -> dict: 'max_charging_from_grid_limit': bc.max_charging_from_grid_limit, 'min_price_difference': bc.min_price_difference, 'min_price_difference_rel': bc.min_price_difference_rel, - 'production_offset_percent': bc.production_offset_percent, 'production_offset': bc.production_offset_percent, 'time_resolution_minutes': bc.time_resolution, 'limit_battery_charge_rate': bc.api_get_limit_battery_charge_rate(), @@ -322,7 +321,7 @@ def clear_mode_override() -> dict: """ bc = self._bc was_active = bc.override_manager.is_active() - bc.override_manager.clear_override() + bc.api_clear_override() return { 'success': True, 'was_active': was_active, @@ -355,11 +354,18 @@ def set_charge_rate(charge_rate_w: int, duration_minutes: float = 30, ) bc.api_apply_override(override) - return { + effective = bc.last_charge_rate + result = { 'success': True, 'override': override.to_dict(), - 'effective_charge_rate_w': bc.last_charge_rate, + 'effective_charge_rate_w': effective, } + if effective != charge_rate_w: + result['note'] = ( + f"Requested {charge_rate_w} W was clamped to " + f"{effective} W by inverter limits." + ) + return result @self.mcp.tool() def set_parameter(parameter: str, value: float) -> dict: @@ -416,7 +422,7 @@ def set_parameter(parameter: str, value: float) -> dict: 'min_price_difference_rel': bc.api_set_min_price_difference_rel, 'production_offset': bc.api_set_production_offset, } - handlers[parameter](value) # pylint: disable=protected-access + handlers[parameter](value) return { 'success': True, 'parameter': parameter, diff --git a/src/batcontrol/override_manager.py b/src/batcontrol/override_manager.py index 7c821ddd..edbc772e 100644 --- a/src/batcontrol/override_manager.py +++ b/src/batcontrol/override_manager.py @@ -26,10 +26,10 @@ class OverrideState: duration_minutes: float reason: str created_at: float = field(default_factory=time.time) - expires_at: float = 0.0 + expires_at: Optional[float] = field(default=None) def __post_init__(self): - if self.expires_at == 0.0: + if self.expires_at is None: self.expires_at = self.created_at + self.duration_minutes * 60 @property @@ -49,6 +49,8 @@ def is_expired(self) -> bool: def to_dict(self) -> dict: """Serialize override state to a dictionary.""" + now = time.time() + remaining_secs = max(0.0, self.expires_at - now) return { 'mode': self.mode, 'charge_rate': self.charge_rate, @@ -56,8 +58,8 @@ def to_dict(self) -> dict: 'reason': self.reason, 'created_at': self.created_at, 'expires_at': self.expires_at, - 'remaining_minutes': round(self.remaining_minutes, 1), - 'is_active': not self.is_expired, + 'remaining_minutes': round(remaining_secs / 60.0, 1), + 'is_active': now < self.expires_at, } diff --git a/tests/batcontrol/test_core.py b/tests/batcontrol/test_core.py index 07999f20..fcef6515 100644 --- a/tests/batcontrol/test_core.py +++ b/tests/batcontrol/test_core.py @@ -356,6 +356,74 @@ def test_logic_factory_accepts_string_resolution_as_int(self, resolution_str): assert logic.interval_minutes == int(resolution_str) +class TestApiSetModeAndChargeDuration: + """Tests for explicit duration_minutes in api_set_mode / api_set_charge_rate.""" + + @pytest.fixture + def bc(self): + """Minimal Batcontrol instance with external deps mocked.""" + with patch('batcontrol.core.tariff_factory.create_tarif_provider'), \ + patch('batcontrol.core.inverter_factory.create_inverter') as mock_inv, \ + patch('batcontrol.core.solar_factory.create_solar_provider'), \ + patch('batcontrol.core.consumption_factory.create_consumption'): + mock_inverter = MagicMock() + mock_inverter.max_pv_charge_rate = 3000 + mock_inverter.max_grid_charge_rate = 5000 + mock_inverter.get_max_capacity = MagicMock(return_value=10000) + mock_inv.return_value = mock_inverter + config = { + 'timezone': 'Europe/Berlin', + 'time_resolution_minutes': 60, + 'inverter': { + 'type': 'dummy', + 'max_grid_charge_rate': 5000, + 'max_pv_charge_rate': 3000, + 'min_pv_charge_rate': 100, + }, + 'utility': {'type': 'tibber', 'token': 'test_token'}, + 'pvinstallations': [], + 'consumption_forecast': {'type': 'simple', 'value': 500}, + 'battery_control': { + 'max_charging_from_grid_limit': 0.8, + 'min_price_difference': 0.05, + }, + 'mqtt': {'enabled': False}, + } + yield Batcontrol(config) + + def test_api_set_mode_explicit_duration_stored(self, bc): + """Explicit duration_minutes is used when provided.""" + bc.api_set_mode(0, duration_minutes=90) + override = bc.override_manager.get_override() + assert override is not None + assert override.duration_minutes == 90 + + def test_api_set_mode_invalid_duration_rejects(self, bc): + """Duration below 1 min is rejected; no override is created.""" + bc.api_set_mode(0, duration_minutes=0.5) + assert not bc.override_manager.is_active() + + def test_api_set_mode_none_duration_uses_mqtt_default(self, bc): + """None duration falls back to _mqtt_override_duration.""" + bc._mqtt_override_duration = 45.0 + bc.api_set_mode(0) + override = bc.override_manager.get_override() + assert override is not None + assert override.duration_minutes == 45.0 + + def test_api_set_charge_rate_explicit_duration_stored(self, bc): + """Explicit duration is passed through to the override.""" + bc.api_set_charge_rate(2000, duration_minutes=20) + override = bc.override_manager.get_override() + assert override is not None + assert override.duration_minutes == 20 + + def test_api_set_charge_rate_invalid_duration_rejects(self, bc): + """Duration above 1440 min is rejected; no override is created.""" + bc.api_set_charge_rate(2000, duration_minutes=1441) + assert not bc.override_manager.is_active() + + class TestOverrideDurationAndClear: """Tests for api_set_override_duration() and api_clear_override().""" diff --git a/tests/batcontrol/test_mcp_server.py b/tests/batcontrol/test_mcp_server.py index f7c98413..31c7839a 100644 --- a/tests/batcontrol/test_mcp_server.py +++ b/tests/batcontrol/test_mcp_server.py @@ -88,6 +88,8 @@ def mock_bc(self): bc.api_set_min_price_difference_rel = MagicMock() bc.api_set_production_offset = MagicMock() bc.api_apply_override = MagicMock() + # Wire api_clear_override to the real manager so override state is cleared + bc.api_clear_override = MagicMock(side_effect=bc.override_manager.clear_override) return bc @pytest.fixture From d7996e8a64d2bc43a97d053590d2ff95d2c6eb5d Mon Sep 17 00:00:00 2001 From: Matthias Strubel Date: Thu, 2 Apr 2026 17:41:49 +0200 Subject: [PATCH 11/13] Address fifth Copilot review: thread safety, MQTT precision, integer timestamps override_manager.py: - get_override() now returns a defensive copy via dataclasses.replace() instead of the live internal OverrideState instance; callers (including other threads) can no longer mutate expires_at/charge_rate without holding the manager lock, preserving the thread-safety guarantee mqtt_api.py: - publish_override_duration() changed from :.0f to :.1f so that fractional-minute durations (e.g. 45.5) are published accurately instead of being silently rounded to the nearest whole minute mcp_server.py: - _format_forecast_array() now uses integer floor division for base_time (int(run_time) // interval_seconds * interval_seconds) and casts each time_start to int, eliminating floating-point drift in JSON timestamps All 373 tests pass; pylint 9.46/10. Co-Authored-By: Claude Sonnet 4.6 --- src/batcontrol/mcp_server.py | 4 ++-- src/batcontrol/mqtt_api.py | 2 +- src/batcontrol/override_manager.py | 7 ++++--- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/batcontrol/mcp_server.py b/src/batcontrol/mcp_server.py index d8e7771c..52fffa06 100644 --- a/src/batcontrol/mcp_server.py +++ b/src/batcontrol/mcp_server.py @@ -60,12 +60,12 @@ def _format_forecast_array(arr, run_time: float, interval_minutes: int, if arr is None: return [] interval_seconds = interval_minutes * 60 - base_time = run_time - (run_time % interval_seconds) + base_time = (int(run_time) // interval_seconds) * interval_seconds result = [] for i, val in enumerate(arr): result.append({ 'slot': i, - 'time_start': base_time + i * interval_seconds, + 'time_start': int(base_time + i * interval_seconds), 'value': round(float(val), digits), }) return result diff --git a/src/batcontrol/mqtt_api.py b/src/batcontrol/mqtt_api.py index d4068722..3a1fc266 100644 --- a/src/batcontrol/mqtt_api.py +++ b/src/batcontrol/mqtt_api.py @@ -464,7 +464,7 @@ def publish_override_duration(self, duration_minutes: float) -> None: if self.client.is_connected(): self.client.publish( self.base_topic + '/override_duration', - f'{duration_minutes:.0f}') + f'{duration_minutes:.1f}') def publish_production_offset(self, production_offset: float) -> None: """ Publish the production offset percentage to MQTT diff --git a/src/batcontrol/override_manager.py b/src/batcontrol/override_manager.py index edbc772e..47b24b79 100644 --- a/src/batcontrol/override_manager.py +++ b/src/batcontrol/override_manager.py @@ -9,7 +9,7 @@ import time import threading import logging -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from typing import Optional logger = logging.getLogger(__name__) @@ -117,8 +117,9 @@ def clear_override(self) -> None: self._override = None def get_override(self) -> Optional[OverrideState]: - """Get the active override, or None if no override is active. + """Get a snapshot of the active override, or None if no override is active. + Returns a defensive copy so callers cannot mutate the internal state. Automatically clears expired overrides. """ with self._lock: @@ -131,7 +132,7 @@ def get_override(self) -> Optional[OverrideState]: ) self._override = None return None - return self._override + return replace(self._override) def is_active(self) -> bool: """Check if an override is currently active (not expired).""" From 02cc075dd71687d92758d7ccac4a6001a425116c Mon Sep 17 00:00:00 2001 From: Matthias Strubel Date: Thu, 2 Apr 2026 19:20:48 +0200 Subject: [PATCH 12/13] Fix skipped review 4051028264: MQTT clear converter and mode validation core.py: - Register clear_override/set MQTT callback with str converter instead of int; any payload (numeric, "true", empty string) now triggers the clear without failing conversion in _handle_message - Update api_clear_override signature to accept str (matches converter) override_manager.py: - Add VALID_OVERRIDE_MODES = {-1, 0, 8, 10} constant - set_override() now raises ValueError for unknown modes before creating the OverrideState; prevents the run() loop from blocking on an override that _apply_override() would silently ignore test_override_manager.py: - Add test_invalid_mode_raises to cover the new validation All 374 tests pass; pylint 9.46/10. Co-Authored-By: Claude Sonnet 4.6 --- src/batcontrol/core.py | 4 ++-- src/batcontrol/override_manager.py | 7 +++++++ tests/batcontrol/test_override_manager.py | 8 ++++++++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/batcontrol/core.py b/src/batcontrol/core.py index 7c79a103..abee37ac 100644 --- a/src/batcontrol/core.py +++ b/src/batcontrol/core.py @@ -281,7 +281,7 @@ def __init__(self, configdict: dict): self.mqtt_api.register_set_callback( 'clear_override', self.api_clear_override, - int + str # any payload (numeric, "true", empty) triggers the clear ) # Inverter Callbacks self.inverter.activate_mqtt(self.mqtt_api) @@ -982,7 +982,7 @@ def api_set_override_duration(self, duration_minutes: float): if self.mqtt_api is not None: self.mqtt_api.publish_override_duration(duration_minutes) - def api_clear_override(self, _value: int = 0): + def api_clear_override(self, _value: str = ""): """ Clear any active override and resume autonomous control. The value parameter is ignored (any value triggers the clear). diff --git a/src/batcontrol/override_manager.py b/src/batcontrol/override_manager.py index 47b24b79..0d57a1d9 100644 --- a/src/batcontrol/override_manager.py +++ b/src/batcontrol/override_manager.py @@ -17,6 +17,9 @@ # Default duration for MQTT overrides (backward compatible) DEFAULT_OVERRIDE_DURATION_MINUTES = 30 +# Valid inverter modes accepted by the override system +VALID_OVERRIDE_MODES = {-1, 0, 8, 10} + @dataclass class OverrideState: @@ -89,6 +92,10 @@ def set_override(self, mode: int, duration_minutes: Optional[float] = None, Returns: The created OverrideState """ + if mode not in VALID_OVERRIDE_MODES: + raise ValueError( + f"Invalid mode {mode}. Valid modes: {sorted(VALID_OVERRIDE_MODES)}") + if duration_minutes is None: duration_minutes = self.default_duration_minutes diff --git a/tests/batcontrol/test_override_manager.py b/tests/batcontrol/test_override_manager.py index 9554756b..50d00b9c 100644 --- a/tests/batcontrol/test_override_manager.py +++ b/tests/batcontrol/test_override_manager.py @@ -120,6 +120,14 @@ def test_invalid_duration_raises(self): with pytest.raises(ValueError): mgr.set_override(mode=0, duration_minutes=-5) + def test_invalid_mode_raises(self): + """Unknown mode should raise ValueError""" + mgr = OverrideManager() + with pytest.raises(ValueError): + mgr.set_override(mode=99, duration_minutes=30) + with pytest.raises(ValueError): + mgr.set_override(mode=5, duration_minutes=30) + def test_override_without_charge_rate(self): """Test override for non-charging modes""" mgr = OverrideManager() From e10787535eb8675319aea6bc2476c278d5b01629 Mon Sep 17 00:00:00 2001 From: Matthias Strubel Date: Thu, 2 Apr 2026 19:30:57 +0200 Subject: [PATCH 13/13] Fix type hints, extract mode constants, update docs for MQTT clarity - Fix api_set_charge_rate() type hint: float = None -> Optional[float] = None - Extract MODE_* constants to _modes.py to eliminate duplication between core.py and mcp_server.py without circular imports - Change mcp-server.md config example from host 0.0.0.0 to 127.0.0.1 to avoid exposing unauthenticated write tools on the network - Update override-manager.md: clear_override/set type is str, not int - Fix __main__.py null-safety: handle mcp: null in config (isinstance check) Co-Authored-By: Claude Sonnet 4.6 --- docs/mcp-server.md | 2 +- docs/override-manager.md | 4 ++-- src/batcontrol/__main__.py | 8 ++++++-- src/batcontrol/_modes.py | 19 +++++++++++++++++++ src/batcontrol/core.py | 24 ++++++++++++++++-------- src/batcontrol/mcp_server.py | 23 ++++++++--------------- 6 files changed, 52 insertions(+), 28 deletions(-) create mode 100644 src/batcontrol/_modes.py diff --git a/docs/mcp-server.md b/docs/mcp-server.md index f2c8e403..51213922 100644 --- a/docs/mcp-server.md +++ b/docs/mcp-server.md @@ -58,7 +58,7 @@ loop, sharing direct access to the `Batcontrol` instance — same pattern as `Mq mcp: enabled: false transport: http # 'http' for network, 'stdio' for pipe - host: 0.0.0.0 # Bind address + host: 127.0.0.1 # Bind address (use 0.0.0.0 only behind a trusted network boundary) port: 8081 # HTTP port ``` diff --git a/docs/override-manager.md b/docs/override-manager.md index 8c811f65..201f3b25 100644 --- a/docs/override-manager.md +++ b/docs/override-manager.md @@ -85,7 +85,7 @@ if override is not None: | Topic | Type | Description | |-------|------|-------------| | `override_duration/set` | float | Set duration in minutes (1–1440, 0=reset to 30) | -| `clear_override/set` | int | Any value clears active override | +| `clear_override/set` | str | Any payload clears active override (e.g. `"1"` or `"clear"`) | ## HA Auto Discovery @@ -110,7 +110,7 @@ Three entities registered: 2. Publish -1 to house/batcontrol/mode/set → Creates a 120-minute force-charge override 3. Override auto-expires after 120 min, OR: - Publish 1 to house/batcontrol/clear_override/set + Publish "1" to house/batcontrol/clear_override/set → Clears immediately, autonomous logic resumes next cycle ``` diff --git a/src/batcontrol/__main__.py b/src/batcontrol/__main__.py index f44f87e3..aaa02a38 100644 --- a/src/batcontrol/__main__.py +++ b/src/batcontrol/__main__.py @@ -96,7 +96,9 @@ def main() -> int: # pylint: disable=too-many-locals,too-many-statements # Both would share the same Batcontrol instance and compete for the port. # Copy the mcp section to avoid mutating the dict stored in bc.config later. if args.mcp_stdio: - config['mcp'] = {**config.get('mcp', {}), 'enabled': False} + existing_mcp = config.get('mcp') + config['mcp'] = {**(existing_mcp if isinstance(existing_mcp, dict) else {}), + 'enabled': False} bc = Batcontrol(config) @@ -110,7 +112,9 @@ def main() -> int: # pylint: disable=too-many-locals,too-many-statements del bc return 1 logger.info("Running MCP server in stdio mode") - mcp = mcp_module.BatcontrolMcpServer(bc, config.get('mcp', {})) + raw_mcp = config.get('mcp') + mcp = mcp_module.BatcontrolMcpServer( + bc, raw_mcp if isinstance(raw_mcp, dict) else {}) try: mcp.run_stdio() except KeyboardInterrupt: diff --git a/src/batcontrol/_modes.py b/src/batcontrol/_modes.py new file mode 100644 index 00000000..b735af12 --- /dev/null +++ b/src/batcontrol/_modes.py @@ -0,0 +1,19 @@ +"""Inverter mode constants shared by core.py and mcp_server.py. + +Extracted to avoid the circular import that would arise from mcp_server.py +importing directly from core.py (core.py imports mcp_server at startup). +""" + +MODE_ALLOW_DISCHARGING = 10 +MODE_LIMIT_BATTERY_CHARGE_RATE = 8 +MODE_AVOID_DISCHARGING = 0 +MODE_FORCE_CHARGING = -1 + +MODE_NAMES = { + MODE_FORCE_CHARGING: "Force Charge from Grid", + MODE_AVOID_DISCHARGING: "Avoid Discharge", + MODE_LIMIT_BATTERY_CHARGE_RATE: "Limit PV Charge Rate", + MODE_ALLOW_DISCHARGING: "Allow Discharge", +} + +VALID_MODES = set(MODE_NAMES.keys()) diff --git a/src/batcontrol/core.py b/src/batcontrol/core.py index abee37ac..b1217bfa 100644 --- a/src/batcontrol/core.py +++ b/src/batcontrol/core.py @@ -15,6 +15,7 @@ import os import logging import platform +from typing import Optional import pytz import numpy as np @@ -33,6 +34,12 @@ from .forecastconsumption import Consumption as consumption_factory from .override_manager import OverrideManager +from ._modes import ( + MODE_ALLOW_DISCHARGING, + MODE_LIMIT_BATTERY_CHARGE_RATE, + MODE_AVOID_DISCHARGING, + MODE_FORCE_CHARGING, +) from . import mcp_server as mcp_module ERROR_IGNORE_TIME = 600 # 10 Minutes @@ -44,11 +51,6 @@ MIN_FORECAST_HOURS = 1 # Minimum required forecast hours FORECAST_TOLERANCE = 3 # Acceptable tolerance for forecast hours -MODE_ALLOW_DISCHARGING = 10 -MODE_LIMIT_BATTERY_CHARGE_RATE = 8 # Limit PV charge, allow discharge -MODE_AVOID_DISCHARGING = 0 -MODE_FORCE_CHARGING = -1 - logger = logging.getLogger(__name__) @@ -307,7 +309,13 @@ def __init__(self, configdict: dict): # Initialize MCP server (optional, requires Python >=3.10 + mcp package) self.mcp_server = None - mcp_config = config.get('mcp', {}) + mcp_config = config.get('mcp') + if not isinstance(mcp_config, dict): + if mcp_config is not None: + logger.warning( + 'Invalid "mcp" configuration: expected a mapping, got %s. ' + 'Ignoring MCP settings.', type(mcp_config).__name__) + mcp_config = {} if mcp_config.get('enabled', False): if not mcp_module.is_available(): logger.warning( @@ -886,7 +894,7 @@ def _apply_override(self, override): elif mode == MODE_ALLOW_DISCHARGING: self.allow_discharging() - def api_set_mode(self, mode: int, duration_minutes: float = None): + def api_set_mode(self, mode: int, duration_minutes: Optional[float] = None): """ Log and change config run mode of inverter(s) from external call. Uses the OverrideManager for time-bounded overrides. @@ -923,7 +931,7 @@ def api_set_mode(self, mode: int, duration_minutes: float = None): ) self._apply_override(override) - def api_set_charge_rate(self, charge_rate: int, duration_minutes: float = None): + def api_set_charge_rate(self, charge_rate: int, duration_minutes: Optional[float] = None): """ Log and change config charge_rate and activate charging. Uses the OverrideManager for time-bounded overrides. diff --git a/src/batcontrol/mcp_server.py b/src/batcontrol/mcp_server.py index 52fffa06..c7ced7d2 100644 --- a/src/batcontrol/mcp_server.py +++ b/src/batcontrol/mcp_server.py @@ -28,22 +28,15 @@ def is_available() -> bool: """Check whether the MCP SDK is installed and importable.""" return MCP_AVAILABLE -logger = logging.getLogger(__name__) - -# Mode constants (duplicated to avoid circular imports) -MODE_ALLOW_DISCHARGING = 10 -MODE_LIMIT_BATTERY_CHARGE_RATE = 8 -MODE_AVOID_DISCHARGING = 0 -MODE_FORCE_CHARGING = -1 +from ._modes import ( + MODE_ALLOW_DISCHARGING, + MODE_LIMIT_BATTERY_CHARGE_RATE, + MODE_FORCE_CHARGING, + MODE_NAMES, + VALID_MODES, +) -MODE_NAMES = { - MODE_FORCE_CHARGING: "Force Charge from Grid", - MODE_AVOID_DISCHARGING: "Avoid Discharge", - MODE_LIMIT_BATTERY_CHARGE_RATE: "Limit PV Charge Rate", - MODE_ALLOW_DISCHARGING: "Allow Discharge", -} - -VALID_MODES = set(MODE_NAMES.keys()) +logger = logging.getLogger(__name__) def _format_forecast_array(arr, run_time: float, interval_minutes: int,