From 51c51bbc5aaee0f72e9f2465f808851450cc8b32 Mon Sep 17 00:00:00 2001 From: Luke Date: Sat, 25 Apr 2026 09:36:21 -0400 Subject: [PATCH 1/7] misc fixes --- .gitignore | 3 +- README.md | 4 +- repository.yaml | 3 + .../https_server/endpoint_rules.py | 6 + .../https_server/routes/user/deviceshare.py | 40 ++++++ .../https_server/routes/user/scene/service.py | 6 +- .../bundled_backend/shared/protocol_auth.py | 33 ++++- .../bundled_backend/shared/routine_runner.py | 117 +++++++++++++++--- .../container_entrypoint.py | 41 ++++++ src/roborock_local_server/server.py | 1 + 10 files changed, 228 insertions(+), 26 deletions(-) create mode 100644 repository.yaml create mode 100644 src/roborock_local_server/bundled_backend/https_server/routes/user/deviceshare.py create mode 100644 src/roborock_local_server/container_entrypoint.py diff --git a/.gitignore b/.gitignore index 66be241..6e2bf35 100644 --- a/.gitignore +++ b/.gitignore @@ -3,8 +3,9 @@ __pycache__/ *.pyo *.pyd .pytest_cache/ +dist/ data/ site/ secrets/ config.toml -mitm_logs \ No newline at end of file +mitm_logs diff --git a/README.md b/README.md index 6adf9a4..4622ed2 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,8 @@ The best way to support this project is the next time you are buying a Roborock device come back here and use one of my affiliate links where I will receive a commission. [![Amazon Affiliate][badge-amazon]][link-amazon] -[![Roborock 5 Off][badge-roborock-discount]][link-roborock-discount] [![Roborock Affiliate][badge-roborock-affiliate]][link-roborock-affiliate] - +[![Roborock 5 Off][badge-roborock-discount]][link-roborock-discount] You can also support via BMAC or paypal: @@ -40,6 +39,7 @@ Additional docs: - [Docs index](docs/index.md) - [Tested vacuums](docs/tested_vacuums.md) - [Home Assistant](docs/home_assistant.md) +- [Home Assistant app files](roborock_local_server_addon/config.yaml) - [Using the Roborock App](docs/roborock_app.md) - [Custom MQTT](docs/custom_mqtt.md) - [Custom certificate management](docs/custom_cert_management.md) diff --git a/repository.yaml b/repository.yaml new file mode 100644 index 0000000..2b92890 --- /dev/null +++ b/repository.yaml @@ -0,0 +1,3 @@ +name: Roborock Local Server Apps +url: "https://github.com/Python-roborock/local_roborock_server" +maintainer: Luke Lashley diff --git a/src/roborock_local_server/bundled_backend/https_server/endpoint_rules.py b/src/roborock_local_server/bundled_backend/https_server/endpoint_rules.py index f87799f..411bbca 100644 --- a/src/roborock_local_server/bundled_backend/https_server/endpoint_rules.py +++ b/src/roborock_local_server/bundled_backend/https_server/endpoint_rules.py @@ -103,6 +103,10 @@ from .routes.user.devices.detail import build_extra as _build_get_device_extra from .routes.user.devices.detail import match as _match_get_device from .routes.user.devices.detail import match_extra as _match_get_device_extra +from .routes.user.deviceshare import build_received_devices as _build_get_received_devices +from .routes.user.deviceshare import build_rooms as _build_get_shared_device_rooms +from .routes.user.deviceshare import match_received_devices as _match_get_received_devices +from .routes.user.deviceshare import match_rooms as _match_get_shared_device_rooms from .routes.user.devices.jobs import build as _build_get_schedules from .routes.user.devices.jobs import match as _match_get_schedules from .routes.user.devices.newadd import build as _build_add_device @@ -567,6 +571,8 @@ def default_endpoint_rules() -> Sequence[EndpointRule]: EndpointRule("post_home_rooms", _match_post_home_rooms, _build_post_home_rooms), EndpointRule("post_scene_create", _match_post_scene_create, _build_post_scene_create), EndpointRule("get_home_rooms", _match_get_home_rooms, _build_get_home_rooms), + EndpointRule("get_received_devices", _match_get_received_devices, _build_get_received_devices), + EndpointRule("get_shared_device_rooms", _match_get_shared_device_rooms, _build_get_shared_device_rooms), EndpointRule("get_scenes", _match_get_scenes, _build_get_scenes), EndpointRule("get_home_scenes", _match_get_home_scenes, _build_get_home_scenes), EndpointRule("get_scene_order", _match_get_scene_order, _build_get_scene_order), diff --git a/src/roborock_local_server/bundled_backend/https_server/routes/user/deviceshare.py b/src/roborock_local_server/bundled_backend/https_server/routes/user/deviceshare.py new file mode 100644 index 0000000..2c13ced --- /dev/null +++ b/src/roborock_local_server/bundled_backend/https_server/routes/user/deviceshare.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import re +from typing import Any + +from shared.context import ServerContext +from shared.http_helpers import wrap_response + +from .devices.service import _home_data as device_home_payload +from .homes.service import home_payload, home_rooms_payload + + +def match_received_devices(path: str, method: str = "GET") -> bool: + clean = path.rstrip("/") + return method.upper() == "GET" and clean == "/user/deviceshare/query/receiveddevices" + + +def build_received_devices( + ctx: ServerContext, + _query_params: dict[str, list[str]], + _body_params: dict[str, list[str]], + _clean_path: str, +) -> dict[str, Any]: + payload = device_home_payload(ctx) + received_devices = payload.get("receivedDevices") if isinstance(payload, dict) else [] + return wrap_response(received_devices if isinstance(received_devices, list) else []) + + +def match_rooms(path: str, method: str = "GET") -> bool: + clean = path.rstrip("/") + return method.upper() == "GET" and bool(re.fullmatch(r"/user/deviceshare/query/[^/]+/rooms", clean)) + + +def build_rooms( + ctx: ServerContext, + _query_params: dict[str, list[str]], + _body_params: dict[str, list[str]], + _clean_path: str, +) -> dict[str, Any]: + return wrap_response(home_rooms_payload(ctx)) diff --git a/src/roborock_local_server/bundled_backend/https_server/routes/user/scene/service.py b/src/roborock_local_server/bundled_backend/https_server/routes/user/scene/service.py index fd4e0ef..b9433f1 100644 --- a/src/roborock_local_server/bundled_backend/https_server/routes/user/scene/service.py +++ b/src/roborock_local_server/bundled_backend/https_server/routes/user/scene/service.py @@ -563,7 +563,9 @@ def _routine_runner_for_context(ctx: ServerContext) -> RoutineRunner: def list_scenes_for_device(ctx: ServerContext, device_id: str) -> list[dict[str, Any]]: - scenes = _scene_state(ctx)["scenes"] + state = _scene_state(ctx) + scenes = state["scenes"] + home_id = state["home_id"] filtered: list[dict[str, Any]] = [] for scene in scenes: if not isinstance(scene, dict): @@ -571,7 +573,7 @@ def list_scenes_for_device(ctx: ServerContext, device_id: str) -> list[dict[str, scene_device = get_value(scene, "device_id", "deviceId", "duid") if scene_device and str(scene_device) != str(device_id): continue - filtered.append(build_scene_payload(scene, home_id=None, include_device_context=False)) + filtered.append(build_scene_payload(scene, home_id=home_id, include_device_context=True)) return filtered diff --git a/src/roborock_local_server/bundled_backend/shared/protocol_auth.py b/src/roborock_local_server/bundled_backend/shared/protocol_auth.py index 23902e4..c876583 100644 --- a/src/roborock_local_server/bundled_backend/shared/protocol_auth.py +++ b/src/roborock_local_server/bundled_backend/shared/protocol_auth.py @@ -23,6 +23,10 @@ def _md5hex(value: str) -> str: return hashlib.md5(value.encode("utf-8")).hexdigest() +def _md5hex_bytes(value: bytes) -> str: + return hashlib.md5(value).hexdigest() + + def _parse_json_body_param_map(body_params: dict[str, list[str]]) -> dict[str, Any]: for raw in body_params.get("__json") or []: try: @@ -63,9 +67,17 @@ def _build_hawk_mac( path: str, query_values: dict[str, Any] | None, form_values: dict[str, Any] | None, + json_body: bytes | str | None = None, timestamp: int, nonce: str, ) -> str: + query_hash = _process_extra_hawk_values(query_values) + if json_body is None: + body_hash = _process_extra_hawk_values(form_values) + else: + if isinstance(json_body, str): + json_body = json_body.encode("utf-8") + body_hash = _md5hex_bytes(json_body) prestr = ":".join( [ hawk_id, @@ -73,8 +85,8 @@ def _build_hawk_mac( nonce, str(timestamp), _md5hex(path), - _process_extra_hawk_values(query_values), - _process_extra_hawk_values(form_values), + query_hash, + body_hash, ] ) return base64.b64encode(hmac.new(hawk_key.encode(), prestr.encode(), hashlib.sha256).digest()).decode() @@ -162,11 +174,16 @@ def build_hawk_authorization( path: str, query_values: dict[str, Any] | None = None, form_values: dict[str, Any] | None = None, + json_body: bytes | str | None = None, timestamp: int | None = None, nonce: str | None = None, ) -> str: ts = int(time.time() if timestamp is None else timestamp) normalized_nonce = _clean_str(nonce) or secrets.token_urlsafe(6) + if json_body is None and isinstance(form_values, Mapping): + raw_json = form_values.get("__json") + if isinstance(raw_json, (str, bytes)): + json_body = raw_json mac = _build_hawk_mac( hawk_id=user.hawk_id, hawk_session=user.hawk_session, @@ -174,6 +191,7 @@ def build_hawk_authorization( path=path, query_values=query_values, form_values=form_values, + json_body=json_body, timestamp=ts, nonce=normalized_nonce, ) @@ -533,6 +551,7 @@ def verify_hawk( query_params: dict[str, list[str]], body_params: dict[str, list[str]], headers: Mapping[str, str], + raw_body: bytes | None = None, now_ts: float | None = None, ) -> tuple[bool, str]: availability = self.availability() @@ -565,13 +584,21 @@ def verify_hawk( if not nonce: return False, "missing_hawk_nonce" + json_body: bytes | None = None + if body_params.get("__json"): + if raw_body is not None: + json_body = raw_body + else: + json_raw = next((value for value in body_params.get("__json", []) if isinstance(value, str)), "") + json_body = json_raw.encode("utf-8") expected_mac = _build_hawk_mac( hawk_id=user.hawk_id, hawk_session=user.hawk_session, hawk_key=user.hawk_key, path=path, query_values=_normalize_param_values(query_params), - form_values=_normalize_param_values(body_params, include_json=True), + form_values=None if json_body is not None else _normalize_param_values(body_params, include_json=True), + json_body=json_body, timestamp=timestamp, nonce=nonce, ) diff --git a/src/roborock_local_server/bundled_backend/shared/routine_runner.py b/src/roborock_local_server/bundled_backend/shared/routine_runner.py index 8f0b934..6bcd596 100644 --- a/src/roborock_local_server/bundled_backend/shared/routine_runner.py +++ b/src/roborock_local_server/bundled_backend/shared/routine_runner.py @@ -54,12 +54,20 @@ def _ensure_local_python_roborock_on_path() -> None: _RESUME_BATTERY_THRESHOLD = 80 _POST_STEP_SETTLE_SECONDS = 15.0 _POST_STEP_SETTLE_TIMEOUT_SECONDS = 10 * 60 +_ACTION_LOCK_RETRY_DELAY_SECONDS = 5.0 +_ACTION_LOCK_RETRY_ATTEMPTS = 6 from .inventory_io import WEB_API_INVENTORY_FILE _SUPPORTED_METHODS = { "do_scenes_app_start", "do_scenes_segments", "do_scenes_zones", } +_STEP_START_COMMANDS = { + RoborockCommand.APP_START, + RoborockCommand.APP_SEGMENT_CLEAN, + RoborockCommand.APP_ZONED_CLEAN, +} +_ACTION_LOCK_ERROR_CODES = {-10003, -10007} _RESUME_COMMAND_BY_IN_CLEANING: dict[int, RoborockCommand] = { RoborockInCleaning.global_clean_not_complete.value: RoborockCommand.APP_START, RoborockInCleaning.zone_clean_not_complete.value: RoborockCommand.RESUME_ZONED_CLEAN, @@ -368,6 +376,37 @@ def _is_optional_unsupported_command(command: RoborockCommand, exc: Exception) - return command == RoborockCommand.SET_MOP_TEMPLATE_ID and isinstance(exc, RoborockUnsupportedFeature) +def _exception_error_payload(exc: Exception) -> dict[str, Any] | None: + args = getattr(exc, "args", ()) + if not args: + return None + payload = args[0] + return payload if isinstance(payload, dict) else None + + +def _exception_error_code(exc: Exception) -> int | None: + payload = _exception_error_payload(exc) + code = payload.get("code") if payload is not None else getattr(exc, "code", None) + return code if isinstance(code, int) else None + + +def _exception_error_message(exc: Exception) -> str: + payload = _exception_error_payload(exc) + if payload is not None: + message = payload.get("message") + if isinstance(message, str): + return message + return str(exc) + + +def _is_retryable_action_locked_error(exc: Exception) -> bool: + code = _exception_error_code(exc) + if code in _ACTION_LOCK_ERROR_CODES: + return True + message = _exception_error_message(exc).strip().lower() + return "action locked" in message or "invalid status" in message + + def _response_dps(message: RoborockMessage) -> dict[str, Any] | None: if message.payload is None: return None @@ -611,7 +650,7 @@ async def wait_for_step_complete(self) -> None: ) from exc - async def wait_for_dock_settle(self) -> None: + async def wait_for_dock_settle(self, *, initial_delay_seconds: float = _POST_STEP_SETTLE_SECONDS) -> None: """Wait for automatic dock activities (e.g. bin emptying) to finish before sending the next step. @@ -626,11 +665,12 @@ async def wait_for_dock_settle(self) -> None: loop = asyncio.get_running_loop() deadline = loop.time() + _POST_STEP_SETTLE_TIMEOUT_SECONDS - self._logger.info( - "Post-step settle: waiting %.0fs before checking dock activity", - _POST_STEP_SETTLE_SECONDS, - ) - await asyncio.sleep(_POST_STEP_SETTLE_SECONDS) + if initial_delay_seconds > 0: + self._logger.info( + "Post-step settle: waiting %.0fs before checking dock activity", + initial_delay_seconds, + ) + await asyncio.sleep(initial_delay_seconds) last_observed = None while True: @@ -862,6 +902,53 @@ async def _stop_scene(self, *, device_id: str, scene_id: int, scene_name: str) - finally: await client.close() + async def _send_step_command( + self, + *, + client: _RoutineMqttClient, + logger: logging.LoggerAdapter, + step: RoutineStep, + routine_command: RoutineCommand, + ) -> None: + attempts = 0 + while True: + attempts += 1 + try: + await client.send_command(routine_command.command, routine_command.params) + return + except RoborockUnsupportedFeature as exc: + if not _is_optional_unsupported_command(routine_command.command, exc): + raise + logger.warning( + "Skipping unsupported routine command step=%s method=%s command=%s: %s", + step.step_id, + step.method, + routine_command.command.value, + exc, + ) + return + except Exception as exc: # noqa: BLE001 + if ( + routine_command.command in _STEP_START_COMMANDS + and attempts < _ACTION_LOCK_RETRY_ATTEMPTS + and _is_retryable_action_locked_error(exc) + ): + logger.warning( + "Routine step=%s method=%s command=%s rejected as action locked; " + "waiting %.0fs and retrying (%s/%s)", + step.step_id, + step.method, + routine_command.command.value, + _ACTION_LOCK_RETRY_DELAY_SECONDS, + attempts, + _ACTION_LOCK_RETRY_ATTEMPTS, + ) + await client.wait_for_dock_settle( + initial_delay_seconds=_ACTION_LOCK_RETRY_DELAY_SECONDS, + ) + continue + raise + async def _run_scene(self, *, scene: dict[str, Any], steps: list[RoutineStep]) -> None: device_id = scene_device_id(scene) device = self._device_record(device_id) @@ -896,18 +983,12 @@ async def _run_scene(self, *, scene: dict[str, Any], steps: list[RoutineStep]) - routine_command.command.value, routine_command.params, ) - try: - await client.send_command(routine_command.command, routine_command.params) - except RoborockUnsupportedFeature as exc: - if not _is_optional_unsupported_command(routine_command.command, exc): - raise - logger.warning( - "Skipping unsupported routine command step=%s method=%s command=%s: %s", - step.step_id, - step.method, - routine_command.command.value, - exc, - ) + await self._send_step_command( + client=client, + logger=logger, + step=step, + routine_command=routine_command, + ) if waits_for_step_complete: logger.info("Waiting for ready state step=%s scene=%s", step.step_id, _scene_name(scene)) await client.wait_for_step_complete() diff --git a/src/roborock_local_server/container_entrypoint.py b/src/roborock_local_server/container_entrypoint.py new file mode 100644 index 0000000..7e5ebcc --- /dev/null +++ b/src/roborock_local_server/container_entrypoint.py @@ -0,0 +1,41 @@ +"""Container entrypoint that supports compose and Home Assistant apps.""" + +from __future__ import annotations + +import os +from pathlib import Path + +from .ha_addon import write_config_from_home_assistant_options + + +def _exec_server(config_path: Path) -> None: + os.execvp( + "roborock-local-server", + ["roborock-local-server", "serve", "--config", str(config_path)], + ) + + +def main() -> int: + compose_config = Path("/app/config.toml") + if compose_config.exists(): + _exec_server(compose_config) + + data_config = Path("/data/config.toml") + if data_config.exists(): + _exec_server(data_config) + + addon_options = Path("/data/options.json") + if addon_options.exists(): + write_config_from_home_assistant_options( + options_path=addon_options, + config_path=data_config, + ) + _exec_server(data_config) + + raise SystemExit( + "No config file found. Expected /app/config.toml, /data/config.toml, or /data/options.json." + ) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/roborock_local_server/server.py b/src/roborock_local_server/server.py index ef5f0dd..466e537 100644 --- a/src/roborock_local_server/server.py +++ b/src/roborock_local_server/server.py @@ -1031,6 +1031,7 @@ async def _handle_roborock_request(self, request: Request) -> Response: query_params=query_params, body_params=body_params, headers=request.headers, + raw_body=raw_body, ) if not authenticated: route_name = f"{required_auth}_auth_failed" From 91c3d086227b65a85f6ce845fb2b9c785e4e757e Mon Sep 17 00:00:00 2001 From: Luke Date: Thu, 30 Apr 2026 22:08:34 -0400 Subject: [PATCH 2/7] misc changes --- Dockerfile | 2 +- config.example.toml | 9 +- docs/home_assistant.md | 49 ++- roborock_local_server_addon/CHANGELOG.md | 5 + roborock_local_server_addon/DOCS.md | 25 ++ roborock_local_server_addon/config.yaml | 69 +++++ scripts/export_home_assistant_dev_addon.py | 195 ++++++++++++ scripts/sync_home_assistant_dev_addon.ps1 | 32 ++ .../mqtt_tls_proxy_server/server.py | 56 +++- .../bundled_backend/shared/routine_runner.py | 25 +- src/roborock_local_server/config.py | 51 +++- src/roborock_local_server/configure.py | 42 ++- .../container_entrypoint.py | 23 +- src/roborock_local_server/ha_addon.py | 282 ++++++++++++++++++ src/roborock_local_server/server.py | 78 +++-- tests/conftest.py | 10 +- tests/contracts/test_ios_app_init_contract.py | 6 +- tests/test_admin_api.py | 218 +++++++++++++- tests/test_config.py | 86 +++++- tests/test_configure.py | 35 ++- tests/test_container_entrypoint.py | 67 +++++ tests/test_ha_addon.py | 172 +++++++++++ tests/test_ha_addon_export.py | 30 ++ tests/test_mqtt_tls_proxy.py | 44 +++ tests/test_plugin_routes.py | 6 +- tests/test_routine_runner.py | 166 +++++++++++ tests/test_supervisor.py | 40 +++ tests/test_version_sync.py | 7 + 28 files changed, 1735 insertions(+), 95 deletions(-) create mode 100644 roborock_local_server_addon/CHANGELOG.md create mode 100644 roborock_local_server_addon/DOCS.md create mode 100644 roborock_local_server_addon/config.yaml create mode 100644 scripts/export_home_assistant_dev_addon.py create mode 100644 scripts/sync_home_assistant_dev_addon.ps1 create mode 100644 src/roborock_local_server/ha_addon.py create mode 100644 tests/test_container_entrypoint.py create mode 100644 tests/test_ha_addon.py create mode 100644 tests/test_ha_addon_export.py diff --git a/Dockerfile b/Dockerfile index 406fd81..14c228b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,4 +23,4 @@ RUN pip install --no-cache-dir /app EXPOSE 443 8883 -CMD ["roborock-local-server", "serve", "--config", "/app/config.toml"] +CMD ["python", "-m", "roborock_local_server.container_entrypoint"] diff --git a/config.example.toml b/config.example.toml index 52614eb..258de39 100644 --- a/config.example.toml +++ b/config.example.toml @@ -1,10 +1,15 @@ [network] # The one hostname the stack will serve. Keep this as the hostname only. -stack_fqdn = "roborock.example.com" +stack_fqdn = "api-roborock.example.com" +listener_mode = "local_tls" bind_host = "0.0.0.0" -# Change these if you need the stack to advertise and listen on custom ports. +# Change these if you need the stack to advertise custom public ports. https_port = 555 mqtt_tls_port = 8881 +# Internal listener ports. In local_tls mode these are the TLS ports this process binds. +# In external_tls mode these are the plaintext internal ports your proxy forwards to. +listen_https_port = 555 +listen_mqtt_port = 8881 region = "us" [broker] diff --git a/docs/home_assistant.md b/docs/home_assistant.md index 6bcd832..71e05c6 100644 --- a/docs/home_assistant.md +++ b/docs/home_assistant.md @@ -2,9 +2,54 @@ Use this after [Installation](installation.md) and [Onboarding](onboarding.md) if you want Home Assistant to talk to your local stack. -To use this server with Home Assistant, edit your config entry at `config/.storage/core.config_entries`. +## Testing unpublished changes on a real Home Assistant instance -Find `"roborock.com"` and replace the endpoint values with your local stack URLs: +If you want Home Assistant to build your current local branch instead of pulling the published GHCR image: + +1. Export a self-contained local add-on repository: + - `uv run python scripts/export_home_assistant_dev_addon.py` +2. Copy the generated folder `dist/home_assistant_dev_addon_repo/` to your Home Assistant host under: + - `/addons/local_roborock_server_dev_repo/` +3. In Home Assistant, open **Settings -> Add-ons -> App Store** and refresh. +4. Open the **Local add-ons** repository and install **Roborock Local Server Dev**. +5. Fill the app options and start it. + +This path is for unpublished development work. It bundles your current `src/` tree into the add-on so Home Assistant can build it locally on the real device. + +## Option 1: Home Assistant App (same GHCR image) + +This repository contains a Home Assistant app definition at `roborock_local_server_addon/` that uses: + +- `ghcr.io/python-roborock/local_roborock_server` + +To install it as a custom repository: + +1. In Home Assistant, go to **Settings -> Apps -> App Store -> Repositories**. +2. Add this repository URL: + - `https://github.com/Python-roborock/local_roborock_server` +3. Install **Roborock Local Server**. +4. Fill the app options (`stack_fqdn`, `listener_mode`, `admin_password`, `protocol_login_email`, `protocol_login_pin`, TLS settings). +5. Start the app. + +Then open: + +- `https://:555/admin` (or your configured HTTPS port) + +Important: installing the Home Assistant app does not automatically rewrite your Roborock integration entry. You still need to update `config/.storage/core.config_entries` endpoint values as shown below so Home Assistant points at your local stack. + +Notes: + +- `listener_mode = local_tls` means this app terminates TLS for both HTTPS and MQTT. +- `listener_mode = external_tls` means your external proxy must terminate TLS for both HTTPS and MQTT and forward plaintext to the app's internal `listen_https_port` and `listen_mqtt_port`. +- If you use Home Assistant's Nginx Proxy Manager add-on for certificate issuance, this add-on can read those PEM files directly through `/all_addon_configs/a0d7b954_nginxproxymanager/letsencrypt/live/...`. + +## Option 2: Existing Docker deployment + +If you keep using Docker Compose, edit your Home Assistant Roborock config entry at: + +- `config/.storage/core.config_entries` + +Find `"roborock.com"` and replace endpoint values with your local stack URLs: - `base_url` -> `https://api-roborock.example.com:555` - `"a"` -> `https://api-roborock.example.com:555` diff --git a/roborock_local_server_addon/CHANGELOG.md b/roborock_local_server_addon/CHANGELOG.md new file mode 100644 index 0000000..305b1e3 --- /dev/null +++ b/roborock_local_server_addon/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 0.0.2-rc1 + +- Initial Home Assistant app manifest using the shared GHCR image. diff --git a/roborock_local_server_addon/DOCS.md b/roborock_local_server_addon/DOCS.md new file mode 100644 index 0000000..6eecf70 --- /dev/null +++ b/roborock_local_server_addon/DOCS.md @@ -0,0 +1,25 @@ +# Roborock Local Server + +This app runs the same `ghcr.io/python-roborock/local_roborock_server` image used for Docker installs. + +## Setup + +1. Set `stack_fqdn` to your `api-...` hostname. +2. Choose `listener_mode`: + - `local_tls`: this app terminates TLS for both HTTPS and MQTT + - `external_tls`: your external proxy must terminate TLS for both HTTPS and MQTT and forward plaintext to this app's `listen_https_port` and `listen_mqtt_port` +3. Set `admin_password`, `protocol_login_email`, and `protocol_login_pin` (6 digits). +4. If you use `local_tls`, choose TLS mode: + - `provided`: set `cert_file` and `key_file` (defaults: `/ssl/fullchain.pem`, `/ssl/privkey.pem`) + - `cloudflare_acme`: set `tls_base_domain`, `tls_email`, `cloudflare_token` +5. Start the app. + +Then open `https://:555/admin` (or your custom HTTPS port). + +This app package does not auto-edit Home Assistant's Roborock config entry. You still need to update `config/.storage/core.config_entries` endpoint values to your local stack URLs. + +## Notes + +- This app expects internal LAN-only usage. Do not expose directly to the internet. +- If you change `https_port` or `mqtt_tls_port`, update your DNS/clients to use those ports. +- If you already manage certificates in another Home Assistant app such as Nginx Proxy Manager, you can point `cert_file` and `key_file` at that app's certs through `/all_addon_configs/...`. Example: `/all_addon_configs/a0d7b954_nginxproxymanager/letsencrypt/live/npm-3/fullchain.pem`. diff --git a/roborock_local_server_addon/config.yaml b/roborock_local_server_addon/config.yaml new file mode 100644 index 0000000..c17cd63 --- /dev/null +++ b/roborock_local_server_addon/config.yaml @@ -0,0 +1,69 @@ +name: Roborock Local Server +version: "0.0.2-rc1" +slug: roborock_local_server +description: Private Roborock HTTPS and MQTT stack for Home Assistant environments. +url: "https://github.com/Python-roborock/local_roborock_server" +image: "ghcr.io/python-roborock/local_roborock_server" +startup: services +boot: auto +init: false +arch: + - amd64 + - aarch64 +ports: + 555/tcp: 555 + 8881/tcp: 8881 +ports_description: + 555/tcp: Roborock HTTPS API + 8881/tcp: Roborock MQTT TLS proxy +map: + - ssl:ro + - addon_config:rw + - all_addon_configs:ro +webui: "https://[HOST]:[PORT:555]/admin" +options: + stack_fqdn: "api-roborock.example.com" + listener_mode: "local_tls" + https_port: 555 + mqtt_tls_port: 8881 + listen_https_port: 555 + listen_mqtt_port: 8881 + region: "us" + use_external_broker: false + broker_host: "127.0.0.1" + broker_port: 18830 + enable_topic_bridge: true + tls_mode: "provided" + tls_base_domain: "" + tls_email: "" + cloudflare_token: "" + cert_file: "/ssl/fullchain.pem" + key_file: "/ssl/privkey.pem" + admin_password: "" + admin_session_secret: "" + protocol_auth_enabled: true + protocol_login_email: "" + protocol_login_pin: "" +schema: + stack_fqdn: str + listener_mode: list(local_tls|external_tls) + https_port: port + mqtt_tls_port: port + listen_https_port: port + listen_mqtt_port: port + region: list(us|eu|cn|ru) + use_external_broker: bool + broker_host: str + broker_port: port + enable_topic_bridge: bool + tls_mode: list(provided|cloudflare_acme) + tls_base_domain: str + tls_email: str + cloudflare_token: str + cert_file: str + key_file: str + admin_password: password + admin_session_secret: str + protocol_auth_enabled: bool + protocol_login_email: email + protocol_login_pin: password diff --git a/scripts/export_home_assistant_dev_addon.py b/scripts/export_home_assistant_dev_addon.py new file mode 100644 index 0000000..a966bf5 --- /dev/null +++ b/scripts/export_home_assistant_dev_addon.py @@ -0,0 +1,195 @@ +from __future__ import annotations + +import argparse +import shutil +from pathlib import Path + +from roborock_local_server import __version__ + + +REPO_ROOT = Path(__file__).resolve().parents[1] +DEFAULT_OUTPUT_DIR = REPO_ROOT / "dist" / "home_assistant_dev_addon_repo" +ADDON_SLUG = "roborock_local_server_dev" +ADDON_DIRNAME = ADDON_SLUG +ADDON_NAME = "Roborock Local Server Dev" + +REPOSITORY_YAML = """name: Roborock Local Server Dev Apps +url: "https://github.com/Python-roborock/local_roborock_server" +maintainer: Luke Lashley +""" + +ADDON_CONFIG_YAML = f"""name: {ADDON_NAME} +version: "{__version__}" +slug: {ADDON_SLUG} +description: Local-build development app for testing unpublished Roborock Local Server changes in Home Assistant. +url: "https://github.com/Python-roborock/local_roborock_server" +startup: services +boot: auto +init: false +arch: + - amd64 + - aarch64 +ports: + 555/tcp: 555 + 8881/tcp: 8881 +ports_description: + 555/tcp: Roborock HTTPS API + 8881/tcp: Roborock MQTT TLS proxy +map: + - ssl:ro + - addon_config:rw + - all_addon_configs:ro +webui: "https://[HOST]:[PORT:555]/admin" +options: + stack_fqdn: "api-roborock.example.com" + listener_mode: "local_tls" + https_port: 555 + mqtt_tls_port: 8881 + listen_https_port: 555 + listen_mqtt_port: 8881 + region: "us" + use_external_broker: false + broker_host: "127.0.0.1" + broker_port: 18830 + enable_topic_bridge: true + tls_mode: "provided" + tls_base_domain: "" + tls_email: "" + cloudflare_token: "" + cert_file: "/ssl/fullchain.pem" + key_file: "/ssl/privkey.pem" + admin_password: "" + admin_session_secret: "" + protocol_auth_enabled: true + protocol_login_email: "" + protocol_login_pin: "" +schema: + stack_fqdn: str + listener_mode: list(local_tls|external_tls) + https_port: port + mqtt_tls_port: port + listen_https_port: port + listen_mqtt_port: port + region: list(us|eu|cn|ru) + use_external_broker: bool + broker_host: str + broker_port: port + enable_topic_bridge: bool + tls_mode: list(provided|cloudflare_acme) + tls_base_domain: str + tls_email: str + cloudflare_token: str + cert_file: str + key_file: str + admin_password: password + admin_session_secret: str + protocol_auth_enabled: bool + protocol_login_email: email + protocol_login_pin: password +""" + +ADDON_DOCS_MD = f"""# {ADDON_NAME} + +This local-build Home Assistant app is exported from your current working tree so you can test unpublished changes on a real Home Assistant instance. + +## Install + +1. Run `uv run python scripts/export_home_assistant_dev_addon.py`. +2. Copy the generated repository folder to your Home Assistant host under `/addons/local_roborock_server_dev_repo/`. +3. In Home Assistant, open **Settings -> Add-ons -> App Store** and refresh. +4. Open the **Local add-ons** repository and install **{ADDON_NAME}**. +5. Set `stack_fqdn`, `listener_mode`, `admin_password`, `protocol_login_email`, `protocol_login_pin`, and your TLS settings. +6. Start the app and open `https://:555/admin`. + +This app does not auto-edit Home Assistant's Roborock config entry. Update `config/.storage/core.config_entries` so Home Assistant points to your local stack URLs. +""" + +ADDON_CHANGELOG_MD = f"""# Changelog + +## {__version__} + +- Exported from the current local working tree for Home Assistant dev testing. +""" + +ADDON_DOCKERFILE = """FROM python:3.11-slim + +RUN apt-get update \\ + && apt-get install -y --no-install-recommends \\ + ca-certificates \\ + curl \\ + mosquitto \\ + openssl \\ + && rm -rf /var/lib/apt/lists/* + +RUN mkdir -p /opt/acme.sh \\ + && curl -fsSL https://github.com/acmesh-official/acme.sh/archive/refs/heads/master.tar.gz \\ + | tar -xz --strip-components=1 -C /opt/acme.sh \\ + && chmod +x /opt/acme.sh/acme.sh \\ + && ln -sf /opt/acme.sh/acme.sh /usr/local/bin/acme.sh + +WORKDIR /app + +COPY app/pyproject.toml app/README.md /app/ +COPY app/src /app/src + +RUN pip install --no-cache-dir /app + +EXPOSE 555 8881 + +CMD ["python", "-m", "roborock_local_server.container_entrypoint"] +""" + +ADDON_DOCKERIGNORE = """__pycache__/ +*.pyc +*.pyo +*.pyd +""" + + +def _write_text(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + +def export_repository(output_dir: Path) -> Path: + if output_dir.exists(): + shutil.rmtree(output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + + addon_dir = output_dir / ADDON_DIRNAME + app_dir = addon_dir / "app" + app_dir.mkdir(parents=True, exist_ok=True) + + _write_text(output_dir / "repository.yaml", REPOSITORY_YAML) + _write_text(addon_dir / "config.yaml", ADDON_CONFIG_YAML) + _write_text(addon_dir / "DOCS.md", ADDON_DOCS_MD) + _write_text(addon_dir / "CHANGELOG.md", ADDON_CHANGELOG_MD) + _write_text(addon_dir / "Dockerfile", ADDON_DOCKERFILE) + _write_text(addon_dir / ".dockerignore", ADDON_DOCKERIGNORE) + + shutil.copy2(REPO_ROOT / "pyproject.toml", app_dir / "pyproject.toml") + shutil.copy2(REPO_ROOT / "README.md", app_dir / "README.md") + shutil.copytree(REPO_ROOT / "src", app_dir / "src") + + return output_dir + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Export a self-contained Home Assistant local add-on repo from the current working tree." + ) + parser.add_argument( + "--output-dir", + type=Path, + default=DEFAULT_OUTPUT_DIR, + help=f"Output directory for the generated local add-on repository. Default: {DEFAULT_OUTPUT_DIR}", + ) + args = parser.parse_args() + output_dir = args.output_dir.resolve() + export_repository(output_dir) + print(output_dir) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/sync_home_assistant_dev_addon.ps1 b/scripts/sync_home_assistant_dev_addon.ps1 new file mode 100644 index 0000000..847ca8b --- /dev/null +++ b/scripts/sync_home_assistant_dev_addon.ps1 @@ -0,0 +1,32 @@ +param( + [string]$HomeAssistantHost = "192.168.20.199", + [string]$AddonSlug = "roborock_local_server_dev" +) + +$ErrorActionPreference = "Stop" + +$repoRoot = Split-Path -Parent $PSScriptRoot +$tempExportRoot = Join-Path $repoRoot "dist\ha_sync_export" +$exportedAddonDir = Join-Path $tempExportRoot $AddonSlug +$haLocalAddonsRoot = "\\$HomeAssistantHost\addons\local" +$haAddonDir = Join-Path $haLocalAddonsRoot $AddonSlug + +if (-not (Test-Path $haLocalAddonsRoot)) { + throw "Home Assistant local add-on path is not reachable: $haLocalAddonsRoot" +} + +uv run python "$PSScriptRoot\export_home_assistant_dev_addon.py" --output-dir $tempExportRoot + +if (-not (Test-Path $exportedAddonDir)) { + throw "Export completed but addon directory was not found: $exportedAddonDir" +} + +New-Item -ItemType Directory -Path $haAddonDir -Force | Out-Null + +robocopy $exportedAddonDir $haAddonDir /MIR /NFL /NDL /NJH /NJS /NP | Out-Null +$robocopyExitCode = $LASTEXITCODE +if ($robocopyExitCode -ge 8) { + throw "robocopy failed with exit code $robocopyExitCode" +} + +Write-Output "Synced $exportedAddonDir -> $haAddonDir" diff --git a/src/roborock_local_server/bundled_backend/mqtt_tls_proxy_server/server.py b/src/roborock_local_server/bundled_backend/mqtt_tls_proxy_server/server.py index 5d4de2b..e4a10b4 100644 --- a/src/roborock_local_server/bundled_backend/mqtt_tls_proxy_server/server.py +++ b/src/roborock_local_server/bundled_backend/mqtt_tls_proxy_server/server.py @@ -29,8 +29,8 @@ class MqttTlsProxy: def __init__( self, *, - cert_file: Path, - key_file: Path, + cert_file: Path | None, + key_file: Path | None, listen_host: str, listen_port: int, backend_host: str, @@ -44,9 +44,11 @@ def __init__( runtime_state: RuntimeState | None = None, runtime_credentials: RuntimeCredentialsStore | None = None, zone_ranges_store: ZoneRangesStore | None = None, + tls_enabled: bool = True, ) -> None: self.cert_file = cert_file self.key_file = key_file + self.tls_enabled = tls_enabled self.listen_host = listen_host self.listen_port = listen_port self.backend_host = backend_host @@ -63,8 +65,7 @@ def __init__( self._running = False self._counter = 0 self._lock = threading.Lock() - self._conn_protocol_levels: dict[str, int] = {} - self._trace_queue: queue.Queue[tuple[str, str, bytes] | None] = queue.Queue() + self._conn_protocol_levels: dict[str, int] = {} self._trace_queue: queue.Queue[tuple[str, str, bytes] | None] = queue.Queue() self._trace_thread: threading.Thread | None = None self._protocol_auth = ( ProtocolAuthStore( @@ -407,8 +408,7 @@ def _trace_packet(self, conn_id: str, direction: str, packet: bytes) -> None: topic, payload = self._extract_publish(packet, self._get_conn_protocol_level(conn_id)) if topic is None or payload is None: - return - if self.runtime_state is not None: + return if self.runtime_state is not None: self.runtime_state.record_mqtt_message( conn_id=conn_id, direction=direction, @@ -589,7 +589,7 @@ def _relay(self, src: socket.socket, dst: socket.socket, conn_id: str, direction except OSError: pass - def _handle_client(self, tls_conn: ssl.SSLSocket, addr: tuple[str, int]) -> None: + def _handle_client(self, tls_conn: socket.socket | ssl.SSLSocket, addr: tuple[str, int]) -> None: conn_id = self._next_conn() backend: socket.socket | None = None relay_started = False @@ -675,7 +675,9 @@ def start(self) -> threading.Thread: thread.start() return thread - def _run(self) -> None: + def _build_tls_context(self) -> ssl.SSLContext: + if self.cert_file is None or self.key_file is None: + raise RuntimeError("TLS-enabled MQTT proxy requires cert_file and key_file") tls_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) tls_ctx.set_ciphers("DEFAULT:@SECLEVEL=0") # Older Roborock firmware MQTT clients negotiate TLSv1.0/1.1. @@ -691,6 +693,31 @@ def _run(self) -> None: tls_ctx.load_cert_chain(str(self.cert_file), str(self.key_file)) tls_ctx.check_hostname = False tls_ctx.verify_mode = ssl.CERT_NONE + return tls_ctx + + def _accept_client_connection( + self, + *, + raw_conn: socket.socket, + addr: tuple[str, int], + tls_ctx: ssl.SSLContext | None, + ) -> socket.socket | ssl.SSLSocket | None: + if not self.tls_enabled: + self.logger.info("Plain MQTT accept from %s:%d", addr[0], addr[1]) + return raw_conn + if tls_ctx is None: + raise RuntimeError("TLS MQTT accept requires an SSL context") + try: + tls_conn = tls_ctx.wrap_socket(raw_conn, server_side=True) + self.logger.info("TLS handshake ok from %s:%d (%s)", addr[0], addr[1], tls_conn.version()) + return tls_conn + except (ssl.SSLError, ConnectionResetError, OSError) as exc: + self.logger.warning("TLS handshake failed from %s:%d: %s", addr[0], addr[1], exc) + raw_conn.close() + return None + + def _run(self) -> None: + tls_ctx = self._build_tls_context() if self.tls_enabled else None self._server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self._server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) @@ -698,7 +725,8 @@ def _run(self) -> None: self._server_socket.listen(10) self._running = True self.logger.info( - "TLS MQTT proxy listening on %s:%d -> %s:%d", + "%s MQTT proxy listening on %s:%d -> %s:%d", + "TLS" if self.tls_enabled else "Plain", self.listen_host, self.listen_port, self.backend_host, @@ -708,14 +736,10 @@ def _run(self) -> None: while self._running: try: raw_conn, addr = self._server_socket.accept() - try: - tls_conn = tls_ctx.wrap_socket(raw_conn, server_side=True) - self.logger.info("TLS handshake ok from %s:%d (%s)", addr[0], addr[1], tls_conn.version()) - except (ssl.SSLError, ConnectionResetError, OSError) as exc: - self.logger.warning("TLS handshake failed from %s:%d: %s", addr[0], addr[1], exc) - raw_conn.close() + client_conn = self._accept_client_connection(raw_conn=raw_conn, addr=addr, tls_ctx=tls_ctx) + if client_conn is None: continue - threading.Thread(target=self._handle_client, args=(tls_conn, addr), daemon=True).start() + threading.Thread(target=self._handle_client, args=(client_conn, addr), daemon=True).start() except OSError as exc: if not self._running: break diff --git a/src/roborock_local_server/bundled_backend/shared/routine_runner.py b/src/roborock_local_server/bundled_backend/shared/routine_runner.py index 6bcd596..569b5f5 100644 --- a/src/roborock_local_server/bundled_backend/shared/routine_runner.py +++ b/src/roborock_local_server/bundled_backend/shared/routine_runner.py @@ -50,6 +50,7 @@ def _ensure_local_python_roborock_on_path() -> None: _STEP_COMPLETE_TIMEOUT_SECONDS = 4 * 60 * 60 _STEP_START_POLL_INTERVAL_SECONDS = 0.5 _STATUS_POLL_INTERVAL_SECONDS = 5.0 +_STEP_COMPLETE_CONFIRM_SECONDS = 30.0 _ROUTINE_READY_STATES = {3, 8, 100} _RESUME_BATTERY_THRESHOLD = 80 _POST_STEP_SETTLE_SECONDS = 15.0 @@ -571,6 +572,7 @@ async def wait_for_step_complete(self) -> None: saw_activity = False saw_cleaning = False sent_resume = False + completion_candidate_since: float | None = None try: while True: @@ -594,8 +596,9 @@ async def wait_for_step_complete(self) -> None: if in_cleaning != RoborockInCleaning.complete.value: saw_cleaning = True + is_non_cleaning = in_cleaning == RoborockInCleaning.complete.value is_ready = ( - in_cleaning == RoborockInCleaning.complete.value + is_non_cleaning and state in _ROUTINE_READY_STATES ) @@ -627,8 +630,17 @@ async def wait_for_step_complete(self) -> None: saw_activity = True if sent_resume and state not in (8, 23, 26): sent_resume = False - elif saw_cleaning: - return + if saw_cleaning: + if is_non_cleaning: + if completion_candidate_since is None: + completion_candidate_since = loop.time() + elif ( + loop.time() - completion_candidate_since + >= _STEP_COMPLETE_CONFIRM_SECONDS + ): + return + else: + completion_candidate_since = None elif saw_activity: self._logger.info( "Routine wait: dock activity cycle ended (no cleaning observed), resetting" @@ -676,11 +688,10 @@ async def wait_for_dock_settle(self, *, initial_delay_seconds: float = _POST_STE while True: remaining = deadline - loop.time() if remaining <= 0: - self._logger.warning( - "Post-step settle: timed out after %.0fs waiting for dock activity to finish", - _POST_STEP_SETTLE_TIMEOUT_SECONDS, + raise RoutineExecutionError( + "Timed out waiting for dock activity to finish " + f"after {_POST_STEP_SETTLE_TIMEOUT_SECONDS}s" ) - break status = await asyncio.wait_for(self.get_status(), timeout=remaining) state = _enum_or_int_value(status.state) diff --git a/src/roborock_local_server/config.py b/src/roborock_local_server/config.py index c60e67e..53a020a 100644 --- a/src/roborock_local_server/config.py +++ b/src/roborock_local_server/config.py @@ -10,9 +10,12 @@ @dataclass(frozen=True) class NetworkConfig: stack_fqdn: str + listener_mode: str bind_host: str https_port: int mqtt_tls_port: int + listen_https_port: int + listen_mqtt_port: int region: str localkey: str duid: str @@ -99,6 +102,14 @@ def _require_non_empty(value: object, field_name: str) -> str: return text +def _require_stack_fqdn(value: object, field_name: str) -> str: + text = _require_non_empty(value, field_name) + hostname = text.split("/", 1)[0].split(":", 1)[0].strip().lower() + if not hostname.startswith("api-"): + raise ValueError(f"{field_name} must start with api-") + return text + + def _as_int(value: object, field_name: str, default: int) -> int: if value in (None, ""): return default @@ -122,6 +133,15 @@ def _as_bool(value: object, default: bool) -> bool: return bool(value) +def _listener_port( + section: dict[str, object], + *, + field_name: str, + default: int, +) -> int: + return _as_int(section.get(field_name), f"network.{field_name}", default) + + def load_config(path: str | Path) -> AppConfig: config_path = Path(path).resolve() parsed = tomllib.loads(config_path.read_text(encoding="utf-8")) @@ -138,6 +158,9 @@ def load_config(path: str | Path) -> AppConfig: tls_mode = str(tls.get("mode", "cloudflare_acme")).strip().lower() if tls_mode not in {"cloudflare_acme", "provided"}: raise ValueError("tls.mode must be 'cloudflare_acme' or 'provided'") + listener_mode = str(network.get("listener_mode", "local_tls")).strip().lower() or "local_tls" + if listener_mode not in {"local_tls", "external_tls"}: + raise ValueError("network.listener_mode must be 'local_tls' or 'external_tls'") raw_broker_host = broker.get("host") broker_host = str(raw_broker_host).strip() if raw_broker_host is not None else "127.0.0.1" @@ -147,10 +170,21 @@ def load_config(path: str | Path) -> AppConfig: config = AppConfig( network=NetworkConfig( - stack_fqdn=_require_non_empty(network.get("stack_fqdn"), "network.stack_fqdn"), + stack_fqdn=_require_stack_fqdn(network.get("stack_fqdn"), "network.stack_fqdn"), + listener_mode=listener_mode, bind_host=str(network.get("bind_host", "0.0.0.0")).strip() or "0.0.0.0", https_port=_as_int(network.get("https_port"), "network.https_port", 555), mqtt_tls_port=_as_int(network.get("mqtt_tls_port"), "network.mqtt_tls_port", 8881), + listen_https_port=_listener_port( + network, + field_name="listen_https_port", + default=_as_int(network.get("https_port"), "network.https_port", 555), + ), + listen_mqtt_port=_listener_port( + network, + field_name="listen_mqtt_port", + default=_as_int(network.get("mqtt_tls_port"), "network.mqtt_tls_port", 8881), + ), region=str(network.get("region", "us")).strip().lower() or "us", localkey=str(network.get("localkey", "")).strip(), duid=str(network.get("duid", "")).strip(), @@ -200,13 +234,14 @@ def load_config(path: str | Path) -> AppConfig: if config.broker.mode == "external": _require_non_empty(config.broker.host, "broker.host") - if config.tls.mode == "cloudflare_acme": - _require_non_empty(config.tls.base_domain, "tls.base_domain") - _require_non_empty(config.tls.email, "tls.email") - _require_non_empty(config.tls.cloudflare_token_file, "tls.cloudflare_token_file") - else: - _require_non_empty(config.tls.cert_file, "tls.cert_file") - _require_non_empty(config.tls.key_file, "tls.key_file") + if config.network.listener_mode == "local_tls": + if config.tls.mode == "cloudflare_acme": + _require_non_empty(config.tls.base_domain, "tls.base_domain") + _require_non_empty(config.tls.email, "tls.email") + _require_non_empty(config.tls.cloudflare_token_file, "tls.cloudflare_token_file") + else: + _require_non_empty(config.tls.cert_file, "tls.cert_file") + _require_non_empty(config.tls.key_file, "tls.key_file") return config diff --git a/src/roborock_local_server/configure.py b/src/roborock_local_server/configure.py index 1c77b0c..544dd2c 100644 --- a/src/roborock_local_server/configure.py +++ b/src/roborock_local_server/configure.py @@ -41,8 +41,11 @@ def hash_password(password: str, *, iterations: int = 600_000) -> str: @dataclass(frozen=True) class ConfigureAnswers: stack_fqdn: str + listener_mode: str https_port: int mqtt_tls_port: int + listen_https_port: int + listen_mqtt_port: int broker_mode: str tls_mode: str base_domain: str @@ -65,7 +68,7 @@ def _toml_string(value: str) -> str: return json.dumps(value) -def _normalize_hostname(raw_value: str, *, field_name: str) -> str: +def _normalize_hostname(raw_value: str, *, field_name: str, require_api_prefix: bool = False) -> str: text = str(raw_value or "").strip() if not text: raise ValueError(f"{field_name} is required") @@ -85,6 +88,8 @@ def _normalize_hostname(raw_value: str, *, field_name: str) -> str: raise ValueError(f"{field_name} must be a hostname without a scheme or path") if "." not in normalized: raise ValueError(f"{field_name} must be a fully qualified domain name") + if require_api_prefix and not normalized.startswith("api-"): + raise ValueError(f"{field_name} must start with api-") return normalized @@ -100,7 +105,11 @@ def _prompt_hostname(prompt: str, *, field_name: str) -> str: while True: raw_value = _prompt_non_empty(prompt) try: - return _normalize_hostname(raw_value, field_name=field_name) + return _normalize_hostname( + raw_value, + field_name=field_name, + require_api_prefix=field_name == "stack_fqdn", + ) except ValueError as exc: print(exc) @@ -177,10 +186,21 @@ def collect_configure_answers() -> ConfigureAnswers: "Stack FQDN (hostname only (no 'https://'); it needs to start with api-): ", field_name="stack_fqdn", ) - https_port = _prompt_port("HTTPS port to advertise and listen on", default=555) - mqtt_tls_port = _prompt_port("MQTT TLS port to advertise and listen on", default=8881) + https_port = _prompt_port("Advertised HTTPS port", default=555) + mqtt_tls_port = _prompt_port("Advertised MQTT TLS port", default=8881) + use_external_tls = _prompt_yes_no( + "Will an external proxy terminate TLS for both HTTPS and MQTT?", + default=False, + ) + listener_mode = "external_tls" if use_external_tls else "local_tls" + listen_https_port = _prompt_port("Internal HTTPS listen port", default=https_port) + listen_mqtt_port = _prompt_port("Internal MQTT listen port", default=mqtt_tls_port) use_external_broker = _prompt_yes_no("Use your own MQTT broker instead of the embedded one?", default=False) - use_cloudflare_acme = _prompt_yes_no("Use Cloudflare DNS-01 for automatic TLS renewal?", default=True) + use_cloudflare_acme = ( + _prompt_yes_no("Use Cloudflare DNS-01 for automatic TLS renewal?", default=True) + if listener_mode == "local_tls" + else False + ) broker_mode = "external" if use_external_broker else "embedded" tls_mode = "cloudflare_acme" if use_cloudflare_acme else "provided" @@ -204,8 +224,11 @@ def collect_configure_answers() -> ConfigureAnswers: protocol_login_pin = _prompt_protocol_login_pin() return ConfigureAnswers( stack_fqdn=stack_fqdn, + listener_mode=listener_mode, https_port=https_port, mqtt_tls_port=mqtt_tls_port, + listen_https_port=listen_https_port, + listen_mqtt_port=listen_mqtt_port, broker_mode=broker_mode, tls_mode=tls_mode, base_domain=base_domain, @@ -222,9 +245,12 @@ def render_config_toml(answers: ConfigureAnswers) -> str: lines = [ "[network]", f"stack_fqdn = {_toml_string(answers.stack_fqdn)}", + f'listener_mode = "{answers.listener_mode}"', 'bind_host = "0.0.0.0"', f"https_port = {answers.https_port}", f"mqtt_tls_port = {answers.mqtt_tls_port}", + f"listen_https_port = {answers.listen_https_port}", + f"listen_mqtt_port = {answers.listen_mqtt_port}", 'region = "us"', "", "[broker]", @@ -260,7 +286,7 @@ def render_config_toml(answers: ConfigureAnswers) -> str: f'mode = "{answers.tls_mode}"', ] ) - if answers.tls_mode == "cloudflare_acme": + if answers.listener_mode == "local_tls" and answers.tls_mode == "cloudflare_acme": lines.extend( [ f"base_domain = {_toml_string(answers.base_domain)}", @@ -311,7 +337,7 @@ def write_config_setup( token_path = config_path.parent / "secrets" / "cloudflare_token" protected_paths = [config_path] - if answers.tls_mode == "cloudflare_acme": + if answers.listener_mode == "local_tls" and answers.tls_mode == "cloudflare_acme": protected_paths.append(token_path) if not force: @@ -324,7 +350,7 @@ def write_config_setup( config_path.write_text(render_config_toml(answers), encoding="utf-8") written_token_path: Path | None = None - if answers.tls_mode == "cloudflare_acme": + if answers.listener_mode == "local_tls" and answers.tls_mode == "cloudflare_acme": token_path.parent.mkdir(parents=True, exist_ok=True) token_path.write_text(answers.cloudflare_token, encoding="utf-8") if os.name != "nt": diff --git a/src/roborock_local_server/container_entrypoint.py b/src/roborock_local_server/container_entrypoint.py index 7e5ebcc..3d423ea 100644 --- a/src/roborock_local_server/container_entrypoint.py +++ b/src/roborock_local_server/container_entrypoint.py @@ -15,27 +15,36 @@ def _exec_server(config_path: Path) -> None: ) -def main() -> int: - compose_config = Path("/app/config.toml") +def _run_entrypoint(*, compose_config: Path, data_config: Path, addon_options: Path) -> None: if compose_config.exists(): _exec_server(compose_config) + return - data_config = Path("/data/config.toml") - if data_config.exists(): - _exec_server(data_config) - - addon_options = Path("/data/options.json") if addon_options.exists(): write_config_from_home_assistant_options( options_path=addon_options, config_path=data_config, ) _exec_server(data_config) + return + + if data_config.exists(): + _exec_server(data_config) + return raise SystemExit( "No config file found. Expected /app/config.toml, /data/config.toml, or /data/options.json." ) +def main() -> int: + _run_entrypoint( + compose_config=Path("/app/config.toml"), + data_config=Path("/data/config.toml"), + addon_options=Path("/data/options.json"), + ) + return 0 + + if __name__ == "__main__": raise SystemExit(main()) diff --git a/src/roborock_local_server/ha_addon.py b/src/roborock_local_server/ha_addon.py new file mode 100644 index 0000000..fe0d8b9 --- /dev/null +++ b/src/roborock_local_server/ha_addon.py @@ -0,0 +1,282 @@ +"""Home Assistant app option adapter for config.toml generation.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import re +import secrets +from typing import Any +from urllib.parse import urlsplit + +from .configure import hash_password + + +DEFAULT_OPTIONS: dict[str, Any] = { + "stack_fqdn": "", + "listener_mode": "local_tls", + "https_port": 555, + "mqtt_tls_port": 8881, + "listen_https_port": 555, + "listen_mqtt_port": 8881, + "region": "us", + "use_external_broker": False, + "broker_host": "127.0.0.1", + "broker_port": 18830, + "enable_topic_bridge": True, + "tls_mode": "provided", + "tls_base_domain": "", + "tls_email": "", + "cloudflare_token": "", + "cert_file": "/ssl/fullchain.pem", + "key_file": "/ssl/privkey.pem", + "admin_password": "", + "admin_session_secret": "", + "protocol_auth_enabled": True, + "protocol_login_email": "", + "protocol_login_pin": "", +} + +_HOST_RE = re.compile(r"^[a-z0-9.-]+$") +DEFAULT_OPTIONS_PATH = Path("/data/options.json") +DEFAULT_CONFIG_PATH = Path("/data/config.toml") +DEFAULT_CLOUDFLARE_TOKEN_PATH = Path("/run/secrets/cloudflare_token") + + +def _toml_string(value: str) -> str: + return json.dumps(value) + + +def _toml_bool(value: bool) -> str: + return "true" if value else "false" + + +def _normalize_hostname(raw_value: str, *, field_name: str, require_api_prefix: bool = False) -> str: + text = str(raw_value or "").strip() + if not text: + raise ValueError(f"{field_name} is required") + if "://" in text: + parsed = urlsplit(text) + candidate = parsed.hostname or "" + else: + candidate = text.split("/", 1)[0].strip() + if ":" in candidate: + candidate = candidate.split(":", 1)[0].strip() + normalized = candidate.strip().strip(".").lower() + if normalized.startswith("*."): + normalized = normalized[2:].strip() + if not normalized: + raise ValueError(f"{field_name} is required") + if " " in normalized or not _HOST_RE.fullmatch(normalized): + raise ValueError(f"{field_name} must be a hostname without a scheme or path") + if "." not in normalized: + raise ValueError(f"{field_name} must be a fully qualified domain name") + if require_api_prefix and not normalized.startswith("api-"): + raise ValueError(f"{field_name} must start with api-") + return normalized + + +def _as_bool(value: object, *, default: bool) -> bool: + if value is None: + return default + if isinstance(value, bool): + return value + if isinstance(value, str): + lowered = value.strip().lower() + if lowered in {"1", "true", "yes", "on"}: + return True + if lowered in {"0", "false", "no", "off"}: + return False + return bool(value) + + +def _as_int(value: object, *, field_name: str, default: int) -> int: + if value in (None, ""): + return default + try: + candidate = int(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"{field_name} must be an integer") from exc + if not (1 <= candidate <= 65535): + raise ValueError(f"{field_name} must be between 1 and 65535") + return candidate + + +def _require_non_empty(value: object, *, field_name: str) -> str: + text = str(value or "").strip() + if not text: + raise ValueError(f"{field_name} is required") + return text + + +def _require_email(value: object, *, field_name: str) -> str: + text = _require_non_empty(value, field_name=field_name) + if "@" not in text: + raise ValueError(f"{field_name} must be an email address") + return text + + +def _require_pin(value: object, *, field_name: str) -> str: + text = _require_non_empty(value, field_name=field_name) + if len(text) != 6 or not text.isdigit(): + raise ValueError(f"{field_name} must be exactly 6 digits") + return text + + +def _load_options(path: Path) -> dict[str, Any]: + if not path.exists(): + return {} + parsed = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(parsed, dict): + raise ValueError(f"{path} must contain a JSON object") + return parsed + + +def _render_config_toml(*, options: dict[str, Any], cloudflare_token_path: Path) -> str: + merged = dict(DEFAULT_OPTIONS) + merged.update(options) + + stack_fqdn = _normalize_hostname( + merged.get("stack_fqdn", ""), + field_name="stack_fqdn", + require_api_prefix=True, + ) + region = str(merged.get("region", "us") or "us").strip().lower() or "us" + listener_mode = str(merged.get("listener_mode", "local_tls") or "local_tls").strip().lower() or "local_tls" + if listener_mode not in {"local_tls", "external_tls"}: + raise ValueError("listener_mode must be 'local_tls' or 'external_tls'") + https_port = _as_int(merged.get("https_port"), field_name="https_port", default=555) + mqtt_tls_port = _as_int(merged.get("mqtt_tls_port"), field_name="mqtt_tls_port", default=8881) + listen_https_port = _as_int(merged.get("listen_https_port"), field_name="listen_https_port", default=https_port) + listen_mqtt_port = _as_int(merged.get("listen_mqtt_port"), field_name="listen_mqtt_port", default=mqtt_tls_port) + + use_external_broker = _as_bool(merged.get("use_external_broker"), default=False) + broker_mode = "external" if use_external_broker else "embedded" + broker_host = str(merged.get("broker_host", "127.0.0.1") or "").strip() + if not broker_host: + broker_host = "127.0.0.1" if not use_external_broker else "" + if use_external_broker and not broker_host: + raise ValueError("broker_host is required when use_external_broker is true") + broker_port_default = 1883 if use_external_broker else 18830 + broker_port = _as_int(merged.get("broker_port"), field_name="broker_port", default=broker_port_default) + enable_topic_bridge = _as_bool(merged.get("enable_topic_bridge"), default=True) + + tls_mode = str(merged.get("tls_mode", "provided") or "provided").strip().lower() + if tls_mode not in {"provided", "cloudflare_acme"}: + raise ValueError("tls_mode must be 'provided' or 'cloudflare_acme'") + tls_base_domain = str(merged.get("tls_base_domain", "") or "").strip() + tls_email = str(merged.get("tls_email", "") or "").strip() + cloudflare_token = str(merged.get("cloudflare_token", "") or "").strip() + cert_file = str(merged.get("cert_file", "/ssl/fullchain.pem") or "").strip() + key_file = str(merged.get("key_file", "/ssl/privkey.pem") or "").strip() + + if listener_mode == "local_tls" and tls_mode == "cloudflare_acme": + _normalize_hostname(tls_base_domain, field_name="tls_base_domain") + _require_email(tls_email, field_name="tls_email") + _require_non_empty(cloudflare_token, field_name="cloudflare_token") + elif listener_mode == "local_tls": + _require_non_empty(cert_file, field_name="cert_file") + _require_non_empty(key_file, field_name="key_file") + + admin_password = _require_non_empty(merged.get("admin_password"), field_name="admin_password") + admin_session_secret = str(merged.get("admin_session_secret", "") or "").strip() or secrets.token_urlsafe(32) + if len(admin_session_secret) < 24: + raise ValueError("admin_session_secret must be at least 24 characters when set") + protocol_auth_enabled = _as_bool(merged.get("protocol_auth_enabled"), default=True) + protocol_login_email = _require_email(merged.get("protocol_login_email"), field_name="protocol_login_email") + protocol_login_pin = _require_pin(merged.get("protocol_login_pin"), field_name="protocol_login_pin") + + password_hash = hash_password(admin_password) + protocol_login_pin_hash = hash_password(protocol_login_pin) + cloudflare_token_file = str(cloudflare_token_path) + + lines = [ + "[network]", + f"stack_fqdn = {_toml_string(stack_fqdn)}", + f"listener_mode = {_toml_string(listener_mode)}", + 'bind_host = "0.0.0.0"', + f"https_port = {https_port}", + f"mqtt_tls_port = {mqtt_tls_port}", + f"listen_https_port = {listen_https_port}", + f"listen_mqtt_port = {listen_mqtt_port}", + f"region = {_toml_string(region)}", + "", + "[broker]", + f"mode = {_toml_string(broker_mode)}", + f"host = {_toml_string(broker_host)}", + f"port = {broker_port}", + 'mosquitto_binary = "mosquitto"', + f"enable_topic_bridge = {_toml_bool(enable_topic_bridge)}", + "", + "[storage]", + 'data_dir = "/data"', + "", + "[tls]", + f"mode = {_toml_string(tls_mode)}", + ] + if listener_mode == "local_tls" and tls_mode == "cloudflare_acme": + lines.extend( + [ + f"base_domain = {_toml_string(tls_base_domain)}", + f"email = {_toml_string(tls_email)}", + f"cloudflare_token_file = {_toml_string(cloudflare_token_file)}", + "renew_days_before = 30", + "renew_check_seconds = 43200", + 'acme_server = "zerossl"', + ] + ) + else: + lines.extend( + [ + 'base_domain = ""', + 'email = ""', + 'cloudflare_token_file = ""', + "renew_days_before = 30", + "renew_check_seconds = 43200", + 'acme_server = "zerossl"', + f"cert_file = {_toml_string(cert_file)}", + f"key_file = {_toml_string(key_file)}", + ] + ) + lines.extend( + [ + "", + "[admin]", + f"password_hash = {_toml_string(password_hash)}", + f"session_secret = {_toml_string(admin_session_secret)}", + "session_ttl_seconds = 86400", + f"protocol_auth_enabled = {_toml_bool(protocol_auth_enabled)}", + f"protocol_login_email = {_toml_string(protocol_login_email)}", + f"protocol_login_pin_hash = {_toml_string(protocol_login_pin_hash)}", + "", + ] + ) + return "\n".join(lines), cloudflare_token if listener_mode == "local_tls" and tls_mode == "cloudflare_acme" else "" + + +def write_config_from_home_assistant_options( + *, + options_path: Path = DEFAULT_OPTIONS_PATH, + config_path: Path = DEFAULT_CONFIG_PATH, + cloudflare_token_path: Path = DEFAULT_CLOUDFLARE_TOKEN_PATH, +) -> Path: + options = _load_options(options_path) + config_text, cloudflare_token = _render_config_toml(options=options, cloudflare_token_path=cloudflare_token_path) + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text(config_text, encoding="utf-8") + if cloudflare_token: + cloudflare_token_path.parent.mkdir(parents=True, exist_ok=True) + cloudflare_token_path.write_text(cloudflare_token, encoding="utf-8") + if os.name != "nt": + cloudflare_token_path.chmod(0o600) + return config_path + + +def main() -> int: + write_config_from_home_assistant_options() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/roborock_local_server/server.py b/src/roborock_local_server/server.py index 466e537..00af443 100644 --- a/src/roborock_local_server/server.py +++ b/src/roborock_local_server/server.py @@ -173,28 +173,36 @@ def __init__( app: FastAPI, bind_host: str, port: int, - cert_file: Path, - key_file: Path, + tls_enabled: bool, + cert_file: Path | None = None, + key_file: Path | None = None, ) -> None: self._app = app self._bind_host = bind_host self._port = port + self._tls_enabled = tls_enabled self._cert_file = cert_file self._key_file = key_file self._server: uvicorn.Server | None = None self._serve_task: asyncio.Task[bool] | None = None async def start(self) -> None: - config = uvicorn.Config( - app=self._app, - host=self._bind_host, - port=self._port, - log_level="warning", - access_log=False, - ssl_certfile=str(self._cert_file), - ssl_keyfile=str(self._key_file), - ssl_ciphers="DEFAULT:@SECLEVEL=0", - ) + config_kwargs: dict[str, Any] = { + "app": self._app, + "host": self._bind_host, + "port": self._port, + "log_level": "warning", + "access_log": False, + } + if self._tls_enabled: + if self._cert_file is None or self._key_file is None: + raise RuntimeError("TLS-enabled HTTP server requires cert_file and key_file") + config_kwargs.update( + ssl_certfile=str(self._cert_file), + ssl_keyfile=str(self._key_file), + ssl_ciphers="DEFAULT:@SECLEVEL=0", + ) + config = uvicorn.Config(**config_kwargs) self._server = uvicorn.Server(config) self._serve_task = asyncio.create_task(self._server.serve(), name="release-https-server") await self._wait_started() @@ -350,14 +358,20 @@ def __init__( running=False, required=True, enabled=True, - detail=f"{self.config.network.bind_host}:{self.config.network.https_port}", + detail=( + f"{self.config.network.listener_mode}:" + f"{self.config.network.bind_host}:{self.config.network.listen_https_port}" + ), ) self.runtime_state.set_service( "mqtt_tls_proxy", running=False, required=True, enabled=True, - detail=f"{self.config.network.bind_host}:{self.config.network.mqtt_tls_port}", + detail=( + f"{self.config.network.listener_mode}:" + f"{self.config.network.bind_host}:{self.config.network.listen_mqtt_port}" + ), ) self.runtime_state.set_service( "mqtt_backend_broker", @@ -421,6 +435,9 @@ def __init__( self.endpoint_rules = default_endpoint_rules() self.app = self._create_app() + def _uses_local_tls(self) -> bool: + return self.config.network.listener_mode == "local_tls" + def _init_zone_ranges_store(self) -> ZoneRangesStore: store = ZoneRangesStore(self.paths.http_jsonl_path.parent) if not store._data: @@ -1443,24 +1460,25 @@ def _create_app(self) -> FastAPI: return app async def _start_http_server(self) -> None: - cert_paths = self.certificate_manager.certificate_paths + cert_paths = self.certificate_manager.certificate_paths if self._uses_local_tls() else None self._http_server = ManagedFastApiServer( app=self.app, bind_host=self.config.network.bind_host, - port=self.config.network.https_port, - cert_file=cert_paths.cert_file, - key_file=cert_paths.key_file, + port=self.config.network.listen_https_port, + tls_enabled=self._uses_local_tls(), + cert_file=cert_paths.cert_file if cert_paths is not None else None, + key_file=cert_paths.key_file if cert_paths is not None else None, ) await self._http_server.start() self.runtime_state.set_service("https_server", running=True, required=True, enabled=True) def _start_mqtt_proxy(self) -> None: - cert_paths = self.certificate_manager.certificate_paths + cert_paths = self.certificate_manager.certificate_paths if self._uses_local_tls() else None self._mqtt_proxy = MqttTlsProxy( - cert_file=cert_paths.cert_file, - key_file=cert_paths.key_file, + cert_file=cert_paths.cert_file if cert_paths is not None else None, + key_file=cert_paths.key_file if cert_paths is not None else None, listen_host=self.config.network.bind_host, - listen_port=self.config.network.mqtt_tls_port, + listen_port=self.config.network.listen_mqtt_port, backend_host=self.config.broker.host, backend_port=self.config.broker.port, localkey=self.context.localkey, @@ -1472,6 +1490,7 @@ def _start_mqtt_proxy(self) -> None: runtime_state=self.runtime_state, runtime_credentials=self.runtime_credentials, zone_ranges_store=self.context.zone_ranges_store, + tls_enabled=self._uses_local_tls(), ) self._mqtt_proxy.start() self.runtime_state.set_service("mqtt_tls_proxy", running=True, required=True, enabled=True) @@ -1503,7 +1522,8 @@ async def start(self) -> None: for path in (self.paths.data_dir, self.paths.runtime_dir, self.paths.state_dir, self.paths.certs_dir, self.paths.acme_dir): path.mkdir(parents=True, exist_ok=True) - self.certificate_manager.ensure_certificate() + if self._uses_local_tls(): + self.certificate_manager.ensure_certificate() self.refresh_inventory_state() if self.config.broker.mode == "embedded": @@ -1535,14 +1555,16 @@ async def start(self) -> None: self._start_mqtt_proxy() self.root_logger.info( - "HTTPS server listening on %s:%d", + "%s server listening on %s:%d", + "HTTPS" if self._uses_local_tls() else "HTTP", self.config.network.bind_host, - self.config.network.https_port, + self.config.network.listen_https_port, ) self.root_logger.info( - "MQTT TLS proxy listening on %s:%d", + "MQTT %s proxy listening on %s:%d", + "TLS" if self._uses_local_tls() else "plaintext", self.config.network.bind_host, - self.config.network.mqtt_tls_port, + self.config.network.listen_mqtt_port, ) self.root_logger.info( "MQTT backend %s on %s:%d", @@ -1551,7 +1573,7 @@ async def start(self) -> None: self.config.broker.port, ) - if self.config.tls.mode == "cloudflare_acme": + if self._uses_local_tls() and self.config.tls.mode == "cloudflare_acme": self._renew_task = asyncio.create_task(self._renew_loop(), name="tls-renew-loop") async def stop(self) -> None: diff --git a/tests/conftest.py b/tests/conftest.py index 237243e..e416b72 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -17,9 +17,12 @@ def write_release_config( tmp_path: Path, *, - stack_fqdn: str = "roborock.example.com", + stack_fqdn: str = "api-roborock.example.com", + listener_mode: str = "local_tls", https_port: int = 443, mqtt_tls_port: int = 8883, + listen_https_port: int | None = None, + listen_mqtt_port: int | None = None, broker_mode: str = "external", enable_topic_bridge: bool = False, protocol_auth_enabled: bool = True, @@ -32,12 +35,17 @@ def write_release_config( (cert_dir / "privkey.pem").write_text("test-key\n", encoding="utf-8") config_file = tmp_path / "config.toml" + resolved_listen_https_port = https_port if listen_https_port is None else listen_https_port + resolved_listen_mqtt_port = mqtt_tls_port if listen_mqtt_port is None else listen_mqtt_port config_file.write_text( f""" [network] stack_fqdn = "{stack_fqdn}" +listener_mode = "{listener_mode}" https_port = {https_port} mqtt_tls_port = {mqtt_tls_port} +listen_https_port = {resolved_listen_https_port} +listen_mqtt_port = {resolved_listen_mqtt_port} [broker] mode = "{broker_mode}" diff --git a/tests/contracts/test_ios_app_init_contract.py b/tests/contracts/test_ios_app_init_contract.py index c8fc9af..d4037f5 100644 --- a/tests/contracts/test_ios_app_init_contract.py +++ b/tests/contracts/test_ios_app_init_contract.py @@ -103,12 +103,14 @@ def test_ios_app_init_contract_from_anonymized_capture(tmp_path: Path, monkeypat for index, request in enumerate(fixture["requests"]): headers = dict(default_headers) headers.update(request.get("headers", {})) + json_body = json.dumps(request["json"], separators=(",", ":")) if request.get("json") is not None else None if request["path"].startswith(("/user/", "/v2/user/", "/v3/user/")): headers["authorization"] = build_hawk_authorization( user=user, path=request["path"], query_values=request.get("query"), - form_values=request.get("form") or request.get("json"), + form_values=request.get("form"), + json_body=json_body, timestamp=fixture["frozen_time"], nonce=f"contract-{index}", ) @@ -117,7 +119,7 @@ def test_ios_app_init_contract_from_anonymized_capture(tmp_path: Path, monkeypat url=request["path"], headers=headers, params=request.get("query"), - json=request.get("json"), + content=json_body if json_body is not None else None, data=request.get("form"), ) assert response.status_code == 200, request["name"] diff --git a/tests/test_admin_api.py b/tests/test_admin_api.py index 6c722b9..80f5f17 100644 --- a/tests/test_admin_api.py +++ b/tests/test_admin_api.py @@ -176,14 +176,24 @@ def _seed_protocol_snapshot(path: Path) -> None: ) -def _hawk_headers(snapshot_path: Path, path: str, *, form_values: dict[str, object] | None = None, json_values: dict[str, object] | None = None) -> dict[str, str]: +def _hawk_headers( + snapshot_path: Path, + path: str, + *, + form_values: dict[str, object] | None = None, + json_values: dict[str, object] | None = None, + json_body: str | None = None, +) -> dict[str, str]: user = ProtocolAuthStore(snapshot_path).availability().user assert user is not None + if json_body is None and json_values is not None: + json_body = json.dumps(json_values, separators=(",", ":")) return { "Authorization": build_hawk_authorization( user=user, path=path, - form_values=form_values or json_values, + form_values=form_values, + json_body=json_body, nonce=f"nonce-{path.replace('/', '-')}", ) } @@ -737,14 +747,18 @@ def test_scene_update_routes_persist_name_and_zone_ranges(tmp_path: Path) -> Non assert rename_response.json()["data"]["name"] == "After dinner" update_payload = _after_dinner_param_payload(device_id, include_ranges=False) + update_body = json.dumps(update_payload, separators=(",", ":")) update_response = client.put( "/user/scene/4491073/param", - json=update_payload, - headers=_hawk_headers( + content=update_body, + headers={ + "content-type": "application/json", + **_hawk_headers( paths.cloud_snapshot_path, "/user/scene/4491073/param", - json_values=update_payload, + json_body=update_body, ), + }, ) assert update_response.status_code == 200 @@ -761,6 +775,200 @@ def test_scene_update_routes_persist_name_and_zone_ranges(tmp_path: Path) -> Non assert second_step["params"]["data"][0]["zones"][0]["range"] == [32550, 22650, 34550, 25200] +def test_get_scenes_for_device_includes_edit_context(tmp_path: Path) -> None: + config_file = write_release_config(tmp_path) + config = load_config(config_file) + paths = resolve_paths(config_file, config) + device_id = "6HL2zfniaoYYV01CkVuhkO" + + paths.inventory_path.parent.mkdir(parents=True, exist_ok=True) + paths.inventory_path.write_text( + json.dumps( + { + "home": {"id": 1233716, "name": "My Home"}, + "devices": [ + { + "duid": device_id, + "name": "Qrevo MaxV", + "model": "roborock.vacuum.a87", + } + ], + "scenes": [ + { + "id": 4491073, + "name": "After dinner", + "device_id": device_id, + "device_name": "Qrevo MaxV", + "enabled": True, + "type": "WORKFLOW", + "param": json.dumps(_after_dinner_param_payload(device_id, include_ranges=True), separators=(",", ":")), + } + ], + } + ) + + "\n", + encoding="utf-8", + ) + _seed_protocol_snapshot(paths.cloud_snapshot_path) + + supervisor = ReleaseSupervisor(config=config, paths=paths) + client = TestClient(supervisor.app) + + response = client.get( + f"/user/scene/device/{device_id}", + headers=_hawk_headers(paths.cloud_snapshot_path, f"/user/scene/device/{device_id}"), + ) + assert response.status_code == 200 + + scenes = response.json()["data"] + assert len(scenes) == 1 + assert scenes[0]["homeId"] == 1233716 + assert scenes[0]["deviceId"] == device_id + assert scenes[0]["deviceName"] == "Qrevo MaxV" + + +def test_post_scene_create_accepts_hawk_json_body_signature(tmp_path: Path) -> None: + config_file = write_release_config(tmp_path) + config = load_config(config_file) + paths = resolve_paths(config_file, config) + device_id = "6HL2zfniaoYYV01CkVuhkO" + + paths.inventory_path.parent.mkdir(parents=True, exist_ok=True) + paths.inventory_path.write_text( + json.dumps( + { + "home": {"id": 1233716, "name": "My Home"}, + "devices": [ + { + "duid": device_id, + "name": "Qrevo MaxV", + "model": "roborock.vacuum.a87", + } + ], + "scenes": [], + } + ) + + "\n", + encoding="utf-8", + ) + _seed_protocol_snapshot(paths.cloud_snapshot_path) + _write_scene_zone_trace(paths.mqtt_jsonl_path) + + supervisor = ReleaseSupervisor(config=config, paths=paths) + client = TestClient(supervisor.app) + + create_payload = { + "name": "Party prep", + "homeId": "1233716", + "param": { + **_after_dinner_param_payload(device_id, include_ranges=False), + "tagId": 1002, + }, + } + create_body = json.dumps(create_payload, separators=(",", ":")) + response = client.post( + "/v2/user/scene", + content=create_body, + headers={ + "content-type": "application/json", + **_hawk_headers( + paths.cloud_snapshot_path, + "/v2/user/scene", + json_body=create_body, + ), + }, + ) + assert response.status_code == 200 + assert response.json()["data"]["name"] == "Party prep" + + stored_inventory = json.loads(paths.inventory_path.read_text(encoding="utf-8")) + assert any(scene["name"] == "Party prep" for scene in stored_inventory["scenes"]) + + +def test_shared_device_query_routes_return_rooms_and_received_devices(tmp_path: Path) -> None: + config_file = write_release_config(tmp_path) + config = load_config(config_file) + paths = resolve_paths(config_file, config) + device_id = "6HL2zfniaoYYV01CkVuhkO" + + paths.inventory_path.parent.mkdir(parents=True, exist_ok=True) + paths.inventory_path.write_text( + json.dumps( + { + "home": { + "id": 1316433, + "name": "My Home", + "rooms": [ + {"id": 10283928, "name": "Kitchen"}, + {"id": 10283924, "name": "Living room"}, + ], + }, + "received_devices": [ + { + "duid": device_id, + "name": "Roborock Qrevo MaxV 2", + "model": "roborock.vacuum.a87", + "product_id": "5gUei3OIJIXVD3eD85Balg", + "local_key": "xPd5Dr8CGGqtdDlH", + "online": True, + "pv": "1.0", + "share": True, + } + ], + } + ) + + "\n", + encoding="utf-8", + ) + _seed_protocol_snapshot(paths.cloud_snapshot_path) + cloud_snapshot = json.loads(paths.cloud_snapshot_path.read_text(encoding="utf-8")) + cloud_snapshot.update( + { + "id": 1316433, + "name": "My Home", + "receivedDevices": [ + { + "duid": device_id, + "name": "Roborock Qrevo MaxV 2", + "productId": "5gUei3OIJIXVD3eD85Balg", + "share": True, + } + ], + "products": [ + { + "id": "5gUei3OIJIXVD3eD85Balg", + "name": "Roborock Qrevo MaxV", + "model": "roborock.vacuum.a87", + "category": "robot.vacuum.cleaner", + } + ], + } + ) + paths.cloud_snapshot_path.write_text(json.dumps(cloud_snapshot) + "\n", encoding="utf-8") + + supervisor = ReleaseSupervisor(config=config, paths=paths) + client = TestClient(supervisor.app) + + received_devices_response = client.get( + "/user/deviceshare/query/receiveddevices", + headers=_hawk_headers(paths.cloud_snapshot_path, "/user/deviceshare/query/receiveddevices"), + ) + assert received_devices_response.status_code == 200 + received_devices = received_devices_response.json()["data"] + assert len(received_devices) == 1 + assert received_devices[0]["duid"] == device_id + + rooms_response = client.get( + f"/user/deviceshare/query/{device_id}/rooms", + headers=_hawk_headers(paths.cloud_snapshot_path, f"/user/deviceshare/query/{device_id}/rooms"), + ) + assert rooms_response.status_code == 200 + assert rooms_response.json()["data"] == [ + {"id": 10283928, "name": "Kitchen"}, + {"id": 10283924, "name": "Living room"}, + ] + + def test_execute_scene_hydrates_missing_zone_ranges_from_mqtt(tmp_path: Path) -> None: config_file = write_release_config(tmp_path) config = load_config(config_file) diff --git a/tests/test_config.py b/tests/test_config.py index 799b898..6fc6e95 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -9,7 +9,10 @@ def test_load_config_and_resolve_paths(tmp_path: Path) -> None: config_file.write_text( """ [network] -stack_fqdn = "roborock.example.com" +stack_fqdn = "api-roborock.example.com" +listener_mode = "local_tls" +listen_https_port = 555 +listen_mqtt_port = 8881 [broker] mode = "embedded" @@ -34,9 +37,12 @@ def test_load_config_and_resolve_paths(tmp_path: Path) -> None: config = load_config(config_file) paths = resolve_paths(config_file, config) - assert config.network.stack_fqdn == "roborock.example.com" + assert config.network.stack_fqdn == "api-roborock.example.com" + assert config.network.listener_mode == "local_tls" assert config.network.https_port == 555 assert config.network.mqtt_tls_port == 8881 + assert config.network.listen_https_port == 555 + assert config.network.listen_mqtt_port == 8881 assert config.admin.protocol_auth_enabled is True assert config.admin.protocol_login_email == "user@example.com" assert paths.data_dir == (tmp_path / "data").resolve() @@ -49,7 +55,10 @@ def test_load_config_requires_protocol_login_credentials(tmp_path: Path) -> None config_file.write_text( """ [network] -stack_fqdn = "roborock.example.com" +stack_fqdn = "api-roborock.example.com" +listener_mode = "local_tls" +listen_https_port = 555 +listen_mqtt_port = 8881 [broker] mode = "embedded" @@ -71,3 +80,74 @@ def test_load_config_requires_protocol_login_credentials(tmp_path: Path) -> None with pytest.raises(ValueError, match="admin.protocol_login_email is required"): load_config(config_file) + + +def test_load_config_requires_api_prefix_for_stack_fqdn(tmp_path: Path) -> None: + config_file = tmp_path / "config.toml" + config_file.write_text( + """ +[network] +stack_fqdn = "lashleyhomeassist.duckdns.org" +listener_mode = "local_tls" +listen_https_port = 555 +listen_mqtt_port = 8881 + +[broker] +mode = "embedded" + +[storage] +data_dir = "data" + +[tls] +mode = "provided" +cert_file = "certs/fullchain.pem" +key_file = "certs/privkey.pem" + +[admin] +password_hash = "pbkdf2_sha256$600000$abc$def" +session_secret = "abcdefghijklmnopqrstuvwxyz123456" +protocol_login_email = "user@example.com" +protocol_login_pin_hash = "pbkdf2_sha256$600000$ghi$jkl" + """.strip(), + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="network.stack_fqdn must start with api-"): + load_config(config_file) + + +def test_load_config_external_tls_allows_missing_cert_paths(tmp_path: Path) -> None: + config_file = tmp_path / "config.toml" + config_file.write_text( + """ +[network] +stack_fqdn = "api-roborock.example.com" +listener_mode = "external_tls" +https_port = 443 +mqtt_tls_port = 8883 +listen_https_port = 8080 +listen_mqtt_port = 18883 + +[broker] +mode = "embedded" + +[storage] +data_dir = "data" + +[tls] +mode = "provided" + +[admin] +password_hash = "pbkdf2_sha256$600000$abc$def" +session_secret = "abcdefghijklmnopqrstuvwxyz123456" +protocol_login_email = "user@example.com" +protocol_login_pin_hash = "pbkdf2_sha256$600000$ghi$jkl" + """.strip(), + encoding="utf-8", + ) + + config = load_config(config_file) + + assert config.network.listener_mode == "external_tls" + assert config.network.listen_https_port == 8080 + assert config.network.listen_mqtt_port == 18883 diff --git a/tests/test_configure.py b/tests/test_configure.py index 2d0299b..2ff0f15 100644 --- a/tests/test_configure.py +++ b/tests/test_configure.py @@ -8,15 +8,21 @@ def _answers( *, + listener_mode: str = "local_tls", https_port: int = 555, mqtt_tls_port: int = 8881, + listen_https_port: int | None = None, + listen_mqtt_port: int | None = None, broker_mode: str = "embedded", tls_mode: str = "cloudflare_acme", ) -> ConfigureAnswers: return ConfigureAnswers( - stack_fqdn="roborock.example.com", + stack_fqdn="api-roborock.example.com", + listener_mode=listener_mode, https_port=https_port, mqtt_tls_port=mqtt_tls_port, + listen_https_port=https_port if listen_https_port is None else listen_https_port, + listen_mqtt_port=mqtt_tls_port if listen_mqtt_port is None else listen_mqtt_port, broker_mode=broker_mode, tls_mode=tls_mode, base_domain="example.com" if tls_mode == "cloudflare_acme" else "", @@ -40,7 +46,8 @@ def test_write_config_setup_embedded_cloudflare(tmp_path: Path) -> None: assert not result.broker_template_needs_edit config = load_config(result.config_file) - assert config.network.stack_fqdn == "roborock.example.com" + assert config.network.stack_fqdn == "api-roborock.example.com" + assert config.network.listener_mode == "local_tls" assert config.network.https_port == 555 assert config.network.mqtt_tls_port == 8881 assert config.broker.mode == "embedded" @@ -94,6 +101,30 @@ def test_write_config_setup_persists_custom_ports(tmp_path: Path) -> None: assert config.network.mqtt_tls_port == 9443 +def test_write_config_setup_external_tls_writes_internal_listener_ports(tmp_path: Path) -> None: + config_file = tmp_path / "config.toml" + + result = write_config_setup( + config_file=config_file, + answers=_answers( + listener_mode="external_tls", + https_port=443, + mqtt_tls_port=8883, + listen_https_port=8080, + listen_mqtt_port=18883, + tls_mode="provided", + ), + ) + + config = load_config(result.config_file) + rendered = config_file.read_text(encoding="utf-8") + + assert config.network.listener_mode == "external_tls" + assert config.network.listen_https_port == 8080 + assert config.network.listen_mqtt_port == 18883 + assert 'listener_mode = "external_tls"' in rendered + + def test_validate_protocol_login_pin_requires_exactly_six_digits() -> None: assert _validate_protocol_login_pin("123456") == "123456" diff --git a/tests/test_container_entrypoint.py b/tests/test_container_entrypoint.py new file mode 100644 index 0000000..c224947 --- /dev/null +++ b/tests/test_container_entrypoint.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from roborock_local_server import container_entrypoint + + +def test_run_entrypoint_prefers_home_assistant_options_over_stale_data_config( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + compose_config = tmp_path / "app-config.toml" + data_config = tmp_path / "data-config.toml" + addon_options = tmp_path / "options.json" + data_config.write_text("stale = true\n", encoding="utf-8") + addon_options.write_text("{}", encoding="utf-8") + + calls: list[tuple[str, Path]] = [] + + monkeypatch.setattr( + container_entrypoint, + "write_config_from_home_assistant_options", + lambda *, options_path, config_path: calls.append(("write", config_path)), + ) + monkeypatch.setattr( + container_entrypoint, + "_exec_server", + lambda config_path: calls.append(("exec", config_path)), + ) + + container_entrypoint._run_entrypoint( + compose_config=compose_config, + data_config=data_config, + addon_options=addon_options, + ) + + assert calls == [("write", data_config), ("exec", data_config)] + + +def test_run_entrypoint_prefers_compose_config_when_present( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + compose_config = tmp_path / "app-config.toml" + data_config = tmp_path / "data-config.toml" + addon_options = tmp_path / "options.json" + compose_config.write_text("compose = true\n", encoding="utf-8") + data_config.write_text("stale = true\n", encoding="utf-8") + addon_options.write_text("{}", encoding="utf-8") + + calls: list[Path] = [] + + monkeypatch.setattr( + container_entrypoint, + "_exec_server", + lambda config_path: calls.append(config_path), + ) + + container_entrypoint._run_entrypoint( + compose_config=compose_config, + data_config=data_config, + addon_options=addon_options, + ) + + assert calls == [compose_config] diff --git a/tests/test_ha_addon.py b/tests/test_ha_addon.py new file mode 100644 index 0000000..a26b1ba --- /dev/null +++ b/tests/test_ha_addon.py @@ -0,0 +1,172 @@ +from __future__ import annotations + +import json +from pathlib import Path +import tomllib + +import pytest + +from roborock_local_server.ha_addon import write_config_from_home_assistant_options + + +def _write_options(path: Path, payload: dict[str, object]) -> None: + path.write_text(json.dumps(payload), encoding="utf-8") + + +def test_write_config_from_home_assistant_options_provided_tls(tmp_path: Path) -> None: + options_path = tmp_path / "options.json" + config_path = tmp_path / "config.toml" + token_path = tmp_path / "cloudflare_token" + + _write_options( + options_path, + { + "stack_fqdn": "https://api-roborock.example.com", + "listener_mode": "local_tls", + "https_port": 8443, + "mqtt_tls_port": 9443, + "listen_https_port": 8080, + "listen_mqtt_port": 18883, + "region": "us", + "use_external_broker": True, + "broker_host": "mqtt.internal", + "broker_port": 1883, + "enable_topic_bridge": False, + "tls_mode": "provided", + "cert_file": "/ssl/fullchain.pem", + "key_file": "/ssl/privkey.pem", + "admin_password": "super-secret-password", + "admin_session_secret": "a" * 32, + "protocol_auth_enabled": True, + "protocol_login_email": "user@example.com", + "protocol_login_pin": "123456", + }, + ) + + write_config_from_home_assistant_options( + options_path=options_path, + config_path=config_path, + cloudflare_token_path=token_path, + ) + + parsed = tomllib.loads(config_path.read_text(encoding="utf-8")) + assert parsed["network"]["stack_fqdn"] == "api-roborock.example.com" + assert parsed["network"]["listener_mode"] == "local_tls" + assert parsed["network"]["https_port"] == 8443 + assert parsed["network"]["mqtt_tls_port"] == 9443 + assert parsed["network"]["listen_https_port"] == 8080 + assert parsed["network"]["listen_mqtt_port"] == 18883 + assert parsed["broker"]["mode"] == "external" + assert parsed["broker"]["host"] == "mqtt.internal" + assert parsed["broker"]["enable_topic_bridge"] is False + assert parsed["tls"]["mode"] == "provided" + assert parsed["tls"]["cert_file"] == "/ssl/fullchain.pem" + assert parsed["tls"]["key_file"] == "/ssl/privkey.pem" + assert parsed["admin"]["protocol_login_email"] == "user@example.com" + assert str(parsed["admin"]["password_hash"]).startswith("pbkdf2_sha256$") + assert str(parsed["admin"]["protocol_login_pin_hash"]).startswith("pbkdf2_sha256$") + assert token_path.exists() is False + + +def test_write_config_from_home_assistant_options_cloudflare(tmp_path: Path) -> None: + options_path = tmp_path / "options.json" + config_path = tmp_path / "config.toml" + token_path = tmp_path / "run" / "secrets" / "cloudflare_token" + + _write_options( + options_path, + { + "stack_fqdn": "api-roborock.example.com", + "tls_mode": "cloudflare_acme", + "tls_base_domain": "example.com", + "tls_email": "acme@example.com", + "cloudflare_token": "cloudflare-token-123", + "admin_password": "secret", + "protocol_login_email": "user@example.com", + "protocol_login_pin": "654321", + }, + ) + + write_config_from_home_assistant_options( + options_path=options_path, + config_path=config_path, + cloudflare_token_path=token_path, + ) + + parsed = tomllib.loads(config_path.read_text(encoding="utf-8")) + assert parsed["tls"]["mode"] == "cloudflare_acme" + assert parsed["tls"]["base_domain"] == "example.com" + assert parsed["tls"]["email"] == "acme@example.com" + assert parsed["tls"]["cloudflare_token_file"] == str(token_path) + assert token_path.read_text(encoding="utf-8") == "cloudflare-token-123" + + +def test_write_config_from_home_assistant_options_external_tls_does_not_require_certs(tmp_path: Path) -> None: + options_path = tmp_path / "options.json" + config_path = tmp_path / "config.toml" + + _write_options( + options_path, + { + "stack_fqdn": "api-roborock.example.com", + "listener_mode": "external_tls", + "https_port": 443, + "mqtt_tls_port": 8883, + "listen_https_port": 8080, + "listen_mqtt_port": 18883, + "admin_password": "secret", + "protocol_login_email": "user@example.com", + "protocol_login_pin": "654321", + }, + ) + + write_config_from_home_assistant_options( + options_path=options_path, + config_path=config_path, + ) + + parsed = tomllib.loads(config_path.read_text(encoding="utf-8")) + assert parsed["network"]["listener_mode"] == "external_tls" + assert parsed["network"]["listen_https_port"] == 8080 + assert parsed["network"]["listen_mqtt_port"] == 18883 + + +def test_write_config_from_home_assistant_options_requires_admin_password(tmp_path: Path) -> None: + options_path = tmp_path / "options.json" + config_path = tmp_path / "config.toml" + + _write_options( + options_path, + { + "stack_fqdn": "api-roborock.example.com", + "protocol_login_email": "user@example.com", + "protocol_login_pin": "123456", + }, + ) + + with pytest.raises(ValueError, match="admin_password is required"): + write_config_from_home_assistant_options( + options_path=options_path, + config_path=config_path, + ) + + +def test_write_config_from_home_assistant_options_requires_api_prefix(tmp_path: Path) -> None: + options_path = tmp_path / "options.json" + config_path = tmp_path / "config.toml" + + _write_options( + options_path, + { + "stack_fqdn": "lashleyhomeassist.duckdns.org", + "admin_password": "super-secret-password", + "protocol_login_email": "user@example.com", + "protocol_login_pin": "123456", + }, + ) + + with pytest.raises(ValueError, match="stack_fqdn must start with api-"): + write_config_from_home_assistant_options( + options_path=options_path, + config_path=config_path, + ) diff --git a/tests/test_ha_addon_export.py b/tests/test_ha_addon_export.py new file mode 100644 index 0000000..3096742 --- /dev/null +++ b/tests/test_ha_addon_export.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from pathlib import Path + +from roborock_local_server import __version__ + +from scripts.export_home_assistant_dev_addon import export_repository + + +def test_export_repository_writes_local_dev_addon(tmp_path: Path) -> None: + output_dir = export_repository(tmp_path / "ha-addon") + + repository_yaml = output_dir / "repository.yaml" + addon_dir = output_dir / "roborock_local_server_dev" + config_yaml = addon_dir / "config.yaml" + dockerfile = addon_dir / "Dockerfile" + exported_init = addon_dir / "app" / "src" / "roborock_local_server" / "__init__.py" + + assert repository_yaml.exists() + assert config_yaml.exists() + assert dockerfile.exists() + assert exported_init.exists() + + config_text = config_yaml.read_text(encoding="utf-8") + assert f'version: "{__version__}"' in config_text + assert "slug: roborock_local_server_dev" in config_text + assert 'image: "ghcr.io/python-roborock/local_roborock_server"' not in config_text + + init_text = exported_init.read_text(encoding="utf-8") + assert f'__version__ = "{__version__}"' in init_text diff --git a/tests/test_mqtt_tls_proxy.py b/tests/test_mqtt_tls_proxy.py index 0e54cda..ffe1549 100644 --- a/tests/test_mqtt_tls_proxy.py +++ b/tests/test_mqtt_tls_proxy.py @@ -486,6 +486,50 @@ def test_read_first_packet_rejects_invalid_remaining_length() -> None: assert src.recv_calls == 1 +def test_accept_client_connection_returns_raw_socket_when_tls_disabled(tmp_path) -> None: + cloud_snapshot_path = tmp_path / "cloud_snapshot.json" + _seed_cloud_snapshot(cloud_snapshot_path) + proxy = MqttTlsProxy( + cert_file=None, + key_file=None, + listen_host="127.0.0.1", + listen_port=18883, + backend_host="127.0.0.1", + backend_port=1883, + localkey="test-local-key", + logger=logging.getLogger("test.mqtt_tls_proxy"), + decoded_jsonl=tmp_path / "decoded.jsonl", + cloud_snapshot_path=cloud_snapshot_path, + tls_enabled=False, + ) + raw_conn = _FakeSourceSocket() + + accepted = proxy._accept_client_connection(raw_conn=raw_conn, addr=("127.0.0.1", 4321), tls_ctx=None) + + assert accepted is raw_conn + assert raw_conn.closed is False + + +def test_build_tls_context_requires_cert_paths_when_tls_enabled(tmp_path) -> None: + cloud_snapshot_path = tmp_path / "cloud_snapshot.json" + _seed_cloud_snapshot(cloud_snapshot_path) + proxy = MqttTlsProxy( + cert_file=None, + key_file=None, + listen_host="127.0.0.1", + listen_port=8883, + backend_host="127.0.0.1", + backend_port=1883, + localkey="test-local-key", + logger=logging.getLogger("test.mqtt_tls_proxy"), + decoded_jsonl=tmp_path / "decoded.jsonl", + cloud_snapshot_path=cloud_snapshot_path, + ) + + with pytest.raises(RuntimeError, match="requires cert_file and key_file"): + proxy._build_tls_context() + + def test_handle_client_traces_packets_already_buffered_before_relay(tmp_path, monkeypatch) -> None: cloud_snapshot_path = tmp_path / "cloud_snapshot.json" _seed_cloud_snapshot(cloud_snapshot_path) diff --git a/tests/test_plugin_routes.py b/tests/test_plugin_routes.py index e00fd1f..516a5aa 100644 --- a/tests/test_plugin_routes.py +++ b/tests/test_plugin_routes.py @@ -30,7 +30,7 @@ def test_api_v1_plugins_returns_proxied_category_urls(tmp_path: Path) -> None: for item in records: url = str(item["url"]) - assert url.startswith("https://roborock.example.com/plugin/proxy/") + assert url.startswith("https://api-roborock.example.com/plugin/proxy/") parsed = urllib.parse.urlsplit(url) source = urllib.parse.parse_qs(parsed.query).get("src", [""])[0] assert source.startswith("https://") @@ -45,14 +45,14 @@ def test_app_plugin_endpoints_return_proxied_urls(tmp_path: Path) -> None: app_plugin_payload = app_plugin_response.json() assert app_plugin_payload["code"] == 200 for item in app_plugin_payload["data"]: - assert str(item["url"]).startswith("https://roborock.example.com/plugin/proxy/") + assert str(item["url"]).startswith("https://api-roborock.example.com/plugin/proxy/") feature_response = client.get("/api/v1/appfeatureplugin") assert feature_response.status_code == 200 feature_payload = feature_response.json() assert feature_payload["code"] == 200 for item in feature_payload["data"]["plugins"]: - assert str(item["url"]).startswith("https://roborock.example.com/plugin/proxy/") + assert str(item["url"]).startswith("https://api-roborock.example.com/plugin/proxy/") def test_plugin_category_zip_uses_expected_source_and_cache(tmp_path: Path, monkeypatch) -> None: diff --git a/tests/test_routine_runner.py b/tests/test_routine_runner.py index cbe2910..7667a0d 100644 --- a/tests/test_routine_runner.py +++ b/tests/test_routine_runner.py @@ -6,6 +6,7 @@ import pytest from roborock.data import StatusV2 +from roborock.exceptions import RoborockException from roborock_local_server.bundled_backend.shared.context import ServerContext import roborock_local_server.bundled_backend.shared.routine_runner as routine_runner_module from roborock_local_server.bundled_backend.shared.routine_runner import RoutineRunner, parse_scene_steps @@ -262,6 +263,118 @@ async def wait_for_step_complete(self) -> None: asyncio.run(exercise()) +def test_run_scene_retries_step_start_when_device_is_action_locked(tmp_path: Path, monkeypatch) -> None: + async def exercise() -> None: + device_id = "6HL2zfniaoYYV01CkVuhkO" + scene = { + "id": 4491073, + "name": "Night clean", + "device_id": device_id, + "param": json.dumps( + { + "action": { + "items": [ + { + "id": 1, + "type": "CMD", + "name": "Step 1", + "finishDpIds": [130], + "param": json.dumps( + { + "method": "do_scenes_segments", + "params": { + "data": [ + { + "tid": "1755507280460", + "segs": [{"sid": 18}], + "fan_power": 108, + "repeat": 1, + } + ] + }, + }, + separators=(",", ":"), + ), + }, + { + "id": 2, + "type": "CMD", + "name": "Step 2", + "finishDpIds": [130], + "param": json.dumps( + { + "method": "do_scenes_segments", + "params": { + "data": [ + { + "tid": "1755507296636", + "segs": [{"sid": 19}], + "fan_power": 103, + "repeat": 1, + } + ] + }, + }, + separators=(",", ":"), + ), + }, + ] + } + }, + separators=(",", ":"), + ), + } + + sent_commands: list[tuple[RoborockCommand, object]] = [] + settle_calls: list[float] = [] + segment_attempts = 0 + + class FakeRoutineClient: + def __init__(self, context, device, logger) -> None: + _ = context, device, logger + + async def connect(self) -> None: + return None + + async def close(self) -> None: + return None + + async def send_command(self, command, params=None): + nonlocal segment_attempts + sent_commands.append((command, params)) + if command == RoborockCommand.APP_SEGMENT_CLEAN: + segment_attempts += 1 + if segment_attempts == 2: + raise RoborockException({"code": -10003, "message": "action locked"}) + return ["ok"] + + async def wait_for_step_complete(self) -> None: + return None + + async def wait_for_dock_settle(self, *, initial_delay_seconds=15.0) -> None: + settle_calls.append(float(initial_delay_seconds)) + + monkeypatch.setattr(routine_runner_module, "_RoutineMqttClient", FakeRoutineClient) + + runner = RoutineRunner(_test_context(tmp_path)) + await runner._run_scene(scene=scene, steps=parse_scene_steps(scene)) + + assert sent_commands == [ + ( + RoborockCommand.REUNION_SCENES, + {"data": [{"tid": "1755507280460"}, {"tid": "1755507296636"}]}, + ), + (RoborockCommand.SET_CUSTOM_MODE, [108]), + (RoborockCommand.APP_SEGMENT_CLEAN, [{"segments": [18], "repeat": 1}]), + (RoborockCommand.SET_CUSTOM_MODE, [103]), + (RoborockCommand.APP_SEGMENT_CLEAN, [{"segments": [19], "repeat": 1}]), + (RoborockCommand.APP_SEGMENT_CLEAN, [{"segments": [19], "repeat": 1}]), + ] + assert settle_calls == [15.0, 5.0] + + asyncio.run(exercise()) + + # --------------------------------------------------------------------------- # wait_for_step_complete tests # --------------------------------------------------------------------------- @@ -290,10 +403,18 @@ async def send_command(self, command: RoborockCommand, params=None) -> None: _ScriptedStatusClient.wait_for_step_complete = ( routine_runner_module._RoutineMqttClient.wait_for_step_complete ) +_ScriptedStatusClient.wait_for_dock_settle = ( + routine_runner_module._RoutineMqttClient.wait_for_dock_settle +) + + +def _set_fast_wait_constants(monkeypatch, *, confirm_seconds: float = 0.0) -> None: + monkeypatch.setattr(routine_runner_module, "_STEP_COMPLETE_CONFIRM_SECONDS", confirm_seconds) def test_wait_for_step_complete_dock_activity_does_not_end_step(monkeypatch) -> None: """Dock activity (emptying bin) followed by ready must not declare step complete.""" + _set_fast_wait_constants(monkeypatch) monkeypatch.setattr(routine_runner_module, "_STEP_START_TIMEOUT_SECONDS", 0.1) monkeypatch.setattr(routine_runner_module, "_STEP_START_POLL_INTERVAL_SECONDS", 0.0) monkeypatch.setattr(routine_runner_module, "_STATUS_POLL_INTERVAL_SECONDS", 0.0) @@ -317,6 +438,7 @@ async def exercise() -> None: def test_wait_for_step_complete_actual_cleaning_completes(monkeypatch) -> None: """Step completes when in_cleaning becomes non-zero then robot returns to ready.""" + _set_fast_wait_constants(monkeypatch) monkeypatch.setattr(routine_runner_module, "_STEP_START_POLL_INTERVAL_SECONDS", 0.0) monkeypatch.setattr(routine_runner_module, "_STATUS_POLL_INTERVAL_SECONDS", 0.0) @@ -334,6 +456,7 @@ async def exercise() -> None: def test_wait_for_step_complete_dock_then_cleaning_completes(monkeypatch) -> None: """Dock activity followed by actual cleaning should complete after cleaning finishes.""" + _set_fast_wait_constants(monkeypatch) monkeypatch.setattr(routine_runner_module, "_STEP_START_POLL_INTERVAL_SECONDS", 0.0) monkeypatch.setattr(routine_runner_module, "_STATUS_POLL_INTERVAL_SECONDS", 0.0) @@ -354,6 +477,7 @@ async def exercise() -> None: def test_wait_for_step_complete_start_timeout(monkeypatch) -> None: """Raises RoutineExecutionError when robot stays in ready state past start deadline.""" + _set_fast_wait_constants(monkeypatch) monkeypatch.setattr(routine_runner_module, "_STEP_START_TIMEOUT_SECONDS", 0.1) monkeypatch.setattr(routine_runner_module, "_STEP_START_POLL_INTERVAL_SECONDS", 0.0) @@ -374,6 +498,7 @@ async def exercise() -> None: def test_wait_for_step_complete_resume_after_mid_clean_charge(monkeypatch) -> None: """Robot returns to dock mid-clean with low battery, charges, gets resumed, completes.""" + _set_fast_wait_constants(monkeypatch) monkeypatch.setattr(routine_runner_module, "_STEP_START_POLL_INTERVAL_SECONDS", 0.0) monkeypatch.setattr(routine_runner_module, "_STATUS_POLL_INTERVAL_SECONDS", 0.0) monkeypatch.setattr(routine_runner_module, "_RESUME_BATTERY_THRESHOLD", 80) @@ -400,6 +525,7 @@ async def exercise() -> None: def test_wait_for_step_complete_resume_zoned_clean(monkeypatch) -> None: """Resume uses correct command for zone cleaning.""" + _set_fast_wait_constants(monkeypatch) monkeypatch.setattr(routine_runner_module, "_STEP_START_POLL_INTERVAL_SECONDS", 0.0) monkeypatch.setattr(routine_runner_module, "_STATUS_POLL_INTERVAL_SECONDS", 0.0) monkeypatch.setattr(routine_runner_module, "_RESUME_BATTERY_THRESHOLD", 80) @@ -420,6 +546,7 @@ async def exercise() -> None: def test_wait_for_step_complete_no_resume_when_battery_low(monkeypatch) -> None: """No resume sent while battery is below threshold.""" + _set_fast_wait_constants(monkeypatch) monkeypatch.setattr(routine_runner_module, "_STEP_START_POLL_INTERVAL_SECONDS", 0.0) monkeypatch.setattr(routine_runner_module, "_STATUS_POLL_INTERVAL_SECONDS", 0.0) monkeypatch.setattr(routine_runner_module, "_STEP_COMPLETE_TIMEOUT_SECONDS", 0.1) @@ -441,6 +568,7 @@ async def exercise() -> None: def test_wait_for_step_complete_resume_only_sent_once(monkeypatch) -> None: """Resume command is only sent once even if robot returns to dock again.""" + _set_fast_wait_constants(monkeypatch) monkeypatch.setattr(routine_runner_module, "_STEP_START_POLL_INTERVAL_SECONDS", 0.0) monkeypatch.setattr(routine_runner_module, "_STATUS_POLL_INTERVAL_SECONDS", 0.0) monkeypatch.setattr(routine_runner_module, "_RESUME_BATTERY_THRESHOLD", 80) @@ -458,3 +586,41 @@ async def exercise() -> None: assert len(client.sent_commands) == 2 asyncio.run(exercise()) + + +def test_wait_for_step_complete_requires_stable_non_cleaning_state(monkeypatch) -> None: + """A brief non-cleaning status must not end the step if cleaning resumes.""" + monkeypatch.setattr(routine_runner_module, "_STEP_COMPLETE_CONFIRM_SECONDS", 0.02) + monkeypatch.setattr(routine_runner_module, "_STEP_START_POLL_INTERVAL_SECONDS", 0.0) + monkeypatch.setattr(routine_runner_module, "_STATUS_POLL_INTERVAL_SECONDS", 0.01) + + async def exercise() -> None: + client = _ScriptedStatusClient([ + {"state": 18, "in_cleaning": 3}, + {"state": 8, "in_cleaning": 0}, # transient non-cleaning state + {"state": 18, "in_cleaning": 3}, # cleaning resumes, so step is not done + {"state": 18, "in_cleaning": 3}, + {"state": 8, "in_cleaning": 0}, + {"state": 8, "in_cleaning": 0}, + {"state": 8, "in_cleaning": 0}, + ]) + await client.wait_for_step_complete() + + asyncio.run(exercise()) + + +def test_wait_for_dock_settle_timeout_raises(monkeypatch) -> None: + _set_fast_wait_constants(monkeypatch) + monkeypatch.setattr(routine_runner_module, "_POST_STEP_SETTLE_TIMEOUT_SECONDS", 0.05) + monkeypatch.setattr(routine_runner_module, "_STATUS_POLL_INTERVAL_SECONDS", 0.0) + + async def exercise() -> None: + client = _ScriptedStatusClient([ + {"state": 18, "in_cleaning": 3}, + {"state": 18, "in_cleaning": 3}, + {"state": 18, "in_cleaning": 3}, + ]) + with pytest.raises(routine_runner_module.RoutineExecutionError, match="dock activity"): + await client.wait_for_dock_settle(initial_delay_seconds=0.0) + + asyncio.run(exercise()) diff --git a/tests/test_supervisor.py b/tests/test_supervisor.py index dbeef7a..d77ecdc 100644 --- a/tests/test_supervisor.py +++ b/tests/test_supervisor.py @@ -55,3 +55,43 @@ def fake_start_mqtt_proxy(self: ReleaseSupervisor) -> None: assert service_map["https_server"]["running"] is False assert service_map["mqtt_tls_proxy"]["running"] is False assert service_map["mqtt_backend_broker"]["running"] is False + + +def test_release_supervisor_external_tls_skips_certificate_setup(tmp_path: Path, monkeypatch) -> None: + config_file = write_release_config( + tmp_path, + listener_mode="external_tls", + listen_https_port=8080, + listen_mqtt_port=18883, + broker_mode="external", + enable_topic_bridge=False, + ) + config = load_config(config_file) + paths = resolve_paths(config_file, config) + supervisor = ReleaseSupervisor(config=config, paths=paths) + + monkeypatch.setattr(server_module, "_connectivity_check", lambda host, port: None) + monkeypatch.setattr( + supervisor.certificate_manager, + "ensure_certificate", + lambda: (_ for _ in ()).throw(AssertionError("ensure_certificate should not run in external_tls mode")), + ) + + async def fake_start_http_server(self: ReleaseSupervisor) -> None: + self._http_server = _DummyHttpServer() # type: ignore[assignment] + self.runtime_state.set_service("https_server", running=True, required=True, enabled=True) + + def fake_start_mqtt_proxy(self: ReleaseSupervisor) -> None: + self._mqtt_proxy = _DummyProxy() # type: ignore[assignment] + self.runtime_state.set_service("mqtt_tls_proxy", running=True, required=True, enabled=True) + + monkeypatch.setattr(ReleaseSupervisor, "_start_http_server", fake_start_http_server) + monkeypatch.setattr(ReleaseSupervisor, "_start_mqtt_proxy", fake_start_mqtt_proxy) + + asyncio.run(supervisor.start()) + health = supervisor.runtime_state.health_snapshot() + service_map = {service["name"]: service for service in health["services"]} + assert service_map["https_server"]["detail"] == "external_tls:0.0.0.0:8080" + assert service_map["mqtt_tls_proxy"]["detail"] == "external_tls:0.0.0.0:18883" + + asyncio.run(supervisor.stop()) diff --git a/tests/test_version_sync.py b/tests/test_version_sync.py index 2c44c2f..48dcc41 100644 --- a/tests/test_version_sync.py +++ b/tests/test_version_sync.py @@ -14,3 +14,10 @@ def test_init_module_exports_single_version_literal() -> None: init_text = Path("src/roborock_local_server/__init__.py").read_text(encoding="utf-8") matches = re.findall(r'__version__\s*=\s*"([^"]+)"', init_text) assert matches == [__version__] + + +def test_home_assistant_addon_version_matches_package_version() -> None: + addon_config = Path("roborock_local_server_addon/config.yaml").read_text(encoding="utf-8") + match = re.search(r'^version:\s*"([^"]+)"\s*$', addon_config, re.MULTILINE) + assert match is not None + assert match.group(1) == __version__ From c1bbe1faf0f057029a27644a27802497845ee2f9 Mon Sep 17 00:00:00 2001 From: Luke Date: Fri, 1 May 2026 18:23:31 -0400 Subject: [PATCH 3/7] fix auth --- .../mqtt_tls_proxy_server/server.py | 157 +++++++++++- .../shared/runtime_credentials.py | 37 +++ .../bundled_backend/shared/runtime_state.py | 30 +++ tests/test_mqtt_tls_proxy.py | 241 ++++++++++++++++++ tests/test_runtime_state.py | 69 +++++ 5 files changed, 523 insertions(+), 11 deletions(-) diff --git a/src/roborock_local_server/bundled_backend/mqtt_tls_proxy_server/server.py b/src/roborock_local_server/bundled_backend/mqtt_tls_proxy_server/server.py index e4a10b4..d8a5d39 100644 --- a/src/roborock_local_server/bundled_backend/mqtt_tls_proxy_server/server.py +++ b/src/roborock_local_server/bundled_backend/mqtt_tls_proxy_server/server.py @@ -65,7 +65,10 @@ def __init__( self._running = False self._counter = 0 self._lock = threading.Lock() - self._conn_protocol_levels: dict[str, int] = {} self._trace_queue: queue.Queue[tuple[str, str, bytes] | None] = queue.Queue() + self._conn_protocol_levels: dict[str, int] = {} + self._conn_endpoints: dict[str, tuple[socket.socket, socket.socket]] = {} + self._pending_onboarding_auth: dict[str, dict[str, str]] = {} + self._trace_queue: queue.Queue[tuple[str, str, bytes] | None] = queue.Queue() self._trace_thread: threading.Thread | None = None self._protocol_auth = ( ProtocolAuthStore( @@ -84,6 +87,38 @@ def _next_conn(self) -> str: self._counter += 1 return str(self._counter) + def _register_conn_endpoints(self, conn_id: str, client_conn: socket.socket, backend_conn: socket.socket) -> None: + with self._lock: + self._conn_endpoints[conn_id] = (client_conn, backend_conn) + + def _pop_conn_endpoints(self, conn_id: str) -> tuple[socket.socket, socket.socket] | None: + with self._lock: + return self._conn_endpoints.pop(conn_id, None) + + def _close_conn_endpoints(self, conn_id: str) -> None: + endpoints = self._pop_conn_endpoints(conn_id) + if endpoints is None: + return + for endpoint in endpoints: + try: + endpoint.close() + except OSError: + pass + + def _set_pending_onboarding_auth(self, conn_id: str, candidate: dict[str, str]) -> None: + with self._lock: + self._pending_onboarding_auth[conn_id] = dict(candidate) + + def _get_pending_onboarding_auth(self, conn_id: str) -> dict[str, str] | None: + with self._lock: + candidate = self._pending_onboarding_auth.get(conn_id) + return dict(candidate) if candidate is not None else None + + def _pop_pending_onboarding_auth(self, conn_id: str) -> dict[str, str] | None: + with self._lock: + candidate = self._pending_onboarding_auth.pop(conn_id, None) + return dict(candidate) if candidate is not None else None + @staticmethod def _decode_remaining_length(data: bytes, start: int) -> tuple[int | None, int]: multiplier = 1 @@ -188,28 +223,37 @@ def _expected_bootstrap_credentials(self) -> tuple[str, str, str] | None: return username, password, client_id def _authorize_connect_packet(self, packet: bytes) -> tuple[bool, str, dict[str, Any] | None]: + authorized, reason, info, _candidate = self._authorize_connect_packet_for_client(packet, client_ip="") + return authorized, reason, info + + def _authorize_connect_packet_for_client( + self, + packet: bytes, + *, + client_ip: str, + ) -> tuple[bool, str, dict[str, Any] | None, dict[str, str] | None]: info = parse_mqtt_connect_packet(packet) if info is None: - return False, "invalid_connect_packet", None + return False, "invalid_connect_packet", None, None username = str(info.get("username") or "").strip() password = str(info.get("password") or "").strip() client_id = str(info.get("client_id") or "").strip() if not username or not password: - return False, "missing_mqtt_credentials", info + return False, "missing_mqtt_credentials", info, None if self._protocol_auth is not None and self._protocol_auth_enabled(): authorized, auth_reason, _matched_user = self._protocol_auth.verify_user_mqtt_credentials(username, password) if authorized: - return True, auth_reason, info + return True, auth_reason, info, None bootstrap_credentials = self._expected_bootstrap_credentials() if bootstrap_credentials is not None: expected_username, expected_password, expected_client_id = bootstrap_credentials if username == expected_username and password == expected_password: if expected_client_id and client_id and client_id != expected_client_id: - return False, "invalid_bootstrap_client_id", info - return True, "bootstrap", info + return False, "invalid_bootstrap_client_id", info, None + return True, "bootstrap", info, None if self.runtime_credentials is not None: authorized, auth_reason, _matched_device = self.runtime_credentials.verify_device_mqtt_credentials( @@ -217,16 +261,96 @@ def _authorize_connect_packet(self, packet: bytes) -> tuple[bool, str, dict[str, password=password, ) if authorized: - return True, auth_reason, info + return True, auth_reason, info, None if auth_reason == "device_mqtt_password_missing": recovered_device = self.runtime_credentials.recover_device_mqtt_password( username=username, password=password, ) if recovered_device is not None: - return True, "device_mqtt_recovered", info + return True, "device_mqtt_recovered", info, None + if auth_reason == "unknown_device_mqtt_username": + candidate = self._resolve_onboarding_device_mqtt_candidate( + client_ip=client_ip, + username=username, + password=password, + ) + if candidate is not None: + return True, "device_mqtt_onboarding_pending", info, candidate + + return False, "invalid_mqtt_credentials", info, None + + def _resolve_onboarding_device_mqtt_candidate( + self, + *, + client_ip: str, + username: str, + password: str, + ) -> dict[str, str] | None: + if self.runtime_state is None or self.runtime_credentials is None: + return None + candidate = self.runtime_state.onboarding_device_mqtt_candidate(client_ip=client_ip) + if candidate is None: + return None + device = self.runtime_credentials.resolve_device( + did=str(candidate.get("did") or ""), + duid=str(candidate.get("duid") or ""), + ) + if device is None: + return None + existing_username = str(device.get("device_mqtt_usr") or "").strip() + if existing_username: + return None + return { + "did": str(device.get("did") or "").strip(), + "duid": str(device.get("duid") or "").strip(), + "name": str(device.get("name") or candidate.get("name") or "").strip(), + "username": username.strip(), + "password": password.strip(), + "client_ip": client_ip.strip(), + } - return False, "invalid_mqtt_credentials", info + def _confirm_pending_onboarding_auth(self, conn_id: str, *, direction: str, topic: str) -> bool: + if direction != "c2b" or self.runtime_credentials is None: + return True + candidate = self._get_pending_onboarding_auth(conn_id) + if candidate is None: + return True + expected_topic = f"rr/d/i/{candidate['did']}/{candidate['username']}" + if topic != expected_topic: + self.logger.warning( + "[conn %s] rejected provisional onboarding MQTT session expected_topic=%s got=%s", + conn_id, + expected_topic, + topic, + ) + self._pop_pending_onboarding_auth(conn_id) + self._close_conn_endpoints(conn_id) + return False + learned = self.runtime_credentials.confirm_device_mqtt_credentials( + did=candidate.get("did", ""), + duid=candidate.get("duid", ""), + username=candidate["username"], + password=candidate["password"], + ) + self._pop_pending_onboarding_auth(conn_id) + if learned is None: + self.logger.warning( + "[conn %s] failed to persist confirmed onboarding MQTT credentials did=%s duid=%s", + conn_id, + candidate.get("did", ""), + candidate.get("duid", ""), + ) + self._close_conn_endpoints(conn_id) + return False + self.logger.info( + "[conn %s] learned onboarding MQTT credentials did=%s duid=%s username=%s", + conn_id, + learned.get("did", ""), + learned.get("duid", ""), + candidate["username"], + ) + return True @classmethod def _extract_publish(cls, packet: bytes, protocol_level: int | None = None) -> tuple[str | None, bytes | None]: @@ -408,7 +532,10 @@ def _trace_packet(self, conn_id: str, direction: str, packet: bytes) -> None: topic, payload = self._extract_publish(packet, self._get_conn_protocol_level(conn_id)) if topic is None or payload is None: - return if self.runtime_state is not None: + return + if not self._confirm_pending_onboarding_auth(conn_id, direction=direction, topic=topic): + return + if self.runtime_state is not None: self.runtime_state.record_mqtt_message( conn_id=conn_id, direction=direction, @@ -609,7 +736,10 @@ def _handle_client(self, tls_conn: socket.socket | ssl.SSLSocket, addr: tuple[st self.logger.warning("[conn %s] client closed before MQTT CONNECT", conn_id) return connect_packet, initial_remainder = first_packet - authorized, auth_reason, connect_info = self._authorize_connect_packet(connect_packet) + authorized, auth_reason, connect_info, onboarding_candidate = self._authorize_connect_packet_for_client( + connect_packet, + client_ip=addr[0], + ) if connect_info is not None: protocol_level = connect_info.get("protocol_level") if isinstance(protocol_level, int): @@ -637,6 +767,9 @@ def _handle_client(self, tls_conn: socket.socket | ssl.SSLSocket, addr: tuple[st backend = socket.socket(socket.AF_INET, socket.SOCK_STREAM) backend.connect((self.backend_host, self.backend_port)) + self._register_conn_endpoints(conn_id, tls_conn, backend) + if onboarding_candidate is not None: + self._set_pending_onboarding_auth(conn_id, onboarding_candidate) c2b_frame_buf = bytearray(initial_remainder) for packet in self._extract_packets(c2b_frame_buf): self._queue_trace_packet(conn_id, "c2b", packet) @@ -655,6 +788,8 @@ def _handle_client(self, tls_conn: socket.socket | ssl.SSLSocket, addr: tuple[st except Exception as exc: self.logger.error("[conn %s] connection error: %s", conn_id, exc) finally: + self._pop_pending_onboarding_auth(conn_id) + self._pop_conn_endpoints(conn_id) if not relay_started: for endpoint in (tls_conn, backend): if endpoint is None: diff --git a/src/roborock_local_server/bundled_backend/shared/runtime_credentials.py b/src/roborock_local_server/bundled_backend/shared/runtime_credentials.py index 6850f92..0977e49 100644 --- a/src/roborock_local_server/bundled_backend/shared/runtime_credentials.py +++ b/src/roborock_local_server/bundled_backend/shared/runtime_credentials.py @@ -653,6 +653,43 @@ def recover_device_mqtt_password(self, *, username: str, password: str) -> dict[ return dict(device) return None + def confirm_device_mqtt_credentials( + self, + *, + did: str = "", + duid: str = "", + username: str, + password: str, + ) -> dict[str, str] | None: + normalized_did = _clean_str(did) + normalized_duid = _clean_str(duid) + normalized_username = _clean_str(username) + normalized_password = _clean_str(password) + if not normalized_username or not normalized_password: + return None + with self._lock: + index = self._find_index_locked(did=normalized_did, duid=normalized_duid, model="") + if index is None: + return None + device = self._devices[index] + existing_username = _clean_str(device.get("device_mqtt_usr")) + existing_password = _clean_str(device.get("device_mqtt_pass")) + if existing_username and existing_username != normalized_username: + return None + if existing_password and existing_password != normalized_password: + return None + changed = False + if existing_username != normalized_username: + device["device_mqtt_usr"] = normalized_username + changed = True + if existing_password != normalized_password: + device["device_mqtt_pass"] = normalized_password + changed = True + if changed: + device["updated_at"] = utcnow_iso() + self._save_locked() + return dict(device) + def recovery_pending_devices(self) -> list[dict[str, str]]: with self._lock: pending = [ diff --git a/src/roborock_local_server/bundled_backend/shared/runtime_state.py b/src/roborock_local_server/bundled_backend/shared/runtime_state.py index 60c5bd1..f50f83a 100644 --- a/src/roborock_local_server/bundled_backend/shared/runtime_state.py +++ b/src/roborock_local_server/bundled_backend/shared/runtime_state.py @@ -399,6 +399,36 @@ def onboarding_session_snapshot(self) -> dict[str, Any]: def pairing_snapshot(self) -> dict[str, Any]: return self.onboarding_session_snapshot() + def onboarding_device_mqtt_candidate(self, *, client_ip: str) -> dict[str, str] | None: + normalized_ip = client_ip.strip() + if not normalized_ip: + return None + with self._lock: + session = self._pairing_session + if session is None or not session.get("active"): + return None + self._refresh_pairing_session_locked(session) + if str(session.get("identity_conflict") or "").strip(): + return None + if not str(session.get("region_at") or "").strip() or not str(session.get("nc_at") or "").strip(): + return None + target_ip = str(session.get("target_ip") or "").strip() + if not target_ip or target_ip != normalized_ip: + return None + target_did = str(session.get("target_did") or "").strip() + target_duid = str(session.get("target_duid") or "").strip() + if not target_did: + return None + key_state = self._session_key_state_locked(target_did, target_duid) + if not bool(key_state.get("has_modulus")): + return None + return { + "did": target_did, + "duid": target_duid, + "name": str(session.get("selected_name") or "").strip(), + "target_ip": target_ip, + } + def recent_events(self, *, limit: int = 200) -> list[dict[str, Any]]: with self._lock: if limit <= 0: diff --git a/tests/test_mqtt_tls_proxy.py b/tests/test_mqtt_tls_proxy.py index ffe1549..5c966c1 100644 --- a/tests/test_mqtt_tls_proxy.py +++ b/tests/test_mqtt_tls_proxy.py @@ -3,11 +3,14 @@ import socket import threading import time +from datetime import datetime, timezone from pathlib import Path import pytest from roborock_local_server.backend import MqttTlsProxy +from roborock_local_server.bundled_backend.shared.runtime_credentials import RuntimeCredentialsStore +from roborock_local_server.bundled_backend.shared.runtime_state import RuntimeState class _FakeSourceSocket: @@ -110,6 +113,19 @@ def _seed_protocol_sessions(path: Path) -> None: ) +def _seed_key_state(path: Path, *, did: str) -> None: + _write_json( + path, + { + "devices": { + did: { + "modulus_hex": "ab", + } + } + }, + ) + + def _build_connect_packet(*, client_id: str, username: str, password: str, protocol_level: int = 4) -> bytes: protocol_name = b"MQTT" variable_header = ( @@ -132,6 +148,12 @@ def _build_connect_packet(*, client_id: str, username: str, password: str, proto return bytes([0x10, len(remaining)]) + remaining +def _build_publish_packet(*, topic: str, payload: bytes = b"{}") -> bytes: + topic_bytes = topic.encode() + remaining = len(topic_bytes).to_bytes(2, "big") + topic_bytes + payload + return bytes([0x30, len(remaining)]) + remaining + + def test_relay_forwards_chunk_before_slow_packet_tracing_finishes(tmp_path, monkeypatch) -> None: cloud_snapshot_path = tmp_path / "cloud_snapshot.json" _seed_cloud_snapshot(cloud_snapshot_path) @@ -412,6 +434,225 @@ def test_authorize_connect_recovers_missing_known_device_mqtt_password(tmp_path) assert reject_reason == "invalid_mqtt_credentials" +def test_authorize_connect_accepts_unknown_device_credentials_only_for_matching_onboarding_session(tmp_path) -> None: + cloud_snapshot_path = tmp_path / "cloud_snapshot.json" + _seed_cloud_snapshot(cloud_snapshot_path) + key_state_path = tmp_path / "device_key_state.json" + _seed_key_state(key_state_path, did="1103821560705") + runtime_credentials_path = tmp_path / "runtime_credentials.json" + _write_json( + runtime_credentials_path, + { + "schema_version": 2, + "mqtt_usr": "bootstrap-user", + "mqtt_passwd": "bootstrap-pass", + "mqtt_clientid": "bootstrap-client", + "devices": [ + { + "did": "1103821560705", + "duid": "6HL2zfniaoYYV01CkVuhkO", + "name": "Roborock Qrevo MaxV 2", + "model": "roborock.vacuum.a87", + "product_id": "5gUei3OIJIXVD3eD85Balg", + "localkey": "xPd5Dr8CGGqtdDlH", + "local_key_source": "inventory", + "device_mqtt_usr": "", + "device_mqtt_pass": "", + "updated_at": "2026-04-17T17:00:00+00:00", + "last_nc_at": "", + "last_mqtt_seen_at": "", + } + ], + }, + ) + runtime_credentials = RuntimeCredentialsStore(runtime_credentials_path) + runtime_state = RuntimeState(log_dir=tmp_path, key_state_file=key_state_path, runtime_credentials=runtime_credentials) + runtime_state.upsert_vacuum("6HL2zfniaoYYV01CkVuhkO", name="Roborock Qrevo MaxV 2", id_kind="duid") + runtime_state.start_onboarding_session(target_duid="6HL2zfniaoYYV01CkVuhkO", target_name="Roborock Qrevo MaxV 2") + event_time = datetime.now(timezone.utc).isoformat() + for route_name, path_name in (("region", "/region"), ("nc_prepare", "/nc")): + runtime_state.record_http_event( + event_time=event_time, + route_name=route_name, + clean_path=path_name, + raw_path=path_name, + method="GET", + host="api-roborock.example.com", + remote="192.168.8.10:54321", + did="1103821560705", + ) + proxy = MqttTlsProxy( + cert_file=tmp_path / "fullchain.pem", + key_file=tmp_path / "privkey.pem", + listen_host="127.0.0.1", + listen_port=8883, + backend_host="127.0.0.1", + backend_port=1883, + localkey="test-local-key", + logger=logging.getLogger("test.mqtt_tls_proxy"), + decoded_jsonl=tmp_path / "decoded.jsonl", + cloud_snapshot_path=cloud_snapshot_path, + runtime_state=runtime_state, + runtime_credentials=runtime_credentials, + ) + + packet = _build_connect_packet( + client_id="a012391cb5f8bc97", + username="c25b14ceac358d2a", + password="ff8922d24a9a9af81f18f35dcee9a5a5", + ) + authorized, reason, info, candidate = proxy._authorize_connect_packet_for_client( + packet, + client_ip="192.168.8.10", + ) + + assert authorized is True + assert reason == "device_mqtt_onboarding_pending" + assert info is not None + assert candidate is not None + assert candidate["did"] == "1103821560705" + persisted = runtime_credentials.resolve_device(did="1103821560705") + assert persisted is not None + assert persisted["device_mqtt_usr"] == "" + assert persisted["device_mqtt_pass"] == "" + + rejected, reject_reason, _info, rejected_candidate = proxy._authorize_connect_packet_for_client( + packet, + client_ip="192.168.8.11", + ) + assert rejected is False + assert reject_reason == "invalid_mqtt_credentials" + assert rejected_candidate is None + + +def test_trace_packet_persists_confirmed_onboarding_device_mqtt_credentials(tmp_path) -> None: + cloud_snapshot_path = tmp_path / "cloud_snapshot.json" + _seed_cloud_snapshot(cloud_snapshot_path) + runtime_credentials_path = tmp_path / "runtime_credentials.json" + _write_json( + runtime_credentials_path, + { + "schema_version": 2, + "devices": [ + { + "did": "1103821560705", + "duid": "6HL2zfniaoYYV01CkVuhkO", + "name": "Roborock Qrevo MaxV 2", + "model": "roborock.vacuum.a87", + "product_id": "5gUei3OIJIXVD3eD85Balg", + "localkey": "xPd5Dr8CGGqtdDlH", + "local_key_source": "inventory", + "device_mqtt_usr": "", + "device_mqtt_pass": "", + "updated_at": "2026-04-17T17:00:00+00:00", + "last_nc_at": "", + "last_mqtt_seen_at": "", + } + ], + }, + ) + runtime_credentials = RuntimeCredentialsStore(runtime_credentials_path) + proxy = MqttTlsProxy( + cert_file=tmp_path / "fullchain.pem", + key_file=tmp_path / "privkey.pem", + listen_host="127.0.0.1", + listen_port=8883, + backend_host="127.0.0.1", + backend_port=1883, + localkey="test-local-key", + logger=logging.getLogger("test.mqtt_tls_proxy"), + decoded_jsonl=tmp_path / "decoded.jsonl", + cloud_snapshot_path=cloud_snapshot_path, + runtime_credentials=runtime_credentials, + ) + proxy._set_pending_onboarding_auth( + "1", + { + "did": "1103821560705", + "duid": "6HL2zfniaoYYV01CkVuhkO", + "name": "Roborock Qrevo MaxV 2", + "username": "c25b14ceac358d2a", + "password": "ff8922d24a9a9af81f18f35dcee9a5a5", + "client_ip": "192.168.8.10", + }, + ) + proxy._register_conn_endpoints("1", _FakeSourceSocket(), _FakeBackendSocket()) + + proxy._trace_packet("1", "c2b", _build_publish_packet(topic="rr/d/i/1103821560705/c25b14ceac358d2a")) + + persisted = runtime_credentials.resolve_device(did="1103821560705") + assert persisted is not None + assert persisted["device_mqtt_usr"] == "c25b14ceac358d2a" + assert persisted["device_mqtt_pass"] == "ff8922d24a9a9af81f18f35dcee9a5a5" + assert proxy._get_pending_onboarding_auth("1") is None + + +def test_trace_packet_closes_provisional_onboarding_session_when_first_publish_topic_mismatches(tmp_path) -> None: + cloud_snapshot_path = tmp_path / "cloud_snapshot.json" + _seed_cloud_snapshot(cloud_snapshot_path) + runtime_credentials_path = tmp_path / "runtime_credentials.json" + _write_json( + runtime_credentials_path, + { + "schema_version": 2, + "devices": [ + { + "did": "1103821560705", + "duid": "6HL2zfniaoYYV01CkVuhkO", + "name": "Roborock Qrevo MaxV 2", + "model": "roborock.vacuum.a87", + "product_id": "5gUei3OIJIXVD3eD85Balg", + "localkey": "xPd5Dr8CGGqtdDlH", + "local_key_source": "inventory", + "device_mqtt_usr": "", + "device_mqtt_pass": "", + "updated_at": "2026-04-17T17:00:00+00:00", + "last_nc_at": "", + "last_mqtt_seen_at": "", + } + ], + }, + ) + runtime_credentials = RuntimeCredentialsStore(runtime_credentials_path) + proxy = MqttTlsProxy( + cert_file=tmp_path / "fullchain.pem", + key_file=tmp_path / "privkey.pem", + listen_host="127.0.0.1", + listen_port=8883, + backend_host="127.0.0.1", + backend_port=1883, + localkey="test-local-key", + logger=logging.getLogger("test.mqtt_tls_proxy"), + decoded_jsonl=tmp_path / "decoded.jsonl", + cloud_snapshot_path=cloud_snapshot_path, + runtime_credentials=runtime_credentials, + ) + client_sock = _FakeSourceSocket() + backend_sock = _FakeBackendSocket() + proxy._set_pending_onboarding_auth( + "1", + { + "did": "1103821560705", + "duid": "6HL2zfniaoYYV01CkVuhkO", + "name": "Roborock Qrevo MaxV 2", + "username": "c25b14ceac358d2a", + "password": "ff8922d24a9a9af81f18f35dcee9a5a5", + "client_ip": "192.168.8.10", + }, + ) + proxy._register_conn_endpoints("1", client_sock, backend_sock) + + proxy._trace_packet("1", "c2b", _build_publish_packet(topic="rr/d/i/9999999999999/c25b14ceac358d2a")) + + persisted = runtime_credentials.resolve_device(did="1103821560705") + assert persisted is not None + assert persisted["device_mqtt_usr"] == "" + assert persisted["device_mqtt_pass"] == "" + assert client_sock.closed is True + assert backend_sock.closed is True + assert proxy._get_pending_onboarding_auth("1") is None + + def test_authorize_connect_accepts_persisted_synced_user_hash_credentials(tmp_path) -> None: cloud_snapshot_path = tmp_path / "cloud_snapshot.json" _seed_cloud_snapshot(cloud_snapshot_path) diff --git a/tests/test_runtime_state.py b/tests/test_runtime_state.py index 9640990..20afe86 100644 --- a/tests/test_runtime_state.py +++ b/tests/test_runtime_state.py @@ -132,3 +132,72 @@ def test_runtime_state_onboarding_session_reports_identity_conflict(tmp_path: Pa assert linked["duid"] == "cloud-q7-b" assert "already linked to DUID cloud-q7-b" in snapshot["identity_conflict"] assert snapshot["status"] == "conflict" + + +def test_runtime_state_onboarding_device_mqtt_candidate_requires_matching_ip_and_public_key(tmp_path: Path) -> None: + credentials_path = tmp_path / "runtime_credentials.json" + credentials_path.write_text( + json.dumps( + { + "schema_version": 2, + "devices": [ + { + "did": "", + "duid": "cloud-q7-a", + "name": "Q7 Upstairs", + "model": "roborock.vacuum.sc05", + "product_id": "product-q7-a", + "localkey": "local-key-a", + } + ], + } + ) + + "\n", + encoding="utf-8", + ) + key_state_path = tmp_path / "device_key_state.json" + key_state_path.write_text( + json.dumps( + { + "devices": { + "1103821560705": { + "modulus_hex": "ab", + } + } + } + ) + + "\n", + encoding="utf-8", + ) + credentials = RuntimeCredentialsStore(credentials_path) + state = RuntimeState(log_dir=tmp_path, key_state_file=key_state_path, runtime_credentials=credentials) + state.upsert_vacuum("cloud-q7-a", name="Q7 Upstairs", id_kind="duid") + state.start_onboarding_session(target_duid="cloud-q7-a", target_name="Q7 Upstairs") + + event_time = datetime.now(timezone.utc).isoformat() + state.record_http_event( + event_time=event_time, + route_name="region", + clean_path="/region", + raw_path="/region", + method="GET", + host="api-roborock.example.com", + remote="192.168.8.10:54321", + did="1103821560705", + ) + state.record_http_event( + event_time=event_time, + route_name="nc_prepare", + clean_path="/nc", + raw_path="/nc", + method="GET", + host="api-roborock.example.com", + remote="192.168.8.10:54321", + did="1103821560705", + ) + + candidate = state.onboarding_device_mqtt_candidate(client_ip="192.168.8.10") + assert candidate is not None + assert candidate["did"] == "1103821560705" + assert candidate["duid"] == "cloud-q7-a" + assert state.onboarding_device_mqtt_candidate(client_ip="192.168.8.11") is None From 03681199ed260f64080609904ca41297e0427952 Mon Sep 17 00:00:00 2001 From: Luke Date: Sun, 3 May 2026 10:06:12 -0400 Subject: [PATCH 4/7] improve app flow --- config.example.toml | 7 +- docs/home_assistant.md | 5 +- pyproject.toml | 2 +- roborock_local_server_addon/DOCS.md | 16 +- roborock_local_server_addon/config.yaml | 20 +-- scripts/export_home_assistant_dev_addon.py | 57 +++---- src/roborock_local_server/__init__.py | 2 +- src/roborock_local_server/config.py | 42 +---- src/roborock_local_server/configure.py | 28 +--- src/roborock_local_server/ha_addon.py | 109 ++++++------ src/roborock_local_server/server.py | 48 ++---- tests/conftest.py | 8 - tests/test_config.py | 23 +-- tests/test_configure.py | 31 ---- tests/test_ha_addon.py | 182 ++++++++++++++++++--- tests/test_ha_addon_export.py | 1 + tests/test_supervisor.py | 40 ----- uv.lock | 2 +- 18 files changed, 290 insertions(+), 333 deletions(-) diff --git a/config.example.toml b/config.example.toml index 258de39..dbf29fd 100644 --- a/config.example.toml +++ b/config.example.toml @@ -1,15 +1,10 @@ [network] # The one hostname the stack will serve. Keep this as the hostname only. stack_fqdn = "api-roborock.example.com" -listener_mode = "local_tls" bind_host = "0.0.0.0" -# Change these if you need the stack to advertise custom public ports. +# Change these if you need custom published ports. https_port = 555 mqtt_tls_port = 8881 -# Internal listener ports. In local_tls mode these are the TLS ports this process binds. -# In external_tls mode these are the plaintext internal ports your proxy forwards to. -listen_https_port = 555 -listen_mqtt_port = 8881 region = "us" [broker] diff --git a/docs/home_assistant.md b/docs/home_assistant.md index 71e05c6..bcba01f 100644 --- a/docs/home_assistant.md +++ b/docs/home_assistant.md @@ -28,7 +28,7 @@ To install it as a custom repository: 2. Add this repository URL: - `https://github.com/Python-roborock/local_roborock_server` 3. Install **Roborock Local Server**. -4. Fill the app options (`stack_fqdn`, `listener_mode`, `admin_password`, `protocol_login_email`, `protocol_login_pin`, TLS settings). +4. Fill the app options (`stack_fqdn`, `admin_password`, `protocol_login_email`, `protocol_login_pin`, TLS settings). 5. Start the app. Then open: @@ -39,8 +39,7 @@ Important: installing the Home Assistant app does not automatically rewrite your Notes: -- `listener_mode = local_tls` means this app terminates TLS for both HTTPS and MQTT. -- `listener_mode = external_tls` means your external proxy must terminate TLS for both HTTPS and MQTT and forward plaintext to the app's internal `listen_https_port` and `listen_mqtt_port`. +- The add-on terminates TLS itself and publishes two ports: HTTPS on `https_port` and MQTT/TLS on `mqtt_tls_port`. - If you use Home Assistant's Nginx Proxy Manager add-on for certificate issuance, this add-on can read those PEM files directly through `/all_addon_configs/a0d7b954_nginxproxymanager/letsencrypt/live/...`. ## Option 2: Existing Docker deployment diff --git a/pyproject.toml b/pyproject.toml index d288a9a..a1997e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "roborock-local-server" -version = "0.0.2-rc1" +version = "0.0.2-rc6" description = "private local Roborock server stack." requires-python = ">=3.11,<3.14" readme = "README.md" diff --git a/roborock_local_server_addon/DOCS.md b/roborock_local_server_addon/DOCS.md index 6eecf70..db35d31 100644 --- a/roborock_local_server_addon/DOCS.md +++ b/roborock_local_server_addon/DOCS.md @@ -2,17 +2,21 @@ This app runs the same `ghcr.io/python-roborock/local_roborock_server` image used for Docker installs. +It publishes two TLS ports directly: + +- `555/tcp` for the Roborock HTTPS API +- `8881/tcp` for the Roborock MQTT TLS proxy + ## Setup 1. Set `stack_fqdn` to your `api-...` hostname. -2. Choose `listener_mode`: - - `local_tls`: this app terminates TLS for both HTTPS and MQTT - - `external_tls`: your external proxy must terminate TLS for both HTTPS and MQTT and forward plaintext to this app's `listen_https_port` and `listen_mqtt_port` -3. Set `admin_password`, `protocol_login_email`, and `protocol_login_pin` (6 digits). -4. If you use `local_tls`, choose TLS mode: +2. Set `admin_password`, `protocol_login_email`, and `protocol_login_pin` (6 digits). +3. Choose TLS mode: - `provided`: set `cert_file` and `key_file` (defaults: `/ssl/fullchain.pem`, `/ssl/privkey.pem`) - `cloudflare_acme`: set `tls_base_domain`, `tls_email`, `cloudflare_token` -5. Start the app. +4. Start the app. + +The add-on always runs the embedded MQTT broker and keeps the topic bridge enabled. Then open `https://:555/admin` (or your custom HTTPS port). diff --git a/roborock_local_server_addon/config.yaml b/roborock_local_server_addon/config.yaml index c17cd63..a97f2ec 100644 --- a/roborock_local_server_addon/config.yaml +++ b/roborock_local_server_addon/config.yaml @@ -1,5 +1,5 @@ name: Roborock Local Server -version: "0.0.2-rc1" +version: "0.0.2-rc6" slug: roborock_local_server description: Private Roborock HTTPS and MQTT stack for Home Assistant environments. url: "https://github.com/Python-roborock/local_roborock_server" @@ -23,16 +23,9 @@ map: webui: "https://[HOST]:[PORT:555]/admin" options: stack_fqdn: "api-roborock.example.com" - listener_mode: "local_tls" https_port: 555 mqtt_tls_port: 8881 - listen_https_port: 555 - listen_mqtt_port: 8881 region: "us" - use_external_broker: false - broker_host: "127.0.0.1" - broker_port: 18830 - enable_topic_bridge: true tls_mode: "provided" tls_base_domain: "" tls_email: "" @@ -40,22 +33,13 @@ options: cert_file: "/ssl/fullchain.pem" key_file: "/ssl/privkey.pem" admin_password: "" - admin_session_secret: "" - protocol_auth_enabled: true protocol_login_email: "" protocol_login_pin: "" schema: stack_fqdn: str - listener_mode: list(local_tls|external_tls) https_port: port mqtt_tls_port: port - listen_https_port: port - listen_mqtt_port: port region: list(us|eu|cn|ru) - use_external_broker: bool - broker_host: str - broker_port: port - enable_topic_bridge: bool tls_mode: list(provided|cloudflare_acme) tls_base_domain: str tls_email: str @@ -63,7 +47,5 @@ schema: cert_file: str key_file: str admin_password: password - admin_session_secret: str - protocol_auth_enabled: bool protocol_login_email: email protocol_login_pin: password diff --git a/scripts/export_home_assistant_dev_addon.py b/scripts/export_home_assistant_dev_addon.py index a966bf5..a99eb89 100644 --- a/scripts/export_home_assistant_dev_addon.py +++ b/scripts/export_home_assistant_dev_addon.py @@ -9,18 +9,18 @@ REPO_ROOT = Path(__file__).resolve().parents[1] DEFAULT_OUTPUT_DIR = REPO_ROOT / "dist" / "home_assistant_dev_addon_repo" -ADDON_SLUG = "roborock_local_server_dev" -ADDON_DIRNAME = ADDON_SLUG -ADDON_NAME = "Roborock Local Server Dev" +DEFAULT_ADDON_SLUG = "roborock_local_server_dev" +DEFAULT_ADDON_NAME = "Roborock Local Server Dev" REPOSITORY_YAML = """name: Roborock Local Server Dev Apps url: "https://github.com/Python-roborock/local_roborock_server" maintainer: Luke Lashley """ -ADDON_CONFIG_YAML = f"""name: {ADDON_NAME} +def _addon_config_yaml(*, addon_name: str, addon_slug: str) -> str: + return f"""name: {addon_name} version: "{__version__}" -slug: {ADDON_SLUG} +slug: {addon_slug} description: Local-build development app for testing unpublished Roborock Local Server changes in Home Assistant. url: "https://github.com/Python-roborock/local_roborock_server" startup: services @@ -42,16 +42,9 @@ webui: "https://[HOST]:[PORT:555]/admin" options: stack_fqdn: "api-roborock.example.com" - listener_mode: "local_tls" https_port: 555 mqtt_tls_port: 8881 - listen_https_port: 555 - listen_mqtt_port: 8881 region: "us" - use_external_broker: false - broker_host: "127.0.0.1" - broker_port: 18830 - enable_topic_bridge: true tls_mode: "provided" tls_base_domain: "" tls_email: "" @@ -59,22 +52,13 @@ cert_file: "/ssl/fullchain.pem" key_file: "/ssl/privkey.pem" admin_password: "" - admin_session_secret: "" - protocol_auth_enabled: true protocol_login_email: "" protocol_login_pin: "" schema: stack_fqdn: str - listener_mode: list(local_tls|external_tls) https_port: port mqtt_tls_port: port - listen_https_port: port - listen_mqtt_port: port region: list(us|eu|cn|ru) - use_external_broker: bool - broker_host: str - broker_port: port - enable_topic_bridge: bool tls_mode: list(provided|cloudflare_acme) tls_base_domain: str tls_email: str @@ -82,23 +66,24 @@ cert_file: str key_file: str admin_password: password - admin_session_secret: str - protocol_auth_enabled: bool protocol_login_email: email protocol_login_pin: password """ -ADDON_DOCS_MD = f"""# {ADDON_NAME} +def _addon_docs_md(*, addon_name: str) -> str: + return f"""# {addon_name} This local-build Home Assistant app is exported from your current working tree so you can test unpublished changes on a real Home Assistant instance. +It publishes two TLS ports directly: `555/tcp` for the Roborock HTTPS API and `8881/tcp` for the Roborock MQTT TLS proxy. + ## Install 1. Run `uv run python scripts/export_home_assistant_dev_addon.py`. 2. Copy the generated repository folder to your Home Assistant host under `/addons/local_roborock_server_dev_repo/`. 3. In Home Assistant, open **Settings -> Add-ons -> App Store** and refresh. -4. Open the **Local add-ons** repository and install **{ADDON_NAME}**. -5. Set `stack_fqdn`, `listener_mode`, `admin_password`, `protocol_login_email`, `protocol_login_pin`, and your TLS settings. +4. Open the **Local add-ons** repository and install **{addon_name}**. +5. Set `stack_fqdn`, `admin_password`, `protocol_login_email`, `protocol_login_pin`, and your TLS settings. 6. Start the app and open `https://:555/admin`. This app does not auto-edit Home Assistant's Roborock config entry. Update `config/.storage/core.config_entries` so Home Assistant points to your local stack URLs. @@ -151,18 +136,18 @@ def _write_text(path: Path, content: str) -> None: path.write_text(content, encoding="utf-8") -def export_repository(output_dir: Path) -> Path: +def export_repository(output_dir: Path, *, addon_slug: str = DEFAULT_ADDON_SLUG, addon_name: str = DEFAULT_ADDON_NAME) -> Path: if output_dir.exists(): shutil.rmtree(output_dir) output_dir.mkdir(parents=True, exist_ok=True) - addon_dir = output_dir / ADDON_DIRNAME + addon_dir = output_dir / addon_slug app_dir = addon_dir / "app" app_dir.mkdir(parents=True, exist_ok=True) _write_text(output_dir / "repository.yaml", REPOSITORY_YAML) - _write_text(addon_dir / "config.yaml", ADDON_CONFIG_YAML) - _write_text(addon_dir / "DOCS.md", ADDON_DOCS_MD) + _write_text(addon_dir / "config.yaml", _addon_config_yaml(addon_name=addon_name, addon_slug=addon_slug)) + _write_text(addon_dir / "DOCS.md", _addon_docs_md(addon_name=addon_name)) _write_text(addon_dir / "CHANGELOG.md", ADDON_CHANGELOG_MD) _write_text(addon_dir / "Dockerfile", ADDON_DOCKERFILE) _write_text(addon_dir / ".dockerignore", ADDON_DOCKERIGNORE) @@ -184,9 +169,19 @@ def main() -> int: default=DEFAULT_OUTPUT_DIR, help=f"Output directory for the generated local add-on repository. Default: {DEFAULT_OUTPUT_DIR}", ) + parser.add_argument( + "--addon-slug", + default=DEFAULT_ADDON_SLUG, + help=f"Addon slug/folder name to export. Default: {DEFAULT_ADDON_SLUG}", + ) + parser.add_argument( + "--addon-name", + default=DEFAULT_ADDON_NAME, + help=f"Addon display name to export. Default: {DEFAULT_ADDON_NAME}", + ) args = parser.parse_args() output_dir = args.output_dir.resolve() - export_repository(output_dir) + export_repository(output_dir, addon_slug=args.addon_slug, addon_name=args.addon_name) print(output_dir) return 0 diff --git a/src/roborock_local_server/__init__.py b/src/roborock_local_server/__init__.py index f4b23ae..deeb218 100644 --- a/src/roborock_local_server/__init__.py +++ b/src/roborock_local_server/__init__.py @@ -2,4 +2,4 @@ __all__ = ["__version__"] -__version__ = "0.0.2-rc1" +__version__ = "0.0.2-rc6" diff --git a/src/roborock_local_server/config.py b/src/roborock_local_server/config.py index 53a020a..c707c8d 100644 --- a/src/roborock_local_server/config.py +++ b/src/roborock_local_server/config.py @@ -10,12 +10,9 @@ @dataclass(frozen=True) class NetworkConfig: stack_fqdn: str - listener_mode: str bind_host: str https_port: int mqtt_tls_port: int - listen_https_port: int - listen_mqtt_port: int region: str localkey: str duid: str @@ -133,15 +130,6 @@ def _as_bool(value: object, default: bool) -> bool: return bool(value) -def _listener_port( - section: dict[str, object], - *, - field_name: str, - default: int, -) -> int: - return _as_int(section.get(field_name), f"network.{field_name}", default) - - def load_config(path: str | Path) -> AppConfig: config_path = Path(path).resolve() parsed = tomllib.loads(config_path.read_text(encoding="utf-8")) @@ -159,8 +147,8 @@ def load_config(path: str | Path) -> AppConfig: if tls_mode not in {"cloudflare_acme", "provided"}: raise ValueError("tls.mode must be 'cloudflare_acme' or 'provided'") listener_mode = str(network.get("listener_mode", "local_tls")).strip().lower() or "local_tls" - if listener_mode not in {"local_tls", "external_tls"}: - raise ValueError("network.listener_mode must be 'local_tls' or 'external_tls'") + if listener_mode != "local_tls": + raise ValueError("network.listener_mode='external_tls' is no longer supported") raw_broker_host = broker.get("host") broker_host = str(raw_broker_host).strip() if raw_broker_host is not None else "127.0.0.1" @@ -171,20 +159,9 @@ def load_config(path: str | Path) -> AppConfig: config = AppConfig( network=NetworkConfig( stack_fqdn=_require_stack_fqdn(network.get("stack_fqdn"), "network.stack_fqdn"), - listener_mode=listener_mode, bind_host=str(network.get("bind_host", "0.0.0.0")).strip() or "0.0.0.0", https_port=_as_int(network.get("https_port"), "network.https_port", 555), mqtt_tls_port=_as_int(network.get("mqtt_tls_port"), "network.mqtt_tls_port", 8881), - listen_https_port=_listener_port( - network, - field_name="listen_https_port", - default=_as_int(network.get("https_port"), "network.https_port", 555), - ), - listen_mqtt_port=_listener_port( - network, - field_name="listen_mqtt_port", - default=_as_int(network.get("mqtt_tls_port"), "network.mqtt_tls_port", 8881), - ), region=str(network.get("region", "us")).strip().lower() or "us", localkey=str(network.get("localkey", "")).strip(), duid=str(network.get("duid", "")).strip(), @@ -234,14 +211,13 @@ def load_config(path: str | Path) -> AppConfig: if config.broker.mode == "external": _require_non_empty(config.broker.host, "broker.host") - if config.network.listener_mode == "local_tls": - if config.tls.mode == "cloudflare_acme": - _require_non_empty(config.tls.base_domain, "tls.base_domain") - _require_non_empty(config.tls.email, "tls.email") - _require_non_empty(config.tls.cloudflare_token_file, "tls.cloudflare_token_file") - else: - _require_non_empty(config.tls.cert_file, "tls.cert_file") - _require_non_empty(config.tls.key_file, "tls.key_file") + if config.tls.mode == "cloudflare_acme": + _require_non_empty(config.tls.base_domain, "tls.base_domain") + _require_non_empty(config.tls.email, "tls.email") + _require_non_empty(config.tls.cloudflare_token_file, "tls.cloudflare_token_file") + else: + _require_non_empty(config.tls.cert_file, "tls.cert_file") + _require_non_empty(config.tls.key_file, "tls.key_file") return config diff --git a/src/roborock_local_server/configure.py b/src/roborock_local_server/configure.py index 544dd2c..d99657c 100644 --- a/src/roborock_local_server/configure.py +++ b/src/roborock_local_server/configure.py @@ -41,11 +41,8 @@ def hash_password(password: str, *, iterations: int = 600_000) -> str: @dataclass(frozen=True) class ConfigureAnswers: stack_fqdn: str - listener_mode: str https_port: int mqtt_tls_port: int - listen_https_port: int - listen_mqtt_port: int broker_mode: str tls_mode: str base_domain: str @@ -188,19 +185,8 @@ def collect_configure_answers() -> ConfigureAnswers: ) https_port = _prompt_port("Advertised HTTPS port", default=555) mqtt_tls_port = _prompt_port("Advertised MQTT TLS port", default=8881) - use_external_tls = _prompt_yes_no( - "Will an external proxy terminate TLS for both HTTPS and MQTT?", - default=False, - ) - listener_mode = "external_tls" if use_external_tls else "local_tls" - listen_https_port = _prompt_port("Internal HTTPS listen port", default=https_port) - listen_mqtt_port = _prompt_port("Internal MQTT listen port", default=mqtt_tls_port) use_external_broker = _prompt_yes_no("Use your own MQTT broker instead of the embedded one?", default=False) - use_cloudflare_acme = ( - _prompt_yes_no("Use Cloudflare DNS-01 for automatic TLS renewal?", default=True) - if listener_mode == "local_tls" - else False - ) + use_cloudflare_acme = _prompt_yes_no("Use Cloudflare DNS-01 for automatic TLS renewal?", default=True) broker_mode = "external" if use_external_broker else "embedded" tls_mode = "cloudflare_acme" if use_cloudflare_acme else "provided" @@ -224,11 +210,8 @@ def collect_configure_answers() -> ConfigureAnswers: protocol_login_pin = _prompt_protocol_login_pin() return ConfigureAnswers( stack_fqdn=stack_fqdn, - listener_mode=listener_mode, https_port=https_port, mqtt_tls_port=mqtt_tls_port, - listen_https_port=listen_https_port, - listen_mqtt_port=listen_mqtt_port, broker_mode=broker_mode, tls_mode=tls_mode, base_domain=base_domain, @@ -245,12 +228,9 @@ def render_config_toml(answers: ConfigureAnswers) -> str: lines = [ "[network]", f"stack_fqdn = {_toml_string(answers.stack_fqdn)}", - f'listener_mode = "{answers.listener_mode}"', 'bind_host = "0.0.0.0"', f"https_port = {answers.https_port}", f"mqtt_tls_port = {answers.mqtt_tls_port}", - f"listen_https_port = {answers.listen_https_port}", - f"listen_mqtt_port = {answers.listen_mqtt_port}", 'region = "us"', "", "[broker]", @@ -286,7 +266,7 @@ def render_config_toml(answers: ConfigureAnswers) -> str: f'mode = "{answers.tls_mode}"', ] ) - if answers.listener_mode == "local_tls" and answers.tls_mode == "cloudflare_acme": + if answers.tls_mode == "cloudflare_acme": lines.extend( [ f"base_domain = {_toml_string(answers.base_domain)}", @@ -337,7 +317,7 @@ def write_config_setup( token_path = config_path.parent / "secrets" / "cloudflare_token" protected_paths = [config_path] - if answers.listener_mode == "local_tls" and answers.tls_mode == "cloudflare_acme": + if answers.tls_mode == "cloudflare_acme": protected_paths.append(token_path) if not force: @@ -350,7 +330,7 @@ def write_config_setup( config_path.write_text(render_config_toml(answers), encoding="utf-8") written_token_path: Path | None = None - if answers.listener_mode == "local_tls" and answers.tls_mode == "cloudflare_acme": + if answers.tls_mode == "cloudflare_acme": token_path.parent.mkdir(parents=True, exist_ok=True) token_path.write_text(answers.cloudflare_token, encoding="utf-8") if os.name != "nt": diff --git a/src/roborock_local_server/ha_addon.py b/src/roborock_local_server/ha_addon.py index fe0d8b9..931542c 100644 --- a/src/roborock_local_server/ha_addon.py +++ b/src/roborock_local_server/ha_addon.py @@ -7,33 +7,29 @@ from pathlib import Path import re import secrets +import tomllib from typing import Any from urllib.parse import urlsplit from .configure import hash_password +DEFAULT_CERT_FILE = "/ssl/fullchain.pem" +DEFAULT_KEY_FILE = "/ssl/privkey.pem" + + DEFAULT_OPTIONS: dict[str, Any] = { "stack_fqdn": "", - "listener_mode": "local_tls", "https_port": 555, "mqtt_tls_port": 8881, - "listen_https_port": 555, - "listen_mqtt_port": 8881, "region": "us", - "use_external_broker": False, - "broker_host": "127.0.0.1", - "broker_port": 18830, - "enable_topic_bridge": True, "tls_mode": "provided", "tls_base_domain": "", "tls_email": "", "cloudflare_token": "", - "cert_file": "/ssl/fullchain.pem", - "key_file": "/ssl/privkey.pem", + "cert_file": DEFAULT_CERT_FILE, + "key_file": DEFAULT_KEY_FILE, "admin_password": "", - "admin_session_secret": "", - "protocol_auth_enabled": True, "protocol_login_email": "", "protocol_login_pin": "", } @@ -77,20 +73,6 @@ def _normalize_hostname(raw_value: str, *, field_name: str, require_api_prefix: return normalized -def _as_bool(value: object, *, default: bool) -> bool: - if value is None: - return default - if isinstance(value, bool): - return value - if isinstance(value, str): - lowered = value.strip().lower() - if lowered in {"1", "true", "yes", "on"}: - return True - if lowered in {"0", "false", "no", "off"}: - return False - return bool(value) - - def _as_int(value: object, *, field_name: str, default: int) -> int: if value in (None, ""): return default @@ -133,7 +115,21 @@ def _load_options(path: Path) -> dict[str, Any]: return parsed -def _render_config_toml(*, options: dict[str, Any], cloudflare_token_path: Path) -> str: +def _load_existing_admin_session_secret(config_path: Path) -> str: + if not config_path.exists(): + return "" + try: + parsed = tomllib.loads(config_path.read_text(encoding="utf-8")) + except (OSError, tomllib.TOMLDecodeError): + return "" + admin = parsed.get("admin") + if not isinstance(admin, dict): + return "" + secret = str(admin.get("session_secret", "") or "").strip() + return secret if len(secret) >= 24 else "" + + +def _render_config_toml(*, options: dict[str, Any], config_path: Path, cloudflare_token_path: Path) -> str: merged = dict(DEFAULT_OPTIONS) merged.update(options) @@ -144,23 +140,16 @@ def _render_config_toml(*, options: dict[str, Any], cloudflare_token_path: Path) ) region = str(merged.get("region", "us") or "us").strip().lower() or "us" listener_mode = str(merged.get("listener_mode", "local_tls") or "local_tls").strip().lower() or "local_tls" - if listener_mode not in {"local_tls", "external_tls"}: - raise ValueError("listener_mode must be 'local_tls' or 'external_tls'") + if listener_mode != "local_tls": + raise ValueError("listener_mode='external_tls' is no longer supported") https_port = _as_int(merged.get("https_port"), field_name="https_port", default=555) mqtt_tls_port = _as_int(merged.get("mqtt_tls_port"), field_name="mqtt_tls_port", default=8881) - listen_https_port = _as_int(merged.get("listen_https_port"), field_name="listen_https_port", default=https_port) - listen_mqtt_port = _as_int(merged.get("listen_mqtt_port"), field_name="listen_mqtt_port", default=mqtt_tls_port) - - use_external_broker = _as_bool(merged.get("use_external_broker"), default=False) - broker_mode = "external" if use_external_broker else "embedded" - broker_host = str(merged.get("broker_host", "127.0.0.1") or "").strip() - if not broker_host: - broker_host = "127.0.0.1" if not use_external_broker else "" - if use_external_broker and not broker_host: - raise ValueError("broker_host is required when use_external_broker is true") - broker_port_default = 1883 if use_external_broker else 18830 - broker_port = _as_int(merged.get("broker_port"), field_name="broker_port", default=broker_port_default) - enable_topic_bridge = _as_bool(merged.get("enable_topic_bridge"), default=True) + + # Legacy HA options for broker selection are ignored now that the add-on + # always runs the embedded broker with the topic bridge enabled. + broker_mode = "embedded" + broker_host = "127.0.0.1" + broker_port = 18830 tls_mode = str(merged.get("tls_mode", "provided") or "provided").strip().lower() if tls_mode not in {"provided", "cloudflare_acme"}: @@ -168,22 +157,31 @@ def _render_config_toml(*, options: dict[str, Any], cloudflare_token_path: Path) tls_base_domain = str(merged.get("tls_base_domain", "") or "").strip() tls_email = str(merged.get("tls_email", "") or "").strip() cloudflare_token = str(merged.get("cloudflare_token", "") or "").strip() - cert_file = str(merged.get("cert_file", "/ssl/fullchain.pem") or "").strip() - key_file = str(merged.get("key_file", "/ssl/privkey.pem") or "").strip() + cert_file = str(merged.get("cert_file", DEFAULT_CERT_FILE) or "").strip() + key_file = str(merged.get("key_file", DEFAULT_KEY_FILE) or "").strip() + effective_tls_mode = "cloudflare_acme" if cloudflare_token else tls_mode - if listener_mode == "local_tls" and tls_mode == "cloudflare_acme": + if effective_tls_mode == "cloudflare_acme": _normalize_hostname(tls_base_domain, field_name="tls_base_domain") _require_email(tls_email, field_name="tls_email") _require_non_empty(cloudflare_token, field_name="cloudflare_token") - elif listener_mode == "local_tls": + else: + cert_file = cert_file or DEFAULT_CERT_FILE + key_file = key_file or DEFAULT_KEY_FILE _require_non_empty(cert_file, field_name="cert_file") _require_non_empty(key_file, field_name="key_file") admin_password = _require_non_empty(merged.get("admin_password"), field_name="admin_password") - admin_session_secret = str(merged.get("admin_session_secret", "") or "").strip() or secrets.token_urlsafe(32) + admin_session_secret = ( + str(merged.get("admin_session_secret", "") or "").strip() + or _load_existing_admin_session_secret(config_path) + or secrets.token_urlsafe(32) + ) if len(admin_session_secret) < 24: raise ValueError("admin_session_secret must be at least 24 characters when set") - protocol_auth_enabled = _as_bool(merged.get("protocol_auth_enabled"), default=True) + # The Home Assistant add-on no longer exposes this toggle. + # Keep protocol auth enabled even if a stale stored option is present. + protocol_auth_enabled = True protocol_login_email = _require_email(merged.get("protocol_login_email"), field_name="protocol_login_email") protocol_login_pin = _require_pin(merged.get("protocol_login_pin"), field_name="protocol_login_pin") @@ -194,12 +192,9 @@ def _render_config_toml(*, options: dict[str, Any], cloudflare_token_path: Path) lines = [ "[network]", f"stack_fqdn = {_toml_string(stack_fqdn)}", - f"listener_mode = {_toml_string(listener_mode)}", 'bind_host = "0.0.0.0"', f"https_port = {https_port}", f"mqtt_tls_port = {mqtt_tls_port}", - f"listen_https_port = {listen_https_port}", - f"listen_mqtt_port = {listen_mqtt_port}", f"region = {_toml_string(region)}", "", "[broker]", @@ -207,15 +202,15 @@ def _render_config_toml(*, options: dict[str, Any], cloudflare_token_path: Path) f"host = {_toml_string(broker_host)}", f"port = {broker_port}", 'mosquitto_binary = "mosquitto"', - f"enable_topic_bridge = {_toml_bool(enable_topic_bridge)}", + "enable_topic_bridge = true", "", "[storage]", 'data_dir = "/data"', "", "[tls]", - f"mode = {_toml_string(tls_mode)}", + f"mode = {_toml_string(effective_tls_mode)}", ] - if listener_mode == "local_tls" and tls_mode == "cloudflare_acme": + if effective_tls_mode == "cloudflare_acme": lines.extend( [ f"base_domain = {_toml_string(tls_base_domain)}", @@ -252,7 +247,7 @@ def _render_config_toml(*, options: dict[str, Any], cloudflare_token_path: Path) "", ] ) - return "\n".join(lines), cloudflare_token if listener_mode == "local_tls" and tls_mode == "cloudflare_acme" else "" + return "\n".join(lines), cloudflare_token if effective_tls_mode == "cloudflare_acme" else "" def write_config_from_home_assistant_options( @@ -262,7 +257,11 @@ def write_config_from_home_assistant_options( cloudflare_token_path: Path = DEFAULT_CLOUDFLARE_TOKEN_PATH, ) -> Path: options = _load_options(options_path) - config_text, cloudflare_token = _render_config_toml(options=options, cloudflare_token_path=cloudflare_token_path) + config_text, cloudflare_token = _render_config_toml( + options=options, + config_path=config_path, + cloudflare_token_path=cloudflare_token_path, + ) config_path.parent.mkdir(parents=True, exist_ok=True) config_path.write_text(config_text, encoding="utf-8") if cloudflare_token: diff --git a/src/roborock_local_server/server.py b/src/roborock_local_server/server.py index 00af443..1a18146 100644 --- a/src/roborock_local_server/server.py +++ b/src/roborock_local_server/server.py @@ -358,20 +358,14 @@ def __init__( running=False, required=True, enabled=True, - detail=( - f"{self.config.network.listener_mode}:" - f"{self.config.network.bind_host}:{self.config.network.listen_https_port}" - ), + detail=f"tls:{self.config.network.bind_host}:{self.config.network.https_port}", ) self.runtime_state.set_service( "mqtt_tls_proxy", running=False, required=True, enabled=True, - detail=( - f"{self.config.network.listener_mode}:" - f"{self.config.network.bind_host}:{self.config.network.listen_mqtt_port}" - ), + detail=f"tls:{self.config.network.bind_host}:{self.config.network.mqtt_tls_port}", ) self.runtime_state.set_service( "mqtt_backend_broker", @@ -435,9 +429,6 @@ def __init__( self.endpoint_rules = default_endpoint_rules() self.app = self._create_app() - def _uses_local_tls(self) -> bool: - return self.config.network.listener_mode == "local_tls" - def _init_zone_ranges_store(self) -> ZoneRangesStore: store = ZoneRangesStore(self.paths.http_jsonl_path.parent) if not store._data: @@ -1460,25 +1451,25 @@ def _create_app(self) -> FastAPI: return app async def _start_http_server(self) -> None: - cert_paths = self.certificate_manager.certificate_paths if self._uses_local_tls() else None + cert_paths = self.certificate_manager.certificate_paths self._http_server = ManagedFastApiServer( app=self.app, bind_host=self.config.network.bind_host, - port=self.config.network.listen_https_port, - tls_enabled=self._uses_local_tls(), - cert_file=cert_paths.cert_file if cert_paths is not None else None, - key_file=cert_paths.key_file if cert_paths is not None else None, + port=self.config.network.https_port, + tls_enabled=True, + cert_file=cert_paths.cert_file, + key_file=cert_paths.key_file, ) await self._http_server.start() self.runtime_state.set_service("https_server", running=True, required=True, enabled=True) def _start_mqtt_proxy(self) -> None: - cert_paths = self.certificate_manager.certificate_paths if self._uses_local_tls() else None + cert_paths = self.certificate_manager.certificate_paths self._mqtt_proxy = MqttTlsProxy( - cert_file=cert_paths.cert_file if cert_paths is not None else None, - key_file=cert_paths.key_file if cert_paths is not None else None, + cert_file=cert_paths.cert_file, + key_file=cert_paths.key_file, listen_host=self.config.network.bind_host, - listen_port=self.config.network.listen_mqtt_port, + listen_port=self.config.network.mqtt_tls_port, backend_host=self.config.broker.host, backend_port=self.config.broker.port, localkey=self.context.localkey, @@ -1490,7 +1481,7 @@ def _start_mqtt_proxy(self) -> None: runtime_state=self.runtime_state, runtime_credentials=self.runtime_credentials, zone_ranges_store=self.context.zone_ranges_store, - tls_enabled=self._uses_local_tls(), + tls_enabled=True, ) self._mqtt_proxy.start() self.runtime_state.set_service("mqtt_tls_proxy", running=True, required=True, enabled=True) @@ -1522,8 +1513,7 @@ async def start(self) -> None: for path in (self.paths.data_dir, self.paths.runtime_dir, self.paths.state_dir, self.paths.certs_dir, self.paths.acme_dir): path.mkdir(parents=True, exist_ok=True) - if self._uses_local_tls(): - self.certificate_manager.ensure_certificate() + self.certificate_manager.ensure_certificate() self.refresh_inventory_state() if self.config.broker.mode == "embedded": @@ -1555,16 +1545,14 @@ async def start(self) -> None: self._start_mqtt_proxy() self.root_logger.info( - "%s server listening on %s:%d", - "HTTPS" if self._uses_local_tls() else "HTTP", + "HTTPS server listening on %s:%d", self.config.network.bind_host, - self.config.network.listen_https_port, + self.config.network.https_port, ) self.root_logger.info( - "MQTT %s proxy listening on %s:%d", - "TLS" if self._uses_local_tls() else "plaintext", + "MQTT TLS proxy listening on %s:%d", self.config.network.bind_host, - self.config.network.listen_mqtt_port, + self.config.network.mqtt_tls_port, ) self.root_logger.info( "MQTT backend %s on %s:%d", @@ -1573,7 +1561,7 @@ async def start(self) -> None: self.config.broker.port, ) - if self._uses_local_tls() and self.config.tls.mode == "cloudflare_acme": + if self.config.tls.mode == "cloudflare_acme": self._renew_task = asyncio.create_task(self._renew_loop(), name="tls-renew-loop") async def stop(self) -> None: diff --git a/tests/conftest.py b/tests/conftest.py index e416b72..9d1f568 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -18,11 +18,8 @@ def write_release_config( tmp_path: Path, *, stack_fqdn: str = "api-roborock.example.com", - listener_mode: str = "local_tls", https_port: int = 443, mqtt_tls_port: int = 8883, - listen_https_port: int | None = None, - listen_mqtt_port: int | None = None, broker_mode: str = "external", enable_topic_bridge: bool = False, protocol_auth_enabled: bool = True, @@ -35,17 +32,12 @@ def write_release_config( (cert_dir / "privkey.pem").write_text("test-key\n", encoding="utf-8") config_file = tmp_path / "config.toml" - resolved_listen_https_port = https_port if listen_https_port is None else listen_https_port - resolved_listen_mqtt_port = mqtt_tls_port if listen_mqtt_port is None else listen_mqtt_port config_file.write_text( f""" [network] stack_fqdn = "{stack_fqdn}" -listener_mode = "{listener_mode}" https_port = {https_port} mqtt_tls_port = {mqtt_tls_port} -listen_https_port = {resolved_listen_https_port} -listen_mqtt_port = {resolved_listen_mqtt_port} [broker] mode = "{broker_mode}" diff --git a/tests/test_config.py b/tests/test_config.py index 6fc6e95..3b70c26 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -10,9 +10,6 @@ def test_load_config_and_resolve_paths(tmp_path: Path) -> None: """ [network] stack_fqdn = "api-roborock.example.com" -listener_mode = "local_tls" -listen_https_port = 555 -listen_mqtt_port = 8881 [broker] mode = "embedded" @@ -38,11 +35,8 @@ def test_load_config_and_resolve_paths(tmp_path: Path) -> None: paths = resolve_paths(config_file, config) assert config.network.stack_fqdn == "api-roborock.example.com" - assert config.network.listener_mode == "local_tls" assert config.network.https_port == 555 assert config.network.mqtt_tls_port == 8881 - assert config.network.listen_https_port == 555 - assert config.network.listen_mqtt_port == 8881 assert config.admin.protocol_auth_enabled is True assert config.admin.protocol_login_email == "user@example.com" assert paths.data_dir == (tmp_path / "data").resolve() @@ -56,9 +50,6 @@ def test_load_config_requires_protocol_login_credentials(tmp_path: Path) -> None """ [network] stack_fqdn = "api-roborock.example.com" -listener_mode = "local_tls" -listen_https_port = 555 -listen_mqtt_port = 8881 [broker] mode = "embedded" @@ -88,9 +79,6 @@ def test_load_config_requires_api_prefix_for_stack_fqdn(tmp_path: Path) -> None: """ [network] stack_fqdn = "lashleyhomeassist.duckdns.org" -listener_mode = "local_tls" -listen_https_port = 555 -listen_mqtt_port = 8881 [broker] mode = "embedded" @@ -116,7 +104,7 @@ def test_load_config_requires_api_prefix_for_stack_fqdn(tmp_path: Path) -> None: load_config(config_file) -def test_load_config_external_tls_allows_missing_cert_paths(tmp_path: Path) -> None: +def test_load_config_rejects_external_tls(tmp_path: Path) -> None: config_file = tmp_path / "config.toml" config_file.write_text( """ @@ -125,8 +113,6 @@ def test_load_config_external_tls_allows_missing_cert_paths(tmp_path: Path) -> N listener_mode = "external_tls" https_port = 443 mqtt_tls_port = 8883 -listen_https_port = 8080 -listen_mqtt_port = 18883 [broker] mode = "embedded" @@ -146,8 +132,5 @@ def test_load_config_external_tls_allows_missing_cert_paths(tmp_path: Path) -> N encoding="utf-8", ) - config = load_config(config_file) - - assert config.network.listener_mode == "external_tls" - assert config.network.listen_https_port == 8080 - assert config.network.listen_mqtt_port == 18883 + with pytest.raises(ValueError, match="external_tls"): + load_config(config_file) diff --git a/tests/test_configure.py b/tests/test_configure.py index 2ff0f15..af6485a 100644 --- a/tests/test_configure.py +++ b/tests/test_configure.py @@ -8,21 +8,15 @@ def _answers( *, - listener_mode: str = "local_tls", https_port: int = 555, mqtt_tls_port: int = 8881, - listen_https_port: int | None = None, - listen_mqtt_port: int | None = None, broker_mode: str = "embedded", tls_mode: str = "cloudflare_acme", ) -> ConfigureAnswers: return ConfigureAnswers( stack_fqdn="api-roborock.example.com", - listener_mode=listener_mode, https_port=https_port, mqtt_tls_port=mqtt_tls_port, - listen_https_port=https_port if listen_https_port is None else listen_https_port, - listen_mqtt_port=mqtt_tls_port if listen_mqtt_port is None else listen_mqtt_port, broker_mode=broker_mode, tls_mode=tls_mode, base_domain="example.com" if tls_mode == "cloudflare_acme" else "", @@ -47,7 +41,6 @@ def test_write_config_setup_embedded_cloudflare(tmp_path: Path) -> None: config = load_config(result.config_file) assert config.network.stack_fqdn == "api-roborock.example.com" - assert config.network.listener_mode == "local_tls" assert config.network.https_port == 555 assert config.network.mqtt_tls_port == 8881 assert config.broker.mode == "embedded" @@ -101,30 +94,6 @@ def test_write_config_setup_persists_custom_ports(tmp_path: Path) -> None: assert config.network.mqtt_tls_port == 9443 -def test_write_config_setup_external_tls_writes_internal_listener_ports(tmp_path: Path) -> None: - config_file = tmp_path / "config.toml" - - result = write_config_setup( - config_file=config_file, - answers=_answers( - listener_mode="external_tls", - https_port=443, - mqtt_tls_port=8883, - listen_https_port=8080, - listen_mqtt_port=18883, - tls_mode="provided", - ), - ) - - config = load_config(result.config_file) - rendered = config_file.read_text(encoding="utf-8") - - assert config.network.listener_mode == "external_tls" - assert config.network.listen_https_port == 8080 - assert config.network.listen_mqtt_port == 18883 - assert 'listener_mode = "external_tls"' in rendered - - def test_validate_protocol_login_pin_requires_exactly_six_digits() -> None: assert _validate_protocol_login_pin("123456") == "123456" diff --git a/tests/test_ha_addon.py b/tests/test_ha_addon.py index a26b1ba..2bd7399 100644 --- a/tests/test_ha_addon.py +++ b/tests/test_ha_addon.py @@ -22,22 +22,13 @@ def test_write_config_from_home_assistant_options_provided_tls(tmp_path: Path) - options_path, { "stack_fqdn": "https://api-roborock.example.com", - "listener_mode": "local_tls", "https_port": 8443, "mqtt_tls_port": 9443, - "listen_https_port": 8080, - "listen_mqtt_port": 18883, "region": "us", - "use_external_broker": True, - "broker_host": "mqtt.internal", - "broker_port": 1883, - "enable_topic_bridge": False, "tls_mode": "provided", "cert_file": "/ssl/fullchain.pem", "key_file": "/ssl/privkey.pem", "admin_password": "super-secret-password", - "admin_session_secret": "a" * 32, - "protocol_auth_enabled": True, "protocol_login_email": "user@example.com", "protocol_login_pin": "123456", }, @@ -51,23 +42,135 @@ def test_write_config_from_home_assistant_options_provided_tls(tmp_path: Path) - parsed = tomllib.loads(config_path.read_text(encoding="utf-8")) assert parsed["network"]["stack_fqdn"] == "api-roborock.example.com" - assert parsed["network"]["listener_mode"] == "local_tls" assert parsed["network"]["https_port"] == 8443 assert parsed["network"]["mqtt_tls_port"] == 9443 - assert parsed["network"]["listen_https_port"] == 8080 - assert parsed["network"]["listen_mqtt_port"] == 18883 - assert parsed["broker"]["mode"] == "external" - assert parsed["broker"]["host"] == "mqtt.internal" - assert parsed["broker"]["enable_topic_bridge"] is False + assert parsed["broker"]["mode"] == "embedded" + assert parsed["broker"]["host"] == "127.0.0.1" + assert parsed["broker"]["port"] == 18830 + assert parsed["broker"]["enable_topic_bridge"] is True assert parsed["tls"]["mode"] == "provided" assert parsed["tls"]["cert_file"] == "/ssl/fullchain.pem" assert parsed["tls"]["key_file"] == "/ssl/privkey.pem" + assert parsed["admin"]["protocol_auth_enabled"] is True assert parsed["admin"]["protocol_login_email"] == "user@example.com" + assert len(str(parsed["admin"]["session_secret"])) >= 24 assert str(parsed["admin"]["password_hash"]).startswith("pbkdf2_sha256$") assert str(parsed["admin"]["protocol_login_pin_hash"]).startswith("pbkdf2_sha256$") assert token_path.exists() is False +def test_write_config_from_home_assistant_options_provided_tls_uses_default_paths_when_blank(tmp_path: Path) -> None: + options_path = tmp_path / "options.json" + config_path = tmp_path / "config.toml" + + _write_options( + options_path, + { + "stack_fqdn": "api-roborock.example.com", + "tls_mode": "provided", + "cert_file": "", + "key_file": "", + "admin_password": "super-secret-password", + "protocol_login_email": "user@example.com", + "protocol_login_pin": "123456", + }, + ) + + write_config_from_home_assistant_options( + options_path=options_path, + config_path=config_path, + ) + + parsed = tomllib.loads(config_path.read_text(encoding="utf-8")) + assert parsed["tls"]["mode"] == "provided" + assert parsed["tls"]["cert_file"] == "/ssl/fullchain.pem" + assert parsed["tls"]["key_file"] == "/ssl/privkey.pem" + + +def test_write_config_from_home_assistant_options_ignores_legacy_protocol_auth_toggle(tmp_path: Path) -> None: + options_path = tmp_path / "options.json" + config_path = tmp_path / "config.toml" + + _write_options( + options_path, + { + "stack_fqdn": "api-roborock.example.com", + "admin_password": "secret", + "protocol_auth_enabled": False, + "protocol_login_email": "user@example.com", + "protocol_login_pin": "654321", + }, + ) + + write_config_from_home_assistant_options( + options_path=options_path, + config_path=config_path, + ) + + parsed = tomllib.loads(config_path.read_text(encoding="utf-8")) + assert parsed["admin"]["protocol_auth_enabled"] is True + + +def test_write_config_from_home_assistant_options_reuses_existing_session_secret(tmp_path: Path) -> None: + options_path = tmp_path / "options.json" + config_path = tmp_path / "config.toml" + + _write_options( + options_path, + { + "stack_fqdn": "api-roborock.example.com", + "admin_password": "secret", + "protocol_login_email": "user@example.com", + "protocol_login_pin": "654321", + }, + ) + + write_config_from_home_assistant_options( + options_path=options_path, + config_path=config_path, + ) + first_secret = tomllib.loads(config_path.read_text(encoding="utf-8"))["admin"]["session_secret"] + + write_config_from_home_assistant_options( + options_path=options_path, + config_path=config_path, + ) + second_secret = tomllib.loads(config_path.read_text(encoding="utf-8"))["admin"]["session_secret"] + + assert len(str(first_secret)) >= 24 + assert second_secret == first_secret + + +def test_write_config_from_home_assistant_options_ignores_legacy_broker_flags(tmp_path: Path) -> None: + options_path = tmp_path / "options.json" + config_path = tmp_path / "config.toml" + + _write_options( + options_path, + { + "stack_fqdn": "api-roborock.example.com", + "use_external_broker": True, + "broker_host": "mqtt.internal", + "broker_port": 1883, + "enable_topic_bridge": False, + "admin_password": "secret", + "protocol_login_email": "user@example.com", + "protocol_login_pin": "654321", + }, + ) + + write_config_from_home_assistant_options( + options_path=options_path, + config_path=config_path, + ) + + parsed = tomllib.loads(config_path.read_text(encoding="utf-8")) + assert parsed["broker"]["mode"] == "embedded" + assert parsed["broker"]["host"] == "127.0.0.1" + assert parsed["broker"]["port"] == 18830 + assert parsed["broker"]["enable_topic_bridge"] is True + + def test_write_config_from_home_assistant_options_cloudflare(tmp_path: Path) -> None: options_path = tmp_path / "options.json" config_path = tmp_path / "config.toml" @@ -101,19 +204,21 @@ def test_write_config_from_home_assistant_options_cloudflare(tmp_path: Path) -> assert token_path.read_text(encoding="utf-8") == "cloudflare-token-123" -def test_write_config_from_home_assistant_options_external_tls_does_not_require_certs(tmp_path: Path) -> None: +def test_write_config_from_home_assistant_options_infers_cloudflare_acme_from_token(tmp_path: Path) -> None: options_path = tmp_path / "options.json" config_path = tmp_path / "config.toml" + token_path = tmp_path / "run" / "secrets" / "cloudflare_token" _write_options( options_path, { "stack_fqdn": "api-roborock.example.com", - "listener_mode": "external_tls", - "https_port": 443, - "mqtt_tls_port": 8883, - "listen_https_port": 8080, - "listen_mqtt_port": 18883, + "tls_mode": "provided", + "tls_base_domain": "example.com", + "tls_email": "acme@example.com", + "cloudflare_token": "cloudflare-token-123", + "cert_file": "", + "key_file": "", "admin_password": "secret", "protocol_login_email": "user@example.com", "protocol_login_pin": "654321", @@ -123,12 +228,41 @@ def test_write_config_from_home_assistant_options_external_tls_does_not_require_ write_config_from_home_assistant_options( options_path=options_path, config_path=config_path, + cloudflare_token_path=token_path, ) parsed = tomllib.loads(config_path.read_text(encoding="utf-8")) - assert parsed["network"]["listener_mode"] == "external_tls" - assert parsed["network"]["listen_https_port"] == 8080 - assert parsed["network"]["listen_mqtt_port"] == 18883 + assert parsed["tls"]["mode"] == "cloudflare_acme" + assert parsed["tls"]["base_domain"] == "example.com" + assert parsed["tls"]["email"] == "acme@example.com" + assert parsed["tls"]["cloudflare_token_file"] == str(token_path) + assert "cert_file" not in parsed["tls"] + assert "key_file" not in parsed["tls"] + assert token_path.read_text(encoding="utf-8") == "cloudflare-token-123" + + +def test_write_config_from_home_assistant_options_rejects_external_tls(tmp_path: Path) -> None: + options_path = tmp_path / "options.json" + config_path = tmp_path / "config.toml" + + _write_options( + options_path, + { + "stack_fqdn": "api-roborock.example.com", + "listener_mode": "external_tls", + "https_port": 443, + "mqtt_tls_port": 8883, + "admin_password": "secret", + "protocol_login_email": "user@example.com", + "protocol_login_pin": "654321", + }, + ) + + with pytest.raises(ValueError, match="external_tls"): + write_config_from_home_assistant_options( + options_path=options_path, + config_path=config_path, + ) def test_write_config_from_home_assistant_options_requires_admin_password(tmp_path: Path) -> None: diff --git a/tests/test_ha_addon_export.py b/tests/test_ha_addon_export.py index 3096742..10002df 100644 --- a/tests/test_ha_addon_export.py +++ b/tests/test_ha_addon_export.py @@ -25,6 +25,7 @@ def test_export_repository_writes_local_dev_addon(tmp_path: Path) -> None: assert f'version: "{__version__}"' in config_text assert "slug: roborock_local_server_dev" in config_text assert 'image: "ghcr.io/python-roborock/local_roborock_server"' not in config_text + assert "protocol_auth_enabled:" not in config_text init_text = exported_init.read_text(encoding="utf-8") assert f'__version__ = "{__version__}"' in init_text diff --git a/tests/test_supervisor.py b/tests/test_supervisor.py index d77ecdc..dbeef7a 100644 --- a/tests/test_supervisor.py +++ b/tests/test_supervisor.py @@ -55,43 +55,3 @@ def fake_start_mqtt_proxy(self: ReleaseSupervisor) -> None: assert service_map["https_server"]["running"] is False assert service_map["mqtt_tls_proxy"]["running"] is False assert service_map["mqtt_backend_broker"]["running"] is False - - -def test_release_supervisor_external_tls_skips_certificate_setup(tmp_path: Path, monkeypatch) -> None: - config_file = write_release_config( - tmp_path, - listener_mode="external_tls", - listen_https_port=8080, - listen_mqtt_port=18883, - broker_mode="external", - enable_topic_bridge=False, - ) - config = load_config(config_file) - paths = resolve_paths(config_file, config) - supervisor = ReleaseSupervisor(config=config, paths=paths) - - monkeypatch.setattr(server_module, "_connectivity_check", lambda host, port: None) - monkeypatch.setattr( - supervisor.certificate_manager, - "ensure_certificate", - lambda: (_ for _ in ()).throw(AssertionError("ensure_certificate should not run in external_tls mode")), - ) - - async def fake_start_http_server(self: ReleaseSupervisor) -> None: - self._http_server = _DummyHttpServer() # type: ignore[assignment] - self.runtime_state.set_service("https_server", running=True, required=True, enabled=True) - - def fake_start_mqtt_proxy(self: ReleaseSupervisor) -> None: - self._mqtt_proxy = _DummyProxy() # type: ignore[assignment] - self.runtime_state.set_service("mqtt_tls_proxy", running=True, required=True, enabled=True) - - monkeypatch.setattr(ReleaseSupervisor, "_start_http_server", fake_start_http_server) - monkeypatch.setattr(ReleaseSupervisor, "_start_mqtt_proxy", fake_start_mqtt_proxy) - - asyncio.run(supervisor.start()) - health = supervisor.runtime_state.health_snapshot() - service_map = {service["name"]: service for service in health["services"]} - assert service_map["https_server"]["detail"] == "external_tls:0.0.0.0:8080" - assert service_map["mqtt_tls_proxy"]["detail"] == "external_tls:0.0.0.0:18883" - - asyncio.run(supervisor.stop()) diff --git a/uv.lock b/uv.lock index 711f1cc..48afdd0 100644 --- a/uv.lock +++ b/uv.lock @@ -1266,7 +1266,7 @@ wheels = [ [[package]] name = "roborock-local-server" -version = "0.0.2rc1" +version = "0.0.2rc6" source = { editable = "." } dependencies = [ { name = "aiohttp" }, From 75d79eaec6c1130e8e2b35ef741d1948b093f9e1 Mon Sep 17 00:00:00 2001 From: Luke Date: Sun, 3 May 2026 14:14:30 -0400 Subject: [PATCH 5/7] working versioning --- .gitignore | 1 + docs/home_assistant.md | 2 ++ docs/roborock_app.md | 12 +++++-- mitm_redirect.py | 2 +- src/roborock_local_server/server.py | 24 +++++++++++++- src/roborock_local_server/standalone_admin.py | 32 +++++++++++++++++++ tests/test_admin_api.py | 2 ++ tests/test_protocol_auth.py | 18 +++++++++++ 8 files changed, 88 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 6e2bf35..fb91cde 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ site/ secrets/ config.toml mitm_logs +.tmp* \ No newline at end of file diff --git a/docs/home_assistant.md b/docs/home_assistant.md index bcba01f..3cf4302 100644 --- a/docs/home_assistant.md +++ b/docs/home_assistant.md @@ -35,6 +35,8 @@ Then open: - `https://:555/admin` (or your configured HTTPS port) +If you need the MITM protocol sync secret for the Roborock app flow, sign in to the admin page and open the **Protocol Auth** section. The dashboard now shows the active `admin.session_secret` with a copy button, so you do not need to inspect `/data/config.toml` manually. + Important: installing the Home Assistant app does not automatically rewrite your Roborock integration entry. You still need to update `config/.storage/core.config_entries` endpoint values as shown below so Home Assistant points at your local stack. Notes: diff --git a/docs/roborock_app.md b/docs/roborock_app.md index ab98cca..86e4425 100644 --- a/docs/roborock_app.md +++ b/docs/roborock_app.md @@ -2,7 +2,9 @@ Use this after [Installation](installation.md) and [Onboarding](onboarding.md) if you want the official Roborock app to talk to your local stack. -During the MITM login step, the script now needs to sync the captured protocol-auth session back to your server. Pass `admin.session_secret` from `config.toml` as `--sync-secret`. That sync callback always uses the `--local-api` host and port. +During the MITM login step, the script now needs to sync the captured protocol-auth session back to your server. Pass `admin.session_secret` from the active server config as `--sync-secret`. That sync callback always uses the `--local-api` host and port. + +The launcher can auto-load a sync secret from `config.toml` beside `mitm_redirect.py`, but that is only correct when that file matches the config used by the running server. If you run the MITM step from a second machine, or your server is using a generated Home Assistant config or another config file, pass `--sync-secret` explicitly. The launcher now preflights that callback before starting `mitmweb`. If the `--local-api` host cannot be reached, if the TLS certificate does not validate for that host, or if the sync secret is rejected, the script exits immediately instead of letting you proceed into a broken login flow. @@ -16,7 +18,9 @@ The launcher now preflights that callback before starting `mitmweb`. If the `--l uv run mitm_redirect.py --local-api api-roborock.example.com --sync-secret YOUR_ADMIN_SESSION_SECRET ``` - Use the `admin.session_secret` value from `config.toml` for `YOUR_ADMIN_SESSION_SECRET`. + Use the `admin.session_secret` value from the config file your running server actually uses for `YOUR_ADMIN_SESSION_SECRET`. + + If startup fails with `invalid_sync_secret`, the launcher either auto-loaded the wrong local `config.toml` or you copied a stale secret. Re-read `admin.session_secret` from the active server config and pass it explicitly with `--sync-secret`. If you use the default local stack ports, host-only values are fine here: the script assumes HTTPS `:555` and MQTT TLS `:8881`. @@ -119,7 +123,9 @@ Make sure you have the following installed: uv run mitm_redirect.py --local-api api-roborock.example.com --sync-secret YOUR_ADMIN_SESSION_SECRET ``` - Use the `admin.session_secret` value from `config.toml` for `YOUR_ADMIN_SESSION_SECRET`. + Use the `admin.session_secret` value from the config file your running server actually uses for `YOUR_ADMIN_SESSION_SECRET`. + + If startup fails with `invalid_sync_secret`, the launcher either auto-loaded the wrong local `config.toml` or you copied a stale secret. Re-read `admin.session_secret` from the active server config and pass it explicitly with `--sync-secret`. If you use the default local stack ports, host-only values are fine here: the script assumes HTTPS `:555` and MQTT TLS `:8881`. diff --git a/mitm_redirect.py b/mitm_redirect.py index d5cbe1c..9e390b3 100644 --- a/mitm_redirect.py +++ b/mitm_redirect.py @@ -684,7 +684,7 @@ def _rewrite_value(text: str) -> str: parser.add_argument( "--sync-secret", default=None, - help="Optional admin.session_secret for protocol auth sync. Defaults to config.toml when available.", + help="Optional admin.session_secret for protocol auth sync. Defaults to ./config.toml beside this script when available; pass explicitly when the server uses a different active config.", ) parser.add_argument("--mode", default="wireguard", help="mitmweb proxy mode (default: wireguard)") parser.add_argument("--listen-port", default=None, help="mitmweb listen port") diff --git a/src/roborock_local_server/server.py b/src/roborock_local_server/server.py index 1a18146..deeda1f 100644 --- a/src/roborock_local_server/server.py +++ b/src/roborock_local_server/server.py @@ -49,6 +49,8 @@ from https_server.routes.auth.service import ( build_login_data_response, cloud_login_data_required_response, + load_cloud_full_snapshot, + with_current_server_urls, ) from .bundled_backend.shared.zone_ranges_store import ZoneRangesStore from .security import AdminSessionManager, verify_password @@ -538,6 +540,25 @@ def _local_protocol_identity(self) -> dict[str, Any]: }, } + def _protocol_login_identity(self) -> dict[str, Any]: + snapshot = load_cloud_full_snapshot(self.context) + if isinstance(snapshot, dict): + meta_value = snapshot.get("meta") + meta = meta_value if isinstance(meta_value, dict) else {} + user_data_value = snapshot.get("user_data") + candidate_user_data = user_data_value if isinstance(user_data_value, dict) else {} + candidate_accounts = ( + meta.get("username"), + candidate_user_data.get("email"), + candidate_user_data.get("username"), + candidate_user_data.get("account"), + ) + if any(self._protocol_login_email_matches(str(candidate or "")) for candidate in candidate_accounts): + patched_user_data = with_current_server_urls(self.context, candidate_user_data) + if str(patched_user_data.get("rruid") or "").strip(): + return patched_user_data + return self._local_protocol_identity() + @staticmethod def _normalized_path(path: str) -> str: normalized = str(path or "").rstrip("/") @@ -810,7 +831,7 @@ async def _handle_protocol_login_route( return "protocol_login_submit_code", status_code, payload try: issued_user_data = self.protocol_auth.issue_local_session( - self._local_protocol_identity(), + self._protocol_login_identity(), source="protocol_code_login", ) except ValueError as exc: @@ -1283,6 +1304,7 @@ def _auth_payload(self) -> dict[str, Any]: ] return { "protocol_auth_enabled": self.protocol_auth_enabled(), + "admin_session_secret": self.config.admin.session_secret, "protocol_sessions": sessions, "protocol_session_count": len(sessions), "pending_device_mqtt_recovery": self._pending_device_mqtt_recovery_payload(), diff --git a/src/roborock_local_server/standalone_admin.py b/src/roborock_local_server/standalone_admin.py index 9b16212..abeb64d 100644 --- a/src/roborock_local_server/standalone_admin.py +++ b/src/roborock_local_server/standalone_admin.py @@ -62,6 +62,14 @@ def _admin_dashboard_html(project_support: dict[str, Any]) -> str:
Loading auth state...
+
+
Protocol Sync Secret
+
+ + +
+
Use this with mitm_redirect.py --sync-secret ....
+
Loading sessions...
@@ -140,6 +148,11 @@ def _admin_dashboard_html(project_support: dict[str, Any]) -> str: document.getElementById("protocolAuthEnabled").checked = enabled; document.getElementById("authMeta").textContent = `Protocol auth: ${{enabled ? "Enabled" : "Disabled"}}. Persisted sessions: ${{Number(auth.protocol_session_count || 0)}}.`; + const sessionSecret = String(auth.admin_session_secret || ""); + document.getElementById("adminSessionSecret").value = sessionSecret; + document.getElementById("syncSecretMeta").textContent = sessionSecret + ? "Use this with mitm_redirect.py --sync-secret ..." + : "No protocol sync secret is configured."; const pendingContainer = document.getElementById("pendingRecovery"); const pendingItems = Array.isArray(auth.pending_device_mqtt_recovery) ? auth.pending_device_mqtt_recovery : []; @@ -248,6 +261,25 @@ def _admin_dashboard_html(project_support: dict[str, Any]) -> str: document.getElementById("authMeta").textContent = error.message; }} }}); + document.getElementById("copySessionSecret").addEventListener("click", async () => {{ + const input = document.getElementById("adminSessionSecret"); + if (!input.value) {{ + document.getElementById("syncSecretMeta").textContent = "No protocol sync secret is configured."; + return; + }} + try {{ + if (navigator.clipboard && navigator.clipboard.writeText) {{ + await navigator.clipboard.writeText(input.value); + }} else {{ + input.focus(); + input.select(); + document.execCommand("copy"); + }} + document.getElementById("syncSecretMeta").textContent = "Copied protocol sync secret."; + }} catch (error) {{ + document.getElementById("syncSecretMeta").textContent = "Copy failed. Select the field and copy it manually."; + }} + }}); document.getElementById("logout").addEventListener("click", async () => {{ await fetch("/admin/api/logout", {{method:"POST"}}); diff --git a/tests/test_admin_api.py b/tests/test_admin_api.py index a8bc2ef..dbc11fc 100644 --- a/tests/test_admin_api.py +++ b/tests/test_admin_api.py @@ -243,6 +243,7 @@ def test_admin_login_and_status_flow(tmp_path: Path) -> None: assert dashboard_page.status_code == 200 assert "Cloud Import" in dashboard_page.text assert "Protocol Auth" in dashboard_page.text + assert "Protocol Sync Secret" in dashboard_page.text assert "Num query samples" in dashboard_page.text assert "Public Key determined" in dashboard_page.text @@ -282,6 +283,7 @@ def test_admin_auth_endpoints_toggle_protocol_auth_and_manage_sessions(tmp_path: assert auth_payload.status_code == 200 auth_json = auth_payload.json() assert auth_json["protocol_auth_enabled"] is True + assert auth_json["admin_session_secret"] == config.admin.session_secret assert auth_json["protocol_session_count"] >= 1 session = next(item for item in auth_json["protocol_sessions"] if item["hawk_id"] == issued["rriot"]["u"]) diff --git a/tests/test_protocol_auth.py b/tests/test_protocol_auth.py index 9ad762c..79fa895 100644 --- a/tests/test_protocol_auth.py +++ b/tests/test_protocol_auth.py @@ -238,6 +238,24 @@ async def fail_submit_code(*, session_id: str, code: str) -> dict[str, object]: assert login_payload["email"] == "user@example.com" +def test_protocol_code_login_reuses_matching_snapshot_identity_for_reauth(tmp_path: Path) -> None: + supervisor, _paths = _build_supervisor(tmp_path) + client = TestClient(supervisor.app) + + login_response = client.post( + "/api/v5/auth/email/login/code", + json={"email": "user@example.com", "code": "123456", "baseUrl": supervisor.context.api_url()}, + ) + assert login_response.status_code == 200 + login_payload = login_response.json()["data"] + assert login_payload["rruid"] == "local-rruid-123" + assert login_payload["token"] != "local-token-123" + assert login_payload["rriot"]["u"] != "hawk-user-123" + assert login_payload["rriot"]["r"]["a"] == supervisor.context.api_url() + assert login_payload["rriot"]["r"]["m"] == supervisor.context.mqtt_url() + assert login_payload["rriot"]["r"]["l"] == supervisor.context.wood_url() + + def test_protocol_code_login_rejects_wrong_email_and_wrong_pin(tmp_path: Path) -> None: supervisor, _paths = _build_supervisor(tmp_path, with_snapshot=False) client = TestClient(supervisor.app) From 40b182132b5adda0aa790787d23ee98388db12b7 Mon Sep 17 00:00:00 2001 From: Luke Date: Sun, 3 May 2026 14:26:03 -0400 Subject: [PATCH 6/7] address feedback --- .gitignore | 3 +- README.md | 1 - docs/home_assistant.md | 16 +- roborock_local_server_addon/CHANGELOG.md | 2 +- scripts/export_home_assistant_dev_addon.py | 190 --------------------- scripts/sync_home_assistant_dev_addon.ps1 | 32 ---- src/roborock_local_server/config.py | 53 +++++- src/roborock_local_server/ha_addon.py | 9 +- tests/test_config.py | 66 +++++++ tests/test_ha_addon.py | 29 ++++ tests/test_ha_addon_export.py | 31 ---- tests/test_protocol_auth.py | 23 +++ tests/test_version_sync.py | 7 + 13 files changed, 181 insertions(+), 281 deletions(-) delete mode 100644 scripts/export_home_assistant_dev_addon.py delete mode 100644 scripts/sync_home_assistant_dev_addon.ps1 delete mode 100644 tests/test_ha_addon_export.py diff --git a/.gitignore b/.gitignore index fb91cde..1e8c21e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,9 +4,10 @@ __pycache__/ *.pyd .pytest_cache/ dist/ +dev/ data/ site/ secrets/ config.toml mitm_logs -.tmp* \ No newline at end of file +.tmp* diff --git a/README.md b/README.md index 4622ed2..65894b7 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,6 @@ Additional docs: - [Docs index](docs/index.md) - [Tested vacuums](docs/tested_vacuums.md) - [Home Assistant](docs/home_assistant.md) -- [Home Assistant app files](roborock_local_server_addon/config.yaml) - [Using the Roborock App](docs/roborock_app.md) - [Custom MQTT](docs/custom_mqtt.md) - [Custom certificate management](docs/custom_cert_management.md) diff --git a/docs/home_assistant.md b/docs/home_assistant.md index 3cf4302..5ae5680 100644 --- a/docs/home_assistant.md +++ b/docs/home_assistant.md @@ -2,23 +2,9 @@ Use this after [Installation](installation.md) and [Onboarding](onboarding.md) if you want Home Assistant to talk to your local stack. -## Testing unpublished changes on a real Home Assistant instance - -If you want Home Assistant to build your current local branch instead of pulling the published GHCR image: - -1. Export a self-contained local add-on repository: - - `uv run python scripts/export_home_assistant_dev_addon.py` -2. Copy the generated folder `dist/home_assistant_dev_addon_repo/` to your Home Assistant host under: - - `/addons/local_roborock_server_dev_repo/` -3. In Home Assistant, open **Settings -> Add-ons -> App Store** and refresh. -4. Open the **Local add-ons** repository and install **Roborock Local Server Dev**. -5. Fill the app options and start it. - -This path is for unpublished development work. It bundles your current `src/` tree into the add-on so Home Assistant can build it locally on the real device. - ## Option 1: Home Assistant App (same GHCR image) -This repository contains a Home Assistant app definition at `roborock_local_server_addon/` that uses: +You can install the Home Assistant app from this repository. It uses: - `ghcr.io/python-roborock/local_roborock_server` diff --git a/roborock_local_server_addon/CHANGELOG.md b/roborock_local_server_addon/CHANGELOG.md index 305b1e3..d336ed9 100644 --- a/roborock_local_server_addon/CHANGELOG.md +++ b/roborock_local_server_addon/CHANGELOG.md @@ -1,5 +1,5 @@ # Changelog -## 0.0.2-rc1 +## 0.0.2-rc6 - Initial Home Assistant app manifest using the shared GHCR image. diff --git a/scripts/export_home_assistant_dev_addon.py b/scripts/export_home_assistant_dev_addon.py deleted file mode 100644 index a99eb89..0000000 --- a/scripts/export_home_assistant_dev_addon.py +++ /dev/null @@ -1,190 +0,0 @@ -from __future__ import annotations - -import argparse -import shutil -from pathlib import Path - -from roborock_local_server import __version__ - - -REPO_ROOT = Path(__file__).resolve().parents[1] -DEFAULT_OUTPUT_DIR = REPO_ROOT / "dist" / "home_assistant_dev_addon_repo" -DEFAULT_ADDON_SLUG = "roborock_local_server_dev" -DEFAULT_ADDON_NAME = "Roborock Local Server Dev" - -REPOSITORY_YAML = """name: Roborock Local Server Dev Apps -url: "https://github.com/Python-roborock/local_roborock_server" -maintainer: Luke Lashley -""" - -def _addon_config_yaml(*, addon_name: str, addon_slug: str) -> str: - return f"""name: {addon_name} -version: "{__version__}" -slug: {addon_slug} -description: Local-build development app for testing unpublished Roborock Local Server changes in Home Assistant. -url: "https://github.com/Python-roborock/local_roborock_server" -startup: services -boot: auto -init: false -arch: - - amd64 - - aarch64 -ports: - 555/tcp: 555 - 8881/tcp: 8881 -ports_description: - 555/tcp: Roborock HTTPS API - 8881/tcp: Roborock MQTT TLS proxy -map: - - ssl:ro - - addon_config:rw - - all_addon_configs:ro -webui: "https://[HOST]:[PORT:555]/admin" -options: - stack_fqdn: "api-roborock.example.com" - https_port: 555 - mqtt_tls_port: 8881 - region: "us" - tls_mode: "provided" - tls_base_domain: "" - tls_email: "" - cloudflare_token: "" - cert_file: "/ssl/fullchain.pem" - key_file: "/ssl/privkey.pem" - admin_password: "" - protocol_login_email: "" - protocol_login_pin: "" -schema: - stack_fqdn: str - https_port: port - mqtt_tls_port: port - region: list(us|eu|cn|ru) - tls_mode: list(provided|cloudflare_acme) - tls_base_domain: str - tls_email: str - cloudflare_token: str - cert_file: str - key_file: str - admin_password: password - protocol_login_email: email - protocol_login_pin: password -""" - -def _addon_docs_md(*, addon_name: str) -> str: - return f"""# {addon_name} - -This local-build Home Assistant app is exported from your current working tree so you can test unpublished changes on a real Home Assistant instance. - -It publishes two TLS ports directly: `555/tcp` for the Roborock HTTPS API and `8881/tcp` for the Roborock MQTT TLS proxy. - -## Install - -1. Run `uv run python scripts/export_home_assistant_dev_addon.py`. -2. Copy the generated repository folder to your Home Assistant host under `/addons/local_roborock_server_dev_repo/`. -3. In Home Assistant, open **Settings -> Add-ons -> App Store** and refresh. -4. Open the **Local add-ons** repository and install **{addon_name}**. -5. Set `stack_fqdn`, `admin_password`, `protocol_login_email`, `protocol_login_pin`, and your TLS settings. -6. Start the app and open `https://:555/admin`. - -This app does not auto-edit Home Assistant's Roborock config entry. Update `config/.storage/core.config_entries` so Home Assistant points to your local stack URLs. -""" - -ADDON_CHANGELOG_MD = f"""# Changelog - -## {__version__} - -- Exported from the current local working tree for Home Assistant dev testing. -""" - -ADDON_DOCKERFILE = """FROM python:3.11-slim - -RUN apt-get update \\ - && apt-get install -y --no-install-recommends \\ - ca-certificates \\ - curl \\ - mosquitto \\ - openssl \\ - && rm -rf /var/lib/apt/lists/* - -RUN mkdir -p /opt/acme.sh \\ - && curl -fsSL https://github.com/acmesh-official/acme.sh/archive/refs/heads/master.tar.gz \\ - | tar -xz --strip-components=1 -C /opt/acme.sh \\ - && chmod +x /opt/acme.sh/acme.sh \\ - && ln -sf /opt/acme.sh/acme.sh /usr/local/bin/acme.sh - -WORKDIR /app - -COPY app/pyproject.toml app/README.md /app/ -COPY app/src /app/src - -RUN pip install --no-cache-dir /app - -EXPOSE 555 8881 - -CMD ["python", "-m", "roborock_local_server.container_entrypoint"] -""" - -ADDON_DOCKERIGNORE = """__pycache__/ -*.pyc -*.pyo -*.pyd -""" - - -def _write_text(path: Path, content: str) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(content, encoding="utf-8") - - -def export_repository(output_dir: Path, *, addon_slug: str = DEFAULT_ADDON_SLUG, addon_name: str = DEFAULT_ADDON_NAME) -> Path: - if output_dir.exists(): - shutil.rmtree(output_dir) - output_dir.mkdir(parents=True, exist_ok=True) - - addon_dir = output_dir / addon_slug - app_dir = addon_dir / "app" - app_dir.mkdir(parents=True, exist_ok=True) - - _write_text(output_dir / "repository.yaml", REPOSITORY_YAML) - _write_text(addon_dir / "config.yaml", _addon_config_yaml(addon_name=addon_name, addon_slug=addon_slug)) - _write_text(addon_dir / "DOCS.md", _addon_docs_md(addon_name=addon_name)) - _write_text(addon_dir / "CHANGELOG.md", ADDON_CHANGELOG_MD) - _write_text(addon_dir / "Dockerfile", ADDON_DOCKERFILE) - _write_text(addon_dir / ".dockerignore", ADDON_DOCKERIGNORE) - - shutil.copy2(REPO_ROOT / "pyproject.toml", app_dir / "pyproject.toml") - shutil.copy2(REPO_ROOT / "README.md", app_dir / "README.md") - shutil.copytree(REPO_ROOT / "src", app_dir / "src") - - return output_dir - - -def main() -> int: - parser = argparse.ArgumentParser( - description="Export a self-contained Home Assistant local add-on repo from the current working tree." - ) - parser.add_argument( - "--output-dir", - type=Path, - default=DEFAULT_OUTPUT_DIR, - help=f"Output directory for the generated local add-on repository. Default: {DEFAULT_OUTPUT_DIR}", - ) - parser.add_argument( - "--addon-slug", - default=DEFAULT_ADDON_SLUG, - help=f"Addon slug/folder name to export. Default: {DEFAULT_ADDON_SLUG}", - ) - parser.add_argument( - "--addon-name", - default=DEFAULT_ADDON_NAME, - help=f"Addon display name to export. Default: {DEFAULT_ADDON_NAME}", - ) - args = parser.parse_args() - output_dir = args.output_dir.resolve() - export_repository(output_dir, addon_slug=args.addon_slug, addon_name=args.addon_name) - print(output_dir) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/sync_home_assistant_dev_addon.ps1 b/scripts/sync_home_assistant_dev_addon.ps1 deleted file mode 100644 index 847ca8b..0000000 --- a/scripts/sync_home_assistant_dev_addon.ps1 +++ /dev/null @@ -1,32 +0,0 @@ -param( - [string]$HomeAssistantHost = "192.168.20.199", - [string]$AddonSlug = "roborock_local_server_dev" -) - -$ErrorActionPreference = "Stop" - -$repoRoot = Split-Path -Parent $PSScriptRoot -$tempExportRoot = Join-Path $repoRoot "dist\ha_sync_export" -$exportedAddonDir = Join-Path $tempExportRoot $AddonSlug -$haLocalAddonsRoot = "\\$HomeAssistantHost\addons\local" -$haAddonDir = Join-Path $haLocalAddonsRoot $AddonSlug - -if (-not (Test-Path $haLocalAddonsRoot)) { - throw "Home Assistant local add-on path is not reachable: $haLocalAddonsRoot" -} - -uv run python "$PSScriptRoot\export_home_assistant_dev_addon.py" --output-dir $tempExportRoot - -if (-not (Test-Path $exportedAddonDir)) { - throw "Export completed but addon directory was not found: $exportedAddonDir" -} - -New-Item -ItemType Directory -Path $haAddonDir -Force | Out-Null - -robocopy $exportedAddonDir $haAddonDir /MIR /NFL /NDL /NJH /NJS /NP | Out-Null -$robocopyExitCode = $LASTEXITCODE -if ($robocopyExitCode -ge 8) { - throw "robocopy failed with exit code $robocopyExitCode" -} - -Write-Output "Synced $exportedAddonDir -> $haAddonDir" diff --git a/src/roborock_local_server/config.py b/src/roborock_local_server/config.py index c707c8d..43a1190 100644 --- a/src/roborock_local_server/config.py +++ b/src/roborock_local_server/config.py @@ -4,7 +4,9 @@ from dataclasses import dataclass from pathlib import Path +import re import tomllib +from urllib.parse import urlsplit @dataclass(frozen=True) @@ -99,12 +101,34 @@ def _require_non_empty(value: object, field_name: str) -> str: return text -def _require_stack_fqdn(value: object, field_name: str) -> str: +_HOST_RE = re.compile(r"^[a-z0-9.-]+$") + + +def _normalize_hostname(value: object, field_name: str, *, require_api_prefix: bool = False) -> str: text = _require_non_empty(value, field_name) - hostname = text.split("/", 1)[0].split(":", 1)[0].strip().lower() - if not hostname.startswith("api-"): + if "://" in text: + parsed = urlsplit(text) + candidate = parsed.hostname or "" + else: + candidate = text.split("/", 1)[0].strip() + if ":" in candidate: + candidate = candidate.split(":", 1)[0].strip() + normalized = candidate.strip().strip(".").lower() + if normalized.startswith("*."): + normalized = normalized[2:].strip() + if not normalized: + raise ValueError(f"{field_name} is required") + if " " in normalized or not _HOST_RE.fullmatch(normalized): + raise ValueError(f"{field_name} must be a hostname without a scheme or path") + if "." not in normalized: + raise ValueError(f"{field_name} must be a fully qualified domain name") + if require_api_prefix and not normalized.startswith("api-"): raise ValueError(f"{field_name} must start with api-") - return text + return normalized + + +def _require_stack_fqdn(value: object, field_name: str) -> str: + return _normalize_hostname(value, field_name, require_api_prefix=True) def _as_int(value: object, field_name: str, default: int) -> int: @@ -116,6 +140,13 @@ def _as_int(value: object, field_name: str, default: int) -> int: raise ValueError(f"{field_name} must be an integer") from exc +def _as_port(value: object, field_name: str, default: int) -> int: + candidate = _as_int(value, field_name, default) + if not (1 <= candidate <= 65535): + raise ValueError(f"{field_name} must be between 1 and 65535") + return candidate + + def _as_bool(value: object, default: bool) -> bool: if value is None: return default @@ -160,8 +191,8 @@ def load_config(path: str | Path) -> AppConfig: network=NetworkConfig( stack_fqdn=_require_stack_fqdn(network.get("stack_fqdn"), "network.stack_fqdn"), bind_host=str(network.get("bind_host", "0.0.0.0")).strip() or "0.0.0.0", - https_port=_as_int(network.get("https_port"), "network.https_port", 555), - mqtt_tls_port=_as_int(network.get("mqtt_tls_port"), "network.mqtt_tls_port", 8881), + https_port=_as_port(network.get("https_port"), "network.https_port", 555), + mqtt_tls_port=_as_port(network.get("mqtt_tls_port"), "network.mqtt_tls_port", 8881), region=str(network.get("region", "us")).strip().lower() or "us", localkey=str(network.get("localkey", "")).strip(), duid=str(network.get("duid", "")).strip(), @@ -172,7 +203,7 @@ def load_config(path: str | Path) -> AppConfig: broker=BrokerConfig( mode=broker_mode, host=broker_host, - port=_as_int(broker.get("port"), "broker.port", broker_port_default), + port=_as_port(broker.get("port"), "broker.port", broker_port_default), mosquitto_binary=str(broker.get("mosquitto_binary", "mosquitto")).strip() or "mosquitto", enable_topic_bridge=_as_bool(broker.get("enable_topic_bridge"), True), ), @@ -181,7 +212,11 @@ def load_config(path: str | Path) -> AppConfig: ), tls=TlsConfig( mode=tls_mode, - base_domain=str(tls.get("base_domain", "")).strip(), + base_domain=( + _normalize_hostname(tls.get("base_domain"), "tls.base_domain") + if str(tls.get("base_domain", "")).strip() + else "" + ), email=str(tls.get("email", "")).strip(), cloudflare_token_file=str(tls.get("cloudflare_token_file", "")).strip(), renew_days_before=_as_int(tls.get("renew_days_before"), "tls.renew_days_before", 30), @@ -212,7 +247,7 @@ def load_config(path: str | Path) -> AppConfig: _require_non_empty(config.broker.host, "broker.host") if config.tls.mode == "cloudflare_acme": - _require_non_empty(config.tls.base_domain, "tls.base_domain") + _normalize_hostname(config.tls.base_domain, "tls.base_domain") _require_non_empty(config.tls.email, "tls.email") _require_non_empty(config.tls.cloudflare_token_file, "tls.cloudflare_token_file") else: diff --git a/src/roborock_local_server/ha_addon.py b/src/roborock_local_server/ha_addon.py index 931542c..6148613 100644 --- a/src/roborock_local_server/ha_addon.py +++ b/src/roborock_local_server/ha_addon.py @@ -129,7 +129,12 @@ def _load_existing_admin_session_secret(config_path: Path) -> str: return secret if len(secret) >= 24 else "" -def _render_config_toml(*, options: dict[str, Any], config_path: Path, cloudflare_token_path: Path) -> str: +def _render_config_toml( + *, + options: dict[str, Any], + config_path: Path, + cloudflare_token_path: Path, +) -> tuple[str, str]: merged = dict(DEFAULT_OPTIONS) merged.update(options) @@ -269,6 +274,8 @@ def write_config_from_home_assistant_options( cloudflare_token_path.write_text(cloudflare_token, encoding="utf-8") if os.name != "nt": cloudflare_token_path.chmod(0o600) + elif cloudflare_token_path.exists(): + cloudflare_token_path.unlink() return config_path diff --git a/tests/test_config.py b/tests/test_config.py index 3b70c26..44701c7 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -134,3 +134,69 @@ def test_load_config_rejects_external_tls(tmp_path: Path) -> None: with pytest.raises(ValueError, match="external_tls"): load_config(config_file) + + +def test_load_config_normalizes_stack_fqdn_and_validates_cloudflare_base_domain(tmp_path: Path) -> None: + config_file = tmp_path / "config.toml" + config_file.write_text( + """ +[network] +stack_fqdn = "https://API-Roborock.Example.com:8443/path" + +[broker] +mode = "embedded" + +[storage] +data_dir = "data" + +[tls] +mode = "cloudflare_acme" +base_domain = "https://Example.com/path" +email = "acme@example.com" +cloudflare_token_file = "secrets/cloudflare_token" + +[admin] +password_hash = "pbkdf2_sha256$600000$abc$def" +session_secret = "abcdefghijklmnopqrstuvwxyz123456" +protocol_login_email = "user@example.com" +protocol_login_pin_hash = "pbkdf2_sha256$600000$ghi$jkl" + """.strip(), + encoding="utf-8", + ) + + config = load_config(config_file) + + assert config.network.stack_fqdn == "api-roborock.example.com" + assert config.tls.base_domain == "example.com" + + +def test_load_config_rejects_invalid_ports(tmp_path: Path) -> None: + config_file = tmp_path / "config.toml" + config_file.write_text( + """ +[network] +stack_fqdn = "api-roborock.example.com" +https_port = 70000 + +[broker] +mode = "embedded" + +[storage] +data_dir = "data" + +[tls] +mode = "provided" +cert_file = "certs/fullchain.pem" +key_file = "certs/privkey.pem" + +[admin] +password_hash = "pbkdf2_sha256$600000$abc$def" +session_secret = "abcdefghijklmnopqrstuvwxyz123456" +protocol_login_email = "user@example.com" +protocol_login_pin_hash = "pbkdf2_sha256$600000$ghi$jkl" + """.strip(), + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="network.https_port must be between 1 and 65535"): + load_config(config_file) diff --git a/tests/test_ha_addon.py b/tests/test_ha_addon.py index 2bd7399..7aafd6b 100644 --- a/tests/test_ha_addon.py +++ b/tests/test_ha_addon.py @@ -304,3 +304,32 @@ def test_write_config_from_home_assistant_options_requires_api_prefix(tmp_path: options_path=options_path, config_path=config_path, ) + + +def test_write_config_from_home_assistant_options_removes_stale_cloudflare_token_when_using_provided_tls( + tmp_path: Path, +) -> None: + options_path = tmp_path / "options.json" + config_path = tmp_path / "config.toml" + token_path = tmp_path / "run" / "secrets" / "cloudflare_token" + token_path.parent.mkdir(parents=True, exist_ok=True) + token_path.write_text("stale-token", encoding="utf-8") + + _write_options( + options_path, + { + "stack_fqdn": "api-roborock.example.com", + "tls_mode": "provided", + "admin_password": "secret", + "protocol_login_email": "user@example.com", + "protocol_login_pin": "654321", + }, + ) + + write_config_from_home_assistant_options( + options_path=options_path, + config_path=config_path, + cloudflare_token_path=token_path, + ) + + assert token_path.exists() is False diff --git a/tests/test_ha_addon_export.py b/tests/test_ha_addon_export.py deleted file mode 100644 index 10002df..0000000 --- a/tests/test_ha_addon_export.py +++ /dev/null @@ -1,31 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -from roborock_local_server import __version__ - -from scripts.export_home_assistant_dev_addon import export_repository - - -def test_export_repository_writes_local_dev_addon(tmp_path: Path) -> None: - output_dir = export_repository(tmp_path / "ha-addon") - - repository_yaml = output_dir / "repository.yaml" - addon_dir = output_dir / "roborock_local_server_dev" - config_yaml = addon_dir / "config.yaml" - dockerfile = addon_dir / "Dockerfile" - exported_init = addon_dir / "app" / "src" / "roborock_local_server" / "__init__.py" - - assert repository_yaml.exists() - assert config_yaml.exists() - assert dockerfile.exists() - assert exported_init.exists() - - config_text = config_yaml.read_text(encoding="utf-8") - assert f'version: "{__version__}"' in config_text - assert "slug: roborock_local_server_dev" in config_text - assert 'image: "ghcr.io/python-roborock/local_roborock_server"' not in config_text - assert "protocol_auth_enabled:" not in config_text - - init_text = exported_init.read_text(encoding="utf-8") - assert f'__version__ = "{__version__}"' in init_text diff --git a/tests/test_protocol_auth.py b/tests/test_protocol_auth.py index 79fa895..55aacf8 100644 --- a/tests/test_protocol_auth.py +++ b/tests/test_protocol_auth.py @@ -256,6 +256,29 @@ def test_protocol_code_login_reuses_matching_snapshot_identity_for_reauth(tmp_pa assert login_payload["rriot"]["r"]["l"] == supervisor.context.wood_url() +def test_protocol_code_login_falls_back_to_local_identity_when_snapshot_email_differs(tmp_path: Path) -> None: + supervisor, _paths = _build_supervisor(tmp_path) + client = TestClient(supervisor.app) + snapshot = json.loads(supervisor.paths.cloud_snapshot_path.read_text(encoding="utf-8")) + snapshot.setdefault("meta", {})["username"] = "other@example.com" + snapshot.setdefault("user_data", {})["email"] = "other@example.com" + supervisor.paths.cloud_snapshot_path.write_text(json.dumps(snapshot, indent=2) + "\n", encoding="utf-8") + + expected_identity = supervisor._local_protocol_identity() + login_response = client.post( + "/api/v5/auth/email/login/code", + json={"email": "user@example.com", "code": "123456", "baseUrl": supervisor.context.api_url()}, + ) + assert login_response.status_code == 200 + login_payload = login_response.json()["data"] + assert login_payload["rruid"] == expected_identity["rruid"] + assert login_payload["email"] == expected_identity["email"] + assert login_payload["rruid"] != "local-rruid-123" + assert login_payload["rriot"]["r"]["a"] == supervisor.context.api_url() + assert login_payload["rriot"]["r"]["m"] == supervisor.context.mqtt_url() + assert login_payload["rriot"]["r"]["l"] == supervisor.context.wood_url() + + def test_protocol_code_login_rejects_wrong_email_and_wrong_pin(tmp_path: Path) -> None: supervisor, _paths = _build_supervisor(tmp_path, with_snapshot=False) client = TestClient(supervisor.app) diff --git a/tests/test_version_sync.py b/tests/test_version_sync.py index 48dcc41..5b4ab09 100644 --- a/tests/test_version_sync.py +++ b/tests/test_version_sync.py @@ -21,3 +21,10 @@ def test_home_assistant_addon_version_matches_package_version() -> None: match = re.search(r'^version:\s*"([^"]+)"\s*$', addon_config, re.MULTILINE) assert match is not None assert match.group(1) == __version__ + + +def test_home_assistant_addon_changelog_tracks_package_version() -> None: + changelog_text = Path("roborock_local_server_addon/CHANGELOG.md").read_text(encoding="utf-8") + match = re.search(r"^##\s+([^\s]+)\s*$", changelog_text, re.MULTILINE) + assert match is not None + assert match.group(1) == __version__ From c63bca08cf04670588dbe36068abed43a88294e5 Mon Sep 17 00:00:00 2001 From: Luke Date: Sun, 3 May 2026 14:46:33 -0400 Subject: [PATCH 7/7] update docs --- README.md | 15 ++-- docs/home_assistant.md | 71 ++++++++++------ docs/index.md | 9 +- docs/installation.md | 101 +++++++++++++---------- mkdocs.yml | 2 +- roborock_local_server_addon/CHANGELOG.md | 2 +- roborock_local_server_addon/DOCS.md | 13 +-- 7 files changed, 130 insertions(+), 83 deletions(-) diff --git a/README.md b/README.md index 65894b7..7a0e02c 100644 --- a/README.md +++ b/README.md @@ -20,25 +20,26 @@ This project is in VERY EARLY BETA!!! Do not use this repository unless you are ## Requirements -- Docker with `docker compose` -- `uv` -- a Linux server or Linux VM on your LAN - a domain you control +- a place to run the stack on your LAN +- either Docker Compose or a Home Assistant installation that supports add-ons +- a second machine for onboarding later - a Cloudflare API token with DNS edit access for the zone if you want automatic certificate renewal ## Getting Started Start here if this is your first time setting up the stack: -1. [Installation](docs/installation.md) for requirements, network setup, configuration, and starting the stack. -2. [Cloudflare setup](docs/cloudflare_setup.md) if you want Cloudflare DNS-01 auto-renew for certificates. -3. [Onboarding](docs/onboarding.md) to pair a vacuum from a second machine after the server is running. +1. [Installation](docs/installation.md) for the shared requirements, network setup, and Docker Compose install path. +2. [Home Assistant](docs/home_assistant.md) if you want to install the stack as a Home Assistant add-on instead of Docker Compose. +3. [Cloudflare setup](docs/cloudflare_setup.md) if you want Cloudflare DNS-01 auto-renew for certificates. +4. [Onboarding](docs/onboarding.md) to pair a vacuum from a second machine after the server is running. Additional docs: - [Docs index](docs/index.md) - [Tested vacuums](docs/tested_vacuums.md) -- [Home Assistant](docs/home_assistant.md) +- [Home Assistant](docs/home_assistant.md) for the add-on install path and Home Assistant integration rewiring - [Using the Roborock App](docs/roborock_app.md) - [Custom MQTT](docs/custom_mqtt.md) - [Custom certificate management](docs/custom_cert_management.md) diff --git a/docs/home_assistant.md b/docs/home_assistant.md index 5ae5680..91c91b9 100644 --- a/docs/home_assistant.md +++ b/docs/home_assistant.md @@ -1,49 +1,74 @@ # Home Assistant -Use this after [Installation](installation.md) and [Onboarding](onboarding.md) if you want Home Assistant to talk to your local stack. +This page covers two separate Home Assistant tasks: -## Option 1: Home Assistant App (same GHCR image) +- installing the local stack as a Home Assistant add-on +- repointing Home Assistant's Roborock integration to a local stack that is already running -You can install the Home Assistant app from this repository. It uses: +## Install As A Home Assistant Add-on + +This is an installation method, not a post-install integration step. The add-on uses the same container image as the Docker deployment: - `ghcr.io/python-roborock/local_roborock_server` -To install it as a custom repository: +### Install Steps + +1. Open the Home Assistant Add-on Store. +2. Add this repository under **Repositories**: -1. In Home Assistant, go to **Settings -> Apps -> App Store -> Repositories**. -2. Add this repository URL: - `https://github.com/Python-roborock/local_roborock_server` + 3. Install **Roborock Local Server**. -4. Fill the app options (`stack_fqdn`, `admin_password`, `protocol_login_email`, `protocol_login_pin`, TLS settings). -5. Start the app. +4. Fill the add-on options: + + - `stack_fqdn` + - `https_port` + - `mqtt_tls_port` + - `region` + - `admin_password` + - `protocol_login_email` + - `protocol_login_pin` + - TLS settings: + - `tls_mode = provided` with `cert_file` and `key_file` + - or `tls_mode = cloudflare_acme` with `tls_base_domain`, `tls_email`, and `cloudflare_token` -Then open: +5. Start the add-on. -- `https://:555/admin` (or your configured HTTPS port) +Then open the admin dashboard at your configured stack hostname, for example: -If you need the MITM protocol sync secret for the Roborock app flow, sign in to the admin page and open the **Protocol Auth** section. The dashboard now shows the active `admin.session_secret` with a copy button, so you do not need to inspect `/data/config.toml` manually. +- `https://api-roborock.example.com:555/admin` -Important: installing the Home Assistant app does not automatically rewrite your Roborock integration entry. You still need to update `config/.storage/core.config_entries` endpoint values as shown below so Home Assistant points at your local stack. +Do not use the Home Assistant UI hostname unless it is the same hostname covered by the TLS certificate you configured for `stack_fqdn`. -Notes: +If you need the MITM protocol sync secret for the Roborock app flow, sign in to the admin page and open **Protocol Auth**. The dashboard shows the active `admin.session_secret`, so you do not need to inspect `/data/config.toml` manually. +### Add-on Behavior + +- The add-on always runs the embedded MQTT broker and keeps the topic bridge enabled. - The add-on terminates TLS itself and publishes two ports: HTTPS on `https_port` and MQTT/TLS on `mqtt_tls_port`. -- If you use Home Assistant's Nginx Proxy Manager add-on for certificate issuance, this add-on can read those PEM files directly through `/all_addon_configs/a0d7b954_nginxproxymanager/letsencrypt/live/...`. +- If you already manage certificates in another Home Assistant add-on such as Nginx Proxy Manager, you can point `cert_file` and `key_file` at those PEM files through `/all_addon_configs/...`. +- Installing the add-on does **not** automatically rewrite Home Assistant's Roborock integration entry. + +## Repoint The Home Assistant Roborock Integration + +This applies whether your local stack is running via Docker Compose or via the Home Assistant add-on. + +1. Open your Home Assistant configuration directory and locate `.storage/core.config_entries`. -## Option 2: Existing Docker deployment + On many Home Assistant systems this file is at `/config/.storage/core.config_entries`. -If you keep using Docker Compose, edit your Home Assistant Roborock config entry at: +2. Find the Roborock entry and replace the endpoint values with your local stack URLs: -- `config/.storage/core.config_entries` + - `base_url` -> `https://api-roborock.example.com:555` + - `"a"` -> `https://api-roborock.example.com:555` + - `"l"` -> `https://api-roborock.example.com:555` + - `"m"` -> `ssl://api-roborock.example.com:8881` -Find `"roborock.com"` and replace endpoint values with your local stack URLs: + The current server advertises the same hostname for HTTPS and MQTT/TLS, so `"m"` should normally use the same `stack_fqdn`, not a separate `mqtt-...` hostname. -- `base_url` -> `https://api-roborock.example.com:555` -- `"a"` -> `https://api-roborock.example.com:555` -- `"l"` -> `https://api-roborock.example.com:555` -- `"m"` -> `ssl://mqtt-roborock.example.com:8881` +3. If you changed `https_port` or `mqtt_tls_port`, use those values instead. -If you changed `network.https_port` or `network.mqtt_tls_port`, use those values instead. +4. Restart Home Assistant so the integration reloads the updated endpoints. ## Related Docs diff --git a/docs/index.md b/docs/index.md index c52cfeb..6fc02c5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,9 +4,12 @@ Use this page as the main docs hub for setup, onboarding, and follow-up guides. ## Start Here -1. [Installation](installation.md) for requirements, network setup, configuration, and starting the stack. -2. [Cloudflare setup](cloudflare_setup.md) if you want Cloudflare DNS-01 auto-renew for certificates. -3. [Onboarding](onboarding.md) to pair a vacuum from a second machine after the server is running. +1. Read [Installation](installation.md) for the shared requirements and network setup. +2. Choose an install method: + - finish the Docker Compose steps in [Installation](installation.md) + - or use [Home Assistant](home_assistant.md) to install the stack as a Home Assistant add-on +3. Use [Cloudflare setup](cloudflare_setup.md) if you want Cloudflare DNS-01 auto-renew for certificates. +4. Run [Onboarding](onboarding.md) from a second machine after the server is running. ## Support This Project diff --git a/docs/installation.md b/docs/installation.md index b3ddba2..227cfae 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -1,66 +1,77 @@ # Installation -Start here for a first-time setup. After the stack is running, continue with [Onboarding](onboarding.md) to pair a vacuum. +Start here for a first-time setup. The project supports two installation methods: -## Requirements +- Docker Compose on your own Linux host or VM +- the Home Assistant add-on from this repository + +After the stack is running, continue with [Onboarding](onboarding.md) to pair a vacuum. + +## Shared Requirements -- Docker with `docker compose` -- Python (I recommend installing [uv](https://docs.astral.sh/uv/getting-started/installation/)) -- Two machines - one to run the server and one to do the onboarding - A domain name that you own -- A machine that can host the stack's HTTPS and MQTT TLS ports internally on your network. The defaults are `555` and `8881`. +- A place to run the stack on your LAN +- A second machine for onboarding later +- A network that can host the stack's HTTPS and MQTT TLS ports internally. The defaults are `555` and `8881`. - A Cloudflare API token with DNS edit access for the zone if you want Cloudflare DNS-01 auto-renew. See [Cloudflare setup](cloudflare_setup.md). ## Network Setup -1. Pick a URL for this application. It needs to be a subdomain of a domain you own, and it **must** start with `api-`. It does NOT need to be accessible outside your network - in fact, I strongly recommend you keep it internal only for now. +1. Pick a hostname for this application. It must be a subdomain of a domain you own, and it **must** start with `api-`. - For example, if you own `example.com`, I'd recommend `api-roborock.example.com`. Throughout the rest of the docs we'll refer to this as the **FQDN**. If you follow this format, you can just replace `example.com` with your real domain wherever you see it. + For example, if you own `example.com`, use `api-roborock.example.com`. Throughout the docs this is the **stack FQDN**. 2. Your network **must** handle its own DNS for the network the vacuum connects to. If it uses an external DNS server like `8.8.8.8`, this will not work. -3. Create DNS records pointing to your server's local IP address for both `api-roborock.example.com` and `mqtt-roborock.example.com`. +3. Create a DNS record pointing your stack FQDN to the local IP of the machine running the stack. -## Docker Setup + With the current server behavior, the same hostname is advertised for both HTTPS and MQTT/TLS, so you do not need a separate `mqtt-...` hostname unless you have built your own custom client routing around one. -1. Clone this repository: +## Method 1: Docker Compose -`git clone https://github.com/Python-roborock/local_roborock_server` +### Additional Requirements -2. Change into the project folder. +- Docker with `docker compose` +- Python +- [uv](https://docs.astral.sh/uv/getting-started/installation/) -```bash -cd local_roborock_server -``` +### Steps -3. Install the project dependencies. +1. Clone this repository: -```bash -uv sync -``` + ```bash + git clone https://github.com/Python-roborock/local_roborock_server + cd local_roborock_server + ``` -4. Run the setup wizard. +2. Install the project dependencies: -```bash -uv run roborock-local-server configure -``` + ```bash + uv sync + ``` + +3. Run the setup wizard: + + ```bash + uv run roborock-local-server configure + ``` -The wizard asks only for: + The wizard asks for: -- your `stack_fqdn` (the URL for your server - must start with `api-`) -- your HTTPS and MQTT TLS ports if you do not want the defaults `555` and `8881` -- embedded MQTT or your own broker -- whether to use Cloudflare DNS-01 auto-renew -- your admin password -- your Home Assistant/app login email and 6-digit PIN + - `stack_fqdn` (must start with `api-`) + - HTTPS and MQTT TLS ports if you do not want the defaults `555` and `8881` + - embedded MQTT or your own broker + - whether to use Cloudflare DNS-01 auto-renew + - your admin password + - your Home Assistant/app login email and 6-digit PIN -It then writes `config.toml`, generates `admin.password_hash` and `admin.session_secret`, and if you chose Cloudflare it also writes `secrets/cloudflare_token`. + It then writes `config.toml`, generates `admin.password_hash` and `admin.session_secret`, and if you chose Cloudflare it also writes `secrets/cloudflare_token`. -5. If you chose external MQTT, fill in `broker.host` in `config.toml` before starting the stack. See [Custom MQTT](custom_mqtt.md). +4. If you chose external MQTT, fill in `broker.host` in `config.toml` before starting the stack. See [Custom MQTT](custom_mqtt.md). -6. If you skipped Cloudflare, put your certificate files in `data/certs/fullchain.pem` and `data/certs/privkey.pem`. See [Custom certificate management](custom_cert_management.md). +5. If you skipped Cloudflare, put your certificate files in `data/certs/fullchain.pem` and `data/certs/privkey.pem`. See [Custom certificate management](custom_cert_management.md). -7. Start the container: +6. Start the container: ```bash docker compose up -d --build @@ -74,15 +85,21 @@ It then writes `config.toml`, generates `admin.password_hash` and `admin.session docker compose up -d --build ``` -8. Go to the admin dashboard: `https://api-roborock.example.com:555/admin` by default, or `https://api-roborock.example.com:YOUR_HTTPS_PORT/admin` if you chose a custom HTTPS port. +## Method 2: Home Assistant Add-on + +Use [Home Assistant](home_assistant.md) as the installation guide if you want to run the stack as a Home Assistant add-on instead of Docker Compose. + +## After The Stack Starts + +1. Open the admin dashboard at `https://api-roborock.example.com:555/admin` by default, or `https://api-roborock.example.com:YOUR_HTTPS_PORT/admin` if you chose a custom HTTPS port. -9. Import your data from the cloud so things like routines and rooms will work. Enter your email in under cloud import, then hit send code. Once the code is returned enter the code and hit fetch data. +2. Import your data from the cloud so things like routines and rooms will work. Enter your email under cloud import, select **Send code**, then enter the returned code and select **Fetch data**. -10. For any routines that use zones, you need to re-save them so the server stores the zone data correctly. In the Roborock app, open each routine that has zones, click on the zone, tap **Edit**, click on any **Zone Cleaning** entry, then tap **Save**. Repeat for each zone in the routine. +3. For any routines that use zones, re-save them so the server stores the zone data correctly. In the Roborock app, open each routine that has zones, open the zone, tap **Edit**, open any **Zone Cleaning** entry, then tap **Save**. Repeat for each zone in the routine. ## Next Steps -- [Onboarding](onboarding.md) for pairing a new vacuum. -- [Home Assistant](home_assistant.md) if you want the local stack in Home Assistant. -- [Using the Roborock App](roborock_app.md) if you want to point the official app at your local stack. -- [Docs index](index.md) for the rest of the guides. +- [Onboarding](onboarding.md) for pairing a new vacuum +- [Home Assistant](home_assistant.md) if you want to repoint Home Assistant's Roborock integration to your local stack +- [Using the Roborock App](roborock_app.md) if you want to point the official app at your local stack +- [Docs index](index.md) for the rest of the guides diff --git a/mkdocs.yml b/mkdocs.yml index 0a06bb1..28ef104 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -50,10 +50,10 @@ nav: - Support This Project: support.md - Getting Started: - Installation: installation.md + - Home Assistant: home_assistant.md - Cloudflare Setup: cloudflare_setup.md - Onboarding: onboarding.md - Integrations: - - Home Assistant: home_assistant.md - Using the Roborock App: roborock_app.md - Reference: - Tested Vacuums: tested_vacuums.md diff --git a/roborock_local_server_addon/CHANGELOG.md b/roborock_local_server_addon/CHANGELOG.md index d336ed9..5fba8ba 100644 --- a/roborock_local_server_addon/CHANGELOG.md +++ b/roborock_local_server_addon/CHANGELOG.md @@ -2,4 +2,4 @@ ## 0.0.2-rc6 -- Initial Home Assistant app manifest using the shared GHCR image. +- Initial Home Assistant add-on manifest using the shared GHCR image. diff --git a/roborock_local_server_addon/DOCS.md b/roborock_local_server_addon/DOCS.md index db35d31..576e779 100644 --- a/roborock_local_server_addon/DOCS.md +++ b/roborock_local_server_addon/DOCS.md @@ -1,6 +1,6 @@ # Roborock Local Server -This app runs the same `ghcr.io/python-roborock/local_roborock_server` image used for Docker installs. +This add-on runs the same `ghcr.io/python-roborock/local_roborock_server` image used for Docker installs. It publishes two TLS ports directly: @@ -14,16 +14,17 @@ It publishes two TLS ports directly: 3. Choose TLS mode: - `provided`: set `cert_file` and `key_file` (defaults: `/ssl/fullchain.pem`, `/ssl/privkey.pem`) - `cloudflare_acme`: set `tls_base_domain`, `tls_email`, `cloudflare_token` -4. Start the app. +4. Start the add-on. The add-on always runs the embedded MQTT broker and keeps the topic bridge enabled. -Then open `https://:555/admin` (or your custom HTTPS port). +Then open `https://api-roborock.example.com:555/admin` using your configured `stack_fqdn` and HTTPS port. -This app package does not auto-edit Home Assistant's Roborock config entry. You still need to update `config/.storage/core.config_entries` endpoint values to your local stack URLs. +This add-on does not auto-edit Home Assistant's Roborock config entry. You still need to update `.storage/core.config_entries` so Home Assistant points at your local stack. ## Notes -- This app expects internal LAN-only usage. Do not expose directly to the internet. +- This add-on expects internal LAN-only usage. Do not expose it directly to the internet. - If you change `https_port` or `mqtt_tls_port`, update your DNS/clients to use those ports. -- If you already manage certificates in another Home Assistant app such as Nginx Proxy Manager, you can point `cert_file` and `key_file` at that app's certs through `/all_addon_configs/...`. Example: `/all_addon_configs/a0d7b954_nginxproxymanager/letsencrypt/live/npm-3/fullchain.pem`. +- The current server advertises the same hostname for HTTPS and MQTT/TLS, so Home Assistant's Roborock entry should normally use `ssl://api-roborock.example.com:8881`, not a separate `mqtt-...` hostname. +- If you already manage certificates in another Home Assistant add-on such as Nginx Proxy Manager, you can point `cert_file` and `key_file` at that add-on's certs through `/all_addon_configs/...`. Example: `/all_addon_configs/a0d7b954_nginxproxymanager/letsencrypt/live/npm-3/fullchain.pem`.