66 Commits

Author SHA1 Message Date
proddy
eaa277fef0 v3.7.2 2025-03-22 10:32:03 +01:00
proddy
e418b7d8e7 fix routing in tabs - #2264 2024-11-30 21:31:42 +01:00
proddy
a4e3be6a69 3.7.1 changelog 2024-11-29 10:30:14 +01:00
proddy
a1bc5bb055 3.7.1 2024-11-29 10:30:05 +01:00
proddy
f444ca31e0 Merge remote-tracking branch 'origin/dev' 2024-10-27 11:41:47 +01:00
proddy
1b70b55989 update demo url 2024-08-10 14:58:48 +02:00
proddy
1487f30c43 Merge remote-tracking branch 'origin/dev' for 3.6.5 2024-03-23 17:56:05 +01:00
Proddy
e00eb8e64f 3.6.4 2023-11-26 20:11:36 +01:00
Proddy
f41bb3671c 3.6.4 2023-11-24 07:36:36 +01:00
Proddy
22c75e6df3 3.6.4 2023-11-24 07:36:29 +01:00
proddy
5ab10b7aa6 fixes #1450 2023-11-22 09:48:09 +01:00
Proddy
ee5fd4d0eb 3.6.3 2023-11-21 14:40:47 +01:00
Proddy
46f35bc67c another patch on 3.6.3 2023-11-21 14:40:32 +01:00
Proddy
ec85a7ec24 3.6.3 (refershed) 2023-11-19 21:24:01 +01:00
Proddy
02f2389587 add workflow_dispatch: 2023-11-18 18:51:12 +01:00
Proddy
7f140021aa quick fix - https://github.com/emsesp/EMS-ESP32/pull/1438 2023-11-18 18:47:28 +01:00
Proddy
6796962c1e 3.6.3 2023-11-18 13:35:20 +01:00
Proddy
df6de21cf4 Merge remote-tracking branch 'origin/dev' 2023-11-18 13:35:04 +01:00
Proddy
df9f75a5c9 updated yarn for 3.6.2 2023-10-01 17:42:54 +02:00
Proddy
7bd8710eb6 3.6.2 2023-10-01 17:40:09 +02:00
Proddy
32f2c6d341 Merge remote-tracking branch 'origin/dev' 2023-10-01 17:40:02 +02:00
Proddy
86919c1684 Merge branch 'origin/dev' 2023-09-09 14:12:07 +02:00
Proddy
86e29515e7 build s3 2023-08-15 18:44:24 +02:00
Proddy
46eb4185d7 update with 3.6.0 2023-08-13 14:37:13 +02:00
Proddy
8da6761a48 Merge branch 'dev' 2023-08-13 14:32:41 +02:00
Proddy
9233f0dfcc v3.5.1 - merge with patch 2023-03-11 16:06:05 +01:00
Proddy
292f743b14 Update bug_report.md 2023-02-19 11:18:08 +01:00
Proddy
dd6dfffd57 Delete questions---troubleshooting.md 2023-02-19 10:49:52 +01:00
Proddy
ec705a5307 Delete feature_request.md 2023-02-19 10:49:45 +01:00
Proddy
f45f071710 Create config.yml 2023-02-19 10:49:32 +01:00
Proddy
f3858546de Merge branch 'origin/dev' 2023-02-06 21:58:27 +01:00
Proddy
d0ac0b7804 update with version on dev 2022-10-30 16:58:37 +01:00
Proddy
d8284ec09f Merge pull request #705 from MichaelDvP/main
v3.4.4 Fix for new installations with filesystem not initializing
2022-10-29 10:46:41 +02:00
MichaelDvP
6e982acde8 v3.4.4 Fix for new installations with filesystem not initializing 2022-10-28 10:50:51 +02:00
Proddy
8c94ce99b2 quick fix for filesystem initialization 2022-10-08 09:23:00 +02:00
proddy
fc057d18c9 Merge remote-tracking branch 'origin/dev' 2022-09-18 14:33:23 +02:00
Proddy
18e9b99413 Merge remote-tracking branch 'origin/dev' into main 2022-05-29 16:16:38 +02:00
Proddy
a47e0e8266 update for 3.4.0 2022-05-23 21:20:45 +02:00
Proddy
f412ddc716 Merge remote-tracking branch 'origin/dev' into main 2022-05-23 21:20:36 +02:00
proddy
29110e96e5 Merge remote-tracking branch 'origin/dev' 2022-01-20 10:51:40 +01:00
proddy
b65866217a 3.4.0 2021-11-28 23:03:28 +01:00
proddy
611e3b1243 Merge remote-tracking branch 'origin/dev' 2021-11-28 23:03:15 +01:00
proddy
2ca0a0c634 v3.2.1 merged from dev 2021-08-08 14:46:14 +02:00
proddy
7eb1f061b7 Merge remote-tracking branch 'origin/dev' for 3.2.0 release 2021-08-06 12:06:08 +02:00
proddy
50459a23fe force v16 of nodejs 2021-06-26 11:13:07 +02:00
proddy
5bf53c3389 3.1.1 2021-06-26 11:03:03 +02:00
proddy
4b7aa95be3 Merge remote-tracking branch 'origin/dev' 2021-06-26 11:02:55 +02:00
Proddy
70943f5758 Update pre_release.yml 2021-05-16 15:52:09 +02:00
Proddy
3bc280b817 Delete check_code.yml 2021-05-16 15:51:56 +02:00
Proddy
62b15a5319 Update pre_release.yml 2021-05-16 15:35:06 +02:00
Proddy
8dd18802d6 Update tagged_release.yml 2021-05-16 15:34:45 +02:00
proddy
57a516a83a updated README and images 2021-05-09 15:13:16 +02:00
proddy
a57fdaa4b3 Merge remote-tracking branch 'origin/dev' into main 2021-05-04 12:21:51 +02:00
proddy
4841e42286 Merge remote-tracking branch 'origin/dev' into main 2021-03-30 16:35:18 +02:00
proddy
8c2d2b06ed cleaned up old changelog 2021-03-18 20:59:09 +01:00
proddy
38c8b1b7f0 3.0.0 2021-03-18 20:58:21 +01:00
proddy
6fb5933a02 Merge remote-tracking branch 'origin/dev' into main 2021-03-18 20:58:12 +01:00
proddy
c0944433be remove workspace.code-workspace 2021-03-16 17:41:42 +01:00
Proddy
478e6362c9 Merge pull request #27 from FauthD:main
Add global names to Dallas sensors to avoid ugly <unknown> and other …
2021-03-16 17:39:00 +01:00
fauthd
4d6354db78 Add stuff to gitignore, add vscode workspace 2021-03-16 16:47:09 +01:00
fauthd
beab0f0c77 Add global names to Dallas sensors to avoid ugly <unknown> and other issues in HA 2021-03-16 16:00:23 +01:00
Proddy
c17749bd22 Update README.md 2021-03-14 23:43:33 +01:00
proddy
2bad769c5c build: include assets 2021-03-14 21:20:51 +01:00
proddy
8ad89ca64b move repo 2021-03-14 21:05:15 +01:00
proddy
9244d8daec Semantic Commit Messages 2021-03-14 18:10:57 +01:00
proddy
02d01334b2 update new build 2021-03-14 17:37:18 +01:00
421 changed files with 39838 additions and 52431 deletions

View File

@@ -1,27 +0,0 @@
{
"name": "EMS-ESP Devcontainer",
"image": "mcr.microsoft.com/devcontainers/typescript-node:1-22-bookworm",
"features": {
"ghcr.io/devcontainers/features/node:1": {},
"ghcr.io/devcontainers-extra/features/pnpm:2": {},
"ghcr.io/devcontainers/features/python:1": {},
"ghcr.io/shyim/devcontainers-features/bun:0": {}
},
"forwardPorts": [
3000,
3080
],
// Use 'postCreateCommand' to run commands after the container is created.
"postCreateCommand": "cd mock-api && pnpm install && cd .. && cd interface && pnpm install",
// Configure tool-specific properties.
"customizations": {
"vscode": {
"extensions": [
"platformio.platformio-ide"
]
}
}
}

View File

@@ -21,8 +21,8 @@ _Make sure your have performed every step and checked the applicable boxes befor
- [ ] Searched the issue in [issues](https://github.com/emsesp/EMS-ESP32/issues)
- [ ] Searched the issue in [discussions](https://github.com/emsesp/EMS-ESP32/discussions)
- [ ] Searched the issue in the [docs](https://emsesp.org/Troubleshooting/)
- [ ] Searched the issue in the [chat](https://discord.gg/GP9DPSgeJq)
- [ ] Searched the issue in the [docs](https://docs.emsesp.org/Troubleshooting/)
- [ ] Searched the issue in the [chat](https://discord.gg/3J3GgnzpyT)
- [ ] Provide the System information in the area below, taken from `http://<IP>/api/system`
```json

View File

@@ -1,11 +1,11 @@
blank_issues_enabled: false
contact_links:
- name: EMS-ESP Docs
url: https://emsesp.org
url: https://docs.emsesp.org
about: All the information related to EMS-ESP.
- name: EMS-ESP Discussions and Support
url: https://github.com/emsesp/EMS-ESP32/discussions
about: EMS-ESP usage Questions, Feature Requests and Projects.
- name: EMS-ESP Users Chat
url: https://discord.gg/GP9DPSgeJq
url: https://discord.gg/3J3GgnzpyT
about: Chat for feedback, questions and troubleshooting.

View File

@@ -1,79 +0,0 @@
name: 'Build dev release'
on:
workflow_dispatch:
push:
paths:
- 'src/emsesp_version.h'
branches:
- 'dev'
permissions:
contents: write
jobs:
pre-release:
name: 'Build Dev Release'
runs-on: ubuntu-latest
steps:
- name: Install python 3.13
uses: actions/setup-python@v6
with:
python-version: '3.13'
- name: Install Node.js 24
uses: actions/setup-node@v6
with:
node-version: 24
- name: Checkout repository
uses: actions/checkout@v5
- name: Enable Corepack
run: corepack enable pnpm
- name: Get the EMS-ESP version
id: build_info
run: |
version=`grep -E '^#define EMSESP_APP_VERSION' ./src/emsesp_version.h | awk -F'"' '{print $2}'`
echo "VERSION=$version" >> $GITHUB_OUTPUT
- name: Install PlatformIO
run: |
python -m pip install --upgrade pip
pip install -U platformio
python -m pip install intelhex
- name: Build webUI
run: |
platformio run -e build_webUI
- name: Build modbus
run: |
platformio run -e build_modbus
- name: Build standalone
run: |
platformio run -e build_standalone
- name: Build all PIO target environments, from default_envs
run: |
platformio run
- name: Commit the generated files
uses: stefanzweifel/git-auto-commit-action@v5
with:
commit_message: "chore: update generated files for v${{steps.build_info.outputs.VERSION}}"
- name: Create GitHub Release
id: 'automatic_releases'
uses: emsesp/action-automatic-releases@v1.0.0
with:
repo_token: '${{ secrets.GITHUB_TOKEN }}'
title: Development Build v${{steps.build_info.outputs.VERSION}}
automatic_release_tag: 'latest'
prerelease: true
files: |
CHANGELOG_LATEST.md
./build/firmware/*.*

View File

@@ -1,17 +1,16 @@
name: 'Publish releases to discord'
name: 'github-releases-to-discord'
on:
workflow_dispatch:
release:
types: [published]
jobs:
github-releases-to-discord:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: GitHub Releases To Discord
uses: SethCohen/github-releases-to-discord@v1.13.1

View File

@@ -1,13 +1,18 @@
name: 'Pre-check on PR'
permissions:
contents: read
name: 'pr_check'
on:
workflow_dispatch:
pull_request:
branches: dev
paths:
- 'src/**'
- '**.c'
- '**.cpp'
- '**.h'
- '**.hpp'
- '**.json'
- '**.py'
- '**.md'
- '.github/workflows/pr_check.yml'
jobs:
pre-release:
@@ -15,18 +20,18 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Install python 3.13
uses: actions/setup-python@v6
- name: Install python 3.11
uses: actions/setup-python@v5
with:
python-version: '3.13'
python-version: '3.11'
- name: Install PlatformIO
run: |
pip install wheel
pip install -U platformio
- name: Run unit tests
- name: Build native
run: |
platformio run -e native-test -t exec
platformio run -e native

66
.github/workflows/pre_release.yml vendored Normal file
View File

@@ -0,0 +1,66 @@
name: 'pre-release'
on:
workflow_dispatch:
push:
branches:
- 'dev'
jobs:
pre-release:
name: 'Automatic pre-release build'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Enable Corepack
run: corepack enable
- name: Install python 3.11
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install Node.js 20
uses: actions/setup-node@v4
with:
node-version: '20.x'
- name: Get EMS-ESP version
id: build_info
run: |
version=`grep -E '^#define EMSESP_APP_VERSION' ./src/version.h | awk -F'"' '{print $2}'`
echo "VERSION=$version" >> $GITHUB_OUTPUT
- name: Install PlatformIO
run: |
python -m pip install --upgrade pip
pip install -U platformio
- name: Build WebUI
run: |
cd interface
yarn install
yarn typesafe-i18n --no-watch
sed -i "s/= 'pl'/= 'en'/" ./src/i18n/i18n-util.ts
yarn build
yarn webUI
- name: Build all PIO target environments from default_envs
run: |
platformio run
env:
NO_BUILD_WEBUI: true
- name: Create GitHub Release
id: 'automatic_releases'
uses: emsesp/action-automatic-releases@v1.0.0
with:
repo_token: '${{ secrets.GITHUB_TOKEN }}'
title: Development Build v${{steps.build_info.outputs.VERSION}}
automatic_release_tag: 'latest'
prerelease: true
files: |
CHANGELOG_LATEST.md
./build/firmware/*.*

View File

@@ -1,14 +1,12 @@
# see https://github.com/marketplace/actions/sonarcloud-scan-for-c-and-c#usage
name: Sonar Check
permissions:
contents: read
on:
push:
branches:
- dev
paths:
- 'src/**'
# pull_request:
# types: [opened, synchronize, reopened]
jobs:
build:
@@ -19,15 +17,18 @@ jobs:
BUILD_WRAPPER_OUT_DIR: bw-output
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install Build Wrapper
uses: SonarSource/sonarqube-scan-action/install-build-wrapper@master
- name: Run Build Wrapper
run: build-wrapper-linux-x86-64 --out-dir ${{ env.BUILD_WRAPPER_OUT_DIR }} make all
- name: SonarQube Scan
uses: SonarSource/sonarqube-scan-action@master
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
- name: Install sonar-scanner and build-wrapper
uses: SonarSource/sonarcloud-github-c-cpp@v2
- name: Run build-wrapper
run: build-wrapper-linux-x86-64 --out-dir ${{ env.BUILD_WRAPPER_OUT_DIR }} make all
- name: Run sonar-scanner
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
run: sonar-scanner --define sonar.cfamily.compile-commands="${{ env.BUILD_WRAPPER_OUT_DIR }}/compile_commands.json"

View File

@@ -1,63 +0,0 @@
name: 'Build stable release'
permissions:
contents: write
on:
workflow_dispatch:
push:
tags:
- 'v*'
jobs:
tagged-release:
name: 'Build Stable Release'
runs-on: ubuntu-latest
steps:
- name: Install python 3.13
uses: actions/setup-python@v6
with:
python-version: '3.13'
- name: Install Node.js 24
uses: actions/setup-node@v6
with:
node-version: 24
- name: Checkout repository
uses: actions/checkout@v5
- name: Enable Corepack
run: corepack enable pnpm
- name: Install PlatformIO
run: |
python -m pip install --upgrade pip
pip install -U platformio
python -m pip install intelhex
- name: Build webUI
run: |
platformio run -e build_webUI
- name: Build modbus
run: |
platformio run -e build_modbus
- name: Build standalone
run: |
platformio run -e build_standalone
- name: Build all PIO target environments, from default_envs
run: |
platformio run
- name: Create GitHub Release
uses: emsesp/action-automatic-releases@v1.0.0
with:
repo_token: '${{ secrets.GITHUB_TOKEN }}'
prerelease: false
files: |
CHANGELOG.md
./build/firmware/*.*

View File

@@ -1,27 +0,0 @@
name: "Mark or close stale issues and PRs"
permissions:
contents: read
issues: write
pull-requests: write
on:
schedule:
- cron: "30 1 * * *"
jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v10
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-stale: 30
days-before-close: 5
stale-issue-message: "This issue is stale because it has been open 30 days with no activity. Remove stale label or comment otherwise this will be closed in 5 days."
stale-pr-message: "This PR has been automatically marked as stale because there has been no activity in last 30 days. It will be closed if no further activity occurs. Thank you for your contributions."
close-issue-message: "This issue was closed because it has been stalled for 5 days with no activity."
close-pr-message: "This PR was automatically closed because of being stale."
stale-pr-label: "stale"
stale-issue-label: "stale"
exempt-issue-labels: "bug,enhancement,pinned,security"
exempt-pr-labels: "bug,enhancement,pinned,security"

57
.github/workflows/tagged_release.yml vendored Normal file
View File

@@ -0,0 +1,57 @@
name: 'tagged-release'
on:
workflow_dispatch:
push:
tags:
- 'v*'
jobs:
tagged-release:
name: 'Tagged Release'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Enable Corepack
run: corepack enable
- name: Install python 3.11
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install Node.js 20
uses: actions/setup-node@v4
with:
node-version: '20.x'
- name: Install PlatformIO
run: |
python -m pip install --upgrade pip
pip install -U platformio
- name: Build WebUI
run: |
cd interface
yarn install
yarn typesafe-i18n --no-watch
sed -i "s/= 'pl'/= 'en'/" ./src/i18n/i18n-util.ts
yarn build
yarn webUI
- name: Build all PIO target environments from default_envs
run: |
platformio run
env:
NO_BUILD_WEBUI: true
- name: Create GitHub Release
uses: emsesp/action-automatic-releases@v1.0.0
with:
repo_token: '${{ secrets.GITHUB_TOKEN }}'
prerelease: false
files: |
CHANGELOG.md
./build/firmware/*.*

View File

@@ -1,65 +1,55 @@
name: 'Build test release'
name: 'test-release'
on:
workflow_dispatch:
push:
branches:
- 'test'
permissions:
contents: read
- 'dev2'
jobs:
pre-release:
name: 'Build Test Release'
name: 'Automatic test-release build'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Install python 3.13
uses: actions/setup-python@v6
with:
python-version: '3.13'
- name: Install Node.js 24
uses: actions/setup-node@v6
with:
node-version: 24
- name: Checkout repository
uses: actions/checkout@v5
- uses: actions/checkout@v4
- name: Enable Corepack
run: corepack enable pnpm
run: corepack enable
- name: Get the EMS-ESP version
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Use Node.js 20.x
uses: actions/setup-node@v4
with:
node-version: '20.x'
- name: Get EMS-ESP source code and version
id: build_info
run: |
version=`grep -E '^#define EMSESP_APP_VERSION' ./src/emsesp_version.h | awk -F'"' '{print $2}'`
version=`grep -E '^#define EMSESP_APP_VERSION' ./src/version.h | awk -F'"' '{print $2}'`
echo "VERSION=$version" >> $GITHUB_OUTPUT
- name: Install PlatformIO
run: |
python -m pip install --upgrade pip
pip install -U platformio
python -m pip install intelhex
- name: Build webUI
- name: Build WebUI
run: |
platformio run -e build_webUI
cd interface
yarn install
yarn typesafe-i18n --no-watch
sed -i "s/= 'pl'/= 'en'/" ./src/i18n/i18n-util.ts
yarn build
yarn webUI
- name: Build modbus
run: |
platformio run -e build_modbus
- name: Build standalone
run: |
platformio run -e build_standalone
- name: Build all PIO target environments, from default_envs
- name: Build all target environments from default_envs
run: |
platformio run
env:
NO_BUILD_WEBUI: true
- name: Create GitHub Release
id: 'automatic_releases'

9
.gitignore vendored
View File

@@ -2,6 +2,7 @@
.vscode/c_cpp_properties.json
.vscode/extensions.json
.vscode/launch.json
.vscode/settings.json
# c++ compiling
.clang_complete
@@ -27,10 +28,14 @@ stats.html
*.sln
*.sw?
.pnp.*
*/.yarn/cache/*
*/.yarn/install-state.gz
analyse.html
interface/vite.config.ts.timestamp*
*.local
src/ESP32React/WWWData.h
.yarn/*
.yarnrc.yml
# i18n generated files
interface/src/i18n/i18n-react.tsx
@@ -71,7 +76,3 @@ CMakeLists.txt
logs/*
sdkconfig.*
sdkconfig_tasmota_esp32
pnpm-lock.yaml
.cache/
interface/.tsbuildinfo
test/test_api/package-lock.json

View File

@@ -5,120 +5,6 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [3.8.1] 11 January 2026
## Added
- update time saved in nvs
- heatpump entities [#2883](https://github.com/emsesp/EMS-ESP32/issues/2883)
- HA input number format (mode) selectable box/slider (slider for max range 100) [#2900](https://github.com/emsesp/EMS-ESP32/discussions/2900)
## Fixed
- fix EMS bus disconnected errors on some systems [#2881](https://github.com/emsesp/EMS-ESP32/issues/2881)
- selflowtemp fix [#2876](https://github.com/emsesp/EMS-ESP32/issues/2876)
- updated valid GPIOs for ESP32S2, ESP32S3 and ESP32 that caused custom systems to block gpios [#2887](https://github.com/emsesp/EMS-ESP32/issues/2887)
- Junkers wwcharge offset [#2860](https://github.com/emsesp/EMS-ESP32/issues/2860)
- fixed minflowtemp [#2890](https://github.com/emsesp/EMS-ESP32/issues/2890)
- don't add HA uom/classes for bool values [#2885](https://github.com/emsesp/EMS-ESP32/issues/2885)
- fixed missing progress bar on web firmware uploads
## Changed
- snapshot gpios stored in temporary ram
- GPIOs stored along with the name and reported in log if conflicting
- free GPIOs depend on board profile [#2901](https://github.com/emsesp/EMS-ESP32/issues/2901)
- prefer PSram for mqtt queue [#2889](https://github.com/emsesp/EMS-ESP32/issues/2889)
- day schedule defult to all days, no day selected is not allowed
- board profile `CUSTOM` can only be selected in developer mode
- mqtt sends round values without decimals (`28` instead of `28.0`)
## [3.8.0] 31 December 2025
## Added
- analogsensor types: NTC and RGB-Led
- Flag for HMC310 [#2465](https://github.com/emsesp/EMS-ESP32/issues/2465)
- boiler auxheatersource [#2489](https://github.com/emsesp/EMS-ESP32/discussions/2489)
- thermostat last error for RC100/300 [#2501](https://github.com/emsesp/EMS-ESP32/issues/2501)
- boiler 0xC6 telegram [#1963](https://github.com/emsesp/EMS-ESP32/issues/1963)
- CS6800i changes [#2448](https://github.com/emsesp/EMS-ESP32/issues/2448), [#2449](https://github.com/emsesp/EMS-ESP32/issues/2449)
- charging pump [#2544](https://github.com/emsesp/EMS-ESP32/issues/2544)
- hybrid CSH5800iG [#2569](https://github.com/emsesp/EMS-ESP32/issues/2569)
- added EMS Device details to Home Assistant MQTT Discovery
- disinfection command [#2601](https://github.com/emsesp/EMS-ESP32/issues/2601)
- added new board profile for upcoming BBQKees E32V2.2
- set differential pressure entity in Mixer device
- set set climate action cooling/heating in HA [#2583](https://github.com/emsesp/EMS-ESP32/issues/2583)
- Internal sensors of E32V2_2
- FW200 display options [#2610](https://github.com/emsesp/EMS-ESP32/discussions/2610)
- CR11 mode settings OFF/MANUAL depends on selTemp [#2437](https://github.com/emsesp/EMS-ESP32/issues/2437)
- implemented eFuse settings for BBQKees boards to store model type and ESP chipset
- analogsensors for pulse output [#2624](https://github.com/emsesp/EMS-ESP32/discussions/2624)
- analogsensors frequency input [#2631](https://github.com/emsesp/EMS-ESP32/discussions/2631)
- SRC plus thermostats [#2636](https://github.com/emsesp/EMS-ESP32/issues/2636)
- Greenstar 2000 [#2645](https://github.com/emsesp/EMS-ESP32/issues/2645)
- RC3xx `dhw modetype` [#2659](https://github.com/emsesp/EMS-ESP32/discussions/2659)
- new boiler entities VR0,VR1, compressor speed [#2669](https://github.com/emsesp/EMS-ESP32/issues/2669)
- solar temperature TS16 [#2690](https://github.com/emsesp/EMS-ESP32/issues/2690)
- pumpmode enum for HT3 boilers, add commands for manual defrost, chimneysweeper [#2727](https://github.com/emsesp/EMS-ESP32/issues/2727)
- pid settings [#2735](https://github.com/emsesp/EMS-ESP32/issues/2735)
- refresh MQTT button added to MQTT Settings page
- heating assistance, rounding custum settings [#2763](https://github.com/emsesp/EMS-ESP32/discussions/2763)
- added counter 0..2 for short pulses, high frequency [#2758](https://github.com/emsesp/EMS-ESP32/issues/2758)
- added LWT (Last Will and Testament) to MQTT entities in Home Assistant
- added api/metrics endpoint for prometheus integration by @gr3enk [#2774](https://github.com/emsesp/EMS-ESP32/pull/2774)
- added RTL8201 to eth phy list [#2800](https://github.com/emsesp/EMS-ESP32/issues/2800)
- added partitions to Web UI Version page, so previous firmware versions can be installed [#2837](https://github.com/emsesp/EMS-ESP32/issues/2837)
- button pressures show LED. On a long press (10 seconds) the LED flashes for 5 seconds to indicate a factory reset is about to happen. [#2848](https://github.com/emsesp/EMS-ESP32/issues/2848)
- added `txpause` command to pause the TX, by setting Txmode to 0 (disabled) [#2850](https://github.com/emsesp/EMS-ESP32/issues/2850)
## Fixed
- dhw/switchtime [#2490](https://github.com/emsesp/EMS-ESP32/issues/2490)
- switch to secure mqtt [#2492](https://github.com/emsesp/EMS-ESP32/issues/2492)
- update link buttons [#2497](https://github.com/emsesp/EMS-ESP32/issues/2497)
- refresh scheduler states [#2502](https://github.com/emsesp/EMS-ESP32/discussions/2502)
- also rebuild HA config on mqtt connect for scheduler, custom and shower
- FB100 controls the hc, not the master [#2510](https://github.com/emsesp/EMS-ESP32/issues/2510)
- IPM DHW module, [#2524](https://github.com/emsesp/EMS-ESP32/issues/2524)
- charge optimization [#2543](https://github.com/emsesp/EMS-ESP32/issues/2543)
- shower active state retained, shows correctly in HA
- MQTT Command Topic with slashes [#2571](https://github.com/emsesp/EMS-ESP32/issues/2571)
- Add pulsed water meter input to V1.3 gateway with Lilygo S3 [#2550](https://github.com/emsesp/EMS-ESP32/issues/2550)
- fix missing long 10-second press of Button to perform a factory reset
- fix wwMaxPower on Junkers ZBS14 [#2609](https://github.com/emsesp/EMS-ESP32/issues/2609)
- ventilation bypass state from telegram 0x55C [#1197](https://github.com/emsesp/EMS-ESP32/issues/1197)
- set selflowtemp for ems+ boilers [#2641](https://github.com/emsesp/EMS-ESP32/discussions/2641)
- syslog timestamp [#2704](https://github.com/emsesp/EMS-ESP32/issues/2704)
- fixed FS format command [#2720](https://github.com/emsesp/EMS-ESP32/discussions/2720)
- dhw priority setting to boiler and mixer, telegrams 0x2CC, 0x2CD, etc.
- check for valid GPIOs when board profile is changed [#2841](https://github.com/emsesp/EMS-ESP32/issues/2841)
## Changed
- show console log with ISO date/time [#2533](https://github.com/emsesp/EMS-ESP32/discussions/2533)
- removed ESP32 CPU temperature
- updated core libraries like AsyncTCP, AsyncWebServer and Modbus
- remove command `scan deep`
- ignore repeated `forceheatingoff` commands [#2641](https://github.com/emsesp/EMS-ESP32/discussions/2641)
- optimized web for better performance by adding lazy loading and caching
- internal system analog sensors (core_voltage, supply_voltage and gateway_temperature) cannot be accidentally removed
- double click button reconnects EMS-ESP to AP
- place system message command in side scheduler loop to reduce stack memory usage by 2KB
- syslog mark interval set to 1 hour
- handle process_telegram in oneloop
- improved GPIO validation for Analog Sensors and System GPIOs
- entities with no values are greyed out in the Web UI in the Customization page
- added System Status to Web Status page
- show number on entities and supported languages in log on boot
- on tx read fail delay the 3rd. retry 2 sec
- move vectors and lists to PSRAM
- removed unused last topic/payload echo-check
- added Home Assistant device details to MQTT Discovery for all devices
- device_class and state_class changes for HA MQTT Discovery [#2825](https://github.com/emsesp/EMS-ESP32/issues/2825)
## [3.7.2] 22 March 2025
## Added
@@ -199,7 +85,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- The automatically generated temperature sensor ID has replaced dashes (`-`) with underscores (`_`) to be compatible with Home Assistant.
- `api/system/info` has it's JSON key names changed to camelCase syntax.
For more details go to [emsesp.org](https://emsesp.org/).
For more details go to [docs.emsesp.org](https://docs.emsesp.org/).
## Added
@@ -331,7 +217,7 @@ For more details go to [emsesp.org](https://emsesp.org/).
## **IMPORTANT! BREAKING CHANGES**
Writeable Text entities have moved from type `sensor` to `text` in Home Assistant to make them also editable within an HA dashboard. Examples are `datetime`, `holidays`, `switchtime`, `vacations`, `maintenancedate`... You will need to manually remove any old discovery topics from your MQTT broker using an application like MQTT Explorer.
Writeable Text entities have moved from type `sensor` to `text` in Home Assistant to make them also editable within an HA dashboard. Examples are `datetime`, `holidays`, `switchtime`, `vacations`, `maintenancedate`. You will need to manually remove any old discovery topics from your MQTT broker using an application like MQTT Explorer.
## Added

View File

@@ -1,33 +1 @@
# Changelog
For more details go to [emsesp.org](https://emsesp.org/).
## [3.8.2]
## Added
- comfortpoint for BC400 [#2935](https://github.com/emsesp/EMS-ESP32/issues/2935)
- customize device brand [#2784](https://github.com/emsesp/EMS-ESP32/issues/2784)
- set model for ems-esp devices temperature, analog, etc. [#2958](https://github.com/emsesp/EMS-ESP32/discussions/2958)
- prometheus metrics for temperature/analog/scheduler/custom [#2962](https://github.com/emsesp/EMS-ESP32/issues/2962)
- boiler pumpkick [#2965](https://github.com/emsesp/EMS-ESP32/discussions/2965)
- heatpump reset [#2933](https://github.com/emsesp/EMS-ESP32/issues/2933)
- e-mail notification using ReadyMail Client
- 2.nd freshwater module (dhw4) [#2991](https://github.com/emsesp/EMS-ESP32/issues/2991)
## Fixed
- SRC climate creation [#2936](https://github.com/emsesp/EMS-ESP32/issues/2936) and [#2960](https://github.com/emsesp/EMS-ESP32/issues/2960)
## Changed
- weblogbuffer up to 1000 messages with PSRAM, mentioned in [#2933](https://github.com/emsesp/EMS-ESP32/issues/2933)
- validate custom entity writes, [#2931](https://github.com/emsesp/EMS-ESP32/issues/2931)
- remove wrong burnMinPower [#2918](https://github.com/emsesp/EMS-ESP32/issues/2918)
- store scheduler active state to nvs [#2946](https://github.com/emsesp/EMS-ESP32/discussions/2946)
- translated modes `heat` and `eco` for HA-climate mode-str-tpl
- support `minflowtemp` and `baseflowtemp` [#2969](https://github.com/emsesp/EMS-ESP32/discussions/2969)
- update version if it is 00.00 in first read [#2981](https://github.com/emsesp/EMS-ESP32/issues/2981)
- device class for % values [#2980](https://github.com/emsesp/EMS-ESP32/issues/2980)
- use tasmota core 2026.03.30
- secure mqtt uses ESP_SSLClient

View File

@@ -6,7 +6,7 @@ Everybody is welcome and invited to contribute to the EMS-ESP Project by:
- providing Pull Requests (Features, Fixes, suggestions)
- testing new released features and report issues on your EMS equipment
- contributing to missing [documentation](https://emsesp.org)
- contributing to missing [documentation](https://docs.emsesp.org)
This document describes rules that are in effect for this repository, meant for handling issues by contributors in the issue tracker and PRs.
@@ -69,7 +69,7 @@ Format: `<type>(<scope>): <subject>`
## Example
```text
```
feat: add hat wobble
^--^ ^------------^
| |
@@ -96,7 +96,7 @@ References:
## Contributor License Agreement (CLA)
```text
```
By making a contribution to this project, I certify that:
(a) The contribution was created in whole or in part by me and I

View File

@@ -19,20 +19,17 @@ C = $(words $N)$(eval N := x $N)
ECHO = python3 $(I)/scripts/echo_progress.py --stepno=$C --nsteps=$T
endif
# Optimize parallel build configuration
# determine number of parallel compiles based on OS
UNAME_S := $(shell uname -s)
JOBS ?= 1
ifeq ($(UNAME_S),Linux)
EXTRA_CPPFLAGS = -D LINUX
JOBS := $(shell nproc)
JOBS ?= $(shell nproc)
endif
ifeq ($(UNAME_S),Darwin)
EXTRA_CPPFLAGS = -D OSX -Wno-tautological-constant-out-of-range-compare
JOBS := $(shell sysctl -n hw.ncpu)
JOBS ?= $(shell sysctl -n hw.ncpu)
endif
# Set optimal parallel build settings
MAKEFLAGS += -j$(JOBS) -l$(shell echo $$(($(JOBS) * 2)))
MAKEFLAGS += -j $(JOBS) -l $(JOBS)
# $(info Number of jobs: $(JOBS))
@@ -48,7 +45,7 @@ MAKEFLAGS += -j$(JOBS) -l$(shell echo $$(($(JOBS) * 2)))
TARGET := emsesp
BUILD := build
SOURCES := src/core src/devices src/web src/test lib_standalone lib/semver lib/espMqttClient/src lib/espMqttClient/src/* lib/ArduinoJson/src lib/uuid-common/src lib/uuid-console/src lib/uuid-log/src lib/PButton
INCLUDES := src/core src/devices src/web src/test lib_standalone lib/* lib/semver lib/espMqttClient/src lib/espMqttClient/src/Transport lib/ArduinoJson/src lib/uuid-common/src lib/uuid-console/src lib/uuid-log/src lib/uuid-telnet/src lib/uuid-syslog/src
INCLUDES := src/core src/devices src/web src/test lib/* lib_standalone lib/semver lib/espMqttClient/src lib/espMqttClient/src/Transport lib/ArduinoJson/src lib/uuid-common/src lib/uuid-console/src lib/uuid-log/src lib/uuid-telnet/src lib/uuid-syslog/src
LIBRARIES :=
CPPCHECK = cppcheck
@@ -65,10 +62,9 @@ CXX_STANDARD := -std=gnu++17
#----------------------------------------------------------------------
DEFINES += -DARDUINOJSON_ENABLE -DARDUINOJSON_ENABLE_ARDUINO_STRING -DARDUINOJSON_USE_DOUBLE=0
DEFINES += -DEMSESP_STANDALONE -DEMSESP_TEST -DEMSESP_DEBUG -DEMC_RX_BUFFER_SIZE=1500
DEFINES += -DNO_TLS_SUPPORT
DEFINES += $(ARGS)
DEFAULTS = -DEMSESP_DEFAULT_LOCALE=\"en\" -DEMSESP_DEFAULT_BOARD_PROFILE=\"S32S3\"
DEFAULTS = -DEMSESP_DEFAULT_LOCALE=\"en\" -DEMSESP_DEFAULT_TX_MODE=8 -DEMSESP_DEFAULT_VERSION=\"3.7.3-dev\" -DEMSESP_DEFAULT_BOARD_PROFILE=\"S32S3\"
#----------------------------------------------------------------------
# Sources & Files
@@ -76,25 +72,16 @@ DEFAULTS = -DEMSESP_DEFAULT_LOCALE=\"en\" -DEMSESP_DEFAULT_BOARD_PROFILE=\"S32S3
OUTPUT := $(CURDIR)/$(TARGET)
SYMBOLS := $(CURDIR)/$(BUILD)/$(TARGET).out
# Optimize source discovery - use shell find for better performance
CSOURCES := $(shell find $(SOURCES) -name "*.c" 2>/dev/null)
CXXSOURCES := $(shell find $(SOURCES) -name "*.cpp" 2>/dev/null)
# Exclude files not needed for standalone build, if they exist
CSOURCES := $(filter-out src/core/ModuleLibrary.c,$(CSOURCES))
CXXSOURCES := $(filter-out src/core/ModuleLibrary.cpp,$(CXXSOURCES))
CSOURCES := $(foreach dir,$(SOURCES),$(wildcard $(dir)/*.c))
CXXSOURCES := $(foreach dir,$(SOURCES),$(wildcard $(dir)/*.cpp))
OBJS := $(patsubst %,$(BUILD)/%.o,$(basename $(CSOURCES)) $(basename $(CXXSOURCES)) )
DEPS := $(patsubst %,$(BUILD)/%.d,$(basename $(CSOURCES)) $(basename $(CXXSOURCES)) )
# Optimize include path discovery
INCLUDE_DIRS := $(shell find $(INCLUDES) -type d 2>/dev/null)
LIBRARY_INCLUDES := $(shell find $(LIBRARIES) -name "include" -type d 2>/dev/null)
INCLUDE += $(addprefix -I,$(INCLUDE_DIRS) $(LIBRARY_INCLUDES))
INCLUDE += $(addprefix -I,$(foreach dir,$(INCLUDES), $(wildcard $(dir))))
INCLUDE += $(addprefix -I,$(foreach dir,$(LIBRARIES),$(wildcard $(dir)/include)))
# Optimize library path discovery
LIBRARY_DIRS := $(shell find $(LIBRARIES) -name "lib" -type d 2>/dev/null)
LDLIBS += $(addprefix -L,$(LIBRARY_DIRS))
LDLIBS += $(addprefix -L,$(foreach dir,$(LIBRARIES),$(wildcard $(dir)/lib)))
#----------------------------------------------------------------------
# Compiler & Linker
@@ -111,12 +98,13 @@ CXX := /usr/bin/g++
# LDFLAGS Linker Flags
#----------------------------------------------------------------------
CPPFLAGS += $(DEFINES) $(DEFAULTS) $(INCLUDE)
CPPFLAGS += -ggdb -g3 -MMD
CPPFLAGS += -flto=auto
CPPFLAGS += -Wall -Wextra -Werror -Wswitch-enum
CPPFLAGS += -Wno-unused-parameter -Wno-missing-braces -Wno-vla-cxx-extension
CPPFLAGS += -ffunction-sections -fdata-sections -fno-exceptions -fno-rtti -fno-threadsafe-statics
CPPFLAGS += -Os -DNDEBUG
CPPFLAGS += -ggdb -g3 -O3
CPPFLAGS += -MMD
CPPFLAGS += -flto=auto -fno-lto
CPPFLAGS += -Wall -Wextra -Werror
CPPFLAGS += -Wswitch-enum
CPPFLAGS += -Wno-unused-parameter
CPPFLAGS += -Wno-missing-braces
CPPFLAGS += $(EXTRA_CPPFLAGS)
@@ -137,13 +125,11 @@ else
LD := $(CXX)
endif
# Dependency file generation
DEPFLAGS += -MF $(BUILD)/$*.d -MT $@
#DEPFLAGS += -MF $(BUILD)/$*.d
LINK.o = $(LD) $(LDFLAGS) $(LDLIBS) $^ -o $@
COMPILE.c = $(CC) $(C_STANDARD) $(CFLAGS) $(DEPFLAGS) -c $< -o $@
COMPILE.cpp = $(CXX) $(CXX_STANDARD) $(CXXFLAGS) $(DEPFLAGS) -c $< -o $@
COMPILE.s = $(CC) $(CFLAGS) $(DEPFLAGS) -c $< -o $@
#----------------------------------------------------------------------
# Special Built-in Target
@@ -156,10 +142,7 @@ COMPILE.s = $(CC) $(CFLAGS) $(DEPFLAGS) -c $< -o $@
.SUFFIXES:
.INTERMEDIATE:
.PRECIOUS: $(OBJS) $(DEPS)
.PHONY: all clean help cppcheck run
# Enable second expansion for more flexible rules
.SECONDEXPANSION:
.PHONY: all clean help
#----------------------------------------------------------------------
# Targets
@@ -174,6 +157,7 @@ $(OUTPUT): $(OBJS)
@mkdir -p $(@D)
@$(ECHO) Linking $@
$(LINK.o)
$(SYMBOLS.out)
$(BUILD)/%.o: %.c
@mkdir -p $(@D)
@@ -187,7 +171,6 @@ $(BUILD)/%.o: %.cpp
$(BUILD)/%.o: %.s
@mkdir -p $(@D)
@$(ECHO) Compiling $@
@$(COMPILE.s)
cppcheck: $(SOURCES)
@@ -202,15 +185,8 @@ clean:
@$(RM) -rf $(BUILD) $(OUTPUT)
help:
@echo "Available targets:"
@echo " all - Build the project (default)"
@echo " run - Build and run the executable"
@echo " clean - Remove build artifacts"
@echo " cppcheck - Run static analysis"
@echo " help - Show this help message"
@echo ""
@echo "Output: $(OUTPUT)"
@echo "Jobs: $(JOBS)"
@echo available targets: all run clean
@echo $(OUTPUT)
-include $(DEPS)

View File

@@ -15,10 +15,10 @@
<a href="https://github.com/emsesp/EMS-ESP32/blob/dev/CONTRIBUTING.md">
<img src="https://img.shields.io/badge/Contribute-ff4785?style=for-the-badge&logo=git&logoColor=white" alt="Contribute" />
</a>
<a href="https://emsesp.org">
<a href="https://docs.emsesp.org">
<img src="https://img.shields.io/badge/Documentation-0077b5?style=for-the-badge&logo=googledocs&logoColor=white" alt="Guides" />
</a>
<a href="https://discord.gg/GP9DPSgeJq">
<a href="https://discord.gg/3J3GgnzpyT">
<img src="https://img.shields.io/badge/Discord-7289da?style=for-the-badge&logo=discord&logoColor=white" alt="Discord" />
</a>
<a href="https://github.com/emsesp/EMS-ESP32/blob/main/CHANGELOG.md">
@@ -32,17 +32,15 @@
[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=emsesp_EMS-ESP32&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=emsesp_EMS-ESP32)
[![Codacy Badge](https://app.codacy.com/project/badge/Grade/9441142f49424ef891e8f5251866ee6b)](https://app.codacy.com/gh/emsesp/EMS-ESP32/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade)
[![downloads](https://img.shields.io/github/downloads/emsesp/EMS-ESP32/total.svg)](https://github.com/emsesp/EMS-ESP32/releases)
[![chat](https://img.shields.io/discord/816637840644505620.svg?style=flat-square&color=blueviolet)](https://discord.gg/GP9DPSgeJq)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/emsesp/EMS-ESP32)
[![chat](https://img.shields.io/discord/816637840644505620.svg?style=flat-square&color=blueviolet)](https://discord.gg/3J3GgnzpyT)
[![GitHub stars](https://img.shields.io/github/stars/emsesp/EMS-ESP32.svg?style=social&label=Star)](https://github.com/emsesp/EMS-ESP32/stargazers)
[![GitHub forks](https://img.shields.io/github/forks/emsesp/EMS-ESP32.svg?style=social&label=Fork)](https://github.com/emsesp/EMS-ESP32/network)
[![GitHub forks](https://img.shields.io/github/forks/emsesp/EMS-ESP32.svg?style=social&label=Fork)](https://github.com/emsesp/EMS-ES32P/network)
[![donate](https://img.shields.io/badge/donate-PayPal-blue.svg)](https://www.paypal.com/paypalme/prderbyshire/2)
**EMS-ESP** is an open-source firmware for the Espressif ESP32 microcontroller to communicate with **EMS** (Energy Management System) compatible equipment from manufacturers such as Bosch, Buderus, Nefit, Junkers, Worcester, Sieger, elm.leblanc and iVT.
It requires a small circuit to interface with the EMS bus which can be purchased from <https://bbqkees-electronics.nl>. These gateways are tested thoroughly and certified to work with EMS-ESP.
It requires a small circuit to interface with the EMS bus which can be purchased from <https://bbqkees-electronics.nl> or custom built.
## 📦&nbsp; **Key Features**
@@ -62,39 +60,35 @@ It requires a small circuit to interface with the EMS bus which can be purchased
## 🚀&nbsp; **Installing**
Head over to the [Installation Guide](https://emsesp.org/Installing) section of the documentation for instructions on how to install EMS-ESP.
Head over to [download.emsesp.org](https://download.emsesp.org) for instructions on how to install EMS-ESP. There is also further details on which boards are supported in [this section](https://docs.emsesp.org/Getting-Started/#first-time-install) of the documentation.
## 📋&nbsp; **Documentation**
Visit [emsesp.org](https://emsesp.org) for more details on how to setup and configure EMS-ESP. You'll also find more a collection of example configuarations, Frequently Asked Questions and Troubleshooting tips.
Visit [emsesp.org](https://docs.emsesp.org) for more details on how to install and configure EMS-ESP. There is also a collection of Frequently Asked Questions and Troubleshooting tips with example customizations from the community.
## 💬&nbsp; **Getting Support**
To chat with the community reach out on our [Discord Server](https://discord.gg/GP9DPSgeJq).
To chat with the community reach out on our [Discord Server](https://discord.gg/3J3GgnzpyT).
If you find an issue or have a request, see the [Getting Support](https://emsesp.org/Support/) section of the documentation. Note if you are using a non-BBQKees EMS gateway, you may need to contact the manufacturer for support.
If you find an issue or have a request, see [here](https://docs.emsesp.org/Support/) on how to submit a bug report or feature request.
## 🎥&nbsp; **Live Demo**
To see a live demo go to [demo.emsesp.org](https://demo.emsesp.org). Pick a language and use any username and password to log in. Note whast you're seeing is static example data so not all features are operational.
For a live demo go to [demo.emsesp.org](https://demo.emsesp.org). Pick a language from the sign on page and log in with any username or password. Note not all features are operational as it's based on static data.
## 💖&nbsp; **Contributors**
EMS-ESP is a project originally created by [proddy](https://github.com/proddy) and maintained by the ems-esp community.
EMS-ESP is a project created by [proddy](https://github.com/proddy) and owned and maintained by both [proddy](https://github.com/proddy) and [MichaelDvP](https://github.com/MichaelDvP) with support from [BBQKees Electronics](https://bbqkees-electronics.nl).
If you like **EMS-ESP**, please give it a ✨ on GitHub, or even better fork it and contribute. You can also offer a small donation. This is an open-source project maintained by volunteers, and your support is greatly appreciated.
## 📦&nbsp; **Building**
See the [Building the firmware](https://emsesp.org/Building) guide in the documentation for instructions on how to build EMS-ESP from this source code.
## 📢&nbsp; **Libraries used**
- [esp8266-react](https://github.com/rjwats/esp8266-react) originally by @rjwats for the core framework that provides the Web UI, which has been heavily modified
- [esp8266-react](https://github.com/rjwats/esp8266-react) by @rjwats for the core framework that provides the Web UI, which has been heavily modified
- [uuid-\*](https://github.com/nomis/mcu-uuid-console) from @nomis. The console, syslog, telnet and logging are based off these awesome open source libraries
- [ArduinoJson](https://github.com/bblanchon/ArduinoJson) for all the JSON processing
- [espMqttClient](https://github.com/bertmelis/espMqttClient) for the MQTT client
- [ESPAsyncWebServer](https://github.com/ESP32Async/ESPAsyncWebServer) and [AsyncTCP](https://github.com/ESP32Async/AsyncTCP) for the Web server
- ESPAsyncWebServer and AsyncTCP for the Web server and TCP backends, with custom modifications for performance
## 📜&nbsp; **License**

View File

@@ -1,5 +0,0 @@
# Security Policy
## Reporting a Vulnerability
Please report any security vulnerabilities using the [Contact Form](https://emsesp.org/About/#-contact).

View File

@@ -5,7 +5,7 @@
},
"core": "esp32",
"extra_flags": [
"-DNO_TLS_SUPPORT",
"-DTASMOTA_SDK",
"-DARDUINO_LOLIN_C3_MINI",
"-DARDUINO_USB_MODE=1",
"-DARDUINO_USB_CDC_ON_BOOT=1"

View File

@@ -6,7 +6,7 @@
"core": "esp32",
"extra_flags": [
"-DBOARD_HAS_PSRAM",
"-DNO_TLS_SUPPORT",
"-DTASMOTA_SDK",
"-DARDUINO_USB_CDC_ON_BOOT=1",
"-DARDUINO_USB_MODE=0"
],
@@ -37,8 +37,8 @@
"flash_size": "4MB",
"maximum_ram_size": 327680,
"maximum_size": 4194304,
"use_1200bps_touch": false,
"wait_for_upload_port": false,
"use_1200bps_touch": true,
"wait_for_upload_port": true,
"require_upload_port": true,
"speed": 921600
},

View File

@@ -21,11 +21,11 @@
"arduino",
"espidf"
],
"name": "Tasmota ESP32-S3 32M Flash OPI PSRAM, 4608KB Code/OTA, 2MB FS",
"name": "Espressif ESP32-S3 32M Flash OPI PSRAM, 4608KB Code/OTA, 2MB FS",
"upload": {
"flash_size": "32MB",
"maximum_ram_size": 327680,
"maximum_size": 33554432,
"maximum_size": 16777216,
"require_upload_port": true,
"speed": 460800
},

View File

@@ -1,7 +1,7 @@
{
"build": {
"core": "esp32",
"extra_flags": "-DNO_TLS_SUPPORT",
"extra_flags": "-DTASMOTA_SDK",
"f_cpu": "240000000L",
"f_flash": "40000000L",
"flash_mode": "dio",
@@ -19,7 +19,7 @@
"arduino",
"espidf"
],
"name": "Tasmota ESP32 16M Flash, 4608KB Code/OTA, 2MB FS",
"name": "Espressif ESP32 16M Flash, 4608KB Code/OTA, 2MB FS",
"upload": {
"flash_size": "16MB",
"maximum_ram_size": 327680,

View File

@@ -19,7 +19,7 @@
"arduino",
"espidf"
],
"name": "Tasmota ESP32 16M Flash DIO PSRAM, 4608KB Code/OTA, 2MB FS",
"name": "Espressif ESP32 16M Flash DIO PSRAM, 4608KB Code/OTA, 2MB FS",
"upload": {
"flash_size": "16MB",
"maximum_ram_size": 327680,

View File

@@ -1,7 +1,7 @@
{
"build": {
"core": "esp32",
"extra_flags": "-DNO_TLS_SUPPORT",
"extra_flags": "-DTASMOTA_SDK",
"f_cpu": "240000000L",
"f_flash": "40000000L",
"flash_mode": "dio",

View File

@@ -1,49 +0,0 @@
{
"build": {
"core": "esp32",
"extra_flags": [
"-DNO_TLS_SUPPORT",
"-DARDUINO_XIAO_ESP32C6",
"-DARDUINO_USB_MODE=1",
"-DARDUINO_USB_CDC_ON_BOOT=1"
],
"f_cpu": "160000000L",
"f_flash": "80000000L",
"flash_mode": "qio",
"hwids": [
[
"0x2886",
"0x0046"
],
[
"0x303a",
"0x1001"
]
],
"mcu": "esp32c6",
"variant": "XIAO_ESP32C6"
},
"connectivity": [
"wifi",
"bluetooth",
"zigbee",
"thread"
],
"debug": {
"openocd_target": "esp32c6.cfg"
},
"frameworks": [
"arduino",
"espidf"
],
"name": "Seeed Studio XIAO ESP32C6",
"upload": {
"flash_size": "4MB",
"maximum_ram_size": 327680,
"maximum_size": 4194304,
"require_upload_port": true,
"speed": 460800
},
"url": "https://wiki.seeedstudio.com/XIAO_ESP32C6_Getting_Started/",
"vendor": "Seeed Studio"
}

View File

@@ -9,7 +9,6 @@
}
],
"dictionaries": ["project-words"],
"caseSensitive": false,
"ignorePaths": [
"node_modules",
"compile_commands.json",
@@ -33,12 +32,6 @@
"**/*.json",
"src/core/modbus_entity_parameters.hpp",
"sdkconfig.*",
"managed_components/**",
"pnpm-*.yaml",
"vite.config.ts",
"lib/esp32-psram/**",
"test/test_api/test_api.h",
"lib_standalone/**",
"lib/mbedtls_ssl/**"
"managed_components/**"
]
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -13,9 +13,9 @@ telegram_type_id,name,is_fetched
0x19,UBAMonitorSlow,
0x1A,UBASetPoints,
0x1C,UBAMaintenanceStatus,
0x1E,HydrTemp,
0x1E,WM10TempMessage,
0x23,JunkersSetMixer,fetched
0x27,UBASettingsWW,fetched
0x26,UBASettingsWW,fetched
0x28,WeatherComp,fetched
0x2A,MC110Status,
0x2E,Meters,
@@ -62,9 +62,7 @@ telegram_type_id,name,is_fetched
0xB1,RC10Monitor,
0xBB,HybridSettings,fetched
0xBF,ErrorMessage,
0xC0,RCErrorMessage,
0xC2,UBAErrorMessage3,
0xC6,UBAErrorMessage3,
0xD1,UBAOutdoorTemp,
0xE3,UBAMonitorSlowPlus2,
0xE4,UBAMonitorFastPlus,
@@ -72,7 +70,6 @@ telegram_type_id,name,is_fetched
0xE6,UBAParametersPlus,fetched
0xE9,UBAMonitorWWPlus,
0xEA,UBAParameterWWPlus,fetched
0xEB,PumpKick,fetched
0x0101,ISM1Set,fetched
0x0103,ISM1StatusMessage,fetched
0x0104,ISM2StatusMessage,
@@ -113,9 +110,9 @@ telegram_type_id,name,is_fetched
0x02A1,RC300Curves,
0x02A2,RC300Curves,
0x02A5,RC300Monitor,fetched
0x02A6,CRFMonitor,
0x02A7,RC300Monitor,
0x02A8,CRFMonitor,
0x02A6,RC300Monitor,
0x02A7,CRFMonitor,
0x02A8,RC300Monitor,
0x02A9,RC300Monitor,
0x02AA,RC300Monitor,
0x02AB,RC300Monitor,
@@ -137,10 +134,12 @@ telegram_type_id,name,is_fetched
0x02BF,RC300Set,
0x02C0,RC300Set,
0x02CC,HPPressure,fetched
0x02CD,MMPLUSConfigMessage,
0x02CD,MMPLUSConfigMessage,fetched
0x02CE,RC300Set2,
0x02D0,RC300Set2,
0x02D2,RC300Set2,
0x02D6,HPPump2,fetched
0x02D7,MMPLUSStatusMessage,
0x02E0,UBASetPoints,
0x02F5,RC300WWmode,fetched
0x02F6,RC300WW2mode,fetched
0x0313,MMPLUSConfigMessage_WWC,fetched
@@ -162,10 +161,6 @@ telegram_type_id,name,is_fetched
0x0380,SM100CollectorConfig,fetched
0x038E,SM100Energy,fetched
0x0391,SM100Time,fetched
0x0421,RC300Set2,
0x0422,RC300Set2,
0x0423,RC300Set2,
0x0424,RC300Set2,
0x043F,CRHolidays,fetched
0x0467,HPSet,
0x0468,HPSet,
@@ -198,10 +193,9 @@ telegram_type_id,name,is_fetched
0x04A2,HpInput,fetched
0x04A5,HPFan,fetched
0x04A7,HPPowerLimit,fetched
0x04AA,HPPower,
0x04AA,HPPower2,fetched
0x04AE,HPEnergy,fetched
0x04AF,HPMeters,fetched
0x055C,VentilationSet,fetched
0x056B,VentilationMode,fetched
0x0583,VentilationMonitor,
0x0585,Blowerspeed,
1 telegram_type_id name is_fetched
13 0x19 UBAMonitorSlow
14 0x1A UBASetPoints
15 0x1C UBAMaintenanceStatus
16 0x1E HydrTemp WM10TempMessage
17 0x23 JunkersSetMixer fetched
18 0x27 0x26 UBASettingsWW fetched
19 0x28 WeatherComp fetched
20 0x2A MC110Status
21 0x2E Meters
62 0xB1 RC10Monitor
63 0xBB HybridSettings fetched
64 0xBF ErrorMessage
0xC0 RCErrorMessage
65 0xC2 UBAErrorMessage3
0xC6 UBAErrorMessage3
66 0xD1 UBAOutdoorTemp
67 0xE3 UBAMonitorSlowPlus2
68 0xE4 UBAMonitorFastPlus
70 0xE6 UBAParametersPlus fetched
71 0xE9 UBAMonitorWWPlus
72 0xEA UBAParameterWWPlus fetched
0xEB PumpKick fetched
73 0x0101 ISM1Set fetched
74 0x0103 ISM1StatusMessage fetched
75 0x0104 ISM2StatusMessage
110 0x02A1 RC300Curves
111 0x02A2 RC300Curves
112 0x02A5 RC300Monitor fetched
113 0x02A6 CRFMonitor RC300Monitor
114 0x02A7 RC300Monitor CRFMonitor
115 0x02A8 CRFMonitor RC300Monitor
116 0x02A9 RC300Monitor
117 0x02AA RC300Monitor
118 0x02AB RC300Monitor
134 0x02BF RC300Set
135 0x02C0 RC300Set
136 0x02CC HPPressure fetched
137 0x02CD MMPLUSConfigMessage fetched
138 0x02CE RC300Set2
139 0x02D0 RC300Set2
140 0x02D2 RC300Set2
141 0x02D6 HPPump2 fetched
142 0x02D7 MMPLUSStatusMessage
0x02E0 UBASetPoints
143 0x02F5 RC300WWmode fetched
144 0x02F6 RC300WW2mode fetched
145 0x0313 MMPLUSConfigMessage_WWC fetched
161 0x0380 SM100CollectorConfig fetched
162 0x038E SM100Energy fetched
163 0x0391 SM100Time fetched
0x0421 RC300Set2
0x0422 RC300Set2
0x0423 RC300Set2
0x0424 RC300Set2
164 0x043F CRHolidays fetched
165 0x0467 HPSet
166 0x0468 HPSet
193 0x04A2 HpInput fetched
194 0x04A5 HPFan fetched
195 0x04A7 HPPowerLimit fetched
196 0x04AA HPPower HPPower2 fetched
197 0x04AE HPEnergy fetched
198 0x04AF HPMeters fetched
0x055C VentilationSet fetched
199 0x056B VentilationMode fetched
200 0x0583 VentilationMonitor
201 0x0585 Blowerspeed

4
interface/.gitattributes vendored Normal file
View File

@@ -0,0 +1,4 @@
/.yarn/** linguist-vendored
/.yarn/releases/* binary
/.yarn/plugins/**/* binary
/.pnp.* binary linguist-generated

View File

@@ -4,4 +4,5 @@ dist/
src/i18n/*
.prettierrc
.yarn/
.typesafe-i18n.json

View File

@@ -1,5 +1,5 @@
{
"adapter": "react",
"baseLocale": "pl",
"$schema": "https://unpkg.com/typesafe-i18n@5.27.1/schema/typesafe-i18n.json"
"$schema": "https://unpkg.com/typesafe-i18n@5.26.2/schema/typesafe-i18n.json"
}

935
interface/.yarn/releases/yarn-4.7.0.cjs vendored Executable file

File diff suppressed because one or more lines are too long

3
interface/.yarnrc.yml Normal file
View File

@@ -0,0 +1,3 @@
nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-4.7.0.cjs

View File

@@ -1,17 +1,17 @@
// @ts-check
import eslint from '@eslint/js';
import prettierConfig from 'eslint-config-prettier';
import { defineConfig } from 'eslint/config';
import tseslint from 'typescript-eslint';
export default defineConfig(
export default tseslint.config(
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
prettierConfig,
{
languageOptions: {
parserOptions: {
project: true
project: true,
tsconfigRootDir: import.meta.dirname
}
}
},

View File

@@ -1,6 +1,6 @@
{
"name": "EMS-ESP",
"version": "3.8.0",
"version": "3.7.2",
"description": "EMS-ESP WebUI",
"homepage": "https://emsesp.org",
"author": "proddy, emsesp.org",
@@ -8,63 +8,59 @@
"private": true,
"type": "module",
"scripts": {
"preinstall": "npx only-allow pnpm",
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"build-hosted": "typesafe-i18n --no-watch && vite build --mode hosted",
"mock-rest": "bun --watch ../mock-api/restServer.ts",
"preview-standalone": "typesafe-i18n --no-watch && vite build && concurrently -c \"auto\" \"pnpm:mock-rest\" \"vite preview\"",
"standalone": "concurrently -c \"auto\" \"typesafe-i18n\" \"pnpm:mock-rest\" \"vite dev\"",
"preview-standalone": "typesafe-i18n --no-watch && vite build && concurrently -c \"auto\" \"yarn:mock-rest\" \"vite preview\"",
"mock-rest": "bun --watch ../mock-api/rest_server.ts",
"standalone": "concurrently -c \"auto\" \"typesafe-i18n\" \"yarn:mock-rest\" \"vite\"",
"typesafe-i18n": "typesafe-i18n --no-watch",
"build_webUI": "typesafe-i18n --no-watch && vite build && node progmem-generator.js",
"webUI": "node progmem-generator.js",
"format": "prettier -l -w '**/*.{ts,tsx,js,css,json,md}'",
"lint": "eslint . --fix",
"standalone-devcontainer": "concurrently -c \"auto\" \"typesafe-i18n\" \"pnpm:mock-rest\" \"vite --host\""
"lint": "eslint . --fix"
},
"dependencies": {
"@alova/adapter-xhr": "2.3.1",
"@alova/adapter-xhr": "2.1.1",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.3.9",
"@mui/material": "^7.3.9",
"@preact/compat": "^18.3.2",
"@table-library/react-table-library": "4.1.15",
"alova": "^3.5.1",
"@emotion/styled": "^11.14.0",
"@mui/icons-material": "^6.4.8",
"@mui/material": "^6.4.8",
"@table-library/react-table-library": "4.1.12",
"alova": "3.2.10",
"async-validator": "^4.2.5",
"etag": "^1.8.1",
"formidable": "^3.5.4",
"jwt-decode": "^4.0.0",
"magic-string": "^0.30.21",
"mime-types": "^3.0.2",
"preact": "^10.29.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-icons": "^5.6.0",
"react-router": "^7.13.1",
"mime-types": "^2.1.35",
"preact": "^10.26.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-icons": "^5.5.0",
"react-router": "^7.4.0",
"react-toastify": "^11.0.5",
"typesafe-i18n": "^5.27.1",
"typescript": "^5.9.3"
"typesafe-i18n": "^5.26.2",
"typescript": "^5.8.2"
},
"devDependencies": {
"@babel/core": "^7.29.0",
"@eslint/js": "^10.0.1",
"@preact/compat": "^18.3.2",
"@preact/preset-vite": "^2.10.5",
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
"@types/node": "^25.5.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"axe-core": "^4.11.1",
"concurrently": "^9.2.1",
"eslint": "^10.1.0",
"eslint-config-prettier": "^10.1.8",
"prettier": "^3.8.1",
"rollup-plugin-visualizer": "^7.0.1",
"terser": "^5.46.1",
"typescript-eslint": "^8.57.1",
"vite": "^8.0.1",
"vite-plugin-imagemin": "^0.6.1"
"@babel/core": "^7.26.10",
"@eslint/js": "^9.23.0",
"@preact/compat": "^18.3.1",
"@preact/preset-vite": "^2.10.1",
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
"@types/formidable": "^3",
"@types/node": "^22.13.11",
"@types/react": "^19.0.12",
"@types/react-dom": "^19.0.4",
"concurrently": "^9.1.2",
"eslint": "^9.23.0",
"eslint-config-prettier": "^10.1.1",
"formidable": "^3.5.2",
"prettier": "^3.5.3",
"rollup-plugin-visualizer": "^5.14.0",
"terser": "^5.39.0",
"typescript-eslint": "8.27.0",
"vite": "^6.2.2",
"vite-plugin-imagemin": "^0.6.1",
"vite-tsconfig-paths": "^5.1.4"
},
"packageManager": "pnpm@10.32.1+sha512.a706938f0e89ac1456b6563eab4edf1d1faf3368d1191fc5c59790e96dc918e4456ab2e67d613de1043d2e8c81f87303e6b40d4ffeca9df15ef1ad567348f2be"
"packageManager": "yarn@4.7.0"
}

6569
interface/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +0,0 @@
onlyBuiltDependencies:
- cwebp-bin
- esbuild
- gifsicle
- jpegtran-bin
- mozjpeg
- optipng-bin
- pngquant-bin

View File

@@ -1,4 +1,4 @@
import etag from 'etag';
import crypto from 'crypto';
import {
createWriteStream,
existsSync,
@@ -15,79 +15,66 @@ const INDENT = ' ';
const outputPath = '../src/ESP32React/WWWData.h';
const sourcePath = './dist';
const bytesPerLine = 20;
let totalSize = 0;
let bundleStats = {
js: { count: 0, uncompressed: 0, compressed: 0 },
css: { count: 0, uncompressed: 0, compressed: 0 },
html: { count: 0, uncompressed: 0, compressed: 0 },
svg: { count: 0, uncompressed: 0, compressed: 0 },
other: { count: 0, uncompressed: 0, compressed: 0 }
};
var totalSize = 0;
const generateWWWClass =
() => `typedef std::function<void(const char * uri, const String & contentType, const uint8_t * content, size_t len, const String & hash)> RouteRegistrationHandler;
// Bundle Statistics:
// - Total compressed size: ${(totalSize / 1000).toFixed(1)} KB
// - Total uncompressed size: ${(Object.values(bundleStats).reduce((sum, stat) => sum + stat.uncompressed, 0) / 1000).toFixed(1)} KB
// - Compression ratio: ${(((Object.values(bundleStats).reduce((sum, stat) => sum + stat.uncompressed, 0) - totalSize) / Object.values(bundleStats).reduce((sum, stat) => sum + stat.uncompressed, 0)) * 100).toFixed(1)}%
// - Generated on: ${new Date().toISOString()}
const generateWWWClass = () =>
`typedef std::function<void(const char * uri, const String & contentType, const uint8_t * content, size_t len, const String & hash)> RouteRegistrationHandler;
// Total size is ${totalSize} bytes
class WWWData {
${INDENT}public:
${INDENT.repeat(2)}static void registerRoutes(RouteRegistrationHandler handler) {
${fileInfo.map((f) => `${INDENT.repeat(3)}handler("${f.uri}", "${f.mimeType}", ${f.variable}, ${f.size}, ${f.hash});`).join('\n')}
${INDENT.repeat(2)}}
${indent}public:
${indent.repeat(2)}static void registerRoutes(RouteRegistrationHandler handler) {
${fileInfo.map((file) => `${indent.repeat(3)}handler("${file.uri}", "${file.mimeType}", ${file.variable}, ${file.size}, "${file.hash}");`).join('\n')}
${indent.repeat(2)}}
};
`;
const getFilesSync = (dir, files = []) => {
function getFilesSync(dir, files = []) {
readdirSync(dir, { withFileTypes: true }).forEach((entry) => {
const entryPath = resolve(dir, entry.name);
entry.isDirectory() ? getFilesSync(entryPath, files) : files.push(entryPath);
if (entry.isDirectory()) {
getFilesSync(entryPath, files);
} else {
files.push(entryPath);
}
});
return files;
};
}
const cleanAndOpen = (path) => {
existsSync(path) && unlinkSync(path);
function cleanAndOpen(path) {
if (existsSync(path)) {
unlinkSync(path);
}
return createWriteStream(path, { flags: 'w+' });
};
const getFileType = (filePath) => {
const ext = filePath.split('.').pop().toLowerCase();
if (ext === 'js') return 'js';
if (ext === 'css') return 'css';
if (ext === 'html') return 'html';
if (ext === 'svg') return 'svg';
return 'other';
};
}
const writeFile = (relativeFilePath, buffer) => {
const variable = `ESP_REACT_DATA_${fileInfo.length}`;
const variable = 'ESP_REACT_DATA_' + fileInfo.length;
const mimeType = mime.lookup(relativeFilePath);
const fileType = getFileType(relativeFilePath);
let size = 0;
writeStream.write(`const uint8_t ${variable}[] = {`);
var size = 0;
writeStream.write('const uint8_t ' + variable + '[] = {');
// const zipBuffer = zlib.brotliCompressSync(buffer, { quality: 1 });
const zipBuffer = zlib.gzipSync(buffer, { level: 9 });
// const hash = crypto.createHash('sha256').update(zipBuffer).digest('hex');
const hash = etag(zipBuffer); // use smaller md5 instead of sha256
// create sha
const hashSum = crypto.createHash('sha256');
hashSum.update(zipBuffer);
const hash = hashSum.digest('hex');
zipBuffer.forEach((b) => {
if (!(size % bytesPerLine)) {
writeStream.write('\n' + INDENT);
writeStream.write('\n');
writeStream.write(indent);
}
writeStream.write('0x' + b.toString(16).toUpperCase().padStart(2, '0') + ',');
writeStream.write('0x' + ('00' + b.toString(16).toUpperCase()).slice(-2) + ',');
size++;
});
size % bytesPerLine && writeStream.write('\n');
writeStream.write('};\n\n');
if (size % bytesPerLine) {
writeStream.write('\n');
}
// Update bundle statistics
bundleStats[fileType].count++;
bundleStats[fileType].uncompressed += buffer.length;
bundleStats[fileType].compressed += zipBuffer.length;
writeStream.write('};\n\n');
fileInfo.push({
uri: '/' + relativeFilePath.replace(sep, '/'),
@@ -97,52 +84,32 @@ const writeFile = (relativeFilePath, buffer) => {
hash
});
// console.log(relativeFilePath + ' (size ' + size + ' bytes)');
totalSize += size;
};
console.log(`Generating ${outputPath} from ${sourcePath}`);
// start
console.log('Generating ' + outputPath + ' from ' + sourcePath);
const includes = ARDUINO_INCLUDES;
const indent = INDENT;
const fileInfo = [];
const writeStream = cleanAndOpen(resolve(outputPath));
writeStream.write(ARDUINO_INCLUDES);
// includes
writeStream.write(includes);
// process static files
const buildPath = resolve(sourcePath);
for (const filePath of getFilesSync(buildPath)) {
writeFile(relative(buildPath, filePath), readFileSync(filePath));
const readStream = readFileSync(filePath);
const relativeFilePath = relative(buildPath, filePath);
writeFile(relativeFilePath, readStream);
}
// add class
writeStream.write(generateWWWClass());
// end
writeStream.end();
// Calculate and display bundle statistics
const totalUncompressed = Object.values(bundleStats).reduce(
(sum, stat) => sum + stat.uncompressed,
0
);
const totalCompressed = Object.values(bundleStats).reduce(
(sum, stat) => sum + stat.compressed,
0
);
const compressionRatio = (
((totalUncompressed - totalCompressed) / totalUncompressed) *
100
).toFixed(1);
console.log('\n📊 Bundle Size Analysis:');
console.log('='.repeat(50));
console.log(`Total compressed size: ${(totalSize / 1000).toFixed(1)} KB`);
console.log(`Total uncompressed size: ${(totalUncompressed / 1000).toFixed(1)} KB`);
console.log(`Compression ratio: ${compressionRatio}%`);
console.log('\n📁 File Type Breakdown:');
Object.entries(bundleStats).forEach(([type, stats]) => {
if (stats.count > 0) {
const ratio = (
((stats.uncompressed - stats.compressed) / stats.uncompressed) *
100
).toFixed(1);
console.log(
`${type.toUpperCase().padEnd(4)}: ${stats.count} files, ${(stats.uncompressed / 1000).toFixed(1)} KB → ${(stats.compressed / 1000).toFixed(1)} KB (${ratio}% compression)`
);
}
});
console.log('='.repeat(50));
console.log('Total size: ' + totalSize / 1000 + ' KB');

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import { ToastContainer, Zoom } from 'react-toastify';
import AppRouting from 'AppRouting';
@@ -8,7 +8,7 @@ import type { Locales } from 'i18n/i18n-types';
import { loadLocaleAsync } from 'i18n/i18n-util.async';
import { detectLocale, navigatorDetector } from 'typesafe-i18n/detectors';
const AVAILABLE_LOCALES = [
const availableLocales = [
'de',
'en',
'it',
@@ -20,56 +20,47 @@ const AVAILABLE_LOCALES = [
'sv',
'tr',
'cz'
] as Locales[];
];
// Static toast configuration - no need to recreate on every render
const TOAST_CONTAINER_PROPS = {
position: 'bottom-left' as const,
autoClose: 3000,
hideProgressBar: false,
newestOnTop: false,
closeOnClick: true,
rtl: false,
pauseOnFocusLoss: true,
draggable: false,
pauseOnHover: false,
transition: Zoom,
closeButton: false,
theme: 'dark' as const,
toastStyle: {
border: '1px solid #177ac9',
width: 'fit-content'
}
};
const App = memo(() => {
const App = () => {
const [wasLoaded, setWasLoaded] = useState(false);
const [locale, setLocale] = useState<Locales>('en');
// Memoize locale initialization to prevent unnecessary re-runs
const initializeLocale = useCallback(async () => {
const browserLocale = detectLocale('en', AVAILABLE_LOCALES, navigatorDetector);
useEffect(() => {
// determine locale, take from session if set other default to browser language
const browserLocale = detectLocale('en', availableLocales, navigatorDetector);
const newLocale = (localStorage.getItem('lang') || browserLocale) as Locales;
localStorage.setItem('lang', newLocale);
setLocale(newLocale);
await loadLocaleAsync(newLocale);
setWasLoaded(true);
void loadLocaleAsync(newLocale).then(() => setWasLoaded(true));
}, []);
useEffect(() => {
void initializeLocale();
}, [initializeLocale]);
if (!wasLoaded) return null;
return (
<TypesafeI18n locale={locale}>
<CustomTheme>
<AppRouting />
<ToastContainer {...TOAST_CONTAINER_PROPS} />
<ToastContainer
position="bottom-left"
autoClose={3000}
hideProgressBar={false}
newestOnTop={false}
closeOnClick
rtl={false}
pauseOnFocusLoss
draggable={false}
pauseOnHover={false}
transition={Zoom}
closeButton={false}
theme="dark"
toastStyle={{
border: '1px solid #177ac9'
}}
/>
</CustomTheme>
</TypesafeI18n>
);
});
};
export default App;

View File

@@ -1,51 +1,32 @@
import { type FC, Suspense, lazy, memo, useContext, useEffect, useRef } from 'react';
import { useContext, useEffect } from 'react';
import { Navigate, Route, Routes } from 'react-router';
import { toast } from 'react-toastify';
import {
LoadingSpinner,
RequireAuthenticated,
RequireUnauthenticated
} from 'components';
import AuthenticatedRouting from 'AuthenticatedRouting';
import SignIn from 'SignIn';
import { RequireAuthenticated, RequireUnauthenticated } from 'components';
import { Authentication, AuthenticationContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react';
// Lazy load route components for better code splitting
const SignIn = lazy(() => import('SignIn'));
const AuthenticatedRouting = lazy(() => import('AuthenticatedRouting'));
interface SecurityRedirectProps {
readonly message: string;
readonly signOut?: boolean;
message: string;
signOut?: boolean;
}
const RootRedirect: FC<SecurityRedirectProps> = memo(
({ message, signOut = false }) => {
const { signOut: contextSignOut } = useContext(AuthenticationContext);
const hasShownToast = useRef(false);
const RootRedirect = ({ message, signOut }: SecurityRedirectProps) => {
const authenticationContext = useContext(AuthenticationContext);
useEffect(() => {
// Prevent duplicate toasts on strict mode or re-renders
if (!hasShownToast.current) {
hasShownToast.current = true;
if (signOut) {
contextSignOut(false);
}
signOut && authenticationContext.signOut(false);
toast.success(message);
}
// Only run once on mount - using ref to track execution
}, []);
}, [message, signOut, authenticationContext]);
return <Navigate to="/" />;
};
return <Navigate to="/" replace />;
}
);
const AppRouting: FC = memo(() => {
const AppRouting = () => {
const { LL } = useI18nContext();
return (
<Authentication>
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route
path="/unauthorized"
@@ -72,9 +53,8 @@ const AppRouting: FC = memo(() => {
}
/>
</Routes>
</Suspense>
</Authentication>
);
});
};
export default AppRouting;

View File

@@ -1,52 +1,46 @@
import { Suspense, lazy, memo, useContext } from 'react';
import { useContext } from 'react';
import { Navigate, Route, Routes } from 'react-router';
import { Layout, LoadingSpinner } from 'components';
import CustomEntities from 'app/main/CustomEntities';
import Customizations from 'app/main/Customizations';
import Dashboard from 'app/main/Dashboard';
import Devices from 'app/main/Devices';
import Help from 'app/main/Help';
import Modules from 'app/main/Modules';
import Scheduler from 'app/main/Scheduler';
import Sensors from 'app/main/Sensors';
import APSettings from 'app/settings/APSettings';
import ApplicationSettings from 'app/settings/ApplicationSettings';
import DownloadUpload from 'app/settings/DownloadUpload';
import MqttSettings from 'app/settings/MqttSettings';
import NTPSettings from 'app/settings/NTPSettings';
import Settings from 'app/settings/Settings';
import Network from 'app/settings/network/Network';
import Security from 'app/settings/security/Security';
import APStatus from 'app/status/APStatus';
import Activity from 'app/status/Activity';
import HardwareStatus from 'app/status/HardwareStatus';
import MqttStatus from 'app/status/MqttStatus';
import NTPStatus from 'app/status/NTPStatus';
import NetworkStatus from 'app/status/NetworkStatus';
import Status from 'app/status/Status';
import SystemLog from 'app/status/SystemLog';
import Version from 'app/status/Version';
import { Layout } from 'components';
import { AuthenticatedContext } from 'contexts/authentication';
// Lazy load all route components for better code splitting
const Dashboard = lazy(() => import('app/main/Dashboard'));
const Devices = lazy(() => import('app/main/Devices'));
const Sensors = lazy(() => import('app/main/Sensors'));
const Help = lazy(() => import('app/main/Help'));
const Customizations = lazy(() => import('app/main/Customizations'));
const Scheduler = lazy(() => import('app/main/Scheduler'));
const CustomEntities = lazy(() => import('app/main/CustomEntities'));
const Modules = lazy(() => import('app/main/Modules'));
const UserProfile = lazy(() => import('app/main/UserProfile'));
const Status = lazy(() => import('app/status/Status'));
const HardwareStatus = lazy(() => import('app/status/HardwareStatus'));
const Activity = lazy(() => import('app/status/Activity'));
const SystemLog = lazy(() => import('app/status/SystemLog'));
const MqttStatus = lazy(() => import('app/status/MqttStatus'));
const NTPStatus = lazy(() => import('app/status/NTPStatus'));
const APStatus = lazy(() => import('app/status/APStatus'));
const NetworkStatus = lazy(() => import('app/status/NetworkStatus'));
const Version = lazy(() => import('app/status/Version'));
const Settings = lazy(() => import('app/settings/Settings'));
const ApplicationSettings = lazy(() => import('app/settings/ApplicationSettings'));
const MqttSettings = lazy(() => import('app/settings/MqttSettings'));
const NTPSettings = lazy(() => import('app/settings/NTPSettings'));
const APSettings = lazy(() => import('app/settings/APSettings'));
const DownloadUpload = lazy(() => import('app/settings/DownloadUpload'));
const Network = lazy(() => import('app/settings/network/Network'));
const Security = lazy(() => import('app/settings/security/Security'));
const AuthenticatedRouting = memo(() => {
const AuthenticatedRouting = () => {
const { me } = useContext(AuthenticatedContext);
return (
<Layout>
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/dashboard/*" element={<Dashboard />} />
<Route path="/devices/*" element={<Devices />} />
<Route path="/sensors/*" element={<Sensors />} />
<Route path="/help/*" element={<Help />} />
<Route path="/user/*" element={<UserProfile />} />
<Route path="/status/*" element={<Status />} />
<Route path="/help/*" element={<Help />} />
<Route path="/*" element={<Navigate to="/" />} />
<Route path="/status/hardwarestatus/*" element={<HardwareStatus />} />
<Route path="/status/activity" element={<Activity />} />
<Route path="/status/log" element={<SystemLog />} />
@@ -59,10 +53,7 @@ const AuthenticatedRouting = memo(() => {
{me.admin && (
<>
<Route path="/settings" element={<Settings />} />
<Route
path="/settings/application"
element={<ApplicationSettings />}
/>
<Route path="/settings/application" element={<ApplicationSettings />} />
<Route path="/settings/mqtt" element={<MqttSettings />} />
<Route path="/settings/ntp" element={<NTPSettings />} />
<Route path="/settings/ap" element={<APSettings />} />
@@ -77,12 +68,9 @@ const AuthenticatedRouting = memo(() => {
<Route path="/customentities" element={<CustomEntities />} />
</>
)}
<Route path="/*" element={<Navigate to="/" />} />
</Routes>
</Suspense>
</Layout>
);
});
};
export default AuthenticatedRouting;

View File

@@ -1,12 +1,6 @@
import { memo } from 'react';
import type { FC } from 'react';
import {
CssBaseline,
ThemeProvider,
responsiveFontSizes,
tooltipClasses
} from '@mui/material';
import { CssBaseline, ThemeProvider, responsiveFontSizes } from '@mui/material';
import { createTheme } from '@mui/material/styles';
import type { RequiredChildrenProps } from 'utils';
@@ -16,9 +10,9 @@ export const dialogStyle = {
borderRadius: '8px',
borderColor: '#565656',
borderStyle: 'solid',
borderWidth: '2px'
borderWidth: '1px'
}
} as const;
};
const theme = responsiveFontSizes(
createTheme({
@@ -36,45 +30,15 @@ const theme = responsiveFontSizes(
text: {
disabled: '#eee' // white
}
},
components: {
MuiListItemText: {
styleOverrides: {
primary: {
fontSize: 14
},
secondary: {
color: '#9e9e9e' // grey[500]
}
}
},
MuiTooltip: {
defaultProps: {
placement: 'top',
arrow: true
},
styleOverrides: {
tooltip: {
padding: '4px 8px',
fontSize: 10,
color: 'rgba(0, 0, 0, 0.87)',
backgroundColor: '#4caf50', // MUI success.main default color
boxShadow: '0px 2px 8px rgba(0, 0, 0, 0.15)',
[`& .${tooltipClasses.arrow}`]: {
color: '#4caf50'
}
}
}
}
}
})
);
const CustomTheme: FC<RequiredChildrenProps> = memo(({ children }) => (
const CustomTheme: FC<RequiredChildrenProps> = ({ children }) => (
<ThemeProvider theme={theme}>
<CssBaseline />
{children}
</ThemeProvider>
));
);
export default CustomTheme;

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { useContext, useState } from 'react';
import { toast } from 'react-toastify';
import ForwardIcon from '@mui/icons-material/Forward';
@@ -19,7 +19,7 @@ import type { SignInRequest } from 'types';
import { onEnterCallback, updateValue } from 'utils';
import { SIGN_IN_REQUEST_VALIDATOR, validate } from 'validators';
const SignIn = memo(() => {
const SignIn = () => {
const authenticationContext = useContext(AuthenticationContext);
const { LL } = useI18nContext();
@@ -42,18 +42,9 @@ const SignIn = memo(() => {
}
});
// Memoize callback to prevent recreation on every render
const updateLoginRequestValue = useMemo(
() =>
updateValue((updater) =>
setSignInRequest(
updater as unknown as (prevState: SignInRequest) => SignInRequest
)
),
[]
);
const updateLoginRequestValue = updateValue(setSignInRequest);
const signIn = useCallback(async () => {
const signIn = async () => {
await callSignIn(signInRequest).catch((event: Error) => {
if (event.message === 'Unauthorized') {
toast.warning(LL.INVALID_LOGIN());
@@ -62,9 +53,9 @@ const SignIn = memo(() => {
}
setProcessing(false);
});
}, [callSignIn, signInRequest, LL]);
};
const validateAndSignIn = useCallback(async () => {
const validateAndSignIn = async () => {
setProcessing(true);
SIGN_IN_REQUEST_VALIDATOR.messages({
required: LL.IS_REQUIRED('%s')
@@ -76,19 +67,9 @@ const SignIn = memo(() => {
setFieldErrors(error as ValidateFieldsError);
setProcessing(false);
}
}, [signInRequest, signIn, LL]);
// Memoize callback to prevent recreation on every render
const submitOnEnter = useMemo(() => onEnterCallback(signIn), [signIn]);
// get rid of scrollbar
useEffect(() => {
const originalOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = originalOverflow;
};
}, []);
const submitOnEnter = onEnterCallback(signIn);
return (
<Box
@@ -111,27 +92,23 @@ const SignIn = memo(() => {
width: '100%'
})}
>
<Typography mb={1} variant="h4">
{PROJECT_NAME}
</Typography>
<Typography variant="h4">{PROJECT_NAME}</Typography>
<LanguageSelector />
<Box
mt={1}
display="flex"
flexDirection="column"
gap={1}
alignItems="center"
>
<Box display="flex" flexDirection="column" alignItems="center">
<ValidatedTextField
fieldErrors={fieldErrors || {}}
fieldErrors={fieldErrors}
disabled={processing}
sx={{
width: '32ch'
width: 240
}}
name="username"
label={LL.USERNAME(0)}
value={signInRequest.username}
onChange={updateLoginRequestValue}
margin="normal"
variant="outlined"
slotProps={{
input: {
autoCapitalize: 'none',
@@ -140,16 +117,17 @@ const SignIn = memo(() => {
}}
/>
<ValidatedPasswordField
fieldErrors={fieldErrors || {}}
fieldErrors={fieldErrors}
disabled={processing}
sx={{
width: '32ch'
width: 240
}}
name="password"
label={LL.PASSWORD()}
value={signInRequest.password}
onChange={updateLoginRequestValue}
onKeyDown={submitOnEnter}
variant="outlined"
/>
</Box>
@@ -166,6 +144,6 @@ const SignIn = memo(() => {
</Paper>
</Box>
);
});
};
export default SignIn;

View File

@@ -20,18 +20,19 @@ import type {
WriteTemperatureSensor
} from '../app/main/types';
const MSGPACK_CONFIG = { responseType: 'arraybuffer' as const };
// Dashboard
export const readDashboard = () =>
alovaInstance.Get<DashboardData>('/rest/dashboardData', MSGPACK_CONFIG);
alovaInstance.Get<DashboardData>('/rest/dashboardData', {
responseType: 'arraybuffer' // uses msgpack
});
// Devices
export const readCoreData = () => alovaInstance.Get<CoreData>('/rest/coreData');
export const readCoreData = () => alovaInstance.Get<CoreData>(`/rest/coreData`);
export const readDeviceData = (id: number) =>
alovaInstance.Get<DeviceData>('/rest/deviceData', {
// alovaInstance.Get<DeviceData>(`/rest/deviceData/${id}`, {
params: { id },
...MSGPACK_CONFIG
responseType: 'arraybuffer' // uses msgpack
});
export const writeDeviceValue = (data: { id: number; c: string; v: unknown }) =>
alovaInstance.Post('/rest/writeDeviceValue', data);
@@ -65,13 +66,12 @@ export const callAction = (action: Action) =>
// SettingsCustomization
export const readDeviceEntities = (id: number) =>
alovaInstance.Get<DeviceEntity[]>('/rest/deviceEntities', {
// alovaInstance.Get<DeviceEntity[]>(`/rest/deviceEntities/${id}`, {
alovaInstance.Get<DeviceEntity[]>(`/rest/deviceEntities`, {
params: { id },
...MSGPACK_CONFIG,
// @ts-expect-error - exactOptionalPropertyTypes compatibility issue
responseType: 'arraybuffer',
transform(data) {
const entities = data as DeviceEntity[];
return entities.map((de) => ({
return (data as DeviceEntity[]).map((de: DeviceEntity) => ({
...de,
o_m: de.m,
o_cn: de.cn,
@@ -92,10 +92,8 @@ export const writeDeviceName = (data: { id: number; name: string }) =>
// SettingsScheduler
export const readSchedule = () =>
alovaInstance.Get<ScheduleItem[]>('/rest/schedule', {
// @ts-expect-error - exactOptionalPropertyTypes compatibility issue
transform(data) {
const schedule = (data as Schedule).schedule;
return schedule.map((si) => ({
return (data as Schedule).schedule.map((si: ScheduleItem) => ({
...si,
o_id: si.id,
o_active: si.active,
@@ -115,8 +113,7 @@ export const writeSchedule = (data: Schedule) =>
export const readModules = () =>
alovaInstance.Get<ModuleItem[]>('/rest/modules', {
transform(data) {
const modules = (data as Modules).modules;
return modules.map((mi) => ({
return (data as Modules).modules.map((mi: ModuleItem) => ({
...mi,
o_enabled: mi.enabled,
o_license: mi.license
@@ -132,10 +129,8 @@ export const writeModules = (data: {
// CustomEntities
export const readCustomEntities = () =>
alovaInstance.Get<EntityItem[]>('/rest/customEntities', {
// @ts-expect-error - exactOptionalPropertyTypes compatibility issue
transform(data) {
const entities = (data as Entities).entities;
return entities.map((ei) => ({
return (data as Entities).entities.map((ei: EntityItem) => ({
...ei,
o_id: ei.id,
o_ram: ei.ram,
@@ -148,8 +143,7 @@ export const readCustomEntities = () =>
o_name: ei.name,
o_writeable: ei.writeable,
o_value: ei.value,
o_deleted: ei.deleted,
o_hide: ei.hide
o_deleted: ei.deleted
}));
}
});

View File

@@ -4,57 +4,55 @@ import ReactHook from 'alova/react';
import { unpack } from './unpack';
export const ACCESS_TOKEN = 'access_token' as const;
// Cached token to avoid repeated localStorage access
let cachedToken: string | null = null;
const getAccessToken = (): string | null => {
if (cachedToken === null) {
cachedToken = localStorage.getItem(ACCESS_TOKEN);
}
return cachedToken;
};
// Clear token cache when needed (e.g., on logout)
export const clearTokenCache = (): void => {
cachedToken = null;
};
const handleResponse = async (response: AlovaXHRResponse) => {
// Handle various HTTP status codes
if (response.status === 205) {
throw new Error('Reboot required');
}
if (response.status === 400) {
throw new Error('Request Failed');
}
if (response.status >= 400) {
throw new Error(response.statusText);
}
const data = (await response.data) as ArrayBuffer;
// Unpack MessagePack data if ArrayBuffer
if (data instanceof ArrayBuffer) {
return unpack(data) as ArrayBuffer;
}
return data;
};
export const ACCESS_TOKEN = 'access_token';
export const alovaInstance = createAlova({
statesHook: ReactHook,
// timeout: 3000, // 3 seconds before throwing a timeout error, default is 0 = none
cacheFor: null, // disable cache
// cacheFor: {
// GET: {
// mode: 'memory',
// expire: 60 * 10 * 1000 // 60 seconds in cache
// }
// },
requestAdapter: xhrRequestAdapter(),
beforeRequest(method) {
const token = getAccessToken();
if (token) {
method.config.headers.Authorization = `Bearer ${token}`;
if (localStorage.getItem(ACCESS_TOKEN)) {
method.config.headers.Authorization =
'Bearer ' + localStorage.getItem(ACCESS_TOKEN);
}
// for simulating very slow networks
// return new Promise((resolve) => {
// const random = 3000 + Math.random() * 2000;
// setTimeout(resolve, Math.floor(random));
// });
},
responded: {
onSuccess: handleResponse
onSuccess: async (response: AlovaXHRResponse) => {
// if (response.status === 202) {
// throw new Error('Wait'); // wifi scan in progress
// } else
if (response.status === 205) {
throw new Error('Reboot required');
} else if (response.status === 400) {
throw new Error('Request Failed');
} else if (response.status >= 400) {
throw new Error(response.statusText);
}
const data: ArrayBuffer = (await response.data) as ArrayBuffer;
if (response.data instanceof ArrayBuffer) {
return unpack(data) as ArrayBuffer;
}
return data;
}
// Interceptor for request failure. This interceptor will be entered when the request is wrong.
// http errors like 401 (unauthorized) are handled either in the methods or AuthenticatedRouting()
// onError: (error, method) => {
// alert(error.message);
// }
}
});

View File

@@ -2,14 +2,12 @@ import type { NetworkSettingsType, NetworkStatusType, WiFiNetworkList } from 'ty
import { alovaInstance } from './endpoints';
const LIST_NETWORKS_TIMEOUT = 20000; // 20 seconds
export const readNetworkStatus = () =>
alovaInstance.Get<NetworkStatusType>('/rest/networkStatus');
export const scanNetworks = () => alovaInstance.Get('/rest/scanNetworks');
export const listNetworks = () =>
alovaInstance.Get<WiFiNetworkList>('/rest/listNetworks', {
timeout: LIST_NETWORKS_TIMEOUT
timeout: 20000 // 20 seconds
});
export const readNetworkSettings = () =>
alovaInstance.Get<NetworkSettingsType>('/rest/networkSettings');

View File

@@ -6,7 +6,7 @@ export const readNTPStatus = () =>
alovaInstance.Get<NTPStatusType>('/rest/ntpStatus');
export const readNTPSettings = () =>
alovaInstance.Get<NTPSettingsType>('/rest/ntpSettings');
alovaInstance.Get<NTPSettingsType>('/rest/ntpSettings', {});
export const updateNTPSettings = (data: NTPSettingsType) =>
alovaInstance.Post<NTPSettingsType>('/rest/ntpSettings', data);

View File

@@ -8,7 +8,7 @@ export const readSystemStatus = () =>
// SystemLog
export const readLogSettings = () =>
alovaInstance.Get<LogSettings>('/rest/logSettings');
alovaInstance.Get<LogSettings>(`/rest/logSettings`);
export const updateLogSettings = (data: LogSettings) =>
alovaInstance.Post('/rest/logSettings', data);
export const fetchLogES = () => alovaInstance.Get('/es/log');
@@ -30,18 +30,16 @@ export const getDevVersion = () =>
cacheFor: 60 * 10 * 1000,
transform(response: { data: { name: string; published_at: string } }) {
return {
name: response.data.name.split(/\s+/).splice(-1)[0]?.substring(1) || '',
name: response.data.name.split(/\s+/).splice(-1)[0].substring(1),
published_at: response.data.published_at
};
}
});
const UPLOAD_TIMEOUT = 60000; // 1 minute
export const uploadFile = (file: File) => {
const formData = new FormData();
formData.append('file', file);
return alovaInstance.Post('/rest/uploadFile', formData, {
timeout: UPLOAD_TIMEOUT
timeout: 60000 // override timeout for uploading firmware - 1 minute
});
};

View File

@@ -1,37 +1,40 @@
// @ts-nocheck - Optimized MessagePack unpacking library for EMS-ESP32
let decoder,
src,
srcEnd,
position = 0,
strings = [],
stringPosition = 0,
currentUnpackr = {},
currentStructures,
srcString,
srcStringStart = 0,
srcStringEnd = 0,
bundledStrings,
referenceMap,
dataView;
const EMPTY_ARRAY = [],
currentExtensions = [];
const defaultOptions = { useRecords: false, mapsAsObjects: true };
let decoder;
try {
decoder = new TextDecoder();
} catch (error) {}
class C1Type {}
const C1 = new C1Type();
let src;
let srcEnd;
let position = 0;
const EMPTY_ARRAY = [];
let strings = EMPTY_ARRAY;
let stringPosition = 0;
let currentUnpackr = {};
let currentStructures;
let srcString;
let srcStringStart = 0;
let srcStringEnd = 0;
let bundledStrings;
let referenceMap;
const currentExtensions = [];
let dataView;
const defaultOptions = {
useRecords: false,
mapsAsObjects: true
};
export class C1Type {}
export const C1 = new C1Type();
C1.name = 'MessagePack 0xC1';
let sequentialMode = false,
inlineObjectReadThreshold = 2,
readStruct,
onLoadedStructures,
onSaveState;
let sequentialMode = false;
let inlineObjectReadThreshold = 2;
let readStruct, onLoadedStructures, onSaveState;
// no-eval build
try {
new Function('');
} catch (error) {
// if eval variants are not supported, do not create inline object readers ever
inlineObjectReadThreshold = Infinity;
}
export class Unpackr {
constructor(options) {
if (options) {
@@ -47,15 +50,19 @@ export class Unpackr {
if (options.structures)
options.structures.sharedLength = options.structures.length;
else if (options.getStructures) {
(options.structures = []).uninitialized = true;
(options.structures = []).uninitialized = true; // this is what we use to denote an uninitialized structures
options.structures.sharedLength = 0;
}
if (options.int64AsNumber) options.int64AsType = 'number';
if (options.int64AsNumber) {
options.int64AsType = 'number';
}
}
Object.assign(this, options);
}
unpack(source, options?: { start?: number; end?: number; lazy?: boolean }) {
unpack(source, options?: any) {
if (src) {
// re-entrant execution, save the state and restore it after we do this unpack
return saveState(() => {
clearSource();
return this
@@ -79,6 +86,9 @@ export class Unpackr {
strings = EMPTY_ARRAY;
bundledStrings = null;
src = source;
// this provides cached access to the data view for a buffer if it is getting reused, which is a recommend
// technique for getting data from a database where it can be copied into an existing buffer instead of creating
// new ones
try {
dataView =
source.dataView ||
@@ -181,10 +191,10 @@ export class Unpackr {
return this.unpack(source, end);
}
}
function getPosition() {
export function getPosition() {
return position;
}
function checkedRead(options?: { lazy?: boolean }) {
export function checkedRead(options: any) {
try {
if (!currentUnpackr.trusted && !sequentialMode) {
const sharedLength = currentStructures.sharedLength || 0;
@@ -254,7 +264,7 @@ function restoreStructures() {
currentStructures.restoreStructures = null;
}
function read() {
export function read() {
let token = src[position++];
if (token < 0xa0) {
if (token < 0x80) {
@@ -579,7 +589,7 @@ const createSecondByteReader = (firstId, read0) =>
return structure.read();
};
function loadStructures() {
export function loadStructures() {
const loadedStructures = saveState(() => {
// save the state in case getStructures modifies our buffer
src = null;
@@ -595,8 +605,9 @@ var readFixedString = readStringJS;
var readString8 = readStringJS;
var readString16 = readStringJS;
var readString32 = readStringJS;
let isNativeAccelerationEnabled = false;
function setExtractor(extractStrings) {
export let isNativeAccelerationEnabled = false;
export function setExtractor(extractStrings) {
isNativeAccelerationEnabled = true;
readFixedString = readString(1);
readString8 = readString(2);
@@ -690,7 +701,7 @@ function readStringJS(length) {
return result;
}
function readString(source, start, length) {
export function readString(source, start, length) {
const existingSrc = src;
src = source;
position = start;
@@ -1054,7 +1065,7 @@ currentExtensions[0x70] = (data) => {
currentExtensions[0x73] = () => new Set(read());
const typedArrays = [
export const typedArrays = [
'Int8',
'Uint8',
'Uint8Clamped',
@@ -1166,20 +1177,44 @@ function saveState(callback) {
dataView = new DataView(src.buffer, src.byteOffset, src.byteLength);
return value;
}
function clearSource() {
export function clearSource() {
src = null;
referenceMap = null;
currentStructures = null;
}
function addExtension(extension) {
export function addExtension(extension) {
if (extension.unpack) currentExtensions[extension.type] = extension.unpack;
else currentExtensions[extension.type] = extension;
}
const mult10 = new Array(147);
export const mult10 = new Array(147); // this is a table matching binary exponents to the multiplier to determine significant digit rounding
for (let i = 0; i < 256; i++) {
mult10[i] = +('1e' + Math.floor(45.15 - i * 0.30103));
}
const defaultUnpackr = new Unpackr({ useRecords: false });
export const Decoder = Unpackr;
var defaultUnpackr = new Unpackr({ useRecords: false });
export const unpack = defaultUnpackr.unpack;
export const unpackMultiple = defaultUnpackr.unpackMultiple;
export const decode = defaultUnpackr.unpack;
export const FLOAT32_OPTIONS = {
NEVER: 0,
ALWAYS: 1,
DECIMAL_ROUND: 3,
DECIMAL_FIT: 4
};
const f32Array = new Float32Array(1);
const u8Array = new Uint8Array(f32Array.buffer, 0, 4);
export function roundFloat32(float32Number) {
f32Array[0] = float32Number;
const multiplier = mult10[((u8Array[3] & 0x7f) << 1) | (u8Array[2] >> 7)];
return (
((multiplier * float32Number + (float32Number > 0 ? 0.5 : -0.5)) >> 0) /
multiplier
);
}
export function setReadStruct(updatedReadStruct, loadedStructs, saveState) {
readStruct = updatedReadStruct;
onLoadedStructures = loadedStructs;
onSaveState = saveState;
}

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from 'react';
import { useCallback, useState } from 'react';
import { useBlocker } from 'react-router';
import { toast } from 'react-toastify';
@@ -35,10 +35,6 @@ import { DeviceValueTypeNames, DeviceValueUOM_s } from './types';
import type { Entities, EntityItem } from './types';
import { entityItemValidation } from './validators';
const MIN_ID = -100;
const MAX_ID = 100;
const ICON_SIZE = 12;
const CustomEntities = () => {
const { LL } = useI18nContext();
const [numChanges, setNumChanges] = useState<number>(0);
@@ -57,20 +53,18 @@ const CustomEntities = () => {
initialData: []
});
const intervalCallback = useCallback(() => {
useInterval(() => {
if (!dialogOpen && !numChanges) {
void fetchEntities();
}
}, [dialogOpen, numChanges, fetchEntities]);
useInterval(intervalCallback);
});
const { send: writeEntities } = useRequest(
(data: Entities) => writeCustomEntities(data),
{ immediate: false }
);
const hasEntityChanged = useCallback((ei: EntityItem) => {
function hasEntityChanged(ei: EntityItem) {
return (
ei.id !== ei.o_id ||
ei.ram !== ei.o_ram ||
@@ -82,15 +76,12 @@ const CustomEntities = () => {
ei.factor !== ei.o_factor ||
ei.value_type !== ei.o_value_type ||
ei.writeable !== ei.o_writeable ||
ei.hide !== ei.o_hide ||
ei.deleted !== ei.o_deleted ||
(ei.value || '') !== (ei.o_value || '')
);
}, []);
}
const entity_theme = useMemo(
() =>
useTheme({
const entity_theme = useTheme({
Table: `
--data-table-library_grid-template-columns: repeat(1, minmax(60px, 1fr)) minmax(80px, auto) 80px 80px 80px 120px;
`,
@@ -140,15 +131,13 @@ const CustomEntities = () => {
background-color: #177ac9;
}
`
}),
[]
);
});
const saveEntities = useCallback(async () => {
const saveEntities = async () => {
await writeEntities({
entities: entities
.filter((ei: EntityItem) => !ei.deleted)
.map((condensed_ei: EntityItem) => ({
.filter((ei) => !ei.deleted)
.map((condensed_ei) => ({
id: condensed_ei.id,
ram: condensed_ei.ram,
name: condensed_ei.name,
@@ -158,7 +147,6 @@ const CustomEntities = () => {
factor: condensed_ei.factor,
uom: condensed_ei.uom,
writeable: condensed_ei.writeable,
hide: condensed_ei.hide,
value_type: condensed_ei.value_type,
value: condensed_ei.value
}))
@@ -173,7 +161,7 @@ const CustomEntities = () => {
await fetchEntities();
setNumChanges(0);
});
}, [entities, writeEntities, LL, fetchEntities]);
};
const editEntityItem = useCallback((ei: EntityItem) => {
setCreating(false);
@@ -181,18 +169,17 @@ const CustomEntities = () => {
setDialogOpen(true);
}, []);
const onDialogClose = useCallback(() => {
const onDialogClose = () => {
setDialogOpen(false);
}, []);
};
const onDialogCancel = useCallback(async () => {
const onDialogCancel = async () => {
await fetchEntities().then(() => {
setNumChanges(0);
});
}, [fetchEntities]);
};
const onDialogSave = useCallback(
(updatedItem: EntityItem) => {
const onDialogSave = (updatedItem: EntityItem) => {
setDialogOpen(false);
void updateState(readCustomEntities(), (data: EntityItem[]) => {
const new_data = creating
@@ -206,14 +193,12 @@ const CustomEntities = () => {
setNumChanges(new_data.filter((ei) => hasEntityChanged(ei)).length);
return new_data;
});
},
[creating, hasEntityChanged]
);
};
const onDialogDup = useCallback((item: EntityItem) => {
const onDialogDup = (item: EntityItem) => {
setCreating(true);
setSelectedEntityItem({
id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
id: Math.floor(Math.random() * (Math.floor(200) - 100) + 100),
name: item.name + '_',
ram: item.ram,
device_id: item.device_id,
@@ -224,16 +209,15 @@ const CustomEntities = () => {
value_type: item.value_type,
writeable: item.writeable,
deleted: false,
hide: item.hide,
value: item.value
});
setDialogOpen(true);
}, []);
};
const addEntityItem = useCallback(() => {
const addEntityItem = () => {
setCreating(true);
setSelectedEntityItem({
id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
id: Math.floor(Math.random() * (Math.floor(200) - 100) + 100),
name: '',
ram: 0,
device_id: '0',
@@ -244,44 +228,35 @@ const CustomEntities = () => {
value_type: 0,
writeable: false,
deleted: false,
hide: false,
value: ''
});
setDialogOpen(true);
}, []);
};
const formatValue = useCallback((value: unknown, uom: number) => {
function formatValue(value: unknown, uom: number) {
return value === undefined
? ''
: typeof value === 'number'
? new Intl.NumberFormat().format(value) +
(uom === 0 ? '' : ` ${DeviceValueUOM_s[uom]}`)
: `${value as string}${uom === 0 ? '' : ` ${DeviceValueUOM_s[uom]}`}`;
}, []);
(uom === 0 ? '' : ' ' + DeviceValueUOM_s[uom])
: (value as string) + (uom === 0 ? '' : ' ' + DeviceValueUOM_s[uom]);
}
const showHex = useCallback((value: number, digit: number) => {
return `0x${value.toString(16).toUpperCase().padStart(digit, '0')}`;
}, []);
function showHex(value: number, digit: number) {
return '0x' + value.toString(16).toUpperCase().padStart(digit, '0');
}
const filteredAndSortedEntities = useMemo(
() =>
entities
?.filter((ei: EntityItem) => !ei.deleted)
.sort((a: EntityItem, b: EntityItem) => a.name.localeCompare(b.name)) ?? [],
[entities]
);
const renderEntity = useCallback(() => {
const renderEntity = () => {
if (!entities) {
return (
<FormLoader onRetry={fetchEntities} errorMessage={error?.message || ''} />
);
return <FormLoader onRetry={fetchEntities} errorMessage={error?.message} />;
}
return (
<Table
data={{
nodes: filteredAndSortedEntities
nodes: entities
.filter((ei) => !ei.deleted)
.sort((a, b) => a.name.localeCompare(b.name))
}}
theme={entity_theme}
layout={{ custom: true }}
@@ -304,21 +279,16 @@ const CustomEntities = () => {
<Cell>
{ei.name}&nbsp;
{ei.writeable && (
<EditOutlinedIcon
color="primary"
sx={{ fontSize: ICON_SIZE }}
/>
<EditOutlinedIcon color="primary" sx={{ fontSize: 12 }} />
)}
</Cell>
<Cell>{ei.ram > 0 ? '' : showHex(ei.device_id as number, 2)}</Cell>
<Cell>{ei.ram > 0 ? '' : showHex(ei.type_id as number, 3)}</Cell>
<Cell>{ei.ram > 0 ? '' : ei.offset}</Cell>
<Cell>
{ei.ram === 1
? 'RAM'
: ei.ram === 2
? 'NVS'
: DeviceValueTypeNames[ei.value_type]}
{ei.ram === 1 ? '' : showHex(ei.device_id as number, 2)}
</Cell>
<Cell>{ei.ram === 1 ? '' : showHex(ei.type_id as number, 3)}</Cell>
<Cell>{ei.ram === 1 ? '' : ei.offset}</Cell>
<Cell>
{ei.ram === 1 ? 'RAM' : DeviceValueTypeNames[ei.value_type]}
</Cell>
<Cell>{formatValue(ei.value, ei.uom)}</Cell>
</Row>
@@ -328,17 +298,7 @@ const CustomEntities = () => {
)}
</Table>
);
}, [
entities,
error,
fetchEntities,
entity_theme,
editEntityItem,
LL,
filteredAndSortedEntities,
showHex,
formatValue
]);
};
return (
<SectionContent>
@@ -361,7 +321,7 @@ const CustomEntities = () => {
/>
)}
<Box mt={2} display="flex" flexWrap="wrap">
<Box mt={1} display="flex" flexWrap="wrap">
<Box flexGrow={1}>
{numChanges > 0 && (
<ButtonRow>

View File

@@ -1,12 +1,8 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useEffect, useState } from 'react';
import AddIcon from '@mui/icons-material/Add';
import CancelIcon from '@mui/icons-material/Cancel';
import CommentsDisabledOutlinedIcon from '@mui/icons-material/CommentsDisabledOutlined';
import DoneIcon from '@mui/icons-material/Done';
import EditOffOutlinedIcon from '@mui/icons-material/EditOffOutlined';
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
import InsertCommentOutlinedIcon from '@mui/icons-material/InsertCommentOutlined';
import RemoveIcon from '@mui/icons-material/RemoveCircleOutline';
import {
Box,
@@ -16,7 +12,7 @@ import {
DialogActions,
DialogContent,
DialogTitle,
Grid,
Grid2 as Grid,
InputAdornment,
MenuItem,
TextField
@@ -33,19 +29,6 @@ import { validate } from 'validators';
import { DeviceValueType, DeviceValueTypeNames, DeviceValueUOM_s } from './types';
import type { EntityItem } from './types';
// Constant value type options for the dropdown
const VALUE_TYPE_OPTIONS = [
DeviceValueType.BOOL,
DeviceValueType.INT8,
DeviceValueType.UINT8,
DeviceValueType.INT16,
DeviceValueType.UINT16,
DeviceValueType.UINT24,
DeviceValueType.TIME,
DeviceValueType.UINT32,
DeviceValueType.STRING
] as const;
interface CustomEntitiesDialogProps {
open: boolean;
creating: boolean;
@@ -68,97 +51,61 @@ const CustomEntitiesDialog = ({
const { LL } = useI18nContext();
const [editItem, setEditItem] = useState<EntityItem>(selectedItem);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const updateFormValue = useMemo(
() =>
updateValue(
setEditItem as unknown as React.Dispatch<
React.SetStateAction<Record<string, unknown>>
>
),
[]
);
const updateFormValue = updateValue(setEditItem);
useEffect(() => {
if (open) {
setFieldErrors(undefined);
// Convert to hex strings - combined into single setEditItem call
const deviceIdHex =
typeof selectedItem.device_id === 'number'
? selectedItem.device_id.toString(16).toUpperCase()
: selectedItem.device_id;
const typeIdHex =
typeof selectedItem.type_id === 'number'
? selectedItem.type_id.toString(16).toUpperCase()
: selectedItem.type_id;
const factorValue =
selectedItem.value_type === DeviceValueType.BOOL &&
typeof selectedItem.factor === 'number'
? selectedItem.factor.toString(16).toUpperCase()
: selectedItem.factor;
setEditItem(selectedItem);
// convert to hex strings straight away
setEditItem({
...selectedItem,
device_id: deviceIdHex,
type_id: typeIdHex,
factor: factorValue
device_id: selectedItem.device_id.toString(16).toUpperCase(),
type_id: selectedItem.type_id.toString(16).toUpperCase(),
factor:
selectedItem.value_type === DeviceValueType.BOOL
? selectedItem.factor.toString(16).toUpperCase()
: selectedItem.factor
});
}
}, [open, selectedItem]);
const handleClose = useCallback(
(_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => {
const handleClose = (_event, reason: 'backdropClick' | 'escapeKeyDown') => {
if (reason !== 'backdropClick') {
onClose();
}
},
[onClose]
);
};
const save = useCallback(async () => {
const save = async () => {
try {
setFieldErrors(undefined);
await validate(validator, editItem);
// Create a copy to avoid mutating the state directly
const processedItem: EntityItem = { ...editItem };
if (typeof processedItem.device_id === 'string') {
processedItem.device_id = Number.parseInt(processedItem.device_id, 16);
if (typeof editItem.device_id === 'string') {
editItem.device_id = parseInt(editItem.device_id, 16);
}
if (typeof processedItem.type_id === 'string') {
processedItem.type_id = Number.parseInt(processedItem.type_id, 16);
if (typeof editItem.type_id === 'string') {
editItem.type_id = parseInt(editItem.type_id, 16);
}
if (
processedItem.value_type === DeviceValueType.BOOL &&
typeof processedItem.factor === 'string'
editItem.value_type === DeviceValueType.BOOL &&
typeof editItem.factor === 'string'
) {
processedItem.factor = Number.parseInt(processedItem.factor, 16);
editItem.factor = parseInt(editItem.factor, 16);
}
onSave(processedItem);
onSave(editItem);
} catch (error) {
setFieldErrors(error as ValidateFieldsError);
}
}, [validator, editItem, onSave]);
};
const remove = useCallback(() => {
const itemWithDeleted = { ...editItem, deleted: true };
onSave(itemWithDeleted);
}, [editItem, onSave]);
const remove = () => {
editItem.deleted = true;
onSave(editItem);
};
const dup = useCallback(() => {
const dup = () => {
onDup(editItem);
}, [editItem, onDup]);
// Memoize UOM menu items to avoid recreating on every render
const uomMenuItems = useMemo(
() =>
DeviceValueUOM_s.map((val, i) => (
<MenuItem key={val} value={i}>
{val}
</MenuItem>
)),
[]
);
};
return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
@@ -166,10 +113,13 @@ const CustomEntitiesDialog = ({
{creating ? LL.ADD(1) + ' ' + LL.NEW(1) : LL.EDIT()}&nbsp;{LL.ENTITY()}
</DialogTitle>
<DialogContent dividers>
<Box display="flex" flexWrap="wrap" mb={1}>
<Box flexWrap="nowrap" whiteSpace="nowrap" />
</Box>
<Grid container spacing={2} rowSpacing={0}>
<Grid size={12}>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
fieldErrors={fieldErrors}
name="name"
label={LL.NAME(0)}
value={editItem.name}
@@ -178,20 +128,6 @@ const CustomEntitiesDialog = ({
onChange={updateFormValue}
/>
</Grid>
<Grid mt={3}>
<BlockFormControlLabel
control={
<Checkbox
icon={<InsertCommentOutlinedIcon htmlColor="white" />}
checkedIcon={<CommentsDisabledOutlinedIcon color="primary" />}
checked={editItem.hide}
onChange={updateFormValue}
name="hide"
/>
}
label="API/MQTT"
/>
</Grid>
<Grid>
<TextField
name="ram"
@@ -205,10 +141,9 @@ const CustomEntitiesDialog = ({
>
<MenuItem value={0}>EMS-{LL.VALUE(1)}</MenuItem>
<MenuItem value={1}>RAM-{LL.VALUE(1)}</MenuItem>
<MenuItem value={2}>NVS-{LL.VALUE(1)}</MenuItem>
</TextField>
</Grid>
{editItem.ram > 0 && (
{editItem.ram === 1 && (
<>
<Grid>
<TextField
@@ -231,19 +166,21 @@ const CustomEntitiesDialog = ({
onChange={updateFormValue}
select
>
{uomMenuItems}
{DeviceValueUOM_s.map((val, i) => (
<MenuItem key={val} value={i}>
{val}
</MenuItem>
))}
</TextField>
</Grid>
</>
)}
{editItem.ram === 0 && (
<>
<Grid mt={3}>
<Grid mt={3} size={9}>
<BlockFormControlLabel
control={
<Checkbox
icon={<EditOffOutlinedIcon color="primary" />}
checkedIcon={<EditOutlinedIcon htmlColor="white" />}
checked={editItem.writeable}
onChange={updateFormValue}
name="writeable"
@@ -254,7 +191,7 @@ const CustomEntitiesDialog = ({
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
fieldErrors={fieldErrors}
name="device_id"
label={LL.ID_OF(LL.DEVICE())}
margin="normal"
@@ -274,7 +211,7 @@ const CustomEntitiesDialog = ({
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
fieldErrors={fieldErrors}
name="type_id"
label={LL.ID_OF(LL.TYPE(1))}
margin="normal"
@@ -294,7 +231,7 @@ const CustomEntitiesDialog = ({
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
fieldErrors={fieldErrors}
name="offset"
label={LL.OFFSET()}
margin="normal"
@@ -315,11 +252,33 @@ const CustomEntitiesDialog = ({
margin="normal"
select
>
{VALUE_TYPE_OPTIONS.map((valueType) => (
<MenuItem key={valueType} value={valueType}>
{DeviceValueTypeNames[valueType]}
<MenuItem value={DeviceValueType.BOOL}>
{DeviceValueTypeNames[DeviceValueType.BOOL]}
</MenuItem>
<MenuItem value={DeviceValueType.INT8}>
{DeviceValueTypeNames[DeviceValueType.INT8]}
</MenuItem>
<MenuItem value={DeviceValueType.UINT8}>
{DeviceValueTypeNames[DeviceValueType.UINT8]}
</MenuItem>
<MenuItem value={DeviceValueType.INT16}>
{DeviceValueTypeNames[DeviceValueType.INT16]}
</MenuItem>
<MenuItem value={DeviceValueType.UINT16}>
{DeviceValueTypeNames[DeviceValueType.UINT16]}
</MenuItem>
<MenuItem value={DeviceValueType.UINT24}>
{DeviceValueTypeNames[DeviceValueType.UINT24]}
</MenuItem>
<MenuItem value={DeviceValueType.TIME}>
{DeviceValueTypeNames[DeviceValueType.TIME]}
</MenuItem>
<MenuItem value={DeviceValueType.UINT32}>
{DeviceValueTypeNames[DeviceValueType.UINT32]}
</MenuItem>
<MenuItem value={DeviceValueType.STRING}>
{DeviceValueTypeNames[DeviceValueType.STRING]}
</MenuItem>
))}
</TextField>
</Grid>
@@ -351,7 +310,11 @@ const CustomEntitiesDialog = ({
onChange={updateFormValue}
select
>
{uomMenuItems}
{DeviceValueUOM_s.map((val, i) => (
<MenuItem key={val} value={i}>
{val}
</MenuItem>
))}
</TextField>
</Grid>
</>
@@ -360,7 +323,7 @@ const CustomEntitiesDialog = ({
editItem.device_id !== '0' && (
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
fieldErrors={fieldErrors}
name="factor"
label={LL.BYTES()}
value={numberValue(editItem.factor as number)}
@@ -378,7 +341,7 @@ const CustomEntitiesDialog = ({
{editItem.value_type === DeviceValueType.BOOL && (
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
fieldErrors={fieldErrors}
name="factor"
label={LL.BITMASK()}
value={editItem.factor as string}

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useBlocker, useLocation } from 'react-router';
import { toast } from 'react-toastify';
@@ -16,7 +16,7 @@ import {
DialogActions,
DialogContent,
DialogTitle,
Grid,
Grid2 as Grid,
InputAdornment,
Link,
MenuItem,
@@ -62,24 +62,7 @@ import OptionIcon from './OptionIcon';
import { DeviceEntityMask } from './types';
import type { APIcall, Device, DeviceEntity } from './types';
export const APIURL = `${window.location.origin}/api/`;
const MAX_BUFFER_SIZE = 2000;
// Helper function to create masked entity ID - extracted to avoid duplication
const createMaskedEntityId = (de: DeviceEntity): string => {
const maskHex = de.m.toString(16).padStart(2, '0');
const hasCustomizations = !!(de.cn || de.mi || de.ma);
const customizations = [
de.cn || '',
de.mi ? `>${de.mi}` : '',
de.ma ? `<${de.ma}` : ''
]
.filter(Boolean)
.join('');
return `${maskHex}${de.id}${hasCustomizations ? `|${customizations}` : ''}`;
};
export const APIURL = window.location.origin + '/api/';
const Customizations = () => {
const { LL } = useI18nContext();
@@ -111,14 +94,13 @@ const Customizations = () => {
const [selectedDeviceTypeNameURL, setSelectedDeviceTypeNameURL] =
useState<string>(''); // needed for API URL
const [selectedDeviceName, setSelectedDeviceName] = useState<string>('');
const [selectedDeviceBrand, setSelectedDeviceBrand] = useState<string>('');
const { send: sendResetCustomizations } = useRequest(resetCustomizations(), {
immediate: false
});
const { send: sendDeviceName } = useRequest(
(data: { id: number; name: string; brand: string }) => writeDeviceName(data),
(data: { id: number; name: string }) => writeDeviceName(data),
{
immediate: false
}
@@ -143,22 +125,13 @@ const Customizations = () => {
const setOriginalSettings = (data: DeviceEntity[]) => {
setDeviceEntities(
data.map((de) => {
const result: DeviceEntity = {
data.map((de) => ({
...de,
o_m: de.m
};
if (de.cn !== undefined) {
result.o_cn = de.cn;
}
if (de.mi !== undefined) {
result.o_mi = de.mi;
}
if (de.ma !== undefined) {
result.o_ma = de.ma;
}
return result;
})
o_m: de.m,
o_cn: de.cn,
o_mi: de.mi,
o_ma: de.ma
}))
);
};
@@ -171,9 +144,7 @@ const Customizations = () => {
);
};
const entities_theme = useMemo(
() =>
useTheme({
const entities_theme = useTheme({
Table: `
--data-table-library_grid-template-columns: 156px repeat(1, minmax(80px, 1fr)) 45px minmax(45px, auto) minmax(120px, auto);
`,
@@ -236,9 +207,7 @@ const Customizations = () => {
padding-right: 8px;
}
`
}),
[]
);
});
function hasEntityChanged(de: DeviceEntity) {
return (
@@ -251,8 +220,19 @@ const Customizations = () => {
useEffect(() => {
if (deviceEntities.length) {
const changedEntities = deviceEntities.filter((de) => hasEntityChanged(de));
setNumChanges(changedEntities.length);
setNumChanges(
deviceEntities
.filter((de) => hasEntityChanged(de))
.map(
(new_de) =>
new_de.m.toString(16).padStart(2, '0') +
new_de.id +
(new_de.cn || new_de.mi || new_de.ma ? '|' : '') +
(new_de.cn ? new_de.cn : '') +
(new_de.mi ? '>' + new_de.mi : '') +
(new_de.ma ? '<' + new_de.ma : '')
).length
);
}
}, [deviceEntities]);
@@ -264,12 +244,8 @@ const Customizations = () => {
setSelectedDevice(-1);
setSelectedDeviceTypeNameURL('');
} else {
const device = devices.devices[index];
if (device) {
setSelectedDeviceTypeNameURL(device.url || '');
setSelectedDeviceName(device.n);
setSelectedDeviceBrand(device.b);
}
setSelectedDeviceTypeNameURL(devices.devices[index].url || '');
setSelectedDeviceName(devices.devices[index].n);
setNumChanges(0);
setRestartNeeded(false);
}
@@ -287,26 +263,18 @@ const Customizations = () => {
return value as string;
}
const isCommand = useCallback((de: DeviceEntity) => {
return de.n && de.n[0] === '!';
}, []);
const formatName = useCallback(
(de: DeviceEntity, withShortname: boolean) => {
let name: string;
if (isCommand(de)) {
name = de.t
? `${LL.COMMAND(1)}: ${de.t} ${de.n?.slice(1)}`
: `${LL.COMMAND(1)}: ${de.n?.slice(1)}`;
} else if (de.cn && de.cn !== '') {
name = de.t ? `${de.t} ${de.cn}` : de.cn;
} else {
name = de.t ? `${de.t} ${de.n}` : de.n || '';
}
return withShortname ? `${name} ${de.id}` : name;
},
[LL]
);
const formatName = (de: DeviceEntity, withShortname: boolean) =>
(de.n && de.n[0] === '!'
? de.t
? LL.COMMAND(1) + ': ' + de.t + ' ' + de.n.slice(1)
: LL.COMMAND(1) + ': ' + de.n.slice(1)
: de.cn && de.cn !== ''
? de.t
? de.t + ' ' + de.cn
: de.cn
: de.t
? de.t + ' ' + de.n
: de.n) + (withShortname ? ' ' + de.id : '');
const getMaskNumber = (newMask: string[]) => {
let new_mask = 0;
@@ -336,33 +304,34 @@ const Customizations = () => {
return new_masks;
};
const filter_entity = useCallback(
(de: DeviceEntity) =>
const filter_entity = (de: DeviceEntity) =>
(de.m & selectedFilters || !selectedFilters) &&
formatName(de, true).toLowerCase().includes(search.toLowerCase()),
[selectedFilters, search, formatName]
);
formatName(de, true).includes(search);
const maskDisabled = useCallback(
(set: boolean) => {
setDeviceEntities((prev) =>
prev.map((de) => {
const maskDisabled = (set: boolean) => {
setDeviceEntities(
deviceEntities.map(function (de) {
if (filter_entity(de)) {
const excludeMask =
DeviceEntityMask.DV_API_MQTT_EXCLUDE | DeviceEntityMask.DV_WEB_EXCLUDE;
return {
...de,
m: set ? de.m | excludeMask : de.m & ~excludeMask
m: set
? de.m |
(DeviceEntityMask.DV_API_MQTT_EXCLUDE |
DeviceEntityMask.DV_WEB_EXCLUDE)
: de.m &
~(
DeviceEntityMask.DV_API_MQTT_EXCLUDE |
DeviceEntityMask.DV_WEB_EXCLUDE
)
};
}
} else {
return de;
}
})
);
},
[filter_entity]
);
};
const resetCustomization = useCallback(async () => {
const resetCustomization = async () => {
try {
await sendResetCustomizations();
toast.info(LL.CUSTOMIZATIONS_RESTART());
@@ -370,30 +339,25 @@ const Customizations = () => {
toast.error((error as Error).message);
} finally {
setConfirmReset(false);
setRestarting(true);
}
}, [sendResetCustomizations, LL]);
};
const onDialogClose = () => {
setDialogOpen(false);
};
const updateDeviceEntity = useCallback((updatedItem: DeviceEntity) => {
const updateDeviceEntity = (updatedItem: DeviceEntity) => {
setDeviceEntities(
(prev) =>
prev?.map((de) =>
deviceEntities?.map((de) =>
de.id === updatedItem.id ? { ...de, ...updatedItem } : de
) ?? []
)
);
}, []);
};
const onDialogSave = useCallback(
(updatedItem: DeviceEntity) => {
const onDialogSave = (updatedItem: DeviceEntity) => {
setDialogOpen(false);
updateDeviceEntity(updatedItem);
},
[updateDeviceEntity]
);
};
const editDeviceEntity = useCallback((de: DeviceEntity) => {
if (de.n === undefined || (de.n && de.n[0] === '!')) {
@@ -408,18 +372,23 @@ const Customizations = () => {
setDialogOpen(true);
}, []);
const saveCustomization = useCallback(async () => {
if (!devices || !deviceEntities || selectedDevice === -1) {
return;
}
const saveCustomization = async () => {
if (devices && deviceEntities && selectedDevice !== -1) {
const masked_entities = deviceEntities
.filter((de: DeviceEntity) => hasEntityChanged(de))
.map((new_de) => createMaskedEntityId(new_de));
.map(
(new_de) =>
new_de.m.toString(16).padStart(2, '0') +
new_de.id +
(new_de.cn || new_de.mi || new_de.ma ? '|' : '') +
(new_de.cn ? new_de.cn : '') +
(new_de.mi ? '>' + new_de.mi : '') +
(new_de.ma ? '<' + new_de.ma : '')
);
// check size in bytes to match buffer in CPP, which is 2048
const bytes = new TextEncoder().encode(JSON.stringify(masked_entities)).length;
if (bytes > MAX_BUFFER_SIZE) {
if (bytes > 2000) {
toast.warning(LL.CUSTOMIZATIONS_FULL());
return;
}
@@ -427,46 +396,30 @@ const Customizations = () => {
await sendCustomizationEntities({
id: selectedDevice,
entity_ids: masked_entities
})
.then(() => {
toast.success(LL.CUSTOMIZATIONS_SAVED());
})
.catch((error: Error) => {
}).catch((error: Error) => {
if (error.message === 'Reboot required') {
setRestartNeeded(true);
} else {
toast.error(error.message);
}
})
.finally(() => {
setOriginalSettings(deviceEntities);
});
}, [devices, deviceEntities, selectedDevice, sendCustomizationEntities, LL]);
setOriginalSettings(deviceEntities);
}
};
const renameDevice = useCallback(async () => {
await sendDeviceName({
id: selectedDevice,
name: selectedDeviceName,
brand: selectedDeviceBrand
})
const renameDevice = async () => {
await sendDeviceName({ id: selectedDevice, name: selectedDeviceName })
.then(() => {
toast.success(LL.UPDATED_OF(LL.NAME(1)));
})
.catch(() => {
toast.error(`${LL.UPDATE_OF(LL.NAME(1))} ${LL.FAILED(1)}`);
toast.error(LL.UPDATE_OF(LL.NAME(1)) + ' ' + LL.FAILED(1));
})
.finally(async () => {
setRename(false);
await fetchCoreData();
});
}, [
selectedDevice,
selectedDeviceName,
selectedDeviceBrand,
sendDeviceName,
LL,
fetchCoreData
]);
};
const renderDeviceList = () => (
<>
@@ -475,26 +428,15 @@ const Customizations = () => {
</Box>
<Box display="flex" flexWrap="wrap" alignItems="center" gap={2}>
{rename ? (
<>
<TextField
name="device"
label={LL.EMS_DEVICE()}
style={{ minWidth: '48%' }}
fullWidth
variant="outlined"
value={selectedDeviceName}
onChange={(e) => setSelectedDeviceName(e.target.value)}
margin="normal"
/>
<TextField
name="brand"
label={LL.BRAND()}
style={{ minWidth: '48%' }}
variant="outlined"
value={selectedDeviceBrand}
onChange={(e) => setSelectedDeviceBrand(e.target.value)}
margin="normal"
/>
</>
) : (
<TextField
name="device"
@@ -540,7 +482,6 @@ const Customizations = () => {
</Button>
</>
) : (
<>
<Button
startIcon={<EditIcon />}
variant="outlined"
@@ -548,30 +489,18 @@ const Customizations = () => {
>
{LL.RENAME()}
</Button>
<Button
startIcon={<SettingsBackupRestoreIcon />}
variant="outlined"
color="error"
onClick={() => setConfirmReset(true)}
>
{LL.REMOVE_ALL()}
</Button>
</>
))}
</Box>
</>
);
const filteredEntities = useMemo(
() => deviceEntities.filter((de) => filter_entity(de)),
[deviceEntities, filter_entity]
);
const renderDeviceData = () => {
const shown_data = deviceEntities.filter((de) => filter_entity(de));
return (
<>
<Box color="warning.main">
<Typography variant="body2" mt={1} mb={1}>
<Typography variant="body2" mt={1}>
<OptionIcon type="favorite" isSet={true} />={LL.CUSTOMIZATIONS_HELP_2()}
&nbsp;&nbsp;
<OptionIcon type="readonly" isSet={true} />={LL.CUSTOMIZATIONS_HELP_3()}
@@ -597,7 +526,6 @@ const Customizations = () => {
size="small"
variant="outlined"
placeholder={LL.SEARCH()}
aria-label={LL.SEARCH()}
onChange={(event) => {
setSearch(event.target.value);
}}
@@ -617,7 +545,7 @@ const Customizations = () => {
size="small"
color="secondary"
value={getMaskString(selectedFilters)}
onChange={(_, mask: string[]) => {
onChange={(event, mask: string[]) => {
setSelectedFilters(getMaskNumber(mask));
}}
>
@@ -666,13 +594,13 @@ const Customizations = () => {
</Grid>
<Grid>
<Typography variant="subtitle2" color="grey">
{LL.SHOWING()}&nbsp;{filteredEntities.length}/{deviceEntities.length}
{LL.SHOWING()}&nbsp;{shown_data.length}/{deviceEntities.length}
&nbsp;{LL.ENTITIES(deviceEntities.length)}
</Typography>
</Grid>
</Grid>
<Table
data={{ nodes: filteredEntities }}
data={{ nodes: shown_data }}
theme={entities_theme}
layout={{ custom: true }}
>
@@ -694,27 +622,14 @@ const Customizations = () => {
<EntityMaskToggle onUpdate={updateDeviceEntity} de={de} />
</Cell>
<Cell>
<span
style={{
color:
de.v === undefined && !isCommand(de) ? 'grey' : 'inherit'
}}
>
{formatName(de, false)}&nbsp;(
<Link
style={{
color:
de.v === undefined && !isCommand(de)
? 'grey'
: 'primary'
}}
target="_blank"
href={APIURL + selectedDeviceTypeNameURL + '/' + de.id}
>
{de.id}
</Link>
)
</span>
</Cell>
<Cell>
{!(de.m & DeviceEntityMask.DV_READONLY) && formatValue(de.mi)}
@@ -739,7 +654,7 @@ const Customizations = () => {
open={confirmReset}
onClose={() => setConfirmReset(false)}
>
<DialogTitle>{LL.REMOVE_ALL()}</DialogTitle>
<DialogTitle>{LL.RESET(1)}</DialogTitle>
<DialogContent dividers>{LL.CUSTOMIZATIONS_RESET()}</DialogContent>
<DialogActions>
<Button
@@ -756,7 +671,7 @@ const Customizations = () => {
onClick={resetCustomization}
color="error"
>
{LL.REMOVE_ALL()}
{LL.RESET(0)}
</Button>
</DialogActions>
</Dialog>
@@ -767,9 +682,8 @@ const Customizations = () => {
{devices && renderDeviceList()}
{selectedDevice !== -1 && !rename && renderDeviceData()}
{restartNeeded ? (
<MessageBox level="warning" message={LL.RESTART_TEXT(0)}>
<MessageBox my={2} level="warning" message={LL.RESTART_TEXT(0)}>
<Button
sx={{ ml: 2 }}
startIcon={<PowerSettingsNewIcon />}
variant="contained"
color="error"
@@ -787,11 +701,7 @@ const Customizations = () => {
startIcon={<CancelIcon />}
variant="outlined"
color="secondary"
onClick={() => {
if (devices) {
void sendDeviceEntities(selectedDevice);
}
}}
onClick={() => devices && sendDeviceEntities(selectedDevice)}
>
{LL.CANCEL()}
</Button>
@@ -806,18 +716,28 @@ const Customizations = () => {
</ButtonRow>
)}
</Box>
{!rename && (
<ButtonRow mt={1}>
<Button
startIcon={<SettingsBackupRestoreIcon />}
variant="outlined"
color="error"
onClick={() => setConfirmReset(true)}
>
{LL.RESET(0)}
</Button>
</ButtonRow>
)}
</Box>
)}
{renderResetDialog()}
</>
);
return restarting ? (
<SystemMonitor />
) : (
return (
<SectionContent>
{blocker ? <BlockNavigation blocker={blocker} /> : null}
{renderContent()}
{restarting ? <SystemMonitor /> : renderContent()}
{selectedDeviceEntity && (
<SettingsCustomizationsDialog
open={dialogOpen}

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { useEffect, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
import CloseIcon from '@mui/icons-material/Close';
@@ -10,7 +10,7 @@ import {
DialogActions,
DialogContent,
DialogTitle,
Grid,
Grid2 as Grid,
TextField,
Typography
} from '@mui/material';
@@ -30,23 +30,6 @@ interface SettingsCustomizationsDialogProps {
selectedItem: DeviceEntity;
}
interface LabelValueProps {
label: string;
value: React.ReactNode;
}
const LabelValue = memo(({ label, value }: LabelValueProps) => (
<Grid container direction="row">
<Typography variant="body2" color="warning.main">
{label}:&nbsp;
</Typography>
<Typography variant="body2">{value}</Typography>
</Grid>
));
LabelValue.displayName = 'LabelValue';
const ICON_SIZE = 16;
const CustomizationsDialog = ({
open,
onClose,
@@ -57,23 +40,12 @@ const CustomizationsDialog = ({
const [editItem, setEditItem] = useState<DeviceEntity>(selectedItem);
const [error, setError] = useState<boolean>(false);
const updateFormValue = useMemo(
() =>
updateValue(
setEditItem as unknown as React.Dispatch<
React.SetStateAction<Record<string, unknown>>
>
),
[]
);
const updateFormValue = updateValue(setEditItem);
const isWriteableNumber = useMemo(
() =>
const isWriteableNumber =
typeof editItem.v === 'number' &&
editItem.w &&
!(editItem.m & DeviceEntityMask.DV_READONLY),
[editItem.v, editItem.w, editItem.m]
);
!(editItem.m & DeviceEntityMask.DV_READONLY);
useEffect(() => {
if (open) {
@@ -82,59 +54,63 @@ const CustomizationsDialog = ({
}
}, [open, selectedItem]);
const handleClose = useCallback(
(_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => {
const handleClose = (_event, reason: 'backdropClick' | 'escapeKeyDown') => {
if (reason !== 'backdropClick') {
onClose();
}
},
[onClose]
);
};
const save = useCallback(() => {
const save = () => {
if (
isWriteableNumber &&
editItem.mi &&
editItem.ma &&
editItem.mi > editItem.ma
editItem.mi > editItem?.ma
) {
setError(true);
} else {
onSave(editItem);
}
}, [isWriteableNumber, editItem, onSave]);
};
const updateDeviceEntity = useCallback((updatedItem: DeviceEntity) => {
setEditItem((prev) => ({ ...prev, m: updatedItem.m }));
}, []);
const dialogTitle = useMemo(() => `${LL.EDIT()} ${LL.ENTITY()}`, [LL]);
const writeableIcon = useMemo(
() =>
editItem.w ? (
<DoneIcon color="success" sx={{ fontSize: ICON_SIZE }} />
) : (
<CloseIcon color="error" sx={{ fontSize: ICON_SIZE }} />
),
[editItem.w]
);
const updateDeviceEntity = (updatedItem: DeviceEntity) => {
setEditItem({ ...editItem, m: updatedItem.m });
};
return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
<DialogTitle>{dialogTitle}</DialogTitle>
<DialogTitle>{LL.EDIT() + ' ' + LL.ENTITY()}</DialogTitle>
<DialogContent dividers>
<LabelValue label={LL.ID_OF(LL.ENTITY())} value={editItem.id} />
<LabelValue
label={`${LL.DEFAULT(1)} ${LL.ENTITY_NAME(1)}`}
value={editItem.n}
/>
<LabelValue label={LL.WRITEABLE()} value={writeableIcon} />
<Grid container>
<Typography variant="body2" color="warning.main">
{LL.ID_OF(LL.ENTITY())}:&nbsp;
</Typography>
<Typography variant="body2">{editItem.id}</Typography>
</Grid>
<Grid container direction="row">
<Typography variant="body2" color="warning.main">
{LL.DEFAULT(1) + ' ' + LL.ENTITY_NAME(1)}:&nbsp;
</Typography>
<Typography variant="body2">{editItem.n}</Typography>
</Grid>
<Grid container direction="row">
<Typography variant="body2" color="warning.main">
{LL.WRITEABLE()}:&nbsp;
</Typography>
<Typography variant="body2">
{editItem.w ? (
<DoneIcon color="success" sx={{ fontSize: 16 }} />
) : (
<CloseIcon color="error" sx={{ fontSize: 16 }} />
)}
</Typography>
</Grid>
<Box mt={1} mb={2}>
<EntityMaskToggle onUpdate={updateDeviceEntity} de={editItem} />
</Box>
<Grid container spacing={2}>
<Grid>
<TextField
@@ -170,14 +146,12 @@ const CustomizationsDialog = ({
</>
)}
</Grid>
{error && (
<Typography variant="body2" color="error" mt={2}>
Error: Check min and max values
</Typography>
)}
</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { useContext, useEffect, useState } from 'react';
import { IconContext } from 'react-icons/lib';
import { Link } from 'react-router';
import { toast } from 'react-toastify';
@@ -14,7 +14,6 @@ import {
IconButton,
ToggleButton,
ToggleButtonGroup,
Tooltip,
Typography
} from '@mui/material';
@@ -45,7 +44,7 @@ import {
} from './types';
import { deviceValueItemValidation } from './validators';
const Dashboard = memo(() => {
const Dashboard = () => {
const { LL } = useI18nContext();
const { me } = useContext(AuthenticatedContext);
@@ -77,8 +76,7 @@ const Dashboard = memo(() => {
}
);
const deviceValueDialogSave = useCallback(
async (devicevalue: DeviceValue) => {
const deviceValueDialogSave = async (devicevalue: DeviceValue) => {
if (!selectedDashboardItem) {
return;
}
@@ -94,13 +92,9 @@ const Dashboard = memo(() => {
setDeviceValueDialogOpen(false);
setSelectedDashboardItem(undefined);
});
},
[selectedDashboardItem, sendDeviceValue, LL]
);
};
const dashboard_theme = useMemo(
() =>
useTheme({
const dashboard_theme = useTheme({
Table: `
--data-table-library_grid-template-columns: minmax(80px, auto) 120px 32px;
`,
@@ -128,14 +122,12 @@ const Dashboard = memo(() => {
text-align: right;
}
`
}),
[]
);
});
const tree = useTree(
{ nodes: [...data.nodes] },
{ nodes: data.nodes },
{
onChange: () => {} // not used but needed
onChange: undefined // not used but needed
},
{
treeIcon: {
@@ -164,19 +156,13 @@ const Dashboard = memo(() => {
}
});
const nodeIds = useMemo(
() => data.nodes.map((item: DashboardItem) => item.id),
[data.nodes]
);
useEffect(() => {
showAll
? tree.fns.onAddAll(nodeIds) // expand tree
? tree.fns.onAddAll(data.nodes.map((item: DashboardItem) => item.id)) // expand tree
: tree.fns.onRemoveAll(); // collapse tree
}, [parentNodes]);
const showType = useCallback(
(n?: string, t?: number) => {
const showType = (n?: string, t?: number) => {
// if we have a name show it
if (n) {
return n;
@@ -197,17 +183,14 @@ const Dashboard = memo(() => {
}
}
return '';
},
[LL]
);
};
const showName = useCallback(
(di: DashboardItem) => {
const showName = (di: DashboardItem) => {
if (di.id < 100) {
// if its a device (parent node) and has entities
if (di.nodes?.length) {
return (
<span style={{ fontSize: '15px' }}>
<span style={{ fontWeight: 'bold', fontSize: '14px' }}>
<DeviceIcon type_id={di.t ?? 0} />
&nbsp;&nbsp;{showType(di.n, di.t)}
<span style={{ color: 'lightblue' }}>&nbsp;({di.nodes?.length})</span>
@@ -218,28 +201,20 @@ const Dashboard = memo(() => {
if (di.dv) {
return <span>{di.dv.id.slice(2)}</span>;
}
return null;
},
[showType]
);
};
const hasMask = useCallback(
(id: string, mask: number) => (parseInt(id.slice(0, 2), 16) & mask) === mask,
[]
);
const hasMask = (id: string, mask: number) =>
(parseInt(id.slice(0, 2), 16) & mask) === mask;
const editDashboardValue = useCallback(
(di: DashboardItem) => {
const editDashboardValue = (di: DashboardItem) => {
if (me.admin && di.dv?.c) {
setSelectedDashboardItem(di);
setDeviceValueDialogOpen(true);
}
},
[me.admin]
);
};
const handleShowAll = (
_event: React.MouseEvent<HTMLElement>,
event: React.MouseEvent<HTMLElement>,
toggle: boolean | null
) => {
if (toggle !== null) {
@@ -248,32 +223,19 @@ const Dashboard = memo(() => {
}
};
const hasFavEntities = useMemo(
() => data.nodes.filter((item: DashboardItem) => item.id <= 90).length,
[data.nodes]
);
const renderContent = () => {
if (!data) {
return (
<FormLoader onRetry={fetchDashboard} errorMessage={error?.message || ''} />
);
return <FormLoader onRetry={fetchDashboard} errorMessage={error?.message} />;
}
const hasFavEntities = data.nodes.filter(
(item: DashboardItem) => item.id <= 90
).length;
return (
<>
{!data.connected && (
<MessageBox level="error" message={LL.EMS_BUS_WARNING() + '.'}>
&nbsp;(
<Link
target="_blank"
to="https://docs.emsesp.org/Troubleshooting#ems-bus-is-not-connecting"
style={{ color: 'white' }}
>
{LL.ONLINE_HELP()}
</Link>
)
</MessageBox>
<MessageBox mb={2} level="error" message={LL.EMS_BUS_WARNING()} />
)}
{data.connected && data.nodes.length > 0 && !hasFavEntities && (
@@ -293,34 +255,39 @@ const Dashboard = memo(() => {
</MessageBox>
)}
<Box
display="flex"
justifyContent="flex-end"
flexWrap="nowrap"
whiteSpace="nowrap"
>
{data.nodes.length > 0 && (
<>
<ToggleButtonGroup
size="small"
color="primary"
size="small"
value={showAll}
exclusive
onChange={handleShowAll}
>
<ButtonTooltip title={LL.ALLVALUES()}>
<ButtonTooltip title={LL.ALLVALUES()} arrow>
<ToggleButton value={true}>
<UnfoldMoreIcon sx={{ fontSize: 18 }} />
</ToggleButton>
</ButtonTooltip>
<ButtonTooltip title={LL.COMPACT()}>
<ButtonTooltip title={LL.COMPACT()} arrow>
<ToggleButton value={false}>
<UnfoldLessIcon sx={{ fontSize: 18 }} />
</ToggleButton>
</ButtonTooltip>
</ToggleButtonGroup>
</Box>
<ButtonTooltip title={LL.DASHBOARD_1()} arrow>
<HelpOutlineIcon color="primary" sx={{ ml: 1, fontSize: 20 }} />
</ButtonTooltip>
{data.nodes.length > 0 ? (
<Box mt={1} justifyContent="center" flexDirection="column">
<Box
padding={1}
justifyContent="center"
flexDirection="column"
sx={{
borderRadius: 1,
border: '1px solid grey'
}}
>
<IconContext.Provider
value={{
color: 'lightblue',
@@ -356,12 +323,12 @@ const Dashboard = memo(() => {
<Cell>
{me.admin &&
di.dv?.c &&
!hasMask(di.dv.id, DeviceEntityMask.DV_READONLY) && (
!hasMask(
di.dv.id,
DeviceEntityMask.DV_READONLY
) && (
<IconButton
size="small"
aria-label={
LL.CHANGE_VALUE() + ' ' + LL.VALUE(0)
}
onClick={() => editDashboardValue(di)}
>
<EditIcon
@@ -386,28 +353,7 @@ const Dashboard = memo(() => {
</Table>
</IconContext.Provider>
</Box>
) : (
<Box
display="flex"
// justifyContent="flex-end"
// flexWrap="nowrap"
// whiteSpace="nowrap"
>
<Typography mt={1} color="warning.main" variant="body1">
no data
</Typography>
<Tooltip title={LL.DASHBOARD_1()}>
<HelpOutlineIcon
sx={{
ml: 1,
mt: 1,
fontSize: 20,
verticalAlign: 'middle'
}}
color="primary"
/>
</Tooltip>
</Box>
</>
)}
</>
);
@@ -429,6 +375,6 @@ const Dashboard = memo(() => {
)}
</SectionContent>
);
});
};
export default Dashboard;

View File

@@ -1,14 +1,13 @@
import { memo } from 'react';
import { AiOutlineAlert, AiOutlineControl, AiOutlineGateway } from 'react-icons/ai';
import { CgSmartHomeBoiler } from 'react-icons/cg';
import { FaSolarPanel } from 'react-icons/fa';
import { GiHeatHaze, GiTap } from 'react-icons/gi';
import { MdPlaylistAdd } from 'react-icons/md';
import { MdMoreTime } from 'react-icons/md';
import {
MdMoreTime,
MdOutlineDevices,
MdOutlinePool,
MdOutlineSensors,
MdPlaylistAdd,
MdThermostatAuto
} from 'react-icons/md';
import { PiFan, PiGauge } from 'react-icons/pi';
@@ -19,10 +18,9 @@ import type { SvgIconProps } from '@mui/material';
import { DeviceType } from './types';
const deviceIconLookup: Record<
DeviceType,
React.ComponentType<SvgIconProps> | null
> = {
const deviceIconLookup: {
[key in DeviceType]: React.ComponentType<SvgIconProps> | undefined;
} = {
[DeviceType.TEMPERATURESENSOR]: TiThermometer,
[DeviceType.ANALOGSENSOR]: PiGauge,
[DeviceType.BOILER]: CgSmartHomeBoiler,
@@ -41,19 +39,15 @@ const deviceIconLookup: Record<
[DeviceType.POOL]: MdOutlinePool,
[DeviceType.CUSTOM]: MdPlaylistAdd,
[DeviceType.UNKNOWN]: MdOutlineSensors,
[DeviceType.SYSTEM]: null,
[DeviceType.SYSTEM]: undefined,
[DeviceType.SCHEDULER]: MdMoreTime,
[DeviceType.GENERIC]: MdOutlineSensors,
[DeviceType.VENTILATION]: PiFan
};
interface DeviceIconProps {
type_id: DeviceType;
}
const DeviceIcon = memo(({ type_id }: DeviceIconProps) => {
const DeviceIcon = ({ type_id }: { type_id: DeviceType }) => {
const Icon = deviceIconLookup[type_id];
return Icon ? <Icon /> : null;
});
};
export default DeviceIcon;

View File

@@ -1,14 +1,12 @@
import {
memo,
useCallback,
useContext,
useEffect,
useLayoutEffect,
useMemo,
useState
} from 'react';
import { IconContext } from 'react-icons';
import { Link, useNavigate } from 'react-router';
import { useNavigate } from 'react-router';
import { toast } from 'react-toastify';
import CommentsDisabledOutlinedIcon from '@mui/icons-material/CommentsDisabledOutlined';
@@ -33,7 +31,7 @@ import {
DialogActions,
DialogContent,
DialogTitle,
Grid,
Grid2 as Grid,
IconButton,
InputAdornment,
List,
@@ -77,7 +75,7 @@ import { DeviceEntityMask, DeviceType, DeviceValueUOM_s } from './types';
import type { Device, DeviceValue } from './types';
import { deviceValueItemValidation } from './validators';
const Devices = memo(() => {
const Devices = () => {
const { LL } = useI18nContext();
const { me } = useContext(AuthenticatedContext);
@@ -93,7 +91,7 @@ const Devices = memo(() => {
useLayoutTitle(LL.DEVICES());
const { data: coreData, send: sendCoreData } = useRequest(readCoreData, {
const { data: coreData, send: sendCoreData } = useRequest(() => readCoreData(), {
initialData: {
connected: true,
devices: []
@@ -118,32 +116,32 @@ const Devices = memo(() => {
);
useLayoutEffect(() => {
let raf = 0;
const updateSize = () => {
cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => {
function updateSize() {
setSize([window.innerWidth, window.innerHeight]);
});
};
}
window.addEventListener('resize', updateSize);
updateSize();
return () => {
window.removeEventListener('resize', updateSize);
cancelAnimationFrame(raf);
};
return () => window.removeEventListener('resize', updateSize);
}, []);
const leftOffset = useCallback(() => {
const leftOffset = () => {
const devicesWindow = document.getElementById('devices-window');
if (!devicesWindow) return 0;
const { left, right } = devicesWindow.getBoundingClientRect();
if (!left || !right) return 0;
return left + (right - left < 400 ? 0 : 200);
}, []);
if (!devicesWindow) {
return 0;
}
const common_theme = useMemo(
() =>
useTheme({
const clientRect = devicesWindow.getBoundingClientRect();
const left = clientRect.left;
const right = clientRect.right;
if (!left || !right) {
return 0;
}
return left + (right - left < 400 ? 0 : 200);
};
const common_theme = useTheme({
BaseRow: `
font-size: 14px;
`,
@@ -165,21 +163,11 @@ const Devices = memo(() => {
background-color: #177ac9;
}
`
}),
[]
);
});
const device_theme = useMemo(
() =>
useTheme([
const device_theme = useTheme([
common_theme,
{
BaseRow: `
font-size: 15px;
.td {
height: 28px;
}
`,
Table: `
--data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) 130px;
`,
@@ -188,21 +176,14 @@ const Devices = memo(() => {
padding: 8px;
`,
Row: `
&:nth-of-type(odd) .td {
background-color: #303030;
},
font-weight: bold;
&:hover .td {
background-color: #177ac9;
},
`
}
]),
[common_theme]
);
]);
const data_theme = useMemo(
() =>
useTheme([
const data_theme = useTheme([
common_theme,
{
Table: `
@@ -244,9 +225,7 @@ const Devices = memo(() => {
}
`
}
]),
[common_theme]
);
]);
const getSortIcon = (state: State, sortKey: unknown) => {
if (state.sortKey === sortKey && state.reverse) {
@@ -259,7 +238,7 @@ const Devices = memo(() => {
};
const dv_sort = useSort(
{ nodes: [...deviceData.nodes] },
{ nodes: deviceData.nodes },
{},
{
sortIcon: {
@@ -289,7 +268,7 @@ const Devices = memo(() => {
}
const device_select = useRowSelect(
{ nodes: [...coreData.devices] },
{ nodes: coreData.devices },
{
onChange: onSelectChange
}
@@ -345,23 +324,18 @@ const Devices = memo(() => {
return sc;
};
const hasMask = useCallback(
(id: string, mask: number) => (parseInt(id.slice(0, 2), 16) & mask) === mask,
[]
);
const hasMask = (id: string, mask: number) =>
(parseInt(id.slice(0, 2), 16) & mask) === mask;
const handleDownloadCsv = () => {
const deviceIndex = coreData.devices.findIndex(
(d: Device) => d.id === device_select.state.id
(d) => d.id === device_select.state.id
);
if (deviceIndex === -1) {
return;
}
const selectedDevice = coreData.devices[deviceIndex];
if (!selectedDevice) {
return;
}
const filename = selectedDevice.tn + '_' + selectedDevice.n;
const filename =
coreData.devices[deviceIndex].tn + '_' + coreData.devices[deviceIndex].n;
const columns = [
{
@@ -376,7 +350,7 @@ const Devices = memo(() => {
{
accessor: (dv: DeviceValue) =>
dv.u !== undefined && DeviceValueUOM_s[dv.u]
? DeviceValueUOM_s[dv.u]?.replace(/[^a-zA-Z0-9]/g, '')
? DeviceValueUOM_s[dv.u].replace(/[^a-zA-Z0-9]/g, '')
: '',
name: 'UoM'
},
@@ -399,9 +373,7 @@ const Devices = memo(() => {
];
const data = onlyFav
? deviceData.nodes.filter((dv: DeviceValue) =>
hasMask(dv.id, DeviceEntityMask.DV_FAVORITE)
)
? deviceData.nodes.filter((dv) => hasMask(dv.id, DeviceEntityMask.DV_FAVORITE))
: deviceData.nodes;
const csvData = data.reduce(
@@ -461,14 +433,10 @@ const Devices = memo(() => {
const renderDeviceDetails = () => {
if (showDeviceInfo) {
const deviceIndex = coreData.devices.findIndex(
(d: Device) => d.id === device_select.state.id
(d) => d.id === device_select.state.id
);
if (deviceIndex === -1) {
return null;
}
const deviceDetails = coreData.devices[deviceIndex];
if (!deviceDetails) {
return null;
return;
}
return (
@@ -481,35 +449,47 @@ const Devices = memo(() => {
<DialogContent dividers>
<List dense={true}>
<ListItem>
<ListItemText primary={LL.TYPE(0)} secondary={deviceDetails.tn} />
<ListItemText
primary={LL.TYPE(0)}
secondary={coreData.devices[deviceIndex].tn}
/>
</ListItem>
<ListItem>
<ListItemText primary={LL.NAME(0)} secondary={deviceDetails.n} />
<ListItemText
primary={LL.NAME(0)}
secondary={coreData.devices[deviceIndex].n}
/>
</ListItem>
{deviceDetails.t !== DeviceType.CUSTOM && (
{coreData.devices[deviceIndex].t !== DeviceType.CUSTOM && (
<>
<ListItem>
<ListItemText primary={LL.BRAND()} secondary={deviceDetails.b} />
<ListItemText
primary={LL.BRAND()}
secondary={coreData.devices[deviceIndex].b}
/>
</ListItem>
<ListItem>
<ListItemText
primary={LL.ID_OF(LL.DEVICE())}
secondary={
'0x' +
('00' + deviceDetails.d.toString(16).toUpperCase()).slice(-2)
(
'00' +
coreData.devices[deviceIndex].d.toString(16).toUpperCase()
).slice(-2)
}
/>
</ListItem>
<ListItem>
<ListItemText
primary={LL.ID_OF(LL.PRODUCT())}
secondary={deviceDetails.p}
secondary={coreData.devices[deviceIndex].p}
/>
</ListItem>
<ListItem>
<ListItemText
primary={LL.VERSION()}
secondary={deviceDetails.v}
secondary={coreData.devices[deviceIndex].v}
/>
</ListItem>
</>
@@ -528,25 +508,10 @@ const Devices = memo(() => {
</Dialog>
);
}
return null;
};
const renderCoreData = () => (
<>
{!coreData.connected ? (
<MessageBox level="error" message={LL.EMS_BUS_WARNING() + '.'}>
&nbsp;(
<Link
target="_blank"
to="https://docs.emsesp.org/Troubleshooting#ems-bus-is-not-connecting"
style={{ color: 'white' }}
>
{LL.ONLINE_HELP()}
</Link>
)
</MessageBox>
) : (
<Box justifyContent="center" flexDirection="column">
<IconContext.Provider
value={{
color: 'lightblue',
@@ -554,8 +519,13 @@ const Devices = memo(() => {
style: { verticalAlign: 'middle' }
}}
>
{!coreData.connected && (
<MessageBox my={2} level="error" message={LL.EMS_BUS_WARNING()} />
)}
{coreData.connected && (
<Table
data={{ nodes: [...coreData.devices] }}
data={{ nodes: coreData.devices }}
select={device_select}
theme={device_theme}
layout={{ custom: true }}
@@ -589,9 +559,8 @@ const Devices = memo(() => {
</>
)}
</Table>
</IconContext.Provider>
</Box>
)}
</IconContext.Provider>
</>
);
@@ -607,13 +576,12 @@ const Devices = memo(() => {
return;
}
const showDeviceValue = useCallback((dv: DeviceValue) => {
const showDeviceValue = (dv: DeviceValue) => {
setSelectedDeviceValue(dv);
setDeviceValueDialogOpen(true);
}, []);
};
const renderNameCell = useCallback(
(dv: DeviceValue) => (
const renderNameCell = (dv: DeviceValue) => (
<>
{dv.id.slice(2)}&nbsp;
{hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) && (
@@ -626,58 +594,46 @@ const Devices = memo(() => {
<CommentsDisabledOutlinedIcon color="primary" sx={{ fontSize: 12 }} />
)}
</>
),
[hasMask]
);
const shown_data = useMemo(() => {
if (onlyFav) {
return deviceData.nodes.filter(
(dv: DeviceValue) =>
const shown_data = onlyFav
? deviceData.nodes.filter(
(dv) =>
hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) &&
dv.id.slice(2).toLowerCase().includes(search.toLowerCase())
);
}
return deviceData.nodes.filter((dv: DeviceValue) =>
dv.id.slice(2).toLowerCase().includes(search.toLowerCase())
);
}, [deviceData.nodes, onlyFav, search]);
dv.id.slice(2).includes(search)
)
: deviceData.nodes.filter((dv) => dv.id.slice(2).includes(search));
const deviceIndex = coreData.devices.findIndex(
(d: Device) => d.id === device_select.state.id
(d) => d.id === device_select.state.id
);
if (deviceIndex === -1) {
return;
}
const deviceInfo = coreData.devices[deviceIndex];
if (!deviceInfo) {
return;
}
const [, height] = size;
return (
<Box
sx={{
backgroundColor: 'black',
position: 'absolute',
left: leftOffset,
left: () => leftOffset(),
right: 0,
bottom: 0,
top: 64,
zIndex: 'modal',
maxHeight: () => (height || 0) - 126,
maxHeight: () => size[1] - 126,
border: '1px solid #177ac9'
}}
>
<Box sx={{ p: 1 }}>
<Grid container justifyContent="space-between">
<Typography noWrap variant="subtitle1" color="warning.main">
{deviceInfo.n}&nbsp;(
{deviceInfo.tn})
{coreData.devices[deviceIndex].n}&nbsp;(
{coreData.devices[deviceIndex].tn})
</Typography>
<Grid justifyContent="flex-end">
<ButtonTooltip title={LL.CLOSE()}>
<IconButton onClick={resetDeviceSelect} aria-label={LL.CLOSE()}>
<IconButton onClick={resetDeviceSelect}>
<HighlightOffIcon color="primary" sx={{ fontSize: 18 }} />
</IconButton>
</ButtonTooltip>
@@ -689,7 +645,6 @@ const Devices = memo(() => {
variant="outlined"
sx={{ width: '22ch' }}
placeholder={LL.SEARCH()}
aria-label={LL.SEARCH()}
onChange={(event) => {
setSearch(event.target.value);
}}
@@ -704,22 +659,19 @@ const Devices = memo(() => {
}}
/>
<ButtonTooltip title={LL.DEVICE_DETAILS()}>
<IconButton
onClick={() => setShowDeviceInfo(true)}
aria-label={LL.DEVICE_DETAILS()}
>
<IconButton onClick={() => setShowDeviceInfo(true)}>
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
</IconButton>
</ButtonTooltip>
{me.admin && (
<ButtonTooltip title={LL.CUSTOMIZATIONS()}>
<IconButton onClick={customize} aria-label={LL.CUSTOMIZATIONS()}>
<IconButton onClick={customize}>
<ConstructionIcon color="primary" sx={{ fontSize: 18 }} />
</IconButton>
</ButtonTooltip>
)}
<ButtonTooltip title={LL.EXPORT()}>
<IconButton onClick={handleDownloadCsv} aria-label={LL.EXPORT()}>
<IconButton onClick={handleDownloadCsv}>
<DownloadIcon color="primary" sx={{ fontSize: 18 }} />
</IconButton>
</ButtonTooltip>
@@ -747,14 +699,14 @@ const Devices = memo(() => {
' ' +
shown_data.length +
'/' +
deviceInfo.e +
coreData.devices[deviceIndex].e +
' ' +
LL.ENTITIES(shown_data.length)}
</span>
</Box>
<Table
data={{ nodes: Array.from(shown_data) }}
data={{ nodes: shown_data }}
theme={data_theme}
sort={dv_sort}
layout={{ custom: true, fixedHeader: true }}
@@ -838,6 +790,6 @@ const Devices = memo(() => {
)}
</SectionContent>
);
});
};
export default Devices;

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useEffect, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
import WarningIcon from '@mui/icons-material/Warning';
@@ -11,7 +11,7 @@ import {
DialogContent,
DialogTitle,
FormHelperText,
Grid,
Grid2 as Grid,
InputAdornment,
MenuItem,
TextField,
@@ -52,7 +52,7 @@ const DevicesDialog = ({
const [editItem, setEditItem] = useState<DeviceValue>(selectedItem);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const updateFormValue = useMemo(() => updateValue(setEditItem), [setEditItem]);
const updateFormValue = updateValue(setEditItem);
useEffect(() => {
if (open) {
@@ -61,7 +61,11 @@ const DevicesDialog = ({
}
}, [open, selectedItem]);
const save = useCallback(async () => {
const close = () => {
onClose();
};
const save = async () => {
try {
setFieldErrors(undefined);
await validate(validator, editItem);
@@ -69,10 +73,9 @@ const DevicesDialog = ({
} catch (error) {
setFieldErrors(error as ValidateFieldsError);
}
}, [validator, editItem, onSave]);
};
const setUom = useCallback(
(uom?: DeviceValueUOM) => {
const setUom = (uom?: DeviceValueUOM) => {
if (uom === undefined) {
return;
}
@@ -86,49 +89,30 @@ const DevicesDialog = ({
default:
return DeviceValueUOM_s[uom];
}
},
[LL]
);
};
const showHelperText = useCallback((dv: DeviceValue) => {
if (dv.h) return dv.h;
if (dv.l) return dv.l.join(' | ');
if (dv.m !== undefined && dv.x !== undefined) {
return (
const showHelperText = (dv: DeviceValue) =>
dv.h ? (
dv.h
) : dv.l ? (
dv.l.join(' | ')
) : dv.m !== undefined && dv.x !== undefined ? (
<>
{dv.m}&nbsp;&rarr;&nbsp;{dv.x}
</>
);
}
return undefined;
}, []);
const isCommand = useMemo(
() => selectedItem.v === '' && selectedItem.c,
[selectedItem.v, selectedItem.c]
);
const dialogTitle = useMemo(() => {
if (isCommand) return LL.RUN_COMMAND();
return writeable ? LL.CHANGE_VALUE() : LL.VALUE(0);
}, [isCommand, writeable, LL]);
const buttonLabel = useMemo(() => {
return isCommand ? LL.EXECUTE() : LL.UPDATE();
}, [isCommand, LL]);
const helperText = useMemo(
() => showHelperText(editItem),
[editItem, showHelperText]
);
const valueLabel = LL.VALUE(0);
) : undefined;
return (
<Dialog sx={dialogStyle} open={open} onClose={onClose}>
<DialogTitle>{dialogTitle}</DialogTitle>
<Dialog sx={dialogStyle} open={open} onClose={close}>
<DialogTitle>
{selectedItem.v === '' && selectedItem.c
? LL.RUN_COMMAND()
: writeable
? LL.CHANGE_VALUE()
: LL.VALUE(0)}
</DialogTitle>
<DialogContent dividers>
<Box color="warning.main" mb={2}>
<Box color="warning.main" p={0} pl={0} pr={0} mt={0} mb={2}>
<Typography variant="body2">{editItem.id.slice(2)}</Typography>
</Box>
<Grid container>
@@ -136,8 +120,8 @@ const DevicesDialog = ({
{editItem.l ? (
<TextField
name="v"
label={LL.VALUE(0)}
value={editItem.v}
aria-label={valueLabel}
disabled={!writeable}
sx={{ width: '30ch' }}
select
@@ -151,9 +135,9 @@ const DevicesDialog = ({
</TextField>
) : editItem.s || editItem.u !== DeviceValueUOM.NONE ? (
<ValidatedTextField
fieldErrors={fieldErrors || {}}
fieldErrors={fieldErrors}
name="v"
label={valueLabel}
label={LL.VALUE(0)}
value={numberValue(Math.round((editItem.v as number) * 10) / 10)}
autoFocus
disabled={!writeable}
@@ -175,9 +159,9 @@ const DevicesDialog = ({
/>
) : (
<ValidatedTextField
fieldErrors={fieldErrors || {}}
fieldErrors={fieldErrors}
name="v"
label={valueLabel}
label={LL.VALUE(0)}
value={editItem.v}
disabled={!writeable}
sx={{ width: '30ch' }}
@@ -186,9 +170,9 @@ const DevicesDialog = ({
/>
)}
</Grid>
{writeable && helperText && (
{writeable && (
<Grid>
<FormHelperText>{helperText}</FormHelperText>
<FormHelperText>{showHelperText(editItem)}</FormHelperText>
</Grid>
)}
</Grid>
@@ -207,7 +191,7 @@ const DevicesDialog = ({
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={onClose}
onClick={close}
color="secondary"
>
{LL.CANCEL()}
@@ -218,7 +202,7 @@ const DevicesDialog = ({
onClick={save}
color="primary"
>
{buttonLabel}
{selectedItem.v === '' && selectedItem.c ? LL.EXECUTE() : LL.UPDATE()}
</Button>
{progress && (
<CircularProgress
@@ -233,7 +217,7 @@ const DevicesDialog = ({
)}
</Box>
) : (
<Button variant="outlined" onClick={onClose} color="secondary">
<Button variant="outlined" onClick={close} color="secondary">
{LL.CLOSE()}
</Button>
)}

View File

@@ -1,5 +1,3 @@
import { useCallback, useMemo } from 'react';
import { ToggleButton, ToggleButtonGroup } from '@mui/material';
import OptionIcon from './OptionIcon';
@@ -11,132 +9,92 @@ interface EntityMaskToggleProps {
de: DeviceEntity;
}
// Available mask values
const MASK_VALUES = [
DeviceEntityMask.DV_WEB_EXCLUDE, // 1
DeviceEntityMask.DV_API_MQTT_EXCLUDE, // 2
DeviceEntityMask.DV_READONLY, // 4
DeviceEntityMask.DV_FAVORITE, // 8
DeviceEntityMask.DV_DELETED // 128
];
/**
* Converts an array of mask strings to a bitmask number
*/
const getMaskNumber = (newMask: string[]): number => {
return newMask.reduce((mask, entry) => mask | Number(entry), 0);
};
/**
* Converts a bitmask number to an array of mask strings
*/
const getMaskString = (mask: number): string[] => {
return MASK_VALUES.filter((value) => (mask & value) === value).map((value) =>
String(value)
);
};
/**
* Checks if a specific mask bit is set
*/
const hasMask = (mask: number, flag: number): boolean => (mask & flag) === flag;
const EntityMaskToggle = ({ onUpdate, de }: EntityMaskToggleProps) => {
const handleChange = useCallback(
(_event: unknown, mask: string[]) => {
// Convert selected masks to a number
const newMask = getMaskNumber(mask);
const updatedDe = { ...de };
// Apply business logic for mask interactions
// If entity has no name and is set to readonly, also exclude from web
if (updatedDe.n === '' && hasMask(newMask, DeviceEntityMask.DV_READONLY)) {
updatedDe.m = newMask | DeviceEntityMask.DV_WEB_EXCLUDE;
} else {
updatedDe.m = newMask;
const getMaskNumber = (newMask: string[]) => {
let new_mask = 0;
for (const entry of newMask) {
new_mask |= Number(entry);
}
return new_mask;
};
// If excluded from web, cannot be favorite
if (hasMask(updatedDe.m, DeviceEntityMask.DV_WEB_EXCLUDE)) {
updatedDe.m = updatedDe.m & ~DeviceEntityMask.DV_FAVORITE;
const getMaskString = (m: number) => {
const new_masks: string[] = [];
if ((m & 1) === 1) {
new_masks.push('1');
}
onUpdate(updatedDe);
},
[de, onUpdate]
);
// Memoize mask string value
const maskStringValue = useMemo(() => getMaskString(de.m), [de.m]);
// Memoize disabled states
const isFavoriteDisabled = useMemo(
() =>
hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE | DeviceEntityMask.DV_DELETED) ||
de.n === undefined,
[de.m, de.n]
);
const isReadonlyDisabled = useMemo(
() =>
!de.w ||
hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE | DeviceEntityMask.DV_FAVORITE),
[de.w, de.m]
);
const isApiMqttExcludeDisabled = useMemo(
() => de.n === '' || hasMask(de.m, DeviceEntityMask.DV_DELETED),
[de.n, de.m]
);
const isWebExcludeDisabled = useMemo(
() => de.n === undefined || hasMask(de.m, DeviceEntityMask.DV_DELETED),
[de.n, de.m]
);
// Memoize mask flag checks
const isFavoriteSet = useMemo(
() => hasMask(de.m, DeviceEntityMask.DV_FAVORITE),
[de.m]
);
const isReadonlySet = useMemo(
() => hasMask(de.m, DeviceEntityMask.DV_READONLY),
[de.m]
);
const isApiMqttExcludeSet = useMemo(
() => hasMask(de.m, DeviceEntityMask.DV_API_MQTT_EXCLUDE),
[de.m]
);
const isWebExcludeSet = useMemo(
() => hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE),
[de.m]
);
const isDeletedSet = useMemo(
() => hasMask(de.m, DeviceEntityMask.DV_DELETED),
[de.m]
);
if ((m & 2) === 2) {
new_masks.push('2');
}
if ((m & 4) === 4) {
new_masks.push('4');
}
if ((m & 8) === 8) {
new_masks.push('8');
}
if ((m & 128) === 128) {
new_masks.push('128');
}
return new_masks;
};
return (
<ToggleButtonGroup
size="small"
color="secondary"
value={maskStringValue}
onChange={handleChange}
value={getMaskString(de.m)}
onChange={(event, mask: string[]) => {
de.m = getMaskNumber(mask);
if (de.n === '' && de.m & DeviceEntityMask.DV_READONLY) {
de.m = de.m | DeviceEntityMask.DV_WEB_EXCLUDE;
}
if (de.m & DeviceEntityMask.DV_WEB_EXCLUDE) {
de.m = de.m & ~DeviceEntityMask.DV_FAVORITE;
}
onUpdate(de);
}}
>
<ToggleButton value="8" disabled={isFavoriteDisabled}>
<OptionIcon type="favorite" isSet={isFavoriteSet} />
<ToggleButton value="8" disabled={(de.m & 0x81) !== 0 || de.n === undefined}>
<OptionIcon
type="favorite"
isSet={
(de.m & DeviceEntityMask.DV_FAVORITE) === DeviceEntityMask.DV_FAVORITE
}
/>
</ToggleButton>
<ToggleButton value="4" disabled={isReadonlyDisabled}>
<OptionIcon type="readonly" isSet={isReadonlySet} />
<ToggleButton value="4" disabled={!de.w || (de.m & 0x83) >= 3}>
<OptionIcon
type="readonly"
isSet={
(de.m & DeviceEntityMask.DV_READONLY) === DeviceEntityMask.DV_READONLY
}
/>
</ToggleButton>
<ToggleButton value="2" disabled={isApiMqttExcludeDisabled}>
<OptionIcon type="api_mqtt_exclude" isSet={isApiMqttExcludeSet} />
<ToggleButton value="2" disabled={de.n === '' || (de.m & 0x80) !== 0}>
<OptionIcon
type="api_mqtt_exclude"
isSet={
(de.m & DeviceEntityMask.DV_API_MQTT_EXCLUDE) ===
DeviceEntityMask.DV_API_MQTT_EXCLUDE
}
/>
</ToggleButton>
<ToggleButton value="1" disabled={isWebExcludeDisabled}>
<OptionIcon type="web_exclude" isSet={isWebExcludeSet} />
<ToggleButton value="1" disabled={de.n === undefined || (de.m & 0x80) !== 0}>
<OptionIcon
type="web_exclude"
isSet={
(de.m & DeviceEntityMask.DV_WEB_EXCLUDE) ===
DeviceEntityMask.DV_WEB_EXCLUDE
}
/>
</ToggleButton>
<ToggleButton value="128">
<OptionIcon type="deleted" isSet={isDeletedSet} />
<OptionIcon
type="deleted"
isSet={
(de.m & DeviceEntityMask.DV_DELETED) === DeviceEntityMask.DV_DELETED
}
/>
</ToggleButton>
</ToggleButtonGroup>
);

View File

@@ -1,5 +1,4 @@
import { memo, useCallback, useContext, useMemo, useState } from 'react';
import type { ReactElement } from 'react';
import { useContext, useState } from 'react';
import { toast } from 'react-toastify';
import CommentIcon from '@mui/icons-material/CommentTwoTone';
@@ -20,7 +19,6 @@ import {
Stack,
Typography
} from '@mui/material';
import type { SxProps, Theme } from '@mui/material/styles';
import { useRequest } from 'alova/client';
import { SectionContent, useLayoutTitle } from 'components';
@@ -31,61 +29,25 @@ import { saveFile } from 'utils';
import { API, callAction } from '../../api/app';
import type { APIcall } from './types';
interface HelpLink {
href: string;
icon: ReactElement;
label: () => string;
}
interface CustomSupport {
img_url: string | null;
html: string | null;
}
const DEFAULT_IMAGE_URL = 'https://emsesp.org/_media/images/installer.jpeg';
const SUPPORT_BOX_STYLES: SxProps<Theme> = {
borderRadius: 3,
border: '1px solid lightblue',
justifyContent: 'space-evenly',
alignItems: 'center'
};
const IMAGE_STYLES: SxProps<Theme> = {
maxHeight: { xs: 100, md: 250 }
};
const AVATAR_STYLES: SxProps<Theme> = {
bgcolor: '#72caf9'
};
const HelpComponent = () => {
const Help = () => {
const { LL } = useI18nContext();
useLayoutTitle(LL.HELP());
const { me } = useContext(AuthenticatedContext);
const [customSupport, setCustomSupport] = useState<CustomSupport>({
img_url: null,
html: null
});
const [imgError, setImgError] = useState<boolean>(false);
const [customSupportIMG, setCustomSupportIMG] = useState<string | null>(null);
const [customSupportHTML, setCustomSupportHTML] = useState<string | null>(null);
const [notFound, setNotFound] = useState<boolean>(false);
// Memoize the request method to prevent re-creation on every render
const getCustomSupportMethod = useMemo(
() => callAction({ action: 'getCustomSupport' }),
[]
);
useRequest(getCustomSupportMethod).onSuccess((event) => {
if (event?.data && Object.keys(event.data).length !== 0) {
const { Support } = event.data as {
Support: { img_url?: string; html?: string[] };
};
setCustomSupport({
img_url: Support.img_url || null,
html: Support.html?.join('<br/>') || null
});
useRequest(() => callAction({ action: 'getCustomSupport' })).onSuccess((event) => {
if (event && event.data && Object.keys(event.data).length !== 0) {
const data = event.data.Support;
if (data.img_url) {
setCustomSupportIMG(data.img_url);
}
if (data.html) {
setCustomSupportHTML(data.html.join('<br/>'));
}
}
});
@@ -97,91 +59,93 @@ const HelpComponent = () => {
toast.info(LL.DOWNLOAD_SUCCESSFUL());
})
.onError((error) => {
toast.error(String(error.error?.message || 'An error occurred'));
toast.error(error.message);
});
// Optimize API call memoization
const apiCall = useMemo(() => ({ device: 'system', cmd: 'info', id: 0 }), []);
const handleDownloadSystemInfo = useCallback(() => {
void sendAPI(apiCall);
}, [sendAPI, apiCall]);
const handleImageError = useCallback(() => {
setImgError(true);
}, []);
// Memoize help links to prevent recreation on every render
const helpLinks: HelpLink[] = useMemo(
() => [
{
href: 'https://emsesp.org',
icon: <MenuBookIcon />,
label: () => LL.HELP_INFORMATION_1()
},
{
href: 'https://discord.gg/GP9DPSgeJq',
icon: <CommentIcon />,
label: () => LL.HELP_INFORMATION_2()
},
{
href: 'https://github.com/emsesp/EMS-ESP32/issues/new/choose',
icon: <GitHubIcon />,
label: () => LL.HELP_INFORMATION_3()
}
],
[LL]
);
const isAdmin = useMemo(() => me?.admin ?? false, [me?.admin]);
// Memoize image source computation
const imageSrc = useMemo(
() =>
imgError || !customSupport.img_url ? DEFAULT_IMAGE_URL : customSupport.img_url,
[imgError, customSupport.img_url]
);
return (
<SectionContent>
{customSupport.html && (
{customSupportHTML && (
<Stack
padding={1}
mb={2}
direction="row"
divider={<Divider orientation="vertical" flexItem />}
sx={SUPPORT_BOX_STYLES}
sx={{
borderRadius: 3,
border: '2px solid grey',
justifyContent: 'space-evenly',
alignItems: 'center'
}}
>
<Typography variant="subtitle1">
<div dangerouslySetInnerHTML={{ __html: customSupport.html }} />
<div dangerouslySetInnerHTML={{ __html: customSupportHTML }} />
</Typography>
<Box
component="img"
referrerPolicy="no-referrer"
sx={IMAGE_STYLES}
onError={handleImageError}
src={imageSrc}
sx={{
maxHeight: { xs: 100, md: 250 }
}}
onError={() => setNotFound(true)}
src={
notFound
? ''
: customSupportIMG ||
'https://docs.emsesp.org/_media/images/installer.jpeg'
}
/>
</Stack>
)}
{isAdmin && (
<List>
{helpLinks.map(({ href, icon, label }) => (
<ListItem key={href}>
{me.admin && (
<List sx={{ borderRadius: 3, border: '2px solid grey' }}>
<ListItem>
<ListItemButton
component="a"
target="_blank"
rel="noreferrer"
href={href}
href="https://docs.emsesp.org"
>
<ListItemAvatar>
<Avatar sx={AVATAR_STYLES}>{icon}</Avatar>
<Avatar sx={{ bgcolor: '#72caf9' }}>
<MenuBookIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={label()} />
<ListItemText primary={LL.HELP_INFORMATION_1()} />
</ListItemButton>
</ListItem>
<ListItem>
<ListItemButton
component="a"
target="_blank"
rel="noreferrer"
href="https://discord.gg/3J3GgnzpyT"
>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#72caf9' }}>
<CommentIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.HELP_INFORMATION_2()} />
</ListItemButton>
</ListItem>
<ListItem>
<ListItemButton
component="a"
target="_blank"
rel="noreferrer"
href="https://github.com/emsesp/EMS-ESP32/issues/new/choose"
>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#72caf9' }}>
<GitHubIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.HELP_INFORMATION_3()} />
</ListItemButton>
</ListItem>
))}
</List>
)}
@@ -193,7 +157,7 @@ const HelpComponent = () => {
startIcon={<DownloadIcon />}
variant="outlined"
color="primary"
onClick={handleDownloadSystemInfo}
onClick={() => sendAPI({ device: 'system', cmd: 'info', id: 0 })}
>
{LL.SUPPORT_INFORMATION(0)}
</Button>
@@ -209,14 +173,11 @@ const HelpComponent = () => {
href="https://emsesp.org"
color="primary"
>
emsesp.org
{'emsesp.org'}
</Link>
</Typography>
</SectionContent>
);
};
// Memoize the component to prevent unnecessary re-renders
const Help = memo(HelpComponent);
export default Help;

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useMemo, useState } from 'react';
import { useCallback, useState } from 'react';
import { useBlocker } from 'react-router';
import { toast } from 'react-toastify';
@@ -31,19 +31,6 @@ import { readModules, writeModules } from '../../api/app';
import ModulesDialog from './ModulesDialog';
import type { ModuleItem } from './types';
const PENDING_COLOR = 'red';
const ACTIVATED_COLOR = '#00FF7F';
const hasModulesChanged = (mi: ModuleItem): boolean =>
mi.enabled !== mi.o_enabled || mi.license !== mi.o_license;
const ColorStatus = memo(({ status }: { status: number }) => {
if (status === 1) {
return <div style={{ color: PENDING_COLOR }}>Pending Activation</div>;
}
return <div style={{ color: ACTIVATED_COLOR }}>Activated</div>;
});
const Modules = () => {
const { LL } = useI18nContext();
const [numChanges, setNumChanges] = useState<number>(0);
@@ -69,9 +56,7 @@ const Modules = () => {
}
);
const modules_theme = useTheme(
useMemo(
() => ({
const modules_theme = useTheme({
Table: `
--data-table-library_grid-template-columns: 48px 180px 120px 100px repeat(1, minmax(160px, 1fr)) 180px;
`,
@@ -111,69 +96,65 @@ const Modules = () => {
background-color: #303030;
}
`
}),
[]
)
);
const onDialogClose = useCallback(() => {
setDialogOpen(false);
}, []);
const updateModuleItem = useCallback((updatedItem: ModuleItem) => {
void updateState(readModules(), (data: ModuleItem[]) => {
const new_data = data.map((mi) =>
mi.id === updatedItem.id ? { ...mi, ...updatedItem } : mi
);
setNumChanges(new_data.filter(hasModulesChanged).length);
return new_data;
});
}, []);
const onDialogSave = useCallback(
(updatedItem: ModuleItem) => {
const onDialogClose = () => {
setDialogOpen(false);
};
const onDialogSave = (updatedItem: ModuleItem) => {
setDialogOpen(false);
updateModuleItem(updatedItem);
},
[updateModuleItem]
);
};
const editModuleItem = useCallback((mi: ModuleItem) => {
setSelectedModuleItem(mi);
setDialogOpen(true);
}, []);
const onCancel = useCallback(async () => {
const onCancel = async () => {
await fetchModules().then(() => {
setNumChanges(0);
});
}, [fetchModules]);
};
const saveModules = useCallback(async () => {
try {
await Promise.all(
modules.map((condensed_mi: ModuleItem) =>
updateModules({
function hasModulesChanged(mi: ModuleItem) {
return mi.enabled !== mi.o_enabled || mi.license !== mi.o_license;
}
const updateModuleItem = (updatedItem: ModuleItem) => {
void updateState(readModules(), (data: ModuleItem[]) => {
const new_data = data.map((mi) =>
mi.id === updatedItem.id ? { ...mi, ...updatedItem } : mi
);
setNumChanges(new_data.filter((mi) => hasModulesChanged(mi)).length);
return new_data;
});
};
const saveModules = async () => {
await updateModules({
modules: modules.map((condensed_mi) => ({
key: condensed_mi.key,
enabled: condensed_mi.enabled,
license: condensed_mi.license
}))
})
)
);
.then(() => {
toast.success(LL.MODULES_UPDATED());
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
} finally {
})
.catch((error: Error) => {
toast.error(error.message);
})
.finally(async () => {
await fetchModules();
setNumChanges(0);
}
}, [modules, updateModules, LL, fetchModules]);
});
};
const content = useMemo(() => {
const renderContent = () => {
if (!modules) {
return (
<FormLoader onRetry={fetchModules} errorMessage={error?.message || ''} />
);
return <FormLoader onRetry={fetchModules} errorMessage={error?.message} />;
}
if (modules.length === 0) {
@@ -184,6 +165,13 @@ const Modules = () => {
);
}
const colorStatus = (status: number) => {
if (status === 1) {
return <div style={{ color: 'red' }}>Pending Activation</div>;
}
return <div style={{ color: '#00FF7F' }}>Activated</div>;
};
return (
<>
<Box mb={2} color="warning.main">
@@ -226,9 +214,7 @@ const Modules = () => {
<Cell>{mi.author}</Cell>
<Cell>{mi.version}</Cell>
<Cell>{mi.message}</Cell>
<Cell>
<ColorStatus status={mi.status} />
</Cell>
<Cell>{colorStatus(mi.status)}</Cell>
</Row>
))}
</Body>
@@ -262,22 +248,12 @@ const Modules = () => {
</Box>
</>
);
}, [
modules,
fetchModules,
error,
modules_theme,
editModuleItem,
LL,
numChanges,
onCancel,
saveModules
]);
};
return (
<SectionContent>
{blocker ? <BlockNavigation blocker={blocker} /> : null}
{content}
{renderContent()}
{selectedModuleItem && (
<ModulesDialog
open={dialogOpen}

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useEffect, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
import DoneIcon from '@mui/icons-material/Done';
@@ -10,7 +10,7 @@ import {
DialogActions,
DialogContent,
DialogTitle,
Grid,
Grid2 as Grid,
TextField
} from '@mui/material';
@@ -37,35 +37,25 @@ const ModulesDialog = ({
const { LL } = useI18nContext();
const [editItem, setEditItem] = useState<ModuleItem>(selectedItem);
const updateFormValue = useMemo(
() =>
updateValue(
setEditItem as unknown as React.Dispatch<
React.SetStateAction<Record<string, unknown>>
>
),
[]
);
const updateFormValue = updateValue(setEditItem);
// Sync form state when dialog opens or selected item changes
useEffect(() => {
if (open) {
setEditItem(selectedItem);
}
}, [open, selectedItem]);
const handleSave = useCallback(() => {
onSave(editItem);
}, [editItem, onSave]);
const close = () => {
onClose();
};
const dialogTitle = useMemo(
() => `${LL.EDIT()} ${editItem.key}`,
[LL, editItem.key]
);
const save = () => {
onSave(editItem);
};
return (
<Dialog sx={dialogStyle} fullWidth maxWidth="xs" open={open} onClose={onClose}>
<DialogTitle>{dialogTitle}</DialogTitle>
<DialogTitle>{LL.EDIT() + ' ' + editItem.key}</DialogTitle>
<DialogContent dividers>
<Grid container>
<BlockFormControlLabel
@@ -95,7 +85,7 @@ const ModulesDialog = ({
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={onClose}
onClick={close}
color="secondary"
>
{LL.CANCEL()}
@@ -103,7 +93,7 @@ const ModulesDialog = ({
<Button
startIcon={<DoneIcon />}
variant="outlined"
onClick={handleSave}
onClick={save}
color="primary"
>
{LL.UPDATE()}

View File

@@ -1,5 +1,3 @@
import { memo } from 'react';
import CommentsDisabledOutlinedIcon from '@mui/icons-material/CommentsDisabledOutlined';
import DeleteForeverIcon from '@mui/icons-material/DeleteForever';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
@@ -12,39 +10,33 @@ import VisibilityOffOutlinedIcon from '@mui/icons-material/VisibilityOffOutlined
import VisibilityOutlinedIcon from '@mui/icons-material/VisibilityOutlined';
import type { SvgIconProps } from '@mui/material';
export type OptionType =
type OptionType =
| 'deleted'
| 'readonly'
| 'web_exclude'
| 'api_mqtt_exclude'
| 'favorite';
type IconPair = [
const OPTION_ICONS: {
[type in OptionType]: [
React.ComponentType<SvgIconProps>,
React.ComponentType<SvgIconProps>
];
const OPTION_ICONS: Record<OptionType, IconPair> = {
} = {
deleted: [DeleteForeverIcon, DeleteOutlineIcon],
readonly: [EditOffOutlinedIcon, EditOutlinedIcon],
web_exclude: [VisibilityOffOutlinedIcon, VisibilityOutlinedIcon],
api_mqtt_exclude: [CommentsDisabledOutlinedIcon, InsertCommentOutlinedIcon],
favorite: [StarIcon, StarOutlineIcon]
} as const;
const ICON_SIZE = 16;
const ICON_SX = { fontSize: ICON_SIZE, verticalAlign: 'middle' } as const;
export interface OptionIconProps {
readonly type: OptionType;
readonly isSet: boolean;
}
const OptionIcon = ({ type, isSet }: OptionIconProps) => {
const [SetIcon, UnsetIcon] = OPTION_ICONS[type];
const Icon = isSet ? SetIcon : UnsetIcon;
return <Icon {...(isSet && { color: 'primary' })} sx={ICON_SX} />;
};
export default memo(OptionIcon);
const OptionIcon = ({ type, isSet }: { type: OptionType; isSet: boolean }) => {
const Icon = OPTION_ICONS[type][isSet ? 0 : 1];
return isSet ? (
<Icon color="primary" sx={{ fontSize: 16, verticalAlign: 'middle' }} />
) : (
<Icon sx={{ fontSize: 16, verticalAlign: 'middle' }} />
);
};
export default OptionIcon;

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useBlocker } from 'react-router';
import { toast } from 'react-toastify';
@@ -27,7 +27,6 @@ import {
useLayoutTitle
} from 'components';
import { useI18nContext } from 'i18n/i18n-react';
import { useInterval } from 'utils';
import { readSchedule, writeSchedule } from '../../api/app';
import SettingsSchedulerDialog from './SchedulerDialog';
@@ -35,31 +34,58 @@ import { ScheduleFlag } from './types';
import type { Schedule, ScheduleItem } from './types';
import { schedulerItemValidation } from './validators';
// Constants
const INTERVAL_DELAY = 30000; // 30 seconds
const MIN_ID = -100;
const MAX_ID = 100;
const ICON_SIZE = 16;
const SCHEDULE_FLAG_THRESHOLD = 127;
const FLAG_ALL_DAYS = 127;
const REFERENCE_YEAR = 2017;
const REFERENCE_MONTH = '01';
const LOG_2 = Math.log(2);
const Scheduler = () => {
const { LL, locale } = useI18nContext();
const [numChanges, setNumChanges] = useState<number>(0);
const blocker = useBlocker(numChanges !== 0);
const [selectedScheduleItem, setSelectedScheduleItem] = useState<ScheduleItem>();
const [dow, setDow] = useState<string[]>([]);
const [creating, setCreating] = useState<boolean>(false);
const [dialogOpen, setDialogOpen] = useState<boolean>(false);
// Days of week starting from Monday (1-7)
const WEEK_DAYS = [1, 2, 3, 4, 5, 6, 7] as const;
useLayoutTitle(LL.SCHEDULER());
const DEFAULT_SCHEDULE_ITEM: Omit<ScheduleItem, 'id' | 'o_id'> = {
active: false,
deleted: false,
flags: FLAG_ALL_DAYS,
time: '',
cmd: '',
value: '',
name: ''
};
const {
data: schedule,
send: fetchSchedule,
error
} = useRequest(readSchedule, {
initialData: []
});
const scheduleTheme = {
const { send: updateSchedule } = useRequest(
(data: Schedule) => writeSchedule(data),
{
immediate: false
}
);
function hasScheduleChanged(si: ScheduleItem) {
return (
si.id !== si.o_id ||
(si.name || '') !== (si.o_name || '') ||
si.active !== si.o_active ||
si.deleted !== si.o_deleted ||
si.flags !== si.o_flags ||
si.time !== si.o_time ||
si.cmd !== si.o_cmd ||
si.value !== si.o_value
);
}
useEffect(() => {
const formatter = new Intl.DateTimeFormat(locale, {
weekday: 'short',
timeZone: 'UTC'
});
const days = [1, 2, 3, 4, 5, 6, 7].map((day) => {
const dd = day < 10 ? `0${day}` : day;
return new Date(`2017-01-${dd}T00:00:00+00:00`);
});
setDow(days.map((date) => formatter.format(date)));
}, [locale]);
const schedule_theme = useTheme({
Table: `
--data-table-library_grid-template-columns: 36px 210px 100px 192px repeat(1, minmax(100px, 1fr)) 160px;
`,
@@ -97,84 +123,13 @@ const scheduleTheme = {
background-color: #177ac9;
}
`
};
const scheduleTypeLabels: Record<number, string> = {
[ScheduleFlag.SCHEDULE_IMMEDIATE]: 'Immediate',
[ScheduleFlag.SCHEDULE_TIMER]: 'Timer',
[ScheduleFlag.SCHEDULE_CONDITION]: 'Condition',
[ScheduleFlag.SCHEDULE_ONCHANGE]: 'On Change'
};
const Scheduler = () => {
const { LL, locale } = useI18nContext();
const [numChanges, setNumChanges] = useState<number>(0);
const blocker = useBlocker(numChanges !== 0);
const [selectedScheduleItem, setSelectedScheduleItem] = useState<ScheduleItem>();
const [dow, setDow] = useState<string[]>([]);
const [creating, setCreating] = useState<boolean>(false);
const [dialogOpen, setDialogOpen] = useState<boolean>(false);
useLayoutTitle(LL.SCHEDULER());
const {
data: schedule,
send: fetchSchedule,
error
} = useRequest(readSchedule, {
initialData: []
});
const { send: updateSchedule } = useRequest(
(data: Schedule) => writeSchedule(data),
{
immediate: false
}
);
const hasScheduleChanged = useCallback((si: ScheduleItem) => {
return (
si.id !== si.o_id ||
(si.name || '') !== (si.o_name || '') ||
si.active !== si.o_active ||
si.deleted !== si.o_deleted ||
si.flags !== si.o_flags ||
si.time !== si.o_time ||
si.cmd !== si.o_cmd ||
si.value !== si.o_value
);
}, []);
const intervalCallback = useCallback(() => {
if (numChanges === 0) {
void fetchSchedule();
}
}, [numChanges, fetchSchedule]);
useInterval(intervalCallback, INTERVAL_DELAY);
useEffect(() => {
const formatter = new Intl.DateTimeFormat(locale, {
weekday: 'short',
timeZone: 'UTC'
});
const days = WEEK_DAYS.map((day) => {
const dayStr = String(day).padStart(2, '0');
return new Date(
`${REFERENCE_YEAR}-${REFERENCE_MONTH}-${dayStr}T00:00:00+00:00`
);
});
setDow(days.map((date) => formatter.format(date)));
}, [locale]);
const schedule_theme = useTheme(scheduleTheme);
const saveSchedule = useCallback(async () => {
try {
const saveSchedule = async () => {
await updateSchedule({
schedule: schedule
.filter((si: ScheduleItem) => !si.deleted)
.map((condensed_si: ScheduleItem) => ({
.filter((si) => !si.deleted)
.map((condensed_si) => ({
id: condensed_si.id,
active: condensed_si.active,
flags: condensed_si.flags,
@@ -183,16 +138,18 @@ const Scheduler = () => {
value: condensed_si.value,
name: condensed_si.name
}))
});
})
.then(() => {
toast.success(LL.SCHEDULE_UPDATED());
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
toast.error(message);
} finally {
})
.catch((error: Error) => {
toast.error(error.message);
})
.finally(async () => {
await fetchSchedule();
setNumChanges(0);
}
}, [LL, schedule, updateSchedule, fetchSchedule]);
});
};
const editScheduleItem = useCallback((si: ScheduleItem) => {
setCreating(false);
@@ -203,22 +160,24 @@ const Scheduler = () => {
}
}, []);
const onDialogClose = useCallback(() => {
const onDialogClose = () => {
setDialogOpen(false);
}, []);
};
const onDialogCancel = useCallback(async () => {
const onDialogCancel = async () => {
await fetchSchedule().then(() => {
setNumChanges(0);
});
}, [fetchSchedule]);
};
const onDialogSave = useCallback(
(updatedItem: ScheduleItem) => {
const onDialogSave = (updatedItem: ScheduleItem) => {
setDialogOpen(false);
void updateState(readSchedule(), (data: ScheduleItem[]) => {
const new_data = creating
? [...data, updatedItem]
? [
...data.filter((si) => creating || si.o_id !== updatedItem.o_id),
updatedItem
]
: data.map((si) =>
si.id === updatedItem.id ? { ...si, ...updatedItem } : si
);
@@ -227,69 +186,67 @@ const Scheduler = () => {
return new_data;
});
},
[creating, hasScheduleChanged]
);
const addScheduleItem = useCallback(() => {
setCreating(true);
const newItem: ScheduleItem = {
id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
...DEFAULT_SCHEDULE_ITEM
};
setSelectedScheduleItem(newItem);
const addScheduleItem = () => {
setCreating(true);
setSelectedScheduleItem({
id: Math.floor(Math.random() * (Math.floor(200) - 100) + 100),
active: false,
deleted: false,
flags: ScheduleFlag.SCHEDULE_DAY,
time: '',
cmd: '',
value: '',
name: ''
});
setDialogOpen(true);
}, []);
};
const filteredAndSortedSchedule = useMemo(
() =>
schedule
.filter((si: ScheduleItem) => !si.deleted)
.sort((a: ScheduleItem, b: ScheduleItem) => a.flags - b.flags),
[schedule]
);
const renderSchedule = () => {
if (!schedule) {
return <FormLoader onRetry={fetchSchedule} errorMessage={error?.message} />;
}
const dayBox = useCallback(
(si: ScheduleItem, flag: number) => {
const dayIndex = Math.log(flag) / LOG_2;
const isActive = (si.flags & flag) === flag;
return (
const dayBox = (si: ScheduleItem, flag: number) => (
<>
<Box>
<Typography sx={{ fontSize: 11 }} color={isActive ? 'primary' : 'grey'}>
{dow[dayIndex]}
<Typography
sx={{ fontSize: 11 }}
color={(si.flags & flag) === flag ? 'primary' : 'grey'}
>
{dow[Math.log(flag) / Math.log(2)]}
</Typography>
</Box>
<Divider orientation="vertical" flexItem />
</>
);
},
[dow]
);
const scheduleType = useCallback((si: ScheduleItem) => {
const label = scheduleTypeLabels[si.flags];
return (
const scheduleType = (si: ScheduleItem) => (
<Box>
<Typography sx={{ fontSize: 11 }} color="primary">
{label || ''}
{si.flags === ScheduleFlag.SCHEDULE_IMMEDIATE ? (
<>Immediate</>
) : si.flags === ScheduleFlag.SCHEDULE_TIMER ? (
<>Timer</>
) : si.flags === ScheduleFlag.SCHEDULE_CONDITION ? (
<>Condition</>
) : si.flags === ScheduleFlag.SCHEDULE_ONCHANGE ? (
<>On Change</>
) : (
<></>
)}
</Typography>
</Box>
);
}, []);
const renderSchedule = useCallback(() => {
if (!schedule) {
return (
<FormLoader onRetry={fetchSchedule} errorMessage={error?.message || ''} />
);
}
return (
<Table
data={{ nodes: filteredAndSortedSchedule }}
data={{
nodes: schedule
.filter((si) => !si.deleted)
.sort((a, b) => a.flags - b.flags)
}}
theme={schedule_theme}
layout={{ custom: true }}
>
@@ -309,15 +266,22 @@ const Scheduler = () => {
{tableList.map((si: ScheduleItem) => (
<Row key={si.id} item={si} onClick={() => editScheduleItem(si)}>
<Cell stiff>
{si.active ? (
<CircleIcon
color={si.active ? 'success' : 'error'}
sx={{ fontSize: ICON_SIZE, verticalAlign: 'middle' }}
color="success"
sx={{ fontSize: 16, verticalAlign: 'middle' }}
/>
) : (
<CircleIcon
color="error"
sx={{ fontSize: 16, verticalAlign: 'middle' }}
/>
)}
</Cell>
<Cell stiff>
<Stack spacing={0.5} direction="row">
<Divider orientation="vertical" flexItem />
{si.flags > SCHEDULE_FLAG_THRESHOLD ? (
{si.flags > 127 ? (
scheduleType(si)
) : (
<>
@@ -343,17 +307,7 @@ const Scheduler = () => {
)}
</Table>
);
}, [
schedule,
error,
fetchSchedule,
filteredAndSortedSchedule,
schedule_theme,
editScheduleItem,
LL,
dayBox,
scheduleType
]);
};
return (
<SectionContent>
@@ -375,7 +329,7 @@ const Scheduler = () => {
/>
)}
<Box display="flex" flexWrap="wrap">
<Box mt={1} display="flex" flexWrap="wrap">
<Box flexGrow={1}>
{numChanges !== 0 && (
<ButtonRow>

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useEffect, useState } from 'react';
import AddIcon from '@mui/icons-material/Add';
import CancelIcon from '@mui/icons-material/Cancel';
@@ -13,7 +13,7 @@ import {
DialogActions,
DialogContent,
DialogTitle,
Grid,
Grid2 as Grid,
TextField,
ToggleButton,
ToggleButtonGroup,
@@ -31,35 +31,6 @@ import { validate } from 'validators';
import { ScheduleFlag } from './types';
import type { ScheduleItem } from './types';
// Constants
const FLAG_MASK_127 = 127;
const SCHEDULE_TYPE_THRESHOLD = 127;
const FLAG_ALL_DAYS = 127;
const DEFAULT_TIME = '00:00';
const TYPOGRAPHY_FONT_SIZE = 10;
// Day of week flag configuration (static, defined outside component)
const DAY_FLAGS = [
{ value: '2', flag: ScheduleFlag.SCHEDULE_MON },
{ value: '4', flag: ScheduleFlag.SCHEDULE_TUE },
{ value: '8', flag: ScheduleFlag.SCHEDULE_WED },
{ value: '16', flag: ScheduleFlag.SCHEDULE_THU },
{ value: '32', flag: ScheduleFlag.SCHEDULE_FRI },
{ value: '64', flag: ScheduleFlag.SCHEDULE_SAT },
{ value: '1', flag: ScheduleFlag.SCHEDULE_SUN }
] as const;
// Day of week flag values array (static)
const FLAG_VALUES = [
ScheduleFlag.SCHEDULE_SUN,
ScheduleFlag.SCHEDULE_MON,
ScheduleFlag.SCHEDULE_TUE,
ScheduleFlag.SCHEDULE_WED,
ScheduleFlag.SCHEDULE_THU,
ScheduleFlag.SCHEDULE_FRI,
ScheduleFlag.SCHEDULE_SAT
] as const;
interface SchedulerDialogProps {
open: boolean;
creating: boolean;
@@ -82,164 +53,107 @@ const SchedulerDialog = ({
const { LL } = useI18nContext();
const [editItem, setEditItem] = useState<ScheduleItem>(selectedItem);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const [scheduleType, setScheduleType] = useState<ScheduleFlag>();
const updateFormValue = useMemo(
() =>
updateValue(
setEditItem as unknown as React.Dispatch<
React.SetStateAction<Record<string, unknown>>
>
),
[]
);
const updateFormValue = updateValue(setEditItem);
useEffect(() => {
if (open) {
setFieldErrors(undefined);
setEditItem(selectedItem);
// Set the flags based on type when page is loaded:
// set the flags based on type when page is loaded...
// 0-127 is day schedule
// 128 is timer
// 129 is on change
// 130 is on condition
// 132 is immediate
setScheduleType(
selectedItem.flags <= SCHEDULE_TYPE_THRESHOLD
? ScheduleFlag.SCHEDULE_DAY
: selectedItem.flags
selectedItem.flags < 128 ? ScheduleFlag.SCHEDULE_DAY : selectedItem.flags
);
}
}, [open, selectedItem]);
// Helper function to handle save operations
const handleSave = useCallback(
async (itemToSave: ScheduleItem) => {
const save = async () => {
try {
setFieldErrors(undefined);
await validate(validator, itemToSave);
onSave(itemToSave);
await validate(validator, editItem);
onSave(editItem);
} catch (error) {
setFieldErrors(error as ValidateFieldsError);
}
},
[validator, onSave]
);
};
const save = useCallback(async () => {
await handleSave(editItem);
}, [editItem, handleSave]);
const saveandactivate = async () => {
editItem.active = true;
try {
setFieldErrors(undefined);
await validate(validator, editItem);
onSave(editItem);
} catch (error) {
setFieldErrors(error as ValidateFieldsError);
}
};
const saveandactivate = useCallback(async () => {
await handleSave({ ...editItem, active: true });
}, [editItem, handleSave]);
const remove = () => {
editItem.deleted = true;
onSave(editItem);
};
const remove = useCallback(() => {
onSave({ ...editItem, deleted: true });
}, [editItem, onSave]);
const getFlagDOWnumber = (newFlag: string[]) => {
let new_flag = 0;
for (const entry of newFlag) {
new_flag |= Number(entry);
}
return new_flag & 127;
};
// Optimize DOW flag conversion
const getFlagDOWnumber = useCallback((flags: string[]) => {
return flags.reduce((acc, flag) => acc | Number(flag), 0) & FLAG_MASK_127;
}, []);
const getFlagDOWstring = (f: number) => {
const new_flags: string[] = [];
if ((f & 129) === 1) {
new_flags.push('1');
}
if ((f & 130) === 2) {
new_flags.push('2');
}
if ((f & 4) === 4) {
new_flags.push('4');
}
if ((f & 8) === 8) {
new_flags.push('8');
}
if ((f & 16) === 16) {
new_flags.push('16');
}
if ((f & 32) === 32) {
new_flags.push('32');
}
if ((f & 64) === 64) {
new_flags.push('64');
}
const getFlagDOWstring = useCallback((f: number) => {
return FLAG_VALUES.filter((flag) => (f & flag) === flag).map((flag) =>
String(flag)
);
}, []);
return new_flags;
};
// Day of week display component
const DayOfWeekButton = useCallback(
(flag: number) => {
const dayIndex = Math.log2(flag);
const isSelected = (editItem.flags & flag) === flag;
return (
const showDOW = (si: ScheduleItem, flag: number) => (
<Typography
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
color={isSelected ? 'primary' : 'grey'}
sx={{ fontSize: 10 }}
color={(si.flags & flag) === flag ? 'primary' : 'grey'}
>
{dow[dayIndex]}
{dow[Math.log(flag) / Math.log(2)]}
</Typography>
);
},
[editItem.flags, dow]
);
const handleClose = useCallback(
(_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => {
const handleClose = (_event, reason: 'backdropClick' | 'escapeKeyDown') => {
if (reason !== 'backdropClick') {
onClose();
}
},
[onClose]
);
const handleScheduleTypeChange = useCallback(
(_event: React.SyntheticEvent<HTMLElement>, flag: ScheduleFlag | null) => {
if (flag !== null) {
setFieldErrors(undefined); // clear any validation errors
setScheduleType(flag);
// wipe the time field when changing the schedule type
// set the flags based on type
const newFlags = flag === ScheduleFlag.SCHEDULE_DAY ? FLAG_ALL_DAYS : flag;
setEditItem((prev) => ({ ...prev, time: '', flags: newFlags }));
}
},
[]
);
const handleDOWChange = useCallback(
(_event: React.SyntheticEvent<HTMLElement>, flags: string[]) => {
const newFlags =
getFlagDOWnumber(flags) === 0 ? FLAG_ALL_DAYS : getFlagDOWnumber(flags);
setEditItem((prev) => ({ ...prev, flags: newFlags }));
},
[getFlagDOWnumber]
);
// Memoize derived values
const isDaySchedule = useMemo(
() => scheduleType === ScheduleFlag.SCHEDULE_DAY,
[scheduleType]
);
const isTimerSchedule = useMemo(
() => scheduleType === ScheduleFlag.SCHEDULE_TIMER,
[scheduleType]
);
const isImmediateSchedule = useMemo(
() => scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE,
[scheduleType]
);
const needsTimeField = useMemo(
() => isDaySchedule || isTimerSchedule,
[isDaySchedule, isTimerSchedule]
);
const dowFlags = useMemo(
() => getFlagDOWstring(editItem.flags),
[editItem.flags, getFlagDOWstring]
);
const timeFieldValue = useMemo(() => {
if (needsTimeField) {
return editItem.time === '' ? DEFAULT_TIME : editItem.time;
}
return editItem.time === DEFAULT_TIME ? '' : editItem.time;
}, [editItem.time, needsTimeField]);
const timeFieldLabel = useMemo(() => {
if (scheduleType === ScheduleFlag.SCHEDULE_TIMER) return LL.TIMER(1);
if (scheduleType === ScheduleFlag.SCHEDULE_CONDITION) return LL.CONDITION();
if (scheduleType === ScheduleFlag.SCHEDULE_ONCHANGE) return LL.ONCHANGE();
if (scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE) return LL.IMMEDIATE();
return LL.TIME(1);
}, [scheduleType, LL]);
};
return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
<DialogTitle>
{creating ? `${LL.ADD(1)} ${LL.NEW(0)}` : LL.EDIT()}&nbsp;
{creating ? LL.ADD(1) + ' ' + LL.NEW(0) : LL.EDIT()}&nbsp;
{LL.SCHEDULE(1)}
</DialogTitle>
<DialogContent dividers>
@@ -249,27 +163,47 @@ const SchedulerDialog = ({
value={scheduleType}
exclusive
disabled={!creating}
onChange={handleScheduleTypeChange}
onChange={(_event, flag: ScheduleFlag) => {
if (flag !== null) {
setFieldErrors(undefined); // clear any validation errors
setScheduleType(flag);
// wipe the time field when changing the schedule type
setEditItem({ ...editItem, time: '' });
// set the flags based on type
// 0-127 is day schedule
// 128 is timer
// 129 is on change
// 130 is on condition
// 132 is immediate
setEditItem(
flag === ScheduleFlag.SCHEDULE_DAY
? { ...editItem, flags: 0 }
: { ...editItem, flags: flag }
);
}
}}
>
<ToggleButton value={ScheduleFlag.SCHEDULE_DAY}>
<Typography
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
color={isDaySchedule ? 'primary' : 'grey'}
sx={{ fontSize: 10 }}
color={scheduleType === ScheduleFlag.SCHEDULE_DAY ? 'primary' : 'grey'}
>
{LL.SCHEDULE(0)}
</Typography>
</ToggleButton>
<ToggleButton value={ScheduleFlag.SCHEDULE_TIMER}>
<Typography
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
color={isTimerSchedule ? 'primary' : 'grey'}
sx={{ fontSize: 10 }}
color={
scheduleType === ScheduleFlag.SCHEDULE_TIMER ? 'primary' : 'grey'
}
>
{LL.TIMER(0)}
</Typography>
</ToggleButton>
<ToggleButton value={ScheduleFlag.SCHEDULE_ONCHANGE}>
<Typography
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
sx={{ fontSize: 10 }}
color={
scheduleType === ScheduleFlag.SCHEDULE_ONCHANGE ? 'primary' : 'grey'
}
@@ -279,7 +213,7 @@ const SchedulerDialog = ({
</ToggleButton>
<ToggleButton value={ScheduleFlag.SCHEDULE_CONDITION}>
<Typography
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
sx={{ fontSize: 10 }}
color={
scheduleType === ScheduleFlag.SCHEDULE_CONDITION ? 'primary' : 'grey'
}
@@ -289,30 +223,50 @@ const SchedulerDialog = ({
</ToggleButton>
<ToggleButton value={ScheduleFlag.SCHEDULE_IMMEDIATE}>
<Typography
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
color={isImmediateSchedule ? 'primary' : 'grey'}
sx={{ fontSize: 10 }}
color={
scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE ? 'primary' : 'grey'
}
>
{LL.IMMEDIATE()}
</Typography>
</ToggleButton>
</ToggleButtonGroup>
{isDaySchedule && (
{scheduleType === ScheduleFlag.SCHEDULE_DAY && (
<ToggleButtonGroup
size="small"
color="secondary"
value={dowFlags}
onChange={handleDOWChange}
value={getFlagDOWstring(editItem.flags)}
onChange={(_event, flag: string[]) => {
setEditItem({ ...editItem, flags: getFlagDOWnumber(flag) });
}}
>
{DAY_FLAGS.map(({ value, flag }) => (
<ToggleButton key={value} value={value}>
{DayOfWeekButton(flag)}
<ToggleButton value="2">
{showDOW(editItem, ScheduleFlag.SCHEDULE_MON)}
</ToggleButton>
<ToggleButton value="4">
{showDOW(editItem, ScheduleFlag.SCHEDULE_TUE)}
</ToggleButton>
<ToggleButton value="8">
{showDOW(editItem, ScheduleFlag.SCHEDULE_WED)}
</ToggleButton>
<ToggleButton value="16">
{showDOW(editItem, ScheduleFlag.SCHEDULE_THU)}
</ToggleButton>
<ToggleButton value="32">
{showDOW(editItem, ScheduleFlag.SCHEDULE_FRI)}
</ToggleButton>
<ToggleButton value="64">
{showDOW(editItem, ScheduleFlag.SCHEDULE_SAT)}
</ToggleButton>
<ToggleButton value="1">
{showDOW(editItem, ScheduleFlag.SCHEDULE_SUN)}
</ToggleButton>
))}
</ToggleButtonGroup>
)}
{!isImmediateSchedule && (
{scheduleType !== ScheduleFlag.SCHEDULE_IMMEDIATE && (
<>
<Grid container>
<BlockFormControlLabel
@@ -327,17 +281,22 @@ const SchedulerDialog = ({
/>
</Grid>
<Grid container>
{needsTimeField ? (
{scheduleType === ScheduleFlag.SCHEDULE_DAY ||
scheduleType === ScheduleFlag.SCHEDULE_TIMER ? (
<>
<TextField
name="time"
type="time"
label={timeFieldLabel}
value={timeFieldValue}
label={
scheduleType === ScheduleFlag.SCHEDULE_TIMER
? LL.TIMER(1)
: LL.TIME(1)
}
value={editItem.time === '' ? '00:00' : editItem.time}
margin="normal"
onChange={updateFormValue}
/>
{isTimerSchedule && (
{scheduleType === ScheduleFlag.SCHEDULE_TIMER && (
<Box color="warning.main" ml={2} mt={4}>
<Typography variant="body2">
{LL.SCHEDULER_HELP_2()}
@@ -348,10 +307,16 @@ const SchedulerDialog = ({
) : (
<TextField
name="time"
label={timeFieldLabel}
label={
scheduleType === ScheduleFlag.SCHEDULE_CONDITION
? LL.CONDITION()
: scheduleType === ScheduleFlag.SCHEDULE_ONCHANGE
? LL.ONCHANGE()
: LL.IMMEDIATE()
}
multiline
fullWidth
value={timeFieldValue}
value={editItem.time === '00:00' ? '' : editItem.time}
margin="normal"
onChange={updateFormValue}
/>
@@ -360,7 +325,7 @@ const SchedulerDialog = ({
</>
)}
<ValidatedTextField
fieldErrors={fieldErrors || {}}
fieldErrors={fieldErrors}
name="cmd"
label={LL.COMMAND(0)}
multiline
@@ -379,7 +344,7 @@ const SchedulerDialog = ({
onChange={updateFormValue}
/>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
fieldErrors={fieldErrors}
name="name"
label={LL.NAME(0) + ' (' + LL.OPTIONAL() + ')'}
value={editItem.name}
@@ -418,7 +383,7 @@ const SchedulerDialog = ({
>
{creating ? LL.ADD(0) : LL.UPDATE()}
</Button>
{isImmediateSchedule && editItem.cmd !== '' && (
{scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE && editItem.cmd !== '' && (
<Button
startIcon={<PlayArrowIcon />}
variant="outlined"

View File

@@ -1,4 +1,4 @@
import { useCallback, useContext, useMemo, useRef, useState } from 'react';
import { useContext, useState } from 'react';
import { toast } from 'react-toastify';
import AddCircleOutlineOutlinedIcon from '@mui/icons-material/AddCircleOutlineOutlined';
@@ -49,74 +49,6 @@ import {
temperatureSensorItemValidation
} from './validators';
// Constants
const MS_PER_SECOND = 1000;
const MS_PER_MINUTE = 60 * MS_PER_SECOND;
const MS_PER_HOUR = 60 * MS_PER_MINUTE;
const MS_PER_DAY = 24 * MS_PER_HOUR;
const MIN_TEMP_ID = -100;
const MAX_TEMP_ID = 100;
const GPIO_25 = 25;
const GPIO_26 = 26;
const HEADER_BUTTON_STYLE: React.CSSProperties = {
fontSize: '14px',
justifyContent: 'flex-start'
};
const HEADER_BUTTON_STYLE_END: React.CSSProperties = {
fontSize: '14px',
justifyContent: 'flex-end'
};
const common_theme = {
BaseRow: `
font-size: 14px;
.td {
height: 32px;
}
`,
HeaderRow: `
text-transform: uppercase;
background-color: black;
color: #90CAF9;
.th {
border-bottom: 1px solid #565656;
height: 36px;
}
`,
Row: `
background-color: #1e1e1e;
position: relative;
cursor: pointer;
.td {
padding: 8px;
border-bottom: 1px solid #565656;
}
&:hover .td {
background-color: #177ac9;
color: white;
}
`,
Cell: `
&:last-of-type {
text-align: right;
},
`
};
const temperature_theme_config = {
Table: `
--data-table-library_grid-template-columns: minmax(0, 1fr) 35%;
`
};
const analog_theme_config = {
Table: `
--data-table-library_grid-template-columns: 80px repeat(1, minmax(0, 1fr)) 120px 110px;
`
};
const Sensors = () => {
const { LL } = useI18nContext();
const { me } = useContext(AuthenticatedContext);
@@ -127,22 +59,18 @@ const Sensors = () => {
const [temperatureDialogOpen, setTemperatureDialogOpen] = useState<boolean>(false);
const [analogDialogOpen, setAnalogDialogOpen] = useState<boolean>(false);
const [creating, setCreating] = useState<boolean>(false);
const firstAvailableGPIO = useRef<number>(undefined);
const { data: sensorData, send: fetchSensorData } = useRequest(readSensorData, {
const { data: sensorData, send: fetchSensorData } = useRequest(
() => readSensorData(),
{
initialData: {
ts: [],
as: [],
analog_enabled: false,
available_gpios: [] as number[],
platform: 'ESP32'
}
}).onSuccess((event) => {
// store the first available GPIO in a ref
if (event.data.available_gpios.length > 0) {
firstAvailableGPIO.current = event.data.available_gpios[0];
}
});
);
const { send: sendTemperatureSensor } = useRequest(
(data: WriteTemperatureSensor) => writeTemperatureSensor(data),
@@ -158,18 +86,118 @@ const Sensors = () => {
}
);
const intervalCallback = useCallback(() => {
useInterval(() => {
if (!temperatureDialogOpen && !analogDialogOpen) {
void fetchSensorData();
}
}, [temperatureDialogOpen, analogDialogOpen, fetchSensorData]);
});
useInterval(intervalCallback);
const common_theme = useTheme({
BaseRow: `
font-size: 14px;
.td {
height: 32px;
}
`,
HeaderRow: `
text-transform: uppercase;
background-color: black;
color: #90CAF9;
.th {
border-bottom: 1px solid #565656;
}
.th {
height: 36px;
}
`,
Row: `
background-color: #1e1e1e;
position: relative;
cursor: pointer;
.td {
padding: 8px;
border-bottom: 1px solid #565656;
}
&:hover .td {
background-color: #177ac9;
}
`,
Cell: `
&:last-of-type {
text-align: right;
},
`
});
const temperature_theme = useTheme([common_theme, temperature_theme_config]);
const analog_theme = useTheme([common_theme, analog_theme_config]);
const temperature_theme = useTheme([
common_theme,
{
Table: `
--data-table-library_grid-template-columns: minmax(0, 1fr) 35%;
`
}
]);
const getSortIcon = useCallback((state: State, sortKey: unknown) => {
const analog_theme = useTheme([
common_theme,
{
Table: `
--data-table-library_grid-template-columns: 80px repeat(1, minmax(0, 1fr)) 120px 110px;
`
}
]);
const RenderTemperatureSensors = () => (
<Table
data={{ nodes: sensorData.ts }}
theme={temperature_theme}
sort={temperature_sort}
layout={{ custom: true }}
>
{(tableList: TemperatureSensor[]) => (
<>
<Header>
<HeaderRow>
<HeaderCell resize>
<Button
fullWidth
style={{ fontSize: '14px', justifyContent: 'flex-start' }}
endIcon={getSortIcon(temperature_sort.state, 'NAME')}
onClick={() =>
temperature_sort.fns.onToggleSort({ sortKey: 'NAME' })
}
>
{LL.NAME(0)}
</Button>
</HeaderCell>
<HeaderCell stiff>
<Button
fullWidth
style={{ fontSize: '14px', justifyContent: 'flex-end' }}
endIcon={getSortIcon(temperature_sort.state, 'VALUE')}
onClick={() =>
temperature_sort.fns.onToggleSort({ sortKey: 'VALUE' })
}
>
{LL.VALUE(0)}
</Button>
</HeaderCell>
</HeaderRow>
</Header>
<Body>
{tableList.map((ts: TemperatureSensor) => (
<Row key={ts.id} item={ts} onClick={() => updateTemperatureSensor(ts)}>
<Cell>{ts.n}</Cell>
<Cell>{formatValue(ts.t, ts.u)}</Cell>
</Row>
))}
</Body>
</>
)}
</Table>
);
const getSortIcon = (state: State, sortKey: unknown) => {
if (state.sortKey === sortKey && state.reverse) {
return <KeyboardArrowDownOutlinedIcon />;
}
@@ -177,7 +205,7 @@ const Sensors = () => {
return <KeyboardArrowUpOutlinedIcon />;
}
return <UnfoldMoreOutlinedIcon />;
}, []);
};
const analog_sort = useSort(
{ nodes: sensorData.as },
@@ -190,20 +218,11 @@ const Sensors = () => {
},
sortToggleType: SortToggleType.AlternateWithReset,
sortFns: {
GPIO: (array) =>
[...array].sort(
(a, b) => ((a as AnalogSensor)?.g ?? 0) - ((b as AnalogSensor)?.g ?? 0)
),
NAME: (array) =>
[...array].sort((a, b) =>
((a as AnalogSensor)?.n ?? '').localeCompare(
(b as AnalogSensor)?.n ?? ''
)
),
TYPE: (array) =>
[...array].sort((a, b) => (a as AnalogSensor).t - (b as AnalogSensor).t),
VALUE: (array) =>
[...array].sort((a, b) => (a as AnalogSensor).v - (b as AnalogSensor).v)
GPIO: (array) => array.sort((a, b) => a.g - b.g),
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
NAME: (array) => array.sort((a, b) => a.n.localeCompare(b.n)),
TYPE: (array) => array.sort((a, b) => a.t - b.t),
VALUE: (array) => array.sort((a, b) => a.v - b.v)
}
}
);
@@ -219,45 +238,34 @@ const Sensors = () => {
},
sortToggleType: SortToggleType.AlternateWithReset,
sortFns: {
NAME: (array) =>
[...array].sort((a, b) =>
(a as TemperatureSensor).n.localeCompare((b as TemperatureSensor).n)
),
VALUE: (array) =>
[...array].sort(
(a, b) =>
((a as TemperatureSensor).t ?? 0) - ((b as TemperatureSensor).t ?? 0)
)
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
NAME: (array) => array.sort((a, b) => a.n.localeCompare(b.n)),
VALUE: (array) => array.sort((a, b) => a.t - b.t)
}
}
);
useLayoutTitle(LL.SENSORS());
const formatDurationMin = useCallback(
(duration_min: number) => {
const totalMs = duration_min * MS_PER_MINUTE;
const days = Math.trunc(totalMs / MS_PER_DAY);
const hours = Math.trunc(totalMs / MS_PER_HOUR) % 24;
const minutes = Math.trunc(totalMs / MS_PER_MINUTE) % 60;
const formatDurationMin = (duration_min: number) => {
const days = Math.trunc((duration_min * 60000) / 86400000);
const hours = Math.trunc((duration_min * 60000) / 3600000) % 24;
const minutes = Math.trunc((duration_min * 60000) / 60000) % 60;
const parts: string[] = [];
if (days > 0) {
parts.push(LL.NUM_DAYS({ num: days }));
let formatted = '';
if (days) {
formatted += LL.NUM_DAYS({ num: days }) + ' ';
}
if (hours > 0) {
parts.push(LL.NUM_HOURS({ num: hours }));
if (hours) {
formatted += LL.NUM_HOURS({ num: hours }) + ' ';
}
if (minutes > 0) {
parts.push(LL.NUM_MINUTES({ num: minutes }));
if (minutes) {
formatted += LL.NUM_MINUTES({ num: minutes });
}
return parts.join(' ');
},
[LL]
);
return formatted;
};
const formatValue = useCallback(
(value: unknown, uom: DeviceValueUOM) => {
function formatValue(value: unknown, uom: DeviceValueUOM) {
if (value === undefined) {
return '';
}
@@ -286,34 +294,23 @@ const Sensors = () => {
default:
return new Intl.NumberFormat().format(value) + ' ' + DeviceValueUOM_s[uom];
}
},
[formatDurationMin, LL]
);
}
const updateTemperatureSensor = useCallback(
(ts: TemperatureSensor) => {
const updateTemperatureSensor = (ts: TemperatureSensor) => {
if (me.admin) {
ts.o_n = ts.n;
setSelectedTemperatureSensor(ts);
setTemperatureDialogOpen(true);
}
},
[me.admin]
);
};
const onTemperatureDialogClose = useCallback(() => {
const onTemperatureDialogClose = () => {
setTemperatureDialogOpen(false);
void fetchSensorData();
}, [fetchSensorData]);
};
const onTemperatureDialogSave = useCallback(
async (ts: TemperatureSensor) => {
await sendTemperatureSensor({
id: ts.id,
name: ts.n,
offset: ts.o,
is_system: ts.s
})
const onTemperatureDialogSave = async (ts: TemperatureSensor) => {
await sendTemperatureSensor({ id: ts.id, name: ts.n, offset: ts.o })
.then(() => {
toast.success(LL.UPDATED_OF(LL.SENSOR(1)));
})
@@ -325,51 +322,40 @@ const Sensors = () => {
setSelectedTemperatureSensor(undefined);
void fetchSensorData();
});
},
[sendTemperatureSensor, LL, fetchSensorData]
);
};
const updateAnalogSensor = useCallback(
(as: AnalogSensor) => {
const updateAnalogSensor = (as: AnalogSensor) => {
if (me.admin) {
setCreating(false);
as.o_n = as.n;
setSelectedAnalogSensor(as);
setAnalogDialogOpen(true);
}
},
[me.admin]
);
};
const onAnalogDialogClose = useCallback(() => {
const onAnalogDialogClose = () => {
setAnalogDialogOpen(false);
void fetchSensorData();
}, [fetchSensorData]);
};
const addAnalogSensor = useCallback(() => {
if (firstAvailableGPIO.current === undefined) {
toast.error('No available GPIO found');
return;
}
const addAnalogSensor = () => {
setCreating(true);
setSelectedAnalogSensor({
id: Math.floor(Math.random() * (MAX_TEMP_ID - MIN_TEMP_ID) + MIN_TEMP_ID),
id: Math.floor(Math.random() * (Math.floor(200) - 100) + 100),
n: '',
g: firstAvailableGPIO.current,
u: DeviceValueUOM.NONE,
g: 21, // default GPIO 21 which is safe for all platforms
u: 0,
v: 0,
o: 0,
t: 0,
f: 1,
t: AnalogType.DIGITAL_IN, // default to digital in 1
d: false,
s: false,
o_n: ''
});
setAnalogDialogOpen(true);
}, []);
};
const onAnalogDialogSave = useCallback(
async (as: AnalogSensor) => {
const onAnalogDialogSave = async (as: AnalogSensor) => {
await sendAnalogSensor({
id: as.id,
gpio: as.g,
@@ -378,8 +364,7 @@ const Sensors = () => {
factor: as.f,
uom: as.u,
type: as.t,
deleted: as.d,
is_system: as.s
deleted: as.d
})
.then(() => {
toast.success(LL.UPDATED_OF(LL.ANALOG_SENSOR(2)));
@@ -392,12 +377,9 @@ const Sensors = () => {
setSelectedAnalogSensor(undefined);
void fetchSensorData();
});
},
[sendAnalogSensor, LL, fetchSensorData]
);
};
const RenderAnalogSensors = useMemo(
() => (
const RenderAnalogSensors = () => (
<Table
data={{ nodes: sensorData.as }}
theme={analog_theme}
@@ -411,7 +393,7 @@ const Sensors = () => {
<HeaderCell stiff>
<Button
fullWidth
style={HEADER_BUTTON_STYLE}
style={{ fontSize: '14px', justifyContent: 'flex-start' }}
endIcon={getSortIcon(analog_sort.state, 'GPIO')}
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'GPIO' })}
>
@@ -421,7 +403,7 @@ const Sensors = () => {
<HeaderCell resize>
<Button
fullWidth
style={HEADER_BUTTON_STYLE}
style={{ fontSize: '14px', justifyContent: 'flex-start' }}
endIcon={getSortIcon(analog_sort.state, 'NAME')}
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'NAME' })}
>
@@ -431,7 +413,7 @@ const Sensors = () => {
<HeaderCell stiff>
<Button
fullWidth
style={HEADER_BUTTON_STYLE}
style={{ fontSize: '14px', justifyContent: 'flex-start' }}
endIcon={getSortIcon(analog_sort.state, 'TYPE')}
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'TYPE' })}
>
@@ -441,11 +423,9 @@ const Sensors = () => {
<HeaderCell stiff>
<Button
fullWidth
style={HEADER_BUTTON_STYLE_END}
style={{ fontSize: '14px', justifyContent: 'flex-end' }}
endIcon={getSortIcon(analog_sort.state, 'VALUE')}
onClick={() =>
analog_sort.fns.onToggleSort({ sortKey: 'VALUE' })
}
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'VALUE' })}
>
{LL.VALUE(0)}
</Button>
@@ -453,24 +433,16 @@ const Sensors = () => {
</HeaderRow>
</Header>
<Body>
{tableList.map((as: AnalogSensor) => (
<Row
style={{ color: as.s ? 'grey' : 'inherit' }}
key={as.id}
item={as}
onClick={() => updateAnalogSensor(as)}
>
<Cell stiff>{as.g}</Cell>
<Cell>{as.n}</Cell>
<Cell stiff>{AnalogTypeNames[as.t - 1]} </Cell>
{(as.t === AnalogType.DIGITAL_OUT &&
as.g !== GPIO_25 &&
as.g !== GPIO_26) ||
as.t === AnalogType.DIGITAL_IN ||
as.t === AnalogType.PULSE ? (
<Cell stiff>{as.v ? LL.ON() : LL.OFF()}</Cell>
{tableList.map((a: AnalogSensor) => (
<Row key={a.id} item={a} onClick={() => updateAnalogSensor(a)}>
<Cell stiff>{a.g}</Cell>
<Cell>{a.n}</Cell>
<Cell stiff>{AnalogTypeNames[a.t]} </Cell>
{(a.t === AnalogType.DIGITAL_OUT && a.g !== 25 && a.g !== 26) ||
a.t === AnalogType.DIGITAL_IN ? (
<Cell stiff>{a.v ? LL.ON() : LL.OFF()}</Cell>
) : (
<Cell stiff>{formatValue(as.v, as.u)}</Cell>
<Cell stiff>{a.t ? formatValue(a.v, a.u) : ''}</Cell>
)}
</Row>
))}
@@ -478,90 +450,14 @@ const Sensors = () => {
</>
)}
</Table>
),
[
analog_sort,
analog_theme,
getSortIcon,
sensorData.as,
LL,
updateAnalogSensor,
formatValue
]
);
const RenderTemperatureSensors = useMemo(
() => (
<Table
data={{ nodes: sensorData.ts }}
theme={temperature_theme}
sort={temperature_sort}
layout={{ custom: true }}
>
{(tableList: TemperatureSensor[]) => (
<>
<Header>
<HeaderRow>
<HeaderCell resize>
<Button
fullWidth
style={HEADER_BUTTON_STYLE}
endIcon={getSortIcon(temperature_sort.state, 'NAME')}
onClick={() =>
temperature_sort.fns.onToggleSort({ sortKey: 'NAME' })
}
>
{LL.NAME(0)}
</Button>
</HeaderCell>
<HeaderCell stiff>
<Button
fullWidth
style={HEADER_BUTTON_STYLE_END}
endIcon={getSortIcon(temperature_sort.state, 'VALUE')}
onClick={() =>
temperature_sort.fns.onToggleSort({ sortKey: 'VALUE' })
}
>
{LL.VALUE(0)}
</Button>
</HeaderCell>
</HeaderRow>
</Header>
<Body>
{tableList.map((ts: TemperatureSensor) => (
<Row
style={{ color: ts.s ? 'grey' : 'inherit' }}
key={ts.id}
item={ts}
onClick={() => updateTemperatureSensor(ts)}
>
<Cell>{ts.n}</Cell>
<Cell>{formatValue(ts.t, ts.u)}</Cell>
</Row>
))}
</Body>
</>
)}
</Table>
),
[
temperature_sort,
temperature_theme,
getSortIcon,
sensorData.ts,
LL,
updateTemperatureSensor,
formatValue
]
);
return (
<SectionContent>
<Typography sx={{ pb: 1 }} variant="h6" color="primary">
<Typography sx={{ pb: 1 }} variant="h6" color="secondary">
{LL.TEMP_SENSORS()}
</Typography>
{RenderTemperatureSensors}
<RenderTemperatureSensors />
{selectedTemperatureSensor && (
<DashboardSensorsTemperatureDialog
open={temperatureDialogOpen}
@@ -574,10 +470,10 @@ const Sensors = () => {
)}
/>
)}
<Typography sx={{ pt: 4, pb: 1 }} variant="h6" color="primary">
<Typography sx={{ pt: 4, pb: 1 }} variant="h6" color="secondary">
{LL.ANALOG_SENSORS()}
</Typography>
{RenderAnalogSensors}
<RenderAnalogSensors />
{selectedAnalogSensor && (
<DashboardSensorsAnalogDialog
open={analogDialogOpen}
@@ -585,13 +481,16 @@ const Sensors = () => {
onSave={onAnalogDialogSave}
creating={creating}
selectedItem={selectedAnalogSensor}
analogGPIOList={sensorData.available_gpios}
disabledTypeList={sensorData.exclude_types}
validator={analogSensorItemValidation(sensorData.as, selectedAnalogSensor)}
validator={analogSensorItemValidation(
sensorData.as,
selectedAnalogSensor,
creating,
sensorData.platform
)}
/>
)}
{sensorData?.analog_enabled === true && me.admin && (
<Box mt={2} display="flex" flexWrap="wrap" justifyContent="flex-end">
<Box mt={1} display="flex" flexWrap="wrap" justifyContent="flex-end">
<Button
variant="outlined"
color="primary"

View File

@@ -1,7 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useEffect, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
import DoneIcon from '@mui/icons-material/Done';
import RemoveIcon from '@mui/icons-material/RemoveCircleOutline';
import WarningIcon from '@mui/icons-material/Warning';
import {
@@ -11,9 +10,10 @@ import {
DialogActions,
DialogContent,
DialogTitle,
Grid,
Grid2 as Grid,
InputAdornment,
MenuItem,
TextField,
Typography
} from '@mui/material';
@@ -34,8 +34,6 @@ interface DashboardSensorsAnalogDialogProps {
onSave: (as: AnalogSensor) => void;
creating: boolean;
selectedItem: AnalogSensor;
analogGPIOList: number[];
disabledTypeList: number[];
validator: Schema;
}
@@ -45,111 +43,13 @@ const SensorsAnalogDialog = ({
onSave,
creating,
selectedItem,
analogGPIOList,
disabledTypeList,
validator
}: DashboardSensorsAnalogDialogProps) => {
const { LL } = useI18nContext();
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const [editItem, setEditItem] = useState<AnalogSensor>(selectedItem);
const updateFormValue = updateValue(setEditItem);
const updateFormValue = useMemo(
() =>
updateValue((updater) =>
setEditItem(
(prev) =>
updater(
prev as unknown as Record<string, unknown>
) as unknown as AnalogSensor
)
),
[setEditItem]
);
// Memoize helper functions to check sensor type conditions
const isCounterOrRate = useMemo(
() =>
editItem.t === AnalogType.COUNTER ||
editItem.t === AnalogType.RATE ||
(editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2),
[editItem.t]
);
const isCounter = useMemo(
() =>
editItem.t === AnalogType.COUNTER ||
(editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2),
[editItem.t]
);
const isFreqType = useMemo(
() => editItem.t >= AnalogType.FREQ_0 && editItem.t <= AnalogType.FREQ_2,
[editItem.t]
);
const isPWM = useMemo(
() =>
editItem.t === AnalogType.PWM_0 ||
editItem.t === AnalogType.PWM_1 ||
editItem.t === AnalogType.PWM_2,
[editItem.t]
);
const isDACOutGPIO = useMemo(
() =>
editItem.t === AnalogType.DIGITAL_OUT &&
(editItem.g === 25 || editItem.g === 26),
[editItem.t, editItem.g]
);
const isDigitalOutGPIO = useMemo(
() =>
editItem.t === AnalogType.DIGITAL_OUT &&
editItem.g !== 25 &&
editItem.g !== 26,
[editItem.t, editItem.g]
);
// Memoize menu items to avoid recreation on each render
const analogTypeMenuItems = useMemo(
() =>
AnalogTypeNames.map((val, i) => ({ name: val, value: i + 1 }))
.sort((a, b) => a.name.localeCompare(b.name))
.map(({ name, value }) => (
<MenuItem
key={name}
value={value}
disabled={disabledTypeList?.includes(value)}
>
{name}
</MenuItem>
)),
[disabledTypeList]
);
const uomMenuItems = useMemo(
() =>
DeviceValueUOM_s.map((val, i) => (
<MenuItem key={val} value={i}>
{val}
</MenuItem>
)),
[]
);
const analogGPIOMenuItems = () =>
// add selectedItem.g to the list
[
...(analogGPIOList?.includes(selectedItem.g) || selectedItem.g === undefined
? analogGPIOList
: [selectedItem.g, ...analogGPIOList])
]
.filter((gpio, idx, arr) => arr.indexOf(gpio) === idx)
.sort((a, b) => a - b)
.map((gpio: number) => {
return (
<MenuItem key={gpio} value={gpio}>
{gpio}
</MenuItem>
);
});
// Reset form when dialog opens or selectedItem changes
useEffect(() => {
if (open) {
setFieldErrors(undefined);
@@ -157,16 +57,13 @@ const SensorsAnalogDialog = ({
}
}, [open, selectedItem]);
const handleClose = useCallback(
(_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => {
const handleClose = (_event, reason: 'backdropClick' | 'escapeKeyDown') => {
if (reason !== 'backdropClick') {
onClose();
}
},
[onClose]
);
};
const save = useCallback(async () => {
const save = async () => {
try {
setFieldErrors(undefined);
await validate(validator, editItem);
@@ -174,84 +71,95 @@ const SensorsAnalogDialog = ({
} catch (error) {
setFieldErrors(error as ValidateFieldsError);
}
}, [validator, editItem, onSave]);
};
const remove = useCallback(() => {
onSave({ ...editItem, d: true });
}, [editItem, onSave]);
const dialogTitle = useMemo(
() =>
`${creating ? LL.ADD(1) + ' ' + LL.NEW(0) : LL.EDIT()} ${LL.ANALOG_SENSOR(0)}`,
[creating, LL]
);
const remove = () => {
editItem.d = true;
onSave(editItem);
};
return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
<DialogTitle>{dialogTitle}</DialogTitle>
<DialogTitle>
{creating ? LL.ADD(1) + ' ' + LL.NEW(0) : LL.EDIT()}&nbsp;
{LL.ANALOG_SENSOR(0)}
</DialogTitle>
<DialogContent dividers>
<Grid container spacing={2}>
<ValidatedTextField
name="g"
label="GPIO"
value={editItem.g}
sx={{ width: '9ch' }}
disabled={editItem.s || !creating}
select
onChange={updateFormValue}
>
{analogGPIOMenuItems()}
</ValidatedTextField>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors}
name="g"
label="GPIO"
sx={{ width: '11ch' }}
value={numberValue(editItem.g)}
type="number"
variant="outlined"
onChange={updateFormValue}
/>
</Grid>
{creating && (
<Grid>
<Box color="warning.main" mt={2}>
<Typography variant="body2">{LL.WARN_GPIO()}</Typography>
</Box>
</Grid>
)}
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors}
name="n"
label={LL.NAME(0)}
value={editItem.n}
fullWidth
variant="outlined"
onChange={updateFormValue}
/>
</Grid>
<Grid>
<ValidatedTextField
<TextField
name="t"
label={LL.TYPE(0)}
value={editItem.t}
fullWidth
select
onChange={updateFormValue}
disabled={editItem.s}
>
{analogTypeMenuItems}
</ValidatedTextField>
{AnalogTypeNames.map((val, i) => (
<MenuItem key={val} value={i}>
{val}
</MenuItem>
))}
</TextField>
</Grid>
{(isCounterOrRate ||
isFreqType ||
editItem.t === AnalogType.ADC ||
editItem.t === AnalogType.TIMER) && (
{editItem.t >= AnalogType.COUNTER && editItem.t <= AnalogType.RATE && (
<Grid>
<ValidatedTextField
<TextField
name="u"
label={LL.UNIT()}
value={editItem.u}
sx={{ width: '15ch' }}
select
onChange={updateFormValue}
disabled={editItem.s}
>
{uomMenuItems}
</ValidatedTextField>
{DeviceValueUOM_s.map((val, i) => (
<MenuItem key={val} value={i}>
{val}
</MenuItem>
))}
</TextField>
</Grid>
)}
{editItem.t === AnalogType.ADC && (
<Grid>
<ValidatedTextField
<TextField
name="o"
label={LL.OFFSET()}
value={numberValue(editItem.o)}
type="number"
sx={{ width: '11ch' }}
variant="outlined"
onChange={updateFormValue}
disabled={editItem.s}
slotProps={{
input: {
startAdornment: (
@@ -263,129 +171,93 @@ const SensorsAnalogDialog = ({
/>
</Grid>
)}
{editItem.t === AnalogType.NTC && (
{editItem.t === AnalogType.COUNTER && (
<Grid>
<ValidatedTextField
name="o"
label={LL.OFFSET()}
value={numberValue(editItem.o)}
sx={{ width: '11ch' }}
type="number"
onChange={updateFormValue}
disabled={editItem.s}
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">°C</InputAdornment>
)
},
htmlInput: { min: '-20', max: '20', step: '0.1' }
}}
/>
</Grid>
)}
{isCounter && (
<Grid>
<ValidatedTextField
<TextField
name="o"
label={LL.STARTVALUE()}
value={numberValue(editItem.o)}
type="number"
sx={{ width: '11ch' }}
variant="outlined"
onChange={updateFormValue}
disabled={editItem.s}
slotProps={{
htmlInput: { step: '0.001' }
}}
/>
</Grid>
)}
{editItem.t === AnalogType.RGB && (
{editItem.t >= AnalogType.COUNTER && editItem.t <= AnalogType.RATE && (
<Grid>
<ValidatedTextField
name="o"
label={'RGB ' + LL.VALUE(0)}
value={numberValue(editItem.o)}
type="number"
sx={{ width: '11ch' }}
onChange={updateFormValue}
disabled={editItem.s}
/>
</Grid>
)}
{(isCounterOrRate ||
isFreqType ||
editItem.t === AnalogType.ADC ||
editItem.t === AnalogType.TIMER) && (
<Grid>
<ValidatedTextField
<TextField
name="f"
label={LL.FACTOR()}
value={numberValue(editItem.f)}
sx={{ width: '14ch' }}
sx={{ width: '11ch' }}
type="number"
variant="outlined"
onChange={updateFormValue}
disabled={editItem.s}
slotProps={{
htmlInput: { step: '0.001' }
}}
/>
</Grid>
)}
{isDACOutGPIO && (
{editItem.t === AnalogType.DIGITAL_OUT &&
(editItem.g === 25 || editItem.g === 26) && (
<Grid>
<ValidatedTextField
<TextField
name="o"
label={LL.VALUE(0)}
value={numberValue(editItem.o)}
sx={{ width: '11ch' }}
type="number"
variant="outlined"
onChange={updateFormValue}
disabled={editItem.s}
slotProps={{
htmlInput: { min: '0', max: '255', step: '1' }
}}
/>
</Grid>
)}
{isDigitalOutGPIO && (
{editItem.t === AnalogType.DIGITAL_OUT &&
editItem.g !== 25 &&
editItem.g !== 26 && (
<>
<Grid>
<ValidatedTextField
<TextField
name="o"
label={LL.VALUE(0)}
value={numberValue(editItem.o)}
select
variant="outlined"
onChange={updateFormValue}
disabled={editItem.s}
>
<MenuItem value={0}>{LL.OFF()}</MenuItem>
<MenuItem value={1}>{LL.ON()}</MenuItem>
</ValidatedTextField>
</TextField>
</Grid>
<Grid>
<ValidatedTextField
<TextField
name="f"
label={LL.POLARITY()}
value={editItem.f}
sx={{ width: '15ch' }}
select
onChange={updateFormValue}
disabled={editItem.s}
>
<MenuItem value={1}>{LL.ACTIVEHIGH()}</MenuItem>
<MenuItem value={-1}>{LL.ACTIVELOW()}</MenuItem>
</ValidatedTextField>
</TextField>
</Grid>
<Grid>
<ValidatedTextField
<TextField
name="u"
label={LL.STARTVALUE()}
sx={{ width: '15ch' }}
value={editItem.u}
select
onChange={updateFormValue}
disabled={editItem.s}
>
<MenuItem value={0}>{LL.UNCHANGED()}</MenuItem>
<MenuItem value={1}>
@@ -394,21 +266,23 @@ const SensorsAnalogDialog = ({
<MenuItem value={2}>
{LL.ALWAYS()}&nbsp;{LL.ON()}
</MenuItem>
</ValidatedTextField>
</TextField>
</Grid>
</>
)}
{isPWM && (
{(editItem.t === AnalogType.PWM_0 ||
editItem.t === AnalogType.PWM_1 ||
editItem.t === AnalogType.PWM_2) && (
<>
<Grid>
<ValidatedTextField
<TextField
name="f"
label={LL.FREQ()}
value={numberValue(editItem.f)}
type="number"
variant="outlined"
sx={{ width: '11ch' }}
onChange={updateFormValue}
disabled={editItem.s}
slotProps={{
input: {
startAdornment: (
@@ -420,14 +294,14 @@ const SensorsAnalogDialog = ({
/>
</Grid>
<Grid>
<ValidatedTextField
<TextField
name="o"
label={LL.DUTY_CYCLE()}
value={numberValue(editItem.o)}
type="number"
sx={{ width: '11ch' }}
variant="outlined"
onChange={updateFormValue}
disabled={editItem.s}
slotProps={{
input: {
startAdornment: (
@@ -440,81 +314,13 @@ const SensorsAnalogDialog = ({
</Grid>
</>
)}
{editItem.t === AnalogType.PULSE && (
<>
<Grid>
<ValidatedTextField
name="o"
label={LL.POLARITY()}
value={editItem.o}
sx={{ width: '11ch' }}
select
onChange={updateFormValue}
disabled={editItem.s}
>
<MenuItem value={0}>{LL.ACTIVEHIGH()}</MenuItem>
<MenuItem value={1}>{LL.ACTIVELOW()}</MenuItem>
</ValidatedTextField>
</Grid>
<Grid>
<ValidatedTextField
name="f"
label="Pulse"
value={numberValue(editItem.f)}
type="number"
sx={{ width: '15ch' }}
onChange={updateFormValue}
disabled={editItem.s}
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">s</InputAdornment>
)
},
htmlInput: { min: '0', max: '10000', step: '0.1' }
}}
/>
</Grid>
</>
)}
</Grid>
{fieldErrors && Object.keys(fieldErrors).length > 0 && (
<Box mt={1}>
{Object.values(fieldErrors).map((errArr, idx) =>
Array.isArray(errArr)
? errArr.map((err, j) => (
<Typography
key={`${idx}-${j}`}
color="error"
variant="caption"
display="block"
>
{err.message}
</Typography>
))
: null
)}
</Box>
)}
{editItem.s && (
<Grid>
<Typography mt={1} color="warning.main" variant="body2">
<WarningIcon
fontSize="small"
sx={{ mr: 1, verticalAlign: 'middle' }}
color="warning"
/>
{LL.SYSTEM(0)} {LL.SENSOR(0)}
</Typography>
</Grid>
)}
</DialogContent>
<DialogActions>
{!creating && (
<Box flexGrow={1} sx={{ '& button': { mt: 0 } }}>
<Button
startIcon={<RemoveIcon />}
disabled={editItem.s}
variant="outlined"
color="warning"
onClick={remove}
@@ -532,7 +338,7 @@ const SensorsAnalogDialog = ({
{LL.CANCEL()}
</Button>
<Button
startIcon={<DoneIcon />}
startIcon={<WarningIcon color="warning" />}
variant="outlined"
onClick={save}
color="primary"

View File

@@ -1,7 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useEffect, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
import DoneIcon from '@mui/icons-material/Done';
import WarningIcon from '@mui/icons-material/Warning';
import {
Box,
@@ -10,7 +9,7 @@ import {
DialogActions,
DialogContent,
DialogTitle,
Grid,
Grid2 as Grid,
InputAdornment,
TextField,
Typography
@@ -34,12 +33,6 @@ interface SensorsTemperatureDialogProps {
validator: Schema;
}
// Constants
const OFFSET_MIN = -5;
const OFFSET_MAX = 5;
const OFFSET_STEP = 0.1;
const TEMP_UNIT = '°C';
const SensorsTemperatureDialog = ({
open,
onClose,
@@ -50,18 +43,7 @@ const SensorsTemperatureDialog = ({
const { LL } = useI18nContext();
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const [editItem, setEditItem] = useState<TemperatureSensor>(selectedItem);
const updateFormValue = useMemo(
() =>
updateValue(
setEditItem as unknown as (
updater: (
prevState: Readonly<Record<string, unknown>>
) => Record<string, unknown>
) => void
),
[setEditItem]
);
const updateFormValue = updateValue(setEditItem);
useEffect(() => {
if (open) {
@@ -70,16 +52,13 @@ const SensorsTemperatureDialog = ({
}
}, [open, selectedItem]);
const handleClose = useCallback(
(_event: React.SyntheticEvent, reason?: string) => {
const handleClose = (_event, reason: 'backdropClick' | 'escapeKeyDown') => {
if (reason !== 'backdropClick') {
onClose();
}
},
[onClose]
);
};
const save = useCallback(async () => {
const save = async () => {
try {
setFieldErrors(undefined);
await validate(validator, editItem);
@@ -87,31 +66,15 @@ const SensorsTemperatureDialog = ({
} catch (error) {
setFieldErrors(error as ValidateFieldsError);
}
}, [validator, editItem, onSave]);
const dialogTitle = useMemo(() => `${LL.EDIT()} ${LL.TEMP_SENSOR()}`, [LL]);
const offsetValue = useMemo(() => numberValue(editItem.o), [editItem.o]);
const slotProps = useMemo(
() => ({
input: {
startAdornment: <InputAdornment position="start">{TEMP_UNIT}</InputAdornment>
},
htmlInput: {
min: OFFSET_MIN,
max: OFFSET_MAX,
step: OFFSET_STEP
}
}),
[]
);
};
return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
<DialogTitle>{dialogTitle}</DialogTitle>
<DialogTitle>
{LL.EDIT()}&nbsp;{LL.TEMP_SENSOR()}
</DialogTitle>
<DialogContent dividers>
<Box color="warning.main" mb={2}>
<Box color="warning.main" p={0} pl={0} pr={0} mt={0} mb={2}>
<Typography variant="body2">
{LL.ID_OF(LL.SENSOR(0))}: {editItem.id}
</Typography>
@@ -119,7 +82,7 @@ const SensorsTemperatureDialog = ({
<Grid container spacing={2}>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors ?? {}}
fieldErrors={fieldErrors}
name="n"
label={LL.NAME(0)}
value={editItem.n}
@@ -131,27 +94,22 @@ const SensorsTemperatureDialog = ({
<TextField
name="o"
label={LL.OFFSET()}
value={offsetValue}
value={numberValue(editItem.o)}
sx={{ width: '11ch' }}
type="number"
variant="outlined"
onChange={updateFormValue}
slotProps={slotProps}
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">°C</InputAdornment>
)
},
htmlInput: { min: '-5', max: '5', step: '0.1' }
}}
/>
</Grid>
</Grid>
{editItem.s && (
<Grid>
<Typography mt={1} color="warning.main" variant="body2">
<WarningIcon
fontSize="small"
sx={{ mr: 1, verticalAlign: 'middle' }}
color="warning"
/>
{LL.SYSTEM(0)} {LL.SENSOR(0)}
</Typography>
</Grid>
)}
</DialogContent>
<DialogActions>
<Button
@@ -163,7 +121,7 @@ const SensorsTemperatureDialog = ({
{LL.CANCEL()}
</Button>
<Button
startIcon={<DoneIcon />}
startIcon={<WarningIcon color="warning" />}
variant="outlined"
onClick={save}
color="primary"

View File

@@ -1,65 +0,0 @@
import { memo, useCallback, useContext } from 'react';
import PersonIcon from '@mui/icons-material/Person';
import {
Avatar,
Box,
Button,
Divider,
List,
ListItem,
ListItemText,
Typography
} from '@mui/material';
import { AuthenticatedContext } from '@/contexts/authentication';
import { SectionContent, useLayoutTitle } from 'components';
import { LanguageSelector } from 'components/inputs';
import { useI18nContext } from 'i18n/i18n-react';
const UserProfileComponent = () => {
const { LL } = useI18nContext();
const { me, signOut } = useContext(AuthenticatedContext);
useLayoutTitle(LL.USER_PROFILE());
const handleSignOut = useCallback(() => {
signOut(true);
}, [signOut]);
return (
<SectionContent>
<List sx={{ flexGrow: 1 }}>
<ListItem disablePadding>
<Avatar sx={{ bgcolor: '#9e9e9e', color: 'white' }}>
<PersonIcon />
</Avatar>
<ListItemText
sx={{ pl: 2, color: '#2196f3' }}
primary={me.username}
secondary={'(' + (me.admin ? LL.ADMINISTRATOR() : LL.GUEST()) + ')'}
/>
</ListItem>
</List>
<Box mt={2} mb={2} display="flex" alignItems="center">
<Typography mr={2} variant="body1" align="center">
{LL.LANGUAGE()}:
</Typography>
<LanguageSelector />
</Box>
<Divider />
<Button
sx={{ mt: 2 }}
variant="outlined"
color="primary"
onClick={handleSignOut}
>
{LL.SIGN_OUT()}
</Button>
</SectionContent>
);
};
const UserProfile = memo(UserProfileComponent);
export default UserProfile;

View File

@@ -2,30 +2,27 @@ import type { TranslationFunctions } from 'i18n/i18n-types';
import { DeviceValueUOM, DeviceValueUOM_s } from './types';
// Cache NumberFormat instances for better performance
const numberFormatter = new Intl.NumberFormat();
const numberFormatterWithDecimal = new Intl.NumberFormat(undefined, {
minimumFractionDigits: 1
});
const formatDurationMin = (LL: TranslationFunctions, duration_min: number) => {
const totalMs = duration_min * 60000;
const days = Math.trunc(totalMs / 86400000);
const hours = Math.trunc(totalMs / 3600000) % 24;
const minutes = Math.trunc(duration_min) % 60;
const days = Math.trunc((duration_min * 60000) / 86400000);
const hours = Math.trunc((duration_min * 60000) / 3600000) % 24;
const minutes = Math.trunc((duration_min * 60000) / 60000) % 60;
const parts: string[] = [];
let formatted = '';
if (days) {
parts.push(LL.NUM_DAYS({ num: days }));
}
if (hours) {
parts.push(LL.NUM_HOURS({ num: hours }));
}
if (minutes) {
parts.push(LL.NUM_MINUTES({ num: minutes }));
formatted += LL.NUM_DAYS({ num: days });
}
return parts.join(' ');
if (hours) {
if (formatted) formatted += ' ';
formatted += LL.NUM_HOURS({ num: hours });
}
if (minutes) {
if (formatted) formatted += ' ';
formatted += LL.NUM_MINUTES({ num: minutes });
}
return formatted;
};
export function formatValue(
@@ -33,21 +30,18 @@ export function formatValue(
value?: unknown,
uom?: DeviceValueUOM
) {
// Handle non-numeric values or missing data
if (typeof value !== 'number' || uom === undefined || value === undefined) {
if (value === undefined || typeof value === 'boolean') {
return '';
}
// Type assertion is safe here since we know it's not a number, boolean, or undefined
return (
(value as string) +
(value === '' || uom === undefined || uom === DeviceValueUOM.NONE
(value === '' || uom === undefined || uom === 0
? ''
: ' ' + DeviceValueUOM_s[uom])
);
}
// Handle numeric values
switch (uom) {
case DeviceValueUOM.HOURS:
return value ? formatDurationMin(LL, value * 60) : LL.NUM_HOURS({ num: 0 });
@@ -56,12 +50,18 @@ export function formatValue(
case DeviceValueUOM.SECONDS:
return LL.NUM_SECONDS({ num: value });
case DeviceValueUOM.NONE:
return numberFormatter.format(value);
return new Intl.NumberFormat().format(value);
case DeviceValueUOM.DEGREES:
case DeviceValueUOM.DEGREES_R:
case DeviceValueUOM.FAHRENHEIT:
return numberFormatterWithDecimal.format(value) + ' ' + DeviceValueUOM_s[uom];
return (
new Intl.NumberFormat(undefined, {
minimumFractionDigits: 1
}).format(value) +
' ' +
DeviceValueUOM_s[uom]
);
default:
return numberFormatter.format(value) + ' ' + DeviceValueUOM_s[uom];
return new Intl.NumberFormat().format(value) + ' ' + DeviceValueUOM_s[uom];
}
}

View File

@@ -43,16 +43,6 @@ export interface Settings {
modbus_port: number;
modbus_max_clients: number;
modbus_timeout: number;
email_enabled: boolean;
email_ssl?: boolean;
email_starttls?: boolean;
email_server: string;
email_port: number;
email_login: string;
email_pass: string;
email_sender: string;
email_recp: string;
email_subject: string;
developer_mode: boolean;
}
@@ -70,7 +60,7 @@ export interface Stat {
}
export interface Activity {
readonly stats: readonly Stat[];
stats: Stat[];
}
export interface Device {
@@ -92,43 +82,38 @@ export interface TemperatureSensor {
t?: number; // temp, optional
o: number; // offset
u: number; // uom
s: boolean; // system sensor flag
o_n?: string;
}
export interface AnalogSensor {
id: number;
g: number; // GPIO
n: string; // name
v: number; // value
u: number; // uom
o: number; // offset
f: number; // factor
t: number; // type
n: string;
v: number;
u: number;
o: number;
f: number;
t: number;
d: boolean; // deleted flag
s: boolean; // system sensor flag
o_n?: string; // original name
o_n?: string;
}
export interface WriteTemperatureSensor {
id: string;
name: string;
offset: number;
is_system: boolean;
}
export interface SensorData {
ts: TemperatureSensor[];
as: AnalogSensor[];
analog_enabled: boolean;
available_gpios: number[];
exclude_types: number[];
platform: string;
}
export interface CoreData {
readonly connected: boolean;
readonly devices: readonly Device[];
connected: boolean;
devices: Device[];
}
export interface DashboardItem {
@@ -137,12 +122,11 @@ export interface DashboardItem {
n?: string; // name, optional
dv?: DeviceValue; // device value, optional
nodes?: DashboardItem[]; // children nodes, optional
parentNode: DashboardItem; // to stop lint errors
}
export interface DashboardData {
readonly connected: boolean; // true if connected to EMS bus
readonly nodes: readonly DashboardItem[];
connected: boolean; // true if connected to EMS bus
nodes: DashboardItem[];
}
export interface DeviceValue {
@@ -155,11 +139,10 @@ export interface DeviceValue {
s?: string; // steps for up/down, optional
m?: number; // min, optional
x?: number; // max, optional
[key: string]: unknown;
}
export interface DeviceData {
readonly nodes: readonly DeviceValue[];
nodes: DeviceValue[];
}
export interface DeviceEntity {
@@ -205,14 +188,13 @@ export enum DeviceValueUOM {
VOLTS,
MBAR,
LH,
CTKWH,
HERTZ
CTKWH
}
export const DeviceValueUOM_s = [
'',
'°C',
'°C Rel',
'°C',
'%',
'l/min',
'kWh',
@@ -236,12 +218,12 @@ export const DeviceValueUOM_s = [
'V',
'mbar',
'l/h',
'ct/kWh',
'Hz'
] as const;
'ct/kWh'
];
export enum AnalogType {
REMOVED = -1,
NOTUSED = 0,
DIGITAL_IN = 1,
COUNTER = 2,
ADC = 3,
@@ -250,45 +232,29 @@ export enum AnalogType {
DIGITAL_OUT = 6,
PWM_0 = 7,
PWM_1 = 8,
PWM_2 = 9,
NTC = 10,
RGB = 11,
PULSE = 12,
FREQ_0 = 13,
FREQ_1 = 14,
FREQ_2 = 15,
CNT_0 = 16,
CNT_1 = 17,
CNT_2 = 18
PWM_2 = 9
}
export const AnalogTypeNames = [
'Digital In', // 1
'Counter', // 2
'ADC In', // 3
'Timer', // 4
'Rate', // 5
'Digital Out', // 6
'PWM 0', // 7
'PWM 1', // 8
'PWM 2', // 9
'NTC Temp', // 10
'RGB Led', // 11
'Pulse', // 12
'Freq 0', // 13
'Freq 1', // 14
'Freq 2', // 15
'Counter 0', // 16
'Counter 1', // 17
'Counter 2' // 18
] as const;
'(disabled)',
'Digital In',
'Counter',
'ADC',
'Timer',
'Rate',
'Digital Out',
'PWM 0',
'PWM 1',
'PWM 2'
];
export const BOARD_PROFILES = {
type BoardProfiles = Record<string, string>;
export const BOARD_PROFILES: BoardProfiles = {
S32: 'BBQKees Gateway S32',
S32S3: 'BBQKees Gateway S3',
E32: 'BBQKees Gateway E32',
E32V2: 'BBQKees Gateway E32 V2',
E32V2_2: 'BBQKees Gateway E32 V2.2',
NODEMCU: 'NodeMCU 32S',
'MH-ET': 'MH-ET Live D1 Mini',
LOLIN: 'Lolin D32',
@@ -297,9 +263,7 @@ export const BOARD_PROFILES = {
C3MINI: 'Wemos C3 Mini',
S2MINI: 'Wemos S2 Mini',
S3MINI: 'Liligo S3'
} as const;
export type BoardProfileKey = keyof typeof BOARD_PROFILES;
};
export interface BoardProfile {
board_profile: string;
@@ -336,7 +300,6 @@ export interface WriteAnalogSensor {
uom: number;
type: number;
deleted: boolean;
is_system: boolean;
}
export enum DeviceEntityMask {
@@ -368,7 +331,7 @@ export interface ScheduleItem {
}
export interface Schedule {
readonly schedule: readonly ScheduleItem[];
schedule: ScheduleItem[];
}
export interface ModuleItem {
@@ -386,7 +349,7 @@ export interface ModuleItem {
}
export interface Modules {
readonly modules: readonly ModuleItem[];
modules: ModuleItem[];
}
export enum ScheduleFlag {
@@ -417,7 +380,6 @@ export interface EntityItem {
value_type: number;
value?: unknown;
writeable: boolean;
hide: boolean;
deleted?: boolean;
o_id?: number;
o_ram?: number;
@@ -431,11 +393,10 @@ export interface EntityItem {
o_deleted?: boolean;
o_writeable?: boolean;
o_value?: unknown;
o_hide?: boolean;
}
export interface Entities {
readonly entities: readonly EntityItem[];
entities: EntityItem[];
}
// matches emsdevice.h DeviceType
@@ -491,4 +452,4 @@ export const DeviceValueTypeNames = [
'ENUM',
'RAW',
'CMD'
] as const;
];

View File

@@ -11,273 +11,376 @@ import type {
TemperatureSensor
} from './types';
// Constants
const ERROR_MESSAGES = {
GPIO_INVALID: 'Must be an valid GPIO port',
NAME_DUPLICATE: 'Name already in use',
GPIO_DUPLICATE: 'GPIO already in use',
VALUE_OUT_OF_RANGE: 'Value out of range',
HEX_REQUIRED: 'Is required and must be in hex format'
} as const;
export const GPIO_VALIDATOR = {
validator(
rule: InternalRuleItem,
value: number,
callback: (error?: string) => void
) {
if (
value &&
(value === 1 ||
(value >= 6 && value <= 11) ||
value === 20 ||
value === 24 ||
(value >= 28 && value <= 31) ||
value > 40 ||
value < 0)
) {
callback('Must be an valid GPIO port');
} else {
callback();
}
}
};
const VALIDATION_LIMITS = {
PORT_MIN: 0,
PORT_MAX: 65535,
MODBUS_MAX_CLIENTS_MIN: 0,
MODBUS_MAX_CLIENTS_MAX: 50,
MODBUS_TIMEOUT_MIN: 100,
MODBUS_TIMEOUT_MAX: 20000,
SYSLOG_MARK_INTERVAL_MIN: 0,
SYSLOG_MARK_INTERVAL_MAX: 3600,
SHOWER_MIN_DURATION_MIN: 10,
SHOWER_MIN_DURATION_MAX: 360,
SHOWER_ALERT_TRIGGER_MIN: 1,
SHOWER_ALERT_TRIGGER_MAX: 20,
SHOWER_ALERT_COLDSHOT_MIN: 1,
SHOWER_ALERT_COLDSHOT_MAX: 10,
REMOTE_TIMEOUT_MIN: 1,
REMOTE_TIMEOUT_MAX: 240,
OFFSET_MIN: 0,
OFFSET_MAX: 255,
COMMAND_MIN: 1,
COMMAND_MAX: 300,
NAME_MAX_LENGTH: 19,
HEX_BASE: 16
} as const;
export const GPIO_VALIDATORR = {
validator(
rule: InternalRuleItem,
value: number,
callback: (error?: string) => void
) {
if (
value &&
(value === 1 ||
(value >= 6 && value <= 11) ||
(value >= 16 && value <= 17) ||
value === 20 ||
value === 24 ||
(value >= 28 && value <= 31) ||
value > 40 ||
value < 0)
) {
callback('Must be an valid GPIO port');
} else {
callback();
}
}
};
type ValidationRules = Array<{
required?: boolean;
message?: string;
[key: string]: unknown;
}>;
export const GPIO_VALIDATORC3 = {
validator(
rule: InternalRuleItem,
value: number,
callback: (error?: string) => void
) {
if (value && ((value >= 11 && value <= 19) || value > 21 || value < 0)) {
callback('Must be an valid GPIO port');
} else {
callback();
}
}
};
export const createSettingsValidator = (settings: Settings) => {
const schema: Record<string, ValidationRules> = {};
export const GPIO_VALIDATORS2 = {
validator(
rule: InternalRuleItem,
value: number,
callback: (error?: string) => void
) {
if (
value &&
((value >= 19 && value <= 20) ||
(value >= 22 && value <= 32) ||
value > 40 ||
value < 0)
) {
callback('Must be an valid GPIO port');
} else {
callback();
}
}
};
// Syslog validations
if (settings.syslog_enabled) {
schema.syslog_host = [
export const GPIO_VALIDATORS3 = {
validator(
rule: InternalRuleItem,
value: number,
callback: (error?: string) => void
) {
if (
value &&
((value >= 19 && value <= 20) ||
(value >= 22 && value <= 37) ||
(value >= 39 && value <= 42) ||
value > 48 ||
value < 0)
) {
callback('Must be an valid GPIO port');
} else {
callback();
}
}
};
export const createSettingsValidator = (settings: Settings) =>
new Schema({
...(settings.board_profile === 'CUSTOM' &&
settings.platform === 'ESP32' && {
led_gpio: [
{ required: true, message: 'LED GPIO is required' },
GPIO_VALIDATOR
],
dallas_gpio: [
{ required: true, message: 'GPIO is required' },
GPIO_VALIDATOR
],
pbutton_gpio: [
{ required: true, message: 'Button GPIO is required' },
GPIO_VALIDATOR
],
tx_gpio: [
{ required: true, message: 'Tx GPIO is required' },
GPIO_VALIDATOR
],
rx_gpio: [{ required: true, message: 'Rx GPIO is required' }, GPIO_VALIDATOR]
}),
...(settings.board_profile === 'CUSTOM' &&
settings.platform === 'ESP32C3' && {
led_gpio: [
{ required: true, message: 'LED GPIO is required' },
GPIO_VALIDATORC3
],
dallas_gpio: [
{ required: true, message: 'GPIO is required' },
GPIO_VALIDATORC3
],
pbutton_gpio: [
{ required: true, message: 'Button GPIO is required' },
GPIO_VALIDATORC3
],
tx_gpio: [
{ required: true, message: 'Tx GPIO is required' },
GPIO_VALIDATORC3
],
rx_gpio: [
{ required: true, message: 'Rx GPIO is required' },
GPIO_VALIDATORC3
]
}),
...(settings.board_profile === 'CUSTOM' &&
settings.platform === 'ESP32S2' && {
led_gpio: [
{ required: true, message: 'LED GPIO is required' },
GPIO_VALIDATORS2
],
dallas_gpio: [
{ required: true, message: 'GPIO is required' },
GPIO_VALIDATORS2
],
pbutton_gpio: [
{ required: true, message: 'Button GPIO is required' },
GPIO_VALIDATORS2
],
tx_gpio: [
{ required: true, message: 'Tx GPIO is required' },
GPIO_VALIDATORS2
],
rx_gpio: [
{ required: true, message: 'Rx GPIO is required' },
GPIO_VALIDATORS2
]
}),
...(settings.board_profile === 'CUSTOM' &&
settings.platform === 'ESP32S3' && {
led_gpio: [
{ required: true, message: 'LED GPIO is required' },
GPIO_VALIDATORS3
],
dallas_gpio: [
{ required: true, message: 'GPIO is required' },
GPIO_VALIDATORS3
],
pbutton_gpio: [
{ required: true, message: 'Button GPIO is required' },
GPIO_VALIDATORS3
],
tx_gpio: [
{ required: true, message: 'Tx GPIO is required' },
GPIO_VALIDATORS3
],
rx_gpio: [
{ required: true, message: 'Rx GPIO is required' },
GPIO_VALIDATORS3
]
}),
...(settings.syslog_enabled && {
syslog_host: [
{ required: true, message: 'Host is required' },
IP_OR_HOSTNAME_VALIDATOR
];
schema.syslog_port = [
],
syslog_port: [
{ required: true, message: 'Port is required' },
{
type: 'number',
min: VALIDATION_LIMITS.PORT_MIN,
max: VALIDATION_LIMITS.PORT_MAX,
message: 'Invalid Port'
}
];
schema.syslog_mark_interval = [
{ type: 'number', min: 0, max: 65535, message: 'Invalid Port' }
],
syslog_mark_interval: [
{ required: true, message: 'Mark interval is required' },
{
type: 'number',
min: VALIDATION_LIMITS.SYSLOG_MARK_INTERVAL_MIN,
max: VALIDATION_LIMITS.SYSLOG_MARK_INTERVAL_MAX,
message: `Must be between ${VALIDATION_LIMITS.SYSLOG_MARK_INTERVAL_MIN} and ${VALIDATION_LIMITS.SYSLOG_MARK_INTERVAL_MAX}`
}
];
}
// Modbus validations
if (settings.modbus_enabled) {
schema.modbus_max_clients = [
{ type: 'number', min: 0, max: 10, message: 'Must be between 0 and 10' }
]
}),
...(settings.modbus_enabled && {
modbus_max_clients: [
{ required: true, message: 'Max clients is required' },
{
type: 'number',
min: VALIDATION_LIMITS.MODBUS_MAX_CLIENTS_MIN,
max: VALIDATION_LIMITS.MODBUS_MAX_CLIENTS_MAX,
message: 'Invalid number'
}
];
schema.modbus_port = [
{ type: 'number', min: 0, max: 50, message: 'Invalid number' }
],
modbus_port: [
{ required: true, message: 'Port is required' },
{
type: 'number',
min: VALIDATION_LIMITS.PORT_MIN,
max: VALIDATION_LIMITS.PORT_MAX,
message: 'Invalid Port'
}
];
schema.modbus_timeout = [
{ type: 'number', min: 0, max: 65535, message: 'Invalid Port' }
],
modbus_timeout: [
{ required: true, message: 'Timeout is required' },
{
type: 'number',
min: VALIDATION_LIMITS.MODBUS_TIMEOUT_MIN,
max: VALIDATION_LIMITS.MODBUS_TIMEOUT_MAX,
message: `Must be between ${VALIDATION_LIMITS.MODBUS_TIMEOUT_MIN} and ${VALIDATION_LIMITS.MODBUS_TIMEOUT_MAX}`
min: 100,
max: 20000,
message: 'Must be between 100 and 20000'
}
];
}
// Shower timer validations
if (settings.shower_timer) {
schema.shower_min_duration = [
]
}),
...(settings.shower_timer && {
shower_min_duration: [
{
type: 'number',
min: VALIDATION_LIMITS.SHOWER_MIN_DURATION_MIN,
max: VALIDATION_LIMITS.SHOWER_MIN_DURATION_MAX,
message: `Time must be between ${VALIDATION_LIMITS.SHOWER_MIN_DURATION_MIN} and ${VALIDATION_LIMITS.SHOWER_MIN_DURATION_MAX} seconds`
min: 10,
max: 360,
message: 'Time must be between 10 and 360 seconds'
}
];
}
// Shower alert validations
if (settings.shower_alert) {
schema.shower_alert_trigger = [
]
}),
...(settings.shower_alert && {
shower_alert_trigger: [
{
type: 'number',
min: VALIDATION_LIMITS.SHOWER_ALERT_TRIGGER_MIN,
max: VALIDATION_LIMITS.SHOWER_ALERT_TRIGGER_MAX,
message: `Time must be between ${VALIDATION_LIMITS.SHOWER_ALERT_TRIGGER_MIN} and ${VALIDATION_LIMITS.SHOWER_ALERT_TRIGGER_MAX} minutes`
min: 1,
max: 20,
message: 'Time must be between 1 and 20 minutes'
}
];
schema.shower_alert_coldshot = [
],
shower_alert_coldshot: [
{
type: 'number',
min: VALIDATION_LIMITS.SHOWER_ALERT_COLDSHOT_MIN,
max: VALIDATION_LIMITS.SHOWER_ALERT_COLDSHOT_MAX,
message: `Time must be between ${VALIDATION_LIMITS.SHOWER_ALERT_COLDSHOT_MIN} and ${VALIDATION_LIMITS.SHOWER_ALERT_COLDSHOT_MAX} seconds`
min: 1,
max: 10,
message: 'Time must be between 1 and 10 seconds'
}
];
}
// Remote timeout validations
if (settings.remote_timeout_en) {
schema.remote_timeout = [
]
}),
...(settings.remote_timeout_en && {
remote_timeout: [
{
type: 'number',
min: VALIDATION_LIMITS.REMOTE_TIMEOUT_MIN,
max: VALIDATION_LIMITS.REMOTE_TIMEOUT_MAX,
message: `Timeout must be between ${VALIDATION_LIMITS.REMOTE_TIMEOUT_MIN} and ${VALIDATION_LIMITS.REMOTE_TIMEOUT_MAX} hours`
}
];
min: 1,
max: 240,
message: 'Timeout must be between 1 and 240 hours'
}
]
})
});
return new Schema(schema);
};
// Generic unique name validator factory
const createUniqueNameValidator = <T extends { name: string }>(
items: T[],
originalName?: string
) => ({
export const uniqueNameValidator = (schedule: ScheduleItem[], o_name?: string) => ({
validator(
_rule: InternalRuleItem,
rule: InternalRuleItem,
name: string,
callback: (error?: string) => void
) {
if (
name !== '' &&
(originalName === undefined ||
originalName.toLowerCase() !== name.toLowerCase()) &&
items.find((item) => item.name.toLowerCase() === name.toLowerCase())
(o_name === undefined || o_name.toLowerCase() !== name.toLowerCase()) &&
schedule.find((si) => si.name.toLowerCase() === name.toLowerCase())
) {
callback(ERROR_MESSAGES.NAME_DUPLICATE);
return;
}
callback('Name already in use');
} else {
callback();
}
});
// Generic field name validator (for cases where the name field has different property names)
const createUniqueFieldNameValidator = <T>(
items: T[],
getName: (item: T) => string,
originalName?: string
) => ({
validator(
_rule: InternalRuleItem,
name: string,
callback: (error?: string) => void
) {
if (
name !== '' &&
(originalName === undefined ||
originalName.toLowerCase() !== name.toLowerCase()) &&
items.find((item) => getName(item).toLowerCase() === name.toLowerCase())
) {
callback(ERROR_MESSAGES.NAME_DUPLICATE);
return;
}
callback();
}
});
const NAME_PATTERN_BASE = '[a-zA-Z0-9_]';
const NAME_PATTERN_MESSAGE = `Must be <${VALIDATION_LIMITS.NAME_MAX_LENGTH + 1} characters: alphanumeric or '_'`;
const NAME_PATTERN = {
type: 'string' as const,
pattern: new RegExp(
`^${NAME_PATTERN_BASE}{0,${VALIDATION_LIMITS.NAME_MAX_LENGTH}}$`
),
message: NAME_PATTERN_MESSAGE
};
const NAME_PATTERN_REQUIRED = {
type: 'string' as const,
pattern: new RegExp(
`^${NAME_PATTERN_BASE}{1,${VALIDATION_LIMITS.NAME_MAX_LENGTH}}$`
),
message: NAME_PATTERN_MESSAGE
};
export const uniqueNameValidator = (schedule: ScheduleItem[], o_name?: string) =>
createUniqueNameValidator(schedule, o_name);
export const schedulerItemValidation = (
schedule: ScheduleItem[],
scheduleItem: ScheduleItem
) =>
new Schema({
name: [NAME_PATTERN, uniqueNameValidator(schedule, scheduleItem.o_name)],
name: [
{
type: 'string',
pattern: /^[a-zA-Z0-9_]{0,19}$/,
message: "Must be <20 characters: alphanumeric or '_'"
},
...[uniqueNameValidator(schedule, scheduleItem.o_name)]
],
cmd: [
{ required: true, message: 'Command is required' },
{
type: 'string',
min: VALIDATION_LIMITS.COMMAND_MIN,
max: VALIDATION_LIMITS.COMMAND_MAX,
message: `Command must be ${VALIDATION_LIMITS.COMMAND_MIN}-${VALIDATION_LIMITS.COMMAND_MAX} characters`
min: 1,
max: 300,
message: 'Command must be 1-300 characters'
}
]
});
export const uniqueCustomNameValidator = (entity: EntityItem[], o_name?: string) =>
createUniqueNameValidator(entity, o_name);
const hexValidator = {
export const uniqueCustomNameValidator = (
entity: EntityItem[],
o_name?: string
) => ({
validator(
_rule: InternalRuleItem,
value: string,
rule: InternalRuleItem,
name: string,
callback: (error?: string) => void
) {
if (!value || Number.isNaN(Number.parseInt(value, VALIDATION_LIMITS.HEX_BASE))) {
callback(ERROR_MESSAGES.HEX_REQUIRED);
return;
}
if (
(o_name === undefined || o_name.toLowerCase() !== name.toLowerCase()) &&
entity.find((ei) => ei.name.toLowerCase() === name.toLowerCase())
) {
callback('Name already in use');
} else {
callback();
}
};
}
});
export const entityItemValidation = (entity: EntityItem[], entityItem: EntityItem) =>
new Schema({
name: [
{ required: true, message: 'Name is required' },
NAME_PATTERN_REQUIRED,
uniqueCustomNameValidator(entity, entityItem.o_name)
{
type: 'string',
pattern: /^[a-zA-Z0-9_]{1,19}$/,
message: "Must be <20 characters: alphanumeric or '_'"
},
...[uniqueCustomNameValidator(entity, entityItem.o_name)]
],
device_id: [
{
validator(
rule: InternalRuleItem,
value: string,
callback: (error?: string) => void
) {
if (isNaN(parseInt(value, 16))) {
callback('Is required and must be in hex format');
}
callback();
}
}
],
type_id: [
{
validator(
rule: InternalRuleItem,
value: string,
callback: (error?: string) => void
) {
if (isNaN(parseInt(value, 16))) {
callback('Is required and must be in hex format');
}
callback();
}
}
],
device_id: [hexValidator],
type_id: [hexValidator],
offset: [
{ required: true, message: 'Offset is required' },
{
type: 'number',
min: VALIDATION_LIMITS.OFFSET_MIN,
max: VALIDATION_LIMITS.OFFSET_MAX,
message: `Must be between ${VALIDATION_LIMITS.OFFSET_MIN} and ${VALIDATION_LIMITS.OFFSET_MAX}`
}
{ type: 'number', min: 0, max: 255, message: 'Must be between 0 and 255' }
],
factor: [{ required: true, message: 'is required' }]
});
@@ -285,34 +388,93 @@ export const entityItemValidation = (entity: EntityItem[], entityItem: EntityIte
export const uniqueTemperatureNameValidator = (
sensors: TemperatureSensor[],
o_name?: string
) => createUniqueFieldNameValidator(sensors, (s) => s.n, o_name);
) => ({
validator(rule: InternalRuleItem, n: string, callback: (error?: string) => void) {
if (
(o_name === undefined || o_name.toLowerCase() !== n.toLowerCase()) &&
n !== '' &&
sensors.find((ts) => ts.n.toLowerCase() === n.toLowerCase())
) {
callback('Name already in use');
} else {
callback();
}
}
});
export const temperatureSensorItemValidation = (
sensors: TemperatureSensor[],
sensor: TemperatureSensor
) =>
new Schema({
n: [NAME_PATTERN, uniqueTemperatureNameValidator(sensors, sensor.o_n)]
n: [
{
type: 'string',
pattern: /^[a-zA-Z0-9_]{0,19}$/,
message: "Must be <20 characters: alphanumeric or '_'"
},
...[uniqueTemperatureNameValidator(sensors, sensor.o_n)]
]
});
export const isGPIOUniqueValidator = (sensors: AnalogSensor[]) => ({
validator(
rule: InternalRuleItem,
gpio: number,
callback: (error?: string) => void
) {
if (sensors.find((as) => as.g === gpio)) {
callback('GPIO already in use');
} else {
callback();
}
}
});
export const uniqueAnalogNameValidator = (
sensors: AnalogSensor[],
o_name?: string
) => createUniqueFieldNameValidator(sensors, (s) => s.n, o_name);
) => ({
validator(rule: InternalRuleItem, n: string, callback: (error?: string) => void) {
if (
(o_name === undefined || o_name.toLowerCase() !== n.toLowerCase()) &&
n !== '' &&
sensors.find((as) => as.n.toLowerCase() === n.toLowerCase())
) {
callback('Name already in use');
} else {
callback();
}
}
});
export const analogSensorItemValidation = (
sensors: AnalogSensor[],
sensor: AnalogSensor
) => {
return new Schema({
// name is required and must be unique
sensor: AnalogSensor,
creating: boolean,
platform: string
) =>
new Schema({
n: [
{ required: true, message: 'Name is required' },
NAME_PATTERN,
uniqueAnalogNameValidator(sensors, sensor.o_n)
{
type: 'string',
pattern: /^[a-zA-Z0-9_]{0,19}$/,
message: "Must be <20 characters: alphanumeric or '_'"
},
...[uniqueAnalogNameValidator(sensors, sensor.o_n)]
],
g: [
{ required: true, message: 'GPIO is required' },
platform === 'ESP32S3'
? GPIO_VALIDATORS3
: platform === 'ESP32S2'
? GPIO_VALIDATORS2
: platform === 'ESP32C3'
? GPIO_VALIDATORC3
: GPIO_VALIDATOR,
...(creating ? [isGPIOUniqueValidator(sensors)] : [])
]
});
};
export const deviceValueItemValidation = (dv: DeviceValue) =>
new Schema({
@@ -320,18 +482,17 @@ export const deviceValueItemValidation = (dv: DeviceValue) =>
{ required: true, message: 'Value is required' },
{
validator(
_rule: InternalRuleItem,
rule: InternalRuleItem,
value: unknown,
callback: (error?: string) => void
) {
if (
typeof value === 'number' &&
dv.m !== undefined &&
dv.x !== undefined &&
dv.m &&
dv.x &&
(value < dv.m || value > dv.x)
) {
callback(ERROR_MESSAGES.VALUE_OUT_OF_RANGE);
return;
callback('Value out of range');
}
callback();
}

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from 'react';
import { useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
import WarningIcon from '@mui/icons-material/Warning';
@@ -27,19 +27,6 @@ export const isAPEnabled = ({ provision_mode }: APSettingsType) =>
provision_mode === APProvisionMode.AP_MODE_ALWAYS ||
provision_mode === APProvisionMode.AP_MODE_DISCONNECTED;
// Efficient range function without recursion
const createRange = (start: number, end: number): number[] => {
const result: number[] = [];
for (let i = start; i <= end; i++) {
result.push(i);
}
return result;
};
// Pre-computed ranges for better performance
const CHANNEL_RANGE = createRange(1, 14);
const MAX_CLIENTS_RANGE = createRange(1, 9);
const APSettings = () => {
const {
loadData,
@@ -63,24 +50,19 @@ const APSettings = () => {
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const updateFormValue = useMemo(
() =>
updateValueDirty(
origData as unknown as Record<string, unknown>,
const updateFormValue = updateValueDirty(
origData,
dirtyFlags,
setDirtyFlags,
updateDataValue as (value: unknown) => void
),
[origData, dirtyFlags, setDirtyFlags, updateDataValue]
updateDataValue
);
// Memoize AP enabled state
const apEnabled = useMemo(() => (data ? isAPEnabled(data) : false), [data]);
// Memoize validation and submit handler
const validateAndSubmit = useCallback(async () => {
if (!data) return;
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage} />;
}
const validateAndSubmit = async () => {
try {
setFieldErrors(undefined);
await validate(createAPSettingsValidator(data), data);
@@ -88,17 +70,17 @@ const APSettings = () => {
} catch (error) {
setFieldErrors(error as ValidateFieldsError);
}
}, [data, saveData]);
};
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
// no lodash - https://asleepace.com/blog/typescript-range-without-a-loop/
function range(a: number, b: number): number[] {
return a < b ? [a, ...range(a + 1, b)] : [b];
}
return (
<>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
fieldErrors={fieldErrors}
name="provision_mode"
label={LL.AP_PROVIDE() + '...'}
value={data.provision_mode}
@@ -118,10 +100,10 @@ const APSettings = () => {
{LL.AP_PROVIDE_TEXT_3()}
</MenuItem>
</ValidatedTextField>
{apEnabled && (
{isAPEnabled(data) && (
<>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
fieldErrors={fieldErrors}
name="ssid"
label={LL.ACCESS_POINT(2) + ' SSID'}
fullWidth
@@ -131,7 +113,7 @@ const APSettings = () => {
margin="normal"
/>
<ValidatedPasswordField
fieldErrors={fieldErrors || {}}
fieldErrors={fieldErrors}
name="password"
label={LL.ACCESS_POINT(2) + ' ' + LL.PASSWORD()}
fullWidth
@@ -141,7 +123,7 @@ const APSettings = () => {
margin="normal"
/>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
fieldErrors={fieldErrors}
name="channel"
label={LL.AP_PREFERRED_CHANNEL()}
value={numberValue(data.channel)}
@@ -152,7 +134,7 @@ const APSettings = () => {
onChange={updateFormValue}
margin="normal"
>
{CHANNEL_RANGE.map((i) => (
{range(1, 14).map((i) => (
<MenuItem key={i} value={i}>
{i}
</MenuItem>
@@ -169,7 +151,7 @@ const APSettings = () => {
label={LL.AP_HIDE_SSID()}
/>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
fieldErrors={fieldErrors}
name="max_clients"
label={LL.AP_MAX_CLIENTS()}
value={numberValue(data.max_clients)}
@@ -180,14 +162,14 @@ const APSettings = () => {
onChange={updateFormValue}
margin="normal"
>
{MAX_CLIENTS_RANGE.map((i) => (
{range(1, 9).map((i) => (
<MenuItem key={i} value={i}>
{i}
</MenuItem>
))}
</ValidatedTextField>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
fieldErrors={fieldErrors}
name="local_ip"
label={LL.AP_LOCAL_IP()}
fullWidth
@@ -197,7 +179,7 @@ const APSettings = () => {
margin="normal"
/>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
fieldErrors={fieldErrors}
name="gateway_ip"
label={LL.NETWORK_GATEWAY()}
fullWidth
@@ -207,7 +189,7 @@ const APSettings = () => {
margin="normal"
/>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
fieldErrors={fieldErrors}
name="subnet_mask"
label={LL.NETWORK_SUBNET()}
fullWidth

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from 'react';
import { useState } from 'react';
import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel';
@@ -9,7 +9,7 @@ import {
Button,
Checkbox,
Divider,
Grid,
Grid2 as Grid,
InputAdornment,
MenuItem,
TextField,
@@ -28,7 +28,6 @@ import {
FormLoader,
MessageBox,
SectionContent,
ValidatedPasswordField,
ValidatedTextField,
useLayoutTitle
} from 'components';
@@ -38,13 +37,13 @@ import { validate } from 'validators';
import { API, getBoardProfile, readSettings, writeSettings } from '../../api/app';
import { BOARD_PROFILES } from '../main/types';
import type { APIcall, BoardProfileKey, Settings } from '../main/types';
import type { APIcall, Settings } from '../main/types';
import { createSettingsValidator } from '../main/validators';
export function boardProfileSelectItems() {
return Object.keys(BOARD_PROFILES).map((code) => (
<MenuItem key={code} value={code}>
{BOARD_PROFILES[code as BoardProfileKey]}
{BOARD_PROFILES[code]}
</MenuItem>
));
}
@@ -73,10 +72,10 @@ const ApplicationSettings = () => {
const { LL } = useI18nContext();
const updateFormValue = updateValueDirty(
origData as unknown as Record<string, unknown>,
origData,
dirtyFlags,
setDirtyFlags,
updateDataValue as (value: unknown) => void
updateDataValue
);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
@@ -107,49 +106,39 @@ const ApplicationSettings = () => {
});
});
// Memoized input props to prevent recreation on every render
const SecondsInputProps = useMemo(
() => ({
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
}),
[LL]
);
const MinutesInputProps = useMemo(
() => ({
endAdornment: <InputAdornment position="end">{LL.MINUTES()}</InputAdornment>
}),
[LL]
);
const HoursInputProps = useMemo(
() => ({
endAdornment: <InputAdornment position="end">{LL.HOURS()}</InputAdornment>
}),
[LL]
);
const doRestart = useCallback(async () => {
const doRestart = async () => {
setRestarting(true);
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
(error: Error) => {
toast.error(error.message);
}
);
}, [sendAPI]);
};
const updateBoardProfile = useCallback(
async (board_profile: string) => {
const updateBoardProfile = async (board_profile: string) => {
await readBoardProfile(board_profile).catch((error: Error) => {
toast.error(error.message);
});
},
[readBoardProfile]
);
};
useLayoutTitle(LL.APPLICATION());
const validateAndSubmit = useCallback(async () => {
const SecondsInputProps = {
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
};
const MinutesInputProps = {
endAdornment: <InputAdornment position="end">{LL.MINUTES()}</InputAdornment>
};
const HoursInputProps = {
endAdornment: <InputAdornment position="end">{LL.HOURS()}</InputAdornment>
};
const content = () => {
if (!data || !hardwareData) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage} />;
}
const validateAndSubmit = async () => {
try {
setFieldErrors(undefined);
await validate(createSettingsValidator(data), data);
@@ -158,10 +147,9 @@ const ApplicationSettings = () => {
} finally {
await saveData();
}
}, [data, saveData]);
};
const changeBoardProfile = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const changeBoardProfile = (event: React.ChangeEvent<HTMLInputElement>) => {
const boardProfile = event.target.value;
updateFormValue(event);
if (boardProfile === 'CUSTOM') {
@@ -172,22 +160,12 @@ const ApplicationSettings = () => {
} else {
void updateBoardProfile(boardProfile);
}
},
[data, updateBoardProfile, updateFormValue, updateDataValue]
);
};
const restart = useCallback(async () => {
const restart = async () => {
await validateAndSubmit();
await doRestart();
}, [validateAndSubmit, doRestart]);
// Memoize board profile select items to prevent recreation
const boardProfileItems = useMemo(() => boardProfileSelectItems(), []);
const content = () => {
if (!data || !hardwareData) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
}
};
return (
<>
@@ -241,7 +219,7 @@ const ApplicationSettings = () => {
<Grid container spacing={2} rowSpacing={0}>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
fieldErrors={fieldErrors}
name="modbus_max_clients"
label={LL.AP_MAX_CLIENTS()}
variant="outlined"
@@ -253,7 +231,7 @@ const ApplicationSettings = () => {
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
fieldErrors={fieldErrors}
name="modbus_port"
label="Port"
variant="outlined"
@@ -265,7 +243,7 @@ const ApplicationSettings = () => {
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
fieldErrors={fieldErrors}
name="modbus_timeout"
label="Timeout"
slotProps={{
@@ -295,7 +273,7 @@ const ApplicationSettings = () => {
<Grid container spacing={2} rowSpacing={0}>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
fieldErrors={fieldErrors}
name="syslog_host"
label="Host"
variant="outlined"
@@ -306,7 +284,7 @@ const ApplicationSettings = () => {
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
fieldErrors={fieldErrors}
name="syslog_port"
label="Port"
variant="outlined"
@@ -329,15 +307,15 @@ const ApplicationSettings = () => {
>
<MenuItem value={-1}>OFF</MenuItem>
<MenuItem value={3}>ERR</MenuItem>
<MenuItem value={4}>WARN</MenuItem>
<MenuItem value={5}>NOTICE</MenuItem>
<MenuItem value={6}>INFO</MenuItem>
<MenuItem value={7}>DEBUG</MenuItem>
<MenuItem value={9}>ALL</MenuItem>
</TextField>
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
fieldErrors={fieldErrors}
name="syslog_mark_interval"
label={LL.MARK_INTERVAL()}
slotProps={{
@@ -352,156 +330,6 @@ const ApplicationSettings = () => {
</Grid>
</Grid>
)}
<Typography color="secondary">eMail</Typography>
<BlockFormControlLabel
control={
<Checkbox
checked={data.email_enabled}
onChange={updateFormValue}
name="email_enabled"
disabled={!hardwareData.psram}
/>
}
label={
<Typography color={!hardwareData.psram ? 'grey' : 'default'}>
Enable eMail notification
{!hardwareData.psram && (
<Typography variant="caption">
&nbsp; &#40;{LL.IS_REQUIRED('PSRAM')}&#41;
</Typography>
)}
</Typography>
}
/>
{data.email_enabled && (
<>
<Grid
container
spacing={2}
direction="row"
justifyContent="flex-start"
alignItems="flex-start"
>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
name="email_server"
label="SMTP Server"
variant="outlined"
value={data.email_server}
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
sx={{ width: '12ch' }}
name="email_port"
variant="outlined"
label="Port"
value={numberValue(data.email_port)}
type="number"
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid size={4} mt={!data.email_ssl && !data.email_starttls ? 0 : 3}>
{!data.email_starttls && (
<BlockFormControlLabel
sx={{ width: '12ch' }}
control={
<Checkbox
checked={data.email_ssl}
onChange={updateFormValue}
name="email_ssl"
disabled={
data.email_starttls || data.email_ssl === undefined
}
/>
}
label="SSL/TLS"
/>
)}
{!data.email_ssl && (
<BlockFormControlLabel
sx={{ width: '12ch' }}
control={
<Checkbox
checked={data.email_starttls}
onChange={updateFormValue}
name="email_starttls"
disabled={
data.email_ssl || data.email_starttls === undefined
}
/>
}
label="STARTTLS"
/>
)}
</Grid>
</Grid>
<Grid container spacing={2} rowSpacing={0}>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
name="email_login"
label="Login"
variant="outlined"
value={data.email_login}
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid>
<ValidatedPasswordField
fieldErrors={fieldErrors || {}}
name="email_pass"
label="Password"
variant="outlined"
value={data.email_pass}
onChange={updateFormValue}
margin="normal"
/>
</Grid>
</Grid>
<Grid container spacing={2} rowSpacing={0}>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
name="email_sender"
label="From"
variant="outlined"
value={data.email_sender}
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
name="email_recp"
label="To"
variant="outlined"
value={data.email_recp}
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
name="email_subject"
label="Subject"
variant="outlined"
value={data.email_subject}
onChange={updateFormValue}
margin="normal"
/>
</Grid>
</Grid>
</>
)}
<Typography sx={{ pb: 1, pt: 2 }} variant="h6" color="primary">
{LL.SENSORS()}
</Typography>
@@ -640,32 +468,24 @@ const ApplicationSettings = () => {
name="board_profile"
label={LL.BOARD_PROFILE()}
value={data.board_profile}
disabled={processingBoard}
disabled={processingBoard || hardwareData.model.startsWith('BBQKees')}
variant="outlined"
onChange={changeBoardProfile}
margin="normal"
select
>
{hardwareData.model.startsWith('BBQKees') ? (
<MenuItem key={hardwareData.board} value={hardwareData.board}>
{BOARD_PROFILES[hardwareData.board as BoardProfileKey]}
</MenuItem>
) : (
boardProfileItems
)}
{(data.board_profile === 'CUSTOM' || data.developer_mode) && <Divider />}
{(data.board_profile === 'CUSTOM' || data.developer_mode) && (
{boardProfileSelectItems()}
<Divider />
<MenuItem key={'CUSTOM'} value={'CUSTOM'}>
{LL.CUSTOM()}&hellip;
</MenuItem>
)}
</TextField>
{data.board_profile === 'CUSTOM' && (
<>
<Grid container spacing={2} rowSpacing={0}>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
fieldErrors={fieldErrors}
name="rx_gpio"
label={LL.GPIO_OF('Rx')}
fullWidth
@@ -678,7 +498,7 @@ const ApplicationSettings = () => {
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
fieldErrors={fieldErrors}
name="tx_gpio"
label={LL.GPIO_OF('Tx')}
fullWidth
@@ -691,7 +511,7 @@ const ApplicationSettings = () => {
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
fieldErrors={fieldErrors}
name="pbutton_gpio"
label={LL.GPIO_OF(LL.BUTTON())}
fullWidth
@@ -704,7 +524,7 @@ const ApplicationSettings = () => {
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
fieldErrors={fieldErrors}
name="dallas_gpio"
label={
LL.GPIO_OF(LL.TEMPERATURE()) + ' (0=' + LL.DISABLED(1) + ')'
@@ -719,7 +539,7 @@ const ApplicationSettings = () => {
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
fieldErrors={fieldErrors}
name="led_gpio"
label={LL.GPIO_OF('LED') + ' (0=' + LL.DISABLED(1) + ')'}
fullWidth
@@ -734,7 +554,7 @@ const ApplicationSettings = () => {
<Grid>
<TextField
name="led_type"
label={'LED ' + LL.TYPE(0)}
label={'LED ' + LL.TYPE()}
value={data.led_type}
fullWidth
variant="outlined"
@@ -761,7 +581,6 @@ const ApplicationSettings = () => {
<MenuItem value={0}>{LL.DISABLED(1)}</MenuItem>
<MenuItem value={1}>LAN8720</MenuItem>
<MenuItem value={2}>TLK110</MenuItem>
<MenuItem value={3}>RTL8201</MenuItem>
</TextField>
</Grid>
</Grid>
@@ -924,7 +743,7 @@ const ApplicationSettings = () => {
{data.remote_timeout_en && (
<Box mt={2}>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
fieldErrors={fieldErrors}
name="remote_timeout"
label={LL.REMOTE_TIMEOUT()}
slotProps={{
@@ -964,7 +783,7 @@ const ApplicationSettings = () => {
{data.shower_timer && (
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
fieldErrors={fieldErrors}
name="shower_min_duration"
label={LL.MIN_DURATION()}
slotProps={{
@@ -982,7 +801,7 @@ const ApplicationSettings = () => {
<>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
fieldErrors={fieldErrors}
name="shower_alert_trigger"
label={LL.TRIGGER_TIME()}
slotProps={{
@@ -998,7 +817,7 @@ const ApplicationSettings = () => {
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
fieldErrors={fieldErrors}
name="shower_alert_coldshot"
label={LL.COLD_SHOT_DURATION()}
slotProps={{
@@ -1017,9 +836,8 @@ const ApplicationSettings = () => {
</Grid>
{restartNeeded && (
<MessageBox level="warning" message={LL.RESTART_TEXT(0)}>
<MessageBox my={2} level="warning" message={LL.RESTART_TEXT(0)}>
<Button
sx={{ ml: 2 }}
startIcon={<PowerSettingsNewIcon />}
variant="contained"
color="error"
@@ -1055,12 +873,10 @@ const ApplicationSettings = () => {
);
};
return restarting ? (
<SystemMonitor />
) : (
return (
<SectionContent>
{blocker ? <BlockNavigation blocker={blocker} /> : null}
{content()}
{restarting ? <SystemMonitor /> : content()}
</SectionContent>
);
};

View File

@@ -1,8 +1,8 @@
import { useCallback, useMemo, useState } from 'react';
import { useState } from 'react';
import { toast } from 'react-toastify';
import DownloadIcon from '@mui/icons-material/GetApp';
import { Box, Button, Grid, Typography } from '@mui/material';
import { Box, Button, Grid2 as Grid, Typography } from '@mui/material';
import * as SystemApi from 'api/system';
import { API, callAction } from 'api/app';
@@ -19,12 +19,6 @@ import {
import { useI18nContext } from 'i18n/i18n-react';
import { saveFile } from 'utils';
interface DownloadButton {
type: string;
label: string | number;
isGridButton: boolean;
}
const DownloadUpload = () => {
const { LL } = useI18nContext();
@@ -41,7 +35,7 @@ const DownloadUpload = () => {
toast.info(LL.DOWNLOAD_SUCCESSFUL());
})
.onError((error) => {
toast.error(String(error.error?.message || 'An error occurred'));
toast.error(error.message);
});
const { send: sendAPI } = useRequest((data: APIcall) => API(data), {
@@ -50,78 +44,24 @@ const DownloadUpload = () => {
const { data, send: loadData, error } = useRequest(SystemApi.readSystemStatus);
const doRestart = useCallback(async () => {
const doRestart = async () => {
setRestarting(true);
try {
await sendAPI({ device: 'system', cmd: 'restart', id: 0 });
} catch (error) {
toast.error((error as Error).message);
setRestarting(false);
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
(error: Error) => {
toast.error(error.message);
}
}, [sendAPI]);
);
};
useLayoutTitle(LL.DOWNLOAD_UPLOAD());
const downloadButtons: DownloadButton[] = useMemo(
() => [
{
type: 'settings',
label: LL.SETTINGS_OF(LL.APPLICATION()),
isGridButton: true
},
{
type: 'customizations',
label: LL.CUSTOMIZATIONS(),
isGridButton: true
},
{
type: 'entities',
label: LL.CUSTOM_ENTITIES(0),
isGridButton: true
},
{
type: 'schedule',
label: LL.SCHEDULE(0),
isGridButton: true
},
{
type: 'systembackup',
label: LL.DOWNLOAD_SYSTEM_BACKUP(),
isGridButton: true
},
{
type: 'allvalues',
label: LL.ALLVALUES(),
isGridButton: false
}
],
[LL]
);
const handleDownload = useCallback(
(type: string) => () => {
void sendExportData(type);
},
[sendExportData]
);
if (restarting) {
return <SystemMonitor />;
}
const content = () => {
if (!data) {
return (
<SectionContent>
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
</SectionContent>
);
return <FormLoader onRetry={loadData} errorMessage={error?.message} />;
}
const gridButtons = downloadButtons.filter((btn) => btn.isGridButton);
const standaloneButton = downloadButtons.find((btn) => !btn.isGridButton);
return (
<SectionContent>
<>
<Typography sx={{ pb: 2 }} variant="h6" color="primary">
{LL.DOWNLOAD(0)}
</Typography>
@@ -129,36 +69,54 @@ const DownloadUpload = () => {
<Typography mb={1} variant="body1" color="warning">
{LL.DOWNLOAD_SETTINGS_TEXT()}.
</Typography>
<Grid container spacing={2}>
{gridButtons.map((button) => (
<Grid key={button.type}>
<Grid container spacing={1}>
<Button
sx={{ ml: 2 }}
startIcon={<DownloadIcon />}
variant="outlined"
color="primary"
onClick={handleDownload(button.type)}
onClick={() => sendExportData('settings')}
>
{button.label}
{LL.SETTINGS_OF(LL.APPLICATION())}
</Button>
</Grid>
))}
</Grid>
<Typography mt={2} mb={1} variant="body1" color="warning">
{LL.DOWNLOAD_SETTINGS_TEXT2()}.
</Typography>
{standaloneButton && (
<Button
sx={{ ml: 2 }}
startIcon={<DownloadIcon />}
variant="outlined"
color="primary"
onClick={handleDownload(standaloneButton.type)}
onClick={() => sendExportData('customizations')}
>
{standaloneButton.label}
{LL.CUSTOMIZATIONS()}
</Button>
<Button
sx={{ ml: 2 }}
startIcon={<DownloadIcon />}
variant="outlined"
color="primary"
onClick={() => sendExportData('entities')}
>
{LL.CUSTOM_ENTITIES(0)}
</Button>
<Button
sx={{ ml: 2 }}
startIcon={<DownloadIcon />}
variant="outlined"
color="primary"
onClick={() => sendExportData('schedule')}
>
{LL.SCHEDULE(0)}
</Button>
</Grid>
<Button
sx={{ ml: 2, mt: 2 }}
startIcon={<DownloadIcon />}
variant="outlined"
color="primary"
onClick={() => sendExportData('allvalues')}
>
{LL.ALLVALUES()}
</Button>
)}
<Typography sx={{ pt: 2, pb: 2 }} variant="h6" color="primary">
{LL.UPLOAD()}
@@ -169,7 +127,12 @@ const DownloadUpload = () => {
</Box>
<SingleUpload text={LL.UPLOAD_DRAG()} doRestart={doRestart} />
</SectionContent>
</>
);
};
return (
<SectionContent>{restarting ? <SystemMonitor /> : content()}</SectionContent>
);
};

View File

@@ -1,14 +1,11 @@
import { useCallback, useMemo, useState } from 'react';
import { toast } from 'react-toastify';
import { useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
import SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore';
import WarningIcon from '@mui/icons-material/Warning';
import {
Box,
Button,
Checkbox,
Grid,
Grid2 as Grid,
InputAdornment,
MenuItem,
TextField,
@@ -33,8 +30,6 @@ import type { MqttSettingsType } from 'types';
import { numberValue, updateValueDirty, useRest } from 'utils';
import { createMqttSettingsValidator, validate } from 'validators';
import { callAction } from '../../api/app';
const MqttSettings = () => {
const {
loadData,
@@ -57,38 +52,23 @@ const MqttSettings = () => {
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const sendResetMQTT = useCallback(() => {
void callAction({ action: 'resetMQTT' })
.then(() => {
toast.success('MQTT ' + LL.REFRESH() + ' successful');
})
.catch((error) => {
toast.error(String(error.error?.message || 'An error occurred'));
});
}, []);
const updateFormValue = useMemo(
() =>
updateValueDirty(
origData as unknown as Record<string, unknown>,
const updateFormValue = updateValueDirty(
origData,
dirtyFlags,
setDirtyFlags,
updateDataValue as (value: unknown) => void
),
[origData, dirtyFlags, setDirtyFlags, updateDataValue]
updateDataValue
);
const SecondsInputProps = useMemo(
() => ({
const SecondsInputProps = {
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
}),
[LL]
);
};
const emptyFieldErrors = useMemo(() => ({}), []);
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage} />;
}
const validateAndSubmit = useCallback(async () => {
if (!data) return;
const validateAndSubmit = async () => {
try {
setFieldErrors(undefined);
await validate(createMqttSettingsValidator(data), data);
@@ -96,40 +76,10 @@ const MqttSettings = () => {
} catch (error) {
setFieldErrors(error as ValidateFieldsError);
}
}, [data, saveData]);
const publishIntervalFields = useMemo(
() => [
{ name: 'publish_time_heartbeat', label: 'Heartbeat', validated: true },
{ name: 'publish_time_boiler', label: LL.MQTT_INT_BOILER(), validated: false },
{
name: 'publish_time_thermostat',
label: LL.MQTT_INT_THERMOSTATS(),
validated: false
},
{ name: 'publish_time_solar', label: LL.MQTT_INT_SOLAR(), validated: false },
{ name: 'publish_time_mixer', label: LL.MQTT_INT_MIXER(), validated: false },
{ name: 'publish_time_water', label: LL.MQTT_INT_WATER(), validated: false },
{ name: 'publish_time_sensor', label: LL.SENSORS(), validated: false },
{ name: 'publish_time_other', label: LL.DEFAULT(0), validated: false }
],
[LL]
);
if (!data) {
return (
<SectionContent>
{blocker ? <BlockNavigation blocker={blocker} /> : null}
<FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />
</SectionContent>
);
}
};
return (
<SectionContent>
{blocker ? <BlockNavigation blocker={blocker} /> : null}
<>
<Box display="flex" gap={2} mb={1}>
<BlockFormControlLabel
control={
<Checkbox
@@ -140,21 +90,10 @@ const MqttSettings = () => {
}
label={LL.ENABLE_MQTT()}
/>
{data.enabled && (
<Button
startIcon={<SettingsBackupRestoreIcon />}
color="secondary"
variant="outlined"
onClick={sendResetMQTT}
>
{LL.REFRESH() + ' MQTT'}
</Button>
)}
</Box>
<Grid container spacing={2} rowSpacing={0}>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors ?? emptyFieldErrors}
fieldErrors={fieldErrors}
name="host"
label={LL.ADDRESS_OF(LL.BROKER())}
multiline
@@ -166,7 +105,7 @@ const MqttSettings = () => {
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors ?? emptyFieldErrors}
fieldErrors={fieldErrors}
name="port"
label="Port"
variant="outlined"
@@ -178,7 +117,7 @@ const MqttSettings = () => {
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors ?? emptyFieldErrors}
fieldErrors={fieldErrors}
name="base"
label={LL.BASE_TOPIC()}
variant="outlined"
@@ -190,7 +129,7 @@ const MqttSettings = () => {
<Grid>
<TextField
name="client_id"
label={`${LL.ID_OF(LL.CLIENT())} (${LL.OPTIONAL()})`}
label={LL.ID_OF(LL.CLIENT()) + ' (' + LL.OPTIONAL() + ')'}
variant="outlined"
value={data.client_id}
onChange={updateFormValue}
@@ -219,7 +158,7 @@ const MqttSettings = () => {
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors ?? emptyFieldErrors}
fieldErrors={fieldErrors}
name="keep_alive"
label="Keep Alive"
slotProps={{
@@ -266,7 +205,6 @@ const MqttSettings = () => {
label={LL.CERT()}
variant="outlined"
value={data.rootCA}
sx={{ width: '50ch' }}
onChange={updateFormValue}
margin="normal"
/>
@@ -316,6 +254,7 @@ const MqttSettings = () => {
}
label={LL.MQTT_RESPONSE()}
/>
{!data.ha_enabled && (
<Grid container spacing={2} rowSpacing={0}>
<Grid>
<BlockFormControlLabel
@@ -324,7 +263,6 @@ const MqttSettings = () => {
name="publish_single"
checked={data.publish_single}
onChange={updateFormValue}
disabled={data.ha_enabled}
/>
}
label={LL.MQTT_PUBLISH_TEXT_1()}
@@ -345,7 +283,9 @@ const MqttSettings = () => {
</Grid>
)}
</Grid>
{/* <Grid container spacing={2} rowSpacing={0}> */}
)}
{!data.publish_single && (
<Grid container spacing={2} rowSpacing={0}>
<Grid>
<BlockFormControlLabel
control={
@@ -353,7 +293,6 @@ const MqttSettings = () => {
name="ha_enabled"
checked={data.ha_enabled}
onChange={updateFormValue}
disabled={data.publish_single}
/>
}
label={LL.MQTT_PUBLISH_TEXT_3()}
@@ -398,78 +337,136 @@ const MqttSettings = () => {
>
<MenuItem value={0}>{LL.MQTT_ENTITY_FORMAT_0()}</MenuItem>
<MenuItem value={3}>
{LL.MQTT_ENTITY_FORMAT_1()}&nbsp;(v3.5)
{LL.MQTT_ENTITY_FORMAT_1()}&nbsp;(v3.6)
</MenuItem>
<MenuItem value={4}>
{LL.MQTT_ENTITY_FORMAT_2()}&nbsp;(v3.5)
</MenuItem>
<MenuItem value={1}>
{LL.MQTT_ENTITY_FORMAT_1()}&nbsp;(latest)
</MenuItem>
<MenuItem value={2}>
{LL.MQTT_ENTITY_FORMAT_2()}&nbsp;(latest)
{LL.MQTT_ENTITY_FORMAT_2()}&nbsp;(v3.6)
</MenuItem>
<MenuItem value={1}>{LL.MQTT_ENTITY_FORMAT_1()}</MenuItem>
<MenuItem value={2}>{LL.MQTT_ENTITY_FORMAT_2()}</MenuItem>
</TextField>
</Grid>
<Grid>
{data.discovery_type === 0 && (
<TextField
name="ha_number_mode"
label={LL.MQTT_INPUT_NUMBER_FORMAT()}
value={data.ha_number_mode}
variant="outlined"
onChange={updateFormValue}
sx={{ width: '20ch' }}
margin="normal"
select
>
<MenuItem value={0}>Box</MenuItem>
<MenuItem value={1}>Slider</MenuItem>
</TextField>
</Grid>
)}
</Grid>
</Grid>
)}
<Typography sx={{ pt: 2 }} variant="h6" color="primary">
{LL.MQTT_PUBLISH_INTERVALS()}&nbsp;(0=auto)
</Typography>
<Grid container spacing={2} rowSpacing={0}>
{publishIntervalFields.map((field) => (
<Grid key={field.name}>
{field.validated ? (
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors ?? emptyFieldErrors}
name={field.name}
label={field.label}
fieldErrors={fieldErrors}
name="publish_time_heartbeat"
label="Heartbeat"
slotProps={{
input: SecondsInputProps
}}
variant="outlined"
value={numberValue(
data[field.name as keyof MqttSettingsType] as number
)}
value={numberValue(data.publish_time_heartbeat)}
type="number"
onChange={updateFormValue}
margin="normal"
/>
) : (
<TextField
name={field.name}
label={field.label}
variant="outlined"
value={numberValue(
data[field.name as keyof MqttSettingsType] as number
)}
type="number"
onChange={updateFormValue}
margin="normal"
slotProps={{
input: SecondsInputProps
}}
/>
)}
</Grid>
))}
<Grid>
<TextField
name="publish_time_boiler"
label={LL.MQTT_INT_BOILER()}
variant="outlined"
value={numberValue(data.publish_time_boiler)}
type="number"
onChange={updateFormValue}
margin="normal"
slotProps={{
input: SecondsInputProps
}}
/>
</Grid>
<Grid>
<TextField
name="publish_time_thermostat"
label={LL.MQTT_INT_THERMOSTATS()}
variant="outlined"
value={numberValue(data.publish_time_thermostat)}
type="number"
onChange={updateFormValue}
margin="normal"
slotProps={{
input: SecondsInputProps
}}
/>
</Grid>
<Grid>
<TextField
name="publish_time_solar"
label={LL.MQTT_INT_SOLAR()}
variant="outlined"
value={numberValue(data.publish_time_solar)}
type="number"
onChange={updateFormValue}
margin="normal"
slotProps={{
input: SecondsInputProps
}}
/>
</Grid>
<Grid>
<TextField
name="publish_time_mixer"
label={LL.MQTT_INT_MIXER()}
variant="outlined"
value={numberValue(data.publish_time_mixer)}
type="number"
onChange={updateFormValue}
margin="normal"
slotProps={{
input: SecondsInputProps
}}
/>
</Grid>
<Grid>
<TextField
name="publish_time_water"
label={LL.MQTT_INT_WATER()}
variant="outlined"
value={numberValue(data.publish_time_water)}
type="number"
onChange={updateFormValue}
margin="normal"
slotProps={{
input: SecondsInputProps
}}
/>
</Grid>
<Grid>
<TextField
name="publish_time_sensor"
label={LL.TEMP_SENSORS()}
variant="outlined"
value={numberValue(data.publish_time_sensor)}
type="number"
onChange={updateFormValue}
margin="normal"
slotProps={{
input: SecondsInputProps
}}
/>
</Grid>
<Grid>
<TextField
name="publish_time_other"
label={LL.DEFAULT(0)}
variant="outlined"
value={numberValue(data.publish_time_other)}
type="number"
onChange={updateFormValue}
margin="normal"
slotProps={{
input: SecondsInputProps
}}
/>
</Grid>
</Grid>
{dirtyFlags && dirtyFlags.length !== 0 && (
<ButtonRow>
@@ -496,6 +493,13 @@ const MqttSettings = () => {
</ButtonRow>
)}
</>
);
};
return (
<SectionContent>
{blocker ? <BlockNavigation blocker={blocker} /> : null}
{content()}
</SectionContent>
);
};

View File

@@ -1,27 +1,12 @@
import { useCallback, useMemo, useState } from 'react';
import { toast } from 'react-toastify';
import { useState } from 'react';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
import CancelIcon from '@mui/icons-material/Cancel';
import WarningIcon from '@mui/icons-material/Warning';
import {
Box,
Button,
Checkbox,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
MenuItem,
TextField,
Typography
} from '@mui/material';
import { Button, Checkbox, MenuItem } from '@mui/material';
import * as NTPApi from 'api/ntp';
import { readNTPSettings } from 'api/ntp';
import { dialogStyle } from 'CustomTheme';
import { useRequest } from 'alova/client';
import { updateState } from 'alova/client';
import type { ValidateFieldsError } from 'async-validator';
import {
@@ -34,12 +19,12 @@ import {
useLayoutTitle
} from 'components';
import { useI18nContext } from 'i18n/i18n-react';
import type { NTPSettingsType, Time } from 'types';
import { formatLocalDateTime, updateValueDirty, useRest } from 'utils';
import type { NTPSettingsType } from 'types';
import { updateValueDirty, useRest } from 'utils';
import { validate } from 'validators';
import { NTP_SETTINGS_VALIDATOR } from 'validators/ntp';
import { TIME_ZONES, selectedTimeZone, useTimeZoneSelectItems } from './TZ';
import { TIME_ZONES, selectedTimeZone, timeZoneSelectItems } from './TZ';
const NTPSettings = () => {
const {
@@ -61,73 +46,21 @@ const NTPSettings = () => {
const { LL } = useI18nContext();
useLayoutTitle('NTP');
// Memoized timezone select items for better performance
const timeZoneItems = useTimeZoneSelectItems();
// Memoized selected timezone value
const selectedTzValue = useMemo(
() => (data ? selectedTimeZone(data.tz_label, data.tz_format) : undefined),
[data?.tz_label, data?.tz_format]
);
const [localTime, setLocalTime] = useState<string>('');
const [settingTime, setSettingTime] = useState<boolean>(false);
const [processing, setProcessing] = useState<boolean>(false);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const { send: updateTime } = useRequest(
(local_time: Time) => NTPApi.updateTime(local_time),
{
immediate: false
}
);
// Memoize updateFormValue to prevent recreation on every render
const updateFormValue = useMemo(
() =>
updateValueDirty(
origData as unknown as Record<string, unknown>,
const updateFormValue = updateValueDirty(
origData,
dirtyFlags,
setDirtyFlags,
updateDataValue as (value: unknown) => void
),
[origData, dirtyFlags, setDirtyFlags, updateDataValue]
updateDataValue
);
// Memoize updateLocalTime handler
const updateLocalTime = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => setLocalTime(event.target.value),
[]
);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
// Memoize openSetTime handler
const openSetTime = useCallback(() => {
setLocalTime(formatLocalDateTime(new Date()));
setSettingTime(true);
}, []);
// Memoize configureTime handler
const configureTime = useCallback(async () => {
setProcessing(true);
try {
await updateTime({ local_time: formatLocalDateTime(new Date(localTime)) });
toast.success(LL.TIME_SET());
setSettingTime(false);
await loadData();
} catch {
toast.error(LL.PROBLEM_UPDATING());
} finally {
setProcessing(false);
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage} />;
}
}, [localTime, updateTime, LL, loadData]);
// Memoize close dialog handler
const handleCloseSetTime = useCallback(() => setSettingTime(false), []);
// Memoize validate and submit handler
const validateAndSubmit = useCallback(async () => {
if (!data) return;
const validateAndSubmit = async () => {
try {
setFieldErrors(undefined);
await validate(NTP_SETTINGS_VALIDATOR, data);
@@ -135,26 +68,16 @@ const NTPSettings = () => {
} catch (error) {
setFieldErrors(error as ValidateFieldsError);
}
}, [data, saveData]);
};
// Memoize timezone change handler
const changeTimeZone = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const changeTimeZone = (event: React.ChangeEvent<HTMLInputElement>) => {
void updateState(readNTPSettings(), (settings: NTPSettingsType) => ({
...settings,
tz_label: event.target.value,
tz_format: TIME_ZONES[event.target.value]
}));
updateFormValue(event);
},
[updateFormValue]
);
// Memoize render content to prevent unnecessary re-renders
const renderContent = useMemo(() => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
}
};
return (
<>
@@ -169,7 +92,7 @@ const NTPSettings = () => {
label={LL.ENABLE_NTP()}
/>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
fieldErrors={fieldErrors}
name="server"
label={LL.NTP_SERVER()}
fullWidth
@@ -179,37 +102,19 @@ const NTPSettings = () => {
margin="normal"
/>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
fieldErrors={fieldErrors}
name="tz_label"
label={LL.TIME_ZONE()}
fullWidth
variant="outlined"
value={selectedTzValue}
value={selectedTimeZone(data.tz_label, data.tz_format)}
onChange={changeTimeZone}
margin="normal"
select
>
<MenuItem disabled>{LL.TIME_ZONE()}...</MenuItem>
{timeZoneItems}
{timeZoneSelectItems()}
</ValidatedTextField>
<Box display="flex" flexWrap="wrap">
{!data.enabled && !dirtyFlags.length && (
<Box flexWrap="nowrap" whiteSpace="nowrap">
<ButtonRow>
<Button
onClick={openSetTime}
variant="outlined"
color="primary"
startIcon={<AccessTimeIcon />}
>
{LL.SET_TIME(0)}
</Button>
</ButtonRow>
</Box>
)}
</Box>
{dirtyFlags && dirtyFlags.length !== 0 && (
<ButtonRow>
<Button
@@ -236,66 +141,12 @@ const NTPSettings = () => {
)}
</>
);
}, [
data,
errorMessage,
loadData,
updateFormValue,
fieldErrors,
selectedTzValue,
changeTimeZone,
timeZoneItems,
dirtyFlags,
openSetTime,
saving,
validateAndSubmit,
LL
]);
};
return (
<SectionContent>
{blocker ? <BlockNavigation blocker={blocker} /> : null}
{renderContent}
<Dialog sx={dialogStyle} open={settingTime} onClose={handleCloseSetTime}>
<DialogTitle>{LL.SET_TIME(1)}</DialogTitle>
<DialogContent dividers>
<Box color="warning.main" p={0} pl={0} pr={0} mt={0} mb={2}>
<Typography variant="body2">{LL.SET_TIME_TEXT()}</Typography>
</Box>
<TextField
label={LL.LOCAL_TIME(0)}
type="datetime-local"
value={localTime}
onChange={updateLocalTime}
disabled={processing}
fullWidth
slotProps={{
inputLabel: {
shrink: true
}
}}
/>
</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={handleCloseSetTime}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<AccessTimeIcon />}
variant="outlined"
onClick={configureTime}
disabled={processing}
color="primary"
>
{LL.UPDATE()}
</Button>
</DialogActions>
</Dialog>
{content()}
</SectionContent>
);
};

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from 'react';
import { useState } from 'react';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
import CancelIcon from '@mui/icons-material/Cancel';
@@ -17,7 +17,6 @@ import {
DialogActions,
DialogContent,
DialogTitle,
Divider,
List
} from '@mui/material';
@@ -30,38 +29,54 @@ import { SectionContent, useLayoutTitle } from 'components';
import ListMenuItem from 'components/layout/ListMenuItem';
import { useI18nContext } from 'i18n/i18n-react';
import SystemMonitor from '../status/SystemMonitor';
const Settings = () => {
const { LL } = useI18nContext();
useLayoutTitle(LL.SETTINGS(0));
const [confirmFactoryReset, setConfirmFactoryReset] = useState(false);
const [restarting, setRestarting] = useState<boolean>();
const [confirmFactoryReset, setConfirmFactoryReset] = useState<boolean>(false);
const { send: sendAPI } = useRequest((data: APIcall) => API(data), {
immediate: false
});
const doFormat = useCallback(async () => {
const doFormat = async () => {
await sendAPI({ device: 'system', cmd: 'format', id: 0 }).then(() => {
setRestarting(true);
setConfirmFactoryReset(false);
});
}, [sendAPI]);
};
const handleFactoryResetClose = useCallback(() => {
setConfirmFactoryReset(false);
}, []);
const renderFactoryResetDialog = () => (
<Dialog
sx={dialogStyle}
open={confirmFactoryReset}
onClose={() => setConfirmFactoryReset(false)}
>
<DialogTitle>{LL.FACTORY_RESET()}</DialogTitle>
<DialogContent dividers>{LL.SYSTEM_FACTORY_TEXT_DIALOG()}</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={() => setConfirmFactoryReset(false)}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<SettingsBackupRestoreIcon />}
variant="outlined"
onClick={doFormat}
color="error"
>
{LL.FACTORY_RESET()}
</Button>
</DialogActions>
</Dialog>
);
const handleFactoryResetClick = useCallback(() => {
setConfirmFactoryReset(true);
}, []);
const content = useMemo(() => {
return (
const content = () => (
<>
<List>
<List sx={{ borderRadius: 3, border: '2px solid grey' }}>
<ListMenuItem
icon={TuneIcon}
bgcolor="#134ba2"
@@ -126,46 +141,13 @@ const Settings = () => {
/>
</List>
<Dialog
sx={dialogStyle}
open={confirmFactoryReset}
onClose={handleFactoryResetClose}
>
<DialogTitle>{LL.FACTORY_RESET()}</DialogTitle>
<DialogContent dividers>{LL.SYSTEM_FACTORY_TEXT_DIALOG()}</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={handleFactoryResetClose}
color="secondary"
>
{LL.CANCEL()}
</Button>
{renderFactoryResetDialog()}
<Box mt={2} display="flex" flexWrap="wrap">
<Button
startIcon={<SettingsBackupRestoreIcon />}
variant="outlined"
onClick={doFormat}
color="error"
>
{LL.FACTORY_RESET()}
</Button>
</DialogActions>
</Dialog>
<Divider />
<Box
mt={2}
display="flex"
justifyContent="flex-end"
flexWrap="nowrap"
whiteSpace="nowrap"
>
<Button
startIcon={<SettingsBackupRestoreIcon />}
variant="outlined"
onClick={handleFactoryResetClick}
onClick={() => setConfirmFactoryReset(true)}
color="error"
>
{LL.FACTORY_RESET()}
@@ -173,16 +155,8 @@ const Settings = () => {
</Box>
</>
);
}, [
LL,
handleFactoryResetClick,
handleFactoryResetClose,
doFormat,
confirmFactoryReset,
restarting
]);
return restarting ? <SystemMonitor /> : <SectionContent>{content}</SectionContent>;
return <SectionContent>{content()}</SectionContent>;
};
export default Settings;

View File

@@ -1,8 +1,8 @@
import { useMemo } from 'react';
import { MenuItem } from '@mui/material';
export const TIME_ZONES: Record<string, string> = {
type TimeZones = Record<string, string>;
export const TIME_ZONES: TimeZones = {
'Africa/Abidjan': 'GMT0',
'Africa/Accra': 'GMT0',
'Africa/Addis_Ababa': 'EAT-3',
@@ -465,33 +465,14 @@ export const TIME_ZONES: Record<string, string> = {
'Pacific/Wallis': 'UNK-12'
};
// Pre-compute sorted timezone labels for better performance
export const TIME_ZONE_LABELS = Object.keys(TIME_ZONES).sort();
export function selectedTimeZone(label: string, format: string) {
return TIME_ZONES[label] === format ? label : undefined;
}
// Memoized version for use in components
export function useTimeZoneSelectItems() {
return useMemo(
() =>
TIME_ZONE_LABELS.map((label) => (
<MenuItem key={label} value={label}>
{label}
</MenuItem>
)),
[]
);
}
// Fallback export for backward compatibility - now memoized
const precomputedTimeZoneItems = TIME_ZONE_LABELS.map((label) => (
export function timeZoneSelectItems() {
return Object.keys(TIME_ZONES).map((label) => (
<MenuItem key={label} value={label}>
{label}
</MenuItem>
));
export function timeZoneSelectItems() {
return precomputedTimeZoneItems;
}

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useMemo, useState } from 'react';
import { useCallback, useState } from 'react';
import {
Navigate,
Route,
@@ -28,13 +28,14 @@ const Network = () => {
[
{
path: '/settings/network/settings',
element: <NetworkSettings />
element: <NetworkSettings />,
dog: 'woof'
},
{ path: '/settings/network/scan', element: <WiFiNetworkScanner /> }
],
useLocation()
);
const routerTab = matchedRoutes?.[0]?.route.path || false;
const routerTab = matchedRoutes?.[0].route.path || false;
const navigate = useNavigate();
@@ -52,17 +53,14 @@ const Network = () => {
setSelectedNetwork(undefined);
}, []);
const contextValue = useMemo(
() => ({
...(selectedNetwork && { selectedNetwork }),
return (
<WiFiConnectionContext.Provider
value={{
selectedNetwork,
selectNetwork,
deselectNetwork
}),
[selectedNetwork, selectNetwork, deselectNetwork]
);
return (
<WiFiConnectionContext.Provider value={contextValue}>
}}
>
<RouterTabs value={routerTab}>
<Tab
value="/settings/network/settings"
@@ -82,4 +80,4 @@ const Network = () => {
);
};
export default memo(Network);
export default Network;

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useContext, useEffect, useState } from 'react';
import { useContext, useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel';
@@ -104,13 +104,19 @@ const NetworkSettings = () => {
origData,
dirtyFlags,
setDirtyFlags,
updateDataValue as (value: unknown) => void
updateDataValue
);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const validateAndSubmit = useCallback(async () => {
if (!data) return;
useEffect(() => deselectNetwork, [deselectNetwork]);
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage} />;
}
const validateAndSubmit = async () => {
try {
setFieldErrors(undefined);
await validate(createNetworkSettingsValidator(data), data);
@@ -119,26 +125,21 @@ const NetworkSettings = () => {
setFieldErrors(error as ValidateFieldsError);
}
deselectNetwork();
}, [data, saveData, deselectNetwork]);
};
const setCancel = useCallback(async () => {
const setCancel = async () => {
deselectNetwork();
await loadData();
}, [deselectNetwork, loadData]);
};
const doRestart = useCallback(async () => {
const doRestart = async () => {
setRestarting(true);
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
(error: Error) => {
toast.error(error.message);
}
);
}, [sendAPI]);
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
}
};
return (
<>
@@ -164,14 +165,14 @@ const NetworkSettings = () => {
selectedNetwork.bssid
}
/>
<IconButton onClick={setCancel} aria-label={LL.CANCEL()}>
<IconButton onClick={setCancel}>
<DeleteIcon />
</IconButton>
</ListItem>
</List>
) : (
<ValidatedTextField
fieldErrors={fieldErrors || {}}
fieldErrors={fieldErrors}
name="ssid"
label={'SSID (' + LL.NETWORK_BLANK_SSID() + ')'}
fullWidth
@@ -182,7 +183,7 @@ const NetworkSettings = () => {
/>
)}
<ValidatedTextField
fieldErrors={fieldErrors || {}}
fieldErrors={fieldErrors}
name="bssid"
label={'BSSID (' + LL.NETWORK_BLANK_BSSID() + ')'}
fullWidth
@@ -193,7 +194,7 @@ const NetworkSettings = () => {
/>
{(!selectedNetwork || !isNetworkOpen(selectedNetwork)) && (
<ValidatedPasswordField
fieldErrors={fieldErrors || {}}
fieldErrors={fieldErrors}
name="password"
label={LL.PASSWORD()}
fullWidth
@@ -250,7 +251,7 @@ const NetworkSettings = () => {
{LL.GENERAL_OPTIONS()}
</Typography>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
fieldErrors={fieldErrors}
name="hostname"
label={LL.HOSTNAME()}
fullWidth
@@ -303,7 +304,7 @@ const NetworkSettings = () => {
{data.static_ip_config && (
<>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
fieldErrors={fieldErrors}
name="local_ip"
label={LL.AP_LOCAL_IP()}
fullWidth
@@ -313,7 +314,7 @@ const NetworkSettings = () => {
margin="normal"
/>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
fieldErrors={fieldErrors}
name="gateway_ip"
label={LL.NETWORK_GATEWAY()}
fullWidth
@@ -323,7 +324,7 @@ const NetworkSettings = () => {
margin="normal"
/>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
fieldErrors={fieldErrors}
name="subnet_mask"
label={LL.NETWORK_SUBNET()}
fullWidth
@@ -333,7 +334,7 @@ const NetworkSettings = () => {
margin="normal"
/>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
fieldErrors={fieldErrors}
name="dns_ip_1"
label="DNS #1"
fullWidth
@@ -343,7 +344,7 @@ const NetworkSettings = () => {
margin="normal"
/>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
fieldErrors={fieldErrors}
name="dns_ip_2"
label="DNS #2"
fullWidth
@@ -355,9 +356,8 @@ const NetworkSettings = () => {
</>
)}
{restartNeeded && (
<MessageBox level="warning" message={LL.RESTART_TEXT(0)}>
<MessageBox my={2} level="warning" message={LL.RESTART_TEXT(0)}>
<Button
sx={{ ml: 2 }}
startIcon={<PowerSettingsNewIcon />}
variant="contained"
color="error"
@@ -397,14 +397,12 @@ const NetworkSettings = () => {
);
};
return restarting ? (
<SystemMonitor />
) : (
return (
<SectionContent>
{blocker ? <BlockNavigation blocker={blocker} /> : null}
{content()}
{restarting ? <SystemMonitor /> : content()}
</SectionContent>
);
};
export default memo(NetworkSettings);
export default NetworkSettings;

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useRef, useState } from 'react';
import { useRef, useState } from 'react';
import PermScanWifiIcon from '@mui/icons-material/PermScanWifi';
import { Button } from '@mui/material';
@@ -48,12 +48,14 @@ const WiFiNetworkScanner = () => {
}
});
const renderNetworkScanner = useCallback(() => {
const renderNetworkScanner = () => {
if (!networkList) {
return <FormLoader errorMessage={errorMessage || ''} />;
return (
<FormLoader message={LL.SCANNING() + '...'} errorMessage={errorMessage} />
);
}
return <WiFiNetworkSelector networkList={networkList} />;
}, [networkList, errorMessage]);
};
return (
<SectionContent>
@@ -73,4 +75,4 @@ const WiFiNetworkScanner = () => {
);
};
export default memo(WiFiNetworkScanner);
export default WiFiNetworkScanner;

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useContext } from 'react';
import { useContext } from 'react';
import LockIcon from '@mui/icons-material/Lock';
import LockOpenIcon from '@mui/icons-material/LockOpen';
@@ -63,8 +63,7 @@ const WiFiNetworkSelector = ({ networkList }: { networkList: WiFiNetworkList })
const wifiConnectionContext = useContext(WiFiConnectionContext);
const renderNetwork = useCallback(
(network: WiFiNetwork) => (
const renderNetwork = (network: WiFiNetwork) => (
<ListItem
key={network.bssid}
onClick={() => wifiConnectionContext.selectNetwork(network)}
@@ -89,15 +88,13 @@ const WiFiNetworkSelector = ({ networkList }: { networkList: WiFiNetworkList })
</Badge>
</ListItemIcon>
</ListItem>
),
[wifiConnectionContext, theme]
);
if (networkList.networks.length === 0) {
return <MessageBox message={LL.NETWORK_NO_WIFI()} level="info" />;
return <MessageBox mt={2} mb={1} message={LL.NETWORK_NO_WIFI()} level="info" />;
}
return <List>{networkList.networks.map(renderNetwork)}</List>;
};
export default memo(WiFiNetworkSelector);
export default WiFiNetworkSelector;

View File

@@ -1,4 +1,4 @@
import { memo, useEffect } from 'react';
import { useEffect } from 'react';
import CloseIcon from '@mui/icons-material/Close';
import {
@@ -40,7 +40,7 @@ const GenerateToken = ({ username, onClose }: GenerateTokenProps) => {
if (open) {
void generateToken();
}
}, [open, generateToken]);
}, [open]);
return (
<Dialog
@@ -86,4 +86,4 @@ const GenerateToken = ({ username, onClose }: GenerateTokenProps) => {
);
};
export default memo(GenerateToken);
export default GenerateToken;

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useContext, useMemo, useState } from 'react';
import { useContext, useState } from 'react';
import { useBlocker } from 'react-router';
import CancelIcon from '@mui/icons-material/Cancel';
@@ -55,9 +55,7 @@ const ManageUsers = () => {
const blocker = useBlocker(changed !== 0);
const { LL } = useI18nContext();
const table_theme = useMemo(
() =>
useTheme({
const table_theme = useTheme({
Table: `
--data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) minmax(120px, max-content) 120px;
`,
@@ -95,45 +93,41 @@ const ManageUsers = () => {
text-align: right;
}
`
}),
[]
);
});
const noAdminConfigured = useCallback(
() => !data?.users.find((u) => u.admin),
[data]
);
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage} />;
}
const removeUser = useCallback(
(toRemove: UserType) => {
if (!data) return;
const noAdminConfigured = () => !data.users.find((u) => u.admin);
const removeUser = (toRemove: UserType) => {
const users = data.users.filter((u) => u.username !== toRemove.username);
updateDataValue({ ...data, users });
setChanged(changed + 1);
},
[data, updateDataValue, changed]
);
};
const createUser = useCallback(() => {
const createUser = () => {
setCreating(true);
setUser({
username: '',
password: '',
admin: true
});
}, []);
};
const editUser = useCallback((toEdit: UserType) => {
const editUser = (toEdit: UserType) => {
setCreating(false);
setUser({ ...toEdit });
}, []);
};
const cancelEditingUser = useCallback(() => {
const cancelEditingUser = () => {
setUser(undefined);
}, []);
};
const doneEditingUser = useCallback(() => {
if (user && data) {
const doneEditingUser = () => {
if (user) {
const users = [
...data.users.filter(
(u: { username: string }) => u.username !== user.username
@@ -144,31 +138,26 @@ const ManageUsers = () => {
setUser(undefined);
setChanged(changed + 1);
}
}, [user, data, updateDataValue, changed]);
};
const closeGenerateToken = useCallback(() => {
const closeGenerateToken = () => {
setGeneratingToken(undefined);
}, []);
};
const generateTokenForUser = useCallback((username: string) => {
const generateToken = (username: string) => {
setGeneratingToken(username);
}, []);
};
const onSubmit = useCallback(async () => {
const onSubmit = async () => {
await saveData();
await authenticatedContext.refresh();
setChanged(0);
}, [saveData, authenticatedContext]);
};
const onCancelSubmit = useCallback(async () => {
const onCancelSubmit = async () => {
await loadData();
setChanged(0);
}, [loadData]);
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
}
};
interface UserType2 {
id: string;
@@ -178,14 +167,10 @@ const ManageUsers = () => {
}
// add id to the type, needed for the table
const user_table = useMemo(
() =>
data.users.map((u) => ({
const user_table = data.users.map((u) => ({
...u,
id: u.username
})) as UserType2[],
[data.users]
);
})) as UserType2[];
return (
<>
@@ -211,24 +196,15 @@ const ManageUsers = () => {
<Cell stiff>
<IconButton
size="small"
aria-label={LL.GENERATING_TOKEN()}
disabled={!authenticatedContext.me.admin}
onClick={() => generateTokenForUser(u.username)}
onClick={() => generateToken(u.username)}
>
<VpnKeyIcon />
</IconButton>
<IconButton
size="small"
onClick={() => removeUser(u)}
aria-label={LL.REMOVE()}
>
<IconButton size="small" onClick={() => removeUser(u)}>
<DeleteIcon />
</IconButton>
<IconButton
size="small"
onClick={() => editUser(u)}
aria-label={LL.EDIT()}
>
<IconButton size="small" onClick={() => editUser(u)}>
<EditIcon />
</IconButton>
</Cell>
@@ -284,11 +260,7 @@ const ManageUsers = () => {
</Box>
</Box>
<GenerateToken
username={generatingToken || ''}
onClose={closeGenerateToken}
/>
{user && (
<GenerateToken username={generatingToken} onClose={closeGenerateToken} />
<User
user={user}
setUser={setUser}
@@ -297,7 +269,6 @@ const ManageUsers = () => {
onCancelEditing={cancelEditingUser}
validator={createUserValidator(data.users, creating)}
/>
)}
</>
);
};
@@ -310,4 +281,4 @@ const ManageUsers = () => {
);
};
export default memo(ManageUsers);
export default ManageUsers;

View File

@@ -1,4 +1,3 @@
import { memo, useMemo } from 'react';
import { Navigate, Route, Routes, matchRoutes, useLocation } from 'react-router';
import { Tab } from '@mui/material';
@@ -13,23 +12,14 @@ const Security = () => {
const { LL } = useI18nContext();
useLayoutTitle(LL.SECURITY(0));
const location = useLocation();
const matchedRoutes = useMemo(
() =>
matchRoutes(
const matchedRoutes = matchRoutes(
[
{
path: '/settings/security/settings',
element: <ManageUsers />
},
{ path: '/settings/security/settings', element: <ManageUsers />, dog: 'woof' },
{ path: '/settings/security/users', element: <SecuritySettings /> }
],
location
),
[location]
useLocation()
);
const routerTab = matchedRoutes?.[0]?.route.path || false;
const routerTab = matchedRoutes?.[0].route.path || false;
return (
<>
@@ -52,4 +42,4 @@ const Security = () => {
);
};
export default memo(Security);
export default Security;

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useContext, useState } from 'react';
import { useContext, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
import WarningIcon from '@mui/icons-material/Warning';
@@ -44,14 +44,18 @@ const SecuritySettings = () => {
const authenticatedContext = useContext(AuthenticatedContext);
const updateFormValue = updateValueDirty(
origData as unknown as Record<string, unknown>,
origData,
dirtyFlags,
setDirtyFlags,
updateDataValue as (value: unknown) => void
updateDataValue
);
const validateAndSubmit = useCallback(async () => {
if (!data) return;
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage} />;
}
const validateAndSubmit = async () => {
try {
setFieldErrors(undefined);
await validate(SECURITY_SETTINGS_VALIDATOR, data);
@@ -60,17 +64,12 @@ const SecuritySettings = () => {
} catch (error) {
setFieldErrors(error as ValidateFieldsError);
}
}, [data, saveData, authenticatedContext]);
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
}
};
return (
<>
<ValidatedPasswordField
fieldErrors={fieldErrors || {}}
fieldErrors={fieldErrors}
name="jwt_secret"
label={LL.SU_PASSWORD()}
fullWidth
@@ -116,4 +115,4 @@ const SecuritySettings = () => {
);
};
export default memo(SecuritySettings);
export default SecuritySettings;

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import type { FC } from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
@@ -45,14 +45,7 @@ const User: FC<UserFormProps> = ({
}) => {
const { LL } = useI18nContext();
const updateFormValue = updateValue((updater) => {
setUser((prevState) => {
if (!prevState) return prevState;
return updater(
prevState as unknown as Record<string, unknown>
) as unknown as UserType;
});
});
const updateFormValue = updateValue(setUser);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const open = !!user;
@@ -62,7 +55,7 @@ const User: FC<UserFormProps> = ({
}
}, [open]);
const validateAndDone = useCallback(async () => {
const validateAndDone = async () => {
if (user) {
try {
setFieldErrors(undefined);
@@ -72,7 +65,7 @@ const User: FC<UserFormProps> = ({
setFieldErrors(error as ValidateFieldsError);
}
}
}, [user, validator, onDoneEditing]);
};
return (
<Dialog
@@ -89,7 +82,7 @@ const User: FC<UserFormProps> = ({
</DialogTitle>
<DialogContent dividers>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
fieldErrors={fieldErrors}
name="username"
label={LL.USERNAME(1)}
fullWidth
@@ -100,7 +93,7 @@ const User: FC<UserFormProps> = ({
margin="normal"
/>
<ValidatedPasswordField
fieldErrors={fieldErrors || {}}
fieldErrors={fieldErrors}
name="password"
label={LL.PASSWORD()}
fullWidth
@@ -144,4 +137,4 @@ const User: FC<UserFormProps> = ({
);
};
export default memo(User);
export default User;

View File

@@ -34,10 +34,19 @@ export const apStatusHighlight = ({ status }: APStatusType, theme: Theme) => {
}
};
const getApStatusText = (
status: APNetworkStatus,
LL: ReturnType<typeof useI18nContext>['LL']
) => {
const APStatus = () => {
const { data, send: loadData, error } = useRequest(APApi.readAPStatus);
useInterval(() => {
void loadData();
});
const { LL } = useI18nContext();
useLayoutTitle(LL.ACCESS_POINT(0));
const theme = useTheme();
const apStatus = ({ status }: APStatusType) => {
switch (status) {
case APNetworkStatus.ACTIVE:
return LL.ACTIVE();
@@ -50,27 +59,12 @@ const getApStatusText = (
}
};
const APStatus = () => {
const { data, send: loadData, error } = useRequest(APApi.readAPStatus);
const { LL } = useI18nContext();
const theme = useTheme();
useLayoutTitle(LL.ACCESS_POINT(0));
useInterval(() => {
void loadData();
});
const content = () => {
if (!data) {
return (
<SectionContent>
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
</SectionContent>
);
return <FormLoader onRetry={loadData} errorMessage={error?.message} />;
}
return (
<SectionContent>
<List>
<ListItem>
<ListItemAvatar>
@@ -78,26 +72,19 @@ const APStatus = () => {
<SettingsInputAntennaIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.STATUS_OF('')}
secondary={getApStatusText(data.status, LL)}
/>
<ListItemText primary={LL.STATUS_OF('')} secondary={apStatus(data)} />
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: 'primary.main' }}>IP</Avatar>
<Avatar>IP</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.ADDRESS_OF('IP')} secondary={data.ip_address} />
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: 'primary.main' }}>
<Avatar>
<DeviceHubIcon />
</Avatar>
</ListItemAvatar>
@@ -106,22 +93,21 @@ const APStatus = () => {
secondary={data.mac_address}
/>
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: 'primary.main' }}>
<Avatar>
<ComputerIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.AP_CLIENTS()} secondary={data.station_num} />
</ListItem>
<Divider variant="inset" component="li" />
</List>
</SectionContent>
);
};
return <SectionContent>{content()}</SectionContent>;
};
export default APStatus;

View File

@@ -1,5 +1,3 @@
import { useCallback, useMemo } from 'react';
import {
Body,
Cell,
@@ -19,12 +17,6 @@ import { useInterval } from 'utils';
import { readActivity } from '../../api/app';
import type { Stat } from '../main/types';
const QUALITY_COLORS = {
PERFECT: '#00FF7F',
WARNING: 'orange',
POOR: 'red'
} as const;
const SystemActivity = () => {
const { data, send: loadData, error } = useRequest(readActivity);
@@ -36,9 +28,7 @@ const SystemActivity = () => {
useLayoutTitle(LL.DATA_TRAFFIC());
const stats_theme = tableTheme(
useMemo(
() => ({
const stats_theme = tableTheme({
Table: `
--data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) 90px 90px 80px;
`,
@@ -74,37 +64,30 @@ const SystemActivity = () => {
text-align: center;
}
`
}),
[]
)
);
});
const showName = useCallback(
(id: number) => {
const name: keyof Translation['STATUS_NAMES'] =
id.toString() as keyof Translation['STATUS_NAMES'];
const showName = (id: number) => {
const name: keyof Translation['STATUS_NAMES'] = id;
return LL.STATUS_NAMES[name]();
},
[LL]
);
};
const showQuality = useCallback((stat: Stat) => {
const showQuality = (stat: Stat) => {
if (stat.q === 0 || stat.s + stat.f === 0) {
return;
}
if (stat.q === 100) {
return <div style={{ color: QUALITY_COLORS.PERFECT }}>{stat.q}%</div>;
return <div style={{ color: '#00FF7F' }}>{stat.q}%</div>;
}
if (stat.q >= 95) {
return <div style={{ color: QUALITY_COLORS.WARNING }}>{stat.q}%</div>;
return <div style={{ color: 'orange' }}>{stat.q}%</div>;
} else {
return <div style={{ color: QUALITY_COLORS.POOR }}>{stat.q}%</div>;
return <div style={{ color: 'red' }}>{stat.q}%</div>;
}
}, []);
};
const content = useMemo(() => {
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
return <FormLoader onRetry={loadData} errorMessage={error?.message} />;
}
return (
@@ -137,9 +120,9 @@ const SystemActivity = () => {
)}
</Table>
);
}, [data, loadData, error?.message, stats_theme, LL, showName, showQuality]);
};
return <SectionContent>{content}</SectionContent>;
return <SectionContent>{content()}</SectionContent>;
};
export default SystemActivity;

View File

@@ -1,5 +1,3 @@
import { ReactElement } from 'react';
import AppsIcon from '@mui/icons-material/Apps';
import DeveloperBoardIcon from '@mui/icons-material/DeveloperBoard';
import DevicesIcon from '@mui/icons-material/Devices';
@@ -26,61 +24,10 @@ import { useInterval } from 'utils';
import BBQKeesIcon from './bbqkees.svg';
// Constants
const AVATAR_COLORS = {
DEFAULT: '#5f9a5f',
BBQKEES: '#003289'
} as const;
const TEMP_THRESHOLD_CELSIUS = 90; // Temperature threshold to determine F vs C
function formatNumber(num: number) {
return new Intl.NumberFormat().format(num);
}
function formatTemperature(temp?: number): string {
if (!temp) return '';
const unit = temp > TEMP_THRESHOLD_CELSIUS ? 'F' : 'C';
return `, T: ${temp} °${unit}`;
}
function formatFlashSpeed(speed: number): string {
return (speed / 1000000).toFixed(0) + ' MHz';
}
function formatCPUCores(cores: number): string {
return cores === 1 ? 'single-core)' : 'dual-core)';
}
// Reusable component for hardware status list items
interface HardwareListItemProps {
icon: ReactElement;
primary: string;
secondary: string;
avatarColor?: string;
customIcon?: ReactElement | undefined;
}
const HardwareListItem = ({
icon,
primary,
secondary,
avatarColor = AVATAR_COLORS.DEFAULT,
customIcon
}: HardwareListItemProps) => (
<>
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: avatarColor, color: 'white' }}>
{customIcon || icon}
</Avatar>
</ListItemAvatar>
<ListItemText primary={primary} secondary={secondary} />
</ListItem>
<Divider variant="inset" component="li" />
</>
);
const HardwareStatus = () => {
const { LL } = useI18nContext();
@@ -92,72 +39,175 @@ const HardwareStatus = () => {
void loadData();
});
const content = () => {
if (!data) {
return (
<SectionContent>
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
</SectionContent>
);
return <FormLoader onRetry={loadData} errorMessage={error?.message} />;
}
return (
<SectionContent>
<List>
<HardwareListItem
icon={<TapAndPlayIcon />}
primary={`${LL.HARDWARE()} ${LL.DEVICE()}`}
secondary={data.model || data.cpu_type}
avatarColor={data.model ? AVATAR_COLORS.BBQKEES : AVATAR_COLORS.DEFAULT}
customIcon={
data.model ? (
<ListItem>
<ListItemAvatar>
{data.model ? (
<Avatar sx={{ bgcolor: '#003289', color: 'white' }}>
<img
alt="BBQKees"
src={BBQKeesIcon}
style={{ width: 16, verticalAlign: 'middle' }}
/>
) : undefined
</Avatar>
) : (
<Avatar sx={{ bgcolor: '#5f9a5f', color: 'white' }}>
<TapAndPlayIcon />
</Avatar>
)}
</ListItemAvatar>
<ListItemText
primary={LL.HARDWARE() + ' ' + LL.DEVICE()}
secondary={data.model ? data.model : data.cpu_type}
/>
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#5f9a5f', color: 'white' }}>
<DevicesIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary="SDK"
secondary={data.arduino_version + ' / ESP-IDF ' + data.sdk_version}
/>
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#5f9a5f', color: 'white' }}>
<DeveloperBoardIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary="CPU"
secondary={
data.esp_platform +
'/' +
data.cpu_type +
' (rev.' +
data.cpu_rev +
', ' +
(data.cpu_cores === 1 ? 'single-core)' : 'dual-core)') +
' @ ' +
data.cpu_freq_mhz +
' Mhz' +
// bit of a hack : if the CPU temp is higher than 90 (=32 Fahrenheit if using Celsius), show F, otherwise C
(data.temperature
? ', T: ' +
data.temperature +
' °' +
(data.temperature > 90 ? 'F' : 'C')
: '')
}
/>
<HardwareListItem
icon={<DevicesIcon />}
primary="SDK"
secondary={`${data.arduino_version} / ESP-IDF ${data.sdk_version}`}
/>
<HardwareListItem
icon={<DeveloperBoardIcon />}
primary="CPU"
secondary={`${data.esp_platform}/${data.cpu_type} (rev.${data.cpu_rev}, ${formatCPUCores(data.cpu_cores)} @ ${data.cpu_freq_mhz} Mhz${formatTemperature(data.temperature)}`}
/>
<HardwareListItem
icon={<MemoryIcon />}
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#5f9a5f', color: 'white' }}>
<MemoryIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.FREE_MEMORY()}
secondary={`${formatNumber(data.free_heap)} KB (${formatNumber(data.max_alloc_heap)} KB max alloc, ${formatNumber(data.free_caps)} KB caps)`}
secondary={
formatNumber(data.free_heap) +
' KB (' +
formatNumber(data.max_alloc_heap) +
' KB max alloc, ' +
formatNumber(data.free_caps) +
' KB caps)'
}
/>
</ListItem>
{data.psram_size !== undefined && data.free_psram !== undefined && (
<HardwareListItem
icon={<AppsIcon />}
<>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#5f9a5f', color: 'white' }}>
<AppsIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.PSRAM()}
secondary={`${formatNumber(data.psram_size)} KB / ${formatNumber(data.free_psram)} KB`}
secondary={
formatNumber(data.psram_size) +
' KB / ' +
formatNumber(data.free_psram) +
' KB'
}
/>
</ListItem>
</>
)}
<HardwareListItem
icon={<SdStorageIcon />}
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#5f9a5f', color: 'white' }}>
<SdStorageIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.FLASH()}
secondary={`${formatNumber(data.flash_chip_size)} KB , ${formatFlashSpeed(data.flash_chip_speed)}`}
secondary={
formatNumber(data.flash_chip_size) +
' KB , ' +
(data.flash_chip_speed / 1000000).toFixed(0) +
' MHz'
}
/>
<HardwareListItem
icon={<SdCardAlertIcon />}
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#5f9a5f', color: 'white' }}>
<SdCardAlertIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.APPSIZE()}
secondary={`${data.partition}: ${formatNumber(data.app_used)} KB / ${formatNumber(data.app_free)} KB`}
secondary={
data.partition +
': ' +
formatNumber(data.app_used) +
' KB / ' +
formatNumber(data.app_free) +
' KB'
}
/>
<HardwareListItem
icon={<FolderIcon />}
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#5f9a5f', color: 'white' }}>
<FolderIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.FILESYSTEM()}
secondary={`${formatNumber(data.fs_used)} KB / ${formatNumber(data.fs_free)} KB`}
secondary={
formatNumber(data.fs_used) +
' KB / ' +
formatNumber(data.fs_free) +
' KB'
}
/>
</ListItem>
<Divider variant="inset" component="li" />
</List>
</SectionContent>
);
};
return <SectionContent>{content()}</SectionContent>;
};
export default HardwareStatus;

View File

@@ -1,5 +1,3 @@
import { type FC, memo, useMemo } from 'react';
import AutoAwesomeMotionIcon from '@mui/icons-material/AutoAwesomeMotion';
import DeviceHubIcon from '@mui/icons-material/DeviceHub';
import ReportIcon from '@mui/icons-material/Report';
@@ -24,28 +22,17 @@ import type { MqttStatusType } from 'types';
import { MqttDisconnectReason } from 'types';
import { useInterval } from 'utils';
// Disconnect reason lookup table - created once, reused across renders
const DISCONNECT_REASONS: Record<MqttDisconnectReason, string> = {
[MqttDisconnectReason.USER_OK]: 'User disconnected',
[MqttDisconnectReason.TCP_DISCONNECTED]: 'TCP disconnected',
[MqttDisconnectReason.MQTT_UNACCEPTABLE_PROTOCOL_VERSION]:
'Unacceptable protocol version',
[MqttDisconnectReason.MQTT_IDENTIFIER_REJECTED]: 'Client ID rejected',
[MqttDisconnectReason.MQTT_SERVER_UNAVAILABLE]: 'Server unavailable',
[MqttDisconnectReason.MQTT_MALFORMED_CREDENTIALS]: 'Malformed credentials',
[MqttDisconnectReason.MQTT_NOT_AUTHORIZED]: 'Not authorized',
[MqttDisconnectReason.TLS_BAD_FINGERPRINT]: 'TLS fingerprint invalid'
};
const getDisconnectReason = (disconnect_reason: MqttDisconnectReason): string =>
DISCONNECT_REASONS[disconnect_reason] ?? 'Unknown';
export const mqttStatusHighlight = (
{ enabled, connected }: MqttStatusType,
theme: Theme
) => {
if (!enabled) return theme.palette.info.main;
return connected ? theme.palette.success.main : theme.palette.error.main;
if (!enabled) {
return theme.palette.info.main;
}
if (connected) {
return theme.palette.success.main;
}
return theme.palette.error.main;
};
export const mqttPublishHighlight = (
@@ -54,22 +41,68 @@ export const mqttPublishHighlight = (
) => {
if (mqtt_fails === 0) return theme.palette.success.main;
if (mqtt_fails < 10) return theme.palette.warning.main;
return theme.palette.error.main;
};
export const mqttQueueHighlight = ({ mqtt_queued }: MqttStatusType, theme: Theme) =>
mqtt_queued <= 1 ? theme.palette.success.main : theme.palette.warning.main;
export const mqttQueueHighlight = (
{ mqtt_queued }: MqttStatusType,
theme: Theme
) => {
if (mqtt_queued <= 1) return theme.palette.success.main;
interface ConnectionStatusProps {
data: MqttStatusType;
theme: Theme;
return theme.palette.warning.main;
};
const MqttStatus = () => {
const { data, send: loadData, error } = useRequest(MqttApi.readMqttStatus);
useInterval(() => {
void loadData();
});
const { LL } = useI18nContext();
useLayoutTitle('MQTT');
const theme = useTheme();
const mqttStatus = ({ enabled, connected, connect_count }: MqttStatusType) => {
if (!enabled) {
return LL.NOT_ENABLED();
}
if (connected) {
return LL.CONNECTED(0) + ' (' + connect_count + ')';
}
return LL.DISCONNECTED() + ' (' + connect_count + ')';
};
const disconnectReason = ({ disconnect_reason }: MqttStatusType) => {
switch (disconnect_reason) {
case MqttDisconnectReason.TCP_DISCONNECTED:
return 'TCP disconnected';
case MqttDisconnectReason.MQTT_UNACCEPTABLE_PROTOCOL_VERSION:
return 'Unacceptable protocol version';
case MqttDisconnectReason.MQTT_IDENTIFIER_REJECTED:
return 'Client ID rejected';
case MqttDisconnectReason.MQTT_SERVER_UNAVAILABLE:
return 'Server unavailable';
case MqttDisconnectReason.MQTT_MALFORMED_CREDENTIALS:
return 'Malformed credentials';
case MqttDisconnectReason.MQTT_NOT_AUTHORIZED:
return 'Not authorized';
case MqttDisconnectReason.TLS_BAD_FINGERPRINT:
return 'TLS fingerprint invalid';
default:
return 'Unknown';
}
};
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={error?.message} />;
}
// Memoized component to prevent unnecessary re-renders when parent updates
const ConnectionStatus: FC<ConnectionStatusProps> = memo(({ data, theme }) => {
const { LL } = useI18nContext();
return (
const renderConnectionStatus = () => (
<>
{!data.connected && (
<>
@@ -81,7 +114,7 @@ const ConnectionStatus: FC<ConnectionStatusProps> = memo(({ data, theme }) => {
</ListItemAvatar>
<ListItemText
primary={LL.DISCONNECT_REASON()}
secondary={getDisconnectReason(data.disconnect_reason)}
secondary={disconnectReason(data)}
/>
</ListItem>
<Divider variant="inset" component="li" />
@@ -114,40 +147,8 @@ const ConnectionStatus: FC<ConnectionStatusProps> = memo(({ data, theme }) => {
<Divider variant="inset" component="li" />
</>
);
});
const MqttStatus = () => {
const { data, send: loadData, error } = useRequest(MqttApi.readMqttStatus);
const { LL } = useI18nContext();
const theme = useTheme();
useLayoutTitle('MQTT');
useInterval(() => {
void loadData();
});
// Memoize error message separately to avoid re-renders on error object changes
const errorMessage = error?.message || '';
const mqttStatusText = useMemo(() => {
if (!data) return '';
if (!data.enabled) return LL.NOT_ENABLED();
return data.connected
? `${LL.CONNECTED(0)} (${data.connect_count})`
: `${LL.DISCONNECTED()} (${data.connect_count})`;
}, [data, LL]);
if (!data) {
return (
<SectionContent>
<FormLoader onRetry={loadData} errorMessage={errorMessage} />
</SectionContent>
);
}
return (
<SectionContent>
<List>
<ListItem>
<ListItemAvatar>
@@ -155,13 +156,15 @@ const MqttStatus = () => {
<DeviceHubIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.STATUS_OF('')} secondary={mqttStatusText} />
<ListItemText primary={LL.STATUS_OF('')} secondary={mqttStatus(data)} />
</ListItem>
<Divider variant="inset" component="li" />
{data.enabled && <ConnectionStatus data={data} theme={theme} />}
{data.enabled && renderConnectionStatus()}
</List>
</SectionContent>
);
};
return <SectionContent>{content()}</SectionContent>;
};
export default MqttStatus;

View File

@@ -1,31 +1,66 @@
import { useMemo } from 'react';
import { useState } from 'react';
import { toast } from 'react-toastify';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
import CancelIcon from '@mui/icons-material/Cancel';
import DnsIcon from '@mui/icons-material/Dns';
import SwapVerticalCircleIcon from '@mui/icons-material/SwapVerticalCircle';
import UpdateIcon from '@mui/icons-material/Update';
import {
Avatar,
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Divider,
List,
ListItem,
ListItemAvatar,
ListItemText,
TextField,
Typography,
useTheme
} from '@mui/material';
import type { Theme } from '@mui/material';
import * as NTPApi from 'api/ntp';
import { dialogStyle } from 'CustomTheme';
import { useRequest } from 'alova/client';
import { FormLoader, SectionContent, useLayoutTitle } from 'components';
import { ButtonRow, FormLoader, SectionContent, useLayoutTitle } from 'components';
import { useI18nContext } from 'i18n/i18n-react';
import type { NTPStatusType } from 'types';
import type { NTPStatusType, Time } from 'types';
import { NTPSyncStatus } from 'types';
import { useInterval } from 'utils';
import { formatDateTime } from 'utils';
import { formatDateTime, formatLocalDateTime } from 'utils';
// Utility functions
const NTPStatus = () => {
const { data, send: loadData, error } = useRequest(NTPApi.readNTPStatus);
useInterval(() => {
void loadData();
});
const [localTime, setLocalTime] = useState<string>('');
const [settingTime, setSettingTime] = useState<boolean>(false);
const [processing, setProcessing] = useState<boolean>(false);
const { LL } = useI18nContext();
useLayoutTitle('NTP');
const { send: updateTime } = useRequest(
(local_time: Time) => NTPApi.updateTime(local_time),
{
immediate: false
}
);
NTPApi.updateTime;
const isNtpActive = ({ status }: NTPStatusType) =>
status === NTPSyncStatus.NTP_ACTIVE;
const isNtpEnabled = ({ status }: NTPStatusType) =>
status !== NTPSyncStatus.NTP_DISABLED;
@@ -42,15 +77,13 @@ const ntpStatusHighlight = ({ status }: NTPStatusType, theme: Theme) => {
}
};
const NTPStatus = () => {
const { data, send: loadData, error } = useRequest(NTPApi.readNTPStatus);
const updateLocalTime = (event: React.ChangeEvent<HTMLInputElement>) =>
setLocalTime(event.target.value);
useInterval(() => {
void loadData();
});
const { LL } = useI18nContext();
useLayoutTitle('NTP');
const openSetTime = () => {
setLocalTime(formatLocalDateTime(new Date()));
setSettingTime(true);
};
const theme = useTheme();
@@ -67,12 +100,77 @@ const NTPStatus = () => {
}
};
const content = useMemo(() => {
const configureTime = async () => {
setProcessing(true);
await updateTime({ local_time: formatLocalDateTime(new Date(localTime)) })
.then(async () => {
toast.success(LL.TIME_SET());
setSettingTime(false);
await loadData();
})
.catch(() => {
toast.error(LL.PROBLEM_UPDATING());
})
.finally(() => {
setProcessing(false);
});
};
const renderSetTimeDialog = () => (
<Dialog
sx={dialogStyle}
open={settingTime}
onClose={() => setSettingTime(false)}
>
<DialogTitle>{LL.SET_TIME(1)}</DialogTitle>
<DialogContent dividers>
<Box color="warning.main" p={0} pl={0} pr={0} mt={0} mb={2}>
<Typography variant="body2">{LL.SET_TIME_TEXT()}</Typography>
</Box>
<TextField
label={LL.LOCAL_TIME(0)}
type="datetime-local"
value={localTime}
onChange={updateLocalTime}
disabled={processing}
fullWidth
slotProps={{
inputLabel: {
shrink: true
}
}}
/>
</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={() => setSettingTime(false)}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<AccessTimeIcon />}
variant="outlined"
onClick={configureTime}
disabled={processing}
color="primary"
>
{LL.UPDATE()}
</Button>
</DialogActions>
</Dialog>
);
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
return <FormLoader onRetry={loadData} errorMessage={error?.message} />;
}
return (
<>
<List>
<ListItem>
<ListItemAvatar>
@@ -121,10 +219,28 @@ const NTPStatus = () => {
</ListItem>
<Divider variant="inset" component="li" />
</List>
<Box display="flex" flexWrap="wrap">
{data && !isNtpActive(data) && (
<Box flexWrap="nowrap" whiteSpace="nowrap">
<ButtonRow>
<Button
onClick={openSetTime}
variant="outlined"
color="primary"
startIcon={<AccessTimeIcon />}
>
{LL.SET_TIME(0)}
</Button>
</ButtonRow>
</Box>
)}
</Box>
{renderSetTimeDialog()}
</>
);
}, [data, error, loadData, LL, theme]);
};
return <SectionContent>{content}</SectionContent>;
return <SectionContent>{content()}</SectionContent>;
};
export default NTPStatus;

View File

@@ -1,5 +1,3 @@
import { useMemo } from 'react';
import DeviceHubIcon from '@mui/icons-material/DeviceHub';
import DnsIcon from '@mui/icons-material/Dns';
import GiteIcon from '@mui/icons-material/Gite';
@@ -27,17 +25,10 @@ import type { NetworkStatusType } from 'types';
import { NetworkConnectionStatus } from 'types';
import { useInterval } from 'utils';
// Utility functions
const isConnected = ({ status }: NetworkStatusType) =>
status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED ||
status === NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED;
export const isWiFi = ({ status }: NetworkStatusType) =>
status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED;
export const isEthernet = ({ status }: NetworkStatusType) =>
status === NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED;
const networkStatusHighlight = ({ status }: NetworkStatusType, theme: Theme) => {
switch (status) {
case NetworkConnectionStatus.WIFI_STATUS_IDLE:
@@ -64,6 +55,11 @@ const networkQualityHighlight = ({ rssi }: NetworkStatusType, theme: Theme) => {
return theme.palette.success.main;
};
export const isWiFi = ({ status }: NetworkStatusType) =>
status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED;
export const isEthernet = ({ status }: NetworkStatusType) =>
status === NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED;
const dnsServers = ({ dns_ip_1, dns_ip_2 }: NetworkStatusType) => {
if (!dns_ip_1) {
return 'none';
@@ -85,33 +81,6 @@ const IPs = (status: NetworkStatusType) => {
return status.local_ip + ', ' + status.local_ipv6;
};
const getNetworkStatusText = (
status: NetworkConnectionStatus,
reconnectCount: number,
LL: ReturnType<typeof useI18nContext>['LL']
) => {
switch (status) {
case NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED:
return LL.CONNECTED(0) + ' (Ethernet)';
case NetworkConnectionStatus.WIFI_STATUS_NO_SHIELD:
return LL.INACTIVE(1);
case NetworkConnectionStatus.WIFI_STATUS_IDLE:
return LL.IDLE();
case NetworkConnectionStatus.WIFI_STATUS_NO_SSID_AVAIL:
return 'No SSID Available';
case NetworkConnectionStatus.WIFI_STATUS_CONNECTED:
return LL.CONNECTED(0) + ' (WiFi) (' + reconnectCount + ')';
case NetworkConnectionStatus.WIFI_STATUS_CONNECT_FAILED:
return LL.CONNECTED(1) + ' ' + LL.FAILED(0) + ' (' + reconnectCount + ')';
case NetworkConnectionStatus.WIFI_STATUS_CONNECTION_LOST:
return LL.CONNECTED(1) + ' ' + LL.LOST() + ' (' + reconnectCount + ')';
case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED:
return LL.DISCONNECTED();
default:
return LL.UNKNOWN();
}
};
const NetworkStatus = () => {
const { data, send: loadData, error } = useRequest(NetworkApi.readNetworkStatus);
@@ -124,30 +93,51 @@ const NetworkStatus = () => {
const theme = useTheme();
const content = useMemo(() => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
const networkStatus = ({ status }: NetworkStatusType) => {
switch (status) {
case NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED:
return LL.CONNECTED(0) + ' (Ethernet)';
case NetworkConnectionStatus.WIFI_STATUS_NO_SHIELD:
return LL.INACTIVE(1);
case NetworkConnectionStatus.WIFI_STATUS_IDLE:
return LL.IDLE();
case NetworkConnectionStatus.WIFI_STATUS_NO_SSID_AVAIL:
return 'No SSID Available';
case NetworkConnectionStatus.WIFI_STATUS_CONNECTED:
return LL.CONNECTED(0) + ' (WiFi) (' + data.reconnect_count + ')';
case NetworkConnectionStatus.WIFI_STATUS_CONNECT_FAILED:
return (
LL.CONNECTED(1) + ' ' + LL.FAILED(0) + ' (' + data.reconnect_count + ')'
);
case NetworkConnectionStatus.WIFI_STATUS_CONNECTION_LOST:
return LL.CONNECTED(1) + ' ' + LL.LOST() + ' (' + data.reconnect_count + ')';
case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED:
return LL.DISCONNECTED();
default:
return LL.UNKNOWN();
}
};
const statusText = getNetworkStatusText(data.status, data.reconnect_count, LL);
const statusColor = networkStatusHighlight(data, theme);
const qualityColor = networkQualityHighlight(data, theme);
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={error?.message} />;
}
return (
<List>
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: statusColor }}>
<Avatar sx={{ bgcolor: networkStatusHighlight(data, theme) }}>
{isWiFi(data) && <WifiIcon />}
{isEthernet(data) && <RouterIcon />}
</Avatar>
</ListItemAvatar>
<ListItemText primary="Status" secondary={statusText} />
<ListItemText primary="Status" secondary={networkStatus(data)} />
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: statusColor }}>
<Avatar sx={{ bgcolor: networkStatusHighlight(data, theme) }}>
<GiteIcon />
</Avatar>
</ListItemAvatar>
@@ -158,13 +148,13 @@ const NetworkStatus = () => {
<>
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: qualityColor }}>
<Avatar sx={{ bgcolor: networkQualityHighlight(data, theme) }}>
<SettingsInputAntennaIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary="SSID (RSSI)"
secondary={`${data.ssid} (${data.rssi} dBm)`}
secondary={data.ssid + ' (' + data.rssi + ' dBm)'}
/>
</ListItem>
<Divider variant="inset" component="li" />
@@ -228,9 +218,9 @@ const NetworkStatus = () => {
)}
</List>
);
}, [data, error, loadData, LL, theme]);
};
return <SectionContent>{content}</SectionContent>;
return <SectionContent>{content()}</SectionContent>;
};
export default NetworkStatus;

View File

@@ -1,4 +1,4 @@
import { useCallback, useContext, useMemo, useState } from 'react';
import { useContext, useState } from 'react';
import { toast } from 'react-toastify';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
@@ -8,10 +8,10 @@ import DeviceHubIcon from '@mui/icons-material/DeviceHub';
import DirectionsBusIcon from '@mui/icons-material/DirectionsBus';
import LogoDevIcon from '@mui/icons-material/LogoDev';
import MemoryIcon from '@mui/icons-material/Memory';
import MonitorHeartIcon from '@mui/icons-material/MonitorHeart';
import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew';
import RouterIcon from '@mui/icons-material/Router';
import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna';
import TimerIcon from '@mui/icons-material/Timer';
import WifiIcon from '@mui/icons-material/Wifi';
import {
Avatar,
@@ -37,34 +37,12 @@ import { FormLoader, SectionContent, useLayoutTitle } from 'components';
import ListMenuItem from 'components/layout/ListMenuItem';
import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react';
import { NTPSyncStatus, NetworkConnectionStatus, SystemStatusCodes } from 'types';
import { NTPSyncStatus, NetworkConnectionStatus } from 'types';
import { useInterval } from 'utils';
import { formatDateTime } from 'utils/time';
import SystemMonitor from './SystemMonitor';
// Pure functions moved outside component to avoid recreation on each render
const formatNumber = (num: number) => new Intl.NumberFormat().format(num);
const formatDurationSec = (
duration_sec: number,
LL: ReturnType<typeof useI18nContext>['LL']
) => {
const ms = duration_sec * 1000;
const days = Math.trunc(ms / 86400000);
const hours = Math.trunc(ms / 3600000) % 24;
const minutes = Math.trunc(ms / 60000) % 60;
const seconds = Math.trunc(ms / 1000) % 60;
const parts: string[] = [];
if (days) parts.push(LL.NUM_DAYS({ num: days }));
if (hours) parts.push(LL.NUM_HOURS({ num: hours }));
if (minutes) parts.push(LL.NUM_MINUTES({ num: minutes }));
parts.push(LL.NUM_SECONDS({ num: seconds }));
return parts.join(' ');
};
const SystemStatus = () => {
const { LL } = useI18nContext();
@@ -84,6 +62,7 @@ const SystemStatus = () => {
send: loadData,
error
} = useRequest(readSystemStatus, {
initialData: [],
async middleware(_, next) {
if (!restarting) {
await next();
@@ -97,46 +76,51 @@ const SystemStatus = () => {
const theme = useTheme();
// Memoize derived status values to avoid recalculation on every render
const busStatus = useMemo(() => {
if (!data) return 'EMS state unknown';
const formatDurationSec = (duration_sec: number) => {
const days = Math.trunc((duration_sec * 1000) / 86400000);
const hours = Math.trunc((duration_sec * 1000) / 3600000) % 24;
const minutes = Math.trunc((duration_sec * 1000) / 60000) % 60;
const seconds = Math.trunc((duration_sec * 1000) / 1000) % 60;
let formatted = '';
if (days) {
formatted += LL.NUM_DAYS({ num: days }) + ' ';
}
if (hours) {
formatted += LL.NUM_HOURS({ num: hours }) + ' ';
}
if (minutes) {
formatted += LL.NUM_MINUTES({ num: minutes }) + ' ';
}
formatted += LL.NUM_SECONDS({ num: seconds });
return formatted;
};
function formatNumber(num: number) {
return new Intl.NumberFormat().format(num);
}
const busStatus = () => {
if (data) {
switch (data.bus_status) {
case busConnectionStatus.BUS_STATUS_CONNECTED:
return `EMS ${LL.CONNECTED(0)} (${formatDurationSec(data.bus_uptime, LL)})`;
return (
'EMS ' +
LL.CONNECTED(0) +
' (' +
formatDurationSec(data.bus_uptime) +
')'
);
case busConnectionStatus.BUS_STATUS_TX_ERRORS:
return 'EMS ' + LL.TX_ISSUES();
case busConnectionStatus.BUS_STATUS_OFFLINE:
return 'EMS ' + LL.DISCONNECTED();
default:
}
}
return 'EMS state unknown';
}
}, [data?.bus_status, data?.bus_uptime, LL]);
// Memoize derived status values to avoid recalculation on every render
const systemStatus = useMemo(() => {
if (!data) return '??';
switch (data.status) {
case SystemStatusCodes.SYSTEM_STATUS_PENDING_UPLOAD:
case SystemStatusCodes.SYSTEM_STATUS_UPLOADING:
return LL.WAIT_FIRMWARE();
case SystemStatusCodes.SYSTEM_STATUS_ERROR_UPLOAD:
return LL.ERROR();
case SystemStatusCodes.SYSTEM_STATUS_PENDING_RESTART:
case SystemStatusCodes.SYSTEM_STATUS_RESTART_REQUESTED:
return LL.RESTARTING_PRE();
case SystemStatusCodes.SYSTEM_STATUS_INVALID_GPIO:
return LL.GPIO_OF(LL.FAILED(0));
default:
// SystemStatusCodes.SYSTEM_STATUS_NORMAL
return 'OK';
}
}, [data?.status, LL]);
const busStatusHighlight = useMemo(() => {
if (!data) return theme.palette.warning.main;
};
const busStatusHighlight = () => {
switch (data.bus_status) {
case busConnectionStatus.BUS_STATUS_TX_ERRORS:
return theme.palette.warning.main;
@@ -147,28 +131,27 @@ const SystemStatus = () => {
default:
return theme.palette.warning.main;
}
}, [data?.bus_status, theme.palette]);
const ntpStatus = useMemo(() => {
if (!data) return LL.UNKNOWN();
};
const ntpStatus = () => {
switch (data.ntp_status) {
case NTPSyncStatus.NTP_DISABLED:
return LL.NOT_ENABLED();
case NTPSyncStatus.NTP_INACTIVE:
return LL.INACTIVE(0);
case NTPSyncStatus.NTP_ACTIVE:
return data.ntp_time
? `${LL.ACTIVE()} (${formatDateTime(data.ntp_time)})`
: LL.ACTIVE();
return (
LL.ACTIVE() +
(data.ntp_time !== undefined
? ' (' + formatDateTime(data.ntp_time) + ')'
: '')
);
default:
return LL.UNKNOWN();
}
}, [data?.ntp_status, data?.ntp_time, LL]);
const ntpStatusHighlight = useMemo(() => {
if (!data) return theme.palette.error.main;
};
const ntpStatusHighlight = () => {
switch (data.ntp_status) {
case NTPSyncStatus.NTP_DISABLED:
return theme.palette.info.main;
@@ -179,11 +162,9 @@ const SystemStatus = () => {
default:
return theme.palette.error.main;
}
}, [data?.ntp_status, theme.palette]);
const networkStatusHighlight = useMemo(() => {
if (!data) return theme.palette.warning.main;
};
const networkStatusHighlight = () => {
switch (data.network_status) {
case NetworkConnectionStatus.WIFI_STATUS_IDLE:
case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED:
@@ -198,11 +179,9 @@ const SystemStatus = () => {
default:
return theme.palette.warning.main;
}
}, [data?.network_status, theme.palette]);
const networkStatus = useMemo(() => {
if (!data) return LL.UNKNOWN();
};
const networkStatus = () => {
switch (data.network_status) {
case NetworkConnectionStatus.WIFI_STATUS_NO_SHIELD:
return LL.INACTIVE(1);
@@ -211,27 +190,24 @@ const SystemStatus = () => {
case NetworkConnectionStatus.WIFI_STATUS_NO_SSID_AVAIL:
return 'No SSID Available';
case NetworkConnectionStatus.WIFI_STATUS_CONNECTED:
return `${LL.CONNECTED(0)} (WiFi, ${data.wifi_rssi} dBm)`;
return LL.CONNECTED(0) + ' (WiFi, ' + data.wifi_rssi + ' dBm)';
case NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED:
return `${LL.CONNECTED(0)} (Ethernet)`;
return LL.CONNECTED(0) + ' (Ethernet)';
case NetworkConnectionStatus.WIFI_STATUS_CONNECT_FAILED:
return `${LL.CONNECTED(1)} ${LL.FAILED(0)}`;
return LL.CONNECTED(1) + ' ' + LL.FAILED(0);
case NetworkConnectionStatus.WIFI_STATUS_CONNECTION_LOST:
return `${LL.CONNECTED(1)} ${LL.LOST()}`;
return LL.CONNECTED(1) + ' ' + LL.LOST();
case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED:
return LL.DISCONNECTED();
default:
return LL.UNKNOWN();
}
}, [data?.network_status, data?.wifi_rssi, LL]);
};
const activeHighlight = useCallback(
(value: boolean) =>
value ? theme.palette.success.main : theme.palette.info.main,
[theme.palette]
);
const activeHighlight = (value: boolean) =>
value ? theme.palette.success.main : theme.palette.info.main;
const doRestart = useCallback(async () => {
const doRestart = async () => {
setConfirmRestart(false);
setRestarting(true);
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
@@ -239,18 +215,13 @@ const SystemStatus = () => {
toast.error(error.message);
}
);
}, [sendAPI]);
};
const handleCloseRestartDialog = useCallback(() => {
setConfirmRestart(false);
}, []);
const renderRestartDialog = useMemo(
() => (
const renderRestartDialog = () => (
<Dialog
sx={dialogStyle}
open={confirmRestart}
onClose={handleCloseRestartDialog}
onClose={() => setConfirmRestart(false)}
>
<DialogTitle>{LL.RESTART()}</DialogTitle>
<DialogContent dividers>{LL.RESTART_CONFIRM()}</DialogContent>
@@ -258,7 +229,7 @@ const SystemStatus = () => {
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={handleCloseRestartDialog}
onClick={() => setConfirmRestart(false)}
color="secondary"
>
{LL.CANCEL()}
@@ -273,80 +244,40 @@ const SystemStatus = () => {
</Button>
</DialogActions>
</Dialog>
),
[confirmRestart, handleCloseRestartDialog, doRestart, LL]
);
// Memoize formatted values
const firmwareVersion = useMemo(
() => `v${data?.emsesp_version || ''}`,
[data?.emsesp_version]
);
const uptimeText = useMemo(
() => (data ? formatDurationSec(data.uptime, LL) : ''),
[data?.uptime, LL]
);
const freeMemoryText = useMemo(
() => (data ? `${formatNumber(data.free_heap)} KB ${LL.FREE_MEMORY()}` : ''),
[data?.free_heap, LL]
);
const networkIcon = useMemo(
() =>
data?.network_status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED
? WifiIcon
: RouterIcon,
[data?.network_status]
);
const mqttStatusText = useMemo(
() => (data?.mqtt_status ? LL.CONNECTED(0) : LL.INACTIVE(0)),
[data?.mqtt_status, LL]
);
const apStatusText = useMemo(
() => (data?.ap_status ? LL.ACTIVE() : LL.INACTIVE(0)),
[data?.ap_status, LL]
);
const handleRestartClick = useCallback(() => {
setConfirmRestart(true);
}, []);
const content = useMemo(() => {
const content = () => {
if (!data || !LL) {
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
return <FormLoader onRetry={loadData} errorMessage={error?.message} />;
}
return (
<>
<List>
<List sx={{ borderRadius: 3, border: '2px solid grey' }}>
<ListMenuItem
icon={BuildIcon}
bgcolor="#72caf9"
label="EMS-ESP Firmware"
text={firmwareVersion}
text={'v' + data.emsesp_version}
to="version"
/>
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#c5572c', color: 'white' }}>
<MonitorHeartIcon />
<TimerIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.STATUS_OF(LL.SYSTEM(0))}
secondary={`${systemStatus} (${LL.UPTIME()}: ${uptimeText})`}
primary={LL.UPTIME()}
secondary={formatDurationSec(data.uptime)}
/>
{me.admin && (
<Button
startIcon={<PowerSettingsNewIcon />}
variant="outlined"
color="error"
onClick={handleRestartClick}
onClick={() => setConfirmRestart(true)}
>
{LL.RESTART()}
</Button>
@@ -358,25 +289,29 @@ const SystemStatus = () => {
icon={MemoryIcon}
bgcolor="#68374d"
label={LL.HARDWARE()}
text={freeMemoryText}
text={formatNumber(data.free_heap) + ' KB' + ' ' + LL.FREE_MEMORY()}
to="/status/hardwarestatus"
/>
<ListMenuItem
disabled={!me.admin}
icon={DirectionsBusIcon}
bgcolor={busStatusHighlight}
bgcolor={busStatusHighlight()}
label={LL.DATA_TRAFFIC()}
text={busStatus}
text={busStatus()}
to="/status/activity"
/>
<ListMenuItem
disabled={!me.admin}
icon={networkIcon}
bgcolor={networkStatusHighlight}
icon={
data.network_status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED
? WifiIcon
: RouterIcon
}
bgcolor={networkStatusHighlight()}
label={LL.NETWORK(1)}
text={networkStatus}
text={networkStatus()}
to="/status/network"
/>
@@ -385,16 +320,16 @@ const SystemStatus = () => {
icon={DeviceHubIcon}
bgcolor={activeHighlight(data.mqtt_status)}
label="MQTT"
text={mqttStatusText}
text={data.mqtt_status ? LL.CONNECTED(0) : LL.INACTIVE(0)}
to="/status/mqtt"
/>
<ListMenuItem
disabled={!me.admin}
icon={AccessTimeIcon}
bgcolor={ntpStatusHighlight}
bgcolor={ntpStatusHighlight()}
label="NTP"
text={ntpStatus}
text={ntpStatus()}
to="/status/ntp"
/>
@@ -403,7 +338,7 @@ const SystemStatus = () => {
icon={SettingsInputAntennaIcon}
bgcolor={activeHighlight(data.ap_status)}
label={LL.ACCESS_POINT(0)}
text={apStatusText}
text={data.ap_status ? LL.ACTIVE() : LL.INACTIVE(0)}
to="/status/ap"
/>
@@ -417,33 +352,14 @@ const SystemStatus = () => {
/>
</List>
{renderRestartDialog}
{renderRestartDialog()}
</>
);
}, [
data,
LL,
firmwareVersion,
uptimeText,
freeMemoryText,
networkIcon,
mqttStatusText,
apStatusText,
busStatus,
busStatusHighlight,
networkStatusHighlight,
networkStatus,
ntpStatusHighlight,
ntpStatus,
activeHighlight,
me.admin,
handleRestartClick,
error,
loadData,
renderRestartDialog
]);
};
return restarting ? <SystemMonitor /> : <SectionContent>{content}</SectionContent>;
return (
<SectionContent>{restarting ? <SystemMonitor /> : content()}</SectionContent>
);
};
export default SystemStatus;

View File

@@ -1,11 +1,4 @@
import {
memo,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState
} from 'react';
import { useEffect, useRef, useState } from 'react';
import { toast } from 'react-toastify';
import DownloadIcon from '@mui/icons-material/GetApp';
@@ -15,7 +8,7 @@ import {
Box,
Button,
Checkbox,
Grid,
Grid2 as Grid,
IconButton,
MenuItem,
TextField,
@@ -38,14 +31,13 @@ import type { LogEntry, LogSettings } from 'types';
import { LogLevel } from 'types';
import { updateValueDirty, useRest } from 'utils';
const TextColors: Record<LogLevel, string> = {
const TextColors = {
[LogLevel.ERROR]: '#ff0000', // red
[LogLevel.WARNING]: '#ff0000', // red
[LogLevel.NOTICE]: '#ffffff', // white
[LogLevel.INFO]: '#ffcc00', // yellow
[LogLevel.DEBUG]: '#00ffff', // cyan
[LogLevel.TRACE]: '#00ffff', // cyan
[LogLevel.ALL]: '#ffffff' // white
[LogLevel.TRACE]: '#00ffff' // cyan
};
const LogEntryLine = styled('span')(
@@ -54,6 +46,11 @@ const LogEntryLine = styled('span')(
})
);
const topOffset = () =>
document.getElementById('log-window')?.getBoundingClientRect().bottom || 0;
const leftOffset = () =>
document.getElementById('log-window')?.getBoundingClientRect().left || 0;
const levelLabel = (level: LogLevel) => {
switch (level) {
case LogLevel.ERROR:
@@ -73,39 +70,6 @@ const levelLabel = (level: LogLevel) => {
}
};
const paddedLevelLabel = (level: LogLevel, compact: boolean) => {
const label = levelLabel(level);
return compact ? ' ' + label[0] : label.padStart(8, '\xa0');
};
const paddedNameLabel = (name: string, compact: boolean) => {
const label = '[' + name + ']';
return compact ? label : label.padEnd(12, '\xa0');
};
const paddedIDLabel = (id: number, compact: boolean) => {
const label = id + ':';
return compact ? label : label.padEnd(7, '\xa0');
};
// Memoized log entry component to prevent unnecessary re-renders
const LogEntryItem = memo(
({ entry, compact }: { entry: LogEntry; compact: boolean }) => {
return (
<div style={{ font: '13px monospace', whiteSpace: 'nowrap' }}>
<span>{entry.t}</span>
<span>{paddedLevelLabel(entry.l, compact)}&nbsp;</span>
<span>{paddedIDLabel(entry.i, compact)} </span>
<span>{paddedNameLabel(entry.n, compact)} </span>
<LogEntryLine details={{ level: entry.l }}>{entry.m}</LogEntryLine>
</div>
);
},
(prevProps, nextProps) =>
prevProps.entry.i === nextProps.entry.i &&
prevProps.compact === nextProps.compact
);
const SystemLog = () => {
const { LL } = useI18nContext();
@@ -137,85 +101,54 @@ const SystemLog = () => {
const [readOpen, setReadOpen] = useState(false);
const [logEntries, setLogEntries] = useState<LogEntry[]>([]);
const [autoscroll, setAutoscroll] = useState(true);
const [boxPosition, setBoxPosition] = useState({ top: 0, left: 0 });
const [lastId, setLastId] = useState<number>(-1);
const ALPHA_NUMERIC_DASH_REGEX = /^[a-fA-F0-9 ]+$/;
const updateFormValue = updateValueDirty(
origData as unknown as Record<string, unknown>,
origData,
dirtyFlags,
setDirtyFlags,
updateDataValue as (value: unknown) => void
updateDataValue
);
// Calculate box position after layout
useLayoutEffect(() => {
const logWindow = document.getElementById('log-window');
if (!logWindow) {
return;
}
const updatePosition = () => {
const windowElement = document.getElementById('log-window');
if (!windowElement) {
return;
}
const rect = windowElement.getBoundingClientRect();
setBoxPosition({ top: rect.bottom, left: rect.left });
};
updatePosition();
// Debounce resize events with requestAnimationFrame
let rafId: number;
const handleResize = () => {
cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(updatePosition);
};
// Update position on window resize
window.addEventListener('resize', handleResize);
const resizeObserver = new ResizeObserver(handleResize);
resizeObserver.observe(logWindow);
return () => {
window.removeEventListener('resize', handleResize);
resizeObserver.disconnect();
cancelAnimationFrame(rafId);
};
}, [data]); // Recalculate when data changes (in case layout shifts)
// Memoize message handler to avoid recreating on every render
const handleLogMessage = useCallback((message: { data: string }) => {
const rawData = message.data;
const logentry = JSON.parse(rawData) as LogEntry;
setLogEntries((log) => {
// Skip if this is a duplicate entry (check last entry id)
if (log.length > 0) {
const lastEntry = log[log.length - 1];
if (lastEntry && logentry.i <= lastEntry.i) {
return log;
}
}
const newLog = [...log, logentry];
return newLog;
});
}, []);
useSSE(fetchLogES, {
immediate: true,
interceptByGlobalResponded: false
})
.onMessage(handleLogMessage)
.onMessage((message: { data: string }) => {
const rawData = message.data;
const logentry = JSON.parse(rawData) as LogEntry;
if (lastId < logentry.i) {
setLogEntries((log) => [...log, logentry]);
setLastId(logentry.i);
}
})
.onError(() => {
toast.error('No connection to Log service');
});
const onDownload = useCallback(() => {
const result = logEntries
.map((i) => `${i.t} ${levelLabel(i.l)} ${i.i}: [${i.n}] ${i.m}`)
.join('\n');
const paddedLevelLabel = (level: LogLevel) => {
const label = levelLabel(level);
return data?.compact ? ' ' + label[0] : label.padStart(8, '\xa0');
};
const paddedNameLabel = (name: string) => {
const label = '[' + name + ']';
return data?.compact ? label : label.padEnd(12, '\xa0');
};
const paddedIDLabel = (id: number) => {
const label = id + ':';
return data?.compact ? label : label.padEnd(7, '\xa0');
};
const onDownload = () => {
let result = '';
for (const i of logEntries) {
result +=
i.t + ' ' + levelLabel(i.l) + ' ' + i.i + ': [' + i.n + '] ' + i.m + '\n';
}
const a = document.createElement('a');
a.setAttribute(
'href',
@@ -225,28 +158,24 @@ const SystemLog = () => {
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}, [logEntries]);
};
const saveSettings = useCallback(async () => {
const saveSettings = async () => {
await saveData();
}, [saveData]);
};
// handle scrolling - optimized to only scroll when needed
// handle scrolling
const ref = useRef<HTMLDivElement>(null);
const logWindowRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (logEntries.length && autoscroll) {
const container = logWindowRef.current;
if (container) {
requestAnimationFrame(() => {
container.scrollTop = container.scrollHeight;
ref.current?.scrollIntoView({
behavior: 'smooth',
block: 'end'
});
}
}
}, [logEntries.length, autoscroll]);
}, [logEntries.length]);
const sendReadCommand = useCallback(() => {
const sendReadCommand = () => {
if (readValue === '') {
setReadOpen(!readOpen);
return;
@@ -257,11 +186,11 @@ const SystemLog = () => {
setReadOpen(false);
setReadValue('');
}
}, [readValue, readOpen, send]);
};
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
return <FormLoader onRetry={loadData} errorMessage={errorMessage} />;
}
return (
@@ -302,8 +231,6 @@ const SystemLog = () => {
<MenuItem value={50}>50</MenuItem>
<MenuItem value={75}>75</MenuItem>
<MenuItem value={100}>100</MenuItem>
<MenuItem value={500}>500</MenuItem>
<MenuItem value={1000}>1000</MenuItem>
</TextField>
</Grid>
)}
@@ -351,7 +278,6 @@ const SystemLog = () => {
>
<IconButton
disableRipple
aria-label={LL.CANCEL()}
onClick={() => {
setReadOpen(false);
setReadValue('');
@@ -377,7 +303,7 @@ const SystemLog = () => {
) : (
<>
{data.developer_mode && (
<IconButton onClick={sendReadCommand} aria-label={LL.EXECUTE()}>
<IconButton onClick={sendReadCommand}>
<PlayArrowIcon color="primary" />
</IconButton>
)}
@@ -399,20 +325,27 @@ const SystemLog = () => {
</Grid>
<Box
ref={logWindowRef}
sx={{
backgroundColor: 'black',
overflowY: 'scroll',
position: 'absolute',
right: 18,
bottom: 18,
left: boxPosition.left,
top: boxPosition.top,
left: () => leftOffset(),
top: () => topOffset(),
p: 1
}}
>
{logEntries.map((e) => (
<LogEntryItem key={e.i} entry={e} compact={data.compact} />
<div key={e.i} style={{ font: '14px monospace', whiteSpace: 'nowrap' }}>
<span>{e.t}</span>
<span>{paddedLevelLabel(e.l)}&nbsp;</span>
<span>{paddedIDLabel(e.i)} </span>
<span>{paddedNameLabel(e.n)} </span>
<LogEntryLine details={{ level: e.l }} key={e.i}>
{e.m}
</LogEntryLine>
</div>
))}
<div ref={ref} />

View File

@@ -1,11 +1,12 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
import { Box, Button, Typography } from '@mui/material';
import { Box, Button, Dialog, DialogContent, Typography } from '@mui/material';
import { callAction } from 'api/app';
import { readSystemStatus } from 'api/system';
import { dialogStyle } from 'CustomTheme';
import { useRequest } from 'alova/client';
import MessageBox from 'components/MessageBox';
import { useI18nContext } from 'i18n/i18n-react';
@@ -16,9 +17,11 @@ import { LinearProgressWithLabel } from '../../components/upload/LinearProgressW
const SystemMonitor = () => {
const [errorMessage, setErrorMessage] = useState<string>();
const hasInitialized = useRef(false);
const { LL } = useI18nContext();
let count = 0;
const { send: setSystemStatus } = useRequest(
(status: string) => callAction({ action: 'systemStatus', param: status }),
{
@@ -29,13 +32,11 @@ const SystemMonitor = () => {
const { data, send } = useRequest(readSystemStatus, {
force: true,
async middleware(_, next) {
// Skip first request to allow AsyncWS to send its response
if (!hasInitialized.current) {
hasInitialized.current = true;
return; // Don't await next() on first call
}
if (count++ >= 1) {
// skip first request (1 second) to allow AsyncWS to send its response
await next();
}
}
})
.onSuccess((event) => {
if (
@@ -50,100 +51,53 @@ const SystemMonitor = () => {
}
})
.onError((error) => {
setErrorMessage(String(error.error?.message || 'An error occurred'));
setErrorMessage(error.message);
});
useInterval(() => {
void send();
}, 1000); // check every 1 second
const { statusMessage, isUploading, progressValue } = useMemo(() => {
const status = data?.status;
const message =
status && status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING
? LL.WAIT_FIRMWARE()
: status === SystemStatusCodes.SYSTEM_STATUS_PENDING_RESTART
? LL.APPLICATION_RESTARTING()
: status === SystemStatusCodes.SYSTEM_STATUS_NORMAL
? LL.RESTARTING_PRE()
: status === SystemStatusCodes.SYSTEM_STATUS_ERROR_UPLOAD
? 'Upload Failed'
: LL.RESTARTING_POST();
const uploading =
status !== undefined && status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING;
const progress =
uploading && status
? Math.round(status - SystemStatusCodes.SYSTEM_STATUS_UPLOADING)
: 0;
return {
statusMessage: message,
isUploading: uploading,
progressValue: progress
};
}, [data?.status, LL]);
const onCancel = useCallback(async () => {
const onCancel = async () => {
setErrorMessage(undefined);
await setSystemStatus(String(SystemStatusCodes.SYSTEM_STATUS_NORMAL));
await setSystemStatus(
SystemStatusCodes.SYSTEM_STATUS_NORMAL as unknown as string
);
document.location.href = '/';
}, [setSystemStatus]);
};
return (
<Box
sx={{
position: 'fixed',
top: 0,
left: 0,
width: '100vw',
height: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
// backdropFilter: 'blur(8px)'
}}
>
<Box
sx={{
width: '30%',
minWidth: '300px',
maxWidth: '500px',
backgroundColor: '#393939',
border: 2,
borderColor: '#565656',
borderRadius: '8px',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3)',
p: 3
}}
>
<Box display="flex" alignItems="center" flexDirection="column">
<img
src="/app/icon.png"
alt="EMS-ESP"
style={{ width: '40px', height: '40px', marginBottom: '16px' }}
/>
<Dialog fullWidth={true} sx={dialogStyle} open={true}>
<DialogContent dividers>
<Box m={0} py={0} display="flex" alignItems="center" flexDirection="column">
<Typography
color="secondary"
variant="h6"
fontWeight={400}
textAlign="center"
>
{statusMessage}
{data?.status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING
? LL.WAIT_FIRMWARE()
: data?.status === SystemStatusCodes.SYSTEM_STATUS_PENDING_RESTART
? LL.APPLICATION_RESTARTING()
: data?.status === SystemStatusCodes.SYSTEM_STATUS_NORMAL
? LL.RESTARTING_PRE()
: data?.status === SystemStatusCodes.SYSTEM_STATUS_ERROR_UPLOAD
? 'Upload Failed'
: LL.RESTARTING_POST()}
</Typography>
{errorMessage ? (
<MessageBox level="error" message={errorMessage}>
<MessageBox my={2} level="error" message={errorMessage}>
<Button
sx={{ ml: 2 }}
size="small"
sx={{ ml: 2 }}
startIcon={<CancelIcon />}
variant="contained"
color="error"
onClick={onCancel}
>
{LL.RESTART()}
{LL.RESET(0)}
</Button>
</MessageBox>
) : (
@@ -151,16 +105,20 @@ const SystemMonitor = () => {
<Typography mt={2} variant="h6" fontWeight={400} textAlign="center">
{LL.PLEASE_WAIT()}&hellip;
</Typography>
{isUploading && (
{data && data.status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING && (
<Box width="100%" pl={2} pr={2} py={2}>
<LinearProgressWithLabel value={progressValue} />
<LinearProgressWithLabel
value={Math.round(
data?.status - SystemStatusCodes.SYSTEM_STATUS_UPLOADING
)}
/>
</Box>
)}
</>
)}
</Box>
</Box>
</Box>
</DialogContent>
</Dialog>
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,22 +1,26 @@
import { type FC, type PropsWithChildren, memo } from 'react';
import type { FC } from 'react';
import { Box } from '@mui/material';
import type { BoxProps } from '@mui/material';
const ButtonRow: FC<PropsWithChildren<BoxProps>> = memo(({ children, ...rest }) => (
const ButtonRow: FC<BoxProps> = ({ children, ...rest }) => (
<Box
sx={{
'& button, & a, & .MuiCard-root': {
mt: 2,
mx: 0.6,
'&:last-child': { mr: 0 },
'&:first-of-type': { ml: 0 }
'&:last-child': {
mr: 0
},
'&:first-of-type': {
ml: 0
}
}
}}
{...rest}
>
{children}
</Box>
));
);
export default ButtonRow;

Some files were not shown because too many files have changed in this diff Show More