@diveflo/matterbridge-mova 0.1.18

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.
@@ -0,0 +1,22 @@
1
+ import { MatterbridgeDynamicPlatform, type PlatformMatterbridge, PlatformConfig } from 'matterbridge';
2
+ import { AnsiLogger, LogLevel } from 'matterbridge/logger';
3
+ export declare class MovaPlatform extends MatterbridgeDynamicPlatform {
4
+ private cloud;
5
+ private devices;
6
+ private cloudDevices;
7
+ private statusInterval;
8
+ private devicePollingTimers;
9
+ private refreshInterval;
10
+ private lastDeviceState;
11
+ constructor(matterbridge: PlatformMatterbridge, log: AnsiLogger, config: PlatformConfig);
12
+ onStart(reason?: string): Promise<void>;
13
+ onConfigure(): Promise<void>;
14
+ onChangeLoggerLevel(logLevel: LogLevel): Promise<void>;
15
+ onShutdown(reason?: string): Promise<void>;
16
+ private loadCachedRooms;
17
+ private saveCachedRooms;
18
+ private discoverDevices;
19
+ private startStatusPolling;
20
+ private scheduleDevicePoll;
21
+ private handleDeviceStatus;
22
+ }
@@ -0,0 +1,246 @@
1
+ import { MatterbridgeDynamicPlatform } from 'matterbridge';
2
+ import { MovaCloudProtocol } from './movaCloud.js';
3
+ import { discoverAndRegisterDevices } from './mova.js';
4
+ import { MovaState } from './types.js';
5
+ const STORAGE_KEYS = {
6
+ rooms: (did) => `rooms_${did}`,
7
+ };
8
+ const POLLING_INTERVALS = {
9
+ active: 15,
10
+ idle: 120,
11
+ };
12
+ const ACTIVE_STATES = new Set([
13
+ MovaState.Cleaning,
14
+ MovaState.Mopping,
15
+ MovaState.GoCharging,
16
+ MovaState.Returning,
17
+ MovaState.Washing,
18
+ MovaState.Drying,
19
+ MovaState.Defecating,
20
+ MovaState.Building,
21
+ MovaState.ManualCleaning,
22
+ MovaState.ZonedCleaning,
23
+ MovaState.SpotCleaning,
24
+ MovaState.FastMapping,
25
+ MovaState.SecondCleaning,
26
+ MovaState.StationCleaning,
27
+ MovaState.Emptying,
28
+ MovaState.ReturningAutoEmpty,
29
+ MovaState.CleaningAutoEmpty,
30
+ ]);
31
+ export class MovaPlatform extends MatterbridgeDynamicPlatform {
32
+ cloud;
33
+ devices = new Map();
34
+ cloudDevices = new Map();
35
+ statusInterval = null;
36
+ devicePollingTimers = new Map();
37
+ refreshInterval;
38
+ lastDeviceState = new Map();
39
+ constructor(matterbridge, log, config) {
40
+ super(matterbridge, log, config);
41
+ if (this.verifyMatterbridgeVersion === undefined || typeof this.verifyMatterbridgeVersion !== 'function' || !this.verifyMatterbridgeVersion('3.4.0')) {
42
+ throw new Error(`This plugin requires Matterbridge version >= "3.4.0". Please update Matterbridge from ${this.matterbridge.matterbridgeVersion} to the latest version.`);
43
+ }
44
+ this.cloud = new MovaCloudProtocol(log);
45
+ this.refreshInterval = config.refreshInterval ?? 120;
46
+ if (this.refreshInterval < 30) {
47
+ this.refreshInterval = 30;
48
+ this.log.warn('Refresh interval too low, setting to 30 seconds');
49
+ }
50
+ this.log.info('Mova Platform initialized');
51
+ }
52
+ async onStart(reason) {
53
+ this.log.info(`onStart called with reason: ${reason ?? 'none'}`);
54
+ await this.ready;
55
+ const config = this.config;
56
+ if (!config.username || !config.password) {
57
+ this.log.error('Missing username or password in configuration');
58
+ return;
59
+ }
60
+ if (!config.country) {
61
+ this.log.error('Missing country/region in configuration');
62
+ return;
63
+ }
64
+ const loginResult = await this.cloud.login(config.username, config.password, config.country);
65
+ if (!loginResult.success) {
66
+ this.log.error(`Failed to login to Mova Cloud: ${loginResult.error}`);
67
+ return;
68
+ }
69
+ await this.discoverDevices();
70
+ }
71
+ async onConfigure() {
72
+ await super.onConfigure();
73
+ this.log.info('onConfigure called');
74
+ this.startStatusPolling();
75
+ for (const [did, device] of this.devices) {
76
+ this.log.info(`Configuring device: ${device.name} (${did})`);
77
+ const cloudDevice = this.cloudDevices.get(did);
78
+ if (cloudDevice) {
79
+ const mqttConnected = await this.cloud.connectMqtt(cloudDevice);
80
+ if (mqttConnected) {
81
+ this.log.info(`MQTT connected for ${device.name}`);
82
+ }
83
+ else {
84
+ this.log.info(`Using cloud polling for ${device.name}`);
85
+ }
86
+ }
87
+ this.cloud.onDeviceStatus(did, (status) => {
88
+ this.handleDeviceStatus(did, status);
89
+ });
90
+ this.cloud.onRoomUpdate(did, (rooms) => {
91
+ this.log.info(`Received ${rooms.length} rooms via MQTT for ${device.name}`);
92
+ device.updateRooms(rooms);
93
+ this.saveCachedRooms(did, rooms).catch((err) => this.log.debug(`Failed to save rooms: ${err}`));
94
+ });
95
+ }
96
+ }
97
+ async onChangeLoggerLevel(logLevel) {
98
+ this.log.info(`onChangeLoggerLevel called with: ${logLevel}`);
99
+ }
100
+ async onShutdown(reason) {
101
+ await super.onShutdown(reason);
102
+ this.log.info(`onShutdown called with reason: ${reason ?? 'none'}`);
103
+ if (this.statusInterval) {
104
+ clearInterval(this.statusInterval);
105
+ this.statusInterval = null;
106
+ }
107
+ for (const [did, timer] of this.devicePollingTimers) {
108
+ clearTimeout(timer);
109
+ this.log.debug(`Stopped polling timer for ${did}`);
110
+ }
111
+ this.devicePollingTimers.clear();
112
+ this.lastDeviceState.clear();
113
+ await this.cloud.disconnect();
114
+ if (this.config.unregisterOnShutdown === true) {
115
+ await this.unregisterAllDevices();
116
+ }
117
+ }
118
+ async loadCachedRooms(did) {
119
+ try {
120
+ if (!this.context) {
121
+ return [];
122
+ }
123
+ const key = STORAGE_KEYS.rooms(did);
124
+ const cached = await this.context.get(key);
125
+ if (cached && Array.isArray(cached) && cached.length > 0) {
126
+ this.log.info(`Loaded ${cached.length} cached rooms from storage for ${did}`);
127
+ return cached;
128
+ }
129
+ }
130
+ catch (error) {
131
+ this.log.debug(`Failed to load cached rooms: ${error}`);
132
+ }
133
+ return [];
134
+ }
135
+ async saveCachedRooms(did, rooms) {
136
+ try {
137
+ if (!this.context || rooms.length === 0) {
138
+ return;
139
+ }
140
+ const key = STORAGE_KEYS.rooms(did);
141
+ await this.context.set(key, rooms);
142
+ this.log.info(`Saved ${rooms.length} rooms to storage for ${did}`);
143
+ }
144
+ catch (error) {
145
+ this.log.debug(`Failed to save cached rooms: ${error}`);
146
+ }
147
+ }
148
+ async discoverDevices() {
149
+ this.log.info('Discovering Mova vacuums...');
150
+ const cloudDevices = await this.cloud.getDevices();
151
+ if (cloudDevices.length === 0) {
152
+ this.log.warn('No Mova vacuums found');
153
+ return;
154
+ }
155
+ for (const cloudDevice of cloudDevices) {
156
+ try {
157
+ this.log.info(`Found vacuum: ${cloudDevice.name} (${cloudDevice.model})`);
158
+ let rooms = await this.cloud.getRoomInfo(cloudDevice.did);
159
+ if (rooms.length === 0) {
160
+ this.log.info(`No cached rooms, trying proactive cloud storage fetch for ${cloudDevice.name}...`);
161
+ const fetchedRooms = await this.cloud.tryFetchMapOnStartup(cloudDevice.did);
162
+ if (fetchedRooms) {
163
+ rooms = this.cloud.getCachedRooms(cloudDevice.did);
164
+ }
165
+ }
166
+ if (rooms.length === 0) {
167
+ this.log.info(`Trying to load rooms from persistent storage for ${cloudDevice.name}...`);
168
+ rooms = await this.loadCachedRooms(cloudDevice.did);
169
+ }
170
+ if (rooms.length === 0) {
171
+ this.log.warn(`No rooms found for ${cloudDevice.name}. Device may be sleeping.`);
172
+ this.log.warn('Tip: Start a cleaning cycle to wake the device and fetch room data.');
173
+ }
174
+ else {
175
+ await this.saveCachedRooms(cloudDevice.did, rooms);
176
+ }
177
+ this.log.info(`Found ${rooms.length} rooms for ${cloudDevice.name}`);
178
+ const status = await this.cloud.getDeviceProperties(cloudDevice.did);
179
+ const device = await discoverAndRegisterDevices(this, this.cloud, cloudDevice, rooms, status);
180
+ if (device) {
181
+ this.devices.set(cloudDevice.did, device);
182
+ this.cloudDevices.set(cloudDevice.did, cloudDevice);
183
+ }
184
+ }
185
+ catch (error) {
186
+ this.log.error(`Failed to register device ${cloudDevice.name}: ${error}`);
187
+ }
188
+ }
189
+ this.log.info(`Registered ${this.devices.size} Mova vacuum(s)`);
190
+ }
191
+ startStatusPolling() {
192
+ if (this.statusInterval) {
193
+ return;
194
+ }
195
+ this.log.info('Starting adaptive status polling');
196
+ for (const [did] of this.devices) {
197
+ this.scheduleDevicePoll(did, POLLING_INTERVALS.idle);
198
+ }
199
+ this.statusInterval = setInterval(async () => {
200
+ for (const [did] of this.devices) {
201
+ if (!this.devicePollingTimers.has(did)) {
202
+ this.scheduleDevicePoll(did, POLLING_INTERVALS.idle);
203
+ }
204
+ }
205
+ }, this.refreshInterval * 1000);
206
+ }
207
+ scheduleDevicePoll(did, intervalSeconds) {
208
+ const existingTimer = this.devicePollingTimers.get(did);
209
+ if (existingTimer) {
210
+ clearTimeout(existingTimer);
211
+ }
212
+ const timer = setTimeout(async () => {
213
+ this.devicePollingTimers.delete(did);
214
+ try {
215
+ const status = await this.cloud.getDeviceProperties(did);
216
+ if (status) {
217
+ this.handleDeviceStatus(did, status);
218
+ const isActive = ACTIVE_STATES.has(status.state);
219
+ const nextInterval = isActive ? POLLING_INTERVALS.active : Math.max(this.refreshInterval, POLLING_INTERVALS.idle);
220
+ const lastState = this.lastDeviceState.get(did);
221
+ if (lastState !== undefined && ACTIVE_STATES.has(lastState) !== isActive) {
222
+ this.log.info(`Device ${did} state changed: polling interval now ${nextInterval}s (${isActive ? 'active' : 'idle'})`);
223
+ }
224
+ this.lastDeviceState.set(did, status.state);
225
+ this.scheduleDevicePoll(did, nextInterval);
226
+ }
227
+ else {
228
+ this.scheduleDevicePoll(did, Math.max(this.refreshInterval, POLLING_INTERVALS.idle));
229
+ }
230
+ }
231
+ catch (error) {
232
+ this.log.error(`Failed to get status for ${did}: ${error}`);
233
+ this.scheduleDevicePoll(did, Math.max(this.refreshInterval, POLLING_INTERVALS.idle));
234
+ }
235
+ }, intervalSeconds * 1000);
236
+ this.devicePollingTimers.set(did, timer);
237
+ }
238
+ handleDeviceStatus(did, status) {
239
+ const device = this.devices.get(did);
240
+ if (!device) {
241
+ return;
242
+ }
243
+ this.log.debug(`Status update for ${device.name}: state=${status.state}, status=${status.status}, battery=${status.battery}%`);
244
+ device.updateStatus(status);
245
+ }
246
+ }
@@ -0,0 +1,247 @@
1
+ export interface MovaConfig {
2
+ username: string;
3
+ password: string;
4
+ country: MovaCountry;
5
+ refreshInterval?: number;
6
+ unregisterOnShutdown?: boolean;
7
+ suctionLevel?: MovaSuctionLevelName;
8
+ vacuumAndMopMode?: MovaVacuumAndMopMode;
9
+ }
10
+ export type MovaCountry = 'cn' | 'eu' | 'us' | 'sg' | 'ru';
11
+ export type MovaSuctionLevelName = 'quiet' | 'standard' | 'strong' | 'turbo';
12
+ export type MovaVacuumAndMopMode = 'vac-mop' | 'vac-then-mop';
13
+ export interface AuthResult {
14
+ success: boolean;
15
+ token?: string;
16
+ refreshToken?: string;
17
+ expiresAt?: number;
18
+ userId?: string;
19
+ error?: string;
20
+ }
21
+ export interface CloudSession {
22
+ token: string;
23
+ refreshToken: string;
24
+ expiresAt: number;
25
+ userId: string;
26
+ country: MovaCountry;
27
+ mqttKey?: string;
28
+ }
29
+ export interface MovaDevice {
30
+ did: string;
31
+ name: string;
32
+ model: string;
33
+ mac: string;
34
+ localIp?: string;
35
+ token?: string;
36
+ online: boolean;
37
+ ownerId: string;
38
+ bindDomain?: string;
39
+ property?: string;
40
+ }
41
+ export interface DeviceStatus {
42
+ state: MovaState;
43
+ status: MovaStatus;
44
+ battery: number;
45
+ fanSpeed: MovaFanSpeed;
46
+ waterFlow: MovaWaterFlow;
47
+ cleaningMode?: MovaCleaningMode;
48
+ errorCode: MovaErrorCode;
49
+ currentArea?: number;
50
+ cleaningTime?: number;
51
+ cleanedArea?: number;
52
+ waterTankInstalled?: boolean;
53
+ mopPadInstalled?: boolean;
54
+ dustCollectionStatus?: number;
55
+ cleanWaterTankStatus?: number;
56
+ dirtyWaterTankStatus?: number;
57
+ }
58
+ export interface RoomInfo {
59
+ id: number;
60
+ name: string;
61
+ floorId?: number;
62
+ icon?: string;
63
+ }
64
+ export declare enum MovaState {
65
+ Unknown = -1,
66
+ Idle = 0,
67
+ Paused = 1,
68
+ Cleaning = 2,
69
+ GoCharging = 3,
70
+ Error = 4,
71
+ Mopping = 5,
72
+ Charging = 6,
73
+ Drying = 7,
74
+ Dormant = 8,
75
+ Washing = 9,
76
+ Returning = 10,
77
+ Defecating = 11,
78
+ Building = 12,
79
+ ManualCleaning = 13,
80
+ Sleeping = 14,
81
+ WaitingForTask = 15,
82
+ StationPaused = 16,
83
+ ManualPaused = 17,
84
+ ZonedPaused = 18,
85
+ ZonedCleaning = 19,
86
+ SpotCleaning = 20,
87
+ FastMapping = 21,
88
+ CruiseWaiting = 22,
89
+ CruiseRunning = 23,
90
+ SecondCleaning = 24,
91
+ HumanFollowing = 25,
92
+ SpotCleaningPaused = 26,
93
+ ReturningAutoEmpty = 27,
94
+ CleaningAutoEmpty = 28,
95
+ StationCleaning = 29,
96
+ ReturningToDrain = 30,
97
+ Draining = 31,
98
+ AutoWaterDraining = 32,
99
+ Emptying = 33,
100
+ DustBagDrying = 34,
101
+ DustBagDryingPaused = 35,
102
+ HeadingToExtraCleaning = 36,
103
+ ExtraCleaning = 37,
104
+ FindingPetPaused = 95,
105
+ FindingPet = 96,
106
+ Shortcut = 97,
107
+ Monitoring = 98,
108
+ MonitoringPaused = 99,
109
+ InitialDeepCleaning = 101,
110
+ InitialDeepCleaningPaused = 102,
111
+ Sanitizing = 103,
112
+ SanitizingWithDry = 104
113
+ }
114
+ export declare enum MovaStatus {
115
+ Unknown = -1,
116
+ Idle = 0,
117
+ Paused = 1,
118
+ Cleaning = 2,
119
+ BackHome = 3,
120
+ PartCleaning = 4,
121
+ FollowWall = 5,
122
+ Charging = 6,
123
+ OTA = 7,
124
+ FCT = 8,
125
+ WifiSet = 9,
126
+ PowerOff = 10,
127
+ Factory = 11,
128
+ Error = 12,
129
+ RemoteControl = 13,
130
+ Sleeping = 14,
131
+ SelfRepair = 15,
132
+ FactoryTest = 16,
133
+ Standby = 17,
134
+ SegmentCleaning = 18,
135
+ ZoneCleaning = 19,
136
+ SpotCleaning = 20,
137
+ FastMapping = 21,
138
+ CruisingPath = 22,
139
+ CruisingPoint = 23,
140
+ SummonClean = 24,
141
+ Shortcut = 25,
142
+ PersonFollow = 26,
143
+ WaterCheck = 27,
144
+ Sweeping = 101,
145
+ Mopping = 102,
146
+ SweepingAndMopping = 103,
147
+ Drying = 104,
148
+ Washing = 105,
149
+ ReturningWashing = 106,
150
+ Building = 107,
151
+ ChargingComplete = 108,
152
+ Upgrading = 109,
153
+ CleanSummarizing = 110,
154
+ StationReset = 111,
155
+ ReturningDrain = 112,
156
+ SelfRepairing = 113,
157
+ SelfWashing = 114,
158
+ BackWashing = 115,
159
+ SelfRefresh = 116,
160
+ SelfDrying = 117,
161
+ WaterCheckStart = 118,
162
+ WaterDraining = 119,
163
+ DryingStart = 120,
164
+ AutoEmptying = 121,
165
+ FillingWater = 122
166
+ }
167
+ export declare enum MovaFanSpeed {
168
+ Quiet = 0,
169
+ Standard = 1,
170
+ Strong = 2,
171
+ Turbo = 3
172
+ }
173
+ export declare enum MovaWaterFlow {
174
+ Low = 1,
175
+ Medium = 2,
176
+ High = 3
177
+ }
178
+ export declare enum MovaCleaningMode {
179
+ Sweeping = 0,
180
+ Mopping = 1,
181
+ SweepingAndMopping = 2,
182
+ MoppingAfterSweeping = 3
183
+ }
184
+ export declare enum MovaErrorCode {
185
+ None = 0,
186
+ Drop = 1,
187
+ Cliff = 2,
188
+ Bumper = 3,
189
+ Gesture = 4,
190
+ BumperRepeat = 5,
191
+ DropRepeat = 6,
192
+ OpticalFlow = 7,
193
+ NoBox = 8,
194
+ NoTankBox = 9,
195
+ WaterBoxEmpty = 10,
196
+ BoxFull = 11,
197
+ Brush = 12,
198
+ SideBrush = 13,
199
+ Fan = 14,
200
+ LeftWheelMotor = 15,
201
+ RightWheelMotor = 16,
202
+ TurnSuffocate = 17,
203
+ ForwardSuffocate = 18,
204
+ ChargerGet = 19,
205
+ BatteryLow = 20,
206
+ ChargeFault = 21,
207
+ BatteryPercentage = 22,
208
+ Heart = 23,
209
+ CameraOcclusion = 24,
210
+ CameraFault = 25,
211
+ EventBattery = 26,
212
+ ForwardLooking = 27,
213
+ Gyroscope = 28,
214
+ WheelJammed = 29,
215
+ DirtyTankFull = 30,
216
+ DirtyTankMissing = 31,
217
+ WaterTankLidOpen = 32,
218
+ MopPadMissing = 33,
219
+ FilterBlocked = 34,
220
+ StationDisconnected = 35,
221
+ NavigationBlocked = 36,
222
+ CannotReachArea = 37,
223
+ DustBagFull = 38,
224
+ DustBagMissing = 39,
225
+ WaterPump = 40,
226
+ CleanTankMissing = 41,
227
+ LidarBlocked = 42,
228
+ RouteBlocked = 100,
229
+ MainBrushJammed = 101,
230
+ SideBrushJammed = 102,
231
+ FilterClogged = 103,
232
+ DustBinNotInstalled = 104,
233
+ StationWaterEmpty = 105,
234
+ StationWaterDirty = 106,
235
+ StationDustFull = 107,
236
+ ReturnFailed = 1000
237
+ }
238
+ export interface MiotProperty {
239
+ siid: number;
240
+ piid: number;
241
+ value?: unknown;
242
+ }
243
+ export interface MiotAction {
244
+ siid: number;
245
+ aiid: number;
246
+ in?: unknown[];
247
+ }