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 +21 -0
- package/README.md +107 -0
- package/admin/jsonConfig.json +58 -0
- package/admin/navimow.png +0 -0
- package/io-package.json +131 -0
- package/lib/adapter-config.d.ts +19 -0
- package/lib/descriptions.json +38 -0
- package/lib/states.json +50 -0
- package/main.js +718 -0
- package/package.json +68 -0
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
|
+

|
|
2
|
+
|
|
3
|
+
# ioBroker.navimow
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/iobroker.navimow)
|
|
6
|
+
[](https://www.npmjs.com/package/iobroker.navimow)
|
|
7
|
+

|
|
8
|
+

|
|
9
|
+
[](https://github.com/TA2k/ioBroker.navimow/blob/main/LICENSE)
|
|
10
|
+
[](https://github.com/TA2k/ioBroker.navimow/issues)
|
|
11
|
+
[](https://github.com/TA2k/ioBroker.navimow/commits/main)
|
|
12
|
+
[](https://www.npmjs.com/package/iobroker.navimow)
|
|
13
|
+
|
|
14
|
+
[](https://nodei.co/npm/iobroker.navimow/)
|
|
15
|
+
|
|
16
|
+
**Tests:** 
|
|
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
|
package/io-package.json
ADDED
|
@@ -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
|
+
}
|
package/lib/states.json
ADDED
|
@@ -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
|
+
}
|