+ Fully supported devices: Roborock S5 and Xiaomi Mi Robot Vacuum Cleaner (v1)
+ Preliminary supported devices: Roborock S6 and other III-generation Roborock vacuums
+
----
### Preamble:
-This is a heavily modified version of [Valetudo by Hypfer](https://github.com/Hypfer/Valetudo), enhanced by me since I found too many features missing in the original package when I've tried to use it for the first time. Next is a quick list of changes first appeared here:
+This is a fork of [Valetudo by Hypfer](https://github.com/Hypfer/Valetudo), created by me since I found too many features missing in the original package when I've tried to use it for the first time.
+
+Features added lately:
+* Preliminary support for Roborock gen3 devices;
+* MQTT: Tracking the time when the dustbin was last emptied or for how long it was in use;
+* MQTT: Possibility of playing sound files on the device by issuing a mqtt command;
+* Optional ability to see a live map on the Remote Control tab;
+
+
+And this is a quick list of features first appeared here:
* Ability to select multiple saved zones at once;
* Selected zones optionally shown at the map tab to see and edit what's actually going to be cleaned;
-* Configurable virtual walls and forbidden zones, finally! (requires Gen2)
+* Configurable virtual walls and forbidden zones (requires Gen2);
+* Ability to see the actual map of cleanings that were finished recently;
* Scheduled zoned cleaning - when you do not need to clean the whole house;
+* Scheduled rooms cleaning - the same thing for newer firmware of Gen2;
* Ability to specify the number of iterations to clean the same zone multiple times;
-* Showing device status on the map, and also dynamically switching buttons;
-* Experimental ability to save/restore maps;
-* Multilanguage support, currently available in bg/de/en/es/fi/fr/hu/it/nl/ru;
+* Display device's status on the map, as well as a set of quick action buttons that are dynamically switching at state changes;
+* Multilanguage support, currently available in bg/ca/cz/de/en/es/fi/fr/hu/it/lv/nl/ru/sv/pl;
* A telegram bot software for controlling the vacuum from the outside world;
-* Full support of room cleaning (requires Gen2 with firmware 2008+).
+* Experimental ability to SAVE and RESTORE the main map (with per-map list of saved zones and spots);
+* Full support of room cleaning (requires Gen2 with firmware 2008+);
+* Cleaning queue, allowing the use of zoned cleaning with more than 5 zones via enqueuing any number of additional cleanups at once;
+* Possibility to enqueue additional zones and segments during cleaning or additional goto spots during the movement;
+* Ability to run Goto + Spot cleaning (by long pressing "Goto" button on the map tab);
+* Selecting the destination for the device to go when the cleaning is finished (configured globally in settings or per-cleaning by long pressing "Start" button on the map tab);
+* Visual preview and edit of zones and rooms for corresponding scheduled cleaning.
+
You can add or improve your own native language support by using ./client/locales/en.json template as an example and sending a PR.
@@ -47,9 +70,6 @@ Check [deployment section](/deployment) or [this wiki page](https://github.com/r
-### Join the Discussion
-* [Valetudo Telegram group](https://t.me/joinchat/AR1z8xOGJQwkApTulyBx1w)
-
### Getting map picture for integrations
-* [valetudo-mapper](https://github.com/rand256/valetudo-mapper) - a companion service for generating PNG Maps
+* [valetudo-mapper](https://github.com/rand256/valetudo-mapper) - a companion service for generating PNG maps;
* You can also try to request a simple map from Valetudo RE itself via http at `/api/simple_map`, but it shouldn't be called too often since resources of the vacuum are limited.
diff --git a/client/about.html b/client/about.html
index 5ea730e9..5d80c88d 100644
--- a/client/about.html
+++ b/client/about.html
@@ -8,19 +8,20 @@
Valetudo RE
free your vacuum from the cloud
+
Based on Valetudo - Another IoT Smarthome Node.js project by Hypfer
+
-
\ No newline at end of file
+
diff --git a/client/home.html b/client/home.html
index 2593b4a5..406d6be2 100644
--- a/client/home.html
+++ b/client/home.html
@@ -6,8 +6,8 @@
- Unknown power
+ Unknown power
+ Unknown Water Grade
+
+ Waterbox:
+
+
+ Mop:
+
- Battery: 0%
+ Battery: 0%
@@ -41,10 +54,11 @@
diff --git a/client/icon-apple.png b/client/icon-apple.png
new file mode 100644
index 00000000..3346bc59
Binary files /dev/null and b/client/icon-apple.png differ
diff --git a/client/index.html b/client/index.html
index f9b31efd..3bbcaad7 100644
--- a/client/index.html
+++ b/client/index.html
@@ -10,14 +10,26 @@
+
-
+
-
+
Mi Robot Vacuum
+
-
Loading...
+
Loading...
\ No newline at end of file
diff --git a/client/map.html b/client/map.html
index 45c0fbdd..1bed1abb 100644
--- a/client/map.html
+++ b/client/map.html
@@ -7,13 +7,9 @@
Status: Connecting...
-
Battery: 0%
-
-
-
-
-
-
+
Battery: 0%
+
Area: 0.0m²
+
Time: 00:00:00
@@ -43,7 +39,7 @@
-
+
@@ -65,7 +61,7 @@
fn.prequest("api/map/latest", "GET", "arraybuffer")
.then(res => {
gzippedMapData = res;
- return fn.device.ver === 3 ? fn.prequest("api/segment_names") : null;
+ return fn.device.features.rooms ? fn.prequest("api/segment_names") : null;
})
.then(res => {
var segmentNames = {};
@@ -81,7 +77,7 @@
}
} else {
map.updateSegmentNames(segmentNames);
- map.updateMap(map.parseMap(gzippedMapData));
+ map.updateMap(map.parseMap(gzippedMapData),true);
}
map.initWebSocket();
resolve(map);
@@ -105,35 +101,29 @@
};
/**
- * Calls the goto api route with the currently set goto coordinates
+ * Calls the goto api route
*/
- function goto_point(point) {
+ function goto_point(withCleaning) {
+ const point = map.getLocations().gotoPoints[0];
+ if (!point) {
+ fn.notificationToastError(i18next.t('map.noGotoTarget',"You need to choose a spot for Goto on the map."));
+ return;
+ }
let button = document.getElementById("goto");
loadingBar.setAttribute("indeterminate", "indeterminate");
button.setAttribute("disabled", "disabled");
fn.prequestWithPayload("api/go_to", JSON.stringify(point), "PUT")
- .then(
- (res) => fn.notificationToastOK(),
- (err) => fn.notificationToastError(err)
- )
- .finally(() => {
- loadingBar.removeAttribute("indeterminate");
- button.removeAttribute("disabled");
- });
- };
-
- /**
- * Calls the zoned cleanup api route with the currently set zones
- */
- function zoned_cleanup(zones) {
- let button = document.getElementById("start_cleaning");
- loadingBar.setAttribute("indeterminate", "indeterminate");
- button.setAttribute("disabled", "disabled");
- fn.prequestWithPayload("api/start_cleaning_zone", JSON.stringify(zones), "PUT")
- .then(
- (res) => fn.notificationToastOK(),
- (err) => fn.notificationToastError(err)
- )
+ .then(res => {
+ if (withCleaning) {
+ return fn.prequestWithPayload("api/override_queue", JSON.stringify({gotoSpot: withCleaning}), "PUT")
+ }
+ return null;
+ })
+ .then(res => {
+ fn.notificationToastOK();
+ setTimeout(() => map.clearLocations(),3e3);
+ })
+ .catch(err => fn.notificationToastError(err))
.finally(() => {
loadingBar.removeAttribute("indeterminate");
button.removeAttribute("disabled");
@@ -141,23 +131,56 @@
};
/**
- * Calls the segmented cleanup api route with the currently chosen segments
+ * Calls the appropriate start api route
*/
- function segmented_cleanup(segments) {
- let button = document.getElementById("start_cleaning"),
- iterations = parseInt(document.getElementById("set_iterations_segment_number").textContent);
- if (!(iterations > 0 && iterations <= 3)) { iterations = 1; }
- loadingBar.setAttribute("indeterminate", "indeterminate");
- button.setAttribute("disabled", "disabled");
- fn.prequestWithPayload("api/start_cleaning_segment", JSON.stringify([segments, iterations]), "PUT")
- .then(
- (res) => fn.notificationToastOK(),
- (err) => fn.notificationToastError(err)
- )
- .finally(() => {
- loadingBar.removeAttribute("indeterminate");
- button.removeAttribute("disabled");
- });
+ function start_cleaning(postCleaning) {
+ var point, promise, button = document.getElementById("start_cleaning");
+ const segments = map.getLocations().segments;
+ const zones = map.getLocations().zones;
+ const res = document.querySelector('.map-page-status').dataset;
+ if (postCleaning === 2 && !(point = map.getLocations().gotoPoints[0])) {
+ fn.notificationToastError(i18next.t('map.noGotoTarget',"You need to choose a spot for Goto."));
+ return;
+ }
+ if (segments.length && [0,2,3,6,8,10,12,17,18].includes(+res.state)) {
+ let iterations = parseInt(document.getElementById("set_iterations_segment_number").textContent);
+ if (!(iterations > 0 && iterations <= 3)) { iterations = 1; }
+ promise = () => fn.prequestWithPayload("api/start_cleaning_segment", JSON.stringify([segments, iterations]), "PUT");
+ } else if (zones.length && [0,2,3,6,8,10,12,17,18].includes(+res.state)) {
+ promise = () => fn.prequestWithPayload("api/start_cleaning_zone", JSON.stringify(zones), "PUT");
+ } else if ([0,2,3,6,8,10,12].includes(+res.state)) {
+ promise = () => {
+ return ons.notification.confirm(i18next.t('map.confirmFullCleaning',"Can't start zoned cleaning: no zones specified. Do you want to run full cleaning instead?"),{buttonLabels: [i18next.t('common.cancel',"Cancel"), i18next.t('common.ok',"OK")], title: i18next.t('common.confirm',"Confirm")})
+ .then(answer => {
+ if (answer === 1) {
+ return fn.prequest("api/start_cleaning_only", "PUT")
+ }
+ return Promise.reject('cancel');
+ });
+ };
+ }
+ if (promise) {
+ loadingBar.setAttribute("indeterminate", "indeterminate");
+ button.setAttribute("disabled", "disabled");
+ promise()
+ .then(res => {
+ if (postCleaning > -1 && postCleaning < 3) {
+ return fn.prequestWithPayload("api/override_queue", JSON.stringify({postCleaning: postCleaning === 2 ? point : postCleaning}), "PUT")
+ }
+ return null;
+ })
+ .then(res => {
+ fn.notificationToastOK();
+ setTimeout(() => map.clearLocations(),3e3);
+ })
+ .catch(err => {
+ if (err !== 'cancel') fn.notificationToastError(err);
+ })
+ .finally(() => {
+ loadingBar.removeAttribute("indeterminate");
+ button.removeAttribute("disabled");
+ });
+ }
};
/**
@@ -179,8 +202,8 @@
});
};
- function setIterationsButton(targetID,value) {
- let iterationsSpan = document.getElementById(targetID)
+ window.fn.setIterationsButton = function(targetID,value) {
+ let iterationsSpan = document.getElementById(targetID);
if (value) {
iterationsSpan.textContent = value;
} else {
@@ -195,11 +218,15 @@
window.fn.reload_map_buttons = function(res) {
res = res || document.querySelector('.map-page-status').dataset;
- if (res.in_cleaning > 0 || res.state === 11 || res.state === 16) {
- if ([2,3,10].includes(res.state)) {
+ if (!+res.state) {
+ return;
+ }
+ // basic rules from home page first
+ if (+res.in_cleaning > 0 || +res.state === 11 || +res.state === 16) {
+ if ([2,10,12].includes(+res.state)) {
document.getElementById("pause_cleaning").style.display = 'none';
document.getElementById("start_cleaning").style.display = '';
- if (res.in_cleaning !== 2) {
+ if (+res.in_cleaning !== 2) {
document.getElementById("add_zone").style.display = '';
}
} else {
@@ -209,34 +236,40 @@
}
} else {
document.getElementById("add_zone").style.display = '';
- if (res.state !== 6) {
+ document.getElementById("start_cleaning").style.display = '';
+ if (+res.state === 6) {
+ document.getElementById("pause_cleaning").style.display = '';
+ } else {
document.getElementById("pause_cleaning").style.display = 'none';
+ }
+ }
+ // additionally allow starting cleaning to enqueue new zones or segments during already running cleaning
+ if ([5,17,18].includes(+res.state)) {
+ document.getElementById("add_zone").style.display = '';
+ if (map.getLocations().segments.length || map.getLocations().zones.length) {
document.getElementById("start_cleaning").style.display = '';
- } else {
- document.getElementById("pause_cleaning").style.display = '';
- document.getElementById("start_cleaning").style.display = 'none';
}
}
- // resume available in paused and charger disconnected while in cleaning states
- if (res.state === 10 || (res.state === 2 && res.in_cleaning > 0)) {
+ // resume available in paused, error and charger disconnected while in cleaning states
+ if (+res.state === 10 || [2,12].includes(+res.state) && +res.in_cleaning > 0) {
document.getElementById("resume_cleaning").style.display = '';
} else {
document.getElementById("resume_cleaning").style.display = 'none';
}
// stop not available in paused and idle states, as well as many other states not related to cleaning, and in spot cleaning
- if ([0,3,6,8,9,10,11,14,15,16].includes(res.state)) {
+ if ([0,3,6,8,9,11,14,15,16].includes(+res.state) || (+res.state === 10 && !+res.in_cleaning)) {
document.getElementById("stop_cleaning").style.display = 'none';
} else {
document.getElementById("stop_cleaning").style.display = '';
}
- // goto available in paused, idle and charging states
- if ([2,3,8,10].includes(res.state)) {
+ // goto available in paused, idle and charging states, and also can be enqueued while already moving
+ if ([2,3,8,10,12,16].includes(+res.state)) {
document.getElementById("goto").style.display = '';
} else {
document.getElementById("goto").style.display = 'none';
}
// spot available in paused and idle states
- if ([2,3,10].includes(res.state)) {
+ if ([2,3,10,12].includes(+res.state)) {
document.getElementById("spot_clean").style.display = '';
} else {
document.getElementById("spot_clean").style.display = 'none';
@@ -255,30 +288,94 @@
document.getElementById("stop_cleaning").style.display = 'none';
};
- document.getElementById("goto").onclick = () => {
- const gotoPoint = map.getLocations().gotoPoints[0];
- if (!gotoPoint) fn.notificationToastError(i18next.t('map.noGotoTarget',"You need to choose a spot for Goto."));
- else goto_point(gotoPoint);
- }
- document.getElementById("start_cleaning").onclick = () => {
- const segments = map.getLocations().segments;
- const zones = map.getLocations().zones;
- if (segments.length) {
- segmented_cleanup(segments);
- } else if (zones.length) {
- zoned_cleanup(zones);
- } else {
- ons.notification.confirm(i18next.t('map.confirmFullCleaning',"Can't start zoned cleaning: no zones specified. Do you want to run full cleaning instead?"),{buttonLabels: [i18next.t('common.cancel',"Cancel"), i18next.t('common.ok',"OK")], title: i18next.t('common.confirm',"Confirm")}).then(function (answer) {
- if (answer === 1) {
- put_command("start_cleaning_only","start_cleaning");
+ function processLongPress(target,doClick,doLongClick) {
+ let timeout = null,
+ pressed = false,
+ down = (e) => {
+ if (e.type === "mousedown" && e.button !== 0) {
+ return;
}
- });
+ pressed = false;
+ if (!timeout) {
+ timeout = setTimeout(() => {
+ pressed = true;
+ doLongClick();
+ },1e3);
+ }
+ return false;
+ },
+ click = (e) => {
+ if (timeout) {
+ clearTimeout(timeout);
+ timeout = null;
+ }
+ if (pressed) {
+ return;
+ }
+ doClick();
+ },
+ up = (e) => {
+ if (timeout) {
+ clearTimeout(timeout);
+ timeout = null;
+ }
+ };
+ target.addEventListener("mousedown", down);
+ target.addEventListener("touchstart", down);
+ target.addEventListener("click", click);
+ target.addEventListener("mouseleave", up);
+ target.addEventListener("touchend", up);
+ target.addEventListener("touchleave", up);
+ target.addEventListener("touchcancel", up);
+ };
+
+ processLongPress(
+ document.getElementById("goto"),
+ () => goto_point(),
+ () => {
+ ons.openActionSheet({
+ title: i18next.t('map.selectEndGoto',"Go to selected spot and..."),
+ cancelable: true,
+ buttons: [
+ i18next.t('map.selectEndStop',"Do stop"),
+ i18next.t('map.selectEndSpot',"Do spot cleaning"),
+ {label: i18next.t('common.cancel',"Cancel"), icon: 'md-close'}
+ ]
+ })
+ .then(index => {
+ if (index > -1 && index < 2) {
+ goto_point(index)
+ }
+ })
}
- }
+ );
+
+ processLongPress(
+ document.getElementById("start_cleaning"),
+ () => start_cleaning(-1),
+ () => {
+ ons.openActionSheet({
+ title: i18next.t('map.selectEndCleaning',"Perform selected cleaning and..."),
+ cancelable: true,
+ buttons: [
+ i18next.t('map.selectEndStop',"Do stop"),
+ i18next.t('map.selectEndBase',"Return to base"),
+ i18next.t('map.selectEndMove',"Go to selected spot"),
+ {label: i18next.t('common.cancel',"Cancel"), icon: 'md-close'}
+ ]
+ })
+ .then(index => {
+ if (index > -1 && index < 3) {
+ start_cleaning(index);
+ }
+ })
+ }
+ );
+
document.getElementById("resume_cleaning").onclick = () => {
const res = document.querySelector('.map-page-status').dataset;
- if (+res.state === 10 || +res.state === 2) {
- if (+res.in_returning === 1 || res.model === "rockrobo.vacuum.v1" && +res.in_cleaning === 0 && +res.state === 10) { // Gen1 is missing in_returning state
+ if ([2,10,12].includes(+res.state)) {
+ if (+res.in_returning === 1 || fn.device.features.nret && +res.in_cleaning === 0 && +res.state === 10) { // Gen1 is missing in_returning state
put_command("drive_home","resume_cleaning");
return;
} else if (+res.in_cleaning > 0) {
@@ -293,30 +390,23 @@
document.getElementById("spot_clean").onclick = () => { put_command("spot_clean"); };
document.getElementById("add_zone").onclick = () => {
- if (map.getLocations().zones.length < 5) {
- map.addZone();
- } else {
- ons.notification.alert(i18next.t('map.tooManyZones',"You can't add more than 5 zones onto the map."),{title: i18next.t('common.attention',"Attention!")});
+ map.addZone();
+ if (!fn.webifSettings.staticMapButtons) {
+ fn.reload_map_buttons();
}
}
document.getElementById("set_iterations").onclick = () => {
map.addIterationsToZone();
- setIterationsButton("set_iterations_number",map.getZoneIterations());
+ window.fn.setIterationsButton("set_iterations_number",map.getZoneIterations());
}
document.getElementById("set_iterations_segment").onclick = () => {
- setIterationsButton("set_iterations_segment_number");
+ window.fn.setIterationsButton("set_iterations_segment_number");
}
document.getElementById("promote_zone").onclick = () => {
map.promoteCurrentZone();
}
- document.getElementById("map_reload_button").onclick = () => {
- loadingBar.setAttribute("indeterminate", "indeterminate");
- fn.prequest("api/poll_map")
- .then(null, (err) => fn.notificationToastError(err))
- .finally (() => loadingBar.removeAttribute("indeterminate"));
- }
document.getElementById("map-canvas").addEventListener('zoneSelection', (e) => {
- if (e.detail.state) setIterationsButton("set_iterations_number",map.getZoneIterations());
+ if (e.detail.state) window.fn.setIterationsButton("set_iterations_number",map.getZoneIterations());
document.getElementById("promote_zone").style.display = e.detail.nf ? "" : "none";
document.getElementById("set_iterations").style.display = e.detail.state ? "" : "none";
document.getElementById("set_iterations_segment").style.display = e.detail.state || !map.getLocations().segments.length ? "none" : "";
@@ -324,11 +414,15 @@
});
document.getElementById("map-canvas").addEventListener('segmentSelection', (e) => {
document.getElementById("set_iterations_segment").style.display = map.getLocations().segments.length ? "" : "none";
+ if (!fn.webifSettings.staticMapButtons) {
+ fn.reload_map_buttons();
+ }
});
document.getElementById("map-canvas").addEventListener('updateStatus', (e) => {
let res = e.detail;
document.querySelector('.map-page-battery').textContent = (res.battery || 0) + '%';
if (res.stateHR !== undefined) {
+ fn.device.state = res.state;
let mapPageStatus = document.querySelector('.map-page-status');
mapPageStatus.textContent = i18next.t('map.statusText',{defaultValue: '{{status}}', status: i18next.t('robot.states.n' + res.state, res.stateHR)});
if (res.state === 12 && res.error_code !== 0) {
@@ -337,10 +431,18 @@
mapPageStatus.dataset['state'] = res['state'];
mapPageStatus.dataset['in_cleaning'] = res['in_cleaning'];
mapPageStatus.dataset['in_returning'] = res['in_returning'];
- mapPageStatus.dataset['model'] = res['model'];
if (!fn.webifSettings.staticMapButtons) {
fn.reload_map_buttons(res);
}
+ let areaLabel = document.querySelector('.map-page-area'),
+ timeLabel = document.querySelector('.map-page-time'),
+ ciEnabled = [5,11,17,18].includes(res.state) || res.clean_area > 0
+ areaLabel.parentNode.style.display = ciEnabled ? '' : 'none';
+ timeLabel.parentNode.style.display = ciEnabled ? '' : 'none';
+ if (ciEnabled) {
+ areaLabel.textContent = (res.clean_area / 1000000).toFixed(2);
+ timeLabel.textContent = fn.secondsToHms(res.clean_time).slice(0,-3);
+ }
}
});
@@ -349,7 +451,6 @@
}
ons.getScriptPage().onShow = function () {
document.querySelector('.map-page-stats').style.display = fn.webifSettings.hideMapStatus ? 'none' : '';
- document.getElementById('map_reload_button').style.display = fn.webifSettings.hideMapReload ? 'none' : '';
document.querySelectorAll('.map-page-buttons').forEach(e => (e.style.display = ''));
updateMapPage();
@@ -396,10 +497,13 @@
canvas#spot-configuration-map,
canvas#forbidden-markers-configuration-map,
canvas#segments-configuration-map,
- canvas#cleaning-history-map {
+ canvas#cleaning-history-map,
+ canvas#timers-map,
+ canvas.control-map {
height: 100%;
width: 100%;
touch-action: none;
+ overflow: hidden;
background-image: linear-gradient(var(--map-background-1), var(--map-background-2));
}
diff --git a/client/segments-configuration-map.html b/client/segments-configuration-map.html
index 8e5a3c65..c83ede74 100644
--- a/client/segments-configuration-map.html
+++ b/client/segments-configuration-map.html
@@ -90,7 +90,7 @@
.then(answer => {
if (answer === 1) {
loadingBarSegmentsConfiguration.setAttribute("indeterminate", "indeterminate");
- return fn.prequest("api/autosplit_segments");
+ return fn.prequest("api/autosplit_segments", "PUT");
}
return Promise.reject("cancel");
})
@@ -274,6 +274,7 @@
fn.notificationToastError(i18next.t('zones.segmentSplitNoLine',"You need to place the cutting line above the room to split it."));
return;
}
+ let targetSegment = map.getLocations().segments[0];
map.clearLocations();
if (Math.hypot(splitPoints[0]-linePoints[0], splitPoints[1]-linePoints[1]) > Math.hypot(splitPoints[0]-linePoints[2], splitPoints[1]-linePoints[3])) {
splitPoints.push(...splitPoints.splice(0,2));
@@ -283,7 +284,7 @@
.then(answer => {
if (answer === 1) {
loadingBarSegmentsConfiguration.setAttribute("indeterminate", "indeterminate");
- return fn.prequestWithPayload("api/split_segment", JSON.stringify([map.getLocations().segments[0], ...map.getLocations().virtualWalls[0]]), "PUT");
+ return fn.prequestWithPayload("api/split_segment", JSON.stringify([targetSegment, ...map.getLocations().virtualWalls[0]]), "PUT");
}
return Promise.reject("cancel");
})
diff --git a/client/settings-access-control.html b/client/settings-access-control.html
index 1a9ef72c..de34c261 100644
--- a/client/settings-access-control.html
+++ b/client/settings-access-control.html
@@ -11,45 +11,36 @@
HTTP Authentication Settings
-
-
- Enabled
-
+
+ Enabled:
+
+
+
-
-
+
+ Username:
+
+
+
+
-
+
+ Password:
+
+
+
+
-
+
+ Password (repeat):
+
+
+
+
@@ -74,16 +65,12 @@
-
+
+ Enter "confirm" below. Don't lock yourself out!
+
Persistent Data Configuration
-
-
- Persistent data or "lab mode" is a feature of the Roborock S5x which allows saving forbidden zones and virtual walls. It also allows the robot to drive back to the dock wherever it is and keeps the map from being rotated.
-
-
-
-
-
-
- Sorry, only Roborock S5x supports the persistent map features.
-
-
-
-
-
+ Cleaning ScheduleNo cleaning schedule is configured yet.
@@ -198,10 +207,6 @@
coords = Array.from(coordInputs).map(a => JSON.parse(a.value)).reduce((acc, val) => acc.concat(val), []);
}
coords = coords.concat(coordinates);
- if (coords.length > 5) {
- fn.notificationToastError(i18next.t('settings.timers.tooManyZones',"You can't use more than 5 zones in one cleaning session."));
- return;
- }
let anchor = document.querySelector('#add-timer-form > hr');
anchor.parentNode.insertBefore(ons.createElement(
'[x] (' + (i18next.t('settings.timers.zoneCount',{defaultValue: "{{count}} zone" + (coordinates.length !== 1 ? "s" : ""), count: coordinates.length})) + ')' + name + ''
@@ -239,6 +244,32 @@
});
};
+ function previewZoneCoordinatesClick() {
+ const form = document.getElementById('add-timer-form');
+ let coords = [], coordInputs = form.querySelectorAll("input[name='zoned_timer_coords[]']");
+ if (coordInputs.length) {
+ coords = Array.from(coordInputs).map(a => JSON.parse(a.value)).reduce((acc, val) => acc.concat(val), []);
+ }
+ loadingBarSettingsTimers.setAttribute("indeterminate", "indeterminate");
+ fn.prequest("api/map/latest", "GET", "arraybuffer")
+ .then(mapData => {
+ fn.pushPage({
+ 'id': 'settings-timers-map.html',
+ 'title': 'Zoned Timers map preview',
+ 'data': {
+ 'map': mapData,
+ 'timerName': form.timer_name.value || '',
+ 'timerType': 'zones',
+ 'timerCoords': coords
+ }
+ });
+ })
+ .catch((err) => {
+ if (err !== "ok") fn.notificationToastError(err);
+ })
+ .finally(() => loadingBarSettingsTimers.removeAttribute("indeterminate"));
+ };
+
function getActualSegments() {
var segmentNames = {};
return fn.prequest("api/segment_names")
@@ -258,7 +289,7 @@
function addSegment(name,id) {
let ids = [], idInputs = document.querySelectorAll("#add-timer-form input[name='segment_timer_id[]']");
- if (idInputs.length) {
+ if (idInputs.length) {
idInputs.forEach(input => ids.push(+input.value));
}
if (ids.includes(id)) { fn.notificationToastError(i18next.t('settings.timers.duplicatedSegment',"You can't add the same room multiple times!")); return; }
@@ -300,6 +331,41 @@
});
};
+ function previewSegmentIndexesClick() {
+ const form = document.getElementById('add-timer-form');
+ let iterations = +form.iterations_select.value || 1;
+ let segments = [], segmentInputs = form.querySelectorAll("input[name='segment_timer_id[]']");
+ if (segmentInputs.length) {
+ segmentInputs.forEach(input => segments.push(+input.value));
+ }
+ loadingBarSettingsTimers.setAttribute("indeterminate", "indeterminate");
+ let mapData, segmentNames = {};
+ fn.prequest("api/map/latest", "GET", "arraybuffer")
+ .then(res => {
+ mapData = res;
+ return fn.prequest("api/segment_names");
+ })
+ .then(res => {
+ res.forEach(pair => segmentNames[pair[0]] = pair[1]);
+ fn.pushPage({
+ 'id': 'settings-timers-map.html',
+ 'title': 'Zoned Timers map preview',
+ 'data': {
+ 'map': mapData,
+ 'timerName': form.timer_name.value || '',
+ 'timerType': 'rooms',
+ 'timerSegments': segments,
+ 'timerSegmentNames': segmentNames,
+ 'timerSegmentIterations': iterations
+ }
+ });
+ })
+ .catch((err) => {
+ if (err !== "ok") fn.notificationToastError(err);
+ })
+ .finally(() => loadingBarSettingsTimers.removeAttribute("indeterminate"));
+ };
+
function switchTimerOnce(elem) {
document.querySelectorAll('ons-checkbox[name="days"]').forEach(cb => {
if (elem.checked) {
@@ -317,10 +383,10 @@
function showTimeZoneDialog() {
loadingBarSettingsTimers.setAttribute("indeterminate", "indeterminate");
let currentTimeZone, promises = [];
- promises.push(fn.prequest("api/get_timezone").then(res => (currentTimeZone = res)));
+ promises.push(fn.prequest("api/timezone").then(res => (currentTimeZone = res)));
let timeZoneSelection = document.getElementById('timezone-selection');
if (!timeZoneSelection.childElementCount) {
- promises.push(fn.prequest("api/get_timezone_list").then(res => {
+ promises.push(fn.prequest("api/timezone_list").then(res => {
res.forEach(function (timezone) {
let option = document.createElement("option");
option.innerText = timezone;
@@ -350,7 +416,7 @@
ons.notification.confirm(i18next.t('settings.timers.confirmTimezone',{defaultValue: "Do you really want to set your timezone to \"{{newTimezone}}\"?", newTimezone: newTimezone}),{title: i18next.t('common.confirm',"Confirm"), buttonLabels: [i18next.t('common.cancel',"Cancel"), i18next.t('common.ok',"OK")]}).then(function (answer) {
if (answer === 1) {
loadingBarSettingsTimers.setAttribute("indeterminate", "indeterminate");
- fn.prequestWithPayload("api/set_timezone", JSON.stringify({ new_zone : newTimezone }), "POST")
+ fn.prequestWithPayload("api/timezone", JSON.stringify({ new_zone : newTimezone }), "PUT")
.then(
(res) => editTimezoneDialog.hide(),
(err) => fn.notificationToastError(err)
@@ -380,7 +446,7 @@
function updateDndTimerPage() {
loadingBarSettingsTimers.setAttribute("indeterminate", "indeterminate");
- fn.prequest("api/get_dnd")
+ fn.prequest("api/dnd")
.then((res) => {
while (dndTimerList.lastElementChild !== dndTimerList.firstElementChild) {
dndTimerList.removeChild(dndTimerList.lastElementChild);
@@ -424,7 +490,7 @@
ons.notification.confirm(i18next.t('settings.timers.confirmDisableDND',"Do you really want to disable DND?"),{title: i18next.t('common.confirm',"Confirm"), buttonLabels: [i18next.t('common.cancel',"Cancel"), i18next.t('common.ok',"OK")]}).then(function (answer) {
if (answer === 1) {
loadingBarSettingsTimers.setAttribute("indeterminate", "indeterminate");
- fn.prequest("api/delete_dnd", "PUT")
+ fn.prequest("api/dnd", "DELETE")
.then(
(res) => updateDndTimerPage(),
(err) => fn.notificationToastError(err)
@@ -440,7 +506,7 @@
var end_hour = document.getElementById('edit-dnd-form').end_hour.value;
var end_minute = document.getElementById('edit-dnd-form').end_minute.value;
if (start_hour && start_minute && end_hour && end_minute) {
- fn.prequestWithPayload("api/set_dnd", JSON.stringify({ start_hour, start_minute, end_hour, end_minute}), "POST")
+ fn.prequestWithPayload("api/dnd", JSON.stringify({ start_hour, start_minute, end_hour, end_minute}), "PUT")
.then((res) => {
hideDndTimerDialog();
updateDndTimerPage();
@@ -542,6 +608,18 @@
});
}
+ function clearCoordinatesAndIndexes() {
+ let inputs = document.getElementById('add-timer-form').querySelectorAll("input[name='zoned_timer_coords[]'], input[name='segment_timer_id[]'");
+ if (inputs.length) {
+ inputs.forEach(a => {
+ while (a.parentNode && a.nodeName !== "ONS-ROW") { a = a.parentNode; };
+ if (a) {
+ a.parentNode.removeChild(a);
+ }
+ });
+ }
+ }
+
function clearTimerDialog(zoned) {
let form = document.getElementById('add-timer-form');
form.zoned.value = zoned ? 1 : 0;
@@ -558,15 +636,72 @@
}
form.once.checked = false;
switchTimerOnce(form.once);
- let inputs = form.querySelectorAll("input[name='zoned_timer_coords[]'], input[name='segment_timer_id[]'");
- if (inputs.length) {
- inputs.forEach(a => {
- while (a.parentNode && a.nodeName !== "ONS-ROW") { a = a.parentNode; };
- if (a) {
- a.parentNode.removeChild(a);
+ clearCoordinatesAndIndexes();
+ }
+
+ function formatZoneCoordinates(coordinates) {
+ console.log('raw coordinates:',JSON.stringify(coordinates));
+ loadingBarSettingsTimers.setAttribute("indeterminate","indeterminate");
+ fn.prequest("api/zones")
+ .then(zones => {
+ if (zones && zones.length) {
+ clearCoordinatesAndIndexes();
+ zones.sort((a,b) => b.coordinates.length - a.coordinates.length);
+ let szcmp = (subzone,coords) => {
+ for (let i = 0; i < 4; i++) {
+ if (coords[i] !== subzone[i]) return false;
+ }
+ return true;
}
- });
- }
+ let i, j, k, len, len2, len3, found, currentCoords = [];
+ timerLoop:
+ for (i = 0, len = coordinates.length; i < len; i++) {
+ zonesLoop:
+ for (j = 0, len2 = zones.length; j < len2; j++) {
+ len3 = zones[j].coordinates.length;
+ if (i + len3 <= len) {
+ found = 0;
+ if (zones[j].coordinates.every((subzone,idx) => szcmp(subzone,coordinates[i+idx]))) {
+ found = 1;
+ } else if (zones[j].coordinates.every(subzone => coordinates.slice(i,i+len3).some(coords => szcmp(subzone,coords)))) {
+ found = 2;
+ }
+ if (found) {
+ if (currentCoords.length) {
+ addZoneCoordinates('-',currentCoords);
+ currentCoords = [];
+ }
+ addZoneCoordinates(zones[j].name + (found > 1 ? ' ⇆' : ''),coordinates.slice(i,i+len3));
+ i += len3 - 1;
+ continue timerLoop;
+ }
+ }
+ }
+ currentCoords.push(coordinates[i]);
+ }
+ if (currentCoords.length) {
+ addZoneCoordinates('-',currentCoords);
+ }
+ }
+ })
+ .catch(err => fn.notificationToastError(err))
+ .finally(() => {
+ loadingBarSettingsTimers.removeAttribute("indeterminate");
+ });
+ }
+
+ function formatSegmentIndexes(indexes) {
+ loadingBarSettingsTimers.setAttribute("indeterminate", "indeterminate");
+ getActualSegments()
+ .then(res => {
+ indexes.forEach(id => {
+ addSegment((res.find(r => r.id === +id) || '').name || (i18next.t('settings.timers.missingSegment',"Segment not found on current map!") + ' (#' + id + ')'),id);
+ })
+ })
+ .catch(err => fn.notificationToastError(err))
+ .finally(() => {
+ loadingBarSettingsTimers.removeAttribute("indeterminate");
+ });
}
function loadTimerDialog(timerId,zoned,timer) {
@@ -588,6 +723,15 @@
default: fanPower = 2;
}
form.fan_power_select.value = fanPower;
+ let waterGrade;
+ switch (timer.waterGrade) {
+ case 200: waterGrade = 0; break;
+ case 201: waterGrade = 1; break;
+ case 202: waterGrade = 2; break;
+ case 203: waterGrade = 3; break;
+ default: waterGrade = 2;
+ }
+ form.water_grade_select.value = waterGrade;
form.iterations_select.value = timer.iterations || 1;
form.zoned.value = zoned ? 1 : 0;
form.edit.value = timerId;
@@ -595,7 +739,7 @@
form.day.value = cron[2] || "";
form.hour.value = cron[1] || "";
form.minute.value = cron[0] || "";
- if (cron[4] === "*" && fn.device.ver === 3) {
+ if (cron[4] === "*" && fn.device.features.v3 && !zoned) {
form.once.checked = true;
switchTimerOnce(form.once);
} else if (cron[4] !== "0,1,2,3,4,5,6") {
@@ -607,21 +751,10 @@
}
}
if (timer.coordinates) {
- addZoneCoordinates('-',timer.coordinates);
+ processZoneCoordinates(timer.coordinates);
}
if (timer.segments) {
- loadingBarSettingsTimers.setAttribute("indeterminate", "indeterminate");
- getActualSegments()
- .then(res => {
- timer.segments.split(',').forEach(id => {
- addSegment((res.find(r => r.id === +id) || '').name || i18next.t('settings.timers.missingSegment',"Segment not found on current map!"),id);
- })
- })
- .catch(err => fn.notificationToastError(err))
- .finally(() => {
- loadingBarSettingsTimers.removeAttribute("indeterminate");
- addSegmentButton.disabled = false;
- });
+ processSegmentIndexes(timer.segments.split(','),timer.iterations);
}
}
@@ -697,9 +830,9 @@
if (daySelection.length) {
daySelection = daySelection.sort().join(",");
} else {
- daySelection = fn.device.ver === 3 && !zoned ? "0,1,2,3,4,5,6" : "*"; // in 2008 a single asterisk in days means "run once and delete" for native cleaning
+ daySelection = fn.device.features.v3 && !zoned ? "0,1,2,3,4,5,6" : "*"; // in 2008 a single asterisk in days means "run once and delete" for native cleaning
}
- if (form.once.checked && fn.device.ver === 3) {
+ if (form.once.checked && fn.device.features.v3) {
daySelection = "*"; // that's it, clean and forget
}
// do not allow meaningless timers
@@ -712,12 +845,22 @@
// fanpower
var fanPower;
switch (form.fan_power_select.value) {
- case "0": fanPower = fn.device.ver === 3 ? 101 : 1 ; break;
- case "1": fanPower = fn.device.ver === 3 ? 101 : 38; break;
- case "3": fanPower = fn.device.ver === 3 ? 103 : 75; break;
- case "4": fanPower = fn.device.ver === 3 ? 104 : 100; break;
+ case "0": fanPower = fn.device.features.v3 ? 101 : 1 ; break;
+ case "1": fanPower = fn.device.features.v3 ? 101 : 38; break;
+ case "3": fanPower = fn.device.features.v3 ? 103 : 75; break;
+ case "4": fanPower = fn.device.features.v3 ? 104 : 100; break;
case "5": fanPower = 105; break;
- default: fanPower = fn.device.ver === 3 ? 102 : 60; break;
+ default: fanPower = fn.device.features.v3 ? 102 : 60; break;
+ }
+ // water
+ var waterGrade = null;
+ if (fn.device.features.water_usage_ctrl)
+ switch (form.waterGrade) {
+ case "0": waterGrade = 200; break;
+ case "1": waterGrade = 201; break;
+ case "2": waterGrade = 202; break;
+ case "3": waterGrade = 203; break;
+ default: waterGrade = 202;
}
var iterations = +form.iterations_select.value;
// next promises
@@ -759,7 +902,7 @@
})
.catch(e => reject(e))
})
- .then(() => fn.prequestWithPayload(zoned ? "api/ztimers" : "api/timers", JSON.stringify({ enabled: true, id: timerName, cron: cronTimer, coordinates: coords, fanpower: fanPower, segments: segments, iterations: iterations, edit: edit }), "PUT"))
+ .then(() => fn.prequestWithPayload(zoned ? "api/ztimers" : "api/timers", JSON.stringify({id: timerName, cron: cronTimer, coordinates: coords, fanpower: fanPower, watergrade: waterGrade, segments: segments, iterations: iterations, edit: edit}), "PUT"))
.then(() => {
updateSettingsTimersPage(zoned);
editTimerDialog.hide();
@@ -772,17 +915,36 @@
});
}
+ function processZoneCoordinates(coordinates,ext) {
+ if (ext) {
+ clearCoordinatesAndIndexes();
+ }
+ addZoneCoordinates('-',coordinates);
+ formatZoneCoordinates(coordinates);
+ }
+ window.fn.processZoneCoordinates = (coordinates) => processZoneCoordinates(coordinates,true);
+
+ function processSegmentIndexes(idxs,iterations,ext) {
+ if (ext) {
+ clearCoordinatesAndIndexes();
+ document.getElementById('add-timer-form').iterations_select.value = parseInt(iterations) || 1;
+ }
+ formatSegmentIndexes(idxs);
+ }
+ window.fn.processSegmentIndexes = (indexes,iterations) => processSegmentIndexes(indexes,iterations,true);
+
//if timerId is set to null then open new timer, else edit existing
function showTimerDialog(timerId,zoned) {
zoned = zoned ? true : false;
// show different headers for different modes and fw
editTimerDialog.querySelectorAll('.ztimer-item').forEach(item => item.style.display = (zoned ? '' : 'none'));
- editTimerDialog.querySelectorAll('.ntimer-item').forEach(item => item.style.display = (!zoned && fn.device.ver === 3 ? '' : 'none'));
+ editTimerDialog.querySelectorAll('.ntimer-item').forEach(item => item.style.display = (!zoned && fn.device.features.v3 ? '' : 'none'));
+ editTimerDialog.querySelectorAll('.wtimer-item').forEach(item => item.style.display = (!zoned && fn.device.features.water_usage_ctrl ? '' : 'none'));
editTimerDialog.querySelector('.edit-timer-desc').textContent = zoned ? i18next.t('settings.timers.timerZoneDesc',"Choose timer name and zone coordinates.") : i18next.t('settings.timers.timerDesc',"Choose timer settings.");
- if (fn.device.ver === 3) {
- editTimerDialog.querySelector('select > option[value="0"]').style.display = 'none';
+ if (fn.device.features.v3) {
+ editTimerDialog.querySelector('select > option[value="0"]').style.display = 'none'; // v3 devices doesn't have Whisper fanspeed
}
- if (fn.device.model === "rockrobo.vacuum.v1") {
+ if (fn.device.features.nmop) {
editTimerDialog.querySelector('select > option[value="5"]').style.display = 'none';
}
clearTimerDialog(zoned);
@@ -790,9 +952,9 @@
container.style.cssText = "height: " + Math.min(zoned ? 470 : 400,window.innerHeight * 0.75) + "px; overflow: auto;";
if (timerId !== null) {
loadingBarSettingsTimers.setAttribute("indeterminate", "indeterminate");
+ let idx, tmr;
fn.prequest(zoned ? "api/ztimers" : "api/timers")
.then(res => {
- let idx;
if ((idx = res.findIndex(timer => timer.id === timerId)) < 0) {
return Promise.reject(i18next.t('settings.timers.missingTimer',"No such timer."));
}
@@ -817,7 +979,10 @@
white-space: nowrap;
cursor: pointer;
color: darkgrey;
- text-align: center
+ line-height: 1.1;
+ }
+ .delete-field-link + ons-col {
+ line-height: 1.1;
}
.action-sheet {
max-height: 100%;
@@ -832,4 +997,4 @@
color: #eb5959;
}
-
\ No newline at end of file
+
diff --git a/client/settings-web-interface.html b/client/settings-web-interface.html
index c375dc5e..03348822 100644
--- a/client/settings-web-interface.html
+++ b/client/settings-web-interface.html
@@ -16,6 +16,8 @@
+
+
@@ -23,8 +25,12 @@
+
+
+
+
@@ -67,15 +73,20 @@
Do not show room markers
hides room selection markers on the map
-
-
Disable map reload button
hides force reload button on the map
-
-
Disable dynamic buttons
shows on map basic buttons only
+
+ Manual control tab
+
+
+
+
Do not show mini-map
it may work improperly on some devices and can't be fixed
+
+
+
Save
@@ -139,7 +150,7 @@
function setWebifSettings() {
loadingBarWebif.setAttribute("indeterminate", "indeterminate");
- fn.prequestWithPayload("api/set_interface_config", JSON.stringify(window.fn.webifSettings), "PUT")
+ fn.prequestWithPayload("api/interface_config", JSON.stringify(window.fn.webifSettings), "PUT")
.then((res) => {
if (oldLocalization !== window.fn.webifSettings.localization || oldStyle !== window.fn.webifSettings.style || oldHideSegmentMarkers !== window.fn.webifSettings.hideSegmentMarkers)
ons.notification.confirm(i18next.t('settings.webInterface.applyChangesConfirm',"Would you like to apply changes right now?"),{buttonLabels: [i18next.t('common.cancel',"Cancel"), i18next.t('common.ok',"OK")], title: i18next.t('common.confirm',"Confirm")}).then(res => {
diff --git a/client/settings.html b/client/settings.html
index 531ac3ee..99d71fe9 100644
--- a/client/settings.html
+++ b/client/settings.html
@@ -12,7 +12,7 @@
Configure carpet mode
-
Persistent Data
+
Persistent Data & System Settings
Configure the lab mode for enabling virtual walls etc