├── 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 | afbeelding 17 | 18 | --- 19 | 20 | ## 2. Install the Integration 21 | 22 | ### Via HACS 23 | 1. Click this button: 24 | 25 | [![Open this repository in HACS](https://my.home-assistant.io/badges/hacs_repository.svg)](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 | afbeelding 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 | afbeelding 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 | --------------------------------------------------------------------------------