Skip to content

feat: add Yeedi Floor 3 Station (kd0una) support with opt-in NGIOT port 443 MQTT - #3

Draft
Qmo37 wants to merge 1 commit into
DeebotUniverse:mainfrom
Qmo37:feat/yeedi-floor3-station-kd0una-ngiot-port443
Draft

feat: add Yeedi Floor 3 Station (kd0una) support with opt-in NGIOT port 443 MQTT#3
Qmo37 wants to merge 1 commit into
DeebotUniverse:mainfrom
Qmo37:feat/yeedi-floor3-station-kd0una-ngiot-port443

Conversation

@Qmo37

@Qmo37 Qmo37 commented Mar 18, 2026

Copy link
Copy Markdown

Pull Request: Add Yeedi Floor 3 Station (kd0una) Support

Target repo: DeebotUniverse/Bumper
Status: DRAFT — core functionality working, station features and full command testing pending


Summary

Adds support for the Yeedi Floor 3 Station (model ID kd0una, product K960_ACS_INT) to Bumper. This device uses the eco-ng protocol (JSON over MQTT) but connects via MQTT over TLS on port 443 instead of the standard 8883, requiring a new listener binding and a post-connect handshake sequence.

Type of Change

  • New feature (non-breaking change which adds functionality)
  • Bug fix
  • Breaking change
  • Documentation update

Changes

1. MQTT Listener on Port 443 — NGIOT Binding (Opt-in)

Problem: The Yeedi Floor 3 Station initiates a raw MQTT v3.1.1 connection inside a TLS tunnel on port 443. Bumper's web server (HTTPS) also defaults to port 443, causing a conflict.

Solution: Opt-in NGIOT binding — the port 443 MQTT listener is only activated when MQTT_LISTEN_PORT_NGIOT is explicitly set as an environment variable. Existing users are completely unaffected. When opt-in is enabled, the web TLS port automatically shifts to 8443 to avoid the conflict (still configurable via WEB_SERVER_HTTPS_PORT).

Files changed:

bumper/utils/settings.py

     WEB_SERVER_TLS_LISTEN_PORT: int = int(os.environ.get("WEB_SERVER_HTTPS_PORT") or 443)
     WEB_SERVER_LISTEN_PORT: int = int(os.environ.get("WEB_SERVER_LISTEN_PORT") or 8007)
     MQTT_LISTEN_PORT: int = int(os.environ.get("MQTT_LISTEN_PORT") or 1883)
     MQTT_LISTEN_PORT_TLS: int = int(os.environ.get("MQTT_LISTEN_PORT_TLS") or 8883)
+    MQTT_LISTEN_PORT_NGIOT: int | None = (
+        int(os.environ["MQTT_LISTEN_PORT_NGIOT"])
+        if os.environ.get("MQTT_LISTEN_PORT_NGIOT")
+        else None
+    )

Web TLS port auto-adjusts when NGIOT is enabled:

-    WEB_SERVER_TLS_LISTEN_PORT: int = int(os.environ.get("WEB_SERVER_HTTPS_PORT") or 443)
+    MQTT_LISTEN_PORT_NGIOT: int | None = (
+        int(os.environ["MQTT_LISTEN_PORT_NGIOT"])
+        if os.environ.get("MQTT_LISTEN_PORT_NGIOT")
+        else None
+    )
+    WEB_SERVER_TLS_LISTEN_PORT: int = int(
+        os.environ.get("WEB_SERVER_HTTPS_PORT")
+        or (8443 if MQTT_LISTEN_PORT_NGIOT == 443 else 443)
+    )

bumper/__init__.py

-    bumper_isc.mqtt_server = server_mqtt.MQTTServer(
-        [
-            server_mqtt.MQTTBinding(bumper_isc.bumper_listen, bumper_isc.MQTT_LISTEN_PORT_TLS, True),
-            server_mqtt.MQTTBinding(bumper_isc.bumper_listen, bumper_isc.MQTT_LISTEN_PORT, False),
-        ],
-    )
+    _mqtt_bindings = [
+        server_mqtt.MQTTBinding(bumper_isc.bumper_listen, bumper_isc.MQTT_LISTEN_PORT_TLS, True),
+        server_mqtt.MQTTBinding(bumper_isc.bumper_listen, bumper_isc.MQTT_LISTEN_PORT, False),
+    ]
+    if bumper_isc.MQTT_LISTEN_PORT_NGIOT is not None:
+        _mqtt_bindings.append(
+            server_mqtt.MQTTBinding(bumper_isc.bumper_listen, bumper_isc.MQTT_LISTEN_PORT_NGIOT, True)
+        )
+    bumper_isc.mqtt_server = server_mqtt.MQTTServer(_mqtt_bindings)

Usage — to enable NGIOT (e.g. systemd service or .env):

MQTT_LISTEN_PORT_NGIOT=443

