Skip to content

Commit 8dda065

Browse files
committed
Release 1.8.1
1 parent 07fa7af commit 8dda065

9 files changed

Lines changed: 304 additions & 26 deletions

File tree

.homeychangelog.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
{
2+
"1.8.1": {
3+
"en": "Improved handling of non-responsive Modbus servers that keep the TCP socket open. Poll timeouts now mark the device unavailable and force a reconnect, while stale Modbus services and sockets are cleaned up during failed connects and reconnects to prevent resource buildup during long outages. Homey visible connection recovery now waits for a valid Modbus snapshot instead of TCP connect alone, and restores availability and connection capabilities before refreshing device values.",
4+
"nl": "Verbeterde afhandeling van Modbus-servers die niet meer reageren maar de TCP-socket open houden. Poll-timeouts zetten het apparaat nu op onbeschikbaar en forceren een reconnect, terwijl oude Modbus-services en sockets worden opgeruimd bij mislukte connects en reconnects om resource-opbouw tijdens lange storingen te voorkomen. Zichtbaar verbindingsherstel in Homey wacht nu op een geldige Modbus-snapshot in plaats van alleen een TCP-connect, en herstelt beschikbaarheid en verbindingscapabilities voordat apparaatwaarden worden ververst."
5+
},
26
"1.8.0": {
37
"en": "Backported the adaptive control and building model improvements from the sister Modbus project. Building model learning is now more resilient with sample gating for non-heating and defrost states, per-sample persistence, stronger state validation, covariance safeguards, and excitation diagnostics. Adaptive control now uses a fixed comfort anchor, improved accumulator settlement, additive wind correction, nullable outdoor-temperature handling, and expanded unit/simulation tests.",
48
"nl": "De verbeteringen voor adaptieve regeling en gebouwmodel uit het zuster Modbus-project zijn overgenomen. Het gebouwmodel leert nu robuuster met sample-gating voor niet-verwarmen en ontdooien, persistentie per geaccepteerde sample, strengere state-validatie, covariance-bescherming en excitation-diagnostiek. De adaptieve regeling gebruikt nu een vast comfort-anker, betere accumulator-settlement, additieve windcorrectie, expliciete afhandeling van ontbrekende buitentemperatuur en uitgebreide unit-/simulatietests."

.homeycompose/app.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"id": "org.hhi.adlar3-heatpump-modbus",
3-
"version": "1.8.0",
3+
"version": "1.8.1",
44
"compatibility": ">=12.3.0",
55
"sdk": 3,
66
"platforms": ["local"],

app.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"_comment": "This file is generated. Please edit .homeycompose/app.json instead.",
33
"id": "org.hhi.adlar3-heatpump-modbus",
4-
"version": "1.8.0",
4+
"version": "1.8.1",
55
"compatibility": ">=12.3.0",
66
"sdk": 3,
77
"platforms": [

lib/modbus/modbus-tcp-service.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,8 @@ export class ModbusTcpService extends EventEmitter {
328328
if (this._connected) {
329329
this._connected = false;
330330
this._gracefulClose();
331+
} else {
332+
this.socket.destroy();
331333
}
332334
this.removeAllListeners();
333335
}

lib/services/modbus-connection-service.ts

Lines changed: 72 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ export class ModbusConnectionService<TSnapshot = DataSnapshot> extends EventEmit
5151
private service: ModbusRuntimeService<TSnapshot> | null = null;
5252
private connected = false;
5353
private retryTimer: ReturnType<typeof setTimeout> | null = null;
54+
private lastConfig: ModbusConnectionConfig | null = null;
55+
private reconnecting = false;
5456

5557
private readonly createService: ModbusConnectionOptions<TSnapshot>['createService'];
5658
private readonly onData: (snapshot: TSnapshot) => void;
@@ -77,18 +79,35 @@ export class ModbusConnectionService<TSnapshot = DataSnapshot> extends EventEmit
7779
* Connect to the Modbus device using the provided config.
7880
*/
7981
async connect(config: ModbusConnectionConfig): Promise<void> {
82+
this.lastConfig = config;
8083
this.logger('ModbusConnectionService: Connecting to', config.host);
8184

85+
if (this.retryTimer) {
86+
this.device.homey.clearTimeout(this.retryTimer);
87+
this.retryTimer = null;
88+
}
89+
90+
if (this.service) {
91+
const oldService = this.service;
92+
this.service = null;
93+
this.connected = false;
94+
await oldService.destroy().catch((err: Error) => {
95+
this.logger('ModbusConnectionService: Error destroying previous service before reconnect:', err.message);
96+
});
97+
}
98+
8299
const timerProvider: TimerProvider = {
83100
setTimeout: this.device.homey.setTimeout.bind(this.device.homey),
84101
setInterval: this.device.homey.setInterval.bind(this.device.homey),
85102
clearTimeout: this.device.homey.clearTimeout.bind(this.device.homey),
86103
clearInterval: this.device.homey.clearInterval.bind(this.device.homey),
87104
};
88105

89-
this.service = this.createService({ config, timerProvider });
106+
const service = this.createService({ config, timerProvider });
107+
this.service = service;
90108

91-
this.service.on('connected', () => {
109+
service.on('connected', () => {
110+
if (this.service !== service) return;
92111
this.connected = true;
93112
this.logger('ModbusConnectionService: Connected');
94113
const superfast = config.pollSuperfastMs ?? 5_000;
@@ -99,7 +118,7 @@ export class ModbusConnectionService<TSnapshot = DataSnapshot> extends EventEmit
99118
const slow = config.pollSlowMs ?? 300_000;
100119
const staggerMs = this._firstConnect ? 300 : 0;
101120
this._firstConnect = false;
102-
this.service!.startPolling({
121+
service.startPolling({
103122
superfast,
104123
superfastAdaptive,
105124
superfastAdaptiveMs,
@@ -111,26 +130,31 @@ export class ModbusConnectionService<TSnapshot = DataSnapshot> extends EventEmit
111130
this.onConnected();
112131
});
113132

114-
this.service.on('disconnected', (reason: string) => {
133+
service.on('disconnected', (reason: string) => {
134+
if (this.service !== service) return;
115135
this.connected = false;
116136
this.logger('ModbusConnectionService: Disconnected:', reason);
117137
this.onDisconnected(reason);
118138
});
119139

120-
this.service.on('reconnecting', (attempt: number, delayMs: number) => {
140+
service.on('reconnecting', (attempt: number, delayMs: number) => {
141+
if (this.service !== service) return;
121142
this.logger(`ModbusConnectionService: Reconnect attempt #${attempt} in ${delayMs}ms`);
122143
});
123144

124-
this.service.on('data', (snapshot: TSnapshot) => {
145+
service.on('data', (snapshot: TSnapshot) => {
146+
if (this.service !== service) return;
125147
this.onData(snapshot);
126148
});
127149

128-
this.service.on('error', (err: Error, ctx: string) => {
150+
service.on('error', (err: Error, ctx: string) => {
151+
if (this.service !== service) return;
129152
this.logger(`ModbusConnectionService: Error [${ctx}]:`, err.message);
130153
this.onError(err, ctx);
131154
});
132155

133-
this.service.on('poll-group-succeeded', (groupName: string) => {
156+
service.on('poll-group-succeeded', (groupName: string) => {
157+
if (this.service !== service) return;
134158
this.onPollGroupSucceeded?.(groupName);
135159
});
136160

@@ -141,16 +165,55 @@ export class ModbusConnectionService<TSnapshot = DataSnapshot> extends EventEmit
141165
}
142166

143167
try {
144-
await this.service.connect();
168+
await service.connect();
145169
} catch (err) {
146170
this.logger('ModbusConnectionService: Initial connect failed, will retry in 30s:', (err as Error).message);
171+
if (this.service === service) {
172+
this.service = null;
173+
this.connected = false;
174+
}
175+
await service.destroy().catch((destroyErr: Error) => {
176+
this.logger('ModbusConnectionService: Error destroying failed service:', destroyErr.message);
177+
});
147178
this.retryTimer = this.device.homey.setTimeout(async () => {
148179
this.retryTimer = null;
149180
await this.connect(config);
150181
}, 30_000);
151182
}
152183
}
153184

185+
async forceReconnect(reason: string): Promise<void> {
186+
if (!this.lastConfig) {
187+
this.logger('ModbusConnectionService: Cannot force reconnect without previous config');
188+
return;
189+
}
190+
if (this.reconnecting) {
191+
this.logger('ModbusConnectionService: Force reconnect already in progress');
192+
return;
193+
}
194+
195+
this.reconnecting = true;
196+
this.logger(`ModbusConnectionService: Force reconnect (${reason})`);
197+
198+
if (this.retryTimer) {
199+
this.device.homey.clearTimeout(this.retryTimer);
200+
this.retryTimer = null;
201+
}
202+
203+
const oldService = this.service;
204+
this.service = null;
205+
this.connected = false;
206+
207+
try {
208+
if (oldService) {
209+
await oldService.destroy();
210+
}
211+
await this.connect(this.lastConfig);
212+
} finally {
213+
this.reconnecting = false;
214+
}
215+
}
216+
154217
/**
155218
* Write a setpoint to the device.
156219
*/

lib/services/service-coordinator.ts

Lines changed: 49 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -391,10 +391,10 @@ export class ServiceCoordinator {
391391
}
392392
}
393393

394-
// ADR-043 Fase 2c: reset naar online alleen als non-fast teller ook schoon is
395-
if (this._connectionQuality !== 'online'
396-
&& this._consecutiveNonFastRequiredFailures === 0) {
397-
this._setConnectionQuality('online');
394+
// Een geldige fast/superfast snapshot bewijst dat Modbus weer antwoordt.
395+
// Herstel Homey beschikbaarheid vóór capability-writes, ook na poll-timeout offline.
396+
if (this._connectionQuality !== 'online' || !this._visibleConnectionConnected) {
397+
this._markPollRecoveredOnline();
398398
}
399399

400400
// Forward snapshot to device for capability updates
@@ -463,18 +463,13 @@ export class ServiceCoordinator {
463463
this.logger('ServiceCoordinator: Disconnect status timer cancelled (reconnected in time)');
464464
}
465465

466-
// ADR-042/043: reset failure counters en zet quality op online via _setConnectionQuality
467-
// (die roept ook setAvailable() aan)
466+
// TCP connect alleen is onvoldoende bewijs dat Modbus weer gezond is:
467+
// bridges/servers kunnen de socket accepteren terwijl register reads blijven hangen.
468+
// Homey-visible herstel gebeurt daarom pas bij een geldige Modbus snapshot.
468469
this._consecutiveFastPollFailures = 0;
469470
this._consecutiveSuperfastPollFailures = 0;
470471
this._consecutiveNonFastRequiredFailures = 0;
471472
this._errorCountByContext.clear();
472-
this._setConnectionQuality('online');
473-
this.energyTracking.setConnectionState(true).catch((e) => {
474-
this.logger('ServiceCoordinator: setConnectionState(true) failed', e);
475-
});
476-
this._setConnectionCapabilities(true, null);
477-
this.adaptiveControl.onConnectionRestored();
478473
}
479474

480475
private _handleDisconnected(reason: string): void {
@@ -640,6 +635,7 @@ export class ServiceCoordinator {
640635
this._degradedSinceTimer = null;
641636
this.logger('ServiceCoordinator: Degraded timeout — setting offline');
642637
this._setConnectionQuality('offline');
638+
this._markPollTimeoutOffline();
643639
}, ServiceCoordinator.DEGRADED_TO_OFFLINE_MS);
644640
}
645641
} else {
@@ -650,6 +646,9 @@ export class ServiceCoordinator {
650646
}
651647

652648
if (quality === 'online') {
649+
if (!this._visibleConnectionConnected) {
650+
this._setConnectionCapabilities(true, null);
651+
}
653652
// ADR-043 Fase 2d: structurele warning heeft hogere prioriteit dan schone online-status
654653
if (this._structurallyUnsupportedFast) {
655654
this.device
@@ -665,6 +664,44 @@ export class ServiceCoordinator {
665664
.catch(() => {});
666665
}
667666
// 'offline' wordt via de bestaande _disconnectStatusTimer afgehandeld
667+
// behalve bij poll-timeout zonder socket-disconnect; dat pad gebruikt _markPollTimeoutOffline().
668+
}
669+
670+
private _markPollTimeoutOffline(): void {
671+
const wasVisiblyConnected = this._visibleConnectionConnected;
672+
this._setConnectionCapabilities(false, 'poll timeout');
673+
if (wasVisiblyConnected) {
674+
this._incrementDailyDisconnectCount();
675+
}
676+
this.device
677+
.setUnavailable('Modbus reageert niet op polling')
678+
.catch(() => {});
679+
this.modbusConnection.forceReconnect('poll timeout').catch((err) => {
680+
this.logger('ServiceCoordinator: Force reconnect after poll timeout failed', err);
681+
});
682+
}
683+
684+
private _markPollRecoveredOnline(): void {
685+
this._consecutiveFastPollFailures = 0;
686+
this._consecutiveSuperfastPollFailures = 0;
687+
this._consecutiveNonFastRequiredFailures = 0;
688+
this._errorCountByContext.clear();
689+
690+
this._setConnectionQuality('online');
691+
692+
if (!this._visibleConnectionConnected) {
693+
this._setConnectionCapabilities(true, null);
694+
}
695+
696+
if (!this._structurallyUnsupportedFast) {
697+
this.device.setAvailable().catch(() => {});
698+
this.device.setWarning(null).catch(() => {});
699+
}
700+
701+
this.energyTracking.setConnectionState(true).catch((e) => {
702+
this.logger('ServiceCoordinator: setConnectionState(true) after poll recovery failed', e);
703+
});
704+
this.adaptiveControl.onConnectionRestored();
668705
}
669706

670707
// ── Public API ─────────────────────────────────────────────────────────────

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "org.hhi.adlar3-heatpump-modbus",
3-
"version": "1.8.0",
3+
"version": "1.8.1",
44
"engines": {
55
"node": ">=12.16.1"
66
},

0 commit comments

Comments
 (0)