Skip to content

feat(T2276): add map camera for X8-Pro-class devices - #636

Open
stevendejongnl wants to merge 11 commits into
damacus:mainfrom
stevendejongnl:feat/map-camera
Open

stevendejongnl wants to merge 11 commits into
damacus:mainfrom
stevendejongnl:feat/map-camera

Conversation

@stevendejongnl

@stevendejongnl stevendejongnl commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Adds a camera.<name>_map entity for X8-Pro-class devices (tested on a
T2276), showing the robot's current cloud floor-plan with one color per
room, the live cleaning route and the rooms of the current map. Also adds
a water level select for the T2276.

What it does

  • thing.m.dev.storage.config.get returns short-lived AWS STS
    credentials and a per-device storage path; the current room layout is
    a presigned S3 object at <path>/common/layout/lay.bin.
  • The file is a small header (magic 0pam, width, height,
    compressed/uncompressed lengths) wrapping a raw LZ4 block. Decompressed,
    it's one byte per pixel: high 6 bits are the room id, low 2 bits are a
    pixel type — the same room-outline raster already documented in
    martijnpoppen/eufy-clean's p2pdata.proto.
  • map_fetch.py implements the fetch/decode chain, including a
    pure-Python LZ4 block decoder instead of depending on the lz4
    package — HA's custom-component requirements install into a container
    with no C compiler and no prebuilt wheel for every Python version,
    which makes that dependency fail to install there.
  • camera.py wires this into a DataUpdateCoordinator-backed camera
    entity, one per vacuum that has Eufy account credentials set on its
    config entry item. It does an explicit first refresh on setup rather
    than waiting for the first scheduled interval, so the camera has real
    data from the moment HA starts instead of serving nothing for up to
    the first refresh interval after every restart.
  • docs/MAP_CAMERA.md covers enabling the camera and pairing it with
    lovelace-xiaomi-vacuum-map-card for a tap-to-clean room map, using
    calibration_source: identity so room outlines are plain image
    pixels instead of needing vacuum-coordinate calibration.

Live cleaning route

  • tuya.m.device.media.latest v3.0 returns only the newest frame of the
    current clean. The camera polls it while the vacuum cleans or returns and
    adds the points to a route (RouteTracker).
  • Pose frames are protobuf messages with zigzag x/y offsets from the dock,
    in 1/10 map pixel. The dock position comes from the lay.bin header.
  • The route is drawn over the map with a dot for the robot. It is split
    where a point is off the map or jumps more than 60 pixels. Poll answers
    without a record id or frames are ignored.

Room attributes

The camera lists each room of the current map with its id, pixel count and
centre, measured from the dock. The vacuum can give rooms new ids when it
builds a new map, so an automation can find a room again by its position.

Water level select (T2276)

The T2276 reports its mop water level on DPS 105 as Low, Mid or
High, and the mobile app writes the same strings. A new select entity
(select.<name>_water_level) reads and writes that datapoint with the
options Low, Medium and High. It works next to the read-only water level
sensor from #621, and the two do not depend on each other. Both define the
water level command in T2276.py and base.py, so whichever merges second
needs a small conflict fix.

Limitations

  • No config-flow UI yet for the Eufy credentials the camera needs —
    they're separate from the local Tuya connection the rest of the
    integration uses, and currently have to be added directly to the
    vacuum's config entry (documented in docs/MAP_CAMERA.md).
  • Only verified against a T2276. The lay.bin format and storage-config
    API are expected to be the same across the X8-Pro class, but that's
    unverified on other models.

Also includes one small unrelated fix: tuyalocaldiscovery.py had two
mypy no-any-return errors on a clean checkout of main (the
cryptography library ships no stubs, so bytes.decode() on its
output was typed Any) — fixed by wrapping both returns in str().

🤖 Generated with Claude Code

@stevendejongnl stevendejongnl changed the title Add map camera for X8-Pro-class devices feat: add map camera for X8-Pro-class devices Oct 25, 2026
@stevendejongnl stevendejongnl changed the title feat: add map camera for X8-Pro-class devices feat(T2276): add map camera for X8-Pro-class devices Oct 25, 2026
@damacus
damacus enabled auto-merge (squash) October 28, 2026 22:46
auto-merge was automatically disabled October 31, 2026 06:47

Head branch was pushed to by a user without write access

@stevendejongnl

Copy link
Copy Markdown
Contributor Author

Added the live cleaning route to the map camera, plus a rooms attribute.

  • While the vacuum is cleaning or returning, the camera polls tuya.m.device.media.latest (v3.0) and draws the robot's route over the map.
  • The camera now has a rooms attribute: id, pixel count and centre (relative to the dock) for each room.
  • The route resets when a new clean starts. Tests are in tests/test_map_fetch.py.