Web TLS automatically shifts to 8443. Override with WEB_SERVER_HTTPS_PORT if needed.

Default behavior (no env var set): Identical to current Bumper — no port 443 MQTT, web TLS stays on 443. Zero impact on existing users.


2. Device Class Registration

Problem: kd0una is not in Bumper's product IoT map, so the robot doesn't appear in GetGlobalDeviceList API responses.

Solution: Added entry to the unofficial product map.

bumper/web/plugins/api/pim/productIotMapUnofficial.json

+  {
+    "classid": "kd0una",
+    "product": {
+      "_id": "yeedi-floor3-station-kd0una",
+      "materialNo": "110-2122-0211",
+      "smartType": "MQ_AP",
+      "name": "yeedi Floor 3 Station",
+      "icon": "630ecb3e2a71f93c2cea5c0f",
+      "model": "K960_ACS_INT",
+      "UILogicId": "k960_ww_h_k960",
+      "ota": true,
+      "supportType": {
+        "share": true,
+        "tmjl": false,
+        "assistant": true,
+        "alexa": true
+      },
+      "iconUrl": "https://portal-ww.ecouser.net/api/pim/file/get/630ecb3e2a71f93c2cea5c0f",
+      "status": "valid"
+    }
+  }

No code changes needed — appsvr.py:_include_product_iot_map_info() already scans both official and unofficial maps by classid.


3. Post-Connect Handshake (SetTime + Config Push)

Problem: After MQTT CONNECT + CONNACK, the real Ecovacs cloud sends a SetTime command and a setting2 configuration push. Without these, the robot may not respond to commands properly.

Solution: Added _send_bot_handshake() method to BumperMQTTServerPlugin that fires automatically when a bot connects.

bumper/mqtt/server.py

New imports at top of file:

+import json
+import random
+import string
+import time

Modified on_broker_client_connected:

     async def on_broker_client_connected(self, client_id: str, client_session: Session) -> None:
         """On client connected."""
         self._set_client_connected(client_id, True, client_session)
+        # Send post-connect handshake for bot devices (SetTime + config)
+        asyncio.create_task(self._send_bot_handshake(client_id))

New method (appended before on_broker_client_disconnected):

async def _send_bot_handshake(self, client_id: str) -> None:
    """Send SetTime and config push to a newly connected bot."""
    try:
        if client_id == helper_bot.HELPER_BOT_CLIENT_ID:
            return

        if (result := self._client_id_split_helper(client_id)) is None:
            return
        did, class_id, resource, client_type = result

        if client_type != "bot":
            return

        helperbot = bumper_isc.mqtt_helperbot
        if helperbot is None or not await helperbot.is_connected:
            _LOGGER.warning(f"Handshake skipped :: HelperBot not connected :: {client_id}")
            return

        # Small delay to let the bot finish subscribing to topics
        await asyncio.sleep(1)

        requester_id = "HelperMQClientId-sts-ngiot-mqserver-eco1-1"
        request_id = "".join(random.choices(string.ascii_letters + string.digits, k=8))

        # 1. Send SetTime
        now_ms = int(time.time() * 1000)
        now_sec = int(time.time())
        set_time_topic = (
            f"iot/p2p/SetTime/{requester_id}/ecosys/1234/"
            f"{did}/{class_id}/{resource}/q/{request_id}/j"
        )
        set_time_payload = json.dumps({"ts": now_ms, "tsInSec": now_sec})
        await helperbot.publish(set_time_topic, set_time_payload)
        _LOGGER.info(f"Handshake :: SetTime sent to {client_id}")

        # 2. Push setting2 config
        setting2_topic = f"iot/cfg/{did}/{class_id}/{resource}/j/setting2"
        setting2_payload = json.dumps({
            "setting2": {"cfg": {"improve": {"version": "11.16", "isAccept": False}}}
        })
        await helperbot.publish(setting2_topic, setting2_payload)
        _LOGGER.info(f"Handshake :: setting2 config pushed to {client_id}")

    except Exception:
        _LOGGER.exception(f"Handshake failed for {client_id}")

Design notes:

  • The handshake runs as a fire-and-forget asyncio.create_task so it doesn't block the MQTT broker's connect callback
  • The 1-second delay ensures the robot has finished subscribing to its topics before receiving commands
  • Only triggers for bot clients (not HelperBot or user/app clients)
  • The handshake is benign for other device models — SetTime is a standard eco-ng command that all devices handle

Device Details

Device:       Yeedi Floor 3 Station
Model ID:     kd0una
Product:      K960_ACS_INT
Protocol:     eco-ng (JSON over MQTT v3.1.1)
MQTT Port:    443 (TLS) — NOT the standard 8883
Region:       TW (Taiwan)
Firmware:     1.10.0
Station:      ACSH (auto-empty, mop wash, mop dry)

