UNPKG

lavva.exalushome

Version:

Library implementing communication and abstraction layers for ExalusHome system

680 lines 32.7 kB
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; import * as signalR from "@microsoft/signalr"; import { DataFrame, Method, Status, } from "../DataFrame"; import { ConnectionResult, AuthorizationInfo, ConnectionState, StreamError, } from "./IExalusConnectionService"; import { TypedEvent } from "../TypedEvent"; import { Api } from "../Api"; import { LoggerService } from "./Logging/LoggerService"; import { Event } from "../Event"; import { ControllerConfigurationService } from "./Controller/ControllerConfigurationService"; import { WebApiCacheService } from "./WebApi/WebApiCacheService"; import { SessionService } from "./Session/SessionService"; import { LogLevel } from "./Logging/ILoggerService"; class FastRetryPolicy { nextRetryDelayInMilliseconds() { return 1000; // constant back‑off } } const TRANSPORTS = [ signalR.HttpTransportType.WebSockets, signalR.HttpTransportType.ServerSentEvents, signalR.HttpTransportType.LongPolling, ]; export class SignalrLogger { constructor() { this._log = Api.Get(LoggerService.ServiceName); } log(level, message) { if (level < ExalusConnectionService.SignalRLogLevel) return; message = `[SignalR] ${message}`; switch (level) { case signalR.LogLevel.Critical: case signalR.LogLevel.Error: this._log.Error(message); break; case signalR.LogLevel.Warning: this._log.Warning(message); break; case signalR.LogLevel.Information: this._log.Info(message); break; default: this._log.Debug(message); break; } } } export class ExalusConnectionService { constructor() { this._connectTask = null; this._pingTimerId = null; this._consecutivePingFailures = 0; this._disconnectedOnPurpose = false; this._address = "packets-broker1.tr7.pl"; this._serversBrokerAddress = "https://servers-broker.tr7.pl"; this._serversBrokerAddressList = [ "https://servers-broker.tr7.pl", "https://broker.tr7.pl", "https://dev-broker.tr7.pl", ]; this._allBrokersChecked = false; this._packetsBrokerServers = [ "packets-broker1.tr7.pl", "packets-broker2.tr7.pl" ]; this._lastReceivedPacket = Date.now(); this._everConnected = false; this._pendingRequests = new Map(); this._log = Api.Get(LoggerService.ServiceName); this._controllerConfiguration = null; this._cache = null; this._session = null; this._dataReceivedEvent = new TypedEvent(); this._pongReceivedEvent = new Event(); this._authorizationReceivedEvent = new TypedEvent(); this._registrationReceivedEvent = new TypedEvent(); this._streamStartedEvent = new TypedEvent(); this._connectionStateChangedEvent = new TypedEvent(); this._errorOccuredEvent = new TypedEvent(); this._tryReconnect = () => { if (navigator.onLine) { this.validateConnectionToController().then((isConnected) => { if (!isConnected) { this._log.Debug(ExalusConnectionService.ServiceName, "Connection lost → reconnecting..."); void this.serialisedConnect(); } }); } }; this._onVisibility = () => { if (document.visibilityState === "visible") { this._log.Debug(ExalusConnectionService.ServiceName, "Page visible → connection check"); this.validateConnectionToController().then((isConnected) => { if (!isConnected) { this._log.Debug(ExalusConnectionService.ServiceName, "Connection lost → reconnecting..."); void this.serialisedConnect(); } }); } }; this._onHidden = () => { }; this._onVisible = () => { this.validateConnectionToController().then((isConnected) => { if (!isConnected) { this._log.Debug(ExalusConnectionService.ServiceName, "Connection lost → reconnecting..."); void this.serialisedConnect(); } }); }; } GetServiceName() { return ExalusConnectionService.ServiceName; } GetAuthorizationInfo() { return this._serialId && this._PIN ? new AuthorizationInfo(this._serialId, this._PIN) : null; } GetControllerSerialNumber() { return this._serialId; } GetControllerPin() { return this._PIN; } SetServersBrokerAddress(a) { this._serversBrokerAddress = a; } SetDefaultPacketsBrokerAddress(a) { this._address = a; } EnablePacketsLogging() { window["packets"] = true; } DisablePacketsLogging() { window["packets"] = false; } SubscribeTo(resourceId, handler) { const h = (f) => { if (f.Resource === resourceId) handler(f); }; this._dataReceivedEvent.Subscribe(h); return () => this._dataReceivedEvent.Unsubscribe(h); } GetServerAddressAsync() { return __awaiter(this, void 0, void 0, function* () { if (!this._serialId) { this._log.Warning(ExalusConnectionService.ServiceName, "GetServerAddressAsync() – serialId nieustawiony"); return null; } const url = `${this._serversBrokerAddress}/api/connections/broker/whichserver/${this._serialId}`; try { const resp = yield fetch(url); switch (resp.status) { case 200: { const addr = (yield resp.text()).trim(); if (addr) { this._log.Debug(ExalusConnectionService.ServiceName, `Broker address resolved: ${addr}`); return addr; } this._log.Warning(ExalusConnectionService.ServiceName, "Broker returned empty address"); return null; } case 204: { if (!this._allBrokersChecked) { this._log.Warning(ExalusConnectionService.ServiceName, `Broker controller ${this._serversBrokerAddress} returned 204 (no content) – controller not found on given server`); yield this.rotateServersBroker(); return this.GetServerAddressAsync(); } else { this._log.Error(ExalusConnectionService.ServiceName, "All brokers checkt but controller not found! Controller probably offline!"); return null; } } default: { this._log.Warning(ExalusConnectionService.ServiceName, `GetServerAddressAsync() – HTTP ${resp.status}`); return null; } } } catch (err) { this._log.Error(ExalusConnectionService.ServiceName, String(err)); return null; } }); } rotateServersBroker() { return __awaiter(this, void 0, void 0, function* () { const idx = this._serversBrokerAddressList.indexOf(this._serversBrokerAddress); const next = idx + 1 < this._serversBrokerAddressList.length ? idx + 1 : 0; this._serversBrokerAddress = this._serversBrokerAddressList[next]; this._allBrokersChecked = next === 0; this._log.Info(ExalusConnectionService.ServiceName, `Switching servers‑broker to: ${this._serversBrokerAddress}`); }); } ConnectAsync(address) { return __awaiter(this, void 0, void 0, function* () { this._address = address; return this.serialisedConnect(); }); } ConnectAndAuthorizeAsync(info) { return __awaiter(this, void 0, void 0, function* () { this._serialId = info.serialNumber; this._PIN = info.pin; Api.WorksInContextOf = this._serialId; let packetsBroker = yield this.GetServerAddressAsync(); if (packetsBroker) { this.SetDefaultPacketsBrokerAddress(packetsBroker); if ((yield this.serialisedConnect()) === ConnectionResult.Connected && (yield this.AuthorizeAsync(info))) { return ConnectionResult.Connected; } } for (const host of this._packetsBrokerServers) { this._log.Info(ExalusConnectionService.ServiceName, `Fallback broker: ${host}`); this.SetDefaultPacketsBrokerAddress(host); if ((yield this.serialisedConnect()) === ConnectionResult.Connected && (yield this.AuthorizeAsync(info))) { return ConnectionResult.Connected; } } return ConnectionResult.FailedToConnect; }); } AuthorizeAsync(info) { return __awaiter(this, void 0, void 0, function* () { return new Promise((resolve) => __awaiter(this, void 0, void 0, function* () { var _a; const timeoutId = window.setTimeout(() => { this._authorizationReceivedEvent.Unsubscribe(onAuth); resolve(false); }, 2000); const onAuth = (ok) => { clearTimeout(timeoutId); this._authorizationReceivedEvent.Unsubscribe(onAuth); this._log.Debug(ExalusConnectionService.ServiceName, `Authorization → ${ok}`); if (ok) this._connectionStateChangedEvent.Invoke(ConnectionState.ConnectedAndAuthorized); resolve(ok); }; this._authorizationReceivedEvent.Subscribe(onAuth); yield ((_a = this._connection) === null || _a === void 0 ? void 0 : _a.send("AuthorizeTo", info.SerialNumber, info.PIN)); })); }); } IsConnected() { var _a; return ((_a = this._connection) === null || _a === void 0 ? void 0 : _a.state) === signalR.HubConnectionState.Connected; } DisconnectAsync() { return __awaiter(this, void 0, void 0, function* () { var _a; this._disconnectedOnPurpose = true; this._log.Debug(ExalusConnectionService.ServiceName, "Disconnecting..."); yield ((_a = this._connection) === null || _a === void 0 ? void 0 : _a.stop()); this.cleanup(); }); } SendAsync(dataFrame_1) { return __awaiter(this, arguments, void 0, function* (dataFrame, logTx = false) { if (!this.IsConnected()) throw new Error("Not connected"); const dump = window["packets"] === true; if (dump || logTx) { this._log.Debug(ExalusConnectionService.ServiceName, `⇢ ${dataFrame.Resource} ${dataFrame.Method} ${dataFrame.TransactionId}` + (dump ? `\n${JSON.stringify(dataFrame, null, 2)}` : "")); } try { yield this._connection.invoke("SendTo", this._serialId, dataFrame); return true; } catch (err) { this._log.Error(ExalusConnectionService.ServiceName, String(err)); return false; } }); } SendAndWaitForResponseAsync(dataFrame_1, timeout_1, useCache_1) { return __awaiter(this, arguments, void 0, function* (dataFrame, timeout, useCache, logTransmission = true) { var _a, _b, _c; if (dataFrame.Method === Method.Get && useCache) { if (!(yield ((_a = this._controllerConfiguration) === null || _a === void 0 ? void 0 : _a.DidCofigurationChangeAsync()))) { const cached = (_b = this._cache) === null || _b === void 0 ? void 0 : _b.GetCache(dataFrame); if (cached) return cached; } } if (!this.IsConnected()) throw new Error("Connection is not established"); if (dataFrame.Resource !== "/users/user/login") yield ((_c = this._session) === null || _c === void 0 ? void 0 : _c.WaitForSessionCreationAsync()); return new Promise((resolve, reject) => __awaiter(this, void 0, void 0, function* () { const timeoutId = window.setTimeout(() => { this._dataReceivedEvent.Unsubscribe(onRx); this._pendingRequests.delete(dataFrame.TransactionId); reject(new signalR.TimeoutError("Response timeout")); }, timeout); const onRx = (frame) => { var _a, _b; if (frame.TransactionId !== dataFrame.TransactionId) return; clearTimeout(timeoutId); this._dataReceivedEvent.Unsubscribe(onRx); this._pendingRequests.delete(dataFrame.TransactionId); if (dataFrame.Method === Method.Get && useCache) (_a = this._cache) === null || _a === void 0 ? void 0 : _a.Cache(frame); if (!useCache && frame.Status === Status.UserIsNotLoggedIn) { void ((_b = this._session) === null || _b === void 0 ? void 0 : _b.RestoreSessionAsync().then(() => resolve(this.SendAndWaitForResponseAsync(dataFrame, timeout, useCache, logTransmission)))); return; } resolve(frame); }; this._dataReceivedEvent.Subscribe(onRx); this._pendingRequests.set(dataFrame.TransactionId, { resolve: (v) => resolve(v), reject, timeoutId, }); if (!(yield this.SendAsync(dataFrame, logTransmission))) { clearTimeout(timeoutId); this._dataReceivedEvent.Unsubscribe(onRx); this._pendingRequests.delete(dataFrame.TransactionId); reject(new Error("Failed to send request")); } })); }); } SendAndHandleResponseAsync(dataFrame_1, timeout_1, dataHandler_1) { return __awaiter(this, arguments, void 0, function* (dataFrame, timeout, dataHandler, logTransmission = true) { var _a; if (!this.IsConnected()) throw new Error("Connection is not established"); if (dataFrame.Resource !== "/users/user/login") yield ((_a = this._session) === null || _a === void 0 ? void 0 : _a.WaitForSessionCreationAsync()); return new Promise((resolve, reject) => __awaiter(this, void 0, void 0, function* () { const resetTimeout = () => window.setTimeout(() => { cleanup(); reject(new signalR.TimeoutError("MultiDataResponse timeout")); }, timeout); let tid = resetTimeout(); const cleanup = () => { clearTimeout(tid); this._dataReceivedEvent.Unsubscribe(onRx); this._pendingRequests.delete(dataFrame.TransactionId); }; const onRx = (frame) => { if (frame.TransactionId !== dataFrame.TransactionId) return; clearTimeout(tid); switch (frame.Status) { case Status.MultiDataResponseStart: case Status.MultiDataResponse: dataHandler(frame); tid = resetTimeout(); break; case Status.MultiDataResponseStop: case Status.OK: dataHandler(frame); cleanup(); resolve(); break; default: cleanup(); reject(new Error(`Unexpected status ${frame.Status}`)); } }; this._dataReceivedEvent.Subscribe(onRx); this._pendingRequests.set(dataFrame.TransactionId, { resolve: () => resolve(), reject, timeoutId: tid, }); if (!(yield this.SendAsync(dataFrame, logTransmission))) { cleanup(); reject(new Error("Failed to send request")); } })); }); } SendAndHandleStreamAsync(dataFrame_1, streamHandler_1) { return __awaiter(this, arguments, void 0, function* (dataFrame, streamHandler, logTransmission = true) { var _a; if (!this.IsConnected()) throw new Error("Connection is not established"); if (dataFrame.Resource !== "/users/user/login") yield ((_a = this._session) === null || _a === void 0 ? void 0 : _a.WaitForSessionCreationAsync()); return new Promise((resolve, reject) => __awaiter(this, void 0, void 0, function* () { const timeoutId = window.setTimeout(() => { this._streamStartedEvent.Unsubscribe(onStreamStart); reject(new signalR.TimeoutError("Stream registration timeout")); }, 8000); const onStreamStart = (streamId) => { if (streamId !== dataFrame.TransactionId) return; clearTimeout(timeoutId); this._streamStartedEvent.Unsubscribe(onStreamStart); this._connection .stream("WatchStream", streamId) .subscribe({ next: (item) => streamHandler.Next(item), complete: () => { streamHandler.Complete(); resolve(); }, error: (err) => { streamHandler.Error(err); reject(new StreamError(String(err))); }, }); }; this._streamStartedEvent.Subscribe(onStreamStart); if (!(yield this.SendAsync(dataFrame, logTransmission))) { clearTimeout(timeoutId); this._streamStartedEvent.Unsubscribe(onStreamStart); reject(new Error("Failed to send request")); } })); }); } PingControllerAsync() { return __awaiter(this, void 0, void 0, function* () { if (!this.IsConnected()) return false; if (Date.now() - this._lastReceivedPacket < ExalusConnectionService.PING_INTERVAL_MS) return false; const frame = new DataFrame(); frame.Resource = "/system/ping"; frame.Method = Method.Get; try { yield this.SendAndWaitForResponseAsync(frame, 2000, false, false); return true; } catch (_a) { return false; } }); } OnDataReceivedEvent() { return this._dataReceivedEvent; } OnConnectionStateChangedEvent() { return this._connectionStateChangedEvent; } OnErrorOccuredEvent() { return this._errorOccuredEvent; } serialisedConnect() { if (this._connectTask) return this._connectTask; this._connectTask = this.connectCore().finally(() => (this._connectTask = null)); return this._connectTask; } connectCore() { return __awaiter(this, void 0, void 0, function* () { var _a, _b, _c, _d, _e; if (!this._address) return ConnectionResult.ControllerIsNotConnected; if (((_a = this._connection) === null || _a === void 0 ? void 0 : _a.state) === signalR.HubConnectionState.Reconnecting) { this._log.Info(ExalusConnectionService.ServiceName, "Connection is already in reconnecting state - letting automatic reconnect handle it"); try { yield this.waitForReconnection(); this._log.Info(ExalusConnectionService.ServiceName, "Automatic reconnect successful"); return ConnectionResult.Connected; } catch (error) { this._log.Warning(ExalusConnectionService.ServiceName, `Automatic reconnect failed: ${error} trying to reconnect by creting new connection`); } } if (((_b = this._connection) === null || _b === void 0 ? void 0 : _b.state) != signalR.HubConnectionState.Disconnected) { this._log.Info(ExalusConnectionService.ServiceName, "connectCore() was called, but connection curently exists! Disconnecting..."); yield ((_c = this._connection) === null || _c === void 0 ? void 0 : _c.stop()); } this.initializeServices(); this.cleanup(); for (const t of TRANSPORTS) { var builder = new signalR.HubConnectionBuilder() .withAutomaticReconnect(new FastRetryPolicy()) .configureLogging(new SignalrLogger()) // https://learn.microsoft.com/en-us/aspnet/core/release-notes/aspnetcore-8.0?view=aspnetcore-8.0#signalr-stateful-reconnect .withStatefulReconnect({ bufferSize: 256000 }) .withServerTimeout(ExalusConnectionService.SERVER_TIMOUT_MS) .withUrl(`https://${this._address}/broker`, { // skipNegotiation tylko dla WebSocket skipNegotiation: t === signalR.HttpTransportType.WebSockets, transport: t, }); if (this._log.LogLevel === LogLevel.Debug) { builder.configureLogging(signalR.LogLevel.Debug); } this._connection = builder.build(); this.wireConnectionEvents(); try { yield this._connection.start(); this.startPingLoop(); this._connectionStateChangedEvent.Invoke(ConnectionState.Connected); return ConnectionResult.Connected; } catch (err) { this._log.Warning(ExalusConnectionService.ServiceName, `Transport ${signalR.HttpTransportType[t]} failed (${err === null || err === void 0 ? void 0 : err.message}).`); yield this._connection.stop().catch(() => { }); (_e = (_d = this._connection).off) === null || _e === void 0 ? void 0 : _e.call(_d); } } this._connectionStateChangedEvent.Invoke(ConnectionState.Failed); return ConnectionResult.FailedToConnect; }); } waitForReconnection(timeoutMs = 0) { return new Promise((resolve, reject) => { if (!this._connection || this._connection.state !== signalR.HubConnectionState.Reconnecting) { reject(new Error("Connection is not in reconnecting state")); return; } let timeoutId = undefined; if (timeoutMs > 0) { timeoutId = window.setTimeout(() => { this._connectionStateChangedEvent.Unsubscribe(handler); reject(new Error("Reconnection timeout")); }, timeoutMs); } const handler = (state) => { if (state === ConnectionState.Connected) { if (timeoutId) clearTimeout(timeoutId); this._connectionStateChangedEvent.Unsubscribe(handler); resolve(); } else if (state === ConnectionState.Disconnected || state === ConnectionState.Failed) { if (timeoutId) clearTimeout(timeoutId); this._connectionStateChangedEvent.Unsubscribe(handler); reject(new Error("Connection closed during reconnection")); } }; this._connectionStateChangedEvent.Subscribe(handler); if (this._connection.state !== signalR.HubConnectionState.Reconnecting) { if (timeoutId) clearTimeout(timeoutId); this._connectionStateChangedEvent.Unsubscribe(handler); if (this._connection.state === signalR.HubConnectionState.Connected) { resolve(); } else { reject(new Error(`Connection is in unexpected state: ${this._connection.state}`)); } } }); } initializeServices() { this._controllerConfiguration = Api.Get(ControllerConfigurationService.ServiceName); this._cache = Api.Get(WebApiCacheService.ServiceName); this._session = Api.Get(SessionService.ServiceName); } wireConnectionEvents() { if (!this._connection) return; this._connection.on("Pong", () => { this._lastReceivedPacket = Date.now(); this._pongReceivedEvent.Invoke(); }); this._connection.on("Authorization", (ok) => this._authorizationReceivedEvent.Invoke(ok)); this._connection.on("Registration", (d) => this._registrationReceivedEvent.Invoke(d)); this._connection.on("SendError", (s, d) => { if (s.startsWith("NotAuthorized:")) { this.AuthorizeAsync(new AuthorizationInfo(this._serialId, this._PIN)); } else { this._errorOccuredEvent.Invoke([s, d]); } }); this._connection.on("Data", (_, json) => { this._lastReceivedPacket = Date.now(); if (window["packets"] === true) this._log.Debug(ExalusConnectionService.ServiceName, `Received: ${JSON.stringify(JSON.parse(json), null, 2)}`); this._dataReceivedEvent.Invoke(JSON.parse(json)); }); this._connection.onclose(() => { this.cleanup(); this._connectionStateChangedEvent.Invoke(ConnectionState.Disconnected); }); this._connection.onreconnecting(() => this._connectionStateChangedEvent.Invoke(ConnectionState.Reconnecting)); this._connection.onreconnected(() => this._connectionStateChangedEvent.Invoke(ConnectionState.Connected)); } startPingLoop() { if (this._pingTimerId !== null) clearInterval(this._pingTimerId); this._pingTimerId = window.setInterval(() => void this.pingOnce(), ExalusConnectionService.PING_INTERVAL_MS); window.addEventListener("online", this._tryReconnect); window.addEventListener("offline", this._tryReconnect); document.addEventListener("visibilitychange", this._onVisibility, true); window.addEventListener("pagehide", this._onHidden, true); window.addEventListener("pageshow", this._onVisible, true); } pingOnce() { return __awaiter(this, void 0, void 0, function* () { if (!this.IsConnected()) return; if (Date.now() - this._lastReceivedPacket < ExalusConnectionService.PING_INTERVAL_MS) return; const ok = yield this.PingControllerAsync(); if (ok) { this._consecutivePingFailures = 0; } else if (++this._consecutivePingFailures >= ExalusConnectionService.MAX_CONSECUTIVE_PING_FAILURES) { this._consecutivePingFailures = 0; this._log.Warning(ExalusConnectionService.ServiceName, "Ping failed too many times → reconnecting..."); if (ExalusConnectionService.SERVER_TIMOUT_MS < ExalusConnectionService.PING_INTERVAL_MS * ExalusConnectionService.MAX_CONSECUTIVE_PING_FAILURES) { this._log.Error("Server timeout is shorter than ping interval! SignalR automatic reconnect will not work!."); } void this.serialisedConnect(); } }); } validateConnectionToController() { return __awaiter(this, void 0, void 0, function* () { var _a; if (this.IsConnected()) { const frame = new DataFrame(); frame.Resource = "/system/ping"; frame.Method = Method.Get; try { var res = yield this.SendAndWaitForResponseAsync(frame, ExalusConnectionService.SERVER_TIMOUT_MS + 1000, false, false); if ((res === null || res === void 0 ? void 0 : res.Status) !== Status.OK) { this._log.Warning(ExalusConnectionService.ServiceName, `Connection check failed, ${res == null ? 'result is null' : `status: ${res.Status}`}`); return false; } else if (((_a = this._connection) === null || _a === void 0 ? void 0 : _a.state) == signalR.HubConnectionState.Reconnecting) { this._log.Warning(ExalusConnectionService.ServiceName, "Connection check failed, but connection is in reconnecting state!"); return false; } else { this._log.Info(ExalusConnectionService.ServiceName, "Connection still active!"); return true; } } catch (_b) { return false; } } else { this._log.Warning(ExalusConnectionService.ServiceName, "Connection was lost."); return false; } }); } cleanup() { var _a, _b; if (this._pingTimerId !== null) { clearInterval(this._pingTimerId); this._pingTimerId = null; } // reject all outstanding waiters for (const [, p] of this._pendingRequests) { clearTimeout(p.timeoutId); p.reject(new Error("Connection lost")); } this._pendingRequests.clear(); window.removeEventListener("online", this._tryReconnect); window.removeEventListener("offline", this._tryReconnect); document.removeEventListener("visibilitychange", this._onVisibility, true); window.removeEventListener("pagehide", this._onHidden, true); window.removeEventListener("pageshow", this._onVisible, true); // Typing workaround: we want to detach *all* handlers (_b = (_a = this._connection) === null || _a === void 0 ? void 0 : _a.off) === null || _b === void 0 ? void 0 : _b.call(_a); this._connection = undefined; } } // end of class ExalusConnectionService.PING_INTERVAL_MS = 5000; ExalusConnectionService.MAX_CONSECUTIVE_PING_FAILURES = 6; ExalusConnectionService.SERVER_TIMOUT_MS = 10000; ExalusConnectionService.SignalRLogLevel = signalR.LogLevel.Warning; ExalusConnectionService.ServiceName = "ExalusConnectionService"; //# sourceMappingURL=ExalusConnectionService.js.map