-
Notifications
You must be signed in to change notification settings - Fork 12
Add MCP server and duration-based override system for batcontrol #311
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
MaStr
wants to merge
13
commits into
main
Choose a base branch
from
claude/evaluate-mcp-server-Qk5i5
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
36115a4
Add MCP server implementation plan and todo
claude a8ff9b9
Add MCP server and duration-based override manager
claude 66341b3
Add MQTT override_duration/set and clear_override/set endpoints
claude 67b3f9b
Replace todo.md with structured docs for MCP, overrides, explainability
claude 79f562d
Make MCP an optional dependency for Python <3.10 compatibility
claude d692e08
Address PR review feedback for MCP server
MaStr a53f898
Address second round of PR review feedback
MaStr 3d0c5fd
Address PR review feedback: lint, API safety, and MCP host/port fix
MaStr 546d264
Address fourth Copilot review: transport warning, naming, epoch fix, …
MaStr 00e1835
Self-review fixes: sentinel, TOCTOU, MQTT publish, validation alignment
MaStr d7996e8
Address fifth Copilot review: thread safety, MQTT precision, integer …
MaStr 02cc075
Fix skipped review 4051028264: MQTT clear converter and mode validation
MaStr e107875
Fix type hints, extract mode constants, update docs for MQTT clarity
MaStr File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| } | ||
| ``` |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,120 @@ | ||
| # 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: 127.0.0.1 # Bind address (use 0.0.0.0 only behind a trusted network boundary) | ||
| 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) | ||
| - **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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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` | str | Any payload clears active override (e.g. `"1"` or `"clear"`) | | ||
|
|
||
| ## 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 | ||
| {} | ||
| ``` |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.