Compare commits

..
81 Commits
Author SHA1 Message Date
Proddy ac570d3ca7 Merge pull request #3256 from proddy/main
copy over 3.9.0-dev optimizations to load pre_load.json faster
2026-09-27 15:48:51 +02:00
proddy bb19e7102e copy over 3.9.0-dev optimizations to load pre_load.json faster without boot 2026-09-27 15:47:54 +02:00
proddy 0798775cd4 3.8.6 2026-09-24 20:39:05 +02:00
proddy 2a20c6c16d 3.8.6 2026-09-24 18:41:08 +02:00
proddy 4f1bae5e19 support newer rgb WS2812B V6 leds 2026-09-24 18:40:48 +02:00
proddy 91d2411885 update 3.8.5 2026-09-22 22:33:06 +02:00
proddy 5436277758 change message when upgrading to major release 2026-09-22 22:18:40 +02:00
proddy c8a96352ec 3.8.4 2026-08-04 08:13:45 +02:00
proddy 02caaef916 fix firmware size check 2026-08-03 09:59:46 +02:00
proddy ba0f4cd008 3.8.3 2026-08-02 09:56:04 +02:00
proddy 45f7d91b43 Merge remote-tracking branch 'origin/dev' 2026-08-02 09:55:48 +02:00
proddy ee3a5d231a Merge remote-tracking branch 'origin/dev' 2026-05-12 20:15:32 +02:00
proddy e8f57b6343 v3.8.1 2026-01-11 19:28:38 +01:00
proddy a1ab81de66 Merge branch 'dev' 2026-01-11 19:14:11 +01:00
proddy 28135c225b Merge branch 'dev' 2025-12-31 21:26:15 +01:00
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
361 changed files with 18817 additions and 42784 deletions
+1 -1
View File
@@ -29,7 +29,7 @@ BreakBeforeBinaryOperators: NonAssignment
BreakConstructorInitializersBeforeComma: true # Always break constructor initializers before commas and align the commas with the colon.
ExperimentalAutoDetectBinPacking: false
KeepEmptyLinesAtTheStartOfBlocks: false
MaxEmptyLinesToKeep: 1
MaxEmptyLinesToKeep: 4
PenaltyBreakBeforeFirstCallParameter: 200
PenaltyExcessCharacter: 10
PointerAlignment: Middle
+2 -4
View File
@@ -1,10 +1,8 @@
{
"name": "EMS-ESP Devcontainer",
"image": "mcr.microsoft.com/devcontainers/typescript-node:5-26-bookworm",
"image": "mcr.microsoft.com/devcontainers/typescript-node:1-22-bookworm",
"features": {
"ghcr.io/devcontainers/features/node:1": {
"version": "26"
},
"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": {}
-10
View File
@@ -1,10 +0,0 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
groups:
github-actions:
patterns:
- "*"
+29 -304
View File
@@ -11,209 +11,27 @@ on:
permissions:
contents: write
# Parallel graph (wall-clock is ~max of the firmware jobs, not the sum):
# webui ──────┐
# ├─► firmware (s_4M / s_16M / s_16M_P / s3_16M_P) ─┐
# modbus ─────┤ ├─► release
# └─► standalone ───────────────────────────────────┘
#
# Extra -j on a single runner does not help: PlatformIO already compiles each
# environment with all CPUs. The win is separate GitHub runners.
jobs:
webui:
name: Build webUI
if: github.event_name == 'workflow_dispatch' || github.repository == 'emsesp/EMS-ESP32'
pre-release:
name: 'Build Dev Release'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Install pnpm
uses: pnpm/action-setup@v6
with:
package_json_file: interface/package.json
- name: Install Node.js 26
uses: actions/setup-node@v7
with:
node-version: 26
- name: Cache pnpm store
uses: actions/cache@v6
with:
path: ~/.local/share/pnpm/store
key: ${{ runner.os }}-pnpm-${{ hashFiles('interface/package.json') }}
restore-keys: |
${{ runner.os }}-pnpm-
- name: Build webUI
env:
CI: true
working-directory: interface
run: |
pnpm install
pnpm build-webUI
- name: Upload WWWData.h
uses: actions/upload-artifact@v7
with:
name: wwwdata
path: src/ESP32React/WWWData.h
if-no-files-found: error
modbus:
name: Build modbus
if: github.event_name == 'workflow_dispatch' || github.repository == 'emsesp/EMS-ESP32'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Install python 3.13
uses: actions/setup-python@v7
uses: actions/setup-python@v6
with:
python-version: '3.13'
- name: Install uv
uses: astral-sh/setup-uv@v7
- name: Install Node.js 24
uses: actions/setup-node@v6
with:
version: "latest"
enable-cache: false
node-version: 24
- name: Install pioarduino
run: uv pip install --system pioarduino==6.2.0
- name: Build modbus
run: platformio run -e build_modbus
- name: Upload modbus header
uses: actions/upload-artifact@v7
with:
name: modbus-header
path: src/core/modbus_entity_parameters.hpp
if-no-files-found: error
standalone:
name: Build standalone
needs: modbus
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Install python 3.13
uses: actions/setup-python@v7
with:
python-version: '3.13'
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
version: "latest"
enable-cache: false
- name: Install pioarduino
run: uv pip install --system pioarduino==6.2.0
- name: Restore generated modbus header
uses: actions/download-artifact@v8
with:
name: modbus-header
path: src/core
- name: Build standalone
run: platformio run -e build_standalone
- name: Upload generated docs
uses: actions/upload-artifact@v7
with:
name: generated-docs
path: |
docs/dump_entities.csv
docs/dump_telegrams.csv
docs/Modbus-Entity-Registers.md
if-no-files-found: error
firmware:
name: Firmware ${{ matrix.env }}
needs: [webui, modbus]
runs-on: ubuntu-latest
strategy:
fail-fast: true
matrix:
# Keep in sync with platformio.ini default_envs
env: [s_4M, s_16M, s_16M_P, s3_16M_P]
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Install python 3.13
uses: actions/setup-python@v7
with:
python-version: '3.13'
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
version: "latest"
enable-cache: false
- name: Cache PlatformIO packages
uses: actions/cache@v6
with:
path: |
~/.platformio/.cache
~/.platformio/packages
~/.platformio/platforms
key: ${{ runner.os }}-pio-${{ matrix.env }}-${{ hashFiles('platformio.ini') }}
restore-keys: |
${{ runner.os }}-pio-${{ matrix.env }}-
${{ runner.os }}-pio-
- name: Install pioarduino
run: uv pip install --system pioarduino==6.2.0
- name: Restore WWWData.h
uses: actions/download-artifact@v8
with:
name: wwwdata
path: src/ESP32React
- name: Restore generated modbus header
uses: actions/download-artifact@v8
with:
name: modbus-header
path: src/core
- name: Setup ccache
uses: hendrikmuhs/ccache-action@v1.2.24
with:
key: firmware-${{ matrix.env }}
restore-keys: firmware-${{ matrix.env }}-
max-size: 1G
- name: Build ${{ matrix.env }}
env:
CCACHE_COMPILERCHECK: content
run: platformio run -e ${{ matrix.env }}
- name: Upload firmware
uses: actions/upload-artifact@v7
with:
name: firmware-${{ matrix.env }}
path: build/firmware
if-no-files-found: error
release:
name: Create GitHub Release
needs: [firmware, standalone]
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
fetch-depth: 0
uses: actions/checkout@v6
- name: Enable Corepack
run: corepack enable pnpm
- name: Get the EMS-ESP version
id: build_info
@@ -221,134 +39,41 @@ jobs:
version=`grep -E '^#define EMSESP_APP_VERSION' ./src/emsesp_version.h | awk -F'"' '{print $2}'`
echo "VERSION=$version" >> $GITHUB_OUTPUT
# must run before the release moves the 'latest' tag to this build
- name: Find the previous dev release
id: previous
- name: Install PlatformIO
run: |
prev=`git rev-parse -q --verify 'refs/tags/latest^{commit}' || git describe --tags --abbrev=0 --match 'v*'`
echo "REF=$prev" >> $GITHUB_OUTPUT
python -m pip install --upgrade pip
pip install -U platformio
python -m pip install intelhex
- name: Restore generated modbus header
uses: actions/download-artifact@v8
with:
name: modbus-header
path: src/core
- name: Restore generated docs
uses: actions/download-artifact@v8
with:
name: generated-docs
path: .
- name: Restore firmware binaries
uses: actions/download-artifact@v8
with:
pattern: firmware-*
merge-multiple: true
path: build/firmware
- name: List firmware files
run: ls -la build/firmware
# the recovery build needs PlatformIO, and pnpm + Node.js to build its own WebUI (recovery/WWWData.h)
- name: Install python 3.13
uses: actions/setup-python@v7
with:
python-version: '3.13'
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
version: "latest"
enable-cache: false
# falls back to the firmware jobs' caches, which hold the same toolchains
- name: Cache PlatformIO packages
uses: actions/cache@v6
with:
path: |
~/.platformio/.cache
~/.platformio/packages
~/.platformio/platforms
key: ${{ runner.os }}-pio-recovery-${{ hashFiles('platformio.ini') }}
restore-keys: |
${{ runner.os }}-pio-recovery-
${{ runner.os }}-pio-
- name: Install pioarduino
run: uv pip install --system pioarduino==6.2.0
- name: Install pnpm
uses: pnpm/action-setup@v6
with:
package_json_file: interface/package.json
- name: Install Node.js 26
uses: actions/setup-node@v7
with:
node-version: 26
- name: Cache pnpm store
uses: actions/cache@v6
with:
path: ~/.local/share/pnpm/store
key: ${{ runner.os }}-pnpm-${{ hashFiles('interface/package.json') }}
restore-keys: |
${{ runner.os }}-pnpm-
- name: Build the recovery firmware for the 16MB boards
- name: Build webUI
run: |
platformio run -e recovery_s_16M -e recovery_s_16M_P -e recovery_s3_16M_P
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@v7
with:
commit_message: "chore: update generated files for v${{steps.build_info.outputs.VERSION}}"
# an empty body falls back to the commit list generated by the release action
- name: Build the release notes
id: notes
env:
PREV_REF: ${{ steps.previous.outputs.REF }}
VERSION: ${{ steps.build_info.outputs.VERSION }}
run: |
./scripts/release_notes.sh "$PREV_REF" "$VERSION" > release_notes.md || :
cat release_notes.md
{
echo 'BODY<<__RELEASE_NOTES_EOF__'
cat release_notes.md
echo '__RELEASE_NOTES_EOF__'
} >> $GITHUB_OUTPUT
- name: Create GitHub Release
id: 'automatic_releases'
uses: emsesp/action-automatic-releases@v1.0.1
with:
repo_token: '${{ secrets.GITHUB_TOKEN }}'
title: Development Build v${{steps.build_info.outputs.VERSION}}
body: ${{ steps.notes.outputs.BODY }}
automatic_release_tag: 'latest'
prerelease: true
files: |
CHANGELOG_LATEST.md
./build/firmware/*.*
- name: Update version in Cloudflare KV store
if: github.repository == 'emsesp/EMS-ESP32'
env:
CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
CF_NAMESPACE_ID: ${{ secrets.CF_NAMESPACE_ID }}
CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
VERSION: ${{ steps.build_info.outputs.VERSION }}
run: |
JSON_DATA=$(jq -n \
--arg version "$VERSION" \
--arg date "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'{version: $version, date: $date}')
echo "JSON_DATA: $JSON_DATA"
curl -sS --fail-with-body \
-X PUT "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/storage/kv/namespaces/${CF_NAMESPACE_ID}/values/dev" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H "Content-Type: application/json" \
-d "$JSON_DATA"
echo
@@ -11,10 +11,10 @@ jobs:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@v6
- name: GitHub Releases To Discord
uses: SethCohen/github-releases-to-discord@v1.20.0
uses: SethCohen/github-releases-to-discord@v1.13.1
with:
webhook_url: ${{ secrets.WEBHOOK_URL }}
color: '2105893'
+4 -4
View File
@@ -15,17 +15,17 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v7
uses: actions/checkout@v6
- name: Install python 3.13
uses: actions/setup-python@v7
uses: actions/setup-python@v6
with:
python-version: '3.13'
- name: Install pioarduino
- name: Install PlatformIO
run: |
pip install wheel
pip install -U "pioarduino==6.2.0"
pip install -U platformio
- name: Run unit tests
run: |
+3 -3
View File
@@ -19,15 +19,15 @@ jobs:
BUILD_WRAPPER_OUT_DIR: bw-output
steps:
- name: Checkout repository
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Install Build Wrapper
uses: SonarSource/sonarqube-scan-action/install-build-wrapper@v8.3.0
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@v8.3.0
uses: SonarSource/sonarqube-scan-action@master
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
+28 -268
View File
@@ -9,269 +9,49 @@ on:
tags:
- 'v*'
# Parallel graph (wall-clock is ~max of the firmware jobs, not the sum):
# webui ──────┐
# ├─► firmware (s_4M / s_16M / s_16M_P / s3_16M_P) ─┐
# modbus ─────┤ ├─► release
# └─► standalone ───────────────────────────────────┘
#
# Extra -j on a single runner does not help: PlatformIO already compiles each
# environment with all CPUs. The win is separate GitHub runners.
jobs:
webui:
name: Build webUI
if: github.event_name == 'workflow_dispatch' || github.repository == 'emsesp/EMS-ESP32'
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@v7
uses: actions/checkout@v6
- name: Enable Corepack
run: corepack enable pnpm
- name: Install pnpm
uses: pnpm/action-setup@v6
with:
package_json_file: interface/package.json
- name: Install Node.js 26
uses: actions/setup-node@v7
with:
node-version: 26
- name: Cache pnpm store
uses: actions/cache@v6
with:
path: ~/.local/share/pnpm/store
key: ${{ runner.os }}-pnpm-${{ hashFiles('interface/package.json') }}
restore-keys: |
${{ runner.os }}-pnpm-
- name: Install PlatformIO
run: |
python -m pip install --upgrade pip
pip install -U platformio
python -m pip install intelhex
- name: Build webUI
env:
CI: true
working-directory: interface
run: |
pnpm install
pnpm build-webUI
- name: Upload WWWData.h
uses: actions/upload-artifact@v7
with:
name: wwwdata
path: src/ESP32React/WWWData.h
if-no-files-found: error
modbus:
name: Build modbus
if: github.event_name == 'workflow_dispatch' || github.repository == 'emsesp/EMS-ESP32'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Install python 3.13
uses: actions/setup-python@v7
with:
python-version: '3.13'
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
version: "latest"
enable-cache: false
- name: Install pioarduino
run: uv pip install --system pioarduino==6.2.0
platformio run -e build-webUI
- name: Build modbus
run: platformio run -e build_modbus
- name: Upload modbus header
uses: actions/upload-artifact@v7
with:
name: modbus-header
path: src/core/modbus_entity_parameters.hpp
if-no-files-found: error
standalone:
name: Build standalone
needs: modbus
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Install python 3.13
uses: actions/setup-python@v7
with:
python-version: '3.13'
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
version: "latest"
enable-cache: false
- name: Install pioarduino
run: uv pip install --system pioarduino==6.2.0
- name: Restore generated modbus header
uses: actions/download-artifact@v8
with:
name: modbus-header
path: src/core
run: |
platformio run -e build_modbus
- name: Build standalone
run: platformio run -e build_standalone
firmware:
name: Firmware ${{ matrix.env }}
needs: [webui, modbus]
runs-on: ubuntu-latest
strategy:
fail-fast: true
matrix:
# Keep in sync with platformio.ini default_envs
env: [s_4M, s_16M, s_16M_P, s3_16M_P]
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Install python 3.13
uses: actions/setup-python@v7
with:
python-version: '3.13'
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
version: "latest"
enable-cache: false
- name: Cache PlatformIO packages
uses: actions/cache@v6
with:
path: |
~/.platformio/.cache
~/.platformio/packages
~/.platformio/platforms
key: ${{ runner.os }}-pio-${{ matrix.env }}-${{ hashFiles('platformio.ini') }}
restore-keys: |
${{ runner.os }}-pio-${{ matrix.env }}-
${{ runner.os }}-pio-
- name: Install pioarduino
run: uv pip install --system pioarduino==6.2.0
- name: Restore WWWData.h
uses: actions/download-artifact@v8
with:
name: wwwdata
path: src/ESP32React
- name: Restore generated modbus header
uses: actions/download-artifact@v8
with:
name: modbus-header
path: src/core
- name: Setup ccache
uses: hendrikmuhs/ccache-action@v1.2.24
with:
key: firmware-${{ matrix.env }}
restore-keys: firmware-${{ matrix.env }}-
max-size: 1G
- name: Build ${{ matrix.env }}
env:
CCACHE_COMPILERCHECK: content
run: platformio run -e ${{ matrix.env }}
- name: Upload firmware
uses: actions/upload-artifact@v7
with:
name: firmware-${{ matrix.env }}
path: build/firmware
if-no-files-found: error
release:
name: Create GitHub Release
needs: [firmware, standalone]
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v7
- 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
platformio run -e build_standalone
- name: Restore generated modbus header
uses: actions/download-artifact@v8
with:
name: modbus-header
path: src/core
- name: Restore firmware binaries
uses: actions/download-artifact@v8
with:
pattern: firmware-*
merge-multiple: true
path: build/firmware
# the recovery build needs PlatformIO, and pnpm + Node.js to build its own WebUI (recovery/WWWData.h)
- name: Install python 3.13
uses: actions/setup-python@v7
with:
python-version: '3.13'
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
version: "latest"
enable-cache: false
# falls back to the firmware jobs' caches, which hold the same toolchains
- name: Cache PlatformIO packages
uses: actions/cache@v6
with:
path: |
~/.platformio/.cache
~/.platformio/packages
~/.platformio/platforms
key: ${{ runner.os }}-pio-recovery-${{ hashFiles('platformio.ini') }}
restore-keys: |
${{ runner.os }}-pio-recovery-
${{ runner.os }}-pio-
- name: Install pioarduino
run: uv pip install --system pioarduino==6.2.0
- name: Install pnpm
uses: pnpm/action-setup@v6
with:
package_json_file: interface/package.json
- name: Install Node.js 26
uses: actions/setup-node@v7
with:
node-version: 26
- name: Cache pnpm store
uses: actions/cache@v6
with:
path: ~/.local/share/pnpm/store
key: ${{ runner.os }}-pnpm-${{ hashFiles('interface/package.json') }}
restore-keys: |
${{ runner.os }}-pnpm-
- name: Build the recovery firmware for the 16MB boards
- name: Build all PIO target environments, from default_envs
run: |
platformio run -e recovery_s_16M -e recovery_s_16M_P -e recovery_s3_16M_P
- name: List firmware files
run: ls -la build/firmware
platformio run
- name: Create GitHub Release
uses: emsesp/action-automatic-releases@v1.0.1
@@ -281,23 +61,3 @@ jobs:
files: |
CHANGELOG.md
./build/firmware/*.*
- name: Update version in Cloudflare KV store
if: github.repository == 'emsesp/EMS-ESP32'
env:
CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
CF_NAMESPACE_ID: ${{ secrets.CF_NAMESPACE_ID }}
CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
VERSION: ${{ steps.build_info.outputs.VERSION }}
run: |
JSON_DATA=$(jq -n \
--arg version "$VERSION" \
--arg date "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'{version: $version, date: $date}')
echo "JSON_DATA: $JSON_DATA"
curl -sS --fail-with-body \
-X PUT "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/storage/kv/namespaces/${CF_NAMESPACE_ID}/values/stable" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H "Content-Type: application/json" \
-d "$JSON_DATA"
echo
+1 -3
View File
@@ -10,11 +10,9 @@ on:
jobs:
stale:
name: Mark or close stale issues and PRs
if: github.repository == 'emsesp/EMS-ESP32'
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v11
- uses: actions/stale@v10
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-stale: 30
+75
View File
@@ -0,0 +1,75 @@
name: 'Build test release'
on:
workflow_dispatch:
push:
branches:
- 'test'
permissions:
contents: read
jobs:
pre-release:
name: 'Build Test Release'
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@v6
- 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: Create GitHub Release
id: 'automatic_releases'
uses: emsesp/action-automatic-releases@v1.0.1
with:
repo_token: '${{ secrets.GITHUB_TOKEN }}'
title: Test Build v${{steps.build_info.outputs.VERSION}}
automatic_release_tag: 'test'
prerelease: true
files: |
CHANGELOG_LATEST.md
./build/firmware/*.*
-40
View File
@@ -1,40 +0,0 @@
name: 'Update versions'
on:
workflow_dispatch:
permissions:
contents: write
jobs:
update-version:
name: 'Update versions in Cloudflare KV store'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Get and Send EMS-ESP version to Cloudflare
env:
CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
CF_NAMESPACE_ID: ${{ secrets.CF_NAMESPACE_ID }}
CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
run: |
version=$(grep -E '^#define EMSESP_APP_VERSION' ./src/emsesp_version.h | awk -F'"' '{print $2}')
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
KV_ENV="stable"
else
KV_ENV="dev"
fi
JSON_DATA=$(jq -n \
--arg version "$version" \
--arg date "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'{version: $version, date: $date}')
echo "KV_ENV: $KV_ENV"
echo "JSON_DATA: $JSON_DATA"
curl -sS --fail-with-body \
-X PUT "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/storage/kv/namespaces/${CF_NAMESPACE_ID}/values/${KV_ENV}" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H "Content-Type: application/json" \
-d "$JSON_DATA"
echo
+1 -7
View File
@@ -2,7 +2,6 @@
.vscode/c_cpp_properties.json
.vscode/extensions.json
.vscode/launch.json
.vscode/settings.json
# c++ compiling
.clang_complete
@@ -32,8 +31,6 @@ analyse.html
interface/vite.config.ts.timestamp*
*.local
src/ESP32React/WWWData.h
recovery/WWWData.h
/interface/dist-recovery
# i18n generated files
interface/src/i18n/i18n-react.tsx
@@ -66,7 +63,7 @@ words-found-verbose.txt
# sonarlint
compile_commands.json
# other files
# pioarduino + hybrid
managed_components
dependencies.lock
CMakeLists.txt
@@ -78,6 +75,3 @@ pnpm-lock.yaml
.cache/
interface/.tsbuildinfo
test/test_api/package-lock.json
.clangd
mklittlefs
.cursor
+101
View File
@@ -0,0 +1,101 @@
{
"search.exclude": {
"**/.yarn": true,
"**/.pnp.*": true
},
"editor.codeActionsOnSave": {
"source.fixAll": "explicit"
},
"eslint.validate": [
"typescript"
],
"eslint.codeActionsOnSave.rules": null,
"eslint.nodePath": "interface/.yarn/sdks",
"eslint.workingDirectories": ["interface"],
"prettier.prettierPath": "",
"typescript.enablePromptUseWorkspaceTsdk": true,
"files.associations": {
"*.tsx": "typescriptreact",
"*.tcc": "cpp",
"optional": "cpp",
"istream": "cpp",
"ostream": "cpp",
"ratio": "cpp",
"system_error": "cpp",
"array": "cpp",
"functional": "cpp",
"regex": "cpp",
"tuple": "cpp",
"type_traits": "cpp",
"utility": "cpp",
"string": "cpp",
"string_view": "cpp",
"atomic": "cpp",
"bitset": "cpp",
"cctype": "cpp",
"chrono": "cpp",
"clocale": "cpp",
"cmath": "cpp",
"condition_variable": "cpp",
"cstdarg": "cpp",
"cstddef": "cpp",
"cstdint": "cpp",
"cstdio": "cpp",
"cstdlib": "cpp",
"cstring": "cpp",
"ctime": "cpp",
"cwchar": "cpp",
"cwctype": "cpp",
"deque": "cpp",
"list": "cpp",
"unordered_map": "cpp",
"unordered_set": "cpp",
"vector": "cpp",
"exception": "cpp",
"algorithm": "cpp",
"iterator": "cpp",
"map": "cpp",
"memory": "cpp",
"memory_resource": "cpp",
"numeric": "cpp",
"random": "cpp",
"set": "cpp",
"fstream": "cpp",
"initializer_list": "cpp",
"iomanip": "cpp",
"iosfwd": "cpp",
"iostream": "cpp",
"limits": "cpp",
"mutex": "cpp",
"new": "cpp",
"sstream": "cpp",
"stdexcept": "cpp",
"streambuf": "cpp",
"thread": "cpp",
"cinttypes": "cpp",
"typeinfo": "cpp"
},
"todo-tree.filtering.excludeGlobs": [
"**/vendor/**",
"**/node_modules/**",
"**/dist/**",
"**/bower_components/**",
"**/build/**",
"**/.vscode/**",
"**/.github/**",
"**/_output/**",
"**/*.min.*",
"**/*.map",
"**/ArduinoJson/**"
],
"cSpell.enableFiletypes": [
"ini",
"makefile"
],
"typescript.preferences.preferTypeOnlyAutoImports": true,
"sonarlint.pathToCompileCommands": "${workspaceFolder}/compile_commands.json",
"sonarlint.connectedMode.project": {
"connectionId": "emsesp",
"projectKey": "emsesp_EMS-ESP32"
}
}
+5 -2
View File
@@ -5,6 +5,7 @@ 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.6] 24 September 2026
## Added
@@ -23,6 +24,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Allow auto-download and install of firmware versions which are smaller to allow update to the latest 3.9.0-development version.
## Changed
## [3.8.3] 1 August 2026
## Added
@@ -53,6 +56,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- set 10 sec timeout for http requests [#3104](https://github.com/emsesp/EMS-ESP32/issues/3104)
- language code "cs" [#3161](https://github.com/emsesp/EMS-ESP32/issues/3161)
## [3.8.2] 12 May 2026
## Added
@@ -119,7 +123,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- 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
@@ -993,4 +996,4 @@ There are breaking changes between 3.5.x and earlier versions of 3.6.0. Please r
- some names of mqtt-tags like in v2.2.1
- new ESP32 partition side to allow for smoother OTA and fallback
- Network Gateway IP is optional (#682)emsesp/EMS-ESP
- moved to a new GitHub repo <https://github.com/emsesp/EMS-ESP32>
- moved to a new GitHub repo <https://github.com/emsesp/EMS-ESP32>
-70
View File
@@ -2,78 +2,8 @@
For more details go to [emsesp.org](https://emsesp.org/).
## [3.9.0]
This release is based on the latest Espressif/Arduino core version 3. It brings in many memory and performance optimizations.
## Added
- user-requested LED blink [#3063](https://github.com/emsesp/EMS-ESP32/issues/3063)
- Commands Service that can be called via MQTT or API or used in the Scheduler Service
- option to disable factory reset [#3150](https://github.com/emsesp/EMS-ESP32/issues/3150)
- TLS support with 4MB boards without PSRAM
- added eth_10mbit to Network Settings, which forces 10BASE-T half-duplex & autonegotiation off, halving the PHY’s power draw. It’s off by default as it will cause issues on managed switches with a port forced to 100Mb [#3213](https://github.com/emsesp/EMS-ESP32/pull/3213)
- cleaner MD5 and firmware uploading in WebUI [#3222](https://github.com/emsesp/EMS-ESP32/issues/3222)
- pc0Flow in l/min [#3224](https://github.com/emsesp/EMS-ESP32/issues/3224)
- remove HA config for excluded entities [#3229](https://github.com/emsesp/EMS-ESP32/issues/3229)
- `show system` reports the Ethernet status on boards with a PHY, so a failed initialisation or a link that never came up is visible instead of Ethernet being omitted completely. Also shows the loop task's lowest free stack, to help diagnose reboots
- new Application Setting "Automatically check for firmware updates", off by default. When enabled EMS-ESP asks emsesp.org for the latest version once a day on its own, otherwise it only asks when the WebUI wants to show it, so an unattended system never contacts emsesp.org
- Automatically check and convert older 3.8.x filesystem to the new 3.9.0 format
- heatpump pool: `poolon` (switch, 0x48A), `hppoolon` (pool heating active, 0x488), MP100 `poolshuntset` (0x5C4) and `poolrequest` (0x5BA), shorter pool names
- EMS-ESP Recovery, a small firmware for the factory (`boot`) partition on 16MB boards. It starts a `<AP name>-recovery` access point and connects to the network with the saved EMS-ESP settings (Ethernet if configured, otherwise WiFi, using the static IP if one is set), and has a WebUI to sign in with an existing admin user, upload a new firmware into either OTA partition, or start one of the installed firmwares. It only reads the EMS-ESP settings, never changes them. Released as `EMS-ESP-Recovery-<version>-<chip>-16MB[+].bin`, and installed by uploading it in the EMS-ESP WebUI like a normal firmware, or flashed with `pio run -e recovery_s3_16M_P -t upload`
- firmware rollback: a newly installed firmware is only kept once it has run for 2 minutes. If it crashes or restarts before then, the device goes back to the previous firmware, or to the Recovery if there is none
- on 16MB boards with the Recovery installed, EMS-ESP switches to it after crashing 5 times in a row. The Recovery shows why it was started and which firmware failed to start
- the Version page shows the installed Recovery version on 16MB boards
- crash test builds to try out the rollback and recovery, see `[env:crashtest_s3_16M_P]` in `platformio.ini`
- `s_16M_P_custom` PlatformIO env that HybridCompiles Arduino/IDF with a trimmed sdkconfig (no Bluetooth, PPP/modem, IP forward/NAPT, ULP, SPI Ethernet, GDBSTUB, FreeRTOS trace, mDNS browse/CLI) and places the UART ISR in IRAM
- 16MB builds use EMS-ESP's own Arduino/IDF libs from [esp32-arduino-lib-builder](https://github.com/emsesp/esp32-arduino-lib-builder) instead of Tasmota's prebuilt cores
- optional `ccache` wrapping when it is installed (`scripts/enable_ccache.py`) to speed up CI
- heat pump `dhw/disinfectduration`, the time allowed to reach the thermal disinfection temperature (0xEA offset 15, 120-240 min) [#3278](https://github.com/emsesp/EMS-ESP32/issues/3278)
## Fixed
- shunting yard show json
- `system/sendmail` called from a Command or the Scheduler computed its value twice, which stripped the quotes from literal text and then failed on characters like `!`. Also shunting yard treated a `?` inside a quoted string as a ternary, so text like `"too hot?"` returned nothing
- memory leak when using different timezones for ems-esp and thermostat[#3184](https://github.com/emsesp/EMS-ESP32/issues/3184)
- LED stayed off on a healthy system when "Disable LED" was unchecked
- Ethernet MAC address changed with the new SDK, breaking DHCP reservations (currently disabled)
- "IPv4 nameserver" showed an IPv6 address when IPv6 was in use
- the Network status page showed at most one IPv6 address and could hide the IPv4 DNS server entirely. It only read the link-local address, so a dual-stack host's ULA and global addresses were never reported, and it only read two of the three DNS slots, which are shared between IPv4 and IPv6. It now lists every address the interface holds, each labelled with its IPv6 scope, and every configured DNS server [#3246](https://github.com/emsesp/EMS-ESP32/issues/3246)
- the Gateway was only ever reported for IPv4, so on a dual-stack network the IPv6 default router was missing from the Network status page, `show system` and the MQTT `info` topic. Routers are learnt from router advertisements rather than configured, so all of them are now listed [#3246](https://github.com/emsesp/EMS-ESP32/issues/3246)
- `show system` had the same gaps, reporting a link-local IPv6 address only and a single IPv4 DNS server. The WiFi and Ethernet sections now list every IPv6 address with its scope, and every DNS server labelled with its own family [#3246](https://github.com/emsesp/EMS-ESP32/issues/3246)
- the Access Point status page reported whichever interface happened to be active, so with the AP down it showed the station's IP and MAC address under an "Inactive" heading. It now always reports the soft-AP's own details, and shows `none` when the AP isn't running
- the MQTT `info` topic published no addresses at all when connected over Ethernet, and over WiFi only an IPv4 address plus a link-local IPv6 one. Both interfaces now report their MAC, IPv4 address and gateway, every IPv6 address and every DNS server. Note that `IPv6 address` is now an array, and `IPv4 DNS Server` is replaced by a `DNS Server` array that covers both families [#3246](https://github.com/emsesp/EMS-ESP32/issues/3246)
- HA Discovery warning on Uptime after EMS-ESP boot due to NTP not ready
- hc/control setting for UI800 thermostats [#3181](https://github.com/emsesp/EMS-ESP32/discussions/3181)
- solar module switches [#3223](https://github.com/emsesp/EMS-ESP32/issues/3223)
- UI800/HMI800.2 thermostats without telegram 0x470 reported `summertemp` as 0 and lost the per-hc heat/cool delays. `summermode` is now also shown for UI800 heatpumps [#3257](https://github.com/emsesp/EMS-ESP32/issues/3257)
- possible crash when the WebUI asked for version information while the versions.json refresh was rewriting the cache from the main loop task
- HTTP client could write past a fixed 64 byte buffer when a URL had a hostname of 64 characters or more, could busy-spin for the whole read budget if the socket reported bytes it wouldn't return, and buffered responses without any size limit
- the "Ethernet clock mode (GPIO16/17) conflicts with PSRAM" error was never logged because Ethernet had already been skipped by the time the check ran
- heat limit entities (`maxheatcomp`, `maxheatheat`, `dhw.maxheat`) read as unknown on heat pumps with an auxiliary heater larger than 9 kW - `enum_maxHeat` was missing the 12 kW and 15 kW steps
- add 0kW to heat limits [#3262](https://github.com/emsesp/EMS-ESP32/issues/3262), [#3265](https://github.com/emsesp/EMS-ESP32/issues/3265)
- show temperature value for sensor 0x40 [#3272](https://github.com/emsesp/EMS-ESP32/issues/3272)
- no EMS bus on custom boards whose interface relies on a pull-up on the Rx line. The newer ESP-IDF no longer enables the internal pull-up on the UART Rx pin, so it is now set explicitly as it was in 3.8 [#3258](https://github.com/emsesp/EMS-ESP32/discussions/3258)
- Guru Meditation (cache error) on HybridCompile builds when the EMS or console UART ISR ran from flash during a SPI flash read (WiFi power-down, mDNS, NVS). The UART ISR and ringbuf ISR helpers are now in IRAM
- compile errors against Arduino 3.3.12 from Tasmota/Arduino headers named `console.h`, `network.h` and `httpClient.h`. Those EMS-ESP headers are now `emsesp_console.h`, `emsesp_network.h` and `emsesp_http_client.h`
- HTTP OTA from WSL/mDNS stalled on IPv6 and used tiny write chunks. Uploads now resolve IPv4 and stream in 64 KB blocks (`scripts/upload.py`, `scripts/upload_cli.py`)
## Changed
- various memory optimizations [#3083](https://github.com/emsesp/EMS-ESP32/issues/3083)
- Scheduler name is now mandatory
- network fallback to AP only after start [#3090](https://github.com/emsesp/EMS-ESP32/issues/3090)
- replaced Web async-validator with custom validator and toast with native snackbar to reduce bundle size
- Gateway and Connect devices are shown in the Devices page, but disabled [3126](https://github.com/emsesp/EMS-ESP32/discussions/3126)
- show control setting only for master thermostats (0x10) [#3173](https://github.com/emsesp/EMS-ESP32/issues/3173)
- remove devices without entities not listed in 0x07 telegram
- changes to the Dialogs in Dashboard page, removed the run icon in the Commands page. Renamed Execute to Run.
- set `None` for undefined values in HA `val_tpl`
- changes in WebUI - remove multiple calls systemInfo endpoint, Vewrsion pages checks for internet connection [#3195](https://github.com/emsesp/EMS-ESP32/pull/3195)
- multiple optimization in network, power down WiFi when Ethernet and WiFi nosleep is instant when selected [#3213](https://github.com/emsesp/EMS-ESP32/pull/3213)
- TLS buffers (~17KB per HTTPS request) are taken from PSRAM on boards that have it, instead of fragmenting the internal heap
- failed versions.json fetches back off from 5 up to 160 minutes instead of retrying every 5 minutes, as each attempt blocks the main loop for as long as the DNS, connect and read timeouts allow
- optimized the LED library to support WS2812B V5 and the newer V6 RGB LEDs
- hold Tx while a K30RF/KM200 gateway is syncing [#3094](https://github.com/emsesp/EMS-ESP32/issues/3094)
- Tasmota platform 2026.05.50 (Arduino 3.3.8 / IDF 5.5.4) to 2026.09.50 (Arduino 3.3.12 / IDF 5.5.5). Building that platform needs PlatformIO Core ≥6.2.0; install `pioarduino==6.2.0` (official `platformio` is still 6.1.19). See CONTRIBUTING.md
- CI dev-release firmware jobs run in parallel (webUI, firmware targets, standalone) instead of one serial job
-34
View File
@@ -10,40 +10,6 @@ Everybody is welcome and invited to contribute to the EMS-ESP Project by:
This document describes rules that are in effect for this repository, meant for handling issues by contributors in the issue tracker and PRs.
## Building the firmware
Arduino/IDF 3.3.12 (Tasmota platform `2026.09.50` in `platformio.ini`) needs **PlatformIO Core ≥6.2.0**. The official `platformio` package on PyPI is still 6.1.19, so `pio upgrade` and `pip install -U platformio` stay on 6.1.19 and you get:
`IncompatiblePlatform: Development platform 'espressif32' is not compatible with PlatformIO Core v6.1.19 and depends on PlatformIO Core >=6.2.0.`
Install **pioarduino** 6.2.0 (drop-in Core fork). It provides the same `pio` / `platformio` commands.
**VS Code / Cursor** (PlatformIO IDE uses `~/.platformio/penv`):
Linux / macOS:
```bash
~/.platformio/penv/bin/pip install -U "pioarduino==6.2.0"
~/.platformio/penv/bin/pio --version
```
Windows (PowerShell):
```powershell
& "$env:USERPROFILE\.platformio\penv\Scripts\pip.exe" install -U "pioarduino==6.2.0"
& "$env:USERPROFILE\.platformio\penv\Scripts\pio.exe" --version
```
You should see `PlatformIO Core, version 6.2.0`. Reload the window (or restart the IDE), then build again.
**CLI-only / CI:**
```bash
pip install -U "pioarduino==6.2.0"
# or: uv pip install --system pioarduino==6.2.0
platformio --version
```
## Opening New Issues
1. Opening an issue means that a problem exists in the code and should be addressed by the project contributors.
+5 -4
View File
@@ -27,7 +27,7 @@ ifeq ($(UNAME_S),Linux)
JOBS := $(shell nproc)
endif
ifeq ($(UNAME_S),Darwin)
EXTRA_CPPFLAGS = -D OSX -Wno-tautological-constant-out-of-range-compare -Wno-unused-lambda-capture
EXTRA_CPPFLAGS = -D OSX -Wno-tautological-constant-out-of-range-compare
JOBS := $(shell sysctl -n hw.ncpu)
endif
@@ -47,7 +47,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/espMqttClient/src lib/espMqttClient/src/* lib/ArduinoJson/src lib/uuid-common/src lib/uuid-console/src lib/uuid-log/src lib/PButton
SOURCES := src/core src/devices src/web src/test lib_standalone 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/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 :=
@@ -65,6 +65,7 @@ CXX_STANDARD := -std=gnu++20
#----------------------------------------------------------------------
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\"
@@ -112,8 +113,8 @@ CXX := /usr/bin/g++
CPPFLAGS += $(DEFINES) $(DEFAULTS) $(INCLUDE)
CPPFLAGS += -ggdb -g3 -MMD
CPPFLAGS += -flto=auto
CPPFLAGS += -Wall -Wextra -Werror
CPPFLAGS += -Wno-unused-parameter -Wno-missing-braces -Wno-vla-cxx-extension -Wno-switch-enum
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
File diff suppressed because it is too large Load Diff
+1
View File
@@ -5,6 +5,7 @@
},
"core": "esp32",
"extra_flags": [
"-DTASMOTA_SDK",
"-DARDUINO_LOLIN_C3_MINI",
"-DARDUINO_USB_MODE=1",
"-DARDUINO_USB_CDC_ON_BOOT=1"
+1
View File
@@ -6,6 +6,7 @@
"core": "esp32",
"extra_flags": [
"-DBOARD_HAS_PSRAM",
"-DTASMOTA_SDK",
"-DARDUINO_USB_CDC_ON_BOOT=1",
"-DARDUINO_USB_MODE=0"
],
+1 -1
View File
@@ -21,7 +21,7 @@
"arduino",
"espidf"
],
"name": "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,
+2 -1
View File
@@ -1,6 +1,7 @@
{
"build": {
"core": "esp32",
"extra_flags": "-DTASMOTA_SDK",
"f_cpu": "240000000L",
"f_flash": "40000000L",
"flash_mode": "dio",
@@ -18,7 +19,7 @@
"arduino",
"espidf"
],
"name": "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,
+1 -1
View File
@@ -19,7 +19,7 @@
"arduino",
"espidf"
],
"name": "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,
+2 -1
View File
@@ -1,6 +1,7 @@
{
"build": {
"core": "esp32",
"extra_flags": "-DTASMOTA_SDK",
"f_cpu": "240000000L",
"f_flash": "40000000L",
"flash_mode": "dio",
@@ -17,7 +18,7 @@
"arduino",
"espidf"
],
"name": "ESP32 4M Flash, 4608KB Code/OTA, 2MB FS",
"name": "Tasmota ESP32 4M Flash, 4608KB Code/OTA, 2MB FS",
"upload": {
"flash_size": "4MB",
"maximum_ram_size": 327680,
+53 -29
View File
@@ -1,6 +1,6 @@
{
"type": "systembackup",
"version": "3.9.0",
"version": "3.8.2",
"systembackup": [
{
"type": "settings",
@@ -15,8 +15,7 @@
"enableMDNS": true,
"enableCORS": false,
"CORSOrigin": "*",
"tx_power": 0,
"eth_10mbit": false
"tx_power": 0
},
"AP": {
"provision_mode": 1,
@@ -38,7 +37,7 @@
"base": "ems-esp",
"username": "",
"password": "",
"client_id": "ems-esp",
"client_id": "esp32-b8ffc9ec",
"keep_alive": 60,
"clean_session": false,
"entity_format": 1,
@@ -65,10 +64,7 @@
"enabled": false,
"server": "time.google.com",
"tz_label": "Europe/Amsterdam",
"tz_format": "CET-1CEST,M3.5.0,M10.5.0/3",
"thermostat_sync": 1,
"tz_label_t": "Europe/Amsterdam",
"tz_format_t": "CET-1CEST,M3.5.0,M10.5.0/3"
"tz_format": "CET-1CEST,M3.5.0,M10.5.0/3"
},
"Security": {
"jwt_secret": "ems-esp-neo",
@@ -86,10 +82,9 @@
]
},
"Settings": {
"version": "3.9.0",
"version": "3.8.2-dev.22",
"board_profile": "E32V2_2",
"platform": "ESP32",
"system_name": "",
"locale": "en",
"tx_mode": 5,
"ems_bus_id": 73,
@@ -136,23 +131,7 @@
"modbus_port": 502,
"modbus_max_clients": 10,
"modbus_timeout": 300,
"developer_mode": false,
"disable_reset": false,
"email_enabled": false,
"email_security": 2,
"email_server": "smtp.example.net",
"email_port": 587,
"email_login": "",
"email_pass": "",
"email_sender": "ems-esp@example.net",
"email_recp": "",
"email_subject": "ems-esp notification"
}
},
{
"type": "commands",
"Commands": {
"commands": []
"developer_mode": false
}
},
{
@@ -164,8 +143,43 @@
{
"type": "customizations",
"Customizations": {
"ts": [],
"as": [],
"ts": [
{
"id": "28_1767_7B13_2502",
"name": "gateway_temperature",
"offset": 0,
"is_system": true
}
],
"as": [
{
"gpio": 39,
"name": "core_voltage",
"offset": 0,
"factor": 0.003771,
"uom": 23,
"type": 3,
"is_system": true
},
{
"gpio": 36,
"name": "supply_voltage",
"offset": 0,
"factor": 0.017,
"uom": 23,
"type": 3,
"is_system": true
},
{
"gpio": 2,
"name": "led",
"offset": 0,
"factor": 1,
"uom": 0,
"type": 6,
"is_system": true
}
],
"masked_entities": []
}
},
@@ -180,6 +194,16 @@
"Modules": {
"modules": []
}
},
{
"type": "nvs",
"nvs": [
{
"type": 1,
"key": "fresh_firmware",
"value": 0
}
]
}
]
}
File diff suppressed because it is too large Load Diff
+5998 -6068
View File
File diff suppressed because it is too large Load Diff
+11 -15
View File
@@ -13,7 +13,7 @@ telegram_type_id,name,is_fetched
0x19,UBAMonitorSlow,
0x1A,UBASetPoints,
0x1C,UBAMaintenanceStatus,
0x1E,HydrTemp,
0x1E,WM10TempMessage,
0x23,JunkersSetMixer,fetched
0x27,UBASettingsWW,fetched
0x28,WeatherComp,fetched
@@ -29,7 +29,7 @@ telegram_type_id,name,is_fetched
0x3B,Energy,
0x3D,RC35Set,
0x3E,RC35Monitor,
0x3F,RC30Timer,
0x3F,RC35Timer,
0x40,RC30Temp,
0x41,RC30Monitor,
0x42,RC35Timer2,
@@ -115,7 +115,7 @@ telegram_type_id,name,is_fetched
0x02A5,CRFMonitor,
0x02A6,RC300Monitor,
0x02A7,RC300Monitor,
0x02A8,RC300Monitor,
0x02A8,CRFMonitor,
0x02A9,RC300Monitor,
0x02AA,RC300Monitor,
0x02AB,RC300Monitor,
@@ -172,20 +172,19 @@ telegram_type_id,name,is_fetched
0x0469,HPSet,
0x046A,HPSet,
0x0470,RC300Summer3,fetched
0x0471,RC300Summer2,
0x0472,RC300Summer2,
0x0473,RC300Summer2,
0x0474,RC300Summer2,
0x0475,RC300Summer2,
0x0476,RC300Summer2,
0x0477,RC300Summer2,
0x0478,RC300Summer2,
0x0471,RC300Sumr2,
0x0472,RC300Sumr2,
0x0473,RC300Sumr2,
0x0474,RC300Sumr2,
0x0475,RC300Sumr2,
0x0476,RC300Sumr2,
0x0477,RC300Sumr2,
0x0478,RC300Sumr2,
0x047B,HP2,
0x0484,HPSilentMode,fetched
0x0485,HpCooling,fetched
0x0486,HpInConfig,fetched
0x0488,HPValve,fetched
0x0489,HPpassive,
0x048A,HpPool,fetched
0x048B,HPPumps,fetched
0x048D,HpPower,fetched
@@ -209,7 +208,6 @@ telegram_type_id,name,is_fetched
0x0585,Blowerspeed,
0x0587,Bypass,
0x05BA,HpPoolStatus,fetched
0x05C4,HpPoolShunt,
0x05D9,Airquality,
0x0772,HIUSettings,
0x0779,HIUMonitor,
@@ -233,6 +231,4 @@ telegram_type_id,name,is_fetched
0x099A,HPStarts,
0x099B,HPFlowTemp,
0x099C,HPComp,
0x099D,HPComp2,
0x09A0,HPTemperature,
0x09A8,HPVolume,
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 UBASettingsWW fetched
19 0x28 WeatherComp fetched
29 0x3B Energy
30 0x3D RC35Set
31 0x3E RC35Monitor
32 0x3F RC30Timer RC35Timer
33 0x40 RC30Temp
34 0x41 RC30Monitor
35 0x42 RC35Timer2
115 0x02A5 CRFMonitor
116 0x02A6 RC300Monitor
117 0x02A7 RC300Monitor
118 0x02A8 RC300Monitor CRFMonitor
119 0x02A9 RC300Monitor
120 0x02AA RC300Monitor
121 0x02AB RC300Monitor
172 0x0469 HPSet
173 0x046A HPSet
174 0x0470 RC300Summer3 fetched
175 0x0471 RC300Summer2 RC300Sumr2
176 0x0472 RC300Summer2 RC300Sumr2
177 0x0473 RC300Summer2 RC300Sumr2
178 0x0474 RC300Summer2 RC300Sumr2
179 0x0475 RC300Summer2 RC300Sumr2
180 0x0476 RC300Summer2 RC300Sumr2
181 0x0477 RC300Summer2 RC300Sumr2
182 0x0478 RC300Summer2 RC300Sumr2
183 0x047B HP2
184 0x0484 HPSilentMode fetched
185 0x0485 HpCooling fetched
186 0x0486 HpInConfig fetched
187 0x0488 HPValve fetched
0x0489 HPpassive
188 0x048A HpPool fetched
189 0x048B HPPumps fetched
190 0x048D HpPower fetched
208 0x0585 Blowerspeed
209 0x0587 Bypass
210 0x05BA HpPoolStatus fetched
0x05C4 HpPoolShunt
211 0x05D9 Airquality
212 0x0772 HIUSettings
213 0x0779 HIUMonitor
231 0x099A HPStarts
232 0x099B HPFlowTemp
233 0x099C HPComp
0x099D HPComp2
234 0x09A0 HPTemperature
0x09A8 HPVolume
-6072
View File
File diff suppressed because it is too large Load Diff
-238
View File
@@ -1,238 +0,0 @@
telegram_type_id,name,is_fetched
0x04,UBAFactory,fetched
0x06,RCTime,
0x0A,EasyMonitor,fetched
0x10,UBAErrorMessage1,
0x11,UBAErrorMessage2,
0x12,RCErrorMessage,
0x13,RCErrorMessage2,
0x14,UBATotalUptime,fetched
0x15,UBAMaintenanceData,
0x16,UBAParameters,fetched
0x18,UBAMonitorFast,
0x19,UBAMonitorSlow,
0x1A,UBASetPoints,
0x1C,UBAMaintenanceStatus,
0x1E,HydrTemp,
0x23,JunkersSetMixer,fetched
0x27,UBASettingsWW,fetched
0x28,WeatherComp,fetched
0x2A,MC110Status,
0x2E,Meters,
0x33,UBAParameterWW,fetched
0x34,UBAMonitorWW,
0x35,UBAFlags,
0x37,WWSettings,fetched
0x38,WWTimer,fetched
0x39,WWCircTimer,fetched
0x3A,RC30WWSettings,fetched
0x3B,Energy,
0x3D,RC35Set,
0x3E,RC35Monitor,
0x3F,RC30Timer,
0x40,RC30Temp,
0x41,RC30Monitor,
0x42,RC35Timer2,
0x47,RC35Set,
0x48,RC35Monitor,
0x49,RC35Timer,
0x4C,RC35Timer2,
0x51,RC35Set,
0x52,RC35Monitor,
0x53,RC35Timer,
0x56,RC35Timer2,
0x5B,RC35Set,
0x5C,RC35Monitor,
0x5D,RC35Timer,
0x60,RC35Timer2,
0x96,SM10Config,fetched
0x97,SM10Monitor,
0x9C,WM10MonitorMessage,
0x9D,WM10SetMessage,
0xA2,RCError,
0xA3,RCOutdoorTemp,
0xA5,IBASettings,fetched
0xA7,RC30Set,
0xA9,RC30Vacation,fetched
0xAA,MMConfigMessage,fetched
0xAB,MMStatusMessage,
0xAC,MMSetMessage,
0xAF,RC20Remote,
0xB0,RC10Set,
0xB1,RC10Monitor,
0xBB,HybridSettings,fetched
0xBF,ErrorMessage,
0xC0,RCErrorMessage,
0xC2,UBAErrorMessage3,
0xC6,UBAErrorMessage3,
0xD1,UBAOutdoorTemp,
0xE3,UBAMonitorSlowPlus2,
0xE4,UBAMonitorFastPlus,
0xE5,UBAMonitorSlowPlus,
0xE6,UBAParametersPlus,fetched
0xE9,UBAMonitorWWPlus,
0xEA,UBAParameterWWPlus,fetched
0xEB,PumpKick,fetched
0x0101,ISM1Set,fetched
0x0103,ISM1StatusMessage,fetched
0x0104,ISM2StatusMessage,
0x010C,IPMStatusMessage,
0x011E,JunkersDisp,fetched
0x012E,HPEnergy1,
0x013B,HPEnergy2,
0x0165,JunkersSet,
0x0166,JunkersSet,
0x0167,JunkersSet,
0x0168,JunkersSet,
0x016E,Absent,fetched
0x016F,JunkersMonitor,
0x0170,JunkersMonitor,
0x0171,JunkersMonitor,
0x0172,JunkersMonitor,
0x0179,JunkersSet,
0x017A,JunkersSet,
0x017B,JunkersSet,
0x017C,JunkersSet,
0x01D3,JunkersDhw,fetched
0x023A,RC300OutdoorTemp,fetched
0x023E,PVSettings,fetched
0x0240,RC300Settings,fetched
0x0241,RC300Settings,fetched
0x0267,RC300Floordry,
0x0269,RC300Holiday,fetched
0x0291,HPMode,fetched
0x0292,HPMode,fetched
0x0293,HPMode,fetched
0x0294,HPMode,fetched
0x029B,RC300Curves,
0x029C,RC300Curves,
0x029D,RC300Curves,
0x029E,RC300Curves,
0x029F,RC300Curves,
0x02A0,RC300Curves,
0x02A1,RC300Curves,
0x02A2,RC300Curves,
0x02A5,CRFMonitor,
0x02A6,RC300Monitor,
0x02A7,RC300Monitor,
0x02A8,RC300Monitor,
0x02A9,RC300Monitor,
0x02AA,RC300Monitor,
0x02AB,RC300Monitor,
0x02AC,RC300Monitor,
0x02AF,RC300Summer,
0x02B0,RC300Summer,
0x02B1,RC300Summer,
0x02B2,RC300Summer,
0x02B3,RC300Summer,
0x02B4,RC300Summer,
0x02B5,RC300Summer,
0x02B6,RC300Summer,
0x02B9,RC300Set,
0x02BA,RC300Set,
0x02BB,RC300Set,
0x02BC,RC300Set,
0x02BD,RC300Set,
0x02BE,RC300Set,
0x02BF,RC300Set,
0x02C0,RC300Set,
0x02CC,HPPressure,fetched
0x02CD,MMPLUSConfigMessage,
0x02D6,HPPump2,fetched
0x02D7,MMPLUSStatusMessage,
0x02E0,UBASetPoints,
0x02F5,RC300WWmode,fetched
0x02F6,RC300WW2mode,fetched
0x0313,MMPLUSConfigMessage_WWC,fetched
0x031B,RC300WWtemp,fetched
0x031D,RC300WWmode2,
0x031E,RC300WWmode2,
0x0331,MMPLUSStatusMessage_WWC,
0x0358,SM100SystemConfig,fetched
0x035A,SM100CircuitConfig,fetched
0x035C,SM100HeatAssist,fetched
0x035D,SM100Circuit2Config,fetched
0x035F,SM100Config1,fetched
0x0361,SM100Differential,fetched
0x0362,SM100Monitor,
0x0363,SM100Monitor2,
0x0364,SM100Status,
0x0366,SM100Config,
0x036A,SM100Status2,
0x0380,SM100CollectorConfig,fetched
0x038E,SM100Energy,fetched
0x0391,SM100Time,fetched
0x0421,RC300Set2,
0x0422,RC300Set2,
0x0423,RC300Set2,
0x0424,RC300Set2,
0x043F,CRHolidays,fetched
0x0467,HPSet,
0x0468,HPSet,
0x0469,HPSet,
0x046A,HPSet,
0x0470,RC300Summer3,fetched
0x0471,RC300Summer2,
0x0472,RC300Summer2,
0x0473,RC300Summer2,
0x0474,RC300Summer2,
0x0475,RC300Summer2,
0x0476,RC300Summer2,
0x0477,RC300Summer2,
0x0478,RC300Summer2,
0x047B,HP2,
0x0484,HPSilentMode,fetched
0x0485,HpCooling,fetched
0x0486,HpInConfig,fetched
0x0488,HPValve,fetched
0x0489,HPpassive,
0x048A,HpPool,fetched
0x048B,HPPumps,fetched
0x048D,HpPower,fetched
0x048F,HpTemperatures,
0x0491,HPAdditionalHeater,fetched
0x0492,HpHeaterConfig,fetched
0x0494,UBAEnergySupplied,
0x0495,UBAInformation,
0x0499,HPDhwSettings,fetched
0x049C,HPSettings2,fetched
0x049D,HPSettings3,fetched
0x04A2,HpInput,fetched
0x04A5,HPFan,fetched
0x04A7,HPPowerLimit,fetched
0x04AA,HPPower2,fetched
0x04AE,HPEnergy,fetched
0x04AF,HPMeters,fetched
0x055C,VentilationSet,fetched
0x056B,VentilationMode,fetched
0x0583,VentilationMonitor,
0x0585,Blowerspeed,
0x0587,Bypass,
0x05BA,HpPoolStatus,fetched
0x05C4,HpPoolShunt,
0x05D9,Airquality,
0x0772,HIUSettings,
0x0779,HIUMonitor,
0x07A5,SM100wwCirc,fetched
0x07A6,SM100wwParam,fetched
0x07AA,SM100wwStatus,
0x07AB,SM100wwCommand,
0x07AC,SM100wwParam1,
0x07AD,SM100ValveStatus,
0x07AE,SM100wwKeepWarm,fetched
0x07D6,SM100wwTemperature,
0x07E0,SM100wwStatus2,fetched
0x0935,EM100SetMessage,fetched
0x0936,EM100OutMessage,
0x0937,EM100TempMessage,
0x0938,EM100InputMessage,
0x0939,EM100MonitorMessage,
0x093A,EM100ConfigMessage,
0x0998,HPSettings,fetched
0x0999,HPFunctionTest,fetched
0x099A,HPStarts,
0x099B,HPFlowTemp,
0x099C,HPComp,
0x099D,HPComp2,
0x09A0,HPTemperature,
0x09A8,HPVolume,
1 telegram_type_id name is_fetched
2 0x04 UBAFactory fetched
3 0x06 RCTime
4 0x0A EasyMonitor fetched
5 0x10 UBAErrorMessage1
6 0x11 UBAErrorMessage2
7 0x12 RCErrorMessage
8 0x13 RCErrorMessage2
9 0x14 UBATotalUptime fetched
10 0x15 UBAMaintenanceData
11 0x16 UBAParameters fetched
12 0x18 UBAMonitorFast
13 0x19 UBAMonitorSlow
14 0x1A UBASetPoints
15 0x1C UBAMaintenanceStatus
16 0x1E HydrTemp
17 0x23 JunkersSetMixer fetched
18 0x27 UBASettingsWW fetched
19 0x28 WeatherComp fetched
20 0x2A MC110Status
21 0x2E Meters
22 0x33 UBAParameterWW fetched
23 0x34 UBAMonitorWW
24 0x35 UBAFlags
25 0x37 WWSettings fetched
26 0x38 WWTimer fetched
27 0x39 WWCircTimer fetched
28 0x3A RC30WWSettings fetched
29 0x3B Energy
30 0x3D RC35Set
31 0x3E RC35Monitor
32 0x3F RC30Timer
33 0x40 RC30Temp
34 0x41 RC30Monitor
35 0x42 RC35Timer2
36 0x47 RC35Set
37 0x48 RC35Monitor
38 0x49 RC35Timer
39 0x4C RC35Timer2
40 0x51 RC35Set
41 0x52 RC35Monitor
42 0x53 RC35Timer
43 0x56 RC35Timer2
44 0x5B RC35Set
45 0x5C RC35Monitor
46 0x5D RC35Timer
47 0x60 RC35Timer2
48 0x96 SM10Config fetched
49 0x97 SM10Monitor
50 0x9C WM10MonitorMessage
51 0x9D WM10SetMessage
52 0xA2 RCError
53 0xA3 RCOutdoorTemp
54 0xA5 IBASettings fetched
55 0xA7 RC30Set
56 0xA9 RC30Vacation fetched
57 0xAA MMConfigMessage fetched
58 0xAB MMStatusMessage
59 0xAC MMSetMessage
60 0xAF RC20Remote
61 0xB0 RC10Set
62 0xB1 RC10Monitor
63 0xBB HybridSettings fetched
64 0xBF ErrorMessage
65 0xC0 RCErrorMessage
66 0xC2 UBAErrorMessage3
67 0xC6 UBAErrorMessage3
68 0xD1 UBAOutdoorTemp
69 0xE3 UBAMonitorSlowPlus2
70 0xE4 UBAMonitorFastPlus
71 0xE5 UBAMonitorSlowPlus
72 0xE6 UBAParametersPlus fetched
73 0xE9 UBAMonitorWWPlus
74 0xEA UBAParameterWWPlus fetched
75 0xEB PumpKick fetched
76 0x0101 ISM1Set fetched
77 0x0103 ISM1StatusMessage fetched
78 0x0104 ISM2StatusMessage
79 0x010C IPMStatusMessage
80 0x011E JunkersDisp fetched
81 0x012E HPEnergy1
82 0x013B HPEnergy2
83 0x0165 JunkersSet
84 0x0166 JunkersSet
85 0x0167 JunkersSet
86 0x0168 JunkersSet
87 0x016E Absent fetched
88 0x016F JunkersMonitor
89 0x0170 JunkersMonitor
90 0x0171 JunkersMonitor
91 0x0172 JunkersMonitor
92 0x0179 JunkersSet
93 0x017A JunkersSet
94 0x017B JunkersSet
95 0x017C JunkersSet
96 0x01D3 JunkersDhw fetched
97 0x023A RC300OutdoorTemp fetched
98 0x023E PVSettings fetched
99 0x0240 RC300Settings fetched
100 0x0241 RC300Settings fetched
101 0x0267 RC300Floordry
102 0x0269 RC300Holiday fetched
103 0x0291 HPMode fetched
104 0x0292 HPMode fetched
105 0x0293 HPMode fetched
106 0x0294 HPMode fetched
107 0x029B RC300Curves
108 0x029C RC300Curves
109 0x029D RC300Curves
110 0x029E RC300Curves
111 0x029F RC300Curves
112 0x02A0 RC300Curves
113 0x02A1 RC300Curves
114 0x02A2 RC300Curves
115 0x02A5 CRFMonitor
116 0x02A6 RC300Monitor
117 0x02A7 RC300Monitor
118 0x02A8 RC300Monitor
119 0x02A9 RC300Monitor
120 0x02AA RC300Monitor
121 0x02AB RC300Monitor
122 0x02AC RC300Monitor
123 0x02AF RC300Summer
124 0x02B0 RC300Summer
125 0x02B1 RC300Summer
126 0x02B2 RC300Summer
127 0x02B3 RC300Summer
128 0x02B4 RC300Summer
129 0x02B5 RC300Summer
130 0x02B6 RC300Summer
131 0x02B9 RC300Set
132 0x02BA RC300Set
133 0x02BB RC300Set
134 0x02BC RC300Set
135 0x02BD RC300Set
136 0x02BE RC300Set
137 0x02BF RC300Set
138 0x02C0 RC300Set
139 0x02CC HPPressure fetched
140 0x02CD MMPLUSConfigMessage
141 0x02D6 HPPump2 fetched
142 0x02D7 MMPLUSStatusMessage
143 0x02E0 UBASetPoints
144 0x02F5 RC300WWmode fetched
145 0x02F6 RC300WW2mode fetched
146 0x0313 MMPLUSConfigMessage_WWC fetched
147 0x031B RC300WWtemp fetched
148 0x031D RC300WWmode2
149 0x031E RC300WWmode2
150 0x0331 MMPLUSStatusMessage_WWC
151 0x0358 SM100SystemConfig fetched
152 0x035A SM100CircuitConfig fetched
153 0x035C SM100HeatAssist fetched
154 0x035D SM100Circuit2Config fetched
155 0x035F SM100Config1 fetched
156 0x0361 SM100Differential fetched
157 0x0362 SM100Monitor
158 0x0363 SM100Monitor2
159 0x0364 SM100Status
160 0x0366 SM100Config
161 0x036A SM100Status2
162 0x0380 SM100CollectorConfig fetched
163 0x038E SM100Energy fetched
164 0x0391 SM100Time fetched
165 0x0421 RC300Set2
166 0x0422 RC300Set2
167 0x0423 RC300Set2
168 0x0424 RC300Set2
169 0x043F CRHolidays fetched
170 0x0467 HPSet
171 0x0468 HPSet
172 0x0469 HPSet
173 0x046A HPSet
174 0x0470 RC300Summer3 fetched
175 0x0471 RC300Summer2
176 0x0472 RC300Summer2
177 0x0473 RC300Summer2
178 0x0474 RC300Summer2
179 0x0475 RC300Summer2
180 0x0476 RC300Summer2
181 0x0477 RC300Summer2
182 0x0478 RC300Summer2
183 0x047B HP2
184 0x0484 HPSilentMode fetched
185 0x0485 HpCooling fetched
186 0x0486 HpInConfig fetched
187 0x0488 HPValve fetched
188 0x0489 HPpassive
189 0x048A HpPool fetched
190 0x048B HPPumps fetched
191 0x048D HpPower fetched
192 0x048F HpTemperatures
193 0x0491 HPAdditionalHeater fetched
194 0x0492 HpHeaterConfig fetched
195 0x0494 UBAEnergySupplied
196 0x0495 UBAInformation
197 0x0499 HPDhwSettings fetched
198 0x049C HPSettings2 fetched
199 0x049D HPSettings3 fetched
200 0x04A2 HpInput fetched
201 0x04A5 HPFan fetched
202 0x04A7 HPPowerLimit fetched
203 0x04AA HPPower2 fetched
204 0x04AE HPEnergy fetched
205 0x04AF HPMeters fetched
206 0x055C VentilationSet fetched
207 0x056B VentilationMode fetched
208 0x0583 VentilationMonitor
209 0x0585 Blowerspeed
210 0x0587 Bypass
211 0x05BA HpPoolStatus fetched
212 0x05C4 HpPoolShunt
213 0x05D9 Airquality
214 0x0772 HIUSettings
215 0x0779 HIUMonitor
216 0x07A5 SM100wwCirc fetched
217 0x07A6 SM100wwParam fetched
218 0x07AA SM100wwStatus
219 0x07AB SM100wwCommand
220 0x07AC SM100wwParam1
221 0x07AD SM100ValveStatus
222 0x07AE SM100wwKeepWarm fetched
223 0x07D6 SM100wwTemperature
224 0x07E0 SM100wwStatus2 fetched
225 0x0935 EM100SetMessage fetched
226 0x0936 EM100OutMessage
227 0x0937 EM100TempMessage
228 0x0938 EM100InputMessage
229 0x0939 EM100MonitorMessage
230 0x093A EM100ConfigMessage
231 0x0998 HPSettings fetched
232 0x0999 HPFunctionTest fetched
233 0x099A HPStarts
234 0x099B HPFlowTemp
235 0x099C HPComp
236 0x099D HPComp2
237 0x09A0 HPTemperature
238 0x09A8 HPVolume
+2 -2
View File
@@ -3,7 +3,7 @@ build_flags =
; WiFi settings
-D FACTORY_WIFI_SSID=\"\"
-D FACTORY_WIFI_PASSWORD=\"\"
-D FACTORY_NETWORK_HOSTNAME=\"ems-esp\"
-D FACTORY_WIFI_HOSTNAME=\"ems-esp\"
; Access point settings
-D FACTORY_AP_PROVISION_MODE=AP_MODE_DISCONNECTED
@@ -24,7 +24,7 @@ build_flags =
-D FACTORY_NTP_TIME_ZONE_LABEL=\"Europe/Amsterdam\"
-D FACTORY_NTP_TIME_ZONE_FORMAT=\"CET-1CEST,M3.5.0,M10.5.0/3\"
-D FACTORY_NTP_SERVER=\"time.google.com\"
-D FACTORY_NTP_THERMOSTAT_SYNC=1
-D FACTORY_NTP_THERMOSTAT_SYNC=0
; MQTT settings
-D FACTORY_MQTT_ENABLED=false
-1
View File
@@ -1,7 +1,6 @@
node_modules/
build/
dist/
dist-recovery/
src/i18n/*
.prettierrc
-1
View File
@@ -18,7 +18,6 @@ export default defineConfig(
{
ignores: [
'dist/*',
'dist-recovery/*',
'*.mjs',
'build/*',
'*.js',
+36 -29
View File
@@ -1,9 +1,9 @@
{
"name": "EMS-ESP",
"version": "3.9.0",
"version": "3.8.2",
"description": "EMS-ESP WebUI",
"homepage": "https://emsesp.org",
"author": "emsesp.org",
"author": "proddy, emsesp.org",
"license": "MIT",
"private": true,
"type": "module",
@@ -18,47 +18,54 @@
"standalone": "concurrently -c \"auto\" \"typesafe-i18n\" \"pnpm:mock-rest\" \"vite dev\"",
"typesafe-i18n": "typesafe-i18n --no-watch",
"build-webUI": "typesafe-i18n --no-watch && vite build && node progmem-generator.js",
"recovery": "vite dev --mode recovery",
"build-recovery": "vite build --mode recovery && node progmem-generator.js --src ./dist-recovery --out ../recovery/WWWData.h --index recovery.html",
"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\""
},
"dependencies": {
"@alova/adapter-xhr": "2.3.3",
"@alova/adapter-xhr": "2.3.1",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^9.5.0",
"@mui/material": "^9.5.0",
"@mui/icons-material": "^9.2.0",
"@mui/material": "^9.2.0",
"@preact/compat": "^18.3.2",
"@table-library/react-table-library": "4.1.15",
"alova": "^3.5.5",
"alova": "3.5.1",
"async-validator": "^4.2.5",
"etag": "^1.8.1",
"formidable": "^3.5.4",
"jwt-decode": "^4.0.0",
"preact": "^11.0.1",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"magic-string": "^0.30.21",
"mime-types": "^3.0.2",
"preact": "^10.29.7",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-icons": "^5.7.0",
"react-router": "^8.4.0",
"typesafe-i18n": "^5.27.1"
"react-router": "^7.18.1",
"react-toastify": "^11.1.0",
"typesafe-i18n": "^5.27.1",
"typescript": "^6.0.3"
},
"devDependencies": {
"@babel/core": "^7.29.7",
"@eslint/js": "^10.0.1",
"@preact/preset-vite": "^2.10.6",
"@preact/compat": "^18.3.2",
"@preact/preset-vite": "^2.10.5",
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
"@types/node": "^26.6.5",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"concurrently": "^10.0.6",
"eslint": "^10.12.0",
"@types/node": "^25.7.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"axe-core": "^4.12.1",
"concurrently": "^9.2.4",
"eslint": "^10.7.0",
"eslint-config-prettier": "^10.1.8",
"etag": "^1.8.1",
"mime-types": "^3.0.2",
"prettier": "^3.9.10",
"rollup-plugin-visualizer": "^7.1.1",
"terser": "^5.51.2",
"typescript": "^6.0.3",
"typescript-eslint": "^8.71.1",
"vite": "^8.3.4",
"vite-plugin-imagemin": "^0.6.1"
"prettier": "^3.9.5",
"rollup-plugin-visualizer": "^7.0.1",
"terser": "^5.47.1",
"typescript-eslint": "^8.59.3",
"vite": "^8.0.12",
"vite-plugin-imagemin": "^0.6.1",
"vite-tsconfig-paths": "^6.1.1"
},
"packageManager": "pnpm@12.11.2"
"packageManager": "pnpm@10.33.4+sha512.1c67b3b359b2d408119ba1ed289f34b8fc3c6873412bec6fd264fbdc82489e510fcbecb9ce9d22dae7f3b76269d8441046014bdca53b9979cd7a561ad631b800"
}
+940 -937
View File
File diff suppressed because it is too large Load Diff
+32 -7
View File
@@ -1,8 +1,33 @@
allowBuilds:
cwebp-bin: false
esbuild: false
gifsicle: false
jpegtran-bin: false
mozjpeg: false
optipng-bin: false
pngquant-bin: false
cwebp-bin: set this to true or false
esbuild: set this to true or false
gifsicle: set this to true or false
jpegtran-bin: set this to true or false
mozjpeg: set this to true or false
optipng-bin: set this to true or false
pngquant-bin: set this to true or false
minimumReleaseAgeExclude:
- '@babel/code-frame@7.29.7'
- '@babel/compat-data@7.29.7'
- '@babel/core@7.29.7'
- '@babel/generator@7.29.7'
- '@babel/helper-compilation-targets@7.29.7'
- '@babel/helper-globals@7.29.7'
- '@babel/helper-module-imports@7.29.7'
- '@babel/helper-module-transforms@7.29.7'
- '@babel/helper-string-parser@7.29.7'
- '@babel/helper-validator-identifier@7.29.7'
- '@babel/helper-validator-option@7.29.7'
- '@babel/helpers@7.29.7'
- '@babel/parser@7.29.7'
- '@babel/template@7.29.7'
- '@babel/traverse@7.29.7'
- '@babel/types@7.29.7'
onlyBuiltDependencies:
- cwebp-bin
- esbuild
- gifsicle
- jpegtran-bin
- mozjpeg
- optipng-bin
- pngquant-bin
+4 -51
View File
@@ -10,17 +10,10 @@ import mime from 'mime-types';
import { relative, resolve, sep } from 'path';
import zlib from 'zlib';
// optional args: --src <dir> --out <file> --index <html file served as /index.html>
const getArg = (name, fallback) => {
const i = process.argv.indexOf(name);
return i !== -1 && process.argv[i + 1] ? process.argv[i + 1] : fallback;
};
const ARDUINO_INCLUDES = '#include <Arduino.h>\n\n';
const INDENT = ' ';
const outputPath = getArg('--out', '../src/ESP32React/WWWData.h');
const sourcePath = getArg('--src', './dist');
const indexFile = getArg('--index', 'index.html');
const outputPath = '../src/ESP32React/WWWData.h';
const sourcePath = './dist';
const bytesPerLine = 20;
let totalSize = 0;
let bundleStats = {
@@ -53,38 +46,6 @@ ${fileInfo.map((f) => `${INDENT}{"${f.uri}", "${f.mimeType}", ${f.variable}, ${f
static constexpr size_t WWW_ASSETS_COUNT = sizeof(WWW_ASSETS) / sizeof(WWW_ASSETS[0]);
`;
// Optional locale allow-list, shared with the Vite build via VITE_APP_LOCALES
// (e.g. "en,de,nl"). When set, locale chunks outside the list are NOT embedded
// into firmware flash. `en` is always kept as the fallback. Unset => embed all.
const ALL_LOCALES = [
'cs',
'de',
'en',
'fr',
'it',
'nl',
'no',
'pl',
'sk',
'sv',
'tr'
];
const localeAllowList = (process.env.VITE_APP_LOCALES || '')
.split(',')
.map((locale) => locale.trim())
.filter(Boolean);
const isExcludedLocaleChunk = (relativeFilePath) => {
if (localeAllowList.length === 0) return false;
const base = relativeFilePath.split(sep).pop();
const match = /^([a-z]{2})-[A-Za-z0-9_-]+\.js$/.exec(base);
if (!match) return false;
const code = match[1];
// Only treat known locale codes as locale chunks; never drop the en fallback.
if (!ALL_LOCALES.includes(code) || code === 'en') return false;
return !localeAllowList.includes(code);
};
const getFilesSync = (dir, files = []) => {
readdirSync(dir, { withFileTypes: true }).forEach((entry) => {
const entryPath = resolve(dir, entry.name);
@@ -136,10 +97,7 @@ const writeFile = (relativeFilePath, buffer) => {
bundleStats[fileType].compressed += zipBuffer.length;
fileInfo.push({
uri:
relativeFilePath === indexFile
? '/index.html'
: '/' + relativeFilePath.replace(sep, '/'),
uri: '/' + relativeFilePath.replace(sep, '/'),
mimeType,
variable,
size,
@@ -158,12 +116,7 @@ writeStream.write(ARDUINO_INCLUDES);
const buildPath = resolve(sourcePath);
for (const filePath of getFilesSync(buildPath)) {
const relativeFilePath = relative(buildPath, filePath);
if (isExcludedLocaleChunk(relativeFilePath)) {
console.log(`Skipping locale (not in VITE_APP_LOCALES): ${relativeFilePath}`);
continue;
}
writeFile(relativeFilePath, readFileSync(filePath));
writeFile(relative(buildPath, filePath), readFileSync(filePath));
}
writeStream.write(generateWWWClass());
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

-13
View File
@@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<link rel="stylesheet" href="/css/roboto.css" />
<title>EMS-ESP Recovery</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/recovery/index.tsx"></script>
</body>
</html>
+37 -37
View File
@@ -1,15 +1,14 @@
import { memo, useEffect, useState } from 'react';
import { Outlet } from 'react-router';
import { memo, useCallback, useEffect, useState } from 'react';
import { ToastContainer, Zoom } from 'react-toastify';
import AppRouting from 'AppRouting';
import CustomTheme from 'CustomTheme';
import { Toaster } from 'components/toast';
import { Authentication } from 'contexts/authentication';
import TypesafeI18n from 'i18n/i18n-react';
import type { Locales } from 'i18n/i18n-types';
import { loadLocaleAsync } from 'i18n/i18n-util.async';
import { detectLocale, navigatorDetector } from 'typesafe-i18n/detectors';
const ALL_LOCALES = [
const AVAILABLE_LOCALES = [
'de',
'en',
'it',
@@ -23,50 +22,51 @@ const ALL_LOCALES = [
'cs'
] as Locales[];
// Optional build-time allow-list (e.g. VITE_APP_LOCALES="en,de,nl"). When unset,
// every locale is available. `en` is always kept as the fallback locale, and the
// progmem generator embeds the matching subset into firmware flash.
const localeAllowList = (import.meta.env.VITE_APP_LOCALES ?? '')
.split(',')
.map((locale) => locale.trim())
.filter(Boolean);
const AVAILABLE_LOCALES: Locales[] = localeAllowList.length
? ALL_LOCALES.filter(
(locale) => locale === 'en' || localeAllowList.includes(locale)
)
: ALL_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 [wasLoaded, setWasLoaded] = useState(false);
const [locale, setLocale] = useState<Locales>('en');
useEffect(() => {
const initializeLocale = async () => {
const browserLocale = detectLocale('en', AVAILABLE_LOCALES, navigatorDetector);
const stored = localStorage.getItem('lang');
// Ignore a stored locale that isn't available (e.g. trimmed from this build).
const newLocale =
stored && AVAILABLE_LOCALES.includes(stored as Locales)
? (stored as Locales)
: browserLocale;
localStorage.setItem('lang', newLocale);
setLocale(newLocale);
await loadLocaleAsync(newLocale);
setWasLoaded(true);
};
void initializeLocale();
// Memoize locale initialization to prevent unnecessary re-runs
const initializeLocale = useCallback(async () => {
const browserLocale = detectLocale('en', AVAILABLE_LOCALES, navigatorDetector);
const newLocale = (localStorage.getItem('lang') || browserLocale) as Locales;
localStorage.setItem('lang', newLocale);
setLocale(newLocale);
await loadLocaleAsync(newLocale);
setWasLoaded(true);
}, []);
useEffect(() => {
void initializeLocale();
}, [initializeLocale]);
if (!wasLoaded) return null;
return (
<TypesafeI18n locale={locale}>
<CustomTheme>
<Authentication>
<Outlet />
</Authentication>
<Toaster />
<AppRouting />
<ToastContainer {...TOAST_CONTAINER_PROPS} />
</CustomTheme>
</TypesafeI18n>
);
+72
View File
@@ -0,0 +1,72 @@
import { type FC, memo, useContext, useEffect, useRef } from 'react';
import { Navigate, Route, Routes } from 'react-router';
import { toast } from 'react-toastify';
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';
interface SecurityRedirectProps {
readonly message: string;
readonly signOut?: boolean;
}
const RootRedirect: FC<SecurityRedirectProps> = memo(
({ message, signOut = false }) => {
const { signOut: contextSignOut } = useContext(AuthenticationContext);
const hasShownToast = useRef(false);
useEffect(() => {
// Prevent duplicate toasts on strict mode or re-renders
if (!hasShownToast.current) {
hasShownToast.current = true;
if (signOut) {
contextSignOut(false);
}
toast.success(message);
}
// Only run once on mount - using ref to track execution
}, []);
return <Navigate to="/" replace />;
}
);
const AppRouting: FC = memo(() => {
const { LL } = useI18nContext();
return (
<Authentication>
<Routes>
<Route
path="/unauthorized"
element={<RootRedirect message={LL.PLEASE_SIGNIN()} signOut />}
/>
<Route
path="/fileUpdated"
element={<RootRedirect message={LL.UPLOAD_SUCCESSFUL()} />}
/>
<Route
path="/"
element={
<RequireUnauthenticated>
<SignIn />
</RequireUnauthenticated>
}
/>
<Route
path="/*"
element={
<RequireAuthenticated>
<AuthenticatedRouting />
</RequireAuthenticated>
}
/>
</Routes>
</Authentication>
);
});
export default AppRouting;
+79
View File
@@ -0,0 +1,79 @@
import { memo, useContext } from 'react';
import { Navigate, Route, Routes } from 'react-router';
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 UserProfile from 'app/main/UserProfile';
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';
const AuthenticatedRouting = memo(() => {
const { me } = useContext(AuthenticatedContext);
return (
<Layout>
<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="/status/hardwarestatus/*" element={<HardwareStatus />} />
<Route path="/status/activity" element={<Activity />} />
<Route path="/status/log" element={<SystemLog />} />
<Route path="/status/mqtt" element={<MqttStatus />} />
<Route path="/status/ntp" element={<NTPStatus />} />
<Route path="/status/ap" element={<APStatus />} />
<Route path="/status/network" element={<NetworkStatus />} />
<Route path="/status/version" element={<Version />} />
{me.admin && (
<>
<Route path="/settings" element={<Settings />} />
<Route path="/settings/application" element={<ApplicationSettings />} />
<Route path="/settings/mqtt" element={<MqttSettings />} />
<Route path="/settings/ntp" element={<NTPSettings />} />
<Route path="/settings/ap" element={<APSettings />} />
<Route path="/settings/modules" element={<Modules />} />
<Route path="/settings/downloadUpload" element={<DownloadUpload />} />
<Route path="/settings/network/*" element={<Network />} />
<Route path="/settings/security/*" element={<Security />} />
<Route path="/customizations" element={<Customizations />} />
<Route path="/scheduler" element={<Scheduler />} />
<Route path="/customentities" element={<CustomEntities />} />
</>
)}
<Route path="/*" element={<Navigate to="/" />} />
</Routes>
</Layout>
);
});
export default AuthenticatedRouting;
+17 -12
View File
@@ -1,4 +1,5 @@
import { memo, useContext, useEffect, useState } from 'react';
import { memo, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { toast } from 'react-toastify';
import ForwardIcon from '@mui/icons-material/Forward';
import { Box, Button, Paper, Typography } from '@mui/material';
@@ -6,19 +7,18 @@ import type { Theme } from '@mui/material/styles';
import * as AuthenticationApi from 'components/routing/authentication';
import { useRequest } from 'alova/client';
import type { ValidateFieldsError } from 'async-validator';
import {
LanguageSelector,
ValidatedPasswordField,
ValidatedTextField
} from 'components';
import { toast } from 'components/toast';
import { AuthenticationContext } from 'contexts/authentication';
import { PROJECT_NAME } from 'env';
import { useI18nContext } from 'i18n/i18n-react';
import type { SignInRequest } from 'types';
import { onEnterCallback, updateValue } from 'utils';
import { SIGN_IN_REQUEST_VALIDATOR, ValidationError, validate } from 'validators';
import type { ValidateFieldsError } from 'validators/schema';
const SignIn = memo(() => {
const authenticationContext = useContext(AuthenticationContext);
@@ -43,13 +43,18 @@ const SignIn = memo(() => {
}
});
const updateLoginRequestValue = updateValue((updater) =>
setSignInRequest(
updater as unknown as (prevState: SignInRequest) => SignInRequest
)
// Memoize callback to prevent recreation on every render
const updateLoginRequestValue = useMemo(
() =>
updateValue((updater) =>
setSignInRequest(
updater as unknown as (prevState: SignInRequest) => SignInRequest
)
),
[]
);
const signIn = async () => {
const signIn = useCallback(async () => {
await callSignIn(signInRequest).catch((event: Error) => {
if (event.message === 'Unauthorized') {
toast.warning(LL.INVALID_LOGIN());
@@ -58,9 +63,9 @@ const SignIn = memo(() => {
}
setProcessing(false);
});
};
}, [callSignIn, signInRequest, LL]);
const validateAndSignIn = async () => {
const validateAndSignIn = useCallback(async () => {
setProcessing(true);
SIGN_IN_REQUEST_VALIDATOR.messages({
required: LL.IS_REQUIRED('%s')
@@ -72,9 +77,9 @@ const SignIn = memo(() => {
setFieldErrors((error as ValidationError).fieldErrors);
setProcessing(false);
}
};
}, [signInRequest, signIn, LL]);
const submitOnEnter = onEnterCallback(signIn);
const submitOnEnter = useMemo(() => onEnterCallback(signIn), [signIn]);
// get rid of scrollbar
useEffect(() => {
+2 -20
View File
@@ -4,8 +4,6 @@ import type {
APIcall,
Action,
Activity,
CommandItem,
Commands,
CoreData,
DashboardData,
DeviceData,
@@ -104,7 +102,8 @@ export const readSchedule = () =>
o_deleted: si.deleted,
o_flags: si.flags,
o_time: si.time,
o_cmd_name: si.cmd_name,
o_cmd: si.cmd,
o_value: si.value,
o_name: si.name
}));
}
@@ -112,23 +111,6 @@ export const readSchedule = () =>
export const writeSchedule = (data: Schedule) =>
alovaInstance.Post('/rest/schedule', data);
// Commands
export const readCommands = () =>
alovaInstance.Get<CommandItem[]>('/rest/commands', {
transform(data) {
const commands = (data as Commands).commands;
return commands.map((ci) => ({
...ci,
o_id: ci.id,
o_cmd: ci.cmd,
o_value: ci.value,
o_name: ci.name
}));
}
});
export const writeCommands = (data: Commands) =>
alovaInstance.Post('/rest/commands', data);
// Modules
export const readModules = () =>
alovaInstance.Get<ModuleItem[]>('/rest/modules', {
+9
View File
@@ -57,3 +57,12 @@ export const alovaInstance = createAlova({
onSuccess: handleResponse
}
});
export const alovaInstanceGH = createAlova({
baseURL:
process.env.NODE_ENV === 'development'
? '/gh'
: 'https://api.github.com/repos/emsesp/EMS-ESP32/releases',
statesHook: ReactHook,
requestAdapter: xhrRequestAdapter()
});
+25 -2
View File
@@ -1,8 +1,8 @@
import type { LogSettings, SystemStatus } from 'types';
import { alovaInstance } from './endpoints';
import { alovaInstance, alovaInstanceGH } from './endpoints';
// systemStatus - also used in System Monitor to ping the ESP
// systemStatus - also used to ping in System Monitor for pinging
export const readSystemStatus = () =>
alovaInstance.Get<SystemStatus>('/rest/systemStatus');
@@ -13,6 +13,29 @@ export const updateLogSettings = (data: LogSettings) =>
alovaInstance.Post('/rest/logSettings', data);
export const fetchLogES = () => alovaInstance.Get('/es/log');
// Get versions from GitHub
// cache for 10 minutes to stop getting the IP blocked by GitHub
export const getStableVersion = () =>
alovaInstanceGH.Get('latest', {
cacheFor: 60 * 10 * 1000,
transform(response: { data: { name: string; published_at: string } }) {
return {
name: response.data.name.substring(1),
published_at: response.data.published_at
};
}
});
export const getDevVersion = () =>
alovaInstanceGH.Get('tags/latest', {
cacheFor: 60 * 10 * 1000,
transform(response: { data: { name: string; published_at: string } }) {
return {
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) => {
-284
View File
@@ -1,284 +0,0 @@
import { useState } from 'react';
import { useBlocker } from 'react-router';
import AddIcon from '@mui/icons-material/Add';
import CancelIcon from '@mui/icons-material/Cancel';
import WarningIcon from '@mui/icons-material/Warning';
import { Box, Button, Typography } from '@mui/material';
import {
Body,
Cell,
Header,
HeaderCell,
HeaderRow,
Row,
Table
} from '@table-library/react-table-library/table';
import { useTheme } from '@table-library/react-table-library/theme';
import { updateState, useRequest } from 'alova/client';
import {
BlockNavigation,
ButtonRow,
FormLoader,
SectionContent,
useLayoutTitle
} from 'components';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react';
import { useInterval } from 'utils';
import { readCommands, writeCommands } from '../../api/app';
import CommandsDialog from './CommandsDialog';
import type { CommandItem, Commands as CommandsType } from './types';
import { commandItemValidation } from './validators';
const INTERVAL_DELAY = 30000;
const MIN_ID = -100;
const MAX_ID = 100;
const DEFAULT_COMMAND_ITEM: Omit<CommandItem, 'id'> = {
cmd: '',
value: '',
name: '',
deleted: false
};
const commandsTheme = {
Table: `
--data-table-library_grid-template-columns: repeat(1, minmax(100px, 1fr)) repeat(1, minmax(100px, 1fr)) 160px;
`,
BaseRow: `
font-size: 14px;
.td {
height: 32px;
}
`,
BaseCell: `
&:nth-of-type(1) {
padding: 8px;
}
`,
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 {
border-bottom: 1px solid #565656;
}
&:hover .td {
background-color: #177ac9;
}
`
};
const Commands = () => {
const { LL } = useI18nContext();
const [numChanges, setNumChanges] = useState<number>(0);
const blocker = useBlocker(numChanges !== 0);
const [selectedItem, setSelectedItem] = useState<CommandItem>();
const [creating, setCreating] = useState<boolean>(false);
const [dialogOpen, setDialogOpen] = useState<boolean>(false);
useLayoutTitle(LL.COMMANDS());
const {
data: commands,
send: fetchCommands,
error
} = useRequest(readCommands, {
initialData: []
});
const { send: updateCommands } = useRequest(
(data: CommandsType) => writeCommands(data),
{ immediate: false }
);
const hasChanged = (ci: CommandItem) =>
ci.id !== ci.o_id ||
(ci.name || '') !== (ci.o_name || '') ||
ci.cmd !== ci.o_cmd ||
ci.value !== ci.o_value ||
ci.deleted !== ci.o_deleted;
useInterval(() => {
if (numChanges === 0) {
void fetchCommands();
}
}, INTERVAL_DELAY);
const theme = useTheme(commandsTheme);
const saveCommands = async () => {
try {
await updateCommands({
commands: commands
.filter((ci: CommandItem) => !ci.deleted)
.map((ci: CommandItem) => ({
id: ci.id,
cmd: ci.cmd,
value: ci.value,
name: ci.name
}))
});
toast.success(LL.UPDATED_OF(LL.COMMANDS()));
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
toast.error(message);
} finally {
await fetchCommands();
setNumChanges(0);
}
};
const editItem = (ci: CommandItem) => {
setCreating(false);
setSelectedItem(ci);
setDialogOpen(true);
if (ci.o_name === undefined) {
ci.o_name = ci.name;
}
};
const onDialogClose = () => {
setDialogOpen(false);
};
const onDialogCancel = async () => {
await fetchCommands().then(() => {
setNumChanges(0);
});
};
const onDialogSave = (updatedItem: CommandItem) => {
setDialogOpen(false);
void updateState(readCommands(), (data: CommandItem[]) => {
const new_data = creating
? [...data, updatedItem]
: data.map((ci) =>
ci.id === updatedItem.id ? { ...ci, ...updatedItem } : ci
);
setNumChanges(new_data.filter((ci) => hasChanged(ci)).length);
return new_data;
});
};
const addItem = () => {
setCreating(true);
const newItem: CommandItem = {
id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
...DEFAULT_COMMAND_ITEM
};
setSelectedItem(newItem);
setDialogOpen(true);
};
const filteredCommands = commands.filter((ci: CommandItem) => !ci.deleted);
const renderCommands = () => {
if (!commands) {
return (
<FormLoader onRetry={fetchCommands} errorMessage={error?.message || ''} />
);
}
return (
<Table
data={{ nodes: filteredCommands }}
theme={theme}
layout={{ custom: true }}
>
{(tableList: CommandItem[]) => (
<>
<Header>
<HeaderRow>
<HeaderCell stiff>{LL.COMMAND(0)}</HeaderCell>
<HeaderCell stiff>{LL.VALUE(0)}</HeaderCell>
<HeaderCell stiff>{LL.NAME(0)}</HeaderCell>
</HeaderRow>
</Header>
<Body>
{tableList.map((ci: CommandItem) => (
<Row key={ci.id} item={ci} onClick={() => editItem(ci)}>
<Cell>{ci.cmd}</Cell>
<Cell>{ci.value}</Cell>
<Cell>{ci.name}</Cell>
</Row>
))}
</Body>
</>
)}
</Table>
);
};
return (
<SectionContent>
{blocker ? <BlockNavigation blocker={blocker} /> : null}
<Typography sx={{ mb: 2 }} color="warning" variant="body1">
{LL.COMMANDS_HELP_1()}.
</Typography>
{renderCommands()}
{selectedItem && (
<CommandsDialog
open={dialogOpen}
creating={creating}
onClose={onDialogClose}
onSave={onDialogSave}
selectedItem={selectedItem}
validator={commandItemValidation(commands, selectedItem)}
/>
)}
<Box sx={{ display: 'flex', flexWrap: 'wrap' }}>
<Box sx={{ flexGrow: 1 }}>
{numChanges !== 0 && (
<ButtonRow>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={onDialogCancel}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<WarningIcon color="warning" />}
variant="contained"
color="info"
onClick={saveCommands}
>
{LL.APPLY_CHANGES(numChanges)}
</Button>
</ButtonRow>
)}
</Box>
<Box sx={{ flexWrap: 'nowrap', whiteSpace: 'nowrap' }}>
<ButtonRow>
<Button
startIcon={<AddIcon />}
variant="outlined"
color="primary"
onClick={addItem}
>
{LL.ADD(0)}
</Button>
</ButtonRow>
</Box>
</Box>
</SectionContent>
);
};
export default Commands;
-206
View File
@@ -1,206 +0,0 @@
import { useEffect, useState } from 'react';
import AddIcon from '@mui/icons-material/Add';
import CancelIcon from '@mui/icons-material/Cancel';
import DoneIcon from '@mui/icons-material/Done';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import RemoveIcon from '@mui/icons-material/RemoveCircleOutlined';
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
TextField
} from '@mui/material';
import { callAction } from '@/api/app';
import { dialogStyle } from 'CustomTheme';
import { useRequest } from 'alova/client';
import { ValidatedTextField } from 'components';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react';
import { updateValue } from 'utils';
import { ValidationError, validate } from 'validators';
import type Schema from 'validators/schema';
import type { ValidateFieldsError } from 'validators/schema';
import type { CommandItem } from './types';
interface CommandsDialogProps {
open: boolean;
creating: boolean;
onClose: () => void;
onSave: (ci: CommandItem) => void;
selectedItem: CommandItem;
validator: Schema;
}
const CommandsDialog = ({
open,
creating,
onClose,
onSave,
selectedItem,
validator
}: CommandsDialogProps) => {
const { LL } = useI18nContext();
const [hasChanges, setHasChanges] = useState<boolean>(false);
const [editItem, setEditItem] = useState<CommandItem>(selectedItem);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const updateFormValue = updateValue(
setEditItem as unknown as React.Dispatch<
React.SetStateAction<Record<string, unknown>>
>
);
useEffect(() => {
if (open) {
setFieldErrors(undefined);
setEditItem(selectedItem);
}
}, [open, selectedItem]);
const hasChanged = (ci: CommandItem) =>
ci.id !== ci.o_id ||
(ci.name || '') !== (ci.o_name || '') ||
ci.cmd !== ci.o_cmd ||
ci.value !== ci.o_value ||
ci.deleted !== ci.o_deleted;
useEffect(() => {
setHasChanges(hasChanged(editItem));
}, [editItem]);
const handleSave = async (itemToSave: CommandItem) => {
try {
setFieldErrors(undefined);
await validate(validator, itemToSave);
onSave(itemToSave);
} catch (error) {
setFieldErrors((error as ValidationError).fieldErrors);
} finally {
// setHasChanges(false);
}
};
const save = async () => {
await handleSave(editItem);
};
const { send: executeCommand } = useRequest(
(id: string) => callAction({ action: 'executeCommand', param: id }),
{ immediate: false }
)
.onSuccess(() => {
toast.success(LL.COMMAND_EXECUTED());
})
.onError((error) => {
toast.error(String(error.error?.message || 'An error occurred'));
});
const execute = async () => {
await executeCommand(editItem.name);
};
const remove = () => {
onSave({ ...editItem, deleted: true });
};
const handleClose = (
_event: React.SyntheticEvent,
reason: 'backdropClick' | 'escapeKeyDown'
) => {
if (reason !== 'backdropClick') {
onClose();
}
};
return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
<DialogTitle>
{creating ? `${LL.ADD(1)} ` : LL.UPDATE() + '/' + LL.RUN()}&nbsp;
{LL.COMMAND(0)}
</DialogTitle>
<DialogContent dividers>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
name="cmd"
label={LL.COMMAND(0)}
multiline
fullWidth
value={editItem.cmd}
margin="normal"
onChange={updateFormValue}
/>
<TextField
name="value"
label={LL.VALUE(0)}
multiline
margin="normal"
fullWidth
value={editItem.value}
onChange={updateFormValue}
/>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
name="name"
label={LL.NAME(0)}
value={editItem.name}
fullWidth
margin="normal"
onChange={updateFormValue}
/>
</DialogContent>
<DialogActions>
{!creating && (
<Box sx={{ flexGrow: 1 }}>
<Button
startIcon={<RemoveIcon />}
variant="outlined"
color="warning"
onClick={remove}
>
{LL.REMOVE()}
</Button>
</Box>
)}
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={onClose}
color="secondary"
>
{LL.CANCEL()}
</Button>
{hasChanges && (
<Button
startIcon={creating ? <AddIcon /> : <DoneIcon />}
variant="outlined"
onClick={save}
color="primary"
>
{creating ? LL.ADD(0) : LL.UPDATE()}
</Button>
)}
{!creating && !hasChanges && editItem.cmd !== '' && (
<Button
startIcon={<PlayArrowIcon />}
variant="outlined"
onClick={execute}
color="success"
>
{LL.RUN()}
</Button>
)}
</DialogActions>
</Dialog>
);
};
export default CommandsDialog;
+72 -50
View File
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { useBlocker } from 'react-router';
import { toast } from 'react-toastify';
import AddIcon from '@mui/icons-material/Add';
import CancelIcon from '@mui/icons-material/Cancel';
@@ -25,7 +26,6 @@ import {
SectionContent,
useLayoutTitle
} from 'components';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react';
import { useInterval } from 'utils';
@@ -57,18 +57,20 @@ const CustomEntities = () => {
initialData: []
});
useInterval(() => {
const intervalCallback = useCallback(() => {
if (!dialogOpen && !numChanges) {
void fetchEntities();
}
});
}, [dialogOpen, numChanges, fetchEntities]);
useInterval(intervalCallback);
const { send: writeEntities } = useRequest(
(data: Entities) => writeCustomEntities(data),
{ immediate: false }
);
const hasEntityChanged = (ei: EntityItem) => {
const hasEntityChanged = useCallback((ei: EntityItem) => {
return (
ei.id !== ei.o_id ||
ei.ram !== ei.o_ram ||
@@ -84,19 +86,21 @@ const CustomEntities = () => {
ei.deleted !== ei.o_deleted ||
(ei.value || '') !== (ei.o_value || '')
);
};
}, []);
const entity_theme = useTheme({
Table: `
const entity_theme = useMemo(
() =>
useTheme({
Table: `
--data-table-library_grid-template-columns: repeat(1, minmax(60px, 1fr)) minmax(80px, auto) 80px 80px 80px 120px;
`,
BaseRow: `
BaseRow: `
font-size: 14px;
.td {
height: 32px;
}
`,
BaseCell: `
BaseCell: `
&:nth-of-type(1) {
padding: 8px;
}
@@ -116,7 +120,7 @@ const CustomEntities = () => {
text-align: center;
}
`,
HeaderRow: `
HeaderRow: `
text-transform: uppercase;
background-color: black;
color: #90CAF9;
@@ -125,7 +129,7 @@ const CustomEntities = () => {
height: 36px;
}
`,
Row: `
Row: `
background-color: #1e1e1e;
position: relative;
cursor: pointer;
@@ -136,9 +140,11 @@ const CustomEntities = () => {
background-color: #177ac9;
}
`
});
}),
[]
);
const saveEntities = async () => {
const saveEntities = useCallback(async () => {
await writeEntities({
entities: entities
.filter((ei: EntityItem) => !ei.deleted)
@@ -167,41 +173,44 @@ const CustomEntities = () => {
await fetchEntities();
setNumChanges(0);
});
};
}, [entities, writeEntities, LL, fetchEntities]);
const editEntityItem = (ei: EntityItem) => {
const editEntityItem = useCallback((ei: EntityItem) => {
setCreating(false);
setSelectedEntityItem(ei);
setDialogOpen(true);
};
}, []);
const onDialogClose = () => {
const onDialogClose = useCallback(() => {
setDialogOpen(false);
};
}, []);
const onDialogCancel = async () => {
const onDialogCancel = useCallback(async () => {
await fetchEntities().then(() => {
setNumChanges(0);
});
};
}, [fetchEntities]);
const onDialogSave = (updatedItem: EntityItem) => {
setDialogOpen(false);
void updateState(readCustomEntities(), (data: EntityItem[]) => {
const new_data = creating
? [
...data.filter((ei) => creating || ei.o_id !== updatedItem.o_id),
updatedItem
]
: data.map((ei) =>
ei.id === updatedItem.id ? { ...ei, ...updatedItem } : ei
);
setNumChanges(new_data.filter((ei) => hasEntityChanged(ei)).length);
return new_data;
});
};
const onDialogSave = useCallback(
(updatedItem: EntityItem) => {
setDialogOpen(false);
void updateState(readCustomEntities(), (data: EntityItem[]) => {
const new_data = creating
? [
...data.filter((ei) => creating || ei.o_id !== updatedItem.o_id),
updatedItem
]
: data.map((ei) =>
ei.id === updatedItem.id ? { ...ei, ...updatedItem } : ei
);
setNumChanges(new_data.filter((ei) => hasEntityChanged(ei)).length);
return new_data;
});
},
[creating, hasEntityChanged]
);
const onDialogDup = (item: EntityItem) => {
const onDialogDup = useCallback((item: EntityItem) => {
setCreating(true);
setSelectedEntityItem({
id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
@@ -219,9 +228,9 @@ const CustomEntities = () => {
value: item.value
});
setDialogOpen(true);
};
}, []);
const addEntityItem = () => {
const addEntityItem = useCallback(() => {
setCreating(true);
setSelectedEntityItem({
id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
@@ -239,27 +248,30 @@ const CustomEntities = () => {
value: ''
});
setDialogOpen(true);
};
}, []);
const formatValue = (value: unknown, uom: number) => {
const formatValue = useCallback((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]}`}`;
};
}, []);
const showHex = (value: number, digit: number) => {
const showHex = useCallback((value: number, digit: number) => {
return `0x${value.toString(16).toUpperCase().padStart(digit, '0')}`;
};
}, []);
const filteredAndSortedEntities =
entities
?.filter((ei: EntityItem) => !ei.deleted)
.sort((a: EntityItem, b: EntityItem) => a.name.localeCompare(b.name)) ?? [];
const filteredAndSortedEntities = useMemo(
() =>
entities
?.filter((ei: EntityItem) => !ei.deleted)
.sort((a: EntityItem, b: EntityItem) => a.name.localeCompare(b.name)) ?? [],
[entities]
);
const renderEntity = () => {
const renderEntity = useCallback(() => {
if (!entities) {
return (
<FormLoader onRetry={fetchEntities} errorMessage={error?.message || ''} />
@@ -316,7 +328,17 @@ const CustomEntities = () => {
)}
</Table>
);
};
}, [
entities,
error,
fetchEntities,
entity_theme,
editEntityItem,
LL,
filteredAndSortedEntities,
showHex,
formatValue
]);
return (
<SectionContent>
+37 -27
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import AddIcon from '@mui/icons-material/Add';
import CancelIcon from '@mui/icons-material/Cancel';
@@ -23,12 +23,12 @@ import {
} from '@mui/material';
import { dialogStyle } from 'CustomTheme';
import type Schema from 'async-validator';
import type { ValidateFieldsError } from 'async-validator';
import { BlockFormControlLabel, ValidatedTextField } from 'components';
import { useI18nContext } from 'i18n/i18n-react';
import { numberValue, updateValue } from 'utils';
import { ValidationError, validate } from 'validators';
import type Schema from 'validators/schema';
import type { ValidateFieldsError } from 'validators/schema';
import { DeviceValueType, DeviceValueTypeNames, DeviceValueUOM_s } from './types';
import type { EntityItem } from './types';
@@ -68,10 +68,14 @@ const CustomEntitiesDialog = ({
const { LL } = useI18nContext();
const [editItem, setEditItem] = useState<EntityItem>(selectedItem);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const updateFormValue = updateValue(
setEditItem as unknown as React.Dispatch<
React.SetStateAction<Record<string, unknown>>
>
const updateFormValue = useMemo(
() =>
updateValue(
setEditItem as unknown as React.Dispatch<
React.SetStateAction<Record<string, unknown>>
>
),
[]
);
useEffect(() => {
@@ -101,16 +105,16 @@ const CustomEntitiesDialog = ({
}
}, [open, selectedItem]);
const handleClose = (
_event: React.SyntheticEvent,
reason: 'backdropClick' | 'escapeKeyDown'
) => {
if (reason !== 'backdropClick') {
onClose();
}
};
const handleClose = useCallback(
(_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => {
if (reason !== 'backdropClick') {
onClose();
}
},
[onClose]
);
const save = async () => {
const save = useCallback(async () => {
try {
setFieldErrors(undefined);
await validate(validator, editItem);
@@ -134,21 +138,27 @@ const CustomEntitiesDialog = ({
} catch (error) {
setFieldErrors((error as ValidationError).fieldErrors);
}
};
}, [validator, editItem, onSave]);
const remove = () => {
onSave({ ...editItem, deleted: true });
};
const remove = useCallback(() => {
const itemWithDeleted = { ...editItem, deleted: true };
onSave(itemWithDeleted);
}, [editItem, onSave]);
const dup = () => {
const dup = useCallback(() => {
onDup(editItem);
};
}, [editItem, onDup]);
const uomMenuItems = DeviceValueUOM_s.map((val, i) => (
<MenuItem key={val} value={i}>
{val}
</MenuItem>
));
// 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}>
+85 -60
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useBlocker, useLocation } from 'react-router';
import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel';
import EditIcon from '@mui/icons-material/Edit';
@@ -45,7 +46,6 @@ import {
SectionContent,
useLayoutTitle
} from 'components';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react';
import {
@@ -171,17 +171,19 @@ const Customizations = () => {
);
};
const entities_theme = useTheme({
Table: `
const entities_theme = useMemo(
() =>
useTheme({
Table: `
--data-table-library_grid-template-columns: 156px repeat(1, minmax(80px, 1fr)) 45px minmax(45px, auto) minmax(120px, auto);
`,
BaseRow: `
BaseRow: `
font-size: 14px;
.td {
height: 32px;
}
`,
BaseCell: `
BaseCell: `
&:nth-of-type(3) {
text-align: right;
}
@@ -192,7 +194,7 @@ const Customizations = () => {
text-align: right;
}
`,
HeaderRow: `
HeaderRow: `
text-transform: uppercase;
background-color: black;
color: #90CAF9;
@@ -204,7 +206,7 @@ const Customizations = () => {
text-align: center;
}
`,
Row: `
Row: `
background-color: #1e1e1e;
position: relative;
cursor: pointer;
@@ -220,7 +222,7 @@ const Customizations = () => {
background-color: #177ac9;
}
`,
Cell: `
Cell: `
&:nth-of-type(2) {
padding: 8px;
}
@@ -234,7 +236,9 @@ const Customizations = () => {
padding-right: 8px;
}
`
});
}),
[]
);
function hasEntityChanged(de: DeviceEntity) {
return (
@@ -283,23 +287,26 @@ const Customizations = () => {
return value as string;
}
const isCommand = (de: DeviceEntity) => {
const isCommand = useCallback((de: DeviceEntity) => {
return de.n && de.n[0] === '!';
};
}, []);
const formatName = (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;
};
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 getMaskNumber = (newMask: string[]) => {
let new_mask = 0;
@@ -329,27 +336,33 @@ const Customizations = () => {
return new_masks;
};
const filter_entity = (de: DeviceEntity) =>
(de.m & selectedFilters || !selectedFilters) &&
formatName(de, true).toLowerCase().includes(search.toLowerCase());
const filter_entity = useCallback(
(de: DeviceEntity) =>
(de.m & selectedFilters || !selectedFilters) &&
formatName(de, true).toLowerCase().includes(search.toLowerCase()),
[selectedFilters, search, formatName]
);
const maskDisabled = (set: boolean) => {
setDeviceEntities((prev) =>
prev.map((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
};
}
return de;
})
);
};
const maskDisabled = useCallback(
(set: boolean) => {
setDeviceEntities((prev) =>
prev.map((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
};
}
return de;
})
);
},
[filter_entity]
);
const resetCustomization = async () => {
const resetCustomization = useCallback(async () => {
try {
await sendResetCustomizations();
toast.info(LL.CUSTOMIZATIONS_RESTART());
@@ -359,27 +372,30 @@ const Customizations = () => {
setConfirmReset(false);
setRestarting(true);
}
};
}, [sendResetCustomizations, LL]);
const onDialogClose = () => {
setDialogOpen(false);
};
const updateDeviceEntity = (updatedItem: DeviceEntity) => {
const updateDeviceEntity = useCallback((updatedItem: DeviceEntity) => {
setDeviceEntities(
(prev) =>
prev?.map((de) =>
de.id === updatedItem.id ? { ...de, ...updatedItem } : de
) ?? []
);
};
}, []);
const onDialogSave = (updatedItem: DeviceEntity) => {
setDialogOpen(false);
updateDeviceEntity(updatedItem);
};
const onDialogSave = useCallback(
(updatedItem: DeviceEntity) => {
setDialogOpen(false);
updateDeviceEntity(updatedItem);
},
[updateDeviceEntity]
);
const editDeviceEntity = (de: DeviceEntity) => {
const editDeviceEntity = useCallback((de: DeviceEntity) => {
if (de.n === undefined || (de.n && de.n[0] === '!')) {
return;
}
@@ -390,9 +406,9 @@ const Customizations = () => {
setSelectedDeviceEntity(de);
setDialogOpen(true);
};
}, []);
const saveCustomization = async () => {
const saveCustomization = useCallback(async () => {
if (!devices || !deviceEntities || selectedDevice === -1) {
return;
}
@@ -425,9 +441,9 @@ const Customizations = () => {
.finally(() => {
setOriginalSettings(deviceEntities);
});
};
}, [devices, deviceEntities, selectedDevice, sendCustomizationEntities, LL]);
const renameDevice = async () => {
const renameDevice = useCallback(async () => {
await sendDeviceName({
id: selectedDevice,
name: selectedDeviceName,
@@ -443,7 +459,14 @@ const Customizations = () => {
setRename(false);
await fetchCoreData();
});
};
}, [
selectedDevice,
selectedDeviceName,
selectedDeviceBrand,
sendDeviceName,
LL,
fetchCoreData
]);
const renderDeviceList = () => (
<>
@@ -489,8 +512,7 @@ const Customizations = () => {
</MenuItem>
{devices.devices.map(
(device: Device) =>
device.id < 90 &&
device.e !== 0 && (
device.id < 90 && (
<MenuItem key={device.id} value={device.id}>
{device.n}&nbsp;({device.tn})
</MenuItem>
@@ -540,7 +562,10 @@ const Customizations = () => {
</>
);
const filteredEntities = deviceEntities.filter((de) => filter_entity(de));
const filteredEntities = useMemo(
() => deviceEntities.filter((de) => filter_entity(de)),
[deviceEntities, filter_entity]
);
const renderDeviceData = () => {
return (
+42 -32
View File
@@ -1,4 +1,4 @@
import { memo, useEffect, useState } from 'react';
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
import CloseIcon from '@mui/icons-material/Close';
@@ -57,16 +57,23 @@ const CustomizationsDialog = ({
const [editItem, setEditItem] = useState<DeviceEntity>(selectedItem);
const [error, setError] = useState<boolean>(false);
const updateFormValue = updateValue(
setEditItem as unknown as React.Dispatch<
React.SetStateAction<Record<string, unknown>>
>
const updateFormValue = useMemo(
() =>
updateValue(
setEditItem as unknown as React.Dispatch<
React.SetStateAction<Record<string, unknown>>
>
),
[]
);
const isWriteableNumber =
typeof editItem.v === 'number' &&
editItem.w &&
!(editItem.m & DeviceEntityMask.DV_READONLY);
const isWriteableNumber = useMemo(
() =>
typeof editItem.v === 'number' &&
editItem.w &&
!(editItem.m & DeviceEntityMask.DV_READONLY),
[editItem.v, editItem.w, editItem.m]
);
useEffect(() => {
if (open) {
@@ -75,16 +82,16 @@ const CustomizationsDialog = ({
}
}, [open, selectedItem]);
const handleClose = (
_event: React.SyntheticEvent,
reason: 'backdropClick' | 'escapeKeyDown'
) => {
if (reason !== 'backdropClick') {
onClose();
}
};
const handleClose = useCallback(
(_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => {
if (reason !== 'backdropClick') {
onClose();
}
},
[onClose]
);
const save = () => {
const save = useCallback(() => {
if (
isWriteableNumber &&
editItem.mi &&
@@ -95,31 +102,34 @@ const CustomizationsDialog = ({
} else {
onSave(editItem);
}
};
}, [isWriteableNumber, editItem, onSave]);
const updateDeviceEntity = (updatedItem: DeviceEntity) => {
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]
);
return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
<DialogTitle>{`${LL.EDIT()} ${LL.ENTITY()}`}</DialogTitle>
<DialogTitle>{dialogTitle}</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={
editItem.w ? (
<DoneIcon color="success" sx={{ fontSize: ICON_SIZE }} />
) : (
<CloseIcon color="error" sx={{ fontSize: ICON_SIZE }} />
)
}
/>
<LabelValue label={LL.WRITEABLE()} value={writeableIcon} />
<Box sx={{ mt: 1, mb: 2 }}>
<EntityMaskToggle onUpdate={updateDeviceEntity} de={editItem} />
+101 -104
View File
@@ -1,12 +1,12 @@
import { memo, useContext, useEffect, useState } from 'react';
import { memo, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { IconContext } from 'react-icons/lib';
import { Link } from 'react-router';
import { toast } from 'react-toastify';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import EditIcon from '@mui/icons-material/Edit';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import HelpOutlineIcon from '@mui/icons-material/HelpOutlined';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import UnfoldLessIcon from '@mui/icons-material/UnfoldLess';
import UnfoldMoreIcon from '@mui/icons-material/UnfoldMore';
import {
@@ -29,7 +29,6 @@ import {
SectionContent,
useLayoutTitle
} from 'components';
import { toast } from 'components/toast';
import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react';
import { useInterval, usePersistState } from 'utils';
@@ -76,38 +75,42 @@ const Dashboard = memo(() => {
{
immediate: false
}
)
.onSuccess(() => {
toast.success(LL.WRITE_CMD_SENT());
})
.onError((error) => {
toast.error(String(error.error?.message || 'An error occurred'));
});
);
const deviceValueDialogSave = async (devicevalue: DeviceValue) => {
if (!selectedDashboardItem) {
return;
}
// skip if we're executing an immediate schedule as this is handled in DevicesDialog::doAction()
if (devicevalue.v !== undefined) {
const deviceValueDialogSave = useCallback(
async (devicevalue: DeviceValue) => {
if (!selectedDashboardItem) {
return;
}
const id = selectedDashboardItem.parentNode.id; // this is the parent ID
await sendDeviceValue({ id, c: devicevalue.c ?? '', v: devicevalue.v });
}
setDeviceValueDialogOpen(false);
setSelectedDashboardItem(undefined);
};
await sendDeviceValue({ id, c: devicevalue.c ?? '', v: devicevalue.v })
.then(() => {
toast.success(LL.WRITE_CMD_SENT());
})
.catch((error: Error) => {
toast.error(error.message);
})
.finally(() => {
setDeviceValueDialogOpen(false);
setSelectedDashboardItem(undefined);
});
},
[selectedDashboardItem, sendDeviceValue, LL]
);
const dashboard_theme = useTheme({
Table: `
const dashboard_theme = useMemo(
() =>
useTheme({
Table: `
--data-table-library_grid-template-columns: minmax(80px, auto) 120px 32px;
`,
BaseRow: `
BaseRow: `
font-size: 14px;
.td {
height: 28px;
}
`,
Row: `
Row: `
cursor: pointer;
background-color: #1e1e1e;
&:nth-of-type(odd) .td {
@@ -117,7 +120,7 @@ const Dashboard = memo(() => {
background-color: #177ac9;
},
`,
BaseCell: `
BaseCell: `
&:nth-of-type(2) {
text-align: right;
}
@@ -125,7 +128,9 @@ const Dashboard = memo(() => {
text-align: right;
}
`
});
}),
[]
);
const tree = useTree(
{ nodes: [...data.nodes] },
@@ -159,71 +164,79 @@ const Dashboard = memo(() => {
}
});
const nodeIds = useMemo(
() => data.nodes.map((item: DashboardItem) => item.id),
[data.nodes]
);
useEffect(() => {
const nodeIds = data.nodes.map((item: DashboardItem) => item.id);
showAll
? tree.fns.onAddAll(nodeIds) // expand tree
: tree.fns.onRemoveAll(); // collapse tree
}, [parentNodes]);
const showType = (n?: string, t?: number) => {
// if we have a name show it
if (n) {
return n;
}
if (t) {
// otherwise pick translation based on type
switch (t) {
case DeviceType.CUSTOM:
return LL.CUSTOM_ENTITIES(0);
case DeviceType.ANALOGSENSOR:
return LL.ANALOG_SENSORS();
case DeviceType.TEMPERATURESENSOR:
return LL.TEMP_SENSORS();
case DeviceType.COMMAND:
return LL.COMMANDS();
case DeviceType.SCHEDULER:
return LL.SCHEDULER();
default:
break;
const showType = useCallback(
(n?: string, t?: number) => {
// if we have a name show it
if (n) {
return n;
}
}
return '';
};
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' }}>
<DeviceIcon type_id={di.t ?? 0} />
&nbsp;&nbsp;{showType(di.n, di.t)}
<span style={{ color: 'lightblue' }}>&nbsp;({di.nodes?.length})</span>
</span>
);
if (t) {
// otherwise pick translation based on type
switch (t) {
case DeviceType.CUSTOM:
return LL.CUSTOM_ENTITIES(0);
case DeviceType.ANALOGSENSOR:
return LL.ANALOG_SENSORS();
case DeviceType.TEMPERATURESENSOR:
return LL.TEMP_SENSORS();
case DeviceType.SCHEDULER:
return LL.SCHEDULER();
default:
break;
}
}
}
if (di.dv) {
return <span>{di.dv.id.slice(2)}</span>;
}
return null;
};
return '';
},
[LL]
);
const hasMask = (id: string, mask: number) =>
(parseInt(id.slice(0, 2), 16) & mask) === mask;
const showName = useCallback(
(di: DashboardItem) => {
if (di.id < 100) {
// if its a device (parent node) and has entities
if (di.nodes?.length) {
return (
<span style={{ fontSize: '15px' }}>
<DeviceIcon type_id={di.t ?? 0} />
&nbsp;&nbsp;{showType(di.n, di.t)}
<span style={{ color: 'lightblue' }}>&nbsp;({di.nodes?.length})</span>
</span>
);
}
}
if (di.dv) {
return <span>{di.dv.id.slice(2)}</span>;
}
return null;
},
[showType]
);
const editDashboardValue = (di: DashboardItem) => {
// don't execute on parent nodes
if (!me.admin || di.id <= 99) {
return;
}
const hasMask = useCallback(
(id: string, mask: number) => (parseInt(id.slice(0, 2), 16) & mask) === mask,
[]
);
if (di.dv?.c) {
setSelectedDashboardItem(di);
setDeviceValueDialogOpen(true);
}
};
const editDashboardValue = useCallback(
(di: DashboardItem) => {
if (me.admin && di.dv?.c) {
setSelectedDashboardItem(di);
setDeviceValueDialogOpen(true);
}
},
[me.admin]
);
const handleShowAll = (
_event: React.MouseEvent<HTMLElement>,
@@ -235,9 +248,10 @@ const Dashboard = memo(() => {
}
};
const hasFavEntities = data.nodes.filter(
(item: DashboardItem) => item.id <= 90
).length;
const hasFavEntities = useMemo(
() => data.nodes.filter((item: DashboardItem) => item.id <= 90).length,
[data.nodes]
);
const renderContent = () => {
if (!data) {
@@ -330,34 +344,20 @@ const Dashboard = memo(() => {
<Cell>
{me.admin &&
di.dv?.c &&
!hasMask(di.dv.id, DeviceEntityMask.DV_READONLY) &&
(di.dv.v === '' || di.dv.v === undefined ? (
<IconButton
size="small"
aria-label={LL.RUN()}
onClick={() => editDashboardValue(di)}
style={{ backgroundColor: 'transparent' }}
>
<PlayArrowIcon
color="primary"
sx={{ fontSize: 16 }}
/>
</IconButton>
) : (
!hasMask(di.dv.id, DeviceEntityMask.DV_READONLY) && (
<IconButton
size="small"
aria-label={
LL.CHANGE_VALUE() + ' ' + LL.VALUE(0)
}
onClick={() => editDashboardValue(di)}
style={{ backgroundColor: 'transparent' }}
>
<EditIcon
color="primary"
sx={{ fontSize: 16 }}
/>
</IconButton>
))}
)}
</Cell>
</>
) : (
@@ -402,10 +402,7 @@ const Dashboard = memo(() => {
{selectedDashboardItem && selectedDashboardItem.dv && (
<DevicesDialog
open={deviceValueDialogOpen}
onClose={() => {
setDeviceValueDialogOpen(false);
setSelectedDashboardItem(undefined);
}}
onClose={() => setDeviceValueDialogOpen(false)}
onSave={deviceValueDialogSave}
selectedItem={selectedDashboardItem.dv}
writeable={true}
-1
View File
@@ -38,7 +38,6 @@ const deviceIconLookup: Record<DeviceType, IconType | null> = {
[DeviceType.CUSTOM]: MdPlaylistAdd,
[DeviceType.UNKNOWN]: MdOutlineSensors,
[DeviceType.SYSTEM]: null,
[DeviceType.COMMAND]: MdPlaylistAdd,
[DeviceType.SCHEDULER]: MdMoreTime,
[DeviceType.GENERIC]: MdOutlineSensors,
[DeviceType.VENTILATION]: PiFan
+122 -113
View File
@@ -4,10 +4,12 @@ import {
useContext,
useEffect,
useLayoutEffect,
useMemo,
useState
} from 'react';
import { IconContext } from 'react-icons';
import { Link, useNavigate } from 'react-router';
import { toast } from 'react-toastify';
import CommentsDisabledOutlinedIcon from '@mui/icons-material/CommentsDisabledOutlined';
import ConstructionIcon from '@mui/icons-material/Construction';
@@ -63,7 +65,6 @@ import {
SectionContent,
useLayoutTitle
} from 'components';
import { toast } from 'components/toast';
import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react';
import { useInterval } from 'utils';
@@ -132,19 +133,21 @@ const Devices = memo(() => {
};
}, []);
const leftOffset = () => {
const leftOffset = useCallback(() => {
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);
};
}, []);
const common_theme = useTheme({
BaseRow: `
const common_theme = useMemo(
() =>
useTheme({
BaseRow: `
font-size: 14px;
`,
HeaderRow: `
HeaderRow: `
text-transform: uppercase;
background-color: black;
color: #90CAF9;
@@ -152,7 +155,7 @@ const Devices = memo(() => {
border-bottom: 1px solid #565656;
}
`,
Row: `
Row: `
cursor: pointer;
background-color: #1E1E1E;
.td {
@@ -162,78 +165,88 @@ const Devices = memo(() => {
background-color: #177ac9;
}
`
});
}),
[]
);
const device_theme = useTheme([
common_theme,
{
BaseRow: `
font-size: 15px;
.td {
height: 28px;
const device_theme = useMemo(
() =>
useTheme([
common_theme,
{
BaseRow: `
font-size: 15px;
.td {
height: 28px;
}
`,
Table: `
--data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) 130px;
`,
HeaderRow: `
.th {
padding: 8px;
`,
Row: `
&:nth-of-type(odd) .td {
background-color: #303030;
},
&:hover .td {
background-color: #177ac9;
},
`
}
]),
[common_theme]
);
const data_theme = useMemo(
() =>
useTheme([
common_theme,
{
Table: `
--data-table-library_grid-template-columns: minmax(200px, auto) minmax(150px, auto) 40px;
height: auto;
max-height: 100%;
overflow-y: scroll;
::-webkit-scrollbar {
display:none;
}
`,
Table: `
--data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) 170px;
`,
HeaderRow: `
.th {
padding: 8px;
`,
Row: `
&:nth-of-type(odd) .td {
BaseRow: `
.td {
height: 32px;
}
`,
BaseCell: `
&:nth-of-type(1) {
border-left: 1px solid #177ac9;
},
&:nth-of-type(2) {
text-align: right;
},
&:nth-of-type(3) {
border-right: 1px solid #177ac9;
}
`,
HeaderRow: `
.th {
border-top: 1px solid #565656;
}
`,
Row: `
&:nth-of-type(odd) .td {
background-color: #303030;
},
&:hover .td {
background-color: #177ac9;
},
`
}
]);
const data_theme = useTheme([
common_theme,
{
Table: `
--data-table-library_grid-template-columns: minmax(200px, auto) minmax(150px, auto) 40px;
height: auto;
max-height: 100%;
overflow-y: scroll;
::-webkit-scrollbar {
display:none;
},
&:hover .td {
background-color: #177ac9;
}
`,
BaseRow: `
.td {
height: 32px;
}
`,
BaseCell: `
&:nth-of-type(1) {
border-left: 1px solid #177ac9;
},
&:nth-of-type(2) {
text-align: right;
},
&:nth-of-type(3) {
border-right: 1px solid #177ac9;
}
`,
HeaderRow: `
.th {
border-top: 1px solid #565656;
}
`,
Row: `
&:nth-of-type(odd) .td {
background-color: #303030;
},
&:hover .td {
background-color: #177ac9;
}
`
}
]);
`
}
]),
[common_theme]
);
const getSortIcon = (state: State, sortKey: unknown) => {
if (state.sortKey === sortKey && state.reverse) {
@@ -332,8 +345,10 @@ const Devices = memo(() => {
return sc;
};
const hasMask = (id: string, mask: number) =>
(parseInt(id.slice(0, 2), 16) & mask) === mask;
const hasMask = useCallback(
(id: string, mask: number) => (parseInt(id.slice(0, 2), 16) & mask) === mask,
[]
);
const handleDownloadCsv = () => {
const deviceIndex = coreData.devices.findIndex(
@@ -558,26 +573,16 @@ const Devices = memo(() => {
<CircularProgress sx={{ margin: 1 }} size={18} />
)}
{tableList.map((device: Device) => (
<Row key={device.id} item={device} disabled={device.e === 0}>
<Row key={device.id} item={device}>
<Cell>
<DeviceIcon type_id={device.t} />
&nbsp;&nbsp;
<span style={{ color: device.e === 0 ? 'grey' : 'white' }}>
{device.n}
{device.n}
<span style={{ color: 'lightblue' }}>
&nbsp;&nbsp;({device.e})
</span>
{device.e !== 0 && (
<span style={{ color: 'lightblue' }}>
&nbsp;&nbsp;({device.e})
</span>
)}
</Cell>
<Cell stiff>
<ButtonTooltip
title={`DeviceID: 0x${('00' + device.d.toString(16).toUpperCase()).slice(-2)}, ProductID: ${device.p}`}
>
<span>{device.tn}</span>
</ButtonTooltip>
</Cell>
<Cell stiff>{device.tn}</Cell>
</Row>
))}
</Body>
@@ -592,7 +597,6 @@ const Devices = memo(() => {
const deviceValueDialogClose = () => {
setDeviceValueDialogOpen(false);
setSelectedDeviceValue(undefined);
if (selectedDevice !== undefined) {
void sendDeviceData(selectedDevice);
}
@@ -603,35 +607,41 @@ const Devices = memo(() => {
return;
}
const showDeviceValue = (dv: DeviceValue) => {
const showDeviceValue = useCallback((dv: DeviceValue) => {
setSelectedDeviceValue(dv);
setDeviceValueDialogOpen(true);
};
}, []);
const renderNameCell = (dv: DeviceValue) => (
<>
{dv.id.slice(2)}&nbsp;
{hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) && (
<StarIcon color="primary" sx={{ fontSize: 12 }} />
)}
{hasMask(dv.id, DeviceEntityMask.DV_READONLY) && (
<EditOffOutlinedIcon color="primary" sx={{ fontSize: 12 }} />
)}
{hasMask(dv.id, DeviceEntityMask.DV_API_MQTT_EXCLUDE) && (
<CommentsDisabledOutlinedIcon color="primary" sx={{ fontSize: 12 }} />
)}
</>
const renderNameCell = useCallback(
(dv: DeviceValue) => (
<>
{dv.id.slice(2)}&nbsp;
{hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) && (
<StarIcon color="primary" sx={{ fontSize: 12 }} />
)}
{hasMask(dv.id, DeviceEntityMask.DV_READONLY) && (
<EditOffOutlinedIcon color="primary" sx={{ fontSize: 12 }} />
)}
{hasMask(dv.id, DeviceEntityMask.DV_API_MQTT_EXCLUDE) && (
<CommentsDisabledOutlinedIcon color="primary" sx={{ fontSize: 12 }} />
)}
</>
),
[hasMask]
);
const shown_data = onlyFav
? deviceData.nodes.filter(
const shown_data = useMemo(() => {
if (onlyFav) {
return deviceData.nodes.filter(
(dv: DeviceValue) =>
hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) &&
dv.id.slice(2).toLowerCase().includes(search.toLowerCase())
)
: deviceData.nodes.filter((dv: DeviceValue) =>
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]);
const deviceIndex = coreData.devices.findIndex(
(d: Device) => d.id === device_select.state.id
@@ -788,7 +798,6 @@ const Devices = memo(() => {
<IconButton
size="small"
onClick={() => showDeviceValue(dv)}
style={{ backgroundColor: 'transparent' }}
>
{dv.v === '' ? (
<PlayArrowIcon color="primary" sx={{ fontSize: 16 }} />
+105 -126
View File
@@ -1,7 +1,6 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import WarningIcon from '@mui/icons-material/Warning';
import {
Box,
@@ -19,18 +18,15 @@ import {
Typography
} from '@mui/material';
import { callAction } from '@/api/app';
import { dialogStyle } from 'CustomTheme';
import { useRequest } from 'alova/client';
import type Schema from 'async-validator';
import type { ValidateFieldsError } from 'async-validator';
import { ValidatedTextField } from 'components';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react';
import { numberValue, updateValue } from 'utils';
import { ValidationError, validate } from 'validators';
import type Schema from 'validators/schema';
import type { ValidateFieldsError } from 'validators/schema';
import { DeviceValueUOM, DeviceValueUOM_s, getDeviceValueKind } from './types';
import { DeviceValueUOM, DeviceValueUOM_s } from './types';
import type { DeviceValue } from './types';
interface DevicesDialogProps {
@@ -56,7 +52,7 @@ const DevicesDialog = ({
const [editItem, setEditItem] = useState<DeviceValue>(selectedItem);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const updateFormValue = updateValue(setEditItem);
const updateFormValue = useMemo(() => updateValue(setEditItem), [setEditItem]);
useEffect(() => {
if (open) {
@@ -65,48 +61,36 @@ const DevicesDialog = ({
}
}, [open, selectedItem]);
const { send: executeCommand } = useRequest(
(id: string) => callAction({ action: 'executeCommand', param: id }),
{ immediate: false }
)
.onSuccess(() => {
toast.success(LL.COMMAND_EXECUTED());
})
.onError((error) => {
toast.error(String(error.error?.message || 'An error occurred'));
});
const doAction = async () => {
const save = useCallback(async () => {
try {
setFieldErrors(undefined);
if (editItem.v === undefined && editItem.c !== undefined) {
await executeCommand(editItem.c);
} else {
await validate(validator, editItem);
}
await validate(validator, editItem);
onSave(editItem);
} catch (error) {
setFieldErrors((error as ValidationError).fieldErrors);
}
};
}, [validator, editItem, onSave]);
const setUom = (uom?: DeviceValueUOM) => {
if (uom === undefined) {
return;
}
switch (uom) {
case DeviceValueUOM.HOURS:
return LL.HOURS();
case DeviceValueUOM.MINUTES:
return LL.MINUTES();
case DeviceValueUOM.SECONDS:
return LL.SECONDS();
default:
return DeviceValueUOM_s[uom];
}
};
const setUom = useCallback(
(uom?: DeviceValueUOM) => {
if (uom === undefined) {
return;
}
switch (uom) {
case DeviceValueUOM.HOURS:
return LL.HOURS();
case DeviceValueUOM.MINUTES:
return LL.MINUTES();
case DeviceValueUOM.SECONDS:
return LL.SECONDS();
default:
return DeviceValueUOM_s[uom];
}
},
[LL]
);
const showHelperText = (dv: DeviceValue) => {
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) {
@@ -117,23 +101,26 @@ const DevicesDialog = ({
);
}
return undefined;
};
}, []);
const kind = getDeviceValueKind(selectedItem);
const isCommand = useMemo(
() => selectedItem.v === '' && selectedItem.c,
[selectedItem.v, selectedItem.c]
);
const dialogTitle =
kind === 'command' || kind === 'deviceCommand'
? LL.RUN() + ' ' + LL.COMMAND(0)
: kind === 'scheduler'
? LL.UPDATE() + ' ' + LL.SCHEDULE(0)
: writeable
? LL.CHANGE_VALUE()
: LL.VALUE(0);
const dialogTitle = useMemo(() => {
if (isCommand) return LL.RUN_COMMAND();
return writeable ? LL.CHANGE_VALUE() : LL.VALUE(0);
}, [isCommand, writeable, LL]);
const buttonLabel =
kind === 'command' || kind === 'deviceCommand' ? LL.RUN() : LL.UPDATE();
const buttonLabel = useMemo(() => {
return isCommand ? LL.EXECUTE() : LL.UPDATE();
}, [isCommand, LL]);
const helperText = showHelperText(editItem);
const helperText = useMemo(
() => showHelperText(editItem),
[editItem, showHelperText]
);
const valueLabel = LL.VALUE(0);
@@ -144,69 +131,67 @@ const DevicesDialog = ({
<Typography sx={{ mb: 2 }} color="warning" variant="body2">
{editItem.id.slice(2)}
</Typography>
{kind !== 'command' && (
<Grid container>
<Grid size={12}>
{editItem.l ? (
<TextField
name="v"
value={editItem.v}
aria-label={valueLabel}
disabled={!writeable}
sx={{ width: '30ch' }}
select
onChange={updateFormValue}
>
{editItem.l.map((val) => (
<MenuItem value={val} key={val}>
{val}
</MenuItem>
))}
</TextField>
) : editItem.s || editItem.u !== DeviceValueUOM.NONE ? (
<ValidatedTextField
fieldErrors={fieldErrors || {}}
name="v"
label={valueLabel}
value={numberValue(Math.round((editItem.v as number) * 10) / 10)}
autoFocus
disabled={!writeable}
type="number"
sx={{ width: '30ch' }}
onChange={updateFormValue}
slotProps={{
htmlInput: editItem.s
? { min: editItem.m, max: editItem.x, step: editItem.s }
: {},
input: {
startAdornment: (
<InputAdornment position="start">
{setUom(editItem.u)}
</InputAdornment>
)
}
}}
/>
) : (
<ValidatedTextField
fieldErrors={fieldErrors || {}}
name="v"
label={valueLabel}
value={editItem.v}
disabled={!writeable}
sx={{ width: '30ch' }}
multiline={!editItem.u}
onChange={updateFormValue}
/>
)}
</Grid>
{writeable && helperText && (
<Grid>
<FormHelperText>{helperText}</FormHelperText>
</Grid>
<Grid container>
<Grid size={12}>
{editItem.l ? (
<TextField
name="v"
value={editItem.v}
aria-label={valueLabel}
disabled={!writeable}
sx={{ width: '30ch' }}
select
onChange={updateFormValue}
>
{editItem.l.map((val) => (
<MenuItem value={val} key={val}>
{val}
</MenuItem>
))}
</TextField>
) : editItem.s || editItem.u !== DeviceValueUOM.NONE ? (
<ValidatedTextField
fieldErrors={fieldErrors || {}}
name="v"
label={valueLabel}
value={numberValue(Math.round((editItem.v as number) * 10) / 10)}
autoFocus
disabled={!writeable}
type="number"
sx={{ width: '30ch' }}
onChange={updateFormValue}
slotProps={{
htmlInput: editItem.s
? { min: editItem.m, max: editItem.x, step: editItem.s }
: {},
input: {
startAdornment: (
<InputAdornment position="start">
{setUom(editItem.u)}
</InputAdornment>
)
}
}}
/>
) : (
<ValidatedTextField
fieldErrors={fieldErrors || {}}
name="v"
label={valueLabel}
value={editItem.v}
disabled={!writeable}
sx={{ width: '30ch' }}
multiline={!editItem.u}
onChange={updateFormValue}
/>
)}
</Grid>
)}
{writeable && helperText && (
<Grid>
<FormHelperText>{helperText}</FormHelperText>
</Grid>
)}
</Grid>
</DialogContent>
<DialogActions>
@@ -228,16 +213,10 @@ const DevicesDialog = ({
{LL.CANCEL()}
</Button>
<Button
startIcon={
kind === 'command' ? (
<PlayArrowIcon />
) : kind === 'deviceEntity' || kind === 'deviceCommand' ? (
<WarningIcon color="warning" />
) : undefined
}
startIcon={<WarningIcon color="warning" />}
variant="outlined"
onClick={doAction}
color={kind !== 'value' ? 'success' : 'primary'}
onClick={save}
color="primary"
>
{buttonLabel}
</Button>
+98 -67
View File
@@ -1,3 +1,5 @@
import { useCallback, useMemo } from 'react';
import { ToggleButton, ToggleButtonGroup } from '@mui/material';
import OptionIcon from './OptionIcon';
@@ -9,6 +11,7 @@ interface EntityMaskToggleProps {
de: DeviceEntity;
}
// Available mask values
const MASK_VALUES = [
DeviceEntityMask.DV_WEB_EXCLUDE, // 1
DeviceEntityMask.DV_API_MQTT_EXCLUDE, // 2
@@ -17,95 +20,123 @@ const MASK_VALUES = [
DeviceEntityMask.DV_DELETED // 128
];
const getMaskNumber = (newMask: string[]): number =>
newMask.reduce((mask, entry) => mask | Number(entry), 0);
/**
* Converts an array of mask strings to a bitmask number
*/
const getMaskNumber = (newMask: string[]): number => {
return newMask.reduce((mask, entry) => mask | Number(entry), 0);
};
const getMaskString = (mask: number): string[] =>
MASK_VALUES.filter((value) => (mask & value) === value).map((value) =>
/**
* 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 = (_event: unknown, mask: string[]) => {
const newMask = getMaskNumber(mask);
const updatedDe = { ...de };
const handleChange = useCallback(
(_event: unknown, mask: string[]) => {
// Convert selected masks to a number
const newMask = getMaskNumber(mask);
const updatedDe = { ...de };
// 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;
}
// 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;
}
// If excluded from web, cannot be favorite
if (hasMask(updatedDe.m, DeviceEntityMask.DV_WEB_EXCLUDE)) {
updatedDe.m = updatedDe.m & ~DeviceEntityMask.DV_FAVORITE;
}
// If excluded from web, cannot be favorite
if (hasMask(updatedDe.m, DeviceEntityMask.DV_WEB_EXCLUDE)) {
updatedDe.m = updatedDe.m & ~DeviceEntityMask.DV_FAVORITE;
}
onUpdate(updatedDe);
};
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]
);
return (
<ToggleButtonGroup
size="small"
color="secondary"
value={getMaskString(de.m)}
value={maskStringValue}
onChange={handleChange}
>
<ToggleButton
value="8"
disabled={
hasMask(
de.m,
DeviceEntityMask.DV_WEB_EXCLUDE | DeviceEntityMask.DV_DELETED
) || de.n === undefined
}
>
<OptionIcon
type="favorite"
isSet={hasMask(de.m, DeviceEntityMask.DV_FAVORITE)}
/>
<ToggleButton value="8" disabled={isFavoriteDisabled}>
<OptionIcon type="favorite" isSet={isFavoriteSet} />
</ToggleButton>
<ToggleButton
value="4"
disabled={
!de.w ||
hasMask(
de.m,
DeviceEntityMask.DV_WEB_EXCLUDE | DeviceEntityMask.DV_FAVORITE
)
}
>
<OptionIcon
type="readonly"
isSet={hasMask(de.m, DeviceEntityMask.DV_READONLY)}
/>
<ToggleButton value="4" disabled={isReadonlyDisabled}>
<OptionIcon type="readonly" isSet={isReadonlySet} />
</ToggleButton>
<ToggleButton
value="2"
disabled={de.n === '' || hasMask(de.m, DeviceEntityMask.DV_DELETED)}
>
<OptionIcon
type="api_mqtt_exclude"
isSet={hasMask(de.m, DeviceEntityMask.DV_API_MQTT_EXCLUDE)}
/>
<ToggleButton value="2" disabled={isApiMqttExcludeDisabled}>
<OptionIcon type="api_mqtt_exclude" isSet={isApiMqttExcludeSet} />
</ToggleButton>
<ToggleButton
value="1"
disabled={de.n === undefined || hasMask(de.m, DeviceEntityMask.DV_DELETED)}
>
<OptionIcon
type="web_exclude"
isSet={hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE)}
/>
<ToggleButton value="1" disabled={isWebExcludeDisabled}>
<OptionIcon type="web_exclude" isSet={isWebExcludeSet} />
</ToggleButton>
<ToggleButton value="128">
<OptionIcon
type="deleted"
isSet={hasMask(de.m, DeviceEntityMask.DV_DELETED)}
/>
<OptionIcon type="deleted" isSet={isDeletedSet} />
</ToggleButton>
</ToggleButtonGroup>
);
+52 -27
View File
@@ -1,5 +1,6 @@
import { memo, useContext, useState } from 'react';
import { memo, useCallback, useContext, useMemo, useState } from 'react';
import type { ReactElement } from 'react';
import { toast } from 'react-toastify';
import CommentIcon from '@mui/icons-material/CommentTwoTone';
import DownloadIcon from '@mui/icons-material/GetApp';
@@ -24,7 +25,6 @@ import type { SxProps, Theme } from '@mui/material/styles';
import { useRequest } from 'alova/client';
import { SectionContent, useLayoutTitle } from 'components';
import { toast } from 'components/toast';
import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react';
import { saveFile } from 'utils';
@@ -60,8 +60,6 @@ const AVATAR_STYLES: SxProps<Theme> = {
bgcolor: '#72caf9'
};
const SYSTEM_INFO_API: APIcall = { device: 'system', cmd: 'info', id: 0 };
const HelpComponent = () => {
const { LL } = useI18nContext();
useLayoutTitle(LL.HELP());
@@ -74,7 +72,12 @@ const HelpComponent = () => {
});
const [imgError, setImgError] = useState<boolean>(false);
useRequest(callAction({ action: 'getCustomSupport' })).onSuccess((event) => {
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[] };
@@ -97,26 +100,47 @@ const HelpComponent = () => {
toast.error(String(error.error?.message || 'An error occurred'));
});
const helpLinks: HelpLink[] = [
{
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()
}
];
// Optimize API call memoization
const apiCall = useMemo(() => ({ device: 'system', cmd: 'info', id: 0 }), []);
const imageSrc =
imgError || !customSupport.img_url ? DEFAULT_IMAGE_URL : customSupport.img_url;
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>
@@ -133,13 +157,13 @@ const HelpComponent = () => {
component="img"
referrerPolicy="no-referrer"
sx={IMAGE_STYLES}
onError={() => setImgError(true)}
onError={handleImageError}
src={imageSrc}
/>
</Stack>
)}
{me?.admin && (
{isAdmin && (
<List>
{helpLinks.map(({ href, icon, label }) => (
<ListItem key={href}>
@@ -167,7 +191,7 @@ const HelpComponent = () => {
startIcon={<DownloadIcon />}
variant="outlined"
color="primary"
onClick={() => void sendAPI(SYSTEM_INFO_API)}
onClick={handleDownloadSystemInfo}
>
{LL.SUPPORT_INFORMATION(0)}
</Button>
@@ -190,6 +214,7 @@ const HelpComponent = () => {
);
};
// Memoize the component to prevent unnecessary re-renders
const Help = memo(HelpComponent);
export default Help;
+78 -60
View File
@@ -1,5 +1,6 @@
import { memo, useState } from 'react';
import { memo, useCallback, useMemo, useState } from 'react';
import { useBlocker } from 'react-router';
import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel';
import CircleIcon from '@mui/icons-material/Circle';
@@ -24,7 +25,6 @@ import {
SectionContent,
useLayoutTitle
} from 'components';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react';
import { readModules, writeModules } from '../../api/app';
@@ -69,53 +69,58 @@ const Modules = () => {
}
);
const modules_theme = useTheme({
Table: `
--data-table-library_grid-template-columns: 48px 180px 120px 100px repeat(1, minmax(160px, 1fr)) 180px;
`,
BaseRow: `
font-size: 14px;
.td {
height: 32px;
}
`,
BaseCell: `
&:nth-of-type(1) {
text-align: center;
}
`,
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 {
border-top: 1px solid #565656;
border-bottom: 1px solid #565656;
}
&:hover .td {
border-top: 1px solid #177ac9;
border-bottom: 1px solid #177ac9;
}
&:nth-of-type(odd) .td {
background-color: #303030;
}
`
});
const modules_theme = useTheme(
useMemo(
() => ({
Table: `
--data-table-library_grid-template-columns: 48px 180px 120px 100px repeat(1, minmax(160px, 1fr)) 180px;
`,
BaseRow: `
font-size: 14px;
.td {
height: 32px;
}
`,
BaseCell: `
&:nth-of-type(1) {
text-align: center;
}
`,
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 {
border-top: 1px solid #565656;
border-bottom: 1px solid #565656;
}
&:hover .td {
border-top: 1px solid #177ac9;
border-bottom: 1px solid #177ac9;
}
&:nth-of-type(odd) .td {
background-color: #303030;
}
`
}),
[]
)
);
const onDialogClose = () => {
const onDialogClose = useCallback(() => {
setDialogOpen(false);
};
}, []);
const updateModuleItem = (updatedItem: ModuleItem) => {
const updateModuleItem = useCallback((updatedItem: ModuleItem) => {
void updateState(readModules(), (data: ModuleItem[]) => {
const new_data = data.map((mi) =>
mi.id === updatedItem.id ? { ...mi, ...updatedItem } : mi
@@ -123,25 +128,28 @@ const Modules = () => {
setNumChanges(new_data.filter(hasModulesChanged).length);
return new_data;
});
};
}, []);
const onDialogSave = (updatedItem: ModuleItem) => {
setDialogOpen(false);
updateModuleItem(updatedItem);
};
const onDialogSave = useCallback(
(updatedItem: ModuleItem) => {
setDialogOpen(false);
updateModuleItem(updatedItem);
},
[updateModuleItem]
);
const editModuleItem = (mi: ModuleItem) => {
const editModuleItem = useCallback((mi: ModuleItem) => {
setSelectedModuleItem(mi);
setDialogOpen(true);
};
}, []);
const onCancel = async () => {
const onCancel = useCallback(async () => {
await fetchModules().then(() => {
setNumChanges(0);
});
};
}, [fetchModules]);
const saveModules = async () => {
const saveModules = useCallback(async () => {
try {
await Promise.all(
modules.map((condensed_mi: ModuleItem) =>
@@ -159,9 +167,9 @@ const Modules = () => {
await fetchModules();
setNumChanges(0);
}
};
}, [modules, updateModules, LL, fetchModules]);
const renderContent = () => {
const content = useMemo(() => {
if (!modules) {
return (
<FormLoader onRetry={fetchModules} errorMessage={error?.message || ''} />
@@ -254,12 +262,22 @@ const Modules = () => {
</Box>
</>
);
};
}, [
modules,
fetchModules,
error,
modules_theme,
editModuleItem,
LL,
numChanges,
onCancel,
saveModules
]);
return (
<SectionContent>
{blocker ? <BlockNavigation blocker={blocker} /> : null}
{renderContent()}
{content}
{selectedModuleItem && (
<ModulesDialog
open={dialogOpen}
+17 -8
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
import DoneIcon from '@mui/icons-material/Done';
@@ -37,10 +37,14 @@ const ModulesDialog = ({
const { LL } = useI18nContext();
const [editItem, setEditItem] = useState<ModuleItem>(selectedItem);
const updateFormValue = updateValue(
setEditItem as unknown as React.Dispatch<
React.SetStateAction<Record<string, unknown>>
>
const updateFormValue = useMemo(
() =>
updateValue(
setEditItem as unknown as React.Dispatch<
React.SetStateAction<Record<string, unknown>>
>
),
[]
);
// Sync form state when dialog opens or selected item changes
@@ -50,13 +54,18 @@ const ModulesDialog = ({
}
}, [open, selectedItem]);
const handleSave = () => {
const handleSave = useCallback(() => {
onSave(editItem);
};
}, [editItem, onSave]);
const dialogTitle = useMemo(
() => `${LL.EDIT()} ${editItem.key}`,
[LL, editItem.key]
);
return (
<Dialog sx={dialogStyle} fullWidth maxWidth="xs" open={open} onClose={onClose}>
<DialogTitle>{`${LL.EDIT()} ${editItem.key}`}</DialogTitle>
<DialogTitle>{dialogTitle}</DialogTitle>
<DialogContent dividers>
<Grid container>
<BlockFormControlLabel
+92 -73
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useBlocker } from 'react-router';
import { toast } from 'react-toastify';
import AddIcon from '@mui/icons-material/Add';
import CancelIcon from '@mui/icons-material/Cancel';
@@ -25,11 +26,10 @@ import {
SectionContent,
useLayoutTitle
} from 'components';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react';
import { useInterval } from 'utils';
import { readCommands, readSchedule, writeSchedule } from '../../api/app';
import { readSchedule, writeSchedule } from '../../api/app';
import SettingsSchedulerDialog from './SchedulerDialog';
import { ScheduleFlag } from './types';
import type { Schedule, ScheduleItem } from './types';
@@ -54,13 +54,14 @@ const DEFAULT_SCHEDULE_ITEM: Omit<ScheduleItem, 'id' | 'o_id'> = {
deleted: false,
flags: FLAG_ALL_DAYS,
time: '',
cmd_name: '',
cmd: '',
value: '',
name: ''
};
const scheduleTheme = {
Table: `
--data-table-library_grid-template-columns: 36px 220px repeat(1, minmax(20px, 1fr)) 192px 160px;
--data-table-library_grid-template-columns: 36px 210px 100px 192px repeat(1, minmax(100px, 1fr)) 160px;
`,
BaseRow: `
font-size: 14px;
@@ -69,8 +70,11 @@ const scheduleTheme = {
}
`,
BaseCell: `
&:nth-of-type(2) {
text-align: center;
}
&:nth-of-type(1) {
text-align: 8px;
text-align: center;
}
`,
HeaderRow: `
@@ -96,6 +100,7 @@ const scheduleTheme = {
};
const scheduleTypeLabels: Record<number, string> = {
[ScheduleFlag.SCHEDULE_IMMEDIATE]: 'Immediate',
[ScheduleFlag.SCHEDULE_TIMER]: 'Timer',
[ScheduleFlag.SCHEDULE_CONDITION]: 'Condition',
[ScheduleFlag.SCHEDULE_ONCHANGE]: 'On Change'
@@ -120,11 +125,6 @@ const Scheduler = () => {
initialData: []
});
const { data: commandNames } = useRequest(readCommands, {
initialData: [],
initializing: true
});
const { send: updateSchedule } = useRequest(
(data: Schedule) => writeSchedule(data),
{
@@ -132,7 +132,7 @@ const Scheduler = () => {
}
);
const hasScheduleChanged = (si: ScheduleItem) => {
const hasScheduleChanged = useCallback((si: ScheduleItem) => {
return (
si.id !== si.o_id ||
(si.name || '') !== (si.o_name || '') ||
@@ -140,15 +140,18 @@ const Scheduler = () => {
si.deleted !== si.o_deleted ||
si.flags !== si.o_flags ||
si.time !== si.o_time ||
si.cmd_name !== si.o_cmd_name
si.cmd !== si.o_cmd ||
si.value !== si.o_value
);
};
}, []);
useInterval(() => {
const intervalCallback = useCallback(() => {
if (numChanges === 0) {
void fetchSchedule();
}
}, INTERVAL_DELAY);
}, [numChanges, fetchSchedule]);
useInterval(intervalCallback, INTERVAL_DELAY);
useEffect(() => {
const formatter = new Intl.DateTimeFormat(locale, {
@@ -166,7 +169,7 @@ const Scheduler = () => {
const schedule_theme = useTheme(scheduleTheme);
const saveSchedule = async () => {
const saveSchedule = useCallback(async () => {
try {
await updateSchedule({
schedule: schedule
@@ -176,7 +179,8 @@ const Scheduler = () => {
active: condensed_si.active,
flags: condensed_si.flags,
time: condensed_si.time,
cmd_name: condensed_si.cmd_name,
cmd: condensed_si.cmd,
value: condensed_si.value,
name: condensed_si.name
}))
});
@@ -188,43 +192,46 @@ const Scheduler = () => {
await fetchSchedule();
setNumChanges(0);
}
};
}, [LL, schedule, updateSchedule, fetchSchedule]);
const editScheduleItem = (si: ScheduleItem) => {
const editScheduleItem = useCallback((si: ScheduleItem) => {
setCreating(false);
setSelectedScheduleItem(si);
setDialogOpen(true);
if (si.o_name === undefined) {
si.o_name = si.name;
}
};
}, []);
const onDialogClose = () => {
const onDialogClose = useCallback(() => {
setDialogOpen(false);
};
}, []);
const onDialogCancel = async () => {
const onDialogCancel = useCallback(async () => {
await fetchSchedule().then(() => {
setNumChanges(0);
});
};
}, [fetchSchedule]);
const onDialogSave = (updatedItem: ScheduleItem) => {
setDialogOpen(false);
void updateState(readSchedule(), (data: ScheduleItem[]) => {
const new_data = creating
? [...data, updatedItem]
: data.map((si) =>
si.id === updatedItem.id ? { ...si, ...updatedItem } : si
);
const onDialogSave = useCallback(
(updatedItem: ScheduleItem) => {
setDialogOpen(false);
void updateState(readSchedule(), (data: ScheduleItem[]) => {
const new_data = creating
? [...data, updatedItem]
: data.map((si) =>
si.id === updatedItem.id ? { ...si, ...updatedItem } : si
);
setNumChanges(new_data.filter((si) => hasScheduleChanged(si)).length);
setNumChanges(new_data.filter((si) => hasScheduleChanged(si)).length);
return new_data;
});
};
return new_data;
});
},
[creating, hasScheduleChanged]
);
const addScheduleItem = () => {
const addScheduleItem = useCallback(() => {
setCreating(true);
const newItem: ScheduleItem = {
id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
@@ -232,29 +239,36 @@ const Scheduler = () => {
};
setSelectedScheduleItem(newItem);
setDialogOpen(true);
};
}, []);
const filteredAndSortedSchedule = schedule
.filter((si: ScheduleItem) => !si.deleted)
.sort((a: ScheduleItem, b: ScheduleItem) => a.flags - b.flags);
const filteredAndSortedSchedule = useMemo(
() =>
schedule
.filter((si: ScheduleItem) => !si.deleted)
.sort((a: ScheduleItem, b: ScheduleItem) => a.flags - b.flags),
[schedule]
);
const dayBox = (si: ScheduleItem, flag: number, isLast = false) => {
const dayIndex = Math.log(flag) / LOG_2;
const isActive = (si.flags & flag) === flag;
const dayBox = useCallback(
(si: ScheduleItem, flag: number) => {
const dayIndex = Math.log(flag) / LOG_2;
const isActive = (si.flags & flag) === flag;
return (
<>
<Box>
<Typography sx={{ fontSize: 11 }} color={isActive ? 'primary' : 'grey'}>
{dow[dayIndex]}
</Typography>
</Box>
{!isLast && <Divider orientation="vertical" flexItem />}
</>
);
};
return (
<>
<Box>
<Typography sx={{ fontSize: 11 }} color={isActive ? 'primary' : 'grey'}>
{dow[dayIndex]}
</Typography>
</Box>
<Divider orientation="vertical" flexItem />
</>
);
},
[dow]
);
const scheduleType = (si: ScheduleItem) => {
const scheduleType = useCallback((si: ScheduleItem) => {
const label = scheduleTypeLabels[si.flags];
return (
@@ -264,9 +278,9 @@ const Scheduler = () => {
</Typography>
</Box>
);
};
}, []);
const renderSchedule = () => {
const renderSchedule = useCallback(() => {
if (!schedule) {
return (
<FormLoader onRetry={fetchSchedule} errorMessage={error?.message || ''} />
@@ -285,10 +299,9 @@ const Scheduler = () => {
<HeaderRow>
<HeaderCell />
<HeaderCell stiff>{LL.SCHEDULE(0)}</HeaderCell>
<HeaderCell stiff>
{LL.TIME(0)}/{LL.CONDITION()}
</HeaderCell>
<HeaderCell stiff>{LL.TIME(0)}/Cond.</HeaderCell>
<HeaderCell stiff>{LL.COMMAND(0)}</HeaderCell>
<HeaderCell stiff>{LL.VALUE(0)}</HeaderCell>
<HeaderCell stiff>{LL.NAME(0)}</HeaderCell>
</HeaderRow>
</Header>
@@ -298,16 +311,12 @@ const Scheduler = () => {
<Cell stiff>
<CircleIcon
color={si.active ? 'success' : 'error'}
sx={{
fontSize: ICON_SIZE,
ml: '4px',
position: 'relative',
top: '2px'
}}
sx={{ fontSize: ICON_SIZE, verticalAlign: 'middle' }}
/>
</Cell>
<Cell stiff>
<Stack spacing={0.5} direction="row">
<Divider orientation="vertical" flexItem />
{si.flags > SCHEDULE_FLAG_THRESHOLD ? (
scheduleType(si)
) : (
@@ -318,13 +327,14 @@ const Scheduler = () => {
{dayBox(si, ScheduleFlag.SCHEDULE_THU)}
{dayBox(si, ScheduleFlag.SCHEDULE_FRI)}
{dayBox(si, ScheduleFlag.SCHEDULE_SAT)}
{dayBox(si, ScheduleFlag.SCHEDULE_SUN, true)}
{dayBox(si, ScheduleFlag.SCHEDULE_SUN)}
</>
)}
</Stack>
</Cell>
<Cell>{si.time === '' ? LL.SCHEDULER_HELP_2() : si.time}</Cell>
<Cell>{si.cmd_name}</Cell>
<Cell>{si.time}</Cell>
<Cell>{si.cmd}</Cell>
<Cell>{si.value}</Cell>
<Cell>{si.name}</Cell>
</Row>
))}
@@ -333,7 +343,17 @@ const Scheduler = () => {
)}
</Table>
);
};
}, [
schedule,
error,
fetchSchedule,
filteredAndSortedSchedule,
schedule_theme,
editScheduleItem,
LL,
dayBox,
scheduleType
]);
return (
<SectionContent>
@@ -352,7 +372,6 @@ const Scheduler = () => {
selectedItem={selectedScheduleItem}
validator={schedulerItemValidation(schedule, selectedScheduleItem)}
dow={dow}
commandNames={commandNames.map((ci) => ci.name)}
/>
)}
+210 -147
View File
@@ -1,9 +1,9 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import AddIcon from '@mui/icons-material/Add';
import CancelIcon from '@mui/icons-material/Cancel';
import CircleIcon from '@mui/icons-material/Circle';
import DoneIcon from '@mui/icons-material/Done';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import RemoveIcon from '@mui/icons-material/RemoveCircleOutlined';
import {
Box,
@@ -14,7 +14,6 @@ import {
DialogContent,
DialogTitle,
Grid,
MenuItem,
TextField,
ToggleButton,
ToggleButtonGroup,
@@ -22,12 +21,12 @@ import {
} from '@mui/material';
import { dialogStyle } from 'CustomTheme';
import type Schema from 'async-validator';
import type { ValidateFieldsError } from 'async-validator';
import { BlockFormControlLabel, ValidatedTextField } from 'components';
import { useI18nContext } from 'i18n/i18n-react';
import { updateValue } from 'utils';
import { ValidationError, validate } from 'validators';
import type Schema from 'validators/schema';
import type { ValidateFieldsError } from 'validators/schema';
import { ScheduleFlag } from './types';
import type { ScheduleItem } from './types';
@@ -61,12 +60,6 @@ const FLAG_VALUES = [
ScheduleFlag.SCHEDULE_SAT
] as const;
const getFlagDOWnumber = (flags: string[]) =>
flags.reduce((acc, flag) => acc | Number(flag), 0) & FLAG_MASK_127;
const getFlagDOWstring = (f: number) =>
FLAG_VALUES.filter((flag) => (f & flag) === flag).map((flag) => String(flag));
interface SchedulerDialogProps {
open: boolean;
creating: boolean;
@@ -75,7 +68,6 @@ interface SchedulerDialogProps {
selectedItem: ScheduleItem;
validator: Schema;
dow: string[];
commandNames: string[];
}
const SchedulerDialog = ({
@@ -85,24 +77,33 @@ const SchedulerDialog = ({
onSave,
selectedItem,
validator,
dow,
commandNames
dow
}: SchedulerDialogProps) => {
const { LL } = useI18nContext();
const [editItem, setEditItem] = useState<ScheduleItem>(selectedItem);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const [scheduleType, setScheduleType] = useState<ScheduleFlag>();
const updateFormValue = updateValue(
setEditItem as unknown as React.Dispatch<
React.SetStateAction<Record<string, unknown>>
>
const updateFormValue = useMemo(
() =>
updateValue(
setEditItem as unknown as React.Dispatch<
React.SetStateAction<Record<string, unknown>>
>
),
[]
);
useEffect(() => {
if (open) {
setFieldErrors(undefined);
setEditItem(selectedItem);
// 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
@@ -111,89 +112,129 @@ const SchedulerDialog = ({
}
}, [open, selectedItem]);
const handleSave = async (itemToSave: ScheduleItem) => {
try {
setFieldErrors(undefined);
await validate(validator, itemToSave);
onSave(itemToSave);
} catch (error) {
setFieldErrors((error as ValidationError).fieldErrors);
}
};
// Helper function to handle save operations
const handleSave = useCallback(
async (itemToSave: ScheduleItem) => {
try {
setFieldErrors(undefined);
await validate(validator, itemToSave);
onSave(itemToSave);
} catch (error) {
setFieldErrors((error as ValidationError).fieldErrors);
}
},
[validator, onSave]
);
const save = async () => {
const save = useCallback(async () => {
await handleSave(editItem);
};
}, [editItem, handleSave]);
const remove = () => {
const saveandactivate = useCallback(async () => {
await handleSave({ ...editItem, active: true });
}, [editItem, handleSave]);
const remove = useCallback(() => {
onSave({ ...editItem, deleted: true });
};
}, [editItem, onSave]);
const DayOfWeekButton = (flag: number) => {
const dayIndex = Math.log2(flag);
const isSelected = (editItem.flags & flag) === flag;
return (
<Typography
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
color={isSelected ? 'primary' : 'grey'}
>
{dow[dayIndex]}
</Typography>
// Optimize DOW flag conversion
const getFlagDOWnumber = useCallback((flags: string[]) => {
return flags.reduce((acc, flag) => acc | Number(flag), 0) & FLAG_MASK_127;
}, []);
const getFlagDOWstring = useCallback((f: number) => {
return FLAG_VALUES.filter((flag) => (f & flag) === flag).map((flag) =>
String(flag)
);
};
}, []);
const handleClose = (
_event: React.SyntheticEvent,
reason: 'backdropClick' | 'escapeKeyDown'
) => {
if (reason !== 'backdropClick') {
onClose();
// Day of week display component
const DayOfWeekButton = useCallback(
(flag: number) => {
const dayIndex = Math.log2(flag);
const isSelected = (editItem.flags & flag) === flag;
return (
<Typography
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
color={isSelected ? 'primary' : 'grey'}
>
{dow[dayIndex]}
</Typography>
);
},
[editItem.flags, dow]
);
const handleClose = useCallback(
(_event: React.SyntheticEvent, 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 handleScheduleTypeChange = (
_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 = (
_event: React.SyntheticEvent<HTMLElement>,
flags: string[]
) => {
const newFlags =
getFlagDOWnumber(flags) === 0 ? FLAG_ALL_DAYS : getFlagDOWnumber(flags);
setEditItem((prev) => ({ ...prev, flags: newFlags }));
};
const isDaySchedule = scheduleType === ScheduleFlag.SCHEDULE_DAY;
const isTimerSchedule = scheduleType === ScheduleFlag.SCHEDULE_TIMER;
const needsTimeField = isDaySchedule || isTimerSchedule;
const dowFlags = getFlagDOWstring(editItem.flags);
const timeFieldValue = needsTimeField
? editItem.time === ''
? DEFAULT_TIME
: editItem.time
: editItem.time === DEFAULT_TIME
? ''
: editItem.time;
const timeFieldLabel = (() => {
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}>
@@ -246,6 +287,14 @@ const SchedulerDialog = ({
{LL.CONDITION()}
</Typography>
</ToggleButton>
<ToggleButton value={ScheduleFlag.SCHEDULE_IMMEDIATE}>
<Typography
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
color={isImmediateSchedule ? 'primary' : 'grey'}
>
{LL.IMMEDIATE()}
</Typography>
</ToggleButton>
</ToggleButtonGroup>
{isDaySchedule && (
@@ -263,74 +312,78 @@ const SchedulerDialog = ({
</ToggleButtonGroup>
)}
<Grid container>
<BlockFormControlLabel
control={
<Checkbox
checked={editItem.active}
onChange={updateFormValue}
name="active"
{!isImmediateSchedule && (
<>
<Grid container>
<BlockFormControlLabel
control={
<Checkbox
checked={editItem.active}
onChange={updateFormValue}
name="active"
/>
}
label={LL.ACTIVE()}
/>
}
label={LL.ACTIVE()}
/>
<CircleIcon
color={editItem.active ? 'success' : 'error'}
sx={{
fontSize: 16,
mt: '12px'
}}
/>
</Grid>
<Grid container>
{needsTimeField ? (
<>
<TextField
name="time"
type="time"
label={timeFieldLabel}
value={timeFieldValue}
margin="normal"
onChange={updateFormValue}
/>
{isTimerSchedule && (
<Typography sx={{ ml: 2, mt: 4 }} color="warning" variant="body2">
00:00 = {LL.SCHEDULER_HELP_2()}
</Typography>
</Grid>
<Grid container>
{needsTimeField ? (
<>
<TextField
name="time"
type="time"
label={timeFieldLabel}
value={timeFieldValue}
margin="normal"
onChange={updateFormValue}
/>
{isTimerSchedule && (
<Typography
sx={{ ml: 2, mt: 4 }}
color="warning"
variant="body2"
>
{LL.SCHEDULER_HELP_2()}
</Typography>
)}
</>
) : (
<TextField
name="time"
label={timeFieldLabel}
multiline
fullWidth
value={timeFieldValue}
margin="normal"
onChange={updateFormValue}
/>
)}
</>
) : (
<TextField
name="time"
label={timeFieldLabel}
multiline
fullWidth
value={timeFieldValue}
margin="normal"
onChange={updateFormValue}
/>
)}
</Grid>
<TextField
name="cmd_name"
</Grid>
</>
)}
<ValidatedTextField
fieldErrors={fieldErrors || {}}
name="cmd"
label={LL.COMMAND(0)}
value={editItem.cmd_name}
multiline
fullWidth
select
value={editItem.cmd}
margin="normal"
onChange={updateFormValue}
>
{commandNames.map((name) => (
<MenuItem key={name} value={name}>
{name}
</MenuItem>
))}
</TextField>
/>
<TextField
name="value"
label={LL.VALUE(0)}
multiline
margin="normal"
fullWidth
value={editItem.value}
onChange={updateFormValue}
/>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
name="name"
label={LL.NAME(0)}
label={LL.NAME(0) + ' (' + LL.OPTIONAL() + ')'}
value={editItem.name}
fullWidth
margin="normal"
@@ -367,6 +420,16 @@ const SchedulerDialog = ({
>
{creating ? LL.ADD(0) : LL.UPDATE()}
</Button>
{isImmediateSchedule && editItem.cmd !== '' && (
<Button
startIcon={<PlayArrowIcon />}
variant="outlined"
onClick={saveandactivate}
color="success"
>
{LL.EXECUTE()}
</Button>
)}
</DialogActions>
</Dialog>
);
+290 -246
View File
@@ -1,4 +1,5 @@
import { useContext, useRef, useState } from 'react';
import { useCallback, useContext, useMemo, useRef, useState } from 'react';
import { toast } from 'react-toastify';
import AddCircleOutlineOutlinedIcon from '@mui/icons-material/AddCircleOutlineOutlined';
import KeyboardArrowDownOutlinedIcon from '@mui/icons-material/KeyboardArrowDownOutlined';
@@ -20,7 +21,6 @@ import { useTheme } from '@table-library/react-table-library/theme';
import type { State } from '@table-library/react-table-library/types/common';
import { useRequest } from 'alova/client';
import { SectionContent, useLayoutTitle } from 'components';
import { toast } from 'components/toast';
import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react';
import { useInterval } from 'utils';
@@ -158,16 +158,18 @@ const Sensors = () => {
}
);
useInterval(() => {
const intervalCallback = useCallback(() => {
if (!temperatureDialogOpen && !analogDialogOpen) {
void fetchSensorData();
}
});
}, [temperatureDialogOpen, analogDialogOpen, fetchSensorData]);
useInterval(intervalCallback);
const temperature_theme = useTheme([common_theme, temperature_theme_config]);
const analog_theme = useTheme([common_theme, analog_theme_config]);
const getSortIcon = (state: State, sortKey: unknown) => {
const getSortIcon = useCallback((state: State, sortKey: unknown) => {
if (state.sortKey === sortKey && state.reverse) {
return <KeyboardArrowDownOutlinedIcon />;
}
@@ -175,7 +177,7 @@ const Sensors = () => {
return <KeyboardArrowUpOutlinedIcon />;
}
return <UnfoldMoreOutlinedIcon />;
};
}, []);
const analog_sort = useSort(
{ nodes: sensorData.as },
@@ -232,104 +234,119 @@ const Sensors = () => {
useLayoutTitle(LL.SENSORS());
const formatDurationMin = (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 = 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 parts: string[] = [];
if (days > 0) {
parts.push(LL.NUM_DAYS({ num: days }));
}
if (hours > 0) {
parts.push(LL.NUM_HOURS({ num: hours }));
}
if (minutes > 0) {
parts.push(LL.NUM_MINUTES({ num: minutes }));
}
return parts.join(' ');
};
const parts: string[] = [];
if (days > 0) {
parts.push(LL.NUM_DAYS({ num: days }));
}
if (hours > 0) {
parts.push(LL.NUM_HOURS({ num: hours }));
}
if (minutes > 0) {
parts.push(LL.NUM_MINUTES({ num: minutes }));
}
return parts.join(' ');
},
[LL]
);
const formatValue = (value: unknown, uom: DeviceValueUOM) => {
if (value === undefined) {
return '';
}
if (typeof value !== 'number') {
return value as string;
}
switch (uom) {
case DeviceValueUOM.HOURS:
return value ? formatDurationMin(value * 60) : LL.NUM_HOURS({ num: 0 });
case DeviceValueUOM.MINUTES:
return value ? formatDurationMin(value) : LL.NUM_MINUTES({ num: 0 });
case DeviceValueUOM.SECONDS:
return LL.NUM_SECONDS({ num: value });
case DeviceValueUOM.NONE:
return new Intl.NumberFormat().format(value);
case DeviceValueUOM.DEGREES:
case DeviceValueUOM.DEGREES_R:
case DeviceValueUOM.FAHRENHEIT:
return (
new Intl.NumberFormat(undefined, {
minimumFractionDigits: 1
}).format(value) +
' ' +
DeviceValueUOM_s[uom]
);
default:
return new Intl.NumberFormat().format(value) + ' ' + DeviceValueUOM_s[uom];
}
};
const formatValue = useCallback(
(value: unknown, uom: DeviceValueUOM) => {
if (value === undefined) {
return '';
}
if (typeof value !== 'number') {
return value as string;
}
switch (uom) {
case DeviceValueUOM.HOURS:
return value ? formatDurationMin(value * 60) : LL.NUM_HOURS({ num: 0 });
case DeviceValueUOM.MINUTES:
return value ? formatDurationMin(value) : LL.NUM_MINUTES({ num: 0 });
case DeviceValueUOM.SECONDS:
return LL.NUM_SECONDS({ num: value });
case DeviceValueUOM.NONE:
return new Intl.NumberFormat().format(value);
case DeviceValueUOM.DEGREES:
case DeviceValueUOM.DEGREES_R:
case DeviceValueUOM.FAHRENHEIT:
return (
new Intl.NumberFormat(undefined, {
minimumFractionDigits: 1
}).format(value) +
' ' +
DeviceValueUOM_s[uom]
);
default:
return new Intl.NumberFormat().format(value) + ' ' + DeviceValueUOM_s[uom];
}
},
[formatDurationMin, LL]
);
const updateTemperatureSensor = (ts: TemperatureSensor) => {
if (me.admin) {
ts.o_n = ts.n;
setSelectedTemperatureSensor(ts);
setTemperatureDialogOpen(true);
}
};
const updateTemperatureSensor = useCallback(
(ts: TemperatureSensor) => {
if (me.admin) {
ts.o_n = ts.n;
setSelectedTemperatureSensor(ts);
setTemperatureDialogOpen(true);
}
},
[me.admin]
);
const onTemperatureDialogClose = () => {
const onTemperatureDialogClose = useCallback(() => {
setTemperatureDialogOpen(false);
void fetchSensorData();
};
}, [fetchSensorData]);
const onTemperatureDialogSave = async (ts: TemperatureSensor) => {
await sendTemperatureSensor({
id: ts.id,
name: ts.n,
offset: ts.o,
is_system: ts.s
})
.then(() => {
toast.success(LL.UPDATED_OF(LL.SENSOR(1)));
const onTemperatureDialogSave = useCallback(
async (ts: TemperatureSensor) => {
await sendTemperatureSensor({
id: ts.id,
name: ts.n,
offset: ts.o,
is_system: ts.s
})
.catch(() => {
toast.error(LL.UPDATE_OF(LL.SENSOR(2)) + ' ' + LL.FAILED(1));
})
.finally(() => {
setTemperatureDialogOpen(false);
setSelectedTemperatureSensor(undefined);
void fetchSensorData();
});
};
.then(() => {
toast.success(LL.UPDATED_OF(LL.SENSOR(1)));
})
.catch(() => {
toast.error(LL.UPDATE_OF(LL.SENSOR(2)) + ' ' + LL.FAILED(1));
})
.finally(() => {
setTemperatureDialogOpen(false);
setSelectedTemperatureSensor(undefined);
void fetchSensorData();
});
},
[sendTemperatureSensor, LL, fetchSensorData]
);
const updateAnalogSensor = (as: AnalogSensor) => {
if (me.admin) {
setCreating(false);
as.o_n = as.n;
setSelectedAnalogSensor(as);
setAnalogDialogOpen(true);
}
};
const updateAnalogSensor = useCallback(
(as: AnalogSensor) => {
if (me.admin) {
setCreating(false);
as.o_n = as.n;
setSelectedAnalogSensor(as);
setAnalogDialogOpen(true);
}
},
[me.admin]
);
const onAnalogDialogClose = () => {
const onAnalogDialogClose = useCallback(() => {
setAnalogDialogOpen(false);
void fetchSensorData();
};
}, [fetchSensorData]);
const addAnalogSensor = () => {
const addAnalogSensor = useCallback(() => {
if (firstAvailableGPIO.current === undefined) {
toast.error(LL.NO_GPIO());
return;
@@ -349,167 +366,194 @@ const Sensors = () => {
o_n: ''
});
setAnalogDialogOpen(true);
};
}, []);
const onAnalogDialogSave = async (as: AnalogSensor) => {
await sendAnalogSensor({
id: as.id,
gpio: as.g,
name: as.n,
offset: as.o,
factor: as.f,
uom: as.u,
type: as.t,
deleted: as.d,
is_system: as.s
})
.then(() => {
toast.success(LL.UPDATED_OF(LL.ANALOG_SENSOR(2)));
const onAnalogDialogSave = useCallback(
async (as: AnalogSensor) => {
await sendAnalogSensor({
id: as.id,
gpio: as.g,
name: as.n,
offset: as.o,
factor: as.f,
uom: as.u,
type: as.t,
deleted: as.d,
is_system: as.s
})
.catch(() => {
toast.error(LL.UPDATE_OF(LL.ANALOG_SENSOR(5)) + ' ' + LL.FAILED(1));
})
.finally(() => {
setAnalogDialogOpen(false);
setSelectedAnalogSensor(undefined);
void fetchSensorData();
});
};
const RenderAnalogSensors = (
<Table
data={{ nodes: sensorData.as }}
theme={analog_theme}
sort={analog_sort}
layout={{ custom: true }}
>
{(tableList: AnalogSensor[]) => (
<>
<Header>
<HeaderRow>
<HeaderCell stiff>
<Button
fullWidth
style={HEADER_BUTTON_STYLE}
endIcon={getSortIcon(analog_sort.state, 'GPIO')}
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'GPIO' })}
>
GPIO
</Button>
</HeaderCell>
<HeaderCell resize>
<Button
fullWidth
style={HEADER_BUTTON_STYLE}
endIcon={getSortIcon(analog_sort.state, 'NAME')}
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'NAME' })}
>
{LL.NAME(0)}
</Button>
</HeaderCell>
<HeaderCell stiff>
<Button
fullWidth
style={HEADER_BUTTON_STYLE}
endIcon={getSortIcon(analog_sort.state, 'TYPE')}
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'TYPE' })}
>
{LL.TYPE(0)}
</Button>
</HeaderCell>
<HeaderCell stiff>
<Button
fullWidth
style={HEADER_BUTTON_STYLE_END}
endIcon={getSortIcon(analog_sort.state, 'VALUE')}
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'VALUE' })}
>
{LL.VALUE(0)}
</Button>
</HeaderCell>
</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>
) : (
<Cell stiff>{formatValue(as.v, as.u)}</Cell>
)}
</Row>
))}
</Body>
</>
)}
</Table>
.then(() => {
toast.success(LL.UPDATED_OF(LL.ANALOG_SENSOR(2)));
})
.catch(() => {
toast.error(LL.UPDATE_OF(LL.ANALOG_SENSOR(5)) + ' ' + LL.FAILED(1));
})
.finally(() => {
setAnalogDialogOpen(false);
setSelectedAnalogSensor(undefined);
void fetchSensorData();
});
},
[sendAnalogSensor, LL, fetchSensorData]
);
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={HEADER_BUTTON_STYLE}
endIcon={getSortIcon(temperature_sort.state, 'NAME')}
onClick={() =>
temperature_sort.fns.onToggleSort({ sortKey: 'NAME' })
}
const RenderAnalogSensors = useMemo(
() => (
<Table
data={{ nodes: sensorData.as }}
theme={analog_theme}
sort={analog_sort}
layout={{ custom: true }}
>
{(tableList: AnalogSensor[]) => (
<>
<Header>
<HeaderRow>
<HeaderCell stiff>
<Button
fullWidth
style={HEADER_BUTTON_STYLE}
endIcon={getSortIcon(analog_sort.state, 'GPIO')}
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'GPIO' })}
>
GPIO
</Button>
</HeaderCell>
<HeaderCell resize>
<Button
fullWidth
style={HEADER_BUTTON_STYLE}
endIcon={getSortIcon(analog_sort.state, 'NAME')}
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'NAME' })}
>
{LL.NAME(0)}
</Button>
</HeaderCell>
<HeaderCell stiff>
<Button
fullWidth
style={HEADER_BUTTON_STYLE}
endIcon={getSortIcon(analog_sort.state, 'TYPE')}
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'TYPE' })}
>
{LL.TYPE(0)}
</Button>
</HeaderCell>
<HeaderCell stiff>
<Button
fullWidth
style={HEADER_BUTTON_STYLE_END}
endIcon={getSortIcon(analog_sort.state, 'VALUE')}
onClick={() =>
analog_sort.fns.onToggleSort({ sortKey: 'VALUE' })
}
>
{LL.VALUE(0)}
</Button>
</HeaderCell>
</HeaderRow>
</Header>
<Body>
{tableList.map((as: AnalogSensor) => (
<Row
style={{ color: as.s ? 'grey' : 'inherit' }}
key={as.id}
item={as}
onClick={() => updateAnalogSensor(as)}
>
{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' })
}
<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>
) : (
<Cell stiff>{formatValue(as.v, as.u)}</Cell>
)}
</Row>
))}
</Body>
</>
)}
</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)}
>
{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>
<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 (
+94 -60
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
import DoneIcon from '@mui/icons-material/Done';
@@ -18,12 +18,12 @@ import {
} from '@mui/material';
import { dialogStyle } from 'CustomTheme';
import type Schema from 'async-validator';
import type { ValidateFieldsError } from 'async-validator';
import { ValidatedTextField } from 'components';
import { useI18nContext } from 'i18n/i18n-react';
import { numberValue, updateValue } from 'utils';
import { ValidationError, validate } from 'validators';
import type Schema from 'validators/schema';
import type { ValidateFieldsError } from 'validators/schema';
import { AnalogType, AnalogTypeNames, DeviceValueUOM_s } from './types';
import type { AnalogSensor } from './types';
@@ -53,54 +53,84 @@ const SensorsAnalogDialog = ({
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const [editItem, setEditItem] = useState<AnalogSensor>(selectedItem);
const updateFormValue = updateValue((updater) =>
setEditItem(
(prev) =>
updater(
prev as unknown as Record<string, unknown>
) as unknown as AnalogSensor
)
const updateFormValue = useMemo(
() =>
updateValue((updater) =>
setEditItem(
(prev) =>
updater(
prev as unknown as Record<string, unknown>
) as unknown as AnalogSensor
)
),
[setEditItem]
);
const isCounterOrRate =
editItem.t === AnalogType.COUNTER ||
editItem.t === AnalogType.RATE ||
(editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2);
const isCounter =
editItem.t === AnalogType.COUNTER ||
(editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2);
const isFreqType =
editItem.t >= AnalogType.FREQ_0 && editItem.t <= AnalogType.FREQ_2;
const isPWM =
editItem.t === AnalogType.PWM_0 ||
editItem.t === AnalogType.PWM_1 ||
editItem.t === AnalogType.PWM_2;
const isDACOutGPIO =
editItem.t === AnalogType.DIGITAL_OUT &&
(editItem.g === 25 || editItem.g === 26);
const isDigitalOutGPIO =
editItem.t === AnalogType.DIGITAL_OUT && editItem.g !== 25 && editItem.g !== 26;
// 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]
);
const analogTypeMenuItems = 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>
));
// 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 = DeviceValueUOM_s.map((val, i) => (
<MenuItem key={val} value={i}>
{val}
</MenuItem>
));
const uomMenuItems = useMemo(
() =>
DeviceValueUOM_s.map((val, i) => (
<MenuItem key={val} value={i}>
{val}
</MenuItem>
)),
[]
);
const analogGPIOMenuItems = () =>
// add selectedItem.g to the list
@@ -127,16 +157,16 @@ const SensorsAnalogDialog = ({
}
}, [open, selectedItem]);
const handleClose = (
_event: React.SyntheticEvent,
reason: 'backdropClick' | 'escapeKeyDown'
) => {
if (reason !== 'backdropClick') {
onClose();
}
};
const handleClose = useCallback(
(_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => {
if (reason !== 'backdropClick') {
onClose();
}
},
[onClose]
);
const save = async () => {
const save = useCallback(async () => {
try {
setFieldErrors(undefined);
await validate(validator, editItem);
@@ -144,13 +174,17 @@ const SensorsAnalogDialog = ({
} catch (error) {
setFieldErrors((error as ValidationError).fieldErrors);
}
};
}, [validator, editItem, onSave]);
const remove = () => {
const remove = useCallback(() => {
onSave({ ...editItem, d: true });
};
}, [editItem, onSave]);
const dialogTitle = `${creating ? LL.ADD(1) + ' ' + LL.NEW(0) : LL.EDIT()} ${LL.ANALOG_SENSOR(0)}`;
const dialogTitle = useMemo(
() =>
`${creating ? LL.ADD(1) + ' ' + LL.NEW(0) : LL.EDIT()} ${LL.ANALOG_SENSOR(0)}`,
[creating, LL]
);
return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
import DoneIcon from '@mui/icons-material/Done';
@@ -16,12 +16,12 @@ import {
} from '@mui/material';
import { dialogStyle } from 'CustomTheme';
import type Schema from 'async-validator';
import type { ValidateFieldsError } from 'async-validator';
import { ValidatedTextField } from 'components';
import { useI18nContext } from 'i18n/i18n-react';
import { numberValue, updateValue } from 'utils';
import { ValidationError, validate } from 'validators';
import type Schema from 'validators/schema';
import type { ValidateFieldsError } from 'validators/schema';
import type { TemperatureSensor } from './types';
@@ -50,12 +50,16 @@ const SensorsTemperatureDialog = ({
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const [editItem, setEditItem] = useState<TemperatureSensor>(selectedItem);
const updateFormValue = updateValue(
setEditItem as unknown as (
updater: (
prevState: Readonly<Record<string, unknown>>
) => Record<string, unknown>
) => void
const updateFormValue = useMemo(
() =>
updateValue(
setEditItem as unknown as (
updater: (
prevState: Readonly<Record<string, unknown>>
) => Record<string, unknown>
) => void
),
[setEditItem]
);
useEffect(() => {
@@ -65,13 +69,16 @@ const SensorsTemperatureDialog = ({
}
}, [open, selectedItem]);
const handleClose = (_event: React.SyntheticEvent, reason?: string) => {
if (reason !== 'backdropClick') {
onClose();
}
};
const handleClose = useCallback(
(_event: React.SyntheticEvent, reason?: string) => {
if (reason !== 'backdropClick') {
onClose();
}
},
[onClose]
);
const save = async () => {
const save = useCallback(async () => {
try {
setFieldErrors(undefined);
await validate(validator, editItem);
@@ -79,11 +86,29 @@ const SensorsTemperatureDialog = ({
} catch (error) {
setFieldErrors((error as ValidationError).fieldErrors);
}
};
}, [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>{`${LL.EDIT()} ${LL.TEMP_SENSOR()}`}</DialogTitle>
<DialogTitle>{dialogTitle}</DialogTitle>
<DialogContent dividers>
<Typography sx={{ mb: 2 }} color="warning" variant="body2">
{LL.ID_OF(LL.SENSOR(0))}: {editItem.id}
@@ -103,23 +128,12 @@ const SensorsTemperatureDialog = ({
<TextField
name="o"
label={LL.OFFSET()}
value={numberValue(editItem.o)}
value={offsetValue}
sx={{ width: '11ch' }}
type="number"
variant="outlined"
onChange={updateFormValue}
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">{TEMP_UNIT}</InputAdornment>
)
},
htmlInput: {
min: OFFSET_MIN,
max: OFFSET_MAX,
step: OFFSET_STEP
}
}}
slotProps={slotProps}
/>
</Grid>
</Grid>
+3 -5
View File
@@ -1,6 +1,5 @@
import { memo, useContext } from 'react';
import { memo, useCallback, useContext } from 'react';
import LogoutIcon from '@mui/icons-material/Logout';
import PersonIcon from '@mui/icons-material/Person';
import {
Avatar,
@@ -24,9 +23,9 @@ const UserProfileComponent = () => {
useLayoutTitle(LL.USER_PROFILE());
const handleSignOut = () => {
const handleSignOut = useCallback(() => {
signOut(true);
};
}, [signOut]);
return (
<SectionContent>
@@ -54,7 +53,6 @@ const UserProfileComponent = () => {
</Box>
<Divider />
<Button
startIcon={<LogoutIcon />}
sx={{ mt: 2 }}
variant="outlined"
color="primary"
+8 -48
View File
@@ -1,5 +1,4 @@
export interface Settings {
system_name: string;
locale: string;
tx_mode: number;
ems_bus_id: number;
@@ -44,18 +43,7 @@ export interface Settings {
modbus_port: number;
modbus_max_clients: number;
modbus_timeout: number;
email_enabled: boolean;
email_security: number;
email_server: string;
email_port: number;
email_login: string;
email_pass: string;
email_sender: string;
email_recp: string;
email_subject: string;
developer_mode: boolean;
disable_reset: boolean;
auto_fw_check: boolean;
}
export enum busConnectionStatus {
@@ -211,18 +199,6 @@ export enum DeviceValueUOM {
HERTZ
}
export type DeviceValueKind =
'value' | 'scheduler' | 'command' | 'deviceCommand' | 'deviceEntity';
export function getDeviceValueKind(item: DeviceValue): DeviceValueKind {
if (!item.c) return 'value';
if (item.v !== undefined && item.l !== undefined && item.u === undefined)
return 'scheduler';
if (item.v === undefined && item.l === undefined) return 'command';
if (item.u === DeviceValueUOM.NONE) return 'deviceCommand';
return 'deviceEntity';
}
export const DeviceValueUOM_s = [
'',
'°C',
@@ -308,7 +284,6 @@ export const BOARD_PROFILES = {
LOLIN: 'Lolin D32',
OLIMEX: 'Olimex ESP32-EVB',
OLIMEXPOE: 'Olimex ESP32-POE',
OLIMEXPOEW: 'Olimex ESP32-POE-ISO WROVER (PSRAM)',
C3MINI: 'Wemos C3 Mini',
S2MINI: 'Wemos S2 Mini',
S3MINI: 'Liligo S3'
@@ -364,19 +339,21 @@ export enum DeviceEntityMask {
}
export interface ScheduleItem {
id: number; // unique index for table
id: number; // unique index
active: boolean;
deleted?: boolean;
flags: number;
time: string; // also used for Condition and On Change
cmd_name: string; // references a named Command
name: string;
cmd: string;
value: string;
name: string; // can be empty
o_id?: number;
o_active?: boolean;
o_deleted?: boolean;
o_flags?: number;
o_time?: string;
o_cmd_name?: string;
o_cmd?: string;
o_value?: string;
o_name?: string;
}
@@ -384,23 +361,6 @@ export interface Schedule {
readonly schedule: readonly ScheduleItem[];
}
export interface CommandItem {
id: number;
cmd: string;
value: string;
name: string;
deleted?: boolean;
o_id?: number;
o_cmd?: string;
o_value?: string;
o_name?: string;
o_deleted?: boolean;
}
export interface Commands {
readonly commands: readonly CommandItem[];
}
export interface ModuleItem {
id: number; // unique index
key: string;
@@ -431,7 +391,8 @@ export enum ScheduleFlag {
SCHEDULE_DAY = 0, // no bits set
SCHEDULE_TIMER = 128, // bit 8
SCHEDULE_ONCHANGE = 129, // bit 1
SCHEDULE_CONDITION = 130 // bit 2
SCHEDULE_CONDITION = 130, // bit 2
SCHEDULE_IMMEDIATE = 132 // bit 3
}
export interface EntityItem {
@@ -474,7 +435,6 @@ export const enum DeviceType {
ANALOGSENSOR = 2,
SCHEDULER = 3,
CUSTOM = 4,
COMMAND = 5,
BOILER,
THERMOSTAT,
MIXER,
+3 -26
View File
@@ -1,10 +1,9 @@
import Schema from 'validators/schema';
import type { InternalRuleItem } from 'validators/schema';
import Schema from 'async-validator';
import type { InternalRuleItem } from 'async-validator';
import { IP_OR_HOSTNAME_VALIDATOR } from 'validators/shared';
import type {
AnalogSensor,
CommandItem,
DeviceValue,
EntityItem,
ScheduleItem,
@@ -233,29 +232,7 @@ export const schedulerItemValidation = (
scheduleItem: ScheduleItem
) =>
new Schema({
name: [
{ required: true, message: 'Name is required' },
NAME_PATTERN_REQUIRED,
uniqueNameValidator(schedule, scheduleItem.o_name)
],
cmd_name: [{ required: true, message: 'Command is required' }]
});
export const uniqueCommandNameValidator = (
commands: CommandItem[],
o_name?: string
) => createUniqueNameValidator(commands, o_name);
export const commandItemValidation = (
commands: CommandItem[],
commandItem: CommandItem
) =>
new Schema({
name: [
{ required: true, message: 'Name is required' },
NAME_PATTERN_REQUIRED,
uniqueCommandNameValidator(commands, commandItem.o_name)
],
name: [NAME_PATTERN, uniqueNameValidator(schedule, scheduleItem.o_name)],
cmd: [
{ required: true, message: 'Command is required' },
{
+20 -10
View File
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
import WarningIcon from '@mui/icons-material/Warning';
@@ -6,6 +6,7 @@ import { Button, Checkbox, MenuItem } from '@mui/material';
import * as APApi from 'api/ap';
import type { ValidateFieldsError } from 'async-validator';
import {
BlockFormControlLabel,
BlockNavigation,
@@ -21,9 +22,9 @@ import type { APSettingsType } from 'types';
import { APProvisionMode } from 'types';
import { numberValue, updateValueDirty, useRest } from 'utils';
import { ValidationError, createAPSettingsValidator, validate } from 'validators';
import type { ValidateFieldsError } from 'validators/schema';
export const isAPEnabled = ({ provision_mode }: APSettingsType) =>
provision_mode === APProvisionMode.AP_MODE_ALWAYS ||
provision_mode === APProvisionMode.AP_MODE_DISCONNECTED;
// Efficient range function without recursion
@@ -62,16 +63,22 @@ const APSettings = () => {
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const updateFormValue = updateValueDirty(
origData as unknown as Record<string, unknown>,
dirtyFlags,
setDirtyFlags,
updateDataValue as (value: unknown) => void
const updateFormValue = useMemo(
() =>
updateValueDirty(
origData as unknown as Record<string, unknown>,
dirtyFlags,
setDirtyFlags,
updateDataValue as (value: unknown) => void
),
[origData, dirtyFlags, setDirtyFlags, updateDataValue]
);
const apEnabled = data ? isAPEnabled(data) : false;
// Memoize AP enabled state
const apEnabled = useMemo(() => (data ? isAPEnabled(data) : false), [data]);
const validateAndSubmit = async () => {
// Memoize validation and submit handler
const validateAndSubmit = useCallback(async () => {
if (!data) return;
try {
@@ -81,7 +88,7 @@ const APSettings = () => {
} catch (error) {
setFieldErrors((error as ValidationError).fieldErrors);
}
};
}, [data, saveData]);
const content = () => {
if (!data) {
@@ -101,6 +108,9 @@ const APSettings = () => {
onChange={updateFormValue}
margin="normal"
>
<MenuItem value={APProvisionMode.AP_MODE_ALWAYS}>
{LL.AP_PROVIDE_TEXT_1()}
</MenuItem>
<MenuItem value={APProvisionMode.AP_MODE_DISCONNECTED}>
{LL.AP_PROVIDE_TEXT_2()}
</MenuItem>
@@ -1,8 +1,8 @@
import { useContext, useEffect, useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel';
import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew';
import SendIcon from '@mui/icons-material/Send';
import WarningIcon from '@mui/icons-material/Warning';
import {
Box,
@@ -20,6 +20,7 @@ import { readSystemStatus } from 'api/system';
import { useRequest } from 'alova/client';
import SystemMonitor from 'app/status/SystemMonitor';
import type { ValidateFieldsError } from 'async-validator';
import {
BlockFormControlLabel,
BlockNavigation,
@@ -27,16 +28,12 @@ import {
FormLoader,
MessageBox,
SectionContent,
ValidatedPasswordField,
ValidatedTextField,
useLayoutTitle
} from 'components';
import { toast } from 'components/toast';
import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react';
import { numberValue, updateValueDirty, useRest } from 'utils';
import { ValidationError, validate } from 'validators';
import type { ValidateFieldsError } from 'validators/schema';
import { API, getBoardProfile, readSettings, writeSettings } from '../../api/app';
import { BOARD_PROFILES } from '../main/types';
@@ -70,14 +67,6 @@ const ApplicationSettings = () => {
update: writeSettings
});
// the menu drawer reads the system name from the authentication context
const { setSystemName } = useContext(AuthenticatedContext);
useEffect(() => {
if (origData?.system_name !== undefined) {
setSystemName(origData.system_name);
}
}, [origData?.system_name, setSystemName]);
const [restarting, setRestarting] = useState<boolean>();
const { LL } = useI18nContext();
@@ -117,79 +106,82 @@ const ApplicationSettings = () => {
});
});
const SecondsInputProps = {
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
};
// Memoized input props to prevent recreation on every render
const SecondsInputProps = useMemo(
() => ({
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
}),
[LL]
);
const MinutesInputProps = {
endAdornment: <InputAdornment position="end">{LL.MINUTES()}</InputAdornment>
};
const MinutesInputProps = useMemo(
() => ({
endAdornment: <InputAdornment position="end">{LL.MINUTES()}</InputAdornment>
}),
[LL]
);
const HoursInputProps = {
endAdornment: <InputAdornment position="end">{LL.HOURS()}</InputAdornment>
};
const HoursInputProps = useMemo(
() => ({
endAdornment: <InputAdornment position="end">{LL.HOURS()}</InputAdornment>
}),
[LL]
);
const doRestart = async () => {
const doRestart = useCallback(async () => {
setRestarting(true);
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
(error: Error) => {
toast.error(error.message);
}
);
};
}, [sendAPI]);
const updateBoardProfile = async (board_profile: string) => {
await readBoardProfile(board_profile).catch((error: Error) => {
toast.error(error.message);
});
};
const updateBoardProfile = useCallback(
async (board_profile: string) => {
await readBoardProfile(board_profile).catch((error: Error) => {
toast.error(error.message);
});
},
[readBoardProfile]
);
useLayoutTitle(LL.APPLICATION());
const validateAndSubmit = async () => {
const validateAndSubmit = useCallback(async () => {
try {
setFieldErrors(undefined);
await validate(createSettingsValidator(data), data);
await saveData();
} catch (error) {
setFieldErrors((error as ValidationError).fieldErrors);
} finally {
await saveData();
}
};
}, [data, saveData]);
const changeBoardProfile = (event: React.ChangeEvent<HTMLInputElement>) => {
const boardProfile = event.target.value;
updateFormValue(event);
if (boardProfile === 'CUSTOM') {
updateDataValue({
...data,
board_profile: boardProfile
});
} else {
void updateBoardProfile(boardProfile);
}
};
const changeBoardProfile = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const boardProfile = event.target.value;
updateFormValue(event);
if (boardProfile === 'CUSTOM') {
updateDataValue({
...data,
board_profile: boardProfile
});
} else {
void updateBoardProfile(boardProfile);
}
},
[data, updateBoardProfile, updateFormValue, updateDataValue]
);
const restart = async () => {
const restart = useCallback(async () => {
await validateAndSubmit();
await doRestart();
};
}, [validateAndSubmit, doRestart]);
const sendmail = async () => {
await sendAPI({
device: 'system',
cmd: 'sendmail',
data: '"Email notification test successful!"',
id: 0
})
.then(() => {
toast.success(LL.TEST_EMAIL_SUCCESSFUL());
})
.catch((error: Error) => {
toast.error(error.message);
});
};
const boardProfileItems = boardProfileSelectItems();
// Memoize board profile select items to prevent recreation
const boardProfileItems = useMemo(() => boardProfileSelectItems(), []);
const content = () => {
if (!data || !hardwareData) {
@@ -198,19 +190,7 @@ const ApplicationSettings = () => {
return (
<>
<Typography variant="h6" color="primary">
{LL.SYSTEM(0)}&nbsp;
{LL.CUSTOMIZATIONS()}
</Typography>
<TextField
name="system_name"
label={LL.SYSTEM_NAME()}
value={data.system_name}
variant="outlined"
onChange={updateFormValue}
margin="normal"
/>
<Typography sx={{ pb: 1, pt: 2 }} variant="h6" color="primary">
<Typography sx={{ pb: 1 }} variant="h6" color="primary">
{LL.SERVICES()}
</Typography>
<Typography color="secondary">API</Typography>
@@ -371,161 +351,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"
sx={{ 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>
<TextField
sx={{ width: '20ch' }}
name="email_security"
label={LL.SECURITY(0)}
value={data.email_security}
variant="outlined"
onChange={updateFormValue}
margin="normal"
select
>
<MenuItem value={0}>{LL.OFF()}</MenuItem>
<MenuItem value={1}>SSL</MenuItem>
<MenuItem value={2}>StartTLS</MenuItem>
</TextField>
</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>
<Button
startIcon={<SendIcon />}
sx={{ mt: 3 }}
variant="outlined"
color="primary"
disabled={dirtyFlags.length !== 0}
onClick={sendmail}
>
Send test email
</Button>
</Grid>
</Grid>
</>
)}
<Typography color="secondary">Firmware</Typography>
<BlockFormControlLabel
control={
<Checkbox
checked={data.auto_fw_check}
onChange={updateFormValue}
name="auto_fw_check"
/>
}
label={LL.AUTO_FW_CHECK()}
/>
<Typography sx={{ pb: 1, pt: 2 }} variant="h6" color="primary">
{LL.SENSORS()}
</Typography>
@@ -848,7 +673,6 @@ const ApplicationSettings = () => {
sx={{ width: '15ch' }}
select
>
{data.developer_mode && <MenuItem value={0}>{LL.OFF()}</MenuItem>}
<MenuItem value={1}>EMS</MenuItem>
<MenuItem value={2}>EMS+</MenuItem>
<MenuItem value={3}>HT3</MenuItem>
@@ -927,16 +751,6 @@ const ApplicationSettings = () => {
}
label={LL.DEVELOPER_MODE()}
/>
<BlockFormControlLabel
control={
<Checkbox
checked={data.disable_reset}
onChange={updateFormValue}
name="disable_reset"
/>
}
label={LL.DISABLE_RESET()}
/>
<BlockFormControlLabel
control={
<Checkbox
+41 -27
View File
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel';
import DownloadIcon from '@mui/icons-material/GetApp';
@@ -26,7 +27,6 @@ import {
SingleUpload,
useLayoutTitle
} from 'components';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react';
import { saveFile } from 'utils';
@@ -57,7 +57,7 @@ const DownloadUpload = () => {
const { data, send: loadData, error } = useRequest(SystemApi.readSystemStatus);
const doRestart = async () => {
const doRestart = useCallback(async () => {
setRestarting(true);
try {
await sendAPI({ device: 'system', cmd: 'restart', id: 0 });
@@ -65,33 +65,16 @@ const DownloadUpload = () => {
toast.error((error as Error).message);
setRestarting(false);
}
};
}, [sendAPI]);
useLayoutTitle(LL.DOWNLOAD_UPLOAD());
const handleCloseBackupDialog = () => {
const handleCloseBackupDialog = useCallback(() => {
setConfirmBackup(false);
};
}, []);
const handleDownload = (type: string) => () => {
void sendExportData(type);
setConfirmBackup(false);
};
if (restarting) {
return <SystemMonitor />;
}
if (!data) {
return (
<SectionContent>
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
</SectionContent>
);
}
return (
<SectionContent>
const renderBackupDialog = useMemo(
() => (
<Dialog
sx={dialogStyle}
open={confirmBackup}
@@ -115,13 +98,40 @@ const DownloadUpload = () => {
<Button
startIcon={<DownloadIcon />}
variant="outlined"
onClick={handleDownload('systembackup')}
onClick={() => handleDownload('systembackup')()}
color="primary"
>
{LL.DOWNLOAD(0)}
</Button>
</DialogActions>
</Dialog>
),
[confirmBackup, handleCloseBackupDialog, LL]
);
const handleDownload = useCallback(
(type: string) => () => {
void sendExportData(type);
setConfirmBackup(false);
},
[sendExportData]
);
if (restarting) {
return <SystemMonitor />;
}
if (!data) {
return (
<SectionContent>
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
</SectionContent>
);
}
return (
<SectionContent>
{renderBackupDialog}
<Typography sx={{ pb: 2 }} variant="h6" color="primary">
{LL.DOWNLOAD(0)}
@@ -165,7 +175,11 @@ const DownloadUpload = () => {
{LL.UPLOAD()}
</Typography>
<SingleUpload text={LL.UPLOAD_BACKUP_TEXT()} doRestart={doRestart} />
<Typography sx={{ pb: 2 }} color="warning" variant="body1">
{LL.UPLOAD_TEXT()}:
</Typography>
<SingleUpload doRestart={doRestart} />
</SectionContent>
);
};
+46 -34
View File
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel';
import SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore';
@@ -16,6 +17,7 @@ import {
import * as MqttApi from 'api/mqtt';
import type { ValidateFieldsError } from 'async-validator';
import {
BlockFormControlLabel,
BlockNavigation,
@@ -26,12 +28,10 @@ import {
ValidatedTextField,
useLayoutTitle
} from 'components';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react';
import type { MqttSettingsType } from 'types';
import { numberValue, updateValueDirty, useRest } from 'utils';
import { ValidationError, createMqttSettingsValidator, validate } from 'validators';
import type { ValidateFieldsError } from 'validators/schema';
import { callAction } from '../../api/app';
@@ -57,7 +57,7 @@ const MqttSettings = () => {
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const sendResetMQTT = () => {
const sendResetMQTT = useCallback(() => {
void callAction({ action: 'resetMQTT' })
.then(() => {
toast.success('MQTT ' + LL.REFRESH() + ' successful');
@@ -65,20 +65,29 @@ const MqttSettings = () => {
.catch((error) => {
toast.error(String(error.error?.message || 'An error occurred'));
});
};
}, []);
const updateFormValue = updateValueDirty(
origData as unknown as Record<string, unknown>,
dirtyFlags,
setDirtyFlags,
updateDataValue as (value: unknown) => void
const updateFormValue = useMemo(
() =>
updateValueDirty(
origData as unknown as Record<string, unknown>,
dirtyFlags,
setDirtyFlags,
updateDataValue as (value: unknown) => void
),
[origData, dirtyFlags, setDirtyFlags, updateDataValue]
);
const SecondsInputProps = {
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
};
const SecondsInputProps = useMemo(
() => ({
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
}),
[LL]
);
const validateAndSubmit = async () => {
const emptyFieldErrors = useMemo(() => ({}), []);
const validateAndSubmit = useCallback(async () => {
if (!data) return;
try {
setFieldErrors(undefined);
@@ -87,22 +96,25 @@ const MqttSettings = () => {
} catch (error) {
setFieldErrors((error as ValidationError).fieldErrors);
}
};
}, [data, saveData]);
const publishIntervalFields = [
{ 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 }
];
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 (
@@ -142,7 +154,7 @@ const MqttSettings = () => {
<Grid container spacing={2} rowSpacing={0}>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors ?? {}}
fieldErrors={fieldErrors ?? emptyFieldErrors}
name="host"
label={LL.ADDRESS_OF(LL.BROKER())}
multiline
@@ -154,7 +166,7 @@ const MqttSettings = () => {
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors ?? {}}
fieldErrors={fieldErrors ?? emptyFieldErrors}
name="port"
label="Port"
variant="outlined"
@@ -166,7 +178,7 @@ const MqttSettings = () => {
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors ?? {}}
fieldErrors={fieldErrors ?? emptyFieldErrors}
name="base"
label={LL.BASE_TOPIC()}
variant="outlined"
@@ -207,7 +219,7 @@ const MqttSettings = () => {
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors ?? {}}
fieldErrors={fieldErrors ?? emptyFieldErrors}
name="keep_alive"
label="Keep Alive"
slotProps={{
@@ -426,7 +438,7 @@ const MqttSettings = () => {
<Grid key={field.name}>
{field.validated ? (
<ValidatedTextField
fieldErrors={fieldErrors ?? {}}
fieldErrors={fieldErrors ?? emptyFieldErrors}
name={field.name}
label={field.label}
slotProps={{
+81 -43
View File
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { toast } from 'react-toastify';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
import CancelIcon from '@mui/icons-material/Cancel';
@@ -23,6 +24,7 @@ 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 {
BlockFormControlLabel,
BlockNavigation,
@@ -32,13 +34,11 @@ import {
ValidatedTextField,
useLayoutTitle
} from 'components';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react';
import type { NTPSettingsType, Time } from 'types';
import { formatLocalDateTime, updateValueDirty, useRest } from 'utils';
import { ValidationError, validate } from 'validators';
import { NTP_SETTINGS_VALIDATOR } from 'validators/ntp';
import type { ValidateFieldsError } from 'validators/schema';
import {
TIME_ZONES,
@@ -67,16 +67,19 @@ const NTPSettings = () => {
const { LL } = useI18nContext();
useLayoutTitle('NTP');
// Memoized timezone select items for better performance
const timeZoneItems = useTimeZoneSelectItems();
const timeZoneItemsT = timeZoneSelectItemsT();
const selectedTzValue = data
? selectedTimeZone(data.tz_label, data.tz_format)
: undefined;
const selectedTzValueT = data
? selectedTimeZone(data.tz_label_t, data.tz_format_t)
: undefined;
// Memoized selected timezone value
const selectedTzValue = useMemo(
() => (data ? selectedTimeZone(data.tz_label, data.tz_format) : undefined),
[data?.tz_label, data?.tz_format]
);
const selectedTzValueT = useMemo(
() => (data ? selectedTimeZone(data.tz_label_t, data.tz_format_t) : undefined),
[data?.tz_label_t, data?.tz_format_t]
);
const [localTime, setLocalTime] = useState<string>('');
const [settingTime, setSettingTime] = useState<boolean>(false);
const [processing, setProcessing] = useState<boolean>(false);
@@ -89,22 +92,32 @@ const NTPSettings = () => {
}
);
const updateFormValue = updateValueDirty(
origData as unknown as Record<string, unknown>,
dirtyFlags,
setDirtyFlags,
updateDataValue as (value: unknown) => void
// Memoize updateFormValue to prevent recreation on every render
const updateFormValue = useMemo(
() =>
updateValueDirty(
origData as unknown as Record<string, unknown>,
dirtyFlags,
setDirtyFlags,
updateDataValue as (value: unknown) => void
),
[origData, dirtyFlags, setDirtyFlags, updateDataValue]
);
const updateLocalTime = (event: React.ChangeEvent<HTMLInputElement>) =>
setLocalTime(event.target.value);
// Memoize updateLocalTime handler
const updateLocalTime = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => setLocalTime(event.target.value),
[]
);
const openSetTime = () => {
// Memoize openSetTime handler
const openSetTime = useCallback(() => {
setLocalTime(formatLocalDateTime(new Date()));
setSettingTime(true);
};
}, []);
const configureTime = async () => {
// Memoize configureTime handler
const configureTime = useCallback(async () => {
setProcessing(true);
try {
@@ -117,11 +130,13 @@ const NTPSettings = () => {
} finally {
setProcessing(false);
}
};
}, [localTime, updateTime, LL, loadData]);
const handleCloseSetTime = () => setSettingTime(false);
// Memoize close dialog handler
const handleCloseSetTime = useCallback(() => setSettingTime(false), []);
const validateAndSubmit = async () => {
// Memoize validate and submit handler
const validateAndSubmit = useCallback(async () => {
if (!data) return;
try {
setFieldErrors(undefined);
@@ -130,27 +145,35 @@ const NTPSettings = () => {
} catch (error) {
setFieldErrors((error as ValidationError).fieldErrors);
}
};
}, [data, saveData]);
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);
};
// Memoize timezone change handler
const changeTimeZone = useCallback(
(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]
);
const changeTimeZoneT = (event: React.ChangeEvent<HTMLInputElement>) => {
void updateState(readNTPSettings(), (settings: NTPSettingsType) => ({
...settings,
tz_label_t: event.target.value,
tz_format_t: TIME_ZONES[event.target.value]
}));
updateFormValue(event);
};
const changeTimeZoneT = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
void updateState(readNTPSettings(), (settings: NTPSettingsType) => ({
...settings,
tz_label_t: event.target.value,
tz_format_t: TIME_ZONES[event.target.value]
}));
updateFormValue(event);
},
[updateFormValue]
);
const renderContent = () => {
// Memoize render content to prevent unnecessary re-renders
const renderContent = useMemo(() => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
}
@@ -276,12 +299,27 @@ const NTPSettings = () => {
)}
</>
);
};
}, [
data,
errorMessage,
loadData,
updateFormValue,
fieldErrors,
selectedTzValue,
selectedTzValueT,
changeTimeZone,
timeZoneItems,
dirtyFlags,
openSetTime,
saving,
validateAndSubmit,
LL
]);
return (
<SectionContent>
{blocker ? <BlockNavigation blocker={blocker} /> : null}
{renderContent()}
{renderContent}
<Dialog sx={dialogStyle} open={settingTime} onClose={handleCloseSetTime}>
<DialogTitle>{LL.SET_TIME(1)}</DialogTitle>
<DialogContent dividers>
+160 -78
View File
@@ -1,108 +1,190 @@
import { useContext } from 'react';
import { useCallback, useMemo, useState } from 'react';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
import BuildIcon from '@mui/icons-material/Build';
import CancelIcon from '@mui/icons-material/Cancel';
import DeviceHubIcon from '@mui/icons-material/DeviceHub';
import ImportExportIcon from '@mui/icons-material/ImportExport';
import LockIcon from '@mui/icons-material/Lock';
import SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore';
import SettingsEthernetIcon from '@mui/icons-material/SettingsEthernet';
import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna';
import TuneIcon from '@mui/icons-material/Tune';
import ViewModuleIcon from '@mui/icons-material/ViewModule';
import { List } from '@mui/material';
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Divider,
List
} from '@mui/material';
import { API } from 'api/app';
import { dialogStyle } from 'CustomTheme';
import { useRequest } from 'alova/client';
import type { APIcall } from 'app/main/types';
import { SectionContent, useLayoutTitle } from 'components';
import ListMenuItem from 'components/layout/ListMenuItem';
import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react';
import SystemMonitor from '../status/SystemMonitor';
const Settings = () => {
const { LL } = useI18nContext();
const { versions } = useContext(AuthenticatedContext);
useLayoutTitle(LL.SETTINGS(0));
const upgradeAvailable = versions?.current?.upgradeable ?? false;
const firmwareText = versions?.current?.version
? `v${versions.current.version}${upgradeAvailable ? ` (${LL.UPDATE_AVAILABLE()})` : ''}`
: '';
const [confirmFactoryReset, setConfirmFactoryReset] = useState(false);
const [restarting, setRestarting] = useState<boolean>();
return (
<SectionContent>
<List>
<ListMenuItem
icon={BuildIcon}
bgcolor="#72caf9"
label="EMS-ESP Firmware"
text={firmwareText}
to="/settings/version"
badge={upgradeAvailable}
/>
const { send: sendAPI } = useRequest((data: APIcall) => API(data), {
immediate: false
});
<ListMenuItem
icon={TuneIcon}
bgcolor="#134ba2"
label={LL.APPLICATION()}
text={LL.APPLICATION_SETTINGS_1()}
to="application"
/>
const doFormat = useCallback(async () => {
await sendAPI({ device: 'system', cmd: 'format', id: 0 }).then(() => {
setRestarting(true);
setConfirmFactoryReset(false);
});
}, [sendAPI]);
<ListMenuItem
icon={SettingsEthernetIcon}
bgcolor="#40828f"
label={LL.NETWORK(0)}
text={LL.CONFIGURE(LL.SETTINGS_OF(LL.NETWORK(1)))}
to="network"
/>
const handleFactoryResetClose = useCallback(() => {
setConfirmFactoryReset(false);
}, []);
<ListMenuItem
icon={SettingsInputAntennaIcon}
bgcolor="#5f9a5f"
label={LL.ACCESS_POINT(0)}
text={LL.CONFIGURE(LL.ACCESS_POINT(1))}
to="ap"
/>
const handleFactoryResetClick = useCallback(() => {
setConfirmFactoryReset(true);
}, []);
<ListMenuItem
icon={AccessTimeIcon}
bgcolor="#c5572c"
label="NTP"
text={LL.CONFIGURE(LL.LOCAL_TIME(1))}
to="ntp"
/>
const content = useMemo(() => {
return (
<>
<List>
<ListMenuItem
icon={TuneIcon}
bgcolor="#134ba2"
label={LL.APPLICATION()}
text={LL.APPLICATION_SETTINGS_1()}
to="application"
/>
<ListMenuItem
icon={DeviceHubIcon}
bgcolor="#68374d"
label="MQTT"
text={LL.CONFIGURE('MQTT')}
to="mqtt"
/>
<ListMenuItem
icon={SettingsEthernetIcon}
bgcolor="#40828f"
label={LL.NETWORK(0)}
text={LL.CONFIGURE(LL.SETTINGS_OF(LL.NETWORK(1)))}
to="network"
/>
<ListMenuItem
icon={LockIcon}
label={LL.SECURITY(0)}
text={LL.SECURITY_1()}
to="security"
/>
<ListMenuItem
icon={SettingsInputAntennaIcon}
bgcolor="#5f9a5f"
label={LL.ACCESS_POINT(0)}
text={LL.CONFIGURE(LL.ACCESS_POINT(1))}
to="ap"
/>
<ListMenuItem
icon={ViewModuleIcon}
bgcolor="#efc34b"
label={LL.MODULES()}
text={LL.MODULES_1()}
to="modules"
/>
<ListMenuItem
icon={AccessTimeIcon}
bgcolor="#c5572c"
label="NTP"
text={LL.CONFIGURE(LL.LOCAL_TIME(1))}
to="ntp"
/>
<ListMenuItem
icon={ImportExportIcon}
bgcolor="#5d89f7"
label={LL.DOWNLOAD_UPLOAD()}
text={LL.DOWNLOAD_UPLOAD_1()}
to="downloadUpload"
/>
</List>
</SectionContent>
);
<ListMenuItem
icon={DeviceHubIcon}
bgcolor="#68374d"
label="MQTT"
text={LL.CONFIGURE('MQTT')}
to="mqtt"
/>
<ListMenuItem
icon={LockIcon}
label={LL.SECURITY(0)}
text={LL.SECURITY_1()}
to="security"
/>
<ListMenuItem
icon={ViewModuleIcon}
bgcolor="#efc34b"
label={LL.MODULES()}
text={LL.MODULES_1()}
to="modules"
/>
<ListMenuItem
icon={ImportExportIcon}
bgcolor="#5d89f7"
label={LL.DOWNLOAD_UPLOAD()}
text={LL.DOWNLOAD_UPLOAD_1()}
to="downloadUpload"
/>
</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>
<Button
startIcon={<SettingsBackupRestoreIcon />}
variant="outlined"
onClick={doFormat}
color="error"
>
{LL.FACTORY_RESET()}
</Button>
</DialogActions>
</Dialog>
<Divider />
<Box
sx={{
mt: 2,
display: 'flex',
justifyContent: 'flex-end',
flexWrap: 'nowrap',
whiteSpace: 'nowrap'
}}
>
<Button
startIcon={<SettingsBackupRestoreIcon />}
variant="outlined"
onClick={handleFactoryResetClick}
color="error"
>
{LL.FACTORY_RESET()}
</Button>
</Box>
</>
);
}, [
LL,
handleFactoryResetClick,
handleFactoryResetClose,
doFormat,
confirmFactoryReset,
restarting
]);
return restarting ? <SystemMonitor /> : <SectionContent>{content}</SectionContent>;
};
export default Settings;
+16 -4
View File
@@ -1,3 +1,5 @@
import { useMemo } from 'react';
import { MenuItem } from '@mui/material';
export const TIME_ZONES: Record<string, string> = {
@@ -470,16 +472,26 @@ 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) => (
<MenuItem key={label} value={label}>
{label}
</MenuItem>
));
export function useTimeZoneSelectItems() {
return precomputedTimeZoneItems;
}
export function timeZoneSelectItems() {
return precomputedTimeZoneItems;
}
File diff suppressed because it is too large Load Diff
+49 -15
View File
@@ -1,5 +1,12 @@
import { memo, useState } from 'react';
import { Outlet, useMatch, useNavigate } from 'react-router';
import { memo, useCallback, useMemo, useState } from 'react';
import {
Navigate,
Route,
Routes,
matchRoutes,
useLocation,
useNavigate
} from 'react-router';
import { Tab } from '@mui/material';
@@ -7,32 +14,52 @@ import { RouterTabs, useLayoutTitle } from 'components';
import { useI18nContext } from 'i18n/i18n-react';
import type { WiFiNetwork } from 'types';
import NetworkSettings from './NetworkSettings';
import { WiFiConnectionContext } from './WiFiConnectionContext';
import WiFiNetworkScanner from './WiFiNetworkScanner';
const Network = () => {
const { LL } = useI18nContext();
useLayoutTitle(LL.NETWORK(0));
const routerTab = useMatch('/settings/network/:tab')?.pathname || false;
// this also works!
// const routerTab = useMatch(`settings/network/:path/*`)?.pathname || false;
const matchedRoutes = matchRoutes(
[
{
path: '/settings/network/settings',
element: <NetworkSettings />
},
{ path: '/settings/network/scan', element: <WiFiNetworkScanner /> }
],
useLocation()
);
const routerTab = matchedRoutes?.[0]?.route.path || false;
const navigate = useNavigate();
const [selectedNetwork, setSelectedNetwork] = useState<WiFiNetwork>();
const selectNetwork = (network: WiFiNetwork) => {
setSelectedNetwork(network);
void navigate('/settings/network/settings');
};
const selectNetwork = useCallback(
(network: WiFiNetwork) => {
setSelectedNetwork(network);
void navigate('/settings/network/settings');
},
[navigate]
);
const deselectNetwork = () => {
const deselectNetwork = useCallback(() => {
setSelectedNetwork(undefined);
};
}, []);
const contextValue = {
...(selectedNetwork && { selectedNetwork }),
selectNetwork,
deselectNetwork
};
const contextValue = useMemo(
() => ({
...(selectedNetwork && { selectedNetwork }),
selectNetwork,
deselectNetwork
}),
[selectedNetwork, selectNetwork, deselectNetwork]
);
return (
<WiFiConnectionContext.Provider value={contextValue}>
@@ -43,7 +70,14 @@ const Network = () => {
/>
<Tab value="/settings/network/scan" label={LL.NETWORK_SCAN()} />
</RouterTabs>
<Outlet />
<Routes>
<Route path="scan" element={<WiFiNetworkScanner />} />
<Route path="settings" element={<NetworkSettings />} />
<Route
path="*"
element={<Navigate replace to="/settings/network/settings" />}
/>
</Routes>
</WiFiConnectionContext.Provider>
);
};
@@ -1,4 +1,5 @@
import { memo, useCallback, useContext, useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel';
import DeleteIcon from '@mui/icons-material/Delete';
@@ -22,10 +23,10 @@ import {
import * as NetworkApi from 'api/network';
import { API } from 'api/app';
import { readSystemStatus } from 'api/system';
import { updateState, useRequest } from 'alova/client';
import type { APIcall } from 'app/main/types';
import type { ValidateFieldsError } from 'async-validator';
import {
BlockFormControlLabel,
BlockNavigation,
@@ -36,13 +37,11 @@ import {
ValidatedPasswordField,
ValidatedTextField
} from 'components';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react';
import type { NetworkSettingsType } from 'types';
import { updateValueDirty, useRest } from 'utils';
import { ValidationError, validate } from 'validators';
import { createNetworkSettingsValidator } from 'validators/network';
import type { ValidateFieldsError } from 'validators/schema';
import SystemMonitor from '../../status/SystemMonitor';
import { WiFiConnectionContext } from './WiFiConnectionContext';
@@ -51,8 +50,6 @@ import { isNetworkOpen, networkSecurityMode } from './WiFiNetworkSelector';
const NetworkSettings = () => {
const { LL } = useI18nContext();
const { data: hardwareData } = useRequest(readSystemStatus);
const { selectedNetwork, deselectNetwork } = useContext(WiFiConnectionContext);
const [initialized, setInitialized] = useState(false);
@@ -92,11 +89,10 @@ const NetworkSettings = () => {
static_ip_config: false,
bandwidth20: false,
tx_power: 0,
nosleep: true,
nosleep: false,
enableMDNS: true,
enableCORS: false,
CORSOrigin: '*',
eth_10mbit: current_data ? current_data.eth_10mbit : false
CORSOrigin: '*'
})
);
}
@@ -125,22 +121,22 @@ const NetworkSettings = () => {
deselectNetwork();
}, [data, saveData, deselectNetwork]);
const setCancel = async () => {
const setCancel = useCallback(async () => {
deselectNetwork();
await loadData();
};
}, [deselectNetwork, loadData]);
const doRestart = async () => {
const doRestart = useCallback(async () => {
setRestarting(true);
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
(error: Error) => {
toast.error(error.message);
}
);
};
}, [sendAPI]);
const content = () => {
if (!data || !hardwareData) {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
}
@@ -177,7 +173,7 @@ const NetworkSettings = () => {
<ValidatedTextField
fieldErrors={fieldErrors || {}}
name="ssid"
label="SSID"
label={'SSID (' + LL.NETWORK_BLANK_SSID() + ')'}
fullWidth
variant="outlined"
value={data.ssid}
@@ -250,23 +246,6 @@ const NetworkSettings = () => {
}
label={LL.NETWORK_LOW_BAND()}
/>
{hardwareData.esp_platform === 'ESP32' && (
<>
<Typography sx={{ pt: 2 }} variant="h6" color="primary">
Ethernet
</Typography>
<BlockFormControlLabel
control={
<Checkbox
name="eth_10mbit"
checked={data.eth_10mbit}
onChange={updateFormValue}
/>
}
label="Force 10Mbit half-duplex"
/>
</>
)}
<Typography sx={{ pt: 2 }} variant="h6" color="primary">
{LL.GENERAL_OPTIONS()}
</Typography>
@@ -1,4 +1,4 @@
import { memo, useRef, useState } from 'react';
import { memo, useCallback, useRef, useState } from 'react';
import PermScanWifiIcon from '@mui/icons-material/PermScanWifi';
import { Button } from '@mui/material';
@@ -48,12 +48,12 @@ const WiFiNetworkScanner = () => {
}
});
const renderNetworkScanner = () => {
const renderNetworkScanner = useCallback(() => {
if (!networkList) {
return <FormLoader errorMessage={errorMessage || ''} />;
}
return <WiFiNetworkSelector networkList={networkList} />;
};
}, [networkList, errorMessage]);
return (
<SectionContent>
@@ -1,4 +1,4 @@
import { memo, useContext } from 'react';
import { memo, useCallback, useContext } from 'react';
import LockIcon from '@mui/icons-material/Lock';
import LockOpenIcon from '@mui/icons-material/LockOpen';
@@ -63,31 +63,34 @@ const WiFiNetworkSelector = ({ networkList }: { networkList: WiFiNetworkList })
const wifiConnectionContext = useContext(WiFiConnectionContext);
const renderNetwork = (network: WiFiNetwork) => (
<ListItem
key={network.bssid}
onClick={() => wifiConnectionContext.selectNetwork(network)}
>
<ListItemAvatar>
<Avatar>{isNetworkOpen(network) ? <LockOpenIcon /> : <LockIcon />}</Avatar>
</ListItemAvatar>
<ListItemText
primary={network.ssid}
secondary={
'Security: ' +
networkSecurityMode(network) +
', Ch: ' +
network.channel +
', bssid: ' +
network.bssid
}
/>
<ListItemIcon>
<Badge badgeContent={network.rssi + 'dBm'}>
<WifiIcon sx={{ color: networkQualityHighlight(network, theme) }} />
</Badge>
</ListItemIcon>
</ListItem>
const renderNetwork = useCallback(
(network: WiFiNetwork) => (
<ListItem
key={network.bssid}
onClick={() => wifiConnectionContext.selectNetwork(network)}
>
<ListItemAvatar>
<Avatar>{isNetworkOpen(network) ? <LockOpenIcon /> : <LockIcon />}</Avatar>
</ListItemAvatar>
<ListItemText
primary={network.ssid}
secondary={
'Security: ' +
networkSecurityMode(network) +
', Ch: ' +
network.channel +
', bssid: ' +
network.bssid
}
/>
<ListItemIcon>
<Badge badgeContent={network.rssi + 'dBm'}>
<WifiIcon sx={{ color: networkQualityHighlight(network, theme) }} />
</Badge>
</ListItemIcon>
</ListItem>
),
[wifiConnectionContext, theme]
);
if (networkList.networks.length === 0) {
@@ -1,4 +1,4 @@
import { memo, useCallback, useContext, useState } from 'react';
import { memo, useCallback, useContext, useMemo, useState } from 'react';
import { useBlocker } from 'react-router';
import CancelIcon from '@mui/icons-material/Cancel';
@@ -55,14 +55,16 @@ const ManageUsers = () => {
const blocker = useBlocker(changed !== 0);
const { LL } = useI18nContext();
const table_theme = useTheme({
Table: `
const table_theme = useMemo(
() =>
useTheme({
Table: `
--data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) minmax(120px, max-content) 120px;
`,
BaseRow: `
BaseRow: `
font-size: 14px;
`,
HeaderRow: `
HeaderRow: `
text-transform: uppercase;
background-color: black;
color: #90CAF9;
@@ -72,7 +74,7 @@ const ManageUsers = () => {
border-bottom: 1px solid #565656;
}
`,
Row: `
Row: `
.td {
padding: 8px;
border-top: 1px solid #565656;
@@ -85,7 +87,7 @@ const ManageUsers = () => {
background-color: #1e1e1e;
}
`,
BaseCell: `
BaseCell: `
&:nth-of-type(2) {
text-align: center;
}
@@ -93,36 +95,44 @@ const ManageUsers = () => {
text-align: right;
}
`
});
}),
[]
);
const noAdminConfigured = () => !data?.users.find((u) => u.admin);
const noAdminConfigured = useCallback(
() => !data?.users.find((u) => u.admin),
[data]
);
const removeUser = (toRemove: UserType) => {
if (!data) return;
const users = data.users.filter((u) => u.username !== toRemove.username);
updateDataValue({ ...data, users });
setChanged(changed + 1);
};
const removeUser = useCallback(
(toRemove: UserType) => {
if (!data) return;
const users = data.users.filter((u) => u.username !== toRemove.username);
updateDataValue({ ...data, users });
setChanged(changed + 1);
},
[data, updateDataValue, changed]
);
const createUser = () => {
const createUser = useCallback(() => {
setCreating(true);
setUser({
username: '',
password: '',
admin: true
});
};
}, []);
const editUser = (toEdit: UserType) => {
const editUser = useCallback((toEdit: UserType) => {
setCreating(false);
setUser({ ...toEdit });
};
}, []);
const cancelEditingUser = () => {
const cancelEditingUser = useCallback(() => {
setUser(undefined);
};
}, []);
const doneEditingUser = () => {
const doneEditingUser = useCallback(() => {
if (user && data) {
const users = [
...data.users.filter(
@@ -134,26 +144,26 @@ const ManageUsers = () => {
setUser(undefined);
setChanged(changed + 1);
}
};
}, [user, data, updateDataValue, changed]);
const closeGenerateToken = useCallback(() => {
setGeneratingToken(undefined);
}, []);
const generateTokenForUser = (username: string) => {
const generateTokenForUser = useCallback((username: string) => {
setGeneratingToken(username);
};
}, []);
const onSubmit = async () => {
const onSubmit = useCallback(async () => {
await saveData();
await authenticatedContext.refresh();
setChanged(0);
};
}, [saveData, authenticatedContext]);
const onCancelSubmit = async () => {
const onCancelSubmit = useCallback(async () => {
await loadData();
setChanged(0);
};
}, [loadData]);
const content = () => {
if (!data) {
@@ -167,10 +177,15 @@ const ManageUsers = () => {
admin: boolean;
}
const user_table = data.users.map((u) => ({
...u,
id: u.username
})) as UserType2[];
// add id to the type, needed for the table
const user_table = useMemo(
() =>
data.users.map((u) => ({
...u,
id: u.username
})) as UserType2[],
[data.users]
);
return (
<>
@@ -1,16 +1,35 @@
import { memo } from 'react';
import { Outlet, useMatch } from 'react-router';
import { memo, useMemo } from 'react';
import { Navigate, Route, Routes, matchRoutes, useLocation } from 'react-router';
import { Tab } from '@mui/material';
import { RouterTabs, useLayoutTitle } from 'components';
import { useI18nContext } from 'i18n/i18n-react';
import ManageUsers from './ManageUsers';
import SecuritySettings from './SecuritySettings';
const Security = () => {
const { LL } = useI18nContext();
useLayoutTitle(LL.SECURITY(0));
const routerTab = useMatch('/settings/security/:tab')?.pathname || false;
const location = useLocation();
const matchedRoutes = useMemo(
() =>
matchRoutes(
[
{
path: '/settings/security/settings',
element: <ManageUsers />
},
{ path: '/settings/security/users', element: <SecuritySettings /> }
],
location
),
[location]
);
const routerTab = matchedRoutes?.[0]?.route.path || false;
return (
<>
@@ -21,7 +40,14 @@ const Security = () => {
/>
<Tab value="/settings/security/users" label={LL.MANAGE_USERS()} />
</RouterTabs>
<Outlet />
<Routes>
<Route path="users" element={<ManageUsers />} />
<Route path="settings" element={<SecuritySettings />} />
<Route
path="*"
element={<Navigate replace to="/settings/security/settings" />}
/>
</Routes>
</>
);
};
@@ -6,6 +6,7 @@ import { Button } from '@mui/material';
import * as SecurityApi from 'api/security';
import type { ValidateFieldsError } from 'async-validator';
import {
BlockNavigation,
ButtonRow,
@@ -19,7 +20,6 @@ import { useI18nContext } from 'i18n/i18n-react';
import type { SecuritySettingsType } from 'types';
import { updateValueDirty, useRest } from 'utils';
import { SECURITY_SETTINGS_VALIDATOR, ValidationError, validate } from 'validators';
import type { ValidateFieldsError } from 'validators/schema';
const SecuritySettings = () => {
const { LL } = useI18nContext();
@@ -79,7 +79,7 @@ const SecuritySettings = () => {
onChange={updateFormValue}
margin="normal"
/>
<MessageBox level="info" message={LL.SU_TEXT()} sx={{ mt: 1 }} />
<MessageBox level="info" message={LL.SU_TEXT()} mt={1} />
{dirtyFlags && dirtyFlags.length !== 0 && (
<ButtonRow>
<Button
+5 -5
View File
@@ -1,4 +1,4 @@
import { memo, useEffect, useState } from 'react';
import { memo, useCallback, useEffect, useState } from 'react';
import type { FC } from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
@@ -14,6 +14,8 @@ import {
} from '@mui/material';
import { dialogStyle } from 'CustomTheme';
import type Schema from 'async-validator';
import type { ValidateFieldsError } from 'async-validator';
import {
BlockFormControlLabel,
ValidatedPasswordField,
@@ -23,8 +25,6 @@ import { useI18nContext } from 'i18n/i18n-react';
import type { UserType } from 'types';
import { updateValue } from 'utils';
import { ValidationError, validate } from 'validators';
import type Schema from 'validators/schema';
import type { ValidateFieldsError } from 'validators/schema';
interface UserFormProps {
creating: boolean;
@@ -62,7 +62,7 @@ const User: FC<UserFormProps> = ({
}
}, [open]);
const validateAndDone = async () => {
const validateAndDone = useCallback(async () => {
if (user) {
try {
setFieldErrors(undefined);
@@ -72,7 +72,7 @@ const User: FC<UserFormProps> = ({
setFieldErrors((error as ValidationError).fieldErrors);
}
}
};
}, [user, validator, onDoneEditing]);
return (
<Dialog
+2 -5
View File
@@ -90,10 +90,7 @@ const APStatus = () => {
<ListItemAvatar>
<Avatar sx={{ bgcolor: 'primary.main' }}>IP</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.ADDRESS_OF('IP')}
secondary={data.ip_address || 'none'}
/>
<ListItemText primary={LL.ADDRESS_OF('IP')} secondary={data.ip_address} />
</ListItem>
<Divider variant="inset" component="li" />
@@ -106,7 +103,7 @@ const APStatus = () => {
</ListItemAvatar>
<ListItemText
primary={LL.ADDRESS_OF('MAC')}
secondary={data.mac_address || 'none'}
secondary={data.mac_address}
/>
</ListItem>
+33 -25
View File
@@ -1,3 +1,5 @@
import { useCallback, useMemo } from 'react';
import {
Body,
Cell,
@@ -34,14 +36,16 @@ const SystemActivity = () => {
useLayoutTitle(LL.DATA_TRAFFIC());
const stats_theme = tableTheme({
Table: `
const stats_theme = tableTheme(
useMemo(
() => ({
Table: `
--data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) 90px 90px 80px;
`,
BaseRow: `
BaseRow: `
font-size: 14px;
`,
HeaderRow: `
HeaderRow: `
text-transform: uppercase;
background-color: black;
color: #90CAF9;
@@ -51,7 +55,7 @@ const SystemActivity = () => {
border-bottom: 1px solid #565656;
}
`,
Row: `
Row: `
.td {
padding: 8px;
border-top: 1px solid #565656;
@@ -65,20 +69,26 @@ const SystemActivity = () => {
background-color: #1e1e1e;
}
`,
BaseCell: `
BaseCell: `
&:not(:first-of-type) {
text-align: center;
}
`
});
}),
[]
)
);
const showName = (id: number) => {
const name: keyof Translation['STATUS_NAMES'] =
id.toString() as keyof Translation['STATUS_NAMES'];
return LL.STATUS_NAMES[name]();
};
const showName = useCallback(
(id: number) => {
const name: keyof Translation['STATUS_NAMES'] =
id.toString() as keyof Translation['STATUS_NAMES'];
return LL.STATUS_NAMES[name]();
},
[LL]
);
const showQuality = (stat: Stat) => {
const showQuality = useCallback((stat: Stat) => {
if (stat.q === 0 || stat.s + stat.f === 0) {
return;
}
@@ -90,18 +100,14 @@ const SystemActivity = () => {
} else {
return <div style={{ color: QUALITY_COLORS.POOR }}>{stat.q}%</div>;
}
};
}, []);
const content = useMemo(() => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
}
if (!data) {
return (
<SectionContent>
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
</SectionContent>
);
}
return (
<SectionContent>
<Table
data={{ nodes: data.stats }}
theme={stats_theme}
@@ -130,8 +136,10 @@ const SystemActivity = () => {
</>
)}
</Table>
</SectionContent>
);
);
}, [data, loadData, error?.message, stats_theme, LL, showName, showQuality]);
return <SectionContent>{content}</SectionContent>;
};
export default SystemActivity;
+9 -8
View File
@@ -1,4 +1,4 @@
import { type FC, memo } from 'react';
import { type FC, memo, useMemo } from 'react';
import AutoAwesomeMotionIcon from '@mui/icons-material/AutoAwesomeMotion';
import DeviceHubIcon from '@mui/icons-material/DeviceHub';
@@ -127,15 +127,16 @@ const MqttStatus = () => {
void loadData();
});
// Memoize error message separately to avoid re-renders on error object changes
const errorMessage = error?.message || '';
const mqttStatusText = !data
? ''
: !data.enabled
? LL.NOT_ENABLED()
: data.connected
? `${LL.CONNECTED(0)} (${data.connect_count})`
: `${LL.DISCONNECTED()} (${data.connect_count})`;
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 (
+11 -11
View File
@@ -1,3 +1,5 @@
import { useMemo } from 'react';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
import DnsIcon from '@mui/icons-material/Dns';
import SwapVerticalCircleIcon from '@mui/icons-material/SwapVerticalCircle';
@@ -65,16 +67,12 @@ const NTPStatus = () => {
}
};
if (!data) {
return (
<SectionContent>
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
</SectionContent>
);
}
const content = useMemo(() => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
}
return (
<SectionContent>
return (
<List>
<ListItem>
<ListItemAvatar>
@@ -123,8 +121,10 @@ const NTPStatus = () => {
</ListItem>
<Divider variant="inset" component="li" />
</List>
</SectionContent>
);
);
}, [data, error, loadData, LL, theme]);
return <SectionContent>{content}</SectionContent>;
};
export default NTPStatus;
+34 -58
View File
@@ -1,3 +1,5 @@
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';
@@ -7,13 +9,11 @@ import SettingsInputComponentIcon from '@mui/icons-material/SettingsInputCompone
import WifiIcon from '@mui/icons-material/Wifi';
import {
Avatar,
Box,
Divider,
List,
ListItem,
ListItemAvatar,
ListItemText,
Typography,
useTheme
} from '@mui/material';
import type { Theme } from '@mui/material';
@@ -64,45 +64,26 @@ const networkQualityHighlight = ({ rssi }: NetworkStatusType, theme: Theme) => {
return theme.palette.success.main;
};
// one address per line, with an optional dimmed note such as the IPv6 scope
const AddressList = ({
addresses
}: {
addresses: { value: string; note?: string }[];
}) => {
if (addresses.length === 0) {
const dnsServers = ({ dns_ip_1, dns_ip_2 }: NetworkStatusType) => {
if (!dns_ip_1) {
return 'none';
}
return addresses.map(({ value, note }) => (
<Box component="span" sx={{ display: 'block' }} key={value}>
{value}
{note && (
<Typography
component="span"
variant="body2"
sx={{ color: 'grey.600', ml: 1 }} // dimmer than the grey[500] secondary text
>
({note})
</Typography>
)}
</Box>
));
return dns_ip_1 + (!dns_ip_2 || dns_ip_2 === '0.0.0.0' ? '' : ', ' + dns_ip_2);
};
const isSet = (ip?: string): ip is string => !!ip && ip !== '0.0.0.0' && ip !== '::';
const ipAddresses = ({ local_ip, ipv6 }: NetworkStatusType) => [
...(isSet(local_ip) ? [{ value: local_ip }] : []),
...(ipv6 ?? []).map(({ address, scope }) => ({ value: address, note: scope }))
];
const gateways = ({ gateway_ip, gateway_ipv6 }: NetworkStatusType) => [
...(isSet(gateway_ip) ? [{ value: gateway_ip }] : []),
...(gateway_ipv6 ?? []).filter(isSet).map((value) => ({ value }))
];
const dnsServers = ({ dns }: NetworkStatusType) =>
(dns ?? []).filter(isSet).map((value) => ({ value }));
const IPs = (status: NetworkStatusType) => {
if (
!status.local_ipv6 ||
status.local_ipv6 === '0000:0000:0000:0000:0000:0000:0000:0000' ||
status.local_ipv6 === '::'
) {
return status.local_ip;
}
if (!status.local_ip || status.local_ip === '0.0.0.0') {
return status.local_ipv6;
}
return status.local_ip + ', ' + status.local_ipv6;
};
const getNetworkStatusText = (
status: NetworkConnectionStatus,
@@ -143,20 +124,16 @@ const NetworkStatus = () => {
const theme = useTheme();
if (!data) {
const content = useMemo(() => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
}
const statusText = getNetworkStatusText(data.status, data.reconnect_count, LL);
const statusColor = networkStatusHighlight(data, theme);
const qualityColor = networkQualityHighlight(data, theme);
return (
<SectionContent>
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
</SectionContent>
);
}
const statusText = getNetworkStatusText(data.status, data.reconnect_count, LL);
const statusColor = networkStatusHighlight(data, theme);
const qualityColor = networkQualityHighlight(data, theme);
return (
<SectionContent>
<List>
<ListItem>
<ListItemAvatar>
@@ -199,10 +176,7 @@ const NetworkStatus = () => {
<ListItemAvatar>
<Avatar>IP</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.ADDRESS_OF('IP')}
secondary={<AddressList addresses={ipAddresses(data)} />}
/>
<ListItemText primary={LL.ADDRESS_OF('IP')} secondary={IPs(data)} />
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
@@ -235,7 +209,7 @@ const NetworkStatus = () => {
</ListItemAvatar>
<ListItemText
primary={LL.NETWORK_GATEWAY()}
secondary={<AddressList addresses={gateways(data)} />}
secondary={data.gateway_ip || 'none'}
/>
</ListItem>
<Divider variant="inset" component="li" />
@@ -247,14 +221,16 @@ const NetworkStatus = () => {
</ListItemAvatar>
<ListItemText
primary={LL.NETWORK_DNS()}
secondary={<AddressList addresses={dnsServers(data)} />}
secondary={dnsServers(data)}
/>
</ListItem>
</>
)}
</List>
</SectionContent>
);
);
}, [data, error, loadData, LL, theme]);
return <SectionContent>{content}</SectionContent>;
};
export default NetworkStatus;
+280 -103
View File
@@ -1,14 +1,25 @@
import { useCallback, useContext, useMemo, useState } from 'react';
import { toast } from 'react-toastify';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
import BuildIcon from '@mui/icons-material/Build';
import CancelIcon from '@mui/icons-material/Cancel';
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 WifiIcon from '@mui/icons-material/Wifi';
import {
Avatar,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
List,
ListItem,
ListItemAvatar,
@@ -16,17 +27,23 @@ import {
useTheme
} from '@mui/material';
import { API } from 'api/app';
import { readSystemStatus } from 'api/system';
import { dialogStyle } from 'CustomTheme';
import { useRequest } from 'alova/client';
import { busConnectionStatus } from 'app/main/types';
import { type APIcall, busConnectionStatus } from 'app/main/types';
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 { 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 = (
@@ -53,7 +70,26 @@ const SystemStatus = () => {
useLayoutTitle(LL.STATUS_OF(''));
const { data, send: loadData, error } = useRequest(readSystemStatus);
const { me } = useContext(AuthenticatedContext);
const [confirmRestart, setConfirmRestart] = useState<boolean>(false);
const [restarting, setRestarting] = useState<boolean>();
const { send: sendAPI } = useRequest((data: APIcall) => API(data), {
immediate: false
});
const {
data,
send: loadData,
error
} = useRequest(readSystemStatus, {
async middleware(_, next) {
if (!restarting) {
await next();
}
}
});
useInterval(() => {
void loadData();
@@ -61,8 +97,10 @@ const SystemStatus = () => {
const theme = useTheme();
const busStatus = (() => {
// Memoize derived status values to avoid recalculation on every render
const busStatus = useMemo(() => {
if (!data) return 'EMS state unknown';
switch (data.bus_status) {
case busConnectionStatus.BUS_STATUS_CONNECTED:
return `EMS ${LL.CONNECTED(0)} (${formatDurationSec(data.bus_uptime, LL)})`;
@@ -73,10 +111,12 @@ const SystemStatus = () => {
default:
return 'EMS state unknown';
}
})();
}, [data?.bus_status, data?.bus_uptime, LL]);
const systemStatus = (() => {
// 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:
@@ -89,12 +129,14 @@ const SystemStatus = () => {
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 = (() => {
const busStatusHighlight = useMemo(() => {
if (!data) return theme.palette.warning.main;
switch (data.bus_status) {
case busConnectionStatus.BUS_STATUS_TX_ERRORS:
return theme.palette.warning.main;
@@ -105,10 +147,11 @@ const SystemStatus = () => {
default:
return theme.palette.warning.main;
}
})();
}, [data?.bus_status, theme.palette]);
const ntpStatus = (() => {
const ntpStatus = useMemo(() => {
if (!data) return LL.UNKNOWN();
switch (data.ntp_status) {
case NTPSyncStatus.NTP_DISABLED:
return LL.NOT_ENABLED();
@@ -121,10 +164,11 @@ const SystemStatus = () => {
default:
return LL.UNKNOWN();
}
})();
}, [data?.ntp_status, data?.ntp_time, LL]);
const ntpStatusHighlight = (() => {
const ntpStatusHighlight = useMemo(() => {
if (!data) return theme.palette.error.main;
switch (data.ntp_status) {
case NTPSyncStatus.NTP_DISABLED:
return theme.palette.info.main;
@@ -135,10 +179,11 @@ const SystemStatus = () => {
default:
return theme.palette.error.main;
}
})();
}, [data?.ntp_status, theme.palette]);
const networkStatusHighlight = (() => {
const networkStatusHighlight = useMemo(() => {
if (!data) return theme.palette.warning.main;
switch (data.network_status) {
case NetworkConnectionStatus.WIFI_STATUS_IDLE:
case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED:
@@ -153,10 +198,11 @@ const SystemStatus = () => {
default:
return theme.palette.warning.main;
}
})();
}, [data?.network_status, theme.palette]);
const networkStatus = (() => {
const networkStatus = useMemo(() => {
if (!data) return LL.UNKNOWN();
switch (data.network_status) {
case NetworkConnectionStatus.WIFI_STATUS_NO_SHIELD:
return LL.INACTIVE(1);
@@ -177,96 +223,227 @@ const SystemStatus = () => {
default:
return LL.UNKNOWN();
}
})();
}, [data?.network_status, data?.wifi_rssi, LL]);
const activeHighlight = (value: boolean) =>
value ? theme.palette.success.main : theme.palette.info.main;
if (!data || !LL) {
return (
<SectionContent>
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
</SectionContent>
);
}
return (
<SectionContent>
<List>
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#c5572c', color: 'white' }}>
<MonitorHeartIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.STATUS_OF(LL.SYSTEM(0))}
secondary={`${systemStatus} (${LL.UPTIME()}: ${formatDurationSec(data.uptime, LL)})`}
/>
</ListItem>
<ListMenuItem
icon={MemoryIcon}
bgcolor="#68374d"
label={LL.HARDWARE()}
text={`${formatNumber(data.free_heap)} KB ${LL.FREE_MEMORY()}`}
to="/status/hardwarestatus"
/>
<ListMenuItem
icon={DirectionsBusIcon}
bgcolor={busStatusHighlight}
label={LL.DATA_TRAFFIC()}
text={busStatus}
to="/status/activity"
/>
<ListMenuItem
icon={
data.network_status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED
? WifiIcon
: RouterIcon
}
bgcolor={networkStatusHighlight}
label={LL.NETWORK(1)}
text={networkStatus}
to="/status/network"
/>
<ListMenuItem
icon={DeviceHubIcon}
bgcolor={activeHighlight(data.mqtt_status)}
label="MQTT"
text={data.mqtt_status ? LL.CONNECTED(0) : LL.INACTIVE(0)}
to="/status/mqtt"
/>
<ListMenuItem
icon={AccessTimeIcon}
bgcolor={ntpStatusHighlight}
label="NTP"
text={ntpStatus}
to="/status/ntp"
/>
<ListMenuItem
icon={SettingsInputAntennaIcon}
bgcolor={activeHighlight(data.ap_status)}
label={LL.ACCESS_POINT(0)}
text={data.ap_status ? LL.ACTIVE() : LL.INACTIVE(0)}
to="/status/ap"
/>
<ListMenuItem
icon={LogoDevIcon}
bgcolor="#40828f"
label={LL.LOG_OF(LL.SYSTEM(0))}
text={LL.VIEW_LOG()}
to="/status/log"
/>
</List>
</SectionContent>
const activeHighlight = useCallback(
(value: boolean) =>
value ? theme.palette.success.main : theme.palette.info.main,
[theme.palette]
);
const doRestart = useCallback(async () => {
setConfirmRestart(false);
setRestarting(true);
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
(error: Error) => {
toast.error(error.message);
}
);
}, [sendAPI]);
const handleCloseRestartDialog = useCallback(() => {
setConfirmRestart(false);
}, []);
const renderRestartDialog = useMemo(
() => (
<Dialog
sx={dialogStyle}
open={confirmRestart}
onClose={handleCloseRestartDialog}
>
<DialogTitle>{LL.RESTART()}</DialogTitle>
<DialogContent dividers>{LL.RESTART_CONFIRM()}</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={handleCloseRestartDialog}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<PowerSettingsNewIcon />}
variant="outlined"
onClick={doRestart}
color="error"
>
{LL.RESTART()}
</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(() => {
if (!data || !LL) {
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
}
return (
<>
<List>
<ListMenuItem
icon={BuildIcon}
bgcolor="#72caf9"
label="EMS-ESP Firmware"
text={firmwareVersion}
to="version"
/>
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#c5572c', color: 'white' }}>
<MonitorHeartIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.STATUS_OF(LL.SYSTEM(0))}
secondary={`${systemStatus} (${LL.UPTIME()}: ${uptimeText})`}
/>
{me.admin && (
<Button
startIcon={<PowerSettingsNewIcon />}
variant="outlined"
color="error"
onClick={handleRestartClick}
>
{LL.RESTART()}
</Button>
)}
</ListItem>
<ListMenuItem
disabled={!me.admin}
icon={MemoryIcon}
bgcolor="#68374d"
label={LL.HARDWARE()}
text={freeMemoryText}
to="/status/hardwarestatus"
/>
<ListMenuItem
disabled={!me.admin}
icon={DirectionsBusIcon}
bgcolor={busStatusHighlight}
label={LL.DATA_TRAFFIC()}
text={busStatus}
to="/status/activity"
/>
<ListMenuItem
disabled={!me.admin}
icon={networkIcon}
bgcolor={networkStatusHighlight}
label={LL.NETWORK(1)}
text={networkStatus}
to="/status/network"
/>
<ListMenuItem
disabled={!me.admin}
icon={DeviceHubIcon}
bgcolor={activeHighlight(data.mqtt_status)}
label="MQTT"
text={mqttStatusText}
to="/status/mqtt"
/>
<ListMenuItem
disabled={!me.admin}
icon={AccessTimeIcon}
bgcolor={ntpStatusHighlight}
label="NTP"
text={ntpStatus}
to="/status/ntp"
/>
<ListMenuItem
disabled={!me.admin}
icon={SettingsInputAntennaIcon}
bgcolor={activeHighlight(data.ap_status)}
label={LL.ACCESS_POINT(0)}
text={apStatusText}
to="/status/ap"
/>
<ListMenuItem
disabled={!me.admin}
icon={LogoDevIcon}
bgcolor="#40828f"
label={LL.LOG_OF(LL.SYSTEM(0))}
text={LL.VIEW_LOG()}
to="/status/log"
/>
</List>
{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>;
};
export default SystemStatus;
+19 -11
View File
@@ -1,4 +1,12 @@
import { memo, useEffect, useLayoutEffect, useRef, useState } from 'react';
import {
memo,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState
} from 'react';
import { toast } from 'react-toastify';
import DownloadIcon from '@mui/icons-material/GetApp';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
@@ -25,7 +33,6 @@ import {
SectionContent,
useLayoutTitle
} from 'components';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react';
import type { LogEntry, LogSettings } from 'types';
import { LogLevel } from 'types';
@@ -178,7 +185,8 @@ const SystemLog = () => {
};
}, [data]); // Recalculate when data changes (in case layout shifts)
const handleLogMessage = (message: { data: string }) => {
// 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) => {
@@ -192,7 +200,7 @@ const SystemLog = () => {
const newLog = [...log, logentry];
return newLog;
});
};
}, []);
useSSE(fetchLogES, {
immediate: true,
@@ -203,7 +211,7 @@ const SystemLog = () => {
toast.error('No connection to Log service');
});
const onDownload = () => {
const onDownload = useCallback(() => {
const result = logEntries
.map((i) => `${i.t} ${levelLabel(i.l)} ${i.i}: [${i.n}] ${i.m}`)
.join('\n');
@@ -217,11 +225,11 @@ const SystemLog = () => {
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
};
}, [logEntries]);
const saveSettings = async () => {
const saveSettings = useCallback(async () => {
await saveData();
};
}, [saveData]);
// handle scrolling - optimized to only scroll when needed
const ref = useRef<HTMLDivElement>(null);
@@ -238,7 +246,7 @@ const SystemLog = () => {
}
}, [logEntries.length, autoscroll]);
const sendReadCommand = () => {
const sendReadCommand = useCallback(() => {
if (readValue === '') {
setReadOpen(!readOpen);
return;
@@ -249,7 +257,7 @@ const SystemLog = () => {
setReadOpen(false);
setReadValue('');
}
};
}, [readValue, readOpen, send]);
const content = () => {
if (!data) {
@@ -369,7 +377,7 @@ const SystemLog = () => {
) : (
<>
{data.developer_mode && (
<IconButton onClick={sendReadCommand} aria-label={LL.RUN()}>
<IconButton onClick={sendReadCommand} aria-label={LL.EXECUTE()}>
<PlayArrowIcon color="primary" />
</IconButton>
)}
+34 -51
View File
@@ -1,4 +1,4 @@
import { useRef, useState } from 'react';
import { useCallback, useMemo, useRef, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
import { Box, Button, Typography } from '@mui/material';
@@ -14,12 +14,7 @@ import { useInterval } from 'utils';
import { LinearProgressWithLabel } from '../../components/upload/LinearProgressWithLabel';
interface SystemMonitorProps {
// when set the page isn't reloaded when done, e.g. when the firmware was installed without a restart
onDone?: (success: boolean) => void;
}
const SystemMonitor = ({ onDone }: SystemMonitorProps) => {
const SystemMonitor = () => {
const [errorMessage, setErrorMessage] = useState<string>();
const hasInitialized = useRef(false);
const { LL } = useI18nContext();
@@ -47,11 +42,7 @@ const SystemMonitor = ({ onDone }: SystemMonitorProps) => {
event.data.status === SystemStatusCodes.SYSTEM_STATUS_NORMAL ||
event.data.status === undefined
) {
if (onDone) {
onDone(true);
} else {
document.location.href = '/';
}
document.location.href = '/';
} else if (
event.data.status === SystemStatusCodes.SYSTEM_STATUS_ERROR_UPLOAD
) {
@@ -66,35 +57,39 @@ const SystemMonitor = ({ onDone }: SystemMonitorProps) => {
void send();
}, 1000); // check every 1 second
const status = data?.status;
const { statusMessage, isUploading, progressValue } = useMemo(() => {
const status = data?.status;
const statusMessage =
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 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 isUploading =
status !== undefined && status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING;
const progressValue =
isUploading && status
? Math.round(status - SystemStatusCodes.SYSTEM_STATUS_UPLOADING)
: 0;
const uploading =
status !== undefined && status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING;
const progress =
uploading && status
? Math.round(status - SystemStatusCodes.SYSTEM_STATUS_UPLOADING)
: 0;
const onCancel = async () => {
return {
statusMessage: message,
isUploading: uploading,
progressValue: progress
};
}, [data?.status, LL]);
const onCancel = useCallback(async () => {
setErrorMessage(undefined);
await setSystemStatus(String(SystemStatusCodes.SYSTEM_STATUS_NORMAL));
if (onDone) {
onDone(false);
} else {
document.location.href = '/';
}
};
document.location.href = '/';
}, [setSystemStatus]);
return (
<Box
@@ -158,21 +153,9 @@ const SystemMonitor = ({ onDone }: SystemMonitorProps) => {
{LL.PLEASE_WAIT()}&hellip;
</Typography>
{isUploading && (
<>
<Box sx={{ width: '100%', pl: 2, pr: 2, py: 2 }}>
<LinearProgressWithLabel value={progressValue} />
</Box>
<Button
sx={{ ml: 2, mt: 2 }}
startIcon={<CancelIcon />}
variant="outlined"
color="secondary"
onClick={onCancel}
>
{LL.CANCEL()}
</Button>
</>
<Box sx={{ width: '100%', pl: 2, pr: 2, py: 2 }}>
<LinearProgressWithLabel value={progressValue} />
</Box>
)}
</>
)}
+927
View File
@@ -0,0 +1,927 @@
import {
memo,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState
} from 'react';
import { Link } from 'react-router';
import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel';
import CloseIcon from '@mui/icons-material/Close';
import CheckIcon from '@mui/icons-material/Done';
import DownloadIcon from '@mui/icons-material/GetApp';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import WarningIcon from '@mui/icons-material/Warning';
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Grid,
IconButton,
Table,
TableBody,
TableCell,
TableRow,
Typography
} from '@mui/material';
import * as SystemApi from 'api/system';
import { API, callAction } from 'api/app';
import { getDevVersion, getStableVersion } from 'api/system';
import { dialogStyle } from 'CustomTheme';
import { useRequest } from 'alova/client';
import type { APIcall } from 'app/main/types';
import SystemMonitor from 'app/status/SystemMonitor';
import {
FormLoader,
SectionContent,
SingleUpload,
useLayoutTitle
} from 'components';
import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react';
import type { TranslationFunctions } from 'i18n/i18n-types';
import { prettyDateTime } from 'utils/time';
// Constants moved outside component to avoid recreation
const STABLE_URL = 'https://github.com/emsesp/EMS-ESP32/releases/download/';
const STABLE_RELNOTES_URL =
'https://github.com/emsesp/EMS-ESP32/blob/main/CHANGELOG.md';
const DEV_URL = 'https://github.com/emsesp/EMS-ESP32/releases/download/latest/';
const DEV_RELNOTES_URL =
'https://github.com/emsesp/EMS-ESP32/blob/dev/CHANGELOG_LATEST.md';
// Types for better type safety
interface PartitionData {
partition: string;
version: string;
install_date?: string;
size: number;
}
interface VersionData {
emsesp_version: string;
arduino_version: string;
esp_platform: string;
flash_chip_size: number;
psram: boolean;
build_flags?: string;
partition: string;
partitions: PartitionData[];
developer_mode: boolean;
}
interface UpgradeCheckData {
emsesp_version: string;
dev_upgradeable: boolean;
stable_upgradeable: boolean;
}
interface VersionInfo {
name: string;
published_at?: string;
}
// Memoized components for better performance
const VersionInfoDialog = memo(
({
showVersionInfo,
latestVersion,
latestDevVersion,
partitionVersion,
partition,
currentPartition,
size,
locale,
LL,
onClose
}: {
showVersionInfo: number;
latestVersion?: VersionInfo;
latestDevVersion?: VersionInfo;
partitionVersion?: VersionInfo | undefined;
partition: string;
currentPartition: string;
size: number;
locale: string;
LL: TranslationFunctions;
onClose: () => void;
}) => {
if (showVersionInfo === 0) return null;
const isStable = showVersionInfo === 1;
const isDev = showVersionInfo === 2;
const isPartition = showVersionInfo === 3;
const version = isStable
? latestVersion
: isDev
? latestDevVersion
: partitionVersion;
const relNotesUrl = isStable
? STABLE_RELNOTES_URL
: isDev
? DEV_RELNOTES_URL
: '';
return (
<Dialog sx={dialogStyle} open={showVersionInfo !== 0} onClose={onClose}>
<DialogTitle>{LL.FIRMWARE_VERSION_INFO()}</DialogTitle>
<DialogContent dividers>
<Table size="small" sx={{ borderCollapse: 'collapse', minWidth: 0 }}>
<TableBody>
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
<TableCell
component="th"
scope="row"
sx={{
color: 'lightblue',
borderBottom: 'none',
pr: 1,
py: 0.5,
fontSize: 13
}}
>
{LL.VERSION()}
</TableCell>
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
{isPartition
? typeof version === 'string'
? version
: version?.name
: version?.name}
</TableCell>
</TableRow>
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
<TableCell
component="th"
scope="row"
sx={{
color: 'lightblue',
borderBottom: 'none',
pr: 1,
py: 0.5,
fontSize: 13,
width: 140
}}
>
{isPartition ? LL.TYPE(0) : LL.RELEASE_TYPE()}
</TableCell>
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
{partition === currentPartition && LL.ACTIVE() + ' '}
{isStable
? LL.STABLE()
: isDev
? LL.DEVELOPMENT()
: 'Partition ' + LL.VERSION()}
</TableCell>
</TableRow>
{isPartition && (
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
<TableCell
component="th"
scope="row"
sx={{
color: 'lightblue',
borderBottom: 'none',
pr: 1,
py: 0.5,
fontSize: 13
}}
>
Partition
</TableCell>
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
{partition}
</TableCell>
</TableRow>
)}
{isPartition && (
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
<TableCell
component="th"
scope="row"
sx={{
color: 'lightblue',
borderBottom: 'none',
pr: 1,
py: 0.5,
fontSize: 13
}}
>
Size
</TableCell>
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
{size} KB
</TableCell>
</TableRow>
)}
{version?.published_at && (
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
<TableCell
component="th"
scope="row"
sx={{
color: 'lightblue',
borderBottom: 'none',
pr: 1,
py: 0.5,
fontSize: 13
}}
>
{isPartition ? 'Install Date' : 'Build Date'}
</TableCell>
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
{prettyDateTime(locale, new Date(version.published_at))}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</DialogContent>
<DialogActions>
{!isPartition && (
<Button
variant="outlined"
component="a"
href={relNotesUrl}
target="_blank"
color="primary"
>
Changelog
</Button>
)}
<Button variant="outlined" onClick={onClose} color="secondary">
{LL.CLOSE()}
</Button>
</DialogActions>
</Dialog>
);
}
);
const InstallDialog = memo(
({
openInstallDialog,
fetchDevVersion,
latestVersion,
latestDevVersion,
upgradeImportantMessageType,
downloadOnly,
platform,
LL,
onClose,
onInstall
}: {
openInstallDialog: boolean;
fetchDevVersion: boolean;
latestVersion?: VersionInfo;
latestDevVersion?: VersionInfo;
upgradeImportantMessageType: number;
downloadOnly: boolean;
platform: string;
LL: TranslationFunctions;
onClose: () => void;
onInstall: (url: string) => void;
}) => {
const binURL = useMemo(() => {
if (!latestVersion || !latestDevVersion) return '';
const version = fetchDevVersion ? latestDevVersion : latestVersion;
const filename = `EMS-ESP-${version.name.replaceAll('.', '_')}-${platform}.bin`;
return fetchDevVersion
? `${DEV_URL}${filename}`
: `${STABLE_URL}v${version.name}/${filename}`;
}, [fetchDevVersion, latestVersion, latestDevVersion, platform]);
return (
<Dialog sx={dialogStyle} open={openInstallDialog} onClose={onClose}>
<DialogTitle>
{`${LL.INSTALL()} ${fetchDevVersion ? LL.DEVELOPMENT() : LL.STABLE()} Firmware`}
</DialogTitle>
<DialogContent dividers>
<Typography sx={{ mb: 2 }}>
{LL.INSTALL_VERSION(
downloadOnly ? LL.DOWNLOAD(1) : LL.INSTALL(),
fetchDevVersion ? latestDevVersion?.name : latestVersion?.name
)}
</Typography>
{upgradeImportantMessageType === 2 && LL.UPGRADE_IMPORTANT_MESSAGES_2()}
{upgradeImportantMessageType === 1 && LL.UPGRADE_IMPORTANT_MESSAGES_1()}
<Typography sx={{ mt: 2 }}>
<Link to="/settings/downloadUpload" style={{ color: 'lightblue' }}>
{LL.DOWNLOAD_SYSTEM_BACKUP()}
</Link>
</Typography>
<Typography sx={{ mt: 2 }}>
<Link
to="https://docs.emsesp.org/FAQ#upgrading-the-firmware"
target="_blank"
rel="noreferrer"
style={{ color: 'lightblue' }}
>
{LL.ONLINE_HELP()}
</Link>
</Typography>
</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={onClose}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<DownloadIcon />}
variant="outlined"
onClick={onClose}
color="primary"
>
<Link
to={binURL}
target="_blank"
rel="noreferrer"
style={{ color: 'lightblue', textDecoration: 'none' }}
>
{LL.DOWNLOAD(0)}
</Link>
</Button>
{!downloadOnly && (
<Button
startIcon={<WarningIcon color="warning" />}
variant="outlined"
onClick={() => onInstall(binURL)}
color="primary"
>
{LL.INSTALL()}
</Button>
)}
</DialogActions>
</Dialog>
);
}
);
const InstallPartitionDialog = memo(
({
openInstallPartitionDialog,
version,
partition,
LL,
onClose,
onInstall
}: {
openInstallPartitionDialog: boolean;
version: string;
partition: string;
LL: TranslationFunctions;
onClose: () => void;
onInstall: (partition: string) => void;
}) => {
return (
<Dialog sx={dialogStyle} open={openInstallPartitionDialog} onClose={onClose}>
<DialogTitle>
{LL.INSTALL()} {LL.STORED_VERSIONS()}
</DialogTitle>
<DialogContent dividers>
<Typography sx={{ mb: 2 }}>
{LL.INSTALL_VERSION(LL.INSTALL(), version)}
</Typography>
</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={onClose}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<WarningIcon color="warning" />}
variant="outlined"
onClick={() => onInstall(partition)}
color="primary"
>
{LL.INSTALL()}
</Button>
</DialogActions>
</Dialog>
);
}
);
// Helper function moved outside component
const getPlatform = (data: VersionData): string => {
return `${data.esp_platform}-${data.flash_chip_size >= 16384 ? '16MB' : '4MB'}${data.psram ? '+' : ''}`;
};
const Version = () => {
const { LL, locale } = useI18nContext();
const { me } = useContext(AuthenticatedContext);
// State management
const [restarting, setRestarting] = useState<boolean>(false);
const [openInstallDialog, setOpenInstallDialog] = useState<boolean>(false);
const [partitionVersion, setPartitionVersion] = useState<VersionInfo | undefined>(
undefined
);
const [partition, setPartition] = useState<string>('');
const [openInstallPartitionDialog, setOpenInstallPartitionDialog] =
useState<boolean>(false);
const [usingDevVersion, setUsingDevVersion] = useState<boolean>(false);
const [fetchDevVersion, setFetchDevVersion] = useState<boolean>(false);
const [devUpgradeAvailable, setDevUpgradeAvailable] = useState<boolean>(false);
const [stableUpgradeAvailable, setStableUpgradeAvailable] =
useState<boolean>(false);
const [internetLive, setInternetLive] = useState<boolean>(false);
const [downloadOnly, setDownloadOnly] = useState<boolean>(false);
const [showVersionInfo, setShowVersionInfo] = useState<number>(0); // 1 = stable, 2 = dev, 3 = partition
const [firmwareSize, setFirmwareSize] = useState<number>(0);
const { send: sendCheckUpgrade } = useRequest(
(versions: string) => callAction({ action: 'checkUpgrade', param: versions }),
{ immediate: false }
).onSuccess((event) => {
const data = event.data as UpgradeCheckData;
setDevUpgradeAvailable(data.dev_upgradeable);
setStableUpgradeAvailable(data.stable_upgradeable);
});
const { send: sendSetPartition } = useRequest(
(partition: string) => callAction({ action: 'setPartition', param: partition }),
{ immediate: false }
).onError((error) => {
toast.error(String(error.error?.message || 'An error occurred'));
});
const {
data,
send: loadData,
error
} = useRequest(SystemApi.readSystemStatus).onSuccess((event) => {
const systemData = event.data as VersionData;
if (systemData.arduino_version.startsWith('Tasmota')) {
setDownloadOnly(true);
}
setUsingDevVersion(systemData.emsesp_version.includes('dev'));
});
const { send: sendUploadURL } = useRequest(
(url: string) => callAction({ action: 'uploadURL', param: url }),
{ immediate: false }
);
const { data: latestVersion } = useRequest(getStableVersion);
const { data: latestDevVersion } = useRequest(getDevVersion);
const { send: sendAPI } = useRequest((data: APIcall) => API(data), {
immediate: false
});
const [upgradeImportantMessageType, setUpgradeImportantMessageType] =
useState<number>(0);
const { send: checkUpgradeImportantMessages } = useRequest(
(version: string) =>
callAction({ action: 'upgradeImportantMessages', param: version }),
{
immediate: false
}
)
.onSuccess((event) => {
const upgradeImportantMessageType_n = (
event.data as { upgradeImportantMessageType: number }
).upgradeImportantMessageType;
setUpgradeImportantMessageType(upgradeImportantMessageType_n);
})
.onError((error) => {
toast.error(String(error.error?.message || 'An error occurred'));
});
// Memoized values
const platform = useMemo(() => (data ? getPlatform(data) : ''), [data]);
// Memoize filtered partitions to avoid recomputing on every render
const otherPartitions = useMemo(
() => data?.partitions.filter((p) => p.partition !== data.partition) ?? [],
[data]
);
const setPartitionVersionInfo = useCallback(
(partition: string) => {
setShowVersionInfo(3);
// search for the partition in the data.partitions array
const partitionData = data?.partitions.find((p) => p.partition === partition);
if (partitionData) {
setPartitionVersion({
name: partitionData.version,
published_at: partitionData.install_date ?? ''
});
setPartition(partitionData.partition);
setFirmwareSize(partitionData.size);
}
},
[data]
);
const doRestart = useCallback(async () => {
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
(error: Error) => {
toast.error(error.message);
}
);
setRestarting(true);
}, [sendAPI]);
const installFirmwareURL = useCallback(
async (url: string) => {
await sendUploadURL(url).catch((error: Error) => {
toast.error(error.message);
});
await doRestart();
},
[sendUploadURL, doRestart]
);
const installPartitionFirmware = useCallback(
async (partition: string) => {
await sendSetPartition(partition).catch((error: Error) => {
toast.error(error.message);
});
setRestarting(true);
},
[sendSetPartition]
);
const showPartitionDialog = useCallback(
(version: string, partition: string, install_date: string) => {
setOpenInstallPartitionDialog(true);
setPartitionVersion({ name: version, published_at: install_date });
setPartition(partition);
},
[]
);
const showFirmwareDialog = useCallback(
(useDevVersion: boolean) => {
setFetchDevVersion(useDevVersion);
void checkUpgradeImportantMessages(
useDevVersion ? latestDevVersion?.name : latestVersion?.name
);
setOpenInstallDialog(true);
},
[latestDevVersion, latestVersion, fetchDevVersion]
);
const closeInstallDialog = useCallback(() => {
setOpenInstallDialog(false);
}, []);
const closeInstallPartitionDialog = useCallback(() => {
setOpenInstallPartitionDialog(false);
}, []);
const handleVersionInfoClose = useCallback(() => {
setShowVersionInfo(0);
setPartitionVersion(undefined);
setPartition('');
}, []);
// check upgrades - only once when both versions are available
const upgradeCheckedRef = useRef(false);
useEffect(() => {
if (latestVersion && latestDevVersion && !upgradeCheckedRef.current) {
upgradeCheckedRef.current = true;
const versions = `${latestDevVersion.name},${latestVersion.name}`;
sendCheckUpgrade(versions)
.catch((error: Error) => {
toast.error(`Failed to check for upgrades: ${error.message}`);
})
.finally(() => {
setInternetLive(true);
});
}
}, [latestVersion, latestDevVersion, sendCheckUpgrade]);
useLayoutTitle('EMS-ESP Firmware');
// Memoized button rendering logic
const showButtons = useCallback(
(showingDev: boolean) => {
const choice = showingDev
? !usingDevVersion
? LL.SWITCH_RELEASE_TYPE(LL.DEVELOPMENT())
: devUpgradeAvailable
? LL.UPDATE_AVAILABLE()
: undefined
: usingDevVersion
? LL.SWITCH_RELEASE_TYPE(LL.STABLE())
: stableUpgradeAvailable
? LL.UPDATE_AVAILABLE()
: undefined;
if (!choice) {
return (
<>
<CheckIcon
color="success"
sx={{ verticalAlign: 'middle', ml: 0.5, mr: 0.5 }}
/>
<span style={{ color: '#66bb6a', fontSize: '0.8em' }}>
{LL.LATEST_VERSION(usingDevVersion ? LL.DEVELOPMENT() : LL.STABLE())}
</span>
<Button
sx={{ ml: 1 }}
variant="outlined"
size="small"
onClick={() => showFirmwareDialog(showingDev)}
>
{LL.REINSTALL()}
</Button>
</>
);
}
if (!me.admin) return null;
return (
<Button
sx={{ ml: 1 }}
variant="outlined"
color={choice === LL.UPDATE_AVAILABLE() ? 'success' : 'warning'}
size="small"
onClick={() => showFirmwareDialog(showingDev)}
>
{choice}
</Button>
);
},
[
usingDevVersion,
devUpgradeAvailable,
stableUpgradeAvailable,
me.admin,
LL,
showFirmwareDialog
]
);
const content = useMemo(() => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
}
return (
<>
<Box sx={{ p: 2, border: '1px solid #565656', borderRadius: 2 }}>
<Typography sx={{ mb: 1 }} variant="h6" color="primary">
{LL.THIS_VERSION()}
</Typography>
<Grid
container
direction="row"
sx={{
justifyContent: 'flex-start',
alignItems: 'baseline'
}}
>
<Grid size={{ xs: 4, md: 2 }}>
<Typography color="secondary">{LL.VERSION()}</Typography>
</Grid>
<Grid size={{ xs: 8, md: 10 }}>
<Typography>
{data.emsesp_version}
{data.build_flags && (
<Typography variant="caption">
&nbsp; &#40;{data.build_flags}&#41;
</Typography>
)}
<IconButton
onClick={() => setPartitionVersionInfo(data.partition)}
aria-label={LL.FIRMWARE_VERSION_INFO()}
>
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
</IconButton>
</Typography>
</Grid>
<Grid size={{ xs: 4, md: 2 }}>
<Typography color="secondary">{LL.PLATFORM()}</Typography>
</Grid>
<Grid size={{ xs: 8, md: 10 }}>
<Typography>
{platform}
<Typography variant="caption">
&nbsp; &#40;
{data.psram ? (
<CheckIcon
color="success"
sx={{
fontSize: '1.5em',
verticalAlign: 'middle'
}}
/>
) : (
<CloseIcon
color="error"
sx={{
fontSize: '1.5em',
verticalAlign: 'middle'
}}
/>
)}
PSRAM&#41;
</Typography>
</Typography>
</Grid>
</Grid>
{internetLive ? (
<>
<Typography sx={{ mt: 4, mb: 1 }} variant="h6" color="primary">
{LL.AVAILABLE_VERSION()}
</Typography>
<Grid
container
direction="row"
rowSpacing={1}
sx={{
justifyContent: 'flex-start',
alignItems: 'baseline'
}}
>
{otherPartitions.length > 0 && data.developer_mode && (
<>
<Grid size={{ xs: 4, md: 2 }}>
<Typography color="secondary">
{LL.STORED_VERSIONS()}
</Typography>
</Grid>
<Grid size={{ xs: 8, md: 10 }}>
{otherPartitions.map((partition) => (
<Typography key={partition.partition} sx={{ mb: 1 }}>
{partition.version}
<IconButton
onClick={() =>
setPartitionVersionInfo(partition.partition)
}
aria-label={LL.FIRMWARE_VERSION_INFO()}
>
<InfoOutlinedIcon
color="primary"
sx={{ fontSize: 18 }}
/>
</IconButton>
<Button
sx={{ ml: 0 }}
variant="outlined"
size="small"
onClick={() =>
showPartitionDialog(
partition.version,
partition.partition,
partition.install_date ?? ''
)
}
>
{LL.INSTALL()}
</Button>
</Typography>
))}
</Grid>
</>
)}
<Grid size={{ xs: 4, md: 2 }}>
<Typography color="secondary">{LL.STABLE()}</Typography>
</Grid>
<Grid size={{ xs: 8, md: 10 }}>
<Typography>
{latestVersion?.name}
<IconButton
onClick={() => setShowVersionInfo(1)}
aria-label={LL.FIRMWARE_VERSION_INFO()}
>
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
</IconButton>
{showButtons(false)}
</Typography>
</Grid>
<Grid size={{ xs: 4, md: 2 }}>
<Typography color="secondary">{LL.DEVELOPMENT()}</Typography>
</Grid>
<Grid size={{ xs: 8, md: 10 }}>
<Typography>
{latestDevVersion?.name}
<IconButton
onClick={() => setShowVersionInfo(2)}
aria-label={LL.FIRMWARE_VERSION_INFO()}
>
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
</IconButton>
{showButtons(true)}
</Typography>
</Grid>
</Grid>
</>
) : (
<Typography sx={{ mt: 2 }} color="warning">
<WarningIcon color="warning" sx={{ verticalAlign: 'middle', mr: 2 }} />
{LL.INTERNET_CONNECTION_REQUIRED()}
</Typography>
)}
{me.admin && (
<>
<VersionInfoDialog
showVersionInfo={showVersionInfo}
latestVersion={latestVersion}
latestDevVersion={latestDevVersion}
partitionVersion={partitionVersion}
locale={locale}
partition={partition}
currentPartition={data?.partition ?? ''}
size={firmwareSize}
LL={LL}
onClose={handleVersionInfoClose}
/>
<InstallDialog
openInstallDialog={openInstallDialog}
fetchDevVersion={fetchDevVersion}
latestVersion={latestVersion}
latestDevVersion={latestDevVersion}
upgradeImportantMessageType={upgradeImportantMessageType}
downloadOnly={downloadOnly}
platform={platform}
LL={LL}
onClose={closeInstallDialog}
onInstall={installFirmwareURL}
/>
<InstallPartitionDialog
openInstallPartitionDialog={openInstallPartitionDialog}
version={partitionVersion?.name || ''}
partition={partition}
LL={LL}
onClose={closeInstallPartitionDialog}
onInstall={installPartitionFirmware}
/>
<Typography sx={{ pt: 2, pb: 2 }} variant="h6" color="primary">
{LL.UPLOAD()}
</Typography>
<SingleUpload doRestart={doRestart} />
</>
)}
</Box>
</>
);
}, [
data,
error,
loadData,
LL,
platform,
internetLive,
latestVersion,
latestDevVersion,
showVersionInfo,
locale,
openInstallDialog,
fetchDevVersion,
downloadOnly,
me.admin,
showButtons,
handleVersionInfoClose,
closeInstallDialog,
installFirmwareURL,
doRestart,
otherPartitions,
setPartitionVersionInfo,
showPartitionDialog,
partitionVersion,
partition,
firmwareSize,
closeInstallPartitionDialog,
installPartitionFirmware
]);
return restarting ? <SystemMonitor /> : <SectionContent>{content}</SectionContent>;
};
export default memo(Version);
+13 -12
View File
@@ -1,4 +1,4 @@
import { type FC, type PropsWithChildren, memo } from 'react';
import { type FC, type PropsWithChildren, memo, useMemo } from 'react';
import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined';
import ErrorIcon from '@mui/icons-material/Error';
@@ -38,17 +38,18 @@ const MessageBox: FC<PropsWithChildren<MessageBoxProps>> = ({
}) => {
const theme = useTheme();
const Icon = LEVEL_ICONS[level];
const palettePath = LEVEL_PALETTE_PATHS[level];
const [paletteKeyName, shade] = palettePath.split('.') as [
keyof typeof theme.palette,
string
];
const paletteKey = theme.palette[paletteKeyName] as unknown as Record<
string,
string
>;
const backgroundColor = paletteKey[shade];
const { Icon, backgroundColor } = useMemo(() => {
const Icon = LEVEL_ICONS[level];
const palettePath = LEVEL_PALETTE_PATHS[level];
const [key, shade] = palettePath.split('.') as [
keyof typeof theme.palette,
string
];
const paletteKey = theme.palette[key] as unknown as Record<string, string>;
const backgroundColor = paletteKey[shade];
return { Icon, backgroundColor };
}, [level, theme]);
return (
<Box
@@ -1,10 +1,10 @@
import { memo, useContext } from 'react';
import { memo, useCallback, useContext, useMemo } from 'react';
import type { ChangeEventHandler } from 'react';
import type { CSSProperties } from 'react';
import { MenuItem, TextField } from '@mui/material';
import CZflag from 'i18n/CS.svg';
import CZflag from 'i18n/CZ.svg';
import DEflag from 'i18n/DE.svg';
import FRflag from 'i18n/FR.svg';
import GBflag from 'i18n/GB.svg';
@@ -44,14 +44,27 @@ const LANGUAGE_OPTIONS: LanguageOption[] = [
const LanguageSelector = () => {
const { setLocale, locale, LL } = useContext(I18nContext);
const onLocaleSelected: ChangeEventHandler<HTMLInputElement> = async ({
target
}) => {
const loc = target.value as Locales;
localStorage.setItem('lang', loc);
await loadLocaleAsync(loc);
setLocale(loc);
};
const onLocaleSelected: ChangeEventHandler<HTMLInputElement> = useCallback(
async ({ target }) => {
const loc = target.value as Locales;
localStorage.setItem('lang', loc);
await loadLocaleAsync(loc);
setLocale(loc);
},
[setLocale]
);
// Memoize menu items to prevent recreation on every render
const menuItems = useMemo(
() =>
LANGUAGE_OPTIONS.map(({ key, flag, label }) => (
<MenuItem key={key} value={key}>
<img src={flag} style={flagStyle} alt={label} />
&nbsp;{label}
</MenuItem>
)),
[]
);
return (
<TextField
@@ -63,12 +76,7 @@ const LanguageSelector = () => {
size="small"
select
>
{LANGUAGE_OPTIONS.map(({ key, flag, label }) => (
<MenuItem key={key} value={key}>
<img src={flag} style={flagStyle} alt={label} />
&nbsp;{label}
</MenuItem>
))}
{menuItems}
</TextField>
);
};
@@ -1,4 +1,4 @@
import { memo, useState } from 'react';
import { memo, useCallback, useState } from 'react';
import type { FC } from 'react';
import VisibilityIcon from '@mui/icons-material/Visibility';
@@ -13,9 +13,9 @@ type ValidatedPasswordFieldProps = Omit<ValidatedTextFieldProps, 'type'>;
const ValidatedPasswordField: FC<ValidatedPasswordFieldProps> = ({ ...props }) => {
const [showPassword, setShowPassword] = useState<boolean>(false);
const togglePasswordVisibility = () => {
const togglePasswordVisibility = useCallback(() => {
setShowPassword((prev) => !prev);
};
}, []);
return (
<ValidatedTextField
@@ -4,7 +4,7 @@ import type { FC } from 'react';
import { FormHelperText, TextField } from '@mui/material';
import type { TextFieldProps } from '@mui/material';
import type { ValidateFieldsError } from 'validators/schema';
import type { ValidateFieldsError } from 'async-validator';
interface ValidatedFieldProps {
fieldErrors?: ValidateFieldsError;
@@ -18,6 +18,7 @@ const LayoutComponent: FC<RequiredChildrenProps> = ({ children }) => {
const [title, setTitle] = useState(PROJECT_NAME);
const { pathname } = useLocation();
// Memoize drawer toggle handler to prevent unnecessary re-renders
const handleDrawerToggle = useCallback(() => {
setMobileOpen((prev) => !prev);
}, []);
@@ -27,6 +28,7 @@ const LayoutComponent: FC<RequiredChildrenProps> = ({ children }) => {
setMobileOpen(false);
}, [pathname]);
// Memoize context value to prevent unnecessary re-renders
const contextValue = useMemo(() => ({ title, setTitle }), [title]);
return (
@@ -1,4 +1,4 @@
import { memo } from 'react';
import { memo, useCallback, useMemo } from 'react';
import { Link, useLocation, useNavigate } from 'react-router';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
@@ -39,11 +39,14 @@ const LayoutAppBarComponent = ({ title, onToggleDrawer }: LayoutAppBarProps) =>
const navigate = useNavigate();
const location = useLocation();
const pathnames = location.pathname.split('/').filter((x) => x);
const pathnames = useMemo(
() => location.pathname.split('/').filter((x) => x),
[location.pathname]
);
const handleBackClick = () => {
const handleBackClick = useCallback(() => {
void navigate('/' + pathnames[0]);
};
}, [navigate, pathnames]);
return (
<AppBar position="fixed" sx={appBarStyles}>
@@ -1,8 +1,7 @@
import { memo, useContext } from 'react';
import { memo, useMemo } from 'react';
import { Box, Divider, Drawer, Toolbar, Typography, styled } from '@mui/material';
import { AuthenticatedContext } from 'contexts/authentication';
import { PROJECT_NAME } from 'env';
import { DRAWER_WIDTH } from './Layout';
@@ -25,33 +24,22 @@ interface LayoutDrawerProps {
}
const LayoutDrawerComponent = ({ mobileOpen, onClose }: LayoutDrawerProps) => {
const { systemName } = useContext(AuthenticatedContext);
const drawer = (
<>
<Toolbar disableGutters>
<Box sx={{ display: 'flex', alignItems: 'center', p: 2 }}>
<LayoutDrawerLogo src="/app/icon.png" alt={PROJECT_NAME} />
<Box
sx={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center'
}}
>
<Typography>{PROJECT_NAME}</Typography>
{systemName && (
<Typography color="secondary" variant="body2">
{systemName}
</Typography>
)}
// Memoize drawer content to prevent unnecessary re-renders
const drawer = useMemo(
() => (
<>
<Toolbar disableGutters>
<Box sx={{ display: 'flex', alignItems: 'center', p: 2 }}>
<LayoutDrawerLogo src="/app/icon.png" alt={PROJECT_NAME} />
<Typography variant="h6">{PROJECT_NAME}</Typography>
</Box>
</Box>
<Divider absolute />
</Toolbar>
<Divider />
<LayoutMenu />
</>
<Divider absolute />
</Toolbar>
<Divider />
<LayoutMenu />
</>
),
[]
);
return (

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