diff --git a/dist/index.html b/dist/index.html
new file mode 100644
index 0000000..54380a5
--- /dev/null
+++ b/dist/index.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+ Tischlerei Dashboard
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index 46a563b..8dfb958 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -32,6 +32,7 @@
"react-dom": "^19.2.3",
"style-loader": "^4.0.0",
"telegraf": "^4.16.3",
+ "tp-link-tapo-connect": "^2.0.8",
"webpack": "^5.104.1",
"webpack-cli": "^6.0.1",
"webpack-dev-middleware": "^7.4.5"
@@ -2512,6 +2513,15 @@
}
}
},
+ "node_modules/@network-utils/arp-lookup": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@network-utils/arp-lookup/-/arp-lookup-2.1.0.tgz",
+ "integrity": "sha512-DFrRcGecVuouFW6KMOX4qnCWkCH44er/A5udIPW7j4aiHDyVjQB5SG9/+beeV7066iA5ZasbBzu162F9ofHNmA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.10.0"
+ }
+ },
"node_modules/@popperjs/core": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
@@ -2938,6 +2948,21 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
+ "node_modules/any-promise": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
+ "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
+ "license": "MIT"
+ },
+ "node_modules/assert-plus": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz",
+ "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
"node_modules/auto-bind": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz",
@@ -2950,6 +2975,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/axios": {
+ "version": "0.21.4",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz",
+ "integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==",
+ "license": "MIT",
+ "dependencies": {
+ "follow-redirects": "^1.14.0"
+ }
+ },
"node_modules/babel-loader": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-10.0.0.tgz",
@@ -3349,6 +3383,12 @@
"node": ">=6.0"
}
},
+ "node_modules/cidr-regex": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/cidr-regex/-/cidr-regex-1.0.7.tgz",
+ "integrity": "sha512-4sQNVjJw/I3kHyb9FGSJ/dBT18BP7pf5zongGsEYExNvtTss24hgJxZr0JK1eWlumX/ij4yiR/vYlndRynnkDQ==",
+ "license": "MIT"
+ },
"node_modules/clean-css": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.3.tgz",
@@ -3540,6 +3580,12 @@
"url": "https://opencollective.com/core-js"
}
},
+ "node_modules/core-util-is": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
+ "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==",
+ "license": "MIT"
+ },
"node_modules/cosmiconfig": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz",
@@ -4123,6 +4169,15 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/extsprintf": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz",
+ "integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==",
+ "engines": [
+ "node >=0.6.0"
+ ],
+ "license": "MIT"
+ },
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -4212,6 +4267,26 @@
"flat": "cli.js"
}
},
+ "node_modules/follow-redirects": {
+ "version": "1.15.11",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
+ "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/RubenVerborgh"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0"
+ },
+ "peerDependenciesMeta": {
+ "debug": {
+ "optional": true
+ }
+ }
+ },
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -4290,6 +4365,17 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/get-ip-range": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/get-ip-range/-/get-ip-range-2.1.1.tgz",
+ "integrity": "sha512-n401kTpf57VhbW2UvInXxhw1DgTJSsZf24gpNhJrFU8Cv+Jk/sQ+qukP2x0EF4MUEjDWlDLeIhIY/f80IV0kqg==",
+ "license": "ISC",
+ "dependencies": {
+ "cidr-regex": "^1.0.7",
+ "ip": "^1.1.5",
+ "ip-address": "^6.1.0"
+ }
+ },
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
@@ -4687,6 +4773,30 @@
"node": ">=10.13.0"
}
},
+ "node_modules/ip": {
+ "version": "1.1.9",
+ "resolved": "https://registry.npmjs.org/ip/-/ip-1.1.9.tgz",
+ "integrity": "sha512-cyRxvOEpNHNtchU3Ln9KC/auJgup87llfQpQ+t5ghoC/UhL16SWzbueiCsdTnWmqAWl7LadfuwhlqmtOaqMHdQ==",
+ "license": "MIT"
+ },
+ "node_modules/ip-address": {
+ "version": "6.4.0",
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-6.4.0.tgz",
+ "integrity": "sha512-c5uxc2WUTuRBVHT/6r4m7HIr/DfV0bF6DvLH3iZGSK8wp8iMwwZSgIq2do0asFf8q9ECug0SE+6+1ACMe4sorA==",
+ "license": "MIT",
+ "dependencies": {
+ "jsbn": "1.1.0",
+ "lodash.find": "4.6.0",
+ "lodash.max": "4.0.1",
+ "lodash.merge": "4.6.2",
+ "lodash.padstart": "4.6.1",
+ "lodash.repeat": "4.1.0",
+ "sprintf-js": "1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -4800,6 +4910,12 @@
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"license": "MIT"
},
+ "node_modules/jsbn": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz",
+ "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==",
+ "license": "MIT"
+ },
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
@@ -4818,6 +4934,12 @@
"integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
"license": "MIT"
},
+ "node_modules/json-schema": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
+ "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==",
+ "license": "(AFL-2.1 OR BSD-3-Clause)"
+ },
"node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
@@ -4858,6 +4980,21 @@
"npm": ">=6"
}
},
+ "node_modules/jsprim": {
+ "version": "1.4.2",
+ "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz",
+ "integrity": "sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==",
+ "license": "MIT",
+ "dependencies": {
+ "assert-plus": "1.0.0",
+ "extsprintf": "1.3.0",
+ "json-schema": "0.4.0",
+ "verror": "1.10.0"
+ },
+ "engines": {
+ "node": ">=0.6.0"
+ }
+ },
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
@@ -4907,6 +5044,20 @@
"url": "https://opencollective.com/webpack"
}
},
+ "node_modules/local-devices": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/local-devices/-/local-devices-4.0.0.tgz",
+ "integrity": "sha512-65EPltZW7bPRTmIiL9yuQcu0Om6dYl4dzRgiIYxl/cUO62FBW09ZpIVtpffXU8vryfGXEXxt68oWK/Kru12FEA==",
+ "license": "MIT",
+ "dependencies": {
+ "get-ip-range": "^2.1.0",
+ "ip": "^1.1.5",
+ "mz": "^2.7.0"
+ },
+ "engines": {
+ "node": ">=10.17"
+ }
+ },
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -4934,6 +5085,12 @@
"integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
"license": "MIT"
},
+ "node_modules/lodash.find": {
+ "version": "4.6.0",
+ "resolved": "https://registry.npmjs.org/lodash.find/-/lodash.find-4.6.0.tgz",
+ "integrity": "sha512-yaRZoAV3Xq28F1iafWN1+a0rflOej93l1DQUejs3SZ41h2O9UJBoS9aueGjPDgAl4B6tPC0NuuchLKaDQQ3Isg==",
+ "license": "MIT"
+ },
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
@@ -4970,12 +5127,36 @@
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
"license": "MIT"
},
+ "node_modules/lodash.max": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/lodash.max/-/lodash.max-4.0.1.tgz",
+ "integrity": "sha512-iykTDTb7PK33HSQmKy34zv+hh4WEu7WonJPXQcgODzUbbtradtNs8RsD/GI7XV++60KaKR1xhW56N4ISqHesfQ==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.merge": {
+ "version": "4.6.2",
+ "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
+ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
+ "license": "MIT"
+ },
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
+ "node_modules/lodash.padstart": {
+ "version": "4.6.1",
+ "resolved": "https://registry.npmjs.org/lodash.padstart/-/lodash.padstart-4.6.1.tgz",
+ "integrity": "sha512-sW73O6S8+Tg66eY56DBk85aQzzUJDtpoXFBgELMd5P/SotAguo+1kYO6RuYgXxA4HJH3LFTFPASX6ET6bjfriw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.repeat": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/lodash.repeat/-/lodash.repeat-4.1.0.tgz",
+ "integrity": "sha512-eWsgQW89IewS95ZOcr15HHCX6FVDxq3f2PNUIng3fyzsPev9imFQxIYdFZ6crl8L56UR6ZlGDLcEb3RZsCSSqw==",
+ "license": "MIT"
+ },
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -5006,6 +5187,16 @@
"yallist": "^3.0.2"
}
},
+ "node_modules/macaddr": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/macaddr/-/macaddr-0.1.1.tgz",
+ "integrity": "sha512-GgNo6LbhjKCQ42DFgB7gUvbNmbccfFxP9WOf2zaA9bnya/5Fe0VpeA664WVbYELqLI4Bxdx9KKLXHwe4xglvIw==",
+ "license": "MPL-2.0",
+ "dependencies": {
+ "assert-plus": "^1.0.0",
+ "jsprim": "^1.4.0"
+ }
+ },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -5145,6 +5336,17 @@
"node": "^20.17.0 || >=22.9.0"
}
},
+ "node_modules/mz": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
+ "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
+ "license": "MIT",
+ "dependencies": {
+ "any-promise": "^1.0.0",
+ "object-assign": "^4.0.1",
+ "thenify-all": "^1.0.0"
+ }
+ },
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -6405,6 +6607,12 @@
"node": ">=0.10.0"
}
},
+ "node_modules/sprintf-js": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.2.tgz",
+ "integrity": "sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug==",
+ "license": "BSD-3-Clause"
+ },
"node_modules/stack-utils": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz",
@@ -6678,6 +6886,27 @@
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"license": "MIT"
},
+ "node_modules/thenify": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
+ "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
+ "license": "MIT",
+ "dependencies": {
+ "any-promise": "^1.0.0"
+ }
+ },
+ "node_modules/thenify-all": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
+ "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
+ "license": "MIT",
+ "dependencies": {
+ "thenify": ">= 3.1.0 < 4"
+ },
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
"node_modules/thingies": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/thingies/-/thingies-2.5.0.tgz",
@@ -6703,6 +6932,19 @@
"node": ">=0.6"
}
},
+ "node_modules/tp-link-tapo-connect": {
+ "version": "2.0.8",
+ "resolved": "https://registry.npmjs.org/tp-link-tapo-connect/-/tp-link-tapo-connect-2.0.8.tgz",
+ "integrity": "sha512-2LcYhx/CEGWKQOMY3Cgb6s7HQ7oAzbgh7Z5BwDqm9D0R0RAa9z/H3F8eWZaBhj5wD69yrTE6b1uh0ib5G3Rv8w==",
+ "license": "ISC",
+ "dependencies": {
+ "@network-utils/arp-lookup": "^2.1.0",
+ "axios": "^0.21.4",
+ "local-devices": "^4.0.0",
+ "macaddr": "^0.1.1",
+ "uuid": "^8.3.2"
+ }
+ },
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
@@ -6866,6 +7108,15 @@
"integrity": "sha512-Z0DbgELS9/L/75wZbro8xAnT50pBVFQZ+hUEueGDU5FN51YSCYM+jdxsfCiHjwNP/4LCDD0i/graKpeBnOXKRA==",
"license": "MIT"
},
+ "node_modules/uuid": {
+ "version": "8.3.2",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
+ "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
+ "license": "MIT",
+ "bin": {
+ "uuid": "dist/bin/uuid"
+ }
+ },
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
@@ -6875,6 +7126,20 @@
"node": ">= 0.8"
}
},
+ "node_modules/verror": {
+ "version": "1.10.0",
+ "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz",
+ "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==",
+ "engines": [
+ "node >=0.6.0"
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "assert-plus": "^1.0.0",
+ "core-util-is": "1.0.2",
+ "extsprintf": "^1.2.0"
+ }
+ },
"node_modules/watchpack": {
"version": "2.4.4",
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz",
diff --git a/package.json b/package.json
index a33d725..1fad010 100644
--- a/package.json
+++ b/package.json
@@ -34,6 +34,7 @@
"react-dom": "^19.2.3",
"style-loader": "^4.0.0",
"telegraf": "^4.16.3",
+ "tp-link-tapo-connect": "^2.0.8",
"webpack": "^5.104.1",
"webpack-cli": "^6.0.1",
"webpack-dev-middleware": "^7.4.5"
diff --git a/src/client/AlarmCard.js b/src/client/AlarmCard.js
index bb67bed..2aeb7ee 100644
--- a/src/client/AlarmCard.js
+++ b/src/client/AlarmCard.js
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { Component } from 'react';
import {
Card,
CardContent,
@@ -10,126 +10,132 @@ import {
Tooltip
} from '@mui/material';
-export default function AlarmCard({ alarm, onEdit, onDelete, onToggle, onMoveUp, onMoveDown, colorTags, readOnly }) {
- // Parse trigger/action data to display summary
- const trigger = alarm.trigger || {};
- const action = alarm.action || {};
-
- // Get color for tag
- const getTagColor = (tagId) => {
+class AlarmCard extends Component {
+ getTagColor = (tagId) => {
+ const { colorTags } = this.props;
const tag = colorTags.find(t => t.id === tagId);
return tag ? tag.color : 'transparent';
};
- const hasTags = alarm.colorTags && alarm.colorTags.length > 0;
+ render() {
+ const { alarm, onEdit, onDelete, onToggle, onMoveUp, onMoveDown, readOnly } = this.props;
- return (
-
-
+ // Parse trigger/action data to display summary
+ const trigger = alarm.trigger || {};
+ const action = alarm.action || {};
- {/* Drag Handle / Sort indicators */}
- {!readOnly && (onMoveUp || onMoveDown) && (
-
-
- ▲
-
-
- ▼
-
-
- )}
+ const hasTags = alarm.colorTags && alarm.colorTags.length > 0;
- {/* Enabled Switch */}
-
+ return (
+
+
-
-
-
- {alarm.name}
+ {/* Drag Handle / Sort indicators */}
+ {!readOnly && (onMoveUp || onMoveDown) && (
+
+
+ ▲
+
+
+ ▼
+
+
+ )}
+
+ {/* Enabled Switch */}
+
+
+
+
+
+ {alarm.name}
+
+
+ {/* Tags */}
+ {hasTags && (
+
+ {alarm.colorTags.map(tagId => (
+
+ ))}
+
+ )}
+
+ {action.severity && (
+
+ )}
+
+
+
+ {/* Trigger Summary */}
+ {trigger.scheduledTime ? (
+ ⏰ {trigger.scheduledTime.time} ({trigger.scheduledTime.days.map(d => d.slice(0, 3)).join(',')})
+ ) : trigger.timeRange ? (
+ ⏰ {trigger.timeRange.start}-{trigger.timeRange.end}
+ ) : trigger.sensors ? (
+
+ 📊 {trigger.sensors.map(s => `${s.sensorLabel || s.sensor} ${s.operator} ${s.value}`).join(trigger.sensorLogic === 'or' ? ' OR ' : ' AND ')}
+
+ ) : (
+ Unknown Trigger
+ )}
+ ➜
+ 🔔 Telegram: "{action.message || 'Alert'}"
-
- {/* Tags */}
- {hasTags && (
-
- {alarm.colorTags.map(tagId => (
-
- ))}
-
- )}
-
- {action.severity && (
-
- )}
-
- {/* Trigger Summary */}
- {trigger.scheduledTime ? (
- ⏰ {trigger.scheduledTime.time} ({trigger.scheduledTime.days.map(d => d.slice(0, 3)).join(',')})
- ) : trigger.timeRange ? (
- ⏰ {trigger.timeRange.start}-{trigger.timeRange.end}
- ) : trigger.sensors ? (
-
- 📊 {trigger.sensors.map(s => `${s.sensorLabel || s.sensor} ${s.operator} ${s.value}`).join(trigger.sensorLogic === 'or' ? ' OR ' : ' AND ')}
-
- ) : (
- Unknown Trigger
- )}
- ➜
- 🔔 Telegram: "{action.message || 'Alert'}"
-
-
-
- {/* Actions */}
- {!readOnly && (
-
-
- ✎
-
-
- 🗑
-
-
- )}
-
-
- );
+ {/* Actions */}
+ {!readOnly && (
+
+
+ ✎
+
+
+ 🗑
+
+
+ )}
+
+
+ );
+ }
}
+
+export default AlarmCard;
diff --git a/src/client/AlarmEditor.js b/src/client/AlarmEditor.js
index 4bafc82..a65f61c 100644
--- a/src/client/AlarmEditor.js
+++ b/src/client/AlarmEditor.js
@@ -1,4 +1,4 @@
-import React, { useState, useEffect } from 'react';
+import React, { Component } from 'react';
import {
Dialog,
DialogTitle,
@@ -23,7 +23,7 @@ import {
Chip,
Alert
} from '@mui/material';
-import { useI18n } from './I18nContext';
+import { withI18n } from './I18nContext';
// Reusing some constants/components from RuleEditor logic if possible, but duplicating for isolation as per plan
const DAYS_KEYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
@@ -36,177 +36,170 @@ const OPERATORS = [
{ value: '==', label: '=' }
];
-function SensorCondition({ condition, sensors, onChange, onRemove, disabled }) {
- return (
-
-
- Sensor
-
-
+class SensorCondition extends Component {
+ render() {
+ const { condition, sensors, onChange, onRemove, disabled } = this.props;
-
-
-
- onChange({ ...condition, value: Number(e.target.value) })}
- sx={{ width: 80 }}
- disabled={disabled}
- />
+ return (
+
+
+ Sensor
+
+
- {onRemove && (
-
- ❌
-
- )}
-
- );
+
+
+
+ onChange({ ...condition, value: Number(e.target.value) })}
+ sx={{ width: 80 }}
+ disabled={disabled}
+ />
+
+ {onRemove && (
+
+ ❌
+
+ )}
+
+ );
+ }
}
-export default function AlarmEditor({ open, alarm, onSave, onClose, sensors = [], outputs = [], colorTags: availableColorTags = [], saving }) {
- const { t } = useI18n();
- const [name, setName] = useState('');
- const [selectedTags, setSelectedTags] = useState([]);
+class AlarmEditor extends Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ name: '',
+ selectedTags: [],
+ triggerMode: 'sensor',
+ outputTarget: '',
+ outputState: 'on',
+ useTimeRange: false,
+ timeStart: '08:00',
+ timeEnd: '18:00',
+ timeRangeDays: ['mon', 'tue', 'wed', 'thu', 'fri'],
+ sensorConditions: [{ sensor: '', operator: '>', value: 25 }],
+ sensorLogic: 'and',
+ message: '',
+ severity: 'warning'
+ };
+ }
- // Trigger Mode: 'sensor' or 'output'
- const [triggerMode, setTriggerMode] = useState('sensor');
+ componentDidUpdate(prevProps) {
+ if (this.props.open !== prevProps.open || this.props.alarm !== prevProps.alarm) {
+ this.initializeState();
+ }
+ }
- // Output Change Config
- const [outputTarget, setOutputTarget] = useState('');
- const [outputState, setOutputState] = useState('on');
+ componentDidMount() {
+ this.initializeState();
+ }
- // Scheduled time (not commonly used for alarms, but keeping parity with rules engine if needed)
- // Actually, alarms are usually condition-based (Value > X). Time-based alarms remind you to do something?
- // Let's keep it simple: SENSORS ONLY for now described in plan ("triggers (based on sensors, time, etc.)")
- // I'll keep the UI structure but maybe default to Sensors.
+ initializeState = () => {
+ const { alarm, sensors } = this.props;
- // Simplification: Alarms usually monitor state.
- // "Time Range" is valid (only alarm between 8am-8pm).
- // "Scheduled Time" (Alarm at 8am) is basically a Reminder.
-
- // I will include: Time Range (Active Window) and Sensor Conditions.
- // I'll omit "Scheduled Time" as a trigger for now unless requested, to reduce complexity,
- // as "Alarm at 8am" is just an event. The user asked for "similar to alarms".
-
- const [useTimeRange, setUseTimeRange] = useState(false);
- const [timeStart, setTimeStart] = useState('08:00');
- const [timeEnd, setTimeEnd] = useState('18:00');
- const [timeRangeDays, setTimeRangeDays] = useState(['mon', 'tue', 'wed', 'thu', 'fri']);
-
- const [sensorConditions, setSensorConditions] = useState([{ sensor: '', operator: '>', value: 25 }]);
- const [sensorLogic, setSensorLogic] = useState('and');
-
- // Action State (Telegram)
- const [message, setMessage] = useState('');
- const [severity, setSeverity] = useState('warning');
-
- useEffect(() => {
if (alarm) {
- setName(alarm.name);
const tags = alarm.colorTags || (alarm.colorTag ? [alarm.colorTag] : []);
- setSelectedTags(Array.isArray(tags) ? tags : []);
-
const trigger = alarm.trigger || {};
- setUseTimeRange(!!trigger.timeRange);
- if (trigger.timeRange) {
- setTimeStart(trigger.timeRange.start || '08:00');
- setTimeEnd(trigger.timeRange.end || '18:00');
- setTimeRangeDays(trigger.timeRange.days || []);
- }
-
- if (trigger.outputChange) {
- setTriggerMode('output');
- setOutputTarget(trigger.outputChange.target || '');
- setOutputState(trigger.outputChange.state || 'on');
- } else if (trigger.sensors && trigger.sensors.length > 0) {
- setTriggerMode('sensor');
- setSensorConditions(trigger.sensors);
- setSensorLogic(trigger.sensorLogic || 'and');
- } else {
- setTriggerMode('sensor');
- setSensorConditions([{ sensor: sensors[0]?.id || '', operator: '>', value: 25 }]);
- }
-
- const action = alarm.action || {};
- setMessage(action.message || '');
- setSeverity(action.severity || 'warning');
-
+ this.setState({
+ name: alarm.name,
+ selectedTags: Array.isArray(tags) ? tags : [],
+ useTimeRange: !!trigger.timeRange,
+ timeStart: trigger.timeRange?.start || '08:00',
+ timeEnd: trigger.timeRange?.end || '18:00',
+ timeRangeDays: trigger.timeRange?.days || [],
+ triggerMode: trigger.outputChange ? 'output' : 'sensor',
+ outputTarget: trigger.outputChange?.target || '',
+ outputState: trigger.outputChange?.state || 'on',
+ sensorConditions: trigger.sensors?.length > 0
+ ? trigger.sensors
+ : [{ sensor: sensors[0]?.id || '', operator: '>', value: 25 }],
+ sensorLogic: trigger.sensorLogic || 'and',
+ message: alarm.action?.message || '',
+ severity: alarm.action?.severity || 'warning'
+ });
} else {
- setName('');
- setSelectedTags([]);
- setTriggerMode('sensor');
- setOutputTarget('');
- setOutputState('on');
- setUseTimeRange(false);
- setTimeStart('08:00');
- setTimeEnd('18:00');
- setTimeRangeDays(['mon', 'tue', 'wed', 'thu', 'fri']);
- setSensorConditions([{ sensor: sensors[0]?.id || '', operator: '>', value: 25 }]);
- setSensorLogic('and');
- setMessage('');
- setSeverity('warning');
- }
- }, [alarm, open, sensors]);
-
- // Default sensor init
- useEffect(() => {
- if (sensorConditions[0]?.sensor === '' && sensors.length > 0) {
- setSensorConditions([{ ...sensorConditions[0], sensor: sensors[0].id }]);
- }
- }, [sensors, sensorConditions]);
-
- const addSensorCondition = () => {
- setSensorConditions([...sensorConditions, { sensor: sensors[0]?.id || '', operator: '>', value: 25 }]);
- };
-
- const updateSensorCondition = (index, newCondition) => {
- const updated = [...sensorConditions];
- updated[index] = newCondition;
- setSensorConditions(updated);
- };
-
- const removeSensorCondition = (index) => {
- if (sensorConditions.length > 1) {
- setSensorConditions(sensorConditions.filter((_, i) => i !== index));
+ this.setState({
+ name: '',
+ selectedTags: [],
+ triggerMode: 'sensor',
+ outputTarget: '',
+ outputState: 'on',
+ useTimeRange: false,
+ timeStart: '08:00',
+ timeEnd: '18:00',
+ timeRangeDays: ['mon', 'tue', 'wed', 'thu', 'fri'],
+ sensorConditions: [{ sensor: sensors[0]?.id || '', operator: '>', value: 25 }],
+ sensorLogic: 'and',
+ message: '',
+ severity: 'warning'
+ });
}
};
- const handleSave = () => {
+ addSensorCondition = () => {
+ const { sensors } = this.props;
+ this.setState(prev => ({
+ sensorConditions: [...prev.sensorConditions, { sensor: sensors[0]?.id || '', operator: '>', value: 25 }]
+ }));
+ };
+
+ updateSensorCondition = (index, newCondition) => {
+ this.setState(prev => {
+ const updated = [...prev.sensorConditions];
+ updated[index] = newCondition;
+ return { sensorConditions: updated };
+ });
+ };
+
+ removeSensorCondition = (index) => {
+ this.setState(prev => {
+ if (prev.sensorConditions.length > 1) {
+ return { sensorConditions: prev.sensorConditions.filter((_, i) => i !== index) };
+ }
+ return prev;
+ });
+ };
+
+ handleSave = () => {
+ const { onSave, sensors } = this.props;
+ const { name, selectedTags, triggerMode, outputTarget, outputState,
+ useTimeRange, timeStart, timeEnd, timeRangeDays,
+ sensorConditions, sensorLogic, message, severity } = this.state;
+
const trigger = {};
- // Always require sensors for an Alarm (otherwise it's just a time-based notification, which is valid too)
- // Let's assume user wants to monitor something.
-
if (triggerMode === 'output') {
trigger.outputChange = {
target: outputTarget,
state: outputState
};
} else {
- // Sensor Mode
if (useTimeRange) {
trigger.timeRange = { start: timeStart, end: timeEnd, days: timeRangeDays };
}
@@ -226,220 +219,229 @@ export default function AlarmEditor({ open, alarm, onSave, onClose, sensors = []
onSave({ name, trigger, action, colorTags: selectedTags });
};
- const isValid = name.trim().length > 0 &&
- ((triggerMode === 'sensor' && sensorConditions.every(c => c.sensor)) ||
- (triggerMode === 'output' && outputTarget)) &&
- message.trim().length > 0;
+ render() {
+ const { open, alarm, onClose, sensors = [], outputs = [], colorTags: availableColorTags = [], saving, i18n: { t } } = this.props;
+ const { name, selectedTags, triggerMode, outputTarget, outputState,
+ useTimeRange, timeStart, timeEnd, timeRangeDays,
+ sensorConditions, sensorLogic, message, severity } = this.state;
- return (
-
- );
+
+ This message will be sent to all users who have linked their Telegram ID in their profile.
+
+
+
+
+
+
+
+
+
+
+ );
+ }
}
+
+export default withI18n(AlarmEditor);
diff --git a/src/client/AlarmManager.js b/src/client/AlarmManager.js
index 17ac0ff..7f74f4f 100644
--- a/src/client/AlarmManager.js
+++ b/src/client/AlarmManager.js
@@ -1,4 +1,4 @@
-import React, { useState, useEffect, useCallback } from 'react';
+import React, { Component } from 'react';
import {
Box,
Typography,
@@ -11,8 +11,9 @@ import {
} from '@mui/material';
import AlarmCard from './AlarmCard';
import AlarmEditor from './AlarmEditor';
-import { useAuth } from './AuthContext';
-import { useI18n } from './I18nContext';
+import { withAuth } from './AuthContext';
+import { withI18n } from './I18nContext';
+import { withDevices } from './DevicesContext';
const COLOR_TAGS = [
{ id: 'red', label: 'Red', color: '#fb4934' },
@@ -25,162 +26,131 @@ const COLOR_TAGS = [
{ id: 'gray', label: 'Gray', color: '#928374' }
];
-export default function AlarmManager() {
- const { isAdmin } = useAuth();
- const { t } = useI18n();
- const [alarms, setAlarms] = useState([]);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
- const [editorOpen, setEditorOpen] = useState(false);
- const [editingAlarm, setEditingAlarm] = useState(null);
- const [devices, setDevices] = useState([]);
- const [saving, setSaving] = useState(false);
- const [filterTag, setFilterTag] = useState(null);
+class AlarmManager extends Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ alarms: [],
+ loading: true,
+ error: null,
+ editorOpen: false,
+ editingAlarm: null,
+ saving: false,
+ filterTag: null
+ };
+ }
- const getAuthHeaders = useCallback(() => {
+ componentDidMount() {
+ this.fetchAlarms();
+ }
+
+ getAuthHeaders = () => {
const token = localStorage.getItem('authToken');
return {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
};
- }, []);
+ };
- const fetchAlarms = useCallback(async () => {
+ fetchAlarms = async () => {
try {
const res = await fetch('api/alarms');
if (!res.ok) throw new Error('Failed to fetch alarms');
const data = await res.json();
- setAlarms(data);
- setError(null);
+ this.setState({ alarms: data, error: null, loading: false });
} catch (err) {
- setError(err.message);
- } finally {
- setLoading(false);
+ this.setState({ error: err.message, loading: false });
}
- }, []);
-
- const fetchDevices = useCallback(async () => {
- try {
- const res = await fetch('api/devices');
- if (res.ok) {
- const data = await res.json();
- setDevices(data);
- }
- } catch (err) {
- console.error('Failed to fetch devices:', err);
- }
- }, []);
-
- useEffect(() => {
- fetchAlarms();
- fetchDevices();
- }, [fetchAlarms, fetchDevices]);
-
- // Build available sensors (same usage as RuleManager)
- const availableSensors = [];
- const seenDevices = new Set();
-
- devices.forEach(d => {
- if (!seenDevices.has(d.dev_name)) {
- seenDevices.add(d.dev_name);
- availableSensors.push({ id: `${d.dev_name}:temp`, label: `${d.dev_name} - Temperature`, type: 'temp' });
- availableSensors.push({ id: `${d.dev_name}:humidity`, label: `${d.dev_name} - Humidity`, type: 'humidity' });
- }
- availableSensors.push({
- id: `${d.dev_name}:${d.port}:level`,
- label: `${d.dev_name} - ${d.port_name} Level`,
- type: 'level'
- });
- });
-
- // Build available outputs
- const availableOutputs = [];
- devices.forEach(d => {
- availableOutputs.push({
- id: `${d.dev_name}:${d.port}:out`,
- label: `${d.dev_name} - ${d.port_name} (Output)`
- });
- });
-
- const handleAddAlarm = () => {
- setEditingAlarm(null);
- setEditorOpen(true);
};
- const handleEditAlarm = (alarm) => {
- setEditingAlarm(alarm);
- setEditorOpen(true);
+ handleAddAlarm = () => {
+ this.setState({ editingAlarm: null, editorOpen: true });
};
- const handleDeleteAlarm = async (id) => {
+ handleEditAlarm = (alarm) => {
+ this.setState({ editingAlarm: alarm, editorOpen: true });
+ };
+
+ handleDeleteAlarm = async (id) => {
if (!confirm('Are you sure you want to delete this alarm?')) return;
- setSaving(true);
+ this.setState({ saving: true });
try {
const res = await fetch(`api/alarms/${id}`, {
method: 'DELETE',
- headers: getAuthHeaders()
+ headers: this.getAuthHeaders()
});
if (!res.ok) throw new Error('Failed to delete alarm');
- setAlarms(alarms.filter(a => a.id !== id));
+ this.setState(prev => ({
+ alarms: prev.alarms.filter(a => a.id !== id),
+ saving: false
+ }));
} catch (err) {
- setError(err.message);
- } finally {
- setSaving(false);
+ this.setState({ error: err.message, saving: false });
}
};
- const handleToggleAlarm = async (id) => {
+ handleToggleAlarm = async (id) => {
+ const { alarms } = this.state;
const alarm = alarms.find(a => a.id === id);
if (!alarm) return;
- setSaving(true);
+ this.setState({ saving: true });
try {
const res = await fetch(`api/alarms/${id}`, {
method: 'PUT',
- headers: getAuthHeaders(),
+ headers: this.getAuthHeaders(),
body: JSON.stringify({ ...alarm, enabled: !alarm.enabled })
});
if (!res.ok) throw new Error('Failed to update alarm');
const updated = await res.json();
- setAlarms(alarms.map(a => a.id === id ? updated : a));
+ this.setState(prev => ({
+ alarms: prev.alarms.map(a => a.id === id ? updated : a),
+ saving: false
+ }));
} catch (err) {
- setError(err.message);
- } finally {
- setSaving(false);
+ this.setState({ error: err.message, saving: false });
}
};
- const handleSaveAlarm = async (alarmData) => {
- setSaving(true);
+ handleSaveAlarm = async (alarmData) => {
+ const { editingAlarm } = this.state;
+ this.setState({ saving: true });
try {
if (editingAlarm) {
const res = await fetch(`api/alarms/${editingAlarm.id}`, {
method: 'PUT',
- headers: getAuthHeaders(),
+ headers: this.getAuthHeaders(),
body: JSON.stringify({ ...alarmData, enabled: editingAlarm.enabled })
});
if (!res.ok) throw new Error('Failed to update alarm');
const updated = await res.json();
- setAlarms(alarms.map(a => a.id === editingAlarm.id ? updated : a));
+ this.setState(prev => ({
+ alarms: prev.alarms.map(a => a.id === editingAlarm.id ? updated : a),
+ editorOpen: false,
+ editingAlarm: null,
+ saving: false
+ }));
} else {
const res = await fetch('api/alarms', {
method: 'POST',
- headers: getAuthHeaders(),
+ headers: this.getAuthHeaders(),
body: JSON.stringify({ ...alarmData, enabled: true })
});
if (!res.ok) throw new Error('Failed to create alarm');
const newAlarm = await res.json();
- setAlarms([...alarms, newAlarm]);
+ this.setState(prev => ({
+ alarms: [...prev.alarms, newAlarm],
+ editorOpen: false,
+ editingAlarm: null,
+ saving: false
+ }));
}
- setEditorOpen(false);
- setEditingAlarm(null);
} catch (err) {
- setError(err.message);
- } finally {
- setSaving(false);
+ this.setState({ error: err.message, saving: false });
}
};
- const handleMoveAlarm = async (id, direction) => {
+ handleMoveAlarm = async (id, direction) => {
+ const { alarms } = this.state;
const idx = alarms.findIndex(a => a.id === id);
if (idx === -1) return;
if (direction === 'up' && idx === 0) return;
@@ -189,117 +159,124 @@ export default function AlarmManager() {
const newAlarms = [...alarms];
const swapIdx = direction === 'up' ? idx - 1 : idx + 1;
[newAlarms[idx], newAlarms[swapIdx]] = [newAlarms[swapIdx], newAlarms[idx]];
- setAlarms(newAlarms);
+ this.setState({ alarms: newAlarms });
try {
await fetch('api/alarms/reorder', {
method: 'PUT',
- headers: getAuthHeaders(),
+ headers: this.getAuthHeaders(),
body: JSON.stringify({ alarmIds: newAlarms.map(a => a.id) })
});
} catch (err) {
- setError('Failed to save order');
+ this.setState({ error: 'Failed to save order' });
}
};
- const filteredAlarms = filterTag
- ? alarms.filter(a => (a.colorTags || []).includes(filterTag))
- : alarms;
+ render() {
+ const { auth: { isAdmin }, i18n: { t }, devicesCtx: { getAvailableSensors, getAvailableOutputs } } = this.props;
+ const { alarms, loading, error, editorOpen, editingAlarm, saving, filterTag } = this.state;
- if (loading) {
- return ;
- }
+ const filteredAlarms = filterTag
+ ? alarms.filter(a => (a.colorTags || []).includes(filterTag))
+ : alarms;
- return (
-
-
-
-
- 🚨 {t('alarms.title') || 'Alarms'}
-
-
- {isAdmin ? 'Manage system alarms and notifications.' : 'View active system alarms.'}
-
+ if (loading) {
+ return ;
+ }
+
+ return (
+
+
+
+
+ 🚨 {t('alarms.title') || 'Alarms'}
+
+
+ {isAdmin ? 'Manage system alarms and notifications.' : 'View active system alarms.'}
+
+
+ {isAdmin && (
+
+ )}
- {isAdmin && (
-
- )}
-
-
+
-
- Filter:
- setFilterTag(null)}
- sx={{ bgcolor: filterTag === null ? '#ebdbb2' : '#504945', color: filterTag === null ? '#282828' : '#ebdbb2' }}
- />
- {COLOR_TAGS.map(tag => (
+
+ Filter:
setFilterTag(filterTag === tag.id ? null : tag.id)}
- sx={{
- bgcolor: filterTag === tag.id ? tag.color : '#504945',
- color: filterTag === tag.id ? '#282828' : tag.color,
- border: `2px solid ${tag.color}`,
- '&:hover': { bgcolor: tag.color, color: '#282828' }
- }}
+ label="All" size="small" onClick={() => this.setState({ filterTag: null })}
+ sx={{ bgcolor: filterTag === null ? '#ebdbb2' : '#504945', color: filterTag === null ? '#282828' : '#ebdbb2' }}
/>
- ))}
-
-
- {error && setError(null)}>{error}}
-
- {filteredAlarms.length === 0 ? (
-
- No alarms found.
-
- ) : (
-
- {filteredAlarms.map((alarm, idx) => (
- handleEditAlarm(alarm) : null}
- onDelete={isAdmin ? () => handleDeleteAlarm(alarm.id) : null}
- onToggle={isAdmin ? () => handleToggleAlarm(alarm.id) : null}
- onMoveUp={isAdmin && idx > 0 ? () => handleMoveAlarm(alarm.id, 'up') : null}
- onMoveDown={isAdmin && idx < filteredAlarms.length - 1 ? () => handleMoveAlarm(alarm.id, 'down') : null}
- colorTags={COLOR_TAGS}
- readOnly={!isAdmin}
+ {COLOR_TAGS.map(tag => (
+ this.setState({ filterTag: filterTag === tag.id ? null : tag.id })}
+ sx={{
+ bgcolor: filterTag === tag.id ? tag.color : '#504945',
+ color: filterTag === tag.id ? '#282828' : tag.color,
+ border: `2px solid ${tag.color}`,
+ '&:hover': { bgcolor: tag.color, color: '#282828' }
+ }}
/>
))}
- )}
- {isAdmin && (
- { setEditorOpen(false); setEditingAlarm(null); }}
- sensors={availableSensors}
- outputs={availableOutputs}
- colorTags={COLOR_TAGS}
- saving={saving}
- />
- )}
-
- );
+ {error && this.setState({ error: null })}>{error}}
+
+ {filteredAlarms.length === 0 ? (
+
+ No alarms found.
+
+ ) : (
+
+ {filteredAlarms.map((alarm, idx) => (
+ this.handleEditAlarm(alarm) : null}
+ onDelete={isAdmin ? () => this.handleDeleteAlarm(alarm.id) : null}
+ onToggle={isAdmin ? () => this.handleToggleAlarm(alarm.id) : null}
+ onMoveUp={isAdmin && idx > 0 ? () => this.handleMoveAlarm(alarm.id, 'up') : null}
+ onMoveDown={isAdmin && idx < filteredAlarms.length - 1 ? () => this.handleMoveAlarm(alarm.id, 'down') : null}
+ colorTags={COLOR_TAGS}
+ readOnly={!isAdmin}
+ />
+ ))}
+
+ )}
+
+ {isAdmin && (
+ { this.setState({ editorOpen: false, editingAlarm: null }); }}
+ sensors={getAvailableSensors()}
+ outputs={getAvailableOutputs()}
+ colorTags={COLOR_TAGS}
+ saving={saving}
+ />
+ )}
+
+ );
+ }
}
+
+export default withDevices(withAuth(withI18n(AlarmManager)));
diff --git a/src/client/App.js b/src/client/App.js
index 418d66f..2513f82 100644
--- a/src/client/App.js
+++ b/src/client/App.js
@@ -1,12 +1,13 @@
-import React, { useState } from 'react';
+import React, { Component } from 'react';
import { ThemeProvider, createTheme, CssBaseline, AppBar, Toolbar, Typography, Container, Box, Button, Chip } from '@mui/material';
import Dashboard from './Dashboard';
import RuleManager from './RuleManager';
import AlarmManager from './AlarmManager';
import LoginDialog from './LoginDialog';
import ProfileDialog from './ProfileDialog';
-import { AuthProvider, useAuth } from './AuthContext';
-import { I18nProvider, useI18n } from './I18nContext';
+import { AuthProvider, withAuth } from './AuthContext';
+import { I18nProvider, withI18n } from './I18nContext';
+import { DevicesProvider } from './DevicesContext';
import LanguageSwitcher from './LanguageSwitcher';
// Gruvbox Dark color palette
@@ -46,107 +47,121 @@ const darkTheme = createTheme({
},
});
-function AppContent() {
- const { user, loading, login, logout, isAuthenticated, isAdmin } = useAuth();
- const { t } = useI18n();
- const [showLogin, setShowLogin] = useState(false);
- const [showProfile, setShowProfile] = useState(false);
+class AppContent extends Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ showLogin: false,
+ showProfile: false
+ };
+ }
- return (
-
-
-
-
- {t('app.title')}
-
-
-
- {isAuthenticated ? (
- <>
- setShowProfile(true)}
- color={isAdmin ? 'secondary' : 'default'}
- size="small"
- sx={{
- fontWeight: 600,
- cursor: 'pointer',
- '&:hover': { opacity: 0.8 },
- ...(isAdmin && {
- background: 'linear-gradient(45deg, #d3869b 30%, #fe8019 90%)'
- })
- }}
- />
- {isAdmin && (
+ render() {
+ const { auth: { user, logout, isAuthenticated, isAdmin }, i18n: { t } } = this.props;
+ const { showLogin, showProfile } = this.state;
+
+ return (
+
+
+
+
+ {t('app.title')}
+
+
+
+ {isAuthenticated ? (
+ <>
this.setState({ showProfile: true })}
+ color={isAdmin ? 'secondary' : 'default'}
size="small"
sx={{
- bgcolor: gruvboxDark.purple,
- color: gruvboxDark.bg0,
- fontWeight: 700
+ fontWeight: 600,
+ cursor: 'pointer',
+ '&:hover': { opacity: 0.8 },
+ ...(isAdmin && {
+ background: 'linear-gradient(45deg, #d3869b 30%, #fe8019 90%)'
+ })
}}
/>
- )}
+ {isAdmin && (
+
+ )}
+
+ >
+ ) : (
- >
- ) : (
-
- )}
-
-
-
-
- {/* Dashboard is always visible to everyone */}
-
+ )}
+
+
+
+
+ {/* Dashboard is always visible to everyone */}
+
- {/* Rule Manager visible to everyone (guests read-only, admins can edit) */}
-
+ {/* Rule Manager visible to everyone (guests read-only, admins can edit) */}
+
- {/* Alarm Manager visible to everyone (guests read-only, admins can edit) */}
-
-
+ {/* Alarm Manager visible to everyone (guests read-only, admins can edit) */}
+
+
- {/* Login dialog - shown on demand */}
- setShowLogin(false)}
- />
+ {/* Login dialog - shown on demand */}
+ this.setState({ showLogin: false })}
+ />
- {/* Profile dialog */}
- setShowProfile(false)}
- />
-
- );
+ {/* Profile dialog */}
+ this.setState({ showProfile: false })}
+ />
+
+ );
+ }
}
-function App() {
- return (
-
-
-
-
-
-
-
-
- );
+const WrappedAppContent = withAuth(withI18n(AppContent));
+
+class App extends Component {
+ render() {
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+ }
}
export default App;
diff --git a/src/client/AuthContext.js b/src/client/AuthContext.js
index 50549a0..a8711bf 100644
--- a/src/client/AuthContext.js
+++ b/src/client/AuthContext.js
@@ -77,3 +77,11 @@ export function useAuth() {
}
return context;
}
+
+// HOC for class components
+export function withAuth(Component) {
+ return function WrappedComponent(props) {
+ const auth = useAuth();
+ return ;
+ };
+}
diff --git a/src/client/ControllerCard.js b/src/client/ControllerCard.js
index 3c60dce..e836686 100644
--- a/src/client/ControllerCard.js
+++ b/src/client/ControllerCard.js
@@ -1,15 +1,39 @@
-import React, { useState, useEffect } from 'react';
+import React, { Component } from 'react';
import { Card, CardHeader, CardContent, Divider, Grid, Box, Typography } from '@mui/material';
import EnvChart from './EnvChart';
import LevelChart from './LevelChart';
-import { useI18n } from './I18nContext';
+import { withI18n } from './I18nContext';
-export default function ControllerCard({ controllerName, ports, range }) {
- const { t } = useI18n();
- const [envData, setEnvData] = useState([]);
- const [portData, setPortData] = useState({});
+class ControllerCard extends Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ envData: [],
+ portData: {}
+ };
+ this.interval = null;
+ }
- const fetchData = async () => {
+ componentDidMount() {
+ this.fetchData();
+ this.interval = setInterval(() => this.fetchData(), 60000);
+ }
+
+ componentDidUpdate(prevProps) {
+ if (prevProps.controllerName !== this.props.controllerName ||
+ prevProps.range !== this.props.range) {
+ this.fetchData();
+ }
+ }
+
+ componentWillUnmount() {
+ if (this.interval) {
+ clearInterval(this.interval);
+ }
+ }
+
+ fetchData = async () => {
+ const { controllerName, ports, range } = this.props;
try {
if (ports.length === 0) return;
@@ -27,12 +51,12 @@ export default function ControllerCard({ controllerName, ports, range }) {
newPortData[item.port] = item.data;
});
- setPortData(newPortData);
+ this.setState({ portData: newPortData });
// Use the data from the first port for the Environment Chart
// This avoids a redundant network request
if (results.length > 0) {
- setEnvData(results[0].data);
+ this.setState({ envData: results[0].data });
}
} catch (err) {
@@ -40,55 +64,55 @@ export default function ControllerCard({ controllerName, ports, range }) {
}
};
- // Initial Fetch & Auto-Refresh
- useEffect(() => {
- fetchData();
- const interval = setInterval(fetchData, 60000);
- return () => clearInterval(interval);
- }, [controllerName, range]); // Depend on range, controllerName changes rarely
+ render() {
+ const { controllerName, ports, range, i18n: { t } } = this.props;
+ const { envData, portData } = this.state;
- return (
-
-
-
- {/* Environment Chart */}
-
-
- {t('controller.environment')}
-
-
-
+ return (
+
+
+
+ {/* Environment Chart */}
+
+
+ {t('controller.environment')}
+
+
+
-
+
- {/* Port Grid */}
-
- {ports.map((port) => {
- const isLight = port.port_name && port.port_name.toLowerCase().includes('light');
- const isCO2 = port.port_name && port.port_name.toLowerCase().includes('co2');
- const pData = portData[port.port] || [];
+ {/* Port Grid */}
+
+ {ports.map((port) => {
+ const isLight = port.port_name && port.port_name.toLowerCase().includes('light');
+ const isCO2 = port.port_name && port.port_name.toLowerCase().includes('co2');
+ const pData = portData[port.port] || [];
- return (
-
-
-
-
- {port.port_name || `${t('controller.port')} ${port.port}`}
-
-
-
-
-
-
-
- );
- })}
-
-
-
- );
+ return (
+
+
+
+
+ {port.port_name || `${t('controller.port')} ${port.port}`}
+
+
+
+
+
+
+
+ );
+ })}
+
+
+
+ );
+ }
}
+
+export default withI18n(ControllerCard);
diff --git a/src/client/Dashboard.js b/src/client/Dashboard.js
index 8b8d7c2..934cfa4 100644
--- a/src/client/Dashboard.js
+++ b/src/client/Dashboard.js
@@ -1,92 +1,67 @@
-import React, { useState, useEffect, useCallback } from 'react';
+import React, { Component } from 'react';
import { Grid, Typography, Button, ButtonGroup, Box, Alert } from '@mui/material';
import ControllerCard from './ControllerCard';
import OutputChart from './OutputChart';
-import { useI18n } from './I18nContext';
+import { withI18n } from './I18nContext';
+import { withDevices } from './DevicesContext';
-export default function Dashboard() {
- const { t } = useI18n();
- const [groupedDevices, setGroupedDevices] = useState({});
- const [allDevices, setAllDevices] = useState([]);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
- const [range, setRange] = useState('day'); // 'day', 'week', 'month'
+class Dashboard extends Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ range: 'day' // 'day', 'week', 'month'
+ };
+ }
- const fetchDevices = useCallback(async () => {
- try {
- // Robust API Base detection
- const baseUrl = window.location.pathname.endsWith('/') ? 'api/' : 'api/';
- // Actually, since we are serving from root or subpath, relative 'api/' is tricky if URL depth changes.
- // Better to use a relative path that works from the page root.
- // If page is /ac-dashboard/, fetch is /ac-dashboard/api/devices.
+ setRange = (range) => {
+ this.setState({ range });
+ };
- const res = await fetch('api/devices');
- if (!res.ok) throw new Error('Failed to fetch devices');
+ render() {
+ const { i18n: { t }, devicesCtx: { devices, groupedDevices, loading, error } } = this.props;
+ const { range } = this.state;
- const devices = await res.json();
+ if (loading) return {t('dashboard.loading')};
+ if (error) return {error};
- // Group by dev_name
- const grouped = devices.reduce((acc, dev) => {
- if (!acc[dev.dev_name]) acc[dev.dev_name] = [];
- acc[dev.dev_name].push(dev);
- return acc;
- }, {});
+ return (
+
+
+
+
+
+
+
+
- setGroupedDevices(grouped);
- setAllDevices(devices);
- setLoading(false);
- } catch (err) {
- console.error(err);
- setError(err.message);
- setLoading(false);
- }
- }, []);
+ {Object.entries(groupedDevices).map(([controllerName, ports]) => (
+
+ ))}
- useEffect(() => {
- fetchDevices();
- }, [fetchDevices]);
-
- // Auto-refresh logic (basic rerender trigger could be added here,
- // but simpler to let ControllerCard handle data fetching internally based on props)
-
- if (loading) return {t('dashboard.loading')};
- if (error) return {error};
-
- return (
-
-
-
-
-
-
-
+
-
- {Object.entries(groupedDevices).map(([controllerName, ports]) => (
-
- ))}
-
-
-
- );
+ );
+ }
}
+
+export default withDevices(withI18n(Dashboard));
diff --git a/src/client/DevicesContext.js b/src/client/DevicesContext.js
new file mode 100644
index 0000000..7344ecd
--- /dev/null
+++ b/src/client/DevicesContext.js
@@ -0,0 +1,135 @@
+import React, { Component, createContext, useContext } from 'react';
+
+const DevicesContext = createContext(null);
+
+export class DevicesProvider extends Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ devices: [],
+ groupedDevices: {},
+ loading: true,
+ error: null
+ };
+ }
+
+ componentDidMount() {
+ this.fetchDevices();
+ }
+
+ fetchDevices = async () => {
+ try {
+ const res = await fetch('api/devices');
+ if (!res.ok) throw new Error('Failed to fetch devices');
+
+ const devices = await res.json();
+
+ // Group by dev_name
+ const grouped = devices.reduce((acc, dev) => {
+ if (!acc[dev.dev_name]) acc[dev.dev_name] = [];
+ acc[dev.dev_name].push(dev);
+ return acc;
+ }, {});
+
+ this.setState({
+ devices,
+ groupedDevices: grouped,
+ loading: false,
+ error: null
+ });
+ } catch (err) {
+ console.error('Failed to fetch devices:', err);
+ this.setState({
+ error: err.message,
+ loading: false
+ });
+ }
+ };
+
+ // Build available sensors from devices
+ getAvailableSensors = () => {
+ const { devices } = this.state;
+ const availableSensors = [];
+ const seenDevices = new Set();
+
+ devices.forEach(d => {
+ if (!seenDevices.has(d.dev_name)) {
+ seenDevices.add(d.dev_name);
+ availableSensors.push({
+ id: `${d.dev_name}:temp`,
+ label: `${d.dev_name} - Temperature`,
+ type: 'temperature'
+ });
+ availableSensors.push({
+ id: `${d.dev_name}:humidity`,
+ label: `${d.dev_name} - Humidity`,
+ type: 'humidity'
+ });
+ }
+ availableSensors.push({
+ id: `${d.dev_name}:${d.port}:level`,
+ label: `${d.dev_name} - ${d.port_name} Level`,
+ type: d.port_name.toLowerCase()
+ });
+ });
+
+ return availableSensors;
+ };
+
+ // Build available outputs from devices
+ getAvailableOutputs = () => {
+ const { devices } = this.state;
+ return [
+ { id: 'tapo-001', label: 'Tapo 001', type: 'plug' },
+ { id: 'tapo-002', label: 'Tapo 002', type: 'plug' },
+ { id: 'tapo-003', label: 'Tapo 003', type: 'plug' },
+ { id: 'tapo-004', label: 'Tapo 004', type: 'plug' },
+ { id: 'tapo-005', label: 'Tapo 005', type: 'plug' },
+ ...devices.map(d => ({
+ id: `${d.dev_name}:${d.port}:out`,
+ label: `${d.dev_name} - ${d.port_name}`,
+ type: d.port_name.toLowerCase()
+ })),
+ { id: 'virtual-1', label: 'Virtual Channel 1', type: 'virtual' },
+ { id: 'virtual-2', label: 'Virtual Channel 2', type: 'virtual' },
+ { id: 'virtual-3', label: 'Virtual Channel 3', type: 'virtual' },
+ { id: 'virtual-4', label: 'Virtual Channel 4', type: 'virtual' }
+ ];
+ };
+
+ render() {
+ const { devices, groupedDevices, loading, error } = this.state;
+
+ const value = {
+ devices,
+ groupedDevices,
+ loading,
+ error,
+ refreshDevices: this.fetchDevices,
+ getAvailableSensors: this.getAvailableSensors,
+ getAvailableOutputs: this.getAvailableOutputs
+ };
+
+ return (
+
+ {this.props.children}
+
+ );
+ }
+}
+
+export function useDevices() {
+ const context = useContext(DevicesContext);
+ if (!context) {
+ throw new Error('useDevices must be used within a DevicesProvider');
+ }
+ return context;
+}
+
+// HOC for class components
+export function withDevices(Component) {
+ return function WrappedComponent(props) {
+ const devices = useDevices();
+ return ;
+ };
+}
diff --git a/src/client/EnvChart.js b/src/client/EnvChart.js
index ba9eeaf..49bf783 100644
--- a/src/client/EnvChart.js
+++ b/src/client/EnvChart.js
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { Component } from 'react';
import {
Chart as ChartJS,
CategoryScale,
@@ -21,10 +21,9 @@ ChartJS.register(
Legend
);
-export default function EnvChart({ data, range }) {
- if (!data || data.length === 0) return null;
-
- const formatDateLabel = (timestamp) => {
+class EnvChart extends Component {
+ formatDateLabel = (timestamp) => {
+ const { range } = this.props;
const date = new Date(timestamp);
if (range === 'day') {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
@@ -34,65 +33,73 @@ export default function EnvChart({ data, range }) {
}
};
- const chartData = {
- labels: data.map(d => formatDateLabel(d.timestamp)),
- datasets: [
- {
- label: 'Temperature (°C)',
- data: data.map(d => d.temp_c),
- borderColor: '#ff6384',
- backgroundColor: '#ff6384',
- yAxisID: 'y',
- tension: 0.4,
- pointRadius: 0,
- borderWidth: 2
- },
- {
- label: 'Humidity (%)',
- data: data.map(d => d.humidity),
- borderColor: '#36a2eb',
- backgroundColor: '#36a2eb',
- yAxisID: 'y1',
- tension: 0.4,
- pointRadius: 0,
- borderWidth: 2
- },
- ],
- };
+ render() {
+ const { data } = this.props;
- const options = {
- responsive: true,
- maintainAspectRatio: false,
- interaction: {
- mode: 'index',
- intersect: false,
- },
- scales: {
- x: {
- ticks: {
- maxRotation: 0,
- autoSkip: true,
- maxTicksLimit: 12
- }
- },
- y: {
- type: 'linear',
- display: true,
- position: 'left',
- title: { display: true, text: 'Temp (°C)' },
- suggestedMin: 15,
- },
- y1: {
- type: 'linear',
- display: true,
- position: 'right',
- grid: { drawOnChartArea: false },
- title: { display: true, text: 'Humidity (%)' },
- suggestedMin: 30,
- suggestedMax: 80,
- },
- },
- };
+ if (!data || data.length === 0) return null;
- return ;
+ const chartData = {
+ labels: data.map(d => this.formatDateLabel(d.timestamp)),
+ datasets: [
+ {
+ label: 'Temperature (°C)',
+ data: data.map(d => d.temp_c),
+ borderColor: '#ff6384',
+ backgroundColor: '#ff6384',
+ yAxisID: 'y',
+ tension: 0.4,
+ pointRadius: 0,
+ borderWidth: 2
+ },
+ {
+ label: 'Humidity (%)',
+ data: data.map(d => d.humidity),
+ borderColor: '#36a2eb',
+ backgroundColor: '#36a2eb',
+ yAxisID: 'y1',
+ tension: 0.4,
+ pointRadius: 0,
+ borderWidth: 2
+ },
+ ],
+ };
+
+ const options = {
+ responsive: true,
+ maintainAspectRatio: false,
+ interaction: {
+ mode: 'index',
+ intersect: false,
+ },
+ scales: {
+ x: {
+ ticks: {
+ maxRotation: 0,
+ autoSkip: true,
+ maxTicksLimit: 12
+ }
+ },
+ y: {
+ type: 'linear',
+ display: true,
+ position: 'left',
+ title: { display: true, text: 'Temp (°C)' },
+ suggestedMin: 15,
+ },
+ y1: {
+ type: 'linear',
+ display: true,
+ position: 'right',
+ grid: { drawOnChartArea: false },
+ title: { display: true, text: 'Humidity (%)' },
+ suggestedMin: 30,
+ suggestedMax: 80,
+ },
+ },
+ };
+
+ return ;
+ }
}
+
+export default EnvChart;
diff --git a/src/client/I18nContext.js b/src/client/I18nContext.js
index 8bfb556..5f1b5e8 100644
--- a/src/client/I18nContext.js
+++ b/src/client/I18nContext.js
@@ -80,3 +80,11 @@ export function useI18n() {
}
return context;
}
+
+// HOC for class components
+export function withI18n(Component) {
+ return function WrappedComponent(props) {
+ const i18n = useI18n();
+ return ;
+ };
+}
diff --git a/src/client/LanguageSwitcher.js b/src/client/LanguageSwitcher.js
index bcb2b84..2d8b71b 100644
--- a/src/client/LanguageSwitcher.js
+++ b/src/client/LanguageSwitcher.js
@@ -1,42 +1,46 @@
-import React from 'react';
+import React, { Component } from 'react';
import { Box, IconButton, Tooltip } from '@mui/material';
-import { useI18n } from './I18nContext';
+import { withI18n } from './I18nContext';
// Flag emojis for language switching
const FLAG_DE = '🇩🇪';
const FLAG_EN = '🇬🇧';
-export default function LanguageSwitcher() {
- const { language, setLanguage } = useI18n();
+class LanguageSwitcher extends Component {
+ render() {
+ const { i18n: { language, setLanguage } } = this.props;
- return (
-
-
- setLanguage('en')}
- sx={{
- opacity: language === 'en' ? 1 : 0.5,
- fontSize: '1.2rem',
- '&:hover': { opacity: 1 }
- }}
- >
- {FLAG_EN}
-
-
-
- setLanguage('de')}
- sx={{
- opacity: language === 'de' ? 1 : 0.5,
- fontSize: '1.2rem',
- '&:hover': { opacity: 1 }
- }}
- >
- {FLAG_DE}
-
-
-
- );
+ return (
+
+
+ setLanguage('en')}
+ sx={{
+ opacity: language === 'en' ? 1 : 0.5,
+ fontSize: '1.2rem',
+ '&:hover': { opacity: 1 }
+ }}
+ >
+ {FLAG_EN}
+
+
+
+ setLanguage('de')}
+ sx={{
+ opacity: language === 'de' ? 1 : 0.5,
+ fontSize: '1.2rem',
+ '&:hover': { opacity: 1 }
+ }}
+ >
+ {FLAG_DE}
+
+
+
+ );
+ }
}
+
+export default withI18n(LanguageSwitcher);
diff --git a/src/client/LevelChart.js b/src/client/LevelChart.js
index 137e785..bdfa97e 100644
--- a/src/client/LevelChart.js
+++ b/src/client/LevelChart.js
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { Component } from 'react';
import {
Chart as ChartJS,
CategoryScale,
@@ -21,10 +21,9 @@ ChartJS.register(
Legend
);
-export default function LevelChart({ data, isLight, isCO2, range }) {
- if (!data || data.length === 0) return null;
-
- const formatDateLabel = (timestamp) => {
+class LevelChart extends Component {
+ formatDateLabel = (timestamp) => {
+ const { range } = this.props;
const date = new Date(timestamp);
if (range === 'day') {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
@@ -34,49 +33,57 @@ export default function LevelChart({ data, isLight, isCO2, range }) {
}
};
- // Determine label and color based on sensor type
- const levelLabel = isCO2 ? 'CO2 (ppm)' : (isLight ? 'Brightness' : 'Fan Speed');
- const levelColor = isCO2 ? '#4caf50' : (isLight ? '#ffcd56' : '#9966ff');
+ render() {
+ const { data, isLight, isCO2 } = this.props;
- const chartData = {
- labels: data.map(d => formatDateLabel(d.timestamp)),
- datasets: [
- {
- label: levelLabel,
- data: data.map(d => d.fan_speed),
- borderColor: levelColor,
- backgroundColor: levelColor,
- stepped: !isCO2, // CO2 uses smooth lines
- tension: isCO2 ? 0.4 : 0, // Smooth for CO2, stepped for others
- borderWidth: 2,
- pointRadius: 0
+ if (!data || data.length === 0) return null;
+
+ // Determine label and color based on sensor type
+ const levelLabel = isCO2 ? 'CO2 (ppm)' : (isLight ? 'Brightness' : 'Fan Speed');
+ const levelColor = isCO2 ? '#4caf50' : (isLight ? '#ffcd56' : '#9966ff');
+
+ const chartData = {
+ labels: data.map(d => this.formatDateLabel(d.timestamp)),
+ datasets: [
+ {
+ label: levelLabel,
+ data: data.map(d => d.fan_speed),
+ borderColor: levelColor,
+ backgroundColor: levelColor,
+ stepped: !isCO2, // CO2 uses smooth lines
+ tension: isCO2 ? 0.4 : 0, // Smooth for CO2, stepped for others
+ borderWidth: 2,
+ pointRadius: 0
+ },
+ ],
+ };
+
+ // CO2 needs different Y-axis scale (ppm range)
+ const yScale = isCO2
+ ? { suggestedMin: 200, suggestedMax: 900 }
+ : { suggestedMin: 0, suggestedMax: 10, ticks: { stepSize: 1 } };
+
+ const options = {
+ responsive: true,
+ maintainAspectRatio: false,
+ interaction: {
+ mode: 'index',
+ intersect: false,
},
- ],
- };
-
- // CO2 needs different Y-axis scale (ppm range)
- const yScale = isCO2
- ? { suggestedMin: 200, suggestedMax: 900 }
- : { suggestedMin: 0, suggestedMax: 10, ticks: { stepSize: 1 } };
-
- const options = {
- responsive: true,
- maintainAspectRatio: false,
- interaction: {
- mode: 'index',
- intersect: false,
- },
- scales: {
- x: {
- ticks: {
- maxRotation: 0,
- autoSkip: true,
- maxTicksLimit: 8
- }
+ scales: {
+ x: {
+ ticks: {
+ maxRotation: 0,
+ autoSkip: true,
+ maxTicksLimit: 8
+ }
+ },
+ y: yScale
},
- y: yScale
- },
- };
+ };
- return ;
+ return ;
+ }
}
+
+export default LevelChart;
diff --git a/src/client/LoginDialog.js b/src/client/LoginDialog.js
index fa9e3ff..42031f2 100644
--- a/src/client/LoginDialog.js
+++ b/src/client/LoginDialog.js
@@ -1,4 +1,4 @@
-import React, { useState } from 'react';
+import React, { Component } from 'react';
import {
Dialog,
DialogTitle,
@@ -13,140 +13,152 @@ import {
IconButton,
Typography
} from '@mui/material';
-import { useAuth } from './AuthContext';
-import { useI18n } from './I18nContext';
+import { withAuth } from './AuthContext';
+import { withI18n } from './I18nContext';
// Simple eye icons using unicode
const VisibilityIcon = () => 👁;
const VisibilityOffIcon = () => 👁🗨;
-export default function LoginDialog({ open, onClose }) {
- const { login } = useAuth();
- const { t } = useI18n();
- const [username, setUsername] = useState('');
- const [password, setPassword] = useState('');
- const [showPassword, setShowPassword] = useState(false);
- const [error, setError] = useState('');
- const [loading, setLoading] = useState(false);
+class LoginDialog extends Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ username: '',
+ password: '',
+ showPassword: false,
+ error: '',
+ loading: false
+ };
+ }
- const handleSubmit = async (e) => {
+ handleSubmit = async (e) => {
e.preventDefault();
- setError('');
- setLoading(true);
+ const { auth: { login }, onClose } = this.props;
+ const { username, password } = this.state;
+
+ this.setState({ error: '', loading: true });
try {
await login(username, password);
// Success - close dialog and reset form
- setUsername('');
- setPassword('');
+ this.setState({ username: '', password: '' });
if (onClose) onClose();
} catch (err) {
- setError(err.message);
+ this.setState({ error: err.message });
} finally {
- setLoading(false);
+ this.setState({ loading: false });
}
};
- const handleClose = () => {
- setError('');
+ handleClose = () => {
+ const { onClose } = this.props;
+ this.setState({ error: '' });
if (onClose) onClose();
};
- return (
-
-
-
- {t('login.title')}
-
-
- {t('login.subtitle')}
-
-
+ render() {
+ const { open, i18n: { t } } = this.props;
+ const { username, password, showPassword, error, loading } = this.state;
-
-
- );
+
+ this.setState({ username: e.target.value })}
+ disabled={loading}
+ sx={{ mb: 2 }}
+ />
+
+ this.setState({ password: e.target.value })}
+ disabled={loading}
+ InputProps={{
+ endAdornment: (
+
+ this.setState({ showPassword: !showPassword })}
+ edge="end"
+ size="small"
+ >
+ {showPassword ? : }
+
+
+ )
+ }}
+ />
+
+
+
+
+
+
+
+ );
+ }
}
+
+export default withAuth(withI18n(LoginDialog));
diff --git a/src/client/OutputChart.js b/src/client/OutputChart.js
index 82034df..3036deb 100644
--- a/src/client/OutputChart.js
+++ b/src/client/OutputChart.js
@@ -1,4 +1,4 @@
-import React, { useState, useEffect } from 'react';
+import React, { Component } from 'react';
import {
Chart as ChartJS,
CategoryScale,
@@ -11,7 +11,7 @@ import {
} from 'chart.js';
import { Line } from 'react-chartjs-2';
import { Box, Paper, CircularProgress } from '@mui/material';
-import { useI18n } from './I18nContext';
+import { withI18n } from './I18nContext';
ChartJS.register(
CategoryScale,
@@ -23,186 +23,210 @@ ChartJS.register(
Legend
);
-export default function OutputChart({ range, devices = [] }) {
- const { t } = useI18n();
- const [data, setData] = useState([]);
- const [loading, setLoading] = useState(true);
-
- useEffect(() => {
- const fetchData = async () => {
- try {
- const res = await fetch('api/outputs/history');
- const logs = await res.json();
- setData(logs);
- } catch (err) {
- console.error('Failed to fetch output history', err);
- } finally {
- setLoading(false);
- }
+class OutputChart extends Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ data: [],
+ loading: true
};
- fetchData();
+ this.interval = null;
+ }
+
+ componentDidMount() {
+ this.fetchData();
// Poll for updates every minute
- const interval = setInterval(fetchData, 60000);
- return () => clearInterval(interval);
- }, []);
+ this.interval = setInterval(() => this.fetchData(), 60000);
+ }
- if (loading) return ;
- if (!data || data.length === 0) return null;
-
- // Group data by "Device:Port"
- const groupedData = {};
- data.forEach(log => {
- const key = `${log.dev_name}:${log.port}`;
- if (!groupedData[key]) groupedData[key] = [];
- groupedData[key].push(log);
- });
-
- // Gruvbox Palette
- const gruvboxColors = ['#fb4934', '#b8bb26', '#fabd2f', '#83a598', '#d3869b', '#8ec07c', '#fe8019', '#928374'];
-
- // Generate datasets
- const datasets = Object.keys(groupedData).map((key, index) => {
- const logs = groupedData[key];
- const color = gruvboxColors[index % gruvboxColors.length];
-
- // Resolve Label
- let label = key;
- const isTapo = key.includes('tapo') || key.includes('Plug'); // Simple check
-
- if (devices && devices.length > 0) {
- const [dName, pNum] = key.split(':');
- const portNum = parseInt(pNum);
- const device = devices.find(d => d.dev_name === dName && d.port === portNum);
- if (device) {
- label = `${device.dev_name} - ${device.port_name}`;
- }
+ componentDidUpdate(prevProps) {
+ if (prevProps.range !== this.props.range) {
+ this.fetchData();
}
+ }
- // Fallback friendly name for Tapo if not found in devices list (which comes from readings)
- // Check if key looks like "tapo-xxx:0"
- if (label === key && key.startsWith('tapo-')) {
- // "tapo-001:0" -> "Tapo Plug 001"
- const parts2 = key.split(':');
- const tapoId = parts2[0].replace('tapo-', '');
- label = `Tapo Plug ${tapoId}`;
+ componentWillUnmount() {
+ if (this.interval) {
+ clearInterval(this.interval);
}
+ }
- return {
- label: label,
- data: logs.map(d => ({
- x: new Date(d.timestamp).getTime(),
- y: d.state === 0 ? 0 : (d.level || 10)
- })),
- borderColor: color,
- backgroundColor: color,
- stepped: true,
- pointRadius: 0,
- borderWidth: 2,
- // Custom property to identify binary devices in tooltip
- isBinary: isTapo
- };
- });
-
- // Create a time axis based on the data range
- const allTimestamps = [...new Set(data.map(d => d.timestamp))].sort();
-
- // We need to normalize data to these timestamps for "Line" chart
- const chartLabels = allTimestamps.map(ts => {
- const date = new Date(ts);
- return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
- });
-
- // We need to map *each* dataset's data to align with `chartLabels`.
- const alignedDatasets = datasets.map((ds, index) => {
- const alignedData = [];
- let lastValue = 0; // Default off
- const offset = index * 0.15; // Small offset to avoid overlap
-
- // Populate alignedData matching `allTimestamps`
- allTimestamps.forEach(ts => {
- // Find if we have a log at this specific timestamp
- const timeMs = new Date(ts).getTime();
- const exactLog = ds.data.find(d => Math.abs(d.x - timeMs) < 1000); // 1s tolerance
-
- if (exactLog) {
- lastValue = exactLog.y;
- }
- // Apply offset to the value for visualization
- // If value is 0 (OFF), we might still want to offset it?
- // Or only if ON? The user said "levels are on top of each other".
- // Even OFF lines might overlap. Let's offset everything.
- alignedData.push(lastValue + offset);
- });
-
- return {
- ...ds,
- data: alignedData
- };
- });
-
- const options = {
- responsive: true,
- maintainAspectRatio: false,
- interaction: {
- mode: 'index',
- intersect: false,
- },
- scales: {
- x: {
- ticks: {
- maxRotation: 0,
- autoSkip: true,
- maxTicksLimit: 12
- }
- },
- y: {
- type: 'linear',
- min: 0,
- max: 12, // Increased max to accommodate offsets
- ticks: {
- stepSize: 1,
- callback: (val) => {
- const rVal = Math.round(val);
- if (rVal === 0) return 'OFF';
- if (rVal === 10) return 'MAX/ON';
- return rVal;
- }
- }
- }
- },
- plugins: {
- legend: {
- position: 'top',
- labels: { color: '#ebdbb2' }
- },
- title: {
- display: true,
- text: 'Output History (Levels & States)',
- color: '#ebdbb2'
- },
- tooltip: {
- callbacks: {
- label: (context) => {
- // Round down to ignore the offset
- const val = Math.floor(context.raw);
- const ds = context.dataset;
-
- if (val === 0) return `${ds.label}: OFF`;
- // If it's binary (Tapo) or max level, show ON
- if (val === 10 || ds.isBinary) return `${ds.label}: ON`;
-
- return `${ds.label}: Level ${val}`;
- }
- }
- }
+ fetchData = async () => {
+ const { range } = this.props;
+ try {
+ const res = await fetch(`api/outputs/history?range=${range || 'day'}`);
+ const logs = await res.json();
+ this.setState({ data: logs, loading: false });
+ } catch (err) {
+ console.error('Failed to fetch output history', err);
+ this.setState({ loading: false });
}
};
- return (
-
-
-
-
-
- );
+ render() {
+ const { devices = [] } = this.props;
+ const { data, loading } = this.state;
+
+ if (loading) return ;
+ if (!data || data.length === 0) return null;
+
+ // Group data by "Device:Port"
+ const groupedData = {};
+ data.forEach(log => {
+ const key = `${log.dev_name}:${log.port}`;
+ if (!groupedData[key]) groupedData[key] = [];
+ groupedData[key].push(log);
+ });
+
+ // Gruvbox Palette
+ const gruvboxColors = ['#fb4934', '#b8bb26', '#fabd2f', '#83a598', '#d3869b', '#8ec07c', '#fe8019', '#928374'];
+
+ // Generate datasets
+ const datasets = Object.keys(groupedData).map((key, index) => {
+ const logs = groupedData[key];
+ const color = gruvboxColors[index % gruvboxColors.length];
+
+ // Resolve Label
+ let label = key;
+ const isTapo = key.includes('tapo') || key.includes('Plug'); // Simple check
+
+ if (devices && devices.length > 0) {
+ const [dName, pNum] = key.split(':');
+ const portNum = parseInt(pNum);
+ const device = devices.find(d => d.dev_name === dName && d.port === portNum);
+ if (device) {
+ label = `${device.dev_name} - ${device.port_name}`;
+ }
+ }
+
+ // Fallback friendly name for Tapo if not found in devices list (which comes from readings)
+ // Check if key looks like "tapo-xxx:0"
+ if (label === key && key.startsWith('tapo-')) {
+ // "tapo-001:0" -> "Tapo Plug 001"
+ const parts2 = key.split(':');
+ const tapoId = parts2[0].replace('tapo-', '');
+ label = `Tapo Plug ${tapoId}`;
+ }
+
+ return {
+ label: label,
+ data: logs.map(d => ({
+ x: new Date(d.timestamp).getTime(),
+ y: d.state === 0 ? 0 : (d.level || 10)
+ })),
+ borderColor: color,
+ backgroundColor: color,
+ stepped: true,
+ pointRadius: 0,
+ borderWidth: 2,
+ // Custom property to identify binary devices in tooltip
+ isBinary: isTapo
+ };
+ });
+
+ // Create a time axis based on the data range
+ const allTimestamps = [...new Set(data.map(d => d.timestamp))].sort();
+
+ // We need to normalize data to these timestamps for "Line" chart
+ const chartLabels = allTimestamps.map(ts => {
+ const date = new Date(ts);
+ return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
+ });
+
+ // We need to map *each* dataset's data to align with `chartLabels`.
+ const alignedDatasets = datasets.map((ds, index) => {
+ const alignedData = [];
+ let lastValue = 0; // Default off
+ const offset = index * 0.15; // Small offset to avoid overlap
+
+ // Populate alignedData matching `allTimestamps`
+ allTimestamps.forEach(ts => {
+ // Find if we have a log at this specific timestamp
+ const timeMs = new Date(ts).getTime();
+ const exactLog = ds.data.find(d => Math.abs(d.x - timeMs) < 1000); // 1s tolerance
+
+ if (exactLog) {
+ lastValue = exactLog.y;
+ }
+ // Apply offset to the value for visualization
+ // If value is 0 (OFF), we might still want to offset it?
+ // Or only if ON? The user said "levels are on top of each other".
+ // Even OFF lines might overlap. Let's offset everything.
+ alignedData.push(lastValue + offset);
+ });
+
+ return {
+ ...ds,
+ data: alignedData
+ };
+ });
+
+ const options = {
+ responsive: true,
+ maintainAspectRatio: false,
+ interaction: {
+ mode: 'index',
+ intersect: false,
+ },
+ scales: {
+ x: {
+ ticks: {
+ maxRotation: 0,
+ autoSkip: true,
+ maxTicksLimit: 12
+ }
+ },
+ y: {
+ type: 'linear',
+ min: 0,
+ max: 12, // Increased max to accommodate offsets
+ ticks: {
+ stepSize: 1,
+ callback: (val) => {
+ const rVal = Math.round(val);
+ if (rVal === 0) return 'OFF';
+ if (rVal === 10) return 'MAX/ON';
+ return rVal;
+ }
+ }
+ }
+ },
+ plugins: {
+ legend: {
+ position: 'top',
+ labels: { color: '#ebdbb2' }
+ },
+ title: {
+ display: true,
+ text: 'Output History (Levels & States)',
+ color: '#ebdbb2'
+ },
+ tooltip: {
+ callbacks: {
+ label: (context) => {
+ // Round down to ignore the offset
+ const val = Math.floor(context.raw);
+ const ds = context.dataset;
+
+ if (val === 0) return `${ds.label}: OFF`;
+ // If it's binary (Tapo) or max level, show ON
+ if (val === 10 || ds.isBinary) return `${ds.label}: ON`;
+
+ return `${ds.label}: Level ${val}`;
+ }
+ }
+ }
+ }
+ };
+
+ return (
+
+
+
+
+
+ );
+ }
}
+
+export default withI18n(OutputChart);
diff --git a/src/client/ProfileDialog.js b/src/client/ProfileDialog.js
index a1c6941..8d269e0 100644
--- a/src/client/ProfileDialog.js
+++ b/src/client/ProfileDialog.js
@@ -1,4 +1,4 @@
-import React, { useState, useEffect } from 'react';
+import React, { Component } from 'react';
import {
Dialog,
DialogTitle,
@@ -12,35 +12,29 @@ import {
CircularProgress,
Link
} from '@mui/material';
-import { useAuth } from './AuthContext';
+import { withAuth } from './AuthContext';
-export default function ProfileDialog({ open, onClose }) {
- const { user, login } = useAuth(); // We need a way to refresh user data or update context.
- // Actually, AuthContext might not expose a "refreshUser" method.
- // For now we will update the local state and rely on next page load/auth check to refresh global state,
- // OR we should ideally update the user object in AuthContext.
- // Checking AuthContext... it has `user` state. It sets user on login/checkAuth.
- // `checkAuth` is not exposed in `useAuth` return typically?
- // Let's assume we can just modify the user locally or ignore it, as long as the server has it.
+class ProfileDialog extends Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ telegramId: '',
+ loading: false,
+ saving: false,
+ error: null,
+ success: false
+ };
+ }
- // Better: Fetch the latest profile data when opening the dialog.
-
- const [telegramId, setTelegramId] = useState('');
- const [loading, setLoading] = useState(false);
- const [saving, setSaving] = useState(false);
- const [error, setError] = useState(null);
- const [success, setSuccess] = useState(false);
-
- useEffect(() => {
- if (open) {
- fetchProfile();
- setSuccess(false);
- setError(null);
+ componentDidUpdate(prevProps) {
+ if (this.props.open && !prevProps.open) {
+ this.fetchProfile();
+ this.setState({ success: false, error: null });
}
- }, [open]);
+ }
- const fetchProfile = async () => {
- setLoading(true);
+ fetchProfile = async () => {
+ this.setState({ loading: true });
try {
const token = localStorage.getItem('authToken');
const res = await fetch('api/auth/me', {
@@ -49,20 +43,21 @@ export default function ProfileDialog({ open, onClose }) {
if (res.ok) {
const data = await res.json();
if (data.user) {
- setTelegramId(data.user.telegramId || '');
+ this.setState({ telegramId: data.user.telegramId || '' });
}
}
} catch (err) {
console.error(err);
} finally {
- setLoading(false);
+ this.setState({ loading: false });
}
};
- const handleSave = async () => {
- setSaving(true);
- setError(null);
- setSuccess(false);
+ handleSave = async () => {
+ const { onClose } = this.props;
+ const { telegramId } = this.state;
+
+ this.setState({ saving: true, error: null, success: false });
try {
const token = localStorage.getItem('authToken');
const res = await fetch('api/auth/profile', {
@@ -79,56 +74,63 @@ export default function ProfileDialog({ open, onClose }) {
throw new Error(errData.error || 'Failed to update profile');
}
- setSuccess(true);
+ this.setState({ success: true });
setTimeout(() => onClose(), 1500);
} catch (err) {
- setError(err.message);
+ this.setState({ error: err.message });
} finally {
- setSaving(false);
+ this.setState({ saving: false });
}
};
- return (
-
- User Profile: {user?.username}
-
-
-
- Link your Telegram account to receive alarm notifications.
-
+ render() {
+ const { open, onClose, auth: { user } } = this.props;
+ const { telegramId, loading, saving, error, success } = this.state;
-
- To get your Telegram ID:
-
- - Search for @TischlereiCtrlBot on Telegram
- - Start the bot (`/start`)
- - It will reply with your ID. Copy it here.
-
-
+ return (
+
+ User Profile: {user?.username}
+
+
+
+ Link your Telegram account to receive alarm notifications.
+
- {loading ? (
-
- ) : (
- setTelegramId(e.target.value)}
- fullWidth
- placeholder="e.g. 123456789"
- helperText="Leave empty to disable notifications"
- />
- )}
+
+ To get your Telegram ID:
+
+ - Search for @TischlereiCtrlBot on Telegram
+ - Start the bot (`/start`)
+ - It will reply with your ID. Copy it here.
+
+
- {error && {error}}
- {success && Profile updated successfully!}
-
-
-
-
-
-
-
- );
+ {loading ? (
+
+ ) : (
+ this.setState({ telegramId: e.target.value })}
+ fullWidth
+ placeholder="e.g. 123456789"
+ helperText="Leave empty to disable notifications"
+ />
+ )}
+
+ {error && {error}}
+ {success && Profile updated successfully!}
+
+
+
+
+
+
+
+ );
+ }
}
+
+export default withAuth(ProfileDialog);
diff --git a/src/client/RuleCard.js b/src/client/RuleCard.js
index e1ffdb7..8bdb83d 100644
--- a/src/client/RuleCard.js
+++ b/src/client/RuleCard.js
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { Component } from 'react';
import {
Box,
Paper,
@@ -8,7 +8,7 @@ import {
Chip,
Tooltip
} from '@mui/material';
-import { useI18n } from './I18nContext';
+import { withI18n } from './I18nContext';
// Simple icons using unicode/emoji
const EditIcon = () => ✏️;
@@ -16,254 +16,263 @@ const DeleteIcon = () => 🗑️;
const dayOrder = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
-function TriggerSummary({ trigger }) {
- const { t } = useI18n();
- const dayLabels = {
- mon: t('days.mon').charAt(0),
- tue: t('days.tue').charAt(0),
- wed: t('days.wed').charAt(0),
- thu: t('days.thu').charAt(0),
- fri: t('days.fri').charAt(0),
- sat: t('days.sat').charAt(0),
- sun: t('days.sun').charAt(0)
- };
- const parts = [];
+class TriggerSummary extends Component {
+ render() {
+ const { trigger, t } = this.props;
+ const dayLabels = {
+ mon: t('days.mon').charAt(0),
+ tue: t('days.tue').charAt(0),
+ wed: t('days.wed').charAt(0),
+ thu: t('days.thu').charAt(0),
+ fri: t('days.fri').charAt(0),
+ sat: t('days.sat').charAt(0),
+ sun: t('days.sun').charAt(0)
+ };
+ const parts = [];
- // Scheduled time (trigger at exact time)
- if (trigger.scheduledTime) {
- const { time, days } = trigger.scheduledTime;
- const isEveryDay = days?.length === 7;
- const isWeekdays = days?.length === 5 && ['mon', 'tue', 'wed', 'thu', 'fri'].every(d => days.includes(d));
- let dayText = isEveryDay ? t('ruleCard.daily') : isWeekdays ? t('ruleCard.weekdays') :
- dayOrder.filter(d => days?.includes(d)).map(d => dayLabels[d]).join('');
+ // Scheduled time (trigger at exact time)
+ if (trigger.scheduledTime) {
+ const { time, days } = trigger.scheduledTime;
+ const isEveryDay = days?.length === 7;
+ const isWeekdays = days?.length === 5 && ['mon', 'tue', 'wed', 'thu', 'fri'].every(d => days.includes(d));
+ let dayText = isEveryDay ? t('ruleCard.daily') : isWeekdays ? t('ruleCard.weekdays') :
+ dayOrder.filter(d => days?.includes(d)).map(d => dayLabels[d]).join('');
- parts.push(
-
-
-
- {t('ruleCard.at')} {time} ({dayText})
-
-
- );
- }
-
- // Time range (active during window)
- if (trigger.timeRange) {
- const { start, end, days } = trigger.timeRange;
- const isEveryDay = days?.length === 7;
- const isWeekdays = days?.length === 5 && ['mon', 'tue', 'wed', 'thu', 'fri'].every(d => days.includes(d));
- let dayText = isEveryDay ? t('ruleCard.daily') : isWeekdays ? t('ruleCard.weekdays') :
- dayOrder.filter(d => days?.includes(d)).map(d => dayLabels[d]).join('');
-
- parts.push(
-
-
-
- {start}–{end} ({dayText})
-
-
- );
- }
-
- // Sensor conditions
- if (trigger.sensors && trigger.sensors.length > 0) {
- const logic = trigger.sensorLogic || 'and';
- const sensorText = trigger.sensors.map((s, i) => (
-
- {i > 0 && }
- {s.sensorLabel || s.sensor} {s.operator} {s.value}
-
- ));
-
- parts.push(
-
-
-
- {sensorText}
-
-
- );
- }
-
- // Legacy support for old trigger format
- if (trigger.type === 'time' && !trigger.timeRange) {
- const days = trigger.days || [];
- const isEveryDay = days.length === 7;
- const isWeekdays = days.length === 5 && ['mon', 'tue', 'wed', 'thu', 'fri'].every(d => days.includes(d));
- let dayText = isEveryDay ? t('ruleCard.daily') : isWeekdays ? t('ruleCard.weekdays') :
- dayOrder.filter(d => days.includes(d)).map(d => dayLabels[d]).join(' ');
-
- parts.push(
-
-
- {t('ruleCard.at')} {trigger.time} ({dayText})
-
- );
- }
-
- if (trigger.type === 'sensor' && !trigger.sensors) {
- parts.push(
-
-
-
- {trigger.sensorLabel || trigger.sensor} {trigger.operator} {trigger.value}
-
-
- );
- }
-
- if (parts.length === 0) return null;
-
- return (
-
- {parts}
-
- );
-}
-
-function ActionSummary({ action }) {
- const { t } = useI18n();
-
- if (action.type === 'toggle') {
- // Check if it's a level or binary action
- const hasLevel = action.level !== undefined;
- return (
-
-
-
- → {action.targetLabel || action.target} {hasLevel ? `${t('ruleCard.level')} ${action.level}` : (action.state ? t('ruleCard.on') : t('ruleCard.off'))}
-
-
- );
- }
-
- if (action.type === 'keepOn') {
- const hasLevel = action.level !== undefined;
- return (
-
-
-
- → {action.targetLabel || action.target} {hasLevel ? `${t('ruleCard.level')} ${action.level}` : t('ruleCard.on')} {t('ruleCard.forMinutes', { duration: action.duration })}
-
-
- );
- }
-
- return null;
-}
-
-export default function RuleCard({ rule, onEdit, onDelete, onToggle, onMoveUp, onMoveDown, colorTags = [], readOnly, activeInfo }) {
- const { t } = useI18n();
- // Get list of tag colors for this rule (handle array or backwards-compat single value)
- const ruleTagIds = rule.colorTags || (rule.colorTag ? [rule.colorTag] : []);
- const ruleTags = ruleTagIds.map(tagId => colorTags.find(t => t.id === tagId)).filter(Boolean);
-
- return (
- 0 ? `4px solid ${ruleTags[0].color}` : '1px solid #504945',
- boxShadow: activeInfo ? '0 0 0 2px #b8bb26' : 'none', // Active highlight
- '&:hover': {
- transform: readOnly ? 'none' : 'translateX(4px)',
- borderColor: rule.enabled ? '#8ec07c' : '#504945'
- }
- }}
- >
-
-
-
-
- {rule.name}
- {/* ACTIVE INDICATOR */}
- {activeInfo && (
-
- )}
-
- {ruleTags.length > 0 && (
-
- {ruleTags.map(tag => (
-
- ))}
-
- )}
- {!rule.enabled && (
-
- )}
-
-
-
-
-
-
+ parts.push(
+
+
+
+ {t('ruleCard.at')} {time} ({dayText})
+
+ );
+ }
- {!readOnly && (
-
- {/* Move buttons */}
-
-
-
- days.includes(d));
+ let dayText = isEveryDay ? t('ruleCard.daily') : isWeekdays ? t('ruleCard.weekdays') :
+ dayOrder.filter(d => days?.includes(d)).map(d => dayLabels[d]).join('');
+
+ parts.push(
+
+
+
+ {start}–{end} ({dayText})
+
+
+ );
+ }
+
+ // Sensor conditions
+ if (trigger.sensors && trigger.sensors.length > 0) {
+ const logic = trigger.sensorLogic || 'and';
+ const sensorText = trigger.sensors.map((s, i) => (
+
+ {i > 0 && }
+ {s.sensorLabel || s.sensor} {s.operator} {s.value}
+
+ ));
+
+ parts.push(
+
+
+
+ {sensorText}
+
+
+ );
+ }
+
+ // Legacy support for old trigger format
+ if (trigger.type === 'time' && !trigger.timeRange) {
+ const days = trigger.days || [];
+ const isEveryDay = days.length === 7;
+ const isWeekdays = days.length === 5 && ['mon', 'tue', 'wed', 'thu', 'fri'].every(d => days.includes(d));
+ let dayText = isEveryDay ? t('ruleCard.daily') : isWeekdays ? t('ruleCard.weekdays') :
+ dayOrder.filter(d => days.includes(d)).map(d => dayLabels[d]).join(' ');
+
+ parts.push(
+
+
+ {t('ruleCard.at')} {trigger.time} ({dayText})
+
+ );
+ }
+
+ if (trigger.type === 'sensor' && !trigger.sensors) {
+ parts.push(
+
+
+
+ {trigger.sensorLabel || trigger.sensor} {trigger.operator} {trigger.value}
+
+
+ );
+ }
+
+ if (parts.length === 0) return null;
+
+ return (
+
+ {parts}
+
+ );
+ }
+}
+
+class ActionSummary extends Component {
+ render() {
+ const { action, t } = this.props;
+
+ if (action.type === 'toggle') {
+ // Check if it's a level or binary action
+ const hasLevel = action.level !== undefined;
+ return (
+
+
+
+ → {action.targetLabel || action.target} {hasLevel ? `${t('ruleCard.level')} ${action.level}` : (action.state ? t('ruleCard.on') : t('ruleCard.off'))}
+
+
+ );
+ }
+
+ if (action.type === 'keepOn') {
+ const hasLevel = action.level !== undefined;
+ return (
+
+
+
+ → {action.targetLabel || action.target} {hasLevel ? `${t('ruleCard.level')} ${action.level}` : t('ruleCard.on')} {t('ruleCard.forMinutes', { duration: action.duration })}
+
+
+ );
+ }
+
+ return null;
+ }
+}
+
+class RuleCard extends Component {
+ render() {
+ const { rule, onEdit, onDelete, onToggle, onMoveUp, onMoveDown, colorTags = [], readOnly, activeInfo, i18n: { t } } = this.props;
+
+ // Get list of tag colors for this rule (handle array or backwards-compat single value)
+ const ruleTagIds = rule.colorTags || (rule.colorTag ? [rule.colorTag] : []);
+ const ruleTags = ruleTagIds.map(tagId => colorTags.find(t => t.id === tagId)).filter(Boolean);
+
+ return (
+ 0 ? `4px solid ${ruleTags[0].color}` : '1px solid #504945',
+ boxShadow: activeInfo ? '0 0 0 2px #b8bb26' : 'none', // Active highlight
+ '&:hover': {
+ transform: readOnly ? 'none' : 'translateX(4px)',
+ borderColor: rule.enabled ? '#8ec07c' : '#504945'
+ }
+ }}
+ >
+
+
+
+
+ {rule.name}
+ {/* ACTIVE INDICATOR */}
+ {activeInfo && (
+
- ▲
-
-
+ sx={{
+ bgcolor: '#b8bb26',
+ color: '#282828',
+ fontWeight: 'bold',
+ height: 20
+ }}
+ />
+ )}
+
+ {ruleTags.length > 0 && (
+
+ {ruleTags.map(tag => (
+
+ ))}
+
+ )}
+ {!rule.enabled && (
+
+ )}
+
+
+
+
+
+
+
+
+ {!readOnly && (
+
+ {/* Move buttons */}
+
+
+
+
+ ▲
+
+
+
+
+
+
+ ▼
+
+
+
+
+
+
-
-
-
- ▼
-
-
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
- )}
-
-
- );
+ )}
+
+
+ );
+ }
}
+
+export default withI18n(RuleCard);
diff --git a/src/client/RuleEditor.js b/src/client/RuleEditor.js
index f55b752..87c2c1b 100644
--- a/src/client/RuleEditor.js
+++ b/src/client/RuleEditor.js
@@ -1,4 +1,4 @@
-import React, { useState, useEffect } from 'react';
+import React, { Component } from 'react';
import {
Dialog,
DialogTitle,
@@ -23,7 +23,7 @@ import {
Paper,
Chip
} from '@mui/material';
-import { useI18n } from './I18nContext';
+import { withI18n } from './I18nContext';
const DAYS_KEYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
@@ -36,12 +36,9 @@ const OPERATORS = [
];
// Single sensor condition component
-function SensorCondition({ condition, sensors, onChange, onRemove, disabled }) {
- const selectedSensor = sensors.find(s => s.id === condition.sensor);
- const isStateSensor = selectedSensor?.type === 'output-state';
-
- // When sensor changes, reset operator to appropriate default
- const handleSensorChange = (newSensorId) => {
+class SensorCondition extends Component {
+ handleSensorChange = (newSensorId) => {
+ const { sensors, condition, onChange } = this.props;
const newSensor = sensors.find(s => s.id === newSensorId);
const newIsState = newSensor?.type === 'output-state';
onChange({
@@ -52,196 +49,196 @@ function SensorCondition({ condition, sensors, onChange, onRemove, disabled }) {
});
};
- return (
-
-
- Sensor
-
-
+ render() {
+ const { condition, sensors, onChange, onRemove, disabled } = this.props;
+ const selectedSensor = sensors.find(s => s.id === condition.sensor);
+ const isStateSensor = selectedSensor?.type === 'output-state';
- {isStateSensor ? (
- // State sensor: is on / is off
-
+ return (
+
+
+ Sensor
- ) : (
- // Value sensor: numeric comparison
- <>
-
+
+ {isStateSensor ? (
+ // State sensor: is on / is off
+
- onChange({ ...condition, value: Number(e.target.value) })}
- sx={{ width: 80 }}
- disabled={disabled}
- />
- >
- )}
+ ) : (
+ // Value sensor: numeric comparison
+ <>
+
+
+
+ onChange({ ...condition, value: Number(e.target.value) })}
+ sx={{ width: 80 }}
+ disabled={disabled}
+ />
+ >
+ )}
- {onRemove && (
-
- ❌
-
- )}
-
- );
+ {onRemove && (
+
+ ❌
+
+ )}
+
+ );
+ }
}
-export default function RuleEditor({ open, rule, onSave, onClose, sensors = [], outputs = [], colorTags: availableColorTags = [], saving }) {
- const { t } = useI18n();
- const [name, setName] = useState('');
- const [selectedTags, setSelectedTags] = useState([]); // array of tag ids
+class RuleEditor extends Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ name: '',
+ selectedTags: [],
+ useScheduledTime: false,
+ scheduledTime: '08:00',
+ scheduledDays: ['mon', 'tue', 'wed', 'thu', 'fri'],
+ useTimeRange: false,
+ timeStart: '08:00',
+ timeEnd: '18:00',
+ timeRangeDays: ['mon', 'tue', 'wed', 'thu', 'fri'],
+ useSensors: false,
+ sensorConditions: [{ sensor: '', operator: '>', value: 25 }],
+ sensorLogic: 'and',
+ actionType: 'toggle',
+ target: '',
+ toggleState: true,
+ outputLevel: 5,
+ duration: 15
+ };
+ }
- // Scheduled time state (trigger at specific time)
- const [useScheduledTime, setUseScheduledTime] = useState(false);
- const [scheduledTime, setScheduledTime] = useState('08:00');
- const [scheduledDays, setScheduledDays] = useState(['mon', 'tue', 'wed', 'thu', 'fri']);
+ componentDidUpdate(prevProps) {
+ if (this.props.open !== prevProps.open || this.props.rule !== prevProps.rule) {
+ this.initializeState();
+ }
+ }
- // Time range state (active during window)
- const [useTimeRange, setUseTimeRange] = useState(false);
- const [timeStart, setTimeStart] = useState('08:00');
- const [timeEnd, setTimeEnd] = useState('18:00');
- const [timeRangeDays, setTimeRangeDays] = useState(['mon', 'tue', 'wed', 'thu', 'fri']);
+ componentDidMount() {
+ this.initializeState();
+ }
- // Sensor conditions state
- const [useSensors, setUseSensors] = useState(false);
- const [sensorConditions, setSensorConditions] = useState([{ sensor: '', operator: '>', value: 25 }]);
- const [sensorLogic, setSensorLogic] = useState('and'); // 'and' or 'or'
+ initializeState = () => {
+ const { rule, sensors, outputs } = this.props;
- // Action state
- const [actionType, setActionType] = useState('toggle');
- const [target, setTarget] = useState('');
- const [toggleState, setToggleState] = useState(true);
- const [outputLevel, setOutputLevel] = useState(5); // 1-10 for port outputs
- const [duration, setDuration] = useState(15);
-
- // Check if target is a binary (on/off) output or level (1-10) output
- const selectedOutput = outputs.find(o => o.id === target);
- const isBinaryOutput = selectedOutput?.type === 'plug' || selectedOutput?.type === 'virtual';
-
- // Reset form when rule changes or dialog opens
- useEffect(() => {
if (rule) {
- setName(rule.name);
- // colorTags can be array or single value for backwards compat
const tags = rule.colorTags || (rule.colorTag ? [rule.colorTag] : []);
- setSelectedTags(Array.isArray(tags) ? tags : []);
-
- // Parse trigger
const trigger = rule.trigger || {};
- // Scheduled time
- setUseScheduledTime(!!trigger.scheduledTime);
- if (trigger.scheduledTime) {
- setScheduledTime(trigger.scheduledTime.time || '08:00');
- setScheduledDays(trigger.scheduledTime.days || []);
- }
-
- // Time range
- setUseTimeRange(!!trigger.timeRange);
- if (trigger.timeRange) {
- setTimeStart(trigger.timeRange.start || '08:00');
- setTimeEnd(trigger.timeRange.end || '18:00');
- setTimeRangeDays(trigger.timeRange.days || []);
- }
-
- setUseSensors(!!trigger.sensors && trigger.sensors.length > 0);
- if (trigger.sensors && trigger.sensors.length > 0) {
- setSensorConditions(trigger.sensors);
- setSensorLogic(trigger.sensorLogic || 'and');
- }
-
- // Parse action
- setActionType(rule.action?.type || 'toggle');
- setTarget(rule.action?.target || '');
- if (rule.action?.type === 'toggle') {
- setToggleState(rule.action?.state ?? true);
- setOutputLevel(rule.action?.level ?? 5);
- } else {
- setDuration(rule.action?.duration || 15);
- }
+ this.setState({
+ name: rule.name,
+ selectedTags: Array.isArray(tags) ? tags : [],
+ useScheduledTime: !!trigger.scheduledTime,
+ scheduledTime: trigger.scheduledTime?.time || '08:00',
+ scheduledDays: trigger.scheduledTime?.days || [],
+ useTimeRange: !!trigger.timeRange,
+ timeStart: trigger.timeRange?.start || '08:00',
+ timeEnd: trigger.timeRange?.end || '18:00',
+ timeRangeDays: trigger.timeRange?.days || [],
+ useSensors: !!trigger.sensors && trigger.sensors.length > 0,
+ sensorConditions: trigger.sensors?.length > 0 ? trigger.sensors : [{ sensor: sensors[0]?.id || '', operator: '>', value: 25 }],
+ sensorLogic: trigger.sensorLogic || 'and',
+ actionType: rule.action?.type || 'toggle',
+ target: rule.action?.target || '',
+ toggleState: rule.action?.state ?? true,
+ outputLevel: rule.action?.level ?? 5,
+ duration: rule.action?.duration || 15
+ });
} else {
- // Reset to defaults
- setName('');
- setSelectedTags([]);
- setUseScheduledTime(true);
- setScheduledTime('08:00');
- setScheduledDays(['mon', 'tue', 'wed', 'thu', 'fri']);
- setUseTimeRange(false);
- setTimeStart('08:00');
- setTimeEnd('18:00');
- setTimeRangeDays(['mon', 'tue', 'wed', 'thu', 'fri']);
- setUseSensors(false);
- setSensorConditions([{ sensor: sensors[0]?.id || '', operator: '>', value: 25 }]);
- setSensorLogic('and');
- setActionType('toggle');
- setTarget(outputs[0]?.id || '');
- setToggleState(true);
- setOutputLevel(5);
- setDuration(15);
- }
- }, [rule, open, sensors, outputs]);
-
- // Set default sensor/output when lists load
- useEffect(() => {
- if (sensorConditions[0]?.sensor === '' && sensors.length > 0) {
- setSensorConditions([{ ...sensorConditions[0], sensor: sensors[0].id }]);
- }
- if (!target && outputs.length > 0) setTarget(outputs[0].id);
- }, [sensors, outputs, sensorConditions, target]);
-
- const handleScheduledDaysChange = (event, newDays) => {
- if (newDays.length > 0) setScheduledDays(newDays);
- };
-
- const handleTimeRangeDaysChange = (event, newDays) => {
- if (newDays.length > 0) setTimeRangeDays(newDays);
- };
-
- const addSensorCondition = () => {
- setSensorConditions([...sensorConditions, { sensor: sensors[0]?.id || '', operator: '>', value: 25 }]);
- };
-
- const updateSensorCondition = (index, newCondition) => {
- const updated = [...sensorConditions];
- updated[index] = newCondition;
- setSensorConditions(updated);
- };
-
- const removeSensorCondition = (index) => {
- if (sensorConditions.length > 1) {
- setSensorConditions(sensorConditions.filter((_, i) => i !== index));
+ this.setState({
+ name: '',
+ selectedTags: [],
+ useScheduledTime: true,
+ scheduledTime: '08:00',
+ scheduledDays: ['mon', 'tue', 'wed', 'thu', 'fri'],
+ useTimeRange: false,
+ timeStart: '08:00',
+ timeEnd: '18:00',
+ timeRangeDays: ['mon', 'tue', 'wed', 'thu', 'fri'],
+ useSensors: false,
+ sensorConditions: [{ sensor: sensors[0]?.id || '', operator: '>', value: 25 }],
+ sensorLogic: 'and',
+ actionType: 'toggle',
+ target: outputs[0]?.id || '',
+ toggleState: true,
+ outputLevel: 5,
+ duration: 15
+ });
}
};
- const handleSave = () => {
+ handleScheduledDaysChange = (event, newDays) => {
+ if (newDays.length > 0) this.setState({ scheduledDays: newDays });
+ };
+
+ handleTimeRangeDaysChange = (event, newDays) => {
+ if (newDays.length > 0) this.setState({ timeRangeDays: newDays });
+ };
+
+ addSensorCondition = () => {
+ const { sensors } = this.props;
+ this.setState(prev => ({
+ sensorConditions: [...prev.sensorConditions, { sensor: sensors[0]?.id || '', operator: '>', value: 25 }]
+ }));
+ };
+
+ updateSensorCondition = (index, newCondition) => {
+ this.setState(prev => {
+ const updated = [...prev.sensorConditions];
+ updated[index] = newCondition;
+ return { sensorConditions: updated };
+ });
+ };
+
+ removeSensorCondition = (index) => {
+ this.setState(prev => {
+ if (prev.sensorConditions.length > 1) {
+ return { sensorConditions: prev.sensorConditions.filter((_, i) => i !== index) };
+ }
+ return prev;
+ });
+ };
+
+ handleSave = () => {
+ const { onSave, sensors, outputs } = this.props;
+ const { name, selectedTags, useScheduledTime, scheduledTime, scheduledDays,
+ useTimeRange, timeStart, timeEnd, timeRangeDays,
+ useSensors, sensorConditions, sensorLogic,
+ actionType, target, toggleState, outputLevel, duration } = this.state;
+
const selectedOutput = outputs.find(o => o.id === target);
// Build trigger object
@@ -285,447 +282,457 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
onSave(ruleData);
};
- const isValid = name.trim().length > 0 &&
- (useScheduledTime || useTimeRange || useSensors) &&
- (!useScheduledTime || scheduledDays.length > 0) &&
- (!useTimeRange || timeRangeDays.length > 0) &&
- (!useSensors || sensorConditions.every(c => c.sensor)) &&
- target;
+ render() {
+ const { open, rule, onClose, sensors = [], outputs = [], colorTags: availableColorTags = [], saving, i18n: { t } } = this.props;
+ const { name, selectedTags, useScheduledTime, scheduledTime, scheduledDays,
+ useTimeRange, timeStart, timeEnd, timeRangeDays,
+ useSensors, sensorConditions, sensorLogic,
+ actionType, target, toggleState, outputLevel, duration } = this.state;
- return (
-
-
- {rule ? t('ruleEditor.editTitle') : t('ruleEditor.createTitle')}
-
+ const selectedOutput = outputs.find(o => o.id === target);
+ const isBinaryOutput = selectedOutput?.type === 'plug' || selectedOutput?.type === 'virtual';
-
-
- {/* Rule Name */}
- setName(e.target.value)}
- fullWidth
- placeholder={t('ruleEditor.ruleNamePlaceholder')}
- disabled={saving}
- />
+ const isValid = name.trim().length > 0 &&
+ (useScheduledTime || useTimeRange || useSensors) &&
+ (!useScheduledTime || scheduledDays.length > 0) &&
+ (!useTimeRange || timeRangeDays.length > 0) &&
+ (!useSensors || sensorConditions.every(c => c.sensor)) &&
+ target;
- {/* Color Tag Picker (multi-select) */}
-
- Tags:
- setSelectedTags([])}
- sx={{
- width: 24,
- height: 24,
- borderRadius: '50%',
- bgcolor: '#504945',
- cursor: 'pointer',
- border: selectedTags.length === 0 ? '3px solid #ebdbb2' : '2px solid #504945',
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'center',
- fontSize: '0.7rem',
- '&:hover': { opacity: 0.8 }
- }}
- >
- ✕
-
- {availableColorTags.map(tag => (
+ return (
+
+
+ {rule ? t('ruleEditor.editTitle') : t('ruleEditor.createTitle')}
+
+
+
+
+ {/* Rule Name */}
+ this.setState({ name: e.target.value })}
+ fullWidth
+ placeholder={t('ruleEditor.ruleNamePlaceholder')}
+ disabled={saving}
+ />
+
+ {/* Color Tag Picker (multi-select) */}
+
+ Tags:
{
- if (selectedTags.includes(tag.id)) {
- setSelectedTags(selectedTags.filter(t => t !== tag.id));
- } else {
- setSelectedTags([...selectedTags, tag.id]);
- }
- }}
+ onClick={() => this.setState({ selectedTags: [] })}
sx={{
width: 24,
height: 24,
borderRadius: '50%',
- bgcolor: tag.color,
+ bgcolor: '#504945',
cursor: 'pointer',
- border: selectedTags.includes(tag.id) ? '3px solid #ebdbb2' : '2px solid transparent',
+ border: selectedTags.length === 0 ? '3px solid #ebdbb2' : '2px solid #504945',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
+ fontSize: '0.7rem',
'&:hover': { opacity: 0.8 }
}}
>
- {selectedTags.includes(tag.id) && ✓}
+ ✕
- ))}
-
-
- {/* TRIGGERS SECTION */}
-
-
- {t('ruleEditor.triggersSection')}
-
-
-
- {/* Scheduled Time Trigger (fires at exact time) */}
-
- setUseScheduledTime(e.target.checked)}
- disabled={saving}
- />
- }
- label={{t('ruleEditor.scheduledTime')}}
- />
-
- {useScheduledTime && (
-
- setScheduledTime(e.target.value)}
- InputLabelProps={{ shrink: true }}
- size="small"
- sx={{ width: 150 }}
- disabled={saving}
- />
-
-
- {t('ruleEditor.days')}
-
-
- {DAYS_KEYS.map(key => (
-
- {t(`days.${key}`)}
-
- ))}
-
-
+ {availableColorTags.map(tag => (
+ {
+ if (selectedTags.includes(tag.id)) {
+ this.setState({ selectedTags: selectedTags.filter(t => t !== tag.id) });
+ } else {
+ this.setState({ selectedTags: [...selectedTags, tag.id] });
+ }
+ }}
+ sx={{
+ width: 24,
+ height: 24,
+ borderRadius: '50%',
+ bgcolor: tag.color,
+ cursor: 'pointer',
+ border: selectedTags.includes(tag.id) ? '3px solid #ebdbb2' : '2px solid transparent',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ '&:hover': { opacity: 0.8 }
+ }}
+ >
+ {selectedTags.includes(tag.id) && ✓}
- )}
-
+ ))}
+
- {/* Time Range Trigger (active within window) */}
-
- setUseTimeRange(e.target.checked)}
- disabled={saving}
- />
- }
- label={⏰ Time Range (active during window)}
- />
+ {/* TRIGGERS SECTION */}
+
+
+ {t('ruleEditor.triggersSection')}
+
+
- {useTimeRange && (
-
-
- setTimeStart(e.target.value)}
- InputLabelProps={{ shrink: true }}
- size="small"
+ {/* Scheduled Time Trigger */}
+
+ this.setState({ useScheduledTime: e.target.checked })}
disabled={saving}
/>
- to
+ }
+ label={{t('ruleEditor.scheduledTime')}}
+ />
+
+ {useScheduledTime && (
+
setTimeEnd(e.target.value)}
+ value={scheduledTime}
+ onChange={(e) => this.setState({ scheduledTime: e.target.value })}
InputLabelProps={{ shrink: true }}
size="small"
+ sx={{ width: 150 }}
disabled={saving}
/>
-
-
-
- {t('ruleEditor.days')}
-
-
- {DAYS_KEYS.map(key => (
-
- {t(`days.${key}`)}
-
- ))}
-
-
-
- )}
-
-
- {/* Sensor Conditions Trigger */}
-
- setUseSensors(e.target.checked)}
- disabled={saving || sensors.length === 0}
- />
- }
- label={
-
- 📊 Sensor Conditions {sensors.length === 0 && '(no sensors available)'}
-
- }
- />
-
- {useSensors && (
-
- {sensorConditions.length > 1 && (
-
- Combine conditions with:
+
+
+ {t('ruleEditor.days')}
+
v && setSensorLogic(v)}
+ value={scheduledDays}
+ onChange={this.handleScheduledDaysChange}
size="small"
disabled={saving}
>
-
- AND
-
-
- OR
-
+ {DAYS_KEYS.map(key => (
+
+ {t(`days.${key}`)}
+
+ ))}
- )}
+
+ )}
+
- {sensorConditions.map((cond, i) => (
-
- {i > 0 && (
-
- )}
- updateSensorCondition(i, newCond)}
- onRemove={sensorConditions.length > 1 ? () => removeSensorCondition(i) : null}
+ {/* Time Range Trigger */}
+
+ this.setState({ useTimeRange: e.target.checked })}
+ disabled={saving}
+ />
+ }
+ label={⏰ Time Range (active during window)}
+ />
+
+ {useTimeRange && (
+
+
+ this.setState({ timeStart: e.target.value })}
+ InputLabelProps={{ shrink: true }}
+ size="small"
+ disabled={saving}
+ />
+ to
+ this.setState({ timeEnd: e.target.value })}
+ InputLabelProps={{ shrink: true }}
+ size="small"
disabled={saving}
/>
+
+
+ {t('ruleEditor.days')}
+
+
+ {DAYS_KEYS.map(key => (
+
+ {t(`days.${key}`)}
+
+ ))}
+
+
+
+ )}
+
+
+ {/* Sensor Conditions Trigger */}
+
+ this.setState({ useSensors: e.target.checked })}
+ disabled={saving || sensors.length === 0}
+ />
+ }
+ label={
+
+ 📊 Sensor Conditions {sensors.length === 0 && '(no sensors available)'}
+
+ }
+ />
+
+ {useSensors && (
+
+ {sensorConditions.length > 1 && (
+
+ Combine conditions with:
+ v && this.setState({ sensorLogic: v })}
+ size="small"
+ disabled={saving}
+ >
+
+ AND
+
+
+ OR
+
+
+
+ )}
+
+ {sensorConditions.map((cond, i) => (
+
+ {i > 0 && (
+
+ )}
+ this.updateSensorCondition(i, newCond)}
+ onRemove={sensorConditions.length > 1 ? () => this.removeSensorCondition(i) : null}
+ disabled={saving}
+ />
+
+ ))}
+
+
+
+ )}
+
+
+
+ {/* ACTION SECTION */}
+
+
+ ACTION (What to do)
+
+
+
+
+ Action Type
+
+
+
+
+ Target Output
+
+
-
-
- )}
-
-
-
- {/* ACTION SECTION */}
-
-
- ACTION (What to do)
-
-
-
-
- Action Type
-
-
-
-
- Target Output
-
-
-
- {/* Toggle action controls */}
- {actionType === 'toggle' && (
-
- {isBinaryOutput ? (
- // Binary output: On/Off switch
- setToggleState(e.target.checked)}
- color="primary"
+ {/* Toggle action controls */}
+ {actionType === 'toggle' && (
+
+ {isBinaryOutput ? (
+ this.setState({ toggleState: e.target.checked })}
+ color="primary"
+ disabled={saving}
+ />
+ }
+ label={toggleState ? 'Turn ON' : 'Turn OFF'}
+ />
+ ) : (
+
+
+ Set Level: {outputLevel}
+
+ this.setState({ outputLevel: val })}
+ min={1}
+ max={10}
+ step={1}
+ marks={[
+ { value: 1, label: '1' },
+ { value: 5, label: '5' },
+ { value: 10, label: '10' }
+ ]}
+ valueLabelDisplay="auto"
disabled={saving}
/>
- }
- label={toggleState ? 'Turn ON' : 'Turn OFF'}
- />
- ) : (
- // Level output: 1-10 slider
-
-
- Set Level: {outputLevel}
-
- setOutputLevel(val)}
- min={1}
- max={10}
- step={1}
- marks={[
- { value: 1, label: '1' },
- { value: 5, label: '5' },
- { value: 10, label: '10' }
- ]}
- valueLabelDisplay="auto"
- disabled={saving}
- />
-
- )}
-
- )}
-
- {/* Keep On action controls */}
- {actionType === 'keepOn' && (
-
- {!isBinaryOutput && (
- // Level for port outputs
-
-
- Set Level: {outputLevel}
-
- setOutputLevel(val)}
- min={1}
- max={10}
- step={1}
- marks={[
- { value: 1, label: '1' },
- { value: 5, label: '5' },
- { value: 10, label: '10' }
- ]}
- valueLabelDisplay="auto"
- disabled={saving}
- />
-
- )}
-
-
- Duration: {duration} minutes
-
- setDuration(val)}
- min={1}
- max={120}
- marks={[
- { value: 1, label: '1m' },
- { value: 30, label: '30m' },
- { value: 60, label: '1h' },
- { value: 120, label: '2h' }
- ]}
- valueLabelDisplay="auto"
- disabled={saving}
- />
+
+ )}
-
- )}
-
-
-
+ )}
-
-
-
-
-
- );
+
+
+
+
+
+
+
+
+ );
+ }
}
+
+export default withI18n(RuleEditor);
diff --git a/src/client/RuleManager.js b/src/client/RuleManager.js
index 8d14e0d..d144640 100644
--- a/src/client/RuleManager.js
+++ b/src/client/RuleManager.js
@@ -1,4 +1,4 @@
-import React, { useState, useEffect, useCallback } from 'react';
+import React, { Component } from 'react';
import {
Box,
Typography,
@@ -12,8 +12,9 @@ import {
} from '@mui/material';
import RuleCard from './RuleCard';
import RuleEditor from './RuleEditor';
-import { useAuth } from './AuthContext';
-import { useI18n } from './I18nContext';
+import { withAuth } from './AuthContext';
+import { withI18n } from './I18nContext';
+import { withDevices } from './DevicesContext';
// 8 color tags
const COLOR_TAGS = [
@@ -27,227 +28,177 @@ const COLOR_TAGS = [
{ id: 'gray', label: 'Gray', color: '#928374' }
];
-export default function RuleManager() {
- const { isAdmin } = useAuth();
- const { t } = useI18n();
- const [rules, setRules] = useState([]);
- const [activeRuleIds, setActiveRuleIds] = useState([]);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
- const [editorOpen, setEditorOpen] = useState(false);
- const [editingRule, setEditingRule] = useState(null);
- const [devices, setDevices] = useState([]);
- const [saving, setSaving] = useState(false);
- const [filterTag, setFilterTag] = useState(null); // null = show all
+class RuleManager extends Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ rules: [],
+ activeRuleIds: [],
+ loading: true,
+ error: null,
+ editorOpen: false,
+ editingRule: null,
+ saving: false,
+ filterTag: null
+ };
+ this.activeRulesInterval = null;
+ }
- // Get auth token from localStorage
- const getAuthHeaders = useCallback(() => {
+ componentDidMount() {
+ this.fetchRules();
+ this.fetchActiveRules();
+ this.activeRulesInterval = setInterval(() => this.fetchActiveRules(), 5000);
+ }
+
+ componentWillUnmount() {
+ if (this.activeRulesInterval) {
+ clearInterval(this.activeRulesInterval);
+ }
+ }
+
+ getAuthHeaders = () => {
const token = localStorage.getItem('authToken');
return {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
};
- }, []);
+ };
- // Fetch rules from server (public endpoint)
- const fetchRules = useCallback(async () => {
+ fetchRules = async () => {
try {
const res = await fetch('api/rules');
- if (!res.ok) {
- throw new Error('Failed to fetch rules');
- }
+ if (!res.ok) throw new Error('Failed to fetch rules');
const data = await res.json();
- setRules(data);
- setError(null);
+ this.setState({ rules: data, error: null, loading: false });
} catch (err) {
- setError(err.message);
- } finally {
- setLoading(false);
+ this.setState({ error: err.message, loading: false });
}
- }, [setRules, setLoading, setError]);
+ };
- // Fetch devices for sensor/output selection
- const fetchDevices = useCallback(async () => {
- try {
- const res = await fetch('api/devices');
- if (res.ok) {
- const data = await res.json();
- setDevices(data);
- }
- } catch (err) {
- console.error('Failed to fetch devices:', err);
- }
- }, []);
-
- const fetchActiveRules = useCallback(async () => {
+ fetchActiveRules = async () => {
try {
const res = await fetch('api/rules/active');
if (res.ok) {
const data = await res.json();
- setActiveRuleIds(data); // "ids" is now a list of objects {id, level, state}
+ this.setState({ activeRuleIds: data });
}
} catch (err) {
console.error('Failed to fetch active rules:', err);
}
- }, []);
-
- useEffect(() => {
- fetchRules();
- fetchDevices();
- fetchActiveRules();
- const interval = setInterval(fetchActiveRules, 5000); // 5s poll
- return () => clearInterval(interval);
- }, [fetchRules, fetchDevices, fetchActiveRules]);
-
- // Build available sensors
- // - Environment sensors (Temp, Humidity) are per DEVICE
- // - Port values (Fan Speed, Brightness, CO2, etc.) are per PORT
- const availableSensors = [];
- const seenDevices = new Set();
-
- devices.forEach(d => {
- // Add environment sensors once per device (temp, humidity)
- if (!seenDevices.has(d.dev_name)) {
- seenDevices.add(d.dev_name);
- availableSensors.push({
- id: `${d.dev_name}:temp`,
- label: `${d.dev_name} - Temperature`,
- type: 'temperature'
- });
- availableSensors.push({
- id: `${d.dev_name}:humidity`,
- label: `${d.dev_name} - Humidity`,
- type: 'humidity'
- });
- }
-
- // Add each port as a sensor (Fan Speed 0-10, Brightness 0-10, CO2, etc.)
- availableSensors.push({
- id: `${d.dev_name}:${d.port}:level`,
- label: `${d.dev_name} - ${d.port_name} Level`,
- type: d.port_name.toLowerCase()
- });
- });
-
- // Build available outputs: Tapo plugs + ALL device ports + 4 virtual channels
- const availableOutputs = [
- // Tapo smart plugs
- { id: 'tapo-001', label: 'Tapo 001', type: 'plug' },
- { id: 'tapo-002', label: 'Tapo 002', type: 'plug' },
- { id: 'tapo-003', label: 'Tapo 003', type: 'plug' },
- { id: 'tapo-004', label: 'Tapo 004', type: 'plug' },
- { id: 'tapo-005', label: 'Tapo 005', type: 'plug' },
- // All device ports as outputs
- ...devices.map(d => ({
- id: `${d.dev_name}:${d.port}:out`,
- label: `${d.dev_name} - ${d.port_name}`,
- type: d.port_name.toLowerCase()
- })),
- // 4 virtual channels
- { id: 'virtual-1', label: 'Virtual Channel 1', type: 'virtual' },
- { id: 'virtual-2', label: 'Virtual Channel 2', type: 'virtual' },
- { id: 'virtual-3', label: 'Virtual Channel 3', type: 'virtual' },
- { id: 'virtual-4', label: 'Virtual Channel 4', type: 'virtual' }
- ];
-
- // Add Tapo and virtual channels as sensors (on/off state)
- [...availableOutputs.filter(o => o.type === 'plug' || o.type === 'virtual')].forEach(o => {
- availableSensors.push({
- id: `${o.id}:state`,
- label: `${o.label} (State)`,
- type: 'output-state'
- });
- });
-
- const handleAddRule = () => {
- setEditingRule(null);
- setEditorOpen(true);
};
- const handleEditRule = (rule) => {
- setEditingRule(rule);
- setEditorOpen(true);
+ getAvailableSensors = () => {
+ const { devicesCtx: { getAvailableSensors, getAvailableOutputs } } = this.props;
+ const sensors = getAvailableSensors();
+ const outputs = getAvailableOutputs();
+
+ // Add Tapo and virtual channels as sensors (on/off state)
+ outputs.filter(o => o.type === 'plug' || o.type === 'virtual').forEach(o => {
+ sensors.push({
+ id: `${o.id}:state`,
+ label: `${o.label} (State)`,
+ type: 'output-state'
+ });
+ });
+
+ return sensors;
};
- const handleDeleteRule = async (ruleId) => {
+ handleAddRule = () => {
+ this.setState({ editingRule: null, editorOpen: true });
+ };
+
+ handleEditRule = (rule) => {
+ this.setState({ editingRule: rule, editorOpen: true });
+ };
+
+ handleDeleteRule = async (ruleId) => {
+ const { i18n: { t } } = this.props;
if (!confirm(t('rules.deleteConfirm'))) return;
- setSaving(true);
+ this.setState({ saving: true });
try {
const res = await fetch(`api/rules/${ruleId}`, {
method: 'DELETE',
- headers: getAuthHeaders()
+ headers: this.getAuthHeaders()
});
if (!res.ok) throw new Error('Failed to delete rule');
- setRules(rules.filter(r => r.id !== ruleId));
+ this.setState(prev => ({
+ rules: prev.rules.filter(r => r.id !== ruleId),
+ saving: false
+ }));
} catch (err) {
- setError(err.message);
- } finally {
- setSaving(false);
+ this.setState({ error: err.message, saving: false });
}
};
- const handleToggleRule = async (ruleId) => {
+ handleToggleRule = async (ruleId) => {
+ const { rules } = this.state;
const rule = rules.find(r => r.id === ruleId);
if (!rule) return;
- setSaving(true);
+ this.setState({ saving: true });
try {
const res = await fetch(`api/rules/${ruleId}`, {
method: 'PUT',
- headers: getAuthHeaders(),
+ headers: this.getAuthHeaders(),
body: JSON.stringify({ ...rule, enabled: !rule.enabled })
});
if (!res.ok) throw new Error('Failed to update rule');
const updated = await res.json();
- setRules(rules.map(r => r.id === ruleId ? updated : r));
+ this.setState(prev => ({
+ rules: prev.rules.map(r => r.id === ruleId ? updated : r),
+ saving: false
+ }));
} catch (err) {
- setError(err.message);
- } finally {
- setSaving(false);
+ this.setState({ error: err.message, saving: false });
}
};
- const handleSaveRule = async (ruleData) => {
- setSaving(true);
+ handleSaveRule = async (ruleData) => {
+ const { editingRule } = this.state;
+ this.setState({ saving: true });
try {
if (editingRule) {
- // Update existing rule - preserve enabled state
const res = await fetch(`api/rules/${editingRule.id}`, {
method: 'PUT',
- headers: getAuthHeaders(),
+ headers: this.getAuthHeaders(),
body: JSON.stringify({ ...ruleData, enabled: editingRule.enabled })
});
if (!res.ok) throw new Error('Failed to update rule');
const updated = await res.json();
- setRules(rules.map(r => r.id === editingRule.id ? updated : r));
+ this.setState(prev => ({
+ rules: prev.rules.map(r => r.id === editingRule.id ? updated : r),
+ editorOpen: false,
+ editingRule: null,
+ saving: false
+ }));
} else {
- // Create new rule
const res = await fetch('api/rules', {
method: 'POST',
- headers: getAuthHeaders(),
+ headers: this.getAuthHeaders(),
body: JSON.stringify({ ...ruleData, enabled: true })
});
if (!res.ok) throw new Error('Failed to create rule');
const newRule = await res.json();
- setRules([...rules, newRule]);
+ this.setState(prev => ({
+ rules: [...prev.rules, newRule],
+ editorOpen: false,
+ editingRule: null,
+ saving: false
+ }));
}
- setEditorOpen(false);
- setEditingRule(null);
} catch (err) {
- setError(err.message);
- } finally {
- setSaving(false);
+ this.setState({ error: err.message, saving: false });
}
};
- const handleCloseEditor = () => {
- setEditorOpen(false);
- setEditingRule(null);
+ handleCloseEditor = () => {
+ this.setState({ editorOpen: false, editingRule: null });
};
- // Move rule up or down
- const handleMoveRule = async (ruleId, direction) => {
+ handleMoveRule = async (ruleId, direction) => {
+ const { rules } = this.state;
const idx = rules.findIndex(r => r.id === ruleId);
if (idx === -1) return;
if (direction === 'up' && idx === 0) return;
@@ -256,145 +207,150 @@ export default function RuleManager() {
const newRules = [...rules];
const swapIdx = direction === 'up' ? idx - 1 : idx + 1;
[newRules[idx], newRules[swapIdx]] = [newRules[swapIdx], newRules[idx]];
- setRules(newRules);
+ this.setState({ rules: newRules });
- // Save new order to server
try {
const ruleIds = newRules.map(r => r.id);
await fetch('api/rules/reorder', {
method: 'PUT',
- headers: getAuthHeaders(),
+ headers: this.getAuthHeaders(),
body: JSON.stringify({ ruleIds })
});
} catch (err) {
- setError('Failed to save order');
+ this.setState({ error: 'Failed to save order' });
}
};
- // Filter rules by color tag
- const filteredRules = filterTag
- ? rules.filter(r => (r.colorTags || []).includes(filterTag))
- : rules;
+ render() {
+ const { auth: { isAdmin }, i18n: { t }, devicesCtx: { getAvailableOutputs } } = this.props;
+ const { rules, activeRuleIds, loading, error, editorOpen, editingRule, saving, filterTag } = this.state;
+
+ const filteredRules = filterTag
+ ? rules.filter(r => (r.colorTags || []).includes(filterTag))
+ : rules;
+
+ if (loading) {
+ return (
+
+
+ {t('rules.loading')}
+
+ );
+ }
- if (loading) {
return (
-
-
- {t('rules.loading')}
-
- );
- }
-
- return (
-
-
-
-
- {t('rules.title')}
-
-
- {isAdmin ? t('rules.adminDescription') : t('rules.guestDescription')}
-
+
+
+
+
+ {t('rules.title')}
+
+
+ {isAdmin ? t('rules.adminDescription') : t('rules.guestDescription')}
+
+
+ {isAdmin && (
+
+ )}
- {isAdmin && (
-
- )}
-
-
+
- {/* Color tag filter */}
-
- Filter:
- setFilterTag(null)}
- sx={{
- bgcolor: filterTag === null ? '#ebdbb2' : '#504945',
- color: filterTag === null ? '#282828' : '#ebdbb2'
- }}
- />
- {COLOR_TAGS.map(tag => (
+ {/* Color tag filter */}
+
+ Filter:
setFilterTag(filterTag === tag.id ? null : tag.id)}
+ onClick={() => this.setState({ filterTag: null })}
sx={{
- bgcolor: filterTag === tag.id ? tag.color : '#504945',
- color: filterTag === tag.id ? '#282828' : tag.color,
- border: `2px solid ${tag.color}`,
- '&:hover': { bgcolor: tag.color, color: '#282828' }
+ bgcolor: filterTag === null ? '#ebdbb2' : '#504945',
+ color: filterTag === null ? '#282828' : '#ebdbb2'
}}
/>
- ))}
-
-
- {error && (
- setError(null)}>
- {error}
-
- )}
-
- {filteredRules.length === 0 ? (
-
-
- {rules.length === 0
- ? (isAdmin ? t('rules.noRules') + ' ' + t('rules.noRulesAdmin') : t('rules.noRules'))
- : 'No rules match the selected filter.'
- }
-
-
- ) : (
-
- {filteredRules.map((rule, idx) => (
- handleEditRule(rule) : null}
- onDelete={isAdmin ? () => handleDeleteRule(rule.id) : null}
- onToggle={isAdmin ? () => handleToggleRule(rule.id) : null}
- onMoveUp={isAdmin && idx > 0 ? () => handleMoveRule(rule.id, 'up') : null}
- onMoveDown={isAdmin && idx < filteredRules.length - 1 ? () => handleMoveRule(rule.id, 'down') : null}
- colorTags={COLOR_TAGS}
- readOnly={!isAdmin}
- activeInfo={activeRuleIds.find(r => r.id === rule.id)}
+ {COLOR_TAGS.map(tag => (
+ this.setState({ filterTag: filterTag === tag.id ? null : tag.id })}
+ sx={{
+ bgcolor: filterTag === tag.id ? tag.color : '#504945',
+ color: filterTag === tag.id ? '#282828' : tag.color,
+ border: `2px solid ${tag.color}`,
+ '&:hover': { bgcolor: tag.color, color: '#282828' }
+ }}
/>
))}
- )}
- {isAdmin && (
-
- )}
-
- );
+ {error && (
+ this.setState({ error: null })}>
+ {error}
+
+ )}
+
+ {filteredRules.length === 0 ? (
+
+
+ {rules.length === 0
+ ? (isAdmin ? t('rules.noRules') + ' ' + t('rules.noRulesAdmin') : t('rules.noRules'))
+ : 'No rules match the selected filter.'
+ }
+
+
+ ) : (
+
+ {filteredRules.map((rule, idx) => (
+ this.handleEditRule(rule) : null}
+ onDelete={isAdmin ? () => this.handleDeleteRule(rule.id) : null}
+ onToggle={isAdmin ? () => this.handleToggleRule(rule.id) : null}
+ onMoveUp={isAdmin && idx > 0 ? () => this.handleMoveRule(rule.id, 'up') : null}
+ onMoveDown={isAdmin && idx < filteredRules.length - 1 ? () => this.handleMoveRule(rule.id, 'down') : null}
+ colorTags={COLOR_TAGS}
+ readOnly={!isAdmin}
+ activeInfo={activeRuleIds.find(r => r.id === rule.id)}
+ />
+ ))}
+
+ )}
+
+ {isAdmin && (
+
+ )}
+
+ );
+ }
}
+
+export default withDevices(withAuth(withI18n(RuleManager)));
diff --git a/tapotest.js b/tapotest.js
new file mode 100644
index 0000000..2b9d84c
--- /dev/null
+++ b/tapotest.js
@@ -0,0 +1,9 @@
+import { cloudLogin, loginDevice } from 'tp-link-tapo-connect';
+
+const cloudApi = await cloudLogin('torosaw@gmail.com', 'HazeWolf99');
+
+const devices = await cloudApi.listDevices();
+console.log(devices);
+
+//const device = await loginDevice('torosaw@gmail.com', 'HazeWolf99', devices[0]);
+//console.log(device);