MQTT Client ID format: {device_id}@kd0una/{resource}
DNS domains (TW region):

  • jmq-ngiot-tw.area.ww.ecouser.net (MQTT)
  • iotin-ww.ecouser.net (IoT init)
  • portal-ww.ecouser.net (REST portal)

Testing

Confirmed Working

  • Robot connects via MQTT over TLS on port 443
  • MQTT authentication succeeds (client ID, username, password accepted)
  • Robot subscribes to all 4 topic patterns
  • SetTime handshake sent and acknowledged (ret: ok)
  • setting2 config push sent
  • GetGlobalDeviceList REST API returns the device
  • getBattery command relayed and response received
  • Home Assistant Ecovacs integration connects and creates entities
  • Battery, charge state, fan speed, water level, volume, true detect all working
  • Consumable sensors (brush, filter, side brush) reporting
  • Vacuum start/stop/dock controls functional
  • Map data rendering in HA (populates after first cleaning run)
  • Robot position tracking (getPos) working
  • Map trace overlay working
  • Child lock, carpet boost, continuous cleaning switches functional
  • Mop auto wash frequency number entity working
  • Station state sensor working
  • Station action buttons (empty dustbin, wash mop, dry mop, clean base) working
  • Auto empty frequency select working
  • Robot reconnects automatically after Bumper restart
  • Bumper auto-starts on LXC boot via systemd

Not Yet Tested

  • Area/room cleaning commands
  • Fan speed changes via HA (quiet/normal/max/max_plus)
  • Water amount changes via HA
  • DND schedule (setBlock / getBlock)
  • Sleep mode (getSleep)
  • Mop drying duration (getDryingDuration — no deebot_client command class exists)
  • Multi-map switching
  • OTA update blocking (should Bumper suppress OTA?)
  • Long-term stability (>24h uptime)

Test Configuration

  • Bumper: Running on Ubuntu LXC (Proxmox), Python 3.13+
  • Home Assistant: HA OS on Proxmox, Ecovacs integration with self-hosted Bumper config
  • deebot_client: Custom kd0una.py hardware capability file (separate PR needed for DeebotUniverse/client.py)
  • Network: Robot on IoT VLAN, Bumper on home LAN, DNS overrides via OPNsense Unbound, firewall rule allowing port 443

Checklist

  • My code follows the style guidelines of this project
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • Any dependent changes have been merged and published in downstream modules

Related Work

  • deebot_client hardware file: kd0una.py needs to be contributed to DeebotUniverse/client.py as a separate PR for native HA support without manual deployment
  • Potential upstream discussion: The port 443 NGIOT binding may benefit other newer Ecovacs/Yeedi devices that use the same connection pattern. Consider making it opt-in via environment variable rather than always-on if the web TLS port change is controversial

Notes for Reviewers

  1. Port 443 is fully opt-in. The NGIOT binding only activates when MQTT_LISTEN_PORT_NGIOT is set. Default behavior is unchanged — existing users are unaffected. When opted in, web TLS shifts automatically from 443 → 8443 to avoid the conflict, and is still overridable via WEB_SERVER_HTTPS_PORT.

  2. The handshake benefits all eco-ng devices, not just kd0una. Many newer Ecovacs devices expect SetTime after connect. This is a general improvement.

  3. The product map entry is data-only — no code change, just a JSON addition. Low risk.

  4. No breaking API changes — all existing MQTT and REST behavior is preserved. The only difference is the web TLS default port number.

…MQTT

- Add opt-in MQTT listener on port 443 (MQTT_LISTEN_PORT_NGIOT env var)
  for devices using NGIOT protocol (raw MQTT over TLS on port 443)
- Auto-shift web TLS port to 8443 when NGIOT port is set to 443
- Add post-connect handshake (SetTime + setting2 config push) for newly
  connected bots, required by eco-ng devices after CONNACK
- Register kd0una (Yeedi Floor 3 Station) in productIotMapUnofficial.json

Existing users are unaffected — NGIOT binding is disabled by default.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Qmo37 added a commit to Qmo37/client.py that referenced this pull request Mar 18, 2026
Add hardware capability definition for the Yeedi Floor 3 Station
(model ID kd0una, product K960_ACS_INT). Device uses eco-ng protocol
(JSON over MQTT) and connects via MQTT over TLS on port 443.

Capabilities include: battery, charge, clean (with area), fan speed
(quiet/normal/max/max_plus), life span (brush/filter/side brush), map
(full map support with trace and position), network, play sound, stats,
water (4 levels + mop attached), station (auto empty, actions, state),
settings (true detect, volume, carpet boost, child lock, mop wash freq),
continuous cleaning, clean count, clean preference, error, custom command.

Verified working via Bumper self-hosted server with a real device.
Companion Bumper PR: DeebotUniverse/bumper#3

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant