Skip to content

Commit d44cde6

Browse files
author
Martin Littkovsky
committed
[roborock] Address review: thing-type version, dock UNDEF, ring majority
Declare thingTypeVersion 1 on roborock:vacuum so freshly created things are not treated as migration candidates by update.xml. A docked robot polls no further maps, so an unknown dock room now goes UNDEF instead of falling back to possibly stale positions; while cleaning, an unresolvable position keeps the last room, since the next map cycle corrects the channel on its own. The ring fallback now requires a real majority, so a 2/1/1 plurality no longer decides the room. Signed-off-by: Martin Littkovsky <2018turtle@proton.me> AI-assisted-by: Claude Code
1 parent e61aee4 commit d44cde6

6 files changed

Lines changed: 113 additions & 40 deletions

File tree

bundles/org.openhab.binding.roborock/src/main/java/org/openhab/binding/roborock/internal/RoborockVacuumHandler.java

Lines changed: 42 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1184,51 +1184,59 @@ private void handleGetMap(int requestId, byte[] mapPayload) {
11841184
}
11851185

11861186
private void updateCurrentRoomState(RRMapData mapData) {
1187-
updateChannelStateIfExists(RobotCapabilities.CURRENT_ROOM.getChannel(),
1188-
resolveRoomStateFromMap(mapData, isAtDock(), segmentRoomNames));
1187+
State roomState = resolveRoomStateFromMap(mapData, isAtDock(), segmentRoomNames);
1188+
if (roomState != null) {
1189+
updateChannelStateIfExists(RobotCapabilities.CURRENT_ROOM.getChannel(), roomState);
1190+
}
11891191
}
11901192

11911193
/**
11921194
* Republishes {@code status#current-room} from the charging dock's position in the most
1193-
* recently parsed map, since no map is polled while the robot sits on the dock. When the cached
1194-
* map holds no dock position the channel is left untouched.
1195+
* recently parsed map, since no map is polled while the robot sits on the dock. When no dock
1196+
* room can be resolved the channel goes {@code UNDEF} rather than keeping the room of the
1197+
* interrupted cleaning run.
11951198
*/
11961199
private void updateCurrentRoomStateFromDock() {
1197-
State dockRoomState = resolveDockRoomState(lastParsedMapData, segmentRoomNames);
1198-
if (dockRoomState != null) {
1199-
updateChannelStateIfExists(RobotCapabilities.CURRENT_ROOM.getChannel(), dockRoomState);
1200-
}
1200+
updateChannelStateIfExists(RobotCapabilities.CURRENT_ROOM.getChannel(),
1201+
resolveDockRoomState(lastParsedMapData, segmentRoomNames));
12011202
}
12021203

