176 Commits

Author SHA1 Message Date
Proddy
9889b1b5c4 Merge pull request #2796 from gr3enk/dev
Adding api/system/metrics endpoint for Prometheus integration
2025-12-08 18:49:42 +01:00
Jakob
bffccc585a test: add tests for /api/system/metrics endpoint 2025-12-07 11:32:37 +01:00
Jakob
a01f10b042 feat: add /api/system/metrics endpoint 2025-12-07 11:31:55 +01:00
Proddy
64058b0f61 Merge pull request #2792 from proddy/dev
small updates
2025-12-04 20:32:21 +01:00
proddy
d7b5c81b0e package update 2025-12-04 20:29:20 +01:00
proddy
02e8dba971 fix max size to 32MB 2025-12-03 22:16:39 +01:00
proddy
59878fb190 remove vscode settings and package.json 2025-12-03 22:11:44 +01:00
proddy
9ff0f83af9 remove obsolete double auto-commit 2025-12-03 22:06:06 +01:00
proddy
e6f825371e fix make for Windows 2025-12-03 22:05:45 +01:00
proddy
45f3f23033 package update 2025-12-03 21:53:33 +01:00
proddy
ffd27db208 show elapsed time 2025-12-03 21:53:26 +01:00
proddy
a452d6131b DiffTemp ranged to 4-15 K - fix #2783 2025-12-03 21:53:16 +01:00
Proddy
03ef981765 Merge pull request #2788 from proddy/dev
fix mem issue
2025-12-02 19:42:05 +01:00
proddy
9ca9f25fd3 rollback modbus hash map - fix #2752 2025-12-02 19:41:17 +01:00
proddy
41122dddb2 dev-34 2025-12-02 19:40:45 +01:00
proddy
1e0c94d007 package update 2025-12-02 19:40:38 +01:00
proddy
3e42a7fb4c package update 2025-12-01 19:56:59 +01:00
proddy
a8fcc1fb44 show mem 2025-12-01 19:56:53 +01:00
proddy
e43416019d package update 2025-12-01 17:40:53 +01:00
Proddy
9f467ecec1 Merge pull request #2782 from proddy/dev
MQTT base fixes
2025-11-30 23:28:16 +01:00
proddy
273d87dbf1 use ~ as MQTT base 2025-11-30 23:27:28 +01:00
proddy
befd21f8cb fixe #2780 2025-11-30 23:27:18 +01:00
Proddy
15e05c4abc Merge pull request #2778 from proddy/dev
update to show heap
2025-11-30 20:51:42 +01:00
proddy
748a2f5fcf update to show heap 2025-11-30 20:51:17 +01:00
Proddy
37ba42faf8 Merge pull request #2775 from proddy/dev
loop tests
2025-11-30 17:24:56 +01:00
proddy
19e343e517 loop tests 2025-11-30 17:23:58 +01:00
Proddy
8f39129bf8 Merge pull request #2773 from proddy/dev
a collection of changes
2025-11-30 15:43:18 +01:00
proddy
9c3521caf2 add target native-test-create 2025-11-30 15:39:36 +01:00
proddy
40fc0fd2f9 anti-rollback! 2025-11-30 15:39:13 +01:00
proddy
ff8566498f typo 2025-11-30 15:15:03 +01:00
proddy
dd06882860 auto-formatting 2025-11-30 14:05:50 +01:00
proddy
fb2294c945 added Prometheus metrics 2025-11-30 14:05:02 +01:00
proddy
26ea8320ce rollback for #2752 2025-11-30 14:04:45 +01:00
proddy
0f4963d91e formatting 2025-11-30 14:04:14 +01:00
proddy
91020abc90 formatting 2025-11-30 14:04:05 +01:00
Proddy
64906f3ea0 Merge branch 'emsesp:dev' into dev 2025-11-30 13:54:26 +01:00
Proddy
28f85b4c5a Merge pull request #2774 from gr3enk/dev
Adding api/metrics endpoint for Prometheus integration
2025-11-30 13:51:47 +01:00
Jakob
b44a0d6813 test: add tests for api/metrics endpoint 2025-11-30 10:37:34 +01:00
Jakob
8af7cde2d6 feat: add api/metrics endpoint 2025-11-30 10:35:19 +01:00
proddy
3fcd656bb6 package update with new alova 2025-11-30 10:22:20 +01:00
proddy
76c827257e mqtt heartbeat, no need to check if connected 2025-11-30 10:22:08 +01:00
proddy
8ca9f7ee30 change ip 2025-11-29 15:36:24 +01:00
proddy
da7a06646a rollback AsyncWS fix #2752 2025-11-29 15:35:57 +01:00
proddy
0a36f1df7a updated 2025-11-29 15:14:33 +01:00
proddy
80e5d30781 dev-33 2025-11-29 15:14:26 +01:00
proddy
9c4beba3b1 add LWT to HA discovery config topic 2025-11-29 15:12:07 +01:00
proddy
0cf932f57e comment change 2025-11-29 14:49:08 +01:00
proddy
5cb9f3b014 init board profile correctly 2025-11-29 14:48:57 +01:00
proddy
3eb581142a default keep alive 60 seconds 2025-11-29 14:48:42 +01:00
proddy
d6c460e7fd ESP32Async/ESPAsyncWebServer @ 3.9.2 2025-11-29 14:48:24 +01:00
proddy
a2baa50530 show users reports error if not admin 2025-11-29 14:48:13 +01:00
proddy
6569b8c038 enable TLS for test data 2025-11-29 14:47:42 +01:00
proddy
48b4bf02a3 wider box for TLS 2025-11-29 14:47:35 +01:00
proddy
693054a92a package update 2025-11-29 14:47:27 +01:00
Proddy
a738cc36dd Merge pull request #2762 from proddy/dev
gpio update
2025-11-26 17:03:09 +01:00
proddy
9b6f9aeda3 dev-32 2025-11-26 16:52:54 +01:00
proddy
8ea8c1821d Merge branch 'dev' of https://github.com/proddy/EMS-ESP32 into dev 2025-11-26 14:00:08 +01:00
proddy
53a1a8826e update 2025-11-26 14:00:06 +01:00
proddy
237551d9f6 remove eth power gpio if set 2025-11-26 14:00:02 +01:00
proddy
f7c7bc65f2 keep loop even if there is gpio errors 2025-11-26 13:59:46 +01:00
proddy
badbd9c6fe show system status in status page 2025-11-26 13:59:34 +01:00
proddy
cc7ac5b911 package update 2025-11-26 13:59:08 +01:00
proddy
5957300c4e change (c) date to 2025 2025-11-24 23:27:01 +01:00
proddy
2e343ce0c3 optimize modbus 2025-11-24 23:25:53 +01:00
proddy
3113a4be2b remove old todos 2025-11-24 23:25:43 +01:00
proddy
7d9cb2932f update tests 2025-11-24 23:17:37 +01:00
proddy
026c2caea0 fix when manually setting board type 2025-11-24 23:17:27 +01:00
proddy
eb058b688d lint warnings 2025-11-24 22:37:51 +01:00
proddy
2a7a0ce3f6 formatting 2025-11-24 22:37:41 +01:00
proddy
a330110eef update 2025-11-24 22:37:30 +01:00
proddy
feed534be5 package update 2025-11-24 22:37:23 +01:00
proddy
2375633bca don't show confusing release type checkboxes 2025-11-24 22:35:07 +01:00
proddy
7018139289 update version data 2025-11-24 22:34:34 +01:00
proddy
5f67934f26 updated 2025-11-24 22:12:46 +01:00
proddy
574cc8ad33 error message text change 2025-11-24 22:12:41 +01:00
proddy
658c613c4e init data with 0, no difference really 2025-11-24 22:12:24 +01:00
proddy
c2ce54c2a7 adjust gpio for ESP32 boards 2025-11-24 16:58:06 +01:00
proddy
58da0d9778 update 2025-11-24 16:57:55 +01:00
proddy
8b90036c11 fixed uart0 for esp chips 2025-11-24 16:44:13 +01:00
proddy
65678ea739 package update 2025-11-24 16:44:03 +01:00
Proddy
34ddc9b7ff Merge pull request #2750 from proddy/dev
adjustments to invalid sensors
2025-11-23 20:35:46 +01:00
proddy
97f9914d33 grey out entities with no value 2025-11-23 20:32:25 +01:00
proddy
8b64851f6f renaming 2025-11-23 20:07:36 +01:00
proddy
c00f50238e add/remove analog gpio from list - fixes #2758 2025-11-23 20:07:22 +01:00
proddy
25ea57e02f don't allow gpio 1 and 3, as will conflict with Serial/UART0 2025-11-23 20:06:50 +01:00
proddy
a951afe205 rename valid_gpio_list to available_gpios 2025-11-23 20:06:26 +01:00
proddy
44c7954ce7 package update 2025-11-23 20:05:17 +01:00
proddy
ef315b6dde consistent borders and removed some comments 2025-11-23 16:55:46 +01:00
proddy
935a9dcbb7 fix remove_gpio 2025-11-23 16:55:25 +01:00
proddy
2bda432d70 show in log if we're autodetecting board 2025-11-23 16:54:56 +01:00
proddy
d567ea3cf0 adjusted remove pin 2025-11-23 16:34:06 +01:00
proddy
7b5e386595 update tests 2025-11-23 14:54:31 +01:00
proddy
1c65a7caba fix count of temp sensors - #2756 2025-11-23 14:46:57 +01:00
proddy
12ebacf1a7 typo 2025-11-23 14:13:29 +01:00
proddy
f2c176111f allow some strapping pins (Michael's fix) 2025-11-23 13:19:09 +01:00
proddy
cc242e5eba get rid of scrollbar 2025-11-23 13:12:59 +01:00
proddy
4888808ad0 skip if CUSTOM 2025-11-23 13:05:06 +01:00
proddy
cd1b5e1d57 skip if CUSTOM 2025-11-23 13:04:54 +01:00
proddy
9661c9a0eb typo 2025-11-23 13:04:31 +01:00
proddy
f6ccf6da44 formatting 2025-11-23 13:04:10 +01:00
proddy
b691488240 fix standalone 2025-11-22 23:05:58 +01:00
proddy
eb71996e6a align signon username/password boxes 2025-11-22 22:58:17 +01:00
proddy
b9566ae1d6 changed debug text 2025-11-22 22:57:59 +01:00
proddy
8016fc4287 gpio checking 2025-11-22 22:38:46 +01:00
proddy
c95b43ea69 remove emsesp namespace 2025-11-22 22:38:20 +01:00
proddy
d767a503cd remove log out of loop if we find a gpio error 2025-11-22 22:37:12 +01:00
proddy
221131f9d3 restart after setting board profile in console 2025-11-22 22:36:43 +01:00
proddy
f307cccabb don't check gpio 2025-11-22 22:36:15 +01:00
proddy
2f21d0d94c remove emsesp namespace 2025-11-22 22:34:28 +01:00
proddy
132d1292ce remove emsesp namespace 2025-11-22 22:34:14 +01:00
proddy
df9a10cb53 remove esmesp:: 2025-11-22 22:33:31 +01:00
proddy
95d564901b formatting 2025-11-22 22:33:17 +01:00
proddy
6424d9b1c0 package update 2025-11-22 22:33:06 +01:00
proddy
38a3d20acf prevent double calling api 2025-11-22 22:32:49 +01:00
proddy
89b117bbc2 formatting 2025-11-22 22:32:39 +01:00
proddy
eeba7a3a6b fix 2025-11-20 23:03:06 +01:00
proddy
23a660aabb updated gpio test logic 2025-11-20 22:58:26 +01:00
proddy
c9bddba446 fix standalone 2025-11-20 15:21:38 +01:00
proddy
3a508a3ec4 fixes #2752 2025-11-20 14:58:54 +01:00
proddy
6bf33f6447 update test API 2025-11-20 14:57:57 +01:00
proddy
a02054ceb6 new test for #2752 2025-11-20 14:35:58 +01:00
proddy
8422521975 formatting of error message box to align buttons 2025-11-20 14:25:25 +01:00
proddy
fbfacc5ed5 3.7.3-dev.31 2025-11-20 14:24:57 +01:00
proddy
b9d96620a4 show restart needed after changing board profile. also no need to restart sensors. 2025-11-20 14:24:40 +01:00
proddy
8c61735579 package update 2025-11-20 14:12:04 +01:00
proddy
56e8ccdfc4 remove gpio checks, as they are performed in supporting functions 2025-11-18 21:38:19 +01:00
proddy
5454e7dd16 warning message formatting so its same as Dashboard 2025-11-18 21:37:55 +01:00
proddy
901a27140c only dallas and led use GPIO 0 for disabled 2025-11-18 20:41:30 +01:00
proddy
37db2b9504 add comment 2025-11-18 20:40:07 +01:00
proddy
5e7768f912 package update 2025-11-18 20:39:51 +01:00
proddy
80dd16740d some fixes on https://github.com/emsesp/EMS-ESP32/pull/2750 2025-11-17 23:00:45 +01:00
proddy
24421a0224 package update 2025-11-17 17:50:43 +01:00
proddy
26d26cf088 formatting 2025-11-17 17:50:34 +01:00
proddy
615c5e8439 addition 2025-11-17 17:49:37 +01:00
proddy
090387ef37 replace disabled sensor gpio (99) with code logic 2025-11-17 17:49:30 +01:00
Proddy
25ea7d8b0c Merge pull request #2749 from proddy/dev
updated doc
2025-11-17 14:36:08 +01:00
proddy
68f067f2c4 added MQTT Refresh button 2025-11-17 14:35:32 +01:00
proddy
e20fa5ab39 package update 2025-11-17 14:35:21 +01:00
proddy
bee307a91d formatting 2025-11-17 14:35:14 +01:00
Proddy
f85226ce55 Merge pull request #2748 from MichaelDvP/dev
fix gpio check
2025-11-17 14:32:17 +01:00
MichaelDvP
f112e6f6cc exclude disabled sensors from dashboard 2025-11-17 13:57:30 +01:00
MichaelDvP
5df82b7e2c Merge branch 'dev' of https://github.com/emsesp/EMS-ESP32 into dev 2025-11-17 13:29:20 +01:00
MichaelDvP
ea75a34c82 fix gpio check 2025-11-17 13:28:00 +01:00
Proddy
07d0de0151 Merge pull request #2747 from proddy/dev
MQTT reset button & label color for TextFields when disabled
2025-11-17 12:27:11 +01:00
proddy
0a75dd7e3c new action resetMQTT, called from MQTT Settings page 2025-11-17 12:26:14 +01:00
proddy
88afd3f453 use ValidatedTextField 2025-11-17 12:25:52 +01:00
proddy
9669a044ba comment out log msg 2025-11-17 12:25:20 +01:00
proddy
e31ebab12b grey out label if disabled (for system sensors) 2025-11-17 12:24:58 +01:00
proddy
e68a3a0d3a update 2025-11-17 12:23:30 +01:00
Proddy
4ae09c8766 Merge pull request #2746 from MichaelDvP/dev
validate board gpios in system
2025-11-17 09:35:03 +01:00
MichaelDvP
a693e96248 validate board gpios in system 2025-11-16 20:41:45 +01:00
Proddy
60b0de79b3 Merge pull request #2744 from proddy/dev
webUI gpio improvements
2025-11-15 23:01:50 +01:00
proddy
86277396f7 fix 2025-11-15 22:05:35 +01:00
proddy
0b65d55601 update test data 2025-11-15 22:05:29 +01:00
proddy
23782ec773 show gpio for system sensors 2025-11-15 20:45:33 +01:00
proddy
0b33497842 update sensor names 2025-11-15 20:45:18 +01:00
proddy
6cd4bcd41c update 2025-11-15 20:22:52 +01:00
proddy
8d8db3ba85 update test data 2025-11-15 20:22:47 +01:00
proddy
d9c2066035 update gpios https://github.com/emsesp/EMS-ESP32/pull/2744 2025-11-15 20:22:37 +01:00
proddy
af5cbf045d update 2025-11-15 16:36:55 +01:00
proddy
99d67cdd42 auto-formatting 2025-11-15 16:36:13 +01:00
proddy
a74910ddf6 remove header for AsyncWS 3.9.0 2025-11-15 16:36:02 +01:00
proddy
d9678d04dd Merge branch 'dev' of https://github.com/proddy/EMS-ESP32 into dev 2025-11-15 16:27:40 +01:00
proddy
c7acf89d84 standardizing is_valid_gpio 2025-11-15 16:27:39 +01:00
Proddy
24f1fe13cc Merge branch 'emsesp:dev' into dev 2025-11-15 16:26:53 +01:00
Proddy
11fcd8bcfe Merge pull request #2745 from MichaelDvP/dev
process_telegram in single loop, update AsyncWebServer, dev30
2025-11-15 16:26:23 +01:00
proddy
bcde5bad63 build_webUI command does it all 2025-11-15 14:21:50 +01:00
proddy
d2a8fbaf1e formatting 2025-11-15 14:21:35 +01:00
proddy
f9d2b18959 package update 2025-11-15 14:21:28 +01:00
proddy
bdba2ae6e4 add clang's .cache folder 2025-11-15 14:21:16 +01:00
proddy
f068ed97f1 determine list of valid gpios in backend code 2025-11-15 14:20:43 +01:00
MichaelDvP
7ab2e782ac process_telegram in single loop, update AsyncWebServer, dev30 2025-11-15 12:49:30 +01:00
Proddy
88a7d12306 Merge pull request #2743 from MichaelDvP/dev
fix #2726
2025-11-14 21:47:03 +01:00
MichaelDvP
dffe8b2648 Merge branch 'dev' of https://github.com/emsesp/EMS-ESP32 into dev 2025-11-14 18:46:20 +01:00
MichaelDvP
daa98b106e update pkg 2025-11-14 18:43:11 +01:00
MichaelDvP
83796341c5 fix #2726 2025-11-14 17:46:13 +01:00
154 changed files with 2653 additions and 1894 deletions

View File

@@ -64,29 +64,7 @@ jobs:
- 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
commit_message: "chore: update generated files for v${{steps.build_info.outputs.VERSION}}"
- name: Create GitHub Release
id: 'automatic_releases'

5
.gitignore vendored
View File

@@ -2,7 +2,6 @@
.vscode/c_cpp_properties.json
.vscode/extensions.json
.vscode/launch.json
.vscode/settings.json
# c++ compiling
.clang_complete
@@ -73,4 +72,6 @@ logs/*
sdkconfig.*
sdkconfig_tasmota_esp32
pnpm-lock.yaml
package.json
.cache/
interface/.tsbuildinfo
test/test_api/package-lock.json

View File

@@ -32,6 +32,10 @@ For more details go to [docs.emsesp.org](https://docs.emsesp.org/).
- 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
@@ -66,3 +70,8 @@ For more details go to [docs.emsesp.org](https://docs.emsesp.org/).
- 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

View File

@@ -21,13 +21,14 @@ endif
# Optimize parallel build configuration
UNAME_S := $(shell uname -s)
JOBS ?= 1
ifeq ($(UNAME_S),Linux)
EXTRA_CPPFLAGS = -D LINUX
JOBS ?= $(shell nproc)
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)
JOBS := $(shell sysctl -n hw.ncpu)
endif
# Set optimal parallel build settings

View File

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

View File

@@ -17,31 +17,31 @@
"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",
"webUI": "vite build && node progmem-generator.js",
"build_webUI": "typesafe-i18n --no-watch && vite build && node progmem-generator.js",
"format": "prettier -l -w '**/*.{ts,tsx,js,css,json,md}'",
"lint": "eslint . --fix",
"standalone-devcontainer": "concurrently -c \"auto\" \"typesafe-i18n\" \"pnpm:mock-rest\" \"vite --host\""
},
"dependencies": {
"@alova/adapter-xhr": "2.2.1",
"@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",
"@mui/icons-material": "^7.3.6",
"@mui/material": "^7.3.6",
"@preact/compat": "^18.3.1",
"@table-library/react-table-library": "4.1.15",
"alova": "3.3.4",
"alova": "3.4.0",
"async-validator": "^4.2.5",
"etag": "^1.8.1",
"formidable": "^3.5.4",
"jwt-decode": "^4.0.0",
"magic-string": "^0.30.21",
"mime-types": "^3.0.1",
"preact": "^10.27.2",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"mime-types": "^3.0.2",
"preact": "^10.28.0",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"react-icons": "^5.5.0",
"react-router": "^7.9.6",
"react-router": "^7.10.1",
"react-toastify": "^11.0.5",
"typesafe-i18n": "^5.26.2",
"typescript": "^5.9.3"
@@ -53,19 +53,19 @@
"@preact/preset-vite": "^2.10.2",
"@trivago/prettier-plugin-sort-imports": "^6.0.0",
"@types/node": "^24.10.1",
"@types/react": "^19.2.4",
"@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.6.2",
"prettier": "^3.7.4",
"rollup-plugin-visualizer": "^6.0.5",
"terser": "^5.44.1",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.2",
"typescript-eslint": "^8.48.1",
"vite": "^7.2.6",
"vite-plugin-imagemin": "^0.6.1",
"vite-tsconfig-paths": "^5.1.4"
},
"packageManager": "pnpm@10.22.0+sha512.bf049efe995b28f527fd2b41ae0474ce29186f7edcb3bf545087bd61fbbebb2bf75362d1307fda09c2d288e1e499787ac12d4fcb617a974718a6051f2eee741c"
"packageManager": "pnpm@10.24.0+sha512.01ff8ae71b4419903b65c60fb2dc9d34cf8bb6e06d03bde112ef38f7a34d6904c424ba66bea5cdcf12890230bf39f9580473140ed9c946fef328b6e5238a345a"
}