stevendejongnl and others added 11 commits November 2, 2026 08:52
bytes.decode() on a cryptography-library result was typed as Any
(the library ships no stubs), so mypy flagged both decrypt helpers as
returning Any where str was declared. Wrap the return in str() to
narrow it back to the declared type.
Reverse engineered the floor-plan map flow for X8-Pro-class devices
(tested against a T2276): thing.m.dev.storage.config.get returns
short-lived AWS STS credentials and a per-device storage path, the
current layout is a presigned S3 object at <path>/common/layout/lay.bin,
and the file itself is a small header (magic 0pam, width, height,
compressed/uncompressed lengths) wrapping a raw LZ4 block. Decompressed,
it's one byte per pixel: high 6 bits are the room id, low 2 bits are a
pixel type, matching the room-outline raster already documented in
martijnpoppen/eufy-clean's p2pdata.proto.

Includes a pure-Python LZ4 block decoder instead of depending on the
lz4 package, since HA's custom-component requirements install into a
container with no C compiler and no prebuilt wheel for every Python
version, which makes that dependency fail to install there.
A camera entity per vacuum showing its current cloud floor-plan map,
rendered from the room-id raster via map_fetch.py. Only created for
vacuums with Eufy account credentials configured (CONF_USERNAME /
CONF_PASSWORD on the config entry item), since the cloud fetch needs a
logged-in session; local-only setups are unaffected.

Uses a DataUpdateCoordinator with an explicit first refresh on setup,
rather than relying on the first scheduled interval tick, so the
camera has real data from the moment Home Assistant starts instead of
serving nothing (HTTP 500 from camera_proxy) for up to the first
refresh interval after every restart.
Covers enabling the camera entity (it needs Eufy cloud credentials on
the vacuum's config entry, with no config-flow UI for that yet) and
wiring lovelace-xiaomi-vacuum-map-card to it for tap-to-clean rooms,
using calibration_source: identity so room outlines are plain image
pixels instead of needing vacuum-coordinate calibration.
The cloud returns only the newest frame of the current clean through
tuya.m.device.media.latest v3.0, so the route is built by polling and
accumulating points. Header frames carry the map size, dock and robot
position. Pose frames are protobuf with zigzag x/y offsets from the dock,
in 1/10 map pixel.

The camera polls every 2 s while the vacuum entity is cleaning or
returning, resets the path when the clean record id changes, and draws
the route and robot position over the rendered map.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Pose frames are offsets from the dock. The first version learned the dock
only from media header frames, which are rare while a clean runs, so the
route could stay empty for a whole clean. The dock is also in the lay.bin
header (bytes 12-15, previously read as origin), so decode_layout now
returns it and the camera uses it right after the map loads.

Offsets are kept raw per clean record and resolved when the dock is known.
The poll interval drops to 1 s because only the newest frame can be read.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
media.latest sometimes answers with no subRecordId and no frames. The
coordinator read that as record 0, treated it as a new clean and wiped
the route mid-clean. Move the route state into RouteTracker, which
ignores answers without a record id or frames, and test it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
After the vacuum built a new map the route drew long lines across and
off the map. The layout was only reloaded every 15 minutes, and the dock
could come from either the layout or a media header frame.

Reload the layout when a new clean record starts and about once a minute
while polling, and take the dock from the layout so the route and the map
share one frame. Split the route wherever a point is off the map or jumps
more than 60 pixels, so glitches are not joined by straight lines.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The vacuum can assign new room ids when it builds a new map, so a fixed
id per room name goes stale. The map camera now lists each room with its
id, pixel count and centre in map pixels, measured from the dock. A room
keeps its place relative to the dock when the map origin shifts, so an
automation can recognise it again by position.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The T2276 reports its mop water level on DPS 105 as Low, Mid or High,
and the mobile app writes the same strings. Add a select entity that
reads and writes that datapoint, so the water level can be set from Home
Assistant and from automations.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Current Pillow type hints mark Image.load() as optional and no longer
list Image.NEAREST. Check the pixel access object and use
Image.Resampling.NEAREST, which behaves the same.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@stevendejongnl

Copy link
Copy Markdown
Contributor Author

Pushed an update:

  • Rebased onto main (3cb72dc).
  • Added a water level select for the T2276 (DPS 105: Low, Mid, High). On a live T2276 I set Low and then High from the select. The Eufy app showed Low after the first write, and the robot's own status showed High after the second.
  • Fixed two mypy errors in the map image renderer. Current Pillow type hints mark Image.load() as optional and no longer list Image.NEAREST.
  • Ran the CI commands locally: flake8 custom_components tests, mypy --config-file=pyproject.toml custom_components, the full test suite (825 passed) and markdownlint on docs/MAP_CAMERA.md. All passed.

I also updated the description to cover the live route, the room attributes and the water level select.

🤖 Generated with Claude Code

This branch has not been deployed

No deployments
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.

2 participants