Repository navigation
feat(T2276): add map camera for X8-Pro-class devices - #636
Open
stevendejongnl wants to merge 11 commits into
Open
stevendejongnl wants to merge 11 commits into
stevendejongnl wants to merge 11 commits into
Conversation
damacus
enabled auto-merge (squash)
October 28, 2026 22:46
damacus
approved these changes
Oct 28, 2026
auto-merge was automatically disabled
October 31, 2026 06:47
Head branch was pushed to by a user without write access
Contributor
Author
|
Added the live cleaning route to the map camera, plus a
|
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
force-pushed
the
feat/map-camera
branch
from
November 2, 2026 07:59
cb194f0 to
fc8fbac
Compare
Contributor
Author
|
Pushed an update:
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds a
camera.<name>_mapentity for X8-Pro-class devices (tested on aT2276), 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.getreturns short-lived AWS STScredentials and a per-device storage path; the current room layout is
a presigned S3 object at
<path>/common/layout/lay.bin.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.pyimplements the fetch/decode chain, including apure-Python LZ4 block decoder instead of depending on the
lz4package — 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.pywires this into aDataUpdateCoordinator-backed cameraentity, 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.mdcovers enabling the camera and pairing it withlovelace-xiaomi-vacuum-map-cardfor a tap-to-clean room map, usingcalibration_source: identityso room outlines are plain imagepixels instead of needing vacuum-coordinate calibration.
Live cleaning route
tuya.m.device.media.latestv3.0 returns only the newest frame of thecurrent clean. The camera polls it while the vacuum cleans or returns and
adds the points to a route (
RouteTracker).in 1/10 map pixel. The dock position comes from the
lay.binheader.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,MidorHigh, and the mobile app writes the same strings. A new select entity(
select.<name>_water_level) reads and writes that datapoint with theoptions 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.pyandbase.py, so whichever merges secondneeds a small conflict fix.
Limitations
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).lay.binformat and storage-configAPI 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.pyhad twomypyno-any-returnerrors on a clean checkout of main (thecryptographylibrary ships no stubs, sobytes.decode()on itsoutput was typed
Any) — fixed by wrapping both returns instr().🤖 Generated with Claude Code