775
interface/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,6 @@ import type { Locales } from 'i18n/i18n-types';
import { loadLocaleAsync } from 'i18n/i18n-util.async';
import { detectLocale, navigatorDetector } from 'typesafe-i18n/detectors';
// Memoize available locales to prevent recreation on every render
const AVAILABLE_LOCALES = [
'de',
'en',

View File

@@ -11,17 +11,15 @@ import { createTheme } from '@mui/material/styles';
import type { RequiredChildrenProps } from 'utils';
// Memoize dialog style to prevent recreation
export const dialogStyle = {
'& .MuiDialog-paper': {
borderRadius: '8px',
borderColor: '#565656',
borderStyle: 'solid',
borderWidth: '1px'
borderWidth: '2px'
}
} as const;
// Memoize theme creation to prevent recreation
const theme = responsiveFontSizes(
createTheme({
typography: {

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useContext, useMemo, useState } from 'react';
import { memo, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { toast } from 'react-toastify';
import ForwardIcon from '@mui/icons-material/Forward';
@@ -81,6 +81,15 @@ const SignIn = memo(() => {
// Memoize callback to prevent recreation on every render
const submitOnEnter = useMemo(() => onEnterCallback(signIn), [signIn]);
// get rid of scrollbar
useEffect(() => {
const originalOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = originalOverflow;
};
}, []);
return (
<Box
display="flex"
@@ -102,23 +111,27 @@ const SignIn = memo(() => {
width: '100%'
})}
>
<Typography variant="h4">{PROJECT_NAME}</Typography>
<Typography mb={1} variant="h4">
{PROJECT_NAME}
</Typography>
<LanguageSelector />
<Box display="flex" flexDirection="column" alignItems="center">
<Box
mt={1}
display="flex"
flexDirection="column"
gap={1}
alignItems="center"
>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
disabled={processing}
sx={{
width: 240
width: '32ch'
}}
name="username"
label={LL.USERNAME(0)}
value={signInRequest.username}
onChange={updateLoginRequestValue}
margin="normal"
variant="outlined"
slotProps={{
input: {
autoCapitalize: 'none',
@@ -130,14 +143,13 @@ const SignIn = memo(() => {
fieldErrors={fieldErrors || {}}
disabled={processing}
sx={{
width: 240
width: '32ch'
}}
name="password"
label={LL.PASSWORD()}
value={signInRequest.password}
onChange={updateLoginRequestValue}
onKeyDown={submitOnEnter}
variant="outlined"
/>
</Box>

View File

@@ -285,13 +285,17 @@ const Customizations = () => {
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 (de.n && de.n[0] === '!') {
if (isCommand(de)) {
name = de.t
? `${LL.COMMAND(1)}: ${de.t} ${de.n.slice(1)}`
: `${LL.COMMAND(1)}: ${de.n.slice(1)}`;
? `${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 {
@@ -543,7 +547,7 @@ const Customizations = () => {
return (
<>
<Box color="warning.main">
<Typography variant="body2" mt={1}>
<Typography variant="body2" mt={1} mb={1}>
<OptionIcon type="favorite" isSet={true} />={LL.CUSTOMIZATIONS_HELP_2()}
&nbsp;&nbsp;
<OptionIcon type="readonly" isSet={true} />={LL.CUSTOMIZATIONS_HELP_3()}
@@ -666,14 +670,27 @@ const Customizations = () => {
<EntityMaskToggle onUpdate={updateDeviceEntity} de={de} />
</Cell>
<Cell>
{formatName(de, false)}&nbsp;(
<Link
target="_blank"
href={APIURL + selectedDeviceTypeNameURL + '/' + de.id}
<span
style={{
color:
de.v === undefined && !isCommand(de) ? 'grey' : 'inherit'
}}
>
{de.id}
</Link>
)
{formatName(de, false)}&nbsp;(
<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)}
@@ -726,8 +743,9 @@ const Customizations = () => {
{devices && renderDeviceList()}
{selectedDevice !== -1 && !rename && renderDeviceData()}
{restartNeeded ? (
<MessageBox my={2} level="warning" message={LL.RESTART_TEXT(0)}>
<MessageBox level="warning" message={LL.RESTART_TEXT(0)}>
<Button
sx={{ ml: 2 }}
startIcon={<PowerSettingsNewIcon />}
variant="contained"
color="error"

View File

@@ -263,7 +263,7 @@ const Dashboard = memo(() => {
return (
<>
{!data.connected && (
<MessageBox mb={2} level="error" message={LL.EMS_BUS_WARNING()} />
<MessageBox level="error" message={LL.EMS_BUS_WARNING()} />
)}
{data.connected && data.nodes.length > 0 && !hasFavEntities && (

View File

@@ -533,19 +533,17 @@ const Devices = memo(() => {
const renderCoreData = () => (
<>
<Box justifyContent="center" flexDirection="column">
<IconContext.Provider
value={{
color: 'lightblue',
size: '18',
style: { verticalAlign: 'middle' }
}}
>
{!coreData.connected && (
<MessageBox my={2} level="error" message={LL.EMS_BUS_WARNING()} />
)}
{coreData.connected && (
{!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}
@@ -581,9 +579,9 @@ const Devices = memo(() => {
</>
)}
</Table>
)}
</IconContext.Provider>
</Box>
</IconContext.Provider>
</Box>
)}
</>
);

View File

@@ -1,4 +1,4 @@
import { useCallback, useContext, useMemo, useState } from 'react';
import { useCallback, useContext, useMemo, useRef, useState } from 'react';
import { toast } from 'react-toastify';
import AddCircleOutlineOutlinedIcon from '@mui/icons-material/AddCircleOutlineOutlined';
@@ -54,7 +54,6 @@ const MS_PER_SECOND = 1000;
const MS_PER_MINUTE = 60 * MS_PER_SECOND;
const MS_PER_HOUR = 60 * MS_PER_MINUTE;
const MS_PER_DAY = 24 * MS_PER_HOUR;
const DEFAULT_GPIO = 21; // Safe GPIO for all platforms
const MIN_TEMP_ID = -100;
const MAX_TEMP_ID = 100;
const GPIO_25 = 25;
@@ -128,14 +127,21 @@ const Sensors = () => {
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(
@@ -185,10 +191,14 @@ const Sensors = () => {
sortToggleType: SortToggleType.AlternateWithReset,
sortFns: {
GPIO: (array) =>
[...array].sort((a, b) => (a as AnalogSensor).g - (b as AnalogSensor).g),
[...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)
((a as AnalogSensor)?.n ?? '').localeCompare(
(b as AnalogSensor)?.n ?? ''
)
),
TYPE: (array) =>
[...array].sort((a, b) => (a as AnalogSensor).t - (b as AnalogSensor).t),
@@ -337,19 +347,23 @@ const Sensors = () => {
}, [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: DEFAULT_GPIO,
u: 0,
g: firstAvailableGPIO.current,
u: DeviceValueUOM.NONE,
v: 0,
o: 0,
t: 0,
f: 1,
t: AnalogType.DIGITAL_IN, // default to digital in 1
d: false,
o_n: '',
s: false
s: false,
o_n: ''
});
setAnalogDialogOpen(true);
}, []);
@@ -448,7 +462,7 @@ const Sensors = () => {
>
<Cell stiff>{as.g}</Cell>
<Cell>{as.n}</Cell>
<Cell stiff>{AnalogTypeNames[as.t]} </Cell>
<Cell stiff>{AnalogTypeNames[as.t - 1]} </Cell>
{(as.t === AnalogType.DIGITAL_OUT &&
as.g !== GPIO_25 &&
as.g !== GPIO_26) ||
@@ -456,9 +470,7 @@ const Sensors = () => {
as.t === AnalogType.PULSE ? (
<Cell stiff>{as.v ? LL.ON() : LL.OFF()}</Cell>
) : (
<Cell stiff>
{as.t !== AnalogType.NOTUSED ? formatValue(as.v, as.u) : ''}
</Cell>
<Cell stiff>{formatValue(as.v, as.u)}</Cell>
)}
</Row>
))}
@@ -573,12 +585,8 @@ const Sensors = () => {
onSave={onAnalogDialogSave}
creating={creating}
selectedItem={selectedAnalogSensor}
validator={analogSensorItemValidation(
sensorData.as,
selectedAnalogSensor,
creating,
sensorData.platform
)}
analogGPIOList={sensorData.available_gpios}
validator={analogSensorItemValidation(sensorData.as, selectedAnalogSensor)}
/>
)}
{sensorData?.analog_enabled === true && me.admin && (

View File

@@ -14,7 +14,6 @@ import {
Grid,
InputAdornment,
MenuItem,
TextField,
Typography
} from '@mui/material';
@@ -35,6 +34,7 @@ interface DashboardSensorsAnalogDialogProps {
onSave: (as: AnalogSensor) => void;
creating: boolean;
selectedItem: AnalogSensor;
analogGPIOList: number[];
validator: Schema;
}
@@ -44,6 +44,7 @@ const SensorsAnalogDialog = ({
onSave,
creating,
selectedItem,
analogGPIOList,
validator
}: DashboardSensorsAnalogDialogProps) => {
const { LL } = useI18nContext();
@@ -97,7 +98,7 @@ const SensorsAnalogDialog = ({
const analogTypeMenuItems = useMemo(
() =>
AnalogTypeNames.map((val, i) => (
<MenuItem key={val} value={i}>
<MenuItem key={val} value={i + 1}>
{val}
</MenuItem>
)),
@@ -114,6 +115,23 @@ const SensorsAnalogDialog = ({
[]
);
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) {
@@ -156,72 +174,64 @@ const SensorsAnalogDialog = ({
<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
fieldErrors={fieldErrors || {}}
name="g"
label="GPIO"
sx={{ width: '11ch' }}
value={numberValue(editItem.g)}
type="number"
variant="outlined"
onChange={updateFormValue}
/>
</Grid>
{creating && (
<Grid>
<Box color="warning.main" mt={2}>
<Typography variant="body2">{LL.WARN_GPIO()}</Typography>
</Box>
</Grid>
)}
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
name="n"
label={LL.NAME(0)}
value={editItem.n}
fullWidth
variant="outlined"
onChange={updateFormValue}
/>
</Grid>
<Grid>
<TextField
<ValidatedTextField
name="t"
label={LL.TYPE(0)}
value={editItem.t}
fullWidth
select
onChange={updateFormValue}
disabled={editItem.s}
>
{analogTypeMenuItems}
</TextField>
</ValidatedTextField>
</Grid>
{(isCounterOrRate || isFreqType) && (
<Grid>
<TextField
<ValidatedTextField
name="u"
label={LL.UNIT()}
value={editItem.u}
sx={{ width: '15ch' }}
select
onChange={updateFormValue}
disabled={editItem.s}
>
{uomMenuItems}
</TextField>
</ValidatedTextField>
</Grid>
)}
{editItem.t === AnalogType.ADC && (
<Grid>
<TextField
<ValidatedTextField
name="o"
label={LL.OFFSET()}
value={numberValue(editItem.o)}
type="number"
sx={{ width: '11ch' }}
variant="outlined"
onChange={updateFormValue}
disabled={editItem.s}
slotProps={{
input: {
startAdornment: (
@@ -235,14 +245,14 @@ const SensorsAnalogDialog = ({
)}
{editItem.t === AnalogType.NTC && (
<Grid>
<TextField
<ValidatedTextField
name="o"
label={LL.OFFSET()}
value={numberValue(editItem.o)}
sx={{ width: '11ch' }}
type="number"
variant="outlined"
onChange={updateFormValue}
disabled={editItem.s}
slotProps={{
input: {
startAdornment: (
@@ -256,14 +266,14 @@ const SensorsAnalogDialog = ({
)}
{editItem.t === AnalogType.COUNTER && (
<Grid>
<TextField
<ValidatedTextField
name="o"
label={LL.STARTVALUE()}
value={numberValue(editItem.o)}
type="number"
sx={{ width: '11ch' }}
variant="outlined"
onChange={updateFormValue}
disabled={editItem.s}
slotProps={{
htmlInput: { step: '0.001' }
}}
@@ -272,27 +282,27 @@ const SensorsAnalogDialog = ({
)}
{editItem.t === AnalogType.RGB && (
<Grid>
<TextField
<ValidatedTextField
name="o"
label={'RGB ' + LL.VALUE(0)}
value={numberValue(editItem.o)}
type="number"
sx={{ width: '11ch' }}
variant="outlined"
onChange={updateFormValue}
disabled={editItem.s}
/>
</Grid>
)}
{isCounterOrRate && (
<Grid>
<TextField
<ValidatedTextField
name="f"
label={LL.FACTOR()}
value={numberValue(editItem.f)}
sx={{ width: '14ch' }}
type="number"
variant="outlined"
onChange={updateFormValue}
disabled={editItem.s}
slotProps={{
htmlInput: { step: '0.001' }
}}
@@ -301,14 +311,14 @@ const SensorsAnalogDialog = ({
)}
{isDigitalOutGPIO && (
<Grid>
<TextField
<ValidatedTextField
name="o"
label={LL.VALUE(0)}
value={numberValue(editItem.o)}
sx={{ width: '11ch' }}
type="number"
variant="outlined"
onChange={updateFormValue}
disabled={editItem.s}
slotProps={{
htmlInput: { min: '0', max: '255', step: '1' }
}}
@@ -318,39 +328,41 @@ const SensorsAnalogDialog = ({
{isDigitalOutNonGPIO && (
<>
<Grid>
<TextField
<ValidatedTextField
name="o"
label={LL.VALUE(0)}
value={numberValue(editItem.o)}
select
variant="outlined"
onChange={updateFormValue}
disabled={editItem.s}
>
<MenuItem value={0}>{LL.OFF()}</MenuItem>
<MenuItem value={1}>{LL.ON()}</MenuItem>
</TextField>
</ValidatedTextField>
</Grid>
<Grid>
<TextField
<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>
</TextField>
</ValidatedTextField>
</Grid>
<Grid>
<TextField
<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}>
@@ -359,21 +371,21 @@ const SensorsAnalogDialog = ({
<MenuItem value={2}>
{LL.ALWAYS()}&nbsp;{LL.ON()}
</MenuItem>
</TextField>
</ValidatedTextField>
</Grid>
</>
)}
{isPWM && (
<>
<Grid>
<TextField
<ValidatedTextField
name="f"
label={LL.FREQ()}
value={numberValue(editItem.f)}
type="number"
variant="outlined"
sx={{ width: '11ch' }}
onChange={updateFormValue}
disabled={editItem.s}
slotProps={{
input: {
startAdornment: (
@@ -385,14 +397,14 @@ const SensorsAnalogDialog = ({
/>
</Grid>
<Grid>
<TextField
<ValidatedTextField
name="o"
label={LL.DUTY_CYCLE()}
value={numberValue(editItem.o)}
type="number"
sx={{ width: '11ch' }}
variant="outlined"
onChange={updateFormValue}
disabled={editItem.s}
slotProps={{
input: {
startAdornment: (
@@ -408,27 +420,28 @@ const SensorsAnalogDialog = ({
{editItem.t === AnalogType.PULSE && (
<>
<Grid>
<TextField
<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>
</TextField>
</ValidatedTextField>
</Grid>
<Grid>
<TextField
<ValidatedTextField
name="f"
label="Pulse"
value={numberValue(editItem.f)}
type="number"
sx={{ width: '15ch' }}
variant="outlined"
onChange={updateFormValue}
disabled={editItem.s}
slotProps={{
input: {
startAdornment: (
@@ -442,6 +455,24 @@ const SensorsAnalogDialog = ({
</>
)}
</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">

View File

@@ -89,15 +89,15 @@ export interface TemperatureSensor {
export interface AnalogSensor {
id: number;
g: number; // GPIO
n: string;
v: number;
u: number;
o: number;
f: number;
t: number;
n: string; // name
v: number; // value
u: number; // uom
o: number; // offset
f: number; // factor
t: number; // type
d: boolean; // deleted flag
s: boolean; // system sensor flag
o_n?: string;
o_n?: string; // original name
}
export interface WriteTemperatureSensor {
@@ -111,6 +111,7 @@ export interface SensorData {
ts: TemperatureSensor[];
as: AnalogSensor[];
analog_enabled: boolean;
available_gpios: number[];
platform: string;
}
@@ -200,7 +201,7 @@ export enum DeviceValueUOM {
export const DeviceValueUOM_s = [
'',
'°C',
'°C',
'°C Rel',
'%',
'l/min',
'kWh',
@@ -230,7 +231,6 @@ export const DeviceValueUOM_s = [
export enum AnalogType {
REMOVED = -1,
NOTUSED = 0,
DIGITAL_IN = 1,
COUNTER = 2,
ADC = 3,
@@ -249,22 +249,21 @@ export enum AnalogType {
}
export const AnalogTypeNames = [
'(disabled)',
'Digital In',
'Counter',
'ADC In',
'Timer',
'Rate',
'Digital Out',
'PWM 0',
'PWM 1',
'PWM 2',
'NTC Temp.',
'RGB Led',
'Pulse',
'Freq 0',
'Freq 1',
'Freq 2'
'Digital In', // 1
'Counter', // 2
'ADC In', // 3
'Timer', // 4
'Rate', // 5
'Digital Out', // 6
'PWM 0', // 7
'PWM 1', // 8
'PWM 2', // 9
'NTC Temp.', // 10
'RGB Led', // 11
'Pulse', // 12
'Freq 0', // 13
'Freq 1', // 14
'Freq 2' // 15
] as const;
export const BOARD_PROFILES = {

View File

@@ -45,121 +45,15 @@ const VALIDATION_LIMITS = {
HEX_BASE: 16
} as const;
// Helper to create GPIO validator from invalid ranges
const createGPIOValidator = (
invalidRanges: Array<number | [number, number]>,
maxValue: number
) => ({
validator(
_rule: InternalRuleItem,
value: number,
callback: (error?: string) => void
) {
if (!value) {
callback();
return;
}
if (value < 0 || value > maxValue) {
callback(ERROR_MESSAGES.GPIO_INVALID);
return;
}
for (const range of invalidRanges) {
if (typeof range === 'number') {
if (value === range) {
callback(ERROR_MESSAGES.GPIO_INVALID);
return;
}
} else {
const [start, end] = range;
if (value >= start && value <= end) {
callback(ERROR_MESSAGES.GPIO_INVALID);
return;
}
}
}
callback();
}
});
export const GPIO_VALIDATOR = createGPIOValidator(
[[6, 11], 1, 20, 24, [28, 31]],
40
);
export const GPIO_VALIDATORC3 = createGPIOValidator([[11, 19]], 21);
export const GPIO_VALIDATORS2 = createGPIOValidator(
[
[19, 20],
[22, 32]
],
40
);
export const GPIO_VALIDATORS3 = createGPIOValidator(
[
[19, 20],
[22, 37],
[39, 42]
],
48
);
const GPIO_FIELD_NAMES = [
'led_gpio',
'dallas_gpio',
'pbutton_gpio',
'tx_gpio',
'rx_gpio'
] as const;
type ValidationRules = Array<{
required?: boolean;
message?: string;
[key: string]: unknown;
}>;
const createGPIOValidations = (
validator: typeof GPIO_VALIDATOR
): Record<string, ValidationRules> =>
GPIO_FIELD_NAMES.reduce(
(acc, field) => {
const fieldName = field.replace('_gpio', '').toUpperCase();
acc[field] = [
{ required: true, message: `${fieldName} GPIO is required` },
validator
];
return acc;
},
{} as Record<string, ValidationRules>
);
const PLATFORM_VALIDATORS = {
ESP32: GPIO_VALIDATOR,
ESP32C3: GPIO_VALIDATORC3,
ESP32S2: GPIO_VALIDATORS2,
ESP32S3: GPIO_VALIDATORS3
} as const;
export const createSettingsValidator = (settings: Settings) => {
const schema: Record<string, ValidationRules> = {};
// Add GPIO validations for CUSTOM board profiles
if (
settings.board_profile === 'CUSTOM' &&
settings.platform in PLATFORM_VALIDATORS
) {
Object.assign(
schema,
createGPIOValidations(
PLATFORM_VALIDATORS[settings.platform as keyof typeof PLATFORM_VALIDATORS]
)
);
}
// Syslog validations
if (settings.syslog_enabled) {
schema.syslog_host = [
@@ -401,52 +295,21 @@ export const temperatureSensorItemValidation = (
n: [NAME_PATTERN, uniqueTemperatureNameValidator(sensors, sensor.o_n)]
});
export const isGPIOUniqueValidator = (sensors: AnalogSensor[]) => ({
validator(
_rule: InternalRuleItem,
gpio: number,
callback: (error?: string) => void
) {
if (sensors.some((as) => as.g === gpio)) {
callback(ERROR_MESSAGES.GPIO_DUPLICATE);
return;
}
callback();
}
});
export const uniqueAnalogNameValidator = (
sensors: AnalogSensor[],
o_name?: string
) => createUniqueFieldNameValidator(sensors, (s) => s.n, o_name);
const getPlatformGPIOValidator = (platform: string) => {
switch (platform) {
case 'ESP32S3':
return GPIO_VALIDATORS3;
case 'ESP32S2':
return GPIO_VALIDATORS2;
case 'ESP32C3':
return GPIO_VALIDATORC3;
default:
return GPIO_VALIDATOR;
}
};
export const analogSensorItemValidation = (
sensors: AnalogSensor[],
sensor: AnalogSensor,
creating: boolean,
platform: string
sensor: AnalogSensor
) => {
const gpioValidator = getPlatformGPIOValidator(platform);
return new Schema({
n: [NAME_PATTERN, uniqueAnalogNameValidator(sensors, sensor.o_n)],
g: [
{ required: true, message: 'GPIO is required' },
gpioValidator,
...(creating ? [isGPIOUniqueValidator(sensors)] : [])
// name is required and must be unique
n: [
{ required: true, message: 'Name is required' },
NAME_PATTERN,
uniqueAnalogNameValidator(sensors, sensor.o_n)
]
});
};

View File

@@ -857,8 +857,9 @@ const ApplicationSettings = () => {
</Grid>
{restartNeeded && (
<MessageBox my={2} level="warning" message={LL.RESTART_TEXT(0)}>
<MessageBox level="warning" message={LL.RESTART_TEXT(0)}>
<Button
sx={{ ml: 2 }}
startIcon={<PowerSettingsNewIcon />}
variant="contained"
color="error"

View File

@@ -1,8 +1,11 @@
import { useCallback, useMemo, useState } from 'react';
import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel';
import SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore';
import WarningIcon from '@mui/icons-material/Warning';
import {
Box,
Button,
Checkbox,
Grid,
@@ -30,6 +33,8 @@ import type { MqttSettingsType } from 'types';
import { numberValue, updateValueDirty, useRest } from 'utils';
import { createMqttSettingsValidator, validate } from 'validators';
import { callAction } from '../../api/app';
const MqttSettings = () => {
const {
loadData,
@@ -52,6 +57,16 @@ const MqttSettings = () => {
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const sendResetMQTT = useCallback(() => {
void callAction({ action: 'resetMQTT' })
.then(() => {
toast.success('MQTT ' + LL.REFRESH() + ' successful');
})
.catch((error) => {
toast.error(String(error.error?.message || 'An error occurred'));
});
}, []);
const updateFormValue = useMemo(
() =>
updateValueDirty(
@@ -114,16 +129,28 @@ const MqttSettings = () => {
<SectionContent>
{blocker ? <BlockNavigation blocker={blocker} /> : null}
<>
<BlockFormControlLabel
control={
<Checkbox
name="enabled"
checked={data.enabled}
onChange={updateFormValue}
/>
}
label={LL.ENABLE_MQTT()}
/>
<Box display="flex" gap={2} mb={1}>
<BlockFormControlLabel
control={
<Checkbox
name="enabled"
checked={data.enabled}
onChange={updateFormValue}
/>
}
label={LL.ENABLE_MQTT()}
/>
{data.enabled && (
<Button
startIcon={<SettingsBackupRestoreIcon />}
color="secondary"
variant="outlined"
onClick={sendResetMQTT}
>
{LL.REFRESH() + ' MQTT'}
</Button>
)}
</Box>
<Grid container spacing={2} rowSpacing={0}>
<Grid>
<ValidatedTextField
@@ -239,6 +266,7 @@ const MqttSettings = () => {
label={LL.CERT()}
variant="outlined"
value={data.rootCA}
sx={{ width: '50ch' }}
onChange={updateFormValue}
margin="normal"
/>

View File

@@ -355,8 +355,9 @@ const NetworkSettings = () => {
</>
)}
{restartNeeded && (
<MessageBox my={2} level="warning" message={LL.RESTART_TEXT(0)}>
<MessageBox level="warning" message={LL.RESTART_TEXT(0)}>
<Button
sx={{ ml: 2 }}
startIcon={<PowerSettingsNewIcon />}
variant="contained"
color="error"

View File

@@ -94,7 +94,7 @@ const WiFiNetworkSelector = ({ networkList }: { networkList: WiFiNetworkList })
);
if (networkList.networks.length === 0) {
return <MessageBox mt={2} mb={1} message={LL.NETWORK_NO_WIFI()} level="info" />;
return <MessageBox message={LL.NETWORK_NO_WIFI()} level="info" />;
}
return <List>{networkList.networks.map(renderNetwork)}</List>;

View File

@@ -8,10 +8,10 @@ import DeviceHubIcon from '@mui/icons-material/DeviceHub';
import DirectionsBusIcon from '@mui/icons-material/DirectionsBus';
import LogoDevIcon from '@mui/icons-material/LogoDev';
import MemoryIcon from '@mui/icons-material/Memory';
import MonitorHeartIcon from '@mui/icons-material/MonitorHeart';
import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew';
import RouterIcon from '@mui/icons-material/Router';
import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna';
import TimerIcon from '@mui/icons-material/Timer';
import WifiIcon from '@mui/icons-material/Wifi';
import {
Avatar,
@@ -37,7 +37,7 @@ import { FormLoader, SectionContent, useLayoutTitle } from 'components';
import ListMenuItem from 'components/layout/ListMenuItem';
import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react';
import { NTPSyncStatus, NetworkConnectionStatus } from 'types';
import { NTPSyncStatus, NetworkConnectionStatus, SystemStatusCodes } from 'types';
import { useInterval } from 'utils';
import { formatDateTime } from 'utils/time';
@@ -113,6 +113,27 @@ const SystemStatus = () => {
}
}, [data?.bus_status, data?.bus_uptime, LL]);
// Memoize derived status values to avoid recalculation on every render
const systemStatus = useMemo(() => {
if (!data) return '??';
switch (data.status) {
case SystemStatusCodes.SYSTEM_STATUS_PENDING_UPLOAD:
case SystemStatusCodes.SYSTEM_STATUS_UPLOADING:
return LL.WAIT_FIRMWARE();
case SystemStatusCodes.SYSTEM_STATUS_ERROR_UPLOAD:
return LL.ERROR();
case SystemStatusCodes.SYSTEM_STATUS_PENDING_RESTART:
case SystemStatusCodes.SYSTEM_STATUS_RESTART_REQUESTED:
return LL.RESTARTING_PRE();
case SystemStatusCodes.SYSTEM_STATUS_INVALID_GPIO:
return LL.GPIO_OF(LL.FAILED(0));
default:
// SystemStatusCodes.SYSTEM_STATUS_NORMAL
return 'OK';
}
}, [data?.status, LL]);
const busStatusHighlight = useMemo(() => {
if (!data) return theme.palette.warning.main;
@@ -313,10 +334,13 @@ const SystemStatus = () => {
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#c5572c', color: 'white' }}>
<TimerIcon />
<MonitorHeartIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.UPTIME()} secondary={uptimeText} />
<ListItemText
primary={LL.STATUS_OF(LL.SYSTEM(0))}
secondary={`${systemStatus} (${LL.UPTIME()}: ${uptimeText})`}
/>
{me.admin && (
<Button
startIcon={<PowerSettingsNewIcon />}

View File

@@ -113,7 +113,7 @@ const SystemMonitor = () => {
minWidth: '300px',
maxWidth: '500px',
backgroundColor: '#393939',
border: 3,
border: 2,
borderColor: '#565656',
borderRadius: '8px',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3)',
@@ -136,10 +136,10 @@ const SystemMonitor = () => {
</Typography>
{errorMessage ? (
<MessageBox my={2} level="error" message={errorMessage}>
<MessageBox level="error" message={errorMessage}>
<Button
size="small"
sx={{ ml: 2 }}
size="small"
startIcon={<CancelIcon />}
variant="contained"
color="error"

View File

@@ -1,4 +1,12 @@
import { memo, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import {
memo,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState
} from 'react';
import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel';
@@ -10,12 +18,10 @@ import WarningIcon from '@mui/icons-material/Warning';
import {
Box,
Button,
Checkbox,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControlLabel,
Grid,
IconButton,
Link,
@@ -356,9 +362,11 @@ const Version = () => {
setShowVersionInfo(0);
}, []);
// Effect for checking upgrades
// check upgrades - only once when both versions are available
const upgradeCheckedRef = useRef(false);
useEffect(() => {
if (latestVersion && latestDevVersion) {
if (latestVersion && latestDevVersion && !upgradeCheckedRef.current) {
upgradeCheckedRef.current = true;
const versions = `${latestDevVersion.name},${latestVersion.name}`;
sendCheckUpgrade(versions)
.catch((error: Error) => {
@@ -501,48 +509,11 @@ const Version = () => {
<Grid size={{ xs: 4, md: 2 }}>
<Typography color="secondary">{LL.RELEASE_TYPE()}</Typography>
</Grid>
<Grid size={{ xs: 8, md: 10 }}>
<FormControlLabel
disabled={!isDev}
control={
<Checkbox
sx={{
'&.Mui-checked': {
color: 'lightblue'
}
}}
/>
}
slotProps={{
typography: {
color: 'grey'
}
}}
checked={!isDev}
label={LL.STABLE()}
sx={{ '& .MuiSvgIcon-root': { fontSize: 16 } }}
/>
<FormControlLabel
disabled={isDev}
control={
<Checkbox
sx={{
'&.Mui-checked': {
color: 'lightblue'
}
}}
/>
}
slotProps={{
typography: {
color: 'grey'
}
}}
checked={isDev}
label={LL.DEVELOPMENT()}
sx={{ '& .MuiSvgIcon-root': { fontSize: 16 } }}
/>
</Grid>
{isDev ? (
<Typography>{LL.DEVELOPMENT()}</Typography>
) : (
<Typography>{LL.STABLE()}</Typography>
)}
</Grid>
{internetLive ? (

View File

@@ -19,10 +19,8 @@ import { I18nContext } from 'i18n/i18n-react';
import type { Locales } from 'i18n/i18n-types';
import { loadLocaleAsync } from 'i18n/i18n-util.async';
// Extract style to constant to prevent recreation
const flagStyle: CSSProperties = { width: 16, verticalAlign: 'middle' };
// Define language options outside component to prevent recreation
interface LanguageOption {
key: Locales;
flag: string;

View File

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

View File

@@ -22,7 +22,6 @@ interface ListMenuItemProps {
disabled?: boolean;
}
// Extract styles to prevent recreation
const iconStyles: CSSProperties = {
justifyContent: 'right',
color: 'lightblue',

View File

@@ -16,7 +16,7 @@ const FormLoaderComponent = ({ errorMessage, onRetry }: FormLoaderProps) => {
if (errorMessage) {
return (
<MessageBox my={2} level="error" message={errorMessage}>
<MessageBox level="error" message={errorMessage}>
{onRetry && (
<Button
sx={{ ml: 2 }}

View File

@@ -3,7 +3,6 @@ import { memo } from 'react';
import { Box, CircularProgress } from '@mui/material';
import type { SxProps, Theme } from '@mui/material';
// Extract styles to prevent recreation on every render
const containerStyles: SxProps<Theme> = {
display: 'flex',
justifyContent: 'center',

View File

@@ -7,7 +7,6 @@ interface LoadingSpinnerProps {
height?: number | string;
}
// Extract styles to prevent recreation on every render
const circularProgressStyles: SxProps<Theme> = (theme: Theme) => ({
margin: theme.spacing(4),
color: theme.palette.text.secondary

View File

@@ -60,7 +60,6 @@ const cz: Translation = {
DUTY_CYCLE: 'Pracovní cyklus',
UNIT: 'Jednotka',
STARTVALUE: 'Počáteční hodnota',
WARN_GPIO: 'Upozornění: buďte opatrní při přiřazování GPIO!',
EDIT: 'Upravit',
SENSOR: 'Senzor',
TEMP_SENSOR: 'Teplotní senzor',

View File

@@ -60,7 +60,6 @@ const de: Translation = {
DUTY_CYCLE: 'Arbeitszyklus',
UNIT: 'Maßeinheit',
STARTVALUE: 'Startwert',
WARN_GPIO: 'Warnung: Vorsicht bei der korrekten Wahl des GPIO!',
EDIT: 'Editiere',
SENSOR: 'Sensor',
TEMP_SENSOR: 'Temperatursensor',

View File

@@ -60,7 +60,6 @@ const en: Translation = {
DUTY_CYCLE: 'Duty Cycle',
UNIT: 'UoM',
STARTVALUE: 'Start Value',
WARN_GPIO: 'Warning: be careful when assigning a GPIO!',
EDIT: 'Edit',
SENSOR: 'Sensor',
TEMP_SENSOR: 'Temperature Sensor',

View File

@@ -60,7 +60,6 @@ const fr: Translation = {
DUTY_CYCLE: 'Cycle de fonctionnement',
UNIT: 'Unité',
STARTVALUE: 'Valeur de départ',
WARN_GPIO: 'Attention: soyez vigilant en choisissant un GPIO!',
EDIT: 'Éditer',
SENSOR: 'Capteur',
TEMP_SENSOR: 'Capteur de température',

View File

@@ -60,7 +60,6 @@ const it: Translation = {
DUTY_CYCLE: 'Ciclo di lavoro',
UNIT: 'UoM',
STARTVALUE: 'Valore di partenza',
WARN_GPIO: 'Avvertimento: prestare attenzione quando si assegna un GPIO!',
EDIT: 'Modifica',
SENSOR: 'Sensore',
TEMP_SENSOR: 'Sensore Temperatura',

View File

@@ -60,7 +60,6 @@ const nl: Translation = {
DUTY_CYCLE: 'Duty Cycle',
UNIT: 'UoM',
STARTVALUE: 'Startwaarde',
WARN_GPIO: 'Waarschuwing: let op met het koppelen van de juiste GPIO pin!',
EDIT: 'Wijzigen',
SENSOR: 'Sensor',
TEMP_SENSOR: 'Temperatuur sensor',

View File

@@ -60,7 +60,6 @@ const no: Translation = {
DUTY_CYCLE: 'Duty Cycle',
UNIT: 'UoM',
STARTVALUE: 'Startverdi',
WARN_GPIO: 'Advarsel: vær forsiktig ved aktivering av GPIO!',
EDIT: 'Endre',
SENSOR: 'Sensor',
TEMP_SENSOR: 'Temperatursensor',

View File

@@ -60,7 +60,6 @@ const pl: BaseTranslation = {
DUTY_CYCLE: 'Wypełnienie',
UNIT: 'J.m.',
STARTVALUE: 'Wartość początkowa',
WARN_GPIO: 'Uwaga! Zachowaj ostrożność przypisując GPIO do urządzenia!',
EDIT: 'Edycja',
SENSOR: '{{c|ustawienia c||ustawień c|}}zujnika',
TEMP_SENSOR: 'czujnika temperatury',

View File

@@ -60,7 +60,6 @@ const sk: Translation = {
DUTY_CYCLE: 'Pracovný cyklus',
UNIT: 'UoM',
STARTVALUE: 'Počiatočná hodnota',
WARN_GPIO: 'Upozornenie: Buďte opatrní pri priraďovaní GPIO!',
EDIT: 'Editovať',
SENSOR: 'Snímač',
TEMP_SENSOR: 'Snímač teploty',

View File

@@ -60,7 +60,6 @@ const sv: Translation = {
DUTY_CYCLE: 'Pulskvot',
UNIT: 'Måttenhet',
STARTVALUE: 'Startvärde',
WARN_GPIO: 'Varning: Var försiktig vid aktivering av GPIO!',
EDIT: 'Ändra',
SENSOR: 'Sensor',
TEMP_SENSOR: 'Temperatursensor',

View File

@@ -60,7 +60,6 @@ const tr: Translation = {
DUTY_CYCLE: 'Görev Çevrimi',
UNIT: 'ÖB',
STARTVALUE: 'Başlangıç değeri',
WARN_GPIO: 'Uyarı: bir GPIO atarken dikkatli olun!',
EDIT: 'Değiştir',
SENSOR: 'Sensör',
TEMP_SENSOR: 'Sıcaklık Sensörü',

View File

@@ -2,13 +2,15 @@ import type { busConnectionStatus } from 'app/main/types';
import type { NetworkConnectionStatus } from './network';
// match SYSTEM_STATUS in System.h
export enum SystemStatusCodes {
SYSTEM_STATUS_NORMAL = 0,
SYSTEM_STATUS_PENDING_UPLOAD = 1,
SYSTEM_STATUS_UPLOADING = 100,
SYSTEM_STATUS_ERROR_UPLOAD = 3,
SYSTEM_STATUS_PENDING_RESTART = 4,
SYSTEM_STATUS_RESTART_REQUESTED = 5
SYSTEM_STATUS_RESTART_REQUESTED = 5,
SYSTEM_STATUS_INVALID_GPIO = 6
}
export interface SystemStatus {
@@ -50,7 +52,7 @@ export interface SystemStatus {
model: string;
has_loader: boolean;
has_partition: boolean;
status: number; // SystemStatusCodes which matches SYSTEM_STATUS in System.h
status: number; // System Status Codes which matches SYSTEM_STATUS in System.h
temperature?: number;
}

View File

@@ -46,15 +46,15 @@ type UpdateEntity<S> = (state: (prevState: Readonly<S>) => S) => void;
*/
export const updateValue =
<S extends Record<string, unknown>>(updateEntity: UpdateEntity<S>) =>
(event: React.ChangeEvent<HTMLInputElement>): void => {
const { name } = event.target;
const value = extractEventValue(event);
(event: React.ChangeEvent<HTMLInputElement>): void => {
const { name } = event.target;
const value = extractEventValue(event);
updateEntity((prevState) => ({
...prevState,
[name]: value
}));
};
updateEntity((prevState) => ({
...prevState,
[name]: value
}));
};
/**
* Creates an event handler that tracks dirty flags for modified fields.
@@ -67,22 +67,22 @@ export const updateValueDirty =
setDirtyFlags: React.Dispatch<React.SetStateAction<string[]>>,
updateDataValue: (updater: (prevState: T) => T) => void
) =>
(event: React.ChangeEvent<HTMLInputElement>): void => {
const { name } = event.target;
const updatedValue = extractEventValue(event);
(event: React.ChangeEvent<HTMLInputElement>): void => {
const { name } = event.target;
const updatedValue = extractEventValue(event);
updateDataValue((prevState) => ({
...prevState,
[name]: updatedValue
}));
updateDataValue((prevState) => ({
...prevState,
[name]: updatedValue
}));
const isDirty = origData[name] !== updatedValue;
const wasDirty = dirtyFlags.includes(name);
const isDirty = origData[name] !== updatedValue;
const wasDirty = dirtyFlags.includes(name);
// Only update dirty flags if the state changed
if (isDirty !== wasDirty) {
setDirtyFlags(
isDirty ? [...dirtyFlags, name] : dirtyFlags.filter((f) => f !== name)
);
}
};
// Only update dirty flags if the state changed
if (isDirty !== wasDirty) {
setDirtyFlags(
isDirty ? [...dirtyFlags, name] : dirtyFlags.filter((f) => f !== name)
);
}
};

View File

@@ -50,7 +50,7 @@ inline Transition mkx(const char c, Parser_state p, State_transition_hook pth) {
}
inline void Parse_error(const std::string & s) {
// emsesp::EMSESP::logger().err("parse error: %s", s.c_str());
// EMSESP::logger().err("parse error: %s", s.c_str());
}
/// Advance parser state machine by a single step.

View File

@@ -346,7 +346,6 @@ void SyslogService::loop() {
}
bool SyslogService::can_transmit() {
// TODO this should be checked also for Eth
if (!host_.empty() && (uint32_t)ip_ == 0) {
WiFi.hostByName(host_.c_str(), ip_);
}

View File

@@ -1,6 +1,6 @@
/*
* EMS-ESP - https://github.com/emsesp/EMS-ESP
* Copyright 2020-2024 emsesp.org - proddy, MichaelDvP
* Copyright 2020-2025 emsesp.org - proddy, MichaelDvP
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by

View File

@@ -1,6 +1,6 @@
/*
* EMS-ESP - https://github.com/emsesp/EMS-ESP
* Copyright 2020-2024 emsesp.org - proddy, MichaelDvP
* Copyright 2020-2025 emsesp.org - proddy, MichaelDvP
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by

View File

@@ -1,6 +1,6 @@
/*
* EMS-ESP - https://github.com/emsesp/EMS-ESP
* Copyright 2020-2024 emsesp.org - proddy, MichaelDvP
* Copyright 2020-2025 emsesp.org - proddy, MichaelDvP
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by

View File

@@ -1,6 +1,6 @@
/*
* EMS-ESP - https://github.com/emsesp/EMS-ESP
* Copyright 2020-2024 emsesp.org - proddy, MichaelDvP
* Copyright 2020-2025 emsesp.org - proddy, MichaelDvP
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by

View File

@@ -13,7 +13,7 @@
"@trivago/prettier-plugin-sort-imports": "^6.0.0",
"formidable": "^3.5.4",
"itty-router": "^5.0.22",
"prettier": "^3.6.2"
"prettier": "^3.7.4"
},
"packageManager": "pnpm@10.22.0+sha512.bf049efe995b28f527fd2b41ae0474ce29186f7edcb3bf545087bd61fbbebb2bf75362d1307fda09c2d288e1e499787ac12d4fcb617a974718a6051f2eee741c"
"packageManager": "pnpm@10.24.0+sha512.01ff8ae71b4419903b65c60fb2dc9d34cf8bb6e06d03bde112ef38f7a34d6904c424ba66bea5cdcf12890230bf39f9580473140ed9c946fef328b6e5238a345a"
}

View File

@@ -13,7 +13,7 @@ importers:
version: 3.1.2
'@trivago/prettier-plugin-sort-imports':
specifier: ^6.0.0
version: 6.0.0(prettier@3.6.2)
version: 6.0.0(prettier@3.7.4)
formidable:
specifier: ^3.5.4
version: 3.5.4
@@ -21,8 +21,8 @@ importers:
specifier: ^5.0.22
version: 5.0.22
prettier:
specifier: ^3.6.2
version: 3.6.2
specifier: ^3.7.4
version: 3.7.4
packages:
@@ -167,8 +167,8 @@ packages:
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
prettier@3.6.2:
resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==}
prettier@3.7.4:
resolution: {integrity: sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==}
engines: {node: '>=14'}
hasBin: true
@@ -246,7 +246,7 @@ snapshots:
dependencies:
'@noble/hashes': 1.8.0
'@trivago/prettier-plugin-sort-imports@6.0.0(prettier@3.6.2)':
'@trivago/prettier-plugin-sort-imports@6.0.0(prettier@3.7.4)':
dependencies:
'@babel/generator': 7.28.5
'@babel/parser': 7.28.5
@@ -256,7 +256,7 @@ snapshots:
lodash-es: 4.17.21
minimatch: 9.0.5
parse-imports-exports: 0.2.4
prettier: 3.6.2
prettier: 3.7.4
transitivePeerDependencies:
- supports-color
@@ -311,6 +311,6 @@ snapshots:
picocolors@1.1.1: {}
prettier@3.6.2: {}
prettier@3.7.4: {}
wrappy@1.0.2: {}

View File

@@ -116,8 +116,8 @@ let system_status = {
let DEV_VERSION_IS_UPGRADEABLE: boolean;
let STABLE_VERSION_IS_UPGRADEABLE: boolean;
let THIS_VERSION: string;
let LATEST_STABLE_VERSION = '3.7.2';
let LATEST_DEV_VERSION = '3.7.3-dev.6';
let LATEST_STABLE_VERSION = '3.7.3';
let LATEST_DEV_VERSION = '3.7.4-dev.2';
// scenarios for testing versioning
let version_test = 0; // on latest stable, or switch to dev
@@ -142,19 +142,19 @@ switch (version_test as number) {
break;
case 2:
// upgrade an older stable to latest stable or switch to latest dev
THIS_VERSION = '3.6.5';
THIS_VERSION = '3.7.2';
STABLE_VERSION_IS_UPGRADEABLE = true;
DEV_VERSION_IS_UPGRADEABLE = true;
break;
case 3:
// upgrade dev to latest, or switch to stable
THIS_VERSION = '3.7.3-dev.2';
THIS_VERSION = '3.7.4-dev.3';
STABLE_VERSION_IS_UPGRADEABLE = false;
DEV_VERSION_IS_UPGRADEABLE = true;
break;
case 4:
// downgrade to an older dev, or switch back to stable
THIS_VERSION = '3.7.3-dev.9';
THIS_VERSION = '3.7.3-dev.1';
STABLE_VERSION_IS_UPGRADEABLE = true;
DEV_VERSION_IS_UPGRADEABLE = false;
break;
@@ -569,14 +569,15 @@ let mqtt_settings = {
publish_time_heartbeat: 60,
publish_time_water: 60,
mqtt_qos: 0,
rootCA: '',
mqtt_retain: false,
ha_enabled: true,
nested_format: 1,
discovery_type: 0,
discovery_prefix: 'homeassistant',
send_response: true,
publish_single: false
publish_single: false,
enableTLS: true,
rootCA: ''
};
const mqtt_status = {
enabled: true,
@@ -984,11 +985,11 @@ const emsesp_sensordata = {
],
// as: [],
as: [
{ id: 1, g: 35, n: 'motor', v: 0, u: 0, o: 17, f: 0, t: 0, d: false, s: false },
{ id: 1, g: 35, n: 'motor', v: 0, u: 0, o: 17, f: 0, t: 7, d: false, s: false },
{
id: 2,
g: 37,
n: 'External switch',
g: 34,
n: 'External_switch',
v: 13,
u: 0,
o: 17,
@@ -999,8 +1000,8 @@ const emsesp_sensordata = {
},
{
id: 3,
g: 39,
n: 'Pulse count',
g: 37,
n: 'Pulse_count',
v: 144,
u: 0,
o: 0,
@@ -1011,7 +1012,7 @@ const emsesp_sensordata = {
},
{
id: 4,
g: 40,
g: 23,
n: 'Pressure',
v: 16,
u: 17,
@@ -1046,7 +1047,8 @@ const emsesp_sensordata = {
s: true
}
],
analog_enabled: true
analog_enabled: true,
available_gpios: [] as number[]
};
const activity = {
@@ -4539,6 +4541,28 @@ router
.get(EMSESP_SENSOR_DATA_ENDPOINT, () => {
// random change the zolder temperature 0-100
emsesp_sensordata.ts[2].t = Math.floor(Math.random() * 100);
// Build list of available GPIOs (S3 board pins) excluding used ones
// and sort it
const allGPIOs = [
2, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 17, 18, 21, 33, 34, 35, 36, 37, 38,
45, 46
];
const usedGPIOs = new Set([
settings.led_gpio,
settings.dallas_gpio,
settings.pbutton_gpio,
settings.rx_gpio,
settings.tx_gpio,
...emsesp_sensordata.as.map((item) => item.g)
]);
emsesp_sensordata.available_gpios = allGPIOs
.filter((gpio) => !usedGPIOs.has(gpio))
.sort((a, b) => a - b);
// console.log('available_gpios', emsesp_sensordata.available_gpios);
return emsesp_sensordata;
})
.get(EMSESP_DEVICEDATA_ENDPOINT1, (request) =>
@@ -5105,6 +5129,10 @@ router
// upload URL
console.log('upload File from URL', content.param);
return status(200);
} else if (action === 'resetMQTT') {
// reset MQTT
console.log('resetting MQTT...');
return status(200);
}
}
return status(404); // cmd not found

View File

@@ -106,7 +106,7 @@ board_build.filesystem = littlefs
lib_deps =
bblanchon/ArduinoJson @ 7.4.2
ESP32Async/AsyncTCP @ 3.4.9
ESP32Async/ESPAsyncWebServer @ 3.8.1
ESP32Async/ESPAsyncWebServer @ 3.9.2
https://github.com/emsesp/EMS-ESP-Modules.git @ 1.0.8
@@ -214,22 +214,20 @@ lib_ldf_mode = off
lib_deps =
; unit tests
; The code is in ./test/test_api.*
; The test code is in ./test/test_api.cpp and the test_api.h file is created by the native-test-create environment.
; to run use `platformio run -e native-test -t exec`. All tests should PASS.
; to update the test results, compile with -DEMSESP_UNITY_CREATE by uncommenting the line below
; then re-run and capture the output between "START - CUT HERE" and "END - CUT HERE" into the test_api.h file
; tip: use https://jsondiff.com/ to compare the expected and actual responses.
[env:native-test]
platform = native
test_build_src = true
build_flags =
; -DEMSESP_UNITY_CREATE
-DARDUINOJSON_ENABLE_ARDUINO_STRING=1
-DEMSESP_STANDALONE -DEMSESP_TEST
-DEMSESP_UNITY
-DEMSESP_DEFAULT_LOCALE=\"en\" -DEMSESP_DEFAULT_TX_MODE=8 -DEMSESP_DEFAULT_VERSION=\"3.7.3-dev.0\" -DEMSESP_DEFAULT_BOARD_PROFILE=\"S32\"
-std=gnu++17 -Og -ggdb
build_type = debug
build_src_flags =
-DEMSESP_STANDALONE -DEMSESP_TEST
-DEMSESP_UNITY
-DARDUINOJSON_ENABLE_ARDUINO_STRING=1
-DEMSESP_DEFAULT_LOCALE=\"en\" -DEMSESP_DEFAULT_TX_MODE=8 -DEMSESP_DEFAULT_VERSION=\"3.7.3-dev.0\" -DEMSESP_DEFAULT_BOARD_PROFILE=\"S32\"
-std=gnu++17 -Og -ggdb
-Wall -Wextra
-Wno-unused-parameter -Wno-sign-compare -Wno-missing-braces
-Wno-vla-cxx-extension -Wno-tautological-constant-out-of-range-compare
@@ -259,6 +257,12 @@ lib_deps = Unity
test_testing_command =
${platformio.build_dir}/${this.__env__}/program
; builds the test cases and creates the test_api.h file
; run with `pio run -e native-test-create -t exec` and capture the output between "START - CUT HERE" and "END - CUT HERE" and paste it into the test_api.h file
[env:native-test-create]
extends = env:native-test
build_flags =
-DEMSESP_UNITY_CREATE
;
; Building and testing locally on OS, which we call "standalone" without an ESP32.
; See https://docs.platformio.org/en/latest/platforms/native.html

View File

@@ -831,7 +831,6 @@ notoken
NOTOKEN
NOTRANSLATION
NOTSET
NOTUSED
NOTYPE
nrgconscomp
nrgconscompcooling
@@ -1443,4 +1442,7 @@ constlow
proplow
chimneysweeper
pumpopt
intergral
intergral
vchip
SPIIO
SPIDQS

View File

@@ -10,11 +10,11 @@ def get_pnpm_executable():
"""Get the appropriate pnpm executable for the current platform."""
# Try different pnpm executable names
pnpm_names = ['pnpm', 'pnpm.cmd', 'pnpm.exe']
for name in pnpm_names:
if shutil.which(name):
return name
# Fallback to pnpm if not found
return 'pnpm'
@@ -30,14 +30,14 @@ def run_command_in_directory(command, directory):
capture_output=True,
text=True
)
if result.stdout:
print(result.stdout)
if result.stderr:
print(result.stderr)
return True
except subprocess.CalledProcessError as e:
print(f"Command failed: {command}")
print(f"Error: {e}")
@@ -54,36 +54,34 @@ def run_command_in_directory(command, directory):
def buildWeb():
interface_dir = Path("interface")
pnpm_exe = get_pnpm_executable()
# Set CI environment variable to make pnpm use silent mode
os.environ['CI'] = 'true'
print("Building web interface...")
# Check if interface directory exists
if not interface_dir.exists():
print(f"Error: Interface directory '{interface_dir}' not found!")
return False
# Check if pnpm is available
if not shutil.which(pnpm_exe):
print(f"Error: '{pnpm_exe}' not found in PATH!")
return False
try:
# Run pnpm commands in the interface directory
commands = [
f"{pnpm_exe} install",
f"{pnpm_exe} typesafe-i18n",
f"{pnpm_exe} build",
f"{pnpm_exe} webUI"
f"{pnpm_exe} build_webUI"
]
for command in commands:
print(f"Running: {command}")
if not run_command_in_directory(command, interface_dir):
return False
# Modify i18n-util.ts file
i18n_file = interface_dir / "src" / "i18n" / "i18n-util.ts"
if i18n_file.exists():
@@ -93,11 +91,12 @@ def buildWeb():
w.write(text)
print("Setting WebUI locale to 'en'")
else:
print(f"Warning: {i18n_file} not found, skipping locale modification")
print(
f"Warning: {i18n_file} not found, skipping locale modification")
print("Web interface build completed successfully!")
return True
except Exception as e:
print(f"Error building web interface: {e}")
return False
@@ -108,8 +107,9 @@ def build_webUI(*args, **kwargs):
if not success:
print("Web interface build failed!")
env.Exit(1)
env.Exit(0)
env.Exit(0)
# Create custom target that only runs the script and then exits, without continuing with the pio workflow
env.AddCustomTarget(
name="build",
@@ -119,4 +119,3 @@ env.AddCustomTarget(
description="installs pnpm packages, updates libraries and builds web UI",
always_build=True
)

View File

@@ -36,7 +36,6 @@ def move_file(source, target, env):
print("app version: " + app_version)
print("platform: " + platform)
# TODO do we need to add a .exe extension for windows? - need to test
variant = "native"
# check if output directories exist and create if necessary

View File

@@ -20,7 +20,7 @@ pnpm format
cd ..
cd interface
pnpm webUI
pnpm build_webUI
cd ..
npx cspell "**"

View File

@@ -16,7 +16,6 @@
#include <Arduino.h>
#include <AsyncJson.h>
#include <AsyncMessagePack.h>
#include <AsyncTCP.h>
#include <WiFi.h>

View File

@@ -167,13 +167,13 @@ void MqttSettingsService::WiFiEvent(WiFiEvent_t event) {
bool MqttSettingsService::configureMqtt() {
// disconnect if already connected
if (_mqttClient->connected()) {
emsesp::EMSESP::logger().info("Disconnecting to configure");
// emsesp::EMSESP::logger().info("Disconnecting to configure");
_mqttClient->disconnect(true);
}
// only connect if WiFi is connected and MQTT is enabled
if (_state.enabled && emsesp::EMSESP::system_.network_connected() && !_state.host.isEmpty()) {
// create last will topic with the base prefixed. It has to be static because the client destroys the reference
// create the Last Will Testament topic (LWT) with the base prefixed. It has to be static because the client destroys the reference
static char will_topic[FACTORY_MQTT_MAX_TOPIC_LENGTH];
if (_state.base.isEmpty()) {
snprintf(will_topic, sizeof(will_topic), "status");

View File

@@ -39,7 +39,7 @@
#endif
#ifndef FACTORY_MQTT_KEEP_ALIVE
#define FACTORY_MQTT_KEEP_ALIVE 16
#define FACTORY_MQTT_KEEP_ALIVE 60
#endif
#ifndef FACTORY_MQTT_CLEAN_SESSION

View File

@@ -24,7 +24,6 @@ static bool formatBssid(const String & bssid, uint8_t (&mac)[6]) {
}
void NetworkSettingsService::begin() {
// TODO: may need to change this for Arduino Core 3.1 / IDF 5.x
// We want the device to come up in opmode=0 (WIFI_OFF), when erasing the flash this is not the default.
// If needed, we save opmode=0 before disabling persistence so the device boots with WiFi disabled in the future.
if (WiFi.getMode() != WIFI_OFF) {
@@ -37,9 +36,11 @@ void NetworkSettingsService::begin() {
WiFi.mode(WIFI_MODE_MAX);
WiFi.mode(WIFI_MODE_NULL);
// scan settings give connect issues since arduino 2.0.14 and arduino 3.x.x with some wifi systems
// WiFi.setScanMethod(WIFI_ALL_CHANNEL_SCAN); // default is FAST_SCAN
// WiFi.setSortMethod(WIFI_CONNECT_AP_BY_SIGNAL); // is default, no need to set
_fsPersistence.readFromFS();
}

View File

@@ -155,7 +155,10 @@ void AnalogSensor::reload(bool get_nvs) {
}
}
if (!found) {
sensors_.emplace_back(sensor.gpio, sensor.name, sensor.offset, sensor.factor, sensor.uom, sensor.type, sensor.is_system);
// it's new, we assume it's valid
AnalogType type = static_cast<AnalogType>(sensor.type);
sensors_.emplace_back(sensor.gpio, sensor.name, sensor.offset, sensor.factor, sensor.uom, type, sensor.is_system);
sensors_.back().ha_registered = false; // this will trigger recreate of the HA config
if (sensor.type == AnalogType::COUNTER || sensor.type >= AnalogType::DIGITAL_OUT) {
sensors_.back().set_value(sensor.offset);
@@ -163,6 +166,8 @@ void AnalogSensor::reload(bool get_nvs) {
sensors_.back().set_value(0); // reset value only for new sensors
}
}
// add the command to set the value of the sensor
if (sensor.type == AnalogType::COUNTER || (sensor.type >= AnalogType::DIGITAL_OUT && sensor.type <= AnalogType::PWM_2)
|| sensor.type == AnalogType::RGB || sensor.type == AnalogType::PULSE) {
Command::add(
@@ -187,16 +192,8 @@ void AnalogSensor::reload(bool get_nvs) {
for (auto & sensor : sensors_) {
sensor.ha_registered = false; // force HA configs to be re-created
// first check if the GPIO is valid. If not, force set it to disabled
if (!System::is_valid_gpio(sensor.gpio())) {
LOG_WARNING("Bad GPIO %d for Sensor %s. Disabling.", sensor.gpio(), sensor.name().c_str());
sensor.set_type(AnalogType::NOTUSED); // set disabled
continue; // skip this loop pass
}
if ((sensor.gpio() == 25 || sensor.gpio() == 26)
&& (sensor.type() == AnalogType::COUNTER || sensor.type() == AnalogType::DIGITAL_IN || sensor.type() == AnalogType::RATE
|| sensor.type() == AnalogType::TIMER)) {
if (sensor.type() == AnalogType::COUNTER || sensor.type() == AnalogType::DIGITAL_IN || sensor.type() == AnalogType::RATE
|| sensor.type() == AnalogType::TIMER) {
// pullup is mapped to DAC, so set to 3.3V
#if CONFIG_IDF_TARGET_ESP32
if (sensor.gpio() == 25 || sensor.gpio() == 26) {
@@ -472,8 +469,9 @@ void AnalogSensor::loop() {
measure(); // take the measurements
}
// update analog information name and offset
// update analog information name, offset, factor, uom, type, deleted, is_system
// a type value of -1 is used to delete the sensor
// the gpio is the key
bool AnalogSensor::update(uint8_t gpio, std::string & name, double offset, double factor, uint8_t uom, int8_t type, bool deleted, bool is_system) {
// first see if we can find the sensor in our customization list
bool found_sensor = false;
@@ -493,8 +491,9 @@ bool AnalogSensor::update(uint8_t gpio, std::string & name, double offset, doubl
found_sensor = true; // found the record
// see if it's marked for deletion
if (deleted) {
EMSESP::nvs_.remove(AnalogCustomization.name.c_str());
LOG_DEBUG("Removing analog sensor GPIO %02d", gpio);
EMSESP::system_.remove_gpio(gpio); // remove from used list only
EMSESP::nvs_.remove(AnalogCustomization.name.c_str());
settings.analogCustomizations.remove(AnalogCustomization);
} else {
// update existing record
@@ -522,6 +521,7 @@ bool AnalogSensor::update(uint8_t gpio, std::string & name, double offset, doubl
// we didn't find it, it's new, so create and store it in the customization list
if (!found_sensor) {
found_sensor = true;
EMSESP::webCustomizationService.update([&](WebCustomization & settings) {
auto newSensor = AnalogCustomization();
newSensor.gpio = gpio;
@@ -532,17 +532,23 @@ bool AnalogSensor::update(uint8_t gpio, std::string & name, double offset, doubl
newSensor.type = type;
newSensor.is_system = is_system;
settings.analogCustomizations.push_back(newSensor);
LOG_DEBUG("Adding new customization for analog sensor GPIO %02d", gpio);
return StateUpdateResult::CHANGED; // persist the change
// check the gpio again and add to used list
if (EMSESP::system_.add_gpio(gpio, "Analog Sensor")) {
LOG_DEBUG("Adding customization for analog sensor GPIO %02d", gpio);
return StateUpdateResult::CHANGED; // persist the change
} else {
found_sensor = false;
return StateUpdateResult::ERROR; // if we can't add the GPIO, return an error
}
});
}
// reloads the sensors in the customizations file into the sensors list
reload();
if (found_sensor) {
reload();
}
// return false if it's an invalid GPIO, an error will show in WebUI
// and reported as an error in the log
return System::is_valid_gpio(gpio);
return found_sensor;
}
// check to see if values have been updated
@@ -622,158 +628,158 @@ void AnalogSensor::publish_values(const bool force) {
JsonDocument doc;
for (auto & sensor : sensors_) {
if (sensor.type() != AnalogType::NOTUSED) {
if (Mqtt::is_nested()) {
char s[10];
JsonObject dataSensor = doc[Helpers::smallitoa(s, sensor.gpio())].to<JsonObject>();
dataSensor["name"] = sensor.name();
if (Mqtt::is_nested()) {
char s[10];
JsonObject dataSensor = doc[Helpers::smallitoa(s, sensor.gpio())].to<JsonObject>();
dataSensor["name"] = sensor.name();
#if CONFIG_IDF_TARGET_ESP32
if (sensor.type() == AnalogType::PULSE || (sensor.type() == AnalogType::DIGITAL_OUT && sensor.gpio() != 25 && sensor.gpio() != 26)) {
if (sensor.type() == AnalogType::PULSE || (sensor.type() == AnalogType::DIGITAL_OUT && sensor.gpio() != 25 && sensor.gpio() != 26)) {
#else
if (sensor.type() == AnalogType::PULSE || sensor.type() == AnalogType::DIGITAL_OUT) {
if (sensor.type() == AnalogType::PULSE || sensor.type() == AnalogType::DIGITAL_OUT) {
#endif
if (EMSESP::system_.bool_format() == BOOL_FORMAT_TRUEFALSE) {
dataSensor["value"] = sensor.value() != 0;
} else if (EMSESP::system_.bool_format() == BOOL_FORMAT_10) {
dataSensor["value"] = sensor.value() != 0 ? 1 : 0;
} else {
char result[12];
dataSensor["value"] = Helpers::render_boolean(result, sensor.value() != 0);
}
} else {
dataSensor["value"] = serialized(Helpers::render_value(s, sensor.value(), 2)); // double
}
} else if (sensor.type() == AnalogType::DIGITAL_IN || sensor.type() == AnalogType::DIGITAL_OUT || sensor.type() == AnalogType::PULSE) {
if (EMSESP::system_.bool_format() == BOOL_FORMAT_TRUEFALSE) {
doc[sensor.name()] = sensor.value() != 0;
dataSensor["value"] = sensor.value() != 0;
} else if (EMSESP::system_.bool_format() == BOOL_FORMAT_10) {
doc[sensor.name()] = sensor.value() != 0 ? 1 : 0;
dataSensor["value"] = sensor.value() != 0 ? 1 : 0;
} else {
char result[12];
doc[sensor.name()] = Helpers::render_boolean(result, sensor.value() != 0);
dataSensor["value"] = Helpers::render_boolean(result, sensor.value() != 0);
}
} else {
char s[10];
doc[sensor.name()] = serialized(Helpers::render_value(s, sensor.value(), 2));
dataSensor["value"] = serialized(Helpers::render_value(s, sensor.value(), 2)); // double
}
} else if (sensor.type() == AnalogType::DIGITAL_IN || sensor.type() == AnalogType::DIGITAL_OUT || sensor.type() == AnalogType::PULSE) {
if (EMSESP::system_.bool_format() == BOOL_FORMAT_TRUEFALSE) {
doc[sensor.name()] = sensor.value() != 0;
} else if (EMSESP::system_.bool_format() == BOOL_FORMAT_10) {
doc[sensor.name()] = sensor.value() != 0 ? 1 : 0;
} else {
char result[12];
doc[sensor.name()] = Helpers::render_boolean(result, sensor.value() != 0);
}
} else {
char s[10];
doc[sensor.name()] = serialized(Helpers::render_value(s, sensor.value(), 2));
}
// create HA config if hasn't already been done
if (Mqtt::ha_enabled() && (!sensor.ha_registered || force)) {
LOG_DEBUG("Recreating HA config for analog sensor GPIO %02d", sensor.gpio());
JsonDocument config;
config["~"] = Mqtt::base();
char stat_t[50];
snprintf(stat_t, sizeof(stat_t), "~/%s_data", F_(analogsensor)); // use base path
config["stat_t"] = stat_t;
char val_obj[50];
char val_cond[95];
if (Mqtt::is_nested()) {
snprintf(val_obj, sizeof(val_obj), "value_json['%02d']['value']", sensor.gpio());
snprintf(val_cond, sizeof(val_cond), "value_json['%02d'] is defined and %s is defined", sensor.gpio(), val_obj);
} else {
snprintf(val_obj, sizeof(val_obj), "value_json['%s']", sensor.name().c_str());
snprintf(val_cond, sizeof(val_cond), "%s is defined", val_obj);
}
char sample_val[12] = "0";
if (sensor.type() == AnalogType::DIGITAL_IN || sensor.type() == AnalogType::DIGITAL_OUT || sensor.type() == AnalogType::PULSE) {
Helpers::render_boolean(sample_val, false);
}
// don't bother with value template conditions if using Domoticz which doesn't fully support MQTT Discovery
if (Mqtt::discovery_type() == Mqtt::discoveryType::HOMEASSISTANT) {
config["val_tpl"] = (std::string) "{{" + val_obj + " if " + val_cond + "}}";
} else {
config["val_tpl"] = (std::string) "{{" + val_obj + "}}";
}
// create HA config if hasn't already been done
if (Mqtt::ha_enabled() && (!sensor.ha_registered || force)) {
LOG_DEBUG("Recreating HA config for analog sensor GPIO %02d", sensor.gpio());
char uniq_s[70];
if (Mqtt::entity_format() == Mqtt::entityFormat::MULTI_SHORT) {
snprintf(uniq_s, sizeof(uniq_s), "%s_%s_%02d", Mqtt::basename().c_str(), F_(analogsensor), sensor.gpio());
} else {
snprintf(uniq_s, sizeof(uniq_s), "%s_%02d", F_(analogsensor), sensor.gpio());
}
JsonDocument config;
config["~"] = Mqtt::base();
config["uniq_id"] = uniq_s;
char stat_t[50];
snprintf(stat_t, sizeof(stat_t), "%s/%s_data", Mqtt::base().c_str(), F_(analogsensor)); // use base path
config["stat_t"] = stat_t;
char name[50];
snprintf(name, sizeof(name), "%s", sensor.name().c_str());
config["name"] = name;
char val_obj[50];
char val_cond[95];
if (Mqtt::is_nested()) {
snprintf(val_obj, sizeof(val_obj), "value_json['%02d']['value']", sensor.gpio());
snprintf(val_cond, sizeof(val_cond), "value_json['%02d'] is defined and %s is defined", sensor.gpio(), val_obj);
} else {
snprintf(val_obj, sizeof(val_obj), "value_json['%s']", sensor.name().c_str());
snprintf(val_cond, sizeof(val_cond), "%s is defined", val_obj);
}
char sample_val[12] = "0";
if (sensor.type() == AnalogType::DIGITAL_IN || sensor.type() == AnalogType::DIGITAL_OUT || sensor.type() == AnalogType::PULSE) {
Helpers::render_boolean(sample_val, false);
}
// don't bother with value template conditions if using Domoticz which doesn't fully support MQTT Discovery
if (Mqtt::discovery_type() == Mqtt::discoveryType::HOMEASSISTANT) {
config["val_tpl"] = (std::string) "{{" + val_obj + " if " + val_cond + "}}";
} else {
config["val_tpl"] = (std::string) "{{" + val_obj + "}}";
}
if (sensor.uom() != DeviceValueUOM::NONE && sensor.type() != AnalogType::DIGITAL_OUT) {
config["unit_of_meas"] = EMSdevice::uom_to_string(sensor.uom());
}
char uniq_s[70];
if (Mqtt::entity_format() == Mqtt::entityFormat::MULTI_SHORT) {
snprintf(uniq_s, sizeof(uniq_s), "%s_%s_%02d", Mqtt::basename().c_str(), F_(analogsensor), sensor.gpio());
} else {
snprintf(uniq_s, sizeof(uniq_s), "%s_%02d", F_(analogsensor), sensor.gpio());
}
char topic[Mqtt::MQTT_TOPIC_MAX_SIZE];
config["uniq_id"] = uniq_s;
char name[50];
snprintf(name, sizeof(name), "%s", sensor.name().c_str());
config["name"] = name;
if (sensor.uom() != DeviceValueUOM::NONE && sensor.type() != AnalogType::DIGITAL_OUT) {
config["unit_of_meas"] = EMSdevice::uom_to_string(sensor.uom());
}
char topic[Mqtt::MQTT_TOPIC_MAX_SIZE];
// Set commands for some analog types
char command_topic[Mqtt::MQTT_TOPIC_MAX_SIZE];
// Set commands for some analog types
char command_topic[Mqtt::MQTT_TOPIC_MAX_SIZE];
#if CONFIG_IDF_TARGET_ESP32
if (sensor.type() == AnalogType::PULSE || (sensor.type() == AnalogType::DIGITAL_OUT && sensor.gpio() != 25 && sensor.gpio() != 26)) {
if (sensor.type() == AnalogType::PULSE || (sensor.type() == AnalogType::DIGITAL_OUT && sensor.gpio() != 25 && sensor.gpio() != 26)) {
#else
if (sensor.type() == AnalogType::PULSE || sensor.type() == AnalogType::DIGITAL_OUT) {
if (sensor.type() == AnalogType::PULSE || sensor.type() == AnalogType::DIGITAL_OUT) {
#endif
snprintf(topic, sizeof(topic), "switch/%s/%s_%02d/config", Mqtt::basename().c_str(), F_(analogsensor), sensor.gpio());
snprintf(command_topic, sizeof(command_topic), "%s/%s/%s", Mqtt::base().c_str(), F_(analogsensor), sensor.name().c_str());
config["cmd_t"] = command_topic;
Mqtt::add_ha_bool(config.as<JsonObject>());
} else if (sensor.type() == AnalogType::DIGITAL_OUT) { // DAC
snprintf(topic, sizeof(topic), "number/%s/%s_%02d/config", Mqtt::basename().c_str(), F_(analogsensor), sensor.gpio());
snprintf(command_topic, sizeof(command_topic), "%s/%s/%s", Mqtt::base().c_str(), F_(analogsensor), sensor.name().c_str());
config["cmd_t"] = command_topic;
config["min"] = 0;
config["max"] = 255;
config["mode"] = "box"; // auto, slider or box
config["step"] = 1;
} else if (sensor.type() >= AnalogType::PWM_0 && sensor.type() <= AnalogType::PWM_2) {
snprintf(topic, sizeof(topic), "number/%s/%s_%02d/config", Mqtt::basename().c_str(), F_(analogsensor), sensor.gpio());
snprintf(command_topic, sizeof(command_topic), "%s/%s/%s", Mqtt::base().c_str(), F_(analogsensor), sensor.name().c_str());
config["cmd_t"] = command_topic;
config["min"] = 0;
config["max"] = 100;
config["mode"] = "box"; // auto, slider or box
config["step"] = 0.1;
} else if (sensor.type() == AnalogType::RGB) {
snprintf(topic, sizeof(topic), "number/%s/%s_%02d/config", Mqtt::basename().c_str(), F_(analogsensor), sensor.gpio());
snprintf(command_topic, sizeof(command_topic), "%s/%s/%s", Mqtt::base().c_str(), F_(analogsensor), sensor.name().c_str());
config["cmd_t"] = command_topic;
config["min"] = 0;
config["max"] = 999999;
config["mode"] = "box"; // auto, slider or box
config["step"] = 1;
} else if (sensor.type() == AnalogType::COUNTER) {
snprintf(topic, sizeof(topic), "sensor/%s/%s_%02d/config", Mqtt::basename().c_str(), F_(analogsensor), sensor.gpio());
snprintf(command_topic, sizeof(command_topic), "%s/%s/%s", Mqtt::base().c_str(), F_(analogsensor), sensor.name().c_str());
config["cmd_t"] = command_topic;
config["stat_cla"] = "total_increasing";
// config["mode"] = "box"; // auto, slider or box
// config["step"] = sensor.factor();
} else if (sensor.type() == AnalogType::DIGITAL_IN) {
snprintf(topic, sizeof(topic), "binary_sensor/%s/%s_%02d/config", Mqtt::basename().c_str(), F_(analogsensor), sensor.gpio());
Mqtt::add_ha_bool(config.as<JsonObject>());
} else {
snprintf(topic, sizeof(topic), "sensor/%s/%s_%02d/config", Mqtt::basename().c_str(), F_(analogsensor), sensor.gpio());
config["stat_cla"] = "measurement";
}
// see if we need to create the [devs] discovery section, as this needs only to be done once for all sensors
bool is_ha_device_created = false;
for (auto const & sensor : sensors_) {
if (sensor.ha_registered) {
is_ha_device_created = true;
break;
}
}
// add default_entity_id
std::string topic_str(topic);
doc["def_ent_id"] = topic_str.substr(0, topic_str.find("/")) + "." + uniq_s;
Mqtt::add_ha_dev_section(config.as<JsonObject>(), "Analog Sensors", nullptr, nullptr, nullptr, false);
Mqtt::add_ha_avail_section(config.as<JsonObject>(), stat_t, !is_ha_device_created, val_cond);
sensor.ha_registered = Mqtt::queue_ha(topic, config.as<JsonObject>());
snprintf(topic, sizeof(topic), "switch/%s/%s_%02d/config", Mqtt::basename().c_str(), F_(analogsensor), sensor.gpio());
snprintf(command_topic, sizeof(command_topic), "%s/%s/%s", Mqtt::base().c_str(), F_(analogsensor), sensor.name().c_str());
config["cmd_t"] = command_topic;
Mqtt::add_ha_bool(config.as<JsonObject>());
} else if (sensor.type() == AnalogType::DIGITAL_OUT) { // DAC
snprintf(topic, sizeof(topic), "number/%s/%s_%02d/config", Mqtt::basename().c_str(), F_(analogsensor), sensor.gpio());
snprintf(command_topic, sizeof(command_topic), "%s/%s/%s", Mqtt::base().c_str(), F_(analogsensor), sensor.name().c_str());
config["cmd_t"] = command_topic;
config["min"] = 0;
config["max"] = 255;
config["mode"] = "box"; // auto, slider or box
config["step"] = 1;
} else if (sensor.type() >= AnalogType::PWM_0 && sensor.type() <= AnalogType::PWM_2) {
snprintf(topic, sizeof(topic), "number/%s/%s_%02d/config", Mqtt::basename().c_str(), F_(analogsensor), sensor.gpio());
snprintf(command_topic, sizeof(command_topic), "%s/%s/%s", Mqtt::base().c_str(), F_(analogsensor), sensor.name().c_str());
config["cmd_t"] = command_topic;
config["min"] = 0;
config["max"] = 100;
config["mode"] = "box"; // auto, slider or box
config["step"] = 0.1;
} else if (sensor.type() == AnalogType::RGB) {
snprintf(topic, sizeof(topic), "number/%s/%s_%02d/config", Mqtt::basename().c_str(), F_(analogsensor), sensor.gpio());
snprintf(command_topic, sizeof(command_topic), "%s/%s/%s", Mqtt::base().c_str(), F_(analogsensor), sensor.name().c_str());
config["cmd_t"] = command_topic;
config["min"] = 0;
config["max"] = 999999;
config["mode"] = "box"; // auto, slider or box
config["step"] = 1;
} else if (sensor.type() == AnalogType::COUNTER) {
snprintf(topic, sizeof(topic), "sensor/%s/%s_%02d/config", Mqtt::basename().c_str(), F_(analogsensor), sensor.gpio());
snprintf(command_topic, sizeof(command_topic), "%s/%s/%s", Mqtt::base().c_str(), F_(analogsensor), sensor.name().c_str());
config["cmd_t"] = command_topic;
config["stat_cla"] = "total_increasing";
// config["mode"] = "box"; // auto, slider or box
// config["step"] = sensor.factor();
} else if (sensor.type() == AnalogType::DIGITAL_IN) {
snprintf(topic, sizeof(topic), "binary_sensor/%s/%s_%02d/config", Mqtt::basename().c_str(), F_(analogsensor), sensor.gpio());
Mqtt::add_ha_bool(config.as<JsonObject>());
} else {
snprintf(topic, sizeof(topic), "sensor/%s/%s_%02d/config", Mqtt::basename().c_str(), F_(analogsensor), sensor.gpio());
config["stat_cla"] = "measurement";
}
// see if we need to create the [devs] discovery section, as this needs only to be done once for all sensors
bool is_ha_device_created = false;
for (auto const & sensor : sensors_) {
if (sensor.ha_registered) {
is_ha_device_created = true;
break;
}
}
// add default_entity_id
std::string topic_str(topic);
doc["def_ent_id"] = topic_str.substr(0, topic_str.find("/")) + "." + uniq_s;
Mqtt::add_ha_dev_section(config.as<JsonObject>(), "Analog Sensors", nullptr, nullptr, nullptr, false);
Mqtt::add_ha_avail_section(config.as<JsonObject>(), stat_t, !is_ha_device_created, val_cond);
sensor.ha_registered = Mqtt::queue_ha(topic, config.as<JsonObject>());
}
}

View File

@@ -1,6 +1,6 @@
/*
* EMS-ESP - https://github.com/emsesp/EMS-ESP
* Copyright 2020-2024 emsesp.org - proddy, MichaelDvP
* Copyright 2020-2025 emsesp.org - proddy, MichaelDvP
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -118,7 +118,6 @@ class AnalogSensor {
~AnalogSensor() = default;
enum AnalogType : int8_t {
NOTUSED = 0, // 0 = disabled
DIGITAL_IN = 1,
COUNTER = 2,
ADC = 3,
@@ -164,12 +163,10 @@ class AnalogSensor {
return (!sensors_.empty());
}
// count number of items in sensors_ where type is not set to disabled and not a system sensor
size_t count_entities(bool exclude_disabled_system = false) const {
if (exclude_disabled_system) {
// count number of items in sensors_ where type is not set to disabled and not a system sensor
return std::count_if(sensors_.begin(), sensors_.end(), [](const Sensor & sensor) {
return sensor.type() != AnalogSensor::AnalogType::NOTUSED && !sensor.is_system();
});
return std::count_if(sensors_.begin(), sensors_.end(), [](const Sensor & sensor) { return !sensor.is_system(); });
}
return sensors_.size();
}

View File

@@ -1,6 +1,6 @@
/*
* EMS-ESP - https://github.com/emsesp/EMS-ESP
* Copyright 2020-2024 emsesp.org - proddy, MichaelDvP
* Copyright 2020-2025 emsesp.org - proddy, MichaelDvP
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -413,7 +413,7 @@ uint8_t Command::call(const uint8_t device_type, const char * command, const cha
}
}
std::string err = "no entity '" + std::string(cmd) + "' in " + dname;
std::string err = "no '" + std::string(cmd) + "' in " + dname;
output["message"] = err;
LOG_WARNING("Command failed: %s", err.c_str());
}
@@ -763,6 +763,8 @@ void Command::show_all(uuid::console::Shell & shell) {
shell.println(COLOR_RESET);
shell.printf(" entities \t\t\t%slist all entities %s*", COLOR_BRIGHT_CYAN, COLOR_BRIGHT_GREEN);
shell.println(COLOR_RESET);
shell.printf(" metrics \t\t\t%slist all prometheus metrics %s*", COLOR_BRIGHT_CYAN, COLOR_BRIGHT_GREEN);
shell.println(COLOR_RESET);
// show system ones first
show(shell, EMSdevice::DeviceType::SYSTEM, true);

View File

@@ -1,6 +1,6 @@
/*
* EMS-ESP - https://github.com/emsesp/EMS-ESP
* Copyright 2020-2024 emsesp.org - proddy, MichaelDvP
* Copyright 2020-2025 emsesp.org - proddy, MichaelDvP
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by

View File

@@ -1,6 +1,6 @@
/*
* EMS-ESP - https://github.com/emsesp/EMS-ESP
* Copyright 2020-2024 emsesp.org - proddy, MichaelDvP
* Copyright 2020-2025 emsesp.org - proddy, MichaelDvP
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by

View File

@@ -1,7 +1,7 @@
/*
* EMS-ESP - https://github.com/emsesp/EMS-ESP
* Copyright 2020-2024 emsesp.org - proddy, MichaelDvP
* Copyright 2020-2025 emsesp.org - proddy, MichaelDvP
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -87,8 +87,8 @@ static void setup_commands(std::shared_ptr<Commands> const & commands) {
Command::show_all(shell);
} else if (command == F_(system)) {
EMSESP::system_.show_system(shell);
} else if (command == F_(users) && (shell.has_flags(CommandFlags::ADMIN))) {
EMSESP::system_.show_users(shell); // admin only
} else if (command == F_(users)) {
EMSESP::system_.show_users(shell);
} else if (command == F_(devices)) {
EMSESP::show_devices(shell);
} else if (command == F_(log)) {
@@ -320,8 +320,8 @@ static void setup_commands(std::shared_ptr<Commands> const & commands) {
settings.eth_clock_mode = data[8];
return StateUpdateResult::CHANGED;
});
shell.printfln("Loaded board profile %s", board_profile.c_str());
EMSESP::system_.network_init(true);
shell.printfln("Loaded board profile %s. Restarting...", board_profile.c_str());
EMSESP::system_.system_restart();
});
commands->add_command(
@@ -338,7 +338,7 @@ static void setup_commands(std::shared_ptr<Commands> const & commands) {
return StateUpdateResult::CHANGED;
});
} else {
shell.println("Must be 0B, 0D, 0A, 0E, 0F, or 48 - 4D");
shell.println("Must be 0B, 0D, 0A, 0E, 0F or 48-4D");
}
},
[](Shell & shell, const std::vector<std::string> & current_arguments, const std::string & next_argument) -> std::vector<std::string> {
@@ -357,7 +357,7 @@ static void setup_commands(std::shared_ptr<Commands> const & commands) {
shell.printfln(F_(tx_mode_fmt), settings.tx_mode);
return StateUpdateResult::CHANGED;
});
EMSESP::uart_init();
EMSESP::system_.uart_init();
});
//

View File

@@ -1,6 +1,6 @@
/*
* EMS-ESP - https://github.com/emsesp/EMS-ESP
* Copyright 2020-2024 emsesp.org - proddy, MichaelDvP
* Copyright 2020-2025 emsesp.org - proddy, MichaelDvP
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by

View File

@@ -1,6 +1,6 @@
/*
* EMS-ESP - https://github.com/emsesp/EMS-ESP
* Copyright 2020-2024 emsesp.org - proddy, MichaelDvP
* Copyright 2020-2025 emsesp.org - proddy, MichaelDvP
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by

View File

@@ -1,6 +1,6 @@
/*
* EMS-ESP - https://github.com/emsesp/EMS-ESP
* Copyright 2020-2024 emsesp.org - proddy, MichaelDvP
* Copyright 2020-2025 emsesp.org - proddy, MichaelDvP
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by

View File

@@ -1,6 +1,6 @@
/*
* EMS-ESP - https://github.com/emsesp/EMS-ESP
* Copyright 2020-2024 emsesp.org - proddy, MichaelDvP
* Copyright 2020-2025 emsesp.org - proddy, MichaelDvP
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -1530,6 +1530,14 @@ bool EMSdevice::get_value_info(JsonObject output, const char * cmd, const int8_t
}
return true;
}
if (!strcmp(cmd, F_(metrics))) {
std::string metrics = get_metrics_prometheus(tag);
if (!metrics.empty()) {
output["api_data"] = metrics;
return true;
}
return false;
}
// search device value with this tag
// make a copy of cmd and split attribute (leave cmd untouched for other devices)
@@ -1693,6 +1701,178 @@ void EMSdevice::get_value_json(JsonObject json, DeviceValue & dv) {
json["visible"] = !dv.has_state(DeviceValueState::DV_WEB_EXCLUDE);
}
// generate Prometheus metrics format from device values
std::string EMSdevice::get_metrics_prometheus(const int8_t tag) {
std::string result;
std::unordered_map<std::string, bool> seen_metrics;
for (auto & dv : devicevalues_) {
if (tag >= 0 && tag != dv.tag) {
continue;
}
// only process number and boolean types for now
if (dv.type != DeviceValueType::BOOL && dv.type != DeviceValueType::UINT8 && dv.type != DeviceValueType::INT8 && dv.type != DeviceValueType::UINT16
&& dv.type != DeviceValueType::INT16 && dv.type != DeviceValueType::UINT24 && dv.type != DeviceValueType::UINT32 && dv.type != DeviceValueType::TIME) {
continue;
}
bool has_value = false;
double metric_value = 0.0;
switch (dv.type) {
case DeviceValueType::BOOL:
if (Helpers::hasValue(*(uint8_t *)(dv.value_p), EMS_VALUE_BOOL)) {
has_value = true;
metric_value = (bool)*(uint8_t *)(dv.value_p) ? 1.0 : 0.0;
}
break;
case DeviceValueType::UINT8:
if (Helpers::hasValue(*(uint8_t *)(dv.value_p))) {
has_value = true;
metric_value = *(uint8_t *)(dv.value_p);
}
break;
case DeviceValueType::INT8:
if (Helpers::hasValue(*(int8_t *)(dv.value_p))) {
has_value = true;
metric_value = *(int8_t *)(dv.value_p);
}
break;
case DeviceValueType::UINT16:
if (Helpers::hasValue(*(uint16_t *)(dv.value_p))) {
has_value = true;
metric_value = *(uint16_t *)(dv.value_p);
}
break;
case DeviceValueType::INT16:
if (Helpers::hasValue(*(int16_t *)(dv.value_p))) {
has_value = true;
metric_value = *(int16_t *)(dv.value_p);
}
break;
case DeviceValueType::UINT24:
case DeviceValueType::UINT32:
case DeviceValueType::TIME:
if (Helpers::hasValue(*(uint32_t *)(dv.value_p))) {
has_value = true;
metric_value = *(uint32_t *)(dv.value_p);
}
break;
default:
break;
}
if (!has_value) {
continue;
}
std::string metric_name = dv.short_name;
size_t last_dot = metric_name.find_last_of('.');
if (last_dot != std::string::npos) {
metric_name = metric_name.substr(last_dot + 1);
}
// sanitize metric name: convert to lowercase and replace non-alphanumeric with underscores
for (char & c : metric_name) {
if (isupper(c)) {
c = tolower(c);
} else if (!isalnum(c) && c != '_') {
c = '_';
}
}
std::string full_metric_name = "emsesp_" + metric_name;
std::string circuit_label;
if (dv.tag != DeviceValueTAG::TAG_NONE) {
const char * circuit = tag_to_mqtt(dv.tag);
if (circuit && strlen(circuit) > 0) {
circuit_label = circuit;
}
}
auto fullname = dv.get_fullname();
std::string help_text;
if (!fullname.empty()) {
help_text = fullname;
} else {
help_text = metric_name;
}
std::string uom_str;
if (dv.type == DeviceValueType::BOOL) {
uom_str = "boolean";
} else if (dv.uom != DeviceValueUOM::NONE) {
uom_str = uom_to_string(dv.uom);
}
std::string help_line = help_text;
if (!uom_str.empty()) {
help_line += ", " + uom_str;
}
bool readable = dv.type != DeviceValueType::CMD && !dv.has_state(DeviceValueState::DV_API_MQTT_EXCLUDE);
bool writeable = dv.has_cmd && !dv.has_state(DeviceValueState::DV_READONLY);
bool visible = !dv.has_state(DeviceValueState::DV_WEB_EXCLUDE);
if (readable) {
help_line += ", readable";
}
if (writeable) {
help_line += ", writeable";
}
if (visible) {
help_line += ", visible";
}
std::string escaped_help;
for (char c : help_line) {
if (c == '\\') {
escaped_help += "\\\\";
} else if (c == '\n') {
escaped_help += "\\n";
} else {
escaped_help += c;
}
}
if (seen_metrics.find(full_metric_name) == seen_metrics.end()) {
result += "# HELP " + full_metric_name + " " + escaped_help + "\n";
result += "# TYPE " + full_metric_name + " gauge\n";
seen_metrics[full_metric_name] = true;
}
result += full_metric_name;
if (!circuit_label.empty()) {
result += "{circuit=\"" + circuit_label + "\"}";
}
result += " ";
char val_str[30];
double final_value = metric_value;
if (dv.numeric_operator != 0) {
if (dv.numeric_operator > 0) {
final_value = metric_value / dv.numeric_operator;
} else {
final_value = metric_value * (-dv.numeric_operator);
}
}
double rounded = (final_value >= 0) ? (double)((int64_t)(final_value + 0.5)) : (double)((int64_t)(final_value - 0.5));
if (dv.type == DeviceValueType::BOOL || (final_value == rounded)) {
snprintf(val_str, sizeof(val_str), "%.0f", final_value);
} else {
snprintf(val_str, sizeof(val_str), "%.2f", final_value);
}
result += val_str;
result += "\n";
}
return result;
}
// mqtt publish all single values from one device (used for time schedule)
void EMSdevice::publish_all_values() {
for (const auto & dv : devicevalues_) {

View File

@@ -1,6 +1,6 @@
/*
* EMS-ESP - https://github.com/emsesp/EMS-ESP
* Copyright 2020-2024 emsesp.org - proddy, MichaelDvP
* Copyright 2020-2025 emsesp.org - proddy, MichaelDvP
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -25,6 +25,8 @@
#include "helpers.h"
#include "emsdevicevalue.h"
#include <unordered_map>
namespace emsesp {
class EMSdevice {
@@ -249,6 +251,7 @@ class EMSdevice {
std::string get_value_uom(const std::string & shortname) const;
bool get_value_info(JsonObject root, const char * cmd, const int8_t id);
void get_value_json(JsonObject output, DeviceValue & dv);
std::string get_metrics_prometheus(const int8_t tag = -1);
void get_dv_info(JsonObject json);
enum OUTPUT_TARGET : uint8_t { API_VERBOSE, API_SHORTNAMES, MQTT, CONSOLE };

View File

@@ -1,6 +1,6 @@
/*
* EMS-ESP - https://github.com/emsesp/EMS-ESP
* Copyright 2020-2024 emsesp.org - proddy, MichaelDvP
* Copyright 2020-2025 emsesp.org - proddy, MichaelDvP
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by

View File

@@ -1,7 +1,7 @@
/*
* EMS-ESP - https://github.com/emsesp/EMS-ESP
* Copyright 2020-2024 emsesp.org - proddy, MichaelDvP
* Copyright 2020-2025 emsesp.org - proddy, MichaelDvP
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by

View File

@@ -1,6 +1,6 @@
/*
* EMS-ESP - https://github.com/emsesp/EMS-ESP
* Copyright 2020-2024 emsesp.org - proddy, MichaelDvP
* Copyright 2020-2025 emsesp.org - proddy, MichaelDvP
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -240,36 +240,6 @@ void EMSESP::watch_id(uint16_t watch_id) {
watch_id_ = watch_id;
}
// resets all counters and bumps the UART
// this is called when the tx_mode is persisted in the FS either via Web UI or the console
void EMSESP::uart_init() {
uint8_t tx_mode = 0;
uint8_t rx_gpio = 0;
uint8_t tx_gpio = 0;
EMSESP::webSettingsService.read([&](WebSettings const & settings) {
tx_mode = settings.tx_mode;
rx_gpio = settings.rx_gpio;
tx_gpio = settings.tx_gpio;
});
EMSuart::stop();
// don't start UART if we have invalid GPIOs
if (System::is_valid_gpio(rx_gpio) && System::is_valid_gpio(tx_gpio)) {
EMSuart::start(tx_mode, rx_gpio, tx_gpio); // start UART
} else {
LOG_WARNING("Invalid UART Rx/Tx GPIOs. Check config.");
}
txservice_.start(); // sends out request to EMS bus for all devices
txservice_.tx_mode(tx_mode);
// force a fetch for all new values, unless Tx is set to off
// if (tx_mode != 0) {
// EMSESP::fetch_device_values();
// }
}
// return status of bus: connected (0), connected but Tx is broken (1), disconnected (2)
uint8_t EMSESP::bus_status() {
if (!rxservice_.bus_connected()) {
@@ -1157,6 +1127,10 @@ bool EMSESP::process_telegram(std::shared_ptr<const Telegram> telegram) {
return false;
}
if (wait_validate_ == telegram->type_id) {
wait_validate_ = 0;
}
// Check for custom entities reding this telegram
webCustomEntityService.get_value(telegram);
@@ -1180,64 +1154,47 @@ bool EMSESP::process_telegram(std::shared_ptr<const Telegram> telegram) {
// returns false if the device_id doesn't recognize it
// after the telegram has been processed, see if there have been values changed and we need to do a MQTT publish
bool telegram_found = false;
uint8_t device_found = 0;
EMSdevice * found_device = nullptr;
// Combined loop: check all conditions in a single pass
// check all conditions in one loop
for (const auto & emsdevice : emsdevices) {
// broadcast or send to us
if (emsdevice->is_device_id(telegram->src) && (telegram->dest == 0 || telegram->dest == EMSbus::ems_bus_id())) {
telegram_found = emsdevice->handle_telegram(telegram);
found_device = emsdevice.get();
break;
}
// check for command to the device
if (!telegram_found && emsdevice->is_device_id(telegram->dest) && telegram->src != EMSbus::ems_bus_id()) {
telegram_found = emsdevice->handle_telegram(telegram);
found_device = emsdevice.get();
break;
}
// check for sends to master thermostat
if (!telegram_found && emsdevice->is_device_id(telegram->src) && telegram->dest == 0x10) {
telegram_found = emsdevice->handle_telegram(telegram);
found_device = emsdevice.get();
break;
if ((emsdevice->is_device_id(telegram->src) && (telegram->dest == 0 || telegram->dest == EMSbus::ems_bus_id() || telegram->dest == 0x10))
|| (emsdevice->is_device_id(telegram->dest) && telegram->src != EMSbus::ems_bus_id())) {
found_device = emsdevice.get();
if (emsdevice->handle_telegram(telegram)) {
telegram_found = true;
if (Mqtt::connected()
&& ((mqtt_.get_publish_onchange(found_device->device_type()) && found_device->has_update())
|| (telegram->type_id == publish_id_ && telegram->dest == EMSbus::ems_bus_id()))) {
if (telegram->type_id == publish_id_) {
publish_id_ = 0;
}
found_device->has_update(false); // reset flag
if (!Mqtt::publish_single()) {
publish_device_values(found_device->device_type()); // publish to MQTT if we explicitly have too
}
}
break; // remove this to handle same telegrams on multiple devices
}
}
}
if (found_device) {
device_found = found_device->unique_id();
// Process the found device directly without another loop
if (!telegram_found && telegram->message_length > 0) {
// handle unknown telegrams
if (!telegram_found) {
// mark nonempty telegrams as ignored
if (found_device && telegram->message_length > 0) {
found_device->add_handlers_ignored(telegram->type_id);
}
if (wait_validate_ == telegram->type_id) {
wait_validate_ = 0;
}
if (Mqtt::connected() && telegram_found
&& ((mqtt_.get_publish_onchange(found_device->device_type()) && found_device->has_update())
|| (telegram->type_id == publish_id_ && telegram->dest == EMSbus::ems_bus_id()))) {
if (telegram->type_id == publish_id_) {
publish_id_ = 0;
// handle unknown broadcasted telegrams (or send to us)
if (telegram->dest == 0 || telegram->dest == EMSbus::ems_bus_id()) {
LOG_DEBUG("No telegram type handler found for ID 0x%02X (src 0x%02X)", telegram->type_id, telegram->src);
if (watch() == WATCH_UNKNOWN) {
LOG_NOTICE("%s", pretty_telegram(telegram).c_str());
}
found_device->has_update(false); // reset flag
if (!Mqtt::publish_single()) {
publish_device_values(found_device->device_type()); // publish to MQTT if we explicitly have too
if (!wait_km_ && !found_device && (telegram->src != EMSbus::ems_bus_id()) && (telegram->message_length > 0)) {
send_read_request(EMSdevice::EMS_TYPE_VERSION, telegram->src);
}
}
}
// handle unknown broadcasted telegrams (or send to us)
if (!telegram_found && (telegram->dest == 0 || telegram->dest == EMSbus::ems_bus_id())) {
LOG_DEBUG("No telegram type handler found for ID 0x%02X (src 0x%02X)", telegram->type_id, telegram->src);
if (watch() == WATCH_UNKNOWN) {
LOG_NOTICE("%s", pretty_telegram(telegram).c_str());
}
if (!wait_km_ && !device_found && (telegram->src != EMSbus::ems_bus_id()) && (telegram->message_length > 0)) {
send_read_request(EMSdevice::EMS_TYPE_VERSION, telegram->src);
}
}
return telegram_found;
}
@@ -1708,6 +1665,9 @@ void EMSESP::start() {
bool factory_settings = false;
#endif
// set valid GPIOs list based on ESP32 chip/platform type
system_.set_valid_system_gpios();
// start web log service. now we can start capturing logs to the web log
webLogService.begin();
@@ -1757,8 +1717,6 @@ void EMSESP::start() {
};
LOG_INFO("Library loaded: %d EMS devices, %d device entities, %s", device_library_.size(), EMSESP_TRANSLATION_COUNT, system_.languages_string().c_str());
system_.reload_settings(); // ... and store some of the settings locally
webCustomizationService.begin(); // load the customizations
webSchedulerService.begin(); // load the scheduler events
webCustomEntityService.begin(); // load the custom telegram reads
@@ -1819,12 +1777,23 @@ void EMSESP::shell_prompt() {
// main loop calling all services
void EMSESP::loop() {
esp32React.loop(); // web services
system_.loop(); // does LED and checks system health, and syslog service
uuid::loop(); // store system uptime
esp32React.loop(); // web services
system_.loop(); // does LED and checks system health, and syslog service
webLogService.loop(); // log in Web UI
// run the loop, unless we're in the middle of an OTA upload
if (EMSESP::system_.systemStatus() == SYSTEM_STATUS::SYSTEM_STATUS_NORMAL) {
webLogService.loop(); // log in Web UI
if (EMSESP::system_.systemStatus() == SYSTEM_STATUS::SYSTEM_STATUS_NORMAL || EMSESP::system_.systemStatus() == SYSTEM_STATUS::SYSTEM_STATUS_INVALID_GPIO) {
// check for GPIO Errors
if (EMSESP::system_.systemStatus() == SYSTEM_STATUS::SYSTEM_STATUS_INVALID_GPIO) {
static bool only_once = false;
if (!only_once) {
LOG_ERROR("Invalid GPIOs used. Please check your settings and log");
only_once = true;
}
}
// loop through the services
rxservice_.loop(); // process any incoming Rx telegrams
shower_.loop(); // check for shower on/off
temperaturesensor_.loop(); // read sensor temperatures
@@ -1849,20 +1818,19 @@ void EMSESP::loop() {
}
}
uuid::loop();
// telnet service
#ifndef EMSESP_STANDALONE
if (system_.telnet_enabled()) {
telnet_.loop();
}
#endif
// console service
Shell::loop_all();
static bool show_prompt = true;
// user has to CTRL-D to create a serial console stream, exit command will close it
// this saves around 2kb of heap memory
static bool show_prompt = true;
if (shell_) {
if (!shell_->running()) {
shell_.reset();

View File

@@ -1,6 +1,6 @@
/*
* EMS-ESP - https://github.com/emsesp/EMS-ESP
* Copyright 2020-2024 emsesp.org - proddy, MichaelDvP
* Copyright 2020-2025 emsesp.org - proddy, MichaelDvP
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -147,8 +147,6 @@ class EMSESP {
static void dump_all_entities(uuid::console::Shell & shell);
static void dump_all_telegrams(uuid::console::Shell & shell);
static void uart_init();
static void incoming_telegram(uint8_t * data, const uint8_t length);
static bool sensor_enabled() {

View File

@@ -1,6 +1,6 @@
/*
* EMS-ESP - https://github.com/emsesp/EMS-ESP
* Copyright 2020-2024 emsesp.org - proddy, MichaelDvP
* Copyright 2020-2025 emsesp.org - proddy, MichaelDvP
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by

View File

@@ -1,6 +1,6 @@
/*
* EMS-ESP - https://github.com/emsesp/EMS-ESP
* Copyright 2020-2024 emsesp.org - proddy, MichaelDvP
* Copyright 2020-2025 emsesp.org - proddy, MichaelDvP
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -697,7 +697,7 @@ bool Helpers::value2bool(const char * value, bool & value_b) {
}
#ifdef EMSESP_STANDALONE
emsesp::EMSESP::logger().debug("Error. value2bool: %s is not a boolean", value);
EMSESP::logger().debug("Error. value2bool: %s is not a boolean", value);
#endif
return false; // not a bool

View File

@@ -1,6 +1,6 @@
/*
* EMS-ESP - https://github.com/emsesp/EMS-ESP
* Copyright 2020-2024 emsesp.org - proddy, MichaelDvP
* Copyright 2020-2025 emsesp.org - proddy, MichaelDvP
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by

View File

@@ -1,6 +1,6 @@
/*
* EMS-ESP - https://github.com/emsesp/EMS-ESP
* Copyright 2020-2024 emsesp.org - proddy, MichaelDvP
* Copyright 2020-2025 emsesp.org - proddy, MichaelDvP
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -86,6 +86,7 @@ MAKE_WORD(info)
MAKE_WORD(settings)
MAKE_WORD(value)
MAKE_WORD(entities)
MAKE_WORD(metrics)
MAKE_WORD(coldshot)
// device types - lowercase, used in MQTT

View File

@@ -1,6 +1,6 @@
/*
* EMS-ESP - https://github.com/emsesp/EMS-ESP
* Copyright 2020-2024 emsesp.org - proddy, MichaelDvP
* Copyright 2020-2025 emsesp.org - proddy, MichaelDvP
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -63,6 +63,7 @@ MAKE_WORD_TRANSLATION(pool_device, "Pool Module", "Poolmodul", "", "Poolmodul",
MAKE_WORD_TRANSLATION(info_cmd, "list all values (verbose)", "Liste aller Werte", "lijst van alle waardes", "lista alla värden", "wyświetl wszystkie wartości", "Viser alle verdier", "", "Tüm değerleri listele", "elenca tutti i valori", "zobraziť všetky hodnoty", "vypsat všechny hodnoty (podrobně)") // TODO translate
MAKE_WORD_TRANSLATION(commands_cmd, "list all commands", "Liste aller Kommandos", "lijst van alle commando's", "lista alla kommandon", "wyświetl wszystkie komendy", "Viser alle kommandoer", "", "Tüm komutları listele", "elencaa tutti i comandi", "zobraziť všetky príkazy", "vypsat všechny příkazy") // TODO translate
MAKE_WORD_TRANSLATION(entities_cmd, "list all entities", "Liste aller Entitäten", "lijst van alle entiteiten", "lista all entiteter", "wyświetl wszsytkie encje", "Viser alle enheter", "", "Tüm varlıkları listele", "elenca tutte le entità", "zobraziť všetky entity", "vypsat všechny entity") // TODO translate
MAKE_WORD_TRANSLATION(metrics_cmd, "list all prometheus metrics", "Liste aller Prometheus Metriken", "lijst van alle Prometheus metriken", "lista alla Prometheus metriker", "wyświetl wszystkie Prometheus metryki", "Viser alle Prometheus metrikker", "", "Tüm Prometheus metriklerini listele", "elenca tutte le metriche Prometheus", "zobraziť všetky Prometheus metriky", "vypsat všechny Prometheus metriky") // TODO translate
MAKE_WORD_TRANSLATION(send_cmd, "send a telegram", "Sende EMS-Telegramm", "stuur een telegram", "skicka ett telegram", "wyślij telegram", "send et telegram", "", "Bir telegram gönder", "invia un telegramma", "poslať telegram", "odeslat telegram") // TODO translate
MAKE_WORD_TRANSLATION(read_cmd, "send read request", "", "", "skicka en läsförfrågan", "", "", "", "", "", "odoslať žiadosť o prečítanie", "") // TODO translate
MAKE_WORD_TRANSLATION(setiovalue_cmd, "set I/O value", "Setze Werte E/A", "instellen standaardwaarde", "sätt ett I/O-värde", "ustaw wartość", "sett en io verdi", "", "Giriş/Çıkış değerlerini ayarla", "imposta valore io", "nastaviť hodnotu io", "nastavit hodnotu I/O") // TODO translate

View File

@@ -1,6 +1,6 @@
/*
* EMS-ESP - https://github.com/emsesp/EMS-ESP
* Copyright 2020-2024 emsesp.org - proddy, MichaelDvP
* Copyright 2020-2025 emsesp.org - proddy, MichaelDvP
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by

View File

@@ -1,6 +1,6 @@
/*
* EMS-ESP - https://github.com/emsesp/EMS-ESP
* Copyright 2020-2024 emsesp.org - proddy, MichaelDvP
* Copyright 2020-2025 emsesp.org - proddy, MichaelDvP
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -321,8 +321,12 @@ void Mqtt::on_publish(uint16_t packetId) const {
LOG_DEBUG("Packet %d sent successful", packetId);
}
// called when MQTT settings have changed via the Web forms
// called when MQTT settings have changed via the MQTT Settings or Application Settings Web pages
void Mqtt::reset_mqtt() {
if (!enabled()) {
return;
}
if (!mqttClient_) {
return;
}
@@ -505,14 +509,14 @@ void Mqtt::on_connect() {
queue_subscribe_message(discovery_prefix_ + "/+/" + Mqtt::basename() + "/#");
}
// send initial MQTT messages for some of our services
EMSESP::system_.send_heartbeat(); // send heartbeat
// re-subscribe to all custom registered MQTT topics
resubscribe();
// publish to the last will topic (see Mqtt::start() function) to say we're alive
queue_publish_retain("status", "online"); // retain: https://github.com/emsesp/EMS-ESP32/discussions/2086
// send initial MQTT messages for some of our services
EMSESP::system_.send_heartbeat(); // send heartbeat
}
// Home Assistant Discovery - the main master Device called EMS-ESP
@@ -528,9 +532,10 @@ void Mqtt::ha_status() {
strcpy(uniq, "system_status");
}
doc["~"] = Mqtt::base();
doc["uniq_id"] = uniq;
doc["def_ent_id"] = (std::string) "binary_sensor." + uniq;
doc["stat_t"] = Mqtt::base() + "/status";
doc["stat_t"] = "~/status";
doc["name"] = "System status";
doc["pl_on"] = "online";
doc["pl_off"] = "offline";
@@ -977,8 +982,9 @@ bool Mqtt::publish_ha_sensor_config(uint8_t type, // EMSdev
return queue_remove_topic(topic);
}
// build the full payload
// build the full topic's payload
JsonDocument doc;
doc["~"] = Mqtt::base();
doc["uniq_id"] = uniq_id;
// set the entity_id. This is breaking change in HA 2025.10.0 - see https://github.com/home-assistant/core/pull/151775
@@ -996,9 +1002,9 @@ bool Mqtt::publish_ha_sensor_config(uint8_t type, // EMSdev
char command_topic[MQTT_TOPIC_MAX_SIZE];
// add command topic
if (tag >= DeviceValueTAG::TAG_HC1) {
snprintf(command_topic, sizeof(command_topic), "%s/%s/%s/%s", Mqtt::base().c_str(), device_name, EMSdevice::tag_to_mqtt(tag), entity);
snprintf(command_topic, sizeof(command_topic), "~/%s/%s/%s", device_name, EMSdevice::tag_to_mqtt(tag), entity);
} else {
snprintf(command_topic, sizeof(command_topic), "%s/%s/%s", Mqtt::base().c_str(), device_name, entity);
snprintf(command_topic, sizeof(command_topic), "~/%s/%s", device_name, entity);
}
doc["cmd_t"] = command_topic;
@@ -1059,9 +1065,9 @@ bool Mqtt::publish_ha_sensor_config(uint8_t type, // EMSdev
// This is where we determine which MQTT topic to pull the data from
// There is one exception for DeviceType::SYSTEM, which uses the heartbeat topic, and when fetching the version we want to take this from the info topic instead
if ((device_type == EMSdevice::DeviceType::SYSTEM) && (strncmp(entity, "version", 7) == 0)) {
snprintf(stat_t, sizeof(stat_t), "%s/%s", Mqtt::base().c_str(), F_(info));
snprintf(stat_t, sizeof(stat_t), "~/%s", F_(info));
} else {
snprintf(stat_t, sizeof(stat_t), "%s/%s", Mqtt::base().c_str(), tag_to_topic(device_type, tag).c_str());
snprintf(stat_t, sizeof(stat_t), "~/%s", tag_to_topic(device_type, tag).c_str());
}
doc["stat_t"] = stat_t;
@@ -1480,6 +1486,11 @@ void Mqtt::add_ha_avail_section(JsonObject doc, const char * state_t, const bool
avty.add(avty_json); // returns 0 if no mem
}
// add LWT (Last Will and Testament)
avty_json.clear();
avty_json["t"] = "~/status"; // as a topic
avty.add(avty_json);
doc["avty_mode"] = "all";
}

View File

@@ -1,6 +1,6 @@
/*
* EMS-ESP - https://github.com/emsesp/EMS-ESP
* Copyright 2020-2024 emsesp.org - proddy, MichaelDvP
* Copyright 2020-2025 emsesp.org - proddy, MichaelDvP
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by

View File

@@ -1,6 +1,6 @@
/*
* EMS-ESP - https://github.com/emsesp/EMS-ESP
* Copyright 2020-2024 emsesp.org - proddy, MichaelDvP
* Copyright 2020-2025 emsesp.org - proddy, MichaelDvP
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by

View File

@@ -1,6 +1,6 @@
/*
* EMS-ESP - https://github.com/emsesp/EMS-ESP
* Copyright 2020-2024 emsesp.org - proddy, MichaelDvP
* Copyright 2020-2025 emsesp.org - proddy, MichaelDvP
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by

View File

@@ -1,6 +1,6 @@
/*
* EMS-ESP - https://github.com/emsesp/EMS-ESP
* Copyright 2020-2024 emsesp.org - proddy, MichaelDvP
* Copyright 2020-2025 emsesp.org - proddy, MichaelDvP
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -197,6 +197,8 @@ void Shower::create_ha_discovery() {
char str[70];
char stat_t[50];
doc["~"] = Mqtt::base();
// shower active
doc["name"] = "Shower Active";
@@ -207,9 +209,7 @@ void Shower::create_ha_discovery() {
}
doc["uniq_id"] = str;
doc["def_ent_id"] = (std::string) "binary_sensor." + str;
snprintf(stat_t, sizeof(stat_t), "%s/shower_active", Mqtt::base().c_str());
doc["stat_t"] = stat_t;
doc["stat_t"] = "~/shower_active";
Mqtt::add_ha_bool(doc.as<JsonObject>());
Mqtt::add_ha_dev_section(doc.as<JsonObject>(), "Shower Sensor", nullptr, nullptr, nullptr, false);
@@ -225,10 +225,7 @@ void Shower::create_ha_discovery() {
doc["uniq_id"] = str;
doc["def_ent_id"] = (std::string) "sensor." + str;
snprintf(stat_t, sizeof(stat_t), "%s/shower_data", Mqtt::base().c_str());
doc["stat_t"] = stat_t;
doc["stat_t"] = "~/shower_data",
doc["name"] = "Shower Duration";
// don't bother with value template conditions if using Domoticz which doesn't fully support MQTT Discovery
@@ -248,29 +245,6 @@ void Shower::create_ha_discovery() {
snprintf(topic, sizeof(topic), "sensor/%s/shower_duration/config", Mqtt::basename().c_str());
Mqtt::queue_ha(topic, doc.as<JsonObject>()); // publish the config payload with retain flag
//
// shower timestamp
//
/* commented out as the publish of timestamp
doc.clear();
snprintf(str, sizeof(str), "%s_shower_timestamp", Mqtt::basename().c_str());
doc["uniq_id"] = str;
snprintf(stat_t, sizeof(stat_t), "%s/shower_data", Mqtt::base().c_str());
doc["stat_t"] = stat_t;
doc["name"] = "Shower Timestamp";
doc["val_tpl"] = "{{value_json.timestamp if value_json.timestamp is defined else 0}}";
// doc["ent_cat"] = "diagnostic";
Mqtt::add_ha_sections_to_doc("shower", stat_t, doc, false, "value_json.timestamp is defined");
snprintf(topic, sizeof(topic), "sensor/%s/shower_timestamp/config", Mqtt::basename().c_str());
Mqtt::queue_ha(topic, doc.as<JsonObject>()); // publish the config payload with retain flag
*/
}
}

View File

@@ -1,6 +1,6 @@
/*
* EMS-ESP - https://github.com/emsesp/EMS-ESP
* Copyright 2020-2024 emsesp.org - proddy, MichaelDvP
* Copyright 2020-2025 emsesp.org - proddy, MichaelDvP
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by

View File

@@ -22,6 +22,8 @@
#include "shuntingYard.h"
namespace emsesp {
// find tokens - optimized to reduce string allocations
std::deque<Token> exprToTokens(const std::string & expr) {
std::deque<Token> tokens;
@@ -340,9 +342,9 @@ bool isnum(const std::string & s) {
// replace commands like "<device>/<hc>/<cmd>" with its value"
std::string commands(std::string & expr, bool quotes) {
auto expr_new = emsesp::Helpers::toLower(expr);
for (uint8_t device = 0; device < emsesp::EMSdevice::DeviceType::UNKNOWN; device++) {
std::string d = (std::string)emsesp::EMSdevice::device_type_2_device_name(device) + "/";
auto expr_new = Helpers::toLower(expr);
for (uint8_t device = 0; device < EMSdevice::DeviceType::UNKNOWN; device++) {
std::string d = (std::string)EMSdevice::device_type_2_device_name(device) + "/";
auto f = expr_new.find(d);
while (f != std::string::npos) {
// entity names are alphanumeric or _
@@ -367,9 +369,9 @@ std::string commands(std::string & expr, bool quotes) {
JsonObject input = doc_in.to<JsonObject>();
std::string cmd_s = "api/" + std::string(cmd);
auto return_code = emsesp::Command::process(cmd_s.c_str(), true, input, output);
auto return_code = Command::process(cmd_s.c_str(), true, input, output);
// check for no value (entity is valid but has no value set)
if (return_code != emsesp::CommandRet::OK && return_code != emsesp::CommandRet::NO_VALUE) {
if (return_code != CommandRet::OK && return_code != CommandRet::NO_VALUE) {
return expr = "";
}
@@ -380,7 +382,7 @@ std::string commands(std::string & expr, bool quotes) {
}
expr.replace(f, l, data);
e = f + data.length();
expr_new = emsesp::Helpers::toLower(expr);
expr_new = Helpers::toLower(expr);
f = expr_new.find(d, e);
}
}
@@ -400,7 +402,7 @@ int to_logic(const std::string & s) {
if (s.empty()) {
return -1;
}
auto l = emsesp::Helpers::toLower(s);
auto l = Helpers::toLower(s);
if (s[0] == '1' || l == "on" || l == "true") {
return 1;
}
@@ -438,7 +440,7 @@ std::string calculate(const std::string & expr) {
const auto tokens = exprToTokens(expr_new);
// for debugging only
// for (const auto & t : tokens) {
// emsesp::EMSESP::logger().debug("shunt token: %s(%d)", t.str.c_str(), t.type);
// EMSESP::logger().debug("shunt token: %s(%d)", t.str.c_str(), t.type);
// Serial.printf("shunt token: %s(%d)\n", t.str.c_str(), t.type);
// Serial.println();
// }
@@ -475,7 +477,7 @@ std::string calculate(const std::string & expr) {
} else if (isnum(rhs)) {
stack.push_back(std::stod(rhs) == 0 ? "1" : "0");
} else {
emsesp::EMSESP::logger().warning("missing operator");
EMSESP::logger().warning("missing operator");
return "";
}
break;
@@ -573,7 +575,7 @@ std::string calculate(const std::string & expr) {
break;
}
// compare strings lower case
stack.push_back((emsesp::Helpers::toLower(lhs) == emsesp::Helpers::toLower(rhs)) ? "1" : "0");
stack.push_back((Helpers::toLower(lhs) == Helpers::toLower(rhs)) ? "1" : "0");
break;
case '!':
if (isnum(rhs) && isnum(lhs)) {
@@ -581,7 +583,7 @@ std::string calculate(const std::string & expr) {
break;
}
// compare strings lower case
stack.push_back((emsesp::Helpers::toLower(lhs) != emsesp::Helpers::toLower(rhs)) ? "1" : "0");
stack.push_back((Helpers::toLower(lhs) != Helpers::toLower(rhs)) ? "1" : "0");
break;
}
} break;
@@ -690,18 +692,18 @@ std::string compute(const std::string & expr) {
std::string url, header_s, value_s, method_s, key_s, keys_s;
// search keys lower case
for (JsonPair p : doc.as<JsonObject>()) {
if (emsesp::Helpers::toLower(p.key().c_str()) == "url") {
if (Helpers::toLower(p.key().c_str()) == "url") {
url = p.value().as<std::string>();
} else if (emsesp::Helpers::toLower(p.key().c_str()) == "header") {
} else if (Helpers::toLower(p.key().c_str()) == "header") {
header_s = p.key().c_str();
} else if (emsesp::Helpers::toLower(p.key().c_str()) == "value") {
} else if (Helpers::toLower(p.key().c_str()) == "value") {
value_s = p.key().c_str();
} else if (emsesp::Helpers::toLower(p.key().c_str()) == "method") {
} else if (Helpers::toLower(p.key().c_str()) == "method") {
method_s = p.key().c_str();
} else if (emsesp::Helpers::toLower(p.key().c_str()) == "key") {
} else if (Helpers::toLower(p.key().c_str()) == "key") {
keys_s = "";
key_s = p.key().c_str();
} else if (emsesp::Helpers::toLower(p.key().c_str()) == "keys") {
} else if (Helpers::toLower(p.key().c_str()) == "keys") {
key_s = "";
keys_s = p.key().c_str();
}
@@ -715,7 +717,7 @@ std::string compute(const std::string & expr) {
std::string method = doc[method_s] | "get";
// if there is data, force a POST
if (value.length() || emsesp::Helpers::toLower(method) == "post") {
if (value.length() || Helpers::toLower(method) == "post") {
if (value.find_first_of('{') != std::string::npos) {
http.addHeader("Content-Type", "application/json"); // auto-set to JSON
}
@@ -805,3 +807,5 @@ std::string compute(const std::string & expr) {
return calculate(expr_new);
}
} // namespace emsesp

View File

@@ -29,6 +29,8 @@
#include <deque>
#include <math.h>
namespace emsesp {
class Token {
public:
enum class Type : uint8_t {
@@ -84,3 +86,5 @@ std::string calculate(const std::string & expr);
std::string compute(const std::string & expr);
#endif
} // namespace emsesp

View File

@@ -1,6 +1,6 @@
/*
* EMS-ESP - https://github.com/emsesp/EMS-ESP
* Copyright 2020-2024 emsesp.org - proddy, MichaelDvP
* Copyright 2020-2025 emsesp.org - proddy, MichaelDvP
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -19,12 +19,12 @@
#include "system.h"
#include "emsesp.h" // for send_raw_telegram() command
#include "shuntingYard.h"
#ifndef EMSESP_STANDALONE
#include "esp_ota_ops.h"
#endif
#include <HTTPClient.h>
#include <semver200.h>
#if defined(EMSESP_TEST)
@@ -86,10 +86,12 @@ static constexpr uint8_t NUM_LANGUAGES = sizeof(languages) / sizeof(const char *
uuid::log::Logger System::logger_{F_(system), uuid::log::Facility::KERN};
// init statics
PButton System::myPButton_;
bool System::test_set_all_active_ = false;
uint32_t System::max_alloc_mem_;
uint32_t System::heap_mem_;
PButton System::myPButton_;
bool System::test_set_all_active_ = false;
uint32_t System::max_alloc_mem_;
uint32_t System::heap_mem_;
std::vector<uint8_t> System::valid_system_gpios_;
std::vector<uint8_t> System::used_gpios_;
// find the index of the language
// 0 = EN, 1 = DE, etc...
@@ -403,76 +405,48 @@ void System::syslog_init() {
#endif
}
// read some specific system settings to store locally for faster access
void System::reload_settings() {
EMSESP::webSettingsService.read([&](WebSettings & settings) {
version_ = settings.version;
// read specific major system settings to store locally for faster access
void System::store_settings(WebSettings & settings) {
version_ = settings.version;
pbutton_gpio_ = settings.pbutton_gpio;
analog_enabled_ = settings.analog_enabled;
low_clock_ = settings.low_clock;
hide_led_ = settings.hide_led;
led_type_ = settings.led_type;
led_gpio_ = settings.led_gpio;
board_profile_ = settings.board_profile;
telnet_enabled_ = settings.telnet_enabled;
rx_gpio_ = settings.rx_gpio;
tx_gpio_ = settings.tx_gpio;
pbutton_gpio_ = settings.pbutton_gpio;
dallas_gpio_ = settings.dallas_gpio;
led_gpio_ = settings.led_gpio;
modbus_enabled_ = settings.modbus_enabled;
modbus_port_ = settings.modbus_port;
modbus_max_clients_ = settings.modbus_max_clients;
modbus_timeout_ = settings.modbus_timeout;
analog_enabled_ = settings.analog_enabled;
low_clock_ = settings.low_clock;
hide_led_ = settings.hide_led;
led_type_ = settings.led_type;
board_profile_ = settings.board_profile;
telnet_enabled_ = settings.telnet_enabled;
rx_gpio_ = settings.rx_gpio;
tx_gpio_ = settings.tx_gpio;
dallas_gpio_ = settings.dallas_gpio;
modbus_enabled_ = settings.modbus_enabled;
modbus_port_ = settings.modbus_port;
modbus_max_clients_ = settings.modbus_max_clients;
modbus_timeout_ = settings.modbus_timeout;
syslog_enabled_ = settings.syslog_enabled;
syslog_level_ = settings.syslog_level;
syslog_mark_interval_ = settings.syslog_mark_interval;
syslog_host_ = settings.syslog_host;
syslog_port_ = settings.syslog_port;
tx_mode_ = settings.tx_mode;
syslog_enabled_ = settings.syslog_enabled;
syslog_level_ = settings.syslog_level;
syslog_mark_interval_ = settings.syslog_mark_interval;
syslog_host_ = settings.syslog_host;
syslog_port_ = settings.syslog_port;
fahrenheit_ = settings.fahrenheit;
bool_format_ = settings.bool_format;
bool_dashboard_ = settings.bool_dashboard;
enum_format_ = settings.enum_format;
readonly_mode_ = settings.readonly_mode;
fahrenheit_ = settings.fahrenheit;
bool_format_ = settings.bool_format;
bool_dashboard_ = settings.bool_dashboard;
enum_format_ = settings.enum_format;
readonly_mode_ = settings.readonly_mode;
phy_type_ = settings.phy_type;
eth_power_ = settings.eth_power;
eth_phy_addr_ = settings.eth_phy_addr;
eth_clock_mode_ = settings.eth_clock_mode;
phy_type_ = settings.phy_type;
eth_power_ = settings.eth_power;
eth_phy_addr_ = settings.eth_phy_addr;
eth_clock_mode_ = settings.eth_clock_mode;
locale_ = settings.locale;
developer_mode_ = settings.developer_mode;
});
}
// check for valid ESP32 pins. This is very dependent on which ESP32 board is being used.
// Typically you can't use 1, 6-11, 20, 24, 28-31 and 40+
// we allow 0 as it has a special function on the NodeMCU apparently
// See https://diyprojects.io/esp32-how-to-use-gpio-digital-io-arduino-code/#.YFpVEq9KhjG
// and https://nodemcu.readthedocs.io/en/dev-esp32/modules/gpio/
bool System::is_valid_gpio(uint8_t pin, bool has_psram) {
#if CONFIG_IDF_TARGET_ESP32 || defined(EMSESP_STANDALONE)
if ((pin == 1) || (pin >= 6 && pin <= 11) || (pin == 20) || (pin == 24) || (pin >= 28 && pin <= 31) || (pin > 40)
|| ((EMSESP::system_.PSram() > 0 || has_psram) && pin >= 16 && pin <= 17)) {
#elif CONFIG_IDF_TARGET_ESP32S2
if ((pin >= 19 && pin <= 20) || (pin >= 22 && pin <= 32) || (pin > 40)) {
#elif CONFIG_IDF_TARGET_ESP32C3
// https://www.wemos.cc/en/latest/c3/c3_mini.html
if ((pin >= 11 && pin <= 19) || (pin > 21)) {
#elif CONFIG_IDF_TARGET_ESP32S3
if ((pin >= 19 && pin <= 20) || (pin >= 22 && pin <= 37) || (pin >= 39 && pin <= 42) || (pin > 48)) {
#endif
return false; // bad pin
}
// extra check for pins 21 and 22 (I2C) when ethernet is onboard
if ((EMSESP::system_.ethernet_connected() || EMSESP::system_.phy_type_ != PHY_type::PHY_TYPE_NONE) && (pin >= 21 && pin <= 22)) {
return false; // bad pin
}
return true;
locale_ = settings.locale;
developer_mode_ = settings.developer_mode;
}
// Starts up the UART Serial bridge
@@ -514,12 +488,12 @@ void System::start() {
hostname(networkSettings.hostname.c_str()); // sets the hostname
});
commands_init(); // console & api commands
led_init(false); // init LED
button_init(false); // the special button
network_init(false); // network
EMSESP::uart_init(); // start UART
syslog_init(); // start syslog
commands_init(); // console & api commands
led_init(); // init LED
button_init(); // button
network_init(); // network
uart_init(); // start UART
syslog_init(); // start syslog
}
// button single click
@@ -565,17 +539,8 @@ void System::button_OnVLongPress(PButton & b) {
}
// push button
void System::button_init(bool refresh) {
if (refresh) {
reload_settings();
}
void System::button_init() {
#ifndef EMSESP_STANDALONE
if (!is_valid_gpio(pbutton_gpio_)) {
LOG_WARNING("Invalid button GPIO. Check config.");
myPButton_.init(255, HIGH); // disable
return;
}
if (!myPButton_.init(pbutton_gpio_, HIGH)) {
LOG_WARNING("Multi-functional button not detected");
return;
@@ -590,21 +555,15 @@ void System::button_init(bool refresh) {
}
// set the LED to on or off when in normal operating mode
void System::led_init(bool refresh) {
if (refresh) {
// disabled old led port before setting new one
if ((led_gpio_ != 0) && is_valid_gpio(led_gpio_)) {
void System::led_init() {
// disabled old led port before setting new one
#if ESP_ARDUINO_VERSION_MAJOR < 3
led_type_ ? neopixelWrite(led_gpio_, 0, 0, 0) : digitalWrite(led_gpio_, !LED_ON);
led_type_ ? neopixelWrite(led_gpio_, 0, 0, 0) : digitalWrite(led_gpio_, !LED_ON);
#else
led_type_ ? rgbLedWrite(led_gpio_, 0, 0, 0) : digitalWrite(led_gpio_, !LED_ON);
led_type_ ? rgbLedWrite(led_gpio_, 0, 0, 0) : digitalWrite(led_gpio_, !LED_ON);
#endif
pinMode(led_gpio_, INPUT);
}
reload_settings();
}
if ((led_gpio_ != 0) && is_valid_gpio(led_gpio_)) { // 0 means disabled
if ((led_gpio_)) { // 0 means disabled
if (led_type_) {
// rgb LED WS2812B, use Neopixel
#if ESP_ARDUINO_VERSION_MAJOR < 3
@@ -616,9 +575,20 @@ void System::led_init(bool refresh) {
pinMode(led_gpio_, OUTPUT);
digitalWrite(led_gpio_, !LED_ON); // start with LED off
}
} else {
LOG_INFO("LED disabled");
}
}
void System::uart_init() {
EMSuart::stop();
// start UART, GPIOs have already been checked
EMSuart::start(tx_mode_, rx_gpio_, tx_gpio_);
EMSESP::txservice_.start(); // reset counters and send devices request
}
// checks system health and handles LED flashing wizardry
void System::loop() {
// check if we're supposed to do a reset/restart
@@ -761,11 +731,6 @@ void System::heartbeat_json(JsonObject output) {
// send periodic MQTT message with system information
void System::send_heartbeat() {
// don't send heartbeat if WiFi or MQTT is not connected
if (!Mqtt::connected()) {
return;
}
refreshHeapMem(); // refresh free heap and max alloc heap
JsonDocument doc;
@@ -776,11 +741,7 @@ void System::send_heartbeat() {
}
// initializes network
void System::network_init(bool refresh) {
if (refresh) {
reload_settings();
}
void System::network_init() {
last_system_check_ = 0; // force the LED to go from fast flash to pulse
#if CONFIG_IDF_TARGET_ESP32
@@ -1031,6 +992,11 @@ int8_t System::wifi_quality(int8_t dBm) {
// print users to console
void System::show_users(uuid::console::Shell & shell) {
if (!shell.has_flags(CommandFlags::ADMIN)) {
shell.printfln("Unauthorized. You need to be an admin to view users.");
return;
}
shell.printfln("Users:");
#ifndef EMSESP_STANDALONE
@@ -1104,7 +1070,7 @@ void System::show_system(uuid::console::Shell & shell) {
shell.printfln(" BSSID: %s", WiFi.BSSIDstr().c_str());
shell.printfln(" RSSI: %d dBm (%d %%)", WiFi.RSSI(), wifi_quality(WiFi.RSSI()));
char result[10];
shell.printfln(" TxPower: %s dBm", emsesp::Helpers::render_value(result, (double)(WiFi.getTxPower() / 4), 1));
shell.printfln(" TxPower: %s dBm", Helpers::render_value(result, (double)(WiFi.getTxPower() / 4), 1));
shell.printfln(" MAC address: %s", WiFi.macAddress().c_str());
shell.printfln(" Hostname: %s", WiFi.getHostname());
shell.printfln(" IPv4 address: %s/%s", uuid::printable_to_string(WiFi.localIP()).c_str(), uuid::printable_to_string(WiFi.subnetMask()).c_str());
@@ -1264,7 +1230,7 @@ bool System::check_upgrade(bool factory_settings) {
version::Semver200_version settings_version(settingsVersion);
if (!missing_version) {
LOG_DEBUG("Checking for version upgrades (settings file has v%d.%d.%d-%s)",
LOG_DEBUG("Checking for version upgrades (settings file is v%d.%d.%d-%s)",
settings_version.major(),
settings_version.minor(),
settings_version.patch(),
@@ -1499,6 +1465,16 @@ bool System::get_value_info(JsonObject output, const char * cmd) {
return command_info("", 0, output);
}
// check for metrics
if (!strcmp(cmd, F_(metrics))) {
std::string metrics = get_metrics_prometheus();
if (!metrics.empty()) {
output["api_data"] = metrics;
return true;
}
return false;
}
// fetch all the data from the system in a different json
JsonDocument doc;
JsonObject root = doc.to<JsonObject>();
@@ -1582,6 +1558,233 @@ void System::get_value_json(JsonObject output, const std::string & circuit, cons
}
}
// generate Prometheus metrics format from system values
std::string System::get_metrics_prometheus() {
std::string result;
std::unordered_map<std::string, bool> seen_metrics;
// get system data
JsonDocument doc;
JsonObject root = doc.to<JsonObject>();
(void)command_info("", 0, root);
// helper function to escape Prometheus label values
auto escape_label = [](const std::string & str) -> std::string {
std::string escaped;
for (char c : str) {
if (c == '\\') {
escaped += "\\\\";
} else if (c == '"') {
escaped += "\\\"";
} else if (c == '\n') {
escaped += "\\n";
} else {
escaped += c;
}
}
return escaped;
};
// helper function to sanitize metric name (convert to lowercase and replace dots with underscores)
auto sanitize_name = [](const std::string & name) -> std::string {
std::string sanitized = name;
for (char & c : sanitized) {
if (c == '.') {
c = '_';
} else if (isupper(c)) {
c = tolower(c);
} else if (!isalnum(c) && c != '_') {
c = '_';
}
}
return sanitized;
};
// helper function to convert label name to lowercase
auto to_lowercase = [](const std::string & str) -> std::string {
std::string result = str;
for (char & c : result) {
if (isupper(c)) {
c = tolower(c);
}
}
return result;
};
// helper function to check if a field should be ignored
auto should_ignore = [](const std::string & path, const std::string & key) -> bool {
if (path == "system" && key == "uptime") {
return true;
}
if (path == "ntp" && key == "timestamp") {
return true;
}
if (path.find("devices[") != std::string::npos) {
if (key == "handlersReceived" || key == "handlersFetched" || key == "handlersPending" || key == "handlersIgnored") {
return true;
}
}
return false;
};
// helper function to process a JSON object recursively
std::function<void(const JsonObject &, const std::string &)> process_object =
[&](const JsonObject & obj, const std::string & prefix) {
std::vector<std::pair<std::string, std::string>> local_info_labels;
bool has_nested_objects = false;
for (JsonPair p : obj) {
std::string key = p.key().c_str();
std::string path = prefix.empty() ? key : prefix + "." + key;
std::string metric_name = prefix.empty() ? key : prefix + "_" + key;
if (should_ignore(prefix, key)) {
continue;
}
if (p.value().is<JsonObject>()) {
// recursive call for nested objects
has_nested_objects = true;
process_object(p.value().as<JsonObject>(), metric_name);
} else if (p.value().is<JsonArray>()) {
// handle arrays (devices)
if (key == "devices") {
JsonArray devices = p.value().as<JsonArray>();
for (JsonObject device : devices) {
std::vector<std::pair<std::string, std::string>> device_labels;
// collect labels from device object
for (JsonPair dp : device) {
std::string dkey = dp.key().c_str();
if (dkey == "type" || dkey == "name" || dkey == "deviceID" || dkey == "brand" || dkey == "version") {
if (dp.value().is<const char *>()) {
std::string val = dp.value().as<const char *>();
if (!val.empty()) {
device_labels.push_back({to_lowercase(dkey), val});
}
}
}
}
// create productID metric
if (device.containsKey("productID") && device["productID"].is<int>()) {
std::string metric = "emsesp_device_productid";
if (seen_metrics.find(metric) == seen_metrics.end()) {
result += "# HELP emsesp_device_productid productID\n";
result += "# TYPE emsesp_device_productid gauge\n";
seen_metrics[metric] = true;
}
result += metric;
if (!device_labels.empty()) {
result += "{";
bool first = true;
for (const auto & label : device_labels) {
if (!first) {
result += ", ";
}
result += label.first + "=\"" + escape_label(label.second) + "\"";
first = false;
}
result += "}";
}
result += " " + std::to_string(device["productID"].as<int>()) + "\n";
}
// create entities metric
if (device.containsKey("entities") && device["entities"].is<int>()) {
std::string metric = "emsesp_device_entities";
if (seen_metrics.find(metric) == seen_metrics.end()) {
result += "# HELP emsesp_device_entities entities\n";
result += "# TYPE emsesp_device_entities gauge\n";
seen_metrics[metric] = true;
}
result += metric;
if (!device_labels.empty()) {
result += "{";
bool first = true;
for (const auto & label : device_labels) {
if (!first) {
result += ", ";
}
result += label.first + "=\"" + escape_label(label.second) + "\"";
first = false;
}
result += "}";
}
result += " " + std::to_string(device["entities"].as<int>()) + "\n";
}
}
}
} else {
// handle primitive values
bool is_number = p.value().is<int>() || p.value().is<float>();
bool is_bool = p.value().is<bool>();
bool is_string = p.value().is<const char *>();
if (is_number || is_bool) {
// add metric
std::string full_metric_name = "emsesp_" + sanitize_name(metric_name);
if (seen_metrics.find(full_metric_name) == seen_metrics.end()) {
result += "# HELP emsesp_" + sanitize_name(metric_name) + " " + key + "\n";
result += "# TYPE emsesp_" + sanitize_name(metric_name) + " gauge\n";
seen_metrics[full_metric_name] = true;
}
result += full_metric_name + " ";
if (is_bool) {
result += p.value().as<bool>() ? "1" : "0";
} else if (p.value().is<int>()) {
result += std::to_string(p.value().as<int>());
} else {
char val_str[30];
snprintf(val_str, sizeof(val_str), "%.2f", p.value().as<float>());
result += val_str;
}
result += "\n";
} else if (is_string) {
// collect string for info metric (skip dynamic strings like uptime and timestamp)
std::string val = p.value().as<const char *>();
if (!val.empty() && key != "uptime" && key != "timestamp") {
local_info_labels.push_back({to_lowercase(key), val});
}
}
}
}
// create _info metric for this object level if we have labels and this is a leaf node (no nested objects)
if (!local_info_labels.empty() && !prefix.empty() && !has_nested_objects) {
std::string info_metric = "emsesp_" + sanitize_name(prefix) + "_info";
if (seen_metrics.find(info_metric) == seen_metrics.end()) {
result += "# HELP " + info_metric + " info\n";
result += "# TYPE " + info_metric + " gauge\n";
seen_metrics[info_metric] = true;
}
result += info_metric;
if (!local_info_labels.empty()) {
result += "{";
bool first = true;
for (const auto & label : local_info_labels) {
if (!first) {
result += ", ";
}
result += label.first + "=\"" + escape_label(label.second) + "\"";
first = false;
}
result += "}";
}
result += " 1\n";
}
};
// process root object
process_object(root, "");
return result;
}
// export status information including the device information
// http://ems-esp/api/system/info
bool System::command_info(const char * value, const int8_t id, JsonObject output) {
@@ -1823,11 +2026,12 @@ bool System::command_info(const char * value, const int8_t id, JsonObject output
node["ethPhyAddr"] = settings.eth_phy_addr;
node["ethClockMmode"] = settings.eth_clock_mode;
}
node["rxGPIO"] = settings.rx_gpio;
node["txGPIO"] = settings.tx_gpio;
node["dallasGPIO"] = settings.dallas_gpio;
node["pbuttonGPIO"] = settings.pbutton_gpio;
node["ledGPIO"] = settings.led_gpio;
node["rxGPIO"] = EMSESP::system_.rx_gpio_;
node["txGPIO"] = EMSESP::system_.tx_gpio_;
node["dallasGPIO"] = EMSESP::system_.dallas_gpio_;
node["pbuttonGPIO"] = EMSESP::system_.pbutton_gpio_;
node["ledGPIO"] = EMSESP::system_.led_gpio_;
node["ledType"] = settings.led_type;
node["ledType"] = settings.led_type;
}
node["hideLed"] = settings.hide_led;
@@ -1933,15 +2137,12 @@ bool System::command_test(const char * value, const int8_t id) {
// takes a board profile and populates a data array with GPIO configurations
// returns false if profile is unknown
//
// data = led, dallas, rx, tx, button, phy_type, eth_power, eth_phy_addr, eth_clock_mode, led_type
// 0=led, 1=dallas, 2=rx, 3=tx, 4=button, 5=phy_type, 6=eth_power, 7=eth_phy_addr, 8=eth_clock_mode, 9=led_type
//
// clock modes:
// 0 = RMII clock input to GPIO0
// 1 = RMII clock output from GPIO0
// 2 = RMII clock output from GPIO16
// 3 = RMII clock output from GPIO17, for 50hz inverted clock
bool System::load_board_profile(std::vector<int8_t> & data, const std::string & board_profile) {
if (board_profile == "S32") {
if (board_profile == "default") {
return false; // unknown, return false
} else if (board_profile == "S32") {
data = {2, 18, 23, 5, 0, PHY_type::PHY_TYPE_NONE, 0, 0, 0, 0}; // BBQKees Gateway S32
} else if (board_profile == "E32") {
data = {2, 4, 5, 17, 33, PHY_type::PHY_TYPE_LAN8720, 16, 1, 0, 0}; // BBQKees Gateway E32
@@ -1971,24 +2172,10 @@ bool System::load_board_profile(std::vector<int8_t> & data, const std::string &
data = {17, 18, 8, 5, 0, PHY_type::PHY_TYPE_NONE, 0, 0, 0, 0}; // Liligo S3
} else if (board_profile == "S32S3") {
data = {2, 18, 5, 17, 0, PHY_type::PHY_TYPE_NONE, 0, 0, 0, 0}; // BBQKees Gateway S3
} else if (board_profile == "CUSTOM") {
// send back current values
data = {(int8_t)EMSESP::system_.led_gpio_,
(int8_t)EMSESP::system_.dallas_gpio_,
(int8_t)EMSESP::system_.rx_gpio_,
(int8_t)EMSESP::system_.tx_gpio_,
(int8_t)EMSESP::system_.pbutton_gpio_,
(int8_t)EMSESP::system_.phy_type_,
EMSESP::system_.eth_power_,
(int8_t)EMSESP::system_.eth_phy_addr_,
(int8_t)EMSESP::system_.eth_clock_mode_,
(int8_t)EMSESP::system_.led_type_};
} else {
LOG_DEBUG("Couldn't identify board profile %s", board_profile.c_str());
return false; // unknown, return false
}
// LOG_DEBUG("Found data for board profile %s", board_profile.c_str());
return true;
}
@@ -2218,7 +2405,6 @@ bool System::uploadFirmwareURL(const char * url) {
// we're about to start the upload, set the status so the Web System Monitor spots it
EMSESP::system_.systemStatus(SYSTEM_STATUS::SYSTEM_STATUS_UPLOADING);
// TODO do we need to stop the UART first with EMSuart::stop() ?
// set a callback so we can monitor progress in the WebUI
Update.onProgress([](size_t progress, size_t total) { EMSESP::system_.systemStatus(SYSTEM_STATUS::SYSTEM_STATUS_UPLOADING + (progress * 100 / total)); });
@@ -2313,11 +2499,136 @@ bool System::command_read(const char * value, const int8_t id) {
// set the system status code - SYSTEM_STATUS in system.h
void System::systemStatus(uint8_t status_code) {
systemStatus_ = status_code;
// LOG_DEBUG("Setting System status code %d", status_code);
LOG_DEBUG("Setting System status code %d", status_code);
}
uint8_t System::systemStatus() {
return systemStatus_;
}
// takes a string range like "6-11, 1, 23, 24-48" which has optional ranges and single values and converts to a vector of ints
std::vector<uint8_t> System::string_range_to_vector(const std::string & range) {
std::vector<uint8_t> gpios;
std::string::size_type pos = 0;
std::string::size_type prev = 0;
auto process_part = [&gpios](std::string part) {
// trim whitespace
part.erase(0, part.find_first_not_of(" \t"));
part.erase(part.find_last_not_of(" \t") + 1);
// check if it's a range (contains '-')
std::string::size_type dash_pos = part.find('-');
if (dash_pos != std::string::npos) {
// it's a range like "6-11"
int start = std::stoi(part.substr(0, dash_pos));
int end = std::stoi(part.substr(dash_pos + 1));
for (int i = start; i <= end; i++) {
gpios.push_back(static_cast<uint8_t>(i));
}
} else {
gpios.push_back(static_cast<uint8_t>(std::stoi(part)));
}
};
while ((pos = range.find(',', prev)) != std::string::npos) {
process_part(range.substr(prev, pos - prev));
prev = pos + 1;
}
// handle the last part
process_part(range.substr(prev));
return gpios;
}
// initialize a list of valid GPIOs based on the ESP32 board
// note: we always allow 0, which is used to indicate Dallas or LED is disabled
void System::set_valid_system_gpios() {
valid_system_gpios_.clear(); // reset system list
used_gpios_.clear(); // reset used list
// get free gpios based on board/platform type
#if CONFIG_IDF_TARGET_ESP32C3
// https://www.wemos.cc/en/latest/c3/c3_mini.html
valid_system_gpios_ = string_range_to_vector("0-10"); // UART0=20,21
#elif CONFIG_IDF_TARGET_ESP32S2
// 43 and 44 are UART0 pins
// 38 and 39 are strapping pins, input only
valid_system_gpios_ = string_range_to_vector("0-14, 19, 20, 21, 33-37, 45, 46");
#elif CONFIG_IDF_TARGET_ESP32S3
// 43 and 44 are UART0 pins
// 33-37 for Octal SPI (SPIIO4 through SPIIO7 and SPIDQS)
// 38 and 39 are input only
// 45 and 36 are strapping pins, input only
valid_system_gpios_ = string_range_to_vector("0-14, 17, 18, 21, 33-39, 45, 46");
#elif CONFIG_IDF_TARGET_ESP32 || defined(EMSESP_STANDALONE)
// 1 and 3 are UART0 pins
// 32-39 is ADC1, input only
valid_system_gpios_ = string_range_to_vector("0, 2, 4, 5, 12-19, 23, 25-27, 32-39");
#else
#endif
// if psram is enabled remove pins 16 and 17 from the list, if set
#if CONFIG_IDF_TARGET_ESP32
if (ESP.getPsramSize() > 0) {
valid_system_gpios_.erase(std::remove(valid_system_gpios_.begin(), valid_system_gpios_.end(), 16), valid_system_gpios_.end());
valid_system_gpios_.erase(std::remove(valid_system_gpios_.begin(), valid_system_gpios_.end(), 17), valid_system_gpios_.end());
}
#endif
}
// check if a pin is valid ESP32 pin and if not already used, add to the used gpio list
// return false if not allowed or already used
bool System::add_gpio(uint8_t pin, const char * source_name) {
// check if this is a valid user GPIO
if (std::find(valid_system_gpios_.begin(), valid_system_gpios_.end(), pin) != valid_system_gpios_.end()) {
// It's valid now check if it's already in the used list
if (std::find(used_gpios_.begin(), used_gpios_.end(), pin) != used_gpios_.end()) {
LOG_WARNING("GPIO %d for %s is already in use", pin, source_name);
return false; // Pin is already used
}
} else {
// not valid
LOG_WARNING("GPIO %d for %s is not valid", pin, source_name);
return false;
}
// remove the old pin, if exists from used list
remove_gpio(pin);
LOG_DEBUG("Adding GPIO %d for %s to used gpio list", pin, source_name);
used_gpios_.push_back(pin); // add to used list
return true;
}
// remove a gpio from both valid and used lists
void System::remove_gpio(uint8_t pin, bool also_system) {
auto it = std::find(used_gpios_.begin(), used_gpios_.end(), pin);
if (it != used_gpios_.end()) {
LOG_DEBUG("GPIO %d removed from used gpio list", pin);
used_gpios_.erase(it);
}
if (also_system) {
it = std::find(valid_system_gpios_.begin(), valid_system_gpios_.end(), pin);
if (it != valid_system_gpios_.end()) {
LOG_DEBUG("GPIO %d removed from valid gpio list", pin);
valid_system_gpios_.erase(it);
}
}
}
// return a list of GPIO's available for use
std::vector<uint8_t> System::available_gpios() {
std::vector<uint8_t> gpios;
for (const auto & gpio : valid_system_gpios_) {
if (std::find(used_gpios_.begin(), used_gpios_.end(), gpio) == used_gpios_.end()) {
gpios.push_back(gpio); // didn't find it in used_gpios_, so it's available
}
}
return gpios;
}
} // namespace emsesp

View File

@@ -1,6 +1,6 @@
/*
* EMS-ESP - https://github.com/emsesp/EMS-ESP
* Copyright 2020-2024 emsesp.org - proddy, MichaelDvP
* Copyright 2020-2025 emsesp.org - proddy, MichaelDvP
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -67,7 +67,8 @@ enum SYSTEM_STATUS : uint8_t {
SYSTEM_STATUS_UPLOADING = 100,
SYSTEM_STATUS_ERROR_UPLOAD = 3,
SYSTEM_STATUS_PENDING_RESTART = 4,
SYSTEM_STATUS_RESTART_REQUESTED = 5
SYSTEM_STATUS_RESTART_REQUESTED = 5,
SYSTEM_STATUS_INVALID_GPIO = 6
};
enum FUSE_VALUE : uint8_t { ALL = 0, MFG = 1, MODEL = 2, BOARD = 3, REV = 4, BATCH = 5, FUSE = 6 };
@@ -92,6 +93,7 @@ class System {
static bool get_value_info(JsonObject root, const char * cmd);
static void get_value_json(JsonObject output, const std::string & circuit, const std::string & name, JsonVariant val);
static std::string get_metrics_prometheus();
#if defined(EMSESP_TEST)
static bool command_test(const char * value, const int8_t id);
@@ -103,11 +105,12 @@ class System {
void system_restart(const char * partition = nullptr);
void show_mem(const char * note);
void reload_settings();
void store_settings(class WebSettings & settings);
void syslog_init();
bool check_upgrade(bool factory_settings);
bool check_restore();
void heartbeat_json(JsonObject output);
void send_heartbeat();
void send_info_mqtt();
@@ -129,10 +132,11 @@ class System {
static bool uploadFirmwareURL(const char * url = nullptr);
void led_init(bool refresh);
void network_init(bool refresh);
void button_init(bool refresh);
void led_init();
void network_init();
void button_init();
void commands_init();
void uart_init();
void systemStatus(uint8_t status_code);
uint8_t systemStatus();
@@ -140,11 +144,16 @@ class System {
static void extractSettings(const char * filename, const char * section, JsonObject output);
static bool saveSettings(const char * filename, const char * section, JsonObject input);
static bool is_valid_gpio(uint8_t pin, bool has_psram = false);
static bool load_board_profile(std::vector<int8_t> & data, const std::string & board_profile);
static bool add_gpio(uint8_t pin, const char * source_name);
static std::vector<uint8_t> available_gpios();
static bool load_board_profile(std::vector<int8_t> & data, const std::string & board_profile);
static bool readCommand(const char * data);
void dallas_gpio(uint8_t gpio) {
dallas_gpio_ = gpio;
}
bool telnet_enabled() {
return telnet_enabled_;
}
@@ -303,6 +312,7 @@ class System {
uint32_t PSram() {
return psram_;
}
uint32_t appFree() {
return appfree_;
}
@@ -336,12 +346,16 @@ class System {
test_set_all_active_ = n;
}
static void set_valid_system_gpios();
#if CONFIG_IDF_TARGET_ESP32S3 || CONFIG_IDF_TARGET_ESP32C3 || CONFIG_IDF_TARGET_ESP32S2
float temperature() {
return temperature_;
}
#endif
static void remove_gpio(uint8_t pin, bool also_system = false); // remove a gpio from both valid (optional) and used lists
private:
static uuid::log::Logger logger_;
@@ -381,6 +395,11 @@ class System {
void led_monitor();
void system_check();
static std::vector<uint8_t> string_range_to_vector(const std::string & range);
static std::vector<uint8_t> valid_system_gpios_; // list of valid GPIOs for the ESP32 board that can be used
static std::vector<uint8_t> used_gpios_; // list of GPIOs used by the application
int8_t wifi_quality(int8_t dBm);
uint8_t healthcheck_ = HEALTHCHECK_NO_NETWORK | HEALTHCHECK_NO_BUS; // start with all flags set, no wifi and no ems bus connection
@@ -396,7 +415,6 @@ class System {
bool eth_present_ = false;
// EMS-ESP settings
// copies from WebSettings class in WebSettingsService.h and loaded with reload_settings()
std::string hostname_;
String locale_;
bool hide_led_;
@@ -408,6 +426,7 @@ class System {
uint8_t pbutton_gpio_;
uint8_t rx_gpio_;
uint8_t tx_gpio_;
uint8_t tx_mode_;
uint8_t dallas_gpio_;
bool telnet_enabled_;
bool syslog_enabled_;

View File

@@ -1,6 +1,6 @@
/*
* EMS-ESP - https://github.com/emsesp/EMS-ESP
* Copyright 2020-2024 emsesp.org - proddy, MichaelDvP
* Copyright 2020-2025 emsesp.org - proddy, MichaelDvP
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -274,7 +274,7 @@ void TxService::send_poll() const {
}
}
// get src id from next telegram to check poll in emsesp::incoming_telegram
// get src id from next telegram to check poll in incoming_telegram() in emsesp.cpp
uint8_t TxService::get_send_id() {
static uint32_t count = 0;
if (!tx_telegrams_.empty() && tx_telegrams_.front().telegram_->src != ems_bus_id()) {

View File

@@ -1,6 +1,6 @@
/*
* EMS-ESP - https://github.com/emsesp/EMS-ESP
* Copyright 2020-2024 emsesp.org - proddy, MichaelDvP
* Copyright 2020-2025 emsesp.org - proddy, MichaelDvP
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by

View File

@@ -1,6 +1,6 @@
/*
* EMS-ESP - https://github.com/emsesp/EMS-ESP
* Copyright 2020-2024 emsesp.org - proddy, MichaelDvP
* Copyright 2020-2025 emsesp.org - proddy, MichaelDvP
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -55,10 +55,12 @@ void TemperatureSensor::start(const bool factory_settings) {
// load settings
void TemperatureSensor::reload() {
// load the service settings
EMSESP::system_.dallas_gpio(0); // reset in system to check valid sensor
EMSESP::webSettingsService.read([&](WebSettings const & settings) {
dallas_gpio_ = settings.dallas_gpio;
parasite_ = settings.dallas_parasite;
});
EMSESP::system_.dallas_gpio(dallas_gpio_); // set to system for checks
for (auto & sensor : sensors_) {
remove_ha_topic(sensor.id());
@@ -505,11 +507,12 @@ void TemperatureSensor::publish_values(const bool force) {
LOG_DEBUG("Recreating HA config for sensor ID %s", sensor.id().c_str());
JsonDocument config;
config["~"] = Mqtt::base();
config["dev_cla"] = "temperature";
config["stat_cla"] = "measurement";
char stat_t[50];
snprintf(stat_t, sizeof(stat_t), "%s/%s_data", Mqtt::base().c_str(), F_(temperaturesensor)); // use base path
snprintf(stat_t, sizeof(stat_t), "~/%s_data", F_(temperaturesensor)); // use base path
config["stat_t"] = stat_t;
config["unit_of_meas"] = EMSdevice::uom_to_string(DeviceValueUOM::DEGREES);

View File

@@ -1,6 +1,6 @@
/*
* EMS-ESP - https://github.com/emsesp/EMS-ESP
* Copyright 2020-2024 emsesp.org - proddy, MichaelDvP
* Copyright 2020-2025 emsesp.org - proddy, MichaelDvP
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -115,9 +115,10 @@ class TemperatureSensor {
}
size_t count_entities(bool exclude_disabled_system = false) const {
return std::count_if(sensors_.begin(), sensors_.end(), [exclude_disabled_system](const Sensor & sensor) {
return exclude_disabled_system ? !sensor.is_system() : sensor.is_system();
});
if (exclude_disabled_system) {
return std::count_if(sensors_.begin(), sensors_.end(), [](const Sensor & sensor) { return !sensor.is_system(); });
}
return sensors_.size();
}
bool update(const std::string & id, const std::string & name, int16_t offset, bool is_system);

View File

@@ -1,6 +1,6 @@
/*
* EMS-ESP - https://github.com/emsesp/EMS-ESP
* Copyright 2020-2024 emsesp.org - proddy, MichaelDvP
* Copyright 2020-2025 emsesp.org - proddy, MichaelDvP
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by

View File

@@ -1,6 +1,6 @@
/*
* EMS-ESP - https://github.com/emsesp/EMS-ESP
* Copyright 2020-2024 emsesp.org - proddy, MichaelDvP
* Copyright 2020-2025 emsesp.org - proddy, MichaelDvP
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by

View File

@@ -1,6 +1,6 @@
/*
* EMS-ESP - https://github.com/emsesp/EMS-ESP
* Copyright 2020-2024 emsesp.org - proddy, MichaelDvP
* Copyright 2020-2025 emsesp.org - proddy, MichaelDvP
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -964,17 +964,17 @@ Boiler::Boiler(uint8_t device_type, int8_t device_id, uint8_t product_id, const
48,
63);
register_device_value(
DeviceValueTAG::TAG_DHW1, &wwComfDiffTemp_, DeviceValueType::UINT8, FL_(wwComfDiffTemp), DeviceValueUOM::K, MAKE_CF_CB(set_wwComfDiffTemp), 4, 12);
DeviceValueTAG::TAG_DHW1, &wwComfDiffTemp_, DeviceValueType::UINT8, FL_(wwComfDiffTemp), DeviceValueUOM::K, MAKE_CF_CB(set_wwComfDiffTemp), 4, 15);
register_device_value(
DeviceValueTAG::TAG_DHW1, &wwEcoDiffTemp_, DeviceValueType::UINT8, FL_(wwEcoDiffTemp), DeviceValueUOM::K, MAKE_CF_CB(set_wwEcoDiffTemp), 4, 12);
DeviceValueTAG::TAG_DHW1, &wwEcoDiffTemp_, DeviceValueType::UINT8, FL_(wwEcoDiffTemp), DeviceValueUOM::K, MAKE_CF_CB(set_wwEcoDiffTemp), 4, 15);
register_device_value(DeviceValueTAG::TAG_DHW1,
&wwEcoPlusDiffTemp_,
DeviceValueType::UINT8,
FL_(wwEcoPlusDiffTemp),
DeviceValueUOM::K,
MAKE_CF_CB(set_wwEcoPlusDiffTemp),
6,
12);
4,
15);
register_device_value(DeviceValueTAG::TAG_DHW1,
&wwComfStopTemp_,
DeviceValueType::UINT8,

View File

@@ -1,6 +1,6 @@
/*
* EMS-ESP - https://github.com/emsesp/EMS-ESP
* Copyright 2020-2024 emsesp.org - proddy, MichaelDvP
* Copyright 2020-2025 emsesp.org - proddy, MichaelDvP
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -100,7 +100,7 @@ class Boiler : public EMSdevice {
uint8_t wwAlternatingOper_; // alternating operation on/off
uint8_t wwAltOpPrioHeat_; // alternating operation, prioritize heat time
uint8_t wwAltOpPrioWw_; // alternating operation, prioritize dhw time
uint8_t wwPrio_;
uint8_t wwPrio_;
// special function
uint8_t forceHeatingOff_;

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