diff --git a/adapter/mqtt.ts b/adapter/mqtt.ts new file mode 100644 index 0000000..d007af3 --- /dev/null +++ b/adapter/mqtt.ts @@ -0,0 +1,158 @@ +import mqtt, { type MqttClient } from "mqtt"; +import type { QoS } from "mqtt-packet"; + +export interface MQTTConfig { + host: string; + port: number; + username?: string; + password?: string; +} + +interface MQTTSubscription { + topic: string; + callback: (topic: string, message: string) => void; +} + +export interface MQTTMessage { + topic: string; + payload: string; + qos?: QoS; + retain?: boolean; +} + +class MQTT { + private client: MqttClient | null = null; + private config: MQTTConfig | null = null; + private readonly subscriptions: MQTTSubscription[] = []; + private isConnected: boolean = false; + private keepAliveTimer: NodeJS.Timeout | null = null; + + async keepAlive(): Promise { + if (this.isConnected) { + return; + } + console.log("MQTT client is not connected, attempting to reconnect..."); + await this.connectMQTT(); + } + + async initialize(config: MQTTConfig): Promise { + this.config = config; + this.connectMQTT(); + + // Start keep-alive timer + this.keepAliveTimer = setInterval(() => { + this.keepAlive(); + }, 5000); // Run every 5 seconds + } + + async connectMQTT(): Promise { + if (!this.config) { + throw new Error("MQTT configuration is not set."); + } + try { + this.client = mqtt.connect(`mqtt://${this.config.host}:${this.config.port}`, { + username: this.config?.username, + password: this.config?.password + }); + await this.setupHandler(); + } catch (error) { + console.error("Failed to connect to MQTT broker:", error); + } + } + + async publish(message: MQTTMessage): Promise { + if (!this.client || !this.isConnected) { + console.error("MQTT client is not connected, cannot publish message."); + return; + } + + const { topic, payload, qos = 0, retain = false } = message; + try { + await this.client.publishAsync(topic, payload, { qos, retain }); + } catch (error) { + console.error(`Failed to publish message to ${topic}:`, error); + } + } + + async subscribe(topic: string, callback: (topic: string, message: string) => void): Promise { + const subscription: MQTTSubscription = { topic, callback }; + this.subscriptions.push(subscription); + + if (this.client && this.isConnected) { + try { + await this.client.subscribeAsync(topic); + } catch (error) { + console.error(`Failed to subscribe to ${topic}:`, error); + } + } + } + + async setupHandler(): Promise { + if (!this.client) { + console.error("MQTT client is not initialized."); + return; + } + + this.client.on("connect", this.onConnect.bind(this)); + this.client.on("message", this.onMessage.bind(this)); + this.client.on("disconnect", this.onDisconnect.bind(this)); + } + + async disconnect(): Promise { + if (this.keepAliveTimer) { + clearInterval(this.keepAliveTimer); + this.keepAliveTimer = null; + } + + if (this.client) { + await this.client.endAsync(); + this.client = null; + } + this.isConnected = false; + } + + private async onConnect(): Promise { + console.log("MQTT connected successfully."); + for (const sub of this.subscriptions) { + if (this.client) { + try { + await this.client.subscribeAsync(sub.topic); + } catch (error) { + console.error(`Failed to subscribe to ${sub.topic}:`, error); + } + } + } + this.isConnected = true; + } + + private onDisconnect(): void { + console.log("MQTT disconnected."); + this.isConnected = false; + } + + private onMessage(topic: string, message: Buffer): void { + const msg = message.toString(); + this.subscriptions.forEach(sub => { + if (this.matchTopic(sub.topic, topic)) { + sub.callback(topic, msg); + } + }); + } + + private matchTopic(subscriptionTopic: string, publishTopic: string): boolean { + // Exact match + if (subscriptionTopic === publishTopic) { + return true; + } + + // Convert MQTT wildcards to regex + const regexPattern = subscriptionTopic + .replace(/\+/g, '[^/]+') // + matches single level + .replace(/#$/, '.*'); // # matches multi-level (only at end) + + const regex = new RegExp(`^${regexPattern}$`); + return regex.test(publishTopic); + } +} + +export default MQTT; \ No newline at end of file diff --git a/bun.lock b/bun.lock index 7d4a858..1e74b03 100644 --- a/bun.lock +++ b/bun.lock @@ -10,6 +10,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.519.0", + "mqtt": "^5.13.1", "next": "15.3.4", "port": "^0.8.1", "react": "^19.1.0", @@ -315,6 +316,8 @@ "@types/react-dom": ["@types/react-dom@19.1.6", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw=="], + "@types/readable-stream": ["@types/readable-stream@4.0.21", "", { "dependencies": { "@types/node": "*" } }, "sha512-19eKVv9tugr03IgfXlA9UVUVRbW6IuqRO5B92Dl4a6pT7K8uaGrNS0GkxiZD0BOk6PLuXl5FhWl//eX/pzYdTQ=="], + "@types/serialport": ["@types/serialport@10.2.0", "", { "dependencies": { "serialport": "*" } }, "sha512-jddRnvcjZLSQHyK8anaUFAAwnET8bcWoM2TVU7SZyY4xVqnorhsvsZLVfqgYk/zintnqrUTCshE/CgqFnBLEcg=="], "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.34.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.34.1", "@typescript-eslint/type-utils": "8.34.1", "@typescript-eslint/utils": "8.34.1", "@typescript-eslint/visitor-keys": "8.34.1", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.34.1", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-STXcN6ebF6li4PxwNeFnqF8/2BNDvBupf2OPx2yWNzr6mKNGF7q49VM00Pz5FaomJyqvbXpY6PhO+T9w139YEQ=="], @@ -375,6 +378,8 @@ "@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.9.1", "", { "os": "win32", "cpu": "x64" }, "sha512-rS86wI4R6cknYM3is3grCb/laE8XBEbpWAMSIPjYfmYp75KL5dT87jXF2orDa4tQYg5aajP5G8Fgh34dRyR+Rw=="], + "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], + "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], @@ -417,10 +422,18 @@ "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "bl": ["bl@6.1.0", "", { "dependencies": { "@types/readable-stream": "^4.0.0", "buffer": "^6.0.3", "inherits": "^2.0.4", "readable-stream": "^4.2.0" } }, "sha512-ClDyJGQkc8ZtzdAAbAwBmhMSpwN/sC9HA8jxdYm6nVUbCfZbe2mgza4qh7AuEYyEPB/c4Kznf9s66bnsKMQDjw=="], + "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], + + "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + "busboy": ["busboy@1.6.0", "", { "dependencies": { "streamsearch": "^1.1.0" } }, "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA=="], "call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="], @@ -451,8 +464,12 @@ "color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="], + "commist": ["commist@3.2.0", "", {}, "sha512-4PIMoPniho+LqXmpS5d3NuGYncG6XWlkBSVGiWycL22dd42OYdUGil2CWuzklaJoNxyxUSpO4MKIBU94viWNAw=="], + "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + "concat-stream": ["concat-stream@2.0.0", "", { "dependencies": { "buffer-from": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.0.2", "typedarray": "^0.0.6" } }, "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], @@ -561,8 +578,12 @@ "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], + "eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], + "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-equals": ["fast-equals@5.2.2", "", {}, "sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw=="], @@ -573,6 +594,8 @@ "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + "fast-unique-numbers": ["fast-unique-numbers@8.0.13", "", { "dependencies": { "@babel/runtime": "^7.23.8", "tslib": "^2.6.2" } }, "sha512-7OnTFAVPefgw2eBJ1xj2PGGR9FwYzSUso9decayHgCDX4sJkHLdcsYTytTg+tYv+wKF3U8gJuSBz2jJpQV4u/g=="], + "fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="], "fdir": ["fdir@6.4.6", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w=="], @@ -631,16 +654,24 @@ "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + "help-me": ["help-me@5.0.0", "", {}, "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg=="], + + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], + "ip-address": ["ip-address@9.0.5", "", { "dependencies": { "jsbn": "1.1.0", "sprintf-js": "^1.1.3" } }, "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g=="], + "is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="], "is-arrayish": ["is-arrayish@0.3.2", "", {}, "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ=="], @@ -703,10 +734,14 @@ "jiti": ["jiti@2.4.2", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A=="], + "js-sdsl": ["js-sdsl@4.3.0", "", {}, "sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="], + "jsbn": ["jsbn@1.1.0", "", {}, "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A=="], + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], @@ -755,6 +790,8 @@ "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + "lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "lucide-react": ["lucide-react@0.519.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-cLJyjRKBJFzaZ/+1oIeQaH7XUdxKOYU3uANcGSrKdIZWElmNbRAm8RXKiTJS7AWLCBOS8b7A497Al/kCHozd+A=="], "magic-string": ["magic-string@0.30.17", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA=="], @@ -775,6 +812,10 @@ "mkdirp": ["mkdirp@3.0.1", "", { "bin": { "mkdirp": "dist/cjs/src/bin.js" } }, "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg=="], + "mqtt": ["mqtt@5.13.1", "", { "dependencies": { "commist": "^3.2.0", "concat-stream": "^2.0.0", "debug": "^4.4.0", "help-me": "^5.0.0", "lru-cache": "^10.4.3", "minimist": "^1.2.8", "mqtt-packet": "^9.0.2", "number-allocator": "^1.0.14", "readable-stream": "^4.7.0", "rfdc": "^1.4.1", "socks": "^2.8.3", "split2": "^4.2.0", "worker-timers": "^7.1.8", "ws": "^8.18.0" }, "bin": { "mqtt_pub": "build/bin/pub.js", "mqtt_sub": "build/bin/sub.js", "mqtt": "build/bin/mqtt.js" } }, "sha512-g+4G+ma0UeL3Pgu1y1si2NHb4VLIEUCtF789WrG99lLG0XZyoab2EJoy58YgGSg/1yFdthDBH0+4llsZZD/vug=="], + + "mqtt-packet": ["mqtt-packet@9.0.2", "", { "dependencies": { "bl": "^6.0.8", "debug": "^4.3.4", "process-nextick-args": "^2.0.1" } }, "sha512-MvIY0B8/qjq7bKxdN1eD+nrljoeaai+qjLJgfRn3TiMuz0pamsIWY2bFODPZMSNmabsLANXsLl4EMoWvlaTZWA=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], @@ -789,6 +830,8 @@ "node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="], + "number-allocator": ["number-allocator@1.0.14", "", { "dependencies": { "debug": "^4.3.1", "js-sdsl": "4.3.0" } }, "sha512-OrL44UTVAvkKdOdRQZIJpLkAdjXGTRda052sN4sO77bKEzYYqWKMBjQvrJFzqygI99gL6Z4u2xctPW1tB8ErvA=="], + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], @@ -833,6 +876,10 @@ "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], + + "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], + "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], @@ -855,6 +902,8 @@ "react-transition-group": ["react-transition-group@4.4.5", "", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="], + "readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], + "recharts": ["recharts@2.15.4", "", { "dependencies": { "clsx": "^2.0.0", "eventemitter3": "^4.0.1", "lodash": "^4.17.21", "react-is": "^18.3.1", "react-smooth": "^4.0.4", "recharts-scale": "^0.4.4", "tiny-invariant": "^1.3.1", "victory-vendor": "^36.6.8" }, "peerDependencies": { "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw=="], "recharts-scale": ["recharts-scale@0.4.5", "", { "dependencies": { "decimal.js-light": "^2.4.1" } }, "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w=="], @@ -871,10 +920,14 @@ "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], "safe-array-concat": ["safe-array-concat@1.1.3", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q=="], + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "safe-push-apply": ["safe-push-apply@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="], "safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="], @@ -909,8 +962,16 @@ "simple-swizzle": ["simple-swizzle@0.2.2", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg=="], + "smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="], + + "socks": ["socks@2.8.5", "", { "dependencies": { "ip-address": "^9.0.5", "smart-buffer": "^4.2.0" } }, "sha512-iF+tNDQla22geJdTyJB1wM/qrX9DMRwWrciEPwWLPRWAUEM8sQiyxgckLxWT1f7+9VabJS0jTGGr4QgBuvi6Ww=="], + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], + + "sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], + "stable-hash": ["stable-hash@0.0.5", "", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="], "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], @@ -929,6 +990,8 @@ "string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="], + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + "strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="], "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], @@ -971,6 +1034,8 @@ "typed-array-length": ["typed-array-length@1.0.7", "", { "dependencies": { "call-bind": "^1.0.7", "for-each": "^0.3.3", "gopd": "^1.0.1", "is-typed-array": "^1.1.13", "possible-typed-array-names": "^1.0.0", "reflect.getprototypeof": "^1.0.6" } }, "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg=="], + "typedarray": ["typedarray@0.0.6", "", {}, "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="], + "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], @@ -985,6 +1050,8 @@ "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + "victory-vendor": ["victory-vendor@36.9.2", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], @@ -999,6 +1066,14 @@ "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + "worker-timers": ["worker-timers@7.1.8", "", { "dependencies": { "@babel/runtime": "^7.24.5", "tslib": "^2.6.2", "worker-timers-broker": "^6.1.8", "worker-timers-worker": "^7.0.71" } }, "sha512-R54psRKYVLuzff7c1OTFcq/4Hue5Vlz4bFtNEIarpSiCYhpifHU3aIQI29S84o1j87ePCYqbmEJPqwBTf+3sfw=="], + + "worker-timers-broker": ["worker-timers-broker@6.1.8", "", { "dependencies": { "@babel/runtime": "^7.24.5", "fast-unique-numbers": "^8.0.13", "tslib": "^2.6.2", "worker-timers-worker": "^7.0.71" } }, "sha512-FUCJu9jlK3A8WqLTKXM9E6kAmI/dR1vAJ8dHYLMisLNB/n3GuaFIjJ7pn16ZcD1zCOf7P6H62lWIEBi+yz/zQQ=="], + + "worker-timers-worker": ["worker-timers-worker@7.0.71", "", { "dependencies": { "@babel/runtime": "^7.24.5", "tslib": "^2.6.2" } }, "sha512-ks/5YKwZsto1c2vmljroppOKCivB/ma97g9y77MAAz2TBBjPPgpoOiS1qYQKIgvGTr2QYPT3XhJWIB6Rj2MVPQ=="], + + "ws": ["ws@8.18.2", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ=="], + "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], @@ -1037,6 +1112,8 @@ "@typescript-eslint/typescript-estree/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], + "concat-stream/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + "eslint-import-resolver-node/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], "eslint-module-utils/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], diff --git a/package.json b/package.json index 78a2b12..911db9a 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.519.0", + "mqtt": "^5.13.1", "next": "15.3.4", "port": "^0.8.1", "react": "^19.1.0", diff --git a/services/BedHardware.ts b/services/BedHardware.ts index 762fd6a..8e00e5c 100644 --- a/services/BedHardware.ts +++ b/services/BedHardware.ts @@ -1,181 +1,126 @@ -import { SerialPort } from 'serialport'; -import { ReadlineParser } from '@serialport/parser-readline'; import { EventEmitter } from 'events'; +import { IBedHardware, PinState, PinChange, BedHardwareConfig } from '../types/bedhardware'; +import { BedHardwareSerial } from './BedHardwareSerial'; +import { BedHardwareMQTT } from './BedHardwareMQTT'; -export interface PinState { - pin: number; - state: number; - name: string; - timestamp: Date; -} +/** + * BedHardware - Factory class for creating bed hardware implementations + * + * Usage: + * // MQTT (connects to broker.hivemq.com with base topic /Jtkcp2N/pressurebed/) + * const hardware = BedHardware.createSimpleMQTT(); + * + * // Serial + * const hardware = BedHardware.createSerial('COM3', 9600); + * + * // With custom topics + * const hardware = BedHardware.createMQTT({ + * topics: { + * pinState: '/custom/pin/state', + * pinChange: '/custom/pin/change', + * initialization: '/custom/init' + * } + * }); + */ +export class BedHardware extends EventEmitter implements IBedHardware { + private implementation: IBedHardware; -export interface PinChange { - pin: number; - previousState: number; - currentState: number; - timestamp: Date; -} - -export class BedHardware extends EventEmitter { - private serialPort: SerialPort | null = null; - private parser: ReadlineParser | null = null; - private pinStates: Map = new Map(); - private isConnected: boolean = false; - - constructor(private portPath: string, private baudRate: number = 9600) { + constructor(config: BedHardwareConfig) { super(); + + if (config.type === 'serial') { + if (!config.serial?.portPath) { + throw new Error('Serial port path is required for serial connection'); + } + this.implementation = new BedHardwareSerial( + config.serial.portPath, + config.serial.baudRate || 9600 ); + } else if (config.type === 'mqtt') { + this.implementation = new BedHardwareMQTT({ + topics: config.mqtt?.topics + }); + } else { + throw new Error(`Unsupported hardware type: ${config.type}`); + } + + // Forward all events from the implementation + this.forwardEvents(); + } + + private forwardEvents(): void { + const events = [ + 'connected', + 'disconnected', + 'error', + 'initialized', + 'pinInitialized', + 'pinChanged' + ]; + + events.forEach(event => { + this.implementation.on(event, (...args: unknown[]) => { + this.emit(event, ...args); + }); + }); + + // Forward dynamic pin events (pin{number}Changed) + this.implementation.on('pinChanged', (pinChange: PinChange) => { + this.emit(`pin${pinChange.pin}Changed`, pinChange); + }); } async connect(): Promise { - try { - this.serialPort = new SerialPort({ - path: this.portPath, - baudRate: this.baudRate, - autoOpen: false - }); - - this.parser = new ReadlineParser({ delimiter: '\n' }); - this.serialPort.pipe(this.parser); - - // Setup event handlers - this.serialPort.on('open', () => { - this.isConnected = true; - this.emit('connected'); - console.log('Serial port opened'); - }); - - this.serialPort.on('error', (error) => { - this.emit('error', error); - console.error('Serial port error:', error); - }); - - this.serialPort.on('close', () => { - this.isConnected = false; - this.emit('disconnected'); - console.log('Serial port closed'); - }); - - this.parser.on('data', (data: string) => { - this.handleSerialData(data.trim()); - }); - - // Open the port - await new Promise((resolve, reject) => { - this.serialPort!.open((error) => { - if (error) { - reject(error); - } else { - resolve(); - } - }); - }); - - } catch (error) { - throw new Error(`Failed to connect to ${this.portPath}: ${error}`); - } + return this.implementation.connect(); } async disconnect(): Promise { - if (this.serialPort && this.serialPort.isOpen) { - await new Promise((resolve) => { - this.serialPort!.close(() => { - resolve(); - }); - }); - } - this.serialPort = null; - this.parser = null; - this.isConnected = false; - } - - private handleSerialData(data: string): void { - const parts = data.split(':'); - - if (parts[0] === 'INIT') { - if (parts[1] === 'START') { - this.emit('initialized'); - console.log('Arduino initialization started'); - } else if (parts.length >= 3) { - // INIT:PIN:STATE format - const pin = parseInt(parts[1]); - const state = parseInt(parts[2]); - - if (!isNaN(pin) && !isNaN(state)) { - const pinState: PinState = { - pin, - state, - name: `PIN${pin}`, - timestamp: new Date() - }; - - this.pinStates.set(pin, pinState); - this.emit('pinInitialized', pinState); - } - } - } else if (parts[0] === 'CHANGE' && parts.length >= 4) { - // CHANGE:PIN:PREVIOUS_STATE:CURRENT_STATE format - const pin = parseInt(parts[1]); - const previousState = parseInt(parts[2]); - const currentState = parseInt(parts[3]); - - if (!isNaN(pin) && !isNaN(previousState) && !isNaN(currentState)) { - const pinChange: PinChange = { - pin, - previousState, - currentState, - timestamp: new Date() - }; - - // Update stored pin state - const pinState: PinState = { - pin, - state: currentState, - name: `PIN${pin}`, - timestamp: new Date() - }; - - this.pinStates.set(pin, pinState); - - this.emit('pinChanged', pinChange); - this.emit(`pin${pin}Changed`, pinChange); - } - } + return this.implementation.disconnect(); } getPinState(pin: number): PinState | undefined { - return this.pinStates.get(pin); + return this.implementation.getPinState(pin); } getAllPinStates(): PinState[] { - return Array.from(this.pinStates.values()); + return this.implementation.getAllPinStates(); } - isPortConnected(): boolean { - return this.isConnected && this.serialPort?.isOpen === true; + isConnected(): boolean { + return this.implementation.isConnected(); } - // Static method to list available serial ports - static async listPorts(): Promise { - const ports = await SerialPort.list(); - return ports.map(port => port.path); + // Static factory methods for convenience + static createSerial(portPath: string, baudRate: number = 9600): BedHardware { + return new BedHardware({ + type: 'serial', + serial: { portPath, baudRate } + }); + } static createMQTT(config?: { + topics?: { + pinState: string; + pinChange: string; + initialization: string; + }; + }): BedHardware { + return new BedHardware({ + type: 'mqtt', + mqtt: config + }); + } + + static createSimpleMQTT(): BedHardware { + return new BedHardware({ + type: 'mqtt', + mqtt: {} + }); + } + + // Static method to list available serial ports (for serial implementation) + static async listSerialPorts(): Promise { + return BedHardwareSerial.listPorts(); } } -// Example usage: -/* -const bedHardware = new BedHardware('/dev/ttyUSB0', 9600); - -bedHardware.on('connected', () => { - console.log('Connected to bed hardware'); -}); - -bedHardware.on('pinChanged', (change: PinChange) => { - console.log(`Pin ${change.pin} changed from ${change.previousState} to ${change.currentState}`); -}); - -bedHardware.on('error', (error) => { - console.error('Hardware error:', error); -}); - -await bedHardware.connect(); -*/ \ No newline at end of file +// Export all classes for direct access if needed +export { BedHardwareSerial, BedHardwareMQTT }; +export * from '../types/bedhardware'; \ No newline at end of file diff --git a/services/BedHardwareMQTT.ts b/services/BedHardwareMQTT.ts new file mode 100644 index 0000000..29ba4a4 --- /dev/null +++ b/services/BedHardwareMQTT.ts @@ -0,0 +1,140 @@ +import { EventEmitter } from 'events'; +import { IBedHardware, PinState, PinChange } from '../types/bedhardware'; +import MQTT from '../adapter/mqtt'; +import { getMQTTClient, BASE_TOPIC } from './mqttService'; + +export interface MqttConfig { + topics?: { + pinState: string; + pinChange: string; + initialization: string; + }; +} + +export class BedHardwareMQTT extends EventEmitter implements IBedHardware { + private client: MQTT | null = null; + private pinStates: Map = new Map(); + private connectionState: boolean = false; + private topics: { + pinState: string; + pinChange: string; + initialization: string; + }; + + constructor(private config: MqttConfig = {}) { + super(); + this.topics = config.topics || { + pinState: `${BASE_TOPIC}pin/state`, + pinChange: `${BASE_TOPIC}pin/change`, + initialization: `${BASE_TOPIC}init` + }; + } + async connect(): Promise { + try { + // Use the MQTT service to get a client connected to HiveMQ + this.client = await getMQTTClient(); + + // Subscribe to topics - adapter handles reconnection/resubscription + await this.client.subscribe(this.topics.initialization, (topic, message) => { + this.handleMqttMessage(topic, message); + }); + + await this.client.subscribe(this.topics.pinState, (topic, message) => { + this.handleMqttMessage(topic, message); + }); + + await this.client.subscribe(this.topics.pinChange, (topic, message) => { + this.handleMqttMessage(topic, message); + }); + + this.connectionState = true; + this.emit('connected'); + console.log('BedHardware MQTT connected to HiveMQ'); + + } catch (error) { + const errorMsg = `Failed to connect to MQTT broker: ${error}`; + console.error(errorMsg); + this.emit('error', new Error(errorMsg)); + throw new Error(errorMsg); + } + } async disconnect(): Promise { + if (this.client) { + await this.client.disconnect(); + } + this.client = null; + this.connectionState = false; + this.emit('disconnected'); + } + + private handleMqttMessage(topic: string, message: string): void { + try { + const data = JSON.parse(message); + + if (topic === this.topics.initialization) { + if (data.type === 'START') { + this.emit('initialized'); + console.log('MQTT initialization started'); + } else if (data.type === 'PIN_INIT' && data.pin !== undefined && data.state !== undefined) { + const pinState: PinState = { + pin: data.pin, + state: data.state, + name: data.name || `PIN${data.pin}`, + timestamp: new Date(data.timestamp || Date.now()) + }; + + this.pinStates.set(data.pin, pinState); + this.emit('pinInitialized', pinState); + } + } else if (topic === this.topics.pinChange) { + if (data.pin !== undefined && data.previousState !== undefined && data.currentState !== undefined) { + const pinChange: PinChange = { + pin: data.pin, + previousState: data.previousState, + currentState: data.currentState, + timestamp: new Date(data.timestamp || Date.now()) + }; + + // Update stored pin state + const pinState: PinState = { + pin: data.pin, + state: data.currentState, + name: data.name || `PIN${data.pin}`, + timestamp: new Date(data.timestamp || Date.now()) + }; + + this.pinStates.set(data.pin, pinState); + + this.emit('pinChanged', pinChange); + this.emit(`pin${data.pin}Changed`, pinChange); + } + } else if (topic === this.topics.pinState) { + if (data.pin !== undefined && data.state !== undefined) { + const pinState: PinState = { + pin: data.pin, + state: data.state, + name: data.name || `PIN${data.pin}`, + timestamp: new Date(data.timestamp || Date.now()) + }; + + this.pinStates.set(data.pin, pinState); + this.emit('pinInitialized', pinState); + } + } + } catch (error) { + console.error('Error parsing MQTT message:', error); + this.emit('error', new Error(`Failed to parse MQTT message: ${error}`)); + } + } + + getPinState(pin: number): PinState | undefined { + return this.pinStates.get(pin); + } + + getAllPinStates(): PinState[] { + return Array.from(this.pinStates.values()); + } + + isConnected(): boolean { + return this.connectionState; + } +} \ No newline at end of file diff --git a/services/BedHardwareSerial.ts b/services/BedHardwareSerial.ts new file mode 100644 index 0000000..1bf4537 --- /dev/null +++ b/services/BedHardwareSerial.ts @@ -0,0 +1,149 @@ +import { SerialPort } from 'serialport'; +import { ReadlineParser } from '@serialport/parser-readline'; +import { EventEmitter } from 'events'; +import { IBedHardware, PinState, PinChange } from '../types/bedhardware'; + +export class BedHardwareSerial extends EventEmitter implements IBedHardware { + private serialPort: SerialPort | null = null; + private parser: ReadlineParser | null = null; + private pinStates: Map = new Map(); + private connectionState: boolean = false; + + constructor(private portPath: string, private baudRate: number = 9600) { + super(); + } + + async connect(): Promise { + try { + this.serialPort = new SerialPort({ + path: this.portPath, + baudRate: this.baudRate, + autoOpen: false + }); + + this.parser = new ReadlineParser({ delimiter: '\n' }); + this.serialPort.pipe(this.parser); + + // Setup event handlers + this.serialPort.on('open', () => { + this.connectionState = true; + this.emit('connected'); + console.log('Serial port opened'); + }); + + this.serialPort.on('error', (error) => { + this.emit('error', error); + console.error('Serial port error:', error); + }); + + this.serialPort.on('close', () => { + this.connectionState = false; + this.emit('disconnected'); + console.log('Serial port closed'); + }); + + this.parser.on('data', (data: string) => { + this.handleSerialData(data.trim()); + }); + + // Open the port + await new Promise((resolve, reject) => { + this.serialPort!.open((error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); + + } catch (error) { + throw new Error(`Failed to connect to ${this.portPath}: ${error}`); + } + } + + async disconnect(): Promise { + if (this.serialPort && this.serialPort.isOpen) { + await new Promise((resolve) => { + this.serialPort!.close(() => { + resolve(); + }); + }); + } + this.serialPort = null; + this.parser = null; + this.connectionState = false; + } + + private handleSerialData(data: string): void { + const parts = data.split(':'); + + if (parts[0] === 'INIT') { + if (parts[1] === 'START') { + this.emit('initialized'); + console.log('Arduino initialization started'); + } else if (parts.length >= 3) { + // INIT:PIN:STATE format + const pin = parseInt(parts[1]); + const state = parseInt(parts[2]); + + if (!isNaN(pin) && !isNaN(state)) { + const pinState: PinState = { + pin, + state, + name: `PIN${pin}`, + timestamp: new Date() + }; + + this.pinStates.set(pin, pinState); + this.emit('pinInitialized', pinState); + } + } + } else if (parts[0] === 'CHANGE' && parts.length >= 4) { + // CHANGE:PIN:PREVIOUS_STATE:CURRENT_STATE format + const pin = parseInt(parts[1]); + const previousState = parseInt(parts[2]); + const currentState = parseInt(parts[3]); + + if (!isNaN(pin) && !isNaN(previousState) && !isNaN(currentState)) { + const pinChange: PinChange = { + pin, + previousState, + currentState, + timestamp: new Date() + }; + + // Update stored pin state + const pinState: PinState = { + pin, + state: currentState, + name: `PIN${pin}`, + timestamp: new Date() + }; + + this.pinStates.set(pin, pinState); + + this.emit('pinChanged', pinChange); + this.emit(`pin${pin}Changed`, pinChange); + } + } + } + + getPinState(pin: number): PinState | undefined { + return this.pinStates.get(pin); + } + + getAllPinStates(): PinState[] { + return Array.from(this.pinStates.values()); + } + + isConnected(): boolean { + return this.connectionState && this.serialPort?.isOpen === true; + } + + // Static method to list available serial ports + static async listPorts(): Promise { + const ports = await SerialPort.list(); + return ports.map(port => port.path); + } +} \ No newline at end of file diff --git a/services/mqttService.ts b/services/mqttService.ts new file mode 100644 index 0000000..59c787e --- /dev/null +++ b/services/mqttService.ts @@ -0,0 +1,72 @@ +import MQTT, { MQTTConfig } from '../adapter/mqtt'; + +// Default MQTT configuration for HiveMQ broker +const defaultConfig: MQTTConfig = { + host: 'broker.hivemq.com', + port: 1883, + username: undefined, + password: undefined +}; + +export const BASE_TOPIC = '/Jtkcp2N/pressurebed/'; + +// Singleton MQTT client instance +let mqttInstance: MQTT | null = null; + +export class MQTTService { + private static instance: MQTT | null = null; + + static async initialize(config?: Partial): Promise { + if (!MQTTService.instance) { + const finalConfig = { ...defaultConfig, ...config }; + MQTTService.instance = new MQTT(); + await MQTTService.instance.initialize(finalConfig); + } + return MQTTService.instance; + } + + static getInstance(): MQTT | null { + return MQTTService.instance; + } + + static async disconnect(): Promise { + if (MQTTService.instance) { + await MQTTService.instance.disconnect(); + MQTTService.instance = null; + } + } +} + +// Factory function to get or create MQTT client +export async function getMQTTClient(config?: Partial): Promise { + if (!mqttInstance) { + mqttInstance = new MQTT(); + const finalConfig = { ...defaultConfig, ...config }; + await mqttInstance.initialize(finalConfig); + } + return mqttInstance; +} + +// Export the singleton instance getter +export function getMQTTInstance(): MQTT | null { + return mqttInstance; +} + +// Cleanup function +export async function disconnectMQTT(): Promise { + if (mqttInstance) { + await mqttInstance.disconnect(); + mqttInstance = null; + } +} + +// Export default configured client (lazy initialization) +const mqttService = { + async getClient(config?: Partial): Promise { + return getMQTTClient(config); + }, + getInstance: getMQTTInstance, + disconnect: disconnectMQTT +}; + +export default mqttService; \ No newline at end of file diff --git a/types/bedhardware.ts b/types/bedhardware.ts new file mode 100644 index 0000000..32dccc1 --- /dev/null +++ b/types/bedhardware.ts @@ -0,0 +1,53 @@ +export interface PinState { + pin: number; + state: number; + name: string; + timestamp: Date; +} + +export interface PinChange { + pin: number; + previousState: number; + currentState: number; + timestamp: Date; +} + +export interface BedHardwareConfig { + type: 'serial' | 'mqtt'; + serial?: { + portPath: string; + baudRate?: number; + }; + mqtt?: { + topics?: { + pinState: string; + pinChange: string; + initialization: string; + }; + }; +} + +export interface IBedHardware { + connect(): Promise; + disconnect(): Promise; + getPinState(pin: number): PinState | undefined; + getAllPinStates(): PinState[]; + isConnected(): boolean; + + // Event emitter methods on(event: 'connected', listener: () => void): this; + on(event: 'disconnected', listener: () => void): this; + on(event: 'error', listener: (error: Error) => void): this; + on(event: 'initialized', listener: () => void): this; + on(event: 'pinInitialized', listener: (pinState: PinState) => void): this; + on(event: 'pinChanged', listener: (pinChange: PinChange) => void): this; + on(event: string, listener: (...args: unknown[]) => void): this; + emit(event: 'connected'): boolean; + emit(event: 'disconnected'): boolean; + emit(event: 'error', error: Error): boolean; + emit(event: 'initialized'): boolean; + emit(event: 'pinInitialized', pinState: PinState): boolean; + emit(event: 'pinChanged', pinChange: PinChange): boolean; + emit(event: string, ...args: unknown[]): boolean; +} + +export type BedHardwareType = 'serial' | 'mqtt'; \ No newline at end of file