12031204
/**
1204-
* Resolves the {@code status#current-room} state for a freshly parsed map, preferring the
1205-
* charging dock's position over the possibly lagging robot position while the robot is docked.
1205+
* Resolves the {@code status#current-room} state for a freshly parsed map: from the charging
1206+
* dock's position while the robot is docked ({@code UNDEF} when no dock room can be resolved),
1207+
* from the robot position otherwise, where {@code null} means the channel must be left as it is
1208+
* because no segment could be resolved at the robot position.
12061209
*/
1207-
static State resolveRoomStateFromMap(RRMapData mapData, boolean atDock, Map<Integer, String> segmentRoomNames) {
1208-
MapPoint position = atDock ? dockPosition(mapData) : null;
1209-
if (position == null) {
1210-
position = robotPosition(mapData);
1211-
}
1210+
static @Nullable State resolveRoomStateFromMap(RRMapData mapData, boolean atDock,
1211+
Map<Integer, String> segmentRoomNames) {
1212+
if (atDock) {
1213+
// No map is polled while the robot sits docked, so an unknown dock room must go UNDEF
1214+
// instead of borrowing the possibly stale robot position.
1215+
MapPoint dockPosition = dockPosition(mapData);
1216+
State dockRoomState = dockPosition == null ? null : roomStateAt(mapData, dockPosition, segmentRoomNames);
1217+
return dockRoomState == null ? UnDefType.UNDEF : dockRoomState;
1218+
}
1219+
MapPoint position = robotPosition(mapData);
12121220
if (position == null) {
12131221
return UnDefType.UNDEF;
12141222
}
1223+
// While cleaning, an unresolvable position keeps the previous room because the next map
1224+
// cycle corrects the channel on its own.
12151225
return roomStateAt(mapData, position, segmentRoomNames);
12161226
}
12171227

12181228
/**
12191229
* Resolves the {@code status#current-room} state from the charging dock's position in the given
1220-
* cached map, or {@code null} when that map holds no dock position and the channel must be left
1221-
* as it is.
1230+
* cached map, going {@code UNDEF} whenever no dock room can be resolved, since nothing
1231+
* self-corrects the channel while the robot sits on the dock.
12221232
*/
1223-
static @Nullable State resolveDockRoomState(@Nullable RRMapData mapData, Map<Integer, String> segmentRoomNames) {
1233+
static State resolveDockRoomState(@Nullable RRMapData mapData, Map<Integer, String> segmentRoomNames) {
12241234
if (mapData == null) {
1225-
return null;
1235+
return UnDefType.UNDEF;
12261236
}
12271237
MapPoint dockPosition = dockPosition(mapData);
1228-
if (dockPosition == null) {
1229-
return null;
1230-
}
1231-
return roomStateAt(mapData, dockPosition, segmentRoomNames);
1238+
State dockRoomState = dockPosition == null ? null : roomStateAt(mapData, dockPosition, segmentRoomNames);
1239+
return dockRoomState == null ? UnDefType.UNDEF : dockRoomState;
12321240
}
12331241

12341242
/**
@@ -1242,9 +1250,18 @@ static boolean hasJustDocked(@Nullable Integer previousStateId, int newStateId)
12421250
return previousStateId == null || !StatusType.getType(previousStateId.intValue()).isAtDock();
12431251
}
12441252

1245-
private static State roomStateAt(RRMapData mapData, MapPoint position, Map<Integer, String> segmentRoomNames) {
1253+
/**
1254+
* Resolves the room state at the given map position, returning {@code null} when no segment
1255+
* can be resolved there at all and {@code UNDEF} when the resolved segment has no name in the
1256+
* table.
1257+
*/
1258+
private static @Nullable State roomStateAt(RRMapData mapData, MapPoint position,
1259+
Map<Integer, String> segmentRoomNames) {
12461260
Optional<Integer> segmentId = RoomAtRobotResolver.resolveSegmentId(mapData, position.x(), position.y());
1247-
String roomName = segmentId.map(segmentRoomNames::get).orElse(null);
1261+
if (segmentId.isEmpty()) {
1262+
return null;
1263+
}
1264+
String roomName = segmentRoomNames.get(segmentId.get());
12481265
return roomName != null ? new StringType(roomName) : UnDefType.UNDEF;
12491266
}
12501267

bundles/org.openhab.binding.roborock/src/main/java/org/openhab/binding/roborock/internal/map/RoomAtRobotResolver.java

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,9 @@ private RoomAtRobotResolver() {
4242

4343
/**
4444
* Resolves the segment id of the map pixel at the given position, falling back to the nearest
45-
* ring within {@link #FALLBACK_SEARCH_RADIUS} that holds any segmented-floor pixels; the
46-
* segment with the strict majority on that ring wins, and a tie resolves to empty rather than
47-
* depending on scan order.
45+
* ring within {@link #FALLBACK_SEARCH_RADIUS} that holds any segmented-floor pixels; a segment
46+
* wins only by holding more than half of that ring's segmented pixels, so ties and mere
47+
* pluralities resolve to empty rather than depending on scan order.
4848
*/
4949
public static Optional<Integer> resolveSegmentId(RRMapData mapData, int positionX, int positionY) {
5050
int width = mapData.imageWidth();
@@ -90,18 +90,18 @@ private static Map<Integer, Integer> collectRing(byte[] imageData, int width, in
9090
private static Optional<Integer> dominantSegment(Map<Integer, Integer> segmentCounts) {
9191
int dominantId = -1;
9292
int highestCount = 0;
93-
boolean tied = false;
93+
int totalCount = 0;
9494
for (Map.Entry<Integer, Integer> candidate : segmentCounts.entrySet()) {
9595
int count = candidate.getValue();
96+
totalCount += count;
9697
if (count > highestCount) {
9798
highestCount = count;
9899
dominantId = candidate.getKey();
99-
tied = false;
100-
} else if (count == highestCount) {
101-
tied = true;
102100
}
103101
}
104-
return tied || dominantId < 0 ? Optional.empty() : Optional.of(dominantId);
102+
// The winner must hold more than half of all counted ring pixels, so a mere plurality like
103+
// 2/1/1 stays unresolved instead of deciding the room with only half the evidence.
104+
return highestCount * 2 > totalCount ? Optional.of(dominantId) : Optional.empty();
105105
}
106106

107107
private static Optional<Integer> segmentAt(byte[] imageData, int width, int height, int x, int y) {

bundles/org.openhab.binding.roborock/src/main/resources/OH-INF/thing/thing-types.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@
4141
<channel-group id="info" typeId="info"/>
4242
</channel-groups>
4343

44+
<properties>
45+
<property name="thingTypeVersion">1</property>
46+
</properties>
47+
4448
<representation-property>duid</representation-property>
4549

4650
<config-description>

bundles/org.openhab.binding.roborock/src/test/java/org/openhab/binding/roborock/internal/RoborockVacuumHandlerCurrentRoomWiringTest.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ void updateCurrentRoomStateUsesRoomAtRobotResolverAndSegmentRoomNames() throws I
122122
String updateCurrentRoomStateBody = extractMethodBody(source,
123123
"private void updateCurrentRoomState(RRMapData mapData)");
124124
String roomStateAtBody = extractMethodBody(source,
125-
"private static State roomStateAt(RRMapData mapData, MapPoint position,");
125+
"private static @Nullable State roomStateAt(RRMapData mapData, MapPoint position,");
126126

127127
assertTrue(updateCurrentRoomStateBody.contains("segmentRoomNames"),
128128
"updateCurrentRoomState should resolve the room name via the segmentRoomNames table");

bundles/org.openhab.binding.roborock/src/test/java/org/openhab/binding/roborock/internal/RoborockVacuumHandlerDockRoomTest.java

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -83,24 +83,41 @@ void mapCycleWhileNotDockedResolvesFromTheRobotPosition() {
8383
}
8484

8585
@Test
86-
void dockingWithoutACachedMapLeavesTheChannelUntouched() {
87-
assertNull(RoborockVacuumHandler.resolveDockRoomState(null, ROOM_NAMES));
86+
void dockingWithoutACachedMapReportsUndef() {
87+
assertEquals(UnDefType.UNDEF, RoborockVacuumHandler.resolveDockRoomState(null, ROOM_NAMES));
8888
}
8989

9090
@Test
91-
void dockingWithoutAChargerPositionInTheCachedMapLeavesTheChannelUntouched() {
91+
void dockingWithoutAChargerPositionInTheCachedMapReportsUndef() {
9292
RRMapData mapData = twoRoomMap(true, false);
9393

94-
assertNull(RoborockVacuumHandler.resolveDockRoomState(mapData, ROOM_NAMES));
94+
assertEquals(UnDefType.UNDEF, RoborockVacuumHandler.resolveDockRoomState(mapData, ROOM_NAMES));
9595
}
9696

9797
@Test
98-
void mapCycleWhileDockedFallsBackToTheRobotPositionWithoutAChargerPosition() {
98+
void mapCycleWhileDockedWithoutAChargerPositionReportsUndefInsteadOfTheRobotPosition() {
9999
RRMapData mapData = twoRoomMap(true, false);
100100

101101
State roomState = RoborockVacuumHandler.resolveRoomStateFromMap(mapData, true, ROOM_NAMES);
102102

103-
assertEquals(new StringType("Flur"), roomState);
103+
assertEquals(UnDefType.UNDEF, roomState);
104+
}
105+
106+
@Test
107+
void anUnresolvablePositionWhileCleaningLeavesTheChannelUntouched() {
108+
RRMapData mapData = unsegmentedFloorMap(true, false);
109+
110+
assertNull(RoborockVacuumHandler.resolveRoomStateFromMap(mapData, false, ROOM_NAMES),
111+
"the next map cycle corrects the room on its own, so a transient resolver miss must not "
112+
+ "drag the channel to UNDEF");
113+
}
114+
115+
@Test
116+
void anUnresolvableDockPositionReportsUndefBecauseNothingSelfCorrectsOnTheDock() {
117+
RRMapData mapData = unsegmentedFloorMap(false, true);
118+
119+
assertEquals(UnDefType.UNDEF, RoborockVacuumHandler.resolveRoomStateFromMap(mapData, true, ROOM_NAMES));
120+
assertEquals(UnDefType.UNDEF, RoborockVacuumHandler.resolveDockRoomState(mapData, ROOM_NAMES));
104121
}
105122

106123
@Test
@@ -188,6 +205,20 @@ private static RRMapData twoRoomMap(boolean withRobotPosition, boolean withCharg
188205
List.of(), new byte[WIDTH * HEIGHT]);
189206
}
190207

208+
/** A map without any segmented-floor pixels, so no position on it resolves to a room. */
209+
private static RRMapData unsegmentedFloorMap(boolean withRobotPosition, boolean withChargerPosition) {
210+
byte[] imageData = new byte[WIDTH * HEIGHT];
211+
212+
Integer robotX = withRobotPosition ? Integer.valueOf(toMapCoordinate(ROBOT_PIXEL_X)) : null;
213+
Integer robotY = withRobotPosition ? Integer.valueOf(toMapCoordinate(ROBOT_PIXEL_Y)) : null;
214+
Integer chargerX = withChargerPosition ? Integer.valueOf(toMapCoordinate(DOCK_PIXEL_X)) : null;
215+
Integer chargerY = withChargerPosition ? Integer.valueOf(toMapCoordinate(DOCK_PIXEL_Y)) : null;
216+
217+
return new RRMapData(WIDTH, HEIGHT, 0, 0, imageData, robotX, robotY, null, chargerX, chargerY, null, null,
218+
List.of(), List.of(), List.of(), List.of(), List.of(), List.of(), List.of(), List.of(), List.of(),
219+
List.of(), new byte[WIDTH * HEIGHT]);
220+
}
221+
191222
private static void fillSegment(byte[] imageData, int fromX, int fromY, int toX, int toY, int segmentId) {
192223
// Classifier 7 in the low 3 bits, segment id in the high 5 bits.
193224
byte pixel = (byte) ((segmentId << 3) | 0x07);

bundles/org.openhab.binding.roborock/src/test/java/org/openhab/binding/roborock/internal/map/RoomAtRobotResolverTest.java

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,27 @@ void picksTheDominantSegmentWhenTheFallbackRingHoldsTwoRooms() {
170170
assertEquals(3, segmentId.get());
171171
}
172172

173+
@Test
174+
void returnsEmptyWhenTheLargestSegmentOnTheRingHoldsOnlyHalfOfItsPixels() {
175+
int width = 10;
176+
int height = 10;
177+
byte[] imageData = new byte[width * height];
178+
int px = 5;
179+
int py = 5;
180+
imageData[py * width + px] = 0x01; // MAP_WALL
181+
// A 2/1/1 split: segment 3 leads but owns exactly half of the ring, not a majority.
182+
imageData[(py - 1) * width + px] = (byte) ((3 << 3) | 0x07);
183+
imageData[(py + 1) * width + px] = (byte) ((3 << 3) | 0x07);
184+
imageData[py * width + (px - 1)] = (byte) ((4 << 3) | 0x07);
185+
imageData[py * width + (px + 1)] = (byte) ((5 << 3) | 0x07);
186+
187+
RRMapData mapData = mapDataWithImage(width, height, 0, 0, imageData);
188+
189+
Optional<Integer> segmentId = RoomAtRobotResolver.resolveSegmentId(mapData, (px + 1) * MM, (py + 1) * MM);
190+
191+
assertFalse(segmentId.isPresent());
192+
}
193+
173194
@Test
174195
void returnsEmptyWhenTheNearestFallbackRingIsTiedBetweenTwoRooms() {
175196
int width = 10;

0 commit comments

Comments
 (0)