From 5f4c4a55d83fc7ed47bbc9f3f76cd581667d2f1b Mon Sep 17 00:00:00 2001 From: sebseb7 Date: Tue, 23 Dec 2025 05:58:03 +0100 Subject: [PATCH] u --- dist/index.html | 14 + package-lock.json | 265 ++++++++ package.json | 1 + src/client/AlarmCard.js | 230 +++---- src/client/AlarmEditor.js | 706 ++++++++++---------- src/client/AlarmManager.js | 359 +++++----- src/client/App.js | 189 +++--- src/client/AuthContext.js | 8 + src/client/ControllerCard.js | 136 ++-- src/client/Dashboard.js | 135 ++-- src/client/DevicesContext.js | 135 ++++ src/client/EnvChart.js | 135 ++-- src/client/I18nContext.js | 8 + src/client/LanguageSwitcher.js | 72 +- src/client/LevelChart.js | 99 +-- src/client/LoginDialog.js | 240 +++---- src/client/OutputChart.js | 372 ++++++----- src/client/ProfileDialog.js | 152 ++--- src/client/RuleCard.js | 497 +++++++------- src/client/RuleEditor.js | 1149 ++++++++++++++++---------------- src/client/RuleManager.js | 492 +++++++------- tapotest.js | 9 + 22 files changed, 2935 insertions(+), 2468 deletions(-) create mode 100644 dist/index.html create mode 100644 src/client/DevicesContext.js create mode 100644 tapotest.js 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 ( - - - {alarm ? 'Edit Alarm' : 'Create Alarm'} - + const isValid = name.trim().length > 0 && + ((triggerMode === 'sensor' && sensorConditions.every(c => c.sensor)) || + (triggerMode === 'output' && outputTarget)) && + message.trim().length > 0; - - - setName(e.target.value)} - fullWidth - disabled={saving} - /> + return ( + + + {alarm ? 'Edit Alarm' : 'Create Alarm'} + - {/* Tags */} - - Tags: - {availableColorTags.map(tag => ( - { - if (selectedTags.includes(tag.id)) { - setSelectedTags(selectedTags.filter(t => t !== tag.id)); - } else { - setSelectedTags([...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' - }} + + + this.setState({ name: e.target.value })} + fullWidth + disabled={saving} + /> + + {/* Tags */} + + Tags: + {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' + }} + > + {selectedTags.includes(tag.id) && } + + ))} + + + {/* TRIGGER */} + TRIGGER CONDITIONS + + + {/* Trigger Mode Selection */} + + Trigger Type + + - {/* TRIGGER */} - TRIGGER CONDITIONS - - - {/* Trigger Mode Selection */} - - Trigger Type - - - - {triggerMode === 'output' ? ( - - - Trigger when a filtered rule changes a device state. - - - - Target Output - - - - State - - - - - ) : ( - <> - {/* Time Window */} - - setUseTimeRange(e.target.checked)} disabled={saving} /> - } - label="Active Time Window (Optional)" - /> - {useTimeRange && ( - - setTimeStart(e.target.value)} - InputLabelProps={{ shrink: true }} size="small" - /> - to - setTimeEnd(e.target.value)} - InputLabelProps={{ shrink: true }} size="small" - /> - - )} - - - {/* Sensors */} - - 📊 Sensor Thresholds - - {sensorConditions.length > 1 && ( - - Logic: - v && setSensorLogic(v)} size="small" - > - AND - OR - - - )} - - {sensorConditions.map((cond, i) => ( - updateSensorCondition(i, newCond)} - onRemove={sensorConditions.length > 1 ? () => removeSensorCondition(i) : null} - disabled={saving} - /> - ))} - + {triggerMode === 'output' ? ( + + + Trigger when a filtered rule changes a device state. + + + + Target Output + + + + State + + - - )} + ) : ( + <> + {/* Time Window */} + + this.setState({ useTimeRange: e.target.checked })} disabled={saving} /> + } + label="Active Time Window (Optional)" + /> + {useTimeRange && ( + + this.setState({ timeStart: e.target.value })} + InputLabelProps={{ shrink: true }} size="small" + /> + to + this.setState({ timeEnd: e.target.value })} + InputLabelProps={{ shrink: true }} size="small" + /> + + )} + - {/* ACTION */} - ACTION (Telelegram Notification) - + {/* Sensors */} + + 📊 Sensor Thresholds + + {sensorConditions.length > 1 && ( + + Logic: + v && this.setState({ sensorLogic: v })} size="small" + > + AND + OR + + + )} - - Severity - - + {sensorConditions.map((cond, i) => ( + this.updateSensorCondition(i, newCond)} + onRemove={sensorConditions.length > 1 ? () => this.removeSensorCondition(i) : null} + disabled={saving} + /> + ))} + + + + + )} - setMessage(e.target.value)} - fullWidth - multiline - rows={2} - placeholder="e.g. Temperature is too high!" - disabled={saving} - /> + {/* ACTION */} + ACTION (Telelegram Notification) + - - This message will be sent to all users who have linked their Telegram ID in their profile. - + + Severity + + - - + this.setState({ message: e.target.value })} + fullWidth + multiline + rows={2} + placeholder="e.g. Temperature is too high!" + disabled={saving} + /> - - - - - - ); + + 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; -
- - {error && ( - - {error} - - )} + return ( + + + + {t('login.title')} + + + {t('login.subtitle')} + + - setUsername(e.target.value)} - disabled={loading} - sx={{ mb: 2 }} - /> - - setPassword(e.target.value)} - disabled={loading} - InputProps={{ - endAdornment: ( - - setShowPassword(!showPassword)} - edge="end" - size="small" - > - {showPassword ? : } - - - ) - }} - /> - - - - - -
-
- ); + + 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: -
    -
  1. Search for @TischlereiCtrlBot on Telegram
  2. -
  3. Start the bot (`/start`)
  4. -
  5. It will reply with your ID. Copy it here.
  6. -
-
+ 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: +
    +
  1. Search for @TischlereiCtrlBot on Telegram
  2. +
  3. Start the bot (`/start`)
  4. +
  5. It will reply with your ID. Copy it here.
  6. +
+
- {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);