mirror of
https://github.com/emsesp/EMS-ESP32.git
synced 2025-12-06 15:59:52 +03:00
Compare commits
32 Commits
37ba42faf8
...
https_36
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f8581f989 | ||
|
|
4c60a88221 | ||
|
|
d093886571 | ||
|
|
9e251fd3de | ||
|
|
89fd16d48f | ||
|
|
75fc8c201a | ||
|
|
f1a3bf0e6a | ||
|
|
86afa20470 | ||
|
|
32e3f69961 | ||
|
|
1b7d999fc4 | ||
|
|
6e6023cfd1 | ||
|
|
e64a374174 | ||
|
|
400100759a | ||
|
|
3e49dc113e | ||
|
|
7b96baa9b8 | ||
|
|
8bdd01f73f | ||
|
|
b36477cc8a | ||
|
|
ebfc4a64e2 | ||
|
|
fd48fb63d8 | ||
|
|
20d747330c | ||
|
|
781fb67a4e | ||
|
|
2b44155869 | ||
|
|
9ca0782ae7 | ||
|
|
4b831e864c | ||
|
|
16dc82da1c | ||
|
|
8cd0a9a761 | ||
|
|
b928ad89d9 | ||
|
|
12671f7ed3 | ||
|
|
22184f13f2 | ||
|
|
778bbe3b81 | ||
|
|
23d4f608c5 | ||
|
|
73a51ae4ad |
@@ -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
|
|
||||||
30
.github/ISSUE_TEMPLATE/bug_report.md
vendored
30
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -1,32 +1,32 @@
|
|||||||
---
|
---
|
||||||
name: Problem Report/Change Request
|
name: Problem Report
|
||||||
about: Create a Report to help us improve
|
about: Create a Report to help us improve
|
||||||
---
|
---
|
||||||
|
|
||||||
<!-- Thanks for reporting an issue for this project. READ THIS FIRST:
|
<!-- Thanks for reporting a problem for this project. READ THIS FIRST:
|
||||||
|
|
||||||
Please DO NOT OPEN AN ISSUE if your EMS-ESP version is not the latest from the dev branch, please update your device before submitting your issue. Your issue might already be solved. The latest precompiled binaries of EMS-ESP can be downloaded from https://github.com/emsesp/EMS-ESP32/releases/tag/latest
|
Please DO NOT OPEN AN ISSUE if your EMS-ESP version is not the latest from the dev branch, please update your device before submitting your issue. Your problem might already be solved. The latest precompiled binaries of EMS-ESP can be downloaded from https://github.com/emsesp/EMS-ESP32/releases/tag/latest
|
||||||
|
|
||||||
Please take a few minutes to complete the requested information below.
|
Please take a few minutes to complete the requested information below.
|
||||||
|
|
||||||
-->
|
-->
|
||||||
|
|
||||||
### DESCRIPTION
|
### PROBLEM DESCRIPTION
|
||||||
|
|
||||||
_A clear and concise description of what the problem is or the change requested._
|
_A clear and concise description of what the problem is._
|
||||||
|
|
||||||
### REQUESTED INFORMATION
|
### REQUESTED INFORMATION
|
||||||
|
|
||||||
_Make sure your have performed every step and checked the applicable boxes before submitting your issue. Thank you!_
|
_Make sure your have performed every step and checked the applicable boxes before submitting your issue. Thank you!_
|
||||||
|
|
||||||
- [ ] Searched the issue in [issues](https://github.com/emsesp/EMS-ESP32/issues)
|
- [ ] Searched the problem in [issues](https://github.com/emsesp/EMS-ESP32/issues)
|
||||||
- [ ] Searched the issue in [discussions](https://github.com/emsesp/EMS-ESP32/discussions)
|
- [ ] Searched the problem in [discussions](https://github.com/emsesp/EMS-ESP32/discussions)
|
||||||
- [ ] Searched the issue in the [docs](https://docs.emsesp.org/Troubleshooting/)
|
- [ ] Searched the problem in the [docs](https://emsesp.github.io/docs/Troubleshooting/)
|
||||||
- [ ] Searched the issue in the [chat](https://discord.gg/3J3GgnzpyT)
|
- [ ] Searched the problem in the [chat](https://discord.gg/3J3GgnzpyT)
|
||||||
- [ ] Provide the System information in the area below, taken from `http://<IP>/api/system`
|
- [ ] Provide the output of http://ems-esp.local/api/system :
|
||||||
|
|
||||||
```json
|
```lua
|
||||||
Paste System information here....
|
System information output here:
|
||||||
|
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -41,10 +41,10 @@ _A clear and concise description of what you expected to happen._
|
|||||||
|
|
||||||
### SCREENSHOTS
|
### SCREENSHOTS
|
||||||
|
|
||||||
_If applicable, add screenshots to help explain your issue._
|
_If applicable, add screenshots to help explain your problem._
|
||||||
|
|
||||||
### ADDITIONAL CONTEXT
|
### ADDITIONAL CONTEXT
|
||||||
|
|
||||||
_Add any other context about the issue here._
|
_Add any other context about the problem here._
|
||||||
|
|
||||||
**(Please remember to close the issue when it has been addressed)**
|
**(Please, remember to close the issue when the problem has been addressed)**
|
||||||
|
|||||||
2
.github/ISSUE_TEMPLATE/config.yml
vendored
2
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,7 +1,7 @@
|
|||||||
blank_issues_enabled: false
|
blank_issues_enabled: false
|
||||||
contact_links:
|
contact_links:
|
||||||
- name: EMS-ESP Docs
|
- name: EMS-ESP Docs
|
||||||
url: https://docs.emsesp.org
|
url: https://emsesp.github.io/docs/
|
||||||
about: All the information related to EMS-ESP.
|
about: All the information related to EMS-ESP.
|
||||||
- name: EMS-ESP Discussions and Support
|
- name: EMS-ESP Discussions and Support
|
||||||
url: https://github.com/emsesp/EMS-ESP32/discussions
|
url: https://github.com/emsesp/EMS-ESP32/discussions
|
||||||
|
|||||||
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,4 +1,4 @@
|
|||||||
name: 'Publish releases to discord'
|
name: 'github-releases-to-discord'
|
||||||
|
|
||||||
on:
|
on:
|
||||||
release:
|
release:
|
||||||
@@ -7,13 +7,10 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
github-releases-to-discord:
|
github-releases-to-discord:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v4
|
||||||
|
- name: Github Releases To Discord
|
||||||
- name: GitHub Releases To Discord
|
|
||||||
uses: SethCohen/github-releases-to-discord@v1.13.1
|
uses: SethCohen/github-releases-to-discord@v1.13.1
|
||||||
with:
|
with:
|
||||||
webhook_url: ${{ secrets.WEBHOOK_URL }}
|
webhook_url: ${{ secrets.WEBHOOK_URL }}
|
||||||
|
|||||||
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
|
|
||||||
61
.github/workflows/pre_release.yml
vendored
Normal file
61
.github/workflows/pre_release.yml
vendored
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
name: 'pre-release'
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- 'dev'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
pre-release:
|
||||||
|
name: 'Automatic pre-release build'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v4
|
||||||
|
with:
|
||||||
|
python-version: '3.11'
|
||||||
|
- uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: '18'
|
||||||
|
|
||||||
|
- name: Get EMS-ESP source code and 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 firmware
|
||||||
|
run: |
|
||||||
|
platformio run -e ci
|
||||||
|
|
||||||
|
- name: Build S3 firmware
|
||||||
|
run: |
|
||||||
|
platformio run -e ci_s3
|
||||||
|
|
||||||
|
- name: Create a GH Release
|
||||||
|
id: 'automatic_releases'
|
||||||
|
uses: 'marvinpinto/action-automatic-releases@latest'
|
||||||
|
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/*.*
|
||||||
33
.github/workflows/sonar_check.yml
vendored
33
.github/workflows/sonar_check.yml
vendored
@@ -1,33 +1,30 @@
|
|||||||
# see https://github.com/marketplace/actions/sonarcloud-scan-for-c-and-c#usage
|
|
||||||
name: Sonar Check
|
name: Sonar Check
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- dev
|
- dev
|
||||||
paths:
|
pull_request:
|
||||||
- 'src/**'
|
types: [opened, synchronize, reopened]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
name: Build and analyze
|
name: Build and analyze
|
||||||
if: github.repository == 'emsesp/EMS-ESP32'
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
# if: github.repository_owner == 'emsesp'
|
||||||
|
# if: github.repository == 'emsesp/EMS-ESP32'
|
||||||
env:
|
env:
|
||||||
BUILD_WRAPPER_OUT_DIR: bw-output
|
BUILD_WRAPPER_OUT_DIR: bw-output
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- uses: actions/checkout@v4
|
||||||
uses: actions/checkout@v5
|
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
|
||||||
- name: Install Build Wrapper
|
- name: Install sonar-scanner and build-wrapper
|
||||||
uses: SonarSource/sonarqube-scan-action/install-build-wrapper@master
|
uses: SonarSource/sonarcloud-github-c-cpp@v2
|
||||||
- name: Run Build Wrapper
|
- name: Run build-wrapper
|
||||||
run: build-wrapper-linux-x86-64 --out-dir ${{ env.BUILD_WRAPPER_OUT_DIR }} make all
|
run: |
|
||||||
- name: SonarQube Scan
|
build-wrapper-linux-x86-64 --out-dir ${{ env.BUILD_WRAPPER_OUT_DIR }} make all
|
||||||
uses: SonarSource/sonarqube-scan-action@master
|
- name: Run sonar-scanner
|
||||||
env:
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||||
|
run: |
|
||||||
|
sonar-scanner --define sonar.cfamily.build-wrapper-output="${{ env.BUILD_WRAPPER_OUT_DIR }}"
|
||||||
|
|||||||
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"
|
|
||||||
53
.github/workflows/tagged_release.yml
vendored
Normal file
53
.github/workflows/tagged_release.yml
vendored
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
name: 'tagged-release'
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
tagged-release:
|
||||||
|
name: 'Tagged Release'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v4
|
||||||
|
with:
|
||||||
|
python-version: '3.11'
|
||||||
|
- uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: '18'
|
||||||
|
|
||||||
|
- name: Install PlatformIO
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install -U platformio
|
||||||
|
platformio upgrade
|
||||||
|
pio pkg update
|
||||||
|
|
||||||
|
- 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 firmware
|
||||||
|
run: |
|
||||||
|
platformio run -e ci
|
||||||
|
|
||||||
|
- name: Build S3 firmware
|
||||||
|
run: |
|
||||||
|
platformio run -e ci_s3
|
||||||
|
|
||||||
|
- name: Release
|
||||||
|
uses: 'marvinpinto/action-automatic-releases@latest'
|
||||||
|
with:
|
||||||
|
repo_token: '${{ secrets.GITHUB_TOKEN }}'
|
||||||
|
prerelease: false
|
||||||
|
files: |
|
||||||
|
CHANGELOG.md
|
||||||
|
./build/firmware/*.*
|
||||||
62
.github/workflows/test_release.yml
vendored
62
.github/workflows/test_release.yml
vendored
@@ -1,69 +1,56 @@
|
|||||||
name: 'Build test release'
|
name: 'test-release'
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- 'test'
|
- 'dev2'
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
pre-release:
|
pre-release:
|
||||||
name: 'Build Test Release'
|
name: 'Automatic test-release build'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
steps:
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
- name: Install python 3.13
|
- uses: actions/setup-python@v4
|
||||||
uses: actions/setup-python@v6
|
|
||||||
with:
|
with:
|
||||||
python-version: '3.13'
|
python-version: '3.11'
|
||||||
|
- uses: actions/setup-node@v3
|
||||||
- name: Install Node.js 24
|
|
||||||
uses: actions/setup-node@v6
|
|
||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: '18'
|
||||||
|
|
||||||
- name: Checkout repository
|
- name: Get EMS-ESP source code and version
|
||||||
uses: actions/checkout@v5
|
|
||||||
|
|
||||||
- name: Enable Corepack
|
|
||||||
run: corepack enable pnpm
|
|
||||||
|
|
||||||
- name: Get the EMS-ESP version
|
|
||||||
id: build_info
|
id: build_info
|
||||||
run: |
|
run: |
|
||||||
version=`grep -E '^#define EMSESP_APP_VERSION' ./src/emsesp_version.h | awk -F'"' '{print $2}'`
|
version=`grep -E '^#define EMSESP_APP_VERSION' ./src/version.h | awk -F'"' '{print $2}'`
|
||||||
echo "VERSION=$version" >> $GITHUB_OUTPUT
|
echo "VERSION=$version" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Install PlatformIO
|
- name: Install PlatformIO
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
pip install -U platformio
|
pip install -U platformio
|
||||||
python -m pip install intelhex
|
|
||||||
|
|
||||||
- name: Build webUI
|
- name: Build WebUI
|
||||||
run: |
|
run: |
|
||||||
platformio run -e build_webUI
|
cd interface
|
||||||
|
yarn install
|
||||||
|
yarn typesafe-i18n --no-watch
|
||||||
|
sed -i "s/= 'pl'/= 'en'/" ./src/i18n/i18n-util.ts
|
||||||
|
yarn build
|
||||||
|
yarn webUI
|
||||||
|
|
||||||
- name: Build modbus
|
- name: Build firmware
|
||||||
run: |
|
run: |
|
||||||
platformio run -e build_modbus
|
platformio run -e ci
|
||||||
|
|
||||||
- name: Build standalone
|
- name: Build S3 firmware
|
||||||
run: |
|
run: |
|
||||||
platformio run -e build_standalone
|
platformio run -e ci_s3
|
||||||
|
|
||||||
- name: Build all PIO target environments, from default_envs
|
- name: Create a GH Release
|
||||||
run: |
|
|
||||||
platformio run
|
|
||||||
|
|
||||||
- name: Create GitHub Release
|
|
||||||
id: 'automatic_releases'
|
id: 'automatic_releases'
|
||||||
uses: emsesp/action-automatic-releases@v1.0.0
|
uses: 'marvinpinto/action-automatic-releases@latest'
|
||||||
with:
|
with:
|
||||||
repo_token: '${{ secrets.GITHUB_TOKEN }}'
|
repo_token: '${{ secrets.GITHUB_TOKEN }}'
|
||||||
title: Test Build v${{steps.build_info.outputs.VERSION}}
|
title: Test Build v${{steps.build_info.outputs.VERSION}}
|
||||||
@@ -72,4 +59,3 @@ jobs:
|
|||||||
files: |
|
files: |
|
||||||
CHANGELOG_LATEST.md
|
CHANGELOG_LATEST.md
|
||||||
./build/firmware/*.*
|
./build/firmware/*.*
|
||||||
|
|
||||||
|
|||||||
59
.gitignore
vendored
59
.gitignore
vendored
@@ -2,7 +2,7 @@
|
|||||||
.vscode/c_cpp_properties.json
|
.vscode/c_cpp_properties.json
|
||||||
.vscode/extensions.json
|
.vscode/extensions.json
|
||||||
.vscode/launch.json
|
.vscode/launch.json
|
||||||
.vscode/settings.json
|
# .vscode/settings.json
|
||||||
|
|
||||||
# c++ compiling
|
# c++ compiling
|
||||||
.clang_complete
|
.clang_complete
|
||||||
@@ -16,11 +16,13 @@ pio_local.ini
|
|||||||
# OS specific
|
# OS specific
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*Thumbs.db
|
*Thumbs.db
|
||||||
|
emsesp
|
||||||
|
|
||||||
# web specific
|
# web specfic
|
||||||
build/
|
build/
|
||||||
dist/
|
dist/
|
||||||
/data/www
|
/data/www
|
||||||
|
/lib/framework/WWWData.h
|
||||||
/interface/build
|
/interface/build
|
||||||
node_modules
|
node_modules
|
||||||
/interface/.eslintcache
|
/interface/.eslintcache
|
||||||
@@ -28,10 +30,20 @@ stats.html
|
|||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
.pnp.*
|
.pnp.*
|
||||||
analyse.html
|
.yarn/*
|
||||||
interface/vite.config.ts.timestamp*
|
!.yarn/patches
|
||||||
*.local
|
!.yarn/plugins
|
||||||
src/ESP32React/WWWData.h
|
!.yarn/releases
|
||||||
|
!.yarn/sdks
|
||||||
|
!.yarn/versions
|
||||||
|
yarn.lock
|
||||||
|
interface/analyse.html
|
||||||
|
|
||||||
|
# scripts
|
||||||
|
test.sh
|
||||||
|
scripts/run.sh
|
||||||
|
scripts/__pycache__
|
||||||
|
/scripts/stackdmp.txt
|
||||||
|
|
||||||
# i18n generated files
|
# i18n generated files
|
||||||
interface/src/i18n/i18n-react.tsx
|
interface/src/i18n/i18n-react.tsx
|
||||||
@@ -40,40 +52,13 @@ interface/src/i18n/i18n-util.ts
|
|||||||
interface/src/i18n/i18n-util.sync.ts
|
interface/src/i18n/i18n-util.sync.ts
|
||||||
interface/src/i18n/i18n-util.async.ts
|
interface/src/i18n/i18n-util.async.ts
|
||||||
|
|
||||||
# scripts
|
|
||||||
test.sh
|
|
||||||
scripts/run.sh
|
|
||||||
scripts/__pycache__
|
|
||||||
scripts/stackdmp.txt
|
|
||||||
|
|
||||||
# sonar
|
# sonar
|
||||||
.scannerwork/
|
.scannerwork/
|
||||||
sonar/
|
sonar/
|
||||||
bw-output/
|
bw-output/
|
||||||
|
|
||||||
# standalone executable for testing
|
# entity dump results
|
||||||
emsesp
|
# dump_entities.csv
|
||||||
interface/tsconfig.tsbuildinfo
|
# dump_entities.xls*
|
||||||
|
|
||||||
# python virtual environment
|
benchmark/*.log
|
||||||
venv/
|
|
||||||
|
|
||||||
# cspell
|
|
||||||
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
|
|
||||||
.cache/
|
|
||||||
interface/.tsbuildinfo
|
|
||||||
test/test_api/package-lock.json
|
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"MD033": false,
|
|
||||||
"MD013": false,
|
|
||||||
"MD045": false,
|
|
||||||
"MD041": false
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
*/node_modules/
|
|
||||||
build/
|
|
||||||
dist/*
|
|
||||||
interface/src/i18n/*
|
|
||||||
|
|
||||||
.typesafe-i18n.json
|
|
||||||
@@ -1,13 +1,8 @@
|
|||||||
{
|
{
|
||||||
"plugins": ["@trivago/prettier-plugin-sort-imports"],
|
|
||||||
"trailingComma": "none",
|
"trailingComma": "none",
|
||||||
"tabWidth": 2,
|
"tabWidth": 2,
|
||||||
"semi": true,
|
"semi": true,
|
||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"printWidth": 85,
|
"printWidth": 120,
|
||||||
"bracketSpacing": true,
|
"bracketSpacing": true
|
||||||
"importOrder": ["^react", "^@mui/(.*)$", "^api*/(.*)$", "<THIRD_PARTY_MODULES>", "^[./]"],
|
|
||||||
"importOrderSeparation": true,
|
|
||||||
"importOrderSortSpecifiers": true,
|
|
||||||
"importOrderGroupNamespaceSpecifiers": true
|
|
||||||
}
|
}
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"sonarCloudOrganization": "emsesp",
|
|
||||||
"projectKey": "emsesp_EMS-ESP32"
|
|
||||||
}
|
|
||||||
12
.vscode/extensions.json
vendored
Normal file
12
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
// See http://go.microsoft.com/fwlink/?LinkId=827846
|
||||||
|
// for the documentation about the extensions.json format
|
||||||
|
"recommendations": [
|
||||||
|
"dbaeumer.vscode-eslint",
|
||||||
|
"esbenp.prettier-vscode",
|
||||||
|
"platformio.platformio-ide"
|
||||||
|
],
|
||||||
|
"unwantedRecommendations": [
|
||||||
|
"ms-vscode.cpptools-extension-pack"
|
||||||
|
]
|
||||||
|
}
|
||||||
17
.vscode/settings.json
vendored
17
.vscode/settings.json
vendored
@@ -6,10 +6,6 @@
|
|||||||
"editor.codeActionsOnSave": {
|
"editor.codeActionsOnSave": {
|
||||||
"source.fixAll": "explicit"
|
"source.fixAll": "explicit"
|
||||||
},
|
},
|
||||||
"eslint.validate": [
|
|
||||||
"typescript"
|
|
||||||
],
|
|
||||||
"eslint.codeActionsOnSave.rules": null,
|
|
||||||
"eslint.nodePath": "interface/.yarn/sdks",
|
"eslint.nodePath": "interface/.yarn/sdks",
|
||||||
"eslint.workingDirectories": ["interface"],
|
"eslint.workingDirectories": ["interface"],
|
||||||
"prettier.prettierPath": "",
|
"prettier.prettierPath": "",
|
||||||
@@ -88,14 +84,5 @@
|
|||||||
"**/*.map",
|
"**/*.map",
|
||||||
"**/ArduinoJson/**"
|
"**/ArduinoJson/**"
|
||||||
],
|
],
|
||||||
"cSpell.enableFiletypes": [
|
"cSpell.enableFiletypes": ["!cpp"]
|
||||||
"ini",
|
}
|
||||||
"makefile"
|
|
||||||
],
|
|
||||||
"typescript.preferences.preferTypeOnlyAutoImports": true,
|
|
||||||
"sonarlint.pathToCompileCommands": "${workspaceFolder}/compile_commands.json",
|
|
||||||
"sonarlint.connectedMode.project": {
|
|
||||||
"connectionId": "emsesp",
|
|
||||||
"projectKey": "emsesp_EMS-ESP32"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
18
.vscode/tasks.json
vendored
Normal file
18
.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
// See https://go.microsoft.com/fwlink/?LinkId=733558
|
||||||
|
// for the documentation about the tasks.json format
|
||||||
|
"version": "2.0.0",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"type": "shell",
|
||||||
|
"label": "build standalone emsesp",
|
||||||
|
"command": "make",
|
||||||
|
"args": [],
|
||||||
|
"problemMatcher": ["$gcc"],
|
||||||
|
"group": {
|
||||||
|
"kind": "build",
|
||||||
|
"isDefault": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
210
CHANGELOG.md
210
CHANGELOG.md
@@ -5,214 +5,6 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
## [3.7.2] 22 March 2025
|
|
||||||
|
|
||||||
## Added
|
|
||||||
|
|
||||||
- change enum_heatingtype for remote control [#2268](https://github.com/emsesp/EMS-ESP32/issues/2268)
|
|
||||||
- system service commands [#2182](https://github.com/emsesp/EMS-ESP32/issues/2182)
|
|
||||||
- read 0x02A5 for thermostat CT200 [#2277](https://github.com/emsesp/EMS-ESP32/issues/2277)
|
|
||||||
- add "duplicate" option to Custom Entities [#2266](https://github.com/emsesp/EMS-ESP32/discussion/2266)
|
|
||||||
- mask bits for bool custom entities
|
|
||||||
- thermostat `reduce threshold` [#2288](https://github.com/emsesp/EMS-ESP32/issues/2288)
|
|
||||||
- thermostat `absent` [#1957](https://github.com/emsesp/EMS-ESP32/issues/1957)
|
|
||||||
- CR11 thermostat [#2295](https://github.com/emsesp/EMS-ESP32/issues/2295)
|
|
||||||
- Show ESP32's CPU temp in Hardware Status
|
|
||||||
- vacation mode for the CR50 [#2403](https://github.com/emsesp/EMS-ESP32/issues/2403)
|
|
||||||
- new Console command "set admin password" to set WebUI admin password
|
|
||||||
- support nested conditions in scheduler [#2451](https://github.com/emsesp/EMS-ESP32/issues/2451)
|
|
||||||
- allow mixed case in scheduler expressions [#2457](https://github.com/emsesp/EMS-ESP32/issues/2457)
|
|
||||||
- Suprapur-o [#2470](https://github.com/emsesp/EMS-ESP32/issues/2470)
|
|
||||||
|
|
||||||
## Fixed
|
|
||||||
|
|
||||||
- long numbers of custom entities [#2267](https://github.com/emsesp/EMS-ESP32/issues/2267)
|
|
||||||
- modbus command path to `api/` [#2276](https://github.com/emsesp/EMS-ESP32/issues/2276)
|
|
||||||
- info command for devices without entity-commands [#2274](https://github.com/emsesp/EMS-ESP32/issues/2274)
|
|
||||||
- CW100 settings telegram 0x241 [#2290](https://github.com/emsesp/EMS-ESP32/issues/2290)
|
|
||||||
- modbus signed 8bit values [#2294](https://github.com/emsesp/EMS-ESP32/issues/2294)
|
|
||||||
- thermostat date [#2313](https://github.com/emsesp/EMS-ESP32/issues/2313)
|
|
||||||
- Updated unknown compressor stati "enum_hpactivity" [#2311](https://github.com/emsesp/EMS-ESP32/pull/2311)
|
|
||||||
- Underline Tab headers in WebUI
|
|
||||||
- console unit tests fixed due to changed shell output
|
|
||||||
- tx-queue overflow in some heatpump systems [#2455](https://github.com/emsesp/EMS-ESP32/issues/2455)
|
|
||||||
|
|
||||||
## Changed
|
|
||||||
|
|
||||||
- show operation in pretty telegram between src and dst [#2263](https://github.com/emsesp/EMS-ESP32/discussions/2263)
|
|
||||||
- update eModbus to 1.7.2 [#2254](https://github.com/emsesp/EMS-ESP32/issues/2254)
|
|
||||||
- modbus timeout default to 300 sec, change setting from ms to sec [#2254](https://github.com/emsesp/EMS-ESP32/issues/2254)
|
|
||||||
- update AsyncTCP and ESPAsyncWebServer to latest versions
|
|
||||||
- update Arduino pio platform to 3.10.0 and optimized flash using build flags
|
|
||||||
- Version checker in WebUI improved
|
|
||||||
- rename `remoteseltemp` to `cooltemp` [#2456](https://github.com/emsesp/EMS-ESP32/issues/2456)
|
|
||||||
|
|
||||||
## [3.7.1] 29 November 2024
|
|
||||||
|
|
||||||
## Added
|
|
||||||
|
|
||||||
- include HA "unit_of_meas", "stat_cla" and "dev_cla" attributes for Number sensors [#2149](https://github.com/emsesp/EMS-ESP32/issues/2149)
|
|
||||||
- Bosch CS6800i AW - Silent Mode + Electrical Power Reduction (HP) [#2147](https://github.com/emsesp/EMS-ESP32/issues/2147)
|
|
||||||
- `/api/system/showeralert` and `/api/system/showertimer` [#2182](https://github.com/emsesp/EMS-ESP32/issues/2182)
|
|
||||||
- MX400 [#2198](https://github.com/emsesp/EMS-ESP32/issues/2198)
|
|
||||||
- SM200 values [#2212](https://github.com/emsesp/EMS-ESP32/discussions/2212)
|
|
||||||
|
|
||||||
## Fixed
|
|
||||||
|
|
||||||
- Modbus integration in 3.7.0 missing offset [#2148](https://github.com/emsesp/EMS-ESP32/issues/2148)
|
|
||||||
- fix changing TZ in NTPsettings without clearing enable+server, added DST support [#2142](https://github.com/emsesp/EMS-ESP32/issues/2142)
|
|
||||||
- Support MQTT Discovery (AD) with Domoticz [#2177](https://github.com/emsesp/EMS-ESP32/issues/2177)
|
|
||||||
- wwExtra (dhw extra) changed from temperature reading to number
|
|
||||||
- auxheaterstatus [#2192](https://github.com/emsesp/EMS-ESP32/issues/2192)
|
|
||||||
- lastCode character check [#2189](https://github.com/emsesp/EMS-ESP32/issues/2189)
|
|
||||||
- reading too many telegram parts
|
|
||||||
- heatpump cost UOMs [#2188](https://github.com/emsesp/EMS-ESP32/issues/2188)
|
|
||||||
- analog dac output and inputs on dac pins [#2201](https://github.com/emsesp/EMS-ESP32/discussions/2201)
|
|
||||||
- api memory leak [#2216](https://github.com/emsesp/EMS-ESP32/issues/2216)
|
|
||||||
- modbus multiple mixers [#2229](https://github.com/emsesp/EMS-ESP32/issues/2229)
|
|
||||||
- Last Will (LWT) not set on MQTT Connect [#2247](https://github.com/emsesp/EMS-ESP32/issues/2247)
|
|
||||||
|
|
||||||
## Changed
|
|
||||||
|
|
||||||
- name of wwstarts2 [#2217](https://github.com/emsesp/EMS-ESP32/discussions/2217)
|
|
||||||
|
|
||||||
## [3.7.0] 27 October 2024
|
|
||||||
|
|
||||||
## **IMPORTANT! BREAKING CHANGES with 3.6.5**
|
|
||||||
|
|
||||||
- "ww" and "wwc" has been renamed to "dhw". It is nested JSON object in both the MQTT and API outputs. The old prefix has also been removed from MQTT topics ([#1634](https://github.com/emsesp/EMS-ESP32/issues/1634)). This will impact historical data in home automation systems like Home Assistant and IOBroker. To preserve the current value of dhw energy (was previously nrgww) refer to this issue [#1938](https://github.com/emsesp/EMS-ESP32/issues/1938).
|
|
||||||
- dhw entities from the MM100/SM100 have been moved under a new Device called 'water'.
|
|
||||||
- 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/).
|
|
||||||
|
|
||||||
## Added
|
|
||||||
|
|
||||||
- some more entities for dhw with SM100 module
|
|
||||||
- thermostat second dhw circuit [#1634](https://github.com/emsesp/EMS-ESP32/issues/1634)
|
|
||||||
- remote thermostat emulation for RC100H, RC200 and FB10 [#1287](https://github.com/emsesp/EMS-ESP32/discussions/1287), [#1602](https://github.com/emsesp/EMS-ESP32/discussions/1602), [#1551](https://github.com/emsesp/EMS-ESP32/discussions/1551)
|
|
||||||
- heatpump dhw stop temperatures [#1624](https://github.com/emsesp/EMS-ESP32/issues/1624)
|
|
||||||
- reset history [#1695](https://github.com/emsesp/EMS-ESP32/issues/1695)
|
|
||||||
- heatpump entities `fan` and `shutdown` [#1690](https://github.com/emsesp/EMS-ESP32/discussions/1690)
|
|
||||||
- mqtt HA-mode 3 for v3.6 compatible HA entities, set on update v3.6->v3.7
|
|
||||||
- HP input states [#1723](https://github.com/emsesp/EMS-ESP32/discussions/1723)
|
|
||||||
- holiday settings for rego 3000 [#1735](https://github.com/emsesp/EMS-ESP32/issues/1735)
|
|
||||||
- Added scripts for OTA (scripts/upload.py and upload_cli.py) [#1738](https://github.com/emsesp/EMS-ESP32/issues/1738)
|
|
||||||
- timeout for remote thermostat emulation [#1680](https://github.com/emsesp/EMS-ESP32/discussions/1680), [#1774](https://github.com/emsesp/EMS-ESP32/issues/1774)
|
|
||||||
- CR120 thermostat as own model() [#1779](https://github.com/emsesp/EMS-ESP32/discussions/1779)
|
|
||||||
- modules - external linkable module library [#1778](https://github.com/emsesp/EMS-ESP32/issues/1778)
|
|
||||||
- scheduler onChange and Conditions [#1806](https://github.com/emsesp/EMS-ESP32/issues/1806)
|
|
||||||
- make remote control timeout editable [#1774](https://github.com/emsesp/EMS-ESP32/issues/1774)
|
|
||||||
- added extra pump characteristics (mode and pressure for EMS+) by @SLTKA [#1802](https://github.com/emsesp/EMS-ESP32/pull/1802)
|
|
||||||
- allow device name to be customized [#1174](https://github.com/emsesp/EMS-ESP32/issues/1174)
|
|
||||||
- Modbus support by @mheyse [#1744](https://github.com/emsesp/EMS-ESP32/issues/1744)
|
|
||||||
- System Message command [#1854](https://github.com/emsesp/EMS-ESP32/issues/1854)
|
|
||||||
- scheduler can use web get/post for values and commands [#1806](https://github.com/emsesp/EMS-ESP32/issues/1806)
|
|
||||||
- RT800 remote emulation [#1867](https://github.com/emsesp/EMS-ESP32/issues/1867)
|
|
||||||
- RC310 cooling parameters [#1857](https://github.com/emsesp/EMS-ESP32/issues/1857)
|
|
||||||
- command `api/device/entities` [#1897](https://github.com/emsesp/EMS-ESP32/issues/1897)
|
|
||||||
- switchprogmode [#1903](https://github.com/emsesp/EMS-ESP32/discussions/1903)
|
|
||||||
- autodetect and download firmware upgrades via the WebUI
|
|
||||||
- command 'show log' that lists out the current weblog buffer, showing last messages.
|
|
||||||
- default web log buffer to 25 lines for ESP32s with no PSRAM
|
|
||||||
- try and determine correct board profile if none is set during boot
|
|
||||||
- auto Scroll in WebLog UI - reduced delay so incoming logs are faster
|
|
||||||
- uploading custom support info, shown to Guest users in Help page [#2054](https://github.com/emsesp/EMS-ESP32/issues/2054)
|
|
||||||
- feature: Dashboard showing all data (favorites, sensors, custom) [#1958](https://github.com/emsesp/EMS-ESP32/issues/1958)
|
|
||||||
- entity for low-temperature boilers pump start temp (pumpOnTemp) #2088 [#2088](https://github.com/emsesp/EMS-ESP32/issues/2088)
|
|
||||||
- internal ESP32 temperature sensor on the S3 [#2077](https://github.com/emsesp/EMS-ESP32/issues/2077)
|
|
||||||
- MQTT status topic (used in connect and last will) set to Retain [#2086](https://github.com/emsesp/EMS-ESP32/discussions/2086)
|
|
||||||
- Czech language [2096](https://github.com/emsesp/EMS-ESP32/issues/2096)
|
|
||||||
- Developer Mode and send EMS Read Commands from WebUI [#2116](https://github.com/emsesp/EMS-ESP32/issues/2116)
|
|
||||||
- Scheduler functions [#2115](https://github.com/emsesp/EMS-ESP32/issues/2115)
|
|
||||||
- Set device custom name from telegram 0x01 [#2073](https://github.com/emsesp/EMS-ESP32/issues/2073)
|
|
||||||
|
|
||||||
## Fixed
|
|
||||||
|
|
||||||
- remote thermostat emulation for RC200 on Rego2000/3000 thermostats [#1691](https://github.com/emsesp/EMS-ESP32/discussions/1691)
|
|
||||||
- log shows data for F7/F9 requests
|
|
||||||
- Detection of LittleFS for factory setting wasn't working
|
|
||||||
- Check for bad GPIOs with Ethernet before the ethernet is initialized
|
|
||||||
- Show values with factor 50 on webUI [#2064](https://github.com/emsesp/EMS-ESP32/issues/2064)
|
|
||||||
- Rendering of values between -1 and 0
|
|
||||||
- Value for 32bit times not-set [#2109](https://github.com/emsesp/EMS-ESP32/issues/2109)
|
|
||||||
|
|
||||||
## Changed
|
|
||||||
|
|
||||||
- use flag for BC400 compatible thermostats, manage different mode settings
|
|
||||||
- use factory partition for 16M flash
|
|
||||||
- store digital out states to nvs
|
|
||||||
- Refresh UI - moving settings to one location [#1665](https://github.com/emsesp/EMS-ESP32/issues/1665)
|
|
||||||
- rename DeviceValueTypes, add UINT32 for custom entities
|
|
||||||
- dynamic register dhw circuits for thermostat
|
|
||||||
- removed OTA feature [#1738](https://github.com/emsesp/EMS-ESP32/issues/1738)
|
|
||||||
- added shower min duration [#1801](https://github.com/emsesp/EMS-ESP32/issues/1801)
|
|
||||||
- Include TXT file along with the generated CSV for Device Data export/download
|
|
||||||
- thermostat/remotetemp as command [#1835](https://github.com/emsesp/EMS-ESP32/discussions/1835)
|
|
||||||
- temperaturesensor id notation with underscore [#1794](https://github.com/emsesp/EMS-ESP32/discussions/1794)
|
|
||||||
- Change key-names in JSON to be compliant and consistent [#1860](https://github.com/emsesp/EMS-ESP32/issues/1860)
|
|
||||||
- Updates to webUI [#1920](https://github.com/emsesp/EMS-ESP32/issues/1920)
|
|
||||||
- Correct firmware naming #1933 [#1933](https://github.com/emsesp/EMS-ESP32/issues/1933)
|
|
||||||
- Don't start Serial console if not connected to a Serial port. Will initiate manually after a CTRL-C/CTRL-S
|
|
||||||
- WebLog UI matches color schema of the terminal console correctly
|
|
||||||
- Updated Web libraries, ArduinoJson
|
|
||||||
- Help page doesn't show detailed tech info if the user is not 'admin' role [#2054](https://github.com/emsesp/EMS-ESP32/issues/2054)
|
|
||||||
- removed system command `allvalues` and moved to an action called `export`
|
|
||||||
- Show ems-esp internal devices in device list of system/info
|
|
||||||
- Scheduler and mqtt run async on systems with psram
|
|
||||||
- Show IPv6 address type (local/global/ula) in log
|
|
||||||
|
|
||||||
## [3.6.5] March 23 2024
|
|
||||||
|
|
||||||
## **IMPORTANT! BREAKING CHANGES**
|
|
||||||
|
|
||||||
- The Wifi Tx Power setting in Network Settings will be reset to Auto
|
|
||||||
|
|
||||||
## Added
|
|
||||||
|
|
||||||
- thermostat boost mode and boost time [#1446](https://github.com/emsesp/EMS-ESP32/issues/1446)
|
|
||||||
- heatpump energy meters [#1463](https://github.com/emsesp/EMS-ESP32/issues/1463)
|
|
||||||
- heatpump max power [#1475](https://github.com/emsesp/EMS-ESP32/issues/1475)
|
|
||||||
- checkbox for MQTT-TLS enable [#1474](https://github.com/emsesp/EMS-ESP32/issues/1474)
|
|
||||||
- added SK (Slovak) language. Thanks @misa1515
|
|
||||||
- CPU info [#1497](https://github.com/emsesp/EMS-ESP32/pull/1497)
|
|
||||||
- Show network hostname in Web UI under Network Status
|
|
||||||
- Improved HA Discovery so each section (EMS device, Scheduler, Analog, Temperature, Custom, Shower) have their own section
|
|
||||||
- boiler Bosch C1200W, id 12, [#1536](https://github.com/emsesp/EMS-ESP32/issues/1536)
|
|
||||||
- mixer MM100 telegram 0x2CC [#1554](https://github.com/emsesp/EMS-ESP32/issues/1554)
|
|
||||||
- boiler hpSetDiffPressure [#1563](https://github.com/emsesp/EMS-ESP32/issues/1563)
|
|
||||||
- custom variables [#1423](https://github.com/emsesp/EMS-ESP32/issues/1423)
|
|
||||||
- weather compensation [#1642](https://github.com/emsesp/EMS-ESP32/issues/1642)
|
|
||||||
- env and partitions for DevKitC-1-N32R8 [#1635](https://github.com/emsesp/EMS-ESP32/discussions/1635)
|
|
||||||
- command `restart partitionname` and button long press to start with other partition [#1657](https://github.com/emsesp/EMS-ESP32/issues/1657)
|
|
||||||
- command `set service <mqtt|ota|ntp|ap> <enable|disable>` [#1663](https://github.com/emsesp/EMS-ESP32/issues/1663)
|
|
||||||
|
|
||||||
## Fixed
|
|
||||||
|
|
||||||
- exhaust temperature for some boilers
|
|
||||||
- add back boil2hyst [#1477](https://github.com/emsesp/EMS-ESP32/issues/1477)
|
|
||||||
- subscribed MQTT topics not detecting changes by EMS-ESP [#1494](https://github.com/emsesp/EMS-ESP32/issues/1494)
|
|
||||||
- changed HA name and grouping to be consistent [#1528](https://github.com/emsesp/EMS-ESP32/issues/1528)
|
|
||||||
- MQTT autodiscovery in Domoticz not working [#1360](https://github.com/emsesp/EMS-ESP32/issues/1528)
|
|
||||||
- dhw comfort for new ems+, [#1495](https://github.com/emsesp/EMS-ESP32/issues/1495)
|
|
||||||
- added writeable icon to Web's Custom Entity page for each entity shown in the table
|
|
||||||
- Wifi Tx Power not adjusted [#1614](https://github.com/emsesp/EMS-ESP32/issues/1614)
|
|
||||||
- MQTT discovery of custom entity doesn't consider type of data [#1587](https://github.com/emsesp/EMS-ESP32/issues/1587)
|
|
||||||
- WiFi TxPower wasn't correctly used. Added an 'Auto' setting, which is the default.
|
|
||||||
- dns w/wo IPv6 [#1644](https://github.com/emsesp/EMS-ESP32/issues/1644)
|
|
||||||
|
|
||||||
## Changed
|
|
||||||
|
|
||||||
- HA don't set entity_category to Diagnostic/Configuration for EMS entities [#1459](https://github.com/emsesp/EMS-ESP32/discussions/1459)
|
|
||||||
- upgraded ArduinoJson to 7.0.0 #1538 and then 7.0.2
|
|
||||||
- small changes to the API for analog and temperature sensors
|
|
||||||
- Length of mqtt Broker adress [#1619](https://github.com/emsesp/EMS-ESP32/issues/1619)
|
|
||||||
- C++ optimizations - see <https://github.com/emsesp/EMS-ESP32/pull/1615>
|
|
||||||
- Send MQTT heartbeat immediately after connection [#1628](https://github.com/emsesp/EMS-ESP32/issues/1628)
|
|
||||||
- 16MB partitions with second nvs, larger FS, Coredump, optional factory partition
|
|
||||||
- stop fetching empty telegrams after 5 min
|
|
||||||
|
|
||||||
## [3.6.4] November 24 2023
|
## [3.6.4] November 24 2023
|
||||||
|
|
||||||
## **IMPORTANT! BREAKING CHANGES**
|
## **IMPORTANT! BREAKING CHANGES**
|
||||||
@@ -444,7 +236,7 @@ There are breaking changes between 3.5.x and earlier versions of 3.6.0. Please r
|
|||||||
|
|
||||||
- fix Table resizing in WebUI [#519](https://github.com/emsesp/EMS-ESP32/issues/519)
|
- fix Table resizing in WebUI [#519](https://github.com/emsesp/EMS-ESP32/issues/519)
|
||||||
- allow larger customization files [#570](https://github.com/emsesp/EMS-ESP32/issues/570)
|
- allow larger customization files [#570](https://github.com/emsesp/EMS-ESP32/issues/570)
|
||||||
- losing entity wwcomfort [#581](https://github.com/emsesp/EMS-ESP32/issues/581)
|
- losing entitiy wwcomfort [#581](https://github.com/emsesp/EMS-ESP32/issues/581)
|
||||||
|
|
||||||
## Changed
|
## Changed
|
||||||
|
|
||||||
|
|||||||
@@ -1,77 +1,24 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
For more details go to [docs.emsesp.org](https://docs.emsesp.org/).
|
## [3.6.5]
|
||||||
|
|
||||||
## [3.7.3]
|
## **IMPORTANT! BREAKING CHANGES**
|
||||||
|
|
||||||
## Added
|
## Added
|
||||||
|
|
||||||
- analogsensor types: NTC and RGB-Led
|
- thermostat boost mode and boost time [#1446](https://github.com/emsesp/EMS-ESP32/issues/1446)
|
||||||
- Flag for HMC310 [#2465](https://github.com/emsesp/EMS-ESP32/issues/2465)
|
- heatpump energy meters [#1463](https://github.com/emsesp/EMS-ESP32/issues/1463)
|
||||||
- boiler auxheatersource [#2489](https://github.com/emsesp/EMS-ESP32/discussions/2489)
|
- heatpump max power [#1475](https://github.com/emsesp/EMS-ESP32/issues/1475)
|
||||||
- thermostat last error for RC100/300 [#2501](https://github.com/emsesp/EMS-ESP32/issues/2501)
|
- checkbox for MQTT-TLS enable [#1474](https://github.com/emsesp/EMS-ESP32/issues/1474)
|
||||||
- boiler 0xC6 telegram [#1963](https://github.com/emsesp/EMS-ESP32/issues/1963)
|
- added SK (Slovencina) language. Thanks @misa1515
|
||||||
- CS6800i changes [#2448](https://github.com/emsesp/EMS-ESP32/issues/2448), [#2449](https://github.com/emsesp/EMS-ESP32/issues/2449)
|
- CPU info [#1497](https://github.com/emsesp/EMS-ESP32/pull/1497)
|
||||||
- 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)
|
|
||||||
- implemented eFuse settings for BBQKees boards to store model type and ESP chipset
|
|
||||||
- Analogsensors for pulse output [#2624](https://github.com/emsesp/EMS-ESP32/discussions/2624)
|
|
||||||
- Analogsensors frequency input [#2631](https://github.com/emsesp/EMS-ESP32/discussions/2631)
|
|
||||||
- SRC plus thermostats [#2636](https://github.com/emsesp/EMS-ESP32/issues/2636)
|
|
||||||
- Greenstar 2000 [#2645](https://github.com/emsesp/EMS-ESP32/issues/2645)
|
|
||||||
- RC3xx `dhw modetype` [#2659](https://github.com/emsesp/EMS-ESP32/discussions/2659)
|
|
||||||
- new boiler entities VR0,VR1, compressor speed [#2669](https://github.com/emsesp/EMS-ESP32/issues/2669)
|
|
||||||
- solar temperature TS16 [#2690](https://github.com/emsesp/EMS-ESP32/issues/2690)
|
|
||||||
- pumpmode enum for HT3 boilers, add commands for manual defrost, chimneysweeper [#2727](https://github.com/emsesp/EMS-ESP32/issues/2727)
|
|
||||||
- pid settings [#2735](https://github.com/emsesp/EMS-ESP32/issues/2735)
|
|
||||||
- refresh MQTT button added to MQTT Settings page
|
|
||||||
- added LWT (Last Will and Testament) to MQTT entities in Home Assistant
|
|
||||||
- added api/metrics endpoint for prometheus integration by @gr3enk
|
|
||||||
[#2774](https://github.com/emsesp/EMS-ESP32/pull/2774)
|
|
||||||
|
|
||||||
## Fixed
|
## Fixed
|
||||||
|
|
||||||
- dhw/switchtime [#2490](https://github.com/emsesp/EMS-ESP32/issues/2490)
|
- exhaust temperature for some boilers
|
||||||
- switch to secure mqtt [#2492](https://github.com/emsesp/EMS-ESP32/issues/2492)
|
- add back boil2hyst [#1477](https://github.com/emsesp/EMS-ESP32/issues/1477)
|
||||||
- update link buttons [#2497](https://github.com/emsesp/EMS-ESP32/issues/2497)
|
- subscribed MQTT topics not detecting changes by EMS-ESP [#1494](https://github.com/emsesp/EMS-ESP32/issues/1494)
|
||||||
- refresh scheduler states [#2502](https://github.com/emsesp/EMS-ESP32/discussions/2502)
|
|
||||||
- also rebuild HA config on mqtt connect for scheduler, custom and shower
|
|
||||||
- FB100 controls the hc, not the master [#2510](https://github.com/emsesp/EMS-ESP32/issues/2510)
|
|
||||||
- IPM DHW module, [#2524](https://github.com/emsesp/EMS-ESP32/issues/2524)
|
|
||||||
- charge optimization [#2543](https://github.com/emsesp/EMS-ESP32/issues/2543)
|
|
||||||
- shower active state retained, shows correctly in HA
|
|
||||||
- MQTT Command Topic with slashes [#2571](https://github.com/emsesp/EMS-ESP32/issues/2571)
|
|
||||||
- Add pulsed water meter input to V1.3 gateway with Lilygo S3 [#2550](https://github.com/emsesp/EMS-ESP32/issues/2550)
|
|
||||||
- fix missing long 10-second press of Button to perform a factory reset
|
|
||||||
- fix wwMaxPower on Junkers ZBS14 [#2609](https://github.com/emsesp/EMS-ESP32/issues/2609)
|
|
||||||
- ventilation bypass state from telegram 0x55C [#1197](https://github.com/emsesp/EMS-ESP32/issues/1197)
|
|
||||||
- set selflowtemp for ems+ boilers [#2641](https://github.com/emsesp/EMS-ESP32/discussions/2641)
|
|
||||||
- syslog timestamp [#2704](https://github.com/emsesp/EMS-ESP32/issues/2704)
|
|
||||||
- fixed FS format command [#2720](https://github.com/emsesp/EMS-ESP32/discussions/2720)
|
|
||||||
- dhw priority setting to boiler and mixer, telegrams 0x2CC, 0x2CD, etc.
|
|
||||||
|
|
||||||
## Changed
|
## Changed
|
||||||
|
|
||||||
- show console log with ISO date/time [#2533](https://github.com/emsesp/EMS-ESP32/discussions/2533)
|
- HA don't set entity_category to Diagnostic/Configuration for EMS entities [#1459](https://github.com/emsesp/EMS-ESP32/discussions/1459)
|
||||||
- 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
|
|
||||||
- syslog mark interval set to 1 hour
|
|
||||||
- handle process_telegram in oneloop
|
|
||||||
- improved GPIO validation for Analog Sensors and System GPIOs
|
|
||||||
- entities with no values are greyed out in the Web UI in the Customization page
|
|
||||||
- added System Status to Web Status page
|
|
||||||
- show number on entities and supported languages in log on boot
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ Everybody is welcome and invited to contribute to the EMS-ESP Project by:
|
|||||||
|
|
||||||
- providing Pull Requests (Features, Fixes, suggestions)
|
- providing Pull Requests (Features, Fixes, suggestions)
|
||||||
- testing new released features and report issues on your EMS equipment
|
- testing new released features and report issues on your EMS equipment
|
||||||
- contributing to missing [documentation](https://docs.emsesp.org)
|
- contributing to missing [documentation](https://emsesp.github.io/docs)
|
||||||
|
|
||||||
This document describes rules that are in effect for this repository, meant for handling issues by contributors in the issue tracker and PRs.
|
This document describes rules that are in effect for this repository, meant for handling issues by contributors in the issue tracker and PRs.
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ Format: `<type>(<scope>): <subject>`
|
|||||||
|
|
||||||
## Example
|
## Example
|
||||||
|
|
||||||
```text
|
```
|
||||||
feat: add hat wobble
|
feat: add hat wobble
|
||||||
^--^ ^------------^
|
^--^ ^------------^
|
||||||
| |
|
| |
|
||||||
@@ -96,7 +96,7 @@ References:
|
|||||||
|
|
||||||
## Contributor License Agreement (CLA)
|
## Contributor License Agreement (CLA)
|
||||||
|
|
||||||
```text
|
```
|
||||||
By making a contribution to this project, I certify that:
|
By making a contribution to this project, I certify that:
|
||||||
|
|
||||||
(a) The contribution was created in whole or in part by me and I
|
(a) The contribution was created in whole or in part by me and I
|
||||||
|
|||||||
124
Makefile
124
Makefile
@@ -2,39 +2,8 @@
|
|||||||
# GNUMakefile for EMS-ESP
|
# GNUMakefile for EMS-ESP
|
||||||
#
|
#
|
||||||
|
|
||||||
_mkfile_path := $(abspath $(lastword $(MAKEFILE_LIST)))
|
NUMJOBS=${NUMJOBS:-" -j4 "}
|
||||||
I := $(patsubst %/,%,$(dir $(_mkfile_path)))
|
MAKEFLAGS+="j "
|
||||||
|
|
||||||
ifneq ($(words $(MAKECMDGOALS)),1)
|
|
||||||
.DEFAULT_GOAL = all
|
|
||||||
%:
|
|
||||||
@$(MAKE) $@ --no-print-directory -rRf $(firstword $(MAKEFILE_LIST))
|
|
||||||
else
|
|
||||||
ifndef ECHO
|
|
||||||
T := $(shell $(MAKE) $(MAKECMDGOALS) --no-print-directory \
|
|
||||||
-nrRf $(firstword $(MAKEFILE_LIST)) \
|
|
||||||
ECHO="COUNTTHIS" | grep -c "COUNTTHIS")
|
|
||||||
N := x
|
|
||||||
C = $(words $N)$(eval N := x $N)
|
|
||||||
ECHO = python3 $(I)/scripts/echo_progress.py --stepno=$C --nsteps=$T
|
|
||||||
endif
|
|
||||||
|
|
||||||
# Optimize parallel build configuration
|
|
||||||
UNAME_S := $(shell uname -s)
|
|
||||||
ifeq ($(UNAME_S),Linux)
|
|
||||||
EXTRA_CPPFLAGS = -D LINUX
|
|
||||||
JOBS ?= $(shell nproc)
|
|
||||||
endif
|
|
||||||
ifeq ($(UNAME_S),Darwin)
|
|
||||||
EXTRA_CPPFLAGS = -D OSX -Wno-tautological-constant-out-of-range-compare
|
|
||||||
JOBS ?= $(shell sysctl -n hw.ncpu)
|
|
||||||
endif
|
|
||||||
|
|
||||||
# Set optimal parallel build settings
|
|
||||||
MAKEFLAGS += -j$(JOBS) -l$(shell echo $$(($(JOBS) * 2)))
|
|
||||||
|
|
||||||
# $(info Number of jobs: $(JOBS))
|
|
||||||
|
|
||||||
#----------------------------------------------------------------------
|
#----------------------------------------------------------------------
|
||||||
# Project Structure
|
# Project Structure
|
||||||
#----------------------------------------------------------------------
|
#----------------------------------------------------------------------
|
||||||
@@ -44,29 +13,36 @@ MAKEFLAGS += -j$(JOBS) -l$(shell echo $$(($(JOBS) * 2)))
|
|||||||
# INCLUDES is a list of directories containing header files
|
# INCLUDES is a list of directories containing header files
|
||||||
# LIBRARIES is a list of directories containing libraries, this must be the top level containing include and lib
|
# LIBRARIES is a list of directories containing libraries, this must be the top level containing include and lib
|
||||||
#----------------------------------------------------------------------
|
#----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#TARGET := $(notdir $(CURDIR))
|
||||||
TARGET := emsesp
|
TARGET := emsesp
|
||||||
BUILD := build
|
BUILD := build
|
||||||
SOURCES := src/core src/devices src/web src/test lib_standalone lib/semver lib/espMqttClient/src lib/espMqttClient/src/* lib/ArduinoJson/src lib/uuid-common/src lib/uuid-console/src lib/uuid-log/src lib/PButton
|
SOURCES := src src/* lib_standalone lib/uuid-common/src lib/uuid-console/src lib/uuid-log/src src/devices lib/ArduinoJson/src lib/PButton lib/semver lib/espMqttClient/src lib/espMqttClient/src/*
|
||||||
INCLUDES := src/core src/devices src/web src/test lib/* lib_standalone lib/semver lib/espMqttClient/src lib/espMqttClient/src/Transport lib/ArduinoJson/src lib/uuid-common/src lib/uuid-console/src lib/uuid-log/src lib/uuid-telnet/src lib/uuid-syslog/src
|
INCLUDES := src lib_standalone lib/espMqttClient/src lib/espMqttClient/src/Transport lib/ArduinoJson/src lib/uuid-common/src lib/uuid-console/src lib/uuid-log/src lib/uuid-telnet/src lib/uuid-syslog/src lib/semver lib/* src/devices
|
||||||
LIBRARIES :=
|
LIBRARIES :=
|
||||||
|
|
||||||
CPPCHECK = cppcheck
|
CPPCHECK = cppcheck
|
||||||
CHECKFLAGS = -q --force --std=gnu++17
|
# CHECKFLAGS = -q --force --std=c++17
|
||||||
|
CHECKFLAGS = -q --force --std=c++11
|
||||||
|
|
||||||
#----------------------------------------------------------------------
|
#----------------------------------------------------------------------
|
||||||
# Languages Standard
|
# Languages Standard
|
||||||
#----------------------------------------------------------------------
|
#----------------------------------------------------------------------
|
||||||
C_STANDARD := -std=c17
|
C_STANDARD := -std=c17
|
||||||
CXX_STANDARD := -std=gnu++17
|
# CXX_STANDARD := -std=c++17
|
||||||
|
CXX_STANDARD := -std=gnu++11
|
||||||
|
|
||||||
|
# C_STANDARD := -std=c11
|
||||||
|
# CXX_STANDARD := -std=c++11
|
||||||
|
|
||||||
#----------------------------------------------------------------------
|
#----------------------------------------------------------------------
|
||||||
# Defined Symbols
|
# Defined Symbols
|
||||||
#----------------------------------------------------------------------
|
#----------------------------------------------------------------------
|
||||||
DEFINES += -DARDUINOJSON_ENABLE -DARDUINOJSON_ENABLE_ARDUINO_STRING -DARDUINOJSON_USE_DOUBLE=0
|
DEFINES += -DARDUINOJSON_ENABLE_STD_STRING=1 -DARDUINOJSON_ENABLE_PROGMEM=1 -DARDUINOJSON_ENABLE_ARDUINO_STRING -DARDUINOJSON_USE_DOUBLE=0
|
||||||
DEFINES += -DEMSESP_STANDALONE -DEMSESP_TEST -DEMSESP_DEBUG -DEMC_RX_BUFFER_SIZE=1500
|
DEFINES += -DEMSESP_DEBUG -DEMSESP_STANDALONE -DEMSESP_TEST -D__linux__ -DEMC_RX_BUFFER_SIZE=1500
|
||||||
DEFINES += $(ARGS)
|
DEFINES += $(ARGS)
|
||||||
|
|
||||||
DEFAULTS = -DEMSESP_DEFAULT_LOCALE=\"en\" -DEMSESP_DEFAULT_TX_MODE=8 -DEMSESP_DEFAULT_VERSION=\"3.7.3-dev\" -DEMSESP_DEFAULT_BOARD_PROFILE=\"S32S3\"
|
DEFAULTS = -DEMSESP_DEFAULT_LOCALE=\"en\" -DEMSESP_DEFAULT_TX_MODE=8 -DEMSESP_DEFAULT_VERSION=\"3.6.4-dev\" -DEMSESP_DEFAULT_BOARD_PROFILE=\"S32\"
|
||||||
|
|
||||||
#----------------------------------------------------------------------
|
#----------------------------------------------------------------------
|
||||||
# Sources & Files
|
# Sources & Files
|
||||||
@@ -74,21 +50,16 @@ DEFAULTS = -DEMSESP_DEFAULT_LOCALE=\"en\" -DEMSESP_DEFAULT_TX_MODE=8 -DEMSESP_DE
|
|||||||
OUTPUT := $(CURDIR)/$(TARGET)
|
OUTPUT := $(CURDIR)/$(TARGET)
|
||||||
SYMBOLS := $(CURDIR)/$(BUILD)/$(TARGET).out
|
SYMBOLS := $(CURDIR)/$(BUILD)/$(TARGET).out
|
||||||
|
|
||||||
# Optimize source discovery - use shell find for better performance
|
CSOURCES := $(foreach dir,$(SOURCES),$(wildcard $(dir)/*.c))
|
||||||
CSOURCES := $(shell find $(SOURCES) -name "*.c" 2>/dev/null)
|
CXXSOURCES := $(foreach dir,$(SOURCES),$(wildcard $(dir)/*.cpp))
|
||||||
CXXSOURCES := $(shell find $(SOURCES) -name "*.cpp" 2>/dev/null)
|
|
||||||
|
|
||||||
OBJS := $(patsubst %,$(BUILD)/%.o,$(basename $(CSOURCES)) $(basename $(CXXSOURCES)))
|
OBJS := $(patsubst %,$(BUILD)/%.o,$(basename $(CSOURCES)) $(basename $(CXXSOURCES)) )
|
||||||
DEPS := $(patsubst %,$(BUILD)/%.d,$(basename $(CSOURCES)) $(basename $(CXXSOURCES)))
|
DEPS := $(patsubst %,$(BUILD)/%.d,$(basename $(CSOURCES)) $(basename $(CXXSOURCES)) )
|
||||||
|
|
||||||
# Optimize include path discovery
|
INCLUDE += $(addprefix -I,$(foreach dir,$(INCLUDES), $(wildcard $(dir))))
|
||||||
INCLUDE_DIRS := $(shell find $(INCLUDES) -type d 2>/dev/null)
|
INCLUDE += $(addprefix -I,$(foreach dir,$(LIBRARIES),$(wildcard $(dir)/include)))
|
||||||
LIBRARY_INCLUDES := $(shell find $(LIBRARIES) -name "include" -type d 2>/dev/null)
|
|
||||||
INCLUDE += $(addprefix -I,$(INCLUDE_DIRS) $(LIBRARY_INCLUDES))
|
|
||||||
|
|
||||||
# Optimize library path discovery
|
LDLIBS += $(addprefix -L,$(foreach dir,$(LIBRARIES),$(wildcard $(dir)/lib)))
|
||||||
LIBRARY_DIRS := $(shell find $(LIBRARIES) -name "lib" -type d 2>/dev/null)
|
|
||||||
LDLIBS += $(addprefix -L,$(LIBRARY_DIRS))
|
|
||||||
|
|
||||||
#----------------------------------------------------------------------
|
#----------------------------------------------------------------------
|
||||||
# Compiler & Linker
|
# Compiler & Linker
|
||||||
@@ -105,18 +76,14 @@ CXX := /usr/bin/g++
|
|||||||
# LDFLAGS Linker Flags
|
# LDFLAGS Linker Flags
|
||||||
#----------------------------------------------------------------------
|
#----------------------------------------------------------------------
|
||||||
CPPFLAGS += $(DEFINES) $(DEFAULTS) $(INCLUDE)
|
CPPFLAGS += $(DEFINES) $(DEFAULTS) $(INCLUDE)
|
||||||
CPPFLAGS += -ggdb -g3 -MMD
|
CPPFLAGS += -ggdb
|
||||||
CPPFLAGS += -flto=auto
|
CPPFLAGS += -g3
|
||||||
CPPFLAGS += -Wall -Wextra -Werror -Wswitch-enum
|
CPPFLAGS += -Os
|
||||||
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)
|
|
||||||
|
|
||||||
CFLAGS += $(CPPFLAGS)
|
CFLAGS += $(CPPFLAGS)
|
||||||
CXXFLAGS += $(CPPFLAGS)
|
CFLAGS += -Wall -Wextra -Werror -Wswitch-enum -Wno-unused-parameter -Wno-inconsistent-missing-override -Wno-missing-braces -Wno-unused-lambda-capture -Wno-sign-compare
|
||||||
LDFLAGS =
|
|
||||||
|
CXXFLAGS += $(CFLAGS) -MMD
|
||||||
|
|
||||||
#----------------------------------------------------------------------
|
#----------------------------------------------------------------------
|
||||||
# Compiler & Linker Commands
|
# Compiler & Linker Commands
|
||||||
@@ -131,8 +98,7 @@ else
|
|||||||
LD := $(CXX)
|
LD := $(CXX)
|
||||||
endif
|
endif
|
||||||
|
|
||||||
# Dependency file generation
|
#DEPFLAGS += -MF $(BUILD)/$*.d
|
||||||
DEPFLAGS += -MF $(BUILD)/$*.d -MT $@
|
|
||||||
|
|
||||||
LINK.o = $(LD) $(LDFLAGS) $(LDLIBS) $^ -o $@
|
LINK.o = $(LD) $(LDFLAGS) $(LDLIBS) $^ -o $@
|
||||||
COMPILE.c = $(CC) $(C_STANDARD) $(CFLAGS) $(DEPFLAGS) -c $< -o $@
|
COMPILE.c = $(CC) $(C_STANDARD) $(CFLAGS) $(DEPFLAGS) -c $< -o $@
|
||||||
@@ -149,10 +115,7 @@ COMPILE.cpp = $(CXX) $(CXX_STANDARD) $(CXXFLAGS) $(DEPFLAGS) -c $< -o $@
|
|||||||
.SUFFIXES:
|
.SUFFIXES:
|
||||||
.INTERMEDIATE:
|
.INTERMEDIATE:
|
||||||
.PRECIOUS: $(OBJS) $(DEPS)
|
.PRECIOUS: $(OBJS) $(DEPS)
|
||||||
.PHONY: all clean help cppcheck run
|
.PHONY: all clean help
|
||||||
|
|
||||||
# Enable second expansion for more flexible rules
|
|
||||||
.SECONDEXPANSION:
|
|
||||||
|
|
||||||
#----------------------------------------------------------------------
|
#----------------------------------------------------------------------
|
||||||
# Targets
|
# Targets
|
||||||
@@ -161,26 +124,23 @@ COMPILE.cpp = $(CXX) $(CXX_STANDARD) $(CXXFLAGS) $(DEPFLAGS) -c $< -o $@
|
|||||||
.SILENT: $(OUTPUT)
|
.SILENT: $(OUTPUT)
|
||||||
|
|
||||||
all: $(OUTPUT)
|
all: $(OUTPUT)
|
||||||
@$(ECHO) Build complete.
|
|
||||||
|
|
||||||
$(OUTPUT): $(OBJS)
|
$(OUTPUT): $(OBJS)
|
||||||
@mkdir -p $(@D)
|
@mkdir -p $(@D)
|
||||||
@$(ECHO) Linking $@
|
|
||||||
$(LINK.o)
|
$(LINK.o)
|
||||||
|
$(SYMBOLS.out)
|
||||||
|
|
||||||
$(BUILD)/%.o: %.c
|
$(BUILD)/%.o: %.c
|
||||||
@mkdir -p $(@D)
|
@mkdir -p $(@D)
|
||||||
@$(ECHO) Compiling $@
|
$(COMPILE.c)
|
||||||
@$(COMPILE.c)
|
|
||||||
|
|
||||||
$(BUILD)/%.o: %.cpp
|
$(BUILD)/%.o: %.cpp
|
||||||
@mkdir -p $(@D)
|
@mkdir -p $(@D)
|
||||||
@$(ECHO) Compiling $@
|
$(COMPILE.cpp)
|
||||||
@$(COMPILE.cpp)
|
|
||||||
|
|
||||||
$(BUILD)/%.o: %.s
|
$(BUILD)/%.o: %.s
|
||||||
@mkdir -p $(@D)
|
@mkdir -p $(@D)
|
||||||
@$(COMPILE.s)
|
$(COMPILE.s)
|
||||||
|
|
||||||
cppcheck: $(SOURCES)
|
cppcheck: $(SOURCES)
|
||||||
$(CPPCHECK) $(CHECKFLAGS) $^
|
$(CPPCHECK) $(CHECKFLAGS) $^
|
||||||
@@ -189,21 +149,11 @@ run: $(OUTPUT)
|
|||||||
@$<
|
@$<
|
||||||
|
|
||||||
.PHONY: clean
|
.PHONY: clean
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
@$(RM) -rf $(BUILD) $(OUTPUT)
|
@$(RM) -rf $(BUILD) $(OUTPUT)
|
||||||
|
|
||||||
help:
|
help:
|
||||||
@echo "Available targets:"
|
@echo available targets: all run clean
|
||||||
@echo " all - Build the project (default)"
|
@echo $(OUTPUT)
|
||||||
@echo " run - Build and run the executable"
|
|
||||||
@echo " clean - Remove build artifacts"
|
|
||||||
@echo " cppcheck - Run static analysis"
|
|
||||||
@echo " help - Show this help message"
|
|
||||||
@echo ""
|
|
||||||
@echo "Output: $(OUTPUT)"
|
|
||||||
@echo "Jobs: $(JOBS)"
|
|
||||||
|
|
||||||
-include $(DEPS)
|
-include $(DEPS)
|
||||||
|
|
||||||
endif
|
|
||||||
|
|||||||
110
README.md
110
README.md
@@ -1,102 +1,54 @@
|
|||||||
<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/blob/main/CHANGELOG.md)
|
||||||
[](https://github.com/emsesp/EMS-ESP32/commits/main)
|
[](https://github.com/emsesp/EMS-ESP32/commits/main)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://sonarcloud.io/summary/new_code?id=emsesp_EMS-ESP32)
|
[](https://sonarcloud.io/summary/new_code?id=emsesp_EMS-ESP32)
|
||||||
[](https://app.codacy.com/gh/emsesp/EMS-ESP32/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade)
|
[](https://www.codacy.com/gh/emsesp/EMS-ESP32/dashboard?utm_source=github.com&utm_medium=referral&utm_content=emsesp/EMS-ESP32&utm_campaign=Badge_Grade)
|
||||||
[](https://github.com/emsesp/EMS-ESP32/releases)
|
[](https://github.com/emsesp/EMS-ESP32/releases)
|
||||||
[](https://discord.gg/3J3GgnzpyT)
|
[](https://discord.gg/3J3GgnzpyT)
|
||||||
|
|
||||||
[](https://github.com/emsesp/EMS-ESP32/stargazers)
|
[](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)
|
[](https://www.paypal.com/paypalme/prderbyshire/2)
|
||||||
|
|
||||||
**EMS-ESP** is an open-source firmware for the Espressif ESP32 microcontroller to communicate with **EMS** (Energy Management System) compatible equipment from manufacturers such as Bosch, Buderus, Nefit, Junkers, Worcester, Sieger, elm.leblanc and iVT.
|
**EMS-ESP** is an open-source firmware for the Espressif ESP32 microcontroller that communicates with **EMS** (Energy Management System) based equipment from manufacturers like Bosch, Buderus, Nefit, Junkers, Worcester and Sieger. It requires a small gateway circuit to interface with the EMS bus which can be purchased from <https://bbqkees-electronics.nl> or custom built.
|
||||||
|
|
||||||
It requires a small circuit to interface with the EMS bus which can be purchased from <https://bbqkees-electronics.nl> or custom built.
|
## **Features**
|
||||||
|
|
||||||
## 📦 **Key Features**
|
- A multi-user, multi-language secure web interface to change settings and monitor incoming data
|
||||||
|
- A console, accessible via Serial and Telnet for more advanced monitoring
|
||||||
|
- Native support for Home Assistant, Domoticz and openHAB via [MQTT Discovery](https://www.home-assistant.io/docs/mqtt/discovery/)
|
||||||
|
- Can run standalone as an independent WiFi Access Point or join an existing WiFi network
|
||||||
|
- Easy first-time configuration via a web Captive Portal
|
||||||
|
- Support for more than [110+ EMS devices](https://emsesp.github.io/docs/All-Devices/) (boilers, thermostats, solar modules, mixer modules, heat pumps, gateways, switches, heat sources)
|
||||||
|
|
||||||
- Compatible with EMS, EMS+, EMS2, EMS Plus, Logamatic EMS, Junkers 2-wire, Heatronic 3 and 4
|
## **Documentation**
|
||||||
- Supporting over 120 different EMS compatible devices such as thermostats, boilers, heat pumps, mixing units, solar modules, connect modules, ventilation units, switches and more
|
|
||||||
- Easy to add external Temperature and Analog sensors that are attached to GPIO pins on the ESP32 board
|
|
||||||
- A multi-user, multi-language web interface to change settings and monitor incoming data
|
|
||||||
- A simple to use console, accessible via Serial/USB or Telnet for advanced operations and detailed monitoring
|
|
||||||
- Native integration with Home Assistant, Domoticz, openHAB and Modbus
|
|
||||||
- Easy setup and install with automatic updates
|
|
||||||
- Simulation of remote thermostats
|
|
||||||
- Localized in 11 languages, and customizable to rename any device or sensor
|
|
||||||
- Extendable by adding own custom EMS entities
|
|
||||||
- Expandable via adding user-built external modules
|
|
||||||
- A powerful Scheduler to automate tasks and trigger events based data changes
|
|
||||||
- A Notification service to alert you of important events
|
|
||||||
|
|
||||||
## 🚀 **Installing**
|
For the complete documentation on how to install, configure and get support visit the [EMS-ESP Wiki](https://emsesp.github.io/docs).
|
||||||
|
|
||||||
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.
|
## **Support**
|
||||||
|
|
||||||
## 📋 **Documentation**
|
|
||||||
|
|
||||||
Visit [emsesp.org](https://docs.emsesp.org) for more details on how to install and configure EMS-ESP. There is also a collection of Frequently Asked Questions and Troubleshooting tips with example customizations from the community.
|
|
||||||
|
|
||||||
## 💬 **Getting Support**
|
|
||||||
|
|
||||||
To chat with the community reach out on our [Discord Server](https://discord.gg/3J3GgnzpyT).
|
To chat with the community reach out on our [Discord Server](https://discord.gg/3J3GgnzpyT).
|
||||||
|
|
||||||
If you find an issue or have a request, see [how to request support](https://docs.emsesp.org/Support/) on how to submit a bug report or feature request.
|
If you like **EMS-ESP**, please give it a star, or fork it and contribute or offer a small donation!
|
||||||
|
|
||||||
## 🎥 **Live Demo**
|
## **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.
|
For a live demo of the Web UI click [here](https://ems-esp.derbyshire.nl) and log in with any username/password.
|
||||||
|
|
||||||
## 💖 **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).
|
EMS-ESP is a project owned and maintained by [proddy](https://github.com/proddy) and [MichaelDvP](https://github.com/MichaelDvP).
|
||||||
|
|
||||||
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.
|
## **Libraries used**
|
||||||
|
|
||||||
## 📦 **Building**
|
- [esp8266-react](https://github.com/rjwats/esp8266-react) by @rjwats for the framework that provides the core of the Web UI
|
||||||
|
- [uuid-\*](https://github.com/nomis/mcu-uuid-console) from @nomis. The console, syslog, telnet and logging are based off these open source libraries
|
||||||
|
- [ArduinoJson](https://github.com/bblanchon/ArduinoJson) for all the JSON
|
||||||
|
- [espMqttClient](https://github.com/bertmelis/espMqttClient) for the MQTT client, with custom modifications from @MichaelDvP and @proddy
|
||||||
|
|
||||||
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`.
|
## **License**
|
||||||
|
|
||||||
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
|
|
||||||
- [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
|
|
||||||
|
|
||||||
## 📜 **License**
|
|
||||||
|
|
||||||
This program is licensed under GPL-3.0
|
This program is licensed under GPL-3.0
|
||||||
|
|
||||||
@@ -106,14 +58,14 @@ This program is licensed under GPL-3.0
|
|||||||
|
|
||||||
| | |
|
| | |
|
||||||
| ---------------------------------- | -------------------------------- |
|
| ---------------------------------- | -------------------------------- |
|
||||||
|  |  |
|
| <img src="media/web_settings.png"> | <img src="media/web_status.png"> |
|
||||||
|  |  |
|
| <img src="media/web_devices.png"> | <img src="media/web_mqtt.png"> |
|
||||||
|  |  |
|
| <img src="media/web_edit.png"> | <img src="media/web_log.png"> |
|
||||||
|
|
||||||
### Telnet Console
|
### Telnet Console
|
||||||
|
|
||||||

|
<img src="media/console0.png" width=80% height=80%>
|
||||||
|
|
||||||
### Home Assistant
|
### In Home Assistant
|
||||||
|
|
||||||

|
<img src="media/ha_lovelace.png" width=80% height=80%>
|
||||||
|
|||||||
39
benchmark/http-client-test.js
Executable file
39
benchmark/http-client-test.js
Executable file
@@ -0,0 +1,39 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
const url = 'http://10.10.10.135/api/system/commands';
|
||||||
|
const queryParams = {
|
||||||
|
entity: 'commands',
|
||||||
|
id: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalRequests = 1000000;
|
||||||
|
const requestsPerCount = 100;
|
||||||
|
|
||||||
|
let requestCount = 0;
|
||||||
|
|
||||||
|
function fetchData() {
|
||||||
|
axios
|
||||||
|
.get(url, { params: queryParams })
|
||||||
|
.then((response) => {
|
||||||
|
requestCount++;
|
||||||
|
|
||||||
|
if (requestCount % requestsPerCount === 0) {
|
||||||
|
console.log(`Requests completed: ${requestCount}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestCount < totalRequests) {
|
||||||
|
fetchData();
|
||||||
|
} else {
|
||||||
|
console.log('All requests completed.');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Error making request:', error.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start making requests
|
||||||
|
console.log(`Starting test`);
|
||||||
|
fetchData();
|
||||||
38
benchmark/loadtest-http.sh
Executable file
38
benchmark/loadtest-http.sh
Executable file
@@ -0,0 +1,38 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Install:
|
||||||
|
# npm install -g autocannon
|
||||||
|
# yarn global add autocannon
|
||||||
|
#
|
||||||
|
# or run https://github.com/nearform/autocannon-ui
|
||||||
|
|
||||||
|
TEST_IP="10.10.10.135"
|
||||||
|
TEST_TIME=60
|
||||||
|
LOG_FILE=http-loadtest.log
|
||||||
|
TIMEOUT=10000
|
||||||
|
PROTOCOL=http
|
||||||
|
#PROTOCOL=https
|
||||||
|
|
||||||
|
if test -f "$LOG_FILE"; then
|
||||||
|
rm $LOG_FILE
|
||||||
|
fi
|
||||||
|
|
||||||
|
# for CONCURRENCY in 1 2 3 4 5 6 7 8 9 10 15 20
|
||||||
|
for CONCURRENCY in 1
|
||||||
|
#for CONCURRENCY in 20
|
||||||
|
do
|
||||||
|
printf "\n\nCLIENTS: *** $CONCURRENCY ***\n\n" >> $LOG_FILE
|
||||||
|
echo "Testing $CONCURRENCY clients on $PROTOCOL://$TEST_IP/"
|
||||||
|
autocannon -c $CONCURRENCY -w 1 -d $TEST_TIME --renderStatusCodes "$PROTOCOL://$TEST_IP/" >> $LOG_FILE 2>&1
|
||||||
|
printf "\n\n----------------\n\n" >> $LOG_FILE
|
||||||
|
sleep 1
|
||||||
|
|
||||||
|
echo "Testing $CONCURRENCY clients on $PROTOCOL://$TEST_IP/api/system/commands"
|
||||||
|
autocannon -c $CONCURRENCY -w 1 -d $TEST_TIME --renderStatusCodes "$PROTOCOL://$TEST_IP/api/system/commands" >> $LOG_FILE 2>&1
|
||||||
|
printf "\n\n----------------\n\n" >> $LOG_FILE
|
||||||
|
sleep 1
|
||||||
|
|
||||||
|
echo "Testing $CONCURRENCY clients on $PROTOCOL://$TEST_IP/app/icon.png"
|
||||||
|
autocannon -c $CONCURRENCY -w 1 -d $TEST_TIME --renderStatusCodes "$PROTOCOL://$TEST_IP/app/icon.png" >> $LOG_FILE 2>&1
|
||||||
|
printf "\n\n----------------\n\n" >> $LOG_FILE
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
8
benchmark/package.json
Normal file
8
benchmark/package.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"autocannon": "^7.14.0",
|
||||||
|
"axios": "^1.6.2",
|
||||||
|
"eventsource": "^2.0.2",
|
||||||
|
"ws": "^8.14.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
39
cspell.json
39
cspell.json
@@ -1,39 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://raw.githubusercontent.com/streetsidesoftware/cspell/main/cspell.schema.json",
|
|
||||||
"version": "0.2",
|
|
||||||
"dictionaryDefinitions": [
|
|
||||||
{
|
|
||||||
"name": "project-words",
|
|
||||||
"path": "./project-words.txt",
|
|
||||||
"addWords": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -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
@@ -1,232 +0,0 @@
|
|||||||
telegram_type_id,name,is_fetched
|
|
||||||
0x04,UBAFactory,fetched
|
|
||||||
0x06,RCTime,
|
|
||||||
0x0A,EasyMonitor,fetched
|
|
||||||
0x10,UBAErrorMessage1,
|
|
||||||
0x11,UBAErrorMessage2,
|
|
||||||
0x12,RCErrorMessage,
|
|
||||||
0x13,RCErrorMessage2,
|
|
||||||
0x14,UBATotalUptime,fetched
|
|
||||||
0x15,UBAMaintenanceData,
|
|
||||||
0x16,UBAParameters,fetched
|
|
||||||
0x18,UBAMonitorFast,
|
|
||||||
0x19,UBAMonitorSlow,
|
|
||||||
0x1A,UBASetPoints,
|
|
||||||
0x1C,UBAMaintenanceStatus,
|
|
||||||
0x1E,WM10TempMessage,
|
|
||||||
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,fetched
|
|
||||||
0x02A6,RC300Monitor,
|
|
||||||
0x02A7,RC300Monitor,
|
|
||||||
0x02A8,RC300Monitor,
|
|
||||||
0x02A9,RC300Monitor,
|
|
||||||
0x02AA,RC300Monitor,
|
|
||||||
0x02AB,RC300Monitor,
|
|
||||||
0x02AC,RC300Monitor,
|
|
||||||
0x02AF,RC300Summer,
|
|
||||||
0x02B0,RC300Summer,
|
|
||||||
0x02B1,RC300Summer,
|
|
||||||
0x02B2,RC300Summer,
|
|
||||||
0x02B3,RC300Summer,
|
|
||||||
0x02B4,RC300Summer,
|
|
||||||
0x02B5,RC300Summer,
|
|
||||||
0x02B6,RC300Summer,
|
|
||||||
0x02B9,RC300Set,
|
|
||||||
0x02BA,RC300Set,
|
|
||||||
0x02BB,RC300Set,
|
|
||||||
0x02BC,RC300Set,
|
|
||||||
0x02BD,RC300Set,
|
|
||||||
0x02BE,RC300Set,
|
|
||||||
0x02BF,RC300Set,
|
|
||||||
0x02C0,RC300Set,
|
|
||||||
0x02CC,HPPressure,fetched
|
|
||||||
0x02CD,MMPLUSConfigMessage,
|
|
||||||
0x02D6,HPPump2,fetched
|
|
||||||
0x02D7,MMPLUSStatusMessage,
|
|
||||||
0x02E0,UBASetPoints,
|
|
||||||
0x02F5,RC300WWmode,fetched
|
|
||||||
0x02F6,RC300WW2mode,fetched
|
|
||||||
0x0313,MMPLUSConfigMessage_WWC,fetched
|
|
||||||
0x031B,RC300WWtemp,fetched
|
|
||||||
0x031D,RC300WWmode2,
|
|
||||||
0x031E,RC300WWmode2,
|
|
||||||
0x0331,MMPLUSStatusMessage_WWC,
|
|
||||||
0x0358,SM100SystemConfig,fetched
|
|
||||||
0x035A,SM100CircuitConfig,fetched
|
|
||||||
0x035C,SM100HeatAssist,fetched
|
|
||||||
0x035D,SM100Circuit2Config,fetched
|
|
||||||
0x035F,SM100Config1,fetched
|
|
||||||
0x0361,SM100Differential,fetched
|
|
||||||
0x0362,SM100Monitor,
|
|
||||||
0x0363,SM100Monitor2,
|
|
||||||
0x0364,SM100Status,
|
|
||||||
0x0366,SM100Config,
|
|
||||||
0x036A,SM100Status2,
|
|
||||||
0x0380,SM100CollectorConfig,fetched
|
|
||||||
0x038E,SM100Energy,fetched
|
|
||||||
0x0391,SM100Time,fetched
|
|
||||||
0x0421,RC300Set2,
|
|
||||||
0x0422,RC300Set2,
|
|
||||||
0x0423,RC300Set2,
|
|
||||||
0x0424,RC300Set2,
|
|
||||||
0x043F,CRHolidays,fetched
|
|
||||||
0x0467,HPSet,
|
|
||||||
0x0468,HPSet,
|
|
||||||
0x0469,HPSet,
|
|
||||||
0x046A,HPSet,
|
|
||||||
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,
|
|
||||||
|
4341
dump_entities.csv
Normal file
4341
dump_entities.csv
Normal file
File diff suppressed because it is too large
Load Diff
6
esp32_partition_16M.csv
Normal file
6
esp32_partition_16M.csv
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# Name, Type, SubType, Offset, Size, Flags
|
||||||
|
nvs, data, nvs, 0x9000, 0x5000,
|
||||||
|
otadata, data, ota, , 0x2000,
|
||||||
|
app0, app, ota_0, , 0x7F0000,
|
||||||
|
app1, app, ota_1, , 0x7F0000,
|
||||||
|
spiffs, data, spiffs, , 64K,
|
||||||
|
@@ -25,12 +25,18 @@ build_flags =
|
|||||||
-D FACTORY_NTP_TIME_ZONE_FORMAT=\"CET-1CEST,M3.5.0,M10.5.0/3\"
|
-D FACTORY_NTP_TIME_ZONE_FORMAT=\"CET-1CEST,M3.5.0,M10.5.0/3\"
|
||||||
-D FACTORY_NTP_SERVER=\"time.google.com\"
|
-D FACTORY_NTP_SERVER=\"time.google.com\"
|
||||||
|
|
||||||
|
; OTA settings
|
||||||
|
-D FACTORY_OTA_PORT=8266
|
||||||
|
-D FACTORY_OTA_PASSWORD=\"ems-esp-neo\"
|
||||||
|
-D FACTORY_OTA_ENABLED=false
|
||||||
|
|
||||||
; MQTT settings
|
; MQTT settings
|
||||||
-D FACTORY_MQTT_ENABLED=false
|
-D FACTORY_MQTT_ENABLED=false
|
||||||
-D FACTORY_MQTT_HOST=\"\"
|
-D FACTORY_MQTT_HOST=\"\"
|
||||||
-D FACTORY_MQTT_PORT=1883
|
-D FACTORY_MQTT_PORT=1883
|
||||||
-D FACTORY_MQTT_USERNAME=\"\"
|
-D FACTORY_MQTT_USERNAME=\"\"
|
||||||
-D FACTORY_MQTT_PASSWORD=\"\"
|
-D FACTORY_MQTT_PASSWORD=\"\"
|
||||||
|
-D FACTORY_MQTT_CLIENT_ID=\"ems-esp\"
|
||||||
-D FACTORY_MQTT_KEEP_ALIVE=60
|
-D FACTORY_MQTT_KEEP_ALIVE=60
|
||||||
-D FACTORY_MQTT_CLEAN_SESSION=false
|
-D FACTORY_MQTT_CLEAN_SESSION=false
|
||||||
-D FACTORY_MQTT_MAX_TOPIC_LENGTH=128
|
-D FACTORY_MQTT_MAX_TOPIC_LENGTH=128
|
||||||
|
|||||||
12
interface/.eslintignore
Normal file
12
interface/.eslintignore
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
node_modules/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
.yarn/
|
||||||
|
|
||||||
|
.prettierrc
|
||||||
|
.eslintrc*
|
||||||
|
env.d.ts
|
||||||
|
progmem-generator.js
|
||||||
|
unpack.ts
|
||||||
|
vite.config.ts
|
||||||
|
package.json
|
||||||
108
interface/.eslintrc.json
Normal file
108
interface/.eslintrc.json
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
{
|
||||||
|
"env": {
|
||||||
|
"browser": true,
|
||||||
|
"es6": true
|
||||||
|
},
|
||||||
|
"extends": [
|
||||||
|
"eslint:recommended",
|
||||||
|
"airbnb/hooks",
|
||||||
|
"airbnb-typescript",
|
||||||
|
"plugin:react/recommended",
|
||||||
|
"plugin:react/jsx-runtime",
|
||||||
|
"plugin:@typescript-eslint/recommended",
|
||||||
|
"plugin:@typescript-eslint/recommended-requiring-type-checking",
|
||||||
|
"plugin:prettier/recommended",
|
||||||
|
"plugin:import/recommended"
|
||||||
|
],
|
||||||
|
"parser": "@typescript-eslint/parser",
|
||||||
|
"parserOptions": {
|
||||||
|
"ecmaFeatures": {
|
||||||
|
"jsx": true
|
||||||
|
},
|
||||||
|
"ecmaVersion": "latest",
|
||||||
|
"sourceType": "module",
|
||||||
|
"tsconfigRootDir": ".",
|
||||||
|
"project": ["tsconfig.json"]
|
||||||
|
},
|
||||||
|
"plugins": ["react", "@typescript-eslint", "autofix", "react-hooks"],
|
||||||
|
"settings": {
|
||||||
|
"import/resolver": {
|
||||||
|
"typescript": {
|
||||||
|
"project": "./tsconfig.json"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"react": {
|
||||||
|
"version": "18.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"object-shorthand": "error",
|
||||||
|
"no-console": "warn",
|
||||||
|
"@typescript-eslint/consistent-type-definitions": ["off", "type"],
|
||||||
|
"@typescript-eslint/explicit-function-return-type": "off",
|
||||||
|
"@typescript-eslint/no-unsafe-call": "off",
|
||||||
|
"@typescript-eslint/no-unsafe-enum-comparison": "off",
|
||||||
|
"@typescript-eslint/no-unsafe-assignment": "off",
|
||||||
|
"@typescript-eslint/no-unsafe-return": "off",
|
||||||
|
"@typescript-eslint/no-unsafe-member-access": "off",
|
||||||
|
"@typescript-eslint/naming-convention": "off",
|
||||||
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
|
"@typescript-eslint/no-unsafe-argument": "off",
|
||||||
|
"@typescript-eslint/restrict-plus-operands": "off",
|
||||||
|
"@typescript-eslint/no-unused-expressions": "off",
|
||||||
|
"@typescript-eslint/no-implied-eval": "off",
|
||||||
|
"@typescript-eslint/no-misused-promises": "off",
|
||||||
|
"arrow-body-style": ["error", "as-needed"],
|
||||||
|
"react-hooks/exhaustive-deps": "warn",
|
||||||
|
"@typescript-eslint/consistent-type-imports": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"prefer": "type-imports"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"import/order": [
|
||||||
|
"warn",
|
||||||
|
{
|
||||||
|
"groups": ["builtin", "external", "parent", "sibling", "index", "object", "type"],
|
||||||
|
"pathGroups": [
|
||||||
|
{
|
||||||
|
"pattern": "@/**/**",
|
||||||
|
"group": "parent",
|
||||||
|
"position": "before"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"alphabetize": { "order": "asc" }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
// "autofix/no-unused-vars": [
|
||||||
|
// "error",
|
||||||
|
// {
|
||||||
|
// "argsIgnorePattern": "^_",
|
||||||
|
// "ignoreRestSiblings": true,
|
||||||
|
// "destructuredArrayIgnorePattern": "^_"
|
||||||
|
// }
|
||||||
|
// ],
|
||||||
|
"react/self-closing-comp": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"component": true,
|
||||||
|
"html": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"@typescript-eslint/ban-types": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"extendDefaults": true,
|
||||||
|
"types": {
|
||||||
|
"{}": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"prettier/prettier": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"endOfLine": "auto"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
7
interface/.gitignore
vendored
Normal file
7
interface/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/sdks
|
||||||
|
!.yarn/versions
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
build/
|
build/
|
||||||
dist/
|
dist/
|
||||||
src/i18n/*
|
|
||||||
|
|
||||||
.prettierrc
|
.prettierrc
|
||||||
|
.yarn/
|
||||||
.typesafe-i18n.json
|
.typesafe-i18n.json
|
||||||
893
interface/.yarn/releases/yarn-4.0.2.cjs
vendored
Executable file
893
interface/.yarn/releases/yarn-4.0.2.cjs
vendored
Executable file
File diff suppressed because one or more lines are too long
7
interface/.yarnrc.yml
Normal file
7
interface/.yarnrc.yml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
compressionLevel: mixed
|
||||||
|
|
||||||
|
enableGlobalCache: false
|
||||||
|
|
||||||
|
nodeLinker: node-modules
|
||||||
|
|
||||||
|
yarnPath: .yarn/releases/yarn-4.0.2.cjs
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
// @ts-check
|
|
||||||
import eslint from '@eslint/js';
|
|
||||||
import prettierConfig from 'eslint-config-prettier';
|
|
||||||
import tseslint from 'typescript-eslint';
|
|
||||||
|
|
||||||
export default tseslint.config(
|
|
||||||
eslint.configs.recommended,
|
|
||||||
...tseslint.configs.recommendedTypeChecked,
|
|
||||||
prettierConfig,
|
|
||||||
{
|
|
||||||
languageOptions: {
|
|
||||||
parserOptions: {
|
|
||||||
project: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ignores: [
|
|
||||||
'dist/*',
|
|
||||||
'*.mjs',
|
|
||||||
'build/*',
|
|
||||||
'*.js',
|
|
||||||
'**/*.cjs',
|
|
||||||
'**/unpack.ts',
|
|
||||||
'i18n*.*'
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
rules: {
|
|
||||||
'@typescript-eslint/no-unsafe-enum-comparison': 'off',
|
|
||||||
'@typescript-eslint/no-unused-expressions': 'off',
|
|
||||||
'@typescript-eslint/no-unsafe-assignment': 'off',
|
|
||||||
'@typescript-eslint/no-unsafe-member-access': 'off',
|
|
||||||
'@typescript-eslint/no-unsafe-call': 'off',
|
|
||||||
'@typescript-eslint/no-misused-promises': [
|
|
||||||
'error',
|
|
||||||
{
|
|
||||||
checksVoidReturn: false
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
@@ -1,71 +1,78 @@
|
|||||||
{
|
{
|
||||||
"name": "EMS-ESP",
|
"name": "EMS-ESP",
|
||||||
"version": "3.7.3",
|
"version": "3.6.5",
|
||||||
"description": "EMS-ESP WebUI",
|
"description": "build EMS-ESP WebUI",
|
||||||
"homepage": "https://emsesp.org",
|
"homepage": "https://emsesp.github.io/docs",
|
||||||
"author": "proddy, emsesp.org",
|
"author": "proddy",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"preinstall": "npx only-allow pnpm",
|
|
||||||
"dev": "vite dev",
|
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"build-hosted": "typesafe-i18n && vite build --mode hosted",
|
"build-hosted": "typesafe-i18n --no-watch && vite build --mode hosted",
|
||||||
"mock-rest": "bun --watch ../mock-api/restServer.ts",
|
"preview-standalone": "typesafe-i18n --no-watch && vite build && concurrently -c \"auto\" \"npm:mock-api\" \"vite preview\"",
|
||||||
"preview-standalone": "typesafe-i18n --no-watch && vite build && concurrently -c \"auto\" \"pnpm:mock-rest\" \"vite preview\"",
|
"mock-api": "bun --watch ../mock-api/server.ts",
|
||||||
"standalone": "concurrently -c \"auto\" \"typesafe-i18n\" \"pnpm:mock-rest\" \"vite dev\"",
|
"standalone": "concurrently -c \"auto\" \"typesafe-i18n\" \"npm:mock-api\" \"vite\"",
|
||||||
"typesafe-i18n": "typesafe-i18n --no-watch",
|
"typesafe-i18n": "typesafe-i18n --no-watch",
|
||||||
"build_webUI": "typesafe-i18n --no-watch && vite build && node progmem-generator.js",
|
"webUI": "node progmem-generator.js",
|
||||||
"format": "prettier -l -w '**/*.{ts,tsx,js,css,json,md}'",
|
"format": "prettier --write '**/*.{ts,tsx,js,css,json,md}'",
|
||||||
"lint": "eslint . --fix",
|
"lint": "eslint . --cache --fix"
|
||||||
"standalone-devcontainer": "concurrently -c \"auto\" \"typesafe-i18n\" \"pnpm:mock-rest\" \"vite --host\""
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@alova/adapter-xhr": "2.3.0",
|
"@alova/adapter-xhr": "^1.0.2",
|
||||||
"@emotion/react": "^11.14.0",
|
"@babel/core": "^7.23.7",
|
||||||
"@emotion/styled": "^11.14.1",
|
"@emotion/react": "^11.11.3",
|
||||||
"@mui/icons-material": "^7.3.5",
|
"@emotion/styled": "^11.11.0",
|
||||||
"@mui/material": "^7.3.5",
|
"@mui/icons-material": "^5.15.2",
|
||||||
"@preact/compat": "^18.3.1",
|
"@mui/material": "^5.15.2",
|
||||||
"@table-library/react-table-library": "4.1.15",
|
"@table-library/react-table-library": "4.1.7",
|
||||||
"alova": "3.4.0",
|
"@types/imagemin": "^8.0.5",
|
||||||
|
"@types/lodash-es": "^4.17.12",
|
||||||
|
"@types/node": "^20.10.6",
|
||||||
|
"@types/react": "^18.2.46",
|
||||||
|
"@types/react-dom": "^18.2.18",
|
||||||
|
"@types/react-router-dom": "^5.3.3",
|
||||||
|
"alova": "^2.16.2",
|
||||||
"async-validator": "^4.2.5",
|
"async-validator": "^4.2.5",
|
||||||
"etag": "^1.8.1",
|
"history": "^5.3.0",
|
||||||
"formidable": "^3.5.4",
|
|
||||||
"jwt-decode": "^4.0.0",
|
"jwt-decode": "^4.0.0",
|
||||||
"magic-string": "^0.30.21",
|
"lodash-es": "^4.17.21",
|
||||||
"mime-types": "^3.0.2",
|
"mime-types": "^2.1.35",
|
||||||
"preact": "^10.27.2",
|
"react": "latest",
|
||||||
"react": "^19.2.0",
|
"react-dom": "latest",
|
||||||
"react-dom": "^19.2.0",
|
"react-dropzone": "^14.2.3",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^4.12.0",
|
||||||
"react-router": "^7.9.6",
|
"react-router-dom": "^6.21.1",
|
||||||
"react-toastify": "^11.0.5",
|
"react-toastify": "^9.1.3",
|
||||||
|
"sockette": "^2.0.6",
|
||||||
"typesafe-i18n": "^5.26.2",
|
"typesafe-i18n": "^5.26.2",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.3.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.28.5",
|
"@preact/compat": "^17.1.2",
|
||||||
"@eslint/js": "^9.39.1",
|
"@preact/preset-vite": "^2.7.0",
|
||||||
"@preact/compat": "^18.3.1",
|
"@typescript-eslint/eslint-plugin": "^6.17.0",
|
||||||
"@preact/preset-vite": "^2.10.2",
|
"@typescript-eslint/parser": "^6.17.0",
|
||||||
"@trivago/prettier-plugin-sort-imports": "^6.0.0",
|
"concurrently": "^8.2.2",
|
||||||
"@types/node": "^24.10.1",
|
"eslint": "^8.56.0",
|
||||||
"@types/react": "^19.2.7",
|
"eslint-config-airbnb": "^19.0.4",
|
||||||
"@types/react-dom": "^19.2.3",
|
"eslint-config-airbnb-typescript": "^17.1.0",
|
||||||
"axe-core": "^4.11.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"concurrently": "^9.2.1",
|
"eslint-import-resolver-typescript": "^3.6.1",
|
||||||
"eslint": "^9.39.1",
|
"eslint-plugin-autofix": "^1.1.0",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-plugin-import": "^2.29.1",
|
||||||
"prettier": "^3.7.3",
|
"eslint-plugin-jsx-a11y": "^6.8.0",
|
||||||
"rollup-plugin-visualizer": "^6.0.5",
|
"eslint-plugin-prettier": "alpha",
|
||||||
"terser": "^5.44.1",
|
"eslint-plugin-react": "^7.33.2",
|
||||||
"typescript-eslint": "^8.48.0",
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
"vite": "^7.2.4",
|
"preact": "^10.19.3",
|
||||||
|
"prettier": "^3.1.1",
|
||||||
|
"rollup-plugin-visualizer": "^5.12.0",
|
||||||
|
"terser": "^5.26.0",
|
||||||
|
"vite": "^5.0.10",
|
||||||
"vite-plugin-imagemin": "^0.6.1",
|
"vite-plugin-imagemin": "^0.6.1",
|
||||||
"vite-tsconfig-paths": "^5.1.4"
|
"vite-tsconfig-paths": "^4.2.3"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.24.0+sha512.01ff8ae71b4419903b65c60fb2dc9d34cf8bb6e06d03bde112ef38f7a34d6904c424ba66bea5cdcf12890230bf39f9580473140ed9c946fef328b6e5238a345a"
|
"packageManager": "yarn@4.0.2"
|
||||||
}
|
}
|
||||||
|
|||||||
6090
interface/pnpm-lock.yaml
generated
6090
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,148 +1,100 @@
|
|||||||
import etag from 'etag';
|
import { readdirSync, existsSync, unlinkSync, readFileSync, createWriteStream } from 'fs';
|
||||||
import {
|
import { resolve, relative, sep } from 'path';
|
||||||
createWriteStream,
|
|
||||||
existsSync,
|
|
||||||
readFileSync,
|
|
||||||
readdirSync,
|
|
||||||
unlinkSync
|
|
||||||
} from 'fs';
|
|
||||||
import mime from 'mime-types';
|
|
||||||
import { relative, resolve, sep } from 'path';
|
|
||||||
import zlib from 'zlib';
|
import zlib from 'zlib';
|
||||||
|
import mime from 'mime-types';
|
||||||
|
|
||||||
const ARDUINO_INCLUDES = '#include <Arduino.h>\n\n';
|
const ARDUINO_INCLUDES = '#include <Arduino.h>\n\n';
|
||||||
const INDENT = ' ';
|
const INDENT = ' ';
|
||||||
const outputPath = '../src/ESP32React/WWWData.h';
|
const outputPath = '../lib/framework/WWWData.h';
|
||||||
const sourcePath = './dist';
|
const sourcePath = './dist';
|
||||||
const bytesPerLine = 20;
|
const bytesPerLine = 20;
|
||||||
let totalSize = 0;
|
var totalSize = 0;
|
||||||
let bundleStats = {
|
|
||||||
js: { count: 0, uncompressed: 0, compressed: 0 },
|
|
||||||
css: { count: 0, uncompressed: 0, compressed: 0 },
|
|
||||||
html: { count: 0, uncompressed: 0, compressed: 0 },
|
|
||||||
svg: { count: 0, uncompressed: 0, compressed: 0 },
|
|
||||||
other: { count: 0, uncompressed: 0, compressed: 0 }
|
|
||||||
};
|
|
||||||
|
|
||||||
const generateWWWClass =
|
const generateWWWClass = () =>
|
||||||
() => `typedef std::function<void(const char * uri, const String & contentType, const uint8_t * content, size_t len, const String & hash)> RouteRegistrationHandler;
|
`typedef std::function<void(const String &, const String & contentType, const uint8_t * content, size_t len)> RouteRegistrationHandler;
|
||||||
// Bundle Statistics:
|
// Total size is ${totalSize} bytes
|
||||||
// - Total compressed size: ${(totalSize / 1000).toFixed(1)} KB
|
|
||||||
// - Total uncompressed size: ${(Object.values(bundleStats).reduce((sum, stat) => sum + stat.uncompressed, 0) / 1000).toFixed(1)} KB
|
|
||||||
// - Compression ratio: ${(((Object.values(bundleStats).reduce((sum, stat) => sum + stat.uncompressed, 0) - totalSize) / Object.values(bundleStats).reduce((sum, stat) => sum + stat.uncompressed, 0)) * 100).toFixed(1)}%
|
|
||||||
// - Generated on: ${new Date().toISOString()}
|
|
||||||
|
|
||||||
class WWWData {
|
class WWWData {
|
||||||
${INDENT}public:
|
${indent}public:
|
||||||
${INDENT.repeat(2)}static void registerRoutes(RouteRegistrationHandler handler) {
|
${indent.repeat(2)}static void registerRoutes(RouteRegistrationHandler handler) {
|
||||||
${fileInfo.map((f) => `${INDENT.repeat(3)}handler("${f.uri}", "${f.mimeType}", ${f.variable}, ${f.size}, ${f.hash});`).join('\n')}
|
${fileInfo
|
||||||
${INDENT.repeat(2)}}
|
.map((file) => `${indent.repeat(3)}handler("${file.uri}", "${file.mimeType}", ${file.variable}, ${file.size});`)
|
||||||
|
.join('\n')}
|
||||||
|
${indent.repeat(2)}}
|
||||||
};
|
};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const getFilesSync = (dir, files = []) => {
|
function getFilesSync(dir, files = []) {
|
||||||
readdirSync(dir, { withFileTypes: true }).forEach((entry) => {
|
readdirSync(dir, { withFileTypes: true }).forEach((entry) => {
|
||||||
const entryPath = resolve(dir, entry.name);
|
const entryPath = resolve(dir, entry.name);
|
||||||
entry.isDirectory() ? getFilesSync(entryPath, files) : files.push(entryPath);
|
if (entry.isDirectory()) {
|
||||||
|
getFilesSync(entryPath, files);
|
||||||
|
} else {
|
||||||
|
files.push(entryPath);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return files;
|
return files;
|
||||||
};
|
}
|
||||||
|
|
||||||
const cleanAndOpen = (path) => {
|
function cleanAndOpen(path) {
|
||||||
existsSync(path) && unlinkSync(path);
|
if (existsSync(path)) {
|
||||||
|
unlinkSync(path);
|
||||||
|
}
|
||||||
return createWriteStream(path, { flags: 'w+' });
|
return createWriteStream(path, { flags: 'w+' });
|
||||||
};
|
}
|
||||||
|
|
||||||
const getFileType = (filePath) => {
|
|
||||||
const ext = filePath.split('.').pop().toLowerCase();
|
|
||||||
if (ext === 'js') return 'js';
|
|
||||||
if (ext === 'css') return 'css';
|
|
||||||
if (ext === 'html') return 'html';
|
|
||||||
if (ext === 'svg') return 'svg';
|
|
||||||
return 'other';
|
|
||||||
};
|
|
||||||
|
|
||||||
const writeFile = (relativeFilePath, buffer) => {
|
const writeFile = (relativeFilePath, buffer) => {
|
||||||
const variable = `ESP_REACT_DATA_${fileInfo.length}`;
|
const variable = 'ESP_REACT_DATA_' + fileInfo.length;
|
||||||
const mimeType = mime.lookup(relativeFilePath);
|
const mimeType = mime.lookup(relativeFilePath);
|
||||||
const fileType = getFileType(relativeFilePath);
|
var size = 0;
|
||||||
let size = 0;
|
writeStream.write('const uint8_t ' + variable + '[] = {');
|
||||||
writeStream.write(`const uint8_t ${variable}[] = {`);
|
// const zipBuffer = zlib.brotliCompressSync(buffer, { quality: 1 });
|
||||||
|
|
||||||
const zipBuffer = zlib.gzipSync(buffer, { level: 9 });
|
const zipBuffer = zlib.gzipSync(buffer, { level: 9 });
|
||||||
// const hash = crypto.createHash('sha256').update(zipBuffer).digest('hex');
|
|
||||||
const hash = etag(zipBuffer); // use smaller md5 instead of sha256
|
|
||||||
|
|
||||||
zipBuffer.forEach((b) => {
|
zipBuffer.forEach((b) => {
|
||||||
if (!(size % bytesPerLine)) {
|
if (!(size % bytesPerLine)) {
|
||||||
writeStream.write('\n' + INDENT);
|
writeStream.write('\n');
|
||||||
|
writeStream.write(indent);
|
||||||
}
|
}
|
||||||
writeStream.write('0x' + b.toString(16).toUpperCase().padStart(2, '0') + ',');
|
writeStream.write('0x' + ('00' + b.toString(16).toUpperCase()).slice(-2) + ',');
|
||||||
size++;
|
size++;
|
||||||
});
|
});
|
||||||
|
if (size % bytesPerLine) {
|
||||||
size % bytesPerLine && writeStream.write('\n');
|
writeStream.write('\n');
|
||||||
|
}
|
||||||
writeStream.write('};\n\n');
|
writeStream.write('};\n\n');
|
||||||
|
|
||||||
// Update bundle statistics
|
|
||||||
bundleStats[fileType].count++;
|
|
||||||
bundleStats[fileType].uncompressed += buffer.length;
|
|
||||||
bundleStats[fileType].compressed += zipBuffer.length;
|
|
||||||
|
|
||||||
fileInfo.push({
|
fileInfo.push({
|
||||||
uri: '/' + relativeFilePath.replace(sep, '/'),
|
uri: '/' + relativeFilePath.replace(sep, '/'),
|
||||||
mimeType,
|
mimeType,
|
||||||
variable,
|
variable,
|
||||||
size,
|
size
|
||||||
hash
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// console.log(relativeFilePath + ' (size ' + size + ' bytes)');
|
||||||
totalSize += size;
|
totalSize += size;
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log(`Generating ${outputPath} from ${sourcePath}`);
|
// start
|
||||||
|
console.log('Generating ' + outputPath + ' from ' + sourcePath);
|
||||||
|
const includes = ARDUINO_INCLUDES;
|
||||||
|
const indent = INDENT;
|
||||||
const fileInfo = [];
|
const fileInfo = [];
|
||||||
const writeStream = cleanAndOpen(resolve(outputPath));
|
const writeStream = cleanAndOpen(resolve(outputPath));
|
||||||
|
|
||||||
writeStream.write(ARDUINO_INCLUDES);
|
// includes
|
||||||
|
writeStream.write(includes);
|
||||||
|
|
||||||
|
// process static files
|
||||||
const buildPath = resolve(sourcePath);
|
const buildPath = resolve(sourcePath);
|
||||||
for (const filePath of getFilesSync(buildPath)) {
|
for (const filePath of getFilesSync(buildPath)) {
|
||||||
writeFile(relative(buildPath, filePath), readFileSync(filePath));
|
const readStream = readFileSync(filePath);
|
||||||
|
const relativeFilePath = relative(buildPath, filePath);
|
||||||
|
writeFile(relativeFilePath, readStream);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// add class
|
||||||
writeStream.write(generateWWWClass());
|
writeStream.write(generateWWWClass());
|
||||||
|
|
||||||
|
// end
|
||||||
writeStream.end();
|
writeStream.end();
|
||||||
|
|
||||||
// Calculate and display bundle statistics
|
console.log('Total size: ' + totalSize / 1000 + ' KB');
|
||||||
const totalUncompressed = Object.values(bundleStats).reduce(
|
|
||||||
(sum, stat) => sum + stat.uncompressed,
|
|
||||||
0
|
|
||||||
);
|
|
||||||
const totalCompressed = Object.values(bundleStats).reduce(
|
|
||||||
(sum, stat) => sum + stat.compressed,
|
|
||||||
0
|
|
||||||
);
|
|
||||||
const compressionRatio = (
|
|
||||||
((totalUncompressed - totalCompressed) / totalUncompressed) *
|
|
||||||
100
|
|
||||||
).toFixed(1);
|
|
||||||
|
|
||||||
console.log('\n📊 Bundle Size Analysis:');
|
|
||||||
console.log('='.repeat(50));
|
|
||||||
console.log(`Total compressed size: ${(totalSize / 1000).toFixed(1)} KB`);
|
|
||||||
console.log(`Total uncompressed size: ${(totalUncompressed / 1000).toFixed(1)} KB`);
|
|
||||||
console.log(`Compression ratio: ${compressionRatio}%`);
|
|
||||||
console.log('\n📁 File Type Breakdown:');
|
|
||||||
Object.entries(bundleStats).forEach(([type, stats]) => {
|
|
||||||
if (stats.count > 0) {
|
|
||||||
const ratio = (
|
|
||||||
((stats.uncompressed - stats.compressed) / stats.uncompressed) *
|
|
||||||
100
|
|
||||||
).toFixed(1);
|
|
||||||
console.log(
|
|
||||||
`${type.toUpperCase().padEnd(4)}: ${stats.count} files, ${(stats.uncompressed / 1000).toFixed(1)} KB → ${(stats.compressed / 1000).toFixed(1)} KB (${ratio}% compression)`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
console.log('='.repeat(50));
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* Uses font-weight 400 (normal) only, no bold, and Latin with a few extra unicode chars.
|
* Uses font-size 400 (normal) only and Latin (plus extra unicode chars) to keep flash memory to a minimum
|
||||||
* This is to keep flash memory to a minimum
|
|
||||||
* View fonts on https://fonts.google.com/
|
* View fonts on https://fonts.google.com/
|
||||||
* Download woff2 using e.g. https://fonts.googleapis.com/css2?family=Lato or https://fonts.googleapis.com/css2?family=Roboto
|
* Download woff2 using e.g. https://fonts.googleapis.com/css2?family=Lato or https://fonts.googleapis.com/css2?family=Roboto
|
||||||
*/
|
*/
|
||||||
@@ -13,9 +12,7 @@
|
|||||||
local('Roboto'),
|
local('Roboto'),
|
||||||
local('Roboto-Regular'),
|
local('Roboto-Regular'),
|
||||||
url(../fonts/re.woff2) format('woff2');
|
url(../fonts/re.woff2) format('woff2');
|
||||||
unicode-range:
|
unicode-range: U+0000-00FF, U+0104-0107, U+0118-0119, U+011E-011F, U+0130-0131, U+0141-0144, U+0152-0153, U+015A-015B,
|
||||||
U+0000-00FF, U+0104-0107, U+0118-0119, U+011E-011F, U+0130-0131, U+0141-0144,
|
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+0152-0153, U+015A-015B, U+015E-015F, U+0179-017C, U+02BB-02BC, U+02C6, U+02DA,
|
U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
|
|
||||||
U+FEFF, U+FFFD;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,75 +1,52 @@
|
|||||||
import { memo, useCallback, useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { ToastContainer, Zoom } from 'react-toastify';
|
import { ToastContainer, Slide } from 'react-toastify';
|
||||||
|
|
||||||
|
import 'react-toastify/dist/ReactToastify.min.css';
|
||||||
|
|
||||||
|
import { localStorageDetector } from 'typesafe-i18n/detectors';
|
||||||
|
import { FeaturesLoader } from './contexts/features';
|
||||||
|
import type { FC } from 'react';
|
||||||
import AppRouting from 'AppRouting';
|
import AppRouting from 'AppRouting';
|
||||||
import CustomTheme from 'CustomTheme';
|
import CustomTheme from 'CustomTheme';
|
||||||
|
|
||||||
import TypesafeI18n from 'i18n/i18n-react';
|
import TypesafeI18n from 'i18n/i18n-react';
|
||||||
import type { Locales } from 'i18n/i18n-types';
|
import { detectLocale } from 'i18n/i18n-util';
|
||||||
import { loadLocaleAsync } from 'i18n/i18n-util.async';
|
import { loadLocaleAsync } from 'i18n/i18n-util.async';
|
||||||
import { detectLocale, navigatorDetector } from 'typesafe-i18n/detectors';
|
|
||||||
|
|
||||||
const AVAILABLE_LOCALES = [
|
const detectedLocale = detectLocale(localStorageDetector);
|
||||||
'de',
|
|
||||||
'en',
|
|
||||||
'it',
|
|
||||||
'fr',
|
|
||||||
'nl',
|
|
||||||
'no',
|
|
||||||
'pl',
|
|
||||||
'sk',
|
|
||||||
'sv',
|
|
||||||
'tr',
|
|
||||||
'cz'
|
|
||||||
] as Locales[];
|
|
||||||
|
|
||||||
// Static toast configuration - no need to recreate on every render
|
const App: FC = () => {
|
||||||
const TOAST_CONTAINER_PROPS = {
|
|
||||||
position: 'bottom-left' as const,
|
|
||||||
autoClose: 3000,
|
|
||||||
hideProgressBar: false,
|
|
||||||
newestOnTop: false,
|
|
||||||
closeOnClick: true,
|
|
||||||
rtl: false,
|
|
||||||
pauseOnFocusLoss: true,
|
|
||||||
draggable: false,
|
|
||||||
pauseOnHover: false,
|
|
||||||
transition: Zoom,
|
|
||||||
closeButton: false,
|
|
||||||
theme: 'dark' as const,
|
|
||||||
toastStyle: {
|
|
||||||
border: '1px solid #177ac9',
|
|
||||||
width: 'fit-content'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const App = memo(() => {
|
|
||||||
const [wasLoaded, setWasLoaded] = useState(false);
|
const [wasLoaded, setWasLoaded] = useState(false);
|
||||||
const [locale, setLocale] = useState<Locales>('en');
|
|
||||||
|
|
||||||
// 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(() => {
|
useEffect(() => {
|
||||||
void initializeLocale();
|
void loadLocaleAsync(detectedLocale).then(() => setWasLoaded(true));
|
||||||
}, [initializeLocale]);
|
}, []);
|
||||||
|
|
||||||
if (!wasLoaded) return null;
|
if (!wasLoaded) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TypesafeI18n locale={locale}>
|
<TypesafeI18n locale={detectedLocale}>
|
||||||
<CustomTheme>
|
<CustomTheme>
|
||||||
|
<FeaturesLoader>
|
||||||
<AppRouting />
|
<AppRouting />
|
||||||
<ToastContainer {...TOAST_CONTAINER_PROPS} />
|
</FeaturesLoader>
|
||||||
|
<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>
|
</CustomTheme>
|
||||||
</TypesafeI18n>
|
</TypesafeI18n>
|
||||||
);
|
);
|
||||||
});
|
};
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
@@ -1,60 +1,54 @@
|
|||||||
import { type FC, Suspense, lazy, memo, useContext, useEffect, useRef } from 'react';
|
import { useContext, useEffect } from 'react';
|
||||||
import { Navigate, Route, Routes } from 'react-router';
|
|
||||||
import { toast } from 'react-toastify';
|
import { Route, Routes, Navigate, useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
import type { FC } from 'react';
|
||||||
|
|
||||||
|
import AuthenticatedRouting from 'AuthenticatedRouting';
|
||||||
|
import SignIn from 'SignIn';
|
||||||
|
import { RequireAuthenticated, RequireUnauthenticated } from 'components';
|
||||||
|
|
||||||
import {
|
|
||||||
LoadingSpinner,
|
|
||||||
RequireAuthenticated,
|
|
||||||
RequireUnauthenticated
|
|
||||||
} from 'components';
|
|
||||||
import { Authentication, AuthenticationContext } from 'contexts/authentication';
|
import { Authentication, AuthenticationContext } from 'contexts/authentication';
|
||||||
import { useI18nContext } from 'i18n/i18n-react';
|
import { useI18nContext } from 'i18n/i18n-react';
|
||||||
|
|
||||||
// Lazy load route components for better code splitting
|
|
||||||
const SignIn = lazy(() => import('SignIn'));
|
|
||||||
const AuthenticatedRouting = lazy(() => import('AuthenticatedRouting'));
|
|
||||||
|
|
||||||
interface SecurityRedirectProps {
|
interface SecurityRedirectProps {
|
||||||
readonly message: string;
|
message: string;
|
||||||
readonly signOut?: boolean;
|
signOut?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RootRedirect: FC<SecurityRedirectProps> = memo(
|
const RootRedirect: FC<SecurityRedirectProps> = ({ message, signOut }) => {
|
||||||
({ message, signOut = false }) => {
|
const authenticationContext = useContext(AuthenticationContext);
|
||||||
const { signOut: contextSignOut } = useContext(AuthenticationContext);
|
|
||||||
const hasShownToast = useRef(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Prevent duplicate toasts on strict mode or re-renders
|
signOut && authenticationContext.signOut(false);
|
||||||
if (!hasShownToast.current) {
|
|
||||||
hasShownToast.current = true;
|
|
||||||
if (signOut) {
|
|
||||||
contextSignOut(false);
|
|
||||||
}
|
|
||||||
toast.success(message);
|
toast.success(message);
|
||||||
}
|
}, [message, signOut, authenticationContext]);
|
||||||
// Only run once on mount - using ref to track execution
|
return <Navigate to="/" />;
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
return <Navigate to="/" replace />;
|
export const RemoveTrailingSlashes = () => {
|
||||||
}
|
const location = useLocation();
|
||||||
);
|
return (
|
||||||
|
location.pathname.match('/.*/$') && (
|
||||||
|
<Navigate
|
||||||
|
to={{
|
||||||
|
pathname: location.pathname.replace(/\/+$/, ''),
|
||||||
|
search: location.search
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const AppRouting: FC = memo(() => {
|
const AppRouting: FC = () => {
|
||||||
const { LL } = useI18nContext();
|
const { LL } = useI18nContext();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Authentication>
|
<Authentication>
|
||||||
<Suspense fallback={<LoadingSpinner />}>
|
<RemoveTrailingSlashes />
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route
|
<Route path="/unauthorized" element={<RootRedirect message={LL.PLEASE_SIGNIN()} signOut />} />
|
||||||
path="/unauthorized"
|
<Route path="/fileUpdated" element={<RootRedirect message={LL.UPLOAD_SUCCESSFUL()} />} />
|
||||||
element={<RootRedirect message={LL.PLEASE_SIGNIN()} signOut />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/fileUpdated"
|
|
||||||
element={<RootRedirect message={LL.UPLOAD_SUCCESSFUL()} />}
|
|
||||||
/>
|
|
||||||
<Route
|
<Route
|
||||||
path="/"
|
path="/"
|
||||||
element={
|
element={
|
||||||
@@ -72,9 +66,8 @@ const AppRouting: FC = memo(() => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
|
||||||
</Authentication>
|
</Authentication>
|
||||||
);
|
);
|
||||||
});
|
};
|
||||||
|
|
||||||
export default AppRouting;
|
export default AppRouting;
|
||||||
|
|||||||
@@ -1,88 +1,64 @@
|
|||||||
import { Suspense, lazy, memo, useContext } from 'react';
|
import { Navigate, Routes, Route } from 'react-router-dom';
|
||||||
import { Navigate, Route, Routes } from 'react-router';
|
import Dashboard from './project/Dashboard';
|
||||||
|
import Help from './project/Help';
|
||||||
|
import Settings from './project/Settings';
|
||||||
|
import type { FC } from 'react';
|
||||||
|
|
||||||
import { Layout, LoadingSpinner } from 'components';
|
import { Layout, RequireAdmin } from 'components';
|
||||||
import { AuthenticatedContext } from 'contexts/authentication';
|
import AccessPoint from 'framework/ap/AccessPoint';
|
||||||
|
import Mqtt from 'framework/mqtt/Mqtt';
|
||||||
|
import NetworkConnection from 'framework/network/NetworkConnection';
|
||||||
|
import NetworkTime from 'framework/ntp/NetworkTime';
|
||||||
|
import Security from 'framework/security/Security';
|
||||||
|
import System from 'framework/system/System';
|
||||||
|
|
||||||
// Lazy load all route components for better code splitting
|
const AuthenticatedRouting: FC = () => (
|
||||||
const Dashboard = lazy(() => import('app/main/Dashboard'));
|
// const location = useLocation();
|
||||||
const Devices = lazy(() => import('app/main/Devices'));
|
// const navigate = useNavigate();
|
||||||
const Sensors = lazy(() => import('app/main/Sensors'));
|
// const handleApiResponseError = useCallback(
|
||||||
const Help = lazy(() => import('app/main/Help'));
|
// (error: AxiosError) => {
|
||||||
const Customizations = lazy(() => import('app/main/Customizations'));
|
// if (error.response && error.response.status === 401) {
|
||||||
const Scheduler = lazy(() => import('app/main/Scheduler'));
|
// AuthenticationApi.storeLoginRedirect(location);
|
||||||
const CustomEntities = lazy(() => import('app/main/CustomEntities'));
|
// navigate('/unauthorized');
|
||||||
const Modules = lazy(() => import('app/main/Modules'));
|
// }
|
||||||
const UserProfile = lazy(() => import('app/main/UserProfile'));
|
// return Promise.reject(error);
|
||||||
|
// },
|
||||||
|
// [location, navigate]
|
||||||
|
// );
|
||||||
|
// useEffect(() => {
|
||||||
|
// const axiosHandlerId = AXIOS.interceptors.response.use((response) => response, handleApiResponseError);
|
||||||
|
// return () => AXIOS.interceptors.response.eject(axiosHandlerId);
|
||||||
|
// }, [handleApiResponseError]);
|
||||||
|
|
||||||
const Status = lazy(() => import('app/status/Status'));
|
|
||||||
const HardwareStatus = lazy(() => import('app/status/HardwareStatus'));
|
|
||||||
const Activity = lazy(() => import('app/status/Activity'));
|
|
||||||
const SystemLog = lazy(() => import('app/status/SystemLog'));
|
|
||||||
const MqttStatus = lazy(() => import('app/status/MqttStatus'));
|
|
||||||
const NTPStatus = lazy(() => import('app/status/NTPStatus'));
|
|
||||||
const APStatus = lazy(() => import('app/status/APStatus'));
|
|
||||||
const NetworkStatus = lazy(() => import('app/status/NetworkStatus'));
|
|
||||||
const Version = lazy(() => import('app/status/Version'));
|
|
||||||
|
|
||||||
const Settings = lazy(() => import('app/settings/Settings'));
|
|
||||||
const ApplicationSettings = lazy(() => import('app/settings/ApplicationSettings'));
|
|
||||||
const MqttSettings = lazy(() => import('app/settings/MqttSettings'));
|
|
||||||
const NTPSettings = lazy(() => import('app/settings/NTPSettings'));
|
|
||||||
const APSettings = lazy(() => import('app/settings/APSettings'));
|
|
||||||
const DownloadUpload = lazy(() => import('app/settings/DownloadUpload'));
|
|
||||||
const Network = lazy(() => import('app/settings/network/Network'));
|
|
||||||
const Security = lazy(() => import('app/settings/security/Security'));
|
|
||||||
|
|
||||||
const AuthenticatedRouting = memo(() => {
|
|
||||||
const { me } = useContext(AuthenticatedContext);
|
|
||||||
return (
|
|
||||||
<Layout>
|
<Layout>
|
||||||
<Suspense fallback={<LoadingSpinner />}>
|
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/dashboard/*" element={<Dashboard />} />
|
<Route path="/dashboard/*" element={<Dashboard />} />
|
||||||
<Route path="/devices/*" element={<Devices />} />
|
|
||||||
<Route path="/sensors/*" element={<Sensors />} />
|
|
||||||
<Route path="/help/*" element={<Help />} />
|
|
||||||
<Route path="/user/*" element={<UserProfile />} />
|
|
||||||
|
|
||||||
<Route path="/status/*" element={<Status />} />
|
|
||||||
<Route path="/status/hardwarestatus/*" element={<HardwareStatus />} />
|
|
||||||
<Route path="/status/activity" element={<Activity />} />
|
|
||||||
<Route path="/status/log" element={<SystemLog />} />
|
|
||||||
<Route path="/status/mqtt" element={<MqttStatus />} />
|
|
||||||
<Route path="/status/ntp" element={<NTPStatus />} />
|
|
||||||
<Route path="/status/ap" element={<APStatus />} />
|
|
||||||
<Route path="/status/network" element={<NetworkStatus />} />
|
|
||||||
<Route path="/status/version" element={<Version />} />
|
|
||||||
|
|
||||||
{me.admin && (
|
|
||||||
<>
|
|
||||||
<Route path="/settings" element={<Settings />} />
|
|
||||||
<Route
|
<Route
|
||||||
path="/settings/application"
|
path="/settings/*"
|
||||||
element={<ApplicationSettings />}
|
element={
|
||||||
|
<RequireAdmin>
|
||||||
|
<Settings />
|
||||||
|
</RequireAdmin>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Route path="/settings/mqtt" element={<MqttSettings />} />
|
<Route path="/help/*" element={<Help />} />
|
||||||
<Route path="/settings/ntp" element={<NTPSettings />} />
|
|
||||||
<Route path="/settings/ap" element={<APSettings />} />
|
|
||||||
<Route path="/settings/modules" element={<Modules />} />
|
|
||||||
<Route path="/settings/downloadUpload" element={<DownloadUpload />} />
|
|
||||||
|
|
||||||
<Route path="/settings/network/*" element={<Network />} />
|
|
||||||
<Route path="/settings/security/*" element={<Security />} />
|
|
||||||
|
|
||||||
<Route path="/customizations" element={<Customizations />} />
|
|
||||||
<Route path="/scheduler" element={<Scheduler />} />
|
|
||||||
<Route path="/customentities" element={<CustomEntities />} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
|
<Route path="/network/*" element={<NetworkConnection />} />
|
||||||
|
<Route path="/ap/*" element={<AccessPoint />} />
|
||||||
|
<Route path="/ntp/*" element={<NetworkTime />} />
|
||||||
|
<Route path="/mqtt/*" element={<Mqtt />} />
|
||||||
|
<Route
|
||||||
|
path="/security/*"
|
||||||
|
element={
|
||||||
|
<RequireAdmin>
|
||||||
|
<Security />
|
||||||
|
</RequireAdmin>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path="/system/*" element={<System />} />
|
||||||
<Route path="/*" element={<Navigate to="/" />} />
|
<Route path="/*" element={<Navigate to="/" />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
});
|
|
||||||
|
|
||||||
export default AuthenticatedRouting;
|
export default AuthenticatedRouting;
|
||||||
|
|||||||
@@ -1,14 +1,7 @@
|
|||||||
import { memo } from 'react';
|
import { CssBaseline } from '@mui/material';
|
||||||
|
import { createTheme, responsiveFontSizes, ThemeProvider } from '@mui/material/styles';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
|
|
||||||
import {
|
|
||||||
CssBaseline,
|
|
||||||
ThemeProvider,
|
|
||||||
responsiveFontSizes,
|
|
||||||
tooltipClasses
|
|
||||||
} from '@mui/material';
|
|
||||||
import { createTheme } from '@mui/material/styles';
|
|
||||||
|
|
||||||
import type { RequiredChildrenProps } from 'utils';
|
import type { RequiredChildrenProps } from 'utils';
|
||||||
|
|
||||||
export const dialogStyle = {
|
export const dialogStyle = {
|
||||||
@@ -16,9 +9,10 @@ export const dialogStyle = {
|
|||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
borderColor: '#565656',
|
borderColor: '#565656',
|
||||||
borderStyle: 'solid',
|
borderStyle: 'solid',
|
||||||
borderWidth: '2px'
|
borderWidth: '1px'
|
||||||
}
|
},
|
||||||
} as const;
|
backdropFilter: 'blur(1px)'
|
||||||
|
};
|
||||||
|
|
||||||
const theme = responsiveFontSizes(
|
const theme = responsiveFontSizes(
|
||||||
createTheme({
|
createTheme({
|
||||||
@@ -36,45 +30,15 @@ const theme = responsiveFontSizes(
|
|||||||
text: {
|
text: {
|
||||||
disabled: '#eee' // white
|
disabled: '#eee' // white
|
||||||
}
|
}
|
||||||
},
|
|
||||||
components: {
|
|
||||||
MuiListItemText: {
|
|
||||||
styleOverrides: {
|
|
||||||
primary: {
|
|
||||||
fontSize: 14
|
|
||||||
},
|
|
||||||
secondary: {
|
|
||||||
color: '#9e9e9e' // grey[500]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
MuiTooltip: {
|
|
||||||
defaultProps: {
|
|
||||||
placement: 'top',
|
|
||||||
arrow: true
|
|
||||||
},
|
|
||||||
styleOverrides: {
|
|
||||||
tooltip: {
|
|
||||||
padding: '4px 8px',
|
|
||||||
fontSize: 10,
|
|
||||||
color: 'rgba(0, 0, 0, 0.87)',
|
|
||||||
backgroundColor: '#4caf50', // MUI success.main default color
|
|
||||||
boxShadow: '0px 2px 8px rgba(0, 0, 0, 0.15)',
|
|
||||||
[`& .${tooltipClasses.arrow}`]: {
|
|
||||||
color: '#4caf50'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
const CustomTheme: FC<RequiredChildrenProps> = memo(({ children }) => (
|
const CustomTheme: FC<RequiredChildrenProps> = ({ children }) => (
|
||||||
<ThemeProvider theme={theme}>
|
<ThemeProvider theme={theme}>
|
||||||
<CssBaseline />
|
<CssBaseline />
|
||||||
{children}
|
{children}
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
));
|
);
|
||||||
|
|
||||||
export default CustomTheme;
|
export default CustomTheme;
|
||||||
|
|||||||
@@ -1,28 +1,41 @@
|
|||||||
import { memo, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
|
||||||
import { toast } from 'react-toastify';
|
|
||||||
|
|
||||||
import ForwardIcon from '@mui/icons-material/Forward';
|
import ForwardIcon from '@mui/icons-material/Forward';
|
||||||
import { Box, Button, Paper, Typography } from '@mui/material';
|
import { Box, Paper, Typography, MenuItem, TextField, Button } from '@mui/material';
|
||||||
|
import { useRequest } from 'alova';
|
||||||
import * as AuthenticationApi from 'components/routing/authentication';
|
import { useContext, useState } from 'react';
|
||||||
import { useRequest } from 'alova/client';
|
import { toast } from 'react-toastify';
|
||||||
|
import { FeaturesContext } from './contexts/features';
|
||||||
import type { ValidateFieldsError } from 'async-validator';
|
import type { ValidateFieldsError } from 'async-validator';
|
||||||
import {
|
|
||||||
LanguageSelector,
|
import type { Locales } from 'i18n/i18n-types';
|
||||||
ValidatedPasswordField,
|
import type { ChangeEventHandler, FC } from 'react';
|
||||||
ValidatedTextField
|
|
||||||
} from 'components';
|
|
||||||
import { AuthenticationContext } from 'contexts/authentication';
|
|
||||||
import { PROJECT_NAME } from 'env';
|
|
||||||
import { useI18nContext } from 'i18n/i18n-react';
|
|
||||||
import type { SignInRequest } from 'types';
|
import type { SignInRequest } from 'types';
|
||||||
|
import * as AuthenticationApi from 'api/authentication';
|
||||||
|
import { PROJECT_NAME } from 'api/env';
|
||||||
|
|
||||||
|
import { ValidatedPasswordField, ValidatedTextField } from 'components';
|
||||||
|
import { AuthenticationContext } from 'contexts/authentication';
|
||||||
|
|
||||||
|
import DEflag from 'i18n/DE.svg';
|
||||||
|
import FRflag from 'i18n/FR.svg';
|
||||||
|
import GBflag from 'i18n/GB.svg';
|
||||||
|
import ITflag from 'i18n/IT.svg';
|
||||||
|
import NLflag from 'i18n/NL.svg';
|
||||||
|
import NOflag from 'i18n/NO.svg';
|
||||||
|
import PLflag from 'i18n/PL.svg';
|
||||||
|
import SKflag from 'i18n/SK.svg';
|
||||||
|
import SVflag from 'i18n/SV.svg';
|
||||||
|
import TRflag from 'i18n/TR.svg';
|
||||||
|
import { I18nContext } from 'i18n/i18n-react';
|
||||||
|
import { loadLocaleAsync } from 'i18n/i18n-util.async';
|
||||||
import { onEnterCallback, updateValue } from 'utils';
|
import { onEnterCallback, updateValue } from 'utils';
|
||||||
import { SIGN_IN_REQUEST_VALIDATOR, validate } from 'validators';
|
import { SIGN_IN_REQUEST_VALIDATOR, validate } from 'validators';
|
||||||
|
|
||||||
const SignIn = memo(() => {
|
const SignIn: FC = () => {
|
||||||
const authenticationContext = useContext(AuthenticationContext);
|
const authenticationContext = useContext(AuthenticationContext);
|
||||||
|
|
||||||
const { LL } = useI18nContext();
|
const { LL, setLocale, locale } = useContext(I18nContext);
|
||||||
|
|
||||||
|
const { features } = useContext(FeaturesContext);
|
||||||
|
|
||||||
const [signInRequest, setSignInRequest] = useState<SignInRequest>({
|
const [signInRequest, setSignInRequest] = useState<SignInRequest>({
|
||||||
username: '',
|
username: '',
|
||||||
@@ -31,30 +44,20 @@ const SignIn = memo(() => {
|
|||||||
const [processing, setProcessing] = useState<boolean>(false);
|
const [processing, setProcessing] = useState<boolean>(false);
|
||||||
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
||||||
|
|
||||||
const { send: callSignIn } = useRequest(
|
const { send: callSignIn, onSuccess } = useRequest((request: SignInRequest) => AuthenticationApi.signIn(request), {
|
||||||
(request: SignInRequest) => AuthenticationApi.signIn(request),
|
|
||||||
{
|
|
||||||
immediate: false
|
immediate: false
|
||||||
}
|
});
|
||||||
).onSuccess((response) => {
|
|
||||||
|
onSuccess((response) => {
|
||||||
if (response.data) {
|
if (response.data) {
|
||||||
authenticationContext.signIn(response.data.access_token);
|
authenticationContext.signIn(response.data.access_token);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Memoize callback to prevent recreation on every render
|
const updateLoginRequestValue = updateValue(setSignInRequest);
|
||||||
const updateLoginRequestValue = useMemo(
|
|
||||||
() =>
|
|
||||||
updateValue((updater) =>
|
|
||||||
setSignInRequest(
|
|
||||||
updater as unknown as (prevState: SignInRequest) => SignInRequest
|
|
||||||
)
|
|
||||||
),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const signIn = useCallback(async () => {
|
const signIn = async () => {
|
||||||
await callSignIn(signInRequest).catch((event: Error) => {
|
await callSignIn(signInRequest).catch((event) => {
|
||||||
if (event.message === 'Unauthorized') {
|
if (event.message === 'Unauthorized') {
|
||||||
toast.warning(LL.INVALID_LOGIN());
|
toast.warning(LL.INVALID_LOGIN());
|
||||||
} else {
|
} else {
|
||||||
@@ -62,9 +65,9 @@ const SignIn = memo(() => {
|
|||||||
}
|
}
|
||||||
setProcessing(false);
|
setProcessing(false);
|
||||||
});
|
});
|
||||||
}, [callSignIn, signInRequest, LL]);
|
};
|
||||||
|
|
||||||
const validateAndSignIn = useCallback(async () => {
|
const validateAndSignIn = async () => {
|
||||||
setProcessing(true);
|
setProcessing(true);
|
||||||
SIGN_IN_REQUEST_VALIDATOR.messages({
|
SIGN_IN_REQUEST_VALIDATOR.messages({
|
||||||
required: LL.IS_REQUIRED('%s')
|
required: LL.IS_REQUIRED('%s')
|
||||||
@@ -72,23 +75,20 @@ const SignIn = memo(() => {
|
|||||||
try {
|
try {
|
||||||
await validate(SIGN_IN_REQUEST_VALIDATOR, signInRequest);
|
await validate(SIGN_IN_REQUEST_VALIDATOR, signInRequest);
|
||||||
await signIn();
|
await signIn();
|
||||||
} catch (error) {
|
} catch (errors: any) {
|
||||||
setFieldErrors(error as ValidateFieldsError);
|
setFieldErrors(errors);
|
||||||
setProcessing(false);
|
setProcessing(false);
|
||||||
}
|
}
|
||||||
}, [signInRequest, signIn, LL]);
|
|
||||||
|
|
||||||
// Memoize callback to prevent recreation on every render
|
|
||||||
const submitOnEnter = useMemo(() => onEnterCallback(signIn), [signIn]);
|
|
||||||
|
|
||||||
// get rid of scrollbar
|
|
||||||
useEffect(() => {
|
|
||||||
const originalOverflow = document.body.style.overflow;
|
|
||||||
document.body.style.overflow = 'hidden';
|
|
||||||
return () => {
|
|
||||||
document.body.style.overflow = originalOverflow;
|
|
||||||
};
|
};
|
||||||
}, []);
|
|
||||||
|
const submitOnEnter = onEnterCallback(signIn);
|
||||||
|
|
||||||
|
const onLocaleSelected: ChangeEventHandler<HTMLInputElement> = async ({ target }) => {
|
||||||
|
const loc = target.value as Locales;
|
||||||
|
localStorage.setItem('lang', loc);
|
||||||
|
await loadLocaleAsync(loc);
|
||||||
|
setLocale(loc);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@@ -111,61 +111,88 @@ const SignIn = memo(() => {
|
|||||||
width: '100%'
|
width: '100%'
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Typography mb={1} variant="h4">
|
<Typography variant="h4">{PROJECT_NAME}</Typography>
|
||||||
{PROJECT_NAME}
|
<Typography variant="subtitle2">{features.version}</Typography>
|
||||||
</Typography>
|
|
||||||
<LanguageSelector />
|
<TextField name="locale" variant="outlined" value={locale} onChange={onLocaleSelected} size="small" select>
|
||||||
<Box
|
<MenuItem key="de" value="de">
|
||||||
mt={1}
|
<img src={DEflag} style={{ width: 16, verticalAlign: 'middle' }} />
|
||||||
display="flex"
|
DE
|
||||||
flexDirection="column"
|
</MenuItem>
|
||||||
gap={1}
|
<MenuItem key="en" value="en">
|
||||||
alignItems="center"
|
<img src={GBflag} style={{ width: 16, verticalAlign: 'middle' }} />
|
||||||
>
|
EN
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem key="fr" value="fr">
|
||||||
|
<img src={FRflag} style={{ width: 16, verticalAlign: 'middle' }} />
|
||||||
|
FR
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem key="it" value="it">
|
||||||
|
<img src={ITflag} style={{ width: 16, verticalAlign: 'middle' }} />
|
||||||
|
IT
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem key="nl" value="nl">
|
||||||
|
<img src={NLflag} style={{ width: 16, verticalAlign: 'middle' }} />
|
||||||
|
NL
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem key="no" value="no">
|
||||||
|
<img src={NOflag} style={{ width: 16, verticalAlign: 'middle' }} />
|
||||||
|
NO
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem key="pl" value="pl">
|
||||||
|
<img src={PLflag} style={{ width: 16, verticalAlign: 'middle' }} />
|
||||||
|
PL
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem key="sk" value="sk">
|
||||||
|
<img src={SKflag} style={{ width: 16, verticalAlign: 'middle' }} />
|
||||||
|
SK
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem key="sv" value="sv">
|
||||||
|
<img src={SVflag} style={{ width: 16, verticalAlign: 'middle' }} />
|
||||||
|
SV
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem key="tr" value="tr">
|
||||||
|
<img src={TRflag} style={{ width: 16, verticalAlign: 'middle' }} />
|
||||||
|
TR
|
||||||
|
</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
<Box display="flex" flexDirection="column" alignItems="center">
|
||||||
<ValidatedTextField
|
<ValidatedTextField
|
||||||
fieldErrors={fieldErrors || {}}
|
fieldErrors={fieldErrors}
|
||||||
disabled={processing}
|
disabled={processing}
|
||||||
sx={{
|
sx={{
|
||||||
width: '32ch'
|
width: 240
|
||||||
}}
|
}}
|
||||||
name="username"
|
name="username"
|
||||||
label={LL.USERNAME(0)}
|
label={LL.USERNAME(0)}
|
||||||
value={signInRequest.username}
|
value={signInRequest.username}
|
||||||
onChange={updateLoginRequestValue}
|
onChange={updateLoginRequestValue}
|
||||||
slotProps={{
|
margin="normal"
|
||||||
input: {
|
variant="outlined"
|
||||||
autoCapitalize: 'none',
|
|
||||||
autoCorrect: 'off'
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<ValidatedPasswordField
|
<ValidatedPasswordField
|
||||||
fieldErrors={fieldErrors || {}}
|
fieldErrors={fieldErrors}
|
||||||
disabled={processing}
|
disabled={processing}
|
||||||
sx={{
|
sx={{
|
||||||
width: '32ch'
|
width: 240
|
||||||
}}
|
}}
|
||||||
name="password"
|
name="password"
|
||||||
label={LL.PASSWORD()}
|
label={LL.PASSWORD()}
|
||||||
value={signInRequest.password}
|
value={signInRequest.password}
|
||||||
onChange={updateLoginRequestValue}
|
onChange={updateLoginRequestValue}
|
||||||
onKeyDown={submitOnEnter}
|
onKeyDown={submitOnEnter}
|
||||||
|
variant="outlined"
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Button
|
<Button variant="contained" color="primary" sx={{ mt: 2 }} onClick={validateAndSignIn} disabled={processing}>
|
||||||
variant="contained"
|
|
||||||
color="primary"
|
|
||||||
sx={{ mt: 2 }}
|
|
||||||
onClick={validateAndSignIn}
|
|
||||||
disabled={processing}
|
|
||||||
>
|
|
||||||
<ForwardIcon sx={{ mr: 1 }} />
|
<ForwardIcon sx={{ mr: 1 }} />
|
||||||
{LL.SIGN_IN()}
|
{LL.SIGN_IN()}
|
||||||
</Button>
|
</Button>
|
||||||
</Paper>
|
</Paper>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
});
|
};
|
||||||
|
|
||||||
export default SignIn;
|
export default SignIn;
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import type { APSettingsType, APStatusType } from 'types';
|
|
||||||
|
|
||||||
import { alovaInstance } from './endpoints';
|
import { alovaInstance } from './endpoints';
|
||||||
|
|
||||||
export const readAPStatus = () => alovaInstance.Get<APStatusType>('/rest/apStatus');
|
import type { APSettings, APStatus } from 'types';
|
||||||
export const readAPSettings = () =>
|
|
||||||
alovaInstance.Get<APSettingsType>('/rest/apSettings');
|
export const readAPStatus = () => alovaInstance.Get<APStatus>('/rest/apStatus');
|
||||||
export const updateAPSettings = (data: APSettingsType) =>
|
export const readAPSettings = () => alovaInstance.Get<APSettings>('/rest/apSettings');
|
||||||
alovaInstance.Post<APSettingsType>('/rest/apSettings', data);
|
export const updateAPSettings = (data: APSettings) => alovaInstance.Post<APSettings>('/rest/apSettings', data);
|
||||||
|
|||||||
@@ -1,157 +0,0 @@
|
|||||||
import { alovaInstance } from 'api/endpoints';
|
|
||||||
|
|
||||||
import type {
|
|
||||||
APIcall,
|
|
||||||
Action,
|
|
||||||
Activity,
|
|
||||||
CoreData,
|
|
||||||
DashboardData,
|
|
||||||
DeviceData,
|
|
||||||
DeviceEntity,
|
|
||||||
Entities,
|
|
||||||
EntityItem,
|
|
||||||
ModuleItem,
|
|
||||||
Modules,
|
|
||||||
Schedule,
|
|
||||||
ScheduleItem,
|
|
||||||
SensorData,
|
|
||||||
Settings,
|
|
||||||
WriteAnalogSensor,
|
|
||||||
WriteTemperatureSensor
|
|
||||||
} from '../app/main/types';
|
|
||||||
|
|
||||||
const MSGPACK_CONFIG = { responseType: 'arraybuffer' as const };
|
|
||||||
|
|
||||||
// Dashboard
|
|
||||||
export const readDashboard = () =>
|
|
||||||
alovaInstance.Get<DashboardData>('/rest/dashboardData', MSGPACK_CONFIG);
|
|
||||||
|
|
||||||
// Devices
|
|
||||||
export const readCoreData = () => alovaInstance.Get<CoreData>('/rest/coreData');
|
|
||||||
export const readDeviceData = (id: number) =>
|
|
||||||
alovaInstance.Get<DeviceData>('/rest/deviceData', {
|
|
||||||
params: { id },
|
|
||||||
...MSGPACK_CONFIG
|
|
||||||
});
|
|
||||||
export const writeDeviceValue = (data: { id: number; c: string; v: unknown }) =>
|
|
||||||
alovaInstance.Post('/rest/writeDeviceValue', data);
|
|
||||||
|
|
||||||
// Application Settings
|
|
||||||
export const readSettings = () => alovaInstance.Get<Settings>('/rest/settings');
|
|
||||||
export const writeSettings = (data: Settings) =>
|
|
||||||
alovaInstance.Post('/rest/settings', data);
|
|
||||||
export const getBoardProfile = (boardProfile: string) =>
|
|
||||||
alovaInstance.Get('/rest/boardProfile', {
|
|
||||||
params: { boardProfile }
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sensors
|
|
||||||
export const readSensorData = () =>
|
|
||||||
alovaInstance.Get<SensorData>('/rest/sensorData');
|
|
||||||
export const writeTemperatureSensor = (ts: WriteTemperatureSensor) =>
|
|
||||||
alovaInstance.Post('/rest/writeTemperatureSensor', ts);
|
|
||||||
export const writeAnalogSensor = (as: WriteAnalogSensor) =>
|
|
||||||
alovaInstance.Post('/rest/writeAnalogSensor', as);
|
|
||||||
|
|
||||||
// Activity
|
|
||||||
export const readActivity = () => alovaInstance.Get<Activity>('/rest/activity');
|
|
||||||
|
|
||||||
// API
|
|
||||||
export const API = (apiCall: APIcall) => alovaInstance.Post('/api', apiCall);
|
|
||||||
|
|
||||||
// Generic action
|
|
||||||
export const callAction = (action: Action) =>
|
|
||||||
alovaInstance.Post('/rest/action', action);
|
|
||||||
|
|
||||||
// SettingsCustomization
|
|
||||||
export const readDeviceEntities = (id: number) =>
|
|
||||||
alovaInstance.Get<DeviceEntity[]>('/rest/deviceEntities', {
|
|
||||||
params: { id },
|
|
||||||
...MSGPACK_CONFIG,
|
|
||||||
// @ts-expect-error - exactOptionalPropertyTypes compatibility issue
|
|
||||||
transform(data) {
|
|
||||||
const entities = data as DeviceEntity[];
|
|
||||||
return entities.map((de) => ({
|
|
||||||
...de,
|
|
||||||
o_m: de.m,
|
|
||||||
o_cn: de.cn,
|
|
||||||
o_mi: de.mi,
|
|
||||||
o_ma: de.ma
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
export const resetCustomizations = () =>
|
|
||||||
alovaInstance.Post('/rest/resetCustomizations');
|
|
||||||
export const writeCustomizationEntities = (data: {
|
|
||||||
id: number;
|
|
||||||
entity_ids: string[];
|
|
||||||
}) => alovaInstance.Post('/rest/customizationEntities', data);
|
|
||||||
export const writeDeviceName = (data: { id: number; name: string }) =>
|
|
||||||
alovaInstance.Post('/rest/writeDeviceName', data);
|
|
||||||
|
|
||||||
// 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) => ({
|
|
||||||
...si,
|
|
||||||
o_id: si.id,
|
|
||||||
o_active: si.active,
|
|
||||||
o_deleted: si.deleted,
|
|
||||||
o_flags: si.flags,
|
|
||||||
o_time: si.time,
|
|
||||||
o_cmd: si.cmd,
|
|
||||||
o_value: si.value,
|
|
||||||
o_name: si.name
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
export const writeSchedule = (data: Schedule) =>
|
|
||||||
alovaInstance.Post('/rest/schedule', data);
|
|
||||||
|
|
||||||
// Modules
|
|
||||||
export const readModules = () =>
|
|
||||||
alovaInstance.Get<ModuleItem[]>('/rest/modules', {
|
|
||||||
transform(data) {
|
|
||||||
const modules = (data as Modules).modules;
|
|
||||||
return modules.map((mi) => ({
|
|
||||||
...mi,
|
|
||||||
o_enabled: mi.enabled,
|
|
||||||
o_license: mi.license
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
export const writeModules = (data: {
|
|
||||||
key: string;
|
|
||||||
enabled: boolean;
|
|
||||||
license: string;
|
|
||||||
}) => alovaInstance.Post('/rest/modules', 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) => ({
|
|
||||||
...ei,
|
|
||||||
o_id: ei.id,
|
|
||||||
o_ram: ei.ram,
|
|
||||||
o_device_id: ei.device_id,
|
|
||||||
o_type_id: ei.type_id,
|
|
||||||
o_offset: ei.offset,
|
|
||||||
o_factor: ei.factor,
|
|
||||||
o_uom: ei.uom,
|
|
||||||
o_value_type: ei.value_type,
|
|
||||||
o_name: ei.name,
|
|
||||||
o_writeable: ei.writeable,
|
|
||||||
o_value: ei.value,
|
|
||||||
o_deleted: ei.deleted,
|
|
||||||
o_hide: ei.hide
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
export const writeCustomEntities = (data: Entities) =>
|
|
||||||
alovaInstance.Post('/rest/customEntities', data);
|
|
||||||
@@ -1,29 +1,21 @@
|
|||||||
import type { Path } from 'react-router';
|
|
||||||
|
|
||||||
import { jwtDecode } from 'jwt-decode';
|
import { jwtDecode } from 'jwt-decode';
|
||||||
import type { Me, SignInRequest, SignInResponse } from 'types';
|
import { ACCESS_TOKEN, alovaInstance } from './endpoints';
|
||||||
|
import type * as H from 'history';
|
||||||
|
import type { Path } from 'react-router-dom';
|
||||||
|
|
||||||
import { ACCESS_TOKEN, alovaInstance } from '../../api/endpoints';
|
import type { Me, SignInRequest, SignInResponse } from 'types';
|
||||||
|
|
||||||
export const SIGN_IN_PATHNAME = 'loginPathname';
|
export const SIGN_IN_PATHNAME = 'loginPathname';
|
||||||
export const SIGN_IN_SEARCH = 'loginSearch';
|
export const SIGN_IN_SEARCH = 'loginSearch';
|
||||||
|
|
||||||
export const verifyAuthorization = () =>
|
export const verifyAuthorization = () => alovaInstance.Get('/rest/verifyAuthorization');
|
||||||
alovaInstance.Get('/rest/verifyAuthorization');
|
export const signIn = (request: SignInRequest) => alovaInstance.Post<SignInResponse>('/rest/signIn', request);
|
||||||
export const signIn = (request: SignInRequest) =>
|
|
||||||
alovaInstance.Post<SignInResponse>('/rest/signIn', request);
|
|
||||||
|
|
||||||
// Cache storage reference to avoid repeated checks
|
|
||||||
let cachedStorage: Storage | undefined;
|
|
||||||
|
|
||||||
export function getStorage() {
|
export function getStorage() {
|
||||||
if (!cachedStorage) {
|
return localStorage || sessionStorage;
|
||||||
cachedStorage = localStorage || sessionStorage;
|
|
||||||
}
|
|
||||||
return cachedStorage;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function storeLoginRedirect(location?: { pathname: string; search: string }) {
|
export function storeLoginRedirect(location?: H.Location) {
|
||||||
if (location) {
|
if (location) {
|
||||||
getStorage().setItem(SIGN_IN_PATHNAME, location.pathname);
|
getStorage().setItem(SIGN_IN_PATHNAME, location.pathname);
|
||||||
getStorage().setItem(SIGN_IN_SEARCH, location.search);
|
getStorage().setItem(SIGN_IN_SEARCH, location.search);
|
||||||
@@ -41,7 +33,7 @@ export function fetchLoginRedirect(): Partial<Path> {
|
|||||||
clearLoginRedirect();
|
clearLoginRedirect();
|
||||||
return {
|
return {
|
||||||
pathname: signInPathname || `/dashboard`,
|
pathname: signInPathname || `/dashboard`,
|
||||||
...(signInPathname && signInSearch && { search: signInSearch })
|
search: (signInPathname && signInSearch) || undefined
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,68 +1,60 @@
|
|||||||
import { type AlovaXHRResponse, xhrRequestAdapter } from '@alova/adapter-xhr';
|
import { xhrRequestAdapter } from '@alova/adapter-xhr';
|
||||||
import { createAlova } from 'alova';
|
import { createAlova } from 'alova';
|
||||||
import ReactHook from 'alova/react';
|
import ReactHook from 'alova/react';
|
||||||
|
import { unpack } from '../api/unpack';
|
||||||
|
|
||||||
import { unpack } from './unpack';
|
export const ACCESS_TOKEN = 'access_token';
|
||||||
|
|
||||||
export const ACCESS_TOKEN = 'access_token' as const;
|
const host = window.location.host;
|
||||||
|
export const WEB_SOCKET_ROOT = 'ws://' + host + '/ws/';
|
||||||
// Cached token to avoid repeated localStorage access
|
export const EVENT_SOURCE_ROOT = 'http://' + host + '/es/';
|
||||||
let cachedToken: string | null = null;
|
|
||||||
|
|
||||||
const getAccessToken = (): string | null => {
|
|
||||||
if (cachedToken === null) {
|
|
||||||
cachedToken = localStorage.getItem(ACCESS_TOKEN);
|
|
||||||
}
|
|
||||||
return cachedToken;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Clear token cache when needed (e.g., on logout)
|
|
||||||
export const clearTokenCache = (): void => {
|
|
||||||
cachedToken = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleResponse = async (response: AlovaXHRResponse) => {
|
|
||||||
// Handle various HTTP status codes
|
|
||||||
if (response.status === 205) {
|
|
||||||
throw new Error('Reboot required');
|
|
||||||
}
|
|
||||||
if (response.status === 400) {
|
|
||||||
throw new Error('Request Failed');
|
|
||||||
}
|
|
||||||
if (response.status >= 400) {
|
|
||||||
throw new Error(response.statusText);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = (await response.data) as ArrayBuffer;
|
|
||||||
|
|
||||||
// Unpack MessagePack data if ArrayBuffer
|
|
||||||
if (data instanceof ArrayBuffer) {
|
|
||||||
return unpack(data) as ArrayBuffer;
|
|
||||||
}
|
|
||||||
|
|
||||||
return data;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const alovaInstance = createAlova({
|
export const alovaInstance = createAlova({
|
||||||
statesHook: ReactHook,
|
statesHook: ReactHook,
|
||||||
cacheFor: null, // disable cache
|
timeout: 3000, // 3 seconds but throwing a timeout error
|
||||||
|
localCache: null,
|
||||||
|
// localCache: {
|
||||||
|
// GET: {
|
||||||
|
// mode: 'placeholder', // see https://alova.js.org/learning/response-cache/#cache-replaceholder-mode
|
||||||
|
// expire: 2000
|
||||||
|
// }
|
||||||
|
// },
|
||||||
requestAdapter: xhrRequestAdapter(),
|
requestAdapter: xhrRequestAdapter(),
|
||||||
beforeRequest(method) {
|
beforeRequest(method) {
|
||||||
const token = getAccessToken();
|
if (localStorage.getItem(ACCESS_TOKEN)) {
|
||||||
if (token) {
|
method.config.headers.Authorization = 'Bearer ' + localStorage.getItem(ACCESS_TOKEN);
|
||||||
method.config.headers.Authorization = `Bearer ${token}`;
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
responded: {
|
responded: {
|
||||||
onSuccess: handleResponse
|
onSuccess: async (response) => {
|
||||||
|
// 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 = await response.data;
|
||||||
|
if (response.data instanceof ArrayBuffer) {
|
||||||
|
return unpack(data);
|
||||||
|
}
|
||||||
|
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({
|
export const alovaInstanceGH = createAlova({
|
||||||
baseURL:
|
baseURL: 'https://api.github.com/repos/emsesp/EMS-ESP32/releases',
|
||||||
process.env.NODE_ENV === 'development'
|
|
||||||
? '/gh'
|
|
||||||
: 'https://api.github.com/repos/emsesp/EMS-ESP32/releases',
|
|
||||||
statesHook: ReactHook,
|
statesHook: ReactHook,
|
||||||
requestAdapter: xhrRequestAdapter()
|
requestAdapter: xhrRequestAdapter()
|
||||||
});
|
});
|
||||||
|
|||||||
5
interface/src/api/features.ts
Normal file
5
interface/src/api/features.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { alovaInstance } from './endpoints';
|
||||||
|
|
||||||
|
import type { Features } from 'types';
|
||||||
|
|
||||||
|
export const readFeatures = () => alovaInstance.Get<Features>('/rest/features');
|
||||||
@@ -1,10 +1,6 @@
|
|||||||
import type { MqttSettingsType, MqttStatusType } from 'types';
|
|
||||||
|
|
||||||
import { alovaInstance } from './endpoints';
|
import { alovaInstance } from './endpoints';
|
||||||
|
import type { MqttSettings, MqttStatus } from 'types';
|
||||||
|
|
||||||
export const readMqttStatus = () =>
|
export const readMqttStatus = () => alovaInstance.Get<MqttStatus>('/rest/mqttStatus');
|
||||||
alovaInstance.Get<MqttStatusType>('/rest/mqttStatus');
|
export const readMqttSettings = () => alovaInstance.Get<MqttSettings>('/rest/mqttSettings');
|
||||||
export const readMqttSettings = () =>
|
export const updateMqttSettings = (data: MqttSettings) => alovaInstance.Post<MqttSettings>('/rest/mqttSettings', data);
|
||||||
alovaInstance.Get<MqttSettingsType>('/rest/mqttSettings');
|
|
||||||
export const updateMqttSettings = (data: MqttSettingsType) =>
|
|
||||||
alovaInstance.Post<MqttSettingsType>('/rest/mqttSettings', data);
|
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
import type { NetworkSettingsType, NetworkStatusType, WiFiNetworkList } from 'types';
|
|
||||||
|
|
||||||
import { alovaInstance } from './endpoints';
|
import { alovaInstance } from './endpoints';
|
||||||
|
|
||||||
const LIST_NETWORKS_TIMEOUT = 20000; // 20 seconds
|
import type { WiFiNetworkList, NetworkSettings, NetworkStatus } from 'types';
|
||||||
|
|
||||||
export const readNetworkStatus = () =>
|
export const readNetworkStatus = () => alovaInstance.Get<NetworkStatus>('/rest/networkStatus');
|
||||||
alovaInstance.Get<NetworkStatusType>('/rest/networkStatus');
|
|
||||||
export const scanNetworks = () => alovaInstance.Get('/rest/scanNetworks');
|
export const scanNetworks = () => alovaInstance.Get('/rest/scanNetworks');
|
||||||
export const listNetworks = () =>
|
export const listNetworks = () =>
|
||||||
alovaInstance.Get<WiFiNetworkList>('/rest/listNetworks', {
|
alovaInstance.Get<WiFiNetworkList>('/rest/listNetworks', {
|
||||||
timeout: LIST_NETWORKS_TIMEOUT
|
name: 'listNetworks',
|
||||||
|
timeout: 20000 // timeout 20 seconds
|
||||||
});
|
});
|
||||||
export const readNetworkSettings = () =>
|
export const readNetworkSettings = () =>
|
||||||
alovaInstance.Get<NetworkSettingsType>('/rest/networkSettings');
|
alovaInstance.Get<NetworkSettings>('/rest/networkSettings', { name: 'networkSettings' });
|
||||||
export const updateNetworkSettings = (wifiSettings: NetworkSettingsType) =>
|
export const updateNetworkSettings = (wifiSettings: NetworkSettings) =>
|
||||||
alovaInstance.Post<NetworkSettingsType>('/rest/networkSettings', wifiSettings);
|
alovaInstance.Post<NetworkSettings>('/rest/networkSettings', wifiSettings);
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
import type { NTPSettingsType, NTPStatusType, Time } from 'types';
|
|
||||||
|
|
||||||
import { alovaInstance } from './endpoints';
|
import { alovaInstance } from './endpoints';
|
||||||
|
import type { NTPSettings, NTPStatus, Time } from 'types';
|
||||||
|
|
||||||
export const readNTPStatus = () =>
|
export const readNTPStatus = () => alovaInstance.Get<NTPStatus>('/rest/ntpStatus');
|
||||||
alovaInstance.Get<NTPStatusType>('/rest/ntpStatus');
|
|
||||||
|
|
||||||
export const readNTPSettings = () =>
|
export const readNTPSettings = () =>
|
||||||
alovaInstance.Get<NTPSettingsType>('/rest/ntpSettings');
|
alovaInstance.Get<NTPSettings>('/rest/ntpSettings', {
|
||||||
export const updateNTPSettings = (data: NTPSettingsType) =>
|
name: 'ntpSettings'
|
||||||
alovaInstance.Post<NTPSettingsType>('/rest/ntpSettings', data);
|
});
|
||||||
|
export const updateNTPSettings = (data: NTPSettings) => alovaInstance.Post<NTPSettings>('/rest/ntpSettings', data);
|
||||||
|
|
||||||
export const updateTime = (data: Time) =>
|
export const updateTime = (data: Time) => alovaInstance.Post<Time>('/rest/time', data);
|
||||||
alovaInstance.Post<Time>('/rest/time', data);
|
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import type { SecuritySettingsType, Token } from 'types';
|
|
||||||
|
|
||||||
import { alovaInstance } from './endpoints';
|
import { alovaInstance } from './endpoints';
|
||||||
|
|
||||||
export const readSecuritySettings = () =>
|
import type { SecuritySettings, Token } from 'types';
|
||||||
alovaInstance.Get<SecuritySettingsType>('/rest/securitySettings');
|
|
||||||
|
|
||||||
export const updateSecuritySettings = (securitySettings: SecuritySettingsType) =>
|
export const readSecuritySettings = () => alovaInstance.Get<SecuritySettings>('/rest/securitySettings');
|
||||||
|
|
||||||
|
export const updateSecuritySettings = (securitySettings: SecuritySettings) =>
|
||||||
alovaInstance.Post('/rest/securitySettings', securitySettings);
|
alovaInstance.Post('/rest/securitySettings', securitySettings);
|
||||||
|
|
||||||
export const generateToken = (username?: string) =>
|
export const generateToken = (username?: string) =>
|
||||||
|
|||||||
@@ -1,47 +1,42 @@
|
|||||||
import type { LogSettings, SystemStatus } from 'types';
|
|
||||||
|
|
||||||
import { alovaInstance, alovaInstanceGH } from './endpoints';
|
import { alovaInstance, alovaInstanceGH } from './endpoints';
|
||||||
|
import type { OTASettings, SystemStatus, LogSettings } from 'types';
|
||||||
|
|
||||||
// systemStatus - also used to ping in System Monitor for pinging
|
// SystemStatus - also used to ping in Restart monitor for pinging
|
||||||
export const readSystemStatus = () =>
|
export const readSystemStatus = () => alovaInstance.Get<SystemStatus>('/rest/systemStatus');
|
||||||
alovaInstance.Get<SystemStatus>('/rest/systemStatus');
|
|
||||||
|
// commands
|
||||||
|
export const restart = () => alovaInstance.Post('/rest/restart');
|
||||||
|
export const partition = () => alovaInstance.Post('/rest/partition');
|
||||||
|
export const factoryReset = () => alovaInstance.Post('/rest/factoryReset');
|
||||||
|
|
||||||
|
// OTA
|
||||||
|
export const readOTASettings = () => alovaInstance.Get<OTASettings>(`/rest/otaSettings`);
|
||||||
|
export const updateOTASettings = (data: any) => alovaInstance.Post('/rest/otaSettings', data);
|
||||||
|
|
||||||
// SystemLog
|
// SystemLog
|
||||||
export const readLogSettings = () =>
|
export const readLogSettings = () => alovaInstance.Get<LogSettings>(`/rest/logSettings`);
|
||||||
alovaInstance.Get<LogSettings>('/rest/logSettings');
|
export const updateLogSettings = (data: any) => alovaInstance.Post('/rest/logSettings', data);
|
||||||
export const updateLogSettings = (data: LogSettings) =>
|
export const fetchLog = () => alovaInstance.Post('/rest/fetchLog');
|
||||||
alovaInstance.Post('/rest/logSettings', data);
|
|
||||||
export const fetchLogES = () => alovaInstance.Get('/es/log');
|
|
||||||
|
|
||||||
// Get versions from GitHub
|
// Get versions from github
|
||||||
// cache for 10 minutes to stop getting the IP blocked by GitHub
|
|
||||||
export const getStableVersion = () =>
|
export const getStableVersion = () =>
|
||||||
alovaInstanceGH.Get('latest', {
|
alovaInstanceGH.Get('latest', {
|
||||||
cacheFor: 60 * 10 * 1000,
|
transformData(response: any) {
|
||||||
transform(response: { data: { name: string; published_at: string } }) {
|
return response.data.name.substring(1);
|
||||||
return {
|
|
||||||
name: response.data.name.substring(1),
|
|
||||||
published_at: response.data.published_at
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
export const getDevVersion = () =>
|
export const getDevVersion = () =>
|
||||||
alovaInstanceGH.Get('tags/latest', {
|
alovaInstanceGH.Get('tags/latest', {
|
||||||
cacheFor: 60 * 10 * 1000,
|
transformData(response: any) {
|
||||||
transform(response: { data: { name: string; published_at: string } }) {
|
return response.data.name.split(/\s+/).splice(-1)[0].substring(1);
|
||||||
return {
|
|
||||||
name: response.data.name.split(/\s+/).splice(-1)[0]?.substring(1) || '',
|
|
||||||
published_at: response.data.published_at
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const UPLOAD_TIMEOUT = 60000; // 1 minute
|
|
||||||
|
|
||||||
export const uploadFile = (file: File) => {
|
export const uploadFile = (file: File) => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
return alovaInstance.Post('/rest/uploadFile', formData, {
|
return alovaInstance.Post('/rest/uploadFile', formData, {
|
||||||
timeout: UPLOAD_TIMEOUT
|
timeout: 60000, // override timeout for uploading firmware - 1 minute
|
||||||
|
enableUpload: true
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,42 +1,44 @@
|
|||||||
// @ts-nocheck - Optimized MessagePack unpacking library for EMS-ESP32
|
let decoder;
|
||||||
let decoder,
|
|
||||||
src,
|
|
||||||
srcEnd,
|
|
||||||
position = 0,
|
|
||||||
strings = [],
|
|
||||||
stringPosition = 0,
|
|
||||||
currentUnpackr = {},
|
|
||||||
currentStructures,
|
|
||||||
srcString,
|
|
||||||
srcStringStart = 0,
|
|
||||||
srcStringEnd = 0,
|
|
||||||
bundledStrings,
|
|
||||||
referenceMap,
|
|
||||||
dataView;
|
|
||||||
const EMPTY_ARRAY = [],
|
|
||||||
currentExtensions = [];
|
|
||||||
const defaultOptions = { useRecords: false, mapsAsObjects: true };
|
|
||||||
try {
|
try {
|
||||||
decoder = new TextDecoder();
|
decoder = new TextDecoder();
|
||||||
} catch (error) {}
|
} catch (error) {}
|
||||||
class C1Type {}
|
let src;
|
||||||
const C1 = new C1Type();
|
let srcEnd;
|
||||||
|
let position = 0;
|
||||||
|
const EMPTY_ARRAY = [];
|
||||||
|
let strings = EMPTY_ARRAY;
|
||||||
|
let stringPosition = 0;
|
||||||
|
let currentUnpackr = {};
|
||||||
|
let currentStructures;
|
||||||
|
let srcString;
|
||||||
|
let srcStringStart = 0;
|
||||||
|
let srcStringEnd = 0;
|
||||||
|
let bundledStrings;
|
||||||
|
let referenceMap;
|
||||||
|
const currentExtensions = [];
|
||||||
|
let dataView;
|
||||||
|
const defaultOptions = {
|
||||||
|
useRecords: false,
|
||||||
|
mapsAsObjects: true
|
||||||
|
};
|
||||||
|
export class C1Type {}
|
||||||
|
export const C1 = new C1Type();
|
||||||
C1.name = 'MessagePack 0xC1';
|
C1.name = 'MessagePack 0xC1';
|
||||||
let sequentialMode = false,
|
let sequentialMode = false;
|
||||||
inlineObjectReadThreshold = 2,
|
let inlineObjectReadThreshold = 2;
|
||||||
readStruct,
|
let readStruct, onLoadedStructures, onSaveState;
|
||||||
onLoadedStructures,
|
// no-eval build
|
||||||
onSaveState;
|
|
||||||
try {
|
try {
|
||||||
new Function('');
|
new Function('');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// if eval variants are not supported, do not create inline object readers ever
|
||||||
inlineObjectReadThreshold = Infinity;
|
inlineObjectReadThreshold = Infinity;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Unpackr {
|
export class Unpackr {
|
||||||
constructor(options) {
|
constructor(options) {
|
||||||
if (options) {
|
if (options) {
|
||||||
if (options.useRecords === false && options.mapsAsObjects === undefined)
|
if (options.useRecords === false && options.mapsAsObjects === undefined) options.mapsAsObjects = true;
|
||||||
options.mapsAsObjects = true;
|
|
||||||
if (options.sequential && options.trusted !== false) {
|
if (options.sequential && options.trusted !== false) {
|
||||||
options.trusted = true;
|
options.trusted = true;
|
||||||
if (!options.structures && options.useRecords != false) {
|
if (!options.structures && options.useRecords != false) {
|
||||||
@@ -44,28 +46,28 @@ export class Unpackr {
|
|||||||
if (!options.maxSharedStructures) options.maxSharedStructures = 0;
|
if (!options.maxSharedStructures) options.maxSharedStructures = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (options.structures)
|
if (options.structures) options.structures.sharedLength = options.structures.length;
|
||||||
options.structures.sharedLength = options.structures.length;
|
|
||||||
else if (options.getStructures) {
|
else if (options.getStructures) {
|
||||||
(options.structures = []).uninitialized = true;
|
(options.structures = []).uninitialized = true; // this is what we use to denote an uninitialized structures
|
||||||
options.structures.sharedLength = 0;
|
options.structures.sharedLength = 0;
|
||||||
}
|
}
|
||||||
if (options.int64AsNumber) options.int64AsType = 'number';
|
if (options.int64AsNumber) {
|
||||||
|
options.int64AsType = 'number';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Object.assign(this, options);
|
Object.assign(this, options);
|
||||||
}
|
}
|
||||||
unpack(source, options?: { start?: number; end?: number; lazy?: boolean }) {
|
|
||||||
|
unpack(source, options?: any) {
|
||||||
if (src) {
|
if (src) {
|
||||||
|
// re-entrant execution, save the state and restore it after we do this unpack
|
||||||
return saveState(() => {
|
return saveState(() => {
|
||||||
clearSource();
|
clearSource();
|
||||||
return this
|
return this ? this.unpack(source, options) : Unpackr.prototype.unpack.call(defaultOptions, source, options);
|
||||||
? this.unpack(source, options)
|
|
||||||
: Unpackr.prototype.unpack.call(defaultOptions, source, options);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (!source.buffer && source.constructor === ArrayBuffer)
|
if (!source.buffer && source.constructor === ArrayBuffer)
|
||||||
source =
|
source = typeof Buffer !== 'undefined' ? Buffer.from(source) : new Uint8Array(source);
|
||||||
typeof Buffer !== 'undefined' ? Buffer.from(source) : new Uint8Array(source);
|
|
||||||
if (typeof options === 'object') {
|
if (typeof options === 'object') {
|
||||||
srcEnd = options.end || source.length;
|
srcEnd = options.end || source.length;
|
||||||
position = options.start || 0;
|
position = options.start || 0;
|
||||||
@@ -79,23 +81,19 @@ export class Unpackr {
|
|||||||
strings = EMPTY_ARRAY;
|
strings = EMPTY_ARRAY;
|
||||||
bundledStrings = null;
|
bundledStrings = null;
|
||||||
src = source;
|
src = source;
|
||||||
|
// this provides cached access to the data view for a buffer if it is getting reused, which is a recommend
|
||||||
|
// technique for getting data from a database where it can be copied into an existing buffer instead of creating
|
||||||
|
// new ones
|
||||||
try {
|
try {
|
||||||
dataView =
|
dataView =
|
||||||
source.dataView ||
|
source.dataView || (source.dataView = new DataView(source.buffer, source.byteOffset, source.byteLength));
|
||||||
(source.dataView = new DataView(
|
|
||||||
source.buffer,
|
|
||||||
source.byteOffset,
|
|
||||||
source.byteLength
|
|
||||||
));
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// if it doesn't have a buffer, maybe it is the wrong type of object
|
// if it doesn't have a buffer, maybe it is the wrong type of object
|
||||||
src = null;
|
src = null;
|
||||||
if (source instanceof Uint8Array) throw error;
|
if (source instanceof Uint8Array) throw error;
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Source must be a Uint8Array or Buffer but was a ' +
|
'Source must be a Uint8Array or Buffer but was a ' +
|
||||||
(source && typeof source == 'object'
|
(source && typeof source == 'object' ? source.constructor.name : typeof source)
|
||||||
? source.constructor.name
|
|
||||||
: typeof source)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (this instanceof Unpackr) {
|
if (this instanceof Unpackr) {
|
||||||
@@ -119,9 +117,7 @@ export class Unpackr {
|
|||||||
try {
|
try {
|
||||||
sequentialMode = true;
|
sequentialMode = true;
|
||||||
const size = source.length;
|
const size = source.length;
|
||||||
const value = this
|
const value = this ? this.unpack(source, size) : defaultUnpackr.unpack(source, size);
|
||||||
? this.unpack(source, size)
|
|
||||||
: defaultUnpackr.unpack(source, size);
|
|
||||||
if (forEach) {
|
if (forEach) {
|
||||||
if (forEach(value) === false) return;
|
if (forEach(value) === false) return;
|
||||||
while (position < size) {
|
while (position < size) {
|
||||||
@@ -149,11 +145,9 @@ export class Unpackr {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_mergeStructures(loadedStructures, existingStructures) {
|
_mergeStructures(loadedStructures, existingStructures) {
|
||||||
if (onLoadedStructures)
|
if (onLoadedStructures) loadedStructures = onLoadedStructures.call(this, loadedStructures);
|
||||||
loadedStructures = onLoadedStructures.call(this, loadedStructures);
|
|
||||||
loadedStructures = loadedStructures || [];
|
loadedStructures = loadedStructures || [];
|
||||||
if (Object.isFrozen(loadedStructures))
|
if (Object.isFrozen(loadedStructures)) loadedStructures = loadedStructures.map((structure) => structure.slice(0));
|
||||||
loadedStructures = loadedStructures.map((structure) => structure.slice(0));
|
|
||||||
for (let i = 0, l = loadedStructures.length; i < l; i++) {
|
for (let i = 0, l = loadedStructures.length; i < l; i++) {
|
||||||
const structure = loadedStructures[i];
|
const structure = loadedStructures[i];
|
||||||
if (structure) {
|
if (structure) {
|
||||||
@@ -168,8 +162,7 @@ export class Unpackr {
|
|||||||
const existing = existingStructures[id];
|
const existing = existingStructures[id];
|
||||||
if (existing) {
|
if (existing) {
|
||||||
if (structure)
|
if (structure)
|
||||||
(loadedStructures.restoreStructures ||
|
(loadedStructures.restoreStructures || (loadedStructures.restoreStructures = []))[id] = structure;
|
||||||
(loadedStructures.restoreStructures = []))[id] = structure;
|
|
||||||
loadedStructures[id] = existing;
|
loadedStructures[id] = existing;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -181,23 +174,17 @@ export class Unpackr {
|
|||||||
return this.unpack(source, end);
|
return this.unpack(source, end);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function getPosition() {
|
export function getPosition() {
|
||||||
return position;
|
return position;
|
||||||
}
|
}
|
||||||
function checkedRead(options?: { lazy?: boolean }) {
|
export function checkedRead(options: any) {
|
||||||
try {
|
try {
|
||||||
if (!currentUnpackr.trusted && !sequentialMode) {
|
if (!currentUnpackr.trusted && !sequentialMode) {
|
||||||
const sharedLength = currentStructures.sharedLength || 0;
|
const sharedLength = currentStructures.sharedLength || 0;
|
||||||
if (sharedLength < currentStructures.length)
|
if (sharedLength < currentStructures.length) currentStructures.length = sharedLength;
|
||||||
currentStructures.length = sharedLength;
|
|
||||||
}
|
}
|
||||||
let result;
|
let result;
|
||||||
if (
|
if (currentUnpackr.randomAccessStructure && src[position] < 0x40 && src[position] >= 0x20 && readStruct) {
|
||||||
currentUnpackr.randomAccessStructure &&
|
|
||||||
src[position] < 0x40 &&
|
|
||||||
src[position] >= 0x20 &&
|
|
||||||
readStruct
|
|
||||||
) {
|
|
||||||
result = readStruct(src, position, srcEnd, currentUnpackr);
|
result = readStruct(src, position, srcEnd, currentUnpackr);
|
||||||
src = null; // dispose of this so that recursive unpack calls don't save state
|
src = null; // dispose of this so that recursive unpack calls don't save state
|
||||||
if (!(options && options.lazy) && result) result = result.toJSON();
|
if (!(options && options.lazy) && result) result = result.toJSON();
|
||||||
@@ -211,8 +198,7 @@ function checkedRead(options?: { lazy?: boolean }) {
|
|||||||
|
|
||||||
if (position == srcEnd) {
|
if (position == srcEnd) {
|
||||||
// finished reading this source, cleanup references
|
// finished reading this source, cleanup references
|
||||||
if (currentStructures && currentStructures.restoreStructures)
|
if (currentStructures && currentStructures.restoreStructures) restoreStructures();
|
||||||
restoreStructures();
|
|
||||||
currentStructures = null;
|
currentStructures = null;
|
||||||
src = null;
|
src = null;
|
||||||
if (referenceMap) referenceMap = null;
|
if (referenceMap) referenceMap = null;
|
||||||
@@ -222,9 +208,10 @@ function checkedRead(options?: { lazy?: boolean }) {
|
|||||||
} else if (!sequentialMode) {
|
} else if (!sequentialMode) {
|
||||||
let jsonView;
|
let jsonView;
|
||||||
try {
|
try {
|
||||||
jsonView = JSON.stringify(result, (_, value) =>
|
jsonView = JSON.stringify(result, (_, value) => (typeof value === 'bigint' ? `${value}n` : value)).slice(
|
||||||
typeof value === 'bigint' ? `${value}n` : value
|
0,
|
||||||
).slice(0, 100);
|
100
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
jsonView = '(JSON view not available ' + error + ')';
|
jsonView = '(JSON view not available ' + error + ')';
|
||||||
}
|
}
|
||||||
@@ -233,14 +220,9 @@ function checkedRead(options?: { lazy?: boolean }) {
|
|||||||
// else more to read, but we are reading sequentially, so don't clear source yet
|
// else more to read, but we are reading sequentially, so don't clear source yet
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (currentStructures && currentStructures.restoreStructures)
|
if (currentStructures && currentStructures.restoreStructures) restoreStructures();
|
||||||
restoreStructures();
|
|
||||||
clearSource();
|
clearSource();
|
||||||
if (
|
if (error instanceof RangeError || error.message.startsWith('Unexpected end of buffer') || position > srcEnd) {
|
||||||
error instanceof RangeError ||
|
|
||||||
error.message.startsWith('Unexpected end of buffer') ||
|
|
||||||
position > srcEnd
|
|
||||||
) {
|
|
||||||
error.incomplete = true;
|
error.incomplete = true;
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
@@ -254,15 +236,14 @@ function restoreStructures() {
|
|||||||
currentStructures.restoreStructures = null;
|
currentStructures.restoreStructures = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function read() {
|
export function read() {
|
||||||
let token = src[position++];
|
let token = src[position++];
|
||||||
if (token < 0xa0) {
|
if (token < 0xa0) {
|
||||||
if (token < 0x80) {
|
if (token < 0x80) {
|
||||||
if (token < 0x40) return token;
|
if (token < 0x40) return token;
|
||||||
else {
|
else {
|
||||||
const structure =
|
const structure =
|
||||||
currentStructures[token & 0x3f] ||
|
currentStructures[token & 0x3f] || (currentUnpackr.getStructures && loadStructures()[token & 0x3f]);
|
||||||
(currentUnpackr.getStructures && loadStructures()[token & 0x3f]);
|
|
||||||
if (structure) {
|
if (structure) {
|
||||||
if (!structure.read) {
|
if (!structure.read) {
|
||||||
structure.read = createStructureReader(structure, token & 0x3f);
|
structure.read = createStructureReader(structure, token & 0x3f);
|
||||||
@@ -301,10 +282,7 @@ function read() {
|
|||||||
// fixstr
|
// fixstr
|
||||||
const length = token - 0xa0;
|
const length = token - 0xa0;
|
||||||
if (srcStringEnd >= position) {
|
if (srcStringEnd >= position) {
|
||||||
return srcString.slice(
|
return srcString.slice(position - srcStringStart, (position += length) - srcStringStart);
|
||||||
position - srcStringStart,
|
|
||||||
(position += length) - srcStringStart
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (srcStringEnd == 0 && srcEnd < 140) {
|
if (srcStringEnd == 0 && srcEnd < 140) {
|
||||||
// for small blocks, avoiding the overhead of the extract call is helpful
|
// for small blocks, avoiding the overhead of the extract call is helpful
|
||||||
@@ -320,16 +298,8 @@ function read() {
|
|||||||
case 0xc1:
|
case 0xc1:
|
||||||
if (bundledStrings) {
|
if (bundledStrings) {
|
||||||
value = read(); // followed by the length of the string in characters (not bytes!)
|
value = read(); // followed by the length of the string in characters (not bytes!)
|
||||||
if (value > 0)
|
if (value > 0) return bundledStrings[1].slice(bundledStrings.position1, (bundledStrings.position1 += value));
|
||||||
return bundledStrings[1].slice(
|
else return bundledStrings[0].slice(bundledStrings.position0, (bundledStrings.position0 -= value));
|
||||||
bundledStrings.position1,
|
|
||||||
(bundledStrings.position1 += value)
|
|
||||||
);
|
|
||||||
else
|
|
||||||
return bundledStrings[0].slice(
|
|
||||||
bundledStrings.position0,
|
|
||||||
(bundledStrings.position0 -= value)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return C1; // "never-used", return special object to denote that
|
return C1; // "never-used", return special object to denote that
|
||||||
case 0xc2:
|
case 0xc2:
|
||||||
@@ -368,8 +338,7 @@ function read() {
|
|||||||
value = dataView.getFloat32(position);
|
value = dataView.getFloat32(position);
|
||||||
if (currentUnpackr.useFloat32 > 2) {
|
if (currentUnpackr.useFloat32 > 2) {
|
||||||
// this does rounding of numbers that were encoded in 32-bit float to nearest significant decimal digit that could be preserved
|
// this does rounding of numbers that were encoded in 32-bit float to nearest significant decimal digit that could be preserved
|
||||||
const multiplier =
|
const multiplier = mult10[((src[position] & 0x7f) << 1) | (src[position + 1] >> 7)];
|
||||||
mult10[((src[position] & 0x7f) << 1) | (src[position + 1] >> 7)];
|
|
||||||
position += 4;
|
position += 4;
|
||||||
return ((multiplier * value + (value > 0 ? 0.5 : -0.5)) >> 0) / multiplier;
|
return ((multiplier * value + (value > 0 ? 0.5 : -0.5)) >> 0) / multiplier;
|
||||||
}
|
}
|
||||||
@@ -422,8 +391,7 @@ function read() {
|
|||||||
value = dataView.getBigInt64(position).toString();
|
value = dataView.getBigInt64(position).toString();
|
||||||
} else if (currentUnpackr.int64AsType === 'auto') {
|
} else if (currentUnpackr.int64AsType === 'auto') {
|
||||||
value = dataView.getBigInt64(position);
|
value = dataView.getBigInt64(position);
|
||||||
if (value >= BigInt(-2) << BigInt(52) && value <= BigInt(2) << BigInt(52))
|
if (value >= BigInt(-2) << BigInt(52) && value <= BigInt(2) << BigInt(52)) value = Number(value);
|
||||||
value = Number(value);
|
|
||||||
} else value = dataView.getBigInt64(position);
|
} else value = dataView.getBigInt64(position);
|
||||||
position += 8;
|
position += 8;
|
||||||
return value;
|
return value;
|
||||||
@@ -465,10 +433,7 @@ function read() {
|
|||||||
// str 8
|
// str 8
|
||||||
value = src[position++];
|
value = src[position++];
|
||||||
if (srcStringEnd >= position) {
|
if (srcStringEnd >= position) {
|
||||||
return srcString.slice(
|
return srcString.slice(position - srcStringStart, (position += value) - srcStringStart);
|
||||||
position - srcStringStart,
|
|
||||||
(position += value) - srcStringStart
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return readString8(value);
|
return readString8(value);
|
||||||
case 0xda:
|
case 0xda:
|
||||||
@@ -476,10 +441,7 @@ function read() {
|
|||||||
value = dataView.getUint16(position);
|
value = dataView.getUint16(position);
|
||||||
position += 2;
|
position += 2;
|
||||||
if (srcStringEnd >= position) {
|
if (srcStringEnd >= position) {
|
||||||
return srcString.slice(
|
return srcString.slice(position - srcStringStart, (position += value) - srcStringStart);
|
||||||
position - srcStringStart,
|
|
||||||
(position += value) - srcStringStart
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return readString16(value);
|
return readString16(value);
|
||||||
case 0xdb:
|
case 0xdb:
|
||||||
@@ -487,10 +449,7 @@ function read() {
|
|||||||
value = dataView.getUint32(position);
|
value = dataView.getUint32(position);
|
||||||
position += 4;
|
position += 4;
|
||||||
if (srcStringEnd >= position) {
|
if (srcStringEnd >= position) {
|
||||||
return srcString.slice(
|
return srcString.slice(position - srcStringStart, (position += value) - srcStringStart);
|
||||||
position - srcStringStart,
|
|
||||||
(position += value) - srcStringStart
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return readString32(value);
|
return readString32(value);
|
||||||
case 0xdc:
|
case 0xdc:
|
||||||
@@ -545,8 +504,7 @@ function createStructureReader(structure, firstId) {
|
|||||||
.join(',') +
|
.join(',') +
|
||||||
'})}'
|
'})}'
|
||||||
)(read));
|
)(read));
|
||||||
if (structure.highByte === 0)
|
if (structure.highByte === 0) structure.read = createSecondByteReader(firstId, structure.read);
|
||||||
structure.read = createSecondByteReader(firstId, structure.read);
|
|
||||||
return readObject(); // second byte is already read, if there is one so immediately read object
|
return readObject(); // second byte is already read, if there is one so immediately read object
|
||||||
}
|
}
|
||||||
const object = {};
|
const object = {};
|
||||||
@@ -569,8 +527,7 @@ const createSecondByteReader = (firstId, read0) =>
|
|||||||
function () {
|
function () {
|
||||||
const highByte = src[position++];
|
const highByte = src[position++];
|
||||||
if (highByte === 0) return read0();
|
if (highByte === 0) return read0();
|
||||||
const id =
|
const id = firstId < 32 ? -(firstId + (highByte << 5)) : firstId + (highByte << 5);
|
||||||
firstId < 32 ? -(firstId + (highByte << 5)) : firstId + (highByte << 5);
|
|
||||||
const structure = currentStructures[id] || loadStructures()[id];
|
const structure = currentStructures[id] || loadStructures()[id];
|
||||||
if (!structure) {
|
if (!structure) {
|
||||||
throw new Error('Record id is not defined for ' + id);
|
throw new Error('Record id is not defined for ' + id);
|
||||||
@@ -579,24 +536,22 @@ const createSecondByteReader = (firstId, read0) =>
|
|||||||
return structure.read();
|
return structure.read();
|
||||||
};
|
};
|
||||||
|
|
||||||
function loadStructures() {
|
export function loadStructures() {
|
||||||
const loadedStructures = saveState(() => {
|
const loadedStructures = saveState(() => {
|
||||||
// save the state in case getStructures modifies our buffer
|
// save the state in case getStructures modifies our buffer
|
||||||
src = null;
|
src = null;
|
||||||
return currentUnpackr.getStructures();
|
return currentUnpackr.getStructures();
|
||||||
});
|
});
|
||||||
return (currentStructures = currentUnpackr._mergeStructures(
|
return (currentStructures = currentUnpackr._mergeStructures(loadedStructures, currentStructures));
|
||||||
loadedStructures,
|
|
||||||
currentStructures
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var readFixedString = readStringJS;
|
var readFixedString = readStringJS;
|
||||||
var readString8 = readStringJS;
|
var readString8 = readStringJS;
|
||||||
var readString16 = readStringJS;
|
var readString16 = readStringJS;
|
||||||
var readString32 = readStringJS;
|
var readString32 = readStringJS;
|
||||||
let isNativeAccelerationEnabled = false;
|
export let isNativeAccelerationEnabled = false;
|
||||||
function setExtractor(extractStrings) {
|
|
||||||
|
export function setExtractor(extractStrings) {
|
||||||
isNativeAccelerationEnabled = true;
|
isNativeAccelerationEnabled = true;
|
||||||
readFixedString = readString(1);
|
readFixedString = readString(1);
|
||||||
readString8 = readString(2);
|
readString8 = readString(2);
|
||||||
@@ -608,11 +563,7 @@ function setExtractor(extractStrings) {
|
|||||||
if (string == null) {
|
if (string == null) {
|
||||||
if (bundledStrings) return readStringJS(length);
|
if (bundledStrings) return readStringJS(length);
|
||||||
const byteOffset = src.byteOffset;
|
const byteOffset = src.byteOffset;
|
||||||
const extraction = extractStrings(
|
const extraction = extractStrings(position - headerLength + byteOffset, srcEnd + byteOffset, src.buffer);
|
||||||
position - headerLength + byteOffset,
|
|
||||||
srcEnd + byteOffset,
|
|
||||||
src.buffer
|
|
||||||
);
|
|
||||||
if (typeof extraction == 'string') {
|
if (typeof extraction == 'string') {
|
||||||
string = extraction;
|
string = extraction;
|
||||||
strings = EMPTY_ARRAY;
|
strings = EMPTY_ARRAY;
|
||||||
@@ -642,8 +593,7 @@ function readStringJS(length) {
|
|||||||
if (length < 16) {
|
if (length < 16) {
|
||||||
if ((result = shortStringInJS(length))) return result;
|
if ((result = shortStringInJS(length))) return result;
|
||||||
}
|
}
|
||||||
if (length > 64 && decoder)
|
if (length > 64 && decoder) return decoder.decode(src.subarray(position, (position += length)));
|
||||||
return decoder.decode(src.subarray(position, (position += length)));
|
|
||||||
const end = position + length;
|
const end = position + length;
|
||||||
const units = [];
|
const units = [];
|
||||||
result = '';
|
result = '';
|
||||||
@@ -666,8 +616,7 @@ function readStringJS(length) {
|
|||||||
const byte2 = src[position++] & 0x3f;
|
const byte2 = src[position++] & 0x3f;
|
||||||
const byte3 = src[position++] & 0x3f;
|
const byte3 = src[position++] & 0x3f;
|
||||||
const byte4 = src[position++] & 0x3f;
|
const byte4 = src[position++] & 0x3f;
|
||||||
let unit =
|
let unit = ((byte1 & 0x07) << 0x12) | (byte2 << 0x0c) | (byte3 << 0x06) | byte4;
|
||||||
((byte1 & 0x07) << 0x12) | (byte2 << 0x0c) | (byte3 << 0x06) | byte4;
|
|
||||||
if (unit > 0xffff) {
|
if (unit > 0xffff) {
|
||||||
unit -= 0x10000;
|
unit -= 0x10000;
|
||||||
units.push(((unit >>> 10) & 0x3ff) | 0xd800);
|
units.push(((unit >>> 10) & 0x3ff) | 0xd800);
|
||||||
@@ -690,7 +639,7 @@ function readStringJS(length) {
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
function readString(source, start, length) {
|
export function readString(source, start, length) {
|
||||||
const existingSrc = src;
|
const existingSrc = src;
|
||||||
src = source;
|
src = source;
|
||||||
position = start;
|
position = start;
|
||||||
@@ -861,8 +810,7 @@ function shortStringInJS(length) {
|
|||||||
position -= 14;
|
position -= 14;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (length < 15)
|
if (length < 15) return fromCharCode(a, b, c, d, e, f, g, h, i, j, k, l, m, n);
|
||||||
return fromCharCode(a, b, c, d, e, f, g, h, i, j, k, l, m, n);
|
|
||||||
const o = src[position++];
|
const o = src[position++];
|
||||||
if ((o & 0x80) > 0) {
|
if ((o & 0x80) > 0) {
|
||||||
position -= 15;
|
position -= 15;
|
||||||
@@ -914,17 +862,14 @@ function readExt(length) {
|
|||||||
const type = src[position++];
|
const type = src[position++];
|
||||||
if (currentExtensions[type]) {
|
if (currentExtensions[type]) {
|
||||||
let end;
|
let end;
|
||||||
return currentExtensions[type](
|
return currentExtensions[type](src.subarray(position, (end = position += length)), (readPosition) => {
|
||||||
src.subarray(position, (end = position += length)),
|
|
||||||
(readPosition) => {
|
|
||||||
position = readPosition;
|
position = readPosition;
|
||||||
try {
|
try {
|
||||||
return read();
|
return read();
|
||||||
} finally {
|
} finally {
|
||||||
position = end;
|
position = end;
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
);
|
|
||||||
} else throw new Error('Unknown extension type ' + type);
|
} else throw new Error('Unknown extension type ' + type);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -936,20 +881,14 @@ function readKey() {
|
|||||||
length = length - 0xa0;
|
length = length - 0xa0;
|
||||||
if (srcStringEnd >= position)
|
if (srcStringEnd >= position)
|
||||||
// if it has been extracted, must use it (and faster anyway)
|
// if it has been extracted, must use it (and faster anyway)
|
||||||
return srcString.slice(
|
return srcString.slice(position - srcStringStart, (position += length) - srcStringStart);
|
||||||
position - srcStringStart,
|
|
||||||
(position += length) - srcStringStart
|
|
||||||
);
|
|
||||||
else if (!(srcStringEnd == 0 && srcEnd < 180)) return readFixedString(length);
|
else if (!(srcStringEnd == 0 && srcEnd < 180)) return readFixedString(length);
|
||||||
} else {
|
} else {
|
||||||
// not cacheable, go back and do a standard read
|
// not cacheable, go back and do a standard read
|
||||||
position--;
|
position--;
|
||||||
return read().toString();
|
return read().toString();
|
||||||
}
|
}
|
||||||
const key =
|
const key = ((length << 5) ^ (length > 1 ? dataView.getUint16(position) : length > 0 ? src[position] : 0)) & 0xfff;
|
||||||
((length << 5) ^
|
|
||||||
(length > 1 ? dataView.getUint16(position) : length > 0 ? src[position] : 0)) &
|
|
||||||
0xfff;
|
|
||||||
let entry = keyCache[key];
|
let entry = keyCache[key];
|
||||||
let checkPosition = position;
|
let checkPosition = position;
|
||||||
let end = position + length - 3;
|
let end = position + length - 3;
|
||||||
@@ -1008,8 +947,7 @@ const recordDefinition = (id, highByte) => {
|
|||||||
}
|
}
|
||||||
const existingStructure = currentStructures[id];
|
const existingStructure = currentStructures[id];
|
||||||
if (existingStructure && existingStructure.isShared) {
|
if (existingStructure && existingStructure.isShared) {
|
||||||
(currentStructures.restoreStructures ||
|
(currentStructures.restoreStructures || (currentStructures.restoreStructures = []))[id] = existingStructure;
|
||||||
(currentStructures.restoreStructures = []))[id] = existingStructure;
|
|
||||||
}
|
}
|
||||||
currentStructures[id] = structure;
|
currentStructures[id] = structure;
|
||||||
structure.read = createStructureReader(structure, firstByte);
|
structure.read = createStructureReader(structure, firstByte);
|
||||||
@@ -1054,7 +992,7 @@ currentExtensions[0x70] = (data) => {
|
|||||||
|
|
||||||
currentExtensions[0x73] = () => new Set(read());
|
currentExtensions[0x73] = () => new Set(read());
|
||||||
|
|
||||||
const typedArrays = [
|
export const typedArrays = [
|
||||||
'Int8',
|
'Int8',
|
||||||
'Uint8',
|
'Uint8',
|
||||||
'Uint8Clamped',
|
'Uint8Clamped',
|
||||||
@@ -1071,8 +1009,7 @@ const typedArrays = [
|
|||||||
currentExtensions[0x74] = (data) => {
|
currentExtensions[0x74] = (data) => {
|
||||||
const typeCode = data[0];
|
const typeCode = data[0];
|
||||||
const typedArrayName = typedArrays[typeCode];
|
const typedArrayName = typedArrays[typeCode];
|
||||||
if (!typedArrayName)
|
if (!typedArrayName) throw new Error('Could not find typed array for code ' + typeCode);
|
||||||
throw new Error('Could not find typed array for code ' + typeCode);
|
|
||||||
// we have to always slice/copy here to get a new ArrayBuffer that is word/byte aligned
|
// we have to always slice/copy here to get a new ArrayBuffer that is word/byte aligned
|
||||||
return new glbl[typedArrayName](Uint8Array.prototype.slice.call(data, 1).buffer);
|
return new glbl[typedArrayName](Uint8Array.prototype.slice.call(data, 1).buffer);
|
||||||
};
|
};
|
||||||
@@ -1096,20 +1033,11 @@ currentExtensions[0x62] = (data) => {
|
|||||||
|
|
||||||
currentExtensions[0xff] = (data) => {
|
currentExtensions[0xff] = (data) => {
|
||||||
// 32-bit date extension
|
// 32-bit date extension
|
||||||
if (data.length == 4)
|
if (data.length == 4) return new Date((data[0] * 0x1000000 + (data[1] << 16) + (data[2] << 8) + data[3]) * 1000);
|
||||||
return new Date(
|
|
||||||
(data[0] * 0x1000000 + (data[1] << 16) + (data[2] << 8) + data[3]) * 1000
|
|
||||||
);
|
|
||||||
else if (data.length == 8)
|
else if (data.length == 8)
|
||||||
return new Date(
|
return new Date(
|
||||||
((data[0] << 22) + (data[1] << 14) + (data[2] << 6) + (data[3] >> 2)) /
|
((data[0] << 22) + (data[1] << 14) + (data[2] << 6) + (data[3] >> 2)) / 1000000 +
|
||||||
1000000 +
|
((data[3] & 0x3) * 0x100000000 + data[4] * 0x1000000 + (data[5] << 16) + (data[6] << 8) + data[7]) * 1000
|
||||||
((data[3] & 0x3) * 0x100000000 +
|
|
||||||
data[4] * 0x1000000 +
|
|
||||||
(data[5] << 16) +
|
|
||||||
(data[6] << 8) +
|
|
||||||
data[7]) *
|
|
||||||
1000
|
|
||||||
);
|
);
|
||||||
else if (data.length == 12)
|
else if (data.length == 12)
|
||||||
return new Date(
|
return new Date(
|
||||||
@@ -1142,10 +1070,7 @@ function saveState(callback) {
|
|||||||
|
|
||||||
const savedSrc = new Uint8Array(src.slice(0, srcEnd)); // we copy the data in case it changes while external data is processed
|
const savedSrc = new Uint8Array(src.slice(0, srcEnd)); // we copy the data in case it changes while external data is processed
|
||||||
const savedStructures = currentStructures;
|
const savedStructures = currentStructures;
|
||||||
const savedStructuresContents = currentStructures.slice(
|
const savedStructuresContents = currentStructures.slice(0, currentStructures.length);
|
||||||
0,
|
|
||||||
currentStructures.length
|
|
||||||
);
|
|
||||||
const savedPackr = currentUnpackr;
|
const savedPackr = currentUnpackr;
|
||||||
const savedSequentialMode = sequentialMode;
|
const savedSequentialMode = sequentialMode;
|
||||||
const value = callback();
|
const value = callback();
|
||||||
@@ -1166,20 +1091,41 @@ function saveState(callback) {
|
|||||||
dataView = new DataView(src.buffer, src.byteOffset, src.byteLength);
|
dataView = new DataView(src.buffer, src.byteOffset, src.byteLength);
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
function clearSource() {
|
export function clearSource() {
|
||||||
src = null;
|
src = null;
|
||||||
referenceMap = null;
|
referenceMap = null;
|
||||||
currentStructures = null;
|
currentStructures = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function addExtension(extension) {
|
export function addExtension(extension) {
|
||||||
if (extension.unpack) currentExtensions[extension.type] = extension.unpack;
|
if (extension.unpack) currentExtensions[extension.type] = extension.unpack;
|
||||||
else currentExtensions[extension.type] = extension;
|
else currentExtensions[extension.type] = extension;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mult10 = new Array(147);
|
export const mult10 = new Array(147); // this is a table matching binary exponents to the multiplier to determine significant digit rounding
|
||||||
for (let i = 0; i < 256; i++) {
|
for (let i = 0; i < 256; i++) {
|
||||||
mult10[i] = +('1e' + Math.floor(45.15 - i * 0.30103));
|
mult10[i] = +('1e' + Math.floor(45.15 - i * 0.30103));
|
||||||
}
|
}
|
||||||
const defaultUnpackr = new Unpackr({ useRecords: false });
|
export const Decoder = Unpackr;
|
||||||
|
var defaultUnpackr = new Unpackr({ useRecords: false });
|
||||||
export const unpack = defaultUnpackr.unpack;
|
export const unpack = defaultUnpackr.unpack;
|
||||||
|
export const unpackMultiple = defaultUnpackr.unpackMultiple;
|
||||||
|
export const decode = defaultUnpackr.unpack;
|
||||||
|
export const FLOAT32_OPTIONS = {
|
||||||
|
NEVER: 0,
|
||||||
|
ALWAYS: 1,
|
||||||
|
DECIMAL_ROUND: 3,
|
||||||
|
DECIMAL_FIT: 4
|
||||||
|
};
|
||||||
|
const f32Array = new Float32Array(1);
|
||||||
|
const u8Array = new Uint8Array(f32Array.buffer, 0, 4);
|
||||||
|
export function roundFloat32(float32Number) {
|
||||||
|
f32Array[0] = float32Number;
|
||||||
|
const multiplier = mult10[((u8Array[3] & 0x7f) << 1) | (u8Array[2] >> 7)];
|
||||||
|
return ((multiplier * float32Number + (float32Number > 0 ? 0.5 : -0.5)) >> 0) / multiplier;
|
||||||
|
}
|
||||||
|
export function setReadStruct(updatedReadStruct, loadedStructs, saveState) {
|
||||||
|
readStruct = updatedReadStruct;
|
||||||
|
onLoadedStructures = loadedStructs;
|
||||||
|
onSaveState = saveState;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,400 +0,0 @@
|
|||||||
import { useCallback, useMemo, useState } from 'react';
|
|
||||||
import { useBlocker } from 'react-router';
|
|
||||||
import { toast } from 'react-toastify';
|
|
||||||
|
|
||||||
import AddIcon from '@mui/icons-material/Add';
|
|
||||||
import CancelIcon from '@mui/icons-material/Cancel';
|
|
||||||
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
|
|
||||||
import WarningIcon from '@mui/icons-material/Warning';
|
|
||||||
import { Box, Button, Typography } from '@mui/material';
|
|
||||||
|
|
||||||
import {
|
|
||||||
Body,
|
|
||||||
Cell,
|
|
||||||
Header,
|
|
||||||
HeaderCell,
|
|
||||||
HeaderRow,
|
|
||||||
Row,
|
|
||||||
Table
|
|
||||||
} from '@table-library/react-table-library/table';
|
|
||||||
import { useTheme } from '@table-library/react-table-library/theme';
|
|
||||||
import { updateState, useRequest } from 'alova/client';
|
|
||||||
import {
|
|
||||||
BlockNavigation,
|
|
||||||
ButtonRow,
|
|
||||||
FormLoader,
|
|
||||||
SectionContent,
|
|
||||||
useLayoutTitle
|
|
||||||
} from 'components';
|
|
||||||
import { useI18nContext } from 'i18n/i18n-react';
|
|
||||||
import { useInterval } from 'utils';
|
|
||||||
|
|
||||||
import { readCustomEntities, writeCustomEntities } from '../../api/app';
|
|
||||||
import SettingsCustomEntitiesDialog from './CustomEntitiesDialog';
|
|
||||||
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);
|
|
||||||
const blocker = useBlocker(numChanges !== 0);
|
|
||||||
const [selectedEntityItem, setSelectedEntityItem] = useState<EntityItem>();
|
|
||||||
const [creating, setCreating] = useState<boolean>(false);
|
|
||||||
const [dialogOpen, setDialogOpen] = useState<boolean>(false);
|
|
||||||
|
|
||||||
useLayoutTitle(LL.CUSTOM_ENTITIES(0));
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: entities,
|
|
||||||
send: fetchEntities,
|
|
||||||
error
|
|
||||||
} = useRequest(readCustomEntities, {
|
|
||||||
initialData: []
|
|
||||||
});
|
|
||||||
|
|
||||||
const intervalCallback = useCallback(() => {
|
|
||||||
if (!dialogOpen && !numChanges) {
|
|
||||||
void fetchEntities();
|
|
||||||
}
|
|
||||||
}, [dialogOpen, numChanges, fetchEntities]);
|
|
||||||
|
|
||||||
useInterval(intervalCallback);
|
|
||||||
|
|
||||||
const { send: writeEntities } = useRequest(
|
|
||||||
(data: Entities) => writeCustomEntities(data),
|
|
||||||
{ immediate: false }
|
|
||||||
);
|
|
||||||
|
|
||||||
const hasEntityChanged = useCallback((ei: EntityItem) => {
|
|
||||||
return (
|
|
||||||
ei.id !== ei.o_id ||
|
|
||||||
ei.ram !== ei.o_ram ||
|
|
||||||
(ei?.name || '') !== (ei?.o_name || '') ||
|
|
||||||
ei.device_id !== ei.o_device_id ||
|
|
||||||
ei.type_id !== ei.o_type_id ||
|
|
||||||
ei.offset !== ei.o_offset ||
|
|
||||||
ei.uom !== ei.o_uom ||
|
|
||||||
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;
|
|
||||||
`,
|
|
||||||
BaseRow: `
|
|
||||||
font-size: 14px;
|
|
||||||
.td {
|
|
||||||
height: 32px;
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
BaseCell: `
|
|
||||||
&:nth-of-type(1) {
|
|
||||||
padding: 8px;
|
|
||||||
}
|
|
||||||
&:nth-of-type(2) {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
&:nth-of-type(3) {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
&:nth-of-type(4) {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
&:nth-of-type(5) {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
&:nth-of-type(6) {
|
|
||||||
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 saveEntities = useCallback(async () => {
|
|
||||||
await writeEntities({
|
|
||||||
entities: entities
|
|
||||||
.filter((ei: EntityItem) => !ei.deleted)
|
|
||||||
.map((condensed_ei: EntityItem) => ({
|
|
||||||
id: condensed_ei.id,
|
|
||||||
ram: condensed_ei.ram,
|
|
||||||
name: condensed_ei.name,
|
|
||||||
device_id: condensed_ei.device_id,
|
|
||||||
type_id: condensed_ei.type_id,
|
|
||||||
offset: condensed_ei.offset,
|
|
||||||
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
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
toast.success(LL.ENTITIES_UPDATED());
|
|
||||||
})
|
|
||||||
.catch((error: Error) => {
|
|
||||||
toast.error(error.message);
|
|
||||||
})
|
|
||||||
.finally(async () => {
|
|
||||||
await fetchEntities();
|
|
||||||
setNumChanges(0);
|
|
||||||
});
|
|
||||||
}, [entities, writeEntities, LL, fetchEntities]);
|
|
||||||
|
|
||||||
const editEntityItem = useCallback((ei: EntityItem) => {
|
|
||||||
setCreating(false);
|
|
||||||
setSelectedEntityItem(ei);
|
|
||||||
setDialogOpen(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const onDialogClose = useCallback(() => {
|
|
||||||
setDialogOpen(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const onDialogCancel = useCallback(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
|
|
||||||
});
|
|
||||||
setDialogOpen(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const addEntityItem = useCallback(() => {
|
|
||||||
setCreating(true);
|
|
||||||
setSelectedEntityItem({
|
|
||||||
id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
|
|
||||||
name: '',
|
|
||||||
ram: 0,
|
|
||||||
device_id: '0',
|
|
||||||
type_id: '0',
|
|
||||||
offset: 0,
|
|
||||||
factor: 1,
|
|
||||||
uom: 0,
|
|
||||||
value_type: 0,
|
|
||||||
writeable: false,
|
|
||||||
deleted: false,
|
|
||||||
hide: false,
|
|
||||||
value: ''
|
|
||||||
});
|
|
||||||
setDialogOpen(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const formatValue = useCallback((value: unknown, uom: number) => {
|
|
||||||
return value === undefined
|
|
||||||
? ''
|
|
||||||
: typeof value === 'number'
|
|
||||||
? new Intl.NumberFormat().format(value) +
|
|
||||||
(uom === 0 ? '' : ` ${DeviceValueUOM_s[uom]}`)
|
|
||||||
: `${value as string}${uom === 0 ? '' : ` ${DeviceValueUOM_s[uom]}`}`;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const showHex = useCallback((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(() => {
|
|
||||||
if (!entities) {
|
|
||||||
return (
|
|
||||||
<FormLoader onRetry={fetchEntities} errorMessage={error?.message || ''} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Table
|
|
||||||
data={{
|
|
||||||
nodes: filteredAndSortedEntities
|
|
||||||
}}
|
|
||||||
theme={entity_theme}
|
|
||||||
layout={{ custom: true }}
|
|
||||||
>
|
|
||||||
{(tableList: EntityItem[]) => (
|
|
||||||
<>
|
|
||||||
<Header>
|
|
||||||
<HeaderRow>
|
|
||||||
<HeaderCell>{LL.NAME(0)}</HeaderCell>
|
|
||||||
<HeaderCell stiff>{LL.ID_OF(LL.DEVICE())}</HeaderCell>
|
|
||||||
<HeaderCell stiff>{LL.ID_OF(LL.TYPE(1))}</HeaderCell>
|
|
||||||
<HeaderCell stiff>{LL.OFFSET()}</HeaderCell>
|
|
||||||
<HeaderCell stiff>{LL.TYPE(0)}</HeaderCell>
|
|
||||||
<HeaderCell stiff>{LL.VALUE(0)}</HeaderCell>
|
|
||||||
</HeaderRow>
|
|
||||||
</Header>
|
|
||||||
<Body>
|
|
||||||
{tableList.map((ei: EntityItem) => (
|
|
||||||
<Row key={ei.name} item={ei} onClick={() => editEntityItem(ei)}>
|
|
||||||
<Cell>
|
|
||||||
{ei.name}
|
|
||||||
{ei.writeable && (
|
|
||||||
<EditOutlinedIcon
|
|
||||||
color="primary"
|
|
||||||
sx={{ fontSize: ICON_SIZE }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Cell>
|
|
||||||
<Cell>
|
|
||||||
{ei.ram === 1 ? '' : showHex(ei.device_id as number, 2)}
|
|
||||||
</Cell>
|
|
||||||
<Cell>{ei.ram === 1 ? '' : showHex(ei.type_id as number, 3)}</Cell>
|
|
||||||
<Cell>{ei.ram === 1 ? '' : ei.offset}</Cell>
|
|
||||||
<Cell>
|
|
||||||
{ei.ram === 1 ? 'RAM' : DeviceValueTypeNames[ei.value_type]}
|
|
||||||
</Cell>
|
|
||||||
<Cell>{formatValue(ei.value, ei.uom)}</Cell>
|
|
||||||
</Row>
|
|
||||||
))}
|
|
||||||
</Body>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Table>
|
|
||||||
);
|
|
||||||
}, [
|
|
||||||
entities,
|
|
||||||
error,
|
|
||||||
fetchEntities,
|
|
||||||
entity_theme,
|
|
||||||
editEntityItem,
|
|
||||||
LL,
|
|
||||||
filteredAndSortedEntities,
|
|
||||||
showHex,
|
|
||||||
formatValue
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SectionContent>
|
|
||||||
{blocker ? <BlockNavigation blocker={blocker} /> : null}
|
|
||||||
<Box mb={2} color="warning.main">
|
|
||||||
<Typography variant="body1">{LL.ENTITIES_HELP_1()}.</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{renderEntity()}
|
|
||||||
|
|
||||||
{selectedEntityItem && (
|
|
||||||
<SettingsCustomEntitiesDialog
|
|
||||||
open={dialogOpen}
|
|
||||||
creating={creating}
|
|
||||||
onClose={onDialogClose}
|
|
||||||
onSave={onDialogSave}
|
|
||||||
onDup={onDialogDup}
|
|
||||||
selectedItem={selectedEntityItem}
|
|
||||||
validator={entityItemValidation(entities, selectedEntityItem)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Box mt={2} display="flex" flexWrap="wrap">
|
|
||||||
<Box flexGrow={1}>
|
|
||||||
{numChanges > 0 && (
|
|
||||||
<ButtonRow>
|
|
||||||
<Button
|
|
||||||
startIcon={<CancelIcon />}
|
|
||||||
variant="outlined"
|
|
||||||
onClick={onDialogCancel}
|
|
||||||
color="secondary"
|
|
||||||
>
|
|
||||||
{LL.CANCEL()}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
startIcon={<WarningIcon color="warning" />}
|
|
||||||
variant="contained"
|
|
||||||
color="info"
|
|
||||||
onClick={saveEntities}
|
|
||||||
>
|
|
||||||
{LL.APPLY_CHANGES(numChanges)}
|
|
||||||
</Button>
|
|
||||||
</ButtonRow>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
<Box flexWrap="nowrap" whiteSpace="nowrap">
|
|
||||||
<Button
|
|
||||||
startIcon={<AddIcon />}
|
|
||||||
variant="outlined"
|
|
||||||
color="primary"
|
|
||||||
onClick={addEntityItem}
|
|
||||||
>
|
|
||||||
{LL.ADD(0)}
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</SectionContent>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CustomEntities;
|
|
||||||
@@ -1,447 +0,0 @@
|
|||||||
import { useCallback, useEffect, useMemo, 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,
|
|
||||||
Button,
|
|
||||||
Checkbox,
|
|
||||||
Dialog,
|
|
||||||
DialogActions,
|
|
||||||
DialogContent,
|
|
||||||
DialogTitle,
|
|
||||||
Grid,
|
|
||||||
InputAdornment,
|
|
||||||
MenuItem,
|
|
||||||
TextField
|
|
||||||
} from '@mui/material';
|
|
||||||
|
|
||||||
import { dialogStyle } from 'CustomTheme';
|
|
||||||
import type Schema from 'async-validator';
|
|
||||||
import type { ValidateFieldsError } from 'async-validator';
|
|
||||||
import { BlockFormControlLabel, ValidatedTextField } from 'components';
|
|
||||||
import { useI18nContext } from 'i18n/i18n-react';
|
|
||||||
import { numberValue, updateValue } from 'utils';
|
|
||||||
import { 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CustomEntitiesDialog = ({
|
|
||||||
open,
|
|
||||||
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>>
|
|
||||||
>
|
|
||||||
),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
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,
|
|
||||||
device_id: deviceIdHex,
|
|
||||||
type_id: typeIdHex,
|
|
||||||
factor: factorValue
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [open, selectedItem]);
|
|
||||||
|
|
||||||
const handleClose = useCallback(
|
|
||||||
(_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => {
|
|
||||||
if (reason !== 'backdropClick') {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[onClose]
|
|
||||||
);
|
|
||||||
|
|
||||||
const save = useCallback(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 processedItem.type_id === 'string') {
|
|
||||||
processedItem.type_id = Number.parseInt(processedItem.type_id, 16);
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
processedItem.value_type === DeviceValueType.BOOL &&
|
|
||||||
typeof processedItem.factor === 'string'
|
|
||||||
) {
|
|
||||||
processedItem.factor = Number.parseInt(processedItem.factor, 16);
|
|
||||||
}
|
|
||||||
onSave(processedItem);
|
|
||||||
} 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>
|
|
||||||
)),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
|
|
||||||
<DialogTitle>
|
|
||||||
{creating ? LL.ADD(1) + ' ' + LL.NEW(1) : LL.EDIT()} {LL.ENTITY()}
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogContent dividers>
|
|
||||||
<Grid container spacing={2} rowSpacing={0}>
|
|
||||||
<Grid size={12}>
|
|
||||||
<ValidatedTextField
|
|
||||||
fieldErrors={fieldErrors || {}}
|
|
||||||
name="name"
|
|
||||||
label={LL.NAME(0)}
|
|
||||||
value={editItem.name}
|
|
||||||
margin="normal"
|
|
||||||
fullWidth
|
|
||||||
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"
|
|
||||||
label={LL.VALUE(0) + ' ' + LL.TYPE(1)}
|
|
||||||
value={editItem.ram}
|
|
||||||
variant="outlined"
|
|
||||||
onChange={updateFormValue}
|
|
||||||
margin="normal"
|
|
||||||
fullWidth
|
|
||||||
select
|
|
||||||
>
|
|
||||||
<MenuItem value={0}>EMS-{LL.VALUE(1)}</MenuItem>
|
|
||||||
<MenuItem value={1}>RAM-{LL.VALUE(1)}</MenuItem>
|
|
||||||
</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>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{editItem.ram === 0 && (
|
|
||||||
<>
|
|
||||||
<Grid mt={3}>
|
|
||||||
<BlockFormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
icon={<EditOffOutlinedIcon color="primary" />}
|
|
||||||
checkedIcon={<EditOutlinedIcon htmlColor="white" />}
|
|
||||||
checked={editItem.writeable}
|
|
||||||
onChange={updateFormValue}
|
|
||||||
name="writeable"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label={LL.WRITEABLE()}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid>
|
|
||||||
<ValidatedTextField
|
|
||||||
fieldErrors={fieldErrors || {}}
|
|
||||||
name="device_id"
|
|
||||||
label={LL.ID_OF(LL.DEVICE())}
|
|
||||||
margin="normal"
|
|
||||||
sx={{ width: '11ch' }}
|
|
||||||
type="string"
|
|
||||||
value={editItem.device_id as string}
|
|
||||||
onChange={updateFormValue}
|
|
||||||
slotProps={{
|
|
||||||
input: {
|
|
||||||
startAdornment: (
|
|
||||||
<InputAdornment position="start">0x</InputAdornment>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
htmlInput: { style: { textTransform: 'uppercase' } }
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid>
|
|
||||||
<ValidatedTextField
|
|
||||||
fieldErrors={fieldErrors || {}}
|
|
||||||
name="type_id"
|
|
||||||
label={LL.ID_OF(LL.TYPE(1))}
|
|
||||||
margin="normal"
|
|
||||||
sx={{ width: '11ch' }}
|
|
||||||
type="string"
|
|
||||||
value={editItem.type_id as string}
|
|
||||||
onChange={updateFormValue}
|
|
||||||
slotProps={{
|
|
||||||
input: {
|
|
||||||
startAdornment: (
|
|
||||||
<InputAdornment position="start">0x</InputAdornment>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
htmlInput: { style: { textTransform: 'uppercase' } }
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid>
|
|
||||||
<ValidatedTextField
|
|
||||||
fieldErrors={fieldErrors || {}}
|
|
||||||
name="offset"
|
|
||||||
label={LL.OFFSET()}
|
|
||||||
margin="normal"
|
|
||||||
sx={{ width: '11ch' }}
|
|
||||||
type="number"
|
|
||||||
value={numberValue(editItem.offset)}
|
|
||||||
onChange={updateFormValue}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid>
|
|
||||||
<TextField
|
|
||||||
name="value_type"
|
|
||||||
label={LL.VALUE(0) + ' ' + LL.TYPE(1)}
|
|
||||||
value={editItem.value_type}
|
|
||||||
variant="outlined"
|
|
||||||
sx={{ width: '11ch' }}
|
|
||||||
onChange={updateFormValue}
|
|
||||||
margin="normal"
|
|
||||||
select
|
|
||||||
>
|
|
||||||
{VALUE_TYPE_OPTIONS.map((valueType) => (
|
|
||||||
<MenuItem key={valueType} value={valueType}>
|
|
||||||
{DeviceValueTypeNames[valueType]}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</TextField>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
{editItem.value_type !== DeviceValueType.BOOL &&
|
|
||||||
editItem.value_type !== DeviceValueType.STRING && (
|
|
||||||
<>
|
|
||||||
<Grid>
|
|
||||||
<TextField
|
|
||||||
name="factor"
|
|
||||||
label={LL.FACTOR()}
|
|
||||||
value={numberValue(editItem.factor as number)}
|
|
||||||
variant="outlined"
|
|
||||||
onChange={updateFormValue}
|
|
||||||
sx={{ width: '11ch' }}
|
|
||||||
margin="normal"
|
|
||||||
type="number"
|
|
||||||
slotProps={{
|
|
||||||
htmlInput: { step: '0.001' }
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid>
|
|
||||||
<TextField
|
|
||||||
name="uom"
|
|
||||||
label={LL.UNIT()}
|
|
||||||
value={editItem.uom}
|
|
||||||
margin="normal"
|
|
||||||
sx={{ width: '11ch' }}
|
|
||||||
onChange={updateFormValue}
|
|
||||||
select
|
|
||||||
>
|
|
||||||
{uomMenuItems}
|
|
||||||
</TextField>
|
|
||||||
</Grid>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{editItem.value_type === DeviceValueType.STRING &&
|
|
||||||
editItem.device_id !== '0' && (
|
|
||||||
<Grid>
|
|
||||||
<ValidatedTextField
|
|
||||||
fieldErrors={fieldErrors || {}}
|
|
||||||
name="factor"
|
|
||||||
label={LL.BYTES()}
|
|
||||||
value={numberValue(editItem.factor as number)}
|
|
||||||
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>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
{!creating && (
|
|
||||||
<Box flexGrow={1}>
|
|
||||||
<Button
|
|
||||||
startIcon={<RemoveIcon />}
|
|
||||||
variant="outlined"
|
|
||||||
color="warning"
|
|
||||||
onClick={remove}
|
|
||||||
>
|
|
||||||
{LL.REMOVE()}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
sx={{ ml: 1 }}
|
|
||||||
startIcon={<AddIcon />}
|
|
||||||
variant="outlined"
|
|
||||||
color="primary"
|
|
||||||
onClick={dup}
|
|
||||||
>
|
|
||||||
{LL.DUPLICATE()}
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
startIcon={<CancelIcon />}
|
|
||||||
variant="outlined"
|
|
||||||
onClick={onClose}
|
|
||||||
color="secondary"
|
|
||||||
>
|
|
||||||
{LL.CANCEL()}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
startIcon={creating ? <AddIcon /> : <DoneIcon />}
|
|
||||||
variant="outlined"
|
|
||||||
onClick={save}
|
|
||||||
color="primary"
|
|
||||||
>
|
|
||||||
{creating ? LL.ADD(0) : LL.UPDATE()}
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CustomEntitiesDialog;
|
|
||||||
@@ -1,807 +0,0 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
||||||
import { useBlocker, useLocation } from 'react-router';
|
|
||||||
import { toast } from 'react-toastify';
|
|
||||||
|
|
||||||
import CancelIcon from '@mui/icons-material/Cancel';
|
|
||||||
import EditIcon from '@mui/icons-material/Edit';
|
|
||||||
import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew';
|
|
||||||
import SaveIcon from '@mui/icons-material/Save';
|
|
||||||
import SearchIcon from '@mui/icons-material/Search';
|
|
||||||
import SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore';
|
|
||||||
import WarningIcon from '@mui/icons-material/Warning';
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Dialog,
|
|
||||||
DialogActions,
|
|
||||||
DialogContent,
|
|
||||||
DialogTitle,
|
|
||||||
Grid,
|
|
||||||
InputAdornment,
|
|
||||||
Link,
|
|
||||||
MenuItem,
|
|
||||||
TextField,
|
|
||||||
ToggleButton,
|
|
||||||
ToggleButtonGroup,
|
|
||||||
Typography
|
|
||||||
} from '@mui/material';
|
|
||||||
|
|
||||||
import {
|
|
||||||
Body,
|
|
||||||
Cell,
|
|
||||||
Header,
|
|
||||||
HeaderCell,
|
|
||||||
HeaderRow,
|
|
||||||
Row,
|
|
||||||
Table
|
|
||||||
} from '@table-library/react-table-library/table';
|
|
||||||
import { useTheme } from '@table-library/react-table-library/theme';
|
|
||||||
import { dialogStyle } from 'CustomTheme';
|
|
||||||
import { useRequest } from 'alova/client';
|
|
||||||
import SystemMonitor from 'app/status/SystemMonitor';
|
|
||||||
import {
|
|
||||||
BlockNavigation,
|
|
||||||
ButtonRow,
|
|
||||||
MessageBox,
|
|
||||||
SectionContent,
|
|
||||||
useLayoutTitle
|
|
||||||
} from 'components';
|
|
||||||
import { useI18nContext } from 'i18n/i18n-react';
|
|
||||||
|
|
||||||
import {
|
|
||||||
API,
|
|
||||||
readCoreData,
|
|
||||||
readDeviceEntities,
|
|
||||||
resetCustomizations,
|
|
||||||
writeCustomizationEntities,
|
|
||||||
writeDeviceName
|
|
||||||
} from '../../api/app';
|
|
||||||
import SettingsCustomizationsDialog from './CustomizationsDialog';
|
|
||||||
import EntityMaskToggle from './EntityMaskToggle';
|
|
||||||
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}` : ''}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const Customizations = () => {
|
|
||||||
const { LL } = useI18nContext();
|
|
||||||
const [numChanges, setNumChanges] = useState<number>(0);
|
|
||||||
const blocker = useBlocker(numChanges !== 0);
|
|
||||||
|
|
||||||
const [restarting, setRestarting] = useState<boolean>(false);
|
|
||||||
const [restartNeeded, setRestartNeeded] = useState<boolean>(false);
|
|
||||||
const [deviceEntities, setDeviceEntities] = useState<DeviceEntity[]>([]);
|
|
||||||
const [confirmReset, setConfirmReset] = useState<boolean>(false);
|
|
||||||
const [selectedFilters, setSelectedFilters] = useState<number>(0);
|
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
const [selectedDeviceEntity, setSelectedDeviceEntity] = useState<DeviceEntity>();
|
|
||||||
const [dialogOpen, setDialogOpen] = useState<boolean>(false);
|
|
||||||
const [rename, setRename] = useState<boolean>(false);
|
|
||||||
|
|
||||||
useLayoutTitle(LL.CUSTOMIZATIONS());
|
|
||||||
|
|
||||||
// fetch devices first from coreData
|
|
||||||
const { data: devices, send: fetchCoreData } = useRequest(readCoreData);
|
|
||||||
|
|
||||||
const { send: sendAPI } = useRequest((data: APIcall) => API(data), {
|
|
||||||
immediate: false
|
|
||||||
});
|
|
||||||
|
|
||||||
const [selectedDevice, setSelectedDevice] = useState<number>(
|
|
||||||
Number(useLocation().state) || -1
|
|
||||||
);
|
|
||||||
const [selectedDeviceTypeNameURL, setSelectedDeviceTypeNameURL] =
|
|
||||||
useState<string>(''); // needed for API URL
|
|
||||||
const [selectedDeviceName, setSelectedDeviceName] = useState<string>('');
|
|
||||||
|
|
||||||
const { send: sendResetCustomizations } = useRequest(resetCustomizations(), {
|
|
||||||
immediate: false
|
|
||||||
});
|
|
||||||
|
|
||||||
const { send: sendDeviceName } = useRequest(
|
|
||||||
(data: { id: number; name: string }) => writeDeviceName(data),
|
|
||||||
{
|
|
||||||
immediate: false
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const { send: sendCustomizationEntities } = useRequest(
|
|
||||||
(data: { id: number; entity_ids: string[] }) => writeCustomizationEntities(data),
|
|
||||||
{
|
|
||||||
immediate: false
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const { send: sendDeviceEntities } = useRequest(
|
|
||||||
(data: number) => readDeviceEntities(data),
|
|
||||||
{
|
|
||||||
initialData: [],
|
|
||||||
immediate: false
|
|
||||||
}
|
|
||||||
).onSuccess((event) => {
|
|
||||||
setOriginalSettings(event.data);
|
|
||||||
});
|
|
||||||
|
|
||||||
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;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const doRestart = async () => {
|
|
||||||
setRestarting(true);
|
|
||||||
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
|
|
||||||
(error: Error) => {
|
|
||||||
toast.error(error.message);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const entities_theme = useMemo(
|
|
||||||
() =>
|
|
||||||
useTheme({
|
|
||||||
Table: `
|
|
||||||
--data-table-library_grid-template-columns: 156px repeat(1, minmax(80px, 1fr)) 45px minmax(45px, auto) minmax(120px, auto);
|
|
||||||
`,
|
|
||||||
BaseRow: `
|
|
||||||
font-size: 14px;
|
|
||||||
.td {
|
|
||||||
height: 32px;
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
BaseCell: `
|
|
||||||
&:nth-of-type(3) {
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
&:nth-of-type(4) {
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
&:last-of-type {
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
HeaderRow: `
|
|
||||||
text-transform: uppercase;
|
|
||||||
background-color: black;
|
|
||||||
color: #90CAF9;
|
|
||||||
.th {
|
|
||||||
border-bottom: 1px solid #565656;
|
|
||||||
height: 36px;
|
|
||||||
}
|
|
||||||
&:nth-of-type(1) .th {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
Row: `
|
|
||||||
background-color: #1e1e1e;
|
|
||||||
position: relative;
|
|
||||||
cursor: pointer;
|
|
||||||
.td {
|
|
||||||
border-top: 1px solid #565656;
|
|
||||||
border-bottom: 1px solid #565656;
|
|
||||||
}
|
|
||||||
&.tr.tr-body.row-select.row-select-single-selected {
|
|
||||||
background-color: #3d4752;
|
|
||||||
}
|
|
||||||
&:hover .td {
|
|
||||||
border-top: 1px solid #177ac9;
|
|
||||||
background-color: #177ac9;
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
Cell: `
|
|
||||||
&:nth-of-type(2) {
|
|
||||||
padding: 8px;
|
|
||||||
}
|
|
||||||
&:nth-of-type(3) {
|
|
||||||
padding-right: 4px;
|
|
||||||
}
|
|
||||||
&:nth-of-type(4) {
|
|
||||||
padding-right: 4px;
|
|
||||||
}
|
|
||||||
&:last-of-type {
|
|
||||||
padding-right: 8px;
|
|
||||||
}
|
|
||||||
`
|
|
||||||
}),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
function hasEntityChanged(de: DeviceEntity) {
|
|
||||||
return (
|
|
||||||
(de?.cn || '') !== (de?.o_cn || '') ||
|
|
||||||
de.m !== de.o_m ||
|
|
||||||
de.ma !== de.o_ma ||
|
|
||||||
de.mi !== de.o_mi
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (deviceEntities.length) {
|
|
||||||
const changedEntities = deviceEntities.filter((de) => hasEntityChanged(de));
|
|
||||||
setNumChanges(changedEntities.length);
|
|
||||||
}
|
|
||||||
}, [deviceEntities]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (devices && selectedDevice !== -1) {
|
|
||||||
void sendDeviceEntities(selectedDevice);
|
|
||||||
const index = devices.devices.findIndex((d) => d.id === selectedDevice);
|
|
||||||
if (index === -1) {
|
|
||||||
setSelectedDevice(-1);
|
|
||||||
setSelectedDeviceTypeNameURL('');
|
|
||||||
} else {
|
|
||||||
const device = devices.devices[index];
|
|
||||||
if (device) {
|
|
||||||
setSelectedDeviceTypeNameURL(device.url || '');
|
|
||||||
setSelectedDeviceName(device.n);
|
|
||||||
}
|
|
||||||
setNumChanges(0);
|
|
||||||
setRestartNeeded(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [devices, selectedDevice]);
|
|
||||||
|
|
||||||
function formatValue(value: unknown) {
|
|
||||||
if (typeof value === 'number') {
|
|
||||||
return new Intl.NumberFormat().format(value);
|
|
||||||
} else if (value === undefined) {
|
|
||||||
return '';
|
|
||||||
} else if (typeof value === 'boolean') {
|
|
||||||
return value ? 'true' : 'false';
|
|
||||||
}
|
|
||||||
return value as string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isCommand = useCallback((de: DeviceEntity) => {
|
|
||||||
return de.n && de.n[0] === '!';
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const formatName = useCallback(
|
|
||||||
(de: DeviceEntity, withShortname: boolean) => {
|
|
||||||
let name: string;
|
|
||||||
if (isCommand(de)) {
|
|
||||||
name = de.t
|
|
||||||
? `${LL.COMMAND(1)}: ${de.t} ${de.n?.slice(1)}`
|
|
||||||
: `${LL.COMMAND(1)}: ${de.n?.slice(1)}`;
|
|
||||||
} else if (de.cn && de.cn !== '') {
|
|
||||||
name = de.t ? `${de.t} ${de.cn}` : de.cn;
|
|
||||||
} else {
|
|
||||||
name = de.t ? `${de.t} ${de.n}` : de.n || '';
|
|
||||||
}
|
|
||||||
return withShortname ? `${name} ${de.id}` : name;
|
|
||||||
},
|
|
||||||
[LL]
|
|
||||||
);
|
|
||||||
|
|
||||||
const getMaskNumber = (newMask: string[]) => {
|
|
||||||
let new_mask = 0;
|
|
||||||
for (const entry of newMask) {
|
|
||||||
new_mask |= Number(entry);
|
|
||||||
}
|
|
||||||
return new_mask;
|
|
||||||
};
|
|
||||||
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
const filter_entity = useCallback(
|
|
||||||
(de: DeviceEntity) =>
|
|
||||||
(de.m & selectedFilters || !selectedFilters) &&
|
|
||||||
formatName(de, true).toLowerCase().includes(search.toLowerCase()),
|
|
||||||
[selectedFilters, search, formatName]
|
|
||||||
);
|
|
||||||
|
|
||||||
const maskDisabled = useCallback(
|
|
||||||
(set: boolean) => {
|
|
||||||
setDeviceEntities((prev) =>
|
|
||||||
prev.map((de) => {
|
|
||||||
if (filter_entity(de)) {
|
|
||||||
const excludeMask =
|
|
||||||
DeviceEntityMask.DV_API_MQTT_EXCLUDE | DeviceEntityMask.DV_WEB_EXCLUDE;
|
|
||||||
return {
|
|
||||||
...de,
|
|
||||||
m: set ? de.m | excludeMask : de.m & ~excludeMask
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return de;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[filter_entity]
|
|
||||||
);
|
|
||||||
|
|
||||||
const resetCustomization = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
await sendResetCustomizations();
|
|
||||||
toast.info(LL.CUSTOMIZATIONS_RESTART());
|
|
||||||
} catch (error) {
|
|
||||||
toast.error((error as Error).message);
|
|
||||||
} finally {
|
|
||||||
setConfirmReset(false);
|
|
||||||
setRestarting(true);
|
|
||||||
}
|
|
||||||
}, [sendResetCustomizations, LL]);
|
|
||||||
|
|
||||||
const onDialogClose = () => {
|
|
||||||
setDialogOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateDeviceEntity = useCallback((updatedItem: DeviceEntity) => {
|
|
||||||
setDeviceEntities(
|
|
||||||
(prev) =>
|
|
||||||
prev?.map((de) =>
|
|
||||||
de.id === updatedItem.id ? { ...de, ...updatedItem } : de
|
|
||||||
) ?? []
|
|
||||||
);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const onDialogSave = useCallback(
|
|
||||||
(updatedItem: DeviceEntity) => {
|
|
||||||
setDialogOpen(false);
|
|
||||||
updateDeviceEntity(updatedItem);
|
|
||||||
},
|
|
||||||
[updateDeviceEntity]
|
|
||||||
);
|
|
||||||
|
|
||||||
const editDeviceEntity = useCallback((de: DeviceEntity) => {
|
|
||||||
if (de.n === undefined || (de.n && de.n[0] === '!')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (de.cn === undefined) {
|
|
||||||
de.cn = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
setSelectedDeviceEntity(de);
|
|
||||||
setDialogOpen(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const saveCustomization = useCallback(async () => {
|
|
||||||
if (!devices || !deviceEntities || selectedDevice === -1) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 > 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) => {
|
|
||||||
if (error.message === 'Reboot required') {
|
|
||||||
setRestartNeeded(true);
|
|
||||||
} else {
|
|
||||||
toast.error(error.message);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setOriginalSettings(deviceEntities);
|
|
||||||
});
|
|
||||||
}, [devices, deviceEntities, selectedDevice, sendCustomizationEntities, LL]);
|
|
||||||
|
|
||||||
const renameDevice = useCallback(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)}`);
|
|
||||||
})
|
|
||||||
.finally(async () => {
|
|
||||||
setRename(false);
|
|
||||||
await fetchCoreData();
|
|
||||||
});
|
|
||||||
}, [selectedDevice, selectedDeviceName, sendDeviceName, LL, fetchCoreData]);
|
|
||||||
|
|
||||||
const renderDeviceList = () => (
|
|
||||||
<>
|
|
||||||
<Box mb={1} color="warning.main">
|
|
||||||
<Typography variant="body1">{LL.CUSTOMIZATIONS_HELP_1()}.</Typography>
|
|
||||||
</Box>
|
|
||||||
<Box display="flex" flexWrap="wrap" alignItems="center" gap={2}>
|
|
||||||
{rename ? (
|
|
||||||
<TextField
|
|
||||||
name="device"
|
|
||||||
label={LL.EMS_DEVICE()}
|
|
||||||
fullWidth
|
|
||||||
variant="outlined"
|
|
||||||
value={selectedDeviceName}
|
|
||||||
onChange={(e) => setSelectedDeviceName(e.target.value)}
|
|
||||||
margin="normal"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<TextField
|
|
||||||
name="device"
|
|
||||||
label={LL.EMS_DEVICE()}
|
|
||||||
variant="outlined"
|
|
||||||
value={selectedDevice}
|
|
||||||
disabled={numChanges !== 0}
|
|
||||||
onChange={(e) => setSelectedDevice(parseInt(e.target.value))}
|
|
||||||
margin="normal"
|
|
||||||
style={{ minWidth: '50%' }}
|
|
||||||
select
|
|
||||||
>
|
|
||||||
<MenuItem disabled key={-1} value={-1}>
|
|
||||||
{LL.SELECT_DEVICE()}...
|
|
||||||
</MenuItem>
|
|
||||||
{devices.devices.map(
|
|
||||||
(device: Device) =>
|
|
||||||
device.id < 90 && (
|
|
||||||
<MenuItem key={device.id} value={device.id}>
|
|
||||||
{device.n} ({device.tn})
|
|
||||||
</MenuItem>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</TextField>
|
|
||||||
)}
|
|
||||||
{selectedDevice !== -1 &&
|
|
||||||
(rename ? (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
startIcon={<CancelIcon />}
|
|
||||||
variant="outlined"
|
|
||||||
color="secondary"
|
|
||||||
onClick={() => setRename(false)}
|
|
||||||
>
|
|
||||||
{LL.CANCEL()}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
startIcon={<SaveIcon />}
|
|
||||||
variant="outlined"
|
|
||||||
onClick={() => renameDevice()}
|
|
||||||
>
|
|
||||||
{LL.RENAME()}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
startIcon={<EditIcon />}
|
|
||||||
variant="outlined"
|
|
||||||
onClick={() => setRename(true)}
|
|
||||||
>
|
|
||||||
{LL.RENAME()}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
startIcon={<SettingsBackupRestoreIcon />}
|
|
||||||
variant="outlined"
|
|
||||||
color="error"
|
|
||||||
onClick={() => setConfirmReset(true)}
|
|
||||||
>
|
|
||||||
{LL.REMOVE_ALL()}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
const filteredEntities = useMemo(
|
|
||||||
() => deviceEntities.filter((de) => filter_entity(de)),
|
|
||||||
[deviceEntities, filter_entity]
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderDeviceData = () => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Box color="warning.main">
|
|
||||||
<Typography variant="body2" mt={1} mb={1}>
|
|
||||||
<OptionIcon type="favorite" isSet={true} />={LL.CUSTOMIZATIONS_HELP_2()}
|
|
||||||
|
|
||||||
<OptionIcon type="readonly" isSet={true} />={LL.CUSTOMIZATIONS_HELP_3()}
|
|
||||||
|
|
||||||
<OptionIcon type="api_mqtt_exclude" isSet={true} />=
|
|
||||||
{LL.CUSTOMIZATIONS_HELP_4()}
|
|
||||||
<OptionIcon type="web_exclude" isSet={true} />=
|
|
||||||
{LL.CUSTOMIZATIONS_HELP_5()}
|
|
||||||
<OptionIcon type="deleted" isSet={true} />={LL.CUSTOMIZATIONS_HELP_6()}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Grid
|
|
||||||
container
|
|
||||||
mb={1}
|
|
||||||
mt={0}
|
|
||||||
spacing={2}
|
|
||||||
direction="row"
|
|
||||||
justifyContent="flex-start"
|
|
||||||
alignItems="center"
|
|
||||||
>
|
|
||||||
<Grid>
|
|
||||||
<TextField
|
|
||||||
size="small"
|
|
||||||
variant="outlined"
|
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid>
|
|
||||||
<ToggleButtonGroup
|
|
||||||
size="small"
|
|
||||||
color="secondary"
|
|
||||||
value={getMaskString(selectedFilters)}
|
|
||||||
onChange={(_, mask: string[]) => {
|
|
||||||
setSelectedFilters(getMaskNumber(mask));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ToggleButton value="8">
|
|
||||||
<OptionIcon type="favorite" isSet={true} />
|
|
||||||
</ToggleButton>
|
|
||||||
<ToggleButton value="4">
|
|
||||||
<OptionIcon type="readonly" isSet={true} />
|
|
||||||
</ToggleButton>
|
|
||||||
<ToggleButton value="2">
|
|
||||||
<OptionIcon type="api_mqtt_exclude" isSet={true} />
|
|
||||||
</ToggleButton>
|
|
||||||
<ToggleButton value="1">
|
|
||||||
<OptionIcon type="web_exclude" isSet={true} />
|
|
||||||
</ToggleButton>
|
|
||||||
<ToggleButton value="128">
|
|
||||||
<OptionIcon type="deleted" isSet={true} />
|
|
||||||
</ToggleButton>
|
|
||||||
</ToggleButtonGroup>
|
|
||||||
</Grid>
|
|
||||||
<Grid>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
sx={{ fontSize: 10 }}
|
|
||||||
variant="outlined"
|
|
||||||
color="inherit"
|
|
||||||
onClick={() => maskDisabled(false)}
|
|
||||||
>
|
|
||||||
{LL.SET_ALL()}
|
|
||||||
<OptionIcon type="api_mqtt_exclude" isSet={false} />
|
|
||||||
<OptionIcon type="web_exclude" isSet={false} />
|
|
||||||
</Button>
|
|
||||||
</Grid>
|
|
||||||
<Grid>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
sx={{ fontSize: 10 }}
|
|
||||||
variant="outlined"
|
|
||||||
color="inherit"
|
|
||||||
onClick={() => maskDisabled(true)}
|
|
||||||
>
|
|
||||||
{LL.SET_ALL()}
|
|
||||||
<OptionIcon type="api_mqtt_exclude" isSet={true} />
|
|
||||||
<OptionIcon type="web_exclude" isSet={true} />
|
|
||||||
</Button>
|
|
||||||
</Grid>
|
|
||||||
<Grid>
|
|
||||||
<Typography variant="subtitle2" color="grey">
|
|
||||||
{LL.SHOWING()} {filteredEntities.length}/{deviceEntities.length}
|
|
||||||
{LL.ENTITIES(deviceEntities.length)}
|
|
||||||
</Typography>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
<Table
|
|
||||||
data={{ nodes: filteredEntities }}
|
|
||||||
theme={entities_theme}
|
|
||||||
layout={{ custom: true }}
|
|
||||||
>
|
|
||||||
{(tableList: DeviceEntity[]) => (
|
|
||||||
<>
|
|
||||||
<Header>
|
|
||||||
<HeaderRow>
|
|
||||||
<HeaderCell stiff>{LL.OPTIONS()}</HeaderCell>
|
|
||||||
<HeaderCell resize>{LL.NAME(1)}</HeaderCell>
|
|
||||||
<HeaderCell stiff>{LL.MIN()}</HeaderCell>
|
|
||||||
<HeaderCell stiff>{LL.MAX()}</HeaderCell>
|
|
||||||
<HeaderCell resize>{LL.VALUE(0)}</HeaderCell>
|
|
||||||
</HeaderRow>
|
|
||||||
</Header>
|
|
||||||
<Body>
|
|
||||||
{tableList.map((de: DeviceEntity) => (
|
|
||||||
<Row key={de.id} item={de} onClick={() => editDeviceEntity(de)}>
|
|
||||||
<Cell stiff>
|
|
||||||
<EntityMaskToggle onUpdate={updateDeviceEntity} de={de} />
|
|
||||||
</Cell>
|
|
||||||
<Cell>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
color:
|
|
||||||
de.v === undefined && !isCommand(de) ? 'grey' : 'inherit'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{formatName(de, false)} (
|
|
||||||
<Link
|
|
||||||
style={{
|
|
||||||
color:
|
|
||||||
de.v === undefined && !isCommand(de)
|
|
||||||
? 'grey'
|
|
||||||
: 'primary'
|
|
||||||
}}
|
|
||||||
target="_blank"
|
|
||||||
href={APIURL + selectedDeviceTypeNameURL + '/' + de.id}
|
|
||||||
>
|
|
||||||
{de.id}
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
</span>
|
|
||||||
</Cell>
|
|
||||||
<Cell>
|
|
||||||
{!(de.m & DeviceEntityMask.DV_READONLY) && formatValue(de.mi)}
|
|
||||||
</Cell>
|
|
||||||
<Cell>
|
|
||||||
{!(de.m & DeviceEntityMask.DV_READONLY) && formatValue(de.ma)}
|
|
||||||
</Cell>
|
|
||||||
<Cell>{formatValue(de.v)}</Cell>
|
|
||||||
</Row>
|
|
||||||
))}
|
|
||||||
</Body>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Table>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderResetDialog = () => (
|
|
||||||
<Dialog
|
|
||||||
sx={dialogStyle}
|
|
||||||
open={confirmReset}
|
|
||||||
onClose={() => setConfirmReset(false)}
|
|
||||||
>
|
|
||||||
<DialogTitle>{LL.REMOVE_ALL()}</DialogTitle>
|
|
||||||
<DialogContent dividers>{LL.CUSTOMIZATIONS_RESET()}</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button
|
|
||||||
startIcon={<CancelIcon />}
|
|
||||||
variant="outlined"
|
|
||||||
onClick={() => setConfirmReset(false)}
|
|
||||||
color="secondary"
|
|
||||||
>
|
|
||||||
{LL.CANCEL()}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
startIcon={<SettingsBackupRestoreIcon />}
|
|
||||||
variant="outlined"
|
|
||||||
onClick={resetCustomization}
|
|
||||||
color="error"
|
|
||||||
>
|
|
||||||
{LL.REMOVE_ALL()}
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderContent = () => (
|
|
||||||
<>
|
|
||||||
{devices && renderDeviceList()}
|
|
||||||
{selectedDevice !== -1 && !rename && renderDeviceData()}
|
|
||||||
{restartNeeded ? (
|
|
||||||
<MessageBox level="warning" message={LL.RESTART_TEXT(0)}>
|
|
||||||
<Button
|
|
||||||
sx={{ ml: 2 }}
|
|
||||||
startIcon={<PowerSettingsNewIcon />}
|
|
||||||
variant="contained"
|
|
||||||
color="error"
|
|
||||||
onClick={doRestart}
|
|
||||||
>
|
|
||||||
{LL.RESTART()}
|
|
||||||
</Button>
|
|
||||||
</MessageBox>
|
|
||||||
) : (
|
|
||||||
<Box display="flex" flexWrap="wrap">
|
|
||||||
<Box flexGrow={1}>
|
|
||||||
{numChanges !== 0 && (
|
|
||||||
<ButtonRow>
|
|
||||||
<Button
|
|
||||||
startIcon={<CancelIcon />}
|
|
||||||
variant="outlined"
|
|
||||||
color="secondary"
|
|
||||||
onClick={() => {
|
|
||||||
if (devices) {
|
|
||||||
void sendDeviceEntities(selectedDevice);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{LL.CANCEL()}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
startIcon={<WarningIcon color="warning" />}
|
|
||||||
variant="contained"
|
|
||||||
color="info"
|
|
||||||
onClick={saveCustomization}
|
|
||||||
>
|
|
||||||
{LL.APPLY_CHANGES(numChanges)}
|
|
||||||
</Button>
|
|
||||||
</ButtonRow>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
{renderResetDialog()}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SectionContent>
|
|
||||||
{blocker ? <BlockNavigation blocker={blocker} /> : null}
|
|
||||||
{restarting ? <SystemMonitor /> : renderContent()}
|
|
||||||
{selectedDeviceEntity && (
|
|
||||||
<SettingsCustomizationsDialog
|
|
||||||
open={dialogOpen}
|
|
||||||
onClose={onDialogClose}
|
|
||||||
onSave={onDialogSave}
|
|
||||||
selectedItem={selectedDeviceEntity}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</SectionContent>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Customizations;
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
|
|
||||||
|
|
||||||
import CancelIcon from '@mui/icons-material/Cancel';
|
|
||||||
import CloseIcon from '@mui/icons-material/Close';
|
|
||||||
import DoneIcon from '@mui/icons-material/Done';
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Dialog,
|
|
||||||
DialogActions,
|
|
||||||
DialogContent,
|
|
||||||
DialogTitle,
|
|
||||||
Grid,
|
|
||||||
TextField,
|
|
||||||
Typography
|
|
||||||
} from '@mui/material';
|
|
||||||
|
|
||||||
import { dialogStyle } from 'CustomTheme';
|
|
||||||
import { useI18nContext } from 'i18n/i18n-react';
|
|
||||||
import { numberValue, updateValue } from 'utils';
|
|
||||||
|
|
||||||
import EntityMaskToggle from './EntityMaskToggle';
|
|
||||||
import { DeviceEntityMask } from './types';
|
|
||||||
import type { DeviceEntity } from './types';
|
|
||||||
|
|
||||||
interface SettingsCustomizationsDialogProps {
|
|
||||||
open: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onSave: (di: DeviceEntity) => void;
|
|
||||||
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,
|
|
||||||
onSave,
|
|
||||||
selectedItem
|
|
||||||
}: SettingsCustomizationsDialogProps) => {
|
|
||||||
const { LL } = useI18nContext();
|
|
||||||
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 isWriteableNumber = useMemo(
|
|
||||||
() =>
|
|
||||||
typeof editItem.v === 'number' &&
|
|
||||||
editItem.w &&
|
|
||||||
!(editItem.m & DeviceEntityMask.DV_READONLY),
|
|
||||||
[editItem.v, editItem.w, editItem.m]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
setError(false);
|
|
||||||
setEditItem(selectedItem);
|
|
||||||
}
|
|
||||||
}, [open, selectedItem]);
|
|
||||||
|
|
||||||
const handleClose = useCallback(
|
|
||||||
(_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => {
|
|
||||||
if (reason !== 'backdropClick') {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[onClose]
|
|
||||||
);
|
|
||||||
|
|
||||||
const save = useCallback(() => {
|
|
||||||
if (
|
|
||||||
isWriteableNumber &&
|
|
||||||
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]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
|
|
||||||
<DialogTitle>{dialogTitle}</DialogTitle>
|
|
||||||
<DialogContent dividers>
|
|
||||||
<LabelValue label={LL.ID_OF(LL.ENTITY())} value={editItem.id} />
|
|
||||||
<LabelValue
|
|
||||||
label={`${LL.DEFAULT(1)} ${LL.ENTITY_NAME(1)}`}
|
|
||||||
value={editItem.n}
|
|
||||||
/>
|
|
||||||
<LabelValue label={LL.WRITEABLE()} value={writeableIcon} />
|
|
||||||
|
|
||||||
<Box mt={1} mb={2}>
|
|
||||||
<EntityMaskToggle onUpdate={updateDeviceEntity} de={editItem} />
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Grid container spacing={2}>
|
|
||||||
<Grid>
|
|
||||||
<TextField
|
|
||||||
name="cn"
|
|
||||||
label={LL.NEW_NAME_OF(LL.ENTITY())}
|
|
||||||
value={editItem.cn}
|
|
||||||
sx={{ width: '30ch' }}
|
|
||||||
onChange={updateFormValue}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
{isWriteableNumber && (
|
|
||||||
<>
|
|
||||||
<Grid>
|
|
||||||
<TextField
|
|
||||||
name="mi"
|
|
||||||
label={LL.MIN()}
|
|
||||||
value={numberValue(editItem.mi)}
|
|
||||||
sx={{ width: '11ch' }}
|
|
||||||
type="number"
|
|
||||||
onChange={updateFormValue}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid>
|
|
||||||
<TextField
|
|
||||||
name="ma"
|
|
||||||
label={LL.MAX()}
|
|
||||||
value={numberValue(editItem.ma)}
|
|
||||||
sx={{ width: '11ch' }}
|
|
||||||
type="number"
|
|
||||||
onChange={updateFormValue}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<Typography variant="body2" color="error" mt={2}>
|
|
||||||
Error: Check min and max values
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
|
||||||
|
|
||||||
<DialogActions>
|
|
||||||
<Button
|
|
||||||
startIcon={<CancelIcon />}
|
|
||||||
variant="outlined"
|
|
||||||
onClick={onClose}
|
|
||||||
color="secondary"
|
|
||||||
>
|
|
||||||
{LL.CANCEL()}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
startIcon={<DoneIcon />}
|
|
||||||
variant="outlined"
|
|
||||||
onClick={save}
|
|
||||||
color="primary"
|
|
||||||
>
|
|
||||||
{LL.UPDATE()}
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CustomizationsDialog;
|
|
||||||
@@ -1,424 +0,0 @@
|
|||||||
import { memo, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
|
||||||
import { IconContext } from 'react-icons/lib';
|
|
||||||
import { Link } from 'react-router';
|
|
||||||
import { toast } from 'react-toastify';
|
|
||||||
|
|
||||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
|
||||||
import EditIcon from '@mui/icons-material/Edit';
|
|
||||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
|
||||||
import HelpOutlineIcon from '@mui/icons-material/HelpOutline';
|
|
||||||
import UnfoldLessIcon from '@mui/icons-material/UnfoldLess';
|
|
||||||
import UnfoldMoreIcon from '@mui/icons-material/UnfoldMore';
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
IconButton,
|
|
||||||
ToggleButton,
|
|
||||||
ToggleButtonGroup,
|
|
||||||
Tooltip,
|
|
||||||
Typography
|
|
||||||
} from '@mui/material';
|
|
||||||
|
|
||||||
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 { AuthenticatedContext } from 'contexts/authentication';
|
|
||||||
import { useI18nContext } from 'i18n/i18n-react';
|
|
||||||
import { useInterval, usePersistState } from 'utils';
|
|
||||||
|
|
||||||
import { readDashboard, writeDeviceValue } from '../../api/app';
|
|
||||||
import DeviceIcon from './DeviceIcon';
|
|
||||||
import DevicesDialog from './DevicesDialog';
|
|
||||||
import { formatValue } from './deviceValue';
|
|
||||||
import {
|
|
||||||
type DashboardItem,
|
|
||||||
DeviceEntityMask,
|
|
||||||
DeviceType,
|
|
||||||
type DeviceValue
|
|
||||||
} from './types';
|
|
||||||
import { deviceValueItemValidation } from './validators';
|
|
||||||
|
|
||||||
const Dashboard = memo(() => {
|
|
||||||
const { LL } = useI18nContext();
|
|
||||||
const { me } = useContext(AuthenticatedContext);
|
|
||||||
|
|
||||||
useLayoutTitle(LL.DASHBOARD());
|
|
||||||
|
|
||||||
const [showAll, setShowAll] = usePersistState(true, 'showAll');
|
|
||||||
|
|
||||||
const [deviceValueDialogOpen, setDeviceValueDialogOpen] = useState<boolean>(false);
|
|
||||||
const [parentNodes, setParentNodes] = useState<number>(0);
|
|
||||||
const [selectedDashboardItem, setSelectedDashboardItem] =
|
|
||||||
useState<DashboardItem>();
|
|
||||||
|
|
||||||
const {
|
|
||||||
data,
|
|
||||||
send: fetchDashboard,
|
|
||||||
error
|
|
||||||
} = useRequest(readDashboard, {
|
|
||||||
initialData: { connected: true, nodes: [] }
|
|
||||||
}).onSuccess((event) => {
|
|
||||||
if (event.data.nodes.length !== parentNodes) {
|
|
||||||
setParentNodes(event.data.nodes.length); // count number of parents/devices
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const { loading: submitting, send: sendDeviceValue } = useRequest(
|
|
||||||
(data: { id: number; c: string; v: unknown }) => writeDeviceValue(data),
|
|
||||||
{
|
|
||||||
immediate: false
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
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 dashboard_theme = useMemo(
|
|
||||||
() =>
|
|
||||||
useTheme({
|
|
||||||
Table: `
|
|
||||||
--data-table-library_grid-template-columns: minmax(80px, auto) 120px 32px;
|
|
||||||
`,
|
|
||||||
BaseRow: `
|
|
||||||
font-size: 14px;
|
|
||||||
.td {
|
|
||||||
height: 28px;
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
Row: `
|
|
||||||
cursor: pointer;
|
|
||||||
background-color: #1e1e1e;
|
|
||||||
&:nth-of-type(odd) .td {
|
|
||||||
background-color: #303030;
|
|
||||||
},
|
|
||||||
&:hover .td {
|
|
||||||
background-color: #177ac9;
|
|
||||||
},
|
|
||||||
`,
|
|
||||||
BaseCell: `
|
|
||||||
&:nth-of-type(2) {
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
&:nth-of-type(3) {
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
`
|
|
||||||
}),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const tree = useTree(
|
|
||||||
{ nodes: [...data.nodes] },
|
|
||||||
{
|
|
||||||
onChange: () => {} // not used but needed
|
|
||||||
},
|
|
||||||
{
|
|
||||||
treeIcon: {
|
|
||||||
margin: '4px',
|
|
||||||
iconDefault: null,
|
|
||||||
iconRight: (
|
|
||||||
<ChevronRightIcon
|
|
||||||
sx={{ fontSize: 16, verticalAlign: 'middle' }}
|
|
||||||
color="info"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
iconDown: (
|
|
||||||
<ExpandMoreIcon
|
|
||||||
sx={{ fontSize: 16, verticalAlign: 'middle' }}
|
|
||||||
color="info"
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
indentation: 45
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
useInterval(() => {
|
|
||||||
if (!deviceValueDialogOpen) {
|
|
||||||
void fetchDashboard();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const nodeIds = useMemo(
|
|
||||||
() => data.nodes.map((item: DashboardItem) => item.id),
|
|
||||||
[data.nodes]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
showAll
|
|
||||||
? tree.fns.onAddAll(nodeIds) // 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;
|
|
||||||
}
|
|
||||||
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]
|
|
||||||
);
|
|
||||||
|
|
||||||
const showName = useCallback(
|
|
||||||
(di: DashboardItem) => {
|
|
||||||
if (di.id < 100) {
|
|
||||||
// if its a device (parent node) and has entities
|
|
||||||
if (di.nodes?.length) {
|
|
||||||
return (
|
|
||||||
<span style={{ fontSize: '15px' }}>
|
|
||||||
<DeviceIcon type_id={di.t ?? 0} />
|
|
||||||
{showType(di.n, di.t)}
|
|
||||||
<span style={{ color: 'lightblue' }}> ({di.nodes?.length})</span>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (di.dv) {
|
|
||||||
return <span>{di.dv.id.slice(2)}</span>;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
[showType]
|
|
||||||
);
|
|
||||||
|
|
||||||
const hasMask = useCallback(
|
|
||||||
(id: string, mask: number) => (parseInt(id.slice(0, 2), 16) & mask) === mask,
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const editDashboardValue = useCallback(
|
|
||||||
(di: DashboardItem) => {
|
|
||||||
if (me.admin && di.dv?.c) {
|
|
||||||
setSelectedDashboardItem(di);
|
|
||||||
setDeviceValueDialogOpen(true);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[me.admin]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleShowAll = (
|
|
||||||
_event: React.MouseEvent<HTMLElement>,
|
|
||||||
toggle: boolean | null
|
|
||||||
) => {
|
|
||||||
if (toggle !== null) {
|
|
||||||
tree.fns.onToggleAll({});
|
|
||||||
setShowAll(toggle);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<>
|
|
||||||
{!data.connected && (
|
|
||||||
<MessageBox level="error" message={LL.EMS_BUS_WARNING()} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Box
|
|
||||||
display="flex"
|
|
||||||
justifyContent="flex-end"
|
|
||||||
flexWrap="nowrap"
|
|
||||||
whiteSpace="nowrap"
|
|
||||||
>
|
|
||||||
<ToggleButtonGroup
|
|
||||||
size="small"
|
|
||||||
color="primary"
|
|
||||||
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>
|
|
||||||
</ToggleButtonGroup>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{data.nodes.length > 0 ? (
|
|
||||||
<Box mt={1} justifyContent="center" flexDirection="column">
|
|
||||||
<IconContext.Provider
|
|
||||||
value={{
|
|
||||||
color: 'lightblue',
|
|
||||||
size: '18',
|
|
||||||
style: { verticalAlign: 'middle' }
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
) : (
|
|
||||||
<Box
|
|
||||||
display="flex"
|
|
||||||
// justifyContent="flex-end"
|
|
||||||
// flexWrap="nowrap"
|
|
||||||
// whiteSpace="nowrap"
|
|
||||||
>
|
|
||||||
<Typography mt={1} color="warning.main" variant="body1">
|
|
||||||
no data
|
|
||||||
</Typography>
|
|
||||||
<Tooltip title={LL.DASHBOARD_1()}>
|
|
||||||
<HelpOutlineIcon
|
|
||||||
sx={{
|
|
||||||
ml: 1,
|
|
||||||
mt: 1,
|
|
||||||
fontSize: 20,
|
|
||||||
verticalAlign: 'middle'
|
|
||||||
}}
|
|
||||||
color="primary"
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SectionContent>
|
|
||||||
{renderContent()}
|
|
||||||
{selectedDashboardItem && selectedDashboardItem.dv && (
|
|
||||||
<DevicesDialog
|
|
||||||
open={deviceValueDialogOpen}
|
|
||||||
onClose={() => setDeviceValueDialogOpen(false)}
|
|
||||||
onSave={deviceValueDialogSave}
|
|
||||||
selectedItem={selectedDashboardItem.dv}
|
|
||||||
writeable={true}
|
|
||||||
validator={deviceValueItemValidation(selectedDashboardItem.dv)}
|
|
||||||
progress={submitting}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</SectionContent>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default Dashboard;
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
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 {
|
|
||||||
MdMoreTime,
|
|
||||||
MdOutlineDevices,
|
|
||||||
MdOutlinePool,
|
|
||||||
MdOutlineSensors,
|
|
||||||
MdPlaylistAdd,
|
|
||||||
MdThermostatAuto
|
|
||||||
} from 'react-icons/md';
|
|
||||||
import { PiFan, PiGauge } from 'react-icons/pi';
|
|
||||||
import { TiFlowSwitch, TiThermometer } from 'react-icons/ti';
|
|
||||||
import { VscVmConnect } from 'react-icons/vsc';
|
|
||||||
|
|
||||||
import type { SvgIconProps } from '@mui/material';
|
|
||||||
|
|
||||||
import { DeviceType } from './types';
|
|
||||||
|
|
||||||
const deviceIconLookup: Record<
|
|
||||||
DeviceType,
|
|
||||||
React.ComponentType<SvgIconProps> | null
|
|
||||||
> = {
|
|
||||||
[DeviceType.TEMPERATURESENSOR]: TiThermometer,
|
|
||||||
[DeviceType.ANALOGSENSOR]: PiGauge,
|
|
||||||
[DeviceType.BOILER]: CgSmartHomeBoiler,
|
|
||||||
[DeviceType.HEATSOURCE]: CgSmartHomeBoiler,
|
|
||||||
[DeviceType.THERMOSTAT]: MdThermostatAuto,
|
|
||||||
[DeviceType.MIXER]: AiOutlineControl,
|
|
||||||
[DeviceType.SOLAR]: FaSolarPanel,
|
|
||||||
[DeviceType.HEATPUMP]: GiHeatHaze,
|
|
||||||
[DeviceType.GATEWAY]: AiOutlineGateway,
|
|
||||||
[DeviceType.SWITCH]: TiFlowSwitch,
|
|
||||||
[DeviceType.CONTROLLER]: VscVmConnect,
|
|
||||||
[DeviceType.CONNECT]: VscVmConnect,
|
|
||||||
[DeviceType.ALERT]: AiOutlineAlert,
|
|
||||||
[DeviceType.EXTENSION]: MdOutlineDevices,
|
|
||||||
[DeviceType.WATER]: GiTap,
|
|
||||||
[DeviceType.POOL]: MdOutlinePool,
|
|
||||||
[DeviceType.CUSTOM]: MdPlaylistAdd,
|
|
||||||
[DeviceType.UNKNOWN]: MdOutlineSensors,
|
|
||||||
[DeviceType.SYSTEM]: null,
|
|
||||||
[DeviceType.SCHEDULER]: MdMoreTime,
|
|
||||||
[DeviceType.GENERIC]: MdOutlineSensors,
|
|
||||||
[DeviceType.VENTILATION]: PiFan
|
|
||||||
};
|
|
||||||
|
|
||||||
interface DeviceIconProps {
|
|
||||||
type_id: DeviceType;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DeviceIcon = memo(({ type_id }: DeviceIconProps) => {
|
|
||||||
const Icon = deviceIconLookup[type_id];
|
|
||||||
return Icon ? <Icon /> : null;
|
|
||||||
});
|
|
||||||
|
|
||||||
export default DeviceIcon;
|
|
||||||
@@ -1,833 +0,0 @@
|
|||||||
import {
|
|
||||||
memo,
|
|
||||||
useCallback,
|
|
||||||
useContext,
|
|
||||||
useEffect,
|
|
||||||
useLayoutEffect,
|
|
||||||
useMemo,
|
|
||||||
useState
|
|
||||||
} from 'react';
|
|
||||||
import { IconContext } from 'react-icons';
|
|
||||||
import { useNavigate } from 'react-router';
|
|
||||||
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 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';
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
CircularProgress,
|
|
||||||
Dialog,
|
|
||||||
DialogActions,
|
|
||||||
DialogContent,
|
|
||||||
DialogTitle,
|
|
||||||
Grid,
|
|
||||||
IconButton,
|
|
||||||
InputAdornment,
|
|
||||||
List,
|
|
||||||
ListItem,
|
|
||||||
ListItemText,
|
|
||||||
TextField,
|
|
||||||
ToggleButton,
|
|
||||||
Typography
|
|
||||||
} from '@mui/material';
|
|
||||||
|
|
||||||
import { useRowSelect } from '@table-library/react-table-library/select';
|
|
||||||
import { SortToggleType, useSort } from '@table-library/react-table-library/sort';
|
|
||||||
import {
|
|
||||||
Body,
|
|
||||||
Cell,
|
|
||||||
Header,
|
|
||||||
HeaderCell,
|
|
||||||
HeaderRow,
|
|
||||||
Row,
|
|
||||||
Table
|
|
||||||
} from '@table-library/react-table-library/table';
|
|
||||||
import { useTheme } from '@table-library/react-table-library/theme';
|
|
||||||
import 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 { AuthenticatedContext } from 'contexts/authentication';
|
|
||||||
import { useI18nContext } from 'i18n/i18n-react';
|
|
||||||
import { useInterval } from 'utils';
|
|
||||||
|
|
||||||
import { readCoreData, readDeviceData, writeDeviceValue } from '../../api/app';
|
|
||||||
import DeviceIcon from './DeviceIcon';
|
|
||||||
import DevicesDialog from './DevicesDialog';
|
|
||||||
import { formatValue } from './deviceValue';
|
|
||||||
import { DeviceEntityMask, DeviceType, DeviceValueUOM_s } from './types';
|
|
||||||
import type { Device, DeviceValue } from './types';
|
|
||||||
import { deviceValueItemValidation } from './validators';
|
|
||||||
|
|
||||||
const Devices = memo(() => {
|
|
||||||
const { LL } = useI18nContext();
|
|
||||||
const { me } = useContext(AuthenticatedContext);
|
|
||||||
|
|
||||||
const [size, setSize] = useState([0, 0]);
|
|
||||||
const [selectedDeviceValue, setSelectedDeviceValue] = useState<DeviceValue>();
|
|
||||||
const [onlyFav, setOnlyFav] = useState(false);
|
|
||||||
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, {
|
|
||||||
initialData: {
|
|
||||||
connected: true,
|
|
||||||
devices: []
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: deviceData, send: sendDeviceData } = useRequest(
|
|
||||||
(id: number) => readDeviceData(id),
|
|
||||||
{
|
|
||||||
initialData: {
|
|
||||||
nodes: []
|
|
||||||
},
|
|
||||||
immediate: false
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const { loading: submitting, send: sendDeviceValue } = useRequest(
|
|
||||||
(data: { id: number; c: string; v: unknown }) => writeDeviceValue(data),
|
|
||||||
{
|
|
||||||
immediate: false
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
let raf = 0;
|
|
||||||
const updateSize = () => {
|
|
||||||
cancelAnimationFrame(raf);
|
|
||||||
raf = requestAnimationFrame(() => {
|
|
||||||
setSize([window.innerWidth, window.innerHeight]);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
window.addEventListener('resize', updateSize);
|
|
||||||
updateSize();
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('resize', updateSize);
|
|
||||||
cancelAnimationFrame(raf);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const leftOffset = useCallback(() => {
|
|
||||||
const devicesWindow = document.getElementById('devices-window');
|
|
||||||
if (!devicesWindow) return 0;
|
|
||||||
const { left, right } = devicesWindow.getBoundingClientRect();
|
|
||||||
if (!left || !right) return 0;
|
|
||||||
return left + (right - left < 400 ? 0 : 200);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const common_theme = useMemo(
|
|
||||||
() =>
|
|
||||||
useTheme({
|
|
||||||
BaseRow: `
|
|
||||||
font-size: 14px;
|
|
||||||
`,
|
|
||||||
HeaderRow: `
|
|
||||||
text-transform: uppercase;
|
|
||||||
background-color: black;
|
|
||||||
color: #90CAF9;
|
|
||||||
.th {
|
|
||||||
border-bottom: 1px solid #565656;
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
Row: `
|
|
||||||
cursor: pointer;
|
|
||||||
background-color: #1E1E1E;
|
|
||||||
.td {
|
|
||||||
padding: 8px;
|
|
||||||
}
|
|
||||||
&.tr.tr-body.row-select.row-select-single-selected {
|
|
||||||
background-color: #177ac9;
|
|
||||||
}
|
|
||||||
`
|
|
||||||
}),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const device_theme = useMemo(
|
|
||||||
() =>
|
|
||||||
useTheme([
|
|
||||||
common_theme,
|
|
||||||
{
|
|
||||||
BaseRow: `
|
|
||||||
font-size: 15px;
|
|
||||||
.td {
|
|
||||||
height: 28px;
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
Table: `
|
|
||||||
--data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) 130px;
|
|
||||||
`,
|
|
||||||
HeaderRow: `
|
|
||||||
.th {
|
|
||||||
padding: 8px;
|
|
||||||
`,
|
|
||||||
Row: `
|
|
||||||
&:nth-of-type(odd) .td {
|
|
||||||
background-color: #303030;
|
|
||||||
},
|
|
||||||
&:hover .td {
|
|
||||||
background-color: #177ac9;
|
|
||||||
},
|
|
||||||
`
|
|
||||||
}
|
|
||||||
]),
|
|
||||||
[common_theme]
|
|
||||||
);
|
|
||||||
|
|
||||||
const data_theme = useMemo(
|
|
||||||
() =>
|
|
||||||
useTheme([
|
|
||||||
common_theme,
|
|
||||||
{
|
|
||||||
Table: `
|
|
||||||
--data-table-library_grid-template-columns: minmax(200px, auto) minmax(150px, auto) 40px;
|
|
||||||
height: auto;
|
|
||||||
max-height: 100%;
|
|
||||||
overflow-y: scroll;
|
|
||||||
::-webkit-scrollbar {
|
|
||||||
display:none;
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
BaseRow: `
|
|
||||||
.td {
|
|
||||||
height: 32px;
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
BaseCell: `
|
|
||||||
&:nth-of-type(1) {
|
|
||||||
border-left: 1px solid #177ac9;
|
|
||||||
},
|
|
||||||
&:nth-of-type(2) {
|
|
||||||
text-align: right;
|
|
||||||
},
|
|
||||||
&:nth-of-type(3) {
|
|
||||||
border-right: 1px solid #177ac9;
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
HeaderRow: `
|
|
||||||
.th {
|
|
||||||
border-top: 1px solid #565656;
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
Row: `
|
|
||||||
&:nth-of-type(odd) .td {
|
|
||||||
background-color: #303030;
|
|
||||||
},
|
|
||||||
&:hover .td {
|
|
||||||
background-color: #177ac9;
|
|
||||||
}
|
|
||||||
`
|
|
||||||
}
|
|
||||||
]),
|
|
||||||
[common_theme]
|
|
||||||
);
|
|
||||||
|
|
||||||
const getSortIcon = (state: State, sortKey: unknown) => {
|
|
||||||
if (state.sortKey === sortKey && state.reverse) {
|
|
||||||
return <KeyboardArrowDownOutlinedIcon />;
|
|
||||||
}
|
|
||||||
if (state.sortKey === sortKey && !state.reverse) {
|
|
||||||
return <KeyboardArrowUpOutlinedIcon />;
|
|
||||||
}
|
|
||||||
return <UnfoldMoreOutlinedIcon />;
|
|
||||||
};
|
|
||||||
|
|
||||||
const dv_sort = useSort(
|
|
||||||
{ nodes: [...deviceData.nodes] },
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
sortIcon: {
|
|
||||||
iconDefault: <UnfoldMoreOutlinedIcon />,
|
|
||||||
iconUp: <KeyboardArrowUpOutlinedIcon />,
|
|
||||||
iconDown: <KeyboardArrowDownOutlinedIcon />
|
|
||||||
},
|
|
||||||
sortToggleType: SortToggleType.AlternateWithReset,
|
|
||||||
sortFns: {
|
|
||||||
NAME: (array) =>
|
|
||||||
array.sort((a, b) =>
|
|
||||||
a.id.toString().slice(2).localeCompare(b.id.toString().slice(2))
|
|
||||||
),
|
|
||||||
|
|
||||||
VALUE: (array) =>
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
|
||||||
array.sort((a, b) => a.v.toString().localeCompare(b.v.toString()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
async function onSelectChange(action: Action, state: State) {
|
|
||||||
setSelectedDevice(state.id as number);
|
|
||||||
if (action.type === 'ADD_BY_ID_EXCLUSIVELY') {
|
|
||||||
await sendDeviceData(state.id as number);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const device_select = useRowSelect(
|
|
||||||
{ nodes: [...coreData.devices] },
|
|
||||||
{
|
|
||||||
onChange: onSelectChange
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const resetDeviceSelect = () => {
|
|
||||||
device_select.fns.onRemoveAll();
|
|
||||||
setSearch('');
|
|
||||||
};
|
|
||||||
|
|
||||||
const escFunction = useCallback(
|
|
||||||
(event: KeyboardEvent) => {
|
|
||||||
if (event.key === 'Escape') {
|
|
||||||
if (device_select) {
|
|
||||||
device_select.fns.onRemoveAll();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[device_select]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
document.addEventListener('keydown', escFunction);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('keydown', escFunction);
|
|
||||||
};
|
|
||||||
}, [escFunction]);
|
|
||||||
|
|
||||||
const customize = () => {
|
|
||||||
if (selectedDevice === 99) {
|
|
||||||
void navigate('/customentities');
|
|
||||||
} else {
|
|
||||||
void navigate('/customizations', { state: selectedDevice });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const escapeCsvCell = (cell: string) => {
|
|
||||||
if (cell == null) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
const sc = cell.toString().trim();
|
|
||||||
if (sc === '' || sc === '""') {
|
|
||||||
return sc;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
sc.includes('"') ||
|
|
||||||
sc.includes(';') ||
|
|
||||||
sc.includes('\n') ||
|
|
||||||
sc.includes('\r')
|
|
||||||
) {
|
|
||||||
return '"' + sc.replace(/"/g, '""') + '"';
|
|
||||||
}
|
|
||||||
return sc;
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasMask = useCallback(
|
|
||||||
(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
|
|
||||||
);
|
|
||||||
if (deviceIndex === -1) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const selectedDevice = coreData.devices[deviceIndex];
|
|
||||||
if (!selectedDevice) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const filename = selectedDevice.tn + '_' + selectedDevice.n;
|
|
||||||
|
|
||||||
const columns = [
|
|
||||||
{
|
|
||||||
accessor: (dv: DeviceValue) => dv.id.slice(2),
|
|
||||||
name: LL.ENTITY_NAME(0)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessor: (dv: DeviceValue) =>
|
|
||||||
typeof dv.v === 'number' ? new Intl.NumberFormat().format(dv.v) : dv.v,
|
|
||||||
name: LL.VALUE(0)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessor: (dv: DeviceValue) =>
|
|
||||||
dv.u !== undefined && DeviceValueUOM_s[dv.u]
|
|
||||||
? DeviceValueUOM_s[dv.u]?.replace(/[^a-zA-Z0-9]/g, '')
|
|
||||||
: '',
|
|
||||||
name: 'UoM'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessor: (dv: DeviceValue) =>
|
|
||||||
dv.c && !hasMask(dv.id, DeviceEntityMask.DV_READONLY) ? 'yes' : 'no',
|
|
||||||
name: LL.WRITEABLE()
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessor: (dv: DeviceValue) =>
|
|
||||||
dv.h
|
|
||||||
? dv.h
|
|
||||||
: dv.l
|
|
||||||
? dv.l.join(' | ')
|
|
||||||
: dv.m !== undefined && dv.x !== undefined
|
|
||||||
? dv.m + ', ' + dv.x
|
|
||||||
: '',
|
|
||||||
name: 'Range'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
const data = onlyFav
|
|
||||||
? deviceData.nodes.filter((dv: DeviceValue) =>
|
|
||||||
hasMask(dv.id, DeviceEntityMask.DV_FAVORITE)
|
|
||||||
)
|
|
||||||
: deviceData.nodes;
|
|
||||||
|
|
||||||
const csvData = data.reduce(
|
|
||||||
(csvString: string, rowItem: DeviceValue) =>
|
|
||||||
csvString +
|
|
||||||
columns
|
|
||||||
.map(({ accessor }: { accessor: (dv: DeviceValue) => unknown }) =>
|
|
||||||
escapeCsvCell(accessor(rowItem) as string)
|
|
||||||
)
|
|
||||||
.join(';') +
|
|
||||||
'\r\n',
|
|
||||||
columns.map(({ name }: { name: string }) => escapeCsvCell(name)).join(';') +
|
|
||||||
'\r\n'
|
|
||||||
);
|
|
||||||
|
|
||||||
const downloadBlob = (blob: Blob) => {
|
|
||||||
const downloadLink = document.createElement('a');
|
|
||||||
downloadLink.download = filename;
|
|
||||||
downloadLink.href = window.URL.createObjectURL(blob);
|
|
||||||
document.body.appendChild(downloadLink);
|
|
||||||
downloadLink.click();
|
|
||||||
document.body.removeChild(downloadLink);
|
|
||||||
};
|
|
||||||
|
|
||||||
const device = { ...{ device: coreData.devices[deviceIndex] }, ...deviceData };
|
|
||||||
downloadBlob(
|
|
||||||
new Blob([JSON.stringify(device, null, 2)], {
|
|
||||||
type: 'text;charset:utf-8'
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
downloadBlob(new Blob([csvData], { type: 'text/csv;charset:utf-8' }));
|
|
||||||
};
|
|
||||||
|
|
||||||
useInterval(() => {
|
|
||||||
if (!deviceValueDialogOpen) {
|
|
||||||
selectedDevice ? void sendDeviceData(selectedDevice) : void sendCoreData();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const deviceValueDialogSave = async (devicevalue: DeviceValue) => {
|
|
||||||
const id = Number(device_select.state.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(async () => {
|
|
||||||
setDeviceValueDialogOpen(false);
|
|
||||||
await sendDeviceData(id);
|
|
||||||
setSelectedDeviceValue(undefined);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderDeviceDetails = () => {
|
|
||||||
if (showDeviceInfo) {
|
|
||||||
const deviceIndex = coreData.devices.findIndex(
|
|
||||||
(d: Device) => d.id === device_select.state.id
|
|
||||||
);
|
|
||||||
if (deviceIndex === -1) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const deviceDetails = coreData.devices[deviceIndex];
|
|
||||||
if (!deviceDetails) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
sx={dialogStyle}
|
|
||||||
open={showDeviceInfo}
|
|
||||||
onClose={() => setShowDeviceInfo(false)}
|
|
||||||
>
|
|
||||||
<DialogTitle>{LL.DEVICE_DETAILS()}</DialogTitle>
|
|
||||||
<DialogContent dividers>
|
|
||||||
<List dense={true}>
|
|
||||||
<ListItem>
|
|
||||||
<ListItemText primary={LL.TYPE(0)} secondary={deviceDetails.tn} />
|
|
||||||
</ListItem>
|
|
||||||
<ListItem>
|
|
||||||
<ListItemText primary={LL.NAME(0)} secondary={deviceDetails.n} />
|
|
||||||
</ListItem>
|
|
||||||
{deviceDetails.t !== DeviceType.CUSTOM && (
|
|
||||||
<>
|
|
||||||
<ListItem>
|
|
||||||
<ListItemText primary={LL.BRAND()} secondary={deviceDetails.b} />
|
|
||||||
</ListItem>
|
|
||||||
<ListItem>
|
|
||||||
<ListItemText
|
|
||||||
primary={LL.ID_OF(LL.DEVICE())}
|
|
||||||
secondary={
|
|
||||||
'0x' +
|
|
||||||
('00' + deviceDetails.d.toString(16).toUpperCase()).slice(-2)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
<ListItem>
|
|
||||||
<ListItemText
|
|
||||||
primary={LL.ID_OF(LL.PRODUCT())}
|
|
||||||
secondary={deviceDetails.p}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
<ListItem>
|
|
||||||
<ListItemText
|
|
||||||
primary={LL.VERSION()}
|
|
||||||
secondary={deviceDetails.v}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</List>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
onClick={() => setShowDeviceInfo(false)}
|
|
||||||
color="secondary"
|
|
||||||
>
|
|
||||||
{LL.CLOSE()}
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderCoreData = () => (
|
|
||||||
<>
|
|
||||||
{!coreData.connected ? (
|
|
||||||
<MessageBox level="error" message={LL.EMS_BUS_WARNING()} />
|
|
||||||
) : (
|
|
||||||
<Box justifyContent="center" flexDirection="column">
|
|
||||||
<IconContext.Provider
|
|
||||||
value={{
|
|
||||||
color: 'lightblue',
|
|
||||||
size: '18',
|
|
||||||
style: { verticalAlign: 'middle' }
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
const deviceValueDialogClose = () => {
|
|
||||||
setDeviceValueDialogOpen(false);
|
|
||||||
if (selectedDevice !== undefined) {
|
|
||||||
void sendDeviceData(selectedDevice);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderDeviceData = () => {
|
|
||||||
if (!selectedDevice) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const showDeviceValue = useCallback((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 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 deviceIndex = coreData.devices.findIndex(
|
|
||||||
(d: Device) => 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,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
top: 64,
|
|
||||||
zIndex: 'modal',
|
|
||||||
maxHeight: () => (height || 0) - 126,
|
|
||||||
border: '1px solid #177ac9'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box sx={{ p: 1 }}>
|
|
||||||
<Grid container justifyContent="space-between">
|
|
||||||
<Typography noWrap variant="subtitle1" color="warning.main">
|
|
||||||
{deviceInfo.n} (
|
|
||||||
{deviceInfo.tn})
|
|
||||||
</Typography>
|
|
||||||
<Grid justifyContent="flex-end">
|
|
||||||
<ButtonTooltip title={LL.CLOSE()}>
|
|
||||||
<IconButton onClick={resetDeviceSelect} aria-label={LL.CLOSE()}>
|
|
||||||
<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) }}
|
|
||||||
theme={data_theme}
|
|
||||||
sort={dv_sort}
|
|
||||||
layout={{ custom: true, fixedHeader: true }}
|
|
||||||
>
|
|
||||||
{(tableList: DeviceValue[]) => (
|
|
||||||
<>
|
|
||||||
<Header>
|
|
||||||
<HeaderRow>
|
|
||||||
<HeaderCell resize>
|
|
||||||
<Button
|
|
||||||
fullWidth
|
|
||||||
style={{ fontSize: '14px', justifyContent: 'flex-start' }}
|
|
||||||
endIcon={getSortIcon(dv_sort.state, 'NAME')}
|
|
||||||
onClick={() => dv_sort.fns.onToggleSort({ sortKey: 'NAME' })}
|
|
||||||
>
|
|
||||||
{LL.ENTITY_NAME(0)}
|
|
||||||
</Button>
|
|
||||||
</HeaderCell>
|
|
||||||
<HeaderCell resize>
|
|
||||||
<Button
|
|
||||||
fullWidth
|
|
||||||
style={{ fontSize: '14px', justifyContent: 'flex-end' }}
|
|
||||||
endIcon={getSortIcon(dv_sort.state, 'VALUE')}
|
|
||||||
onClick={() => dv_sort.fns.onToggleSort({ sortKey: 'VALUE' })}
|
|
||||||
>
|
|
||||||
{LL.VALUE(0)}
|
|
||||||
</Button>
|
|
||||||
</HeaderCell>
|
|
||||||
<HeaderCell stiff />
|
|
||||||
</HeaderRow>
|
|
||||||
</Header>
|
|
||||||
<Body>
|
|
||||||
{tableList.map((dv: DeviceValue) => (
|
|
||||||
<Row key={dv.id} item={dv} onClick={() => showDeviceValue(dv)}>
|
|
||||||
<Cell>{renderNameCell(dv)}</Cell>
|
|
||||||
<Cell>{formatValue(LL, dv.v, dv.u)}</Cell>
|
|
||||||
<Cell stiff>
|
|
||||||
{me.admin &&
|
|
||||||
dv.c &&
|
|
||||||
!hasMask(dv.id, DeviceEntityMask.DV_READONLY) && (
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
onClick={() => showDeviceValue(dv)}
|
|
||||||
>
|
|
||||||
{dv.v === '' ? (
|
|
||||||
<PlayArrowIcon color="primary" sx={{ fontSize: 16 }} />
|
|
||||||
) : (
|
|
||||||
<EditIcon color="primary" sx={{ fontSize: 16 }} />
|
|
||||||
)}
|
|
||||||
</IconButton>
|
|
||||||
)}
|
|
||||||
</Cell>
|
|
||||||
</Row>
|
|
||||||
))}
|
|
||||||
</Body>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Table>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SectionContent id="devices-window">
|
|
||||||
{renderCoreData()}
|
|
||||||
{renderDeviceData()}
|
|
||||||
{renderDeviceDetails()}
|
|
||||||
{selectedDeviceValue && (
|
|
||||||
<DevicesDialog
|
|
||||||
open={deviceValueDialogOpen}
|
|
||||||
onClose={deviceValueDialogClose}
|
|
||||||
onSave={deviceValueDialogSave}
|
|
||||||
selectedItem={selectedDeviceValue}
|
|
||||||
writeable={
|
|
||||||
selectedDeviceValue.c !== undefined &&
|
|
||||||
!hasMask(selectedDeviceValue.id, DeviceEntityMask.DV_READONLY)
|
|
||||||
}
|
|
||||||
validator={deviceValueItemValidation(selectedDeviceValue)}
|
|
||||||
progress={submitting}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</SectionContent>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default Devices;
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
import { useCallback, useMemo } from 'react';
|
|
||||||
|
|
||||||
import { ToggleButton, ToggleButtonGroup } from '@mui/material';
|
|
||||||
|
|
||||||
import OptionIcon from './OptionIcon';
|
|
||||||
import { DeviceEntityMask } from './types';
|
|
||||||
import type { DeviceEntity } from './types';
|
|
||||||
|
|
||||||
interface EntityMaskToggleProps {
|
|
||||||
onUpdate: (de: DeviceEntity) => void;
|
|
||||||
de: DeviceEntity;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Available mask values
|
|
||||||
const MASK_VALUES = [
|
|
||||||
DeviceEntityMask.DV_WEB_EXCLUDE, // 1
|
|
||||||
DeviceEntityMask.DV_API_MQTT_EXCLUDE, // 2
|
|
||||||
DeviceEntityMask.DV_READONLY, // 4
|
|
||||||
DeviceEntityMask.DV_FAVORITE, // 8
|
|
||||||
DeviceEntityMask.DV_DELETED // 128
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts an array of mask strings to a bitmask number
|
|
||||||
*/
|
|
||||||
const getMaskNumber = (newMask: string[]): number => {
|
|
||||||
return newMask.reduce((mask, entry) => mask | Number(entry), 0);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts a bitmask number to an array of mask strings
|
|
||||||
*/
|
|
||||||
const getMaskString = (mask: number): string[] => {
|
|
||||||
return MASK_VALUES.filter((value) => (mask & value) === value).map((value) =>
|
|
||||||
String(value)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if a specific mask bit is set
|
|
||||||
*/
|
|
||||||
const hasMask = (mask: number, flag: number): boolean => (mask & flag) === flag;
|
|
||||||
|
|
||||||
const EntityMaskToggle = ({ onUpdate, de }: EntityMaskToggleProps) => {
|
|
||||||
const handleChange = useCallback(
|
|
||||||
(_event: unknown, mask: string[]) => {
|
|
||||||
// Convert selected masks to a number
|
|
||||||
const newMask = getMaskNumber(mask);
|
|
||||||
const updatedDe = { ...de };
|
|
||||||
|
|
||||||
// Apply business logic for mask interactions
|
|
||||||
// If entity has no name and is set to readonly, also exclude from web
|
|
||||||
if (updatedDe.n === '' && hasMask(newMask, DeviceEntityMask.DV_READONLY)) {
|
|
||||||
updatedDe.m = newMask | DeviceEntityMask.DV_WEB_EXCLUDE;
|
|
||||||
} else {
|
|
||||||
updatedDe.m = newMask;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If excluded from web, cannot be favorite
|
|
||||||
if (hasMask(updatedDe.m, DeviceEntityMask.DV_WEB_EXCLUDE)) {
|
|
||||||
updatedDe.m = updatedDe.m & ~DeviceEntityMask.DV_FAVORITE;
|
|
||||||
}
|
|
||||||
|
|
||||||
onUpdate(updatedDe);
|
|
||||||
},
|
|
||||||
[de, onUpdate]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Memoize mask string value
|
|
||||||
const maskStringValue = useMemo(() => getMaskString(de.m), [de.m]);
|
|
||||||
|
|
||||||
// Memoize disabled states
|
|
||||||
const isFavoriteDisabled = useMemo(
|
|
||||||
() =>
|
|
||||||
hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE | DeviceEntityMask.DV_DELETED) ||
|
|
||||||
de.n === undefined,
|
|
||||||
[de.m, de.n]
|
|
||||||
);
|
|
||||||
|
|
||||||
const isReadonlyDisabled = useMemo(
|
|
||||||
() =>
|
|
||||||
!de.w ||
|
|
||||||
hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE | DeviceEntityMask.DV_FAVORITE),
|
|
||||||
[de.w, de.m]
|
|
||||||
);
|
|
||||||
|
|
||||||
const isApiMqttExcludeDisabled = useMemo(
|
|
||||||
() => de.n === '' || hasMask(de.m, DeviceEntityMask.DV_DELETED),
|
|
||||||
[de.n, de.m]
|
|
||||||
);
|
|
||||||
|
|
||||||
const isWebExcludeDisabled = useMemo(
|
|
||||||
() => de.n === undefined || hasMask(de.m, DeviceEntityMask.DV_DELETED),
|
|
||||||
[de.n, de.m]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Memoize mask flag checks
|
|
||||||
const isFavoriteSet = useMemo(
|
|
||||||
() => hasMask(de.m, DeviceEntityMask.DV_FAVORITE),
|
|
||||||
[de.m]
|
|
||||||
);
|
|
||||||
const isReadonlySet = useMemo(
|
|
||||||
() => hasMask(de.m, DeviceEntityMask.DV_READONLY),
|
|
||||||
[de.m]
|
|
||||||
);
|
|
||||||
const isApiMqttExcludeSet = useMemo(
|
|
||||||
() => hasMask(de.m, DeviceEntityMask.DV_API_MQTT_EXCLUDE),
|
|
||||||
[de.m]
|
|
||||||
);
|
|
||||||
const isWebExcludeSet = useMemo(
|
|
||||||
() => hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE),
|
|
||||||
[de.m]
|
|
||||||
);
|
|
||||||
const isDeletedSet = useMemo(
|
|
||||||
() => hasMask(de.m, DeviceEntityMask.DV_DELETED),
|
|
||||||
[de.m]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ToggleButtonGroup
|
|
||||||
size="small"
|
|
||||||
color="secondary"
|
|
||||||
value={maskStringValue}
|
|
||||||
onChange={handleChange}
|
|
||||||
>
|
|
||||||
<ToggleButton value="8" disabled={isFavoriteDisabled}>
|
|
||||||
<OptionIcon type="favorite" isSet={isFavoriteSet} />
|
|
||||||
</ToggleButton>
|
|
||||||
<ToggleButton value="4" disabled={isReadonlyDisabled}>
|
|
||||||
<OptionIcon type="readonly" isSet={isReadonlySet} />
|
|
||||||
</ToggleButton>
|
|
||||||
<ToggleButton value="2" disabled={isApiMqttExcludeDisabled}>
|
|
||||||
<OptionIcon type="api_mqtt_exclude" isSet={isApiMqttExcludeSet} />
|
|
||||||
</ToggleButton>
|
|
||||||
<ToggleButton value="1" disabled={isWebExcludeDisabled}>
|
|
||||||
<OptionIcon type="web_exclude" isSet={isWebExcludeSet} />
|
|
||||||
</ToggleButton>
|
|
||||||
<ToggleButton value="128">
|
|
||||||
<OptionIcon type="deleted" isSet={isDeletedSet} />
|
|
||||||
</ToggleButton>
|
|
||||||
</ToggleButtonGroup>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default EntityMaskToggle;
|
|
||||||
@@ -1,223 +0,0 @@
|
|||||||
import { memo, useCallback, useContext, useMemo, useState } from 'react';
|
|
||||||
import type { ReactElement } from 'react';
|
|
||||||
import { toast } from 'react-toastify';
|
|
||||||
|
|
||||||
import CommentIcon from '@mui/icons-material/CommentTwoTone';
|
|
||||||
import DownloadIcon from '@mui/icons-material/GetApp';
|
|
||||||
import GitHubIcon from '@mui/icons-material/GitHub';
|
|
||||||
import MenuBookIcon from '@mui/icons-material/MenuBookTwoTone';
|
|
||||||
import {
|
|
||||||
Avatar,
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Divider,
|
|
||||||
Link,
|
|
||||||
List,
|
|
||||||
ListItem,
|
|
||||||
ListItemAvatar,
|
|
||||||
ListItemButton,
|
|
||||||
ListItemText,
|
|
||||||
Stack,
|
|
||||||
Typography
|
|
||||||
} from '@mui/material';
|
|
||||||
import type { SxProps, Theme } from '@mui/material/styles';
|
|
||||||
|
|
||||||
import { useRequest } from 'alova/client';
|
|
||||||
import { SectionContent, useLayoutTitle } from 'components';
|
|
||||||
import { AuthenticatedContext } from 'contexts/authentication';
|
|
||||||
import { useI18nContext } from 'i18n/i18n-react';
|
|
||||||
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 { 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);
|
|
||||||
|
|
||||||
// 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
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const { send: sendAPI } = useRequest((data: APIcall) => API(data), {
|
|
||||||
immediate: false
|
|
||||||
})
|
|
||||||
.onSuccess((event) => {
|
|
||||||
saveFile(event.data, 'system_info', '.json');
|
|
||||||
toast.info(LL.DOWNLOAD_SUCCESSFUL());
|
|
||||||
})
|
|
||||||
.onError((error) => {
|
|
||||||
toast.error(String(error.error?.message || 'An error occurred'));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Optimize API call memoization
|
|
||||||
const apiCall = useMemo(() => ({ device: 'system', cmd: 'info', id: 0 }), []);
|
|
||||||
|
|
||||||
const handleDownloadSystemInfo = useCallback(() => {
|
|
||||||
void sendAPI(apiCall);
|
|
||||||
}, [sendAPI, apiCall]);
|
|
||||||
|
|
||||||
const handleImageError = useCallback(() => {
|
|
||||||
setImgError(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Memoize help links to prevent recreation on every render
|
|
||||||
const helpLinks: HelpLink[] = useMemo(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
href: 'https://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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{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>
|
|
||||||
))}
|
|
||||||
</List>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Box p={2} color="warning.main">
|
|
||||||
<Typography mb={1} variant="body1">
|
|
||||||
{LL.HELP_INFORMATION_4()}.
|
|
||||||
</Typography>
|
|
||||||
<Button
|
|
||||||
startIcon={<DownloadIcon />}
|
|
||||||
variant="outlined"
|
|
||||||
color="primary"
|
|
||||||
onClick={handleDownloadSystemInfo}
|
|
||||||
>
|
|
||||||
{LL.SUPPORT_INFORMATION(0)}
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
</Typography>
|
|
||||||
</SectionContent>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Memoize the component to prevent unnecessary re-renders
|
|
||||||
const Help = memo(HelpComponent);
|
|
||||||
|
|
||||||
export default Help;
|
|
||||||
@@ -1,293 +0,0 @@
|
|||||||
import { memo, useCallback, useMemo, useState } from 'react';
|
|
||||||
import { useBlocker } from 'react-router';
|
|
||||||
import { toast } from 'react-toastify';
|
|
||||||
|
|
||||||
import CancelIcon from '@mui/icons-material/Cancel';
|
|
||||||
import CircleIcon from '@mui/icons-material/Circle';
|
|
||||||
import WarningIcon from '@mui/icons-material/Warning';
|
|
||||||
import { Box, Button, Typography } from '@mui/material';
|
|
||||||
|
|
||||||
import {
|
|
||||||
Body,
|
|
||||||
Cell,
|
|
||||||
Header,
|
|
||||||
HeaderCell,
|
|
||||||
HeaderRow,
|
|
||||||
Row,
|
|
||||||
Table
|
|
||||||
} from '@table-library/react-table-library/table';
|
|
||||||
import { useTheme } from '@table-library/react-table-library/theme';
|
|
||||||
import { updateState, useRequest } from 'alova/client';
|
|
||||||
import {
|
|
||||||
BlockNavigation,
|
|
||||||
ButtonRow,
|
|
||||||
FormLoader,
|
|
||||||
SectionContent,
|
|
||||||
useLayoutTitle
|
|
||||||
} from 'components';
|
|
||||||
import { useI18nContext } from 'i18n/i18n-react';
|
|
||||||
|
|
||||||
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);
|
|
||||||
const blocker = useBlocker(numChanges !== 0);
|
|
||||||
|
|
||||||
const [selectedModuleItem, setSelectedModuleItem] = useState<ModuleItem>();
|
|
||||||
const [dialogOpen, setDialogOpen] = useState<boolean>(false);
|
|
||||||
|
|
||||||
useLayoutTitle(LL.MODULES());
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: modules,
|
|
||||||
send: fetchModules,
|
|
||||||
error
|
|
||||||
} = useRequest(readModules, {
|
|
||||||
initialData: []
|
|
||||||
});
|
|
||||||
|
|
||||||
const { send: updateModules } = useRequest(
|
|
||||||
(data: { key: string; enabled: boolean; license: string }) => writeModules(data),
|
|
||||||
{
|
|
||||||
immediate: false
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const modules_theme = useTheme(
|
|
||||||
useMemo(
|
|
||||||
() => ({
|
|
||||||
Table: `
|
|
||||||
--data-table-library_grid-template-columns: 48px 180px 120px 100px repeat(1, minmax(160px, 1fr)) 180px;
|
|
||||||
`,
|
|
||||||
BaseRow: `
|
|
||||||
font-size: 14px;
|
|
||||||
.td {
|
|
||||||
height: 32px;
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
BaseCell: `
|
|
||||||
&:nth-of-type(1) {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
HeaderRow: `
|
|
||||||
text-transform: uppercase;
|
|
||||||
background-color: black;
|
|
||||||
color: #90CAF9;
|
|
||||||
.th {
|
|
||||||
border-bottom: 1px solid #565656;
|
|
||||||
height: 36px;
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
Row: `
|
|
||||||
background-color: #1e1e1e;
|
|
||||||
position: relative;
|
|
||||||
cursor: pointer;
|
|
||||||
.td {
|
|
||||||
border-top: 1px solid #565656;
|
|
||||||
border-bottom: 1px solid #565656;
|
|
||||||
}
|
|
||||||
&:hover .td {
|
|
||||||
border-top: 1px solid #177ac9;
|
|
||||||
border-bottom: 1px solid #177ac9;
|
|
||||||
}
|
|
||||||
&:nth-of-type(odd) .td {
|
|
||||||
background-color: #303030;
|
|
||||||
}
|
|
||||||
`
|
|
||||||
}),
|
|
||||||
[]
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const onDialogClose = useCallback(() => {
|
|
||||||
setDialogOpen(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const updateModuleItem = useCallback((updatedItem: ModuleItem) => {
|
|
||||||
void updateState(readModules(), (data: ModuleItem[]) => {
|
|
||||||
const new_data = data.map((mi) =>
|
|
||||||
mi.id === updatedItem.id ? { ...mi, ...updatedItem } : mi
|
|
||||||
);
|
|
||||||
setNumChanges(new_data.filter(hasModulesChanged).length);
|
|
||||||
return new_data;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const onDialogSave = useCallback(
|
|
||||||
(updatedItem: ModuleItem) => {
|
|
||||||
setDialogOpen(false);
|
|
||||||
updateModuleItem(updatedItem);
|
|
||||||
},
|
|
||||||
[updateModuleItem]
|
|
||||||
);
|
|
||||||
|
|
||||||
const editModuleItem = useCallback((mi: ModuleItem) => {
|
|
||||||
setSelectedModuleItem(mi);
|
|
||||||
setDialogOpen(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const onCancel = useCallback(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
|
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
|
||||||
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]);
|
|
||||||
|
|
||||||
const content = useMemo(() => {
|
|
||||||
if (!modules) {
|
|
||||||
return (
|
|
||||||
<FormLoader onRetry={fetchModules} errorMessage={error?.message || ''} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (modules.length === 0) {
|
|
||||||
return (
|
|
||||||
<Typography variant="body2" color="error">
|
|
||||||
{LL.MODULES_NONE()}
|
|
||||||
</Typography>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Box mb={2} color="warning.main">
|
|
||||||
<Typography variant="body1">{LL.MODULES_DESCRIPTION()}.</Typography>
|
|
||||||
</Box>
|
|
||||||
<Table
|
|
||||||
data={{ nodes: modules }}
|
|
||||||
theme={modules_theme}
|
|
||||||
layout={{ custom: true }}
|
|
||||||
>
|
|
||||||
{(tableList: ModuleItem[]) => (
|
|
||||||
<>
|
|
||||||
<Header>
|
|
||||||
<HeaderRow>
|
|
||||||
<HeaderCell />
|
|
||||||
<HeaderCell>{LL.NAME(0)}</HeaderCell>
|
|
||||||
<HeaderCell>Author</HeaderCell>
|
|
||||||
<HeaderCell>{LL.VERSION()}</HeaderCell>
|
|
||||||
<HeaderCell>Message</HeaderCell>
|
|
||||||
<HeaderCell>{LL.STATUS_OF('')}</HeaderCell>
|
|
||||||
</HeaderRow>
|
|
||||||
</Header>
|
|
||||||
<Body>
|
|
||||||
{tableList.map((mi: ModuleItem) => (
|
|
||||||
<Row key={mi.id} item={mi} onClick={() => editModuleItem(mi)}>
|
|
||||||
<Cell stiff>
|
|
||||||
{mi.enabled ? (
|
|
||||||
<CircleIcon
|
|
||||||
color="success"
|
|
||||||
sx={{ fontSize: 16, verticalAlign: 'middle' }}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<CircleIcon
|
|
||||||
color="error"
|
|
||||||
sx={{ fontSize: 16, verticalAlign: 'middle' }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Cell>
|
|
||||||
<Cell>{mi.name}</Cell>
|
|
||||||
<Cell>{mi.author}</Cell>
|
|
||||||
<Cell>{mi.version}</Cell>
|
|
||||||
<Cell>{mi.message}</Cell>
|
|
||||||
<Cell>
|
|
||||||
<ColorStatus status={mi.status} />
|
|
||||||
</Cell>
|
|
||||||
</Row>
|
|
||||||
))}
|
|
||||||
</Body>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Table>
|
|
||||||
|
|
||||||
<Box mt={1} display="flex" flexWrap="wrap">
|
|
||||||
<Box flexGrow={1}>
|
|
||||||
{numChanges !== 0 && (
|
|
||||||
<ButtonRow>
|
|
||||||
<Button
|
|
||||||
startIcon={<CancelIcon />}
|
|
||||||
variant="outlined"
|
|
||||||
onClick={onCancel}
|
|
||||||
color="secondary"
|
|
||||||
>
|
|
||||||
{LL.CANCEL()}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
startIcon={<WarningIcon color="warning" />}
|
|
||||||
variant="contained"
|
|
||||||
color="info"
|
|
||||||
onClick={saveModules}
|
|
||||||
>
|
|
||||||
{LL.APPLY_CHANGES(numChanges)}
|
|
||||||
</Button>
|
|
||||||
</ButtonRow>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}, [
|
|
||||||
modules,
|
|
||||||
fetchModules,
|
|
||||||
error,
|
|
||||||
modules_theme,
|
|
||||||
editModuleItem,
|
|
||||||
LL,
|
|
||||||
numChanges,
|
|
||||||
onCancel,
|
|
||||||
saveModules
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SectionContent>
|
|
||||||
{blocker ? <BlockNavigation blocker={blocker} /> : null}
|
|
||||||
{content}
|
|
||||||
{selectedModuleItem && (
|
|
||||||
<ModulesDialog
|
|
||||||
open={dialogOpen}
|
|
||||||
onClose={onDialogClose}
|
|
||||||
onSave={onDialogSave}
|
|
||||||
selectedItem={selectedModuleItem}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</SectionContent>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Modules;
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
||||||
|
|
||||||
import CancelIcon from '@mui/icons-material/Cancel';
|
|
||||||
import DoneIcon from '@mui/icons-material/Done';
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Checkbox,
|
|
||||||
Dialog,
|
|
||||||
DialogActions,
|
|
||||||
DialogContent,
|
|
||||||
DialogTitle,
|
|
||||||
Grid,
|
|
||||||
TextField
|
|
||||||
} from '@mui/material';
|
|
||||||
|
|
||||||
import { dialogStyle } from 'CustomTheme';
|
|
||||||
import { BlockFormControlLabel } from 'components';
|
|
||||||
import { useI18nContext } from 'i18n/i18n-react';
|
|
||||||
import { updateValue } from 'utils';
|
|
||||||
|
|
||||||
import type { ModuleItem } from './types';
|
|
||||||
|
|
||||||
interface ModulesDialogProps {
|
|
||||||
open: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onSave: (mi: ModuleItem) => void;
|
|
||||||
selectedItem: ModuleItem;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ModulesDialog = ({
|
|
||||||
open,
|
|
||||||
onClose,
|
|
||||||
onSave,
|
|
||||||
selectedItem
|
|
||||||
}: ModulesDialogProps) => {
|
|
||||||
const { LL } = useI18nContext();
|
|
||||||
const [editItem, setEditItem] = useState<ModuleItem>(selectedItem);
|
|
||||||
|
|
||||||
const updateFormValue = useMemo(
|
|
||||||
() =>
|
|
||||||
updateValue(
|
|
||||||
setEditItem as unknown as React.Dispatch<
|
|
||||||
React.SetStateAction<Record<string, unknown>>
|
|
||||||
>
|
|
||||||
),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Sync form state when dialog opens or selected item changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
setEditItem(selectedItem);
|
|
||||||
}
|
|
||||||
}, [open, selectedItem]);
|
|
||||||
|
|
||||||
const handleSave = useCallback(() => {
|
|
||||||
onSave(editItem);
|
|
||||||
}, [editItem, onSave]);
|
|
||||||
|
|
||||||
const dialogTitle = useMemo(
|
|
||||||
() => `${LL.EDIT()} ${editItem.key}`,
|
|
||||||
[LL, editItem.key]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog sx={dialogStyle} fullWidth maxWidth="xs" open={open} onClose={onClose}>
|
|
||||||
<DialogTitle>{dialogTitle}</DialogTitle>
|
|
||||||
<DialogContent dividers>
|
|
||||||
<Grid container>
|
|
||||||
<BlockFormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={editItem.enabled}
|
|
||||||
onChange={updateFormValue}
|
|
||||||
name="enabled"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="Enabled"
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Box mt={2} mb={1}>
|
|
||||||
<TextField
|
|
||||||
name="license"
|
|
||||||
label="License Key"
|
|
||||||
multiline
|
|
||||||
rows={6}
|
|
||||||
fullWidth
|
|
||||||
value={editItem.license}
|
|
||||||
onChange={updateFormValue}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button
|
|
||||||
startIcon={<CancelIcon />}
|
|
||||||
variant="outlined"
|
|
||||||
onClick={onClose}
|
|
||||||
color="secondary"
|
|
||||||
>
|
|
||||||
{LL.CANCEL()}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
startIcon={<DoneIcon />}
|
|
||||||
variant="outlined"
|
|
||||||
onClick={handleSave}
|
|
||||||
color="primary"
|
|
||||||
>
|
|
||||||
{LL.UPDATE()}
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ModulesDialog;
|
|
||||||
@@ -1,417 +0,0 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
||||||
import { useBlocker } from 'react-router';
|
|
||||||
import { toast } from 'react-toastify';
|
|
||||||
|
|
||||||
import AddIcon from '@mui/icons-material/Add';
|
|
||||||
import CancelIcon from '@mui/icons-material/Cancel';
|
|
||||||
import CircleIcon from '@mui/icons-material/Circle';
|
|
||||||
import WarningIcon from '@mui/icons-material/Warning';
|
|
||||||
import { Box, Button, Divider, Stack, Typography } from '@mui/material';
|
|
||||||
|
|
||||||
import {
|
|
||||||
Body,
|
|
||||||
Cell,
|
|
||||||
Header,
|
|
||||||
HeaderCell,
|
|
||||||
HeaderRow,
|
|
||||||
Row,
|
|
||||||
Table
|
|
||||||
} from '@table-library/react-table-library/table';
|
|
||||||
import { useTheme } from '@table-library/react-table-library/theme';
|
|
||||||
import { updateState, useRequest } from 'alova/client';
|
|
||||||
import {
|
|
||||||
BlockNavigation,
|
|
||||||
ButtonRow,
|
|
||||||
FormLoader,
|
|
||||||
SectionContent,
|
|
||||||
useLayoutTitle
|
|
||||||
} from 'components';
|
|
||||||
import { useI18nContext } from 'i18n/i18n-react';
|
|
||||||
import { useInterval } from 'utils';
|
|
||||||
|
|
||||||
import { readSchedule, writeSchedule } from '../../api/app';
|
|
||||||
import SettingsSchedulerDialog from './SchedulerDialog';
|
|
||||||
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);
|
|
||||||
const blocker = useBlocker(numChanges !== 0);
|
|
||||||
const [selectedScheduleItem, setSelectedScheduleItem] = useState<ScheduleItem>();
|
|
||||||
const [dow, setDow] = useState<string[]>([]);
|
|
||||||
const [creating, setCreating] = useState<boolean>(false);
|
|
||||||
const [dialogOpen, setDialogOpen] = useState<boolean>(false);
|
|
||||||
|
|
||||||
useLayoutTitle(LL.SCHEDULER());
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: schedule,
|
|
||||||
send: fetchSchedule,
|
|
||||||
error
|
|
||||||
} = useRequest(readSchedule, {
|
|
||||||
initialData: []
|
|
||||||
});
|
|
||||||
|
|
||||||
const { send: updateSchedule } = useRequest(
|
|
||||||
(data: Schedule) => writeSchedule(data),
|
|
||||||
{
|
|
||||||
immediate: false
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const hasScheduleChanged = useCallback((si: ScheduleItem) => {
|
|
||||||
return (
|
|
||||||
si.id !== si.o_id ||
|
|
||||||
(si.name || '') !== (si.o_name || '') ||
|
|
||||||
si.active !== si.o_active ||
|
|
||||||
si.deleted !== si.o_deleted ||
|
|
||||||
si.flags !== si.o_flags ||
|
|
||||||
si.time !== si.o_time ||
|
|
||||||
si.cmd !== si.o_cmd ||
|
|
||||||
si.value !== si.o_value
|
|
||||||
);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const intervalCallback = useCallback(() => {
|
|
||||||
if (numChanges === 0) {
|
|
||||||
void fetchSchedule();
|
|
||||||
}
|
|
||||||
}, [numChanges, fetchSchedule]);
|
|
||||||
|
|
||||||
useInterval(intervalCallback, INTERVAL_DELAY);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const formatter = new Intl.DateTimeFormat(locale, {
|
|
||||||
weekday: 'short',
|
|
||||||
timeZone: 'UTC'
|
|
||||||
});
|
|
||||||
const days = WEEK_DAYS.map((day) => {
|
|
||||||
const dayStr = String(day).padStart(2, '0');
|
|
||||||
return new Date(
|
|
||||||
`${REFERENCE_YEAR}-${REFERENCE_MONTH}-${dayStr}T00:00:00+00:00`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
setDow(days.map((date) => formatter.format(date)));
|
|
||||||
}, [locale]);
|
|
||||||
|
|
||||||
const schedule_theme = useTheme(scheduleTheme);
|
|
||||||
|
|
||||||
const saveSchedule = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
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
|
|
||||||
}))
|
|
||||||
});
|
|
||||||
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);
|
|
||||||
setSelectedScheduleItem(si);
|
|
||||||
setDialogOpen(true);
|
|
||||||
if (si.o_name === undefined) {
|
|
||||||
si.o_name = si.name;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const onDialogClose = useCallback(() => {
|
|
||||||
setDialogOpen(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const onDialogCancel = useCallback(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
|
|
||||||
);
|
|
||||||
|
|
||||||
setNumChanges(new_data.filter((si) => hasScheduleChanged(si)).length);
|
|
||||||
|
|
||||||
return new_data;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[creating, hasScheduleChanged]
|
|
||||||
);
|
|
||||||
|
|
||||||
const addScheduleItem = useCallback(() => {
|
|
||||||
setCreating(true);
|
|
||||||
const newItem: ScheduleItem = {
|
|
||||||
id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
|
|
||||||
...DEFAULT_SCHEDULE_ITEM
|
|
||||||
};
|
|
||||||
setSelectedScheduleItem(newItem);
|
|
||||||
setDialogOpen(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const filteredAndSortedSchedule = useMemo(
|
|
||||||
() =>
|
|
||||||
schedule
|
|
||||||
.filter((si: ScheduleItem) => !si.deleted)
|
|
||||||
.sort((a: ScheduleItem, b: ScheduleItem) => a.flags - b.flags),
|
|
||||||
[schedule]
|
|
||||||
);
|
|
||||||
|
|
||||||
const dayBox = useCallback(
|
|
||||||
(si: ScheduleItem, flag: number) => {
|
|
||||||
const dayIndex = Math.log(flag) / LOG_2;
|
|
||||||
const isActive = (si.flags & flag) === flag;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Box>
|
|
||||||
<Typography sx={{ fontSize: 11 }} color={isActive ? 'primary' : 'grey'}>
|
|
||||||
{dow[dayIndex]}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Divider orientation="vertical" flexItem />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[dow]
|
|
||||||
);
|
|
||||||
|
|
||||||
const scheduleType = useCallback((si: ScheduleItem) => {
|
|
||||||
const label = scheduleTypeLabels[si.flags];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
<Typography sx={{ fontSize: 11 }} color="primary">
|
|
||||||
{label || ''}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const renderSchedule = useCallback(() => {
|
|
||||||
if (!schedule) {
|
|
||||||
return (
|
|
||||||
<FormLoader onRetry={fetchSchedule} errorMessage={error?.message || ''} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Table
|
|
||||||
data={{ nodes: filteredAndSortedSchedule }}
|
|
||||||
theme={schedule_theme}
|
|
||||||
layout={{ custom: true }}
|
|
||||||
>
|
|
||||||
{(tableList: ScheduleItem[]) => (
|
|
||||||
<>
|
|
||||||
<Header>
|
|
||||||
<HeaderRow>
|
|
||||||
<HeaderCell />
|
|
||||||
<HeaderCell stiff>{LL.SCHEDULE(0)}</HeaderCell>
|
|
||||||
<HeaderCell stiff>{LL.TIME(0)}/Cond.</HeaderCell>
|
|
||||||
<HeaderCell stiff>{LL.COMMAND(0)}</HeaderCell>
|
|
||||||
<HeaderCell stiff>{LL.VALUE(0)}</HeaderCell>
|
|
||||||
<HeaderCell stiff>{LL.NAME(0)}</HeaderCell>
|
|
||||||
</HeaderRow>
|
|
||||||
</Header>
|
|
||||||
<Body>
|
|
||||||
{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' }}
|
|
||||||
/>
|
|
||||||
</Cell>
|
|
||||||
<Cell stiff>
|
|
||||||
<Stack spacing={0.5} direction="row">
|
|
||||||
<Divider orientation="vertical" flexItem />
|
|
||||||
{si.flags > SCHEDULE_FLAG_THRESHOLD ? (
|
|
||||||
scheduleType(si)
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{dayBox(si, ScheduleFlag.SCHEDULE_MON)}
|
|
||||||
{dayBox(si, ScheduleFlag.SCHEDULE_TUE)}
|
|
||||||
{dayBox(si, ScheduleFlag.SCHEDULE_WED)}
|
|
||||||
{dayBox(si, ScheduleFlag.SCHEDULE_THU)}
|
|
||||||
{dayBox(si, ScheduleFlag.SCHEDULE_FRI)}
|
|
||||||
{dayBox(si, ScheduleFlag.SCHEDULE_SAT)}
|
|
||||||
{dayBox(si, ScheduleFlag.SCHEDULE_SUN)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Cell>
|
|
||||||
<Cell>{si.time}</Cell>
|
|
||||||
<Cell>{si.cmd}</Cell>
|
|
||||||
<Cell>{si.value}</Cell>
|
|
||||||
<Cell>{si.name}</Cell>
|
|
||||||
</Row>
|
|
||||||
))}
|
|
||||||
</Body>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Table>
|
|
||||||
);
|
|
||||||
}, [
|
|
||||||
schedule,
|
|
||||||
error,
|
|
||||||
fetchSchedule,
|
|
||||||
filteredAndSortedSchedule,
|
|
||||||
schedule_theme,
|
|
||||||
editScheduleItem,
|
|
||||||
LL,
|
|
||||||
dayBox,
|
|
||||||
scheduleType
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SectionContent>
|
|
||||||
{blocker ? <BlockNavigation blocker={blocker} /> : null}
|
|
||||||
<Box mb={2} color="warning.main">
|
|
||||||
<Typography variant="body1">{LL.SCHEDULER_HELP_1()}.</Typography>
|
|
||||||
</Box>
|
|
||||||
{renderSchedule()}
|
|
||||||
|
|
||||||
{selectedScheduleItem && (
|
|
||||||
<SettingsSchedulerDialog
|
|
||||||
open={dialogOpen}
|
|
||||||
creating={creating}
|
|
||||||
onClose={onDialogClose}
|
|
||||||
onSave={onDialogSave}
|
|
||||||
selectedItem={selectedScheduleItem}
|
|
||||||
validator={schedulerItemValidation(schedule, selectedScheduleItem)}
|
|
||||||
dow={dow}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Box display="flex" flexWrap="wrap">
|
|
||||||
<Box flexGrow={1}>
|
|
||||||
{numChanges !== 0 && (
|
|
||||||
<ButtonRow>
|
|
||||||
<Button
|
|
||||||
startIcon={<CancelIcon />}
|
|
||||||
variant="outlined"
|
|
||||||
onClick={onDialogCancel}
|
|
||||||
color="secondary"
|
|
||||||
>
|
|
||||||
{LL.CANCEL()}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
startIcon={<WarningIcon color="warning" />}
|
|
||||||
variant="contained"
|
|
||||||
color="info"
|
|
||||||
onClick={saveSchedule}
|
|
||||||
>
|
|
||||||
{LL.APPLY_CHANGES(numChanges)}
|
|
||||||
</Button>
|
|
||||||
</ButtonRow>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
<Box flexWrap="nowrap" whiteSpace="nowrap">
|
|
||||||
<ButtonRow>
|
|
||||||
<Button
|
|
||||||
startIcon={<AddIcon />}
|
|
||||||
variant="outlined"
|
|
||||||
color="primary"
|
|
||||||
onClick={addScheduleItem}
|
|
||||||
>
|
|
||||||
{LL.ADD(0)}
|
|
||||||
</Button>
|
|
||||||
</ButtonRow>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</SectionContent>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Scheduler;
|
|
||||||
@@ -1,434 +0,0 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
||||||
|
|
||||||
import AddIcon from '@mui/icons-material/Add';
|
|
||||||
import CancelIcon from '@mui/icons-material/Cancel';
|
|
||||||
import DoneIcon from '@mui/icons-material/Done';
|
|
||||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
|
||||||
import RemoveIcon from '@mui/icons-material/RemoveCircleOutline';
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Checkbox,
|
|
||||||
Dialog,
|
|
||||||
DialogActions,
|
|
||||||
DialogContent,
|
|
||||||
DialogTitle,
|
|
||||||
Grid,
|
|
||||||
TextField,
|
|
||||||
ToggleButton,
|
|
||||||
ToggleButtonGroup,
|
|
||||||
Typography
|
|
||||||
} from '@mui/material';
|
|
||||||
|
|
||||||
import { dialogStyle } from 'CustomTheme';
|
|
||||||
import type Schema from 'async-validator';
|
|
||||||
import type { ValidateFieldsError } from 'async-validator';
|
|
||||||
import { BlockFormControlLabel, ValidatedTextField } from 'components';
|
|
||||||
import { useI18nContext } from 'i18n/i18n-react';
|
|
||||||
import { updateValue } from 'utils';
|
|
||||||
import { 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;
|
|
||||||
onClose: () => void;
|
|
||||||
onSave: (ei: ScheduleItem) => void;
|
|
||||||
selectedItem: ScheduleItem;
|
|
||||||
validator: Schema;
|
|
||||||
dow: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const SchedulerDialog = ({
|
|
||||||
open,
|
|
||||||
creating,
|
|
||||||
onClose,
|
|
||||||
onSave,
|
|
||||||
selectedItem,
|
|
||||||
validator,
|
|
||||||
dow
|
|
||||||
}: SchedulerDialogProps) => {
|
|
||||||
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>>
|
|
||||||
>
|
|
||||||
),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
setFieldErrors(undefined);
|
|
||||||
setEditItem(selectedItem);
|
|
||||||
// Set the flags based on type when page is loaded:
|
|
||||||
// 0-127 is day schedule
|
|
||||||
// 128 is timer
|
|
||||||
// 129 is on change
|
|
||||||
// 130 is on condition
|
|
||||||
// 132 is immediate
|
|
||||||
setScheduleType(
|
|
||||||
selectedItem.flags < SCHEDULE_TYPE_THRESHOLD
|
|
||||||
? ScheduleFlag.SCHEDULE_DAY
|
|
||||||
: 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;
|
|
||||||
}
|
|
||||||
return editItem.time === DEFAULT_TIME ? '' : editItem.time;
|
|
||||||
}, [editItem.time, needsTimeField]);
|
|
||||||
|
|
||||||
const timeFieldLabel = useMemo(() => {
|
|
||||||
if (scheduleType === ScheduleFlag.SCHEDULE_TIMER) return LL.TIMER(1);
|
|
||||||
if (scheduleType === ScheduleFlag.SCHEDULE_CONDITION) return LL.CONDITION();
|
|
||||||
if (scheduleType === ScheduleFlag.SCHEDULE_ONCHANGE) return LL.ONCHANGE();
|
|
||||||
if (scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE) return LL.IMMEDIATE();
|
|
||||||
return LL.TIME(1);
|
|
||||||
}, [scheduleType, LL]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
|
|
||||||
<DialogTitle>
|
|
||||||
{creating ? `${LL.ADD(1)} ${LL.NEW(0)}` : LL.EDIT()}
|
|
||||||
{LL.SCHEDULE(1)}
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogContent dividers>
|
|
||||||
<ToggleButtonGroup
|
|
||||||
size="small"
|
|
||||||
color="secondary"
|
|
||||||
value={scheduleType}
|
|
||||||
exclusive
|
|
||||||
disabled={!creating}
|
|
||||||
onChange={handleScheduleTypeChange}
|
|
||||||
>
|
|
||||||
<ToggleButton value={ScheduleFlag.SCHEDULE_DAY}>
|
|
||||||
<Typography
|
|
||||||
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
|
|
||||||
color={isDaySchedule ? 'primary' : 'grey'}
|
|
||||||
>
|
|
||||||
{LL.SCHEDULE(0)}
|
|
||||||
</Typography>
|
|
||||||
</ToggleButton>
|
|
||||||
<ToggleButton value={ScheduleFlag.SCHEDULE_TIMER}>
|
|
||||||
<Typography
|
|
||||||
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
|
|
||||||
color={isTimerSchedule ? 'primary' : 'grey'}
|
|
||||||
>
|
|
||||||
{LL.TIMER(0)}
|
|
||||||
</Typography>
|
|
||||||
</ToggleButton>
|
|
||||||
<ToggleButton value={ScheduleFlag.SCHEDULE_ONCHANGE}>
|
|
||||||
<Typography
|
|
||||||
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
|
|
||||||
color={
|
|
||||||
scheduleType === ScheduleFlag.SCHEDULE_ONCHANGE ? 'primary' : 'grey'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{LL.ONCHANGE()}
|
|
||||||
</Typography>
|
|
||||||
</ToggleButton>
|
|
||||||
<ToggleButton value={ScheduleFlag.SCHEDULE_CONDITION}>
|
|
||||||
<Typography
|
|
||||||
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
|
|
||||||
color={
|
|
||||||
scheduleType === ScheduleFlag.SCHEDULE_CONDITION ? 'primary' : 'grey'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{LL.CONDITION()}
|
|
||||||
</Typography>
|
|
||||||
</ToggleButton>
|
|
||||||
<ToggleButton value={ScheduleFlag.SCHEDULE_IMMEDIATE}>
|
|
||||||
<Typography
|
|
||||||
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
|
|
||||||
color={isImmediateSchedule ? 'primary' : 'grey'}
|
|
||||||
>
|
|
||||||
{LL.IMMEDIATE()}
|
|
||||||
</Typography>
|
|
||||||
</ToggleButton>
|
|
||||||
</ToggleButtonGroup>
|
|
||||||
|
|
||||||
{isDaySchedule && (
|
|
||||||
<ToggleButtonGroup
|
|
||||||
size="small"
|
|
||||||
color="secondary"
|
|
||||||
value={dowFlags}
|
|
||||||
onChange={handleDOWChange}
|
|
||||||
>
|
|
||||||
{DAY_FLAGS.map(({ value, flag }) => (
|
|
||||||
<ToggleButton key={value} value={value}>
|
|
||||||
{DayOfWeekButton(flag)}
|
|
||||||
</ToggleButton>
|
|
||||||
))}
|
|
||||||
</ToggleButtonGroup>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isImmediateSchedule && (
|
|
||||||
<>
|
|
||||||
<Grid container>
|
|
||||||
<BlockFormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={editItem.active}
|
|
||||||
onChange={updateFormValue}
|
|
||||||
name="active"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label={LL.ACTIVE()}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid container>
|
|
||||||
{needsTimeField ? (
|
|
||||||
<>
|
|
||||||
<TextField
|
|
||||||
name="time"
|
|
||||||
type="time"
|
|
||||||
label={timeFieldLabel}
|
|
||||||
value={timeFieldValue}
|
|
||||||
margin="normal"
|
|
||||||
onChange={updateFormValue}
|
|
||||||
/>
|
|
||||||
{isTimerSchedule && (
|
|
||||||
<Box color="warning.main" ml={2} mt={4}>
|
|
||||||
<Typography variant="body2">
|
|
||||||
{LL.SCHEDULER_HELP_2()}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<TextField
|
|
||||||
name="time"
|
|
||||||
label={timeFieldLabel}
|
|
||||||
multiline
|
|
||||||
fullWidth
|
|
||||||
value={timeFieldValue}
|
|
||||||
margin="normal"
|
|
||||||
onChange={updateFormValue}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Grid>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<ValidatedTextField
|
|
||||||
fieldErrors={fieldErrors || {}}
|
|
||||||
name="cmd"
|
|
||||||
label={LL.COMMAND(0)}
|
|
||||||
multiline
|
|
||||||
fullWidth
|
|
||||||
value={editItem.cmd}
|
|
||||||
margin="normal"
|
|
||||||
onChange={updateFormValue}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
name="value"
|
|
||||||
label={LL.VALUE(0)}
|
|
||||||
multiline
|
|
||||||
margin="normal"
|
|
||||||
fullWidth
|
|
||||||
value={editItem.value}
|
|
||||||
onChange={updateFormValue}
|
|
||||||
/>
|
|
||||||
<ValidatedTextField
|
|
||||||
fieldErrors={fieldErrors || {}}
|
|
||||||
name="name"
|
|
||||||
label={LL.NAME(0) + ' (' + LL.OPTIONAL() + ')'}
|
|
||||||
value={editItem.name}
|
|
||||||
fullWidth
|
|
||||||
margin="normal"
|
|
||||||
onChange={updateFormValue}
|
|
||||||
/>
|
|
||||||
</DialogContent>
|
|
||||||
|
|
||||||
<DialogActions>
|
|
||||||
{!creating && (
|
|
||||||
<Box flexGrow={1}>
|
|
||||||
<Button
|
|
||||||
startIcon={<RemoveIcon />}
|
|
||||||
variant="outlined"
|
|
||||||
color="warning"
|
|
||||||
onClick={remove}
|
|
||||||
>
|
|
||||||
{LL.REMOVE()}
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
startIcon={<CancelIcon />}
|
|
||||||
variant="outlined"
|
|
||||||
onClick={onClose}
|
|
||||||
color="secondary"
|
|
||||||
>
|
|
||||||
{LL.CANCEL()}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
startIcon={creating ? <AddIcon /> : <DoneIcon />}
|
|
||||||
variant="outlined"
|
|
||||||
onClick={save}
|
|
||||||
color="primary"
|
|
||||||
>
|
|
||||||
{creating ? LL.ADD(0) : LL.UPDATE()}
|
|
||||||
</Button>
|
|
||||||
{isImmediateSchedule && editItem.cmd !== '' && (
|
|
||||||
<Button
|
|
||||||
startIcon={<PlayArrowIcon />}
|
|
||||||
variant="outlined"
|
|
||||||
onClick={saveandactivate}
|
|
||||||
color="success"
|
|
||||||
>
|
|
||||||
{LL.EXECUTE()}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SchedulerDialog;
|
|
||||||
@@ -1,608 +0,0 @@
|
|||||||
import { useCallback, useContext, useMemo, useRef, useState } from 'react';
|
|
||||||
import { toast } from 'react-toastify';
|
|
||||||
|
|
||||||
import AddCircleOutlineOutlinedIcon from '@mui/icons-material/AddCircleOutlineOutlined';
|
|
||||||
import KeyboardArrowDownOutlinedIcon from '@mui/icons-material/KeyboardArrowDownOutlined';
|
|
||||||
import KeyboardArrowUpOutlinedIcon from '@mui/icons-material/KeyboardArrowUpOutlined';
|
|
||||||
import UnfoldMoreOutlinedIcon from '@mui/icons-material/UnfoldMoreOutlined';
|
|
||||||
import { Box, Button, Typography } from '@mui/material';
|
|
||||||
|
|
||||||
import { SortToggleType, useSort } from '@table-library/react-table-library/sort';
|
|
||||||
import {
|
|
||||||
Body,
|
|
||||||
Cell,
|
|
||||||
Header,
|
|
||||||
HeaderCell,
|
|
||||||
HeaderRow,
|
|
||||||
Row,
|
|
||||||
Table
|
|
||||||
} from '@table-library/react-table-library/table';
|
|
||||||
import { useTheme } from '@table-library/react-table-library/theme';
|
|
||||||
import type { State } from '@table-library/react-table-library/types/common';
|
|
||||||
import { useRequest } from 'alova/client';
|
|
||||||
import { SectionContent, useLayoutTitle } from 'components';
|
|
||||||
import { AuthenticatedContext } from 'contexts/authentication';
|
|
||||||
import { useI18nContext } from 'i18n/i18n-react';
|
|
||||||
import { useInterval } from 'utils';
|
|
||||||
|
|
||||||
import {
|
|
||||||
readSensorData,
|
|
||||||
writeAnalogSensor,
|
|
||||||
writeTemperatureSensor
|
|
||||||
} from '../../api/app';
|
|
||||||
import DashboardSensorsAnalogDialog from './SensorsAnalogDialog';
|
|
||||||
import DashboardSensorsTemperatureDialog from './SensorsTemperatureDialog';
|
|
||||||
import {
|
|
||||||
AnalogType,
|
|
||||||
AnalogTypeNames,
|
|
||||||
DeviceValueUOM,
|
|
||||||
DeviceValueUOM_s
|
|
||||||
} from './types';
|
|
||||||
import type {
|
|
||||||
AnalogSensor,
|
|
||||||
TemperatureSensor,
|
|
||||||
WriteAnalogSensor,
|
|
||||||
WriteTemperatureSensor
|
|
||||||
} from './types';
|
|
||||||
import {
|
|
||||||
analogSensorItemValidation,
|
|
||||||
temperatureSensorItemValidation
|
|
||||||
} from './validators';
|
|
||||||
|
|
||||||
// Constants
|
|
||||||
const MS_PER_SECOND = 1000;
|
|
||||||
const MS_PER_MINUTE = 60 * MS_PER_SECOND;
|
|
||||||
const MS_PER_HOUR = 60 * MS_PER_MINUTE;
|
|
||||||
const MS_PER_DAY = 24 * MS_PER_HOUR;
|
|
||||||
const MIN_TEMP_ID = -100;
|
|
||||||
const MAX_TEMP_ID = 100;
|
|
||||||
const GPIO_25 = 25;
|
|
||||||
const GPIO_26 = 26;
|
|
||||||
|
|
||||||
const HEADER_BUTTON_STYLE: React.CSSProperties = {
|
|
||||||
fontSize: '14px',
|
|
||||||
justifyContent: 'flex-start'
|
|
||||||
};
|
|
||||||
|
|
||||||
const HEADER_BUTTON_STYLE_END: React.CSSProperties = {
|
|
||||||
fontSize: '14px',
|
|
||||||
justifyContent: 'flex-end'
|
|
||||||
};
|
|
||||||
|
|
||||||
const common_theme = {
|
|
||||||
BaseRow: `
|
|
||||||
font-size: 14px;
|
|
||||||
.td {
|
|
||||||
height: 32px;
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
HeaderRow: `
|
|
||||||
text-transform: uppercase;
|
|
||||||
background-color: black;
|
|
||||||
color: #90CAF9;
|
|
||||||
.th {
|
|
||||||
border-bottom: 1px solid #565656;
|
|
||||||
height: 36px;
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
Row: `
|
|
||||||
background-color: #1e1e1e;
|
|
||||||
position: relative;
|
|
||||||
cursor: pointer;
|
|
||||||
.td {
|
|
||||||
padding: 8px;
|
|
||||||
border-bottom: 1px solid #565656;
|
|
||||||
}
|
|
||||||
&:hover .td {
|
|
||||||
background-color: #177ac9;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
Cell: `
|
|
||||||
&:last-of-type {
|
|
||||||
text-align: right;
|
|
||||||
},
|
|
||||||
`
|
|
||||||
};
|
|
||||||
|
|
||||||
const temperature_theme_config = {
|
|
||||||
Table: `
|
|
||||||
--data-table-library_grid-template-columns: minmax(0, 1fr) 35%;
|
|
||||||
`
|
|
||||||
};
|
|
||||||
|
|
||||||
const analog_theme_config = {
|
|
||||||
Table: `
|
|
||||||
--data-table-library_grid-template-columns: 80px repeat(1, minmax(0, 1fr)) 120px 110px;
|
|
||||||
`
|
|
||||||
};
|
|
||||||
|
|
||||||
const Sensors = () => {
|
|
||||||
const { LL } = useI18nContext();
|
|
||||||
const { me } = useContext(AuthenticatedContext);
|
|
||||||
|
|
||||||
const [selectedTemperatureSensor, setSelectedTemperatureSensor] =
|
|
||||||
useState<TemperatureSensor>();
|
|
||||||
const [selectedAnalogSensor, setSelectedAnalogSensor] = useState<AnalogSensor>();
|
|
||||||
const [temperatureDialogOpen, setTemperatureDialogOpen] = useState<boolean>(false);
|
|
||||||
const [analogDialogOpen, setAnalogDialogOpen] = useState<boolean>(false);
|
|
||||||
const [creating, setCreating] = useState<boolean>(false);
|
|
||||||
const firstAvailableGPIO = useRef<number>(undefined);
|
|
||||||
|
|
||||||
const { data: sensorData, send: fetchSensorData } = useRequest(readSensorData, {
|
|
||||||
initialData: {
|
|
||||||
ts: [],
|
|
||||||
as: [],
|
|
||||||
analog_enabled: false,
|
|
||||||
available_gpios: [] as number[],
|
|
||||||
platform: 'ESP32'
|
|
||||||
}
|
|
||||||
}).onSuccess((event) => {
|
|
||||||
// store the first available GPIO in a ref
|
|
||||||
if (event.data.available_gpios.length > 0) {
|
|
||||||
firstAvailableGPIO.current = event.data.available_gpios[0];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const { send: sendTemperatureSensor } = useRequest(
|
|
||||||
(data: WriteTemperatureSensor) => writeTemperatureSensor(data),
|
|
||||||
{
|
|
||||||
immediate: false
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const { send: sendAnalogSensor } = useRequest(
|
|
||||||
(data: WriteAnalogSensor) => writeAnalogSensor(data),
|
|
||||||
{
|
|
||||||
immediate: false
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const intervalCallback = useCallback(() => {
|
|
||||||
if (!temperatureDialogOpen && !analogDialogOpen) {
|
|
||||||
void fetchSensorData();
|
|
||||||
}
|
|
||||||
}, [temperatureDialogOpen, analogDialogOpen, fetchSensorData]);
|
|
||||||
|
|
||||||
useInterval(intervalCallback);
|
|
||||||
|
|
||||||
const temperature_theme = useTheme([common_theme, temperature_theme_config]);
|
|
||||||
const analog_theme = useTheme([common_theme, analog_theme_config]);
|
|
||||||
|
|
||||||
const getSortIcon = useCallback((state: State, sortKey: unknown) => {
|
|
||||||
if (state.sortKey === sortKey && state.reverse) {
|
|
||||||
return <KeyboardArrowDownOutlinedIcon />;
|
|
||||||
}
|
|
||||||
if (state.sortKey === sortKey && !state.reverse) {
|
|
||||||
return <KeyboardArrowUpOutlinedIcon />;
|
|
||||||
}
|
|
||||||
return <UnfoldMoreOutlinedIcon />;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const analog_sort = useSort(
|
|
||||||
{ nodes: sensorData.as },
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
sortIcon: {
|
|
||||||
iconDefault: <UnfoldMoreOutlinedIcon />,
|
|
||||||
iconUp: <KeyboardArrowUpOutlinedIcon />,
|
|
||||||
iconDown: <KeyboardArrowDownOutlinedIcon />
|
|
||||||
},
|
|
||||||
sortToggleType: SortToggleType.AlternateWithReset,
|
|
||||||
sortFns: {
|
|
||||||
GPIO: (array) =>
|
|
||||||
[...array].sort(
|
|
||||||
(a, b) => ((a as AnalogSensor)?.g ?? 0) - ((b as AnalogSensor)?.g ?? 0)
|
|
||||||
),
|
|
||||||
NAME: (array) =>
|
|
||||||
[...array].sort((a, b) =>
|
|
||||||
((a as AnalogSensor)?.n ?? '').localeCompare(
|
|
||||||
(b as AnalogSensor)?.n ?? ''
|
|
||||||
)
|
|
||||||
),
|
|
||||||
TYPE: (array) =>
|
|
||||||
[...array].sort((a, b) => (a as AnalogSensor).t - (b as AnalogSensor).t),
|
|
||||||
VALUE: (array) =>
|
|
||||||
[...array].sort((a, b) => (a as AnalogSensor).v - (b as AnalogSensor).v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const temperature_sort = useSort(
|
|
||||||
{ nodes: sensorData.ts },
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
sortIcon: {
|
|
||||||
iconDefault: <UnfoldMoreOutlinedIcon />,
|
|
||||||
iconUp: <KeyboardArrowUpOutlinedIcon />,
|
|
||||||
iconDown: <KeyboardArrowDownOutlinedIcon />
|
|
||||||
},
|
|
||||||
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)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
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 parts: string[] = [];
|
|
||||||
if (days > 0) {
|
|
||||||
parts.push(LL.NUM_DAYS({ num: days }));
|
|
||||||
}
|
|
||||||
if (hours > 0) {
|
|
||||||
parts.push(LL.NUM_HOURS({ num: hours }));
|
|
||||||
}
|
|
||||||
if (minutes > 0) {
|
|
||||||
parts.push(LL.NUM_MINUTES({ num: minutes }));
|
|
||||||
}
|
|
||||||
return parts.join(' ');
|
|
||||||
},
|
|
||||||
[LL]
|
|
||||||
);
|
|
||||||
|
|
||||||
const formatValue = useCallback(
|
|
||||||
(value: unknown, uom: DeviceValueUOM) => {
|
|
||||||
if (value === undefined) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
if (typeof value !== 'number') {
|
|
||||||
return value as string;
|
|
||||||
}
|
|
||||||
switch (uom) {
|
|
||||||
case DeviceValueUOM.HOURS:
|
|
||||||
return value ? formatDurationMin(value * 60) : LL.NUM_HOURS({ num: 0 });
|
|
||||||
case DeviceValueUOM.MINUTES:
|
|
||||||
return value ? formatDurationMin(value) : LL.NUM_MINUTES({ num: 0 });
|
|
||||||
case DeviceValueUOM.SECONDS:
|
|
||||||
return LL.NUM_SECONDS({ num: value });
|
|
||||||
case DeviceValueUOM.NONE:
|
|
||||||
return new Intl.NumberFormat().format(value);
|
|
||||||
case DeviceValueUOM.DEGREES:
|
|
||||||
case DeviceValueUOM.DEGREES_R:
|
|
||||||
case DeviceValueUOM.FAHRENHEIT:
|
|
||||||
return (
|
|
||||||
new Intl.NumberFormat(undefined, {
|
|
||||||
minimumFractionDigits: 1
|
|
||||||
}).format(value) +
|
|
||||||
' ' +
|
|
||||||
DeviceValueUOM_s[uom]
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
return new Intl.NumberFormat().format(value) + ' ' + DeviceValueUOM_s[uom];
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[formatDurationMin, LL]
|
|
||||||
);
|
|
||||||
|
|
||||||
const updateTemperatureSensor = useCallback(
|
|
||||||
(ts: TemperatureSensor) => {
|
|
||||||
if (me.admin) {
|
|
||||||
ts.o_n = ts.n;
|
|
||||||
setSelectedTemperatureSensor(ts);
|
|
||||||
setTemperatureDialogOpen(true);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[me.admin]
|
|
||||||
);
|
|
||||||
|
|
||||||
const onTemperatureDialogClose = useCallback(() => {
|
|
||||||
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
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
toast.success(LL.UPDATED_OF(LL.SENSOR(1)));
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
toast.error(LL.UPDATE_OF(LL.SENSOR(2)) + ' ' + LL.FAILED(1));
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setTemperatureDialogOpen(false);
|
|
||||||
setSelectedTemperatureSensor(undefined);
|
|
||||||
void fetchSensorData();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[sendTemperatureSensor, LL, fetchSensorData]
|
|
||||||
);
|
|
||||||
|
|
||||||
const updateAnalogSensor = useCallback(
|
|
||||||
(as: AnalogSensor) => {
|
|
||||||
if (me.admin) {
|
|
||||||
setCreating(false);
|
|
||||||
as.o_n = as.n;
|
|
||||||
setSelectedAnalogSensor(as);
|
|
||||||
setAnalogDialogOpen(true);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[me.admin]
|
|
||||||
);
|
|
||||||
|
|
||||||
const onAnalogDialogClose = useCallback(() => {
|
|
||||||
setAnalogDialogOpen(false);
|
|
||||||
void fetchSensorData();
|
|
||||||
}, [fetchSensorData]);
|
|
||||||
|
|
||||||
const addAnalogSensor = useCallback(() => {
|
|
||||||
if (firstAvailableGPIO.current === undefined) {
|
|
||||||
toast.error('No available GPIO found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setCreating(true);
|
|
||||||
setSelectedAnalogSensor({
|
|
||||||
id: Math.floor(Math.random() * (MAX_TEMP_ID - MIN_TEMP_ID) + MIN_TEMP_ID),
|
|
||||||
n: '',
|
|
||||||
g: firstAvailableGPIO.current,
|
|
||||||
u: DeviceValueUOM.NONE,
|
|
||||||
v: 0,
|
|
||||||
o: 0,
|
|
||||||
f: 1,
|
|
||||||
t: AnalogType.DIGITAL_IN, // default to digital in 1
|
|
||||||
d: false,
|
|
||||||
s: false,
|
|
||||||
o_n: ''
|
|
||||||
});
|
|
||||||
setAnalogDialogOpen(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const onAnalogDialogSave = useCallback(
|
|
||||||
async (as: AnalogSensor) => {
|
|
||||||
await sendAnalogSensor({
|
|
||||||
id: as.id,
|
|
||||||
gpio: as.g,
|
|
||||||
name: as.n,
|
|
||||||
offset: as.o,
|
|
||||||
factor: as.f,
|
|
||||||
uom: as.u,
|
|
||||||
type: as.t,
|
|
||||||
deleted: as.d,
|
|
||||||
is_system: as.s
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
toast.success(LL.UPDATED_OF(LL.ANALOG_SENSOR(2)));
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
toast.error(LL.UPDATE_OF(LL.ANALOG_SENSOR(5)) + ' ' + LL.FAILED(1));
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setAnalogDialogOpen(false);
|
|
||||||
setSelectedAnalogSensor(undefined);
|
|
||||||
void fetchSensorData();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[sendAnalogSensor, LL, 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)}
|
|
||||||
>
|
|
||||||
<Cell stiff>{as.g}</Cell>
|
|
||||||
<Cell>{as.n}</Cell>
|
|
||||||
<Cell stiff>{AnalogTypeNames[as.t - 1]} </Cell>
|
|
||||||
{(as.t === AnalogType.DIGITAL_OUT &&
|
|
||||||
as.g !== GPIO_25 &&
|
|
||||||
as.g !== GPIO_26) ||
|
|
||||||
as.t === AnalogType.DIGITAL_IN ||
|
|
||||||
as.t === AnalogType.PULSE ? (
|
|
||||||
<Cell stiff>{as.v ? LL.ON() : LL.OFF()}</Cell>
|
|
||||||
) : (
|
|
||||||
<Cell stiff>{formatValue(as.v, as.u)}</Cell>
|
|
||||||
)}
|
|
||||||
</Row>
|
|
||||||
))}
|
|
||||||
</Body>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Table>
|
|
||||||
),
|
|
||||||
[
|
|
||||||
analog_sort,
|
|
||||||
analog_theme,
|
|
||||||
getSortIcon,
|
|
||||||
sensorData.as,
|
|
||||||
LL,
|
|
||||||
updateAnalogSensor,
|
|
||||||
formatValue
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
const RenderTemperatureSensors = useMemo(
|
|
||||||
() => (
|
|
||||||
<Table
|
|
||||||
data={{ nodes: sensorData.ts }}
|
|
||||||
theme={temperature_theme}
|
|
||||||
sort={temperature_sort}
|
|
||||||
layout={{ custom: true }}
|
|
||||||
>
|
|
||||||
{(tableList: TemperatureSensor[]) => (
|
|
||||||
<>
|
|
||||||
<Header>
|
|
||||||
<HeaderRow>
|
|
||||||
<HeaderCell resize>
|
|
||||||
<Button
|
|
||||||
fullWidth
|
|
||||||
style={HEADER_BUTTON_STYLE}
|
|
||||||
endIcon={getSortIcon(temperature_sort.state, 'NAME')}
|
|
||||||
onClick={() =>
|
|
||||||
temperature_sort.fns.onToggleSort({ sortKey: 'NAME' })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{LL.NAME(0)}
|
|
||||||
</Button>
|
|
||||||
</HeaderCell>
|
|
||||||
<HeaderCell stiff>
|
|
||||||
<Button
|
|
||||||
fullWidth
|
|
||||||
style={HEADER_BUTTON_STYLE_END}
|
|
||||||
endIcon={getSortIcon(temperature_sort.state, 'VALUE')}
|
|
||||||
onClick={() =>
|
|
||||||
temperature_sort.fns.onToggleSort({ sortKey: 'VALUE' })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{LL.VALUE(0)}
|
|
||||||
</Button>
|
|
||||||
</HeaderCell>
|
|
||||||
</HeaderRow>
|
|
||||||
</Header>
|
|
||||||
<Body>
|
|
||||||
{tableList.map((ts: TemperatureSensor) => (
|
|
||||||
<Row
|
|
||||||
style={{ color: ts.s ? 'grey' : 'inherit' }}
|
|
||||||
key={ts.id}
|
|
||||||
item={ts}
|
|
||||||
onClick={() => updateTemperatureSensor(ts)}
|
|
||||||
>
|
|
||||||
<Cell>{ts.n}</Cell>
|
|
||||||
<Cell>{formatValue(ts.t, ts.u)}</Cell>
|
|
||||||
</Row>
|
|
||||||
))}
|
|
||||||
</Body>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Table>
|
|
||||||
),
|
|
||||||
[
|
|
||||||
temperature_sort,
|
|
||||||
temperature_theme,
|
|
||||||
getSortIcon,
|
|
||||||
sensorData.ts,
|
|
||||||
LL,
|
|
||||||
updateTemperatureSensor,
|
|
||||||
formatValue
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SectionContent>
|
|
||||||
<Typography sx={{ pb: 1 }} variant="h6" color="primary">
|
|
||||||
{LL.TEMP_SENSORS()}
|
|
||||||
</Typography>
|
|
||||||
{RenderTemperatureSensors}
|
|
||||||
{selectedTemperatureSensor && (
|
|
||||||
<DashboardSensorsTemperatureDialog
|
|
||||||
open={temperatureDialogOpen}
|
|
||||||
onClose={onTemperatureDialogClose}
|
|
||||||
onSave={onTemperatureDialogSave}
|
|
||||||
selectedItem={selectedTemperatureSensor}
|
|
||||||
validator={temperatureSensorItemValidation(
|
|
||||||
sensorData.ts,
|
|
||||||
selectedTemperatureSensor
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Typography sx={{ pt: 4, pb: 1 }} variant="h6" color="primary">
|
|
||||||
{LL.ANALOG_SENSORS()}
|
|
||||||
</Typography>
|
|
||||||
{RenderAnalogSensors}
|
|
||||||
{selectedAnalogSensor && (
|
|
||||||
<DashboardSensorsAnalogDialog
|
|
||||||
open={analogDialogOpen}
|
|
||||||
onClose={onAnalogDialogClose}
|
|
||||||
onSave={onAnalogDialogSave}
|
|
||||||
creating={creating}
|
|
||||||
selectedItem={selectedAnalogSensor}
|
|
||||||
analogGPIOList={sensorData.available_gpios}
|
|
||||||
validator={analogSensorItemValidation(sensorData.as, selectedAnalogSensor)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{sensorData?.analog_enabled === true && me.admin && (
|
|
||||||
<Box mt={2} display="flex" flexWrap="wrap" justifyContent="flex-end">
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
color="primary"
|
|
||||||
startIcon={<AddCircleOutlineOutlinedIcon />}
|
|
||||||
onClick={addAnalogSensor}
|
|
||||||
>
|
|
||||||
{LL.ADD(0)}
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</SectionContent>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Sensors;
|
|
||||||
@@ -1,524 +0,0 @@
|
|||||||
import { useCallback, useEffect, useMemo, 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 {
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Dialog,
|
|
||||||
DialogActions,
|
|
||||||
DialogContent,
|
|
||||||
DialogTitle,
|
|
||||||
Grid,
|
|
||||||
InputAdornment,
|
|
||||||
MenuItem,
|
|
||||||
Typography
|
|
||||||
} from '@mui/material';
|
|
||||||
|
|
||||||
import { dialogStyle } from 'CustomTheme';
|
|
||||||
import type Schema from 'async-validator';
|
|
||||||
import type { ValidateFieldsError } from 'async-validator';
|
|
||||||
import { ValidatedTextField } from 'components';
|
|
||||||
import { useI18nContext } from 'i18n/i18n-react';
|
|
||||||
import { numberValue, updateValue } from 'utils';
|
|
||||||
import { validate } from 'validators';
|
|
||||||
|
|
||||||
import { AnalogType, AnalogTypeNames, DeviceValueUOM_s } from './types';
|
|
||||||
import type { AnalogSensor } from './types';
|
|
||||||
|
|
||||||
interface DashboardSensorsAnalogDialogProps {
|
|
||||||
open: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onSave: (as: AnalogSensor) => void;
|
|
||||||
creating: boolean;
|
|
||||||
selectedItem: AnalogSensor;
|
|
||||||
analogGPIOList: number[];
|
|
||||||
validator: Schema;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SensorsAnalogDialog = ({
|
|
||||||
open,
|
|
||||||
onClose,
|
|
||||||
onSave,
|
|
||||||
creating,
|
|
||||||
selectedItem,
|
|
||||||
analogGPIOList,
|
|
||||||
validator
|
|
||||||
}: DashboardSensorsAnalogDialogProps) => {
|
|
||||||
const { LL } = useI18nContext();
|
|
||||||
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
|
||||||
const [editItem, setEditItem] = useState<AnalogSensor>(selectedItem);
|
|
||||||
|
|
||||||
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 + 1}>
|
|
||||||
{val}
|
|
||||||
</MenuItem>
|
|
||||||
)),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const uomMenuItems = useMemo(
|
|
||||||
() =>
|
|
||||||
DeviceValueUOM_s.map((val, i) => (
|
|
||||||
<MenuItem key={val} value={i}>
|
|
||||||
{val}
|
|
||||||
</MenuItem>
|
|
||||||
)),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const analogGPIOMenuItems = () =>
|
|
||||||
// add selectedItem.g to the list
|
|
||||||
[
|
|
||||||
...(analogGPIOList?.includes(selectedItem.g) || selectedItem.g === undefined
|
|
||||||
? analogGPIOList
|
|
||||||
: [selectedItem.g, ...analogGPIOList])
|
|
||||||
]
|
|
||||||
.filter((gpio, idx, arr) => arr.indexOf(gpio) === idx)
|
|
||||||
.sort((a, b) => a - b)
|
|
||||||
.map((gpio: number) => {
|
|
||||||
return (
|
|
||||||
<MenuItem key={gpio} value={gpio}>
|
|
||||||
{gpio}
|
|
||||||
</MenuItem>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Reset form when dialog opens or selectedItem changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
setFieldErrors(undefined);
|
|
||||||
setEditItem(selectedItem);
|
|
||||||
}
|
|
||||||
}, [open, selectedItem]);
|
|
||||||
|
|
||||||
const handleClose = useCallback(
|
|
||||||
(_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => {
|
|
||||||
if (reason !== 'backdropClick') {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[onClose]
|
|
||||||
);
|
|
||||||
|
|
||||||
const save = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
setFieldErrors(undefined);
|
|
||||||
await validate(validator, editItem);
|
|
||||||
onSave(editItem);
|
|
||||||
} 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]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
|
|
||||||
<DialogTitle>{dialogTitle}</DialogTitle>
|
|
||||||
<DialogContent dividers>
|
|
||||||
<Grid container spacing={2}>
|
|
||||||
<ValidatedTextField
|
|
||||||
name="g"
|
|
||||||
label="GPIO"
|
|
||||||
value={editItem.g}
|
|
||||||
sx={{ width: '9ch' }}
|
|
||||||
disabled={editItem.s || !creating}
|
|
||||||
select
|
|
||||||
onChange={updateFormValue}
|
|
||||||
>
|
|
||||||
{analogGPIOMenuItems()}
|
|
||||||
</ValidatedTextField>
|
|
||||||
<Grid>
|
|
||||||
<ValidatedTextField
|
|
||||||
name="n"
|
|
||||||
label={LL.NAME(0)}
|
|
||||||
value={editItem.n}
|
|
||||||
fullWidth
|
|
||||||
onChange={updateFormValue}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid>
|
|
||||||
<ValidatedTextField
|
|
||||||
name="t"
|
|
||||||
label={LL.TYPE(0)}
|
|
||||||
value={editItem.t}
|
|
||||||
fullWidth
|
|
||||||
select
|
|
||||||
onChange={updateFormValue}
|
|
||||||
disabled={editItem.s}
|
|
||||||
>
|
|
||||||
{analogTypeMenuItems}
|
|
||||||
</ValidatedTextField>
|
|
||||||
</Grid>
|
|
||||||
{(isCounterOrRate || isFreqType) && (
|
|
||||||
<Grid>
|
|
||||||
<ValidatedTextField
|
|
||||||
name="u"
|
|
||||||
label={LL.UNIT()}
|
|
||||||
value={editItem.u}
|
|
||||||
sx={{ width: '15ch' }}
|
|
||||||
select
|
|
||||||
onChange={updateFormValue}
|
|
||||||
disabled={editItem.s}
|
|
||||||
>
|
|
||||||
{uomMenuItems}
|
|
||||||
</ValidatedTextField>
|
|
||||||
</Grid>
|
|
||||||
)}
|
|
||||||
{editItem.t === AnalogType.ADC && (
|
|
||||||
<Grid>
|
|
||||||
<ValidatedTextField
|
|
||||||
name="o"
|
|
||||||
label={LL.OFFSET()}
|
|
||||||
value={numberValue(editItem.o)}
|
|
||||||
type="number"
|
|
||||||
sx={{ width: '11ch' }}
|
|
||||||
onChange={updateFormValue}
|
|
||||||
disabled={editItem.s}
|
|
||||||
slotProps={{
|
|
||||||
input: {
|
|
||||||
startAdornment: (
|
|
||||||
<InputAdornment position="start">mV</InputAdornment>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
htmlInput: { min: '0', max: '3300', step: '1' }
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
)}
|
|
||||||
{editItem.t === AnalogType.NTC && (
|
|
||||||
<Grid>
|
|
||||||
<ValidatedTextField
|
|
||||||
name="o"
|
|
||||||
label={LL.OFFSET()}
|
|
||||||
value={numberValue(editItem.o)}
|
|
||||||
sx={{ width: '11ch' }}
|
|
||||||
type="number"
|
|
||||||
onChange={updateFormValue}
|
|
||||||
disabled={editItem.s}
|
|
||||||
slotProps={{
|
|
||||||
input: {
|
|
||||||
startAdornment: (
|
|
||||||
<InputAdornment position="start">°C</InputAdornment>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
htmlInput: { min: '-20', max: '20', step: '0.1' }
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
)}
|
|
||||||
{editItem.t === AnalogType.COUNTER && (
|
|
||||||
<Grid>
|
|
||||||
<ValidatedTextField
|
|
||||||
name="o"
|
|
||||||
label={LL.STARTVALUE()}
|
|
||||||
value={numberValue(editItem.o)}
|
|
||||||
type="number"
|
|
||||||
sx={{ width: '11ch' }}
|
|
||||||
onChange={updateFormValue}
|
|
||||||
disabled={editItem.s}
|
|
||||||
slotProps={{
|
|
||||||
htmlInput: { step: '0.001' }
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
)}
|
|
||||||
{editItem.t === AnalogType.RGB && (
|
|
||||||
<Grid>
|
|
||||||
<ValidatedTextField
|
|
||||||
name="o"
|
|
||||||
label={'RGB ' + LL.VALUE(0)}
|
|
||||||
value={numberValue(editItem.o)}
|
|
||||||
type="number"
|
|
||||||
sx={{ width: '11ch' }}
|
|
||||||
onChange={updateFormValue}
|
|
||||||
disabled={editItem.s}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
)}
|
|
||||||
{isCounterOrRate && (
|
|
||||||
<Grid>
|
|
||||||
<ValidatedTextField
|
|
||||||
name="f"
|
|
||||||
label={LL.FACTOR()}
|
|
||||||
value={numberValue(editItem.f)}
|
|
||||||
sx={{ width: '14ch' }}
|
|
||||||
type="number"
|
|
||||||
onChange={updateFormValue}
|
|
||||||
disabled={editItem.s}
|
|
||||||
slotProps={{
|
|
||||||
htmlInput: { step: '0.001' }
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
)}
|
|
||||||
{isDigitalOutGPIO && (
|
|
||||||
<Grid>
|
|
||||||
<ValidatedTextField
|
|
||||||
name="o"
|
|
||||||
label={LL.VALUE(0)}
|
|
||||||
value={numberValue(editItem.o)}
|
|
||||||
sx={{ width: '11ch' }}
|
|
||||||
type="number"
|
|
||||||
onChange={updateFormValue}
|
|
||||||
disabled={editItem.s}
|
|
||||||
slotProps={{
|
|
||||||
htmlInput: { min: '0', max: '255', step: '1' }
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
)}
|
|
||||||
{isDigitalOutNonGPIO && (
|
|
||||||
<>
|
|
||||||
<Grid>
|
|
||||||
<ValidatedTextField
|
|
||||||
name="o"
|
|
||||||
label={LL.VALUE(0)}
|
|
||||||
value={numberValue(editItem.o)}
|
|
||||||
select
|
|
||||||
onChange={updateFormValue}
|
|
||||||
disabled={editItem.s}
|
|
||||||
>
|
|
||||||
<MenuItem value={0}>{LL.OFF()}</MenuItem>
|
|
||||||
<MenuItem value={1}>{LL.ON()}</MenuItem>
|
|
||||||
</ValidatedTextField>
|
|
||||||
</Grid>
|
|
||||||
<Grid>
|
|
||||||
<ValidatedTextField
|
|
||||||
name="f"
|
|
||||||
label={LL.POLARITY()}
|
|
||||||
value={editItem.f}
|
|
||||||
sx={{ width: '15ch' }}
|
|
||||||
select
|
|
||||||
onChange={updateFormValue}
|
|
||||||
disabled={editItem.s}
|
|
||||||
>
|
|
||||||
<MenuItem value={1}>{LL.ACTIVEHIGH()}</MenuItem>
|
|
||||||
<MenuItem value={-1}>{LL.ACTIVELOW()}</MenuItem>
|
|
||||||
</ValidatedTextField>
|
|
||||||
</Grid>
|
|
||||||
<Grid>
|
|
||||||
<ValidatedTextField
|
|
||||||
name="u"
|
|
||||||
label={LL.STARTVALUE()}
|
|
||||||
sx={{ width: '15ch' }}
|
|
||||||
value={editItem.u}
|
|
||||||
select
|
|
||||||
onChange={updateFormValue}
|
|
||||||
disabled={editItem.s}
|
|
||||||
>
|
|
||||||
<MenuItem value={0}>{LL.UNCHANGED()}</MenuItem>
|
|
||||||
<MenuItem value={1}>
|
|
||||||
{LL.ALWAYS()} {LL.OFF()}
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem value={2}>
|
|
||||||
{LL.ALWAYS()} {LL.ON()}
|
|
||||||
</MenuItem>
|
|
||||||
</ValidatedTextField>
|
|
||||||
</Grid>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{isPWM && (
|
|
||||||
<>
|
|
||||||
<Grid>
|
|
||||||
<ValidatedTextField
|
|
||||||
name="f"
|
|
||||||
label={LL.FREQ()}
|
|
||||||
value={numberValue(editItem.f)}
|
|
||||||
type="number"
|
|
||||||
sx={{ width: '11ch' }}
|
|
||||||
onChange={updateFormValue}
|
|
||||||
disabled={editItem.s}
|
|
||||||
slotProps={{
|
|
||||||
input: {
|
|
||||||
startAdornment: (
|
|
||||||
<InputAdornment position="start">Hz</InputAdornment>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
htmlInput: { min: '1', max: '5000', step: '1' }
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid>
|
|
||||||
<ValidatedTextField
|
|
||||||
name="o"
|
|
||||||
label={LL.DUTY_CYCLE()}
|
|
||||||
value={numberValue(editItem.o)}
|
|
||||||
type="number"
|
|
||||||
sx={{ width: '11ch' }}
|
|
||||||
onChange={updateFormValue}
|
|
||||||
disabled={editItem.s}
|
|
||||||
slotProps={{
|
|
||||||
input: {
|
|
||||||
startAdornment: (
|
|
||||||
<InputAdornment position="start">%</InputAdornment>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
htmlInput: { min: '0', max: '100', step: '0.1' }
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{editItem.t === AnalogType.PULSE && (
|
|
||||||
<>
|
|
||||||
<Grid>
|
|
||||||
<ValidatedTextField
|
|
||||||
name="o"
|
|
||||||
label={LL.POLARITY()}
|
|
||||||
value={editItem.o}
|
|
||||||
sx={{ width: '11ch' }}
|
|
||||||
select
|
|
||||||
onChange={updateFormValue}
|
|
||||||
disabled={editItem.s}
|
|
||||||
>
|
|
||||||
<MenuItem value={0}>{LL.ACTIVEHIGH()}</MenuItem>
|
|
||||||
<MenuItem value={1}>{LL.ACTIVELOW()}</MenuItem>
|
|
||||||
</ValidatedTextField>
|
|
||||||
</Grid>
|
|
||||||
<Grid>
|
|
||||||
<ValidatedTextField
|
|
||||||
name="f"
|
|
||||||
label="Pulse"
|
|
||||||
value={numberValue(editItem.f)}
|
|
||||||
type="number"
|
|
||||||
sx={{ width: '15ch' }}
|
|
||||||
onChange={updateFormValue}
|
|
||||||
disabled={editItem.s}
|
|
||||||
slotProps={{
|
|
||||||
input: {
|
|
||||||
startAdornment: (
|
|
||||||
<InputAdornment position="start">s</InputAdornment>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
htmlInput: { min: '0', max: '10000', step: '0.1' }
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Grid>
|
|
||||||
{fieldErrors && Object.keys(fieldErrors).length > 0 && (
|
|
||||||
<Box mt={1}>
|
|
||||||
{Object.values(fieldErrors).map((errArr, idx) =>
|
|
||||||
Array.isArray(errArr)
|
|
||||||
? errArr.map((err, j) => (
|
|
||||||
<Typography
|
|
||||||
key={`${idx}-${j}`}
|
|
||||||
color="error"
|
|
||||||
variant="caption"
|
|
||||||
display="block"
|
|
||||||
>
|
|
||||||
{err.message}
|
|
||||||
</Typography>
|
|
||||||
))
|
|
||||||
: null
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
{editItem.s && (
|
|
||||||
<Grid>
|
|
||||||
<Typography mt={1} color="warning.main" variant="body2">
|
|
||||||
<WarningIcon
|
|
||||||
fontSize="small"
|
|
||||||
sx={{ mr: 1, verticalAlign: 'middle' }}
|
|
||||||
color="warning"
|
|
||||||
/>
|
|
||||||
{LL.SYSTEM(0)} {LL.SENSOR(0)}
|
|
||||||
</Typography>
|
|
||||||
</Grid>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
{!creating && (
|
|
||||||
<Box flexGrow={1} sx={{ '& button': { mt: 0 } }}>
|
|
||||||
<Button
|
|
||||||
startIcon={<RemoveIcon />}
|
|
||||||
disabled={editItem.s}
|
|
||||||
variant="outlined"
|
|
||||||
color="warning"
|
|
||||||
onClick={remove}
|
|
||||||
>
|
|
||||||
{LL.REMOVE()}
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
startIcon={<CancelIcon />}
|
|
||||||
variant="outlined"
|
|
||||||
onClick={onClose}
|
|
||||||
color="secondary"
|
|
||||||
>
|
|
||||||
{LL.CANCEL()}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
startIcon={<DoneIcon />}
|
|
||||||
variant="outlined"
|
|
||||||
onClick={save}
|
|
||||||
color="primary"
|
|
||||||
>
|
|
||||||
{creating ? LL.ADD(0) : LL.UPDATE()}
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SensorsAnalogDialog;
|
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
import { useCallback, useEffect, useMemo, 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,
|
|
||||||
Button,
|
|
||||||
Dialog,
|
|
||||||
DialogActions,
|
|
||||||
DialogContent,
|
|
||||||
DialogTitle,
|
|
||||||
Grid,
|
|
||||||
InputAdornment,
|
|
||||||
TextField,
|
|
||||||
Typography
|
|
||||||
} from '@mui/material';
|
|
||||||
|
|
||||||
import { dialogStyle } from 'CustomTheme';
|
|
||||||
import type Schema from 'async-validator';
|
|
||||||
import type { ValidateFieldsError } from 'async-validator';
|
|
||||||
import { ValidatedTextField } from 'components';
|
|
||||||
import { useI18nContext } from 'i18n/i18n-react';
|
|
||||||
import { numberValue, updateValue } from 'utils';
|
|
||||||
import { validate } from 'validators';
|
|
||||||
|
|
||||||
import type { TemperatureSensor } from './types';
|
|
||||||
|
|
||||||
interface SensorsTemperatureDialogProps {
|
|
||||||
open: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onSave: (ts: TemperatureSensor) => void;
|
|
||||||
selectedItem: TemperatureSensor;
|
|
||||||
validator: Schema;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Constants
|
|
||||||
const OFFSET_MIN = -5;
|
|
||||||
const OFFSET_MAX = 5;
|
|
||||||
const OFFSET_STEP = 0.1;
|
|
||||||
const TEMP_UNIT = '°C';
|
|
||||||
|
|
||||||
const SensorsTemperatureDialog = ({
|
|
||||||
open,
|
|
||||||
onClose,
|
|
||||||
onSave,
|
|
||||||
selectedItem,
|
|
||||||
validator
|
|
||||||
}: SensorsTemperatureDialogProps) => {
|
|
||||||
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]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
setFieldErrors(undefined);
|
|
||||||
setEditItem(selectedItem);
|
|
||||||
}
|
|
||||||
}, [open, selectedItem]);
|
|
||||||
|
|
||||||
const handleClose = useCallback(
|
|
||||||
(_event: React.SyntheticEvent, reason?: string) => {
|
|
||||||
if (reason !== 'backdropClick') {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[onClose]
|
|
||||||
);
|
|
||||||
|
|
||||||
const save = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
setFieldErrors(undefined);
|
|
||||||
await validate(validator, editItem);
|
|
||||||
onSave(editItem);
|
|
||||||
} 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>
|
|
||||||
<DialogContent dividers>
|
|
||||||
<Box color="warning.main" mb={2}>
|
|
||||||
<Typography variant="body2">
|
|
||||||
{LL.ID_OF(LL.SENSOR(0))}: {editItem.id}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Grid container spacing={2}>
|
|
||||||
<Grid>
|
|
||||||
<ValidatedTextField
|
|
||||||
fieldErrors={fieldErrors ?? {}}
|
|
||||||
name="n"
|
|
||||||
label={LL.NAME(0)}
|
|
||||||
value={editItem.n}
|
|
||||||
sx={{ width: '30ch' }}
|
|
||||||
onChange={updateFormValue}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid>
|
|
||||||
<TextField
|
|
||||||
name="o"
|
|
||||||
label={LL.OFFSET()}
|
|
||||||
value={offsetValue}
|
|
||||||
sx={{ width: '11ch' }}
|
|
||||||
type="number"
|
|
||||||
variant="outlined"
|
|
||||||
onChange={updateFormValue}
|
|
||||||
slotProps={slotProps}
|
|
||||||
/>
|
|
||||||
</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
|
|
||||||
startIcon={<CancelIcon />}
|
|
||||||
variant="outlined"
|
|
||||||
onClick={onClose}
|
|
||||||
color="secondary"
|
|
||||||
>
|
|
||||||
{LL.CANCEL()}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
startIcon={<DoneIcon />}
|
|
||||||
variant="outlined"
|
|
||||||
onClick={save}
|
|
||||||
color="primary"
|
|
||||||
>
|
|
||||||
{LL.UPDATE()}
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SensorsTemperatureDialog;
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
import { memo, useCallback, useContext } from 'react';
|
|
||||||
|
|
||||||
import PersonIcon from '@mui/icons-material/Person';
|
|
||||||
import {
|
|
||||||
Avatar,
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Divider,
|
|
||||||
List,
|
|
||||||
ListItem,
|
|
||||||
ListItemText,
|
|
||||||
Typography
|
|
||||||
} from '@mui/material';
|
|
||||||
|
|
||||||
import { AuthenticatedContext } from '@/contexts/authentication';
|
|
||||||
import { SectionContent, useLayoutTitle } from 'components';
|
|
||||||
import { LanguageSelector } from 'components/inputs';
|
|
||||||
import { useI18nContext } from 'i18n/i18n-react';
|
|
||||||
|
|
||||||
const UserProfileComponent = () => {
|
|
||||||
const { LL } = useI18nContext();
|
|
||||||
const { me, signOut } = useContext(AuthenticatedContext);
|
|
||||||
|
|
||||||
useLayoutTitle(LL.USER_PROFILE());
|
|
||||||
|
|
||||||
const handleSignOut = useCallback(() => {
|
|
||||||
signOut(true);
|
|
||||||
}, [signOut]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SectionContent>
|
|
||||||
<List sx={{ flexGrow: 1 }}>
|
|
||||||
<ListItem disablePadding>
|
|
||||||
<Avatar sx={{ bgcolor: '#9e9e9e', color: 'white' }}>
|
|
||||||
<PersonIcon />
|
|
||||||
</Avatar>
|
|
||||||
<ListItemText
|
|
||||||
sx={{ pl: 2, color: '#2196f3' }}
|
|
||||||
primary={me.username}
|
|
||||||
secondary={'(' + (me.admin ? LL.ADMINISTRATOR() : LL.GUEST()) + ')'}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
</List>
|
|
||||||
<Box mt={2} mb={2} display="flex" alignItems="center">
|
|
||||||
<Typography mr={2} variant="body1" align="center">
|
|
||||||
{LL.LANGUAGE()}:
|
|
||||||
</Typography>
|
|
||||||
<LanguageSelector />
|
|
||||||
</Box>
|
|
||||||
<Divider />
|
|
||||||
<Button
|
|
||||||
sx={{ mt: 2 }}
|
|
||||||
variant="outlined"
|
|
||||||
color="primary"
|
|
||||||
onClick={handleSignOut}
|
|
||||||
>
|
|
||||||
{LL.SIGN_OUT()}
|
|
||||||
</Button>
|
|
||||||
</SectionContent>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const UserProfile = memo(UserProfileComponent);
|
|
||||||
|
|
||||||
export default UserProfile;
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
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 parts: string[] = [];
|
|
||||||
if (days) {
|
|
||||||
parts.push(LL.NUM_DAYS({ num: days }));
|
|
||||||
}
|
|
||||||
if (hours) {
|
|
||||||
parts.push(LL.NUM_HOURS({ num: hours }));
|
|
||||||
}
|
|
||||||
if (minutes) {
|
|
||||||
parts.push(LL.NUM_MINUTES({ num: minutes }));
|
|
||||||
}
|
|
||||||
|
|
||||||
return parts.join(' ');
|
|
||||||
};
|
|
||||||
|
|
||||||
export function formatValue(
|
|
||||||
LL: TranslationFunctions,
|
|
||||||
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])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle numeric values
|
|
||||||
switch (uom) {
|
|
||||||
case DeviceValueUOM.HOURS:
|
|
||||||
return value ? formatDurationMin(LL, value * 60) : LL.NUM_HOURS({ num: 0 });
|
|
||||||
case DeviceValueUOM.MINUTES:
|
|
||||||
return value ? formatDurationMin(LL, value) : LL.NUM_MINUTES({ num: 0 });
|
|
||||||
case DeviceValueUOM.SECONDS:
|
|
||||||
return LL.NUM_SECONDS({ num: value });
|
|
||||||
case DeviceValueUOM.NONE:
|
|
||||||
return numberFormatter.format(value);
|
|
||||||
case DeviceValueUOM.DEGREES:
|
|
||||||
case DeviceValueUOM.DEGREES_R:
|
|
||||||
case DeviceValueUOM.FAHRENHEIT:
|
|
||||||
return numberFormatterWithDecimal.format(value) + ' ' + DeviceValueUOM_s[uom];
|
|
||||||
default:
|
|
||||||
return numberFormatter.format(value) + ' ' + DeviceValueUOM_s[uom];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,340 +0,0 @@
|
|||||||
import Schema from 'async-validator';
|
|
||||||
import type { InternalRuleItem } from 'async-validator';
|
|
||||||
import { IP_OR_HOSTNAME_VALIDATOR } from 'validators/shared';
|
|
||||||
|
|
||||||
import type {
|
|
||||||
AnalogSensor,
|
|
||||||
DeviceValue,
|
|
||||||
EntityItem,
|
|
||||||
ScheduleItem,
|
|
||||||
Settings,
|
|
||||||
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: 3600,
|
|
||||||
SHOWER_MIN_DURATION_MIN: 10,
|
|
||||||
SHOWER_MIN_DURATION_MAX: 360,
|
|
||||||
SHOWER_ALERT_TRIGGER_MIN: 1,
|
|
||||||
SHOWER_ALERT_TRIGGER_MAX: 20,
|
|
||||||
SHOWER_ALERT_COLDSHOT_MIN: 1,
|
|
||||||
SHOWER_ALERT_COLDSHOT_MAX: 10,
|
|
||||||
REMOTE_TIMEOUT_MIN: 1,
|
|
||||||
REMOTE_TIMEOUT_MAX: 240,
|
|
||||||
OFFSET_MIN: 0,
|
|
||||||
OFFSET_MAX: 255,
|
|
||||||
COMMAND_MIN: 1,
|
|
||||||
COMMAND_MAX: 300,
|
|
||||||
NAME_MAX_LENGTH: 19,
|
|
||||||
HEX_BASE: 16
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
type ValidationRules = Array<{
|
|
||||||
required?: boolean;
|
|
||||||
message?: string;
|
|
||||||
[key: string]: unknown;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
export const createSettingsValidator = (settings: Settings) => {
|
|
||||||
const schema: Record<string, ValidationRules> = {};
|
|
||||||
|
|
||||||
// Syslog validations
|
|
||||||
if (settings.syslog_enabled) {
|
|
||||||
schema.syslog_host = [
|
|
||||||
{ required: true, message: 'Host is required' },
|
|
||||||
IP_OR_HOSTNAME_VALIDATOR
|
|
||||||
];
|
|
||||||
schema.syslog_port = [
|
|
||||||
{ required: true, message: 'Port is required' },
|
|
||||||
{
|
|
||||||
type: 'number',
|
|
||||||
min: VALIDATION_LIMITS.PORT_MIN,
|
|
||||||
max: VALIDATION_LIMITS.PORT_MAX,
|
|
||||||
message: 'Invalid Port'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
schema.syslog_mark_interval = [
|
|
||||||
{ required: true, message: 'Mark interval is required' },
|
|
||||||
{
|
|
||||||
type: 'number',
|
|
||||||
min: VALIDATION_LIMITS.SYSLOG_MARK_INTERVAL_MIN,
|
|
||||||
max: VALIDATION_LIMITS.SYSLOG_MARK_INTERVAL_MAX,
|
|
||||||
message: `Must be between ${VALIDATION_LIMITS.SYSLOG_MARK_INTERVAL_MIN} and ${VALIDATION_LIMITS.SYSLOG_MARK_INTERVAL_MAX}`
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Modbus validations
|
|
||||||
if (settings.modbus_enabled) {
|
|
||||||
schema.modbus_max_clients = [
|
|
||||||
{ required: true, message: 'Max clients is required' },
|
|
||||||
{
|
|
||||||
type: 'number',
|
|
||||||
min: VALIDATION_LIMITS.MODBUS_MAX_CLIENTS_MIN,
|
|
||||||
max: VALIDATION_LIMITS.MODBUS_MAX_CLIENTS_MAX,
|
|
||||||
message: 'Invalid number'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
schema.modbus_port = [
|
|
||||||
{ required: true, message: 'Port is required' },
|
|
||||||
{
|
|
||||||
type: 'number',
|
|
||||||
min: VALIDATION_LIMITS.PORT_MIN,
|
|
||||||
max: VALIDATION_LIMITS.PORT_MAX,
|
|
||||||
message: 'Invalid Port'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
schema.modbus_timeout = [
|
|
||||||
{ required: true, message: 'Timeout is required' },
|
|
||||||
{
|
|
||||||
type: 'number',
|
|
||||||
min: VALIDATION_LIMITS.MODBUS_TIMEOUT_MIN,
|
|
||||||
max: VALIDATION_LIMITS.MODBUS_TIMEOUT_MAX,
|
|
||||||
message: `Must be between ${VALIDATION_LIMITS.MODBUS_TIMEOUT_MIN} and ${VALIDATION_LIMITS.MODBUS_TIMEOUT_MAX}`
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shower timer validations
|
|
||||||
if (settings.shower_timer) {
|
|
||||||
schema.shower_min_duration = [
|
|
||||||
{
|
|
||||||
type: 'number',
|
|
||||||
min: VALIDATION_LIMITS.SHOWER_MIN_DURATION_MIN,
|
|
||||||
max: VALIDATION_LIMITS.SHOWER_MIN_DURATION_MAX,
|
|
||||||
message: `Time must be between ${VALIDATION_LIMITS.SHOWER_MIN_DURATION_MIN} and ${VALIDATION_LIMITS.SHOWER_MIN_DURATION_MAX} seconds`
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shower alert validations
|
|
||||||
if (settings.shower_alert) {
|
|
||||||
schema.shower_alert_trigger = [
|
|
||||||
{
|
|
||||||
type: 'number',
|
|
||||||
min: VALIDATION_LIMITS.SHOWER_ALERT_TRIGGER_MIN,
|
|
||||||
max: VALIDATION_LIMITS.SHOWER_ALERT_TRIGGER_MAX,
|
|
||||||
message: `Time must be between ${VALIDATION_LIMITS.SHOWER_ALERT_TRIGGER_MIN} and ${VALIDATION_LIMITS.SHOWER_ALERT_TRIGGER_MAX} minutes`
|
|
||||||
}
|
|
||||||
];
|
|
||||||
schema.shower_alert_coldshot = [
|
|
||||||
{
|
|
||||||
type: 'number',
|
|
||||||
min: VALIDATION_LIMITS.SHOWER_ALERT_COLDSHOT_MIN,
|
|
||||||
max: VALIDATION_LIMITS.SHOWER_ALERT_COLDSHOT_MAX,
|
|
||||||
message: `Time must be between ${VALIDATION_LIMITS.SHOWER_ALERT_COLDSHOT_MIN} and ${VALIDATION_LIMITS.SHOWER_ALERT_COLDSHOT_MAX} seconds`
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remote timeout validations
|
|
||||||
if (settings.remote_timeout_en) {
|
|
||||||
schema.remote_timeout = [
|
|
||||||
{
|
|
||||||
type: 'number',
|
|
||||||
min: VALIDATION_LIMITS.REMOTE_TIMEOUT_MIN,
|
|
||||||
max: VALIDATION_LIMITS.REMOTE_TIMEOUT_MAX,
|
|
||||||
message: `Timeout must be between ${VALIDATION_LIMITS.REMOTE_TIMEOUT_MIN} and ${VALIDATION_LIMITS.REMOTE_TIMEOUT_MAX} hours`
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Schema(schema);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Generic unique name validator factory
|
|
||||||
const createUniqueNameValidator = <T extends { name: string }>(
|
|
||||||
items: T[],
|
|
||||||
originalName?: 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())
|
|
||||||
) {
|
|
||||||
callback(ERROR_MESSAGES.NAME_DUPLICATE);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
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)],
|
|
||||||
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`
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
export const uniqueCustomNameValidator = (entity: EntityItem[], o_name?: string) =>
|
|
||||||
createUniqueNameValidator(entity, o_name);
|
|
||||||
|
|
||||||
const hexValidator = {
|
|
||||||
validator(
|
|
||||||
_rule: InternalRuleItem,
|
|
||||||
value: string,
|
|
||||||
callback: (error?: string) => void
|
|
||||||
) {
|
|
||||||
if (!value || Number.isNaN(Number.parseInt(value, VALIDATION_LIMITS.HEX_BASE))) {
|
|
||||||
callback(ERROR_MESSAGES.HEX_REQUIRED);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
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}`
|
|
||||||
}
|
|
||||||
],
|
|
||||||
factor: [{ required: true, message: 'is required' }]
|
|
||||||
});
|
|
||||||
|
|
||||||
export const uniqueTemperatureNameValidator = (
|
|
||||||
sensors: TemperatureSensor[],
|
|
||||||
o_name?: string
|
|
||||||
) => createUniqueFieldNameValidator(sensors, (s) => s.n, o_name);
|
|
||||||
|
|
||||||
export const temperatureSensorItemValidation = (
|
|
||||||
sensors: TemperatureSensor[],
|
|
||||||
sensor: TemperatureSensor
|
|
||||||
) =>
|
|
||||||
new Schema({
|
|
||||||
n: [NAME_PATTERN, uniqueTemperatureNameValidator(sensors, sensor.o_n)]
|
|
||||||
});
|
|
||||||
|
|
||||||
export const uniqueAnalogNameValidator = (
|
|
||||||
sensors: AnalogSensor[],
|
|
||||||
o_name?: string
|
|
||||||
) => createUniqueFieldNameValidator(sensors, (s) => s.n, o_name);
|
|
||||||
|
|
||||||
export const analogSensorItemValidation = (
|
|
||||||
sensors: AnalogSensor[],
|
|
||||||
sensor: AnalogSensor
|
|
||||||
) => {
|
|
||||||
return new Schema({
|
|
||||||
// name is required and must be unique
|
|
||||||
n: [
|
|
||||||
{ required: true, message: 'Name is required' },
|
|
||||||
NAME_PATTERN,
|
|
||||||
uniqueAnalogNameValidator(sensors, sensor.o_n)
|
|
||||||
]
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deviceValueItemValidation = (dv: DeviceValue) =>
|
|
||||||
new Schema({
|
|
||||||
v: [
|
|
||||||
{ required: true, message: 'Value is required' },
|
|
||||||
{
|
|
||||||
validator(
|
|
||||||
_rule: InternalRuleItem,
|
|
||||||
value: unknown,
|
|
||||||
callback: (error?: string) => void
|
|
||||||
) {
|
|
||||||
if (
|
|
||||||
typeof value === 'number' &&
|
|
||||||
dv.m !== undefined &&
|
|
||||||
dv.x !== undefined &&
|
|
||||||
(value < dv.m || value > dv.x)
|
|
||||||
) {
|
|
||||||
callback(ERROR_MESSAGES.VALUE_OUT_OF_RANGE);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
callback();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
import { useCallback, useMemo, useState } from 'react';
|
|
||||||
import { toast } from 'react-toastify';
|
|
||||||
|
|
||||||
import DownloadIcon from '@mui/icons-material/GetApp';
|
|
||||||
import { Box, Button, Grid, Typography } from '@mui/material';
|
|
||||||
|
|
||||||
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 {
|
|
||||||
FormLoader,
|
|
||||||
SectionContent,
|
|
||||||
SingleUpload,
|
|
||||||
useLayoutTitle
|
|
||||||
} from 'components';
|
|
||||||
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();
|
|
||||||
|
|
||||||
const [restarting, setRestarting] = useState<boolean>(false);
|
|
||||||
|
|
||||||
const { send: sendExportData } = useRequest(
|
|
||||||
(type: string) => callAction({ action: 'export', param: type }),
|
|
||||||
{
|
|
||||||
immediate: false
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.onSuccess((event) => {
|
|
||||||
saveFile(event.data, event.args[0], '.json');
|
|
||||||
toast.info(LL.DOWNLOAD_SUCCESSFUL());
|
|
||||||
})
|
|
||||||
.onError((error) => {
|
|
||||||
toast.error(String(error.error?.message || 'An error occurred'));
|
|
||||||
});
|
|
||||||
|
|
||||||
const { send: sendAPI } = useRequest((data: APIcall) => API(data), {
|
|
||||||
immediate: false
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data, send: loadData, error } = useRequest(SystemApi.readSystemStatus);
|
|
||||||
|
|
||||||
const doRestart = useCallback(async () => {
|
|
||||||
setRestarting(true);
|
|
||||||
try {
|
|
||||||
await sendAPI({ device: 'system', cmd: 'restart', id: 0 });
|
|
||||||
} catch (error) {
|
|
||||||
toast.error((error as Error).message);
|
|
||||||
setRestarting(false);
|
|
||||||
}
|
|
||||||
}, [sendAPI]);
|
|
||||||
|
|
||||||
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 handleDownload = useCallback(
|
|
||||||
(type: string) => () => {
|
|
||||||
void sendExportData(type);
|
|
||||||
},
|
|
||||||
[sendExportData]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (restarting) {
|
|
||||||
return <SystemMonitor />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!data) {
|
|
||||||
return (
|
|
||||||
<SectionContent>
|
|
||||||
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
|
|
||||||
</SectionContent>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const gridButtons = downloadButtons.filter((btn) => btn.isGridButton);
|
|
||||||
const standaloneButton = downloadButtons.find((btn) => !btn.isGridButton);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SectionContent>
|
|
||||||
<Typography sx={{ pb: 2 }} variant="h6" color="primary">
|
|
||||||
{LL.DOWNLOAD(0)}
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default DownloadUpload;
|
|
||||||
@@ -1,487 +0,0 @@
|
|||||||
import { useCallback, useMemo, useState } from 'react';
|
|
||||||
import { toast } from 'react-toastify';
|
|
||||||
|
|
||||||
import CancelIcon from '@mui/icons-material/Cancel';
|
|
||||||
import SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore';
|
|
||||||
import WarningIcon from '@mui/icons-material/Warning';
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Checkbox,
|
|
||||||
Grid,
|
|
||||||
InputAdornment,
|
|
||||||
MenuItem,
|
|
||||||
TextField,
|
|
||||||
Typography
|
|
||||||
} from '@mui/material';
|
|
||||||
|
|
||||||
import * as MqttApi from 'api/mqtt';
|
|
||||||
|
|
||||||
import type { ValidateFieldsError } from 'async-validator';
|
|
||||||
import {
|
|
||||||
BlockFormControlLabel,
|
|
||||||
BlockNavigation,
|
|
||||||
ButtonRow,
|
|
||||||
FormLoader,
|
|
||||||
SectionContent,
|
|
||||||
ValidatedPasswordField,
|
|
||||||
ValidatedTextField,
|
|
||||||
useLayoutTitle
|
|
||||||
} from 'components';
|
|
||||||
import { useI18nContext } from 'i18n/i18n-react';
|
|
||||||
import type { MqttSettingsType } from 'types';
|
|
||||||
import { numberValue, updateValueDirty, useRest } from 'utils';
|
|
||||||
import { createMqttSettingsValidator, validate } from 'validators';
|
|
||||||
|
|
||||||
import { callAction } from '../../api/app';
|
|
||||||
|
|
||||||
const MqttSettings = () => {
|
|
||||||
const {
|
|
||||||
loadData,
|
|
||||||
saving,
|
|
||||||
data,
|
|
||||||
updateDataValue,
|
|
||||||
origData,
|
|
||||||
dirtyFlags,
|
|
||||||
setDirtyFlags,
|
|
||||||
blocker,
|
|
||||||
saveData,
|
|
||||||
errorMessage
|
|
||||||
} = useRest<MqttSettingsType>({
|
|
||||||
read: MqttApi.readMqttSettings,
|
|
||||||
update: MqttApi.updateMqttSettings
|
|
||||||
});
|
|
||||||
|
|
||||||
const { LL } = useI18nContext();
|
|
||||||
useLayoutTitle('MQTT');
|
|
||||||
|
|
||||||
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
|
||||||
|
|
||||||
const sendResetMQTT = useCallback(() => {
|
|
||||||
void callAction({ action: 'resetMQTT' })
|
|
||||||
.then(() => {
|
|
||||||
toast.success('MQTT ' + LL.REFRESH() + ' successful');
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
toast.error(String(error.error?.message || 'An error occurred'));
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const updateFormValue = useMemo(
|
|
||||||
() =>
|
|
||||||
updateValueDirty(
|
|
||||||
origData as unknown as Record<string, unknown>,
|
|
||||||
dirtyFlags,
|
|
||||||
setDirtyFlags,
|
|
||||||
updateDataValue as (value: unknown) => void
|
|
||||||
),
|
|
||||||
[origData, dirtyFlags, setDirtyFlags, updateDataValue]
|
|
||||||
);
|
|
||||||
|
|
||||||
const SecondsInputProps = useMemo(
|
|
||||||
() => ({
|
|
||||||
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
|
|
||||||
}),
|
|
||||||
[LL]
|
|
||||||
);
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}, [data, saveData]);
|
|
||||||
|
|
||||||
const publishIntervalFields = useMemo(
|
|
||||||
() => [
|
|
||||||
{ name: 'publish_time_heartbeat', label: 'Heartbeat', validated: true },
|
|
||||||
{ name: 'publish_time_boiler', label: LL.MQTT_INT_BOILER(), validated: false },
|
|
||||||
{
|
|
||||||
name: 'publish_time_thermostat',
|
|
||||||
label: LL.MQTT_INT_THERMOSTATS(),
|
|
||||||
validated: false
|
|
||||||
},
|
|
||||||
{ name: 'publish_time_solar', label: LL.MQTT_INT_SOLAR(), validated: false },
|
|
||||||
{ name: 'publish_time_mixer', label: LL.MQTT_INT_MIXER(), validated: false },
|
|
||||||
{ name: 'publish_time_water', label: LL.MQTT_INT_WATER(), validated: false },
|
|
||||||
{ name: 'publish_time_sensor', label: LL.SENSORS(), validated: false },
|
|
||||||
{ name: 'publish_time_other', label: LL.DEFAULT(0), validated: false }
|
|
||||||
],
|
|
||||||
[LL]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!data) {
|
|
||||||
return (
|
|
||||||
<SectionContent>
|
|
||||||
{blocker ? <BlockNavigation blocker={blocker} /> : null}
|
|
||||||
<FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />
|
|
||||||
</SectionContent>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SectionContent>
|
|
||||||
{blocker ? <BlockNavigation blocker={blocker} /> : null}
|
|
||||||
<>
|
|
||||||
<Box display="flex" gap={2} mb={1}>
|
|
||||||
<BlockFormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
name="enabled"
|
|
||||||
checked={data.enabled}
|
|
||||||
onChange={updateFormValue}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label={LL.ENABLE_MQTT()}
|
|
||||||
/>
|
|
||||||
{data.enabled && (
|
|
||||||
<Button
|
|
||||||
startIcon={<SettingsBackupRestoreIcon />}
|
|
||||||
color="secondary"
|
|
||||||
variant="outlined"
|
|
||||||
onClick={sendResetMQTT}
|
|
||||||
>
|
|
||||||
{LL.REFRESH() + ' MQTT'}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
<Grid container spacing={2} rowSpacing={0}>
|
|
||||||
<Grid>
|
|
||||||
<ValidatedTextField
|
|
||||||
fieldErrors={fieldErrors ?? emptyFieldErrors}
|
|
||||||
name="host"
|
|
||||||
label={LL.ADDRESS_OF(LL.BROKER())}
|
|
||||||
multiline
|
|
||||||
variant="outlined"
|
|
||||||
value={data.host}
|
|
||||||
onChange={updateFormValue}
|
|
||||||
margin="normal"
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid>
|
|
||||||
<ValidatedTextField
|
|
||||||
fieldErrors={fieldErrors ?? emptyFieldErrors}
|
|
||||||
name="port"
|
|
||||||
label="Port"
|
|
||||||
variant="outlined"
|
|
||||||
value={numberValue(data.port)}
|
|
||||||
type="number"
|
|
||||||
onChange={updateFormValue}
|
|
||||||
margin="normal"
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid>
|
|
||||||
<ValidatedTextField
|
|
||||||
fieldErrors={fieldErrors ?? emptyFieldErrors}
|
|
||||||
name="base"
|
|
||||||
label={LL.BASE_TOPIC()}
|
|
||||||
variant="outlined"
|
|
||||||
value={data.base}
|
|
||||||
onChange={updateFormValue}
|
|
||||||
margin="normal"
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid>
|
|
||||||
<TextField
|
|
||||||
name="client_id"
|
|
||||||
label={`${LL.ID_OF(LL.CLIENT())} (${LL.OPTIONAL()})`}
|
|
||||||
variant="outlined"
|
|
||||||
value={data.client_id}
|
|
||||||
onChange={updateFormValue}
|
|
||||||
margin="normal"
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid>
|
|
||||||
<TextField
|
|
||||||
name="username"
|
|
||||||
label={LL.USERNAME(0)}
|
|
||||||
variant="outlined"
|
|
||||||
value={data.username}
|
|
||||||
onChange={updateFormValue}
|
|
||||||
margin="normal"
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid>
|
|
||||||
<ValidatedPasswordField
|
|
||||||
name="password"
|
|
||||||
label={LL.PASSWORD()}
|
|
||||||
variant="outlined"
|
|
||||||
value={data.password}
|
|
||||||
onChange={updateFormValue}
|
|
||||||
margin="normal"
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid>
|
|
||||||
<ValidatedTextField
|
|
||||||
fieldErrors={fieldErrors ?? emptyFieldErrors}
|
|
||||||
name="keep_alive"
|
|
||||||
label="Keep Alive"
|
|
||||||
slotProps={{
|
|
||||||
input: SecondsInputProps
|
|
||||||
}}
|
|
||||||
variant="outlined"
|
|
||||||
value={numberValue(data.keep_alive)}
|
|
||||||
type="number"
|
|
||||||
onChange={updateFormValue}
|
|
||||||
margin="normal"
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid>
|
|
||||||
<TextField
|
|
||||||
name="mqtt_qos"
|
|
||||||
label="QoS"
|
|
||||||
value={data.mqtt_qos}
|
|
||||||
variant="outlined"
|
|
||||||
onChange={updateFormValue}
|
|
||||||
margin="normal"
|
|
||||||
select
|
|
||||||
>
|
|
||||||
<MenuItem value={0}>0</MenuItem>
|
|
||||||
<MenuItem value={1}>1</MenuItem>
|
|
||||||
<MenuItem value={2}>2</MenuItem>
|
|
||||||
</TextField>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
{data.enableTLS !== undefined && (
|
|
||||||
<BlockFormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
name="enableTLS"
|
|
||||||
checked={data.enableTLS}
|
|
||||||
onChange={updateFormValue}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label={LL.ENABLE_TLS()}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{data.enableTLS === true && (
|
|
||||||
<ValidatedPasswordField
|
|
||||||
name="rootCA"
|
|
||||||
label={LL.CERT()}
|
|
||||||
variant="outlined"
|
|
||||||
value={data.rootCA}
|
|
||||||
sx={{ width: '50ch' }}
|
|
||||||
onChange={updateFormValue}
|
|
||||||
margin="normal"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<BlockFormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
name="clean_session"
|
|
||||||
checked={data.clean_session}
|
|
||||||
onChange={updateFormValue}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label={LL.MQTT_CLEAN_SESSION()}
|
|
||||||
/>
|
|
||||||
<BlockFormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
name="mqtt_retain"
|
|
||||||
checked={data.mqtt_retain}
|
|
||||||
onChange={updateFormValue}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label={LL.MQTT_RETAIN_FLAG()}
|
|
||||||
/>
|
|
||||||
<Typography sx={{ pt: 2 }} variant="h6" color="primary">
|
|
||||||
{LL.FORMATTING()}
|
|
||||||
</Typography>
|
|
||||||
<TextField
|
|
||||||
name="nested_format"
|
|
||||||
label={LL.MQTT_FORMAT()}
|
|
||||||
value={data.nested_format}
|
|
||||||
variant="outlined"
|
|
||||||
onChange={updateFormValue}
|
|
||||||
margin="normal"
|
|
||||||
select
|
|
||||||
>
|
|
||||||
<MenuItem value={1}>{LL.MQTT_NEST_1()}</MenuItem>
|
|
||||||
<MenuItem value={2}>{LL.MQTT_NEST_2()}</MenuItem>
|
|
||||||
</TextField>
|
|
||||||
<BlockFormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
name="send_response"
|
|
||||||
checked={data.send_response}
|
|
||||||
onChange={updateFormValue}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
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 && (
|
|
||||||
<Grid>
|
|
||||||
<BlockFormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
name="publish_single2cmd"
|
|
||||||
checked={data.publish_single2cmd}
|
|
||||||
onChange={updateFormValue}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label={LL.MQTT_PUBLISH_TEXT_2()}
|
|
||||||
/>
|
|
||||||
</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}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label={LL.MQTT_PUBLISH_TEXT_3()}
|
|
||||||
/>
|
|
||||||
</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.5)
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem value={4}>
|
|
||||||
{LL.MQTT_ENTITY_FORMAT_2()} (v3.5)
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem value={1}>
|
|
||||||
{LL.MQTT_ENTITY_FORMAT_1()} (latest)
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem value={2}>
|
|
||||||
{LL.MQTT_ENTITY_FORMAT_2()} (latest)
|
|
||||||
</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>
|
|
||||||
{dirtyFlags && dirtyFlags.length !== 0 && (
|
|
||||||
<ButtonRow>
|
|
||||||
<Button
|
|
||||||
startIcon={<CancelIcon />}
|
|
||||||
disabled={saving}
|
|
||||||
variant="outlined"
|
|
||||||
color="secondary"
|
|
||||||
type="submit"
|
|
||||||
onClick={loadData}
|
|
||||||
>
|
|
||||||
{LL.CANCEL()}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
startIcon={<WarningIcon color="warning" />}
|
|
||||||
disabled={saving}
|
|
||||||
variant="contained"
|
|
||||||
color="info"
|
|
||||||
type="submit"
|
|
||||||
onClick={validateAndSubmit}
|
|
||||||
>
|
|
||||||
{LL.APPLY_CHANGES(dirtyFlags.length)}
|
|
||||||
</Button>
|
|
||||||
</ButtonRow>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
</SectionContent>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default MqttSettings;
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user