mirror of
https://github.com/emsesp/EMS-ESP32.git
synced 2026-03-26 19:46:32 +03:00
Compare commits
62 Commits
32474d10ce
...
v3.7.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f444ca31e0 | ||
|
|
1b70b55989 | ||
|
|
1487f30c43 | ||
|
|
e00eb8e64f | ||
|
|
f41bb3671c | ||
|
|
22c75e6df3 | ||
|
|
5ab10b7aa6 | ||
|
|
ee5fd4d0eb | ||
|
|
46f35bc67c | ||
|
|
ec85a7ec24 | ||
|
|
02f2389587 | ||
|
|
7f140021aa | ||
|
|
6796962c1e | ||
|
|
df6de21cf4 | ||
|
|
df9f75a5c9 | ||
|
|
7bd8710eb6 | ||
|
|
32f2c6d341 | ||
|
|
86919c1684 | ||
|
|
86e29515e7 | ||
|
|
46eb4185d7 | ||
|
|
8da6761a48 | ||
|
|
9233f0dfcc | ||
|
|
292f743b14 | ||
|
|
dd6dfffd57 | ||
|
|
ec705a5307 | ||
|
|
f45f071710 | ||
|
|
f3858546de | ||
|
|
d0ac0b7804 | ||
|
|
d8284ec09f | ||
|
|
6e982acde8 | ||
|
|
8c94ce99b2 | ||
|
|
fc057d18c9 | ||
|
|
18e9b99413 | ||
|
|
a47e0e8266 | ||
|
|
f412ddc716 | ||
|
|
29110e96e5 | ||
|
|
b65866217a | ||
|
|
611e3b1243 | ||
|
|
2ca0a0c634 | ||
|
|
7eb1f061b7 | ||
|
|
50459a23fe | ||
|
|
5bf53c3389 | ||
|
|
4b7aa95be3 | ||
|
|
70943f5758 | ||
|
|
3bc280b817 | ||
|
|
62b15a5319 | ||
|
|
8dd18802d6 | ||
|
|
57a516a83a | ||
|
|
a57fdaa4b3 | ||
|
|
4841e42286 | ||
|
|
8c2d2b06ed | ||
|
|
38c8b1b7f0 | ||
|
|
6fb5933a02 | ||
|
|
c0944433be | ||
|
|
478e6362c9 | ||
|
|
4d6354db78 | ||
|
|
beab0f0c77 | ||
|
|
c17749bd22 | ||
|
|
2bad769c5c | ||
|
|
8ad89ca64b | ||
|
|
9244d8daec | ||
|
|
02d01334b2 |
@@ -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
.github/CODE_OF_CONDUCT.md
vendored
132
.github/CODE_OF_CONDUCT.md
vendored
@@ -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
|
||||
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -21,7 +21,7 @@ _Make sure your have performed every step and checked the applicable boxes befor
|
||||
|
||||
- [ ] Searched the issue in [issues](https://github.com/emsesp/EMS-ESP32/issues)
|
||||
- [ ] Searched the issue in [discussions](https://github.com/emsesp/EMS-ESP32/discussions)
|
||||
- [ ] Searched the issue in the [docs](https://docs.emsesp.org/Troubleshooting/)
|
||||
- [ ] Searched the issue in the [docs](https://emsesp.org/Troubleshooting/)
|
||||
- [ ] Searched the issue in the [chat](https://discord.gg/3J3GgnzpyT)
|
||||
- [ ] Provide the System information in the area below, taken from `http://<IP>/api/system`
|
||||
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/config.yml
vendored
2
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,7 +1,7 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: EMS-ESP Docs
|
||||
url: https://docs.emsesp.org
|
||||
url: https://emsesp.org
|
||||
about: All the information related to EMS-ESP.
|
||||
- name: EMS-ESP Discussions and Support
|
||||
url: https://github.com/emsesp/EMS-ESP32/discussions
|
||||
|
||||
1
.github/SUPPORT.md
vendored
1
.github/SUPPORT.md
vendored
@@ -1 +0,0 @@
|
||||
# Support
|
||||
101
.github/workflows/dev_release.yml
vendored
101
.github/workflows/dev_release.yml
vendored
@@ -1,101 +0,0 @@
|
||||
name: 'Build dev release'
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
paths:
|
||||
- 'src/emsesp_version.h'
|
||||
branches:
|
||||
- 'dev'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
pre-release:
|
||||
name: 'Build Dev Release'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
- name: Install python 3.13
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Install Node.js 24
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Enable Corepack
|
||||
run: corepack enable pnpm
|
||||
|
||||
- name: Get the EMS-ESP version
|
||||
id: build_info
|
||||
run: |
|
||||
version=`grep -E '^#define EMSESP_APP_VERSION' ./src/emsesp_version.h | awk -F'"' '{print $2}'`
|
||||
echo "VERSION=$version" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Install PlatformIO
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -U platformio
|
||||
python -m pip install intelhex
|
||||
|
||||
- name: Build webUI
|
||||
run: |
|
||||
platformio run -e build_webUI
|
||||
|
||||
- name: Build modbus
|
||||
run: |
|
||||
platformio run -e build_modbus
|
||||
|
||||
- name: Build standalone
|
||||
run: |
|
||||
platformio run -e build_standalone
|
||||
|
||||
- name: Build all PIO target environments, from default_envs
|
||||
run: |
|
||||
platformio run
|
||||
|
||||
- name: Commit the generated files
|
||||
uses: stefanzweifel/git-auto-commit-action@v5
|
||||
with:
|
||||
commit_message: "chore: update generated files"
|
||||
|
||||
- name: Configure Git
|
||||
run: |
|
||||
git config --local user.email "action@github.com"
|
||||
git config --local user.name "GitHub Action"
|
||||
|
||||
- name: Check for changes and commit
|
||||
run: |
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
echo "Changes detected, committing..."
|
||||
git add .
|
||||
git commit -m "Auto-commit build artifacts and configuration updates
|
||||
|
||||
- Updated build configurations
|
||||
- Generated build artifacts
|
||||
- Version: ${{steps.build_info.outputs.VERSION}}"
|
||||
|
||||
echo "Pushing changes to repository..."
|
||||
git push origin dev
|
||||
else
|
||||
echo "No changes to commit"
|
||||
fi
|
||||
|
||||
- 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/*.*
|
||||
@@ -1,17 +1,16 @@
|
||||
name: 'Publish releases to discord'
|
||||
name: 'github-releases-to-discord'
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
github-releases-to-discord:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: GitHub Releases To Discord
|
||||
uses: SethCohen/github-releases-to-discord@v1.13.1
|
||||
|
||||
32
.github/workflows/pr_check.yml
vendored
32
.github/workflows/pr_check.yml
vendored
@@ -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@v5
|
||||
|
||||
- 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
.github/workflows/pre_release.yml
vendored
Normal file
64
.github/workflows/pre_release.yml
vendored
Normal 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/*.*
|
||||
27
.github/workflows/sonar_check.yml
vendored
27
.github/workflows/sonar_check.yml
vendored
@@ -1,14 +1,12 @@
|
||||
# see https://github.com/marketplace/actions/sonarcloud-scan-for-c-and-c#usage
|
||||
name: Sonar Check
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
paths:
|
||||
- 'src/**'
|
||||
# pull_request:
|
||||
# types: [opened, synchronize, reopened]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -19,15 +17,18 @@ jobs:
|
||||
BUILD_WRAPPER_OUT_DIR: bw-output
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install Build Wrapper
|
||||
uses: SonarSource/sonarqube-scan-action/install-build-wrapper@master
|
||||
- name: Run Build Wrapper
|
||||
run: build-wrapper-linux-x86-64 --out-dir ${{ env.BUILD_WRAPPER_OUT_DIR }} make all
|
||||
- name: SonarQube Scan
|
||||
uses: SonarSource/sonarqube-scan-action@master
|
||||
|
||||
- name: Install sonar-scanner and build-wrapper
|
||||
uses: SonarSource/sonarcloud-github-c-cpp@v2
|
||||
|
||||
- name: Run build-wrapper
|
||||
run: build-wrapper-linux-x86-64 --out-dir ${{ env.BUILD_WRAPPER_OUT_DIR }} make all
|
||||
|
||||
- name: Run sonar-scanner
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
|
||||
run: sonar-scanner --define sonar.cfamily.compile-commands="${{ env.BUILD_WRAPPER_OUT_DIR }}/compile_commands.json"
|
||||
|
||||
63
.github/workflows/stable_release.yml
vendored
63
.github/workflows/stable_release.yml
vendored
@@ -1,63 +0,0 @@
|
||||
name: 'Build stable release'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
tagged-release:
|
||||
name: 'Build Stable Release'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
- name: Install python 3.13
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Install Node.js 24
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Enable Corepack
|
||||
run: corepack enable pnpm
|
||||
|
||||
- name: Install PlatformIO
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -U platformio
|
||||
python -m pip install intelhex
|
||||
|
||||
- name: Build webUI
|
||||
run: |
|
||||
platformio run -e build_webUI
|
||||
|
||||
- name: Build modbus
|
||||
run: |
|
||||
platformio run -e build_modbus
|
||||
|
||||
- name: Build standalone
|
||||
run: |
|
||||
platformio run -e build_standalone
|
||||
|
||||
- name: Build all PIO target environments, from default_envs
|
||||
run: |
|
||||
platformio run
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: emsesp/action-automatic-releases@v1.0.0
|
||||
with:
|
||||
repo_token: '${{ secrets.GITHUB_TOKEN }}'
|
||||
prerelease: false
|
||||
files: |
|
||||
CHANGELOG.md
|
||||
./build/firmware/*.*
|
||||
27
.github/workflows/stale_issues.yml
vendored
27
.github/workflows/stale_issues.yml
vendored
@@ -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
.github/workflows/tagged_release.yml
vendored
Normal file
55
.github/workflows/tagged_release.yml
vendored
Normal 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/*.*
|
||||
65
.github/workflows/test_release.yml
vendored
65
.github/workflows/test_release.yml
vendored
@@ -1,66 +1,46 @@
|
||||
name: 'Build test release'
|
||||
name: 'test-release'
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- 'test'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
- 'dev2'
|
||||
|
||||
jobs:
|
||||
pre-release:
|
||||
name: 'Build Test Release'
|
||||
name: 'Automatic test-release build'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
|
||||
- name: Install python 3.13
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Install Node.js 24
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
- name: Enable Corepack
|
||||
run: corepack enable pnpm
|
||||
|
||||
- name: Get the EMS-ESP version
|
||||
run: corepack enable
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Use Node.js 20.x
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.x'
|
||||
- name: Get EMS-ESP source code and version
|
||||
id: build_info
|
||||
run: |
|
||||
version=`grep -E '^#define EMSESP_APP_VERSION' ./src/emsesp_version.h | awk -F'"' '{print $2}'`
|
||||
version=`grep -E '^#define EMSESP_APP_VERSION' ./src/version.h | awk -F'"' '{print $2}'`
|
||||
echo "VERSION=$version" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Install PlatformIO
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -U platformio
|
||||
python -m pip install intelhex
|
||||
|
||||
- name: Build webUI
|
||||
- name: Build WebUI
|
||||
run: |
|
||||
platformio run -e build_webUI
|
||||
|
||||
- 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
|
||||
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 target environments from default_envs
|
||||
run: |
|
||||
platformio run
|
||||
|
||||
- name: Create GitHub Release
|
||||
id: 'automatic_releases'
|
||||
uses: emsesp/action-automatic-releases@v1.0.0
|
||||
@@ -72,4 +52,3 @@ jobs:
|
||||
files: |
|
||||
CHANGELOG_LATEST.md
|
||||
./build/firmware/*.*
|
||||
|
||||
|
||||
22
.gitignore
vendored
22
.gitignore
vendored
@@ -12,15 +12,17 @@ cppcheck.out.xml
|
||||
# platformio
|
||||
.pio
|
||||
pio_local.ini
|
||||
*_old
|
||||
|
||||
# OS specific
|
||||
.DS_Store
|
||||
*Thumbs.db
|
||||
|
||||
# web specific
|
||||
# web specfic
|
||||
build/
|
||||
dist/
|
||||
/data/www
|
||||
/lib/framework/WWWData.h
|
||||
/interface/build
|
||||
node_modules
|
||||
/interface/.eslintcache
|
||||
@@ -28,10 +30,16 @@ stats.html
|
||||
*.sln
|
||||
*.sw?
|
||||
.pnp.*
|
||||
*/.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/sdks
|
||||
!.yarn/versions
|
||||
yarn.lock
|
||||
analyse.html
|
||||
interface/vite.config.ts.timestamp*
|
||||
*.local
|
||||
src/ESP32React/WWWData.h
|
||||
|
||||
# i18n generated files
|
||||
interface/src/i18n/i18n-react.tsx
|
||||
@@ -63,14 +71,4 @@ words-found-verbose.txt
|
||||
|
||||
# sonarlint
|
||||
compile_commands.json
|
||||
|
||||
# pioarduino + hybrid
|
||||
managed_components
|
||||
dependencies.lock
|
||||
CMakeLists.txt
|
||||
.dummy/*
|
||||
logs/*
|
||||
sdkconfig.*
|
||||
sdkconfig_tasmota_esp32
|
||||
pnpm-lock.yaml
|
||||
package.json
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"MD033": false,
|
||||
"MD013": false,
|
||||
"MD045": false,
|
||||
"MD041": false
|
||||
}
|
||||
77
CHANGELOG.md
77
CHANGELOG.md
@@ -5,78 +5,7 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [3.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
|
||||
## [3.7.0] October 27 2024
|
||||
|
||||
## **IMPORTANT! BREAKING CHANGES with 3.6.5**
|
||||
|
||||
@@ -85,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.
|
||||
- `api/system/info` has it's JSON key names changed to camelCase syntax.
|
||||
|
||||
For more details go to [docs.emsesp.org](https://docs.emsesp.org/).
|
||||
For more details go to [www.emsesp.org](https://www.emsesp.org/).
|
||||
|
||||
## Added
|
||||
|
||||
@@ -217,7 +146,7 @@ For more details go to [docs.emsesp.org](https://docs.emsesp.org/).
|
||||
|
||||
## **IMPORTANT! BREAKING CHANGES**
|
||||
|
||||
Writeable Text entities have moved from type `sensor` to `text` in Home Assistant to make them also editable within an HA dashboard. Examples are `datetime`, `holidays`, `switchtime`, `vacations`, `maintenancedate`... You will need to manually remove any old discovery topics from your MQTT broker using an application like MQTT Explorer.
|
||||
Writeable Text entities have moved from type `sensor` to `text` in Home Assistant to make them also editable within an HA dashboard. Examples are `datetime`, `holidays`, `switchtime`, `vacations`, `maintenancedate`. You will need to manually remove any old discovery topics from your MQTT broker using an application like MQTT Explorer.
|
||||
|
||||
## Added
|
||||
|
||||
|
||||
@@ -1,64 +1 @@
|
||||
# Changelog
|
||||
|
||||
For more details go to [docs.emsesp.org](https://docs.emsesp.org/).
|
||||
|
||||
## [3.7.3]
|
||||
|
||||
## 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)
|
||||
- add 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)
|
||||
- Fuse settings for BBQKees boards
|
||||
- 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)
|
||||
|
||||
## 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)
|
||||
|
||||
## Changed
|
||||
|
||||
- show console log with ISO date/time [#2533](https://github.com/emsesp/EMS-ESP32/discussions/2533)
|
||||
- remove 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
|
||||
|
||||
@@ -6,7 +6,7 @@ Everybody is welcome and invited to contribute to the EMS-ESP Project by:
|
||||
|
||||
- providing Pull Requests (Features, Fixes, suggestions)
|
||||
- testing new released features and report issues on your EMS equipment
|
||||
- contributing to missing [documentation](https://docs.emsesp.org)
|
||||
- contributing to missing [documentation](https://emsesp.org)
|
||||
|
||||
This document describes rules that are in effect for this repository, meant for handling issues by contributors in the issue tracker and PRs.
|
||||
|
||||
@@ -69,7 +69,7 @@ Format: `<type>(<scope>): <subject>`
|
||||
|
||||
## Example
|
||||
|
||||
```text
|
||||
```
|
||||
feat: add hat wobble
|
||||
^--^ ^------------^
|
||||
| |
|
||||
@@ -96,7 +96,7 @@ References:
|
||||
|
||||
## Contributor License Agreement (CLA)
|
||||
|
||||
```text
|
||||
```
|
||||
By making a contribution to this project, I certify that:
|
||||
|
||||
(a) The contribution was created in whole or in part by me and I
|
||||
|
||||
123
Makefile
123
Makefile
@@ -1,39 +1,10 @@
|
||||
#
|
||||
# GNUMakefile for EMS-ESP
|
||||
# This is mainly used to generate the .o files for SonarQube analysis
|
||||
#
|
||||
|
||||
_mkfile_path := $(abspath $(lastword $(MAKEFILE_LIST)))
|
||||
I := $(patsubst %/,%,$(dir $(_mkfile_path)))
|
||||
|
||||
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)
|
||||
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))
|
||||
# NUMJOBS=${NUMJOBS:-" -j10 "}
|
||||
# MAKEFLAGS+="j "
|
||||
|
||||
#----------------------------------------------------------------------
|
||||
# Project Structure
|
||||
@@ -44,20 +15,26 @@ MAKEFLAGS += -j$(JOBS) -l$(shell echo $$(($(JOBS) * 2)))
|
||||
# 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
|
||||
#----------------------------------------------------------------------
|
||||
|
||||
#TARGET := $(notdir $(CURDIR))
|
||||
TARGET := emsesp
|
||||
BUILD := build
|
||||
SOURCES := src/core src/devices src/web src/test lib_standalone lib/semver lib/espMqttClient/src lib/espMqttClient/src/* lib/ArduinoJson/src lib/uuid-common/src lib/uuid-console/src lib/uuid-log/src lib/PButton
|
||||
INCLUDES := src/core src/devices src/web src/test lib/* lib_standalone lib/semver lib/espMqttClient/src lib/espMqttClient/src/Transport lib/ArduinoJson/src lib/uuid-common/src lib/uuid-console/src lib/uuid-log/src lib/uuid-telnet/src lib/uuid-syslog/src
|
||||
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 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 :=
|
||||
|
||||
CPPCHECK = cppcheck
|
||||
CHECKFLAGS = -q --force --std=gnu++17
|
||||
# CHECKFLAGS = -q --force --std=c++17
|
||||
CHECKFLAGS = -q --force --std=c++11
|
||||
|
||||
#----------------------------------------------------------------------
|
||||
# Languages Standard
|
||||
#----------------------------------------------------------------------
|
||||
C_STANDARD := -std=c17
|
||||
CXX_STANDARD := -std=gnu++17
|
||||
CXX_STANDARD := -std=gnu++14
|
||||
|
||||
# C_STANDARD := -std=c11
|
||||
# CXX_STANDARD := -std=c++11
|
||||
|
||||
#----------------------------------------------------------------------
|
||||
# Defined Symbols
|
||||
@@ -66,7 +43,7 @@ DEFINES += -DARDUINOJSON_ENABLE -DARDUINOJSON_ENABLE_ARDUINO_STRING -DARDUINOJSO
|
||||
DEFINES += -DEMSESP_STANDALONE -DEMSESP_TEST -DEMSESP_DEBUG -DEMC_RX_BUFFER_SIZE=1500
|
||||
DEFINES += $(ARGS)
|
||||
|
||||
DEFAULTS = -DEMSESP_DEFAULT_LOCALE=\"en\" -DEMSESP_DEFAULT_TX_MODE=8 -DEMSESP_DEFAULT_VERSION=\"3.7.3-dev\" -DEMSESP_DEFAULT_BOARD_PROFILE=\"S32S3\"
|
||||
DEFAULTS = -DEMSESP_DEFAULT_LOCALE=\"en\" -DEMSESP_DEFAULT_TX_MODE=8 -DEMSESP_DEFAULT_VERSION=\"3.7.0-dev\" -DEMSESP_DEFAULT_BOARD_PROFILE=\"S32\"
|
||||
|
||||
#----------------------------------------------------------------------
|
||||
# Sources & Files
|
||||
@@ -74,21 +51,16 @@ DEFAULTS = -DEMSESP_DEFAULT_LOCALE=\"en\" -DEMSESP_DEFAULT_TX_MODE=8 -DEMSESP_DE
|
||||
OUTPUT := $(CURDIR)/$(TARGET)
|
||||
SYMBOLS := $(CURDIR)/$(BUILD)/$(TARGET).out
|
||||
|
||||
# Optimize source discovery - use shell find for better performance
|
||||
CSOURCES := $(shell find $(SOURCES) -name "*.c" 2>/dev/null)
|
||||
CXXSOURCES := $(shell find $(SOURCES) -name "*.cpp" 2>/dev/null)
|
||||
CSOURCES := $(foreach dir,$(SOURCES),$(wildcard $(dir)/*.c))
|
||||
CXXSOURCES := $(foreach dir,$(SOURCES),$(wildcard $(dir)/*.cpp))
|
||||
|
||||
OBJS := $(patsubst %,$(BUILD)/%.o,$(basename $(CSOURCES)) $(basename $(CXXSOURCES)))
|
||||
DEPS := $(patsubst %,$(BUILD)/%.d,$(basename $(CSOURCES)) $(basename $(CXXSOURCES)))
|
||||
OBJS := $(patsubst %,$(BUILD)/%.o,$(basename $(CSOURCES)) $(basename $(CXXSOURCES)) )
|
||||
DEPS := $(patsubst %,$(BUILD)/%.d,$(basename $(CSOURCES)) $(basename $(CXXSOURCES)) )
|
||||
|
||||
# Optimize include path discovery
|
||||
INCLUDE_DIRS := $(shell find $(INCLUDES) -type d 2>/dev/null)
|
||||
LIBRARY_INCLUDES := $(shell find $(LIBRARIES) -name "include" -type d 2>/dev/null)
|
||||
INCLUDE += $(addprefix -I,$(INCLUDE_DIRS) $(LIBRARY_INCLUDES))
|
||||
INCLUDE += $(addprefix -I,$(foreach dir,$(INCLUDES), $(wildcard $(dir))))
|
||||
INCLUDE += $(addprefix -I,$(foreach dir,$(LIBRARIES),$(wildcard $(dir)/include)))
|
||||
|
||||
# Optimize library path discovery
|
||||
LIBRARY_DIRS := $(shell find $(LIBRARIES) -name "lib" -type d 2>/dev/null)
|
||||
LDLIBS += $(addprefix -L,$(LIBRARY_DIRS))
|
||||
LDLIBS += $(addprefix -L,$(foreach dir,$(LIBRARIES),$(wildcard $(dir)/lib)))
|
||||
|
||||
#----------------------------------------------------------------------
|
||||
# Compiler & Linker
|
||||
@@ -105,18 +77,14 @@ CXX := /usr/bin/g++
|
||||
# LDFLAGS Linker Flags
|
||||
#----------------------------------------------------------------------
|
||||
CPPFLAGS += $(DEFINES) $(DEFAULTS) $(INCLUDE)
|
||||
CPPFLAGS += -ggdb -g3 -MMD
|
||||
CPPFLAGS += -flto=auto
|
||||
CPPFLAGS += -Wall -Wextra -Werror -Wswitch-enum
|
||||
CPPFLAGS += -Wno-unused-parameter -Wno-missing-braces -Wno-vla-cxx-extension
|
||||
CPPFLAGS += -ffunction-sections -fdata-sections -fno-exceptions -fno-rtti -fno-threadsafe-statics
|
||||
CPPFLAGS += -Os -DNDEBUG
|
||||
|
||||
CPPFLAGS += $(EXTRA_CPPFLAGS)
|
||||
CPPFLAGS += -ggdb
|
||||
CPPFLAGS += -g3
|
||||
CPPFLAGS += -Os
|
||||
|
||||
CFLAGS += $(CPPFLAGS)
|
||||
CXXFLAGS += $(CPPFLAGS)
|
||||
LDFLAGS =
|
||||
CFLAGS += -Wall -Wextra -Werror -Wswitch-enum
|
||||
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
|
||||
@@ -131,8 +99,7 @@ else
|
||||
LD := $(CXX)
|
||||
endif
|
||||
|
||||
# Dependency file generation
|
||||
DEPFLAGS += -MF $(BUILD)/$*.d -MT $@
|
||||
#DEPFLAGS += -MF $(BUILD)/$*.d
|
||||
|
||||
LINK.o = $(LD) $(LDFLAGS) $(LDLIBS) $^ -o $@
|
||||
COMPILE.c = $(CC) $(C_STANDARD) $(CFLAGS) $(DEPFLAGS) -c $< -o $@
|
||||
@@ -149,10 +116,7 @@ COMPILE.cpp = $(CXX) $(CXX_STANDARD) $(CXXFLAGS) $(DEPFLAGS) -c $< -o $@
|
||||
.SUFFIXES:
|
||||
.INTERMEDIATE:
|
||||
.PRECIOUS: $(OBJS) $(DEPS)
|
||||
.PHONY: all clean help cppcheck run
|
||||
|
||||
# Enable second expansion for more flexible rules
|
||||
.SECONDEXPANSION:
|
||||
.PHONY: all clean help
|
||||
|
||||
#----------------------------------------------------------------------
|
||||
# Targets
|
||||
@@ -161,26 +125,23 @@ COMPILE.cpp = $(CXX) $(CXX_STANDARD) $(CXXFLAGS) $(DEPFLAGS) -c $< -o $@
|
||||
.SILENT: $(OUTPUT)
|
||||
|
||||
all: $(OUTPUT)
|
||||
@$(ECHO) Build complete.
|
||||
|
||||
$(OUTPUT): $(OBJS)
|
||||
@mkdir -p $(@D)
|
||||
@$(ECHO) Linking $@
|
||||
$(LINK.o)
|
||||
|
||||
$(SYMBOLS.out)
|
||||
|
||||
$(BUILD)/%.o: %.c
|
||||
@mkdir -p $(@D)
|
||||
@$(ECHO) Compiling $@
|
||||
@$(COMPILE.c)
|
||||
$(COMPILE.c)
|
||||
|
||||
$(BUILD)/%.o: %.cpp
|
||||
@mkdir -p $(@D)
|
||||
@$(ECHO) Compiling $@
|
||||
@$(COMPILE.cpp)
|
||||
$(COMPILE.cpp)
|
||||
|
||||
$(BUILD)/%.o: %.s
|
||||
@mkdir -p $(@D)
|
||||
@$(COMPILE.s)
|
||||
$(COMPILE.s)
|
||||
|
||||
cppcheck: $(SOURCES)
|
||||
$(CPPCHECK) $(CHECKFLAGS) $^
|
||||
@@ -189,21 +150,11 @@ run: $(OUTPUT)
|
||||
@$<
|
||||
|
||||
.PHONY: clean
|
||||
|
||||
clean:
|
||||
@$(RM) -rf $(BUILD) $(OUTPUT)
|
||||
|
||||
help:
|
||||
@echo "Available targets:"
|
||||
@echo " all - Build the project (default)"
|
||||
@echo " run - Build and run the executable"
|
||||
@echo " clean - Remove build artifacts"
|
||||
@echo " cppcheck - Run static analysis"
|
||||
@echo " help - Show this help message"
|
||||
@echo ""
|
||||
@echo "Output: $(OUTPUT)"
|
||||
@echo "Jobs: $(JOBS)"
|
||||
@echo available targets: all run clean
|
||||
@echo $(OUTPUT)
|
||||
|
||||
-include $(DEPS)
|
||||
|
||||
endif
|
||||
-include $(DEPS)
|
||||
66
README.md
66
README.md
@@ -1,30 +1,4 @@
|
||||
<div align="center">
|
||||
<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://docs.emsesp.org">
|
||||
<img src="https://img.shields.io/badge/Documentation-0077b5?style=for-the-badge&logo=googledocs&logoColor=white" alt="Guides" />
|
||||
</a>
|
||||
<a href="https://discord.gg/3J3GgnzpyT">
|
||||
<img src="https://img.shields.io/badge/Discord-7289da?style=for-the-badge&logo=discord&logoColor=white" alt="Discord" />
|
||||
</a>
|
||||
<a href="https://github.com/emsesp/EMS-ESP32/blob/main/CHANGELOG.md">
|
||||
<img src="https://img.shields.io/badge/Changelog-6c5ce7?style=for-the-badge&logo=git&logoColor=white" alt="Changelog" />
|
||||
</a>
|
||||
</p>
|
||||
# 
|
||||
|
||||
[](https://github.com/emsesp/EMS-ESP32/blob/main/CHANGELOG.md)
|
||||
[](https://github.com/emsesp/EMS-ESP32/commits/main)
|
||||
@@ -35,14 +9,14 @@
|
||||
[](https://discord.gg/3J3GgnzpyT)
|
||||
|
||||
[](https://github.com/emsesp/EMS-ESP32/stargazers)
|
||||
[](https://github.com/emsesp/EMS-ESP32/network)
|
||||
[](https://github.com/emsesp/EMS-ES32P/network)
|
||||
[](https://www.paypal.com/paypalme/prderbyshire/2)
|
||||
|
||||
**EMS-ESP** is an open-source firmware for the Espressif ESP32 microcontroller to communicate with **EMS** (Energy Management System) compatible equipment from manufacturers such as Bosch, Buderus, Nefit, Junkers, Worcester, Sieger, elm.leblanc and iVT.
|
||||
|
||||
It requires a small circuit to interface with the EMS bus which can be purchased from <https://bbqkees-electronics.nl> or custom built.
|
||||
|
||||
## 📦 **Key Features**
|
||||
## **Key Features**
|
||||
|
||||
- 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
|
||||
@@ -58,45 +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 Notification service to alert you of important events
|
||||
|
||||
## 🚀 **Installing**
|
||||
## **Installing**
|
||||
|
||||
Head over to [download.emsesp.org](https://download.emsesp.org) for instructions on how to install EMS-ESP. There is also further details on which boards are supported in [this section](https://docs.emsesp.org/Installing/) of the documentation.
|
||||
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.
|
||||
|
||||
## 📋 **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://docs.emsesp.org) for more details on how to install and configure EMS-ESP. There is also a collection of Frequently Asked Questions and Troubleshooting tips with example customizations from the community.
|
||||
## **Documentation**
|
||||
|
||||
## 💬 **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.
|
||||
|
||||
## **Getting Support**
|
||||
|
||||
To chat with the community reach out on our [Discord Server](https://discord.gg/3J3GgnzpyT).
|
||||
|
||||
If you find an issue or have a request, see [how to request support](https://docs.emsesp.org/Support/) on how to submit a bug report or feature request.
|
||||
If you find an issue or have a request, see [here](https://emsesp.org/Support/) on how to submit a bug report or feature request.
|
||||
|
||||
## 🎥 **Live Demo**
|
||||
## **Live Demo**
|
||||
|
||||
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.
|
||||
|
||||
## 💖 **Contributors**
|
||||
## **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.
|
||||
|
||||
## 📦 **Building**
|
||||
## **Libraries used**
|
||||
|
||||
To build the web interface only, run `platformio run -e build_webUI`. This will install the necessary dependencies and build the web interface and also create the embedded code used need to build the firmware. You can run the web interface locally by going to the `interface` directory and running `pnpm standalone`.
|
||||
|
||||
To build the firmware, run `platformio run`. This will build the firmware for all ESP32 modules and place the binaries in the `build/firmware` folder. If you want to configure the build for a single platform create a local `pio_local.ni` file in the root directory (see example in `pio_local.ini_example`).
|
||||
|
||||
## 📢 **Libraries used**
|
||||
|
||||
- [esp8266-react](https://github.com/rjwats/esp8266-react) originally by @rjwats for the core framework that provides the Web UI, which has been heavily modified
|
||||
- [esp8266-react](https://github.com/rjwats/esp8266-react) by @rjwats for the core framework that provides the Web UI, which has been heavily modified
|
||||
- [uuid-\*](https://github.com/nomis/mcu-uuid-console) from @nomis. The console, syslog, telnet and logging are based off these awesome open source libraries
|
||||
- [ArduinoJson](https://github.com/bblanchon/ArduinoJson) for all the JSON processing
|
||||
- [espMqttClient](https://github.com/bertmelis/espMqttClient) for the MQTT client
|
||||
- [ESPAsyncWebServer](https://github.com/ESP32Async/ESPAsyncWebServer) and [AsyncTCP](https://github.com/ESP32Async/AsyncTCP) for the Web server
|
||||
- ESPAsyncWebServer and AsyncTCP for the Web server and TCP backends, with custom modifications for performance
|
||||
|
||||
## 📜 **License**
|
||||
## **License**
|
||||
|
||||
This program is licensed under GPL-3.0
|
||||
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
{
|
||||
"build": {
|
||||
"arduino": {
|
||||
"ldscript": "esp32c3_out.ld"
|
||||
},
|
||||
"core": "esp32",
|
||||
"extra_flags": [
|
||||
"-DTASMOTA_SDK",
|
||||
"-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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
{
|
||||
"build": {
|
||||
"arduino": {
|
||||
"ldscript": "esp32s2_out.ld"
|
||||
},
|
||||
"core": "esp32",
|
||||
"extra_flags": [
|
||||
"-DBOARD_HAS_PSRAM",
|
||||
"-DTASMOTA_SDK",
|
||||
"-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": true,
|
||||
"wait_for_upload_port": true,
|
||||
"require_upload_port": true,
|
||||
"speed": 921600
|
||||
},
|
||||
"url": "https://www.wemos.cc/en/latest/s2/s2_mini.html",
|
||||
"vendor": "WEMOS"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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": "Espressif ESP32-S3 32M Flash OPI PSRAM, 4608KB Code/OTA, 2MB FS",
|
||||
"upload": {
|
||||
"flash_size": "32MB",
|
||||
"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"
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"build": {
|
||||
"core": "esp32",
|
||||
"extra_flags": "-DTASMOTA_SDK",
|
||||
"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": "Espressif 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"
|
||||
}
|
||||
@@ -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": "Espressif 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"
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
{
|
||||
"build": {
|
||||
"core": "esp32",
|
||||
"extra_flags": "-DTASMOTA_SDK",
|
||||
"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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
28
cspell.json
28
cspell.json
@@ -9,31 +9,5 @@
|
||||
}
|
||||
],
|
||||
"dictionaries": ["project-words"],
|
||||
"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"
|
||||
]
|
||||
"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"]
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
{
|
||||
"type": "settings",
|
||||
"Network": {
|
||||
"ssid": "my_wifi_ssid",
|
||||
"bssid": "",
|
||||
"password": "my_wifi_password",
|
||||
"hostname": "ems-esp"
|
||||
},
|
||||
"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": false,
|
||||
"host": "127.0.0.1",
|
||||
"port": 1883,
|
||||
"base": "ems-esp",
|
||||
"username": "username",
|
||||
"password": "password",
|
||||
"client_id": "ems-esp",
|
||||
"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": false,
|
||||
"nested_format": 1,
|
||||
"discovery_prefix": "homeassistant",
|
||||
"discovery_type": 0,
|
||||
"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": {
|
||||
"board_profile": "S3",
|
||||
"locale": "en",
|
||||
"tx_mode": 1,
|
||||
"ems_bus_id": 11,
|
||||
"boiler_heatingoff": false,
|
||||
"hide_led": true,
|
||||
"telnet_enabled": true,
|
||||
"notoken_api": false,
|
||||
"analog_enabled": true,
|
||||
"fahrenheit": false,
|
||||
"bool_format": 1,
|
||||
"bool_dashboard": 1,
|
||||
"enum_format": 1
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
5102
dump_entities.csv
Normal file
5102
dump_entities.csv
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,231 +1,212 @@
|
||||
telegram_type_id,name,is_fetched
|
||||
0x04,UBAFactory,fetched
|
||||
0x06,RCTime,
|
||||
0x0A,EasyMonitor,fetched
|
||||
0x10,UBAErrorMessage1,
|
||||
0x11,UBAErrorMessage2,
|
||||
0x12,RCErrorMessage,
|
||||
0x13,RCErrorMessage2,
|
||||
0x14,UBATotalUptime,fetched
|
||||
0x15,UBAMaintenanceData,
|
||||
0x16,UBAParameters,fetched
|
||||
0x18,UBAMonitorFast,
|
||||
0x19,UBAMonitorSlow,
|
||||
0x1A,UBASetPoints,
|
||||
0x1C,UBAMaintenanceStatus,
|
||||
0x1E,HydrTemp,
|
||||
0x23,JunkersSetMixer,fetched
|
||||
0x27,UBASettingsWW,fetched
|
||||
0x28,WeatherComp,fetched
|
||||
0x2A,MC110Status,
|
||||
0x2E,Meters,
|
||||
0x33,UBAParameterWW,fetched
|
||||
0x34,UBAMonitorWW,
|
||||
0x35,UBAFlags,
|
||||
0x37,WWSettings,fetched
|
||||
0x38,WWTimer,fetched
|
||||
0x39,WWCircTimer,fetched
|
||||
0x3A,RC30WWSettings,fetched
|
||||
0x3B,Energy,
|
||||
0x3D,RC35Set,
|
||||
0x3E,RC35Monitor,
|
||||
0x3F,RC35Timer,
|
||||
0x40,RC30Temp,
|
||||
0x41,RC30Monitor,
|
||||
0x42,RC35Timer2,
|
||||
0x47,RC35Set,
|
||||
0x48,RC35Monitor,
|
||||
0x49,RC35Timer,
|
||||
0x4C,RC35Timer2,
|
||||
0x51,RC35Set,
|
||||
0x52,RC35Monitor,
|
||||
0x53,RC35Timer,
|
||||
0x56,RC35Timer2,
|
||||
0x5B,RC35Set,
|
||||
0x5C,RC35Monitor,
|
||||
0x5D,RC35Timer,
|
||||
0x60,RC35Timer2,
|
||||
0x96,SM10Config,fetched
|
||||
0x97,SM10Monitor,
|
||||
0x9C,WM10MonitorMessage,
|
||||
0x9D,WM10SetMessage,
|
||||
0xA2,RCError,
|
||||
0xA3,RCOutdoorTemp,
|
||||
0xA5,IBASettings,fetched
|
||||
0xA7,RC30Set,
|
||||
0xA9,RC30Vacation,fetched
|
||||
0xAA,MMConfigMessage,fetched
|
||||
0xAB,MMStatusMessage,
|
||||
0xAC,MMSetMessage,
|
||||
0xAF,RC20Remote,
|
||||
0xB0,RC10Set,
|
||||
0xB1,RC10Monitor,
|
||||
0xBB,HybridSettings,fetched
|
||||
0xBF,ErrorMessage,
|
||||
0xC0,RCErrorMessage,
|
||||
0xC2,UBAErrorMessage3,
|
||||
0xC6,UBAErrorMessage3,
|
||||
0xD1,UBAOutdoorTemp,
|
||||
0xE3,UBAMonitorSlowPlus2,
|
||||
0xE4,UBAMonitorFastPlus,
|
||||
0xE5,UBAMonitorSlowPlus,
|
||||
0xE6,UBAParametersPlus,fetched
|
||||
0xE9,UBAMonitorWWPlus,
|
||||
0xEA,UBAParameterWWPlus,fetched
|
||||
0x0101,ISM1Set,fetched
|
||||
0x0103,ISM1StatusMessage,fetched
|
||||
0x0104,ISM2StatusMessage,
|
||||
0x010C,IPMStatusMessage,
|
||||
0x011E,JunkersDisp,fetched
|
||||
0x012E,HPEnergy1,
|
||||
0x013B,HPEnergy2,
|
||||
0x0165,JunkersSet,
|
||||
0x0166,JunkersSet,
|
||||
0x0167,JunkersSet,
|
||||
0x0168,JunkersSet,
|
||||
0x016E,Absent,fetched
|
||||
0x016F,JunkersMonitor,
|
||||
0x0170,JunkersMonitor,
|
||||
0x0171,JunkersMonitor,
|
||||
0x0172,JunkersMonitor,
|
||||
0x0179,JunkersSet,
|
||||
0x017A,JunkersSet,
|
||||
0x017B,JunkersSet,
|
||||
0x017C,JunkersSet,
|
||||
0x01D3,JunkersDhw,fetched
|
||||
0x023A,RC300OutdoorTemp,fetched
|
||||
0x023E,PVSettings,fetched
|
||||
0x0240,RC300Settings,fetched
|
||||
0x0241,RC300Settings,fetched
|
||||
0x0267,RC300Floordry,
|
||||
0x0269,RC300Holiday,fetched
|
||||
0x0291,HPMode,fetched
|
||||
0x0292,HPMode,fetched
|
||||
0x0293,HPMode,fetched
|
||||
0x0294,HPMode,fetched
|
||||
0x029B,RC300Curves,
|
||||
0x029C,RC300Curves,
|
||||
0x029D,RC300Curves,
|
||||
0x029E,RC300Curves,
|
||||
0x029F,RC300Curves,
|
||||
0x02A0,RC300Curves,
|
||||
0x02A1,RC300Curves,
|
||||
0x02A2,RC300Curves,
|
||||
0x02A5,RC300Monitor,
|
||||
0x02A6,RC300Monitor,
|
||||
0x02A7,RC300Monitor,
|
||||
0x02A8,RC300Monitor,
|
||||
0x02A9,RC300Monitor,
|
||||
0x02AA,RC300Monitor,
|
||||
0x02AB,RC300Monitor,
|
||||
0x02AC,RC300Monitor,
|
||||
0x02AF,RC300Summer,
|
||||
0x02B0,RC300Summer,
|
||||
0x02B1,RC300Summer,
|
||||
0x02B2,RC300Summer,
|
||||
0x02B3,RC300Summer,
|
||||
0x02B4,RC300Summer,
|
||||
0x02B5,RC300Summer,
|
||||
0x02B6,RC300Summer,
|
||||
0x02B9,RC300Set,
|
||||
0x02BA,RC300Set,
|
||||
0x02BB,RC300Set,
|
||||
0x02BC,RC300Set,
|
||||
0x02BD,RC300Set,
|
||||
0x02BE,RC300Set,
|
||||
0x02BF,RC300Set,
|
||||
0x02C0,RC300Set,
|
||||
0x02CC,HPPressure,fetched
|
||||
0x02CD,MMPLUSConfigMessage,fetched
|
||||
0x02CE,RC300Set2,
|
||||
0x02D0,RC300Set2,
|
||||
0x02D2,RC300Set2,
|
||||
0x02D6,HPPump2,fetched
|
||||
0x02D7,MMPLUSStatusMessage,
|
||||
0x02E0,UBASetPoints,
|
||||
0x02F5,RC300WWmode,fetched
|
||||
0x02F6,RC300WW2mode,fetched
|
||||
0x0313,MMPLUSConfigMessage_WWC,fetched
|
||||
0x031B,RC300WWtemp,fetched
|
||||
0x031D,RC300WWmode2,
|
||||
0x031E,RC300WWmode2,
|
||||
0x0331,MMPLUSStatusMessage_WWC,
|
||||
0x0358,SM100SystemConfig,fetched
|
||||
0x035A,SM100CircuitConfig,fetched
|
||||
0x035C,SM100HeatAssist,fetched
|
||||
0x035D,SM100Circuit2Config,fetched
|
||||
0x035F,SM100Config1,fetched
|
||||
0x0361,SM100Differential,fetched
|
||||
0x0362,SM100Monitor,
|
||||
0x0363,SM100Monitor2,
|
||||
0x0364,SM100Status,
|
||||
0x0366,SM100Config,
|
||||
0x036A,SM100Status2,
|
||||
0x0380,SM100CollectorConfig,fetched
|
||||
0x038E,SM100Energy,fetched
|
||||
0x0391,SM100Time,fetched
|
||||
0x043F,CRHolidays,fetched
|
||||
0x0467,HPSet,
|
||||
0x0468,HPSet,
|
||||
0x0469,HPSet,
|
||||
0x046A,HPSet,
|
||||
0x0471,RC300Summer2,
|
||||
0x0472,RC300Summer2,
|
||||
0x0473,RC300Summer2,
|
||||
0x0474,RC300Summer2,
|
||||
0x0475,RC300Summer2,
|
||||
0x0476,RC300Summer2,
|
||||
0x0477,RC300Summer2,
|
||||
0x0478,RC300Summer2,
|
||||
0x047B,HP2,
|
||||
0x0484,HPSilentMode,fetched
|
||||
0x0485,HpCooling,fetched
|
||||
0x0486,HpInConfig,fetched
|
||||
0x0488,HPValve,fetched
|
||||
0x048A,HpPool,fetched
|
||||
0x048B,HPPumps,fetched
|
||||
0x048D,HpPower,fetched
|
||||
0x048F,HpTemperatures,
|
||||
0x0491,HPAdditionalHeater,fetched
|
||||
0x0492,HpHeaterConfig,fetched
|
||||
0x0494,UBAEnergySupplied,
|
||||
0x0495,UBAInformation,
|
||||
0x0499,HPDhwSettings,fetched
|
||||
0x049C,HPSettings2,fetched
|
||||
0x049D,HPSettings3,fetched
|
||||
0x04A2,HpInput,fetched
|
||||
0x04A5,HPFan,fetched
|
||||
0x04A7,HPPowerLimit,fetched
|
||||
0x04AA,HPPower2,fetched
|
||||
0x04AE,HPEnergy,fetched
|
||||
0x04AF,HPMeters,fetched
|
||||
0x055C,VentilationSet,fetched
|
||||
0x056B,VentilationMode,fetched
|
||||
0x0583,VentilationMonitor,
|
||||
0x0585,Blowerspeed,
|
||||
0x0587,Bypass,
|
||||
0x05BA,HpPoolStatus,fetched
|
||||
0x05D9,Airquality,
|
||||
0x0772,HIUSettings,
|
||||
0x0779,HIUMonitor,
|
||||
0x07A5,SM100wwCirc,fetched
|
||||
0x07A6,SM100wwParam,fetched
|
||||
0x07AA,SM100wwStatus,
|
||||
0x07AB,SM100wwCommand,
|
||||
0x07AC,SM100wwParam1,
|
||||
0x07AD,SM100ValveStatus,
|
||||
0x07AE,SM100wwKeepWarm,fetched
|
||||
0x07D6,SM100wwTemperature,
|
||||
0x07E0,SM100wwStatus2,fetched
|
||||
0x0935,EM100SetMessage,fetched
|
||||
0x0936,EM100OutMessage,
|
||||
0x0937,EM100TempMessage,
|
||||
0x0938,EM100InputMessage,
|
||||
0x0939,EM100MonitorMessage,
|
||||
0x093A,EM100ConfigMessage,
|
||||
0x0998,HPSettings,fetched
|
||||
0x0999,HPFunctionTest,fetched
|
||||
0x099A,HPStarts,
|
||||
0x099B,HPFlowTemp,
|
||||
0x099C,HPComp,
|
||||
0x09A0,HPTemperature,
|
||||
telegram_type_id,name,is_fetched
|
||||
0x04,UBAFactory,fetched
|
||||
0x06,RCTime,
|
||||
0x0A,EasyMonitor,fetched
|
||||
0x10,UBAErrorMessage1,
|
||||
0x11,UBAErrorMessage2,
|
||||
0x12,RCErrorMessage,
|
||||
0x13,RCErrorMessage2,
|
||||
0x14,UBATotalUptime,fetched
|
||||
0x15,UBAMaintenanceData,
|
||||
0x16,UBAParameters,fetched
|
||||
0x18,UBAMonitorFast,
|
||||
0x19,UBAMonitorSlow,
|
||||
0x1A,UBASetPoints,
|
||||
0x1C,UBAMaintenanceStatus,
|
||||
0x1E,WM10TempMessage,
|
||||
0x23,JunkersSetMixer,fetched
|
||||
0x26,UBASettingsWW,fetched
|
||||
0x28,WeatherComp,fetched
|
||||
0x2A,MC110Status,
|
||||
0x2E,Meters,
|
||||
0x33,UBAParameterWW,fetched
|
||||
0x34,UBAMonitorWW,
|
||||
0x35,UBAFlags,
|
||||
0x37,WWSettings,fetched
|
||||
0x38,WWTimer,fetched
|
||||
0x39,WWCircTimer,fetched
|
||||
0x3A,RC30WWSettings,fetched
|
||||
0x3B,Energy,
|
||||
0x3D,RC35Set,
|
||||
0x3E,RC35Monitor,
|
||||
0x3F,RC35Timer,
|
||||
0x40,RC30Temp,
|
||||
0x41,RC30Monitor,
|
||||
0x42,RC35Timer2,
|
||||
0x47,RC35Set,
|
||||
0x48,RC35Monitor,
|
||||
0x49,RC35Timer,
|
||||
0x4C,RC35Timer2,
|
||||
0x51,RC35Set,
|
||||
0x52,RC35Monitor,
|
||||
0x53,RC35Timer,
|
||||
0x56,RC35Timer2,
|
||||
0x5B,RC35Set,
|
||||
0x5C,RC35Monitor,
|
||||
0x5D,RC35Timer,
|
||||
0x60,RC35Timer2,
|
||||
0x96,SM10Config,fetched
|
||||
0x97,SM10Monitor,
|
||||
0x9C,WM10MonitorMessage,
|
||||
0x9D,WM10SetMessage,
|
||||
0xA2,RCError,
|
||||
0xA3,RCOutdoorTemp,
|
||||
0xA5,IBASettings,fetched
|
||||
0xA7,RC30Set,
|
||||
0xA9,RC30Vacation,fetched
|
||||
0xAA,MMConfigMessage,fetched
|
||||
0xAB,MMStatusMessage,
|
||||
0xAC,MMSetMessage,
|
||||
0xAF,RC20Remote,
|
||||
0xB0,RC10Set,
|
||||
0xB1,RC10Monitor,
|
||||
0xBB,HybridSettings,fetched
|
||||
0xBF,ErrorMessage,
|
||||
0xC2,UBAErrorMessage3,
|
||||
0xD1,UBAOutdoorTemp,
|
||||
0xE3,UBAMonitorSlowPlus2,
|
||||
0xE4,UBAMonitorFastPlus,
|
||||
0xE5,UBAMonitorSlowPlus,
|
||||
0xE6,UBAParametersPlus,fetched
|
||||
0xE9,UBAMonitorWWPlus,
|
||||
0xEA,UBAParameterWWPlus,fetched
|
||||
0x0101,ISM1Set,fetched
|
||||
0x0103,ISM1StatusMessage,fetched
|
||||
0x0104,ISM2StatusMessage,
|
||||
0x010C,IPMStatusMessage,
|
||||
0x011E,IPMTempMessage,
|
||||
0x0165,JunkersSet,
|
||||
0x0166,JunkersSet,
|
||||
0x0167,JunkersSet,
|
||||
0x0168,JunkersSet,
|
||||
0x016F,JunkersMonitor,
|
||||
0x0170,JunkersMonitor,
|
||||
0x0171,JunkersMonitor,
|
||||
0x0172,JunkersMonitor,
|
||||
0x0179,JunkersSet,
|
||||
0x017A,JunkersSet,
|
||||
0x017B,JunkersSet,
|
||||
0x017C,JunkersSet,
|
||||
0x01D3,JunkersDhw,fetched
|
||||
0x023A,RC300OutdoorTemp,fetched
|
||||
0x023E,PVSettings,fetched
|
||||
0x0240,RC300Settings,fetched
|
||||
0x0267,RC300Floordry,
|
||||
0x0269,RC300Holiday1,fetched
|
||||
0x0291,HPMode,fetched
|
||||
0x0292,HPMode,fetched
|
||||
0x0293,HPMode,fetched
|
||||
0x0294,HPMode,fetched
|
||||
0x029B,RC300Curves,
|
||||
0x029C,RC300Curves,
|
||||
0x029D,RC300Curves,
|
||||
0x029E,RC300Curves,
|
||||
0x029F,RC300Curves,
|
||||
0x02A0,RC300Curves,
|
||||
0x02A1,RC300Curves,
|
||||
0x02A2,RC300Curves,
|
||||
0x02A5,RC300Monitor,
|
||||
0x02A6,RC300Monitor,
|
||||
0x02A7,RC300Monitor,
|
||||
0x02A8,RC300Monitor,
|
||||
0x02A9,RC300Monitor,
|
||||
0x02AA,RC300Monitor,
|
||||
0x02AB,RC300Monitor,
|
||||
0x02AC,RC300Monitor,
|
||||
0x02AF,RC300Summer,
|
||||
0x02B0,RC300Summer,
|
||||
0x02B1,RC300Summer,
|
||||
0x02B2,RC300Summer,
|
||||
0x02B3,RC300Summer,
|
||||
0x02B4,RC300Summer,
|
||||
0x02B5,RC300Summer,
|
||||
0x02B6,RC300Summer,
|
||||
0x02B9,RC300Set,
|
||||
0x02BA,RC300Set,
|
||||
0x02BB,RC300Set,
|
||||
0x02BC,RC300Set,
|
||||
0x02BD,RC300Set,
|
||||
0x02BE,RC300Set,
|
||||
0x02BF,RC300Set,
|
||||
0x02C0,RC300Set,
|
||||
0x02CC,RC300Set2,
|
||||
0x02CD,MMPLUSConfigMessage,fetched
|
||||
0x02CE,RC300Set2,
|
||||
0x02D0,RC300Set2,
|
||||
0x02D2,RC300Set2,
|
||||
0x02D5,MMPLUSConfigMessage,fetched
|
||||
0x02D6,HPPump2,fetched
|
||||
0x02D7,MMPLUSStatusMessage,
|
||||
0x02DF,MMPLUSStatusMessage,
|
||||
0x02F5,RC300WWmode,fetched
|
||||
0x02F6,RC300WW2mode,fetched
|
||||
0x031B,RC300WWtemp,fetched
|
||||
0x031D,RC300WWmode2,
|
||||
0x031E,RC300WWmode2,
|
||||
0x0358,SM100SystemConfig,fetched
|
||||
0x035A,SM100CircuitConfig,fetched
|
||||
0x035C,SM100HeatAssist,fetched
|
||||
0x035D,SM100Circuit2Config,fetched
|
||||
0x035F,SM100Config1,fetched
|
||||
0x0361,SM100Differential,fetched
|
||||
0x0362,SM100Monitor,
|
||||
0x0363,SM100Monitor2,
|
||||
0x0364,SM100Status,
|
||||
0x0366,SM100Config,
|
||||
0x036A,SM100Status2,
|
||||
0x0380,SM100CollectorConfig,fetched
|
||||
0x038E,SM100Energy,fetched
|
||||
0x0391,SM100Time,fetched
|
||||
0x0467,HPSet,
|
||||
0x0468,HPSet,
|
||||
0x0469,HPSet,
|
||||
0x046A,HPSet,
|
||||
0x0471,RC300Summer2,
|
||||
0x0472,RC300Summer2,
|
||||
0x0473,RC300Summer2,
|
||||
0x0474,RC300Summer2,
|
||||
0x0475,RC300Summer2,
|
||||
0x0476,RC300Summer2,
|
||||
0x0477,RC300Summer2,
|
||||
0x0478,RC300Summer2,
|
||||
0x047B,HP2,
|
||||
0x0484,HPSilentMode,fetched
|
||||
0x0485,HpCooling,fetched
|
||||
0x0486,HpInConfig,fetched
|
||||
0x0488,HPValve,fetched
|
||||
0x048A,HpPool,fetched
|
||||
0x048B,HPPumps,fetched
|
||||
0x048D,HpPower,fetched
|
||||
0x048F,HpTemperatures,
|
||||
0x0491,HPAdditionalHeater,fetched
|
||||
0x0492,HpHeaterConfig,fetched
|
||||
0x0494,UBAEnergySupplied,
|
||||
0x0495,UBAInformation,
|
||||
0x0499,HPDhwSettings,fetched
|
||||
0x049C,HPSettings2,fetched
|
||||
0x049D,HPSettings3,fetched
|
||||
0x04A2,HpInput,fetched
|
||||
0x04A5,HPFan,fetched
|
||||
0x04A7,HPPowerLimit,fetched
|
||||
0x04AA,HPPower2,fetched
|
||||
0x04AE,HPEnergy,fetched
|
||||
0x04AF,HPMeters,fetched
|
||||
0x056B,VentilationMode,fetched
|
||||
0x0583,VentilationMonitor,
|
||||
0x0585,Blowerspeed,
|
||||
0x0587,Bypass,
|
||||
0x05BA,HpPoolStatus,fetched
|
||||
0x05D9,Airquality,
|
||||
0x0772,HIUSettings,
|
||||
0x0779,HIUMonitor,
|
||||
0x0935,EM100SetMessage,fetched
|
||||
0x0936,EM100OutMessage,
|
||||
0x0937,EM100TempMessage,
|
||||
0x0938,EM100InputMessage,
|
||||
0x0939,EM100MonitorMessage,
|
||||
0x093A,EM100ConfigMessage,
|
||||
0x0998,HPSettings,fetched
|
||||
0x0999,HPFunctionTest,fetched
|
||||
0x099B,HPFlowTemp,
|
||||
0x099C,HPComp,
|
||||
0x09A0,HPTemperature,
|
||||
|
4
interface/.gitattributes
vendored
Normal file
4
interface/.gitattributes
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
/.yarn/** linguist-vendored
|
||||
/.yarn/releases/* binary
|
||||
/.yarn/plugins/**/* binary
|
||||
/.pnp.* binary linguist-generated
|
||||
@@ -4,4 +4,5 @@ dist/
|
||||
src/i18n/*
|
||||
|
||||
.prettierrc
|
||||
.typesafe-i18n.json
|
||||
.yarn/
|
||||
.typesafe-i18n.json
|
||||
1
interface/.yarnrc.yml
Normal file
1
interface/.yarnrc.yml
Normal file
@@ -0,0 +1 @@
|
||||
nodeLinker: node-modules
|
||||
@@ -10,7 +10,8 @@ export default tseslint.config(
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: true
|
||||
project: true,
|
||||
tsconfigRootDir: import.meta.dirname
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "EMS-ESP",
|
||||
"version": "3.7.3",
|
||||
"version": "3.7.0",
|
||||
"description": "EMS-ESP WebUI",
|
||||
"homepage": "https://emsesp.org",
|
||||
"author": "proddy, emsesp.org",
|
||||
@@ -8,64 +8,60 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"build-hosted": "typesafe-i18n && vite build --mode hosted",
|
||||
"mock-rest": "bun --watch ../mock-api/restServer.ts",
|
||||
"preview-standalone": "typesafe-i18n --no-watch && vite build && concurrently -c \"auto\" \"pnpm:mock-rest\" \"vite preview\"",
|
||||
"standalone": "concurrently -c \"auto\" \"typesafe-i18n\" \"pnpm:mock-rest\" \"vite dev\"",
|
||||
"build-hosted": "typesafe-i18n --no-watch && vite build --mode hosted",
|
||||
"preview-standalone": "typesafe-i18n --no-watch && vite build && concurrently -c \"auto\" \"npm:mock-rest\" \"vite preview\"",
|
||||
"mock-rest": "bun --watch ../mock-api/rest_server.ts",
|
||||
"standalone": "concurrently -c \"auto\" \"typesafe-i18n\" \"npm:mock-rest\" \"vite\"",
|
||||
"typesafe-i18n": "typesafe-i18n --no-watch",
|
||||
"webUI": "vite build && node progmem-generator.js",
|
||||
"webUI": "node progmem-generator.js",
|
||||
"format": "prettier -l -w '**/*.{ts,tsx,js,css,json,md}'",
|
||||
"lint": "eslint . --fix",
|
||||
"standalone-devcontainer": "concurrently -c \"auto\" \"typesafe-i18n\" \"pnpm:mock-rest\" \"vite --host\""
|
||||
"lint": "eslint . --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@alova/adapter-xhr": "2.2.1",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@mui/icons-material": "^7.3.5",
|
||||
"@mui/material": "^7.3.5",
|
||||
"@preact/compat": "^18.3.1",
|
||||
"@table-library/react-table-library": "4.1.15",
|
||||
"alova": "3.3.4",
|
||||
"@alova/adapter-xhr": "2.0.9",
|
||||
"@emotion/react": "^11.13.3",
|
||||
"@emotion/styled": "^11.13.0",
|
||||
"@mui/icons-material": "^6.1.5",
|
||||
"@mui/material": "^6.1.5",
|
||||
"@table-library/react-table-library": "4.1.7",
|
||||
"alova": "3.1.1",
|
||||
"async-validator": "^4.2.5",
|
||||
"etag": "^1.8.1",
|
||||
"formidable": "^3.5.4",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"magic-string": "^0.30.21",
|
||||
"mime-types": "^3.0.1",
|
||||
"preact": "^10.27.2",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-router": "^7.9.5",
|
||||
"react-toastify": "^11.0.5",
|
||||
"mime-types": "^2.1.35",
|
||||
"preact": "^10.24.3",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-icons": "^5.3.0",
|
||||
"react-router-dom": "^6.27.0",
|
||||
"react-toastify": "^10.0.6",
|
||||
"typesafe-i18n": "^5.26.2",
|
||||
"typescript": "^5.9.3"
|
||||
"typescript": "^5.6.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.28.5",
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@babel/core": "^7.26.0",
|
||||
"@eslint/js": "^9.13.0",
|
||||
"@preact/compat": "^18.3.1",
|
||||
"@preact/preset-vite": "^2.10.2",
|
||||
"@trivago/prettier-plugin-sort-imports": "^6.0.0",
|
||||
"@types/node": "^24.10.0",
|
||||
"@types/react": "^19.2.2",
|
||||
"@types/react-dom": "^19.2.2",
|
||||
"axe-core": "^4.11.0",
|
||||
"concurrently": "^9.2.1",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"prettier": "^3.6.2",
|
||||
"rollup-plugin-visualizer": "^6.0.5",
|
||||
"terser": "^5.44.1",
|
||||
"typescript-eslint": "^8.46.3",
|
||||
"vite": "^7.2.2",
|
||||
"@preact/preset-vite": "^2.9.1",
|
||||
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
|
||||
"@types/formidable": "^3",
|
||||
"@types/node": "^22.8.1",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"concurrently": "^9.0.1",
|
||||
"eslint": "^9.13.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"formidable": "^3.5.2",
|
||||
"prettier": "^3.3.3",
|
||||
"rollup-plugin-visualizer": "^5.12.0",
|
||||
"terser": "^5.36.0",
|
||||
"typescript-eslint": "8.11.0",
|
||||
"vite": "^5.4.10",
|
||||
"vite-plugin-imagemin": "^0.6.1",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
"vite-tsconfig-paths": "^5.0.1"
|
||||
},
|
||||
"packageManager": "pnpm@10.20.0+sha512.cf9998222162dd85864d0a8102e7892e7ba4ceadebbf5a31f9c2fce48dfce317a9c53b9f6464d1ef9042cba2e02ae02a9f7c143a2b438cd93c91840f0192b9dd"
|
||||
"packageManager": "yarn@4.5.1"
|
||||
}
|
||||
|
||||
6091
interface/pnpm-lock.yaml
generated
6091
interface/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,8 +0,0 @@
|
||||
onlyBuiltDependencies:
|
||||
- cwebp-bin
|
||||
- esbuild
|
||||
- gifsicle
|
||||
- jpegtran-bin
|
||||
- mozjpeg
|
||||
- optipng-bin
|
||||
- pngquant-bin
|
||||
@@ -1,4 +1,4 @@
|
||||
import etag from 'etag';
|
||||
import crypto from 'crypto';
|
||||
import {
|
||||
createWriteStream,
|
||||
existsSync,
|
||||
@@ -12,82 +12,69 @@ import zlib from 'zlib';
|
||||
|
||||
const ARDUINO_INCLUDES = '#include <Arduino.h>\n\n';
|
||||
const INDENT = ' ';
|
||||
const outputPath = '../src/ESP32React/WWWData.h';
|
||||
const outputPath = '../lib/framework/WWWData.h';
|
||||
const sourcePath = './dist';
|
||||
const bytesPerLine = 20;
|
||||
let totalSize = 0;
|
||||
let bundleStats = {
|
||||
js: { count: 0, uncompressed: 0, compressed: 0 },
|
||||
css: { count: 0, uncompressed: 0, compressed: 0 },
|
||||
html: { count: 0, uncompressed: 0, compressed: 0 },
|
||||
svg: { count: 0, uncompressed: 0, compressed: 0 },
|
||||
other: { count: 0, uncompressed: 0, compressed: 0 }
|
||||
};
|
||||
var totalSize = 0;
|
||||
|
||||
const generateWWWClass =
|
||||
() => `typedef std::function<void(const char * uri, const String & contentType, const uint8_t * content, size_t len, const String & hash)> RouteRegistrationHandler;
|
||||
// Bundle Statistics:
|
||||
// - Total compressed size: ${(totalSize / 1000).toFixed(1)} KB
|
||||
// - Total uncompressed size: ${(Object.values(bundleStats).reduce((sum, stat) => sum + stat.uncompressed, 0) / 1000).toFixed(1)} KB
|
||||
// - Compression ratio: ${(((Object.values(bundleStats).reduce((sum, stat) => sum + stat.uncompressed, 0) - totalSize) / Object.values(bundleStats).reduce((sum, stat) => sum + stat.uncompressed, 0)) * 100).toFixed(1)}%
|
||||
// - Generated on: ${new Date().toISOString()}
|
||||
const generateWWWClass = () =>
|
||||
`typedef std::function<void(const char * uri, const String & contentType, const uint8_t * content, size_t len, const String & hash)> RouteRegistrationHandler;
|
||||
// Total size is ${totalSize} bytes
|
||||
|
||||
class WWWData {
|
||||
${INDENT}public:
|
||||
${INDENT.repeat(2)}static void registerRoutes(RouteRegistrationHandler handler) {
|
||||
${fileInfo.map((f) => `${INDENT.repeat(3)}handler("${f.uri}", "${f.mimeType}", ${f.variable}, ${f.size}, ${f.hash});`).join('\n')}
|
||||
${INDENT.repeat(2)}}
|
||||
${indent}public:
|
||||
${indent.repeat(2)}static void registerRoutes(RouteRegistrationHandler handler) {
|
||||
${fileInfo.map((file) => `${indent.repeat(3)}handler("${file.uri}", "${file.mimeType}", ${file.variable}, ${file.size}, "${file.hash}");`).join('\n')}
|
||||
${indent.repeat(2)}}
|
||||
};
|
||||
`;
|
||||
|
||||
const getFilesSync = (dir, files = []) => {
|
||||
function getFilesSync(dir, files = []) {
|
||||
readdirSync(dir, { withFileTypes: true }).forEach((entry) => {
|
||||
const entryPath = resolve(dir, entry.name);
|
||||
entry.isDirectory() ? getFilesSync(entryPath, files) : files.push(entryPath);
|
||||
if (entry.isDirectory()) {
|
||||
getFilesSync(entryPath, files);
|
||||
} else {
|
||||
files.push(entryPath);
|
||||
}
|
||||
});
|
||||
return files;
|
||||
};
|
||||
}
|
||||
|
||||
const cleanAndOpen = (path) => {
|
||||
existsSync(path) && unlinkSync(path);
|
||||
function cleanAndOpen(path) {
|
||||
if (existsSync(path)) {
|
||||
unlinkSync(path);
|
||||
}
|
||||
return createWriteStream(path, { flags: 'w+' });
|
||||
};
|
||||
|
||||
const getFileType = (filePath) => {
|
||||
const ext = filePath.split('.').pop().toLowerCase();
|
||||
if (ext === 'js') return 'js';
|
||||
if (ext === 'css') return 'css';
|
||||
if (ext === 'html') return 'html';
|
||||
if (ext === 'svg') return 'svg';
|
||||
return 'other';
|
||||
};
|
||||
}
|
||||
|
||||
const writeFile = (relativeFilePath, buffer) => {
|
||||
const variable = `ESP_REACT_DATA_${fileInfo.length}`;
|
||||
const variable = 'ESP_REACT_DATA_' + fileInfo.length;
|
||||
const mimeType = mime.lookup(relativeFilePath);
|
||||
const fileType = getFileType(relativeFilePath);
|
||||
let size = 0;
|
||||
writeStream.write(`const uint8_t ${variable}[] = {`);
|
||||
|
||||
var size = 0;
|
||||
writeStream.write('const uint8_t ' + variable + '[] = {');
|
||||
// const zipBuffer = zlib.brotliCompressSync(buffer, { quality: 1 });
|
||||
const zipBuffer = zlib.gzipSync(buffer, { level: 9 });
|
||||
// const hash = crypto.createHash('sha256').update(zipBuffer).digest('hex');
|
||||
const hash = etag(zipBuffer); // use smaller md5 instead of sha256
|
||||
|
||||
// create sha
|
||||
const hashSum = crypto.createHash('sha256');
|
||||
hashSum.update(zipBuffer);
|
||||
const hash = hashSum.digest('hex');
|
||||
|
||||
zipBuffer.forEach((b) => {
|
||||
if (!(size % bytesPerLine)) {
|
||||
writeStream.write('\n' + INDENT);
|
||||
writeStream.write('\n');
|
||||
writeStream.write(indent);
|
||||
}
|
||||
writeStream.write('0x' + b.toString(16).toUpperCase().padStart(2, '0') + ',');
|
||||
writeStream.write('0x' + ('00' + b.toString(16).toUpperCase()).slice(-2) + ',');
|
||||
size++;
|
||||
});
|
||||
|
||||
size % bytesPerLine && writeStream.write('\n');
|
||||
writeStream.write('};\n\n');
|
||||
if (size % bytesPerLine) {
|
||||
writeStream.write('\n');
|
||||
}
|
||||
|
||||
// Update bundle statistics
|
||||
bundleStats[fileType].count++;
|
||||
bundleStats[fileType].uncompressed += buffer.length;
|
||||
bundleStats[fileType].compressed += zipBuffer.length;
|
||||
writeStream.write('};\n\n');
|
||||
|
||||
fileInfo.push({
|
||||
uri: '/' + relativeFilePath.replace(sep, '/'),
|
||||
@@ -97,52 +84,32 @@ const writeFile = (relativeFilePath, buffer) => {
|
||||
hash
|
||||
});
|
||||
|
||||
// console.log(relativeFilePath + ' (size ' + size + ' bytes)');
|
||||
totalSize += size;
|
||||
};
|
||||
|
||||
console.log(`Generating ${outputPath} from ${sourcePath}`);
|
||||
// start
|
||||
console.log('Generating ' + outputPath + ' from ' + sourcePath);
|
||||
const includes = ARDUINO_INCLUDES;
|
||||
const indent = INDENT;
|
||||
const fileInfo = [];
|
||||
const writeStream = cleanAndOpen(resolve(outputPath));
|
||||
|
||||
writeStream.write(ARDUINO_INCLUDES);
|
||||
// includes
|
||||
writeStream.write(includes);
|
||||
|
||||
// process static files
|
||||
const buildPath = resolve(sourcePath);
|
||||
for (const filePath of getFilesSync(buildPath)) {
|
||||
writeFile(relative(buildPath, filePath), readFileSync(filePath));
|
||||
const readStream = readFileSync(filePath);
|
||||
const relativeFilePath = relative(buildPath, filePath);
|
||||
writeFile(relativeFilePath, readStream);
|
||||
}
|
||||
|
||||
// add class
|
||||
writeStream.write(generateWWWClass());
|
||||
|
||||
// end
|
||||
writeStream.end();
|
||||
|
||||
// Calculate and display bundle statistics
|
||||
const totalUncompressed = Object.values(bundleStats).reduce(
|
||||
(sum, stat) => sum + stat.uncompressed,
|
||||
0
|
||||
);
|
||||
const totalCompressed = Object.values(bundleStats).reduce(
|
||||
(sum, stat) => sum + stat.compressed,
|
||||
0
|
||||
);
|
||||
const compressionRatio = (
|
||||
((totalUncompressed - totalCompressed) / totalUncompressed) *
|
||||
100
|
||||
).toFixed(1);
|
||||
|
||||
console.log('\n📊 Bundle Size Analysis:');
|
||||
console.log('='.repeat(50));
|
||||
console.log(`Total compressed size: ${(totalSize / 1000).toFixed(1)} KB`);
|
||||
console.log(`Total uncompressed size: ${(totalUncompressed / 1000).toFixed(1)} KB`);
|
||||
console.log(`Compression ratio: ${compressionRatio}%`);
|
||||
console.log('\n📁 File Type Breakdown:');
|
||||
Object.entries(bundleStats).forEach(([type, stats]) => {
|
||||
if (stats.count > 0) {
|
||||
const ratio = (
|
||||
((stats.uncompressed - stats.compressed) / stats.uncompressed) *
|
||||
100
|
||||
).toFixed(1);
|
||||
console.log(
|
||||
`${type.toUpperCase().padEnd(4)}: ${stats.count} files, ${(stats.uncompressed / 1000).toFixed(1)} KB → ${(stats.compressed / 1000).toFixed(1)} KB (${ratio}% compression)`
|
||||
);
|
||||
}
|
||||
});
|
||||
console.log('='.repeat(50));
|
||||
console.log('Total size: ' + totalSize / 1000 + ' KB');
|
||||
|
||||
@@ -13,9 +13,8 @@
|
||||
local('Roboto'),
|
||||
local('Roboto-Regular'),
|
||||
url(../fonts/re.woff2) format('woff2');
|
||||
unicode-range:
|
||||
U+0000-00FF, U+0104-0107, U+0118-0119, U+011E-011F, U+0130-0131, U+0141-0144,
|
||||
U+0152-0153, U+015A-015B, U+015E-015F, U+0179-017C, U+02BB-02BC, U+02C6, U+02DA,
|
||||
U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
|
||||
U+FEFF, U+FFFD;
|
||||
unicode-range: U+0000-00FF, U+0104-0107, U+0118-0119, U+011E-011F, U+0130-0131,
|
||||
U+0141-0144, U+0152-0153, U+015A-015B, U+015E-015F, U+0179-017C, U+02BB-02BC,
|
||||
U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193,
|
||||
U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
@@ -1,76 +1,46 @@
|
||||
import { memo, useCallback, useEffect, useState } from 'react';
|
||||
import { ToastContainer, Zoom } from 'react-toastify';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Slide, ToastContainer } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.min.css';
|
||||
|
||||
import AppRouting from 'AppRouting';
|
||||
import CustomTheme from 'CustomTheme';
|
||||
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 { detectLocale, navigatorDetector } from 'typesafe-i18n/detectors';
|
||||
import { localStorageDetector } from 'typesafe-i18n/detectors';
|
||||
|
||||
// Memoize available locales to prevent recreation on every render
|
||||
const AVAILABLE_LOCALES = [
|
||||
'de',
|
||||
'en',
|
||||
'it',
|
||||
'fr',
|
||||
'nl',
|
||||
'no',
|
||||
'pl',
|
||||
'sk',
|
||||
'sv',
|
||||
'tr',
|
||||
'cz'
|
||||
] as Locales[];
|
||||
const detectedLocale = detectLocale(localStorageDetector);
|
||||
|
||||
// Static toast configuration - no need to recreate on every render
|
||||
const TOAST_CONTAINER_PROPS = {
|
||||
position: 'bottom-left' as const,
|
||||
autoClose: 3000,
|
||||
hideProgressBar: false,
|
||||
newestOnTop: false,
|
||||
closeOnClick: true,
|
||||
rtl: false,
|
||||
pauseOnFocusLoss: true,
|
||||
draggable: false,
|
||||
pauseOnHover: false,
|
||||
transition: Zoom,
|
||||
closeButton: false,
|
||||
theme: 'dark' as const,
|
||||
toastStyle: {
|
||||
border: '1px solid #177ac9',
|
||||
width: 'fit-content'
|
||||
}
|
||||
};
|
||||
|
||||
const App = memo(() => {
|
||||
const App = () => {
|
||||
const [wasLoaded, setWasLoaded] = useState(false);
|
||||
const [locale, setLocale] = useState<Locales>('en');
|
||||
|
||||
// Memoize locale initialization to prevent unnecessary re-runs
|
||||
const initializeLocale = useCallback(async () => {
|
||||
const browserLocale = detectLocale('en', AVAILABLE_LOCALES, navigatorDetector);
|
||||
const newLocale = (localStorage.getItem('lang') || browserLocale) as Locales;
|
||||
localStorage.setItem('lang', newLocale);
|
||||
setLocale(newLocale);
|
||||
await loadLocaleAsync(newLocale);
|
||||
setWasLoaded(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void initializeLocale();
|
||||
}, [initializeLocale]);
|
||||
void loadLocaleAsync(detectedLocale).then(() => setWasLoaded(true));
|
||||
}, []);
|
||||
|
||||
if (!wasLoaded) return null;
|
||||
|
||||
return (
|
||||
<TypesafeI18n locale={locale}>
|
||||
<TypesafeI18n locale={detectedLocale}>
|
||||
<CustomTheme>
|
||||
<AppRouting />
|
||||
<ToastContainer {...TOAST_CONTAINER_PROPS} />
|
||||
<ToastContainer
|
||||
position="bottom-left"
|
||||
autoClose={3000}
|
||||
hideProgressBar={false}
|
||||
newestOnTop={false}
|
||||
closeOnClick={true}
|
||||
rtl={false}
|
||||
pauseOnFocusLoss={false}
|
||||
draggable={false}
|
||||
pauseOnHover={false}
|
||||
transition={Slide}
|
||||
closeButton={false}
|
||||
theme="light"
|
||||
/>
|
||||
</CustomTheme>
|
||||
</TypesafeI18n>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export default App;
|
||||
|
||||
@@ -1,80 +1,60 @@
|
||||
import { type FC, Suspense, lazy, memo, useContext, useEffect, useRef } from 'react';
|
||||
import { Navigate, Route, Routes } from 'react-router';
|
||||
import { useContext, useEffect } from 'react';
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import {
|
||||
LoadingSpinner,
|
||||
RequireAuthenticated,
|
||||
RequireUnauthenticated
|
||||
} from 'components';
|
||||
import AuthenticatedRouting from 'AuthenticatedRouting';
|
||||
import SignIn from 'SignIn';
|
||||
import { RequireAuthenticated, RequireUnauthenticated } from 'components';
|
||||
import { Authentication, AuthenticationContext } from 'contexts/authentication';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
|
||||
// Lazy load route components for better code splitting
|
||||
const SignIn = lazy(() => import('SignIn'));
|
||||
const AuthenticatedRouting = lazy(() => import('AuthenticatedRouting'));
|
||||
|
||||
interface SecurityRedirectProps {
|
||||
readonly message: string;
|
||||
readonly signOut?: boolean;
|
||||
message: string;
|
||||
signOut?: boolean;
|
||||
}
|
||||
|
||||
const RootRedirect: FC<SecurityRedirectProps> = memo(
|
||||
({ message, signOut = false }) => {
|
||||
const { signOut: contextSignOut } = useContext(AuthenticationContext);
|
||||
const hasShownToast = useRef(false);
|
||||
const RootRedirect = ({ message, signOut }: SecurityRedirectProps) => {
|
||||
const authenticationContext = useContext(AuthenticationContext);
|
||||
useEffect(() => {
|
||||
signOut && authenticationContext.signOut(false);
|
||||
toast.success(message);
|
||||
}, [message, signOut, authenticationContext]);
|
||||
return <Navigate to="/" />;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Prevent duplicate toasts on strict mode or re-renders
|
||||
if (!hasShownToast.current) {
|
||||
hasShownToast.current = true;
|
||||
if (signOut) {
|
||||
contextSignOut(false);
|
||||
}
|
||||
toast.success(message);
|
||||
}
|
||||
// Only run once on mount - using ref to track execution
|
||||
}, []);
|
||||
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
);
|
||||
|
||||
const AppRouting: FC = memo(() => {
|
||||
const AppRouting = () => {
|
||||
const { LL } = useI18nContext();
|
||||
|
||||
return (
|
||||
<Authentication>
|
||||
<Suspense fallback={<LoadingSpinner />}>
|
||||
<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>
|
||||
</Suspense>
|
||||
<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;
|
||||
|
||||
@@ -1,86 +1,76 @@
|
||||
import { Suspense, lazy, memo, useContext } from 'react';
|
||||
import { Navigate, Route, Routes } from 'react-router';
|
||||
import { useContext } from 'react';
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
|
||||
import { Layout, LoadingSpinner } from 'components';
|
||||
import CustomEntities from 'app/main/CustomEntities';
|
||||
import Customizations from 'app/main/Customizations';
|
||||
import Dashboard from 'app/main/Dashboard';
|
||||
import Devices from 'app/main/Devices';
|
||||
import Help from 'app/main/Help';
|
||||
import Modules from 'app/main/Modules';
|
||||
import Scheduler from 'app/main/Scheduler';
|
||||
import Sensors from 'app/main/Sensors';
|
||||
import APSettings from 'app/settings/APSettings';
|
||||
import ApplicationSettings from 'app/settings/ApplicationSettings';
|
||||
import DownloadUpload from 'app/settings/DownloadUpload';
|
||||
import MqttSettings from 'app/settings/MqttSettings';
|
||||
import NTPSettings from 'app/settings/NTPSettings';
|
||||
import Settings from 'app/settings/Settings';
|
||||
import 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';
|
||||
|
||||
// Lazy load all route components for better code splitting
|
||||
const Dashboard = lazy(() => import('app/main/Dashboard'));
|
||||
const Devices = lazy(() => import('app/main/Devices'));
|
||||
const Sensors = lazy(() => import('app/main/Sensors'));
|
||||
const Help = lazy(() => import('app/main/Help'));
|
||||
const Customizations = lazy(() => import('app/main/Customizations'));
|
||||
const Scheduler = lazy(() => import('app/main/Scheduler'));
|
||||
const CustomEntities = lazy(() => import('app/main/CustomEntities'));
|
||||
const Modules = lazy(() => import('app/main/Modules'));
|
||||
|
||||
const Status = lazy(() => import('app/status/Status'));
|
||||
const HardwareStatus = lazy(() => import('app/status/HardwareStatus'));
|
||||
const Activity = lazy(() => import('app/status/Activity'));
|
||||
const SystemLog = lazy(() => import('app/status/SystemLog'));
|
||||
const MqttStatus = lazy(() => import('app/status/MqttStatus'));
|
||||
const NTPStatus = lazy(() => import('app/status/NTPStatus'));
|
||||
const APStatus = lazy(() => import('app/status/APStatus'));
|
||||
const NetworkStatus = lazy(() => import('app/status/NetworkStatus'));
|
||||
const Version = lazy(() => import('app/status/Version'));
|
||||
|
||||
const Settings = lazy(() => import('app/settings/Settings'));
|
||||
const ApplicationSettings = lazy(() => import('app/settings/ApplicationSettings'));
|
||||
const MqttSettings = lazy(() => import('app/settings/MqttSettings'));
|
||||
const NTPSettings = lazy(() => import('app/settings/NTPSettings'));
|
||||
const APSettings = lazy(() => import('app/settings/APSettings'));
|
||||
const DownloadUpload = lazy(() => import('app/settings/DownloadUpload'));
|
||||
const Network = lazy(() => import('app/settings/network/Network'));
|
||||
const Security = lazy(() => import('app/settings/security/Security'));
|
||||
|
||||
const AuthenticatedRouting = memo(() => {
|
||||
const AuthenticatedRouting = () => {
|
||||
const { me } = useContext(AuthenticatedContext);
|
||||
return (
|
||||
<Layout>
|
||||
<Suspense fallback={<LoadingSpinner />}>
|
||||
<Routes>
|
||||
<Route path="/dashboard/*" element={<Dashboard />} />
|
||||
<Route path="/devices/*" element={<Devices />} />
|
||||
<Route path="/sensors/*" element={<Sensors />} />
|
||||
<Route path="/help/*" element={<Help />} />
|
||||
<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/*" element={<Status />} />
|
||||
<Route path="/status/hardwarestatus/*" element={<HardwareStatus />} />
|
||||
<Route path="/status/activity" element={<Activity />} />
|
||||
<Route path="/status/log" element={<SystemLog />} />
|
||||
<Route path="/status/mqtt" element={<MqttStatus />} />
|
||||
<Route path="/status/ntp" element={<NTPStatus />} />
|
||||
<Route path="/status/ap" element={<APStatus />} />
|
||||
<Route path="/status/network" element={<NetworkStatus />} />
|
||||
<Route path="/status/version" element={<Version />} />
|
||||
<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/application"
|
||||
element={<ApplicationSettings />}
|
||||
/>
|
||||
<Route path="/settings/mqtt" element={<MqttSettings />} />
|
||||
<Route path="/settings/ntp" element={<NTPSettings />} />
|
||||
<Route path="/settings/ap" element={<APSettings />} />
|
||||
<Route path="/settings/modules" element={<Modules />} />
|
||||
<Route path="/settings/downloadUpload" element={<DownloadUpload />} />
|
||||
{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="/settings/network/*" element={<Network />} />
|
||||
<Route path="/settings/security/*" element={<Security />} />
|
||||
|
||||
<Route path="/customizations" element={<Customizations />} />
|
||||
<Route path="/scheduler" element={<Scheduler />} />
|
||||
<Route path="/customentities" element={<CustomEntities />} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Route path="/*" element={<Navigate to="/" />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
<Route path="/customizations" element={<Customizations />} />
|
||||
<Route path="/scheduler" element={<Scheduler />} />
|
||||
<Route path="/customentities" element={<CustomEntities />} />
|
||||
</>
|
||||
)}
|
||||
</Routes>
|
||||
</Layout>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export default AuthenticatedRouting;
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import { memo } from 'react';
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { CssBaseline } from '@mui/material';
|
||||
import {
|
||||
CssBaseline,
|
||||
ThemeProvider,
|
||||
responsiveFontSizes,
|
||||
tooltipClasses
|
||||
} from '@mui/material';
|
||||
import { createTheme } from '@mui/material/styles';
|
||||
createTheme,
|
||||
responsiveFontSizes
|
||||
} from '@mui/material/styles';
|
||||
|
||||
import type { RequiredChildrenProps } from 'utils';
|
||||
|
||||
// Memoize dialog style to prevent recreation
|
||||
export const dialogStyle = {
|
||||
'& .MuiDialog-paper': {
|
||||
borderRadius: '8px',
|
||||
@@ -19,9 +16,8 @@ export const dialogStyle = {
|
||||
borderStyle: 'solid',
|
||||
borderWidth: '1px'
|
||||
}
|
||||
} as const;
|
||||
};
|
||||
|
||||
// Memoize theme creation to prevent recreation
|
||||
const theme = responsiveFontSizes(
|
||||
createTheme({
|
||||
typography: {
|
||||
@@ -38,45 +34,15 @@ const theme = responsiveFontSizes(
|
||||
text: {
|
||||
disabled: '#eee' // white
|
||||
}
|
||||
},
|
||||
components: {
|
||||
MuiListItemText: {
|
||||
styleOverrides: {
|
||||
primary: {
|
||||
fontSize: 14
|
||||
},
|
||||
secondary: {
|
||||
color: '#9e9e9e' // grey[500]
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiTooltip: {
|
||||
defaultProps: {
|
||||
placement: 'top',
|
||||
arrow: true
|
||||
},
|
||||
styleOverrides: {
|
||||
tooltip: {
|
||||
padding: '4px 8px',
|
||||
fontSize: 10,
|
||||
color: 'rgba(0, 0, 0, 0.87)',
|
||||
backgroundColor: '#4caf50', // MUI success.main default color
|
||||
boxShadow: '0px 2px 8px rgba(0, 0, 0, 0.15)',
|
||||
[`& .${tooltipClasses.arrow}`]: {
|
||||
color: '#4caf50'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const CustomTheme: FC<RequiredChildrenProps> = memo(({ children }) => (
|
||||
const CustomTheme: FC<RequiredChildrenProps> = ({ children }) => (
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
));
|
||||
);
|
||||
|
||||
export default CustomTheme;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useCallback, useContext, useMemo, useState } from 'react';
|
||||
import { useContext, useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import ForwardIcon from '@mui/icons-material/Forward';
|
||||
@@ -19,7 +19,7 @@ import type { SignInRequest } from 'types';
|
||||
import { onEnterCallback, updateValue } from 'utils';
|
||||
import { SIGN_IN_REQUEST_VALIDATOR, validate } from 'validators';
|
||||
|
||||
const SignIn = memo(() => {
|
||||
const SignIn = () => {
|
||||
const authenticationContext = useContext(AuthenticationContext);
|
||||
|
||||
const { LL } = useI18nContext();
|
||||
@@ -42,18 +42,9 @@ const SignIn = memo(() => {
|
||||
}
|
||||
});
|
||||
|
||||
// Memoize callback to prevent recreation on every render
|
||||
const updateLoginRequestValue = useMemo(
|
||||
() =>
|
||||
updateValue((updater) =>
|
||||
setSignInRequest(
|
||||
updater as unknown as (prevState: SignInRequest) => SignInRequest
|
||||
)
|
||||
),
|
||||
[]
|
||||
);
|
||||
const updateLoginRequestValue = updateValue(setSignInRequest);
|
||||
|
||||
const signIn = useCallback(async () => {
|
||||
const signIn = async () => {
|
||||
await callSignIn(signInRequest).catch((event: Error) => {
|
||||
if (event.message === 'Unauthorized') {
|
||||
toast.warning(LL.INVALID_LOGIN());
|
||||
@@ -62,9 +53,9 @@ const SignIn = memo(() => {
|
||||
}
|
||||
setProcessing(false);
|
||||
});
|
||||
}, [callSignIn, signInRequest, LL]);
|
||||
};
|
||||
|
||||
const validateAndSignIn = useCallback(async () => {
|
||||
const validateAndSignIn = async () => {
|
||||
setProcessing(true);
|
||||
SIGN_IN_REQUEST_VALIDATOR.messages({
|
||||
required: LL.IS_REQUIRED('%s')
|
||||
@@ -76,10 +67,9 @@ const SignIn = memo(() => {
|
||||
setFieldErrors(error as ValidateFieldsError);
|
||||
setProcessing(false);
|
||||
}
|
||||
}, [signInRequest, signIn, LL]);
|
||||
};
|
||||
|
||||
// Memoize callback to prevent recreation on every render
|
||||
const submitOnEnter = useMemo(() => onEnterCallback(signIn), [signIn]);
|
||||
const submitOnEnter = onEnterCallback(signIn);
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -108,7 +98,7 @@ const SignIn = memo(() => {
|
||||
|
||||
<Box display="flex" flexDirection="column" alignItems="center">
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
fieldErrors={fieldErrors}
|
||||
disabled={processing}
|
||||
sx={{
|
||||
width: 240
|
||||
@@ -127,7 +117,7 @@ const SignIn = memo(() => {
|
||||
}}
|
||||
/>
|
||||
<ValidatedPasswordField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
fieldErrors={fieldErrors}
|
||||
disabled={processing}
|
||||
sx={{
|
||||
width: 240
|
||||
@@ -154,6 +144,6 @@ const SignIn = memo(() => {
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export default SignIn;
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
Action,
|
||||
Activity,
|
||||
CoreData,
|
||||
DashboardData,
|
||||
DashboardItem,
|
||||
DeviceData,
|
||||
DeviceEntity,
|
||||
Entities,
|
||||
@@ -20,18 +20,19 @@ import type {
|
||||
WriteTemperatureSensor
|
||||
} from '../app/main/types';
|
||||
|
||||
const MSGPACK_CONFIG = { responseType: 'arraybuffer' as const };
|
||||
|
||||
// Dashboard
|
||||
export const readDashboard = () =>
|
||||
alovaInstance.Get<DashboardData>('/rest/dashboardData', MSGPACK_CONFIG);
|
||||
alovaInstance.Get<DashboardItem[]>('/rest/dashboardData', {
|
||||
responseType: 'arraybuffer' // uses msgpack
|
||||
});
|
||||
|
||||
// Devices
|
||||
export const readCoreData = () => alovaInstance.Get<CoreData>('/rest/coreData');
|
||||
export const readCoreData = () => alovaInstance.Get<CoreData>(`/rest/coreData`);
|
||||
export const readDeviceData = (id: number) =>
|
||||
alovaInstance.Get<DeviceData>('/rest/deviceData', {
|
||||
// alovaInstance.Get<DeviceData>(`/rest/deviceData/${id}`, {
|
||||
params: { id },
|
||||
...MSGPACK_CONFIG
|
||||
responseType: 'arraybuffer' // uses msgpack
|
||||
});
|
||||
export const writeDeviceValue = (data: { id: number; c: string; v: unknown }) =>
|
||||
alovaInstance.Post('/rest/writeDeviceValue', data);
|
||||
@@ -65,13 +66,12 @@ export const callAction = (action: Action) =>
|
||||
|
||||
// SettingsCustomization
|
||||
export const readDeviceEntities = (id: number) =>
|
||||
alovaInstance.Get<DeviceEntity[]>('/rest/deviceEntities', {
|
||||
// alovaInstance.Get<DeviceEntity[]>(`/rest/deviceEntities/${id}`, {
|
||||
alovaInstance.Get<DeviceEntity[]>(`/rest/deviceEntities`, {
|
||||
params: { id },
|
||||
...MSGPACK_CONFIG,
|
||||
// @ts-expect-error - exactOptionalPropertyTypes compatibility issue
|
||||
responseType: 'arraybuffer',
|
||||
transform(data) {
|
||||
const entities = data as DeviceEntity[];
|
||||
return entities.map((de) => ({
|
||||
return (data as DeviceEntity[]).map((de: DeviceEntity) => ({
|
||||
...de,
|
||||
o_m: de.m,
|
||||
o_cn: de.cn,
|
||||
@@ -92,10 +92,8 @@ export const writeDeviceName = (data: { id: number; name: string }) =>
|
||||
// SettingsScheduler
|
||||
export const readSchedule = () =>
|
||||
alovaInstance.Get<ScheduleItem[]>('/rest/schedule', {
|
||||
// @ts-expect-error - exactOptionalPropertyTypes compatibility issue
|
||||
transform(data) {
|
||||
const schedule = (data as Schedule).schedule;
|
||||
return schedule.map((si) => ({
|
||||
return (data as Schedule).schedule.map((si: ScheduleItem) => ({
|
||||
...si,
|
||||
o_id: si.id,
|
||||
o_active: si.active,
|
||||
@@ -115,8 +113,7 @@ export const writeSchedule = (data: Schedule) =>
|
||||
export const readModules = () =>
|
||||
alovaInstance.Get<ModuleItem[]>('/rest/modules', {
|
||||
transform(data) {
|
||||
const modules = (data as Modules).modules;
|
||||
return modules.map((mi) => ({
|
||||
return (data as Modules).modules.map((mi: ModuleItem) => ({
|
||||
...mi,
|
||||
o_enabled: mi.enabled,
|
||||
o_license: mi.license
|
||||
@@ -132,10 +129,8 @@ export const writeModules = (data: {
|
||||
// CustomEntities
|
||||
export const readCustomEntities = () =>
|
||||
alovaInstance.Get<EntityItem[]>('/rest/customEntities', {
|
||||
// @ts-expect-error - exactOptionalPropertyTypes compatibility issue
|
||||
transform(data) {
|
||||
const entities = (data as Entities).entities;
|
||||
return entities.map((ei) => ({
|
||||
return (data as Entities).entities.map((ei: EntityItem) => ({
|
||||
...ei,
|
||||
o_id: ei.id,
|
||||
o_ram: ei.ram,
|
||||
@@ -148,8 +143,7 @@ export const readCustomEntities = () =>
|
||||
o_name: ei.name,
|
||||
o_writeable: ei.writeable,
|
||||
o_value: ei.value,
|
||||
o_deleted: ei.deleted,
|
||||
o_hide: ei.hide
|
||||
o_deleted: ei.deleted
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -4,65 +4,60 @@ import ReactHook from 'alova/react';
|
||||
|
||||
import { unpack } from './unpack';
|
||||
|
||||
export const ACCESS_TOKEN = 'access_token' as const;
|
||||
|
||||
// Cached token to avoid repeated localStorage access
|
||||
let cachedToken: string | null = null;
|
||||
|
||||
const getAccessToken = (): string | null => {
|
||||
if (cachedToken === null) {
|
||||
cachedToken = localStorage.getItem(ACCESS_TOKEN);
|
||||
}
|
||||
return cachedToken;
|
||||
};
|
||||
|
||||
// Clear token cache when needed (e.g., on logout)
|
||||
export const clearTokenCache = (): void => {
|
||||
cachedToken = null;
|
||||
};
|
||||
|
||||
const handleResponse = async (response: AlovaXHRResponse) => {
|
||||
// Handle various HTTP status codes
|
||||
if (response.status === 205) {
|
||||
throw new Error('Reboot required');
|
||||
}
|
||||
if (response.status === 400) {
|
||||
throw new Error('Request Failed');
|
||||
}
|
||||
if (response.status >= 400) {
|
||||
throw new Error(response.statusText);
|
||||
}
|
||||
|
||||
const data = (await response.data) as ArrayBuffer;
|
||||
|
||||
// Unpack MessagePack data if ArrayBuffer
|
||||
if (data instanceof ArrayBuffer) {
|
||||
return unpack(data) as ArrayBuffer;
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
export const ACCESS_TOKEN = 'access_token';
|
||||
|
||||
export const alovaInstance = createAlova({
|
||||
statesHook: ReactHook,
|
||||
// timeout: 3000, // 3 seconds before throwing a timeout error, default is 0 = none
|
||||
cacheFor: null, // disable cache
|
||||
// cacheFor: {
|
||||
// GET: {
|
||||
// mode: 'memory',
|
||||
// expire: 60 * 10 * 1000 // 60 seconds in cache
|
||||
// }
|
||||
// },
|
||||
requestAdapter: xhrRequestAdapter(),
|
||||
beforeRequest(method) {
|
||||
const token = getAccessToken();
|
||||
if (token) {
|
||||
method.config.headers.Authorization = `Bearer ${token}`;
|
||||
if (localStorage.getItem(ACCESS_TOKEN)) {
|
||||
method.config.headers.Authorization =
|
||||
'Bearer ' + localStorage.getItem(ACCESS_TOKEN);
|
||||
}
|
||||
// for simulating vrey slow networks
|
||||
// return new Promise((resolve) => {
|
||||
// const random = 3000 + Math.random() * 2000;
|
||||
// setTimeout(resolve, Math.floor(random));
|
||||
// });
|
||||
},
|
||||
|
||||
responded: {
|
||||
onSuccess: handleResponse
|
||||
onSuccess: async (response: AlovaXHRResponse) => {
|
||||
// if (response.status === 202) {
|
||||
// throw new Error('Wait'); // wifi scan in progress
|
||||
// } else
|
||||
if (response.status === 205) {
|
||||
throw new Error('Reboot required');
|
||||
} else if (response.status === 400) {
|
||||
throw new Error('Request Failed');
|
||||
} else if (response.status >= 400) {
|
||||
throw new Error(response.statusText);
|
||||
}
|
||||
const data: ArrayBuffer = (await response.data) as ArrayBuffer;
|
||||
if (response.data instanceof ArrayBuffer) {
|
||||
return unpack(data) as ArrayBuffer;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
// Interceptor for request failure. This interceptor will be entered when the request is wrong.
|
||||
// http errors like 401 (unauthorized) are handled either in the methods or AuthenticatedRouting()
|
||||
// onError: (error, method) => {
|
||||
// alert(error.message);
|
||||
// }
|
||||
}
|
||||
});
|
||||
|
||||
export const alovaInstanceGH = createAlova({
|
||||
baseURL:
|
||||
process.env.NODE_ENV === 'development'
|
||||
? '/gh'
|
||||
: 'https://api.github.com/repos/emsesp/EMS-ESP32/releases',
|
||||
baseURL: 'https://api.github.com/repos/emsesp/EMS-ESP32/releases',
|
||||
statesHook: ReactHook,
|
||||
requestAdapter: xhrRequestAdapter()
|
||||
});
|
||||
|
||||
@@ -2,14 +2,12 @@ import type { NetworkSettingsType, NetworkStatusType, WiFiNetworkList } from 'ty
|
||||
|
||||
import { alovaInstance } from './endpoints';
|
||||
|
||||
const LIST_NETWORKS_TIMEOUT = 20000; // 20 seconds
|
||||
|
||||
export const readNetworkStatus = () =>
|
||||
alovaInstance.Get<NetworkStatusType>('/rest/networkStatus');
|
||||
export const scanNetworks = () => alovaInstance.Get('/rest/scanNetworks');
|
||||
export const listNetworks = () =>
|
||||
alovaInstance.Get<WiFiNetworkList>('/rest/listNetworks', {
|
||||
timeout: LIST_NETWORKS_TIMEOUT
|
||||
timeout: 20000 // 20 seconds
|
||||
});
|
||||
export const readNetworkSettings = () =>
|
||||
alovaInstance.Get<NetworkSettingsType>('/rest/networkSettings');
|
||||
|
||||
@@ -6,7 +6,7 @@ export const readNTPStatus = () =>
|
||||
alovaInstance.Get<NTPStatusType>('/rest/ntpStatus');
|
||||
|
||||
export const readNTPSettings = () =>
|
||||
alovaInstance.Get<NTPSettingsType>('/rest/ntpSettings');
|
||||
alovaInstance.Get<NTPSettingsType>('/rest/ntpSettings', {});
|
||||
export const updateNTPSettings = (data: NTPSettingsType) =>
|
||||
alovaInstance.Post<NTPSettingsType>('/rest/ntpSettings', data);
|
||||
|
||||
|
||||
@@ -2,46 +2,35 @@ import type { LogSettings, SystemStatus } from 'types';
|
||||
|
||||
import { alovaInstance, alovaInstanceGH } from './endpoints';
|
||||
|
||||
// systemStatus - also used to ping in System Monitor for pinging
|
||||
// systemStatus - also used to ping in Restart monitor for pinging
|
||||
export const readSystemStatus = () =>
|
||||
alovaInstance.Get<SystemStatus>('/rest/systemStatus');
|
||||
|
||||
// SystemLog
|
||||
export const readLogSettings = () =>
|
||||
alovaInstance.Get<LogSettings>('/rest/logSettings');
|
||||
alovaInstance.Get<LogSettings>(`/rest/logSettings`);
|
||||
export const updateLogSettings = (data: LogSettings) =>
|
||||
alovaInstance.Post('/rest/logSettings', data);
|
||||
export const fetchLogES = () => alovaInstance.Get('/es/log');
|
||||
|
||||
// Get versions from GitHub
|
||||
// cache for 10 minutes to stop getting the IP blocked by GitHub
|
||||
export const getStableVersion = () =>
|
||||
alovaInstanceGH.Get('latest', {
|
||||
cacheFor: 60 * 10 * 1000,
|
||||
transform(response: { data: { name: string; published_at: string } }) {
|
||||
return {
|
||||
name: response.data.name.substring(1),
|
||||
published_at: response.data.published_at
|
||||
};
|
||||
transform(response: { data: { name: string } }) {
|
||||
return response.data.name.substring(1);
|
||||
}
|
||||
});
|
||||
export const getDevVersion = () =>
|
||||
alovaInstanceGH.Get('tags/latest', {
|
||||
cacheFor: 60 * 10 * 1000,
|
||||
transform(response: { data: { name: string; published_at: string } }) {
|
||||
return {
|
||||
name: response.data.name.split(/\s+/).splice(-1)[0]?.substring(1) || '',
|
||||
published_at: response.data.published_at
|
||||
};
|
||||
transform(response: { data: { name: string } }) {
|
||||
return response.data.name.split(/\s+/).splice(-1)[0].substring(1);
|
||||
}
|
||||
});
|
||||
|
||||
const UPLOAD_TIMEOUT = 60000; // 1 minute
|
||||
|
||||
export const uploadFile = (file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return alovaInstance.Post('/rest/uploadFile', formData, {
|
||||
timeout: UPLOAD_TIMEOUT
|
||||
timeout: 60000 // override timeout for uploading firmware - 1 minute
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,37 +1,40 @@
|
||||
// @ts-nocheck - Optimized MessagePack unpacking library for EMS-ESP32
|
||||
let decoder,
|
||||
src,
|
||||
srcEnd,
|
||||
position = 0,
|
||||
strings = [],
|
||||
stringPosition = 0,
|
||||
currentUnpackr = {},
|
||||
currentStructures,
|
||||
srcString,
|
||||
srcStringStart = 0,
|
||||
srcStringEnd = 0,
|
||||
bundledStrings,
|
||||
referenceMap,
|
||||
dataView;
|
||||
const EMPTY_ARRAY = [],
|
||||
currentExtensions = [];
|
||||
const defaultOptions = { useRecords: false, mapsAsObjects: true };
|
||||
let decoder;
|
||||
try {
|
||||
decoder = new TextDecoder();
|
||||
} catch (error) {}
|
||||
class C1Type {}
|
||||
const C1 = new C1Type();
|
||||
let src;
|
||||
let srcEnd;
|
||||
let position = 0;
|
||||
const EMPTY_ARRAY = [];
|
||||
let strings = EMPTY_ARRAY;
|
||||
let stringPosition = 0;
|
||||
let currentUnpackr = {};
|
||||
let currentStructures;
|
||||
let srcString;
|
||||
let srcStringStart = 0;
|
||||
let srcStringEnd = 0;
|
||||
let bundledStrings;
|
||||
let referenceMap;
|
||||
const currentExtensions = [];
|
||||
let dataView;
|
||||
const defaultOptions = {
|
||||
useRecords: false,
|
||||
mapsAsObjects: true
|
||||
};
|
||||
export class C1Type {}
|
||||
export const C1 = new C1Type();
|
||||
C1.name = 'MessagePack 0xC1';
|
||||
let sequentialMode = false,
|
||||
inlineObjectReadThreshold = 2,
|
||||
readStruct,
|
||||
onLoadedStructures,
|
||||
onSaveState;
|
||||
let sequentialMode = false;
|
||||
let inlineObjectReadThreshold = 2;
|
||||
let readStruct, onLoadedStructures, onSaveState;
|
||||
// no-eval build
|
||||
try {
|
||||
new Function('');
|
||||
} catch (error) {
|
||||
// if eval variants are not supported, do not create inline object readers ever
|
||||
inlineObjectReadThreshold = Infinity;
|
||||
}
|
||||
|
||||
export class Unpackr {
|
||||
constructor(options) {
|
||||
if (options) {
|
||||
@@ -47,15 +50,19 @@ export class Unpackr {
|
||||
if (options.structures)
|
||||
options.structures.sharedLength = options.structures.length;
|
||||
else if (options.getStructures) {
|
||||
(options.structures = []).uninitialized = true;
|
||||
(options.structures = []).uninitialized = true; // this is what we use to denote an uninitialized structures
|
||||
options.structures.sharedLength = 0;
|
||||
}
|
||||
if (options.int64AsNumber) options.int64AsType = 'number';
|
||||
if (options.int64AsNumber) {
|
||||
options.int64AsType = 'number';
|
||||
}
|
||||
}
|
||||
Object.assign(this, options);
|
||||
}
|
||||
unpack(source, options?: { start?: number; end?: number; lazy?: boolean }) {
|
||||
|
||||
unpack(source, options?: any) {
|
||||
if (src) {
|
||||
// re-entrant execution, save the state and restore it after we do this unpack
|
||||
return saveState(() => {
|
||||
clearSource();
|
||||
return this
|
||||
@@ -79,6 +86,9 @@ export class Unpackr {
|
||||
strings = EMPTY_ARRAY;
|
||||
bundledStrings = null;
|
||||
src = source;
|
||||
// this provides cached access to the data view for a buffer if it is getting reused, which is a recommend
|
||||
// technique for getting data from a database where it can be copied into an existing buffer instead of creating
|
||||
// new ones
|
||||
try {
|
||||
dataView =
|
||||
source.dataView ||
|
||||
@@ -181,10 +191,10 @@ export class Unpackr {
|
||||
return this.unpack(source, end);
|
||||
}
|
||||
}
|
||||
function getPosition() {
|
||||
export function getPosition() {
|
||||
return position;
|
||||
}
|
||||
function checkedRead(options?: { lazy?: boolean }) {
|
||||
export function checkedRead(options: any) {
|
||||
try {
|
||||
if (!currentUnpackr.trusted && !sequentialMode) {
|
||||
const sharedLength = currentStructures.sharedLength || 0;
|
||||
@@ -254,7 +264,7 @@ function restoreStructures() {
|
||||
currentStructures.restoreStructures = null;
|
||||
}
|
||||
|
||||
function read() {
|
||||
export function read() {
|
||||
let token = src[position++];
|
||||
if (token < 0xa0) {
|
||||
if (token < 0x80) {
|
||||
@@ -579,7 +589,7 @@ const createSecondByteReader = (firstId, read0) =>
|
||||
return structure.read();
|
||||
};
|
||||
|
||||
function loadStructures() {
|
||||
export function loadStructures() {
|
||||
const loadedStructures = saveState(() => {
|
||||
// save the state in case getStructures modifies our buffer
|
||||
src = null;
|
||||
@@ -595,8 +605,9 @@ var readFixedString = readStringJS;
|
||||
var readString8 = readStringJS;
|
||||
var readString16 = readStringJS;
|
||||
var readString32 = readStringJS;
|
||||
let isNativeAccelerationEnabled = false;
|
||||
function setExtractor(extractStrings) {
|
||||
export let isNativeAccelerationEnabled = false;
|
||||
|
||||
export function setExtractor(extractStrings) {
|
||||
isNativeAccelerationEnabled = true;
|
||||
readFixedString = readString(1);
|
||||
readString8 = readString(2);
|
||||
@@ -690,7 +701,7 @@ function readStringJS(length) {
|
||||
|
||||
return result;
|
||||
}
|
||||
function readString(source, start, length) {
|
||||
export function readString(source, start, length) {
|
||||
const existingSrc = src;
|
||||
src = source;
|
||||
position = start;
|
||||
@@ -1054,7 +1065,7 @@ currentExtensions[0x70] = (data) => {
|
||||
|
||||
currentExtensions[0x73] = () => new Set(read());
|
||||
|
||||
const typedArrays = [
|
||||
export const typedArrays = [
|
||||
'Int8',
|
||||
'Uint8',
|
||||
'Uint8Clamped',
|
||||
@@ -1166,20 +1177,44 @@ function saveState(callback) {
|
||||
dataView = new DataView(src.buffer, src.byteOffset, src.byteLength);
|
||||
return value;
|
||||
}
|
||||
function clearSource() {
|
||||
export function clearSource() {
|
||||
src = null;
|
||||
referenceMap = null;
|
||||
currentStructures = null;
|
||||
}
|
||||
|
||||
function addExtension(extension) {
|
||||
export function addExtension(extension) {
|
||||
if (extension.unpack) currentExtensions[extension.type] = extension.unpack;
|
||||
else currentExtensions[extension.type] = extension;
|
||||
}
|
||||
|
||||
const mult10 = new Array(147);
|
||||
export const mult10 = new Array(147); // this is a table matching binary exponents to the multiplier to determine significant digit rounding
|
||||
for (let i = 0; i < 256; i++) {
|
||||
mult10[i] = +('1e' + Math.floor(45.15 - i * 0.30103));
|
||||
}
|
||||
const defaultUnpackr = new Unpackr({ useRecords: false });
|
||||
export const Decoder = Unpackr;
|
||||
var defaultUnpackr = new Unpackr({ useRecords: false });
|
||||
export const unpack = defaultUnpackr.unpack;
|
||||
export const unpackMultiple = defaultUnpackr.unpackMultiple;
|
||||
export const decode = defaultUnpackr.unpack;
|
||||
export const FLOAT32_OPTIONS = {
|
||||
NEVER: 0,
|
||||
ALWAYS: 1,
|
||||
DECIMAL_ROUND: 3,
|
||||
DECIMAL_FIT: 4
|
||||
};
|
||||
const f32Array = new Float32Array(1);
|
||||
const u8Array = new Uint8Array(f32Array.buffer, 0, 4);
|
||||
export function roundFloat32(float32Number) {
|
||||
f32Array[0] = float32Number;
|
||||
const multiplier = mult10[((u8Array[3] & 0x7f) << 1) | (u8Array[2] >> 7)];
|
||||
return (
|
||||
((multiplier * float32Number + (float32Number > 0 ? 0.5 : -0.5)) >> 0) /
|
||||
multiplier
|
||||
);
|
||||
}
|
||||
export function setReadStruct(updatedReadStruct, loadedStructs, saveState) {
|
||||
readStruct = updatedReadStruct;
|
||||
onLoadedStructures = loadedStructs;
|
||||
onSaveState = saveState;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useBlocker } from 'react-router';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useBlocker } from 'react-router-dom';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
@@ -35,10 +35,6 @@ import { DeviceValueTypeNames, DeviceValueUOM_s } from './types';
|
||||
import type { Entities, EntityItem } from './types';
|
||||
import { entityItemValidation } from './validators';
|
||||
|
||||
const MIN_ID = -100;
|
||||
const MAX_ID = 100;
|
||||
const ICON_SIZE = 12;
|
||||
|
||||
const CustomEntities = () => {
|
||||
const { LL } = useI18nContext();
|
||||
const [numChanges, setNumChanges] = useState<number>(0);
|
||||
@@ -57,20 +53,18 @@ const CustomEntities = () => {
|
||||
initialData: []
|
||||
});
|
||||
|
||||
const intervalCallback = useCallback(() => {
|
||||
useInterval(() => {
|
||||
if (!dialogOpen && !numChanges) {
|
||||
void fetchEntities();
|
||||
}
|
||||
}, [dialogOpen, numChanges, fetchEntities]);
|
||||
|
||||
useInterval(intervalCallback);
|
||||
}, 3000);
|
||||
|
||||
const { send: writeEntities } = useRequest(
|
||||
(data: Entities) => writeCustomEntities(data),
|
||||
{ immediate: false }
|
||||
);
|
||||
|
||||
const hasEntityChanged = useCallback((ei: EntityItem) => {
|
||||
function hasEntityChanged(ei: EntityItem) {
|
||||
return (
|
||||
ei.id !== ei.o_id ||
|
||||
ei.ram !== ei.o_ram ||
|
||||
@@ -82,25 +76,22 @@ const CustomEntities = () => {
|
||||
ei.factor !== ei.o_factor ||
|
||||
ei.value_type !== ei.o_value_type ||
|
||||
ei.writeable !== ei.o_writeable ||
|
||||
ei.hide !== ei.o_hide ||
|
||||
ei.deleted !== ei.o_deleted ||
|
||||
(ei.value || '') !== (ei.o_value || '')
|
||||
);
|
||||
}, []);
|
||||
}
|
||||
|
||||
const entity_theme = useMemo(
|
||||
() =>
|
||||
useTheme({
|
||||
Table: `
|
||||
--data-table-library_grid-template-columns: repeat(1, minmax(60px, 1fr)) minmax(80px, auto) 80px 80px 80px 120px;
|
||||
const entity_theme = useTheme({
|
||||
Table: `
|
||||
--data-table-library_grid-template-columns: repeat(1, minmax(60px, 1fr)) minmax(80px, auto) 80px 80px 80px 90px;
|
||||
`,
|
||||
BaseRow: `
|
||||
BaseRow: `
|
||||
font-size: 14px;
|
||||
.td {
|
||||
height: 32px;
|
||||
}
|
||||
`,
|
||||
BaseCell: `
|
||||
BaseCell: `
|
||||
&:nth-of-type(1) {
|
||||
padding: 8px;
|
||||
}
|
||||
@@ -120,7 +111,7 @@ const CustomEntities = () => {
|
||||
text-align: center;
|
||||
}
|
||||
`,
|
||||
HeaderRow: `
|
||||
HeaderRow: `
|
||||
text-transform: uppercase;
|
||||
background-color: black;
|
||||
color: #90CAF9;
|
||||
@@ -129,7 +120,7 @@ const CustomEntities = () => {
|
||||
height: 36px;
|
||||
}
|
||||
`,
|
||||
Row: `
|
||||
Row: `
|
||||
background-color: #1e1e1e;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
@@ -140,15 +131,13 @@ const CustomEntities = () => {
|
||||
background-color: #177ac9;
|
||||
}
|
||||
`
|
||||
}),
|
||||
[]
|
||||
);
|
||||
});
|
||||
|
||||
const saveEntities = useCallback(async () => {
|
||||
const saveEntities = async () => {
|
||||
await writeEntities({
|
||||
entities: entities
|
||||
.filter((ei: EntityItem) => !ei.deleted)
|
||||
.map((condensed_ei: EntityItem) => ({
|
||||
.filter((ei) => !ei.deleted)
|
||||
.map((condensed_ei) => ({
|
||||
id: condensed_ei.id,
|
||||
ram: condensed_ei.ram,
|
||||
name: condensed_ei.name,
|
||||
@@ -158,7 +147,6 @@ const CustomEntities = () => {
|
||||
factor: condensed_ei.factor,
|
||||
uom: condensed_ei.uom,
|
||||
writeable: condensed_ei.writeable,
|
||||
hide: condensed_ei.hide,
|
||||
value_type: condensed_ei.value_type,
|
||||
value: condensed_ei.value
|
||||
}))
|
||||
@@ -173,7 +161,7 @@ const CustomEntities = () => {
|
||||
await fetchEntities();
|
||||
setNumChanges(0);
|
||||
});
|
||||
}, [entities, writeEntities, LL, fetchEntities]);
|
||||
};
|
||||
|
||||
const editEntityItem = useCallback((ei: EntityItem) => {
|
||||
setCreating(false);
|
||||
@@ -181,59 +169,36 @@ const CustomEntities = () => {
|
||||
setDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const onDialogClose = useCallback(() => {
|
||||
const onDialogClose = () => {
|
||||
setDialogOpen(false);
|
||||
}, []);
|
||||
};
|
||||
|
||||
const onDialogCancel = useCallback(async () => {
|
||||
const onDialogCancel = async () => {
|
||||
await fetchEntities().then(() => {
|
||||
setNumChanges(0);
|
||||
});
|
||||
}, [fetchEntities]);
|
||||
};
|
||||
|
||||
const onDialogSave = useCallback(
|
||||
(updatedItem: EntityItem) => {
|
||||
setDialogOpen(false);
|
||||
void updateState(readCustomEntities(), (data: EntityItem[]) => {
|
||||
const new_data = creating
|
||||
? [
|
||||
...data.filter((ei) => creating || ei.o_id !== updatedItem.o_id),
|
||||
updatedItem
|
||||
]
|
||||
: data.map((ei) =>
|
||||
ei.id === updatedItem.id ? { ...ei, ...updatedItem } : ei
|
||||
);
|
||||
setNumChanges(new_data.filter((ei) => hasEntityChanged(ei)).length);
|
||||
return new_data;
|
||||
});
|
||||
},
|
||||
[creating, hasEntityChanged]
|
||||
);
|
||||
|
||||
const onDialogDup = useCallback((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
|
||||
const onDialogSave = (updatedItem: EntityItem) => {
|
||||
setDialogOpen(false);
|
||||
void updateState(readCustomEntities(), (data: EntityItem[]) => {
|
||||
const new_data = creating
|
||||
? [
|
||||
...data.filter((ei) => creating || ei.o_id !== updatedItem.o_id),
|
||||
updatedItem
|
||||
]
|
||||
: data.map((ei) =>
|
||||
ei.id === updatedItem.id ? { ...ei, ...updatedItem } : ei
|
||||
);
|
||||
setNumChanges(new_data.filter((ei) => hasEntityChanged(ei)).length);
|
||||
return new_data;
|
||||
});
|
||||
setDialogOpen(true);
|
||||
}, []);
|
||||
};
|
||||
|
||||
const addEntityItem = useCallback(() => {
|
||||
const addEntityItem = () => {
|
||||
setCreating(true);
|
||||
setSelectedEntityItem({
|
||||
id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
|
||||
id: Math.floor(Math.random() * (Math.floor(200) - 100) + 100),
|
||||
name: '',
|
||||
ram: 0,
|
||||
device_id: '0',
|
||||
@@ -244,44 +209,35 @@ const CustomEntities = () => {
|
||||
value_type: 0,
|
||||
writeable: false,
|
||||
deleted: false,
|
||||
hide: false,
|
||||
value: ''
|
||||
});
|
||||
setDialogOpen(true);
|
||||
}, []);
|
||||
};
|
||||
|
||||
const formatValue = useCallback((value: unknown, uom: number) => {
|
||||
function formatValue(value: unknown, uom: number) {
|
||||
return value === undefined
|
||||
? ''
|
||||
: typeof value === 'number'
|
||||
? new Intl.NumberFormat().format(value) +
|
||||
(uom === 0 ? '' : ` ${DeviceValueUOM_s[uom]}`)
|
||||
: `${value as string}${uom === 0 ? '' : ` ${DeviceValueUOM_s[uom]}`}`;
|
||||
}, []);
|
||||
(uom === 0 ? '' : ' ' + DeviceValueUOM_s[uom])
|
||||
: (value as string);
|
||||
}
|
||||
|
||||
const showHex = useCallback((value: number, digit: number) => {
|
||||
return `0x${value.toString(16).toUpperCase().padStart(digit, '0')}`;
|
||||
}, []);
|
||||
function showHex(value: number, digit: number) {
|
||||
return '0x' + value.toString(16).toUpperCase().padStart(digit, '0');
|
||||
}
|
||||
|
||||
const filteredAndSortedEntities = useMemo(
|
||||
() =>
|
||||
entities
|
||||
?.filter((ei: EntityItem) => !ei.deleted)
|
||||
.sort((a: EntityItem, b: EntityItem) => a.name.localeCompare(b.name)) ?? [],
|
||||
[entities]
|
||||
);
|
||||
|
||||
const renderEntity = useCallback(() => {
|
||||
const renderEntity = () => {
|
||||
if (!entities) {
|
||||
return (
|
||||
<FormLoader onRetry={fetchEntities} errorMessage={error?.message || ''} />
|
||||
);
|
||||
return <FormLoader onRetry={fetchEntities} errorMessage={error?.message} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Table
|
||||
data={{
|
||||
nodes: filteredAndSortedEntities
|
||||
nodes: entities
|
||||
.filter((ei) => !ei.deleted)
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
}}
|
||||
theme={entity_theme}
|
||||
layout={{ custom: true }}
|
||||
@@ -304,10 +260,7 @@ const CustomEntities = () => {
|
||||
<Cell>
|
||||
{ei.name}
|
||||
{ei.writeable && (
|
||||
<EditOutlinedIcon
|
||||
color="primary"
|
||||
sx={{ fontSize: ICON_SIZE }}
|
||||
/>
|
||||
<EditOutlinedIcon color="primary" sx={{ fontSize: 12 }} />
|
||||
)}
|
||||
</Cell>
|
||||
<Cell>
|
||||
@@ -326,17 +279,7 @@ const CustomEntities = () => {
|
||||
)}
|
||||
</Table>
|
||||
);
|
||||
}, [
|
||||
entities,
|
||||
error,
|
||||
fetchEntities,
|
||||
entity_theme,
|
||||
editEntityItem,
|
||||
LL,
|
||||
filteredAndSortedEntities,
|
||||
showHex,
|
||||
formatValue
|
||||
]);
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionContent>
|
||||
@@ -353,13 +296,12 @@ const CustomEntities = () => {
|
||||
creating={creating}
|
||||
onClose={onDialogClose}
|
||||
onSave={onDialogSave}
|
||||
onDup={onDialogDup}
|
||||
selectedItem={selectedEntityItem}
|
||||
validator={entityItemValidation(entities, selectedEntityItem)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Box mt={2} display="flex" flexWrap="wrap">
|
||||
<Box mt={1} display="flex" flexWrap="wrap">
|
||||
<Box flexGrow={1}>
|
||||
{numChanges > 0 && (
|
||||
<ButtonRow>
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import CommentsDisabledOutlinedIcon from '@mui/icons-material/CommentsDisabledOutlined';
|
||||
import DoneIcon from '@mui/icons-material/Done';
|
||||
import EditOffOutlinedIcon from '@mui/icons-material/EditOffOutlined';
|
||||
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
|
||||
import InsertCommentOutlinedIcon from '@mui/icons-material/InsertCommentOutlined';
|
||||
import RemoveIcon from '@mui/icons-material/RemoveCircleOutline';
|
||||
import {
|
||||
Box,
|
||||
@@ -16,11 +12,11 @@ import {
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Grid,
|
||||
InputAdornment,
|
||||
MenuItem,
|
||||
TextField
|
||||
} from '@mui/material';
|
||||
import Grid from '@mui/material/Grid2';
|
||||
|
||||
import { dialogStyle } from 'CustomTheme';
|
||||
import type Schema from 'async-validator';
|
||||
@@ -33,25 +29,11 @@ import { validate } from 'validators';
|
||||
import { DeviceValueType, DeviceValueTypeNames, DeviceValueUOM_s } from './types';
|
||||
import type { EntityItem } from './types';
|
||||
|
||||
// Constant value type options for the dropdown
|
||||
const VALUE_TYPE_OPTIONS = [
|
||||
DeviceValueType.BOOL,
|
||||
DeviceValueType.INT8,
|
||||
DeviceValueType.UINT8,
|
||||
DeviceValueType.INT16,
|
||||
DeviceValueType.UINT16,
|
||||
DeviceValueType.UINT24,
|
||||
DeviceValueType.TIME,
|
||||
DeviceValueType.UINT32,
|
||||
DeviceValueType.STRING
|
||||
] as const;
|
||||
|
||||
interface CustomEntitiesDialogProps {
|
||||
open: boolean;
|
||||
creating: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (ei: EntityItem) => void;
|
||||
onDup: (ei: EntityItem) => void;
|
||||
selectedItem: EntityItem;
|
||||
validator: Schema;
|
||||
}
|
||||
@@ -61,104 +43,53 @@ const CustomEntitiesDialog = ({
|
||||
creating,
|
||||
onClose,
|
||||
onSave,
|
||||
onDup,
|
||||
selectedItem,
|
||||
validator
|
||||
}: CustomEntitiesDialogProps) => {
|
||||
const { LL } = useI18nContext();
|
||||
const [editItem, setEditItem] = useState<EntityItem>(selectedItem);
|
||||
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
||||
const updateFormValue = useMemo(
|
||||
() =>
|
||||
updateValue(
|
||||
setEditItem as unknown as React.Dispatch<
|
||||
React.SetStateAction<Record<string, unknown>>
|
||||
>
|
||||
),
|
||||
[]
|
||||
);
|
||||
const updateFormValue = updateValue(setEditItem);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setFieldErrors(undefined);
|
||||
// Convert to hex strings - combined into single setEditItem call
|
||||
const deviceIdHex =
|
||||
typeof selectedItem.device_id === 'number'
|
||||
? selectedItem.device_id.toString(16).toUpperCase()
|
||||
: selectedItem.device_id;
|
||||
const typeIdHex =
|
||||
typeof selectedItem.type_id === 'number'
|
||||
? selectedItem.type_id.toString(16).toUpperCase()
|
||||
: selectedItem.type_id;
|
||||
const factorValue =
|
||||
selectedItem.value_type === DeviceValueType.BOOL &&
|
||||
typeof selectedItem.factor === 'number'
|
||||
? selectedItem.factor.toString(16).toUpperCase()
|
||||
: selectedItem.factor;
|
||||
|
||||
setEditItem(selectedItem);
|
||||
// convert to hex strings straight away
|
||||
setEditItem({
|
||||
...selectedItem,
|
||||
device_id: deviceIdHex,
|
||||
type_id: typeIdHex,
|
||||
factor: factorValue
|
||||
device_id: selectedItem.device_id.toString(16).toUpperCase(),
|
||||
type_id: selectedItem.type_id.toString(16).toUpperCase()
|
||||
});
|
||||
}
|
||||
}, [open, selectedItem]);
|
||||
|
||||
const handleClose = useCallback(
|
||||
(_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => {
|
||||
if (reason !== 'backdropClick') {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose]
|
||||
);
|
||||
const handleClose = (_event, reason: 'backdropClick' | 'escapeKeyDown') => {
|
||||
if (reason !== 'backdropClick') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const save = useCallback(async () => {
|
||||
const save = async () => {
|
||||
try {
|
||||
setFieldErrors(undefined);
|
||||
await validate(validator, editItem);
|
||||
|
||||
// Create a copy to avoid mutating the state directly
|
||||
const processedItem: EntityItem = { ...editItem };
|
||||
|
||||
if (typeof processedItem.device_id === 'string') {
|
||||
processedItem.device_id = Number.parseInt(processedItem.device_id, 16);
|
||||
if (typeof editItem.device_id === 'string') {
|
||||
editItem.device_id = parseInt(editItem.device_id, 16);
|
||||
}
|
||||
if (typeof processedItem.type_id === 'string') {
|
||||
processedItem.type_id = Number.parseInt(processedItem.type_id, 16);
|
||||
if (typeof editItem.type_id === 'string') {
|
||||
editItem.type_id = parseInt(editItem.type_id, 16);
|
||||
}
|
||||
if (
|
||||
processedItem.value_type === DeviceValueType.BOOL &&
|
||||
typeof processedItem.factor === 'string'
|
||||
) {
|
||||
processedItem.factor = Number.parseInt(processedItem.factor, 16);
|
||||
}
|
||||
onSave(processedItem);
|
||||
onSave(editItem);
|
||||
} catch (error) {
|
||||
setFieldErrors(error as ValidateFieldsError);
|
||||
}
|
||||
}, [validator, editItem, onSave]);
|
||||
};
|
||||
|
||||
const remove = useCallback(() => {
|
||||
const itemWithDeleted = { ...editItem, deleted: true };
|
||||
onSave(itemWithDeleted);
|
||||
}, [editItem, onSave]);
|
||||
|
||||
const dup = useCallback(() => {
|
||||
onDup(editItem);
|
||||
}, [editItem, onDup]);
|
||||
|
||||
// Memoize UOM menu items to avoid recreating on every render
|
||||
const uomMenuItems = useMemo(
|
||||
() =>
|
||||
DeviceValueUOM_s.map((val, i) => (
|
||||
<MenuItem key={val} value={i}>
|
||||
{val}
|
||||
</MenuItem>
|
||||
)),
|
||||
[]
|
||||
);
|
||||
const remove = () => {
|
||||
editItem.deleted = true;
|
||||
onSave(editItem);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
|
||||
@@ -166,10 +97,13 @@ const CustomEntitiesDialog = ({
|
||||
{creating ? LL.ADD(1) + ' ' + LL.NEW(1) : LL.EDIT()} {LL.ENTITY()}
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<Box display="flex" flexWrap="wrap" mb={1}>
|
||||
<Box flexWrap="nowrap" whiteSpace="nowrap" />
|
||||
</Box>
|
||||
<Grid container spacing={2} rowSpacing={0}>
|
||||
<Grid size={12}>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
fieldErrors={fieldErrors}
|
||||
name="name"
|
||||
label={LL.NAME(0)}
|
||||
value={editItem.name}
|
||||
@@ -178,20 +112,6 @@ const CustomEntitiesDialog = ({
|
||||
onChange={updateFormValue}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid mt={3}>
|
||||
<BlockFormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
icon={<InsertCommentOutlinedIcon htmlColor="white" />}
|
||||
checkedIcon={<CommentsDisabledOutlinedIcon color="primary" />}
|
||||
checked={editItem.hide}
|
||||
onChange={updateFormValue}
|
||||
name="hide"
|
||||
/>
|
||||
}
|
||||
label="API/MQTT"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid>
|
||||
<TextField
|
||||
name="ram"
|
||||
@@ -208,41 +128,25 @@ const CustomEntitiesDialog = ({
|
||||
</TextField>
|
||||
</Grid>
|
||||
{editItem.ram === 1 && (
|
||||
<>
|
||||
<Grid>
|
||||
<TextField
|
||||
name="value"
|
||||
label={LL.DEFAULT(0) + ' ' + LL.VALUE(0)}
|
||||
type="string"
|
||||
value={editItem.value as string}
|
||||
variant="outlined"
|
||||
onChange={updateFormValue}
|
||||
fullWidth
|
||||
margin="normal"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid>
|
||||
<TextField
|
||||
name="uom"
|
||||
label={LL.UNIT()}
|
||||
value={editItem.uom}
|
||||
margin="normal"
|
||||
onChange={updateFormValue}
|
||||
select
|
||||
>
|
||||
{uomMenuItems}
|
||||
</TextField>
|
||||
</Grid>
|
||||
</>
|
||||
<Grid>
|
||||
<TextField
|
||||
name="value"
|
||||
label={LL.DEFAULT(0) + ' ' + LL.VALUE(0)}
|
||||
type="string"
|
||||
value={editItem.value as string}
|
||||
variant="outlined"
|
||||
onChange={updateFormValue}
|
||||
fullWidth
|
||||
margin="normal"
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
{editItem.ram === 0 && (
|
||||
<>
|
||||
<Grid mt={3}>
|
||||
<Grid mt={3} size={9}>
|
||||
<BlockFormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
icon={<EditOffOutlinedIcon color="primary" />}
|
||||
checkedIcon={<EditOutlinedIcon htmlColor="white" />}
|
||||
checked={editItem.writeable}
|
||||
onChange={updateFormValue}
|
||||
name="writeable"
|
||||
@@ -253,7 +157,7 @@ const CustomEntitiesDialog = ({
|
||||
</Grid>
|
||||
<Grid>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
fieldErrors={fieldErrors}
|
||||
name="device_id"
|
||||
label={LL.ID_OF(LL.DEVICE())}
|
||||
margin="normal"
|
||||
@@ -273,7 +177,7 @@ const CustomEntitiesDialog = ({
|
||||
</Grid>
|
||||
<Grid>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
fieldErrors={fieldErrors}
|
||||
name="type_id"
|
||||
label={LL.ID_OF(LL.TYPE(1))}
|
||||
margin="normal"
|
||||
@@ -293,7 +197,7 @@ const CustomEntitiesDialog = ({
|
||||
</Grid>
|
||||
<Grid>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
fieldErrors={fieldErrors}
|
||||
name="offset"
|
||||
label={LL.OFFSET()}
|
||||
margin="normal"
|
||||
@@ -314,11 +218,33 @@ const CustomEntitiesDialog = ({
|
||||
margin="normal"
|
||||
select
|
||||
>
|
||||
{VALUE_TYPE_OPTIONS.map((valueType) => (
|
||||
<MenuItem key={valueType} value={valueType}>
|
||||
{DeviceValueTypeNames[valueType]}
|
||||
</MenuItem>
|
||||
))}
|
||||
<MenuItem value={DeviceValueType.BOOL}>
|
||||
{DeviceValueTypeNames[DeviceValueType.BOOL]}
|
||||
</MenuItem>
|
||||
<MenuItem value={DeviceValueType.INT8}>
|
||||
{DeviceValueTypeNames[DeviceValueType.INT8]}
|
||||
</MenuItem>
|
||||
<MenuItem value={DeviceValueType.UINT8}>
|
||||
{DeviceValueTypeNames[DeviceValueType.UINT8]}
|
||||
</MenuItem>
|
||||
<MenuItem value={DeviceValueType.INT16}>
|
||||
{DeviceValueTypeNames[DeviceValueType.INT16]}
|
||||
</MenuItem>
|
||||
<MenuItem value={DeviceValueType.UINT16}>
|
||||
{DeviceValueTypeNames[DeviceValueType.UINT16]}
|
||||
</MenuItem>
|
||||
<MenuItem value={DeviceValueType.UINT24}>
|
||||
{DeviceValueTypeNames[DeviceValueType.UINT24]}
|
||||
</MenuItem>
|
||||
<MenuItem value={DeviceValueType.TIME}>
|
||||
{DeviceValueTypeNames[DeviceValueType.TIME]}
|
||||
</MenuItem>
|
||||
<MenuItem value={DeviceValueType.UINT32}>
|
||||
{DeviceValueTypeNames[DeviceValueType.UINT32]}
|
||||
</MenuItem>
|
||||
<MenuItem value={DeviceValueType.STRING}>
|
||||
{DeviceValueTypeNames[DeviceValueType.STRING]}
|
||||
</MenuItem>
|
||||
</TextField>
|
||||
</Grid>
|
||||
|
||||
@@ -329,7 +255,7 @@ const CustomEntitiesDialog = ({
|
||||
<TextField
|
||||
name="factor"
|
||||
label={LL.FACTOR()}
|
||||
value={numberValue(editItem.factor as number)}
|
||||
value={numberValue(editItem.factor)}
|
||||
variant="outlined"
|
||||
onChange={updateFormValue}
|
||||
sx={{ width: '11ch' }}
|
||||
@@ -350,7 +276,11 @@ const CustomEntitiesDialog = ({
|
||||
onChange={updateFormValue}
|
||||
select
|
||||
>
|
||||
{uomMenuItems}
|
||||
{DeviceValueUOM_s.map((val, i) => (
|
||||
<MenuItem key={val} value={i}>
|
||||
{val}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
</Grid>
|
||||
</>
|
||||
@@ -359,44 +289,18 @@ const CustomEntitiesDialog = ({
|
||||
editItem.device_id !== '0' && (
|
||||
<Grid>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
fieldErrors={fieldErrors}
|
||||
name="factor"
|
||||
label={LL.BYTES()}
|
||||
value={numberValue(editItem.factor as number)}
|
||||
label="Bytes"
|
||||
value={numberValue(editItem.factor)}
|
||||
sx={{ width: '11ch' }}
|
||||
variant="outlined"
|
||||
onChange={updateFormValue}
|
||||
margin="normal"
|
||||
type="number"
|
||||
slotProps={{
|
||||
htmlInput: { step: '1', min: '1', max: '255' }
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
{editItem.value_type === DeviceValueType.BOOL && (
|
||||
<Grid>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
name="factor"
|
||||
label={LL.BITMASK()}
|
||||
value={editItem.factor as string}
|
||||
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>
|
||||
@@ -412,15 +316,6 @@ const CustomEntitiesDialog = ({
|
||||
>
|
||||
{LL.REMOVE()}
|
||||
</Button>
|
||||
<Button
|
||||
sx={{ ml: 1 }}
|
||||
startIcon={<AddIcon />}
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={dup}
|
||||
>
|
||||
{LL.DUPLICATE()}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
<Button
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useBlocker, useLocation } from 'react-router';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useBlocker, useLocation } from 'react-router-dom';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Grid,
|
||||
InputAdornment,
|
||||
Link,
|
||||
MenuItem,
|
||||
@@ -25,6 +24,7 @@ import {
|
||||
ToggleButtonGroup,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
import Grid from '@mui/material/Grid2';
|
||||
|
||||
import {
|
||||
Body,
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
import { useTheme } from '@table-library/react-table-library/theme';
|
||||
import { dialogStyle } from 'CustomTheme';
|
||||
import { useRequest } from 'alova/client';
|
||||
import SystemMonitor from 'app/status/SystemMonitor';
|
||||
import RestartMonitor from 'app/status/RestartMonitor';
|
||||
import {
|
||||
BlockNavigation,
|
||||
ButtonRow,
|
||||
@@ -62,24 +62,7 @@ import OptionIcon from './OptionIcon';
|
||||
import { DeviceEntityMask } from './types';
|
||||
import type { APIcall, Device, DeviceEntity } from './types';
|
||||
|
||||
export const APIURL = `${window.location.origin}/api/`;
|
||||
|
||||
const MAX_BUFFER_SIZE = 2000;
|
||||
|
||||
// Helper function to create masked entity ID - extracted to avoid duplication
|
||||
const createMaskedEntityId = (de: DeviceEntity): string => {
|
||||
const maskHex = de.m.toString(16).padStart(2, '0');
|
||||
const hasCustomizations = !!(de.cn || de.mi || de.ma);
|
||||
const customizations = [
|
||||
de.cn || '',
|
||||
de.mi ? `>${de.mi}` : '',
|
||||
de.ma ? `<${de.ma}` : ''
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('');
|
||||
|
||||
return `${maskHex}${de.id}${hasCustomizations ? `|${customizations}` : ''}`;
|
||||
};
|
||||
export const APIURL = window.location.origin + '/api/';
|
||||
|
||||
const Customizations = () => {
|
||||
const { LL } = useI18nContext();
|
||||
@@ -142,22 +125,13 @@ const Customizations = () => {
|
||||
|
||||
const setOriginalSettings = (data: DeviceEntity[]) => {
|
||||
setDeviceEntities(
|
||||
data.map((de) => {
|
||||
const result: DeviceEntity = {
|
||||
...de,
|
||||
o_m: de.m
|
||||
};
|
||||
if (de.cn !== undefined) {
|
||||
result.o_cn = de.cn;
|
||||
}
|
||||
if (de.mi !== undefined) {
|
||||
result.o_mi = de.mi;
|
||||
}
|
||||
if (de.ma !== undefined) {
|
||||
result.o_ma = de.ma;
|
||||
}
|
||||
return result;
|
||||
})
|
||||
data.map((de) => ({
|
||||
...de,
|
||||
o_m: de.m,
|
||||
o_cn: de.cn,
|
||||
o_mi: de.mi,
|
||||
o_ma: de.ma
|
||||
}))
|
||||
);
|
||||
};
|
||||
|
||||
@@ -170,19 +144,17 @@ const Customizations = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const entities_theme = useMemo(
|
||||
() =>
|
||||
useTheme({
|
||||
Table: `
|
||||
const entities_theme = useTheme({
|
||||
Table: `
|
||||
--data-table-library_grid-template-columns: 156px repeat(1, minmax(80px, 1fr)) 45px minmax(45px, auto) minmax(120px, auto);
|
||||
`,
|
||||
BaseRow: `
|
||||
BaseRow: `
|
||||
font-size: 14px;
|
||||
.td {
|
||||
height: 32px;
|
||||
}
|
||||
`,
|
||||
BaseCell: `
|
||||
BaseCell: `
|
||||
&:nth-of-type(3) {
|
||||
text-align: right;
|
||||
}
|
||||
@@ -193,7 +165,7 @@ const Customizations = () => {
|
||||
text-align: right;
|
||||
}
|
||||
`,
|
||||
HeaderRow: `
|
||||
HeaderRow: `
|
||||
text-transform: uppercase;
|
||||
background-color: black;
|
||||
color: #90CAF9;
|
||||
@@ -205,7 +177,7 @@ const Customizations = () => {
|
||||
text-align: center;
|
||||
}
|
||||
`,
|
||||
Row: `
|
||||
Row: `
|
||||
background-color: #1e1e1e;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
@@ -221,7 +193,7 @@ const Customizations = () => {
|
||||
background-color: #177ac9;
|
||||
}
|
||||
`,
|
||||
Cell: `
|
||||
Cell: `
|
||||
&:nth-of-type(2) {
|
||||
padding: 8px;
|
||||
}
|
||||
@@ -235,9 +207,7 @@ const Customizations = () => {
|
||||
padding-right: 8px;
|
||||
}
|
||||
`
|
||||
}),
|
||||
[]
|
||||
);
|
||||
});
|
||||
|
||||
function hasEntityChanged(de: DeviceEntity) {
|
||||
return (
|
||||
@@ -250,8 +220,19 @@ const Customizations = () => {
|
||||
|
||||
useEffect(() => {
|
||||
if (deviceEntities.length) {
|
||||
const changedEntities = deviceEntities.filter((de) => hasEntityChanged(de));
|
||||
setNumChanges(changedEntities.length);
|
||||
setNumChanges(
|
||||
deviceEntities
|
||||
.filter((de) => hasEntityChanged(de))
|
||||
.map(
|
||||
(new_de) =>
|
||||
new_de.m.toString(16).padStart(2, '0') +
|
||||
new_de.id +
|
||||
(new_de.cn || new_de.mi || new_de.ma ? '|' : '') +
|
||||
(new_de.cn ? new_de.cn : '') +
|
||||
(new_de.mi ? '>' + new_de.mi : '') +
|
||||
(new_de.ma ? '<' + new_de.ma : '')
|
||||
).length
|
||||
);
|
||||
}
|
||||
}, [deviceEntities]);
|
||||
|
||||
@@ -263,11 +244,8 @@ const Customizations = () => {
|
||||
setSelectedDevice(-1);
|
||||
setSelectedDeviceTypeNameURL('');
|
||||
} else {
|
||||
const device = devices.devices[index];
|
||||
if (device) {
|
||||
setSelectedDeviceTypeNameURL(device.url || '');
|
||||
setSelectedDeviceName(device.n);
|
||||
}
|
||||
setSelectedDeviceTypeNameURL(devices.devices[index].url || '');
|
||||
setSelectedDeviceName(devices.devices[index].n);
|
||||
setNumChanges(0);
|
||||
setRestartNeeded(false);
|
||||
}
|
||||
@@ -285,22 +263,18 @@ const Customizations = () => {
|
||||
return value as string;
|
||||
}
|
||||
|
||||
const formatName = useCallback(
|
||||
(de: DeviceEntity, withShortname: boolean) => {
|
||||
let name: string;
|
||||
if (de.n && de.n[0] === '!') {
|
||||
name = de.t
|
||||
? `${LL.COMMAND(1)}: ${de.t} ${de.n.slice(1)}`
|
||||
: `${LL.COMMAND(1)}: ${de.n.slice(1)}`;
|
||||
} else if (de.cn && de.cn !== '') {
|
||||
name = de.t ? `${de.t} ${de.cn}` : de.cn;
|
||||
} else {
|
||||
name = de.t ? `${de.t} ${de.n}` : de.n || '';
|
||||
}
|
||||
return withShortname ? `${name} ${de.id}` : name;
|
||||
},
|
||||
[LL]
|
||||
);
|
||||
const formatName = (de: DeviceEntity, withShortname: boolean) =>
|
||||
(de.n && de.n[0] === '!'
|
||||
? de.t
|
||||
? LL.COMMAND(1) + ': ' + de.t + ' ' + de.n.slice(1)
|
||||
: LL.COMMAND(1) + ': ' + de.n.slice(1)
|
||||
: de.cn && de.cn !== ''
|
||||
? de.t
|
||||
? de.t + ' ' + de.cn
|
||||
: de.cn
|
||||
: de.t
|
||||
? de.t + ' ' + de.n
|
||||
: de.n) + (withShortname ? ' ' + de.id : '');
|
||||
|
||||
const getMaskNumber = (newMask: string[]) => {
|
||||
let new_mask = 0;
|
||||
@@ -330,33 +304,34 @@ const Customizations = () => {
|
||||
return new_masks;
|
||||
};
|
||||
|
||||
const filter_entity = useCallback(
|
||||
(de: DeviceEntity) =>
|
||||
(de.m & selectedFilters || !selectedFilters) &&
|
||||
formatName(de, true).toLowerCase().includes(search.toLowerCase()),
|
||||
[selectedFilters, search, formatName]
|
||||
);
|
||||
const filter_entity = (de: DeviceEntity) =>
|
||||
(de.m & selectedFilters || !selectedFilters) &&
|
||||
formatName(de, true).includes(search);
|
||||
|
||||
const maskDisabled = useCallback(
|
||||
(set: boolean) => {
|
||||
setDeviceEntities((prev) =>
|
||||
prev.map((de) => {
|
||||
if (filter_entity(de)) {
|
||||
const excludeMask =
|
||||
DeviceEntityMask.DV_API_MQTT_EXCLUDE | DeviceEntityMask.DV_WEB_EXCLUDE;
|
||||
return {
|
||||
...de,
|
||||
m: set ? de.m | excludeMask : de.m & ~excludeMask
|
||||
};
|
||||
}
|
||||
const maskDisabled = (set: boolean) => {
|
||||
setDeviceEntities(
|
||||
deviceEntities.map(function (de) {
|
||||
if (filter_entity(de)) {
|
||||
return {
|
||||
...de,
|
||||
m: set
|
||||
? de.m |
|
||||
(DeviceEntityMask.DV_API_MQTT_EXCLUDE |
|
||||
DeviceEntityMask.DV_WEB_EXCLUDE)
|
||||
: de.m &
|
||||
~(
|
||||
DeviceEntityMask.DV_API_MQTT_EXCLUDE |
|
||||
DeviceEntityMask.DV_WEB_EXCLUDE
|
||||
)
|
||||
};
|
||||
} else {
|
||||
return de;
|
||||
})
|
||||
);
|
||||
},
|
||||
[filter_entity]
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const resetCustomization = useCallback(async () => {
|
||||
const resetCustomization = async () => {
|
||||
try {
|
||||
await sendResetCustomizations();
|
||||
toast.info(LL.CUSTOMIZATIONS_RESTART());
|
||||
@@ -365,28 +340,24 @@ const Customizations = () => {
|
||||
} finally {
|
||||
setConfirmReset(false);
|
||||
}
|
||||
}, [sendResetCustomizations, LL]);
|
||||
};
|
||||
|
||||
const onDialogClose = () => {
|
||||
setDialogOpen(false);
|
||||
};
|
||||
|
||||
const updateDeviceEntity = useCallback((updatedItem: DeviceEntity) => {
|
||||
const updateDeviceEntity = (updatedItem: DeviceEntity) => {
|
||||
setDeviceEntities(
|
||||
(prev) =>
|
||||
prev?.map((de) =>
|
||||
de.id === updatedItem.id ? { ...de, ...updatedItem } : de
|
||||
) ?? []
|
||||
deviceEntities?.map((de) =>
|
||||
de.id === updatedItem.id ? { ...de, ...updatedItem } : de
|
||||
)
|
||||
);
|
||||
}, []);
|
||||
};
|
||||
|
||||
const onDialogSave = useCallback(
|
||||
(updatedItem: DeviceEntity) => {
|
||||
setDialogOpen(false);
|
||||
updateDeviceEntity(updatedItem);
|
||||
},
|
||||
[updateDeviceEntity]
|
||||
);
|
||||
const onDialogSave = (updatedItem: DeviceEntity) => {
|
||||
setDialogOpen(false);
|
||||
updateDeviceEntity(updatedItem);
|
||||
};
|
||||
|
||||
const editDeviceEntity = useCallback((de: DeviceEntity) => {
|
||||
if (de.n === undefined || (de.n && de.n[0] === '!')) {
|
||||
@@ -401,54 +372,54 @@ const Customizations = () => {
|
||||
setDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const saveCustomization = useCallback(async () => {
|
||||
if (!devices || !deviceEntities || selectedDevice === -1) {
|
||||
return;
|
||||
}
|
||||
const saveCustomization = async () => {
|
||||
if (devices && deviceEntities && selectedDevice !== -1) {
|
||||
const masked_entities = deviceEntities
|
||||
.filter((de: DeviceEntity) => hasEntityChanged(de))
|
||||
.map(
|
||||
(new_de) =>
|
||||
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
|
||||
.filter((de: DeviceEntity) => hasEntityChanged(de))
|
||||
.map((new_de) => createMaskedEntityId(new_de));
|
||||
// check size in bytes to match buffer in CPP, which is 2048
|
||||
const bytes = new TextEncoder().encode(JSON.stringify(masked_entities)).length;
|
||||
if (bytes > 2000) {
|
||||
toast.warning(LL.CUSTOMIZATIONS_FULL());
|
||||
return;
|
||||
}
|
||||
|
||||
// check size in bytes to match buffer in CPP, which is 2048
|
||||
const bytes = new TextEncoder().encode(JSON.stringify(masked_entities)).length;
|
||||
if (bytes > MAX_BUFFER_SIZE) {
|
||||
toast.warning(LL.CUSTOMIZATIONS_FULL());
|
||||
return;
|
||||
}
|
||||
|
||||
await sendCustomizationEntities({
|
||||
id: selectedDevice,
|
||||
entity_ids: masked_entities
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(LL.CUSTOMIZATIONS_SAVED());
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
await sendCustomizationEntities({
|
||||
id: selectedDevice,
|
||||
entity_ids: masked_entities
|
||||
}).catch((error: Error) => {
|
||||
if (error.message === 'Reboot required') {
|
||||
setRestartNeeded(true);
|
||||
} else {
|
||||
toast.error(error.message);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setOriginalSettings(deviceEntities);
|
||||
});
|
||||
}, [devices, deviceEntities, selectedDevice, sendCustomizationEntities, LL]);
|
||||
setOriginalSettings(deviceEntities);
|
||||
}
|
||||
};
|
||||
|
||||
const renameDevice = useCallback(async () => {
|
||||
const renameDevice = async () => {
|
||||
await sendDeviceName({ id: selectedDevice, name: selectedDeviceName })
|
||||
.then(() => {
|
||||
toast.success(LL.UPDATED_OF(LL.NAME(1)));
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(`${LL.UPDATE_OF(LL.NAME(1))} ${LL.FAILED(1)}`);
|
||||
toast.error(LL.UPDATE_OF(LL.NAME(1)) + ' ' + LL.FAILED(1));
|
||||
})
|
||||
.finally(async () => {
|
||||
setRename(false);
|
||||
await fetchCoreData();
|
||||
});
|
||||
}, [selectedDevice, selectedDeviceName, sendDeviceName, LL, fetchCoreData]);
|
||||
};
|
||||
|
||||
const renderDeviceList = () => (
|
||||
<>
|
||||
@@ -523,12 +494,9 @@ const Customizations = () => {
|
||||
</>
|
||||
);
|
||||
|
||||
const filteredEntities = useMemo(
|
||||
() => deviceEntities.filter((de) => filter_entity(de)),
|
||||
[deviceEntities, filter_entity]
|
||||
);
|
||||
|
||||
const renderDeviceData = () => {
|
||||
const shown_data = deviceEntities.filter((de) => filter_entity(de));
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box color="warning.main">
|
||||
@@ -558,7 +526,6 @@ const Customizations = () => {
|
||||
size="small"
|
||||
variant="outlined"
|
||||
placeholder={LL.SEARCH()}
|
||||
aria-label={LL.SEARCH()}
|
||||
onChange={(event) => {
|
||||
setSearch(event.target.value);
|
||||
}}
|
||||
@@ -578,7 +545,7 @@ const Customizations = () => {
|
||||
size="small"
|
||||
color="secondary"
|
||||
value={getMaskString(selectedFilters)}
|
||||
onChange={(_, mask: string[]) => {
|
||||
onChange={(event, mask: string[]) => {
|
||||
setSelectedFilters(getMaskNumber(mask));
|
||||
}}
|
||||
>
|
||||
@@ -626,14 +593,14 @@ const Customizations = () => {
|
||||
</Button>
|
||||
</Grid>
|
||||
<Grid>
|
||||
<Typography variant="subtitle2" color="grey">
|
||||
{LL.SHOWING()} {filteredEntities.length}/{deviceEntities.length}
|
||||
<Typography variant="subtitle2" color="primary">
|
||||
{LL.SHOWING()} {shown_data.length}/{deviceEntities.length}
|
||||
{LL.ENTITIES(deviceEntities.length)}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Table
|
||||
data={{ nodes: filteredEntities }}
|
||||
data={{ nodes: shown_data }}
|
||||
theme={entities_theme}
|
||||
layout={{ custom: true }}
|
||||
>
|
||||
@@ -734,11 +701,7 @@ const Customizations = () => {
|
||||
startIcon={<CancelIcon />}
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={() => {
|
||||
if (devices) {
|
||||
void sendDeviceEntities(selectedDevice);
|
||||
}
|
||||
}}
|
||||
onClick={() => devices && sendDeviceEntities(selectedDevice)}
|
||||
>
|
||||
{LL.CANCEL()}
|
||||
</Button>
|
||||
@@ -774,7 +737,7 @@ const Customizations = () => {
|
||||
return (
|
||||
<SectionContent>
|
||||
{blocker ? <BlockNavigation blocker={blocker} /> : null}
|
||||
{restarting ? <SystemMonitor /> : renderContent()}
|
||||
{restarting ? <RestartMonitor /> : renderContent()}
|
||||
{selectedDeviceEntity && (
|
||||
<SettingsCustomizationsDialog
|
||||
open={dialogOpen}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
@@ -10,10 +10,10 @@ import {
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Grid,
|
||||
TextField,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
import Grid from '@mui/material/Grid2';
|
||||
|
||||
import { dialogStyle } from 'CustomTheme';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
@@ -30,23 +30,6 @@ interface SettingsCustomizationsDialogProps {
|
||||
selectedItem: DeviceEntity;
|
||||
}
|
||||
|
||||
interface LabelValueProps {
|
||||
label: string;
|
||||
value: React.ReactNode;
|
||||
}
|
||||
|
||||
const LabelValue = memo(({ label, value }: LabelValueProps) => (
|
||||
<Grid container direction="row">
|
||||
<Typography variant="body2" color="warning.main">
|
||||
{label}:
|
||||
</Typography>
|
||||
<Typography variant="body2">{value}</Typography>
|
||||
</Grid>
|
||||
));
|
||||
LabelValue.displayName = 'LabelValue';
|
||||
|
||||
const ICON_SIZE = 16;
|
||||
|
||||
const CustomizationsDialog = ({
|
||||
open,
|
||||
onClose,
|
||||
@@ -57,23 +40,12 @@ const CustomizationsDialog = ({
|
||||
const [editItem, setEditItem] = useState<DeviceEntity>(selectedItem);
|
||||
const [error, setError] = useState<boolean>(false);
|
||||
|
||||
const updateFormValue = useMemo(
|
||||
() =>
|
||||
updateValue(
|
||||
setEditItem as unknown as React.Dispatch<
|
||||
React.SetStateAction<Record<string, unknown>>
|
||||
>
|
||||
),
|
||||
[]
|
||||
);
|
||||
const updateFormValue = updateValue(setEditItem);
|
||||
|
||||
const isWriteableNumber = useMemo(
|
||||
() =>
|
||||
typeof editItem.v === 'number' &&
|
||||
editItem.w &&
|
||||
!(editItem.m & DeviceEntityMask.DV_READONLY),
|
||||
[editItem.v, editItem.w, editItem.m]
|
||||
);
|
||||
const isWriteableNumber =
|
||||
typeof editItem.v === 'number' &&
|
||||
editItem.w &&
|
||||
!(editItem.m & DeviceEntityMask.DV_READONLY);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
@@ -82,59 +54,63 @@ const CustomizationsDialog = ({
|
||||
}
|
||||
}, [open, selectedItem]);
|
||||
|
||||
const handleClose = useCallback(
|
||||
(_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => {
|
||||
if (reason !== 'backdropClick') {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose]
|
||||
);
|
||||
const handleClose = (_event, reason: 'backdropClick' | 'escapeKeyDown') => {
|
||||
if (reason !== 'backdropClick') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const save = useCallback(() => {
|
||||
const save = () => {
|
||||
if (
|
||||
isWriteableNumber &&
|
||||
editItem.mi &&
|
||||
editItem.ma &&
|
||||
editItem.mi > editItem.ma
|
||||
editItem.mi > editItem?.ma
|
||||
) {
|
||||
setError(true);
|
||||
} else {
|
||||
onSave(editItem);
|
||||
}
|
||||
}, [isWriteableNumber, editItem, onSave]);
|
||||
};
|
||||
|
||||
const updateDeviceEntity = useCallback((updatedItem: DeviceEntity) => {
|
||||
setEditItem((prev) => ({ ...prev, m: updatedItem.m }));
|
||||
}, []);
|
||||
|
||||
const dialogTitle = useMemo(() => `${LL.EDIT()} ${LL.ENTITY()}`, [LL]);
|
||||
|
||||
const writeableIcon = useMemo(
|
||||
() =>
|
||||
editItem.w ? (
|
||||
<DoneIcon color="success" sx={{ fontSize: ICON_SIZE }} />
|
||||
) : (
|
||||
<CloseIcon color="error" sx={{ fontSize: ICON_SIZE }} />
|
||||
),
|
||||
[editItem.w]
|
||||
);
|
||||
const updateDeviceEntity = (updatedItem: DeviceEntity) => {
|
||||
setEditItem({ ...editItem, m: updatedItem.m });
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
|
||||
<DialogTitle>{dialogTitle}</DialogTitle>
|
||||
<DialogTitle>{LL.EDIT() + ' ' + LL.ENTITY()}</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<LabelValue label={LL.ID_OF(LL.ENTITY())} value={editItem.id} />
|
||||
<LabelValue
|
||||
label={`${LL.DEFAULT(1)} ${LL.ENTITY_NAME(1)}`}
|
||||
value={editItem.n}
|
||||
/>
|
||||
<LabelValue label={LL.WRITEABLE()} value={writeableIcon} />
|
||||
<Grid container>
|
||||
<Typography variant="body2" color="warning.main">
|
||||
{LL.ID_OF(LL.ENTITY())}:
|
||||
</Typography>
|
||||
<Typography variant="body2">{editItem.id}</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid container direction="row">
|
||||
<Typography variant="body2" color="warning.main">
|
||||
{LL.DEFAULT(1) + ' ' + LL.ENTITY_NAME(1)}:
|
||||
</Typography>
|
||||
<Typography variant="body2">{editItem.n}</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid container direction="row">
|
||||
<Typography variant="body2" color="warning.main">
|
||||
{LL.WRITEABLE()}:
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{editItem.w ? (
|
||||
<DoneIcon color="success" sx={{ fontSize: 16 }} />
|
||||
) : (
|
||||
<CloseIcon color="error" sx={{ fontSize: 16 }} />
|
||||
)}
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
<Box mt={1} mb={2}>
|
||||
<EntityMaskToggle onUpdate={updateDeviceEntity} de={editItem} />
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
<Grid>
|
||||
<TextField
|
||||
@@ -170,14 +146,12 @@ const CustomizationsDialog = ({
|
||||
</>
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
{error && (
|
||||
<Typography variant="body2" color="error" mt={2}>
|
||||
Error: Check min and max values
|
||||
</Typography>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Button
|
||||
startIcon={<CancelIcon />}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { memo, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { useContext, useEffect, useState } from 'react';
|
||||
import { IconContext } from 'react-icons/lib';
|
||||
import { Link } from 'react-router';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import HelpOutlineIcon from '@mui/icons-material/HelpOutline';
|
||||
import UnfoldLessIcon from '@mui/icons-material/UnfoldLess';
|
||||
import UnfoldMoreIcon from '@mui/icons-material/UnfoldMore';
|
||||
import {
|
||||
@@ -17,18 +15,13 @@ import {
|
||||
Tooltip,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
import Grid from '@mui/material/Grid2';
|
||||
|
||||
import { Body, Cell, Row, Table } from '@table-library/react-table-library/table';
|
||||
import { useTheme } from '@table-library/react-table-library/theme';
|
||||
import { CellTree, useTree } from '@table-library/react-table-library/tree';
|
||||
import { useRequest } from 'alova/client';
|
||||
import {
|
||||
ButtonTooltip,
|
||||
FormLoader,
|
||||
MessageBox,
|
||||
SectionContent,
|
||||
useLayoutTitle
|
||||
} from 'components';
|
||||
import { FormLoader, SectionContent, useLayoutTitle } from 'components';
|
||||
import { AuthenticatedContext } from 'contexts/authentication';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
import { useInterval, usePersistState } from 'utils';
|
||||
@@ -45,7 +38,7 @@ import {
|
||||
} from './types';
|
||||
import { deviceValueItemValidation } from './validators';
|
||||
|
||||
const Dashboard = memo(() => {
|
||||
const Dashboard = () => {
|
||||
const { LL } = useI18nContext();
|
||||
const { me } = useContext(AuthenticatedContext);
|
||||
|
||||
@@ -61,12 +54,13 @@ const Dashboard = memo(() => {
|
||||
const {
|
||||
data,
|
||||
send: fetchDashboard,
|
||||
error
|
||||
error,
|
||||
loading
|
||||
} = useRequest(readDashboard, {
|
||||
initialData: { connected: true, nodes: [] }
|
||||
initialData: []
|
||||
}).onSuccess((event) => {
|
||||
if (event.data.nodes.length !== parentNodes) {
|
||||
setParentNodes(event.data.nodes.length); // count number of parents/devices
|
||||
if (event.data.length !== parentNodes) {
|
||||
setParentNodes(event.data.length); // count number of parents/devices
|
||||
}
|
||||
});
|
||||
|
||||
@@ -77,40 +71,35 @@ const Dashboard = memo(() => {
|
||||
}
|
||||
);
|
||||
|
||||
const deviceValueDialogSave = useCallback(
|
||||
async (devicevalue: DeviceValue) => {
|
||||
if (!selectedDashboardItem) {
|
||||
return;
|
||||
}
|
||||
const id = selectedDashboardItem.parentNode.id; // this is the parent ID
|
||||
await sendDeviceValue({ id, c: devicevalue.c ?? '', v: devicevalue.v })
|
||||
.then(() => {
|
||||
toast.success(LL.WRITE_CMD_SENT());
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
toast.error(error.message);
|
||||
})
|
||||
.finally(() => {
|
||||
setDeviceValueDialogOpen(false);
|
||||
setSelectedDashboardItem(undefined);
|
||||
});
|
||||
},
|
||||
[selectedDashboardItem, sendDeviceValue, LL]
|
||||
);
|
||||
const deviceValueDialogSave = async (devicevalue: DeviceValue) => {
|
||||
if (!selectedDashboardItem) {
|
||||
return;
|
||||
}
|
||||
const id = selectedDashboardItem.parentNode.id; // this is the parent ID
|
||||
await sendDeviceValue({ id, c: devicevalue.c ?? '', v: devicevalue.v })
|
||||
.then(() => {
|
||||
toast.success(LL.WRITE_CMD_SENT());
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
toast.error(error.message);
|
||||
})
|
||||
.finally(() => {
|
||||
setDeviceValueDialogOpen(false);
|
||||
setSelectedDashboardItem(undefined);
|
||||
});
|
||||
};
|
||||
|
||||
const dashboard_theme = useMemo(
|
||||
() =>
|
||||
useTheme({
|
||||
Table: `
|
||||
const dashboard_theme = useTheme({
|
||||
Table: `
|
||||
--data-table-library_grid-template-columns: minmax(80px, auto) 120px 32px;
|
||||
`,
|
||||
BaseRow: `
|
||||
BaseRow: `
|
||||
font-size: 14px;
|
||||
.td {
|
||||
height: 28px;
|
||||
}
|
||||
`,
|
||||
Row: `
|
||||
Row: `
|
||||
cursor: pointer;
|
||||
background-color: #1e1e1e;
|
||||
&:nth-of-type(odd) .td {
|
||||
@@ -118,9 +107,9 @@ const Dashboard = memo(() => {
|
||||
},
|
||||
&:hover .td {
|
||||
background-color: #177ac9;
|
||||
},
|
||||
}
|
||||
`,
|
||||
BaseCell: `
|
||||
BaseCell: `
|
||||
&:nth-of-type(2) {
|
||||
text-align: right;
|
||||
}
|
||||
@@ -128,14 +117,12 @@ const Dashboard = memo(() => {
|
||||
text-align: right;
|
||||
}
|
||||
`
|
||||
}),
|
||||
[]
|
||||
);
|
||||
});
|
||||
|
||||
const tree = useTree(
|
||||
{ nodes: [...data.nodes] },
|
||||
{ nodes: data },
|
||||
{
|
||||
onChange: () => {} // not used but needed
|
||||
onChange: undefined // not used but needed
|
||||
},
|
||||
{
|
||||
treeIcon: {
|
||||
@@ -162,84 +149,69 @@ const Dashboard = memo(() => {
|
||||
if (!deviceValueDialogOpen) {
|
||||
void fetchDashboard();
|
||||
}
|
||||
});
|
||||
|
||||
const nodeIds = useMemo(
|
||||
() => data.nodes.map((item: DashboardItem) => item.id),
|
||||
[data.nodes]
|
||||
);
|
||||
}, 3000);
|
||||
|
||||
useEffect(() => {
|
||||
showAll
|
||||
? tree.fns.onAddAll(nodeIds) // expand tree
|
||||
? tree.fns.onAddAll(data.map((item: DashboardItem) => item.id)) // expand tree
|
||||
: tree.fns.onRemoveAll(); // collapse tree
|
||||
}, [parentNodes]);
|
||||
|
||||
const showType = useCallback(
|
||||
(n?: string, t?: number) => {
|
||||
// if we have a name show it
|
||||
if (n) {
|
||||
return n;
|
||||
const showType = (n?: string, t?: number) => {
|
||||
// if we have a name show it
|
||||
if (n) {
|
||||
return n;
|
||||
}
|
||||
if (t) {
|
||||
// otherwise pick translation based on type
|
||||
switch (t) {
|
||||
case DeviceType.CUSTOM:
|
||||
return LL.CUSTOM_ENTITIES(0);
|
||||
case DeviceType.ANALOGSENSOR:
|
||||
return LL.ANALOG_SENSORS();
|
||||
case DeviceType.TEMPERATURESENSOR:
|
||||
return LL.TEMP_SENSORS();
|
||||
case DeviceType.SCHEDULER:
|
||||
return LL.SCHEDULER();
|
||||
default:
|
||||
break;
|
||||
}
|
||||
if (t) {
|
||||
// otherwise pick translation based on type
|
||||
switch (t) {
|
||||
case DeviceType.CUSTOM:
|
||||
return LL.CUSTOM_ENTITIES(0);
|
||||
case DeviceType.ANALOGSENSOR:
|
||||
return LL.ANALOG_SENSORS();
|
||||
case DeviceType.TEMPERATURESENSOR:
|
||||
return LL.TEMP_SENSORS();
|
||||
case DeviceType.SCHEDULER:
|
||||
return LL.SCHEDULER();
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
},
|
||||
[LL]
|
||||
);
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const showName = useCallback(
|
||||
(di: DashboardItem) => {
|
||||
if (di.id < 100) {
|
||||
// if its a device (parent node) and has entities
|
||||
if (di.nodes?.length) {
|
||||
return (
|
||||
<span style={{ fontSize: '15px' }}>
|
||||
const showName = (di: DashboardItem) => {
|
||||
if (di.id < 100) {
|
||||
// if its a device (parent node) and has entities
|
||||
if (di.nodes?.length) {
|
||||
return (
|
||||
<>
|
||||
<span style="font-size: 14px">
|
||||
<DeviceIcon type_id={di.t ?? 0} />
|
||||
{showType(di.n, di.t)}
|
||||
<span style={{ color: 'lightblue' }}> ({di.nodes?.length})</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
<span style={{ color: 'lightblue' }}> ({di.nodes?.length})</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (di.dv) {
|
||||
return <span>{di.dv.id.slice(2)}</span>;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[showType]
|
||||
);
|
||||
}
|
||||
if (di.dv) {
|
||||
return <span style="color:lightgrey">{di.dv.id.slice(2)}</span>;
|
||||
}
|
||||
};
|
||||
|
||||
const hasMask = useCallback(
|
||||
(id: string, mask: number) => (parseInt(id.slice(0, 2), 16) & mask) === mask,
|
||||
[]
|
||||
);
|
||||
const hasMask = (id: string, mask: number) =>
|
||||
(parseInt(id.slice(0, 2), 16) & mask) === mask;
|
||||
|
||||
const editDashboardValue = useCallback(
|
||||
(di: DashboardItem) => {
|
||||
if (me.admin && di.dv?.c) {
|
||||
setSelectedDashboardItem(di);
|
||||
setDeviceValueDialogOpen(true);
|
||||
}
|
||||
},
|
||||
[me.admin]
|
||||
);
|
||||
const editDashboardValue = (di: DashboardItem) => {
|
||||
if (me.admin && di.dv?.c) {
|
||||
setSelectedDashboardItem(di);
|
||||
setDeviceValueDialogOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleShowAll = (
|
||||
_event: React.MouseEvent<HTMLElement>,
|
||||
event: React.MouseEvent<HTMLElement>,
|
||||
toggle: boolean | null
|
||||
) => {
|
||||
if (toggle !== null) {
|
||||
@@ -248,151 +220,127 @@ const Dashboard = memo(() => {
|
||||
}
|
||||
};
|
||||
|
||||
const hasFavEntities = useMemo(
|
||||
() => data.nodes.filter((item: DashboardItem) => item.id <= 90).length,
|
||||
[data.nodes]
|
||||
);
|
||||
|
||||
const renderContent = () => {
|
||||
if (!data) {
|
||||
return (
|
||||
<FormLoader onRetry={fetchDashboard} errorMessage={error?.message || ''} />
|
||||
);
|
||||
return <FormLoader onRetry={fetchDashboard} errorMessage={error?.message} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{!data.connected && (
|
||||
<MessageBox mb={2} level="error" message={LL.EMS_BUS_WARNING()} />
|
||||
)}
|
||||
<Box
|
||||
sx={{
|
||||
backgroundColor: 'black',
|
||||
pt: 1,
|
||||
pl: 2
|
||||
}}
|
||||
>
|
||||
<Grid container spacing={0} justifyContent="flex-start">
|
||||
<Grid size={11}>
|
||||
<Typography mb={2} variant="body1" color="warning">
|
||||
{LL.DASHBOARD_1()}.
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
{data.connected && data.nodes.length > 0 && !hasFavEntities && (
|
||||
<MessageBox mb={2} level="warning">
|
||||
<Typography>
|
||||
{LL.NO_DATA_1()}
|
||||
<Link to="/customizations" style={{ color: 'white' }}>
|
||||
{LL.CUSTOMIZATIONS()}
|
||||
</Link>
|
||||
{LL.NO_DATA_2()}
|
||||
{LL.NO_DATA_3()}
|
||||
<Link to="/devices" style={{ color: 'white' }}>
|
||||
{LL.DEVICES()}
|
||||
</Link>
|
||||
.
|
||||
</Typography>
|
||||
</MessageBox>
|
||||
)}
|
||||
|
||||
{data.nodes.length > 0 && (
|
||||
<>
|
||||
<Box
|
||||
display="flex"
|
||||
justifyContent="flex-end"
|
||||
flexWrap="nowrap"
|
||||
whiteSpace="nowrap"
|
||||
>
|
||||
<Grid size={1} alignItems="end">
|
||||
<ToggleButtonGroup
|
||||
size="small"
|
||||
color="primary"
|
||||
size="small"
|
||||
value={showAll}
|
||||
exclusive
|
||||
onChange={handleShowAll}
|
||||
>
|
||||
<ButtonTooltip title={LL.ALLVALUES()}>
|
||||
<ToggleButton value={true}>
|
||||
<UnfoldMoreIcon sx={{ fontSize: 18 }} />
|
||||
</ToggleButton>
|
||||
</ButtonTooltip>
|
||||
<ButtonTooltip title={LL.COMPACT()}>
|
||||
<ToggleButton value={false}>
|
||||
<UnfoldLessIcon sx={{ fontSize: 18 }} />
|
||||
</ToggleButton>
|
||||
</ButtonTooltip>
|
||||
<ToggleButton value={true}>
|
||||
<UnfoldMoreIcon sx={{ fontSize: 14 }} />
|
||||
</ToggleButton>
|
||||
<ToggleButton value={false}>
|
||||
<UnfoldLessIcon sx={{ fontSize: 14 }} />
|
||||
</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
<Tooltip title={LL.DASHBOARD_1()}>
|
||||
<HelpOutlineIcon
|
||||
sx={{
|
||||
ml: 1,
|
||||
mt: 1,
|
||||
fontSize: 20,
|
||||
verticalAlign: 'middle'
|
||||
}}
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
|
||||
<Box mt={1} justifyContent="center" flexDirection="column">
|
||||
<IconContext.Provider
|
||||
value={{
|
||||
color: 'lightblue',
|
||||
size: '18',
|
||||
style: { verticalAlign: 'middle' }
|
||||
}}
|
||||
<Box
|
||||
padding={1}
|
||||
justifyContent="center"
|
||||
flexDirection="column"
|
||||
sx={{
|
||||
borderRadius: 1,
|
||||
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
|
||||
data={{ nodes: data }}
|
||||
theme={dashboard_theme}
|
||||
layout={{ custom: true }}
|
||||
tree={tree}
|
||||
>
|
||||
<Table
|
||||
data={{ nodes: data.nodes }}
|
||||
theme={dashboard_theme}
|
||||
layout={{ custom: true }}
|
||||
tree={tree}
|
||||
>
|
||||
{(tableList: DashboardItem[]) => (
|
||||
<Body>
|
||||
{tableList.map((di: DashboardItem) => (
|
||||
<Row
|
||||
key={di.id}
|
||||
item={di}
|
||||
onClick={() => editDashboardValue(di)}
|
||||
>
|
||||
{di.id > 99 ? (
|
||||
<>
|
||||
<Cell>{showName(di)}</Cell>
|
||||
<Cell>
|
||||
<ButtonTooltip
|
||||
title={formatValue(LL, di.dv?.v, di.dv?.u)}
|
||||
>
|
||||
<span>{formatValue(LL, di.dv?.v, di.dv?.u)}</span>
|
||||
</ButtonTooltip>
|
||||
</Cell>
|
||||
{(tableList: DashboardItem[]) => (
|
||||
<Body>
|
||||
{tableList.map((di: DashboardItem) => (
|
||||
<Row
|
||||
key={di.id}
|
||||
item={di}
|
||||
onClick={() => editDashboardValue(di)}
|
||||
>
|
||||
{di.id > 99 ? (
|
||||
<>
|
||||
<Cell>{showName(di)}</Cell>
|
||||
<Cell>
|
||||
<Tooltip
|
||||
placement="left"
|
||||
title={formatValue(LL, di.dv?.v, di.dv?.u)}
|
||||
arrow
|
||||
>
|
||||
<span style={{ color: 'lightgrey' }}>
|
||||
{formatValue(LL, di.dv?.v, di.dv?.u)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Cell>
|
||||
|
||||
<Cell>
|
||||
{me.admin &&
|
||||
di.dv?.c &&
|
||||
!hasMask(
|
||||
di.dv.id,
|
||||
DeviceEntityMask.DV_READONLY
|
||||
) && (
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label={
|
||||
LL.CHANGE_VALUE() + ' ' + LL.VALUE(0)
|
||||
}
|
||||
onClick={() => editDashboardValue(di)}
|
||||
>
|
||||
<EditIcon
|
||||
color="primary"
|
||||
sx={{ fontSize: 16 }}
|
||||
/>
|
||||
</IconButton>
|
||||
)}
|
||||
</Cell>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CellTree item={di}>{showName(di)}</CellTree>
|
||||
<Cell />
|
||||
<Cell />
|
||||
</>
|
||||
)}
|
||||
</Row>
|
||||
))}
|
||||
</Body>
|
||||
)}
|
||||
</Table>
|
||||
</IconContext.Provider>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
<Cell>
|
||||
{me.admin &&
|
||||
di.dv?.c &&
|
||||
!hasMask(di.dv.id, DeviceEntityMask.DV_READONLY) && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => editDashboardValue(di)}
|
||||
>
|
||||
<EditIcon
|
||||
color="primary"
|
||||
sx={{ fontSize: 16 }}
|
||||
/>
|
||||
</IconButton>
|
||||
)}
|
||||
</Cell>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CellTree item={di}>{showName(di)}</CellTree>
|
||||
<Cell />
|
||||
<Cell />
|
||||
</>
|
||||
)}
|
||||
</Row>
|
||||
))}
|
||||
</Body>
|
||||
)}
|
||||
</Table>
|
||||
)}
|
||||
</IconContext.Provider>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -413,6 +361,6 @@ const Dashboard = memo(() => {
|
||||
)}
|
||||
</SectionContent>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { memo } from 'react';
|
||||
import { AiOutlineAlert, AiOutlineControl, AiOutlineGateway } from 'react-icons/ai';
|
||||
import { CgSmartHomeBoiler } from 'react-icons/cg';
|
||||
import { FaSolarPanel } from 'react-icons/fa';
|
||||
import { GiHeatHaze, GiTap } from 'react-icons/gi';
|
||||
import { MdPlaylistAdd } from 'react-icons/md';
|
||||
import { MdMoreTime } from 'react-icons/md';
|
||||
import {
|
||||
MdMoreTime,
|
||||
MdOutlineDevices,
|
||||
MdOutlinePool,
|
||||
MdOutlineSensors,
|
||||
MdPlaylistAdd,
|
||||
MdThermostatAuto
|
||||
} from 'react-icons/md';
|
||||
import { PiFan, PiGauge } from 'react-icons/pi';
|
||||
@@ -19,10 +18,9 @@ import type { SvgIconProps } from '@mui/material';
|
||||
|
||||
import { DeviceType } from './types';
|
||||
|
||||
const deviceIconLookup: Record<
|
||||
DeviceType,
|
||||
React.ComponentType<SvgIconProps> | null
|
||||
> = {
|
||||
const deviceIconLookup: {
|
||||
[key in DeviceType]: React.ComponentType<SvgIconProps> | undefined;
|
||||
} = {
|
||||
[DeviceType.TEMPERATURESENSOR]: TiThermometer,
|
||||
[DeviceType.ANALOGSENSOR]: PiGauge,
|
||||
[DeviceType.BOILER]: CgSmartHomeBoiler,
|
||||
@@ -41,19 +39,15 @@ const deviceIconLookup: Record<
|
||||
[DeviceType.POOL]: MdOutlinePool,
|
||||
[DeviceType.CUSTOM]: MdPlaylistAdd,
|
||||
[DeviceType.UNKNOWN]: MdOutlineSensors,
|
||||
[DeviceType.SYSTEM]: null,
|
||||
[DeviceType.SYSTEM]: undefined,
|
||||
[DeviceType.SCHEDULER]: MdMoreTime,
|
||||
[DeviceType.GENERIC]: MdOutlineSensors,
|
||||
[DeviceType.VENTILATION]: PiFan
|
||||
};
|
||||
|
||||
interface DeviceIconProps {
|
||||
type_id: DeviceType;
|
||||
}
|
||||
|
||||
const DeviceIcon = memo(({ type_id }: DeviceIconProps) => {
|
||||
const DeviceIcon = ({ type_id }: { type_id: DeviceType }) => {
|
||||
const Icon = deviceIconLookup[type_id];
|
||||
return Icon ? <Icon /> : null;
|
||||
});
|
||||
};
|
||||
|
||||
export default DeviceIcon;
|
||||
|
||||
@@ -1,27 +1,24 @@
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useState
|
||||
} from 'react';
|
||||
import { IconContext } from 'react-icons';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import CommentsDisabledOutlinedIcon from '@mui/icons-material/CommentsDisabledOutlined';
|
||||
import ConstructionIcon from '@mui/icons-material/Construction';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import EditOffOutlinedIcon from '@mui/icons-material/EditOffOutlined';
|
||||
import FormatListNumberedIcon from '@mui/icons-material/FormatListNumbered';
|
||||
import DownloadIcon from '@mui/icons-material/GetApp';
|
||||
import HighlightOffIcon from '@mui/icons-material/HighlightOff';
|
||||
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
|
||||
import KeyboardArrowDownOutlinedIcon from '@mui/icons-material/KeyboardArrowDownOutlined';
|
||||
import KeyboardArrowUpOutlinedIcon from '@mui/icons-material/KeyboardArrowUpOutlined';
|
||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import StarIcon from '@mui/icons-material/Star';
|
||||
import StarBorderOutlinedIcon from '@mui/icons-material/StarBorderOutlined';
|
||||
import UnfoldMoreOutlinedIcon from '@mui/icons-material/UnfoldMoreOutlined';
|
||||
@@ -33,16 +30,17 @@ import {
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Grid,
|
||||
IconButton,
|
||||
InputAdornment,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
TextField,
|
||||
ToggleButton,
|
||||
Typography
|
||||
Tooltip,
|
||||
type TooltipProps,
|
||||
Typography,
|
||||
styled,
|
||||
tooltipClasses
|
||||
} from '@mui/material';
|
||||
import Grid from '@mui/material/Grid2';
|
||||
|
||||
import { useRowSelect } from '@table-library/react-table-library/select';
|
||||
import { SortToggleType, useSort } from '@table-library/react-table-library/sort';
|
||||
@@ -59,12 +57,7 @@ import { useTheme } from '@table-library/react-table-library/theme';
|
||||
import type { Action, State } from '@table-library/react-table-library/types/common';
|
||||
import { dialogStyle } from 'CustomTheme';
|
||||
import { useRequest } from 'alova/client';
|
||||
import {
|
||||
ButtonTooltip,
|
||||
MessageBox,
|
||||
SectionContent,
|
||||
useLayoutTitle
|
||||
} from 'components';
|
||||
import { MessageBox, SectionContent, useLayoutTitle } from 'components';
|
||||
import { AuthenticatedContext } from 'contexts/authentication';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
import { useInterval } from 'utils';
|
||||
@@ -77,7 +70,7 @@ import { DeviceEntityMask, DeviceType, DeviceValueUOM_s } from './types';
|
||||
import type { Device, DeviceValue } from './types';
|
||||
import { deviceValueItemValidation } from './validators';
|
||||
|
||||
const Devices = memo(() => {
|
||||
const Devices = () => {
|
||||
const { LL } = useI18nContext();
|
||||
const { me } = useContext(AuthenticatedContext);
|
||||
|
||||
@@ -87,13 +80,12 @@ const Devices = memo(() => {
|
||||
const [deviceValueDialogOpen, setDeviceValueDialogOpen] = useState(false);
|
||||
const [showDeviceInfo, setShowDeviceInfo] = useState(false);
|
||||
const [selectedDevice, setSelectedDevice] = useState<number>();
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
useLayoutTitle(LL.DEVICES());
|
||||
|
||||
const { data: coreData, send: sendCoreData } = useRequest(readCoreData, {
|
||||
const { data: coreData, send: sendCoreData } = useRequest(() => readCoreData(), {
|
||||
initialData: {
|
||||
connected: true,
|
||||
devices: []
|
||||
@@ -118,36 +110,36 @@ const Devices = memo(() => {
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
let raf = 0;
|
||||
const updateSize = () => {
|
||||
cancelAnimationFrame(raf);
|
||||
raf = requestAnimationFrame(() => {
|
||||
setSize([window.innerWidth, window.innerHeight]);
|
||||
});
|
||||
};
|
||||
function updateSize() {
|
||||
setSize([window.innerWidth, window.innerHeight]);
|
||||
}
|
||||
window.addEventListener('resize', updateSize);
|
||||
updateSize();
|
||||
return () => {
|
||||
window.removeEventListener('resize', updateSize);
|
||||
cancelAnimationFrame(raf);
|
||||
};
|
||||
return () => window.removeEventListener('resize', updateSize);
|
||||
}, []);
|
||||
|
||||
const leftOffset = useCallback(() => {
|
||||
const leftOffset = () => {
|
||||
const devicesWindow = document.getElementById('devices-window');
|
||||
if (!devicesWindow) return 0;
|
||||
const { left, right } = devicesWindow.getBoundingClientRect();
|
||||
if (!left || !right) return 0;
|
||||
return left + (right - left < 400 ? 0 : 200);
|
||||
}, []);
|
||||
if (!devicesWindow) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const common_theme = useMemo(
|
||||
() =>
|
||||
useTheme({
|
||||
BaseRow: `
|
||||
const clientRect = devicesWindow.getBoundingClientRect();
|
||||
const left = clientRect.left;
|
||||
const right = clientRect.right;
|
||||
|
||||
if (!left || !right) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return left + (right - left < 400 ? 0 : 200);
|
||||
};
|
||||
|
||||
const common_theme = useTheme({
|
||||
BaseRow: `
|
||||
font-size: 14px;
|
||||
`,
|
||||
HeaderRow: `
|
||||
HeaderRow: `
|
||||
text-transform: uppercase;
|
||||
background-color: black;
|
||||
color: #90CAF9;
|
||||
@@ -155,7 +147,7 @@ const Devices = memo(() => {
|
||||
border-bottom: 1px solid #565656;
|
||||
}
|
||||
`,
|
||||
Row: `
|
||||
Row: `
|
||||
cursor: pointer;
|
||||
background-color: #1E1E1E;
|
||||
.td {
|
||||
@@ -163,49 +155,33 @@ const Devices = memo(() => {
|
||||
}
|
||||
&.tr.tr-body.row-select.row-select-single-selected {
|
||||
background-color: #177ac9;
|
||||
font-weight: normal;
|
||||
}
|
||||
`
|
||||
}),
|
||||
[]
|
||||
);
|
||||
});
|
||||
|
||||
const device_theme = useMemo(
|
||||
() =>
|
||||
useTheme([
|
||||
common_theme,
|
||||
{
|
||||
BaseRow: `
|
||||
font-size: 15px;
|
||||
.td {
|
||||
height: 28px;
|
||||
}
|
||||
`,
|
||||
Table: `
|
||||
const device_theme = useTheme([
|
||||
common_theme,
|
||||
{
|
||||
Table: `
|
||||
--data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) 130px;
|
||||
`,
|
||||
HeaderRow: `
|
||||
HeaderRow: `
|
||||
.th {
|
||||
padding: 8px;
|
||||
height: 36px;
|
||||
`,
|
||||
Row: `
|
||||
&:nth-of-type(odd) .td {
|
||||
background-color: #303030;
|
||||
},
|
||||
Row: `
|
||||
&:hover .td {
|
||||
background-color: #177ac9;
|
||||
},
|
||||
background-color: #177ac9;
|
||||
`
|
||||
}
|
||||
]),
|
||||
[common_theme]
|
||||
);
|
||||
}
|
||||
]);
|
||||
|
||||
const data_theme = useMemo(
|
||||
() =>
|
||||
useTheme([
|
||||
common_theme,
|
||||
{
|
||||
Table: `
|
||||
const data_theme = useTheme([
|
||||
common_theme,
|
||||
{
|
||||
Table: `
|
||||
--data-table-library_grid-template-columns: minmax(200px, auto) minmax(150px, auto) 40px;
|
||||
height: auto;
|
||||
max-height: 100%;
|
||||
@@ -214,12 +190,12 @@ const Devices = memo(() => {
|
||||
display:none;
|
||||
}
|
||||
`,
|
||||
BaseRow: `
|
||||
BaseRow: `
|
||||
.td {
|
||||
height: 32px;
|
||||
}
|
||||
`,
|
||||
BaseCell: `
|
||||
BaseCell: `
|
||||
&:nth-of-type(1) {
|
||||
border-left: 1px solid #177ac9;
|
||||
},
|
||||
@@ -230,23 +206,35 @@ const Devices = memo(() => {
|
||||
border-right: 1px solid #177ac9;
|
||||
}
|
||||
`,
|
||||
HeaderRow: `
|
||||
HeaderRow: `
|
||||
.th {
|
||||
border-top: 1px solid #565656;
|
||||
}
|
||||
`,
|
||||
Row: `
|
||||
Row: `
|
||||
&:nth-of-type(odd) .td {
|
||||
background-color: #303030;
|
||||
},
|
||||
&:hover .td {
|
||||
background-color: #177ac9;
|
||||
background-color: #177ac9;
|
||||
}
|
||||
`
|
||||
}
|
||||
]),
|
||||
[common_theme]
|
||||
);
|
||||
}
|
||||
]);
|
||||
|
||||
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) => {
|
||||
if (state.sortKey === sortKey && state.reverse) {
|
||||
@@ -259,7 +247,7 @@ const Devices = memo(() => {
|
||||
};
|
||||
|
||||
const dv_sort = useSort(
|
||||
{ nodes: [...deviceData.nodes] },
|
||||
{ nodes: deviceData.nodes },
|
||||
{},
|
||||
{
|
||||
sortIcon: {
|
||||
@@ -289,7 +277,7 @@ const Devices = memo(() => {
|
||||
}
|
||||
|
||||
const device_select = useRowSelect(
|
||||
{ nodes: [...coreData.devices] },
|
||||
{ nodes: coreData.devices },
|
||||
{
|
||||
onChange: onSelectChange
|
||||
}
|
||||
@@ -297,7 +285,6 @@ const Devices = memo(() => {
|
||||
|
||||
const resetDeviceSelect = () => {
|
||||
device_select.fns.onRemoveAll();
|
||||
setSearch('');
|
||||
};
|
||||
|
||||
const escFunction = useCallback(
|
||||
@@ -320,9 +307,9 @@ const Devices = memo(() => {
|
||||
|
||||
const customize = () => {
|
||||
if (selectedDevice === 99) {
|
||||
void navigate('/customentities');
|
||||
navigate('/customentities');
|
||||
} else {
|
||||
void navigate('/customizations', { state: selectedDevice });
|
||||
navigate('/customizations', { state: selectedDevice });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -345,23 +332,18 @@ const Devices = memo(() => {
|
||||
return sc;
|
||||
};
|
||||
|
||||
const hasMask = useCallback(
|
||||
(id: string, mask: number) => (parseInt(id.slice(0, 2), 16) & mask) === mask,
|
||||
[]
|
||||
);
|
||||
const hasMask = (id: string, mask: number) =>
|
||||
(parseInt(id.slice(0, 2), 16) & mask) === mask;
|
||||
|
||||
const handleDownloadCsv = () => {
|
||||
const deviceIndex = coreData.devices.findIndex(
|
||||
(d: Device) => d.id === device_select.state.id
|
||||
(d) => d.id === device_select.state.id
|
||||
);
|
||||
if (deviceIndex === -1) {
|
||||
return;
|
||||
}
|
||||
const selectedDevice = coreData.devices[deviceIndex];
|
||||
if (!selectedDevice) {
|
||||
return;
|
||||
}
|
||||
const filename = selectedDevice.tn + '_' + selectedDevice.n;
|
||||
const filename =
|
||||
coreData.devices[deviceIndex].tn + '_' + coreData.devices[deviceIndex].n;
|
||||
|
||||
const columns = [
|
||||
{
|
||||
@@ -376,7 +358,7 @@ const Devices = memo(() => {
|
||||
{
|
||||
accessor: (dv: DeviceValue) =>
|
||||
dv.u !== undefined && DeviceValueUOM_s[dv.u]
|
||||
? DeviceValueUOM_s[dv.u]?.replace(/[^a-zA-Z0-9]/g, '')
|
||||
? DeviceValueUOM_s[dv.u].replace(/[^a-zA-Z0-9]/g, '')
|
||||
: '',
|
||||
name: 'UoM'
|
||||
},
|
||||
@@ -399,9 +381,7 @@ const Devices = memo(() => {
|
||||
];
|
||||
|
||||
const data = onlyFav
|
||||
? deviceData.nodes.filter((dv: DeviceValue) =>
|
||||
hasMask(dv.id, DeviceEntityMask.DV_FAVORITE)
|
||||
)
|
||||
? deviceData.nodes.filter((dv) => hasMask(dv.id, DeviceEntityMask.DV_FAVORITE))
|
||||
: deviceData.nodes;
|
||||
|
||||
const csvData = data.reduce(
|
||||
@@ -440,7 +420,7 @@ const Devices = memo(() => {
|
||||
if (!deviceValueDialogOpen) {
|
||||
selectedDevice ? void sendDeviceData(selectedDevice) : void sendCoreData();
|
||||
}
|
||||
});
|
||||
}, 3000);
|
||||
|
||||
const deviceValueDialogSave = async (devicevalue: DeviceValue) => {
|
||||
const id = Number(device_select.state.id);
|
||||
@@ -461,14 +441,10 @@ const Devices = memo(() => {
|
||||
const renderDeviceDetails = () => {
|
||||
if (showDeviceInfo) {
|
||||
const deviceIndex = coreData.devices.findIndex(
|
||||
(d: Device) => d.id === device_select.state.id
|
||||
(d) => d.id === device_select.state.id
|
||||
);
|
||||
if (deviceIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
const deviceDetails = coreData.devices[deviceIndex];
|
||||
if (!deviceDetails) {
|
||||
return null;
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -481,35 +457,47 @@ const Devices = memo(() => {
|
||||
<DialogContent dividers>
|
||||
<List dense={true}>
|
||||
<ListItem>
|
||||
<ListItemText primary={LL.TYPE(0)} secondary={deviceDetails.tn} />
|
||||
<ListItemText
|
||||
primary={LL.TYPE(0)}
|
||||
secondary={coreData.devices[deviceIndex].tn}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItemText primary={LL.NAME(0)} secondary={deviceDetails.n} />
|
||||
<ListItemText
|
||||
primary={LL.NAME(0)}
|
||||
secondary={coreData.devices[deviceIndex].n}
|
||||
/>
|
||||
</ListItem>
|
||||
{deviceDetails.t !== DeviceType.CUSTOM && (
|
||||
{coreData.devices[deviceIndex].t !== DeviceType.CUSTOM && (
|
||||
<>
|
||||
<ListItem>
|
||||
<ListItemText primary={LL.BRAND()} secondary={deviceDetails.b} />
|
||||
<ListItemText
|
||||
primary={LL.BRAND()}
|
||||
secondary={coreData.devices[deviceIndex].b}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItemText
|
||||
primary={LL.ID_OF(LL.DEVICE())}
|
||||
secondary={
|
||||
'0x' +
|
||||
('00' + deviceDetails.d.toString(16).toUpperCase()).slice(-2)
|
||||
(
|
||||
'00' +
|
||||
coreData.devices[deviceIndex].d.toString(16).toUpperCase()
|
||||
).slice(-2)
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItemText
|
||||
primary={LL.ID_OF(LL.PRODUCT())}
|
||||
secondary={deviceDetails.p}
|
||||
secondary={coreData.devices[deviceIndex].p}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItemText
|
||||
primary={LL.VERSION()}
|
||||
secondary={deviceDetails.v}
|
||||
secondary={coreData.devices[deviceIndex].v}
|
||||
/>
|
||||
</ListItem>
|
||||
</>
|
||||
@@ -528,70 +516,65 @@ const Devices = memo(() => {
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const renderCoreData = () => (
|
||||
<>
|
||||
<Box justifyContent="center" flexDirection="column">
|
||||
<IconContext.Provider
|
||||
value={{
|
||||
color: 'lightblue',
|
||||
size: '18',
|
||||
style: { verticalAlign: 'middle' }
|
||||
}}
|
||||
>
|
||||
{!coreData.connected && (
|
||||
<MessageBox my={2} level="error" message={LL.EMS_BUS_WARNING()} />
|
||||
)}
|
||||
<IconContext.Provider
|
||||
value={{
|
||||
color: 'lightblue',
|
||||
size: '18',
|
||||
style: { verticalAlign: 'middle' }
|
||||
}}
|
||||
>
|
||||
{!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 }}
|
||||
>
|
||||
{(tableList: Device[]) => (
|
||||
<>
|
||||
<Header>
|
||||
<HeaderRow>
|
||||
<HeaderCell resize>{LL.DESCRIPTION()}</HeaderCell>
|
||||
<HeaderCell stiff>{LL.TYPE(0)}</HeaderCell>
|
||||
</HeaderRow>
|
||||
</Header>
|
||||
<Body>
|
||||
{tableList.length === 0 && (
|
||||
<CircularProgress sx={{ margin: 1 }} size={18} />
|
||||
)}
|
||||
{tableList.map((device: Device) => (
|
||||
<Row key={device.id} item={device}>
|
||||
<Cell>
|
||||
<DeviceIcon type_id={device.t} />
|
||||
|
||||
{device.n}
|
||||
<span style={{ color: 'lightblue' }}>
|
||||
({device.e})
|
||||
</span>
|
||||
</Cell>
|
||||
<Cell stiff>{device.tn}</Cell>
|
||||
</Row>
|
||||
))}
|
||||
</Body>
|
||||
</>
|
||||
)}
|
||||
</Table>
|
||||
)}
|
||||
</IconContext.Provider>
|
||||
</Box>
|
||||
{coreData.connected && (
|
||||
<Table
|
||||
data={{ nodes: coreData.devices }}
|
||||
select={device_select}
|
||||
theme={device_theme}
|
||||
layout={{ custom: true }}
|
||||
>
|
||||
{(tableList: Device[]) => (
|
||||
<>
|
||||
<Header>
|
||||
<HeaderRow>
|
||||
<HeaderCell resize>{LL.DESCRIPTION()}</HeaderCell>
|
||||
<HeaderCell stiff>{LL.TYPE(0)}</HeaderCell>
|
||||
</HeaderRow>
|
||||
</Header>
|
||||
<Body>
|
||||
{tableList.length === 0 && (
|
||||
<CircularProgress sx={{ margin: 1 }} size={18} />
|
||||
)}
|
||||
{tableList.map((device: Device) => (
|
||||
<Row key={device.id} item={device}>
|
||||
<Cell>
|
||||
<DeviceIcon type_id={device.t} />
|
||||
|
||||
{device.n}
|
||||
<span style={{ color: 'lightblue' }}>
|
||||
({device.e})
|
||||
</span>
|
||||
</Cell>
|
||||
<Cell stiff>{device.tn}</Cell>
|
||||
</Row>
|
||||
))}
|
||||
</Body>
|
||||
</>
|
||||
)}
|
||||
</Table>
|
||||
)}
|
||||
</IconContext.Provider>
|
||||
</>
|
||||
);
|
||||
|
||||
const deviceValueDialogClose = () => {
|
||||
setDeviceValueDialogOpen(false);
|
||||
if (selectedDevice !== undefined) {
|
||||
void sendDeviceData(selectedDevice);
|
||||
}
|
||||
void sendDeviceData(selectedDevice);
|
||||
};
|
||||
|
||||
const renderDeviceData = () => {
|
||||
@@ -599,154 +582,105 @@ const Devices = memo(() => {
|
||||
return;
|
||||
}
|
||||
|
||||
const showDeviceValue = useCallback((dv: DeviceValue) => {
|
||||
const showDeviceValue = (dv: DeviceValue) => {
|
||||
setSelectedDeviceValue(dv);
|
||||
setDeviceValueDialogOpen(true);
|
||||
}, []);
|
||||
};
|
||||
|
||||
const renderNameCell = useCallback(
|
||||
(dv: DeviceValue) => (
|
||||
<>
|
||||
{dv.id.slice(2)}
|
||||
{hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) && (
|
||||
<StarIcon color="primary" sx={{ fontSize: 12 }} />
|
||||
)}
|
||||
{hasMask(dv.id, DeviceEntityMask.DV_READONLY) && (
|
||||
<EditOffOutlinedIcon color="primary" sx={{ fontSize: 12 }} />
|
||||
)}
|
||||
{hasMask(dv.id, DeviceEntityMask.DV_API_MQTT_EXCLUDE) && (
|
||||
<CommentsDisabledOutlinedIcon color="primary" sx={{ fontSize: 12 }} />
|
||||
)}
|
||||
</>
|
||||
),
|
||||
[hasMask]
|
||||
const renderNameCell = (dv: DeviceValue) => (
|
||||
<>
|
||||
{dv.id.slice(2)}
|
||||
{hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) && (
|
||||
<StarIcon color="primary" sx={{ fontSize: 12 }} />
|
||||
)}
|
||||
{hasMask(dv.id, DeviceEntityMask.DV_READONLY) && (
|
||||
<EditOffOutlinedIcon color="primary" sx={{ fontSize: 12 }} />
|
||||
)}
|
||||
{hasMask(dv.id, DeviceEntityMask.DV_API_MQTT_EXCLUDE) && (
|
||||
<CommentsDisabledOutlinedIcon color="primary" sx={{ fontSize: 12 }} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const shown_data = useMemo(() => {
|
||||
if (onlyFav) {
|
||||
return deviceData.nodes.filter(
|
||||
(dv: DeviceValue) =>
|
||||
hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) &&
|
||||
dv.id.slice(2).toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
}
|
||||
return deviceData.nodes.filter((dv: DeviceValue) =>
|
||||
dv.id.slice(2).toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
}, [deviceData.nodes, onlyFav, search]);
|
||||
const shown_data = onlyFav
|
||||
? deviceData.nodes.filter((dv) => hasMask(dv.id, DeviceEntityMask.DV_FAVORITE))
|
||||
: deviceData.nodes;
|
||||
|
||||
const deviceIndex = coreData.devices.findIndex(
|
||||
(d: Device) => d.id === device_select.state.id
|
||||
(d) => d.id === device_select.state.id
|
||||
);
|
||||
if (deviceIndex === -1) {
|
||||
return;
|
||||
}
|
||||
const deviceInfo = coreData.devices[deviceIndex];
|
||||
if (!deviceInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [, height] = size;
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
backgroundColor: 'black',
|
||||
position: 'absolute',
|
||||
left: leftOffset,
|
||||
left: () => leftOffset(),
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
top: 64,
|
||||
zIndex: 'modal',
|
||||
maxHeight: () => (height || 0) - 126,
|
||||
maxHeight: () => size[1] - 126,
|
||||
border: '1px solid #177ac9'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Box sx={{ border: '1px solid #177ac9' }}>
|
||||
<Typography noWrap variant="subtitle1" color="warning.main" sx={{ ml: 1 }}>
|
||||
{coreData.devices[deviceIndex].n} (
|
||||
{coreData.devices[deviceIndex].tn})
|
||||
</Typography>
|
||||
|
||||
<Grid container justifyContent="space-between">
|
||||
<Typography noWrap variant="subtitle1" color="warning.main">
|
||||
{deviceInfo.n} (
|
||||
{deviceInfo.tn})
|
||||
<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>
|
||||
<Grid justifyContent="flex-end">
|
||||
<ButtonTooltip title={LL.CLOSE()}>
|
||||
<IconButton onClick={resetDeviceSelect} aria-label={LL.CLOSE()}>
|
||||
<ButtonTooltip title={LL.CANCEL()}>
|
||||
<IconButton onClick={resetDeviceSelect}>
|
||||
<HighlightOffIcon color="primary" sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
</ButtonTooltip>
|
||||
</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' }}>
|
||||
|
||||
{LL.SHOWING() +
|
||||
' ' +
|
||||
shown_data.length +
|
||||
'/' +
|
||||
deviceInfo.e +
|
||||
' ' +
|
||||
LL.ENTITIES(shown_data.length)}
|
||||
</span>
|
||||
</Box>
|
||||
|
||||
<Table
|
||||
data={{ nodes: Array.from(shown_data) }}
|
||||
data={{ nodes: shown_data }}
|
||||
theme={data_theme}
|
||||
sort={dv_sort}
|
||||
layout={{ custom: true, fixedHeader: true }}
|
||||
@@ -830,6 +764,6 @@ const Devices = memo(() => {
|
||||
)}
|
||||
</SectionContent>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export default Devices;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import WarningIcon from '@mui/icons-material/Warning';
|
||||
@@ -11,12 +11,12 @@ import {
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
FormHelperText,
|
||||
Grid,
|
||||
InputAdornment,
|
||||
MenuItem,
|
||||
TextField,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
import Grid from '@mui/material/Grid2';
|
||||
|
||||
import { dialogStyle } from 'CustomTheme';
|
||||
import type Schema from 'async-validator';
|
||||
@@ -52,7 +52,7 @@ const DevicesDialog = ({
|
||||
const [editItem, setEditItem] = useState<DeviceValue>(selectedItem);
|
||||
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
||||
|
||||
const updateFormValue = useMemo(() => updateValue(setEditItem), [setEditItem]);
|
||||
const updateFormValue = updateValue(setEditItem);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
@@ -61,7 +61,11 @@ const DevicesDialog = ({
|
||||
}
|
||||
}, [open, selectedItem]);
|
||||
|
||||
const save = useCallback(async () => {
|
||||
const close = () => {
|
||||
onClose();
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
try {
|
||||
setFieldErrors(undefined);
|
||||
await validate(validator, editItem);
|
||||
@@ -69,66 +73,46 @@ const DevicesDialog = ({
|
||||
} catch (error) {
|
||||
setFieldErrors(error as ValidateFieldsError);
|
||||
}
|
||||
}, [validator, editItem, onSave]);
|
||||
};
|
||||
|
||||
const setUom = useCallback(
|
||||
(uom?: DeviceValueUOM) => {
|
||||
if (uom === undefined) {
|
||||
return;
|
||||
}
|
||||
switch (uom) {
|
||||
case DeviceValueUOM.HOURS:
|
||||
return LL.HOURS();
|
||||
case DeviceValueUOM.MINUTES:
|
||||
return LL.MINUTES();
|
||||
case DeviceValueUOM.SECONDS:
|
||||
return LL.SECONDS();
|
||||
default:
|
||||
return DeviceValueUOM_s[uom];
|
||||
}
|
||||
},
|
||||
[LL]
|
||||
);
|
||||
|
||||
const showHelperText = useCallback((dv: DeviceValue) => {
|
||||
if (dv.h) return dv.h;
|
||||
if (dv.l) return dv.l.join(' | ');
|
||||
if (dv.m !== undefined && dv.x !== undefined) {
|
||||
return (
|
||||
<>
|
||||
{dv.m} → {dv.x}
|
||||
</>
|
||||
);
|
||||
const setUom = (uom?: DeviceValueUOM) => {
|
||||
if (uom === undefined) {
|
||||
return;
|
||||
}
|
||||
return undefined;
|
||||
}, []);
|
||||
switch (uom) {
|
||||
case DeviceValueUOM.HOURS:
|
||||
return LL.HOURS();
|
||||
case DeviceValueUOM.MINUTES:
|
||||
return LL.MINUTES();
|
||||
case DeviceValueUOM.SECONDS:
|
||||
return LL.SECONDS();
|
||||
default:
|
||||
return DeviceValueUOM_s[uom];
|
||||
}
|
||||
};
|
||||
|
||||
const isCommand = useMemo(
|
||||
() => selectedItem.v === '' && selectedItem.c,
|
||||
[selectedItem.v, selectedItem.c]
|
||||
);
|
||||
|
||||
const dialogTitle = useMemo(() => {
|
||||
if (isCommand) return LL.RUN_COMMAND();
|
||||
return writeable ? LL.CHANGE_VALUE() : LL.VALUE(0);
|
||||
}, [isCommand, writeable, LL]);
|
||||
|
||||
const buttonLabel = useMemo(() => {
|
||||
return isCommand ? LL.EXECUTE() : LL.UPDATE();
|
||||
}, [isCommand, LL]);
|
||||
|
||||
const helperText = useMemo(
|
||||
() => showHelperText(editItem),
|
||||
[editItem, showHelperText]
|
||||
);
|
||||
|
||||
const valueLabel = LL.VALUE(0);
|
||||
const showHelperText = (dv: DeviceValue) =>
|
||||
dv.h ? (
|
||||
dv.h
|
||||
) : dv.l ? (
|
||||
dv.l.join(' | ')
|
||||
) : dv.m !== undefined && dv.x !== undefined ? (
|
||||
<>
|
||||
{dv.m} → {dv.x}
|
||||
</>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<Dialog sx={dialogStyle} open={open} onClose={onClose}>
|
||||
<DialogTitle>{dialogTitle}</DialogTitle>
|
||||
<Dialog sx={dialogStyle} open={open} onClose={close}>
|
||||
<DialogTitle>
|
||||
{selectedItem.v === '' && selectedItem.c
|
||||
? LL.RUN_COMMAND()
|
||||
: writeable
|
||||
? LL.CHANGE_VALUE()
|
||||
: LL.VALUE(0)}
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<Box color="warning.main" mb={2}>
|
||||
<Box color="warning.main" p={0} pl={0} pr={0} mt={0} mb={2}>
|
||||
<Typography variant="body2">{editItem.id.slice(2)}</Typography>
|
||||
</Box>
|
||||
<Grid container>
|
||||
@@ -136,8 +120,8 @@ const DevicesDialog = ({
|
||||
{editItem.l ? (
|
||||
<TextField
|
||||
name="v"
|
||||
label={LL.VALUE(0)}
|
||||
value={editItem.v}
|
||||
aria-label={valueLabel}
|
||||
disabled={!writeable}
|
||||
sx={{ width: '30ch' }}
|
||||
select
|
||||
@@ -151,9 +135,9 @@ const DevicesDialog = ({
|
||||
</TextField>
|
||||
) : editItem.s || editItem.u !== DeviceValueUOM.NONE ? (
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
fieldErrors={fieldErrors}
|
||||
name="v"
|
||||
label={valueLabel}
|
||||
label={LL.VALUE(0)}
|
||||
value={numberValue(Math.round((editItem.v as number) * 10) / 10)}
|
||||
autoFocus
|
||||
disabled={!writeable}
|
||||
@@ -175,9 +159,9 @@ const DevicesDialog = ({
|
||||
/>
|
||||
) : (
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
fieldErrors={fieldErrors}
|
||||
name="v"
|
||||
label={valueLabel}
|
||||
label={LL.VALUE(0)}
|
||||
value={editItem.v}
|
||||
disabled={!writeable}
|
||||
sx={{ width: '30ch' }}
|
||||
@@ -186,9 +170,9 @@ const DevicesDialog = ({
|
||||
/>
|
||||
)}
|
||||
</Grid>
|
||||
{writeable && helperText && (
|
||||
{writeable && (
|
||||
<Grid>
|
||||
<FormHelperText>{helperText}</FormHelperText>
|
||||
<FormHelperText>{showHelperText(editItem)}</FormHelperText>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
@@ -207,7 +191,7 @@ const DevicesDialog = ({
|
||||
<Button
|
||||
startIcon={<CancelIcon />}
|
||||
variant="outlined"
|
||||
onClick={onClose}
|
||||
onClick={close}
|
||||
color="secondary"
|
||||
>
|
||||
{LL.CANCEL()}
|
||||
@@ -218,7 +202,7 @@ const DevicesDialog = ({
|
||||
onClick={save}
|
||||
color="primary"
|
||||
>
|
||||
{buttonLabel}
|
||||
{selectedItem.v === '' && selectedItem.c ? LL.EXECUTE() : LL.UPDATE()}
|
||||
</Button>
|
||||
{progress && (
|
||||
<CircularProgress
|
||||
@@ -233,7 +217,7 @@ const DevicesDialog = ({
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<Button variant="outlined" onClick={onClose} color="secondary">
|
||||
<Button variant="outlined" onClick={close} color="secondary">
|
||||
{LL.CLOSE()}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import { ToggleButton, ToggleButtonGroup } from '@mui/material';
|
||||
|
||||
import OptionIcon from './OptionIcon';
|
||||
@@ -11,132 +9,92 @@ interface EntityMaskToggleProps {
|
||||
de: DeviceEntity;
|
||||
}
|
||||
|
||||
// Available mask values
|
||||
const MASK_VALUES = [
|
||||
DeviceEntityMask.DV_WEB_EXCLUDE, // 1
|
||||
DeviceEntityMask.DV_API_MQTT_EXCLUDE, // 2
|
||||
DeviceEntityMask.DV_READONLY, // 4
|
||||
DeviceEntityMask.DV_FAVORITE, // 8
|
||||
DeviceEntityMask.DV_DELETED // 128
|
||||
];
|
||||
|
||||
/**
|
||||
* Converts an array of mask strings to a bitmask number
|
||||
*/
|
||||
const getMaskNumber = (newMask: string[]): number => {
|
||||
return newMask.reduce((mask, entry) => mask | Number(entry), 0);
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts a bitmask number to an array of mask strings
|
||||
*/
|
||||
const getMaskString = (mask: number): string[] => {
|
||||
return MASK_VALUES.filter((value) => (mask & value) === value).map((value) =>
|
||||
String(value)
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a specific mask bit is set
|
||||
*/
|
||||
const hasMask = (mask: number, flag: number): boolean => (mask & flag) === flag;
|
||||
|
||||
const EntityMaskToggle = ({ onUpdate, de }: EntityMaskToggleProps) => {
|
||||
const handleChange = useCallback(
|
||||
(_event: unknown, mask: string[]) => {
|
||||
// Convert selected masks to a number
|
||||
const newMask = getMaskNumber(mask);
|
||||
const updatedDe = { ...de };
|
||||
const getMaskNumber = (newMask: string[]) => {
|
||||
let new_mask = 0;
|
||||
for (const entry of newMask) {
|
||||
new_mask |= Number(entry);
|
||||
}
|
||||
return new_mask;
|
||||
};
|
||||
|
||||
// Apply business logic for mask interactions
|
||||
// If entity has no name and is set to readonly, also exclude from web
|
||||
if (updatedDe.n === '' && hasMask(newMask, DeviceEntityMask.DV_READONLY)) {
|
||||
updatedDe.m = newMask | DeviceEntityMask.DV_WEB_EXCLUDE;
|
||||
} else {
|
||||
updatedDe.m = newMask;
|
||||
}
|
||||
|
||||
// If excluded from web, cannot be favorite
|
||||
if (hasMask(updatedDe.m, DeviceEntityMask.DV_WEB_EXCLUDE)) {
|
||||
updatedDe.m = updatedDe.m & ~DeviceEntityMask.DV_FAVORITE;
|
||||
}
|
||||
|
||||
onUpdate(updatedDe);
|
||||
},
|
||||
[de, onUpdate]
|
||||
);
|
||||
|
||||
// Memoize mask string value
|
||||
const maskStringValue = useMemo(() => getMaskString(de.m), [de.m]);
|
||||
|
||||
// Memoize disabled states
|
||||
const isFavoriteDisabled = useMemo(
|
||||
() =>
|
||||
hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE | DeviceEntityMask.DV_DELETED) ||
|
||||
de.n === undefined,
|
||||
[de.m, de.n]
|
||||
);
|
||||
|
||||
const isReadonlyDisabled = useMemo(
|
||||
() =>
|
||||
!de.w ||
|
||||
hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE | DeviceEntityMask.DV_FAVORITE),
|
||||
[de.w, de.m]
|
||||
);
|
||||
|
||||
const isApiMqttExcludeDisabled = useMemo(
|
||||
() => de.n === '' || hasMask(de.m, DeviceEntityMask.DV_DELETED),
|
||||
[de.n, de.m]
|
||||
);
|
||||
|
||||
const isWebExcludeDisabled = useMemo(
|
||||
() => de.n === undefined || hasMask(de.m, DeviceEntityMask.DV_DELETED),
|
||||
[de.n, de.m]
|
||||
);
|
||||
|
||||
// Memoize mask flag checks
|
||||
const isFavoriteSet = useMemo(
|
||||
() => hasMask(de.m, DeviceEntityMask.DV_FAVORITE),
|
||||
[de.m]
|
||||
);
|
||||
const isReadonlySet = useMemo(
|
||||
() => hasMask(de.m, DeviceEntityMask.DV_READONLY),
|
||||
[de.m]
|
||||
);
|
||||
const isApiMqttExcludeSet = useMemo(
|
||||
() => hasMask(de.m, DeviceEntityMask.DV_API_MQTT_EXCLUDE),
|
||||
[de.m]
|
||||
);
|
||||
const isWebExcludeSet = useMemo(
|
||||
() => hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE),
|
||||
[de.m]
|
||||
);
|
||||
const isDeletedSet = useMemo(
|
||||
() => hasMask(de.m, DeviceEntityMask.DV_DELETED),
|
||||
[de.m]
|
||||
);
|
||||
const getMaskString = (m: number) => {
|
||||
const new_masks: string[] = [];
|
||||
if ((m & 1) === 1) {
|
||||
new_masks.push('1');
|
||||
}
|
||||
if ((m & 2) === 2) {
|
||||
new_masks.push('2');
|
||||
}
|
||||
if ((m & 4) === 4) {
|
||||
new_masks.push('4');
|
||||
}
|
||||
if ((m & 8) === 8) {
|
||||
new_masks.push('8');
|
||||
}
|
||||
if ((m & 128) === 128) {
|
||||
new_masks.push('128');
|
||||
}
|
||||
return new_masks;
|
||||
};
|
||||
|
||||
return (
|
||||
<ToggleButtonGroup
|
||||
size="small"
|
||||
color="secondary"
|
||||
value={maskStringValue}
|
||||
onChange={handleChange}
|
||||
value={getMaskString(de.m)}
|
||||
onChange={(event, mask: string[]) => {
|
||||
de.m = getMaskNumber(mask);
|
||||
if (de.n === '' && de.m & DeviceEntityMask.DV_READONLY) {
|
||||
de.m = de.m | DeviceEntityMask.DV_WEB_EXCLUDE;
|
||||
}
|
||||
if (de.m & DeviceEntityMask.DV_WEB_EXCLUDE) {
|
||||
de.m = de.m & ~DeviceEntityMask.DV_FAVORITE;
|
||||
}
|
||||
onUpdate(de);
|
||||
}}
|
||||
>
|
||||
<ToggleButton value="8" disabled={isFavoriteDisabled}>
|
||||
<OptionIcon type="favorite" isSet={isFavoriteSet} />
|
||||
<ToggleButton value="8" disabled={(de.m & 0x81) !== 0 || de.n === undefined}>
|
||||
<OptionIcon
|
||||
type="favorite"
|
||||
isSet={
|
||||
(de.m & DeviceEntityMask.DV_FAVORITE) === DeviceEntityMask.DV_FAVORITE
|
||||
}
|
||||
/>
|
||||
</ToggleButton>
|
||||
<ToggleButton value="4" disabled={isReadonlyDisabled}>
|
||||
<OptionIcon type="readonly" isSet={isReadonlySet} />
|
||||
<ToggleButton value="4" disabled={!de.w || (de.m & 0x83) >= 3}>
|
||||
<OptionIcon
|
||||
type="readonly"
|
||||
isSet={
|
||||
(de.m & DeviceEntityMask.DV_READONLY) === DeviceEntityMask.DV_READONLY
|
||||
}
|
||||
/>
|
||||
</ToggleButton>
|
||||
<ToggleButton value="2" disabled={isApiMqttExcludeDisabled}>
|
||||
<OptionIcon type="api_mqtt_exclude" isSet={isApiMqttExcludeSet} />
|
||||
<ToggleButton value="2" disabled={de.n === '' || (de.m & 0x80) !== 0}>
|
||||
<OptionIcon
|
||||
type="api_mqtt_exclude"
|
||||
isSet={
|
||||
(de.m & DeviceEntityMask.DV_API_MQTT_EXCLUDE) ===
|
||||
DeviceEntityMask.DV_API_MQTT_EXCLUDE
|
||||
}
|
||||
/>
|
||||
</ToggleButton>
|
||||
<ToggleButton value="1" disabled={isWebExcludeDisabled}>
|
||||
<OptionIcon type="web_exclude" isSet={isWebExcludeSet} />
|
||||
<ToggleButton value="1" disabled={de.n === undefined || (de.m & 0x80) !== 0}>
|
||||
<OptionIcon
|
||||
type="web_exclude"
|
||||
isSet={
|
||||
(de.m & DeviceEntityMask.DV_WEB_EXCLUDE) ===
|
||||
DeviceEntityMask.DV_WEB_EXCLUDE
|
||||
}
|
||||
/>
|
||||
</ToggleButton>
|
||||
<ToggleButton value="128">
|
||||
<OptionIcon type="deleted" isSet={isDeletedSet} />
|
||||
<OptionIcon
|
||||
type="deleted"
|
||||
isSet={
|
||||
(de.m & DeviceEntityMask.DV_DELETED) === DeviceEntityMask.DV_DELETED
|
||||
}
|
||||
/>
|
||||
</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { memo, useCallback, useContext, useMemo, useState } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import { useContext, useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import CommentIcon from '@mui/icons-material/CommentTwoTone';
|
||||
@@ -20,7 +19,6 @@ import {
|
||||
Stack,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
import type { SxProps, Theme } from '@mui/material/styles';
|
||||
|
||||
import { useRequest } from 'alova/client';
|
||||
import { SectionContent, useLayoutTitle } from 'components';
|
||||
@@ -31,65 +29,42 @@ import { saveFile } from 'utils';
|
||||
import { API, callAction } from '../../api/app';
|
||||
import type { APIcall } from './types';
|
||||
|
||||
interface HelpLink {
|
||||
href: string;
|
||||
icon: ReactElement;
|
||||
label: () => string;
|
||||
}
|
||||
|
||||
interface CustomSupport {
|
||||
img_url: string | null;
|
||||
html: string | null;
|
||||
}
|
||||
|
||||
// Constants moved outside component to prevent recreation
|
||||
const DEFAULT_IMAGE_URL = 'https://docs.emsesp.org/_media/images/installer.jpeg';
|
||||
|
||||
const SUPPORT_BOX_STYLES: SxProps<Theme> = {
|
||||
borderRadius: 3,
|
||||
border: '1px solid lightblue',
|
||||
justifyContent: 'space-evenly',
|
||||
alignItems: 'center'
|
||||
};
|
||||
|
||||
const IMAGE_STYLES: SxProps<Theme> = {
|
||||
maxHeight: { xs: 100, md: 250 }
|
||||
};
|
||||
|
||||
const AVATAR_STYLES: SxProps<Theme> = {
|
||||
bgcolor: '#72caf9'
|
||||
};
|
||||
|
||||
const HelpComponent = () => {
|
||||
const Help = () => {
|
||||
const { LL } = useI18nContext();
|
||||
useLayoutTitle(LL.HELP());
|
||||
|
||||
const { me } = useContext(AuthenticatedContext);
|
||||
|
||||
const [customSupport, setCustomSupport] = useState<CustomSupport>({
|
||||
img_url: null,
|
||||
html: null
|
||||
});
|
||||
const [imgError, setImgError] = useState<boolean>(false);
|
||||
const [customSupportIMG, setCustomSupportIMG] = useState<string | null>(null);
|
||||
const [customSupportHTML, setCustomSupportHTML] = useState<string | null>(null);
|
||||
const [notFound, setNotFound] = useState<boolean>(false);
|
||||
|
||||
// Memoize the request method to prevent re-creation on every render
|
||||
const getCustomSupportMethod = useMemo(
|
||||
() => callAction({ action: 'getCustomSupport' }),
|
||||
[]
|
||||
);
|
||||
|
||||
useRequest(getCustomSupportMethod).onSuccess((event) => {
|
||||
if (event?.data && Object.keys(event.data).length !== 0) {
|
||||
const { Support } = event.data as {
|
||||
Support: { img_url?: string; html?: string[] };
|
||||
};
|
||||
setCustomSupport({
|
||||
img_url: Support.img_url || null,
|
||||
html: Support.html?.join('<br/>') || null
|
||||
});
|
||||
useRequest(() => callAction({ action: 'customSupport' })).onSuccess((event) => {
|
||||
if (event && event.data && Object.keys(event.data).length !== 0) {
|
||||
const data = event.data.Support;
|
||||
if (data.img_url) {
|
||||
setCustomSupportIMG(data.img_url);
|
||||
}
|
||||
if (data.html) {
|
||||
setCustomSupportHTML(data.html.join('<br/>'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 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), {
|
||||
immediate: false
|
||||
})
|
||||
@@ -98,91 +73,82 @@ const HelpComponent = () => {
|
||||
toast.info(LL.DOWNLOAD_SUCCESSFUL());
|
||||
})
|
||||
.onError((error) => {
|
||||
toast.error(String(error.error?.message || 'An error occurred'));
|
||||
toast.error(error.message);
|
||||
});
|
||||
|
||||
// Optimize API call memoization
|
||||
const apiCall = useMemo(() => ({ device: 'system', cmd: 'info', id: 0 }), []);
|
||||
|
||||
const handleDownloadSystemInfo = useCallback(() => {
|
||||
void sendAPI(apiCall);
|
||||
}, [sendAPI, apiCall]);
|
||||
|
||||
const handleImageError = useCallback(() => {
|
||||
setImgError(true);
|
||||
}, []);
|
||||
|
||||
// Memoize help links to prevent recreation on every render
|
||||
const helpLinks: HelpLink[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
href: 'https://docs.emsesp.org',
|
||||
icon: <MenuBookIcon />,
|
||||
label: () => LL.HELP_INFORMATION_1()
|
||||
},
|
||||
{
|
||||
href: 'https://discord.gg/3J3GgnzpyT',
|
||||
icon: <CommentIcon />,
|
||||
label: () => LL.HELP_INFORMATION_2()
|
||||
},
|
||||
{
|
||||
href: 'https://github.com/emsesp/EMS-ESP32/issues/new/choose',
|
||||
icon: <GitHubIcon />,
|
||||
label: () => LL.HELP_INFORMATION_3()
|
||||
}
|
||||
],
|
||||
[LL]
|
||||
);
|
||||
|
||||
const isAdmin = useMemo(() => me?.admin ?? false, [me?.admin]);
|
||||
|
||||
// Memoize image source computation
|
||||
const imageSrc = useMemo(
|
||||
() =>
|
||||
imgError || !customSupport.img_url ? DEFAULT_IMAGE_URL : customSupport.img_url,
|
||||
[imgError, customSupport.img_url]
|
||||
);
|
||||
|
||||
return (
|
||||
<SectionContent>
|
||||
{customSupport.html && (
|
||||
<Stack
|
||||
padding={1}
|
||||
mb={2}
|
||||
direction="row"
|
||||
divider={<Divider orientation="vertical" flexItem />}
|
||||
sx={SUPPORT_BOX_STYLES}
|
||||
>
|
||||
<Typography variant="subtitle1">
|
||||
<div dangerouslySetInnerHTML={{ __html: customSupport.html }} />
|
||||
</Typography>
|
||||
<Box
|
||||
component="img"
|
||||
referrerPolicy="no-referrer"
|
||||
sx={IMAGE_STYLES}
|
||||
onError={handleImageError}
|
||||
src={imageSrc}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
<Stack
|
||||
padding={1}
|
||||
mb={2}
|
||||
direction="row"
|
||||
divider={<Divider orientation="vertical" flexItem />}
|
||||
sx={{
|
||||
borderRadius: 3,
|
||||
border: '2px solid grey',
|
||||
justifyContent: 'space-evenly',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle1">
|
||||
{customSupportHTML ? (
|
||||
<div dangerouslySetInnerHTML={{ __html: customSupportHTML }} />
|
||||
) : (
|
||||
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>
|
||||
|
||||
{isAdmin && (
|
||||
<List>
|
||||
{helpLinks.map(({ href, icon, label }) => (
|
||||
<ListItem key={href}>
|
||||
<ListItemButton
|
||||
component="a"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href={href}
|
||||
>
|
||||
<ListItemAvatar>
|
||||
<Avatar sx={AVATAR_STYLES}>{icon}</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText primary={label()} />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
{me.admin && (
|
||||
<List sx={{ borderRadius: 3, border: '2px solid grey' }}>
|
||||
<ListItem>
|
||||
<ListItemButton component="a" href="https://emsesp.org">
|
||||
<ListItemAvatar>
|
||||
<Avatar sx={{ bgcolor: '#72caf9' }}>
|
||||
<MenuBookIcon />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText primary={LL.HELP_INFORMATION_1()} />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
|
||||
<ListItem>
|
||||
<ListItemButton component="a" href="https://discord.gg/3J3GgnzpyT">
|
||||
<ListItemAvatar>
|
||||
<Avatar sx={{ bgcolor: '#72caf9' }}>
|
||||
<CommentIcon />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText primary={LL.HELP_INFORMATION_2()} />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
|
||||
<ListItem>
|
||||
<ListItemButton
|
||||
component="a"
|
||||
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>
|
||||
)}
|
||||
|
||||
@@ -194,30 +160,32 @@ const HelpComponent = () => {
|
||||
startIcon={<DownloadIcon />}
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={handleDownloadSystemInfo}
|
||||
onClick={() => sendAPI({ device: 'system', cmd: 'info', id: 0 })}
|
||||
>
|
||||
{LL.SUPPORT_INFORMATION(0)}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* <Button
|
||||
sx={{ ml: 2 }}
|
||||
startIcon={<DownloadIcon />}
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={() => sendExportAllValues()}
|
||||
>
|
||||
{LL.DOWNLOAD(1)} {LL.ALLVALUES()}
|
||||
</Button> */}
|
||||
|
||||
<Divider sx={{ mt: 4 }} />
|
||||
|
||||
<Typography color="white" variant="subtitle1" align="center" mt={1}>
|
||||
©
|
||||
<Link
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href="https://emsesp.org"
|
||||
color="primary"
|
||||
>
|
||||
emsesp.org
|
||||
<Link target="_blank" href="https://emsesp.org" color="primary">
|
||||
{'emsesp.org'}
|
||||
</Link>
|
||||
</Typography>
|
||||
</SectionContent>
|
||||
);
|
||||
};
|
||||
|
||||
// Memoize the component to prevent unnecessary re-renders
|
||||
const Help = memo(HelpComponent);
|
||||
|
||||
export default Help;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { memo, useCallback, useMemo, useState } from 'react';
|
||||
import { useBlocker } from 'react-router';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useBlocker } from 'react-router-dom';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
@@ -31,19 +31,6 @@ import { readModules, writeModules } from '../../api/app';
|
||||
import ModulesDialog from './ModulesDialog';
|
||||
import type { ModuleItem } from './types';
|
||||
|
||||
const PENDING_COLOR = 'red';
|
||||
const ACTIVATED_COLOR = '#00FF7F';
|
||||
|
||||
const hasModulesChanged = (mi: ModuleItem): boolean =>
|
||||
mi.enabled !== mi.o_enabled || mi.license !== mi.o_license;
|
||||
|
||||
const ColorStatus = memo(({ status }: { status: number }) => {
|
||||
if (status === 1) {
|
||||
return <div style={{ color: PENDING_COLOR }}>Pending Activation</div>;
|
||||
}
|
||||
return <div style={{ color: ACTIVATED_COLOR }}>Activated</div>;
|
||||
});
|
||||
|
||||
const Modules = () => {
|
||||
const { LL } = useI18nContext();
|
||||
const [numChanges, setNumChanges] = useState<number>(0);
|
||||
@@ -69,111 +56,105 @@ const Modules = () => {
|
||||
}
|
||||
);
|
||||
|
||||
const modules_theme = useTheme(
|
||||
useMemo(
|
||||
() => ({
|
||||
Table: `
|
||||
--data-table-library_grid-template-columns: 48px 180px 120px 100px repeat(1, minmax(160px, 1fr)) 180px;
|
||||
`,
|
||||
BaseRow: `
|
||||
font-size: 14px;
|
||||
.td {
|
||||
height: 32px;
|
||||
}
|
||||
`,
|
||||
BaseCell: `
|
||||
&:nth-of-type(1) {
|
||||
text-align: center;
|
||||
}
|
||||
`,
|
||||
HeaderRow: `
|
||||
text-transform: uppercase;
|
||||
background-color: black;
|
||||
color: #90CAF9;
|
||||
.th {
|
||||
border-bottom: 1px solid #565656;
|
||||
height: 36px;
|
||||
}
|
||||
`,
|
||||
Row: `
|
||||
background-color: #1e1e1e;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
.td {
|
||||
border-top: 1px solid #565656;
|
||||
border-bottom: 1px solid #565656;
|
||||
}
|
||||
&:hover .td {
|
||||
border-top: 1px solid #177ac9;
|
||||
border-bottom: 1px solid #177ac9;
|
||||
}
|
||||
&:nth-of-type(odd) .td {
|
||||
background-color: #303030;
|
||||
}
|
||||
`
|
||||
}),
|
||||
[]
|
||||
)
|
||||
);
|
||||
const modules_theme = useTheme({
|
||||
Table: `
|
||||
--data-table-library_grid-template-columns: 48px 180px 120px 100px repeat(1, minmax(160px, 1fr)) 180px;
|
||||
`,
|
||||
BaseRow: `
|
||||
font-size: 14px;
|
||||
.td {
|
||||
height: 32px;
|
||||
}
|
||||
`,
|
||||
BaseCell: `
|
||||
&:nth-of-type(1) {
|
||||
text-align: center;
|
||||
}
|
||||
`,
|
||||
HeaderRow: `
|
||||
text-transform: uppercase;
|
||||
background-color: black;
|
||||
color: #90CAF9;
|
||||
.th {
|
||||
border-bottom: 1px solid #565656;
|
||||
height: 36px;
|
||||
}
|
||||
`,
|
||||
Row: `
|
||||
background-color: #1e1e1e;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
.td {
|
||||
border-top: 1px solid #565656;
|
||||
border-bottom: 1px solid #565656;
|
||||
}
|
||||
&:hover .td {
|
||||
border-top: 1px solid #177ac9;
|
||||
border-bottom: 1px solid #177ac9;
|
||||
}
|
||||
&:nth-of-type(odd) .td {
|
||||
background-color: #303030;
|
||||
}
|
||||
`
|
||||
});
|
||||
|
||||
const onDialogClose = useCallback(() => {
|
||||
const onDialogClose = () => {
|
||||
setDialogOpen(false);
|
||||
}, []);
|
||||
};
|
||||
|
||||
const updateModuleItem = useCallback((updatedItem: ModuleItem) => {
|
||||
void updateState(readModules(), (data: ModuleItem[]) => {
|
||||
const new_data = data.map((mi) =>
|
||||
mi.id === updatedItem.id ? { ...mi, ...updatedItem } : mi
|
||||
);
|
||||
setNumChanges(new_data.filter(hasModulesChanged).length);
|
||||
return new_data;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const onDialogSave = useCallback(
|
||||
(updatedItem: ModuleItem) => {
|
||||
setDialogOpen(false);
|
||||
updateModuleItem(updatedItem);
|
||||
},
|
||||
[updateModuleItem]
|
||||
);
|
||||
const onDialogSave = (updatedItem: ModuleItem) => {
|
||||
setDialogOpen(false);
|
||||
updateModuleItem(updatedItem);
|
||||
};
|
||||
|
||||
const editModuleItem = useCallback((mi: ModuleItem) => {
|
||||
setSelectedModuleItem(mi);
|
||||
setDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const onCancel = useCallback(async () => {
|
||||
const onCancel = async () => {
|
||||
await fetchModules().then(() => {
|
||||
setNumChanges(0);
|
||||
});
|
||||
}, [fetchModules]);
|
||||
};
|
||||
|
||||
const saveModules = useCallback(async () => {
|
||||
try {
|
||||
await Promise.all(
|
||||
modules.map((condensed_mi: ModuleItem) =>
|
||||
updateModules({
|
||||
key: condensed_mi.key,
|
||||
enabled: condensed_mi.enabled,
|
||||
license: condensed_mi.license
|
||||
})
|
||||
)
|
||||
function hasModulesChanged(mi: ModuleItem) {
|
||||
return mi.enabled !== mi.o_enabled || mi.license !== mi.o_license;
|
||||
}
|
||||
|
||||
const updateModuleItem = (updatedItem: ModuleItem) => {
|
||||
void updateState(readModules(), (data: ModuleItem[]) => {
|
||||
const new_data = data.map((mi) =>
|
||||
mi.id === updatedItem.id ? { ...mi, ...updatedItem } : mi
|
||||
);
|
||||
toast.success(LL.MODULES_UPDATED());
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
await fetchModules();
|
||||
setNumChanges(0);
|
||||
}
|
||||
}, [modules, updateModules, LL, fetchModules]);
|
||||
setNumChanges(new_data.filter((mi) => hasModulesChanged(mi)).length);
|
||||
return new_data;
|
||||
});
|
||||
};
|
||||
|
||||
const content = useMemo(() => {
|
||||
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 = () => {
|
||||
if (!modules) {
|
||||
return (
|
||||
<FormLoader onRetry={fetchModules} errorMessage={error?.message || ''} />
|
||||
);
|
||||
return <FormLoader onRetry={fetchModules} errorMessage={error?.message} />;
|
||||
}
|
||||
|
||||
if (modules.length === 0) {
|
||||
@@ -184,6 +165,13 @@ const Modules = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const colorStatus = (status: number) => {
|
||||
if (status === 1) {
|
||||
return <div style={{ color: 'red' }}>Pending Activation</div>;
|
||||
}
|
||||
return <div style={{ color: '#00FF7F' }}>Activated</div>;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box mb={2} color="warning.main">
|
||||
@@ -226,9 +214,7 @@ const Modules = () => {
|
||||
<Cell>{mi.author}</Cell>
|
||||
<Cell>{mi.version}</Cell>
|
||||
<Cell>{mi.message}</Cell>
|
||||
<Cell>
|
||||
<ColorStatus status={mi.status} />
|
||||
</Cell>
|
||||
<Cell>{colorStatus(mi.status)}</Cell>
|
||||
</Row>
|
||||
))}
|
||||
</Body>
|
||||
@@ -262,22 +248,12 @@ const Modules = () => {
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}, [
|
||||
modules,
|
||||
fetchModules,
|
||||
error,
|
||||
modules_theme,
|
||||
editModuleItem,
|
||||
LL,
|
||||
numChanges,
|
||||
onCancel,
|
||||
saveModules
|
||||
]);
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionContent>
|
||||
{blocker ? <BlockNavigation blocker={blocker} /> : null}
|
||||
{content}
|
||||
{renderContent()}
|
||||
{selectedModuleItem && (
|
||||
<ModulesDialog
|
||||
open={dialogOpen}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import DoneIcon from '@mui/icons-material/Done';
|
||||
@@ -10,9 +10,9 @@ import {
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Grid,
|
||||
TextField
|
||||
} from '@mui/material';
|
||||
import Grid from '@mui/material/Grid2';
|
||||
|
||||
import { dialogStyle } from 'CustomTheme';
|
||||
import { BlockFormControlLabel } from 'components';
|
||||
@@ -37,35 +37,25 @@ const ModulesDialog = ({
|
||||
const { LL } = useI18nContext();
|
||||
const [editItem, setEditItem] = useState<ModuleItem>(selectedItem);
|
||||
|
||||
const updateFormValue = useMemo(
|
||||
() =>
|
||||
updateValue(
|
||||
setEditItem as unknown as React.Dispatch<
|
||||
React.SetStateAction<Record<string, unknown>>
|
||||
>
|
||||
),
|
||||
[]
|
||||
);
|
||||
const updateFormValue = updateValue(setEditItem);
|
||||
|
||||
// Sync form state when dialog opens or selected item changes
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setEditItem(selectedItem);
|
||||
}
|
||||
}, [open, selectedItem]);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
onSave(editItem);
|
||||
}, [editItem, onSave]);
|
||||
const close = () => {
|
||||
onClose();
|
||||
};
|
||||
|
||||
const dialogTitle = useMemo(
|
||||
() => `${LL.EDIT()} ${editItem.key}`,
|
||||
[LL, editItem.key]
|
||||
);
|
||||
const save = () => {
|
||||
onSave(editItem);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog sx={dialogStyle} fullWidth maxWidth="xs" open={open} onClose={onClose}>
|
||||
<DialogTitle>{dialogTitle}</DialogTitle>
|
||||
<DialogTitle>{LL.EDIT() + ' ' + editItem.key}</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<Grid container>
|
||||
<BlockFormControlLabel
|
||||
@@ -95,7 +85,7 @@ const ModulesDialog = ({
|
||||
<Button
|
||||
startIcon={<CancelIcon />}
|
||||
variant="outlined"
|
||||
onClick={onClose}
|
||||
onClick={close}
|
||||
color="secondary"
|
||||
>
|
||||
{LL.CANCEL()}
|
||||
@@ -103,7 +93,7 @@ const ModulesDialog = ({
|
||||
<Button
|
||||
startIcon={<DoneIcon />}
|
||||
variant="outlined"
|
||||
onClick={handleSave}
|
||||
onClick={save}
|
||||
color="primary"
|
||||
>
|
||||
{LL.UPDATE()}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { memo } from 'react';
|
||||
|
||||
import CommentsDisabledOutlinedIcon from '@mui/icons-material/CommentsDisabledOutlined';
|
||||
import DeleteForeverIcon from '@mui/icons-material/DeleteForever';
|
||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||
@@ -12,39 +10,33 @@ import VisibilityOffOutlinedIcon from '@mui/icons-material/VisibilityOffOutlined
|
||||
import VisibilityOutlinedIcon from '@mui/icons-material/VisibilityOutlined';
|
||||
import type { SvgIconProps } from '@mui/material';
|
||||
|
||||
export type OptionType =
|
||||
type OptionType =
|
||||
| 'deleted'
|
||||
| 'readonly'
|
||||
| 'web_exclude'
|
||||
| 'api_mqtt_exclude'
|
||||
| 'favorite';
|
||||
|
||||
type IconPair = [
|
||||
React.ComponentType<SvgIconProps>,
|
||||
React.ComponentType<SvgIconProps>
|
||||
];
|
||||
|
||||
const OPTION_ICONS: Record<OptionType, IconPair> = {
|
||||
const OPTION_ICONS: {
|
||||
[type in OptionType]: [
|
||||
React.ComponentType<SvgIconProps>,
|
||||
React.ComponentType<SvgIconProps>
|
||||
];
|
||||
} = {
|
||||
deleted: [DeleteForeverIcon, DeleteOutlineIcon],
|
||||
readonly: [EditOffOutlinedIcon, EditOutlinedIcon],
|
||||
web_exclude: [VisibilityOffOutlinedIcon, VisibilityOutlinedIcon],
|
||||
api_mqtt_exclude: [CommentsDisabledOutlinedIcon, InsertCommentOutlinedIcon],
|
||||
favorite: [StarIcon, StarOutlineIcon]
|
||||
} as const;
|
||||
|
||||
const ICON_SIZE = 16;
|
||||
const ICON_SX = { fontSize: ICON_SIZE, verticalAlign: 'middle' } as const;
|
||||
|
||||
export interface OptionIconProps {
|
||||
readonly type: OptionType;
|
||||
readonly isSet: boolean;
|
||||
}
|
||||
|
||||
const OptionIcon = ({ type, isSet }: OptionIconProps) => {
|
||||
const [SetIcon, UnsetIcon] = OPTION_ICONS[type];
|
||||
const Icon = isSet ? SetIcon : UnsetIcon;
|
||||
|
||||
return <Icon {...(isSet && { color: 'primary' })} sx={ICON_SX} />;
|
||||
};
|
||||
|
||||
export default memo(OptionIcon);
|
||||
const OptionIcon = ({ type, isSet }: { type: OptionType; isSet: boolean }) => {
|
||||
const Icon = OPTION_ICONS[type][isSet ? 0 : 1];
|
||||
return isSet ? (
|
||||
<Icon color="primary" sx={{ fontSize: 16, verticalAlign: 'middle' }} />
|
||||
) : (
|
||||
<Icon sx={{ fontSize: 16, verticalAlign: 'middle' }} />
|
||||
);
|
||||
};
|
||||
|
||||
export default OptionIcon;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useBlocker } from 'react-router';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useBlocker } from 'react-router-dom';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
useLayoutTitle
|
||||
} from 'components';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
import { useInterval } from 'utils';
|
||||
|
||||
import { readSchedule, writeSchedule } from '../../api/app';
|
||||
import SettingsSchedulerDialog from './SchedulerDialog';
|
||||
@@ -35,76 +34,6 @@ import { ScheduleFlag } from './types';
|
||||
import type { Schedule, ScheduleItem } from './types';
|
||||
import { schedulerItemValidation } from './validators';
|
||||
|
||||
// Constants
|
||||
const INTERVAL_DELAY = 30000; // 30 seconds
|
||||
const MIN_ID = -100;
|
||||
const MAX_ID = 100;
|
||||
const ICON_SIZE = 16;
|
||||
const SCHEDULE_FLAG_THRESHOLD = 127;
|
||||
const REFERENCE_YEAR = 2017;
|
||||
const REFERENCE_MONTH = '01';
|
||||
const LOG_2 = Math.log(2);
|
||||
|
||||
// Days of week starting from Monday (1-7)
|
||||
const WEEK_DAYS = [1, 2, 3, 4, 5, 6, 7] as const;
|
||||
|
||||
const DEFAULT_SCHEDULE_ITEM: Omit<ScheduleItem, 'id' | 'o_id'> = {
|
||||
active: false,
|
||||
deleted: false,
|
||||
flags: ScheduleFlag.SCHEDULE_DAY,
|
||||
time: '',
|
||||
cmd: '',
|
||||
value: '',
|
||||
name: ''
|
||||
};
|
||||
|
||||
const scheduleTheme = {
|
||||
Table: `
|
||||
--data-table-library_grid-template-columns: 36px 210px 100px 192px repeat(1, minmax(100px, 1fr)) 160px;
|
||||
`,
|
||||
BaseRow: `
|
||||
font-size: 14px;
|
||||
.td {
|
||||
height: 32px;
|
||||
}
|
||||
`,
|
||||
BaseCell: `
|
||||
&:nth-of-type(2) {
|
||||
text-align: center;
|
||||
}
|
||||
&:nth-of-type(1) {
|
||||
text-align: center;
|
||||
}
|
||||
`,
|
||||
HeaderRow: `
|
||||
text-transform: uppercase;
|
||||
background-color: black;
|
||||
color: #90CAF9;
|
||||
.th {
|
||||
border-bottom: 1px solid #565656;
|
||||
height: 36px;
|
||||
}
|
||||
`,
|
||||
Row: `
|
||||
background-color: #1e1e1e;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
.td {
|
||||
border-bottom: 1px solid #565656;
|
||||
}
|
||||
&:hover .td {
|
||||
background-color: #177ac9;
|
||||
}
|
||||
`
|
||||
};
|
||||
|
||||
const scheduleTypeLabels: Record<number, string> = {
|
||||
[ScheduleFlag.SCHEDULE_IMMEDIATE]: 'Immediate',
|
||||
[ScheduleFlag.SCHEDULE_TIMER]: 'Timer',
|
||||
[ScheduleFlag.SCHEDULE_CONDITION]: 'Condition',
|
||||
[ScheduleFlag.SCHEDULE_ONCHANGE]: 'On Change'
|
||||
};
|
||||
|
||||
const Scheduler = () => {
|
||||
const { LL, locale } = useI18nContext();
|
||||
const [numChanges, setNumChanges] = useState<number>(0);
|
||||
@@ -131,7 +60,7 @@ const Scheduler = () => {
|
||||
}
|
||||
);
|
||||
|
||||
const hasScheduleChanged = useCallback((si: ScheduleItem) => {
|
||||
function hasScheduleChanged(si: ScheduleItem) {
|
||||
return (
|
||||
si.id !== si.o_id ||
|
||||
(si.name || '') !== (si.o_name || '') ||
|
||||
@@ -142,56 +71,85 @@ const Scheduler = () => {
|
||||
si.cmd !== si.o_cmd ||
|
||||
si.value !== si.o_value
|
||||
);
|
||||
}, []);
|
||||
|
||||
const intervalCallback = useCallback(() => {
|
||||
if (numChanges === 0) {
|
||||
void fetchSchedule();
|
||||
}
|
||||
}, [numChanges, fetchSchedule]);
|
||||
|
||||
useInterval(intervalCallback, INTERVAL_DELAY);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const formatter = new Intl.DateTimeFormat(locale, {
|
||||
weekday: 'short',
|
||||
timeZone: 'UTC'
|
||||
});
|
||||
const days = WEEK_DAYS.map((day) => {
|
||||
const dayStr = String(day).padStart(2, '0');
|
||||
return new Date(
|
||||
`${REFERENCE_YEAR}-${REFERENCE_MONTH}-${dayStr}T00:00:00+00:00`
|
||||
);
|
||||
const days = [1, 2, 3, 4, 5, 6, 7].map((day) => {
|
||||
const dd = day < 10 ? `0${day}` : day;
|
||||
return new Date(`2017-01-${dd}T00:00:00+00:00`);
|
||||
});
|
||||
setDow(days.map((date) => formatter.format(date)));
|
||||
}, [locale]);
|
||||
|
||||
const schedule_theme = useTheme(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 = useCallback(async () => {
|
||||
try {
|
||||
await updateSchedule({
|
||||
schedule: schedule
|
||||
.filter((si: ScheduleItem) => !si.deleted)
|
||||
.map((condensed_si: ScheduleItem) => ({
|
||||
id: condensed_si.id,
|
||||
active: condensed_si.active,
|
||||
flags: condensed_si.flags,
|
||||
time: condensed_si.time,
|
||||
cmd: condensed_si.cmd,
|
||||
value: condensed_si.value,
|
||||
name: condensed_si.name
|
||||
}))
|
||||
const saveSchedule = async () => {
|
||||
await updateSchedule({
|
||||
schedule: schedule
|
||||
.filter((si) => !si.deleted)
|
||||
.map((condensed_si) => ({
|
||||
id: condensed_si.id,
|
||||
active: condensed_si.active,
|
||||
flags: condensed_si.flags,
|
||||
time: condensed_si.time,
|
||||
cmd: condensed_si.cmd,
|
||||
value: condensed_si.value,
|
||||
name: condensed_si.name
|
||||
}))
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(LL.SCHEDULE_UPDATED());
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
toast.error(error.message);
|
||||
})
|
||||
.finally(async () => {
|
||||
await fetchSchedule();
|
||||
setNumChanges(0);
|
||||
});
|
||||
toast.success(LL.SCHEDULE_UPDATED());
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
toast.error(message);
|
||||
} finally {
|
||||
await fetchSchedule();
|
||||
setNumChanges(0);
|
||||
}
|
||||
}, [LL, schedule, updateSchedule, fetchSchedule]);
|
||||
};
|
||||
|
||||
const editScheduleItem = useCallback((si: ScheduleItem) => {
|
||||
setCreating(false);
|
||||
@@ -202,93 +160,93 @@ const Scheduler = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onDialogClose = useCallback(() => {
|
||||
const onDialogClose = () => {
|
||||
setDialogOpen(false);
|
||||
}, []);
|
||||
};
|
||||
|
||||
const onDialogCancel = useCallback(async () => {
|
||||
const onDialogCancel = async () => {
|
||||
await fetchSchedule().then(() => {
|
||||
setNumChanges(0);
|
||||
});
|
||||
}, [fetchSchedule]);
|
||||
};
|
||||
|
||||
const onDialogSave = useCallback(
|
||||
(updatedItem: ScheduleItem) => {
|
||||
setDialogOpen(false);
|
||||
void updateState(readSchedule(), (data: ScheduleItem[]) => {
|
||||
const new_data = creating
|
||||
? [...data, updatedItem]
|
||||
: data.map((si) =>
|
||||
si.id === updatedItem.id ? { ...si, ...updatedItem } : si
|
||||
);
|
||||
const onDialogSave = (updatedItem: ScheduleItem) => {
|
||||
setDialogOpen(false);
|
||||
void updateState(readSchedule(), (data: ScheduleItem[]) => {
|
||||
const new_data = creating
|
||||
? [
|
||||
...data.filter((si) => creating || si.o_id !== updatedItem.o_id),
|
||||
updatedItem
|
||||
]
|
||||
: data.map((si) =>
|
||||
si.id === updatedItem.id ? { ...si, ...updatedItem } : si
|
||||
);
|
||||
|
||||
setNumChanges(new_data.filter((si) => hasScheduleChanged(si)).length);
|
||||
setNumChanges(new_data.filter((si) => hasScheduleChanged(si)).length);
|
||||
|
||||
return new_data;
|
||||
});
|
||||
},
|
||||
[creating, hasScheduleChanged]
|
||||
);
|
||||
return new_data;
|
||||
});
|
||||
};
|
||||
|
||||
const addScheduleItem = useCallback(() => {
|
||||
const addScheduleItem = () => {
|
||||
setCreating(true);
|
||||
const newItem: ScheduleItem = {
|
||||
id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
|
||||
...DEFAULT_SCHEDULE_ITEM
|
||||
};
|
||||
setSelectedScheduleItem(newItem);
|
||||
setSelectedScheduleItem({
|
||||
id: Math.floor(Math.random() * (Math.floor(200) - 100) + 100),
|
||||
active: false,
|
||||
deleted: false,
|
||||
flags: ScheduleFlag.SCHEDULE_DAY,
|
||||
time: '',
|
||||
cmd: '',
|
||||
value: '',
|
||||
name: ''
|
||||
});
|
||||
setDialogOpen(true);
|
||||
}, []);
|
||||
};
|
||||
|
||||
const filteredAndSortedSchedule = useMemo(
|
||||
() =>
|
||||
schedule
|
||||
.filter((si: ScheduleItem) => !si.deleted)
|
||||
.sort((a: ScheduleItem, b: ScheduleItem) => a.flags - b.flags),
|
||||
[schedule]
|
||||
);
|
||||
const renderSchedule = () => {
|
||||
if (!schedule) {
|
||||
return <FormLoader onRetry={fetchSchedule} errorMessage={error?.message} />;
|
||||
}
|
||||
|
||||
const dayBox = useCallback(
|
||||
(si: ScheduleItem, flag: number) => {
|
||||
const dayIndex = Math.log(flag) / LOG_2;
|
||||
const isActive = (si.flags & flag) === flag;
|
||||
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 />
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box>
|
||||
<Typography sx={{ fontSize: 11 }} color={isActive ? 'primary' : 'grey'}>
|
||||
{dow[dayIndex]}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Divider orientation="vertical" flexItem />
|
||||
</>
|
||||
);
|
||||
},
|
||||
[dow]
|
||||
);
|
||||
|
||||
const scheduleType = useCallback((si: ScheduleItem) => {
|
||||
const label = scheduleTypeLabels[si.flags];
|
||||
|
||||
return (
|
||||
const scheduleType = (si: ScheduleItem) => (
|
||||
<Box>
|
||||
<Typography sx={{ fontSize: 11 }} color="primary">
|
||||
{label || ''}
|
||||
{si.flags === ScheduleFlag.SCHEDULE_IMMEDIATE ? (
|
||||
<>Immediate</>
|
||||
) : si.flags === ScheduleFlag.SCHEDULE_TIMER ? (
|
||||
<>Timer</>
|
||||
) : si.flags === ScheduleFlag.SCHEDULE_CONDITION ? (
|
||||
<>Condition</>
|
||||
) : si.flags === ScheduleFlag.SCHEDULE_ONCHANGE ? (
|
||||
<>On Change</>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}, []);
|
||||
|
||||
const renderSchedule = useCallback(() => {
|
||||
if (!schedule) {
|
||||
return (
|
||||
<FormLoader onRetry={fetchSchedule} errorMessage={error?.message || ''} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Table
|
||||
data={{ nodes: filteredAndSortedSchedule }}
|
||||
data={{
|
||||
nodes: schedule
|
||||
.filter((si) => !si.deleted)
|
||||
.sort((a, b) => a.flags - b.flags)
|
||||
}}
|
||||
theme={schedule_theme}
|
||||
layout={{ custom: true }}
|
||||
>
|
||||
@@ -308,15 +266,22 @@ const Scheduler = () => {
|
||||
{tableList.map((si: ScheduleItem) => (
|
||||
<Row key={si.id} item={si} onClick={() => editScheduleItem(si)}>
|
||||
<Cell stiff>
|
||||
<CircleIcon
|
||||
color={si.active ? 'success' : 'error'}
|
||||
sx={{ fontSize: ICON_SIZE, verticalAlign: 'middle' }}
|
||||
/>
|
||||
{si.active ? (
|
||||
<CircleIcon
|
||||
color="success"
|
||||
sx={{ fontSize: 16, verticalAlign: 'middle' }}
|
||||
/>
|
||||
) : (
|
||||
<CircleIcon
|
||||
color="error"
|
||||
sx={{ fontSize: 16, verticalAlign: 'middle' }}
|
||||
/>
|
||||
)}
|
||||
</Cell>
|
||||
<Cell stiff>
|
||||
<Stack spacing={0.5} direction="row">
|
||||
<Divider orientation="vertical" flexItem />
|
||||
{si.flags > SCHEDULE_FLAG_THRESHOLD ? (
|
||||
{si.flags > 127 ? (
|
||||
scheduleType(si)
|
||||
) : (
|
||||
<>
|
||||
@@ -342,17 +307,7 @@ const Scheduler = () => {
|
||||
)}
|
||||
</Table>
|
||||
);
|
||||
}, [
|
||||
schedule,
|
||||
error,
|
||||
fetchSchedule,
|
||||
filteredAndSortedSchedule,
|
||||
schedule_theme,
|
||||
editScheduleItem,
|
||||
LL,
|
||||
dayBox,
|
||||
scheduleType
|
||||
]);
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionContent>
|
||||
@@ -374,7 +329,7 @@ const Scheduler = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
<Box display="flex" flexWrap="wrap">
|
||||
<Box mt={1} display="flex" flexWrap="wrap">
|
||||
<Box flexGrow={1}>
|
||||
{numChanges !== 0 && (
|
||||
<ButtonRow>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
@@ -13,12 +13,12 @@ import {
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Grid,
|
||||
TextField,
|
||||
ToggleButton,
|
||||
ToggleButtonGroup,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
import Grid from '@mui/material/Grid2';
|
||||
|
||||
import { dialogStyle } from 'CustomTheme';
|
||||
import type Schema from 'async-validator';
|
||||
@@ -31,34 +31,6 @@ import { validate } from 'validators';
|
||||
import { ScheduleFlag } from './types';
|
||||
import type { ScheduleItem } from './types';
|
||||
|
||||
// Constants
|
||||
const FLAG_MASK_127 = 127;
|
||||
const SCHEDULE_TYPE_THRESHOLD = 128;
|
||||
const DEFAULT_TIME = '00:00';
|
||||
const TYPOGRAPHY_FONT_SIZE = 10;
|
||||
|
||||
// Day of week flag configuration (static, defined outside component)
|
||||
const DAY_FLAGS = [
|
||||
{ value: '2', flag: ScheduleFlag.SCHEDULE_MON },
|
||||
{ value: '4', flag: ScheduleFlag.SCHEDULE_TUE },
|
||||
{ value: '8', flag: ScheduleFlag.SCHEDULE_WED },
|
||||
{ value: '16', flag: ScheduleFlag.SCHEDULE_THU },
|
||||
{ value: '32', flag: ScheduleFlag.SCHEDULE_FRI },
|
||||
{ value: '64', flag: ScheduleFlag.SCHEDULE_SAT },
|
||||
{ value: '1', flag: ScheduleFlag.SCHEDULE_SUN }
|
||||
] as const;
|
||||
|
||||
// Day of week flag values array (static)
|
||||
const FLAG_VALUES = [
|
||||
ScheduleFlag.SCHEDULE_SUN,
|
||||
ScheduleFlag.SCHEDULE_MON,
|
||||
ScheduleFlag.SCHEDULE_TUE,
|
||||
ScheduleFlag.SCHEDULE_WED,
|
||||
ScheduleFlag.SCHEDULE_THU,
|
||||
ScheduleFlag.SCHEDULE_FRI,
|
||||
ScheduleFlag.SCHEDULE_SAT
|
||||
] as const;
|
||||
|
||||
interface SchedulerDialogProps {
|
||||
open: boolean;
|
||||
creating: boolean;
|
||||
@@ -81,163 +53,107 @@ const SchedulerDialog = ({
|
||||
const { LL } = useI18nContext();
|
||||
const [editItem, setEditItem] = useState<ScheduleItem>(selectedItem);
|
||||
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
||||
|
||||
const [scheduleType, setScheduleType] = useState<ScheduleFlag>();
|
||||
|
||||
const updateFormValue = useMemo(
|
||||
() =>
|
||||
updateValue(
|
||||
setEditItem as unknown as React.Dispatch<
|
||||
React.SetStateAction<Record<string, unknown>>
|
||||
>
|
||||
),
|
||||
[]
|
||||
);
|
||||
const updateFormValue = updateValue(setEditItem);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setFieldErrors(undefined);
|
||||
setEditItem(selectedItem);
|
||||
// Set the flags based on type when page is loaded:
|
||||
// set the flags based on type when page is loaded...
|
||||
// 0-127 is day schedule
|
||||
// 128 is timer
|
||||
// 129 is on change
|
||||
// 130 is on condition
|
||||
// 132 is immediate
|
||||
setScheduleType(
|
||||
selectedItem.flags < SCHEDULE_TYPE_THRESHOLD
|
||||
? ScheduleFlag.SCHEDULE_DAY
|
||||
: selectedItem.flags
|
||||
selectedItem.flags < 128 ? ScheduleFlag.SCHEDULE_DAY : selectedItem.flags
|
||||
);
|
||||
}
|
||||
}, [open, selectedItem]);
|
||||
|
||||
// Helper function to handle save operations
|
||||
const handleSave = useCallback(
|
||||
async (itemToSave: ScheduleItem) => {
|
||||
try {
|
||||
setFieldErrors(undefined);
|
||||
await validate(validator, itemToSave);
|
||||
onSave(itemToSave);
|
||||
} catch (error) {
|
||||
setFieldErrors(error as ValidateFieldsError);
|
||||
}
|
||||
},
|
||||
[validator, onSave]
|
||||
);
|
||||
|
||||
const save = useCallback(async () => {
|
||||
await handleSave(editItem);
|
||||
}, [editItem, handleSave]);
|
||||
|
||||
const saveandactivate = useCallback(async () => {
|
||||
await handleSave({ ...editItem, active: true });
|
||||
}, [editItem, handleSave]);
|
||||
|
||||
const remove = useCallback(() => {
|
||||
onSave({ ...editItem, deleted: true });
|
||||
}, [editItem, onSave]);
|
||||
|
||||
// Optimize DOW flag conversion
|
||||
const getFlagDOWnumber = useCallback((flags: string[]) => {
|
||||
return flags.reduce((acc, flag) => acc | Number(flag), 0) & FLAG_MASK_127;
|
||||
}, []);
|
||||
|
||||
const getFlagDOWstring = useCallback((f: number) => {
|
||||
return FLAG_VALUES.filter((flag) => (f & flag) === flag).map((flag) =>
|
||||
String(flag)
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Day of week display component
|
||||
const DayOfWeekButton = useCallback(
|
||||
(flag: number) => {
|
||||
const dayIndex = Math.log2(flag);
|
||||
const isSelected = (editItem.flags & flag) === flag;
|
||||
return (
|
||||
<Typography
|
||||
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
|
||||
color={isSelected ? 'primary' : 'grey'}
|
||||
>
|
||||
{dow[dayIndex]}
|
||||
</Typography>
|
||||
);
|
||||
},
|
||||
[editItem.flags, dow]
|
||||
);
|
||||
|
||||
const handleClose = useCallback(
|
||||
(_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => {
|
||||
if (reason !== 'backdropClick') {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose]
|
||||
);
|
||||
|
||||
const handleScheduleTypeChange = useCallback(
|
||||
(_event: React.SyntheticEvent<HTMLElement>, flag: ScheduleFlag | null) => {
|
||||
if (flag !== null) {
|
||||
setFieldErrors(undefined); // clear any validation errors
|
||||
setScheduleType(flag);
|
||||
// wipe the time field when changing the schedule type
|
||||
// set the flags based on type
|
||||
const newFlags = flag === ScheduleFlag.SCHEDULE_DAY ? 0 : flag;
|
||||
setEditItem((prev) => ({ ...prev, time: '', flags: newFlags }));
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleDOWChange = useCallback(
|
||||
(_event: React.SyntheticEvent<HTMLElement>, flags: string[]) => {
|
||||
const newFlags = getFlagDOWnumber(flags);
|
||||
setEditItem((prev) => ({ ...prev, flags: newFlags }));
|
||||
},
|
||||
[getFlagDOWnumber]
|
||||
);
|
||||
|
||||
// Memoize derived values
|
||||
const isDaySchedule = useMemo(
|
||||
() => scheduleType === ScheduleFlag.SCHEDULE_DAY,
|
||||
[scheduleType]
|
||||
);
|
||||
const isTimerSchedule = useMemo(
|
||||
() => scheduleType === ScheduleFlag.SCHEDULE_TIMER,
|
||||
[scheduleType]
|
||||
);
|
||||
const isImmediateSchedule = useMemo(
|
||||
() => scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE,
|
||||
[scheduleType]
|
||||
);
|
||||
const needsTimeField = useMemo(
|
||||
() => isDaySchedule || isTimerSchedule,
|
||||
[isDaySchedule, isTimerSchedule]
|
||||
);
|
||||
|
||||
const dowFlags = useMemo(
|
||||
() => getFlagDOWstring(editItem.flags),
|
||||
[editItem.flags, getFlagDOWstring]
|
||||
);
|
||||
|
||||
const timeFieldValue = useMemo(() => {
|
||||
if (needsTimeField) {
|
||||
return editItem.time === '' ? DEFAULT_TIME : editItem.time;
|
||||
const save = async () => {
|
||||
try {
|
||||
setFieldErrors(undefined);
|
||||
await validate(validator, editItem);
|
||||
onSave(editItem);
|
||||
} catch (error) {
|
||||
setFieldErrors(error as ValidateFieldsError);
|
||||
}
|
||||
return editItem.time === DEFAULT_TIME ? '' : editItem.time;
|
||||
}, [editItem.time, needsTimeField]);
|
||||
};
|
||||
|
||||
const timeFieldLabel = useMemo(() => {
|
||||
if (scheduleType === ScheduleFlag.SCHEDULE_TIMER) return LL.TIMER(1);
|
||||
if (scheduleType === ScheduleFlag.SCHEDULE_CONDITION) return LL.CONDITION();
|
||||
if (scheduleType === ScheduleFlag.SCHEDULE_ONCHANGE) return LL.ONCHANGE();
|
||||
if (scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE) return LL.IMMEDIATE();
|
||||
return LL.TIME(1);
|
||||
}, [scheduleType, LL]);
|
||||
const saveandactivate = async () => {
|
||||
editItem.active = true;
|
||||
try {
|
||||
setFieldErrors(undefined);
|
||||
await validate(validator, editItem);
|
||||
onSave(editItem);
|
||||
} catch (error) {
|
||||
setFieldErrors(error as ValidateFieldsError);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = () => {
|
||||
editItem.deleted = true;
|
||||
onSave(editItem);
|
||||
};
|
||||
|
||||
const getFlagDOWnumber = (newFlag: string[]) => {
|
||||
let new_flag = 0;
|
||||
for (const entry of newFlag) {
|
||||
new_flag |= Number(entry);
|
||||
}
|
||||
return new_flag & 127;
|
||||
};
|
||||
|
||||
const getFlagDOWstring = (f: number) => {
|
||||
const new_flags: string[] = [];
|
||||
if ((f & 129) === 1) {
|
||||
new_flags.push('1');
|
||||
}
|
||||
if ((f & 130) === 2) {
|
||||
new_flags.push('2');
|
||||
}
|
||||
if ((f & 4) === 4) {
|
||||
new_flags.push('4');
|
||||
}
|
||||
if ((f & 8) === 8) {
|
||||
new_flags.push('8');
|
||||
}
|
||||
if ((f & 16) === 16) {
|
||||
new_flags.push('16');
|
||||
}
|
||||
if ((f & 32) === 32) {
|
||||
new_flags.push('32');
|
||||
}
|
||||
if ((f & 64) === 64) {
|
||||
new_flags.push('64');
|
||||
}
|
||||
|
||||
return new_flags;
|
||||
};
|
||||
|
||||
const showDOW = (si: ScheduleItem, flag: number) => (
|
||||
<Typography
|
||||
sx={{ fontSize: 10 }}
|
||||
color={(si.flags & flag) === flag ? 'primary' : 'grey'}
|
||||
>
|
||||
{dow[Math.log(flag) / Math.log(2)]}
|
||||
</Typography>
|
||||
);
|
||||
|
||||
const handleClose = (_event, reason: 'backdropClick' | 'escapeKeyDown') => {
|
||||
if (reason !== 'backdropClick') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
|
||||
<DialogTitle>
|
||||
{creating ? `${LL.ADD(1)} ${LL.NEW(0)}` : LL.EDIT()}
|
||||
{creating ? LL.ADD(1) + ' ' + LL.NEW(0) : LL.EDIT()}
|
||||
{LL.SCHEDULE(1)}
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
@@ -247,27 +163,47 @@ const SchedulerDialog = ({
|
||||
value={scheduleType}
|
||||
exclusive
|
||||
disabled={!creating}
|
||||
onChange={handleScheduleTypeChange}
|
||||
onChange={(_event, flag: ScheduleFlag) => {
|
||||
if (flag !== null) {
|
||||
setFieldErrors(undefined); // clear any validation errors
|
||||
setScheduleType(flag);
|
||||
// wipe the time field when changing the schedule type
|
||||
setEditItem({ ...editItem, time: '' });
|
||||
// set the flags based on type
|
||||
// 0-127 is day schedule
|
||||
// 128 is timer
|
||||
// 129 is on change
|
||||
// 130 is on condition
|
||||
// 132 is immediate
|
||||
setEditItem(
|
||||
flag === ScheduleFlag.SCHEDULE_DAY
|
||||
? { ...editItem, flags: 0 }
|
||||
: { ...editItem, flags: flag }
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ToggleButton value={ScheduleFlag.SCHEDULE_DAY}>
|
||||
<Typography
|
||||
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
|
||||
color={isDaySchedule ? 'primary' : 'grey'}
|
||||
sx={{ fontSize: 10 }}
|
||||
color={scheduleType === ScheduleFlag.SCHEDULE_DAY ? 'primary' : 'grey'}
|
||||
>
|
||||
{LL.SCHEDULE(0)}
|
||||
</Typography>
|
||||
</ToggleButton>
|
||||
<ToggleButton value={ScheduleFlag.SCHEDULE_TIMER}>
|
||||
<Typography
|
||||
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
|
||||
color={isTimerSchedule ? 'primary' : 'grey'}
|
||||
sx={{ fontSize: 10 }}
|
||||
color={
|
||||
scheduleType === ScheduleFlag.SCHEDULE_TIMER ? 'primary' : 'grey'
|
||||
}
|
||||
>
|
||||
{LL.TIMER(0)}
|
||||
</Typography>
|
||||
</ToggleButton>
|
||||
<ToggleButton value={ScheduleFlag.SCHEDULE_ONCHANGE}>
|
||||
<Typography
|
||||
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
|
||||
sx={{ fontSize: 10 }}
|
||||
color={
|
||||
scheduleType === ScheduleFlag.SCHEDULE_ONCHANGE ? 'primary' : 'grey'
|
||||
}
|
||||
@@ -277,7 +213,7 @@ const SchedulerDialog = ({
|
||||
</ToggleButton>
|
||||
<ToggleButton value={ScheduleFlag.SCHEDULE_CONDITION}>
|
||||
<Typography
|
||||
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
|
||||
sx={{ fontSize: 10 }}
|
||||
color={
|
||||
scheduleType === ScheduleFlag.SCHEDULE_CONDITION ? 'primary' : 'grey'
|
||||
}
|
||||
@@ -287,30 +223,50 @@ const SchedulerDialog = ({
|
||||
</ToggleButton>
|
||||
<ToggleButton value={ScheduleFlag.SCHEDULE_IMMEDIATE}>
|
||||
<Typography
|
||||
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
|
||||
color={isImmediateSchedule ? 'primary' : 'grey'}
|
||||
sx={{ fontSize: 10 }}
|
||||
color={
|
||||
scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE ? 'primary' : 'grey'
|
||||
}
|
||||
>
|
||||
{LL.IMMEDIATE()}
|
||||
</Typography>
|
||||
</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
|
||||
{isDaySchedule && (
|
||||
{scheduleType === ScheduleFlag.SCHEDULE_DAY && (
|
||||
<ToggleButtonGroup
|
||||
size="small"
|
||||
color="secondary"
|
||||
value={dowFlags}
|
||||
onChange={handleDOWChange}
|
||||
value={getFlagDOWstring(editItem.flags)}
|
||||
onChange={(_event, flag: string[]) => {
|
||||
setEditItem({ ...editItem, flags: getFlagDOWnumber(flag) });
|
||||
}}
|
||||
>
|
||||
{DAY_FLAGS.map(({ value, flag }) => (
|
||||
<ToggleButton key={value} value={value}>
|
||||
{DayOfWeekButton(flag)}
|
||||
</ToggleButton>
|
||||
))}
|
||||
<ToggleButton value="2">
|
||||
{showDOW(editItem, ScheduleFlag.SCHEDULE_MON)}
|
||||
</ToggleButton>
|
||||
<ToggleButton value="4">
|
||||
{showDOW(editItem, ScheduleFlag.SCHEDULE_TUE)}
|
||||
</ToggleButton>
|
||||
<ToggleButton value="8">
|
||||
{showDOW(editItem, ScheduleFlag.SCHEDULE_WED)}
|
||||
</ToggleButton>
|
||||
<ToggleButton value="16">
|
||||
{showDOW(editItem, ScheduleFlag.SCHEDULE_THU)}
|
||||
</ToggleButton>
|
||||
<ToggleButton value="32">
|
||||
{showDOW(editItem, ScheduleFlag.SCHEDULE_FRI)}
|
||||
</ToggleButton>
|
||||
<ToggleButton value="64">
|
||||
{showDOW(editItem, ScheduleFlag.SCHEDULE_SAT)}
|
||||
</ToggleButton>
|
||||
<ToggleButton value="1">
|
||||
{showDOW(editItem, ScheduleFlag.SCHEDULE_SUN)}
|
||||
</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
)}
|
||||
|
||||
{!isImmediateSchedule && (
|
||||
{scheduleType !== ScheduleFlag.SCHEDULE_IMMEDIATE && (
|
||||
<>
|
||||
<Grid container>
|
||||
<BlockFormControlLabel
|
||||
@@ -325,17 +281,22 @@ const SchedulerDialog = ({
|
||||
/>
|
||||
</Grid>
|
||||
<Grid container>
|
||||
{needsTimeField ? (
|
||||
{scheduleType === ScheduleFlag.SCHEDULE_DAY ||
|
||||
scheduleType === ScheduleFlag.SCHEDULE_TIMER ? (
|
||||
<>
|
||||
<TextField
|
||||
name="time"
|
||||
type="time"
|
||||
label={timeFieldLabel}
|
||||
value={timeFieldValue}
|
||||
label={
|
||||
scheduleType === ScheduleFlag.SCHEDULE_TIMER
|
||||
? LL.TIMER(1)
|
||||
: LL.TIME(1)
|
||||
}
|
||||
value={editItem.time === '' ? '00:00' : editItem.time}
|
||||
margin="normal"
|
||||
onChange={updateFormValue}
|
||||
/>
|
||||
{isTimerSchedule && (
|
||||
{scheduleType === ScheduleFlag.SCHEDULE_TIMER && (
|
||||
<Box color="warning.main" ml={2} mt={4}>
|
||||
<Typography variant="body2">
|
||||
{LL.SCHEDULER_HELP_2()}
|
||||
@@ -346,10 +307,16 @@ const SchedulerDialog = ({
|
||||
) : (
|
||||
<TextField
|
||||
name="time"
|
||||
label={timeFieldLabel}
|
||||
label={
|
||||
scheduleType === ScheduleFlag.SCHEDULE_CONDITION
|
||||
? LL.CONDITION()
|
||||
: scheduleType === ScheduleFlag.SCHEDULE_ONCHANGE
|
||||
? LL.ONCHANGE()
|
||||
: LL.IMMEDIATE()
|
||||
}
|
||||
multiline
|
||||
fullWidth
|
||||
value={timeFieldValue}
|
||||
value={editItem.time === '00:00' ? '' : editItem.time}
|
||||
margin="normal"
|
||||
onChange={updateFormValue}
|
||||
/>
|
||||
@@ -358,7 +325,7 @@ const SchedulerDialog = ({
|
||||
</>
|
||||
)}
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
fieldErrors={fieldErrors}
|
||||
name="cmd"
|
||||
label={LL.COMMAND(0)}
|
||||
multiline
|
||||
@@ -377,7 +344,7 @@ const SchedulerDialog = ({
|
||||
onChange={updateFormValue}
|
||||
/>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
fieldErrors={fieldErrors}
|
||||
name="name"
|
||||
label={LL.NAME(0) + ' (' + LL.OPTIONAL() + ')'}
|
||||
value={editItem.name}
|
||||
@@ -416,7 +383,7 @@ const SchedulerDialog = ({
|
||||
>
|
||||
{creating ? LL.ADD(0) : LL.UPDATE()}
|
||||
</Button>
|
||||
{isImmediateSchedule && editItem.cmd !== '' && (
|
||||
{scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE && editItem.cmd !== '' && (
|
||||
<Button
|
||||
startIcon={<PlayArrowIcon />}
|
||||
variant="outlined"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useContext, useMemo, useState } from 'react';
|
||||
import { useContext, useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import AddCircleOutlineOutlinedIcon from '@mui/icons-material/AddCircleOutlineOutlined';
|
||||
@@ -49,75 +49,6 @@ import {
|
||||
temperatureSensorItemValidation
|
||||
} from './validators';
|
||||
|
||||
// Constants
|
||||
const MS_PER_SECOND = 1000;
|
||||
const MS_PER_MINUTE = 60 * MS_PER_SECOND;
|
||||
const MS_PER_HOUR = 60 * MS_PER_MINUTE;
|
||||
const MS_PER_DAY = 24 * MS_PER_HOUR;
|
||||
const DEFAULT_GPIO = 21; // Safe GPIO for all platforms
|
||||
const MIN_TEMP_ID = -100;
|
||||
const MAX_TEMP_ID = 100;
|
||||
const GPIO_25 = 25;
|
||||
const GPIO_26 = 26;
|
||||
|
||||
const HEADER_BUTTON_STYLE: React.CSSProperties = {
|
||||
fontSize: '14px',
|
||||
justifyContent: 'flex-start'
|
||||
};
|
||||
|
||||
const HEADER_BUTTON_STYLE_END: React.CSSProperties = {
|
||||
fontSize: '14px',
|
||||
justifyContent: 'flex-end'
|
||||
};
|
||||
|
||||
const common_theme = {
|
||||
BaseRow: `
|
||||
font-size: 14px;
|
||||
.td {
|
||||
height: 32px;
|
||||
}
|
||||
`,
|
||||
HeaderRow: `
|
||||
text-transform: uppercase;
|
||||
background-color: black;
|
||||
color: #90CAF9;
|
||||
.th {
|
||||
border-bottom: 1px solid #565656;
|
||||
height: 36px;
|
||||
}
|
||||
`,
|
||||
Row: `
|
||||
background-color: #1e1e1e;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
.td {
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid #565656;
|
||||
}
|
||||
&:hover .td {
|
||||
background-color: #177ac9;
|
||||
color: white;
|
||||
}
|
||||
`,
|
||||
Cell: `
|
||||
&:last-of-type {
|
||||
text-align: right;
|
||||
},
|
||||
`
|
||||
};
|
||||
|
||||
const temperature_theme_config = {
|
||||
Table: `
|
||||
--data-table-library_grid-template-columns: minmax(0, 1fr) 35%;
|
||||
`
|
||||
};
|
||||
|
||||
const analog_theme_config = {
|
||||
Table: `
|
||||
--data-table-library_grid-template-columns: 80px repeat(1, minmax(0, 1fr)) 120px 110px;
|
||||
`
|
||||
};
|
||||
|
||||
const Sensors = () => {
|
||||
const { LL } = useI18nContext();
|
||||
const { me } = useContext(AuthenticatedContext);
|
||||
@@ -129,14 +60,17 @@ const Sensors = () => {
|
||||
const [analogDialogOpen, setAnalogDialogOpen] = useState<boolean>(false);
|
||||
const [creating, setCreating] = useState<boolean>(false);
|
||||
|
||||
const { data: sensorData, send: fetchSensorData } = useRequest(readSensorData, {
|
||||
initialData: {
|
||||
ts: [],
|
||||
as: [],
|
||||
analog_enabled: false,
|
||||
platform: 'ESP32'
|
||||
const { data: sensorData, send: fetchSensorData } = useRequest(
|
||||
() => readSensorData(),
|
||||
{
|
||||
initialData: {
|
||||
ts: [],
|
||||
as: [],
|
||||
analog_enabled: false,
|
||||
platform: 'ESP32'
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
const { send: sendTemperatureSensor } = useRequest(
|
||||
(data: WriteTemperatureSensor) => writeTemperatureSensor(data),
|
||||
@@ -152,18 +86,118 @@ const Sensors = () => {
|
||||
}
|
||||
);
|
||||
|
||||
const intervalCallback = useCallback(() => {
|
||||
useInterval(() => {
|
||||
if (!temperatureDialogOpen && !analogDialogOpen) {
|
||||
void fetchSensorData();
|
||||
}
|
||||
}, [temperatureDialogOpen, analogDialogOpen, fetchSensorData]);
|
||||
}, 3000);
|
||||
|
||||
useInterval(intervalCallback);
|
||||
const common_theme = useTheme({
|
||||
BaseRow: `
|
||||
font-size: 14px;
|
||||
.td {
|
||||
height: 32px;
|
||||
}
|
||||
`,
|
||||
HeaderRow: `
|
||||
text-transform: uppercase;
|
||||
background-color: black;
|
||||
color: #90CAF9;
|
||||
.th {
|
||||
border-bottom: 1px solid #565656;
|
||||
}
|
||||
.th {
|
||||
height: 36px;
|
||||
}
|
||||
`,
|
||||
Row: `
|
||||
background-color: #1e1e1e;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
.td {
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid #565656;
|
||||
}
|
||||
&:hover .td {
|
||||
background-color: #177ac9;
|
||||
}
|
||||
`,
|
||||
Cell: `
|
||||
&:last-of-type {
|
||||
text-align: right;
|
||||
},
|
||||
`
|
||||
});
|
||||
|
||||
const temperature_theme = useTheme([common_theme, temperature_theme_config]);
|
||||
const analog_theme = useTheme([common_theme, analog_theme_config]);
|
||||
const temperature_theme = useTheme([
|
||||
common_theme,
|
||||
{
|
||||
Table: `
|
||||
--data-table-library_grid-template-columns: minmax(0, 1fr) 35%;
|
||||
`
|
||||
}
|
||||
]);
|
||||
|
||||
const getSortIcon = useCallback((state: State, sortKey: unknown) => {
|
||||
const analog_theme = useTheme([
|
||||
common_theme,
|
||||
{
|
||||
Table: `
|
||||
--data-table-library_grid-template-columns: 80px repeat(1, minmax(0, 1fr)) 120px 110px;
|
||||
`
|
||||
}
|
||||
]);
|
||||
|
||||
const RenderTemperatureSensors = () => (
|
||||
<Table
|
||||
data={{ nodes: sensorData.ts }}
|
||||
theme={temperature_theme}
|
||||
sort={temperature_sort}
|
||||
layout={{ custom: true }}
|
||||
>
|
||||
{(tableList: TemperatureSensor[]) => (
|
||||
<>
|
||||
<Header>
|
||||
<HeaderRow>
|
||||
<HeaderCell resize>
|
||||
<Button
|
||||
fullWidth
|
||||
style={{ fontSize: '14px', justifyContent: 'flex-start' }}
|
||||
endIcon={getSortIcon(temperature_sort.state, 'NAME')}
|
||||
onClick={() =>
|
||||
temperature_sort.fns.onToggleSort({ sortKey: 'NAME' })
|
||||
}
|
||||
>
|
||||
{LL.NAME(0)}
|
||||
</Button>
|
||||
</HeaderCell>
|
||||
<HeaderCell stiff>
|
||||
<Button
|
||||
fullWidth
|
||||
style={{ fontSize: '14px', justifyContent: 'flex-end' }}
|
||||
endIcon={getSortIcon(temperature_sort.state, 'VALUE')}
|
||||
onClick={() =>
|
||||
temperature_sort.fns.onToggleSort({ sortKey: 'VALUE' })
|
||||
}
|
||||
>
|
||||
{LL.VALUE(0)}
|
||||
</Button>
|
||||
</HeaderCell>
|
||||
</HeaderRow>
|
||||
</Header>
|
||||
<Body>
|
||||
{tableList.map((ts: TemperatureSensor) => (
|
||||
<Row key={ts.id} item={ts} onClick={() => updateTemperatureSensor(ts)}>
|
||||
<Cell>{ts.n}</Cell>
|
||||
<Cell>{formatValue(ts.t, ts.u)}</Cell>
|
||||
</Row>
|
||||
))}
|
||||
</Body>
|
||||
</>
|
||||
)}
|
||||
</Table>
|
||||
);
|
||||
|
||||
const getSortIcon = (state: State, sortKey: unknown) => {
|
||||
if (state.sortKey === sortKey && state.reverse) {
|
||||
return <KeyboardArrowDownOutlinedIcon />;
|
||||
}
|
||||
@@ -171,7 +205,7 @@ const Sensors = () => {
|
||||
return <KeyboardArrowUpOutlinedIcon />;
|
||||
}
|
||||
return <UnfoldMoreOutlinedIcon />;
|
||||
}, []);
|
||||
};
|
||||
|
||||
const analog_sort = useSort(
|
||||
{ nodes: sensorData.as },
|
||||
@@ -184,16 +218,11 @@ const Sensors = () => {
|
||||
},
|
||||
sortToggleType: SortToggleType.AlternateWithReset,
|
||||
sortFns: {
|
||||
GPIO: (array) =>
|
||||
[...array].sort((a, b) => (a as AnalogSensor).g - (b as AnalogSensor).g),
|
||||
NAME: (array) =>
|
||||
[...array].sort((a, b) =>
|
||||
(a as AnalogSensor).n.localeCompare((b as AnalogSensor).n)
|
||||
),
|
||||
TYPE: (array) =>
|
||||
[...array].sort((a, b) => (a as AnalogSensor).t - (b as AnalogSensor).t),
|
||||
VALUE: (array) =>
|
||||
[...array].sort((a, b) => (a as AnalogSensor).v - (b as AnalogSensor).v)
|
||||
GPIO: (array) => array.sort((a, b) => a.g - b.g),
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
NAME: (array) => array.sort((a, b) => a.n.localeCompare(b.n)),
|
||||
TYPE: (array) => array.sort((a, b) => a.t - b.t),
|
||||
VALUE: (array) => array.sort((a, b) => a.v - b.v)
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -209,347 +238,225 @@ const Sensors = () => {
|
||||
},
|
||||
sortToggleType: SortToggleType.AlternateWithReset,
|
||||
sortFns: {
|
||||
NAME: (array) =>
|
||||
[...array].sort((a, b) =>
|
||||
(a as TemperatureSensor).n.localeCompare((b as TemperatureSensor).n)
|
||||
),
|
||||
VALUE: (array) =>
|
||||
[...array].sort(
|
||||
(a, b) =>
|
||||
((a as TemperatureSensor).t ?? 0) - ((b as TemperatureSensor).t ?? 0)
|
||||
)
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
NAME: (array) => array.sort((a, b) => a.n.localeCompare(b.n)),
|
||||
VALUE: (array) => array.sort((a, b) => a.t - b.t)
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
useLayoutTitle(LL.SENSORS());
|
||||
|
||||
const formatDurationMin = useCallback(
|
||||
(duration_min: number) => {
|
||||
const totalMs = duration_min * MS_PER_MINUTE;
|
||||
const days = Math.trunc(totalMs / MS_PER_DAY);
|
||||
const hours = Math.trunc(totalMs / MS_PER_HOUR) % 24;
|
||||
const minutes = Math.trunc(totalMs / MS_PER_MINUTE) % 60;
|
||||
const formatDurationMin = (duration_min: number) => {
|
||||
const days = Math.trunc((duration_min * 60000) / 86400000);
|
||||
const hours = Math.trunc((duration_min * 60000) / 3600000) % 24;
|
||||
const minutes = Math.trunc((duration_min * 60000) / 60000) % 60;
|
||||
|
||||
const parts: string[] = [];
|
||||
if (days > 0) {
|
||||
parts.push(LL.NUM_DAYS({ num: days }));
|
||||
}
|
||||
if (hours > 0) {
|
||||
parts.push(LL.NUM_HOURS({ num: hours }));
|
||||
}
|
||||
if (minutes > 0) {
|
||||
parts.push(LL.NUM_MINUTES({ num: minutes }));
|
||||
}
|
||||
return parts.join(' ');
|
||||
},
|
||||
[LL]
|
||||
);
|
||||
let formatted = '';
|
||||
if (days) {
|
||||
formatted += LL.NUM_DAYS({ num: days }) + ' ';
|
||||
}
|
||||
if (hours) {
|
||||
formatted += LL.NUM_HOURS({ num: hours }) + ' ';
|
||||
}
|
||||
if (minutes) {
|
||||
formatted += LL.NUM_MINUTES({ num: minutes });
|
||||
}
|
||||
return formatted;
|
||||
};
|
||||
|
||||
const formatValue = useCallback(
|
||||
(value: unknown, uom: DeviceValueUOM) => {
|
||||
if (value === undefined) {
|
||||
return '';
|
||||
}
|
||||
if (typeof value !== 'number') {
|
||||
return value as string;
|
||||
}
|
||||
switch (uom) {
|
||||
case DeviceValueUOM.HOURS:
|
||||
return value ? formatDurationMin(value * 60) : LL.NUM_HOURS({ num: 0 });
|
||||
case DeviceValueUOM.MINUTES:
|
||||
return value ? formatDurationMin(value) : LL.NUM_MINUTES({ num: 0 });
|
||||
case DeviceValueUOM.SECONDS:
|
||||
return LL.NUM_SECONDS({ num: value });
|
||||
case DeviceValueUOM.NONE:
|
||||
return new Intl.NumberFormat().format(value);
|
||||
case DeviceValueUOM.DEGREES:
|
||||
case DeviceValueUOM.DEGREES_R:
|
||||
case DeviceValueUOM.FAHRENHEIT:
|
||||
return (
|
||||
new Intl.NumberFormat(undefined, {
|
||||
minimumFractionDigits: 1
|
||||
}).format(value) +
|
||||
' ' +
|
||||
DeviceValueUOM_s[uom]
|
||||
);
|
||||
default:
|
||||
return new Intl.NumberFormat().format(value) + ' ' + DeviceValueUOM_s[uom];
|
||||
}
|
||||
},
|
||||
[formatDurationMin, LL]
|
||||
);
|
||||
function formatValue(value: unknown, uom: DeviceValueUOM) {
|
||||
if (value === undefined) {
|
||||
return '';
|
||||
}
|
||||
if (typeof value !== 'number') {
|
||||
return value as string;
|
||||
}
|
||||
switch (uom) {
|
||||
case DeviceValueUOM.HOURS:
|
||||
return value ? formatDurationMin(value * 60) : LL.NUM_HOURS({ num: 0 });
|
||||
case DeviceValueUOM.MINUTES:
|
||||
return value ? formatDurationMin(value) : LL.NUM_MINUTES({ num: 0 });
|
||||
case DeviceValueUOM.SECONDS:
|
||||
return LL.NUM_SECONDS({ num: value });
|
||||
case DeviceValueUOM.NONE:
|
||||
return new Intl.NumberFormat().format(value);
|
||||
case DeviceValueUOM.DEGREES:
|
||||
case DeviceValueUOM.DEGREES_R:
|
||||
case DeviceValueUOM.FAHRENHEIT:
|
||||
return (
|
||||
new Intl.NumberFormat(undefined, {
|
||||
minimumFractionDigits: 1
|
||||
}).format(value) +
|
||||
' ' +
|
||||
DeviceValueUOM_s[uom]
|
||||
);
|
||||
default:
|
||||
return new Intl.NumberFormat().format(value) + ' ' + DeviceValueUOM_s[uom];
|
||||
}
|
||||
}
|
||||
|
||||
const updateTemperatureSensor = useCallback(
|
||||
(ts: TemperatureSensor) => {
|
||||
if (me.admin) {
|
||||
ts.o_n = ts.n;
|
||||
setSelectedTemperatureSensor(ts);
|
||||
setTemperatureDialogOpen(true);
|
||||
}
|
||||
},
|
||||
[me.admin]
|
||||
);
|
||||
const updateTemperatureSensor = (ts: TemperatureSensor) => {
|
||||
if (me.admin) {
|
||||
ts.o_n = ts.n;
|
||||
setSelectedTemperatureSensor(ts);
|
||||
setTemperatureDialogOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const onTemperatureDialogClose = useCallback(() => {
|
||||
const onTemperatureDialogClose = () => {
|
||||
setTemperatureDialogOpen(false);
|
||||
void fetchSensorData();
|
||||
}, [fetchSensorData]);
|
||||
};
|
||||
|
||||
const onTemperatureDialogSave = useCallback(
|
||||
async (ts: TemperatureSensor) => {
|
||||
await sendTemperatureSensor({
|
||||
id: ts.id,
|
||||
name: ts.n,
|
||||
offset: ts.o,
|
||||
is_system: ts.s
|
||||
const onTemperatureDialogSave = async (ts: TemperatureSensor) => {
|
||||
await sendTemperatureSensor({ id: ts.id, name: ts.n, offset: ts.o })
|
||||
.then(() => {
|
||||
toast.success(LL.UPDATED_OF(LL.SENSOR(1)));
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(LL.UPDATED_OF(LL.SENSOR(1)));
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(LL.UPDATE_OF(LL.SENSOR(2)) + ' ' + LL.FAILED(1));
|
||||
})
|
||||
.finally(() => {
|
||||
setTemperatureDialogOpen(false);
|
||||
setSelectedTemperatureSensor(undefined);
|
||||
void fetchSensorData();
|
||||
});
|
||||
},
|
||||
[sendTemperatureSensor, LL, fetchSensorData]
|
||||
);
|
||||
.catch(() => {
|
||||
toast.error(LL.UPDATE_OF(LL.SENSOR(2)) + ' ' + LL.FAILED(1));
|
||||
})
|
||||
.finally(() => {
|
||||
setTemperatureDialogOpen(false);
|
||||
setSelectedTemperatureSensor(undefined);
|
||||
void fetchSensorData();
|
||||
});
|
||||
};
|
||||
|
||||
const updateAnalogSensor = useCallback(
|
||||
(as: AnalogSensor) => {
|
||||
if (me.admin) {
|
||||
setCreating(false);
|
||||
as.o_n = as.n;
|
||||
setSelectedAnalogSensor(as);
|
||||
setAnalogDialogOpen(true);
|
||||
}
|
||||
},
|
||||
[me.admin]
|
||||
);
|
||||
const updateAnalogSensor = (as: AnalogSensor) => {
|
||||
if (me.admin) {
|
||||
setCreating(false);
|
||||
as.o_n = as.n;
|
||||
setSelectedAnalogSensor(as);
|
||||
setAnalogDialogOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const onAnalogDialogClose = useCallback(() => {
|
||||
const onAnalogDialogClose = () => {
|
||||
setAnalogDialogOpen(false);
|
||||
void fetchSensorData();
|
||||
}, [fetchSensorData]);
|
||||
};
|
||||
|
||||
const addAnalogSensor = useCallback(() => {
|
||||
const addAnalogSensor = () => {
|
||||
setCreating(true);
|
||||
setSelectedAnalogSensor({
|
||||
id: Math.floor(Math.random() * (MAX_TEMP_ID - MIN_TEMP_ID) + MIN_TEMP_ID),
|
||||
id: Math.floor(Math.random() * (Math.floor(200) - 100) + 100),
|
||||
n: '',
|
||||
g: DEFAULT_GPIO,
|
||||
g: 21, // default GPIO 21 which is safe for all platforms
|
||||
u: 0,
|
||||
v: 0,
|
||||
o: 0,
|
||||
t: 0,
|
||||
f: 1,
|
||||
d: false,
|
||||
o_n: '',
|
||||
s: false
|
||||
o_n: ''
|
||||
});
|
||||
setAnalogDialogOpen(true);
|
||||
}, []);
|
||||
};
|
||||
|
||||
const onAnalogDialogSave = useCallback(
|
||||
async (as: AnalogSensor) => {
|
||||
await sendAnalogSensor({
|
||||
id: as.id,
|
||||
gpio: as.g,
|
||||
name: as.n,
|
||||
offset: as.o,
|
||||
factor: as.f,
|
||||
uom: as.u,
|
||||
type: as.t,
|
||||
deleted: as.d,
|
||||
is_system: as.s
|
||||
const onAnalogDialogSave = async (as: AnalogSensor) => {
|
||||
await sendAnalogSensor({
|
||||
id: as.id,
|
||||
gpio: as.g,
|
||||
name: as.n,
|
||||
offset: as.o,
|
||||
factor: as.f,
|
||||
uom: as.u,
|
||||
type: as.t,
|
||||
deleted: as.d
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(LL.UPDATED_OF(LL.ANALOG_SENSOR(2)));
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(LL.UPDATED_OF(LL.ANALOG_SENSOR(2)));
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(LL.UPDATE_OF(LL.ANALOG_SENSOR(5)) + ' ' + LL.FAILED(1));
|
||||
})
|
||||
.finally(() => {
|
||||
setAnalogDialogOpen(false);
|
||||
setSelectedAnalogSensor(undefined);
|
||||
void fetchSensorData();
|
||||
});
|
||||
},
|
||||
[sendAnalogSensor, LL, fetchSensorData]
|
||||
);
|
||||
.catch(() => {
|
||||
toast.error(LL.UPDATE_OF(LL.ANALOG_SENSOR(5)) + ' ' + LL.FAILED(1));
|
||||
})
|
||||
.finally(() => {
|
||||
setAnalogDialogOpen(false);
|
||||
setSelectedAnalogSensor(undefined);
|
||||
void fetchSensorData();
|
||||
});
|
||||
};
|
||||
|
||||
const RenderAnalogSensors = useMemo(
|
||||
() => (
|
||||
<Table
|
||||
data={{ nodes: sensorData.as }}
|
||||
theme={analog_theme}
|
||||
sort={analog_sort}
|
||||
layout={{ custom: true }}
|
||||
>
|
||||
{(tableList: AnalogSensor[]) => (
|
||||
<>
|
||||
<Header>
|
||||
<HeaderRow>
|
||||
<HeaderCell stiff>
|
||||
<Button
|
||||
fullWidth
|
||||
style={HEADER_BUTTON_STYLE}
|
||||
endIcon={getSortIcon(analog_sort.state, 'GPIO')}
|
||||
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'GPIO' })}
|
||||
>
|
||||
GPIO
|
||||
</Button>
|
||||
</HeaderCell>
|
||||
<HeaderCell resize>
|
||||
<Button
|
||||
fullWidth
|
||||
style={HEADER_BUTTON_STYLE}
|
||||
endIcon={getSortIcon(analog_sort.state, 'NAME')}
|
||||
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'NAME' })}
|
||||
>
|
||||
{LL.NAME(0)}
|
||||
</Button>
|
||||
</HeaderCell>
|
||||
<HeaderCell stiff>
|
||||
<Button
|
||||
fullWidth
|
||||
style={HEADER_BUTTON_STYLE}
|
||||
endIcon={getSortIcon(analog_sort.state, 'TYPE')}
|
||||
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'TYPE' })}
|
||||
>
|
||||
{LL.TYPE(0)}
|
||||
</Button>
|
||||
</HeaderCell>
|
||||
<HeaderCell stiff>
|
||||
<Button
|
||||
fullWidth
|
||||
style={HEADER_BUTTON_STYLE_END}
|
||||
endIcon={getSortIcon(analog_sort.state, 'VALUE')}
|
||||
onClick={() =>
|
||||
analog_sort.fns.onToggleSort({ sortKey: 'VALUE' })
|
||||
}
|
||||
>
|
||||
{LL.VALUE(0)}
|
||||
</Button>
|
||||
</HeaderCell>
|
||||
</HeaderRow>
|
||||
</Header>
|
||||
<Body>
|
||||
{tableList.map((as: AnalogSensor) => (
|
||||
<Row
|
||||
style={{ color: as.s ? 'grey' : 'inherit' }}
|
||||
key={as.id}
|
||||
item={as}
|
||||
onClick={() => updateAnalogSensor(as)}
|
||||
const RenderAnalogSensors = () => (
|
||||
<Table
|
||||
data={{ nodes: sensorData.as }}
|
||||
theme={analog_theme}
|
||||
sort={analog_sort}
|
||||
layout={{ custom: true }}
|
||||
>
|
||||
{(tableList: AnalogSensor[]) => (
|
||||
<>
|
||||
<Header>
|
||||
<HeaderRow>
|
||||
<HeaderCell stiff>
|
||||
<Button
|
||||
fullWidth
|
||||
style={{ fontSize: '14px', justifyContent: 'flex-start' }}
|
||||
endIcon={getSortIcon(analog_sort.state, 'GPIO')}
|
||||
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'GPIO' })}
|
||||
>
|
||||
<Cell stiff>{as.g}</Cell>
|
||||
<Cell>{as.n}</Cell>
|
||||
<Cell stiff>{AnalogTypeNames[as.t]} </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>
|
||||
{as.t !== AnalogType.NOTUSED ? formatValue(as.v, as.u) : ''}
|
||||
</Cell>
|
||||
)}
|
||||
</Row>
|
||||
))}
|
||||
</Body>
|
||||
</>
|
||||
)}
|
||||
</Table>
|
||||
),
|
||||
[
|
||||
analog_sort,
|
||||
analog_theme,
|
||||
getSortIcon,
|
||||
sensorData.as,
|
||||
LL,
|
||||
updateAnalogSensor,
|
||||
formatValue
|
||||
]
|
||||
);
|
||||
|
||||
const RenderTemperatureSensors = useMemo(
|
||||
() => (
|
||||
<Table
|
||||
data={{ nodes: sensorData.ts }}
|
||||
theme={temperature_theme}
|
||||
sort={temperature_sort}
|
||||
layout={{ custom: true }}
|
||||
>
|
||||
{(tableList: TemperatureSensor[]) => (
|
||||
<>
|
||||
<Header>
|
||||
<HeaderRow>
|
||||
<HeaderCell resize>
|
||||
<Button
|
||||
fullWidth
|
||||
style={HEADER_BUTTON_STYLE}
|
||||
endIcon={getSortIcon(temperature_sort.state, 'NAME')}
|
||||
onClick={() =>
|
||||
temperature_sort.fns.onToggleSort({ sortKey: 'NAME' })
|
||||
}
|
||||
>
|
||||
{LL.NAME(0)}
|
||||
</Button>
|
||||
</HeaderCell>
|
||||
<HeaderCell stiff>
|
||||
<Button
|
||||
fullWidth
|
||||
style={HEADER_BUTTON_STYLE_END}
|
||||
endIcon={getSortIcon(temperature_sort.state, 'VALUE')}
|
||||
onClick={() =>
|
||||
temperature_sort.fns.onToggleSort({ sortKey: 'VALUE' })
|
||||
}
|
||||
>
|
||||
{LL.VALUE(0)}
|
||||
</Button>
|
||||
</HeaderCell>
|
||||
</HeaderRow>
|
||||
</Header>
|
||||
<Body>
|
||||
{tableList.map((ts: TemperatureSensor) => (
|
||||
<Row
|
||||
style={{ color: ts.s ? 'grey' : 'inherit' }}
|
||||
key={ts.id}
|
||||
item={ts}
|
||||
onClick={() => updateTemperatureSensor(ts)}
|
||||
GPIO
|
||||
</Button>
|
||||
</HeaderCell>
|
||||
<HeaderCell resize>
|
||||
<Button
|
||||
fullWidth
|
||||
style={{ fontSize: '14px', justifyContent: 'flex-start' }}
|
||||
endIcon={getSortIcon(analog_sort.state, 'NAME')}
|
||||
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'NAME' })}
|
||||
>
|
||||
<Cell>{ts.n}</Cell>
|
||||
<Cell>{formatValue(ts.t, ts.u)}</Cell>
|
||||
</Row>
|
||||
))}
|
||||
</Body>
|
||||
</>
|
||||
)}
|
||||
</Table>
|
||||
),
|
||||
[
|
||||
temperature_sort,
|
||||
temperature_theme,
|
||||
getSortIcon,
|
||||
sensorData.ts,
|
||||
LL,
|
||||
updateTemperatureSensor,
|
||||
formatValue
|
||||
]
|
||||
{LL.NAME(0)}
|
||||
</Button>
|
||||
</HeaderCell>
|
||||
<HeaderCell stiff>
|
||||
<Button
|
||||
fullWidth
|
||||
style={{ fontSize: '14px', justifyContent: 'flex-start' }}
|
||||
endIcon={getSortIcon(analog_sort.state, 'TYPE')}
|
||||
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'TYPE' })}
|
||||
>
|
||||
{LL.TYPE(0)}
|
||||
</Button>
|
||||
</HeaderCell>
|
||||
<HeaderCell stiff>
|
||||
<Button
|
||||
fullWidth
|
||||
style={{ fontSize: '14px', justifyContent: 'flex-end' }}
|
||||
endIcon={getSortIcon(analog_sort.state, 'VALUE')}
|
||||
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'VALUE' })}
|
||||
>
|
||||
{LL.VALUE(0)}
|
||||
</Button>
|
||||
</HeaderCell>
|
||||
</HeaderRow>
|
||||
</Header>
|
||||
<Body>
|
||||
{tableList.map((a: AnalogSensor) => (
|
||||
<Row key={a.id} item={a} onClick={() => updateAnalogSensor(a)}>
|
||||
<Cell stiff>{a.g}</Cell>
|
||||
<Cell>{a.n}</Cell>
|
||||
<Cell stiff>{AnalogTypeNames[a.t]} </Cell>
|
||||
{a.t === AnalogType.DIGITAL_OUT || a.t === AnalogType.DIGITAL_IN ? (
|
||||
<Cell stiff>{a.v ? LL.ON() : LL.OFF()}</Cell>
|
||||
) : (
|
||||
<Cell stiff>{a.t ? formatValue(a.v, a.u) : ''}</Cell>
|
||||
)}
|
||||
</Row>
|
||||
))}
|
||||
</Body>
|
||||
</>
|
||||
)}
|
||||
</Table>
|
||||
);
|
||||
|
||||
return (
|
||||
<SectionContent>
|
||||
<Typography sx={{ pb: 1 }} variant="h6" color="primary">
|
||||
<Typography sx={{ pb: 1 }} variant="h6" color="secondary">
|
||||
{LL.TEMP_SENSORS()}
|
||||
</Typography>
|
||||
{RenderTemperatureSensors}
|
||||
<RenderTemperatureSensors />
|
||||
{selectedTemperatureSensor && (
|
||||
<DashboardSensorsTemperatureDialog
|
||||
open={temperatureDialogOpen}
|
||||
@@ -562,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()}
|
||||
</Typography>
|
||||
{RenderAnalogSensors}
|
||||
<RenderAnalogSensors />
|
||||
{selectedAnalogSensor && (
|
||||
<DashboardSensorsAnalogDialog
|
||||
open={analogDialogOpen}
|
||||
@@ -582,7 +489,7 @@ const Sensors = () => {
|
||||
/>
|
||||
)}
|
||||
{sensorData?.analog_enabled === true && me.admin && (
|
||||
<Box mt={2} display="flex" flexWrap="wrap" justifyContent="flex-end">
|
||||
<Box mt={1} display="flex" flexWrap="wrap" justifyContent="flex-end">
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import DoneIcon from '@mui/icons-material/Done';
|
||||
import RemoveIcon from '@mui/icons-material/RemoveCircleOutline';
|
||||
import WarningIcon from '@mui/icons-material/Warning';
|
||||
import {
|
||||
@@ -11,12 +10,12 @@ import {
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Grid,
|
||||
InputAdornment,
|
||||
MenuItem,
|
||||
TextField,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
import Grid from '@mui/material/Grid2';
|
||||
|
||||
import { dialogStyle } from 'CustomTheme';
|
||||
import type Schema from 'async-validator';
|
||||
@@ -49,72 +48,8 @@ const SensorsAnalogDialog = ({
|
||||
const { LL } = useI18nContext();
|
||||
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
||||
const [editItem, setEditItem] = useState<AnalogSensor>(selectedItem);
|
||||
const updateFormValue = updateValue(setEditItem);
|
||||
|
||||
const updateFormValue = useMemo(
|
||||
() =>
|
||||
updateValue((updater) =>
|
||||
setEditItem(
|
||||
(prev) =>
|
||||
updater(
|
||||
prev as unknown as Record<string, unknown>
|
||||
) as unknown as AnalogSensor
|
||||
)
|
||||
),
|
||||
[setEditItem]
|
||||
);
|
||||
|
||||
// Memoize helper functions to check sensor type conditions
|
||||
const isCounterOrRate = useMemo(
|
||||
() => editItem.t >= AnalogType.COUNTER && editItem.t <= AnalogType.RATE,
|
||||
[editItem.t]
|
||||
);
|
||||
const isFreqType = useMemo(
|
||||
() => editItem.t >= AnalogType.FREQ_0 && editItem.t <= AnalogType.FREQ_2,
|
||||
[editItem.t]
|
||||
);
|
||||
const isPWM = useMemo(
|
||||
() =>
|
||||
editItem.t === AnalogType.PWM_0 ||
|
||||
editItem.t === AnalogType.PWM_1 ||
|
||||
editItem.t === AnalogType.PWM_2,
|
||||
[editItem.t]
|
||||
);
|
||||
const isDigitalOutGPIO = useMemo(
|
||||
() =>
|
||||
editItem.t === AnalogType.DIGITAL_OUT &&
|
||||
(editItem.g === 25 || editItem.g === 26),
|
||||
[editItem.t, editItem.g]
|
||||
);
|
||||
const isDigitalOutNonGPIO = useMemo(
|
||||
() =>
|
||||
editItem.t === AnalogType.DIGITAL_OUT &&
|
||||
editItem.g !== 25 &&
|
||||
editItem.g !== 26,
|
||||
[editItem.t, editItem.g]
|
||||
);
|
||||
|
||||
// Memoize menu items to avoid recreation on each render
|
||||
const analogTypeMenuItems = useMemo(
|
||||
() =>
|
||||
AnalogTypeNames.map((val, i) => (
|
||||
<MenuItem key={val} value={i}>
|
||||
{val}
|
||||
</MenuItem>
|
||||
)),
|
||||
[]
|
||||
);
|
||||
|
||||
const uomMenuItems = useMemo(
|
||||
() =>
|
||||
DeviceValueUOM_s.map((val, i) => (
|
||||
<MenuItem key={val} value={i}>
|
||||
{val}
|
||||
</MenuItem>
|
||||
)),
|
||||
[]
|
||||
);
|
||||
|
||||
// Reset form when dialog opens or selectedItem changes
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setFieldErrors(undefined);
|
||||
@@ -122,16 +57,13 @@ const SensorsAnalogDialog = ({
|
||||
}
|
||||
}, [open, selectedItem]);
|
||||
|
||||
const handleClose = useCallback(
|
||||
(_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => {
|
||||
if (reason !== 'backdropClick') {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose]
|
||||
);
|
||||
const handleClose = (_event, reason: 'backdropClick' | 'escapeKeyDown') => {
|
||||
if (reason !== 'backdropClick') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const save = useCallback(async () => {
|
||||
const save = async () => {
|
||||
try {
|
||||
setFieldErrors(undefined);
|
||||
await validate(validator, editItem);
|
||||
@@ -139,26 +71,24 @@ const SensorsAnalogDialog = ({
|
||||
} catch (error) {
|
||||
setFieldErrors(error as ValidateFieldsError);
|
||||
}
|
||||
}, [validator, editItem, onSave]);
|
||||
};
|
||||
|
||||
const remove = useCallback(() => {
|
||||
onSave({ ...editItem, d: true });
|
||||
}, [editItem, onSave]);
|
||||
|
||||
const dialogTitle = useMemo(
|
||||
() =>
|
||||
`${creating ? LL.ADD(1) + ' ' + LL.NEW(0) : LL.EDIT()} ${LL.ANALOG_SENSOR(0)}`,
|
||||
[creating, LL]
|
||||
);
|
||||
const remove = () => {
|
||||
editItem.d = true;
|
||||
onSave(editItem);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
|
||||
<DialogTitle>{dialogTitle}</DialogTitle>
|
||||
<DialogTitle>
|
||||
{creating ? LL.ADD(1) + ' ' + LL.NEW(0) : LL.EDIT()}
|
||||
{LL.ANALOG_SENSOR(0)}
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<Grid container spacing={2}>
|
||||
<Grid>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
fieldErrors={fieldErrors}
|
||||
name="g"
|
||||
label="GPIO"
|
||||
sx={{ width: '11ch' }}
|
||||
@@ -177,7 +107,7 @@ const SensorsAnalogDialog = ({
|
||||
)}
|
||||
<Grid>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
fieldErrors={fieldErrors}
|
||||
name="n"
|
||||
label={LL.NAME(0)}
|
||||
value={editItem.n}
|
||||
@@ -195,10 +125,14 @@ const SensorsAnalogDialog = ({
|
||||
select
|
||||
onChange={updateFormValue}
|
||||
>
|
||||
{analogTypeMenuItems}
|
||||
{AnalogTypeNames.map((val, i) => (
|
||||
<MenuItem key={val} value={i}>
|
||||
{val}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
</Grid>
|
||||
{(isCounterOrRate || isFreqType) && (
|
||||
{editItem.t >= AnalogType.COUNTER && editItem.t <= AnalogType.RATE && (
|
||||
<Grid>
|
||||
<TextField
|
||||
name="u"
|
||||
@@ -208,7 +142,11 @@ const SensorsAnalogDialog = ({
|
||||
select
|
||||
onChange={updateFormValue}
|
||||
>
|
||||
{uomMenuItems}
|
||||
{DeviceValueUOM_s.map((val, i) => (
|
||||
<MenuItem key={val} value={i}>
|
||||
{val}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
</Grid>
|
||||
)}
|
||||
@@ -233,27 +171,6 @@ const SensorsAnalogDialog = ({
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
{editItem.t === AnalogType.NTC && (
|
||||
<Grid>
|
||||
<TextField
|
||||
name="o"
|
||||
label={LL.OFFSET()}
|
||||
value={numberValue(editItem.o)}
|
||||
sx={{ width: '11ch' }}
|
||||
type="number"
|
||||
variant="outlined"
|
||||
onChange={updateFormValue}
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">°C</InputAdornment>
|
||||
)
|
||||
},
|
||||
htmlInput: { min: '-20', max: '20', step: '0.1' }
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
{editItem.t === AnalogType.COUNTER && (
|
||||
<Grid>
|
||||
<TextField
|
||||
@@ -270,26 +187,13 @@ const SensorsAnalogDialog = ({
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
{editItem.t === AnalogType.RGB && (
|
||||
<Grid>
|
||||
<TextField
|
||||
name="o"
|
||||
label={'RGB ' + LL.VALUE(0)}
|
||||
value={numberValue(editItem.o)}
|
||||
type="number"
|
||||
sx={{ width: '11ch' }}
|
||||
variant="outlined"
|
||||
onChange={updateFormValue}
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
{isCounterOrRate && (
|
||||
{editItem.t >= AnalogType.COUNTER && editItem.t <= AnalogType.RATE && (
|
||||
<Grid>
|
||||
<TextField
|
||||
name="f"
|
||||
label={LL.FACTOR()}
|
||||
value={numberValue(editItem.f)}
|
||||
sx={{ width: '14ch' }}
|
||||
sx={{ width: '11ch' }}
|
||||
type="number"
|
||||
variant="outlined"
|
||||
onChange={updateFormValue}
|
||||
@@ -299,71 +203,76 @@ const SensorsAnalogDialog = ({
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
{isDigitalOutGPIO && (
|
||||
<Grid>
|
||||
<TextField
|
||||
name="o"
|
||||
label={LL.VALUE(0)}
|
||||
value={numberValue(editItem.o)}
|
||||
sx={{ width: '11ch' }}
|
||||
type="number"
|
||||
variant="outlined"
|
||||
onChange={updateFormValue}
|
||||
slotProps={{
|
||||
htmlInput: { min: '0', max: '255', step: '1' }
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
{isDigitalOutNonGPIO && (
|
||||
<>
|
||||
{editItem.t === AnalogType.DIGITAL_OUT &&
|
||||
(editItem.g === 25 || editItem.g === 26) && (
|
||||
<Grid>
|
||||
<TextField
|
||||
name="o"
|
||||
label={LL.VALUE(0)}
|
||||
value={numberValue(editItem.o)}
|
||||
select
|
||||
sx={{ width: '11ch' }}
|
||||
type="number"
|
||||
variant="outlined"
|
||||
onChange={updateFormValue}
|
||||
>
|
||||
<MenuItem value={0}>{LL.OFF()}</MenuItem>
|
||||
<MenuItem value={1}>{LL.ON()}</MenuItem>
|
||||
</TextField>
|
||||
slotProps={{
|
||||
htmlInput: { min: '0', max: '255', step: '1' }
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid>
|
||||
<TextField
|
||||
name="f"
|
||||
label={LL.POLARITY()}
|
||||
value={editItem.f}
|
||||
sx={{ width: '15ch' }}
|
||||
select
|
||||
onChange={updateFormValue}
|
||||
>
|
||||
<MenuItem value={1}>{LL.ACTIVEHIGH()}</MenuItem>
|
||||
<MenuItem value={-1}>{LL.ACTIVELOW()}</MenuItem>
|
||||
</TextField>
|
||||
</Grid>
|
||||
<Grid>
|
||||
<TextField
|
||||
name="u"
|
||||
label={LL.STARTVALUE()}
|
||||
sx={{ width: '15ch' }}
|
||||
value={editItem.u}
|
||||
select
|
||||
onChange={updateFormValue}
|
||||
>
|
||||
<MenuItem value={0}>{LL.UNCHANGED()}</MenuItem>
|
||||
<MenuItem value={1}>
|
||||
{LL.ALWAYS()} {LL.OFF()}
|
||||
</MenuItem>
|
||||
<MenuItem value={2}>
|
||||
{LL.ALWAYS()} {LL.ON()}
|
||||
</MenuItem>
|
||||
</TextField>
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
{isPWM && (
|
||||
)}
|
||||
{editItem.t === AnalogType.DIGITAL_OUT &&
|
||||
editItem.g !== 25 &&
|
||||
editItem.g !== 26 && (
|
||||
<>
|
||||
<Grid>
|
||||
<TextField
|
||||
name="o"
|
||||
label={LL.VALUE(0)}
|
||||
value={numberValue(editItem.o)}
|
||||
select
|
||||
variant="outlined"
|
||||
onChange={updateFormValue}
|
||||
>
|
||||
<MenuItem value={0}>{LL.OFF()}</MenuItem>
|
||||
<MenuItem value={1}>{LL.ON()}</MenuItem>
|
||||
</TextField>
|
||||
</Grid>
|
||||
<Grid>
|
||||
<TextField
|
||||
name="f"
|
||||
label={LL.POLARITY()}
|
||||
value={editItem.f}
|
||||
sx={{ width: '15ch' }}
|
||||
select
|
||||
onChange={updateFormValue}
|
||||
>
|
||||
<MenuItem value={1}>{LL.ACTIVEHIGH()}</MenuItem>
|
||||
<MenuItem value={-1}>{LL.ACTIVELOW()}</MenuItem>
|
||||
</TextField>
|
||||
</Grid>
|
||||
<Grid>
|
||||
<TextField
|
||||
name="u"
|
||||
label={LL.STARTVALUE()}
|
||||
sx={{ width: '15ch' }}
|
||||
value={editItem.u}
|
||||
select
|
||||
onChange={updateFormValue}
|
||||
>
|
||||
<MenuItem value={0}>{LL.UNCHANGED()}</MenuItem>
|
||||
<MenuItem value={1}>
|
||||
{LL.ALWAYS()} {LL.OFF()}
|
||||
</MenuItem>
|
||||
<MenuItem value={2}>
|
||||
{LL.ALWAYS()} {LL.ON()}
|
||||
</MenuItem>
|
||||
</TextField>
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
{(editItem.t === AnalogType.PWM_0 ||
|
||||
editItem.t === AnalogType.PWM_1 ||
|
||||
editItem.t === AnalogType.PWM_2) && (
|
||||
<>
|
||||
<Grid>
|
||||
<TextField
|
||||
@@ -405,62 +314,13 @@ const SensorsAnalogDialog = ({
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
{editItem.t === AnalogType.PULSE && (
|
||||
<>
|
||||
<Grid>
|
||||
<TextField
|
||||
name="o"
|
||||
label={LL.POLARITY()}
|
||||
value={editItem.o}
|
||||
sx={{ width: '11ch' }}
|
||||
select
|
||||
onChange={updateFormValue}
|
||||
>
|
||||
<MenuItem value={0}>{LL.ACTIVEHIGH()}</MenuItem>
|
||||
<MenuItem value={1}>{LL.ACTIVELOW()}</MenuItem>
|
||||
</TextField>
|
||||
</Grid>
|
||||
<Grid>
|
||||
<TextField
|
||||
name="f"
|
||||
label="Pulse"
|
||||
value={numberValue(editItem.f)}
|
||||
type="number"
|
||||
sx={{ width: '15ch' }}
|
||||
variant="outlined"
|
||||
onChange={updateFormValue}
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">s</InputAdornment>
|
||||
)
|
||||
},
|
||||
htmlInput: { min: '0', max: '10000', step: '0.1' }
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
</Grid>
|
||||
{editItem.s && (
|
||||
<Grid>
|
||||
<Typography mt={1} color="warning.main" variant="body2">
|
||||
<WarningIcon
|
||||
fontSize="small"
|
||||
sx={{ mr: 1, verticalAlign: 'middle' }}
|
||||
color="warning"
|
||||
/>
|
||||
{LL.SYSTEM(0)} {LL.SENSOR(0)}
|
||||
</Typography>
|
||||
</Grid>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
{!creating && (
|
||||
<Box flexGrow={1} sx={{ '& button': { mt: 0 } }}>
|
||||
<Button
|
||||
startIcon={<RemoveIcon />}
|
||||
disabled={editItem.s}
|
||||
variant="outlined"
|
||||
color="warning"
|
||||
onClick={remove}
|
||||
@@ -478,7 +338,7 @@ const SensorsAnalogDialog = ({
|
||||
{LL.CANCEL()}
|
||||
</Button>
|
||||
<Button
|
||||
startIcon={<DoneIcon />}
|
||||
startIcon={<WarningIcon color="warning" />}
|
||||
variant="outlined"
|
||||
onClick={save}
|
||||
color="primary"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import DoneIcon from '@mui/icons-material/Done';
|
||||
import WarningIcon from '@mui/icons-material/Warning';
|
||||
import {
|
||||
Box,
|
||||
@@ -10,11 +9,11 @@ import {
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Grid,
|
||||
InputAdornment,
|
||||
TextField,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
import Grid from '@mui/material/Grid2';
|
||||
|
||||
import { dialogStyle } from 'CustomTheme';
|
||||
import type Schema from 'async-validator';
|
||||
@@ -34,12 +33,6 @@ interface SensorsTemperatureDialogProps {
|
||||
validator: Schema;
|
||||
}
|
||||
|
||||
// Constants
|
||||
const OFFSET_MIN = -5;
|
||||
const OFFSET_MAX = 5;
|
||||
const OFFSET_STEP = 0.1;
|
||||
const TEMP_UNIT = '°C';
|
||||
|
||||
const SensorsTemperatureDialog = ({
|
||||
open,
|
||||
onClose,
|
||||
@@ -50,18 +43,7 @@ const SensorsTemperatureDialog = ({
|
||||
const { LL } = useI18nContext();
|
||||
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
||||
const [editItem, setEditItem] = useState<TemperatureSensor>(selectedItem);
|
||||
|
||||
const updateFormValue = useMemo(
|
||||
() =>
|
||||
updateValue(
|
||||
setEditItem as unknown as (
|
||||
updater: (
|
||||
prevState: Readonly<Record<string, unknown>>
|
||||
) => Record<string, unknown>
|
||||
) => void
|
||||
),
|
||||
[setEditItem]
|
||||
);
|
||||
const updateFormValue = updateValue(setEditItem);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
@@ -70,16 +52,13 @@ const SensorsTemperatureDialog = ({
|
||||
}
|
||||
}, [open, selectedItem]);
|
||||
|
||||
const handleClose = useCallback(
|
||||
(_event: React.SyntheticEvent, reason?: string) => {
|
||||
if (reason !== 'backdropClick') {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose]
|
||||
);
|
||||
const handleClose = (_event, reason: 'backdropClick' | 'escapeKeyDown') => {
|
||||
if (reason !== 'backdropClick') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const save = useCallback(async () => {
|
||||
const save = async () => {
|
||||
try {
|
||||
setFieldErrors(undefined);
|
||||
await validate(validator, editItem);
|
||||
@@ -87,31 +66,15 @@ const SensorsTemperatureDialog = ({
|
||||
} catch (error) {
|
||||
setFieldErrors(error as ValidateFieldsError);
|
||||
}
|
||||
}, [validator, editItem, onSave]);
|
||||
|
||||
const dialogTitle = useMemo(() => `${LL.EDIT()} ${LL.TEMP_SENSOR()}`, [LL]);
|
||||
|
||||
const offsetValue = useMemo(() => numberValue(editItem.o), [editItem.o]);
|
||||
|
||||
const slotProps = useMemo(
|
||||
() => ({
|
||||
input: {
|
||||
startAdornment: <InputAdornment position="start">{TEMP_UNIT}</InputAdornment>
|
||||
},
|
||||
htmlInput: {
|
||||
min: OFFSET_MIN,
|
||||
max: OFFSET_MAX,
|
||||
step: OFFSET_STEP
|
||||
}
|
||||
}),
|
||||
[]
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
|
||||
<DialogTitle>{dialogTitle}</DialogTitle>
|
||||
<DialogTitle>
|
||||
{LL.EDIT()} {LL.TEMP_SENSOR()}
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<Box color="warning.main" mb={2}>
|
||||
<Box color="warning.main" p={0} pl={0} pr={0} mt={0} mb={2}>
|
||||
<Typography variant="body2">
|
||||
{LL.ID_OF(LL.SENSOR(0))}: {editItem.id}
|
||||
</Typography>
|
||||
@@ -119,7 +82,7 @@ const SensorsTemperatureDialog = ({
|
||||
<Grid container spacing={2}>
|
||||
<Grid>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors ?? {}}
|
||||
fieldErrors={fieldErrors}
|
||||
name="n"
|
||||
label={LL.NAME(0)}
|
||||
value={editItem.n}
|
||||
@@ -131,27 +94,22 @@ const SensorsTemperatureDialog = ({
|
||||
<TextField
|
||||
name="o"
|
||||
label={LL.OFFSET()}
|
||||
value={offsetValue}
|
||||
value={numberValue(editItem.o)}
|
||||
sx={{ width: '11ch' }}
|
||||
type="number"
|
||||
variant="outlined"
|
||||
onChange={updateFormValue}
|
||||
slotProps={slotProps}
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">°C</InputAdornment>
|
||||
)
|
||||
},
|
||||
htmlInput: { min: '-5', max: '5', step: '0.1' }
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
{editItem.s && (
|
||||
<Grid>
|
||||
<Typography mt={1} color="warning.main" variant="body2">
|
||||
<WarningIcon
|
||||
fontSize="small"
|
||||
sx={{ mr: 1, verticalAlign: 'middle' }}
|
||||
color="warning"
|
||||
/>
|
||||
{LL.SYSTEM(0)} {LL.SENSOR(0)}
|
||||
</Typography>
|
||||
</Grid>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
@@ -163,7 +121,7 @@ const SensorsTemperatureDialog = ({
|
||||
{LL.CANCEL()}
|
||||
</Button>
|
||||
<Button
|
||||
startIcon={<DoneIcon />}
|
||||
startIcon={<WarningIcon color="warning" />}
|
||||
variant="outlined"
|
||||
onClick={save}
|
||||
color="primary"
|
||||
|
||||
@@ -2,30 +2,27 @@ import type { TranslationFunctions } from 'i18n/i18n-types';
|
||||
|
||||
import { DeviceValueUOM, DeviceValueUOM_s } from './types';
|
||||
|
||||
// Cache NumberFormat instances for better performance
|
||||
const numberFormatter = new Intl.NumberFormat();
|
||||
const numberFormatterWithDecimal = new Intl.NumberFormat(undefined, {
|
||||
minimumFractionDigits: 1
|
||||
});
|
||||
|
||||
const formatDurationMin = (LL: TranslationFunctions, duration_min: number) => {
|
||||
const totalMs = duration_min * 60000;
|
||||
const days = Math.trunc(totalMs / 86400000);
|
||||
const hours = Math.trunc(totalMs / 3600000) % 24;
|
||||
const minutes = Math.trunc(duration_min) % 60;
|
||||
const days = Math.trunc((duration_min * 60000) / 86400000);
|
||||
const hours = Math.trunc((duration_min * 60000) / 3600000) % 24;
|
||||
const minutes = Math.trunc((duration_min * 60000) / 60000) % 60;
|
||||
|
||||
const parts: string[] = [];
|
||||
let formatted = '';
|
||||
if (days) {
|
||||
parts.push(LL.NUM_DAYS({ num: days }));
|
||||
}
|
||||
if (hours) {
|
||||
parts.push(LL.NUM_HOURS({ num: hours }));
|
||||
}
|
||||
if (minutes) {
|
||||
parts.push(LL.NUM_MINUTES({ num: minutes }));
|
||||
formatted += LL.NUM_DAYS({ num: days });
|
||||
}
|
||||
|
||||
return parts.join(' ');
|
||||
if (hours) {
|
||||
if (formatted) formatted += ' ';
|
||||
formatted += LL.NUM_HOURS({ num: hours });
|
||||
}
|
||||
|
||||
if (minutes) {
|
||||
if (formatted) formatted += ' ';
|
||||
formatted += LL.NUM_MINUTES({ num: minutes });
|
||||
}
|
||||
|
||||
return formatted;
|
||||
};
|
||||
|
||||
export function formatValue(
|
||||
@@ -33,21 +30,13 @@ export function formatValue(
|
||||
value?: unknown,
|
||||
uom?: DeviceValueUOM
|
||||
) {
|
||||
// Handle non-numeric values or missing data
|
||||
if (typeof value !== 'number' || uom === undefined || value === undefined) {
|
||||
if (value === undefined || typeof value === 'boolean') {
|
||||
return '';
|
||||
}
|
||||
// Type assertion is safe here since we know it's not a number, boolean, or undefined
|
||||
return (
|
||||
(value as string) +
|
||||
(value === '' || uom === undefined || uom === DeviceValueUOM.NONE
|
||||
? ''
|
||||
: ' ' + DeviceValueUOM_s[uom])
|
||||
);
|
||||
return value as string;
|
||||
}
|
||||
|
||||
// Handle numeric values
|
||||
switch (uom) {
|
||||
case DeviceValueUOM.HOURS:
|
||||
return value ? formatDurationMin(LL, value * 60) : LL.NUM_HOURS({ num: 0 });
|
||||
@@ -56,12 +45,18 @@ export function formatValue(
|
||||
case DeviceValueUOM.SECONDS:
|
||||
return LL.NUM_SECONDS({ num: value });
|
||||
case DeviceValueUOM.NONE:
|
||||
return numberFormatter.format(value);
|
||||
return new Intl.NumberFormat().format(value);
|
||||
case DeviceValueUOM.DEGREES:
|
||||
case DeviceValueUOM.DEGREES_R:
|
||||
case DeviceValueUOM.FAHRENHEIT:
|
||||
return numberFormatterWithDecimal.format(value) + ' ' + DeviceValueUOM_s[uom];
|
||||
return (
|
||||
new Intl.NumberFormat(undefined, {
|
||||
minimumFractionDigits: 1
|
||||
}).format(value) +
|
||||
' ' +
|
||||
DeviceValueUOM_s[uom]
|
||||
);
|
||||
default:
|
||||
return numberFormatter.format(value) + ' ' + DeviceValueUOM_s[uom];
|
||||
return new Intl.NumberFormat().format(value) + ' ' + DeviceValueUOM_s[uom];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ export interface Settings {
|
||||
dallas_gpio: number;
|
||||
dallas_parasite: boolean;
|
||||
led_gpio: number;
|
||||
led_type: number;
|
||||
hide_led: boolean;
|
||||
low_clock: boolean;
|
||||
notoken_api: boolean;
|
||||
@@ -60,7 +59,7 @@ export interface Stat {
|
||||
}
|
||||
|
||||
export interface Activity {
|
||||
readonly stats: readonly Stat[];
|
||||
stats: Stat[];
|
||||
}
|
||||
|
||||
export interface Device {
|
||||
@@ -72,7 +71,7 @@ export interface Device {
|
||||
d: number; // deviceid
|
||||
p: number; // productid
|
||||
v: string; // version
|
||||
e: number; // total number of entities
|
||||
e: number; // entities
|
||||
url?: string; // lowercase type name used in API URL
|
||||
}
|
||||
|
||||
@@ -82,7 +81,6 @@ export interface TemperatureSensor {
|
||||
t?: number; // temp, optional
|
||||
o: number; // offset
|
||||
u: number; // uom
|
||||
s: boolean; // system sensor flag
|
||||
o_n?: string;
|
||||
}
|
||||
|
||||
@@ -96,7 +94,6 @@ export interface AnalogSensor {
|
||||
f: number;
|
||||
t: number;
|
||||
d: boolean; // deleted flag
|
||||
s: boolean; // system sensor flag
|
||||
o_n?: string;
|
||||
}
|
||||
|
||||
@@ -104,7 +101,6 @@ export interface WriteTemperatureSensor {
|
||||
id: string;
|
||||
name: string;
|
||||
offset: number;
|
||||
is_system: boolean;
|
||||
}
|
||||
|
||||
export interface SensorData {
|
||||
@@ -115,8 +111,8 @@ export interface SensorData {
|
||||
}
|
||||
|
||||
export interface CoreData {
|
||||
readonly connected: boolean;
|
||||
readonly devices: readonly Device[];
|
||||
connected: boolean;
|
||||
devices: Device[];
|
||||
}
|
||||
|
||||
export interface DashboardItem {
|
||||
@@ -125,12 +121,6 @@ export interface DashboardItem {
|
||||
n?: string; // name, optional
|
||||
dv?: DeviceValue; // device value, optional
|
||||
nodes?: DashboardItem[]; // children nodes, optional
|
||||
parentNode: DashboardItem; // to stop lint errors
|
||||
}
|
||||
|
||||
export interface DashboardData {
|
||||
readonly connected: boolean; // true if connected to EMS bus
|
||||
readonly nodes: readonly DashboardItem[];
|
||||
}
|
||||
|
||||
export interface DeviceValue {
|
||||
@@ -143,11 +133,10 @@ export interface DeviceValue {
|
||||
s?: string; // steps for up/down, optional
|
||||
m?: number; // min, optional
|
||||
x?: number; // max, optional
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface DeviceData {
|
||||
readonly nodes: readonly DeviceValue[];
|
||||
nodes: DeviceValue[];
|
||||
}
|
||||
|
||||
export interface DeviceEntity {
|
||||
@@ -192,9 +181,7 @@ export enum DeviceValueUOM {
|
||||
K,
|
||||
VOLTS,
|
||||
MBAR,
|
||||
LH,
|
||||
CTKWH,
|
||||
HZ
|
||||
LH
|
||||
}
|
||||
|
||||
export const DeviceValueUOM_s = [
|
||||
@@ -223,10 +210,8 @@ export const DeviceValueUOM_s = [
|
||||
'K',
|
||||
'V',
|
||||
'mbar',
|
||||
'l/h',
|
||||
'ct/kWh',
|
||||
'Hz'
|
||||
] as const;
|
||||
'l/h'
|
||||
];
|
||||
|
||||
export enum AnalogType {
|
||||
REMOVED = -1,
|
||||
@@ -239,40 +224,29 @@ export enum AnalogType {
|
||||
DIGITAL_OUT = 6,
|
||||
PWM_0 = 7,
|
||||
PWM_1 = 8,
|
||||
PWM_2 = 9,
|
||||
NTC = 10,
|
||||
RGB = 11,
|
||||
PULSE = 12,
|
||||
FREQ_0 = 13,
|
||||
FREQ_1 = 14,
|
||||
FREQ_2 = 15
|
||||
PWM_2 = 9
|
||||
}
|
||||
|
||||
export const AnalogTypeNames = [
|
||||
'(disabled)',
|
||||
'Digital In',
|
||||
'Counter',
|
||||
'ADC In',
|
||||
'ADC',
|
||||
'Timer',
|
||||
'Rate',
|
||||
'Digital Out',
|
||||
'PWM 0',
|
||||
'PWM 1',
|
||||
'PWM 2',
|
||||
'NTC Temp.',
|
||||
'RGB Led',
|
||||
'Pulse',
|
||||
'Freq 0',
|
||||
'Freq 1',
|
||||
'Freq 2'
|
||||
] as const;
|
||||
'PWM 2'
|
||||
];
|
||||
|
||||
export const BOARD_PROFILES = {
|
||||
type BoardProfiles = Record<string, string>;
|
||||
|
||||
export const BOARD_PROFILES: BoardProfiles = {
|
||||
S32: 'BBQKees Gateway S32',
|
||||
S32S3: 'BBQKees Gateway S3',
|
||||
E32: 'BBQKees Gateway E32',
|
||||
E32V2: 'BBQKees Gateway E32 V2',
|
||||
E32V2_2: 'BBQKees Gateway E32 V2.2',
|
||||
NODEMCU: 'NodeMCU 32S',
|
||||
'MH-ET': 'MH-ET Live D1 Mini',
|
||||
LOLIN: 'Lolin D32',
|
||||
@@ -281,14 +255,11 @@ export const BOARD_PROFILES = {
|
||||
C3MINI: 'Wemos C3 Mini',
|
||||
S2MINI: 'Wemos S2 Mini',
|
||||
S3MINI: 'Liligo S3'
|
||||
} as const;
|
||||
|
||||
export type BoardProfileKey = keyof typeof BOARD_PROFILES;
|
||||
};
|
||||
|
||||
export interface BoardProfile {
|
||||
board_profile: string;
|
||||
led_gpio: number;
|
||||
led_type: number;
|
||||
dallas_gpio: number;
|
||||
rx_gpio: number;
|
||||
tx_gpio: number;
|
||||
@@ -320,7 +291,6 @@ export interface WriteAnalogSensor {
|
||||
uom: number;
|
||||
type: number;
|
||||
deleted: boolean;
|
||||
is_system: boolean;
|
||||
}
|
||||
|
||||
export enum DeviceEntityMask {
|
||||
@@ -352,7 +322,7 @@ export interface ScheduleItem {
|
||||
}
|
||||
|
||||
export interface Schedule {
|
||||
readonly schedule: readonly ScheduleItem[];
|
||||
schedule: ScheduleItem[];
|
||||
}
|
||||
|
||||
export interface ModuleItem {
|
||||
@@ -370,7 +340,7 @@ export interface ModuleItem {
|
||||
}
|
||||
|
||||
export interface Modules {
|
||||
readonly modules: readonly ModuleItem[];
|
||||
modules: ModuleItem[];
|
||||
}
|
||||
|
||||
export enum ScheduleFlag {
|
||||
@@ -396,12 +366,11 @@ export interface EntityItem {
|
||||
device_id: number | string;
|
||||
type_id: number | string;
|
||||
offset: number;
|
||||
factor: number | string;
|
||||
factor: number;
|
||||
uom: number;
|
||||
value_type: number;
|
||||
value?: unknown;
|
||||
writeable: boolean;
|
||||
hide: boolean;
|
||||
deleted?: boolean;
|
||||
o_id?: number;
|
||||
o_ram?: number;
|
||||
@@ -409,17 +378,16 @@ export interface EntityItem {
|
||||
o_device_id?: number | string;
|
||||
o_type_id?: number | string;
|
||||
o_offset?: number;
|
||||
o_factor?: number | string;
|
||||
o_factor?: number;
|
||||
o_uom?: number;
|
||||
o_value_type?: number;
|
||||
o_deleted?: boolean;
|
||||
o_writeable?: boolean;
|
||||
o_value?: unknown;
|
||||
o_hide?: boolean;
|
||||
}
|
||||
|
||||
export interface Entities {
|
||||
readonly entities: readonly EntityItem[];
|
||||
entities: EntityItem[];
|
||||
}
|
||||
|
||||
// matches emsdevice.h DeviceType
|
||||
@@ -475,4 +443,4 @@ export const DeviceValueTypeNames = [
|
||||
'ENUM',
|
||||
'RAW',
|
||||
'CMD'
|
||||
] as const;
|
||||
];
|
||||
|
||||
@@ -11,445 +11,473 @@ import type {
|
||||
TemperatureSensor
|
||||
} from './types';
|
||||
|
||||
// Constants
|
||||
const ERROR_MESSAGES = {
|
||||
GPIO_INVALID: 'Must be an valid GPIO port',
|
||||
NAME_DUPLICATE: 'Name already in use',
|
||||
GPIO_DUPLICATE: 'GPIO already in use',
|
||||
VALUE_OUT_OF_RANGE: 'Value out of range',
|
||||
HEX_REQUIRED: 'Is required and must be in hex format'
|
||||
} as const;
|
||||
|
||||
const VALIDATION_LIMITS = {
|
||||
PORT_MIN: 0,
|
||||
PORT_MAX: 65535,
|
||||
MODBUS_MAX_CLIENTS_MIN: 0,
|
||||
MODBUS_MAX_CLIENTS_MAX: 50,
|
||||
MODBUS_TIMEOUT_MIN: 100,
|
||||
MODBUS_TIMEOUT_MAX: 20000,
|
||||
SYSLOG_MARK_INTERVAL_MIN: 0,
|
||||
SYSLOG_MARK_INTERVAL_MAX: 10,
|
||||
SHOWER_MIN_DURATION_MIN: 10,
|
||||
SHOWER_MIN_DURATION_MAX: 360,
|
||||
SHOWER_ALERT_TRIGGER_MIN: 1,
|
||||
SHOWER_ALERT_TRIGGER_MAX: 20,
|
||||
SHOWER_ALERT_COLDSHOT_MIN: 1,
|
||||
SHOWER_ALERT_COLDSHOT_MAX: 10,
|
||||
REMOTE_TIMEOUT_MIN: 1,
|
||||
REMOTE_TIMEOUT_MAX: 240,
|
||||
OFFSET_MIN: 0,
|
||||
OFFSET_MAX: 255,
|
||||
COMMAND_MIN: 1,
|
||||
COMMAND_MAX: 300,
|
||||
NAME_MAX_LENGTH: 19,
|
||||
HEX_BASE: 16
|
||||
} as const;
|
||||
|
||||
// Helper to create GPIO validator from invalid ranges
|
||||
const createGPIOValidator = (
|
||||
invalidRanges: Array<number | [number, number]>,
|
||||
maxValue: number
|
||||
) => ({
|
||||
export const GPIO_VALIDATOR = {
|
||||
validator(
|
||||
_rule: InternalRuleItem,
|
||||
rule: InternalRuleItem,
|
||||
value: number,
|
||||
callback: (error?: string) => void
|
||||
) {
|
||||
if (!value) {
|
||||
if (
|
||||
value &&
|
||||
(value === 1 ||
|
||||
(value >= 6 && value <= 11) ||
|
||||
value === 20 ||
|
||||
value === 24 ||
|
||||
(value >= 28 && value <= 31) ||
|
||||
value > 40 ||
|
||||
value < 0)
|
||||
) {
|
||||
callback('Must be an valid GPIO port');
|
||||
} else {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
|
||||
if (value < 0 || value > maxValue) {
|
||||
callback(ERROR_MESSAGES.GPIO_INVALID);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const range of invalidRanges) {
|
||||
if (typeof range === 'number') {
|
||||
if (value === range) {
|
||||
callback(ERROR_MESSAGES.GPIO_INVALID);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const [start, end] = range;
|
||||
if (value >= start && value <= end) {
|
||||
callback(ERROR_MESSAGES.GPIO_INVALID);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
callback();
|
||||
}
|
||||
});
|
||||
|
||||
export const GPIO_VALIDATOR = createGPIOValidator(
|
||||
[[6, 11], 1, 20, 24, [28, 31]],
|
||||
40
|
||||
);
|
||||
|
||||
export const GPIO_VALIDATORC3 = createGPIOValidator([[11, 19]], 21);
|
||||
|
||||
export const GPIO_VALIDATORS2 = createGPIOValidator(
|
||||
[
|
||||
[19, 20],
|
||||
[22, 32]
|
||||
],
|
||||
40
|
||||
);
|
||||
|
||||
export const GPIO_VALIDATORS3 = createGPIOValidator(
|
||||
[
|
||||
[19, 20],
|
||||
[22, 37],
|
||||
[39, 42]
|
||||
],
|
||||
48
|
||||
);
|
||||
|
||||
const GPIO_FIELD_NAMES = [
|
||||
'led_gpio',
|
||||
'dallas_gpio',
|
||||
'pbutton_gpio',
|
||||
'tx_gpio',
|
||||
'rx_gpio'
|
||||
] as const;
|
||||
|
||||
type ValidationRules = Array<{
|
||||
required?: boolean;
|
||||
message?: string;
|
||||
[key: string]: unknown;
|
||||
}>;
|
||||
|
||||
const createGPIOValidations = (
|
||||
validator: typeof GPIO_VALIDATOR
|
||||
): Record<string, ValidationRules> =>
|
||||
GPIO_FIELD_NAMES.reduce(
|
||||
(acc, field) => {
|
||||
const fieldName = field.replace('_gpio', '').toUpperCase();
|
||||
acc[field] = [
|
||||
{ required: true, message: `${fieldName} GPIO is required` },
|
||||
validator
|
||||
];
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, ValidationRules>
|
||||
);
|
||||
|
||||
const PLATFORM_VALIDATORS = {
|
||||
ESP32: GPIO_VALIDATOR,
|
||||
ESP32C3: GPIO_VALIDATORC3,
|
||||
ESP32S2: GPIO_VALIDATORS2,
|
||||
ESP32S3: GPIO_VALIDATORS3
|
||||
} as const;
|
||||
|
||||
export const createSettingsValidator = (settings: Settings) => {
|
||||
const schema: Record<string, ValidationRules> = {};
|
||||
|
||||
// Add GPIO validations for CUSTOM board profiles
|
||||
if (
|
||||
settings.board_profile === 'CUSTOM' &&
|
||||
settings.platform in PLATFORM_VALIDATORS
|
||||
) {
|
||||
Object.assign(
|
||||
schema,
|
||||
createGPIOValidations(
|
||||
PLATFORM_VALIDATORS[settings.platform as keyof typeof PLATFORM_VALIDATORS]
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// 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
|
||||
const createUniqueNameValidator = <T extends { name: string }>(
|
||||
items: T[],
|
||||
originalName?: string
|
||||
) => ({
|
||||
export const GPIO_VALIDATORR = {
|
||||
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,
|
||||
callback: (error?: string) => void
|
||||
) {
|
||||
if (
|
||||
name !== '' &&
|
||||
(originalName === undefined ||
|
||||
originalName.toLowerCase() !== name.toLowerCase()) &&
|
||||
items.find((item) => item.name.toLowerCase() === name.toLowerCase())
|
||||
(o_name === undefined || o_name.toLowerCase() !== name.toLowerCase()) &&
|
||||
schedule.find((si) => si.name.toLowerCase() === name.toLowerCase())
|
||||
) {
|
||||
callback(ERROR_MESSAGES.NAME_DUPLICATE);
|
||||
return;
|
||||
callback('Name already in use');
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
callback();
|
||||
}
|
||||
});
|
||||
|
||||
// Generic field name validator (for cases where the name field has different property names)
|
||||
const createUniqueFieldNameValidator = <T>(
|
||||
items: T[],
|
||||
getName: (item: T) => string,
|
||||
originalName?: string
|
||||
) => ({
|
||||
validator(
|
||||
_rule: InternalRuleItem,
|
||||
name: string,
|
||||
callback: (error?: string) => void
|
||||
) {
|
||||
if (
|
||||
name !== '' &&
|
||||
(originalName === undefined ||
|
||||
originalName.toLowerCase() !== name.toLowerCase()) &&
|
||||
items.find((item) => getName(item).toLowerCase() === name.toLowerCase())
|
||||
) {
|
||||
callback(ERROR_MESSAGES.NAME_DUPLICATE);
|
||||
return;
|
||||
}
|
||||
callback();
|
||||
}
|
||||
});
|
||||
|
||||
const NAME_PATTERN_BASE = '[a-zA-Z0-9_]';
|
||||
const NAME_PATTERN_MESSAGE = `Must be <${VALIDATION_LIMITS.NAME_MAX_LENGTH + 1} characters: alphanumeric or '_'`;
|
||||
|
||||
const NAME_PATTERN = {
|
||||
type: 'string' as const,
|
||||
pattern: new RegExp(
|
||||
`^${NAME_PATTERN_BASE}{0,${VALIDATION_LIMITS.NAME_MAX_LENGTH}}$`
|
||||
),
|
||||
message: NAME_PATTERN_MESSAGE
|
||||
};
|
||||
|
||||
const NAME_PATTERN_REQUIRED = {
|
||||
type: 'string' as const,
|
||||
pattern: new RegExp(
|
||||
`^${NAME_PATTERN_BASE}{1,${VALIDATION_LIMITS.NAME_MAX_LENGTH}}$`
|
||||
),
|
||||
message: NAME_PATTERN_MESSAGE
|
||||
};
|
||||
|
||||
export const uniqueNameValidator = (schedule: ScheduleItem[], o_name?: string) =>
|
||||
createUniqueNameValidator(schedule, o_name);
|
||||
|
||||
export const schedulerItemValidation = (
|
||||
schedule: ScheduleItem[],
|
||||
scheduleItem: ScheduleItem
|
||||
) =>
|
||||
new Schema({
|
||||
name: [NAME_PATTERN, uniqueNameValidator(schedule, scheduleItem.o_name)],
|
||||
name: [
|
||||
{
|
||||
type: 'string',
|
||||
pattern: /^[a-zA-Z0-9_]{0,19}$/,
|
||||
message: "Must be <20 characters: alphanumeric or '_'"
|
||||
},
|
||||
...[uniqueNameValidator(schedule, scheduleItem.o_name)]
|
||||
],
|
||||
cmd: [
|
||||
{ required: true, message: 'Command is required' },
|
||||
{
|
||||
type: 'string',
|
||||
min: VALIDATION_LIMITS.COMMAND_MIN,
|
||||
max: VALIDATION_LIMITS.COMMAND_MAX,
|
||||
message: `Command must be ${VALIDATION_LIMITS.COMMAND_MIN}-${VALIDATION_LIMITS.COMMAND_MAX} characters`
|
||||
min: 1,
|
||||
max: 300,
|
||||
message: 'Command must be 1-300 characters'
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
export const uniqueCustomNameValidator = (entity: EntityItem[], o_name?: string) =>
|
||||
createUniqueNameValidator(entity, o_name);
|
||||
|
||||
const hexValidator = {
|
||||
export const uniqueCustomNameValidator = (
|
||||
entity: EntityItem[],
|
||||
o_name?: string
|
||||
) => ({
|
||||
validator(
|
||||
_rule: InternalRuleItem,
|
||||
value: string,
|
||||
rule: InternalRuleItem,
|
||||
name: string,
|
||||
callback: (error?: string) => void
|
||||
) {
|
||||
if (!value || Number.isNaN(Number.parseInt(value, VALIDATION_LIMITS.HEX_BASE))) {
|
||||
callback(ERROR_MESSAGES.HEX_REQUIRED);
|
||||
return;
|
||||
if (
|
||||
(o_name === undefined || o_name.toLowerCase() !== name.toLowerCase()) &&
|
||||
entity.find((ei) => ei.name.toLowerCase() === name.toLowerCase())
|
||||
) {
|
||||
callback('Name already in use');
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
callback();
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
export const entityItemValidation = (entity: EntityItem[], entityItem: EntityItem) =>
|
||||
new Schema({
|
||||
name: [
|
||||
{ required: true, message: 'Name is required' },
|
||||
NAME_PATTERN_REQUIRED,
|
||||
uniqueCustomNameValidator(entity, entityItem.o_name)
|
||||
],
|
||||
device_id: [hexValidator],
|
||||
type_id: [hexValidator],
|
||||
offset: [
|
||||
{ required: true, message: 'Offset is required' },
|
||||
{
|
||||
type: 'number',
|
||||
min: VALIDATION_LIMITS.OFFSET_MIN,
|
||||
max: VALIDATION_LIMITS.OFFSET_MAX,
|
||||
message: `Must be between ${VALIDATION_LIMITS.OFFSET_MIN} and ${VALIDATION_LIMITS.OFFSET_MAX}`
|
||||
type: 'string',
|
||||
pattern: /^[a-zA-Z0-9_]{1,19}$/,
|
||||
message: "Must be <20 characters: alphanumeric or '_'"
|
||||
},
|
||||
...[uniqueCustomNameValidator(entity, entityItem.o_name)]
|
||||
],
|
||||
device_id: [
|
||||
{
|
||||
validator(
|
||||
rule: InternalRuleItem,
|
||||
value: string,
|
||||
callback: (error?: string) => void
|
||||
) {
|
||||
if (isNaN(parseInt(value, 16))) {
|
||||
callback('Is required and must be in hex format');
|
||||
}
|
||||
callback();
|
||||
}
|
||||
}
|
||||
],
|
||||
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 = (
|
||||
sensors: TemperatureSensor[],
|
||||
o_name?: string
|
||||
) => createUniqueFieldNameValidator(sensors, (s) => s.n, o_name);
|
||||
) => ({
|
||||
validator(rule: InternalRuleItem, n: string, callback: (error?: string) => void) {
|
||||
if (
|
||||
(o_name === undefined || o_name.toLowerCase() !== n.toLowerCase()) &&
|
||||
n !== '' &&
|
||||
sensors.find((ts) => ts.n.toLowerCase() === n.toLowerCase())
|
||||
) {
|
||||
callback('Name already in use');
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const temperatureSensorItemValidation = (
|
||||
sensors: TemperatureSensor[],
|
||||
sensor: TemperatureSensor
|
||||
) =>
|
||||
new Schema({
|
||||
n: [NAME_PATTERN, uniqueTemperatureNameValidator(sensors, sensor.o_n)]
|
||||
n: [
|
||||
{
|
||||
type: 'string',
|
||||
pattern: /^[a-zA-Z0-9_]{0,19}$/,
|
||||
message: "Must be <20 characters: alphanumeric or '_'"
|
||||
},
|
||||
...[uniqueTemperatureNameValidator(sensors, sensor.o_n)]
|
||||
]
|
||||
});
|
||||
|
||||
export const isGPIOUniqueValidator = (sensors: AnalogSensor[]) => ({
|
||||
validator(
|
||||
_rule: InternalRuleItem,
|
||||
rule: InternalRuleItem,
|
||||
gpio: number,
|
||||
callback: (error?: string) => void
|
||||
) {
|
||||
if (sensors.some((as) => as.g === gpio)) {
|
||||
callback(ERROR_MESSAGES.GPIO_DUPLICATE);
|
||||
return;
|
||||
if (sensors.find((as) => as.g === gpio)) {
|
||||
callback('GPIO already in use');
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
callback();
|
||||
}
|
||||
});
|
||||
|
||||
export const uniqueAnalogNameValidator = (
|
||||
sensors: AnalogSensor[],
|
||||
o_name?: string
|
||||
) => createUniqueFieldNameValidator(sensors, (s) => s.n, o_name);
|
||||
|
||||
const getPlatformGPIOValidator = (platform: string) => {
|
||||
switch (platform) {
|
||||
case 'ESP32S3':
|
||||
return GPIO_VALIDATORS3;
|
||||
case 'ESP32S2':
|
||||
return GPIO_VALIDATORS2;
|
||||
case 'ESP32C3':
|
||||
return GPIO_VALIDATORC3;
|
||||
default:
|
||||
return GPIO_VALIDATOR;
|
||||
) => ({
|
||||
validator(rule: InternalRuleItem, n: string, callback: (error?: string) => void) {
|
||||
if (
|
||||
(o_name === undefined || o_name.toLowerCase() !== n.toLowerCase()) &&
|
||||
n !== '' &&
|
||||
sensors.find((as) => as.n.toLowerCase() === n.toLowerCase())
|
||||
) {
|
||||
callback('Name already in use');
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
export const analogSensorItemValidation = (
|
||||
sensors: AnalogSensor[],
|
||||
sensor: AnalogSensor,
|
||||
creating: boolean,
|
||||
platform: string
|
||||
) => {
|
||||
const gpioValidator = getPlatformGPIOValidator(platform);
|
||||
|
||||
return new Schema({
|
||||
n: [NAME_PATTERN, uniqueAnalogNameValidator(sensors, sensor.o_n)],
|
||||
) =>
|
||||
new Schema({
|
||||
n: [
|
||||
{
|
||||
type: 'string',
|
||||
pattern: /^[a-zA-Z0-9_]{0,19}$/,
|
||||
message: "Must be <20 characters: alphanumeric or '_'"
|
||||
},
|
||||
...[uniqueAnalogNameValidator(sensors, sensor.o_n)]
|
||||
],
|
||||
g: [
|
||||
{ required: true, message: 'GPIO is required' },
|
||||
gpioValidator,
|
||||
platform === 'ESP32S3'
|
||||
? GPIO_VALIDATORS3
|
||||
: platform === 'ESP32S2'
|
||||
? GPIO_VALIDATORS2
|
||||
: platform === 'ESP32C3'
|
||||
? GPIO_VALIDATORC3
|
||||
: GPIO_VALIDATOR,
|
||||
...(creating ? [isGPIOUniqueValidator(sensors)] : [])
|
||||
]
|
||||
});
|
||||
};
|
||||
|
||||
export const deviceValueItemValidation = (dv: DeviceValue) =>
|
||||
new Schema({
|
||||
@@ -457,18 +485,17 @@ export const deviceValueItemValidation = (dv: DeviceValue) =>
|
||||
{ required: true, message: 'Value is required' },
|
||||
{
|
||||
validator(
|
||||
_rule: InternalRuleItem,
|
||||
rule: InternalRuleItem,
|
||||
value: unknown,
|
||||
callback: (error?: string) => void
|
||||
) {
|
||||
if (
|
||||
typeof value === 'number' &&
|
||||
dv.m !== undefined &&
|
||||
dv.x !== undefined &&
|
||||
dv.m &&
|
||||
dv.x &&
|
||||
(value < dv.m || value > dv.x)
|
||||
) {
|
||||
callback(ERROR_MESSAGES.VALUE_OUT_OF_RANGE);
|
||||
return;
|
||||
callback('Value out of range');
|
||||
}
|
||||
callback();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import WarningIcon from '@mui/icons-material/Warning';
|
||||
@@ -27,19 +27,6 @@ export const isAPEnabled = ({ provision_mode }: APSettingsType) =>
|
||||
provision_mode === APProvisionMode.AP_MODE_ALWAYS ||
|
||||
provision_mode === APProvisionMode.AP_MODE_DISCONNECTED;
|
||||
|
||||
// Efficient range function without recursion
|
||||
const createRange = (start: number, end: number): number[] => {
|
||||
const result: number[] = [];
|
||||
for (let i = start; i <= end; i++) {
|
||||
result.push(i);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
// Pre-computed ranges for better performance
|
||||
const CHANNEL_RANGE = createRange(1, 14);
|
||||
const MAX_CLIENTS_RANGE = createRange(1, 9);
|
||||
|
||||
const APSettings = () => {
|
||||
const {
|
||||
loadData,
|
||||
@@ -59,46 +46,41 @@ const APSettings = () => {
|
||||
|
||||
const { LL } = useI18nContext();
|
||||
|
||||
useLayoutTitle(LL.ACCESS_POINT(0));
|
||||
useLayoutTitle(LL.SETTINGS_OF(LL.ACCESS_POINT(0)));
|
||||
|
||||
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
||||
|
||||
const updateFormValue = useMemo(
|
||||
() =>
|
||||
updateValueDirty(
|
||||
origData as unknown as Record<string, unknown>,
|
||||
dirtyFlags,
|
||||
setDirtyFlags,
|
||||
updateDataValue as (value: unknown) => void
|
||||
),
|
||||
[origData, dirtyFlags, setDirtyFlags, updateDataValue]
|
||||
const updateFormValue = updateValueDirty(
|
||||
origData,
|
||||
dirtyFlags,
|
||||
setDirtyFlags,
|
||||
updateDataValue
|
||||
);
|
||||
|
||||
// Memoize AP enabled state
|
||||
const apEnabled = useMemo(() => (data ? isAPEnabled(data) : false), [data]);
|
||||
|
||||
// Memoize validation and submit handler
|
||||
const validateAndSubmit = useCallback(async () => {
|
||||
if (!data) return;
|
||||
|
||||
try {
|
||||
setFieldErrors(undefined);
|
||||
await validate(createAPSettingsValidator(data), data);
|
||||
await saveData();
|
||||
} catch (error) {
|
||||
setFieldErrors(error as ValidateFieldsError);
|
||||
}
|
||||
}, [data, saveData]);
|
||||
|
||||
const content = () => {
|
||||
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 (
|
||||
<>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
fieldErrors={fieldErrors}
|
||||
name="provision_mode"
|
||||
label={LL.AP_PROVIDE() + '...'}
|
||||
value={data.provision_mode}
|
||||
@@ -118,10 +100,10 @@ const APSettings = () => {
|
||||
{LL.AP_PROVIDE_TEXT_3()}
|
||||
</MenuItem>
|
||||
</ValidatedTextField>
|
||||
{apEnabled && (
|
||||
{isAPEnabled(data) && (
|
||||
<>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
fieldErrors={fieldErrors}
|
||||
name="ssid"
|
||||
label={LL.ACCESS_POINT(2) + ' SSID'}
|
||||
fullWidth
|
||||
@@ -131,7 +113,7 @@ const APSettings = () => {
|
||||
margin="normal"
|
||||
/>
|
||||
<ValidatedPasswordField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
fieldErrors={fieldErrors}
|
||||
name="password"
|
||||
label={LL.ACCESS_POINT(2) + ' ' + LL.PASSWORD()}
|
||||
fullWidth
|
||||
@@ -141,7 +123,7 @@ const APSettings = () => {
|
||||
margin="normal"
|
||||
/>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
fieldErrors={fieldErrors}
|
||||
name="channel"
|
||||
label={LL.AP_PREFERRED_CHANNEL()}
|
||||
value={numberValue(data.channel)}
|
||||
@@ -152,7 +134,7 @@ const APSettings = () => {
|
||||
onChange={updateFormValue}
|
||||
margin="normal"
|
||||
>
|
||||
{CHANNEL_RANGE.map((i) => (
|
||||
{range(1, 14).map((i) => (
|
||||
<MenuItem key={i} value={i}>
|
||||
{i}
|
||||
</MenuItem>
|
||||
@@ -169,7 +151,7 @@ const APSettings = () => {
|
||||
label={LL.AP_HIDE_SSID()}
|
||||
/>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
fieldErrors={fieldErrors}
|
||||
name="max_clients"
|
||||
label={LL.AP_MAX_CLIENTS()}
|
||||
value={numberValue(data.max_clients)}
|
||||
@@ -180,14 +162,14 @@ const APSettings = () => {
|
||||
onChange={updateFormValue}
|
||||
margin="normal"
|
||||
>
|
||||
{MAX_CLIENTS_RANGE.map((i) => (
|
||||
{range(1, 9).map((i) => (
|
||||
<MenuItem key={i} value={i}>
|
||||
{i}
|
||||
</MenuItem>
|
||||
))}
|
||||
</ValidatedTextField>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
fieldErrors={fieldErrors}
|
||||
name="local_ip"
|
||||
label={LL.AP_LOCAL_IP()}
|
||||
fullWidth
|
||||
@@ -197,7 +179,7 @@ const APSettings = () => {
|
||||
margin="normal"
|
||||
/>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
fieldErrors={fieldErrors}
|
||||
name="gateway_ip"
|
||||
label={LL.NETWORK_GATEWAY()}
|
||||
fullWidth
|
||||
@@ -207,7 +189,7 @@ const APSettings = () => {
|
||||
margin="normal"
|
||||
/>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
fieldErrors={fieldErrors}
|
||||
name="subnet_mask"
|
||||
label={LL.NETWORK_SUBNET()}
|
||||
fullWidth
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
@@ -9,17 +9,17 @@ import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Grid,
|
||||
InputAdornment,
|
||||
MenuItem,
|
||||
TextField,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
import Grid from '@mui/material/Grid2';
|
||||
|
||||
import { readSystemStatus } from 'api/system';
|
||||
|
||||
import { useRequest } from 'alova/client';
|
||||
import SystemMonitor from 'app/status/SystemMonitor';
|
||||
import RestartMonitor from 'app/status/RestartMonitor';
|
||||
import type { ValidateFieldsError } from 'async-validator';
|
||||
import {
|
||||
BlockFormControlLabel,
|
||||
@@ -37,13 +37,13 @@ import { validate } from 'validators';
|
||||
|
||||
import { API, getBoardProfile, readSettings, writeSettings } from '../../api/app';
|
||||
import { BOARD_PROFILES } from '../main/types';
|
||||
import type { APIcall, BoardProfileKey, Settings } from '../main/types';
|
||||
import type { APIcall, Settings } from '../main/types';
|
||||
import { createSettingsValidator } from '../main/validators';
|
||||
|
||||
export function boardProfileSelectItems() {
|
||||
return Object.keys(BOARD_PROFILES).map((code) => (
|
||||
<MenuItem key={code} value={code}>
|
||||
{BOARD_PROFILES[code as BoardProfileKey]}
|
||||
{BOARD_PROFILES[code]}
|
||||
</MenuItem>
|
||||
));
|
||||
}
|
||||
@@ -72,10 +72,10 @@ const ApplicationSettings = () => {
|
||||
const { LL } = useI18nContext();
|
||||
|
||||
const updateFormValue = updateValueDirty(
|
||||
origData as unknown as Record<string, unknown>,
|
||||
origData,
|
||||
dirtyFlags,
|
||||
setDirtyFlags,
|
||||
updateDataValue as (value: unknown) => void
|
||||
updateDataValue
|
||||
);
|
||||
|
||||
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
||||
@@ -106,61 +106,53 @@ const ApplicationSettings = () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Memoized input props to prevent recreation on every render
|
||||
const SecondsInputProps = useMemo(
|
||||
() => ({
|
||||
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
|
||||
}),
|
||||
[LL]
|
||||
);
|
||||
|
||||
const MinutesInputProps = useMemo(
|
||||
() => ({
|
||||
endAdornment: <InputAdornment position="end">{LL.MINUTES()}</InputAdornment>
|
||||
}),
|
||||
[LL]
|
||||
);
|
||||
|
||||
const HoursInputProps = useMemo(
|
||||
() => ({
|
||||
endAdornment: <InputAdornment position="end">{LL.HOURS()}</InputAdornment>
|
||||
}),
|
||||
[LL]
|
||||
);
|
||||
|
||||
const doRestart = useCallback(async () => {
|
||||
const doRestart = async () => {
|
||||
setRestarting(true);
|
||||
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
|
||||
(error: Error) => {
|
||||
toast.error(error.message);
|
||||
}
|
||||
);
|
||||
}, [sendAPI]);
|
||||
};
|
||||
|
||||
const updateBoardProfile = useCallback(
|
||||
async (board_profile: string) => {
|
||||
await readBoardProfile(board_profile).catch((error: Error) => {
|
||||
toast.error(error.message);
|
||||
});
|
||||
},
|
||||
[readBoardProfile]
|
||||
);
|
||||
const updateBoardProfile = async (board_profile: string) => {
|
||||
await readBoardProfile(board_profile).catch((error: Error) => {
|
||||
toast.error(error.message);
|
||||
});
|
||||
};
|
||||
|
||||
useLayoutTitle(LL.APPLICATION());
|
||||
useLayoutTitle(LL.SETTINGS_OF(LL.APPLICATION()));
|
||||
|
||||
const validateAndSubmit = useCallback(async () => {
|
||||
try {
|
||||
setFieldErrors(undefined);
|
||||
await validate(createSettingsValidator(data), data);
|
||||
} catch (error) {
|
||||
setFieldErrors(error as ValidateFieldsError);
|
||||
} finally {
|
||||
await saveData();
|
||||
const SecondsInputProps = {
|
||||
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
|
||||
};
|
||||
const MilliSecondsInputProps = {
|
||||
endAdornment: <InputAdornment position="end">ms</InputAdornment>
|
||||
};
|
||||
const MinutesInputProps = {
|
||||
endAdornment: <InputAdornment position="end">{LL.MINUTES()}</InputAdornment>
|
||||
};
|
||||
const HoursInputProps = {
|
||||
endAdornment: <InputAdornment position="end">{LL.HOURS()}</InputAdornment>
|
||||
};
|
||||
|
||||
const content = () => {
|
||||
if (!data || !hardwareData) {
|
||||
return <FormLoader onRetry={loadData} errorMessage={errorMessage} />;
|
||||
}
|
||||
}, [data, saveData]);
|
||||
|
||||
const changeBoardProfile = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
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') {
|
||||
@@ -171,22 +163,12 @@ const ApplicationSettings = () => {
|
||||
} else {
|
||||
void updateBoardProfile(boardProfile);
|
||||
}
|
||||
},
|
||||
[data, updateBoardProfile, updateFormValue, updateDataValue]
|
||||
);
|
||||
};
|
||||
|
||||
const restart = useCallback(async () => {
|
||||
await validateAndSubmit();
|
||||
await doRestart();
|
||||
}, [validateAndSubmit, doRestart]);
|
||||
|
||||
// Memoize board profile select items to prevent recreation
|
||||
const boardProfileItems = useMemo(() => boardProfileSelectItems(), []);
|
||||
|
||||
const content = () => {
|
||||
if (!data || !hardwareData) {
|
||||
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
|
||||
}
|
||||
const restart = async () => {
|
||||
await validateAndSubmit();
|
||||
await doRestart();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -225,22 +207,13 @@ const ApplicationSettings = () => {
|
||||
disabled={!hardwareData.psram}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Typography color={!hardwareData.psram ? 'grey' : 'default'}>
|
||||
{LL.ENABLE_MODBUS()}
|
||||
{!hardwareData.psram && (
|
||||
<Typography variant="caption">
|
||||
({LL.IS_REQUIRED('PSRAM')})
|
||||
</Typography>
|
||||
)}
|
||||
</Typography>
|
||||
}
|
||||
label={LL.ENABLE_MODBUS()}
|
||||
/>
|
||||
{data.modbus_enabled && (
|
||||
<Grid container spacing={2} rowSpacing={0}>
|
||||
<Grid>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
fieldErrors={fieldErrors}
|
||||
name="modbus_max_clients"
|
||||
label={LL.AP_MAX_CLIENTS()}
|
||||
variant="outlined"
|
||||
@@ -252,7 +225,7 @@ const ApplicationSettings = () => {
|
||||
</Grid>
|
||||
<Grid>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
fieldErrors={fieldErrors}
|
||||
name="modbus_port"
|
||||
label="Port"
|
||||
variant="outlined"
|
||||
@@ -264,11 +237,11 @@ const ApplicationSettings = () => {
|
||||
</Grid>
|
||||
<Grid>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
fieldErrors={fieldErrors}
|
||||
name="modbus_timeout"
|
||||
label="Timeout"
|
||||
slotProps={{
|
||||
input: SecondsInputProps
|
||||
input: MilliSecondsInputProps
|
||||
}}
|
||||
variant="outlined"
|
||||
value={numberValue(data.modbus_timeout)}
|
||||
@@ -294,7 +267,7 @@ const ApplicationSettings = () => {
|
||||
<Grid container spacing={2} rowSpacing={0}>
|
||||
<Grid>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
fieldErrors={fieldErrors}
|
||||
name="syslog_host"
|
||||
label="Host"
|
||||
variant="outlined"
|
||||
@@ -305,7 +278,7 @@ const ApplicationSettings = () => {
|
||||
</Grid>
|
||||
<Grid>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
fieldErrors={fieldErrors}
|
||||
name="syslog_port"
|
||||
label="Port"
|
||||
variant="outlined"
|
||||
@@ -336,7 +309,7 @@ const ApplicationSettings = () => {
|
||||
</Grid>
|
||||
<Grid>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
fieldErrors={fieldErrors}
|
||||
name="syslog_mark_interval"
|
||||
label={LL.MARK_INTERVAL()}
|
||||
slotProps={{
|
||||
@@ -495,7 +468,7 @@ const ApplicationSettings = () => {
|
||||
margin="normal"
|
||||
select
|
||||
>
|
||||
{boardProfileItems}
|
||||
{boardProfileSelectItems()}
|
||||
<Divider />
|
||||
<MenuItem key={'CUSTOM'} value={'CUSTOM'}>
|
||||
{LL.CUSTOM()}…
|
||||
@@ -506,7 +479,7 @@ const ApplicationSettings = () => {
|
||||
<Grid container spacing={2} rowSpacing={0}>
|
||||
<Grid>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
fieldErrors={fieldErrors}
|
||||
name="rx_gpio"
|
||||
label={LL.GPIO_OF('Rx')}
|
||||
fullWidth
|
||||
@@ -519,7 +492,7 @@ const ApplicationSettings = () => {
|
||||
</Grid>
|
||||
<Grid>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
fieldErrors={fieldErrors}
|
||||
name="tx_gpio"
|
||||
label={LL.GPIO_OF('Tx')}
|
||||
fullWidth
|
||||
@@ -532,7 +505,7 @@ const ApplicationSettings = () => {
|
||||
</Grid>
|
||||
<Grid>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
fieldErrors={fieldErrors}
|
||||
name="pbutton_gpio"
|
||||
label={LL.GPIO_OF(LL.BUTTON())}
|
||||
fullWidth
|
||||
@@ -545,7 +518,7 @@ const ApplicationSettings = () => {
|
||||
</Grid>
|
||||
<Grid>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
fieldErrors={fieldErrors}
|
||||
name="dallas_gpio"
|
||||
label={
|
||||
LL.GPIO_OF(LL.TEMPERATURE()) + ' (0=' + LL.DISABLED(1) + ')'
|
||||
@@ -560,7 +533,7 @@ const ApplicationSettings = () => {
|
||||
</Grid>
|
||||
<Grid>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
fieldErrors={fieldErrors}
|
||||
name="led_gpio"
|
||||
label={LL.GPIO_OF('LED') + ' (0=' + LL.DISABLED(1) + ')'}
|
||||
fullWidth
|
||||
@@ -571,23 +544,6 @@ const ApplicationSettings = () => {
|
||||
margin="normal"
|
||||
/>
|
||||
</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>
|
||||
<TextField
|
||||
name="phy_type"
|
||||
@@ -764,7 +720,7 @@ const ApplicationSettings = () => {
|
||||
{data.remote_timeout_en && (
|
||||
<Box mt={2}>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
fieldErrors={fieldErrors}
|
||||
name="remote_timeout"
|
||||
label={LL.REMOTE_TIMEOUT()}
|
||||
slotProps={{
|
||||
@@ -804,7 +760,7 @@ const ApplicationSettings = () => {
|
||||
{data.shower_timer && (
|
||||
<Grid>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
fieldErrors={fieldErrors}
|
||||
name="shower_min_duration"
|
||||
label={LL.MIN_DURATION()}
|
||||
slotProps={{
|
||||
@@ -822,7 +778,7 @@ const ApplicationSettings = () => {
|
||||
<>
|
||||
<Grid>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
fieldErrors={fieldErrors}
|
||||
name="shower_alert_trigger"
|
||||
label={LL.TRIGGER_TIME()}
|
||||
slotProps={{
|
||||
@@ -838,7 +794,7 @@ const ApplicationSettings = () => {
|
||||
</Grid>
|
||||
<Grid>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
fieldErrors={fieldErrors}
|
||||
name="shower_alert_coldshot"
|
||||
label={LL.COLD_SHOT_DURATION()}
|
||||
slotProps={{
|
||||
@@ -897,7 +853,7 @@ const ApplicationSettings = () => {
|
||||
return (
|
||||
<SectionContent>
|
||||
{blocker ? <BlockNavigation blocker={blocker} /> : null}
|
||||
{restarting ? <SystemMonitor /> : content()}
|
||||
{restarting ? <RestartMonitor /> : content()}
|
||||
</SectionContent>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import DownloadIcon from '@mui/icons-material/GetApp';
|
||||
import { Box, Button, Grid, Typography } from '@mui/material';
|
||||
import { Box, Button, Typography } from '@mui/material';
|
||||
import Grid from '@mui/material/Grid2';
|
||||
|
||||
import * as SystemApi from 'api/system';
|
||||
import { API, callAction } from 'api/app';
|
||||
|
||||
import { useRequest } from 'alova/client';
|
||||
import type { APIcall } from 'app/main/types';
|
||||
import SystemMonitor from 'app/status/SystemMonitor';
|
||||
import RestartMonitor from 'app/status/RestartMonitor';
|
||||
import {
|
||||
FormLoader,
|
||||
SectionContent,
|
||||
@@ -19,13 +20,6 @@ import {
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
import { saveFile } from 'utils';
|
||||
|
||||
interface DownloadButton {
|
||||
key: string;
|
||||
type: string;
|
||||
label: string | number;
|
||||
isGridButton: boolean;
|
||||
}
|
||||
|
||||
const DownloadUpload = () => {
|
||||
const { LL } = useI18nContext();
|
||||
|
||||
@@ -42,7 +36,7 @@ const DownloadUpload = () => {
|
||||
toast.info(LL.DOWNLOAD_SUCCESSFUL());
|
||||
})
|
||||
.onError((error) => {
|
||||
toast.error(String(error.error?.message || 'An error occurred'));
|
||||
toast.error(error.message);
|
||||
});
|
||||
|
||||
const { send: sendAPI } = useRequest((data: APIcall) => API(data), {
|
||||
@@ -51,126 +45,86 @@ const DownloadUpload = () => {
|
||||
|
||||
const { data, send: loadData, error } = useRequest(SystemApi.readSystemStatus);
|
||||
|
||||
const doRestart = useCallback(async () => {
|
||||
const doRestart = async () => {
|
||||
setRestarting(true);
|
||||
try {
|
||||
await sendAPI({ device: 'system', cmd: 'restart', id: 0 });
|
||||
} catch (error) {
|
||||
toast.error((error as Error).message);
|
||||
setRestarting(false);
|
||||
}
|
||||
}, [sendAPI]);
|
||||
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
|
||||
(error: Error) => {
|
||||
toast.error(error.message);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
useLayoutTitle(LL.DOWNLOAD_UPLOAD());
|
||||
|
||||
const downloadButtons: DownloadButton[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'settings',
|
||||
type: 'settings',
|
||||
label: LL.SETTINGS_OF(LL.APPLICATION()),
|
||||
isGridButton: true
|
||||
},
|
||||
{
|
||||
key: 'customizations',
|
||||
type: 'customizations',
|
||||
label: LL.CUSTOMIZATIONS(),
|
||||
isGridButton: true
|
||||
},
|
||||
{
|
||||
key: 'entities',
|
||||
type: 'entities',
|
||||
label: LL.CUSTOM_ENTITIES(0),
|
||||
isGridButton: true
|
||||
},
|
||||
{
|
||||
key: 'schedule',
|
||||
type: 'schedule',
|
||||
label: LL.SCHEDULE(0),
|
||||
isGridButton: true
|
||||
},
|
||||
{
|
||||
key: 'allvalues',
|
||||
type: 'allvalues',
|
||||
label: LL.ALLVALUES(),
|
||||
isGridButton: false
|
||||
}
|
||||
],
|
||||
[LL]
|
||||
);
|
||||
const content = () => {
|
||||
if (!data) {
|
||||
return <FormLoader onRetry={loadData} errorMessage={error?.message} />;
|
||||
}
|
||||
|
||||
const handleDownload = useCallback(
|
||||
(type: string) => () => {
|
||||
void sendExportData(type);
|
||||
},
|
||||
[sendExportData]
|
||||
);
|
||||
|
||||
if (restarting) {
|
||||
return <SystemMonitor />;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<SectionContent>
|
||||
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
|
||||
</SectionContent>
|
||||
);
|
||||
}
|
||||
<>
|
||||
<Typography sx={{ pb: 2 }} variant="h6" color="primary">
|
||||
{LL.DOWNLOAD(0)}
|
||||
</Typography>
|
||||
|
||||
const gridButtons = downloadButtons.filter((btn) => btn.isGridButton);
|
||||
const standaloneButton = downloadButtons.find((btn) => !btn.isGridButton);
|
||||
<Typography mb={1} variant="body1" color="warning">
|
||||
{LL.DOWNLOAD_SETTINGS_TEXT()}.
|
||||
</Typography>
|
||||
<Grid container spacing={1}>
|
||||
<Button
|
||||
sx={{ ml: 2 }}
|
||||
startIcon={<DownloadIcon />}
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={() => sendExportData('settings')}
|
||||
>
|
||||
{LL.SETTINGS_OF(LL.APPLICATION())}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
sx={{ ml: 2 }}
|
||||
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>
|
||||
|
||||
<Typography sx={{ pt: 2, pb: 2 }} variant="h6" color="primary">
|
||||
{LL.UPLOAD()}
|
||||
</Typography>
|
||||
|
||||
<Box color="warning.main" sx={{ pb: 2 }}>
|
||||
<Typography variant="body1">{LL.UPLOAD_TEXT()}.</Typography>
|
||||
</Box>
|
||||
|
||||
<SingleUpload doRestart={doRestart} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionContent>
|
||||
<Typography sx={{ pb: 2 }} variant="h6" color="primary">
|
||||
{LL.DOWNLOAD(0)}
|
||||
</Typography>
|
||||
|
||||
<Typography mb={1} variant="body1" color="warning">
|
||||
{LL.DOWNLOAD_SETTINGS_TEXT()}.
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
{gridButtons.map((button) => (
|
||||
<Grid key={button.key}>
|
||||
<Button
|
||||
startIcon={<DownloadIcon />}
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={handleDownload(button.type)}
|
||||
>
|
||||
{button.label}
|
||||
</Button>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
<Typography mt={2} mb={1} variant="body1" color="warning">
|
||||
{LL.DOWNLOAD_SETTINGS_TEXT2()}.
|
||||
</Typography>
|
||||
|
||||
{standaloneButton && (
|
||||
<Button
|
||||
startIcon={<DownloadIcon />}
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={handleDownload(standaloneButton.type)}
|
||||
>
|
||||
{standaloneButton.label}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Typography sx={{ pt: 2, pb: 2 }} variant="h6" color="primary">
|
||||
{LL.UPLOAD()}
|
||||
</Typography>
|
||||
|
||||
<Box color="warning.main" sx={{ pb: 2 }}>
|
||||
<Typography variant="body1">{LL.UPLOAD_TEXT()}.</Typography>
|
||||
</Box>
|
||||
|
||||
<SingleUpload text={LL.UPLOAD_DRAG()} doRestart={doRestart} />
|
||||
</SectionContent>
|
||||
<SectionContent>{restarting ? <RestartMonitor /> : content()}</SectionContent>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import WarningIcon from '@mui/icons-material/Warning';
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Grid,
|
||||
InputAdornment,
|
||||
MenuItem,
|
||||
TextField,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
import Grid from '@mui/material/Grid2';
|
||||
|
||||
import * as MqttApi from 'api/mqtt';
|
||||
|
||||
@@ -48,71 +48,37 @@ const MqttSettings = () => {
|
||||
});
|
||||
|
||||
const { LL } = useI18nContext();
|
||||
useLayoutTitle('MQTT');
|
||||
useLayoutTitle(LL.SETTINGS_OF('MQTT'));
|
||||
|
||||
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
||||
|
||||
const updateFormValue = useMemo(
|
||||
() =>
|
||||
updateValueDirty(
|
||||
origData as unknown as Record<string, unknown>,
|
||||
dirtyFlags,
|
||||
setDirtyFlags,
|
||||
updateDataValue as (value: unknown) => void
|
||||
),
|
||||
[origData, dirtyFlags, setDirtyFlags, updateDataValue]
|
||||
const updateFormValue = updateValueDirty(
|
||||
origData,
|
||||
dirtyFlags,
|
||||
setDirtyFlags,
|
||||
updateDataValue
|
||||
);
|
||||
|
||||
const SecondsInputProps = useMemo(
|
||||
() => ({
|
||||
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
|
||||
}),
|
||||
[LL]
|
||||
);
|
||||
const SecondsInputProps = {
|
||||
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
|
||||
};
|
||||
|
||||
const emptyFieldErrors = useMemo(() => ({}), []);
|
||||
|
||||
const validateAndSubmit = useCallback(async () => {
|
||||
if (!data) return;
|
||||
try {
|
||||
setFieldErrors(undefined);
|
||||
await validate(createMqttSettingsValidator(data), data);
|
||||
await saveData();
|
||||
} catch (error) {
|
||||
setFieldErrors(error as ValidateFieldsError);
|
||||
const content = () => {
|
||||
if (!data) {
|
||||
return <FormLoader onRetry={loadData} errorMessage={errorMessage} />;
|
||||
}
|
||||
}, [data, saveData]);
|
||||
|
||||
const publishIntervalFields = useMemo(
|
||||
() => [
|
||||
{ name: 'publish_time_heartbeat', label: 'Heartbeat', validated: true },
|
||||
{ name: 'publish_time_boiler', label: LL.MQTT_INT_BOILER(), validated: false },
|
||||
{
|
||||
name: 'publish_time_thermostat',
|
||||
label: LL.MQTT_INT_THERMOSTATS(),
|
||||
validated: false
|
||||
},
|
||||
{ name: 'publish_time_solar', label: LL.MQTT_INT_SOLAR(), validated: false },
|
||||
{ name: 'publish_time_mixer', label: LL.MQTT_INT_MIXER(), validated: false },
|
||||
{ name: 'publish_time_water', label: LL.MQTT_INT_WATER(), validated: false },
|
||||
{ name: 'publish_time_sensor', label: LL.SENSORS(), validated: false },
|
||||
{ name: 'publish_time_other', label: LL.DEFAULT(0), validated: false }
|
||||
],
|
||||
[LL]
|
||||
);
|
||||
const validateAndSubmit = async () => {
|
||||
try {
|
||||
setFieldErrors(undefined);
|
||||
await validate(createMqttSettingsValidator(data), data);
|
||||
await saveData();
|
||||
} catch (error) {
|
||||
setFieldErrors(error as ValidateFieldsError);
|
||||
}
|
||||
};
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<SectionContent>
|
||||
{blocker ? <BlockNavigation blocker={blocker} /> : null}
|
||||
<FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />
|
||||
</SectionContent>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionContent>
|
||||
{blocker ? <BlockNavigation blocker={blocker} /> : null}
|
||||
<>
|
||||
<BlockFormControlLabel
|
||||
control={
|
||||
@@ -127,7 +93,7 @@ const MqttSettings = () => {
|
||||
<Grid container spacing={2} rowSpacing={0}>
|
||||
<Grid>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors ?? emptyFieldErrors}
|
||||
fieldErrors={fieldErrors}
|
||||
name="host"
|
||||
label={LL.ADDRESS_OF(LL.BROKER())}
|
||||
multiline
|
||||
@@ -139,7 +105,7 @@ const MqttSettings = () => {
|
||||
</Grid>
|
||||
<Grid>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors ?? emptyFieldErrors}
|
||||
fieldErrors={fieldErrors}
|
||||
name="port"
|
||||
label="Port"
|
||||
variant="outlined"
|
||||
@@ -151,7 +117,7 @@ const MqttSettings = () => {
|
||||
</Grid>
|
||||
<Grid>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors ?? emptyFieldErrors}
|
||||
fieldErrors={fieldErrors}
|
||||
name="base"
|
||||
label={LL.BASE_TOPIC()}
|
||||
variant="outlined"
|
||||
@@ -163,7 +129,7 @@ const MqttSettings = () => {
|
||||
<Grid>
|
||||
<TextField
|
||||
name="client_id"
|
||||
label={`${LL.ID_OF(LL.CLIENT())} (${LL.OPTIONAL()})`}
|
||||
label={LL.ID_OF(LL.CLIENT()) + ' (' + LL.OPTIONAL() + ')'}
|
||||
variant="outlined"
|
||||
value={data.client_id}
|
||||
onChange={updateFormValue}
|
||||
@@ -192,7 +158,7 @@ const MqttSettings = () => {
|
||||
</Grid>
|
||||
<Grid>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors ?? emptyFieldErrors}
|
||||
fieldErrors={fieldErrors}
|
||||
name="keep_alive"
|
||||
label="Keep Alive"
|
||||
slotProps={{
|
||||
@@ -288,140 +254,219 @@ const MqttSettings = () => {
|
||||
}
|
||||
label={LL.MQTT_RESPONSE()}
|
||||
/>
|
||||
<Grid container spacing={2} rowSpacing={0}>
|
||||
<Grid>
|
||||
<BlockFormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
name="publish_single"
|
||||
checked={data.publish_single}
|
||||
onChange={updateFormValue}
|
||||
disabled={data.ha_enabled}
|
||||
/>
|
||||
}
|
||||
label={LL.MQTT_PUBLISH_TEXT_1()}
|
||||
/>
|
||||
</Grid>
|
||||
{data.publish_single && (
|
||||
{!data.ha_enabled && (
|
||||
<Grid container spacing={2} rowSpacing={0}>
|
||||
<Grid>
|
||||
<BlockFormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
name="publish_single2cmd"
|
||||
checked={data.publish_single2cmd}
|
||||
name="publish_single"
|
||||
checked={data.publish_single}
|
||||
onChange={updateFormValue}
|
||||
/>
|
||||
}
|
||||
label={LL.MQTT_PUBLISH_TEXT_2()}
|
||||
label={LL.MQTT_PUBLISH_TEXT_1()}
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
<Grid container spacing={2} rowSpacing={0}>
|
||||
<Grid>
|
||||
<BlockFormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
name="ha_enabled"
|
||||
checked={data.ha_enabled}
|
||||
onChange={updateFormValue}
|
||||
disabled={data.publish_single}
|
||||
{data.publish_single && (
|
||||
<Grid>
|
||||
<BlockFormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
name="publish_single2cmd"
|
||||
checked={data.publish_single2cmd}
|
||||
onChange={updateFormValue}
|
||||
/>
|
||||
}
|
||||
label={LL.MQTT_PUBLISH_TEXT_2()}
|
||||
/>
|
||||
}
|
||||
label={LL.MQTT_PUBLISH_TEXT_3()}
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
{data.ha_enabled && (
|
||||
<Grid container spacing={2} rowSpacing={0}>
|
||||
<Grid>
|
||||
<TextField
|
||||
name="discovery_type"
|
||||
label={LL.MQTT_PUBLISH_TEXT_5()}
|
||||
value={data.discovery_type}
|
||||
variant="outlined"
|
||||
onChange={updateFormValue}
|
||||
margin="normal"
|
||||
select
|
||||
>
|
||||
<MenuItem value={0}>Home Assistant</MenuItem>
|
||||
<MenuItem value={1}>Domoticz</MenuItem>
|
||||
<MenuItem value={2}>Domoticz (latest)</MenuItem>
|
||||
</TextField>
|
||||
</Grid>
|
||||
<Grid>
|
||||
<TextField
|
||||
name="discovery_prefix"
|
||||
label={LL.MQTT_PUBLISH_TEXT_4()}
|
||||
variant="outlined"
|
||||
value={data.discovery_prefix}
|
||||
onChange={updateFormValue}
|
||||
margin="normal"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid>
|
||||
<TextField
|
||||
name="entity_format"
|
||||
label={LL.MQTT_ENTITY_FORMAT()}
|
||||
value={data.entity_format}
|
||||
variant="outlined"
|
||||
onChange={updateFormValue}
|
||||
margin="normal"
|
||||
select
|
||||
>
|
||||
<MenuItem value={0}>{LL.MQTT_ENTITY_FORMAT_0()}</MenuItem>
|
||||
<MenuItem value={3}>
|
||||
{LL.MQTT_ENTITY_FORMAT_1()} (v3.6)
|
||||
</MenuItem>
|
||||
<MenuItem value={4}>
|
||||
{LL.MQTT_ENTITY_FORMAT_2()} (v3.6)
|
||||
</MenuItem>
|
||||
<MenuItem value={1}>{LL.MQTT_ENTITY_FORMAT_1()}</MenuItem>
|
||||
<MenuItem value={2}>{LL.MQTT_ENTITY_FORMAT_2()}</MenuItem>
|
||||
</TextField>
|
||||
</Grid>
|
||||
)}
|
||||
{!data.publish_single && (
|
||||
<Grid container spacing={2} rowSpacing={0}>
|
||||
<Grid>
|
||||
<BlockFormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
name="ha_enabled"
|
||||
checked={data.ha_enabled}
|
||||
onChange={updateFormValue}
|
||||
/>
|
||||
}
|
||||
label={LL.MQTT_PUBLISH_TEXT_3()}
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
{data.ha_enabled && (
|
||||
<Grid container spacing={2} rowSpacing={0}>
|
||||
<Grid>
|
||||
<TextField
|
||||
name="discovery_type"
|
||||
label={LL.MQTT_PUBLISH_TEXT_5()}
|
||||
value={data.discovery_type}
|
||||
variant="outlined"
|
||||
onChange={updateFormValue}
|
||||
margin="normal"
|
||||
select
|
||||
>
|
||||
<MenuItem value={0}>Home Assistant</MenuItem>
|
||||
<MenuItem value={1}>Domoticz</MenuItem>
|
||||
<MenuItem value={2}>Domoticz (latest)</MenuItem>
|
||||
</TextField>
|
||||
</Grid>
|
||||
<Grid>
|
||||
<TextField
|
||||
name="discovery_prefix"
|
||||
label={LL.MQTT_PUBLISH_TEXT_4()}
|
||||
variant="outlined"
|
||||
value={data.discovery_prefix}
|
||||
onChange={updateFormValue}
|
||||
margin="normal"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid>
|
||||
<TextField
|
||||
name="entity_format"
|
||||
label={LL.MQTT_ENTITY_FORMAT()}
|
||||
value={data.entity_format}
|
||||
variant="outlined"
|
||||
onChange={updateFormValue}
|
||||
margin="normal"
|
||||
select
|
||||
>
|
||||
<MenuItem value={0}>{LL.MQTT_ENTITY_FORMAT_0()}</MenuItem>
|
||||
<MenuItem value={3}>
|
||||
{LL.MQTT_ENTITY_FORMAT_1()} (v3.6)
|
||||
</MenuItem>
|
||||
<MenuItem value={4}>
|
||||
{LL.MQTT_ENTITY_FORMAT_2()} (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>
|
||||
)}
|
||||
<Typography sx={{ pt: 2 }} variant="h6" color="primary">
|
||||
{LL.MQTT_PUBLISH_INTERVALS()} (0=auto)
|
||||
</Typography>
|
||||
<Grid container spacing={2} rowSpacing={0}>
|
||||
{publishIntervalFields.map((field) => (
|
||||
<Grid key={field.name}>
|
||||
{field.validated ? (
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors ?? emptyFieldErrors}
|
||||
name={field.name}
|
||||
label={field.label}
|
||||
slotProps={{
|
||||
input: SecondsInputProps
|
||||
}}
|
||||
variant="outlined"
|
||||
value={numberValue(
|
||||
data[field.name as keyof MqttSettingsType] as number
|
||||
)}
|
||||
type="number"
|
||||
onChange={updateFormValue}
|
||||
margin="normal"
|
||||
/>
|
||||
) : (
|
||||
<TextField
|
||||
name={field.name}
|
||||
label={field.label}
|
||||
variant="outlined"
|
||||
value={numberValue(
|
||||
data[field.name as keyof MqttSettingsType] as number
|
||||
)}
|
||||
type="number"
|
||||
onChange={updateFormValue}
|
||||
margin="normal"
|
||||
slotProps={{
|
||||
input: SecondsInputProps
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Grid>
|
||||
))}
|
||||
<Grid>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors}
|
||||
name="publish_time_heartbeat"
|
||||
label="Heartbeat"
|
||||
slotProps={{
|
||||
input: SecondsInputProps
|
||||
}}
|
||||
variant="outlined"
|
||||
value={numberValue(data.publish_time_heartbeat)}
|
||||
type="number"
|
||||
onChange={updateFormValue}
|
||||
margin="normal"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid>
|
||||
<TextField
|
||||
name="publish_time_boiler"
|
||||
label={LL.MQTT_INT_BOILER()}
|
||||
variant="outlined"
|
||||
value={numberValue(data.publish_time_boiler)}
|
||||
type="number"
|
||||
onChange={updateFormValue}
|
||||
margin="normal"
|
||||
slotProps={{
|
||||
input: SecondsInputProps
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid>
|
||||
<TextField
|
||||
name="publish_time_thermostat"
|
||||
label={LL.MQTT_INT_THERMOSTATS()}
|
||||
variant="outlined"
|
||||
value={numberValue(data.publish_time_thermostat)}
|
||||
type="number"
|
||||
onChange={updateFormValue}
|
||||
margin="normal"
|
||||
slotProps={{
|
||||
input: SecondsInputProps
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid>
|
||||
<TextField
|
||||
name="publish_time_solar"
|
||||
label={LL.MQTT_INT_SOLAR()}
|
||||
variant="outlined"
|
||||
value={numberValue(data.publish_time_solar)}
|
||||
type="number"
|
||||
onChange={updateFormValue}
|
||||
margin="normal"
|
||||
slotProps={{
|
||||
input: SecondsInputProps
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid>
|
||||
<TextField
|
||||
name="publish_time_mixer"
|
||||
label={LL.MQTT_INT_MIXER()}
|
||||
variant="outlined"
|
||||
value={numberValue(data.publish_time_mixer)}
|
||||
type="number"
|
||||
onChange={updateFormValue}
|
||||
margin="normal"
|
||||
slotProps={{
|
||||
input: SecondsInputProps
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid>
|
||||
<TextField
|
||||
name="publish_time_water"
|
||||
label={LL.MQTT_INT_WATER()}
|
||||
variant="outlined"
|
||||
value={numberValue(data.publish_time_water)}
|
||||
type="number"
|
||||
onChange={updateFormValue}
|
||||
margin="normal"
|
||||
slotProps={{
|
||||
input: SecondsInputProps
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid>
|
||||
<TextField
|
||||
name="publish_time_sensor"
|
||||
label={LL.TEMP_SENSORS()}
|
||||
variant="outlined"
|
||||
value={numberValue(data.publish_time_sensor)}
|
||||
type="number"
|
||||
onChange={updateFormValue}
|
||||
margin="normal"
|
||||
slotProps={{
|
||||
input: SecondsInputProps
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid>
|
||||
<TextField
|
||||
name="publish_time_other"
|
||||
label={LL.DEFAULT(0)}
|
||||
variant="outlined"
|
||||
value={numberValue(data.publish_time_other)}
|
||||
type="number"
|
||||
onChange={updateFormValue}
|
||||
margin="normal"
|
||||
slotProps={{
|
||||
input: SecondsInputProps
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
{dirtyFlags && dirtyFlags.length !== 0 && (
|
||||
<ButtonRow>
|
||||
@@ -448,6 +493,13 @@ const MqttSettings = () => {
|
||||
</ButtonRow>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionContent>
|
||||
{blocker ? <BlockNavigation blocker={blocker} /> : null}
|
||||
{content()}
|
||||
</SectionContent>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,27 +1,12 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useState } from 'react';
|
||||
|
||||
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import WarningIcon from '@mui/icons-material/Warning';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
MenuItem,
|
||||
TextField,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
import { Button, Checkbox, MenuItem } from '@mui/material';
|
||||
|
||||
import * as NTPApi from 'api/ntp';
|
||||
import { readNTPSettings } from 'api/ntp';
|
||||
|
||||
import { dialogStyle } from 'CustomTheme';
|
||||
import { useRequest } from 'alova/client';
|
||||
import { updateState } from 'alova/client';
|
||||
import type { ValidateFieldsError } from 'async-validator';
|
||||
import {
|
||||
@@ -34,12 +19,12 @@ import {
|
||||
useLayoutTitle
|
||||
} from 'components';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
import type { NTPSettingsType, Time } from 'types';
|
||||
import { formatLocalDateTime, updateValueDirty, useRest } from 'utils';
|
||||
import type { NTPSettingsType } from 'types';
|
||||
import { updateValueDirty, useRest } from 'utils';
|
||||
import { validate } from 'validators';
|
||||
import { NTP_SETTINGS_VALIDATOR } from 'validators/ntp';
|
||||
|
||||
import { TIME_ZONES, selectedTimeZone, useTimeZoneSelectItems } from './TZ';
|
||||
import { TIME_ZONES, selectedTimeZone, timeZoneSelectItems } from './TZ';
|
||||
|
||||
const NTPSettings = () => {
|
||||
const {
|
||||
@@ -59,102 +44,40 @@ const NTPSettings = () => {
|
||||
});
|
||||
|
||||
const { LL } = useI18nContext();
|
||||
useLayoutTitle('NTP');
|
||||
useLayoutTitle(LL.SETTINGS_OF('NTP'));
|
||||
|
||||
// Memoized timezone select items for better performance
|
||||
const timeZoneItems = useTimeZoneSelectItems();
|
||||
|
||||
// Memoized selected timezone value
|
||||
const selectedTzValue = useMemo(
|
||||
() => (data ? selectedTimeZone(data.tz_label, data.tz_format) : undefined),
|
||||
[data?.tz_label, data?.tz_format]
|
||||
const updateFormValue = updateValueDirty(
|
||||
origData,
|
||||
dirtyFlags,
|
||||
setDirtyFlags,
|
||||
updateDataValue
|
||||
);
|
||||
|
||||
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 content = () => {
|
||||
if (!data) {
|
||||
return <FormLoader onRetry={loadData} errorMessage={errorMessage} />;
|
||||
}
|
||||
);
|
||||
|
||||
// Memoize updateFormValue to prevent recreation on every render
|
||||
const updateFormValue = useMemo(
|
||||
() =>
|
||||
updateValueDirty(
|
||||
origData as unknown as Record<string, unknown>,
|
||||
dirtyFlags,
|
||||
setDirtyFlags,
|
||||
updateDataValue as (value: unknown) => void
|
||||
),
|
||||
[origData, dirtyFlags, setDirtyFlags, updateDataValue]
|
||||
);
|
||||
const validateAndSubmit = async () => {
|
||||
try {
|
||||
setFieldErrors(undefined);
|
||||
await validate(NTP_SETTINGS_VALIDATOR, data);
|
||||
await saveData();
|
||||
} catch (error) {
|
||||
setFieldErrors(error as ValidateFieldsError);
|
||||
}
|
||||
};
|
||||
|
||||
// Memoize updateLocalTime handler
|
||||
const updateLocalTime = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => setLocalTime(event.target.value),
|
||||
[]
|
||||
);
|
||||
|
||||
// Memoize openSetTime handler
|
||||
const openSetTime = useCallback(() => {
|
||||
setLocalTime(formatLocalDateTime(new Date()));
|
||||
setSettingTime(true);
|
||||
}, []);
|
||||
|
||||
// Memoize configureTime handler
|
||||
const configureTime = useCallback(async () => {
|
||||
setProcessing(true);
|
||||
|
||||
try {
|
||||
await updateTime({ local_time: formatLocalDateTime(new Date(localTime)) });
|
||||
toast.success(LL.TIME_SET());
|
||||
setSettingTime(false);
|
||||
await loadData();
|
||||
} catch {
|
||||
toast.error(LL.PROBLEM_UPDATING());
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
}, [localTime, updateTime, LL, loadData]);
|
||||
|
||||
// Memoize close dialog handler
|
||||
const handleCloseSetTime = useCallback(() => setSettingTime(false), []);
|
||||
|
||||
// Memoize validate and submit handler
|
||||
const validateAndSubmit = useCallback(async () => {
|
||||
if (!data) return;
|
||||
try {
|
||||
setFieldErrors(undefined);
|
||||
await validate(NTP_SETTINGS_VALIDATOR, data);
|
||||
await saveData();
|
||||
} catch (error) {
|
||||
setFieldErrors(error as ValidateFieldsError);
|
||||
}
|
||||
}, [data, saveData]);
|
||||
|
||||
// Memoize timezone change handler
|
||||
const changeTimeZone = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
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]
|
||||
}));
|
||||
updateFormValue(event);
|
||||
},
|
||||
[updateFormValue]
|
||||
);
|
||||
|
||||
// Memoize render content to prevent unnecessary re-renders
|
||||
const renderContent = useMemo(() => {
|
||||
if (!data) {
|
||||
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -169,7 +92,7 @@ const NTPSettings = () => {
|
||||
label={LL.ENABLE_NTP()}
|
||||
/>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
fieldErrors={fieldErrors}
|
||||
name="server"
|
||||
label={LL.NTP_SERVER()}
|
||||
fullWidth
|
||||
@@ -179,37 +102,19 @@ const NTPSettings = () => {
|
||||
margin="normal"
|
||||
/>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
fieldErrors={fieldErrors}
|
||||
name="tz_label"
|
||||
label={LL.TIME_ZONE()}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={selectedTzValue}
|
||||
value={selectedTimeZone(data.tz_label, data.tz_format)}
|
||||
onChange={changeTimeZone}
|
||||
margin="normal"
|
||||
select
|
||||
>
|
||||
<MenuItem disabled>{LL.TIME_ZONE()}...</MenuItem>
|
||||
{timeZoneItems}
|
||||
{timeZoneSelectItems()}
|
||||
</ValidatedTextField>
|
||||
|
||||
<Box display="flex" flexWrap="wrap">
|
||||
{!data.enabled && !dirtyFlags.length && (
|
||||
<Box flexWrap="nowrap" whiteSpace="nowrap">
|
||||
<ButtonRow>
|
||||
<Button
|
||||
onClick={openSetTime}
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
startIcon={<AccessTimeIcon />}
|
||||
>
|
||||
{LL.SET_TIME(0)}
|
||||
</Button>
|
||||
</ButtonRow>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{dirtyFlags && dirtyFlags.length !== 0 && (
|
||||
<ButtonRow>
|
||||
<Button
|
||||
@@ -236,66 +141,12 @@ const NTPSettings = () => {
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}, [
|
||||
data,
|
||||
errorMessage,
|
||||
loadData,
|
||||
updateFormValue,
|
||||
fieldErrors,
|
||||
selectedTzValue,
|
||||
changeTimeZone,
|
||||
timeZoneItems,
|
||||
dirtyFlags,
|
||||
openSetTime,
|
||||
saving,
|
||||
validateAndSubmit,
|
||||
LL
|
||||
]);
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionContent>
|
||||
{blocker ? <BlockNavigation blocker={blocker} /> : null}
|
||||
{renderContent}
|
||||
<Dialog sx={dialogStyle} open={settingTime} onClose={handleCloseSetTime}>
|
||||
<DialogTitle>{LL.SET_TIME(1)}</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<Box color="warning.main" p={0} pl={0} pr={0} mt={0} mb={2}>
|
||||
<Typography variant="body2">{LL.SET_TIME_TEXT()}</Typography>
|
||||
</Box>
|
||||
<TextField
|
||||
label={LL.LOCAL_TIME(0)}
|
||||
type="datetime-local"
|
||||
value={localTime}
|
||||
onChange={updateLocalTime}
|
||||
disabled={processing}
|
||||
fullWidth
|
||||
slotProps={{
|
||||
inputLabel: {
|
||||
shrink: true
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
startIcon={<CancelIcon />}
|
||||
variant="outlined"
|
||||
onClick={handleCloseSetTime}
|
||||
color="secondary"
|
||||
>
|
||||
{LL.CANCEL()}
|
||||
</Button>
|
||||
<Button
|
||||
startIcon={<AccessTimeIcon />}
|
||||
variant="outlined"
|
||||
onClick={configureTime}
|
||||
disabled={processing}
|
||||
color="primary"
|
||||
>
|
||||
{LL.UPDATE()}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
{content()}
|
||||
</SectionContent>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
||||
import BuildIcon from '@mui/icons-material/Build';
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import DeviceHubIcon from '@mui/icons-material/DeviceHub';
|
||||
import ImportExportIcon from '@mui/icons-material/ImportExport';
|
||||
@@ -17,11 +18,10 @@ import {
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Divider,
|
||||
List
|
||||
} from '@mui/material';
|
||||
|
||||
import { API } from 'api/app';
|
||||
import { API, callAction } from 'api/app';
|
||||
|
||||
import { dialogStyle } from 'CustomTheme';
|
||||
import { useRequest } from 'alova/client';
|
||||
@@ -30,159 +30,147 @@ import { SectionContent, useLayoutTitle } from 'components';
|
||||
import ListMenuItem from 'components/layout/ListMenuItem';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
|
||||
import SystemMonitor from '../status/SystemMonitor';
|
||||
|
||||
const Settings = () => {
|
||||
const { LL } = useI18nContext();
|
||||
useLayoutTitle(LL.SETTINGS(0));
|
||||
|
||||
const [confirmFactoryReset, setConfirmFactoryReset] = useState(false);
|
||||
const [restarting, setRestarting] = useState<boolean>();
|
||||
const [confirmFactoryReset, setConfirmFactoryReset] = useState<boolean>(false);
|
||||
|
||||
const { send: sendAPI } = useRequest((data: APIcall) => API(data), {
|
||||
immediate: false
|
||||
});
|
||||
|
||||
const doFormat = useCallback(async () => {
|
||||
// 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(() => {
|
||||
setRestarting(true);
|
||||
setConfirmFactoryReset(false);
|
||||
});
|
||||
}, [sendAPI]);
|
||||
};
|
||||
|
||||
const handleFactoryResetClose = useCallback(() => {
|
||||
setConfirmFactoryReset(false);
|
||||
}, []);
|
||||
|
||||
const handleFactoryResetClick = useCallback(() => {
|
||||
setConfirmFactoryReset(true);
|
||||
}, []);
|
||||
|
||||
const content = useMemo(() => {
|
||||
return (
|
||||
<>
|
||||
<List>
|
||||
<ListMenuItem
|
||||
icon={TuneIcon}
|
||||
bgcolor="#134ba2"
|
||||
label={LL.APPLICATION()}
|
||||
text={LL.APPLICATION_SETTINGS_1()}
|
||||
to="application"
|
||||
/>
|
||||
|
||||
<ListMenuItem
|
||||
icon={SettingsEthernetIcon}
|
||||
bgcolor="#40828f"
|
||||
label={LL.NETWORK(0)}
|
||||
text={LL.CONFIGURE(LL.SETTINGS_OF(LL.NETWORK(1)))}
|
||||
to="network"
|
||||
/>
|
||||
|
||||
<ListMenuItem
|
||||
icon={SettingsInputAntennaIcon}
|
||||
bgcolor="#5f9a5f"
|
||||
label={LL.ACCESS_POINT(0)}
|
||||
text={LL.CONFIGURE(LL.ACCESS_POINT(1))}
|
||||
to="ap"
|
||||
/>
|
||||
|
||||
<ListMenuItem
|
||||
icon={AccessTimeIcon}
|
||||
bgcolor="#c5572c"
|
||||
label="NTP"
|
||||
text={LL.CONFIGURE(LL.LOCAL_TIME(1))}
|
||||
to="ntp"
|
||||
/>
|
||||
|
||||
<ListMenuItem
|
||||
icon={DeviceHubIcon}
|
||||
bgcolor="#68374d"
|
||||
label="MQTT"
|
||||
text={LL.CONFIGURE('MQTT')}
|
||||
to="mqtt"
|
||||
/>
|
||||
|
||||
<ListMenuItem
|
||||
icon={LockIcon}
|
||||
label={LL.SECURITY(0)}
|
||||
text={LL.SECURITY_1()}
|
||||
to="security"
|
||||
/>
|
||||
|
||||
<ListMenuItem
|
||||
icon={ViewModuleIcon}
|
||||
bgcolor="#efc34b"
|
||||
label={LL.MODULES()}
|
||||
text={LL.MODULES_1()}
|
||||
to="modules"
|
||||
/>
|
||||
|
||||
<ListMenuItem
|
||||
icon={ImportExportIcon}
|
||||
bgcolor="#5d89f7"
|
||||
label={LL.DOWNLOAD_UPLOAD()}
|
||||
text={LL.DOWNLOAD_UPLOAD_1()}
|
||||
to="downloadUpload"
|
||||
/>
|
||||
</List>
|
||||
|
||||
<Dialog
|
||||
sx={dialogStyle}
|
||||
open={confirmFactoryReset}
|
||||
onClose={handleFactoryResetClose}
|
||||
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"
|
||||
>
|
||||
<DialogTitle>{LL.FACTORY_RESET()}</DialogTitle>
|
||||
<DialogContent dividers>{LL.SYSTEM_FACTORY_TEXT_DIALOG()}</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
startIcon={<CancelIcon />}
|
||||
variant="outlined"
|
||||
onClick={handleFactoryResetClose}
|
||||
color="secondary"
|
||||
>
|
||||
{LL.CANCEL()}
|
||||
</Button>
|
||||
<Button
|
||||
startIcon={<SettingsBackupRestoreIcon />}
|
||||
variant="outlined"
|
||||
onClick={doFormat}
|
||||
color="error"
|
||||
>
|
||||
{LL.FACTORY_RESET()}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box
|
||||
mt={2}
|
||||
display="flex"
|
||||
justifyContent="flex-end"
|
||||
flexWrap="nowrap"
|
||||
whiteSpace="nowrap"
|
||||
{LL.CANCEL()}
|
||||
</Button>
|
||||
<Button
|
||||
startIcon={<SettingsBackupRestoreIcon />}
|
||||
variant="outlined"
|
||||
onClick={doFormat}
|
||||
color="error"
|
||||
>
|
||||
<Button
|
||||
startIcon={<SettingsBackupRestoreIcon />}
|
||||
variant="outlined"
|
||||
onClick={handleFactoryResetClick}
|
||||
color="error"
|
||||
>
|
||||
{LL.FACTORY_RESET()}
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}, [
|
||||
LL,
|
||||
handleFactoryResetClick,
|
||||
handleFactoryResetClose,
|
||||
doFormat,
|
||||
confirmFactoryReset,
|
||||
restarting
|
||||
]);
|
||||
{LL.FACTORY_RESET()}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
return <SectionContent>{restarting ? <SystemMonitor /> : content}</SectionContent>;
|
||||
const content = () => (
|
||||
<>
|
||||
<List sx={{ borderRadius: 3, border: '2px solid grey' }}>
|
||||
<ListMenuItem
|
||||
icon={BuildIcon}
|
||||
bgcolor="#72caf9"
|
||||
label={LL.EMS_ESP_VER()}
|
||||
text={data.emsesp_version}
|
||||
to="version"
|
||||
/>
|
||||
|
||||
<ListMenuItem
|
||||
icon={TuneIcon}
|
||||
bgcolor="#134ba2"
|
||||
label={LL.APPLICATION()}
|
||||
text={LL.APPLICATION_SETTINGS_1()}
|
||||
to="application"
|
||||
/>
|
||||
|
||||
<ListMenuItem
|
||||
icon={SettingsEthernetIcon}
|
||||
bgcolor="#40828f"
|
||||
label={LL.NETWORK(0)}
|
||||
text={LL.CONFIGURE(LL.SETTINGS_OF(LL.NETWORK(1)))}
|
||||
to="network"
|
||||
/>
|
||||
|
||||
<ListMenuItem
|
||||
icon={SettingsInputAntennaIcon}
|
||||
bgcolor="#5f9a5f"
|
||||
label={LL.ACCESS_POINT(0)}
|
||||
text={LL.CONFIGURE(LL.ACCESS_POINT(1))}
|
||||
to="ap"
|
||||
/>
|
||||
|
||||
<ListMenuItem
|
||||
icon={AccessTimeIcon}
|
||||
bgcolor="#c5572c"
|
||||
label="NTP"
|
||||
text={LL.CONFIGURE(LL.LOCAL_TIME(1))}
|
||||
to="ntp"
|
||||
/>
|
||||
|
||||
<ListMenuItem
|
||||
icon={DeviceHubIcon}
|
||||
bgcolor="#68374d"
|
||||
label="MQTT"
|
||||
text={LL.CONFIGURE('MQTT')}
|
||||
to="mqtt"
|
||||
/>
|
||||
|
||||
<ListMenuItem
|
||||
icon={LockIcon}
|
||||
label={LL.SECURITY(0)}
|
||||
text={LL.SECURITY_1()}
|
||||
to="security"
|
||||
/>
|
||||
|
||||
<ListMenuItem
|
||||
icon={ViewModuleIcon}
|
||||
bgcolor="#efc34b"
|
||||
label={LL.MODULES()}
|
||||
text={LL.MODULES_1()}
|
||||
to="modules"
|
||||
/>
|
||||
|
||||
<ListMenuItem
|
||||
icon={ImportExportIcon}
|
||||
bgcolor="#5d89f7"
|
||||
label={LL.DOWNLOAD_UPLOAD()}
|
||||
text={LL.DOWNLOAD_UPLOAD_1()}
|
||||
to="upload"
|
||||
/>
|
||||
</List>
|
||||
|
||||
{renderFactoryResetDialog()}
|
||||
|
||||
<Box mt={2} display="flex" flexWrap="wrap">
|
||||
<Button
|
||||
startIcon={<SettingsBackupRestoreIcon />}
|
||||
variant="outlined"
|
||||
onClick={() => setConfirmFactoryReset(true)}
|
||||
color="error"
|
||||
>
|
||||
{LL.FACTORY_RESET()}
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
|
||||
return <SectionContent>{content()}</SectionContent>;
|
||||
};
|
||||
|
||||
export default Settings;
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { MenuItem } from '@mui/material';
|
||||
|
||||
type TimeZones = Record<string, string>;
|
||||
|
||||
export const TIME_ZONES: Readonly<TimeZones> = {
|
||||
export const TIME_ZONES: TimeZones = {
|
||||
'Africa/Abidjan': 'GMT0',
|
||||
'Africa/Accra': 'GMT0',
|
||||
'Africa/Addis_Ababa': 'EAT-3',
|
||||
@@ -467,33 +465,14 @@ export const TIME_ZONES: Readonly<TimeZones> = {
|
||||
'Pacific/Wallis': 'UNK-12'
|
||||
};
|
||||
|
||||
// Pre-compute sorted timezone labels for better performance
|
||||
export const TIME_ZONE_LABELS = Object.keys(TIME_ZONES).sort();
|
||||
|
||||
export function selectedTimeZone(label: string, format: string) {
|
||||
return TIME_ZONES[label] === format ? label : undefined;
|
||||
}
|
||||
|
||||
// Memoized version for use in components
|
||||
export function useTimeZoneSelectItems() {
|
||||
return useMemo(
|
||||
() =>
|
||||
TIME_ZONE_LABELS.map((label) => (
|
||||
<MenuItem key={label} value={label}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
)),
|
||||
[]
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback export for backward compatibility - now memoized
|
||||
const precomputedTimeZoneItems = TIME_ZONE_LABELS.map((label) => (
|
||||
<MenuItem key={label} value={label}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
));
|
||||
|
||||
export function timeZoneSelectItems() {
|
||||
return precomputedTimeZoneItems;
|
||||
return Object.keys(TIME_ZONES).map((label) => (
|
||||
<MenuItem key={label} value={label}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
));
|
||||
}
|
||||
|
||||
277
interface/src/app/settings/Version.tsx
Normal file
277
interface/src/app/settings/Version.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
|
||||
import WarningIcon from '@mui/icons-material/Warning';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Divider,
|
||||
Link,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
import Grid from '@mui/material/Grid2';
|
||||
|
||||
import * as SystemApi from 'api/system';
|
||||
import { callAction } from 'api/app';
|
||||
import { getDevVersion, getStableVersion } from 'api/system';
|
||||
|
||||
import { dialogStyle } from 'CustomTheme';
|
||||
import { useRequest } from 'alova/client';
|
||||
import RestartMonitor from 'app/status/RestartMonitor';
|
||||
import { FormLoader, SectionContent, useLayoutTitle } from 'components';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
|
||||
const Version = () => {
|
||||
const { LL } = useI18nContext();
|
||||
|
||||
const [restarting, setRestarting] = useState<boolean>(false);
|
||||
const [openDialog, setOpenDialog] = useState<boolean>(false);
|
||||
const [useDev, setUseDev] = useState<boolean>(false);
|
||||
const [upgradeAvailable, setUpgradeAvailable] = useState<boolean>(false);
|
||||
|
||||
const { send: sendCheckUpgrade } = useRequest(
|
||||
(version: string) => callAction({ action: 'checkUpgrade', param: version }),
|
||||
{
|
||||
immediate: false
|
||||
}
|
||||
).onSuccess((event) => {
|
||||
const data = event.data as { emsesp_version: string; upgradeable: boolean };
|
||||
setUpgradeAvailable(data.upgradeable);
|
||||
});
|
||||
|
||||
const { data, send: loadData, error } = useRequest(SystemApi.readSystemStatus);
|
||||
|
||||
const { send: sendUploadURL } = useRequest(
|
||||
(url: string) => callAction({ action: 'uploadURL', param: url }),
|
||||
{
|
||||
immediate: false
|
||||
}
|
||||
);
|
||||
|
||||
// called immediately to get the latest version, on page load
|
||||
const { data: latestVersion } = useRequest(getStableVersion, {
|
||||
// uncomment next 2 lines for testing, uses https://github.com/emsesp/EMS-ESP32/releases/download/v3.6.5/EMS-ESP-3_6_5-ESP32-16MB+.bin
|
||||
// immediate: false,
|
||||
// initialData: '3.6.5'
|
||||
});
|
||||
|
||||
// called immediately to get the latest version, on page load, then check for upgrade (works for both dev and stable)
|
||||
const { data: latestDevVersion } = useRequest(getDevVersion, {
|
||||
// uncomment next 2 lines for testing, uses https://github.com/emsesp/EMS-ESP32/releases/download/latest/EMS-ESP-3_7_0-dev_31-ESP32-16MB+.bin
|
||||
// immediate: false,
|
||||
// initialData: '3.7.0-dev.32'
|
||||
}).onSuccess((event) => {
|
||||
void sendCheckUpgrade(event.data);
|
||||
});
|
||||
|
||||
const STABLE_URL = 'https://github.com/emsesp/EMS-ESP32/releases/download/';
|
||||
const STABLE_RELNOTES_URL =
|
||||
'https://github.com/emsesp/EMS-ESP32/blob/main/CHANGELOG.md';
|
||||
|
||||
const DEV_URL = 'https://github.com/emsesp/EMS-ESP32/releases/download/latest/';
|
||||
const DEV_RELNOTES_URL =
|
||||
'https://github.com/emsesp/EMS-ESP32/blob/dev/CHANGELOG_LATEST.md';
|
||||
|
||||
const getBinURL = (useDevVersion: boolean) => {
|
||||
if (!latestVersion || !latestDevVersion) {
|
||||
return '';
|
||||
}
|
||||
const filename =
|
||||
'EMS-ESP-' +
|
||||
(useDevVersion ? latestDevVersion : latestVersion).replaceAll('.', '_') +
|
||||
'-' +
|
||||
getPlatform() +
|
||||
'.bin';
|
||||
return useDevVersion
|
||||
? DEV_URL + filename
|
||||
: STABLE_URL + 'v' + latestVersion + '/' + filename;
|
||||
};
|
||||
|
||||
const getPlatform = () => {
|
||||
return (
|
||||
[data.esp_platform, data.flash_chip_size >= 16384 ? '16MB' : '4MB'].join('-') +
|
||||
(data.psram ? '+' : '')
|
||||
);
|
||||
};
|
||||
|
||||
const installFirmwareURL = async (url: string) => {
|
||||
await sendUploadURL(url).catch((error: Error) => {
|
||||
toast.error(error.message);
|
||||
});
|
||||
setRestarting(true);
|
||||
};
|
||||
|
||||
useLayoutTitle(LL.EMS_ESP_VER());
|
||||
|
||||
const internet_live =
|
||||
latestDevVersion !== undefined && latestVersion !== undefined;
|
||||
|
||||
const renderUploadDialog = () => {
|
||||
if (!internet_live) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
sx={dialogStyle}
|
||||
open={openDialog}
|
||||
onClose={() => setOpenDialog(false)}
|
||||
>
|
||||
<DialogTitle>
|
||||
{LL.INSTALL('') +
|
||||
' ' +
|
||||
(useDev ? LL.DEVELOPMENT() : LL.STABLE()) +
|
||||
' Firmware'}
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<Typography mb={2}>
|
||||
{LL.INSTALL_VERSION(useDev ? latestDevVersion : latestVersion)}
|
||||
</Typography>
|
||||
<Link
|
||||
target="_blank"
|
||||
href={useDev ? DEV_RELNOTES_URL : STABLE_RELNOTES_URL}
|
||||
color="primary"
|
||||
>
|
||||
changelog
|
||||
</Link>
|
||||
|
|
||||
<Link target="_blank" href={getBinURL(useDev)} color="primary">
|
||||
{LL.DOWNLOAD(1)}
|
||||
</Link>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
startIcon={<CancelIcon />}
|
||||
variant="outlined"
|
||||
onClick={() => setOpenDialog(false)}
|
||||
color="secondary"
|
||||
>
|
||||
{LL.CANCEL()}
|
||||
</Button>
|
||||
<Button
|
||||
startIcon={<WarningIcon color="warning" />}
|
||||
variant="outlined"
|
||||
onClick={() => installFirmwareURL(getBinURL(useDev))}
|
||||
color="primary"
|
||||
>
|
||||
{LL.INSTALL('')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
// useDevVersion = true to force using the dev version
|
||||
const showFirmwareDialog = (useDevVersion: boolean) => {
|
||||
if (useDevVersion || data.emsesp_version.includes('dev')) {
|
||||
setUseDev(true);
|
||||
}
|
||||
setOpenDialog(true);
|
||||
};
|
||||
|
||||
const content = () => {
|
||||
if (!data) {
|
||||
return <FormLoader onRetry={loadData} errorMessage={error?.message} />;
|
||||
}
|
||||
|
||||
const isDev = data.emsesp_version.includes('dev');
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box p={2} border="1px solid grey" borderRadius={2}>
|
||||
<Grid container spacing={3}>
|
||||
<Grid mb={1}>
|
||||
<Typography mb={1} fontWeight={'fontWeightBold'}>
|
||||
{LL.VERSION()}
|
||||
</Typography>
|
||||
<Typography mb={1} fontWeight={'fontWeightBold'}>
|
||||
Platform
|
||||
</Typography>
|
||||
<Typography mb={1} fontWeight={'fontWeightBold'}>
|
||||
Release
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid>
|
||||
<Typography mb={1}>
|
||||
{data.emsesp_version}
|
||||
{data.build_flags && (
|
||||
<Typography variant="caption">
|
||||
({data.build_flags})
|
||||
</Typography>
|
||||
)}
|
||||
</Typography>
|
||||
<Typography mb={1}>{getPlatform()}</Typography>
|
||||
<Typography>
|
||||
{isDev ? LL.DEVELOPMENT() : LL.STABLE()}
|
||||
<Link
|
||||
target="_blank"
|
||||
href={useDev ? DEV_RELNOTES_URL : STABLE_RELNOTES_URL}
|
||||
color="primary"
|
||||
>
|
||||
(changelog)
|
||||
</Link>
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Divider />
|
||||
|
||||
{!isDev && (
|
||||
<Button
|
||||
sx={{ mt: 2 }}
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
size="small"
|
||||
onClick={() => showFirmwareDialog(true)}
|
||||
>
|
||||
{LL.SWITCH_DEV()}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Typography mt={2} color="warning">
|
||||
<InfoOutlinedIcon color="warning" sx={{ verticalAlign: 'middle' }} />
|
||||
|
||||
{upgradeAvailable ? LL.UPGRADE_AVAILABLE() : LL.LATEST_VERSION()}
|
||||
{upgradeAvailable &&
|
||||
internet_live &&
|
||||
(data.psram ? (
|
||||
<Button
|
||||
sx={{ ml: 2, textTransform: 'none' }}
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
size="small"
|
||||
onClick={() => showFirmwareDialog(false)}
|
||||
>
|
||||
{isDev
|
||||
? LL.INSTALL('v' + latestDevVersion)
|
||||
: LL.INSTALL('v' + latestVersion)}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
|
||||
<Link target="_blank" href={getBinURL(isDev)} color="primary">
|
||||
{LL.DOWNLOAD(1)} v
|
||||
{isDev ? latestDevVersion : latestVersion}
|
||||
</Link>
|
||||
</>
|
||||
))}
|
||||
</Typography>
|
||||
|
||||
{renderUploadDialog()}
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionContent>{restarting ? <RestartMonitor /> : content()}</SectionContent>
|
||||
);
|
||||
};
|
||||
|
||||
export default Version;
|
||||
@@ -1,16 +1,9 @@
|
||||
import { memo, useCallback, useMemo, useState } from 'react';
|
||||
import {
|
||||
Navigate,
|
||||
Route,
|
||||
Routes,
|
||||
matchRoutes,
|
||||
useLocation,
|
||||
useNavigate
|
||||
} from 'react-router';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Navigate, Route, Routes, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { Tab } from '@mui/material';
|
||||
|
||||
import { RouterTabs, useLayoutTitle } from 'components';
|
||||
import { RouterTabs, useLayoutTitle, useRouterTab } from 'components';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
import type { WiFiNetwork } from 'types';
|
||||
|
||||
@@ -20,21 +13,9 @@ import WiFiNetworkScanner from './WiFiNetworkScanner';
|
||||
|
||||
const Network = () => {
|
||||
const { LL } = useI18nContext();
|
||||
useLayoutTitle(LL.NETWORK(0));
|
||||
useLayoutTitle(LL.SETTINGS_OF(LL.NETWORK(0)));
|
||||
|
||||
// this also works!
|
||||
// const routerTab = useMatch(`settings/network/:path/*`)?.pathname || false;
|
||||
const matchedRoutes = matchRoutes(
|
||||
[
|
||||
{
|
||||
path: '/settings/network/settings',
|
||||
element: <NetworkSettings />
|
||||
},
|
||||
{ path: '/settings/network/scan', element: <WiFiNetworkScanner /> }
|
||||
],
|
||||
useLocation()
|
||||
);
|
||||
const routerTab = matchedRoutes?.[0]?.route.path || false;
|
||||
const { routerTab } = useRouterTab();
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -43,7 +24,7 @@ const Network = () => {
|
||||
const selectNetwork = useCallback(
|
||||
(network: WiFiNetwork) => {
|
||||
setSelectedNetwork(network);
|
||||
void navigate('/settings/network/settings');
|
||||
navigate('settings');
|
||||
},
|
||||
[navigate]
|
||||
);
|
||||
@@ -52,34 +33,25 @@ const Network = () => {
|
||||
setSelectedNetwork(undefined);
|
||||
}, []);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
...(selectedNetwork && { selectedNetwork }),
|
||||
selectNetwork,
|
||||
deselectNetwork
|
||||
}),
|
||||
[selectedNetwork, selectNetwork, deselectNetwork]
|
||||
);
|
||||
|
||||
return (
|
||||
<WiFiConnectionContext.Provider value={contextValue}>
|
||||
<WiFiConnectionContext.Provider
|
||||
value={{
|
||||
selectedNetwork,
|
||||
selectNetwork,
|
||||
deselectNetwork
|
||||
}}
|
||||
>
|
||||
<RouterTabs value={routerTab}>
|
||||
<Tab
|
||||
value="/settings/network/settings"
|
||||
label={LL.SETTINGS_OF(LL.NETWORK(1))}
|
||||
/>
|
||||
<Tab value="/settings/network/scan" label={LL.NETWORK_SCAN()} />
|
||||
<Tab value="settings" label={LL.SETTINGS_OF(LL.NETWORK(1))} />
|
||||
<Tab value="scan" label={LL.NETWORK_SCAN()} />
|
||||
</RouterTabs>
|
||||
<Routes>
|
||||
<Route path="scan" element={<WiFiNetworkScanner />} />
|
||||
<Route path="settings" element={<NetworkSettings />} />
|
||||
<Route
|
||||
path="*"
|
||||
element={<Navigate replace to="/settings/network/settings" />}
|
||||
/>
|
||||
<Route path="*" element={<Navigate replace to="settings" />} />
|
||||
</Routes>
|
||||
</WiFiConnectionContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(Network);
|
||||
export default Network;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useCallback, useContext, useEffect, useState } from 'react';
|
||||
import { useContext, useEffect, useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
@@ -43,7 +43,7 @@ import { updateValueDirty, useRest } from 'utils';
|
||||
import { validate } from 'validators';
|
||||
import { createNetworkSettingsValidator } from 'validators/network';
|
||||
|
||||
import SystemMonitor from '../../status/SystemMonitor';
|
||||
import RestartMonitor from '../../status/RestartMonitor';
|
||||
import { WiFiConnectionContext } from './WiFiConnectionContext';
|
||||
import { isNetworkOpen, networkSecurityMode } from './WiFiNetworkSelector';
|
||||
|
||||
@@ -104,42 +104,43 @@ const NetworkSettings = () => {
|
||||
origData,
|
||||
dirtyFlags,
|
||||
setDirtyFlags,
|
||||
updateDataValue as (value: unknown) => void
|
||||
updateDataValue
|
||||
);
|
||||
|
||||
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
||||
|
||||
const validateAndSubmit = useCallback(async () => {
|
||||
if (!data) return;
|
||||
try {
|
||||
setFieldErrors(undefined);
|
||||
await validate(createNetworkSettingsValidator(data), data);
|
||||
await saveData();
|
||||
} catch (error) {
|
||||
setFieldErrors(error as ValidateFieldsError);
|
||||
}
|
||||
deselectNetwork();
|
||||
}, [data, saveData, deselectNetwork]);
|
||||
|
||||
const setCancel = useCallback(async () => {
|
||||
deselectNetwork();
|
||||
await loadData();
|
||||
}, [deselectNetwork, loadData]);
|
||||
|
||||
const doRestart = useCallback(async () => {
|
||||
setRestarting(true);
|
||||
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
|
||||
(error: Error) => {
|
||||
toast.error(error.message);
|
||||
}
|
||||
);
|
||||
}, [sendAPI]);
|
||||
useEffect(() => deselectNetwork, [deselectNetwork]);
|
||||
|
||||
const content = () => {
|
||||
if (!data) {
|
||||
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
|
||||
return <FormLoader onRetry={loadData} errorMessage={errorMessage} />;
|
||||
}
|
||||
|
||||
const validateAndSubmit = async () => {
|
||||
try {
|
||||
setFieldErrors(undefined);
|
||||
await validate(createNetworkSettingsValidator(data), data);
|
||||
await saveData();
|
||||
} catch (error) {
|
||||
setFieldErrors(error as ValidateFieldsError);
|
||||
}
|
||||
deselectNetwork();
|
||||
};
|
||||
|
||||
const setCancel = async () => {
|
||||
deselectNetwork();
|
||||
await loadData();
|
||||
};
|
||||
|
||||
const doRestart = async () => {
|
||||
setRestarting(true);
|
||||
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
|
||||
(error: Error) => {
|
||||
toast.error(error.message);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Typography variant="h6" color="primary">
|
||||
@@ -164,14 +165,14 @@ const NetworkSettings = () => {
|
||||
selectedNetwork.bssid
|
||||
}
|
||||
/>
|
||||
<IconButton onClick={setCancel} aria-label={LL.CANCEL()}>
|
||||
<IconButton onClick={setCancel}>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</ListItem>
|
||||
</List>
|
||||
) : (
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
fieldErrors={fieldErrors}
|
||||
name="ssid"
|
||||
label={'SSID (' + LL.NETWORK_BLANK_SSID() + ')'}
|
||||
fullWidth
|
||||
@@ -182,7 +183,7 @@ const NetworkSettings = () => {
|
||||
/>
|
||||
)}
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
fieldErrors={fieldErrors}
|
||||
name="bssid"
|
||||
label={'BSSID (' + LL.NETWORK_BLANK_BSSID() + ')'}
|
||||
fullWidth
|
||||
@@ -193,7 +194,7 @@ const NetworkSettings = () => {
|
||||
/>
|
||||
{(!selectedNetwork || !isNetworkOpen(selectedNetwork)) && (
|
||||
<ValidatedPasswordField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
fieldErrors={fieldErrors}
|
||||
name="password"
|
||||
label={LL.PASSWORD()}
|
||||
fullWidth
|
||||
@@ -250,7 +251,7 @@ const NetworkSettings = () => {
|
||||
{LL.GENERAL_OPTIONS()}
|
||||
</Typography>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
fieldErrors={fieldErrors}
|
||||
name="hostname"
|
||||
label={LL.HOSTNAME()}
|
||||
fullWidth
|
||||
@@ -303,7 +304,7 @@ const NetworkSettings = () => {
|
||||
{data.static_ip_config && (
|
||||
<>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
fieldErrors={fieldErrors}
|
||||
name="local_ip"
|
||||
label={LL.AP_LOCAL_IP()}
|
||||
fullWidth
|
||||
@@ -313,7 +314,7 @@ const NetworkSettings = () => {
|
||||
margin="normal"
|
||||
/>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
fieldErrors={fieldErrors}
|
||||
name="gateway_ip"
|
||||
label={LL.NETWORK_GATEWAY()}
|
||||
fullWidth
|
||||
@@ -323,7 +324,7 @@ const NetworkSettings = () => {
|
||||
margin="normal"
|
||||
/>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
fieldErrors={fieldErrors}
|
||||
name="subnet_mask"
|
||||
label={LL.NETWORK_SUBNET()}
|
||||
fullWidth
|
||||
@@ -333,7 +334,7 @@ const NetworkSettings = () => {
|
||||
margin="normal"
|
||||
/>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
fieldErrors={fieldErrors}
|
||||
name="dns_ip_1"
|
||||
label="DNS #1"
|
||||
fullWidth
|
||||
@@ -343,7 +344,7 @@ const NetworkSettings = () => {
|
||||
margin="normal"
|
||||
/>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
fieldErrors={fieldErrors}
|
||||
name="dns_ip_2"
|
||||
label="DNS #2"
|
||||
fullWidth
|
||||
@@ -399,9 +400,9 @@ const NetworkSettings = () => {
|
||||
return (
|
||||
<SectionContent>
|
||||
{blocker ? <BlockNavigation blocker={blocker} /> : null}
|
||||
{restarting ? <SystemMonitor /> : content()}
|
||||
{restarting ? <RestartMonitor /> : content()}
|
||||
</SectionContent>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(NetworkSettings);
|
||||
export default NetworkSettings;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useCallback, useRef, useState } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
import PermScanWifiIcon from '@mui/icons-material/PermScanWifi';
|
||||
import { Button } from '@mui/material';
|
||||
@@ -48,12 +48,14 @@ const WiFiNetworkScanner = () => {
|
||||
}
|
||||
});
|
||||
|
||||
const renderNetworkScanner = useCallback(() => {
|
||||
const renderNetworkScanner = () => {
|
||||
if (!networkList) {
|
||||
return <FormLoader errorMessage={errorMessage || ''} />;
|
||||
return (
|
||||
<FormLoader message={LL.SCANNING() + '...'} errorMessage={errorMessage} />
|
||||
);
|
||||
}
|
||||
return <WiFiNetworkSelector networkList={networkList} />;
|
||||
}, [networkList, errorMessage]);
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionContent>
|
||||
@@ -73,4 +75,4 @@ const WiFiNetworkScanner = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(WiFiNetworkScanner);
|
||||
export default WiFiNetworkScanner;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useCallback, useContext } from 'react';
|
||||
import { useContext } from 'react';
|
||||
|
||||
import LockIcon from '@mui/icons-material/Lock';
|
||||
import LockOpenIcon from '@mui/icons-material/LockOpen';
|
||||
@@ -63,34 +63,31 @@ const WiFiNetworkSelector = ({ networkList }: { networkList: WiFiNetworkList })
|
||||
|
||||
const wifiConnectionContext = useContext(WiFiConnectionContext);
|
||||
|
||||
const renderNetwork = useCallback(
|
||||
(network: WiFiNetwork) => (
|
||||
<ListItem
|
||||
key={network.bssid}
|
||||
onClick={() => wifiConnectionContext.selectNetwork(network)}
|
||||
>
|
||||
<ListItemAvatar>
|
||||
<Avatar>{isNetworkOpen(network) ? <LockOpenIcon /> : <LockIcon />}</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={network.ssid}
|
||||
secondary={
|
||||
'Security: ' +
|
||||
networkSecurityMode(network) +
|
||||
', Ch: ' +
|
||||
network.channel +
|
||||
', bssid: ' +
|
||||
network.bssid
|
||||
}
|
||||
/>
|
||||
<ListItemIcon>
|
||||
<Badge badgeContent={network.rssi + 'dBm'}>
|
||||
<WifiIcon sx={{ color: networkQualityHighlight(network, theme) }} />
|
||||
</Badge>
|
||||
</ListItemIcon>
|
||||
</ListItem>
|
||||
),
|
||||
[wifiConnectionContext, theme]
|
||||
const renderNetwork = (network: WiFiNetwork) => (
|
||||
<ListItem
|
||||
key={network.bssid}
|
||||
onClick={() => wifiConnectionContext.selectNetwork(network)}
|
||||
>
|
||||
<ListItemAvatar>
|
||||
<Avatar>{isNetworkOpen(network) ? <LockOpenIcon /> : <LockIcon />}</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={network.ssid}
|
||||
secondary={
|
||||
'Security: ' +
|
||||
networkSecurityMode(network) +
|
||||
', Ch: ' +
|
||||
network.channel +
|
||||
', bssid: ' +
|
||||
network.bssid
|
||||
}
|
||||
/>
|
||||
<ListItemIcon>
|
||||
<Badge badgeContent={network.rssi + 'dBm'}>
|
||||
<WifiIcon sx={{ color: networkQualityHighlight(network, theme) }} />
|
||||
</Badge>
|
||||
</ListItemIcon>
|
||||
</ListItem>
|
||||
);
|
||||
|
||||
if (networkList.networks.length === 0) {
|
||||
@@ -100,4 +97,4 @@ const WiFiNetworkSelector = ({ networkList }: { networkList: WiFiNetworkList })
|
||||
return <List>{networkList.networks.map(renderNetwork)}</List>;
|
||||
};
|
||||
|
||||
export default memo(WiFiNetworkSelector);
|
||||
export default WiFiNetworkSelector;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useEffect } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import {
|
||||
@@ -40,7 +40,7 @@ const GenerateToken = ({ username, onClose }: GenerateTokenProps) => {
|
||||
if (open) {
|
||||
void generateToken();
|
||||
}
|
||||
}, [open, generateToken]);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@@ -86,4 +86,4 @@ const GenerateToken = ({ username, onClose }: GenerateTokenProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(GenerateToken);
|
||||
export default GenerateToken;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { memo, useCallback, useContext, useMemo, useState } from 'react';
|
||||
import { useBlocker } from 'react-router';
|
||||
import { useContext, useState } from 'react';
|
||||
import { useBlocker } from 'react-router-dom';
|
||||
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import CheckIcon from '@mui/icons-material/Check';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import CheckIcon from '@mui/icons-material/Done';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import PersonAddIcon from '@mui/icons-material/PersonAdd';
|
||||
import VpnKeyIcon from '@mui/icons-material/VpnKey';
|
||||
@@ -55,16 +55,14 @@ const ManageUsers = () => {
|
||||
const blocker = useBlocker(changed !== 0);
|
||||
const { LL } = useI18nContext();
|
||||
|
||||
const table_theme = useMemo(
|
||||
() =>
|
||||
useTheme({
|
||||
Table: `
|
||||
const table_theme = useTheme({
|
||||
Table: `
|
||||
--data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) minmax(120px, max-content) 120px;
|
||||
`,
|
||||
BaseRow: `
|
||||
BaseRow: `
|
||||
font-size: 14px;
|
||||
`,
|
||||
HeaderRow: `
|
||||
HeaderRow: `
|
||||
text-transform: uppercase;
|
||||
background-color: black;
|
||||
color: #90CAF9;
|
||||
@@ -74,7 +72,7 @@ const ManageUsers = () => {
|
||||
border-bottom: 1px solid #565656;
|
||||
}
|
||||
`,
|
||||
Row: `
|
||||
Row: `
|
||||
.td {
|
||||
padding: 8px;
|
||||
border-top: 1px solid #565656;
|
||||
@@ -87,7 +85,7 @@ const ManageUsers = () => {
|
||||
background-color: #1e1e1e;
|
||||
}
|
||||
`,
|
||||
BaseCell: `
|
||||
BaseCell: `
|
||||
&:nth-of-type(2) {
|
||||
text-align: center;
|
||||
}
|
||||
@@ -95,81 +93,72 @@ const ManageUsers = () => {
|
||||
text-align: right;
|
||||
}
|
||||
`
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const noAdminConfigured = useCallback(
|
||||
() => !data?.users.find((u) => u.admin),
|
||||
[data]
|
||||
);
|
||||
|
||||
const removeUser = useCallback(
|
||||
(toRemove: UserType) => {
|
||||
if (!data) return;
|
||||
const users = data.users.filter((u) => u.username !== toRemove.username);
|
||||
updateDataValue({ ...data, users });
|
||||
setChanged(changed + 1);
|
||||
},
|
||||
[data, updateDataValue, changed]
|
||||
);
|
||||
|
||||
const createUser = useCallback(() => {
|
||||
setCreating(true);
|
||||
setUser({
|
||||
username: '',
|
||||
password: '',
|
||||
admin: true
|
||||
});
|
||||
}, []);
|
||||
|
||||
const editUser = useCallback((toEdit: UserType) => {
|
||||
setCreating(false);
|
||||
setUser({ ...toEdit });
|
||||
}, []);
|
||||
|
||||
const cancelEditingUser = useCallback(() => {
|
||||
setUser(undefined);
|
||||
}, []);
|
||||
|
||||
const doneEditingUser = useCallback(() => {
|
||||
if (user && data) {
|
||||
const users = [
|
||||
...data.users.filter(
|
||||
(u: { username: string }) => u.username !== user.username
|
||||
),
|
||||
user
|
||||
];
|
||||
updateDataValue({ ...data, users });
|
||||
setUser(undefined);
|
||||
setChanged(changed + 1);
|
||||
}
|
||||
}, [user, data, updateDataValue, changed]);
|
||||
|
||||
const closeGenerateToken = useCallback(() => {
|
||||
setGeneratingToken(undefined);
|
||||
}, []);
|
||||
|
||||
const generateTokenForUser = useCallback((username: string) => {
|
||||
setGeneratingToken(username);
|
||||
}, []);
|
||||
|
||||
const onSubmit = useCallback(async () => {
|
||||
await saveData();
|
||||
await authenticatedContext.refresh();
|
||||
setChanged(0);
|
||||
}, [saveData, authenticatedContext]);
|
||||
|
||||
const onCancelSubmit = useCallback(async () => {
|
||||
await loadData();
|
||||
setChanged(0);
|
||||
}, [loadData]);
|
||||
});
|
||||
|
||||
const content = () => {
|
||||
if (!data) {
|
||||
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
|
||||
return <FormLoader onRetry={loadData} errorMessage={errorMessage} />;
|
||||
}
|
||||
|
||||
const noAdminConfigured = () => !data.users.find((u) => u.admin);
|
||||
|
||||
const removeUser = (toRemove: UserType) => {
|
||||
const users = data.users.filter((u) => u.username !== toRemove.username);
|
||||
updateDataValue({ ...data, users });
|
||||
setChanged(changed + 1);
|
||||
};
|
||||
|
||||
const createUser = () => {
|
||||
setCreating(true);
|
||||
setUser({
|
||||
username: '',
|
||||
password: '',
|
||||
admin: true
|
||||
});
|
||||
};
|
||||
|
||||
const editUser = (toEdit: UserType) => {
|
||||
setCreating(false);
|
||||
setUser({ ...toEdit });
|
||||
};
|
||||
|
||||
const cancelEditingUser = () => {
|
||||
setUser(undefined);
|
||||
};
|
||||
|
||||
const doneEditingUser = () => {
|
||||
if (user) {
|
||||
const users = [
|
||||
...data.users.filter(
|
||||
(u: { username: string }) => u.username !== user.username
|
||||
),
|
||||
user
|
||||
];
|
||||
updateDataValue({ ...data, users });
|
||||
setUser(undefined);
|
||||
setChanged(changed + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const closeGenerateToken = () => {
|
||||
setGeneratingToken(undefined);
|
||||
};
|
||||
|
||||
const generateToken = (username: string) => {
|
||||
setGeneratingToken(username);
|
||||
};
|
||||
|
||||
const onSubmit = async () => {
|
||||
await saveData();
|
||||
await authenticatedContext.refresh();
|
||||
setChanged(0);
|
||||
};
|
||||
|
||||
const onCancelSubmit = async () => {
|
||||
await loadData();
|
||||
setChanged(0);
|
||||
};
|
||||
|
||||
interface UserType2 {
|
||||
id: string;
|
||||
username: string;
|
||||
@@ -178,14 +167,10 @@ const ManageUsers = () => {
|
||||
}
|
||||
|
||||
// add id to the type, needed for the table
|
||||
const user_table = useMemo(
|
||||
() =>
|
||||
data.users.map((u) => ({
|
||||
...u,
|
||||
id: u.username
|
||||
})) as UserType2[],
|
||||
[data.users]
|
||||
);
|
||||
const user_table = data.users.map((u) => ({
|
||||
...u,
|
||||
id: u.username
|
||||
})) as UserType2[];
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -211,24 +196,15 @@ const ManageUsers = () => {
|
||||
<Cell stiff>
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label={LL.GENERATING_TOKEN()}
|
||||
disabled={!authenticatedContext.me.admin}
|
||||
onClick={() => generateTokenForUser(u.username)}
|
||||
onClick={() => generateToken(u.username)}
|
||||
>
|
||||
<VpnKeyIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => removeUser(u)}
|
||||
aria-label={LL.REMOVE()}
|
||||
>
|
||||
<IconButton size="small" onClick={() => removeUser(u)}>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => editUser(u)}
|
||||
aria-label={LL.EDIT()}
|
||||
>
|
||||
<IconButton size="small" onClick={() => editUser(u)}>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
</Cell>
|
||||
@@ -284,20 +260,15 @@ const ManageUsers = () => {
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<GenerateToken
|
||||
username={generatingToken || ''}
|
||||
onClose={closeGenerateToken}
|
||||
<GenerateToken username={generatingToken} onClose={closeGenerateToken} />
|
||||
<User
|
||||
user={user}
|
||||
setUser={setUser}
|
||||
creating={creating}
|
||||
onDoneEditing={doneEditingUser}
|
||||
onCancelEditing={cancelEditingUser}
|
||||
validator={createUserValidator(data.users, creating)}
|
||||
/>
|
||||
{user && (
|
||||
<User
|
||||
user={user}
|
||||
setUser={setUser}
|
||||
creating={creating}
|
||||
onDoneEditing={doneEditingUser}
|
||||
onCancelEditing={cancelEditingUser}
|
||||
validator={createUserValidator(data.users, creating)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -310,4 +281,4 @@ const ManageUsers = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(ManageUsers);
|
||||
export default ManageUsers;
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { memo, useMemo } from 'react';
|
||||
import { Navigate, Route, Routes, matchRoutes, useLocation } from 'react-router';
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
|
||||
import { Tab } from '@mui/material';
|
||||
|
||||
import { RouterTabs, useLayoutTitle } from 'components';
|
||||
import { RouterTabs, useLayoutTitle, useRouterTab } from 'components';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
|
||||
import ManageUsers from './ManageUsers';
|
||||
@@ -11,45 +10,23 @@ import SecuritySettings from './SecuritySettings';
|
||||
|
||||
const Security = () => {
|
||||
const { LL } = useI18nContext();
|
||||
useLayoutTitle(LL.SECURITY(0));
|
||||
useLayoutTitle(LL.SETTINGS_OF(LL.SECURITY(0)));
|
||||
|
||||
const location = useLocation();
|
||||
|
||||
const matchedRoutes = useMemo(
|
||||
() =>
|
||||
matchRoutes(
|
||||
[
|
||||
{
|
||||
path: '/settings/security/settings',
|
||||
element: <ManageUsers />
|
||||
},
|
||||
{ path: '/settings/security/users', element: <SecuritySettings /> }
|
||||
],
|
||||
location
|
||||
),
|
||||
[location]
|
||||
);
|
||||
const routerTab = matchedRoutes?.[0]?.route.path || false;
|
||||
const { routerTab } = useRouterTab();
|
||||
|
||||
return (
|
||||
<>
|
||||
<RouterTabs value={routerTab}>
|
||||
<Tab
|
||||
value="/settings/security/settings"
|
||||
label={LL.SETTINGS_OF(LL.SECURITY(1))}
|
||||
/>
|
||||
<Tab value="/settings/security/users" label={LL.MANAGE_USERS()} />
|
||||
<Tab value="settings" label={LL.SETTINGS_OF(LL.SECURITY(1))} />
|
||||
<Tab value="users" label={LL.MANAGE_USERS()} />
|
||||
</RouterTabs>
|
||||
<Routes>
|
||||
<Route path="users" element={<ManageUsers />} />
|
||||
<Route path="settings" element={<SecuritySettings />} />
|
||||
<Route
|
||||
path="*"
|
||||
element={<Navigate replace to="/settings/security/settings" />}
|
||||
/>
|
||||
<Route path="*" element={<Navigate replace to="settings" />} />
|
||||
</Routes>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(Security);
|
||||
export default Security;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useCallback, useContext, useState } from 'react';
|
||||
import { useContext, useState } from 'react';
|
||||
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import WarningIcon from '@mui/icons-material/Warning';
|
||||
@@ -44,33 +44,32 @@ const SecuritySettings = () => {
|
||||
const authenticatedContext = useContext(AuthenticatedContext);
|
||||
|
||||
const updateFormValue = updateValueDirty(
|
||||
origData as unknown as Record<string, unknown>,
|
||||
origData,
|
||||
dirtyFlags,
|
||||
setDirtyFlags,
|
||||
updateDataValue as (value: unknown) => void
|
||||
updateDataValue
|
||||
);
|
||||
|
||||
const validateAndSubmit = useCallback(async () => {
|
||||
if (!data) return;
|
||||
try {
|
||||
setFieldErrors(undefined);
|
||||
await validate(SECURITY_SETTINGS_VALIDATOR, data);
|
||||
await saveData();
|
||||
await authenticatedContext.refresh();
|
||||
} catch (error) {
|
||||
setFieldErrors(error as ValidateFieldsError);
|
||||
}
|
||||
}, [data, saveData, authenticatedContext]);
|
||||
|
||||
const content = () => {
|
||||
if (!data) {
|
||||
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
|
||||
return <FormLoader onRetry={loadData} errorMessage={errorMessage} />;
|
||||
}
|
||||
|
||||
const validateAndSubmit = async () => {
|
||||
try {
|
||||
setFieldErrors(undefined);
|
||||
await validate(SECURITY_SETTINGS_VALIDATOR, data);
|
||||
await saveData();
|
||||
await authenticatedContext.refresh();
|
||||
} catch (error) {
|
||||
setFieldErrors(error as ValidateFieldsError);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ValidatedPasswordField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
fieldErrors={fieldErrors}
|
||||
name="jwt_secret"
|
||||
label={LL.SU_PASSWORD()}
|
||||
fullWidth
|
||||
@@ -116,4 +115,4 @@ const SecuritySettings = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(SecuritySettings);
|
||||
export default SecuritySettings;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useCallback, useEffect, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { FC } from 'react';
|
||||
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
@@ -45,14 +45,7 @@ const User: FC<UserFormProps> = ({
|
||||
}) => {
|
||||
const { LL } = useI18nContext();
|
||||
|
||||
const updateFormValue = updateValue((updater) => {
|
||||
setUser((prevState) => {
|
||||
if (!prevState) return prevState;
|
||||
return updater(
|
||||
prevState as unknown as Record<string, unknown>
|
||||
) as unknown as UserType;
|
||||
});
|
||||
});
|
||||
const updateFormValue = updateValue(setUser);
|
||||
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
||||
const open = !!user;
|
||||
|
||||
@@ -62,7 +55,7 @@ const User: FC<UserFormProps> = ({
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const validateAndDone = useCallback(async () => {
|
||||
const validateAndDone = async () => {
|
||||
if (user) {
|
||||
try {
|
||||
setFieldErrors(undefined);
|
||||
@@ -72,7 +65,7 @@ const User: FC<UserFormProps> = ({
|
||||
setFieldErrors(error as ValidateFieldsError);
|
||||
}
|
||||
}
|
||||
}, [user, validator, onDoneEditing]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@@ -89,7 +82,7 @@ const User: FC<UserFormProps> = ({
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
fieldErrors={fieldErrors}
|
||||
name="username"
|
||||
label={LL.USERNAME(1)}
|
||||
fullWidth
|
||||
@@ -100,7 +93,7 @@ const User: FC<UserFormProps> = ({
|
||||
margin="normal"
|
||||
/>
|
||||
<ValidatedPasswordField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
fieldErrors={fieldErrors}
|
||||
name="password"
|
||||
label={LL.PASSWORD()}
|
||||
fullWidth
|
||||
@@ -144,4 +137,4 @@ const User: FC<UserFormProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(User);
|
||||
export default User;
|
||||
|
||||
@@ -14,12 +14,11 @@ import type { Theme } from '@mui/material';
|
||||
|
||||
import * as APApi from 'api/ap';
|
||||
|
||||
import { useRequest } from 'alova/client';
|
||||
import { useAutoRequest } from 'alova/client';
|
||||
import { FormLoader, SectionContent, useLayoutTitle } from 'components';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
import type { APStatusType } from 'types';
|
||||
import { APNetworkStatus } from 'types';
|
||||
import { useInterval } from 'utils';
|
||||
|
||||
export const apStatusHighlight = ({ status }: APStatusType, theme: Theme) => {
|
||||
switch (status) {
|
||||
@@ -34,43 +33,37 @@ export const apStatusHighlight = ({ status }: APStatusType, theme: Theme) => {
|
||||
}
|
||||
};
|
||||
|
||||
const getApStatusText = (
|
||||
status: APNetworkStatus,
|
||||
LL: ReturnType<typeof useI18nContext>['LL']
|
||||
) => {
|
||||
switch (status) {
|
||||
case APNetworkStatus.ACTIVE:
|
||||
return LL.ACTIVE();
|
||||
case APNetworkStatus.INACTIVE:
|
||||
return LL.INACTIVE(0);
|
||||
case APNetworkStatus.LINGERING:
|
||||
return 'Lingering until idle';
|
||||
default:
|
||||
return LL.UNKNOWN();
|
||||
}
|
||||
};
|
||||
|
||||
const APStatus = () => {
|
||||
const { data, send: loadData, error } = useRequest(APApi.readAPStatus);
|
||||
const {
|
||||
data,
|
||||
send: loadData,
|
||||
error
|
||||
} = useAutoRequest(APApi.readAPStatus, { pollingTime: 3000 });
|
||||
|
||||
const { LL } = useI18nContext();
|
||||
useLayoutTitle(LL.STATUS_OF(LL.ACCESS_POINT(0)));
|
||||
|
||||
const theme = useTheme();
|
||||
|
||||
useLayoutTitle(LL.ACCESS_POINT(0));
|
||||
const apStatus = ({ status }: APStatusType) => {
|
||||
switch (status) {
|
||||
case APNetworkStatus.ACTIVE:
|
||||
return LL.ACTIVE();
|
||||
case APNetworkStatus.INACTIVE:
|
||||
return LL.INACTIVE(0);
|
||||
case APNetworkStatus.LINGERING:
|
||||
return 'Lingering until idle';
|
||||
default:
|
||||
return LL.UNKNOWN();
|
||||
}
|
||||
};
|
||||
|
||||
useInterval(() => {
|
||||
void loadData();
|
||||
});
|
||||
const content = () => {
|
||||
if (!data) {
|
||||
return <FormLoader onRetry={loadData} errorMessage={error?.message} />;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<SectionContent>
|
||||
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
|
||||
</SectionContent>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionContent>
|
||||
<List>
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
@@ -78,26 +71,19 @@ const APStatus = () => {
|
||||
<SettingsInputAntennaIcon />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={LL.STATUS_OF('')}
|
||||
secondary={getApStatusText(data.status, LL)}
|
||||
/>
|
||||
<ListItemText primary={LL.STATUS_OF('')} secondary={apStatus(data)} />
|
||||
</ListItem>
|
||||
|
||||
<Divider variant="inset" component="li" />
|
||||
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<Avatar sx={{ bgcolor: 'primary.main' }}>IP</Avatar>
|
||||
<Avatar>IP</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText primary={LL.ADDRESS_OF('IP')} secondary={data.ip_address} />
|
||||
</ListItem>
|
||||
|
||||
<Divider variant="inset" component="li" />
|
||||
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<Avatar sx={{ bgcolor: 'primary.main' }}>
|
||||
<Avatar>
|
||||
<DeviceHubIcon />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
@@ -106,22 +92,21 @@ const APStatus = () => {
|
||||
secondary={data.mac_address}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<Divider variant="inset" component="li" />
|
||||
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<Avatar sx={{ bgcolor: 'primary.main' }}>
|
||||
<Avatar>
|
||||
<ComputerIcon />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText primary={LL.AP_CLIENTS()} secondary={data.station_num} />
|
||||
</ListItem>
|
||||
|
||||
<Divider variant="inset" component="li" />
|
||||
</List>
|
||||
</SectionContent>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
return <SectionContent>{content()}</SectionContent>;
|
||||
};
|
||||
|
||||
export default APStatus;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user