├── .releaseconfig.json ├── admin ├── S5.png ├── robot.png ├── robot1.png ├── tank.png ├── spaceship.png ├── mihome-vacuum.png ├── valetudo_conf.png ├── root │ └── howto.md ├── i18n │ ├── zh-cn │ │ └── translations.json │ ├── en │ │ └── translations.json │ ├── nl │ │ └── translations.json │ ├── pl │ │ └── translations.json │ ├── ru │ │ └── translations.json │ ├── de │ │ └── translations.json │ ├── it │ │ └── translations.json │ ├── pt │ │ └── translations.json │ ├── es │ │ └── translations.json │ └── fr │ │ └── translations.json ├── valetudo_logo_small.svg └── index.html ├── widgets ├── mihome-vacuum │ └── img │ │ ├── off.png │ │ ├── on.png │ │ ├── home.png │ │ ├── search.png │ │ ├── vacuum.png │ │ └── previewControl.png └── mihome-vacuum.html ├── test ├── tsconfig.json ├── package.js ├── mocha.custom.json ├── unit.js ├── integration.js └── mocha.setup.js ├── .gitignore ├── prettier.config.mjs ├── tsconfig.check.json ├── .github ├── ISSUE_TEMPLATE │ ├── new_device.md │ └── bug_report.md ├── dependabot.yml ├── auto-merge.yml ├── workflows │ ├── dependabot-auto-merge.yml │ └── test-and-release.yml └── copilot-instructions.md ├── main.test.js ├── eslint.config.mjs ├── tsconfig.json ├── LICENSE ├── package.json ├── lib ├── stockCommands.js ├── tools.js ├── timerManager.js ├── maphelper.js ├── viomi.js ├── RRMapParser.js ├── roomManager.js └── XiaomiCloudConnector.js ├── gulpfile.js └── io-package.json /.releaseconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["iobroker", "license","manual-review"] 3 | 4 | } -------------------------------------------------------------------------------- /admin/S5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iobroker-community-adapters/ioBroker.mihome-vacuum/HEAD/admin/S5.png -------------------------------------------------------------------------------- /admin/robot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iobroker-community-adapters/ioBroker.mihome-vacuum/HEAD/admin/robot.png -------------------------------------------------------------------------------- /admin/robot1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iobroker-community-adapters/ioBroker.mihome-vacuum/HEAD/admin/robot1.png -------------------------------------------------------------------------------- /admin/tank.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iobroker-community-adapters/ioBroker.mihome-vacuum/HEAD/admin/tank.png -------------------------------------------------------------------------------- /admin/spaceship.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iobroker-community-adapters/ioBroker.mihome-vacuum/HEAD/admin/spaceship.png -------------------------------------------------------------------------------- /admin/mihome-vacuum.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iobroker-community-adapters/ioBroker.mihome-vacuum/HEAD/admin/mihome-vacuum.png -------------------------------------------------------------------------------- /admin/valetudo_conf.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iobroker-community-adapters/ioBroker.mihome-vacuum/HEAD/admin/valetudo_conf.png -------------------------------------------------------------------------------- /widgets/mihome-vacuum/img/off.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iobroker-community-adapters/ioBroker.mihome-vacuum/HEAD/widgets/mihome-vacuum/img/off.png -------------------------------------------------------------------------------- /widgets/mihome-vacuum/img/on.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iobroker-community-adapters/ioBroker.mihome-vacuum/HEAD/widgets/mihome-vacuum/img/on.png -------------------------------------------------------------------------------- /widgets/mihome-vacuum/img/home.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iobroker-community-adapters/ioBroker.mihome-vacuum/HEAD/widgets/mihome-vacuum/img/home.png -------------------------------------------------------------------------------- /widgets/mihome-vacuum/img/search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iobroker-community-adapters/ioBroker.mihome-vacuum/HEAD/widgets/mihome-vacuum/img/search.png -------------------------------------------------------------------------------- /widgets/mihome-vacuum/img/vacuum.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iobroker-community-adapters/ioBroker.mihome-vacuum/HEAD/widgets/mihome-vacuum/img/vacuum.png -------------------------------------------------------------------------------- /widgets/mihome-vacuum/img/previewControl.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iobroker-community-adapters/ioBroker.mihome-vacuum/HEAD/widgets/mihome-vacuum/img/previewControl.png -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "noImplicitAny": false 5 | }, 6 | "include": [ 7 | "./**/*.js" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /test/package.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const { tests } = require('@iobroker/testing'); 3 | 4 | // Validate the package files 5 | tests.packageFiles(path.join(__dirname, '..')); 6 | -------------------------------------------------------------------------------- /test/mocha.custom.json: -------------------------------------------------------------------------------- 1 | { 2 | "require": [ 3 | "test/mocha.setup.js" 4 | ], 5 | "watch-files": [ 6 | "!(node_modules|test)/**/*.test.js", 7 | "*.test.js", 8 | "test/**/test!(PackageFiles|Startup).js" 9 | ] 10 | } -------------------------------------------------------------------------------- /test/unit.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const { tests } = require('@iobroker/testing'); 3 | 4 | // Run unit tests - See https://github.com/ioBroker/testing for a detailed explanation and further options 5 | tests.unit(path.join(__dirname, '..')); 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .git 2 | .idea 3 | node_modules 4 | nbproject 5 | admin/i18n/flat.txt 6 | admin/i18n/*/flat.txt 7 | 8 | # ioBroker dev-server 9 | .dev-server/ 10 | .vscode/launch.json 11 | 12 | #ignore .commitinfo created by ioBroker release script 13 | .commitinfo 14 | -------------------------------------------------------------------------------- /test/integration.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const { tests } = require('@iobroker/testing'); 3 | 4 | // Run integration tests - See https://github.com/ioBroker/testing for a detailed explanation and further options 5 | tests.integration(path.join(__dirname, '..')); 6 | -------------------------------------------------------------------------------- /prettier.config.mjs: -------------------------------------------------------------------------------- 1 | // iobroker prettier configuration file 2 | import prettierConfig from "@iobroker/eslint-config/prettier.config.mjs"; 3 | 4 | export default { 5 | ...prettierConfig, 6 | // uncomment next line if you prefer double quotes 7 | // singleQuote: false, 8 | }; 9 | -------------------------------------------------------------------------------- /admin/root/howto.md: -------------------------------------------------------------------------------- 1 | Das Rooten beschränkt sich aktuell auf die Saugroboter V1 und S5/50 ebenso darf die aktulle Firmware beim S50 nicht größer as 1900 sein da hier ein Schutz mit eingebaut wurde, dass man keine kleiner FW flashen kann (hier muss dann ggf erst einmal auf Werkseinst ellung resetten werden). -------------------------------------------------------------------------------- /tsconfig.check.json: -------------------------------------------------------------------------------- 1 | // Specialized tsconfig for type-checking js files 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": {}, 5 | "include": [ 6 | "**/*.js", 7 | "**/*.d.ts" 8 | ], 9 | "exclude": [ 10 | "**/build", 11 | "node_modules/", 12 | "widgets/", 13 | "gulpfile.js" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /test/mocha.setup.js: -------------------------------------------------------------------------------- 1 | // Don't silently swallow unhandled rejections 2 | process.on('unhandledRejection', (e) => { 3 | throw e; 4 | }); 5 | 6 | // enable the should interface with sinon 7 | // and load chai-as-promised and sinon-chai by default 8 | const sinonChai = require('sinon-chai'); 9 | const chaiAsPromised = require('chai-as-promised'); 10 | const { should, use } = require('chai'); 11 | 12 | should(); 13 | use(sinonChai); 14 | use(chaiAsPromised); -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/new_device.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: neue Geräte (deutsch) 3 | about: Bei supportwusch für neue Geräte 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Infos zum Gerät:** 11 | Achtung! Aktuell nur Roborock Geräte anfragen 12 | - roborock 13 | - modell 14 | - Bezeichnung (z.B roborock.vacuum.a15) 15 | 16 | **Wichtig** 17 | Das Einbinden neuer Geräte ist aufwending und erfordert viel Recherche, 18 | hierfür fehlt mir die Zeit. Also bitte sucht selbst auf git ob und wie der Sauger eingebunden werden kann, 19 | und verlinkt die stellen 20 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Dependabot will run on day 14 of each month at 01:34 (Europe/Berlin timezone) 2 | version: 2 3 | updates: 4 | 5 | - package-ecosystem: "npm" 6 | directory: "/" 7 | schedule: 8 | interval: "cron" 9 | timezone: "Europe/Berlin" 10 | cronjob: "34 1 14 * *" 11 | open-pull-requests-limit: 15 12 | versioning-strategy: "increase" 13 | 14 | - package-ecosystem: "github-actions" 15 | directory: "/" 16 | schedule: 17 | interval: "cron" 18 | timezone: "Europe/Berlin" 19 | cronjob: "34 1 14 * *" 20 | open-pull-requests-limit: 15 21 | -------------------------------------------------------------------------------- /.github/auto-merge.yml: -------------------------------------------------------------------------------- 1 | # Configure here which dependency updates should be merged automatically. 2 | # The recommended configuration is the following: 3 | - match: 4 | # Only merge patches for production dependencies 5 | dependency_type: production 6 | update_type: "semver:patch" 7 | - match: 8 | # Except for security fixes, here we allow minor patches 9 | dependency_type: production 10 | update_type: "security:minor" 11 | - match: 12 | # and development dependencies can have a minor update, too 13 | dependency_type: development 14 | update_type: "semver:minor" 15 | 16 | # The syntax is based on the legacy dependabot v1 automerged_updates syntax, see: 17 | # https://dependabot.com/docs/config-file/#automerged_updates -------------------------------------------------------------------------------- /.github/workflows/dependabot-auto-merge.yml: -------------------------------------------------------------------------------- 1 | # Automatically merge Dependabot PRs when version comparison is within the range 2 | # that is configured in .github/auto-merge.yml 3 | 4 | name: Auto-Merge Dependabot PRs 5 | 6 | on: 7 | pull_request_target: 8 | 9 | jobs: 10 | auto-merge: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v4 15 | 16 | - name: Check if PR should be auto-merged 17 | uses: ahmadnassri/action-dependabot-auto-merge@v2 18 | with: 19 | # This must be a personal access token with push access 20 | github-token: ${{ secrets.AUTO_MERGE_TOKEN }} 21 | # By default, squash and merge, so Github chooses nice commit messages 22 | command: squash and merge 23 | -------------------------------------------------------------------------------- /main.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /** 4 | * This is a dummy TypeScript test file using chai and mocha 5 | * 6 | * It's automatically excluded from npm and its build output is excluded from both git and npm. 7 | * It is advised to test all your modules with accompanying *.test.js-files 8 | */ 9 | 10 | // tslint:disable:no-unused-expression 11 | 12 | const { expect } = require("chai"); 13 | // import { functionToTest } from "./moduleToTest"; 14 | 15 | describe("module to test => function to test", () => { 16 | // initializing logic 17 | const expected = 5; 18 | 19 | it(`should return ${expected}`, () => { 20 | const result = 5; 21 | // assign result a value from functionToTest 22 | expect(result).to.equal(expected); 23 | // or using the should() syntax 24 | result.should.equal(expected); 25 | }); 26 | // ... more tests => it 27 | }); 28 | 29 | // ... more test suites => describe 30 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | // ioBroker eslint template configuration file for js and ts files 2 | // Please note that esm or react based modules need additional modules loaded. 3 | import config from "@iobroker/eslint-config"; 4 | 5 | export default [ 6 | ...config, 7 | 8 | { 9 | // specify files to exclude from linting here 10 | ignores: [ 11 | ".dev-server/", 12 | ".vscode/", 13 | "*.test.js", 14 | "test/**/*.js", 15 | "*.config.mjs", 16 | "build", 17 | "admin/build", 18 | "admin/words.js", 19 | "admin/admin.d.ts", 20 | "**/adapter-config.d.ts", 21 | ], 22 | }, 23 | 24 | { 25 | // you may disable some 'jsdoc' warnings - but using jsdoc is highly recommended 26 | // as this improves maintainability. jsdoc warnings will not block buiuld process. 27 | rules: { 28 | 'jsdoc/require-jsdoc': 'off', 29 | '@typescript-eslint/no-this-alias': 'off' 30 | }, 31 | }, 32 | ]; 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Something is not working as it should 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 15 | 16 | **Describe the bug** 17 | A clear and concise description of what the bug is. 18 | 19 | **To Reproduce** 20 | Steps to reproduce the behavior: 21 | 22 | 1. Go to '...' 23 | 2. Click on '...' 24 | 3. Scroll down to '....' 25 | 4. See error 26 | 27 | **Expected behavior** 28 | A clear and concise description of what you expected to happen. 29 | 30 | **Screenshots & Logfiles** 31 | If applicable, add screenshots and logfiles to help explain your problem. 32 | 33 | **Versions:** 34 | 35 | - Adapter version: 36 | - JS-Controller version: 37 | - Node version: 38 | - vacuum cleaner: 39 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | // Root tsconfig to set the settings and power editor support for all TS files 2 | { 3 | "compileOnSave": true, 4 | "compilerOptions": { 5 | // do not compile anything, this file is just to configure type checking 6 | "noEmit": true, 7 | 8 | // check JS files 9 | "allowJs": true, 10 | "checkJs": true, 11 | 12 | "module": "commonjs", 13 | "moduleResolution": "node", 14 | "esModuleInterop": true, 15 | // this is necessary for the automatic typing of the adapter config 16 | "resolveJsonModule": true, 17 | 18 | // Set this to false if you want to disable the very strict rules (not recommended) 19 | "strict": true, 20 | // Or enable some of those features for more fine-grained control 21 | // "strictNullChecks": true, 22 | // "strictPropertyInitialization": true, 23 | // "strictBindCallApply": true, 24 | "noImplicitAny": false, 25 | // "noUnusedLocals": true, 26 | // "noUnusedParameters": true, 27 | 28 | // Consider targetting es2019 or higher if you only support Node.js 12+ 29 | "target": "es2018", 30 | 31 | }, 32 | "include": [ 33 | "**/*.js", 34 | "**/*.d.ts" 35 | ], 36 | "exclude": [ 37 | "node_modules/**" 38 | ] 39 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2023-2025 iobroker community developers 4 | Copyright (c) 2017-2023 bluefox 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | 24 | -------------------------------------------------------------------------------- /admin/i18n/zh-cn/translations.json: -------------------------------------------------------------------------------- 1 | { 2 | "Choose your robot": "选择你的机器人", 3 | "Color floor:": "彩色地板:", 4 | "Color path:": "颜色路径:", 5 | "Color wall:": "彩色墙:", 6 | "Enable Valetudo": "启用Valetudo", 7 | "Friday": "星期五", 8 | "IP address:": "IP地址:", 9 | "Invalid token length. Expected 32 or 96 HEX chars.": "令牌长度无效。", 10 | "Map preview": "地图预览", 11 | "Map save intervall": "地图保存间隔", 12 | "MiHome-vacuum adapter settings": "MiHome真空适配器设置", 13 | "Monday": "星期一", 14 | "Own port:": "自己的港口:", 15 | "Please get Devices first": "请先获取设备", 16 | "Resume paused zonecleaning with start button": "使用开始按钮恢复暂停的区域清理", 17 | "Robot images": "机器人图像", 18 | "Saturday": "星期六", 19 | "Send own commands": "发送自己的命令", 20 | "Skip Timer": "跳过计时器", 21 | "Sunday": "星期日", 22 | "Thursday": "星期四", 23 | "Timer": "计时器", 24 | "Token": "代币", 25 | "Tuesday": "星期二", 26 | "Vacuum port:": "真空口:", 27 | "Wednesday": "星期三", 28 | "adapter for control of Xiaomi/Roborock vacuum cleaner": "小米/罗伯克吸尘器控制适配器", 29 | "add a state for Alexa": "为Alexa添加状态", 30 | "add timer and choose room channels directly and/or choose rooms, which finds assigned room channels": "添加计时器并直接选择房间频道和/或选择房间,以查找分配的房间频道", 31 | "additional settings": "其他设置", 32 | "choose Device": "选择设备", 33 | "clean Room": "整理房间", 34 | "clean assigned rooms": "清洁分配的房间", 35 | "clean water Filter": "净水过滤器", 36 | "connection settings": "连接设置", 37 | "day": "天", 38 | "disabled": "残障人士", 39 | "donateInformation": "欢迎通过Github或ioBroker论坛建议新功能或设备。如果您喜欢此适配器,则非常欢迎您捐赠。", 40 | "enabled": "已启用", 41 | "get Status Intervall": "请求状态(以s为单位)", 42 | "get WiFi Intervall": "在s中请求WiFi状态", 43 | "hour": "小时", 44 | "insert map Index or zone coordinates": "插入地图索引或区域坐标", 45 | "load rooms from robot": "从机器人装载室", 46 | "manager": "使用经理", 47 | "minute": "分钟", 48 | "model": "模型", 49 | "next timer": "下一个计时器", 50 | "not available": "无法使用", 51 | "rooms": "房间", 52 | "same start time of 2 timer not possible": "相同的2个计时器开始时间", 53 | "send Pause Before Home": "在回家之前发送暂停", 54 | "settings": "设置", 55 | "start now": "现在开始", 56 | "waiting position": "等待位置", 57 | "water box installed": "水箱安装", 58 | "water filter reset": "滤水器重置" 59 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "iobroker.mihome-vacuum", 3 | "version": "5.3.0", 4 | "description": "Control your mihome vacuum cleaner with ioBroker", 5 | "keywords": [ 6 | "ioBroker", 7 | "mihome-vacuum" 8 | ], 9 | "homepage": "https://github.com/iobroker-community-adapters/ioBroker.mihome-vacuum", 10 | "bugs": { 11 | "url": "https://github.com/iobroker-community-adapters/ioBroker.mihome-vacuum/issues" 12 | }, 13 | "license": "MIT", 14 | "author": { 15 | "name": "bluefox", 16 | "email": "dogafox@gmail.com" 17 | }, 18 | "main": "main.js", 19 | "repository": { 20 | "type": "git", 21 | "url": "https://github.com/iobroker-community-adapters/ioBroker.mihome-vacuum" 22 | }, 23 | "scripts": { 24 | "test:js": "mocha --config test/mocha.custom.json \"{!(node_modules|test)/**/*.test.js,*.test.js,test/**/test!(PackageFiles|Startup).js}\"", 25 | "test:package": "mocha test/package --exit", 26 | "test:unit": "mocha test/unit --exit", 27 | "test:integration": "mocha test/integration --exit", 28 | "test": "npm run test:js && npm run test:package", 29 | "check": "tsc --noEmit -p tsconfig.check.json", 30 | "lint": "eslint -c eslint.config.mjs .", 31 | "translate": "gulp translateAndUpdateWordsJS", 32 | "release": "release-script" 33 | }, 34 | "files": [ 35 | "admin{,/!(src)/**}/!(tsconfig|tsconfig.*).json", 36 | "admin{,/!(src)/**}/*.{html,css,png,svg,jpg,js}", 37 | "lib/", 38 | "www/", 39 | "io-package.json", 40 | "LICENSE", 41 | "main.js" 42 | ], 43 | "optionalDependencies": { 44 | "canvas": "^3.1.2" 45 | }, 46 | "engines": { 47 | "node": ">=18" 48 | }, 49 | "dependencies": { 50 | "@iobroker/adapter-core": "^3.2.3", 51 | "qs": "^6.14.0" 52 | }, 53 | "devDependencies": { 54 | "@alcalzone/release-script": "^3.8.0", 55 | "@alcalzone/release-script-plugin-iobroker": "^3.7.2", 56 | "@alcalzone/release-script-plugin-license": "^3.7.0", 57 | "@alcalzone/release-script-plugin-manual-review": "^3.7.0", 58 | "@iobroker/eslint-config": "^2.2.0", 59 | "@iobroker/testing": "^5.2.2", 60 | "@types/chai": "^5.2.2", 61 | "@types/chai-as-promised": "^8.0.2", 62 | "@types/gulp": "^4.0.18", 63 | "@types/mocha": "^10.0.10", 64 | "@types/node": "^24.10.1", 65 | "@types/proxyquire": "^1.3.31", 66 | "@types/sinon": "^17.0.3", 67 | "@types/sinon-chai": "^4.0.0", 68 | "axios": "^1.13.2", 69 | "chai": "^5.1.2", 70 | "chai-as-promised": "^8.0.2", 71 | "gulp": "^5.0.1", 72 | "mocha": "^11.7.5", 73 | "proxyquire": "^2.1.3", 74 | "sinon": "^21.0.0", 75 | "sinon-chai": "^4.0.1", 76 | "typescript": "^5.9.3" 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /admin/i18n/en/translations.json: -------------------------------------------------------------------------------- 1 | { 2 | "Choose your robot": "Choose your robot", 3 | "Color floor:": "Color floor:", 4 | "Color path:": "Color path:", 5 | "Color wall:": "Color wall:", 6 | "Enable Valetudo": "Enable Valetudo", 7 | "Friday": "Friday", 8 | "IP address:": "IP address:", 9 | "Invalid token length. Expected 32 or 96 HEX chars.": "Invalid token length. Expected 32 or 96 HEX chars.", 10 | "Map preview": "Map preview", 11 | "Map save intervall": "Map save intervall", 12 | "MiHome-vacuum adapter settings": "MiHome-vacuum adapter settings", 13 | "Monday": "Monday", 14 | "Own port:": "Own port:", 15 | "Please get Devices first": "Please get Devices first", 16 | "Resume paused zonecleaning with start button": "Resume paused zonecleaning with start button", 17 | "Robot images": "Robot images", 18 | "Saturday": "Saturday", 19 | "Send own commands": "Send own commands", 20 | "Skip Timer": "Skip Timer", 21 | "Sunday": "Sunday", 22 | "Thursday": "Thursday", 23 | "Timer": "Timer", 24 | "Token": "Token", 25 | "Tuesday": "Tuesday", 26 | "Vacuum port:": "Vacuum port: ", 27 | "Wednesday": "Wednesday", 28 | "adapter for control of Xiaomi/Roborock vacuum cleaner": "adapter for control of Xiaomi/Roborock vacuum cleaner", 29 | "add a state for Alexa": "add a state for Alexa", 30 | "add timer and choose room channels directly and/or choose rooms, which finds assigned room channels": "add timer and choose room channels directly and/or choose rooms, which finds assigned room channels", 31 | "additional settings": "additional settings", 32 | "choose Device": "choose Device", 33 | "clean Room": "clean Room", 34 | "clean assigned rooms": "clean assigned rooms", 35 | "clean water Filter": "clean water Filter", 36 | "connection settings": "Connection settings", 37 | "day": "day", 38 | "disabled": "disabled", 39 | "donateInformation": "Feel free to suggest new features or devices via Github or ioBroker forum. If you like this adapter, you very welcome to donate.", 40 | "enabled": "enabled", 41 | "get Status Intervall": "request Status in s", 42 | "get WiFi Intervall": "request WiFi Status in s", 43 | "hour": "hour", 44 | "insert map Index or zone coordinates": "insert map Index or zone coordinates", 45 | "load rooms from robot": "load rooms from robot", 46 | "manager": "use manager", 47 | "minute": "minute", 48 | "model": "model", 49 | "next timer": "next timer", 50 | "not available": "not available", 51 | "rooms": "rooms", 52 | "same start time of 2 timer not possible": "same start time of 2 timer not possible", 53 | "send Pause Before Home": "send Pause Before Home", 54 | "settings": "Settings", 55 | "start now": "start now", 56 | "waiting position": "waiting position", 57 | "water box installed": "water box installed", 58 | "water filter reset": "water filter reset" 59 | } -------------------------------------------------------------------------------- /admin/valetudo_logo_small.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | image/svg+xml -------------------------------------------------------------------------------- /admin/i18n/nl/translations.json: -------------------------------------------------------------------------------- 1 | { 2 | "Choose your robot": "Kies je robot", 3 | "Color floor:": "Kleur vloer:", 4 | "Color path:": "Kleur pad:", 5 | "Color wall:": "Kleur muur:", 6 | "Enable Valetudo": "Valetudo inschakelen", 7 | "Friday": "vrijdag", 8 | "IP address:": "IP adres:", 9 | "Invalid token length. Expected 32 or 96 HEX chars.": "Ongeldige tokenlengte. Verwachte 32 of 96 HEX-tekens.", 10 | "Map preview": "Voorbeeld van kaart", 11 | "Map save intervall": "Kaart opslaan intervall", 12 | "MiHome-vacuum adapter settings": "MiHome-vacuüm adapterinstellingen", 13 | "Monday": "maandag", 14 | "Own port:": "Eigen poort:", 15 | "Please get Devices first": "Koop eerst Apparaten", 16 | "Resume paused zonecleaning with start button": "Pauzeer zonecleaning opnieuw met de startknop", 17 | "Robot images": "Robotafbeeldingen", 18 | "Saturday": "zaterdag", 19 | "Send own commands": "Stuur eigen commando's", 20 | "Skip Timer": "Timer overslaan", 21 | "Sunday": "zondag", 22 | "Thursday": "donderdag", 23 | "Timer": "timer", 24 | "Token": "token", 25 | "Tuesday": "dinsdag", 26 | "Vacuum port:": "Vacuümpoort:", 27 | "Wednesday": "woensdag", 28 | "adapter for control of Xiaomi/Roborock vacuum cleaner": "adapter voor bediening van Xiaomi / Roborock stofzuiger", 29 | "add a state for Alexa": "voeg een staat toe voor Alexa", 30 | "add timer and choose room channels directly and/or choose rooms, which finds assigned room channels": "voeg de timer toe en kies direct kamerkanalen en / of kies kamers, die toegewezen kamerkanalen vinden", 31 | "additional settings": "aanvullende instellingen", 32 | "choose Device": "kies Apparaat", 33 | "clean Room": "schone kamer", 34 | "clean assigned rooms": "schone toegewezen kamers", 35 | "clean water Filter": "schoon waterfilter", 36 | "connection settings": "verbindingsinstellingen", 37 | "day": "dag", 38 | "disabled": "gehandicapt", 39 | "donateInformation": "Voel je vrij om nieuwe functies of apparaten voor te stellen via Github of het ioBroker-forum. Als je deze adapter leuk vindt, ben je van harte welkom om te doneren.", 40 | "enabled": "ingeschakeld", 41 | "get Status Intervall": "verzoek Status in s", 42 | "get WiFi Intervall": "vraag WiFi-status aan in s", 43 | "hour": "uur", 44 | "insert map Index or zone coordinates": "kaart invoegen Index- of zonecoördinaten", 45 | "load rooms from robot": "laad kamers van robot", 46 | "manager": "gebruik beheerder", 47 | "minute": "minuut", 48 | "model": "model", 49 | "next timer": "volgende timer", 50 | "not available": "niet beschikbaar", 51 | "rooms": "kamers", 52 | "same start time of 2 timer not possible": "zelfde starttijd van 2 timer niet mogelijk", 53 | "send Pause Before Home": "stuur een pauze voor thuis", 54 | "settings": "instellingen", 55 | "start now": "begin nu", 56 | "waiting position": "wachtpositie", 57 | "water box installed": "waterbak geïnstalleerd", 58 | "water filter reset": "waterfilter reset" 59 | } -------------------------------------------------------------------------------- /lib/stockCommands.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | find: { 3 | method: 'find_me', 4 | }, 5 | start: { 6 | method: 'app_start', 7 | }, 8 | pause: { 9 | method: 'app_pause', 10 | }, 11 | home: { 12 | method: 'app_charge', 13 | }, 14 | get_status: { 15 | method: 'get_status', 16 | }, 17 | get_consumable: { 18 | method: 'get_consumable', 19 | }, 20 | get_carpet_mode: { 21 | method: 'get_carpet_mode', 22 | }, 23 | get_sound_volume: { 24 | method: 'get_sound_volume', 25 | }, 26 | sound_volume: { 27 | method: 'change_sound_volume', 28 | }, 29 | sound_volume_test: { 30 | method: 'test_sound_volume', 31 | }, 32 | fan_power: { 33 | method: 'set_custom_mode', 34 | }, 35 | mop_mode: { 36 | method: 'set_mop_mode', 37 | }, 38 | water_box_mode: { 39 | method: 'set_water_box_custom_mode', 40 | }, 41 | clean_summary: { 42 | method: 'get_clean_summary', 43 | }, 44 | miIO_info: { 45 | method: 'miIO.info', 46 | }, 47 | clean_record: { 48 | method: 'get_clean_record', 49 | }, 50 | filter_reset: { 51 | method: 'reset_consumable', 52 | params: 'filter_work_time', 53 | }, 54 | sensors_reset: { 55 | method: 'reset_consumable', 56 | params: 'sensor_dirty_time', 57 | }, 58 | main_brush_reset: { 59 | method: 'reset_consumable', 60 | params: 'main_brush_work_time', 61 | }, 62 | mop_pad_reset: { 63 | method: 'reset_consumable', 64 | params: 'mop_pad_work_time', 65 | }, 66 | side_brush_reset: { 67 | method: 'reset_consumable', 68 | params: 'side_brush_work_time', 69 | }, 70 | water_filter_reset: { 71 | method: 'reset_consumable', 72 | params: 'filter_element_work_time', 73 | }, 74 | strainer_reset: { 75 | method: 'reset_consumable', 76 | params: 'strainer_work_times', 77 | }, 78 | cleaner_filter_reset: { 79 | method: 'reset_consumable', 80 | params: 'cleaning_brush_work_times', 81 | }, 82 | dust_collection_reset: { 83 | method: 'reset_consumable', 84 | params: 'dust_collection_work_times', 85 | }, 86 | spotclean: { 87 | method: 'app_spot', 88 | }, 89 | resumeZoneClean: { 90 | method: 'resume_zoned_clean', 91 | }, 92 | resumeRoomClean: { 93 | method: 'resume_segment_clean', 94 | }, 95 | loadRooms: { 96 | method: 'get_room_mapping', 97 | }, 98 | loadMap: { 99 | method: 'get_map_v1', 100 | }, 101 | startDustCollect: { 102 | method: 'app_start_collect_dust', 103 | }, 104 | stopDustCollect: { 105 | method: 'app_stop_collect_dust', 106 | }, 107 | startWashMop: { 108 | method: 'app_start_wash', 109 | }, 110 | stopWashMop: { 111 | method: 'app_stop_wash', 112 | }, 113 | }; 114 | -------------------------------------------------------------------------------- /admin/i18n/pl/translations.json: -------------------------------------------------------------------------------- 1 | { 2 | "Choose your robot": "Wybierz swojego robota", 3 | "Color floor:": "Kolor podłogi:", 4 | "Color path:": "Ścieżka kolorów:", 5 | "Color wall:": "Kolor ściany:", 6 | "Enable Valetudo": "Włącz Valetudo", 7 | "Friday": "Piątek", 8 | "IP address:": "Adres IP:", 9 | "Invalid token length. Expected 32 or 96 HEX chars.": "Nieprawidłowa długość tokenu. Oczekiwano 32 lub 96 znaków HEX.", 10 | "Map preview": "Podgląd mapy", 11 | "Map save intervall": "Interwał zapisu mapy", 12 | "MiHome-vacuum adapter settings": "Ustawienia adaptera MiHome-vacuum", 13 | "Monday": "Poniedziałek", 14 | "Own port:": "Własny port:", 15 | "Please get Devices first": "Najpierw zdobądź Urządzenia", 16 | "Resume paused zonecleaning with start button": "Wznów wstrzymanie procesu oczyszczania za pomocą przycisku Start", 17 | "Robot images": "Obrazy robotów", 18 | "Saturday": "Sobota", 19 | "Send own commands": "Wyślij własne polecenia", 20 | "Skip Timer": "Skip Timer", 21 | "Sunday": "Niedziela", 22 | "Thursday": "Czwartek", 23 | "Timer": "Regulator czasowy", 24 | "Token": "Znak", 25 | "Tuesday": "Wtorek", 26 | "Vacuum port:": "Port próżniowy:", 27 | "Wednesday": "Środa", 28 | "adapter for control of Xiaomi/Roborock vacuum cleaner": "adapter do sterowania odkurzaczem Xiaomi / Roborock", 29 | "add a state for Alexa": "dodaj stan dla Alexy", 30 | "add timer and choose room channels directly and/or choose rooms, which finds assigned room channels": "dodaj zegar i wybierz kanały pokojowe bezpośrednio i / lub wybierz pokoje, które wyszukają przypisane kanały pokojowe", 31 | "additional settings": "dodatkowe ustawienia", 32 | "choose Device": "wybierz Urządzenie", 33 | "clean Room": "czysty pokój", 34 | "clean assigned rooms": "posprzątaj przydzielone pokoje", 35 | "clean water Filter": "Filtr czystej wody", 36 | "connection settings": "ustawienia połączenia", 37 | "day": "dzień", 38 | "disabled": "niepełnosprawny", 39 | "donateInformation": "Zachęcamy do sugerowania nowych funkcji lub urządzeń za pośrednictwem forum Github lub ioBroker. Jeśli podoba Ci się ten adapter, możesz przekazać darowiznę.", 40 | "enabled": "włączone", 41 | "get Status Intervall": "żądanie Status w s", 42 | "get WiFi Intervall": "żądanie statusu WiFi za s", 43 | "hour": "godzina", 44 | "insert map Index or zone coordinates": "wstaw mapę Współrzędne indeksu lub strefy", 45 | "load rooms from robot": "ładuj pokoje z robota", 46 | "manager": "użyj menedżera", 47 | "minute": "minuta", 48 | "model": "model", 49 | "next timer": "następny minutnik", 50 | "not available": "niedostępne", 51 | "rooms": "pokoje", 52 | "same start time of 2 timer not possible": "ten sam czas rozpoczęcia 2 timera nie jest możliwy", 53 | "send Pause Before Home": "wyślij Wstrzymaj przed domem", 54 | "settings": "ustawienia", 55 | "start now": "zacząć teraz", 56 | "waiting position": "pozycja oczekiwania", 57 | "water box installed": "zainstalowany pojemnik na wodę", 58 | "water filter reset": "reset filtra wody" 59 | } -------------------------------------------------------------------------------- /admin/i18n/ru/translations.json: -------------------------------------------------------------------------------- 1 | { 2 | "Choose your robot": "Выбери своего робота", 3 | "Color floor:": "Цвет пола:", 4 | "Color path:": "Цветовая дорожка:", 5 | "Color wall:": "Цветная стена:", 6 | "Enable Valetudo": "Включить Валетудо", 7 | "Friday": "пятница", 8 | "IP address:": "Айпи адрес:", 9 | "Invalid token length. Expected 32 or 96 HEX chars.": "Недопустимая длина токена. Ожидаемые 32 или 96 символов HEX.", 10 | "Map preview": "Предварительный просмотр карты", 11 | "Map save intervall": "Карта сохранить интервал", 12 | "MiHome-vacuum adapter settings": "Настройки адаптера MiHome-vacuum", 13 | "Monday": "понедельник", 14 | "Own port:": "Собственный порт:", 15 | "Please get Devices first": "Сначала приобретите устройства", 16 | "Resume paused zonecleaning with start button": "Возобновление приостановки zonecleaning с кнопкой запуска", 17 | "Robot images": "Робот изображения", 18 | "Saturday": "суббота", 19 | "Send own commands": "Отправить собственные команды", 20 | "Skip Timer": "Пропустить таймер", 21 | "Sunday": "Воскресенье", 22 | "Thursday": "Четверг", 23 | "Timer": "таймер", 24 | "Token": "Знак", 25 | "Tuesday": "вторник", 26 | "Vacuum port:": "Вакуумный порт:", 27 | "Wednesday": "среда", 28 | "adapter for control of Xiaomi/Roborock vacuum cleaner": "адаптер для управления пылесосом Xiaomi / Roborock", 29 | "add a state for Alexa": "добавить информацию о пользователе Alexa", 30 | "add timer and choose room channels directly and/or choose rooms, which finds assigned room channels": "добавить таймер и выбрать каналы номеров напрямую и / или выбрать номера, которые находят назначенные каналы номеров", 31 | "additional settings": "дополнительные настройки", 32 | "choose Device": "выберите Устройство", 33 | "clean Room": "чистая комната", 34 | "clean assigned rooms": "чистые назначенные комнаты", 35 | "clean water Filter": "Фильтр чистой воды", 36 | "connection settings": "Настройки соединения", 37 | "day": "день", 38 | "disabled": "отключен", 39 | "donateInformation": "Не стесняйтесь предлагать новые функции или устройства через Github или форум ioBroker. Если вам понравился этот адаптер, пожалуйста, сделайте пожертвование.", 40 | "enabled": "включен", 41 | "get Status Intervall": "Состояние запроса в с", 42 | "get WiFi Intervall": "запросить статус WiFi в с", 43 | "hour": "час", 44 | "insert map Index or zone coordinates": "вставить карту указатель или координаты зоны", 45 | "load rooms from robot": "загрузить комнаты от робота", 46 | "manager": "использовать менеджер", 47 | "minute": "минут", 48 | "model": "модель", 49 | "next timer": "следующий таймер", 50 | "not available": "нет в наличии", 51 | "rooms": "номера", 52 | "same start time of 2 timer not possible": "то же время начала 2 таймера невозможно", 53 | "send Pause Before Home": "отправить паузу перед домом", 54 | "settings": "настройки", 55 | "start now": "начинай сейчас", 56 | "waiting position": "позиция ожидания", 57 | "water box installed": "установлен водяной бокс", 58 | "water filter reset": "сброс фильтра воды" 59 | } -------------------------------------------------------------------------------- /admin/i18n/de/translations.json: -------------------------------------------------------------------------------- 1 | { 2 | "Choose your robot": "Wähle einen Roboter", 3 | "Color floor:": "Bodenfarbe:", 4 | "Color path:": "Farbe der Route:", 5 | "Color wall:": "Wandfarbe:", 6 | "Enable Valetudo": "Aktiviere Valetudo", 7 | "Friday": "Freitag", 8 | "IP address:": "IP-Adresse:", 9 | "Invalid token length. Expected 32 or 96 HEX chars.": "Ungültige Tokenlänge Erwartete 32 oder 96 HEX-Zeichen.", 10 | "Map preview": "Kartenvorschau", 11 | "Map save intervall": "Intervall für die Kartenspeicherung", 12 | "MiHome-vacuum adapter settings": "MiHome-Vakuum Adaptereinstellungen", 13 | "Monday": "Montag", 14 | "Own port:": "Eigener Port:", 15 | "Please get Devices first": "Bitte holen Sie sich zuerst Geräte", 16 | "Resume paused zonecleaning with start button": "Zonenreinigung nach Pause fortsetzen", 17 | "Robot images": "Roboterbilder", 18 | "Saturday": "Samstag", 19 | "Send own commands": "Sende eigene Befehle", 20 | "Skip Timer": "Timer aussetzen", 21 | "Sunday": "Sonntag", 22 | "Thursday": "Donnerstag", 23 | "Timer": "Timer", 24 | "Token": "Token", 25 | "Tuesday": "Dienstag", 26 | "Vacuum port:": "Port des Roboters: ", 27 | "Wednesday": "Mittwoch", 28 | "adapter for control of Xiaomi/Roborock vacuum cleaner": "Adapter zur Steuerung des Staubsaugers Xiaomi / Roborock", 29 | "add a state for Alexa": "Füge einen Zustand für Alexa hinzu", 30 | "add timer and choose room channels directly and/or choose rooms, which finds assigned room channels": "Timer hinzufügen und Raumkanäle direkt auswählen und/oder Räume auswählen, um zugewiesene Raumkanäle zu finden", 31 | "additional settings": "zusätzliche Einstellungen", 32 | "choose Device": "Wählen Sie Gerät", 33 | "clean Room": "reinige Zimmer", 34 | "clean assigned rooms": "zugewiesene Zimmer reinigen", 35 | "clean water Filter": "säubere Wasser Filter", 36 | "connection settings": "Verbindungseinstellungen", 37 | "day": "Tag", 38 | "disabled": "deaktiviert", 39 | "donateInformation": "Neue Geräte / Features können gerne über Github oder das ioBroker Forum angefragt werden. Wenn dieser Adapter gefällt / nützlich ist, sind Spenden herzlich Willkommen.", 40 | "enabled": "aktiv", 41 | "get Status Intervall": "Status anfordern in Sekunden", 42 | "get WiFi Intervall": "WLAN-Status anfordern in Sekunden", 43 | "hour": "Stunde", 44 | "insert map Index or zone coordinates": "Kartenindex oder Zonenkoordinaten einfügen", 45 | "load rooms from robot": "Lade Räume vom Roboter", 46 | "manager": "Verwende Manager", 47 | "minute": "Minute", 48 | "model": "Modell", 49 | "next timer": "nächster Timer", 50 | "not available": "Nicht verfügbar", 51 | "rooms": "Räume", 52 | "same start time of 2 timer not possible": "gleiche Startzeit von 2 Timer nicht möglich", 53 | "send Pause Before Home": "Pause senden bevor Zuhause Befehl gesendet wird", 54 | "settings": "Einstellungen", 55 | "start now": "Jetzt anfangen", 56 | "waiting position": "Warteposition", 57 | "water box installed": "Wischeinheit installiert", 58 | "water filter reset": "Wasser Filter zurücksetzen" 59 | } -------------------------------------------------------------------------------- /admin/i18n/it/translations.json: -------------------------------------------------------------------------------- 1 | { 2 | "Choose your robot": "Scegli il tuo robot", 3 | "Color floor:": "Colore piano:", 4 | "Color path:": "Percorso colore:", 5 | "Color wall:": "Muro di colore:", 6 | "Enable Valetudo": "Abilita Valetudo", 7 | "Friday": "Venerdì", 8 | "IP address:": "Indirizzo IP:", 9 | "Invalid token length. Expected 32 or 96 HEX chars.": "Lunghezza del token non valida. Previsti 32 o 96 caratteri HEX.", 10 | "Map preview": "Anteprima della mappa", 11 | "Map save intervall": "Intervallo di salvataggio della mappa", 12 | "MiHome-vacuum adapter settings": "Impostazioni dell'adattatore per vuoto MiHome", 13 | "Monday": "Lunedi", 14 | "Own port:": "Porto proprio:", 15 | "Please get Devices first": "Per favore procurati prima i dispositivi", 16 | "Resume paused zonecleaning with start button": "Riprendi zonecleaning in pausa con il pulsante di avvio", 17 | "Robot images": "Immagini di robot", 18 | "Saturday": "Sabato", 19 | "Send own commands": "Invia i tuoi comandi", 20 | "Skip Timer": "Skip Timer", 21 | "Sunday": "Domenica", 22 | "Thursday": "giovedi", 23 | "Timer": "Timer", 24 | "Token": "Gettone", 25 | "Tuesday": "martedì", 26 | "Vacuum port:": "Porta di vuoto:", 27 | "Wednesday": "mercoledì", 28 | "adapter for control of Xiaomi/Roborock vacuum cleaner": "adattatore per controllo aspirapolvere Xiaomi / Roborock", 29 | "add a state for Alexa": "aggiungi uno stato per Alexa", 30 | "add timer and choose room channels directly and/or choose rooms, which finds assigned room channels": "aggiungi il timer e scegli i canali room direttamente e / o scegli room, che trova i canali room assegnati", 31 | "additional settings": "altre impostazioni", 32 | "choose Device": "scegli Dispositivo", 33 | "clean Room": "stanza pulita", 34 | "clean assigned rooms": "camere pulite assegnate", 35 | "clean water Filter": "filtro per acqua pulita", 36 | "connection settings": "impostazioni di connessione", 37 | "day": "giorno", 38 | "disabled": "Disabilitato", 39 | "donateInformation": "Sentiti libero di suggerire nuove funzionalità o dispositivi tramite Github o il forum ioBroker. Se ti piace questo adattatore, ti invitiamo a donare.", 40 | "enabled": "abilitato", 41 | "get Status Intervall": "richiesta Status in s", 42 | "get WiFi Intervall": "richiedere lo stato WiFi in s", 43 | "hour": "ora", 44 | "insert map Index or zone coordinates": "inserisci l'indice della mappa o le coordinate della zona", 45 | "load rooms from robot": "caricare le stanze dal robot", 46 | "manager": "utilizzare il gestore", 47 | "minute": "minuto", 48 | "model": "modello", 49 | "next timer": "timer successivo", 50 | "not available": "non disponibile", 51 | "rooms": "camere", 52 | "same start time of 2 timer not possible": "la stessa ora di inizio del timer 2 non è possibile", 53 | "send Pause Before Home": "invia Pause Before Home", 54 | "settings": "impostazioni", 55 | "start now": "Parti ora", 56 | "waiting position": "posizione di attesa", 57 | "water box installed": "scatola dell'acqua installata", 58 | "water filter reset": "reset del filtro dell'acqua" 59 | } -------------------------------------------------------------------------------- /admin/i18n/pt/translations.json: -------------------------------------------------------------------------------- 1 | { 2 | "Choose your robot": "Escolha o seu robô", 3 | "Color floor:": "Piso de cor:", 4 | "Color path:": "Caminho da cor:", 5 | "Color wall:": "Parede de cor:", 6 | "Enable Valetudo": "Ativar Valetudo", 7 | "Friday": "Sexta-feira", 8 | "IP address:": "Endereço de IP:", 9 | "Invalid token length. Expected 32 or 96 HEX chars.": "Comprimento de token inválido. Esperava 32 ou 96 caracteres HEX.", 10 | "Map preview": "Visualização do mapa", 11 | "Map save intervall": "Intervalo de salvamento do mapa", 12 | "MiHome-vacuum adapter settings": "Configurações do adaptador MiHome-vácuo", 13 | "Monday": "Segunda-feira", 14 | "Own port:": "Porta própria:", 15 | "Please get Devices first": "Obtenha os dispositivos primeiro", 16 | "Resume paused zonecleaning with start button": "Retomar o zonecleaning pausado com o botão Iniciar", 17 | "Robot images": "Imagens de robô", 18 | "Saturday": "Sábado", 19 | "Send own commands": "Mandar comandos próprios", 20 | "Skip Timer": "Skip Timer", 21 | "Sunday": "Domingo", 22 | "Thursday": "Quinta-feira", 23 | "Timer": "Cronômetro", 24 | "Token": "Símbolo", 25 | "Tuesday": "terça", 26 | "Vacuum port:": "Porta de vácuo:", 27 | "Wednesday": "Quarta-feira", 28 | "adapter for control of Xiaomi/Roborock vacuum cleaner": "adaptador para controle do aspirador Xiaomi / Roborock", 29 | "add a state for Alexa": "adicione um estado para Alexa", 30 | "add timer and choose room channels directly and/or choose rooms, which finds assigned room channels": "adicione temporizador e escolha os canais da sala diretamente e / ou escolha as salas, que encontram os canais de sala atribuídos", 31 | "additional settings": "Configurações adicionais", 32 | "choose Device": "escolha Dispositivo", 33 | "clean Room": "quarto limpo", 34 | "clean assigned rooms": "limpar quartos designados", 35 | "clean water Filter": "filtro de agua potável", 36 | "connection settings": "configurações de conexão", 37 | "day": "dia", 38 | "disabled": "Desativado", 39 | "donateInformation": "Sinta-se à vontade para sugerir novos recursos ou dispositivos por meio do fórum Github ou ioBroker. Se você gosta deste adaptador, fique à vontade para doar.", 40 | "enabled": "ativado", 41 | "get Status Intervall": "request Status in s", 42 | "get WiFi Intervall": "solicitar status WiFi em s", 43 | "hour": "hora", 44 | "insert map Index or zone coordinates": "inserir mapa Coordenadas de índice ou zona", 45 | "load rooms from robot": "carregar salas do robô", 46 | "manager": "gerenciador de uso", 47 | "minute": "minuto", 48 | "model": "modelo", 49 | "next timer": "próximo temporizador", 50 | "not available": "não disponível", 51 | "rooms": "quartos", 52 | "same start time of 2 timer not possible": "mesmo horário de início de 2 temporizadores não é possível", 53 | "send Pause Before Home": "enviar pausa antes de casa", 54 | "settings": "configurações", 55 | "start now": "Comece agora", 56 | "waiting position": "posição de espera", 57 | "water box installed": "caixa de água instalada", 58 | "water filter reset": "redefinição do filtro de água" 59 | } -------------------------------------------------------------------------------- /admin/i18n/es/translations.json: -------------------------------------------------------------------------------- 1 | { 2 | "Choose your robot": "Elige tu robot", 3 | "Color floor:": "Piso de color:", 4 | "Color path:": "Ruta de color:", 5 | "Color wall:": "Pared de color:", 6 | "Enable Valetudo": "Habilitar Valetudo", 7 | "Friday": "viernes", 8 | "IP address:": "Dirección IP:", 9 | "Invalid token length. Expected 32 or 96 HEX chars.": "Longitud de token inválida. Se esperan 32 o 96 caracteres HEX.", 10 | "Map preview": "Vista previa del mapa", 11 | "Map save intervall": "Mapa guardar intervalo", 12 | "MiHome-vacuum adapter settings": "Configuración del adaptador MiHome-vacuum", 13 | "Monday": "lunes", 14 | "Own port:": "Puerto propio:", 15 | "Please get Devices first": "Obtenga primero los dispositivos", 16 | "Resume paused zonecleaning with start button": "Reanudar la zona de limpieza en pausa con el botón de inicio", 17 | "Robot images": "Imágenes de robot", 18 | "Saturday": "sábado", 19 | "Send own commands": "Enviar los propios comandos", 20 | "Skip Timer": "Saltar temporizador", 21 | "Sunday": "domingo", 22 | "Thursday": "jueves", 23 | "Timer": "Temporizador", 24 | "Token": "Simbólico", 25 | "Tuesday": "martes", 26 | "Vacuum port:": "Puerto de vacío:", 27 | "Wednesday": "miércoles", 28 | "adapter for control of Xiaomi/Roborock vacuum cleaner": "Adaptador para el control de la aspiradora Xiaomi / Roborock", 29 | "add a state for Alexa": "agregar un estado para Alexa", 30 | "add timer and choose room channels directly and/or choose rooms, which finds assigned room channels": "agregue temporizador y elija canales de sala directamente y / o elija salas, que encuentra los canales de sala asignados", 31 | "additional settings": "ajustes adicionales", 32 | "choose Device": "elegir dispositivo", 33 | "clean Room": "Habitación limpia", 34 | "clean assigned rooms": "limpiar habitaciones asignadas", 35 | "clean water Filter": "filtro de agua limpia", 36 | "connection settings": "configuración de conexión", 37 | "day": "día", 38 | "disabled": "discapacitado", 39 | "donateInformation": "No dude en sugerir nuevas funciones o dispositivos a través de Github o del foro ioBroker. Si le gusta este adaptador, le invitamos a donar.", 40 | "enabled": "habilitado", 41 | "get Status Intervall": "Solicitar estado en s", 42 | "get WiFi Intervall": "solicitar estado de WiFi en s", 43 | "hour": "hora", 44 | "insert map Index or zone coordinates": "inserte el índice del mapa o las coordenadas de zona", 45 | "load rooms from robot": "cargar habitaciones desde robot", 46 | "manager": "administrador de uso", 47 | "minute": "minuto", 48 | "model": "modelo", 49 | "next timer": "siguiente temporizador", 50 | "not available": "no disponible", 51 | "rooms": "habitaciones", 52 | "same start time of 2 timer not possible": "No es posible la misma hora de inicio de 2 temporizadores", 53 | "send Pause Before Home": "enviar pausa antes de casa", 54 | "settings": "ajustes", 55 | "start now": "empezar ahora", 56 | "waiting position": "posición de espera", 57 | "water box installed": "caja de agua instalada", 58 | "water filter reset": "restablecimiento del filtro de agua" 59 | } -------------------------------------------------------------------------------- /admin/i18n/fr/translations.json: -------------------------------------------------------------------------------- 1 | { 2 | "Choose your robot": "Choisissez votre robot", 3 | "Color floor:": "Plancher couleur:", 4 | "Color path:": "Chemin de couleur:", 5 | "Color wall:": "Mur de couleur:", 6 | "Enable Valetudo": "Activer Valetudo", 7 | "Friday": "Vendredi", 8 | "IP address:": "Adresse IP:", 9 | "Invalid token length. Expected 32 or 96 HEX chars.": "Longueur de jeton incorrecte Caractères HEX attendus 32 ou 96.", 10 | "Map preview": "Aperçu de la carte", 11 | "Map save intervall": "Carte enregistrer l'intervalle", 12 | "MiHome-vacuum adapter settings": "MiHome-paramètres d'adaptateur de vide", 13 | "Monday": "Lundi", 14 | "Own port:": "Propre port:", 15 | "Please get Devices first": "Veuillez d'abord vous procurer des appareils", 16 | "Resume paused zonecleaning with start button": "Reprendre le nettoyage en mode pause avec le bouton Démarrer", 17 | "Robot images": "Images de robots", 18 | "Saturday": "samedi", 19 | "Send own commands": "Envoyer ses propres commandes", 20 | "Skip Timer": "Passer la minuterie", 21 | "Sunday": "dimanche", 22 | "Thursday": "Jeudi", 23 | "Timer": "Minuteur", 24 | "Token": "Jeton", 25 | "Tuesday": "Mardi", 26 | "Vacuum port:": "Port de vide:", 27 | "Wednesday": "Mercredi", 28 | "adapter for control of Xiaomi/Roborock vacuum cleaner": "adaptateur pour contrôler l'aspirateur Xiaomi / Roborock", 29 | "add a state for Alexa": "ajouter un état pour Alexa", 30 | "add timer and choose room channels directly and/or choose rooms, which finds assigned room channels": "ajouter une minuterie et choisir directement les canaux de pièce et / ou choisir les chambres, qui trouve les canaux de pièce attribués", 31 | "additional settings": "paramètres additionnels", 32 | "choose Device": "choisissez Appareil", 33 | "clean Room": "chambre propre", 34 | "clean assigned rooms": "nettoyer les chambres attribuées", 35 | "clean water Filter": "Filtre à eau propre", 36 | "connection settings": "paramètres de connexion", 37 | "day": "journée", 38 | "disabled": "désactivée", 39 | "donateInformation": "N'hésitez pas à proposer de nouvelles fonctionnalités ou appareils via le forum Github ou ioBroker. Si vous aimez cet adaptateur, n'hésitez pas à faire un don.", 40 | "enabled": "activée", 41 | "get Status Intervall": "demander le statut en s", 42 | "get WiFi Intervall": "demande d'état WiFi en s", 43 | "hour": "heure", 44 | "insert map Index or zone coordinates": "insérer la carte Index ou coordonnées de zone", 45 | "load rooms from robot": "salles de chargement du robot", 46 | "manager": "gestionnaire d'utilisation", 47 | "minute": "minute", 48 | "model": "modèle", 49 | "next timer": "minuterie suivante", 50 | "not available": "indisponible", 51 | "rooms": "pièces", 52 | "same start time of 2 timer not possible": "même heure de démarrage de 2 minuteries impossible", 53 | "send Pause Before Home": "envoyer une pause avant la maison", 54 | "settings": "paramètres", 55 | "start now": "commencez maintenant", 56 | "waiting position": "position d'attente", 57 | "water box installed": "boîte à eau installée", 58 | "water filter reset": "réinitialisation du filtre à eau" 59 | } -------------------------------------------------------------------------------- /lib/tools.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios').default; 2 | 3 | /** 4 | * Tests whether the given variable is a real object and not an Array 5 | * 6 | * @param it The variable to test 7 | * @returns true or false 8 | */ 9 | function isObject(it) { 10 | // This is necessary because: 11 | // typeof null === 'object' 12 | // typeof [] === 'object' 13 | // [] instanceof Object === true 14 | return Object.prototype.toString.call(it) === '[object Object]'; 15 | } 16 | 17 | /** 18 | * Tests whether the given variable is really an Array 19 | * 20 | * @param it The variable to test 21 | * @returns true or false 22 | */ 23 | function isArray(it) { 24 | if (typeof Array.isArray === 'function') { 25 | return Array.isArray(it); 26 | } 27 | return Object.prototype.toString.call(it) === '[object Array]'; 28 | } 29 | 30 | /** 31 | * Translates text to the target language. Automatically chooses the right translation API. 32 | * 33 | * @param text The text to translate 34 | * @param targetLang The target languate 35 | * @param [yandexApiKey] The yandex API key. You can create one for free at https://translate.yandex.com/developers 36 | * @returns translatet text 37 | */ 38 | async function translateText(text, targetLang, yandexApiKey) { 39 | if (targetLang === 'en') { 40 | return text; 41 | } else if (!text) { 42 | return ''; 43 | } 44 | if (yandexApiKey) { 45 | return translateYandex(text, targetLang, yandexApiKey); 46 | } 47 | return translateGoogle(text, targetLang); 48 | } 49 | 50 | /** 51 | * Translates text with Yandex API 52 | * 53 | * @param text The text to translate 54 | * @param targetLang The target languate 55 | * @param apiKey The yandex API key. You can create one for free at https://translate.yandex.com/developers 56 | * @returns translated text 57 | */ 58 | async function translateYandex(text, targetLang, apiKey) { 59 | if (targetLang === 'zh-cn') { 60 | targetLang = 'zh'; 61 | } 62 | try { 63 | const url = `https://translate.yandex.net/api/v1.5/tr.json/translate?key=${apiKey}&text=${encodeURIComponent(text)}&lang=en-${targetLang}`; 64 | const response = await axios({ url, timeout: 15000 }); 65 | if (response.data && response.data.text && isArray(response.data.text)) { 66 | return response.data.text[0]; 67 | } 68 | throw new Error('Invalid response for translate request'); 69 | } catch (e) { 70 | throw new Error(`Could not translate to "${targetLang}": ${e}`); 71 | } 72 | } 73 | 74 | /** 75 | * Translates text with Google API 76 | * 77 | * @param text The text to translate 78 | * @param targetLang The target languate 79 | * @returns translated text 80 | */ 81 | async function translateGoogle(text, targetLang) { 82 | try { 83 | const url = `http://translate.googleapis.com/translate_a/single?client=gtx&sl=en&tl=${targetLang}&dt=t&q=${encodeURIComponent(text)}&ie=UTF-8&oe=UTF-8`; 84 | const response = await axios({ url, timeout: 15000 }); 85 | if (isArray(response.data)) { 86 | // we got a valid response 87 | return response.data[0][0][0]; 88 | } 89 | throw new Error('Invalid response for translate request'); 90 | } catch (e) { 91 | if (e.response && e.response.status === 429) { 92 | throw new Error(`Could not translate to "${targetLang}": Rate-limited by Google Translate`); 93 | } else { 94 | throw new Error(`Could not translate to "${targetLang}": ${e}`); 95 | } 96 | } 97 | } 98 | 99 | module.exports = { 100 | isArray, 101 | isObject, 102 | translateText, 103 | }; 104 | -------------------------------------------------------------------------------- /.github/workflows/test-and-release.yml: -------------------------------------------------------------------------------- 1 | name: Test and Release 2 | 3 | # Run this job on all pushes and pull requests 4 | # as well as tags with a semantic version 5 | on: 6 | push: 7 | branches: 8 | - "*" 9 | tags: 10 | # normal versions 11 | - "v[0-9]+.[0-9]+.[0-9]+" 12 | # pre-releases 13 | - "v[0-9]+.[0-9]+.[0-9]+-**" 14 | pull_request: {} 15 | 16 | jobs: 17 | # Performs quick checks before the expensive test runs 18 | check-and-lint: 19 | if: contains(github.event.head_commit.message, '[skip ci]') == false 20 | 21 | runs-on: ubuntu-latest 22 | 23 | strategy: 24 | matrix: 25 | node-version: [20.x] 26 | 27 | steps: 28 | - name: Checkout code 29 | uses: actions/checkout@v4 30 | 31 | - name: Use Node.js ${{ matrix.node-version }} 32 | uses: actions/setup-node@v4 33 | with: 34 | node-version: ${{ matrix.node-version }} 35 | 36 | - name: Install Dependencies 37 | run: npm ci 38 | 39 | - name: Lint source code 40 | run: npm run lint 41 | - name: Test package files 42 | run: npm run test:package 43 | 44 | # Runs adapter tests on all supported node versions and OSes 45 | adapter-tests: 46 | if: contains(github.event.head_commit.message, '[skip ci]') == false 47 | 48 | needs: [check-and-lint] 49 | 50 | runs-on: ${{ matrix.os }} 51 | strategy: 52 | matrix: 53 | node-version: [18.x, 20.x, 22.x, 24.x] 54 | os: [ubuntu-latest, windows-latest, macos-latest] 55 | 56 | steps: 57 | - name: Checkout code 58 | uses: actions/checkout@v4 59 | 60 | - name: Use Node.js ${{ matrix.node-version }} 61 | uses: actions/setup-node@v4 62 | with: 63 | node-version: ${{ matrix.node-version }} 64 | 65 | - name: Install Dependencies 66 | run: npm ci 67 | 68 | - name: Run unit tests 69 | run: npm run test:unit 70 | 71 | - name: Run integration tests (unix only) 72 | if: startsWith(runner.OS, 'windows') == false 73 | run: DEBUG=testing:* npm run test:integration 74 | 75 | - name: Run integration tests (windows only) 76 | if: startsWith(runner.OS, 'windows') 77 | run: set DEBUG=testing:* & npm run test:integration 78 | 79 | # Deploys the final package to NPM 80 | deploy: 81 | needs: [adapter-tests] 82 | 83 | # Trigger this step only when a commit on master is tagged with a version number 84 | if: | 85 | contains(github.event.head_commit.message, '[skip ci]') == false && 86 | github.event_name == 'push' && 87 | startsWith(github.ref, 'refs/tags/') 88 | runs-on: ubuntu-latest 89 | strategy: 90 | matrix: 91 | node-version: [20.x] 92 | 93 | steps: 94 | - name: Checkout code 95 | uses: actions/checkout@v4 96 | 97 | - name: Use Node.js ${{ matrix.node-version }} 98 | uses: actions/setup-node@v4 99 | with: 100 | node-version: ${{ matrix.node-version }} 101 | 102 | - name: Extract the version and commit body from the tag 103 | id: extract_release 104 | # The body may be multiline, therefore we need to escape some characters 105 | run: | 106 | VERSION="${{ github.ref }}" 107 | VERSION=${VERSION##*/} 108 | VERSION=${VERSION##*v} 109 | echo "::set-output name=VERSION::$VERSION" 110 | BODY=$(git show -s --format=%b) 111 | BODY="${BODY//'%'/'%25'}" 112 | BODY="${BODY//$'\n'/'%0A'}" 113 | BODY="${BODY//$'\r'/'%0D'}" 114 | echo "::set-output name=BODY::$BODY" 115 | 116 | - name: Install Dependencies 117 | run: npm install 118 | 119 | # - name: Create a clean build 120 | # run: npm run build 121 | - name: Publish package to npm 122 | run: | 123 | npm config set //registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }} 124 | npm whoami 125 | npm publish 126 | 127 | - name: Create Github Release 128 | uses: actions/create-release@v1 129 | env: 130 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 131 | with: 132 | tag_name: ${{ github.ref }} 133 | release_name: Release v${{ steps.extract_release.outputs.VERSION }} 134 | draft: false 135 | # Prerelease versions create pre-releases on Github 136 | prerelease: ${{ contains(steps.extract_release.outputs.VERSION, '-') }} 137 | body: ${{ steps.extract_release.outputs.BODY }} 138 | 139 | - name: Notify Sentry.io about the release 140 | run: | 141 | npm i -g @sentry/cli 142 | export SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} 143 | export SENTRY_URL=https://sentry.iobroker.net 144 | export SENTRY_ORG=iobroker 145 | export SENTRY_PROJECT=iobroker-mihome-vacuum 146 | export SENTRY_VERSION=iobroker.mihome-vacuum@${{ steps.extract_release.outputs.VERSION }} 147 | sentry-cli releases new $SENTRY_VERSION 148 | sentry-cli releases set-commits $SENTRY_VERSION --auto 149 | sentry-cli releases finalize $SENTRY_VERSION 150 | 151 | # Add the following line BEFORE finalize if sourcemap uploads are needed 152 | # sentry-cli releases files $SENTRY_VERSION upload-sourcemaps build/ 153 | -------------------------------------------------------------------------------- /lib/timerManager.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | let adapter = null; 3 | let timerManager = null; 4 | let i18n = null; 5 | 6 | class TimerManager { 7 | constructor(adapterInstance, i18nInstance) { 8 | adapter = adapterInstance; 9 | i18n = i18nInstance; 10 | timerManager = this; 11 | this.nextTimerId = null; 12 | this.nextProcessTime = null; 13 | 14 | setTimeout(() => { 15 | adapter.setObjectNotExists('info.nextTimer', { 16 | type: 'state', 17 | common: { 18 | name: i18n.nextTimer, 19 | type: 'string', 20 | role: 'info', 21 | read: true, 22 | write: false, 23 | }, 24 | native: {}, 25 | }); 26 | this.calcNextProcess(); 27 | }, 500); 28 | } 29 | 30 | check() { 31 | //adapter.log.warn('Timer Check... this.nextProcessTime: '+ this.nextProcessTime + ' this.nextProcessTime: '+ this.nextProcessTime); 32 | if (this.nextProcessTime > 0 && this.nextProcessTime < new Date()) { 33 | const diff = new Date().getTime() - this.nextProcessTime.getTime(); 34 | if (diff > 3600000) { 35 | adapter.log.info('timer was to old, skipped'); 36 | timerManager.calcNextProcess(); 37 | } else { 38 | adapter.log.debug('timer will trigger soon...'); 39 | this.nextProcessTime = new Date(this.nextProcessTime.getTime() + 3600000); 40 | 41 | setTimeout(() => { 42 | adapter.log.info(`start cleaning by timer ${timerManager.nextTimerId}`); 43 | adapter.setForeignState( 44 | timerManager.nextTimerId, 45 | TimerManager.START, 46 | false, 47 | (err, obj) => 48 | // obj not exist anymore, so we need recalc, otherwise it would be triggered by stateChange 49 | !obj && timerManager.calcNextProcess(), 50 | ); 51 | }, adapter.config.pingInterval - diff); 52 | } 53 | } 54 | } 55 | 56 | // calculate the nexttime, when the timer (state) should running 57 | _calcNextProcessTime(timerObj, now, onlyCalc) { 58 | let nextProcessTime = timerObj.native.nextProcessTime ? new Date(timerObj.native.nextProcessTime) : 0; 59 | if (!nextProcessTime || nextProcessTime < now) { 60 | const terms = timerObj._id.split('.').pop().split('_'); 61 | const minute = parseInt(terms[2], 10); 62 | const hour = parseInt(terms[1], 10); 63 | const day = terms[0].split(''); 64 | if (!day.length) { 65 | nextProcessTime = 0; 66 | } else { 67 | nextProcessTime = new Date(now); 68 | nextProcessTime.setHours(hour, minute, 0, 0); 69 | if (hour < now.getHours() || (hour === now.getHours() && minute < now.getMinutes())) { 70 | nextProcessTime.setDate(nextProcessTime.getDate() + 1); 71 | } 72 | const nowDay = nextProcessTime.getDay(); 73 | let dayDiff = -99; 74 | for (let i = day.length - 1; i >= 0 && day[i] >= nowDay; i--) { 75 | dayDiff = day[i] - nowDay; 76 | } 77 | if (dayDiff < 0) { 78 | dayDiff = day[0] - nowDay + 7; 79 | } 80 | dayDiff && nextProcessTime.setDate(nextProcessTime.getDate() + dayDiff); 81 | } 82 | 83 | if (nextProcessTime && nextProcessTime != timerObj.native.nextProcessTime && !onlyCalc) { 84 | timerObj.native.nextProcessTime = nextProcessTime; 85 | timerObj.common.states['1'] = 86 | `${i18n.weekDaysFull[nextProcessTime.getDay()]} ${adapter.formatDate(nextProcessTime, 'hh:mm')}`; 87 | let name = ''; 88 | if (day.length > 0 || timerObj.native.channels) { 89 | for (const d in day) { 90 | name += `${i18n.weekDaysFull[day[d]].substr(0, 2)} `; 91 | } 92 | } else { 93 | name += `${i18n.weekDaysFull[day[0]]} `; 94 | } 95 | name += `${'0'.concat(hour.toString()).slice(-2)}:${'0'.concat(minute.toString()).slice(-2)}`; 96 | timerObj.common.name = name; 97 | 98 | if (timerObj.native.channels) { 99 | name += ' >'; 100 | adapter.getChannelsOf('rooms', function (err, roomObjs) { 101 | let channels = ''; 102 | for (const r in roomObjs) { 103 | if (timerObj.native.channels.indexOf(roomObjs[r]._id.split('.').pop()) >= 0) { 104 | channels += `,${roomObjs[r].common.name}`; 105 | } 106 | } 107 | timerObj.common.name += ` >${channels.slice(1)}`; 108 | adapter.setObject(timerObj._id, timerObj); 109 | }); 110 | } else { 111 | adapter.setObject(timerObj._id, timerObj); 112 | } 113 | adapter.log.info( 114 | `calculate new process time (${timerObj.common.states['1']}) for timer ${timerObj._id}`, 115 | ); 116 | } 117 | } 118 | return nextProcessTime; 119 | } 120 | 121 | calcNextProcess() { 122 | const now = new Date(new Date().getTime() + 60000); //some time to calculate ... 123 | timerManager.nextProcessTime = new Date(now.getTime() + 604800000); // we start latest 1 week later... 124 | timerManager.nextTimerId = null; 125 | adapter.getStatesOf('timer', (err, timerObjects) => { 126 | try { 127 | const timers = {}; 128 | for (const t in timerObjects) { 129 | timers[timerObjects[t]._id] = { 130 | obj: timerObjects[t], 131 | time: timerManager._calcNextProcessTime(timerObjects[t], now), 132 | }; 133 | } 134 | 135 | adapter.getStates('timer.*', function (err, timerStates) { 136 | for (const t in timerStates) { 137 | if (timerStates[t] !== null && timerStates[t].val != TimerManager.DISABLED) { 138 | if (timerStates[t].val == TimerManager.SKIP) { 139 | timers[t].time = timerManager._calcNextProcessTime( 140 | timers[t].obj, 141 | new Date(timers[t].time.setMinutes(1)), 142 | true, 143 | ); 144 | } 145 | if (timers[t].time < timerManager.nextProcessTime) { 146 | timerManager.nextProcessTime = timers[t].time; 147 | timerManager.nextTimerId = t; 148 | } 149 | } 150 | } 151 | const nextTimerName = 152 | timerManager.nextTimerId && timerManager.nextProcessTime 153 | ? `${i18n.weekDaysFull[timerManager.nextProcessTime.getDay()]} ${adapter.formatDate( 154 | timerManager.nextProcessTime, 155 | 'hh:mm', 156 | )}` 157 | : i18n.notAvailable; 158 | const timerFolder = { 159 | id: `${adapter.namespace}.timer`, 160 | type: 'channel', 161 | native: {}, 162 | common: { name: `${i18n.nextTimer}: ${nextTimerName}` }, 163 | }; 164 | timerManager.nextProcessTime = new Date( 165 | timerManager.nextProcessTime.getTime() - adapter.config.pingInterval, 166 | ); 167 | adapter.setObject('timer', timerFolder); 168 | adapter.setState('info.nextTimer', nextTimerName, true); 169 | adapter.log.info(`settest ${timerFolder.common.name}`); 170 | }); 171 | } catch (error) { 172 | adapter.log.warn(`Could not calculate next timer ${error}`); 173 | if (adapter.supportsFeature && adapter.supportsFeature('PLUGINS')) { 174 | const sentryInstance = adapter.getPluginInstance('sentry'); 175 | if (sentryInstance) { 176 | sentryInstance.getSentryObject().captureException(error); 177 | } 178 | } 179 | } 180 | }); 181 | } 182 | } 183 | 184 | TimerManager.DISABLED = -1; 185 | TimerManager.SKIP = 0; 186 | TimerManager.ENABLED = 1; 187 | TimerManager.START = 2; 188 | 189 | module.exports = TimerManager; 190 | -------------------------------------------------------------------------------- /widgets/mihome-vacuum.html: -------------------------------------------------------------------------------- 1 | 95 | 134 | -------------------------------------------------------------------------------- /admin/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 32 | 33 | 189 | 190 |
191 | 192 | 193 | 194 | 195 | 198 | 199 |
196 |

MiHome-vacuum adapter settings

197 |
200 | 201 | 202 | 203 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 231 | 232 | 233 | 234 | 235 | 236 | 239 | 240 | 241 | 242 | 243 | 244 | 245 |
204 |

settings

205 |
237 |

experimental

238 |
246 |
247 | 248 | -------------------------------------------------------------------------------- /lib/maphelper.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | //const zlib = require('zlib'); 3 | const zlib = require('node:zlib'); 4 | const RRMapParser = require('./RRMapParser'); 5 | //const mapCreator = require('./mapCreator'); 6 | // libs for Cloudmap 7 | const XiaomiCloudConnector = require('./XiaomiCloudConnector'); 8 | 9 | //load if map is selected 10 | let mapCreator = { 11 | load: function () { 12 | try { 13 | mapCreator = require('./mapCreator'); 14 | return true; 15 | } catch (error) { 16 | console.warn(error); 17 | return false; 18 | } 19 | }, 20 | }; 21 | 22 | const mapUrlCache = []; 23 | 24 | //helpermap just for dev 25 | // let maptest = '["robomap%2F74476450%2F0"]'; 26 | class MapHelper { 27 | constructor(options, adapter) { 28 | if (typeof adapter === 'undefined') { 29 | adapter = adapter_helper; 30 | } 31 | let did; 32 | try { 33 | did = JSON.parse(adapter.config.devices).did; 34 | } catch (error) { 35 | adapter.log.error(error); 36 | } 37 | this.adapter = adapter; 38 | this.ready = false; 39 | 40 | this.config = { 41 | username: adapter && adapter.config && adapter.config.email ? adapter.config.email : '', 42 | password: adapter && adapter.config && adapter.config.password ? adapter.config.password : '', 43 | deviceId: did ? did : '', 44 | server: adapter && adapter.config && adapter.config.server ? adapter.config.server : '-', 45 | valetudo: 46 | adapter && adapter.config && adapter.config.valetudo_enable ? adapter.config.valetudo_enable : false, 47 | mimap: adapter && adapter.config && adapter.config.enableMiMap ? adapter.config.enableMiMap : false, 48 | ip: adapter && adapter.config && adapter.config.ip ? adapter.config.ip : '', 49 | COLOR_OPTIONS: { 50 | FLOORCOLOR: adapter.config.valetudo_color_floor, 51 | WALLCOLOR: adapter.config.valetudo_color_wall, 52 | PATHCOLOR: adapter.config.valetudo_color_path, 53 | ROBOT: adapter.config.robot_select, 54 | newmap: adapter && adapter.config && adapter.config.newmap ? adapter.config.newmap : false, 55 | }, 56 | }; 57 | if (this.config.valetudo || this.config.mimap) { 58 | adapter.log.debug(`load Map creator... ${mapCreator.load()}`); 59 | } 60 | 61 | this.cloudConnector = new XiaomiCloudConnector(adapter.log, { 62 | username: this.config.username, 63 | password: this.config.password, 64 | }); 65 | //this.adapter.log.debug("Maphelper_config___" + JSON.stringify(this.config)); 66 | //this.login(); 67 | } 68 | 69 | getRawMapData(urlstring) { 70 | let url; 71 | 72 | // micloud 73 | if (typeof urlstring !== 'undefined' && this.config.mimap) { 74 | url = urlstring; 75 | } else { 76 | // Valetudo 77 | url = `http://${this.config.ip}/api/map/latest`; 78 | } 79 | 80 | // Return new promise 81 | return new Promise(function (resolve, reject) { 82 | axios 83 | .get(url, { 84 | responseType: 'arraybuffer', // wichtig für Binärdaten 85 | decompress: false, // wir entpacken manuell 86 | }) 87 | .then(response => { 88 | const status = response.status; 89 | const buffer = Buffer.from(response.data); 90 | 91 | if (status !== 200) { 92 | if (status === 404) { 93 | //this.adapter.log.debug(`Mapresponse_ ${JSON.stringify(response.headers)}`); 94 | reject(`wrong server selected___${JSON.stringify(response.headers)}`); 95 | } else { 96 | reject(`no map found on server___${JSON.stringify(response.headers)}`); 97 | } 98 | return; 99 | } 100 | 101 | try { 102 | if (buffer[0x00] === 0x1f && buffer[0x01] === 0x8b) { 103 | // gzip-Daten 104 | zlib.gunzip(buffer, (err, decoded) => { 105 | if (err) { 106 | reject(err); 107 | } else { 108 | resolve(RRMapParser.PARSEDATA(decoded)); 109 | } 110 | }); 111 | } else { 112 | resolve(JSON.parse(buffer.toString('utf8'))); 113 | } 114 | } catch (e) { 115 | reject(e); 116 | } 117 | }) 118 | .catch(err => { 119 | reject(err); 120 | }); 121 | }); 122 | } 123 | 124 | getMapBase64(url) { 125 | return new Promise((resolve, reject) => { 126 | if (!mapCreator.CanvasMap) { 127 | this.adapter.log.warn( 128 | 'CANVAS package not installed....please install Canvas package manually or disable Map in config see also https://github.com/iobroker-community-adapters/ioBroker.mihome-vacuum#error-at-installation', 129 | ); 130 | this.config.mimap = false; 131 | this.config.valetudo = false; 132 | reject('CanvasMap not loaded'); 133 | } 134 | this.getRawMapData(url) 135 | .then(data => { 136 | try { 137 | //(self.adapter.log.debug(JSON.stringify(data)); 138 | const map = mapCreator.CanvasMap(data, this.config.COLOR_OPTIONS, this.adapter); 139 | //console.log('') 140 | resolve([map, data.image.segments.id, data.currently_cleaned_zones, data.goto_target]); 141 | } catch (e) { 142 | reject(e); 143 | } 144 | }) 145 | .catch(error => reject(error)); 146 | }); 147 | } 148 | 149 | login() { 150 | return this.cloudConnector.login(); 151 | } 152 | 153 | updateMap(mapurl, dontRetry) { 154 | return new Promise((resolve, reject) => { 155 | // if mimap is selected 156 | if (this.config.mimap === true) { 157 | this.adapter.log.debug('update_Map Mimap enabled'); 158 | if (dontRetry && this.cloudConnector.loggedIn()) { 159 | this.adapter.log.debug('dont retry'); 160 | return reject('dont repeat'); 161 | } 162 | const unixTime = Math.floor(Date.now() / 1000); 163 | if (!mapUrlCache[mapurl] || mapUrlCache[mapurl].expires < unixTime - 60) { 164 | this.adapter.log.debug('update_Map need new mapurl'); 165 | this.getMapURL(mapurl) 166 | .then(result => { 167 | mapUrlCache[mapurl] = { 168 | expires: result.result.expires_time, 169 | url: result.result.url, 170 | }; 171 | 172 | this.adapter.log.debug(`update_Map got new url:${mapUrlCache[mapurl].url}`); 173 | this.adapter.log.debug(`update_Map got new expires:${mapUrlCache[mapurl].expires}`); 174 | this.adapter.log.debug(`update_Map got new time:${unixTime}`); 175 | this.getMapBase64(mapUrlCache[mapurl].url) 176 | .then(mapData => resolve(mapData)) 177 | .catch(error => reject(error)); 178 | }) 179 | .catch(error => { 180 | //reject(error); 181 | this.adapter.log.warn(`map error:${error}`); 182 | if (!dontRetry) { 183 | this.login() 184 | .then(() => this.updateMap(mapurl, true)) 185 | .catch(error => reject(error)); 186 | } 187 | }); 188 | } else { 189 | this.adapter.log.debug('update_Map use old mapurl'); 190 | this.getMapBase64(mapUrlCache[mapurl].url) 191 | .then(mapData => resolve(mapData)) 192 | .catch(error => reject(error)); 193 | } 194 | } else if (this.config.valetudo === true) { 195 | this.getMapBase64() 196 | .then(mapData => resolve(mapData)) 197 | .catch(error => reject(error)); 198 | } 199 | }); 200 | } 201 | 202 | getMapURL(mapName) { 203 | return new Promise((resolve, reject) => 204 | this.login() 205 | .then(() => { 206 | let url; 207 | if (this.config.server === '-') { 208 | url = 'https://api.io.mi.com/app/home/getmapfileurl'; 209 | } else { 210 | url = `https://${this.config.server}.api.io.mi.com/app/home/getmapfileurl`; 211 | } 212 | const data = JSON.stringify({ 213 | obj_name: mapName, 214 | }); 215 | this.cloudConnector 216 | .executeEncryptedApiCall(url, { data }) 217 | .then(json => { 218 | try { 219 | if (json.message === 'ok') { 220 | resolve(json); 221 | } else { 222 | throw json.message; 223 | } 224 | } catch (err) { 225 | this.adapter.log.error(`Error when receiving map url: ${json}`); 226 | //this.cloudConnector.refreshToken(); 227 | reject(err); 228 | } 229 | }) 230 | .catch(error => { 231 | this.cloudConnector.refreshToken(); 232 | this.adapter.log.warn(`Get Error when receiving map url: ${error.message}`); 233 | reject(error); 234 | }); 235 | }) 236 | .catch(error => reject(error)), 237 | ); 238 | } 239 | } 240 | 241 | // just for testing 242 | //----------------------------------- 243 | const adapter_helper = { 244 | log: { 245 | info: function (msg) { 246 | console.log(`INFO: ${msg}`); 247 | }, 248 | error: function (msg) { 249 | console.log(`ERROR: ${msg}`); 250 | }, 251 | debug: function (msg) { 252 | console.log(`DEBUG: ${msg}`); 253 | }, 254 | warn: function (msg) { 255 | console.log(`WARN: ${msg}`); 256 | }, 257 | }, 258 | msg: { 259 | info: [], 260 | error: [], 261 | debug: [], 262 | warn: [], 263 | }, 264 | }; 265 | module.exports = MapHelper; 266 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * ioBroker gulpfile 3 | * Date: 2019-01-28 4 | */ 5 | 'use strict'; 6 | 7 | const gulp = require('gulp'); 8 | const fs = require('fs'); 9 | const pkg = require('./package.json'); 10 | const iopackage = require('./io-package.json'); 11 | const version = pkg && pkg.version ? pkg.version : iopackage.common.version; 12 | const fileName = 'words.js'; 13 | const EMPTY = ''; 14 | const translate = require('./lib/tools').translateText; 15 | const languages = { 16 | en: {}, 17 | de: {}, 18 | ru: {}, 19 | pt: {}, 20 | nl: {}, 21 | fr: {}, 22 | it: {}, 23 | es: {}, 24 | pl: {}, 25 | 'zh-cn': {}, 26 | }; 27 | 28 | function lang2data(lang) { 29 | let str = '{\n'; 30 | let count = 0; 31 | for (const w in lang) { 32 | count++; 33 | const key = ` "${w.replace(/"/g, '\\"')}": `; 34 | str += `${key}"${lang[w].replace(/"/g, '\\"')}",\n`; 35 | } 36 | if (!count) { 37 | return '{\n}'; 38 | } 39 | return `${str.substring(0, str.length - 2)}\n}`; 40 | } 41 | 42 | function readWordJs(src) { 43 | try { 44 | let words; 45 | if (fs.existsSync(`${src}js/${fileName}`)) { 46 | words = fs.readFileSync(`${src}js/${fileName}`).toString(); 47 | } else { 48 | words = fs.readFileSync(src + fileName).toString(); 49 | } 50 | words = words.substring(words.indexOf('{'), words.length); 51 | words = words.substring(0, words.lastIndexOf(';')); 52 | 53 | const resultFunc = new Function(`return ${words};`); 54 | 55 | return resultFunc(); 56 | } catch (e) { 57 | console.log(`readWordJs: ${e}`); 58 | return null; 59 | } 60 | } 61 | 62 | function padRight(text, totalLength) { 63 | return text + (text.length < totalLength ? new Array(totalLength - text.length).join(' ') : ''); 64 | } 65 | 66 | function writeWordJs(data, src) { 67 | let text = ''; 68 | text += '/*global systemDictionary:true */\n'; 69 | text += "'use strict';\n\n"; 70 | text += 'systemDictionary = {\n'; 71 | for (const word in data) { 72 | text += ` ${padRight(`"${word.replace(/"/g, '\\"')}": {`, 50)}`; 73 | let line = ''; 74 | for (const lang in data[word]) { 75 | line += `"${lang}": "${padRight(`${data[word][lang].replace(/"/g, '\\"')}",`, 50)} `; 76 | } 77 | if (line) { 78 | line = line.trim(); 79 | line = line.substring(0, line.length - 1); 80 | } 81 | text += `${line}},\n`; 82 | } 83 | text += '};'; 84 | if (fs.existsSync(`${src}js/${fileName}`)) { 85 | fs.writeFileSync(`${src}js/${fileName}`, text); 86 | } else { 87 | fs.writeFileSync(`${src}${fileName}`, text); 88 | } 89 | } 90 | 91 | function words2languages(src) { 92 | const langs = Object.assign({}, languages); 93 | const data = readWordJs(src); 94 | if (data) { 95 | for (const word in data) { 96 | for (const lang in data[word]) { 97 | langs[lang][word] = data[word][lang]; 98 | // pre-fill all other languages 99 | for (const j in langs) { 100 | langs[j][word] = langs[j][word] || EMPTY; 101 | } 102 | } 103 | } 104 | if (!fs.existsSync(`${src}i18n/`)) { 105 | fs.mkdirSync(`${src}i18n/`); 106 | } 107 | for (const l in langs) { 108 | const keys = Object.keys(langs[l]); 109 | keys.sort(); 110 | const obj = {}; 111 | for (let k = 0; k < keys.length; k++) { 112 | obj[keys[k]] = langs[l][keys[k]]; 113 | } 114 | if (!fs.existsSync(`${src}i18n/${l}`)) { 115 | fs.mkdirSync(`${src}i18n/${l}`); 116 | } 117 | 118 | fs.writeFileSync(`${src}i18n/${l}/translations.json`, lang2data(obj)); 119 | } 120 | } else { 121 | console.error(`Cannot read or parse ${fileName}`); 122 | } 123 | } 124 | 125 | function languages2words(src) { 126 | const dirs = fs.readdirSync(`${src}i18n/`); 127 | const langs = {}; 128 | const bigOne = {}; 129 | const order = Object.keys(languages); 130 | dirs.sort(function (a, b) { 131 | const posA = order.indexOf(a); 132 | const posB = order.indexOf(b); 133 | if (posA === -1 && posB === -1) { 134 | if (a > b) { 135 | return 1; 136 | } 137 | if (a < b) { 138 | return -1; 139 | } 140 | return 0; 141 | } else if (posA === -1) { 142 | return -1; 143 | } else if (posB === -1) { 144 | return 1; 145 | } 146 | if (posA > posB) { 147 | return 1; 148 | } 149 | if (posA < posB) { 150 | return -1; 151 | } 152 | return 0; 153 | }); 154 | for (const lang of dirs) { 155 | if (lang === 'flat.txt') { 156 | continue; 157 | } 158 | langs[lang] = fs.readFileSync(`${src}i18n/${lang}/translations.json`).toString(); 159 | langs[lang] = JSON.parse(langs[lang]); 160 | const words = langs[lang]; 161 | for (const word in words) { 162 | bigOne[word] = bigOne[word] || {}; 163 | if (words[word] !== EMPTY) { 164 | bigOne[word][lang] = words[word]; 165 | } 166 | } 167 | } 168 | // read actual words.js 169 | const aWords = readWordJs(src); 170 | 171 | const temporaryIgnore = ['flat.txt']; 172 | if (aWords) { 173 | // Merge words together 174 | for (const w in aWords) { 175 | if (!bigOne[w]) { 176 | console.warn(`Take from actual words.js: ${w}`); 177 | bigOne[w] = aWords[w]; 178 | } 179 | dirs.forEach(function (lang) { 180 | if (temporaryIgnore.indexOf(lang) !== -1) { 181 | return; 182 | } 183 | if (!bigOne[w][lang]) { 184 | console.warn(`Missing "${lang}": ${w}`); 185 | } 186 | }); 187 | } 188 | } 189 | 190 | writeWordJs(bigOne, src); 191 | } 192 | 193 | async function translateNotExisting(obj, baseText, yandex) { 194 | let t = obj['en']; 195 | if (!t) { 196 | t = baseText; 197 | } 198 | 199 | if (t) { 200 | for (const l in languages) { 201 | if (!obj[l]) { 202 | const time = new Date().getTime(); 203 | obj[l] = await translate(t, l, yandex); 204 | console.log(`en -> ${l} ${new Date().getTime() - time} ms`); 205 | } 206 | } 207 | } 208 | } 209 | 210 | //TASKS 211 | 212 | gulp.task('adminWords2languages', function (done) { 213 | words2languages('./admin/'); 214 | done(); 215 | }); 216 | 217 | gulp.task('adminLanguages2words', function (done) { 218 | languages2words('./admin/'); 219 | done(); 220 | }); 221 | 222 | gulp.task('updatePackages', function (done) { 223 | iopackage.common.version = pkg.version; 224 | iopackage.common.news = iopackage.common.news || {}; 225 | if (!iopackage.common.news[pkg.version]) { 226 | const news = iopackage.common.news; 227 | const newNews = {}; 228 | 229 | newNews[pkg.version] = { 230 | en: 'news', 231 | de: 'neues', 232 | ru: 'новое', 233 | pt: 'novidades', 234 | nl: 'nieuws', 235 | fr: 'nouvelles', 236 | it: 'notizie', 237 | es: 'noticias', 238 | pl: 'nowości', 239 | 'zh-cn': '新', 240 | }; 241 | iopackage.common.news = Object.assign(newNews, news); 242 | } 243 | fs.writeFileSync('io-package.json', JSON.stringify(iopackage, null, 4)); 244 | done(); 245 | }); 246 | 247 | gulp.task('updateReadme', function (done) { 248 | const readme = fs.readFileSync('README.md').toString(); 249 | const pos = readme.indexOf('## Changelog\n'); 250 | if (pos !== -1) { 251 | const readmeStart = readme.substring(0, pos + '## Changelog\n'.length); 252 | const readmeEnd = readme.substring(pos + '## Changelog\n'.length); 253 | 254 | if (readme.indexOf(version) === -1) { 255 | const timestamp = new Date(); 256 | const date = `${timestamp.getFullYear()}-${`0${(timestamp.getMonth() + 1).toString(10)}`.slice(-2)}-${`0${timestamp.getDate().toString(10)}`.slice( 257 | -2, 258 | )}`; 259 | 260 | let news = ''; 261 | if (iopackage.common.news && iopackage.common.news[pkg.version]) { 262 | news += `* ${iopackage.common.news[pkg.version].en}`; 263 | } 264 | 265 | fs.writeFileSync( 266 | 'README.md', 267 | `${readmeStart}### ${version} (${date})\n${news ? `${news}\n\n` : '\n'}${readmeEnd}`, 268 | ); 269 | } 270 | } 271 | done(); 272 | }); 273 | 274 | gulp.task('translate', async function () { 275 | let yandex; 276 | const i = process.argv.indexOf('--yandex'); 277 | if (i > -1) { 278 | yandex = process.argv[i + 1]; 279 | } 280 | 281 | if (iopackage && iopackage.common) { 282 | if (iopackage.common.news) { 283 | console.log('Translate News'); 284 | for (const k in iopackage.common.news) { 285 | console.log(`News: ${k}`); 286 | const nw = iopackage.common.news[k]; 287 | await translateNotExisting(nw, null, yandex); 288 | } 289 | } 290 | if (iopackage.common.titleLang) { 291 | console.log('Translate Title'); 292 | await translateNotExisting(iopackage.common.titleLang, iopackage.common.title, yandex); 293 | } 294 | if (iopackage.common.desc) { 295 | console.log('Translate Description'); 296 | await translateNotExisting(iopackage.common.desc, null, yandex); 297 | } 298 | 299 | if (iopackage.messages) { 300 | console.log('Translate Messages'); 301 | for (const msg of iopackage.messages) { 302 | if (msg.title) { 303 | await translateNotExisting(msg.title, null, yandex); 304 | } 305 | if (msg.text) { 306 | console.log(msg.text.en); 307 | await translateNotExisting(msg.text, null, yandex); 308 | } 309 | } 310 | } 311 | 312 | if (fs.existsSync('./admin/i18n/en/translations.json')) { 313 | const enTranslations = require('./admin/i18n/en/translations.json'); 314 | for (const l in languages) { 315 | console.log(`Translate Text: ${l}`); 316 | let existing = {}; 317 | if (fs.existsSync(`./admin/i18n/${l}/translations.json`)) { 318 | existing = require(`./admin/i18n/${l}/translations.json`); 319 | } 320 | for (const t in enTranslations) { 321 | if (!existing[t]) { 322 | existing[t] = await translate(enTranslations[t], l, yandex); 323 | } 324 | } 325 | if (!fs.existsSync(`./admin/i18n/${l}/`)) { 326 | fs.mkdirSync(`./admin/i18n/${l}/`); 327 | } 328 | fs.writeFileSync(`./admin/i18n/${l}/translations.json`, JSON.stringify(existing, null, 4)); 329 | } 330 | } 331 | } 332 | fs.writeFileSync('io-package.json', JSON.stringify(iopackage, null, 4)); 333 | }); 334 | 335 | gulp.task('translateAndUpdateWordsJS', gulp.series('translate', 'adminLanguages2words', 'adminWords2languages')); 336 | 337 | gulp.task('default', gulp.series('updatePackages', 'updateReadme')); 338 | -------------------------------------------------------------------------------- /lib/viomi.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | //const Miio = require("iobroker.mihome-vacuum/lib/miio"); 4 | 5 | let adapter = null; 6 | const objects = require('./objects'); 7 | const lastProps = {}; 8 | 9 | class ViomiManager { 10 | constructor(adapterInstance, Miio) { 11 | this.Miio = Miio; 12 | adapter = adapterInstance; 13 | adapter.log.debug('select viomi protocol....'); 14 | 15 | this.globalTimeouts = {}; 16 | 17 | this.ViomiDevices = [ 18 | 'dreame.vacuum.mc1808', 19 | 'viomi.vacuum.v6', 20 | 'viomi.vacuum.v7', 21 | 'viomi.vacuum.v8', 22 | 'viomi.vacuum.v19', 23 | ]; 24 | 25 | // result is for "get_prop" with PARAMS is: 26 | // [5,3,0,2105,100,0,"0",0,0,10,"0",0,1,1,12,1,1,0,0,0,0,1] 27 | this.PARAMS = [ 28 | 'run_state', 29 | 'suction_grade', 30 | 'mode', 31 | 'err_state', 32 | 'battary_life', 33 | 'start_time', 34 | 'order_time', 35 | 's_time', 36 | 's_area', 37 | 'v_state', 38 | 'zone_data', 39 | 'repeat_state', 40 | 'remember_map', 41 | 'has_map', 42 | 'water_grade', 43 | 'box_type', 44 | 'mop _type', 45 | 'is_mop', 46 | 'light_state', 47 | 'has_newmap', 48 | 'is_charge', 49 | 'is_work', 50 | ]; 51 | 52 | this.ERROR_CODES = { 53 | 500: 'Radar timed out', 54 | 501: 'Wheels stuck', 55 | 502: 'Low battery', 56 | 503: 'Dust bin missing', 57 | 508: 'Uneven ground', 58 | 509: 'Cliff sensor error', 59 | 510: 'Collision sensor error', 60 | 511: 'Could not return to dock', 61 | 512: 'Could not return to dock', 62 | 513: 'Could not navigate', 63 | 514: 'Vacuum stuck', 64 | 515: 'Charging error', 65 | 516: 'Mop temperature error', 66 | 521: 'Water tank is not installed', 67 | 522: 'Mop is not installed', 68 | 525: 'Insufficient water in water tank', 69 | 527: 'Remove mop', 70 | 528: 'Dust bin missing', 71 | 529: 'Mop and water tank missing', 72 | 530: 'Mop and water tank missing', 73 | 531: 'Water tank is not installed', 74 | 2101: 'Unsufficient battery, continuing cleaning after recharge', 75 | 2105: 'No Error', 76 | }; 77 | 78 | this.STATES = { 79 | '-1': 'Unknown', 80 | 0: 'IdleNotDocked ', 81 | 1: 'Idle', 82 | 2: 'Idle 2', 83 | 3: 'Cleaning', 84 | 4: 'Returning ', 85 | 5: 'Docked', 86 | 6: 'VacuumingAndMopping', 87 | }; 88 | 89 | this.FANSPEED = { 90 | 0: 'Silent', 91 | 1: 'Standard', 92 | 2: 'Medium', 93 | 3: 'Turbo', 94 | }; 95 | 96 | this.MODE = { 97 | 0: 'Vacuum', 98 | 1: 'VacuumAndMop', 99 | 2: 'Mop', 100 | }; 101 | 102 | this.main(); 103 | } 104 | async main() { 105 | await this.initStates(); 106 | 107 | this.getStates(); 108 | } 109 | async getStates() { 110 | clearTimeout(this.globalTimeouts['getStates']); 111 | let DeviceData; 112 | 113 | adapter.log.debug('get params for Viomi'); 114 | try { 115 | DeviceData = await this.Miio.sendMessage('get_prop', this.PARAMS); 116 | adapter.log.debug(`Recievded params for viomi: ${JSON.stringify(DeviceData)}`); 117 | } catch (error) { 118 | DeviceData = null; 119 | adapter.log.debug(error); 120 | } 121 | 122 | if (DeviceData && DeviceData.result) { 123 | const answer = DeviceData.result; 124 | answer.forEach((element, index) => { 125 | const objExist = objects.viomiObjects.find(element => element._id === this.PARAMS[index]); 126 | 127 | lastProps[this.PARAMS[index]] = element; 128 | 129 | if (typeof objExist !== 'undefined') { 130 | if (objExist.common.type === 'boolean') { 131 | adapter.setStateAsync(`control.${this.PARAMS[index]}`, { 132 | val: !!element, 133 | ack: true, 134 | }); 135 | } else { 136 | adapter.setStateAsync(`control.${this.PARAMS[index]}`, { 137 | val: element, 138 | ack: true, 139 | }); 140 | } 141 | } 142 | }); 143 | } 144 | this.globalTimeouts['getStates'] = setTimeout(this.getStates.bind(this), adapter.config.pingInterval); 145 | } 146 | 147 | /** Parses the answer of get_room_mapping */ 148 | async initStates() { 149 | objects.viomiObjects.map(o => adapter.setObjectNotExistsAsync(`control${o._id ? `.${o._id}` : ''}`, o)); 150 | } 151 | 152 | async stateChange(id, state) { 153 | if (!state || state.ack) { 154 | return; 155 | } 156 | const terms = id.split('.'); 157 | const command = terms.pop(); 158 | let data; 159 | let actionMode, method, params; 160 | 161 | try { 162 | switch (command) { 163 | case 'suction_grade': 164 | data = await this.Miio.sendMessage('set_suction', [state.val]); 165 | 166 | adapter.log.debug('change suction_grade'); 167 | if (data) { 168 | adapter.setStateAsync(id, { 169 | val: state.val, 170 | ack: true, 171 | }); 172 | } 173 | break; 174 | case 'water_grade': 175 | data = await this.Miio.sendMessage('set_suction', [state.val]); 176 | 177 | adapter.log.debug('change water_grade'); 178 | if (data) { 179 | adapter.setStateAsync(id, { 180 | val: state.val, 181 | ack: true, 182 | }); 183 | } 184 | break; 185 | case 'is_mop': 186 | data = await this.Miio.sendMessage('set_mop', [state.val]); 187 | 188 | adapter.log.debug('change mop'); 189 | if (data) { 190 | adapter.setStateAsync(id, { 191 | val: state.val, 192 | ack: true, 193 | }); 194 | } 195 | break; 196 | case 'light_state': 197 | data = await this.Miio.sendMessage('set_light', [state.val ? 1 : 0]); 198 | 199 | adapter.log.debug('change light_state'); 200 | if (data) { 201 | adapter.setStateAsync(id, { 202 | val: state.val, 203 | ack: true, 204 | }); 205 | } 206 | break; 207 | case 'start': 208 | if (lastProps.mode === 4) { 209 | //i dont know now 210 | return; 211 | } 212 | if (lastProps.mode === 2) { 213 | actionMode = 2; 214 | } else { 215 | if (lastProps.is_mop === 2) { 216 | actionMode = 3; 217 | } else { 218 | actionMode = lastProps.is_mop; 219 | } 220 | } 221 | if (lastProps.mode === 3) { 222 | method = 'set_mode'; 223 | params = [3, 1]; 224 | } else { 225 | method = 'set_mode_withroom'; 226 | params = [actionMode, 1, 0]; 227 | } 228 | 229 | data = await this.Miio.sendMessage(method, params); 230 | 231 | adapter.log.debug(`start with: ${method} params:${params}`); 232 | 233 | if (data) { 234 | adapter.setStateAsync(id, { 235 | val: state.val, 236 | ack: true, 237 | }); 238 | } 239 | break; 240 | case 'pause': 241 | if (lastProps.mode === 4) { 242 | //i dont know now 243 | return; 244 | } 245 | if (lastProps.mode === 2) { 246 | actionMode = 2; 247 | } else { 248 | if (lastProps.is_mop === 2) { 249 | actionMode = 3; 250 | } else { 251 | actionMode = lastProps.is_mop; 252 | } 253 | } 254 | if (lastProps.mode === 3) { 255 | method = 'set_mode'; 256 | params = [3, 3]; 257 | } else { 258 | method = 'set_mode_withroom'; 259 | params = [actionMode, 3, 0]; 260 | } 261 | 262 | data = await this.Miio.sendMessage(method, params); 263 | 264 | adapter.log.debug(`pause with: ${method} params:${params}`); 265 | 266 | if (data) { 267 | adapter.setStateAsync(id, { 268 | val: state.val, 269 | ack: true, 270 | }); 271 | } 272 | break; 273 | case 'stop': 274 | if (lastProps.mode === 3) { 275 | method = 'set_mode'; 276 | params = [3, 0]; 277 | } else if (lastProps.is_mop === 4) { 278 | method = 'set_pointclean'; 279 | params = [0, 0, 0]; 280 | } else { 281 | method = 'set_mode'; 282 | params = [0]; 283 | } 284 | data = await this.Miio.sendMessage(method, params); 285 | 286 | adapter.log.debug(`stop with: ${method} params:${params}`); 287 | 288 | if (data) { 289 | adapter.setStateAsync(id, { 290 | val: state.val, 291 | ack: true, 292 | }); 293 | } 294 | break; 295 | case 'return_dock': 296 | data = await this.Miio.sendMessage('set_charge', [1]); 297 | 298 | adapter.log.debug('change mop'); 299 | if (data) { 300 | adapter.setStateAsync(id, { 301 | val: state.val, 302 | ack: true, 303 | }); 304 | } 305 | break; 306 | default: 307 | break; 308 | } 309 | } catch (error) { 310 | adapter.log.warn(`Cant send command please try again${command} - ${error}`); 311 | } 312 | } 313 | 314 | startClean() { 315 | //return 'set_mode_withroom', [0, 1, 0]; 316 | } 317 | 318 | async close() { 319 | Object.keys(this.globalTimeouts).forEach( 320 | id => this.globalTimeouts[id] && clearTimeout(this.globalTimeouts[id]), 321 | ); 322 | this.globalTimeouts = {}; 323 | } 324 | } 325 | module.exports = ViomiManager; 326 | -------------------------------------------------------------------------------- /lib/RRMapParser.js: -------------------------------------------------------------------------------- 1 | //const { off } = require('process'); 2 | const Tools = { 3 | DIMENSION_PIXELS: 1024, 4 | DIMENSION_MM: 50 * 1024, 5 | }; 6 | 7 | const RRMapParser = function () {}; 8 | 9 | RRMapParser.TYPES = { 10 | CHARGER_LOCATION: 1, 11 | IMAGE: 2, 12 | PATH: 3, 13 | GOTO_PATH: 4, 14 | GOTO_PREDICTED_PATH: 5, 15 | CURRENTLY_CLEANED_ZONES: 6, 16 | GOTO_TARGET: 7, 17 | ROBOT_POSITION: 8, 18 | FORBIDDEN_ZONES: 9, 19 | VIRTUAL_WALLS: 10, 20 | CURRENTLY_CLEANED_BLOCKS: 11, 21 | NO_MOP_ZONE: 12, 22 | OBSTACLES: 13, 23 | IGNORED_OBSTACLES: 14, 24 | OBSTACLES3: 15, 25 | IGNORED_OBSTACLES2: 16, 26 | CARPET_MAP: 17, 27 | MOP_PATH: 18, 28 | CARPET_FORBIDDEN: 19, 29 | SMART_ZONE_PATH_TYPE: 20, 30 | SMART_ZONE: 21, 31 | CUSTOM_CARPET: 22, 32 | CL_FORBIDDEN_ZONES: 23, 33 | FLOOR_MAP: 24, 34 | FURNITURES: 25, 35 | DOCK_TYPE: 26, 36 | ENEMIES: 27, 37 | DIGEST: 1024, 38 | }; 39 | 40 | RRMapParser.PARSEBLOCK = function parseBlock(buf, offset, result) { 41 | result = result || {}; 42 | if (buf.length <= offset) { 43 | return result; 44 | } 45 | let g3offset = 0; 46 | 47 | const type = buf.readUInt16LE(0x00 + offset); 48 | const hlength = buf.readUInt16LE(0x02 + offset); 49 | const length = buf.readUInt32LE(0x04 + offset); 50 | //console.log('type' + type + 'länge: ' + length); 51 | switch (type) { 52 | case RRMapParser.TYPES.ROBOT_POSITION: 53 | case RRMapParser.TYPES.CHARGER_LOCATION: 54 | result[type] = { 55 | position: [buf.readUInt16LE(0x08 + offset), buf.readUInt16LE(0x0c + offset)], 56 | angle: length >= 12 ? buf.readInt32LE(0x10 + offset) : 0, // gen3+ 57 | }; 58 | break; 59 | 60 | case RRMapParser.TYPES.IMAGE: { 61 | if (hlength > 24) { 62 | // gen3+ 63 | g3offset = 4; 64 | } 65 | const parameters = { 66 | segments: { 67 | count: g3offset ? buf.readInt32LE(0x08 + offset) : 0, 68 | id: [], 69 | }, 70 | position: { 71 | top: buf.readInt32LE(0x08 + g3offset + offset), 72 | left: buf.readInt32LE(0x0c + g3offset + offset), 73 | }, 74 | dimensions: { 75 | height: buf.readInt32LE(0x10 + g3offset + offset), 76 | width: buf.readInt32LE(0x14 + g3offset + offset), 77 | }, 78 | pixels: [], 79 | }; 80 | 81 | parameters.position.top = Tools.DIMENSION_PIXELS - parameters.position.top - parameters.dimensions.height; 82 | 83 | if (parameters.dimensions.height > 0 && parameters.dimensions.width > 0) { 84 | parameters.pixels = { 85 | floor: [], 86 | obstacle: [], 87 | segments: [], 88 | }; 89 | for (let s, i = 0; i < length; i++) { 90 | switch (buf.readUInt8(0x18 + g3offset + offset + i) & 0x07) { 91 | case 0: 92 | break; 93 | 94 | case 1: 95 | parameters.pixels.obstacle.push(i); 96 | break; 97 | 98 | default: 99 | parameters.pixels.floor.push(i); 100 | s = (buf.readUInt8(0x18 + g3offset + offset + i) & 248) >> 3; 101 | if (s !== 0) { 102 | if (!parameters.segments.id.includes(s)) { 103 | parameters.segments.id.push(s); 104 | } 105 | parameters.pixels.segments.push(i | (s << 21)); 106 | } 107 | break; 108 | } 109 | } 110 | } 111 | result[type] = parameters; 112 | break; 113 | } 114 | case RRMapParser.TYPES.CARPET_MAP: { 115 | if (hlength > 24) { 116 | // gen3+ 117 | g3offset = 4; 118 | } 119 | const carpet = []; 120 | 121 | for (let i = 0; i < length; i++) { 122 | switch (buf.readUInt8(0x18 + offset + i) & 0x07) { 123 | case 0: 124 | break; 125 | 126 | case 1: 127 | carpet.push(i); 128 | break; 129 | 130 | default: 131 | break; 132 | } 133 | } 134 | result[type] = carpet; 135 | break; 136 | } 137 | case RRMapParser.TYPES.MOP_PATH: { 138 | const mopactive = []; 139 | for (let i = 0; i < length; i++) { 140 | mopactive.push(buf.readUInt8(0x14 + offset + i)); 141 | } 142 | result[type] = mopactive; 143 | 144 | break; 145 | } 146 | case RRMapParser.TYPES.PATH: 147 | case RRMapParser.TYPES.GOTO_PATH: 148 | case RRMapParser.TYPES.GOTO_PREDICTED_PATH: { 149 | const points = []; 150 | for (let i = 0; i < length; i = i + 4) { 151 | //to draw these coordinates onto the map pixels, they have to be divided by 50 152 | points.push([buf.readUInt16LE(0x14 + offset + i), buf.readUInt16LE(0x14 + offset + i + 2)]); 153 | } 154 | result[type] = { 155 | //point_count: buf.readUInt32LE(0x08 + offset), 156 | //point_size: buf.readUInt32LE(0x0c + offset), 157 | current_angle: buf.readUInt32LE(0x10 + offset), //This is always 0. Roborock didn't bother 158 | points: points, 159 | }; 160 | break; 161 | } 162 | case RRMapParser.TYPES.GOTO_TARGET: 163 | result[type] = { 164 | position: [buf.readUInt16LE(0x08 + offset), buf.readUInt16LE(0x0a + offset)], 165 | }; 166 | break; 167 | 168 | case RRMapParser.TYPES.CURRENTLY_CLEANED_ZONES: { 169 | const zoneCount = buf.readUInt32LE(0x08 + offset); 170 | const zones = []; 171 | if (zoneCount > 0) { 172 | for (let i = 0; i < length; i = i + 8) { 173 | zones.push([ 174 | buf.readUInt16LE(0x0c + offset + i), 175 | buf.readUInt16LE(0x0c + offset + i + 2), 176 | buf.readUInt16LE(0x0c + offset + i + 4), 177 | buf.readUInt16LE(0x0c + offset + i + 6), 178 | ]); 179 | } 180 | 181 | result[type] = zones; 182 | } 183 | break; 184 | } 185 | case RRMapParser.TYPES.FORBIDDEN_ZONES: { 186 | const forbiddenZoneCount = buf.readUInt32LE(0x08 + offset); 187 | const forbiddenZones = []; 188 | if (forbiddenZoneCount > 0) { 189 | for (let i = 0; i < length; i = i + 16) { 190 | forbiddenZones.push([ 191 | buf.readUInt16LE(0x0c + offset + i), 192 | buf.readUInt16LE(0x0c + offset + i + 2), 193 | buf.readUInt16LE(0x0c + offset + i + 4), 194 | buf.readUInt16LE(0x0c + offset + i + 6), 195 | buf.readUInt16LE(0x0c + offset + i + 8), 196 | buf.readUInt16LE(0x0c + offset + i + 10), 197 | buf.readUInt16LE(0x0c + offset + i + 12), 198 | buf.readUInt16LE(0x0c + offset + i + 14), 199 | ]); 200 | } 201 | 202 | result[type] = forbiddenZones; 203 | } 204 | break; 205 | } 206 | case RRMapParser.TYPES.VIRTUAL_WALLS: { 207 | const wallCount = buf.readUInt32LE(0x08 + offset); 208 | const walls = []; 209 | if (wallCount > 0) { 210 | for (let i = 0; i < length; i = i + 8) { 211 | walls.push([ 212 | buf.readUInt16LE(0x0c + offset + i), 213 | buf.readUInt16LE(0x0c + offset + i + 2), 214 | buf.readUInt16LE(0x0c + offset + i + 4), 215 | buf.readUInt16LE(0x0c + offset + i + 6), 216 | ]); 217 | } 218 | 219 | result[type] = walls; 220 | } 221 | break; 222 | } 223 | case RRMapParser.TYPES.CURRENTLY_CLEANED_BLOCKS: { 224 | const blockCount = buf.readUInt32LE(0x08 + offset); 225 | const blocks = []; 226 | if (blockCount > 0) { 227 | for (let i = 0; i < length; i++) { 228 | blocks.push(buf.readUInt8(0x0c + offset + i)); 229 | } 230 | result[type] = blocks; 231 | } 232 | break; 233 | } 234 | } 235 | return parseBlock(buf, offset + length + hlength, result); 236 | }; 237 | 238 | /** 239 | * 240 | * @param mapBuf {Buffer} Should contain map in RRMap Format 241 | * @returns parsedMapData 242 | */ 243 | RRMapParser.PARSE = function parse(mapBuf) { 244 | if (mapBuf && mapBuf[0x00] === 0x72 && mapBuf[0x01] === 0x72) { 245 | // rr 246 | const parsedMapData = { 247 | header_length: mapBuf.readUInt16LE(0x02), 248 | data_length: mapBuf.readUInt16LE(0x04), 249 | version: { 250 | major: mapBuf.readUInt16LE(0x08), 251 | minor: mapBuf.readUInt16LE(0x0a), 252 | }, 253 | map_index: mapBuf.readUInt16LE(0x0c), 254 | map_sequence: mapBuf.readUInt16LE(0x10), 255 | }; 256 | return parsedMapData; 257 | } 258 | return {}; 259 | }; 260 | 261 | RRMapParser.PARSEDATA = function parseData(mapBuf) { 262 | if (!this.PARSE(mapBuf).map_index) { 263 | return null; 264 | } 265 | const blocks = RRMapParser.PARSEBLOCK(mapBuf, 0x14); 266 | const parsedMapData = {}; 267 | if (blocks[RRMapParser.TYPES.IMAGE]) { 268 | //We need the image to flip everything else correctly 269 | parsedMapData.image = blocks[RRMapParser.TYPES.IMAGE]; 270 | parsedMapData.image.pixels.carpet = blocks[RRMapParser.TYPES.CARPET_MAP]; 271 | [ 272 | { 273 | type: RRMapParser.TYPES.PATH, 274 | path: 'path', 275 | }, 276 | { 277 | type: RRMapParser.TYPES.GOTO_PREDICTED_PATH, 278 | path: 'goto_predicted_path', 279 | }, 280 | { 281 | type: RRMapParser.TYPES.MOP_PATH, 282 | path: 'mop_path', 283 | }, 284 | ].forEach(item => { 285 | if (blocks[item.type]) { 286 | parsedMapData[item.path] = blocks[item.type]; 287 | if (parsedMapData[item.path].points) { 288 | parsedMapData[item.path].points = parsedMapData[item.path].points.map(point => { 289 | point[1] = Tools.DIMENSION_MM - point[1]; 290 | return point; 291 | }); 292 | } else { 293 | parsedMapData[item.path].points = []; 294 | } 295 | 296 | if (parsedMapData[item.path].points.length >= 2) { 297 | parsedMapData[item.path].current_angle = 298 | (Math.atan2( 299 | parsedMapData[item.path].points[parsedMapData[item.path].points.length - 1][1] - 300 | parsedMapData[item.path].points[parsedMapData[item.path].points.length - 2][1], 301 | 302 | parsedMapData[item.path].points[parsedMapData[item.path].points.length - 1][0] - 303 | parsedMapData[item.path].points[parsedMapData[item.path].points.length - 2][0], 304 | ) * 305 | 180) / 306 | Math.PI; 307 | } 308 | } 309 | }); 310 | if (blocks[RRMapParser.TYPES.CHARGER_LOCATION]) { 311 | parsedMapData.charger = blocks[RRMapParser.TYPES.CHARGER_LOCATION].position; 312 | parsedMapData.charger[1] = Tools.DIMENSION_MM - parsedMapData.charger[1]; 313 | } 314 | if (blocks[RRMapParser.TYPES.ROBOT_POSITION]) { 315 | parsedMapData.robot = blocks[RRMapParser.TYPES.ROBOT_POSITION].position; 316 | parsedMapData.robot[1] = Tools.DIMENSION_MM - parsedMapData.robot[1]; 317 | } 318 | if (blocks[RRMapParser.TYPES.GOTO_TARGET]) { 319 | parsedMapData.goto_target = blocks[RRMapParser.TYPES.GOTO_TARGET].position; 320 | parsedMapData.goto_target[1] = Tools.DIMENSION_MM - parsedMapData.goto_target[1]; 321 | } 322 | if (blocks[RRMapParser.TYPES.CURRENTLY_CLEANED_ZONES]) { 323 | parsedMapData.currently_cleaned_zones = blocks[RRMapParser.TYPES.CURRENTLY_CLEANED_ZONES]; 324 | if (parsedMapData.currently_cleaned_zones) { 325 | parsedMapData.currently_cleaned_zones = parsedMapData.currently_cleaned_zones.map(zone => { 326 | zone[1] = Tools.DIMENSION_MM - zone[1]; 327 | zone[3] = Tools.DIMENSION_MM - zone[3]; 328 | 329 | return zone; 330 | }); 331 | } else { 332 | parsedMapData.currently_cleaned_zones = []; 333 | } 334 | } 335 | if (blocks[RRMapParser.TYPES.FORBIDDEN_ZONES]) { 336 | parsedMapData.forbidden_zones = blocks[RRMapParser.TYPES.FORBIDDEN_ZONES]; 337 | if (parsedMapData.forbidden_zones) { 338 | parsedMapData.forbidden_zones = parsedMapData.forbidden_zones.map(zone => { 339 | zone[1] = Tools.DIMENSION_MM - zone[1]; 340 | zone[3] = Tools.DIMENSION_MM - zone[3]; 341 | zone[5] = Tools.DIMENSION_MM - zone[5]; 342 | zone[7] = Tools.DIMENSION_MM - zone[7]; 343 | 344 | return zone; 345 | }); 346 | } else { 347 | parsedMapData.forbidden_zones = []; 348 | } 349 | } 350 | if (blocks[RRMapParser.TYPES.VIRTUAL_WALLS]) { 351 | parsedMapData.virtual_walls = blocks[RRMapParser.TYPES.VIRTUAL_WALLS]; 352 | if (parsedMapData.virtual_walls) { 353 | parsedMapData.virtual_walls = parsedMapData.virtual_walls.map(wall => { 354 | wall[1] = Tools.DIMENSION_MM - wall[1]; 355 | wall[3] = Tools.DIMENSION_MM - wall[3]; 356 | 357 | return wall; 358 | }); 359 | } else { 360 | parsedMapData.virtual_walls = []; 361 | } 362 | } 363 | if (blocks[RRMapParser.TYPES.CURRENTLY_CLEANED_BLOCKS]) { 364 | parsedMapData.currently_cleaned_blocks = blocks[RRMapParser.TYPES.CURRENTLY_CLEANED_BLOCKS]; 365 | } 366 | } 367 | return parsedMapData; 368 | }; 369 | 370 | module.exports = RRMapParser; 371 | -------------------------------------------------------------------------------- /lib/roomManager.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | let adapter = null; 3 | let roomManager = null; 4 | let i18n = null; 5 | 6 | class RoomManager { 7 | constructor(adapterInstance, i18nInstance) { 8 | adapter = adapterInstance; 9 | i18n = i18nInstance; 10 | // eslint 11 | roomManager = this; 12 | this.stateRoomClean = { 13 | type: 'state', 14 | common: { 15 | name: i18n.cleanRoom, 16 | type: 'boolean', 17 | role: 'button', 18 | read: false, 19 | write: true, 20 | def: false, 21 | desc: 'Start Room Cleaning', 22 | smartName: i18n.cleanRooms, 23 | }, 24 | native: {}, 25 | }; 26 | this.stateRoomStatus = { 27 | type: 'state', 28 | common: { 29 | name: 'info', 30 | type: 'string', 31 | role: 'info', 32 | read: true, 33 | write: false, 34 | def: '', 35 | desc: 'Status of Cleaning', 36 | }, 37 | native: {}, 38 | }; 39 | this.stateRoomRepeat = { 40 | type: 'state', 41 | common: { 42 | name: 'repeat', 43 | type: 'number', 44 | role: 'level.repeat', 45 | read: true, 46 | write: true, 47 | min: 1, 48 | max: 10, 49 | step: 1, 50 | def: 1, 51 | desc: 'number of iterations', 52 | }, 53 | native: {}, 54 | }; 55 | adapter.setObject('rooms.loadRooms', { 56 | type: 'state', 57 | common: { 58 | name: i18n.loadRooms, 59 | type: 'boolean', 60 | role: 'button', 61 | read: false, 62 | write: true, 63 | def: false, 64 | desc: "loads id's from stored rooms", 65 | }, 66 | native: {}, 67 | }); 68 | adapter.setObject('rooms.multiRoomClean', { 69 | type: 'state', 70 | common: { 71 | name: i18n.cleanMultiRooms, 72 | type: 'boolean', 73 | role: 'button', 74 | read: false, 75 | write: true, 76 | def: false, 77 | desc: 'clean all rooms, which are connected to this datapoint', 78 | }, 79 | native: {}, 80 | }); 81 | adapter.setObject( 82 | 'rooms.addRoom', 83 | { 84 | type: 'state', 85 | common: { 86 | name: i18n.addRoom, 87 | type: 'string', 88 | role: 'value', 89 | read: true, 90 | write: true, 91 | desc: 'add roos manual with map Index or zone coordinates', 92 | }, 93 | native: {}, 94 | }, 95 | (err, obj) => obj && adapter.setForeignState(obj.id, i18n.addRoom, true), 96 | ); 97 | 98 | adapter.getStates(`${adapter.namespace}.rooms.*.mapIndex`, (err, states) => { 99 | if (states) { 100 | for (const stateId in states) { 101 | this.updateRoomStates(stateId.replace('.mapIndex', '')); 102 | } 103 | } 104 | }); 105 | } 106 | 107 | /** 108 | * Parses the answer of get_room_mapping "result":[[16,"881001046149"],[17,"881001046154"],[18,"881001046142"],[19,"881001046148"] 109 | * 110 | * @param response answer 111 | */ 112 | processRoomMaping(response) { 113 | const rooms = {}; 114 | let room; 115 | if (typeof response.result !== 'object') { 116 | return false; 117 | } 118 | 119 | for (const r in response.result) { 120 | room = response.result[r]; 121 | if (room[1]) { 122 | rooms[room[1]] = room[0]; 123 | } else { 124 | adapter.log.warn(`empty roomid for segment ${room[0]}`); 125 | } 126 | } 127 | adapter.getChannelsOf('rooms', function (err, roomObjs) { 128 | if (roomObjs) { 129 | for (const r in roomObjs) { 130 | const roomObj = roomObjs[r]; 131 | const extRoomId = roomObj._id.split('.').pop(); 132 | if (extRoomId.indexOf('manual_') === -1) { 133 | room = rooms[extRoomId]; 134 | if (!room) { 135 | adapter.setStateChanged( 136 | `${roomObj._id}.mapIndex`, 137 | i18n.notAvailable, 138 | true, 139 | (err, id, notChanged) => { 140 | if (!notChanged) { 141 | adapter.log.info(`room: ${extRoomId} not mapped`); 142 | adapter.setState(`${roomObj._id}.state`, i18n.notAvailable, true); 143 | } 144 | }, 145 | ); 146 | } else { 147 | const roomNo = parseInt(room, 10); 148 | adapter.setStateChanged(`${roomObj._id}.mapIndex`, roomNo, true, (err, id, notChanged) => { 149 | if (!notChanged) { 150 | adapter.log.info(`room: ${extRoomId} mapped with index ${roomNo}`); 151 | roomManager.updateRoomStates(roomObj._id); 152 | } 153 | }); 154 | delete rooms[extRoomId]; 155 | } 156 | } 157 | } 158 | } 159 | for (const extRoomId in rooms) { 160 | adapter.getObject(`rooms.${extRoomId}`, function (err, roomObj) { 161 | if (roomObj) { 162 | adapter.setStateChanged(`${roomObj._id}.mapIndex`, rooms[extRoomId], true); 163 | } else { 164 | roomManager.createRoom(extRoomId, rooms[extRoomId]); 165 | } 166 | }); 167 | } 168 | }); 169 | } 170 | 171 | cleanRooms(mapIndexStates) { 172 | adapter.getForeignStates(mapIndexStates, function (err, states) { 173 | const mapIndex = []; 174 | const zones = []; 175 | const mapChannels = []; 176 | const zoneChannels = []; 177 | if (states) { 178 | for (const stateId in states) { 179 | if (stateId.indexOf('.mapIndex') > 0) { 180 | const val = (states[stateId] && states[stateId].val) || 'invalid'; 181 | if (!isNaN(val)) { 182 | mapIndex.indexOf(parseInt(val, 10)) === -1 && 183 | mapIndex.push(val) && 184 | mapChannels.push(stateId.replace(/\.([^.]+)$/, '')); 185 | } else if (val[0] === '[') { 186 | zones.indexOf(val) === -1 && 187 | zones.push(val) && 188 | zoneChannels.push(stateId.replace(/\.([^.]+)$/, '')); 189 | } else { 190 | adapter.log.error(`could not clean ${stateId}, because mapIndex/zone is invalid: ${val}`); 191 | } 192 | } else { 193 | adapter.log.error(`state must be .mapIndex for roomManager.cleanRooms ${stateId}`); 194 | } 195 | } 196 | if (mapIndex.length > 0) { 197 | adapter.sendTo(adapter.namespace, 'cleanSegments', { segments: mapIndex, channels: mapChannels }); 198 | } 199 | if (zones.length > 0) { 200 | adapter.sendTo(adapter.namespace, 'cleanZone', { zones: zones, channels: zoneChannels }); 201 | } 202 | } 203 | }); 204 | } 205 | 206 | // search for assigned roomObjs or id on timer or other state 207 | cleanRoomsFromState(id) { 208 | adapter.getForeignObjects(id, 'state', 'rooms', (err, states) => { 209 | if (states && states[id].native) { 210 | const mapIndex = []; 211 | if (states[id].native.channels) { 212 | for (const i in states[id].native.channels) { 213 | mapIndex.push(adapter.namespace.concat('.rooms.', states[id].native.channels[i], '.mapIndex')); 214 | } 215 | } 216 | let rooms = ''; 217 | for (const r in states[id].enums) { 218 | rooms += r; 219 | } 220 | 221 | if (rooms.length > 0) { 222 | roomManager.findMapIndexByRoom(rooms, states => roomManager.cleanRooms(mapIndex.concat(states))); 223 | } else if (mapIndex.length > 0) { 224 | roomManager.cleanRooms(mapIndex); 225 | } else { 226 | adapter.log.warn(`no room found for ${id}`); 227 | } 228 | } 229 | }); 230 | } 231 | 232 | findMapIndexByRoom(rooms, callback) { 233 | adapter.getForeignObjects(`${adapter.namespace}.rooms.*.mapIndex`, 'state', 'rooms', (err, states) => { 234 | if (states) { 235 | const mapIndexStates = []; 236 | for (const stateId in states) { 237 | for (const r in states[stateId].enums) { 238 | if (rooms.indexOf(r) >= 0 && stateId.indexOf('.mapIndex') > 0) { 239 | // bug in js-controller 1.5, that not only mapIndex in states 240 | mapIndexStates.push(stateId); 241 | } 242 | } 243 | } 244 | callback && callback(mapIndexStates); 245 | } 246 | }); 247 | } 248 | 249 | findChannelsByMapIndex(mapList, callback) { 250 | adapter.getStates('rooms.*.mapIndex', (err, states) => { 251 | const channels = []; 252 | if (states) { 253 | for (const stateId in states) { 254 | if (states[stateId] && mapList.indexOf(states[stateId].val) >= 0) { 255 | channels.push(stateId.replace(/\.([^.]+)$/, '')); 256 | } 257 | } 258 | } 259 | callback && callback(channels); 260 | }); 261 | } 262 | 263 | createRoom(roomId, mapIndex) { 264 | adapter.log.info(`create new room: ${roomId}`); 265 | adapter.createChannel('rooms', roomId, (err, roomObj) => { 266 | if (roomObj) { 267 | const commonZone = { 268 | name: 'map zone', 269 | type: 'string', 270 | role: 'value', 271 | read: false, 272 | write: false, 273 | desc: 'coordinates of map zone', 274 | }; 275 | const commonMap = { 276 | name: 'map index', 277 | type: 'number', 278 | role: 'value', 279 | read: false, 280 | write: false, 281 | desc: 'index of assigned map', 282 | }; 283 | adapter.setObjectNotExists( 284 | `${roomObj.id}.mapIndex`, 285 | { 286 | type: 'state', 287 | common: mapIndex[0] === '[' ? commonZone : commonMap, 288 | native: {}, 289 | }, 290 | err => !err && adapter.setState(`${roomObj.id}.mapIndex`, mapIndex, true), 291 | ); 292 | this.updateRoomStates(roomObj.id); 293 | } 294 | }); 295 | } 296 | 297 | updateRoomStates(roomObj_id) { 298 | adapter.setObjectNotExists(`${roomObj_id}.roomClean`, roomManager.stateRoomClean); 299 | adapter.setObjectNotExists(`${roomObj_id}.state`, roomManager.stateRoomStatus, () => 300 | adapter.setForeignState(`${roomObj_id}.state`, '', true), 301 | ); 302 | adapter.setObjectNotExists(`${roomObj_id}.repeat`, roomManager.stateRoomRepeat); 303 | adapter.getObject('control.fan_power', (err, obj) => { 304 | obj && 305 | adapter.getState(obj._id, () => { 306 | adapter.setObjectNotExists( 307 | `${roomObj_id}.roomFanPower`, 308 | { 309 | type: 'state', 310 | common: obj.common, 311 | native: {}, 312 | }, 313 | //,err => !err && comonState && adapter.setState(roomObj_id + '.roomFanPower', comonState.val, false) 314 | ); 315 | }); 316 | }); 317 | adapter.getObject('control.water_box_mode', (err, obj) => { 318 | obj && 319 | adapter.getState(obj._id, () => { 320 | adapter.setObjectNotExists( 321 | `${roomObj_id}.roomWaterBoxMode`, 322 | { 323 | type: 'state', 324 | common: obj.common, 325 | native: {}, 326 | }, 327 | //,err => !err && comonState && adapter.setState(roomObj_id + '.roomWaterBoxMode', comonState.val, false) 328 | ); 329 | }); 330 | }); 331 | adapter.getObject('control.water_box_level', (err, obj) => { 332 | obj && 333 | adapter.getState(obj._id, () => { 334 | adapter.setObjectNotExists( 335 | `${roomObj_id}.roomWaterBoxLevel`, 336 | { 337 | type: 'state', 338 | common: obj.common, 339 | native: {}, 340 | }, 341 | //,err => !err && comonState && adapter.setState(roomObj_id + '.roomWaterBoxLevel', comonState.val, false) 342 | ); 343 | }); 344 | }); 345 | adapter.getObject('control.mop_mode', (err, obj) => { 346 | obj && 347 | adapter.getState(obj._id, () => { 348 | adapter.setObjectNotExists( 349 | `${roomObj_id}.roomMopMode`, 350 | { 351 | type: 'state', 352 | common: obj.common, 353 | native: {}, 354 | }, 355 | //,err => !err && comonState && adapter.setState(roomObj_id + '.roomMopMode', comonState.val, false) 356 | ); 357 | }); 358 | }); 359 | } 360 | } 361 | 362 | module.exports = RoomManager; 363 | -------------------------------------------------------------------------------- /io-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "common": { 3 | "name": "mihome-vacuum", 4 | "version": "5.3.0", 5 | "news": { 6 | "5.3.0": { 7 | "en": "update dependecies\nreplace request with axios\nfix login issues by replacing and moving code to XiaomiCloudConnector", 8 | "de": "aktualisierung der abhängigkeiten\nersetzen anfrage mit axios\nfix Login-Probleme durch Austausch und Verschieben von Code auf XiaomiCloudConnector", 9 | "ru": "обновление зависимостей\nзамена запроса на axios\nисправить проблемы с входом, заменив и переместив код в XiaomiCloudConnector", 10 | "pt": "atualizar dependências\nsubstituir o pedido por axios\ncorrigir problemas de login substituindo e movendo código para XiaomiCloudConnector", 11 | "nl": "afhankelijkheden bijwerken\nverzoek vervangen door axios\nfix login problemen door het vervangen en verplaatsen van code naar XiaomiCloudConnector", 12 | "fr": "mettre à jour les dépendances\nremplacer la demande par axios\ncorrection des problèmes de connexion en remplaçant et en déplaçant le code vers XiaomiCloudConnector", 13 | "it": "dipendenze dell'aggiornamento\nsostituire la richiesta con assios\nrisolvere i problemi di login sostituendo e spostando il codice a XiaomiCloudConnector", 14 | "es": "dependencias de actualización\nreemplazar la solicitud por axios\nsolucionar problemas de inicio de sesión reemplazando y moviendo código a XiaomiCloudConnector", 15 | "pl": "uaktualnianie zależności\nzastąp wniosek aksjozami\nnaprawić problemy logowania poprzez zastąpienie i przeniesienie kodu do XiaomiCloudConnector", 16 | "uk": "оновлення залежності\nзамінити запит з axios\nвиправлено проблеми логіну за допомогою заміни та переміщення коду до XiaomiCloudConnector", 17 | "zh-cn": "更新依赖关系\n将请求替换为轴\n通过替换和移动代码到 XiaomiCloud 连接器来修正登录问题" 18 | }, 19 | "5.2.0": { 20 | "en": "add IP Adress to info\nassign rockrobo (valetudo) to roborock Manager", 21 | "de": "iP-Adresse hinzufügen\nrockrobo (valetudo) dem roborock Manager zuordnen", 22 | "ru": "добавить IP адрес в информацию\nназначить Rockrobo (valetudo) менеджеру по разбойникам", 23 | "pt": "adicionar endereço IP para informações\natribuir rockrobo (vatudo) ao gerente de roborock", 24 | "nl": "iP adres toevoegen aan info\nrockrobo (valetudo) toewijzen aan Roborock Manager", 25 | "fr": "ajouter IP Adresse à info\naffecter rockrobo (valetudo) au gestionnaire de roborock", 26 | "it": "aggiungere indirizzo IP alle informazioni\nassegnare rockrobo (valetudo) a roborock Manager", 27 | "es": "añadir IP Dirección a info\nasignar rockrobo (valetudo) a roborock Manager", 28 | "pl": "dodaj adres IP do informacji\nprzydzielić rockrobo (valetudo) do roborock Manager", 29 | "uk": "додати IP-адресу на інформацію\nпризначте рокробо (valetudo) до roborock Manager", 30 | "zh-cn": "将 IP 服装添加到信息中\n指定 Rockrobo( valetudo) 为 Roborock 管理器" 31 | }, 32 | "5.1.0": { 33 | "en": "Added mop pad status and some states for Dreame/Xiaomi \nchange model handling", 34 | "de": "Hinzugefügt Mop Pad Status und einige Zustände für Dreame/Xiaomi\nänderung modellhandling", 35 | "ru": "Добавлен статус мопа пада и некоторые штаты для Dreame/Xiaomi\nизменение модели обработки", 36 | "pt": "Adicionado mop pad status e alguns estados para Dreame/Xiaomi\nmanipulação do modelo de mudança", 37 | "nl": "Toegevoegd mop pad status en sommige staten voor Dreame/Xiaomi\nmodelhandling wijzigen", 38 | "fr": "Ajout du statut de mop pad et de certains états pour Dreame/Xiaomi\nchangement de la gestion du modèle", 39 | "it": "Aggiunto stato mop pad e alcuni stati per Dreame/Xiaomi\ncambiamento modello di gestione", 40 | "es": "Estado de almohadillas mop agregado y algunos estados para Dreame/Xiaomi\ncambio modelo de manejo", 41 | "pl": "Dodano stan mopa i kilka stanów dla Dreame / Xiaomi\nzmiana obsługi modelu", 42 | "uk": "Додано mop місце та деякі стани для Dreame / Xiaomi\nзміна моделювання моделі", 43 | "zh-cn": "添加拖把状态和一些Dreake/小米状态\n更改模式处理" 44 | }, 45 | "5.0.0": { 46 | "en": "token from config now encrypted, user has to re-choose device in settings\nsome fixes in UI Setting", 47 | "de": "token from config jetzt verschlüsselt, benutzer muss das gerät in einstellungen neu auswählen\neinige Reparaturen in UI-Einstellung", 48 | "ru": "токен из настройки, теперь зашифрованный, пользователь должен переделать устройство в настройках\nнекоторые исправления в настройке пользовательского интерфейса", 49 | "pt": "token de configuração agora criptografado, o usuário tem que re-escolha dispositivo em configurações\nalgumas correções em UI Setting", 50 | "nl": "token van config nu versleuteld, gebruiker moet opnieuw kiezen apparaat in instellingen\nsommige fixes in UI-instellingen", 51 | "fr": "jeton de config maintenant chiffré, l'utilisateur doit re-choisir le périphérique dans les paramètres\nquelques corrections dans la configuration de l'interface utilisateur", 52 | "it": "token da config ora crittografato, l'utente deve ri-scegliere il dispositivo nelle impostazioni\nalcune correzioni in UI Setting", 53 | "es": "token from config now encrypted, user has to re-choose device in settings\nalgunos arreglos en UI Setting", 54 | "pl": "token z pliku konfiguracyjnego zaszyfrowany, użytkownik musi ponownie wybrać urządzenie w ustawieniach\nniektóre poprawki w ustawieniu interfejsu użytkownika", 55 | "uk": "token від config тепер зашифровано, користувач повинен переробити пристрій в налаштуваннях\nдеякі виправлення в налаштуваннях UI", 56 | "zh-cn": "正在加密配置中的符号, 用户必须在设置中重新选择设备\n用户界面设置中的一些修正" 57 | }, 58 | "4.3.0": { 59 | "en": "added dreame error messages\nresponsive design added\nupdate dependecies and linting", 60 | "de": "traumhafte fehlermeldungen hinzugefügt\nansprechendes design hinzugefügt\naktualisieren von abhängigkeiten und hinweisen", 61 | "ru": "добавленные сообщения об ошибках сна\nадаптивный дизайн добавлен\nобновление иждивенчества и линтинг", 62 | "pt": "adicionado mensagens de erro de sonho\ndesign responsivo adicionado\ndependências de atualização e forro", 63 | "nl": "toegevoegd droomfoutmeldingen\nresponsief ontwerp toegevoegd\nupdate afhankelijkheden en linting", 64 | "fr": "messages d'erreur de rêve ajouté\ndesign adapté ajouté\nmettre à jour les dépendances et le lintage", 65 | "it": "messaggi di errore sognanti aggiunti\ndesign reattivo aggiunto\naggiornamento dipendenze e linting", 66 | "es": "mensajes de error de sueño\ndiseño sensible añadido\ndependencias de actualización y revestimiento", 67 | "pl": "dodane komunikaty błędów dreame\nresponsible design added\naktualizacji zależności i lintowania", 68 | "uk": "додано повідомлення про помилку dreame\nдодано адаптивний дизайн\nоновлення залежності і linting", 69 | "zh-cn": "添加 dreame 错误消息\n添加应答设计\n更新依赖关系和内嵌" 70 | }, 71 | "4.2.0": { 72 | "en": "Adapter requires node.js 18 and js-controller >= 5 now\nDependencies have been updated\nupdate dependecies\nreplace zlib with native zlib", 73 | "de": "Adapter benötigt node.js 18 und js-controller >= 5 jetzt\nAbhängigkeiten wurden aktualisiert\naktualisierung der abhängigkeiten\nersetzen zlib mit nativem zlib", 74 | "ru": "Адаптер требует node.js 18 и js-controller >= 5 сейчас\nЗависимость обновлена\nобновление\nзаменить zlib на родной zlib", 75 | "pt": "Adapter requer node.js 18 e js-controller >= 5 agora\nAs dependências foram atualizadas\natualizações\nsubstituir zlib com zlib nativo", 76 | "nl": "Adapter vereist node.js 18 en js-controller Nu 5\nAfhankelijkheden zijn bijgewerkt\nafhankelijkheden bijwerken\nzlib vervangen door native zlib", 77 | "fr": "Adaptateur nécessite node.js 18 et js-controller >= 5 maintenant\nLes dépendances ont été actualisées\nmettre à jour les dépendances\nremplacer zlib par zlib natif", 78 | "it": "Adattatore richiede node.js 18 e js-controller >= 5 ora\nLe dipendenze sono state aggiornate\ndipendenze dell'aggiornamento\nsostituire zlib con zlib nativo", 79 | "es": "Adaptador requiere node.js 18 y js-controller √= 5 ahora\nSe han actualizado las dependencias\ndependencias de actualización\nreemplazar zlib con zlib nativo", 80 | "pl": "Adapter wymaga node.js 18 i sterownika js- > = 5 teraz\nZaktualizowano zależności\nuaktualnianie zależności\nzastąpić zlib natywnym zlib", 81 | "uk": "Адаптер вимагає node.js 18 і js-controller >= 5 тепер\nЗалежність було оновлено\nоновлення залежності\nзамінити zlib з рідною zlib", 82 | "zh-cn": "适配器需要节点.js 18和js控制器 QQ 现在5号\n依赖关系已更新\n更新依赖关系\n将 zlib 替换为本地 zlib" 83 | }, 84 | "4.1.1": { 85 | "en": "adapt stockConsumables to dreame\nfix url #886", 86 | "de": "zubehör Bequemlichkeiten zum Träumen\nurl #886", 87 | "ru": "адаптер Консуммы, чтобы мечтать\nисправить #886", 88 | "pt": "adaptar o estoque Consumíveis para sonhar\ncorrigir url #886", 89 | "nl": "voorraad aanpassen Verbruiksgoederen om te dromen\nfix url #886", 90 | "fr": "adapter le stock Consommables à rêver\nrèglement n° 886", 91 | "it": "azione adatta Consumabili a sognare\ncorrezione url #886", 92 | "es": "adapt stock Consumibles para soñar\nfijar url #886", 93 | "pl": "dostosować zasoby Materiały eksploatacyjne do zanurzenia\nfix url # 886", 94 | "uk": "адаптивний запас Здатні мрії\nзафіксувати виворіт #886", 95 | "zh-cn": "调整存量 做梦的消耗品\n修复url #886" 96 | } 97 | }, 98 | "titleLang": { 99 | "en": "Control of Xiaomi/Roborock vacuum cleaner", 100 | "de": "Steuerung des Staubsaugers Xiaomi/Roborock", 101 | "ru": "Управление пылесосом Xiaomi/Roborock", 102 | "pt": "Controle do aspirador Xiaomi/Roborock", 103 | "nl": "Bediening van Xiaomi/Roborock stofzuiger", 104 | "fr": "Contrôle de l'aspirateur Xiaomi/Roborock", 105 | "it": "Controllo dell'aspirapolvere Xiaomi/Roborock", 106 | "es": "Control de la aspiradora Xiaomi/Roborock", 107 | "pl": "Kontrola odkurzacza Xiaomi/Roborock", 108 | "uk": "Керування пилососом Xiaomi/Roborock", 109 | "zh-cn": "小米/罗伯克吸尘器的控制" 110 | }, 111 | "desc": { 112 | "en": "This adapter allows control Xiaomi vacuum cleaner", 113 | "de": "Dieser Adapter ermöglicht die Steuerung Xiaomi Staubsauger", 114 | "ru": "Этот адаптер позволяет контролировать пылесос Xiaomi", 115 | "pt": "Este adaptador permite controle Xiaomi aspirador de pó", 116 | "nl": "Met deze adapter kunt u de Xiaomi-stofzuiger bedienen", 117 | "fr": "Cet adaptateur permet de contrôler l'aspirateur Xiaomi", 118 | "it": "Questo adattatore consente il controllo dell'aspirapolvere Xiaomi", 119 | "es": "Este adaptador permite el control del aspirador Xiaomi", 120 | "pl": "Ten adapter umożliwia kontrolę odkurzacza Xiaomi", 121 | "uk": "Цей адаптер дозволяє керувати пилососом Xiaomi", 122 | "zh-cn": "这款适配器可以控制小米吸尘器" 123 | }, 124 | "authors": [ 125 | "bluefox " 126 | ], 127 | "osDependencies": { 128 | "linux": [ 129 | "libcairo2-dev", 130 | "libpango1.0-dev", 131 | "libjpeg-dev", 132 | "libgif-dev", 133 | "librsvg2-dev" 134 | ] 135 | }, 136 | "licenseInformation": { 137 | "license": "MIT", 138 | "type": "free" 139 | }, 140 | "adminUI": { 141 | "config": "materialize" 142 | }, 143 | "platform": "Javascript/Node.js", 144 | "mode": "daemon", 145 | "loglevel": "info", 146 | "compact": true, 147 | "icon": "mihome-vacuum.png", 148 | "readme": "https://github.com/iobroker-community-adapters/ioBroker.mihome-vacuum/blob/master/README.md", 149 | "keywords": [ 150 | "url", 151 | "html", 152 | "file", 153 | "mihome-vacuum" 154 | ], 155 | "extIcon": "https://raw.githubusercontent.com/iobroker-community-adapters/ioBroker.mihome-vacuum/master/admin/mihome-vacuum.png", 156 | "type": "household", 157 | "messagebox": true, 158 | "connectionType": "local", 159 | "dataSource": "poll", 160 | "tier": 2, 161 | "dependencies": [ 162 | { 163 | "js-controller": ">=5.0.19" 164 | } 165 | ], 166 | "globalDependencies": [ 167 | { 168 | "admin": ">=7.4.10" 169 | } 170 | ], 171 | "plugins": { 172 | "sentry": { 173 | "dsn": "https://b93cc849b5544d1682e20820bf56d636@sentry.iobroker.net/46" 174 | } 175 | } 176 | }, 177 | "encryptedNative": [ 178 | "password", 179 | "token" 180 | ], 181 | "protectedNative": [ 182 | "password", 183 | "token" 184 | ], 185 | "messages": [ 186 | { 187 | "condition": { 188 | "operand": "and", 189 | "rules": [ 190 | "oldVersion<5.0.0", 191 | "newVersion>=5.0.0" 192 | ] 193 | }, 194 | "title": { 195 | "en": "Important notice!", 196 | "de": "Wichtiger Hinweis!", 197 | "ru": "Важное замечание!", 198 | "pt": "Notícia importante!", 199 | "nl": "Belangrijke mededeling!", 200 | "fr": "Avis important!", 201 | "it": "Avviso IMPORTANTE!", 202 | "es": "Noticia importante!", 203 | "pl": "Ważna uwaga!", 204 | "zh-cn": "重要通知!" 205 | }, 206 | "text": { 207 | "en": "BREAKING CHANGE - After update you have to click get-Device in settings and choose your robor again and save", 208 | "de": "WICHTIGSTE ÄNDERUNG – Nach dem Update müssen Sie in den Einstellungen auf „Get-Device“ klicken, Ihren Robor erneut auswählen und speichern", 209 | "ru": "СЕРЬЕЗНОЕ ИЗМЕНЕНИЕ. После обновления вам нужно нажать «Получить устройство» в настройках, снова выбрать свой робот и сохранить его.", 210 | "pt": "MUDANÇA DE QUEBRA - Após a atualização você deve clicar em get-Device nas configurações e escolher seu robor novamente e salvar", 211 | "nl": "BREAKING CHANGE - Na de update moet je in de instellingen op Get-Device klikken en je robor opnieuw kiezen en opslaan", 212 | "fr": "CHANGEMENT RUPTURE - Après la mise à jour, vous devez cliquer sur Obtenir l'appareil dans les paramètres, choisir à nouveau votre robot et enregistrer", 213 | "it": "MODIFICA ROTANTE - Dopo l'aggiornamento devi fare clic su Ottieni dispositivo nelle impostazioni, scegliere nuovamente il tuo robor e salvare", 214 | "es": "CAMBIO IMPORTANTE: después de la actualización, debe hacer clic en Obtener dispositivo en la configuración, elegir su robot nuevamente y guardar.", 215 | "pl": "PRZERWA ZMIANY - Po aktualizacji musisz kliknąć opcję Pobierz urządzenie w ustawieniach, ponownie wybrać robota i zapisać", 216 | "zh-cn": "重大更改 - 更新后,您必须单击设置中的“获取设备”,然后再次选择您的机器人并保存" 217 | }, 218 | "level": "warn", 219 | "buttons": [ 220 | "agree", 221 | "cancel" 222 | ] 223 | } 224 | ], 225 | "native": { 226 | "email": "", 227 | "password": "", 228 | "server": "-", 229 | "token": "", 230 | "ip": "", 231 | "model": "", 232 | "manager": "", 233 | "lib": "", 234 | "enableMiMap": false, 235 | "enableSelfCommands": false, 236 | "sendPauseBeforeHome": false, 237 | "enableResumeZone": false, 238 | "port": 54321, 239 | "ownPort": 53421, 240 | "pingInterval": 20000, 241 | "wifiInterval": 60000, 242 | "valetudo_enable": false, 243 | "valetudo_color_floor": "#56affc", 244 | "valetudo_color_wall": "#b3edff", 245 | "valetudo_color_path": "#FFFFFF", 246 | "robot_select": "robot", 247 | "valetudo_requestIntervall": 2000, 248 | "valetudo_MapsaveIntervall": 5000, 249 | "newmap": false 250 | }, 251 | "instanceObjects": [ 252 | { 253 | "_id": "userfiles", 254 | "type": "meta", 255 | "common": { 256 | "name": "User files for mihome-vacuum", 257 | "type": "meta.user" 258 | }, 259 | "native": {} 260 | }, 261 | { 262 | "_id": "info", 263 | "type": "channel", 264 | "common": { 265 | "name": "Information" 266 | }, 267 | "native": {} 268 | }, 269 | { 270 | "_id": "info.connection", 271 | "type": "state", 272 | "common": { 273 | "role": "indicator.connected", 274 | "name": "If connected to robot", 275 | "type": "boolean", 276 | "read": true, 277 | "write": false 278 | }, 279 | "native": {} 280 | }, 281 | { 282 | "_id": "info.IPAddress", 283 | "type": "state", 284 | "common": { 285 | "role": "info.ip", 286 | "name": "IP adress of robot", 287 | "type": "string", 288 | "read": true, 289 | "write": false 290 | }, 291 | "native": {} 292 | }, 293 | { 294 | "_id": "", 295 | "type": "device", 296 | "common": { 297 | "name": "Vacuum robot", 298 | "role": "vacuumCleaner" 299 | }, 300 | "native": {} 301 | } 302 | ] 303 | } 304 | -------------------------------------------------------------------------------- /lib/XiaomiCloudConnector.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | const crypto = require('crypto'); 3 | const qs = require('qs'); 4 | 5 | class XiaomiCloudConnector { 6 | constructor(logger, authObj) { 7 | this.logger = logger; 8 | this.agent = this.generateAgent(); 9 | this.deviceId = this.generateDeviceId(); 10 | 11 | this.username = null; 12 | this.password = null; 13 | 14 | this._sign = null; 15 | this.captCode = false; 16 | this.ssecurity = null; 17 | this.userId = null; 18 | this.location = null; 19 | 20 | this.serviceToken = null; 21 | 22 | this.homeIds = null; 23 | this.init(authObj); 24 | 25 | this.session = axios.create({ withCredentials: true }); 26 | } 27 | init(authObj) { 28 | if (authObj) { 29 | if (authObj.username) { 30 | this.username = authObj.username; 31 | } 32 | if (authObj.password) { 33 | this.password = authObj.password; 34 | } 35 | if (authObj._sign) { 36 | this._sign = authObj._sign; 37 | } 38 | if (authObj.captCode) { 39 | this.captCode = authObj.captCode; 40 | } 41 | if (authObj.deviceId) { 42 | this.deviceId = authObj.deviceId; 43 | } 44 | } 45 | this.commonCookies = `sdkVersion=accountsdk-18.8.15; deviceId=${this.deviceId};`; 46 | } 47 | loggedIn() { 48 | return !!this.serviceToken; 49 | } 50 | 51 | async loginStep1() { 52 | if (this._sign) { 53 | return { ok: true }; 54 | } 55 | this.logger.debug('CloudApi-CloudApi-Step 1: Getting sign token'); 56 | const url = 'https://account.xiaomi.com/pass/serviceLogin?sid=xiaomiio&_json=true'; 57 | const headers = { 58 | 'User-Agent': this.agent, 59 | 'Content-Type': 'application/x-www-form-urlencoded', 60 | Cookie: `userId=${this.username}; ${this.commonCookies}`, 61 | }; 62 | //this.logger.debug(headers.Cookie); 63 | try { 64 | const response = await this.session.get(url, { 65 | headers, 66 | }); 67 | 68 | const body = this.parseJSON(response.data); 69 | if (response.status === 200 && body._sign) { 70 | this._sign = body._sign; 71 | return { ok: true }; 72 | } 73 | } catch (err) { 74 | // @ts-expect-error err.message not defined 75 | this.logger.error('CloudApi-Login Step 1 failed:', err.message); 76 | // @ts-expect-error err.message not defined 77 | return { err: err.message }; 78 | } 79 | return { err: 'Could not get signature' }; 80 | } 81 | 82 | async loginStep2() { 83 | if (this.userId) { 84 | return { ok: true }; 85 | } 86 | this.logger.debug('CloudApi-Step 2: Authenticating user'); 87 | const url = 'https://account.xiaomi.com/pass/serviceLoginAuth2'; 88 | const hash = crypto.createHash('md5').update(this.password).digest('hex').toUpperCase(); 89 | const headers = { 90 | 'User-Agent': this.agent, 91 | 'Content-Type': 'application/x-www-form-urlencoded', 92 | Cookie: `${this.commonCookies} pass_ua=web; uLocale=de_DE;`, 93 | }; 94 | const fields = { 95 | sid: 'xiaomiio', 96 | hash, 97 | callback: 'https://sts.api.io.mi.com/sts', 98 | qs: '%3Fsid%3Dxiaomiio%26_json%3Dtrue', 99 | user: this.username, 100 | _sign: this._sign, 101 | _json: 'true', 102 | }; 103 | if (typeof this.captCode == 'string') { 104 | fields.captCode = this.captCode; 105 | this.captCode = true; 106 | } 107 | const call = `${url}?${qs.stringify(fields)}`; 108 | //this.logger.debug(`CloudApi-call: ${call}`); 109 | //this.logger.debug(`CloudApi-headers: ${JSON.stringify(headers)}`); 110 | try { 111 | const response = await this.session.post(`${call}`, null, { 112 | headers, 113 | maxRedirects: 0, 114 | }); 115 | 116 | let data = this.parseJSON(response.data); 117 | if (data.captchaUrl) { 118 | // wir bekommen nicht die aktuelle session hier, daher klappt das Auflösen des captcha nicht 119 | this.logger.error('CloudApi-Login failed, because no captcha resolving possible'); 120 | return { err: 'Login failed, because no captcha resolving possible' }; 121 | /* 122 | if (!this.captCode) { 123 | this.logger.error('CloudApi-Login failed, because no captcha resolving possible'); 124 | return { err: 'Login failed, because no captcha resolving possible' }; 125 | } 126 | let captchaUrl = data.captchaUrl; 127 | if (captchaUrl.indexOf('/') == 0) { 128 | captchaUrl = `https://account.xiaomi.com${captchaUrl}`; 129 | } 130 | return { 131 | err: 'Please resolve captcha', 132 | captchaUrl: captchaUrl, 133 | username: this.username, 134 | password: this.password, 135 | _sign: this._sign, 136 | deviceId: this.deviceId, 137 | }; 138 | */ 139 | } 140 | 141 | if (data.ssecurity && data.ssecurity.length > 4) { 142 | this.ssecurity = data.ssecurity; 143 | this.location = data.location; 144 | this.userId = data.userId; 145 | return { ok: true }; 146 | } else if (data.notificationUrl) { 147 | this.logger.error( 148 | `CloudApi-Login failed, because Two factor authentication required, please use following url and restart adapter\n${data.notificationUrl}`, 149 | ); 150 | return { err: 'Login failed, because Two factor authentication required' }; 151 | } 152 | } catch (err) { 153 | // @ts-expect-error err.message not defined 154 | this.logger.error('CloudApi-Login Step 2 failed:', err.message); 155 | // @ts-expect-error err.message not defined 156 | return { err: err.message }; 157 | } 158 | return { err: 'could not get securityToken' }; 159 | } 160 | 161 | async loginStep3() { 162 | this.logger.debug('CloudApi-Step 3: Fetching service token'); 163 | const headers = { 164 | 'User-Agent': this.agent, 165 | 'Content-Type': 'application/x-www-form-urlencoded', 166 | Cookie: this.commonCookies, 167 | }; 168 | //this.logger.debug(headers.Cookie); 169 | try { 170 | const response = await this.session.get(this.location, { headers }); 171 | if (response.status === 200) { 172 | const setCookie = response.headers['set-cookie'] || []; 173 | const serviceToken = setCookie.find(c => c.includes('serviceToken')); 174 | this.serviceToken = serviceToken ? serviceToken.split('=')[1].split(';')[0] : null; 175 | if ((this, serviceToken)) { 176 | return { ok: true }; 177 | } 178 | throw 'serviceToken not found'; 179 | } 180 | } catch (err) { 181 | // @ts-expect-error err.message not defined 182 | this.logger.error('CloudApi-Login Step 3 failed:', err.message); 183 | // @ts-expect-error err.message not defined 184 | return { err: err.message }; 185 | } 186 | return { err: 'could not get serviceToken' }; 187 | } 188 | 189 | async refreshToken() { 190 | this._sign = null; 191 | this.captCode = false; 192 | this.ssecurity = null; 193 | this.serviceToken = null; 194 | return this.login(); 195 | } 196 | 197 | async login() { 198 | if (this.serviceToken) { 199 | return { ok: true }; 200 | } 201 | this.logger.debug('CloudApi-Login gestartet…'); 202 | let result = await this.loginStep1(); 203 | if (!result.err) { 204 | result = await this.loginStep2(); 205 | if (!result.err) { 206 | result = await this.loginStep3(); 207 | if (!result.err) { 208 | this.logger.debug('CloudApi-Login erfolgreich!'); 209 | return { ok: true }; 210 | } 211 | throw result; 212 | } else { 213 | throw result; 214 | } 215 | } else { 216 | throw result; 217 | } 218 | } 219 | 220 | parseJSON(raw) { 221 | try { 222 | if (typeof raw === 'string') { 223 | return JSON.parse(raw.replace('&&&START&&&', '')); 224 | } 225 | return raw; 226 | } catch (err) { 227 | // @ts-expect-error err.message not defined 228 | this.logger.error('CloudApi-JSON Parse Error:', err.message); 229 | return {}; 230 | } 231 | } 232 | 233 | async getHomes(country) { 234 | const url = `${this.getApiUrl(country)}/v2/homeroom/gethome`; 235 | const data = JSON.stringify({ fg: true, fetch_share: true, fetch_share_dev: true, limit: 300, app_ver: 7 }); 236 | return await this.executeEncryptedApiCall(url, { data }).then(json => { 237 | this.homeIds = []; 238 | if (json && json.result && json.result.homelist) { 239 | for (let h of json.result.homelist) { 240 | this.homeIds.push(h.id); 241 | } 242 | } 243 | }); 244 | } 245 | async getDevices(country, homeIds) { 246 | if (!homeIds) { 247 | if (!this.homeIds) { 248 | await this.getHomes(country); 249 | } 250 | homeIds = this.homeIds?.slice(); 251 | } else if (typeof homeIds != 'object') { 252 | homeIds = [homeIds]; 253 | } 254 | const url = `${this.getApiUrl(country)}/v2/home/home_device_list`; 255 | const params = { 256 | home_owner: this.userId, 257 | limit: 200, 258 | get_split_device: true, 259 | support_smart_home: true, 260 | }; 261 | const devices = {}; 262 | for (let homeId of homeIds) { 263 | params.home_id = homeId; 264 | const data = JSON.stringify(params); 265 | devices[homeId] = await this.executeEncryptedApiCall(url, { data }); 266 | } 267 | return devices; 268 | } 269 | 270 | async executeEncryptedApiCall(url, params) { 271 | const headers = { 272 | 'Accept-Encoding': 'identity', 273 | 'User-Agent': this.agent, 274 | 'Content-Type': 'application/x-www-form-urlencoded', 275 | 'x-xiaomi-protocal-flag-cli': 'PROTOCAL-HTTP2', 276 | 'MIOT-ENCRYPT-ALGORITHM': 'ENCRYPT-RC4', 277 | Cookie: `userId=${this.userId}; yetAnotherServiceToken=${this.serviceToken}; serviceToken=${this.serviceToken}; locale=de_DE; timezone=GMT%2B02%3A00; is_daylight=1; dst_offset=3600000; channel=MI_APP_STORE; ${this.commonCookies}`, 278 | }; 279 | //this.logger.debug(headers.Cookie); 280 | const millis = Date.now(); 281 | const nonce = this.generateNonce(millis); 282 | const signedNonce = this.signedNonce(nonce, this.ssecurity); 283 | const rc4 = new XiaomiRC4Cipher(signedNonce); 284 | this.logger.debug(`CloudApi-call: ${url} with ${JSON.stringify(params)}`); 285 | const fields = this.generateEncryptedParams(rc4, url, 'POST', nonce, params, this.ssecurity); 286 | //this.logger.debug(fields); 287 | //this.logger.debug(`Headers: ${JSON.stringify(headers)}`) 288 | const query = qs.stringify(fields, { encode: true }); 289 | //this.logger.debug(query); 290 | try { 291 | const response = await axios.post(`${url}?${query}`, null, { 292 | headers, 293 | maxRedirects: 0, 294 | //validateStatus: () => true, // Damit 401 nicht als Fehler behandelt wird 295 | }); 296 | 297 | if (response.status === 200) { 298 | const decrypted = new XiaomiRC4Cipher(signedNonce).decrypt(response.data); 299 | this.logger.debug(`CloudApi-get ${decrypted}`); 300 | return JSON.parse(decrypted); 301 | } 302 | } catch (err) { 303 | //@ts-expect-error undefined err.message 304 | this.logger.error(`CloudApi: executeEncryptedApiCall Error: ${err.message}`); 305 | //@ts-expect-error undefined err.message 306 | throw err.message; 307 | } 308 | return null; 309 | } 310 | 311 | generateAgent() { 312 | // Erzeuge agent_id: 13 Großbuchstaben zwischen A (65) und E (69) 313 | let agentId = Array.from({ length: 13 }, () => String.fromCharCode(Math.floor(Math.random() * 5) + 65)).join( 314 | '', 315 | ); 316 | // Erzeuge random_text: 18 Kleinbuchstaben zwischen a (97) und z (122) 317 | let randomText = Array.from({ length: 18 }, () => 318 | String.fromCharCode(Math.floor(Math.random() * 26) + 97), 319 | ).join(''); 320 | return `${randomText}-${agentId} APP/com.xiaomi.mihome APPV/10.5.201`; 321 | } 322 | generateDeviceId() { 323 | return Array.from({ length: 6 }, () => String.fromCharCode(Math.floor(Math.random() * 26) + 97)).join(''); 324 | } 325 | 326 | getApiUrl(country) { 327 | return `https://${country === 'cn' || country === '-' ? '' : `${country}.`}api.io.mi.com/app`; 328 | } 329 | 330 | signedNonce(nonce, ssecurity) { 331 | const hash = crypto 332 | .createHash('sha256') 333 | .update(Buffer.concat([Buffer.from(ssecurity, 'base64'), Buffer.from(nonce, 'base64')])) 334 | .digest(); 335 | return Buffer.from(hash).toString('base64'); 336 | } 337 | 338 | generateNonce(millis) { 339 | const randomBytes = crypto.randomBytes(8); 340 | const timeBytes = Buffer.alloc(4); 341 | timeBytes.writeUInt32BE(Math.floor(millis / 60000), 0); 342 | return Buffer.concat([randomBytes, timeBytes]).toString('base64'); 343 | } 344 | 345 | generateEncSignature(url, method, signedNonce, params) { 346 | const paramsArray = []; 347 | paramsArray.push('POST'); 348 | paramsArray.push(`/${url.split('/app/')[1]}`); 349 | for (const key in params) { 350 | paramsArray.push(`${key}=${params[key]}`); 351 | } 352 | paramsArray.push(signedNonce); 353 | const shasum = crypto.createHash('sha1'); 354 | return shasum.update(paramsArray.join('&'), 'utf8').digest('base64'); 355 | /*paramArray 1. call 356 | ( 357 | [0] => POST 358 | [1] => /home/device_list 359 | [2] => data={"getVirtualModel":true,"getHuamiDevices":1,"get_split_device":false,"support_smart_home":true} 360 | [3] => ADD83VgGuKnY10hfkjsdgfD43eeXeFg/+GdANJDAf7U= 361 | ) 362 | paramArray 2.call 363 | ( 364 | [0] => POST 365 | [1] => /home/device_list 366 | [2] => data=IShRZk6Pq6BiYbsOWj8oRSjPkoQjSHIhq5hiF9LyeSeGNnCwjKSu0/TkOoPsi89fPLJmoZNS9ABIYEeLDy5rC42Rix+EaS95ZL6UoeprLZ01unoIjWKydxpbnA7nmo34= 367 | [3] => rc4_hash__=b2x/O1G7jrkuep4zWxdnFiDUmplkCm5k3rKXXg== 368 | [4] => ADD83VgGuKnY10hfkjsdgfD43eeXeF//+GdANJDAf7U= 369 | ) 370 | */ 371 | } 372 | 373 | generateEncryptedParams(rc4, url, method, nonce, params, ssecurity) { 374 | params['rc4_hash__'] = this.generateEncSignature(url, method, rc4.passwordB64, params); 375 | for (const [k, v] of Object.entries(params)) { 376 | params[k] = rc4.encrypt(v); 377 | } 378 | params['signature'] = this.generateEncSignature(url, method, rc4.passwordB64, params); 379 | params['ssecurity'] = ssecurity; 380 | params['_nonce'] = nonce; 381 | 382 | return params; 383 | } 384 | } 385 | 386 | class XiaomiRC4Cipher { 387 | constructor(passwordB64) { 388 | this.passwordB64 = passwordB64; 389 | this.key = Buffer.from(passwordB64, 'base64'); 390 | this.S = new Uint8Array(256); 391 | for (let i = 0; i < 256; i++) { 392 | this.S[i] = i; 393 | } 394 | 395 | let j = 0; 396 | for (let i = 0; i < 256; i++) { 397 | j = (j + this.S[i] + this.key[i % this.key.length]) % 256; 398 | [this.S[i], this.S[j]] = [this.S[j], this.S[i]]; 399 | } 400 | 401 | // Drop Phase 402 | this.i = 0; 403 | this.j = 0; 404 | for (let drop = 0; drop < 1024; drop++) { 405 | this.generateKeystreamByte(); 406 | } 407 | } 408 | 409 | generateKeystreamByte() { 410 | this.i = (this.i + 1) % 256; 411 | this.j = (this.j + this.S[this.i]) % 256; 412 | [this.S[this.i], this.S[this.j]] = [this.S[this.j], this.S[this.i]]; 413 | return this.S[(this.S[this.i] + this.S[this.j]) % 256]; 414 | } 415 | 416 | encrypt(plainText) { 417 | const input = Buffer.from(String(plainText), 'utf8'); 418 | const output = Buffer.alloc(input.length); 419 | for (let k = 0; k < input.length; k++) { 420 | const rnd = this.generateKeystreamByte(); 421 | output[k] = input[k] ^ rnd; 422 | } 423 | return output.toString('base64'); 424 | } 425 | 426 | decrypt(cipherTextB64) { 427 | const input = Buffer.from(cipherTextB64, 'base64'); 428 | const output = Buffer.alloc(input.length); 429 | for (let k = 0; k < input.length; k++) { 430 | const rnd = this.generateKeystreamByte(); 431 | output[k] = input[k] ^ rnd; 432 | } 433 | return output.toString('utf8'); 434 | } 435 | } 436 | 437 | module.exports = XiaomiCloudConnector; 438 | 439 | /* 440 | var argv = require('minimist')(process.argv.slice(2)); 441 | const CloudApi = new XiaomiCloudConnector( 442 | console, 443 | { username: argv.u, password: argv.p, captCode: true }, 444 | ); 445 | CloudApi.login() 446 | .then(result => { 447 | console.log(result); 448 | }) 449 | .catch(result => { 450 | console.log(result); 451 | }); 452 | */ 453 | -------------------------------------------------------------------------------- /.github/copilot-instructions.md: -------------------------------------------------------------------------------- 1 | # ioBroker Adapter Development with GitHub Copilot 2 | 3 | **Version:** 0.4.0 4 | **Template Source:** https://github.com/DrozmotiX/ioBroker-Copilot-Instructions 5 | 6 | This file contains instructions and best practices for GitHub Copilot when working on ioBroker adapter development. 7 | 8 | ## Project Context 9 | 10 | You are working on an ioBroker adapter. ioBroker is an integration platform for the Internet of Things, focused on building smart home and industrial IoT solutions. Adapters are plugins that connect ioBroker to external systems, devices, or services. 11 | 12 | ### mihome-vacuum Adapter Specific Context 13 | 14 | This adapter enables control of Xiaomi/Roborock vacuum cleaners through ioBroker. Key features include: 15 | 16 | - **Supported Devices**: Xiaomi Mi Robot, Roborock S5/S6/S7/S8, Dreame vacuum cleaners, and Valetudo-flashed devices 17 | - **Core Functionality**: 18 | - Robot control (start/stop/pause/dock) 19 | - Real-time status monitoring 20 | - Zone and room-based cleaning 21 | - Map generation and visualization 22 | - Consumables tracking (brushes, filters, mop) 23 | - Scheduling and timers 24 | - Multi-robot support 25 | 26 | - **Technical Architecture**: 27 | - Uses miIO protocol for Xiaomi devices 28 | - Supports both cloud and local connections 29 | - Canvas-based map generation with visualization 30 | - Multiple device managers (VacuumManager, RoborockManager, DreameManager) 31 | - Encrypted token storage for security 32 | 33 | - **External Dependencies**: 34 | - Optional canvas package for map generation 35 | - Network connectivity to vacuum devices 36 | - Xiaomi cloud API for device discovery 37 | - Various vacuum-specific communication protocols 38 | 39 | ## Testing 40 | 41 | ### Unit Testing 42 | - Use Jest as the primary testing framework for ioBroker adapters 43 | - Create tests for all adapter main functions and helper methods 44 | - Test error handling scenarios and edge cases 45 | - Mock external API calls and hardware dependencies 46 | - For adapters connecting to APIs/devices not reachable by internet, provide example data files to allow testing of functionality without live connections 47 | - Example test structure: 48 | ```javascript 49 | describe('AdapterName', () => { 50 | let adapter; 51 | 52 | beforeEach(() => { 53 | // Setup test adapter instance 54 | }); 55 | 56 | test('should initialize correctly', () => { 57 | // Test adapter initialization 58 | }); 59 | }); 60 | ``` 61 | 62 | ### Integration Testing 63 | 64 | **IMPORTANT**: Use the official `@iobroker/testing` framework for all integration tests. This is the ONLY correct way to test ioBroker adapters. 65 | 66 | **Official Documentation**: https://github.com/ioBroker/testing 67 | 68 | #### Framework Structure 69 | Integration tests MUST follow this exact pattern: 70 | 71 | ```javascript 72 | const path = require('path'); 73 | const { tests } = require('@iobroker/testing'); 74 | 75 | // Define test coordinates or configuration 76 | const TEST_COORDINATES = '52.520008,13.404954'; // Berlin 77 | const wait = ms => new Promise(resolve => setTimeout(resolve, ms)); 78 | 79 | // Use tests.integration() with defineAdditionalTests 80 | tests.integration(path.join(__dirname, '..'), { 81 | defineAdditionalTests({ suite }) { 82 | suite('Test adapter with specific configuration', (getHarness) => { 83 | let harness; 84 | 85 | before(() => { 86 | harness = getHarness(); 87 | }); 88 | 89 | it('should configure and start adapter', function () { 90 | return new Promise(async (resolve, reject) => { 91 | try { 92 | harness = getHarness(); 93 | 94 | // Get adapter object using promisified pattern 95 | const obj = await new Promise((res, rej) => { 96 | harness.objects.getObject('system.adapter.your-adapter.0', (err, o) => { 97 | if (err) return rej(err); 98 | res(o); 99 | }); 100 | }); 101 | 102 | if (!obj) { 103 | return reject(new Error('Adapter object not found')); 104 | } 105 | 106 | // Configure adapter properties 107 | Object.assign(obj.native, { 108 | position: TEST_COORDINATES, 109 | createCurrently: true, 110 | createHourly: true, 111 | createDaily: true, 112 | // Add other configuration as needed 113 | }); 114 | 115 | // Set the updated configuration 116 | harness.objects.setObject(obj._id, obj); 117 | 118 | console.log('✅ Step 1: Configuration written, starting adapter...'); 119 | 120 | // Start adapter and wait 121 | await harness.startAdapterAndWait(); 122 | 123 | console.log('✅ Step 2: Adapter started'); 124 | 125 | // Wait for adapter to process data 126 | const waitMs = 15000; 127 | await wait(waitMs); 128 | 129 | console.log('🔍 Step 3: Checking states after adapter run...'); 130 | 131 | // Verify that basic states exist 132 | const states = await harness.states.getKeysAsync('your-adapter.0.*'); 133 | console.log(`Found ${states.length} states created by adapter`); 134 | 135 | if (states.length === 0) { 136 | throw new Error('No states were created by the adapter'); 137 | } 138 | 139 | resolve(); 140 | } catch (error) { 141 | console.error('Integration test failed:', error); 142 | reject(error); 143 | } 144 | }); 145 | }).timeout(60000); // Generous timeout for integration tests 146 | }); 147 | } 148 | }); 149 | ``` 150 | 151 | #### Testing Adapter-Specific Functionality 152 | 153 | For mihome-vacuum adapter testing: 154 | - Mock vacuum device responses for offline testing 155 | - Test map generation functionality with sample data 156 | - Validate device manager selection logic 157 | - Test encryption/decryption of tokens 158 | - Verify multi-device support scenarios 159 | 160 | ### Advanced Testing Patterns 161 | 162 | #### Mocking Hardware/Network Dependencies 163 | ```javascript 164 | // Mock miIO protocol responses for testing 165 | const mockMiio = { 166 | sendMessage: (command) => { 167 | if (command === 'miIO.info') { 168 | return Promise.resolve({ 169 | result: { 170 | model: 'roborock.vacuum.s5', 171 | fw_ver: '1.0.0', 172 | hw_ver: 'MW300' 173 | } 174 | }); 175 | } 176 | return Promise.resolve({ result: 'ok' }); 177 | } 178 | }; 179 | 180 | // Test map generation with sample data 181 | const sampleMapData = { 182 | image: { 183 | dimensions: { width: 1024, height: 1024 }, 184 | position: { top: 0, left: 0 }, 185 | pixels: Buffer.alloc(1024 * 1024 * 4) // RGBA buffer 186 | }, 187 | path: { 188 | points: [[100, 100], [200, 200], [300, 300]] 189 | }, 190 | charger: { position: [512, 512] } 191 | }; 192 | ``` 193 | 194 | ## Adapter Architecture 195 | 196 | ### Core Components 197 | 198 | #### Device Managers 199 | - **VacuumManager**: Base functionality for Xiaomi Mi Robot 200 | - **RoborockManager**: Extended features for Roborock devices 201 | - **DreameManager**: Dreame vacuum-specific implementation 202 | - Each manager handles device-specific protocols and features 203 | 204 | #### Map Generation (lib/mapCreator.js) 205 | - Canvas-based rendering of vacuum maps 206 | - Configurable colors and robot icons 207 | - Path visualization and zone highlighting 208 | - Optional dependency - graceful fallback when canvas unavailable 209 | 210 | #### Communication Protocols 211 | - **miIO Protocol**: Direct device communication 212 | - **Cloud API**: Xiaomi cloud service integration 213 | - **Valetudo**: Local-only firmware support 214 | 215 | ### Configuration Management 216 | - Encrypted storage of sensitive data (tokens, passwords) 217 | - Multi-device configuration support 218 | - Server region selection for cloud connectivity 219 | 220 | ## Error Handling 221 | 222 | ### Adapter-Specific Error Scenarios 223 | - Device offline/unreachable 224 | - Invalid tokens or authentication failures 225 | - Map generation failures (missing canvas) 226 | - Protocol version mismatches 227 | - Network connectivity issues 228 | 229 | ### Error Response Patterns 230 | ```javascript 231 | // Graceful degradation for optional features 232 | try { 233 | const mapImage = await generateMap(mapData); 234 | await this.setStateAsync('map.image', mapImage); 235 | } catch (error) { 236 | this.log.warn('Map generation failed, canvas not available: ' + error.message); 237 | // Continue without map functionality 238 | } 239 | 240 | // Retry logic for network operations 241 | async function callWithRetry(operation, retries = 3) { 242 | for (let i = 0; i < retries; i++) { 243 | try { 244 | return await operation(); 245 | } catch (error) { 246 | if (i === retries - 1) throw error; 247 | await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1))); 248 | } 249 | } 250 | } 251 | ``` 252 | 253 | ## Device Protocol Integration 254 | 255 | ### miIO Protocol Implementation 256 | - Token-based authentication 257 | - Command/response pattern 258 | - Device capability detection 259 | - Status polling and event handling 260 | 261 | ### Multi-Device Architecture 262 | ```javascript 263 | // Device manager factory pattern 264 | getManager(model, configuredManager) { 265 | const managers = { 266 | 'roborock.vacuum.s5': RoborockManager, 267 | 'roborock.vacuum.s6': RoborockManager, 268 | 'dreame.vacuum.mc1808': DreameManager, 269 | // ... more device mappings 270 | }; 271 | 272 | return configuredManager || managers[model] || VacuumManager; 273 | } 274 | ``` 275 | 276 | ### State Management 277 | - Real-time status updates 278 | - Battery and consumables monitoring 279 | - Cleaning history and statistics 280 | - Error code translation and reporting 281 | 282 | ## Performance Considerations 283 | 284 | ### Network Optimization 285 | - Configurable polling intervals 286 | - Connection pooling for multiple devices 287 | - Graceful handling of network timeouts 288 | 289 | ### Resource Management 290 | ```javascript 291 | // Proper cleanup in unload method 292 | async onUnload(callback) { 293 | try { 294 | // Clear timers 295 | if (this.pingTimeout) { 296 | clearTimeout(this.pingTimeout); 297 | this.pingTimeout = undefined; 298 | } 299 | 300 | // Close device connections 301 | if (this.miio) { 302 | await this.miio.destroy(); 303 | } 304 | 305 | callback(); 306 | } catch (e) { 307 | callback(); 308 | } 309 | } 310 | ``` 311 | 312 | ## Security Best Practices 313 | 314 | ### Token and Password Handling 315 | - Use ioBroker's built-in encryption for sensitive data 316 | - Never log tokens or passwords in plain text 317 | - Implement secure token refresh mechanisms 318 | 319 | ### Network Security 320 | - Support for local-only operation (Valetudo) 321 | - Validate all external API responses 322 | - Implement rate limiting for API calls 323 | 324 | ## Code Style and Standards 325 | 326 | - Follow JavaScript/TypeScript best practices 327 | - Use async/await for asynchronous operations 328 | - Implement proper resource cleanup in `unload()` method 329 | - Use semantic versioning for adapter releases 330 | - Include proper JSDoc comments for public methods 331 | 332 | ## CI/CD and Testing Integration 333 | 334 | ### GitHub Actions for API Testing 335 | For adapters with external API dependencies, implement separate CI/CD jobs: 336 | 337 | ```yaml 338 | # Tests API connectivity with demo credentials (runs separately) 339 | demo-api-tests: 340 | if: contains(github.event.head_commit.message, '[skip ci]') == false 341 | 342 | runs-on: ubuntu-22.04 343 | 344 | steps: 345 | - name: Checkout code 346 | uses: actions/checkout@v4 347 | 348 | - name: Use Node.js 20.x 349 | uses: actions/setup-node@v4 350 | with: 351 | node-version: 20.x 352 | cache: 'npm' 353 | 354 | - name: Install dependencies 355 | run: npm ci 356 | 357 | - name: Run demo API tests 358 | run: npm run test:integration-demo 359 | ``` 360 | 361 | ### CI/CD Best Practices 362 | - Run credential tests separately from main test suite 363 | - Use ubuntu-22.04 for consistency 364 | - Don't make credential tests required for deployment 365 | - Provide clear failure messages for API connectivity issues 366 | - Use appropriate timeouts for external API calls (120+ seconds) 367 | 368 | ### Package.json Script Integration 369 | Add dedicated script for credential testing: 370 | ```json 371 | { 372 | "scripts": { 373 | "test:integration-demo": "mocha test/integration-demo --exit" 374 | } 375 | } 376 | ``` 377 | 378 | ### Practical Example: Complete API Testing Implementation 379 | Here's a complete example based on lessons learned from the Discovergy adapter: 380 | 381 | #### test/integration-demo.js 382 | ```javascript 383 | const path = require("path"); 384 | const { tests } = require("@iobroker/testing"); 385 | 386 | // Helper function to encrypt password using ioBroker's encryption method 387 | async function encryptPassword(harness, password) { 388 | const systemConfig = await harness.objects.getObjectAsync("system.config"); 389 | 390 | if (!systemConfig || !systemConfig.native || !systemConfig.native.secret) { 391 | throw new Error("Could not retrieve system secret for password encryption"); 392 | } 393 | 394 | const secret = systemConfig.native.secret; 395 | let result = ''; 396 | for (let i = 0; i < password.length; ++i) { 397 | result += String.fromCharCode(secret[i % secret.length].charCodeAt(0) ^ password.charCodeAt(i)); 398 | } 399 | 400 | return result; 401 | } 402 | 403 | // Run integration tests with demo credentials 404 | tests.integration(path.join(__dirname, ".."), { 405 | defineAdditionalTests({ suite }) { 406 | suite("API Testing with Demo Credentials", (getHarness) => { 407 | let harness; 408 | 409 | before(() => { 410 | harness = getHarness(); 411 | }); 412 | 413 | it("Should connect to API and initialize with demo credentials", async () => { 414 | console.log("Setting up demo credentials..."); 415 | 416 | if (harness.isAdapterRunning()) { 417 | await harness.stopAdapter(); 418 | } 419 | 420 | const encryptedPassword = await encryptPassword(harness, "demo_password"); 421 | 422 | await harness.changeAdapterConfig("your-adapter", { 423 | native: { 424 | username: "demo@provider.com", 425 | password: encryptedPassword, 426 | // other config options 427 | } 428 | }); 429 | 430 | console.log("Starting adapter with demo credentials..."); 431 | await harness.startAdapter(); 432 | 433 | // Wait for API calls and initialization 434 | await new Promise(resolve => setTimeout(resolve, 60000)); 435 | 436 | const connectionState = await harness.states.getStateAsync("your-adapter.0.info.connection"); 437 | 438 | if (connectionState && connectionState.val === true) { 439 | console.log("✅ SUCCESS: API connection established"); 440 | return true; 441 | } else { 442 | throw new Error("API Test Failed: Expected API connection to be established with demo credentials. " + 443 | "Check logs above for specific API errors (DNS resolution, 401 Unauthorized, network issues, etc.)"); 444 | } 445 | }).timeout(120000); 446 | }); 447 | } 448 | }); 449 | ``` 450 | 451 | ## Adapter-Specific Development Guidelines 452 | 453 | ### Map Generation Best Practices 454 | - Always check for canvas availability before attempting map operations 455 | - Provide fallback functionality when canvas is not available 456 | - Use efficient rendering techniques for large map data 457 | - Implement proper error handling for corrupted map data 458 | 459 | ### Device Communication Patterns 460 | ```javascript 461 | // Robust device communication with retry logic 462 | async function sendDeviceCommand(command, params = []) { 463 | const maxRetries = 3; 464 | let lastError; 465 | 466 | for (let attempt = 1; attempt <= maxRetries; attempt++) { 467 | try { 468 | const result = await this.miio.sendMessage(command, params); 469 | return result; 470 | } catch (error) { 471 | lastError = error; 472 | this.log.debug(`Command ${command} attempt ${attempt} failed: ${error.message}`); 473 | 474 | if (attempt < maxRetries) { 475 | await new Promise(resolve => setTimeout(resolve, 1000 * attempt)); 476 | } 477 | } 478 | } 479 | 480 | throw lastError; 481 | } 482 | ``` 483 | 484 | ### Configuration Validation 485 | ```javascript 486 | // Validate configuration before adapter startup 487 | function validateConfig(config) { 488 | const errors = []; 489 | 490 | if (!config.token && !config.email) { 491 | errors.push('Either token or email/password must be provided'); 492 | } 493 | 494 | if (config.pingInterval < 10000) { 495 | errors.push('Ping interval must be at least 10 seconds'); 496 | } 497 | 498 | if (errors.length > 0) { 499 | throw new Error('Configuration validation failed: ' + errors.join(', ')); 500 | } 501 | } 502 | ``` --------------------------------------------------------------------------------