mirror of
https://github.com/emsesp/EMS-ESP32.git
synced 2025-12-06 15:59:52 +03:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8284ec09f | ||
|
|
6e982acde8 | ||
|
|
8c94ce99b2 | ||
|
|
fc057d18c9 | ||
|
|
18e9b99413 | ||
|
|
a47e0e8266 | ||
|
|
f412ddc716 | ||
|
|
29110e96e5 | ||
|
|
b65866217a | ||
|
|
611e3b1243 | ||
|
|
2ca0a0c634 | ||
|
|
7eb1f061b7 | ||
|
|
50459a23fe | ||
|
|
5bf53c3389 | ||
|
|
4b7aa95be3 | ||
|
|
70943f5758 | ||
|
|
3bc280b817 | ||
|
|
62b15a5319 | ||
|
|
8dd18802d6 | ||
|
|
57a516a83a | ||
|
|
a57fdaa4b3 | ||
|
|
4841e42286 | ||
|
|
8c2d2b06ed | ||
|
|
38c8b1b7f0 | ||
|
|
6fb5933a02 | ||
|
|
c0944433be | ||
|
|
478e6362c9 | ||
|
|
4d6354db78 | ||
|
|
beab0f0c77 | ||
|
|
c17749bd22 | ||
|
|
2bad769c5c | ||
|
|
8ad89ca64b | ||
|
|
9244d8daec | ||
|
|
02d01334b2 |
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"name": "EMS-ESP Devcontainer",
|
||||
"image": "mcr.microsoft.com/devcontainers/typescript-node:1-22-bookworm",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/node:1": {},
|
||||
"ghcr.io/devcontainers-extra/features/pnpm:2": {},
|
||||
"ghcr.io/devcontainers/features/python:1": {},
|
||||
"ghcr.io/shyim/devcontainers-features/bun:0": {}
|
||||
},
|
||||
|
||||
"forwardPorts": [
|
||||
3000,
|
||||
3080
|
||||
],
|
||||
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
"postCreateCommand": "cd mock-api && pnpm install && cd .. && cd interface && pnpm install",
|
||||
|
||||
// Configure tool-specific properties.
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"platformio.platformio-ide"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
132
.github/CODE_OF_CONDUCT.md
vendored
132
.github/CODE_OF_CONDUCT.md
vendored
@@ -1,132 +0,0 @@
|
||||
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.0, available at
|
||||
[https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][v2.0].
|
||||
|
||||
Community Impact Guidelines were inspired by
|
||||
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available
|
||||
at [https://www.contributor-covenant.org/translations][translations].
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
[v2.0]: https://www.contributor-covenant.org/version/2/0/code_of_conduct.html
|
||||
[Mozilla CoC]: https://github.com/mozilla/diversity
|
||||
[FAQ]: https://www.contributor-covenant.org/faq
|
||||
[translations]: https://www.contributor-covenant.org/translations
|
||||
61
.github/ISSUE_TEMPLATE/bug_report.md
vendored
61
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -1,50 +1,35 @@
|
||||
---
|
||||
name: Problem Report/Change Request
|
||||
about: Create a Report to help us improve
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
<!-- Thanks for reporting an issue for this project. READ THIS FIRST:
|
||||
*Before creating a new issue please check that you have:*
|
||||
|
||||
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
|
||||
* *searched the existing [issues](https://github.com/emsesp/EMS-ESP32/issues) (both open and closed)*
|
||||
* *searched the [documentation help section](https://emsesp.github.io/docs)*
|
||||
|
||||
Please take a few minutes to complete the requested information below.
|
||||
*Completing this template will help developers and contributors to address the issue. Try to be as specific and extensive as possible. If the information provided is not enough the issue will likely be closed.*
|
||||
|
||||
-->
|
||||
*You can now remove this line and the above ones. Text in italic is meant to be replaced by your own words. If any of the sections below are not relevant to the issue (for instance, the screenshots) then you can delete them.*
|
||||
|
||||
### DESCRIPTION
|
||||
**Bug description**
|
||||
*A clear and concise description of what the bug is. Mention which EMS-ESP version you're using.*
|
||||
|
||||
_A clear and concise description of what the problem is or the change requested._
|
||||
**Steps to reproduce**
|
||||
*Steps to reproduce the behavior.*
|
||||
|
||||
### REQUESTED INFORMATION
|
||||
**Expected behavior**
|
||||
*A clear and concise description of what you expected to happen.*
|
||||
|
||||
_Make sure your have performed every step and checked the applicable boxes before submitting your issue. Thank you!_
|
||||
**Screenshots**
|
||||
*If applicable, add screenshots to help explain your problem.*
|
||||
|
||||
- [ ] Searched the issue in [issues](https://github.com/emsesp/EMS-ESP32/issues)
|
||||
- [ ] Searched the issue in [discussions](https://github.com/emsesp/EMS-ESP32/discussions)
|
||||
- [ ] Searched the issue in the [docs](https://docs.emsesp.org/Troubleshooting/)
|
||||
- [ ] Searched the issue in the [chat](https://discord.gg/3J3GgnzpyT)
|
||||
- [ ] Provide the System information in the area below, taken from `http://<IP>/api/system`
|
||||
**Device information**
|
||||
*Copy-paste here the information as it is outputted by the device. You can get this information by from http://ems-esp.local/api/system*
|
||||
|
||||
```json
|
||||
Paste System information here....
|
||||
|
||||
|
||||
```
|
||||
|
||||
### TO REPRODUCE
|
||||
|
||||
_Steps to reproduce the behavior:_
|
||||
|
||||
### EXPECTED BEHAVIOUR
|
||||
|
||||
_A clear and concise description of what you expected to happen._
|
||||
|
||||
### SCREENSHOTS
|
||||
|
||||
_If applicable, add screenshots to help explain your issue._
|
||||
|
||||
### ADDITIONAL CONTEXT
|
||||
|
||||
_Add any other context about the issue here._
|
||||
|
||||
**(Please remember to close the issue when it has been addressed)**
|
||||
**Additional context**
|
||||
*Add any other context about the problem here.*
|
||||
|
||||
11
.github/ISSUE_TEMPLATE/config.yml
vendored
11
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,11 +0,0 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: EMS-ESP Docs
|
||||
url: https://docs.emsesp.org
|
||||
about: All the information related to EMS-ESP.
|
||||
- name: EMS-ESP Discussions and Support
|
||||
url: https://github.com/emsesp/EMS-ESP32/discussions
|
||||
about: EMS-ESP usage Questions, Feature Requests and Projects.
|
||||
- name: EMS-ESP Users Chat
|
||||
url: https://discord.gg/3J3GgnzpyT
|
||||
about: Chat for feedback, questions and troubleshooting.
|
||||
26
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
26
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
*Before creating a new feature request please check that you have searched the existing [issues](https://github.com/emsesp/EMS-ESP32/issues) (both open and closed)*
|
||||
|
||||
*Completing this template will help developers and contributors evaluating the feature. If the information provided is not enough the issue will likely be closed.*
|
||||
|
||||
*You can now remove this line and the above ones. Text in italic is meant to be replaced by your own words. If any of the sections below are not relevant to the request then you can delete them.*
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
*A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]*
|
||||
|
||||
**Describe the solution you'd like**
|
||||
*A clear and concise description of what you want to happen.*
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
*A clear and concise description of any alternative solutions or features you've considered.*
|
||||
|
||||
**Additional context**
|
||||
*Add any other context or screenshots about the feature request here.*
|
||||
29
.github/ISSUE_TEMPLATE/questions---troubleshooting.md
vendored
Normal file
29
.github/ISSUE_TEMPLATE/questions---troubleshooting.md
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
---
|
||||
name: Questions & Troubleshooting
|
||||
about: Anything not a bug or feature request
|
||||
title: ''
|
||||
labels: question
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
*Before creating a new issue please check that you have:*
|
||||
|
||||
* *searched the existing [issues](https://github.com/emsesp/EMS-ESP32/issues) (both open and closed)*
|
||||
* *searched the [documentation help section](https://emsesp.github.io/docs)*
|
||||
|
||||
*Completing this template will help developers and contributors help you. Try to be as specific and extensive as possible. If the information provided is not enough the issue will likely be closed.*
|
||||
|
||||
*You can now remove this line and the above ones. Text in italic is meant to be replaced by your own words. If any of the sections below are not relevant to the issue (for instance, the screenshots) then you can delete them.*
|
||||
|
||||
**Question**
|
||||
*A clear and concise description of what the problem/doubt is.*
|
||||
|
||||
**Screenshots**
|
||||
*If applicable, add screenshots to help explain your problem.*
|
||||
|
||||
**Device information**
|
||||
*Copy-paste here the information as it is outputted by the device. You can get this information from http://ems-esp.local/api/system*
|
||||
|
||||
**Additional context**
|
||||
*Add any other context about the problem here.*
|
||||
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/*.*
|
||||
26
.github/workflows/github-releases-to-discord.yml
vendored
26
.github/workflows/github-releases-to-discord.yml
vendored
@@ -1,26 +0,0 @@
|
||||
name: 'Publish releases to discord'
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
github-releases-to-discord:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: GitHub Releases To Discord
|
||||
uses: SethCohen/github-releases-to-discord@v1.13.1
|
||||
with:
|
||||
webhook_url: ${{ secrets.WEBHOOK_URL }}
|
||||
color: '2105893'
|
||||
username: 'Release Changelog'
|
||||
avatar_url: 'https://cdn.discordapp.com/icons/816637840644505620/0b14718532d855c452903851b4f0c9a2.png'
|
||||
content: '||@everyone||'
|
||||
footer_title: 'Changelog'
|
||||
footer_icon_url: 'https://cdn.discordapp.com/icons/816637840644505620/0b14718532d855c452903851b4f0c9a2.png'
|
||||
footer_timestamp: true
|
||||
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
|
||||
57
.github/workflows/pre_release.yml
vendored
Normal file
57
.github/workflows/pre_release.yml
vendored
Normal file
@@ -0,0 +1,57 @@
|
||||
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@v2
|
||||
- uses: actions/setup-python@v2
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: '16'
|
||||
|
||||
- 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 "::set-output name=version::$version"
|
||||
|
||||
- name: Install PlatformIO
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -U platformio
|
||||
platformio upgrade
|
||||
platformio update
|
||||
|
||||
- name: Build WebUI
|
||||
run: |
|
||||
cd interface
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Build firmware
|
||||
run: |
|
||||
platformio run -e ci
|
||||
|
||||
- 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/*.*
|
||||
|
||||
64
.github/workflows/sonar_check.yml
vendored
64
.github/workflows/sonar_check.yml
vendored
@@ -1,33 +1,57 @@
|
||||
# see https://github.com/marketplace/actions/sonarcloud-scan-for-c-and-c#usage
|
||||
name: Sonar Check
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
paths:
|
||||
- 'src/**'
|
||||
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
jobs:
|
||||
build:
|
||||
name: Build and analyze
|
||||
if: github.repository == 'emsesp/EMS-ESP32'
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository_owner == 'emsesp'
|
||||
# if: github.repository == 'emsesp/EMS-ESP32'
|
||||
env:
|
||||
BUILD_WRAPPER_OUT_DIR: bw-output
|
||||
# https://binaries.sonarsource.com/?prefix=Distribution/sonar-scanner-cli/
|
||||
SONAR_SCANNER_VERSION: 4.7.0.2747
|
||||
SONAR_SERVER_URL: "https://sonarcloud.io"
|
||||
BUILD_WRAPPER_OUT_DIR: build_wrapper_output_directory
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install Build Wrapper
|
||||
uses: SonarSource/sonarqube-scan-action/install-build-wrapper@master
|
||||
- name: Run Build Wrapper
|
||||
run: build-wrapper-linux-x86-64 --out-dir ${{ env.BUILD_WRAPPER_OUT_DIR }} make all
|
||||
- name: SonarQube Scan
|
||||
uses: SonarSource/sonarqube-scan-action@master
|
||||
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
|
||||
- name: Set up JDK 11
|
||||
uses: actions/setup-java@v1
|
||||
with:
|
||||
java-version: 11
|
||||
- name: Cache SonarCloud packages
|
||||
uses: actions/cache@v1
|
||||
with:
|
||||
path: ~/.sonar/cache
|
||||
key: ${{ runner.os }}-sonar
|
||||
restore-keys: ${{ runner.os }}-sonar
|
||||
- name: Download and set up sonar-scanner
|
||||
env:
|
||||
SONAR_SCANNER_DOWNLOAD_URL: https://binaries.sonarsource.com/Distribution/sonar-scanner-cli/sonar-scanner-cli-${{ env.SONAR_SCANNER_VERSION }}-linux.zip
|
||||
run: |
|
||||
mkdir -p $HOME/.sonar
|
||||
curl -sSLo $HOME/.sonar/sonar-scanner.zip ${{ env.SONAR_SCANNER_DOWNLOAD_URL }}
|
||||
unzip -o $HOME/.sonar/sonar-scanner.zip -d $HOME/.sonar/
|
||||
echo "$HOME/.sonar/sonar-scanner-${{ env.SONAR_SCANNER_VERSION }}-linux/bin" >> $GITHUB_PATH
|
||||
- name: Download and set up build-wrapper
|
||||
env:
|
||||
BUILD_WRAPPER_DOWNLOAD_URL: ${{ env.SONAR_SERVER_URL }}/static/cpp/build-wrapper-linux-x86.zip
|
||||
run: |
|
||||
curl -sSLo $HOME/.sonar/build-wrapper-linux-x86.zip ${{ env.BUILD_WRAPPER_DOWNLOAD_URL }}
|
||||
unzip -o $HOME/.sonar/build-wrapper-linux-x86.zip -d $HOME/.sonar/
|
||||
echo "$HOME/.sonar/build-wrapper-linux-x86" >> $GITHUB_PATH
|
||||
- name: Run build-wrapper
|
||||
run: |
|
||||
make clean
|
||||
build-wrapper-linux-x86-64 --out-dir ${{ env.BUILD_WRAPPER_OUT_DIR }} make clean all
|
||||
- name: Run sonar-scanner
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
|
||||
run: |
|
||||
sonar-scanner
|
||||
|
||||
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"
|
||||
46
.github/workflows/tagged_release.yml
vendored
Normal file
46
.github/workflows/tagged_release.yml
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
name: "tagged-release"
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
jobs:
|
||||
tagged-release:
|
||||
|
||||
name: "Tagged Release"
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-python@v2
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: '16'
|
||||
|
||||
- name: Install PlatformIO
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -U platformio
|
||||
platformio upgrade
|
||||
platformio update
|
||||
|
||||
- name: Build WebUI
|
||||
run: |
|
||||
cd interface
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Build firmware
|
||||
run: |
|
||||
platformio run -e ci
|
||||
|
||||
- name: Release
|
||||
uses: "marvinpinto/action-automatic-releases@latest"
|
||||
with:
|
||||
repo_token: "${{ secrets.GITHUB_TOKEN }}"
|
||||
prerelease: false
|
||||
files: |
|
||||
CHANGELOG.md
|
||||
./build/firmware/*.*
|
||||
75
.github/workflows/test_release.yml
vendored
75
.github/workflows/test_release.yml
vendored
@@ -1,75 +0,0 @@
|
||||
name: 'Build test release'
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- 'test'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
pre-release:
|
||||
name: 'Build Test Release'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
|
||||
- name: Install python 3.13
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Install Node.js 24
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@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: Create GitHub Release
|
||||
id: 'automatic_releases'
|
||||
uses: emsesp/action-automatic-releases@v1.0.0
|
||||
with:
|
||||
repo_token: '${{ secrets.GITHUB_TOKEN }}'
|
||||
title: Test Build v${{steps.build_info.outputs.VERSION}}
|
||||
automatic_release_tag: 'test'
|
||||
prerelease: true
|
||||
files: |
|
||||
CHANGELOG_LATEST.md
|
||||
./build/firmware/*.*
|
||||
|
||||
68
.gitignore
vendored
68
.gitignore
vendored
@@ -1,79 +1,37 @@
|
||||
# vscode
|
||||
.vscode/c_cpp_properties.json
|
||||
.vscode/extensions.json
|
||||
.vscode/launch.json
|
||||
.vscode/settings.json
|
||||
.vscode
|
||||
.directory
|
||||
workspace.code-workspace
|
||||
|
||||
# c++ compiling
|
||||
# build
|
||||
build/
|
||||
.clang_complete
|
||||
.gcc-flags.json
|
||||
cppcheck.out.xml
|
||||
debug.log
|
||||
|
||||
# platformio
|
||||
.pio
|
||||
pio_local.ini
|
||||
/.VSCodeCounter
|
||||
|
||||
# OS specific
|
||||
.DS_Store
|
||||
*Thumbs.db
|
||||
|
||||
# web specific
|
||||
build/
|
||||
dist/
|
||||
# project specfic
|
||||
/scripts/stackdmp.txt
|
||||
emsesp
|
||||
/data/www
|
||||
/lib/framework/WWWData.h
|
||||
/interface/build
|
||||
node_modules
|
||||
/interface/.eslintcache
|
||||
stats.html
|
||||
*.sln
|
||||
*.sw?
|
||||
.pnp.*
|
||||
analyse.html
|
||||
interface/vite.config.ts.timestamp*
|
||||
*.local
|
||||
src/ESP32React/WWWData.h
|
||||
|
||||
# i18n generated files
|
||||
interface/src/i18n/i18n-react.tsx
|
||||
interface/src/i18n/i18n-types.ts
|
||||
interface/src/i18n/i18n-util.ts
|
||||
interface/src/i18n/i18n-util.sync.ts
|
||||
interface/src/i18n/i18n-util.async.ts
|
||||
|
||||
# scripts
|
||||
test.sh
|
||||
scripts/run.sh
|
||||
scripts/__pycache__
|
||||
scripts/stackdmp.txt
|
||||
.temp
|
||||
|
||||
# sonar
|
||||
.scannerwork/
|
||||
sonar/
|
||||
bw-output/
|
||||
|
||||
# standalone executable for testing
|
||||
emsesp
|
||||
interface/tsconfig.tsbuildinfo
|
||||
|
||||
# python virtual environment
|
||||
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
|
||||
build_wrapper_output_directory/
|
||||
|
||||
@@ -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
|
||||
13
.prettierrc
13
.prettierrc
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"plugins": ["@trivago/prettier-plugin-sort-imports"],
|
||||
"trailingComma": "none",
|
||||
"tabWidth": 2,
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"printWidth": 85,
|
||||
"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"
|
||||
}
|
||||
101
.vscode/settings.json
vendored
101
.vscode/settings.json
vendored
@@ -1,101 +0,0 @@
|
||||
{
|
||||
"search.exclude": {
|
||||
"**/.yarn": true,
|
||||
"**/.pnp.*": true
|
||||
},
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll": "explicit"
|
||||
},
|
||||
"eslint.validate": [
|
||||
"typescript"
|
||||
],
|
||||
"eslint.codeActionsOnSave.rules": null,
|
||||
"eslint.nodePath": "interface/.yarn/sdks",
|
||||
"eslint.workingDirectories": ["interface"],
|
||||
"prettier.prettierPath": "",
|
||||
"typescript.enablePromptUseWorkspaceTsdk": true,
|
||||
"files.associations": {
|
||||
"*.tsx": "typescriptreact",
|
||||
"*.tcc": "cpp",
|
||||
"optional": "cpp",
|
||||
"istream": "cpp",
|
||||
"ostream": "cpp",
|
||||
"ratio": "cpp",
|
||||
"system_error": "cpp",
|
||||
"array": "cpp",
|
||||
"functional": "cpp",
|
||||
"regex": "cpp",
|
||||
"tuple": "cpp",
|
||||
"type_traits": "cpp",
|
||||
"utility": "cpp",
|
||||
"string": "cpp",
|
||||
"string_view": "cpp",
|
||||
"atomic": "cpp",
|
||||
"bitset": "cpp",
|
||||
"cctype": "cpp",
|
||||
"chrono": "cpp",
|
||||
"clocale": "cpp",
|
||||
"cmath": "cpp",
|
||||
"condition_variable": "cpp",
|
||||
"cstdarg": "cpp",
|
||||
"cstddef": "cpp",
|
||||
"cstdint": "cpp",
|
||||
"cstdio": "cpp",
|
||||
"cstdlib": "cpp",
|
||||
"cstring": "cpp",
|
||||
"ctime": "cpp",
|
||||
"cwchar": "cpp",
|
||||
"cwctype": "cpp",
|
||||
"deque": "cpp",
|
||||
"list": "cpp",
|
||||
"unordered_map": "cpp",
|
||||
"unordered_set": "cpp",
|
||||
"vector": "cpp",
|
||||
"exception": "cpp",
|
||||
"algorithm": "cpp",
|
||||
"iterator": "cpp",
|
||||
"map": "cpp",
|
||||
"memory": "cpp",
|
||||
"memory_resource": "cpp",
|
||||
"numeric": "cpp",
|
||||
"random": "cpp",
|
||||
"set": "cpp",
|
||||
"fstream": "cpp",
|
||||
"initializer_list": "cpp",
|
||||
"iomanip": "cpp",
|
||||
"iosfwd": "cpp",
|
||||
"iostream": "cpp",
|
||||
"limits": "cpp",
|
||||
"mutex": "cpp",
|
||||
"new": "cpp",
|
||||
"sstream": "cpp",
|
||||
"stdexcept": "cpp",
|
||||
"streambuf": "cpp",
|
||||
"thread": "cpp",
|
||||
"cinttypes": "cpp",
|
||||
"typeinfo": "cpp"
|
||||
},
|
||||
"todo-tree.filtering.excludeGlobs": [
|
||||
"**/vendor/**",
|
||||
"**/node_modules/**",
|
||||
"**/dist/**",
|
||||
"**/bower_components/**",
|
||||
"**/build/**",
|
||||
"**/.vscode/**",
|
||||
"**/.github/**",
|
||||
"**/_output/**",
|
||||
"**/*.min.*",
|
||||
"**/*.map",
|
||||
"**/ArduinoJson/**"
|
||||
],
|
||||
"cSpell.enableFiletypes": [
|
||||
"ini",
|
||||
"makefile"
|
||||
],
|
||||
"typescript.preferences.preferTypeOnlyAutoImports": true,
|
||||
"sonarlint.pathToCompileCommands": "${workspaceFolder}/compile_commands.json",
|
||||
"sonarlint.connectedMode.project": {
|
||||
"connectionId": "emsesp",
|
||||
"projectKey": "emsesp_EMS-ESP32"
|
||||
}
|
||||
}
|
||||
431
CHANGELOG.md
431
CHANGELOG.md
@@ -5,427 +5,19 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [3.7.2] 22 March 2025
|
||||
|
||||
## Added
|
||||
|
||||
- change enum_heatingtype for remote control [#2268](https://github.com/emsesp/EMS-ESP32/issues/2268)
|
||||
- system service commands [#2182](https://github.com/emsesp/EMS-ESP32/issues/2182)
|
||||
- read 0x02A5 for thermostat CT200 [#2277](https://github.com/emsesp/EMS-ESP32/issues/2277)
|
||||
- add "duplicate" option to Custom Entities [#2266](https://github.com/emsesp/EMS-ESP32/discussion/2266)
|
||||
- mask bits for bool custom entities
|
||||
- thermostat `reduce threshold` [#2288](https://github.com/emsesp/EMS-ESP32/issues/2288)
|
||||
- thermostat `absent` [#1957](https://github.com/emsesp/EMS-ESP32/issues/1957)
|
||||
- CR11 thermostat [#2295](https://github.com/emsesp/EMS-ESP32/issues/2295)
|
||||
- Show ESP32's CPU temp in Hardware Status
|
||||
- vacation mode for the CR50 [#2403](https://github.com/emsesp/EMS-ESP32/issues/2403)
|
||||
- new Console command "set admin password" to set WebUI admin password
|
||||
- support nested conditions in scheduler [#2451](https://github.com/emsesp/EMS-ESP32/issues/2451)
|
||||
- allow mixed case in scheduler expressions [#2457](https://github.com/emsesp/EMS-ESP32/issues/2457)
|
||||
- Suprapur-o [#2470](https://github.com/emsesp/EMS-ESP32/issues/2470)
|
||||
# [3.4.4]
|
||||
|
||||
## 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)
|
||||
- Fix for new installations with filesystem not initializing
|
||||
|
||||
## 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)
|
||||
# [3.4.3]
|
||||
|
||||
## 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)
|
||||
- Fix for new installations with filesystem not initializing
|
||||
|
||||
## 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
|
||||
|
||||
## **IMPORTANT! BREAKING CHANGES**
|
||||
|
||||
Writeable Text entities have moved from type `sensor` to `text` in Home Assistant to make them also editable within an HA dashboard. Examples are `datetime`, `holidays`, `switchtime`, `vacations`, `maintenancedate`... You will need to manually remove any old discovery topics from your MQTT broker using an application like MQTT Explorer.
|
||||
|
||||
## Added
|
||||
|
||||
- humidity for ventilation devices
|
||||
- telegrams for RC100H, hc2, etc. (seen on discord, not tested)
|
||||
- names for BC400, GB192i.2, read temperatures for low loss header and heatblock [#1317](https://github.com/emsesp/EMS-ESP32/discussions/1317)
|
||||
- option for `forceheatingoff` [#1262](https://github.com/emsesp/EMS-ESP32/issues/1262)
|
||||
- remote thermostat emulation RC100H for RC3xx [#1278](https://github.com/emsesp/EMS-ESP32/discussions/1278)
|
||||
- shower_data MQTT payload contains the timestamp [#1329](https://github.com/emsesp/EMS-ESP32/issues/1329)
|
||||
- HA discovery for writeable text entities [#1337](https://github.com/emsesp/EMS-ESP32/pull/1377)
|
||||
- autodetect board_profile, store in nvs, add telnet command option, add E32V2
|
||||
- heat pump high res energy counters [#1348, #1349. #1350](https://github.com/emsesp/EMS-ESP32/issues/1348)
|
||||
- optional bssid in network settings
|
||||
- extension module EM100 [#1315](https://github.com/emsesp/EMS-ESP32/discussions/1315)
|
||||
- digital_out with new options for polarity and startup state
|
||||
- added 'system allvalues' command that dumps all the EMS device values, plus sensors and any custom entities
|
||||
|
||||
## Fixed
|
||||
|
||||
- remove command `remoteseltemp`, thermostat accept it only from remote thermostat
|
||||
- shower_data MQTT payload contains the timestamp [#1329](https://github.com/emsesp/EMS-ESP32/issues/1329)
|
||||
- fixed helper text in Web Device Entity dialog box for numerical ranges
|
||||
- MQTT base with paths not working in HA [#1393](https://github.com/emsesp/EMS-ESP32/issues/1393)
|
||||
- set/read thermostat mode for RC100-RC300, [#1440](https://github.com/emsesp/EMS-ESP32/issues/1440) [#1442](https://github.com/emsesp/EMS-ESP32/issues/1442)
|
||||
- some setting commands for ems-boiler have used wrong ems+ telegram in 3.6.3
|
||||
|
||||
## Changed
|
||||
|
||||
- update to platform 6.4.0, arduino 2.0.14 / idf 4.4.6
|
||||
- small changes for arduino 3.0.0 / idf 5.1 compatibility (not backward compatible to platform 6.3.2 and before)
|
||||
- AP start after 10 sec, stay until station/eth connected
|
||||
- tested wifi-all-channel-scan (3.6.3-dev4 a-e), removed again because of connect issues
|
||||
- mqtt disconnect stops queue
|
||||
|
||||
## [3.6.2] October 1 2023
|
||||
|
||||
## **IMPORTANT! BREAKING CHANGES**
|
||||
|
||||
## Added
|
||||
|
||||
- Power entities
|
||||
- Optional input of BSSID for AP connection
|
||||
- Return empty json if no entries in scheduler/custom/analogsensor/temperaturesensor
|
||||
|
||||
## Fixed
|
||||
|
||||
- Wifi full scan to get strongest AP
|
||||
- Add missing dhw tags
|
||||
- Sending a dash/- to the Reset command doesn't return an error [#1308](https://github.com/emsesp/EMS-ESP32/discussions/1308)
|
||||
|
||||
## Changed
|
||||
|
||||
- MQTT queue max 300 messages, check heap and maxAlloc
|
||||
- API call commands are logged as WARN in the log
|
||||
- Reset Command renamed to 'reset' in lowercase in EN
|
||||
|
||||
## [3.6.1] September 9 2023
|
||||
|
||||
## **IMPORTANT! BREAKING CHANGES**
|
||||
|
||||
- `shower_data` MQTT topic shows duration is seconds (was previously a full english sentence)
|
||||
|
||||
## Added
|
||||
|
||||
- Show WiFi rssi in Network Status Page, show quality as color
|
||||
|
||||
## Fixed
|
||||
|
||||
- Issue in espMqttClient causing a memory leak when MQTT broker is disconnected due to network unavailability [#1264](https://github.com/emsesp/EMS-ESP32/issues/1264)
|
||||
- Using MQTT enum values correctly formatted in MQTT Discovery [#1280](https://github.com/emsesp/EMS-ESP32/issues/1280)
|
||||
|
||||
## Changed
|
||||
|
||||
- MQTT free mem check set to 60 kb
|
||||
- Small cosmetic changes to Searching in Customization web page
|
||||
- Updated to espressif32@6.4.0
|
||||
|
||||
# [3.6.0] August 13 2023
|
||||
|
||||
## **IMPORTANT! BREAKING CHANGES**
|
||||
|
||||
There are breaking changes between 3.5.x and earlier versions of 3.6.0. Please read carefully before applying the update.
|
||||
|
||||
- The sensors have been renamed. `dallassensor` is now `temperaturesensor` in the MQTT topic and named `ts` in the Customizations file. Likewise `analogs` is now `analogsensor` in MQTT and called `as` in the Customizations file. If you have previous customizations you will need to manually update by downloading, changing the JSON file and uploading. It's also recommended cleaning up any old MQTT topics from your broker using an application like MQTTExplorer.
|
||||
|
||||
## Added
|
||||
|
||||
- Workaround for better Domoticz MQTT integration? [#904](https://github.com/emsesp/EMS-ESP32/issues/904)
|
||||
- Show MAC address without connecting to network enhancement [#933](https://github.com/emsesp/EMS-ESP32/issues/933)
|
||||
- Warn user in WebUI of unsaved changes [#911](https://github.com/emsesp/EMS-ESP32/issues/911)
|
||||
- Detect old Tado thermostat, device-id 0x19, no entities
|
||||
- Some more HM200 entities [#500](https://github.com/emsesp/EMS-ESP32/issues/500)
|
||||
- Added Scheduler [#701](https://github.com/emsesp/EMS-ESP32/issues/701)
|
||||
- Added Custom Entities read/write from EMS bus
|
||||
- Build S3 binary with github actions
|
||||
- Greenstar HIU [#1158](https://github.com/emsesp/EMS-ESP32/issues/1158)
|
||||
- AM200 code 10 [#1161](https://github.com/emsesp/EMS-ESP32/issues/1161)
|
||||
- Ventilation device (Logavent HRV176) [#1172](https://github.com/emsesp/EMS-ESP32/issues/1172)
|
||||
- Turn ETH off on wifi connect [#1167](https://github.com/emsesp/EMS-ESP32/issues/1167)
|
||||
- Support for multiple EMS-ESPs with HA [#1196](https://github.com/emsesp/EMS-ESP32/issues/1196)
|
||||
- Italian translation [#1199](https://github.com/emsesp/EMS-ESP32/issues/1199)
|
||||
- Turkish language support [#1076](https://github.com/emsesp/EMS-ESP32/issues/1076)
|
||||
- Buderus GB182 - HC1 mode change not work bug [#1193](https://github.com/emsesp/EMS-ESP32/issues/1193)
|
||||
- Minimal flow temperature enhancement [#1192](https://github.com/emsesp/EMS-ESP32/issues/1192)
|
||||
- Roomtemperature Switching Difference enhancement [#1191](https://github.com/emsesp/EMS-ESP32/issues/1191)
|
||||
- Dew Point Temperature Difference enhancement [#1190](https://github.com/emsesp/EMS-ESP32/issues/1190)
|
||||
- Control of heating circuit mode enhancement [#1187](https://github.com/emsesp/EMS-ESP32/issues/1187)
|
||||
- Warn user in WebUI of unsaved changes enhancement [#911](https://github.com/emsesp/EMS-ESP32/issues/911)
|
||||
- Create safebuild app to fit into factory partition to give ESP32 more flash memory enhancement [#608](https://github.com/emsesp/EMS-ESP32/issues/608)
|
||||
- Support ESP32 S2, C3 mini and S3 [#605](https://github.com/emsesp/EMS-ESP32/issues/605)
|
||||
- Support Buderus AM200 [#1161](https://github.com/emsesp/EMS-ESP32/issues/1161)
|
||||
- Custom telegram handler [#1155](https://github.com/emsesp/EMS-ESP32/issues/1155)
|
||||
- Added support for TLS in MQTT (ESP32-S3 only) [#1178](https://github.com/emsesp/EMS-ESP32/issues/1178)
|
||||
- Boardprofile BBQKees Gateway S3
|
||||
- Custom entity type RAW [#1212](https://github.com/emsesp/EMS-ESP32/discussions/1212)
|
||||
- API command response [#1212](https://github.com/emsesp/EMS-ESP32/discussions/1212)
|
||||
|
||||
## Fixed
|
||||
|
||||
- HA-discovery for analog sensor commands [#1035](https://github.com/emsesp/EMS-ESP32/issues/1035)
|
||||
- Enum order of RC3x nofrost mode
|
||||
- Heartbeat interval
|
||||
- Exhaust temperature always zero on GB125/MC110/RC310 bug [#1147](https://github.com/emsesp/EMS-ESP32/issues/1147)
|
||||
- thermostat modetype is not changing when mode changes (e.g. to night) bugSomething isn't working [#1098](https://github.com/emsesp/EMS-ESP32/issues/1098)
|
||||
- NTP: cant apply changed timezone [#1182](https://github.com/emsesp/EMS-ESP32/issues/1182)
|
||||
- Missing Status of VS1 for Buderus SM200 enhancement [#1034](https://github.com/emsesp/EMS-ESP32/issues/1034)
|
||||
- Allowed gpios for S3
|
||||
|
||||
## Changed
|
||||
|
||||
- Optional upgrade to platform-espressif32 6.3.0 (after 5.3.0) [#862](https://github.com/emsesp/EMS-ESP32/issues/862)
|
||||
- Use byte 3 for detection RC30 active heatingcircuit [#786](https://github.com/emsesp/EMS-ESP32/issues/786)
|
||||
- Write repeated selflowtemp if tx-queue is empty without verify [#954](https://github.com/emsesp/EMS-ESP32/issues/954)
|
||||
- HA discovery recreate after disconnect by device [#1067](https://github.com/emsesp/EMS-ESP32/issues/1067)
|
||||
- File upload: check flash size (overflow) instead of filesize
|
||||
- Improved HA Discovery so previous configs no longer need to be removed when starting [#1077](https://github.com/emsesp/EMS-ESP32/pull/1077) (thanks @pswid)
|
||||
- Enlarge UART-Stack to 2,5k
|
||||
- Retry timeout for Mqtt-QOS1/2 10seconds
|
||||
- Optimize WebUI rendering when using Dialog Boxes [#1116](https://github.com/emsesp/EMS-ESP32/issues/1116)
|
||||
- Optimize Web libraries to reduce bundle size (3.6.x) [#1112](https://github.com/emsesp/EMS-ESP32/issues/1112)
|
||||
- Use [espMqttClient](https://github.com/bertmelis/espMqttClient) with integrated queue [#1178](https://github.com/emsesp/EMS-ESP32/issues/1178)
|
||||
- Move Sensors from Web dashboard to it's own tab enhancement [#1170](https://github.com/emsesp/EMS-ESP32/issues/1170)
|
||||
- Optimize WebUI dashboard data [#1169](https://github.com/emsesp/EMS-ESP32/issues/1169)
|
||||
- Replace React core library with Preact to save on memory footprint
|
||||
- Response to `system/send` raw reads gives combined data for telegrams with more parts
|
||||
|
||||
# [3.5.0] February 6 2023
|
||||
|
||||
## **IMPORTANT! BREAKING CHANGES**
|
||||
|
||||
- When upgrading to v3.5 for the first time from v3.4 on a BBQKees Gateway board you will need to use the [EMS-EPS Flasher](https://github.com/emsesp/EMS-ESP-Flasher/releases) to correctly re-partition the flash. Make sure you backup the settings and customizations from the WebUI (System->Upload/Download) and restore after the upgrade.
|
||||
|
||||
## Added
|
||||
|
||||
- Translations in Web UI and all device entity names (DE, NL, SV, PL, NO, FR) [#22](https://github.com/emsesp/EMS-ESP32/issues/22)
|
||||
- Add support for Lolin C3 mini [#620](https://github.com/emsesp/EMS-ESP32/pull/620)
|
||||
- Add support for ESP32-S2 [#667](https://github.com/emsesp/EMS-ESP32/pull/667)
|
||||
- Add devices: Greenstar 30Ri boiler, Junkers FW500 thermostat, Buderus BC30 controller
|
||||
- Add program memory info
|
||||
- Add mqtt queue and connection infos
|
||||
- Adapt min/max if ems-value is not in this range
|
||||
- Add heat pump settings for inputs and limits [#600](https://github.com/emsesp/EMS-ESP32/issues/600)
|
||||
- Add hybrid heatpump [#500](https://github.com/emsesp/EMS-ESP32/issues/500)
|
||||
- Add translated tags
|
||||
- Add min/max to customization table [#686](https://github.com/emsesp/EMS-ESP32/issues/686)
|
||||
- Add MD5 check [#637](https://github.com/emsesp/EMS-ESP32/issues/637)
|
||||
- Add more bus-ids [#673](https://github.com/emsesp/EMS-ESP32/issues/673)
|
||||
- Use HA connectivity device class for Status, added boot time [#751](https://github.com/emsesp/EMS-ESP32/issues/751)
|
||||
- Add commands for analog sensors outputs
|
||||
- Support for multiple EMS-ESPs with MQTT and HA [[#759](https://github.com/emsesp/EMS-ESP32/issues/759)]
|
||||
- Settings for heatpump silent mode and additional heater [[#802](https://github.com/emsesp/EMS-ESP32/issues/802)] [[#803](https://github.com/emsesp/EMS-ESP32/issues/803)]
|
||||
- Zone module MZ100 [#826](https://github.com/emsesp/EMS-ESP32/issues/826)
|
||||
- Default MQTT hostname is blank [#829](https://github.com/emsesp/EMS-ESP32/issues/829)
|
||||
- wwCurFlow for ems+ devices [#829](https://github.com/emsesp/EMS-ESP32/issues/829)
|
||||
- Add Rego 3000, TR120RF thermostats [#917](https://github.com/emsesp/EMS-ESP32/issues/917)
|
||||
- Add config for ESP32-S3
|
||||
- Add heatpump silent mode and other entities [#896](https://github.com/emsesp/EMS-ESP32/issues/896)
|
||||
- Allow reboot to other partition (factory or asymetric OTA)
|
||||
- Blacklist entities to remove from memory [#891](https://github.com/emsesp/EMS-ESP32/issues/891)
|
||||
- Add boiler pump operating mode [#944](https://github.com/emsesp/EMS-ESP32/issues/944)
|
||||
|
||||
## Fixed
|
||||
|
||||
- Factory Reset not working [#628](https://github.com/emsesp/EMS-ESP32/issues/628)
|
||||
- Valid 4 byte values [#820](https://github.com/emsesp/EMS-ESP32/issues/820)
|
||||
- Commands for multiple thermostats [#826](https://github.com/emsesp/EMS-ESP32/issues/826)
|
||||
- API queries for multiple devices [#865](https://github.com/emsesp/EMS-ESP32/issues/865)
|
||||
- Console crash when using call with command `hcx` only. [#841](https://github.com/emsesp/EMS-ESP32/issues/841)
|
||||
- `heatingPump2Mod` was wrong, changed to absBurnPow [[#908](https://github.com/emsesp/EMS-ESP32/issues/908)
|
||||
- Rounding of web input values
|
||||
- Analog sensor with single gpio number [#915](https://github.com/emsesp/EMS-ESP32/issues/915)
|
||||
- HA dallas and analog configs: remove/rebuild on change [#888](https://github.com/emsesp/EMS-ESP32/issues/888)
|
||||
- Modes and set seltemp for RC30 and RC20 [#932](https://github.com/emsesp/EMS-ESP32/issues/932)
|
||||
|
||||
## Changed
|
||||
|
||||
- Discovery in HomeAssistant don't work with custom base topic. [#596](https://github.com/emsesp/EMS-ESP32/issues/596) Base topic containing `/` are changed to `_`
|
||||
- RF room temperature sensor are shown as thermostat
|
||||
- Render mqtt float json values with trailing zero
|
||||
- Removed flash strings, to increase available heap memory
|
||||
- Reload page after restart button is pressed
|
||||
- Analog/dallas values command as list like ems-devices
|
||||
- Analog/dallas HA-entities based on id
|
||||
- MQTT Base is a mandatory field. Removed MQTT topic length from settings
|
||||
- HA duration class for time entities [[#822](https://github.com/emsesp/EMS-ESP32/issues/822)
|
||||
- AM200 alternative heatsource as class heatsource [[#857](https://github.com/emsesp/EMS-ESP32/issues/857)
|
||||
|
||||
# [3.4.2] September 18 2022
|
||||
# [3.4.2]
|
||||
|
||||
## Added
|
||||
|
||||
@@ -444,11 +36,12 @@ 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)
|
||||
- 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
|
||||
|
||||
- Shorten "friendly names" in Home Assistant [#555](https://github.com/emsesp/EMS-ESP32/issues/555)
|
||||
|
||||
- platformio 2.3.0 (IDF 4, Arduino 2)
|
||||
- remove master-thermostat, support multiple thermostats
|
||||
- merge up- and download in webui [#577](https://github.com/emsesp/EMS-ESP32/issues/577)
|
||||
@@ -461,17 +54,13 @@ There are breaking changes between 3.5.x and earlier versions of 3.6.0. Please r
|
||||
|
||||
## Changed
|
||||
|
||||
- Controller data in web-ui only for IVT [#522](https://github.com/emsesp/EMS-ESP32/issues/522)
|
||||
- Rename hidden `climate` to a more explaining name [#523](https://github.com/emsesp/EMS-ESP32/issues/523)
|
||||
- Minor changes to the Customizations web page [#527](https://github.com/emsesp/EMS-ESP32/pull/527)
|
||||
|
||||
# [3.4.0] May 23 2022
|
||||
|
||||
## Added
|
||||
|
||||
- WebUI optimizations, updated look&feel and better performance [#124](https://github.com/emsesp/EMS-ESP32/issues/124)
|
||||
- Auto refresh of WebUI after successful firmware upload [#178](https://github.com/emsesp/EMS-ESP32/issues/178)
|
||||
- New Customization Service in WebUI. First feature is the ability to enable/disabled Entities (device values) from EMS devices [#206](https://github.com/emsesp/EMS-ESP32/issues/206)
|
||||
- New Customization Service in WebUI. First feature is the ability to enable/disabled Enitites (device values) from EMS devices [#206](https://github.com/emsesp/EMS-ESP32/issues/206)
|
||||
- Option to disable Telnet Console [#209](https://github.com/emsesp/EMS-ESP32/issues/209)
|
||||
- Added Hide SSID, Max Clients and Preferred Channel to Access Point
|
||||
- Merged in MichaelDvP's changes like Fahrenheit conversion, publish single (for IOBroker) and a few other critical optimizations
|
||||
@@ -571,7 +160,7 @@ There are breaking changes between 3.5.x and earlier versions of 3.6.0. Please r
|
||||
- Added pool data to telegrams 0x494 & 0x495 [#102](https://github.com/emsesp/EMS-ESP32/issues/102)
|
||||
- Add RC300 second summermode telegram [#108](https://github.com/emsesp/EMS-ESP32/issues/108)
|
||||
- Add support for the RC25 thermostat [#106](https://github.com/emsesp/EMS-ESP32/issues/106)
|
||||
- Add new command 'entities' for a device, e.g. <http://ems-esp/api/boiler/entities> to show the shortname, description and HA Entity name (if HA enabled) [#116](https://github.com/emsesp/EMS-ESP32/issues/116)
|
||||
- Add new command 'entities' for a device, e.g. http://ems-esp/api/boiler/entities to show the shortname, description and HA Entity name (if HA enabled) [#116](https://github.com/emsesp/EMS-ESP32/issues/116)
|
||||
- Support for Junkers program and remote (fb10/fb110) temperature
|
||||
- Home Assistant `state_class` attribute for Wh, kWh, W and KW [#129](https://github.com/emsesp/EMS-ESP32/issues/129)
|
||||
- Add current room influence for RC300 [#136](https://github.com/emsesp/EMS-ESP32/issues/136)
|
||||
@@ -793,4 +382,4 @@ There are breaking changes between 3.5.x and earlier versions of 3.6.0. Please r
|
||||
- some names of mqtt-tags like in v2.2.1
|
||||
- new ESP32 partition side to allow for smoother OTA and fallback
|
||||
- Network Gateway IP is optional (#682)emsesp/EMS-ESP
|
||||
- moved to a new GitHub repo <https://github.com/emsesp/EMS-ESP32>
|
||||
- moved to a new GitHub repo https://github.com/emsesp/EMS-ESP32
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
For more details go to [docs.emsesp.org](https://docs.emsesp.org/).
|
||||
|
||||
## [3.7.3]
|
||||
|
||||
## Added
|
||||
|
||||
- analogsensor types: NTC and RGB-Led
|
||||
- Flag for HMC310 [#2465](https://github.com/emsesp/EMS-ESP32/issues/2465)
|
||||
- boiler auxheatersource [#2489](https://github.com/emsesp/EMS-ESP32/discussions/2489)
|
||||
- thermostat last error for RC100/300 [#2501](https://github.com/emsesp/EMS-ESP32/issues/2501)
|
||||
- boiler 0xC6 telegram [#1963](https://github.com/emsesp/EMS-ESP32/issues/1963)
|
||||
- CS6800i changes [#2448](https://github.com/emsesp/EMS-ESP32/issues/2448), [#2449](https://github.com/emsesp/EMS-ESP32/issues/2449)
|
||||
- charging pump [#2544](https://github.com/emsesp/EMS-ESP32/issues/2544)
|
||||
- hybrid CSH5800iG [#2569](https://github.com/emsesp/EMS-ESP32/issues/2569)
|
||||
- add EMS Device details to Home Assistant MQTT Discovery
|
||||
- disinfection command [#2601](https://github.com/emsesp/EMS-ESP32/issues/2601)
|
||||
- added new board profile for upcoming BBQKees E32V2.2
|
||||
- set differential pressure entity in Mixer device
|
||||
- set set climate action cooling/heating in HA [#2583](https://github.com/emsesp/EMS-ESP32/issues/2583)
|
||||
- Internal sensors of E32V2_2
|
||||
- FW200 display options [#2610](https://github.com/emsesp/EMS-ESP32/discussions/2610)
|
||||
- CR11 mode settings OFF/MANUAL depends on selTemp [#2437](https://github.com/emsesp/EMS-ESP32/issues/2437)
|
||||
- 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
|
||||
|
||||
- dhw/switchtime [#2490](https://github.com/emsesp/EMS-ESP32/issues/2490)
|
||||
- switch to secure mqtt [#2492](https://github.com/emsesp/EMS-ESP32/issues/2492)
|
||||
- update link buttons [#2497](https://github.com/emsesp/EMS-ESP32/issues/2497)
|
||||
- refresh scheduler states [#2502](https://github.com/emsesp/EMS-ESP32/discussions/2502)
|
||||
- also rebuild HA config on mqtt connect for scheduler, custom and shower
|
||||
- FB100 controls the hc, not the master [#2510](https://github.com/emsesp/EMS-ESP32/issues/2510)
|
||||
- IPM DHW module, [#2524](https://github.com/emsesp/EMS-ESP32/issues/2524)
|
||||
- charge optimization [#2543](https://github.com/emsesp/EMS-ESP32/issues/2543)
|
||||
- shower active state retained, shows correctly in HA
|
||||
- MQTT Command Topic with slashes [#2571](https://github.com/emsesp/EMS-ESP32/issues/2571)
|
||||
- Add pulsed water meter input to V1.3 gateway with Lilygo S3 [#2550](https://github.com/emsesp/EMS-ESP32/issues/2550)
|
||||
- fix missing long 10-second press of Button to perform a factory reset
|
||||
- fix wwMaxPower on Junkers ZBS14 [#2609](https://github.com/emsesp/EMS-ESP32/issues/2609)
|
||||
- ventilation bypass state from telegram 0x55C [#1197](https://github.com/emsesp/EMS-ESP32/issues/1197)
|
||||
- set selflowtemp for ems+ boilers [#2641](https://github.com/emsesp/EMS-ESP32/discussions/2641)
|
||||
- syslog timestamp [#2704](https://github.com/emsesp/EMS-ESP32/issues/2704)
|
||||
- fixed FS format command [#2720](https://github.com/emsesp/EMS-ESP32/discussions/2720)
|
||||
- dhw priority setting to boiler and mixer, telegrams 0x2CC, 0x2CD, etc.
|
||||
|
||||
## Changed
|
||||
|
||||
- show console log with ISO date/time [#2533](https://github.com/emsesp/EMS-ESP32/discussions/2533)
|
||||
- remove ESP32 CPU temperature
|
||||
- updated core libraries like AsyncTCP, AsyncWebServer and Modbus
|
||||
- remove command `scan deep`
|
||||
- ignore repeated `forceheatingoff` commands [#2641](https://github.com/emsesp/EMS-ESP32/discussions/2641)
|
||||
- optimized web for better performance by adding lazy loading and caching
|
||||
- internal system analog sensors (core_voltage, supply_voltage and gateway_temperature) cannot be accidentally removed
|
||||
- double click button reconnects EMS-ESP to AP
|
||||
- place system message command in side scheduler loop to reduce stack memory usage by 2KB
|
||||
- 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)
|
||||
- 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.
|
||||
|
||||
@@ -69,7 +69,7 @@ Format: `<type>(<scope>): <subject>`
|
||||
|
||||
## Example
|
||||
|
||||
```text
|
||||
```
|
||||
feat: add hat wobble
|
||||
^--^ ^------------^
|
||||
| |
|
||||
@@ -96,7 +96,7 @@ References:
|
||||
|
||||
## Contributor License Agreement (CLA)
|
||||
|
||||
```text
|
||||
```
|
||||
By making a contribution to this project, I certify that:
|
||||
|
||||
(a) The contribution was created in whole or in part by me and I
|
||||
|
||||
133
Makefile
133
Makefile
@@ -1,40 +1,9 @@
|
||||
#
|
||||
# GNUMakefile for EMS-ESP
|
||||
# (c) 2020 Paul Derbyshire
|
||||
#
|
||||
|
||||
_mkfile_path := $(abspath $(lastword $(MAKEFILE_LIST)))
|
||||
I := $(patsubst %/,%,$(dir $(_mkfile_path)))
|
||||
|
||||
ifneq ($(words $(MAKECMDGOALS)),1)
|
||||
.DEFAULT_GOAL = all
|
||||
%:
|
||||
@$(MAKE) $@ --no-print-directory -rRf $(firstword $(MAKEFILE_LIST))
|
||||
else
|
||||
ifndef ECHO
|
||||
T := $(shell $(MAKE) $(MAKECMDGOALS) --no-print-directory \
|
||||
-nrRf $(firstword $(MAKEFILE_LIST)) \
|
||||
ECHO="COUNTTHIS" | grep -c "COUNTTHIS")
|
||||
N := x
|
||||
C = $(words $N)$(eval N := x $N)
|
||||
ECHO = python3 $(I)/scripts/echo_progress.py --stepno=$C --nsteps=$T
|
||||
endif
|
||||
|
||||
# Optimize parallel build configuration
|
||||
UNAME_S := $(shell uname -s)
|
||||
ifeq ($(UNAME_S),Linux)
|
||||
EXTRA_CPPFLAGS = -D LINUX
|
||||
JOBS ?= $(shell nproc)
|
||||
endif
|
||||
ifeq ($(UNAME_S),Darwin)
|
||||
EXTRA_CPPFLAGS = -D OSX -Wno-tautological-constant-out-of-range-compare
|
||||
JOBS ?= $(shell sysctl -n hw.ncpu)
|
||||
endif
|
||||
|
||||
# Set optimal parallel build settings
|
||||
MAKEFLAGS += -j$(JOBS) -l$(shell echo $$(($(JOBS) * 2)))
|
||||
|
||||
# $(info Number of jobs: $(JOBS))
|
||||
|
||||
NUMJOBS=${NUMJOBS:-" -j4 "}
|
||||
MAKEFLAGS+="j "
|
||||
#----------------------------------------------------------------------
|
||||
# Project Structure
|
||||
#----------------------------------------------------------------------
|
||||
@@ -44,29 +13,27 @@ MAKEFLAGS += -j$(JOBS) -l$(shell echo $$(($(JOBS) * 2)))
|
||||
# INCLUDES is a list of directories containing header files
|
||||
# LIBRARIES is a list of directories containing libraries, this must be the top level containing include and lib
|
||||
#----------------------------------------------------------------------
|
||||
|
||||
#TARGET := $(notdir $(CURDIR))
|
||||
TARGET := emsesp
|
||||
BUILD := build
|
||||
SOURCES := src/core src/devices src/web src/test lib_standalone lib/semver lib/espMqttClient/src lib/espMqttClient/src/* lib/ArduinoJson/src lib/uuid-common/src lib/uuid-console/src lib/uuid-log/src lib/PButton
|
||||
INCLUDES := src/core src/devices src/web src/test lib/* lib_standalone lib/semver lib/espMqttClient/src lib/espMqttClient/src/Transport lib/ArduinoJson/src lib/uuid-common/src lib/uuid-console/src lib/uuid-log/src lib/uuid-telnet/src lib/uuid-syslog/src
|
||||
LIBRARIES :=
|
||||
SOURCES := src src/* lib_standalone lib/uuid-common/src lib/uuid-console/src lib/uuid-log/src src/devices lib/ArduinoJson/src lib/PButton
|
||||
INCLUDES := src lib_standalone lib/ArduinoJson/src lib/uuid-common/src lib/uuid-console/src lib/uuid-log/src lib/uuid-telnet/src lib/uuid-syslog/src lib/* src/devices
|
||||
LIBRARIES :=
|
||||
|
||||
CPPCHECK = cppcheck
|
||||
CHECKFLAGS = -q --force --std=gnu++17
|
||||
CHECKFLAGS = -q --force --std=c++11
|
||||
|
||||
#----------------------------------------------------------------------
|
||||
# Languages Standard
|
||||
#----------------------------------------------------------------------
|
||||
C_STANDARD := -std=c17
|
||||
CXX_STANDARD := -std=gnu++17
|
||||
C_STANDARD := -std=c11
|
||||
CXX_STANDARD := -std=c++11
|
||||
|
||||
#----------------------------------------------------------------------
|
||||
# Defined Symbols
|
||||
#----------------------------------------------------------------------
|
||||
DEFINES += -DARDUINOJSON_ENABLE -DARDUINOJSON_ENABLE_ARDUINO_STRING -DARDUINOJSON_USE_DOUBLE=0
|
||||
DEFINES += -DEMSESP_STANDALONE -DEMSESP_TEST -DEMSESP_DEBUG -DEMC_RX_BUFFER_SIZE=1500
|
||||
DEFINES += $(ARGS)
|
||||
|
||||
DEFAULTS = -DEMSESP_DEFAULT_LOCALE=\"en\" -DEMSESP_DEFAULT_TX_MODE=8 -DEMSESP_DEFAULT_VERSION=\"3.7.3-dev\" -DEMSESP_DEFAULT_BOARD_PROFILE=\"S32S3\"
|
||||
DEFINES += -DFACTORY_WIFI_HOSTNAME=\"ems-esp\" -DARDUINOJSON_ENABLE_STD_STRING=1 -DARDUINOJSON_ENABLE_PROGMEM=1 -DARDUINOJSON_ENABLE_ARDUINO_STRING -DARDUINOJSON_USE_DOUBLE=0 -DEMSESP_DEBUG -DEMSESP_STANDALONE -DEMSESP_USE_SERIAL -DEMSESP_DEFAULT_BOARD_PROFILE=\"LOLIN\"
|
||||
|
||||
#----------------------------------------------------------------------
|
||||
# Sources & Files
|
||||
@@ -74,21 +41,16 @@ DEFAULTS = -DEMSESP_DEFAULT_LOCALE=\"en\" -DEMSESP_DEFAULT_TX_MODE=8 -DEMSESP_DE
|
||||
OUTPUT := $(CURDIR)/$(TARGET)
|
||||
SYMBOLS := $(CURDIR)/$(BUILD)/$(TARGET).out
|
||||
|
||||
# Optimize source discovery - use shell find for better performance
|
||||
CSOURCES := $(shell find $(SOURCES) -name "*.c" 2>/dev/null)
|
||||
CXXSOURCES := $(shell find $(SOURCES) -name "*.cpp" 2>/dev/null)
|
||||
CSOURCES := $(foreach dir,$(SOURCES),$(wildcard $(dir)/*.c))
|
||||
CXXSOURCES := $(foreach dir,$(SOURCES),$(wildcard $(dir)/*.cpp))
|
||||
|
||||
OBJS := $(patsubst %,$(BUILD)/%.o,$(basename $(CSOURCES)) $(basename $(CXXSOURCES)))
|
||||
DEPS := $(patsubst %,$(BUILD)/%.d,$(basename $(CSOURCES)) $(basename $(CXXSOURCES)))
|
||||
OBJS := $(patsubst %,$(BUILD)/%.o,$(basename $(CSOURCES)) $(basename $(CXXSOURCES)) )
|
||||
DEPS := $(patsubst %,$(BUILD)/%.d,$(basename $(CSOURCES)) $(basename $(CXXSOURCES)) )
|
||||
|
||||
# Optimize include path discovery
|
||||
INCLUDE_DIRS := $(shell find $(INCLUDES) -type d 2>/dev/null)
|
||||
LIBRARY_INCLUDES := $(shell find $(LIBRARIES) -name "include" -type d 2>/dev/null)
|
||||
INCLUDE += $(addprefix -I,$(INCLUDE_DIRS) $(LIBRARY_INCLUDES))
|
||||
INCLUDE += $(addprefix -I,$(foreach dir,$(INCLUDES), $(wildcard $(dir))))
|
||||
INCLUDE += $(addprefix -I,$(foreach dir,$(LIBRARIES),$(wildcard $(dir)/include)))
|
||||
|
||||
# Optimize library path discovery
|
||||
LIBRARY_DIRS := $(shell find $(LIBRARIES) -name "lib" -type d 2>/dev/null)
|
||||
LDLIBS += $(addprefix -L,$(LIBRARY_DIRS))
|
||||
LDLIBS += $(addprefix -L,$(foreach dir,$(LIBRARIES),$(wildcard $(dir)/lib)))
|
||||
|
||||
#----------------------------------------------------------------------
|
||||
# Compiler & Linker
|
||||
@@ -104,19 +66,17 @@ CXX := /usr/bin/g++
|
||||
# CXXFLAGS C++ Compiler Flags
|
||||
# LDFLAGS Linker Flags
|
||||
#----------------------------------------------------------------------
|
||||
CPPFLAGS += $(DEFINES) $(DEFAULTS) $(INCLUDE)
|
||||
CPPFLAGS += -ggdb -g3 -MMD
|
||||
CPPFLAGS += -flto=auto
|
||||
CPPFLAGS += -Wall -Wextra -Werror -Wswitch-enum
|
||||
CPPFLAGS += -Wno-unused-parameter -Wno-missing-braces -Wno-vla-cxx-extension
|
||||
CPPFLAGS += -ffunction-sections -fdata-sections -fno-exceptions -fno-rtti -fno-threadsafe-statics
|
||||
CPPFLAGS += -Os -DNDEBUG
|
||||
|
||||
CPPFLAGS += $(EXTRA_CPPFLAGS)
|
||||
CPPFLAGS += $(DEFINES) $(INCLUDE)
|
||||
CPPFLAGS += -ggdb
|
||||
CPPFLAGS += -g3
|
||||
CPPFLAGS += -Os
|
||||
|
||||
CFLAGS += $(CPPFLAGS)
|
||||
CXXFLAGS += $(CPPFLAGS)
|
||||
LDFLAGS =
|
||||
CFLAGS += -Wall
|
||||
CFLAGS += -Wextra
|
||||
CFLAGS += -Wno-unused-parameter
|
||||
|
||||
CXXFLAGS += $(CFLAGS) -MMD
|
||||
|
||||
#----------------------------------------------------------------------
|
||||
# Compiler & Linker Commands
|
||||
@@ -131,8 +91,7 @@ else
|
||||
LD := $(CXX)
|
||||
endif
|
||||
|
||||
# Dependency file generation
|
||||
DEPFLAGS += -MF $(BUILD)/$*.d -MT $@
|
||||
#DEPFLAGS += -MF $(BUILD)/$*.d
|
||||
|
||||
LINK.o = $(LD) $(LDFLAGS) $(LDLIBS) $^ -o $@
|
||||
COMPILE.c = $(CC) $(C_STANDARD) $(CFLAGS) $(DEPFLAGS) -c $< -o $@
|
||||
@@ -149,38 +108,30 @@ COMPILE.cpp = $(CXX) $(CXX_STANDARD) $(CXXFLAGS) $(DEPFLAGS) -c $< -o $@
|
||||
.SUFFIXES:
|
||||
.INTERMEDIATE:
|
||||
.PRECIOUS: $(OBJS) $(DEPS)
|
||||
.PHONY: all clean help cppcheck run
|
||||
|
||||
# Enable second expansion for more flexible rules
|
||||
.SECONDEXPANSION:
|
||||
.PHONY: all clean help
|
||||
|
||||
#----------------------------------------------------------------------
|
||||
# Targets
|
||||
#----------------------------------------------------------------------
|
||||
.PHONY: all
|
||||
.SILENT: $(OUTPUT)
|
||||
|
||||
all: $(OUTPUT)
|
||||
@$(ECHO) Build complete.
|
||||
|
||||
$(OUTPUT): $(OBJS)
|
||||
@mkdir -p $(@D)
|
||||
@$(ECHO) Linking $@
|
||||
$(LINK.o)
|
||||
|
||||
$(SYMBOLS.out)
|
||||
|
||||
$(BUILD)/%.o: %.c
|
||||
@mkdir -p $(@D)
|
||||
@$(ECHO) Compiling $@
|
||||
@$(COMPILE.c)
|
||||
$(COMPILE.c)
|
||||
|
||||
$(BUILD)/%.o: %.cpp
|
||||
@mkdir -p $(@D)
|
||||
@$(ECHO) Compiling $@
|
||||
@$(COMPILE.cpp)
|
||||
$(COMPILE.cpp)
|
||||
|
||||
$(BUILD)/%.o: %.s
|
||||
@mkdir -p $(@D)
|
||||
@$(COMPILE.s)
|
||||
$(COMPILE.s)
|
||||
|
||||
cppcheck: $(SOURCES)
|
||||
$(CPPCHECK) $(CHECKFLAGS) $^
|
||||
@@ -189,21 +140,11 @@ run: $(OUTPUT)
|
||||
@$<
|
||||
|
||||
.PHONY: clean
|
||||
|
||||
clean:
|
||||
@$(RM) -rf $(BUILD) $(OUTPUT)
|
||||
|
||||
help:
|
||||
@echo "Available targets:"
|
||||
@echo " all - Build the project (default)"
|
||||
@echo " run - Build and run the executable"
|
||||
@echo " clean - Remove build artifacts"
|
||||
@echo " cppcheck - Run static analysis"
|
||||
@echo " help - Show this help message"
|
||||
@echo ""
|
||||
@echo "Output: $(OUTPUT)"
|
||||
@echo "Jobs: $(JOBS)"
|
||||
@echo available targets: all run clean
|
||||
@echo $(OUTPUT)
|
||||
|
||||
-include $(DEPS)
|
||||
|
||||
endif
|
||||
|
||||
192
README.md
192
README.md
@@ -1,119 +1,129 @@
|
||||
<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>
|
||||
**EMS-ESP** is an open-source firmware for the Espressif ESP8266 and ESP32 microcontroller that communicates with **EMS** (Energy Management System) based equipment from manufacturers like Bosch, Buderus, Nefit, Junkers, Worcester and Sieger.
|
||||
|
||||
<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>
|
||||
This project is the specifically for the ESP32. Compared with the previous ESP8266 (version 2) release it has the following enhancements:
|
||||
|
||||
- Ethernet Support
|
||||
- Pre-configured circuit board layouts
|
||||
- Supports writing EMS values directly from within Web UI
|
||||
- Mock API server for faster offline development and testing
|
||||
- Improved API and MQTT commands
|
||||
- Improvements to Dallas temperature sensors
|
||||
- Embedded log tracing in the Web UI
|
||||
|
||||
[](https://github.com/emsesp/EMS-ESP32/blob/main/CHANGELOG.md)
|
||||
[](https://github.com/emsesp/EMS-ESP32/commits/main)
|
||||
[](LICENSE)
|
||||
[](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://discord.gg/3J3GgnzpyT)
|
||||
|
||||
If you like **EMS-ESP**, please give it a star, or fork it and contribute!
|
||||
|
||||
[](https://github.com/emsesp/EMS-ESP32/stargazers)
|
||||
[](https://github.com/emsesp/EMS-ESP32/network)
|
||||
[](https://github.com/emsesp/EMS-ES32P/network)
|
||||
[](https://www.paypal.com/paypalme/prderbyshire/2)
|
||||
|
||||
**EMS-ESP** is an open-source firmware for the Espressif ESP32 microcontroller to communicate with **EMS** (Energy Management System) compatible equipment from manufacturers such as Bosch, Buderus, Nefit, Junkers, Worcester, Sieger, elm.leblanc and iVT.
|
||||
Note, EMS-ESP requires a small hardware circuit that can convert the EMS bus data to be read by the microcontroller. These can be ordered at <https://bbqkees-electronics.nl> or contact the contributors that can provide the schematic and designs.
|
||||
|
||||
It requires a small circuit to interface with the EMS bus which can be purchased from <https://bbqkees-electronics.nl> or custom built.
|
||||
<img src="media/gateway-integration.jpg" width=40%>
|
||||
|
||||
## 📦 **Key Features**
|
||||
---
|
||||
|
||||
- Compatible with EMS, EMS+, EMS2, EMS Plus, Logamatic EMS, Junkers 2-wire, Heatronic 3 and 4
|
||||
- Supporting over 120 different EMS compatible devices such as thermostats, boilers, heat pumps, mixing units, solar modules, connect modules, ventilation units, switches and more
|
||||
- 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
|
||||
# **Features**
|
||||
|
||||
## 🚀 **Installing**
|
||||
- A multi-user 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 and Domoticz 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 [100 EMS devices](https://emsesp.github.io/docs/#/Supported-EMS-Devices) (boilers, thermostats, solar modules, mixer modules, heat pumps, gateways)
|
||||
|
||||
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.
|
||||
## **Demo**
|
||||
|
||||
## 📋 **Documentation**
|
||||
See a demo [here](https://ems-esp.derbyshire.nl). Log in with any username/password.
|
||||
|
||||
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.
|
||||
# **Screenshots**
|
||||
|
||||
## 💬 **Getting Support**
|
||||
|
||||
To chat with the community reach out on our [Discord Server](https://discord.gg/3J3GgnzpyT).
|
||||
|
||||
If you find an issue or have a request, see [how to request support](https://docs.emsesp.org/Support/) on how to submit a bug report or feature request.
|
||||
|
||||
## 🎥 **Live Demo**
|
||||
|
||||
For a live demo go to [demo.emsesp.org](https://demo.emsesp.org). Pick a language from the sign on page and log in with any username or password. Note not all features are operational as it's based on static data.
|
||||
|
||||
## 💖 **Contributors**
|
||||
|
||||
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).
|
||||
|
||||
If you like **EMS-ESP**, please give it a ✨ on GitHub, or even better fork it and contribute. You can also offer a small donation. This is an open-source project maintained by volunteers, and your support is greatly appreciated.
|
||||
|
||||
## 📦 **Building**
|
||||
|
||||
To build the web interface only, run `platformio run -e build_webUI`. This will install the necessary dependencies and build the web interface and also create the embedded code used need to build the firmware. You can run the web interface locally by going to the `interface` directory and running `pnpm standalone`.
|
||||
|
||||
To build the firmware, run `platformio run`. This will build the firmware for all ESP32 modules and place the binaries in the `build/firmware` folder. If you want to configure the build for a single platform create a local `pio_local.ni` file in the root directory (see example in `pio_local.ini_example`).
|
||||
|
||||
## 📢 **Libraries used**
|
||||
|
||||
- [esp8266-react](https://github.com/rjwats/esp8266-react) originally by @rjwats for the core framework that provides the Web UI, which has been heavily modified
|
||||
- [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
|
||||
|
||||
## **Screenshots**
|
||||
|
||||
### Web Interface
|
||||
## Web Interface
|
||||
|
||||
| | |
|
||||
| ---------------------------------- | -------------------------------- |
|
||||
|  |  |
|
||||
|  |  |
|
||||
|  |  |
|
||||
| <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/console.png" width=80% height=80%>
|
||||
|
||||
### Home Assistant
|
||||
## In Home Assistant
|
||||
|
||||

|
||||
<img src="media/ha_lovelace.png" width=80% height=80%>
|
||||
|
||||
# **Installing**
|
||||
|
||||
Refer to the [official documentation](https://emsesp.github.io/docs) to how to install the firmware and configure it. The documentation is being constantly updated as new features and settings are added.
|
||||
|
||||
You can choose to use an pre-built firmware image or compile the code yourself:
|
||||
|
||||
- [Uploading a pre-built firmware build](https://emsesp.github.io/docs/#/Uploading-firmware)
|
||||
- [Building the firmware from source code and flashing manually](https://emsesp.github.io/docs/#/Building-firmware)
|
||||
|
||||
# **Support Information**
|
||||
|
||||
If you're looking for support on **EMS-ESP** there are some options available:
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Official EMS-ESP Documentation](https://emsesp.github.io/docs): For information on how to build and upload the firmware
|
||||
- [FAQ and Troubleshooting](https://emsesp.github.io/docs/#/Troubleshooting): For information on common problems and solutions. See also [BBQKees's wiki](https://bbqkees-electronics.nl/wiki/gateway/troubleshooting.html)
|
||||
|
||||
## Support Community
|
||||
|
||||
- [Discord Server](https://discord.gg/3J3GgnzpyT): For support, troubleshooting and general questions. You have better chances to get fast answers from members of the community
|
||||
- [Search in Issues](https://github.com/emsesp/EMS-ESP32/issues): You might find an answer to your question by searching current or closed issues
|
||||
|
||||
## Developer's Community
|
||||
|
||||
- [Bug Report](https://github.com/emsesp/EMS-ESP32/issues/new?template=bug_report.md): For reporting Bugs
|
||||
- [Feature Request](https://github.com/emsesp/EMS-ESP32/issues/new?template=feature_request.md): For requesting features/functions
|
||||
- [Troubleshooting](https://github.com/emsesp/EMS-ESP32/issues/new?template=questions---troubleshooting.md): As a last resort, you can open new _Troubleshooting & Question_ issue on GitHub if the solution could not be found using the other channels. Just remember: the more info you provide the more chances you'll have to get an accurate answer
|
||||
|
||||
# **Contributors ✨**
|
||||
|
||||
EMS-ESP is a project originally created and owned by [proddy](https://github.com/proddy). Key contributors are:
|
||||
|
||||
<!-- prettier-ignore-start -->
|
||||
<!-- markdownlint-disable -->
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="https://github.com/MichaelDvP"><img src="https://avatars.githubusercontent.com/u/59284019?v=3?s=100" width="100px;" alt=""/><br /><sub><b>MichaelDvP</b></sub></a><br /></a> <a href="https://github.com/emsesp/EMS-ESP/commits?author=MichaelDvP" title="v2 Commits">v2</a>
|
||||
<a href="https://github.com/emsesp/EMS-ESP32/commits?author=MichaelDvP" title="v3 Commits">v3</a>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
</table>
|
||||
<!-- markdownlint-restore -->
|
||||
<!-- prettier-ignore-end -->
|
||||
|
||||
You can also contribute to EMS-ESP by
|
||||
|
||||
- providing Pull Requests (Features, Fixes, suggestions)
|
||||
- testing new released features and report issues on your EMS equipment
|
||||
- contributing to missing [Documentation](https://emsesp.github.io/docs)
|
||||
|
||||
# **Libraries used**
|
||||
|
||||
- [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 JSON
|
||||
- [AsyncMqttClient](https://github.com/marvinroger/async-mqtt-client) for the MQTT client, with custom modifications from @bertmelis and @proddy
|
||||
- ESPAsyncWebServer and AsyncTCP for the Web server and TCP backends, with custom modifications for performance
|
||||
|
||||
# **License**
|
||||
|
||||
This program is licensed under GPL-3.0
|
||||
|
||||
5
RELEASENOTES.md
Normal file
5
RELEASENOTES.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# 
|
||||
|
||||
# Firmware Installation
|
||||
|
||||
Follow the instructions in the [documentation](https://emsesp.github.io/docs) on how to install the firmware binaries in the Assets below.
|
||||
7
RELEASENOTES_DEV.md
Normal file
7
RELEASENOTES_DEV.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# 
|
||||
|
||||
This is a snapshot of the current "beta" development code and firmware binaries for the ESP32. It has all the latest features and fixes but please be aware that this is still experimental firmware used for testing and thus may contain the odd bug. Use at your own risk and remember to report an issue if you find something unusual.
|
||||
|
||||
# Firmware Installation
|
||||
|
||||
Follow the instructions in the [documentation](https://emsesp.github.io/docs) on how to install the firmware binaries in the Assets below.
|
||||
@@ -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,
|
||||
|
6
esp32_partition_app1984k_spiffs64k.csv
Normal file
6
esp32_partition_app1984k_spiffs64k.csv
Normal file
@@ -0,0 +1,6 @@
|
||||
# Name, Type, SubType, Offset, Size, Flags
|
||||
nvs, data, nvs, 0x9000, 0x5000,
|
||||
otadata, data, ota, 0xE000, 0x2000,
|
||||
app0, app, ota_0, 0x10000, 0x1F0000,
|
||||
app1, app, ota_1, 0x200000, 0x1F0000,
|
||||
spiffs, data, spiffs, 0x3F0000, 0x10000,
|
||||
|
@@ -7,8 +7,8 @@ build_flags =
|
||||
|
||||
; Access point settings
|
||||
-D FACTORY_AP_PROVISION_MODE=AP_MODE_DISCONNECTED
|
||||
-D FACTORY_AP_SSID=\"ems-esp\"
|
||||
-D FACTORY_AP_PASSWORD=\"ems-esp-neo\"
|
||||
-D FACTORY_AP_SSID=\"ems-esp\" ; 1-64 characters
|
||||
-D FACTORY_AP_PASSWORD=\"ems-esp-neo\" ; 8-64 characters
|
||||
-D FACTORY_AP_LOCAL_IP=\"192.168.4.1\"
|
||||
-D FACTORY_AP_GATEWAY_IP=\"192.168.4.1\"
|
||||
-D FACTORY_AP_SUBNET_MASK=\"255.255.255.0\"
|
||||
@@ -25,12 +25,18 @@ build_flags =
|
||||
-D FACTORY_NTP_TIME_ZONE_FORMAT=\"CET-1CEST,M3.5.0,M10.5.0/3\"
|
||||
-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=true
|
||||
|
||||
; MQTT settings
|
||||
-D FACTORY_MQTT_ENABLED=false
|
||||
-D FACTORY_MQTT_HOST=\"\"
|
||||
-D FACTORY_MQTT_HOST=\"test.mosquitto.org\"
|
||||
-D FACTORY_MQTT_PORT=1883
|
||||
-D FACTORY_MQTT_USERNAME=\"\"
|
||||
-D FACTORY_MQTT_PASSWORD=\"\"
|
||||
-D FACTORY_MQTT_CLIENT_ID=\"ems-esp\"
|
||||
-D FACTORY_MQTT_KEEP_ALIVE=60
|
||||
-D FACTORY_MQTT_CLEAN_SESSION=false
|
||||
-D FACTORY_MQTT_MAX_TOPIC_LENGTH=128
|
||||
|
||||
8
interface/.env
Normal file
8
interface/.env
Normal file
@@ -0,0 +1,8 @@
|
||||
# This enables lint extensions
|
||||
EXTEND_ESLINT=true
|
||||
|
||||
# This is the name of your project. It appears on the sign-in page and in the menu bar.
|
||||
REACT_APP_PROJECT_NAME=EMS-ESP
|
||||
|
||||
# This is the url path your project will be exposed under.
|
||||
REACT_APP_PROJECT_PATH=ems-esp
|
||||
@@ -1,2 +0,0 @@
|
||||
VITE_ALOVA_TIPS=0
|
||||
REACT_APP_ALOVA_TIPS=0
|
||||
3
interface/.env.hosted
Normal file
3
interface/.env.hosted
Normal file
@@ -0,0 +1,3 @@
|
||||
GENERATE_SOURCEMAP=false
|
||||
|
||||
REACT_APP_HOSTED=true
|
||||
1
interface/.env.production
Normal file
1
interface/.env.production
Normal file
@@ -0,0 +1 @@
|
||||
GENERATE_SOURCEMAP=false
|
||||
@@ -1,7 +0,0 @@
|
||||
node_modules/
|
||||
build/
|
||||
dist/
|
||||
src/i18n/*
|
||||
|
||||
.prettierrc
|
||||
.typesafe-i18n.json
|
||||
6
interface/.prettierrc
Normal file
6
interface/.prettierrc
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"semi": true,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 120
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"adapter": "react",
|
||||
"baseLocale": "pl",
|
||||
"$schema": "https://unpkg.com/typesafe-i18n@5.26.2/schema/typesafe-i18n.json"
|
||||
}
|
||||
30
interface/config-overrides.js
Normal file
30
interface/config-overrides.js
Normal file
@@ -0,0 +1,30 @@
|
||||
const { WebpackManifestPlugin } = require('webpack-manifest-plugin');
|
||||
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
|
||||
const ProgmemGenerator = require('./progmem-generator.js');
|
||||
const TerserPlugin = require('terser-webpack-plugin');
|
||||
|
||||
module.exports = function override(config, env) {
|
||||
const hosted = process.env.REACT_APP_HOSTED;
|
||||
|
||||
if (env === 'production' && !hosted) {
|
||||
// rename the ouput file, we need it's path to be short, for embedded FS
|
||||
config.output.filename = 'js/[id].[chunkhash:4].js';
|
||||
config.output.chunkFilename = 'js/[id].[chunkhash:4].js';
|
||||
|
||||
// take out the manifest plugin
|
||||
config.plugins = config.plugins.filter((plugin) => !(plugin instanceof WebpackManifestPlugin));
|
||||
|
||||
// shorten css filenames
|
||||
const miniCssExtractPlugin = config.plugins.find((plugin) => plugin instanceof MiniCssExtractPlugin);
|
||||
miniCssExtractPlugin.options.filename = 'css/[id].[contenthash:4].css';
|
||||
miniCssExtractPlugin.options.chunkFilename = 'css/[id].[contenthash:4].c.css';
|
||||
|
||||
// don't emit license file
|
||||
const terserPlugin = config.optimization.minimizer.find((plugin) => plugin instanceof TerserPlugin);
|
||||
terserPlugin.options.extractComments = false;
|
||||
|
||||
// build progmem data files
|
||||
config.plugins.push(new ProgmemGenerator({ outputPath: '../lib/framework/WWWData.h', bytesPerLine: 20 }));
|
||||
}
|
||||
return config;
|
||||
};
|
||||
@@ -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,14 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<link rel="stylesheet" href="/css/roboto.css" />
|
||||
<link rel="manifest" href="/app/manifest.json" />
|
||||
<title>EMS-ESP</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
29916
interface/package-lock.json
generated
Normal file
29916
interface/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,71 +1,103 @@
|
||||
{
|
||||
"name": "EMS-ESP",
|
||||
"version": "3.7.3",
|
||||
"description": "EMS-ESP WebUI",
|
||||
"homepage": "https://emsesp.org",
|
||||
"author": "proddy, emsesp.org",
|
||||
"license": "MIT",
|
||||
"version": "3.4.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"build-hosted": "typesafe-i18n && vite build --mode hosted",
|
||||
"mock-rest": "bun --watch ../mock-api/restServer.ts",
|
||||
"preview-standalone": "typesafe-i18n --no-watch && vite build && concurrently -c \"auto\" \"pnpm:mock-rest\" \"vite preview\"",
|
||||
"standalone": "concurrently -c \"auto\" \"typesafe-i18n\" \"pnpm:mock-rest\" \"vite dev\"",
|
||||
"typesafe-i18n": "typesafe-i18n --no-watch",
|
||||
"build_webUI": "typesafe-i18n --no-watch && vite build && node progmem-generator.js",
|
||||
"format": "prettier -l -w '**/*.{ts,tsx,js,css,json,md}'",
|
||||
"lint": "eslint . --fix",
|
||||
"standalone-devcontainer": "concurrently -c \"auto\" \"typesafe-i18n\" \"pnpm:mock-rest\" \"vite --host\""
|
||||
},
|
||||
"proxy": "http://localhost:3080",
|
||||
"dependencies": {
|
||||
"@alova/adapter-xhr": "2.3.0",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@mui/icons-material": "^7.3.5",
|
||||
"@mui/material": "^7.3.5",
|
||||
"@preact/compat": "^18.3.1",
|
||||
"@table-library/react-table-library": "4.1.15",
|
||||
"alova": "3.4.0",
|
||||
"@emotion/react": "^11.10.4",
|
||||
"@emotion/styled": "^11.10.4",
|
||||
"@msgpack/msgpack": "^2.8.0",
|
||||
"@mui/icons-material": "^5.10.3",
|
||||
"@mui/material": "^5.10.5",
|
||||
"@table-library/react-table-library": "4.0.18",
|
||||
"@types/lodash": "^4.14.185",
|
||||
"@types/node": "^18.7.18",
|
||||
"@types/react": "^18.0.20",
|
||||
"@types/react-dom": "^18.0.6",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"async-validator": "^4.2.5",
|
||||
"etag": "^1.8.1",
|
||||
"formidable": "^3.5.4",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"magic-string": "^0.30.21",
|
||||
"mime-types": "^3.0.2",
|
||||
"preact": "^10.28.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-router": "^7.10.0",
|
||||
"react-toastify": "^11.0.5",
|
||||
"typesafe-i18n": "^5.26.2",
|
||||
"typescript": "^5.9.3"
|
||||
"axios": "^0.27.2",
|
||||
"http-proxy-middleware": "^2.0.6",
|
||||
"jwt-decode": "^3.1.2",
|
||||
"lodash": "^4.17.21",
|
||||
"notistack": "^2.0.5",
|
||||
"parse-ms": "^3.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-app-rewired": "^2.2.1",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-dropzone": "^14.2.2",
|
||||
"react-icons": "^4.4.0",
|
||||
"react-router-dom": "^6.4.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"sockette": "^2.0.6",
|
||||
"typescript": "^4.8.3"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-app-rewired start",
|
||||
"build": "react-app-rewired build",
|
||||
"test": "react-app-rewired test",
|
||||
"eject": "react-scripts eject",
|
||||
"format": "prettier --write '**/*.{ts,tsx,js,css,json,md}'",
|
||||
"build-hosted": "env-cmd -f .env.hosted npm run build",
|
||||
"build-localhost": "PUBLIC_URL=/ react-app-rewired build",
|
||||
"mock-api": "nodemon --watch ../mock-api ../mock-api/server.js",
|
||||
"standalone": "npm-run-all -p start mock-api",
|
||||
"lint": "eslint . --ext .ts,.tsx"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
],
|
||||
"rules": {
|
||||
"eol-last": 1,
|
||||
"react/jsx-closing-bracket-location": 1,
|
||||
"react/jsx-closing-tag-location": 1,
|
||||
"react/jsx-wrap-multilines": 1,
|
||||
"react/jsx-curly-newline": 1,
|
||||
"no-multiple-empty-lines": [
|
||||
1,
|
||||
{
|
||||
"max": 1
|
||||
}
|
||||
],
|
||||
"no-trailing-spaces": 1,
|
||||
"semi": 1,
|
||||
"no-extra-semi": 1,
|
||||
"react/jsx-max-props-per-line": [
|
||||
1,
|
||||
{
|
||||
"when": "multiline"
|
||||
}
|
||||
],
|
||||
"react/jsx-first-prop-new-line": [
|
||||
1,
|
||||
"multiline"
|
||||
],
|
||||
"@typescript-eslint/no-shadow": 1,
|
||||
"max-len": [
|
||||
1,
|
||||
{
|
||||
"code": 200
|
||||
}
|
||||
],
|
||||
"arrow-parens": 1
|
||||
}
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.28.5",
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@preact/compat": "^18.3.1",
|
||||
"@preact/preset-vite": "^2.10.2",
|
||||
"@trivago/prettier-plugin-sort-imports": "^6.0.0",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"axe-core": "^4.11.0",
|
||||
"concurrently": "^9.2.1",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"prettier": "^3.7.3",
|
||||
"rollup-plugin-visualizer": "^6.0.5",
|
||||
"terser": "^5.44.1",
|
||||
"typescript-eslint": "^8.48.1",
|
||||
"vite": "^7.2.6",
|
||||
"vite-plugin-imagemin": "^0.6.1",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
},
|
||||
"packageManager": "pnpm@10.24.0+sha512.01ff8ae71b4419903b65c60fb2dc9d34cf8bb6e06d03bde112ef38f7a34d6904c424ba66bea5cdcf12890230bf39f9580473140ed9c946fef328b6e5238a345a"
|
||||
"nodemon": "^2.0.20",
|
||||
"npm-run-all": "^4.1.5"
|
||||
}
|
||||
}
|
||||
|
||||
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,121 @@
|
||||
import etag from 'etag';
|
||||
import {
|
||||
createWriteStream,
|
||||
existsSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
unlinkSync
|
||||
} from 'fs';
|
||||
import mime from 'mime-types';
|
||||
import { relative, resolve, sep } from 'path';
|
||||
import zlib from 'zlib';
|
||||
const { resolve, relative, sep } = require('path');
|
||||
const { readdirSync, existsSync, unlinkSync, readFileSync, createWriteStream } = require('fs');
|
||||
var zlib = require('zlib');
|
||||
var mime = require('mime-types');
|
||||
|
||||
const ARDUINO_INCLUDES = '#include <Arduino.h>\n\n';
|
||||
const INDENT = ' ';
|
||||
const outputPath = '../src/ESP32React/WWWData.h';
|
||||
const sourcePath = './dist';
|
||||
const bytesPerLine = 20;
|
||||
let totalSize = 0;
|
||||
let bundleStats = {
|
||||
js: { count: 0, uncompressed: 0, compressed: 0 },
|
||||
css: { count: 0, uncompressed: 0, compressed: 0 },
|
||||
html: { count: 0, uncompressed: 0, compressed: 0 },
|
||||
svg: { count: 0, uncompressed: 0, compressed: 0 },
|
||||
other: { count: 0, uncompressed: 0, compressed: 0 }
|
||||
};
|
||||
|
||||
const generateWWWClass =
|
||||
() => `typedef std::function<void(const char * uri, const String & contentType, const uint8_t * content, size_t len, const String & hash)> RouteRegistrationHandler;
|
||||
// Bundle Statistics:
|
||||
// - Total compressed size: ${(totalSize / 1000).toFixed(1)} KB
|
||||
// - Total uncompressed size: ${(Object.values(bundleStats).reduce((sum, stat) => sum + stat.uncompressed, 0) / 1000).toFixed(1)} KB
|
||||
// - Compression ratio: ${(((Object.values(bundleStats).reduce((sum, stat) => sum + stat.uncompressed, 0) - totalSize) / Object.values(bundleStats).reduce((sum, stat) => sum + stat.uncompressed, 0)) * 100).toFixed(1)}%
|
||||
// - Generated on: ${new Date().toISOString()}
|
||||
|
||||
class WWWData {
|
||||
${INDENT}public:
|
||||
${INDENT.repeat(2)}static void registerRoutes(RouteRegistrationHandler handler) {
|
||||
${fileInfo.map((f) => `${INDENT.repeat(3)}handler("${f.uri}", "${f.mimeType}", ${f.variable}, ${f.size}, ${f.hash});`).join('\n')}
|
||||
${INDENT.repeat(2)}}
|
||||
};
|
||||
`;
|
||||
|
||||
const getFilesSync = (dir, files = []) => {
|
||||
function getFilesSync(dir, files = []) {
|
||||
readdirSync(dir, { withFileTypes: true }).forEach((entry) => {
|
||||
const entryPath = resolve(dir, entry.name);
|
||||
entry.isDirectory() ? getFilesSync(entryPath, files) : files.push(entryPath);
|
||||
if (entry.isDirectory()) {
|
||||
getFilesSync(entryPath, files);
|
||||
} else {
|
||||
files.push(entryPath);
|
||||
}
|
||||
});
|
||||
return files;
|
||||
};
|
||||
|
||||
const cleanAndOpen = (path) => {
|
||||
existsSync(path) && unlinkSync(path);
|
||||
return createWriteStream(path, { flags: 'w+' });
|
||||
};
|
||||
|
||||
const getFileType = (filePath) => {
|
||||
const ext = filePath.split('.').pop().toLowerCase();
|
||||
if (ext === 'js') return 'js';
|
||||
if (ext === 'css') return 'css';
|
||||
if (ext === 'html') return 'html';
|
||||
if (ext === 'svg') return 'svg';
|
||||
return 'other';
|
||||
};
|
||||
|
||||
const writeFile = (relativeFilePath, buffer) => {
|
||||
const variable = `ESP_REACT_DATA_${fileInfo.length}`;
|
||||
const mimeType = mime.lookup(relativeFilePath);
|
||||
const fileType = getFileType(relativeFilePath);
|
||||
let size = 0;
|
||||
writeStream.write(`const uint8_t ${variable}[] = {`);
|
||||
|
||||
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) => {
|
||||
if (!(size % bytesPerLine)) {
|
||||
writeStream.write('\n' + INDENT);
|
||||
}
|
||||
writeStream.write('0x' + b.toString(16).toUpperCase().padStart(2, '0') + ',');
|
||||
size++;
|
||||
});
|
||||
|
||||
size % bytesPerLine && writeStream.write('\n');
|
||||
writeStream.write('};\n\n');
|
||||
|
||||
// Update bundle statistics
|
||||
bundleStats[fileType].count++;
|
||||
bundleStats[fileType].uncompressed += buffer.length;
|
||||
bundleStats[fileType].compressed += zipBuffer.length;
|
||||
|
||||
fileInfo.push({
|
||||
uri: '/' + relativeFilePath.replace(sep, '/'),
|
||||
mimeType,
|
||||
variable,
|
||||
size,
|
||||
hash
|
||||
});
|
||||
|
||||
totalSize += size;
|
||||
};
|
||||
|
||||
console.log(`Generating ${outputPath} from ${sourcePath}`);
|
||||
const fileInfo = [];
|
||||
const writeStream = cleanAndOpen(resolve(outputPath));
|
||||
|
||||
writeStream.write(ARDUINO_INCLUDES);
|
||||
|
||||
const buildPath = resolve(sourcePath);
|
||||
for (const filePath of getFilesSync(buildPath)) {
|
||||
writeFile(relative(buildPath, filePath), readFileSync(filePath));
|
||||
}
|
||||
|
||||
writeStream.write(generateWWWClass());
|
||||
writeStream.end();
|
||||
function coherseToBuffer(input) {
|
||||
return Buffer.isBuffer(input) ? input : Buffer.from(input);
|
||||
}
|
||||
|
||||
// Calculate and display bundle statistics
|
||||
const totalUncompressed = Object.values(bundleStats).reduce(
|
||||
(sum, stat) => sum + stat.uncompressed,
|
||||
0
|
||||
);
|
||||
const totalCompressed = Object.values(bundleStats).reduce(
|
||||
(sum, stat) => sum + stat.compressed,
|
||||
0
|
||||
);
|
||||
const compressionRatio = (
|
||||
((totalUncompressed - totalCompressed) / totalUncompressed) *
|
||||
100
|
||||
).toFixed(1);
|
||||
|
||||
console.log('\n📊 Bundle Size Analysis:');
|
||||
console.log('='.repeat(50));
|
||||
console.log(`Total compressed size: ${(totalSize / 1000).toFixed(1)} KB`);
|
||||
console.log(`Total uncompressed size: ${(totalUncompressed / 1000).toFixed(1)} KB`);
|
||||
console.log(`Compression ratio: ${compressionRatio}%`);
|
||||
console.log('\n📁 File Type Breakdown:');
|
||||
Object.entries(bundleStats).forEach(([type, stats]) => {
|
||||
if (stats.count > 0) {
|
||||
const ratio = (
|
||||
((stats.uncompressed - stats.compressed) / stats.uncompressed) *
|
||||
100
|
||||
).toFixed(1);
|
||||
console.log(
|
||||
`${type.toUpperCase().padEnd(4)}: ${stats.count} files, ${(stats.uncompressed / 1000).toFixed(1)} KB → ${(stats.compressed / 1000).toFixed(1)} KB (${ratio}% compression)`
|
||||
);
|
||||
function cleanAndOpen(path) {
|
||||
if (existsSync(path)) {
|
||||
unlinkSync(path);
|
||||
}
|
||||
});
|
||||
console.log('='.repeat(50));
|
||||
return createWriteStream(path, { flags: 'w+' });
|
||||
}
|
||||
|
||||
class ProgmemGenerator {
|
||||
constructor(options = {}) {
|
||||
const { outputPath, bytesPerLine = 20, indent = ' ', includes = ARDUINO_INCLUDES } = options;
|
||||
this.options = { outputPath, bytesPerLine, indent, includes };
|
||||
}
|
||||
|
||||
apply(compiler) {
|
||||
compiler.hooks.emit.tapAsync({ name: 'ProgmemGenerator' }, (compilation, callback) => {
|
||||
const { outputPath, bytesPerLine, indent, includes } = this.options;
|
||||
const fileInfo = [];
|
||||
const writeStream = cleanAndOpen(resolve(compilation.options.context, outputPath));
|
||||
try {
|
||||
const writeIncludes = () => {
|
||||
writeStream.write(includes);
|
||||
};
|
||||
|
||||
const writeFile = (relativeFilePath, buffer) => {
|
||||
const variable = 'ESP_REACT_DATA_' + fileInfo.length;
|
||||
const mimeType = mime.lookup(relativeFilePath);
|
||||
var size = 0;
|
||||
writeStream.write('const uint8_t ' + variable + '[] PROGMEM = {');
|
||||
const zipBuffer = zlib.gzipSync(buffer);
|
||||
zipBuffer.forEach((b) => {
|
||||
if (!(size % bytesPerLine)) {
|
||||
writeStream.write('\n');
|
||||
writeStream.write(indent);
|
||||
}
|
||||
writeStream.write('0x' + ('00' + b.toString(16).toUpperCase()).substr(-2) + ',');
|
||||
size++;
|
||||
});
|
||||
if (size % bytesPerLine) {
|
||||
writeStream.write('\n');
|
||||
}
|
||||
writeStream.write('};\n\n');
|
||||
fileInfo.push({
|
||||
uri: '/' + relativeFilePath.replace(sep, '/'),
|
||||
mimeType,
|
||||
variable,
|
||||
size
|
||||
});
|
||||
};
|
||||
|
||||
const writeFiles = () => {
|
||||
// process static files
|
||||
const buildPath = compilation.options.output.path;
|
||||
for (const filePath of getFilesSync(buildPath)) {
|
||||
const readStream = readFileSync(filePath);
|
||||
const relativeFilePath = relative(buildPath, filePath);
|
||||
writeFile(relativeFilePath, readStream);
|
||||
}
|
||||
// process assets
|
||||
const { assets } = compilation;
|
||||
Object.keys(assets).forEach((relativeFilePath) => {
|
||||
writeFile(relativeFilePath, coherseToBuffer(assets[relativeFilePath].source()));
|
||||
});
|
||||
};
|
||||
|
||||
const generateWWWClass = () => {
|
||||
// eslint-disable-next-line max-len
|
||||
return `typedef std::function<void(const String& uri, const String& contentType, const uint8_t * content, size_t len)> RouteRegistrationHandler;
|
||||
|
||||
class WWWData {
|
||||
${indent}public:
|
||||
${indent.repeat(2)}static void registerRoutes(RouteRegistrationHandler handler) {
|
||||
${fileInfo
|
||||
.map((file) => `${indent.repeat(3)}handler("${file.uri}", "${file.mimeType}", ${file.variable}, ${file.size});`)
|
||||
.join('\n')}
|
||||
${indent.repeat(2)}}
|
||||
};
|
||||
`;
|
||||
};
|
||||
|
||||
const writeWWWClass = () => {
|
||||
writeStream.write(generateWWWClass());
|
||||
};
|
||||
|
||||
writeIncludes();
|
||||
writeFiles();
|
||||
writeWWWClass();
|
||||
|
||||
writeStream.on('finish', () => {
|
||||
callback();
|
||||
});
|
||||
} finally {
|
||||
writeStream.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ProgmemGenerator;
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 8.4 KiB |
@@ -1,21 +1,24 @@
|
||||
/*
|
||||
* Uses font-weight 400 (normal) only, no bold, and Latin with a few extra unicode chars.
|
||||
* This is to keep flash memory to a minimum
|
||||
* 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
|
||||
/*
|
||||
* Just supporting latin due to size constrains on the esp chip
|
||||
*
|
||||
* The framework only makes use of 400 (regular) + 500 (medium) weight fonts.
|
||||
*
|
||||
* If using light or strong typography variants you will need to add additional fonts.
|
||||
*/
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
/* src: url(https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu4mxK.woff2) format('woff2'); */
|
||||
src:
|
||||
local('Roboto'),
|
||||
local('Roboto-Regular'),
|
||||
url(../fonts/re.woff2) format('woff2');
|
||||
unicode-range:
|
||||
U+0000-00FF, U+0104-0107, U+0118-0119, U+011E-011F, U+0130-0131, U+0141-0144,
|
||||
U+0152-0153, U+015A-015B, U+015E-015F, U+0179-017C, U+02BB-02BC, U+02C6, U+02DA,
|
||||
U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
|
||||
U+FEFF, U+FFFD;
|
||||
src: local('Roboto'), local('Roboto-Regular'), url(../fonts/re.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC,
|
||||
U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
src: local('Roboto Medium'), local('Roboto-Medium'), url(../fonts/md.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC,
|
||||
U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
BIN
interface/public/fonts/md.woff2
Normal file
BIN
interface/public/fonts/md.woff2
Normal file
Binary file not shown.
Binary file not shown.
17
interface/public/index.html
Normal file
17
interface/public/index.html
Normal file
@@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, user-scalable=0, maximum-scale=1, minimum-scale=1"
|
||||
/>
|
||||
<link rel="stylesheet" href="%PUBLIC_URL%/css/roboto.css" />
|
||||
<link rel="manifest" href="%PUBLIC_URL%/app/manifest.json" />
|
||||
<title>EMS-ESP</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript> You need to enable JavaScript to run this app. </noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,75 +1,45 @@
|
||||
import { memo, useCallback, useEffect, useState } from 'react';
|
||||
import { ToastContainer, Zoom } from 'react-toastify';
|
||||
import { FC, createRef, createContext, useContext, RefObject } from 'react';
|
||||
import { SnackbarProvider } from 'notistack';
|
||||
|
||||
import AppRouting from 'AppRouting';
|
||||
import CustomTheme from 'CustomTheme';
|
||||
import TypesafeI18n from 'i18n/i18n-react';
|
||||
import type { Locales } from 'i18n/i18n-types';
|
||||
import { loadLocaleAsync } from 'i18n/i18n-util.async';
|
||||
import { detectLocale, navigatorDetector } from 'typesafe-i18n/detectors';
|
||||
import { IconButton } from '@mui/material';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
|
||||
const AVAILABLE_LOCALES = [
|
||||
'de',
|
||||
'en',
|
||||
'it',
|
||||
'fr',
|
||||
'nl',
|
||||
'no',
|
||||
'pl',
|
||||
'sk',
|
||||
'sv',
|
||||
'tr',
|
||||
'cz'
|
||||
] as Locales[];
|
||||
import { FeaturesLoader } from './contexts/features';
|
||||
|
||||
// Static toast configuration - no need to recreate on every render
|
||||
const TOAST_CONTAINER_PROPS = {
|
||||
position: 'bottom-left' as const,
|
||||
autoClose: 3000,
|
||||
hideProgressBar: false,
|
||||
newestOnTop: false,
|
||||
closeOnClick: true,
|
||||
rtl: false,
|
||||
pauseOnFocusLoss: true,
|
||||
draggable: false,
|
||||
pauseOnHover: false,
|
||||
transition: Zoom,
|
||||
closeButton: false,
|
||||
theme: 'dark' as const,
|
||||
toastStyle: {
|
||||
border: '1px solid #177ac9',
|
||||
width: 'fit-content'
|
||||
}
|
||||
};
|
||||
import CustomTheme from './CustomTheme';
|
||||
import AppRouting from './AppRouting';
|
||||
|
||||
const App = memo(() => {
|
||||
const [wasLoaded, setWasLoaded] = useState(false);
|
||||
const [locale, setLocale] = useState<Locales>('en');
|
||||
const App: FC = () => {
|
||||
const notistackRef: RefObject<any> = createRef();
|
||||
|
||||
// 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);
|
||||
}, []);
|
||||
const onClickDismiss = (key: string | number | undefined) => () => {
|
||||
notistackRef.current.closeSnackbar(key);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void initializeLocale();
|
||||
}, [initializeLocale]);
|
||||
const ColorModeContext = createContext({ toggleColorMode: () => {} });
|
||||
|
||||
if (!wasLoaded) return null;
|
||||
const colorMode = useContext(ColorModeContext);
|
||||
|
||||
return (
|
||||
<TypesafeI18n locale={locale}>
|
||||
<ColorModeContext.Provider value={colorMode}>
|
||||
<CustomTheme>
|
||||
<AppRouting />
|
||||
<ToastContainer {...TOAST_CONTAINER_PROPS} />
|
||||
<SnackbarProvider
|
||||
maxSnack={3}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
|
||||
ref={notistackRef}
|
||||
action={(key) => (
|
||||
<IconButton onClick={onClickDismiss(key)} size="small">
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
>
|
||||
<FeaturesLoader>
|
||||
<AppRouting />
|
||||
</FeaturesLoader>
|
||||
</SnackbarProvider>
|
||||
</CustomTheme>
|
||||
</TypesafeI18n>
|
||||
</ColorModeContext.Provider>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export default App;
|
||||
|
||||
@@ -1,60 +1,54 @@
|
||||
import { type FC, Suspense, lazy, memo, useContext, useEffect, useRef } from 'react';
|
||||
import { Navigate, Route, Routes } from 'react-router';
|
||||
import { toast } from 'react-toastify';
|
||||
import { FC, useContext, useEffect } from 'react';
|
||||
import { Navigate, Routes, Route, useLocation } from 'react-router-dom';
|
||||
import { useSnackbar, VariantType } from 'notistack';
|
||||
|
||||
import {
|
||||
LoadingSpinner,
|
||||
RequireAuthenticated,
|
||||
RequireUnauthenticated
|
||||
} from 'components';
|
||||
import { Authentication, AuthenticationContext } from 'contexts/authentication';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
import { Authentication, AuthenticationContext } from './contexts/authentication';
|
||||
import { FeaturesContext } from './contexts/features';
|
||||
import { RequireAuthenticated, RequireUnauthenticated } from './components';
|
||||
|
||||
// Lazy load route components for better code splitting
|
||||
const SignIn = lazy(() => import('SignIn'));
|
||||
const AuthenticatedRouting = lazy(() => import('AuthenticatedRouting'));
|
||||
import SignIn from './SignIn';
|
||||
import AuthenticatedRouting from './AuthenticatedRouting';
|
||||
|
||||
interface SecurityRedirectProps {
|
||||
readonly message: string;
|
||||
readonly signOut?: boolean;
|
||||
message: string;
|
||||
variant?: VariantType;
|
||||
signOut?: boolean;
|
||||
}
|
||||
|
||||
const RootRedirect: FC<SecurityRedirectProps> = memo(
|
||||
({ message, signOut = false }) => {
|
||||
const { signOut: contextSignOut } = useContext(AuthenticationContext);
|
||||
const hasShownToast = useRef(false);
|
||||
const RootRedirect: FC<SecurityRedirectProps> = ({ message, variant, signOut }) => {
|
||||
const authenticationContext = useContext(AuthenticationContext);
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
useEffect(() => {
|
||||
signOut && authenticationContext.signOut(false);
|
||||
enqueueSnackbar(message, { variant });
|
||||
}, [message, variant, signOut, authenticationContext, enqueueSnackbar]);
|
||||
return <Navigate to="/" />;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Prevent duplicate toasts on strict mode or re-renders
|
||||
if (!hasShownToast.current) {
|
||||
hasShownToast.current = true;
|
||||
if (signOut) {
|
||||
contextSignOut(false);
|
||||
}
|
||||
toast.success(message);
|
||||
}
|
||||
// Only run once on mount - using ref to track execution
|
||||
}, []);
|
||||
export const RemoveTrailingSlashes = () => {
|
||||
const location = useLocation();
|
||||
return (
|
||||
location.pathname.match('/.*/$') && (
|
||||
<Navigate
|
||||
to={{
|
||||
pathname: location.pathname.replace(/\/+$/, ''),
|
||||
search: location.search
|
||||
}}
|
||||
/>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
);
|
||||
|
||||
const AppRouting: FC = memo(() => {
|
||||
const { LL } = useI18nContext();
|
||||
const AppRouting: FC = () => {
|
||||
const { features } = useContext(FeaturesContext);
|
||||
|
||||
return (
|
||||
<Authentication>
|
||||
<Suspense fallback={<LoadingSpinner />}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/unauthorized"
|
||||
element={<RootRedirect message={LL.PLEASE_SIGNIN()} signOut />}
|
||||
/>
|
||||
<Route
|
||||
path="/fileUpdated"
|
||||
element={<RootRedirect message={LL.UPLOAD_SUCCESSFUL()} />}
|
||||
/>
|
||||
<RemoveTrailingSlashes />
|
||||
<Routes>
|
||||
<Route path="/unauthorized" element={<RootRedirect message="Please sign in to continue" signOut />} />
|
||||
<Route path="/fileUpdated" element={<RootRedirect message="Upload successful" variant="success" />} />
|
||||
{features.security && (
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
@@ -63,18 +57,18 @@ const AppRouting: FC = memo(() => {
|
||||
</RequireUnauthenticated>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/*"
|
||||
element={
|
||||
<RequireAuthenticated>
|
||||
<AuthenticatedRouting />
|
||||
</RequireAuthenticated>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</Suspense>
|
||||
)}
|
||||
<Route
|
||||
path="/*"
|
||||
element={
|
||||
<RequireAuthenticated>
|
||||
<AuthenticatedRouting />
|
||||
</RequireAuthenticated>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</Authentication>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export default AppRouting;
|
||||
|
||||
@@ -1,88 +1,66 @@
|
||||
import { Suspense, lazy, memo, useContext } from 'react';
|
||||
import { Navigate, Route, Routes } from 'react-router';
|
||||
import { FC, useCallback, useContext, useEffect } from 'react';
|
||||
import { Navigate, Routes, Route, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { AxiosError } from 'axios';
|
||||
|
||||
import { Layout, LoadingSpinner } from 'components';
|
||||
import { AuthenticatedContext } from 'contexts/authentication';
|
||||
import { FeaturesContext } from './contexts/features';
|
||||
import * as AuthenticationApi from './api/authentication';
|
||||
import { PROJECT_PATH } from './api/env';
|
||||
import { AXIOS } from './api/endpoints';
|
||||
import { Layout, RequireAdmin } from './components';
|
||||
|
||||
// Lazy load all route components for better code splitting
|
||||
const Dashboard = lazy(() => import('app/main/Dashboard'));
|
||||
const Devices = lazy(() => import('app/main/Devices'));
|
||||
const Sensors = lazy(() => import('app/main/Sensors'));
|
||||
const Help = lazy(() => import('app/main/Help'));
|
||||
const Customizations = lazy(() => import('app/main/Customizations'));
|
||||
const Scheduler = lazy(() => import('app/main/Scheduler'));
|
||||
const CustomEntities = lazy(() => import('app/main/CustomEntities'));
|
||||
const Modules = lazy(() => import('app/main/Modules'));
|
||||
const UserProfile = lazy(() => import('app/main/UserProfile'));
|
||||
import ProjectRouting from './project/ProjectRouting';
|
||||
|
||||
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'));
|
||||
import NetworkConnection from './framework/network/NetworkConnection';
|
||||
import AccessPoint from './framework/ap/AccessPoint';
|
||||
import NetworkTime from './framework/ntp/NetworkTime';
|
||||
import Mqtt from './framework/mqtt/Mqtt';
|
||||
import System from './framework/system/System';
|
||||
import Security from './framework/security/Security';
|
||||
|
||||
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: FC = () => {
|
||||
const { features } = useContext(FeaturesContext);
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleApiResponseError = useCallback(
|
||||
(error: AxiosError) => {
|
||||
if (error.response && error.response.status === 401) {
|
||||
AuthenticationApi.storeLoginRedirect(location);
|
||||
navigate('/unauthorized');
|
||||
}
|
||||
return Promise.reject(error);
|
||||
},
|
||||
[location, navigate]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const axiosHandlerId = AXIOS.interceptors.response.use((response) => response, handleApiResponseError);
|
||||
return () => AXIOS.interceptors.response.eject(axiosHandlerId);
|
||||
}, [handleApiResponseError]);
|
||||
|
||||
const AuthenticatedRouting = memo(() => {
|
||||
const { me } = useContext(AuthenticatedContext);
|
||||
return (
|
||||
<Layout>
|
||||
<Suspense fallback={<LoadingSpinner />}>
|
||||
<Routes>
|
||||
<Route path="/dashboard/*" element={<Dashboard />} />
|
||||
<Route path="/devices/*" element={<Devices />} />
|
||||
<Route path="/sensors/*" element={<Sensors />} />
|
||||
<Route path="/help/*" element={<Help />} />
|
||||
<Route path="/user/*" element={<UserProfile />} />
|
||||
|
||||
<Route path="/status/*" element={<Status />} />
|
||||
<Route path="/status/hardwarestatus/*" element={<HardwareStatus />} />
|
||||
<Route path="/status/activity" element={<Activity />} />
|
||||
<Route path="/status/log" element={<SystemLog />} />
|
||||
<Route path="/status/mqtt" element={<MqttStatus />} />
|
||||
<Route path="/status/ntp" element={<NTPStatus />} />
|
||||
<Route path="/status/ap" element={<APStatus />} />
|
||||
<Route path="/status/network" element={<NetworkStatus />} />
|
||||
<Route path="/status/version" element={<Version />} />
|
||||
|
||||
{me.admin && (
|
||||
<>
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route
|
||||
path="/settings/application"
|
||||
element={<ApplicationSettings />}
|
||||
/>
|
||||
<Route path="/settings/mqtt" element={<MqttSettings />} />
|
||||
<Route path="/settings/ntp" element={<NTPSettings />} />
|
||||
<Route path="/settings/ap" element={<APSettings />} />
|
||||
<Route path="/settings/modules" element={<Modules />} />
|
||||
<Route path="/settings/downloadUpload" element={<DownloadUpload />} />
|
||||
|
||||
<Route path="/settings/network/*" element={<Network />} />
|
||||
<Route path="/settings/security/*" element={<Security />} />
|
||||
|
||||
<Route path="/customizations" element={<Customizations />} />
|
||||
<Route path="/scheduler" element={<Scheduler />} />
|
||||
<Route path="/customentities" element={<CustomEntities />} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Route path="/*" element={<Navigate to="/" />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
<Routes>
|
||||
{features.project && <Route path={`/${PROJECT_PATH}/*`} element={<ProjectRouting />} />}
|
||||
<Route path="/network/*" element={<NetworkConnection />} />
|
||||
<Route path="/ap/*" element={<AccessPoint />} />
|
||||
{features.ntp && <Route path="/ntp/*" element={<NetworkTime />} />}
|
||||
{features.mqtt && <Route path="/mqtt/*" element={<Mqtt />} />}
|
||||
{features.security && (
|
||||
<Route
|
||||
path="/security/*"
|
||||
element={
|
||||
<RequireAdmin>
|
||||
<Security />
|
||||
</RequireAdmin>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Route path="/system/*" element={<System />} />
|
||||
<Route path="/*" element={<Navigate to={AuthenticationApi.getDefaultRoute(features)} />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export default AuthenticatedRouting;
|
||||
|
||||
@@ -1,24 +1,10 @@
|
||||
import { memo } from 'react';
|
||||
import type { FC } from 'react';
|
||||
import { FC } from 'react';
|
||||
|
||||
import {
|
||||
CssBaseline,
|
||||
ThemeProvider,
|
||||
responsiveFontSizes,
|
||||
tooltipClasses
|
||||
} from '@mui/material';
|
||||
import { createTheme } from '@mui/material/styles';
|
||||
import { CssBaseline } from '@mui/material';
|
||||
import { createTheme, responsiveFontSizes, ThemeProvider } from '@mui/material/styles';
|
||||
import { blueGrey, blue } from '@mui/material/colors';
|
||||
|
||||
import type { RequiredChildrenProps } from 'utils';
|
||||
|
||||
export const dialogStyle = {
|
||||
'& .MuiDialog-paper': {
|
||||
borderRadius: '8px',
|
||||
borderColor: '#565656',
|
||||
borderStyle: 'solid',
|
||||
borderWidth: '2px'
|
||||
}
|
||||
} as const;
|
||||
import { RequiredChildrenProps } from './utils';
|
||||
|
||||
const theme = responsiveFontSizes(
|
||||
createTheme({
|
||||
@@ -28,53 +14,20 @@ const theme = responsiveFontSizes(
|
||||
palette: {
|
||||
mode: 'dark',
|
||||
secondary: {
|
||||
main: '#2196f3' // blue[500]
|
||||
main: blue[500]
|
||||
},
|
||||
info: {
|
||||
main: '#607d8b' // blueGrey[500]
|
||||
},
|
||||
text: {
|
||||
disabled: '#eee' // white
|
||||
}
|
||||
},
|
||||
components: {
|
||||
MuiListItemText: {
|
||||
styleOverrides: {
|
||||
primary: {
|
||||
fontSize: 14
|
||||
},
|
||||
secondary: {
|
||||
color: '#9e9e9e' // grey[500]
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiTooltip: {
|
||||
defaultProps: {
|
||||
placement: 'top',
|
||||
arrow: true
|
||||
},
|
||||
styleOverrides: {
|
||||
tooltip: {
|
||||
padding: '4px 8px',
|
||||
fontSize: 10,
|
||||
color: 'rgba(0, 0, 0, 0.87)',
|
||||
backgroundColor: '#4caf50', // MUI success.main default color
|
||||
boxShadow: '0px 2px 8px rgba(0, 0, 0, 0.15)',
|
||||
[`& .${tooltipClasses.arrow}`]: {
|
||||
color: '#4caf50'
|
||||
}
|
||||
}
|
||||
}
|
||||
main: blueGrey[500]
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const CustomTheme: FC<RequiredChildrenProps> = memo(({ children }) => (
|
||||
const CustomTheme: FC<RequiredChildrenProps> = ({ children }) => (
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
));
|
||||
);
|
||||
|
||||
export default CustomTheme;
|
||||
|
||||
@@ -1,28 +1,24 @@
|
||||
import { memo, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { FC, useContext, useState } from 'react';
|
||||
import { ValidateFieldsError } from 'async-validator';
|
||||
import { useSnackbar } from 'notistack';
|
||||
|
||||
import { Box, Fab, Paper, Typography } from '@mui/material';
|
||||
import ForwardIcon from '@mui/icons-material/Forward';
|
||||
import { Box, Button, Paper, Typography } from '@mui/material';
|
||||
|
||||
import * as AuthenticationApi from 'components/routing/authentication';
|
||||
import { useRequest } from 'alova/client';
|
||||
import type { ValidateFieldsError } from 'async-validator';
|
||||
import {
|
||||
LanguageSelector,
|
||||
ValidatedPasswordField,
|
||||
ValidatedTextField
|
||||
} from 'components';
|
||||
import { AuthenticationContext } from 'contexts/authentication';
|
||||
import { PROJECT_NAME } from 'env';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
import type { SignInRequest } from 'types';
|
||||
import { onEnterCallback, updateValue } from 'utils';
|
||||
import { SIGN_IN_REQUEST_VALIDATOR, validate } from 'validators';
|
||||
import * as AuthenticationApi from './api/authentication';
|
||||
import { PROJECT_NAME } from './api/env';
|
||||
import { AuthenticationContext } from './contexts/authentication';
|
||||
|
||||
const SignIn = memo(() => {
|
||||
import { AxiosError } from 'axios';
|
||||
|
||||
import { extractErrorMessage, onEnterCallback, updateValue } from './utils';
|
||||
import { SignInRequest } from './types';
|
||||
import { ValidatedTextField } from './components';
|
||||
import { SIGN_IN_REQUEST_VALIDATOR, validate } from './validators';
|
||||
|
||||
const SignIn: FC = () => {
|
||||
const authenticationContext = useContext(AuthenticationContext);
|
||||
|
||||
const { LL } = useI18nContext();
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
|
||||
const [signInRequest, setSignInRequest] = useState<SignInRequest>({
|
||||
username: '',
|
||||
@@ -31,64 +27,36 @@ const SignIn = memo(() => {
|
||||
const [processing, setProcessing] = useState<boolean>(false);
|
||||
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
||||
|
||||
const { send: callSignIn } = useRequest(
|
||||
(request: SignInRequest) => AuthenticationApi.signIn(request),
|
||||
{
|
||||
immediate: false
|
||||
}
|
||||
).onSuccess((response) => {
|
||||
if (response.data) {
|
||||
authenticationContext.signIn(response.data.access_token);
|
||||
}
|
||||
});
|
||||
const updateLoginRequestValue = updateValue(setSignInRequest);
|
||||
|
||||
// Memoize callback to prevent recreation on every render
|
||||
const updateLoginRequestValue = useMemo(
|
||||
() =>
|
||||
updateValue((updater) =>
|
||||
setSignInRequest(
|
||||
updater as unknown as (prevState: SignInRequest) => SignInRequest
|
||||
)
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
const signIn = useCallback(async () => {
|
||||
await callSignIn(signInRequest).catch((event: Error) => {
|
||||
if (event.message === 'Unauthorized') {
|
||||
toast.warning(LL.INVALID_LOGIN());
|
||||
} else {
|
||||
toast.error(LL.ERROR() + ' ' + event.message);
|
||||
}
|
||||
setProcessing(false);
|
||||
});
|
||||
}, [callSignIn, signInRequest, LL]);
|
||||
|
||||
const validateAndSignIn = useCallback(async () => {
|
||||
const validateAndSignIn = async () => {
|
||||
setProcessing(true);
|
||||
SIGN_IN_REQUEST_VALIDATOR.messages({
|
||||
required: LL.IS_REQUIRED('%s')
|
||||
});
|
||||
try {
|
||||
await validate(SIGN_IN_REQUEST_VALIDATOR, signInRequest);
|
||||
await signIn();
|
||||
} catch (error) {
|
||||
setFieldErrors(error as ValidateFieldsError);
|
||||
signIn();
|
||||
} catch (errors: any) {
|
||||
setFieldErrors(errors);
|
||||
setProcessing(false);
|
||||
}
|
||||
}, [signInRequest, signIn, LL]);
|
||||
};
|
||||
|
||||
// Memoize callback to prevent recreation on every render
|
||||
const submitOnEnter = useMemo(() => onEnterCallback(signIn), [signIn]);
|
||||
const signIn = async () => {
|
||||
try {
|
||||
const { data: loginResponse } = await AuthenticationApi.signIn(signInRequest);
|
||||
authenticationContext.signIn(loginResponse.access_token);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof AxiosError) {
|
||||
if (error.response?.status === 401) {
|
||||
enqueueSnackbar('Invalid login details', { variant: 'warning' });
|
||||
}
|
||||
} else {
|
||||
enqueueSnackbar(extractErrorMessage(error, 'Unexpected error, please try again'), { variant: 'error' });
|
||||
}
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 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);
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -104,68 +72,46 @@ const SignIn = memo(() => {
|
||||
sx={(theme) => ({
|
||||
textAlign: 'center',
|
||||
padding: theme.spacing(2),
|
||||
paddingTop: '172px',
|
||||
paddingTop: '200px',
|
||||
backgroundImage: 'url("/app/icon.png")',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: '50% ' + theme.spacing(2),
|
||||
backgroundSize: 'auto 150px',
|
||||
width: '100%'
|
||||
})}
|
||||
>
|
||||
<Typography mb={1} variant="h4">
|
||||
{PROJECT_NAME}
|
||||
</Typography>
|
||||
<LanguageSelector />
|
||||
<Box
|
||||
mt={1}
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
gap={1}
|
||||
alignItems="center"
|
||||
>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
disabled={processing}
|
||||
sx={{
|
||||
width: '32ch'
|
||||
}}
|
||||
name="username"
|
||||
label={LL.USERNAME(0)}
|
||||
value={signInRequest.username}
|
||||
onChange={updateLoginRequestValue}
|
||||
slotProps={{
|
||||
input: {
|
||||
autoCapitalize: 'none',
|
||||
autoCorrect: 'off'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<ValidatedPasswordField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
disabled={processing}
|
||||
sx={{
|
||||
width: '32ch'
|
||||
}}
|
||||
name="password"
|
||||
label={LL.PASSWORD()}
|
||||
value={signInRequest.password}
|
||||
onChange={updateLoginRequestValue}
|
||||
onKeyDown={submitOnEnter}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
sx={{ mt: 2 }}
|
||||
onClick={validateAndSignIn}
|
||||
<Typography variant="h4">{PROJECT_NAME}</Typography>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors}
|
||||
disabled={processing}
|
||||
>
|
||||
name="username"
|
||||
label="Username"
|
||||
value={signInRequest.username}
|
||||
onChange={updateLoginRequestValue}
|
||||
margin="normal"
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
/>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors}
|
||||
disabled={processing}
|
||||
type="password"
|
||||
name="password"
|
||||
label="Password"
|
||||
value={signInRequest.password}
|
||||
onChange={updateLoginRequestValue}
|
||||
onKeyDown={submitOnEnter}
|
||||
margin="normal"
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
/>
|
||||
<Fab variant="extended" color="primary" sx={{ mt: 2 }} onClick={validateAndSignIn} disabled={processing}>
|
||||
<ForwardIcon sx={{ mr: 1 }} />
|
||||
{LL.SIGN_IN()}
|
||||
</Button>
|
||||
Sign In
|
||||
</Fab>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export default SignIn;
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import type { APSettingsType, APStatusType } from 'types';
|
||||
import { AxiosPromise } from 'axios';
|
||||
|
||||
import { alovaInstance } from './endpoints';
|
||||
import { APSettings, APStatus } from '../types';
|
||||
import { AXIOS } from './endpoints';
|
||||
|
||||
export const readAPStatus = () => alovaInstance.Get<APStatusType>('/rest/apStatus');
|
||||
export const readAPSettings = () =>
|
||||
alovaInstance.Get<APSettingsType>('/rest/apSettings');
|
||||
export const updateAPSettings = (data: APSettingsType) =>
|
||||
alovaInstance.Post<APSettingsType>('/rest/apSettings', data);
|
||||
export function readAPStatus(): AxiosPromise<APStatus> {
|
||||
return AXIOS.get('/apStatus');
|
||||
}
|
||||
|
||||
export function readAPSettings(): AxiosPromise<APSettings> {
|
||||
return AXIOS.get('/apSettings');
|
||||
}
|
||||
|
||||
export function updateAPSettings(apSettings: APSettings): AxiosPromise<APSettings> {
|
||||
return AXIOS.post('/apSettings', apSettings);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
64
interface/src/api/authentication.ts
Normal file
64
interface/src/api/authentication.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { AxiosPromise } from 'axios';
|
||||
import * as H from 'history';
|
||||
import jwtDecode from 'jwt-decode';
|
||||
import { Path } from 'react-router-dom';
|
||||
|
||||
import { Features, Me, SignInRequest, SignInResponse } from '../types';
|
||||
|
||||
import { ACCESS_TOKEN, AXIOS } from './endpoints';
|
||||
import { PROJECT_PATH } from './env';
|
||||
|
||||
export const SIGN_IN_PATHNAME = 'loginPathname';
|
||||
export const SIGN_IN_SEARCH = 'loginSearch';
|
||||
|
||||
export const getDefaultRoute = (features: Features) => (features.project ? `/${PROJECT_PATH}` : '/wifi');
|
||||
|
||||
export function verifyAuthorization(): AxiosPromise<void> {
|
||||
return AXIOS.get('/verifyAuthorization');
|
||||
}
|
||||
|
||||
export function signIn(request: SignInRequest): AxiosPromise<SignInResponse> {
|
||||
return AXIOS.post('/signIn', request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback to sessionStorage if localStorage is absent. WebView may not have local storage enabled.
|
||||
*/
|
||||
export function getStorage() {
|
||||
return localStorage || sessionStorage;
|
||||
}
|
||||
|
||||
export function storeLoginRedirect(location?: H.Location) {
|
||||
if (location) {
|
||||
getStorage().setItem(SIGN_IN_PATHNAME, location.pathname);
|
||||
getStorage().setItem(SIGN_IN_SEARCH, location.search);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearLoginRedirect() {
|
||||
getStorage().removeItem(SIGN_IN_PATHNAME);
|
||||
getStorage().removeItem(SIGN_IN_SEARCH);
|
||||
}
|
||||
|
||||
export function fetchLoginRedirect(features: Features): Partial<Path> {
|
||||
const signInPathname = getStorage().getItem(SIGN_IN_PATHNAME);
|
||||
const signInSearch = getStorage().getItem(SIGN_IN_SEARCH);
|
||||
clearLoginRedirect();
|
||||
return {
|
||||
pathname: signInPathname || getDefaultRoute(features),
|
||||
search: (signInPathname && signInSearch) || undefined
|
||||
};
|
||||
}
|
||||
|
||||
export const clearAccessToken = () => localStorage.removeItem(ACCESS_TOKEN);
|
||||
export const decodeMeJWT = (accessToken: string): Me => jwtDecode(accessToken) as Me;
|
||||
|
||||
export function addAccessTokenParameter(url: string) {
|
||||
const accessToken = getStorage().getItem(ACCESS_TOKEN);
|
||||
if (!accessToken) {
|
||||
return url;
|
||||
}
|
||||
const parsedUrl = new URL(url);
|
||||
parsedUrl.searchParams.set(ACCESS_TOKEN, accessToken);
|
||||
return parsedUrl.toString();
|
||||
}
|
||||
@@ -1,68 +1,105 @@
|
||||
import { type AlovaXHRResponse, xhrRequestAdapter } from '@alova/adapter-xhr';
|
||||
import { createAlova } from 'alova';
|
||||
import ReactHook from 'alova/react';
|
||||
import axios, { AxiosPromise, CancelToken } from 'axios';
|
||||
|
||||
import { unpack } from './unpack';
|
||||
import { decode } from '@msgpack/msgpack';
|
||||
|
||||
export const ACCESS_TOKEN = 'access_token' as const;
|
||||
export const WS_BASE_URL = '/ws/';
|
||||
export const API_BASE_URL = '/rest/';
|
||||
export const ES_BASE_URL = '/es/';
|
||||
export const EMSESP_API_BASE_URL = '/api/';
|
||||
export const ACCESS_TOKEN = 'access_token';
|
||||
export const WEB_SOCKET_ROOT = calculateWebSocketRoot(WS_BASE_URL);
|
||||
export const EVENT_SOURCE_ROOT = calculateEventSourceRoot(ES_BASE_URL);
|
||||
|
||||
// Cached token to avoid repeated localStorage access
|
||||
let cachedToken: string | null = null;
|
||||
|
||||
const getAccessToken = (): string | null => {
|
||||
if (cachedToken === null) {
|
||||
cachedToken = localStorage.getItem(ACCESS_TOKEN);
|
||||
}
|
||||
return cachedToken;
|
||||
};
|
||||
|
||||
// Clear token cache when needed (e.g., on logout)
|
||||
export const clearTokenCache = (): void => {
|
||||
cachedToken = null;
|
||||
};
|
||||
|
||||
const handleResponse = async (response: AlovaXHRResponse) => {
|
||||
// Handle various HTTP status codes
|
||||
if (response.status === 205) {
|
||||
throw new Error('Reboot required');
|
||||
}
|
||||
if (response.status === 400) {
|
||||
throw new Error('Request Failed');
|
||||
}
|
||||
if (response.status >= 400) {
|
||||
throw new Error(response.statusText);
|
||||
}
|
||||
|
||||
const data = (await response.data) as ArrayBuffer;
|
||||
|
||||
// Unpack MessagePack data if ArrayBuffer
|
||||
if (data instanceof ArrayBuffer) {
|
||||
return unpack(data) as ArrayBuffer;
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
export const alovaInstance = createAlova({
|
||||
statesHook: ReactHook,
|
||||
cacheFor: null, // disable cache
|
||||
requestAdapter: xhrRequestAdapter(),
|
||||
beforeRequest(method) {
|
||||
const token = getAccessToken();
|
||||
if (token) {
|
||||
method.config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
export const AXIOS = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
responded: {
|
||||
onSuccess: handleResponse
|
||||
}
|
||||
transformRequest: [
|
||||
(data, headers) => {
|
||||
if (headers) {
|
||||
if (localStorage.getItem(ACCESS_TOKEN)) {
|
||||
headers.Authorization = 'Bearer ' + localStorage.getItem(ACCESS_TOKEN);
|
||||
}
|
||||
if (headers['Content-Type'] !== 'application/json') {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
return JSON.stringify(data);
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
export const alovaInstanceGH = createAlova({
|
||||
baseURL:
|
||||
process.env.NODE_ENV === 'development'
|
||||
? '/gh'
|
||||
: 'https://api.github.com/repos/emsesp/EMS-ESP32/releases',
|
||||
statesHook: ReactHook,
|
||||
requestAdapter: xhrRequestAdapter()
|
||||
export const AXIOS_API = axios.create({
|
||||
baseURL: EMSESP_API_BASE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
transformRequest: [
|
||||
(data, headers) => {
|
||||
if (headers) {
|
||||
if (localStorage.getItem(ACCESS_TOKEN)) {
|
||||
headers.Authorization = 'Bearer ' + localStorage.getItem(ACCESS_TOKEN);
|
||||
}
|
||||
if (headers['Content-Type'] !== 'application/json') {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
return JSON.stringify(data);
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
export const AXIOS_BIN = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
responseType: 'arraybuffer',
|
||||
transformRequest: [
|
||||
(data, headers) => {
|
||||
if (headers) {
|
||||
if (localStorage.getItem(ACCESS_TOKEN)) {
|
||||
headers.Authorization = 'Bearer ' + localStorage.getItem(ACCESS_TOKEN);
|
||||
}
|
||||
if (headers['Content-Type'] !== 'application/json') {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
return JSON.stringify(data);
|
||||
}
|
||||
],
|
||||
transformResponse: [
|
||||
(data) => {
|
||||
return decode(data);
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
function calculateWebSocketRoot(webSocketPath: string) {
|
||||
const location = window.location;
|
||||
const webProtocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
return webProtocol + '//' + location.host + webSocketPath;
|
||||
}
|
||||
|
||||
function calculateEventSourceRoot(endpointPath: string) {
|
||||
const location = window.location;
|
||||
return location.protocol + '//' + location.host + endpointPath;
|
||||
}
|
||||
|
||||
export interface FileUploadConfig {
|
||||
cancelToken?: CancelToken;
|
||||
onUploadProgress?: (progressEvent: ProgressEvent) => void;
|
||||
}
|
||||
|
||||
export const startUploadFile = (url: string, file: File, config?: FileUploadConfig): AxiosPromise<void> => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
return AXIOS.post(url, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
},
|
||||
...(config || {})
|
||||
});
|
||||
};
|
||||
|
||||
2
interface/src/api/env.ts
Normal file
2
interface/src/api/env.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const PROJECT_NAME = process.env.REACT_APP_PROJECT_NAME || 'EMS-ESP';
|
||||
export const PROJECT_PATH = process.env.REACT_APP_PROJECT_PATH || 'project';
|
||||
9
interface/src/api/features.ts
Normal file
9
interface/src/api/features.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { AxiosPromise } from 'axios';
|
||||
|
||||
import { Features } from '../types';
|
||||
|
||||
import { AXIOS } from './endpoints';
|
||||
|
||||
export function readFeatures(): AxiosPromise<Features> {
|
||||
return AXIOS.get('/features');
|
||||
}
|
||||
@@ -1,10 +1,16 @@
|
||||
import type { MqttSettingsType, MqttStatusType } from 'types';
|
||||
import { AxiosPromise } from 'axios';
|
||||
import { MqttSettings, MqttStatus } from '../types';
|
||||
|
||||
import { alovaInstance } from './endpoints';
|
||||
import { AXIOS } from './endpoints';
|
||||
|
||||
export const readMqttStatus = () =>
|
||||
alovaInstance.Get<MqttStatusType>('/rest/mqttStatus');
|
||||
export const readMqttSettings = () =>
|
||||
alovaInstance.Get<MqttSettingsType>('/rest/mqttSettings');
|
||||
export const updateMqttSettings = (data: MqttSettingsType) =>
|
||||
alovaInstance.Post<MqttSettingsType>('/rest/mqttSettings', data);
|
||||
export function readMqttStatus(): AxiosPromise<MqttStatus> {
|
||||
return AXIOS.get('/mqttStatus');
|
||||
}
|
||||
|
||||
export function readMqttSettings(): AxiosPromise<MqttSettings> {
|
||||
return AXIOS.get('/mqttSettings');
|
||||
}
|
||||
|
||||
export function updateMqttSettings(ntpSettings: MqttSettings): AxiosPromise<MqttSettings> {
|
||||
return AXIOS.post('/mqttSettings', ntpSettings);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
import type { NetworkSettingsType, NetworkStatusType, WiFiNetworkList } from 'types';
|
||||
import { AxiosPromise } from 'axios';
|
||||
|
||||
import { alovaInstance } from './endpoints';
|
||||
import { WiFiNetworkList, NetworkSettings, NetworkStatus } from '../types';
|
||||
|
||||
const LIST_NETWORKS_TIMEOUT = 20000; // 20 seconds
|
||||
import { AXIOS } from './endpoints';
|
||||
|
||||
export const readNetworkStatus = () =>
|
||||
alovaInstance.Get<NetworkStatusType>('/rest/networkStatus');
|
||||
export const scanNetworks = () => alovaInstance.Get('/rest/scanNetworks');
|
||||
export const listNetworks = () =>
|
||||
alovaInstance.Get<WiFiNetworkList>('/rest/listNetworks', {
|
||||
timeout: LIST_NETWORKS_TIMEOUT
|
||||
});
|
||||
export const readNetworkSettings = () =>
|
||||
alovaInstance.Get<NetworkSettingsType>('/rest/networkSettings');
|
||||
export const updateNetworkSettings = (wifiSettings: NetworkSettingsType) =>
|
||||
alovaInstance.Post<NetworkSettingsType>('/rest/networkSettings', wifiSettings);
|
||||
export function readNetworkStatus(): AxiosPromise<NetworkStatus> {
|
||||
return AXIOS.get('/networkStatus');
|
||||
}
|
||||
|
||||
export function scanNetworks(): AxiosPromise<void> {
|
||||
return AXIOS.get('/scanNetworks');
|
||||
}
|
||||
|
||||
export function listNetworks(): AxiosPromise<WiFiNetworkList> {
|
||||
return AXIOS.get('/listNetworks');
|
||||
}
|
||||
|
||||
export function readNetworkSettings(): AxiosPromise<NetworkSettings> {
|
||||
return AXIOS.get('/networkSettings');
|
||||
}
|
||||
|
||||
export function updateNetworkSettings(wifiSettings: NetworkSettings): AxiosPromise<NetworkSettings> {
|
||||
return AXIOS.post('/networkSettings', wifiSettings);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import type { NTPSettingsType, NTPStatusType, Time } from 'types';
|
||||
import { AxiosPromise } from 'axios';
|
||||
import { NTPSettings, NTPStatus, Time } from '../types';
|
||||
|
||||
import { alovaInstance } from './endpoints';
|
||||
import { AXIOS } from './endpoints';
|
||||
|
||||
export const readNTPStatus = () =>
|
||||
alovaInstance.Get<NTPStatusType>('/rest/ntpStatus');
|
||||
export function readNTPStatus(): AxiosPromise<NTPStatus> {
|
||||
return AXIOS.get('/ntpStatus');
|
||||
}
|
||||
|
||||
export const readNTPSettings = () =>
|
||||
alovaInstance.Get<NTPSettingsType>('/rest/ntpSettings');
|
||||
export const updateNTPSettings = (data: NTPSettingsType) =>
|
||||
alovaInstance.Post<NTPSettingsType>('/rest/ntpSettings', data);
|
||||
export function readNTPSettings(): AxiosPromise<NTPSettings> {
|
||||
return AXIOS.get('/ntpSettings');
|
||||
}
|
||||
|
||||
export const updateTime = (data: Time) =>
|
||||
alovaInstance.Post<Time>('/rest/time', data);
|
||||
export function updateNTPSettings(ntpSettings: NTPSettings): AxiosPromise<NTPSettings> {
|
||||
return AXIOS.post('/ntpSettings', ntpSettings);
|
||||
}
|
||||
|
||||
export function updateTime(time: Time): AxiosPromise<Time> {
|
||||
return AXIOS.post('/time', time);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import type { SecuritySettingsType, Token } from 'types';
|
||||
import { AxiosPromise } from 'axios';
|
||||
|
||||
import { alovaInstance } from './endpoints';
|
||||
import { SecuritySettings, Token } from '../types';
|
||||
|
||||
export const readSecuritySettings = () =>
|
||||
alovaInstance.Get<SecuritySettingsType>('/rest/securitySettings');
|
||||
import { AXIOS } from './endpoints';
|
||||
|
||||
export const updateSecuritySettings = (securitySettings: SecuritySettingsType) =>
|
||||
alovaInstance.Post('/rest/securitySettings', securitySettings);
|
||||
export function readSecuritySettings(): AxiosPromise<SecuritySettings> {
|
||||
return AXIOS.get('/securitySettings');
|
||||
}
|
||||
|
||||
export const generateToken = (username?: string) =>
|
||||
alovaInstance.Get<Token>('/rest/generateToken', {
|
||||
params: { username }
|
||||
});
|
||||
export function updateSecuritySettings(securitySettings: SecuritySettings): AxiosPromise<SecuritySettings> {
|
||||
return AXIOS.post('/securitySettings', securitySettings);
|
||||
}
|
||||
|
||||
export function generateToken(username?: string): AxiosPromise<Token> {
|
||||
return AXIOS.get('/generateToken', { params: { username } });
|
||||
}
|
||||
|
||||
@@ -1,47 +1,41 @@
|
||||
import type { LogSettings, SystemStatus } from 'types';
|
||||
import { AxiosPromise } from 'axios';
|
||||
|
||||
import { alovaInstance, alovaInstanceGH } from './endpoints';
|
||||
import { OTASettings, SystemStatus, LogSettings, LogEntries } from '../types';
|
||||
|
||||
// systemStatus - also used to ping in System Monitor for pinging
|
||||
export const readSystemStatus = () =>
|
||||
alovaInstance.Get<SystemStatus>('/rest/systemStatus');
|
||||
import { AXIOS, AXIOS_BIN, FileUploadConfig, startUploadFile } from './endpoints';
|
||||
|
||||
// SystemLog
|
||||
export const readLogSettings = () =>
|
||||
alovaInstance.Get<LogSettings>('/rest/logSettings');
|
||||
export const updateLogSettings = (data: LogSettings) =>
|
||||
alovaInstance.Post('/rest/logSettings', data);
|
||||
export const fetchLogES = () => alovaInstance.Get('/es/log');
|
||||
export function readSystemStatus(timeout?: number): AxiosPromise<SystemStatus> {
|
||||
return AXIOS.get('/systemStatus', { timeout });
|
||||
}
|
||||
|
||||
// Get versions from GitHub
|
||||
// cache for 10 minutes to stop getting the IP blocked by GitHub
|
||||
export const getStableVersion = () =>
|
||||
alovaInstanceGH.Get('latest', {
|
||||
cacheFor: 60 * 10 * 1000,
|
||||
transform(response: { data: { name: string; published_at: string } }) {
|
||||
return {
|
||||
name: response.data.name.substring(1),
|
||||
published_at: response.data.published_at
|
||||
};
|
||||
}
|
||||
});
|
||||
export const getDevVersion = () =>
|
||||
alovaInstanceGH.Get('tags/latest', {
|
||||
cacheFor: 60 * 10 * 1000,
|
||||
transform(response: { data: { name: string; published_at: string } }) {
|
||||
return {
|
||||
name: response.data.name.split(/\s+/).splice(-1)[0]?.substring(1) || '',
|
||||
published_at: response.data.published_at
|
||||
};
|
||||
}
|
||||
});
|
||||
export function restart(): AxiosPromise<void> {
|
||||
return AXIOS.post('/restart');
|
||||
}
|
||||
|
||||
const UPLOAD_TIMEOUT = 60000; // 1 minute
|
||||
export function factoryReset(): AxiosPromise<void> {
|
||||
return AXIOS.post('/factoryReset');
|
||||
}
|
||||
|
||||
export function readOTASettings(): AxiosPromise<OTASettings> {
|
||||
return AXIOS.get('/otaSettings');
|
||||
}
|
||||
|
||||
export function updateOTASettings(otaSettings: OTASettings): AxiosPromise<OTASettings> {
|
||||
return AXIOS.post('/otaSettings', otaSettings);
|
||||
}
|
||||
|
||||
export const uploadFile = (file: File, config?: FileUploadConfig): AxiosPromise<void> =>
|
||||
startUploadFile('/uploadFile', file, config);
|
||||
|
||||
export function readLogSettings(): AxiosPromise<LogSettings> {
|
||||
return AXIOS.get('/logSettings');
|
||||
}
|
||||
|
||||
export function updateLogSettings(logSettings: LogSettings): AxiosPromise<LogSettings> {
|
||||
return AXIOS.post('/logSettings', logSettings);
|
||||
}
|
||||
|
||||
export function readLogEntries(): AxiosPromise<LogEntries> {
|
||||
return AXIOS_BIN.get('/fetchLog');
|
||||
}
|
||||
|
||||
export const uploadFile = (file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return alovaInstance.Post('/rest/uploadFile', formData, {
|
||||
timeout: UPLOAD_TIMEOUT
|
||||
});
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,245 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import WarningIcon from '@mui/icons-material/Warning';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
FormHelperText,
|
||||
Grid,
|
||||
InputAdornment,
|
||||
MenuItem,
|
||||
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 { DeviceValueUOM, DeviceValueUOM_s } from './types';
|
||||
import type { DeviceValue } from './types';
|
||||
|
||||
interface DevicesDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (as: DeviceValue) => void;
|
||||
selectedItem: DeviceValue;
|
||||
writeable: boolean;
|
||||
validator: Schema;
|
||||
progress: boolean;
|
||||
}
|
||||
|
||||
const DevicesDialog = ({
|
||||
open,
|
||||
onClose,
|
||||
onSave,
|
||||
selectedItem,
|
||||
writeable,
|
||||
validator,
|
||||
progress
|
||||
}: DevicesDialogProps) => {
|
||||
const { LL } = useI18nContext();
|
||||
const [editItem, setEditItem] = useState<DeviceValue>(selectedItem);
|
||||
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
||||
|
||||
const updateFormValue = useMemo(() => updateValue(setEditItem), [setEditItem]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setFieldErrors(undefined);
|
||||
setEditItem(selectedItem);
|
||||
}
|
||||
}, [open, selectedItem]);
|
||||
|
||||
const save = useCallback(async () => {
|
||||
try {
|
||||
setFieldErrors(undefined);
|
||||
await validate(validator, editItem);
|
||||
onSave(editItem);
|
||||
} catch (error) {
|
||||
setFieldErrors(error as ValidateFieldsError);
|
||||
}
|
||||
}, [validator, editItem, onSave]);
|
||||
|
||||
const setUom = useCallback(
|
||||
(uom?: DeviceValueUOM) => {
|
||||
if (uom === undefined) {
|
||||
return;
|
||||
}
|
||||
switch (uom) {
|
||||
case DeviceValueUOM.HOURS:
|
||||
return LL.HOURS();
|
||||
case DeviceValueUOM.MINUTES:
|
||||
return LL.MINUTES();
|
||||
case DeviceValueUOM.SECONDS:
|
||||
return LL.SECONDS();
|
||||
default:
|
||||
return DeviceValueUOM_s[uom];
|
||||
}
|
||||
},
|
||||
[LL]
|
||||
);
|
||||
|
||||
const showHelperText = useCallback((dv: DeviceValue) => {
|
||||
if (dv.h) return dv.h;
|
||||
if (dv.l) return dv.l.join(' | ');
|
||||
if (dv.m !== undefined && dv.x !== undefined) {
|
||||
return (
|
||||
<>
|
||||
{dv.m} → {dv.x}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}, []);
|
||||
|
||||
const isCommand = useMemo(
|
||||
() => selectedItem.v === '' && selectedItem.c,
|
||||
[selectedItem.v, selectedItem.c]
|
||||
);
|
||||
|
||||
const dialogTitle = useMemo(() => {
|
||||
if (isCommand) return LL.RUN_COMMAND();
|
||||
return writeable ? LL.CHANGE_VALUE() : LL.VALUE(0);
|
||||
}, [isCommand, writeable, LL]);
|
||||
|
||||
const buttonLabel = useMemo(() => {
|
||||
return isCommand ? LL.EXECUTE() : LL.UPDATE();
|
||||
}, [isCommand, LL]);
|
||||
|
||||
const helperText = useMemo(
|
||||
() => showHelperText(editItem),
|
||||
[editItem, showHelperText]
|
||||
);
|
||||
|
||||
const valueLabel = LL.VALUE(0);
|
||||
|
||||
return (
|
||||
<Dialog sx={dialogStyle} open={open} onClose={onClose}>
|
||||
<DialogTitle>{dialogTitle}</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<Box color="warning.main" mb={2}>
|
||||
<Typography variant="body2">{editItem.id.slice(2)}</Typography>
|
||||
</Box>
|
||||
<Grid container>
|
||||
<Grid size={12}>
|
||||
{editItem.l ? (
|
||||
<TextField
|
||||
name="v"
|
||||
value={editItem.v}
|
||||
aria-label={valueLabel}
|
||||
disabled={!writeable}
|
||||
sx={{ width: '30ch' }}
|
||||
select
|
||||
onChange={updateFormValue}
|
||||
>
|
||||
{editItem.l.map((val) => (
|
||||
<MenuItem value={val} key={val}>
|
||||
{val}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
) : editItem.s || editItem.u !== DeviceValueUOM.NONE ? (
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
name="v"
|
||||
label={valueLabel}
|
||||
value={numberValue(Math.round((editItem.v as number) * 10) / 10)}
|
||||
autoFocus
|
||||
disabled={!writeable}
|
||||
type="number"
|
||||
sx={{ width: '30ch' }}
|
||||
onChange={updateFormValue}
|
||||
slotProps={{
|
||||
htmlInput: editItem.s
|
||||
? { min: editItem.m, max: editItem.x, step: editItem.s }
|
||||
: {},
|
||||
input: {
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
{setUom(editItem.u)}
|
||||
</InputAdornment>
|
||||
)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
name="v"
|
||||
label={valueLabel}
|
||||
value={editItem.v}
|
||||
disabled={!writeable}
|
||||
sx={{ width: '30ch' }}
|
||||
multiline={!editItem.u}
|
||||
onChange={updateFormValue}
|
||||
/>
|
||||
)}
|
||||
</Grid>
|
||||
{writeable && helperText && (
|
||||
<Grid>
|
||||
<FormHelperText>{helperText}</FormHelperText>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
{writeable ? (
|
||||
<Box
|
||||
sx={{
|
||||
'& button, & a, & .MuiCard-root': {
|
||||
mx: 0.6
|
||||
},
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
startIcon={<CancelIcon />}
|
||||
variant="outlined"
|
||||
onClick={onClose}
|
||||
color="secondary"
|
||||
>
|
||||
{LL.CANCEL()}
|
||||
</Button>
|
||||
<Button
|
||||
startIcon={<WarningIcon color="warning" />}
|
||||
variant="outlined"
|
||||
onClick={save}
|
||||
color="primary"
|
||||
>
|
||||
{buttonLabel}
|
||||
</Button>
|
||||
{progress && (
|
||||
<CircularProgress
|
||||
size={24}
|
||||
sx={{
|
||||
color: '#4caf50',
|
||||
position: 'absolute',
|
||||
right: '20%',
|
||||
marginTop: '6px'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<Button variant="outlined" onClick={onClose} color="secondary">
|
||||
{LL.CLOSE()}
|
||||
</Button>
|
||||
)}
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default DevicesDialog;
|
||||
@@ -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,50 +0,0 @@
|
||||
import { memo } from 'react';
|
||||
|
||||
import CommentsDisabledOutlinedIcon from '@mui/icons-material/CommentsDisabledOutlined';
|
||||
import DeleteForeverIcon from '@mui/icons-material/DeleteForever';
|
||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||
import EditOffOutlinedIcon from '@mui/icons-material/EditOffOutlined';
|
||||
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
|
||||
import InsertCommentOutlinedIcon from '@mui/icons-material/InsertCommentOutlined';
|
||||
import StarIcon from '@mui/icons-material/Star';
|
||||
import StarOutlineIcon from '@mui/icons-material/StarOutline';
|
||||
import VisibilityOffOutlinedIcon from '@mui/icons-material/VisibilityOffOutlined';
|
||||
import VisibilityOutlinedIcon from '@mui/icons-material/VisibilityOutlined';
|
||||
import type { SvgIconProps } from '@mui/material';
|
||||
|
||||
export type OptionType =
|
||||
| 'deleted'
|
||||
| 'readonly'
|
||||
| 'web_exclude'
|
||||
| 'api_mqtt_exclude'
|
||||
| 'favorite';
|
||||
|
||||
type IconPair = [
|
||||
React.ComponentType<SvgIconProps>,
|
||||
React.ComponentType<SvgIconProps>
|
||||
];
|
||||
|
||||
const OPTION_ICONS: Record<OptionType, IconPair> = {
|
||||
deleted: [DeleteForeverIcon, DeleteOutlineIcon],
|
||||
readonly: [EditOffOutlinedIcon, EditOutlinedIcon],
|
||||
web_exclude: [VisibilityOffOutlinedIcon, VisibilityOutlinedIcon],
|
||||
api_mqtt_exclude: [CommentsDisabledOutlinedIcon, InsertCommentOutlinedIcon],
|
||||
favorite: [StarIcon, StarOutlineIcon]
|
||||
} as const;
|
||||
|
||||
const ICON_SIZE = 16;
|
||||
const ICON_SX = { fontSize: ICON_SIZE, verticalAlign: 'middle' } as const;
|
||||
|
||||
export interface OptionIconProps {
|
||||
readonly type: OptionType;
|
||||
readonly isSet: boolean;
|
||||
}
|
||||
|
||||
const OptionIcon = ({ type, isSet }: OptionIconProps) => {
|
||||
const [SetIcon, UnsetIcon] = OPTION_ICONS[type];
|
||||
const Icon = isSet ? SetIcon : UnsetIcon;
|
||||
|
||||
return <Icon {...(isSet && { color: 'primary' })} sx={ICON_SX} />;
|
||||
};
|
||||
|
||||
export default memo(OptionIcon);
|
||||
@@ -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;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user