Merge branch 'dev'

This commit is contained in:
proddy
2025-12-31 21:26:15 +01:00
parent eaa277fef0
commit 28135c225b
385 changed files with 40221 additions and 38187 deletions

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
blank_issues_enabled: false blank_issues_enabled: false
contact_links: contact_links:
- name: EMS-ESP Docs - name: EMS-ESP Docs
url: https://docs.emsesp.org url: https://emsesp.org
about: All the information related to EMS-ESP. about: All the information related to EMS-ESP.
- name: EMS-ESP Discussions and Support - name: EMS-ESP Discussions and Support
url: https://github.com/emsesp/EMS-ESP32/discussions url: https://github.com/emsesp/EMS-ESP32/discussions

79
.github/workflows/dev_release.yml vendored Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

63
.github/workflows/stable_release.yml vendored Normal file
View File

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

27
.github/workflows/stale_issues.yml vendored Normal file
View File

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

View File

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

View File

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

9
.gitignore vendored
View File

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

View File

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

View File

@@ -1 +1,3 @@
# Changelog # Changelog
For more details go to [emsesp.org](https://emsesp.org/).

View File

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

View File

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

View File

@@ -15,7 +15,7 @@
<a href="https://github.com/emsesp/EMS-ESP32/blob/dev/CONTRIBUTING.md"> <a href="https://github.com/emsesp/EMS-ESP32/blob/dev/CONTRIBUTING.md">
<img src="https://img.shields.io/badge/Contribute-ff4785?style=for-the-badge&logo=git&logoColor=white" alt="Contribute" /> <img src="https://img.shields.io/badge/Contribute-ff4785?style=for-the-badge&logo=git&logoColor=white" alt="Contribute" />
</a> </a>
<a href="https://docs.emsesp.org"> <a href="https://emsesp.org">
<img src="https://img.shields.io/badge/Documentation-0077b5?style=for-the-badge&logo=googledocs&logoColor=white" alt="Guides" /> <img src="https://img.shields.io/badge/Documentation-0077b5?style=for-the-badge&logo=googledocs&logoColor=white" alt="Guides" />
</a> </a>
<a href="https://discord.gg/3J3GgnzpyT"> <a href="https://discord.gg/3J3GgnzpyT">
@@ -35,7 +35,7 @@
[![chat](https://img.shields.io/discord/816637840644505620.svg?style=flat-square&color=blueviolet)](https://discord.gg/3J3GgnzpyT) [![chat](https://img.shields.io/discord/816637840644505620.svg?style=flat-square&color=blueviolet)](https://discord.gg/3J3GgnzpyT)
[![GitHub stars](https://img.shields.io/github/stars/emsesp/EMS-ESP32.svg?style=social&label=Star)](https://github.com/emsesp/EMS-ESP32/stargazers) [![GitHub stars](https://img.shields.io/github/stars/emsesp/EMS-ESP32.svg?style=social&label=Star)](https://github.com/emsesp/EMS-ESP32/stargazers)
[![GitHub forks](https://img.shields.io/github/forks/emsesp/EMS-ESP32.svg?style=social&label=Fork)](https://github.com/emsesp/EMS-ES32P/network) [![GitHub forks](https://img.shields.io/github/forks/emsesp/EMS-ESP32.svg?style=social&label=Fork)](https://github.com/emsesp/EMS-ESP32/network)
[![donate](https://img.shields.io/badge/donate-PayPal-blue.svg)](https://www.paypal.com/paypalme/prderbyshire/2) [![donate](https://img.shields.io/badge/donate-PayPal-blue.svg)](https://www.paypal.com/paypalme/prderbyshire/2)
**EMS-ESP** is an open-source firmware for the Espressif ESP32 microcontroller to communicate with **EMS** (Energy Management System) compatible equipment from manufacturers such as Bosch, Buderus, Nefit, Junkers, Worcester, Sieger, elm.leblanc and iVT. **EMS-ESP** is an open-source firmware for the Espressif ESP32 microcontroller to communicate with **EMS** (Energy Management System) compatible equipment from manufacturers such as Bosch, Buderus, Nefit, Junkers, Worcester, Sieger, elm.leblanc and iVT.
@@ -60,17 +60,17 @@ It requires a small circuit to interface with the EMS bus which can be purchased
## 🚀&nbsp; **Installing** ## 🚀&nbsp; **Installing**
Head over to [download.emsesp.org](https://download.emsesp.org) for instructions on how to install EMS-ESP. There is also further details on which boards are supported in [this section](https://docs.emsesp.org/Getting-Started/#first-time-install) of the documentation. Head over to the [Installation Guide](https://emsesp.org/Installing) section of the documentation for instructions on how to install EMS-ESP.
## 📋&nbsp; **Documentation** ## 📋&nbsp; **Documentation**
Visit [emsesp.org](https://docs.emsesp.org) for more details on how to install and configure EMS-ESP. There is also a collection of Frequently Asked Questions and Troubleshooting tips with example customizations from the community. Visit [emsesp.org](https://emsesp.org) for more details on how to install and configure EMS-ESP. There is also a collection of Frequently Asked Questions and Troubleshooting tips with example customizations from the community.
## 💬&nbsp; **Getting Support** ## 💬&nbsp; **Getting Support**
To chat with the community reach out on our [Discord Server](https://discord.gg/3J3GgnzpyT). To chat with the community reach out on our [Discord Server](https://discord.gg/3J3GgnzpyT).
If you find an issue or have a request, see [here](https://docs.emsesp.org/Support/) on how to submit a bug report or feature request. If you find an issue or have a request, see [how to request support](https://emsesp.org/Support/) on how to submit a bug report or feature request.
## 🎥&nbsp; **Live Demo** ## 🎥&nbsp; **Live Demo**
@@ -82,13 +82,19 @@ EMS-ESP is a project created by [proddy](https://github.com/proddy) and owned an
If you like **EMS-ESP**, please give it a ✨ on GitHub, or even better fork it and contribute. You can also offer a small donation. This is an open-source project maintained by volunteers, and your support is greatly appreciated. If you like **EMS-ESP**, please give it a ✨ on GitHub, or even better fork it and contribute. You can also offer a small donation. This is an open-source project maintained by volunteers, and your support is greatly appreciated.
## 📦&nbsp; **Building**
To build the web interface only, run `platformio run -e build_webUI`. This will install the necessary dependencies and build the web interface and also create the embedded code used need to build the firmware. You can run the web interface locally by going to the `interface` directory and running `pnpm standalone`.
To build the firmware, run `platformio run`. This will build the firmware for all ESP32 modules and place the binaries in the `build/firmware` folder. If you want to configure the build for a single platform create a local `pio_local.ni` file in the root directory (see example in `pio_local.ini_example`).
## 📢&nbsp; **Libraries used** ## 📢&nbsp; **Libraries used**
- [esp8266-react](https://github.com/rjwats/esp8266-react) by @rjwats for the core framework that provides the Web UI, which has been heavily modified - [esp8266-react](https://github.com/rjwats/esp8266-react) originally by @rjwats for the core framework that provides the Web UI, which has been heavily modified
- [uuid-\*](https://github.com/nomis/mcu-uuid-console) from @nomis. The console, syslog, telnet and logging are based off these awesome open source libraries - [uuid-\*](https://github.com/nomis/mcu-uuid-console) from @nomis. The console, syslog, telnet and logging are based off these awesome open source libraries
- [ArduinoJson](https://github.com/bblanchon/ArduinoJson) for all the JSON processing - [ArduinoJson](https://github.com/bblanchon/ArduinoJson) for all the JSON processing
- [espMqttClient](https://github.com/bertmelis/espMqttClient) for the MQTT client - [espMqttClient](https://github.com/bertmelis/espMqttClient) for the MQTT client
- ESPAsyncWebServer and AsyncTCP for the Web server and TCP backends, with custom modifications for performance - [ESPAsyncWebServer](https://github.com/ESP32Async/ESPAsyncWebServer) and [AsyncTCP](https://github.com/ESP32Async/AsyncTCP) for the Web server
## 📜&nbsp; **License** ## 📜&nbsp; **License**

View File

@@ -25,7 +25,7 @@
"upload": { "upload": {
"flash_size": "32MB", "flash_size": "32MB",
"maximum_ram_size": 327680, "maximum_ram_size": 327680,
"maximum_size": 16777216, "maximum_size": 33554432,
"require_upload_port": true, "require_upload_port": true,
"speed": 460800 "speed": 460800
}, },

View File

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

View File

@@ -32,6 +32,9 @@
"**/*.json", "**/*.json",
"src/core/modbus_entity_parameters.hpp", "src/core/modbus_entity_parameters.hpp",
"sdkconfig.*", "sdkconfig.*",
"managed_components/**" "managed_components/**",
"pnpm-*.yaml",
"vite.config.ts",
"lib/esp32-psram/**"
] ]
} }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,227 +1,232 @@
telegram_type_id,name,is_fetched telegram_type_id,name,is_fetched
0x04,UBAFactory,fetched 0x04,UBAFactory,fetched
0x06,RCTime, 0x06,RCTime,
0x0A,EasyMonitor,fetched 0x0A,EasyMonitor,fetched
0x10,UBAErrorMessage1, 0x10,UBAErrorMessage1,
0x11,UBAErrorMessage2, 0x11,UBAErrorMessage2,
0x12,RCErrorMessage, 0x12,RCErrorMessage,
0x13,RCErrorMessage2, 0x13,RCErrorMessage2,
0x14,UBATotalUptime,fetched 0x14,UBATotalUptime,fetched
0x15,UBAMaintenanceData, 0x15,UBAMaintenanceData,
0x16,UBAParameters,fetched 0x16,UBAParameters,fetched
0x18,UBAMonitorFast, 0x18,UBAMonitorFast,
0x19,UBAMonitorSlow, 0x19,UBAMonitorSlow,
0x1A,UBASetPoints, 0x1A,UBASetPoints,
0x1C,UBAMaintenanceStatus, 0x1C,UBAMaintenanceStatus,
0x1E,WM10TempMessage, 0x1E,WM10TempMessage,
0x23,JunkersSetMixer,fetched 0x23,JunkersSetMixer,fetched
0x26,UBASettingsWW,fetched 0x27,UBASettingsWW,fetched
0x28,WeatherComp,fetched 0x28,WeatherComp,fetched
0x2A,MC110Status, 0x2A,MC110Status,
0x2E,Meters, 0x2E,Meters,
0x33,UBAParameterWW,fetched 0x33,UBAParameterWW,fetched
0x34,UBAMonitorWW, 0x34,UBAMonitorWW,
0x35,UBAFlags, 0x35,UBAFlags,
0x37,WWSettings,fetched 0x37,WWSettings,fetched
0x38,WWTimer,fetched 0x38,WWTimer,fetched
0x39,WWCircTimer,fetched 0x39,WWCircTimer,fetched
0x3A,RC30WWSettings,fetched 0x3A,RC30WWSettings,fetched
0x3B,Energy, 0x3B,Energy,
0x3D,RC35Set, 0x3D,RC35Set,
0x3E,RC35Monitor, 0x3E,RC35Monitor,
0x3F,RC35Timer, 0x3F,RC35Timer,
0x40,RC30Temp, 0x40,RC30Temp,
0x41,RC30Monitor, 0x41,RC30Monitor,
0x42,RC35Timer2, 0x42,RC35Timer2,
0x47,RC35Set, 0x47,RC35Set,
0x48,RC35Monitor, 0x48,RC35Monitor,
0x49,RC35Timer, 0x49,RC35Timer,
0x4C,RC35Timer2, 0x4C,RC35Timer2,
0x51,RC35Set, 0x51,RC35Set,
0x52,RC35Monitor, 0x52,RC35Monitor,
0x53,RC35Timer, 0x53,RC35Timer,
0x56,RC35Timer2, 0x56,RC35Timer2,
0x5B,RC35Set, 0x5B,RC35Set,
0x5C,RC35Monitor, 0x5C,RC35Monitor,
0x5D,RC35Timer, 0x5D,RC35Timer,
0x60,RC35Timer2, 0x60,RC35Timer2,
0x96,SM10Config,fetched 0x96,SM10Config,fetched
0x97,SM10Monitor, 0x97,SM10Monitor,
0x9C,WM10MonitorMessage, 0x9C,WM10MonitorMessage,
0x9D,WM10SetMessage, 0x9D,WM10SetMessage,
0xA2,RCError, 0xA2,RCError,
0xA3,RCOutdoorTemp, 0xA3,RCOutdoorTemp,
0xA5,IBASettings,fetched 0xA5,IBASettings,fetched
0xA7,RC30Set, 0xA7,RC30Set,
0xA9,RC30Vacation,fetched 0xA9,RC30Vacation,fetched
0xAA,MMConfigMessage,fetched 0xAA,MMConfigMessage,fetched
0xAB,MMStatusMessage, 0xAB,MMStatusMessage,
0xAC,MMSetMessage, 0xAC,MMSetMessage,
0xAF,RC20Remote, 0xAF,RC20Remote,
0xB0,RC10Set, 0xB0,RC10Set,
0xB1,RC10Monitor, 0xB1,RC10Monitor,
0xBB,HybridSettings,fetched 0xBB,HybridSettings,fetched
0xBF,ErrorMessage, 0xBF,ErrorMessage,
0xC2,UBAErrorMessage3, 0xC0,RCErrorMessage,
0xD1,UBAOutdoorTemp, 0xC2,UBAErrorMessage3,
0xE3,UBAMonitorSlowPlus2, 0xC6,UBAErrorMessage3,
0xE4,UBAMonitorFastPlus, 0xD1,UBAOutdoorTemp,
0xE5,UBAMonitorSlowPlus, 0xE3,UBAMonitorSlowPlus2,
0xE6,UBAParametersPlus,fetched 0xE4,UBAMonitorFastPlus,
0xE9,UBAMonitorWWPlus, 0xE5,UBAMonitorSlowPlus,
0xEA,UBAParameterWWPlus,fetched 0xE6,UBAParametersPlus,fetched
0x0101,ISM1Set,fetched 0xE9,UBAMonitorWWPlus,
0x0103,ISM1StatusMessage,fetched 0xEA,UBAParameterWWPlus,fetched
0x0104,ISM2StatusMessage, 0x0101,ISM1Set,fetched
0x010C,IPMStatusMessage, 0x0103,ISM1StatusMessage,fetched
0x011E,IPMTempMessage, 0x0104,ISM2StatusMessage,
0x012E,HPEnergy1, 0x010C,IPMStatusMessage,
0x013B,HPEnergy2, 0x011E,JunkersDisp,fetched
0x0165,JunkersSet, 0x012E,HPEnergy1,
0x0166,JunkersSet, 0x013B,HPEnergy2,
0x0167,JunkersSet, 0x0165,JunkersSet,
0x0168,JunkersSet, 0x0166,JunkersSet,
0x016E,Absent,fetched 0x0167,JunkersSet,
0x016F,JunkersMonitor, 0x0168,JunkersSet,
0x0170,JunkersMonitor, 0x016E,Absent,fetched
0x0171,JunkersMonitor, 0x016F,JunkersMonitor,
0x0172,JunkersMonitor, 0x0170,JunkersMonitor,
0x0179,JunkersSet, 0x0171,JunkersMonitor,
0x017A,JunkersSet, 0x0172,JunkersMonitor,
0x017B,JunkersSet, 0x0179,JunkersSet,
0x017C,JunkersSet, 0x017A,JunkersSet,
0x01D3,JunkersDhw,fetched 0x017B,JunkersSet,
0x023A,RC300OutdoorTemp,fetched 0x017C,JunkersSet,
0x023E,PVSettings,fetched 0x01D3,JunkersDhw,fetched
0x0240,RC300Settings,fetched 0x023A,RC300OutdoorTemp,fetched
0x0241,RC300Settings,fetched 0x023E,PVSettings,fetched
0x0267,RC300Floordry, 0x0240,RC300Settings,fetched
0x0269,RC300Holiday,fetched 0x0241,RC300Settings,fetched
0x0291,HPMode,fetched 0x0267,RC300Floordry,
0x0292,HPMode,fetched 0x0269,RC300Holiday,fetched
0x0293,HPMode,fetched 0x0291,HPMode,fetched
0x0294,HPMode,fetched 0x0292,HPMode,fetched
0x029B,RC300Curves, 0x0293,HPMode,fetched
0x029C,RC300Curves, 0x0294,HPMode,fetched
0x029D,RC300Curves, 0x029B,RC300Curves,
0x029E,RC300Curves, 0x029C,RC300Curves,
0x029F,RC300Curves, 0x029D,RC300Curves,
0x02A0,RC300Curves, 0x029E,RC300Curves,
0x02A1,RC300Curves, 0x029F,RC300Curves,
0x02A2,RC300Curves, 0x02A0,RC300Curves,
0x02A5,RC300Monitor,fetched 0x02A1,RC300Curves,
0x02A6,RC300Monitor, 0x02A2,RC300Curves,
0x02A7,CRFMonitor, 0x02A5,RC300Monitor,fetched
0x02A8,RC300Monitor, 0x02A6,RC300Monitor,
0x02A9,RC300Monitor, 0x02A7,RC300Monitor,
0x02AA,RC300Monitor, 0x02A8,RC300Monitor,
0x02AB,RC300Monitor, 0x02A9,RC300Monitor,
0x02AC,RC300Monitor, 0x02AA,RC300Monitor,
0x02AF,RC300Summer, 0x02AB,RC300Monitor,
0x02B0,RC300Summer, 0x02AC,RC300Monitor,
0x02B1,RC300Summer, 0x02AF,RC300Summer,
0x02B2,RC300Summer, 0x02B0,RC300Summer,
0x02B3,RC300Summer, 0x02B1,RC300Summer,
0x02B4,RC300Summer, 0x02B2,RC300Summer,
0x02B5,RC300Summer, 0x02B3,RC300Summer,
0x02B6,RC300Summer, 0x02B4,RC300Summer,
0x02B9,RC300Set, 0x02B5,RC300Summer,
0x02BA,RC300Set, 0x02B6,RC300Summer,
0x02BB,RC300Set, 0x02B9,RC300Set,
0x02BC,RC300Set, 0x02BA,RC300Set,
0x02BD,RC300Set, 0x02BB,RC300Set,
0x02BE,RC300Set, 0x02BC,RC300Set,
0x02BF,RC300Set, 0x02BD,RC300Set,
0x02C0,RC300Set, 0x02BE,RC300Set,
0x02CC,HPPressure,fetched 0x02BF,RC300Set,
0x02CD,MMPLUSConfigMessage,fetched 0x02C0,RC300Set,
0x02CE,RC300Set2, 0x02CC,HPPressure,fetched
0x02D0,RC300Set2, 0x02CD,MMPLUSConfigMessage,
0x02D2,RC300Set2, 0x02D6,HPPump2,fetched
0x02D6,HPPump2,fetched 0x02D7,MMPLUSStatusMessage,
0x02D7,MMPLUSStatusMessage, 0x02E0,UBASetPoints,
0x02F5,RC300WWmode,fetched 0x02F5,RC300WWmode,fetched
0x02F6,RC300WW2mode,fetched 0x02F6,RC300WW2mode,fetched
0x0313,MMPLUSConfigMessage_WWC,fetched 0x0313,MMPLUSConfigMessage_WWC,fetched
0x031B,RC300WWtemp,fetched 0x031B,RC300WWtemp,fetched
0x031D,RC300WWmode2, 0x031D,RC300WWmode2,
0x031E,RC300WWmode2, 0x031E,RC300WWmode2,
0x0331,MMPLUSStatusMessage_WWC, 0x0331,MMPLUSStatusMessage_WWC,
0x0358,SM100SystemConfig,fetched 0x0358,SM100SystemConfig,fetched
0x035A,SM100CircuitConfig,fetched 0x035A,SM100CircuitConfig,fetched
0x035C,SM100HeatAssist,fetched 0x035C,SM100HeatAssist,fetched
0x035D,SM100Circuit2Config,fetched 0x035D,SM100Circuit2Config,fetched
0x035F,SM100Config1,fetched 0x035F,SM100Config1,fetched
0x0361,SM100Differential,fetched 0x0361,SM100Differential,fetched
0x0362,SM100Monitor, 0x0362,SM100Monitor,
0x0363,SM100Monitor2, 0x0363,SM100Monitor2,
0x0364,SM100Status, 0x0364,SM100Status,
0x0366,SM100Config, 0x0366,SM100Config,
0x036A,SM100Status2, 0x036A,SM100Status2,
0x0380,SM100CollectorConfig,fetched 0x0380,SM100CollectorConfig,fetched
0x038E,SM100Energy,fetched 0x038E,SM100Energy,fetched
0x0391,SM100Time,fetched 0x0391,SM100Time,fetched
0x043F,CRHolidays,fetched 0x0421,RC300Set2,
0x0467,HPSet, 0x0422,RC300Set2,
0x0468,HPSet, 0x0423,RC300Set2,
0x0469,HPSet, 0x0424,RC300Set2,
0x046A,HPSet, 0x043F,CRHolidays,fetched
0x0471,RC300Summer2, 0x0467,HPSet,
0x0472,RC300Summer2, 0x0468,HPSet,
0x0473,RC300Summer2, 0x0469,HPSet,
0x0474,RC300Summer2, 0x046A,HPSet,
0x0475,RC300Summer2, 0x0471,RC300Summer2,
0x0476,RC300Summer2, 0x0472,RC300Summer2,
0x0477,RC300Summer2, 0x0473,RC300Summer2,
0x0478,RC300Summer2, 0x0474,RC300Summer2,
0x047B,HP2, 0x0475,RC300Summer2,
0x0484,HPSilentMode,fetched 0x0476,RC300Summer2,
0x0485,HpCooling,fetched 0x0477,RC300Summer2,
0x0486,HpInConfig,fetched 0x0478,RC300Summer2,
0x0488,HPValve,fetched 0x047B,HP2,
0x048A,HpPool,fetched 0x0484,HPSilentMode,fetched
0x048B,HPPumps,fetched 0x0485,HpCooling,fetched
0x048D,HpPower,fetched 0x0486,HpInConfig,fetched
0x048F,HpTemperatures, 0x0488,HPValve,fetched
0x0491,HPAdditionalHeater,fetched 0x048A,HpPool,fetched
0x0492,HpHeaterConfig,fetched 0x048B,HPPumps,fetched
0x0494,UBAEnergySupplied, 0x048D,HpPower,fetched
0x0495,UBAInformation, 0x048F,HpTemperatures,
0x0499,HPDhwSettings,fetched 0x0491,HPAdditionalHeater,fetched
0x049C,HPSettings2,fetched 0x0492,HpHeaterConfig,fetched
0x049D,HPSettings3,fetched 0x0494,UBAEnergySupplied,
0x04A2,HpInput,fetched 0x0495,UBAInformation,
0x04A5,HPFan,fetched 0x0499,HPDhwSettings,fetched
0x04A7,HPPowerLimit,fetched 0x049C,HPSettings2,fetched
0x04AA,HPPower2,fetched 0x049D,HPSettings3,fetched
0x04AE,HPEnergy,fetched 0x04A2,HpInput,fetched
0x04AF,HPMeters,fetched 0x04A5,HPFan,fetched
0x056B,VentilationMode,fetched 0x04A7,HPPowerLimit,fetched
0x0583,VentilationMonitor, 0x04AA,HPPower2,fetched
0x0585,Blowerspeed, 0x04AE,HPEnergy,fetched
0x0587,Bypass, 0x04AF,HPMeters,fetched
0x05BA,HpPoolStatus,fetched 0x055C,VentilationSet,fetched
0x05D9,Airquality, 0x056B,VentilationMode,fetched
0x0772,HIUSettings, 0x0583,VentilationMonitor,
0x0779,HIUMonitor, 0x0585,Blowerspeed,
0x07A5,SM100wwCirc,fetched 0x0587,Bypass,
0x07A6,SM100wwParam,fetched 0x05BA,HpPoolStatus,fetched
0x07AA,SM100wwStatus, 0x05D9,Airquality,
0x07AB,SM100wwCommand, 0x0772,HIUSettings,
0x07AC,SM100wwParam1, 0x0779,HIUMonitor,
0x07AD,SM100ValveStatus, 0x07A5,SM100wwCirc,fetched
0x07AE,SM100wwKeepWarm,fetched 0x07A6,SM100wwParam,fetched
0x07D6,SM100wwTemperature, 0x07AA,SM100wwStatus,
0x07E0,SM100wwStatus2,fetched 0x07AB,SM100wwCommand,
0x0935,EM100SetMessage,fetched 0x07AC,SM100wwParam1,
0x0936,EM100OutMessage, 0x07AD,SM100ValveStatus,
0x0937,EM100TempMessage, 0x07AE,SM100wwKeepWarm,fetched
0x0938,EM100InputMessage, 0x07D6,SM100wwTemperature,
0x0939,EM100MonitorMessage, 0x07E0,SM100wwStatus2,fetched
0x093A,EM100ConfigMessage, 0x0935,EM100SetMessage,fetched
0x0998,HPSettings,fetched 0x0936,EM100OutMessage,
0x0999,HPFunctionTest,fetched 0x0937,EM100TempMessage,
0x099A,HPStarts, 0x0938,EM100InputMessage,
0x099B,HPFlowTemp, 0x0939,EM100MonitorMessage,
0x099C,HPComp, 0x093A,EM100ConfigMessage,
0x09A0,HPTemperature, 0x0998,HPSettings,fetched
0x0999,HPFunctionTest,fetched
0x099A,HPStarts,
0x099B,HPFlowTemp,
0x099C,HPComp,
0x09A0,HPTemperature,
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 WM10TempMessage
17 0x23 JunkersSetMixer fetched
18 0x26 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 RC35Timer
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 0xC2 0xC0 UBAErrorMessage3 RCErrorMessage
66 0xD1 0xC2 UBAOutdoorTemp UBAErrorMessage3
67 0xE3 0xC6 UBAMonitorSlowPlus2 UBAErrorMessage3
68 0xE4 0xD1 UBAMonitorFastPlus UBAOutdoorTemp
69 0xE5 0xE3 UBAMonitorSlowPlus UBAMonitorSlowPlus2
70 0xE6 0xE4 UBAParametersPlus UBAMonitorFastPlus fetched
71 0xE9 0xE5 UBAMonitorWWPlus UBAMonitorSlowPlus
72 0xEA 0xE6 UBAParameterWWPlus UBAParametersPlus fetched
73 0x0101 0xE9 ISM1Set UBAMonitorWWPlus fetched
74 0x0103 0xEA ISM1StatusMessage UBAParameterWWPlus fetched
75 0x0104 0x0101 ISM2StatusMessage ISM1Set fetched
76 0x010C 0x0103 IPMStatusMessage ISM1StatusMessage fetched
77 0x011E 0x0104 IPMTempMessage ISM2StatusMessage
78 0x012E 0x010C HPEnergy1 IPMStatusMessage
79 0x013B 0x011E HPEnergy2 JunkersDisp fetched
80 0x0165 0x012E JunkersSet HPEnergy1
81 0x0166 0x013B JunkersSet HPEnergy2
82 0x0167 0x0165 JunkersSet
83 0x0168 0x0166 JunkersSet
84 0x016E 0x0167 Absent JunkersSet fetched
85 0x016F 0x0168 JunkersMonitor JunkersSet
86 0x0170 0x016E JunkersMonitor Absent fetched
87 0x0171 0x016F JunkersMonitor
88 0x0172 0x0170 JunkersMonitor
89 0x0179 0x0171 JunkersSet JunkersMonitor
90 0x017A 0x0172 JunkersSet JunkersMonitor
91 0x017B 0x0179 JunkersSet
92 0x017C 0x017A JunkersSet
93 0x01D3 0x017B JunkersDhw JunkersSet fetched
94 0x023A 0x017C RC300OutdoorTemp JunkersSet fetched
95 0x023E 0x01D3 PVSettings JunkersDhw fetched
96 0x0240 0x023A RC300Settings RC300OutdoorTemp fetched
97 0x0241 0x023E RC300Settings PVSettings fetched
98 0x0267 0x0240 RC300Floordry RC300Settings fetched
99 0x0269 0x0241 RC300Holiday RC300Settings fetched
100 0x0291 0x0267 HPMode RC300Floordry fetched
101 0x0292 0x0269 HPMode RC300Holiday fetched
102 0x0293 0x0291 HPMode fetched
103 0x0294 0x0292 HPMode fetched
104 0x029B 0x0293 RC300Curves HPMode fetched
105 0x029C 0x0294 RC300Curves HPMode fetched
106 0x029D 0x029B RC300Curves
107 0x029E 0x029C RC300Curves
108 0x029F 0x029D RC300Curves
109 0x02A0 0x029E RC300Curves
110 0x02A1 0x029F RC300Curves
111 0x02A2 0x02A0 RC300Curves
112 0x02A5 0x02A1 RC300Monitor RC300Curves fetched
113 0x02A6 0x02A2 RC300Monitor RC300Curves
114 0x02A7 0x02A5 CRFMonitor RC300Monitor fetched
115 0x02A8 0x02A6 RC300Monitor
116 0x02A9 0x02A7 RC300Monitor
117 0x02AA 0x02A8 RC300Monitor
118 0x02AB 0x02A9 RC300Monitor
119 0x02AC 0x02AA RC300Monitor
120 0x02AF 0x02AB RC300Summer RC300Monitor
121 0x02B0 0x02AC RC300Summer RC300Monitor
122 0x02B1 0x02AF RC300Summer
123 0x02B2 0x02B0 RC300Summer
124 0x02B3 0x02B1 RC300Summer
125 0x02B4 0x02B2 RC300Summer
126 0x02B5 0x02B3 RC300Summer
127 0x02B6 0x02B4 RC300Summer
128 0x02B9 0x02B5 RC300Set RC300Summer
129 0x02BA 0x02B6 RC300Set RC300Summer
130 0x02BB 0x02B9 RC300Set
131 0x02BC 0x02BA RC300Set
132 0x02BD 0x02BB RC300Set
133 0x02BE 0x02BC RC300Set
134 0x02BF 0x02BD RC300Set
135 0x02C0 0x02BE RC300Set
136 0x02CC 0x02BF HPPressure RC300Set fetched
137 0x02CD 0x02C0 MMPLUSConfigMessage RC300Set fetched
138 0x02CE 0x02CC RC300Set2 HPPressure fetched
139 0x02D0 0x02CD RC300Set2 MMPLUSConfigMessage
140 0x02D2 0x02D6 RC300Set2 HPPump2 fetched
141 0x02D6 0x02D7 HPPump2 MMPLUSStatusMessage fetched
142 0x02D7 0x02E0 MMPLUSStatusMessage UBASetPoints
143 0x02F5 RC300WWmode fetched
144 0x02F6 RC300WW2mode fetched
145 0x0313 MMPLUSConfigMessage_WWC fetched
146 0x031B RC300WWtemp fetched
147 0x031D RC300WWmode2
148 0x031E RC300WWmode2
149 0x0331 MMPLUSStatusMessage_WWC
150 0x0358 SM100SystemConfig fetched
151 0x035A SM100CircuitConfig fetched
152 0x035C SM100HeatAssist fetched
153 0x035D SM100Circuit2Config fetched
154 0x035F SM100Config1 fetched
155 0x0361 SM100Differential fetched
156 0x0362 SM100Monitor
157 0x0363 SM100Monitor2
158 0x0364 SM100Status
159 0x0366 SM100Config
160 0x036A SM100Status2
161 0x0380 SM100CollectorConfig fetched
162 0x038E SM100Energy fetched
163 0x0391 SM100Time fetched
164 0x043F 0x0421 CRHolidays RC300Set2 fetched
165 0x0467 0x0422 HPSet RC300Set2
166 0x0468 0x0423 HPSet RC300Set2
167 0x0469 0x0424 HPSet RC300Set2
168 0x046A 0x043F HPSet CRHolidays fetched
169 0x0471 0x0467 RC300Summer2 HPSet
170 0x0472 0x0468 RC300Summer2 HPSet
171 0x0473 0x0469 RC300Summer2 HPSet
172 0x0474 0x046A RC300Summer2 HPSet
173 0x0475 0x0471 RC300Summer2
174 0x0476 0x0472 RC300Summer2
175 0x0477 0x0473 RC300Summer2
176 0x0478 0x0474 RC300Summer2
177 0x047B 0x0475 HP2 RC300Summer2
178 0x0484 0x0476 HPSilentMode RC300Summer2 fetched
179 0x0485 0x0477 HpCooling RC300Summer2 fetched
180 0x0486 0x0478 HpInConfig RC300Summer2 fetched
181 0x0488 0x047B HPValve HP2 fetched
182 0x048A 0x0484 HpPool HPSilentMode fetched
183 0x048B 0x0485 HPPumps HpCooling fetched
184 0x048D 0x0486 HpPower HpInConfig fetched
185 0x048F 0x0488 HpTemperatures HPValve fetched
186 0x0491 0x048A HPAdditionalHeater HpPool fetched
187 0x0492 0x048B HpHeaterConfig HPPumps fetched
188 0x0494 0x048D UBAEnergySupplied HpPower fetched
189 0x0495 0x048F UBAInformation HpTemperatures
190 0x0499 0x0491 HPDhwSettings HPAdditionalHeater fetched
191 0x049C 0x0492 HPSettings2 HpHeaterConfig fetched
192 0x049D 0x0494 HPSettings3 UBAEnergySupplied fetched
193 0x04A2 0x0495 HpInput UBAInformation fetched
194 0x04A5 0x0499 HPFan HPDhwSettings fetched
195 0x04A7 0x049C HPPowerLimit HPSettings2 fetched
196 0x04AA 0x049D HPPower2 HPSettings3 fetched
197 0x04AE 0x04A2 HPEnergy HpInput fetched
198 0x04AF 0x04A5 HPMeters HPFan fetched
199 0x056B 0x04A7 VentilationMode HPPowerLimit fetched
200 0x0583 0x04AA VentilationMonitor HPPower2 fetched
201 0x0585 0x04AE Blowerspeed HPEnergy fetched
202 0x0587 0x04AF Bypass HPMeters fetched
203 0x05BA 0x055C HpPoolStatus VentilationSet fetched
204 0x05D9 0x056B Airquality VentilationMode fetched
205 0x0772 0x0583 HIUSettings VentilationMonitor
206 0x0779 0x0585 HIUMonitor Blowerspeed
207 0x07A5 0x0587 SM100wwCirc Bypass fetched
208 0x07A6 0x05BA SM100wwParam HpPoolStatus fetched
209 0x07AA 0x05D9 SM100wwStatus Airquality
210 0x07AB 0x0772 SM100wwCommand HIUSettings
211 0x07AC 0x0779 SM100wwParam1 HIUMonitor
212 0x07AD 0x07A5 SM100ValveStatus SM100wwCirc fetched
213 0x07AE 0x07A6 SM100wwKeepWarm SM100wwParam fetched
214 0x07D6 0x07AA SM100wwTemperature SM100wwStatus
215 0x07E0 0x07AB SM100wwStatus2 SM100wwCommand fetched
216 0x0935 0x07AC EM100SetMessage SM100wwParam1 fetched
217 0x0936 0x07AD EM100OutMessage SM100ValveStatus
218 0x0937 0x07AE EM100TempMessage SM100wwKeepWarm fetched
219 0x0938 0x07D6 EM100InputMessage SM100wwTemperature
220 0x0939 0x07E0 EM100MonitorMessage SM100wwStatus2 fetched
221 0x093A 0x0935 EM100ConfigMessage EM100SetMessage fetched
222 0x0998 0x0936 HPSettings EM100OutMessage fetched
223 0x0999 0x0937 HPFunctionTest EM100TempMessage fetched
224 0x099A 0x0938 HPStarts EM100InputMessage
225 0x099B 0x0939 HPFlowTemp EM100MonitorMessage
226 0x099C 0x093A HPComp EM100ConfigMessage
227 0x09A0 0x0998 HPTemperature HPSettings fetched
228 0x0999 HPFunctionTest fetched
229 0x099A HPStarts
230 0x099B HPFlowTemp
231 0x099C HPComp
232 0x09A0 HPTemperature

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -10,8 +10,7 @@ export default tseslint.config(
{ {
languageOptions: { languageOptions: {
parserOptions: { parserOptions: {
project: true, project: true
tsconfigRootDir: import.meta.dirname
} }
} }
}, },

View File

@@ -1,6 +1,6 @@
{ {
"name": "EMS-ESP", "name": "EMS-ESP",
"version": "3.7.2", "version": "3.8.0",
"description": "EMS-ESP WebUI", "description": "EMS-ESP WebUI",
"homepage": "https://emsesp.org", "homepage": "https://emsesp.org",
"author": "proddy, emsesp.org", "author": "proddy, emsesp.org",
@@ -8,59 +8,64 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"preinstall": "npx only-allow pnpm",
"dev": "vite dev", "dev": "vite dev",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"build-hosted": "typesafe-i18n --no-watch && vite build --mode hosted", "build-hosted": "typesafe-i18n && vite build --mode hosted",
"preview-standalone": "typesafe-i18n --no-watch && vite build && concurrently -c \"auto\" \"yarn:mock-rest\" \"vite preview\"", "mock-rest": "bun --watch ../mock-api/restServer.ts",
"mock-rest": "bun --watch ../mock-api/rest_server.ts", "preview-standalone": "typesafe-i18n --no-watch && vite build && concurrently -c \"auto\" \"pnpm:mock-rest\" \"vite preview\"",
"standalone": "concurrently -c \"auto\" \"typesafe-i18n\" \"yarn:mock-rest\" \"vite\"", "standalone": "concurrently -c \"auto\" \"typesafe-i18n\" \"pnpm:mock-rest\" \"vite dev\"",
"typesafe-i18n": "typesafe-i18n --no-watch", "typesafe-i18n": "typesafe-i18n --no-watch",
"webUI": "node progmem-generator.js", "build_webUI": "typesafe-i18n --no-watch && vite build && node progmem-generator.js",
"format": "prettier -l -w '**/*.{ts,tsx,js,css,json,md}'", "format": "prettier -l -w '**/*.{ts,tsx,js,css,json,md}'",
"lint": "eslint . --fix" "lint": "eslint . --fix",
"standalone-devcontainer": "concurrently -c \"auto\" \"typesafe-i18n\" \"pnpm:mock-rest\" \"vite --host\""
}, },
"dependencies": { "dependencies": {
"@alova/adapter-xhr": "2.1.1", "@alova/adapter-xhr": "2.3.1",
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0", "@emotion/styled": "^11.14.1",
"@mui/icons-material": "^6.4.8", "@mui/icons-material": "^7.3.6",
"@mui/material": "^6.4.8", "@mui/material": "^7.3.6",
"@table-library/react-table-library": "4.1.12", "@preact/compat": "^18.3.1",
"alova": "3.2.10", "@table-library/react-table-library": "4.1.15",
"alova": "3.4.1",
"async-validator": "^4.2.5", "async-validator": "^4.2.5",
"etag": "^1.8.1",
"formidable": "^3.5.4",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"mime-types": "^2.1.35", "magic-string": "^0.30.21",
"preact": "^10.26.4", "mime-types": "^3.0.2",
"react": "^19.0.0", "preact": "^10.28.1",
"react-dom": "^19.0.0", "react": "^19.2.3",
"react-dom": "^19.2.3",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"react-router": "^7.4.0", "react-router": "^7.11.0",
"react-toastify": "^11.0.5", "react-toastify": "^11.0.5",
"typesafe-i18n": "^5.26.2", "typesafe-i18n": "^5.26.2",
"typescript": "^5.8.2" "typescript": "^5.9.3"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.26.10", "@babel/core": "^7.28.5",
"@eslint/js": "^9.23.0", "@eslint/js": "^9.39.2",
"@preact/compat": "^18.3.1", "@preact/compat": "^18.3.1",
"@preact/preset-vite": "^2.10.1", "@preact/preset-vite": "^2.10.2",
"@trivago/prettier-plugin-sort-imports": "^5.2.2", "@trivago/prettier-plugin-sort-imports": "^6.0.0",
"@types/formidable": "^3", "@types/node": "^25.0.3",
"@types/node": "^22.13.11", "@types/react": "^19.2.7",
"@types/react": "^19.0.12", "@types/react-dom": "^19.2.3",
"@types/react-dom": "^19.0.4", "axe-core": "^4.11.0",
"concurrently": "^9.1.2", "concurrently": "^9.2.1",
"eslint": "^9.23.0", "eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.1", "eslint-config-prettier": "^10.1.8",
"formidable": "^3.5.2", "prettier": "^3.7.4",
"prettier": "^3.5.3", "rollup-plugin-visualizer": "^6.0.5",
"rollup-plugin-visualizer": "^5.14.0", "terser": "^5.44.1",
"terser": "^5.39.0", "typescript-eslint": "^8.51.0",
"typescript-eslint": "8.27.0", "vite": "^7.3.0",
"vite": "^6.2.2",
"vite-plugin-imagemin": "^0.6.1", "vite-plugin-imagemin": "^0.6.1",
"vite-tsconfig-paths": "^5.1.4" "vite-tsconfig-paths": "^6.0.3"
}, },
"packageManager": "yarn@4.7.0" "packageManager": "pnpm@10.27.0+sha512.72d699da16b1179c14ba9e64dc71c9a40988cbdc65c264cb0e489db7de917f20dcf4d64d8723625f2969ba52d4b7e2a1170682d9ac2a5dcaeaab732b7e16f04a"
} }

View File

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

View File

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

View File

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

View File

@@ -1,60 +1,80 @@
import { useContext, useEffect } from 'react'; import { type FC, Suspense, lazy, memo, useContext, useEffect, useRef } from 'react';
import { Navigate, Route, Routes } from 'react-router'; import { Navigate, Route, Routes } from 'react-router';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import AuthenticatedRouting from 'AuthenticatedRouting'; import {
import SignIn from 'SignIn'; LoadingSpinner,
import { RequireAuthenticated, RequireUnauthenticated } from 'components'; RequireAuthenticated,
RequireUnauthenticated
} from 'components';
import { Authentication, AuthenticationContext } from 'contexts/authentication'; import { Authentication, AuthenticationContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
// Lazy load route components for better code splitting
const SignIn = lazy(() => import('SignIn'));
const AuthenticatedRouting = lazy(() => import('AuthenticatedRouting'));
interface SecurityRedirectProps { interface SecurityRedirectProps {
message: string; readonly message: string;
signOut?: boolean; readonly signOut?: boolean;
} }
const RootRedirect = ({ message, signOut }: SecurityRedirectProps) => { const RootRedirect: FC<SecurityRedirectProps> = memo(
const authenticationContext = useContext(AuthenticationContext); ({ message, signOut = false }) => {
useEffect(() => { const { signOut: contextSignOut } = useContext(AuthenticationContext);
signOut && authenticationContext.signOut(false); const hasShownToast = useRef(false);
toast.success(message);
}, [message, signOut, authenticationContext]);
return <Navigate to="/" />;
};
const AppRouting = () => { 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(); const { LL } = useI18nContext();
return ( return (
<Authentication> <Authentication>
<Routes> <Suspense fallback={<LoadingSpinner />}>
<Route <Routes>
path="/unauthorized" <Route
element={<RootRedirect message={LL.PLEASE_SIGNIN()} signOut />} path="/unauthorized"
/> element={<RootRedirect message={LL.PLEASE_SIGNIN()} signOut />}
<Route />
path="/fileUpdated" <Route
element={<RootRedirect message={LL.UPLOAD_SUCCESSFUL()} />} path="/fileUpdated"
/> element={<RootRedirect message={LL.UPLOAD_SUCCESSFUL()} />}
<Route />
path="/" <Route
element={ path="/"
<RequireUnauthenticated> element={
<SignIn /> <RequireUnauthenticated>
</RequireUnauthenticated> <SignIn />
} </RequireUnauthenticated>
/> }
<Route />
path="/*" <Route
element={ path="/*"
<RequireAuthenticated> element={
<AuthenticatedRouting /> <RequireAuthenticated>
</RequireAuthenticated> <AuthenticatedRouting />
} </RequireAuthenticated>
/> }
</Routes> />
</Routes>
</Suspense>
</Authentication> </Authentication>
); );
}; });
export default AppRouting; export default AppRouting;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { useBlocker, useLocation } from 'react-router'; import { useBlocker, useLocation } from 'react-router';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
@@ -16,7 +16,7 @@ import {
DialogActions, DialogActions,
DialogContent, DialogContent,
DialogTitle, DialogTitle,
Grid2 as Grid, Grid,
InputAdornment, InputAdornment,
Link, Link,
MenuItem, MenuItem,
@@ -62,7 +62,24 @@ import OptionIcon from './OptionIcon';
import { DeviceEntityMask } from './types'; import { DeviceEntityMask } from './types';
import type { APIcall, Device, DeviceEntity } from './types'; import type { APIcall, Device, DeviceEntity } from './types';
export const APIURL = window.location.origin + '/api/'; export const APIURL = `${window.location.origin}/api/`;
const MAX_BUFFER_SIZE = 2000;
// Helper function to create masked entity ID - extracted to avoid duplication
const createMaskedEntityId = (de: DeviceEntity): string => {
const maskHex = de.m.toString(16).padStart(2, '0');
const hasCustomizations = !!(de.cn || de.mi || de.ma);
const customizations = [
de.cn || '',
de.mi ? `>${de.mi}` : '',
de.ma ? `<${de.ma}` : ''
]
.filter(Boolean)
.join('');
return `${maskHex}${de.id}${hasCustomizations ? `|${customizations}` : ''}`;
};
const Customizations = () => { const Customizations = () => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
@@ -125,13 +142,22 @@ const Customizations = () => {
const setOriginalSettings = (data: DeviceEntity[]) => { const setOriginalSettings = (data: DeviceEntity[]) => {
setDeviceEntities( setDeviceEntities(
data.map((de) => ({ data.map((de) => {
...de, const result: DeviceEntity = {
o_m: de.m, ...de,
o_cn: de.cn, o_m: de.m
o_mi: de.mi, };
o_ma: de.ma if (de.cn !== undefined) {
})) result.o_cn = de.cn;
}
if (de.mi !== undefined) {
result.o_mi = de.mi;
}
if (de.ma !== undefined) {
result.o_ma = de.ma;
}
return result;
})
); );
}; };
@@ -144,17 +170,19 @@ const Customizations = () => {
); );
}; };
const entities_theme = useTheme({ const entities_theme = useMemo(
Table: ` () =>
useTheme({
Table: `
--data-table-library_grid-template-columns: 156px repeat(1, minmax(80px, 1fr)) 45px minmax(45px, auto) minmax(120px, auto); --data-table-library_grid-template-columns: 156px repeat(1, minmax(80px, 1fr)) 45px minmax(45px, auto) minmax(120px, auto);
`, `,
BaseRow: ` BaseRow: `
font-size: 14px; font-size: 14px;
.td { .td {
height: 32px; height: 32px;
} }
`, `,
BaseCell: ` BaseCell: `
&:nth-of-type(3) { &:nth-of-type(3) {
text-align: right; text-align: right;
} }
@@ -165,7 +193,7 @@ const Customizations = () => {
text-align: right; text-align: right;
} }
`, `,
HeaderRow: ` HeaderRow: `
text-transform: uppercase; text-transform: uppercase;
background-color: black; background-color: black;
color: #90CAF9; color: #90CAF9;
@@ -177,7 +205,7 @@ const Customizations = () => {
text-align: center; text-align: center;
} }
`, `,
Row: ` Row: `
background-color: #1e1e1e; background-color: #1e1e1e;
position: relative; position: relative;
cursor: pointer; cursor: pointer;
@@ -193,7 +221,7 @@ const Customizations = () => {
background-color: #177ac9; background-color: #177ac9;
} }
`, `,
Cell: ` Cell: `
&:nth-of-type(2) { &:nth-of-type(2) {
padding: 8px; padding: 8px;
} }
@@ -207,7 +235,9 @@ const Customizations = () => {
padding-right: 8px; padding-right: 8px;
} }
` `
}); }),
[]
);
function hasEntityChanged(de: DeviceEntity) { function hasEntityChanged(de: DeviceEntity) {
return ( return (
@@ -220,19 +250,8 @@ const Customizations = () => {
useEffect(() => { useEffect(() => {
if (deviceEntities.length) { if (deviceEntities.length) {
setNumChanges( const changedEntities = deviceEntities.filter((de) => hasEntityChanged(de));
deviceEntities setNumChanges(changedEntities.length);
.filter((de) => hasEntityChanged(de))
.map(
(new_de) =>
new_de.m.toString(16).padStart(2, '0') +
new_de.id +
(new_de.cn || new_de.mi || new_de.ma ? '|' : '') +
(new_de.cn ? new_de.cn : '') +
(new_de.mi ? '>' + new_de.mi : '') +
(new_de.ma ? '<' + new_de.ma : '')
).length
);
} }
}, [deviceEntities]); }, [deviceEntities]);
@@ -244,8 +263,11 @@ const Customizations = () => {
setSelectedDevice(-1); setSelectedDevice(-1);
setSelectedDeviceTypeNameURL(''); setSelectedDeviceTypeNameURL('');
} else { } else {
setSelectedDeviceTypeNameURL(devices.devices[index].url || ''); const device = devices.devices[index];
setSelectedDeviceName(devices.devices[index].n); if (device) {
setSelectedDeviceTypeNameURL(device.url || '');
setSelectedDeviceName(device.n);
}
setNumChanges(0); setNumChanges(0);
setRestartNeeded(false); setRestartNeeded(false);
} }
@@ -263,18 +285,26 @@ const Customizations = () => {
return value as string; return value as string;
} }
const formatName = (de: DeviceEntity, withShortname: boolean) => const isCommand = useCallback((de: DeviceEntity) => {
(de.n && de.n[0] === '!' return de.n && de.n[0] === '!';
? de.t }, []);
? LL.COMMAND(1) + ': ' + de.t + ' ' + de.n.slice(1)
: LL.COMMAND(1) + ': ' + de.n.slice(1) const formatName = useCallback(
: de.cn && de.cn !== '' (de: DeviceEntity, withShortname: boolean) => {
? de.t let name: string;
? de.t + ' ' + de.cn if (isCommand(de)) {
: de.cn name = de.t
: de.t ? `${LL.COMMAND(1)}: ${de.t} ${de.n?.slice(1)}`
? de.t + ' ' + de.n : `${LL.COMMAND(1)}: ${de.n?.slice(1)}`;
: de.n) + (withShortname ? ' ' + de.id : ''); } 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[]) => { const getMaskNumber = (newMask: string[]) => {
let new_mask = 0; let new_mask = 0;
@@ -304,34 +334,33 @@ const Customizations = () => {
return new_masks; return new_masks;
}; };
const filter_entity = (de: DeviceEntity) => const filter_entity = useCallback(
(de.m & selectedFilters || !selectedFilters) && (de: DeviceEntity) =>
formatName(de, true).includes(search); (de.m & selectedFilters || !selectedFilters) &&
formatName(de, true).toLowerCase().includes(search.toLowerCase()),
[selectedFilters, search, formatName]
);
const maskDisabled = (set: boolean) => { const maskDisabled = useCallback(
setDeviceEntities( (set: boolean) => {
deviceEntities.map(function (de) { setDeviceEntities((prev) =>
if (filter_entity(de)) { prev.map((de) => {
return { if (filter_entity(de)) {
...de, const excludeMask =
m: set DeviceEntityMask.DV_API_MQTT_EXCLUDE | DeviceEntityMask.DV_WEB_EXCLUDE;
? de.m | return {
(DeviceEntityMask.DV_API_MQTT_EXCLUDE | ...de,
DeviceEntityMask.DV_WEB_EXCLUDE) m: set ? de.m | excludeMask : de.m & ~excludeMask
: de.m & };
~( }
DeviceEntityMask.DV_API_MQTT_EXCLUDE |
DeviceEntityMask.DV_WEB_EXCLUDE
)
};
} else {
return de; return de;
} })
}) );
); },
}; [filter_entity]
);
const resetCustomization = async () => { const resetCustomization = useCallback(async () => {
try { try {
await sendResetCustomizations(); await sendResetCustomizations();
toast.info(LL.CUSTOMIZATIONS_RESTART()); toast.info(LL.CUSTOMIZATIONS_RESTART());
@@ -339,25 +368,30 @@ const Customizations = () => {
toast.error((error as Error).message); toast.error((error as Error).message);
} finally { } finally {
setConfirmReset(false); setConfirmReset(false);
setRestarting(true);
} }
}; }, [sendResetCustomizations, LL]);
const onDialogClose = () => { const onDialogClose = () => {
setDialogOpen(false); setDialogOpen(false);
}; };
const updateDeviceEntity = (updatedItem: DeviceEntity) => { const updateDeviceEntity = useCallback((updatedItem: DeviceEntity) => {
setDeviceEntities( setDeviceEntities(
deviceEntities?.map((de) => (prev) =>
de.id === updatedItem.id ? { ...de, ...updatedItem } : de prev?.map((de) =>
) de.id === updatedItem.id ? { ...de, ...updatedItem } : de
) ?? []
); );
}; }, []);
const onDialogSave = (updatedItem: DeviceEntity) => { const onDialogSave = useCallback(
setDialogOpen(false); (updatedItem: DeviceEntity) => {
updateDeviceEntity(updatedItem); setDialogOpen(false);
}; updateDeviceEntity(updatedItem);
},
[updateDeviceEntity]
);
const editDeviceEntity = useCallback((de: DeviceEntity) => { const editDeviceEntity = useCallback((de: DeviceEntity) => {
if (de.n === undefined || (de.n && de.n[0] === '!')) { if (de.n === undefined || (de.n && de.n[0] === '!')) {
@@ -372,54 +406,54 @@ const Customizations = () => {
setDialogOpen(true); setDialogOpen(true);
}, []); }, []);
const saveCustomization = async () => { const saveCustomization = useCallback(async () => {
if (devices && deviceEntities && selectedDevice !== -1) { if (!devices || !deviceEntities || selectedDevice === -1) {
const masked_entities = deviceEntities return;
.filter((de: DeviceEntity) => hasEntityChanged(de)) }
.map(
(new_de) =>
new_de.m.toString(16).padStart(2, '0') +
new_de.id +
(new_de.cn || new_de.mi || new_de.ma ? '|' : '') +
(new_de.cn ? new_de.cn : '') +
(new_de.mi ? '>' + new_de.mi : '') +
(new_de.ma ? '<' + new_de.ma : '')
);
// check size in bytes to match buffer in CPP, which is 2048 const masked_entities = deviceEntities
const bytes = new TextEncoder().encode(JSON.stringify(masked_entities)).length; .filter((de: DeviceEntity) => hasEntityChanged(de))
if (bytes > 2000) { .map((new_de) => createMaskedEntityId(new_de));
toast.warning(LL.CUSTOMIZATIONS_FULL());
return;
}
await sendCustomizationEntities({ // check size in bytes to match buffer in CPP, which is 2048
id: selectedDevice, const bytes = new TextEncoder().encode(JSON.stringify(masked_entities)).length;
entity_ids: masked_entities if (bytes > MAX_BUFFER_SIZE) {
}).catch((error: Error) => { toast.warning(LL.CUSTOMIZATIONS_FULL());
return;
}
await sendCustomizationEntities({
id: selectedDevice,
entity_ids: masked_entities
})
.then(() => {
toast.success(LL.CUSTOMIZATIONS_SAVED());
})
.catch((error: Error) => {
if (error.message === 'Reboot required') { if (error.message === 'Reboot required') {
setRestartNeeded(true); setRestartNeeded(true);
} else { } else {
toast.error(error.message); toast.error(error.message);
} }
})
.finally(() => {
setOriginalSettings(deviceEntities);
}); });
setOriginalSettings(deviceEntities); }, [devices, deviceEntities, selectedDevice, sendCustomizationEntities, LL]);
}
};
const renameDevice = async () => { const renameDevice = useCallback(async () => {
await sendDeviceName({ id: selectedDevice, name: selectedDeviceName }) await sendDeviceName({ id: selectedDevice, name: selectedDeviceName })
.then(() => { .then(() => {
toast.success(LL.UPDATED_OF(LL.NAME(1))); toast.success(LL.UPDATED_OF(LL.NAME(1)));
}) })
.catch(() => { .catch(() => {
toast.error(LL.UPDATE_OF(LL.NAME(1)) + ' ' + LL.FAILED(1)); toast.error(`${LL.UPDATE_OF(LL.NAME(1))} ${LL.FAILED(1)}`);
}) })
.finally(async () => { .finally(async () => {
setRename(false); setRename(false);
await fetchCoreData(); await fetchCoreData();
}); });
}; }, [selectedDevice, selectedDeviceName, sendDeviceName, LL, fetchCoreData]);
const renderDeviceList = () => ( const renderDeviceList = () => (
<> <>
@@ -482,25 +516,38 @@ const Customizations = () => {
</Button> </Button>
</> </>
) : ( ) : (
<Button <>
startIcon={<EditIcon />} <Button
variant="outlined" startIcon={<EditIcon />}
onClick={() => setRename(true)} variant="outlined"
> onClick={() => setRename(true)}
{LL.RENAME()} >
</Button> {LL.RENAME()}
</Button>
<Button
startIcon={<SettingsBackupRestoreIcon />}
variant="outlined"
color="error"
onClick={() => setConfirmReset(true)}
>
{LL.REMOVE_ALL()}
</Button>
</>
))} ))}
</Box> </Box>
</> </>
); );
const renderDeviceData = () => { const filteredEntities = useMemo(
const shown_data = deviceEntities.filter((de) => filter_entity(de)); () => deviceEntities.filter((de) => filter_entity(de)),
[deviceEntities, filter_entity]
);
const renderDeviceData = () => {
return ( return (
<> <>
<Box color="warning.main"> <Box color="warning.main">
<Typography variant="body2" mt={1}> <Typography variant="body2" mt={1} mb={1}>
<OptionIcon type="favorite" isSet={true} />={LL.CUSTOMIZATIONS_HELP_2()} <OptionIcon type="favorite" isSet={true} />={LL.CUSTOMIZATIONS_HELP_2()}
&nbsp;&nbsp; &nbsp;&nbsp;
<OptionIcon type="readonly" isSet={true} />={LL.CUSTOMIZATIONS_HELP_3()} <OptionIcon type="readonly" isSet={true} />={LL.CUSTOMIZATIONS_HELP_3()}
@@ -526,6 +573,7 @@ const Customizations = () => {
size="small" size="small"
variant="outlined" variant="outlined"
placeholder={LL.SEARCH()} placeholder={LL.SEARCH()}
aria-label={LL.SEARCH()}
onChange={(event) => { onChange={(event) => {
setSearch(event.target.value); setSearch(event.target.value);
}} }}
@@ -545,7 +593,7 @@ const Customizations = () => {
size="small" size="small"
color="secondary" color="secondary"
value={getMaskString(selectedFilters)} value={getMaskString(selectedFilters)}
onChange={(event, mask: string[]) => { onChange={(_, mask: string[]) => {
setSelectedFilters(getMaskNumber(mask)); setSelectedFilters(getMaskNumber(mask));
}} }}
> >
@@ -594,13 +642,13 @@ const Customizations = () => {
</Grid> </Grid>
<Grid> <Grid>
<Typography variant="subtitle2" color="grey"> <Typography variant="subtitle2" color="grey">
{LL.SHOWING()}&nbsp;{shown_data.length}/{deviceEntities.length} {LL.SHOWING()}&nbsp;{filteredEntities.length}/{deviceEntities.length}
&nbsp;{LL.ENTITIES(deviceEntities.length)} &nbsp;{LL.ENTITIES(deviceEntities.length)}
</Typography> </Typography>
</Grid> </Grid>
</Grid> </Grid>
<Table <Table
data={{ nodes: shown_data }} data={{ nodes: filteredEntities }}
theme={entities_theme} theme={entities_theme}
layout={{ custom: true }} layout={{ custom: true }}
> >
@@ -622,14 +670,27 @@ const Customizations = () => {
<EntityMaskToggle onUpdate={updateDeviceEntity} de={de} /> <EntityMaskToggle onUpdate={updateDeviceEntity} de={de} />
</Cell> </Cell>
<Cell> <Cell>
{formatName(de, false)}&nbsp;( <span
<Link style={{
target="_blank" color:
href={APIURL + selectedDeviceTypeNameURL + '/' + de.id} de.v === undefined && !isCommand(de) ? 'grey' : 'inherit'
}}
> >
{de.id} {formatName(de, false)}&nbsp;(
</Link> <Link
) style={{
color:
de.v === undefined && !isCommand(de)
? 'grey'
: 'primary'
}}
target="_blank"
href={APIURL + selectedDeviceTypeNameURL + '/' + de.id}
>
{de.id}
</Link>
)
</span>
</Cell> </Cell>
<Cell> <Cell>
{!(de.m & DeviceEntityMask.DV_READONLY) && formatValue(de.mi)} {!(de.m & DeviceEntityMask.DV_READONLY) && formatValue(de.mi)}
@@ -654,7 +715,7 @@ const Customizations = () => {
open={confirmReset} open={confirmReset}
onClose={() => setConfirmReset(false)} onClose={() => setConfirmReset(false)}
> >
<DialogTitle>{LL.RESET(1)}</DialogTitle> <DialogTitle>{LL.REMOVE_ALL()}</DialogTitle>
<DialogContent dividers>{LL.CUSTOMIZATIONS_RESET()}</DialogContent> <DialogContent dividers>{LL.CUSTOMIZATIONS_RESET()}</DialogContent>
<DialogActions> <DialogActions>
<Button <Button
@@ -671,7 +732,7 @@ const Customizations = () => {
onClick={resetCustomization} onClick={resetCustomization}
color="error" color="error"
> >
{LL.RESET(0)} {LL.REMOVE_ALL()}
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
@@ -682,8 +743,9 @@ const Customizations = () => {
{devices && renderDeviceList()} {devices && renderDeviceList()}
{selectedDevice !== -1 && !rename && renderDeviceData()} {selectedDevice !== -1 && !rename && renderDeviceData()}
{restartNeeded ? ( {restartNeeded ? (
<MessageBox my={2} level="warning" message={LL.RESTART_TEXT(0)}> <MessageBox level="warning" message={LL.RESTART_TEXT(0)}>
<Button <Button
sx={{ ml: 2 }}
startIcon={<PowerSettingsNewIcon />} startIcon={<PowerSettingsNewIcon />}
variant="contained" variant="contained"
color="error" color="error"
@@ -701,7 +763,11 @@ const Customizations = () => {
startIcon={<CancelIcon />} startIcon={<CancelIcon />}
variant="outlined" variant="outlined"
color="secondary" color="secondary"
onClick={() => devices && sendDeviceEntities(selectedDevice)} onClick={() => {
if (devices) {
void sendDeviceEntities(selectedDevice);
}
}}
> >
{LL.CANCEL()} {LL.CANCEL()}
</Button> </Button>
@@ -716,18 +782,6 @@ const Customizations = () => {
</ButtonRow> </ButtonRow>
)} )}
</Box> </Box>
{!rename && (
<ButtonRow mt={1}>
<Button
startIcon={<SettingsBackupRestoreIcon />}
variant="outlined"
color="error"
onClick={() => setConfirmReset(true)}
>
{LL.RESET(0)}
</Button>
</ButtonRow>
)}
</Box> </Box>
)} )}
{renderResetDialog()} {renderResetDialog()}

View File

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

View File

@@ -1,4 +1,4 @@
import { useContext, useEffect, useState } from 'react'; import { memo, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { IconContext } from 'react-icons/lib'; import { IconContext } from 'react-icons/lib';
import { Link } from 'react-router'; import { Link } from 'react-router';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
@@ -14,6 +14,7 @@ import {
IconButton, IconButton,
ToggleButton, ToggleButton,
ToggleButtonGroup, ToggleButtonGroup,
Tooltip,
Typography Typography
} from '@mui/material'; } from '@mui/material';
@@ -44,7 +45,7 @@ import {
} from './types'; } from './types';
import { deviceValueItemValidation } from './validators'; import { deviceValueItemValidation } from './validators';
const Dashboard = () => { const Dashboard = memo(() => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const { me } = useContext(AuthenticatedContext); const { me } = useContext(AuthenticatedContext);
@@ -76,35 +77,40 @@ const Dashboard = () => {
} }
); );
const deviceValueDialogSave = async (devicevalue: DeviceValue) => { const deviceValueDialogSave = useCallback(
if (!selectedDashboardItem) { async (devicevalue: DeviceValue) => {
return; if (!selectedDashboardItem) {
} return;
const id = selectedDashboardItem.parentNode.id; // this is the parent ID }
await sendDeviceValue({ id, c: devicevalue.c ?? '', v: devicevalue.v }) const id = selectedDashboardItem.parentNode.id; // this is the parent ID
.then(() => { await sendDeviceValue({ id, c: devicevalue.c ?? '', v: devicevalue.v })
toast.success(LL.WRITE_CMD_SENT()); .then(() => {
}) toast.success(LL.WRITE_CMD_SENT());
.catch((error: Error) => { })
toast.error(error.message); .catch((error: Error) => {
}) toast.error(error.message);
.finally(() => { })
setDeviceValueDialogOpen(false); .finally(() => {
setSelectedDashboardItem(undefined); setDeviceValueDialogOpen(false);
}); setSelectedDashboardItem(undefined);
}; });
},
[selectedDashboardItem, sendDeviceValue, LL]
);
const dashboard_theme = useTheme({ const dashboard_theme = useMemo(
Table: ` () =>
useTheme({
Table: `
--data-table-library_grid-template-columns: minmax(80px, auto) 120px 32px; --data-table-library_grid-template-columns: minmax(80px, auto) 120px 32px;
`, `,
BaseRow: ` BaseRow: `
font-size: 14px; font-size: 14px;
.td { .td {
height: 28px; height: 28px;
} }
`, `,
Row: ` Row: `
cursor: pointer; cursor: pointer;
background-color: #1e1e1e; background-color: #1e1e1e;
&:nth-of-type(odd) .td { &:nth-of-type(odd) .td {
@@ -114,7 +120,7 @@ const Dashboard = () => {
background-color: #177ac9; background-color: #177ac9;
}, },
`, `,
BaseCell: ` BaseCell: `
&:nth-of-type(2) { &:nth-of-type(2) {
text-align: right; text-align: right;
} }
@@ -122,12 +128,14 @@ const Dashboard = () => {
text-align: right; text-align: right;
} }
` `
}); }),
[]
);
const tree = useTree( const tree = useTree(
{ nodes: data.nodes }, { nodes: [...data.nodes] },
{ {
onChange: undefined // not used but needed onChange: () => {} // not used but needed
}, },
{ {
treeIcon: { treeIcon: {
@@ -156,65 +164,82 @@ const Dashboard = () => {
} }
}); });
const nodeIds = useMemo(
() => data.nodes.map((item: DashboardItem) => item.id),
[data.nodes]
);
useEffect(() => { useEffect(() => {
showAll showAll
? tree.fns.onAddAll(data.nodes.map((item: DashboardItem) => item.id)) // expand tree ? tree.fns.onAddAll(nodeIds) // expand tree
: tree.fns.onRemoveAll(); // collapse tree : tree.fns.onRemoveAll(); // collapse tree
}, [parentNodes]); }, [parentNodes]);
const showType = (n?: string, t?: number) => { const showType = useCallback(
// if we have a name show it (n?: string, t?: number) => {
if (n) { // if we have a name show it
return n; 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.SCHEDULER:
return LL.SCHEDULER();
default:
break;
} }
} if (t) {
return ''; // otherwise pick translation based on type
}; switch (t) {
case DeviceType.CUSTOM:
const showName = (di: DashboardItem) => { return LL.CUSTOM_ENTITIES(0);
if (di.id < 100) { case DeviceType.ANALOGSENSOR:
// if its a device (parent node) and has entities return LL.ANALOG_SENSORS();
if (di.nodes?.length) { case DeviceType.TEMPERATURESENSOR:
return ( return LL.TEMP_SENSORS();
<span style={{ fontWeight: 'bold', fontSize: '14px' }}> case DeviceType.SCHEDULER:
<DeviceIcon type_id={di.t ?? 0} /> return LL.SCHEDULER();
&nbsp;&nbsp;{showType(di.n, di.t)} default:
<span style={{ color: 'lightblue' }}>&nbsp;({di.nodes?.length})</span> break;
</span> }
);
} }
} return '';
if (di.dv) { },
return <span>{di.dv.id.slice(2)}</span>; [LL]
} );
};
const hasMask = (id: string, mask: number) => const showName = useCallback(
(parseInt(id.slice(0, 2), 16) & mask) === mask; (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) => { const hasMask = useCallback(
if (me.admin && di.dv?.c) { (id: string, mask: number) => (parseInt(id.slice(0, 2), 16) & mask) === mask,
setSelectedDashboardItem(di); []
setDeviceValueDialogOpen(true); );
}
}; const editDashboardValue = useCallback(
(di: DashboardItem) => {
if (me.admin && di.dv?.c) {
setSelectedDashboardItem(di);
setDeviceValueDialogOpen(true);
}
},
[me.admin]
);
const handleShowAll = ( const handleShowAll = (
event: React.MouseEvent<HTMLElement>, _event: React.MouseEvent<HTMLElement>,
toggle: boolean | null toggle: boolean | null
) => { ) => {
if (toggle !== null) { if (toggle !== null) {
@@ -223,19 +248,22 @@ const Dashboard = () => {
} }
}; };
const hasFavEntities = useMemo(
() => data.nodes.filter((item: DashboardItem) => item.id <= 90).length,
[data.nodes]
);
const renderContent = () => { const renderContent = () => {
if (!data) { if (!data) {
return <FormLoader onRetry={fetchDashboard} errorMessage={error?.message} />; return (
<FormLoader onRetry={fetchDashboard} errorMessage={error?.message || ''} />
);
} }
const hasFavEntities = data.nodes.filter(
(item: DashboardItem) => item.id <= 90
).length;
return ( return (
<> <>
{!data.connected && ( {!data.connected && (
<MessageBox mb={2} level="error" message={LL.EMS_BUS_WARNING()} /> <MessageBox level="error" message={LL.EMS_BUS_WARNING()} />
)} )}
{data.connected && data.nodes.length > 0 && !hasFavEntities && ( {data.connected && data.nodes.length > 0 && !hasFavEntities && (
@@ -255,105 +283,121 @@ const Dashboard = () => {
</MessageBox> </MessageBox>
)} )}
{data.nodes.length > 0 && ( <Box
<> display="flex"
<ToggleButtonGroup justifyContent="flex-end"
color="primary" flexWrap="nowrap"
size="small" whiteSpace="nowrap"
value={showAll} >
exclusive <ToggleButtonGroup
onChange={handleShowAll} size="small"
> color="primary"
<ButtonTooltip title={LL.ALLVALUES()} arrow> value={showAll}
<ToggleButton value={true}> exclusive
<UnfoldMoreIcon sx={{ fontSize: 18 }} /> onChange={handleShowAll}
</ToggleButton> >
</ButtonTooltip> <ButtonTooltip title={LL.ALLVALUES()}>
<ButtonTooltip title={LL.COMPACT()} arrow> <ToggleButton value={true}>
<ToggleButton value={false}> <UnfoldMoreIcon sx={{ fontSize: 18 }} />
<UnfoldLessIcon sx={{ fontSize: 18 }} /> </ToggleButton>
</ToggleButton>
</ButtonTooltip>
</ToggleButtonGroup>
<ButtonTooltip title={LL.DASHBOARD_1()} arrow>
<HelpOutlineIcon color="primary" sx={{ ml: 1, fontSize: 20 }} />
</ButtonTooltip> </ButtonTooltip>
<ButtonTooltip title={LL.COMPACT()}>
<ToggleButton value={false}>
<UnfoldLessIcon sx={{ fontSize: 18 }} />
</ToggleButton>
</ButtonTooltip>
</ToggleButtonGroup>
</Box>
<Box {data.nodes.length > 0 ? (
padding={1} <Box mt={1} justifyContent="center" flexDirection="column">
justifyContent="center" <IconContext.Provider
flexDirection="column" value={{
sx={{ color: 'lightblue',
borderRadius: 1, size: '18',
border: '1px solid grey' style: { verticalAlign: 'middle' }
}} }}
> >
<IconContext.Provider <Table
value={{ data={{ nodes: data.nodes }}
color: 'lightblue', theme={dashboard_theme}
size: '18', layout={{ custom: true }}
style: { verticalAlign: 'middle' } tree={tree}
}}
> >
<Table {(tableList: DashboardItem[]) => (
data={{ nodes: data.nodes }} <Body>
theme={dashboard_theme} {tableList.map((di: DashboardItem) => (
layout={{ custom: true }} <Row
tree={tree} key={di.id}
> item={di}
{(tableList: DashboardItem[]) => ( onClick={() => editDashboardValue(di)}
<Body> >
{tableList.map((di: DashboardItem) => ( {di.id > 99 ? (
<Row <>
key={di.id} <Cell>{showName(di)}</Cell>
item={di} <Cell>
onClick={() => editDashboardValue(di)} <ButtonTooltip
> title={formatValue(LL, di.dv?.v, di.dv?.u)}
{di.id > 99 ? ( >
<> <span>{formatValue(LL, di.dv?.v, di.dv?.u)}</span>
<Cell>{showName(di)}</Cell> </ButtonTooltip>
<Cell> </Cell>
<ButtonTooltip
title={formatValue(LL, di.dv?.v, di.dv?.u)}
>
<span>{formatValue(LL, di.dv?.v, di.dv?.u)}</span>
</ButtonTooltip>
</Cell>
<Cell> <Cell>
{me.admin && {me.admin &&
di.dv?.c && di.dv?.c &&
!hasMask( !hasMask(di.dv.id, DeviceEntityMask.DV_READONLY) && (
di.dv.id, <IconButton
DeviceEntityMask.DV_READONLY size="small"
) && ( aria-label={
<IconButton LL.CHANGE_VALUE() + ' ' + LL.VALUE(0)
size="small" }
onClick={() => editDashboardValue(di)} onClick={() => editDashboardValue(di)}
> >
<EditIcon <EditIcon
color="primary" color="primary"
sx={{ fontSize: 16 }} sx={{ fontSize: 16 }}
/> />
</IconButton> </IconButton>
)} )}
</Cell> </Cell>
</> </>
) : ( ) : (
<> <>
<CellTree item={di}>{showName(di)}</CellTree> <CellTree item={di}>{showName(di)}</CellTree>
<Cell /> <Cell />
<Cell /> <Cell />
</> </>
)} )}
</Row> </Row>
))} ))}
</Body> </Body>
)} )}
</Table> </Table>
</IconContext.Provider> </IconContext.Provider>
</Box> </Box>
</> ) : (
<Box
display="flex"
// justifyContent="flex-end"
// flexWrap="nowrap"
// whiteSpace="nowrap"
>
<Typography mt={1} color="warning.main" variant="body1">
no data
</Typography>
<Tooltip title={LL.DASHBOARD_1()}>
<HelpOutlineIcon
sx={{
ml: 1,
mt: 1,
fontSize: 20,
verticalAlign: 'middle'
}}
color="primary"
/>
</Tooltip>
</Box>
)} )}
</> </>
); );
@@ -375,6 +419,6 @@ const Dashboard = () => {
)} )}
</SectionContent> </SectionContent>
); );
}; });
export default Dashboard; export default Dashboard;

View File

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

View File

@@ -1,8 +1,10 @@
import { import {
memo,
useCallback, useCallback,
useContext, useContext,
useEffect, useEffect,
useLayoutEffect, useLayoutEffect,
useMemo,
useState useState
} from 'react'; } from 'react';
import { IconContext } from 'react-icons'; import { IconContext } from 'react-icons';
@@ -31,7 +33,7 @@ import {
DialogActions, DialogActions,
DialogContent, DialogContent,
DialogTitle, DialogTitle,
Grid2 as Grid, Grid,
IconButton, IconButton,
InputAdornment, InputAdornment,
List, List,
@@ -75,7 +77,7 @@ import { DeviceEntityMask, DeviceType, DeviceValueUOM_s } from './types';
import type { Device, DeviceValue } from './types'; import type { Device, DeviceValue } from './types';
import { deviceValueItemValidation } from './validators'; import { deviceValueItemValidation } from './validators';
const Devices = () => { const Devices = memo(() => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const { me } = useContext(AuthenticatedContext); const { me } = useContext(AuthenticatedContext);
@@ -91,7 +93,7 @@ const Devices = () => {
useLayoutTitle(LL.DEVICES()); useLayoutTitle(LL.DEVICES());
const { data: coreData, send: sendCoreData } = useRequest(() => readCoreData(), { const { data: coreData, send: sendCoreData } = useRequest(readCoreData, {
initialData: { initialData: {
connected: true, connected: true,
devices: [] devices: []
@@ -116,36 +118,36 @@ const Devices = () => {
); );
useLayoutEffect(() => { useLayoutEffect(() => {
function updateSize() { let raf = 0;
setSize([window.innerWidth, window.innerHeight]); const updateSize = () => {
} cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => {
setSize([window.innerWidth, window.innerHeight]);
});
};
window.addEventListener('resize', updateSize); window.addEventListener('resize', updateSize);
updateSize(); updateSize();
return () => window.removeEventListener('resize', updateSize); return () => {
window.removeEventListener('resize', updateSize);
cancelAnimationFrame(raf);
};
}, []); }, []);
const leftOffset = () => { const leftOffset = useCallback(() => {
const devicesWindow = document.getElementById('devices-window'); const devicesWindow = document.getElementById('devices-window');
if (!devicesWindow) { if (!devicesWindow) return 0;
return 0; const { left, right } = devicesWindow.getBoundingClientRect();
} if (!left || !right) return 0;
const clientRect = devicesWindow.getBoundingClientRect();
const left = clientRect.left;
const right = clientRect.right;
if (!left || !right) {
return 0;
}
return left + (right - left < 400 ? 0 : 200); return left + (right - left < 400 ? 0 : 200);
}; }, []);
const common_theme = useTheme({ const common_theme = useMemo(
BaseRow: ` () =>
useTheme({
BaseRow: `
font-size: 14px; font-size: 14px;
`, `,
HeaderRow: ` HeaderRow: `
text-transform: uppercase; text-transform: uppercase;
background-color: black; background-color: black;
color: #90CAF9; color: #90CAF9;
@@ -153,7 +155,7 @@ const Devices = () => {
border-bottom: 1px solid #565656; border-bottom: 1px solid #565656;
} }
`, `,
Row: ` Row: `
cursor: pointer; cursor: pointer;
background-color: #1E1E1E; background-color: #1E1E1E;
.td { .td {
@@ -163,30 +165,47 @@ const Devices = () => {
background-color: #177ac9; background-color: #177ac9;
} }
` `
}); }),
[]
);
const device_theme = useTheme([ const device_theme = useMemo(
common_theme, () =>
{ useTheme([
Table: ` common_theme,
{
BaseRow: `
font-size: 15px;
.td {
height: 28px;
}
`,
Table: `
--data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) 130px; --data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) 130px;
`, `,
HeaderRow: ` HeaderRow: `
.th { .th {
padding: 8px; padding: 8px;
`, `,
Row: ` Row: `
font-weight: bold; &:nth-of-type(odd) .td {
background-color: #303030;
},
&:hover .td { &:hover .td {
background-color: #177ac9; background-color: #177ac9;
},
` `
} }
]); ]),
[common_theme]
);
const data_theme = useTheme([ const data_theme = useMemo(
common_theme, () =>
{ useTheme([
Table: ` common_theme,
{
Table: `
--data-table-library_grid-template-columns: minmax(200px, auto) minmax(150px, auto) 40px; --data-table-library_grid-template-columns: minmax(200px, auto) minmax(150px, auto) 40px;
height: auto; height: auto;
max-height: 100%; max-height: 100%;
@@ -195,12 +214,12 @@ const Devices = () => {
display:none; display:none;
} }
`, `,
BaseRow: ` BaseRow: `
.td { .td {
height: 32px; height: 32px;
} }
`, `,
BaseCell: ` BaseCell: `
&:nth-of-type(1) { &:nth-of-type(1) {
border-left: 1px solid #177ac9; border-left: 1px solid #177ac9;
}, },
@@ -211,12 +230,12 @@ const Devices = () => {
border-right: 1px solid #177ac9; border-right: 1px solid #177ac9;
} }
`, `,
HeaderRow: ` HeaderRow: `
.th { .th {
border-top: 1px solid #565656; border-top: 1px solid #565656;
} }
`, `,
Row: ` Row: `
&:nth-of-type(odd) .td { &:nth-of-type(odd) .td {
background-color: #303030; background-color: #303030;
}, },
@@ -224,8 +243,10 @@ const Devices = () => {
background-color: #177ac9; background-color: #177ac9;
} }
` `
} }
]); ]),
[common_theme]
);
const getSortIcon = (state: State, sortKey: unknown) => { const getSortIcon = (state: State, sortKey: unknown) => {
if (state.sortKey === sortKey && state.reverse) { if (state.sortKey === sortKey && state.reverse) {
@@ -238,7 +259,7 @@ const Devices = () => {
}; };
const dv_sort = useSort( const dv_sort = useSort(
{ nodes: deviceData.nodes }, { nodes: [...deviceData.nodes] },
{}, {},
{ {
sortIcon: { sortIcon: {
@@ -268,7 +289,7 @@ const Devices = () => {
} }
const device_select = useRowSelect( const device_select = useRowSelect(
{ nodes: coreData.devices }, { nodes: [...coreData.devices] },
{ {
onChange: onSelectChange onChange: onSelectChange
} }
@@ -324,18 +345,23 @@ const Devices = () => {
return sc; return sc;
}; };
const hasMask = (id: string, mask: number) => const hasMask = useCallback(
(parseInt(id.slice(0, 2), 16) & mask) === mask; (id: string, mask: number) => (parseInt(id.slice(0, 2), 16) & mask) === mask,
[]
);
const handleDownloadCsv = () => { const handleDownloadCsv = () => {
const deviceIndex = coreData.devices.findIndex( const deviceIndex = coreData.devices.findIndex(
(d) => d.id === device_select.state.id (d: Device) => d.id === device_select.state.id
); );
if (deviceIndex === -1) { if (deviceIndex === -1) {
return; return;
} }
const filename = const selectedDevice = coreData.devices[deviceIndex];
coreData.devices[deviceIndex].tn + '_' + coreData.devices[deviceIndex].n; if (!selectedDevice) {
return;
}
const filename = selectedDevice.tn + '_' + selectedDevice.n;
const columns = [ const columns = [
{ {
@@ -350,7 +376,7 @@ const Devices = () => {
{ {
accessor: (dv: DeviceValue) => accessor: (dv: DeviceValue) =>
dv.u !== undefined && DeviceValueUOM_s[dv.u] dv.u !== undefined && DeviceValueUOM_s[dv.u]
? DeviceValueUOM_s[dv.u].replace(/[^a-zA-Z0-9]/g, '') ? DeviceValueUOM_s[dv.u]?.replace(/[^a-zA-Z0-9]/g, '')
: '', : '',
name: 'UoM' name: 'UoM'
}, },
@@ -373,7 +399,9 @@ const Devices = () => {
]; ];
const data = onlyFav const data = onlyFav
? deviceData.nodes.filter((dv) => hasMask(dv.id, DeviceEntityMask.DV_FAVORITE)) ? deviceData.nodes.filter((dv: DeviceValue) =>
hasMask(dv.id, DeviceEntityMask.DV_FAVORITE)
)
: deviceData.nodes; : deviceData.nodes;
const csvData = data.reduce( const csvData = data.reduce(
@@ -433,10 +461,14 @@ const Devices = () => {
const renderDeviceDetails = () => { const renderDeviceDetails = () => {
if (showDeviceInfo) { if (showDeviceInfo) {
const deviceIndex = coreData.devices.findIndex( const deviceIndex = coreData.devices.findIndex(
(d) => d.id === device_select.state.id (d: Device) => d.id === device_select.state.id
); );
if (deviceIndex === -1) { if (deviceIndex === -1) {
return; return null;
}
const deviceDetails = coreData.devices[deviceIndex];
if (!deviceDetails) {
return null;
} }
return ( return (
@@ -449,47 +481,35 @@ const Devices = () => {
<DialogContent dividers> <DialogContent dividers>
<List dense={true}> <List dense={true}>
<ListItem> <ListItem>
<ListItemText <ListItemText primary={LL.TYPE(0)} secondary={deviceDetails.tn} />
primary={LL.TYPE(0)}
secondary={coreData.devices[deviceIndex].tn}
/>
</ListItem> </ListItem>
<ListItem> <ListItem>
<ListItemText <ListItemText primary={LL.NAME(0)} secondary={deviceDetails.n} />
primary={LL.NAME(0)}
secondary={coreData.devices[deviceIndex].n}
/>
</ListItem> </ListItem>
{coreData.devices[deviceIndex].t !== DeviceType.CUSTOM && ( {deviceDetails.t !== DeviceType.CUSTOM && (
<> <>
<ListItem> <ListItem>
<ListItemText <ListItemText primary={LL.BRAND()} secondary={deviceDetails.b} />
primary={LL.BRAND()}
secondary={coreData.devices[deviceIndex].b}
/>
</ListItem> </ListItem>
<ListItem> <ListItem>
<ListItemText <ListItemText
primary={LL.ID_OF(LL.DEVICE())} primary={LL.ID_OF(LL.DEVICE())}
secondary={ secondary={
'0x' + '0x' +
( ('00' + deviceDetails.d.toString(16).toUpperCase()).slice(-2)
'00' +
coreData.devices[deviceIndex].d.toString(16).toUpperCase()
).slice(-2)
} }
/> />
</ListItem> </ListItem>
<ListItem> <ListItem>
<ListItemText <ListItemText
primary={LL.ID_OF(LL.PRODUCT())} primary={LL.ID_OF(LL.PRODUCT())}
secondary={coreData.devices[deviceIndex].p} secondary={deviceDetails.p}
/> />
</ListItem> </ListItem>
<ListItem> <ListItem>
<ListItemText <ListItemText
primary={LL.VERSION()} primary={LL.VERSION()}
secondary={coreData.devices[deviceIndex].v} secondary={deviceDetails.v}
/> />
</ListItem> </ListItem>
</> </>
@@ -508,59 +528,60 @@ const Devices = () => {
</Dialog> </Dialog>
); );
} }
return null;
}; };
const renderCoreData = () => ( const renderCoreData = () => (
<> <>
<IconContext.Provider {!coreData.connected ? (
value={{ <MessageBox level="error" message={LL.EMS_BUS_WARNING()} />
color: 'lightblue', ) : (
size: '18', <Box justifyContent="center" flexDirection="column">
style: { verticalAlign: 'middle' } <IconContext.Provider
}} value={{
> color: 'lightblue',
{!coreData.connected && ( size: '18',
<MessageBox my={2} level="error" message={LL.EMS_BUS_WARNING()} /> style: { verticalAlign: 'middle' }
)} }}
{coreData.connected && (
<Table
data={{ nodes: coreData.devices }}
select={device_select}
theme={device_theme}
layout={{ custom: true }}
> >
{(tableList: Device[]) => ( <Table
<> data={{ nodes: [...coreData.devices] }}
<Header> select={device_select}
<HeaderRow> theme={device_theme}
<HeaderCell resize>{LL.DESCRIPTION()}</HeaderCell> layout={{ custom: true }}
<HeaderCell stiff>{LL.TYPE(0)}</HeaderCell> >
</HeaderRow> {(tableList: Device[]) => (
</Header> <>
<Body> <Header>
{tableList.length === 0 && ( <HeaderRow>
<CircularProgress sx={{ margin: 1 }} size={18} /> <HeaderCell resize>{LL.DESCRIPTION()}</HeaderCell>
)} <HeaderCell stiff>{LL.TYPE(0)}</HeaderCell>
{tableList.map((device: Device) => ( </HeaderRow>
<Row key={device.id} item={device}> </Header>
<Cell> <Body>
<DeviceIcon type_id={device.t} /> {tableList.length === 0 && (
&nbsp;&nbsp; <CircularProgress sx={{ margin: 1 }} size={18} />
{device.n} )}
<span style={{ color: 'lightblue' }}> {tableList.map((device: Device) => (
&nbsp;&nbsp;({device.e}) <Row key={device.id} item={device}>
</span> <Cell>
</Cell> <DeviceIcon type_id={device.t} />
<Cell stiff>{device.tn}</Cell> &nbsp;&nbsp;
</Row> {device.n}
))} <span style={{ color: 'lightblue' }}>
</Body> &nbsp;&nbsp;({device.e})
</> </span>
)} </Cell>
</Table> <Cell stiff>{device.tn}</Cell>
)} </Row>
</IconContext.Provider> ))}
</Body>
</>
)}
</Table>
</IconContext.Provider>
</Box>
)}
</> </>
); );
@@ -576,64 +597,77 @@ const Devices = () => {
return; return;
} }
const showDeviceValue = (dv: DeviceValue) => { const showDeviceValue = useCallback((dv: DeviceValue) => {
setSelectedDeviceValue(dv); setSelectedDeviceValue(dv);
setDeviceValueDialogOpen(true); setDeviceValueDialogOpen(true);
}; }, []);
const renderNameCell = (dv: DeviceValue) => ( const renderNameCell = useCallback(
<> (dv: DeviceValue) => (
{dv.id.slice(2)}&nbsp; <>
{hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) && ( {dv.id.slice(2)}&nbsp;
<StarIcon color="primary" sx={{ fontSize: 12 }} /> {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_READONLY) && (
)} <EditOffOutlinedIcon color="primary" sx={{ fontSize: 12 }} />
{hasMask(dv.id, DeviceEntityMask.DV_API_MQTT_EXCLUDE) && ( )}
<CommentsDisabledOutlinedIcon color="primary" sx={{ fontSize: 12 }} /> {hasMask(dv.id, DeviceEntityMask.DV_API_MQTT_EXCLUDE) && (
)} <CommentsDisabledOutlinedIcon color="primary" sx={{ fontSize: 12 }} />
</> )}
</>
),
[hasMask]
); );
const shown_data = onlyFav const shown_data = useMemo(() => {
? deviceData.nodes.filter( if (onlyFav) {
(dv) => return deviceData.nodes.filter(
(dv: DeviceValue) =>
hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) && hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) &&
dv.id.slice(2).includes(search) dv.id.slice(2).toLowerCase().includes(search.toLowerCase())
) );
: deviceData.nodes.filter((dv) => dv.id.slice(2).includes(search)); }
return deviceData.nodes.filter((dv: DeviceValue) =>
dv.id.slice(2).toLowerCase().includes(search.toLowerCase())
);
}, [deviceData.nodes, onlyFav, search]);
const deviceIndex = coreData.devices.findIndex( const deviceIndex = coreData.devices.findIndex(
(d) => d.id === device_select.state.id (d: Device) => d.id === device_select.state.id
); );
if (deviceIndex === -1) { if (deviceIndex === -1) {
return; return;
} }
const deviceInfo = coreData.devices[deviceIndex];
if (!deviceInfo) {
return;
}
const [, height] = size;
return ( return (
<Box <Box
sx={{ sx={{
backgroundColor: 'black', backgroundColor: 'black',
position: 'absolute', position: 'absolute',
left: () => leftOffset(), left: leftOffset,
right: 0, right: 0,
bottom: 0, bottom: 0,
top: 64, top: 64,
zIndex: 'modal', zIndex: 'modal',
maxHeight: () => size[1] - 126, maxHeight: () => (height || 0) - 126,
border: '1px solid #177ac9' border: '1px solid #177ac9'
}} }}
> >
<Box sx={{ p: 1 }}> <Box sx={{ p: 1 }}>
<Grid container justifyContent="space-between"> <Grid container justifyContent="space-between">
<Typography noWrap variant="subtitle1" color="warning.main"> <Typography noWrap variant="subtitle1" color="warning.main">
{coreData.devices[deviceIndex].n}&nbsp;( {deviceInfo.n}&nbsp;(
{coreData.devices[deviceIndex].tn}) {deviceInfo.tn})
</Typography> </Typography>
<Grid justifyContent="flex-end"> <Grid justifyContent="flex-end">
<ButtonTooltip title={LL.CLOSE()}> <ButtonTooltip title={LL.CLOSE()}>
<IconButton onClick={resetDeviceSelect}> <IconButton onClick={resetDeviceSelect} aria-label={LL.CLOSE()}>
<HighlightOffIcon color="primary" sx={{ fontSize: 18 }} /> <HighlightOffIcon color="primary" sx={{ fontSize: 18 }} />
</IconButton> </IconButton>
</ButtonTooltip> </ButtonTooltip>
@@ -645,6 +679,7 @@ const Devices = () => {
variant="outlined" variant="outlined"
sx={{ width: '22ch' }} sx={{ width: '22ch' }}
placeholder={LL.SEARCH()} placeholder={LL.SEARCH()}
aria-label={LL.SEARCH()}
onChange={(event) => { onChange={(event) => {
setSearch(event.target.value); setSearch(event.target.value);
}} }}
@@ -659,19 +694,22 @@ const Devices = () => {
}} }}
/> />
<ButtonTooltip title={LL.DEVICE_DETAILS()}> <ButtonTooltip title={LL.DEVICE_DETAILS()}>
<IconButton onClick={() => setShowDeviceInfo(true)}> <IconButton
onClick={() => setShowDeviceInfo(true)}
aria-label={LL.DEVICE_DETAILS()}
>
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} /> <InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
</IconButton> </IconButton>
</ButtonTooltip> </ButtonTooltip>
{me.admin && ( {me.admin && (
<ButtonTooltip title={LL.CUSTOMIZATIONS()}> <ButtonTooltip title={LL.CUSTOMIZATIONS()}>
<IconButton onClick={customize}> <IconButton onClick={customize} aria-label={LL.CUSTOMIZATIONS()}>
<ConstructionIcon color="primary" sx={{ fontSize: 18 }} /> <ConstructionIcon color="primary" sx={{ fontSize: 18 }} />
</IconButton> </IconButton>
</ButtonTooltip> </ButtonTooltip>
)} )}
<ButtonTooltip title={LL.EXPORT()}> <ButtonTooltip title={LL.EXPORT()}>
<IconButton onClick={handleDownloadCsv}> <IconButton onClick={handleDownloadCsv} aria-label={LL.EXPORT()}>
<DownloadIcon color="primary" sx={{ fontSize: 18 }} /> <DownloadIcon color="primary" sx={{ fontSize: 18 }} />
</IconButton> </IconButton>
</ButtonTooltip> </ButtonTooltip>
@@ -699,14 +737,14 @@ const Devices = () => {
' ' + ' ' +
shown_data.length + shown_data.length +
'/' + '/' +
coreData.devices[deviceIndex].e + deviceInfo.e +
' ' + ' ' +
LL.ENTITIES(shown_data.length)} LL.ENTITIES(shown_data.length)}
</span> </span>
</Box> </Box>
<Table <Table
data={{ nodes: shown_data }} data={{ nodes: Array.from(shown_data) }}
theme={data_theme} theme={data_theme}
sort={dv_sort} sort={dv_sort}
layout={{ custom: true, fixedHeader: true }} layout={{ custom: true, fixedHeader: true }}
@@ -790,6 +828,6 @@ const Devices = () => {
)} )}
</SectionContent> </SectionContent>
); );
}; });
export default Devices; export default Devices;

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

View File

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

View File

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

View File

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

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 CancelIcon from '@mui/icons-material/Cancel';
import DoneIcon from '@mui/icons-material/Done'; import DoneIcon from '@mui/icons-material/Done';
@@ -10,7 +10,7 @@ import {
DialogActions, DialogActions,
DialogContent, DialogContent,
DialogTitle, DialogTitle,
Grid2 as Grid, Grid,
TextField TextField
} from '@mui/material'; } from '@mui/material';
@@ -37,25 +37,35 @@ const ModulesDialog = ({
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const [editItem, setEditItem] = useState<ModuleItem>(selectedItem); const [editItem, setEditItem] = useState<ModuleItem>(selectedItem);
const updateFormValue = updateValue(setEditItem); 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
useEffect(() => { useEffect(() => {
if (open) { if (open) {
setEditItem(selectedItem); setEditItem(selectedItem);
} }
}, [open, selectedItem]); }, [open, selectedItem]);
const close = () => { const handleSave = useCallback(() => {
onClose();
};
const save = () => {
onSave(editItem); onSave(editItem);
}; }, [editItem, onSave]);
const dialogTitle = useMemo(
() => `${LL.EDIT()} ${editItem.key}`,
[LL, editItem.key]
);
return ( return (
<Dialog sx={dialogStyle} fullWidth maxWidth="xs" open={open} onClose={onClose}> <Dialog sx={dialogStyle} fullWidth maxWidth="xs" open={open} onClose={onClose}>
<DialogTitle>{LL.EDIT() + ' ' + editItem.key}</DialogTitle> <DialogTitle>{dialogTitle}</DialogTitle>
<DialogContent dividers> <DialogContent dividers>
<Grid container> <Grid container>
<BlockFormControlLabel <BlockFormControlLabel
@@ -85,7 +95,7 @@ const ModulesDialog = ({
<Button <Button
startIcon={<CancelIcon />} startIcon={<CancelIcon />}
variant="outlined" variant="outlined"
onClick={close} onClick={onClose}
color="secondary" color="secondary"
> >
{LL.CANCEL()} {LL.CANCEL()}
@@ -93,7 +103,7 @@ const ModulesDialog = ({
<Button <Button
startIcon={<DoneIcon />} startIcon={<DoneIcon />}
variant="outlined" variant="outlined"
onClick={save} onClick={handleSave}
color="primary" color="primary"
> >
{LL.UPDATE()} {LL.UPDATE()}

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
@@ -9,7 +9,7 @@ import {
Button, Button,
Checkbox, Checkbox,
Divider, Divider,
Grid2 as Grid, Grid,
InputAdornment, InputAdornment,
MenuItem, MenuItem,
TextField, TextField,
@@ -37,13 +37,13 @@ import { validate } from 'validators';
import { API, getBoardProfile, readSettings, writeSettings } from '../../api/app'; import { API, getBoardProfile, readSettings, writeSettings } from '../../api/app';
import { BOARD_PROFILES } from '../main/types'; import { BOARD_PROFILES } from '../main/types';
import type { APIcall, Settings } from '../main/types'; import type { APIcall, BoardProfileKey, Settings } from '../main/types';
import { createSettingsValidator } from '../main/validators'; import { createSettingsValidator } from '../main/validators';
export function boardProfileSelectItems() { export function boardProfileSelectItems() {
return Object.keys(BOARD_PROFILES).map((code) => ( return Object.keys(BOARD_PROFILES).map((code) => (
<MenuItem key={code} value={code}> <MenuItem key={code} value={code}>
{BOARD_PROFILES[code]} {BOARD_PROFILES[code as BoardProfileKey]}
</MenuItem> </MenuItem>
)); ));
} }
@@ -72,10 +72,10 @@ const ApplicationSettings = () => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const updateFormValue = updateValueDirty( const updateFormValue = updateValueDirty(
origData, origData as unknown as Record<string, unknown>,
dirtyFlags, dirtyFlags,
setDirtyFlags, setDirtyFlags,
updateDataValue updateDataValue as (value: unknown) => void
); );
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>(); const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
@@ -106,50 +106,61 @@ const ApplicationSettings = () => {
}); });
}); });
const doRestart = async () => { // Memoized input props to prevent recreation on every render
const SecondsInputProps = useMemo(
() => ({
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
}),
[LL]
);
const MinutesInputProps = useMemo(
() => ({
endAdornment: <InputAdornment position="end">{LL.MINUTES()}</InputAdornment>
}),
[LL]
);
const HoursInputProps = useMemo(
() => ({
endAdornment: <InputAdornment position="end">{LL.HOURS()}</InputAdornment>
}),
[LL]
);
const doRestart = useCallback(async () => {
setRestarting(true); setRestarting(true);
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch( await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
(error: Error) => { (error: Error) => {
toast.error(error.message); toast.error(error.message);
} }
); );
}; }, [sendAPI]);
const updateBoardProfile = async (board_profile: string) => { const updateBoardProfile = useCallback(
await readBoardProfile(board_profile).catch((error: Error) => { async (board_profile: string) => {
toast.error(error.message); await readBoardProfile(board_profile).catch((error: Error) => {
}); toast.error(error.message);
}; });
},
[readBoardProfile]
);
useLayoutTitle(LL.APPLICATION()); useLayoutTitle(LL.APPLICATION());
const SecondsInputProps = { const validateAndSubmit = useCallback(async () => {
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment> try {
}; setFieldErrors(undefined);
const MinutesInputProps = { await validate(createSettingsValidator(data), data);
endAdornment: <InputAdornment position="end">{LL.MINUTES()}</InputAdornment> } catch (error) {
}; setFieldErrors(error as ValidateFieldsError);
const HoursInputProps = { } finally {
endAdornment: <InputAdornment position="end">{LL.HOURS()}</InputAdornment> await saveData();
};
const content = () => {
if (!data || !hardwareData) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage} />;
} }
}, [data, saveData]);
const validateAndSubmit = async () => { const changeBoardProfile = useCallback(
try { (event: React.ChangeEvent<HTMLInputElement>) => {
setFieldErrors(undefined);
await validate(createSettingsValidator(data), data);
} catch (error) {
setFieldErrors(error as ValidateFieldsError);
} finally {
await saveData();
}
};
const changeBoardProfile = (event: React.ChangeEvent<HTMLInputElement>) => {
const boardProfile = event.target.value; const boardProfile = event.target.value;
updateFormValue(event); updateFormValue(event);
if (boardProfile === 'CUSTOM') { if (boardProfile === 'CUSTOM') {
@@ -160,12 +171,22 @@ const ApplicationSettings = () => {
} else { } else {
void updateBoardProfile(boardProfile); void updateBoardProfile(boardProfile);
} }
}; },
[data, updateBoardProfile, updateFormValue, updateDataValue]
);
const restart = async () => { const restart = useCallback(async () => {
await validateAndSubmit(); await validateAndSubmit();
await doRestart(); await doRestart();
}; }, [validateAndSubmit, doRestart]);
// Memoize board profile select items to prevent recreation
const boardProfileItems = useMemo(() => boardProfileSelectItems(), []);
const content = () => {
if (!data || !hardwareData) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
}
return ( return (
<> <>
@@ -219,7 +240,7 @@ const ApplicationSettings = () => {
<Grid container spacing={2} rowSpacing={0}> <Grid container spacing={2} rowSpacing={0}>
<Grid> <Grid>
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors} fieldErrors={fieldErrors || {}}
name="modbus_max_clients" name="modbus_max_clients"
label={LL.AP_MAX_CLIENTS()} label={LL.AP_MAX_CLIENTS()}
variant="outlined" variant="outlined"
@@ -231,7 +252,7 @@ const ApplicationSettings = () => {
</Grid> </Grid>
<Grid> <Grid>
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors} fieldErrors={fieldErrors || {}}
name="modbus_port" name="modbus_port"
label="Port" label="Port"
variant="outlined" variant="outlined"
@@ -243,7 +264,7 @@ const ApplicationSettings = () => {
</Grid> </Grid>
<Grid> <Grid>
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors} fieldErrors={fieldErrors || {}}
name="modbus_timeout" name="modbus_timeout"
label="Timeout" label="Timeout"
slotProps={{ slotProps={{
@@ -273,7 +294,7 @@ const ApplicationSettings = () => {
<Grid container spacing={2} rowSpacing={0}> <Grid container spacing={2} rowSpacing={0}>
<Grid> <Grid>
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors} fieldErrors={fieldErrors || {}}
name="syslog_host" name="syslog_host"
label="Host" label="Host"
variant="outlined" variant="outlined"
@@ -284,7 +305,7 @@ const ApplicationSettings = () => {
</Grid> </Grid>
<Grid> <Grid>
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors} fieldErrors={fieldErrors || {}}
name="syslog_port" name="syslog_port"
label="Port" label="Port"
variant="outlined" variant="outlined"
@@ -307,15 +328,15 @@ const ApplicationSettings = () => {
> >
<MenuItem value={-1}>OFF</MenuItem> <MenuItem value={-1}>OFF</MenuItem>
<MenuItem value={3}>ERR</MenuItem> <MenuItem value={3}>ERR</MenuItem>
<MenuItem value={4}>WARN</MenuItem>
<MenuItem value={5}>NOTICE</MenuItem> <MenuItem value={5}>NOTICE</MenuItem>
<MenuItem value={6}>INFO</MenuItem> <MenuItem value={6}>INFO</MenuItem>
<MenuItem value={7}>DEBUG</MenuItem>
<MenuItem value={9}>ALL</MenuItem> <MenuItem value={9}>ALL</MenuItem>
</TextField> </TextField>
</Grid> </Grid>
<Grid> <Grid>
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors} fieldErrors={fieldErrors || {}}
name="syslog_mark_interval" name="syslog_mark_interval"
label={LL.MARK_INTERVAL()} label={LL.MARK_INTERVAL()}
slotProps={{ slotProps={{
@@ -474,7 +495,7 @@ const ApplicationSettings = () => {
margin="normal" margin="normal"
select select
> >
{boardProfileSelectItems()} {boardProfileItems}
<Divider /> <Divider />
<MenuItem key={'CUSTOM'} value={'CUSTOM'}> <MenuItem key={'CUSTOM'} value={'CUSTOM'}>
{LL.CUSTOM()}&hellip; {LL.CUSTOM()}&hellip;
@@ -485,7 +506,7 @@ const ApplicationSettings = () => {
<Grid container spacing={2} rowSpacing={0}> <Grid container spacing={2} rowSpacing={0}>
<Grid> <Grid>
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors} fieldErrors={fieldErrors || {}}
name="rx_gpio" name="rx_gpio"
label={LL.GPIO_OF('Rx')} label={LL.GPIO_OF('Rx')}
fullWidth fullWidth
@@ -498,7 +519,7 @@ const ApplicationSettings = () => {
</Grid> </Grid>
<Grid> <Grid>
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors} fieldErrors={fieldErrors || {}}
name="tx_gpio" name="tx_gpio"
label={LL.GPIO_OF('Tx')} label={LL.GPIO_OF('Tx')}
fullWidth fullWidth
@@ -511,7 +532,7 @@ const ApplicationSettings = () => {
</Grid> </Grid>
<Grid> <Grid>
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors} fieldErrors={fieldErrors || {}}
name="pbutton_gpio" name="pbutton_gpio"
label={LL.GPIO_OF(LL.BUTTON())} label={LL.GPIO_OF(LL.BUTTON())}
fullWidth fullWidth
@@ -524,7 +545,7 @@ const ApplicationSettings = () => {
</Grid> </Grid>
<Grid> <Grid>
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors} fieldErrors={fieldErrors || {}}
name="dallas_gpio" name="dallas_gpio"
label={ label={
LL.GPIO_OF(LL.TEMPERATURE()) + ' (0=' + LL.DISABLED(1) + ')' LL.GPIO_OF(LL.TEMPERATURE()) + ' (0=' + LL.DISABLED(1) + ')'
@@ -539,7 +560,7 @@ const ApplicationSettings = () => {
</Grid> </Grid>
<Grid> <Grid>
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors} fieldErrors={fieldErrors || {}}
name="led_gpio" name="led_gpio"
label={LL.GPIO_OF('LED') + ' (0=' + LL.DISABLED(1) + ')'} label={LL.GPIO_OF('LED') + ' (0=' + LL.DISABLED(1) + ')'}
fullWidth fullWidth
@@ -554,7 +575,7 @@ const ApplicationSettings = () => {
<Grid> <Grid>
<TextField <TextField
name="led_type" name="led_type"
label={'LED ' + LL.TYPE()} label={'LED ' + LL.TYPE(0)}
value={data.led_type} value={data.led_type}
fullWidth fullWidth
variant="outlined" variant="outlined"
@@ -581,6 +602,7 @@ const ApplicationSettings = () => {
<MenuItem value={0}>{LL.DISABLED(1)}</MenuItem> <MenuItem value={0}>{LL.DISABLED(1)}</MenuItem>
<MenuItem value={1}>LAN8720</MenuItem> <MenuItem value={1}>LAN8720</MenuItem>
<MenuItem value={2}>TLK110</MenuItem> <MenuItem value={2}>TLK110</MenuItem>
<MenuItem value={3}>RTL8201</MenuItem>
</TextField> </TextField>
</Grid> </Grid>
</Grid> </Grid>
@@ -743,7 +765,7 @@ const ApplicationSettings = () => {
{data.remote_timeout_en && ( {data.remote_timeout_en && (
<Box mt={2}> <Box mt={2}>
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors} fieldErrors={fieldErrors || {}}
name="remote_timeout" name="remote_timeout"
label={LL.REMOTE_TIMEOUT()} label={LL.REMOTE_TIMEOUT()}
slotProps={{ slotProps={{
@@ -783,7 +805,7 @@ const ApplicationSettings = () => {
{data.shower_timer && ( {data.shower_timer && (
<Grid> <Grid>
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors} fieldErrors={fieldErrors || {}}
name="shower_min_duration" name="shower_min_duration"
label={LL.MIN_DURATION()} label={LL.MIN_DURATION()}
slotProps={{ slotProps={{
@@ -801,7 +823,7 @@ const ApplicationSettings = () => {
<> <>
<Grid> <Grid>
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors} fieldErrors={fieldErrors || {}}
name="shower_alert_trigger" name="shower_alert_trigger"
label={LL.TRIGGER_TIME()} label={LL.TRIGGER_TIME()}
slotProps={{ slotProps={{
@@ -817,7 +839,7 @@ const ApplicationSettings = () => {
</Grid> </Grid>
<Grid> <Grid>
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors} fieldErrors={fieldErrors || {}}
name="shower_alert_coldshot" name="shower_alert_coldshot"
label={LL.COLD_SHOT_DURATION()} label={LL.COLD_SHOT_DURATION()}
slotProps={{ slotProps={{
@@ -836,8 +858,9 @@ const ApplicationSettings = () => {
</Grid> </Grid>
{restartNeeded && ( {restartNeeded && (
<MessageBox my={2} level="warning" message={LL.RESTART_TEXT(0)}> <MessageBox level="warning" message={LL.RESTART_TEXT(0)}>
<Button <Button
sx={{ ml: 2 }}
startIcon={<PowerSettingsNewIcon />} startIcon={<PowerSettingsNewIcon />}
variant="contained" variant="contained"
color="error" color="error"

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import AccessTimeIcon from '@mui/icons-material/AccessTime'; import AccessTimeIcon from '@mui/icons-material/AccessTime';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
@@ -17,6 +17,7 @@ import {
DialogActions, DialogActions,
DialogContent, DialogContent,
DialogTitle, DialogTitle,
Divider,
List List
} from '@mui/material'; } from '@mui/material';
@@ -29,134 +30,159 @@ import { SectionContent, useLayoutTitle } from 'components';
import ListMenuItem from 'components/layout/ListMenuItem'; import ListMenuItem from 'components/layout/ListMenuItem';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import SystemMonitor from '../status/SystemMonitor';
const Settings = () => { const Settings = () => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
useLayoutTitle(LL.SETTINGS(0)); useLayoutTitle(LL.SETTINGS(0));
const [confirmFactoryReset, setConfirmFactoryReset] = useState<boolean>(false); const [confirmFactoryReset, setConfirmFactoryReset] = useState(false);
const [restarting, setRestarting] = useState<boolean>();
const { send: sendAPI } = useRequest((data: APIcall) => API(data), { const { send: sendAPI } = useRequest((data: APIcall) => API(data), {
immediate: false immediate: false
}); });
const doFormat = async () => { const doFormat = useCallback(async () => {
await sendAPI({ device: 'system', cmd: 'format', id: 0 }).then(() => { await sendAPI({ device: 'system', cmd: 'format', id: 0 }).then(() => {
setRestarting(true);
setConfirmFactoryReset(false); setConfirmFactoryReset(false);
}); });
}; }, [sendAPI]);
const renderFactoryResetDialog = () => ( const handleFactoryResetClose = useCallback(() => {
<Dialog setConfirmFactoryReset(false);
sx={dialogStyle} }, []);
open={confirmFactoryReset}
onClose={() => setConfirmFactoryReset(false)} const handleFactoryResetClick = useCallback(() => {
> setConfirmFactoryReset(true);
<DialogTitle>{LL.FACTORY_RESET()}</DialogTitle> }, []);
<DialogContent dividers>{LL.SYSTEM_FACTORY_TEXT_DIALOG()}</DialogContent>
<DialogActions> const content = useMemo(() => {
<Button return (
startIcon={<CancelIcon />} <>
variant="outlined" <List>
onClick={() => setConfirmFactoryReset(false)} <ListMenuItem
color="secondary" icon={TuneIcon}
bgcolor="#134ba2"
label={LL.APPLICATION()}
text={LL.APPLICATION_SETTINGS_1()}
to="application"
/>
<ListMenuItem
icon={SettingsEthernetIcon}
bgcolor="#40828f"
label={LL.NETWORK(0)}
text={LL.CONFIGURE(LL.SETTINGS_OF(LL.NETWORK(1)))}
to="network"
/>
<ListMenuItem
icon={SettingsInputAntennaIcon}
bgcolor="#5f9a5f"
label={LL.ACCESS_POINT(0)}
text={LL.CONFIGURE(LL.ACCESS_POINT(1))}
to="ap"
/>
<ListMenuItem
icon={AccessTimeIcon}
bgcolor="#c5572c"
label="NTP"
text={LL.CONFIGURE(LL.LOCAL_TIME(1))}
to="ntp"
/>
<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}
> >
{LL.CANCEL()} <DialogTitle>{LL.FACTORY_RESET()}</DialogTitle>
</Button> <DialogContent dividers>{LL.SYSTEM_FACTORY_TEXT_DIALOG()}</DialogContent>
<Button <DialogActions>
startIcon={<SettingsBackupRestoreIcon />} <Button
variant="outlined" startIcon={<CancelIcon />}
onClick={doFormat} variant="outlined"
color="error" onClick={handleFactoryResetClose}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<SettingsBackupRestoreIcon />}
variant="outlined"
onClick={doFormat}
color="error"
>
{LL.FACTORY_RESET()}
</Button>
</DialogActions>
</Dialog>
<Divider />
<Box
mt={2}
display="flex"
justifyContent="flex-end"
flexWrap="nowrap"
whiteSpace="nowrap"
> >
{LL.FACTORY_RESET()} <Button
</Button> startIcon={<SettingsBackupRestoreIcon />}
</DialogActions> variant="outlined"
</Dialog> onClick={handleFactoryResetClick}
); color="error"
>
{LL.FACTORY_RESET()}
</Button>
</Box>
</>
);
}, [
LL,
handleFactoryResetClick,
handleFactoryResetClose,
doFormat,
confirmFactoryReset,
restarting
]);
const content = () => ( return <SectionContent>{restarting ? <SystemMonitor /> : content}</SectionContent>;
<>
<List sx={{ borderRadius: 3, border: '2px solid grey' }}>
<ListMenuItem
icon={TuneIcon}
bgcolor="#134ba2"
label={LL.APPLICATION()}
text={LL.APPLICATION_SETTINGS_1()}
to="application"
/>
<ListMenuItem
icon={SettingsEthernetIcon}
bgcolor="#40828f"
label={LL.NETWORK(0)}
text={LL.CONFIGURE(LL.SETTINGS_OF(LL.NETWORK(1)))}
to="network"
/>
<ListMenuItem
icon={SettingsInputAntennaIcon}
bgcolor="#5f9a5f"
label={LL.ACCESS_POINT(0)}
text={LL.CONFIGURE(LL.ACCESS_POINT(1))}
to="ap"
/>
<ListMenuItem
icon={AccessTimeIcon}
bgcolor="#c5572c"
label="NTP"
text={LL.CONFIGURE(LL.LOCAL_TIME(1))}
to="ntp"
/>
<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>
{renderFactoryResetDialog()}
<Box mt={2} display="flex" flexWrap="wrap">
<Button
startIcon={<SettingsBackupRestoreIcon />}
variant="outlined"
onClick={() => setConfirmFactoryReset(true)}
color="error"
>
{LL.FACTORY_RESET()}
</Button>
</Box>
</>
);
return <SectionContent>{content()}</SectionContent>;
}; };
export default Settings; export default Settings;

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { useContext } from 'react'; import { memo, useCallback, useContext } from 'react';
import LockIcon from '@mui/icons-material/Lock'; import LockIcon from '@mui/icons-material/Lock';
import LockOpenIcon from '@mui/icons-material/LockOpen'; import LockOpenIcon from '@mui/icons-material/LockOpen';
@@ -63,38 +63,41 @@ const WiFiNetworkSelector = ({ networkList }: { networkList: WiFiNetworkList })
const wifiConnectionContext = useContext(WiFiConnectionContext); const wifiConnectionContext = useContext(WiFiConnectionContext);
const renderNetwork = (network: WiFiNetwork) => ( const renderNetwork = useCallback(
<ListItem (network: WiFiNetwork) => (
key={network.bssid} <ListItem
onClick={() => wifiConnectionContext.selectNetwork(network)} key={network.bssid}
> onClick={() => wifiConnectionContext.selectNetwork(network)}
<ListItemAvatar> >
<Avatar>{isNetworkOpen(network) ? <LockOpenIcon /> : <LockIcon />}</Avatar> <ListItemAvatar>
</ListItemAvatar> <Avatar>{isNetworkOpen(network) ? <LockOpenIcon /> : <LockIcon />}</Avatar>
<ListItemText </ListItemAvatar>
primary={network.ssid} <ListItemText
secondary={ primary={network.ssid}
'Security: ' + secondary={
networkSecurityMode(network) + 'Security: ' +
', Ch: ' + networkSecurityMode(network) +
network.channel + ', Ch: ' +
', bssid: ' + network.channel +
network.bssid ', bssid: ' +
} network.bssid
/> }
<ListItemIcon> />
<Badge badgeContent={network.rssi + 'dBm'}> <ListItemIcon>
<WifiIcon sx={{ color: networkQualityHighlight(network, theme) }} /> <Badge badgeContent={network.rssi + 'dBm'}>
</Badge> <WifiIcon sx={{ color: networkQualityHighlight(network, theme) }} />
</ListItemIcon> </Badge>
</ListItem> </ListItemIcon>
</ListItem>
),
[wifiConnectionContext, theme]
); );
if (networkList.networks.length === 0) { if (networkList.networks.length === 0) {
return <MessageBox mt={2} mb={1} message={LL.NETWORK_NO_WIFI()} level="info" />; return <MessageBox message={LL.NETWORK_NO_WIFI()} level="info" />;
} }
return <List>{networkList.networks.map(renderNetwork)}</List>; return <List>{networkList.networks.map(renderNetwork)}</List>;
}; };
export default WiFiNetworkSelector; export default memo(WiFiNetworkSelector);

View File

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

View File

@@ -1,4 +1,4 @@
import { useContext, useState } from 'react'; import { memo, useCallback, useContext, useMemo, useState } from 'react';
import { useBlocker } from 'react-router'; import { useBlocker } from 'react-router';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
@@ -55,14 +55,16 @@ const ManageUsers = () => {
const blocker = useBlocker(changed !== 0); const blocker = useBlocker(changed !== 0);
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const table_theme = useTheme({ const table_theme = useMemo(
Table: ` () =>
useTheme({
Table: `
--data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) minmax(120px, max-content) 120px; --data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) minmax(120px, max-content) 120px;
`, `,
BaseRow: ` BaseRow: `
font-size: 14px; font-size: 14px;
`, `,
HeaderRow: ` HeaderRow: `
text-transform: uppercase; text-transform: uppercase;
background-color: black; background-color: black;
color: #90CAF9; color: #90CAF9;
@@ -72,7 +74,7 @@ const ManageUsers = () => {
border-bottom: 1px solid #565656; border-bottom: 1px solid #565656;
} }
`, `,
Row: ` Row: `
.td { .td {
padding: 8px; padding: 8px;
border-top: 1px solid #565656; border-top: 1px solid #565656;
@@ -85,7 +87,7 @@ const ManageUsers = () => {
background-color: #1e1e1e; background-color: #1e1e1e;
} }
`, `,
BaseCell: ` BaseCell: `
&:nth-of-type(2) { &:nth-of-type(2) {
text-align: center; text-align: center;
} }
@@ -93,71 +95,80 @@ const ManageUsers = () => {
text-align: right; text-align: right;
} }
` `
}); }),
[]
);
const content = () => { const noAdminConfigured = useCallback(
if (!data) { () => !data?.users.find((u) => u.admin),
return <FormLoader onRetry={loadData} errorMessage={errorMessage} />; [data]
} );
const noAdminConfigured = () => !data.users.find((u) => u.admin); const removeUser = useCallback(
(toRemove: UserType) => {
const removeUser = (toRemove: UserType) => { if (!data) return;
const users = data.users.filter((u) => u.username !== toRemove.username); const users = data.users.filter((u) => u.username !== toRemove.username);
updateDataValue({ ...data, users }); updateDataValue({ ...data, users });
setChanged(changed + 1); setChanged(changed + 1);
}; },
[data, updateDataValue, changed]
);
const createUser = () => { const createUser = useCallback(() => {
setCreating(true); setCreating(true);
setUser({ setUser({
username: '', username: '',
password: '', password: '',
admin: true admin: true
}); });
}; }, []);
const editUser = (toEdit: UserType) => { const editUser = useCallback((toEdit: UserType) => {
setCreating(false); setCreating(false);
setUser({ ...toEdit }); setUser({ ...toEdit });
}; }, []);
const cancelEditingUser = () => { const cancelEditingUser = useCallback(() => {
setUser(undefined);
}, []);
const doneEditingUser = useCallback(() => {
if (user && data) {
const users = [
...data.users.filter(
(u: { username: string }) => u.username !== user.username
),
user
];
updateDataValue({ ...data, users });
setUser(undefined); setUser(undefined);
}; setChanged(changed + 1);
}
}, [user, data, updateDataValue, changed]);
const doneEditingUser = () => { const closeGenerateToken = useCallback(() => {
if (user) { setGeneratingToken(undefined);
const users = [ }, []);
...data.users.filter(
(u: { username: string }) => u.username !== user.username
),
user
];
updateDataValue({ ...data, users });
setUser(undefined);
setChanged(changed + 1);
}
};
const closeGenerateToken = () => { const generateTokenForUser = useCallback((username: string) => {
setGeneratingToken(undefined); setGeneratingToken(username);
}; }, []);
const generateToken = (username: string) => { const onSubmit = useCallback(async () => {
setGeneratingToken(username); await saveData();
}; await authenticatedContext.refresh();
setChanged(0);
}, [saveData, authenticatedContext]);
const onSubmit = async () => { const onCancelSubmit = useCallback(async () => {
await saveData(); await loadData();
await authenticatedContext.refresh(); setChanged(0);
setChanged(0); }, [loadData]);
};
const onCancelSubmit = async () => { const content = () => {
await loadData(); if (!data) {
setChanged(0); return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
}; }
interface UserType2 { interface UserType2 {
id: string; id: string;
@@ -167,10 +178,14 @@ const ManageUsers = () => {
} }
// add id to the type, needed for the table // add id to the type, needed for the table
const user_table = data.users.map((u) => ({ const user_table = useMemo(
...u, () =>
id: u.username data.users.map((u) => ({
})) as UserType2[]; ...u,
id: u.username
})) as UserType2[],
[data.users]
);
return ( return (
<> <>
@@ -196,15 +211,24 @@ const ManageUsers = () => {
<Cell stiff> <Cell stiff>
<IconButton <IconButton
size="small" size="small"
aria-label={LL.GENERATING_TOKEN()}
disabled={!authenticatedContext.me.admin} disabled={!authenticatedContext.me.admin}
onClick={() => generateToken(u.username)} onClick={() => generateTokenForUser(u.username)}
> >
<VpnKeyIcon /> <VpnKeyIcon />
</IconButton> </IconButton>
<IconButton size="small" onClick={() => removeUser(u)}> <IconButton
size="small"
onClick={() => removeUser(u)}
aria-label={LL.REMOVE()}
>
<DeleteIcon /> <DeleteIcon />
</IconButton> </IconButton>
<IconButton size="small" onClick={() => editUser(u)}> <IconButton
size="small"
onClick={() => editUser(u)}
aria-label={LL.EDIT()}
>
<EditIcon /> <EditIcon />
</IconButton> </IconButton>
</Cell> </Cell>
@@ -260,15 +284,20 @@ const ManageUsers = () => {
</Box> </Box>
</Box> </Box>
<GenerateToken username={generatingToken} onClose={closeGenerateToken} /> <GenerateToken
<User username={generatingToken || ''}
user={user} onClose={closeGenerateToken}
setUser={setUser}
creating={creating}
onDoneEditing={doneEditingUser}
onCancelEditing={cancelEditingUser}
validator={createUserValidator(data.users, creating)}
/> />
{user && (
<User
user={user}
setUser={setUser}
creating={creating}
onDoneEditing={doneEditingUser}
onCancelEditing={cancelEditingUser}
validator={createUserValidator(data.users, creating)}
/>
)}
</> </>
); );
}; };
@@ -281,4 +310,4 @@ const ManageUsers = () => {
); );
}; };
export default ManageUsers; export default memo(ManageUsers);

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,5 @@
import { useCallback, useMemo } from 'react';
import { import {
Body, Body,
Cell, Cell,
@@ -17,6 +19,12 @@ import { useInterval } from 'utils';
import { readActivity } from '../../api/app'; import { readActivity } from '../../api/app';
import type { Stat } from '../main/types'; import type { Stat } from '../main/types';
const QUALITY_COLORS = {
PERFECT: '#00FF7F',
WARNING: 'orange',
POOR: 'red'
} as const;
const SystemActivity = () => { const SystemActivity = () => {
const { data, send: loadData, error } = useRequest(readActivity); const { data, send: loadData, error } = useRequest(readActivity);
@@ -28,14 +36,16 @@ const SystemActivity = () => {
useLayoutTitle(LL.DATA_TRAFFIC()); useLayoutTitle(LL.DATA_TRAFFIC());
const stats_theme = tableTheme({ const stats_theme = tableTheme(
Table: ` useMemo(
() => ({
Table: `
--data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) 90px 90px 80px; --data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) 90px 90px 80px;
`, `,
BaseRow: ` BaseRow: `
font-size: 14px; font-size: 14px;
`, `,
HeaderRow: ` HeaderRow: `
text-transform: uppercase; text-transform: uppercase;
background-color: black; background-color: black;
color: #90CAF9; color: #90CAF9;
@@ -45,7 +55,7 @@ const SystemActivity = () => {
border-bottom: 1px solid #565656; border-bottom: 1px solid #565656;
} }
`, `,
Row: ` Row: `
.td { .td {
padding: 8px; padding: 8px;
border-top: 1px solid #565656; border-top: 1px solid #565656;
@@ -59,35 +69,42 @@ const SystemActivity = () => {
background-color: #1e1e1e; background-color: #1e1e1e;
} }
`, `,
BaseCell: ` BaseCell: `
&:not(:first-of-type) { &:not(:first-of-type) {
text-align: center; text-align: center;
} }
` `
}); }),
[]
)
);
const showName = (id: number) => { const showName = useCallback(
const name: keyof Translation['STATUS_NAMES'] = id; (id: number) => {
return LL.STATUS_NAMES[name](); 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) { if (stat.q === 0 || stat.s + stat.f === 0) {
return; return;
} }
if (stat.q === 100) { if (stat.q === 100) {
return <div style={{ color: '#00FF7F' }}>{stat.q}%</div>; return <div style={{ color: QUALITY_COLORS.PERFECT }}>{stat.q}%</div>;
} }
if (stat.q >= 95) { if (stat.q >= 95) {
return <div style={{ color: 'orange' }}>{stat.q}%</div>; return <div style={{ color: QUALITY_COLORS.WARNING }}>{stat.q}%</div>;
} else { } else {
return <div style={{ color: 'red' }}>{stat.q}%</div>; return <div style={{ color: QUALITY_COLORS.POOR }}>{stat.q}%</div>;
} }
}; }, []);
const content = () => { const content = useMemo(() => {
if (!data) { if (!data) {
return <FormLoader onRetry={loadData} errorMessage={error?.message} />; return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
} }
return ( return (
@@ -120,9 +137,9 @@ const SystemActivity = () => {
)} )}
</Table> </Table>
); );
}; }, [data, loadData, error?.message, stats_theme, LL, showName, showQuality]);
return <SectionContent>{content()}</SectionContent>; return <SectionContent>{content}</SectionContent>;
}; };
export default SystemActivity; export default SystemActivity;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,17 +1,7 @@
import { Tooltip, type TooltipProps, styled, tooltipClasses } from '@mui/material'; import { Tooltip, type TooltipProps } from '@mui/material';
export const ButtonTooltip = styled(({ className, ...props }: TooltipProps) => ( export const ButtonTooltip = ({ children, ...props }: TooltipProps) => (
<Tooltip {...props} placement="top" arrow classes={{ popper: className }} /> <Tooltip {...props}>{children}</Tooltip>
))(({ theme }) => ({ );
[`& .${tooltipClasses.arrow}`]: {
color: theme.palette.success.main
},
[`& .${tooltipClasses.tooltip}`]: {
backgroundColor: theme.palette.success.main,
color: 'rgba(0, 0, 0, 0.87)',
boxShadow: theme.shadows[1],
fontSize: 10
}
}));
export default ButtonTooltip; export default ButtonTooltip;

View File

@@ -1,11 +1,11 @@
import type { FC } from 'react'; import { type FC, memo, useMemo } from 'react';
import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined'; import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined';
import ErrorIcon from '@mui/icons-material/Error'; import ErrorIcon from '@mui/icons-material/Error';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import ReportProblemOutlinedIcon from '@mui/icons-material/ReportProblemOutlined'; import ReportProblemOutlinedIcon from '@mui/icons-material/ReportProblemOutlined';
import { Box, Typography, useTheme } from '@mui/material'; import { Box, Typography, useTheme } from '@mui/material';
import type { BoxProps, SvgIconProps, Theme } from '@mui/material'; import type { BoxProps, SvgIconProps } from '@mui/material';
type MessageBoxLevel = 'warning' | 'success' | 'info' | 'error'; type MessageBoxLevel = 'warning' | 'success' | 'info' | 'error';
@@ -14,22 +14,18 @@ export interface MessageBoxProps extends BoxProps {
message?: string; message?: string;
} }
const LEVEL_ICONS: { const LEVEL_ICONS: Record<MessageBoxLevel, React.ComponentType<SvgIconProps>> = {
[type in MessageBoxLevel]: React.ComponentType<SvgIconProps>;
} = {
success: CheckCircleOutlineOutlinedIcon, success: CheckCircleOutlineOutlinedIcon,
info: InfoOutlinedIcon, info: InfoOutlinedIcon,
warning: ReportProblemOutlinedIcon, warning: ReportProblemOutlinedIcon,
error: ErrorIcon error: ErrorIcon
}; };
const LEVEL_BACKGROUNDS: { const LEVEL_PALETTE_PATHS: Record<MessageBoxLevel, string> = {
[type in MessageBoxLevel]: (theme: Theme) => string; success: 'success.dark',
} = { info: 'info.main',
success: (theme: Theme) => theme.palette.success.dark, warning: 'warning.dark',
info: (theme: Theme) => theme.palette.info.main, error: 'error.dark'
warning: (theme: Theme) => theme.palette.warning.dark,
error: (theme: Theme) => theme.palette.error.dark
}; };
const MessageBox: FC<MessageBoxProps> = ({ const MessageBox: FC<MessageBoxProps> = ({
@@ -40,25 +36,38 @@ const MessageBox: FC<MessageBoxProps> = ({
...rest ...rest
}) => { }) => {
const theme = useTheme(); const theme = useTheme();
const Icon = LEVEL_ICONS[level];
const backgroundColor = LEVEL_BACKGROUNDS[level](theme); const { Icon, backgroundColor } = useMemo(() => {
const color = 'white'; 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 ( return (
<Box <Box
p={2} p={2}
display="flex" display="flex"
alignItems="center" alignItems="center"
borderRadius={1} borderRadius={1}
sx={{ backgroundColor, color, ...sx }} sx={{ backgroundColor, color: 'white', ...sx }}
{...rest} {...rest}
> >
<Icon /> <Icon />
<Typography sx={{ ml: 2 }} variant="body1"> {(message || children) && (
{message ?? ''} <Typography sx={{ ml: 2 }} variant="body1">
</Typography> {message}
{children} {children}
</Typography>
)}
</Box> </Box>
); );
}; };
export default MessageBox; export default memo(MessageBox);

View File

@@ -1,33 +1,28 @@
import { memo } from 'react';
import type { FC } from 'react'; import type { FC } from 'react';
import { Divider, Paper } from '@mui/material'; import { Paper } from '@mui/material';
import type { SxProps, Theme } from '@mui/material/styles';
import type { RequiredChildrenProps } from 'utils'; import type { RequiredChildrenProps } from 'utils';
interface SectionContentProps extends RequiredChildrenProps { interface SectionContentProps extends RequiredChildrenProps {
title?: string;
id?: string; id?: string;
} }
const SectionContent: FC<SectionContentProps> = (props) => { // Extract styles to avoid recreation on every render
const { children, title, id } = props; const paperStyles: SxProps<Theme> = {
return ( p: 1.5,
<Paper id={id} sx={{ p: 2, m: 2 }}> m: 1.5,
{title && ( borderRadius: 3,
<Divider border: '1px solid rgb(65, 65, 65)'
sx={{
pb: 2,
borderColor: 'primary.main',
fontSize: 20,
color: 'primary.main'
}}
>
{title}
</Divider>
)}
{children}
</Paper>
);
}; };
export default SectionContent; const SectionContent: FC<SectionContentProps> = ({ children, id }) => (
<Paper id={id} sx={paperStyles}>
{children}
</Paper>
);
// Memoize to prevent unnecessary re-renders
export default memo(SectionContent);

View File

@@ -1,10 +1,15 @@
// use direct exports to reduce bundle size
export { default as SectionContent } from './SectionContent';
export { default as ButtonRow } from './ButtonRow';
export { default as MessageBox } from './MessageBox';
export { default as ButtonTooltip } from './ButtonTooltip';
// Re-export sub-modules
export * from './inputs'; export * from './inputs';
export * from './layout'; export * from './layout';
export * from './loading'; export * from './loading';
export * from './routing'; export * from './routing';
export * from './upload'; export * from './upload';
export { default as SectionContent } from './SectionContent';
export { default as ButtonRow } from './ButtonRow'; // Specific routing exports
export { default as MessageBox } from './MessageBox';
export { default as BlockNavigation } from './routing/BlockNavigation'; export { default as BlockNavigation } from './routing/BlockNavigation';
export { default as ButtonTooltip } from './ButtonTooltip';

View File

@@ -1,3 +1,4 @@
import { memo } from 'react';
import type { FC } from 'react'; import type { FC } from 'react';
import { FormControlLabel } from '@mui/material'; import { FormControlLabel } from '@mui/material';
@@ -9,4 +10,4 @@ const BlockFormControlLabel: FC<FormControlLabelProps> = (props) => (
</div> </div>
); );
export default BlockFormControlLabel; export default memo(BlockFormControlLabel);

View File

@@ -1,4 +1,6 @@
import { type ChangeEventHandler, 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 { MenuItem, TextField } from '@mui/material';
@@ -17,73 +19,66 @@ import { I18nContext } from 'i18n/i18n-react';
import type { Locales } from 'i18n/i18n-types'; import type { Locales } from 'i18n/i18n-types';
import { loadLocaleAsync } from 'i18n/i18n-util.async'; import { loadLocaleAsync } from 'i18n/i18n-util.async';
const LanguageSelector = () => { const flagStyle: CSSProperties = { width: 16, verticalAlign: 'middle' };
const { setLocale, locale } = useContext(I18nContext);
const onLocaleSelected: ChangeEventHandler<HTMLInputElement> = async ({ interface LanguageOption {
target key: Locales;
}) => { flag: string;
const loc = target.value as Locales; label: string;
localStorage.setItem('lang', loc); }
await loadLocaleAsync(loc);
setLocale(loc); const LANGUAGE_OPTIONS: LanguageOption[] = [
}; { key: 'cz', flag: CZflag, label: 'CZ' },
{ key: 'de', flag: DEflag, label: 'DE' },
{ key: 'en', flag: GBflag, label: 'EN' },
{ key: 'fr', flag: FRflag, label: 'FR' },
{ key: 'it', flag: ITflag, label: 'IT' },
{ key: 'nl', flag: NLflag, label: 'NL' },
{ key: 'no', flag: NOflag, label: 'NO' },
{ key: 'pl', flag: PLflag, label: 'PL' },
{ key: 'sk', flag: SKflag, label: 'SK' },
{ key: 'sv', flag: SVflag, label: 'SV' },
{ key: 'tr', flag: TRflag, label: 'TR' }
];
const LanguageSelector = () => {
const { setLocale, locale, LL } = useContext(I18nContext);
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 ( return (
<TextField <TextField
name="locale" name="locale"
variant="outlined" variant="outlined"
aria-label={LL.LANGUAGE()}
value={locale} value={locale}
onChange={onLocaleSelected} onChange={onLocaleSelected}
size="small" size="small"
select select
> >
<MenuItem key="cz" value="cz"> {menuItems}
<img src={CZflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;CZ
</MenuItem>
<MenuItem key="de" value="de">
<img src={DEflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;DE
</MenuItem>
<MenuItem key="en" value="en">
<img src={GBflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;EN
</MenuItem>
<MenuItem key="fr" value="fr">
<img src={FRflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;FR
</MenuItem>
<MenuItem key="it" value="it">
<img src={ITflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;IT
</MenuItem>
<MenuItem key="nl" value="nl">
<img src={NLflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;NL
</MenuItem>
<MenuItem key="no" value="no">
<img src={NOflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;NO
</MenuItem>
<MenuItem key="pl" value="pl">
<img src={PLflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;PL
</MenuItem>
<MenuItem key="sk" value="sk">
<img src={SKflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;SK
</MenuItem>
<MenuItem key="sv" value="sv">
<img src={SVflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;SV
</MenuItem>
<MenuItem key="tr" value="tr">
<img src={TRflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;TR
</MenuItem>
</TextField> </TextField>
); );
}; };
export default LanguageSelector; export default memo(LanguageSelector);

View File

@@ -1,4 +1,4 @@
import { useState } from 'react'; import { memo, useCallback, useState } from 'react';
import type { FC } from 'react'; import type { FC } from 'react';
import VisibilityIcon from '@mui/icons-material/Visibility'; import VisibilityIcon from '@mui/icons-material/Visibility';
@@ -13,6 +13,10 @@ type ValidatedPasswordFieldProps = Omit<ValidatedTextFieldProps, 'type'>;
const ValidatedPasswordField: FC<ValidatedPasswordFieldProps> = ({ ...props }) => { const ValidatedPasswordField: FC<ValidatedPasswordFieldProps> = ({ ...props }) => {
const [showPassword, setShowPassword] = useState<boolean>(false); const [showPassword, setShowPassword] = useState<boolean>(false);
const togglePasswordVisibility = useCallback(() => {
setShowPassword((prev) => !prev);
}, []);
return ( return (
<ValidatedTextField <ValidatedTextField
{...props} {...props}
@@ -21,7 +25,11 @@ const ValidatedPasswordField: FC<ValidatedPasswordFieldProps> = ({ ...props }) =
input: { input: {
endAdornment: ( endAdornment: (
<InputAdornment position="end"> <InputAdornment position="end">
<IconButton onClick={() => setShowPassword(!showPassword)} edge="end"> <IconButton
onClick={togglePasswordVisibility}
edge="end"
aria-label="Password visibility"
>
{showPassword ? <VisibilityIcon /> : <VisibilityOffIcon />} {showPassword ? <VisibilityIcon /> : <VisibilityOffIcon />}
</IconButton> </IconButton>
</InputAdornment> </InputAdornment>
@@ -32,4 +40,4 @@ const ValidatedPasswordField: FC<ValidatedPasswordFieldProps> = ({ ...props }) =
); );
}; };
export default ValidatedPasswordField; export default memo(ValidatedPasswordField);

View File

@@ -1,3 +1,4 @@
import { memo } from 'react';
import type { FC } from 'react'; import type { FC } from 'react';
import { FormHelperText, TextField } from '@mui/material'; import { FormHelperText, TextField } from '@mui/material';
@@ -14,18 +15,42 @@ export type ValidatedTextFieldProps = ValidatedFieldProps & TextFieldProps;
const ValidatedTextField: FC<ValidatedTextFieldProps> = ({ const ValidatedTextField: FC<ValidatedTextFieldProps> = ({
fieldErrors, fieldErrors,
sx,
...rest ...rest
}) => { }) => {
const errors = fieldErrors && fieldErrors[rest.name]; const errors = fieldErrors?.[rest.name];
const renderErrors = () =>
errors &&
errors.map((e) => <FormHelperText key={e.message}>{e.message}</FormHelperText>);
return ( return (
<> <>
<TextField error={!!errors} {...rest} /> <TextField
{renderErrors()} error={!!errors}
{...rest}
aria-label="Error"
sx={{
'& .MuiInputBase-input.Mui-disabled': {
WebkitTextFillColor: 'grey'
},
...(sx || {})
}}
{...(rest.disabled && {
slotProps: {
select: {
IconComponent: () => null
},
inputLabel: {
style: { color: 'grey' }
}
}
})}
color={rest.disabled ? 'secondary' : 'primary'}
/>
{errors?.map((e) => (
<FormHelperText key={e.message} sx={{ color: 'rgb(250, 95, 84)' }}>
{e.message}
</FormHelperText>
))}
</> </>
); );
}; };
export default ValidatedTextField; export default memo(ValidatedTextField);

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