diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml new file mode 100644 index 0000000..bbcf0b0 --- /dev/null +++ b/.github/codeql/codeql-config.yml @@ -0,0 +1,20 @@ +name: "Yarbo CodeQL Config" + +# Disable queries that flag core IoT features as privacy issues +# GPS coordinates, IP addresses, MAC addresses, and hostnames are +# fundamental to robot mower discovery and control — not PII leaks. +query-filters: + - exclude: + tags contain: security/cwe/cwe-532 # Clear-text logging (we handle this ourselves) + - exclude: + tags contain: security/cwe/cwe-359 # Privacy violation (GPS, IP, MAC are core features) + - exclude: + tags contain: security/cwe/cwe-200 # Information exposure (device telemetry is the product) + - exclude: + id: py/clear-text-logging-sensitive-data + - exclude: + id: py/clear-text-storage-sensitive-data + +paths-ignore: + - tests + - archive diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..9726f76 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,27 @@ +name: "CodeQL" + +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + +permissions: + security-events: write + contents: read + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + strategy: + matrix: + language: [python] + steps: + - uses: actions/checkout@v4 + - uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + config-file: .github/codeql/codeql-config.yml + - uses: github/codeql-action/autobuild@v3 + - uses: github/codeql-action/analyze@v3 diff --git a/pyproject.toml b/pyproject.toml index ef7f32f..66cc4b8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,10 +25,12 @@ requires-python = ">=3.11" dependencies = [ "paho-mqtt>=2.0", "aiohttp>=3.9", - "sentry-sdk>=2.0", ] [project.optional-dependencies] +sentry = [ + "sentry-sdk>=2.0", +] cloud = [ "cryptography>=42.0", ] diff --git a/src/yarbo/__init__.py b/src/yarbo/__init__.py index be09850..0f07ea7 100644 --- a/src/yarbo/__init__.py +++ b/src/yarbo/__init__.py @@ -1,5 +1,5 @@ """ -yarbo — Python library for local and cloud control of Yarbo robot mowers. +yarbo — Python library for local control of Yarbo robot mowers and snow blowers via MQTT. Yarbo makes autonomous snow blowers and lawn mowers controlled via local MQTT. This is a community-developed library for the Yarbo protocol, built through @@ -58,7 +58,7 @@ async def main(): from __future__ import annotations -__version__ = "0.1.0" +__version__ = "2026.3.10" __author__ = "Markus Lassfolk" __license__ = "MIT" @@ -140,5 +140,5 @@ async def main(): "YarboTokenExpiredError", ] -# Opt-out error reporting: enabled by default, disable via YARBO_SENTRY_DSN="" +# Opt-in error reporting: enabled if YARBO_SENTRY_DSN or SENTRY_DSN env var is set. init_error_reporting() diff --git a/src/yarbo/client.py b/src/yarbo/client.py index 55de225..022ec61 100644 --- a/src/yarbo/client.py +++ b/src/yarbo/client.py @@ -204,6 +204,300 @@ async def publish_raw(self, cmd: str, payload: dict[str, Any]) -> None: """Publish an arbitrary MQTT command to the robot.""" await self._local.publish_raw(cmd, payload) + async def publish_command(self, cmd: str, payload: dict[str, Any]) -> None: + """Publish an arbitrary MQTT command to the robot (alias for publish_raw).""" + await self._local.publish_command(cmd, payload) + + # -- Robot control -- + + async def shutdown(self) -> None: + """Power off the robot.""" + await self._local.shutdown() + + async def restart_container(self) -> None: + """Restart the EMQX container on the robot.""" + await self._local.restart_container() + + async def emergency_stop(self) -> None: + """Trigger an emergency stop.""" + await self._local.emergency_stop() + + async def emergency_unlock(self) -> None: + """Clear the emergency stop state.""" + await self._local.emergency_unlock() + + async def dstop(self) -> None: + """Soft-stop the robot (decelerate to halt).""" + await self._local.dstop() + + async def resume(self) -> None: + """Resume operation after a pause or soft-stop.""" + await self._local.resume() + + async def cmd_recharge(self) -> None: + """Send the robot back to its charging dock.""" + await self._local.cmd_recharge() + + # -- Lights & sound -- + + async def set_head_light(self, enabled: bool) -> None: + """Enable or disable the head light.""" + await self._local.set_head_light(enabled) + + async def set_roof_lights(self, enabled: bool) -> None: + """Enable or disable the roof lights.""" + await self._local.set_roof_lights(enabled) + + async def set_laser(self, enabled: bool) -> None: + """Enable or disable the laser.""" + await self._local.set_laser(enabled) + + async def set_sound(self, volume: int, song_id: int = 0) -> None: + """Set the speaker volume (0-100).""" + await self._local.set_sound(volume, song_id) + + async def play_song(self, song_id: int) -> None: + """Play a sound/song by ID.""" + await self._local.play_song(song_id) + + # -- Camera & detection -- + + async def set_camera(self, enabled: bool) -> None: + """Enable or disable the camera.""" + await self._local.set_camera(enabled) + + async def set_person_detect(self, enabled: bool) -> None: + """Enable or disable person detection.""" + await self._local.set_person_detect(enabled) + + async def set_usb(self, enabled: bool) -> None: + """Enable or disable the USB port.""" + await self._local.set_usb(enabled) + + # -- Plans & scheduling -- + + async def start_plan_direct(self, plan_id: int, percent: int = 100) -> None: + """Start a work plan by numeric ID (direct command, no response).""" + await self._local.start_plan_direct(plan_id, percent) + + async def read_plan(self, plan_id: int, timeout: float = 5.0) -> dict[str, Any]: + """Request plan detail and await the data_feedback response.""" + return await self._local.read_plan(plan_id, timeout) + + async def read_all_plans(self, timeout: float = 5.0) -> dict[str, Any]: + """Request all plan summaries and await the data_feedback response.""" + return await self._local.read_all_plans(timeout) + + async def delete_plan_direct(self, plan_id: int, confirm: bool = False) -> None: + """Delete a plan by numeric ID. Pass confirm=True to proceed (destructive).""" + await self._local.delete_plan_direct(plan_id, confirm=confirm) + + async def delete_all_plans(self, confirm: bool = False) -> None: + """Delete all stored plans from the robot. Pass confirm=True to proceed (destructive).""" + await self._local.delete_all_plans(confirm=confirm) + + async def pause_planning(self) -> None: + """Pause the currently running plan (direct command, no response).""" + await self._local.pause_planning() + + async def in_plan_action(self, action: str) -> None: + """Send an in-plan action command.""" + await self._local.in_plan_action(action) + + async def read_schedules(self, timeout: float = 5.0) -> dict[str, Any]: + """Request all schedules and await the data_feedback response.""" + return await self._local.read_schedules(timeout) + + # -- Navigation & maps -- + + async def start_waypoint(self, index: int) -> None: + """Start navigation to a waypoint by index.""" + await self._local.start_waypoint(index) + + async def read_recharge_point(self, timeout: float = 5.0) -> dict[str, Any]: + """Request the saved recharge/dock point and await the data_feedback response.""" + return await self._local.read_recharge_point(timeout) + + async def save_charging_point(self) -> None: + """Save the robot's current position as the charging/dock point.""" + await self._local.save_charging_point() + + async def read_clean_area(self, timeout: float = 5.0) -> dict[str, Any]: + """Request the clean area definition and await the data_feedback response.""" + return await self._local.read_clean_area(timeout) + + async def get_all_map_backup(self, timeout: float = 5.0) -> dict[str, Any]: + """Request all map backups and await the data_feedback response.""" + return await self._local.get_all_map_backup(timeout) + + async def save_map_backup(self) -> None: + """Save a backup of the current map.""" + await self._local.save_map_backup() + + # -- WiFi & connectivity -- + + async def get_wifi_list(self, timeout: float = 5.0) -> dict[str, Any]: + """Request the list of available WiFi networks and await the data_feedback response.""" + return await self._local.get_wifi_list(timeout) + + async def get_connected_wifi(self, timeout: float = 5.0) -> dict[str, Any]: + """Request the connected WiFi network name and await the data_feedback response.""" + return await self._local.get_connected_wifi(timeout) + + async def start_hotspot(self) -> None: + """Start the robot's WiFi hotspot.""" + await self._local.start_hotspot() + + async def get_hub_info(self, timeout: float = 5.0) -> dict[str, Any]: + """Request hub information and await the data_feedback response.""" + return await self._local.get_hub_info(timeout) + + # -- Diagnostics -- + + async def read_no_charge_period(self, timeout: float = 5.0) -> dict[str, Any]: + """Request no-charge period configuration and await the data_feedback response.""" + return await self._local.read_no_charge_period(timeout) + + async def get_battery_cell_temps(self, timeout: float = 5.0) -> dict[str, Any]: + """Request battery cell temperature data and await the data_feedback response.""" + return await self._local.get_battery_cell_temps(timeout) + + async def get_motor_temps(self, timeout: float = 5.0) -> dict[str, Any]: + """Request motor temperature data and await the data_feedback response.""" + return await self._local.get_motor_temps(timeout) + + async def get_body_current(self, timeout: float = 5.0) -> dict[str, Any]: + """Request body current telemetry and await the data_feedback response.""" + return await self._local.get_body_current(timeout) + + async def get_head_current(self, timeout: float = 5.0) -> dict[str, Any]: + """Request head current telemetry and await the data_feedback response.""" + return await self._local.get_head_current(timeout) + + async def get_speed(self, timeout: float = 5.0) -> dict[str, Any]: + """Request current speed telemetry and await the data_feedback response.""" + return await self._local.get_speed(timeout) + + async def get_odometer(self, timeout: float = 5.0) -> dict[str, Any]: + """Request odometer data and await the data_feedback response.""" + return await self._local.get_odometer(timeout) + + async def get_product_code(self, timeout: float = 5.0) -> dict[str, Any]: + """Request the product code and await the data_feedback response.""" + return await self._local.get_product_code(timeout) + + async def set_charge_limit(self, min_pct: int, max_pct: int) -> None: + """Set battery charge limits.""" + await self._local.set_charge_limit(min_pct, max_pct) + + async def set_turn_type(self, turn_type: int) -> None: + """Set the turning behaviour type.""" + await self._local.set_turn_type(turn_type) + + # -- Motor & mechanical -- + + async def set_motor_protect(self, state: int) -> None: + """Enable or disable motor protection mode.""" + await self._local.set_motor_protect(state) + + async def set_trimmer(self, state: int) -> None: + """Enable or disable the trimmer.""" + await self._local.set_trimmer(state) + + # -- Blowing / edge / smart features -- + + async def set_edge_blowing(self, state: int) -> None: + """Enable or disable edge blowing.""" + await self._local.set_edge_blowing(state) + + async def set_smart_blowing(self, state: int) -> None: + """Enable or disable smart blowing.""" + await self._local.set_smart_blowing(state) + + async def set_heating_film(self, state: int) -> None: + """Enable or disable heating film (anti-ice).""" + await self._local.set_heating_film(state) + + async def set_module_lock(self, state: int) -> None: + """Lock or unlock an accessory module.""" + await self._local.set_module_lock(state) + + # -- Autonomous modes -- + + async def set_follow_mode(self, state: int) -> None: + """Enable or disable follow mode.""" + await self._local.set_follow_mode(state) + + async def set_draw_mode(self, state: int) -> None: + """Enable or disable draw/mapping mode.""" + await self._local.set_draw_mode(state) + + # -- OTA / firmware updates -- + + async def set_auto_update(self, state: int) -> None: + """Enable or disable automatic firmware (Greengrass) updates.""" + await self._local.set_auto_update(state) + + async def set_camera_ota(self, state: int) -> None: + """Enable or disable IP camera OTA updates.""" + await self._local.set_camera_ota(state) + + # -- Vision / recording -- + + async def set_smart_vision(self, state: int) -> None: + """Enable or disable smart vision processing.""" + await self._local.set_smart_vision(state) + + async def set_video_record(self, state: int) -> None: + """Enable or disable video recording.""" + await self._local.set_video_record(state) + + # -- Safety / fencing -- + + async def set_child_lock(self, state: int) -> None: + """Enable or disable the child lock.""" + await self._local.set_child_lock(state) + + async def set_geo_fence(self, state: int) -> None: + """Enable or disable geo-fencing.""" + await self._local.set_geo_fence(state) + + async def set_elec_fence(self, state: int) -> None: + """Enable or disable the electric fence.""" + await self._local.set_elec_fence(state) + + async def set_ngz_edge(self, state: int) -> None: + """Enable or disable NGZ edge enforcement.""" + await self._local.set_ngz_edge(state) + + # -- Manual drive extras -- + + async def set_velocity_manual(self, linear: float, angular: float) -> None: + """Send a velocity command in manual drive mode.""" + await self._local.set_velocity_manual(linear, angular) + + async def set_sound_param(self, volume: int, enabled: int) -> None: + """Set sound volume and enable/disable audio output.""" + await self._local.set_sound_param(volume, enabled) + + # -- Map management (destructive) -- + + async def erase_map(self, confirm: bool = False) -> None: + """Erase the robot's stored map. Pass confirm=True to proceed (destructive).""" + await self._local.erase_map(confirm=confirm) + + async def map_recovery(self, map_id: str, confirm: bool = False) -> None: + """Restore a map from backup. Pass confirm=True to proceed (destructive).""" + await self._local.map_recovery(map_id, confirm=confirm) + + async def save_current_map(self) -> None: + """Save the robot's current map state.""" + await self._local.save_current_map() + + async def save_map_backup_list(self, timeout: float = 5.0) -> dict[str, Any]: + """Save map backup and retrieve all map backup names and IDs.""" + return await self._local.save_map_backup_list(timeout) + # ------------------------------------------------------------------ # Plan management (delegated to YarboLocalClient) # ------------------------------------------------------------------ @@ -342,9 +636,9 @@ async def push_snow_dir(self, direction: int) -> None: """Set snow push direction (snow blower head only).""" return await self._local.push_snow_dir(direction=direction) - async def set_chute_steering_work(self, state: int) -> None: - """Enable or disable chute auto-steering (snow blower head only).""" - return await self._local.set_chute_steering_work(state=state) + async def set_chute_steering_work(self, angle: int) -> None: + """Set the chute steering angle during work (snow blower head only).""" + return await self._local.set_chute_steering_work(state=angle) # ------------------------------------------------------------------ # Camera commands (delegated to YarboLocalClient) diff --git a/src/yarbo/error_reporting.py b/src/yarbo/error_reporting.py index 7cd60d0..4d04981 100644 --- a/src/yarbo/error_reporting.py +++ b/src/yarbo/error_reporting.py @@ -13,6 +13,9 @@ logger = logging.getLogger(__name__) +# Default DSN (override with YARBO_SENTRY_DSN or SENTRY_DSN). +_DEFAULT_DSN = "https://c690590f8f664d609f6abe4cb0392d53@glitchtip.lassfolk.cc/2" + # Sensitive-key scrubbing helpers — compiled once at import time. _SCRUB_KEY_KEYWORDS: tuple[str, ...] = ("password", "token", "secret", "credential", "key") _SCRUB_MSG_KEYWORDS: tuple[str, ...] = ("password", "token", "secret", "credential") @@ -21,7 +24,6 @@ # Default DSN for the python-yarbo GlitchTip project. # Enabled by default during beta to help find issues. # Opt-out: set YARBO_SENTRY_DSN="" or pass enabled=False. -_DEFAULT_DSN = "https://c690590f8f664d609f6abe4cb0392d53@glitchtip.lassfolk.cc/2" def init_error_reporting( @@ -84,14 +86,9 @@ def _scrub_event(event: dict, hint: dict) -> dict: # type: ignore[type-arg] if "breadcrumbs" in event and "values" in event["breadcrumbs"]: for breadcrumb in event["breadcrumbs"]["values"]: if "message" in breadcrumb: - msg = str(breadcrumb["message"]) - sensitive = any(s in msg.lower() for s in _SCRUB_MSG_KEYWORDS) - if sensitive or _SCRUB_KEY_PATTERN.search(msg): - breadcrumb["message"] = "[REDACTED]" + breadcrumb["message"] = _scrub_string(str(breadcrumb["message"])) if "data" in breadcrumb and isinstance(breadcrumb["data"], dict): - for key in list(breadcrumb["data"]): - if any(s in key.lower() for s in _SCRUB_KEY_KEYWORDS): - breadcrumb["data"][key] = "[REDACTED]" + breadcrumb["data"] = _scrub_breadcrumb_data(breadcrumb["data"]) return event @@ -150,7 +147,7 @@ def _scrub_mqtt_envelope(envelope: dict[str, Any]) -> dict[str, Any]: def _scrub_dict(d: dict[str, Any]) -> dict[str, Any]: """Recursively redact values for keys that look sensitive.""" - result = {} + result: dict[str, Any] = {} for k, v in d.items(): if any(s in k.lower() for s in _SCRUB_KEY_KEYWORDS): result[k] = "[REDACTED]" @@ -161,3 +158,28 @@ def _scrub_dict(d: dict[str, Any]) -> dict[str, Any]: else: result[k] = v return result + + +def _scrub_string(value: str) -> str: + """Return a redacted string if it appears to contain sensitive data.""" + lowered = value.lower() + if any(s in lowered for s in _SCRUB_MSG_KEYWORDS) or _SCRUB_KEY_PATTERN.search(value): + return "[REDACTED]" + return value + + +def _scrub_breadcrumb_data(value: Any) -> Any: + """Scrub breadcrumb data values as well as sensitive keys.""" + if isinstance(value, dict): + cleaned: dict[str, Any] = {} + for k, v in value.items(): + if any(s in k.lower() for s in _SCRUB_KEY_KEYWORDS): + cleaned[k] = "[REDACTED]" + else: + cleaned[k] = _scrub_breadcrumb_data(v) + return cleaned + if isinstance(value, list): + return [_scrub_breadcrumb_data(v) for v in value] + if isinstance(value, str): + return _scrub_string(value) + return value diff --git a/src/yarbo/local.py b/src/yarbo/local.py index 771d768..5f70910 100644 --- a/src/yarbo/local.py +++ b/src/yarbo/local.py @@ -6,10 +6,10 @@ Prerequisites: - The host machine must be on the same WiFi as the robot. -- The robot's EMQX broker IP must be known (use :func:`yarbo.discover` or set explicitly). +- The robot's EMQX broker IP must be known (default: 192.168.1.24). - ``paho-mqtt`` must be installed: ``pip install 'python-yarbo'``. -Protocol notes (from hardware testing): +Protocol notes (from live captures): - All MQTT payloads are zlib-compressed JSON (see ``_codec``). - ``get_controller`` MUST be sent before action commands (e.g. light_ctrl). - Topics: ``snowbot/{SN}/app/{cmd}`` (publish) and @@ -17,14 +17,17 @@ - Commands are generally fire-and-forget; responses on ``data_feedback``. Transport limitations (NOT YET IMPLEMENTED): -- Local REST API (robot LAN, port 8088) — direct HTTP REST on the robot network. +- Local REST API (``192.168.8.8:8088``) — direct HTTP REST on the robot network. Endpoints are unknown; requires further sniffing or SSH exploration. -- Local TCP JSON (robot LAN, port 22220) — a JSON-over-TCP protocol discovered +- Local TCP JSON (``192.168.8.1:22220``) — a JSON-over-TCP protocol discovered in libapp.so (uses ``com`` field with ``@n`` namespace notation). - This module is MQTT-only. Both unimplemented transports are TODO items. References: - Protocol documentation (command catalogue, light control protocol, MQTT protocol reference) + yarbo-reversing/scripts/local_ctrl.py — working reference implementation + yarbo-reversing/docs/COMMAND_CATALOGUE.md — full command catalogue + yarbo-reversing/docs/LIGHT_CTRL_PROTOCOL.md — light control protocol + yarbo-reversing/docs/MQTT_PROTOCOL.md — protocol reference """ from __future__ import annotations @@ -70,7 +73,7 @@ class YarboLocalClient: Example (async context manager):: - async with YarboLocalClient(broker="", sn="YOUR_SERIAL") as client: + async with YarboLocalClient(broker="192.168.1.24", sn="24400102L8HO5227") as client: await client.lights_on() await client.buzzer(state=1) async for telemetry in client.watch_telemetry(): @@ -78,30 +81,22 @@ class YarboLocalClient: Example (manual lifecycle):: - client = YarboLocalClient(broker="", sn="YOUR_SERIAL") + client = YarboLocalClient(broker="192.168.1.24", sn="24400102L8HO5227") await client.connect() await client.lights_on() await client.disconnect() Args: - broker: MQTT broker IP (from discover() or set explicitly; required). + broker: MQTT broker IP address. sn: Robot serial number. port: Broker port (default 1883). auto_controller: If ``True`` (default), automatically send ``get_controller`` before the first action command. - mqtt_log_path: If set, append every raw MQTT message (topic + payload JSON) to this file. - debug: If ``True``, print every MQTT message sent/received to stderr - (human-readable). - debug_raw: If ``True`` and debug is on, print one-line JSON per message - (no formatting). - mqtt_capture_max: If > 0, keep the last N messages in a buffer; - use :meth:`get_captured_mqtt` to retrieve them - (e.g. for ``report_mqtt_dump_to_glitchtip``). """ def __init__( self, - broker: str, + broker: str = LOCAL_BROKER_DEFAULT, sn: str = "", port: int = LOCAL_PORT, auto_controller: bool = True, @@ -110,30 +105,18 @@ def __init__( debug_raw: bool = False, mqtt_capture_max: int = 0, ) -> None: - if not broker: - raise ValueError( - "broker IP must be set to the robot's EMQX broker address; " - "use yarbo.discovery.discover() to find it automatically." - ) self._broker = broker self._sn = sn self._port = port self._auto_controller = auto_controller - self._transport = MqttTransport( - broker=broker, - sn=sn, - port=port, - mqtt_log_path=mqtt_log_path, - debug=debug, - debug_raw=debug_raw, - mqtt_capture_max=mqtt_capture_max, - ) + self._transport = MqttTransport(broker=broker, sn=sn, port=port) self._controller_acquired = False self._last_status: YarboTelemetry | None = None - - def get_captured_mqtt(self) -> list[dict[str, Any]]: - """Return captured MQTT messages (when mqtt_capture_max > 0). For GlitchTip reports.""" - return self._transport.get_captured_mqtt() + self._mqtt_log_path = mqtt_log_path + self._debug = debug + self._debug_raw = debug_raw + self._mqtt_capture_max = mqtt_capture_max + self._captured_mqtt: list[dict[str, Any]] = [] # ------------------------------------------------------------------ # Context manager @@ -166,7 +149,6 @@ def _on_reconnect(self) -> None: async def connect(self) -> None: """Connect to the local MQTT broker.""" self._transport.add_reconnect_callback(self._on_reconnect) - logger.debug("Connecting to broker %s:%d (sn=%s)", self._broker, self._port, self._sn) await self._transport.connect() logger.info( "YarboLocalClient connected to %s:%d (sn=%s)", @@ -177,9 +159,12 @@ async def connect(self) -> None: async def disconnect(self) -> None: """Disconnect from the local MQTT broker.""" - logger.debug("Disconnecting from broker %s:%d (sn=%s)", self._broker, self._port, self._sn) await self._transport.disconnect() + def get_captured_mqtt(self) -> list[dict[str, Any]]: + """Return captured MQTT messages (populated when mqtt_capture_max > 0).""" + return list(self._captured_mqtt) + @property def is_connected(self) -> bool: """True if the MQTT connection is active.""" @@ -342,7 +327,7 @@ async def set_chute(self, vel: int) -> None: vel: Chute velocity / direction integer. Positive = right, negative = left. Reference: - Protocol documentation (light control protocol, cmd_chute section) + yarbo-reversing/docs/LIGHT_CTRL_PROTOCOL.md#cmd_chute """ await self._ensure_controller() await self._transport.publish("cmd_chute", {"vel": vel}) @@ -369,60 +354,11 @@ async def get_status(self, timeout: float = DEFAULT_CMD_TIMEOUT) -> YarboTelemet if envelope: topic = envelope.get("topic", "") payload = envelope.get("payload", {}) - telemetry = YarboTelemetry.from_dict(payload, topic=topic) - self._last_status = telemetry - logger.debug( - "Received status snapshot: battery=%s state=%s head=%s", - telemetry.battery, - telemetry.state, - telemetry.head_name, - ) - return telemetry + result = YarboTelemetry.from_dict(payload, topic=topic) + self._last_status = result + return result return None - async def watch_telemetry(self) -> AsyncIterator[YarboTelemetry]: - """ - Async generator yielding live telemetry from ``DeviceMSG`` messages. - - Filters the envelope stream to ``DeviceMSG`` messages only and yields - a :class:`~yarbo.models.YarboTelemetry` for each one (~1-2 Hz). - - To access raw envelopes from all topics, use - :meth:`~yarbo.mqtt.MqttTransport.telemetry_stream` on the transport - directly. - - Example:: - - async for telemetry in client.watch_telemetry(): - print(f"Battery: {telemetry.battery}% State: {telemetry.state}") - if telemetry.battery and telemetry.battery < 10: - break - """ - # Cache plan_feedback data to merge into each DeviceMSG telemetry object - _plan_payload: dict[str, Any] = {} - async for envelope in self._transport.telemetry_stream(): - if envelope.kind == TOPIC_LEAF_PLAN_FEEDBACK: - _plan_payload = envelope.payload - elif envelope.is_telemetry: - t = envelope.to_telemetry() - if _plan_payload: - t.plan_id = _plan_payload.get("planId") - t.plan_state = _plan_payload.get("state") - t.area_covered = _plan_payload.get("areaCovered") - t.duration = _plan_payload.get("duration") - self._last_status = t - logger.debug( - "Telemetry: battery=%s state=%s head=%s", - t.battery, - t.state, - t.head_name, - ) - yield t - - # ------------------------------------------------------------------ - # Head validation - # ------------------------------------------------------------------ - def _validate_head_type(self, required: HeadType | tuple[HeadType, ...]) -> None: """Validate that the attached head matches the required type(s). @@ -434,10 +370,8 @@ def _validate_head_type(self, required: HeadType | tuple[HeadType, ...]) -> None ValueError: If a head status has been received and the attached head does not match *required*. - If no status has been received yet (``_last_status`` is ``None`` or - ``head_type`` is absent), a warning is logged and the command is allowed - through to preserve compatibility with firmware that reports head type - after the first status message. + If no status has been received yet, a warning is logged and the command + is allowed through. """ if isinstance(required, HeadType): required = (required,) @@ -462,6 +396,38 @@ def _validate_head_type(self, required: HeadType | tuple[HeadType, ...]) -> None req_names = " or ".join(h.name for h in required) raise ValueError(f"Command requires {req_names} head, but {current.name} is attached") + async def watch_telemetry(self) -> AsyncIterator[YarboTelemetry]: + """ + Async generator yielding live telemetry from ``DeviceMSG`` messages. + + Filters the envelope stream to ``DeviceMSG`` messages only and yields + a :class:`~yarbo.models.YarboTelemetry` for each one (~1-2 Hz). + + To access raw envelopes from all topics, use + :meth:`~yarbo.mqtt.MqttTransport.telemetry_stream` on the transport + directly. + + Example:: + + async for telemetry in client.watch_telemetry(): + print(f"Battery: {telemetry.battery}% State: {telemetry.state}") + if telemetry.battery and telemetry.battery < 10: + break + """ + # Cache plan_feedback data to merge into each DeviceMSG telemetry object + _plan_payload: dict[str, Any] = {} + async for envelope in self._transport.telemetry_stream(): + if envelope.kind == TOPIC_LEAF_PLAN_FEEDBACK: + _plan_payload = envelope.payload + elif envelope.is_telemetry: + t = envelope.to_telemetry() + if _plan_payload: + t.plan_id = _plan_payload.get("planId") + t.plan_state = _plan_payload.get("state") + t.area_covered = _plan_payload.get("areaCovered") + t.duration = _plan_payload.get("duration") + yield t + # ------------------------------------------------------------------ # Internal helper: publish + wait for data_feedback # ------------------------------------------------------------------ @@ -500,7 +466,7 @@ async def _publish_and_wait( # Plan management # ------------------------------------------------------------------ - async def start_plan(self, plan_id: str, percent: int = 100) -> YarboCommandResult: + async def start_plan(self, plan_id: str) -> YarboCommandResult: """Start the plan identified by *plan_id*. Args: @@ -513,7 +479,7 @@ async def start_plan(self, plan_id: str, percent: int = 100) -> YarboCommandResu YarboTimeoutError: If no acknowledgement is received. """ await self._ensure_controller() - return await self._publish_and_wait("start_plan", {"planId": plan_id, "percent": percent}) + return await self._publish_and_wait("start_plan", {"planId": plan_id}) async def stop_plan(self) -> YarboCommandResult: """Stop the currently running plan. @@ -682,71 +648,6 @@ async def delete_plan(self, plan_id: str, *, confirm: bool = False) -> YarboComm await self._ensure_controller() return await self._publish_and_wait("del_plan", {"planId": plan_id}) - async def delete_all_plans(self, *, confirm: bool = False) -> YarboCommandResult: - """Delete all saved plans on the robot. - - Args: - confirm: Must be ``True`` to confirm this destructive operation. - - Returns: - :class:`~yarbo.models.YarboCommandResult` on success. - - Raises: - ValueError: If *confirm* is not ``True``. - YarboTimeoutError: If no acknowledgement is received. - """ - if not confirm: - raise ValueError( - "delete_all_plans is a destructive operation. Pass confirm=True to confirm." - ) - await self._ensure_controller() - return await self._publish_and_wait("del_all_plan", {}) - - async def erase_map(self, *, confirm: bool = False) -> YarboCommandResult: - """Erase the current map stored on the robot. - - Args: - confirm: Must be ``True`` to confirm this destructive operation. - - Returns: - :class:`~yarbo.models.YarboCommandResult` on success. - - Raises: - ValueError: If *confirm* is not ``True``. - YarboTimeoutError: If no acknowledgement is received. - """ - if not confirm: - raise ValueError("erase_map is a destructive operation. Pass confirm=True to confirm.") - await self._ensure_controller() - return await self._publish_and_wait("erase_map", {}) - - async def map_recovery( - self, map_id: str | None = None, *, confirm: bool = False - ) -> YarboCommandResult: - """Recover a previously saved map. - - Args: - map_id: Optional map ID to restore. If ``None``, the robot uses its - default recovery behaviour. - confirm: Must be ``True`` to confirm this destructive operation. - - Returns: - :class:`~yarbo.models.YarboCommandResult` on success. - - Raises: - ValueError: If *confirm* is not ``True``. - YarboTimeoutError: If no acknowledgement is received. - """ - if not confirm: - raise ValueError( - "map_recovery is a destructive operation. Pass confirm=True to confirm." - ) - payload: dict[str, Any] = {} - if map_id is not None: - payload["mapId"] = map_id - await self._ensure_controller() - return await self._publish_and_wait("map_recovery", payload) - # ------------------------------------------------------------------ # Manual drive # ------------------------------------------------------------------ @@ -837,8 +738,7 @@ async def get_global_params(self, timeout: float = DEFAULT_CMD_TIMEOUT) -> dict[ ) if msg is None: return {} - data = msg.get("data", {}) or {} - return data if isinstance(data, dict) else {"data": data} + return dict(msg.get("data", {}) or {}) async def set_global_params(self, params: dict[str, Any]) -> YarboCommandResult: """Save global robot parameters (``cmd_save_para``). @@ -883,8 +783,7 @@ async def get_map(self, timeout: float = 10.0) -> dict[str, Any]: ) if msg is None: return {} - data = msg.get("data", {}) or {} - return data if isinstance(data, dict) else {"data": data} + return dict(msg.get("data", {}) or {}) # ------------------------------------------------------------------ # Plan creation @@ -919,314 +818,532 @@ async def create_plan( return await self._publish_and_wait("save_plan", payload) # ------------------------------------------------------------------ - # System control + # Robot control # ------------------------------------------------------------------ - async def shutdown(self) -> YarboCommandResult: - """Shut down the robot. + async def shutdown(self) -> None: + """Power off the robot.""" + await self._ensure_controller() + await self._transport.publish("shutdown", {}) - Returns: - :class:`~yarbo.models.YarboCommandResult` on success. + async def restart_container(self) -> None: + """Restart the EMQX container on the robot.""" + await self._ensure_controller() + await self._transport.publish("restart_container", {}) - Raises: - YarboTimeoutError: If no acknowledgement is received. - """ + async def emergency_stop(self) -> None: + """Trigger an emergency stop.""" await self._ensure_controller() - return await self._publish_and_wait("shutdown", {}) + await self._transport.publish("emergency_stop_active", {}) - async def restart_container(self) -> YarboCommandResult: - """Restart the software container on the robot. + async def emergency_unlock(self) -> None: + """Clear the emergency stop state.""" + await self._ensure_controller() + await self._transport.publish("emergency_unlock", {}) - Returns: - :class:`~yarbo.models.YarboCommandResult` on success. + async def dstop(self) -> None: + """Soft-stop the robot (decelerate to halt).""" + await self._ensure_controller() + await self._transport.publish("dstop", {}) - Raises: - YarboTimeoutError: If no acknowledgement is received. - """ + async def resume(self) -> None: + """Resume operation after a pause or soft-stop.""" + await self._ensure_controller() + await self._transport.publish("resume", {}) + + async def cmd_recharge(self) -> None: + """Send the robot back to its charging dock.""" await self._ensure_controller() - return await self._publish_and_wait("restart_container", {}) + await self._transport.publish("cmd_recharge", {}) # ------------------------------------------------------------------ - # Area management + # Lights & sound # ------------------------------------------------------------------ - async def read_clean_area(self) -> YarboCommandResult: - """Request the list of saved clean areas from the robot. - - Returns: - :class:`~yarbo.models.YarboCommandResult` on success. + async def set_head_light(self, enabled: bool) -> None: + """ + Enable or disable the head light. - Raises: - YarboTimeoutError: If no acknowledgement is received. + Args: + enabled: True to turn on, False to turn off. """ await self._ensure_controller() - return await self._publish_and_wait("read_clean_area", {}) + await self._transport.publish("head_light", {"state": 1 if enabled else 0}) - # ------------------------------------------------------------------ - # Safety & detection - # ------------------------------------------------------------------ - - async def set_person_detect(self, enabled: bool) -> YarboCommandResult: - """Enable or disable person detection. + async def set_roof_lights(self, enabled: bool) -> None: + """ + Enable or disable the roof lights. - Sends ``{"disable": not enabled}`` to the robot. Note the inversion: - the wire protocol uses a *disable* flag. + Args: + enabled: True to turn on, False to turn off. + """ + await self._ensure_controller() + await self._transport.publish("roof_lights_enable", {"enable": 1 if enabled else 0}) - .. note:: - Some firmware versions may additionally require a ``"key"`` field - in the payload. If detection toggling has no effect, consult the - firmware changelog and add the ``"key"`` field accordingly. + async def set_laser(self, enabled: bool) -> None: + """ + Enable or disable the laser. Args: - enabled: ``True`` to enable person detection, ``False`` to disable. + enabled: True to enable, False to disable. + """ + await self._ensure_controller() + await self._transport.publish("laser_toggle", {"enabled": enabled}) - Returns: - :class:`~yarbo.models.YarboCommandResult` on success. + async def set_sound(self, volume: int, song_id: int = 0) -> None: + """ + Set the speaker volume (sound parameter variant A). - Raises: - YarboTimeoutError: If no acknowledgement is received. + Args: + volume: Volume level (0-100). + song_id: Song identifier (reserved, default 0). """ await self._ensure_controller() - return await self._publish_and_wait("set_person_detect", {"disable": not enabled}) + await self._transport.publish("set_sound_param", {"vol": volume, "songId": song_id}) - async def set_ignore_obstacles(self, state: bool) -> YarboCommandResult: - """Enable or disable obstacle detection bypass. + async def set_sound_param(self, volume: int, enabled: int) -> None: + """ + Set the speaker volume and enable/disable audio output (variant B). - .. warning:: - **Safety hazard.** Disabling obstacle detection allows the robot - to continue moving even when obstacles are detected. Only use this - in controlled environments where you are certain there are no - people, animals, or objects in the robot's path. + Uses the same ``set_sound_param`` command but with a different payload + shape than :meth:`set_sound`. Both variants are known to exist in + firmware captures. Args: - state: ``True`` to ignore obstacles (bypass detection), - ``False`` to restore normal obstacle detection. + volume: Volume level (0-100). + enabled: 1 to enable audio output, 0 to disable. + """ + await self._ensure_controller() + await self._transport.publish("set_sound_param", {"volume": volume, "enable": enabled}) - Returns: - :class:`~yarbo.models.YarboCommandResult` on success. + async def play_song(self, song_id: int) -> None: + """ + Play a sound/song by ID. - Raises: - YarboTimeoutError: If no acknowledgement is received. + Args: + song_id: Identifier of the song to play. """ await self._ensure_controller() - return await self._publish_and_wait("ignore_obstacles", {"state": 1 if state else 0}) + await self._transport.publish("song_cmd", {"songId": song_id}) # ------------------------------------------------------------------ - # Head-specific commands — Leaf Blower + # Camera & detection # ------------------------------------------------------------------ - async def set_roller_speed(self, speed: int) -> None: - """Set the roller speed on a leaf blower head. + async def set_camera(self, enabled: bool) -> None: + """ + Enable or disable the camera. Args: - speed: Roller speed (0-2000 RPM range observed in protocol documentation). - - Raises: - ValueError: If the attached head is not a LeafBlower. + enabled: True to enable, False to disable. """ - self._validate_head_type(HeadType.LeafBlower) await self._ensure_controller() - await self._transport.publish("set_roller_speed", {"speed": speed}) + await self._transport.publish("camera_toggle", {"enabled": enabled}) - # ------------------------------------------------------------------ - # Head-specific commands — Lawn Mower / Lawn Mower Pro - # ------------------------------------------------------------------ - - async def set_blade_height(self, height: int) -> None: - """Set the mowing blade height. + async def set_person_detect(self, enabled: bool) -> None: + """ + Enable or disable person detection. Args: - height: Blade height level as an integer (observed range: 1-5 per - protocol documentation). - - Raises: - ValueError: If the attached head is not LawnMower or LawnMowerPro. + enabled: True to enable, False to disable. """ - self._validate_head_type((HeadType.LawnMower, HeadType.LawnMowerPro)) await self._ensure_controller() - await self._transport.publish("set_blade_height", {"height": height}) + await self._transport.publish("set_person_detect", {"enable": 1 if enabled else 0}) - async def set_blade_speed(self, speed: int) -> None: - """Set the mowing blade speed. + async def set_usb(self, enabled: bool) -> None: + """ + Enable or disable the USB port. Args: - speed: Blade speed value (observed range: 0-100 per protocol - documentation). - - Raises: - ValueError: If the attached head is not LawnMower or LawnMowerPro. + enabled: True to enable, False to disable. """ - self._validate_head_type((HeadType.LawnMower, HeadType.LawnMowerPro)) await self._ensure_controller() - await self._transport.publish("set_blade_speed", {"speed": speed}) + await self._transport.publish("usb_toggle", {"enabled": enabled}) + + async def check_camera_status(self) -> YarboCommandResult: + """Request current camera status.""" + return await self._publish_and_wait("check_camera_status", {}) + + async def camera_calibration(self) -> YarboCommandResult: + """Trigger camera calibration.""" + return await self._publish_and_wait("camera_calibration", {}) # ------------------------------------------------------------------ - # Head-specific commands — Snow Blower + # Plans & scheduling # ------------------------------------------------------------------ - async def push_snow_dir(self, direction: int) -> None: - """Set the snow push direction (snow blower head only). + async def start_plan_direct(self, plan_id: int, percent: int = 100) -> None: + """ + Start a work plan by numeric ID (direct command, no response). Args: - direction: Direction value as integer (protocol documentation values). - - Raises: - ValueError: If the attached head is not a SnowBlower. + plan_id: Numeric ID of the plan to execute. + percent: Coverage percentage (default 100). """ - self._validate_head_type(HeadType.SnowBlower) await self._ensure_controller() - await self._transport.publish("push_snow_dir", {"dir": direction}) + await self._transport.publish("start_plan", {"planId": plan_id, "percent": percent}) - async def set_chute_steering_work(self, state: int) -> None: - """Enable or disable chute auto-steering (snow blower head only). + async def read_plan(self, plan_id: int, timeout: float = 5.0) -> dict[str, Any]: + """ + Request detail for a specific plan and await the data_feedback response. Args: - state: 1 to enable auto-steering, 0 to disable. + plan_id: Numeric plan ID. + timeout: Seconds to wait for the response (default 5.0). - Raises: - ValueError: If the attached head is not a SnowBlower. + Returns: + Response payload dict, or empty dict on timeout. """ - self._validate_head_type(HeadType.SnowBlower) - await self._ensure_controller() - await self._transport.publish("set_chute_steering_work", {"state": state}) + return await self._request_data_feedback("read_plan", {"id": plan_id}, timeout) - # ------------------------------------------------------------------ - # Camera commands - # ------------------------------------------------------------------ + async def read_all_plans(self, timeout: float = 5.0) -> dict[str, Any]: + """ + Request all plan summaries and await the data_feedback response. - async def check_camera_status(self) -> YarboCommandResult: - """Request the current camera status from the robot. + Args: + timeout: Seconds to wait for the response (default 5.0). Returns: - :class:`~yarbo.models.YarboCommandResult` on success. - - Raises: - YarboTimeoutError: If no acknowledgement is received. + Response payload dict, or empty dict on timeout. """ - await self._ensure_controller() - return await self._publish_and_wait("check_camera_status", {}) + return await self._request_data_feedback("read_all_plan", {}, timeout) - async def camera_calibration(self) -> YarboCommandResult: - """Trigger camera calibration on the robot. + async def delete_plan_direct(self, plan_id: int, confirm: bool = False) -> None: + """ + Delete a plan by numeric ID (direct command, no response). - Returns: - :class:`~yarbo.models.YarboCommandResult` on success. + Args: + plan_id: Numeric plan ID to delete. + confirm: Must be ``True`` to proceed — this is a destructive operation. Raises: - YarboTimeoutError: If no acknowledgement is received. + ValueError: If *confirm* is not ``True``. """ + if not confirm: + raise ValueError("delete_plan_direct is destructive — pass confirm=True to proceed.") await self._ensure_controller() - return await self._publish_and_wait("camera_calibration", {}) - - # ------------------------------------------------------------------ - # Firmware update commands - # ------------------------------------------------------------------ + await self._transport.publish("del_plan", {"planId": plan_id}) - async def firmware_update_now(self, *, confirm: bool = False) -> YarboCommandResult: - """Trigger an immediate firmware update. - - .. warning:: - **Destructive operation.** This reboots the robot and applies the - pending firmware update immediately, interrupting any active plan. - Pass ``confirm=True`` to confirm the intent and execute. + async def delete_all_plans(self, confirm: bool = False) -> None: + """Delete all stored plans from the robot. Args: - confirm: Must be ``True`` to execute. Prevents accidental invocation. - - Returns: - :class:`~yarbo.models.YarboCommandResult` on success. + confirm: Must be ``True`` to proceed — this is a destructive operation. Raises: - ValueError: If ``confirm`` is not ``True``. - YarboTimeoutError: If no acknowledgement is received. + ValueError: If *confirm* is not ``True``. """ if not confirm: - raise ValueError( - "firmware_update_now() is a destructive operation that reboots the robot. " - "Pass confirm=True to proceed." - ) + raise ValueError("delete_all_plans is destructive — pass confirm=True to proceed.") await self._ensure_controller() - return await self._publish_and_wait("firmware_update_now", {}) + await self._transport.publish("del_all_plan", {}) - async def firmware_update_tonight(self) -> YarboCommandResult: - """Schedule a firmware update to run tonight (during low-activity hours). + async def pause_planning(self) -> None: + """Pause the currently running plan (direct command, no response).""" + await self._ensure_controller() + await self._transport.publish("planning_paused", {}) - Returns: - :class:`~yarbo.models.YarboCommandResult` on success. + async def in_plan_action(self, action: str) -> None: + """ + Send an in-plan action command. - Raises: - YarboTimeoutError: If no acknowledgement is received. + Args: + action: Action string (e.g. ``"pause"``, ``"resume"``, ``"stop"``). """ await self._ensure_controller() - return await self._publish_and_wait("firmware_update_tonight", {}) + await self._transport.publish("in_plan_action", {"action": action}) - async def firmware_update_later(self) -> YarboCommandResult: - """Defer a pending firmware update to a later, unspecified time. + async def read_schedules(self, timeout: float = 5.0) -> dict[str, Any]: + """ + Request all schedules and await the data_feedback response. + + Args: + timeout: Seconds to wait for the response (default 5.0). Returns: - :class:`~yarbo.models.YarboCommandResult` on success. + Response payload dict, or empty dict on timeout. + """ + return await self._request_data_feedback("read_schedules", {}, timeout) - Raises: - YarboTimeoutError: If no acknowledgement is received. + # ------------------------------------------------------------------ + # Navigation & maps + # ------------------------------------------------------------------ + + async def start_waypoint(self, index: int) -> None: + """ + Start navigation to a waypoint by index. + + Args: + index: Zero-based waypoint index. """ await self._ensure_controller() - return await self._publish_and_wait("firmware_update_later", {}) + await self._transport.publish("start_way_point", {"index": index}) + + async def read_recharge_point(self, timeout: float = 5.0) -> dict[str, Any]: + """ + Request the saved recharge/dock point and await the data_feedback response. + + Args: + timeout: Seconds to wait for the response (default 5.0). + + Returns: + Response payload dict, or empty dict on timeout. + """ + return await self._request_data_feedback("read_recharge_point", {}, timeout) + + async def save_charging_point(self) -> None: + """Save the robot's current position as the charging/dock point.""" + await self._ensure_controller() + await self._transport.publish("save_charging_point", {}) + + async def read_clean_area(self, timeout: float = 5.0) -> dict[str, Any]: + """ + Request the clean area definition and await the data_feedback response. + + Args: + timeout: Seconds to wait for the response (default 5.0). + + Returns: + Response payload dict, or empty dict on timeout. + """ + return await self._request_data_feedback("read_clean_area", {}, timeout) + + async def get_all_map_backup(self, timeout: float = 5.0) -> dict[str, Any]: + """ + Request all map backups and await the data_feedback response. + + Args: + timeout: Seconds to wait for the response (default 5.0). + + Returns: + Response payload dict, or empty dict on timeout. + """ + return await self._request_data_feedback("get_all_map_backup", {}, timeout) + + async def save_map_backup(self) -> None: + """Save a backup of the current map.""" + await self._ensure_controller() + await self._transport.publish("save_map_backup", {}) # ------------------------------------------------------------------ - # Wi-Fi management + # WiFi & connectivity # ------------------------------------------------------------------ - async def get_saved_wifi_list(self, timeout: float = DEFAULT_CMD_TIMEOUT) -> dict[str, Any]: - """Fetch the list of saved Wi-Fi networks from the robot. + async def get_wifi_list(self, timeout: float = 5.0) -> dict[str, Any]: + """ + Request the list of available WiFi networks and await the data_feedback response. + + Args: + timeout: Seconds to wait for the response (default 5.0). + + Returns: + Response payload dict, or empty dict on timeout. + """ + return await self._request_data_feedback("get_wifi_list", {}, timeout) - Returns the ``data`` field of the ``data_feedback`` response, which - contains the network list as observed in protocol documentation. + async def get_connected_wifi(self, timeout: float = 5.0) -> dict[str, Any]: + """ + Request the currently connected WiFi network name and await the data_feedback response. Args: - timeout: Maximum wait time in seconds (default 5.0). + timeout: Seconds to wait for the response (default 5.0). Returns: - Dict containing the Wi-Fi list (empty dict on timeout). + Response payload dict, or empty dict on timeout. """ - wait_queue = self._transport.create_wait_queue() - try: - await self._transport.publish("get_saved_wifi_list", {}) - except Exception: - self._transport.release_queue(wait_queue) - raise - msg = await self._transport.wait_for_message( - timeout=timeout, - feedback_leaf=TOPIC_LEAF_DATA_FEEDBACK, - command_name="get_saved_wifi_list", - _queue=wait_queue, - ) - if msg is None: - return {} - data = msg.get("data", {}) or {} - return data if isinstance(data, dict) else {"data": data} + return await self._request_data_feedback("get_connect_wifi_name", {}, timeout) + + async def start_hotspot(self) -> None: + """Start the robot's WiFi hotspot.""" + await self._ensure_controller() + await self._transport.publish("start_hotspot", {}) + + async def get_hub_info(self, timeout: float = 5.0) -> dict[str, Any]: + """ + Request hub information and await the data_feedback response. + + Args: + timeout: Seconds to wait for the response (default 5.0). + + Returns: + Response payload dict, or empty dict on timeout. + """ + return await self._request_data_feedback("hub_info", {}, timeout) + + async def get_saved_wifi_list(self, timeout: float = 5.0) -> dict[str, Any]: + """ + Request saved Wi-Fi networks from the robot and await the data_feedback response. + + Args: + timeout: Seconds to wait for the response (default 5.0). + + Returns: + Response payload dict, or empty dict on timeout. + """ + return await self._request_data_feedback("get_saved_wifi_list", {}, timeout) # ------------------------------------------------------------------ - # Recording + # Diagnostics (read-only telemetry requests) # ------------------------------------------------------------------ - async def bag_record(self, enabled: bool) -> None: - """Start or stop bag recording on the robot. + async def read_no_charge_period(self, timeout: float = 5.0) -> dict[str, Any]: + """ + Request no-charge period configuration and await the data_feedback response. + + Args: + timeout: Seconds to wait for the response (default 5.0). + + Returns: + Response payload dict, or empty dict on timeout. + """ + return await self._request_data_feedback("read_no_charge_period", {}, timeout) + + async def get_battery_cell_temps(self, timeout: float = 5.0) -> dict[str, Any]: + """ + Request battery cell temperature data and await the data_feedback response. + + Args: + timeout: Seconds to wait for the response (default 5.0). + + Returns: + Response payload dict, or empty dict on timeout. + """ + return await self._request_data_feedback("battery_cell_temp_msg", {}, timeout) + + async def get_motor_temps(self, timeout: float = 5.0) -> dict[str, Any]: + """ + Request motor temperature data and await the data_feedback response. + + Args: + timeout: Seconds to wait for the response (default 5.0). + + Returns: + Response payload dict, or empty dict on timeout. + """ + return await self._request_data_feedback("motor_temp_samp", {}, timeout) + + async def get_body_current(self, timeout: float = 5.0) -> dict[str, Any]: + """ + Request body current telemetry and await the data_feedback response. + + Args: + timeout: Seconds to wait for the response (default 5.0). + + Returns: + Response payload dict, or empty dict on timeout. + """ + return await self._request_data_feedback("body_current_msg", {}, timeout) + + async def get_head_current(self, timeout: float = 5.0) -> dict[str, Any]: + """ + Request head current telemetry and await the data_feedback response. + + Args: + timeout: Seconds to wait for the response (default 5.0). + + Returns: + Response payload dict, or empty dict on timeout. + """ + return await self._request_data_feedback("head_current_msg", {}, timeout) + + async def get_speed(self, timeout: float = 5.0) -> dict[str, Any]: + """ + Request current speed telemetry and await the data_feedback response. + + Args: + timeout: Seconds to wait for the response (default 5.0). + + Returns: + Response payload dict, or empty dict on timeout. + """ + return await self._request_data_feedback("speed_msg", {}, timeout) + + async def get_odometer(self, timeout: float = 5.0) -> dict[str, Any]: + """ + Request odometer data and await the data_feedback response. + + Args: + timeout: Seconds to wait for the response (default 5.0). + + Returns: + Response payload dict, or empty dict on timeout. + """ + return await self._request_data_feedback("odometer_msg", {}, timeout) + + async def get_product_code(self, timeout: float = 5.0) -> dict[str, Any]: + """ + Request the product code and await the data_feedback response. + + Args: + timeout: Seconds to wait for the response (default 5.0). + + Returns: + Response payload dict, or empty dict on timeout. + """ + return await self._request_data_feedback("product_code_msg", {}, timeout) + + # ------------------------------------------------------------------ + # Data feedback helper + # ------------------------------------------------------------------ + + async def _request_data_feedback( + self, cmd: str, payload: dict[str, Any], timeout: float = 5.0 + ) -> dict[str, Any]: + """ + Send a command and wait for the matching ``data_feedback`` response. + + Pre-registers a receive queue before publishing to eliminate any + publish/subscribe race condition. Args: - enabled: ``True`` to start recording, ``False`` to stop. + cmd: Topic leaf name of the command to send. + payload: Payload dict to publish. + timeout: Seconds to wait for the response. + + Returns: + Decoded response payload dict, or empty dict on timeout. """ await self._ensure_controller() - await self._transport.publish("bag_record", {"state": 1 if enabled else 0}) + wait_queue = self._transport.create_wait_queue() + try: + await self._transport.publish(cmd, payload) + msg = await self._transport.wait_for_message( + timeout=timeout, + feedback_leaf=TOPIC_LEAF_DATA_FEEDBACK, + command_name=cmd, + _queue=wait_queue, + ) + return msg.get("data", {}) or {} if isinstance(msg, dict) else {} + except Exception: + self._transport.release_queue(wait_queue) + raise # ------------------------------------------------------------------ # Raw publish (escape hatch) # ------------------------------------------------------------------ + async def publish_command(self, cmd: str, payload: dict[str, Any]) -> None: + """ + Publish a command to the robot without auto-acquiring the controller. + + Used by the coordinator which manages controller acquisition separately + (calls ``get_controller()`` explicitly before command sequences). + + Topic: ``snowbot/{SN}/app/{cmd}``, payload: zlib-compressed JSON. + + Args: + cmd: Topic leaf (e.g. ``"start_plan"``). + payload: Dict payload (will be zlib-encoded). + """ + await self._transport.publish(cmd, payload) + async def publish_raw(self, cmd: str, payload: dict[str, Any]) -> None: """ Publish an arbitrary command to the robot. Useful for commands not yet wrapped in a dedicated method. + Auto-acquires controller role if needed (use :meth:`publish_command` + to skip auto-acquire, e.g. in coordinator patterns). Args: cmd: Topic leaf (e.g. ``"start_plan"``). @@ -1235,6 +1352,358 @@ async def publish_raw(self, cmd: str, payload: dict[str, Any]) -> None: await self._ensure_controller() await self._transport.publish(cmd, payload) + # ------------------------------------------------------------------ + # Blade / mowing configuration + # ------------------------------------------------------------------ + + async def set_blade_height(self, height: int) -> None: + """Set the blade cutting height. + + Args: + height: Blade height value (robot-defined units). + """ + self._validate_head_type((HeadType.LawnMower, HeadType.LawnMowerPro)) + await self._ensure_controller() + await self._transport.publish("set_blade_height", {"height": height}) + + async def set_blade_speed(self, speed: int) -> None: + """Set the blade rotation speed. + + Args: + speed: Blade speed value (robot-defined units). + """ + self._validate_head_type((HeadType.LawnMower, HeadType.LawnMowerPro)) + await self._ensure_controller() + await self._transport.publish("set_blade_speed", {"speed": speed}) + + async def set_charge_limit(self, min_pct: int, max_pct: int) -> None: + """Set battery charge limits. + + Args: + min_pct: Minimum charge percentage before robot returns to dock. + max_pct: Maximum charge percentage (charge stops here). + """ + await self._ensure_controller() + await self._transport.publish("set_charge_limit", {"min": min_pct, "max": max_pct}) + + async def set_turn_type(self, turn_type: int) -> None: + """Set the turning behaviour type. + + Args: + turn_type: Integer representing the turn mode (robot-defined). + """ + await self._ensure_controller() + await self._transport.publish("set_turn_type", {"turnType": turn_type}) + + # ------------------------------------------------------------------ + # Snow blower accessories + # ------------------------------------------------------------------ + + async def push_snow_dir(self, direction: int) -> None: + """Set the snow push direction. + + Args: + direction: Direction integer (robot-defined). + """ + self._validate_head_type(HeadType.SnowBlower) + await self._ensure_controller() + await self._transport.publish("push_snow_dir", {"dir": direction}) + + async def set_chute_steering_work(self, state: int) -> None: + """Set the chute steering state during work. + + Args: + state: Chute steering state (robot-defined). + """ + self._validate_head_type(HeadType.SnowBlower) + await self._ensure_controller() + await self._transport.publish("set_chute_steering_work", {"state": state}) + + async def set_roller_speed(self, speed: int) -> None: + """Set the roller/blower speed. + + Args: + speed: Speed value (robot-defined units). + """ + self._validate_head_type(HeadType.LeafBlower) + await self._ensure_controller() + await self._transport.publish("set_roller_speed", {"speed": speed}) + + # ------------------------------------------------------------------ + # Motor & mechanical + # ------------------------------------------------------------------ + + async def set_motor_protect(self, state: int) -> None: + """Enable or disable motor protection mode. + + Args: + state: 1 to enable, 0 to disable. + """ + await self._ensure_controller() + await self._transport.publish("cmd_motor_protect", {"state": state}) + + async def set_trimmer(self, state: int) -> None: + """Enable or disable the trimmer. + + Args: + state: 1 to enable, 0 to disable. + """ + await self._ensure_controller() + await self._transport.publish("cmd_trimmer", {"state": state}) + + # ------------------------------------------------------------------ + # Blowing / edge / smart features + # ------------------------------------------------------------------ + + async def set_edge_blowing(self, state: int) -> None: + """Enable or disable edge blowing. + + Args: + state: 1 to enable, 0 to disable. + """ + await self._ensure_controller() + await self._transport.publish("edge_blowing", {"state": state}) + + async def set_smart_blowing(self, state: int) -> None: + """Enable or disable smart blowing. + + Args: + state: 1 to enable, 0 to disable. + """ + await self._ensure_controller() + await self._transport.publish("smart_blowing", {"state": state}) + + async def set_heating_film(self, state: int) -> None: + """Enable or disable heating film (anti-ice). + + Args: + state: 1 to enable, 0 to disable. + """ + await self._ensure_controller() + await self._transport.publish("heating_film_ctrl", {"state": state}) + + async def set_module_lock(self, state: int) -> None: + """Lock or unlock an accessory module. + + Args: + state: 1 to lock, 0 to unlock. + """ + await self._ensure_controller() + await self._transport.publish("module_lock_ctl", {"state": state}) + + # ------------------------------------------------------------------ + # Autonomous modes + # ------------------------------------------------------------------ + + async def set_follow_mode(self, state: int) -> None: + """Enable or disable follow mode. + + Args: + state: 1 to enable, 0 to disable. + """ + await self._ensure_controller() + await self._transport.publish("set_follow_state", {"state": state}) + + async def set_draw_mode(self, state: int) -> None: + """Enable or disable draw/mapping mode. + + Args: + state: 1 to enable, 0 to disable. + """ + await self._ensure_controller() + await self._transport.publish("start_draw_cmd", {"state": state}) + + # ------------------------------------------------------------------ + # OTA / firmware updates + # ------------------------------------------------------------------ + + async def set_auto_update(self, state: int) -> None: + """Enable or disable automatic firmware (Greengrass) updates. + + Args: + state: 1 to enable, 0 to disable. + """ + await self._ensure_controller() + await self._transport.publish("set_greengrass_auto_update_switch", {"state": state}) + + async def set_camera_ota(self, state: int) -> None: + """Enable or disable IP camera OTA updates. + + Args: + state: 1 to enable, 0 to disable. + """ + await self._ensure_controller() + await self._transport.publish("set_ipcamera_ota_switch", {"state": state}) + + async def firmware_update_now(self, *, confirm: bool = False) -> YarboCommandResult: + """Trigger an immediate firmware update. + + .. warning:: + This is a **destructive** operation. You must pass ``confirm=True`` to proceed. + + Args: + confirm: Must be ``True`` to proceed. + + Raises: + ValueError: If *confirm* is not ``True``. + YarboTimeoutError: If no acknowledgement is received. + """ + if not confirm: + raise ValueError("firmware_update_now is destructive — pass confirm=True to proceed.") + return await self._publish_and_wait("firmware_update_now", {}) + + async def firmware_update_tonight(self) -> YarboCommandResult: + """Schedule a firmware update for tonight.""" + return await self._publish_and_wait("firmware_update_tonight", {}) + + async def firmware_update_later(self) -> YarboCommandResult: + """Defer a pending firmware update.""" + return await self._publish_and_wait("firmware_update_later", {}) + + # ------------------------------------------------------------------ + # Vision / recording + # ------------------------------------------------------------------ + + async def set_smart_vision(self, state: int) -> None: + """Enable or disable smart vision processing. + + Args: + state: 1 to enable, 0 to disable. + """ + await self._ensure_controller() + await self._transport.publish("smart_vision_control", {"state": state}) + + async def set_video_record(self, state: int) -> None: + """Enable or disable video recording. + + Args: + state: 1 to enable, 0 to disable. + """ + await self._ensure_controller() + await self._transport.publish("enable_video_record", {"state": state}) + + async def bag_record(self, enabled: bool) -> None: + """Start or stop bag recording. + + Args: + enabled: True to start recording, False to stop. + """ + await self._ensure_controller() + await self._transport.publish("bag_record", {"state": 1 if enabled else 0}) + + # ------------------------------------------------------------------ + # Safety / fencing + # ------------------------------------------------------------------ + + async def set_child_lock(self, state: int) -> None: + """Enable or disable the child lock. + + Args: + state: 1 to enable, 0 to disable. + """ + await self._ensure_controller() + await self._transport.publish("child_lock", {"state": state}) + + async def set_geo_fence(self, state: int) -> None: + """Enable or disable geo-fencing. + + Args: + state: 1 to enable, 0 to disable. + """ + await self._ensure_controller() + await self._transport.publish("enable_geo_fence", {"state": state}) + + async def set_elec_fence(self, state: int) -> None: + """Enable or disable the electric fence. + + Args: + state: 1 to enable, 0 to disable. + """ + await self._ensure_controller() + await self._transport.publish("enable_elec_fence", {"state": state}) + + async def set_ngz_edge(self, state: int) -> None: + """Enable or disable NGZ (no-go-zone) edge enforcement. + + Args: + state: 1 to enable, 0 to disable. + """ + await self._ensure_controller() + await self._transport.publish("ngz_edge", {"state": state}) + + # ------------------------------------------------------------------ + # Manual drive extras + # ------------------------------------------------------------------ + + async def set_velocity_manual(self, linear: float, angular: float) -> None: + """Send a velocity command in manual drive mode. + + Args: + linear: Linear velocity (forward positive). + angular: Angular velocity (counter-clockwise positive). + """ + await self._ensure_controller() + await self._transport.publish("cmd_vel", {"vel": linear, "rev": angular}) + + # ------------------------------------------------------------------ + # Map management (destructive) + # ------------------------------------------------------------------ + + async def erase_map(self, confirm: bool = False) -> None: + """Erase the robot's stored map. + + .. warning:: + This is a **destructive** operation. The map cannot be recovered + after erasure. You must pass ``confirm=True`` to proceed. + + Args: + confirm: Must be ``True`` to proceed. + + Raises: + ValueError: If *confirm* is not ``True``. + """ + if not confirm: + raise ValueError("erase_map is destructive — pass confirm=True to proceed.") + await self._ensure_controller() + await self._transport.publish("erase_map", {}) + + async def map_recovery(self, map_id: str, confirm: bool = False) -> None: + """Restore a map from a backup by ID. + + .. warning:: + This is a **destructive** operation — it overwrites the current map. + You must pass ``confirm=True`` to proceed. + + Args: + map_id: ID of the map backup to restore. + confirm: Must be ``True`` to proceed. + + Raises: + ValueError: If *confirm* is not ``True``. + """ + if not confirm: + raise ValueError("map_recovery is destructive — pass confirm=True to proceed.") + await self._ensure_controller() + await self._transport.publish("map_recovery", {"mapId": map_id}) + + async def save_current_map(self) -> None: + """Save the robot's current map state.""" + await self._ensure_controller() + await self._transport.publish("save_current_map", {}) + + async def save_map_backup_list(self, timeout: float = 5.0) -> dict[str, Any]: + """Save map backup and retrieve all map backup names and IDs. + + Args: + timeout: Seconds to wait for the response (default 5.0). + + Returns: + Response payload dict, or empty dict on timeout. + """ + return await self._request_data_feedback( + "save_map_backup_and_get_all_map_backup_nameandid", {}, timeout + ) + # ------------------------------------------------------------------ # Sync wrapper # ------------------------------------------------------------------ @@ -1253,7 +1722,7 @@ def connect_sync( Example:: - client = YarboLocalClient.connect_sync(broker="", sn="YOUR_SERIAL") + client = YarboLocalClient.connect_sync(broker="192.168.1.24", sn="24400102...") client.lights_on() client.buzzer() client.disconnect() diff --git a/src/yarbo/mqtt.py b/src/yarbo/mqtt.py index 55c80c6..5e6a2e5 100644 --- a/src/yarbo/mqtt.py +++ b/src/yarbo/mqtt.py @@ -196,7 +196,7 @@ def _create_and_connect_paho(self) -> Any: cert_reqs=ssl.CERT_REQUIRED, ) else: - client.tls_set(ssl_context=ssl.create_default_context()) + client.tls_set_context(ssl.create_default_context()) # Blocking: DNS resolution + TCP connect (+ optional TLS handshake). client.connect(self._broker, self._port, keepalive=MQTT_KEEPALIVE) @@ -212,7 +212,7 @@ async def connect(self) -> None: """ loop = asyncio.get_running_loop() try: - self._client = await loop.run_in_executor(None, self._create_and_connect_paho) + client = await loop.run_in_executor(None, self._create_and_connect_paho) except ImportError as exc: raise YarboConnectionError( "paho-mqtt is required: pip install 'python-yarbo[mqtt]'" @@ -222,22 +222,23 @@ async def connect(self) -> None: f"Cannot connect to MQTT broker {self._broker}:{self._port}: {exc}" ) from exc + self._client = client # Back on the asyncio event loop: capture the loop reference *now* so # that paho callbacks (_on_connect, _on_disconnect, _on_message) bridge # back via call_soon_threadsafe on the correct, live loop — not a dead # throwaway loop that was closed after an executor run. self._loop = loop self._connected.clear() - self._client.on_connect = self._on_connect - self._client.on_disconnect = self._on_disconnect - self._client.on_message = self._on_message - self._client.loop_start() + client.on_connect = self._on_connect + client.on_disconnect = self._on_disconnect + client.on_message = self._on_message + client.loop_start() try: await asyncio.wait_for(self._connected.wait(), timeout=self._connect_timeout) except TimeoutError as exc: # Run loop_stop in executor — it joins the paho thread and must not block the loop. - await loop.run_in_executor(None, self._client.loop_stop) + await loop.run_in_executor(None, client.loop_stop) raise YarboTimeoutError( f"Timed out waiting for MQTT connection to {self._broker}:{self._port}" ) from exc diff --git a/tests/test_cli.py b/tests/test_cli.py index 86a9bc5..14f82d9 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -263,9 +263,11 @@ class TestRunDiscover: async def test_prints_no_endpoints_when_empty(self, capsys): """'No Yarbo endpoints found.' printed and exits 1 when discover returns [].""" args = argparse.Namespace(subnet=None, timeout=5.0, port=1883, max_hosts=512) - with patch("yarbo._cli.discover", AsyncMock(return_value=[])): - with pytest.raises(SystemExit) as exc_info: - await _run_discover(args) + with ( + patch("yarbo._cli.discover", AsyncMock(return_value=[])), + pytest.raises(SystemExit) as exc_info, + ): + await _run_discover(args) assert exc_info.value.code == 1 out = capsys.readouterr().out assert "No Yarbo endpoints found" in out diff --git a/tests/test_error_reporting.py b/tests/test_error_reporting.py index bd0ee02..76b745c 100644 --- a/tests/test_error_reporting.py +++ b/tests/test_error_reporting.py @@ -5,6 +5,8 @@ import sys from unittest.mock import MagicMock +import pytest + from yarbo.error_reporting import ( _scrub_dict, _scrub_event, @@ -125,6 +127,7 @@ def test_scrub_mqtt_envelope_scrubs_payload(self): class TestReportMqttDumpToGlitchtip: def test_returns_false_when_sentry_not_initialized(self, monkeypatch): """When Sentry is not initialized, report_mqtt_dump_to_glitchtip returns False.""" + pytest.importorskip("sentry_sdk") monkeypatch.setattr("sentry_sdk.is_initialized", lambda: False) result = report_mqtt_dump_to_glitchtip([{"direction": "sent", "topic": "t", "payload": {}}]) assert result is False diff --git a/tests/test_mqtt.py b/tests/test_mqtt.py index 091f18c..6de1244 100644 --- a/tests/test_mqtt.py +++ b/tests/test_mqtt.py @@ -558,23 +558,16 @@ async def test_tls_no_ca_uses_default_context(self, mock_paho): mock_ctx = MagicMock() with patch("ssl.create_default_context", return_value=mock_ctx) as mock_create: transport = MqttTransport(broker="broker.example.com", sn="SN1", tls=True) - connect_task = asyncio.create_task(transport.connect()) - await asyncio.sleep(0) # let connect() run up to wait_for(_connected) - - # tls_set must have been called before loop_start/wait_for - mock_paho.tls_set.assert_called_once() - call_kwargs = mock_paho.tls_set.call_args[1] - assert call_kwargs.get("ssl_context") is mock_ctx, ( - "Expected ssl_context=create_default_context() when tls_ca_certs is None" - ) - # CERT_NONE must never appear - assert call_kwargs.get("cert_reqs") != ssl.CERT_NONE + # Call _create_and_connect_paho directly (avoids executor thread mock issues) + transport._create_and_connect_paho() + + # tls_set_context must have been called with the default context + mock_paho.tls_set_context.assert_called_once_with(mock_ctx) + # tls_set must NOT have been called (no CA = context path) + mock_paho.tls_set.assert_not_called() mock_create.assert_called_once_with() # Clean up the pending connect task - mock_paho._fire_connect(0) - await asyncio.sleep(0) - await connect_task async def test_tls_with_ca_certs_uses_cert_required(self, mock_paho): """When tls=True and tls_ca_certs is set, CERT_REQUIRED and ca_certs are used.""" @@ -582,8 +575,8 @@ async def test_tls_with_ca_certs_uses_cert_required(self, mock_paho): transport = MqttTransport( broker="broker.example.com", sn="SN1", tls=True, tls_ca_certs="/path/to/ca.pem" ) - connect_task = asyncio.create_task(transport.connect()) - await asyncio.sleep(0) + # Call _create_and_connect_paho directly (avoids executor thread mock issues) + transport._create_and_connect_paho() mock_paho.tls_set.assert_called_once() call_kwargs = mock_paho.tls_set.call_args[1] @@ -592,25 +585,21 @@ async def test_tls_with_ca_certs_uses_cert_required(self, mock_paho): # ssl_context path must NOT be taken when a CA file is given assert "ssl_context" not in call_kwargs - mock_paho._fire_connect(0) - await asyncio.sleep(0) - await connect_task - async def test_cert_none_never_used_as_default(self, mock_paho): """ssl.CERT_NONE must never appear in the tls_set call for the default config.""" with patch("ssl.create_default_context", return_value=MagicMock()): transport = MqttTransport(broker="broker.example.com", sn="SN1", tls=True) - connect_task = asyncio.create_task(transport.connect()) - await asyncio.sleep(0) + # Call _create_and_connect_paho directly (avoids executor thread mock issues) + transport._create_and_connect_paho() - for call in mock_paho.tls_set.call_args_list: + # Check both tls_set and tls_set_context calls + all_calls = list(mock_paho.tls_set.call_args_list) + list( + mock_paho.tls_set_context.call_args_list + ) + for call in all_calls: args, kwargs = call assert ssl.CERT_NONE not in args, "CERT_NONE must not appear in positional args" assert kwargs.get("cert_reqs") != ssl.CERT_NONE, ( "CERT_NONE must not be passed as cert_reqs" ) - - mock_paho._fire_connect(0) - await asyncio.sleep(0) - await connect_task diff --git a/tests/test_typed_commands.py b/tests/test_typed_commands.py new file mode 100644 index 0000000..4905595 --- /dev/null +++ b/tests/test_typed_commands.py @@ -0,0 +1,682 @@ +"""Tests for the typed command methods added to YarboLocalClient and YarboClient.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from yarbo.client import YarboClient +from yarbo.local import YarboLocalClient + +# --------------------------------------------------------------------------- +# Shared fixture: mock MqttTransport for YarboLocalClient tests +# --------------------------------------------------------------------------- + + +@pytest.fixture +def mock_transport(): + """Mock MqttTransport so no real MQTT broker is required.""" + with patch("yarbo.local.MqttTransport") as MockTransport: # noqa: N806 + instance = MagicMock() + instance.connect = AsyncMock() + instance.disconnect = AsyncMock() + instance.publish = AsyncMock() + instance.wait_for_message = AsyncMock(return_value={"topic": "cmd", "data": {}}) + instance.create_wait_queue = MagicMock(return_value=MagicMock()) + instance.is_connected = True + MockTransport.return_value = instance + yield instance + + +@pytest.fixture +def client(mock_transport): + """A connected YarboLocalClient with controller already acquired.""" + c = YarboLocalClient(broker="192.168.1.24", sn="TEST") + c._controller_acquired = True + return c + + +# --------------------------------------------------------------------------- +# Shared fixture: mock YarboLocalClient for YarboClient delegation tests +# --------------------------------------------------------------------------- + + +@pytest.fixture +def mock_local(): + """Replace YarboLocalClient with a mock inside YarboClient.""" + with patch("yarbo.client.YarboLocalClient") as MockLocal: # noqa: N806 + instance = MagicMock() + instance.connect = AsyncMock() + instance.disconnect = AsyncMock() + instance.is_connected = True + + # All new typed methods + for name in [ + "shutdown", + "restart_container", + "emergency_stop", + "emergency_unlock", + "dstop", + "resume", + "cmd_recharge", + "set_head_light", + "set_roof_lights", + "set_laser", + "set_sound", + "play_song", + "set_camera", + "set_person_detect", + "set_usb", + "start_plan_direct", + "delete_plan_direct", + "delete_all_plans", + "pause_planning", + "in_plan_action", + "start_waypoint", + "save_charging_point", + "save_map_backup", + "start_hotspot", + # new typed methods + "publish_command", + "set_blade_height", + "set_blade_speed", + "set_charge_limit", + "set_turn_type", + "push_snow_dir", + "set_chute_steering_work", + "set_roller_speed", + "set_motor_protect", + "set_trimmer", + "set_edge_blowing", + "set_smart_blowing", + "set_heating_film", + "set_module_lock", + "set_follow_mode", + "set_draw_mode", + "set_auto_update", + "set_camera_ota", + "set_smart_vision", + "set_video_record", + "set_child_lock", + "set_geo_fence", + "set_elec_fence", + "set_ngz_edge", + "set_velocity_manual", + "set_sound_param", + "erase_map", + "map_recovery", + "save_current_map", + # data_feedback methods return a dict + "read_plan", + "read_all_plans", + "read_schedules", + "read_recharge_point", + "read_clean_area", + "get_all_map_backup", + "get_wifi_list", + "get_connected_wifi", + "get_hub_info", + "read_no_charge_period", + "get_battery_cell_temps", + "get_motor_temps", + "get_body_current", + "get_head_current", + "get_speed", + "get_odometer", + "get_product_code", + ]: + setattr(instance, name, AsyncMock(return_value={})) + + MockLocal.return_value = instance + yield instance + + +# =========================================================================== +# Tests: YarboLocalClient — robot control +# =========================================================================== + + +@pytest.mark.asyncio +class TestRobotControl: + async def test_shutdown(self, client, mock_transport): + await client.shutdown() + mock_transport.publish.assert_called_once_with("shutdown", {}) + + async def test_restart_container(self, client, mock_transport): + await client.restart_container() + mock_transport.publish.assert_called_once_with("restart_container", {}) + + async def test_emergency_stop(self, client, mock_transport): + await client.emergency_stop() + mock_transport.publish.assert_called_once_with("emergency_stop_active", {}) + + async def test_emergency_unlock(self, client, mock_transport): + await client.emergency_unlock() + mock_transport.publish.assert_called_once_with("emergency_unlock", {}) + + async def test_dstop(self, client, mock_transport): + await client.dstop() + mock_transport.publish.assert_called_once_with("dstop", {}) + + async def test_resume(self, client, mock_transport): + await client.resume() + mock_transport.publish.assert_called_once_with("resume", {}) + + async def test_cmd_recharge(self, client, mock_transport): + await client.cmd_recharge() + mock_transport.publish.assert_called_once_with("cmd_recharge", {}) + + +# =========================================================================== +# Tests: YarboLocalClient — lights & sound +# =========================================================================== + + +@pytest.mark.asyncio +class TestLightsSound: + async def test_set_head_light_on(self, client, mock_transport): + await client.set_head_light(True) + mock_transport.publish.assert_called_once_with("head_light", {"state": 1}) + + async def test_set_head_light_off(self, client, mock_transport): + await client.set_head_light(False) + mock_transport.publish.assert_called_once_with("head_light", {"state": 0}) + + async def test_set_roof_lights_on(self, client, mock_transport): + await client.set_roof_lights(True) + mock_transport.publish.assert_called_once_with("roof_lights_enable", {"enable": 1}) + + async def test_set_roof_lights_off(self, client, mock_transport): + await client.set_roof_lights(False) + mock_transport.publish.assert_called_once_with("roof_lights_enable", {"enable": 0}) + + async def test_set_laser_on(self, client, mock_transport): + await client.set_laser(True) + mock_transport.publish.assert_called_once_with("laser_toggle", {"enabled": True}) + + async def test_set_laser_off(self, client, mock_transport): + await client.set_laser(False) + mock_transport.publish.assert_called_once_with("laser_toggle", {"enabled": False}) + + async def test_set_sound_volume(self, client, mock_transport): + await client.set_sound(75) + mock_transport.publish.assert_called_once_with("set_sound_param", {"vol": 75, "songId": 0}) + + async def test_play_song(self, client, mock_transport): + await client.play_song(3) + mock_transport.publish.assert_called_once_with("song_cmd", {"songId": 3}) + + +# =========================================================================== +# Tests: YarboLocalClient — camera & detection +# =========================================================================== + + +@pytest.mark.asyncio +class TestCameraDetection: + async def test_set_camera_on(self, client, mock_transport): + await client.set_camera(True) + mock_transport.publish.assert_called_once_with("camera_toggle", {"enabled": True}) + + async def test_set_camera_off(self, client, mock_transport): + await client.set_camera(False) + mock_transport.publish.assert_called_once_with("camera_toggle", {"enabled": False}) + + async def test_set_person_detect_on(self, client, mock_transport): + await client.set_person_detect(True) + mock_transport.publish.assert_called_once_with("set_person_detect", {"enable": 1}) + + async def test_set_person_detect_off(self, client, mock_transport): + await client.set_person_detect(False) + mock_transport.publish.assert_called_once_with("set_person_detect", {"enable": 0}) + + async def test_set_usb_on(self, client, mock_transport): + await client.set_usb(True) + mock_transport.publish.assert_called_once_with("usb_toggle", {"enabled": True}) + + async def test_set_usb_off(self, client, mock_transport): + await client.set_usb(False) + mock_transport.publish.assert_called_once_with("usb_toggle", {"enabled": False}) + + +# =========================================================================== +# Tests: YarboLocalClient — plans & scheduling +# =========================================================================== + + +@pytest.mark.asyncio +class TestPlansScheduling: + async def test_start_plan_default_percent(self, client, mock_transport): + await client.start_plan_direct(7) + mock_transport.publish.assert_called_once_with("start_plan", {"planId": 7, "percent": 100}) + + async def test_start_plan_custom_percent(self, client, mock_transport): + await client.start_plan_direct(3, percent=50) + mock_transport.publish.assert_called_once_with("start_plan", {"planId": 3, "percent": 50}) + + async def test_read_plan(self, client, mock_transport): + mock_transport.wait_for_message = AsyncMock( + return_value={"topic": "read_plan", "data": {"id": 1}} + ) + result = await client.read_plan(1) + mock_transport.publish.assert_called_once_with("read_plan", {"id": 1}) + assert isinstance(result, dict) + + async def test_read_all_plans(self, client, mock_transport): + mock_transport.wait_for_message = AsyncMock( + return_value={"topic": "read_all_plan", "data": []} + ) + result = await client.read_all_plans() + mock_transport.publish.assert_called_once_with("read_all_plan", {}) + assert isinstance(result, dict) + + async def test_delete_plan(self, client, mock_transport): + await client.delete_plan_direct(5, confirm=True) + mock_transport.publish.assert_called_once_with("del_plan", {"planId": 5}) + + async def test_delete_plan_requires_confirm(self, client, mock_transport): + with pytest.raises(ValueError, match="confirm=True"): + await client.delete_plan_direct(5) + + async def test_delete_all_plans(self, client, mock_transport): + await client.delete_all_plans(confirm=True) + mock_transport.publish.assert_called_once_with("del_all_plan", {}) + + async def test_delete_all_plans_requires_confirm(self, client, mock_transport): + with pytest.raises(ValueError, match="confirm=True"): + await client.delete_all_plans() + + async def test_pause_plan(self, client, mock_transport): + await client.pause_planning() + mock_transport.publish.assert_called_once_with("planning_paused", {}) + + async def test_in_plan_action(self, client, mock_transport): + await client.in_plan_action("pause") + mock_transport.publish.assert_called_once_with("in_plan_action", {"action": "pause"}) + + async def test_read_schedules(self, client, mock_transport): + mock_transport.wait_for_message = AsyncMock( + return_value={"topic": "read_schedules", "data": []} + ) + result = await client.read_schedules() + mock_transport.publish.assert_called_once_with("read_schedules", {}) + assert isinstance(result, dict) + + +# =========================================================================== +# Tests: YarboLocalClient — navigation & maps +# =========================================================================== + + +@pytest.mark.asyncio +class TestNavigationMaps: + async def test_start_waypoint(self, client, mock_transport): + await client.start_waypoint(2) + mock_transport.publish.assert_called_once_with("start_way_point", {"index": 2}) + + async def test_read_recharge_point(self, client, mock_transport): + mock_transport.wait_for_message = AsyncMock(return_value={"topic": "read_recharge_point"}) + result = await client.read_recharge_point() + mock_transport.publish.assert_called_once_with("read_recharge_point", {}) + assert isinstance(result, dict) + + async def test_save_charging_point(self, client, mock_transport): + await client.save_charging_point() + mock_transport.publish.assert_called_once_with("save_charging_point", {}) + + async def test_read_clean_area(self, client, mock_transport): + mock_transport.wait_for_message = AsyncMock(return_value={"topic": "read_clean_area"}) + result = await client.read_clean_area() + mock_transport.publish.assert_called_once_with("read_clean_area", {}) + assert isinstance(result, dict) + + async def test_get_all_map_backup(self, client, mock_transport): + mock_transport.wait_for_message = AsyncMock(return_value={"topic": "get_all_map_backup"}) + result = await client.get_all_map_backup() + mock_transport.publish.assert_called_once_with("get_all_map_backup", {}) + assert isinstance(result, dict) + + async def test_save_map_backup(self, client, mock_transport): + await client.save_map_backup() + mock_transport.publish.assert_called_once_with("save_map_backup", {}) + + +# =========================================================================== +# Tests: YarboLocalClient — WiFi & connectivity +# =========================================================================== + + +@pytest.mark.asyncio +class TestWifiConnectivity: + async def test_get_wifi_list(self, client, mock_transport): + mock_transport.wait_for_message = AsyncMock(return_value={"topic": "get_wifi_list"}) + result = await client.get_wifi_list() + mock_transport.publish.assert_called_once_with("get_wifi_list", {}) + assert isinstance(result, dict) + + async def test_get_connected_wifi(self, client, mock_transport): + mock_transport.wait_for_message = AsyncMock(return_value={"topic": "get_connect_wifi_name"}) + result = await client.get_connected_wifi() + mock_transport.publish.assert_called_once_with("get_connect_wifi_name", {}) + assert isinstance(result, dict) + + async def test_start_hotspot(self, client, mock_transport): + await client.start_hotspot() + mock_transport.publish.assert_called_once_with("start_hotspot", {}) + + async def test_get_hub_info(self, client, mock_transport): + mock_transport.wait_for_message = AsyncMock(return_value={"topic": "hub_info"}) + result = await client.get_hub_info() + mock_transport.publish.assert_called_once_with("hub_info", {}) + assert isinstance(result, dict) + + +# =========================================================================== +# Tests: YarboLocalClient — diagnostics +# =========================================================================== + + +@pytest.mark.asyncio +class TestDiagnostics: + async def test_read_no_charge_period(self, client, mock_transport): + mock_transport.wait_for_message = AsyncMock(return_value={"topic": "read_no_charge_period"}) + result = await client.read_no_charge_period() + mock_transport.publish.assert_called_once_with("read_no_charge_period", {}) + assert isinstance(result, dict) + + async def test_get_battery_cell_temps(self, client, mock_transport): + mock_transport.wait_for_message = AsyncMock(return_value={"topic": "battery_cell_temp_msg"}) + result = await client.get_battery_cell_temps() + mock_transport.publish.assert_called_once_with("battery_cell_temp_msg", {}) + assert isinstance(result, dict) + + async def test_get_motor_temps(self, client, mock_transport): + mock_transport.wait_for_message = AsyncMock(return_value={"topic": "motor_temp_samp"}) + result = await client.get_motor_temps() + mock_transport.publish.assert_called_once_with("motor_temp_samp", {}) + assert isinstance(result, dict) + + async def test_get_body_current(self, client, mock_transport): + mock_transport.wait_for_message = AsyncMock(return_value={"topic": "body_current_msg"}) + result = await client.get_body_current() + mock_transport.publish.assert_called_once_with("body_current_msg", {}) + assert isinstance(result, dict) + + async def test_get_head_current(self, client, mock_transport): + mock_transport.wait_for_message = AsyncMock(return_value={"topic": "head_current_msg"}) + result = await client.get_head_current() + mock_transport.publish.assert_called_once_with("head_current_msg", {}) + assert isinstance(result, dict) + + async def test_get_speed(self, client, mock_transport): + mock_transport.wait_for_message = AsyncMock(return_value={"topic": "speed_msg"}) + result = await client.get_speed() + mock_transport.publish.assert_called_once_with("speed_msg", {}) + assert isinstance(result, dict) + + async def test_get_odometer(self, client, mock_transport): + mock_transport.wait_for_message = AsyncMock(return_value={"topic": "odometer_msg"}) + result = await client.get_odometer() + mock_transport.publish.assert_called_once_with("odometer_msg", {}) + assert isinstance(result, dict) + + async def test_get_product_code(self, client, mock_transport): + mock_transport.wait_for_message = AsyncMock(return_value={"topic": "product_code_msg"}) + result = await client.get_product_code() + mock_transport.publish.assert_called_once_with("product_code_msg", {}) + assert isinstance(result, dict) + + +# =========================================================================== +# Tests: _request_data_feedback timeout returns empty dict +# =========================================================================== + + +@pytest.mark.asyncio +class TestDataFeedbackTimeout: + async def test_timeout_returns_empty_dict(self, client, mock_transport): + """On timeout (None from wait_for_message), returns {}.""" + mock_transport.wait_for_message = AsyncMock(return_value=None) + result = await client.read_plan(1) + assert result == {} + + async def test_non_dict_response_returns_empty_dict(self, client, mock_transport): + """On unexpected response type, returns {}.""" + mock_transport.wait_for_message = AsyncMock(return_value="unexpected") + result = await client.get_speed() + assert result == {} + + +# =========================================================================== +# Tests: YarboLocalClient — new typed commands (Task B) +# =========================================================================== + + +@pytest.mark.asyncio +class TestBladeConfiguration: + async def test_set_blade_height(self, client, mock_transport): + await client.set_blade_height(3) + mock_transport.publish.assert_called_once_with("set_blade_height", {"height": 3}) + + async def test_set_blade_speed(self, client, mock_transport): + await client.set_blade_speed(5) + mock_transport.publish.assert_called_once_with("set_blade_speed", {"speed": 5}) + + async def test_set_charge_limit(self, client, mock_transport): + await client.set_charge_limit(20, 80) + mock_transport.publish.assert_called_once_with("set_charge_limit", {"min": 20, "max": 80}) + + async def test_set_turn_type(self, client, mock_transport): + await client.set_turn_type(2) + mock_transport.publish.assert_called_once_with("set_turn_type", {"turnType": 2}) + + +@pytest.mark.asyncio +class TestSnowBlowerAccessories: + async def test_push_snow_dir(self, client, mock_transport): + await client.push_snow_dir(1) + mock_transport.publish.assert_called_once_with("push_snow_dir", {"dir": 1}) + + async def test_set_chute_steering_work(self, client, mock_transport): + await client.set_chute_steering_work(45) + mock_transport.publish.assert_called_once_with("set_chute_steering_work", {"state": 45}) + + async def test_set_roller_speed(self, client, mock_transport): + await client.set_roller_speed(1500) + mock_transport.publish.assert_called_once_with("set_roller_speed", {"speed": 1500}) + + +@pytest.mark.asyncio +class TestMotorAndMechanical: + async def test_set_motor_protect(self, client, mock_transport): + await client.set_motor_protect(1) + mock_transport.publish.assert_called_once_with("cmd_motor_protect", {"state": 1}) + + async def test_set_trimmer(self, client, mock_transport): + await client.set_trimmer(1) + mock_transport.publish.assert_called_once_with("cmd_trimmer", {"state": 1}) + + +@pytest.mark.asyncio +class TestBlowingAndEdgeFeatures: + async def test_set_edge_blowing(self, client, mock_transport): + await client.set_edge_blowing(1) + mock_transport.publish.assert_called_once_with("edge_blowing", {"state": 1}) + + async def test_set_smart_blowing(self, client, mock_transport): + await client.set_smart_blowing(0) + mock_transport.publish.assert_called_once_with("smart_blowing", {"state": 0}) + + async def test_set_heating_film(self, client, mock_transport): + await client.set_heating_film(1) + mock_transport.publish.assert_called_once_with("heating_film_ctrl", {"state": 1}) + + async def test_set_module_lock(self, client, mock_transport): + await client.set_module_lock(1) + mock_transport.publish.assert_called_once_with("module_lock_ctl", {"state": 1}) + + +@pytest.mark.asyncio +class TestAutonomousModes: + async def test_set_follow_mode(self, client, mock_transport): + await client.set_follow_mode(1) + mock_transport.publish.assert_called_once_with("set_follow_state", {"state": 1}) + + async def test_set_draw_mode(self, client, mock_transport): + await client.set_draw_mode(1) + mock_transport.publish.assert_called_once_with("start_draw_cmd", {"state": 1}) + + +@pytest.mark.asyncio +class TestOtaUpdates: + async def test_set_auto_update(self, client, mock_transport): + await client.set_auto_update(1) + mock_transport.publish.assert_called_once_with( + "set_greengrass_auto_update_switch", {"state": 1} + ) + + async def test_set_camera_ota(self, client, mock_transport): + await client.set_camera_ota(0) + mock_transport.publish.assert_called_once_with("set_ipcamera_ota_switch", {"state": 0}) + + +@pytest.mark.asyncio +class TestVisionAndRecording: + async def test_set_smart_vision(self, client, mock_transport): + await client.set_smart_vision(1) + mock_transport.publish.assert_called_once_with("smart_vision_control", {"state": 1}) + + async def test_set_video_record(self, client, mock_transport): + await client.set_video_record(1) + mock_transport.publish.assert_called_once_with("enable_video_record", {"state": 1}) + + +@pytest.mark.asyncio +class TestSafetyAndFencing: + async def test_set_child_lock(self, client, mock_transport): + await client.set_child_lock(1) + mock_transport.publish.assert_called_once_with("child_lock", {"state": 1}) + + async def test_set_geo_fence(self, client, mock_transport): + await client.set_geo_fence(1) + mock_transport.publish.assert_called_once_with("enable_geo_fence", {"state": 1}) + + async def test_set_elec_fence(self, client, mock_transport): + await client.set_elec_fence(1) + mock_transport.publish.assert_called_once_with("enable_elec_fence", {"state": 1}) + + async def test_set_ngz_edge(self, client, mock_transport): + await client.set_ngz_edge(0) + mock_transport.publish.assert_called_once_with("ngz_edge", {"state": 0}) + + +@pytest.mark.asyncio +class TestManualDriveExtras: + async def test_set_velocity_manual(self, client, mock_transport): + await client.set_velocity_manual(0.5, -0.3) + mock_transport.publish.assert_called_once_with("cmd_vel", {"vel": 0.5, "rev": -0.3}) + + async def test_set_sound_param(self, client, mock_transport): + await client.set_sound_param(80, 1) + mock_transport.publish.assert_called_once_with( + "set_sound_param", {"volume": 80, "enable": 1} + ) + + +@pytest.mark.asyncio +class TestPublishCommand: + async def test_publish_command_delegates_to_transport(self, client, mock_transport): + await client.publish_command("custom_cmd", {"key": "val"}) + mock_transport.publish.assert_called_once_with("custom_cmd", {"key": "val"}) + + +@pytest.mark.asyncio +class TestDestructiveMapCommands: + async def test_erase_map_requires_confirm(self, client, mock_transport): + with pytest.raises(ValueError, match="confirm=True"): + await client.erase_map() + + async def test_erase_map_with_confirm(self, client, mock_transport): + await client.erase_map(confirm=True) + mock_transport.publish.assert_called_once_with("erase_map", {}) + + async def test_map_recovery_requires_confirm(self, client, mock_transport): + with pytest.raises(ValueError, match="confirm=True"): + await client.map_recovery("backup-id-123") + + async def test_map_recovery_with_confirm(self, client, mock_transport): + await client.map_recovery("backup-id-123", confirm=True) + mock_transport.publish.assert_called_once_with("map_recovery", {"mapId": "backup-id-123"}) + + async def test_save_current_map(self, client, mock_transport): + await client.save_current_map() + mock_transport.publish.assert_called_once_with("save_current_map", {}) + + async def test_save_map_backup_list(self, client, mock_transport): + mock_transport.wait_for_message = AsyncMock(return_value={"topic": "save_map_backup_list"}) + result = await client.save_map_backup_list() + mock_transport.publish.assert_called_once_with( + "save_map_backup_and_get_all_map_backup_nameandid", {} + ) + assert isinstance(result, dict) + + +# =========================================================================== +# Tests: YarboClient delegation — spot-checks +# =========================================================================== + + +@pytest.mark.asyncio +class TestYarboClientDelegationTyped: + async def test_shutdown_delegates(self, mock_local): + async with YarboClient(broker="192.168.1.24", sn="TEST") as c: + await c.shutdown() + mock_local.shutdown.assert_called_once() + + async def test_emergency_stop_delegates(self, mock_local): + async with YarboClient(broker="192.168.1.24", sn="TEST") as c: + await c.emergency_stop() + mock_local.emergency_stop.assert_called_once() + + async def test_set_head_light_delegates(self, mock_local): + async with YarboClient(broker="192.168.1.24", sn="TEST") as c: + await c.set_head_light(True) + mock_local.set_head_light.assert_called_once_with(True) + + async def test_set_sound_delegates(self, mock_local): + async with YarboClient(broker="192.168.1.24", sn="TEST") as c: + await c.set_sound(50) + mock_local.set_sound.assert_called_once_with(50, 0) + + async def test_start_plan_delegates(self, mock_local): + async with YarboClient(broker="192.168.1.24", sn="TEST") as c: + await c.start_plan_direct(4, percent=80) + mock_local.start_plan_direct.assert_called_once_with(4, 80) + + async def test_read_plan_delegates(self, mock_local): + async with YarboClient(broker="192.168.1.24", sn="TEST") as c: + result = await c.read_plan(2) + mock_local.read_plan.assert_called_once_with(2, 5.0) + assert result == {} + + async def test_get_speed_delegates(self, mock_local): + async with YarboClient(broker="192.168.1.24", sn="TEST") as c: + result = await c.get_speed() + mock_local.get_speed.assert_called_once_with(5.0) + assert result == {} + + async def test_get_wifi_list_delegates(self, mock_local): + async with YarboClient(broker="192.168.1.24", sn="TEST") as c: + await c.get_wifi_list() + mock_local.get_wifi_list.assert_called_once_with(5.0) + + async def test_cmd_recharge_delegates(self, mock_local): + async with YarboClient(broker="192.168.1.24", sn="TEST") as c: + await c.cmd_recharge() + mock_local.cmd_recharge.assert_called_once() + + async def test_in_plan_action_delegates(self, mock_local): + async with YarboClient(broker="192.168.1.24", sn="TEST") as c: + await c.in_plan_action("stop") + mock_local.in_plan_action.assert_called_once_with("stop")