Compare commits

..
62 Commits
Author SHA1 Message Date
proddy f444ca31e0 Merge remote-tracking branch 'origin/dev' 2024-10-27 11:41:47 +01:00
proddy 1b70b55989 update demo url 2024-08-10 14:58:48 +02:00
proddy 1487f30c43 Merge remote-tracking branch 'origin/dev' for 3.6.5 2024-03-23 17:56:05 +01:00
Proddy e00eb8e64f 3.6.4 2023-11-26 20:11:36 +01:00
Proddy f41bb3671c 3.6.4 2023-11-24 07:36:36 +01:00
Proddy 22c75e6df3 3.6.4 2023-11-24 07:36:29 +01:00
proddy 5ab10b7aa6 fixes #1450 2023-11-22 09:48:09 +01:00
Proddy ee5fd4d0eb 3.6.3 2023-11-21 14:40:47 +01:00
Proddy 46f35bc67c another patch on 3.6.3 2023-11-21 14:40:32 +01:00
Proddy ec85a7ec24 3.6.3 (refershed) 2023-11-19 21:24:01 +01:00
Proddy 02f2389587 add workflow_dispatch: 2023-11-18 18:51:12 +01:00
Proddy 7f140021aa quick fix - https://github.com/emsesp/EMS-ESP32/pull/1438 2023-11-18 18:47:28 +01:00
Proddy 6796962c1e 3.6.3 2023-11-18 13:35:20 +01:00
Proddy df6de21cf4 Merge remote-tracking branch 'origin/dev' 2023-11-18 13:35:04 +01:00
Proddy df9f75a5c9 updated yarn for 3.6.2 2023-10-01 17:42:54 +02:00
Proddy 7bd8710eb6 3.6.2 2023-10-01 17:40:09 +02:00
Proddy 32f2c6d341 Merge remote-tracking branch 'origin/dev' 2023-10-01 17:40:02 +02:00
Proddy 86919c1684 Merge branch 'origin/dev' 2023-09-09 14:12:07 +02:00
Proddy 86e29515e7 build s3 2023-08-15 18:44:24 +02:00
Proddy 46eb4185d7 update with 3.6.0 2023-08-13 14:37:13 +02:00
Proddy 8da6761a48 Merge branch 'dev' 2023-08-13 14:32:41 +02:00
Proddy 9233f0dfcc v3.5.1 - merge with patch 2023-03-11 16:06:05 +01:00
Proddy 292f743b14 Update bug_report.md 2023-02-19 11:18:08 +01:00
Proddy dd6dfffd57 Delete questions---troubleshooting.md 2023-02-19 10:49:52 +01:00
Proddy ec705a5307 Delete feature_request.md 2023-02-19 10:49:45 +01:00
Proddy f45f071710 Create config.yml 2023-02-19 10:49:32 +01:00
Proddy f3858546de Merge branch 'origin/dev' 2023-02-06 21:58:27 +01:00
Proddy d0ac0b7804 update with version on dev 2022-10-30 16:58:37 +01:00
Proddy d8284ec09f Merge pull request #705 from MichaelDvP/main
v3.4.4 Fix for new installations with filesystem not initializing
2022-10-29 10:46:41 +02:00
MichaelDvP 6e982acde8 v3.4.4 Fix for new installations with filesystem not initializing 2022-10-28 10:50:51 +02:00
Proddy 8c94ce99b2 quick fix for filesystem initialization 2022-10-08 09:23:00 +02:00
proddy fc057d18c9 Merge remote-tracking branch 'origin/dev' 2022-09-18 14:33:23 +02:00
Proddy 18e9b99413 Merge remote-tracking branch 'origin/dev' into main 2022-05-29 16:16:38 +02:00
Proddy a47e0e8266 update for 3.4.0 2022-05-23 21:20:45 +02:00
Proddy f412ddc716 Merge remote-tracking branch 'origin/dev' into main 2022-05-23 21:20:36 +02:00
proddy 29110e96e5 Merge remote-tracking branch 'origin/dev' 2022-01-20 10:51:40 +01:00
proddy b65866217a 3.4.0 2021-11-28 23:03:28 +01:00
proddy 611e3b1243 Merge remote-tracking branch 'origin/dev' 2021-11-28 23:03:15 +01:00
proddy 2ca0a0c634 v3.2.1 merged from dev 2021-08-08 14:46:14 +02:00
proddy 7eb1f061b7 Merge remote-tracking branch 'origin/dev' for 3.2.0 release 2021-08-06 12:06:08 +02:00
proddy 50459a23fe force v16 of nodejs 2021-06-26 11:13:07 +02:00
proddy 5bf53c3389 3.1.1 2021-06-26 11:03:03 +02:00
proddy 4b7aa95be3 Merge remote-tracking branch 'origin/dev' 2021-06-26 11:02:55 +02:00
Proddy 70943f5758 Update pre_release.yml 2021-05-16 15:52:09 +02:00
Proddy 3bc280b817 Delete check_code.yml 2021-05-16 15:51:56 +02:00
Proddy 62b15a5319 Update pre_release.yml 2021-05-16 15:35:06 +02:00
Proddy 8dd18802d6 Update tagged_release.yml 2021-05-16 15:34:45 +02:00
proddy 57a516a83a updated README and images 2021-05-09 15:13:16 +02:00
proddy a57fdaa4b3 Merge remote-tracking branch 'origin/dev' into main 2021-05-04 12:21:51 +02:00
proddy 4841e42286 Merge remote-tracking branch 'origin/dev' into main 2021-03-30 16:35:18 +02:00
proddy 8c2d2b06ed cleaned up old changelog 2021-03-18 20:59:09 +01:00
proddy 38c8b1b7f0 3.0.0 2021-03-18 20:58:21 +01:00
proddy 6fb5933a02 Merge remote-tracking branch 'origin/dev' into main 2021-03-18 20:58:12 +01:00
proddy c0944433be remove workspace.code-workspace 2021-03-16 17:41:42 +01:00
Proddy 478e6362c9 Merge pull request #27 from FauthD:main
Add global names to Dallas sensors to avoid ugly <unknown> and other …
2021-03-16 17:39:00 +01:00
fauthd 4d6354db78 Add stuff to gitignore, add vscode workspace 2021-03-16 16:47:09 +01:00
fauthd beab0f0c77 Add global names to Dallas sensors to avoid ugly <unknown> and other issues in HA 2021-03-16 16:00:23 +01:00
Proddy c17749bd22 Update README.md 2021-03-14 23:43:33 +01:00
proddy 2bad769c5c build: include assets 2021-03-14 21:20:51 +01:00
proddy 8ad89ca64b move repo 2021-03-14 21:05:15 +01:00
proddy 9244d8daec Semantic Commit Messages 2021-03-14 18:10:57 +01:00
proddy 02d01334b2 update new build 2021-03-14 17:37:18 +01:00
575 changed files with 56691 additions and 69626 deletions
-27
View File
@@ -1,27 +0,0 @@
{
"name": "EMS-ESP Devcontainer",
"image": "mcr.microsoft.com/devcontainers/typescript-node:1-22-bookworm",
"features": {
"ghcr.io/devcontainers/features/node:1": {},
"ghcr.io/devcontainers-extra/features/pnpm:2": {},
"ghcr.io/devcontainers/features/python:1": {},
"ghcr.io/shyim/devcontainers-features/bun:0": {}
},
"forwardPorts": [
3000,
3080
],
// Use 'postCreateCommand' to run commands after the container is created.
"postCreateCommand": "cd mock-api && pnpm install && cd .. && cd interface && pnpm install",
// Configure tool-specific properties.
"customizations": {
"vscode": {
"extensions": [
"platformio.platformio-ide"
]
}
}
}
-132
View File
@@ -1,132 +0,0 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
[https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][v2.0].
Community Impact Guidelines were inspired by
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
For answers to common questions about this code of conduct, see the FAQ at
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available
at [https://www.contributor-covenant.org/translations][translations].
[homepage]: https://www.contributor-covenant.org
[v2.0]: https://www.contributor-covenant.org/version/2/0/code_of_conduct.html
[Mozilla CoC]: https://github.com/mozilla/diversity
[FAQ]: https://www.contributor-covenant.org/faq
[translations]: https://www.contributor-covenant.org/translations
+1 -1
View File
@@ -22,7 +22,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://emsesp.org/Troubleshooting/) - [ ] Searched the issue in the [docs](https://emsesp.org/Troubleshooting/)
- [ ] Searched the issue in the [chat](https://discord.gg/GP9DPSgeJq) - [ ] Searched the issue in the [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`
```json ```json
+1 -1
View File
@@ -7,5 +7,5 @@ contact_links:
url: https://github.com/emsesp/EMS-ESP32/discussions url: https://github.com/emsesp/EMS-ESP32/discussions
about: EMS-ESP usage Questions, Feature Requests and Projects. about: EMS-ESP usage Questions, Feature Requests and Projects.
- name: EMS-ESP Users Chat - name: EMS-ESP Users Chat
url: https://discord.gg/GP9DPSgeJq url: https://discord.gg/3J3GgnzpyT
about: Chat for feedback, questions and troubleshooting. about: Chat for feedback, questions and troubleshooting.
-1
View File
@@ -1 +0,0 @@
# Support
-99
View File
@@ -1,99 +0,0 @@
name: 'Build dev release'
on:
workflow_dispatch:
push:
paths:
- 'src/emsesp_version.h'
branches:
- 'dev'
permissions:
contents: write
jobs:
pre-release:
name: 'Build Dev Release'
runs-on: ubuntu-latest
steps:
- name: Install python 3.13
uses: actions/setup-python@v6
with:
python-version: '3.13'
- name: Install Node.js 24
uses: actions/setup-node@v6
with:
node-version: 24
- name: Checkout repository
uses: actions/checkout@v6
- name: Enable Corepack
run: corepack enable pnpm
- name: Get the EMS-ESP version
id: build_info
run: |
version=`grep -E '^#define EMSESP_APP_VERSION' ./src/emsesp_version.h | awk -F'"' '{print $2}'`
echo "VERSION=$version" >> $GITHUB_OUTPUT
- name: Install PlatformIO
run: |
python -m pip install --upgrade pip
pip install -U platformio
python -m pip install intelhex
- name: Build webUI
run: |
platformio run -e build-webUI
- name: Build modbus
run: |
platformio run -e build_modbus
- name: Build standalone
run: |
platformio run -e build_standalone
- name: Build all PIO target environments, from default_envs
run: |
platformio run
- name: Commit the generated files
uses: stefanzweifel/git-auto-commit-action@v7
with:
commit_message: "chore: update generated files for v${{steps.build_info.outputs.VERSION}}"
- name: Create GitHub Release
id: 'automatic_releases'
uses: emsesp/action-automatic-releases@v1.0.1
with:
repo_token: '${{ secrets.GITHUB_TOKEN }}'
title: Development Build v${{steps.build_info.outputs.VERSION}}
automatic_release_tag: 'latest'
prerelease: true
files: |
CHANGELOG_LATEST.md
./build/firmware/*.*
- name: Update version in Cloudflare KV store
if: github.repository == 'emsesp/EMS-ESP32'
env:
CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
CF_NAMESPACE_ID: ${{ secrets.CF_NAMESPACE_ID }}
CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
VERSION: ${{ steps.build_info.outputs.VERSION }}
run: |
JSON_DATA=$(jq -n \
--arg version "$VERSION" \
--arg date "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'{version: $version, date: $date}')
echo "JSON_DATA: $JSON_DATA"
curl -sS --fail-with-body \
-X PUT "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/storage/kv/namespaces/${CF_NAMESPACE_ID}/values/dev" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H "Content-Type: application/json" \
-d "$JSON_DATA"
echo
@@ -1,17 +1,16 @@
name: 'Publish releases to discord' name: 'github-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@v6 uses: actions/checkout@v4
- 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
-32
View File
@@ -1,32 +0,0 @@
name: 'Pre-check on PR'
permissions:
contents: read
on:
workflow_dispatch:
pull_request:
branches: dev
paths:
- 'src/**'
jobs:
pre-release:
name: 'Automatic pre-release build'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Install python 3.13
uses: actions/setup-python@v6
with:
python-version: '3.13'
- name: Install PlatformIO
run: |
pip install wheel
pip install -U platformio
- name: Run unit tests
run: |
platformio run -e native-test -t exec
+64
View File
@@ -0,0 +1,64 @@
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
- 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/*.*
+14 -13
View File
@@ -1,14 +1,12 @@
# 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
paths: # pull_request:
- 'src/**' # types: [opened, synchronize, reopened]
jobs: jobs:
build: build:
@@ -19,15 +17,18 @@ jobs:
BUILD_WRAPPER_OUT_DIR: bw-output BUILD_WRAPPER_OUT_DIR: bw-output
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v6 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Install Build Wrapper
uses: SonarSource/sonarqube-scan-action/install-build-wrapper@master - name: Install sonar-scanner and build-wrapper
- name: Run Build Wrapper uses: SonarSource/sonarcloud-github-c-cpp@v2
run: build-wrapper-linux-x86-64 --out-dir ${{ env.BUILD_WRAPPER_OUT_DIR }} make all
- name: SonarQube Scan - name: Run build-wrapper
uses: SonarSource/sonarqube-scan-action@master run: build-wrapper-linux-x86-64 --out-dir ${{ env.BUILD_WRAPPER_OUT_DIR }} make all
- 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"
-90
View File
@@ -1,90 +0,0 @@
name: 'Build stable release'
permissions:
contents: write
on:
workflow_dispatch:
push:
tags:
- 'v*'
jobs:
tagged-release:
name: 'Build Stable Release'
runs-on: ubuntu-latest
steps:
- name: Install python 3.13
uses: actions/setup-python@v6
with:
python-version: '3.13'
- name: Install Node.js 24
uses: actions/setup-node@v6
with:
node-version: 24
- name: Checkout repository
uses: actions/checkout@v6
- name: Enable Corepack
run: corepack enable pnpm
- name: Get the EMS-ESP version
id: build_info
run: |
version=`grep -E '^#define EMSESP_APP_VERSION' ./src/emsesp_version.h | awk -F'"' '{print $2}'`
echo "VERSION=$version" >> $GITHUB_OUTPUT
- name: Install PlatformIO
run: |
python -m pip install --upgrade pip
pip install -U platformio
python -m pip install intelhex
- name: Build webUI
run: |
platformio run -e build-webUI
- name: Build modbus
run: |
platformio run -e build_modbus
- name: Build standalone
run: |
platformio run -e build_standalone
- name: Build all PIO target environments, from default_envs
run: |
platformio run
- name: Create GitHub Release
uses: emsesp/action-automatic-releases@v1.0.1
with:
repo_token: '${{ secrets.GITHUB_TOKEN }}'
prerelease: false
files: |
CHANGELOG.md
./build/firmware/*.*
- name: Update version in Cloudflare KV store
if: github.repository == 'emsesp/EMS-ESP32'
env:
CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
CF_NAMESPACE_ID: ${{ secrets.CF_NAMESPACE_ID }}
CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
VERSION: ${{ steps.build_info.outputs.VERSION }}
run: |
JSON_DATA=$(jq -n \
--arg version "$VERSION" \
--arg date "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'{version: $version, date: $date}')
echo "JSON_DATA: $JSON_DATA"
curl -sS --fail-with-body \
-X PUT "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/storage/kv/namespaces/${CF_NAMESPACE_ID}/values/stable" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H "Content-Type: application/json" \
-d "$JSON_DATA"
echo
-27
View File
@@ -1,27 +0,0 @@
name: "Mark or close stale issues and PRs"
permissions:
contents: read
issues: write
pull-requests: write
on:
schedule:
- cron: "30 1 * * *"
jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v10
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-stale: 30
days-before-close: 5
stale-issue-message: "This issue is stale because it has been open 30 days with no activity. Remove stale label or comment otherwise this will be closed in 5 days."
stale-pr-message: "This PR has been automatically marked as stale because there has been no activity in last 30 days. It will be closed if no further activity occurs. Thank you for your contributions."
close-issue-message: "This issue was closed because it has been stalled for 5 days with no activity."
close-pr-message: "This PR was automatically closed because of being stale."
stale-pr-label: "stale"
stale-issue-label: "stale"
exempt-issue-labels: "bug,enhancement,pinned,security"
exempt-pr-labels: "bug,enhancement,pinned,security"
+55
View File
@@ -0,0 +1,55 @@
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
- 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/*.*
+23 -44
View File
@@ -1,69 +1,49 @@
name: 'Build test release' name: 'test-release'
on: on:
workflow_dispatch: workflow_dispatch:
push: push:
branches: branches:
- 'test' - 'dev2'
permissions:
contents: read
jobs: jobs:
pre-release: pre-release:
name: 'Build Test Release' name: 'Automatic test-release build'
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@v6
- name: Enable Corepack - name: Enable Corepack
run: corepack enable pnpm run: corepack enable
- 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/emsesp_version.h | awk -F'"' '{print $2}'` version=`grep -E '^#define EMSESP_APP_VERSION' ./src/version.h | awk -F'"' '{print $2}'`
echo "VERSION=$version" >> $GITHUB_OUTPUT 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: |
platformio run -e build-webUI cd interface
yarn install
- name: Build modbus yarn typesafe-i18n --no-watch
run: | sed -i "s/= 'pl'/= 'en'/" ./src/i18n/i18n-util.ts
platformio run -e build_modbus yarn build
yarn webUI
- name: Build standalone - name: Build all target environments from default_envs
run: |
platformio run -e build_standalone
- name: Build all PIO target environments, from default_envs
run: | run: |
platformio run platformio run
- name: Create GitHub Release - name: Create GitHub Release
id: 'automatic_releases' id: 'automatic_releases'
uses: emsesp/action-automatic-releases@v1.0.1 uses: emsesp/action-automatic-releases@v1.0.0
with: with:
repo_token: '${{ secrets.GITHUB_TOKEN }}' repo_token: '${{ secrets.GITHUB_TOKEN }}'
title: Test Build v${{steps.build_info.outputs.VERSION}} title: Test Build v${{steps.build_info.outputs.VERSION}}
@@ -72,4 +52,3 @@ jobs:
files: | files: |
CHANGELOG_LATEST.md CHANGELOG_LATEST.md
./build/firmware/*.* ./build/firmware/*.*
-40
View File
@@ -1,40 +0,0 @@
name: 'Update versions'
on:
workflow_dispatch:
permissions:
contents: write
jobs:
update-version:
name: 'Update versions in Cloudflare KV store'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Get and Send EMS-ESP version to Cloudflare
env:
CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
CF_NAMESPACE_ID: ${{ secrets.CF_NAMESPACE_ID }}
CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
run: |
version=$(grep -E '^#define EMSESP_APP_VERSION' ./src/emsesp_version.h | awk -F'"' '{print $2}')
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
KV_ENV="stable"
else
KV_ENV="dev"
fi
JSON_DATA=$(jq -n \
--arg version "$version" \
--arg date "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'{version: $version, date: $date}')
echo "KV_ENV: $KV_ENV"
echo "JSON_DATA: $JSON_DATA"
curl -sS --fail-with-body \
-X PUT "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/storage/kv/namespaces/${CF_NAMESPACE_ID}/values/${KV_ENV}" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H "Content-Type: application/json" \
-d "$JSON_DATA"
echo
+11 -18
View File
@@ -12,15 +12,17 @@ cppcheck.out.xml
# platformio # platformio
.pio .pio
pio_local.ini pio_local.ini
*_old
# OS specific # OS specific
.DS_Store .DS_Store
*Thumbs.db *Thumbs.db
# web specific # web specfic
build/ build/
dist/ dist/
/data/www /data/www
/lib/framework/WWWData.h
/interface/build /interface/build
node_modules node_modules
/interface/.eslintcache /interface/.eslintcache
@@ -28,10 +30,16 @@ stats.html
*.sln *.sln
*.sw? *.sw?
.pnp.* .pnp.*
*/.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
yarn.lock
analyse.html analyse.html
interface/vite.config.ts.timestamp* interface/vite.config.ts.timestamp*
*.local *.local
src/ESP32React/WWWData.h
# i18n generated files # i18n generated files
interface/src/i18n/i18n-react.tsx interface/src/i18n/i18n-react.tsx
@@ -63,19 +71,4 @@ words-found-verbose.txt
# sonarlint # sonarlint
compile_commands.json compile_commands.json
package.json
# other files
managed_components
dependencies.lock
CMakeLists.txt
.dummy/*
logs/*
sdkconfig.*
sdkconfig_tasmota_esp32
pnpm-lock.yaml
.cache/
interface/.tsbuildinfo
test/test_api/package-lock.json
.clangd
mklittlefs
.cursor
-6
View File
@@ -1,6 +0,0 @@
{
"MD033": false,
"MD013": false,
"MD045": false,
"MD041": false
}
+101
View File
@@ -0,0 +1,101 @@
{
"search.exclude": {
"**/.yarn": true,
"**/.pnp.*": true
},
"editor.codeActionsOnSave": {
"source.fixAll": "explicit"
},
"eslint.validate": [
"typescript"
],
"eslint.codeActionsOnSave.rules": null,
"eslint.nodePath": "interface/.yarn/sdks",
"eslint.workingDirectories": ["interface"],
"prettier.prettierPath": "",
"typescript.enablePromptUseWorkspaceTsdk": true,
"files.associations": {
"*.tsx": "typescriptreact",
"*.tcc": "cpp",
"optional": "cpp",
"istream": "cpp",
"ostream": "cpp",
"ratio": "cpp",
"system_error": "cpp",
"array": "cpp",
"functional": "cpp",
"regex": "cpp",
"tuple": "cpp",
"type_traits": "cpp",
"utility": "cpp",
"string": "cpp",
"string_view": "cpp",
"atomic": "cpp",
"bitset": "cpp",
"cctype": "cpp",
"chrono": "cpp",
"clocale": "cpp",
"cmath": "cpp",
"condition_variable": "cpp",
"cstdarg": "cpp",
"cstddef": "cpp",
"cstdint": "cpp",
"cstdio": "cpp",
"cstdlib": "cpp",
"cstring": "cpp",
"ctime": "cpp",
"cwchar": "cpp",
"cwctype": "cpp",
"deque": "cpp",
"list": "cpp",
"unordered_map": "cpp",
"unordered_set": "cpp",
"vector": "cpp",
"exception": "cpp",
"algorithm": "cpp",
"iterator": "cpp",
"map": "cpp",
"memory": "cpp",
"memory_resource": "cpp",
"numeric": "cpp",
"random": "cpp",
"set": "cpp",
"fstream": "cpp",
"initializer_list": "cpp",
"iomanip": "cpp",
"iosfwd": "cpp",
"iostream": "cpp",
"limits": "cpp",
"mutex": "cpp",
"new": "cpp",
"sstream": "cpp",
"stdexcept": "cpp",
"streambuf": "cpp",
"thread": "cpp",
"cinttypes": "cpp",
"typeinfo": "cpp"
},
"todo-tree.filtering.excludeGlobs": [
"**/vendor/**",
"**/node_modules/**",
"**/dist/**",
"**/bower_components/**",
"**/build/**",
"**/.vscode/**",
"**/.github/**",
"**/_output/**",
"**/*.min.*",
"**/*.map",
"**/ArduinoJson/**"
],
"cSpell.enableFiletypes": [
"ini",
"makefile"
],
"typescript.preferences.preferTypeOnlyAutoImports": true,
"sonarlint.pathToCompileCommands": "${workspaceFolder}/compile_commands.json",
"sonarlint.connectedMode.project": {
"connectionId": "emsesp",
"projectKey": "emsesp_EMS-ESP32"
}
}
+4 -257
View File
@@ -5,260 +5,7 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 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.3] 1 August 2026 ## [3.7.0] October 27 2024
## Added
- KM300 at address 0x4A [#3084](https://github.com/emsesp/EMS-ESP32/issues/3084)
## Fixed
- signed value for solarInfuence [#3077](https://github.com/emsesp/EMS-ESP32/issues/3077)
- set bin file upload limit to 1M again [#3086](https://github.com/emsesp/EMS-ESP32/issues/3086)
- check arithmetric operations on strings [#3127](https://github.com/emsesp/EMS-ESP32/discussions/3127)
- prevent system crash during WiFi network discovery on ESP32-S3 hardware [#3128](https://github.com/emsesp/EMS-ESP32/pull/3128)
- fix setting date/time on Junkers thermostats
- offset of hpminflowtemp [#3144](https://github.com/emsesp/EMS-ESP32/issues/3144)
- water circulation command [#3149](https://github.com/emsesp/EMS-ESP32/pull/3149)
- start scheduler after customEntities, delay scheduler loop after startup, handling of `system/restart` command [#3146](https://github.com/emsesp/EMS-ESP32/issues/3146)
- possible memory leaks fixed.
- repeated startup schedules compute value
- call `system/message` called from scheduler processes compute twice
- water module SM100/200 as dhw1/dhw2 [#3164](https://github.com/emsesp/EMS-ESP32/issues/3164)
## Changed
- call compute value direct, enlarge TCP stack [#3127](https://github.com/emsesp/EMS-ESP32/discussions/3127)
- Dewtemperature for Easycontrol calculated by ems-esp [#3135](https://github.com/emsesp/EMS-ESP32/issues/3135)
- add option for sync thermostat to ntp, allow different time zones [#3148](https://github.com/emsesp/EMS-ESP32/discussions/3148), **defaults to sync**.
- block too many GET requests [mentioned in #3104](https://github.com/emsesp/EMS-ESP32/issues/3104)
- set 10 sec timeout for http requests [#3104](https://github.com/emsesp/EMS-ESP32/issues/3104)
- language code "cs" [#3161](https://github.com/emsesp/EMS-ESP32/issues/3161)
## [3.8.2] 12 May 2026
## Added
- comfortpoint for BC400 [#2935](https://github.com/emsesp/EMS-ESP32/issues/2935)
- customize device brand [#2784](https://github.com/emsesp/EMS-ESP32/issues/2784)
- set model for ems-esp devices temperature, analog, etc. [#2958](https://github.com/emsesp/EMS-ESP32/discussions/2958)
- prometheus metrics for temperature/analog/scheduler/custom [#2962](https://github.com/emsesp/EMS-ESP32/issues/2962)
- boiler pumpkick [#2965](https://github.com/emsesp/EMS-ESP32/discussions/2965)
- heatpump reset [#2933](https://github.com/emsesp/EMS-ESP32/issues/2933)
- 2.nd freshwater module (dhw4, dhw5) [#2991](https://github.com/emsesp/EMS-ESP32/issues/2991)
- full system backup and restore
- auto-logic to set ht3/ems+ tx-mode
- polariity for digital_in sensors [#3070](https://github.com/emsesp/EMS-ESP32/discussions/3070)
## Fixed
- SRC climate creation [#2936](https://github.com/emsesp/EMS-ESP32/issues/2936) and [#2960](https://github.com/emsesp/EMS-ESP32/issues/2960)
- missing translations [#3015](https://github.com/emsesp/EMS-ESP32/issues/3015)
- custom entities check fetch length
- modbus initialization [#3064](https://github.com/emsesp/EMS-ESP32/issues/3064)
## Changed
- weblogbuffer up to 1000 messages with PSRAM, mentioned in [#2933](https://github.com/emsesp/EMS-ESP32/issues/2933)
- validate custom entity writes, [#2931](https://github.com/emsesp/EMS-ESP32/issues/2931)
- remove wrong burnMinPower [#2918](https://github.com/emsesp/EMS-ESP32/issues/2918)
- store scheduler active state to nvs [#2946](https://github.com/emsesp/EMS-ESP32/discussions/2946)
- translated modes `heat` and `eco` for HA-climate mode-str-tpl
- support `minflowtemp` and `baseflowtemp` [#2969](https://github.com/emsesp/EMS-ESP32/discussions/2969)
- update version if it is 00.00 in first read [#2981](https://github.com/emsesp/EMS-ESP32/issues/2981)
- device class for % values [#2980](https://github.com/emsesp/EMS-ESP32/issues/2980)
- fetch telegrams: set length to fetch [#3017](https://github.com/emsesp/EMS-ESP32/issues/3017)
- move http client from stack to heap
- heap optimizations [#3021](https://github.com/emsesp/EMS-ESP32/discussions/3021)
- check and read 0x470 as summer2_typeids[0] only if received [#2686](https://github.com/emsesp/EMS-ESP32/issues/2686), [#3055](https://github.com/emsesp/EMS-ESP32/issues/3055)
- default bus-id: gateway1(0x49), tx-mode: auto
## [3.8.1] 11 January 2026
## Added
- update time saved in nvs
- heatpump entities [#2883](https://github.com/emsesp/EMS-ESP32/issues/2883)
- HA input number format (mode) selectable box/slider (slider for max range 100) [#2900](https://github.com/emsesp/EMS-ESP32/discussions/2900)
## Fixed
- fix EMS bus disconnected errors on some systems [#2881](https://github.com/emsesp/EMS-ESP32/issues/2881)
- selflowtemp fix [#2876](https://github.com/emsesp/EMS-ESP32/issues/2876)
- updated valid GPIOs for ESP32S2, ESP32S3 and ESP32 that caused custom systems to block gpios [#2887](https://github.com/emsesp/EMS-ESP32/issues/2887)
- Junkers wwcharge offset [#2860](https://github.com/emsesp/EMS-ESP32/issues/2860)
- fixed minflowtemp [#2890](https://github.com/emsesp/EMS-ESP32/issues/2890)
- don't add HA uom/classes for bool values [#2885](https://github.com/emsesp/EMS-ESP32/issues/2885)
- fixed missing progress bar on web firmware uploads
## Changed
- snapshot gpios stored in temporary ram
- GPIOs stored along with the name and reported in log if conflicting
- free GPIOs depend on board profile [#2901](https://github.com/emsesp/EMS-ESP32/issues/2901)
- prefer PSram for mqtt queue [#2889](https://github.com/emsesp/EMS-ESP32/issues/2889)
- day schedule defult to all days, no day selected is not allowed
- board profile `CUSTOM` can only be selected in developer mode
- mqtt sends round values without decimals (`28` instead of `28.0`)
## [3.8.0] 31 December 2025
## Added
- analogsensor types: NTC and RGB-Led
- Flag for HMC310 [#2465](https://github.com/emsesp/EMS-ESP32/issues/2465)
- boiler auxheatersource [#2489](https://github.com/emsesp/EMS-ESP32/discussions/2489)
- thermostat last error for RC100/300 [#2501](https://github.com/emsesp/EMS-ESP32/issues/2501)
- boiler 0xC6 telegram [#1963](https://github.com/emsesp/EMS-ESP32/issues/1963)
- CS6800i changes [#2448](https://github.com/emsesp/EMS-ESP32/issues/2448), [#2449](https://github.com/emsesp/EMS-ESP32/issues/2449)
- charging pump [#2544](https://github.com/emsesp/EMS-ESP32/issues/2544)
- hybrid CSH5800iG [#2569](https://github.com/emsesp/EMS-ESP32/issues/2569)
- added EMS Device details to Home Assistant MQTT Discovery
- disinfection command [#2601](https://github.com/emsesp/EMS-ESP32/issues/2601)
- added new board profile for upcoming BBQKees E32V2.2
- set differential pressure entity in Mixer device
- set set climate action cooling/heating in HA [#2583](https://github.com/emsesp/EMS-ESP32/issues/2583)
- Internal sensors of E32V2_2
- FW200 display options [#2610](https://github.com/emsesp/EMS-ESP32/discussions/2610)
- CR11 mode settings OFF/MANUAL depends on selTemp [#2437](https://github.com/emsesp/EMS-ESP32/issues/2437)
- implemented eFuse settings for BBQKees boards to store model type and ESP chipset
- analogsensors for pulse output [#2624](https://github.com/emsesp/EMS-ESP32/discussions/2624)
- analogsensors frequency input [#2631](https://github.com/emsesp/EMS-ESP32/discussions/2631)
- SRC plus thermostats [#2636](https://github.com/emsesp/EMS-ESP32/issues/2636)
- Greenstar 2000 [#2645](https://github.com/emsesp/EMS-ESP32/issues/2645)
- RC3xx `dhw modetype` [#2659](https://github.com/emsesp/EMS-ESP32/discussions/2659)
- new boiler entities VR0,VR1, compressor speed [#2669](https://github.com/emsesp/EMS-ESP32/issues/2669)
- solar temperature TS16 [#2690](https://github.com/emsesp/EMS-ESP32/issues/2690)
- pumpmode enum for HT3 boilers, add commands for manual defrost, chimneysweeper [#2727](https://github.com/emsesp/EMS-ESP32/issues/2727)
- pid settings [#2735](https://github.com/emsesp/EMS-ESP32/issues/2735)
- refresh MQTT button added to MQTT Settings page
- heating assistance, rounding custum settings [#2763](https://github.com/emsesp/EMS-ESP32/discussions/2763)
- added counter 0..2 for short pulses, high frequency [#2758](https://github.com/emsesp/EMS-ESP32/issues/2758)
- added LWT (Last Will and Testament) to MQTT entities in Home Assistant
- added api/metrics endpoint for prometheus integration by @gr3enk [#2774](https://github.com/emsesp/EMS-ESP32/pull/2774)
- added RTL8201 to eth phy list [#2800](https://github.com/emsesp/EMS-ESP32/issues/2800)
- added partitions to Web UI Version page, so previous firmware versions can be installed [#2837](https://github.com/emsesp/EMS-ESP32/issues/2837)
- button pressures show LED. On a long press (10 seconds) the LED flashes for 5 seconds to indicate a factory reset is about to happen. [#2848](https://github.com/emsesp/EMS-ESP32/issues/2848)
- added `txpause` command to pause the TX, by setting Txmode to 0 (disabled) [#2850](https://github.com/emsesp/EMS-ESP32/issues/2850)
## Fixed
- dhw/switchtime [#2490](https://github.com/emsesp/EMS-ESP32/issues/2490)
- switch to secure mqtt [#2492](https://github.com/emsesp/EMS-ESP32/issues/2492)
- update link buttons [#2497](https://github.com/emsesp/EMS-ESP32/issues/2497)
- refresh scheduler states [#2502](https://github.com/emsesp/EMS-ESP32/discussions/2502)
- also rebuild HA config on mqtt connect for scheduler, custom and shower
- FB100 controls the hc, not the master [#2510](https://github.com/emsesp/EMS-ESP32/issues/2510)
- IPM DHW module, [#2524](https://github.com/emsesp/EMS-ESP32/issues/2524)
- charge optimization [#2543](https://github.com/emsesp/EMS-ESP32/issues/2543)
- shower active state retained, shows correctly in HA
- MQTT Command Topic with slashes [#2571](https://github.com/emsesp/EMS-ESP32/issues/2571)
- Add pulsed water meter input to V1.3 gateway with Lilygo S3 [#2550](https://github.com/emsesp/EMS-ESP32/issues/2550)
- fix missing long 10-second press of Button to perform a factory reset
- fix wwMaxPower on Junkers ZBS14 [#2609](https://github.com/emsesp/EMS-ESP32/issues/2609)
- ventilation bypass state from telegram 0x55C [#1197](https://github.com/emsesp/EMS-ESP32/issues/1197)
- set selflowtemp for ems+ boilers [#2641](https://github.com/emsesp/EMS-ESP32/discussions/2641)
- syslog timestamp [#2704](https://github.com/emsesp/EMS-ESP32/issues/2704)
- fixed FS format command [#2720](https://github.com/emsesp/EMS-ESP32/discussions/2720)
- dhw priority setting to boiler and mixer, telegrams 0x2CC, 0x2CD, etc.
- check for valid GPIOs when board profile is changed [#2841](https://github.com/emsesp/EMS-ESP32/issues/2841)
## Changed
- show console log with ISO date/time [#2533](https://github.com/emsesp/EMS-ESP32/discussions/2533)
- removed ESP32 CPU temperature
- updated core libraries like AsyncTCP, AsyncWebServer and Modbus
- remove command `scan deep`
- ignore repeated `forceheatingoff` commands [#2641](https://github.com/emsesp/EMS-ESP32/discussions/2641)
- optimized web for better performance by adding lazy loading and caching
- internal system analog sensors (core_voltage, supply_voltage and gateway_temperature) cannot be accidentally removed
- double click button reconnects EMS-ESP to AP
- place system message command in side scheduler loop to reduce stack memory usage by 2KB
- syslog mark interval set to 1 hour
- handle process_telegram in oneloop
- improved GPIO validation for Analog Sensors and System GPIOs
- entities with no values are greyed out in the Web UI in the Customization page
- added System Status to Web Status page
- show number on entities and supported languages in log on boot
- on tx read fail delay the 3rd. retry 2 sec
- move vectors and lists to PSRAM
- removed unused last topic/payload echo-check
- added Home Assistant device details to MQTT Discovery for all devices
- device_class and state_class changes for HA MQTT Discovery [#2825](https://github.com/emsesp/EMS-ESP32/issues/2825)
## [3.7.2] 22 March 2025
## Added
- change enum_heatingtype for remote control [#2268](https://github.com/emsesp/EMS-ESP32/issues/2268)
- system service commands [#2182](https://github.com/emsesp/EMS-ESP32/issues/2182)
- read 0x02A5 for thermostat CT200 [#2277](https://github.com/emsesp/EMS-ESP32/issues/2277)
- add "duplicate" option to Custom Entities [#2266](https://github.com/emsesp/EMS-ESP32/discussion/2266)
- mask bits for bool custom entities
- thermostat `reduce threshold` [#2288](https://github.com/emsesp/EMS-ESP32/issues/2288)
- thermostat `absent` [#1957](https://github.com/emsesp/EMS-ESP32/issues/1957)
- CR11 thermostat [#2295](https://github.com/emsesp/EMS-ESP32/issues/2295)
- Show ESP32's CPU temp in Hardware Status
- vacation mode for the CR50 [#2403](https://github.com/emsesp/EMS-ESP32/issues/2403)
- new Console command "set admin password" to set WebUI admin password
- support nested conditions in scheduler [#2451](https://github.com/emsesp/EMS-ESP32/issues/2451)
- allow mixed case in scheduler expressions [#2457](https://github.com/emsesp/EMS-ESP32/issues/2457)
- Suprapur-o [#2470](https://github.com/emsesp/EMS-ESP32/issues/2470)
## Fixed
- long numbers of custom entities [#2267](https://github.com/emsesp/EMS-ESP32/issues/2267)
- modbus command path to `api/` [#2276](https://github.com/emsesp/EMS-ESP32/issues/2276)
- info command for devices without entity-commands [#2274](https://github.com/emsesp/EMS-ESP32/issues/2274)
- CW100 settings telegram 0x241 [#2290](https://github.com/emsesp/EMS-ESP32/issues/2290)
- modbus signed 8bit values [#2294](https://github.com/emsesp/EMS-ESP32/issues/2294)
- thermostat date [#2313](https://github.com/emsesp/EMS-ESP32/issues/2313)
- Updated unknown compressor stati "enum_hpactivity" [#2311](https://github.com/emsesp/EMS-ESP32/pull/2311)
- Underline Tab headers in WebUI
- console unit tests fixed due to changed shell output
- tx-queue overflow in some heatpump systems [#2455](https://github.com/emsesp/EMS-ESP32/issues/2455)
## Changed
- show operation in pretty telegram between src and dst [#2263](https://github.com/emsesp/EMS-ESP32/discussions/2263)
- update eModbus to 1.7.2 [#2254](https://github.com/emsesp/EMS-ESP32/issues/2254)
- modbus timeout default to 300 sec, change setting from ms to sec [#2254](https://github.com/emsesp/EMS-ESP32/issues/2254)
- update AsyncTCP and ESPAsyncWebServer to latest versions
- update Arduino pio platform to 3.10.0 and optimized flash using build flags
- Version checker in WebUI improved
- rename `remoteseltemp` to `cooltemp` [#2456](https://github.com/emsesp/EMS-ESP32/issues/2456)
## [3.7.1] 29 November 2024
## Added
- include HA "unit_of_meas", "stat_cla" and "dev_cla" attributes for Number sensors [#2149](https://github.com/emsesp/EMS-ESP32/issues/2149)
- Bosch CS6800i AW - Silent Mode + Electrical Power Reduction (HP) [#2147](https://github.com/emsesp/EMS-ESP32/issues/2147)
- `/api/system/showeralert` and `/api/system/showertimer` [#2182](https://github.com/emsesp/EMS-ESP32/issues/2182)
- MX400 [#2198](https://github.com/emsesp/EMS-ESP32/issues/2198)
- SM200 values [#2212](https://github.com/emsesp/EMS-ESP32/discussions/2212)
## Fixed
- Modbus integration in 3.7.0 missing offset [#2148](https://github.com/emsesp/EMS-ESP32/issues/2148)
- fix changing TZ in NTPsettings without clearing enable+server, added DST support [#2142](https://github.com/emsesp/EMS-ESP32/issues/2142)
- Support MQTT Discovery (AD) with Domoticz [#2177](https://github.com/emsesp/EMS-ESP32/issues/2177)
- wwExtra (dhw extra) changed from temperature reading to number
- auxheaterstatus [#2192](https://github.com/emsesp/EMS-ESP32/issues/2192)
- lastCode character check [#2189](https://github.com/emsesp/EMS-ESP32/issues/2189)
- reading too many telegram parts
- heatpump cost UOMs [#2188](https://github.com/emsesp/EMS-ESP32/issues/2188)
- analog dac output and inputs on dac pins [#2201](https://github.com/emsesp/EMS-ESP32/discussions/2201)
- api memory leak [#2216](https://github.com/emsesp/EMS-ESP32/issues/2216)
- modbus multiple mixers [#2229](https://github.com/emsesp/EMS-ESP32/issues/2229)
- Last Will (LWT) not set on MQTT Connect [#2247](https://github.com/emsesp/EMS-ESP32/issues/2247)
## Changed
- name of wwstarts2 [#2217](https://github.com/emsesp/EMS-ESP32/discussions/2217)
## [3.7.0] 27 October 2024
## **IMPORTANT! BREAKING CHANGES with 3.6.5** ## **IMPORTANT! BREAKING CHANGES with 3.6.5**
@@ -267,7 +14,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 [emsesp.org](https://emsesp.org/). For more details go to [www.emsesp.org](https://www.emsesp.org/).
## Added ## Added
@@ -399,7 +146,7 @@ For more details go to [emsesp.org](https://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
@@ -975,4 +722,4 @@ There are breaking changes between 3.5.x and earlier versions of 3.6.0. Please r
- some names of mqtt-tags like in v2.2.1 - some names of mqtt-tags like in v2.2.1
- new ESP32 partition side to allow for smoother OTA and fallback - new ESP32 partition side to allow for smoother OTA and fallback
- Network Gateway IP is optional (#682)emsesp/EMS-ESP - Network Gateway IP is optional (#682)emsesp/EMS-ESP
- moved to a new GitHub repo <https://github.com/emsesp/EMS-ESP32> - moved to a new GitHub repo <https://github.com/emsesp/EMS-ESP32>
-40
View File
@@ -1,41 +1 @@
# Changelog # Changelog
For more details go to [emsesp.org](https://emsesp.org/).
## [3.9.0]
This release is based on the latest Espressif/Arduino core version 3. It brings in many memory and performance optimizations. Note it does require the user to manually migrate settings from 3.8.x to 3.9.0.
## Added
- user-requested LED blink [#3063](https://github.com/emsesp/EMS-ESP32/issues/3063)
- Commands Service that can be called via MQTT or API or used in the Scheduler Service
- option to disable factory reset [#3150](https://github.com/emsesp/EMS-ESP32/issues/3150)
- TLS support with 4MB boards without PSRAM
- added eth_10mbit to Network Settings, which forces 10BASE-T half-duplex & autonegotiation off, halving the PHYs power draw. Its off by default as it will cause issues on managed switches with a port forced to 100Mb [#3213](https://github.com/emsesp/EMS-ESP32/pull/3213)
## Fixed
- shunting yard show json
- `system/sendmail` called from a Command or the Scheduler computed its value twice, which stripped the quotes from literal text and then failed on characters like `!`. Also shunting yard treated a `?` inside a quoted string as a ternary, so text like `"too hot?"` returned nothing
- memory leak when using different timezones for ems-esp and thermostat[#3184](https://github.com/emsesp/EMS-ESP32/issues/3184)
- LED stayed off on a healthy system when "Disable LED" was unchecked
- Ethernet MAC address changed with the new SDK, breaking DHCP reservations (currently disabled)
- "IPv4 nameserver" showed an IPv6 address when IPv6 was in use
- HA Discovery warning on Uptime after EMS-ESP boot due to NTP not ready
- hc/control setting for UI800 thermostats [#3181](https://github.com/emsesp/EMS-ESP32/discussions/3181)
## Changed
- various memory optimizations [#3083](https://github.com/emsesp/EMS-ESP32/issues/3083)
- Scheduler name is now mandatory
- network fallback to AP only after start [#3090](https://github.com/emsesp/EMS-ESP32/issues/3090)
- replaced Web async-validator with custom validator and toast with native snackbar to reduce bundle size
- Gateway and Connect devices are shown in the Devices page, but disabled [3126](https://github.com/emsesp/EMS-ESP32/discussions/3126)
- show control setting only for master thermostats (0x10) [#3173](https://github.com/emsesp/EMS-ESP32/issues/3173)
- remove devices without entities not listed in 0x07 telegram
- changes to the Dialogs in Dashboard page, removed the run icon in the Commands page. Renamed Execute to Run.
- set `None` for undefined values in HA `val_tpl`
- changes in WebUI - remove multiple calls systemInfo endpoint, Vewrsion pages checks for internet connection [#3195](https://github.com/emsesp/EMS-ESP32/pull/3195)
- multiple optimization in network, power down WiFi when Ethernet and WiFi nosleep is instant when selected [#3213](https://github.com/emsesp/EMS-ESP32/pull/3213)
+2 -2
View File
@@ -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
+39 -95
View File
@@ -1,40 +1,10 @@
# #
# GNUMakefile for EMS-ESP # GNUMakefile for EMS-ESP
# This is mainly used to generate the .o files for SonarQube analysis
# #
_mkfile_path := $(abspath $(lastword $(MAKEFILE_LIST))) # NUMJOBS=${NUMJOBS:-" -j10 "}
I := $(patsubst %/,%,$(dir $(_mkfile_path))) # MAKEFLAGS+="j "
ifneq ($(words $(MAKECMDGOALS)),1)
.DEFAULT_GOAL = all
%:
@$(MAKE) $@ --no-print-directory -rRf $(firstword $(MAKEFILE_LIST))
else
ifndef ECHO
T := $(shell $(MAKE) $(MAKECMDGOALS) --no-print-directory \
-nrRf $(firstword $(MAKEFILE_LIST)) \
ECHO="COUNTTHIS" | grep -c "COUNTTHIS")
N := x
C = $(words $N)$(eval N := x $N)
ECHO = python3 $(I)/scripts/echo_progress.py --stepno=$C --nsteps=$T
endif
# Optimize parallel build configuration
UNAME_S := $(shell uname -s)
JOBS ?= 1
ifeq ($(UNAME_S),Linux)
EXTRA_CPPFLAGS = -D LINUX
JOBS := $(shell nproc)
endif
ifeq ($(UNAME_S),Darwin)
EXTRA_CPPFLAGS = -D OSX -Wno-tautological-constant-out-of-range-compare
JOBS := $(shell sysctl -n hw.ncpu)
endif
# Set optimal parallel build settings
MAKEFLAGS += -j$(JOBS) -l$(shell echo $$(($(JOBS) * 2)))
# $(info Number of jobs: $(JOBS))
#---------------------------------------------------------------------- #----------------------------------------------------------------------
# Project Structure # Project Structure
@@ -45,29 +15,35 @@ MAKEFLAGS += -j$(JOBS) -l$(shell echo $$(($(JOBS) * 2)))
# INCLUDES is a list of directories containing header files # INCLUDES is a list of directories containing header files
# LIBRARIES is a list of directories containing libraries, this must be the top level containing include and lib # LIBRARIES is a list of directories containing libraries, this must be the top level containing include and lib
#---------------------------------------------------------------------- #----------------------------------------------------------------------
#TARGET := $(notdir $(CURDIR))
TARGET := emsesp TARGET := emsesp
BUILD := build BUILD := build
SOURCES := src/core src/devices src/web src/test lib_standalone lib/espMqttClient/src lib/espMqttClient/src/* lib/ArduinoJson/src lib/uuid-common/src lib/uuid-console/src lib/uuid-log/src lib/PButton SOURCES := src src/* lib_standalone lib/uuid-common/src lib/uuid-console/src lib/uuid-log/src src/devices lib/ArduinoJson/src lib/PButton lib/semver lib/espMqttClient/src lib/espMqttClient/src/*
INCLUDES := src/core src/devices src/web src/test lib_standalone lib/* lib/espMqttClient/src lib/espMqttClient/src/Transport lib/ArduinoJson/src lib/uuid-common/src lib/uuid-console/src lib/uuid-log/src lib/uuid-telnet/src lib/uuid-syslog/src INCLUDES := src lib_standalone 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 lib/semver lib/* src/devices
LIBRARIES := LIBRARIES :=
CPPCHECK = cppcheck CPPCHECK = cppcheck
CHECKFLAGS = -q --force --std=gnu++20 # CHECKFLAGS = -q --force --std=c++17
CHECKFLAGS = -q --force --std=c++11
#---------------------------------------------------------------------- #----------------------------------------------------------------------
# Languages Standard # Languages Standard
#---------------------------------------------------------------------- #----------------------------------------------------------------------
C_STANDARD := -std=c20 C_STANDARD := -std=c17
CXX_STANDARD := -std=gnu++20 CXX_STANDARD := -std=gnu++14
# C_STANDARD := -std=c11
# CXX_STANDARD := -std=c++11
#---------------------------------------------------------------------- #----------------------------------------------------------------------
# Defined Symbols # Defined Symbols
#---------------------------------------------------------------------- #----------------------------------------------------------------------
DEFINES += -DARDUINOJSON_ENABLE -DARDUINOJSON_ENABLE_ARDUINO_STRING -DARDUINOJSON_USE_DOUBLE=0 DEFINES += -DARDUINOJSON_ENABLE -DARDUINOJSON_ENABLE_ARDUINO_STRING -DARDUINOJSON_USE_DOUBLE=0
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_BOARD_PROFILE=\"S32S3\" DEFAULTS = -DEMSESP_DEFAULT_LOCALE=\"en\" -DEMSESP_DEFAULT_TX_MODE=8 -DEMSESP_DEFAULT_VERSION=\"3.7.0-dev\" -DEMSESP_DEFAULT_BOARD_PROFILE=\"S32\"
#---------------------------------------------------------------------- #----------------------------------------------------------------------
# Sources & Files # Sources & Files
@@ -75,25 +51,16 @@ DEFAULTS = -DEMSESP_DEFAULT_LOCALE=\"en\" -DEMSESP_DEFAULT_BOARD_PROFILE=\"S32S3
OUTPUT := $(CURDIR)/$(TARGET) OUTPUT := $(CURDIR)/$(TARGET)
SYMBOLS := $(CURDIR)/$(BUILD)/$(TARGET).out SYMBOLS := $(CURDIR)/$(BUILD)/$(TARGET).out
# Optimize source discovery - use shell find for better performance CSOURCES := $(foreach dir,$(SOURCES),$(wildcard $(dir)/*.c))
CSOURCES := $(shell find $(SOURCES) -name "*.c" 2>/dev/null) CXXSOURCES := $(foreach dir,$(SOURCES),$(wildcard $(dir)/*.cpp))
CXXSOURCES := $(shell find $(SOURCES) -name "*.cpp" 2>/dev/null)
# Exclude files not needed for standalone build, if they exist OBJS := $(patsubst %,$(BUILD)/%.o,$(basename $(CSOURCES)) $(basename $(CXXSOURCES)) )
CSOURCES := $(filter-out src/core/ModuleLibrary.c,$(CSOURCES)) DEPS := $(patsubst %,$(BUILD)/%.d,$(basename $(CSOURCES)) $(basename $(CXXSOURCES)) )
CXXSOURCES := $(filter-out src/core/ModuleLibrary.cpp,$(CXXSOURCES))
OBJS := $(patsubst %,$(BUILD)/%.o,$(basename $(CSOURCES)) $(basename $(CXXSOURCES))) INCLUDE += $(addprefix -I,$(foreach dir,$(INCLUDES), $(wildcard $(dir))))
DEPS := $(patsubst %,$(BUILD)/%.d,$(basename $(CSOURCES)) $(basename $(CXXSOURCES))) INCLUDE += $(addprefix -I,$(foreach dir,$(LIBRARIES),$(wildcard $(dir)/include)))
# Optimize include path discovery LDLIBS += $(addprefix -L,$(foreach dir,$(LIBRARIES),$(wildcard $(dir)/lib)))
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))
# 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
@@ -110,18 +77,14 @@ CXX := /usr/bin/g++
# LDFLAGS Linker Flags # LDFLAGS Linker Flags
#---------------------------------------------------------------------- #----------------------------------------------------------------------
CPPFLAGS += $(DEFINES) $(DEFAULTS) $(INCLUDE) CPPFLAGS += $(DEFINES) $(DEFAULTS) $(INCLUDE)
CPPFLAGS += -ggdb -g3 -MMD CPPFLAGS += -ggdb
CPPFLAGS += -flto=auto CPPFLAGS += -g3
CPPFLAGS += -Wall -Wextra -Werror CPPFLAGS += -Os
CPPFLAGS += -Wno-unused-parameter -Wno-missing-braces -Wno-vla-cxx-extension -Wno-switch-enum -Wno-unused-lambda-capture
CPPFLAGS += -ffunction-sections -fdata-sections -fno-exceptions -fno-rtti -fno-threadsafe-statics
CPPFLAGS += -Os -DNDEBUG
CPPFLAGS += $(EXTRA_CPPFLAGS)
CFLAGS += $(CPPFLAGS) CFLAGS += $(CPPFLAGS)
CXXFLAGS += $(CPPFLAGS) CFLAGS += -Wall -Wextra -Werror -Wswitch-enum
LDFLAGS = CFLAGS += -Wno-tautological-constant-out-of-range-compare -Wno-unused-parameter -Wno-inconsistent-missing-override -Wno-missing-braces -Wno-unused-lambda-capture -Wno-sign-compare
CXXFLAGS += $(CFLAGS) -MMD
#---------------------------------------------------------------------- #----------------------------------------------------------------------
# Compiler & Linker Commands # Compiler & Linker Commands
@@ -136,13 +99,11 @@ else
LD := $(CXX) LD := $(CXX)
endif endif
# Dependency file generation #DEPFLAGS += -MF $(BUILD)/$*.d
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
@@ -155,10 +116,7 @@ COMPILE.s = $(CC) $(CFLAGS) $(DEPFLAGS) -c $< -o $@
.SUFFIXES: .SUFFIXES:
.INTERMEDIATE: .INTERMEDIATE:
.PRECIOUS: $(OBJS) $(DEPS) .PRECIOUS: $(OBJS) $(DEPS)
.PHONY: all clean help cppcheck run .PHONY: all clean help
# Enable second expansion for more flexible rules
.SECONDEXPANSION:
#---------------------------------------------------------------------- #----------------------------------------------------------------------
# Targets # Targets
@@ -167,27 +125,23 @@ COMPILE.s = $(CC) $(CFLAGS) $(DEPFLAGS) -c $< -o $@
.SILENT: $(OUTPUT) .SILENT: $(OUTPUT)
all: $(OUTPUT) all: $(OUTPUT)
@$(ECHO) Build complete.
$(OUTPUT): $(OBJS) $(OUTPUT): $(OBJS)
@mkdir -p $(@D) @mkdir -p $(@D)
@$(ECHO) Linking $@
$(LINK.o) $(LINK.o)
$(SYMBOLS.out)
$(BUILD)/%.o: %.c $(BUILD)/%.o: %.c
@mkdir -p $(@D) @mkdir -p $(@D)
@$(ECHO) Compiling $@ $(COMPILE.c)
@$(COMPILE.c)
$(BUILD)/%.o: %.cpp $(BUILD)/%.o: %.cpp
@mkdir -p $(@D) @mkdir -p $(@D)
@$(ECHO) Compiling $@ $(COMPILE.cpp)
@$(COMPILE.cpp)
$(BUILD)/%.o: %.s $(BUILD)/%.o: %.s
@mkdir -p $(@D) @mkdir -p $(@D)
@$(ECHO) Compiling $@ $(COMPILE.s)
@$(COMPILE.s)
cppcheck: $(SOURCES) cppcheck: $(SOURCES)
$(CPPCHECK) $(CHECKFLAGS) $^ $(CPPCHECK) $(CHECKFLAGS) $^
@@ -196,21 +150,11 @@ run: $(OUTPUT)
@$< @$<
.PHONY: clean .PHONY: clean
clean: clean:
@$(RM) -rf $(BUILD) $(OUTPUT) @$(RM) -rf $(BUILD) $(OUTPUT)
help: help:
@echo "Available targets:" @echo available targets: all run clean
@echo " all - Build the project (default)" @echo $(OUTPUT)
@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)
endif
+24 -52
View File
@@ -1,30 +1,4 @@
<div align="center"> # ![logo](media/EMS-ESP_logo_dark.png)
<p align="center">
<a href="#">
<img src="https://raw.githubusercontent.com/emsesp/EMS-ESP32/dev/media/favicon/android-chrome-512x512.png" height="100px" />
</a>
</p>
</div>
<h1 align="center">EMS-ESP</h1>
<p align="center">
<a href="https://emsesp.org">
<img src="https://img.shields.io/badge/Website-0077b5?style=for-the-badge&logo=googlehome&logoColor=white" alt="Website" />
</a>
<a href="https://github.com/emsesp/EMS-ESP32/blob/dev/CONTRIBUTING.md">
<img src="https://img.shields.io/badge/Contribute-ff4785?style=for-the-badge&logo=git&logoColor=white" alt="Contribute" />
</a>
<a href="https://emsesp.org">
<img src="https://img.shields.io/badge/Documentation-0077b5?style=for-the-badge&logo=googledocs&logoColor=white" alt="Guides" />
</a>
<a href="https://discord.gg/GP9DPSgeJq">
<img src="https://img.shields.io/badge/Discord-7289da?style=for-the-badge&logo=discord&logoColor=white" alt="Discord" />
</a>
<a href="https://github.com/emsesp/EMS-ESP32/blob/main/CHANGELOG.md">
<img src="https://img.shields.io/badge/Changelog-6c5ce7?style=for-the-badge&logo=git&logoColor=white" alt="Changelog" />
</a>
</p>
[![version](https://img.shields.io/github/release/emsesp/EMS-ESP32.svg?label=Latest%20Release)](https://github.com/emsesp/EMS-ESP32/blob/main/CHANGELOG.md) [![version](https://img.shields.io/github/release/emsesp/EMS-ESP32.svg?label=Latest%20Release)](https://github.com/emsesp/EMS-ESP32/blob/main/CHANGELOG.md)
[![release-date](https://img.shields.io/github/release-date/emsesp/EMS-ESP32.svg?label=Released)](https://github.com/emsesp/EMS-ESP32/commits/main) [![release-date](https://img.shields.io/github/release-date/emsesp/EMS-ESP32.svg?label=Released)](https://github.com/emsesp/EMS-ESP32/commits/main)
@@ -32,19 +6,17 @@
[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=emsesp_EMS-ESP32&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=emsesp_EMS-ESP32) [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=emsesp_EMS-ESP32&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=emsesp_EMS-ESP32)
[![Codacy Badge](https://app.codacy.com/project/badge/Grade/9441142f49424ef891e8f5251866ee6b)](https://app.codacy.com/gh/emsesp/EMS-ESP32/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade) [![Codacy Badge](https://app.codacy.com/project/badge/Grade/9441142f49424ef891e8f5251866ee6b)](https://app.codacy.com/gh/emsesp/EMS-ESP32/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade)
[![downloads](https://img.shields.io/github/downloads/emsesp/EMS-ESP32/total.svg)](https://github.com/emsesp/EMS-ESP32/releases) [![downloads](https://img.shields.io/github/downloads/emsesp/EMS-ESP32/total.svg)](https://github.com/emsesp/EMS-ESP32/releases)
[![chat](https://img.shields.io/discord/816637840644505620.svg?style=flat-square&color=blueviolet)](https://discord.gg/GP9DPSgeJq) [![chat](https://img.shields.io/discord/816637840644505620.svg?style=flat-square&color=blueviolet)](https://discord.gg/3J3GgnzpyT)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/emsesp/EMS-ESP32)
[![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-ESP32/network) [![GitHub forks](https://img.shields.io/github/forks/emsesp/EMS-ESP32.svg?style=social&label=Fork)](https://github.com/emsesp/EMS-ES32P/network)
[![donate](https://img.shields.io/badge/donate-PayPal-blue.svg)](https://www.paypal.com/paypalme/prderbyshire/2) [![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.
It requires a small circuit to interface with the EMS bus which can be purchased from <https://bbqkees-electronics.nl>. These gateways are tested thoroughly and certified to work with EMS-ESP. It requires a small circuit to interface with the EMS bus which can be purchased from <https://bbqkees-electronics.nl> or custom built.
## **Key Features**
## 📦&nbsp; **Key Features**
- Compatible with EMS, EMS+, EMS2, EMS Plus, Logamatic EMS, Junkers 2-wire, Heatronic 3 and 4 - Compatible with EMS, EMS+, EMS2, EMS Plus, Logamatic EMS, Junkers 2-wire, Heatronic 3 and 4
- Supporting over 120 different EMS compatible devices such as thermostats, boilers, heat pumps, mixing units, solar modules, connect modules, ventilation units, switches and more - Supporting over 120 different EMS compatible devices such as thermostats, boilers, heat pumps, mixing units, solar modules, connect modules, ventilation units, switches and more
@@ -60,43 +32,43 @@ It requires a small circuit to interface with the EMS bus which can be purchased
- A powerful Scheduler to automate tasks and trigger events based data changes - A powerful Scheduler to automate tasks and trigger events based data changes
- A Notification service to alert you of important events - A Notification service to alert you of important events
## 🚀&nbsp; **Installing** ## **Installing**
Head over to the [Installation Guide](https://emsesp.org/Installing) section of the documentation for instructions on how to install EMS-ESP. For a quick install of the latest stable release go to [https://install.emsesp.org](https://install.emsesp.org). For other methods of installing and upgrading, and switching over to the development version go to [this section](https://emsesp.org/Getting-Started/#first-time-install) in the documentation.
## 📋&nbsp; **Documentation** If you're upgrading a BBQKees Electronics EMS Gateway and unsure which firmware to use, please refer to the [this overview](https://emsesp.org/Getting-Started/#bbqkees-electronics-ems-gateway).
Visit [emsesp.org](https://emsesp.org) for more details on how to setup and configure EMS-ESP. You'll also find more a collection of example configuarations, Frequently Asked Questions and Troubleshooting tips. ## **Documentation**
## 💬&nbsp; **Getting Support** 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.
To chat with the community reach out on our [Discord Server](https://discord.gg/GP9DPSgeJq). ## **Getting Support**
If you find an issue or have a request, see the [Getting Support](https://emsesp.org/Support/) section of the documentation. Note if you are using a non-BBQKees EMS gateway, you may need to contact the manufacturer for support. To chat with the community reach out on our [Discord Server](https://discord.gg/3J3GgnzpyT).
## 🎥&nbsp; **Live Demo** If you find an issue or have a request, see [here](https://emsesp.org/Support/) on how to submit a bug report or feature request.
To see a live demo go to [demo.emsesp.org](https://demo.emsesp.org). Pick a language and use any username and password to log in. Note whast you're seeing is static example data so not all features are operational. ## **Live Demo**
## 💖&nbsp; **Contributors** For a live demo go to [demo.emsesp.org](https://demo.emsesp.org). Pick a language from the sign on page and log in with any username or password. Note not all features are operational as it's based on static data.
EMS-ESP is a project originally created by [proddy](https://github.com/proddy) and maintained by the ems-esp community. ## **Contributors**
EMS-ESP is a project created by [proddy](https://github.com/proddy) and owned and maintained by both [proddy](https://github.com/proddy) and [MichaelDvP](https://github.com/MichaelDvP) with support from [BBQKees Electronics](https://bbqkees-electronics.nl).
You can contact us using [this form](https://emsesp.org/Contact/).
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** ## **Libraries used**
See the [Building the firmware](https://emsesp.org/Building) guide in the documentation for instructions on how to build EMS-ESP from this source code. - [esp8266-react](https://github.com/rjwats/esp8266-react) by @rjwats for the core framework that provides the Web UI, which has been heavily modified
## 📢&nbsp; **Libraries used**
- [esp8266-react](https://github.com/rjwats/esp8266-react) originally by @rjwats for the core framework that provides the Web UI, which has been heavily modified
- [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](https://github.com/ESP32Async/ESPAsyncWebServer) and [AsyncTCP](https://github.com/ESP32Async/AsyncTCP) for the Web server - ESPAsyncWebServer and AsyncTCP for the Web server and TCP backends, with custom modifications for performance
## 📜&nbsp; **License** ## **License**
This program is licensed under GPL-3.0 This program is licensed under GPL-3.0
-5
View File
@@ -1,5 +0,0 @@
# Security Policy
## Reporting a Vulnerability
Please report any security vulnerabilities using the [Contact Form](https://emsesp.org/About/#-contact).
-44
View File
@@ -1,44 +0,0 @@
{
"build": {
"arduino": {
"ldscript": "esp32c3_out.ld"
},
"core": "esp32",
"extra_flags": [
"-DARDUINO_LOLIN_C3_MINI",
"-DARDUINO_USB_MODE=1",
"-DARDUINO_USB_CDC_ON_BOOT=1"
],
"f_cpu": "160000000L",
"f_flash": "80000000L",
"flash_mode": "qio",
"hwids": [
[
"0X303A",
"0x1001"
]
],
"mcu": "esp32c3",
"variant": "lolin_c3_mini"
},
"connectivity": [
"wifi"
],
"debug": {
"openocd_target": "esp32c3.cfg"
},
"frameworks": [
"arduino",
"espidf"
],
"name": "WEMOS LOLIN C3 Mini",
"upload": {
"flash_size": "4MB",
"maximum_ram_size": 327680,
"maximum_size": 4194304,
"require_upload_port": true,
"speed": 460800
},
"url": "https://www.wemos.cc/en/latest/c3/c3_mini.html",
"vendor": "WEMOS"
}
-34
View File
@@ -1,34 +0,0 @@
{
"build": {
"core": "esp32",
"f_cpu": "240000000L",
"f_flash": "40000000L",
"flash_mode": "dio",
"mcu": "esp32",
"variant": "esp32"
},
"connectivity": [
"wifi",
"ethernet"
],
"debug": {
"openocd_board": "esp32.cfg"
},
"frameworks": [
"arduino",
"espidf"
],
"name": "Espressif ESP32 Dev Module",
"upload": {
"flash_size": "16MB",
"maximum_ram_size": 327680,
"maximum_size": 16777216,
"require_upload_port": true,
"speed": 460800
},
"download": {
"speed": 230400
},
"url": "https://en.wikipedia.org/wiki/ESP32",
"vendor": "Espressif"
}
-46
View File
@@ -1,46 +0,0 @@
{
"build": {
"arduino": {
"ldscript": "esp32s2_out.ld"
},
"core": "esp32",
"extra_flags": [
"-DBOARD_HAS_PSRAM",
"-DARDUINO_USB_CDC_ON_BOOT=1",
"-DARDUINO_USB_MODE=0"
],
"f_cpu": "240000000L",
"f_flash": "80000000L",
"flash_mode": "dio",
"hwids": [
[
"0X303A",
"0x80C2"
]
],
"mcu": "esp32s2",
"variant": "lolin_s2_mini"
},
"connectivity": [
"wifi"
],
"debug": {
"openocd_target": "esp32s2.cfg"
},
"frameworks": [
"arduino",
"espidf"
],
"name": "WEMOS LOLIN S2 Mini",
"upload": {
"flash_size": "4MB",
"maximum_ram_size": 327680,
"maximum_size": 4194304,
"use_1200bps_touch": false,
"wait_for_upload_port": false,
"require_upload_port": true,
"speed": 921600
},
"url": "https://www.wemos.cc/en/latest/s2/s2_mini.html",
"vendor": "WEMOS"
}
-44
View File
@@ -1,44 +0,0 @@
{
"build": {
"arduino": {
"ldscript": "esp32s3_out.ld",
"memory_type": "qio_opi"
},
"core": "esp32",
"extra_flags": [
"-DBOARD_HAS_PSRAM",
"-DARDUINO_USB_MODE=1",
"-DARDUINO_USB_CDC_ON_BOOT=1",
"-DARDUINO_RUNNING_CORE=1",
"-DARDUINO_EVENT_RUNNING_CORE=1"
],
"f_cpu": "240000000L",
"f_flash": "80000000L",
"flash_mode": "qio",
"mcu": "esp32s3",
"variant": "esp32s3"
},
"connectivity": [
"wifi"
],
"debug": {
"openocd_target": "esp32s3.cfg"
},
"frameworks": [
"arduino",
"espidf"
],
"name": "Espressif ESP32-S3 16M Flash OPI PSRAM, 4608KB Code/OTA, 2MB FS",
"upload": {
"flash_size": "16MB",
"maximum_ram_size": 327680,
"maximum_size": 16777216,
"require_upload_port": true,
"speed": 460800
},
"download": {
"speed": 230400
},
"url": "https://docs.espressif.com/projects/esp-idf/en/latest/esp32s3/hw-reference/esp32s3/",
"vendor": "Espressif"
}
-37
View File
@@ -1,37 +0,0 @@
{
"build": {
"arduino":{
"memory_type": "opi_opi"
},
"core": "esp32",
"extra_flags": "-DBOARD_HAS_PSRAM",
"f_cpu": "240000000L",
"f_flash": "80000000L",
"flash_mode": "opi",
"mcu": "esp32s3",
"variant": "esp32s3"
},
"connectivity": [
"wifi"
],
"debug": {
"openocd_target": "esp32s3.cfg"
},
"frameworks": [
"arduino",
"espidf"
],
"name": "Tasmota ESP32-S3 32M Flash OPI PSRAM, 4608KB Code/OTA, 2MB FS",
"upload": {
"flash_size": "32MB",
"maximum_ram_size": 327680,
"maximum_size": 33554432,
"require_upload_port": true,
"speed": 460800
},
"download": {
"speed": 230400
},
"url": "https://docs.espressif.com/projects/esp-idf/en/latest/esp32s3/hw-reference/esp32s3/",
"vendor": "Espressif"
}
-34
View File
@@ -1,34 +0,0 @@
{
"build": {
"core": "esp32",
"f_cpu": "240000000L",
"f_flash": "40000000L",
"flash_mode": "dio",
"mcu": "esp32",
"variant": "esp32"
},
"connectivity": [
"wifi",
"ethernet"
],
"debug": {
"openocd_target": "esp32.cfg"
},
"frameworks": [
"arduino",
"espidf"
],
"name": "Tasmota ESP32 16M Flash, 4608KB Code/OTA, 2MB FS",
"upload": {
"flash_size": "16MB",
"maximum_ram_size": 327680,
"maximum_size": 16777216,
"require_upload_port": true,
"speed": 460800
},
"download": {
"speed": 230400
},
"url": "https://en.wikipedia.org/wiki/ESP32",
"vendor": "Espressif"
}
-35
View File
@@ -1,35 +0,0 @@
{
"build": {
"core": "esp32",
"extra_flags": "-DBOARD_HAS_PSRAM",
"f_cpu": "240000000L",
"f_flash": "80000000L",
"flash_mode": "dio",
"mcu": "esp32",
"variant": "esp32"
},
"connectivity": [
"wifi",
"ethernet"
],
"debug": {
"openocd_target": "esp32.cfg"
},
"frameworks": [
"arduino",
"espidf"
],
"name": "Tasmota ESP32 16M Flash DIO PSRAM, 4608KB Code/OTA, 2MB FS",
"upload": {
"flash_size": "16MB",
"maximum_ram_size": 327680,
"maximum_size": 16777216,
"require_upload_port": true,
"speed": 460800
},
"download": {
"speed": 230400
},
"url": "https://en.wikipedia.org/wiki/ESP32",
"vendor": "Espressif"
}
-33
View File
@@ -1,33 +0,0 @@
{
"build": {
"core": "esp32",
"f_cpu": "240000000L",
"f_flash": "40000000L",
"flash_mode": "dio",
"mcu": "esp32",
"variant": "esp32"
},
"connectivity": [
"wifi"
],
"debug": {
"openocd_target": "esp32.cfg"
},
"frameworks": [
"arduino",
"espidf"
],
"name": "Tasmota ESP32 4M Flash, 4608KB Code/OTA, 2MB FS",
"upload": {
"flash_size": "4MB",
"maximum_ram_size": 327680,
"maximum_size": 4194304,
"require_upload_port": true,
"speed": 460800
},
"download": {
"speed": 230400
},
"url": "https://en.wikipedia.org/wiki/ESP32",
"vendor": "Espressif"
}
-48
View File
@@ -1,48 +0,0 @@
{
"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"
}
+1 -32
View File
@@ -9,36 +9,5 @@
} }
], ],
"dictionaries": ["project-words"], "dictionaries": ["project-words"],
"caseSensitive": false, "ignorePaths": ["node_modules", "compile_commands.json", "WWWData.h", "**/venv/**", "lib/eModbus", "lib/ESPAsyncWebServer", "lib/espMqttClient", "analyse.html", "dist", "**/*.csv", "locale_translations.h", "TZ.tsx", "**/*.txt","build/**", "**/i18n/**", "/project-words.txt"]
"ignorePaths": [
"node_modules",
"compile_commands.json",
"WWWData.h", "**/venv/**",
"lib/eModbus",
"lib/ESPAsyncWebServer",
"lib/espMqttClient",
"analyse.html",
"dist",
"**/*.csv",
"**/*.md",
"**/*.py",
"locale_translations.h",
"TZ.tsx",
"**/*.txt",
"build/**",
"**/i18n/**",
"/project-words.txt",
"Makefile",
"**/*.ini",
"**/*.json",
"src/core/modbus_entity_parameters.hpp",
"sdkconfig.*",
"managed_components/**",
"pnpm-*.yaml",
"vite.config.ts",
"lib/esp32-psram/**",
"test/test_api/test_api.h",
"lib_standalone/**",
"**/*.js"
]
} }
-344
View File
@@ -1,344 +0,0 @@
{
"type": "systembackup",
"version": "3.9.0",
"date": "2026-06-14T10:59:16",
"systembackup": [
{
"type": "settings",
"Network": {
"ssid": "",
"bssid": "",
"password": "",
"hostname": "ems-esp",
"static_ip_config": false,
"bandwidth20": false,
"nosleep": true,
"enableMDNS": true,
"enableCORS": false,
"CORSOrigin": "*",
"tx_power": 0,
"eth_10mbit": false
},
"AP": {
"provision_mode": 2,
"ssid": "ems-esp",
"password": "ems-esp-neo",
"channel": 1,
"ssid_hidden": false,
"max_clients": 4,
"local_ip": "192.168.4.1",
"gateway_ip": "192.168.4.1",
"subnet_mask": "255.255.255.0"
},
"MQTT": {
"enableTLS": false,
"rootCA": "",
"enabled": true,
"host": "192.168.1.2",
"port": 1883,
"base": "ems-esp",
"username": "username",
"password": "password",
"client_id": "esp32",
"keep_alive": 60,
"clean_session": false,
"entity_format": 1,
"publish_time_boiler": 10,
"publish_time_thermostat": 10,
"publish_time_solar": 10,
"publish_time_mixer": 10,
"publish_time_water": 10,
"publish_time_other": 60,
"publish_time_sensor": 10,
"publish_time_heartbeat": 60,
"mqtt_qos": 0,
"mqtt_retain": false,
"ha_enabled": true,
"nested_format": 1,
"discovery_prefix": "homeassistant",
"discovery_type": 0,
"ha_number_mode": 1,
"publish_single": false,
"publish_single2cmd": false,
"send_response": false
},
"NTP": {
"enabled": true,
"server": "time.google.com",
"tz_label": "Europe/Amsterdam",
"tz_format": "CET-1CEST,M3.5.0,M10.5.0/3"
},
"Security": {
"jwt_secret": "ems-esp-neo",
"users": [
{
"username": "admin",
"password": "admin",
"admin": true
},
{
"username": "guest",
"password": "guest",
"admin": false
}
]
},
"Settings": {
"version": "3.9.0",
"board_profile": "E32V2_2",
"platform": "ESP32",
"system_name": "prod",
"locale": "en",
"tx_mode": 1,
"ems_bus_id": 73,
"syslog_enabled": false,
"syslog_level": 3,
"trace_raw": false,
"syslog_mark_interval": 0,
"syslog_host": "",
"syslog_port": 514,
"boiler_heatingoff": false,
"remote_timeout": 24,
"remote_timeout_en": false,
"shower_timer": true,
"shower_alert": false,
"shower_alert_coldshot": 10,
"shower_alert_trigger": 7,
"shower_min_duration": 180,
"rx_gpio": 4,
"tx_gpio": 5,
"dallas_gpio": 14,
"dallas_parasite": false,
"led_gpio": 32,
"hide_led": true,
"led_type": 1,
"low_clock": false,
"telnet_enabled": true,
"notoken_api": false,
"readonly_mode": false,
"analog_enabled": true,
"pbutton_gpio": 34,
"solar_maxflow": 30,
"fahrenheit": false,
"bool_format": 1,
"bool_dashboard": 1,
"enum_format": 1,
"weblog_level": 6,
"weblog_buffer": 50,
"weblog_compact": true,
"phy_type": 1,
"eth_power": 15,
"eth_phy_addr": 0,
"eth_clock_mode": 1,
"modbus_enabled": false,
"modbus_port": 502,
"modbus_max_clients": 10,
"modbus_timeout": 300,
"developer_mode": true,
"disable_reset": false,
"email_enabled": true,
"email_security": 2,
"email_server": "smtp.gmail.com",
"email_port": 587,
"email_login": "test@emsesp.org",
"email_pass": "password",
"email_sender": "test@emsesp.org",
"email_recp": "test@emsesp.org",
"email_subject": "ems-esp notification"
}
},
{
"type": "commands",
"Commands": {
"commands": [
{
"id": 1,
"cmd": "{\"url\":\"https://api.pushover.net/1/messages.json\"}",
"value": "{\"user\":\"userkey\", \"token\":\"tokenkey\", \"message\":\"custom/message2\"}",
"name": "push_over"
},
{
"id": 2,
"cmd": "system/message",
"value": "hello",
"name": "sendmessage"
},
{
"id": 3,
"cmd": "system/message",
"value": "{\"url\":\"http://emsesp.org/versions.json\"}",
"name": "sendversions"
},
{
"id": 4,
"cmd": "custom/message",
"value": "{\"url\":\"http://emsesp.org/versions.json\"}",
"name": "getversions"
},
{
"id": 5,
"cmd": "system/sendmail",
"value": "\"test email with \" + custom/message2",
"name": "sendmail"
}
]
}
},
{
"type": "schedule",
"Schedule": {
"schedule": [
{
"id": 1,
"flags": 128,
"active": false,
"time": "",
"cmd_name": "sendmail",
"name": "sendmailtest"
}
]
}
},
{
"type": "customizations",
"Customizations": {
"as": [
{
"gpio": 39,
"name": "core_voltage",
"offset": 0,
"factor": 0.003771,
"uom": 23,
"type": 3,
"is_system": true
},
{
"gpio": 36,
"name": "supply_voltage",
"offset": 0,
"factor": 0.017,
"uom": 23,
"type": 3,
"is_system": true
},
{
"gpio": 2,
"name": "led",
"offset": 0,
"factor": 1,
"uom": 0,
"type": 6,
"is_system": true
}
],
"masked_entities": []
}
},
{
"type": "entities",
"Entities": {
"entities": [
{
"id": 0,
"ram": 1,
"device_id": 0,
"type_id": 0,
"offset": 0,
"factor": 1,
"name": "message",
"uom": 0,
"value_type": 9,
"writeable": true,
"hide": false,
"value": "{\"stable\":{\"version\":\"3.8.3\",\"date\":\"2026-08-01\"},\"dev\":{\"version\":\"3.9.0-dev.0\",\"date\":\"2026-08-01T09:55:29Z\"}}"
},
{
"id": 1,
"ram": 1,
"device_id": 0,
"type_id": 0,
"offset": 0,
"factor": 1,
"name": "message2",
"uom": 0,
"value_type": 9,
"writeable": true,
"hide": false,
"value": "testing"
}
]
}
},
{
"type": "modules",
"Modules": {
"modules": []
}
},
{
"type": "nvs",
"nvs": [
{
"type": 1,
"key": "nompower",
"value": 30
},
{
"type": 33,
"key": "boot",
"value": "3.9.0"
},
{
"type": 17,
"key": "led",
"value": 0
},
{
"type": 33,
"key": "intTemp",
"value": "28_1767_7B13_2502"
},
{
"type": 4,
"key": "d_boot",
"value": 1
},
{
"type": 33,
"key": "app1",
"value": "3.9.0"
},
{
"type": 33,
"key": "app0",
"value": "3.9.0"
},
{
"type": 66,
"key": "nrgheat",
"value": 49355.29793
},
{
"type": 4,
"key": "d_app1",
"value": 1781336521
},
{
"type": 1,
"key": "fresh_firmware",
"value": 0
},
{
"type": 4,
"key": "d_app0",
"value": 1781363944
},
{
"type": 66,
"key": "nrgww",
"value": 41852.79107
}
]
}
]
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+5102
View File
File diff suppressed because it is too large Load Diff
+212 -234
View File
@@ -1,234 +1,212 @@
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
0x27,UBASettingsWW,fetched 0x26,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,
0xC0,RCErrorMessage, 0xC2,UBAErrorMessage3,
0xC2,UBAErrorMessage3, 0xD1,UBAOutdoorTemp,
0xC6,UBAErrorMessage3, 0xE3,UBAMonitorSlowPlus2,
0xD1,UBAOutdoorTemp, 0xE4,UBAMonitorFastPlus,
0xE3,UBAMonitorSlowPlus2, 0xE5,UBAMonitorSlowPlus,
0xE4,UBAMonitorFastPlus, 0xE6,UBAParametersPlus,fetched
0xE5,UBAMonitorSlowPlus, 0xE9,UBAMonitorWWPlus,
0xE6,UBAParametersPlus,fetched 0xEA,UBAParameterWWPlus,fetched
0xE9,UBAMonitorWWPlus, 0x0101,ISM1Set,fetched
0xEA,UBAParameterWWPlus,fetched 0x0103,ISM1StatusMessage,fetched
0xEB,PumpKick,fetched 0x0104,ISM2StatusMessage,
0x0101,ISM1Set,fetched 0x010C,IPMStatusMessage,
0x0103,ISM1StatusMessage,fetched 0x011E,IPMTempMessage,
0x0104,ISM2StatusMessage, 0x0165,JunkersSet,
0x010C,IPMStatusMessage, 0x0166,JunkersSet,
0x011E,JunkersDisp,fetched 0x0167,JunkersSet,
0x012E,HPEnergy1, 0x0168,JunkersSet,
0x013B,HPEnergy2, 0x016F,JunkersMonitor,
0x0165,JunkersSet, 0x0170,JunkersMonitor,
0x0166,JunkersSet, 0x0171,JunkersMonitor,
0x0167,JunkersSet, 0x0172,JunkersMonitor,
0x0168,JunkersSet, 0x0179,JunkersSet,
0x016E,Absent,fetched 0x017A,JunkersSet,
0x016F,JunkersMonitor, 0x017B,JunkersSet,
0x0170,JunkersMonitor, 0x017C,JunkersSet,
0x0171,JunkersMonitor, 0x01D3,JunkersDhw,fetched
0x0172,JunkersMonitor, 0x023A,RC300OutdoorTemp,fetched
0x0179,JunkersSet, 0x023E,PVSettings,fetched
0x017A,JunkersSet, 0x0240,RC300Settings,fetched
0x017B,JunkersSet, 0x0267,RC300Floordry,
0x017C,JunkersSet, 0x0269,RC300Holiday1,fetched
0x01D3,JunkersDhw,fetched 0x0291,HPMode,fetched
0x023A,RC300OutdoorTemp,fetched 0x0292,HPMode,fetched
0x023E,PVSettings,fetched 0x0293,HPMode,fetched
0x0240,RC300Settings,fetched 0x0294,HPMode,fetched
0x0241,RC300Settings,fetched 0x029B,RC300Curves,
0x0267,RC300Floordry, 0x029C,RC300Curves,
0x0269,RC300Holiday,fetched 0x029D,RC300Curves,
0x0291,HPMode,fetched 0x029E,RC300Curves,
0x0292,HPMode,fetched 0x029F,RC300Curves,
0x0293,HPMode,fetched 0x02A0,RC300Curves,
0x0294,HPMode,fetched 0x02A1,RC300Curves,
0x029B,RC300Curves, 0x02A2,RC300Curves,
0x029C,RC300Curves, 0x02A5,RC300Monitor,
0x029D,RC300Curves, 0x02A6,RC300Monitor,
0x029E,RC300Curves, 0x02A7,RC300Monitor,
0x029F,RC300Curves, 0x02A8,RC300Monitor,
0x02A0,RC300Curves, 0x02A9,RC300Monitor,
0x02A1,RC300Curves, 0x02AA,RC300Monitor,
0x02A2,RC300Curves, 0x02AB,RC300Monitor,
0x02A5,CRFMonitor, 0x02AC,RC300Monitor,
0x02A6,RC300Monitor, 0x02AF,RC300Summer,
0x02A7,RC300Monitor, 0x02B0,RC300Summer,
0x02A8,CRFMonitor, 0x02B1,RC300Summer,
0x02A9,RC300Monitor, 0x02B2,RC300Summer,
0x02AA,RC300Monitor, 0x02B3,RC300Summer,
0x02AB,RC300Monitor, 0x02B4,RC300Summer,
0x02AC,RC300Monitor, 0x02B5,RC300Summer,
0x02AF,RC300Summer, 0x02B6,RC300Summer,
0x02B0,RC300Summer, 0x02B9,RC300Set,
0x02B1,RC300Summer, 0x02BA,RC300Set,
0x02B2,RC300Summer, 0x02BB,RC300Set,
0x02B3,RC300Summer, 0x02BC,RC300Set,
0x02B4,RC300Summer, 0x02BD,RC300Set,
0x02B5,RC300Summer, 0x02BE,RC300Set,
0x02B6,RC300Summer, 0x02BF,RC300Set,
0x02B9,RC300Set, 0x02C0,RC300Set,
0x02BA,RC300Set, 0x02CC,RC300Set2,
0x02BB,RC300Set, 0x02CD,MMPLUSConfigMessage,fetched
0x02BC,RC300Set, 0x02CE,RC300Set2,
0x02BD,RC300Set, 0x02D0,RC300Set2,
0x02BE,RC300Set, 0x02D2,RC300Set2,
0x02BF,RC300Set, 0x02D5,MMPLUSConfigMessage,fetched
0x02C0,RC300Set, 0x02D6,HPPump2,fetched
0x02CC,HPPressure,fetched 0x02D7,MMPLUSStatusMessage,
0x02CD,MMPLUSConfigMessage, 0x02DF,MMPLUSStatusMessage,
0x02D6,HPPump2,fetched 0x02F5,RC300WWmode,fetched
0x02D7,MMPLUSStatusMessage, 0x02F6,RC300WW2mode,fetched
0x02E0,UBASetPoints, 0x031B,RC300WWtemp,fetched
0x02F5,RC300WWmode,fetched 0x031D,RC300WWmode2,
0x02F6,RC300WW2mode,fetched 0x031E,RC300WWmode2,
0x0313,MMPLUSConfigMessage_WWC,fetched 0x0358,SM100SystemConfig,fetched
0x031B,RC300WWtemp,fetched 0x035A,SM100CircuitConfig,fetched
0x031D,RC300WWmode2, 0x035C,SM100HeatAssist,fetched
0x031E,RC300WWmode2, 0x035D,SM100Circuit2Config,fetched
0x0331,MMPLUSStatusMessage_WWC, 0x035F,SM100Config1,fetched
0x0358,SM100SystemConfig,fetched 0x0361,SM100Differential,fetched
0x035A,SM100CircuitConfig,fetched 0x0362,SM100Monitor,
0x035C,SM100HeatAssist,fetched 0x0363,SM100Monitor2,
0x035D,SM100Circuit2Config,fetched 0x0364,SM100Status,
0x035F,SM100Config1,fetched 0x0366,SM100Config,
0x0361,SM100Differential,fetched 0x036A,SM100Status2,
0x0362,SM100Monitor, 0x0380,SM100CollectorConfig,fetched
0x0363,SM100Monitor2, 0x038E,SM100Energy,fetched
0x0364,SM100Status, 0x0391,SM100Time,fetched
0x0366,SM100Config, 0x0467,HPSet,
0x036A,SM100Status2, 0x0468,HPSet,
0x0380,SM100CollectorConfig,fetched 0x0469,HPSet,
0x038E,SM100Energy,fetched 0x046A,HPSet,
0x0391,SM100Time,fetched 0x0471,RC300Summer2,
0x0421,RC300Set2, 0x0472,RC300Summer2,
0x0422,RC300Set2, 0x0473,RC300Summer2,
0x0423,RC300Set2, 0x0474,RC300Summer2,
0x0424,RC300Set2, 0x0475,RC300Summer2,
0x043F,CRHolidays,fetched 0x0476,RC300Summer2,
0x0467,HPSet, 0x0477,RC300Summer2,
0x0468,HPSet, 0x0478,RC300Summer2,
0x0469,HPSet, 0x047B,HP2,
0x046A,HPSet, 0x0484,HPSilentMode,fetched
0x0470,RC300Summer3,fetched 0x0485,HpCooling,fetched
0x0471,RC300Sumr2, 0x0486,HpInConfig,fetched
0x0472,RC300Sumr2, 0x0488,HPValve,fetched
0x0473,RC300Sumr2, 0x048A,HpPool,fetched
0x0474,RC300Sumr2, 0x048B,HPPumps,fetched
0x0475,RC300Sumr2, 0x048D,HpPower,fetched
0x0476,RC300Sumr2, 0x048F,HpTemperatures,
0x0477,RC300Sumr2, 0x0491,HPAdditionalHeater,fetched
0x0478,RC300Sumr2, 0x0492,HpHeaterConfig,fetched
0x047B,HP2, 0x0494,UBAEnergySupplied,
0x0484,HPSilentMode,fetched 0x0495,UBAInformation,
0x0485,HpCooling,fetched 0x0499,HPDhwSettings,fetched
0x0486,HpInConfig,fetched 0x049C,HPSettings2,fetched
0x0488,HPValve,fetched 0x049D,HPSettings3,fetched
0x048A,HpPool,fetched 0x04A2,HpInput,fetched
0x048B,HPPumps,fetched 0x04A5,HPFan,fetched
0x048D,HpPower,fetched 0x04A7,HPPowerLimit,fetched
0x048F,HpTemperatures, 0x04AA,HPPower2,fetched
0x0491,HPAdditionalHeater,fetched 0x04AE,HPEnergy,fetched
0x0492,HpHeaterConfig,fetched 0x04AF,HPMeters,fetched
0x0494,UBAEnergySupplied, 0x056B,VentilationMode,fetched
0x0495,UBAInformation, 0x0583,VentilationMonitor,
0x0499,HPDhwSettings,fetched 0x0585,Blowerspeed,
0x049C,HPSettings2,fetched 0x0587,Bypass,
0x049D,HPSettings3,fetched 0x05BA,HpPoolStatus,fetched
0x04A2,HpInput,fetched 0x05D9,Airquality,
0x04A5,HPFan,fetched 0x0772,HIUSettings,
0x04A7,HPPowerLimit,fetched 0x0779,HIUMonitor,
0x04AA,HPPower2,fetched 0x0935,EM100SetMessage,fetched
0x04AE,HPEnergy,fetched 0x0936,EM100OutMessage,
0x04AF,HPMeters,fetched 0x0937,EM100TempMessage,
0x055C,VentilationSet,fetched 0x0938,EM100InputMessage,
0x056B,VentilationMode,fetched 0x0939,EM100MonitorMessage,
0x0583,VentilationMonitor, 0x093A,EM100ConfigMessage,
0x0585,Blowerspeed, 0x0998,HPSettings,fetched
0x0587,Bypass, 0x0999,HPFunctionTest,fetched
0x05BA,HpPoolStatus,fetched 0x099B,HPFlowTemp,
0x05D9,Airquality, 0x099C,HPComp,
0x0772,HIUSettings, 0x09A0,HPTemperature,
0x0779,HIUMonitor,
0x07A5,SM100wwCirc,fetched
0x07A6,SM100wwParam,fetched
0x07AA,SM100wwStatus,
0x07AB,SM100wwCommand,
0x07AC,SM100wwParam1,
0x07AD,SM100ValveStatus,
0x07AE,SM100wwKeepWarm,fetched
0x07D6,SM100wwTemperature,
0x07E0,SM100wwStatus2,fetched
0x0935,EM100SetMessage,fetched
0x0936,EM100OutMessage,
0x0937,EM100TempMessage,
0x0938,EM100InputMessage,
0x0939,EM100MonitorMessage,
0x093A,EM100ConfigMessage,
0x0998,HPSettings,fetched
0x0999,HPFunctionTest,fetched
0x099A,HPStarts,
0x099B,HPFlowTemp,
0x099C,HPComp,
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 0x27 0x26 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 0xC0 0xC2 RCErrorMessage UBAErrorMessage3
66 0xC2 0xD1 UBAErrorMessage3 UBAOutdoorTemp
67 0xC6 0xE3 UBAErrorMessage3 UBAMonitorSlowPlus2
68 0xD1 0xE4 UBAOutdoorTemp UBAMonitorFastPlus
69 0xE3 0xE5 UBAMonitorSlowPlus2 UBAMonitorSlowPlus
70 0xE4 0xE6 UBAMonitorFastPlus UBAParametersPlus fetched
71 0xE5 0xE9 UBAMonitorSlowPlus UBAMonitorWWPlus
72 0xE6 0xEA UBAParametersPlus UBAParameterWWPlus fetched
73 0xE9 0x0101 UBAMonitorWWPlus ISM1Set fetched
74 0xEA 0x0103 UBAParameterWWPlus ISM1StatusMessage fetched
75 0xEB 0x0104 PumpKick ISM2StatusMessage fetched
76 0x0101 0x010C ISM1Set IPMStatusMessage fetched
77 0x0103 0x011E ISM1StatusMessage IPMTempMessage fetched
78 0x0104 0x0165 ISM2StatusMessage JunkersSet
79 0x010C 0x0166 IPMStatusMessage JunkersSet
80 0x011E 0x0167 JunkersDisp JunkersSet fetched
81 0x012E 0x0168 HPEnergy1 JunkersSet
82 0x013B 0x016F HPEnergy2 JunkersMonitor
83 0x0165 0x0170 JunkersSet JunkersMonitor
84 0x0166 0x0171 JunkersSet JunkersMonitor
85 0x0167 0x0172 JunkersSet JunkersMonitor
86 0x0168 0x0179 JunkersSet
87 0x016E 0x017A Absent JunkersSet fetched
88 0x016F 0x017B JunkersMonitor JunkersSet
89 0x0170 0x017C JunkersMonitor JunkersSet
90 0x0171 0x01D3 JunkersMonitor JunkersDhw fetched
91 0x0172 0x023A JunkersMonitor RC300OutdoorTemp fetched
92 0x0179 0x023E JunkersSet PVSettings fetched
93 0x017A 0x0240 JunkersSet RC300Settings fetched
94 0x017B 0x0267 JunkersSet RC300Floordry
95 0x017C 0x0269 JunkersSet RC300Holiday1 fetched
96 0x01D3 0x0291 JunkersDhw HPMode fetched
97 0x023A 0x0292 RC300OutdoorTemp HPMode fetched
98 0x023E 0x0293 PVSettings HPMode fetched
99 0x0240 0x0294 RC300Settings HPMode fetched
100 0x0241 0x029B RC300Settings RC300Curves fetched
101 0x0267 0x029C RC300Floordry RC300Curves
102 0x0269 0x029D RC300Holiday RC300Curves fetched
103 0x0291 0x029E HPMode RC300Curves fetched
104 0x0292 0x029F HPMode RC300Curves fetched
105 0x0293 0x02A0 HPMode RC300Curves fetched
106 0x0294 0x02A1 HPMode RC300Curves fetched
107 0x029B 0x02A2 RC300Curves
108 0x029C 0x02A5 RC300Curves RC300Monitor
109 0x029D 0x02A6 RC300Curves RC300Monitor
110 0x029E 0x02A7 RC300Curves RC300Monitor
111 0x029F 0x02A8 RC300Curves RC300Monitor
112 0x02A0 0x02A9 RC300Curves RC300Monitor
113 0x02A1 0x02AA RC300Curves RC300Monitor
114 0x02A2 0x02AB RC300Curves RC300Monitor
115 0x02A5 0x02AC CRFMonitor RC300Monitor
116 0x02A6 0x02AF RC300Monitor RC300Summer
117 0x02A7 0x02B0 RC300Monitor RC300Summer
118 0x02A8 0x02B1 CRFMonitor RC300Summer
119 0x02A9 0x02B2 RC300Monitor RC300Summer
120 0x02AA 0x02B3 RC300Monitor RC300Summer
121 0x02AB 0x02B4 RC300Monitor RC300Summer
122 0x02AC 0x02B5 RC300Monitor RC300Summer
123 0x02AF 0x02B6 RC300Summer
124 0x02B0 0x02B9 RC300Summer RC300Set
125 0x02B1 0x02BA RC300Summer RC300Set
126 0x02B2 0x02BB RC300Summer RC300Set
127 0x02B3 0x02BC RC300Summer RC300Set
128 0x02B4 0x02BD RC300Summer RC300Set
129 0x02B5 0x02BE RC300Summer RC300Set
130 0x02B6 0x02BF RC300Summer RC300Set
131 0x02B9 0x02C0 RC300Set
132 0x02BA 0x02CC RC300Set RC300Set2
133 0x02BB 0x02CD RC300Set MMPLUSConfigMessage fetched
134 0x02BC 0x02CE RC300Set RC300Set2
135 0x02BD 0x02D0 RC300Set RC300Set2
136 0x02BE 0x02D2 RC300Set RC300Set2
137 0x02BF 0x02D5 RC300Set MMPLUSConfigMessage fetched
138 0x02C0 0x02D6 RC300Set HPPump2 fetched
139 0x02CC 0x02D7 HPPressure MMPLUSStatusMessage fetched
140 0x02CD 0x02DF MMPLUSConfigMessage MMPLUSStatusMessage
141 0x02D6 0x02F5 HPPump2 RC300WWmode fetched
142 0x02D7 0x02F6 MMPLUSStatusMessage RC300WW2mode fetched
143 0x02E0 0x031B UBASetPoints RC300WWtemp fetched
144 0x02F5 0x031D RC300WWmode RC300WWmode2 fetched
145 0x02F6 0x031E RC300WW2mode RC300WWmode2 fetched
146 0x0313 0x0358 MMPLUSConfigMessage_WWC SM100SystemConfig fetched
147 0x031B 0x035A RC300WWtemp SM100CircuitConfig fetched
148 0x031D 0x035C RC300WWmode2 SM100HeatAssist fetched
149 0x031E 0x035D RC300WWmode2 SM100Circuit2Config fetched
150 0x0331 0x035F MMPLUSStatusMessage_WWC SM100Config1 fetched
151 0x0358 0x0361 SM100SystemConfig SM100Differential fetched
152 0x035A 0x0362 SM100CircuitConfig SM100Monitor fetched
153 0x035C 0x0363 SM100HeatAssist SM100Monitor2 fetched
154 0x035D 0x0364 SM100Circuit2Config SM100Status fetched
155 0x035F 0x0366 SM100Config1 SM100Config fetched
156 0x0361 0x036A SM100Differential SM100Status2 fetched
157 0x0362 0x0380 SM100Monitor SM100CollectorConfig fetched
158 0x0363 0x038E SM100Monitor2 SM100Energy fetched
159 0x0364 0x0391 SM100Status SM100Time fetched
160 0x0366 0x0467 SM100Config HPSet
161 0x036A 0x0468 SM100Status2 HPSet
162 0x0380 0x0469 SM100CollectorConfig HPSet fetched
163 0x038E 0x046A SM100Energy HPSet fetched
164 0x0391 0x0471 SM100Time RC300Summer2 fetched
165 0x0421 0x0472 RC300Set2 RC300Summer2
166 0x0422 0x0473 RC300Set2 RC300Summer2
167 0x0423 0x0474 RC300Set2 RC300Summer2
168 0x0424 0x0475 RC300Set2 RC300Summer2
169 0x043F 0x0476 CRHolidays RC300Summer2 fetched
170 0x0467 0x0477 HPSet RC300Summer2
171 0x0468 0x0478 HPSet RC300Summer2
172 0x0469 0x047B HPSet HP2
173 0x046A 0x0484 HPSet HPSilentMode fetched
174 0x0470 0x0485 RC300Summer3 HpCooling fetched
175 0x0471 0x0486 RC300Sumr2 HpInConfig fetched
176 0x0472 0x0488 RC300Sumr2 HPValve fetched
177 0x0473 0x048A RC300Sumr2 HpPool fetched
178 0x0474 0x048B RC300Sumr2 HPPumps fetched
179 0x0475 0x048D RC300Sumr2 HpPower fetched
180 0x0476 0x048F RC300Sumr2 HpTemperatures
181 0x0477 0x0491 RC300Sumr2 HPAdditionalHeater fetched
182 0x0478 0x0492 RC300Sumr2 HpHeaterConfig fetched
183 0x047B 0x0494 HP2 UBAEnergySupplied
184 0x0484 0x0495 HPSilentMode UBAInformation fetched
185 0x0485 0x0499 HpCooling HPDhwSettings fetched
186 0x0486 0x049C HpInConfig HPSettings2 fetched
187 0x0488 0x049D HPValve HPSettings3 fetched
188 0x048A 0x04A2 HpPool HpInput fetched
189 0x048B 0x04A5 HPPumps HPFan fetched
190 0x048D 0x04A7 HpPower HPPowerLimit fetched
191 0x048F 0x04AA HpTemperatures HPPower2 fetched
192 0x0491 0x04AE HPAdditionalHeater HPEnergy fetched
193 0x0492 0x04AF HpHeaterConfig HPMeters fetched
194 0x0494 0x056B UBAEnergySupplied VentilationMode fetched
195 0x0495 0x0583 UBAInformation VentilationMonitor
196 0x0499 0x0585 HPDhwSettings Blowerspeed fetched
197 0x049C 0x0587 HPSettings2 Bypass fetched
198 0x049D 0x05BA HPSettings3 HpPoolStatus fetched
199 0x04A2 0x05D9 HpInput Airquality fetched
200 0x04A5 0x0772 HPFan HIUSettings fetched
201 0x04A7 0x0779 HPPowerLimit HIUMonitor fetched
202 0x04AA 0x0935 HPPower2 EM100SetMessage fetched
203 0x04AE 0x0936 HPEnergy EM100OutMessage fetched
204 0x04AF 0x0937 HPMeters EM100TempMessage fetched
205 0x055C 0x0938 VentilationSet EM100InputMessage fetched
206 0x056B 0x0939 VentilationMode EM100MonitorMessage fetched
207 0x0583 0x093A VentilationMonitor EM100ConfigMessage
208 0x0585 0x0998 Blowerspeed HPSettings fetched
209 0x0587 0x0999 Bypass HPFunctionTest fetched
210 0x05BA 0x099B HpPoolStatus HPFlowTemp fetched
211 0x05D9 0x099C Airquality HPComp
212 0x0772 0x09A0 HIUSettings HPTemperature
0x0779 HIUMonitor
0x07A5 SM100wwCirc fetched
0x07A6 SM100wwParam fetched
0x07AA SM100wwStatus
0x07AB SM100wwCommand
0x07AC SM100wwParam1
0x07AD SM100ValveStatus
0x07AE SM100wwKeepWarm fetched
0x07D6 SM100wwTemperature
0x07E0 SM100wwStatus2 fetched
0x0935 EM100SetMessage fetched
0x0936 EM100OutMessage
0x0937 EM100TempMessage
0x0938 EM100InputMessage
0x0939 EM100MonitorMessage
0x093A EM100ConfigMessage
0x0998 HPSettings fetched
0x0999 HPFunctionTest fetched
0x099A HPStarts
0x099B HPFlowTemp
0x099C HPComp
0x09A0 HPTemperature
+1 -2
View File
@@ -3,7 +3,7 @@ build_flags =
; WiFi settings ; WiFi settings
-D FACTORY_WIFI_SSID=\"\" -D FACTORY_WIFI_SSID=\"\"
-D FACTORY_WIFI_PASSWORD=\"\" -D FACTORY_WIFI_PASSWORD=\"\"
-D FACTORY_NETWORK_HOSTNAME=\"ems-esp\" -D FACTORY_WIFI_HOSTNAME=\"ems-esp\"
; Access point settings ; Access point settings
-D FACTORY_AP_PROVISION_MODE=AP_MODE_DISCONNECTED -D FACTORY_AP_PROVISION_MODE=AP_MODE_DISCONNECTED
@@ -24,7 +24,6 @@ build_flags =
-D FACTORY_NTP_TIME_ZONE_LABEL=\"Europe/Amsterdam\" -D FACTORY_NTP_TIME_ZONE_LABEL=\"Europe/Amsterdam\"
-D FACTORY_NTP_TIME_ZONE_FORMAT=\"CET-1CEST,M3.5.0,M10.5.0/3\" -D FACTORY_NTP_TIME_ZONE_FORMAT=\"CET-1CEST,M3.5.0,M10.5.0/3\"
-D FACTORY_NTP_SERVER=\"time.google.com\" -D FACTORY_NTP_SERVER=\"time.google.com\"
-D FACTORY_NTP_THERMOSTAT_SYNC=1
; MQTT settings ; MQTT settings
-D FACTORY_MQTT_ENABLED=false -D FACTORY_MQTT_ENABLED=false
+4
View File
@@ -0,0 +1,4 @@
/.yarn/** linguist-vendored
/.yarn/releases/* binary
/.yarn/plugins/**/* binary
/.pnp.* binary linguist-generated
+2 -1
View File
@@ -4,4 +4,5 @@ dist/
src/i18n/* src/i18n/*
.prettierrc .prettierrc
.typesafe-i18n.json .yarn/
.typesafe-i18n.json
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"adapter": "react", "adapter": "react",
"baseLocale": "pl", "baseLocale": "pl",
"$schema": "https://unpkg.com/typesafe-i18n@5.27.1/schema/typesafe-i18n.json" "$schema": "https://unpkg.com/typesafe-i18n@5.26.2/schema/typesafe-i18n.json"
} }
+1
View File
@@ -0,0 +1 @@
nodeLinker: node-modules
+3 -3
View File
@@ -1,17 +1,17 @@
// @ts-check // @ts-check
import eslint from '@eslint/js'; import eslint from '@eslint/js';
import prettierConfig from 'eslint-config-prettier'; import prettierConfig from 'eslint-config-prettier';
import { defineConfig } from 'eslint/config';
import tseslint from 'typescript-eslint'; import tseslint from 'typescript-eslint';
export default defineConfig( export default tseslint.config(
eslint.configs.recommended, eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked, ...tseslint.configs.recommendedTypeChecked,
prettierConfig, prettierConfig,
{ {
languageOptions: { languageOptions: {
parserOptions: { parserOptions: {
project: true project: true,
tsconfigRootDir: import.meta.dirname
} }
} }
}, },
+46 -41
View File
@@ -1,62 +1,67 @@
{ {
"name": "EMS-ESP", "name": "EMS-ESP",
"version": "3.9.0", "version": "3.7.0",
"description": "EMS-ESP WebUI", "description": "EMS-ESP WebUI",
"homepage": "https://emsesp.org", "homepage": "https://emsesp.org",
"author": "emsesp.org", "author": "proddy, emsesp.org",
"license": "MIT", "license": "MIT",
"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 --no-watch && vite build --mode hosted",
"mock-rest": "bun --watch ../mock-api/restServer.ts", "preview-standalone": "typesafe-i18n --no-watch && vite build && concurrently -c \"auto\" \"npm:mock-rest\" \"vite preview\"",
"preview-standalone": "typesafe-i18n --no-watch && vite build && concurrently -c \"auto\" \"pnpm:mock-rest\" \"vite preview\"", "mock-rest": "bun --watch ../mock-api/rest_server.ts",
"standalone": "concurrently -c \"auto\" \"typesafe-i18n\" \"pnpm:mock-rest\" \"vite dev\"", "standalone": "concurrently -c \"auto\" \"typesafe-i18n\" \"npm:mock-rest\" \"vite\"",
"typesafe-i18n": "typesafe-i18n --no-watch", "typesafe-i18n": "typesafe-i18n --no-watch",
"build-webUI": "typesafe-i18n --no-watch && vite build && node progmem-generator.js", "webUI": "node progmem-generator.js",
"format": "prettier -l -w '**/*.{ts,tsx,js,css,json,md}'", "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.3.3", "@alova/adapter-xhr": "2.0.9",
"@emotion/react": "^11.14.0", "@emotion/react": "^11.13.3",
"@emotion/styled": "^11.14.1", "@emotion/styled": "^11.13.0",
"@mui/icons-material": "^9.4.0", "@mui/icons-material": "^6.1.5",
"@mui/material": "^9.4.0", "@mui/material": "^6.1.5",
"@table-library/react-table-library": "4.1.15", "@table-library/react-table-library": "4.1.7",
"alova": "^3.5.5", "alova": "3.1.1",
"async-validator": "^4.2.5",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"preact": "^10.29.8", "mime-types": "^2.1.35",
"react": "^19.2.8", "preact": "^10.24.3",
"react-dom": "^19.2.8", "react": "^18.3.1",
"react-icons": "^5.7.0", "react-dom": "^18.3.1",
"react-router": "^8.3.1", "react-icons": "^5.3.0",
"typesafe-i18n": "^5.27.1" "react-router-dom": "^6.27.0",
"react-toastify": "^10.0.6",
"typesafe-i18n": "^5.26.2",
"typescript": "^5.6.3"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@babel/core": "^7.26.0",
"@preact/preset-vite": "^2.10.6", "@eslint/js": "^9.13.0",
"@trivago/prettier-plugin-sort-imports": "^6.0.2", "@preact/compat": "^18.3.1",
"@types/node": "^26.4.1", "@preact/preset-vite": "^2.9.1",
"@types/react": "^19.2.18", "@trivago/prettier-plugin-sort-imports": "^4.3.0",
"@types/react-dom": "^19.2.5", "@types/formidable": "^3",
"concurrently": "^10.0.5", "@types/node": "^22.8.1",
"eslint": "^10.9.1", "@types/react": "^18.3.12",
"eslint-config-prettier": "^10.1.8", "@types/react-dom": "^18.3.1",
"etag": "^1.8.1", "@types/react-router-dom": "^5.3.3",
"mime-types": "^3.0.2", "concurrently": "^9.0.1",
"prettier": "^3.9.6", "eslint": "^9.13.0",
"rollup-plugin-visualizer": "^7.1.1", "eslint-config-prettier": "^9.1.0",
"terser": "^5.51.2", "formidable": "^3.5.2",
"typescript": "^6.0.3", "prettier": "^3.3.3",
"typescript-eslint": "^8.69.0", "rollup-plugin-visualizer": "^5.12.0",
"vite": "^8.2.2", "terser": "^5.36.0",
"vite-plugin-imagemin": "^0.6.1" "typescript-eslint": "8.11.0",
"vite": "^5.4.10",
"vite-plugin-imagemin": "^0.6.1",
"vite-tsconfig-paths": "^5.0.1"
}, },
"packageManager": "pnpm@11.25.0+sha512.5cde925b4f075f725eb71fbae18a42ffe784524789f19b61c731cb8721ec28aaee160e01a8d5af4fedb2a42cdbf300efe23db356b0d4a17b4d63e11f8ab7c956" "packageManager": "yarn@4.5.1"
} }
-5899
View File
File diff suppressed because it is too large Load Diff
-8
View File
@@ -1,8 +0,0 @@
allowBuilds:
cwebp-bin: false
esbuild: false
gifsicle: false
jpegtran-bin: false
mozjpeg: false
optipng-bin: false
pngquant-bin: false
+56 -134
View File
@@ -1,4 +1,4 @@
import etag from 'etag'; import crypto from 'crypto';
import { import {
createWriteStream, createWriteStream,
existsSync, existsSync,
@@ -12,182 +12,104 @@ import zlib from 'zlib';
const ARDUINO_INCLUDES = '#include <Arduino.h>\n\n'; const ARDUINO_INCLUDES = '#include <Arduino.h>\n\n';
const INDENT = ' '; const INDENT = ' ';
const outputPath = '../src/ESP32React/WWWData.h'; const outputPath = '../lib/framework/WWWData.h';
const sourcePath = './dist'; const sourcePath = './dist';
const bytesPerLine = 20; const bytesPerLine = 20;
let totalSize = 0; var totalSize = 0;
let bundleStats = {
js: { count: 0, uncompressed: 0, compressed: 0 }, const generateWWWClass = () =>
css: { count: 0, uncompressed: 0, compressed: 0 }, `typedef std::function<void(const char * uri, const String & contentType, const uint8_t * content, size_t len, const String & hash)> RouteRegistrationHandler;
html: { count: 0, uncompressed: 0, compressed: 0 }, // Total size is ${totalSize} bytes
svg: { count: 0, uncompressed: 0, compressed: 0 },
other: { count: 0, uncompressed: 0, compressed: 0 } class WWWData {
${indent}public:
${indent.repeat(2)}static void registerRoutes(RouteRegistrationHandler handler) {
${fileInfo.map((file) => `${indent.repeat(3)}handler("${file.uri}", "${file.mimeType}", ${file.variable}, ${file.size}, "${file.hash}");`).join('\n')}
${indent.repeat(2)}}
}; };
// AsyncWebHandler that performs the lookup.
const generateWWWClass = () => `// 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()}
struct WWWAsset {
${INDENT}const char * uri;
${INDENT}const char * contentType;
${INDENT}const uint8_t * content;
${INDENT}size_t len;
${INDENT}const char * etag; // already includes enclosing double quotes
};
static const WWWAsset WWW_ASSETS[] = {
${fileInfo.map((f) => `${INDENT}{"${f.uri}", "${f.mimeType}", ${f.variable}, ${f.size}, "\\"${f.rawHash}\\""},`).join('\n')}
};
static constexpr size_t WWW_ASSETS_COUNT = sizeof(WWW_ASSETS) / sizeof(WWW_ASSETS[0]);
`; `;
// Optional locale allow-list, shared with the Vite build via VITE_APP_LOCALES function getFilesSync(dir, files = []) {
// (e.g. "en,de,nl"). When set, locale chunks outside the list are NOT embedded
// into firmware flash. `en` is always kept as the fallback. Unset => embed all.
const ALL_LOCALES = [
'cs',
'de',
'en',
'fr',
'it',
'nl',
'no',
'pl',
'sk',
'sv',
'tr'
];
const localeAllowList = (process.env.VITE_APP_LOCALES || '')
.split(',')
.map((locale) => locale.trim())
.filter(Boolean);
const isExcludedLocaleChunk = (relativeFilePath) => {
if (localeAllowList.length === 0) return false;
const base = relativeFilePath.split(sep).pop();
const match = /^([a-z]{2})-[A-Za-z0-9_-]+\.js$/.exec(base);
if (!match) return false;
const code = match[1];
// Only treat known locale codes as locale chunks; never drop the en fallback.
if (!ALL_LOCALES.includes(code) || code === 'en') return false;
return !localeAllowList.includes(code);
};
const getFilesSync = (dir, files = []) => {
readdirSync(dir, { withFileTypes: true }).forEach((entry) => { readdirSync(dir, { withFileTypes: true }).forEach((entry) => {
const entryPath = resolve(dir, entry.name); const entryPath = resolve(dir, entry.name);
entry.isDirectory() ? getFilesSync(entryPath, files) : files.push(entryPath); if (entry.isDirectory()) {
getFilesSync(entryPath, files);
} else {
files.push(entryPath);
}
}); });
return files; return files;
}; }
const cleanAndOpen = (path) => { function cleanAndOpen(path) {
existsSync(path) && unlinkSync(path); if (existsSync(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);
const fileType = getFileType(relativeFilePath); var size = 0;
let size = 0; writeStream.write('const uint8_t ' + variable + '[] = {');
writeStream.write(`const uint8_t ${variable}[] = {`); // const zipBuffer = zlib.brotliCompressSync(buffer, { quality: 1 });
const zipBuffer = zlib.gzipSync(buffer, { level: 9 }); const zipBuffer = zlib.gzipSync(buffer, { level: 9 });
// const hash = crypto.createHash('sha256').update(zipBuffer).digest('hex');
const hash = etag(zipBuffer); // use smaller md5 instead of sha256 // create sha
const rawHash = hash.replace(/^"|"$/g, ''); const hashSum = crypto.createHash('sha256');
hashSum.update(zipBuffer);
const hash = hashSum.digest('hex');
zipBuffer.forEach((b) => { zipBuffer.forEach((b) => {
if (!(size % bytesPerLine)) { if (!(size % bytesPerLine)) {
writeStream.write('\n' + INDENT); writeStream.write('\n');
writeStream.write(indent);
} }
writeStream.write('0x' + b.toString(16).toUpperCase().padStart(2, '0') + ','); writeStream.write('0x' + ('00' + b.toString(16).toUpperCase()).slice(-2) + ',');
size++; size++;
}); });
size % bytesPerLine && writeStream.write('\n'); if (size % bytesPerLine) {
writeStream.write('};\n\n'); writeStream.write('\n');
}
// Update bundle statistics writeStream.write('};\n\n');
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,
variable, variable,
size, size,
hash, hash
rawHash
}); });
// console.log(relativeFilePath + ' (size ' + size + ' bytes)');
totalSize += size; totalSize += size;
}; };
console.log(`Generating ${outputPath} from ${sourcePath}`); // start
console.log('Generating ' + outputPath + ' from ' + sourcePath);
const includes = ARDUINO_INCLUDES;
const indent = INDENT;
const fileInfo = []; const fileInfo = [];
const writeStream = cleanAndOpen(resolve(outputPath)); const writeStream = cleanAndOpen(resolve(outputPath));
writeStream.write(ARDUINO_INCLUDES); // 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);
const relativeFilePath = relative(buildPath, filePath); const relativeFilePath = relative(buildPath, filePath);
if (isExcludedLocaleChunk(relativeFilePath)) { writeFile(relativeFilePath, readStream);
console.log(`Skipping locale (not in VITE_APP_LOCALES): ${relativeFilePath}`);
continue;
}
writeFile(relativeFilePath, readFileSync(filePath));
} }
// add class
writeStream.write(generateWWWClass()); writeStream.write(generateWWWClass());
// end
writeStream.end(); writeStream.end();
// Calculate and display bundle statistics console.log('Total size: ' + totalSize / 1000 + ' KB');
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));
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

+4 -5
View File
@@ -13,9 +13,8 @@
local('Roboto'), local('Roboto'),
local('Roboto-Regular'), local('Roboto-Regular'),
url(../fonts/re.woff2) format('woff2'); url(../fonts/re.woff2) format('woff2');
unicode-range: unicode-range: U+0000-00FF, U+0104-0107, U+0118-0119, U+011E-011F, U+0130-0131,
U+0000-00FF, U+0104-0107, U+0118-0119, U+011E-011F, U+0130-0131, U+0141-0144, U+0141-0144, U+0152-0153, U+015A-015B, U+015E-015F, U+0179-017C, U+02BB-02BC,
U+0152-0153, U+015A-015B, U+015E-015F, U+0179-017C, U+02BB-02BC, U+02C6, U+02DA, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193,
U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+2212, U+2215, U+FEFF, U+FFFD;
U+FEFF, U+FFFD;
} }
+26 -55
View File
@@ -1,75 +1,46 @@
import { memo, useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Outlet } from 'react-router'; import { Slide, ToastContainer } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.min.css';
import AppRouting from 'AppRouting';
import CustomTheme from 'CustomTheme'; import CustomTheme from 'CustomTheme';
import { Toaster } from 'components/toast';
import { Authentication } from 'contexts/authentication';
import TypesafeI18n from 'i18n/i18n-react'; import TypesafeI18n from 'i18n/i18n-react';
import type { Locales } from 'i18n/i18n-types'; import { detectLocale } from 'i18n/i18n-util';
import { loadLocaleAsync } from 'i18n/i18n-util.async'; import { loadLocaleAsync } from 'i18n/i18n-util.async';
import { detectLocale, navigatorDetector } from 'typesafe-i18n/detectors'; import { localStorageDetector } from 'typesafe-i18n/detectors';
const ALL_LOCALES = [ const detectedLocale = detectLocale(localStorageDetector);
'de',
'en',
'it',
'fr',
'nl',
'no',
'pl',
'sk',
'sv',
'tr',
'cs'
] as Locales[];
// Optional build-time allow-list (e.g. VITE_APP_LOCALES="en,de,nl"). When unset, const App = () => {
// every locale is available. `en` is always kept as the fallback locale, and the
// progmem generator embeds the matching subset into firmware flash.
const localeAllowList = (import.meta.env.VITE_APP_LOCALES ?? '')
.split(',')
.map((locale) => locale.trim())
.filter(Boolean);
const AVAILABLE_LOCALES: Locales[] = localeAllowList.length
? ALL_LOCALES.filter(
(locale) => locale === 'en' || localeAllowList.includes(locale)
)
: ALL_LOCALES;
const App = memo(() => {
const [wasLoaded, setWasLoaded] = useState(false); const [wasLoaded, setWasLoaded] = useState(false);
const [locale, setLocale] = useState<Locales>('en');
useEffect(() => { useEffect(() => {
const initializeLocale = async () => { void loadLocaleAsync(detectedLocale).then(() => setWasLoaded(true));
const browserLocale = detectLocale('en', AVAILABLE_LOCALES, navigatorDetector);
const stored = localStorage.getItem('lang');
// Ignore a stored locale that isn't available (e.g. trimmed from this build).
const newLocale =
stored && AVAILABLE_LOCALES.includes(stored as Locales)
? (stored as Locales)
: browserLocale;
localStorage.setItem('lang', newLocale);
setLocale(newLocale);
await loadLocaleAsync(newLocale);
setWasLoaded(true);
};
void initializeLocale();
}, []); }, []);
if (!wasLoaded) return null; if (!wasLoaded) return null;
return ( return (
<TypesafeI18n locale={locale}> <TypesafeI18n locale={detectedLocale}>
<CustomTheme> <CustomTheme>
<Authentication> <AppRouting />
<Outlet /> <ToastContainer
</Authentication> position="bottom-left"
<Toaster /> autoClose={3000}
hideProgressBar={false}
newestOnTop={false}
closeOnClick={true}
rtl={false}
pauseOnFocusLoss={false}
draggable={false}
pauseOnHover={false}
transition={Slide}
closeButton={false}
theme="light"
/>
</CustomTheme> </CustomTheme>
</TypesafeI18n> </TypesafeI18n>
); );
}); };
export default App; export default App;
+60
View File
@@ -0,0 +1,60 @@
import { useContext, useEffect } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
import { toast } from 'react-toastify';
import AuthenticatedRouting from 'AuthenticatedRouting';
import SignIn from 'SignIn';
import { RequireAuthenticated, RequireUnauthenticated } from 'components';
import { Authentication, AuthenticationContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react';
interface SecurityRedirectProps {
message: string;
signOut?: boolean;
}
const RootRedirect = ({ message, signOut }: SecurityRedirectProps) => {
const authenticationContext = useContext(AuthenticationContext);
useEffect(() => {
signOut && authenticationContext.signOut(false);
toast.success(message);
}, [message, signOut, authenticationContext]);
return <Navigate to="/" />;
};
const AppRouting = () => {
const { LL } = useI18nContext();
return (
<Authentication>
<Routes>
<Route
path="/unauthorized"
element={<RootRedirect message={LL.PLEASE_SIGNIN()} signOut />}
/>
<Route
path="/fileUpdated"
element={<RootRedirect message={LL.UPLOAD_SUCCESSFUL()} />}
/>
<Route
path="/"
element={
<RequireUnauthenticated>
<SignIn />
</RequireUnauthenticated>
}
/>
<Route
path="/*"
element={
<RequireAuthenticated>
<AuthenticatedRouting />
</RequireAuthenticated>
}
/>
</Routes>
</Authentication>
);
};
export default AppRouting;
+76
View File
@@ -0,0 +1,76 @@
import { useContext } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
import CustomEntities from 'app/main/CustomEntities';
import Customizations from 'app/main/Customizations';
import Dashboard from 'app/main/Dashboard';
import Devices from 'app/main/Devices';
import Help from 'app/main/Help';
import Modules from 'app/main/Modules';
import Scheduler from 'app/main/Scheduler';
import Sensors from 'app/main/Sensors';
import APSettings from 'app/settings/APSettings';
import ApplicationSettings from 'app/settings/ApplicationSettings';
import DownloadUpload from 'app/settings/DownloadUpload';
import MqttSettings from 'app/settings/MqttSettings';
import NTPSettings from 'app/settings/NTPSettings';
import Settings from 'app/settings/Settings';
import Version from 'app/settings/Version';
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 { Layout } from 'components';
import { AuthenticatedContext } from 'contexts/authentication';
const AuthenticatedRouting = () => {
const { me } = useContext(AuthenticatedContext);
return (
<Layout>
<Routes>
<Route path="/dashboard/*" element={<Dashboard />} />
<Route path="/devices/*" element={<Devices />} />
<Route path="/sensors/*" element={<Sensors />} />
<Route path="/status/*" element={<Status />} />
<Route path="/help/*" element={<Help />} />
<Route path="/*" element={<Navigate to="/" />} />
<Route path="/status/hardwarestatus/*" element={<HardwareStatus />} />
<Route path="/status/activity" element={<Activity />} />
<Route path="/status/log" element={<SystemLog />} />
<Route path="/status/mqtt" element={<MqttStatus />} />
<Route path="/status/ntp" element={<NTPStatus />} />
<Route path="/status/ap" element={<APStatus />} />
<Route path="/status/network" element={<NetworkStatus />} />
{me.admin && (
<>
<Route path="/settings" element={<Settings />} />
<Route path="/settings/version" element={<Version />} />
<Route path="/settings/application" element={<ApplicationSettings />} />
<Route path="/settings/mqtt" element={<MqttSettings />} />
<Route path="/settings/ntp" element={<NTPSettings />} />
<Route path="/settings/ap" element={<APSettings />} />
<Route path="/settings/modules" element={<Modules />} />
<Route path="/settings/upload" element={<DownloadUpload />} />
<Route path="/settings/network/*" element={<Network />} />
<Route path="/settings/security/*" element={<Security />} />
<Route path="/customizations" element={<Customizations />} />
<Route path="/scheduler" element={<Scheduler />} />
<Route path="/customentities" element={<CustomEntities />} />
</>
)}
</Routes>
</Layout>
);
};
export default AuthenticatedRouting;
+8 -40
View File
@@ -1,13 +1,11 @@
import { memo } from 'react';
import type { FC } from 'react'; import type { FC } from 'react';
import { CssBaseline } from '@mui/material';
import { import {
CssBaseline,
ThemeProvider, ThemeProvider,
responsiveFontSizes, createTheme,
tooltipClasses responsiveFontSizes
} from '@mui/material'; } from '@mui/material/styles';
import { createTheme } from '@mui/material/styles';
import type { RequiredChildrenProps } from 'utils'; import type { RequiredChildrenProps } from 'utils';
@@ -16,9 +14,9 @@ export const dialogStyle = {
borderRadius: '8px', borderRadius: '8px',
borderColor: '#565656', borderColor: '#565656',
borderStyle: 'solid', borderStyle: 'solid',
borderWidth: '2px' borderWidth: '1px'
} }
} as const; };
const theme = responsiveFontSizes( const theme = responsiveFontSizes(
createTheme({ createTheme({
@@ -36,45 +34,15 @@ 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> = memo(({ children }) => ( const CustomTheme: FC<RequiredChildrenProps> = ({ children }) => (
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<CssBaseline /> <CssBaseline />
{children} {children}
</ThemeProvider> </ThemeProvider>
)); );
export default CustomTheme; export default CustomTheme;
+27 -48
View File
@@ -1,26 +1,25 @@
import { memo, useContext, useEffect, useState } from 'react'; import { useContext, useState } from 'react';
import { toast } from 'react-toastify';
import ForwardIcon from '@mui/icons-material/Forward'; import ForwardIcon from '@mui/icons-material/Forward';
import { Box, Button, Paper, Typography } from '@mui/material'; import { Box, Button, Paper, Typography } from '@mui/material';
import type { Theme } from '@mui/material/styles';
import * as AuthenticationApi from 'components/routing/authentication'; import * as AuthenticationApi from 'components/routing/authentication';
import { useRequest } from 'alova/client'; import { useRequest } from 'alova/client';
import type { ValidateFieldsError } from 'async-validator';
import { import {
LanguageSelector, LanguageSelector,
ValidatedPasswordField, ValidatedPasswordField,
ValidatedTextField ValidatedTextField
} from 'components'; } from 'components';
import { toast } from 'components/toast';
import { AuthenticationContext } from 'contexts/authentication'; import { AuthenticationContext } from 'contexts/authentication';
import { PROJECT_NAME } from 'env'; import { PROJECT_NAME } from 'env';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import type { SignInRequest } from 'types'; import type { SignInRequest } from 'types';
import { onEnterCallback, updateValue } from 'utils'; import { onEnterCallback, updateValue } from 'utils';
import { SIGN_IN_REQUEST_VALIDATOR, ValidationError, validate } from 'validators'; import { SIGN_IN_REQUEST_VALIDATOR, validate } from 'validators';
import type { ValidateFieldsError } from 'validators/schema';
const SignIn = memo(() => { const SignIn = () => {
const authenticationContext = useContext(AuthenticationContext); const authenticationContext = useContext(AuthenticationContext);
const { LL } = useI18nContext(); const { LL } = useI18nContext();
@@ -37,17 +36,13 @@ const SignIn = memo(() => {
{ {
immediate: false immediate: false
} }
).onSuccess((response: { data: { access_token: string } }) => { ).onSuccess((response) => {
if (response.data) { if (response.data) {
authenticationContext.signIn(response.data.access_token); authenticationContext.signIn(response.data.access_token);
} }
}); });
const updateLoginRequestValue = updateValue((updater) => const updateLoginRequestValue = updateValue(setSignInRequest);
setSignInRequest(
updater as unknown as (prevState: SignInRequest) => SignInRequest
)
);
const signIn = async () => { const signIn = async () => {
await callSignIn(signInRequest).catch((event: Error) => { await callSignIn(signInRequest).catch((event: Error) => {
@@ -69,33 +64,22 @@ const SignIn = memo(() => {
await validate(SIGN_IN_REQUEST_VALIDATOR, signInRequest); await validate(SIGN_IN_REQUEST_VALIDATOR, signInRequest);
await signIn(); await signIn();
} catch (error) { } catch (error) {
setFieldErrors((error as ValidationError).fieldErrors); setFieldErrors(error as ValidateFieldsError);
setProcessing(false); setProcessing(false);
} }
}; };
const submitOnEnter = onEnterCallback(signIn); const submitOnEnter = onEnterCallback(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
sx={(theme: Theme) => ({ display="flex"
display: 'flex', height="100vh"
height: '100vh', margin="auto"
margin: 'auto', padding={2}
padding: 2, justifyContent="center"
justifyContent: 'center', flexDirection="column"
flexDirection: 'column', maxWidth={(theme) => theme.breakpoints.values.sm}
maxWidth: theme.breakpoints.values.sm
})}
> >
<Paper <Paper
sx={(theme) => ({ sx={(theme) => ({
@@ -108,29 +92,23 @@ const SignIn = memo(() => {
width: '100%' width: '100%'
})} })}
> >
<Typography sx={{ mb: 1 }} variant="h4"> <Typography variant="h4">{PROJECT_NAME}</Typography>
{PROJECT_NAME}
</Typography>
<LanguageSelector /> <LanguageSelector />
<Box
sx={{ <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: '32ch' width: 240
}} }}
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',
@@ -139,16 +117,17 @@ const SignIn = memo(() => {
}} }}
/> />
<ValidatedPasswordField <ValidatedPasswordField
fieldErrors={fieldErrors || {}} fieldErrors={fieldErrors}
disabled={processing} disabled={processing}
sx={{ sx={{
width: '32ch' width: 240
}} }}
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>
@@ -165,6 +144,6 @@ const SignIn = memo(() => {
</Paper> </Paper>
</Box> </Box>
); );
}); };
export default SignIn; export default SignIn;
+17 -42
View File
@@ -4,10 +4,8 @@ import type {
APIcall, APIcall,
Action, Action,
Activity, Activity,
CommandItem,
Commands,
CoreData, CoreData,
DashboardData, DashboardItem,
DeviceData, DeviceData,
DeviceEntity, DeviceEntity,
Entities, Entities,
@@ -22,18 +20,19 @@ 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', MSGPACK_CONFIG); alovaInstance.Get<DashboardItem[]>('/rest/dashboardData', {
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 },
...MSGPACK_CONFIG responseType: 'arraybuffer' // uses msgpack
}); });
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);
@@ -67,13 +66,12 @@ export const callAction = (action: Action) =>
// SettingsCustomization // SettingsCustomization
export const readDeviceEntities = (id: number) => export const readDeviceEntities = (id: number) =>
alovaInstance.Get<DeviceEntity[]>('/rest/deviceEntities', { // alovaInstance.Get<DeviceEntity[]>(`/rest/deviceEntities/${id}`, {
alovaInstance.Get<DeviceEntity[]>(`/rest/deviceEntities`, {
params: { id }, params: { id },
...MSGPACK_CONFIG, responseType: 'arraybuffer',
// @ts-expect-error - exactOptionalPropertyTypes compatibility issue
transform(data) { transform(data) {
const entities = data as DeviceEntity[]; return (data as DeviceEntity[]).map((de: DeviceEntity) => ({
return entities.map((de) => ({
...de, ...de,
o_m: de.m, o_m: de.m,
o_cn: de.cn, o_cn: de.cn,
@@ -94,17 +92,16 @@ 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) {
const schedule = (data as Schedule).schedule; return (data as Schedule).schedule.map((si: ScheduleItem) => ({
return schedule.map((si) => ({
...si, ...si,
o_id: si.id, o_id: si.id,
o_active: si.active, o_active: si.active,
o_deleted: si.deleted, o_deleted: si.deleted,
o_flags: si.flags, o_flags: si.flags,
o_time: si.time, o_time: si.time,
o_cmd_name: si.cmd_name, o_cmd: si.cmd,
o_value: si.value,
o_name: si.name o_name: si.name
})); }));
} }
@@ -112,30 +109,11 @@ export const readSchedule = () =>
export const writeSchedule = (data: Schedule) => export const writeSchedule = (data: Schedule) =>
alovaInstance.Post('/rest/schedule', data); alovaInstance.Post('/rest/schedule', data);
// Commands
export const readCommands = () =>
alovaInstance.Get<CommandItem[]>('/rest/commands', {
// @ts-expect-error - exactOptionalPropertyTypes compatibility issue
transform(data) {
const commands = (data as Commands).commands;
return commands.map((ci) => ({
...ci,
o_id: ci.id,
o_cmd: ci.cmd,
o_value: ci.value,
o_name: ci.name
}));
}
});
export const writeCommands = (data: Commands) =>
alovaInstance.Post('/rest/commands', data);
// Modules // Modules
export const readModules = () => export const readModules = () =>
alovaInstance.Get<ModuleItem[]>('/rest/modules', { alovaInstance.Get<ModuleItem[]>('/rest/modules', {
transform(data) { transform(data) {
const modules = (data as Modules).modules; return (data as Modules).modules.map((mi: ModuleItem) => ({
return modules.map((mi) => ({
...mi, ...mi,
o_enabled: mi.enabled, o_enabled: mi.enabled,
o_license: mi.license o_license: mi.license
@@ -151,10 +129,8 @@ 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) {
const entities = (data as Entities).entities; return (data as Entities).entities.map((ei: EntityItem) => ({
return entities.map((ei) => ({
...ei, ...ei,
o_id: ei.id, o_id: ei.id,
o_ram: ei.ram, o_ram: ei.ram,
@@ -167,8 +143,7 @@ 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
})); }));
} }
}); });
+46 -42
View File
@@ -4,56 +4,60 @@ import ReactHook from 'alova/react';
import { unpack } from './unpack'; import { unpack } from './unpack';
export const ACCESS_TOKEN = 'access_token' as const; export const ACCESS_TOKEN = 'access_token';
// 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) {
const token = getAccessToken(); if (localStorage.getItem(ACCESS_TOKEN)) {
if (token) { method.config.headers.Authorization =
method.config.headers.Authorization = `Bearer ${token}`; 'Bearer ' + localStorage.getItem(ACCESS_TOKEN);
} }
// for simulating vrey slow networks
// return new Promise((resolve) => {
// const random = 3000 + Math.random() * 2000;
// setTimeout(resolve, Math.floor(random));
// });
}, },
responded: { responded: {
onSuccess: handleResponse onSuccess: async (response: AlovaXHRResponse) => {
// if (response.status === 202) {
// throw new Error('Wait'); // wifi scan in progress
// } else
if (response.status === 205) {
throw new Error('Reboot required');
} else if (response.status === 400) {
throw new Error('Request Failed');
} else if (response.status >= 400) {
throw new Error(response.statusText);
}
const data: ArrayBuffer = (await response.data) as ArrayBuffer;
if (response.data instanceof ArrayBuffer) {
return unpack(data) as ArrayBuffer;
}
return data;
}
// Interceptor for request failure. This interceptor will be entered when the request is wrong.
// http errors like 401 (unauthorized) are handled either in the methods or AuthenticatedRouting()
// onError: (error, method) => {
// alert(error.message);
// }
} }
}); });
export const alovaInstanceGH = createAlova({
baseURL: 'https://api.github.com/repos/emsesp/EMS-ESP32/releases',
statesHook: ReactHook,
requestAdapter: xhrRequestAdapter()
});
+1 -3
View File
@@ -2,14 +2,12 @@ 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: LIST_NETWORKS_TIMEOUT timeout: 20000 // 20 seconds
}); });
export const readNetworkSettings = () => export const readNetworkSettings = () =>
alovaInstance.Get<NetworkSettingsType>('/rest/networkSettings'); alovaInstance.Get<NetworkSettingsType>('/rest/networkSettings');
+1 -1
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);
+17 -5
View File
@@ -1,24 +1,36 @@
import type { LogSettings, SystemStatus } from 'types'; import type { LogSettings, SystemStatus } from 'types';
import { alovaInstance } from './endpoints'; import { alovaInstance, alovaInstanceGH } from './endpoints';
// systemStatus - also used in System Monitor to ping the ESP // systemStatus - also used to ping in Restart monitor for pinging
export const readSystemStatus = () => export const readSystemStatus = () =>
alovaInstance.Get<SystemStatus>('/rest/systemStatus'); alovaInstance.Get<SystemStatus>('/rest/systemStatus');
// 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');
const UPLOAD_TIMEOUT = 60000; // 1 minute // Get versions from GitHub
export const getStableVersion = () =>
alovaInstanceGH.Get('latest', {
transform(response: { data: { name: string } }) {
return response.data.name.substring(1);
}
});
export const getDevVersion = () =>
alovaInstanceGH.Get('tags/latest', {
transform(response: { data: { name: string } }) {
return response.data.name.split(/\s+/).splice(-1)[0].substring(1);
}
});
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: UPLOAD_TIMEOUT timeout: 60000 // override timeout for uploading firmware - 1 minute
}); });
}; };
+75 -40
View File
@@ -1,37 +1,40 @@
// @ts-nocheck - Optimized MessagePack unpacking library for EMS-ESP32 let decoder;
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) {}
class C1Type {} let src;
const C1 = new C1Type(); let srcEnd;
let position = 0;
const EMPTY_ARRAY = [];
let strings = EMPTY_ARRAY;
let stringPosition = 0;
let currentUnpackr = {};
let currentStructures;
let srcString;
let srcStringStart = 0;
let srcStringEnd = 0;
let bundledStrings;
let referenceMap;
const currentExtensions = [];
let dataView;
const defaultOptions = {
useRecords: false,
mapsAsObjects: true
};
export class C1Type {}
export const C1 = new C1Type();
C1.name = 'MessagePack 0xC1'; C1.name = 'MessagePack 0xC1';
let sequentialMode = false, let sequentialMode = false;
inlineObjectReadThreshold = 2, let inlineObjectReadThreshold = 2;
readStruct, let readStruct, onLoadedStructures, onSaveState;
onLoadedStructures, // no-eval build
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) {
@@ -47,15 +50,19 @@ 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; (options.structures = []).uninitialized = true; // this is what we use to denote an uninitialized structures
options.structures.sharedLength = 0; options.structures.sharedLength = 0;
} }
if (options.int64AsNumber) options.int64AsType = 'number'; if (options.int64AsNumber) {
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
@@ -79,6 +86,9 @@ 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 ||
@@ -181,10 +191,10 @@ export class Unpackr {
return this.unpack(source, end); return this.unpack(source, end);
} }
} }
function getPosition() { export function getPosition() {
return position; return position;
} }
function checkedRead(options?: { lazy?: boolean }) { export function checkedRead(options: any) {
try { try {
if (!currentUnpackr.trusted && !sequentialMode) { if (!currentUnpackr.trusted && !sequentialMode) {
const sharedLength = currentStructures.sharedLength || 0; const sharedLength = currentStructures.sharedLength || 0;
@@ -254,7 +264,7 @@ function restoreStructures() {
currentStructures.restoreStructures = null; currentStructures.restoreStructures = null;
} }
function read() { export function read() {
let token = src[position++]; let token = src[position++];
if (token < 0xa0) { if (token < 0xa0) {
if (token < 0x80) { if (token < 0x80) {
@@ -579,7 +589,7 @@ const createSecondByteReader = (firstId, read0) =>
return structure.read(); return structure.read();
}; };
function loadStructures() { export 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;
@@ -595,8 +605,9 @@ var readFixedString = readStringJS;
var readString8 = readStringJS; var readString8 = readStringJS;
var readString16 = readStringJS; var readString16 = readStringJS;
var readString32 = readStringJS; var readString32 = readStringJS;
let isNativeAccelerationEnabled = false; export 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);
@@ -690,7 +701,7 @@ function readStringJS(length) {
return result; return result;
} }
function readString(source, start, length) { export function readString(source, start, length) {
const existingSrc = src; const existingSrc = src;
src = source; src = source;
position = start; position = start;
@@ -1054,7 +1065,7 @@ currentExtensions[0x70] = (data) => {
currentExtensions[0x73] = () => new Set(read()); currentExtensions[0x73] = () => new Set(read());
const typedArrays = [ export const typedArrays = [
'Int8', 'Int8',
'Uint8', 'Uint8',
'Uint8Clamped', 'Uint8Clamped',
@@ -1166,20 +1177,44 @@ 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;
} }
function clearSource() { export function clearSource() {
src = null; src = null;
referenceMap = null; referenceMap = null;
currentStructures = null; currentStructures = null;
} }
function addExtension(extension) { export 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;
} }
const mult10 = new Array(147); export const mult10 = new Array(147); // this is a table matching binary exponents to the multiplier to determine significant digit rounding
for (let i = 0; i < 256; i++) { 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));
} }
const defaultUnpackr = new Unpackr({ useRecords: false }); export const Decoder = Unpackr;
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;
}
-284
View File
@@ -1,284 +0,0 @@
import { useState } from 'react';
import { useBlocker } from 'react-router';
import AddIcon from '@mui/icons-material/Add';
import CancelIcon from '@mui/icons-material/Cancel';
import WarningIcon from '@mui/icons-material/Warning';
import { Box, Button, Typography } from '@mui/material';
import {
Body,
Cell,
Header,
HeaderCell,
HeaderRow,
Row,
Table
} from '@table-library/react-table-library/table';
import { useTheme } from '@table-library/react-table-library/theme';
import { updateState, useRequest } from 'alova/client';
import {
BlockNavigation,
ButtonRow,
FormLoader,
SectionContent,
useLayoutTitle
} from 'components';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react';
import { useInterval } from 'utils';
import { readCommands, writeCommands } from '../../api/app';
import CommandsDialog from './CommandsDialog';
import type { CommandItem, Commands as CommandsType } from './types';
import { commandItemValidation } from './validators';
const INTERVAL_DELAY = 30000;
const MIN_ID = -100;
const MAX_ID = 100;
const DEFAULT_COMMAND_ITEM: Omit<CommandItem, 'id'> = {
cmd: '',
value: '',
name: '',
deleted: false
};
const commandsTheme = {
Table: `
--data-table-library_grid-template-columns: repeat(1, minmax(100px, 1fr)) repeat(1, minmax(100px, 1fr)) 160px;
`,
BaseRow: `
font-size: 14px;
.td {
height: 32px;
}
`,
BaseCell: `
&:nth-of-type(1) {
padding: 8px;
}
`,
HeaderRow: `
text-transform: uppercase;
background-color: black;
color: #90CAF9;
.th {
border-bottom: 1px solid #565656;
height: 36px;
}
`,
Row: `
background-color: #1e1e1e;
position: relative;
cursor: pointer;
.td {
border-bottom: 1px solid #565656;
}
&:hover .td {
background-color: #177ac9;
}
`
};
const Commands = () => {
const { LL } = useI18nContext();
const [numChanges, setNumChanges] = useState<number>(0);
const blocker = useBlocker(numChanges !== 0);
const [selectedItem, setSelectedItem] = useState<CommandItem>();
const [creating, setCreating] = useState<boolean>(false);
const [dialogOpen, setDialogOpen] = useState<boolean>(false);
useLayoutTitle(LL.COMMANDS());
const {
data: commands,
send: fetchCommands,
error
} = useRequest(readCommands, {
initialData: []
});
const { send: updateCommands } = useRequest(
(data: CommandsType) => writeCommands(data),
{ immediate: false }
);
const hasChanged = (ci: CommandItem) =>
ci.id !== ci.o_id ||
(ci.name || '') !== (ci.o_name || '') ||
ci.cmd !== ci.o_cmd ||
ci.value !== ci.o_value ||
ci.deleted !== ci.o_deleted;
useInterval(() => {
if (numChanges === 0) {
void fetchCommands();
}
}, INTERVAL_DELAY);
const theme = useTheme(commandsTheme);
const saveCommands = async () => {
try {
await updateCommands({
commands: commands
.filter((ci: CommandItem) => !ci.deleted)
.map((ci: CommandItem) => ({
id: ci.id,
cmd: ci.cmd,
value: ci.value,
name: ci.name
}))
});
toast.success(LL.UPDATED_OF(LL.COMMANDS()));
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
toast.error(message);
} finally {
await fetchCommands();
setNumChanges(0);
}
};
const editItem = (ci: CommandItem) => {
setCreating(false);
setSelectedItem(ci);
setDialogOpen(true);
if (ci.o_name === undefined) {
ci.o_name = ci.name;
}
};
const onDialogClose = () => {
setDialogOpen(false);
};
const onDialogCancel = async () => {
await fetchCommands().then(() => {
setNumChanges(0);
});
};
const onDialogSave = (updatedItem: CommandItem) => {
setDialogOpen(false);
void updateState(readCommands(), (data: CommandItem[]) => {
const new_data = creating
? [...data, updatedItem]
: data.map((ci) =>
ci.id === updatedItem.id ? { ...ci, ...updatedItem } : ci
);
setNumChanges(new_data.filter((ci) => hasChanged(ci)).length);
return new_data;
});
};
const addItem = () => {
setCreating(true);
const newItem: CommandItem = {
id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
...DEFAULT_COMMAND_ITEM
};
setSelectedItem(newItem);
setDialogOpen(true);
};
const filteredCommands = commands.filter((ci: CommandItem) => !ci.deleted);
const renderCommands = () => {
if (!commands) {
return (
<FormLoader onRetry={fetchCommands} errorMessage={error?.message || ''} />
);
}
return (
<Table
data={{ nodes: filteredCommands }}
theme={theme}
layout={{ custom: true }}
>
{(tableList: CommandItem[]) => (
<>
<Header>
<HeaderRow>
<HeaderCell stiff>{LL.COMMAND(0)}</HeaderCell>
<HeaderCell stiff>{LL.VALUE(0)}</HeaderCell>
<HeaderCell stiff>{LL.NAME(0)}</HeaderCell>
</HeaderRow>
</Header>
<Body>
{tableList.map((ci: CommandItem) => (
<Row key={ci.id} item={ci} onClick={() => editItem(ci)}>
<Cell>{ci.cmd}</Cell>
<Cell>{ci.value}</Cell>
<Cell>{ci.name}</Cell>
</Row>
))}
</Body>
</>
)}
</Table>
);
};
return (
<SectionContent>
{blocker ? <BlockNavigation blocker={blocker} /> : null}
<Typography sx={{ mb: 2 }} color="warning" variant="body1">
{LL.COMMANDS_HELP_1()}.
</Typography>
{renderCommands()}
{selectedItem && (
<CommandsDialog
open={dialogOpen}
creating={creating}
onClose={onDialogClose}
onSave={onDialogSave}
selectedItem={selectedItem}
validator={commandItemValidation(commands, selectedItem)}
/>
)}
<Box sx={{ display: 'flex', flexWrap: 'wrap' }}>
<Box sx={{ flexGrow: 1 }}>
{numChanges !== 0 && (
<ButtonRow>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={onDialogCancel}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<WarningIcon color="warning" />}
variant="contained"
color="info"
onClick={saveCommands}
>
{LL.APPLY_CHANGES(numChanges)}
</Button>
</ButtonRow>
)}
</Box>
<Box sx={{ flexWrap: 'nowrap', whiteSpace: 'nowrap' }}>
<ButtonRow>
<Button
startIcon={<AddIcon />}
variant="outlined"
color="primary"
onClick={addItem}
>
{LL.ADD(0)}
</Button>
</ButtonRow>
</Box>
</Box>
</SectionContent>
);
};
export default Commands;
-206
View File
@@ -1,206 +0,0 @@
import { useEffect, useState } from 'react';
import AddIcon from '@mui/icons-material/Add';
import CancelIcon from '@mui/icons-material/Cancel';
import DoneIcon from '@mui/icons-material/Done';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import RemoveIcon from '@mui/icons-material/RemoveCircleOutlined';
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
TextField
} from '@mui/material';
import { callAction } from '@/api/app';
import { dialogStyle } from 'CustomTheme';
import { useRequest } from 'alova/client';
import { ValidatedTextField } from 'components';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react';
import { updateValue } from 'utils';
import { ValidationError, validate } from 'validators';
import type Schema from 'validators/schema';
import type { ValidateFieldsError } from 'validators/schema';
import type { CommandItem } from './types';
interface CommandsDialogProps {
open: boolean;
creating: boolean;
onClose: () => void;
onSave: (ci: CommandItem) => void;
selectedItem: CommandItem;
validator: Schema;
}
const CommandsDialog = ({
open,
creating,
onClose,
onSave,
selectedItem,
validator
}: CommandsDialogProps) => {
const { LL } = useI18nContext();
const [hasChanges, setHasChanges] = useState<boolean>(false);
const [editItem, setEditItem] = useState<CommandItem>(selectedItem);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const updateFormValue = updateValue(
setEditItem as unknown as React.Dispatch<
React.SetStateAction<Record<string, unknown>>
>
);
useEffect(() => {
if (open) {
setFieldErrors(undefined);
setEditItem(selectedItem);
}
}, [open, selectedItem]);
const hasChanged = (ci: CommandItem) =>
ci.id !== ci.o_id ||
(ci.name || '') !== (ci.o_name || '') ||
ci.cmd !== ci.o_cmd ||
ci.value !== ci.o_value ||
ci.deleted !== ci.o_deleted;
useEffect(() => {
setHasChanges(hasChanged(editItem));
}, [editItem]);
const handleSave = async (itemToSave: CommandItem) => {
try {
setFieldErrors(undefined);
await validate(validator, itemToSave);
onSave(itemToSave);
} catch (error) {
setFieldErrors((error as ValidationError).fieldErrors);
} finally {
// setHasChanges(false);
}
};
const save = async () => {
await handleSave(editItem);
};
const { send: executeCommand } = useRequest(
(id: string) => callAction({ action: 'executeCommand', param: id }),
{ immediate: false }
)
.onSuccess(() => {
toast.success(LL.COMMAND_EXECUTED());
})
.onError((error) => {
toast.error(String(error.error?.message || 'An error occurred'));
});
const execute = async () => {
await executeCommand(editItem.name);
};
const remove = () => {
onSave({ ...editItem, deleted: true });
};
const handleClose = (
_event: React.SyntheticEvent,
reason: 'backdropClick' | 'escapeKeyDown'
) => {
if (reason !== 'backdropClick') {
onClose();
}
};
return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
<DialogTitle>
{creating ? `${LL.ADD(1)} ` : LL.UPDATE() + '/' + LL.RUN()}&nbsp;
{LL.COMMAND(0)}
</DialogTitle>
<DialogContent dividers>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
name="cmd"
label={LL.COMMAND(0)}
multiline
fullWidth
value={editItem.cmd}
margin="normal"
onChange={updateFormValue}
/>
<TextField
name="value"
label={LL.VALUE(0)}
multiline
margin="normal"
fullWidth
value={editItem.value}
onChange={updateFormValue}
/>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
name="name"
label={LL.NAME(0)}
value={editItem.name}
fullWidth
margin="normal"
onChange={updateFormValue}
/>
</DialogContent>
<DialogActions>
{!creating && (
<Box sx={{ flexGrow: 1 }}>
<Button
startIcon={<RemoveIcon />}
variant="outlined"
color="warning"
onClick={remove}
>
{LL.REMOVE()}
</Button>
</Box>
)}
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={onClose}
color="secondary"
>
{LL.CANCEL()}
</Button>
{hasChanges && (
<Button
startIcon={creating ? <AddIcon /> : <DoneIcon />}
variant="outlined"
onClick={save}
color="primary"
>
{creating ? LL.ADD(0) : LL.UPDATE()}
</Button>
)}
{!creating && !hasChanges && editItem.cmd !== '' && (
<Button
startIcon={<PlayArrowIcon />}
variant="outlined"
onClick={execute}
color="success"
>
{LL.RUN()}
</Button>
)}
</DialogActions>
</Dialog>
);
};
export default CommandsDialog;
+36 -74
View File
@@ -1,5 +1,6 @@
import { useState } from 'react'; import { useCallback, useState } from 'react';
import { useBlocker } from 'react-router'; import { useBlocker } from 'react-router-dom';
import { toast } from 'react-toastify';
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';
@@ -25,7 +26,6 @@ import {
SectionContent, SectionContent,
useLayoutTitle useLayoutTitle
} from 'components'; } from 'components';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import { useInterval } from 'utils'; import { useInterval } from 'utils';
@@ -35,10 +35,6 @@ 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);
@@ -61,14 +57,14 @@ const CustomEntities = () => {
if (!dialogOpen && !numChanges) { if (!dialogOpen && !numChanges) {
void fetchEntities(); void fetchEntities();
} }
}); }, 3000);
const { send: writeEntities } = useRequest( const { send: writeEntities } = useRequest(
(data: Entities) => writeCustomEntities(data), (data: Entities) => writeCustomEntities(data),
{ immediate: false } { immediate: false }
); );
const hasEntityChanged = (ei: EntityItem) => { function hasEntityChanged(ei: EntityItem) {
return ( return (
ei.id !== ei.o_id || ei.id !== ei.o_id ||
ei.ram !== ei.o_ram || ei.ram !== ei.o_ram ||
@@ -80,15 +76,14 @@ 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 = useTheme({
Table: ` 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 90px;
`, `,
BaseRow: ` BaseRow: `
font-size: 14px; font-size: 14px;
@@ -141,8 +136,8 @@ const CustomEntities = () => {
const saveEntities = async () => { const saveEntities = async () => {
await writeEntities({ await writeEntities({
entities: entities entities: entities
.filter((ei: EntityItem) => !ei.deleted) .filter((ei) => !ei.deleted)
.map((condensed_ei: EntityItem) => ({ .map((condensed_ei) => ({
id: condensed_ei.id, id: condensed_ei.id,
ram: condensed_ei.ram, ram: condensed_ei.ram,
name: condensed_ei.name, name: condensed_ei.name,
@@ -152,7 +147,6 @@ 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
})) }))
@@ -169,11 +163,11 @@ const CustomEntities = () => {
}); });
}; };
const editEntityItem = (ei: EntityItem) => { const editEntityItem = useCallback((ei: EntityItem) => {
setCreating(false); setCreating(false);
setSelectedEntityItem(ei); setSelectedEntityItem(ei);
setDialogOpen(true); setDialogOpen(true);
}; }, []);
const onDialogClose = () => { const onDialogClose = () => {
setDialogOpen(false); setDialogOpen(false);
@@ -201,30 +195,10 @@ const CustomEntities = () => {
}); });
}; };
const onDialogDup = (item: EntityItem) => {
setCreating(true);
setSelectedEntityItem({
id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
name: item.name + '_',
ram: item.ram,
device_id: item.device_id,
type_id: item.type_id,
offset: item.offset,
factor: item.factor,
uom: item.uom,
value_type: item.value_type,
writeable: item.writeable,
deleted: false,
hide: item.hide,
value: item.value
});
setDialogOpen(true);
};
const addEntityItem = () => { const addEntityItem = () => {
setCreating(true); setCreating(true);
setSelectedEntityItem({ setSelectedEntityItem({
id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID), id: Math.floor(Math.random() * (Math.floor(200) - 100) + 100),
name: '', name: '',
ram: 0, ram: 0,
device_id: '0', device_id: '0',
@@ -235,41 +209,35 @@ const CustomEntities = () => {
value_type: 0, value_type: 0,
writeable: false, writeable: false,
deleted: false, deleted: false,
hide: false,
value: '' value: ''
}); });
setDialogOpen(true); setDialogOpen(true);
}; };
const formatValue = (value: unknown, uom: number) => { function formatValue(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);
}; }
const showHex = (value: number, digit: number) => { function showHex(value: number, digit: number) {
return `0x${value.toString(16).toUpperCase().padStart(digit, '0')}`; return '0x' + value.toString(16).toUpperCase().padStart(digit, '0');
}; }
const filteredAndSortedEntities =
entities
?.filter((ei: EntityItem) => !ei.deleted)
.sort((a: EntityItem, b: EntityItem) => a.name.localeCompare(b.name)) ?? [];
const renderEntity = () => { const renderEntity = () => {
if (!entities) { if (!entities) {
return ( return <FormLoader onRetry={fetchEntities} errorMessage={error?.message} />;
<FormLoader onRetry={fetchEntities} errorMessage={error?.message || ''} />
);
} }
return ( return (
<Table <Table
data={{ data={{
nodes: filteredAndSortedEntities nodes: entities
.filter((ei) => !ei.deleted)
.sort((a, b) => a.name.localeCompare(b.name))
}} }}
theme={entity_theme} theme={entity_theme}
layout={{ custom: true }} layout={{ custom: true }}
@@ -292,21 +260,16 @@ const CustomEntities = () => {
<Cell> <Cell>
{ei.name}&nbsp; {ei.name}&nbsp;
{ei.writeable && ( {ei.writeable && (
<EditOutlinedIcon <EditOutlinedIcon color="primary" sx={{ fontSize: 12 }} />
color="primary"
sx={{ fontSize: ICON_SIZE }}
/>
)} )}
</Cell> </Cell>
<Cell>{ei.ram > 0 ? '' : showHex(ei.device_id as number, 2)}</Cell>
<Cell>{ei.ram > 0 ? '' : showHex(ei.type_id as number, 3)}</Cell>
<Cell>{ei.ram > 0 ? '' : ei.offset}</Cell>
<Cell> <Cell>
{ei.ram === 1 {ei.ram === 1 ? '' : showHex(ei.device_id as number, 2)}
? 'RAM' </Cell>
: ei.ram === 2 <Cell>{ei.ram === 1 ? '' : showHex(ei.type_id as number, 3)}</Cell>
? 'NVS' <Cell>{ei.ram === 1 ? '' : ei.offset}</Cell>
: DeviceValueTypeNames[ei.value_type]} <Cell>
{ei.ram === 1 ? 'RAM' : DeviceValueTypeNames[ei.value_type]}
</Cell> </Cell>
<Cell>{formatValue(ei.value, ei.uom)}</Cell> <Cell>{formatValue(ei.value, ei.uom)}</Cell>
</Row> </Row>
@@ -321,9 +284,9 @@ const CustomEntities = () => {
return ( return (
<SectionContent> <SectionContent>
{blocker ? <BlockNavigation blocker={blocker} /> : null} {blocker ? <BlockNavigation blocker={blocker} /> : null}
<Typography sx={{ mb: 2 }} color="warning" variant="body1"> <Box mb={2} color="warning.main">
{LL.ENTITIES_HELP_1()}. <Typography variant="body1">{LL.ENTITIES_HELP_1()}.</Typography>
</Typography> </Box>
{renderEntity()} {renderEntity()}
@@ -333,14 +296,13 @@ const CustomEntities = () => {
creating={creating} creating={creating}
onClose={onDialogClose} onClose={onDialogClose}
onSave={onDialogSave} onSave={onDialogSave}
onDup={onDialogDup}
selectedItem={selectedEntityItem} selectedItem={selectedEntityItem}
validator={entityItemValidation(entities, selectedEntityItem)} validator={entityItemValidation(entities, selectedEntityItem)}
/> />
)} )}
<Box sx={{ mt: 2, display: 'flex', flexWrap: 'wrap' }}> <Box mt={1} display="flex" flexWrap="wrap">
<Box sx={{ flexGrow: 1 }}> <Box flexGrow={1}>
{numChanges > 0 && ( {numChanges > 0 && (
<ButtonRow> <ButtonRow>
<Button <Button
@@ -362,7 +324,7 @@ const CustomEntities = () => {
</ButtonRow> </ButtonRow>
)} )}
</Box> </Box>
<Box sx={{ flexWrap: 'nowrap', whiteSpace: 'nowrap' }}> <Box flexWrap="nowrap" whiteSpace="nowrap">
<Button <Button
startIcon={<AddIcon />} startIcon={<AddIcon />}
variant="outlined" variant="outlined"
+79 -175
View File
@@ -2,12 +2,8 @@ import { useEffect, 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 RemoveIcon from '@mui/icons-material/RemoveCircleOutline';
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
import InsertCommentOutlinedIcon from '@mui/icons-material/InsertCommentOutlined';
import RemoveIcon from '@mui/icons-material/RemoveCircleOutlined';
import { import {
Box, Box,
Button, Button,
@@ -16,42 +12,28 @@ import {
DialogActions, DialogActions,
DialogContent, DialogContent,
DialogTitle, DialogTitle,
Grid,
InputAdornment, InputAdornment,
MenuItem, MenuItem,
TextField TextField
} from '@mui/material'; } from '@mui/material';
import Grid from '@mui/material/Grid2';
import { dialogStyle } from 'CustomTheme'; import { dialogStyle } from 'CustomTheme';
import type Schema from 'async-validator';
import type { ValidateFieldsError } from 'async-validator';
import { BlockFormControlLabel, ValidatedTextField } from 'components'; import { BlockFormControlLabel, ValidatedTextField } from 'components';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import { numberValue, updateValue } from 'utils'; import { numberValue, updateValue } from 'utils';
import { ValidationError, validate } from 'validators'; import { validate } from 'validators';
import type Schema from 'validators/schema';
import type { ValidateFieldsError } from 'validators/schema';
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;
onClose: () => void; onClose: () => void;
onSave: (ei: EntityItem) => void; onSave: (ei: EntityItem) => void;
onDup: (ei: EntityItem) => void;
selectedItem: EntityItem; selectedItem: EntityItem;
validator: Schema; validator: Schema;
} }
@@ -61,50 +43,28 @@ const CustomEntitiesDialog = ({
creating, creating,
onClose, onClose,
onSave, onSave,
onDup,
selectedItem, selectedItem,
validator validator
}: CustomEntitiesDialogProps) => { }: CustomEntitiesDialogProps) => {
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( const updateFormValue = updateValue(setEditItem);
setEditItem as unknown as React.Dispatch<
React.SetStateAction<Record<string, unknown>>
>
);
useEffect(() => { useEffect(() => {
if (open) { if (open) {
setFieldErrors(undefined); setFieldErrors(undefined);
// Convert to hex strings - combined into single setEditItem call setEditItem(selectedItem);
const deviceIdHex = // convert to hex strings straight away
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: deviceIdHex, device_id: selectedItem.device_id.toString(16).toUpperCase(),
type_id: typeIdHex, type_id: selectedItem.type_id.toString(16).toUpperCase()
factor: factorValue
}); });
} }
}, [open, selectedItem]); }, [open, selectedItem]);
const handleClose = ( const handleClose = (_event, reason: 'backdropClick' | 'escapeKeyDown') => {
_event: React.SyntheticEvent,
reason: 'backdropClick' | 'escapeKeyDown'
) => {
if (reason !== 'backdropClick') { if (reason !== 'backdropClick') {
onClose(); onClose();
} }
@@ -114,52 +74,36 @@ const CustomEntitiesDialog = ({
try { try {
setFieldErrors(undefined); setFieldErrors(undefined);
await validate(validator, editItem); await validate(validator, editItem);
if (typeof editItem.device_id === 'string') {
// Create a copy to avoid mutating the state directly editItem.device_id = parseInt(editItem.device_id, 16);
const processedItem: EntityItem = { ...editItem };
if (typeof processedItem.device_id === 'string') {
processedItem.device_id = Number.parseInt(processedItem.device_id, 16);
} }
if (typeof processedItem.type_id === 'string') { if (typeof editItem.type_id === 'string') {
processedItem.type_id = Number.parseInt(processedItem.type_id, 16); editItem.type_id = parseInt(editItem.type_id, 16);
} }
if ( onSave(editItem);
processedItem.value_type === DeviceValueType.BOOL &&
typeof processedItem.factor === 'string'
) {
processedItem.factor = Number.parseInt(processedItem.factor, 16);
}
onSave(processedItem);
} catch (error) { } catch (error) {
setFieldErrors((error as ValidationError).fieldErrors); setFieldErrors(error as ValidateFieldsError);
} }
}; };
const remove = () => { const remove = () => {
onSave({ ...editItem, deleted: true }); editItem.deleted = true;
onSave(editItem);
}; };
const dup = () => {
onDup(editItem);
};
const uomMenuItems = 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}>
<DialogTitle> <DialogTitle>
{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}
@@ -168,20 +112,6 @@ const CustomEntitiesDialog = ({
onChange={updateFormValue} onChange={updateFormValue}
/> />
</Grid> </Grid>
<Grid sx={{ 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"
@@ -195,45 +125,28 @@ const CustomEntitiesDialog = ({
> >
<MenuItem value={0}>EMS-{LL.VALUE(1)}</MenuItem> <MenuItem value={0}>EMS-{LL.VALUE(1)}</MenuItem>
<MenuItem value={1}>RAM-{LL.VALUE(1)}</MenuItem> <MenuItem value={1}>RAM-{LL.VALUE(1)}</MenuItem>
<MenuItem value={2}>NVS-{LL.VALUE(1)}</MenuItem>
</TextField> </TextField>
</Grid> </Grid>
{editItem.ram > 0 && ( {editItem.ram === 1 && (
<> <Grid>
<Grid> <TextField
<TextField name="value"
name="value" label={LL.DEFAULT(0) + ' ' + LL.VALUE(0)}
label={LL.DEFAULT(0) + ' ' + LL.VALUE(0)} type="string"
type="string" value={editItem.value as string}
value={editItem.value} variant="outlined"
variant="outlined" onChange={updateFormValue}
onChange={updateFormValue} fullWidth
fullWidth margin="normal"
margin="normal" />
/> </Grid>
</Grid>
<Grid>
<TextField
name="uom"
label={LL.UNIT()}
value={editItem.uom}
margin="normal"
onChange={updateFormValue}
select
>
{uomMenuItems}
</TextField>
</Grid>
</>
)} )}
{editItem.ram === 0 && ( {editItem.ram === 0 && (
<> <>
<Grid sx={{ mt: 3 }}> <Grid mt={3} size={9}>
<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"
@@ -244,13 +157,13 @@ 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"
sx={{ width: '11ch' }} sx={{ width: '11ch' }}
type="string" type="string"
value={editItem.device_id} value={editItem.device_id as string}
onChange={updateFormValue} onChange={updateFormValue}
slotProps={{ slotProps={{
input: { input: {
@@ -264,13 +177,13 @@ 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"
sx={{ width: '11ch' }} sx={{ width: '11ch' }}
type="string" type="string"
value={editItem.type_id} value={editItem.type_id as string}
onChange={updateFormValue} onChange={updateFormValue}
slotProps={{ slotProps={{
input: { input: {
@@ -284,7 +197,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"
@@ -305,11 +218,33 @@ const CustomEntitiesDialog = ({
margin="normal" margin="normal"
select select
> >
{VALUE_TYPE_OPTIONS.map((valueType) => ( <MenuItem value={DeviceValueType.BOOL}>
<MenuItem key={valueType} value={valueType}> {DeviceValueTypeNames[DeviceValueType.BOOL]}
{DeviceValueTypeNames[valueType]} </MenuItem>
</MenuItem> <MenuItem value={DeviceValueType.INT8}>
))} {DeviceValueTypeNames[DeviceValueType.INT8]}
</MenuItem>
<MenuItem value={DeviceValueType.UINT8}>
{DeviceValueTypeNames[DeviceValueType.UINT8]}
</MenuItem>
<MenuItem value={DeviceValueType.INT16}>
{DeviceValueTypeNames[DeviceValueType.INT16]}
</MenuItem>
<MenuItem value={DeviceValueType.UINT16}>
{DeviceValueTypeNames[DeviceValueType.UINT16]}
</MenuItem>
<MenuItem value={DeviceValueType.UINT24}>
{DeviceValueTypeNames[DeviceValueType.UINT24]}
</MenuItem>
<MenuItem value={DeviceValueType.TIME}>
{DeviceValueTypeNames[DeviceValueType.TIME]}
</MenuItem>
<MenuItem value={DeviceValueType.UINT32}>
{DeviceValueTypeNames[DeviceValueType.UINT32]}
</MenuItem>
<MenuItem value={DeviceValueType.STRING}>
{DeviceValueTypeNames[DeviceValueType.STRING]}
</MenuItem>
</TextField> </TextField>
</Grid> </Grid>
@@ -320,7 +255,7 @@ const CustomEntitiesDialog = ({
<TextField <TextField
name="factor" name="factor"
label={LL.FACTOR()} label={LL.FACTOR()}
value={numberValue(editItem.factor as number)} value={numberValue(editItem.factor)}
variant="outlined" variant="outlined"
onChange={updateFormValue} onChange={updateFormValue}
sx={{ width: '11ch' }} sx={{ width: '11ch' }}
@@ -341,7 +276,11 @@ const CustomEntitiesDialog = ({
onChange={updateFormValue} onChange={updateFormValue}
select select
> >
{uomMenuItems} {DeviceValueUOM_s.map((val, i) => (
<MenuItem key={val} value={i}>
{val}
</MenuItem>
))}
</TextField> </TextField>
</Grid> </Grid>
</> </>
@@ -350,51 +289,25 @@ 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="Bytes"
value={numberValue(editItem.factor as number)} value={numberValue(editItem.factor)}
sx={{ width: '11ch' }} sx={{ width: '11ch' }}
variant="outlined" variant="outlined"
onChange={updateFormValue} onChange={updateFormValue}
margin="normal" margin="normal"
type="number" type="number"
slotProps={{
htmlInput: { step: '1', min: '1', max: '255' }
}}
/> />
</Grid> </Grid>
)} )}
{editItem.value_type === DeviceValueType.BOOL && (
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
name="factor"
label={LL.BITMASK()}
value={editItem.factor}
sx={{ width: '11ch' }}
variant="outlined"
onChange={updateFormValue}
margin="normal"
type="string"
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">0x</InputAdornment>
)
},
htmlInput: { style: { textTransform: 'uppercase' } }
}}
/>
</Grid>
)}
</> </>
)} )}
</Grid> </Grid>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
{!creating && ( {!creating && (
<Box sx={{ flexGrow: 1 }}> <Box flexGrow={1}>
<Button <Button
startIcon={<RemoveIcon />} startIcon={<RemoveIcon />}
variant="outlined" variant="outlined"
@@ -403,15 +316,6 @@ const CustomEntitiesDialog = ({
> >
{LL.REMOVE()} {LL.REMOVE()}
</Button> </Button>
<Button
sx={{ ml: 1 }}
startIcon={<AddIcon />}
variant="outlined"
color="primary"
onClick={dup}
>
{LL.DUPLICATE()}
</Button>
</Box> </Box>
)} )}
<Button <Button
+157 -207
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useBlocker, useLocation } from 'react-router'; import { useBlocker, useLocation } from 'react-router-dom';
import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import EditIcon from '@mui/icons-material/Edit'; import EditIcon from '@mui/icons-material/Edit';
@@ -15,7 +16,6 @@ import {
DialogActions, DialogActions,
DialogContent, DialogContent,
DialogTitle, DialogTitle,
Grid,
InputAdornment, InputAdornment,
Link, Link,
MenuItem, MenuItem,
@@ -24,6 +24,7 @@ import {
ToggleButtonGroup, ToggleButtonGroup,
Typography Typography
} from '@mui/material'; } from '@mui/material';
import Grid from '@mui/material/Grid2';
import { import {
Body, Body,
@@ -37,7 +38,7 @@ import {
import { useTheme } from '@table-library/react-table-library/theme'; import { useTheme } from '@table-library/react-table-library/theme';
import { dialogStyle } from 'CustomTheme'; import { dialogStyle } from 'CustomTheme';
import { useRequest } from 'alova/client'; import { useRequest } from 'alova/client';
import SystemMonitor from 'app/status/SystemMonitor'; import RestartMonitor from 'app/status/RestartMonitor';
import { import {
BlockNavigation, BlockNavigation,
ButtonRow, ButtonRow,
@@ -45,7 +46,6 @@ import {
SectionContent, SectionContent,
useLayoutTitle useLayoutTitle
} from 'components'; } from 'components';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import { import {
@@ -62,24 +62,7 @@ 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();
@@ -111,14 +94,13 @@ const Customizations = () => {
const [selectedDeviceTypeNameURL, setSelectedDeviceTypeNameURL] = const [selectedDeviceTypeNameURL, setSelectedDeviceTypeNameURL] =
useState<string>(''); // needed for API URL useState<string>(''); // needed for API URL
const [selectedDeviceName, setSelectedDeviceName] = useState<string>(''); const [selectedDeviceName, setSelectedDeviceName] = useState<string>('');
const [selectedDeviceBrand, setSelectedDeviceBrand] = useState<string>('');
const { send: sendResetCustomizations } = useRequest(resetCustomizations(), { const { send: sendResetCustomizations } = useRequest(resetCustomizations(), {
immediate: false immediate: false
}); });
const { send: sendDeviceName } = useRequest( const { send: sendDeviceName } = useRequest(
(data: { id: number; name: string; brand: string }) => writeDeviceName(data), (data: { id: number; name: string }) => writeDeviceName(data),
{ {
immediate: false immediate: false
} }
@@ -143,22 +125,13 @@ const Customizations = () => {
const setOriginalSettings = (data: DeviceEntity[]) => { const setOriginalSettings = (data: DeviceEntity[]) => {
setDeviceEntities( setDeviceEntities(
data.map((de) => { data.map((de) => ({
const result: DeviceEntity = { ...de,
...de, o_m: de.m,
o_m: de.m o_cn: de.cn,
}; o_mi: de.mi,
if (de.cn !== undefined) { o_ma: de.ma
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;
})
); );
}; };
@@ -247,8 +220,19 @@ const Customizations = () => {
useEffect(() => { useEffect(() => {
if (deviceEntities.length) { if (deviceEntities.length) {
const changedEntities = deviceEntities.filter((de) => hasEntityChanged(de)); setNumChanges(
setNumChanges(changedEntities.length); deviceEntities
.filter((de) => hasEntityChanged(de))
.map(
(new_de) =>
new_de.m.toString(16).padStart(2, '0') +
new_de.id +
(new_de.cn || new_de.mi || new_de.ma ? '|' : '') +
(new_de.cn ? new_de.cn : '') +
(new_de.mi ? '>' + new_de.mi : '') +
(new_de.ma ? '<' + new_de.ma : '')
).length
);
} }
}, [deviceEntities]); }, [deviceEntities]);
@@ -260,12 +244,8 @@ const Customizations = () => {
setSelectedDevice(-1); setSelectedDevice(-1);
setSelectedDeviceTypeNameURL(''); setSelectedDeviceTypeNameURL('');
} else { } else {
const device = devices.devices[index]; setSelectedDeviceTypeNameURL(devices.devices[index].url || '');
if (device) { setSelectedDeviceName(devices.devices[index].n);
setSelectedDeviceTypeNameURL(device.url || '');
setSelectedDeviceName(device.n);
setSelectedDeviceBrand(device.b);
}
setNumChanges(0); setNumChanges(0);
setRestartNeeded(false); setRestartNeeded(false);
} }
@@ -283,23 +263,18 @@ const Customizations = () => {
return value as string; return value as string;
} }
const isCommand = (de: DeviceEntity) => { const formatName = (de: DeviceEntity, withShortname: boolean) =>
return de.n && de.n[0] === '!'; (de.n && de.n[0] === '!'
}; ? de.t
? LL.COMMAND(1) + ': ' + de.t + ' ' + de.n.slice(1)
const formatName = (de: DeviceEntity, withShortname: boolean) => { : LL.COMMAND(1) + ': ' + de.n.slice(1)
let name: string; : de.cn && de.cn !== ''
if (isCommand(de)) { ? de.t
name = de.t ? de.t + ' ' + de.cn
? `${LL.COMMAND(1)}: ${de.t} ${de.n?.slice(1)}` : de.cn
: `${LL.COMMAND(1)}: ${de.n?.slice(1)}`; : de.t
} else if (de.cn && de.cn !== '') { ? de.t + ' ' + de.n
name = de.t ? `${de.t} ${de.cn}` : de.cn; : de.n) + (withShortname ? ' ' + de.id : '');
} else {
name = de.t ? `${de.t} ${de.n}` : de.n || '';
}
return withShortname ? `${name} ${de.id}` : name;
};
const getMaskNumber = (newMask: string[]) => { const getMaskNumber = (newMask: string[]) => {
let new_mask = 0; let new_mask = 0;
@@ -331,20 +306,27 @@ const Customizations = () => {
const filter_entity = (de: DeviceEntity) => const filter_entity = (de: DeviceEntity) =>
(de.m & selectedFilters || !selectedFilters) && (de.m & selectedFilters || !selectedFilters) &&
formatName(de, true).toLowerCase().includes(search.toLowerCase()); formatName(de, true).includes(search);
const maskDisabled = (set: boolean) => { const maskDisabled = (set: boolean) => {
setDeviceEntities((prev) => setDeviceEntities(
prev.map((de) => { deviceEntities.map(function (de) {
if (filter_entity(de)) { if (filter_entity(de)) {
const excludeMask =
DeviceEntityMask.DV_API_MQTT_EXCLUDE | DeviceEntityMask.DV_WEB_EXCLUDE;
return { return {
...de, ...de,
m: set ? de.m | excludeMask : de.m & ~excludeMask m: set
? de.m |
(DeviceEntityMask.DV_API_MQTT_EXCLUDE |
DeviceEntityMask.DV_WEB_EXCLUDE)
: de.m &
~(
DeviceEntityMask.DV_API_MQTT_EXCLUDE |
DeviceEntityMask.DV_WEB_EXCLUDE
)
}; };
} else {
return de;
} }
return de;
}) })
); );
}; };
@@ -357,7 +339,6 @@ const Customizations = () => {
toast.error((error as Error).message); toast.error((error as Error).message);
} finally { } finally {
setConfirmReset(false); setConfirmReset(false);
setRestarting(true);
} }
}; };
@@ -367,10 +348,9 @@ const Customizations = () => {
const updateDeviceEntity = (updatedItem: DeviceEntity) => { const updateDeviceEntity = (updatedItem: DeviceEntity) => {
setDeviceEntities( setDeviceEntities(
(prev) => deviceEntities?.map((de) =>
prev?.map((de) => de.id === updatedItem.id ? { ...de, ...updatedItem } : de
de.id === updatedItem.id ? { ...de, ...updatedItem } : de )
) ?? []
); );
}; };
@@ -379,7 +359,7 @@ const Customizations = () => {
updateDeviceEntity(updatedItem); updateDeviceEntity(updatedItem);
}; };
const editDeviceEntity = (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] === '!')) {
return; return;
} }
@@ -390,54 +370,50 @@ const Customizations = () => {
setSelectedDeviceEntity(de); setSelectedDeviceEntity(de);
setDialogOpen(true); setDialogOpen(true);
}; }, []);
const saveCustomization = async () => { const saveCustomization = async () => {
if (!devices || !deviceEntities || selectedDevice === -1) { if (devices && deviceEntities && selectedDevice !== -1) {
return; const masked_entities = deviceEntities
} .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 : '')
);
const masked_entities = deviceEntities // check size in bytes to match buffer in CPP, which is 2048
.filter((de: DeviceEntity) => hasEntityChanged(de)) const bytes = new TextEncoder().encode(JSON.stringify(masked_entities)).length;
.map((new_de) => createMaskedEntityId(new_de)); if (bytes > 2000) {
toast.warning(LL.CUSTOMIZATIONS_FULL());
return;
}
// check size in bytes to match buffer in CPP, which is 2048 await sendCustomizationEntities({
const bytes = new TextEncoder().encode(JSON.stringify(masked_entities)).length; id: selectedDevice,
if (bytes > MAX_BUFFER_SIZE) { entity_ids: masked_entities
toast.warning(LL.CUSTOMIZATIONS_FULL()); }).catch((error: Error) => {
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);
}
}; };
const renameDevice = async () => { const renameDevice = async () => {
await sendDeviceName({ await sendDeviceName({ id: selectedDevice, name: selectedDeviceName })
id: selectedDevice,
name: selectedDeviceName,
brand: selectedDeviceBrand
})
.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);
@@ -447,31 +423,20 @@ const Customizations = () => {
const renderDeviceList = () => ( const renderDeviceList = () => (
<> <>
<Typography sx={{ mb: 1 }} color="warning" variant="body1"> <Box mb={1} color="warning.main">
{LL.CUSTOMIZATIONS_HELP_1()}. <Typography variant="body1">{LL.CUSTOMIZATIONS_HELP_1()}.</Typography>
</Typography> </Box>
<Box sx={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 2 }}> <Box display="flex" flexWrap="wrap" alignItems="center" gap={2}>
{rename ? ( {rename ? (
<> <TextField
<TextField name="device"
name="device" label={LL.EMS_DEVICE()}
label={LL.EMS_DEVICE()} fullWidth
style={{ minWidth: '48%' }} variant="outlined"
variant="outlined" value={selectedDeviceName}
value={selectedDeviceName} onChange={(e) => setSelectedDeviceName(e.target.value)}
onChange={(e) => setSelectedDeviceName(e.target.value)} margin="normal"
margin="normal" />
/>
<TextField
name="brand"
label={LL.BRAND()}
style={{ minWidth: '48%' }}
variant="outlined"
value={selectedDeviceBrand}
onChange={(e) => setSelectedDeviceBrand(e.target.value)}
margin="normal"
/>
</>
) : ( ) : (
<TextField <TextField
name="device" name="device"
@@ -489,8 +454,7 @@ const Customizations = () => {
</MenuItem> </MenuItem>
{devices.devices.map( {devices.devices.map(
(device: Device) => (device: Device) =>
device.id < 90 && device.id < 90 && (
device.e !== 0 && (
<MenuItem key={device.id} value={device.id}> <MenuItem key={device.id} value={device.id}>
{device.n}&nbsp;({device.tn}) {device.n}&nbsp;({device.tn})
</MenuItem> </MenuItem>
@@ -518,56 +482,50 @@ const Customizations = () => {
</Button> </Button>
</> </>
) : ( ) : (
<> <Button
<Button startIcon={<EditIcon />}
startIcon={<EditIcon />} variant="outlined"
variant="outlined" onClick={() => setRename(true)}
onClick={() => setRename(true)} >
> {LL.RENAME()}
{LL.RENAME()} </Button>
</Button>
<Button
startIcon={<SettingsBackupRestoreIcon />}
variant="outlined"
color="error"
onClick={() => setConfirmReset(true)}
>
{LL.REMOVE_ALL()}
</Button>
</>
))} ))}
</Box> </Box>
</> </>
); );
const filteredEntities = deviceEntities.filter((de) => filter_entity(de));
const renderDeviceData = () => { const renderDeviceData = () => {
const shown_data = deviceEntities.filter((de) => filter_entity(de));
return ( return (
<> <>
<Typography sx={{ mt: 1, mb: 1 }} color="warning" variant="body2"> <Box color="warning.main">
<OptionIcon type="favorite" isSet={true} />={LL.CUSTOMIZATIONS_HELP_2()} <Typography variant="body2" mt={1}>
&nbsp;&nbsp; <OptionIcon type="favorite" isSet={true} />={LL.CUSTOMIZATIONS_HELP_2()}
<OptionIcon type="readonly" isSet={true} />={LL.CUSTOMIZATIONS_HELP_3()} &nbsp;&nbsp;
&nbsp;&nbsp; <OptionIcon type="readonly" isSet={true} />={LL.CUSTOMIZATIONS_HELP_3()}
<OptionIcon type="api_mqtt_exclude" isSet={true} />= &nbsp;&nbsp;
{LL.CUSTOMIZATIONS_HELP_4()}&nbsp;&nbsp; <OptionIcon type="api_mqtt_exclude" isSet={true} />=
<OptionIcon type="web_exclude" isSet={true} />={LL.CUSTOMIZATIONS_HELP_5()} {LL.CUSTOMIZATIONS_HELP_4()}&nbsp;&nbsp;
&nbsp;&nbsp; <OptionIcon type="web_exclude" isSet={true} />=
<OptionIcon type="deleted" isSet={true} />={LL.CUSTOMIZATIONS_HELP_6()} {LL.CUSTOMIZATIONS_HELP_5()}&nbsp;&nbsp;
</Typography> <OptionIcon type="deleted" isSet={true} />={LL.CUSTOMIZATIONS_HELP_6()}
</Typography>
</Box>
<Grid <Grid
container container
mb={1}
mt={0}
spacing={2} spacing={2}
direction="row" direction="row"
sx={{ mb: 1, mt: 0, justifyContent: 'flex-start', alignItems: 'center' }} justifyContent="flex-start"
alignItems="center"
> >
<Grid> <Grid>
<TextField <TextField
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);
}} }}
@@ -587,7 +545,7 @@ const Customizations = () => {
size="small" size="small"
color="secondary" color="secondary"
value={getMaskString(selectedFilters)} value={getMaskString(selectedFilters)}
onChange={(_, mask: string[]) => { onChange={(event, mask: string[]) => {
setSelectedFilters(getMaskNumber(mask)); setSelectedFilters(getMaskNumber(mask));
}} }}
> >
@@ -635,14 +593,14 @@ const Customizations = () => {
</Button> </Button>
</Grid> </Grid>
<Grid> <Grid>
<Typography variant="subtitle2" color="grey"> <Typography variant="subtitle2" color="primary">
{LL.SHOWING()}&nbsp;{filteredEntities.length}/{deviceEntities.length} {LL.SHOWING()}&nbsp;{shown_data.length}/{deviceEntities.length}
&nbsp;{LL.ENTITIES(deviceEntities.length)} &nbsp;{LL.ENTITIES(deviceEntities.length)}
</Typography> </Typography>
</Grid> </Grid>
</Grid> </Grid>
<Table <Table
data={{ nodes: filteredEntities }} data={{ nodes: shown_data }}
theme={entities_theme} theme={entities_theme}
layout={{ custom: true }} layout={{ custom: true }}
> >
@@ -664,27 +622,14 @@ const Customizations = () => {
<EntityMaskToggle onUpdate={updateDeviceEntity} de={de} /> <EntityMaskToggle onUpdate={updateDeviceEntity} de={de} />
</Cell> </Cell>
<Cell> <Cell>
<span {formatName(de, false)}&nbsp;(
style={{ <Link
color: target="_blank"
de.v === undefined && !isCommand(de) ? 'grey' : 'inherit' href={APIURL + selectedDeviceTypeNameURL + '/' + de.id}
}}
> >
{formatName(de, false)}&nbsp;( {de.id}
<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)}
@@ -709,7 +654,7 @@ const Customizations = () => {
open={confirmReset} open={confirmReset}
onClose={() => setConfirmReset(false)} onClose={() => setConfirmReset(false)}
> >
<DialogTitle>{LL.REMOVE_ALL()}</DialogTitle> <DialogTitle>{LL.RESET(1)}</DialogTitle>
<DialogContent dividers>{LL.CUSTOMIZATIONS_RESET()}</DialogContent> <DialogContent dividers>{LL.CUSTOMIZATIONS_RESET()}</DialogContent>
<DialogActions> <DialogActions>
<Button <Button
@@ -726,7 +671,7 @@ const Customizations = () => {
onClick={resetCustomization} onClick={resetCustomization}
color="error" color="error"
> >
{LL.REMOVE_ALL()} {LL.RESET(0)}
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
@@ -737,9 +682,8 @@ const Customizations = () => {
{devices && renderDeviceList()} {devices && renderDeviceList()}
{selectedDevice !== -1 && !rename && renderDeviceData()} {selectedDevice !== -1 && !rename && renderDeviceData()}
{restartNeeded ? ( {restartNeeded ? (
<MessageBox level="warning" message={LL.RESTART_TEXT(0)}> <MessageBox my={2} level="warning" message={LL.RESTART_TEXT(0)}>
<Button <Button
sx={{ ml: 2 }}
startIcon={<PowerSettingsNewIcon />} startIcon={<PowerSettingsNewIcon />}
variant="contained" variant="contained"
color="error" color="error"
@@ -749,19 +693,15 @@ const Customizations = () => {
</Button> </Button>
</MessageBox> </MessageBox>
) : ( ) : (
<Box sx={{ display: 'flex', flexWrap: 'wrap' }}> <Box display="flex" flexWrap="wrap">
<Box sx={{ flexGrow: 1 }}> <Box flexGrow={1}>
{numChanges !== 0 && ( {numChanges !== 0 && (
<ButtonRow> <ButtonRow>
<Button <Button
startIcon={<CancelIcon />} startIcon={<CancelIcon />}
variant="outlined" variant="outlined"
color="secondary" color="secondary"
onClick={() => { onClick={() => devices && sendDeviceEntities(selectedDevice)}
if (devices) {
void sendDeviceEntities(selectedDevice);
}
}}
> >
{LL.CANCEL()} {LL.CANCEL()}
</Button> </Button>
@@ -776,18 +716,28 @@ 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()}
</> </>
); );
return restarting ? ( return (
<SystemMonitor />
) : (
<SectionContent> <SectionContent>
{blocker ? <BlockNavigation blocker={blocker} /> : null} {blocker ? <BlockNavigation blocker={blocker} /> : null}
{renderContent()} {restarting ? <RestartMonitor /> : renderContent()}
{selectedDeviceEntity && ( {selectedDeviceEntity && (
<SettingsCustomizationsDialog <SettingsCustomizationsDialog
open={dialogOpen} open={dialogOpen}
+35 -51
View File
@@ -1,4 +1,4 @@
import { memo, useEffect, useState } from 'react'; import { useEffect, 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,10 +10,10 @@ import {
DialogActions, DialogActions,
DialogContent, DialogContent,
DialogTitle, DialogTitle,
Grid,
TextField, TextField,
Typography Typography
} from '@mui/material'; } from '@mui/material';
import Grid from '@mui/material/Grid2';
import { dialogStyle } from 'CustomTheme'; import { dialogStyle } from 'CustomTheme';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
@@ -30,23 +30,6 @@ 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">
{label}:&nbsp;
</Typography>
<Typography variant="body2">{value}</Typography>
</Grid>
));
LabelValue.displayName = 'LabelValue';
const ICON_SIZE = 16;
const CustomizationsDialog = ({ const CustomizationsDialog = ({
open, open,
onClose, onClose,
@@ -57,11 +40,7 @@ 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( const updateFormValue = updateValue(setEditItem);
setEditItem as unknown as React.Dispatch<
React.SetStateAction<Record<string, unknown>>
>
);
const isWriteableNumber = const isWriteableNumber =
typeof editItem.v === 'number' && typeof editItem.v === 'number' &&
@@ -75,10 +54,7 @@ const CustomizationsDialog = ({
} }
}, [open, selectedItem]); }, [open, selectedItem]);
const handleClose = ( const handleClose = (_event, reason: 'backdropClick' | 'escapeKeyDown') => {
_event: React.SyntheticEvent,
reason: 'backdropClick' | 'escapeKeyDown'
) => {
if (reason !== 'backdropClick') { if (reason !== 'backdropClick') {
onClose(); onClose();
} }
@@ -89,7 +65,7 @@ const CustomizationsDialog = ({
isWriteableNumber && isWriteableNumber &&
editItem.mi && editItem.mi &&
editItem.ma && editItem.ma &&
editItem.mi > editItem.ma editItem.mi > editItem?.ma
) { ) {
setError(true); setError(true);
} else { } else {
@@ -98,33 +74,43 @@ const CustomizationsDialog = ({
}; };
const updateDeviceEntity = (updatedItem: DeviceEntity) => { const updateDeviceEntity = (updatedItem: DeviceEntity) => {
setEditItem((prev) => ({ ...prev, m: updatedItem.m })); setEditItem({ ...editItem, m: updatedItem.m });
}; };
return ( return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}> <Dialog sx={dialogStyle} open={open} onClose={handleClose}>
<DialogTitle>{`${LL.EDIT()} ${LL.ENTITY()}`}</DialogTitle> <DialogTitle>{LL.EDIT() + ' ' + LL.ENTITY()}</DialogTitle>
<DialogContent dividers> <DialogContent dividers>
<LabelValue label={LL.ID_OF(LL.ENTITY())} value={editItem.id} /> <Grid container>
<LabelValue <Typography variant="body2" color="warning.main">
label={`${LL.DEFAULT(1)} ${LL.ENTITY_NAME(1)}`} {LL.ID_OF(LL.ENTITY())}:&nbsp;
value={editItem.n} </Typography>
/> <Typography variant="body2">{editItem.id}</Typography>
<LabelValue </Grid>
label={LL.WRITEABLE()}
value={
editItem.w ? (
<DoneIcon color="success" sx={{ fontSize: ICON_SIZE }} />
) : (
<CloseIcon color="error" sx={{ fontSize: ICON_SIZE }} />
)
}
/>
<Box sx={{ mt: 1, mb: 2 }}> <Grid container direction="row">
<Typography variant="body2" color="warning.main">
{LL.DEFAULT(1) + ' ' + LL.ENTITY_NAME(1)}:&nbsp;
</Typography>
<Typography variant="body2">{editItem.n}</Typography>
</Grid>
<Grid container direction="row">
<Typography variant="body2" color="warning.main">
{LL.WRITEABLE()}:&nbsp;
</Typography>
<Typography variant="body2">
{editItem.w ? (
<DoneIcon color="success" sx={{ fontSize: 16 }} />
) : (
<CloseIcon color="error" sx={{ fontSize: 16 }} />
)}
</Typography>
</Grid>
<Box mt={1} mb={2}>
<EntityMaskToggle onUpdate={updateDeviceEntity} de={editItem} /> <EntityMaskToggle onUpdate={updateDeviceEntity} de={editItem} />
</Box> </Box>
<Grid container spacing={2}> <Grid container spacing={2}>
<Grid> <Grid>
<TextField <TextField
@@ -160,14 +146,12 @@ const CustomizationsDialog = ({
</> </>
)} )}
</Grid> </Grid>
{error && ( {error && (
<Typography sx={{ mt: 2 }} variant="body2" color="error"> <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 />}
+101 -155
View File
@@ -1,12 +1,10 @@
import { memo, useContext, useEffect, useState } from 'react'; import { useContext, useEffect, useState } from 'react';
import { IconContext } from 'react-icons/lib'; import { IconContext } from 'react-icons/lib';
import { Link } from 'react-router'; import { toast } from 'react-toastify';
import ChevronRightIcon from '@mui/icons-material/ChevronRight'; import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import EditIcon from '@mui/icons-material/Edit'; import EditIcon from '@mui/icons-material/Edit';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import HelpOutlineIcon from '@mui/icons-material/HelpOutlined';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import UnfoldLessIcon from '@mui/icons-material/UnfoldLess'; import UnfoldLessIcon from '@mui/icons-material/UnfoldLess';
import UnfoldMoreIcon from '@mui/icons-material/UnfoldMore'; import UnfoldMoreIcon from '@mui/icons-material/UnfoldMore';
import { import {
@@ -17,19 +15,13 @@ import {
Tooltip, Tooltip,
Typography Typography
} from '@mui/material'; } from '@mui/material';
import Grid from '@mui/material/Grid2';
import { Body, Cell, Row, Table } from '@table-library/react-table-library/table'; import { Body, Cell, Row, Table } from '@table-library/react-table-library/table';
import { useTheme } from '@table-library/react-table-library/theme'; import { useTheme } from '@table-library/react-table-library/theme';
import { CellTree, useTree } from '@table-library/react-table-library/tree'; import { CellTree, useTree } from '@table-library/react-table-library/tree';
import { useRequest } from 'alova/client'; import { useRequest } from 'alova/client';
import { import { FormLoader, SectionContent, useLayoutTitle } from 'components';
ButtonTooltip,
FormLoader,
MessageBox,
SectionContent,
useLayoutTitle
} from 'components';
import { toast } from 'components/toast';
import { AuthenticatedContext } from 'contexts/authentication'; import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import { useInterval, usePersistState } from 'utils'; import { useInterval, usePersistState } from 'utils';
@@ -46,7 +38,7 @@ import {
} from './types'; } from './types';
import { deviceValueItemValidation } from './validators'; import { deviceValueItemValidation } from './validators';
const Dashboard = memo(() => { const Dashboard = () => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const { me } = useContext(AuthenticatedContext); const { me } = useContext(AuthenticatedContext);
@@ -62,12 +54,13 @@ const Dashboard = memo(() => {
const { const {
data, data,
send: fetchDashboard, send: fetchDashboard,
error error,
loading
} = useRequest(readDashboard, { } = useRequest(readDashboard, {
initialData: { connected: true, nodes: [] } initialData: []
}).onSuccess((event) => { }).onSuccess((event) => {
if (event.data.nodes.length !== parentNodes) { if (event.data.length !== parentNodes) {
setParentNodes(event.data.nodes.length); // count number of parents/devices setParentNodes(event.data.length); // count number of parents/devices
} }
}); });
@@ -76,25 +69,24 @@ const Dashboard = memo(() => {
{ {
immediate: false immediate: false
} }
) );
.onSuccess(() => {
toast.success(LL.WRITE_CMD_SENT());
})
.onError((error) => {
toast.error(String(error.error?.message || 'An error occurred'));
});
const deviceValueDialogSave = async (devicevalue: DeviceValue) => { const deviceValueDialogSave = async (devicevalue: DeviceValue) => {
if (!selectedDashboardItem) { if (!selectedDashboardItem) {
return; return;
} }
// skip if we're executing an immediate schedule as this is handled in DevicesDialog::doAction() const id = selectedDashboardItem.parentNode.id; // this is the parent ID
if (devicevalue.v !== undefined) { 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());
} })
setDeviceValueDialogOpen(false); .catch((error: Error) => {
setSelectedDashboardItem(undefined); toast.error(error.message);
})
.finally(() => {
setDeviceValueDialogOpen(false);
setSelectedDashboardItem(undefined);
});
}; };
const dashboard_theme = useTheme({ const dashboard_theme = useTheme({
@@ -115,7 +107,7 @@ const Dashboard = memo(() => {
}, },
&:hover .td { &:hover .td {
background-color: #177ac9; background-color: #177ac9;
}, }
`, `,
BaseCell: ` BaseCell: `
&:nth-of-type(2) { &:nth-of-type(2) {
@@ -128,9 +120,9 @@ const Dashboard = memo(() => {
}); });
const tree = useTree( const tree = useTree(
{ nodes: [...data.nodes] }, { nodes: data },
{ {
onChange: () => {} // not used but needed onChange: undefined // not used but needed
}, },
{ {
treeIcon: { treeIcon: {
@@ -157,12 +149,11 @@ const Dashboard = memo(() => {
if (!deviceValueDialogOpen) { if (!deviceValueDialogOpen) {
void fetchDashboard(); void fetchDashboard();
} }
}); }, 3000);
useEffect(() => { useEffect(() => {
const nodeIds = data.nodes.map((item: DashboardItem) => item.id);
showAll showAll
? tree.fns.onAddAll(nodeIds) // expand tree ? tree.fns.onAddAll(data.map((item: DashboardItem) => item.id)) // expand tree
: tree.fns.onRemoveAll(); // collapse tree : tree.fns.onRemoveAll(); // collapse tree
}, [parentNodes]); }, [parentNodes]);
@@ -180,8 +171,6 @@ const Dashboard = memo(() => {
return LL.ANALOG_SENSORS(); return LL.ANALOG_SENSORS();
case DeviceType.TEMPERATURESENSOR: case DeviceType.TEMPERATURESENSOR:
return LL.TEMP_SENSORS(); return LL.TEMP_SENSORS();
case DeviceType.COMMAND:
return LL.COMMANDS();
case DeviceType.SCHEDULER: case DeviceType.SCHEDULER:
return LL.SCHEDULER(); return LL.SCHEDULER();
default: default:
@@ -196,37 +185,33 @@ const Dashboard = memo(() => {
// if its a device (parent node) and has entities // if its a device (parent node) and has entities
if (di.nodes?.length) { if (di.nodes?.length) {
return ( return (
<span style={{ fontSize: '15px' }}> <>
<DeviceIcon type_id={di.t ?? 0} /> <span style="font-size: 14px">
&nbsp;&nbsp;{showType(di.n, di.t)} <DeviceIcon type_id={di.t ?? 0} />
&nbsp;&nbsp;{showType(di.n, di.t)}
</span>
<span style={{ color: 'lightblue' }}>&nbsp;({di.nodes?.length})</span> <span style={{ color: 'lightblue' }}>&nbsp;({di.nodes?.length})</span>
</span> </>
); );
} }
} }
if (di.dv) { if (di.dv) {
return <span>{di.dv.id.slice(2)}</span>; return <span style="color:lightgrey">{di.dv.id.slice(2)}</span>;
} }
return null;
}; };
const hasMask = (id: string, mask: number) => const hasMask = (id: string, mask: number) =>
(parseInt(id.slice(0, 2), 16) & mask) === mask; (parseInt(id.slice(0, 2), 16) & mask) === mask;
const editDashboardValue = (di: DashboardItem) => { const editDashboardValue = (di: DashboardItem) => {
// don't execute on parent nodes if (me.admin && di.dv?.c) {
if (!me.admin || di.id <= 99) {
return;
}
if (di.dv?.c) {
setSelectedDashboardItem(di); setSelectedDashboardItem(di);
setDeviceValueDialogOpen(true); setDeviceValueDialogOpen(true);
} }
}; };
const handleShowAll = ( const handleShowAll = (
_event: React.MouseEvent<HTMLElement>, event: React.MouseEvent<HTMLElement>,
toggle: boolean | null toggle: boolean | null
) => { ) => {
if (toggle !== null) { if (toggle !== null) {
@@ -235,75 +220,69 @@ const Dashboard = memo(() => {
} }
}; };
const hasFavEntities = data.nodes.filter(
(item: DashboardItem) => item.id <= 90
).length;
const renderContent = () => { const renderContent = () => {
if (!data) { if (!data) {
return ( return <FormLoader onRetry={fetchDashboard} errorMessage={error?.message} />;
<FormLoader onRetry={fetchDashboard} errorMessage={error?.message || ''} />
);
} }
return ( return (
<> <>
{data.connected && data.nodes.length > 0 && !hasFavEntities && (
<MessageBox sx={{ mb: 2 }} level="warning">
<Typography>
{LL.NO_DATA_1()}&nbsp;
<Link to="/customizations" style={{ color: 'white' }}>
{LL.CUSTOMIZATIONS()}
</Link>
&nbsp;{LL.NO_DATA_2()}&nbsp;
{LL.NO_DATA_3()}&nbsp;
<Link to="/devices" style={{ color: 'white' }}>
{LL.DEVICES()}
</Link>
.
</Typography>
</MessageBox>
)}
<Box <Box
sx={{ sx={{
display: 'flex', backgroundColor: 'black',
justifyContent: 'flex-end', pt: 1,
flexWrap: 'nowrap', pl: 2
whiteSpace: 'nowrap'
}} }}
> >
<ToggleButtonGroup <Grid container spacing={0} justifyContent="flex-start">
size="small" <Grid size={11}>
color="primary" <Typography mb={2} variant="body1" color="warning">
value={showAll} {LL.DASHBOARD_1()}.
exclusive </Typography>
onChange={handleShowAll} </Grid>
>
<ButtonTooltip title={LL.ALLVALUES()}> <Grid size={1} alignItems="end">
<ToggleButton value={true}> <ToggleButtonGroup
<UnfoldMoreIcon sx={{ fontSize: 18 }} /> color="primary"
</ToggleButton> size="small"
</ButtonTooltip> value={showAll}
<ButtonTooltip title={LL.COMPACT()}> exclusive
<ToggleButton value={false}> onChange={handleShowAll}
<UnfoldLessIcon sx={{ fontSize: 18 }} /> >
</ToggleButton> <ToggleButton value={true}>
</ButtonTooltip> <UnfoldMoreIcon sx={{ fontSize: 14 }} />
</ToggleButtonGroup> </ToggleButton>
<ToggleButton value={false}>
<UnfoldLessIcon sx={{ fontSize: 14 }} />
</ToggleButton>
</ToggleButtonGroup>
</Grid>
</Grid>
</Box> </Box>
{data.nodes.length > 0 ? ( <Box
<Box sx={{ mt: 1, justifyContent: 'center', flexDirection: 'column' }}> padding={1}
<IconContext.Provider justifyContent="center"
value={{ flexDirection="column"
color: 'lightblue', sx={{
size: '18', borderRadius: 1,
style: { verticalAlign: 'middle' } border: '1px solid grey'
}} }}
> >
<IconContext.Provider
value={{
color: 'lightblue',
size: '16',
style: { verticalAlign: 'middle' }
}}
>
{!loading && data.length === 0 ? (
<Typography variant="subtitle2" color="secondary">
{LL.NO_DATA()}
</Typography>
) : (
<Table <Table
data={{ nodes: data.nodes }} data={{ nodes: data }}
theme={dashboard_theme} theme={dashboard_theme}
layout={{ custom: true }} layout={{ custom: true }}
tree={tree} tree={tree}
@@ -320,44 +299,31 @@ const Dashboard = memo(() => {
<> <>
<Cell>{showName(di)}</Cell> <Cell>{showName(di)}</Cell>
<Cell> <Cell>
<ButtonTooltip <Tooltip
placement="left"
title={formatValue(LL, di.dv?.v, di.dv?.u)} title={formatValue(LL, di.dv?.v, di.dv?.u)}
arrow
> >
<span>{formatValue(LL, di.dv?.v, di.dv?.u)}</span> <span style={{ color: 'lightgrey' }}>
</ButtonTooltip> {formatValue(LL, di.dv?.v, di.dv?.u)}
</span>
</Tooltip>
</Cell> </Cell>
<Cell> <Cell>
{me.admin && {me.admin &&
di.dv?.c && di.dv?.c &&
!hasMask(di.dv.id, DeviceEntityMask.DV_READONLY) && !hasMask(di.dv.id, DeviceEntityMask.DV_READONLY) && (
(di.dv.v === '' || di.dv.v === undefined ? (
<IconButton <IconButton
size="small" size="small"
aria-label={LL.RUN()}
onClick={() => editDashboardValue(di)} onClick={() => editDashboardValue(di)}
style={{ backgroundColor: 'transparent' }}
>
<PlayArrowIcon
color="primary"
sx={{ fontSize: 16 }}
/>
</IconButton>
) : (
<IconButton
size="small"
aria-label={
LL.CHANGE_VALUE() + ' ' + LL.VALUE(0)
}
onClick={() => editDashboardValue(di)}
style={{ backgroundColor: 'transparent' }}
> >
<EditIcon <EditIcon
color="primary" color="primary"
sx={{ fontSize: 16 }} sx={{ fontSize: 16 }}
/> />
</IconButton> </IconButton>
))} )}
</Cell> </Cell>
</> </>
) : ( ) : (
@@ -372,26 +338,9 @@ const Dashboard = memo(() => {
</Body> </Body>
)} )}
</Table> </Table>
</IconContext.Provider> )}
</Box> </IconContext.Provider>
) : ( </Box>
<Box sx={{ display: 'flex' }}>
<Typography sx={{ mt: 1 }} color="warning" variant="body1">
no data
</Typography>
<Tooltip title={LL.DASHBOARD_1()}>
<HelpOutlineIcon
sx={{
ml: 1,
mt: 1,
fontSize: 20,
verticalAlign: 'middle'
}}
color="primary"
/>
</Tooltip>
</Box>
)}
</> </>
); );
}; };
@@ -402,10 +351,7 @@ const Dashboard = memo(() => {
{selectedDashboardItem && selectedDashboardItem.dv && ( {selectedDashboardItem && selectedDashboardItem.dv && (
<DevicesDialog <DevicesDialog
open={deviceValueDialogOpen} open={deviceValueDialogOpen}
onClose={() => { onClose={() => setDeviceValueDialogOpen(false)}
setDeviceValueDialogOpen(false);
setSelectedDashboardItem(undefined);
}}
onSave={deviceValueDialogSave} onSave={deviceValueDialogSave}
selectedItem={selectedDashboardItem.dv} selectedItem={selectedDashboardItem.dv}
writeable={true} writeable={true}
@@ -415,6 +361,6 @@ const Dashboard = memo(() => {
)} )}
</SectionContent> </SectionContent>
); );
}); };
export default Dashboard; export default Dashboard;
+10 -13
View File
@@ -1,24 +1,26 @@
import { memo } from 'react';
import type { IconType } from 'react-icons';
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';
import { TiFlowSwitch, TiThermometer } from 'react-icons/ti'; import { TiFlowSwitch, TiThermometer } from 'react-icons/ti';
import { VscVmConnect } from 'react-icons/vsc'; import { VscVmConnect } from 'react-icons/vsc';
import type { SvgIconProps } from '@mui/material';
import { DeviceType } from './types'; import { DeviceType } from './types';
const deviceIconLookup: Record<DeviceType, IconType | null> = { const deviceIconLookup: {
[key in DeviceType]: React.ComponentType<SvgIconProps> | undefined;
} = {
[DeviceType.TEMPERATURESENSOR]: TiThermometer, [DeviceType.TEMPERATURESENSOR]: TiThermometer,
[DeviceType.ANALOGSENSOR]: PiGauge, [DeviceType.ANALOGSENSOR]: PiGauge,
[DeviceType.BOILER]: CgSmartHomeBoiler, [DeviceType.BOILER]: CgSmartHomeBoiler,
@@ -37,20 +39,15 @@ const deviceIconLookup: Record<DeviceType, IconType | null> = {
[DeviceType.POOL]: MdOutlinePool, [DeviceType.POOL]: MdOutlinePool,
[DeviceType.CUSTOM]: MdPlaylistAdd, [DeviceType.CUSTOM]: MdPlaylistAdd,
[DeviceType.UNKNOWN]: MdOutlineSensors, [DeviceType.UNKNOWN]: MdOutlineSensors,
[DeviceType.SYSTEM]: null, [DeviceType.SYSTEM]: undefined,
[DeviceType.COMMAND]: MdPlaylistAdd,
[DeviceType.SCHEDULER]: MdMoreTime, [DeviceType.SCHEDULER]: MdMoreTime,
[DeviceType.GENERIC]: MdOutlineSensors, [DeviceType.GENERIC]: MdOutlineSensors,
[DeviceType.VENTILATION]: PiFan [DeviceType.VENTILATION]: PiFan
}; };
interface DeviceIconProps { const DeviceIcon = ({ type_id }: { type_id: DeviceType }) => {
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;
+214 -279
View File
@@ -1,5 +1,4 @@
import { import {
memo,
useCallback, useCallback,
useContext, useContext,
useEffect, useEffect,
@@ -7,19 +6,19 @@ import {
useState useState
} from 'react'; } from 'react';
import { IconContext } from 'react-icons'; import { IconContext } from 'react-icons';
import { Link, useNavigate } from 'react-router'; import { useNavigate } from 'react-router-dom';
import { toast } from 'react-toastify';
import CommentsDisabledOutlinedIcon from '@mui/icons-material/CommentsDisabledOutlined'; import CommentsDisabledOutlinedIcon from '@mui/icons-material/CommentsDisabledOutlined';
import ConstructionIcon from '@mui/icons-material/Construction';
import EditIcon from '@mui/icons-material/Edit'; import EditIcon from '@mui/icons-material/Edit';
import EditOffOutlinedIcon from '@mui/icons-material/EditOffOutlined'; import EditOffOutlinedIcon from '@mui/icons-material/EditOffOutlined';
import FormatListNumberedIcon from '@mui/icons-material/FormatListNumbered';
import DownloadIcon from '@mui/icons-material/GetApp'; import DownloadIcon from '@mui/icons-material/GetApp';
import HighlightOffIcon from '@mui/icons-material/HighlightOff'; import HighlightOffIcon from '@mui/icons-material/HighlightOff';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import KeyboardArrowDownOutlinedIcon from '@mui/icons-material/KeyboardArrowDownOutlined'; import KeyboardArrowDownOutlinedIcon from '@mui/icons-material/KeyboardArrowDownOutlined';
import KeyboardArrowUpOutlinedIcon from '@mui/icons-material/KeyboardArrowUpOutlined'; import KeyboardArrowUpOutlinedIcon from '@mui/icons-material/KeyboardArrowUpOutlined';
import PlayArrowIcon from '@mui/icons-material/PlayArrow'; import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import SearchIcon from '@mui/icons-material/Search';
import StarIcon from '@mui/icons-material/Star'; import StarIcon from '@mui/icons-material/Star';
import StarBorderOutlinedIcon from '@mui/icons-material/StarBorderOutlined'; import StarBorderOutlinedIcon from '@mui/icons-material/StarBorderOutlined';
import UnfoldMoreOutlinedIcon from '@mui/icons-material/UnfoldMoreOutlined'; import UnfoldMoreOutlinedIcon from '@mui/icons-material/UnfoldMoreOutlined';
@@ -31,16 +30,17 @@ import {
DialogActions, DialogActions,
DialogContent, DialogContent,
DialogTitle, DialogTitle,
Grid,
IconButton, IconButton,
InputAdornment,
List, List,
ListItem, ListItem,
ListItemText, ListItemText,
TextField, Tooltip,
ToggleButton, type TooltipProps,
Typography Typography,
styled,
tooltipClasses
} from '@mui/material'; } from '@mui/material';
import Grid from '@mui/material/Grid2';
import { useRowSelect } from '@table-library/react-table-library/select'; import { useRowSelect } from '@table-library/react-table-library/select';
import { SortToggleType, useSort } from '@table-library/react-table-library/sort'; import { SortToggleType, useSort } from '@table-library/react-table-library/sort';
@@ -57,13 +57,7 @@ import { useTheme } from '@table-library/react-table-library/theme';
import type { Action, State } from '@table-library/react-table-library/types/common'; import type { Action, State } from '@table-library/react-table-library/types/common';
import { dialogStyle } from 'CustomTheme'; import { dialogStyle } from 'CustomTheme';
import { useRequest } from 'alova/client'; import { useRequest } from 'alova/client';
import { import { MessageBox, SectionContent, useLayoutTitle } from 'components';
ButtonTooltip,
MessageBox,
SectionContent,
useLayoutTitle
} from 'components';
import { toast } from 'components/toast';
import { AuthenticatedContext } from 'contexts/authentication'; import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import { useInterval } from 'utils'; import { useInterval } from 'utils';
@@ -76,7 +70,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 = memo(() => { const Devices = () => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const { me } = useContext(AuthenticatedContext); const { me } = useContext(AuthenticatedContext);
@@ -86,13 +80,12 @@ const Devices = memo(() => {
const [deviceValueDialogOpen, setDeviceValueDialogOpen] = useState(false); const [deviceValueDialogOpen, setDeviceValueDialogOpen] = useState(false);
const [showDeviceInfo, setShowDeviceInfo] = useState(false); const [showDeviceInfo, setShowDeviceInfo] = useState(false);
const [selectedDevice, setSelectedDevice] = useState<number>(); const [selectedDevice, setSelectedDevice] = useState<number>();
const [search, setSearch] = useState('');
const navigate = useNavigate(); const navigate = useNavigate();
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: []
@@ -117,26 +110,28 @@ const Devices = memo(() => {
); );
useLayoutEffect(() => { useLayoutEffect(() => {
let raf = 0; function updateSize() {
const updateSize = () => { setSize([window.innerWidth, window.innerHeight]);
cancelAnimationFrame(raf); }
raf = requestAnimationFrame(() => {
setSize([window.innerWidth, window.innerHeight]);
});
};
window.addEventListener('resize', updateSize); window.addEventListener('resize', updateSize);
updateSize(); updateSize();
return () => { return () => window.removeEventListener('resize', updateSize);
window.removeEventListener('resize', updateSize);
cancelAnimationFrame(raf);
};
}, []); }, []);
const leftOffset = () => { const leftOffset = () => {
const devicesWindow = document.getElementById('devices-window'); const devicesWindow = document.getElementById('devices-window');
if (!devicesWindow) return 0; if (!devicesWindow) {
const { left, right } = devicesWindow.getBoundingClientRect(); return 0;
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);
}; };
@@ -160,6 +155,7 @@ const Devices = memo(() => {
} }
&.tr.tr-body.row-select.row-select-single-selected { &.tr.tr-body.row-select.row-select-single-selected {
background-color: #177ac9; background-color: #177ac9;
font-weight: normal;
} }
` `
}); });
@@ -167,27 +163,18 @@ const Devices = memo(() => {
const device_theme = useTheme([ const device_theme = useTheme([
common_theme, common_theme,
{ {
BaseRow: `
font-size: 15px;
.td {
height: 28px;
}
`,
Table: ` 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;
`, height: 36px;
`,
Row: ` Row: `
&:nth-of-type(odd) .td { &:hover .td {
background-color: #303030;
},
&:hover .td {
background-color: #177ac9; background-color: #177ac9;
}, `
`
} }
]); ]);
@@ -195,46 +182,60 @@ const Devices = memo(() => {
common_theme, common_theme,
{ {
Table: ` 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%;
overflow-y: scroll; overflow-y: scroll;
::-webkit-scrollbar { ::-webkit-scrollbar {
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;
}, },
&:nth-of-type(2) { &:nth-of-type(2) {
text-align: right; text-align: right;
}, },
&:nth-of-type(3) { &:nth-of-type(3) {
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;
}, },
&:hover .td { &:hover .td {
background-color: #177ac9; background-color: #177ac9;
} }
` `
} }
]); ]);
const ButtonTooltip = styled(({ className, ...props }: TooltipProps) => (
<Tooltip {...props} arrow classes={{ popper: className }} />
))(({ 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
}
}));
const getSortIcon = (state: State, sortKey: unknown) => { const getSortIcon = (state: State, sortKey: unknown) => {
if (state.sortKey === sortKey && state.reverse) { if (state.sortKey === sortKey && state.reverse) {
return <KeyboardArrowDownOutlinedIcon />; return <KeyboardArrowDownOutlinedIcon />;
@@ -246,7 +247,7 @@ const Devices = memo(() => {
}; };
const dv_sort = useSort( const dv_sort = useSort(
{ nodes: [...deviceData.nodes] }, { nodes: deviceData.nodes },
{}, {},
{ {
sortIcon: { sortIcon: {
@@ -276,7 +277,7 @@ const Devices = memo(() => {
} }
const device_select = useRowSelect( const device_select = useRowSelect(
{ nodes: [...coreData.devices] }, { nodes: coreData.devices },
{ {
onChange: onSelectChange onChange: onSelectChange
} }
@@ -284,7 +285,6 @@ const Devices = memo(() => {
const resetDeviceSelect = () => { const resetDeviceSelect = () => {
device_select.fns.onRemoveAll(); device_select.fns.onRemoveAll();
setSearch('');
}; };
const escFunction = useCallback( const escFunction = useCallback(
@@ -307,9 +307,9 @@ const Devices = memo(() => {
const customize = () => { const customize = () => {
if (selectedDevice === 99) { if (selectedDevice === 99) {
void navigate('/customentities'); navigate('/customentities');
} else { } else {
void navigate('/customizations', { state: selectedDevice }); navigate('/customizations', { state: selectedDevice });
} }
}; };
@@ -337,16 +337,13 @@ const Devices = memo(() => {
const handleDownloadCsv = () => { const handleDownloadCsv = () => {
const deviceIndex = coreData.devices.findIndex( const deviceIndex = coreData.devices.findIndex(
(d: Device) => d.id === device_select.state.id (d) => d.id === device_select.state.id
); );
if (deviceIndex === -1) { if (deviceIndex === -1) {
return; return;
} }
const selectedDevice = coreData.devices[deviceIndex]; const filename =
if (!selectedDevice) { coreData.devices[deviceIndex].tn + '_' + coreData.devices[deviceIndex].n;
return;
}
const filename = selectedDevice.tn + '_' + selectedDevice.n;
const columns = [ const columns = [
{ {
@@ -361,7 +358,7 @@ const Devices = memo(() => {
{ {
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'
}, },
@@ -384,9 +381,7 @@ const Devices = memo(() => {
]; ];
const data = onlyFav const data = onlyFav
? deviceData.nodes.filter((dv: DeviceValue) => ? deviceData.nodes.filter((dv) => hasMask(dv.id, DeviceEntityMask.DV_FAVORITE))
hasMask(dv.id, DeviceEntityMask.DV_FAVORITE)
)
: deviceData.nodes; : deviceData.nodes;
const csvData = data.reduce( const csvData = data.reduce(
@@ -425,7 +420,7 @@ const Devices = memo(() => {
if (!deviceValueDialogOpen) { if (!deviceValueDialogOpen) {
selectedDevice ? void sendDeviceData(selectedDevice) : void sendCoreData(); selectedDevice ? void sendDeviceData(selectedDevice) : void sendCoreData();
} }
}); }, 3000);
const deviceValueDialogSave = async (devicevalue: DeviceValue) => { const deviceValueDialogSave = async (devicevalue: DeviceValue) => {
const id = Number(device_select.state.id); const id = Number(device_select.state.id);
@@ -446,14 +441,10 @@ const Devices = memo(() => {
const renderDeviceDetails = () => { const renderDeviceDetails = () => {
if (showDeviceInfo) { if (showDeviceInfo) {
const deviceIndex = coreData.devices.findIndex( const deviceIndex = coreData.devices.findIndex(
(d: Device) => d.id === device_select.state.id (d) => d.id === device_select.state.id
); );
if (deviceIndex === -1) { if (deviceIndex === -1) {
return null; return;
}
const deviceDetails = coreData.devices[deviceIndex];
if (!deviceDetails) {
return null;
} }
return ( return (
@@ -466,35 +457,47 @@ const Devices = memo(() => {
<DialogContent dividers> <DialogContent dividers>
<List dense={true}> <List dense={true}>
<ListItem> <ListItem>
<ListItemText primary={LL.TYPE(0)} secondary={deviceDetails.tn} /> <ListItemText
primary={LL.TYPE(0)}
secondary={coreData.devices[deviceIndex].tn}
/>
</ListItem> </ListItem>
<ListItem> <ListItem>
<ListItemText primary={LL.NAME(0)} secondary={deviceDetails.n} /> <ListItemText
primary={LL.NAME(0)}
secondary={coreData.devices[deviceIndex].n}
/>
</ListItem> </ListItem>
{deviceDetails.t !== DeviceType.CUSTOM && ( {coreData.devices[deviceIndex].t !== DeviceType.CUSTOM && (
<> <>
<ListItem> <ListItem>
<ListItemText primary={LL.BRAND()} secondary={deviceDetails.b} /> <ListItemText
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={deviceDetails.p} secondary={coreData.devices[deviceIndex].p}
/> />
</ListItem> </ListItem>
<ListItem> <ListItem>
<ListItemText <ListItemText
primary={LL.VERSION()} primary={LL.VERSION()}
secondary={deviceDetails.v} secondary={coreData.devices[deviceIndex].v}
/> />
</ListItem> </ListItem>
</> </>
@@ -513,89 +516,65 @@ const Devices = memo(() => {
</Dialog> </Dialog>
); );
} }
return null;
}; };
const renderCoreData = () => ( const renderCoreData = () => (
<> <>
{!coreData.connected ? ( <IconContext.Provider
<MessageBox level="error" message={LL.EMS_BUS_WARNING() + '.'}> value={{
&nbsp;( color: 'lightblue',
<Link size: '18',
target="_blank" style: { verticalAlign: 'middle' }
to="https://docs.emsesp.org/Troubleshooting#ems-bus-is-not-connecting" }}
style={{ color: 'white' }} >
{!coreData.connected && (
<MessageBox my={2} level="error" message={LL.EMS_BUS_WARNING()} />
)}
{coreData.connected && (
<Table
data={{ nodes: coreData.devices }}
select={device_select}
theme={device_theme}
layout={{ custom: true }}
> >
{LL.ONLINE_HELP()} {(tableList: Device[]) => (
</Link> <>
) <Header>
</MessageBox> <HeaderRow>
) : ( <HeaderCell resize>{LL.DESCRIPTION()}</HeaderCell>
<Box sx={{ justifyContent: 'center', flexDirection: 'column' }}> <HeaderCell stiff>{LL.TYPE(0)}</HeaderCell>
<IconContext.Provider </HeaderRow>
value={{ </Header>
color: 'lightblue', <Body>
size: '18', {tableList.length === 0 && (
style: { verticalAlign: 'middle' } <CircularProgress sx={{ margin: 1 }} size={18} />
}} )}
> {tableList.map((device: Device) => (
<Table <Row key={device.id} item={device}>
data={{ nodes: [...coreData.devices] }} <Cell>
select={device_select} <DeviceIcon type_id={device.t} />
theme={device_theme} &nbsp;&nbsp;
layout={{ custom: true }} {device.n}
> <span style={{ color: 'lightblue' }}>
{(tableList: Device[]) => ( &nbsp;&nbsp;({device.e})
<> </span>
<Header> </Cell>
<HeaderRow> <Cell stiff>{device.tn}</Cell>
<HeaderCell resize>{LL.DESCRIPTION()}</HeaderCell> </Row>
<HeaderCell stiff>{LL.TYPE(0)}</HeaderCell> ))}
</HeaderRow> </Body>
</Header> </>
<Body> )}
{tableList.length === 0 && ( </Table>
<CircularProgress sx={{ margin: 1 }} size={18} /> )}
)} </IconContext.Provider>
{tableList.map((device: Device) => (
<Row key={device.id} item={device} disabled={device.e === 0}>
<Cell>
<DeviceIcon type_id={device.t} />
&nbsp;&nbsp;
<span style={{ color: device.e === 0 ? 'grey' : 'white' }}>
{device.n}
</span>
{device.e !== 0 && (
<span style={{ color: 'lightblue' }}>
&nbsp;&nbsp;({device.e})
</span>
)}
</Cell>
<Cell stiff>
<ButtonTooltip
title={`DeviceID: 0x${('00' + device.d.toString(16).toUpperCase()).slice(-2)}, ProductID: ${device.p}`}
>
<span>{device.tn}</span>
</ButtonTooltip>
</Cell>
</Row>
))}
</Body>
</>
)}
</Table>
</IconContext.Provider>
</Box>
)}
</> </>
); );
const deviceValueDialogClose = () => { const deviceValueDialogClose = () => {
setDeviceValueDialogOpen(false); setDeviceValueDialogOpen(false);
setSelectedDeviceValue(undefined); void sendDeviceData(selectedDevice);
if (selectedDevice !== undefined) {
void sendDeviceData(selectedDevice);
}
}; };
const renderDeviceData = () => { const renderDeviceData = () => {
@@ -624,127 +603,84 @@ const Devices = memo(() => {
); );
const shown_data = onlyFav const shown_data = onlyFav
? deviceData.nodes.filter( ? deviceData.nodes.filter((dv) => hasMask(dv.id, DeviceEntityMask.DV_FAVORITE))
(dv: DeviceValue) => : deviceData.nodes;
hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) &&
dv.id.slice(2).toLowerCase().includes(search.toLowerCase())
)
: deviceData.nodes.filter((dv: DeviceValue) =>
dv.id.slice(2).toLowerCase().includes(search.toLowerCase())
);
const deviceIndex = coreData.devices.findIndex( const deviceIndex = coreData.devices.findIndex(
(d: Device) => d.id === device_select.state.id (d) => 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: () => (height || 0) - 126, maxHeight: () => size[1] - 126,
border: '1px solid #177ac9' border: '1px solid #177ac9'
}} }}
> >
<Box sx={{ p: 1 }}> <Box sx={{ border: '1px solid #177ac9' }}>
<Grid container sx={{ justifyContent: 'space-between' }}> <Typography noWrap variant="subtitle1" color="warning.main" sx={{ ml: 1 }}>
<Typography noWrap variant="subtitle1" color="warning"> {coreData.devices[deviceIndex].n}&nbsp;(
{deviceInfo.n}&nbsp;( {coreData.devices[deviceIndex].tn})
{deviceInfo.tn}) </Typography>
<Grid container justifyContent="space-between">
<Typography sx={{ ml: 1 }} variant="subtitle2" color="grey">
{LL.SHOWING() +
' ' +
shown_data.length +
'/' +
coreData.devices[deviceIndex].e +
' ' +
LL.ENTITIES(shown_data.length)}
<ButtonTooltip title="Info">
<IconButton onClick={() => setShowDeviceInfo(true)}>
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
</IconButton>
</ButtonTooltip>
{me.admin && (
<ButtonTooltip title={LL.CUSTOMIZATIONS()}>
<IconButton onClick={customize}>
<FormatListNumberedIcon color="primary" sx={{ fontSize: 18 }} />
</IconButton>
</ButtonTooltip>
)}
<ButtonTooltip title={LL.EXPORT()}>
<IconButton onClick={handleDownloadCsv}>
<DownloadIcon color="primary" sx={{ fontSize: 18 }} />
</IconButton>
</ButtonTooltip>
<ButtonTooltip title={LL.FAVORITES()}>
<IconButton onClick={() => setOnlyFav(!onlyFav)}>
{onlyFav ? (
<StarIcon color="primary" sx={{ fontSize: 18 }} />
) : (
<StarBorderOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
)}
</IconButton>
</ButtonTooltip>
</Typography> </Typography>
<Grid sx={{ justifyContent: 'flex-end' }}> <Grid justifyContent="flex-end">
<ButtonTooltip title={LL.CLOSE()}> <ButtonTooltip title={LL.CANCEL()}>
<IconButton onClick={resetDeviceSelect} aria-label={LL.CLOSE()}> <IconButton onClick={resetDeviceSelect}>
<HighlightOffIcon color="primary" sx={{ fontSize: 18 }} /> <HighlightOffIcon color="primary" sx={{ fontSize: 18 }} />
</IconButton> </IconButton>
</ButtonTooltip> </ButtonTooltip>
</Grid> </Grid>
</Grid> </Grid>
<TextField
size="small"
variant="outlined"
sx={{ width: '22ch' }}
placeholder={LL.SEARCH()}
aria-label={LL.SEARCH()}
onChange={(event) => {
setSearch(event.target.value);
}}
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<SearchIcon color="primary" sx={{ fontSize: 16 }} />
</InputAdornment>
)
}
}}
/>
<ButtonTooltip title={LL.DEVICE_DETAILS()}>
<IconButton
onClick={() => setShowDeviceInfo(true)}
aria-label={LL.DEVICE_DETAILS()}
>
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
</IconButton>
</ButtonTooltip>
{me.admin && (
<ButtonTooltip title={LL.CUSTOMIZATIONS()}>
<IconButton onClick={customize} aria-label={LL.CUSTOMIZATIONS()}>
<ConstructionIcon color="primary" sx={{ fontSize: 18 }} />
</IconButton>
</ButtonTooltip>
)}
<ButtonTooltip title={LL.EXPORT()}>
<IconButton onClick={handleDownloadCsv} aria-label={LL.EXPORT()}>
<DownloadIcon color="primary" sx={{ fontSize: 18 }} />
</IconButton>
</ButtonTooltip>
<ButtonTooltip title={LL.FAVORITES()}>
<ToggleButton
value="1"
size="small"
selected={onlyFav}
onChange={() => {
setOnlyFav(!onlyFav);
}}
>
{onlyFav ? (
<StarIcon color="primary" sx={{ fontSize: 18 }} />
) : (
<StarBorderOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
)}{' '}
</ToggleButton>
</ButtonTooltip>
<span style={{ color: 'grey', fontSize: '12px' }}>
&nbsp;
{LL.SHOWING() +
' ' +
shown_data.length +
'/' +
deviceInfo.e +
' ' +
LL.ENTITIES(shown_data.length)}
</span>
</Box> </Box>
<Table <Table
data={{ nodes: Array.from(shown_data) }} data={{ nodes: shown_data }}
theme={data_theme} theme={data_theme}
sort={dv_sort} sort={dv_sort}
layout={{ custom: true, fixedHeader: true }} layout={{ custom: true, fixedHeader: true }}
@@ -788,7 +724,6 @@ const Devices = memo(() => {
<IconButton <IconButton
size="small" size="small"
onClick={() => showDeviceValue(dv)} onClick={() => showDeviceValue(dv)}
style={{ backgroundColor: 'transparent' }}
> >
{dv.v === '' ? ( {dv.v === '' ? (
<PlayArrowIcon color="primary" sx={{ fontSize: 16 }} /> <PlayArrowIcon color="primary" sx={{ fontSize: 16 }} />
@@ -829,6 +764,6 @@ const Devices = memo(() => {
)} )}
</SectionContent> </SectionContent>
); );
}); };
export default Devices; export default Devices;
+97 -134
View File
@@ -1,7 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import WarningIcon from '@mui/icons-material/Warning'; import WarningIcon from '@mui/icons-material/Warning';
import { import {
Box, Box,
@@ -12,25 +11,22 @@ import {
DialogContent, DialogContent,
DialogTitle, DialogTitle,
FormHelperText, FormHelperText,
Grid,
InputAdornment, InputAdornment,
MenuItem, MenuItem,
TextField, TextField,
Typography Typography
} from '@mui/material'; } from '@mui/material';
import Grid from '@mui/material/Grid2';
import { callAction } from '@/api/app';
import { dialogStyle } from 'CustomTheme'; import { dialogStyle } from 'CustomTheme';
import { useRequest } from 'alova/client'; import type Schema from 'async-validator';
import type { ValidateFieldsError } from 'async-validator';
import { ValidatedTextField } from 'components'; import { ValidatedTextField } from 'components';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import { numberValue, updateValue } from 'utils'; import { numberValue, updateValue } from 'utils';
import { ValidationError, validate } from 'validators'; import { validate } from 'validators';
import type Schema from 'validators/schema';
import type { ValidateFieldsError } from 'validators/schema';
import { DeviceValueUOM, DeviceValueUOM_s, getDeviceValueKind } from './types'; import { DeviceValueUOM, DeviceValueUOM_s } from './types';
import type { DeviceValue } from './types'; import type { DeviceValue } from './types';
interface DevicesDialogProps { interface DevicesDialogProps {
@@ -65,28 +61,17 @@ const DevicesDialog = ({
} }
}, [open, selectedItem]); }, [open, selectedItem]);
const { send: executeCommand } = useRequest( const close = () => {
(id: string) => callAction({ action: 'executeCommand', param: id }), onClose();
{ immediate: false } };
)
.onSuccess(() => {
toast.success(LL.COMMAND_EXECUTED());
})
.onError((error) => {
toast.error(String(error.error?.message || 'An error occurred'));
});
const doAction = async () => { const save = async () => {
try { try {
setFieldErrors(undefined); setFieldErrors(undefined);
if (editItem.v === undefined && editItem.c !== undefined) { await validate(validator, editItem);
await executeCommand(editItem.c);
} else {
await validate(validator, editItem);
}
onSave(editItem); onSave(editItem);
} catch (error) { } catch (error) {
setFieldErrors((error as ValidationError).fieldErrors); setFieldErrors(error as ValidateFieldsError);
} }
}; };
@@ -106,107 +91,91 @@ const DevicesDialog = ({
} }
}; };
const showHelperText = (dv: DeviceValue) => { const showHelperText = (dv: DeviceValue) =>
if (dv.h) return dv.h; dv.h ? (
if (dv.l) return dv.l.join(' | '); dv.h
if (dv.m !== undefined && dv.x !== undefined) { ) : dv.l ? (
return ( dv.l.join(' | ')
<> ) : dv.m !== undefined && dv.x !== undefined ? (
{dv.m}&nbsp;&rarr;&nbsp;{dv.x} <>
</> {dv.m}&nbsp;&rarr;&nbsp;{dv.x}
); </>
} ) : undefined;
return undefined;
};
const kind = getDeviceValueKind(selectedItem);
const dialogTitle =
kind === 'command' || kind === 'deviceCommand'
? LL.RUN() + ' ' + LL.COMMAND(0)
: kind === 'scheduler'
? LL.UPDATE() + ' ' + LL.SCHEDULE(0)
: writeable
? LL.CHANGE_VALUE()
: LL.VALUE(0);
const buttonLabel =
kind === 'command' || kind === 'deviceCommand' ? LL.RUN() : LL.UPDATE();
const helperText = showHelperText(editItem);
const valueLabel = LL.VALUE(0);
return ( return (
<Dialog sx={dialogStyle} open={open} onClose={onClose}> <Dialog sx={dialogStyle} open={open} onClose={close}>
<DialogTitle>{dialogTitle}</DialogTitle> <DialogTitle>
{selectedItem.v === '' && selectedItem.c
? LL.RUN_COMMAND()
: writeable
? LL.CHANGE_VALUE()
: LL.VALUE(0)}
</DialogTitle>
<DialogContent dividers> <DialogContent dividers>
<Typography sx={{ mb: 2 }} color="warning" variant="body2"> <Box color="warning.main" p={0} pl={0} pr={0} mt={0} mb={2}>
{editItem.id.slice(2)} <Typography variant="body2">{editItem.id.slice(2)}</Typography>
</Typography> </Box>
{kind !== 'command' && ( <Grid container>
<Grid container> <Grid size={12}>
<Grid size={12}> {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 onChange={updateFormValue}
onChange={updateFormValue} >
> {editItem.l.map((val) => (
{editItem.l.map((val) => ( <MenuItem value={val} key={val}>
<MenuItem value={val} key={val}> {val}
{val} </MenuItem>
</MenuItem> ))}
))} </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} type="number"
type="number" sx={{ width: '30ch' }}
sx={{ width: '30ch' }} onChange={updateFormValue}
onChange={updateFormValue} slotProps={{
slotProps={{ htmlInput: editItem.s
htmlInput: editItem.s ? { min: editItem.m, max: editItem.x, step: editItem.s }
? { min: editItem.m, max: editItem.x, step: editItem.s } : {},
: {}, input: {
input: { startAdornment: (
startAdornment: ( <InputAdornment position="start">
<InputAdornment position="start"> {setUom(editItem.u)}
{setUom(editItem.u)} </InputAdornment>
</InputAdornment> )
) }
} }}
}} />
/> ) : (
) : ( <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' }} multiline={!editItem.u}
multiline={!editItem.u} onChange={updateFormValue}
onChange={updateFormValue} />
/>
)}
</Grid>
{writeable && helperText && (
<Grid>
<FormHelperText>{helperText}</FormHelperText>
</Grid>
)} )}
</Grid> </Grid>
)} {writeable && (
<Grid>
<FormHelperText>{showHelperText(editItem)}</FormHelperText>
</Grid>
)}
</Grid>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
@@ -222,24 +191,18 @@ const DevicesDialog = ({
<Button <Button
startIcon={<CancelIcon />} startIcon={<CancelIcon />}
variant="outlined" variant="outlined"
onClick={onClose} onClick={close}
color="secondary" color="secondary"
> >
{LL.CANCEL()} {LL.CANCEL()}
</Button> </Button>
<Button <Button
startIcon={ startIcon={<WarningIcon color="warning" />}
kind === 'command' ? (
<PlayArrowIcon />
) : kind === 'deviceEntity' || kind === 'deviceCommand' ? (
<WarningIcon color="warning" />
) : undefined
}
variant="outlined" variant="outlined"
onClick={doAction} onClick={save}
color={kind !== 'value' ? 'success' : 'primary'} color="primary"
> >
{buttonLabel} {selectedItem.v === '' && selectedItem.c ? LL.EXECUTE() : LL.UPDATE()}
</Button> </Button>
{progress && ( {progress && (
<CircularProgress <CircularProgress
@@ -254,7 +217,7 @@ const DevicesDialog = ({
)} )}
</Box> </Box>
) : ( ) : (
<Button variant="outlined" onClick={onClose} color="secondary"> <Button variant="outlined" onClick={close} color="secondary">
{LL.CLOSE()} {LL.CLOSE()}
</Button> </Button>
)} )}
+54 -65
View File
@@ -9,42 +9,33 @@ interface EntityMaskToggleProps {
de: DeviceEntity; de: DeviceEntity;
} }
const MASK_VALUES = [
DeviceEntityMask.DV_WEB_EXCLUDE, // 1
DeviceEntityMask.DV_API_MQTT_EXCLUDE, // 2
DeviceEntityMask.DV_READONLY, // 4
DeviceEntityMask.DV_FAVORITE, // 8
DeviceEntityMask.DV_DELETED // 128
];
const getMaskNumber = (newMask: string[]): number =>
newMask.reduce((mask, entry) => mask | Number(entry), 0);
const getMaskString = (mask: number): string[] =>
MASK_VALUES.filter((value) => (mask & value) === value).map((value) =>
String(value)
);
const hasMask = (mask: number, flag: number): boolean => (mask & flag) === flag;
const EntityMaskToggle = ({ onUpdate, de }: EntityMaskToggleProps) => { const EntityMaskToggle = ({ onUpdate, de }: EntityMaskToggleProps) => {
const handleChange = (_event: unknown, mask: string[]) => { const getMaskNumber = (newMask: string[]) => {
const newMask = getMaskNumber(mask); let new_mask = 0;
const updatedDe = { ...de }; for (const entry of newMask) {
new_mask |= Number(entry);
// 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;
} }
return new_mask;
};
// If excluded from web, cannot be favorite const getMaskString = (m: number) => {
if (hasMask(updatedDe.m, DeviceEntityMask.DV_WEB_EXCLUDE)) { const new_masks: string[] = [];
updatedDe.m = updatedDe.m & ~DeviceEntityMask.DV_FAVORITE; if ((m & 1) === 1) {
new_masks.push('1');
} }
if ((m & 2) === 2) {
onUpdate(updatedDe); new_masks.push('2');
}
if ((m & 4) === 4) {
new_masks.push('4');
}
if ((m & 8) === 8) {
new_masks.push('8');
}
if ((m & 128) === 128) {
new_masks.push('128');
}
return new_masks;
}; };
return ( return (
@@ -52,59 +43,57 @@ const EntityMaskToggle = ({ onUpdate, de }: EntityMaskToggleProps) => {
size="small" size="small"
color="secondary" color="secondary"
value={getMaskString(de.m)} value={getMaskString(de.m)}
onChange={handleChange} onChange={(event, mask: string[]) => {
> de.m = getMaskNumber(mask);
<ToggleButton if (de.n === '' && de.m & DeviceEntityMask.DV_READONLY) {
value="8" de.m = de.m | DeviceEntityMask.DV_WEB_EXCLUDE;
disabled={
hasMask(
de.m,
DeviceEntityMask.DV_WEB_EXCLUDE | DeviceEntityMask.DV_DELETED
) || de.n === undefined
} }
> 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}>
<OptionIcon <OptionIcon
type="favorite" type="favorite"
isSet={hasMask(de.m, DeviceEntityMask.DV_FAVORITE)} isSet={
(de.m & DeviceEntityMask.DV_FAVORITE) === DeviceEntityMask.DV_FAVORITE
}
/> />
</ToggleButton> </ToggleButton>
<ToggleButton <ToggleButton value="4" disabled={!de.w || (de.m & 0x83) >= 3}>
value="4"
disabled={
!de.w ||
hasMask(
de.m,
DeviceEntityMask.DV_WEB_EXCLUDE | DeviceEntityMask.DV_FAVORITE
)
}
>
<OptionIcon <OptionIcon
type="readonly" type="readonly"
isSet={hasMask(de.m, DeviceEntityMask.DV_READONLY)} isSet={
(de.m & DeviceEntityMask.DV_READONLY) === DeviceEntityMask.DV_READONLY
}
/> />
</ToggleButton> </ToggleButton>
<ToggleButton <ToggleButton value="2" disabled={de.n === '' || (de.m & 0x80) !== 0}>
value="2"
disabled={de.n === '' || hasMask(de.m, DeviceEntityMask.DV_DELETED)}
>
<OptionIcon <OptionIcon
type="api_mqtt_exclude" type="api_mqtt_exclude"
isSet={hasMask(de.m, DeviceEntityMask.DV_API_MQTT_EXCLUDE)} isSet={
(de.m & DeviceEntityMask.DV_API_MQTT_EXCLUDE) ===
DeviceEntityMask.DV_API_MQTT_EXCLUDE
}
/> />
</ToggleButton> </ToggleButton>
<ToggleButton <ToggleButton value="1" disabled={de.n === undefined || (de.m & 0x80) !== 0}>
value="1"
disabled={de.n === undefined || hasMask(de.m, DeviceEntityMask.DV_DELETED)}
>
<OptionIcon <OptionIcon
type="web_exclude" type="web_exclude"
isSet={hasMask(de.m, DeviceEntityMask.DV_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" type="deleted"
isSet={hasMask(de.m, DeviceEntityMask.DV_DELETED)} isSet={
(de.m & DeviceEntityMask.DV_DELETED) === DeviceEntityMask.DV_DELETED
}
/> />
</ToggleButton> </ToggleButton>
</ToggleButtonGroup> </ToggleButtonGroup>
+118 -122
View File
@@ -1,5 +1,5 @@
import { memo, useContext, useState } from 'react'; import { useContext, useState } from 'react';
import type { ReactElement } from 'react'; import { toast } from 'react-toastify';
import CommentIcon from '@mui/icons-material/CommentTwoTone'; import CommentIcon from '@mui/icons-material/CommentTwoTone';
import DownloadIcon from '@mui/icons-material/GetApp'; import DownloadIcon from '@mui/icons-material/GetApp';
@@ -10,7 +10,6 @@ import {
Box, Box,
Button, Button,
Divider, Divider,
Grid,
Link, Link,
List, List,
ListItem, ListItem,
@@ -20,11 +19,9 @@ 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';
import { toast } from 'components/toast';
import { AuthenticatedContext } from 'contexts/authentication'; import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import { saveFile } from 'utils'; import { saveFile } from 'utils';
@@ -32,60 +29,42 @@ 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';
interface HelpLink { const Help = () => {
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 SYSTEM_INFO_API: APIcall = { device: 'system', cmd: 'info', id: 0 };
const HelpComponent = () => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
useLayoutTitle(LL.HELP()); useLayoutTitle(LL.HELP());
const { me } = useContext(AuthenticatedContext); const { me } = useContext(AuthenticatedContext);
const [customSupport, setCustomSupport] = useState<CustomSupport>({ const [customSupportIMG, setCustomSupportIMG] = useState<string | null>(null);
img_url: null, const [customSupportHTML, setCustomSupportHTML] = useState<string | null>(null);
html: null const [notFound, setNotFound] = useState<boolean>(false);
});
const [imgError, setImgError] = useState<boolean>(false);
useRequest(callAction({ action: 'getCustomSupport' })).onSuccess((event) => { useRequest(() => callAction({ action: 'customSupport' })).onSuccess((event) => {
if (event?.data && Object.keys(event.data).length !== 0) { if (event && event.data && Object.keys(event.data).length !== 0) {
const { Support } = event.data as { const data = event.data.Support;
Support: { img_url?: string; html?: string[] }; if (data.img_url) {
}; setCustomSupportIMG(data.img_url);
setCustomSupport({ }
img_url: Support.img_url || null, if (data.html) {
html: Support.html?.join('<br/>') || null setCustomSupportHTML(data.html.join('<br/>'));
}); }
} }
}); });
// const { send: sendExportAllValues } = useRequest(
// () => callAction({ action: 'export', param: 'allvalues' }),
// {
// immediate: false
// }
// )
// .onSuccess((event) => {
// saveFile(event.data, 'allvalues', '.txt');
// toast.info(LL.DOWNLOAD_SUCCESSFUL());
// })
// .onError((error) => {
// toast.error(error.message);
// });
const { send: sendAPI } = useRequest((data: APIcall) => API(data), { const { send: sendAPI } = useRequest((data: APIcall) => API(data), {
immediate: false immediate: false
}) })
@@ -94,102 +73,119 @@ const HelpComponent = () => {
toast.info(LL.DOWNLOAD_SUCCESSFUL()); toast.info(LL.DOWNLOAD_SUCCESSFUL());
}) })
.onError((error) => { .onError((error) => {
toast.error(String(error.error?.message || 'An error occurred')); toast.error(error.message);
}); });
const helpLinks: HelpLink[] = [
{
href: 'https://emsesp.org',
icon: <MenuBookIcon />,
label: () => LL.HELP_INFORMATION_1()
},
{
href: 'https://discord.gg/GP9DPSgeJq',
icon: <CommentIcon />,
label: () => LL.HELP_INFORMATION_2()
},
{
href: 'https://github.com/emsesp/EMS-ESP32/issues/new/choose',
icon: <GitHubIcon />,
label: () => LL.HELP_INFORMATION_3()
}
];
const imageSrc =
imgError || !customSupport.img_url ? DEFAULT_IMAGE_URL : customSupport.img_url;
return ( return (
<SectionContent> <SectionContent>
{customSupport.html && ( <Stack
<Stack padding={1}
direction="row" mb={2}
divider={<Divider orientation="vertical" flexItem />} direction="row"
sx={{ padding: 1, mb: 2, ...SUPPORT_BOX_STYLES }} divider={<Divider orientation="vertical" flexItem />}
> sx={{
<Typography variant="subtitle1"> borderRadius: 3,
<div dangerouslySetInnerHTML={{ __html: customSupport.html }} /> border: '2px solid grey',
</Typography> justifyContent: 'space-evenly',
<Box alignItems: 'center'
component="img" }}
referrerPolicy="no-referrer" >
sx={IMAGE_STYLES} <Typography variant="subtitle1">
onError={() => setImgError(true)} {customSupportHTML ? (
src={imageSrc} <div dangerouslySetInnerHTML={{ __html: customSupportHTML }} />
/> ) : (
</Stack> LL.HELP_INFORMATION_5()
)} )}
</Typography>
<Box
component="img"
referrerPolicy="no-referrer"
sx={{
maxHeight: { xs: 100, md: 250 }
}}
onError={() => setNotFound(true)}
src={
notFound
? ''
: customSupportIMG || 'https://emsesp.org/_media/images/installer.jpeg'
}
/>
</Stack>
{me?.admin && ( {me.admin && (
<List> <List sx={{ borderRadius: 3, border: '2px solid grey' }}>
{helpLinks.map(({ href, icon, label }) => ( <ListItem>
<ListItem key={href}> <ListItemButton component="a" href="https://emsesp.org">
<ListItemButton <ListItemAvatar>
component="a" <Avatar sx={{ bgcolor: '#72caf9' }}>
target="_blank" <MenuBookIcon />
rel="noreferrer" </Avatar>
href={href} </ListItemAvatar>
> <ListItemText primary={LL.HELP_INFORMATION_1()} />
<ListItemAvatar> </ListItemButton>
<Avatar sx={AVATAR_STYLES}>{icon}</Avatar> </ListItem>
</ListItemAvatar>
<ListItemText primary={label()} /> <ListItem>
</ListItemButton> <ListItemButton component="a" href="https://discord.gg/3J3GgnzpyT">
</ListItem> <ListItemAvatar>
))} <Avatar sx={{ bgcolor: '#72caf9' }}>
<CommentIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.HELP_INFORMATION_2()} />
</ListItemButton>
</ListItem>
<ListItem>
<ListItemButton
component="a"
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>
)} )}
<Grid container spacing={2} sx={{ mt: 2, alignItems: 'center' }}> <Box p={2} color="warning.main">
<Typography sx={{ mb: 1 }} color="warning" variant="body1"> <Typography mb={1} variant="body1">
{LL.HELP_INFORMATION_4()}: {LL.HELP_INFORMATION_4()}.
</Typography> </Typography>
<Button <Button
startIcon={<DownloadIcon />} startIcon={<DownloadIcon />}
variant="outlined" variant="outlined"
color="primary" color="primary"
onClick={() => void sendAPI(SYSTEM_INFO_API)} onClick={() => sendAPI({ device: 'system', cmd: 'info', id: 0 })}
> >
{LL.SUPPORT_INFORMATION(0)} {LL.SUPPORT_INFORMATION(0)}
</Button> </Button>
</Grid> </Box>
{/* <Button
sx={{ ml: 2 }}
startIcon={<DownloadIcon />}
variant="outlined"
color="primary"
onClick={() => sendExportAllValues()}
>
{LL.DOWNLOAD(1)}&nbsp;{LL.ALLVALUES()}
</Button> */}
<Divider sx={{ mt: 4 }} /> <Divider sx={{ mt: 4 }} />
<Typography color="white" variant="subtitle1" align="center" sx={{ mt: 1 }}> <Typography color="white" variant="subtitle1" align="center" mt={1}>
&copy;&nbsp; &copy;&nbsp;
<Link <Link target="_blank" href="https://emsesp.org" color="primary">
target="_blank" {'emsesp.org'}
rel="noreferrer"
href="https://emsesp.org"
color="primary"
>
emsesp.org
</Link> </Link>
</Typography> </Typography>
</SectionContent> </SectionContent>
); );
}; };
const Help = memo(HelpComponent);
export default Help; export default Help;
+50 -56
View File
@@ -1,5 +1,6 @@
import { memo, useState } from 'react'; import { useCallback, useState } from 'react';
import { useBlocker } from 'react-router'; import { useBlocker } from 'react-router-dom';
import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import CircleIcon from '@mui/icons-material/Circle'; import CircleIcon from '@mui/icons-material/Circle';
@@ -24,26 +25,12 @@ import {
SectionContent, SectionContent,
useLayoutTitle useLayoutTitle
} from 'components'; } from 'components';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import { readModules, writeModules } from '../../api/app'; 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);
@@ -115,25 +102,15 @@ const Modules = () => {
setDialogOpen(false); setDialogOpen(false);
}; };
const updateModuleItem = (updatedItem: ModuleItem) => {
void updateState(readModules(), (data: ModuleItem[]) => {
const new_data = data.map((mi) =>
mi.id === updatedItem.id ? { ...mi, ...updatedItem } : mi
);
setNumChanges(new_data.filter(hasModulesChanged).length);
return new_data;
});
};
const onDialogSave = (updatedItem: ModuleItem) => { const onDialogSave = (updatedItem: ModuleItem) => {
setDialogOpen(false); setDialogOpen(false);
updateModuleItem(updatedItem); updateModuleItem(updatedItem);
}; };
const editModuleItem = (mi: ModuleItem) => { const editModuleItem = useCallback((mi: ModuleItem) => {
setSelectedModuleItem(mi); setSelectedModuleItem(mi);
setDialogOpen(true); setDialogOpen(true);
}; }, []);
const onCancel = async () => { const onCancel = async () => {
await fetchModules().then(() => { await fetchModules().then(() => {
@@ -141,31 +118,43 @@ const Modules = () => {
}); });
}; };
const saveModules = async () => { function hasModulesChanged(mi: ModuleItem) {
try { return mi.enabled !== mi.o_enabled || mi.license !== mi.o_license;
await Promise.all( }
modules.map((condensed_mi: ModuleItem) =>
updateModules({ const updateModuleItem = (updatedItem: ModuleItem) => {
key: condensed_mi.key, void updateState(readModules(), (data: ModuleItem[]) => {
enabled: condensed_mi.enabled, const new_data = data.map((mi) =>
license: condensed_mi.license mi.id === updatedItem.id ? { ...mi, ...updatedItem } : mi
})
)
); );
toast.success(LL.MODULES_UPDATED()); setNumChanges(new_data.filter((mi) => hasModulesChanged(mi)).length);
} catch (error) { return new_data;
toast.error(error instanceof Error ? error.message : String(error)); });
} finally { };
await fetchModules();
setNumChanges(0); const saveModules = async () => {
} await updateModules({
modules: modules.map((condensed_mi) => ({
key: condensed_mi.key,
enabled: condensed_mi.enabled,
license: condensed_mi.license
}))
})
.then(() => {
toast.success(LL.MODULES_UPDATED());
})
.catch((error: Error) => {
toast.error(error.message);
})
.finally(async () => {
await fetchModules();
setNumChanges(0);
});
}; };
const renderContent = () => { const renderContent = () => {
if (!modules) { if (!modules) {
return ( return <FormLoader onRetry={fetchModules} errorMessage={error?.message} />;
<FormLoader onRetry={fetchModules} errorMessage={error?.message || ''} />
);
} }
if (modules.length === 0) { if (modules.length === 0) {
@@ -176,11 +165,18 @@ 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 (
<> <>
<Typography sx={{ mb: 2 }} color="warning" variant="body1"> <Box mb={2} color="warning.main">
{LL.MODULES_DESCRIPTION()}. <Typography variant="body1">{LL.MODULES_DESCRIPTION()}.</Typography>
</Typography> </Box>
<Table <Table
data={{ nodes: modules }} data={{ nodes: modules }}
theme={modules_theme} theme={modules_theme}
@@ -218,9 +214,7 @@ 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> <Cell>{colorStatus(mi.status)}</Cell>
<ColorStatus status={mi.status} />
</Cell>
</Row> </Row>
))} ))}
</Body> </Body>
@@ -228,8 +222,8 @@ const Modules = () => {
)} )}
</Table> </Table>
<Box sx={{ mt: 1, display: 'flex', flexWrap: 'wrap' }}> <Box mt={1} display="flex" flexWrap="wrap">
<Box sx={{ flexGrow: 1 }}> <Box flexGrow={1}>
{numChanges !== 0 && ( {numChanges !== 0 && (
<ButtonRow> <ButtonRow>
<Button <Button
+11 -12
View File
@@ -10,9 +10,9 @@ import {
DialogActions, DialogActions,
DialogContent, DialogContent,
DialogTitle, DialogTitle,
Grid,
TextField TextField
} from '@mui/material'; } from '@mui/material';
import Grid from '@mui/material/Grid2';
import { dialogStyle } from 'CustomTheme'; import { dialogStyle } from 'CustomTheme';
import { BlockFormControlLabel } from 'components'; import { BlockFormControlLabel } from 'components';
@@ -37,26 +37,25 @@ const ModulesDialog = ({
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const [editItem, setEditItem] = useState<ModuleItem>(selectedItem); const [editItem, setEditItem] = useState<ModuleItem>(selectedItem);
const updateFormValue = updateValue( const updateFormValue = updateValue(setEditItem);
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 handleSave = () => { const close = () => {
onClose();
};
const save = () => {
onSave(editItem); onSave(editItem);
}; };
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>{LL.EDIT() + ' ' + editItem.key}</DialogTitle>
<DialogContent dividers> <DialogContent dividers>
<Grid container> <Grid container>
<BlockFormControlLabel <BlockFormControlLabel
@@ -70,7 +69,7 @@ const ModulesDialog = ({
label="Enabled" label="Enabled"
/> />
</Grid> </Grid>
<Box sx={{ mt: 2, mb: 1 }}> <Box mt={2} mb={1}>
<TextField <TextField
name="license" name="license"
label="License Key" label="License Key"
@@ -86,7 +85,7 @@ const ModulesDialog = ({
<Button <Button
startIcon={<CancelIcon />} startIcon={<CancelIcon />}
variant="outlined" variant="outlined"
onClick={onClose} onClick={close}
color="secondary" color="secondary"
> >
{LL.CANCEL()} {LL.CANCEL()}
@@ -94,7 +93,7 @@ const ModulesDialog = ({
<Button <Button
startIcon={<DoneIcon />} startIcon={<DoneIcon />}
variant="outlined" variant="outlined"
onClick={handleSave} onClick={save}
color="primary" color="primary"
> >
{LL.UPDATE()} {LL.UPDATE()}
+24 -28
View File
@@ -1,46 +1,42 @@
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/DeleteOutlined'; import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import EditOffOutlinedIcon from '@mui/icons-material/EditOffOutlined'; import EditOffOutlinedIcon from '@mui/icons-material/EditOffOutlined';
import EditOutlinedIcon from '@mui/icons-material/EditOutlined'; import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
import InsertCommentOutlinedIcon from '@mui/icons-material/InsertCommentOutlined'; import InsertCommentOutlinedIcon from '@mui/icons-material/InsertCommentOutlined';
import StarIcon from '@mui/icons-material/Star'; import StarIcon from '@mui/icons-material/Star';
import StarOutlineIcon from '@mui/icons-material/StarOutlined'; import StarOutlineIcon from '@mui/icons-material/StarOutline';
import VisibilityOffOutlinedIcon from '@mui/icons-material/VisibilityOffOutlined'; 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';
export type OptionType = type OptionType =
'deleted' | 'readonly' | 'web_exclude' | 'api_mqtt_exclude' | 'favorite'; | 'deleted'
| 'readonly'
| 'web_exclude'
| 'api_mqtt_exclude'
| 'favorite';
type IconPair = [ const OPTION_ICONS: {
React.ComponentType<SvgIconProps>, [type in OptionType]: [
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} />;
}; };
export default memo(OptionIcon); const OptionIcon = ({ type, isSet }: { type: OptionType; isSet: boolean }) => {
const Icon = OPTION_ICONS[type][isSet ? 0 : 1];
return isSet ? (
<Icon color="primary" sx={{ fontSize: 16, verticalAlign: 'middle' }} />
) : (
<Icon sx={{ fontSize: 16, verticalAlign: 'middle' }} />
);
};
export default OptionIcon;
+151 -178
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useBlocker } from 'react-router'; import { useBlocker } from 'react-router-dom';
import { toast } from 'react-toastify';
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';
@@ -25,82 +26,14 @@ import {
SectionContent, SectionContent,
useLayoutTitle useLayoutTitle
} from 'components'; } from 'components';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import { useInterval } from 'utils';
import { readCommands, readSchedule, writeSchedule } from '../../api/app'; import { readSchedule, writeSchedule } from '../../api/app';
import SettingsSchedulerDialog from './SchedulerDialog'; import SettingsSchedulerDialog from './SchedulerDialog';
import { ScheduleFlag } from './types'; 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 FLAG_ALL_DAYS = 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: FLAG_ALL_DAYS,
time: '',
cmd_name: '',
name: ''
};
const scheduleTheme = {
Table: `
--data-table-library_grid-template-columns: 36px 220px repeat(1, minmax(20px, 1fr)) 192px 160px;
`,
BaseRow: `
font-size: 14px;
.td {
height: 32px;
}
`,
BaseCell: `
&:nth-of-type(1) {
text-align: 8px;
}
`,
HeaderRow: `
text-transform: uppercase;
background-color: black;
color: #90CAF9;
.th {
border-bottom: 1px solid #565656;
height: 36px;
}
`,
Row: `
background-color: #1e1e1e;
position: relative;
cursor: pointer;
.td {
border-bottom: 1px solid #565656;
}
&:hover .td {
background-color: #177ac9;
}
`
};
const scheduleTypeLabels: Record<number, string> = {
[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);
@@ -120,11 +53,6 @@ const Scheduler = () => {
initialData: [] initialData: []
}); });
const { data: commandNames } = useRequest(readCommands, {
initialData: [],
initializing: true
});
const { send: updateSchedule } = useRequest( const { send: updateSchedule } = useRequest(
(data: Schedule) => writeSchedule(data), (data: Schedule) => writeSchedule(data),
{ {
@@ -132,7 +60,7 @@ const Scheduler = () => {
} }
); );
const hasScheduleChanged = (si: ScheduleItem) => { function hasScheduleChanged(si: ScheduleItem) {
return ( return (
si.id !== si.o_id || si.id !== si.o_id ||
(si.name || '') !== (si.o_name || '') || (si.name || '') !== (si.o_name || '') ||
@@ -140,64 +68,97 @@ const Scheduler = () => {
si.deleted !== si.o_deleted || si.deleted !== si.o_deleted ||
si.flags !== si.o_flags || si.flags !== si.o_flags ||
si.time !== si.o_time || si.time !== si.o_time ||
si.cmd_name !== si.o_cmd_name si.cmd !== si.o_cmd ||
si.value !== si.o_value
); );
}; }
useInterval(() => {
if (numChanges === 0) {
void fetchSchedule();
}
}, 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 = WEEK_DAYS.map((day) => { const days = [1, 2, 3, 4, 5, 6, 7].map((day) => {
const dayStr = String(day).padStart(2, '0'); const dd = day < 10 ? `0${day}` : day;
return new Date( return new Date(`2017-01-${dd}T00:00:00+00:00`);
`${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(scheduleTheme); const schedule_theme = useTheme({
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 = async () => {
try { await updateSchedule({
await updateSchedule({ schedule: schedule
schedule: schedule .filter((si) => !si.deleted)
.filter((si: ScheduleItem) => !si.deleted) .map((condensed_si) => ({
.map((condensed_si: ScheduleItem) => ({ id: condensed_si.id,
id: condensed_si.id, active: condensed_si.active,
active: condensed_si.active, flags: condensed_si.flags,
flags: condensed_si.flags, time: condensed_si.time,
time: condensed_si.time, cmd: condensed_si.cmd,
cmd_name: condensed_si.cmd_name, value: condensed_si.value,
name: condensed_si.name 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);
}
}; };
const editScheduleItem = (si: ScheduleItem) => { const editScheduleItem = useCallback((si: ScheduleItem) => {
setCreating(false); setCreating(false);
setSelectedScheduleItem(si); setSelectedScheduleItem(si);
setDialogOpen(true); setDialogOpen(true);
if (si.o_name === undefined) { if (si.o_name === undefined) {
si.o_name = si.name; si.o_name = si.name;
} }
}; }, []);
const onDialogClose = () => { const onDialogClose = () => {
setDialogOpen(false); setDialogOpen(false);
@@ -213,7 +174,10 @@ const Scheduler = () => {
setDialogOpen(false); setDialogOpen(false);
void updateState(readSchedule(), (data: ScheduleItem[]) => { void updateState(readSchedule(), (data: ScheduleItem[]) => {
const new_data = creating const new_data = creating
? [...data, updatedItem] ? [
...data.filter((si) => creating || si.o_id !== updatedItem.o_id),
updatedItem
]
: data.map((si) => : data.map((si) =>
si.id === updatedItem.id ? { ...si, ...updatedItem } : si si.id === updatedItem.id ? { ...si, ...updatedItem } : si
); );
@@ -226,56 +190,63 @@ const Scheduler = () => {
const addScheduleItem = () => { const addScheduleItem = () => {
setCreating(true); setCreating(true);
const newItem: ScheduleItem = { setSelectedScheduleItem({
id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID), id: Math.floor(Math.random() * (Math.floor(200) - 100) + 100),
...DEFAULT_SCHEDULE_ITEM active: false,
}; deleted: false,
setSelectedScheduleItem(newItem); flags: ScheduleFlag.SCHEDULE_DAY,
time: '',
cmd: '',
value: '',
name: ''
});
setDialogOpen(true); setDialogOpen(true);
}; };
const filteredAndSortedSchedule = schedule
.filter((si: ScheduleItem) => !si.deleted)
.sort((a: ScheduleItem, b: ScheduleItem) => a.flags - b.flags);
const dayBox = (si: ScheduleItem, flag: number, isLast = false) => {
const dayIndex = Math.log(flag) / LOG_2;
const isActive = (si.flags & flag) === flag;
return (
<>
<Box>
<Typography sx={{ fontSize: 11 }} color={isActive ? 'primary' : 'grey'}>
{dow[dayIndex]}
</Typography>
</Box>
{!isLast && <Divider orientation="vertical" flexItem />}
</>
);
};
const scheduleType = (si: ScheduleItem) => {
const label = scheduleTypeLabels[si.flags];
return (
<Box>
<Typography sx={{ fontSize: 11 }} color="primary">
{label || ''}
</Typography>
</Box>
);
};
const renderSchedule = () => { const renderSchedule = () => {
if (!schedule) { if (!schedule) {
return ( return <FormLoader onRetry={fetchSchedule} errorMessage={error?.message} />;
<FormLoader onRetry={fetchSchedule} errorMessage={error?.message || ''} />
);
} }
const dayBox = (si: ScheduleItem, flag: number) => (
<>
<Box>
<Typography
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) => (
<Box>
<Typography sx={{ fontSize: 11 }} color="primary">
{si.flags === ScheduleFlag.SCHEDULE_IMMEDIATE ? (
<>Immediate</>
) : si.flags === ScheduleFlag.SCHEDULE_TIMER ? (
<>Timer</>
) : si.flags === ScheduleFlag.SCHEDULE_CONDITION ? (
<>Condition</>
) : si.flags === ScheduleFlag.SCHEDULE_ONCHANGE ? (
<>On Change</>
) : (
<></>
)}
</Typography>
</Box>
);
return ( return (
<Table <Table
data={{ nodes: filteredAndSortedSchedule }} data={{
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 }}
> >
@@ -285,10 +256,9 @@ const Scheduler = () => {
<HeaderRow> <HeaderRow>
<HeaderCell /> <HeaderCell />
<HeaderCell stiff>{LL.SCHEDULE(0)}</HeaderCell> <HeaderCell stiff>{LL.SCHEDULE(0)}</HeaderCell>
<HeaderCell stiff> <HeaderCell stiff>{LL.TIME(0)}/Cond.</HeaderCell>
{LL.TIME(0)}/{LL.CONDITION()}
</HeaderCell>
<HeaderCell stiff>{LL.COMMAND(0)}</HeaderCell> <HeaderCell stiff>{LL.COMMAND(0)}</HeaderCell>
<HeaderCell stiff>{LL.VALUE(0)}</HeaderCell>
<HeaderCell stiff>{LL.NAME(0)}</HeaderCell> <HeaderCell stiff>{LL.NAME(0)}</HeaderCell>
</HeaderRow> </HeaderRow>
</Header> </Header>
@@ -296,19 +266,22 @@ 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>
<CircleIcon {si.active ? (
color={si.active ? 'success' : 'error'} <CircleIcon
sx={{ color="success"
fontSize: ICON_SIZE, sx={{ fontSize: 16, verticalAlign: 'middle' }}
ml: '4px', />
position: 'relative', ) : (
top: '2px' <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">
{si.flags > SCHEDULE_FLAG_THRESHOLD ? ( <Divider orientation="vertical" flexItem />
{si.flags > 127 ? (
scheduleType(si) scheduleType(si)
) : ( ) : (
<> <>
@@ -318,13 +291,14 @@ const Scheduler = () => {
{dayBox(si, ScheduleFlag.SCHEDULE_THU)} {dayBox(si, ScheduleFlag.SCHEDULE_THU)}
{dayBox(si, ScheduleFlag.SCHEDULE_FRI)} {dayBox(si, ScheduleFlag.SCHEDULE_FRI)}
{dayBox(si, ScheduleFlag.SCHEDULE_SAT)} {dayBox(si, ScheduleFlag.SCHEDULE_SAT)}
{dayBox(si, ScheduleFlag.SCHEDULE_SUN, true)} {dayBox(si, ScheduleFlag.SCHEDULE_SUN)}
</> </>
)} )}
</Stack> </Stack>
</Cell> </Cell>
<Cell>{si.time === '' ? LL.SCHEDULER_HELP_2() : si.time}</Cell> <Cell>{si.time}</Cell>
<Cell>{si.cmd_name}</Cell> <Cell>{si.cmd}</Cell>
<Cell>{si.value}</Cell>
<Cell>{si.name}</Cell> <Cell>{si.name}</Cell>
</Row> </Row>
))} ))}
@@ -338,9 +312,9 @@ const Scheduler = () => {
return ( return (
<SectionContent> <SectionContent>
{blocker ? <BlockNavigation blocker={blocker} /> : null} {blocker ? <BlockNavigation blocker={blocker} /> : null}
<Typography sx={{ mb: 2 }} color="warning" variant="body1"> <Box mb={2} color="warning.main">
{LL.SCHEDULER_HELP_1()}. <Typography variant="body1">{LL.SCHEDULER_HELP_1()}.</Typography>
</Typography> </Box>
{renderSchedule()} {renderSchedule()}
{selectedScheduleItem && ( {selectedScheduleItem && (
@@ -352,12 +326,11 @@ const Scheduler = () => {
selectedItem={selectedScheduleItem} selectedItem={selectedScheduleItem}
validator={schedulerItemValidation(schedule, selectedScheduleItem)} validator={schedulerItemValidation(schedule, selectedScheduleItem)}
dow={dow} dow={dow}
commandNames={commandNames.map((ci) => ci.name)}
/> />
)} )}
<Box sx={{ display: 'flex', flexWrap: 'wrap' }}> <Box mt={1} display="flex" flexWrap="wrap">
<Box sx={{ flexGrow: 1 }}> <Box flexGrow={1}>
{numChanges !== 0 && ( {numChanges !== 0 && (
<ButtonRow> <ButtonRow>
<Button <Button
@@ -379,7 +352,7 @@ const Scheduler = () => {
</ButtonRow> </ButtonRow>
)} )}
</Box> </Box>
<Box sx={{ flexWrap: 'nowrap', whiteSpace: 'nowrap' }}> <Box flexWrap="nowrap" whiteSpace="nowrap">
<ButtonRow> <ButtonRow>
<Button <Button
startIcon={<AddIcon />} startIcon={<AddIcon />}
+222 -196
View File
@@ -2,9 +2,9 @@ import { useEffect, 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 CircleIcon from '@mui/icons-material/Circle';
import DoneIcon from '@mui/icons-material/Done'; import DoneIcon from '@mui/icons-material/Done';
import RemoveIcon from '@mui/icons-material/RemoveCircleOutlined'; import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import RemoveIcon from '@mui/icons-material/RemoveCircleOutline';
import { import {
Box, Box,
Button, Button,
@@ -13,60 +13,24 @@ import {
DialogActions, DialogActions,
DialogContent, DialogContent,
DialogTitle, DialogTitle,
Grid,
MenuItem,
TextField, TextField,
ToggleButton, ToggleButton,
ToggleButtonGroup, ToggleButtonGroup,
Typography Typography
} from '@mui/material'; } from '@mui/material';
import Grid from '@mui/material/Grid2';
import { dialogStyle } from 'CustomTheme'; import { dialogStyle } from 'CustomTheme';
import type Schema from 'async-validator';
import type { ValidateFieldsError } from 'async-validator';
import { BlockFormControlLabel, ValidatedTextField } from 'components'; import { BlockFormControlLabel, ValidatedTextField } from 'components';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import { updateValue } from 'utils'; import { updateValue } from 'utils';
import { ValidationError, validate } from 'validators'; import { validate } from 'validators';
import type Schema from 'validators/schema';
import type { ValidateFieldsError } from 'validators/schema';
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 = 127;
const FLAG_ALL_DAYS = 127;
const DEFAULT_TIME = '00:00';
const TYPOGRAPHY_FONT_SIZE = 10;
// Day of week flag configuration (static, defined outside component)
const DAY_FLAGS = [
{ value: '2', flag: ScheduleFlag.SCHEDULE_MON },
{ value: '4', flag: ScheduleFlag.SCHEDULE_TUE },
{ value: '8', flag: ScheduleFlag.SCHEDULE_WED },
{ value: '16', flag: ScheduleFlag.SCHEDULE_THU },
{ value: '32', flag: ScheduleFlag.SCHEDULE_FRI },
{ value: '64', flag: ScheduleFlag.SCHEDULE_SAT },
{ value: '1', flag: ScheduleFlag.SCHEDULE_SUN }
] as const;
// Day of week flag values array (static)
const FLAG_VALUES = [
ScheduleFlag.SCHEDULE_SUN,
ScheduleFlag.SCHEDULE_MON,
ScheduleFlag.SCHEDULE_TUE,
ScheduleFlag.SCHEDULE_WED,
ScheduleFlag.SCHEDULE_THU,
ScheduleFlag.SCHEDULE_FRI,
ScheduleFlag.SCHEDULE_SAT
] as const;
const getFlagDOWnumber = (flags: string[]) =>
flags.reduce((acc, flag) => acc | Number(flag), 0) & FLAG_MASK_127;
const getFlagDOWstring = (f: number) =>
FLAG_VALUES.filter((flag) => (f & flag) === flag).map((flag) => String(flag));
interface SchedulerDialogProps { interface SchedulerDialogProps {
open: boolean; open: boolean;
creating: boolean; creating: boolean;
@@ -75,7 +39,6 @@ interface SchedulerDialogProps {
selectedItem: ScheduleItem; selectedItem: ScheduleItem;
validator: Schema; validator: Schema;
dow: string[]; dow: string[];
commandNames: string[];
} }
const SchedulerDialog = ({ const SchedulerDialog = ({
@@ -85,120 +48,112 @@ const SchedulerDialog = ({
onSave, onSave,
selectedItem, selectedItem,
validator, validator,
dow, dow
commandNames
}: SchedulerDialogProps) => { }: SchedulerDialogProps) => {
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( const updateFormValue = updateValue(setEditItem);
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...
// 0-127 is day schedule
// 128 is timer
// 129 is on change
// 130 is on condition
// 132 is immediate
setScheduleType( setScheduleType(
selectedItem.flags <= SCHEDULE_TYPE_THRESHOLD selectedItem.flags < 128 ? ScheduleFlag.SCHEDULE_DAY : selectedItem.flags
? ScheduleFlag.SCHEDULE_DAY
: selectedItem.flags
); );
} }
}, [open, selectedItem]); }, [open, selectedItem]);
const handleSave = async (itemToSave: ScheduleItem) => { const save = async () => {
try { try {
setFieldErrors(undefined); setFieldErrors(undefined);
await validate(validator, itemToSave); await validate(validator, editItem);
onSave(itemToSave); onSave(editItem);
} catch (error) { } catch (error) {
setFieldErrors((error as ValidationError).fieldErrors); setFieldErrors(error as ValidateFieldsError);
} }
}; };
const save = async () => { const saveandactivate = async () => {
await handleSave(editItem); editItem.active = true;
try {
setFieldErrors(undefined);
await validate(validator, editItem);
onSave(editItem);
} catch (error) {
setFieldErrors(error as ValidateFieldsError);
}
}; };
const remove = () => { const remove = () => {
onSave({ ...editItem, deleted: true }); editItem.deleted = true;
onSave(editItem);
}; };
const DayOfWeekButton = (flag: number) => { const getFlagDOWnumber = (newFlag: string[]) => {
const dayIndex = Math.log2(flag); let new_flag = 0;
const isSelected = (editItem.flags & flag) === flag; for (const entry of newFlag) {
return ( new_flag |= Number(entry);
<Typography }
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }} return new_flag & 127;
color={isSelected ? 'primary' : 'grey'}
>
{dow[dayIndex]}
</Typography>
);
}; };
const handleClose = ( const getFlagDOWstring = (f: number) => {
_event: React.SyntheticEvent, const new_flags: string[] = [];
reason: 'backdropClick' | 'escapeKeyDown' 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') => {
if (reason !== 'backdropClick') { if (reason !== 'backdropClick') {
onClose(); onClose();
} }
}; };
const handleScheduleTypeChange = (
_event: React.SyntheticEvent<HTMLElement>,
flag: ScheduleFlag | null
) => {
if (flag !== null) {
setFieldErrors(undefined); // clear any validation errors
setScheduleType(flag);
// wipe the time field when changing the schedule type
// set the flags based on type
const newFlags = flag === ScheduleFlag.SCHEDULE_DAY ? FLAG_ALL_DAYS : flag;
setEditItem((prev) => ({ ...prev, time: '', flags: newFlags }));
}
};
const handleDOWChange = (
_event: React.SyntheticEvent<HTMLElement>,
flags: string[]
) => {
const newFlags =
getFlagDOWnumber(flags) === 0 ? FLAG_ALL_DAYS : getFlagDOWnumber(flags);
setEditItem((prev) => ({ ...prev, flags: newFlags }));
};
const isDaySchedule = scheduleType === ScheduleFlag.SCHEDULE_DAY;
const isTimerSchedule = scheduleType === ScheduleFlag.SCHEDULE_TIMER;
const needsTimeField = isDaySchedule || isTimerSchedule;
const dowFlags = getFlagDOWstring(editItem.flags);
const timeFieldValue = needsTimeField
? editItem.time === ''
? DEFAULT_TIME
: editItem.time
: editItem.time === DEFAULT_TIME
? ''
: editItem.time;
const timeFieldLabel = (() => {
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();
return LL.TIME(1);
})();
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>
@@ -208,27 +163,47 @@ const SchedulerDialog = ({
value={scheduleType} value={scheduleType}
exclusive exclusive
disabled={!creating} disabled={!creating}
onChange={handleScheduleTypeChange} onChange={(_event, flag: ScheduleFlag) => {
if (flag !== null) {
setFieldErrors(undefined); // clear any validation errors
setScheduleType(flag);
// wipe the time field when changing the schedule type
setEditItem({ ...editItem, time: '' });
// set the flags based on type
// 0-127 is day schedule
// 128 is timer
// 129 is on change
// 130 is on condition
// 132 is immediate
setEditItem(
flag === ScheduleFlag.SCHEDULE_DAY
? { ...editItem, flags: 0 }
: { ...editItem, flags: flag }
);
}
}}
> >
<ToggleButton value={ScheduleFlag.SCHEDULE_DAY}> <ToggleButton value={ScheduleFlag.SCHEDULE_DAY}>
<Typography <Typography
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }} sx={{ fontSize: 10 }}
color={isDaySchedule ? 'primary' : 'grey'} color={scheduleType === ScheduleFlag.SCHEDULE_DAY ? '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: TYPOGRAPHY_FONT_SIZE }} sx={{ fontSize: 10 }}
color={isTimerSchedule ? 'primary' : 'grey'} color={
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: TYPOGRAPHY_FONT_SIZE }} sx={{ fontSize: 10 }}
color={ color={
scheduleType === ScheduleFlag.SCHEDULE_ONCHANGE ? 'primary' : 'grey' scheduleType === ScheduleFlag.SCHEDULE_ONCHANGE ? 'primary' : 'grey'
} }
@@ -238,7 +213,7 @@ const SchedulerDialog = ({
</ToggleButton> </ToggleButton>
<ToggleButton value={ScheduleFlag.SCHEDULE_CONDITION}> <ToggleButton value={ScheduleFlag.SCHEDULE_CONDITION}>
<Typography <Typography
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }} sx={{ fontSize: 10 }}
color={ color={
scheduleType === ScheduleFlag.SCHEDULE_CONDITION ? 'primary' : 'grey' scheduleType === ScheduleFlag.SCHEDULE_CONDITION ? 'primary' : 'grey'
} }
@@ -246,91 +221,132 @@ const SchedulerDialog = ({
{LL.CONDITION()} {LL.CONDITION()}
</Typography> </Typography>
</ToggleButton> </ToggleButton>
<ToggleButton value={ScheduleFlag.SCHEDULE_IMMEDIATE}>
<Typography
sx={{ fontSize: 10 }}
color={
scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE ? 'primary' : 'grey'
}
>
{LL.IMMEDIATE()}
</Typography>
</ToggleButton>
</ToggleButtonGroup> </ToggleButtonGroup>
{isDaySchedule && ( {scheduleType === ScheduleFlag.SCHEDULE_DAY && (
<ToggleButtonGroup <ToggleButtonGroup
size="small" size="small"
color="secondary" color="secondary"
value={dowFlags} value={getFlagDOWstring(editItem.flags)}
onChange={handleDOWChange} onChange={(_event, flag: string[]) => {
setEditItem({ ...editItem, flags: getFlagDOWnumber(flag) });
}}
> >
{DAY_FLAGS.map(({ value, flag }) => ( <ToggleButton value="2">
<ToggleButton key={value} value={value}> {showDOW(editItem, ScheduleFlag.SCHEDULE_MON)}
{DayOfWeekButton(flag)} </ToggleButton>
</ToggleButton> <ToggleButton value="4">
))} {showDOW(editItem, ScheduleFlag.SCHEDULE_TUE)}
</ToggleButton>
<ToggleButton value="8">
{showDOW(editItem, ScheduleFlag.SCHEDULE_WED)}
</ToggleButton>
<ToggleButton value="16">
{showDOW(editItem, ScheduleFlag.SCHEDULE_THU)}
</ToggleButton>
<ToggleButton value="32">
{showDOW(editItem, ScheduleFlag.SCHEDULE_FRI)}
</ToggleButton>
<ToggleButton value="64">
{showDOW(editItem, ScheduleFlag.SCHEDULE_SAT)}
</ToggleButton>
<ToggleButton value="1">
{showDOW(editItem, ScheduleFlag.SCHEDULE_SUN)}
</ToggleButton>
</ToggleButtonGroup> </ToggleButtonGroup>
)} )}
<Grid container> {scheduleType !== ScheduleFlag.SCHEDULE_IMMEDIATE && (
<BlockFormControlLabel <>
control={ <Grid container>
<Checkbox <BlockFormControlLabel
checked={editItem.active} control={
onChange={updateFormValue} <Checkbox
name="active" checked={editItem.active}
onChange={updateFormValue}
name="active"
/>
}
label={LL.ACTIVE()}
/> />
} </Grid>
label={LL.ACTIVE()} <Grid container>
/> {scheduleType === ScheduleFlag.SCHEDULE_DAY ||
<CircleIcon scheduleType === ScheduleFlag.SCHEDULE_TIMER ? (
color={editItem.active ? 'success' : 'error'} <>
sx={{ <TextField
fontSize: 16, name="time"
mt: '12px' type="time"
}} label={
/> scheduleType === ScheduleFlag.SCHEDULE_TIMER
</Grid> ? LL.TIMER(1)
: LL.TIME(1)
<Grid container> }
{needsTimeField ? ( value={editItem.time === '' ? '00:00' : editItem.time}
<> margin="normal"
<TextField onChange={updateFormValue}
name="time" />
type="time" {scheduleType === ScheduleFlag.SCHEDULE_TIMER && (
label={timeFieldLabel} <Box color="warning.main" ml={2} mt={4}>
value={timeFieldValue} <Typography variant="body2">
margin="normal" {LL.SCHEDULER_HELP_2()}
onChange={updateFormValue} </Typography>
/> </Box>
{isTimerSchedule && ( )}
<Typography sx={{ ml: 2, mt: 4 }} color="warning" variant="body2"> </>
00:00 = {LL.SCHEDULER_HELP_2()} ) : (
</Typography> <TextField
name="time"
label={
scheduleType === ScheduleFlag.SCHEDULE_CONDITION
? LL.CONDITION()
: scheduleType === ScheduleFlag.SCHEDULE_ONCHANGE
? LL.ONCHANGE()
: LL.IMMEDIATE()
}
multiline
fullWidth
value={editItem.time === '00:00' ? '' : editItem.time}
margin="normal"
onChange={updateFormValue}
/>
)} )}
</> </Grid>
) : ( </>
<TextField )}
name="time" <ValidatedTextField
label={timeFieldLabel} fieldErrors={fieldErrors}
multiline name="cmd"
fullWidth
value={timeFieldValue}
margin="normal"
onChange={updateFormValue}
/>
)}
</Grid>
<TextField
name="cmd_name"
label={LL.COMMAND(0)} label={LL.COMMAND(0)}
value={editItem.cmd_name} multiline
fullWidth fullWidth
select value={editItem.cmd}
margin="normal" margin="normal"
onChange={updateFormValue} onChange={updateFormValue}
> />
{commandNames.map((name) => ( <TextField
<MenuItem key={name} value={name}> name="value"
{name} label={LL.VALUE(0)}
</MenuItem> multiline
))} margin="normal"
</TextField> fullWidth
value={editItem.value}
onChange={updateFormValue}
/>
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors || {}} fieldErrors={fieldErrors}
name="name" name="name"
label={LL.NAME(0)} label={LL.NAME(0) + ' (' + LL.OPTIONAL() + ')'}
value={editItem.name} value={editItem.name}
fullWidth fullWidth
margin="normal" margin="normal"
@@ -340,7 +356,7 @@ const SchedulerDialog = ({
<DialogActions> <DialogActions>
{!creating && ( {!creating && (
<Box sx={{ flexGrow: 1 }}> <Box flexGrow={1}>
<Button <Button
startIcon={<RemoveIcon />} startIcon={<RemoveIcon />}
variant="outlined" variant="outlined"
@@ -367,6 +383,16 @@ const SchedulerDialog = ({
> >
{creating ? LL.ADD(0) : LL.UPDATE()} {creating ? LL.ADD(0) : LL.UPDATE()}
</Button> </Button>
{scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE && editItem.cmd !== '' && (
<Button
startIcon={<PlayArrowIcon />}
variant="outlined"
onClick={saveandactivate}
color="success"
>
{LL.EXECUTE()}
</Button>
)}
</DialogActions> </DialogActions>
</Dialog> </Dialog>
); );
+167 -232
View File
@@ -1,4 +1,5 @@
import { useContext, useRef, useState } from 'react'; import { useContext, useState } from 'react';
import { toast } from 'react-toastify';
import AddCircleOutlineOutlinedIcon from '@mui/icons-material/AddCircleOutlineOutlined'; import AddCircleOutlineOutlinedIcon from '@mui/icons-material/AddCircleOutlineOutlined';
import KeyboardArrowDownOutlinedIcon from '@mui/icons-material/KeyboardArrowDownOutlined'; import KeyboardArrowDownOutlinedIcon from '@mui/icons-material/KeyboardArrowDownOutlined';
@@ -20,7 +21,6 @@ import { useTheme } from '@table-library/react-table-library/theme';
import type { State } from '@table-library/react-table-library/types/common'; import type { State } from '@table-library/react-table-library/types/common';
import { useRequest } from 'alova/client'; import { useRequest } from 'alova/client';
import { SectionContent, useLayoutTitle } from 'components'; import { SectionContent, useLayoutTitle } from 'components';
import { toast } from 'components/toast';
import { AuthenticatedContext } from 'contexts/authentication'; import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import { useInterval } from 'utils'; import { useInterval } from 'utils';
@@ -49,74 +49,6 @@ 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);
@@ -127,22 +59,18 @@ 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(readSensorData, { const { data: sensorData, send: fetchSensorData } = useRequest(
initialData: { () => readSensorData(),
ts: [], {
as: [], initialData: {
analog_enabled: false, ts: [],
available_gpios: [] as number[], as: [],
platform: 'ESP32' analog_enabled: false,
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),
@@ -162,10 +90,112 @@ const Sensors = () => {
if (!temperatureDialogOpen && !analogDialogOpen) { if (!temperatureDialogOpen && !analogDialogOpen) {
void fetchSensorData(); void fetchSensorData();
} }
}, 3000);
const common_theme = useTheme({
BaseRow: `
font-size: 14px;
.td {
height: 32px;
}
`,
HeaderRow: `
text-transform: uppercase;
background-color: black;
color: #90CAF9;
.th {
border-bottom: 1px solid #565656;
}
.th {
height: 36px;
}
`,
Row: `
background-color: #1e1e1e;
position: relative;
cursor: pointer;
.td {
padding: 8px;
border-bottom: 1px solid #565656;
}
&:hover .td {
background-color: #177ac9;
}
`,
Cell: `
&:last-of-type {
text-align: right;
},
`
}); });
const temperature_theme = useTheme([common_theme, temperature_theme_config]); const temperature_theme = useTheme([
const analog_theme = useTheme([common_theme, analog_theme_config]); common_theme,
{
Table: `
--data-table-library_grid-template-columns: minmax(0, 1fr) 35%;
`
}
]);
const analog_theme = useTheme([
common_theme,
{
Table: `
--data-table-library_grid-template-columns: 80px repeat(1, minmax(0, 1fr)) 120px 110px;
`
}
]);
const RenderTemperatureSensors = () => (
<Table
data={{ nodes: sensorData.ts }}
theme={temperature_theme}
sort={temperature_sort}
layout={{ custom: true }}
>
{(tableList: TemperatureSensor[]) => (
<>
<Header>
<HeaderRow>
<HeaderCell resize>
<Button
fullWidth
style={{ fontSize: '14px', justifyContent: 'flex-start' }}
endIcon={getSortIcon(temperature_sort.state, 'NAME')}
onClick={() =>
temperature_sort.fns.onToggleSort({ sortKey: 'NAME' })
}
>
{LL.NAME(0)}
</Button>
</HeaderCell>
<HeaderCell stiff>
<Button
fullWidth
style={{ fontSize: '14px', justifyContent: 'flex-end' }}
endIcon={getSortIcon(temperature_sort.state, 'VALUE')}
onClick={() =>
temperature_sort.fns.onToggleSort({ sortKey: 'VALUE' })
}
>
{LL.VALUE(0)}
</Button>
</HeaderCell>
</HeaderRow>
</Header>
<Body>
{tableList.map((ts: TemperatureSensor) => (
<Row key={ts.id} item={ts} onClick={() => updateTemperatureSensor(ts)}>
<Cell>{ts.n}</Cell>
<Cell>{formatValue(ts.t, ts.u)}</Cell>
</Row>
))}
</Body>
</>
)}
</Table>
);
const getSortIcon = (state: State, sortKey: unknown) => { const getSortIcon = (state: State, sortKey: unknown) => {
if (state.sortKey === sortKey && state.reverse) { if (state.sortKey === sortKey && state.reverse) {
@@ -188,20 +218,11 @@ const Sensors = () => {
}, },
sortToggleType: SortToggleType.AlternateWithReset, sortToggleType: SortToggleType.AlternateWithReset,
sortFns: { sortFns: {
GPIO: (array) => GPIO: (array) => array.sort((a, b) => a.g - b.g),
[...array].sort( // eslint-disable-next-line @typescript-eslint/no-unsafe-return
(a, b) => ((a as AnalogSensor)?.g ?? 0) - ((b as AnalogSensor)?.g ?? 0) NAME: (array) => array.sort((a, b) => a.n.localeCompare(b.n)),
), TYPE: (array) => array.sort((a, b) => a.t - b.t),
NAME: (array) => VALUE: (array) => array.sort((a, b) => a.v - b.v)
[...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)
} }
} }
); );
@@ -217,15 +238,9 @@ const Sensors = () => {
}, },
sortToggleType: SortToggleType.AlternateWithReset, sortToggleType: SortToggleType.AlternateWithReset,
sortFns: { sortFns: {
NAME: (array) => // eslint-disable-next-line @typescript-eslint/no-unsafe-return
[...array].sort((a, b) => NAME: (array) => array.sort((a, b) => a.n.localeCompare(b.n)),
(a as TemperatureSensor).n.localeCompare((b as TemperatureSensor).n) VALUE: (array) => array.sort((a, b) => a.t - b.t)
),
VALUE: (array) =>
[...array].sort(
(a, b) =>
((a as TemperatureSensor).t ?? 0) - ((b as TemperatureSensor).t ?? 0)
)
} }
} }
); );
@@ -233,25 +248,24 @@ const Sensors = () => {
useLayoutTitle(LL.SENSORS()); useLayoutTitle(LL.SENSORS());
const formatDurationMin = (duration_min: number) => { const formatDurationMin = (duration_min: number) => {
const totalMs = duration_min * MS_PER_MINUTE; const days = Math.trunc((duration_min * 60000) / 86400000);
const days = Math.trunc(totalMs / MS_PER_DAY); const hours = Math.trunc((duration_min * 60000) / 3600000) % 24;
const hours = Math.trunc(totalMs / MS_PER_HOUR) % 24; const minutes = Math.trunc((duration_min * 60000) / 60000) % 60;
const minutes = Math.trunc(totalMs / MS_PER_MINUTE) % 60;
const parts: string[] = []; let formatted = '';
if (days > 0) { if (days) {
parts.push(LL.NUM_DAYS({ num: days })); formatted += LL.NUM_DAYS({ num: days }) + ' ';
} }
if (hours > 0) { if (hours) {
parts.push(LL.NUM_HOURS({ num: hours })); formatted += LL.NUM_HOURS({ num: hours }) + ' ';
} }
if (minutes > 0) { if (minutes) {
parts.push(LL.NUM_MINUTES({ num: minutes })); formatted += LL.NUM_MINUTES({ num: minutes });
} }
return parts.join(' '); return formatted;
}; };
const formatValue = (value: unknown, uom: DeviceValueUOM) => { function formatValue(value: unknown, uom: DeviceValueUOM) {
if (value === undefined) { if (value === undefined) {
return ''; return '';
} }
@@ -280,7 +294,7 @@ const Sensors = () => {
default: default:
return new Intl.NumberFormat().format(value) + ' ' + DeviceValueUOM_s[uom]; return new Intl.NumberFormat().format(value) + ' ' + DeviceValueUOM_s[uom];
} }
}; }
const updateTemperatureSensor = (ts: TemperatureSensor) => { const updateTemperatureSensor = (ts: TemperatureSensor) => {
if (me.admin) { if (me.admin) {
@@ -296,12 +310,7 @@ const Sensors = () => {
}; };
const onTemperatureDialogSave = async (ts: TemperatureSensor) => { const onTemperatureDialogSave = async (ts: TemperatureSensor) => {
await sendTemperatureSensor({ await sendTemperatureSensor({ id: ts.id, name: ts.n, offset: ts.o })
id: ts.id,
name: ts.n,
offset: ts.o,
is_system: ts.s
})
.then(() => { .then(() => {
toast.success(LL.UPDATED_OF(LL.SENSOR(1))); toast.success(LL.UPDATED_OF(LL.SENSOR(1)));
}) })
@@ -330,22 +339,17 @@ const Sensors = () => {
}; };
const addAnalogSensor = () => { const addAnalogSensor = () => {
if (firstAvailableGPIO.current === undefined) {
toast.error(LL.NO_GPIO());
return;
}
setCreating(true); setCreating(true);
setSelectedAnalogSensor({ setSelectedAnalogSensor({
id: Math.floor(Math.random() * (MAX_TEMP_ID - MIN_TEMP_ID) + MIN_TEMP_ID), id: Math.floor(Math.random() * (Math.floor(200) - 100) + 100),
n: '', n: '',
g: firstAvailableGPIO.current, g: 21, // default GPIO 21 which is safe for all platforms
u: DeviceValueUOM.NONE, u: 0,
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);
@@ -360,8 +364,7 @@ const Sensors = () => {
factor: as.f, factor: as.f,
uom: as.u, uom: as.u,
type: as.t, type: as.t,
deleted: as.d, deleted: as.d
is_system: as.s
}) })
.then(() => { .then(() => {
toast.success(LL.UPDATED_OF(LL.ANALOG_SENSOR(2))); toast.success(LL.UPDATED_OF(LL.ANALOG_SENSOR(2)));
@@ -376,7 +379,7 @@ const Sensors = () => {
}); });
}; };
const RenderAnalogSensors = ( const RenderAnalogSensors = () => (
<Table <Table
data={{ nodes: sensorData.as }} data={{ nodes: sensorData.as }}
theme={analog_theme} theme={analog_theme}
@@ -390,7 +393,7 @@ const Sensors = () => {
<HeaderCell stiff> <HeaderCell stiff>
<Button <Button
fullWidth fullWidth
style={HEADER_BUTTON_STYLE} style={{ fontSize: '14px', justifyContent: 'flex-start' }}
endIcon={getSortIcon(analog_sort.state, 'GPIO')} endIcon={getSortIcon(analog_sort.state, 'GPIO')}
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'GPIO' })} onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'GPIO' })}
> >
@@ -400,7 +403,7 @@ const Sensors = () => {
<HeaderCell resize> <HeaderCell resize>
<Button <Button
fullWidth fullWidth
style={HEADER_BUTTON_STYLE} style={{ fontSize: '14px', justifyContent: 'flex-start' }}
endIcon={getSortIcon(analog_sort.state, 'NAME')} endIcon={getSortIcon(analog_sort.state, 'NAME')}
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'NAME' })} onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'NAME' })}
> >
@@ -410,7 +413,7 @@ const Sensors = () => {
<HeaderCell stiff> <HeaderCell stiff>
<Button <Button
fullWidth fullWidth
style={HEADER_BUTTON_STYLE} style={{ fontSize: '14px', justifyContent: 'flex-start' }}
endIcon={getSortIcon(analog_sort.state, 'TYPE')} endIcon={getSortIcon(analog_sort.state, 'TYPE')}
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'TYPE' })} onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'TYPE' })}
> >
@@ -420,7 +423,7 @@ const Sensors = () => {
<HeaderCell stiff> <HeaderCell stiff>
<Button <Button
fullWidth fullWidth
style={HEADER_BUTTON_STYLE_END} style={{ fontSize: '14px', justifyContent: 'flex-end' }}
endIcon={getSortIcon(analog_sort.state, 'VALUE')} endIcon={getSortIcon(analog_sort.state, 'VALUE')}
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'VALUE' })} onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'VALUE' })}
> >
@@ -430,24 +433,15 @@ const Sensors = () => {
</HeaderRow> </HeaderRow>
</Header> </Header>
<Body> <Body>
{tableList.map((as: AnalogSensor) => ( {tableList.map((a: AnalogSensor) => (
<Row <Row key={a.id} item={a} onClick={() => updateAnalogSensor(a)}>
style={{ color: as.s ? 'grey' : 'inherit' }} <Cell stiff>{a.g}</Cell>
key={as.id} <Cell>{a.n}</Cell>
item={as} <Cell stiff>{AnalogTypeNames[a.t]} </Cell>
onClick={() => updateAnalogSensor(as)} {a.t === AnalogType.DIGITAL_OUT || a.t === AnalogType.DIGITAL_IN ? (
> <Cell stiff>{a.v ? LL.ON() : LL.OFF()}</Cell>
<Cell stiff>{as.g}</Cell>
<Cell>{as.n}</Cell>
<Cell stiff>{AnalogTypeNames[as.t - 1]} </Cell>
{(as.t === AnalogType.DIGITAL_OUT &&
as.g !== GPIO_25 &&
as.g !== GPIO_26) ||
as.t === AnalogType.DIGITAL_IN ||
as.t === AnalogType.PULSE ? (
<Cell stiff>{as.v ? LL.ON() : LL.OFF()}</Cell>
) : ( ) : (
<Cell stiff>{formatValue(as.v, as.u)}</Cell> <Cell stiff>{a.t ? formatValue(a.v, a.u) : ''}</Cell>
)} )}
</Row> </Row>
))} ))}
@@ -457,67 +451,12 @@ const Sensors = () => {
</Table> </Table>
); );
const RenderTemperatureSensors = (
<Table
data={{ nodes: sensorData.ts }}
theme={temperature_theme}
sort={temperature_sort}
layout={{ custom: true }}
>
{(tableList: TemperatureSensor[]) => (
<>
<Header>
<HeaderRow>
<HeaderCell resize>
<Button
fullWidth
style={HEADER_BUTTON_STYLE}
endIcon={getSortIcon(temperature_sort.state, 'NAME')}
onClick={() =>
temperature_sort.fns.onToggleSort({ sortKey: 'NAME' })
}
>
{LL.NAME(0)}
</Button>
</HeaderCell>
<HeaderCell stiff>
<Button
fullWidth
style={HEADER_BUTTON_STYLE_END}
endIcon={getSortIcon(temperature_sort.state, 'VALUE')}
onClick={() =>
temperature_sort.fns.onToggleSort({ sortKey: 'VALUE' })
}
>
{LL.VALUE(0)}
</Button>
</HeaderCell>
</HeaderRow>
</Header>
<Body>
{tableList.map((ts: TemperatureSensor) => (
<Row
style={{ color: ts.s ? 'grey' : 'inherit' }}
key={ts.id}
item={ts}
onClick={() => updateTemperatureSensor(ts)}
>
<Cell>{ts.n}</Cell>
<Cell>{formatValue(ts.t, ts.u)}</Cell>
</Row>
))}
</Body>
</>
)}
</Table>
);
return ( return (
<SectionContent> <SectionContent>
<Typography sx={{ pb: 1 }} variant="h6" color="primary"> <Typography sx={{ pb: 1 }} variant="h6" color="secondary">
{LL.TEMP_SENSORS()} {LL.TEMP_SENSORS()}
</Typography> </Typography>
{RenderTemperatureSensors} <RenderTemperatureSensors />
{selectedTemperatureSensor && ( {selectedTemperatureSensor && (
<DashboardSensorsTemperatureDialog <DashboardSensorsTemperatureDialog
open={temperatureDialogOpen} open={temperatureDialogOpen}
@@ -530,10 +469,10 @@ const Sensors = () => {
)} )}
/> />
)} )}
<Typography sx={{ pt: 4, pb: 1 }} variant="h6" color="primary"> <Typography sx={{ pt: 4, pb: 1 }} variant="h6" color="secondary">
{LL.ANALOG_SENSORS()} {LL.ANALOG_SENSORS()}
</Typography> </Typography>
{RenderAnalogSensors} <RenderAnalogSensors />
{selectedAnalogSensor && ( {selectedAnalogSensor && (
<DashboardSensorsAnalogDialog <DashboardSensorsAnalogDialog
open={analogDialogOpen} open={analogDialogOpen}
@@ -541,20 +480,16 @@ const Sensors = () => {
onSave={onAnalogDialogSave} onSave={onAnalogDialogSave}
creating={creating} creating={creating}
selectedItem={selectedAnalogSensor} selectedItem={selectedAnalogSensor}
analogGPIOList={sensorData.available_gpios} validator={analogSensorItemValidation(
disabledTypeList={sensorData.exclude_types} sensorData.as,
validator={analogSensorItemValidation(sensorData.as, selectedAnalogSensor)} selectedAnalogSensor,
creating,
sensorData.platform
)}
/> />
)} )}
{sensorData?.analog_enabled === true && me.admin && ( {sensorData?.analog_enabled === true && me.admin && (
<Box <Box mt={1} display="flex" flexWrap="wrap" justifyContent="flex-end">
sx={{
mt: 2,
display: 'flex',
flexWrap: 'wrap',
justifyContent: 'flex-end'
}}
>
<Button <Button
variant="outlined" variant="outlined"
color="primary" color="primary"
+130 -307
View File
@@ -1,8 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, 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/RemoveCircleOutlined';
import WarningIcon from '@mui/icons-material/Warning'; import WarningIcon from '@mui/icons-material/Warning';
import { import {
Box, Box,
@@ -11,19 +10,20 @@ import {
DialogActions, DialogActions,
DialogContent, DialogContent,
DialogTitle, DialogTitle,
Grid,
InputAdornment, InputAdornment,
MenuItem, MenuItem,
TextField,
Typography Typography
} from '@mui/material'; } from '@mui/material';
import Grid from '@mui/material/Grid2';
import { dialogStyle } from 'CustomTheme'; import { dialogStyle } from 'CustomTheme';
import type Schema from 'async-validator';
import type { ValidateFieldsError } from 'async-validator';
import { ValidatedTextField } from 'components'; import { ValidatedTextField } from 'components';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import { numberValue, updateValue } from 'utils'; import { numberValue, updateValue } from 'utils';
import { ValidationError, validate } from 'validators'; import { validate } from 'validators';
import type Schema from 'validators/schema';
import type { ValidateFieldsError } from 'validators/schema';
import { AnalogType, AnalogTypeNames, DeviceValueUOM_s } from './types'; import { AnalogType, AnalogTypeNames, DeviceValueUOM_s } from './types';
import type { AnalogSensor } from './types'; import type { AnalogSensor } from './types';
@@ -34,8 +34,6 @@ 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;
} }
@@ -45,81 +43,13 @@ 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 = updateValue((updater) =>
setEditItem(
(prev) =>
updater(
prev as unknown as Record<string, unknown>
) as unknown as AnalogSensor
)
);
const isCounterOrRate =
editItem.t === AnalogType.COUNTER ||
editItem.t === AnalogType.RATE ||
(editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2);
const isCounter =
editItem.t === AnalogType.COUNTER ||
(editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2);
const isFreqType =
editItem.t >= AnalogType.FREQ_0 && editItem.t <= AnalogType.FREQ_2;
const isPWM =
editItem.t === AnalogType.PWM_0 ||
editItem.t === AnalogType.PWM_1 ||
editItem.t === AnalogType.PWM_2;
const isDACOutGPIO =
editItem.t === AnalogType.DIGITAL_OUT &&
(editItem.g === 25 || editItem.g === 26);
const isDigitalOutGPIO =
editItem.t === AnalogType.DIGITAL_OUT && editItem.g !== 25 && editItem.g !== 26;
const analogTypeMenuItems = AnalogTypeNames.map((val, i) => ({
name: val,
value: i + 1
}))
.sort((a, b) => a.name.localeCompare(b.name))
.map(({ name, value }) => (
<MenuItem
key={name}
value={value}
disabled={disabledTypeList?.includes(value)}
>
{name}
</MenuItem>
));
const uomMenuItems = 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);
@@ -127,10 +57,7 @@ const SensorsAnalogDialog = ({
} }
}, [open, selectedItem]); }, [open, selectedItem]);
const handleClose = ( const handleClose = (_event, reason: 'backdropClick' | 'escapeKeyDown') => {
_event: React.SyntheticEvent,
reason: 'backdropClick' | 'escapeKeyDown'
) => {
if (reason !== 'backdropClick') { if (reason !== 'backdropClick') {
onClose(); onClose();
} }
@@ -142,99 +69,97 @@ const SensorsAnalogDialog = ({
await validate(validator, editItem); await validate(validator, editItem);
onSave(editItem); onSave(editItem);
} catch (error) { } catch (error) {
setFieldErrors((error as ValidationError).fieldErrors); setFieldErrors(error as ValidateFieldsError);
} }
}; };
const remove = () => { const remove = () => {
onSave({ ...editItem, d: true }); editItem.d = true;
onSave(editItem);
}; };
const dialogTitle = `${creating ? LL.ADD(1) + ' ' + LL.NEW(0) : LL.EDIT()} ${LL.ANALOG_SENSOR(0)}`;
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>
<ValidatedTextField <TextField
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}
> >
{analogTypeMenuItems} {AnalogTypeNames.map((val, i) => (
</ValidatedTextField> <MenuItem key={val} value={i}>
{val}
</MenuItem>
))}
</TextField>
</Grid> </Grid>
{(isCounterOrRate || {editItem.t >= AnalogType.COUNTER && editItem.t <= AnalogType.RATE && (
isFreqType ||
editItem.t === AnalogType.ADC ||
editItem.t === AnalogType.TIMER) && (
<Grid> <Grid>
<ValidatedTextField <TextField
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}
> >
{uomMenuItems} {DeviceValueUOM_s.map((val, i) => (
</ValidatedTextField> <MenuItem key={val} value={i}>
</Grid> {val}
)} </MenuItem>
{editItem.t === AnalogType.DIGITAL_IN && ( ))}
<Grid> </TextField>
<ValidatedTextField
name="f"
label={LL.POLARITY()}
value={editItem.f}
sx={{ width: '15ch' }}
fullWidth
select
onChange={updateFormValue}
disabled={editItem.s}
>
<MenuItem value={1}>{LL.ACTIVEHIGH()}</MenuItem>
<MenuItem value={0}>{LL.ACTIVELOW()}</MenuItem>
</ValidatedTextField>
</Grid> </Grid>
)} )}
{editItem.t === AnalogType.ADC && ( {editItem.t === AnalogType.ADC && (
<Grid> <Grid>
<ValidatedTextField <TextField
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: (
@@ -246,152 +171,118 @@ const SensorsAnalogDialog = ({
/> />
</Grid> </Grid>
)} )}
{editItem.t === AnalogType.NTC && ( {editItem.t === AnalogType.COUNTER && (
<Grid> <Grid>
<ValidatedTextField <TextField
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.RGB && ( {editItem.t >= AnalogType.COUNTER && editItem.t <= AnalogType.RATE && (
<Grid> <Grid>
<ValidatedTextField <TextField
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: '14ch' }} sx={{ width: '11ch' }}
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>
)} )}
{isDACOutGPIO && ( {editItem.t === AnalogType.DIGITAL_OUT &&
<Grid> (editItem.g === 25 || editItem.g === 26) && (
<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>
<ValidatedTextField <TextField
name="o" name="o"
label={LL.VALUE(0)} label={LL.VALUE(0)}
value={numberValue(editItem.o)} value={numberValue(editItem.o)}
select sx={{ width: '11ch' }}
type="number"
variant="outlined"
onChange={updateFormValue} onChange={updateFormValue}
disabled={editItem.s} slotProps={{
> htmlInput: { min: '0', max: '255', step: '1' }
<MenuItem value={0}>{LL.OFF()}</MenuItem> }}
<MenuItem value={1}>{LL.ON()}</MenuItem> />
</ValidatedTextField>
</Grid> </Grid>
<Grid> )}
<ValidatedTextField {editItem.t === AnalogType.DIGITAL_OUT &&
name="f" editItem.g !== 25 &&
label={LL.POLARITY()} editItem.g !== 26 && (
value={editItem.f} <>
sx={{ width: '15ch' }} <Grid>
select <TextField
onChange={updateFormValue} name="o"
disabled={editItem.s} label={LL.VALUE(0)}
> value={numberValue(editItem.o)}
<MenuItem value={1}>{LL.ACTIVEHIGH()}</MenuItem> select
<MenuItem value={-1}>{LL.ACTIVELOW()}</MenuItem> variant="outlined"
</ValidatedTextField> onChange={updateFormValue}
</Grid> >
<Grid> <MenuItem value={0}>{LL.OFF()}</MenuItem>
<ValidatedTextField <MenuItem value={1}>{LL.ON()}</MenuItem>
name="u" </TextField>
label={LL.STARTVALUE()} </Grid>
sx={{ width: '15ch' }} <Grid>
value={editItem.u} <TextField
select name="f"
onChange={updateFormValue} label={LL.POLARITY()}
disabled={editItem.s} value={editItem.f}
> sx={{ width: '15ch' }}
<MenuItem value={0}>{LL.UNCHANGED()}</MenuItem> select
<MenuItem value={1}> onChange={updateFormValue}
{LL.ALWAYS()}&nbsp;{LL.OFF()} >
</MenuItem> <MenuItem value={1}>{LL.ACTIVEHIGH()}</MenuItem>
<MenuItem value={2}> <MenuItem value={-1}>{LL.ACTIVELOW()}</MenuItem>
{LL.ALWAYS()}&nbsp;{LL.ON()} </TextField>
</MenuItem> </Grid>
</ValidatedTextField> <Grid>
</Grid> <TextField
</> name="u"
)} label={LL.STARTVALUE()}
{isPWM && ( sx={{ width: '15ch' }}
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>
<ValidatedTextField <TextField
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: (
@@ -403,14 +294,14 @@ const SensorsAnalogDialog = ({
/> />
</Grid> </Grid>
<Grid> <Grid>
<ValidatedTextField <TextField
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: (
@@ -423,81 +314,13 @@ const SensorsAnalogDialog = ({
</Grid> </Grid>
</> </>
)} )}
{editItem.t === AnalogType.PULSE && (
<>
<Grid>
<ValidatedTextField
name="o"
label={LL.POLARITY()}
value={editItem.o}
sx={{ width: '15ch' }}
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 sx={{ mt: 1 }}>
{Object.values(fieldErrors).map((errArr, idx) =>
Array.isArray(errArr)
? errArr.map((err, j) => (
<Typography
key={`${idx}-${j}`}
color="error"
variant="caption"
sx={{ display: 'block' }}
>
{err.message}
</Typography>
))
: null
)}
</Box>
)}
{editItem.s && (
<Grid>
<Typography sx={{ mt: 1 }} color="warning" 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 sx={{ flexGrow: 1, '& 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}
@@ -515,7 +338,7 @@ const SensorsAnalogDialog = ({
{LL.CANCEL()} {LL.CANCEL()}
</Button> </Button>
<Button <Button
startIcon={<DoneIcon />} startIcon={<WarningIcon color="warning" />}
variant="outlined" variant="outlined"
onClick={save} onClick={save}
color="primary" color="primary"
@@ -1,27 +1,27 @@
import { useEffect, useState } from 'react'; import { useEffect, 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,
Button, Button,
Dialog, Dialog,
DialogActions, DialogActions,
DialogContent, DialogContent,
DialogTitle, DialogTitle,
Grid,
InputAdornment, InputAdornment,
TextField, TextField,
Typography Typography
} from '@mui/material'; } from '@mui/material';
import Grid from '@mui/material/Grid2';
import { dialogStyle } from 'CustomTheme'; import { dialogStyle } from 'CustomTheme';
import type Schema from 'async-validator';
import type { ValidateFieldsError } from 'async-validator';
import { ValidatedTextField } from 'components'; import { ValidatedTextField } from 'components';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import { numberValue, updateValue } from 'utils'; import { numberValue, updateValue } from 'utils';
import { ValidationError, validate } from 'validators'; import { validate } from 'validators';
import type Schema from 'validators/schema';
import type { ValidateFieldsError } from 'validators/schema';
import type { TemperatureSensor } from './types'; import type { TemperatureSensor } from './types';
@@ -33,12 +33,6 @@ 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,
@@ -49,14 +43,7 @@ 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 = updateValue(
setEditItem as unknown as (
updater: (
prevState: Readonly<Record<string, unknown>>
) => Record<string, unknown>
) => void
);
useEffect(() => { useEffect(() => {
if (open) { if (open) {
@@ -65,7 +52,7 @@ const SensorsTemperatureDialog = ({
} }
}, [open, selectedItem]); }, [open, selectedItem]);
const handleClose = (_event: React.SyntheticEvent, reason?: string) => { const handleClose = (_event, reason: 'backdropClick' | 'escapeKeyDown') => {
if (reason !== 'backdropClick') { if (reason !== 'backdropClick') {
onClose(); onClose();
} }
@@ -77,21 +64,25 @@ const SensorsTemperatureDialog = ({
await validate(validator, editItem); await validate(validator, editItem);
onSave(editItem); onSave(editItem);
} catch (error) { } catch (error) {
setFieldErrors((error as ValidationError).fieldErrors); setFieldErrors(error as ValidateFieldsError);
} }
}; };
return ( return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}> <Dialog sx={dialogStyle} open={open} onClose={handleClose}>
<DialogTitle>{`${LL.EDIT()} ${LL.TEMP_SENSOR()}`}</DialogTitle> <DialogTitle>
{LL.EDIT()}&nbsp;{LL.TEMP_SENSOR()}
</DialogTitle>
<DialogContent dividers> <DialogContent dividers>
<Typography sx={{ mb: 2 }} color="warning" variant="body2"> <Box color="warning.main" p={0} pl={0} pr={0} mt={0} mb={2}>
{LL.ID_OF(LL.SENSOR(0))}: {editItem.id} <Typography variant="body2">
</Typography> {LL.ID_OF(LL.SENSOR(0))}: {editItem.id}
</Typography>
</Box>
<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}
@@ -111,30 +102,14 @@ const SensorsTemperatureDialog = ({
slotProps={{ slotProps={{
input: { input: {
startAdornment: ( startAdornment: (
<InputAdornment position="start">{TEMP_UNIT}</InputAdornment> <InputAdornment position="start">°C</InputAdornment>
) )
}, },
htmlInput: { htmlInput: { min: '-5', max: '5', step: '0.1' }
min: OFFSET_MIN,
max: OFFSET_MAX,
step: OFFSET_STEP
}
}} }}
/> />
</Grid> </Grid>
</Grid> </Grid>
{editItem.s && (
<Grid>
<Typography sx={{ mt: 1 }} color="warning" 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
@@ -146,7 +121,7 @@ const SensorsTemperatureDialog = ({
{LL.CANCEL()} {LL.CANCEL()}
</Button> </Button>
<Button <Button
startIcon={<DoneIcon />} startIcon={<WarningIcon color="warning" />}
variant="outlined" variant="outlined"
onClick={save} onClick={save}
color="primary" color="primary"
-69
View File
@@ -1,69 +0,0 @@
import { memo, 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 = () => {
signOut(true);
};
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 sx={{ mt: 2, mb: 2, display: 'flex', alignItems: 'center' }}>
<Typography
sx={{ mr: 2, textAlign: 'center' }}
color="warning"
variant="body1"
>
{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;
+26 -31
View File
@@ -2,30 +2,27 @@ 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 totalMs = duration_min * 60000; const days = Math.trunc((duration_min * 60000) / 86400000);
const days = Math.trunc(totalMs / 86400000); const hours = Math.trunc((duration_min * 60000) / 3600000) % 24;
const hours = Math.trunc(totalMs / 3600000) % 24; const minutes = Math.trunc((duration_min * 60000) / 60000) % 60;
const minutes = Math.trunc(duration_min) % 60;
const parts: string[] = []; let formatted = '';
if (days) { if (days) {
parts.push(LL.NUM_DAYS({ num: days })); formatted += LL.NUM_DAYS({ num: days });
}
if (hours) {
parts.push(LL.NUM_HOURS({ num: hours }));
}
if (minutes) {
parts.push(LL.NUM_MINUTES({ num: minutes }));
} }
return parts.join(' '); if (hours) {
if (formatted) formatted += ' ';
formatted += LL.NUM_HOURS({ num: hours });
}
if (minutes) {
if (formatted) formatted += ' ';
formatted += LL.NUM_MINUTES({ num: minutes });
}
return formatted;
}; };
export function formatValue( export function formatValue(
@@ -33,21 +30,13 @@ 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 value as string;
return (
(value as string) +
(value === '' || uom === undefined || uom === DeviceValueUOM.NONE
? ''
: ' ' + 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 });
@@ -56,12 +45,18 @@ 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 numberFormatter.format(value); return new Intl.NumberFormat().format(value);
case DeviceValueUOM.DEGREES: case DeviceValueUOM.DEGREES:
case DeviceValueUOM.DEGREES_R: case DeviceValueUOM.DEGREES_R:
case DeviceValueUOM.FAHRENHEIT: case DeviceValueUOM.FAHRENHEIT:
return numberFormatterWithDecimal.format(value) + ' ' + DeviceValueUOM_s[uom]; return (
new Intl.NumberFormat(undefined, {
minimumFractionDigits: 1
}).format(value) +
' ' +
DeviceValueUOM_s[uom]
);
default: default:
return numberFormatter.format(value) + ' ' + DeviceValueUOM_s[uom]; return new Intl.NumberFormat().format(value) + ' ' + DeviceValueUOM_s[uom];
} }
} }
+47 -124
View File
@@ -1,5 +1,4 @@
export interface Settings { export interface Settings {
system_name: string;
locale: string; locale: string;
tx_mode: number; tx_mode: number;
ems_bus_id: number; ems_bus_id: number;
@@ -22,7 +21,6 @@ export interface Settings {
dallas_gpio: number; dallas_gpio: number;
dallas_parasite: boolean; dallas_parasite: boolean;
led_gpio: number; led_gpio: number;
led_type: number;
hide_led: boolean; hide_led: boolean;
low_clock: boolean; low_clock: boolean;
notoken_api: boolean; notoken_api: boolean;
@@ -44,17 +42,7 @@ export interface Settings {
modbus_port: number; modbus_port: number;
modbus_max_clients: number; modbus_max_clients: number;
modbus_timeout: number; modbus_timeout: number;
email_enabled: boolean;
email_security: number;
email_server: string;
email_port: number;
email_login: string;
email_pass: string;
email_sender: string;
email_recp: string;
email_subject: string;
developer_mode: boolean; developer_mode: boolean;
disable_reset: boolean;
} }
export enum busConnectionStatus { export enum busConnectionStatus {
@@ -71,7 +59,7 @@ export interface Stat {
} }
export interface Activity { export interface Activity {
readonly stats: readonly Stat[]; stats: Stat[];
} }
export interface Device { export interface Device {
@@ -83,7 +71,7 @@ export interface Device {
d: number; // deviceid d: number; // deviceid
p: number; // productid p: number; // productid
v: string; // version v: string; // version
e: number; // total number of entities e: number; // entities
url?: string; // lowercase type name used in API URL url?: string; // lowercase type name used in API URL
} }
@@ -93,43 +81,38 @@ 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; // name n: string;
v: number; // value v: number;
u: number; // uom u: number;
o: number; // offset o: number;
f: number; // factor f: number;
t: number; // type t: number;
d: boolean; // deleted flag d: boolean; // deleted flag
s: boolean; // system sensor flag o_n?: string;
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 {
readonly connected: boolean; connected: boolean;
readonly devices: readonly Device[]; devices: Device[];
} }
export interface DashboardItem { export interface DashboardItem {
@@ -138,12 +121,6 @@ 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 {
readonly connected: boolean; // true if connected to EMS bus
readonly nodes: readonly DashboardItem[];
} }
export interface DeviceValue { export interface DeviceValue {
@@ -156,11 +133,10 @@ 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 {
readonly nodes: readonly DeviceValue[]; nodes: DeviceValue[];
} }
export interface DeviceEntity { export interface DeviceEntity {
@@ -205,27 +181,13 @@ export enum DeviceValueUOM {
K, K,
VOLTS, VOLTS,
MBAR, MBAR,
LH, LH
CTKWH,
HERTZ
}
export type DeviceValueKind =
'value' | 'scheduler' | 'command' | 'deviceCommand' | 'deviceEntity';
export function getDeviceValueKind(item: DeviceValue): DeviceValueKind {
if (!item.c) return 'value';
if (item.v !== undefined && item.l !== undefined && item.u === undefined)
return 'scheduler';
if (item.v === undefined && item.l === undefined) return 'command';
if (item.u === DeviceValueUOM.NONE) return 'deviceCommand';
return 'deviceEntity';
} }
export const DeviceValueUOM_s = [ export const DeviceValueUOM_s = [
'', '',
'°C', '°C',
'°C Rel', '°C',
'%', '%',
'l/min', 'l/min',
'kWh', 'kWh',
@@ -248,13 +210,12 @@ export const DeviceValueUOM_s = [
'K', 'K',
'V', 'V',
'mbar', 'mbar',
'l/h', 'l/h'
'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,
@@ -263,62 +224,42 @@ 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 = [
'Digital In', // 1 '(disabled)',
'Counter', // 2 'Digital In',
'ADC In', // 3 'Counter',
'Timer', // 4 'ADC',
'Rate', // 5 'Timer',
'Digital Out', // 6 'Rate',
'PWM 0', // 7 'Digital Out',
'PWM 1', // 8 'PWM 0',
'PWM 2', // 9 'PWM 1',
'NTC Temp', // 10 'PWM 2'
'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;
export const BOARD_PROFILES = { type BoardProfiles = Record<string, string>;
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',
OLIMEX: 'Olimex ESP32-EVB', OLIMEX: 'Olimex ESP32-EVB',
OLIMEXPOE: 'Olimex ESP32-POE', OLIMEXPOE: 'Olimex ESP32-POE',
OLIMEXPOEW: 'Olimex ESP32-POE-ISO WROVER (PSRAM)',
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;
led_gpio: number; led_gpio: number;
led_type: number;
dallas_gpio: number; dallas_gpio: number;
rx_gpio: number; rx_gpio: number;
tx_gpio: number; tx_gpio: number;
@@ -350,7 +291,6 @@ export interface WriteAnalogSensor {
uom: number; uom: number;
type: number; type: number;
deleted: boolean; deleted: boolean;
is_system: boolean;
} }
export enum DeviceEntityMask { export enum DeviceEntityMask {
@@ -363,41 +303,26 @@ export enum DeviceEntityMask {
} }
export interface ScheduleItem { export interface ScheduleItem {
id: number; // unique index for table id: number; // unique index
active: boolean; active: boolean;
deleted?: boolean; deleted?: boolean;
flags: number; flags: number;
time: string; // also used for Condition and On Change time: string; // also used for Condition and On Change
cmd_name: string; // references a named Command cmd: string;
name: string; value: string;
name: string; // can be empty
o_id?: number; o_id?: number;
o_active?: boolean; o_active?: boolean;
o_deleted?: boolean; o_deleted?: boolean;
o_flags?: number; o_flags?: number;
o_time?: string; o_time?: string;
o_cmd_name?: string; o_cmd?: string;
o_value?: string;
o_name?: string; o_name?: string;
} }
export interface Schedule { export interface Schedule {
readonly schedule: readonly ScheduleItem[]; schedule: ScheduleItem[];
}
export interface CommandItem {
id: number;
cmd: string;
value: string;
name: string;
deleted?: boolean;
o_id?: number;
o_cmd?: string;
o_value?: string;
o_name?: string;
o_deleted?: boolean;
}
export interface Commands {
readonly commands: readonly CommandItem[];
} }
export interface ModuleItem { export interface ModuleItem {
@@ -415,7 +340,7 @@ export interface ModuleItem {
} }
export interface Modules { export interface Modules {
readonly modules: readonly ModuleItem[]; modules: ModuleItem[];
} }
export enum ScheduleFlag { export enum ScheduleFlag {
@@ -430,7 +355,8 @@ export enum ScheduleFlag {
SCHEDULE_DAY = 0, // no bits set SCHEDULE_DAY = 0, // no bits set
SCHEDULE_TIMER = 128, // bit 8 SCHEDULE_TIMER = 128, // bit 8
SCHEDULE_ONCHANGE = 129, // bit 1 SCHEDULE_ONCHANGE = 129, // bit 1
SCHEDULE_CONDITION = 130 // bit 2 SCHEDULE_CONDITION = 130, // bit 2
SCHEDULE_IMMEDIATE = 132 // bit 3
} }
export interface EntityItem { export interface EntityItem {
@@ -440,12 +366,11 @@ export interface EntityItem {
device_id: number | string; device_id: number | string;
type_id: number | string; type_id: number | string;
offset: number; offset: number;
factor: number | string; factor: number;
uom: number; uom: number;
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;
@@ -453,17 +378,16 @@ export interface EntityItem {
o_device_id?: number | string; o_device_id?: number | string;
o_type_id?: number | string; o_type_id?: number | string;
o_offset?: number; o_offset?: number;
o_factor?: number | string; o_factor?: number;
o_uom?: number; o_uom?: number;
o_value_type?: number; o_value_type?: number;
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 {
readonly entities: readonly EntityItem[]; entities: EntityItem[];
} }
// matches emsdevice.h DeviceType // matches emsdevice.h DeviceType
@@ -473,7 +397,6 @@ export const enum DeviceType {
ANALOGSENSOR = 2, ANALOGSENSOR = 2,
SCHEDULER = 3, SCHEDULER = 3,
CUSTOM = 4, CUSTOM = 4,
COMMAND = 5,
BOILER, BOILER,
THERMOSTAT, THERMOSTAT,
MIXER, MIXER,
@@ -520,4 +443,4 @@ export const DeviceValueTypeNames = [
'ENUM', 'ENUM',
'RAW', 'RAW',
'CMD' 'CMD'
] as const; ];
+409 -268
View File
@@ -1,10 +1,9 @@
import Schema from 'validators/schema'; import Schema from 'async-validator';
import type { InternalRuleItem } from 'validators/schema'; import type { InternalRuleItem } from 'async-validator';
import { IP_OR_HOSTNAME_VALIDATOR } from 'validators/shared'; import { IP_OR_HOSTNAME_VALIDATOR } from 'validators/shared';
import type { import type {
AnalogSensor, AnalogSensor,
CommandItem,
DeviceValue, DeviceValue,
EntityItem, EntityItem,
ScheduleItem, ScheduleItem,
@@ -12,330 +11,473 @@ import type {
TemperatureSensor TemperatureSensor
} from './types'; } from './types';
// Constants export const GPIO_VALIDATOR = {
const ERROR_MESSAGES = { validator(
GPIO_INVALID: 'Must be an valid GPIO port', rule: InternalRuleItem,
NAME_DUPLICATE: 'Name already in use', value: number,
GPIO_DUPLICATE: 'GPIO already in use', callback: (error?: string) => void
VALUE_OUT_OF_RANGE: 'Value out of range', ) {
HEX_REQUIRED: 'Is required and must be in hex format' if (
} as const; value &&
(value === 1 ||
const VALIDATION_LIMITS = { (value >= 6 && value <= 11) ||
PORT_MIN: 0, value === 20 ||
PORT_MAX: 65535, value === 24 ||
MODBUS_MAX_CLIENTS_MIN: 0, (value >= 28 && value <= 31) ||
MODBUS_MAX_CLIENTS_MAX: 50, value > 40 ||
MODBUS_TIMEOUT_MIN: 100, value < 0)
MODBUS_TIMEOUT_MAX: 20000, ) {
SYSLOG_MARK_INTERVAL_MIN: 0, callback('Must be an valid GPIO port');
SYSLOG_MARK_INTERVAL_MAX: 3600, } else {
SHOWER_MIN_DURATION_MIN: 10, callback();
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);
}; };
// Generic unique name validator factory export const GPIO_VALIDATORR = {
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 !== '' &&
(originalName === undefined || (o_name === undefined || o_name.toLowerCase() !== name.toLowerCase()) &&
originalName.toLowerCase() !== name.toLowerCase()) && schedule.find((si) => si.name.toLowerCase() === name.toLowerCase())
items.find((item) => item.name.toLowerCase() === name.toLowerCase())
) { ) {
callback(ERROR_MESSAGES.NAME_DUPLICATE); callback('Name already in use');
return; } else {
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: [
{ required: true, message: 'Name is required' }, {
NAME_PATTERN_REQUIRED, type: 'string',
uniqueNameValidator(schedule, scheduleItem.o_name) pattern: /^[a-zA-Z0-9_]{0,19}$/,
], message: "Must be <20 characters: alphanumeric or '_'"
cmd_name: [{ required: true, message: 'Command is required' }] },
}); ...[uniqueNameValidator(schedule, scheduleItem.o_name)]
export const uniqueCommandNameValidator = (
commands: CommandItem[],
o_name?: string
) => createUniqueNameValidator(commands, o_name);
export const commandItemValidation = (
commands: CommandItem[],
commandItem: CommandItem
) =>
new Schema({
name: [
{ required: true, message: 'Name is required' },
NAME_PATTERN_REQUIRED,
uniqueCommandNameValidator(commands, commandItem.o_name)
], ],
cmd: [ cmd: [
{ required: true, message: 'Command is required' }, { required: true, message: 'Command is required' },
{ {
type: 'string', type: 'string',
min: VALIDATION_LIMITS.COMMAND_MIN, min: 1,
max: VALIDATION_LIMITS.COMMAND_MAX, max: 300,
message: `Command must be ${VALIDATION_LIMITS.COMMAND_MIN}-${VALIDATION_LIMITS.COMMAND_MAX} characters` message: 'Command must be 1-300 characters'
} }
] ]
}); });
export const uniqueCustomNameValidator = (entity: EntityItem[], o_name?: string) => export const uniqueCustomNameValidator = (
createUniqueNameValidator(entity, o_name); entity: EntityItem[],
o_name?: string
const hexValidator = { ) => ({
validator( validator(
_rule: InternalRuleItem, rule: InternalRuleItem,
value: string, name: string,
callback: (error?: string) => void callback: (error?: string) => void
) { ) {
if (!value || Number.isNaN(Number.parseInt(value, VALIDATION_LIMITS.HEX_BASE))) { if (
callback(ERROR_MESSAGES.HEX_REQUIRED); (o_name === undefined || o_name.toLowerCase() !== name.toLowerCase()) &&
return; entity.find((ei) => ei.name.toLowerCase() === name.toLowerCase())
) {
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,
uniqueCustomNameValidator(entity, entityItem.o_name)
],
device_id: [hexValidator],
type_id: [hexValidator],
offset: [
{ required: true, message: 'Offset is required' },
{ {
type: 'number', type: 'string',
min: VALIDATION_LIMITS.OFFSET_MIN, pattern: /^[a-zA-Z0-9_]{1,19}$/,
max: VALIDATION_LIMITS.OFFSET_MAX, message: "Must be <20 characters: alphanumeric or '_'"
message: `Must be between ${VALIDATION_LIMITS.OFFSET_MIN} and ${VALIDATION_LIMITS.OFFSET_MAX}` },
...[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();
}
} }
], ],
factor: [{ required: true, message: 'is required' }] 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();
}
}
],
offset: [
{ required: true, message: 'Offset is required' },
{ type: 'number', min: 0, max: 255, message: 'Must be between 0 and 255' }
],
factor: [
{ required: true, message: 'Bytes is required' },
{ type: 'number', min: 1, max: 255, message: 'Must be between 1 and 255' }
]
}); });
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: [NAME_PATTERN, uniqueTemperatureNameValidator(sensors, sensor.o_n)] n: [
{
type: 'string',
pattern: /^[a-zA-Z0-9_]{0,19}$/,
message: "Must be <20 characters: alphanumeric or '_'"
},
...[uniqueTemperatureNameValidator(sensors, sensor.o_n)]
]
}); });
export const isGPIOUniqueValidator = (sensors: AnalogSensor[]) => ({
validator(
rule: InternalRuleItem,
gpio: number,
callback: (error?: string) => void
) {
if (sensors.find((as) => as.g === gpio)) {
callback('GPIO already in use');
} else {
callback();
}
}
});
export const uniqueAnalogNameValidator = ( 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,
return new Schema({ platform: string
// name is required and must be unique ) =>
new Schema({
n: [ n: [
{ required: true, message: 'Name is required' }, {
NAME_PATTERN, type: 'string',
uniqueAnalogNameValidator(sensors, sensor.o_n) pattern: /^[a-zA-Z0-9_]{0,19}$/,
message: "Must be <20 characters: alphanumeric or '_'"
},
...[uniqueAnalogNameValidator(sensors, sensor.o_n)]
],
g: [
{ required: true, message: 'GPIO is required' },
platform === 'ESP32S3'
? GPIO_VALIDATORS3
: platform === 'ESP32S2'
? GPIO_VALIDATORS2
: platform === 'ESP32C3'
? GPIO_VALIDATORC3
: GPIO_VALIDATOR,
...(creating ? [isGPIOUniqueValidator(sensors)] : [])
] ]
}); });
};
export const deviceValueItemValidation = (dv: DeviceValue) => export const deviceValueItemValidation = (dv: DeviceValue) =>
new Schema({ new Schema({
@@ -343,18 +485,17 @@ 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 !== undefined && dv.m &&
dv.x !== undefined && dv.x &&
(value < dv.m || value > dv.x) (value < dv.m || value > dv.x)
) { ) {
callback(ERROR_MESSAGES.VALUE_OUT_OF_RANGE); callback('Value out of range');
return;
} }
callback(); callback();
} }
+36 -44
View File
@@ -6,6 +6,7 @@ import { Button, Checkbox, MenuItem } from '@mui/material';
import * as APApi from 'api/ap'; import * as APApi from 'api/ap';
import type { ValidateFieldsError } from 'async-validator';
import { import {
BlockFormControlLabel, BlockFormControlLabel,
BlockNavigation, BlockNavigation,
@@ -20,25 +21,12 @@ import { useI18nContext } from 'i18n/i18n-react';
import type { APSettingsType } from 'types'; import type { APSettingsType } from 'types';
import { APProvisionMode } from 'types'; import { APProvisionMode } from 'types';
import { numberValue, updateValueDirty, useRest } from 'utils'; import { numberValue, updateValueDirty, useRest } from 'utils';
import { ValidationError, createAPSettingsValidator, validate } from 'validators'; import { createAPSettingsValidator, validate } from 'validators';
import type { ValidateFieldsError } from 'validators/schema';
export const isAPEnabled = ({ provision_mode }: APSettingsType) => export const isAPEnabled = ({ provision_mode }: APSettingsType) =>
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,
@@ -58,40 +46,41 @@ const APSettings = () => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
useLayoutTitle(LL.ACCESS_POINT(0)); useLayoutTitle(LL.SETTINGS_OF(LL.ACCESS_POINT(0)));
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>(); const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const updateFormValue = updateValueDirty( const updateFormValue = updateValueDirty(
origData as unknown as Record<string, unknown>, origData,
dirtyFlags, dirtyFlags,
setDirtyFlags, setDirtyFlags,
updateDataValue as (value: unknown) => void updateDataValue
); );
const apEnabled = data ? isAPEnabled(data) : false;
const validateAndSubmit = async () => {
if (!data) return;
try {
setFieldErrors(undefined);
await validate(createAPSettingsValidator(data), data);
await saveData();
} catch (error) {
setFieldErrors((error as ValidationError).fieldErrors);
}
};
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}
@@ -101,6 +90,9 @@ const APSettings = () => {
onChange={updateFormValue} onChange={updateFormValue}
margin="normal" margin="normal"
> >
<MenuItem value={APProvisionMode.AP_MODE_ALWAYS}>
{LL.AP_PROVIDE_TEXT_1()}
</MenuItem>
<MenuItem value={APProvisionMode.AP_MODE_DISCONNECTED}> <MenuItem value={APProvisionMode.AP_MODE_DISCONNECTED}>
{LL.AP_PROVIDE_TEXT_2()} {LL.AP_PROVIDE_TEXT_2()}
</MenuItem> </MenuItem>
@@ -108,10 +100,10 @@ const APSettings = () => {
{LL.AP_PROVIDE_TEXT_3()} {LL.AP_PROVIDE_TEXT_3()}
</MenuItem> </MenuItem>
</ValidatedTextField> </ValidatedTextField>
{apEnabled && ( {isAPEnabled(data) && (
<> <>
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors || {}} fieldErrors={fieldErrors}
name="ssid" name="ssid"
label={LL.ACCESS_POINT(2) + ' SSID'} label={LL.ACCESS_POINT(2) + ' SSID'}
fullWidth fullWidth
@@ -121,7 +113,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
@@ -131,7 +123,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)}
@@ -142,7 +134,7 @@ const APSettings = () => {
onChange={updateFormValue} onChange={updateFormValue}
margin="normal" margin="normal"
> >
{CHANNEL_RANGE.map((i) => ( {range(1, 14).map((i) => (
<MenuItem key={i} value={i}> <MenuItem key={i} value={i}>
{i} {i}
</MenuItem> </MenuItem>
@@ -159,7 +151,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)}
@@ -170,14 +162,14 @@ const APSettings = () => {
onChange={updateFormValue} onChange={updateFormValue}
margin="normal" margin="normal"
> >
{MAX_CLIENTS_RANGE.map((i) => ( {range(1, 9).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
@@ -187,7 +179,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
@@ -197,7 +189,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
@@ -1,4 +1,5 @@
import { useContext, useEffect, useState } from 'react'; import { useState } from 'react';
import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew'; import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew';
@@ -8,17 +9,18 @@ import {
Button, Button,
Checkbox, Checkbox,
Divider, Divider,
Grid,
InputAdornment, InputAdornment,
MenuItem, MenuItem,
TextField, TextField,
Typography Typography
} from '@mui/material'; } from '@mui/material';
import Grid from '@mui/material/Grid2';
import { readSystemStatus } from 'api/system'; import { readSystemStatus } from 'api/system';
import { useRequest } from 'alova/client'; import { useRequest } from 'alova/client';
import SystemMonitor from 'app/status/SystemMonitor'; import RestartMonitor from 'app/status/RestartMonitor';
import type { ValidateFieldsError } from 'async-validator';
import { import {
BlockFormControlLabel, BlockFormControlLabel,
BlockNavigation, BlockNavigation,
@@ -26,26 +28,22 @@ import {
FormLoader, FormLoader,
MessageBox, MessageBox,
SectionContent, SectionContent,
ValidatedPasswordField,
ValidatedTextField, ValidatedTextField,
useLayoutTitle useLayoutTitle
} from 'components'; } from 'components';
import { toast } from 'components/toast';
import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import { numberValue, updateValueDirty, useRest } from 'utils'; import { numberValue, updateValueDirty, useRest } from 'utils';
import { ValidationError, validate } from 'validators'; import { validate } from 'validators';
import type { ValidateFieldsError } from 'validators/schema';
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, BoardProfileKey, Settings } from '../main/types'; import type { APIcall, 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 as BoardProfileKey]} {BOARD_PROFILES[code]}
</MenuItem> </MenuItem>
)); ));
} }
@@ -69,23 +67,15 @@ const ApplicationSettings = () => {
update: writeSettings update: writeSettings
}); });
// the menu drawer reads the system name from the authentication context
const { setSystemName } = useContext(AuthenticatedContext);
useEffect(() => {
if (origData?.system_name !== undefined) {
setSystemName(origData.system_name);
}
}, [origData?.system_name, setSystemName]);
const [restarting, setRestarting] = useState<boolean>(); const [restarting, setRestarting] = useState<boolean>();
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const updateFormValue = updateValueDirty( const updateFormValue = updateValueDirty(
origData as unknown as Record<string, unknown>, origData,
dirtyFlags, dirtyFlags,
setDirtyFlags, setDirtyFlags,
updateDataValue as (value: unknown) => void updateDataValue
); );
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>(); const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
@@ -116,18 +106,6 @@ const ApplicationSettings = () => {
}); });
}); });
const SecondsInputProps = {
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
};
const MinutesInputProps = {
endAdornment: <InputAdornment position="end">{LL.MINUTES()}</InputAdornment>
};
const HoursInputProps = {
endAdornment: <InputAdornment position="end">{LL.HOURS()}</InputAdornment>
};
const doRestart = async () => { const doRestart = async () => {
setRestarting(true); setRestarting(true);
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch( await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
@@ -143,73 +121,58 @@ const ApplicationSettings = () => {
}); });
}; };
useLayoutTitle(LL.APPLICATION()); useLayoutTitle(LL.SETTINGS_OF(LL.APPLICATION()));
const validateAndSubmit = async () => { const SecondsInputProps = {
try { endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
setFieldErrors(undefined);
await validate(createSettingsValidator(data), data);
await saveData();
} catch (error) {
setFieldErrors((error as ValidationError).fieldErrors);
}
}; };
const MilliSecondsInputProps = {
const changeBoardProfile = (event: React.ChangeEvent<HTMLInputElement>) => { endAdornment: <InputAdornment position="end">ms</InputAdornment>
const boardProfile = event.target.value;
updateFormValue(event);
if (boardProfile === 'CUSTOM') {
updateDataValue({
...data,
board_profile: boardProfile
});
} else {
void updateBoardProfile(boardProfile);
}
}; };
const MinutesInputProps = {
const restart = async () => { endAdornment: <InputAdornment position="end">{LL.MINUTES()}</InputAdornment>
await validateAndSubmit();
await doRestart();
}; };
const HoursInputProps = {
const sendmail = async () => { endAdornment: <InputAdornment position="end">{LL.HOURS()}</InputAdornment>
await sendAPI({
device: 'system',
cmd: 'sendmail',
data: '"Email notification test successful!"',
id: 0
})
.then(() => {
toast.success(LL.TEST_EMAIL_SUCCESSFUL());
})
.catch((error: Error) => {
toast.error(error.message);
});
}; };
const boardProfileItems = boardProfileSelectItems();
const content = () => { const content = () => {
if (!data || !hardwareData) { if (!data || !hardwareData) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />; return <FormLoader onRetry={loadData} errorMessage={errorMessage} />;
} }
const validateAndSubmit = async () => {
try {
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;
updateFormValue(event);
if (boardProfile === 'CUSTOM') {
updateDataValue({
...data,
board_profile: boardProfile
});
} else {
void updateBoardProfile(boardProfile);
}
};
const restart = async () => {
await validateAndSubmit();
await doRestart();
};
return ( return (
<> <>
<Typography variant="h6" color="primary"> <Typography sx={{ pb: 1 }} variant="h6" color="primary">
{LL.SYSTEM(0)}&nbsp;
{LL.CUSTOMIZATIONS()}
</Typography>
<TextField
name="system_name"
label={LL.SYSTEM_NAME()}
value={data.system_name}
variant="outlined"
onChange={updateFormValue}
margin="normal"
/>
<Typography sx={{ pb: 1, pt: 2 }} variant="h6" color="primary">
{LL.SERVICES()} {LL.SERVICES()}
</Typography> </Typography>
<Typography color="secondary">API</Typography> <Typography color="secondary">API</Typography>
@@ -244,22 +207,13 @@ const ApplicationSettings = () => {
disabled={!hardwareData.psram} disabled={!hardwareData.psram}
/> />
} }
label={ label={LL.ENABLE_MODBUS()}
<Typography color={!hardwareData.psram ? 'grey' : 'default'}>
{LL.ENABLE_MODBUS()}
{!hardwareData.psram && (
<Typography variant="caption">
&nbsp; &#40;{LL.IS_REQUIRED('PSRAM')}&#41;
</Typography>
)}
</Typography>
}
/> />
{data.modbus_enabled && ( {data.modbus_enabled && (
<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"
@@ -271,7 +225,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"
@@ -283,11 +237,11 @@ const ApplicationSettings = () => {
</Grid> </Grid>
<Grid> <Grid>
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors || {}} fieldErrors={fieldErrors}
name="modbus_timeout" name="modbus_timeout"
label="Timeout" label="Timeout"
slotProps={{ slotProps={{
input: SecondsInputProps input: MilliSecondsInputProps
}} }}
variant="outlined" variant="outlined"
value={numberValue(data.modbus_timeout)} value={numberValue(data.modbus_timeout)}
@@ -313,7 +267,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"
@@ -324,7 +278,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"
@@ -347,15 +301,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={{
@@ -370,148 +324,6 @@ const ApplicationSettings = () => {
</Grid> </Grid>
</Grid> </Grid>
)} )}
<Typography color="secondary">eMail</Typography>
<BlockFormControlLabel
control={
<Checkbox
checked={data.email_enabled}
onChange={updateFormValue}
name="email_enabled"
disabled={!hardwareData.psram}
/>
}
label={
<Typography color={!hardwareData.psram ? 'grey' : 'default'}>
Enable eMail notification
{!hardwareData.psram && (
<Typography variant="caption">
&nbsp; &#40;{LL.IS_REQUIRED('PSRAM')}&#41;
</Typography>
)}
</Typography>
}
/>
{data.email_enabled && (
<>
<Grid
container
spacing={2}
direction="row"
sx={{ justifyContent: 'flex-start', alignItems: 'flex-start' }}
>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
name="email_server"
label="SMTP Server"
variant="outlined"
value={data.email_server}
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
sx={{ width: '12ch' }}
name="email_port"
variant="outlined"
label="Port"
value={numberValue(data.email_port)}
type="number"
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid>
<TextField
sx={{ width: '20ch' }}
name="email_security"
label={LL.SECURITY(0)}
value={data.email_security}
variant="outlined"
onChange={updateFormValue}
margin="normal"
select
>
<MenuItem value={0}>{LL.OFF()}</MenuItem>
<MenuItem value={1}>SSL</MenuItem>
<MenuItem value={2}>StartTLS</MenuItem>
</TextField>
</Grid>
</Grid>
<Grid container spacing={2} rowSpacing={0}>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
name="email_login"
label="Login"
variant="outlined"
value={data.email_login}
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid>
<ValidatedPasswordField
fieldErrors={fieldErrors || {}}
name="email_pass"
label="Password"
variant="outlined"
value={data.email_pass}
onChange={updateFormValue}
margin="normal"
/>
</Grid>
</Grid>
<Grid container spacing={2} rowSpacing={0}>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
name="email_sender"
label="From"
variant="outlined"
value={data.email_sender}
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
name="email_recp"
label="To"
variant="outlined"
value={data.email_recp}
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
name="email_subject"
label="Subject"
variant="outlined"
value={data.email_subject}
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid>
<Button
sx={{ mt: 3 }}
variant="outlined"
color="primary"
disabled={dirtyFlags.length !== 0}
onClick={sendmail}
>
Send test email
</Button>
</Grid>
</Grid>
</>
)}
<Typography sx={{ pb: 1, pt: 2 }} variant="h6" color="primary"> <Typography sx={{ pb: 1, pt: 2 }} variant="h6" color="primary">
{LL.SENSORS()} {LL.SENSORS()}
</Typography> </Typography>
@@ -557,7 +369,7 @@ const ApplicationSettings = () => {
margin="normal" margin="normal"
select select
> >
<MenuItem value="cs">Česky (CS)</MenuItem> <MenuItem value="cz">Česky (CZ)</MenuItem>
<MenuItem value="de">Deutsch (DE)</MenuItem> <MenuItem value="de">Deutsch (DE)</MenuItem>
<MenuItem value="en">English (EN)</MenuItem> <MenuItem value="en">English (EN)</MenuItem>
<MenuItem value="fr">Français (FR)</MenuItem> <MenuItem value="fr">Français (FR)</MenuItem>
@@ -650,32 +462,24 @@ const ApplicationSettings = () => {
name="board_profile" name="board_profile"
label={LL.BOARD_PROFILE()} label={LL.BOARD_PROFILE()}
value={data.board_profile} value={data.board_profile}
disabled={processingBoard} disabled={processingBoard || hardwareData.model.startsWith('BBQKees')}
variant="outlined" variant="outlined"
onChange={changeBoardProfile} onChange={changeBoardProfile}
margin="normal" margin="normal"
select select
> >
{hardwareData.model.startsWith('BBQKees') ? ( {boardProfileSelectItems()}
<MenuItem key={hardwareData.board} value={hardwareData.board}> <Divider />
{BOARD_PROFILES[hardwareData.board as BoardProfileKey]} <MenuItem key={'CUSTOM'} value={'CUSTOM'}>
</MenuItem> {LL.CUSTOM()}&hellip;
) : ( </MenuItem>
boardProfileItems
)}
{(data.board_profile === 'CUSTOM' || data.developer_mode) && <Divider />}
{(data.board_profile === 'CUSTOM' || data.developer_mode) && (
<MenuItem key={'CUSTOM'} value={'CUSTOM'}>
{LL.CUSTOM()}&hellip;
</MenuItem>
)}
</TextField> </TextField>
{data.board_profile === 'CUSTOM' && ( {data.board_profile === 'CUSTOM' && (
<> <>
<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
@@ -688,7 +492,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
@@ -701,7 +505,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
@@ -714,7 +518,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) + ')'
@@ -729,7 +533,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
@@ -740,23 +544,6 @@ const ApplicationSettings = () => {
margin="normal" margin="normal"
/> />
</Grid> </Grid>
{data.led_gpio !== 0 && (
<Grid>
<TextField
name="led_type"
label={'LED ' + LL.TYPE(0)}
value={data.led_type}
fullWidth
variant="outlined"
onChange={updateFormValue}
margin="normal"
select
>
<MenuItem value={0}>LED</MenuItem>
<MenuItem value={1}>RGB-LED</MenuItem>
</TextField>
</Grid>
)}
<Grid> <Grid>
<TextField <TextField
name="phy_type" name="phy_type"
@@ -771,7 +558,6 @@ 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>
@@ -838,7 +624,6 @@ const ApplicationSettings = () => {
<MenuItem value={2}>EMS+</MenuItem> <MenuItem value={2}>EMS+</MenuItem>
<MenuItem value={3}>HT3</MenuItem> <MenuItem value={3}>HT3</MenuItem>
<MenuItem value={4}>{LL.HARDWARE()}</MenuItem> <MenuItem value={4}>{LL.HARDWARE()}</MenuItem>
<MenuItem value={5}>Auto</MenuItem>
</TextField> </TextField>
</Grid> </Grid>
<Grid> <Grid>
@@ -912,16 +697,6 @@ const ApplicationSettings = () => {
} }
label={LL.DEVELOPER_MODE()} label={LL.DEVELOPER_MODE()}
/> />
<BlockFormControlLabel
control={
<Checkbox
checked={data.disable_reset}
onChange={updateFormValue}
name="disable_reset"
/>
}
label={LL.DISABLE_RESET()}
/>
<BlockFormControlLabel <BlockFormControlLabel
control={ control={
<Checkbox <Checkbox
@@ -943,9 +718,9 @@ const ApplicationSettings = () => {
label={LL.REMOTE_TIMEOUT_EN()} label={LL.REMOTE_TIMEOUT_EN()}
/> />
{data.remote_timeout_en && ( {data.remote_timeout_en && (
<Box sx={{ 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={{
@@ -985,7 +760,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={{
@@ -1003,7 +778,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={{
@@ -1019,7 +794,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={{
@@ -1038,9 +813,8 @@ const ApplicationSettings = () => {
</Grid> </Grid>
{restartNeeded && ( {restartNeeded && (
<MessageBox level="warning" message={LL.RESTART_TEXT(0)}> <MessageBox my={2} level="warning" message={LL.RESTART_TEXT(0)}>
<Button <Button
sx={{ ml: 2 }}
startIcon={<PowerSettingsNewIcon />} startIcon={<PowerSettingsNewIcon />}
variant="contained" variant="contained"
color="error" color="error"
@@ -1076,12 +850,10 @@ const ApplicationSettings = () => {
); );
}; };
return restarting ? ( return (
<SystemMonitor />
) : (
<SectionContent> <SectionContent>
{blocker ? <BlockNavigation blocker={blocker} /> : null} {blocker ? <BlockNavigation blocker={blocker} /> : null}
{content()} {restarting ? <RestartMonitor /> : content()}
</SectionContent> </SectionContent>
); );
}; };
+64 -110
View File
@@ -1,40 +1,28 @@
import { useState } from 'react'; import { useState } from 'react';
import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel';
import DownloadIcon from '@mui/icons-material/GetApp'; import DownloadIcon from '@mui/icons-material/GetApp';
import WarningIcon from '@mui/icons-material/Warning'; import { Box, Button, Typography } from '@mui/material';
import { import Grid from '@mui/material/Grid2';
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
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';
import { dialogStyle } from '@/CustomTheme';
import { useRequest } from 'alova/client'; import { useRequest } from 'alova/client';
import type { APIcall } from 'app/main/types'; import type { APIcall } from 'app/main/types';
import SystemMonitor from 'app/status/SystemMonitor'; import RestartMonitor from 'app/status/RestartMonitor';
import { import {
FormLoader, FormLoader,
SectionContent, SectionContent,
SingleUpload, SingleUpload,
useLayoutTitle useLayoutTitle
} from 'components'; } from 'components';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import { saveFile } from 'utils'; import { saveFile } from 'utils';
const DownloadUpload = () => { const DownloadUpload = () => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const [confirmBackup, setConfirmBackup] = useState<boolean>(false);
const [restarting, setRestarting] = useState<boolean>(false); const [restarting, setRestarting] = useState<boolean>(false);
const { send: sendExportData } = useRequest( const { send: sendExportData } = useRequest(
@@ -48,7 +36,7 @@ const DownloadUpload = () => {
toast.info(LL.DOWNLOAD_SUCCESSFUL()); toast.info(LL.DOWNLOAD_SUCCESSFUL());
}) })
.onError((error) => { .onError((error) => {
toast.error(String(error.error?.message || 'An error occurred')); toast.error(error.message);
}); });
const { send: sendAPI } = useRequest((data: APIcall) => API(data), { const { send: sendAPI } = useRequest((data: APIcall) => API(data), {
@@ -59,118 +47,84 @@ const DownloadUpload = () => {
const doRestart = async () => { const doRestart = async () => {
setRestarting(true); setRestarting(true);
try { await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }); (error: Error) => {
} catch (error) { toast.error(error.message);
toast.error((error as Error).message); }
setRestarting(false); );
}
}; };
useLayoutTitle(LL.DOWNLOAD_UPLOAD()); useLayoutTitle(LL.DOWNLOAD_UPLOAD());
const handleCloseBackupDialog = () => { const content = () => {
setConfirmBackup(false); if (!data) {
}; return <FormLoader onRetry={loadData} errorMessage={error?.message} />;
}
const handleDownload = (type: string) => () => {
void sendExportData(type);
setConfirmBackup(false);
};
if (restarting) {
return <SystemMonitor />;
}
if (!data) {
return ( return (
<SectionContent> <>
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} /> <Typography sx={{ pb: 2 }} variant="h6" color="primary">
</SectionContent> {LL.DOWNLOAD(0)}
); </Typography>
}
return ( <Typography mb={1} variant="body1" color="warning">
<SectionContent> {LL.DOWNLOAD_SETTINGS_TEXT()}.
<Dialog </Typography>
sx={dialogStyle} <Grid container spacing={1}>
open={confirmBackup}
onClose={handleCloseBackupDialog}
>
<DialogTitle>{LL.DOWNLOAD_SYSTEM_BACKUP()}</DialogTitle>
<DialogContent dividers>
<WarningIcon color="warning" sx={{ fontSize: 18 }} />
&nbsp;
{LL.WARNING_SYSTEM_BACKUP()}
</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={handleCloseBackupDialog}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button <Button
sx={{ ml: 2 }}
startIcon={<DownloadIcon />} startIcon={<DownloadIcon />}
variant="outlined" variant="outlined"
onClick={handleDownload('systembackup')}
color="primary" color="primary"
onClick={() => sendExportData('settings')}
> >
{LL.DOWNLOAD(0)} {LL.SETTINGS_OF(LL.APPLICATION())}
</Button> </Button>
</DialogActions>
</Dialog>
<Typography sx={{ pb: 2 }} variant="h6" color="primary"> <Button
{LL.DOWNLOAD(0)} sx={{ ml: 2 }}
</Typography> startIcon={<DownloadIcon />}
variant="outlined"
color="primary"
onClick={() => sendExportData('customizations')}
>
{LL.CUSTOMIZATIONS()}
</Button>
<Button
sx={{ ml: 2 }}
startIcon={<DownloadIcon />}
variant="outlined"
color="primary"
onClick={() => sendExportData('entities')}
>
{LL.CUSTOM_ENTITIES(0)}
</Button>
<Button
sx={{ ml: 2 }}
startIcon={<DownloadIcon />}
variant="outlined"
color="primary"
onClick={() => sendExportData('schedule')}
>
{LL.SCHEDULE(0)}
</Button>
</Grid>
<Grid <Typography sx={{ pt: 2, pb: 2 }} variant="h6" color="primary">
container {LL.UPLOAD()}
spacing={2}
sx={{
alignItems: 'center'
}}
>
<Typography variant="body1" color="warning">
{LL.DOWNLOAD_SETTINGS_TEXT()}:
</Typography> </Typography>
<Button
startIcon={<DownloadIcon />}
variant="outlined"
color="primary"
onClick={() => setConfirmBackup(true)}
>
{LL.DOWNLOAD_SYSTEM_BACKUP()}
</Button>
</Grid>
<Grid container spacing={2} sx={{ mt: 2, alignItems: 'center' }}> <Box color="warning.main" sx={{ pb: 2 }}>
<Typography variant="body1" color="warning"> <Typography variant="body1">{LL.UPLOAD_TEXT()}.</Typography>
{LL.DOWNLOAD_SETTINGS_TEXT2()}: </Box>
</Typography>
<Button
startIcon={<DownloadIcon />}
variant="outlined"
color="primary"
onClick={handleDownload('allvalues')}
>
{LL.ALLVALUES()}
</Button>
</Grid>
<Typography sx={{ pt: 2, pb: 2 }} variant="h6" color="primary"> <SingleUpload doRestart={doRestart} />
{LL.UPLOAD()} </>
</Typography> );
};
<Typography sx={{ pb: 2 }} color="warning" variant="body1"> return (
{LL.UPLOAD_TEXT()}: <SectionContent>{restarting ? <RestartMonitor /> : content()}</SectionContent>
</Typography>
<SingleUpload doRestart={doRestart} />
</SectionContent>
); );
}; };
+234 -218
View File
@@ -1,21 +1,20 @@
import { useState } from 'react'; import { useState } from 'react';
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,
Grid,
InputAdornment, InputAdornment,
MenuItem, MenuItem,
TextField, TextField,
Typography Typography
} from '@mui/material'; } from '@mui/material';
import Grid from '@mui/material/Grid2';
import * as MqttApi from 'api/mqtt'; import * as MqttApi from 'api/mqtt';
import type { ValidateFieldsError } from 'async-validator';
import { import {
BlockFormControlLabel, BlockFormControlLabel,
BlockNavigation, BlockNavigation,
@@ -26,14 +25,10 @@ import {
ValidatedTextField, ValidatedTextField,
useLayoutTitle useLayoutTitle
} from 'components'; } from 'components';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import type { MqttSettingsType } from 'types'; import type { MqttSettingsType } from 'types';
import { numberValue, updateValueDirty, useRest } from 'utils'; import { numberValue, updateValueDirty, useRest } from 'utils';
import { ValidationError, createMqttSettingsValidator, validate } from 'validators'; import { createMqttSettingsValidator, validate } from 'validators';
import type { ValidateFieldsError } from 'validators/schema';
import { callAction } from '../../api/app';
const MqttSettings = () => { const MqttSettings = () => {
const { const {
@@ -53,96 +48,52 @@ const MqttSettings = () => {
}); });
const { LL } = useI18nContext(); const { LL } = useI18nContext();
useLayoutTitle('MQTT'); useLayoutTitle(LL.SETTINGS_OF('MQTT'));
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>(); const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const sendResetMQTT = () => {
void callAction({ action: 'resetMQTT' })
.then(() => {
toast.success('MQTT ' + LL.REFRESH() + ' successful');
})
.catch((error) => {
toast.error(String(error.error?.message || 'An error occurred'));
});
};
const updateFormValue = updateValueDirty( const updateFormValue = updateValueDirty(
origData as unknown as Record<string, unknown>, origData,
dirtyFlags, dirtyFlags,
setDirtyFlags, setDirtyFlags,
updateDataValue as (value: unknown) => void updateDataValue
); );
const SecondsInputProps = { const SecondsInputProps = {
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment> endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
}; };
const validateAndSubmit = async () => { const content = () => {
if (!data) return; if (!data) {
try { return <FormLoader onRetry={loadData} errorMessage={errorMessage} />;
setFieldErrors(undefined);
await validate(createMqttSettingsValidator(data), data);
await saveData();
} catch (error) {
setFieldErrors((error as ValidationError).fieldErrors);
} }
};
const publishIntervalFields = [ const validateAndSubmit = async () => {
{ name: 'publish_time_heartbeat', label: 'Heartbeat', validated: true }, try {
{ name: 'publish_time_boiler', label: LL.MQTT_INT_BOILER(), validated: false }, setFieldErrors(undefined);
{ await validate(createMqttSettingsValidator(data), data);
name: 'publish_time_thermostat', await saveData();
label: LL.MQTT_INT_THERMOSTATS(), } catch (error) {
validated: false setFieldErrors(error as ValidateFieldsError);
}, }
{ 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 }
];
if (!data) {
return ( return (
<SectionContent>
{blocker ? <BlockNavigation blocker={blocker} /> : null}
<FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />
</SectionContent>
);
}
return (
<SectionContent>
{blocker ? <BlockNavigation blocker={blocker} /> : null}
<> <>
<Box sx={{ display: 'flex', gap: 2, mb: 1 }}> <BlockFormControlLabel
<BlockFormControlLabel control={
control={ <Checkbox
<Checkbox name="enabled"
name="enabled" checked={data.enabled}
checked={data.enabled} onChange={updateFormValue}
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}
name="host" name="host"
label={LL.ADDRESS_OF(LL.BROKER())} label={LL.ADDRESS_OF(LL.BROKER())}
multiline multiline
@@ -154,7 +105,7 @@ const MqttSettings = () => {
</Grid> </Grid>
<Grid> <Grid>
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors ?? {}} fieldErrors={fieldErrors}
name="port" name="port"
label="Port" label="Port"
variant="outlined" variant="outlined"
@@ -166,7 +117,7 @@ const MqttSettings = () => {
</Grid> </Grid>
<Grid> <Grid>
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors ?? {}} fieldErrors={fieldErrors}
name="base" name="base"
label={LL.BASE_TOPIC()} label={LL.BASE_TOPIC()}
variant="outlined" variant="outlined"
@@ -178,7 +129,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}
@@ -207,7 +158,7 @@ const MqttSettings = () => {
</Grid> </Grid>
<Grid> <Grid>
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors ?? {}} fieldErrors={fieldErrors}
name="keep_alive" name="keep_alive"
label="Keep Alive" label="Keep Alive"
slotProps={{ slotProps={{
@@ -254,7 +205,6 @@ 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"
/> />
@@ -304,160 +254,219 @@ const MqttSettings = () => {
} }
label={LL.MQTT_RESPONSE()} label={LL.MQTT_RESPONSE()}
/> />
<Grid container spacing={2} rowSpacing={0}> {!data.ha_enabled && (
<Grid> <Grid container spacing={2} rowSpacing={0}>
<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_single2cmd" name="publish_single"
checked={data.publish_single2cmd} checked={data.publish_single}
onChange={updateFormValue} onChange={updateFormValue}
/> />
} }
label={LL.MQTT_PUBLISH_TEXT_2()} label={LL.MQTT_PUBLISH_TEXT_1()}
/> />
</Grid> </Grid>
)} {data.publish_single && (
</Grid> <Grid>
{/* <Grid container spacing={2} rowSpacing={0}> */} <BlockFormControlLabel
<Grid> control={
<BlockFormControlLabel <Checkbox
control={ name="publish_single2cmd"
<Checkbox checked={data.publish_single2cmd}
name="ha_enabled" onChange={updateFormValue}
checked={data.ha_enabled} />
onChange={updateFormValue} }
disabled={data.publish_single} label={LL.MQTT_PUBLISH_TEXT_2()}
/> />
} </Grid>
label={LL.MQTT_PUBLISH_TEXT_3()} )}
/> </Grid>
</Grid> )}
{data.ha_enabled && ( {!data.publish_single && (
<Grid container spacing={2} rowSpacing={0}> <Grid container spacing={2} rowSpacing={0}>
<Grid> <Grid>
<TextField <BlockFormControlLabel
name="discovery_type" control={
label={LL.MQTT_PUBLISH_TEXT_5()} <Checkbox
value={data.discovery_type} name="ha_enabled"
variant="outlined" checked={data.ha_enabled}
onChange={updateFormValue} onChange={updateFormValue}
margin="normal" />
select }
> label={LL.MQTT_PUBLISH_TEXT_3()}
<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> {data.ha_enabled && (
<TextField <Grid container spacing={2} rowSpacing={0}>
name="entity_format" <Grid>
label={LL.MQTT_ENTITY_FORMAT()} <TextField
value={data.entity_format} name="discovery_type"
variant="outlined" label={LL.MQTT_PUBLISH_TEXT_5()}
onChange={updateFormValue} value={data.discovery_type}
margin="normal" variant="outlined"
select onChange={updateFormValue}
> margin="normal"
<MenuItem value={0}>{LL.MQTT_ENTITY_FORMAT_0()}</MenuItem> select
<MenuItem value={3}> >
{LL.MQTT_ENTITY_FORMAT_1()}&nbsp;(v3.5) <MenuItem value={0}>Home Assistant</MenuItem>
</MenuItem> <MenuItem value={1}>Domoticz</MenuItem>
<MenuItem value={4}> <MenuItem value={2}>Domoticz (latest)</MenuItem>
{LL.MQTT_ENTITY_FORMAT_2()}&nbsp;(v3.5) </TextField>
</MenuItem> </Grid>
<MenuItem value={1}> <Grid>
{LL.MQTT_ENTITY_FORMAT_1()}&nbsp;(latest) <TextField
</MenuItem> name="discovery_prefix"
<MenuItem value={2}> label={LL.MQTT_PUBLISH_TEXT_4()}
{LL.MQTT_ENTITY_FORMAT_2()}&nbsp;(latest) variant="outlined"
</MenuItem> value={data.discovery_prefix}
</TextField> onChange={updateFormValue}
</Grid> margin="normal"
<Grid> />
{data.discovery_type === 0 && ( </Grid>
<TextField <Grid>
name="ha_number_mode" <TextField
label={LL.MQTT_INPUT_NUMBER_FORMAT()} name="entity_format"
value={data.ha_number_mode} label={LL.MQTT_ENTITY_FORMAT()}
variant="outlined" value={data.entity_format}
onChange={updateFormValue} variant="outlined"
sx={{ width: '20ch' }} onChange={updateFormValue}
margin="normal" margin="normal"
select select
> >
<MenuItem value={0}>Box</MenuItem> <MenuItem value={0}>{LL.MQTT_ENTITY_FORMAT_0()}</MenuItem>
<MenuItem value={1}>Slider</MenuItem> <MenuItem value={3}>
</TextField> {LL.MQTT_ENTITY_FORMAT_1()}&nbsp;(v3.6)
)} </MenuItem>
</Grid> <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>
)} )}
<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}>
{publishIntervalFields.map((field) => ( <Grid>
<Grid key={field.name}> <ValidatedTextField
{field.validated ? ( fieldErrors={fieldErrors}
<ValidatedTextField name="publish_time_heartbeat"
fieldErrors={fieldErrors ?? {}} label="Heartbeat"
name={field.name} slotProps={{
label={field.label} input: SecondsInputProps
slotProps={{ }}
input: SecondsInputProps variant="outlined"
}} value={numberValue(data.publish_time_heartbeat)}
variant="outlined" type="number"
value={numberValue( onChange={updateFormValue}
data[field.name as keyof MqttSettingsType] as number margin="normal"
)} />
type="number" </Grid>
onChange={updateFormValue} <Grid>
margin="normal" <TextField
/> name="publish_time_boiler"
) : ( label={LL.MQTT_INT_BOILER()}
<TextField variant="outlined"
name={field.name} value={numberValue(data.publish_time_boiler)}
label={field.label} type="number"
variant="outlined" onChange={updateFormValue}
value={numberValue( margin="normal"
data[field.name as keyof MqttSettingsType] as number slotProps={{
)} input: SecondsInputProps
type="number" }}
onChange={updateFormValue} />
margin="normal" </Grid>
slotProps={{ <Grid>
input: SecondsInputProps <TextField
}} name="publish_time_thermostat"
/> label={LL.MQTT_INT_THERMOSTATS()}
)} variant="outlined"
</Grid> value={numberValue(data.publish_time_thermostat)}
))} type="number"
onChange={updateFormValue}
margin="normal"
slotProps={{
input: SecondsInputProps
}}
/>
</Grid>
<Grid>
<TextField
name="publish_time_solar"
label={LL.MQTT_INT_SOLAR()}
variant="outlined"
value={numberValue(data.publish_time_solar)}
type="number"
onChange={updateFormValue}
margin="normal"
slotProps={{
input: SecondsInputProps
}}
/>
</Grid>
<Grid>
<TextField
name="publish_time_mixer"
label={LL.MQTT_INT_MIXER()}
variant="outlined"
value={numberValue(data.publish_time_mixer)}
type="number"
onChange={updateFormValue}
margin="normal"
slotProps={{
input: SecondsInputProps
}}
/>
</Grid>
<Grid>
<TextField
name="publish_time_water"
label={LL.MQTT_INT_WATER()}
variant="outlined"
value={numberValue(data.publish_time_water)}
type="number"
onChange={updateFormValue}
margin="normal"
slotProps={{
input: SecondsInputProps
}}
/>
</Grid>
<Grid>
<TextField
name="publish_time_sensor"
label={LL.TEMP_SENSORS()}
variant="outlined"
value={numberValue(data.publish_time_sensor)}
type="number"
onChange={updateFormValue}
margin="normal"
slotProps={{
input: SecondsInputProps
}}
/>
</Grid>
<Grid>
<TextField
name="publish_time_other"
label={LL.DEFAULT(0)}
variant="outlined"
value={numberValue(data.publish_time_other)}
type="number"
onChange={updateFormValue}
margin="normal"
slotProps={{
input: SecondsInputProps
}}
/>
</Grid>
</Grid> </Grid>
{dirtyFlags && dirtyFlags.length !== 0 && ( {dirtyFlags && dirtyFlags.length !== 0 && (
<ButtonRow> <ButtonRow>
@@ -484,6 +493,13 @@ const MqttSettings = () => {
</ButtonRow> </ButtonRow>
)} )}
</> </>
);
};
return (
<SectionContent>
{blocker ? <BlockNavigation blocker={blocker} /> : null}
{content()}
</SectionContent> </SectionContent>
); );
}; };
+36 -211
View File
@@ -1,28 +1,14 @@
import { useState } from 'react'; import { useState } from 'react';
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 { import { Button, Checkbox, MenuItem } from '@mui/material';
Box,
Button,
Checkbox,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Grid,
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 { import {
BlockFormControlLabel, BlockFormControlLabel,
BlockNavigation, BlockNavigation,
@@ -32,20 +18,13 @@ import {
ValidatedTextField, ValidatedTextField,
useLayoutTitle useLayoutTitle
} from 'components'; } from 'components';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import type { NTPSettingsType, Time } from 'types'; import type { NTPSettingsType } from 'types';
import { formatLocalDateTime, updateValueDirty, useRest } from 'utils'; import { updateValueDirty, useRest } from 'utils';
import { ValidationError, validate } from 'validators'; import { validate } from 'validators';
import { NTP_SETTINGS_VALIDATOR } from 'validators/ntp'; import { NTP_SETTINGS_VALIDATOR } from 'validators/ntp';
import type { ValidateFieldsError } from 'validators/schema';
import { import { TIME_ZONES, selectedTimeZone, timeZoneSelectItems } from './TZ';
TIME_ZONES,
selectedTimeZone,
timeZoneSelectItemsT,
useTimeZoneSelectItems
} from './TZ';
const NTPSettings = () => { const NTPSettings = () => {
const { const {
@@ -65,96 +44,41 @@ const NTPSettings = () => {
}); });
const { LL } = useI18nContext(); const { LL } = useI18nContext();
useLayoutTitle('NTP'); useLayoutTitle(LL.SETTINGS_OF('NTP'));
const timeZoneItems = useTimeZoneSelectItems();
const timeZoneItemsT = timeZoneSelectItemsT();
const selectedTzValue = data
? selectedTimeZone(data.tz_label, data.tz_format)
: undefined;
const selectedTzValueT = data
? selectedTimeZone(data.tz_label_t, data.tz_format_t)
: undefined;
const [localTime, setLocalTime] = useState<string>('');
const [settingTime, setSettingTime] = useState<boolean>(false);
const [processing, setProcessing] = useState<boolean>(false);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const { send: updateTime } = useRequest(
(local_time: Time) => NTPApi.updateTime(local_time),
{
immediate: false
}
);
const updateFormValue = updateValueDirty( const updateFormValue = updateValueDirty(
origData as unknown as Record<string, unknown>, origData,
dirtyFlags, dirtyFlags,
setDirtyFlags, setDirtyFlags,
updateDataValue as (value: unknown) => void updateDataValue
); );
const updateLocalTime = (event: React.ChangeEvent<HTMLInputElement>) => const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
setLocalTime(event.target.value);
const openSetTime = () => { const content = () => {
setLocalTime(formatLocalDateTime(new Date()));
setSettingTime(true);
};
const configureTime = async () => {
setProcessing(true);
try {
await updateTime({ local_time: formatLocalDateTime(new Date(localTime)) });
toast.success(LL.TIME_SET());
setSettingTime(false);
await loadData();
} catch {
toast.error(LL.PROBLEM_UPDATING());
} finally {
setProcessing(false);
}
};
const handleCloseSetTime = () => setSettingTime(false);
const validateAndSubmit = async () => {
if (!data) return;
try {
setFieldErrors(undefined);
await validate(NTP_SETTINGS_VALIDATOR, data);
await saveData();
} catch (error) {
setFieldErrors((error as ValidationError).fieldErrors);
}
};
const changeTimeZone = (event: React.ChangeEvent<HTMLInputElement>) => {
void updateState(readNTPSettings(), (settings: NTPSettingsType) => ({
...settings,
tz_label: event.target.value,
tz_format: TIME_ZONES[event.target.value]
}));
updateFormValue(event);
};
const changeTimeZoneT = (event: React.ChangeEvent<HTMLInputElement>) => {
void updateState(readNTPSettings(), (settings: NTPSettingsType) => ({
...settings,
tz_label_t: event.target.value,
tz_format_t: TIME_ZONES[event.target.value]
}));
updateFormValue(event);
};
const renderContent = () => {
if (!data) { if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />; return <FormLoader onRetry={loadData} errorMessage={errorMessage} />;
} }
const validateAndSubmit = async () => {
try {
setFieldErrors(undefined);
await validate(NTP_SETTINGS_VALIDATOR, data);
await saveData();
} catch (error) {
setFieldErrors(error as ValidateFieldsError);
}
};
const changeTimeZone = (event: React.ChangeEvent<HTMLInputElement>) => {
updateFormValue(event);
void updateState(readNTPSettings(), (settings: NTPSettingsType) => ({
...settings,
tz_label: event.target.value,
tz_format: TIME_ZONES[event.target.value]
}));
};
return ( return (
<> <>
<BlockFormControlLabel <BlockFormControlLabel
@@ -168,88 +92,29 @@ 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
variant="outlined" variant="outlined"
disabled={!data.enabled}
value={data.server} value={data.server}
onChange={updateFormValue} onChange={updateFormValue}
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={selectedTzValue} value={selectedTimeZone(data.tz_label, data.tz_format)}
onChange={changeTimeZone} onChange={changeTimeZone}
margin="normal" margin="normal"
select select
> >
<MenuItem disabled>{LL.TIME_ZONE()}...</MenuItem> <MenuItem disabled>{LL.TIME_ZONE()}...</MenuItem>
{timeZoneItems} {timeZoneSelectItems()}
</ValidatedTextField> </ValidatedTextField>
{data.enabled && (
<Grid container spacing={2} rowSpacing={0}>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
name="thermostat_sync"
label="Sync EMS-Thermostat"
variant="outlined"
sx={{ width: '30ch' }}
value={data.thermostat_sync}
onChange={updateFormValue}
margin="normal"
select
>
<MenuItem value={0}>{LL.OFF()}</MenuItem>
<MenuItem value={1}>{LL.ON()}</MenuItem>
<MenuItem value={2}>
{LL.USE()} {LL.TIME_ZONE()}
</MenuItem>
</ValidatedTextField>
</Grid>
<Grid>
{data.thermostat_sync === 2 && (
<ValidatedTextField
fieldErrors={fieldErrors || {}}
name="tz_label_t"
label={LL.TIME_ZONE() + ' Thermostat'}
sx={{ width: '30ch' }}
variant="outlined"
value={selectedTzValueT}
onChange={changeTimeZoneT}
margin="normal"
select
>
<MenuItem disabled>{LL.TIME_ZONE()}...</MenuItem>
{timeZoneItemsT}
</ValidatedTextField>
)}
</Grid>
</Grid>
)}
<Box sx={{ display: 'flex', flexWrap: 'wrap' }}>
{!data.enabled && !dirtyFlags.length && (
<Box sx={{ 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
@@ -281,47 +146,7 @@ const NTPSettings = () => {
return ( return (
<SectionContent> <SectionContent>
{blocker ? <BlockNavigation blocker={blocker} /> : null} {blocker ? <BlockNavigation blocker={blocker} /> : null}
{renderContent()} {content()}
<Dialog sx={dialogStyle} open={settingTime} onClose={handleCloseSetTime}>
<DialogTitle>{LL.SET_TIME(1)}</DialogTitle>
<DialogContent dividers>
<Typography color="warning" variant="body2">
{LL.SET_TIME_TEXT()}
</Typography>
<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>
); );
}; };
+85 -17
View File
@@ -1,41 +1,94 @@
import { useContext } from 'react'; import { useState } from 'react';
import AccessTimeIcon from '@mui/icons-material/AccessTime'; import AccessTimeIcon from '@mui/icons-material/AccessTime';
import BuildIcon from '@mui/icons-material/Build'; import BuildIcon from '@mui/icons-material/Build';
import CancelIcon from '@mui/icons-material/Cancel';
import DeviceHubIcon from '@mui/icons-material/DeviceHub'; import DeviceHubIcon from '@mui/icons-material/DeviceHub';
import ImportExportIcon from '@mui/icons-material/ImportExport'; import ImportExportIcon from '@mui/icons-material/ImportExport';
import LockIcon from '@mui/icons-material/Lock'; import LockIcon from '@mui/icons-material/Lock';
import SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore';
import SettingsEthernetIcon from '@mui/icons-material/SettingsEthernet'; import SettingsEthernetIcon from '@mui/icons-material/SettingsEthernet';
import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna'; import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna';
import TuneIcon from '@mui/icons-material/Tune'; import TuneIcon from '@mui/icons-material/Tune';
import ViewModuleIcon from '@mui/icons-material/ViewModule'; import ViewModuleIcon from '@mui/icons-material/ViewModule';
import { List } from '@mui/material'; import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
List
} from '@mui/material';
import { API, callAction } from 'api/app';
import { dialogStyle } from 'CustomTheme';
import { useRequest } from 'alova/client';
import type { APIcall } from 'app/main/types';
import { SectionContent, useLayoutTitle } from 'components'; import { SectionContent, useLayoutTitle } from 'components';
import ListMenuItem from 'components/layout/ListMenuItem'; import ListMenuItem from 'components/layout/ListMenuItem';
import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
const Settings = () => { const Settings = () => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const { versions } = useContext(AuthenticatedContext);
useLayoutTitle(LL.SETTINGS(0)); useLayoutTitle(LL.SETTINGS(0));
const upgradeAvailable = versions?.current?.upgradeable ?? false; const [confirmFactoryReset, setConfirmFactoryReset] = useState<boolean>(false);
const firmwareText = versions?.current?.version
? `v${versions.current.version}${upgradeAvailable ? ` (${LL.UPDATE_AVAILABLE()})` : ''}`
: '';
return ( const { send: sendAPI } = useRequest((data: APIcall) => API(data), {
<SectionContent> immediate: false
<List> });
// call checkUpgrade with no param to fetch EMS-ESP version
const { data } = useRequest(() => callAction({ action: 'checkUpgrade' }), {
initialData: { emsesp_version: '...' }
});
const doFormat = async () => {
await sendAPI({ device: 'system', cmd: 'format', id: 0 }).then(() => {
setConfirmFactoryReset(false);
});
};
const renderFactoryResetDialog = () => (
<Dialog
sx={dialogStyle}
open={confirmFactoryReset}
onClose={() => setConfirmFactoryReset(false)}
>
<DialogTitle>{LL.FACTORY_RESET()}</DialogTitle>
<DialogContent dividers>{LL.SYSTEM_FACTORY_TEXT_DIALOG()}</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={() => setConfirmFactoryReset(false)}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<SettingsBackupRestoreIcon />}
variant="outlined"
onClick={doFormat}
color="error"
>
{LL.FACTORY_RESET()}
</Button>
</DialogActions>
</Dialog>
);
const content = () => (
<>
<List sx={{ borderRadius: 3, border: '2px solid grey' }}>
<ListMenuItem <ListMenuItem
icon={BuildIcon} icon={BuildIcon}
bgcolor="#72caf9" bgcolor="#72caf9"
label="EMS-ESP Firmware" label={LL.EMS_ESP_VER()}
text={firmwareText} text={data.emsesp_version}
to="/settings/version" to="version"
badge={upgradeAvailable}
/> />
<ListMenuItem <ListMenuItem
@@ -98,11 +151,26 @@ const Settings = () => {
bgcolor="#5d89f7" bgcolor="#5d89f7"
label={LL.DOWNLOAD_UPLOAD()} label={LL.DOWNLOAD_UPLOAD()}
text={LL.DOWNLOAD_UPLOAD_1()} text={LL.DOWNLOAD_UPLOAD_1()}
to="downloadUpload" to="upload"
/> />
</List> </List>
</SectionContent>
{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;
+8 -19
View File
@@ -1,6 +1,8 @@
import { MenuItem } from '@mui/material'; import { MenuItem } from '@mui/material';
export const TIME_ZONES: Record<string, string> = { type TimeZones = Record<string, string>;
export const TIME_ZONES: TimeZones = {
'Africa/Abidjan': 'GMT0', 'Africa/Abidjan': 'GMT0',
'Africa/Accra': 'GMT0', 'Africa/Accra': 'GMT0',
'Africa/Addis_Ababa': 'EAT-3', 'Africa/Addis_Ababa': 'EAT-3',
@@ -463,27 +465,14 @@ export const TIME_ZONES: Record<string, string> = {
'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;
} }
const precomputedTimeZoneItems = TIME_ZONE_LABELS.map((label) => (
<MenuItem key={label} value={label}>
{label}
</MenuItem>
));
export function useTimeZoneSelectItems() {
return precomputedTimeZoneItems;
}
export function timeZoneSelectItems() { export function timeZoneSelectItems() {
return precomputedTimeZoneItems; return Object.keys(TIME_ZONES).map((label) => (
} <MenuItem key={label} value={label}>
{label}
export function timeZoneSelectItemsT() { </MenuItem>
return precomputedTimeZoneItems; ));
} }
File diff suppressed because it is too large Load Diff
+31 -25
View File
@@ -1,51 +1,57 @@
import { memo, useState } from 'react'; import { useCallback, useState } from 'react';
import { Outlet, useMatch, useNavigate } from 'react-router'; import { Navigate, Route, Routes, useNavigate } from 'react-router-dom';
import { Tab } from '@mui/material'; import { Tab } from '@mui/material';
import { RouterTabs, useLayoutTitle } from 'components'; import { RouterTabs, useLayoutTitle, useRouterTab } from 'components';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import type { WiFiNetwork } from 'types'; import type { WiFiNetwork } from 'types';
import NetworkSettings from './NetworkSettings';
import { WiFiConnectionContext } from './WiFiConnectionContext'; import { WiFiConnectionContext } from './WiFiConnectionContext';
import WiFiNetworkScanner from './WiFiNetworkScanner';
const Network = () => { const Network = () => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
useLayoutTitle(LL.NETWORK(0)); useLayoutTitle(LL.SETTINGS_OF(LL.NETWORK(0)));
const routerTab = useMatch('/settings/network/:tab')?.pathname || false; const { routerTab } = useRouterTab();
const navigate = useNavigate(); const navigate = useNavigate();
const [selectedNetwork, setSelectedNetwork] = useState<WiFiNetwork>(); const [selectedNetwork, setSelectedNetwork] = useState<WiFiNetwork>();
const selectNetwork = (network: WiFiNetwork) => { const selectNetwork = useCallback(
setSelectedNetwork(network); (network: WiFiNetwork) => {
void navigate('/settings/network/settings'); setSelectedNetwork(network);
}; navigate('settings');
},
[navigate]
);
const deselectNetwork = () => { const deselectNetwork = useCallback(() => {
setSelectedNetwork(undefined); setSelectedNetwork(undefined);
}; }, []);
const contextValue = {
...(selectedNetwork && { selectedNetwork }),
selectNetwork,
deselectNetwork
};
return ( return (
<WiFiConnectionContext.Provider value={contextValue}> <WiFiConnectionContext.Provider
value={{
selectedNetwork,
selectNetwork,
deselectNetwork
}}
>
<RouterTabs value={routerTab}> <RouterTabs value={routerTab}>
<Tab <Tab value="settings" label={LL.SETTINGS_OF(LL.NETWORK(1))} />
value="/settings/network/settings" <Tab value="scan" label={LL.NETWORK_SCAN()} />
label={LL.SETTINGS_OF(LL.NETWORK(1))}
/>
<Tab value="/settings/network/scan" label={LL.NETWORK_SCAN()} />
</RouterTabs> </RouterTabs>
<Outlet /> <Routes>
<Route path="scan" element={<WiFiNetworkScanner />} />
<Route path="settings" element={<NetworkSettings />} />
<Route path="*" element={<Navigate replace to="settings" />} />
</Routes>
</WiFiConnectionContext.Provider> </WiFiConnectionContext.Provider>
); );
}; };
export default memo(Network); export default Network;

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