├── docs
├── Marstek_Device_Open_API_EN_.Rev1.0.pdf
└── DESIGN.md
├── hacs.json
├── .github
├── workflows
│ ├── validate.yml
│ └── release.yml
└── ISSUE_TEMPLATE
│ └── bug_report.yml
├── custom_components
└── marstek_local_api
│ ├── manifest.json
│ ├── strings.json
│ ├── translations
│ └── en.json
│ ├── const.py
│ ├── __init__.py
│ ├── diagnostics.py
│ ├── services.yaml
│ ├── binary_sensor.py
│ ├── compatibility.py
│ ├── button.py
│ ├── services.py
│ └── config_flow.py
├── .gitignore
├── tools
├── README.md
└── release.py
├── test
├── README.md
└── discover_api.py
└── README.md
/docs/Marstek_Device_Open_API_EN_.Rev1.0.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jaapp/ha-marstek-local-api/HEAD/docs/Marstek_Device_Open_API_EN_.Rev1.0.pdf
--------------------------------------------------------------------------------
/hacs.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Marstek Local API",
3 | "render_readme": true,
4 | "content_in_root": false,
5 | "filename": "marstek_local_api.zip",
6 | "zip_release": false
7 | }
8 |
--------------------------------------------------------------------------------
/.github/workflows/validate.yml:
--------------------------------------------------------------------------------
1 | name: Validate
2 |
3 | on:
4 | push:
5 | pull_request:
6 | schedule:
7 | - cron: "0 0 * * *"
8 |
9 | jobs:
10 | validate:
11 | runs-on: ubuntu-latest
12 | steps:
13 | - uses: actions/checkout@v4
14 | - name: HACS validation
15 | uses: hacs/action@main
16 | with:
17 | category: integration
18 | - name: Hassfest validation
19 | uses: home-assistant/actions/hassfest@master
20 |
--------------------------------------------------------------------------------
/custom_components/marstek_local_api/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "domain": "marstek_local_api",
3 | "name": "Marstek Local API",
4 | "codeowners": [
5 | "@jaapp"
6 | ],
7 | "config_flow": true,
8 | "dhcp": [
9 | {
10 | "macaddress": "60CF84*"
11 | }
12 | ],
13 | "documentation": "https://github.com/jaapp/ha-marstek-local-api",
14 | "integration_type": "device",
15 | "iot_class": "local_polling",
16 | "issue_tracker": "https://github.com/jaapp/ha-marstek-local-api/issues",
17 | "requirements": [],
18 | "version": "1.2.0.rc7"
19 | }
20 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # Distribution / packaging
7 | .Python
8 | build/
9 | develop-eggs/
10 | dist/
11 | downloads/
12 | eggs/
13 | .eggs/
14 | lib/
15 | lib64/
16 | parts/
17 | sdist/
18 | var/
19 | wheels/
20 | *.egg-info/
21 | .installed.cfg
22 | *.egg
23 |
24 | # PyCharm
25 | .idea/
26 |
27 | # VS Code
28 | .vscode/
29 |
30 | # macOS
31 | .DS_Store
32 |
33 | # pytest
34 | .pytest_cache/
35 |
36 | # mypy
37 | .mypy_cache/
38 |
39 | # Home Assistant
40 | home-assistant.log
41 | test/logs/
42 |
43 | # Git worktrees
44 | .worktrees/
45 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.yml:
--------------------------------------------------------------------------------
1 | name: Bug report
2 | about: Report a bug or unexpected behaviour — please include diagnostics and logs (required)
3 | title: "[BUG] "
4 | labels:
5 | - bug
6 | body:
7 | - type: checkboxes
8 | id: confirm_diagnostics
9 | attributes:
10 | label: Diagnostics confirmation
11 | description: Please confirm you have attached the integration diagnostics export and any relevant Home Assistant logs. (required)
12 | options:
13 | - label: I have attached diagnostics and relevant logs
14 | value: diagnostics_attached
15 | required: true
16 | - type: textarea
17 | id: diagnostics
18 | attributes:
19 | label: Diagnostics (attach or paste)
20 | description: Attach the diagnostics ZIP, paste diagnostics JSON, or provide a link to the uploaded file. Diagnostics are required.
21 | placeholder: Attach the diagnostics ZIP or paste a link/text here
22 | required: true
23 | - type: textarea
24 | id: description
25 | attributes:
26 | label: Issue description
27 | description: Briefly describe the problem (what happened, when, and any immediate symptoms).
28 | placeholder: e.g. Battery SOC stuck at 0% after firmware update
29 |
--------------------------------------------------------------------------------
/tools/README.md:
--------------------------------------------------------------------------------
1 | # Release Tooling
2 |
3 | `tools/release.py` automates Marstek Local API releases by bumping manifest
4 | versions, creating commits/tags, and optionally pushing or opening GitHub releases.
5 |
6 | ## Interactive workflow
7 |
8 | Running the script without arguments launches an interactive wizard:
9 |
10 | ```bash
11 | python tools/release.py
12 | ```
13 |
14 | The wizard mirrors the grinder release flow: it inspects the latest tag, shows the
15 | recent commit log, and offers numbered options (promote RC, patch/minor/major RC,
16 | continue RC cycle, or provide a custom version). Once confirmed, it updates the
17 | manifest(s), commits, tags, pushes, and creates the GitHub release. Enter `q` or
18 | press `Ctrl+C` at any prompt to cancel.
19 |
20 | ## Non-interactive examples
21 |
22 | ```bash
23 | # Create the next release candidate (auto-increment rc number)
24 | python tools/release.py rc 1.2.0 --skip-github
25 |
26 | # Create an explicit RC and skip the GitHub release
27 | python tools/release.py rc 1.2.0 --rc-number 3 --skip-github
28 |
29 | # Publish a final release with notes from a file and push everything
30 | python tools/release.py final 1.2.0 --notes-file notes.md --push
31 | ```
32 |
33 | Run `python tools/release.py --help` for the complete CLI reference.
34 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release Validation
2 |
3 | on:
4 | push:
5 | tags:
6 | - "v*.*.*"
7 | release:
8 | types:
9 | - published
10 |
11 | jobs:
12 | manifest-version:
13 | name: Verify Manifest Version
14 | runs-on: ubuntu-latest
15 | steps:
16 | - name: Checkout repository
17 | uses: actions/checkout@v4
18 |
19 | - name: Determine release tag
20 | id: tag
21 | shell: bash
22 | run: |
23 | if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
24 | echo "tag=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
25 | elif [ "${GITHUB_EVENT_NAME}" = "release" ]; then
26 | echo "tag=${{ github.event.release.tag_name }}" >> "$GITHUB_OUTPUT"
27 | else
28 | echo "Unsupported event type: ${GITHUB_EVENT_NAME}" >&2
29 | exit 1
30 | fi
31 |
32 | - name: Ensure manifest version matches release tag
33 | shell: bash
34 | run: |
35 | TAG_NAME="${{ steps.tag.outputs.tag }}"
36 | if [ -z "${TAG_NAME}" ]; then
37 | echo "Failed to determine release tag."
38 | exit 1
39 | fi
40 |
41 | TRIMMED_TAG="${TAG_NAME#v}"
42 | MANIFEST_VERSION=$(jq -r '.version' custom_components/marstek/manifest.json)
43 |
44 | echo "Tag: ${TAG_NAME}"
45 | echo "Manifest version: ${MANIFEST_VERSION}"
46 |
47 | if [ "${MANIFEST_VERSION}" != "${TRIMMED_TAG}" ]; then
48 | echo "Manifest version must match the release tag (without leading 'v')."
49 | exit 1
50 | fi
51 |
--------------------------------------------------------------------------------
/custom_components/marstek_local_api/strings.json:
--------------------------------------------------------------------------------
1 | {
2 | "config": {
3 | "step": {
4 | "user": {
5 | "title": "Marstek Local API",
6 | "description": "Set up Marstek device using Local API"
7 | },
8 | "discovery": {
9 | "title": "Discovered Devices",
10 | "description": "Select a discovered Marstek device or choose manual entry",
11 | "data": {
12 | "device": "Device"
13 | }
14 | },
15 | "manual": {
16 | "title": "Manual Configuration",
17 | "description": "Enter the IP address of your Marstek device",
18 | "data": {
19 | "host": "IP Address",
20 | "port": "Port"
21 | }
22 | },
23 | "discovery_confirm": {
24 | "title": "Discovered Marstek Device",
25 | "description": "A Marstek device ({name}) was discovered on your network. Do you want to add it to Home Assistant?"
26 | }
27 | },
28 | "error": {
29 | "cannot_connect": "Failed to connect to device",
30 | "device_not_found": "Selected device not found",
31 | "unknown": "Unexpected error"
32 | },
33 | "abort": {
34 | "already_configured": "Device is already configured",
35 | "cannot_connect": "Failed to connect to discovered device",
36 | "unknown": "Unknown error occurred during discovery"
37 | }
38 | },
39 | "options": {
40 | "step": {
41 | "init": {
42 | "title": "Marstek Local API Options",
43 | "description": "Select what you want to configure",
44 | "data": {
45 | "action": "Action"
46 | }
47 | },
48 | "scan_interval": {
49 | "title": "Update Interval",
50 | "description": "Configure how often data is refreshed",
51 | "data": {
52 | "scan_interval": "Update Interval (seconds)"
53 | }
54 | },
55 | "rename_device": {
56 | "title": "Rename Device",
57 | "description": "Update how the device appears in Home Assistant",
58 | "data": {
59 | "device": "Device",
60 | "name": "New name"
61 | }
62 | },
63 | "remove_device": {
64 | "title": "Remove Device",
65 | "description": "Select a device to remove from this integration",
66 | "data": {
67 | "device": "Device"
68 | }
69 | },
70 | "add_device": {
71 | "title": "Add Device",
72 | "description": "Select a discovered device or choose manual entry",
73 | "data": {
74 | "device": "Device"
75 | }
76 | },
77 | "add_device_manual": {
78 | "title": "Manual Device Entry",
79 | "description": "Enter the connection details for the device",
80 | "data": {
81 | "host": "IP Address",
82 | "port": "Port"
83 | }
84 | }
85 | },
86 | "error": {
87 | "invalid_name": "Provide a valid name",
88 | "cannot_remove_last_device": "At least one device must remain configured",
89 | "device_already_configured": "This device is already configured",
90 | "device_not_found": "Selected device not found",
91 | "cannot_connect": "Failed to connect to device",
92 | "unknown": "Unexpected error"
93 | }
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/custom_components/marstek_local_api/translations/en.json:
--------------------------------------------------------------------------------
1 | {
2 | "config": {
3 | "step": {
4 | "user": {
5 | "title": "Marstek Local API",
6 | "description": "Set up Marstek device using Local API"
7 | },
8 | "discovery": {
9 | "title": "Discovered Devices",
10 | "description": "Select a discovered Marstek device or choose manual entry",
11 | "data": {
12 | "device": "Device"
13 | }
14 | },
15 | "manual": {
16 | "title": "Manual Configuration",
17 | "description": "Enter the IP address of your Marstek device",
18 | "data": {
19 | "host": "IP Address",
20 | "port": "Port"
21 | }
22 | },
23 | "discovery_confirm": {
24 | "title": "Discovered Marstek Device",
25 | "description": "A Marstek device ({name}) was discovered on your network. Do you want to add it to Home Assistant?"
26 | }
27 | },
28 | "error": {
29 | "cannot_connect": "Failed to connect to device",
30 | "device_not_found": "Selected device not found",
31 | "unknown": "Unexpected error"
32 | },
33 | "abort": {
34 | "already_configured": "Device is already configured",
35 | "cannot_connect": "Failed to connect to discovered device",
36 | "unknown": "Unknown error occurred during discovery"
37 | }
38 | },
39 | "options": {
40 | "step": {
41 | "init": {
42 | "title": "Marstek Local API Options",
43 | "description": "Select what you want to configure",
44 | "data": {
45 | "action": "Action"
46 | }
47 | },
48 | "scan_interval": {
49 | "title": "Update Interval",
50 | "description": "Configure how often data is refreshed",
51 | "data": {
52 | "scan_interval": "Update Interval (seconds)"
53 | }
54 | },
55 | "rename_device": {
56 | "title": "Rename Device",
57 | "description": "Update how the device appears in Home Assistant",
58 | "data": {
59 | "device": "Device",
60 | "name": "New name"
61 | }
62 | },
63 | "remove_device": {
64 | "title": "Remove Device",
65 | "description": "Select a device to remove from this integration",
66 | "data": {
67 | "device": "Device"
68 | }
69 | },
70 | "add_device": {
71 | "title": "Add Device",
72 | "description": "Select a discovered device or choose manual entry",
73 | "data": {
74 | "device": "Device"
75 | }
76 | },
77 | "add_device_manual": {
78 | "title": "Manual Device Entry",
79 | "description": "Enter the connection details for the device",
80 | "data": {
81 | "host": "IP Address",
82 | "port": "Port"
83 | }
84 | }
85 | },
86 | "error": {
87 | "invalid_name": "Provide a valid name",
88 | "cannot_remove_last_device": "At least one device must remain configured",
89 | "device_already_configured": "This device is already configured",
90 | "device_not_found": "Selected device not found",
91 | "cannot_connect": "Failed to connect to device",
92 | "unknown": "Unexpected error"
93 | }
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/custom_components/marstek_local_api/const.py:
--------------------------------------------------------------------------------
1 | """Constants for the Marstek Local API integration."""
2 | from typing import Final
3 |
4 | DOMAIN: Final = "marstek_local_api"
5 |
6 | # Configuration keys
7 | CONF_PORT: Final = "port"
8 |
9 | # Default values
10 | DEFAULT_PORT: Final = 30000
11 | DEFAULT_SCAN_INTERVAL: Final = 60 # Base interval in seconds
12 | DISCOVERY_TIMEOUT: Final = 9 # Discovery window in seconds
13 | DISCOVERY_BROADCAST_INTERVAL: Final = 2 # Broadcast every 2 seconds during discovery
14 |
15 | # Update intervals (in multiples of base interval)
16 | UPDATE_INTERVAL_FAST: Final = 1 # ES, Battery status (60s)
17 | UPDATE_INTERVAL_MEDIUM: Final = 5 # EM, PV, Mode (300s)
18 | UPDATE_INTERVAL_SLOW: Final = 10 # Device, WiFi, BLE (600s)
19 |
20 | # Communication timeouts
21 | COMMAND_TIMEOUT: Final = 15 # Timeout for commands in seconds
22 | MAX_RETRIES: Final = 3 # Maximum retries for critical commands
23 | RETRY_DELAY: Final = 2 # Delay between retries in seconds
24 | COMMAND_MAX_ATTEMPTS: Final = 3 # Attempts per command before giving up
25 | COMMAND_BACKOFF_BASE: Final = 1.5 # Base delay for command retry backoff
26 | COMMAND_BACKOFF_FACTOR: Final = 2.0 # Multiplier for successive backoff delays
27 | COMMAND_BACKOFF_MAX: Final = 12.0 # Upper bound on backoff delay
28 | COMMAND_BACKOFF_JITTER: Final = 0.4 # Additional random jitter for backoff
29 | UNAVAILABLE_THRESHOLD: Final = 120 # Seconds before marking device unavailable
30 |
31 | # API Methods
32 | METHOD_GET_DEVICE: Final = "Marstek.GetDevice"
33 | METHOD_WIFI_STATUS: Final = "Wifi.GetStatus"
34 | METHOD_BLE_STATUS: Final = "BLE.GetStatus"
35 | METHOD_BATTERY_STATUS: Final = "Bat.GetStatus"
36 | METHOD_PV_STATUS: Final = "PV.GetStatus"
37 | METHOD_ES_STATUS: Final = "ES.GetStatus"
38 | METHOD_ES_MODE: Final = "ES.GetMode"
39 | METHOD_ES_SET_MODE: Final = "ES.SetMode"
40 | METHOD_EM_STATUS: Final = "EM.GetStatus"
41 |
42 | # All API methods for compatibility tracking
43 | ALL_API_METHODS: Final = [
44 | METHOD_GET_DEVICE,
45 | METHOD_WIFI_STATUS,
46 | METHOD_BLE_STATUS,
47 | METHOD_BATTERY_STATUS,
48 | METHOD_PV_STATUS,
49 | METHOD_ES_STATUS,
50 | METHOD_ES_MODE,
51 | METHOD_EM_STATUS,
52 | ]
53 |
54 | # JSON-RPC Error Codes (from API spec)
55 | ERROR_PARSE_ERROR: Final = -32700
56 | ERROR_INVALID_REQUEST: Final = -32600
57 | ERROR_METHOD_NOT_FOUND: Final = -32601
58 | ERROR_INVALID_PARAMS: Final = -32602
59 | ERROR_INTERNAL_ERROR: Final = -32603
60 |
61 | # Device models
62 | DEVICE_MODEL_VENUS_C: Final = "VenusC"
63 | DEVICE_MODEL_VENUS_D: Final = "VenusD"
64 | DEVICE_MODEL_VENUS_E: Final = "VenusE"
65 |
66 | # Operating modes
67 | MODE_AUTO: Final = "Auto"
68 | MODE_AI: Final = "AI"
69 | MODE_MANUAL: Final = "Manual"
70 | MODE_PASSIVE: Final = "Passive"
71 |
72 | OPERATING_MODES: Final = [MODE_AUTO, MODE_AI, MODE_MANUAL, MODE_PASSIVE]
73 |
74 | # Battery states
75 | BATTERY_STATE_IDLE: Final = "idle"
76 | BATTERY_STATE_CHARGING: Final = "charging"
77 | BATTERY_STATE_DISCHARGING: Final = "discharging"
78 |
79 | # Bluetooth states
80 | BLE_STATE_CONNECT: Final = "connect"
81 | BLE_STATE_DISCONNECT: Final = "disconnect"
82 |
83 | # CT states
84 | CT_STATE_DISCONNECTED: Final = 0
85 | CT_STATE_CONNECTED: Final = 1
86 |
87 | # Data keys
88 | DATA_COORDINATOR: Final = "coordinator"
89 | DATA_DEVICE_INFO: Final = "device_info"
90 |
91 | # Platforms
92 | PLATFORMS: Final = ["sensor", "binary_sensor", "button"]
93 |
94 | # Services
95 | SERVICE_REQUEST_SYNC: Final = "request_data_sync"
96 | SERVICE_SET_MANUAL_SCHEDULE: Final = "set_manual_schedule"
97 | SERVICE_SET_MANUAL_SCHEDULES: Final = "set_manual_schedules"
98 | SERVICE_CLEAR_MANUAL_SCHEDULES: Final = "clear_manual_schedules"
99 | SERVICE_SET_PASSIVE_MODE: Final = "set_passive_mode"
100 |
101 | # Schedule configuration
102 | WEEKDAY_MAP: Final = {
103 | "mon": 1, # 0000001
104 | "tue": 2, # 0000010
105 | "wed": 4, # 0000100
106 | "thu": 8, # 0001000
107 | "fri": 16, # 0010000
108 | "sat": 32, # 0100000
109 | "sun": 64, # 1000000
110 | }
111 | WEEKDAYS_ALL: Final = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
112 | MAX_SCHEDULE_SLOTS: Final = 10 # Venus C/E supports slots 0-9
113 |
--------------------------------------------------------------------------------
/custom_components/marstek_local_api/__init__.py:
--------------------------------------------------------------------------------
1 | """The Marstek Local API integration."""
2 | from __future__ import annotations
3 |
4 | import logging
5 |
6 | from homeassistant.config_entries import ConfigEntry
7 | from homeassistant.const import CONF_HOST, Platform
8 | from homeassistant.core import HomeAssistant
9 |
10 | from .api import MarstekUDPClient
11 | from .const import (
12 | CONF_PORT,
13 | DATA_COORDINATOR,
14 | DEFAULT_SCAN_INTERVAL,
15 | DOMAIN,
16 | )
17 | from .coordinator import MarstekDataUpdateCoordinator, MarstekMultiDeviceCoordinator
18 | from .services import async_setup_services, async_unload_services
19 |
20 | _LOGGER = logging.getLogger(__name__)
21 |
22 | PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.BINARY_SENSOR, Platform.BUTTON]
23 |
24 |
25 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
26 | """Set up Marstek Local API from a config entry."""
27 | hass.data.setdefault(DOMAIN, {})
28 |
29 | # Get scan interval from options (Design Doc §297-302)
30 | scan_interval = entry.options.get("scan_interval", DEFAULT_SCAN_INTERVAL)
31 |
32 | # Check if this is a multi-device or single-device entry
33 | if "devices" in entry.data:
34 | # Multi-device mode
35 | _LOGGER.info("Setting up multi-device entry with %d devices", len(entry.data["devices"]))
36 |
37 | # Create multi-device coordinator
38 | coordinator = MarstekMultiDeviceCoordinator(
39 | hass,
40 | devices=entry.data["devices"],
41 | scan_interval=scan_interval,
42 | config_entry=entry,
43 | )
44 |
45 | # Set up device coordinators
46 | await coordinator.async_setup()
47 |
48 | # Fetch initial data
49 | await coordinator.async_config_entry_first_refresh()
50 |
51 | else:
52 | # Single device mode (legacy/backwards compatibility)
53 | _LOGGER.info("Setting up single-device entry")
54 |
55 | # Create API client
56 | # Bind to same port as device (required by Marstek protocol)
57 | # Use reuse_port to allow multiple instances
58 | api = MarstekUDPClient(
59 | hass,
60 | host=entry.data[CONF_HOST],
61 | port=entry.data[CONF_PORT], # Bind to device port (with reuse_port)
62 | remote_port=entry.data[CONF_PORT], # Send to device port
63 | )
64 |
65 | # Connect to device
66 | try:
67 | await api.connect()
68 | except Exception as err:
69 | _LOGGER.error("Failed to connect to Marstek device: %s", err)
70 | return False
71 |
72 | # Create coordinator
73 | coordinator = MarstekDataUpdateCoordinator(
74 | hass,
75 | api,
76 | device_name=entry.data.get("device", "Marstek Device"),
77 | firmware_version=entry.data.get("firmware", 0),
78 | device_model=entry.data.get("device", ""),
79 | scan_interval=scan_interval,
80 | config_entry=entry,
81 | )
82 |
83 | # Fetch initial data
84 | await coordinator.async_config_entry_first_refresh()
85 |
86 | # Store coordinator
87 | hass.data[DOMAIN][entry.entry_id] = {
88 | DATA_COORDINATOR: coordinator,
89 | }
90 |
91 | if len(hass.data[DOMAIN]) == 1:
92 | await async_setup_services(hass)
93 |
94 | # Register options update listener
95 | entry.async_on_unload(entry.add_update_listener(async_reload_entry))
96 |
97 | # Forward entry setup to platforms
98 | await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
99 |
100 | return True
101 |
102 |
103 | async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
104 | """Reload the config entry when options change."""
105 | await hass.config_entries.async_reload(entry.entry_id)
106 |
107 |
108 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
109 | """Unload a config entry."""
110 | # Unload platforms
111 | unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
112 |
113 | if unload_ok:
114 | # Disconnect API(s)
115 | coordinator = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR]
116 |
117 | if isinstance(coordinator, MarstekMultiDeviceCoordinator):
118 | # Disconnect all device APIs
119 | for device_coordinator in coordinator.device_coordinators.values():
120 | await device_coordinator.api.disconnect()
121 | else:
122 | # Single device coordinator
123 | await coordinator.api.disconnect()
124 |
125 | # Remove entry from domain data
126 | hass.data[DOMAIN].pop(entry.entry_id)
127 |
128 | if not hass.data[DOMAIN]:
129 | await async_unload_services(hass)
130 |
131 | return unload_ok
132 |
--------------------------------------------------------------------------------
/custom_components/marstek_local_api/diagnostics.py:
--------------------------------------------------------------------------------
1 | """Diagnostics support for Marstek Local API."""
2 | from __future__ import annotations
3 |
4 | from typing import Any
5 |
6 | from homeassistant.config_entries import ConfigEntry
7 | from homeassistant.core import HomeAssistant
8 | from homeassistant.helpers.redact import async_redact_data
9 |
10 | from .const import DATA_COORDINATOR, DOMAIN
11 | from .coordinator import MarstekDataUpdateCoordinator, MarstekMultiDeviceCoordinator
12 |
13 | TO_REDACT = ["wifi_name", "ssid"]
14 |
15 |
16 | def _command_compatibility_summary(command_stats: dict[str, Any]) -> dict[str, Any]:
17 | """Generate compatibility summary from command statistics."""
18 | supported = []
19 | unsupported = []
20 | unknown = []
21 |
22 | for method, stats in command_stats.items():
23 | support_status = stats.get("supported")
24 | if support_status is True:
25 | supported.append(method)
26 | elif support_status is False:
27 | unsupported.append(method)
28 | else:
29 | unknown.append(method)
30 |
31 | return {
32 | "supported_commands": supported,
33 | "unsupported_commands": unsupported,
34 | "unknown_commands": unknown,
35 | "support_ratio": f"{len(supported)}/{len(command_stats)}",
36 | }
37 |
38 |
39 | def _command_stats_snapshot(coordinator: MarstekDataUpdateCoordinator) -> dict[str, Any]:
40 | """Get all command statistics for compatibility tracking."""
41 | return coordinator.api.get_all_command_stats()
42 |
43 |
44 | def _coordinator_snapshot(coordinator: MarstekDataUpdateCoordinator) -> dict[str, Any]:
45 | diagnostic_payload = coordinator.data.get("_diagnostic") if coordinator.data else None
46 | update_interval = coordinator.update_interval.total_seconds() if coordinator.update_interval else None
47 |
48 | # Get device identification from coordinator data
49 | device_info = coordinator.data.get("device", {}) if coordinator.data else {}
50 |
51 | # Get command stats
52 | command_stats = _command_stats_snapshot(coordinator)
53 | compatibility_summary = _command_compatibility_summary(command_stats)
54 |
55 | snapshot = {
56 | # Device identification
57 | "device_model": device_info.get("device") or coordinator.device_model,
58 | "firmware_version": device_info.get("ver") or coordinator.firmware_version,
59 | "ble_mac": device_info.get("ble_mac"),
60 | "wifi_mac": device_info.get("wifi_mac"),
61 | "wifi_name": device_info.get("wifi_name"),
62 | "device_ip": device_info.get("ip"),
63 |
64 | # Coordinator info
65 | "device_name": coordinator.name,
66 | "update_interval": update_interval,
67 | "update_count": coordinator.update_count,
68 | "last_update_started": coordinator._last_update_start, # pylint: disable=protected-access
69 |
70 | # Current sensor data
71 | "sensor_data": coordinator.data,
72 |
73 | # Diagnostic payload
74 | "diagnostic_payload": diagnostic_payload,
75 |
76 | # Command compatibility matrix
77 | "command_compatibility": command_stats,
78 | "compatibility_summary": compatibility_summary,
79 | }
80 |
81 | return async_redact_data(snapshot, TO_REDACT)
82 |
83 |
84 | def _multi_diagnostics(coordinator: MarstekMultiDeviceCoordinator) -> dict[str, Any]:
85 | devices: dict[str, Any] = {}
86 | for mac, device_coordinator in coordinator.device_coordinators.items():
87 | devices[mac] = _coordinator_snapshot(device_coordinator)
88 |
89 | aggregates = coordinator.data.get("aggregates") if coordinator.data else None
90 |
91 | return {
92 | "requested_interval": coordinator.update_interval.total_seconds() if coordinator.update_interval else None,
93 | "diagnostic_payload": coordinator.data.get("_diagnostic") if coordinator.data else None,
94 | "devices": devices,
95 | "aggregates": aggregates,
96 | }
97 |
98 |
99 | async def async_get_config_entry_diagnostics(
100 | hass: HomeAssistant, entry: ConfigEntry
101 | ) -> dict[str, Any]:
102 | """Return diagnostics for a config entry."""
103 | data = hass.data.get(DOMAIN, {}).get(entry.entry_id)
104 | if not data:
105 | return {"error": "integration_not_initialized"}
106 |
107 | coordinator = data.get(DATA_COORDINATOR)
108 |
109 | if isinstance(coordinator, MarstekMultiDeviceCoordinator):
110 | return {
111 | "entry": {
112 | "title": entry.title,
113 | "device_count": len(coordinator.device_coordinators),
114 | },
115 | "multi": _multi_diagnostics(coordinator),
116 | }
117 |
118 | if isinstance(coordinator, MarstekDataUpdateCoordinator):
119 | return {
120 | "entry": {
121 | "title": entry.title,
122 | "device": entry.data.get("device"),
123 | "ble_mac": entry.data.get("ble_mac"),
124 | },
125 | "device": _coordinator_snapshot(coordinator),
126 | }
127 |
128 | return {"error": "unknown_coordinator"}
129 |
--------------------------------------------------------------------------------
/custom_components/marstek_local_api/services.yaml:
--------------------------------------------------------------------------------
1 | request_data_sync:
2 | name: Request Data Sync
3 | description: Trigger an immediate data refresh from every configured Marstek device.
4 | fields:
5 | entry_id:
6 | name: Config Entry ID
7 | description: Optional config entry ID to refresh. When omitted, all entries are refreshed.
8 | example: "1234567890abcdef1234567890abcdef"
9 | device_id:
10 | name: Battery
11 | description: Optional battery to refresh. When omitted, every active battery is refreshed.
12 | selector:
13 | device:
14 | integration: marstek_local_api
15 |
16 | set_manual_schedule:
17 | name: Set Manual Schedule
18 | description: Configure a single manual mode time schedule slot for the selected battery.
19 | fields:
20 | device_id:
21 | name: Battery
22 | description: "Select the battery whose manual mode schedules you want to configure."
23 | required: true
24 | selector:
25 | device:
26 | integration: marstek_local_api
27 | time_num:
28 | name: Schedule Slot
29 | description: Schedule slot number (0-9). Each slot is an independent schedule.
30 | required: true
31 | example: 0
32 | selector:
33 | number:
34 | min: 0
35 | max: 9
36 | mode: slider
37 | start_time:
38 | name: Start Time
39 | description: When to start this schedule (24-hour format).
40 | required: true
41 | example: "08:00"
42 | selector:
43 | time:
44 | end_time:
45 | name: End Time
46 | description: When to end this schedule (24-hour format).
47 | required: true
48 | example: "16:00"
49 | selector:
50 | time:
51 | days:
52 | name: Days of Week
53 | description: Which days this schedule applies to.
54 | default: ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
55 | example: ["mon", "tue", "wed", "thu", "fri"]
56 | selector:
57 | select:
58 | multiple: true
59 | options:
60 | - label: Monday
61 | value: mon
62 | - label: Tuesday
63 | value: tue
64 | - label: Wednesday
65 | value: wed
66 | - label: Thursday
67 | value: thu
68 | - label: Friday
69 | value: fri
70 | - label: Saturday
71 | value: sat
72 | - label: Sunday
73 | value: sun
74 | power:
75 | name: Power Limit
76 | description: Power limit in watts. Use negative values for charging (e.g., -2000 = 2000W charge limit) and positive values for discharging (e.g., 800 = 800W discharge limit). Use 0 for no limit.
77 | default: 0
78 | example: -2000
79 | selector:
80 | number:
81 | min: -10000
82 | max: 10000
83 | step: 100
84 | unit_of_measurement: W
85 | enabled:
86 | name: Enabled
87 | description: Whether this schedule is active.
88 | default: true
89 | selector:
90 | boolean:
91 |
92 | set_manual_schedules:
93 | name: Set Multiple Manual Schedules
94 | description: Configure manual mode schedules using YAML for the selected battery.
95 | fields:
96 | device_id:
97 | name: Battery
98 | description: "Select the battery whose manual mode schedules you want to configure."
99 | required: true
100 | selector:
101 | device:
102 | integration: marstek_local_api
103 | schedules:
104 | name: Schedules
105 | description: List of schedules to configure (YAML list). Each item must include time_num, start_time, end_time, and optionally days, power, and enabled. Power uses negative values for charging and positive values for discharging.
106 | required: true
107 | example: |
108 | # Slot 0 - weekday charge window
109 | - time_num: 0
110 | start_time: "08:00"
111 | end_time: "16:00"
112 | days: ["mon", "tue", "wed", "thu", "fri"]
113 | power: -2000
114 | enabled: true
115 | # Slot 1 - weekday discharge window
116 | - time_num: 1
117 | start_time: "18:00"
118 | end_time: "22:00"
119 | days: ["mon", "tue", "wed", "thu", "fri"]
120 | power: 800
121 | enabled: true
122 | selector:
123 | object:
124 |
125 | clear_manual_schedules:
126 | name: Clear Manual Schedules
127 | description: Remove all configured manual mode schedules by disabling all 10 schedule slots on the selected battery.
128 | fields:
129 | device_id:
130 | name: Battery
131 | description: "Select the battery whose schedules you want to clear."
132 | required: true
133 | selector:
134 | device:
135 | integration: marstek_local_api
136 |
137 | set_passive_mode:
138 | name: Set Passive Mode
139 | description: Switch the selected battery to Passive mode with specified power and duration. Negative power values charge (e.g., -2000 = charge at 2000W), positive values discharge (e.g., 1500 = discharge at 1500W).
140 | fields:
141 | device_id:
142 | name: Battery
143 | description: "Select the battery whose passive mode you want to change."
144 | required: true
145 | selector:
146 | device:
147 | integration: marstek_local_api
148 | power:
149 | name: Power
150 | description: Power in watts. Negative values = charging (e.g., -2000 = charge at 2000W), positive values = discharging (e.g., 1500 = discharge at 1500W).
151 | required: true
152 | example: -2000
153 | selector:
154 | number:
155 | min: -10000
156 | max: 10000
157 | step: 100
158 | unit_of_measurement: W
159 | duration:
160 | name: Duration
161 | description: Duration in seconds (1-86400). For example, 3600 = 1 hour.
162 | required: true
163 | example: 3600
164 | selector:
165 | number:
166 | min: 1
167 | max: 86400
168 | step: 60
169 | unit_of_measurement: s
170 |
--------------------------------------------------------------------------------
/test/README.md:
--------------------------------------------------------------------------------
1 | # Marstek Local API - Test Suite
2 |
3 | ## Overview
4 |
5 | This test suite validates the integration components in isolation without requiring a full Home Assistant installation.
6 |
7 | ## Test Scripts
8 |
9 | ### `test_tool.py`
10 |
11 | Standalone test tool that:
12 | - Discovers Marstek devices on the local network
13 | - Tests all API methods (device info, WiFi, BLE, battery, energy system, etc.)
14 | - Provides commands to apply/clear manual schedules, set passive mode, and switch operating modes
15 | - Applies firmware-specific value scaling
16 | - Calculates derived sensors (power in/out, battery state, available capacity)
17 | - Displays all sensor data in a formatted terminal output
18 |
19 | ## Requirements
20 |
21 | - Python 3.10+
22 | - No additional dependencies required (uses only stdlib)
23 | - Marstek device with Local API enabled
24 |
25 | ## Running Tests
26 |
27 | ### Quick Test
28 |
29 | ```bash
30 | cd marstek-local-api
31 | python3 test/test_tool.py discover
32 | ```
33 |
34 | ### Expected Output
35 |
36 | If devices are found:
37 | ```
38 | ================================================================================
39 | Marstek Local API Integration - Standalone Test
40 | ================================================================================
41 |
42 | Step 1: Discovering devices on network...
43 | Broadcasting on port 30000...
44 |
45 | ✅ Found 1 device(s):
46 |
47 | Device 1:
48 | Model: VenusE
49 | IP Address: 192.168.1.10
50 | MAC: AABBCCDDEEFF
51 | Firmware: v111
52 |
53 | ================================================================================
54 | Testing Device 1: VenusE (192.168.1.10)
55 | ================================================================================
56 |
57 | 📋 Device Information
58 | --------------------------------------------------------------------------------
59 | Device Model: VenusE
60 | Firmware Version: 111
61 | BLE MAC: AABBCCDDEEFF
62 | WiFi MAC: AABBCCDDEEFF
63 | WiFi Name: MY_WIFI
64 | IP Address: 192.168.1.10
65 |
66 | 📶 WiFi Status
67 | --------------------------------------------------------------------------------
68 | SSID: MY_WIFI
69 | Signal Strength: -45 dBm
70 | IP Address: 192.168.1.10
71 | Gateway: 192.168.1.1
72 | Subnet Mask: 255.255.255.0
73 | DNS Server: 192.168.1.1
74 |
75 | 🔋 Battery Status
76 | --------------------------------------------------------------------------------
77 | State of Charge: 98%
78 | Temperature: 25.0°C
79 | Remaining Capacity: 2508.0 Wh
80 | Rated Capacity: 2560.0 Wh
81 | Available Capacity: 51.2 Wh
82 | Charging Enabled: True
83 | Discharging Enabled: True
84 |
85 | ⚡ Energy System Status
86 | --------------------------------------------------------------------------------
87 | Battery Power: -150 W
88 | Battery State: discharging
89 | Battery Power In: 0 W
90 | Battery Power Out: 150 W
91 | Grid Power: 100 W
92 | Total Grid Import: 1607 Wh
93 | Total Grid Export: 844 Wh
94 |
95 | [... more sections ...]
96 | ```
97 |
98 | If no devices found:
99 | ```
100 | ❌ No devices found!
101 |
102 | Troubleshooting:
103 | 1. Ensure Marstek device is powered on
104 | 2. Check Local API is enabled in Marstek app
105 | 3. Verify device and computer are on same network
106 | 4. Check firewall allows UDP port 30000
107 | ```
108 |
109 | ## How It Works
110 |
111 | The test script:
112 |
113 | 1. **Loads Integration Components Directly**
114 | - Uses `importlib.util` to load integration modules without Home Assistant
115 | - Creates fake package structure in `sys.modules` to support relative imports
116 | - Mocks Home Assistant framework modules (homeassistant.core, etc.)
117 | - Loads actual integration code: `api.py`, `const.py`, `coordinator.py`, `sensor.py`
118 | - No Home Assistant installation required
119 |
120 | 2. **Discovers Devices**
121 | - Broadcasts UDP discovery message on port 30000
122 | - Waits 9 seconds for responses
123 | - Parses device information from responses
124 |
125 | 3. **Tests All API Methods**
126 | - Connects to each discovered device
127 | - Calls all API methods (GetDevice, WiFi, BLE, Battery, ES, EM, PV)
128 | - Uses **actual coordinator scaling logic** (no duplication)
129 | - Uses **actual sensor value_fn lambdas** for calculated sensors (no duplication)
130 |
131 | 4. **Displays Results**
132 | - Formatted terminal output with sections
133 | - Shows all sensor values with units
134 | - Handles missing/null values gracefully
135 |
136 | ## Testing Strategy
137 |
138 | This test script validates:
139 | - ✅ UDP communication and discovery protocol
140 | - ✅ API method implementations
141 | - ✅ Firmware version detection and value scaling (using real coordinator code)
142 | - ✅ Calculated sensor logic (using real sensor definitions)
143 | - ✅ Error handling and graceful degradation
144 |
145 | **Key Principle**: The test imports and uses the actual integration code - no logic duplication. This ensures:
146 | - Changes to scaling logic are automatically reflected in tests
147 | - Changes to sensor calculations are automatically reflected in tests
148 | - Tests validate the real implementation, not a reimplementation
149 |
150 | ## Integration with HACS
151 |
152 | While this test runs standalone, it uses the **actual integration components** from:
153 | - `custom_components/marstek_local_api/api.py` - UDP client and API methods
154 | - `custom_components/marstek_local_api/const.py` - Constants and thresholds
155 | - `custom_components/marstek_local_api/coordinator.py` - Firmware scaling logic
156 | - `custom_components/marstek_local_api/sensor.py` - Sensor definitions and calculated values
157 |
158 | This ensures the test validates the real code that will run in Home Assistant.
159 |
160 | ## Troubleshooting
161 |
162 | ### Import Errors
163 |
164 | If you get import errors, ensure you're running from the repository root:
165 | ```bash
166 | cd /path/to/marstek-local-api
167 | python3 test/test_tool.py discover
168 | ```
169 |
170 | ### No Devices Found
171 |
172 | - Check device is powered on and connected to WiFi
173 | - Verify Local API is enabled in Marstek mobile app
174 | - Ensure computer and device are on same network
175 | - Check firewall allows UDP port 30000
176 |
177 | ### Timeout Errors
178 |
179 | - Discovery timeout is 9 seconds (configurable in code)
180 | - API command timeout is 15 seconds per method
181 | - Network congestion may cause delays
182 |
183 | ## Future Tests
184 |
185 | Planned additions:
186 | - Unit tests for value scaling logic
187 | - Mock device responses for CI/CD
188 | - Performance tests for concurrent device polling
189 | - Integration tests with Home Assistant test framework
190 |
--------------------------------------------------------------------------------
/custom_components/marstek_local_api/binary_sensor.py:
--------------------------------------------------------------------------------
1 | """Binary sensor platform for Marstek Local API."""
2 | from __future__ import annotations
3 |
4 | from collections.abc import Callable
5 | from dataclasses import dataclass
6 | import logging
7 |
8 | from homeassistant.components.binary_sensor import (
9 | BinarySensorDeviceClass,
10 | BinarySensorEntity,
11 | BinarySensorEntityDescription,
12 | )
13 | from homeassistant.config_entries import ConfigEntry
14 | from homeassistant.core import HomeAssistant
15 | from homeassistant.helpers.entity import DeviceInfo
16 | from homeassistant.helpers.entity_platform import AddEntitiesCallback
17 | from homeassistant.helpers.update_coordinator import CoordinatorEntity
18 |
19 | from .const import (
20 | BLE_STATE_CONNECT,
21 | CT_STATE_CONNECTED,
22 | DATA_COORDINATOR,
23 | DOMAIN,
24 | )
25 | from .coordinator import MarstekDataUpdateCoordinator, MarstekMultiDeviceCoordinator
26 |
27 | _LOGGER = logging.getLogger(__name__)
28 |
29 |
30 | @dataclass
31 | class MarstekBinarySensorEntityDescription(BinarySensorEntityDescription):
32 | """Describes Marstek binary sensor entity."""
33 |
34 | value_fn: Callable[[dict], bool] | None = None
35 | available_fn: Callable[[dict], bool] | None = None
36 |
37 |
38 | BINARY_SENSOR_TYPES: tuple[MarstekBinarySensorEntityDescription, ...] = (
39 | # Battery charging/discharging flags
40 | MarstekBinarySensorEntityDescription(
41 | key="charging_enabled",
42 | name="Charging enabled",
43 | device_class=BinarySensorDeviceClass.BATTERY_CHARGING,
44 | value_fn=lambda data: data.get("battery", {}).get("charg_flag", False),
45 | ),
46 | MarstekBinarySensorEntityDescription(
47 | key="discharging_enabled",
48 | name="Discharging enabled",
49 | value_fn=lambda data: data.get("battery", {}).get("dischrg_flag", False),
50 | ),
51 | # Bluetooth connection
52 | MarstekBinarySensorEntityDescription(
53 | key="bluetooth_connected",
54 | name="Bluetooth connected",
55 | device_class=BinarySensorDeviceClass.CONNECTIVITY,
56 | value_fn=lambda data: data.get("ble", {}).get("state") == BLE_STATE_CONNECT,
57 | ),
58 | # CT connection
59 | MarstekBinarySensorEntityDescription(
60 | key="ct_connected",
61 | name="CT connected",
62 | device_class=BinarySensorDeviceClass.CONNECTIVITY,
63 | value_fn=lambda data: data.get("em", {}).get("ct_state") == CT_STATE_CONNECTED,
64 | ),
65 | )
66 |
67 |
68 | async def async_setup_entry(
69 | hass: HomeAssistant,
70 | entry: ConfigEntry,
71 | async_add_entities: AddEntitiesCallback,
72 | ) -> None:
73 | """Set up Marstek binary sensor based on a config entry."""
74 | coordinator = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR]
75 |
76 | entities = []
77 |
78 | # Check if multi-device or single-device mode
79 | if isinstance(coordinator, MarstekMultiDeviceCoordinator):
80 | # Multi-device mode - create binary sensors for each device
81 | for mac in coordinator.get_device_macs():
82 | device_coordinator = coordinator.device_coordinators[mac]
83 | device_data = next(d for d in coordinator.devices if (d.get("ble_mac") or d.get("wifi_mac")) == mac)
84 |
85 | for description in BINARY_SENSOR_TYPES:
86 | entities.append(
87 | MarstekMultiDeviceBinarySensor(
88 | coordinator=coordinator,
89 | device_coordinator=device_coordinator,
90 | entity_description=description,
91 | device_mac=mac,
92 | device_data=device_data,
93 | )
94 | )
95 | else:
96 | # Single device mode (legacy)
97 | for description in BINARY_SENSOR_TYPES:
98 | entities.append(
99 | MarstekBinarySensor(
100 | coordinator=coordinator,
101 | entity_description=description,
102 | entry=entry,
103 | )
104 | )
105 |
106 | async_add_entities(entities)
107 |
108 |
109 | class MarstekBinarySensor(CoordinatorEntity, BinarySensorEntity):
110 | """Representation of a Marstek binary sensor."""
111 |
112 | entity_description: MarstekBinarySensorEntityDescription
113 |
114 | def __init__(
115 | self,
116 | coordinator: MarstekDataUpdateCoordinator,
117 | entity_description: MarstekBinarySensorEntityDescription,
118 | entry: ConfigEntry,
119 | ) -> None:
120 | """Initialize the binary sensor."""
121 | super().__init__(coordinator)
122 | self.entity_description = entity_description
123 | self._attr_has_entity_name = True
124 | device_mac = entry.data.get("ble_mac") or entry.data.get("wifi_mac")
125 | self._attr_unique_id = f"{device_mac}_{entity_description.key}"
126 | self._attr_device_info = DeviceInfo(
127 | identifiers={(DOMAIN, device_mac)},
128 | name=f"Marstek {entry.data['device']}",
129 | manufacturer="Marstek",
130 | model=entry.data["device"],
131 | sw_version=str(entry.data.get("firmware", "Unknown")),
132 | )
133 |
134 | @property
135 | def is_on(self) -> bool | None:
136 | """Return true if the binary sensor is on."""
137 | if self.entity_description.value_fn:
138 | return self.entity_description.value_fn(self.coordinator.data)
139 | return None
140 |
141 | @property
142 | def available(self) -> bool:
143 | """Return if entity is available - keep sensors available if we have data."""
144 | if self.entity_description.available_fn:
145 | return self.entity_description.available_fn(self.coordinator.data)
146 | # Keep entity available if we have any data at all (prevents "unknown" on transient failures)
147 | return self.coordinator.data is not None and len(self.coordinator.data) > 0
148 |
149 |
150 | class MarstekMultiDeviceBinarySensor(CoordinatorEntity, BinarySensorEntity):
151 | """Representation of a Marstek binary sensor in multi-device mode."""
152 |
153 | entity_description: MarstekBinarySensorEntityDescription
154 |
155 | def __init__(
156 | self,
157 | coordinator: MarstekMultiDeviceCoordinator,
158 | device_coordinator: MarstekDataUpdateCoordinator,
159 | entity_description: MarstekBinarySensorEntityDescription,
160 | device_mac: str,
161 | device_data: dict,
162 | ) -> None:
163 | """Initialize the binary sensor."""
164 | super().__init__(coordinator)
165 | self.entity_description = entity_description
166 | self.device_coordinator = device_coordinator
167 | self.device_mac = device_mac
168 | self._attr_has_entity_name = True
169 | self._attr_unique_id = f"{device_mac}_{entity_description.key}"
170 |
171 | # Extract last 4 chars of MAC for device name differentiation
172 | mac_suffix = device_mac.replace(":", "")[-4:]
173 |
174 | self._attr_device_info = DeviceInfo(
175 | identifiers={(DOMAIN, device_mac)},
176 | name=f"Marstek {device_data.get('device', 'Device')} {mac_suffix}",
177 | manufacturer="Marstek",
178 | model=device_data.get("device", "Unknown"),
179 | sw_version=str(device_data.get("firmware", "Unknown")),
180 | )
181 |
182 | @property
183 | def is_on(self) -> bool | None:
184 | """Return true if the binary sensor is on."""
185 | if self.entity_description.value_fn:
186 | device_data = self.coordinator.get_device_data(self.device_mac)
187 | return self.entity_description.value_fn(device_data)
188 | return None
189 |
190 | @property
191 | def available(self) -> bool:
192 | """Return if entity is available - keep sensors available if we have data."""
193 | if self.entity_description.available_fn:
194 | device_data = self.coordinator.get_device_data(self.device_mac)
195 | return self.entity_description.available_fn(device_data)
196 | # Keep entity available if device has any data at all (prevents "unknown" on transient failures)
197 | device_data = self.coordinator.get_device_data(self.device_mac)
198 | return device_data is not None and len(device_data) > 0
199 |
--------------------------------------------------------------------------------
/custom_components/marstek_local_api/compatibility.py:
--------------------------------------------------------------------------------
1 | """Compatibility matrix for Marstek devices across firmware and hardware versions.
2 |
3 | DESIGN PHILOSOPHY:
4 | ------------------
5 | This matrix exists to support the LATEST firmware versions. As older firmware versions
6 | become obsolete, their entries can be removed from this matrix. The goal is NOT to
7 | maintain backward compatibility indefinitely, but to handle the current generation
8 | of devices.
9 |
10 | MISSING FIELDS:
11 | ---------------
12 | If a field is not present in the API response payload, that's acceptable. The sensor
13 | layer will handle missing values and display "unknown" to the user. No special handling
14 | is needed in this compatibility layer.
15 |
16 | SCALING LOOKUP LOGIC:
17 | ---------------------
18 | Matrix keys are (hardware_version, firmware_version) tuples.
19 | Firmware version means "from this version onwards".
20 |
21 | Example: Device with HW 2.0, FW 200
22 | - Matrix has entries: (HW_VERSION_2, 0) and (HW_VERSION_2, 154)
23 | - Lookup finds highest FW <= 200, which is 154
24 | - Uses the scaling factor for (HW_VERSION_2, 154)
25 |
26 | HARDWARE VERSIONS:
27 | ------------------
28 | - HW 2.0: Original hardware (e.g., "VenusE")
29 | - HW 3.0: Newer hardware (e.g., "VenusE 3.0")
30 |
31 | All defaults are explicit in the matrix for maintainability.
32 | """
33 | from __future__ import annotations
34 |
35 | import logging
36 | import re
37 | from typing import Any, Final
38 |
39 | _LOGGER = logging.getLogger(__name__)
40 |
41 | # Hardware version detection
42 | HW_VERSION_2: Final = "2.0"
43 | HW_VERSION_3: Final = "3.0"
44 |
45 |
46 | def parse_hardware_version(device_model: str) -> str:
47 | """Extract hardware version from device model string.
48 |
49 | Examples:
50 | "VenusE" -> "2.0"
51 | "VenusE 3.0" -> "3.0"
52 | "VenusD" -> "2.0"
53 | """
54 | if not device_model:
55 | return HW_VERSION_2
56 |
57 | # Check for explicit version in model name
58 | match = re.search(r'(\d+\.\d+)', device_model)
59 | if match:
60 | return match.group(1)
61 |
62 | # Default to hardware version 2.0
63 | return HW_VERSION_2
64 |
65 |
66 | def get_base_model(device_model: str) -> str:
67 | """Get base model name without hardware version suffix.
68 |
69 | Examples:
70 | "VenusE 3.0" -> "VenusE"
71 | "VenusE" -> "VenusE"
72 | "VenusD" -> "VenusD"
73 | """
74 | if not device_model:
75 | return ""
76 |
77 | # Remove version suffix
78 | return re.sub(r'\s+\d+\.\d+.*$', '', device_model)
79 |
80 |
81 | class CompatibilityMatrix:
82 | """Centralized compatibility matrix for version-dependent value scaling.
83 |
84 | This class handles all firmware and hardware version-specific scaling logic
85 | in one location. All defaults are explicit for maintainability.
86 | """
87 |
88 | # ============================================================================
89 | # SCALING MATRIX
90 | # ============================================================================
91 | # Format: {field_name: {(hw_version, fw_version): divisor}}
92 | #
93 | # The raw API value is DIVIDED by the divisor to get the final value.
94 | # Firmware version means "from this version onwards".
95 | # Lookup finds the highest firmware version <= actual device firmware.
96 | # ============================================================================
97 |
98 | SCALING_MATRIX: dict[str, dict[tuple[str, int], float]] = {
99 | # Battery temperature (°C)
100 | "bat_temp": {
101 | (HW_VERSION_2, 0): 1.0, # FW 0-153: raw value in °C
102 | (HW_VERSION_2, 154): 0.1, # FW 154+: raw value in deci-°C (÷0.1 = ×10)
103 | (HW_VERSION_3, 0): 1.0, # FW 0+: raw value in °C
104 | (HW_VERSION_3, 139): 10.0, # FW 0+: raw value in deca-°C (÷10)
105 | },
106 |
107 | # Battery capacity (Wh)
108 | "bat_capacity": {
109 | (HW_VERSION_2, 0): 100.0, # FW 0-153: raw value in centi-Wh (÷100)
110 | (HW_VERSION_2, 154): 1.0, # FW 154+: raw value in Wh
111 | (HW_VERSION_3, 0): 1.0, # FW 0+: raw value in Wh
112 | (HW_VERSION_3, 139): 0.1, # FW 0+: raw value in deci-Wh (÷0.1)
113 | },
114 |
115 | # Battery power (W)
116 | "bat_power": {
117 | (HW_VERSION_2, 0): 10.0, # FW 0-153: raw value in deca-W (÷10)
118 | (HW_VERSION_2, 154): 1.0, # FW 154+: raw value in W
119 | (HW_VERSION_3, 0): 1.0, # FW 0+: raw value in W
120 | },
121 |
122 | # Grid import energy (Wh)
123 | "total_grid_input_energy": {
124 | (HW_VERSION_2, 0): 0.1, # FW 0-153: raw × 10 = Wh (÷0.1)
125 | (HW_VERSION_2, 154): 0.01, # FW 154+: raw × 100 = Wh (÷0.01)
126 | (HW_VERSION_3, 0): 1.0, # FW 0+: raw value in Wh
127 | },
128 |
129 | # Grid export energy (Wh)
130 | "total_grid_output_energy": {
131 | (HW_VERSION_2, 0): 0.1, # FW 0-153: raw × 10 = Wh (÷0.1)
132 | (HW_VERSION_2, 154): 0.01, # FW 154+: raw × 100 = Wh (÷0.01)
133 | (HW_VERSION_3, 0): 1.0, # FW 0+: raw value in Wh
134 | },
135 |
136 | # Load energy (Wh)
137 | "total_load_energy": {
138 | (HW_VERSION_2, 0): 0.1, # FW 0-153: raw × 10 = Wh (÷0.1)
139 | (HW_VERSION_2, 154): 0.01, # FW 154+: raw × 100 = Wh (÷0.01)
140 | (HW_VERSION_3, 0): 1.0, # FW 0+: raw value in Wh
141 | },
142 |
143 | # Battery voltage (V) - ALWAYS scaled by 100
144 | "bat_voltage": {
145 | (HW_VERSION_2, 0): 100.0, # All FW: raw in centi-V (÷100)
146 | (HW_VERSION_3, 0): 100.0, # All FW: raw in centi-V (÷100)
147 | },
148 |
149 | # Battery current (A) - ALWAYS scaled by 100
150 | "bat_current": {
151 | (HW_VERSION_2, 0): 100.0, # All FW: raw in centi-A (÷100)
152 | (HW_VERSION_3, 0): 100.0, # All FW: raw in centi-A (÷100)
153 | },
154 | }
155 |
156 | def __init__(self, device_model: str, firmware_version: int) -> None:
157 | """Initialize compatibility matrix for a specific device.
158 |
159 | Args:
160 | device_model: Full device model string (e.g., "VenusE", "VenusE 3.0")
161 | firmware_version: Firmware version number (e.g., 139, 154, 200)
162 | """
163 | self.device_model = device_model
164 | self.firmware_version = firmware_version
165 | self.hardware_version = parse_hardware_version(device_model)
166 | self.base_model = get_base_model(device_model)
167 |
168 | _LOGGER.debug(
169 | "Initialized compatibility matrix: model=%s, base=%s, hw=%s, fw=%d",
170 | device_model, self.base_model, self.hardware_version, firmware_version
171 | )
172 |
173 | def scale_value(self, value: float | None, field: str) -> float | None:
174 | """Scale a raw API value based on firmware and hardware version.
175 |
176 | Lookup logic:
177 | 1. Find all entries for this hardware version and field
178 | 2. Select the highest firmware version <= actual device firmware
179 | 3. Return scaled value using that divisor
180 |
181 | Args:
182 | value: Raw value from API
183 | field: Field name (e.g., "bat_temp", "bat_power")
184 |
185 | Returns:
186 | Scaled value in correct units, or None if input is None.
187 | If no scaling is defined, returns the raw value unchanged (default 1.0).
188 | """
189 | if value is None:
190 | return None
191 |
192 | # If field not in matrix, return raw value (no scaling needed)
193 | if field not in self.SCALING_MATRIX:
194 | return value
195 |
196 | scaling_map = self.SCALING_MATRIX[field]
197 |
198 | # Find all entries matching our hardware version
199 | matching_entries = [
200 | (fw_ver, divisor)
201 | for (hw_ver, fw_ver), divisor in scaling_map.items()
202 | if hw_ver == self.hardware_version
203 | ]
204 |
205 | # If no entries for this hardware version, return raw value
206 | if not matching_entries:
207 | _LOGGER.debug(
208 | "No scaling entries for %s with hw=%s, using raw value",
209 | field, self.hardware_version
210 | )
211 | return value
212 |
213 | # Find the highest firmware version <= our actual firmware
214 | applicable_entries = [
215 | (fw_ver, divisor)
216 | for fw_ver, divisor in matching_entries
217 | if fw_ver <= self.firmware_version
218 | ]
219 |
220 | # If no applicable entry (our FW is older than any defined), return raw value
221 | if not applicable_entries:
222 | return value
223 |
224 | # Get the entry with the highest firmware version
225 | selected_fw_ver, divisor = max(applicable_entries, key=lambda x: x[0])
226 | scaled = value / divisor
227 |
228 | return scaled
229 |
230 | def get_info(self) -> dict[str, Any]:
231 | """Get compatibility information for diagnostics.
232 |
233 | Returns:
234 | Dictionary with compatibility details
235 | """
236 | return {
237 | "device_model": self.device_model,
238 | "base_model": self.base_model,
239 | "hardware_version": self.hardware_version,
240 | "firmware_version": self.firmware_version,
241 | }
242 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Marstek Local API for Home Assistant
2 |
3 | > **Firmware warning:** Marstek’s Local API firmware is still immature, so most glitches originate in the batteries, not here.
4 | > Report issues to Marstek unless you can clearly trace them to this project.
5 |
6 | Home Assistant integration that talks directly to Marstek Venus C/D/E batteries over the official Local API. It delivers local-only telemetry, mode control, and fleet-wide aggregation without relying on the Marstek cloud.
7 |
8 | ---
9 |
10 | ## 1. Enable the Local API
11 |
12 | 1. Make sure your batteries are on the latest firmware.
13 | 2. Use the [Marstek Venus Monitor](https://rweijnen.github.io/marstek-venus-monitor/latest/) tool to enable *Local API / Open API* on each device.
14 | 3. Note the UDP port (default `30000`) and confirm the devices respond on your LAN.
15 |
16 |
17 |
18 | ---
19 |
20 | ## 2. Install the Integration
21 |
22 | ### Via HACS
23 | 1. Click this button:
24 |
25 | [](https://my.home-assistant.io/redirect/hacs_repository/?owner=jaapp&repository=ha-marstek-local-api&category=integration)
26 |
27 | Or:
28 | 1. Open **HACS → Integrations → Custom repositories**.
29 | 2. Add `https://github.com/jaapp/ha-marstek-local-api` as an *Integration*.
30 | 3. Install **Marstek Local API** and restart Home Assistant.
31 |
32 | ### Manual copy
33 | 1. Drop `custom_components/marstek_local_api` into your Home Assistant `custom_components` folder.
34 | 2. Restart Home Assistant.
35 |
36 | ---
37 |
38 | ## 3. Add Devices
39 |
40 | 1. Go to **Settings → Devices & Services → Add Integration** and search for **Marstek Local API**.
41 | 2. The discovery step lists every battery it finds on your network. Select one device or pick **All devices** to build a single multi-battery entry.
42 | 3. If discovery misses a unit, choose **Manual IP entry** and provide the host/port you noted earlier.
43 |
44 | After setup you can return to **Settings → Devices & Services → Marstek Local API → Configure** to:
45 | - Rename devices, adjust the polling interval, or add/remove batteries to the existing multi-device entry.
46 | - Trigger discovery again when new batteries join the network.
47 |
48 | > **Important:** If you want all batteries to live under the same config entry (and keep the virtual **Marstek System** device), use the integration’s **Configure** button to add/remove batteries. The default Home Assistant “Add Device” button creates a brand-new config entry and a separate virtual system device.
49 |
50 |
51 |
52 |
53 | ---
54 |
55 | ## 4. Single Entry vs. Virtual System Battery
56 |
57 | - **Single-device entry**: created when you add an individual battery. Each entry exposes the battery’s entities and optional operating-mode controls.
58 | - **Multi-device entry**: created when you pick *All devices* or add more batteries through the options flow. The integration keeps one config entry containing all members and exposes a synthetic device called **“Marstek System”**.
59 | - The “system” device aggregates fleet metrics (total capacity, total grid import/export, combined state, etc.).
60 | - Every physical battery still appears as its own device with per-pack entities.
61 |
62 |
63 |
64 | ---
65 |
66 | ## 5. Entities
67 |
68 | | Category | Sensor (entity suffix) | Unit | Notes | Polling multiplier | Default interval (s) |
69 | | --- | --- | --- | --- | ---: | ---: |
70 | | **Battery** | `battery_soc` | % | State of charge | 1x | 60 |
71 | | | `battery_temperature` | °C | Pack temperature | 1x | 60 |
72 | | | `battery_capacity` | kWh | Remaining capacity | 1x | 60 |
73 | | | `battery_rated_capacity` | kWh | Rated pack capacity | 1x | 60 |
74 | | | `battery_available_capacity` | kWh | Estimated energy still available before full charge | 1x | 60 |
75 | | | `battery_voltage` | V | Pack voltage | 1x | 60 |
76 | | | `battery_current` | A | Pack current (positive = charge) | 1x | 60 |
77 | | **Energy system (ES)** | `battery_power` | W | Pack power (positive = charge) | 1x | 60 |
78 | | | `battery_power_in` / `battery_power_out` | W | Split charge/discharge power | 1x | 60 |
79 | | | `battery_state` | text | `charging` / `discharging` / `idle` | 1x | 60 |
80 | | | `grid_power` | W | Grid import/export (positive = import) | 1x | 60 |
81 | | | `offgrid_power` | W | Off-grid load | 1x | 60 |
82 | | | `pv_power_es` | W | Solar production reported via ES | 1x | 60 |
83 | | | `total_pv_energy` | kWh | Lifetime PV energy | 1x | 60 |
84 | | | `total_grid_import` / `total_grid_export` | kWh | Lifetime grid counters | 1x | 60 |
85 | | | `total_load_energy` | kWh | Lifetime load energy | 1x | 60 |
86 | | **Energy meter / CT** | `ct_phase_a_power`, `ct_phase_b_power`, `ct_phase_c_power` | W | Per-phase measurements (if CTs installed) | 5x | 300 |
87 | | | `ct_total_power` | W | CT aggregate | 5x | 300 |
88 | | **Mode** | `operating_mode` | text | Current mode (read-only sensor) | 5x | 300 |
89 | | **PV (Venus D only)** | `pv_power`, `pv_voltage`, `pv_current` | W / V / A | MPPT telemetry | 5x | 300 |
90 | | **Network** | `wifi_rssi` | dBm | Wi-Fi signal | 10x | 600 |
91 | | | `wifi_ssid`, `wifi_ip`, `wifi_gateway`, `wifi_subnet`, `wifi_dns` | text | Wi-Fi configuration | 10x | 600 |
92 | | **Device info** | `device_model`, `firmware_version`, `ble_mac`, `wifi_mac`, `device_ip` | text | Identification fields | 10x | 600 |
93 | | **Diagnostics** | `last_message_received` | seconds | Time since the last successful poll | 1x | 60 |
94 |
95 | Every sensor listed above also exists in an aggregated form under the **Marstek System** device whenever you manage multiple batteries together (prefixed with `system_`).
96 |
97 | ### Mode Control Buttons
98 |
99 | Each battery exposes three button entities for quick mode switching:
100 |
101 | - `button.marstek_auto_mode` - Switch to Auto mode
102 | - `button.marstek_ai_mode` - Switch to AI mode
103 | - `button.marstek_manual_mode` - Switch to Manual mode
104 |
105 | The `sensor.marstek_operating_mode` displays the current active mode (Auto, AI, Manual, or Passive). **Passive mode** requires parameters (power and duration) and can only be activated via the `set_passive_mode` service (see Services section below).
106 |
107 | ---
108 |
109 | ## 6. Services
110 |
111 | ### Data Synchronization
112 |
113 | | Service | Description | Parameters |
114 | | --- | --- | --- |
115 | | `marstek_local_api.request_data_sync` | Triggers an immediate poll of every configured coordinator. | Optional `entry_id` (specific config entry) and/or `device_id` (single battery). |
116 |
117 | ### Manual Mode Scheduling
118 |
119 | The integration provides three services for configuring manual mode schedules. Manual mode allows you to define up to 10 time-based schedules that control when the battery charges/discharges and at what power level.
120 |
121 | > Select the **battery device** for all schedule services. The integration targets the correct device coordinator automatically.
122 |
123 | > **Note:** The Marstek Local API does not support reading schedule configurations back from the device. Schedules are write-only, so the integration cannot display currently configured schedules.
124 |
125 | | Service | Description |
126 | | --- | --- |
127 | | `marstek_local_api.set_manual_schedule` | Configure a single schedule slot (0-9) with time, days, and power settings. |
128 | | `marstek_local_api.set_manual_schedules` | Configure multiple schedule slots at once using YAML. |
129 | | `marstek_local_api.clear_manual_schedules` | Disable all 10 schedule slots. |
130 |
131 | #### Setting a Single Schedule
132 |
133 | Configure one schedule slot at a time through the Home Assistant UI:
134 |
135 | ```yaml
136 | service: marstek_local_api.set_manual_schedule
137 | data:
138 | device_id: "1234567890abcdef1234567890abcdef"
139 | time_num: 0 # Slot 0-9
140 | start_time: "08:00"
141 | end_time: "16:00"
142 | days:
143 | - mon
144 | - tue
145 | - wed
146 | - thu
147 | - fri
148 | power: -2000 # Negative = charge limit (2000W), positive = discharge limit
149 | enabled: true
150 | ```
151 |
152 | #### Setting Multiple Schedules
153 |
154 | Configure several slots at once using YAML mode in Developer Tools → Services:
155 |
156 | ```yaml
157 | service: marstek_local_api.set_manual_schedules
158 | data:
159 | device_id: "1234567890abcdef1234567890abcdef"
160 | schedules:
161 | - time_num: 0
162 | start_time: "08:00"
163 | end_time: "16:00"
164 | days: [mon, tue, wed, thu, fri]
165 | power: -2000 # Charge at max 2000W
166 | enabled: true
167 | - time_num: 1
168 | start_time: "18:00"
169 | end_time: "22:00"
170 | days: [mon, tue, wed, thu, fri]
171 | power: 800 # Discharge at max 800W
172 | enabled: true
173 | ```
174 |
175 | #### Clearing All Schedules
176 |
177 | Remove all configured schedules by disabling all 10 slots:
178 |
179 | ```yaml
180 | service: marstek_local_api.clear_manual_schedules
181 | data:
182 | device_id: "1234567890abcdef1234567890abcdef"
183 | ```
184 |
185 | > Expect this call to run for several minutes—the Marstek API accepts only one slot at a time and rejects most writes on the first attempt, so the integration walks through all ten slots with retries and back-off until the device finally accepts them.
186 |
187 | #### Schedule Parameters
188 |
189 | - **time_num**: Schedule slot number (0-9). Each slot is independent.
190 | - **start_time** / **end_time**: 24-hour format (HH:MM). Schedules can span midnight.
191 | - **days**: List of weekdays (`mon`, `tue`, `wed`, `thu`, `fri`, `sat`, `sun`). Defaults to all days.
192 | - **power**: Power limit in watts. **Important:** Use negative values for charging (e.g., `-2000` = 2000W charge limit) and positive values for discharging (e.g., `800` = 800W discharge limit). Use `0` for no limit.
193 | - **enabled**: Whether this schedule is active (default: `true`).
194 | - **device_id**: Home Assistant device ID of the target battery (required).
195 |
196 | #### Important Notes
197 |
198 | - Changing the operating mode to Manual via the button entity will **not** activate any schedules automatically. You must configure schedules using the services above.
199 | - Multiple schedules can overlap. The device handles priority internally.
200 | - Schedule configurations are stored on the device and persist across reboots.
201 | - Since schedule reading is not supported, keep a copy of your schedule configuration in Home Assistant automations or scripts.
202 |
203 | You can call these services from **Developer Tools → Services** or use them in automations and scripts.
204 |
205 | ### Passive Mode Control
206 |
207 | The `marstek_local_api.set_passive_mode` service enables **Passive mode** for direct power control. Passive mode allows you to charge or discharge the selected battery at a specific power level for a defined duration.
208 |
209 | **Important:** Power values use signed integers:
210 | - **Negative values** = Charging (e.g., `-2000` means charge at 2000W)
211 | - **Positive values** = Discharging (e.g., `1500` means discharge at 1500W)
212 |
213 | #### Service Parameters
214 |
215 | | Parameter | Required | Type | Range | Description |
216 | | --- | --- | --- | --- | --- |
217 | | `device_id` | Yes | string | - | Battery to control. The integration communicates with the selected device directly. |
218 | | `power` | Yes | integer | -10000 to 10000 | Power in watts (negative = charge, positive = discharge) |
219 | | `duration` | Yes | integer | 1 to 86400 | Duration in seconds (max 24 hours) |
220 |
221 | #### Examples
222 |
223 | **Charge at 2000W for 1 hour:**
224 | ```yaml
225 | service: marstek_local_api.set_passive_mode
226 | data:
227 | device_id: "1234567890abcdef1234567890abcdef"
228 | power: -2000 # Negative = charging
229 | duration: 3600 # 1 hour in seconds
230 | ```
231 |
232 | **Discharge at 1500W for 30 minutes:**
233 | ```yaml
234 | service: marstek_local_api.set_passive_mode
235 | data:
236 | device_id: "1234567890abcdef1234567890abcdef"
237 | power: 1500 # Positive = discharging
238 | duration: 1800 # 30 minutes in seconds
239 | ```
240 |
241 | **Use in an automation (charge during cheap electricity hours):**
242 | ```yaml
243 | automation:
244 | - alias: "Charge battery during off-peak hours"
245 | trigger:
246 | - platform: time
247 | at: "02:00:00"
248 | action:
249 | - service: marstek_local_api.set_passive_mode
250 | data:
251 | device_id: "1234567890abcdef1234567890abcdef"
252 | power: -3000 # Charge at 3000W
253 | duration: 14400 # 4 hours
254 | ```
255 |
256 | ---
257 |
258 | ## 7. Tips & Troubleshooting
259 |
260 | - Keep the standard polling interval (60 s) unless you have explicit reasons to slow it down. Faster intervals than 60s can lead to the battery becoming unresponsive.
261 | - If discovery fails, double-check that the Local API remains enabled after firmware upgrades and that UDP port `30000` is accessible from Home Assistant.
262 | - For verbose logging, append the following to `configuration.yaml`:
263 | ```yaml
264 | logger:
265 | logs:
266 | custom_components.marstek_local_api: debug
267 | ```
268 |
269 | ## API maturity & known issues
270 |
271 | Note: the Marstek Local API is still relatively new and evolving. Behavior can vary between hardware revisions (v2/v3) and firmware versions (EMS and BMS). When reporting issues, always include diagnostic data (logs and the integration's diagnostic fields).
272 |
273 | Known issues:
274 | - Polling too often might cause connection to be lost to the CT002/3
275 | - Battery temperature may read 10× too high on older BMS versions.
276 | - API call timeouts (shown as warnings in the log).
277 | - Some API calls are not supported on older firmware — please ensure devices are updated before filing issues.
278 | - Manual mode requests must include a schedule: the API rejects `ES.SetMode` without `manual_cfg`, and because schedules are write-only the integration always sends a disabled placeholder in slot 9. Reapply your own slot 9 schedule after toggling Manual mode if needed.
279 | - Polling faster than 60s is not advised; devices have been reported to become unstable (e.g. losing CT003 connection).
280 | - Energy counters / capacity fields may be reported in Wh instead of kWh on certain firmware (values appear 1000× off).
281 | - `ES.GetStatus` can be unresponsive on some Venus E v3 firmwares (reported on v137 / v139).
282 | - CT connection state may be reported as "disconnected" / power values might not be updated even when a CT is connected (appears fixed in HW v2 firmware v154+).
283 |
284 | Most of these issues are resolved by updating the device to the latest firmware — Marstek staggers rollouts, so many systems still run older versions. The Local API is evolving quickly and should stabilise as updates are deployed.
285 |
286 | Example warnings:
287 |
288 | ```
289 | 2025-10-21 10:01:34.986 WARNING (MainThread) [custom_components.marstek_local_api.api] Command ES.GetStatus timed out after 15s (attempt 1/3, host=192.168.0.47)
290 | 2025-10-21 10:02:28.693 ERROR (MainThread) [custom_components.marstek_local_api.api] Command EM.GetStatus failed after 3 attempt(s); returning no result
291 | ```
292 |
293 | Quick note for issue reports (EN): always attach the integration diagnostics export and relevant HA logs when filing a bug — it is required for effective troubleshooting.
294 |
295 |
296 | ### Standalone device tool
297 |
298 | In the repository you'll find `test/test_tool.py`, a CLI that reuses the integration code to diagnose and control batteries outside Home Assistant:
299 |
300 | ```bash
301 | cd test
302 | python3 test_tool.py discover # discover and print diagnostics
303 | python3 test_tool.py discover --ip 192.168.7.101 # target a specific IP
304 | python3 test_tool.py set-test-schedules # apply test schedules
305 | python3 test_tool.py clear-schedules # clear manual schedules
306 | python3 test_tool.py set-passive --power -2000 --duration 3600
307 | python3 test_tool.py set-mode auto --ip 192.168.7.101
308 | ```
309 |
310 | The default `discover` command runs the full diagnostic suite. Additional subcommands allow you to verify manual scheduling, passive mode, and operating mode changes without installing Home Assistant.
311 |
--------------------------------------------------------------------------------
/custom_components/marstek_local_api/button.py:
--------------------------------------------------------------------------------
1 | """Button platform for Marstek Local API."""
2 | from __future__ import annotations
3 |
4 | import asyncio
5 | import logging
6 |
7 | from homeassistant.components.button import ButtonEntity
8 | from homeassistant.config_entries import ConfigEntry
9 | from homeassistant.core import HomeAssistant
10 | from homeassistant.exceptions import HomeAssistantError
11 | from homeassistant.helpers.entity import DeviceInfo
12 | from homeassistant.helpers.entity_platform import AddEntitiesCallback
13 | from homeassistant.helpers.update_coordinator import CoordinatorEntity
14 |
15 | from .const import (
16 | DATA_COORDINATOR,
17 | DOMAIN,
18 | MAX_RETRIES,
19 | MODE_AI,
20 | MODE_AUTO,
21 | MODE_MANUAL,
22 | RETRY_DELAY,
23 | )
24 | from .coordinator import MarstekDataUpdateCoordinator, MarstekMultiDeviceCoordinator
25 |
26 | _LOGGER = logging.getLogger(__name__)
27 |
28 | DEFAULT_MANUAL_MODE_CFG: dict[str, int | str] = {
29 | "time_num": 9,
30 | "start_time": "00:00",
31 | "end_time": "00:00",
32 | "week_set": 0,
33 | "power": 0,
34 | "enable": 0,
35 | }
36 |
37 |
38 | def _mode_state_from_config(mode: str, config: dict) -> dict:
39 | """Extract mode state information from a config payload."""
40 | state: dict[str, object] = {"mode": mode}
41 |
42 | if mode == MODE_AUTO and "auto_cfg" in config:
43 | state["auto_cfg"] = dict(config["auto_cfg"])
44 | elif mode == MODE_AI and "ai_cfg" in config:
45 | state["ai_cfg"] = dict(config["ai_cfg"])
46 | elif mode == MODE_MANUAL and "manual_cfg" in config:
47 | state["manual_cfg"] = dict(config["manual_cfg"])
48 |
49 | return state
50 |
51 |
52 | async def async_setup_entry(
53 | hass: HomeAssistant,
54 | entry: ConfigEntry,
55 | async_add_entities: AddEntitiesCallback,
56 | ) -> None:
57 | """Set up Marstek buttons based on a config entry."""
58 | coordinator = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR]
59 |
60 | entities = []
61 |
62 | # Check if multi-device or single-device mode
63 | if isinstance(coordinator, MarstekMultiDeviceCoordinator):
64 | # Multi-device mode - create button entities for each device
65 | for mac in coordinator.get_device_macs():
66 | device_coordinator = coordinator.device_coordinators[mac]
67 | device_data = next(d for d in coordinator.devices if (d.get("ble_mac") or d.get("wifi_mac")) == mac)
68 |
69 | entities.extend([
70 | MarstekMultiDeviceAutoModeButton(
71 | coordinator=coordinator,
72 | device_coordinator=device_coordinator,
73 | device_mac=mac,
74 | device_data=device_data,
75 | ),
76 | MarstekMultiDeviceAIModeButton(
77 | coordinator=coordinator,
78 | device_coordinator=device_coordinator,
79 | device_mac=mac,
80 | device_data=device_data,
81 | ),
82 | MarstekMultiDeviceManualModeButton(
83 | coordinator=coordinator,
84 | device_coordinator=device_coordinator,
85 | device_mac=mac,
86 | device_data=device_data,
87 | ),
88 | ])
89 | else:
90 | # Single device mode
91 | entities.extend([
92 | MarstekAutoModeButton(coordinator, entry),
93 | MarstekAIModeButton(coordinator, entry),
94 | MarstekManualModeButton(coordinator, entry),
95 | ])
96 |
97 | async_add_entities(entities)
98 |
99 |
100 | class MarstekModeButton(CoordinatorEntity, ButtonEntity):
101 | """Base class for Marstek mode buttons."""
102 |
103 | def __init__(
104 | self,
105 | coordinator: MarstekDataUpdateCoordinator,
106 | entry: ConfigEntry,
107 | mode: str,
108 | name: str,
109 | icon: str,
110 | ) -> None:
111 | """Initialize the button."""
112 | super().__init__(coordinator)
113 | self._mode = mode
114 | self._attr_has_entity_name = True
115 | device_mac = entry.data.get("ble_mac") or entry.data.get("wifi_mac")
116 | self._attr_unique_id = f"{device_mac}_{mode.lower()}_mode_button"
117 | self._attr_name = name
118 | self._attr_icon = icon
119 | self._attr_device_info = DeviceInfo(
120 | identifiers={(DOMAIN, device_mac)},
121 | name=f"Marstek {entry.data['device']}",
122 | manufacturer="Marstek",
123 | model=entry.data["device"],
124 | sw_version=str(entry.data.get("firmware", "Unknown")),
125 | )
126 |
127 | @property
128 | def available(self) -> bool:
129 | """Return if entity is available."""
130 | return self.coordinator.data is not None and len(self.coordinator.data) > 0
131 |
132 | async def async_press(self) -> None:
133 | """Handle the button press."""
134 | config = self._build_mode_config()
135 |
136 | success = False
137 | last_error: str | None = None
138 |
139 | try:
140 | # Retry logic
141 | for attempt in range(1, MAX_RETRIES + 1):
142 | try:
143 | if await self.coordinator.api.set_es_mode(config):
144 | self._update_cached_mode(config)
145 | _LOGGER.info("Successfully set operating mode to %s", self._mode)
146 | success = True
147 | break
148 |
149 | last_error = "device rejected mode change"
150 | _LOGGER.warning(
151 | "Device rejected mode change to %s (attempt %d/%d)",
152 | self._mode,
153 | attempt,
154 | MAX_RETRIES,
155 | )
156 |
157 | except Exception as err:
158 | last_error = str(err)
159 | _LOGGER.error(
160 | "Error setting mode to %s (attempt %d/%d): %s",
161 | self._mode,
162 | attempt,
163 | MAX_RETRIES,
164 | err,
165 | )
166 |
167 | # Wait before retry (except on last attempt)
168 | if attempt < MAX_RETRIES:
169 | await asyncio.sleep(RETRY_DELAY)
170 | finally:
171 | await self._refresh_mode_data()
172 |
173 | if success:
174 | return
175 |
176 | _LOGGER.error(
177 | "Failed to set operating mode to %s after %d attempts",
178 | self._mode,
179 | MAX_RETRIES,
180 | )
181 | message = f"Failed to set operating mode to {self._mode}"
182 | if last_error:
183 | message = f"{message}: {last_error}"
184 | raise HomeAssistantError(message)
185 |
186 | async def _refresh_mode_data(self) -> None:
187 | """Force a coordinator refresh so entities reflect the latest state."""
188 | try:
189 | await self.coordinator.async_refresh()
190 | except Exception as err:
191 | _LOGGER.warning("Failed to refresh data after mode change: %s", err)
192 |
193 | def _build_mode_config(self) -> dict:
194 | """Build configuration for the selected mode."""
195 | if self._mode == MODE_AUTO:
196 | return {
197 | "mode": MODE_AUTO,
198 | "auto_cfg": {"enable": 1},
199 | }
200 | elif self._mode == MODE_AI:
201 | return {
202 | "mode": MODE_AI,
203 | "ai_cfg": {"enable": 1},
204 | }
205 | elif self._mode == MODE_MANUAL:
206 | return {
207 | "mode": MODE_MANUAL,
208 | "manual_cfg": dict(DEFAULT_MANUAL_MODE_CFG),
209 | }
210 |
211 | return {}
212 |
213 | def _update_cached_mode(self, config: dict) -> None:
214 | """Update coordinator cache so sensors reflect the new mode immediately."""
215 | current = self.coordinator.data or {}
216 | updated = dict(current)
217 | mode_state = _mode_state_from_config(self._mode, config)
218 | updated["mode"] = {**(current.get("mode") or {}), **mode_state}
219 | self.coordinator.async_set_updated_data(updated)
220 |
221 |
222 | class MarstekAutoModeButton(MarstekModeButton):
223 | """Button to switch to Auto mode."""
224 |
225 | def __init__(
226 | self,
227 | coordinator: MarstekDataUpdateCoordinator,
228 | entry: ConfigEntry,
229 | ) -> None:
230 | """Initialize the Auto mode button."""
231 | super().__init__(coordinator, entry, MODE_AUTO, "Auto mode", "mdi:auto-mode")
232 |
233 |
234 | class MarstekAIModeButton(MarstekModeButton):
235 | """Button to switch to AI mode."""
236 |
237 | def __init__(
238 | self,
239 | coordinator: MarstekDataUpdateCoordinator,
240 | entry: ConfigEntry,
241 | ) -> None:
242 | """Initialize the AI mode button."""
243 | super().__init__(coordinator, entry, MODE_AI, "AI mode", "mdi:brain")
244 |
245 |
246 | class MarstekManualModeButton(MarstekModeButton):
247 | """Button to switch to Manual mode."""
248 |
249 | def __init__(
250 | self,
251 | coordinator: MarstekDataUpdateCoordinator,
252 | entry: ConfigEntry,
253 | ) -> None:
254 | """Initialize the Manual mode button."""
255 | super().__init__(coordinator, entry, MODE_MANUAL, "Manual mode", "mdi:calendar-clock")
256 |
257 |
258 | class MarstekMultiDeviceModeButton(CoordinatorEntity, ButtonEntity):
259 | """Base class for Marstek mode buttons in multi-device mode."""
260 |
261 | def __init__(
262 | self,
263 | coordinator: MarstekMultiDeviceCoordinator,
264 | device_coordinator: MarstekDataUpdateCoordinator,
265 | device_mac: str,
266 | device_data: dict,
267 | mode: str,
268 | name: str,
269 | icon: str,
270 | ) -> None:
271 | """Initialize the button."""
272 | super().__init__(coordinator)
273 | self.device_coordinator = device_coordinator
274 | self.device_mac = device_mac
275 | self._mode = mode
276 | self._attr_has_entity_name = True
277 | self._attr_unique_id = f"{device_mac}_{mode.lower()}_mode_button"
278 | self._attr_name = name
279 | self._attr_icon = icon
280 |
281 | # Extract last 4 chars of MAC for device name differentiation
282 | mac_suffix = device_mac.replace(":", "")[-4:]
283 |
284 | self._attr_device_info = DeviceInfo(
285 | identifiers={(DOMAIN, device_mac)},
286 | name=f"Marstek {device_data.get('device', 'Device')} {mac_suffix}",
287 | manufacturer="Marstek",
288 | model=device_data.get("device", "Unknown"),
289 | sw_version=str(device_data.get("firmware", "Unknown")),
290 | )
291 |
292 | @property
293 | def available(self) -> bool:
294 | """Return if entity is available."""
295 | device_data = self.coordinator.get_device_data(self.device_mac)
296 | return device_data is not None and len(device_data) > 0
297 |
298 | async def async_press(self) -> None:
299 | """Handle the button press."""
300 | config = self._build_mode_config()
301 |
302 | success = False
303 | last_error: str | None = None
304 |
305 | try:
306 | # Retry logic
307 | for attempt in range(1, MAX_RETRIES + 1):
308 | try:
309 | if await self.device_coordinator.api.set_es_mode(config):
310 | self._update_cached_mode(config)
311 | _LOGGER.info(
312 | "Successfully set operating mode to %s for device %s",
313 | self._mode,
314 | self.device_mac,
315 | )
316 | success = True
317 | break
318 |
319 | last_error = "device rejected mode change"
320 | _LOGGER.warning(
321 | "Device %s rejected mode change to %s (attempt %d/%d)",
322 | self.device_mac,
323 | self._mode,
324 | attempt,
325 | MAX_RETRIES,
326 | )
327 |
328 | except Exception as err:
329 | last_error = str(err)
330 | _LOGGER.error(
331 | "Error setting mode to %s for device %s (attempt %d/%d): %s",
332 | self._mode,
333 | self.device_mac,
334 | attempt,
335 | MAX_RETRIES,
336 | err,
337 | )
338 |
339 | # Wait before retry (except on last attempt)
340 | if attempt < MAX_RETRIES:
341 | await asyncio.sleep(RETRY_DELAY)
342 | finally:
343 | await self._refresh_mode_data()
344 |
345 | if success:
346 | return
347 |
348 | _LOGGER.error(
349 | "Failed to set operating mode to %s for device %s after %d attempts",
350 | self._mode,
351 | self.device_mac,
352 | MAX_RETRIES,
353 | )
354 | message = (
355 | f"Failed to set operating mode to {self._mode} for device {self.device_mac}"
356 | )
357 | if last_error:
358 | message = f"{message}: {last_error}"
359 | raise HomeAssistantError(message)
360 |
361 | async def _refresh_mode_data(self) -> None:
362 | """Force a refresh on the device and aggregate coordinators."""
363 | try:
364 | await self.device_coordinator.async_refresh()
365 | except Exception as err:
366 | _LOGGER.warning(
367 | "Failed to refresh device %s data after mode change: %s",
368 | self.device_mac,
369 | err,
370 | )
371 |
372 | try:
373 | await self.coordinator.async_refresh()
374 | except Exception as err:
375 | _LOGGER.warning(
376 | "Failed to refresh aggregate data after mode change for %s: %s",
377 | self.device_mac,
378 | err,
379 | )
380 |
381 | def _build_mode_config(self) -> dict:
382 | """Build configuration for the selected mode."""
383 | if self._mode == MODE_AUTO:
384 | return {
385 | "mode": MODE_AUTO,
386 | "auto_cfg": {"enable": 1},
387 | }
388 | elif self._mode == MODE_AI:
389 | return {
390 | "mode": MODE_AI,
391 | "ai_cfg": {"enable": 1},
392 | }
393 | elif self._mode == MODE_MANUAL:
394 | return {
395 | "mode": MODE_MANUAL,
396 | "manual_cfg": dict(DEFAULT_MANUAL_MODE_CFG),
397 | }
398 |
399 | return {}
400 |
401 | def _update_device_cache(self, state: dict) -> dict:
402 | """Update the per-device coordinator cache and return the new payload."""
403 | current_device = self.device_coordinator.data or {}
404 | updated_device = dict(current_device)
405 | updated_device["mode"] = {**(current_device.get("mode") or {}), **state}
406 | self.device_coordinator.async_set_updated_data(updated_device)
407 | return updated_device
408 |
409 | def _update_cached_mode(self, config: dict) -> None:
410 | """Update device and aggregate caches so sensors reflect the new mode immediately."""
411 | state = _mode_state_from_config(self._mode, config)
412 | updated_device = self._update_device_cache(state)
413 |
414 | current_system = self.coordinator.data or {}
415 | devices = dict((current_system.get("devices") or {}))
416 | devices[self.device_mac] = updated_device
417 |
418 | updated_system = dict(current_system)
419 | updated_system["devices"] = devices
420 | self.coordinator.async_set_updated_data(updated_system)
421 |
422 |
423 | class MarstekMultiDeviceAutoModeButton(MarstekMultiDeviceModeButton):
424 | """Button to switch to Auto mode in multi-device mode."""
425 |
426 | def __init__(
427 | self,
428 | coordinator: MarstekMultiDeviceCoordinator,
429 | device_coordinator: MarstekDataUpdateCoordinator,
430 | device_mac: str,
431 | device_data: dict,
432 | ) -> None:
433 | """Initialize the Auto mode button."""
434 | super().__init__(
435 | coordinator, device_coordinator, device_mac, device_data, MODE_AUTO, "Auto mode", "mdi:auto-mode"
436 | )
437 |
438 |
439 | class MarstekMultiDeviceAIModeButton(MarstekMultiDeviceModeButton):
440 | """Button to switch to AI mode in multi-device mode."""
441 |
442 | def __init__(
443 | self,
444 | coordinator: MarstekMultiDeviceCoordinator,
445 | device_coordinator: MarstekDataUpdateCoordinator,
446 | device_mac: str,
447 | device_data: dict,
448 | ) -> None:
449 | """Initialize the AI mode button."""
450 | super().__init__(
451 | coordinator, device_coordinator, device_mac, device_data, MODE_AI, "AI mode", "mdi:brain"
452 | )
453 |
454 |
455 | class MarstekMultiDeviceManualModeButton(MarstekMultiDeviceModeButton):
456 | """Button to switch to Manual mode in multi-device mode."""
457 |
458 | def __init__(
459 | self,
460 | coordinator: MarstekMultiDeviceCoordinator,
461 | device_coordinator: MarstekDataUpdateCoordinator,
462 | device_mac: str,
463 | device_data: dict,
464 | ) -> None:
465 | """Initialize the Manual mode button."""
466 | super().__init__(
467 | coordinator, device_coordinator, device_mac, device_data, MODE_MANUAL, "Manual mode", "mdi:calendar-clock"
468 | )
469 |
--------------------------------------------------------------------------------
/test/discover_api.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | """Systematic API endpoint discovery for Marstek devices.
3 |
4 | This script attempts to discover undocumented API endpoints by systematically
5 | testing various method names and parameter combinations.
6 |
7 | Usage:
8 | python3 discover_api.py # Auto-discover device
9 | python3 discover_api.py 192.168.7.101 # Test specific IP
10 | python3 discover_api.py --verbose # Show all attempts
11 | python3 discover_api.py --delay 2.0 # Increase delay between requests
12 | """
13 |
14 | import argparse
15 | import json
16 | import socket
17 | import sys
18 | import time
19 | from dataclasses import dataclass
20 | from typing import Any, Optional, TextIO
21 | from datetime import datetime
22 | from pathlib import Path
23 |
24 | # Configuration
25 | DEFAULT_PORT = 30000
26 | DISCOVERY_TIMEOUT = 9
27 | COMMAND_TIMEOUT = 15
28 | MAX_RETRIES = 3
29 | RATE_LIMIT_DELAY = 15.0 # Matches production integration; override with --delay for faster probing
30 | # Retry backoff uses the delay multiplier (1x, 3x, ...), so longer delays probe more gently.
31 |
32 | LOG_DIR = Path(__file__).resolve().parent / "logs"
33 | LOG_FILE = LOG_DIR / "discover_api.log"
34 | LOG_HANDLE: Optional[TextIO] = None
35 | LOG_AVAILABLE = False
36 |
37 |
38 | def log(message: str = "", *, flush: bool = False, console: bool = True) -> None:
39 | """Log message to console and log file."""
40 | if console:
41 | print(message, flush=flush)
42 | if LOG_HANDLE:
43 | LOG_HANDLE.write(message + "\n")
44 | LOG_HANDLE.flush()
45 |
46 |
47 | def start_log_session(session_label: str, delay: float) -> None:
48 | """Initialise logging for this script run."""
49 | global LOG_HANDLE, LOG_AVAILABLE
50 |
51 | try:
52 | LOG_DIR.mkdir(parents=True, exist_ok=True)
53 | except OSError as err:
54 | print(f"⚠️ Could not create log directory {LOG_DIR}: {err}")
55 | LOG_AVAILABLE = False
56 | return
57 |
58 | try:
59 | LOG_HANDLE = LOG_FILE.open("a", encoding="utf-8")
60 | except OSError as err:
61 | LOG_HANDLE = None
62 | print(f"⚠️ Could not open log file {LOG_FILE}: {err}")
63 | LOG_AVAILABLE = False
64 | return
65 |
66 | LOG_AVAILABLE = True
67 | timestamp = datetime.now().isoformat(timespec="seconds")
68 | header = [
69 | "=" * 80,
70 | f"{timestamp} | Session start: {session_label}",
71 | f"Rate limit: {delay}s",
72 | ]
73 | for line in header:
74 | log(line, console=False)
75 |
76 |
77 | def end_log_session() -> None:
78 | """Close log file handle if open."""
79 | global LOG_HANDLE, LOG_AVAILABLE
80 |
81 | if LOG_HANDLE:
82 | LOG_HANDLE.write("Session end\n\n")
83 | LOG_HANDLE.flush()
84 | LOG_HANDLE.close()
85 | LOG_HANDLE = None
86 | LOG_AVAILABLE = False
87 |
88 |
89 | @dataclass
90 | class ApiResult:
91 | """Result of an API endpoint test."""
92 | method: str
93 | params: dict
94 | success: bool
95 | error_code: Optional[int] = None
96 | error_message: Optional[str] = None
97 | result: Optional[dict] = None
98 | response_time: Optional[float] = None
99 |
100 |
101 | # Candidate endpoints to test
102 | ENDPOINT_CANDIDATES = [
103 | # Known working endpoint - sanity check
104 | ("ES.GetMode", {"id": 0}), # Should succeed - validates tool is working
105 |
106 | # Most likely - ES component variants
107 | ("ES.GetConfig", {"id": 0}),
108 | ("ES.GetModeConfig", {"id": 0}),
109 | ("ES.GetManualConfig", {"id": 0}),
110 | ("ES.GetManualCfg", {"id": 0}),
111 | ("ES.GetSchedule", {"id": 0}),
112 | ("ES.GetSchedules", {"id": 0}),
113 | ("ES.GetTimeSchedule", {"id": 0}),
114 | ("ES.GetSettings", {"id": 0}),
115 | ("ES.GetConfiguration", {"id": 0}),
116 | ("ES.GetAllModes", {"id": 0}),
117 | ("ES.ListSchedules", {"id": 0}),
118 | ("ES.QuerySchedule", {"id": 0}),
119 |
120 | # ES.GetMode with additional parameters
121 | ("ES.GetMode", {"id": 0, "detailed": True}),
122 | ("ES.GetMode", {"id": 0, "include_config": True}),
123 | ("ES.GetMode", {"id": 0, "include_schedules": True}),
124 | ("ES.GetMode", {"id": 0, "mode": "Manual"}),
125 |
126 | # ES.GetMode with schedule slot parameter
127 | ("ES.GetMode", {"id": 0, "time_num": 0}),
128 | ("ES.GetMode", {"id": 0, "time_num": 1}),
129 |
130 | # Manual component
131 | ("Manual.GetStatus", {"id": 0}),
132 | ("Manual.GetConfig", {"id": 0}),
133 | ("Manual.GetSchedules", {"id": 0}),
134 | ("Manual.GetSchedule", {"id": 0}),
135 | ("Manual.GetSchedule", {"id": 0, "time_num": 0}),
136 |
137 | # Schedule component
138 | ("Schedule.GetStatus", {"id": 0}),
139 | ("Schedule.GetConfig", {"id": 0}),
140 | ("Schedule.GetAll", {"id": 0}),
141 | ("Schedule.List", {"id": 0}),
142 |
143 | # Other mode configs
144 | ("ES.GetAutoCfg", {"id": 0}),
145 | ("ES.GetAICfg", {"id": 0}),
146 | ("ES.GetPassiveCfg", {"id": 0}),
147 |
148 | # Alternative component names
149 | ("Config.GetManual", {"id": 0}),
150 | ("Config.GetSchedules", {"id": 0}),
151 | ("System.GetSchedules", {"id": 0}),
152 | ("Mode.GetConfig", {"id": 0}),
153 | ("Mode.GetManual", {"id": 0}),
154 | ]
155 |
156 |
157 | class MarstekApiDiscovery:
158 | """Standalone UDP client for API endpoint discovery."""
159 |
160 | def __init__(self, host: str, port: int = DEFAULT_PORT, verbose: bool = False, delay: float = RATE_LIMIT_DELAY):
161 | self.host = host
162 | self.port = port
163 | self.verbose = verbose
164 | self.delay = delay
165 | self.sock: Optional[socket.socket] = None
166 | self.request_id = 0
167 |
168 | def connect(self):
169 | """Create UDP socket."""
170 | self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
171 | self.sock.settimeout(COMMAND_TIMEOUT)
172 | self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
173 | if hasattr(socket, "SO_REUSEPORT"):
174 | self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
175 |
176 | try:
177 | # Bind to the same port the device expects (matches HA integration behaviour)
178 | self.sock.bind(("", self.port))
179 | except OSError as err:
180 | log(f"⚠️ Could not bind command socket to UDP port {self.port}: {err}")
181 | log(" This may prevent responses from being received.")
182 | else:
183 | local_port = self.sock.getsockname()[1]
184 | log(f"[Socket] Bound local UDP port {local_port}")
185 |
186 | def disconnect(self):
187 | """Close UDP socket."""
188 | if self.sock:
189 | self.sock.close()
190 | self.sock = None
191 |
192 | def send_command(self, method: str, params: dict, retries: int = MAX_RETRIES) -> ApiResult:
193 | """Send command with retry and backoff logic."""
194 | if not self.sock:
195 | raise RuntimeError("Socket is not connected. Call connect() first.")
196 |
197 | self.request_id += 1
198 | request = {
199 | "id": self.request_id,
200 | "method": method,
201 | "params": params,
202 | }
203 |
204 | start_time = time.time()
205 | last_error = None
206 |
207 | for attempt in range(1, retries + 1):
208 | try:
209 | # Send request
210 | message = json.dumps(request).encode('utf-8')
211 | self.sock.sendto(message, (self.host, self.port))
212 |
213 | # Receive response
214 | try:
215 | data, _ = self.sock.recvfrom(65535)
216 | response = json.loads(data.decode('utf-8'))
217 | response_time = time.time() - start_time
218 |
219 | # Check for error
220 | if "error" in response:
221 | error = response["error"]
222 | return ApiResult(
223 | method=method,
224 | params=params,
225 | success=False,
226 | error_code=error.get("code"),
227 | error_message=error.get("message"),
228 | response_time=response_time,
229 | )
230 |
231 | # Success
232 | return ApiResult(
233 | method=method,
234 | params=params,
235 | success=True,
236 | result=response.get("result"),
237 | response_time=response_time,
238 | )
239 |
240 | except socket.timeout:
241 | last_error = f"Timeout (attempt {attempt}/{retries})"
242 | if self.verbose:
243 | log(f" ⏱️ {last_error}")
244 |
245 | # Delay before retry using configured multiplier
246 | if attempt < retries:
247 | multiplier = 1 if attempt == 1 else 3
248 | backoff = self.delay * multiplier
249 | if self.verbose:
250 | log(f" 🔁 Retry in {backoff:.1f}s (multiplier x{multiplier})")
251 | time.sleep(backoff)
252 |
253 | except Exception as e:
254 | last_error = str(e)
255 | if self.verbose:
256 | log(f" ❌ Error: {e}")
257 |
258 | # All retries failed
259 | response_time = time.time() - start_time
260 | return ApiResult(
261 | method=method,
262 | params=params,
263 | success=False,
264 | error_message=f"Failed after {retries} attempts: {last_error}",
265 | response_time=response_time,
266 | )
267 |
268 | def test_endpoint(self, method: str, params: dict) -> ApiResult:
269 | """Test a single endpoint."""
270 | result = self.send_command(method, params)
271 |
272 | # Rate limiting
273 | time.sleep(self.delay)
274 |
275 | return result
276 |
277 | def handshake(self) -> ApiResult:
278 | """Perform an initial handshake to validate connectivity."""
279 | log("[Handshake] Requesting device info...", flush=True)
280 | result = self.send_command("Marstek.GetDevice", {"ble_mac": "0"})
281 | if result.success and result.result:
282 | device = result.result.get("device", "Unknown")
283 | firmware = result.result.get("ver", "unknown")
284 | log(f"[Handshake] ✅ Device responded: {device} (firmware v{firmware})")
285 | else:
286 | log("[Handshake] ⚠️ Device did not respond to Marstek.GetDevice request")
287 | if result.error_message:
288 | log(f"[Handshake] Error: {result.error_message}")
289 | log()
290 | return result
291 |
292 |
293 | def get_broadcast_addresses() -> list[str]:
294 | """Get broadcast addresses for local networks."""
295 | import subprocess
296 |
297 | broadcast_addrs = []
298 |
299 | try:
300 | # Parse ifconfig to find network broadcast addresses
301 | result = subprocess.run(['ifconfig'], capture_output=True, text=True, timeout=2)
302 |
303 | for line in result.stdout.split('\n'):
304 | if '\tinet ' in line and 'broadcast' in line:
305 | parts = line.strip().split()
306 | if 'broadcast' in parts:
307 | idx = parts.index('broadcast')
308 | if idx + 1 < len(parts):
309 | broadcast = parts[idx + 1]
310 | # Skip loopback
311 | if not broadcast.startswith('127.'):
312 | broadcast_addrs.append(broadcast)
313 | except Exception:
314 | pass
315 |
316 | # Always include global broadcast as fallback
317 | if '255.255.255.255' not in broadcast_addrs:
318 | broadcast_addrs.append('255.255.255.255')
319 |
320 | return broadcast_addrs
321 |
322 |
323 | def discover_device() -> Optional[str]:
324 | """Discover Marstek device on network."""
325 | log("🔍 Discovering Marstek devices...")
326 |
327 | broadcast_addrs = get_broadcast_addresses()
328 | log(f"Broadcasting to: {', '.join(broadcast_addrs)}")
329 |
330 | sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
331 | sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
332 | sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
333 | if hasattr(socket, "SO_REUSEPORT"):
334 | sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
335 |
336 | try:
337 | # Bind to Marstek discovery port so the device can respond to the expected port
338 | sock.bind(("", DEFAULT_PORT))
339 | except OSError as err:
340 | log(f"⚠️ Could not bind to UDP port {DEFAULT_PORT}: {err}")
341 | log(" Discovery responses might not be received.")
342 |
343 | sock.settimeout(2.0) # 2-second timeout for receiving
344 |
345 | discovery_message = json.dumps({
346 | "id": 0,
347 | "method": "Marstek.GetDevice",
348 | "params": {"ble_mac": "0"}
349 | }).encode('utf-8')
350 |
351 | try:
352 | start = time.time()
353 | last_broadcast = 0
354 |
355 | # Broadcast repeatedly every 2 seconds during discovery window
356 | while time.time() - start < DISCOVERY_TIMEOUT:
357 | current_time = time.time()
358 |
359 | # Send broadcast every 2 seconds to all broadcast addresses
360 | if current_time - last_broadcast >= 2.0:
361 | for broadcast_addr in broadcast_addrs:
362 | sock.sendto(discovery_message, (broadcast_addr, DEFAULT_PORT))
363 | last_broadcast = current_time
364 |
365 | # Try to receive responses
366 | try:
367 | data, addr = sock.recvfrom(65535)
368 | response = json.loads(data.decode('utf-8'))
369 |
370 | if "result" in response and "device" in response["result"]:
371 | device = response["result"]
372 | ip = device.get("ip", addr[0])
373 | log(f"✅ Found {device['device']} at {ip}")
374 | sock.close()
375 | return ip
376 |
377 | except socket.timeout:
378 | # Normal - no response yet, continue broadcasting
379 | continue
380 | except Exception:
381 | continue
382 |
383 | # Wait 2 more seconds for delayed responses
384 | sock.settimeout(2.0)
385 | try:
386 | data, addr = sock.recvfrom(65535)
387 | response = json.loads(data.decode('utf-8'))
388 |
389 | if "result" in response and "device" in response["result"]:
390 | device = response["result"]
391 | ip = device.get("ip", addr[0])
392 | log(f"✅ Found {device['device']} at {ip}")
393 | sock.close()
394 | return ip
395 | except:
396 | pass
397 |
398 | finally:
399 | sock.close()
400 |
401 | return None
402 |
403 |
404 | def test_all_endpoints(client: MarstekApiDiscovery):
405 | """Test all candidate endpoints."""
406 | log()
407 | log("=" * 80)
408 | log("API Endpoint Discovery")
409 | log("=" * 80)
410 | log(f"Target: {client.host}:{client.port}")
411 | log(f"Rate limit: {client.delay}s between requests")
412 | log(f"Testing {len(ENDPOINT_CANDIDATES)} endpoint candidates...")
413 | log("=" * 80)
414 | log()
415 |
416 | results = {
417 | "found": [], # Working endpoints
418 | "exists": [], # Exists but wrong params
419 | "not_found": [], # Method not found
420 | "timeout": [], # Timeouts
421 | "other_error": [], # Other errors
422 | }
423 |
424 | total = len(ENDPOINT_CANDIDATES)
425 | max_wait = COMMAND_TIMEOUT * MAX_RETRIES
426 | multipliers: list[float] = []
427 | if MAX_RETRIES > 1:
428 | multipliers.append(1.0)
429 | if MAX_RETRIES > 2:
430 | multipliers.extend([3.0] * (MAX_RETRIES - 2))
431 | backoff_total = sum(multiplier * client.delay for multiplier in multipliers)
432 | max_wait_with_backoff = max_wait + backoff_total
433 |
434 | for idx, (method, params) in enumerate(ENDPOINT_CANDIDATES, 1):
435 | params_str = json.dumps(params) if params != {"id": 0} else ""
436 | label = f"{method}{' ' + params_str if params_str else ''}"
437 |
438 | if client.verbose:
439 | log(f"[{idx}/{total}] Testing {label}...", flush=True)
440 | else:
441 | log(f"[{idx}/{total}] Testing {label} (max wait ~{max_wait_with_backoff:.0f}s incl. backoff)", flush=True)
442 |
443 | result = client.test_endpoint(method, params)
444 |
445 | if result.success:
446 | results["found"].append(result)
447 | log(f" 🎉 FOUND! {method} -> Success!")
448 | log(f" Result: {json.dumps(result.result, indent=2)}")
449 |
450 | elif result.error_code == -32601:
451 | # Method not found
452 | results["not_found"].append(result)
453 | log(f" ❌ Not found: {method}")
454 |
455 | elif result.error_code == -32602:
456 | # Invalid params - METHOD EXISTS!
457 | results["exists"].append(result)
458 | log(f" ⚠️ EXISTS but wrong params: {method}")
459 | log(f" Error: {result.error_message}")
460 |
461 | elif result.error_code:
462 | # Other error code
463 | results["other_error"].append(result)
464 | log(f" ⚠️ Error {result.error_code}: {method} - {result.error_message}")
465 |
466 | else:
467 | # Timeout or network error
468 | results["timeout"].append(result)
469 | log(f" ⏱️ Timeout: {method} ({result.error_message or 'no response'})")
470 |
471 | return results
472 |
473 |
474 | def print_summary(results: dict):
475 | """Print summary of discovery results."""
476 | log()
477 | log("=" * 80)
478 | log("DISCOVERY SUMMARY")
479 | log("=" * 80)
480 | log()
481 |
482 | if results["found"]:
483 | log(f"✅ WORKING ENDPOINTS ({len(results['found'])}):")
484 | for r in results["found"]:
485 | params_str = json.dumps(r.params) if r.params != {"id": 0} else ""
486 | log(f" • {r.method}{' ' + params_str if params_str else ''}")
487 | log(f" Response time: {r.response_time:.2f}s")
488 | log()
489 |
490 | if results["exists"]:
491 | log(f"⚠️ ENDPOINTS THAT EXIST BUT NEED DIFFERENT PARAMS ({len(results['exists'])}):")
492 | for r in results["exists"]:
493 | params_str = json.dumps(r.params)
494 | log(f" • {r.method} (tried with {params_str})")
495 | log(f" Error: {r.error_message}")
496 | log()
497 |
498 | if results["other_error"]:
499 | log(f"⚠️ OTHER ERRORS ({len(results['other_error'])}):")
500 | for r in results["other_error"]:
501 | log(f" • {r.method}: Error {r.error_code} - {r.error_message}")
502 | log()
503 |
504 | log(f"❌ Not found: {len(results['not_found'])}")
505 | log(f"⏱️ Timeouts: {len(results['timeout'])}")
506 | log()
507 |
508 | total_tested = sum(len(v) for v in results.values())
509 | log(f"Total endpoints tested: {total_tested}")
510 | log("=" * 80)
511 |
512 |
513 | def main():
514 | """Main entry point."""
515 | parser = argparse.ArgumentParser(
516 | description="Discover undocumented Marstek API endpoints"
517 | )
518 | parser.add_argument(
519 | "ip",
520 | nargs="?",
521 | help="Target device IP (default: auto-discover)",
522 | )
523 | parser.add_argument(
524 | "-v", "--verbose",
525 | action="store_true",
526 | help="Show all endpoint tests including failures",
527 | )
528 | parser.add_argument(
529 | "-d", "--delay",
530 | type=float,
531 | default=RATE_LIMIT_DELAY,
532 | help=f"Delay between requests in seconds (default: {RATE_LIMIT_DELAY})",
533 | )
534 |
535 | args = parser.parse_args()
536 | session_label = args.ip or "auto-discover"
537 | start_log_session(session_label, args.delay)
538 |
539 | # Get target IP
540 | target_ip = args.ip
541 | if not target_ip:
542 | target_ip = discover_device()
543 | if not target_ip:
544 | log("❌ No devices found! Please specify IP address.")
545 | if LOG_AVAILABLE:
546 | log(f"📝 Detailed log saved to {LOG_FILE}")
547 | else:
548 | log("ℹ️ File logging unavailable for this run.")
549 | end_log_session()
550 | sys.exit(1)
551 |
552 | log()
553 | log(f"🎯 Target device: {target_ip}")
554 | log()
555 | log(f"[Session] Using target device: {target_ip}")
556 | log(f"[Session] Rate limit: {args.delay}s")
557 |
558 | # Create client
559 | client: Optional[MarstekApiDiscovery] = MarstekApiDiscovery(target_ip, verbose=args.verbose, delay=args.delay)
560 |
561 | try:
562 | client.connect()
563 | handshake_result = client.handshake()
564 | if not handshake_result.success:
565 | log("⚠️ Skipping endpoint sweep because handshake failed.")
566 | log(" Check network/firewall settings or increase --delay.")
567 | if LOG_AVAILABLE:
568 | log(f"📝 Detailed log saved to {LOG_FILE}")
569 | else:
570 | log("ℹ️ File logging unavailable for this run.")
571 | return
572 | results = test_all_endpoints(client)
573 | print_summary(results)
574 | if LOG_AVAILABLE:
575 | log(f"📝 Detailed log saved to {LOG_FILE}")
576 | else:
577 | log("ℹ️ File logging unavailable for this run.")
578 |
579 | except KeyboardInterrupt:
580 | log("\n\n⚠️ Discovery interrupted by user")
581 | sys.exit(0)
582 |
583 | finally:
584 | if client:
585 | client.disconnect()
586 | end_log_session()
587 |
588 |
589 | if __name__ == "__main__":
590 | main()
591 |
--------------------------------------------------------------------------------
/custom_components/marstek_local_api/services.py:
--------------------------------------------------------------------------------
1 | """Service helpers for the Marstek Local API integration."""
2 | from __future__ import annotations
3 |
4 | import asyncio
5 | import logging
6 | from datetime import time
7 |
8 | import voluptuous as vol
9 |
10 | from homeassistant.core import HomeAssistant, ServiceCall
11 | from homeassistant.exceptions import HomeAssistantError
12 | from homeassistant.helpers import config_validation as cv
13 | from homeassistant.helpers import device_registry as dr
14 |
15 | from .const import (
16 | DATA_COORDINATOR,
17 | DOMAIN,
18 | MAX_SCHEDULE_SLOTS,
19 | MODE_MANUAL,
20 | MODE_PASSIVE,
21 | SERVICE_CLEAR_MANUAL_SCHEDULES,
22 | SERVICE_REQUEST_SYNC,
23 | SERVICE_SET_MANUAL_SCHEDULE,
24 | SERVICE_SET_MANUAL_SCHEDULES,
25 | SERVICE_SET_PASSIVE_MODE,
26 | WEEKDAY_MAP,
27 | )
28 | from .coordinator import MarstekDataUpdateCoordinator, MarstekMultiDeviceCoordinator
29 |
30 | _LOGGER = logging.getLogger(__name__)
31 |
32 | SERVICE_REQUEST_SYNC_SCHEMA = vol.Schema(
33 | {
34 | vol.Optional("entry_id"): cv.string,
35 | vol.Optional("device_id"): cv.string,
36 | }
37 | )
38 |
39 | # Schedule service schemas
40 | def _days_to_week_set(days: list[str]) -> int:
41 | """Convert list of day names to week_set bitmap."""
42 | return sum(WEEKDAY_MAP[day] for day in days)
43 |
44 |
45 | SERVICE_SET_MANUAL_SCHEDULE_SCHEMA = vol.Schema(
46 | {
47 | vol.Required("device_id"): cv.string,
48 | vol.Required("time_num"): vol.All(vol.Coerce(int), vol.Range(min=0, max=MAX_SCHEDULE_SLOTS - 1)),
49 | vol.Required("start_time"): cv.time,
50 | vol.Required("end_time"): cv.time,
51 | vol.Optional("days", default=list(WEEKDAY_MAP.keys())): vol.All(
52 | cv.ensure_list, [vol.In(WEEKDAY_MAP.keys())]
53 | ),
54 | vol.Optional("power", default=0): vol.Coerce(int), # Negative=charge, positive=discharge, 0=no limit
55 | vol.Optional("enabled", default=True): cv.boolean,
56 | }
57 | )
58 |
59 | SERVICE_SET_MANUAL_SCHEDULES_SCHEMA = vol.Schema(
60 | {
61 | vol.Required("device_id"): cv.string,
62 | vol.Required("schedules"): [
63 | vol.Schema(
64 | {
65 | vol.Required("time_num"): vol.All(vol.Coerce(int), vol.Range(min=0, max=MAX_SCHEDULE_SLOTS - 1)),
66 | vol.Required("start_time"): cv.time,
67 | vol.Required("end_time"): cv.time,
68 | vol.Optional("days", default=list(WEEKDAY_MAP.keys())): vol.All(
69 | cv.ensure_list, [vol.In(WEEKDAY_MAP.keys())]
70 | ),
71 | vol.Optional("power", default=0): vol.Coerce(int), # Negative=charge, positive=discharge, 0=no limit
72 | vol.Optional("enabled", default=True): cv.boolean,
73 | }
74 | )
75 | ],
76 | }
77 | )
78 |
79 | SERVICE_CLEAR_MANUAL_SCHEDULES_SCHEMA = vol.Schema(
80 | {
81 | vol.Required("device_id"): cv.string,
82 | }
83 | )
84 |
85 | SERVICE_SET_PASSIVE_MODE_SCHEMA = vol.Schema(
86 | {
87 | vol.Required("device_id"): cv.string,
88 | vol.Required("power"): vol.All(vol.Coerce(int), vol.Range(min=-10000, max=10000)),
89 | vol.Required("duration"): vol.All(vol.Coerce(int), vol.Range(min=1, max=86400)),
90 | }
91 | )
92 |
93 |
94 | def _resolve_device_context(
95 | hass: HomeAssistant,
96 | device_id: str,
97 | ) -> tuple[MarstekDataUpdateCoordinator, MarstekMultiDeviceCoordinator | None, str | None]:
98 | """Resolve the per-device coordinator (and aggregate coordinator if any) for a Home Assistant device."""
99 | domain_data = hass.data.get(DOMAIN)
100 | if not domain_data:
101 | raise HomeAssistantError("Integration has no active entries")
102 |
103 | device_registry = dr.async_get(hass)
104 | device_entry = device_registry.async_get(device_id)
105 | if not device_entry:
106 | raise HomeAssistantError(f"Unknown device_id: {device_id}")
107 |
108 | if not device_entry.config_entries:
109 | raise HomeAssistantError(
110 | f"Device {device_id} is not associated with any Marstek config entry"
111 | )
112 |
113 | for entry_id in device_entry.config_entries:
114 | entry_payload = domain_data.get(entry_id)
115 | if not entry_payload:
116 | continue
117 |
118 | coordinator = entry_payload.get(DATA_COORDINATOR)
119 | if coordinator is None:
120 | continue
121 |
122 | if isinstance(coordinator, MarstekDataUpdateCoordinator):
123 | return coordinator, None, None
124 |
125 | device_identifier: str | None = None
126 | for domain, identifier in device_entry.identifiers:
127 | if domain == DOMAIN:
128 | device_identifier = identifier
129 | break
130 |
131 | if not device_identifier:
132 | raise HomeAssistantError(
133 | f"Device {device_id} lacks Marstek identifiers"
134 | )
135 |
136 | if device_identifier.startswith("system_"):
137 | raise HomeAssistantError(
138 | f"Device {device_id} targets the aggregate system; please choose a specific battery device"
139 | )
140 |
141 | device_coordinator = coordinator.device_coordinators.get(device_identifier)
142 | if device_coordinator is None:
143 | # Fallback to case-insensitive comparison
144 | for mac, candidate in coordinator.device_coordinators.items():
145 | if mac.lower() == device_identifier.lower():
146 | device_coordinator = candidate
147 | device_identifier = mac
148 | break
149 |
150 | if device_coordinator is None:
151 | raise HomeAssistantError(
152 | f"Could not find device coordinator for device {device_id}"
153 | )
154 |
155 | return device_coordinator, coordinator, device_identifier
156 |
157 | raise HomeAssistantError(
158 | f"Device {device_id} is not part of an active Marstek config entry"
159 | )
160 |
161 |
162 | async def _refresh_after_write(
163 | device_coordinator: MarstekDataUpdateCoordinator,
164 | aggregate_coordinator: MarstekMultiDeviceCoordinator | None,
165 | ) -> None:
166 | """Refresh device/aggregate coordinators after a state-changing operation."""
167 | try:
168 | await device_coordinator.async_request_refresh()
169 | except Exception as err: # noqa: BLE001
170 | _LOGGER.warning("Failed to refresh device coordinator after write: %s", err)
171 |
172 | if aggregate_coordinator:
173 | try:
174 | await aggregate_coordinator.async_request_refresh()
175 | except Exception as err: # noqa: BLE001
176 | _LOGGER.warning("Failed to refresh aggregate coordinator after write: %s", err)
177 |
178 |
179 | def _apply_local_mode_state(
180 | device_coordinator: MarstekDataUpdateCoordinator,
181 | aggregate_coordinator: MarstekMultiDeviceCoordinator | None,
182 | device_identifier: str | None,
183 | mode: str,
184 | mode_payload: dict | None = None,
185 | ) -> None:
186 | """Update cached coordinator data so operating mode sensors reflect changes immediately."""
187 | device_data = dict(device_coordinator.data or {})
188 | mode_state: dict[str, object] = {"mode": mode}
189 | if mode_payload:
190 | mode_state.update(mode_payload)
191 |
192 | current_mode = dict(device_data.get("mode") or {})
193 | current_mode.update(mode_state)
194 | device_data["mode"] = current_mode
195 | device_coordinator.async_set_updated_data(device_data)
196 |
197 | if aggregate_coordinator and device_identifier:
198 | aggregate_data = dict(aggregate_coordinator.data or {})
199 | devices = dict(aggregate_data.get("devices") or {})
200 | devices[device_identifier] = device_data
201 | aggregate_data["devices"] = devices
202 | aggregate_coordinator.async_set_updated_data(aggregate_data)
203 |
204 |
205 | async def async_setup_services(hass: HomeAssistant) -> None:
206 | """Register integration level services."""
207 |
208 | if hass.services.has_service(DOMAIN, SERVICE_REQUEST_SYNC):
209 | return
210 |
211 | async def _async_request_sync(call: ServiceCall) -> None:
212 | """Trigger an on-demand refresh across configured coordinators."""
213 | entry_id: str | None = call.data.get("entry_id")
214 | device_id: str | None = call.data.get("device_id")
215 | domain_data = hass.data.get(DOMAIN)
216 |
217 | if not domain_data:
218 | _LOGGER.debug("Request sync skipped - integration has no active entries")
219 | return
220 |
221 | if device_id:
222 | device_registry = dr.async_get(hass)
223 | device_entry = device_registry.async_get(device_id)
224 | if not device_entry:
225 | raise HomeAssistantError(f"Unknown device_id: {device_id}")
226 |
227 | if not device_entry.config_entries:
228 | raise HomeAssistantError(
229 | f"Device {device_id} is not associated with any Marstek config entry"
230 | )
231 |
232 | refreshed = False
233 | for candidate_entry_id in device_entry.config_entries:
234 | entry_payload = domain_data.get(candidate_entry_id)
235 | if not entry_payload:
236 | continue
237 | await _async_refresh_entry(candidate_entry_id, entry_payload)
238 | refreshed = True
239 |
240 | if not refreshed:
241 | raise HomeAssistantError(
242 | f"Device {device_id} is not part of an active Marstek config entry"
243 | )
244 | return
245 |
246 | if entry_id:
247 | entry_payload = domain_data.get(entry_id)
248 | if not entry_payload:
249 | _LOGGER.warning(
250 | "request_data_sync service received unknown entry_id: %s",
251 | entry_id,
252 | )
253 | return
254 | await _async_refresh_entry(entry_id, entry_payload)
255 | return
256 |
257 | for current_entry_id, entry_payload in domain_data.items():
258 | await _async_refresh_entry(current_entry_id, entry_payload)
259 |
260 | hass.services.async_register(
261 | DOMAIN,
262 | SERVICE_REQUEST_SYNC,
263 | _async_request_sync,
264 | schema=SERVICE_REQUEST_SYNC_SCHEMA,
265 | )
266 |
267 | async def _async_set_manual_schedule(call: ServiceCall) -> None:
268 | """Set a single manual mode schedule."""
269 | device_id = call.data["device_id"]
270 | time_num = call.data["time_num"]
271 | start_time: time = call.data["start_time"]
272 | end_time: time = call.data["end_time"]
273 | days = call.data["days"]
274 | power = call.data["power"]
275 | enabled = call.data["enabled"]
276 |
277 | device_coordinator, aggregate_coordinator, device_identifier = _resolve_device_context(
278 | hass,
279 | device_id,
280 | )
281 | target_label = device_identifier or device_id
282 |
283 | # Build manual_cfg
284 | manual_cfg = {
285 | "time_num": time_num,
286 | "start_time": start_time.strftime("%H:%M"),
287 | "end_time": end_time.strftime("%H:%M"),
288 | "week_set": _days_to_week_set(days),
289 | "power": power,
290 | "enable": 1 if enabled else 0,
291 | }
292 |
293 | config = {
294 | "mode": MODE_MANUAL,
295 | "manual_cfg": manual_cfg,
296 | }
297 |
298 | # Set mode via API
299 | try:
300 | success = await device_coordinator.api.set_es_mode(config)
301 | if success:
302 | _LOGGER.info(
303 | "Successfully set manual schedule %d for %s",
304 | time_num,
305 | target_label,
306 | )
307 | _apply_local_mode_state(
308 | device_coordinator,
309 | aggregate_coordinator,
310 | device_identifier,
311 | MODE_MANUAL,
312 | {"manual_cfg": manual_cfg},
313 | )
314 | hass.async_create_task(
315 | _refresh_after_write(device_coordinator, aggregate_coordinator)
316 | )
317 | else:
318 | raise HomeAssistantError(
319 | f"Device rejected schedule configuration for slot {time_num}"
320 | )
321 | except Exception as err:
322 | _LOGGER.error("Error setting manual schedule: %s", err)
323 | raise HomeAssistantError(f"Failed to set manual schedule: {err}") from err
324 |
325 | async def _async_set_manual_schedules(call: ServiceCall) -> None:
326 | """Set multiple manual mode schedules at once."""
327 | device_id = call.data["device_id"]
328 | schedules = call.data["schedules"]
329 |
330 | device_coordinator, aggregate_coordinator, device_identifier = _resolve_device_context(
331 | hass,
332 | device_id,
333 | )
334 | target_label = device_identifier or device_id
335 |
336 | _LOGGER.info("Setting %d manual schedules for %s", len(schedules), target_label)
337 |
338 | failed_slots = []
339 | any_success = False
340 |
341 | # Set each schedule sequentially
342 | for schedule in schedules:
343 | time_num = schedule["time_num"]
344 | start_time: time = schedule["start_time"]
345 | end_time: time = schedule["end_time"]
346 | days = schedule["days"]
347 | power = schedule["power"]
348 | enabled = schedule["enabled"]
349 |
350 | manual_cfg = {
351 | "time_num": time_num,
352 | "start_time": start_time.strftime("%H:%M"),
353 | "end_time": end_time.strftime("%H:%M"),
354 | "week_set": _days_to_week_set(days),
355 | "power": power,
356 | "enable": 1 if enabled else 0,
357 | }
358 |
359 | config = {
360 | "mode": MODE_MANUAL,
361 | "manual_cfg": manual_cfg,
362 | }
363 |
364 | try:
365 | success = await device_coordinator.api.set_es_mode(config)
366 | if success:
367 | _LOGGER.debug("Successfully set schedule slot %d", time_num)
368 | any_success = True
369 | else:
370 | _LOGGER.warning("Device rejected schedule slot %d", time_num)
371 | failed_slots.append(time_num)
372 | except Exception as err:
373 | _LOGGER.error("Error setting schedule slot %d: %s", time_num, err)
374 | failed_slots.append(time_num)
375 |
376 | # Small delay between calls for reliability
377 | await asyncio.sleep(0.5)
378 |
379 | # Refresh coordinator after all schedules are set
380 | if any_success:
381 | _apply_local_mode_state(
382 | device_coordinator,
383 | aggregate_coordinator,
384 | device_identifier,
385 | MODE_MANUAL,
386 | )
387 | hass.async_create_task(
388 | _refresh_after_write(device_coordinator, aggregate_coordinator)
389 | )
390 |
391 | if failed_slots:
392 | raise HomeAssistantError(
393 | f"Failed to set schedules for slots: {failed_slots}"
394 | )
395 |
396 | _LOGGER.info("Successfully set all %d schedules", len(schedules))
397 |
398 | async def _async_clear_manual_schedules(call: ServiceCall) -> None:
399 | """Clear all manual schedules by disabling all slots."""
400 | device_id = call.data["device_id"]
401 |
402 | device_coordinator, aggregate_coordinator, device_identifier = _resolve_device_context(
403 | hass,
404 | device_id,
405 | )
406 | target_label = device_identifier or device_id
407 |
408 | _LOGGER.info("Clearing all manual schedules for %s", target_label)
409 |
410 | failed_slots = []
411 | any_success = False
412 |
413 | # Disable all 10 schedule slots
414 | for i in range(MAX_SCHEDULE_SLOTS):
415 | config = {
416 | "mode": MODE_MANUAL,
417 | "manual_cfg": {
418 | "time_num": i,
419 | "start_time": "00:00",
420 | "end_time": "00:00",
421 | "week_set": 0, # No days
422 | "power": 0,
423 | "enable": 0, # Disabled
424 | },
425 | }
426 |
427 | try:
428 | success = await device_coordinator.api.set_es_mode(config)
429 | if success:
430 | any_success = True
431 | else:
432 | _LOGGER.warning("Device rejected clearing schedule slot %d", i)
433 | failed_slots.append(i)
434 | except Exception as err:
435 | _LOGGER.error("Error clearing schedule slot %d: %s", i, err)
436 | failed_slots.append(i)
437 |
438 | # Small delay between calls
439 | await asyncio.sleep(0.3)
440 |
441 | # Refresh coordinator
442 | if any_success:
443 | _apply_local_mode_state(
444 | device_coordinator,
445 | aggregate_coordinator,
446 | device_identifier,
447 | MODE_MANUAL,
448 | )
449 | hass.async_create_task(
450 | _refresh_after_write(device_coordinator, aggregate_coordinator)
451 | )
452 |
453 | if failed_slots:
454 | raise HomeAssistantError(
455 | f"Failed to clear schedules for slots: {failed_slots}"
456 | )
457 |
458 | _LOGGER.info("Successfully cleared all manual schedules")
459 |
460 | hass.services.async_register(
461 | DOMAIN,
462 | SERVICE_SET_MANUAL_SCHEDULE,
463 | _async_set_manual_schedule,
464 | schema=SERVICE_SET_MANUAL_SCHEDULE_SCHEMA,
465 | )
466 |
467 | hass.services.async_register(
468 | DOMAIN,
469 | SERVICE_SET_MANUAL_SCHEDULES,
470 | _async_set_manual_schedules,
471 | schema=SERVICE_SET_MANUAL_SCHEDULES_SCHEMA,
472 | )
473 |
474 | hass.services.async_register(
475 | DOMAIN,
476 | SERVICE_CLEAR_MANUAL_SCHEDULES,
477 | _async_clear_manual_schedules,
478 | schema=SERVICE_CLEAR_MANUAL_SCHEDULES_SCHEMA,
479 | )
480 |
481 | async def _async_set_passive_mode(call: ServiceCall) -> None:
482 | """Set passive mode with specified power and duration."""
483 | device_id = call.data["device_id"]
484 | power = call.data["power"]
485 | duration = call.data["duration"]
486 |
487 | device_coordinator, aggregate_coordinator, device_identifier = _resolve_device_context(
488 | hass,
489 | device_id,
490 | )
491 | target_label = device_identifier or device_id
492 |
493 | # Build passive mode config
494 | config = {
495 | "mode": MODE_PASSIVE,
496 | "passive_cfg": {
497 | "power": power,
498 | "cd_time": duration,
499 | },
500 | }
501 |
502 | # Set mode via API
503 | try:
504 | success = await device_coordinator.api.set_es_mode(config)
505 | if success:
506 | _LOGGER.info(
507 | "Successfully set passive mode: power=%dW, duration=%ds for %s",
508 | power,
509 | duration,
510 | target_label,
511 | )
512 | _apply_local_mode_state(
513 | device_coordinator,
514 | aggregate_coordinator,
515 | device_identifier,
516 | MODE_PASSIVE,
517 | {"passive_cfg": config["passive_cfg"]},
518 | )
519 | hass.async_create_task(
520 | _refresh_after_write(device_coordinator, aggregate_coordinator)
521 | )
522 | else:
523 | raise HomeAssistantError(
524 | f"Device rejected passive mode configuration (power={power}W, duration={duration}s)"
525 | )
526 | except Exception as err:
527 | _LOGGER.error("Error setting passive mode: %s", err)
528 | raise HomeAssistantError(f"Failed to set passive mode: {err}") from err
529 |
530 | hass.services.async_register(
531 | DOMAIN,
532 | SERVICE_SET_PASSIVE_MODE,
533 | _async_set_passive_mode,
534 | schema=SERVICE_SET_PASSIVE_MODE_SCHEMA,
535 | )
536 |
537 | _LOGGER.info("Registered service %s.%s", DOMAIN, SERVICE_REQUEST_SYNC)
538 | _LOGGER.info("Registered service %s.%s", DOMAIN, SERVICE_SET_MANUAL_SCHEDULE)
539 | _LOGGER.info("Registered service %s.%s", DOMAIN, SERVICE_SET_MANUAL_SCHEDULES)
540 | _LOGGER.info("Registered service %s.%s", DOMAIN, SERVICE_CLEAR_MANUAL_SCHEDULES)
541 | _LOGGER.info("Registered service %s.%s", DOMAIN, SERVICE_SET_PASSIVE_MODE)
542 |
543 |
544 | async def async_unload_services(hass: HomeAssistant) -> None:
545 | """Unregister integration level services."""
546 | if hass.services.has_service(DOMAIN, SERVICE_REQUEST_SYNC):
547 | hass.services.async_remove(DOMAIN, SERVICE_REQUEST_SYNC)
548 | _LOGGER.debug("Unregistered service %s.%s", DOMAIN, SERVICE_REQUEST_SYNC)
549 |
550 | if hass.services.has_service(DOMAIN, SERVICE_SET_MANUAL_SCHEDULE):
551 | hass.services.async_remove(DOMAIN, SERVICE_SET_MANUAL_SCHEDULE)
552 | _LOGGER.debug("Unregistered service %s.%s", DOMAIN, SERVICE_SET_MANUAL_SCHEDULE)
553 |
554 | if hass.services.has_service(DOMAIN, SERVICE_SET_MANUAL_SCHEDULES):
555 | hass.services.async_remove(DOMAIN, SERVICE_SET_MANUAL_SCHEDULES)
556 | _LOGGER.debug("Unregistered service %s.%s", DOMAIN, SERVICE_SET_MANUAL_SCHEDULES)
557 |
558 | if hass.services.has_service(DOMAIN, SERVICE_CLEAR_MANUAL_SCHEDULES):
559 | hass.services.async_remove(DOMAIN, SERVICE_CLEAR_MANUAL_SCHEDULES)
560 | _LOGGER.debug("Unregistered service %s.%s", DOMAIN, SERVICE_CLEAR_MANUAL_SCHEDULES)
561 |
562 | if hass.services.has_service(DOMAIN, SERVICE_SET_PASSIVE_MODE):
563 | hass.services.async_remove(DOMAIN, SERVICE_SET_PASSIVE_MODE)
564 | _LOGGER.debug("Unregistered service %s.%s", DOMAIN, SERVICE_SET_PASSIVE_MODE)
565 |
566 |
567 | async def _async_refresh_entry(entry_id: str, payload: dict) -> None:
568 | """Refresh a single config entry."""
569 | coordinator = payload.get(DATA_COORDINATOR)
570 | if coordinator is None:
571 | _LOGGER.debug("No coordinator stored for entry %s", entry_id)
572 | return
573 |
574 | if isinstance(coordinator, MarstekMultiDeviceCoordinator):
575 | _LOGGER.debug("Requesting multi-device sync for entry %s", entry_id)
576 | await coordinator.async_request_refresh()
577 | for mac, device_coordinator in coordinator.device_coordinators.items():
578 | if isinstance(device_coordinator, MarstekDataUpdateCoordinator):
579 | await device_coordinator.async_request_refresh()
580 | _LOGGER.debug("Requested device-level sync for %s (%s)", mac, entry_id)
581 | elif isinstance(coordinator, MarstekDataUpdateCoordinator):
582 | _LOGGER.debug("Requesting single-device sync for entry %s", entry_id)
583 | await coordinator.async_request_refresh()
584 | else:
585 | _LOGGER.debug(
586 | "Coordinator type %s not recognised for entry %s",
587 | type(coordinator),
588 | entry_id,
589 | )
590 |
--------------------------------------------------------------------------------
/docs/DESIGN.md:
--------------------------------------------------------------------------------
1 | # Marstek Local API Integration - Design Document
2 |
3 | ## Overview
4 |
5 | Home Assistant integration for Marstek energy storage systems using the official Local API (Rev 1.0). Provides comprehensive monitoring and control of Marstek Venus C/D/E devices without requiring cloud connectivity or additional hardware.
6 |
7 | **Version:** 1.0
8 | **Target Devices:** Venus C, Venus D, Venus E
9 | **Protocol:** JSON over UDP (port 30000+)
10 | **Requirements:** Local API enabled in Marstek app
11 |
12 | ---
13 |
14 | ## Architecture
15 |
16 | ### Component Structure
17 |
18 | ```
19 | marstek_local_api/
20 | ├── __init__.py # Integration setup, coordinator
21 | ├── manifest.json # Integration metadata
22 | ├── config_flow.py # UI configuration flow
23 | ├── const.py # Constants and mappings
24 | ├── coordinator.py # Data update coordinator
25 | ├── api.py # Local API client
26 | ├── sensor.py # Sensor platform
27 | ├── binary_sensor.py # Binary sensor platform
28 | ├── switch.py # Switch platform (future)
29 | └── select.py # Select platform (operating modes)
30 | ```
31 |
32 | ### Data Flow
33 |
34 | ```
35 | ┌─────────────────────────────────────────────────┐
36 | │ Home Assistant │
37 | │ │
38 | │ ┌─────────────────────────────────────────┐ │
39 | │ │ MarstekDataUpdateCoordinator │ │
40 | │ │ (polls every 30s) │ │
41 | │ │ │ │
42 | │ │ ┌─────────────────────────────────┐ │ │
43 | │ │ │ MarstekLocalAPI │ │ │
44 | │ │ │ - Device discovery (UDP) │ │ │
45 | │ │ │ - Multiple method calls │ │ │
46 | │ │ │ - Error handling │ │ │
47 | │ │ └─────────────────────────────────┘ │ │
48 | │ └─────────────────────────────────────────┘ │
49 | │ │ │
50 | │ ▼ │
51 | │ ┌─────────────────────────────────────────┐ │
52 | │ │ Entities (Sensors, Binary Sensors) │ │
53 | │ │ - Battery sensors │ │
54 | │ │ - Grid/CT sensors │ │
55 | │ │ - Energy system sensors │ │
56 | │ │ - PV sensors (Venus D) │ │
57 | │ │ - Network sensors │ │
58 | │ │ - Calculated sensors │ │
59 | │ └─────────────────────────────────────────┘ │
60 | └─────────────────────────────────────────────────┘
61 | │
62 | │ JSON/UDP (port 30000+)
63 | ▼
64 | ┌──────────────────────┐
65 | │ Marstek Venus E │
66 | │ (192.168.x.x:30000) │
67 | └──────────────────────┘
68 | ```
69 |
70 | ---
71 |
72 | ## Supported API Components
73 |
74 | ### 3.1 Marstek (Device Discovery)
75 | **Method:** `Marstek.GetDevice`
76 |
77 | | Field | Type | Sensor | Description |
78 | |-------|------|--------|-------------|
79 | | device | string | ✅ Text | Device model (VenusC, VenusD, VenusE) |
80 | | ver | number | ✅ Sensor | Firmware version |
81 | | ble_mac | string | ✅ Text | Bluetooth MAC |
82 | | wifi_mac | string | ✅ Text | WiFi MAC |
83 | | wifi_name | string | ✅ Text | WiFi SSID |
84 | | ip | string | ✅ Text | Device IP address |
85 |
86 | ### 3.2 WiFi
87 | **Method:** `Wifi.GetStatus`
88 |
89 | | Field | Type | Sensor | Description |
90 | |-------|------|--------|-------------|
91 | | ssid | string | ✅ Text | WiFi name |
92 | | rssi | number | ✅ Sensor | WiFi signal strength (dBm) |
93 | | sta_ip | string | ✅ Text | Device IP |
94 | | sta_gate | string | ✅ Text | Gateway IP |
95 | | sta_mask | string | ✅ Text | Subnet mask |
96 | | sta_dns | string | ✅ Text | DNS server |
97 |
98 | ### 3.3 Bluetooth
99 | **Method:** `BLE.GetStatus`
100 |
101 | | Field | Type | Sensor | Description |
102 | |-------|------|--------|-------------|
103 | | state | string | ✅ Binary | Bluetooth connection state |
104 | | ble_mac | string | ✅ Text | Bluetooth MAC address |
105 |
106 | ### 3.4 Battery
107 | **Method:** `Bat.GetStatus`
108 |
109 | | Field | Type | Sensor | Description |
110 | |-------|------|--------|-------------|
111 | | soc | number | ✅ Sensor | State of Charge (%) |
112 | | charg_flag | boolean | ✅ Binary | Charging permission flag |
113 | | dischrg_flag | boolean | ✅ Binary | Discharge permission flag |
114 | | bat_temp | number | ✅ Sensor | Battery temperature (°C) |
115 | | bat_capacity | number | ✅ Sensor | Battery remaining capacity (Wh) |
116 | | rated_capacity | number | ✅ Sensor | Battery rated capacity (Wh) |
117 |
118 | **Calculated Sensors:**
119 | - Available Capacity: `(100 - SOC) × rated_capacity / 100` (Wh)
120 | - Battery State: `charging` / `discharging` / `idle` (based on bat_power)
121 |
122 | ### 3.5 PV (Photovoltaic) - Venus D Only
123 | **Method:** `PV.GetStatus`
124 |
125 | | Field | Type | Sensor | Description |
126 | |-------|------|--------|-------------|
127 | | pv_power | number | ✅ Sensor | Solar charging power (W) |
128 | | pv_voltage | number | ✅ Sensor | Solar voltage (V) |
129 | | pv_current | number | ✅ Sensor | Solar current (A) |
130 |
131 | ### 3.6 ES (Energy System)
132 | **Method:** `ES.GetStatus`
133 |
134 | | Field | Type | Sensor | Description |
135 | |-------|------|--------|-------------|
136 | | bat_soc | number | ✅ Sensor | Total battery SOC (%) |
137 | | bat_cap | number | ✅ Sensor | Total battery capacity (Wh) |
138 | | bat_power | number | ✅ Sensor | Battery power (W, +charge/-discharge) |
139 | | pv_power | number | ✅ Sensor | Solar charging power (W) |
140 | | ongrid_power | number | ✅ Sensor | Grid-tied power (W) |
141 | | offgrid_power | number | ✅ Sensor | Off-grid power (W) |
142 | | total_pv_energy | number | ✅ Sensor | Total solar energy generated (Wh) |
143 | | total_grid_output_energy | number | ✅ Sensor | Total grid export energy (Wh) |
144 | | total_grid_input_energy | number | ✅ Sensor | Total grid import energy (Wh) |
145 | | total_load_energy | number | ✅ Sensor | Total load energy consumed (Wh) |
146 |
147 | **Method:** `ES.GetMode`
148 |
149 | | Field | Type | Sensor | Description |
150 | |-------|------|--------|-------------|
151 | | mode | string | ✅ Select | Operating mode (Auto/AI/Manual/Passive) |
152 | | ongrid_power | number | ✅ Sensor | Current grid power (W) |
153 | | offgrid_power | number | ✅ Sensor | Current off-grid power (W) |
154 | | bat_soc | number | ✅ Sensor | Battery SOC (%) |
155 |
156 | **Method:** `ES.SetMode` (Control)
157 | - Set operating mode: Auto, AI, Manual, Passive
158 | - Configure time schedules (Manual mode)
159 | - Set power limits (Passive mode)
160 |
161 | ### 3.7 EM (Energy Meter / CT)
162 | **Method:** `EM.GetStatus`
163 |
164 | | Field | Type | Sensor | Description |
165 | |-------|------|--------|-------------|
166 | | ct_state | number | ✅ Binary | CT connection status (0=disconnected, 1=connected) |
167 | | a_power | number | ✅ Sensor | Phase A power (W) |
168 | | b_power | number | ✅ Sensor | Phase B power (W) |
169 | | c_power | number | ✅ Sensor | Phase C power (W) |
170 | | total_power | number | ✅ Sensor | Total power (W) |
171 |
172 | ---
173 |
174 | ## Calculated/Derived Sensors
175 |
176 | ### Battery Power Flow
177 | Based on `ES.GetStatus.bat_power`:
178 |
179 | | Sensor | Calculation | Purpose |
180 | |--------|-------------|---------|
181 | | Battery Power In | `max(0, bat_power)` | Positive charging power |
182 | | Battery Power Out | `max(0, -bat_power)` | Positive discharging power |
183 | | Battery State | Based on bat_power sign | Text: charging/discharging/idle |
184 |
185 | ### Energy Dashboard Integration
186 | Using Home Assistant's integration platform:
187 |
188 | | Sensor | Source | Integration Type |
189 | |--------|--------|------------------|
190 | | Battery Energy In | Battery Power In | Integration (Riemann sum) |
191 | | Battery Energy Out | Battery Power Out | Integration (Riemann sum) |
192 | | Daily Battery In | Battery Energy In | Utility meter (daily cycle) |
193 | | Daily Battery Out | Battery Energy Out | Utility meter (daily cycle) |
194 |
195 | ### System Aggregates
196 | For multi-device setups:
197 |
198 | | Sensor | Calculation | Description |
199 | |--------|-------------|-------------|
200 | | System Total Battery Power | Sum all `bat_power` | Total system battery power |
201 | | System Average SOC | Average all `bat_soc` | Average system SOC |
202 | | System Total Capacity | Sum all `bat_cap` | Total system capacity |
203 |
204 | ---
205 |
206 | ## Device Structure
207 |
208 | ### Single Device Setup
209 | ```
210 | Venus E (192.168.1.10)
211 | ├── Battery
212 | │ ├── SOC (%)
213 | │ ├── Temperature (°C)
214 | │ ├── Capacity (Wh)
215 | │ ├── Rated Capacity (Wh)
216 | │ ├── Available Capacity (Wh) [calc]
217 | │ ├── Power (W)
218 | │ ├── Power In (W) [calc]
219 | │ ├── Power Out (W) [calc]
220 | │ ├── Charging Flag (binary)
221 | │ ├── Discharging Flag (binary)
222 | │ └── State (text) [calc]
223 | │
224 | ├── Energy System
225 | │ ├── Operating Mode (select)
226 | │ ├── Grid Power (W)
227 | │ ├── Off-Grid Power (W)
228 | │ ├── Total PV Energy (Wh)
229 | │ ├── Total Grid Import (Wh)
230 | │ ├── Total Grid Export (Wh)
231 | │ └── Total Load Energy (Wh)
232 | │
233 | ├── Grid/CT Meter
234 | │ ├── CT Connected (binary)
235 | │ ├── Phase A Power (W)
236 | │ ├── Phase B Power (W)
237 | │ ├── Phase C Power (W)
238 | │ └── Total Power (W)
239 | │
240 | ├── Solar (Venus D only)
241 | │ ├── PV Power (W)
242 | │ ├── PV Voltage (V)
243 | │ └── PV Current (A)
244 | │
245 | ├── Network
246 | │ ├── WiFi SSID (text)
247 | │ ├── WiFi RSSI (dBm)
248 | │ ├── WiFi IP (text)
249 | │ ├── WiFi Gateway (text)
250 | │ ├── WiFi Subnet (text)
251 | │ ├── WiFi DNS (text)
252 | │ ├── BLE Connected (binary)
253 | │ └── BLE MAC (text)
254 | │
255 | └── Device Info
256 | ├── Model (text)
257 | ├── Firmware Version (sensor)
258 | ├── WiFi MAC (text)
259 | └── IP Address (text)
260 | ```
261 |
262 | ### Multi-Device Setup
263 | ```
264 | Marstek System
265 | ├── Venus E #1 (Individual sensors as above)
266 | ├── Venus E #2 (Individual sensors as above)
267 | └── System Aggregates
268 | ├── Total Battery Power (W)
269 | ├── Average SOC (%)
270 | ├── Total Capacity (Wh)
271 | └── Combined Grid Power (W)
272 | ```
273 |
274 | ---
275 |
276 | ## Configuration Flow
277 |
278 | ### Step 1: Discovery
279 | - UDP broadcast `Marstek.GetDevice` on port 30000
280 | - Auto-discover all Marstek devices on LAN
281 | - Display discovered devices with model and IP
282 |
283 | ### Step 2: Device Selection
284 | - User selects device(s) to add
285 | - Option: "Add all discovered devices"
286 | - Option: "Manual IP entry" (if discovery fails)
287 |
288 | ### Step 3: Configuration
289 | ```yaml
290 | Configuration Fields:
291 | - Device IP: 192.168.1.10
292 | - Port: 30000 (default, customizable)
293 | - Update Interval: 60s (default, base interval for tiered polling)
294 | - Device Name: (optional, auto-fills with model)
295 | ```
296 |
297 | ### Step 4: Options Flow
298 | After setup, user can configure:
299 | - Update interval
300 | - Enable/disable specific sensor categories
301 | - Multi-device aggregation settings
302 |
303 | ---
304 |
305 | ## API Client Implementation
306 |
307 | ### Core Methods
308 | ```python
309 | class MarstekLocalAPI:
310 | async def discover_devices(self) -> list[dict]
311 | async def get_device_info(self, ip: str, port: int) -> dict
312 | async def get_wifi_status(self, ip: str, port: int) -> dict
313 | async def get_ble_status(self, ip: str, port: int) -> dict
314 | async def get_battery_status(self, ip: str, port: int) -> dict
315 | async def get_pv_status(self, ip: str, port: int) -> dict
316 | async def get_es_status(self, ip: str, port: int) -> dict
317 | async def get_es_mode(self, ip: str, port: int) -> dict
318 | async def set_es_mode(self, ip: str, port: int, config: dict) -> bool
319 | async def get_em_status(self, ip: str, port: int) -> dict
320 | ```
321 |
322 | ### Error Handling
323 | - **Timeout**: Retry once, then mark device unavailable
324 | - **JSON Parse Error**: Log and skip update cycle
325 | - **Method Not Found**: Device doesn't support this feature
326 | - **Invalid Params**: Log configuration issue
327 |
328 | ---
329 |
330 | ## Data Update Strategy
331 |
332 | ### Coordinator Pattern
333 | ```python
334 | class MarstekDataUpdateCoordinator(DataUpdateCoordinator):
335 | update_interval = 60 seconds (base interval)
336 |
337 | async def _async_update_data():
338 | # See "Polling Strategy" section for tiered polling implementation
339 | # High priority (60s): ES, Battery
340 | # Medium priority (300s): EM, PV, Mode
341 | # Low priority (600s): Device, WiFi, BLE
342 | return data
343 | ```
344 |
345 | ### Update Intervals
346 | - **Fast poll** (60s): ES & Battery status — real-time power/energy
347 | - **Medium poll** (300s): EM, PV, Mode — slower-changing data
348 | - **Slow poll** (600s): Device, WiFi, BLE — static/diagnostic data
349 |
350 | ---
351 |
352 | ## Energy Dashboard Integration
353 |
354 | ### Configuration
355 | ```yaml
356 | # Automatic sensor configuration for Energy Dashboard
357 |
358 | Grid Consumption:
359 | - sensor.marstek_total_grid_input_energy
360 |
361 | Grid Return:
362 | - sensor.marstek_total_grid_output_energy
363 |
364 | Solar Production:
365 | - sensor.marstek_total_pv_energy
366 |
367 | Battery:
368 | - Energy going in: sensor.marstek_battery_energy_in
369 | - Energy going out: sensor.marstek_battery_energy_out
370 | ```
371 |
372 | ### Required Sensor Attributes
373 | - `device_class`: energy
374 | - `state_class`: total_increasing
375 | - `unit_of_measurement`: Wh or kWh
376 |
377 | ---
378 |
379 | ## Control Features
380 |
381 | ### Operating Mode Control
382 | **Entity:** `select.marstek_operating_mode`
383 |
384 | Options:
385 | - Auto: Automatic mode
386 | - AI: AI-based optimization
387 | - Manual: Time-based schedules
388 | - Passive: Fixed power control
389 |
390 | ### Future Controls (via ES.SetMode)
391 | - Manual mode schedules
392 | - Power limit settings
393 | - Passive mode power/countdown
394 |
395 | ---
396 |
397 | ## Comparison: Local API vs BLE Gateway
398 |
399 | | Feature | Local API | BLE Gateway | Winner |
400 | |---------|-----------|-------------|--------|
401 | | **Setup** | Enable in app | ESP32 hardware | Local API |
402 | | **Reliability** | Network-based | BLE range limited | Local API |
403 | | **Official Support** | ✅ Official | ❌ Reverse-eng | Local API |
404 | | **Battery Data** | SOC, temp, capacity, power | + individual cells, V, I | BLE |
405 | | **Grid/CT Data** | ✅ Full phase data | ❌ None | Local API |
406 | | **Solar Data** | ✅ PV stats | ❌ None | Local API |
407 | | **Operating Mode** | ✅ Read + Control | ❌ None | Local API |
408 | | **Energy Totals** | ✅ Grid, PV, load | ❌ None | Local API |
409 | | **Output Control** | ❌ Not supported | ✅ Full control | BLE |
410 | | **Cell Voltages** | ❌ Not available | ✅ 16 cells | BLE |
411 |
412 | **Recommendation:** Use Local API for comprehensive system monitoring. Use BLE Gateway only if you need individual cell voltages or output control.
413 |
414 | ---
415 |
416 | ## Device Compatibility
417 |
418 | | Device | Marstek | WiFi | BLE | Battery | PV | ES | EM |
419 | |--------|---------|------|-----|---------|----|----|-----|
420 | | Venus C | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ |
421 | | Venus E | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ |
422 | | Venus D | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
423 |
424 | ---
425 |
426 | ## Firmware Version Handling
427 |
428 | ### Version Detection
429 | On device setup, query firmware version from `Marstek.GetDevice` response (`ver` field).
430 |
431 | ### Value Scaling by Firmware
432 |
433 | Different firmware versions use different decimal multipliers for certain values:
434 |
435 | | Field | Firmware < 154 | Firmware >= 154 |
436 | |-------|----------------|-----------------|
437 | | `bat_temp` | ÷ 10.0 | ÷ 1.0 |
438 | | `bat_capacity` | ÷ 100.0 | ÷ 1000.0 |
439 | | `bat_power` | ÷ 10.0 | ÷ 1.0 |
440 | | `total_grid_input_energy` | ÷ 100.0 | ÷ 10.0 |
441 | | `total_grid_output_energy` | ÷ 100.0 | ÷ 10.0 |
442 | | `total_load_energy` | ÷ 100.0 | ÷ 10.0 |
443 |
444 | **Implementation:**
445 | - Store firmware version in device data
446 | - Apply appropriate multiplier in sensor value_template or during data parsing
447 | - Log firmware version on integration setup for diagnostics
448 |
449 | ---
450 |
451 | ## Polling Strategy
452 |
453 | ### Non-Uniform Update Intervals
454 |
455 | Not all API methods need equal polling frequency. Optimize network usage with tiered polling:
456 |
457 | | Priority | Interval | Methods | Rationale |
458 | |----------|----------|---------|-----------|
459 | | High | 60s | `ES.GetStatus`, `Bat.GetStatus` | Real-time power/energy and battery state of charge |
460 | | Medium | 300s | `EM.GetStatus`, `PV.GetStatus`, `ES.GetMode` | Slower-changing CT, solar, mode data |
461 | | Low | 600s | `Marstek.GetDevice`, `Wifi.GetStatus`, `BLE.GetStatus` | Static/diagnostic data |
462 |
463 | **Implementation Pattern:**
464 | ```python
465 | class MarstekDataUpdateCoordinator:
466 | def __init__(self):
467 | self.update_count = 0
468 | self.base_interval = 60 # seconds
469 |
470 | async def _async_update_data(self):
471 | data = {}
472 |
473 | # Every update (60s)
474 | data["es"] = await self.api.get_es_status()
475 | data["battery"] = await self.api.get_battery_status()
476 |
477 | # Every 5th update (300s)
478 | if self.update_count % 5 == 0:
479 | data["pv"] = await self.api.get_pv_status()
480 | data["mode"] = await self.api.get_es_mode()
481 | data["em"] = await self.api.get_em_status()
482 |
483 | # Every 10th update (600s)
484 | if self.update_count % 10 == 0:
485 | data["device"] = await self.api.get_device_info()
486 | data["wifi"] = await self.api.get_wifi_status()
487 | data["ble"] = await self.api.get_ble_status()
488 |
489 | self.update_count += 1
490 | return data
491 | ```
492 |
493 | ---
494 |
495 | ## Reliability & Error Handling
496 |
497 | ### UDP Communication Challenges
498 |
499 | Based on production use, Marstek UDP API has reliability issues:
500 | - **Silent packet loss**: Battery ignores some requests without error
501 | - **Communication stops**: UDP may stop working after extended operation
502 | - **CT integration conflicts**: May conflict with CT002/CT003 devices
503 |
504 | ### Retry Mechanism
505 |
506 | **Mode Change Commands:**
507 | - Retry up to 5 times with 2-second delays
508 | - Validate `set_result` field in response
509 | - Timeout after 15 seconds per attempt
510 |
511 | ```python
512 | async def set_mode(config: dict, retries: int = 5):
513 | for attempt in range(retries):
514 | try:
515 | result = await send_command("ES.SetMode", {"id": 0, "config": config}, timeout=15)
516 | if result.get("set_result") == True:
517 | return True
518 | raise ValueError("Device rejected mode change")
519 | except Exception as e:
520 | if attempt < retries - 1:
521 | await asyncio.sleep(2)
522 | continue
523 | raise
524 |
525 | ## Diagnostics & Telemetry
526 |
527 | - The UDP client records per-method statistics (`total_attempts`, `total_success`, `total_timeouts`, `last_latency`).
528 | - Device coordinators summarise these values together with poll timing (`target_interval`, `actual_interval`).
529 | - A diagnostics handler (`diagnostics.py`) exposes the snapshot so users can download poll/latency numbers from Home Assistant's diagnostics panel.
530 |
531 |
532 | ### Command Response Matching
533 |
534 | **Pattern:**
535 | - Generate unique message ID: `f"homeassistant-{uuid4().hex[:8]}"`
536 | - Register temporary handler for response
537 | - Match response by ID field
538 | - Timeout and cleanup after 15 seconds
539 |
540 | ```python
541 | async def send_command(method: str, params: dict, timeout: int = 15):
542 | msg_id = f"homeassistant-{uuid4().hex[:8]}"
543 | payload = {"id": msg_id, "method": method, "params": params}
544 |
545 | response_event = asyncio.Event()
546 | response_data = {}
547 |
548 | def handler(json_msg):
549 | if json_msg.get("id") == msg_id:
550 | response_data.update(json_msg)
551 | response_event.set()
552 |
553 | self.register_handler(handler)
554 | try:
555 | await self.socket.send(json.dumps(payload))
556 | await asyncio.wait_for(response_event.wait(), timeout=timeout)
557 | return response_data.get("result")
558 | finally:
559 | self.unregister_handler(handler)
560 | ```
561 |
562 | ### Connection Health Monitoring
563 |
564 | - Coordinators store `last_message_seconds` so diagnostics can report how long it has been since a successful response.
565 | - Callers should mark the device unavailable when this exceeds `UNAVAILABLE_THRESHOLD` (120 seconds).
566 |
567 | ### Socket Architecture
568 |
569 | **Single Shared Socket:**
570 | - One UDP socket per HA instance (not per device)
571 | - Multiple handler callbacks registered
572 | - Handlers filter messages by source IP or message ID
573 | - Prevents port binding conflicts
574 |
575 | ```python
576 | class MarstekUDPSocket:
577 | def __init__(self):
578 | self.socket = None
579 | self.handlers = []
580 |
581 | def register_handler(self, callback):
582 | if callback not in self.handlers:
583 | self.handlers.append(callback)
584 |
585 | def unregister_handler(self, callback):
586 | if callback in self.handlers:
587 | self.handlers.remove(callback)
588 |
589 | async def on_message(self, data, addr):
590 | json_msg = json.loads(data.decode())
591 | for handler in self.handlers:
592 | await handler(json_msg, addr)
593 | ```
594 |
595 | ---
596 |
597 | ## Device Discovery
598 |
599 | ### Broadcast Discovery Protocol
600 |
601 | **Timing:**
602 | - Broadcast interval: 2 seconds
603 | - Discovery window: 9 seconds total
604 | - Message: `{"id":"homeassistant-discover","method":"Marstek.GetDevice","params":{"ble_mac":"0"}}`
605 |
606 | **Implementation:**
607 | ```python
608 | async def discover_devices(timeout: int = 9):
609 | devices = []
610 | discovered_macs = set()
611 |
612 | async def handler(json_msg, remote):
613 | if json_msg.get("method") == "Marstek.GetDevice" and "result" in json_msg:
614 | mac = json_msg["result"].get("wifi_mac")
615 | if mac and mac not in discovered_macs:
616 | discovered_macs.add(mac)
617 | devices.append({
618 | "name": json_msg["result"]["device"],
619 | "ip": remote[0],
620 | "mac": mac,
621 | "firmware": json_msg["result"]["ver"]
622 | })
623 |
624 | register_handler(handler)
625 | try:
626 | end_time = time.time() + timeout
627 | while time.time() < end_time:
628 | await broadcast(discover_message)
629 | await asyncio.sleep(2)
630 | finally:
631 | unregister_handler(handler)
632 |
633 | return devices
634 | ```
635 |
636 | ---
637 |
638 | ## Known Issues & Limitations
639 |
640 | ### UDP Reliability
641 | - **Symptom**: Communication stops without error after hours/days
642 | - **Mitigation**: Connection health monitoring, automatic reconnection
643 | - **User Action**: May need to restart HA integration or Marstek device
644 |
645 | ### Packet Loss
646 | - **Symptom**: Some UDP requests ignored by battery
647 | - **Mitigation**: Retry mechanism for critical commands
648 | - **Impact**: Occasional delayed updates
649 |
650 | ### Device Conflicts
651 | - **Symptom**: Battery stops responding when CT002/CT003 BLE devices are paired
652 | - **Cause**: Unknown (possibly firmware limitation)
653 | - **Workaround**: Use Local API for CT data via EM.GetStatus instead of BLE CT devices
654 |
655 | ### Single Session Limit
656 | - **Cloud API**: Marstek cloud only allows one active session per account
657 | - **Local API**: Not affected - multiple integrations can access local API simultaneously
658 |
659 | ### Missing Features vs BLE Gateway
660 | - No individual cell voltage monitoring (16 cells)
661 | - No per-cell temperature sensors
662 | - No direct output control switches
663 | - No EPS mode control
664 |
665 | ---
666 |
667 | ## Diagnostic Sensors
668 |
669 | Additional sensors for monitoring integration health:
670 |
671 | | Sensor | Purpose | Update Interval |
672 | |--------|---------|-----------------|
673 | | Last Message Received | Seconds since last API response | 1s |
674 | | Firmware Version | Device firmware version | On setup |
675 | | WiFi RSSI | Signal strength diagnostic | 60s |
676 | | BLE Connection State | Bluetooth status | 60s |
677 | | CT Connection State | CT meter connectivity | 15s |
678 |
679 | ---
680 |
681 | ## Installation
682 |
683 | ### HACS Installation
684 | 1. Add custom repository: `https://github.com/[user]/marstek-local-api`
685 | 2. Install "Marstek Local API" integration
686 | 3. Restart Home Assistant
687 | 4. Enable Local API in Marstek app
688 | 5. Add integration via UI
689 |
690 | ### Requirements
691 | - Home Assistant 2024.1.0 or newer
692 | - Marstek device with Local API enabled
693 | - Network connectivity to device
694 |
695 | ---
696 |
697 | ## Future Enhancements
698 |
699 | ### Phase 2
700 | - [ ] Switch entities for device control
701 | - [ ] Number entities for power limits
702 | - [ ] Diagnostic sensors (error logs, events)
703 | - [ ] Service calls for advanced control
704 |
705 | ### Phase 3
706 | - [ ] Automation triggers on mode changes
707 | - [ ] Battery health tracking over time
708 | - [ ] Load balancing across multiple devices
709 | - [ ] Integration with dynamic pricing
710 |
711 | ---
712 |
713 | ## Testing Strategy
714 |
715 | ### Unit Tests
716 | - API client methods
717 | - Data parsing
718 | - Error handling
719 | - Coordinator updates
720 |
721 | ### Integration Tests
722 | - Config flow
723 | - Sensor creation
724 | - State updates
725 | - Multi-device handling
726 |
727 | ### Manual Testing
728 | - Discovery on real network
729 | - All API methods
730 | - Error scenarios
731 | - Energy dashboard integration
732 |
733 | ---
734 |
735 | ## References
736 |
737 | - [Marstek Device Open API Rev 1.0](../Marstek_Device_Open_API_EN_.Rev1.0.pdf)
738 | - [Home Assistant Integration Development](https://developers.home-assistant.io/)
739 | - [ESPHome BLE Gateway](../marstek-ble-gateway/) (for feature comparison)
740 | - [Existing integrations](../home-assistant-marstek-local-api/) (for lessons learned)
741 |
--------------------------------------------------------------------------------
/tools/release.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | """Automation helper for Marstek Local API releases.
3 |
4 | This script manages release candidates and final releases by:
5 | * bumping manifest version numbers
6 | * creating a release commit
7 | * tagging the release
8 | * optionally pushing to origin
9 | * optionally creating a GitHub release (requires GITHUB_TOKEN)
10 |
11 | Usage examples:
12 | # Create the next release candidate for 1.2.0 (auto-increments rc number)
13 | python tools/release.py rc 1.2.0
14 |
15 | # Create final release 1.2.0 with explicit notes and push/tag/release
16 | python tools/release.py final 1.2.0 --notes-file notes.md --push
17 | """
18 | from __future__ import annotations
19 |
20 | import argparse
21 | import json
22 | import os
23 | from dataclasses import dataclass
24 | from pathlib import Path
25 | import re
26 | import subprocess
27 | import sys
28 | import textwrap
29 | from typing import Iterable, Sequence
30 | from urllib import error, request
31 |
32 | REPO_ROOT = Path(__file__).resolve().parent.parent
33 | DEFAULT_MANIFEST_GLOB = "custom_components/*/manifest.json"
34 |
35 |
36 | class ReleaseError(RuntimeError):
37 | """Raised for recoverable release issues."""
38 |
39 |
40 | def run_git(
41 | args: Sequence[str],
42 | *,
43 | capture_output: bool = False,
44 | check: bool = True,
45 | ) -> str:
46 | """Run a git command in the repository root."""
47 | cmd = ["git", *args]
48 | print("+", " ".join(cmd))
49 |
50 | run_kwargs: dict[str, object] = {"cwd": REPO_ROOT, "text": True}
51 | if capture_output:
52 | run_kwargs.update({"stdout": subprocess.PIPE, "stderr": subprocess.PIPE})
53 | else:
54 | run_kwargs["stderr"] = subprocess.PIPE
55 |
56 | result = subprocess.run(cmd, **run_kwargs)
57 |
58 | if check and result.returncode != 0:
59 | stderr = getattr(result, "stderr", "") or ""
60 | message = stderr.strip() or f"'git {' '.join(args)}' failed with {result.returncode}"
61 | raise ReleaseError(message)
62 |
63 | if capture_output:
64 | return result.stdout
65 | return ""
66 |
67 |
68 | def ensure_clean_worktree() -> None:
69 | """Abort if the repository contains uncommitted changes."""
70 | status = run_git(["status", "--porcelain"], capture_output=True)
71 | if status.strip():
72 | raise ReleaseError(
73 | "Repository has uncommitted changes. Please commit or stash before releasing."
74 | )
75 |
76 |
77 | def load_manifest_paths(explicit_paths: list[str] | None) -> list[Path]:
78 | """Return the manifest files to update."""
79 | if explicit_paths:
80 | manifests = [REPO_ROOT / path for path in explicit_paths]
81 | else:
82 | manifests = sorted(REPO_ROOT.glob(DEFAULT_MANIFEST_GLOB))
83 |
84 | if not manifests:
85 | raise ReleaseError(
86 | f"No manifest files found (searched for {DEFAULT_MANIFEST_GLOB})."
87 | )
88 |
89 | for manifest in manifests:
90 | if not manifest.exists():
91 | raise ReleaseError(f"Manifest file not found: {manifest}")
92 |
93 | return manifests
94 |
95 |
96 | def update_manifest_versions(
97 | manifests: Iterable[Path],
98 | *,
99 | new_version: str,
100 | dry_run: bool,
101 | ) -> list[Path]:
102 | """Update the version field in manifest files."""
103 | updated: list[Path] = []
104 | for manifest in manifests:
105 | data = json.loads(manifest.read_text())
106 | old_version = data.get("version")
107 | if old_version == new_version:
108 | raise ReleaseError(
109 | f"{manifest.relative_to(REPO_ROOT)} already has version {new_version}."
110 | )
111 | data["version"] = new_version
112 | serialised = json.dumps(data, indent=2) + "\n"
113 | if dry_run:
114 | print(
115 | f"[dry-run] Would update {manifest.relative_to(REPO_ROOT)}: "
116 | f"{old_version} -> {new_version}"
117 | )
118 | else:
119 | manifest.write_text(serialised)
120 | updated.append(manifest)
121 | return updated
122 |
123 |
124 | def validate_base_version(version: str) -> str:
125 | """Ensure version is in MAJOR.MINOR.PATCH format."""
126 | if not re.fullmatch(r"\d+\.\d+\.\d+", version):
127 | raise ReleaseError(f"Invalid base version '{version}'. Expected MAJOR.MINOR.PATCH.")
128 | return version
129 |
130 |
131 | def compute_rc_version(
132 | base_version: str,
133 | *,
134 | rc_number: int | None,
135 | ) -> tuple[str, int]:
136 | """Determine release candidate version string."""
137 | tags_output = run_git(["tag"], capture_output=True)
138 | pattern = re.compile(rf"^v{re.escape(base_version)}\.rc(\d+)$")
139 | existing_rcs = [int(match.group(1)) for tag in tags_output.splitlines() if (match := pattern.match(tag))]
140 |
141 | if rc_number is None:
142 | rc_number = max(existing_rcs, default=0) + 1
143 | elif rc_number <= 0:
144 | raise ReleaseError("RC number must be positive.")
145 | elif rc_number in existing_rcs:
146 | raise ReleaseError(
147 | f"Release candidate v{base_version}.rc{rc_number} already exists."
148 | )
149 |
150 | rc_version = f"{base_version}.rc{rc_number}"
151 | return rc_version, rc_number
152 |
153 |
154 | def get_latest_tag() -> str | None:
155 | """Return the latest reachable tag, if any."""
156 | try:
157 | tag = run_git(["describe", "--abbrev=0", "--tags"], capture_output=True).strip()
158 | except ReleaseError:
159 | return None
160 | return tag or None
161 |
162 |
163 | def generate_release_notes(previous_tag: str | None) -> str:
164 | """Generate default release notes from git history."""
165 | if previous_tag:
166 | rev_range = f"{previous_tag}..HEAD"
167 | else:
168 | rev_range = "HEAD"
169 |
170 | try:
171 | log_output = run_git(
172 | ["log", rev_range, "--no-merges", "--pretty=format:- %s"],
173 | capture_output=True,
174 | )
175 | except ReleaseError:
176 | log_output = ""
177 |
178 | notes = log_output.strip()
179 | if not notes:
180 | notes = "No notable changes."
181 | return notes
182 |
183 |
184 | def parse_repo_remote(remote: str) -> tuple[str, str]:
185 | """Extract owner/repo from git remote URL."""
186 | url = run_git(["remote", "get-url", remote], capture_output=True).strip()
187 | if url.startswith("git@github.com:"):
188 | path = url.split(":", 1)[1]
189 | elif url.startswith("https://github.com/"):
190 | path = url.split("github.com/", 1)[1]
191 | else:
192 | raise ReleaseError(f"Unsupported GitHub remote URL: {url}")
193 |
194 | if path.endswith(".git"):
195 | path = path[:-4]
196 |
197 | if path.count("/") != 1:
198 | raise ReleaseError(f"Unable to parse owner/repo from {url}")
199 |
200 | owner, repo = path.split("/")
201 | return owner, repo
202 |
203 |
204 | def http_post_json(url: str, payload: dict[str, object], headers: dict[str, str]) -> dict[str, object]:
205 | """Send a JSON POST request and return the parsed response."""
206 | data = json.dumps(payload).encode("utf-8")
207 | req = request.Request(
208 | url,
209 | data=data,
210 | method="POST",
211 | headers={
212 | "Accept": "application/vnd.github+json",
213 | "Content-Type": "application/json",
214 | **headers,
215 | },
216 | )
217 | try:
218 | with request.urlopen(req) as resp:
219 | response_bytes = resp.read()
220 | return json.loads(response_bytes.decode("utf-8"))
221 | except error.HTTPError as exc:
222 | detail = exc.read().decode("utf-8", errors="ignore")
223 | raise ReleaseError(
224 | f"GitHub API request failed ({exc.code}): {detail or exc.reason}"
225 | ) from exc
226 |
227 |
228 | def create_github_release(
229 | *,
230 | tag_name: str,
231 | release_name: str,
232 | body: str,
233 | prerelease: bool,
234 | remote: str,
235 | ) -> None:
236 | """Create a release on GitHub."""
237 | token = os.environ.get("GITHUB_TOKEN")
238 | if not token:
239 | raise ReleaseError(
240 | "GITHUB_TOKEN environment variable is required to create GitHub releases."
241 | )
242 |
243 | owner, repo = parse_repo_remote(remote)
244 | target_commitish = run_git(["rev-parse", "HEAD"], capture_output=True).strip()
245 |
246 | payload = {
247 | "tag_name": tag_name,
248 | "name": release_name,
249 | "body": body,
250 | "prerelease": prerelease,
251 | "target_commitish": target_commitish,
252 | }
253 |
254 | url = f"https://api.github.com/repos/{owner}/{repo}/releases"
255 | print(f"+ POST {url}")
256 | response = http_post_json(url, payload, headers={"Authorization": f"Bearer {token}"})
257 | html_url = response.get("html_url")
258 | if html_url:
259 | print(f"Created GitHub release: {html_url}")
260 |
261 |
262 | def build_parser() -> argparse.ArgumentParser:
263 | """Configure CLI arguments."""
264 | parser = argparse.ArgumentParser(
265 | description="Create release candidates and final releases for Marstek Local API.",
266 | formatter_class=argparse.RawDescriptionHelpFormatter,
267 | epilog=textwrap.dedent(
268 | """\
269 | Examples:
270 | python tools/release.py rc 1.2.0
271 | python tools/release.py rc 1.2.0 --rc-number 3
272 | python tools/release.py final 1.2.0 --notes \"Bug fixes\" --push
273 | """
274 | ),
275 | )
276 |
277 | subparsers = parser.add_subparsers(dest="command", required=True)
278 |
279 | def add_common_options(subparser: argparse.ArgumentParser) -> None:
280 | subparser.add_argument(
281 | "--manifest",
282 | action="append",
283 | help="Manifest file(s) to update (defaults to custom_components/*/manifest.json).",
284 | )
285 | subparser.add_argument(
286 | "--notes",
287 | help="Release notes text. Overrides auto-generated notes.",
288 | )
289 | subparser.add_argument(
290 | "--notes-file",
291 | help="Path to a file containing release notes.",
292 | )
293 | subparser.add_argument(
294 | "--remote",
295 | default="origin",
296 | help="Git remote to push and use for GitHub releases (default: origin).",
297 | )
298 | subparser.add_argument(
299 | "--push",
300 | action="store_true",
301 | help="Push the release commit and tag to the remote.",
302 | )
303 | subparser.add_argument(
304 | "--skip-github",
305 | action="store_true",
306 | help="Skip creating a GitHub release.",
307 | )
308 | subparser.add_argument(
309 | "--skip-tag",
310 | action="store_true",
311 | help="Do not create a git tag.",
312 | )
313 | subparser.add_argument(
314 | "--skip-commit",
315 | action="store_true",
316 | help="Do not create a release commit.",
317 | )
318 | subparser.add_argument(
319 | "--dry-run",
320 | action="store_true",
321 | help="Show actions without modifying anything.",
322 | )
323 | subparser.add_argument(
324 | "--commit-message",
325 | help="Custom commit message (default: Release ).",
326 | )
327 |
328 | final_parser = subparsers.add_parser(
329 | "final",
330 | help="Create a final release with the provided semantic version.",
331 | )
332 | final_parser.add_argument("version", help="Release version (MAJOR.MINOR.PATCH).")
333 | add_common_options(final_parser)
334 |
335 | rc_parser = subparsers.add_parser(
336 | "rc",
337 | help="Create a release candidate for the provided base version.",
338 | )
339 | rc_parser.add_argument("base_version", help="Base version (MAJOR.MINOR.PATCH).")
340 | rc_parser.add_argument(
341 | "--rc-number",
342 | type=int,
343 | help="Explicit RC number (default: next available).",
344 | )
345 | add_common_options(rc_parser)
346 |
347 | return parser
348 |
349 |
350 | def read_notes(args: argparse.Namespace, previous_tag: str | None) -> str:
351 | """Determine release notes text."""
352 | if args.notes_file:
353 | path = Path(args.notes_file)
354 | if not path.exists():
355 | raise ReleaseError(f"Notes file not found: {path}")
356 | return path.read_text().strip()
357 | if args.notes:
358 | return args.notes.strip()
359 | return generate_release_notes(previous_tag)
360 |
361 |
362 | def push_changes(remote: str, tag_name: str, *, push_tag: bool, push_branch: bool) -> None:
363 | """Push release commit and/or tag to the remote."""
364 | if push_branch:
365 | current_branch = run_git(
366 | ["rev-parse", "--abbrev-ref", "HEAD"],
367 | capture_output=True,
368 | ).strip()
369 | run_git(["push", remote, current_branch])
370 | if push_tag and tag_name:
371 | run_git(["push", remote, tag_name])
372 |
373 |
374 | @dataclass
375 | class ReleaseConfig:
376 | """Normalized configuration for executing a release."""
377 |
378 | version: str
379 | prerelease: bool
380 | base_version: str | None
381 | rc_number: int | None
382 | manifest_paths: list[Path]
383 | notes: str
384 | notes_source: str
385 | dry_run: bool
386 | create_commit: bool
387 | create_tag: bool
388 | push_branch: bool
389 | push_tag: bool
390 | create_github_release: bool
391 | remote: str
392 | commit_message: str
393 | previous_tag: str | None
394 |
395 |
396 | def check_git_status_interactive() -> None:
397 | """Warn about dirty worktree and allow the user to continue or abort."""
398 | status = run_git(["status", "--porcelain"], capture_output=True)
399 | if status.strip():
400 | print("⚠️ Uncommitted changes detected:\n")
401 | print(status.rstrip())
402 | response = prompt_input("\nContinue anyway? (y/N): ").strip().lower()
403 | if response != "y":
404 | raise ReleaseError("Aborted by user.")
405 |
406 |
407 | def collect_recent_commits(previous_tag: str | None) -> str:
408 | """Return a shortlog of commits since previous_tag."""
409 | if previous_tag:
410 | return run_git(
411 | ["log", f"{previous_tag}..HEAD", "--oneline"],
412 | capture_output=True,
413 | ).strip()
414 | return run_git(["log", "--oneline", "-10"], capture_output=True).strip()
415 |
416 |
417 | def prompt_input(message: str) -> str:
418 | """Read input from stdin and raise ReleaseError on cancellation."""
419 | try:
420 | return input(message)
421 | except EOFError as exc:
422 | raise ReleaseError("Input cancelled.") from exc
423 | except KeyboardInterrupt as exc:
424 | raise ReleaseError("Aborted by user.") from exc
425 |
426 |
427 | def build_interactive_config() -> ReleaseConfig:
428 | """Build a ReleaseConfig via the interactive flow inspired by grinder tool."""
429 | print("=== Marstek Local API Release Helper ===\n")
430 |
431 | check_git_status_interactive()
432 |
433 | manifest_paths = load_manifest_paths(None)
434 | manifest_list = ", ".join(str(path.relative_to(REPO_ROOT)) for path in manifest_paths)
435 | current_manifest_version = detect_current_manifest_version()
436 |
437 | previous_tag = get_latest_tag()
438 | current_tag_display = previous_tag or "v0.0.0"
439 |
440 | print(f"Detected manifest(s): {manifest_list}")
441 | if current_manifest_version:
442 | print(f"Current manifest version: {current_manifest_version}")
443 | print(f"Latest git tag: {current_tag_display}")
444 |
445 | commits = collect_recent_commits(previous_tag)
446 | if commits:
447 | print("\nRecent commits since last tag:")
448 | print(commits)
449 | else:
450 | print("\nNo new commits since last tag.")
451 | response = prompt_input("Continue anyway? (y/N): ").strip().lower()
452 | if response != "y":
453 | raise ReleaseError("Aborted by user.")
454 |
455 | tag_version = current_tag_display.lstrip("v")
456 | if tag_version == "0.0.0":
457 | # No real release yet
458 | tag_is_rc = False
459 | base_version = "0.0.0"
460 | else:
461 | tag_is_rc = is_rc_version(tag_version)
462 | base_version = strip_rc_suffix(tag_version) or "0.0.0"
463 |
464 | # Compute candidate versions
465 | patch_base = increment_base_version(base_version, "patch")
466 | minor_base = increment_base_version(base_version, "minor")
467 | major_base = increment_base_version(base_version, "major")
468 |
469 | patch_rc, patch_rc_number = compute_rc_version(patch_base, rc_number=None)
470 | minor_rc, minor_rc_number = compute_rc_version(minor_base, rc_number=None)
471 | major_rc, major_rc_number = compute_rc_version(major_base, rc_number=None)
472 |
473 | if tag_is_rc:
474 | promoted_version = strip_rc_suffix(tag_version)
475 | assert promoted_version is not None
476 | continue_rc, continue_rc_number = compute_rc_version(base_version, rc_number=None)
477 | else:
478 | promoted_version = None
479 | continue_rc = minor_rc
480 | continue_rc_number = minor_rc_number
481 |
482 | print("\nWhat would you like to release?")
483 | option_map: dict[str, dict[str, str | int | None]] = {}
484 |
485 | if tag_is_rc and promoted_version:
486 | print(f"0. Promote RC to stable: v{tag_version} → v{promoted_version}")
487 | option_map["0"] = {
488 | "version": promoted_version,
489 | "command": "final",
490 | "rc_number": None,
491 | "base_version": None,
492 | }
493 |
494 | print(f"1. Patch RC (bug fixes): v{tag_version} → v{patch_rc}")
495 | option_map["1"] = {
496 | "version": patch_rc,
497 | "command": "rc",
498 | "rc_number": patch_rc_number,
499 | "base_version": patch_base,
500 | }
501 |
502 | print(f"2. Minor RC (features): v{tag_version} → v{minor_rc}")
503 | option_map["2"] = {
504 | "version": minor_rc,
505 | "command": "rc",
506 | "rc_number": minor_rc_number,
507 | "base_version": minor_base,
508 | }
509 |
510 | print(f"3. Major RC (breaking changes): v{tag_version} → v{major_rc}")
511 | option_map["3"] = {
512 | "version": major_rc,
513 | "command": "rc",
514 | "rc_number": major_rc_number,
515 | "base_version": major_base,
516 | }
517 |
518 | if tag_is_rc:
519 | print(f"4. Continue RC testing: v{tag_version} → v{continue_rc}")
520 | else:
521 | print(f"4. Start RC cycle: v{tag_version} → v{continue_rc}")
522 | option_map["4"] = {
523 | "version": continue_rc,
524 | "command": "rc",
525 | "rc_number": continue_rc_number,
526 | "base_version": base_version if tag_is_rc else minor_base,
527 | }
528 |
529 | print("5. Custom version")
530 | print("6. Cancel")
531 |
532 | valid_choices = list(option_map.keys()) + ["5", "6"]
533 | choice = prompt_input(f"\nEnter choice ({', '.join(valid_choices)}): ").strip()
534 |
535 | if choice == "6":
536 | raise ReleaseError("Aborted by user.")
537 |
538 | if choice == "5":
539 | custom_version = prompt_input("Enter version (e.g., 1.2.3 or 1.2.3-rc.4): ").strip()
540 | if custom_version.startswith("v"):
541 | custom_version = custom_version[1:]
542 | if is_rc_version(custom_version):
543 | base_version, rc_number = parse_rc_components(custom_version)
544 | command = "rc"
545 | else:
546 | validate_base_version(custom_version)
547 | base_version = None
548 | rc_number = None
549 | command = "final"
550 | selected = {
551 | "version": custom_version,
552 | "command": command,
553 | "rc_number": rc_number,
554 | "base_version": base_version,
555 | }
556 | elif choice in option_map:
557 | selected = option_map[choice]
558 | else:
559 | raise ReleaseError("Invalid selection.")
560 |
561 | version = str(selected["version"])
562 | command = str(selected["command"])
563 | rc_number = selected["rc_number"]
564 | base_version_selected = selected["base_version"]
565 |
566 | print(f"\nPreparing release v{version}")
567 | notes = generate_release_notes(previous_tag)
568 |
569 | print("\n--- Release Preview ---")
570 | print(f"Version: v{version}")
571 | print(f"Manifest(s): {manifest_list}")
572 | print("Release Notes:")
573 | print(notes if notes else " (none)")
574 | print("--- End Preview ---\n")
575 |
576 | response = prompt_input(f"Proceed with release v{version}? (y/N): ").strip().lower()
577 | if response != "y":
578 | raise ReleaseError("Aborted by user.")
579 |
580 | prerelease = is_rc_version(version)
581 | if prerelease and base_version_selected is None:
582 | base_version_selected, rc_number = parse_rc_components(version)
583 |
584 | config = ReleaseConfig(
585 | version=version,
586 | prerelease=prerelease,
587 | base_version=base_version_selected if isinstance(base_version_selected, str) else None,
588 | rc_number=int(rc_number) if rc_number is not None else None,
589 | manifest_paths=manifest_paths,
590 | notes=notes,
591 | notes_source="auto-generated",
592 | dry_run=False,
593 | create_commit=True,
594 | create_tag=True,
595 | push_branch=True,
596 | push_tag=True,
597 | create_github_release=True,
598 | remote="origin",
599 | commit_message=f"Release {version}",
600 | previous_tag=previous_tag,
601 | )
602 | return config
603 |
604 |
605 | def create_config_from_args(args: argparse.Namespace) -> ReleaseConfig:
606 | """Translate CLI arguments to a ReleaseConfig."""
607 | manifest_paths = load_manifest_paths(args.manifest)
608 | previous_tag = get_latest_tag()
609 |
610 | if args.command == "final":
611 | version = validate_base_version(args.version)
612 | prerelease = False
613 | base_version = None
614 | rc_number = None
615 | else:
616 | base_version = validate_base_version(args.base_version)
617 | version, rc_number = compute_rc_version(base_version, rc_number=args.rc_number)
618 | prerelease = True
619 |
620 | notes = read_notes(args, previous_tag)
621 | notes_source = "provided" if (args.notes or args.notes_file) else "auto-generated"
622 |
623 | config = ReleaseConfig(
624 | version=version,
625 | prerelease=prerelease,
626 | base_version=base_version,
627 | rc_number=rc_number,
628 | manifest_paths=manifest_paths,
629 | notes=notes,
630 | notes_source=notes_source,
631 | dry_run=bool(args.dry_run),
632 | create_commit=not args.skip_commit,
633 | create_tag=not args.skip_tag,
634 | push_branch=bool(args.push),
635 | push_tag=bool(args.push) and not args.skip_tag,
636 | create_github_release=not args.skip_github,
637 | remote=args.remote,
638 | commit_message=args.commit_message or f"Release {version}",
639 | previous_tag=previous_tag,
640 | )
641 | return config
642 |
643 |
644 | def execute_release(config: ReleaseConfig) -> None:
645 | """Perform the release according to the supplied configuration."""
646 | release_tag = f"v{config.version}"
647 | manifest_rel_paths = [str(path.relative_to(REPO_ROOT)) for path in config.manifest_paths]
648 |
649 | updated_manifests = update_manifest_versions(
650 | config.manifest_paths,
651 | new_version=config.version,
652 | dry_run=config.dry_run,
653 | )
654 |
655 | if config.create_commit and not config.dry_run:
656 | run_git(["add", *[str(path.relative_to(REPO_ROOT)) for path in updated_manifests]])
657 | run_git(["commit", "-m", config.commit_message])
658 | elif config.create_commit:
659 | print("[dry-run] Would stage manifest changes and commit.")
660 | else:
661 | print("[skip] Not creating release commit.")
662 |
663 | if config.create_tag and not config.dry_run:
664 | run_git(["tag", "-a", release_tag, "-m", f"Release {config.version}"])
665 | elif config.create_tag:
666 | print(f"[dry-run] Would create tag {release_tag}.")
667 | else:
668 | print("[skip] Not creating git tag.")
669 |
670 | if config.push_branch or config.push_tag:
671 | if config.dry_run:
672 | print("[dry-run] Would push release commit/tag.")
673 | else:
674 | push_changes(
675 | config.remote,
676 | release_tag,
677 | push_tag=config.push_tag,
678 | push_branch=config.push_branch,
679 | )
680 |
681 | if config.create_github_release:
682 | if config.dry_run:
683 | print("[dry-run] Would create GitHub release.")
684 | else:
685 | release_name = release_tag
686 | if config.prerelease and config.base_version and config.rc_number is not None:
687 | release_name = f"{config.base_version} RC {config.rc_number}"
688 | create_github_release(
689 | tag_name=release_tag,
690 | release_name=release_name,
691 | body=config.notes,
692 | prerelease=config.prerelease,
693 | remote=config.remote,
694 | )
695 | else:
696 | print("[skip] Not creating GitHub release.")
697 |
698 | print("\nRelease details:")
699 | print(f" Version: {config.version}")
700 | print(f" Tag: {release_tag}")
701 | print(f" Type: {'Release Candidate' if config.prerelease else 'Final Release'}")
702 | if config.rc_number:
703 | print(f" RC number: {config.rc_number}")
704 | if config.base_version:
705 | print(f" Base version: {config.base_version}")
706 | print(f" Notes source: {config.notes_source}")
707 | print(f" Manifests: {', '.join(manifest_rel_paths)}")
708 |
709 |
710 | def detect_current_manifest_version() -> str | None:
711 | """Return the version currently stored in the first manifest, if any."""
712 | for manifest in sorted(REPO_ROOT.glob(DEFAULT_MANIFEST_GLOB)):
713 | try:
714 | data = json.loads(manifest.read_text())
715 | except (OSError, json.JSONDecodeError):
716 | continue
717 | version = data.get("version")
718 | if isinstance(version, str) and version:
719 | return version
720 | return None
721 |
722 |
723 | def strip_rc_suffix(version: str | None) -> str | None:
724 | """Return version without any -rc.* suffix."""
725 | if not version:
726 | return None
727 | match = re.match(r"(\d+\.\d+\.\d+)", version)
728 | if match:
729 | return match.group(1)
730 | return version
731 |
732 |
733 | def increment_base_version(version: str, increment: str) -> str:
734 | """Increment a semantic version string (without rc suffix)."""
735 | validate_base_version(version)
736 | major, minor, patch = map(int, version.split("."))
737 |
738 | if increment == "major":
739 | major += 1
740 | minor = 0
741 | patch = 0
742 | elif increment == "minor":
743 | minor += 1
744 | patch = 0
745 | elif increment == "patch":
746 | patch += 1
747 | else:
748 | raise ReleaseError(f"Unknown increment type '{increment}'.")
749 | return f"{major}.{minor}.{patch}"
750 |
751 |
752 | RC_VERSION_REGEX = re.compile(r"^(\d+\.\d+\.\d+)\.rc(\d+)$")
753 |
754 |
755 | def is_rc_version(version: str) -> bool:
756 | """Return True if version string denotes a release candidate."""
757 | return bool(RC_VERSION_REGEX.fullmatch(version))
758 |
759 |
760 | def parse_rc_components(version: str) -> tuple[str, int]:
761 | """Extract base version and RC number from an RC version string."""
762 | match = RC_VERSION_REGEX.fullmatch(version)
763 | if not match:
764 | raise ReleaseError(f"Invalid RC version: {version}")
765 | base_version = match.group(1)
766 | rc_number = int(match.group(2))
767 | return base_version, rc_number
768 |
769 |
770 | def main(argv: list[str] | None = None) -> None:
771 | os.chdir(REPO_ROOT)
772 |
773 | parser = build_parser()
774 | argv_list = list(sys.argv[1:] if argv is None else argv)
775 | if not argv_list:
776 | config = build_interactive_config()
777 | execute_release(config)
778 | return
779 |
780 | args = parser.parse_args(argv_list)
781 |
782 | ensure_clean_worktree()
783 |
784 | config = create_config_from_args(args)
785 | execute_release(config)
786 |
787 |
788 | if __name__ == "__main__":
789 | try:
790 | main()
791 | except ReleaseError as err:
792 | print(f"error: {err}", file=sys.stderr)
793 | sys.exit(1)
794 |
--------------------------------------------------------------------------------
/custom_components/marstek_local_api/config_flow.py:
--------------------------------------------------------------------------------
1 | """Config flow for Marstek Local API integration."""
2 | from __future__ import annotations
3 |
4 | import asyncio
5 | import logging
6 | from typing import Any
7 |
8 | import voluptuous as vol
9 |
10 | from homeassistant import config_entries
11 | from homeassistant.components import dhcp
12 | from homeassistant.config_entries import ConfigEntry
13 | from homeassistant.const import CONF_HOST
14 | from homeassistant.core import HomeAssistant
15 | from homeassistant.data_entry_flow import FlowResult
16 | from homeassistant.exceptions import HomeAssistantError
17 | from homeassistant.helpers import config_validation as cv
18 |
19 | from .api import MarstekAPIError, MarstekUDPClient
20 | from .const import CONF_PORT, DATA_COORDINATOR, DEFAULT_PORT, DEFAULT_SCAN_INTERVAL, DOMAIN
21 |
22 | _LOGGER = logging.getLogger(__name__)
23 |
24 | STEP_USER_DATA_SCHEMA = vol.Schema(
25 | {
26 | vol.Optional(CONF_HOST): str,
27 | vol.Optional(CONF_PORT, default=DEFAULT_PORT): int,
28 | }
29 | )
30 |
31 |
32 | async def validate_input(hass: HomeAssistant, data: dict[str, Any], use_ephemeral_port: bool = False) -> dict[str, Any]:
33 | """Validate the user input allows us to connect.
34 |
35 | Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user.
36 | use_ephemeral_port: Deprecated parameter, kept for compatibility
37 | """
38 | # Always bind to device port (reuse_port allows multiple instances)
39 | target_port = data.get(CONF_PORT, DEFAULT_PORT)
40 | api = MarstekUDPClient(hass, data.get(CONF_HOST), target_port, remote_port=target_port)
41 |
42 | try:
43 | await api.connect()
44 |
45 | # Try to get device info
46 | device_info = await api.get_device_info()
47 |
48 | if not device_info:
49 | raise CannotConnect("Failed to get device information")
50 |
51 | # Return info that you want to store in the config entry.
52 | return {
53 | "title": f"{device_info.get('device', 'Marstek Device')} ({device_info.get('ble_mac', device_info.get('wifi_mac', 'Unknown'))})",
54 | "device": device_info.get("device"),
55 | "firmware": device_info.get("ver"),
56 | "wifi_mac": device_info.get("wifi_mac"),
57 | "ble_mac": device_info.get("ble_mac"),
58 | }
59 |
60 | except MarstekAPIError as err:
61 | _LOGGER.error("Error connecting to Marstek device: %s", err)
62 | raise CannotConnect from err
63 | finally:
64 | await api.disconnect()
65 |
66 | class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
67 | """Handle a config flow for Marstek Local API."""
68 |
69 | VERSION = 1
70 |
71 | def __init__(self) -> None:
72 | """Initialize the config flow."""
73 | self._discovered_devices: list[dict] = []
74 |
75 | async def async_step_user(
76 | self, user_input: dict[str, Any] | None = None
77 | ) -> FlowResult:
78 | """Handle the initial step."""
79 | # Start discovery
80 | return await self.async_step_discovery()
81 |
82 | async def async_step_discovery(
83 | self, user_input: dict[str, Any] | None = None
84 | ) -> FlowResult:
85 | """Handle discovery of devices."""
86 | errors = {}
87 |
88 | if user_input is None:
89 | # Perform discovery
90 | # Temporarily disconnect existing integration clients to avoid port conflicts
91 | paused_clients = []
92 | for entry in self._async_current_entries():
93 | if DOMAIN in self.hass.data and entry.entry_id in self.hass.data[DOMAIN]:
94 | coordinator = self.hass.data[DOMAIN][entry.entry_id].get(DATA_COORDINATOR)
95 | if coordinator:
96 | # Handle both single-device and multi-device coordinators
97 | if hasattr(coordinator, 'device_coordinators'):
98 | # Multi-device coordinator
99 | _LOGGER.debug("Pausing multi-device coordinator %s during discovery", entry.title)
100 | for device_coordinator in coordinator.device_coordinators.values():
101 | if device_coordinator.api:
102 | await device_coordinator.api.disconnect()
103 | paused_clients.append(device_coordinator.api)
104 | elif hasattr(coordinator, 'api') and coordinator.api:
105 | # Single-device coordinator
106 | _LOGGER.debug("Pausing API client for %s during discovery", entry.title)
107 | await coordinator.api.disconnect()
108 | paused_clients.append(coordinator.api)
109 |
110 | # Wait a bit for disconnections to complete and sockets to close
111 | import asyncio
112 | await asyncio.sleep(1)
113 |
114 | # Bind to same port as device (required by Marstek protocol)
115 | api = MarstekUDPClient(self.hass, port=DEFAULT_PORT, remote_port=DEFAULT_PORT)
116 | try:
117 | await api.connect()
118 | self._discovered_devices = await api.discover_devices()
119 | await api.disconnect()
120 |
121 | _LOGGER.info("Discovered %d device(s): %s", len(self._discovered_devices), self._discovered_devices)
122 | except Exception as err:
123 | _LOGGER.error("Discovery failed: %s", err, exc_info=True)
124 | try:
125 | await api.disconnect()
126 | except Exception:
127 | pass # Ignore disconnect errors
128 | return await self.async_step_manual()
129 | finally:
130 | # Wait a bit before resuming to ensure discovery socket is fully closed
131 | await asyncio.sleep(1)
132 |
133 | # Resume paused clients
134 | for client in paused_clients:
135 | try:
136 | _LOGGER.debug("Resuming paused API client for host %s", client.host)
137 | await client.connect()
138 | except Exception as err:
139 | _LOGGER.warning("Failed to resume client for host %s: %s", client.host, err)
140 |
141 | if not self._discovered_devices:
142 | # No devices found, offer manual entry
143 | return await self.async_step_manual()
144 |
145 | # Build list of discovered devices
146 | devices_list = {}
147 |
148 | # Add "All devices" option if multiple devices found
149 | if len(self._discovered_devices) > 1:
150 | devices_list["__all__"] = f"All devices ({len(self._discovered_devices)} batteries)"
151 |
152 | for device in self._discovered_devices:
153 | mac = device["mac"]
154 | # Show all devices, the abort happens when user selects one already configured
155 | devices_list[mac] = f"{device['name']} ({device['ip']})"
156 | _LOGGER.debug("Adding device to list: %s (%s) MAC: %s", device['name'], device['ip'], mac)
157 |
158 | _LOGGER.info("Built device list with %d device(s)", len(devices_list))
159 |
160 | # Add manual entry option
161 | devices_list["manual"] = "Manual IP entry"
162 |
163 | return self.async_show_form(
164 | step_id="discovery",
165 | data_schema=vol.Schema(
166 | {
167 | vol.Required("device"): vol.In(devices_list),
168 | }
169 | ),
170 | errors=errors,
171 | )
172 |
173 | # User selected a device
174 | selected = user_input["device"]
175 |
176 | if selected == "manual":
177 | return await self.async_step_manual()
178 |
179 | # Check if user selected "All devices"
180 | if selected == "__all__":
181 | # Create multi-device entry using combined BLE MACs for uniqueness
182 | all_ble_macs = sorted(
183 | {
184 | d["ble_mac"]
185 | for d in self._discovered_devices
186 | if d.get("ble_mac")
187 | }
188 | )
189 | unique_id = "_".join(all_ble_macs)
190 |
191 | if not unique_id:
192 | _LOGGER.debug("No BLE MACs found during multi-device selection; skipping duplicate guard")
193 | else:
194 | await self.async_set_unique_id(unique_id)
195 | self._abort_if_unique_id_configured()
196 |
197 | return self.async_create_entry(
198 | title=f"Marstek System ({len(self._discovered_devices)} batteries)",
199 | data={
200 | "devices": [
201 | {
202 | CONF_HOST: d["ip"],
203 | CONF_PORT: DEFAULT_PORT,
204 | "wifi_mac": d.get("wifi_mac"),
205 | "ble_mac": d.get("ble_mac"),
206 | "device": d["name"],
207 | "firmware": d["firmware"],
208 | }
209 | for d in self._discovered_devices
210 | ],
211 | },
212 | )
213 |
214 | # Find selected device (single device mode)
215 | device = next(
216 | (d for d in self._discovered_devices if d["mac"] == selected), None
217 | )
218 |
219 | if not device:
220 | errors["base"] = "device_not_found"
221 | return self.async_show_form(step_id="discovery", errors=errors)
222 |
223 | # Check if already configured
224 | unique_id = device.get("ble_mac")
225 | if not unique_id:
226 | _LOGGER.debug("Device %s missing BLE MAC; continuing without duplicate guard", device.get("ip"))
227 | else:
228 | await self.async_set_unique_id(unique_id)
229 | self._abort_if_unique_id_configured()
230 |
231 | # Create entry for single device
232 | return self.async_create_entry(
233 | title=f"{device['name']} ({device['ip']})",
234 | data={
235 | CONF_HOST: device["ip"],
236 | CONF_PORT: DEFAULT_PORT,
237 | "wifi_mac": device.get("wifi_mac"),
238 | "ble_mac": device.get("ble_mac"),
239 | "device": device["name"],
240 | "firmware": device["firmware"],
241 | },
242 | )
243 |
244 | async def async_step_manual(
245 | self, user_input: dict[str, Any] | None = None
246 | ) -> FlowResult:
247 | """Handle manual IP entry."""
248 | errors = {}
249 |
250 | if user_input is not None:
251 | try:
252 | info = await validate_input(self.hass, user_input)
253 |
254 | # Check if already configured
255 | unique_id = info.get("ble_mac")
256 | if not unique_id:
257 | _LOGGER.debug("Manual setup for host %s missing BLE MAC; skipping duplicate guard", user_input.get(CONF_HOST))
258 | else:
259 | await self.async_set_unique_id(unique_id)
260 | self._abort_if_unique_id_configured()
261 |
262 | return self.async_create_entry(
263 | title=info["title"],
264 | data={
265 | CONF_HOST: user_input[CONF_HOST],
266 | CONF_PORT: user_input[CONF_PORT],
267 | "wifi_mac": info["wifi_mac"],
268 | "ble_mac": info["ble_mac"],
269 | "device": info["device"],
270 | "firmware": info["firmware"],
271 | },
272 | )
273 |
274 | except CannotConnect:
275 | errors["base"] = "cannot_connect"
276 | except Exception: # pylint: disable=broad-except
277 | _LOGGER.exception("Unexpected exception")
278 | errors["base"] = "unknown"
279 |
280 | return self.async_show_form(
281 | step_id="manual",
282 | data_schema=STEP_USER_DATA_SCHEMA,
283 | errors=errors,
284 | )
285 |
286 | async def async_step_dhcp(
287 | self, discovery_info: dhcp.DhcpServiceInfo
288 | ) -> FlowResult:
289 | """Handle DHCP discovery."""
290 | # Extract info from DHCP discovery
291 | host = discovery_info.ip
292 | mac = discovery_info.macaddress
293 |
294 | # Validate the device using ephemeral port to avoid conflicts
295 | try:
296 | info = await validate_input(
297 | self.hass,
298 | {CONF_HOST: host, CONF_PORT: DEFAULT_PORT},
299 | use_ephemeral_port=True
300 | )
301 |
302 | # Check if already configured
303 | unique_id = info.get("ble_mac")
304 | if not unique_id:
305 | _LOGGER.debug("DHCP discovery for host %s missing BLE MAC; skipping duplicate guard", host)
306 | else:
307 | await self.async_set_unique_id(unique_id)
308 | self._abort_if_unique_id_configured(updates={CONF_HOST: host})
309 |
310 | # Store discovery info for confirmation
311 | self.context["title_placeholders"] = {"name": info["title"]}
312 | self.context["device_info"] = {
313 | CONF_HOST: host,
314 | CONF_PORT: DEFAULT_PORT,
315 | "wifi_mac": info["wifi_mac"],
316 | "ble_mac": info["ble_mac"],
317 | "device": info["device"],
318 | "firmware": info["firmware"],
319 | }
320 |
321 | return await self.async_step_discovery_confirm()
322 |
323 | except CannotConnect:
324 | return self.async_abort(reason="cannot_connect")
325 | except Exception: # pylint: disable=broad-except
326 | _LOGGER.exception("Unexpected exception during DHCP discovery")
327 | return self.async_abort(reason="unknown")
328 |
329 | async def async_step_discovery_confirm(
330 | self, user_input: dict[str, Any] | None = None
331 | ) -> FlowResult:
332 | """Confirm discovery."""
333 | if user_input is not None:
334 | device_info = self.context["device_info"]
335 | return self.async_create_entry(
336 | title=self.context["title_placeholders"]["name"],
337 | data=device_info,
338 | )
339 |
340 | return self.async_show_form(
341 | step_id="discovery_confirm",
342 | description_placeholders=self.context.get("title_placeholders"),
343 | )
344 |
345 | @staticmethod
346 | def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow:
347 | """Get the options flow for this handler."""
348 | return OptionsFlow(config_entry)
349 |
350 |
351 | class OptionsFlow(config_entries.OptionsFlow):
352 | """Handle options flow for Marstek Local API."""
353 |
354 | def __init__(self, config_entry: ConfigEntry) -> None:
355 | """Initialise the options flow."""
356 | self.config_entry = config_entry
357 | self._devices: list[dict[str, Any]] = list(config_entry.data.get("devices", []))
358 | self._discovered_devices: list[dict[str, Any]] = []
359 |
360 | async def async_step_init(
361 | self, user_input: dict[str, Any] | None = None
362 | ) -> FlowResult:
363 | """Entry-point for options flow; present available actions."""
364 | actions: dict[str, str] = {
365 | "scan_interval": "Adjust update interval",
366 | }
367 |
368 | if self._devices:
369 | actions.update(
370 | {
371 | "rename_device": "Rename a device",
372 | "remove_device": "Remove a device",
373 | "add_device": "Add a device",
374 | }
375 | )
376 |
377 | if user_input is not None:
378 | action = user_input["action"]
379 | if action == "scan_interval":
380 | return await self.async_step_scan_interval()
381 | if action == "rename_device":
382 | return await self.async_step_rename_device()
383 | if action == "remove_device":
384 | return await self.async_step_remove_device()
385 | if action == "add_device":
386 | return await self.async_step_add_device()
387 |
388 | return self.async_show_form(
389 | step_id="init",
390 | data_schema=vol.Schema(
391 | {
392 | vol.Required("action"): vol.In(actions),
393 | }
394 | ),
395 | )
396 |
397 | async def async_step_scan_interval(
398 | self, user_input: dict[str, Any] | None = None
399 | ) -> FlowResult:
400 | """Adjust polling interval."""
401 | if user_input is not None:
402 | return self.async_create_entry(title="", data=user_input)
403 |
404 | return self.async_show_form(
405 | step_id="scan_interval",
406 | data_schema=vol.Schema(
407 | {
408 | vol.Optional(
409 | "scan_interval",
410 | default=self.config_entry.options.get(
411 | "scan_interval", DEFAULT_SCAN_INTERVAL
412 | ),
413 | ): vol.All(vol.Coerce(int), vol.Range(min=15, max=900)),
414 | }
415 | ),
416 | )
417 |
418 | async def async_step_rename_device(
419 | self, user_input: dict[str, Any] | None = None
420 | ) -> FlowResult:
421 | """Rename an existing device in a multi-device configuration."""
422 | if not self._devices:
423 | return self.async_abort(reason="unknown")
424 |
425 | errors: dict[str, str] = {}
426 |
427 | device_options = {
428 | idx: f"{device.get('device', f'Device {idx + 1}')} ({device.get('host')}:{device.get('port')})"
429 | for idx, device in enumerate(self._devices)
430 | }
431 |
432 | if user_input is not None:
433 | device_index = user_input["device"]
434 | new_name: str = user_input["name"].strip()
435 |
436 | if not new_name:
437 | errors["name"] = "invalid_name"
438 | elif device_index >= len(self._devices):
439 | errors["base"] = "device_not_found"
440 | else:
441 | current_device = self._devices[device_index]
442 | if current_device.get("device") == new_name:
443 | return self.async_create_entry(title="", data={})
444 |
445 | updated_devices = list(self._devices)
446 | updated_device = dict(updated_devices[device_index])
447 | updated_device["device"] = new_name
448 | updated_devices[device_index] = updated_device
449 |
450 | new_data = {**self.config_entry.data, "devices": updated_devices}
451 | self.hass.config_entries.async_update_entry(
452 | self.config_entry,
453 | data=new_data,
454 | )
455 | self._devices = updated_devices
456 | return self.async_create_entry(title="", data={})
457 |
458 | default_index = 0
459 | default_name = (
460 | self._devices[default_index].get("device", f"Device {default_index + 1}")
461 | if self._devices
462 | else ""
463 | )
464 |
465 | return self.async_show_form(
466 | step_id="rename_device",
467 | data_schema=vol.Schema(
468 | {
469 | vol.Required("device", default=default_index): vol.In(device_options),
470 | vol.Required("name", default=default_name): cv.string,
471 | }
472 | ),
473 | errors=errors,
474 | )
475 |
476 | async def async_step_remove_device(
477 | self, user_input: dict[str, Any] | None = None
478 | ) -> FlowResult:
479 | """Remove a device from a multi-device configuration."""
480 | if not self._devices:
481 | return self.async_abort(reason="unknown")
482 |
483 | errors: dict[str, str] = {}
484 |
485 | if len(self._devices) <= 1:
486 | errors["base"] = "cannot_remove_last_device"
487 |
488 | device_options = {
489 | idx: f"{device.get('device', f'Device {idx + 1}')} ({device.get('host')}:{device.get('port')})"
490 | for idx, device in enumerate(self._devices)
491 | }
492 |
493 | if user_input is not None:
494 | if errors:
495 | return self.async_show_form(
496 | step_id="remove_device",
497 | data_schema=vol.Schema(
498 | {
499 | vol.Required("device"): vol.In(device_options),
500 | }
501 | ),
502 | errors=errors,
503 | )
504 |
505 | device_index = user_input["device"]
506 | if device_index >= len(self._devices):
507 | errors["base"] = "device_not_found"
508 | else:
509 | updated_devices = [
510 | device
511 | for idx, device in enumerate(self._devices)
512 | if idx != device_index
513 | ]
514 | if not updated_devices:
515 | errors["base"] = "cannot_remove_last_device"
516 | else:
517 | new_data = {**self.config_entry.data, "devices": updated_devices}
518 | self.hass.config_entries.async_update_entry(
519 | self.config_entry,
520 | data=new_data,
521 | )
522 | self._devices = updated_devices
523 | return self.async_create_entry(title="", data={})
524 |
525 | return self.async_show_form(
526 | step_id="remove_device",
527 | data_schema=vol.Schema(
528 | {
529 | vol.Required("device"): vol.In(device_options),
530 | }
531 | ),
532 | errors=errors,
533 | )
534 |
535 | async def async_step_add_device(
536 | self, user_input: dict[str, Any] | None = None
537 | ) -> FlowResult:
538 | """Discover and add a new device to the configuration."""
539 | if not self._devices:
540 | return self.async_abort(reason="unknown")
541 |
542 | errors: dict[str, str] = {}
543 |
544 | if user_input is None:
545 | await self._async_discover_devices()
546 |
547 | existing_macs = {
548 | device.get("ble_mac") or device.get("wifi_mac")
549 | for device in self._devices
550 | if device.get("ble_mac") or device.get("wifi_mac")
551 | }
552 | discovered_options: dict[str, str] = {}
553 |
554 | for device in self._discovered_devices:
555 | mac = device.get("ble_mac") or device.get("wifi_mac") or device.get("mac")
556 | if mac and mac in existing_macs:
557 | continue
558 | discovered_options[device["mac"]] = f"{device['name']} ({device['ip']})"
559 |
560 | discovered_options["manual"] = "Manual IP entry"
561 |
562 | if user_input is not None:
563 | selection = user_input["device"]
564 |
565 | if selection == "manual":
566 | return await self.async_step_add_device_manual()
567 |
568 | device = next(
569 | (item for item in self._discovered_devices if item["mac"] == selection),
570 | None,
571 | )
572 | if not device:
573 | errors["base"] = "device_not_found"
574 | else:
575 | if (
576 | device.get("ble_mac") in existing_macs
577 | or device.get("wifi_mac") in existing_macs
578 | ):
579 | errors["base"] = "device_already_configured"
580 | else:
581 | updated_devices = list(self._devices)
582 | updated_devices.append(
583 | {
584 | CONF_HOST: device["ip"],
585 | CONF_PORT: DEFAULT_PORT,
586 | "wifi_mac": device.get("wifi_mac"),
587 | "ble_mac": device.get("ble_mac"),
588 | "device": device["name"],
589 | "firmware": device["firmware"],
590 | }
591 | )
592 | new_data = {**self.config_entry.data, "devices": updated_devices}
593 | self.hass.config_entries.async_update_entry(
594 | self.config_entry,
595 | data=new_data,
596 | )
597 | self._devices = updated_devices
598 | return self.async_create_entry(title="", data={})
599 |
600 | return self.async_show_form(
601 | step_id="add_device",
602 | data_schema=vol.Schema(
603 | {
604 | vol.Required("device"): vol.In(discovered_options),
605 | }
606 | ),
607 | errors=errors,
608 | )
609 |
610 | async def async_step_add_device_manual(
611 | self, user_input: dict[str, Any] | None = None
612 | ) -> FlowResult:
613 | """Add a device via manual IP entry."""
614 | if not self._devices:
615 | return self.async_abort(reason="unknown")
616 |
617 | errors: dict[str, str] = {}
618 |
619 | if user_input is not None:
620 | try:
621 | info = await validate_input(
622 | self.hass,
623 | user_input,
624 | )
625 |
626 | mac = info.get("ble_mac") or info.get("wifi_mac")
627 | if any(
628 | mac
629 | and mac
630 | == (device.get("ble_mac") or device.get("wifi_mac"))
631 | for device in self._devices
632 | ):
633 | errors["base"] = "device_already_configured"
634 | else:
635 | updated_devices = list(self._devices)
636 | updated_devices.append(
637 | {
638 | CONF_HOST: user_input[CONF_HOST],
639 | CONF_PORT: user_input.get(CONF_PORT, DEFAULT_PORT),
640 | "wifi_mac": info.get("wifi_mac"),
641 | "ble_mac": info.get("ble_mac"),
642 | "device": info.get("device"),
643 | "firmware": info.get("firmware"),
644 | }
645 | )
646 | new_data = {**self.config_entry.data, "devices": updated_devices}
647 | self.hass.config_entries.async_update_entry(
648 | self.config_entry,
649 | data=new_data,
650 | )
651 | self._devices = updated_devices
652 | return self.async_create_entry(title="", data={})
653 |
654 | except CannotConnect:
655 | errors["base"] = "cannot_connect"
656 | except Exception: # pylint: disable=broad-except
657 | _LOGGER.exception("Unexpected exception during manual device addition")
658 | errors["base"] = "unknown"
659 |
660 | return self.async_show_form(
661 | step_id="add_device_manual",
662 | data_schema=vol.Schema(
663 | {
664 | vol.Required(CONF_HOST): cv.string,
665 | vol.Optional(
666 | CONF_PORT, default=DEFAULT_PORT
667 | ): vol.All(vol.Coerce(int), vol.Range(min=1, max=65535)),
668 | }
669 | ),
670 | errors=errors,
671 | )
672 |
673 | async def _async_discover_devices(self) -> None:
674 | """Discover devices using the same strategy as the config flow."""
675 | paused_clients: list[MarstekUDPClient] = []
676 | self._discovered_devices = []
677 |
678 | if DOMAIN in self.hass.data:
679 | for _entry_id, entry_data in self.hass.data[DOMAIN].items():
680 | coordinator = entry_data.get(DATA_COORDINATOR)
681 | if not coordinator:
682 | continue
683 |
684 | if hasattr(coordinator, "device_coordinators"):
685 | for device_coordinator in coordinator.device_coordinators.values():
686 | if device_coordinator.api:
687 | await device_coordinator.api.disconnect()
688 | paused_clients.append(device_coordinator.api)
689 | elif hasattr(coordinator, "api") and coordinator.api:
690 | await coordinator.api.disconnect()
691 | paused_clients.append(coordinator.api)
692 |
693 | await asyncio.sleep(1)
694 |
695 | api = MarstekUDPClient(self.hass, port=DEFAULT_PORT, remote_port=DEFAULT_PORT)
696 | try:
697 | await api.connect()
698 | self._discovered_devices = await api.discover_devices()
699 | except Exception as err: # pylint: disable=broad-except
700 | _LOGGER.error("Discovery failed during options flow: %s", err, exc_info=True)
701 | finally:
702 | try:
703 | await api.disconnect()
704 | except Exception: # pylint: disable=broad-except
705 | pass
706 |
707 | await asyncio.sleep(1)
708 |
709 | for client in paused_clients:
710 | try:
711 | await client.connect()
712 | except Exception as err: # pylint: disable=broad-except
713 | _LOGGER.warning("Failed to resume client during options flow: %s", err)
714 |
715 |
716 | class CannotConnect(HomeAssistantError):
717 | """Error to indicate we cannot connect."""
718 |
--------------------------------------------------------------------------------