iobroker.navimow 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 TA2k <tombox2020@gmail.com>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,107 @@
1
+ ![Logo](admin/navimow.png)
2
+
3
+ # ioBroker.navimow
4
+
5
+ [![NPM version](https://img.shields.io/npm/v/iobroker.navimow.svg)](https://www.npmjs.com/package/iobroker.navimow)
6
+ [![Downloads](https://img.shields.io/npm/dm/iobroker.navimow.svg)](https://www.npmjs.com/package/iobroker.navimow)
7
+ ![Number of Installations](https://iobroker.live/badges/navimow-installed.svg)
8
+ ![Current version in stable repository](https://iobroker.live/badges/navimow-stable.svg)
9
+ [![GitHub license](https://img.shields.io/github/license/TA2k/ioBroker.navimow)](https://github.com/TA2k/ioBroker.navimow/blob/main/LICENSE)
10
+ [![GitHub issues](https://img.shields.io/github/issues/TA2k/ioBroker.navimow)](https://github.com/TA2k/ioBroker.navimow/issues)
11
+ [![GitHub last commit](https://img.shields.io/github/last-commit/TA2k/ioBroker.navimow)](https://github.com/TA2k/ioBroker.navimow/commits/main)
12
+ [![node](https://img.shields.io/node/v/iobroker.navimow)](https://www.npmjs.com/package/iobroker.navimow)
13
+
14
+ [![NPM](https://nodei.co/npm/iobroker.navimow.png?downloads=true)](https://nodei.co/npm/iobroker.navimow/)
15
+
16
+ **Tests:** ![Test and Release](https://github.com/TA2k/ioBroker.navimow/workflows/Test%20and%20Release/badge.svg)
17
+
18
+ ## Navimow Adapter for ioBroker
19
+
20
+ ioBroker adapter for Segway Navimow robotic mowers. Uses the official [Navimow SDK](https://github.com/segwaynavimow/navimow-sdk) REST API and MQTT for real-time updates.
21
+
22
+ ## Features
23
+
24
+ - OAuth2 login via Navimow account
25
+ - Real-time status updates via MQTT (WebSocket Secure)
26
+ - HTTP polling as fallback
27
+ - Remote control: Start, Stop, Pause, Resume, Dock
28
+ - Automatic token refresh with MQTT reconnect
29
+
30
+ ## Setup
31
+
32
+ 1. Open the adapter settings in ioBroker Admin
33
+ 2. Click **"Navimow Login öffnen"** to open the Navimow login page
34
+ 3. Login with your Navimow account
35
+ 4. After login the browser shows **"Seite nicht erreichbar"** - this is expected
36
+ 5. Copy the complete URL from the browser address bar (contains `?code=XXXXX`)
37
+ 6. Paste the URL into the **Authorization Code** field and save
38
+ 7. The adapter exchanges the code for a token and starts automatically
39
+
40
+ The token is refreshed automatically. A re-login is only needed if the refresh token expires.
41
+
42
+ ## States
43
+
44
+ For each mower device the following channels are created:
45
+
46
+ | Channel | Description |
47
+ | ------------------------ | -------------------------------------------------------- |
48
+ | `{deviceId}.general` | Device info (name, model, serial number, firmware) |
49
+ | `{deviceId}.status` | Current status (vehicleState, battery, position, signal) |
50
+ | `{deviceId}.status.json` | Raw JSON of the last status update |
51
+ | `{deviceId}.events` | MQTT events |
52
+ | `{deviceId}.attributes` | MQTT device attributes |
53
+ | `{deviceId}.remote` | Remote control buttons |
54
+
55
+ ### Remote Controls
56
+
57
+ | State | Description |
58
+ | ---------------- | ------------------------------- |
59
+ | `remote.Refresh` | Trigger a manual status refresh |
60
+ | `remote.start` | Start mowing |
61
+ | `remote.stop` | Stop mowing |
62
+ | `remote.pause` | Pause mowing |
63
+ | `remote.resume` | Resume mowing |
64
+ | `remote.dock` | Return to dock |
65
+
66
+ Remote states reflect the current device state with `ack:true`. For example, when the mower is mowing, `remote.start` is `true`.
67
+
68
+ ## API
69
+
70
+ Based on the [Navimow SDK](https://github.com/segwaynavimow/navimow-sdk) and [Navimow HA Integration](https://github.com/segwaynavimow/NavimowHA).
71
+
72
+ | Endpoint | Purpose |
73
+ | ------------------------------------------ | ------------------------------------------ |
74
+ | `POST /openapi/oauth/getAccessToken` | OAuth2 token exchange and refresh |
75
+ | `GET /openapi/smarthome/authList` | Discover devices |
76
+ | `POST /openapi/smarthome/getVehicleStatus` | Get device status |
77
+ | `POST /openapi/smarthome/sendCommands` | Send commands (Google Smart Home protocol) |
78
+ | `GET /openapi/mqtt/userInfo/get/v2` | Get MQTT connection credentials |
79
+
80
+ ## Changelog
81
+ ### 1.0.0 (2026-03-15)
82
+
83
+ - (TA2k) initial release
84
+
85
+ ## License
86
+
87
+ MIT License
88
+
89
+ Copyright (c) 2026 TA2k <tombox2020@gmail.com>
90
+
91
+ Permission is hereby granted, free of charge, to any person obtaining a copy
92
+ of this software and associated documentation files (the "Software"), to deal
93
+ in the Software without restriction, including without limitation the rights
94
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
95
+ copies of the Software, and to permit persons to whom the Software is
96
+ furnished to do so, subject to the following conditions:
97
+
98
+ The above copyright notice and this permission notice shall be included in all
99
+ copies or substantial portions of the Software.
100
+
101
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
102
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
103
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
104
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
105
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
106
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
107
+ SOFTWARE.
@@ -0,0 +1,58 @@
1
+ {
2
+ "type": "panel",
3
+ "i18n": false,
4
+ "items": {
5
+ "_oauth_header": {
6
+ "type": "header",
7
+ "text": {
8
+ "en": "Navimow Login",
9
+ "de": "Navimow Login"
10
+ },
11
+ "size": 3,
12
+ "newLine": true
13
+ },
14
+ "_oauth_info": {
15
+ "type": "staticText",
16
+ "text": {
17
+ "en": "1. Click the login link below and login with your Navimow account<br>2. After login you will be redirected - copy the complete URL from the browser address bar<br><i>The URL contains <b>?code=XXXXX</b></i><br>3. Paste the URL below and save",
18
+ "de": "1. Klicke auf den Login-Link unten und melde dich mit deinem Navimow-Konto an<br>2. Nach dem Login wirst du weitergeleitet - kopiere die komplette URL aus der Browser-Adressleiste<br><i>Die URL enthält <b>?code=XXXXX</b></i><br>3. Füge die URL unten ein und speichere"
19
+ },
20
+ "newLine": true,
21
+ "sm": 12
22
+ },
23
+ "_oauth_link": {
24
+ "type": "staticLink",
25
+ "href": "https://navimow-h5-fra.willand.com/smartHome/login?channel=homeassistant&client_id=homeassistant&response_type=code&redirect_uri=http%3A%2F%2Flocalhost%3A1%2Fcallback",
26
+ "label": {
27
+ "en": "Open Navimow Login",
28
+ "de": "Navimow Login öffnen"
29
+ },
30
+ "newLine": true,
31
+ "sm": 4,
32
+ "button": true
33
+ },
34
+ "authCode": {
35
+ "type": "text",
36
+ "newLine": true,
37
+ "sm": 6,
38
+ "label": {
39
+ "en": "Authorization Code",
40
+ "de": "Autorisierungscode"
41
+ },
42
+ "help": {
43
+ "en": "Paste the full URL or just the code value",
44
+ "de": "Die vollständige URL oder nur den Code-Wert einfügen"
45
+ }
46
+ },
47
+ "interval": {
48
+ "type": "number",
49
+ "newLine": true,
50
+ "min": 0.5,
51
+ "sm": 2,
52
+ "label": {
53
+ "en": "Update interval (in minutes)",
54
+ "de": "Aktualisierungsintervall (in Minuten)"
55
+ }
56
+ }
57
+ }
58
+ }
Binary file
@@ -0,0 +1,131 @@
1
+ {
2
+ "common": {
3
+ "name": "navimow",
4
+ "version": "1.0.0",
5
+ "news": {
6
+ "1.0.0": {
7
+ "en": "initial release",
8
+ "de": "erstausstrahlung",
9
+ "ru": "первоначальное освобождение",
10
+ "pt": "libertação inicial",
11
+ "nl": "eerste release",
12
+ "fr": "libération initiale",
13
+ "it": "rilascio iniziale",
14
+ "es": "liberación inicial",
15
+ "pl": "początkowe zwolnienie",
16
+ "uk": "початковий реліз",
17
+ "zh-cn": "初步释放"
18
+ },
19
+ "0.0.1": {
20
+ "en": "initial release",
21
+ "de": "Erstveröffentlichung",
22
+ "ru": "Начальная версия",
23
+ "pt": "lançamento inicial",
24
+ "nl": "Eerste uitgave",
25
+ "fr": "Première version",
26
+ "it": "Versione iniziale",
27
+ "es": "Versión inicial",
28
+ "pl": "Pierwsze wydanie",
29
+ "uk": "Початкова версія",
30
+ "zh-cn": "首次出版"
31
+ }
32
+ },
33
+ "titleLang": {
34
+ "en": "NaviMow",
35
+ "de": "NaviMow",
36
+ "ru": "НавиМоу",
37
+ "pt": "NaviMow",
38
+ "nl": "NaviMow",
39
+ "fr": "NaviMow",
40
+ "it": "NaviMow",
41
+ "es": "NaviMow",
42
+ "pl": "NaviMow",
43
+ "uk": "NaviMow",
44
+ "zh-cn": "导航软件"
45
+ },
46
+ "desc": {
47
+ "en": "Adapter for NaviMower from Segway",
48
+ "de": "Adapter für NaviMower von Segway",
49
+ "ru": "Адаптер для NaviMower от Segway",
50
+ "pt": "Adaptador para NaviMower da Segway",
51
+ "nl": "Adapter voor NaviMower van Segway",
52
+ "fr": "Adaptateur pour NaviMower de Segway",
53
+ "it": "Adattatore per NaviMower di Segway",
54
+ "es": "Adaptador para NaviMower de Segway",
55
+ "pl": "Adapter do kosiarki NaviMower firmy Segway",
56
+ "uk": "Адаптер для NaviMower від Segway",
57
+ "zh-cn": "Segway 的 NaviMower 适配器"
58
+ },
59
+ "authors": [
60
+ "TA2k <tombox2020@gmail.com>"
61
+ ],
62
+ "keywords": [
63
+ "navimower",
64
+ "segway"
65
+ ],
66
+ "licenseInformation": {
67
+ "license": "MIT",
68
+ "type": "free"
69
+ },
70
+ "tier": 3,
71
+ "platform": "Javascript/Node.js",
72
+ "icon": "navimow.png",
73
+ "enabled": true,
74
+ "extIcon": "https://raw.githubusercontent.com/TA2k/ioBroker.navimow/main/admin/navimow.png",
75
+ "readme": "https://github.com/TA2k/ioBroker.navimow/blob/main/README.md",
76
+ "loglevel": "info",
77
+ "mode": "daemon",
78
+ "type": "garden",
79
+ "compact": true,
80
+ "connectionType": "cloud",
81
+ "dataSource": "poll",
82
+ "adminUI": {
83
+ "config": "json"
84
+ },
85
+ "plugins": {
86
+ "sentry": {
87
+ "dsn": "https://c9bd6c851b1246da95e6e982aa9b88f3@sentry.iobroker.net/152"
88
+ }
89
+ },
90
+ "dependencies": [
91
+ {
92
+ "js-controller": ">=5.0.19"
93
+ }
94
+ ],
95
+ "globalDependencies": [
96
+ {
97
+ "admin": ">=5.2.28"
98
+ }
99
+ ]
100
+ },
101
+ "encryptedNative": [],
102
+ "protectedNative": [],
103
+ "native": {
104
+ "authCode": "",
105
+ "interval": 10
106
+ },
107
+ "objects": [],
108
+ "instanceObjects": [
109
+ {
110
+ "_id": "info",
111
+ "type": "channel",
112
+ "common": {
113
+ "name": "Information"
114
+ },
115
+ "native": {}
116
+ },
117
+ {
118
+ "_id": "info.connection",
119
+ "type": "state",
120
+ "common": {
121
+ "role": "indicator.connected",
122
+ "name": "Device or service connected",
123
+ "type": "boolean",
124
+ "read": true,
125
+ "write": false,
126
+ "def": false
127
+ },
128
+ "native": {}
129
+ }
130
+ ]
131
+ }
@@ -0,0 +1,19 @@
1
+ // This file extends the AdapterConfig type from "@types/iobroker"
2
+ // using the actual properties present in io-package.json
3
+ // in order to provide typings for adapter.config properties
4
+
5
+ import { native } from '../io-package.json';
6
+
7
+ type _AdapterConfig = typeof native;
8
+
9
+ // Augment the globally declared type ioBroker.AdapterConfig
10
+ declare global {
11
+ namespace ioBroker {
12
+ interface AdapterConfig extends _AdapterConfig {
13
+ // Do not enter anything here!
14
+ }
15
+ }
16
+ }
17
+
18
+ // this is required so the above AdapterConfig is found by TypeScript / type checking
19
+ export {};
@@ -0,0 +1,38 @@
1
+ {
2
+ "id": "Device ID",
3
+ "device_id": "Device ID",
4
+ "name": "Device Name",
5
+ "model": "Model",
6
+ "firmware_version": "Firmware Version",
7
+ "serial_number": "Serial Number",
8
+ "mac_address": "MAC Address",
9
+ "online": "Online Status",
10
+ "product_key": "Product Key",
11
+ "productKey": "Product Key",
12
+ "device_name": "Device Name",
13
+ "deviceName": "Device Name",
14
+ "iot_id": "IoT ID",
15
+ "iotId": "IoT ID",
16
+ "vehicleState": "Mower State",
17
+ "status": "Status",
18
+ "state": "State",
19
+ "battery": "Battery Level (%)",
20
+ "capacityRemaining": "Remaining Capacity",
21
+ "rawValue": "Raw Value",
22
+ "unit": "Unit",
23
+ "position": "GPS Position",
24
+ "latitude": "Latitude",
25
+ "longitude": "Longitude",
26
+ "lat": "Latitude",
27
+ "lng": "Longitude",
28
+ "error_code": "Error Code",
29
+ "error_message": "Error Message",
30
+ "errorCode": "Error Code",
31
+ "signal_strength": "Signal Strength",
32
+ "signalStrength": "Signal Strength",
33
+ "mowing_time": "Current Mowing Time (s)",
34
+ "total_mowing_time": "Total Mowing Time (s)",
35
+ "timestamp": "Last Update Timestamp",
36
+ "descriptiveCapacityRemaining": "Battery Description",
37
+ "extra": "Extra Information"
38
+ }
@@ -0,0 +1,50 @@
1
+ {
2
+ "vehicleState": {
3
+ "isDocked": "Docked",
4
+ "isIdel": "Idle",
5
+ "isIdle": "Idle",
6
+ "isMapping": "Mapping",
7
+ "isRunning": "Mowing",
8
+ "isPaused": "Paused",
9
+ "isDocking": "Returning to Dock",
10
+ "Error": "Error",
11
+ "error": "Error",
12
+ "isLifted": "Lifted (Error)",
13
+ "inSoftwareUpdate": "Software Update",
14
+ "Self-Checking": "Self-Checking",
15
+ "Self-checking": "Self-Checking",
16
+ "Offline": "Offline",
17
+ "offline": "Offline"
18
+ },
19
+ "status": {
20
+ "idle": "Idle",
21
+ "mowing": "Mowing",
22
+ "paused": "Paused",
23
+ "docked": "Docked",
24
+ "charging": "Charging",
25
+ "error": "Error",
26
+ "returning": "Returning to Dock",
27
+ "unknown": "Unknown"
28
+ },
29
+ "state": {
30
+ "idle": "Idle",
31
+ "mowing": "Mowing",
32
+ "paused": "Paused",
33
+ "docked": "Docked",
34
+ "charging": "Charging",
35
+ "error": "Error",
36
+ "returning": "Returning to Dock",
37
+ "unknown": "Unknown"
38
+ },
39
+ "error_code": {
40
+ "none": "No Error",
41
+ "stuck": "Stuck",
42
+ "lifted": "Lifted",
43
+ "rain": "Rain",
44
+ "battery_low": "Battery Low",
45
+ "sensor_error": "Sensor Error",
46
+ "motor_error": "Motor Error",
47
+ "blade_error": "Blade Error",
48
+ "unknown": "Unknown Error"
49
+ }
50
+ }
package/main.js ADDED
@@ -0,0 +1,718 @@
1
+ 'use strict';
2
+
3
+ const utils = require('@iobroker/adapter-core');
4
+ const axios = require('axios');
5
+ const Json2iob = require('json2iob');
6
+ const crypto = require('crypto');
7
+ const mqtt = require('mqtt');
8
+ const { URL } = require('url');
9
+ const descriptions = require('./lib/descriptions.json');
10
+ const states = require('./lib/states.json');
11
+
12
+ const API_BASE_URL = 'https://navimow-fra.ninebot.com';
13
+ const OAUTH2_TOKEN_URL = API_BASE_URL + '/openapi/oauth/getAccessToken';
14
+ const CLIENT_ID = 'homeassistant';
15
+ const CLIENT_SECRET = '57056e15-722e-42be-bbaa-b0cbfb208a52';
16
+ const REDIRECT_URI = 'http://localhost:1/callback';
17
+
18
+
19
+ // Command mapping: name -> { command, params }
20
+ const COMMAND_MAP = {
21
+ start: { command: 'action.devices.commands.StartStop', params: { on: true } },
22
+ stop: { command: 'action.devices.commands.StartStop', params: { on: false } },
23
+ pause: { command: 'action.devices.commands.PauseUnpause', params: { on: false } },
24
+ resume: { command: 'action.devices.commands.PauseUnpause', params: { on: true } },
25
+ dock: { command: 'action.devices.commands.Dock', params: null },
26
+ };
27
+
28
+ class Navimow extends utils.Adapter {
29
+ constructor(options) {
30
+ super({
31
+ ...options,
32
+ name: 'navimow',
33
+ });
34
+ this.on('ready', this.onReady.bind(this));
35
+ this.on('stateChange', this.onStateChange.bind(this));
36
+ this.on('unload', this.onUnload.bind(this));
37
+ this.deviceArray = [];
38
+ this.json2iob = new Json2iob(this);
39
+ this.requestClient = axios.create({
40
+ baseURL: API_BASE_URL,
41
+ headers: { 'Content-Type': 'application/json' },
42
+ });
43
+ this.session = {};
44
+ this.updateInterval = null;
45
+ this.refreshTokenTimeout = null;
46
+ this.refreshTimeout = undefined;
47
+ this.mqttClient = null;
48
+ this.mqttConnected = false;
49
+ this.lastMqttMessage = 0;
50
+ }
51
+
52
+ async onReady() {
53
+ this.setState('info.connection', false, true);
54
+ if (this.config.interval < 0.5) {
55
+ this.log.info('Set interval to minimum 0.5');
56
+ this.config.interval = 0.5;
57
+ }
58
+
59
+ this.subscribeStates('*');
60
+
61
+ await this.setObjectNotExistsAsync('auth', {
62
+ type: 'channel',
63
+ common: { name: 'Authentication' },
64
+ native: {},
65
+ });
66
+ await this.setObjectNotExistsAsync('auth.token', {
67
+ type: 'state',
68
+ common: { name: 'Token Data', type: 'string', role: 'json', read: true, write: false },
69
+ native: {},
70
+ });
71
+
72
+ // Step 1: New auth code in config -> exchange for token
73
+ if (this.config.authCode) {
74
+ let authCode = this.config.authCode.trim();
75
+ this.log.debug('Auth code input: ' + authCode.substring(0, 20) + '...');
76
+ // Extract code from full URL if user pasted the entire redirect URL
77
+ if (authCode.startsWith('http')) {
78
+ try {
79
+ const parsed = new URL(authCode);
80
+ authCode = parsed.searchParams.get('code') || authCode;
81
+ this.log.debug('Extracted code from URL: ' + authCode.substring(0, 20) + '...');
82
+ } catch {
83
+ this.log.debug('Auth code is not a valid URL, using as-is');
84
+ }
85
+ }
86
+ this.log.info('Authorization code found in config, exchanging for token...');
87
+ const tokenData = await this.exchangeCodeForToken(authCode);
88
+ if (tokenData) {
89
+ await this.storeToken(tokenData);
90
+ this.log.info('Token obtained. Clearing auth code from config.');
91
+ this.extendForeignObject('system.adapter.' + this.namespace, {
92
+ native: { authCode: '' },
93
+ });
94
+ } else {
95
+ this.log.error('Token exchange failed. Check the authorization code.');
96
+ }
97
+ }
98
+
99
+ // Step 2: Restore stored token and try refresh
100
+ this.log.debug('Loading stored token...');
101
+ const tokenState = await this.getStateAsync('auth.token');
102
+ if (tokenState && tokenState.val) {
103
+ let tokenObj;
104
+ try {
105
+ tokenObj = JSON.parse(/** @type {string} */ (tokenState.val));
106
+ } catch {
107
+ tokenObj = { access_token: tokenState.val };
108
+ }
109
+
110
+ if (tokenObj.refresh_token) {
111
+ this.log.info('Refresh token found, trying to refresh...');
112
+ const refreshed = await this.refreshToken(tokenObj.refresh_token);
113
+ if (refreshed) {
114
+ tokenObj = refreshed;
115
+ await this.storeToken(tokenObj);
116
+ this.log.info('Token refreshed successfully');
117
+ } else {
118
+ this.log.warn('Token refresh failed, using stored access token');
119
+ }
120
+ }
121
+
122
+ if (tokenObj.access_token) {
123
+ this.session = tokenObj;
124
+ this.setState('info.connection', true, true);
125
+ this.log.info('Token loaded (expires_in: ' + (tokenObj.expires_in || 'unknown') + 's)');
126
+ this.log.debug('Access token starts with: ' + tokenObj.access_token.substring(0, 20) + '...');
127
+ await this.getDeviceList();
128
+ this.log.debug('Device array: ' + JSON.stringify(this.deviceArray));
129
+ await this.updateDevices();
130
+
131
+ // Connect MQTT for real-time updates
132
+ await this.connectMqtt();
133
+
134
+ // HTTP polling as fallback only when MQTT is stale (no message for 5 min)
135
+ const pollMs = this.config.interval * 60 * 1000;
136
+ this.updateInterval = setInterval(() => {
137
+ const mqttStale = Date.now() - this.lastMqttMessage > 5 * 60 * 1000;
138
+ if (!this.mqttConnected || mqttStale) {
139
+ this.log.debug('MQTT ' + (this.mqttConnected ? 'stale' : 'disconnected') + ', polling via HTTP');
140
+ this.updateDevices();
141
+ }
142
+ }, pollMs);
143
+
144
+ // Schedule token refresh
145
+ if (tokenObj.expires_in) {
146
+ const refreshMs = (tokenObj.expires_in - 300) * 1000;
147
+ if (refreshMs > 0) {
148
+ this.refreshTokenTimeout = setTimeout(() => {
149
+ this.handleTokenRefresh();
150
+ }, refreshMs);
151
+ this.log.info('Token refresh scheduled in ' + Math.round(refreshMs / 60000) + ' min');
152
+ }
153
+ }
154
+ } else {
155
+ this.log.warn('No valid access token found.');
156
+ }
157
+ } else {
158
+ this.log.warn(
159
+ 'No token found. Open the Navimow login link in adapter settings, copy the code and paste it into the settings.',
160
+ );
161
+ }
162
+ }
163
+
164
+ // ---- MQTT ----
165
+
166
+ connectMqtt() {
167
+ if (this.deviceArray.length === 0) {
168
+ this.log.info('No devices, skipping MQTT');
169
+ return Promise.resolve();
170
+ }
171
+
172
+ return this.requestClient({
173
+ method: 'get',
174
+ url: '/openapi/mqtt/userInfo/get/v2',
175
+ headers: this.getAuthHeaders(),
176
+ })
177
+ .then((res) => {
178
+ this.log.debug('MQTT info: ' + JSON.stringify(res.data));
179
+ if (!res.data || res.data.code !== 1) {
180
+ this.log.warn('Failed to get MQTT info: ' + JSON.stringify(res.data));
181
+ return;
182
+ }
183
+ const mqttInfo = res.data.data ?? {};
184
+ const mqttUrlRaw = mqttInfo.mqttUrl;
185
+ const mqttHost = mqttInfo.mqttHost || 'mqtt.navimow.com';
186
+ const mqttUsername = mqttInfo.userName;
187
+ const mqttPassword = mqttInfo.pwdInfo;
188
+
189
+ let brokerUrl;
190
+ const mqttOpts = {
191
+ username: mqttUsername,
192
+ password: mqttPassword,
193
+ clientId: 'web_' + (mqttUsername || 'iobroker') + '_' + crypto.randomUUID().substring(0, 10),
194
+ keepalive: 60,
195
+ reconnectPeriod: 10000,
196
+ };
197
+
198
+ if (mqttUrlRaw) {
199
+ // WebSocket mode
200
+ try {
201
+ const parsed = new URL(mqttUrlRaw);
202
+ const wsScheme = parsed.protocol === 'wss:' ? 'wss' : 'ws';
203
+ const wsPort = parsed.port || (wsScheme === 'wss' ? 443 : 80);
204
+ const wsPath = (parsed.pathname || '/') + (parsed.search || '');
205
+ brokerUrl = wsScheme + '://' + (parsed.hostname || mqttHost) + ':' + wsPort + wsPath;
206
+ mqttOpts.wsOptions = {
207
+ headers: { Authorization: 'Bearer ' + this.session.access_token },
208
+ };
209
+ if (wsScheme === 'wss') {
210
+ mqttOpts.rejectUnauthorized = true;
211
+ }
212
+ } catch {
213
+ // Fallback: treat mqttUrl as ws path
214
+ brokerUrl = 'wss://' + mqttHost + ':443' + mqttUrlRaw;
215
+ mqttOpts.wsOptions = {
216
+ headers: { Authorization: 'Bearer ' + this.session.access_token },
217
+ };
218
+ }
219
+ } else {
220
+ // TCP mode
221
+ brokerUrl = 'mqtt://' + mqttHost + ':1883';
222
+ }
223
+
224
+ this.log.info('MQTT connecting to ' + brokerUrl);
225
+ this.log.debug('MQTT clientId: ' + mqttOpts.clientId);
226
+ this.log.debug('MQTT username: ' + (mqttUsername || 'none'));
227
+ this.mqttClient = mqtt.connect(brokerUrl, mqttOpts);
228
+
229
+ this.mqttClient.on('connect', () => {
230
+ this.log.info('MQTT connected');
231
+ this.mqttConnected = true;
232
+ // Subscribe to device topics
233
+ for (const deviceId of this.deviceArray) {
234
+ const topics = [
235
+ '/downlink/vehicle/' + deviceId + '/realtimeDate/state',
236
+ '/downlink/vehicle/' + deviceId + '/realtimeDate/event',
237
+ '/downlink/vehicle/' + deviceId + '/realtimeDate/attributes',
238
+ ];
239
+ for (const topic of topics) {
240
+ this.mqttClient && this.mqttClient.subscribe(topic, (err) => {
241
+ if (err) {
242
+ this.log.error('MQTT subscribe error for ' + topic + ': ' + err.message);
243
+ } else {
244
+ this.log.debug('MQTT subscribed to ' + topic);
245
+ }
246
+ });
247
+ }
248
+ }
249
+ });
250
+
251
+ this.mqttClient.on('message', (topic, payload) => {
252
+ this.lastMqttMessage = Date.now();
253
+ this.handleMqttMessage(topic, payload);
254
+ });
255
+
256
+ this.mqttClient.on('error', (err) => {
257
+ this.log.error('MQTT error: ' + err.message);
258
+ if ('code' in err) {
259
+ this.log.debug('MQTT error code: ' + /** @type {any} */ (err).code);
260
+ }
261
+ });
262
+
263
+ this.mqttClient.on('close', () => {
264
+ this.log.info('MQTT connection closed');
265
+ this.mqttConnected = false;
266
+ });
267
+
268
+ this.mqttClient.on('reconnect', () => {
269
+ this.log.debug('MQTT reconnecting...');
270
+ });
271
+ })
272
+ .catch((error) => {
273
+ this.log.warn('MQTT setup failed: ' + error.message);
274
+ error.response && this.log.debug(JSON.stringify(error.response.data));
275
+ });
276
+ }
277
+
278
+ handleMqttMessage(topic, payload) {
279
+ try {
280
+ const parts = topic.split('/').filter((p) => p !== '');
281
+ // Expected: downlink/vehicle/{device_id}/realtimeDate/{channel}
282
+ if (parts.length !== 5 || parts[0] !== 'downlink' || parts[1] !== 'vehicle') {
283
+ this.log.debug('MQTT unknown topic: ' + topic);
284
+ return;
285
+ }
286
+ const deviceId = parts[2];
287
+ const channel = parts[4]; // state, event, attributes
288
+
289
+ if (!this.deviceArray.includes(deviceId)) {
290
+ this.log.debug('MQTT message for unknown device: ' + deviceId);
291
+ return;
292
+ }
293
+
294
+ const data = JSON.parse(payload.toString());
295
+ data.device_id = data.device_id || deviceId;
296
+
297
+ this.log.debug('MQTT ' + channel + ' for ' + deviceId + ': ' + JSON.stringify(data));
298
+
299
+ if (channel === 'state') {
300
+ this.setState(deviceId + '.status.json', JSON.stringify(data), true);
301
+ this.json2iob.parse(deviceId + '.status', data, {
302
+ forceIndex: true,
303
+ channelName: 'Status',
304
+ descriptions,
305
+ states,
306
+ });
307
+ } else if (channel === 'event') {
308
+ this.json2iob.parse(deviceId + '.events', data, {
309
+ forceIndex: true,
310
+ channelName: 'Events',
311
+ descriptions,
312
+ states,
313
+ });
314
+ } else if (channel === 'attributes') {
315
+ this.json2iob.parse(deviceId + '.attributes', data, {
316
+ forceIndex: true,
317
+ channelName: 'Attributes',
318
+ descriptions,
319
+ states,
320
+ });
321
+ }
322
+ } catch (e) {
323
+ this.log.error('MQTT message parse error: ' + e.message);
324
+ }
325
+ }
326
+
327
+ /**
328
+ * Map vehicleState to the active remote command
329
+ * @param {string} deviceId
330
+ * @param {string} vehicleState
331
+ */
332
+ updateRemoteStates(deviceId, vehicleState) {
333
+ const stateToRemote = {
334
+ isRunning: 'start',
335
+ mowing: 'start',
336
+ isPaused: 'pause',
337
+ paused: 'pause',
338
+ isDocking: 'dock',
339
+ returning: 'dock',
340
+ isDocked: 'dock',
341
+ docked: 'dock',
342
+ charging: 'dock',
343
+ isIdle: 'stop',
344
+ isIdel: 'stop',
345
+ idle: 'stop',
346
+ };
347
+ const activeCmd = stateToRemote[vehicleState] || null;
348
+ for (const cmd of Object.keys(COMMAND_MAP)) {
349
+ this.setState(deviceId + '.remote.' + cmd, cmd === activeCmd, true);
350
+ }
351
+ }
352
+
353
+ disconnectMqtt() {
354
+ if (this.mqttClient) {
355
+ this.mqttClient.end(true);
356
+ this.mqttClient = null;
357
+ this.mqttConnected = false;
358
+ this.log.info('MQTT disconnected');
359
+ }
360
+ }
361
+
362
+ // ---- Token Management ----
363
+
364
+ async storeToken(tokenData) {
365
+ this.session = tokenData;
366
+ await this.setStateAsync('auth.token', { val: JSON.stringify(tokenData), ack: true });
367
+ this.log.info('Token stored');
368
+ }
369
+
370
+ getAuthHeaders() {
371
+ return {
372
+ Authorization: 'Bearer ' + this.session.access_token,
373
+ 'Content-Type': 'application/json',
374
+ requestId: crypto.randomUUID(),
375
+ };
376
+ }
377
+
378
+ exchangeCodeForToken(code) {
379
+ this.log.debug('Exchanging auth code for token (code length: ' + code.length + ')');
380
+ return this.requestClient({
381
+ method: 'post',
382
+ url: OAUTH2_TOKEN_URL,
383
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
384
+ data: {
385
+ grant_type: 'authorization_code',
386
+ code: code,
387
+ client_id: CLIENT_ID,
388
+ client_secret: CLIENT_SECRET,
389
+ redirect_uri: REDIRECT_URI,
390
+ },
391
+ })
392
+ .then((res) => {
393
+ this.log.debug(JSON.stringify(res.data));
394
+ if (res.data && res.data.access_token) {
395
+ return res.data;
396
+ }
397
+ this.log.error('Token exchange failed: ' + JSON.stringify(res.data));
398
+ return null;
399
+ })
400
+ .catch((error) => {
401
+ this.log.error('Token exchange error: ' + error.message);
402
+ error.response && this.log.error(JSON.stringify(error.response.data));
403
+ return null;
404
+ });
405
+ }
406
+
407
+ refreshToken(refreshTokenValue) {
408
+ this.log.debug('Refreshing token...');
409
+ return this.requestClient({
410
+ method: 'post',
411
+ url: OAUTH2_TOKEN_URL,
412
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
413
+ data: {
414
+ grant_type: 'refresh_token',
415
+ refresh_token: refreshTokenValue,
416
+ client_id: CLIENT_ID,
417
+ client_secret: CLIENT_SECRET,
418
+ },
419
+ })
420
+ .then((res) => {
421
+ this.log.debug(JSON.stringify(res.data));
422
+ if (res.data && res.data.access_token) {
423
+ return res.data;
424
+ }
425
+ this.log.warn('Token refresh returned no token: ' + JSON.stringify(res.data));
426
+ return null;
427
+ })
428
+ .catch((error) => {
429
+ this.log.warn('Token refresh failed: ' + error.message);
430
+ error.response && this.log.debug(JSON.stringify(error.response.data));
431
+ return null;
432
+ });
433
+ }
434
+
435
+ async handleTokenRefresh() {
436
+ if (!this.session.refresh_token) {
437
+ this.log.warn('No refresh token available. Please re-login via settings.');
438
+ return;
439
+ }
440
+ const tokenData = await this.refreshToken(this.session.refresh_token);
441
+ if (tokenData) {
442
+ await this.storeToken(tokenData);
443
+ this.log.info('Token refreshed successfully');
444
+ // Reconnect MQTT with new token
445
+ this.log.debug('Reconnecting MQTT with new token...');
446
+ this.disconnectMqtt();
447
+ this.connectMqtt();
448
+ if (tokenData.expires_in) {
449
+ const refreshMs = (tokenData.expires_in - 300) * 1000;
450
+ if (refreshMs > 0) {
451
+ this.refreshTokenTimeout = setTimeout(() => {
452
+ this.handleTokenRefresh();
453
+ }, refreshMs);
454
+ }
455
+ }
456
+ } else {
457
+ this.log.error('Token refresh failed. Please re-login via settings.');
458
+ this.setState('info.connection', false, true);
459
+ }
460
+ }
461
+
462
+ // ---- REST API ----
463
+
464
+ getDeviceList() {
465
+ return this.requestClient({
466
+ method: 'get',
467
+ url: '/openapi/smarthome/authList',
468
+ headers: this.getAuthHeaders(),
469
+ })
470
+ .then(async (res) => {
471
+ this.log.debug(JSON.stringify(res.data));
472
+ if (res.data && res.data.code !== 1) {
473
+ this.log.error('getDeviceList failed: ' + (res.data.desc || JSON.stringify(res.data)));
474
+ return;
475
+ }
476
+ const devices = res.data.data?.payload?.devices || [];
477
+ if (devices.length === 0) {
478
+ this.log.warn('No devices found');
479
+ return;
480
+ }
481
+
482
+ this.deviceArray = [];
483
+ for (const device of devices) {
484
+ const id = device.id;
485
+ if (!id) {
486
+ continue;
487
+ }
488
+ this.deviceArray.push(id);
489
+ const name = device.name || id;
490
+
491
+ await this.setObjectNotExistsAsync(id, {
492
+ type: 'device',
493
+ common: { name: name },
494
+ native: {},
495
+ });
496
+ await this.setObjectNotExistsAsync(id + '.remote', {
497
+ type: 'channel',
498
+ common: { name: 'Remote Controls' },
499
+ native: {},
500
+ });
501
+ await this.setObjectNotExistsAsync(id + '.status', {
502
+ type: 'channel',
503
+ common: { name: 'Status' },
504
+ native: {},
505
+ });
506
+ await this.setObjectNotExistsAsync(id + '.status.json', {
507
+ type: 'state',
508
+ common: { name: 'Raw JSON', write: false, read: true, type: 'string', role: 'json' },
509
+ native: {},
510
+ });
511
+
512
+ const remoteArray = [
513
+ { command: 'Refresh', name: 'Refresh status' },
514
+ { command: 'start', name: 'Start mowing' },
515
+ { command: 'stop', name: 'Stop mowing' },
516
+ { command: 'pause', name: 'Pause mowing' },
517
+ { command: 'resume', name: 'Resume mowing' },
518
+ { command: 'dock', name: 'Return to dock' },
519
+ ];
520
+ for (const remote of remoteArray) {
521
+ await this.setObjectNotExistsAsync(id + '.remote.' + remote.command, {
522
+ type: 'state',
523
+ common: {
524
+ name: remote.name,
525
+ type: 'boolean',
526
+ role: 'button',
527
+ def: false,
528
+ write: true,
529
+ read: true,
530
+ },
531
+ native: {},
532
+ });
533
+ }
534
+ this.json2iob.parse(id + '.general', device, { descriptions, states });
535
+ }
536
+ this.log.info('Found ' + devices.length + ' device(s)');
537
+ })
538
+ .catch((error) => {
539
+ this.log.error('getDeviceList error: ' + error.message);
540
+ error.response && this.log.error(JSON.stringify(error.response.data));
541
+ });
542
+ }
543
+
544
+ updateDevices() {
545
+ if (this.deviceArray.length === 0) {
546
+ return Promise.resolve();
547
+ }
548
+ if (!this.session.access_token) {
549
+ this.log.warn('No access token available. Please login first.');
550
+ return Promise.resolve();
551
+ }
552
+
553
+ return this.requestClient({
554
+ method: 'post',
555
+ url: '/openapi/smarthome/getVehicleStatus',
556
+ headers: this.getAuthHeaders(),
557
+ data: {
558
+ devices: this.deviceArray.map((id) => ({ id: id })),
559
+ },
560
+ })
561
+ .then((res) => {
562
+ this.log.debug(JSON.stringify(res.data));
563
+ if (!res.data || res.data.code !== 1) {
564
+ this.log.error(
565
+ 'updateDevices failed: ' + ((res.data && res.data.desc) || JSON.stringify(res.data)),
566
+ );
567
+ return;
568
+ }
569
+ const devices = res.data.data?.payload?.devices || [];
570
+
571
+ for (const deviceData of devices) {
572
+ const id = deviceData.id || deviceData.device_id;
573
+ if (!id || !this.deviceArray.includes(id)) {
574
+ continue;
575
+ }
576
+
577
+ this.setState(id + '.status.json', JSON.stringify(deviceData), true);
578
+
579
+ this.json2iob.parse(id + '.status', deviceData, {
580
+ forceIndex: true,
581
+ channelName: 'Status',
582
+ descriptions,
583
+ states,
584
+ });
585
+
586
+ }
587
+ })
588
+ .catch((error) => {
589
+ if (error.response && error.response.status === 401) {
590
+ this.log.warn('Token expired (401). Trying refresh...');
591
+ this.setState('info.connection', false, true);
592
+ this.handleTokenRefresh();
593
+ return;
594
+ }
595
+ this.log.error('updateDevices error: ' + error.message);
596
+ error.response && this.log.error(JSON.stringify(error.response.data));
597
+ });
598
+ }
599
+
600
+ sendMowerCommand(deviceId, commandName) {
601
+ const mapping = COMMAND_MAP[commandName];
602
+ if (!mapping) {
603
+ this.log.error('Unknown command: ' + commandName);
604
+ return Promise.resolve();
605
+ }
606
+
607
+ const execution = { command: mapping.command };
608
+ if (mapping.params) {
609
+ execution.params = mapping.params;
610
+ }
611
+
612
+ this.log.info('Sending command "' + commandName + '" to device ' + deviceId);
613
+ this.log.debug('Command payload: ' + JSON.stringify(execution));
614
+
615
+ return this.requestClient({
616
+ method: 'post',
617
+ url: '/openapi/smarthome/sendCommands',
618
+ headers: this.getAuthHeaders(),
619
+ data: {
620
+ commands: [
621
+ {
622
+ devices: [{ id: deviceId }],
623
+ execution: execution,
624
+ },
625
+ ],
626
+ },
627
+ })
628
+ .then((res) => {
629
+ this.log.debug(JSON.stringify(res.data));
630
+ if (!res.data || res.data.code !== 1) {
631
+ this.log.error(
632
+ 'Command failed: ' + ((res.data && res.data.desc) || JSON.stringify(res.data)),
633
+ );
634
+ return;
635
+ }
636
+ const results = res.data.data?.payload?.commands || [];
637
+ for (const result of results) {
638
+ if (result.status === 'ERROR' && result.errorCode !== 'alreadyInState') {
639
+ this.log.error('Command error: ' + (result.errorCode || 'unknown'));
640
+ }
641
+ }
642
+ this.log.info('Command "' + commandName + '" sent successfully');
643
+ clearTimeout(this.refreshTimeout);
644
+ this.refreshTimeout = setTimeout(() => {
645
+ this.updateDevices();
646
+ }, 5 * 1000);
647
+ })
648
+ .catch((error) => {
649
+ if (error.response && error.response.status === 401) {
650
+ this.log.warn('Token expired (401). Trying refresh...');
651
+ this.setState('info.connection', false, true);
652
+ this.handleTokenRefresh();
653
+ return;
654
+ }
655
+ this.log.error('sendCommand error: ' + error.message);
656
+ error.response && this.log.error(JSON.stringify(error.response.data));
657
+ });
658
+ }
659
+
660
+ // ---- State Changes ----
661
+
662
+ onStateChange(id, state) {
663
+ if (!state) {
664
+ return;
665
+ }
666
+ const parts = id.split('.');
667
+ const deviceId = parts[2];
668
+ const channel = parts[3];
669
+ const command = parts[4];
670
+
671
+ // ack:true = device confirmed value -> reset remote buttons on state change
672
+ if (state.ack) {
673
+ if (channel === 'status' && (command === 'vehicleState' || command === 'state' || command === 'status')) {
674
+ this.log.debug('Device state changed to "' + state.val + '", updating remote states');
675
+ this.updateRemoteStates(deviceId, String(state.val));
676
+ }
677
+ return;
678
+ }
679
+
680
+ // ack:false = user action -> handle remote commands
681
+ if (channel !== 'remote') {
682
+ return;
683
+ }
684
+
685
+ this.log.debug('Remote command triggered: ' + command + ' for device ' + deviceId);
686
+
687
+ if (command === 'Refresh') {
688
+ this.updateDevices();
689
+ return;
690
+ }
691
+
692
+ if (COMMAND_MAP[command]) {
693
+ this.sendMowerCommand(deviceId, command);
694
+ } else {
695
+ this.log.warn('Unknown remote command: ' + command);
696
+ }
697
+ }
698
+
699
+ onUnload(callback) {
700
+ try {
701
+ this.log.debug('Adapter unloading, cleaning up...');
702
+ this.setState('info.connection', false, true);
703
+ this.disconnectMqtt();
704
+ this.updateInterval && clearInterval(this.updateInterval);
705
+ this.refreshTokenTimeout && clearTimeout(this.refreshTokenTimeout);
706
+ this.refreshTimeout && clearTimeout(this.refreshTimeout);
707
+ callback();
708
+ } catch {
709
+ callback();
710
+ }
711
+ }
712
+ }
713
+
714
+ if (require.main !== module) {
715
+ module.exports = (options) => new Navimow(options);
716
+ } else {
717
+ new Navimow();
718
+ }
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "iobroker.navimow",
3
+ "version": "1.0.0",
4
+ "description": "Adapter for NaviMower from Segway",
5
+ "author": {
6
+ "name": "TA2k",
7
+ "email": "tombox2020@gmail.com"
8
+ },
9
+ "homepage": "https://github.com/TA2k/ioBroker.navimow",
10
+ "license": "MIT",
11
+ "keywords": [
12
+ "ioBroker",
13
+ "navimower",
14
+ "segway"
15
+ ],
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "https://github.com/TA2k/ioBroker.navimow.git"
19
+ },
20
+ "engines": {
21
+ "node": ">= 20"
22
+ },
23
+ "dependencies": {
24
+ "@iobroker/adapter-core": "^3.3.2",
25
+ "axios": "^1.13.6",
26
+ "json2iob": "^2.6.22",
27
+ "mqtt": "^5.15.0"
28
+ },
29
+ "devDependencies": {
30
+ "@eslint/js": "^10.0.1",
31
+ "@iobroker/testing": "^5.2.2",
32
+ "@tsconfig/node22": "^22.0.5",
33
+ "@types/node": "^25.5.0",
34
+ "eslint": "^10.0.3",
35
+ "eslint-config-prettier": "^10.1.8",
36
+ "eslint-plugin-prettier": "^5.5.5",
37
+ "prettier": "^3.8.1",
38
+ "@alcalzone/release-script": "^5.1.1",
39
+ "@alcalzone/release-script-plugin-iobroker": "^5.1.2",
40
+ "@alcalzone/release-script-plugin-license": "^5.1.1",
41
+ "@alcalzone/release-script-plugin-manual-review": "^5.1.1",
42
+ "typescript": "~5.9.3"
43
+ },
44
+ "main": "main.js",
45
+ "files": [
46
+ "admin{,/!(src)/**}/!(tsconfig|tsconfig.*|.eslintrc).{json,json5}",
47
+ "admin{,/!(src)/**}/*.{html,css,png,svg,jpg,js}",
48
+ "lib/",
49
+ "www/",
50
+ "io-package.json",
51
+ "LICENSE",
52
+ "main.js"
53
+ ],
54
+ "scripts": {
55
+ "test:js": "mocha --config test/mocharc.custom.json \"{!(node_modules|test)/**/*.test.js,*.test.js,test/**/test!(PackageFiles|Startup).js}\"",
56
+ "test:package": "mocha test/package --exit",
57
+ "test:integration": "mocha test/integration --exit",
58
+ "test": "npm run test:js && npm run test:package",
59
+ "check": "tsc --noEmit -p tsconfig.check.json",
60
+ "lint": "eslint .",
61
+ "translate": "translate-adapter",
62
+ "release": "release-script"
63
+ },
64
+ "bugs": {
65
+ "url": "https://github.com/TA2k/ioBroker.navimow/issues"
66
+ },
67
+ "readmeFilename": "README.md"
68
+ }