diff --git a/CHANGELOG_LATEST.md b/CHANGELOG_LATEST.md
index b483725d2..da70841b8 100644
--- a/CHANGELOG_LATEST.md
+++ b/CHANGELOG_LATEST.md
@@ -8,6 +8,7 @@ For more details go to [emsesp.org](https://emsesp.org/).
- user-requested LED blink [#3063](https://github.com/emsesp/EMS-ESP32/issues/3063)
- KM300 at address 0x4A [#3084](https://github.com/emsesp/EMS-ESP32/issues/3084)
+- Commands Service that can be called via MQTT or API or used in the Scheduler Service
## Fixed
diff --git a/interface/package.json b/interface/package.json
index 9f9a491a2..30c8db7a8 100644
--- a/interface/package.json
+++ b/interface/package.json
@@ -19,15 +19,15 @@
"typesafe-i18n": "typesafe-i18n --no-watch",
"build-webUI": "typesafe-i18n --no-watch && vite build && node progmem-generator.js",
"format": "prettier -l -w '**/*.{ts,tsx,js,css,json,md}'",
- "lint": "eslint . --fix",
+ "lint": "typesafe-i18n --no-watch && eslint . --fix",
"standalone-devcontainer": "concurrently -c \"auto\" \"typesafe-i18n\" \"pnpm:mock-rest\" \"vite --host\""
},
"dependencies": {
"@alova/adapter-xhr": "2.3.1",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
- "@mui/icons-material": "^9.0.1",
- "@mui/material": "^9.0.1",
+ "@mui/icons-material": "^9.1.1",
+ "@mui/material": "^9.1.1",
"@table-library/react-table-library": "4.1.15",
"alova": "^3.5.1",
"async-validator": "^4.2.5",
@@ -47,18 +47,18 @@
"@eslint/js": "^10.0.1",
"@preact/preset-vite": "^2.10.5",
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
- "@types/node": "^25.9.2",
+ "@types/node": "^25.9.3",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"concurrently": "^10.0.3",
"eslint": "^10.4.1",
"eslint-config-prettier": "^10.1.8",
- "prettier": "^3.8.3",
+ "prettier": "^3.8.4",
"rollup-plugin-visualizer": "^7.0.1",
"terser": "^5.48.0",
- "typescript-eslint": "^8.60.1",
+ "typescript-eslint": "^8.61.0",
"vite": "^8.0.16",
"vite-plugin-imagemin": "^0.6.1"
},
- "packageManager": "pnpm@11.5.2+sha512.71c631e382066efc25625d5cf029075de07b61b37f6e27350fbd84b1bda5864c8c1967adc280776b45c30a715c0359a3be08fef42d5bb09e2b99029979692916"
+ "packageManager": "pnpm@11.6.0+sha512.9a36518224080c6fe5165afdcfe79bfa118c29be703f3f462b1e32efe1e98e47e8750b148e08286250aad4113cc7993ca413c4e2cd447752708c2ee5751bc95f"
}
diff --git a/interface/pnpm-lock.yaml b/interface/pnpm-lock.yaml
index f2d996746..f682f2f83 100644
--- a/interface/pnpm-lock.yaml
+++ b/interface/pnpm-lock.yaml
@@ -18,11 +18,11 @@ importers:
specifier: ^11.14.1
version: 11.14.1(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(react@19.2.7)
'@mui/icons-material':
- specifier: ^9.0.1
- version: 9.0.1(@mui/material@9.0.1(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@types/react@19.2.17)(react@19.2.7)
+ specifier: ^9.1.1
+ version: 9.1.1(@mui/material@9.1.1(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@types/react@19.2.17)(react@19.2.7)
'@mui/material':
- specifier: ^9.0.1
- version: 9.0.1(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+ specifier: ^9.1.1
+ version: 9.1.1(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@table-library/react-table-library':
specifier: 4.1.15
version: 4.1.15(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -71,13 +71,13 @@ importers:
version: 10.0.1(eslint@10.4.1)
'@preact/preset-vite':
specifier: ^2.10.5
- version: 2.10.5(@babel/core@7.29.7)(preact@10.29.2)(vite@8.0.16(@types/node@25.9.2)(terser@5.48.0))
+ version: 2.10.5(@babel/core@7.29.7)(preact@10.29.2)(vite@8.0.16(@types/node@25.9.3)(terser@5.48.0))
'@trivago/prettier-plugin-sort-imports':
specifier: ^6.0.2
- version: 6.0.2(prettier@3.8.3)
+ version: 6.0.2(prettier@3.8.4)
'@types/node':
- specifier: ^25.9.2
- version: 25.9.2
+ specifier: ^25.9.3
+ version: 25.9.3
'@types/react':
specifier: ^19.2.17
version: 19.2.17
@@ -94,8 +94,8 @@ importers:
specifier: ^10.1.8
version: 10.1.8(eslint@10.4.1)
prettier:
- specifier: ^3.8.3
- version: 3.8.3
+ specifier: ^3.8.4
+ version: 3.8.4
rollup-plugin-visualizer:
specifier: ^7.0.1
version: 7.0.1(rolldown@1.0.3)
@@ -103,14 +103,14 @@ importers:
specifier: ^5.48.0
version: 5.48.0
typescript-eslint:
- specifier: ^8.60.1
- version: 8.60.1(eslint@10.4.1)(typescript@6.0.3)
+ specifier: ^8.61.0
+ version: 8.61.0(eslint@10.4.1)(typescript@6.0.3)
vite:
specifier: ^8.0.16
- version: 8.0.16(@types/node@25.9.2)(terser@5.48.0)
+ version: 8.0.16(@types/node@25.9.3)(terser@5.48.0)
vite-plugin-imagemin:
specifier: ^0.6.1
- version: 0.6.1(vite@8.0.16(@types/node@25.9.2)(terser@5.48.0))
+ version: 0.6.1(vite@8.0.16(@types/node@25.9.3)(terser@5.48.0))
packages:
@@ -366,27 +366,27 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
- '@mui/core-downloads-tracker@9.0.1':
- resolution: {integrity: sha512-GzamIIhZ1bH77dq7eKaeyRgJdkypsxin4jBFq2EMs4lBWRR0LFO1CSVMsoebn/VvjcNrnrOrjy48MkrkQUK2iw==}
+ '@mui/core-downloads-tracker@9.1.1':
+ resolution: {integrity: sha512-AupmMICbdJHqAh6FfOMaaiiIr7dfEgZJn5DFfiPuGNrbs+ZZy9cD1APwO0TSVBz5j08MJEEY6n7iC76/2wjMEA==}
- '@mui/icons-material@9.0.1':
- resolution: {integrity: sha512-5PRpQjVLTNLyV/2J9J53Yz4R0tVbodG0BQDN2zQI1QBG1OPYM25ar+4N20eyFOfJT6zKglLzsnU70+zdVLaTkw==}
+ '@mui/icons-material@9.1.1':
+ resolution: {integrity: sha512-OXhm9DajemStb58AumM06DuPhHTa3XD36TFD4yf6WtJyNRO5DfEZbbnHlBg/US2Y2oOXwM/XurMTBOD6L/YYZw==}
engines: {node: '>=14.0.0'}
peerDependencies:
- '@mui/material': ^9.0.1
+ '@mui/material': ^9.1.1
'@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0
react: ^17.0.0 || ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@types/react':
optional: true
- '@mui/material@9.0.1':
- resolution: {integrity: sha512-voyCpeUxcSWLN7KPZuq0pGCIt726T9K6kiVM3XUcywZDAlZSarLHaUxJVQpospbjjOzN53hwyjo8s6KoWl6utw==}
+ '@mui/material@9.1.1':
+ resolution: {integrity: sha512-Wv+gInjrpf99l1Q0oHe0eOWGTnlbkzs5nowClX65KCT/2fyPMwcbFEEkUsOHdpcHhB5UAbz/d7jlwt5ajWVvlA==}
engines: {node: '>=14.0.0'}
peerDependencies:
'@emotion/react': ^11.5.0
'@emotion/styled': ^11.3.0
- '@mui/material-pigment-css': ^9.0.1
+ '@mui/material-pigment-css': ^9.1.1
'@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0
react: ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0
@@ -400,8 +400,8 @@ packages:
'@types/react':
optional: true
- '@mui/private-theming@9.0.1':
- resolution: {integrity: sha512-pSIGq4Yw749KHEwlkYZWVERgHgwJELP6ODtBNUfV8V4oIb5H+h7IQDFXuk/b2oQccODK1enJAtiEzlgLZmq+8g==}
+ '@mui/private-theming@9.1.1':
+ resolution: {integrity: sha512-oH6c+d6sJ1CZT0Vg2/fHdUQ5zvo9Pn+f+WWk0tlQliHqqIRdN32DZ7UxjalW3LUj4OkHbdWR31biWuLxK9i7Cg==}
engines: {node: '>=14.0.0'}
peerDependencies:
'@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0
@@ -410,8 +410,8 @@ packages:
'@types/react':
optional: true
- '@mui/styled-engine@9.0.0':
- resolution: {integrity: sha512-9RLGdX4Jg0aQPRuvqh/OLzYSPlgd5zyEw5/1HIRfdavSiOd03WtUaGZH9/w1RoTYuRKwpgy0hpIFaMHIqPVIWg==}
+ '@mui/styled-engine@9.1.1':
+ resolution: {integrity: sha512-neaYKdJfvEG54q8efHLJR7swpHG/gfSv9xGqW5iTSMsubD7yPCPFrhVBt284j1DOF3uZaaDJSHQL7gz6jGF21Q==}
engines: {node: '>=14.0.0'}
peerDependencies:
'@emotion/react': ^11.4.1
@@ -423,8 +423,8 @@ packages:
'@emotion/styled':
optional: true
- '@mui/system@9.0.1':
- resolution: {integrity: sha512-WvlioaLxk6ewUIOfh0StxUvOPDS1mCfzaulcudsL1brZNXuh0N9FMk7RpH7ImJKjEz412SEy/V/yvqmtxbqxCQ==}
+ '@mui/system@9.1.1':
+ resolution: {integrity: sha512-q+aqNa58QZUwmmyUvJKKrStrej+4BcWFw4M0Ug+zRylPIQgR64cqvBnE3QTfLZm4OXulydp8Hl3zwKxMayrdsA==}
engines: {node: '>=14.0.0'}
peerDependencies:
'@emotion/react': ^11.5.0
@@ -439,16 +439,16 @@ packages:
'@types/react':
optional: true
- '@mui/types@9.0.0':
- resolution: {integrity: sha512-i1cuFCAWN44b3AJWO7mh7tuh1sqbQSeVr/94oG0TX5uXivac8XalgE4/6fQZcmGZigzbQ35IXxj/4jLpRIBYZg==}
+ '@mui/types@9.1.1':
+ resolution: {integrity: sha512-Zjt7u8wNvDg40rPTGoL+TnfkpuSKjwubsNSFRH1KAVZLcaV4I3AFNHIFbvH7p4F3alEibSbdd90xAgn5Rnfndg==}
peerDependencies:
'@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@types/react':
optional: true
- '@mui/utils@9.0.1':
- resolution: {integrity: sha512-f3UO3jNN1pYg5zxqXC81Bvv8hx5ACcYc0387382ZI7M5ono1heIwHYLrKsz85myguWdeVKPRZGmDdynWUBjK2g==}
+ '@mui/utils@9.1.1':
+ resolution: {integrity: sha512-qSNfnkzZMptaaWFFklpDf4NPJztgwsMDVfM/sSDt+wq4ssYSBhLYwwjuB6eS/+p2IUYbeRzHluzXbw0Zn7aI4A==}
engines: {node: '>=14.0.0'}
peerDependencies:
'@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0
@@ -457,8 +457,8 @@ packages:
'@types/react':
optional: true
- '@napi-rs/wasm-runtime@1.1.4':
- resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==}
+ '@napi-rs/wasm-runtime@1.1.5':
+ resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==}
peerDependencies:
'@emnapi/core': ^1.7.1
'@emnapi/runtime': ^1.7.1
@@ -688,8 +688,8 @@ packages:
resolution: {integrity: sha512-zmPitbQ8+6zNutpwgcQuLcsEpn/Cj54Kbn7L5pX0Os5kdWplB7xPgEh/g+SWOB/qmows2gpuCaPyduq8ZZRnxA==}
deprecated: This is a stub types definition. minimatch provides its own type definitions, so you do not need this installed.
- '@types/node@25.9.2':
- resolution: {integrity: sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==}
+ '@types/node@25.9.3':
+ resolution: {integrity: sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==}
'@types/parse-json@4.0.2':
resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==}
@@ -716,63 +716,63 @@ packages:
'@types/svgo@2.6.4':
resolution: {integrity: sha512-l4cmyPEckf8moNYHdJ+4wkHvFxjyW6ulm9l4YGaOxeyBWPhBOT0gvni1InpFPdzx1dKf/2s62qGITwxNWnPQng==}
- '@typescript-eslint/eslint-plugin@8.60.1':
- resolution: {integrity: sha512-JQ4S5GB0tfjO8BuJ4fcX+HodkzJjYBV+7OJ+wLygaX7OGQ7FudyHL4NSCA6ob+w3Yn+5MkKIozOwQhXeM7opVg==}
+ '@typescript-eslint/eslint-plugin@8.61.0':
+ resolution: {integrity: sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
- '@typescript-eslint/parser': ^8.60.1
+ '@typescript-eslint/parser': ^8.61.0
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
- '@typescript-eslint/parser@8.60.1':
- resolution: {integrity: sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA==}
+ '@typescript-eslint/parser@8.61.0':
+ resolution: {integrity: sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
- '@typescript-eslint/project-service@8.60.1':
- resolution: {integrity: sha512-eXkTH2bxmXlqD1RnOPmLZ9ZM9D3VwSx04JOwBnP9RQ+yUA5a2Mu7SfW8uaV2Aon53NJzZlZYuX7tn91Izf+xaw==}
+ '@typescript-eslint/project-service@8.61.0':
+ resolution: {integrity: sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.1.0'
- '@typescript-eslint/scope-manager@8.60.1':
- resolution: {integrity: sha512-gvI5OQoptnxQnchOirukCuQ55svJSTuD/4k5+pC267xyBtYry748R9/c3tYUzb/iE6RZfllRz2lVulLCHkTm4w==}
+ '@typescript-eslint/scope-manager@8.61.0':
+ resolution: {integrity: sha512-IWdXFHFSb6mlC3HPc7QsLDm5zYEbUla6trDEHf32D3/dnuUyXd87plScSNXSbm0/RxMvObpI17sv/EDTGrGZkA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
- '@typescript-eslint/tsconfig-utils@8.60.1':
- resolution: {integrity: sha512-nh8w4qAteiKuZu3pSSzG/yGKpw0OlkrKnzFmbVRenKaD4qc+7i1GrmZaLVkr8rk4uipiPGMOW4YsM6WmKZ5CvA==}
+ '@typescript-eslint/tsconfig-utils@8.61.0':
+ resolution: {integrity: sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.1.0'
- '@typescript-eslint/type-utils@8.60.1':
- resolution: {integrity: sha512-sdwTrpjosW7ANQYJ39ZBF1ZyEMEGVB2UsikrserVM/30a/F1dTLnu9bGxEdosugyu5caigjLrR2qiD11asjI1A==}
+ '@typescript-eslint/type-utils@8.61.0':
+ resolution: {integrity: sha512-TuBiQYIkd97yBfInHCTKVYMbX4kvEmpOEuixIuzCU9p8BGT1SfyyO0d0IfDMbPIHcjn/hWnusUX5e8v5Xg+X8A==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
- '@typescript-eslint/types@8.60.1':
- resolution: {integrity: sha512-4h0tY8ppCkdCzcrl2YM5M3my0xsE1Tf8om3owEu5oPWmXwkKRmk0j0LGDzYBGUcAlesEbxBhazqu/K4cu3Ug7w==}
+ '@typescript-eslint/types@8.61.0':
+ resolution: {integrity: sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
- '@typescript-eslint/typescript-estree@8.60.1':
- resolution: {integrity: sha512-alpRkfG8hlVE5kdJW2GkfgDgXxold3e8e4l6EnmhRmRLbekgAPCCGDVD++sABy9FcgPFroq+uFcCSM1vR57Cew==}
+ '@typescript-eslint/typescript-estree@8.61.0':
+ resolution: {integrity: sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.1.0'
- '@typescript-eslint/utils@8.60.1':
- resolution: {integrity: sha512-h2MPBLoNtjc3qZWfY3Tl51yPorQ2McHn8pJfcMNTcIvrrZrr90Ykffit0yjrPFWQcRcUxzH20+6OcVdW4yHtUg==}
+ '@typescript-eslint/utils@8.61.0':
+ resolution: {integrity: sha512-3bzFt7ImFMW/jVYwJamDoe/dMOdFLSC6pom6rRjdh4SZJEYupyMzem8e7vKZLclLfpHjlwSAXOUxtKxGXUiLqA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
- '@typescript-eslint/visitor-keys@8.60.1':
- resolution: {integrity: sha512-EbGRQg4FhrmwLodl+t3JNAnXHWVr9Vp+Zl1QBZVPY4ByfkzIT8cX3K6QWODHtkIZqqJVEWvhHSx3v5PDHsaQag==}
+ '@typescript-eslint/visitor-keys@8.61.0':
+ resolution: {integrity: sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
acorn-jsx@5.3.2:
@@ -853,8 +853,8 @@ packages:
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
- baseline-browser-mapping@2.10.34:
- resolution: {integrity: sha512-IMDedajPifLnHNY0X9n8hKxRTQ6/eTHwr5bDo04WnuqxyKw6LYtQywCuuqPZwhl3aBXMvQpJov42GLCwRRdQzw==}
+ baseline-browser-mapping@2.10.35:
+ resolution: {integrity: sha512-honAfLBde0HAFLdNyBEfuuENkF6zR+ozxqxa/2zJKHBe1qzLqyTSeRKpdPEHAP03rlDGyQOPnCSxnVpVqQo9Mg==}
engines: {node: '>=6.0.0'}
hasBin: true
@@ -952,8 +952,8 @@ packages:
resolution: {integrity: sha512-DLIsRzJVBQu72meAKPkWQOLcujdXT32hwdfnkI1frSiSRMK1MofjKHf+MEx0SB6fjEFXL8fBDv1dKymBlOp4Qw==}
engines: {node: '>=0.10.0'}
- caniuse-lite@1.0.30001797:
- resolution: {integrity: sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==}
+ caniuse-lite@1.0.30001799:
+ resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==}
caw@2.0.1:
resolution: {integrity: sha512-Cg8/ZSBEa8ZVY9HspcGUYaK63d/bN7rqS3CYCzEGUxuYv6UlmcjzDUz2fCFFHyTvUW5Pk0I+3hkA3iXlIj6guA==}
@@ -1185,8 +1185,8 @@ packages:
duplexer3@0.1.5:
resolution: {integrity: sha512-1A8za6ws41LQgv9HrE/66jyC5yuSjQ3L/KOpFtoBilsAK2iA2wuS5rTt1OCzIvtS2V7nVmedsUU+DGRcjBmOYA==}
- electron-to-chromium@1.5.368:
- resolution: {integrity: sha512-7RckJJK4uESJF9PxvfMWd3TGqIiieUTG4HxnKaKuIpGbcr+r2ZEB3g2gAhCP3Fqm42vJSzLfgab9eva/C4/XVw==}
+ electron-to-chromium@1.5.371:
+ resolution: {integrity: sha512-e9htk9mAYL6AzmkEhSvVVw7IWGSBJ/Bqdn2eRyRLrj1g6sncN4WbFt5qnILYoCktktr45pyjIrOiRvBThQ808w==}
emoji-regex@10.6.0:
resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
@@ -2388,8 +2388,8 @@ packages:
resolution: {integrity: sha512-ravE6m9Atw9Z/jjttRUZ+clIXogdghyZAuWJ3qEzjT+jI/dL1ifAqhZeC5VHzQp1MSt1+jxKkFNemj/iO7tVUA==}
engines: {node: '>=4'}
- prettier@3.8.3:
- resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==}
+ prettier@3.8.4:
+ resolution: {integrity: sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==}
engines: {node: '>=14'}
hasBin: true
@@ -2582,8 +2582,8 @@ packages:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
- semver@7.8.2:
- resolution: {integrity: sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==}
+ semver@7.8.4:
+ resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==}
engines: {node: '>=10'}
hasBin: true
@@ -2827,8 +2827,8 @@ packages:
peerDependencies:
typescript: '>=3.5.1'
- typescript-eslint@8.60.1:
- resolution: {integrity: sha512-6m5hkkRAp8lKvhVpcprAIn5KkehQEh+47oHH2VGnExEh7dhNxXlg6GPAOIu6TxbVQxhebrJDvjl3020ooiWCMA==}
+ typescript-eslint@8.61.0:
+ resolution: {integrity: sha512-8y31Rd0eGTrDKqhy6vT0HtzhN+YLjQizwX3aA3hPXP/ynSfnrBXcQY5IzsP9/DM7+klX4IUncZZjkchP0z+rUw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
@@ -3314,23 +3314,23 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
- '@mui/core-downloads-tracker@9.0.1': {}
+ '@mui/core-downloads-tracker@9.1.1': {}
- '@mui/icons-material@9.0.1(@mui/material@9.0.1(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@types/react@19.2.17)(react@19.2.7)':
+ '@mui/icons-material@9.1.1(@mui/material@9.1.1(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@types/react@19.2.17)(react@19.2.7)':
dependencies:
'@babel/runtime': 7.29.7
- '@mui/material': 9.0.1(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+ '@mui/material': 9.1.1(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
react: 19.2.7
optionalDependencies:
'@types/react': 19.2.17
- '@mui/material@9.0.1(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
+ '@mui/material@9.1.1(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@babel/runtime': 7.29.7
- '@mui/core-downloads-tracker': 9.0.1
- '@mui/system': 9.0.1(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(react@19.2.7)
- '@mui/types': 9.0.0(@types/react@19.2.17)
- '@mui/utils': 9.0.1(@types/react@19.2.17)(react@19.2.7)
+ '@mui/core-downloads-tracker': 9.1.1
+ '@mui/system': 9.1.1(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(react@19.2.7)
+ '@mui/types': 9.1.1(@types/react@19.2.17)
+ '@mui/utils': 9.1.1(@types/react@19.2.17)(react@19.2.7)
'@popperjs/core': 2.11.8
'@types/react-transition-group': 4.4.12(@types/react@19.2.17)
clsx: 2.1.1
@@ -3345,16 +3345,16 @@ snapshots:
'@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(react@19.2.7)
'@types/react': 19.2.17
- '@mui/private-theming@9.0.1(@types/react@19.2.17)(react@19.2.7)':
+ '@mui/private-theming@9.1.1(@types/react@19.2.17)(react@19.2.7)':
dependencies:
'@babel/runtime': 7.29.7
- '@mui/utils': 9.0.1(@types/react@19.2.17)(react@19.2.7)
+ '@mui/utils': 9.1.1(@types/react@19.2.17)(react@19.2.7)
prop-types: 15.8.1
react: 19.2.7
optionalDependencies:
'@types/react': 19.2.17
- '@mui/styled-engine@9.0.0(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)':
+ '@mui/styled-engine@9.1.1(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)':
dependencies:
'@babel/runtime': 7.29.7
'@emotion/cache': 11.14.0
@@ -3367,13 +3367,13 @@ snapshots:
'@emotion/react': 11.14.0(@types/react@19.2.17)(react@19.2.7)
'@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(react@19.2.7)
- '@mui/system@9.0.1(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(react@19.2.7)':
+ '@mui/system@9.1.1(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(react@19.2.7)':
dependencies:
'@babel/runtime': 7.29.7
- '@mui/private-theming': 9.0.1(@types/react@19.2.17)(react@19.2.7)
- '@mui/styled-engine': 9.0.0(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)
- '@mui/types': 9.0.0(@types/react@19.2.17)
- '@mui/utils': 9.0.1(@types/react@19.2.17)(react@19.2.7)
+ '@mui/private-theming': 9.1.1(@types/react@19.2.17)(react@19.2.7)
+ '@mui/styled-engine': 9.1.1(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)
+ '@mui/types': 9.1.1(@types/react@19.2.17)
+ '@mui/utils': 9.1.1(@types/react@19.2.17)(react@19.2.7)
clsx: 2.1.1
csstype: 3.2.3
prop-types: 15.8.1
@@ -3383,16 +3383,16 @@ snapshots:
'@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(react@19.2.7)
'@types/react': 19.2.17
- '@mui/types@9.0.0(@types/react@19.2.17)':
+ '@mui/types@9.1.1(@types/react@19.2.17)':
dependencies:
'@babel/runtime': 7.29.7
optionalDependencies:
'@types/react': 19.2.17
- '@mui/utils@9.0.1(@types/react@19.2.17)(react@19.2.7)':
+ '@mui/utils@9.1.1(@types/react@19.2.17)(react@19.2.7)':
dependencies:
'@babel/runtime': 7.29.7
- '@mui/types': 9.0.0(@types/react@19.2.17)
+ '@mui/types': 9.1.1(@types/react@19.2.17)
'@types/prop-types': 15.7.15
clsx: 2.1.1
prop-types: 15.8.1
@@ -3401,7 +3401,7 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.17
- '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
+ '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
dependencies:
'@emnapi/core': 1.10.0
'@emnapi/runtime': 1.10.0
@@ -3424,19 +3424,19 @@ snapshots:
'@popperjs/core@2.11.8': {}
- '@preact/preset-vite@2.10.5(@babel/core@7.29.7)(preact@10.29.2)(vite@8.0.16(@types/node@25.9.2)(terser@5.48.0))':
+ '@preact/preset-vite@2.10.5(@babel/core@7.29.7)(preact@10.29.2)(vite@8.0.16(@types/node@25.9.3)(terser@5.48.0))':
dependencies:
'@babel/core': 7.29.7
'@babel/plugin-transform-react-jsx': 7.29.7(@babel/core@7.29.7)
'@babel/plugin-transform-react-jsx-development': 7.29.7(@babel/core@7.29.7)
- '@prefresh/vite': 2.4.12(preact@10.29.2)(vite@8.0.16(@types/node@25.9.2)(terser@5.48.0))
+ '@prefresh/vite': 2.4.12(preact@10.29.2)(vite@8.0.16(@types/node@25.9.3)(terser@5.48.0))
'@rollup/pluginutils': 5.4.0
babel-plugin-transform-hook-names: 1.0.2(@babel/core@7.29.7)
debug: 4.4.3
magic-string: 0.30.21
picocolors: 1.1.1
- vite: 8.0.16(@types/node@25.9.2)(terser@5.48.0)
- vite-prerender-plugin: 0.5.13(vite@8.0.16(@types/node@25.9.2)(terser@5.48.0))
+ vite: 8.0.16(@types/node@25.9.3)(terser@5.48.0)
+ vite-prerender-plugin: 0.5.13(vite@8.0.16(@types/node@25.9.3)(terser@5.48.0))
zimmerframe: 1.1.4
transitivePeerDependencies:
- preact
@@ -3451,7 +3451,7 @@ snapshots:
'@prefresh/utils@1.2.1': {}
- '@prefresh/vite@2.4.12(preact@10.29.2)(vite@8.0.16(@types/node@25.9.2)(terser@5.48.0))':
+ '@prefresh/vite@2.4.12(preact@10.29.2)(vite@8.0.16(@types/node@25.9.3)(terser@5.48.0))':
dependencies:
'@babel/core': 7.29.7
'@prefresh/babel-plugin': 0.5.3
@@ -3459,7 +3459,7 @@ snapshots:
'@prefresh/utils': 1.2.1
'@rollup/pluginutils': 4.2.1
preact: 10.29.2
- vite: 8.0.16(@types/node@25.9.2)(terser@5.48.0)
+ vite: 8.0.16(@types/node@25.9.3)(terser@5.48.0)
transitivePeerDependencies:
- supports-color
@@ -3503,7 +3503,7 @@ snapshots:
dependencies:
'@emnapi/core': 1.10.0
'@emnapi/runtime': 1.10.0
- '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
+ '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
optional: true
'@rolldown/binding-win32-arm64-msvc@1.0.3':
@@ -3536,7 +3536,7 @@ snapshots:
react-virtualized-auto-sizer: 1.0.26(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
react-window: 1.8.11(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
- '@trivago/prettier-plugin-sort-imports@6.0.2(prettier@3.8.3)':
+ '@trivago/prettier-plugin-sort-imports@6.0.2(prettier@3.8.4)':
dependencies:
'@babel/generator': 7.29.7
'@babel/parser': 7.29.7
@@ -3546,7 +3546,7 @@ snapshots:
lodash-es: 4.18.1
minimatch: 9.0.9
parse-imports-exports: 0.2.4
- prettier: 3.8.3
+ prettier: 3.8.4
transitivePeerDependencies:
- supports-color
@@ -3562,7 +3562,7 @@ snapshots:
'@types/glob@7.2.0':
dependencies:
'@types/minimatch': 6.0.0
- '@types/node': 25.9.2
+ '@types/node': 25.9.3
'@types/imagemin-gifsicle@7.0.4':
dependencies:
@@ -3591,19 +3591,19 @@ snapshots:
'@types/imagemin@7.0.1':
dependencies:
- '@types/node': 25.9.2
+ '@types/node': 25.9.3
'@types/json-schema@7.0.15': {}
'@types/keyv@3.1.4':
dependencies:
- '@types/node': 25.9.2
+ '@types/node': 25.9.3
'@types/minimatch@6.0.0':
dependencies:
minimatch: 10.2.5
- '@types/node@25.9.2':
+ '@types/node@25.9.3':
dependencies:
undici-types: 7.24.6
@@ -3625,20 +3625,20 @@ snapshots:
'@types/responselike@1.0.3':
dependencies:
- '@types/node': 25.9.2
+ '@types/node': 25.9.3
'@types/svgo@2.6.4':
dependencies:
- '@types/node': 25.9.2
+ '@types/node': 25.9.3
- '@typescript-eslint/eslint-plugin@8.60.1(@typescript-eslint/parser@8.60.1(eslint@10.4.1)(typescript@6.0.3))(eslint@10.4.1)(typescript@6.0.3)':
+ '@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.4.1)(typescript@6.0.3))(eslint@10.4.1)(typescript@6.0.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
- '@typescript-eslint/parser': 8.60.1(eslint@10.4.1)(typescript@6.0.3)
- '@typescript-eslint/scope-manager': 8.60.1
- '@typescript-eslint/type-utils': 8.60.1(eslint@10.4.1)(typescript@6.0.3)
- '@typescript-eslint/utils': 8.60.1(eslint@10.4.1)(typescript@6.0.3)
- '@typescript-eslint/visitor-keys': 8.60.1
+ '@typescript-eslint/parser': 8.61.0(eslint@10.4.1)(typescript@6.0.3)
+ '@typescript-eslint/scope-manager': 8.61.0
+ '@typescript-eslint/type-utils': 8.61.0(eslint@10.4.1)(typescript@6.0.3)
+ '@typescript-eslint/utils': 8.61.0(eslint@10.4.1)(typescript@6.0.3)
+ '@typescript-eslint/visitor-keys': 8.61.0
eslint: 10.4.1
ignore: 7.0.5
natural-compare: 1.4.0
@@ -3647,41 +3647,41 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/parser@8.60.1(eslint@10.4.1)(typescript@6.0.3)':
+ '@typescript-eslint/parser@8.61.0(eslint@10.4.1)(typescript@6.0.3)':
dependencies:
- '@typescript-eslint/scope-manager': 8.60.1
- '@typescript-eslint/types': 8.60.1
- '@typescript-eslint/typescript-estree': 8.60.1(typescript@6.0.3)
- '@typescript-eslint/visitor-keys': 8.60.1
+ '@typescript-eslint/scope-manager': 8.61.0
+ '@typescript-eslint/types': 8.61.0
+ '@typescript-eslint/typescript-estree': 8.61.0(typescript@6.0.3)
+ '@typescript-eslint/visitor-keys': 8.61.0
debug: 4.4.3
eslint: 10.4.1
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/project-service@8.60.1(typescript@6.0.3)':
+ '@typescript-eslint/project-service@8.61.0(typescript@6.0.3)':
dependencies:
- '@typescript-eslint/tsconfig-utils': 8.60.1(typescript@6.0.3)
- '@typescript-eslint/types': 8.60.1
+ '@typescript-eslint/tsconfig-utils': 8.61.0(typescript@6.0.3)
+ '@typescript-eslint/types': 8.61.0
debug: 4.4.3
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/scope-manager@8.60.1':
+ '@typescript-eslint/scope-manager@8.61.0':
dependencies:
- '@typescript-eslint/types': 8.60.1
- '@typescript-eslint/visitor-keys': 8.60.1
+ '@typescript-eslint/types': 8.61.0
+ '@typescript-eslint/visitor-keys': 8.61.0
- '@typescript-eslint/tsconfig-utils@8.60.1(typescript@6.0.3)':
+ '@typescript-eslint/tsconfig-utils@8.61.0(typescript@6.0.3)':
dependencies:
typescript: 6.0.3
- '@typescript-eslint/type-utils@8.60.1(eslint@10.4.1)(typescript@6.0.3)':
+ '@typescript-eslint/type-utils@8.61.0(eslint@10.4.1)(typescript@6.0.3)':
dependencies:
- '@typescript-eslint/types': 8.60.1
- '@typescript-eslint/typescript-estree': 8.60.1(typescript@6.0.3)
- '@typescript-eslint/utils': 8.60.1(eslint@10.4.1)(typescript@6.0.3)
+ '@typescript-eslint/types': 8.61.0
+ '@typescript-eslint/typescript-estree': 8.61.0(typescript@6.0.3)
+ '@typescript-eslint/utils': 8.61.0(eslint@10.4.1)(typescript@6.0.3)
debug: 4.4.3
eslint: 10.4.1
ts-api-utils: 2.5.0(typescript@6.0.3)
@@ -3689,37 +3689,37 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/types@8.60.1': {}
+ '@typescript-eslint/types@8.61.0': {}
- '@typescript-eslint/typescript-estree@8.60.1(typescript@6.0.3)':
+ '@typescript-eslint/typescript-estree@8.61.0(typescript@6.0.3)':
dependencies:
- '@typescript-eslint/project-service': 8.60.1(typescript@6.0.3)
- '@typescript-eslint/tsconfig-utils': 8.60.1(typescript@6.0.3)
- '@typescript-eslint/types': 8.60.1
- '@typescript-eslint/visitor-keys': 8.60.1
+ '@typescript-eslint/project-service': 8.61.0(typescript@6.0.3)
+ '@typescript-eslint/tsconfig-utils': 8.61.0(typescript@6.0.3)
+ '@typescript-eslint/types': 8.61.0
+ '@typescript-eslint/visitor-keys': 8.61.0
debug: 4.4.3
minimatch: 10.2.5
- semver: 7.8.2
+ semver: 7.8.4
tinyglobby: 0.2.17
ts-api-utils: 2.5.0(typescript@6.0.3)
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/utils@8.60.1(eslint@10.4.1)(typescript@6.0.3)':
+ '@typescript-eslint/utils@8.61.0(eslint@10.4.1)(typescript@6.0.3)':
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@10.4.1)
- '@typescript-eslint/scope-manager': 8.60.1
- '@typescript-eslint/types': 8.60.1
- '@typescript-eslint/typescript-estree': 8.60.1(typescript@6.0.3)
+ '@typescript-eslint/scope-manager': 8.61.0
+ '@typescript-eslint/types': 8.61.0
+ '@typescript-eslint/typescript-estree': 8.61.0(typescript@6.0.3)
eslint: 10.4.1
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/visitor-keys@8.60.1':
+ '@typescript-eslint/visitor-keys@8.61.0':
dependencies:
- '@typescript-eslint/types': 8.60.1
+ '@typescript-eslint/types': 8.61.0
eslint-visitor-keys: 5.0.1
acorn-jsx@5.3.2(acorn@8.16.0):
@@ -3784,7 +3784,7 @@ snapshots:
base64-js@1.5.1: {}
- baseline-browser-mapping@2.10.34: {}
+ baseline-browser-mapping@2.10.35: {}
bin-build@3.0.0:
dependencies:
@@ -3845,9 +3845,9 @@ snapshots:
browserslist@4.28.2:
dependencies:
- baseline-browser-mapping: 2.10.34
- caniuse-lite: 1.0.30001797
- electron-to-chromium: 1.5.368
+ baseline-browser-mapping: 2.10.35
+ caniuse-lite: 1.0.30001799
+ electron-to-chromium: 1.5.371
node-releases: 2.0.47
update-browserslist-db: 1.2.3(browserslist@4.28.2)
@@ -3909,7 +3909,7 @@ snapshots:
camelcase@2.1.1: {}
- caniuse-lite@1.0.30001797: {}
+ caniuse-lite@1.0.30001799: {}
caw@2.0.1:
dependencies:
@@ -4202,7 +4202,7 @@ snapshots:
duplexer3@0.1.5: {}
- electron-to-chromium@1.5.368: {}
+ electron-to-chromium@1.5.371: {}
emoji-regex@10.6.0: {}
@@ -5334,7 +5334,7 @@ snapshots:
prepend-http@2.0.0: {}
- prettier@3.8.3: {}
+ prettier@3.8.4: {}
process-nextick-args@2.0.1: {}
@@ -5528,7 +5528,7 @@ snapshots:
semver@6.3.1: {}
- semver@7.8.2: {}
+ semver@7.8.4: {}
set-cookie-parser@2.7.2: {}
@@ -5753,12 +5753,12 @@ snapshots:
dependencies:
typescript: 6.0.3
- typescript-eslint@8.60.1(eslint@10.4.1)(typescript@6.0.3):
+ typescript-eslint@8.61.0(eslint@10.4.1)(typescript@6.0.3):
dependencies:
- '@typescript-eslint/eslint-plugin': 8.60.1(@typescript-eslint/parser@8.60.1(eslint@10.4.1)(typescript@6.0.3))(eslint@10.4.1)(typescript@6.0.3)
- '@typescript-eslint/parser': 8.60.1(eslint@10.4.1)(typescript@6.0.3)
- '@typescript-eslint/typescript-estree': 8.60.1(typescript@6.0.3)
- '@typescript-eslint/utils': 8.60.1(eslint@10.4.1)(typescript@6.0.3)
+ '@typescript-eslint/eslint-plugin': 8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.4.1)(typescript@6.0.3))(eslint@10.4.1)(typescript@6.0.3)
+ '@typescript-eslint/parser': 8.61.0(eslint@10.4.1)(typescript@6.0.3)
+ '@typescript-eslint/typescript-estree': 8.61.0(typescript@6.0.3)
+ '@typescript-eslint/utils': 8.61.0(eslint@10.4.1)(typescript@6.0.3)
eslint: 10.4.1
typescript: 6.0.3
transitivePeerDependencies:
@@ -5804,7 +5804,7 @@ snapshots:
spdx-correct: 3.2.0
spdx-expression-parse: 3.0.1
- vite-plugin-imagemin@0.6.1(vite@8.0.16(@types/node@25.9.2)(terser@5.48.0)):
+ vite-plugin-imagemin@0.6.1(vite@8.0.16(@types/node@25.9.3)(terser@5.48.0)):
dependencies:
'@types/imagemin': 7.0.1
'@types/imagemin-gifsicle': 7.0.4
@@ -5829,11 +5829,11 @@ snapshots:
imagemin-webp: 6.1.0
jpegtran-bin: 6.0.1
pathe: 0.2.0
- vite: 8.0.16(@types/node@25.9.2)(terser@5.48.0)
+ vite: 8.0.16(@types/node@25.9.3)(terser@5.48.0)
transitivePeerDependencies:
- supports-color
- vite-prerender-plugin@0.5.13(vite@8.0.16(@types/node@25.9.2)(terser@5.48.0)):
+ vite-prerender-plugin@0.5.13(vite@8.0.16(@types/node@25.9.3)(terser@5.48.0)):
dependencies:
kolorist: 1.8.0
magic-string: 0.30.21
@@ -5841,9 +5841,9 @@ snapshots:
simple-code-frame: 1.3.0
source-map: 0.7.6
stack-trace: 1.0.0
- vite: 8.0.16(@types/node@25.9.2)(terser@5.48.0)
+ vite: 8.0.16(@types/node@25.9.3)(terser@5.48.0)
- vite@8.0.16(@types/node@25.9.2)(terser@5.48.0):
+ vite@8.0.16(@types/node@25.9.3)(terser@5.48.0):
dependencies:
lightningcss: 1.32.0
picomatch: 4.0.4
@@ -5851,7 +5851,7 @@ snapshots:
rolldown: 1.0.3
tinyglobby: 0.2.17
optionalDependencies:
- '@types/node': 25.9.2
+ '@types/node': 25.9.3
fsevents: 2.3.3
terser: 5.48.0
diff --git a/interface/src/AuthenticatedRouting.tsx b/interface/src/AuthenticatedRouting.tsx
index 1bab479ae..ae4eeb1b6 100644
--- a/interface/src/AuthenticatedRouting.tsx
+++ b/interface/src/AuthenticatedRouting.tsx
@@ -1,6 +1,7 @@
import { memo, useContext } from 'react';
import { Navigate, Route, Routes } from 'react-router';
+import Commands from 'app/main/Commands';
import CustomEntities from 'app/main/CustomEntities';
import Customizations from 'app/main/Customizations';
import Dashboard from 'app/main/Dashboard';
@@ -65,6 +66,7 @@ const AuthenticatedRouting = memo(() => {
} />
} />
+ } />
} />
} />
>
diff --git a/interface/src/api/app.ts b/interface/src/api/app.ts
index 668e61904..e352b8692 100644
--- a/interface/src/api/app.ts
+++ b/interface/src/api/app.ts
@@ -4,6 +4,8 @@ import type {
APIcall,
Action,
Activity,
+ CommandItem,
+ Commands,
CoreData,
DashboardData,
DeviceData,
@@ -102,8 +104,7 @@ export const readSchedule = () =>
o_deleted: si.deleted,
o_flags: si.flags,
o_time: si.time,
- o_cmd: si.cmd,
- o_value: si.value,
+ o_cmd_name: si.cmd_name,
o_name: si.name
}));
}
@@ -111,6 +112,24 @@ export const readSchedule = () =>
export const writeSchedule = (data: Schedule) =>
alovaInstance.Post('/rest/schedule', data);
+// Commands
+export const readCommands = () =>
+ alovaInstance.Get('/rest/commands', {
+ // @ts-expect-error - exactOptionalPropertyTypes compatibility issue
+ transform(data) {
+ const commands = (data as Commands).commands;
+ return commands.map((ci) => ({
+ ...ci,
+ o_id: ci.id,
+ o_cmd: ci.cmd,
+ o_value: ci.value,
+ o_name: ci.name
+ }));
+ }
+ });
+export const writeCommands = (data: Commands) =>
+ alovaInstance.Post('/rest/commands', data);
+
// Modules
export const readModules = () =>
alovaInstance.Get('/rest/modules', {
diff --git a/interface/src/app/main/Commands.tsx b/interface/src/app/main/Commands.tsx
new file mode 100644
index 000000000..4a20e1c39
--- /dev/null
+++ b/interface/src/app/main/Commands.tsx
@@ -0,0 +1,284 @@
+import { useState } from 'react';
+import { useBlocker } from 'react-router';
+import { toast } from 'react-toastify';
+
+import AddIcon from '@mui/icons-material/Add';
+import CancelIcon from '@mui/icons-material/Cancel';
+import WarningIcon from '@mui/icons-material/Warning';
+import { Box, Button, Typography } from '@mui/material';
+
+import {
+ Body,
+ Cell,
+ Header,
+ HeaderCell,
+ HeaderRow,
+ Row,
+ Table
+} from '@table-library/react-table-library/table';
+import { useTheme } from '@table-library/react-table-library/theme';
+import { updateState, useRequest } from 'alova/client';
+import {
+ BlockNavigation,
+ ButtonRow,
+ FormLoader,
+ SectionContent,
+ useLayoutTitle
+} from 'components';
+import { useI18nContext } from 'i18n/i18n-react';
+import { useInterval } from 'utils';
+
+import { readCommands, writeCommands } from '../../api/app';
+import CommandsDialog from './CommandsDialog';
+import type { CommandItem, Commands as CommandsType } from './types';
+import { commandItemValidation } from './validators';
+
+const INTERVAL_DELAY = 30000;
+const MIN_ID = -100;
+const MAX_ID = 100;
+
+const DEFAULT_COMMAND_ITEM: Omit = {
+ cmd: '',
+ value: '',
+ name: '',
+ deleted: false
+};
+
+const commandsTheme = {
+ Table: `
+ --data-table-library_grid-template-columns: repeat(1, minmax(100px, 1fr)) repeat(1, minmax(100px, 1fr)) 160px;
+ `,
+ BaseRow: `
+ font-size: 14px;
+ .td {
+ height: 32px;
+ }
+ `,
+ BaseCell: `
+ &:nth-of-type(1) {
+ padding: 8px;
+ }
+ `,
+ HeaderRow: `
+ text-transform: uppercase;
+ background-color: black;
+ color: #90CAF9;
+ .th {
+ border-bottom: 1px solid #565656;
+ height: 36px;
+ }
+ `,
+ Row: `
+ background-color: #1e1e1e;
+ position: relative;
+ cursor: pointer;
+ .td {
+ border-bottom: 1px solid #565656;
+ }
+ &:hover .td {
+ background-color: #177ac9;
+ }
+ `
+};
+
+const CommandsPage = () => {
+ const { LL } = useI18nContext();
+ const [numChanges, setNumChanges] = useState(0);
+ const blocker = useBlocker(numChanges !== 0);
+ const [selectedItem, setSelectedItem] = useState();
+ const [creating, setCreating] = useState(false);
+ const [dialogOpen, setDialogOpen] = useState(false);
+
+ useLayoutTitle(LL.COMMANDS());
+
+ const {
+ data: commands,
+ send: fetchCommands,
+ error
+ } = useRequest(readCommands, {
+ initialData: []
+ });
+
+ const { send: updateCommands } = useRequest(
+ (data: CommandsType) => writeCommands(data),
+ { immediate: false }
+ );
+
+ const hasChanged = (ci: CommandItem) =>
+ ci.id !== ci.o_id ||
+ (ci.name || '') !== (ci.o_name || '') ||
+ ci.cmd !== ci.o_cmd ||
+ ci.value !== ci.o_value ||
+ ci.deleted !== ci.o_deleted;
+
+ useInterval(() => {
+ if (numChanges === 0) {
+ void fetchCommands();
+ }
+ }, INTERVAL_DELAY);
+
+ const theme = useTheme(commandsTheme);
+
+ const saveCommands = async () => {
+ try {
+ await updateCommands({
+ commands: commands
+ .filter((ci: CommandItem) => !ci.deleted)
+ .map((ci: CommandItem) => ({
+ id: ci.id,
+ cmd: ci.cmd,
+ value: ci.value,
+ name: ci.name
+ }))
+ });
+ toast.success(LL.UPDATED_OF(LL.COMMANDS()));
+ } catch (error: unknown) {
+ const message = error instanceof Error ? error.message : String(error);
+ toast.error(message);
+ } finally {
+ await fetchCommands();
+ setNumChanges(0);
+ }
+ };
+
+ const editItem = (ci: CommandItem) => {
+ setCreating(false);
+ setSelectedItem(ci);
+ setDialogOpen(true);
+ if (ci.o_name === undefined) {
+ ci.o_name = ci.name;
+ }
+ };
+
+ const onDialogClose = () => {
+ setDialogOpen(false);
+ };
+
+ const onDialogCancel = async () => {
+ await fetchCommands().then(() => {
+ setNumChanges(0);
+ });
+ };
+
+ const onDialogSave = (updatedItem: CommandItem) => {
+ setDialogOpen(false);
+ void updateState(readCommands(), (data: CommandItem[]) => {
+ const new_data = creating
+ ? [...data, updatedItem]
+ : data.map((ci) =>
+ ci.id === updatedItem.id ? { ...ci, ...updatedItem } : ci
+ );
+ setNumChanges(new_data.filter((ci) => hasChanged(ci)).length);
+ return new_data;
+ });
+ };
+
+ const addItem = () => {
+ setCreating(true);
+ const newItem: CommandItem = {
+ id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
+ ...DEFAULT_COMMAND_ITEM
+ };
+ setSelectedItem(newItem);
+ setDialogOpen(true);
+ };
+
+ const filteredCommands = commands.filter((ci: CommandItem) => !ci.deleted);
+
+ const renderCommands = () => {
+ if (!commands) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {(tableList: CommandItem[]) => (
+ <>
+
+
+ {LL.COMMAND(0)}
+ {LL.VALUE(0)}
+ {LL.NAME(0)}
+
+
+
+ {tableList.map((ci: CommandItem) => (
+ editItem(ci)}>
+ | {ci.cmd} |
+ {ci.value} |
+ {ci.name} |
+
+ ))}
+
+ >
+ )}
+
+ );
+ };
+
+ return (
+
+ {blocker ? : null}
+
+ {LL.COMMANDS_HELP_1()}.
+
+ {renderCommands()}
+
+ {selectedItem && (
+
+ )}
+
+
+
+ {numChanges !== 0 && (
+
+ }
+ variant="outlined"
+ onClick={onDialogCancel}
+ color="secondary"
+ >
+ {LL.CANCEL()}
+
+ }
+ variant="contained"
+ color="info"
+ onClick={saveCommands}
+ >
+ {LL.APPLY_CHANGES(numChanges)}
+
+
+ )}
+
+
+
+ }
+ variant="outlined"
+ color="primary"
+ onClick={addItem}
+ >
+ {LL.ADD(0)}
+
+
+
+
+
+ );
+};
+
+export default CommandsPage;
diff --git a/interface/src/app/main/CommandsDialog.tsx b/interface/src/app/main/CommandsDialog.tsx
new file mode 100644
index 000000000..ac8dd74ea
--- /dev/null
+++ b/interface/src/app/main/CommandsDialog.tsx
@@ -0,0 +1,206 @@
+import { useEffect, useState } from 'react';
+import { toast } from 'react-toastify';
+
+import AddIcon from '@mui/icons-material/Add';
+import CancelIcon from '@mui/icons-material/Cancel';
+import DoneIcon from '@mui/icons-material/Done';
+import PlayArrowIcon from '@mui/icons-material/PlayArrow';
+import RemoveIcon from '@mui/icons-material/RemoveCircleOutlined';
+import {
+ Box,
+ Button,
+ Dialog,
+ DialogActions,
+ DialogContent,
+ DialogTitle,
+ TextField
+} from '@mui/material';
+
+import { callAction } from '@/api/app';
+import { dialogStyle } from 'CustomTheme';
+import { useRequest } from 'alova/client';
+import type Schema from 'async-validator';
+import type { ValidateFieldsError } from 'async-validator';
+import { ValidatedTextField } from 'components';
+import { useI18nContext } from 'i18n/i18n-react';
+import { updateValue } from 'utils';
+import { ValidationError, validate } from 'validators';
+
+import type { CommandItem } from './types';
+
+interface CommandsDialogProps {
+ open: boolean;
+ creating: boolean;
+ onClose: () => void;
+ onSave: (ci: CommandItem) => void;
+ selectedItem: CommandItem;
+ validator: Schema;
+}
+
+const CommandsDialog = ({
+ open,
+ creating,
+ onClose,
+ onSave,
+ selectedItem,
+ validator
+}: CommandsDialogProps) => {
+ const { LL } = useI18nContext();
+ const [hasChanges, setHasChanges] = useState(false);
+ const [editItem, setEditItem] = useState(selectedItem);
+ const [fieldErrors, setFieldErrors] = useState();
+
+ const updateFormValue = updateValue(
+ setEditItem as unknown as React.Dispatch<
+ React.SetStateAction>
+ >
+ );
+
+ useEffect(() => {
+ if (open) {
+ setFieldErrors(undefined);
+ setEditItem(selectedItem);
+ }
+ }, [open, selectedItem]);
+
+ const hasChanged = (ci: CommandItem) =>
+ ci.id !== ci.o_id ||
+ (ci.name || '') !== (ci.o_name || '') ||
+ ci.cmd !== ci.o_cmd ||
+ ci.value !== ci.o_value ||
+ ci.deleted !== ci.o_deleted;
+
+ useEffect(() => {
+ setHasChanges(hasChanged(editItem));
+ }, [editItem]);
+
+ const handleSave = async (itemToSave: CommandItem) => {
+ try {
+ setFieldErrors(undefined);
+ await validate(validator, itemToSave);
+ onSave(itemToSave);
+ } catch (error) {
+ setFieldErrors((error as ValidationError).fieldErrors);
+ } finally {
+ setHasChanges(false);
+ }
+ };
+
+ const save = async () => {
+ await handleSave(editItem);
+ };
+
+ const { send: executeCommand } = useRequest(
+ (id: string) => callAction({ action: 'executeCommand', param: id }),
+ { immediate: false }
+ )
+ .onSuccess(() => {
+ toast.success(LL.EXECUTE_COMMAND_SENT());
+ })
+ .onError((error) => {
+ toast.error(String(error.error?.message || 'An error occurred'));
+ });
+
+ const execute = async () => {
+ await executeCommand(editItem.name);
+ };
+
+ const remove = () => {
+ onSave({ ...editItem, deleted: true });
+ };
+
+ const handleClose = (
+ _event: React.SyntheticEvent,
+ reason: 'backdropClick' | 'escapeKeyDown'
+ ) => {
+ if (reason !== 'backdropClick') {
+ onClose();
+ }
+ };
+
+ return (
+
+ );
+};
+
+export default CommandsDialog;
diff --git a/interface/src/app/main/Dashboard.tsx b/interface/src/app/main/Dashboard.tsx
index f404ed3f7..e90cbca19 100644
--- a/interface/src/app/main/Dashboard.tsx
+++ b/interface/src/app/main/Dashboard.tsx
@@ -180,6 +180,8 @@ const Dashboard = memo(() => {
return LL.ANALOG_SENSORS();
case DeviceType.TEMPERATURESENSOR:
return LL.TEMP_SENSORS();
+ case DeviceType.COMMAND:
+ return LL.COMMANDS();
case DeviceType.SCHEDULER:
return LL.SCHEDULER();
default:
diff --git a/interface/src/app/main/DeviceIcon.tsx b/interface/src/app/main/DeviceIcon.tsx
index 79207ff37..1266e9b55 100644
--- a/interface/src/app/main/DeviceIcon.tsx
+++ b/interface/src/app/main/DeviceIcon.tsx
@@ -38,6 +38,7 @@ const deviceIconLookup: Record = {
[DeviceType.CUSTOM]: MdPlaylistAdd,
[DeviceType.UNKNOWN]: MdOutlineSensors,
[DeviceType.SYSTEM]: null,
+ [DeviceType.COMMAND]: MdPlaylistAdd,
[DeviceType.SCHEDULER]: MdMoreTime,
[DeviceType.GENERIC]: MdOutlineSensors,
[DeviceType.VENTILATION]: PiFan
diff --git a/interface/src/app/main/DevicesDialog.tsx b/interface/src/app/main/DevicesDialog.tsx
index ce0526292..3b98822d8 100644
--- a/interface/src/app/main/DevicesDialog.tsx
+++ b/interface/src/app/main/DevicesDialog.tsx
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel';
+import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import WarningIcon from '@mui/icons-material/Warning';
import {
Box,
@@ -64,12 +65,12 @@ const DevicesDialog = ({
}
}, [open, selectedItem]);
- const { send: executeSchedule } = useRequest(
- (id: string) => callAction({ action: 'executeSchedule', param: id }),
+ const { send: executeCommand } = useRequest(
+ (id: string) => callAction({ action: 'executeCommand', param: id }),
{ immediate: false }
)
.onSuccess(() => {
- toast.success(LL.EXECUTE_SCHEDULE_SENT());
+ toast.success(LL.EXECUTE_COMMAND_SENT());
})
.onError((error) => {
toast.error(String(error.error?.message || 'An error occurred'));
@@ -79,7 +80,7 @@ const DevicesDialog = ({
try {
setFieldErrors(undefined);
if (editItem.v === undefined && editItem.c !== undefined) {
- await executeSchedule(editItem.c);
+ await executeCommand(editItem.c);
} else {
await validate(validator, editItem);
}
@@ -226,10 +227,12 @@ const DevicesDialog = ({
{LL.CANCEL()}
}
+ startIcon={
+ isCommand ? :
+ }
variant="outlined"
onClick={doAction}
- color="primary"
+ color={isCommand ? 'success' : 'primary'}
>
{buttonLabel}
diff --git a/interface/src/app/main/Scheduler.tsx b/interface/src/app/main/Scheduler.tsx
index 1bb612854..b372cbc9a 100644
--- a/interface/src/app/main/Scheduler.tsx
+++ b/interface/src/app/main/Scheduler.tsx
@@ -29,7 +29,7 @@ import {
import { useI18nContext } from 'i18n/i18n-react';
import { useInterval } from 'utils';
-import { readSchedule, writeSchedule } from '../../api/app';
+import { readCommands, readSchedule, writeSchedule } from '../../api/app';
import SettingsSchedulerDialog from './SchedulerDialog';
import { ScheduleFlag } from './types';
import type { Schedule, ScheduleItem } from './types';
@@ -54,14 +54,13 @@ const DEFAULT_SCHEDULE_ITEM: Omit = {
deleted: false,
flags: FLAG_ALL_DAYS,
time: '',
- cmd: '',
- value: '',
+ cmd_name: '',
name: ''
};
const scheduleTheme = {
Table: `
- --data-table-library_grid-template-columns: 36px 210px 100px 192px repeat(1, minmax(100px, 1fr)) 160px;
+ --data-table-library_grid-template-columns: 36px 220px repeat(1, minmax(20px, 1fr)) 192px 160px;
`,
BaseRow: `
font-size: 14px;
@@ -70,11 +69,8 @@ const scheduleTheme = {
}
`,
BaseCell: `
- &:nth-of-type(2) {
- text-align: center;
- }
&:nth-of-type(1) {
- text-align: center;
+ text-align: 8px;
}
`,
HeaderRow: `
@@ -100,7 +96,6 @@ const scheduleTheme = {
};
const scheduleTypeLabels: Record = {
- [ScheduleFlag.SCHEDULE_IMMEDIATE]: 'Immediate',
[ScheduleFlag.SCHEDULE_TIMER]: 'Timer',
[ScheduleFlag.SCHEDULE_CONDITION]: 'Condition',
[ScheduleFlag.SCHEDULE_ONCHANGE]: 'On Change'
@@ -125,6 +120,11 @@ const Scheduler = () => {
initialData: []
});
+ const { data: commandNames } = useRequest(readCommands, {
+ initialData: [],
+ initializing: true
+ });
+
const { send: updateSchedule } = useRequest(
(data: Schedule) => writeSchedule(data),
{
@@ -140,8 +140,7 @@ const Scheduler = () => {
si.deleted !== si.o_deleted ||
si.flags !== si.o_flags ||
si.time !== si.o_time ||
- si.cmd !== si.o_cmd ||
- si.value !== si.o_value
+ si.cmd_name !== si.o_cmd_name
);
};
@@ -177,8 +176,7 @@ const Scheduler = () => {
active: condensed_si.active,
flags: condensed_si.flags,
time: condensed_si.time,
- cmd: condensed_si.cmd,
- value: condensed_si.value,
+ cmd_name: condensed_si.cmd_name,
name: condensed_si.name
}))
});
@@ -287,9 +285,10 @@ const Scheduler = () => {
{LL.SCHEDULE(0)}
- {LL.TIME(0)}/Cond.
+
+ {LL.TIME(0)}/{LL.CONDITION()}
+
{LL.COMMAND(0)}
- {LL.VALUE(0)}
{LL.NAME(0)}
@@ -297,12 +296,15 @@ const Scheduler = () => {
{tableList.map((si: ScheduleItem) => (
editScheduleItem(si)}>
|
- {si.flags !== ScheduleFlag.SCHEDULE_IMMEDIATE && (
-
- )}
+
|
@@ -321,9 +323,8 @@ const Scheduler = () => {
)}
|
- {si.time} |
- {si.cmd} |
- {si.value} |
+ {si.time === '' ? LL.SCHEDULER_HELP_2() : si.time} |
+ {si.cmd_name} |
{si.name} |
))}
@@ -351,6 +352,7 @@ const Scheduler = () => {
selectedItem={selectedScheduleItem}
validator={schedulerItemValidation(schedule, selectedScheduleItem)}
dow={dow}
+ commandNames={commandNames.map((ci) => ci.name)}
/>
)}
diff --git a/interface/src/app/main/SchedulerDialog.tsx b/interface/src/app/main/SchedulerDialog.tsx
index 8e1be27b5..b1d120077 100644
--- a/interface/src/app/main/SchedulerDialog.tsx
+++ b/interface/src/app/main/SchedulerDialog.tsx
@@ -1,10 +1,9 @@
import { useEffect, useState } from 'react';
-import { toast } from 'react-toastify';
import AddIcon from '@mui/icons-material/Add';
import CancelIcon from '@mui/icons-material/Cancel';
+import CircleIcon from '@mui/icons-material/Circle';
import DoneIcon from '@mui/icons-material/Done';
-import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import RemoveIcon from '@mui/icons-material/RemoveCircleOutlined';
import {
Box,
@@ -15,15 +14,14 @@ import {
DialogContent,
DialogTitle,
Grid,
+ MenuItem,
TextField,
ToggleButton,
ToggleButtonGroup,
Typography
} from '@mui/material';
-import { callAction } from '@/api/app';
import { dialogStyle } from 'CustomTheme';
-import { useRequest } from 'alova/client';
import type Schema from 'async-validator';
import type { ValidateFieldsError } from 'async-validator';
import { BlockFormControlLabel, ValidatedTextField } from 'components';
@@ -77,6 +75,7 @@ interface SchedulerDialogProps {
selectedItem: ScheduleItem;
validator: Schema;
dow: string[];
+ commandNames: string[];
}
const SchedulerDialog = ({
@@ -86,7 +85,8 @@ const SchedulerDialog = ({
onSave,
selectedItem,
validator,
- dow
+ dow,
+ commandNames
}: SchedulerDialogProps) => {
const { LL } = useI18nContext();
const [editItem, setEditItem] = useState(selectedItem);
@@ -103,12 +103,6 @@ const SchedulerDialog = ({
if (open) {
setFieldErrors(undefined);
setEditItem(selectedItem);
- // Set the flags based on type when page is loaded:
- // 0-127 is day schedule
- // 128 is timer
- // 129 is on change
- // 130 is on condition
- // 132 is immediate
setScheduleType(
selectedItem.flags <= SCHEDULE_TYPE_THRESHOLD
? ScheduleFlag.SCHEDULE_DAY
@@ -131,21 +125,6 @@ const SchedulerDialog = ({
await handleSave(editItem);
};
- const { send: executeSchedule } = useRequest(
- (id: string) => callAction({ action: 'executeSchedule', param: id }),
- { immediate: false }
- )
- .onSuccess(() => {
- toast.success(LL.EXECUTE_SCHEDULE_SENT());
- })
- .onError((error) => {
- toast.error(String(error.error?.message || 'An error occurred'));
- });
-
- const execute = async () => {
- await executeSchedule(editItem.name);
- };
-
const remove = () => {
onSave({ ...editItem, deleted: true });
};
@@ -197,7 +176,6 @@ const SchedulerDialog = ({
const isDaySchedule = scheduleType === ScheduleFlag.SCHEDULE_DAY;
const isTimerSchedule = scheduleType === ScheduleFlag.SCHEDULE_TIMER;
- const isImmediateSchedule = scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE;
const needsTimeField = isDaySchedule || isTimerSchedule;
const dowFlags = getFlagDOWstring(editItem.flags);
@@ -214,7 +192,6 @@ const SchedulerDialog = ({
if (scheduleType === ScheduleFlag.SCHEDULE_TIMER) return LL.TIMER(1);
if (scheduleType === ScheduleFlag.SCHEDULE_CONDITION) return LL.CONDITION();
if (scheduleType === ScheduleFlag.SCHEDULE_ONCHANGE) return LL.ONCHANGE();
- if (scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE) return LL.IMMEDIATE();
return LL.TIME(1);
})();
@@ -269,14 +246,6 @@ const SchedulerDialog = ({
{LL.CONDITION()}
-
-
- {LL.IMMEDIATE()}
-
-
{isDaySchedule && (
@@ -294,74 +263,70 @@ const SchedulerDialog = ({
)}
- {!isImmediateSchedule && (
- <>
-
-
- }
- label={LL.ACTIVE()}
+
+
-
-
- {needsTimeField ? (
- <>
-
- {isTimerSchedule && (
-
- {LL.SCHEDULER_HELP_2()}
-
- )}
- >
- ) : (
-
+ }
+ label={LL.ACTIVE()}
+ />
+
+
+
+
+ {needsTimeField ? (
+ <>
+
+ {isTimerSchedule && (
+
+ 00:00 = {LL.SCHEDULER_HELP_2()}
+
)}
-
- >
- )}
-
+ >
+ ) : (
+
+ )}
+
+ >
+ {commandNames.map((name) => (
+
+ ))}
+
{creating ? LL.ADD(0) : LL.UPDATE()}
- {isImmediateSchedule && !creating && editItem.cmd !== '' && (
- }
- variant="outlined"
- onClick={execute}
- color="success"
- >
- {LL.EXECUTE()}
-
- )}
);
diff --git a/interface/src/app/main/types.ts b/interface/src/app/main/types.ts
index 391c22a01..137078120 100644
--- a/interface/src/app/main/types.ts
+++ b/interface/src/app/main/types.ts
@@ -354,16 +354,14 @@ export interface ScheduleItem {
deleted?: boolean;
flags: number;
time: string; // also used for Condition and On Change
- cmd: string;
- value: string;
+ cmd_name: string; // references a named Command
name: string;
o_id?: number;
o_active?: boolean;
o_deleted?: boolean;
o_flags?: number;
o_time?: string;
- o_cmd?: string;
- o_value?: string;
+ o_cmd_name?: string;
o_name?: string;
}
@@ -371,6 +369,23 @@ export interface Schedule {
readonly schedule: readonly ScheduleItem[];
}
+export interface CommandItem {
+ id: number;
+ cmd: string;
+ value: string;
+ name: string;
+ deleted?: boolean;
+ o_id?: number;
+ o_cmd?: string;
+ o_value?: string;
+ o_name?: string;
+ o_deleted?: boolean;
+}
+
+export interface Commands {
+ readonly commands: readonly CommandItem[];
+}
+
export interface ModuleItem {
id: number; // unique index
key: string;
@@ -401,8 +416,7 @@ export enum ScheduleFlag {
SCHEDULE_DAY = 0, // no bits set
SCHEDULE_TIMER = 128, // bit 8
SCHEDULE_ONCHANGE = 129, // bit 1
- SCHEDULE_CONDITION = 130, // bit 2
- SCHEDULE_IMMEDIATE = 132 // bit 3
+ SCHEDULE_CONDITION = 130 // bit 2
}
export interface EntityItem {
@@ -445,6 +459,7 @@ export const enum DeviceType {
ANALOGSENSOR = 2,
SCHEDULER = 3,
CUSTOM = 4,
+ COMMAND = 5,
BOILER,
THERMOSTAT,
MIXER,
diff --git a/interface/src/app/main/validators.ts b/interface/src/app/main/validators.ts
index 0c87821ea..a1c2e7d26 100644
--- a/interface/src/app/main/validators.ts
+++ b/interface/src/app/main/validators.ts
@@ -4,6 +4,7 @@ import { IP_OR_HOSTNAME_VALIDATOR } from 'validators/shared';
import type {
AnalogSensor,
+ CommandItem,
DeviceValue,
EntityItem,
ScheduleItem,
@@ -237,6 +238,24 @@ export const schedulerItemValidation = (
NAME_PATTERN_REQUIRED,
uniqueNameValidator(schedule, scheduleItem.o_name)
],
+ cmd_name: [{ required: true, message: 'Command is required' }]
+ });
+
+export const uniqueCommandNameValidator = (
+ commands: CommandItem[],
+ o_name?: string
+) => createUniqueNameValidator(commands, o_name);
+
+export const commandItemValidation = (
+ commands: CommandItem[],
+ commandItem: CommandItem
+) =>
+ new Schema({
+ name: [
+ { required: true, message: 'Name is required' },
+ NAME_PATTERN_REQUIRED,
+ uniqueCommandNameValidator(commands, commandItem.o_name)
+ ],
cmd: [
{ required: true, message: 'Command is required' },
{
diff --git a/interface/src/components/layout/LayoutMenu.tsx b/interface/src/components/layout/LayoutMenu.tsx
index 5d52921a6..bcfafac5d 100644
--- a/interface/src/components/layout/LayoutMenu.tsx
+++ b/interface/src/components/layout/LayoutMenu.tsx
@@ -8,6 +8,7 @@ import KeyboardArrowDown from '@mui/icons-material/KeyboardArrowDown';
import LiveHelpIcon from '@mui/icons-material/LiveHelp';
import MoreTimeIcon from '@mui/icons-material/MoreTime';
import PlaylistAddIcon from '@mui/icons-material/PlaylistAdd';
+import PlaylistPlayIcon from '@mui/icons-material/PlaylistPlay';
import SensorsIcon from '@mui/icons-material/Sensors';
import SettingsIcon from '@mui/icons-material/Settings';
import StarIcon from '@mui/icons-material/Star';
@@ -80,6 +81,12 @@ const LayoutMenuComponent = () => {
disabled={!me.admin}
to={`/customizations`}
/>
+
=14.0.0'}
- itty-router@5.0.23:
- resolution: {integrity: sha512-i49WU+SNPrwOZA4Z61En1RYd5h2Lcqa+5IvCpMrNi4dxymzJK15ozUUnRrWIUAv95Zamd4eJPAot2UvHRrQg7w==}
+ itty-router@5.0.24:
+ resolution: {integrity: sha512-PXij1qGKtE6jg3dLgrTRdh/Frovl5Wc4Av67sD2xfhDPrgNt4u79++/il8/c/OfZGkhD/8uYJub8tjo+H165rA==}
javascript-natural-sort@0.7.1:
resolution: {integrity: sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==}
@@ -167,8 +167,8 @@ packages:
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
- prettier@3.8.3:
- resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==}
+ prettier@3.8.4:
+ resolution: {integrity: sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==}
engines: {node: '>=14'}
hasBin: true
@@ -246,7 +246,7 @@ snapshots:
dependencies:
'@noble/hashes': 1.8.0
- '@trivago/prettier-plugin-sort-imports@6.0.2(prettier@3.8.3)':
+ '@trivago/prettier-plugin-sort-imports@6.0.2(prettier@3.8.4)':
dependencies:
'@babel/generator': 7.29.7
'@babel/parser': 7.29.7
@@ -256,7 +256,7 @@ snapshots:
lodash-es: 4.18.1
minimatch: 9.0.9
parse-imports-exports: 0.2.4
- prettier: 3.8.3
+ prettier: 3.8.4
transitivePeerDependencies:
- supports-color
@@ -283,7 +283,7 @@ snapshots:
dezalgo: 1.0.4
once: 1.4.0
- itty-router@5.0.23: {}
+ itty-router@5.0.24: {}
javascript-natural-sort@0.7.1: {}
@@ -311,6 +311,6 @@ snapshots:
picocolors@1.1.1: {}
- prettier@3.8.3: {}
+ prettier@3.8.4: {}
wrappy@1.0.2: {}
diff --git a/mock-api/restServer.ts b/mock-api/restServer.ts
index 362682e7d..48b92a76a 100644
--- a/mock-api/restServer.ts
+++ b/mock-api/restServer.ts
@@ -241,8 +241,7 @@ const enum ScheduleFlag {
SCHEDULE_DAY = 0,
SCHEDULE_TIMER = 128,
SCHEDULE_ONCHANGE = 129,
- SCHEDULE_CONDITION = 130,
- SCHEDULE_IMMEDIATE = 132
+ SCHEDULE_CONDITION = 130
}
const enum DeviceType {
@@ -251,6 +250,7 @@ const enum DeviceType {
ANALOGSENSOR,
SCHEDULER,
CUSTOM,
+ COMMAND,
BOILER,
THERMOSTAT,
MIXER,
@@ -271,6 +271,7 @@ const enum DeviceType {
}
const enum DeviceTypeUniqueID {
+ COMMAND_UID = 95,
SCHEDULER_UID = 96,
ANALOGSENSOR_UID = 97,
TEMPERATURESENSOR_UID = 98,
@@ -374,6 +375,8 @@ function export_data(type: string) {
return emsesp_customentities;
case 'schedule':
return emsesp_schedule;
+ case 'commands':
+ return emsesp_commands;
case 'modules':
return emsesp_modules;
case 'allvalues':
@@ -409,9 +412,9 @@ function custom_support() {
};
}
-// run a schedule
-function executeSchedule(name: string) {
- console.log('executing schedule', name);
+// run a command
+function executeCommand(name: string) {
+ console.log('executing command', name);
return status(200);
}
@@ -751,6 +754,7 @@ const EMSESP_RESET_CUSTOMIZATIONS_ENDPOINT =
REST_ENDPOINT_ROOT + 'resetCustomizations';
const EMSESP_SCHEDULE_ENDPOINT = REST_ENDPOINT_ROOT + 'schedule';
+const EMSESP_COMMANDS_ENDPOINT = REST_ENDPOINT_ROOT + 'commands';
const EMSESP_CUSTOMENTITIES_ENDPOINT = REST_ENDPOINT_ROOT + 'customEntities';
const EMSESP_MODULES_ENDPOINT = REST_ENDPOINT_ROOT + 'modules';
const EMSESP_ACTION_ENDPOINT = REST_ENDPOINT_ROOT + 'action';
@@ -4147,6 +4151,48 @@ let emsesp_customentities = {
]
};
+// COMMANDS
+let emsesp_commands = {
+ commands: [
+ {
+ id: 1,
+ cmd: 'hc1/mode',
+ value: 'day',
+ name: 'set_day_mode'
+ },
+ {
+ id: 2,
+ cmd: 'hc1/mode',
+ value: 'night',
+ name: 'set_night_mode'
+ },
+ {
+ id: 3,
+ cmd: 'thermostat/hc2/seltemp',
+ value: '20',
+ name: 'set_temp_20'
+ },
+ {
+ id: 4,
+ cmd: 'system/restart',
+ value: '',
+ name: 'restart_system'
+ },
+ {
+ id: 5,
+ cmd: 'boiler/selflowtemp',
+ value: '(custom/setpoint - boiler/outdoortemp) * 2.8 + 3',
+ name: 'heatingcurve'
+ },
+ {
+ id: 6,
+ cmd: 'system/message',
+ value: '"hello world"',
+ name: 'send_message'
+ }
+ ]
+};
+
// SCHEDULE
let emsesp_schedule = {
schedule: [
@@ -4155,8 +4201,7 @@ let emsesp_schedule = {
active: true,
flags: 6,
time: '07:30',
- cmd: 'hc1/mode',
- value: 'day',
+ cmd_name: 'set_day_mode',
name: 'day_mode'
},
{
@@ -4164,8 +4209,7 @@ let emsesp_schedule = {
active: true,
flags: 31,
time: '23:00',
- cmd: 'hc1/mode',
- value: 'night',
+ cmd_name: 'set_night_mode',
name: 'night_mode'
},
{
@@ -4173,8 +4217,7 @@ let emsesp_schedule = {
active: true,
flags: 10,
time: '00:00',
- cmd: 'thermostat/hc2/seltemp',
- value: '20',
+ cmd_name: 'set_temp_20',
name: 'temp_20'
},
{
@@ -4182,8 +4225,7 @@ let emsesp_schedule = {
active: false,
flags: ScheduleFlag.SCHEDULE_TIMER,
time: '04:00',
- cmd: 'system/restart',
- value: '',
+ cmd_name: 'restart_system',
name: 'auto_restart'
},
{
@@ -4191,8 +4233,7 @@ let emsesp_schedule = {
active: false,
flags: ScheduleFlag.SCHEDULE_CONDITION,
time: 'system/network info/rssi < -70',
- cmd: 'system/restart',
- value: '',
+ cmd_name: 'restart_system',
name: 'bad_wifi'
},
{
@@ -4200,18 +4241,16 @@ let emsesp_schedule = {
active: false,
flags: ScheduleFlag.SCHEDULE_ONCHANGE,
time: 'boiler/outdoortemp',
- cmd: 'boiler/selflowtemp',
- value: '(custom/setpoint - boiler/outdoortemp) * 2.8 + 3',
+ cmd_name: 'heatingcurve',
name: 'heatingcurve'
},
{
id: 7,
- active: false,
- flags: ScheduleFlag.SCHEDULE_IMMEDIATE,
+ active: true,
+ flags: ScheduleFlag.SCHEDULE_TIMER,
time: '',
- cmd: 'system/message',
- value: '"hello world"',
- name: 'send_message'
+ cmd_name: 'send_message',
+ name: 'on_boot'
}
]
};
@@ -4765,20 +4804,17 @@ router
}
// add the scheduler data
- // filter emsesp_schedule with only if it has a name and create data in one pass
- const namedSchedules = emsesp_schedule.schedule.filter((item) => item.name);
+ const namedSchedules = emsesp_schedule.schedule.filter(
+ (item: any) => item.name
+ );
if (namedSchedules.length > 0) {
- const scheduler_data = namedSchedules.map((item, index) => ({
+ const scheduler_data = namedSchedules.map((item: any, index: number) => ({
id: DeviceTypeUniqueID.SCHEDULER_UID * 100 + index,
dv: {
id: '00' + item.name,
c: item.name,
- ...(item.flags === ScheduleFlag.SCHEDULE_IMMEDIATE
- ? {}
- : {
- v: item.active ? 'on' : 'off',
- l: ['off', 'on']
- })
+ v: item.active ? 'on' : 'off',
+ l: ['off', 'on']
}
}));
dashboard_object = {
@@ -4788,6 +4824,26 @@ router
};
dashboard_nodes.push(dashboard_object);
}
+
+ // add the command items (executable from dashboard)
+ const namedCommands = emsesp_commands.commands.filter(
+ (item: any) => item.name
+ );
+ if (namedCommands.length > 0) {
+ const command_data = namedCommands.map((item: any, index: number) => ({
+ id: DeviceTypeUniqueID.COMMAND_UID * 100 + index,
+ dv: {
+ id: '00' + item.name,
+ c: item.name
+ }
+ }));
+ dashboard_object = {
+ id: DeviceTypeUniqueID.COMMAND_UID,
+ t: DeviceType.COMMAND,
+ nodes: command_data
+ };
+ dashboard_nodes.push(dashboard_object);
+ }
} else {
// for testing only
// add the custom entity data
@@ -4877,6 +4933,15 @@ router
return status(200);
})
+ // Commands
+ .post(EMSESP_COMMANDS_ENDPOINT, async (request: any) => {
+ const content = await request.json();
+ emsesp_commands = content;
+ console.log('commands saved', emsesp_commands);
+ return status(200);
+ })
+ .get(EMSESP_COMMANDS_ENDPOINT, () => emsesp_commands)
+
// Scheduler
.post(EMSESP_SCHEDULE_ENDPOINT, async (request: any) => {
const content = await request.json();
@@ -4977,15 +5042,17 @@ router
}
if (id === DeviceTypeUniqueID.SCHEDULER_UID) {
// toggle scheduler
- // find the schedule in emsesp_schedule via the name and toggle the active
const objIndex = emsesp_schedule.schedule.findIndex(
- (obj) => obj.name === command
+ (obj: any) => obj.name === command
);
if (objIndex !== -1) {
emsesp_schedule.schedule[objIndex].active = value;
console.log("Toggle schedule '" + command + "' to " + value);
}
}
+ if (id === DeviceTypeUniqueID.COMMAND_UID) {
+ console.log("Execute command '" + command + "'");
+ }
// await delay(1000); // wait to show spinner
// console.log(
@@ -5246,9 +5313,9 @@ router
} else if (action === 'upgradeImportantMessages') {
// check upgrade important messages
return upgradeImportantMessages(content.param);
- } else if (action === 'executeSchedule') {
- // execute schedule
- return executeSchedule(content.param);
+ } else if (action === 'executeCommand') {
+ // execute command
+ return executeCommand(content.param);
}
}
return status(404); // cmd not found
diff --git a/platformio.ini b/platformio.ini
index f35b66211..b7b5ec4c6 100644
--- a/platformio.ini
+++ b/platformio.ini
@@ -43,8 +43,6 @@ build_flags =
; ESPAsyncWebServer
; -D WS_MAX_QUEUED_MESSAGES=0 ; not used, default 8
; -D SSE_MAX_QUEUED_MESSAGES=1 ; for log messages, default 32
- -D EMSESP_SCHEDULER_RUNNING_CORE=1
- -D EMSESP_SCHEDULER_STACKSIZE=8192 ; 8KB
-D EMSESP_MQTT_RUNNING_CORE=1 ; default 1
; -D EMSESP_MQTT_STACKSIZE=5120 ; default
-D EMSESP_UART_RUNNING_CORE=1 ; default any core
@@ -92,7 +90,7 @@ board_build.littlefs_version = 2.0
lib_deps =
bblanchon/ArduinoJson @ 7.4.3
ESP32Async/AsyncTCP @ 3.4.10
- ESP32Async/ESPAsyncWebServer @ 3.11.0
+ ESP32Async/ESPAsyncWebServer @ 3.11.1
https://github.com/mobizt/ReadyMail.git @ 0.4.2
https://github.com/mobizt/ESP_SSLClient.git @ 3.1.3
; https://github.com/emsesp/EMS-ESP-Modules.git @ 1.0.8
diff --git a/project-words.txt b/project-words.txt
index 8d6209c83..e84ea5537 100644
--- a/project-words.txt
+++ b/project-words.txt
@@ -1341,4 +1341,5 @@ serialises
SPIRAM
optimisations
IILE
-Sumr
\ No newline at end of file
+Sumr
+eraseap
\ No newline at end of file
diff --git a/scripts/generate_test_api.py b/scripts/generate_test_api.py
index d881caa32..c66cbcc85 100644
--- a/scripts/generate_test_api.py
+++ b/scripts/generate_test_api.py
@@ -6,6 +6,8 @@ Workflow:
2. Extract everything between the START/END "CUT HERE" markers.
3. Write that block to test/test_api/test_api.h.
4. Run `pio run -e native-test -t exec`.
+
+run with `python3 scripts/generate_test_api.py`
"""
import subprocess
diff --git a/src/core/analogsensor.cpp b/src/core/analogsensor.cpp
index 170399e85..44893d958 100644
--- a/src/core/analogsensor.cpp
+++ b/src/core/analogsensor.cpp
@@ -25,7 +25,7 @@ uuid::log::Logger AnalogSensor::logger_{F_(analogsensor), uuid::log::Facility
std::vector AnalogSensor::exclude_types_;
#ifndef EMSESP_STANDALONE
-portMUX_TYPE mux = portMUX_INITIALIZER_UNLOCKED;
+portMUX_TYPE mux = portMUX_INITIALIZER_UNLOCKED;
volatile unsigned long AnalogSensor::edge[] = {0, 0, 0};
volatile unsigned long AnalogSensor::edgecnt[] = {0, 0, 0};
@@ -102,7 +102,7 @@ void AnalogSensor::start(const bool factory_settings) {
Command::add(
EMSdevice::DeviceType::ANALOGSENSOR,
F_(setvalue),
- [&](const char * value, const int8_t id) { return command_setvalue(value, id); },
+ [&](const char * value, const int8_t id, JsonObject output) { return command_setvalue(value, id); },
FL_(setiovalue_cmd),
CommandFlag::ADMIN_ONLY);
@@ -195,7 +195,7 @@ void AnalogSensor::reload(bool get_nvs) {
Command::add(
EMSdevice::DeviceType::ANALOGSENSOR,
sensor.name,
- [&](const char * value, const int8_t id) { return command_setvalue(value, sensor.gpio); },
+ [&](const char * value, const int8_t id, JsonObject output) { return command_setvalue(value, sensor.gpio); },
sensor.type == AnalogType::COUNTER || (sensor.type >= AnalogType::CNT_0 && sensor.type <= AnalogType::CNT_2) ? FL_(counter)
: sensor.type == AnalogType::DIGITAL_OUT ? FL_(digital_out)
: sensor.type == AnalogType::RGB ? FL_(RGB)
@@ -671,7 +671,7 @@ void AnalogSensor::publish_values(const bool force) {
publish_sensor(sensor);
}
return;
- } else if (!EMSESP::mqtt_.get_publish_onchange(0)) {
+ } else if (!EMSESP::mqtt_.get_publish_onchange(EMSdevice::DeviceType::SYSTEM)) {
return; // wait for first time period
}
}
diff --git a/src/core/command.cpp b/src/core/command.cpp
index 39cc17c1a..a0e9f41c6 100644
--- a/src/core/command.cpp
+++ b/src/core/command.cpp
@@ -378,7 +378,12 @@ uint8_t Command::call(const uint8_t device_type, const char * command, const cha
if (!strcmp(cmd, F_(commands))) {
return Command::list(device_type, output);
}
- if (EMSESP::get_device_value_info(output, cmd, id, device_type)) { // entity = cmd
+ // for the Commands device, calling a named command executes it (using its stored value)
+ // rather than returning its definition, so skip the value-info lookup and fall through
+ // to the registered command function. The list keywords above are still handled.
+ bool is_named_command = (device_type == EMSdevice::DeviceType::COMMAND) && strcmp(cmd, F_(info)) && strcmp(cmd, F_(values))
+ && strcmp(cmd, F_(entities)) && strcmp(cmd, F_(metrics));
+ if (!is_named_command && EMSESP::get_device_value_info(output, cmd, id, device_type)) { // entity = cmd
LOG_DEBUG("Fetched device entity/attributes for %s/%s (id=%d)", dname, cmd, id);
return CommandRet::OK;
}
@@ -438,16 +443,13 @@ uint8_t Command::call(const uint8_t device_type, const char * command, const cha
// call the function based on command function type
// commands return true or false only (bool)
uint8_t return_code = CommandRet::OK;
- if (cf->cmdfunction_json_) {
- // handle commands that report back a JSON body
- return_code = ((cf->cmdfunction_json_)(value, id, output)) ? CommandRet::OK : CommandRet::ERROR;
- } else if (cf->cmdfunction_) {
- // if it's a read only command and we're trying to set a value, return an error
- if (!single_command && EMSESP::cmd_is_readonly(device_type, device_id, cmd, id)) {
+ if (cf->cmdfunction_) {
+ // JSON-output commands bypass the readonly check; for the rest, reject a write to a read-only entity
+ if (!cf->has_json_output_ && !single_command && EMSESP::cmd_is_readonly(device_type, device_id, cmd, id)) {
return_code = CommandRet::INVALID; // error on readonly or invalid hc
} else {
- // call the command...
- return_code = ((cf->cmdfunction_)(value, id)) ? CommandRet::OK : CommandRet::ERROR;
+ // call the command (the output object is ignored by non-JSON commands)
+ return_code = ((cf->cmdfunction_)(value, id, output)) ? CommandRet::OK : CommandRet::ERROR;
}
}
@@ -501,7 +503,7 @@ void Command::add(const uint8_t device_type, const uint8_t device_id, const char
flags |= CommandFlag::HIDDEN;
}
- cmdfunctions_.emplace_back(device_type, device_id, flags, cmd, cb, nullptr, description); // callback for json is nullptr
+ cmdfunctions_.emplace_back(device_type, device_id, flags, false, cmd, cb, description); // not a json-output command
}
// add a command with no json output
@@ -511,13 +513,14 @@ void Command::add(const uint8_t device_type, const char * cmd, const cmd_functio
}
// add a command to the list, which does return a json object as output
-void Command::add(const uint8_t device_type, const char * cmd, const cmd_json_function_p cb, const char * const * description, uint8_t flags) {
+// these commands bypass the readonly check (they are actions, not entity setters)
+void Command::add_json(const uint8_t device_type, const char * cmd, const cmd_function_p cb, const char * const * description, uint8_t flags) {
// if the command already exists for that device type don't add it
if (find_command(device_type, 0, cmd, flags) != nullptr) {
return;
}
- cmdfunctions_.emplace_back(device_type, 0, flags, cmd, nullptr, cb, description); // callback for json is included
+ cmdfunctions_.emplace_back(device_type, 0, flags, true, cmd, cb, description); // json-output command
}
// see if a command exists for that device type
@@ -716,6 +719,10 @@ bool Command::device_has_commands(const uint8_t device_type) {
return true;
}
+ if (device_type == EMSdevice::DeviceType::COMMAND) {
+ return true;
+ }
+
if (device_type == EMSdevice::DeviceType::CUSTOM) {
return true;
}
@@ -741,6 +748,7 @@ bool Command::device_has_commands(const uint8_t device_type) {
void Command::show_devices(uuid::console::Shell & shell) {
shell.printf("%s ", EMSdevice::device_type_2_device_name(EMSdevice::DeviceType::SYSTEM));
shell.printf("%s ", EMSdevice::device_type_2_device_name(EMSdevice::DeviceType::CUSTOM));
+ shell.printf("%s ", EMSdevice::device_type_2_device_name(EMSdevice::DeviceType::COMMAND));
shell.printf("%s ", EMSdevice::device_type_2_device_name(EMSdevice::DeviceType::SCHEDULER));
if (EMSESP::sensor_enabled()) {
shell.printf("%s ", EMSdevice::device_type_2_device_name(EMSdevice::DeviceType::TEMPERATURESENSOR));
@@ -779,6 +787,7 @@ void Command::show_all(uuid::console::Shell & shell) {
// show system ones first
show(shell, EMSdevice::DeviceType::SYSTEM, true);
show(shell, EMSdevice::DeviceType::CUSTOM, true);
+ show(shell, EMSdevice::DeviceType::COMMAND, true);
show(shell, EMSdevice::DeviceType::SCHEDULER, true);
// then sensors
diff --git a/src/core/command.h b/src/core/command.h
index 2c9cffc91..2644a29b4 100644
--- a/src/core/command.h
+++ b/src/core/command.h
@@ -54,33 +54,32 @@ enum CommandRet : uint8_t {
NO_VALUE // 6 - no value
};
-using cmd_function_p = std::function;
-using cmd_json_function_p = std::function;
+using cmd_function_p = std::function;
class Command {
public:
struct CmdFunction {
- uint8_t device_type_; // DeviceType::
+ uint8_t device_type_; // DeviceType::
uint8_t device_id_;
- uint8_t flags_; // mqtt flags for command subscriptions
+ uint8_t flags_; // mqtt flags for command subscriptions
+ bool has_json_output_; // true if the command writes JSON output; such commands bypass the readonly check
const char * cmd_;
cmd_function_p cmdfunction_;
- cmd_json_function_p cmdfunction_json_;
const char * const * description_;
- CmdFunction(const uint8_t device_type,
- const uint8_t device_id,
- const uint8_t flags,
- const char * cmd,
- const cmd_function_p cmdfunction,
- const cmd_json_function_p cmdfunction_json,
- const char * const * description)
+ CmdFunction(const uint8_t device_type,
+ const uint8_t device_id,
+ const uint8_t flags,
+ const bool has_json_output,
+ const char * cmd,
+ const cmd_function_p cmdfunction,
+ const char * const * description)
: device_type_(device_type)
, device_id_(device_id)
, flags_(flags)
+ , has_json_output_(has_json_output)
, cmd_(cmd)
, cmdfunction_(cmdfunction)
- , cmdfunction_json_(cmdfunction_json)
, description_(description) {
}
@@ -116,12 +115,13 @@ class Command {
// same for system/temperature/analog devices
static void
add(const uint8_t device_type, const char * cmd, const cmd_function_p cb, const char * const * description, uint8_t flags = CommandFlag::CMD_FLAG_DEFAULT);
- // callback function taking value, id and a json object for its output
- static void add(const uint8_t device_type,
- const char * cmd,
- const cmd_json_function_p cb,
- const char * const * description,
- uint8_t flags = CommandFlag::CMD_FLAG_DEFAULT);
+ // command that writes a JSON object as its output; bypasses the readonly check
+ static void
+ add_json(const uint8_t device_type, const char * cmd, const cmd_function_p cb, const char * const * description, uint8_t flags = CommandFlag::CMD_FLAG_DEFAULT);
+
+ static void reserve(size_t num) {
+ cmdfunctions_.reserve(num);
+ }
static void show_all(uuid::console::Shell & shell);
static Command::CmdFunction * find_command(const uint8_t device_type, const uint8_t device_id, const char * cmd, const uint8_t flag);
diff --git a/src/core/console.cpp b/src/core/console.cpp
index eb2d5146a..ef6275714 100644
--- a/src/core/console.cpp
+++ b/src/core/console.cpp
@@ -624,15 +624,15 @@ void EMSESPShell::stopped() {
// show welcome banner
void EMSESPShell::display_banner() {
println();
- printfln("┌───────────────────────────────────────┐");
- printfln("│ %sEMS-ESP version %-20s%s │", COLOR_BOLD_ON, EMSESP_APP_VERSION, COLOR_BOLD_OFF);
- printfln("│ │");
- printfln("│ %shelp%s to show available commands │", COLOR_UNDERLINE, COLOR_RESET);
- printfln("│ %ssu%s to access admin commands │", COLOR_UNDERLINE, COLOR_RESET);
- printfln("│ │");
- printfln("│ %s%shttps://github.com/emsesp/EMS-ESP32%s │", COLOR_BRIGHT_GREEN, COLOR_UNDERLINE, COLOR_RESET);
- printfln("│ │");
- printfln("└───────────────────────────────────────┘");
+ printfln("┌─────────────────────────────────────┐");
+ printfln("│ EMS-ESP version %-18s │", EMSESP_APP_VERSION);
+ printfln("│ │");
+ printfln("│ %shelp%s to show available commands │", COLOR_UNDERLINE, COLOR_RESET);
+ printfln("│ %ssu%s to access admin commands │", COLOR_UNDERLINE, COLOR_RESET);
+ printfln("│ │");
+ printfln("│ %s%shttps://emsesp.org%s │", COLOR_GREEN, COLOR_UNDERLINE, COLOR_RESET);
+ printfln("│ │");
+ printfln("└─────────────────────────────────────┘");
println();
// set console name
diff --git a/src/core/emsdevice.cpp b/src/core/emsdevice.cpp
index 8b7ed7c11..41a83ecb8 100644
--- a/src/core/emsdevice.cpp
+++ b/src/core/emsdevice.cpp
@@ -145,6 +145,8 @@ const char * EMSdevice::device_type_2_device_name(const uint8_t device_type) {
return F_(scheduler);
case DeviceType::CUSTOM:
return F_(custom);
+ case DeviceType::COMMAND:
+ return F_(commands);
case DeviceType::BOILER:
return F_(boiler);
case DeviceType::THERMOSTAT:
@@ -297,6 +299,9 @@ uint8_t EMSdevice::device_name_2_device_type(const char * topic) {
if (!strcmp(lowtopic, F_(scheduler))) {
return DeviceType::SCHEDULER;
}
+ if (!strcmp(lowtopic, F_(commands))) {
+ return DeviceType::COMMAND;
+ }
if (!strcmp(lowtopic, F_(system))) {
return DeviceType::SYSTEM;
}
diff --git a/src/core/emsdevice.h b/src/core/emsdevice.h
index 44e781f88..93a0595f9 100644
--- a/src/core/emsdevice.h
+++ b/src/core/emsdevice.h
@@ -405,6 +405,7 @@ class EMSdevice {
// Unique Identifiers for each Device type, used in Dashboard table
// 100 and above is reserved for DeviceType
enum DeviceTypeUniqueID : uint8_t {
+ COMMAND_UID = 95,
SCHEDULER_UID = 96,
ANALOGSENSOR_UID = 97,
TEMPERATURESENSOR_UID = 98,
@@ -417,6 +418,7 @@ class EMSdevice {
ANALOGSENSOR, // for internal analog sensors
SCHEDULER, // for internal schedule
CUSTOM, // for user defined entities
+ COMMAND, // for user defined commands
BOILER, // from here on enum the ems-devices
THERMOSTAT,
MIXER,
diff --git a/src/core/emsesp.cpp b/src/core/emsesp.cpp
index 202bf3d59..582e94fdd 100644
--- a/src/core/emsesp.cpp
+++ b/src/core/emsesp.cpp
@@ -51,22 +51,20 @@ uint32_t EMSESP::last_fetch_ = 0;
AsyncWebServer webServer(80);
#if defined(EMSESP_STANDALONE)
-FS dummyFS;
-ESP32React EMSESP::esp32React(&webServer, &dummyFS);
-WebSettingsService EMSESP::webSettingsService = WebSettingsService(&webServer, &dummyFS, EMSESP::esp32React.getSecurityManager());
-WebCustomizationService EMSESP::webCustomizationService = WebCustomizationService(&webServer, &dummyFS, EMSESP::esp32React.getSecurityManager());
-WebSchedulerService EMSESP::webSchedulerService = WebSchedulerService(&webServer, &dummyFS, EMSESP::esp32React.getSecurityManager());
-WebCustomEntityService EMSESP::webCustomEntityService = WebCustomEntityService(&webServer, &dummyFS, EMSESP::esp32React.getSecurityManager());
-WebModulesService EMSESP::webModulesService = WebModulesService(&webServer, &dummyFS, EMSESP::esp32React.getSecurityManager());
+FS dummyFS;
+auto & fsRef = dummyFS;
#else
-ESP32React EMSESP::esp32React(&webServer, &LittleFS);
-WebSettingsService EMSESP::webSettingsService = WebSettingsService(&webServer, &LittleFS, EMSESP::esp32React.getSecurityManager());
-WebCustomizationService EMSESP::webCustomizationService = WebCustomizationService(&webServer, &LittleFS, EMSESP::esp32React.getSecurityManager());
-WebSchedulerService EMSESP::webSchedulerService = WebSchedulerService(&webServer, &LittleFS, EMSESP::esp32React.getSecurityManager());
-WebCustomEntityService EMSESP::webCustomEntityService = WebCustomEntityService(&webServer, &LittleFS, EMSESP::esp32React.getSecurityManager());
-WebModulesService EMSESP::webModulesService = WebModulesService(&webServer, &LittleFS, EMSESP::esp32React.getSecurityManager());
+auto & fsRef = LittleFS;
#endif
+ESP32React EMSESP::esp32React(&webServer, &fsRef);
+WebSettingsService EMSESP::webSettingsService = WebSettingsService(&webServer, &fsRef, EMSESP::esp32React.getSecurityManager());
+WebCustomizationService EMSESP::webCustomizationService = WebCustomizationService(&webServer, &fsRef, EMSESP::esp32React.getSecurityManager());
+WebSchedulerService EMSESP::webSchedulerService = WebSchedulerService(&webServer, &fsRef, EMSESP::esp32React.getSecurityManager());
+WebCommandService EMSESP::webCommandService = WebCommandService(&webServer, &fsRef, EMSESP::esp32React.getSecurityManager());
+WebCustomEntityService EMSESP::webCustomEntityService = WebCustomEntityService(&webServer, &fsRef, EMSESP::esp32React.getSecurityManager());
+WebModulesService EMSESP::webModulesService = WebModulesService(&webServer, &fsRef, EMSESP::esp32React.getSecurityManager());
+
WebActivityService EMSESP::webActivityService = WebActivityService(&webServer, EMSESP::esp32React.getSecurityManager());
WebStatusService EMSESP::webStatusService = WebStatusService(&webServer, EMSESP::esp32React.getSecurityManager());
WebDataService EMSESP::webDataService = WebDataService(&webServer, EMSESP::esp32React.getSecurityManager());
@@ -682,6 +680,7 @@ void EMSESP::publish_other_values() {
// publish_device_values(EMSdevice::DeviceType::GENERIC);
webSchedulerService.publish();
+ webCommandService.publish();
webCustomEntityService.publish();
}
@@ -788,6 +787,11 @@ bool EMSESP::get_device_value_info(JsonObject root, const char * cmd, const int8
return webSchedulerService.get_value_info(root, cmd);
}
+ // commands
+ if (devicetype == DeviceType::COMMAND) {
+ return webCommandService.get_value_info(root, cmd);
+ }
+
// custom entities
if (devicetype == DeviceType::CUSTOM) {
return webCustomEntityService.get_value_info(root, cmd);
@@ -915,7 +919,6 @@ std::string EMSESP::pretty_telegram(const std::shared_ptr & tele
}
}
- // Optimized: Use stack buffer and build string once to avoid multiple temporary allocations
char buf[250];
if (telegram->operation == Telegram::Operation::RX_READ) {
auto pos = snprintf(buf,
@@ -1128,7 +1131,7 @@ bool EMSESP::process_telegram(const std::shared_ptr & telegram)
wait_validate_ = 0;
}
- // Check for custom entities reding this telegram
+ // check for custom entities reding this telegram
webCustomEntityService.get_value(telegram);
// check for common types, like the Version(0x02)
@@ -1179,6 +1182,7 @@ bool EMSESP::process_telegram(const std::shared_ptr & telegram)
}
}
}
+
// handle unknown telegrams
if (!telegram_found) {
// mark nonempty telegrams as ignored
@@ -1760,6 +1764,7 @@ void EMSESP::start() {
// start the core web services, as this loads the settings from the filesystem
// this will also handle any MQTT subscriptions
webCustomizationService.begin(); // load the customizations
+ webCommandService.begin(); // load the user commands
webSchedulerService.begin(); // load the scheduler events
webCustomEntityService.begin(); // load the custom telegram reads
@@ -1853,9 +1858,7 @@ void EMSESP::loop() {
publish_all_loop(); // with HA messages in parts to avoid flooding the MQTT queue
mqtt_.loop(); // sends out anything in the MQTT queue
webModulesService.loop(); // loop through the external library modules
- if (system_.PSram() == 0) { // run non-async if there is no PSRAM available
- webSchedulerService.loop();
- }
+ webSchedulerService.loop(); // scheduler timing logic; command execution is offloaded to WebCommandService's worker task
scheduled_fetch_values(); // force a query on the EMS devices to fetch latest data at a set interval (1 min)
}
// check for GPIO Errors - this is called once when booting
diff --git a/src/core/emsesp.h b/src/core/emsesp.h
index 920f16309..e59db86ae 100644
--- a/src/core/emsesp.h
+++ b/src/core/emsesp.h
@@ -51,6 +51,7 @@
#include "../web/WebSettingsService.h"
#include "../web/WebCustomizationService.h"
#include "../web/WebSchedulerService.h"
+#include "../web/WebCommandService.h"
#include "../web/WebAPIService.h"
#include "../web/WebLogService.h"
#include "../web/WebCustomEntityService.h"
@@ -97,7 +98,9 @@ class Module {}; // forward declaration
return +[](emsesp::EMSdevice * dev, const std::shared_ptr & t) { static_cast(dev)->__f(t); }; \
}())
-#define MAKE_CF_CB(__f) [&](const char * value, const int8_t id) { return __f(value, id); } // for Command Function callbacks Command::cmd_function_p
+// for Command Function callbacks (Command::cmd_function_p). The unified callback takes a JsonObject
+// output which entity/setter commands ignore.
+#define MAKE_CF_CB(__f) [&](const char * value, const int8_t id, JsonObject output) { return __f(value, id); }
namespace emsesp {
@@ -260,6 +263,7 @@ class EMSESP {
static WebLogService webLogService;
static WebCustomizationService webCustomizationService;
static WebSchedulerService webSchedulerService;
+ static WebCommandService webCommandService;
static WebCustomEntityService webCustomEntityService;
static WebModulesService webModulesService;
diff --git a/src/core/firmwareVersion.cpp b/src/core/firmwareVersion.cpp
index de2aed742..a63350012 100644
--- a/src/core/firmwareVersion.cpp
+++ b/src/core/firmwareVersion.cpp
@@ -19,6 +19,7 @@
#include "firmwareVersion.h"
#include
+#include
#include
namespace emsesp {
@@ -47,6 +48,66 @@ const std::string & FirmwareVersion::prerelease() const {
return prerelease_;
}
+// semver prerelease ordering: a release (empty tag) ranks higher than any prerelease,
+// and dot-separated numeric identifiers are compared numerically (so dev.9 < dev.12).
+// returns <0, 0 or >0
+static int compare_prerelease(const std::string & a, const std::string & b) {
+ if (a == b) {
+ return 0;
+ }
+ if (a.empty()) {
+ return 1; // release > prerelease
+ }
+ if (b.empty()) {
+ return -1;
+ }
+
+ size_t ia = 0;
+ size_t ib = 0;
+ while (ia < a.size() && ib < b.size()) {
+ size_t ea = a.find('.', ia);
+ size_t eb = b.find('.', ib);
+ if (ea == std::string::npos) {
+ ea = a.size();
+ }
+ if (eb == std::string::npos) {
+ eb = b.size();
+ }
+ std::string id_a = a.substr(ia, ea - ia);
+ std::string id_b = b.substr(ib, eb - ib);
+
+ bool num_a = !id_a.empty() && id_a.find_first_not_of("0123456789") == std::string::npos;
+ bool num_b = !id_b.empty() && id_b.find_first_not_of("0123456789") == std::string::npos;
+
+ if (num_a && num_b) {
+ long va = atol(id_a.c_str());
+ long vb = atol(id_b.c_str());
+ if (va != vb) {
+ return (va < vb) ? -1 : 1;
+ }
+ } else if (num_a != num_b) {
+ return num_a ? -1 : 1; // numeric identifiers rank lower than alphanumeric ones
+ } else {
+ int cmp = id_a.compare(id_b);
+ if (cmp != 0) {
+ return (cmp < 0) ? -1 : 1;
+ }
+ }
+
+ ia = ea + 1;
+ ib = eb + 1;
+ }
+
+ // all shared identifiers are equal; the one with more identifiers ranks higher
+ if (ia < a.size()) {
+ return 1;
+ }
+ if (ib < b.size()) {
+ return -1;
+ }
+ return 0;
+}
+
bool operator<(const FirmwareVersion & a, const FirmwareVersion & b) {
if (a.major_ != b.major_)
return a.major_ < b.major_;
@@ -54,7 +115,7 @@ bool operator<(const FirmwareVersion & a, const FirmwareVersion & b) {
return a.minor_ < b.minor_;
if (a.patch_ != b.patch_)
return a.patch_ < b.patch_;
- return a.prerelease_ < b.prerelease_;
+ return compare_prerelease(a.prerelease_, b.prerelease_) < 0;
}
bool operator>(const FirmwareVersion & a, const FirmwareVersion & b) {
@@ -62,7 +123,7 @@ bool operator>(const FirmwareVersion & a, const FirmwareVersion & b) {
}
bool operator==(const FirmwareVersion & a, const FirmwareVersion & b) {
- return a.major_ == b.major_ && a.minor_ == b.minor_ && a.patch_ == b.patch_ && a.prerelease_ == b.prerelease_;
+ return a.major_ == b.major_ && a.minor_ == b.minor_ && a.patch_ == b.patch_ && compare_prerelease(a.prerelease_, b.prerelease_) == 0;
}
bool operator!=(const FirmwareVersion & a, const FirmwareVersion & b) {
diff --git a/src/core/firmwareVersion.h b/src/core/firmwareVersion.h
index 8076b14c5..3f6639eb1 100644
--- a/src/core/firmwareVersion.h
+++ b/src/core/firmwareVersion.h
@@ -37,7 +37,6 @@ class FirmwareVersion {
int patch() const;
const std::string & prerelease() const;
- // Numeric-only comparison (major.minor.patch). Prerelease tags are ignored on purpose.
friend bool operator<(const FirmwareVersion & a, const FirmwareVersion & b);
friend bool operator>(const FirmwareVersion & a, const FirmwareVersion & b);
friend bool operator==(const FirmwareVersion & a, const FirmwareVersion & b);
diff --git a/src/core/helpers.cpp b/src/core/helpers.cpp
index 8098f93d8..52d37b50b 100644
--- a/src/core/helpers.cpp
+++ b/src/core/helpers.cpp
@@ -34,7 +34,6 @@ char * Helpers::hextoa(char * result, const uint8_t value) {
}
// same as hextoa but uses to a hex std::string
-// Optimized: Avoid string concatenation to reduce temporary allocations
std::string Helpers::hextoa(const uint8_t value, bool prefix) {
if (prefix) {
char buf[5]; // "0x" + 2 hex chars + null
@@ -60,7 +59,6 @@ char * Helpers::hextoa(char * result, const uint16_t value) {
}
// same as above but to a hex string
-// Optimized: Avoid string concatenation to reduce temporary allocations
std::string Helpers::hextoa(const uint16_t value, bool prefix) {
if (prefix) {
char buf[7]; // "0x" + 4 hex chars + null
@@ -114,7 +112,6 @@ char * Helpers::ultostr(char * ptr, uint32_t value, const uint8_t base) {
// fast itoa returning a std::string
// http://www.strudel.org.uk/itoa/
-// Optimized: Use stack buffer to avoid heap allocation, then create string once
std::string Helpers::itoa(int16_t value) {
// int16_t max: -32768 to 32767 = max 6 chars + null
char buf[8];
@@ -140,7 +137,7 @@ std::string Helpers::itoa(int16_t value) {
/*
* fast itoa
* written by Lukás Chmela, Released under GPLv3. http://www.strudel.org.uk/itoa/ version 0.4
- * optimized for ESP32
+ * optimized for ESP32 for EMS-ESP
*/
char * Helpers::itoa(int32_t value, char * result, const uint8_t base) {
// check that the base if valid
diff --git a/src/core/locale_translations.h b/src/core/locale_translations.h
index 06e397e7a..f5ece66ac 100644
--- a/src/core/locale_translations.h
+++ b/src/core/locale_translations.h
@@ -75,7 +75,8 @@ MAKE_WORD_TRANSLATION(format_cmd, "factory reset EMS-ESP", "EMS-ESP auf Werksein
MAKE_WORD_TRANSLATION(watch_cmd, "watch incoming telegrams", "Beobachte eingehende Telegramme", "inkomende telegrammen bekijken", "visa inkommande telegram", "obserwuj przyczodzące telegramy", "se innkommende telegrammer", "surveiller les télégrammes entrants", "Gelen telegramları izle", "guardare i telegrammi in arrivo", "sledovať prichádzajúce telegramy", "sledovat příchozí telegramy")
MAKE_WORD_TRANSLATION(publish_cmd, "publish all to MQTT", "Publiziere MQTT", "publiceer alles naar MQTT", "publicera allt till MQTT", "opublikuj wszystko na MQTT", "Publiser alt til MQTT", "publier tout vers MQTT", "Hepsini MQTTye gönder", "pubblica tutto su MQTT", "zverejniť všetko na MQTT", "publikovat vše do MQTT")
MAKE_WORD_TRANSLATION(system_info_cmd, "show system info", "Zeige Systeminformationen", "toon systeemstatus", "visa systeminformation", "pokaż status systemu", "vis system status", "afficher les informations système", "Sistem Durumunu Göster", "visualizza stati di sistema", "zobraziť stav systému", "zobrazit informace o systému")
-MAKE_WORD_TRANSLATION(schedule_cmd, "enable schedule item", "Aktiviere Zeitplanelemente", "activeer tijdschema item", "aktivera schemalagt objekt", "aktywuj wybrany harmonogram", "aktiver planlagt element", "activer élément programmé", "program öğesini etkinleştir", "abilitare l'elemento programmato", "povoliť položku plánovania", "povolit položku plánování")
+MAKE_WORD_TRANSLATION(schedule_cmd, "enable/disable schedule item", "Aktiviere/Deaktiviere Zeitplanelemente", "activeer/deactiveer tijdschema item", "aktivera/deaktivera schemalagt objekt", "aktywuj/deaktywuj wybrany harmonogram", "aktiver/deaktiver planlagt element", "activer/deactiver élément programmé", "program öğesini etkinleştir/devre dışı bırak", "abilitare/disabilitare l'elemento programmato", "povoliť/deaktivovať položku plánovania", "povolit/deaktivovat položku plánování")
+MAKE_WORD_TRANSLATION(command_cmd, "execute command", "Befehl ausführen", "opdracht uitvoeren", "kör kommando", "wykonaj polecenie", "kjør kommando", "exécuter commande", "komut çalıştır", "esegui comando", "vykonať príkaz", "provést příkaz")
MAKE_WORD_TRANSLATION(entity_cmd, "set custom value", "Sende eigene Entitäten", "verstuur custom waarde", "sätt ett eget värde", "wyślij własną wartość", "sett egendefinert verdi", "définir valeur personnalisée", "özel değer ayarla", "imposta valori personalizzati", "nastaviť vlastnú hodnotu", "nastavit vlastní hodnotu")
MAKE_WORD_TRANSLATION(commands_response, "get response", "Hole Antwort", "Verzoek om antwoord", "hämta svar", "uzyskaj odpowiedź", "få svar", "obtenir réponse", "yanıt al", "ottieni risposta", "získať odpoveď", "získat odpověď")
MAKE_WORD_TRANSLATION(coldshot_cmd, "send a cold shot of water", "Zugabe einer Menge kalten Wassers", "stuur koud water", "sckicka en liten mängd kallvatten", "uruchom tryśnięcie zimnej wody", "send kaldtvannspuls", "envoyer de l'eau froide", "soğuk su gönder", "invia acqua fredda", "pošlite studenú dávku vody", "poslat studenou vodu")
diff --git a/src/core/mqtt.cpp b/src/core/mqtt.cpp
index 44e9c85cc..5c77f7b9c 100644
--- a/src/core/mqtt.cpp
+++ b/src/core/mqtt.cpp
@@ -378,7 +378,7 @@ void Mqtt::start() {
initialized_ = true;
// add the 'publish' command ('call system publish' in console or via API)
- Command::add(EMSdevice::DeviceType::SYSTEM, F_(publish), System::command_publish, FL_(publish_cmd));
+ Command::add(EMSdevice::DeviceType::SYSTEM, F_(publish), MAKE_CF_CB(System::command_publish), FL_(publish_cmd));
#if defined(EMSESP_STANDALONE)
Mqtt::on_connect(); // simulate an MQTT connection
@@ -510,6 +510,7 @@ void Mqtt::on_connect() {
// send initial MQTT messages for some of our services
EMSESP::system_.send_heartbeat(); // send heartbeat
EMSESP::webCustomEntityService.publish(true);
+ EMSESP::webCommandService.publish(true);
EMSESP::webSchedulerService.publish(true);
EMSESP::analogsensor_.publish_values(true);
EMSESP::temperaturesensor_.publish_values(true);
diff --git a/src/core/network.cpp b/src/core/network.cpp
index 90ae94d01..b732a9d8f 100644
--- a/src/core/network.cpp
+++ b/src/core/network.cpp
@@ -82,7 +82,7 @@ void Network::begin() {
WiFi.persistent(false);
WiFi.setAutoReconnect(false);
WiFi.mode(WIFI_STA);
- WiFi.disconnect(true, true); // wipe old settings in NVS
+ WiFi.disconnect(true, true); // wipe old settings in NVS. Will give a warning on boot but can be ignored.
WiFi.setScanMethod(WIFI_ALL_CHANNEL_SCAN);
WiFi.setHostname(hostname_.c_str()); // updates shared default_hostname buffer
WiFi.enableSTA(true); // creates the STA netif
@@ -308,7 +308,11 @@ void Network::checkConnection() {
network_ip_ = 0;
has_ipv6_ = false;
connect_retry_ = 0;
- if (network_iface_ == NetIface::ETHERNET) {
+ // reset the active interface so findNetworks() treats the next link-up (even on the
+ // same interface) as a new connection and re-runs the setup, including startmDNS()
+ const NetIface lost_iface = network_iface_;
+ network_iface_ = NetIface::NONE;
+ if (lost_iface == NetIface::ETHERNET) {
LOG_WARNING("Ethernet connection lost");
return;
}
diff --git a/src/core/shower.cpp b/src/core/shower.cpp
index 105b155bb..cbbafdf61 100644
--- a/src/core/shower.cpp
+++ b/src/core/shower.cpp
@@ -33,7 +33,7 @@ void Shower::start() {
shower_min_duration_ = settings.shower_min_duration; // in seconds
});
- Command::add(
+ Command::add_json(
EMSdevice::DeviceType::BOILER,
F_(coldshot),
[&](const char * value, const int8_t id, JsonObject output) {
diff --git a/src/core/shuntingYard.cpp b/src/core/shuntingYard.cpp
index 9f833892a..7041ca49f 100644
--- a/src/core/shuntingYard.cpp
+++ b/src/core/shuntingYard.cpp
@@ -27,7 +27,7 @@
namespace emsesp {
-// find tokens - optimized to reduce string allocations
+// find tokens
std::deque exprToTokens(const std::string & expr) {
std::deque tokens;
@@ -231,7 +231,7 @@ std::deque exprToTokens(const std::string & expr) {
return tokens;
}
-// sort tokens to RPN form - optimized for memory usage
+// sort tokens to RPN form
std::deque shuntingYard(const std::deque & tokens) {
std::deque queue;
std::vector stack;
@@ -347,7 +347,6 @@ bool isnum(const std::string & s) {
std::string commands(std::string & expr, bool quotes) {
auto expr_new = Helpers::toLower(expr);
for (uint8_t device = 0; device < EMSdevice::DeviceType::UNKNOWN; device++) {
- // Optimized: build string with reserve to avoid temporary allocations
std::string d;
d.reserve(32); // typical device name length + "/"
d = EMSdevice::device_type_2_device_name(device);
@@ -374,8 +373,7 @@ std::string commands(std::string & expr, bool quotes) {
JsonDocument doc_in;
JsonObject output = doc_out.to();
JsonObject input = doc_in.to();
- // Optimized: use stack buffer for small strings to avoid heap allocation
- char cmd_s[COMMAND_MAX_LENGTH + 5]; // "api/" prefix + cmd
+ char cmd_s[COMMAND_MAX_LENGTH + 5]; // "api/" prefix + cmd
snprintf(cmd_s, sizeof(cmd_s), "api/%s", cmd);
auto return_code = Command::process(cmd_s, true, input, output);
diff --git a/src/core/system.cpp b/src/core/system.cpp
index 3190fc77e..208e657c5 100644
--- a/src/core/system.cpp
+++ b/src/core/system.cpp
@@ -990,22 +990,24 @@ void System::system_check() {
// commands - takes static function pointers
// can be called via Console using 'call system '
void System::commands_init() {
- Command::add(EMSdevice::DeviceType::SYSTEM, F_(read), System::command_read, FL_(read_cmd), CommandFlag::ADMIN_ONLY);
- Command::add(EMSdevice::DeviceType::SYSTEM, F_(send), System::command_send, FL_(send_cmd), CommandFlag::ADMIN_ONLY);
- Command::add(EMSdevice::DeviceType::SYSTEM, F_(fetch), System::command_fetch, FL_(fetch_cmd), CommandFlag::ADMIN_ONLY);
- Command::add(EMSdevice::DeviceType::SYSTEM, F_(sendmail), System::command_sendmail, FL_(sendmail_cmd), CommandFlag::ADMIN_ONLY);
- Command::add(EMSdevice::DeviceType::SYSTEM, F_(restart), System::command_restart, FL_(restart_cmd), CommandFlag::ADMIN_ONLY);
- Command::add(EMSdevice::DeviceType::SYSTEM, F_(format), System::command_format, FL_(format_cmd), CommandFlag::ADMIN_ONLY);
- Command::add(EMSdevice::DeviceType::SYSTEM, F_(txpause), System::command_txpause, FL_(txpause_cmd), CommandFlag::ADMIN_ONLY);
- Command::add(EMSdevice::DeviceType::SYSTEM, F_(led), System::command_led, FL_(led_cmd), CommandFlag::ADMIN_ONLY);
- Command::add(EMSdevice::DeviceType::SYSTEM, F_(watch), System::command_watch, FL_(watch_cmd));
- Command::add(EMSdevice::DeviceType::SYSTEM, F_(message), System::command_message, FL_(message_cmd));
+ // Command::reserve(200);
+
+ Command::add(EMSdevice::DeviceType::SYSTEM, F_(read), MAKE_CF_CB(System::command_read), FL_(read_cmd), CommandFlag::ADMIN_ONLY);
+ Command::add(EMSdevice::DeviceType::SYSTEM, F_(send), MAKE_CF_CB(System::command_send), FL_(send_cmd), CommandFlag::ADMIN_ONLY);
+ Command::add(EMSdevice::DeviceType::SYSTEM, F_(fetch), MAKE_CF_CB(System::command_fetch), FL_(fetch_cmd), CommandFlag::ADMIN_ONLY);
+ Command::add(EMSdevice::DeviceType::SYSTEM, F_(sendmail), MAKE_CF_CB(System::command_sendmail), FL_(sendmail_cmd), CommandFlag::ADMIN_ONLY);
+ Command::add(EMSdevice::DeviceType::SYSTEM, F_(restart), MAKE_CF_CB(System::command_restart), FL_(restart_cmd), CommandFlag::ADMIN_ONLY);
+ Command::add(EMSdevice::DeviceType::SYSTEM, F_(format), MAKE_CF_CB(System::command_format), FL_(format_cmd), CommandFlag::ADMIN_ONLY);
+ Command::add(EMSdevice::DeviceType::SYSTEM, F_(txpause), MAKE_CF_CB(System::command_txpause), FL_(txpause_cmd), CommandFlag::ADMIN_ONLY);
+ Command::add(EMSdevice::DeviceType::SYSTEM, F_(led), MAKE_CF_CB(System::command_led), FL_(led_cmd), CommandFlag::ADMIN_ONLY);
+ Command::add(EMSdevice::DeviceType::SYSTEM, F_(watch), MAKE_CF_CB(System::command_watch), FL_(watch_cmd));
+ Command::add_json(EMSdevice::DeviceType::SYSTEM, F_(message), System::command_message, FL_(message_cmd));
#if defined(EMSESP_TEST)
- Command::add(EMSdevice::DeviceType::SYSTEM, ("test"), System::command_test, FL_(test_cmd));
+ Command::add(EMSdevice::DeviceType::SYSTEM, ("test"), MAKE_CF_CB(System::command_test), FL_(test_cmd));
#endif
// these commands will return data in JSON format
- Command::add(EMSdevice::DeviceType::SYSTEM, F("response"), System::command_response, FL_(commands_response));
+ Command::add_json(EMSdevice::DeviceType::SYSTEM, F("response"), System::command_response, FL_(commands_response));
// MQTT subscribe "ems-esp/system/#"
Mqtt::subscribe(EMSdevice::DeviceType::SYSTEM, "system/#", nullptr); // use empty function callback
@@ -1511,7 +1513,20 @@ bool System::check_upgrade() {
EMSESP::network_.reconnect();
}
- // changes going to v3.9 from an earlier version
+ // capture the raw Scheduler file now, before any upgrade step below rewrites it in the new format.
+ // it's needed further down to migrate the pre-v3.9.0-dev.12 inline command format into the Commands Service
+#ifndef EMSESP_STANDALONE
+ JsonDocument oldScheduleDoc(PSRAM_DOC);
+ {
+ File schedulerFile = LittleFS.open(EMSESP_SCHEDULER_FILE);
+ if (schedulerFile) {
+ deserializeJson(oldScheduleDoc, schedulerFile);
+ schedulerFile.close();
+ }
+ }
+#endif
+
+ // changes going to v3.9 from an 3.8.x or earlier
if (settings_version.major() == 3 && settings_version.minor() < 9) {
#ifndef EMSESP_STANDALONE
// AP_MODE_ALWAYS has been removed
@@ -1523,7 +1538,7 @@ bool System::check_upgrade() {
}
return StateUpdateResult::UNCHANGED;
});
- // Scheduler name is now mandatory, update FS
+ // Scheduler name is now mandatory, update FS if name is empty
uint8_t i = 0;
bool schedule_changed = false;
EMSESP::webSchedulerService.update([&](WebScheduler & scheduler) {
@@ -1538,6 +1553,80 @@ bool System::check_upgrade() {
#endif
}
+ // Core3 3.9.0-dev.12 implements the new Commands Service.
+ // versions before that stored the command (cmd) and value inline within each Scheduler entry
+#ifndef EMSESP_STANDALONE
+ {
+ JsonArray oldScheduleItems = oldScheduleDoc["schedule"].as();
+
+ // only migrate if at least one entry still uses the old inline format (has "cmd" but no "cmd_name")
+ bool old_format = false;
+ for (JsonObject item : oldScheduleItems) {
+ if (!item["cmd"].isNull() && item["cmd_name"].isNull()) {
+ old_format = true;
+ break;
+ }
+ }
+
+ if (old_format) {
+ LOG_INFO("Upgrade: Migrating %d Scheduler entries to the new Commands Service", (int)oldScheduleItems.size());
+
+ // create a Command for each Scheduler entry, reusing the entry's name (generating one if empty)
+ EMSESP::webCommandService.update([&](WebCommands & commands) {
+ commands.commandItems.clear();
+ uint8_t idx = 0;
+ for (JsonObject item : oldScheduleItems) {
+ auto ci = CommandItem();
+ ci.cmd = item["cmd"].as();
+ ci.value = item["value"].as();
+ const char * nm = item["name"];
+ // name could still be empty
+ if (nm != nullptr && nm[0] != '\0') {
+ strlcpy(ci.name, nm, sizeof(ci.name));
+ } else {
+ snprintf(ci.name, sizeof(ci.name), "schedule_%d", idx);
+ }
+ commands.commandItems.push_back(ci);
+ idx++;
+ }
+ return StateUpdateResult::CHANGED;
+ });
+
+ // point each Scheduler entry at its new Command via cmd_name
+ EMSESP::webSchedulerService.update([&](WebScheduler & scheduler) {
+ uint8_t idx = 0;
+ auto it = scheduler.scheduleItems.begin();
+ for (JsonObject item : oldScheduleItems) {
+ if (it == scheduler.scheduleItems.end()) {
+ break;
+ }
+ // flag 132 (0x84) is the old IMMEDIATE format which has no command - erase the entry
+ if (item["flags"].as() == 0x84) {
+ it = scheduler.scheduleItems.erase(it);
+ idx++;
+ continue;
+ }
+ const char * nm = item["name"];
+ char cmd_name[sizeof(it->name)];
+ if (nm != nullptr && nm[0] != '\0') {
+ strlcpy(cmd_name, nm, sizeof(cmd_name));
+ } else {
+ snprintf(cmd_name, sizeof(cmd_name), "schedule_%d", idx);
+ strlcpy(it->name, cmd_name, sizeof(it->name)); // keep entry name consistent with its command
+ }
+ it->cmd_name = cmd_name;
+ ++it;
+ idx++;
+ }
+ return StateUpdateResult::CHANGED;
+ });
+
+ // reboot so both services reload cleanly in the new format and re-register their commands
+ reboot_required = true;
+ }
+ }
+#endif
+
// changes to application settings
EMSESP::webSettingsService.update([&](WebSettings & settings) {
// force web buffer to 25 for those boards without psram
@@ -1592,6 +1681,7 @@ static const std::pair SECTION_MAP[] = {
{NTP_SETTINGS_FILE, "NTP"},
{SECURITY_SETTINGS_FILE, "Security"},
{EMSESP_SETTINGS_FILE, "Settings"},
+ {EMSESP_COMMANDS_FILE, "Commands"},
{EMSESP_SCHEDULER_FILE, "Schedule"},
{EMSESP_CUSTOMIZATION_FILE, "Customizations"},
{EMSESP_CUSTOMENTITY_FILE, "Entities"},
@@ -1667,6 +1757,8 @@ void System::exportSystemBackup(JsonObject output) {
exportSettings("settings", SECURITY_SETTINGS_FILE, node);
exportSettings("settings", EMSESP_SETTINGS_FILE, node);
+ node = nodes.add();
+ exportSettings("commands", EMSESP_COMMANDS_FILE, node);
node = nodes.add();
exportSettings("schedule", EMSESP_SCHEDULER_FILE, node);
node = nodes.add();
@@ -2610,6 +2702,12 @@ bool System::command_info(const char * value, const int8_t id, JsonObject output
obj["name"] = F_(scheduler);
obj["entities"] = EMSESP::webSchedulerService.count_entities();
}
+ if (EMSESP::webCommandService.count_entities()) {
+ JsonObject obj = devices.add();
+ obj["type"] = F_(commands);
+ obj["name"] = F_(commands);
+ obj["entities"] = EMSESP::webCommandService.count_entities();
+ }
if (EMSESP::webCustomEntityService.count_entities()) {
JsonObject obj = devices.add();
obj["type"] = F_(custom);
diff --git a/src/core/temperaturesensor.cpp b/src/core/temperaturesensor.cpp
index 493db0da2..437aac353 100644
--- a/src/core/temperaturesensor.cpp
+++ b/src/core/temperaturesensor.cpp
@@ -489,7 +489,7 @@ void TemperatureSensor::publish_values(const bool force) {
publish_sensor(sensor);
}
return;
- } else if (!EMSESP::mqtt_.get_publish_onchange(0)) {
+ } else if (!EMSESP::mqtt_.get_publish_onchange(EMSdevice::DeviceType::SYSTEM)) {
return; // wait for first time period
}
}
diff --git a/src/emsesp_version.h b/src/emsesp_version.h
index f23c4d33f..a0b2b0af5 100644
--- a/src/emsesp_version.h
+++ b/src/emsesp_version.h
@@ -1 +1 @@
-#define EMSESP_APP_VERSION "3.9.0-dev.11"
+#define EMSESP_APP_VERSION "3.9.0-dev.12"
diff --git a/src/test/test.cpp b/src/test/test.cpp
index ba2c64842..e632ab167 100644
--- a/src/test/test.cpp
+++ b/src/test/test.cpp
@@ -350,6 +350,7 @@ void Test::run_test(uuid::console::Shell & shell, const std::string & cmd, const
EMSESP::webCustomEntityService.load_test_data(); // custom entities
EMSESP::webCustomizationService.load_test_data(); // set customizations - this will overwrite any settings in the FS
EMSESP::temperaturesensor_.load_test_data(); // add temperature sensors
+ EMSESP::webCommandService.load_test_data(); // add command items
EMSESP::webSchedulerService.load_test_data(); // add scheduler data
shell.invoke_command("show values");
@@ -406,7 +407,7 @@ void Test::run_test(uuid::console::Shell & shell, const std::string & cmd, const
if (command == "scheduler") {
shell.printfln("Adding Scheduler items...");
- // add some dummy entities
+ EMSESP::webCommandService.load_test_data();
EMSESP::webSchedulerService.load_test_data();
#ifdef EMSESP_STANDALONE
@@ -1116,6 +1117,7 @@ void Test::run_test(uuid::console::Shell & shell, const std::string & cmd, const
EMSESP::webCustomEntityService.load_test_data(); // custom entities
EMSESP::webCustomizationService.load_test_data(); // set customizations - this will overwrite any settings in the FS
EMSESP::temperaturesensor_.load_test_data(); // add temperature sensors
+ EMSESP::webCommandService.load_test_data(); // add command items
EMSESP::webSchedulerService.load_test_data(); // run scheduler tests, and conditions
JsonDocument doc;
@@ -1379,6 +1381,7 @@ void Test::run_test(uuid::console::Shell & shell, const std::string & cmd, const
EMSESP::webCustomEntityService.load_test_data(); // custom entities
EMSESP::webCustomizationService.load_test_data(); // set customizations - this will overwrite any settings in the FS
EMSESP::temperaturesensor_.load_test_data(); // add temperature sensors
+ EMSESP::webCommandService.load_test_data(); // add command items
EMSESP::webSchedulerService.load_test_data(); // run scheduler tests, and conditions
request.method(HTTP_GET);
@@ -2110,6 +2113,101 @@ void Test::run_test(uuid::console::Shell & shell, const std::string & cmd, const
ok = true;
}
+ if (command == "version") {
+ shell.printfln("Testing version upgrade and downgrade detection...");
+
+ // mirrors System::check_upgrade(): settings = version stored in settings file, current = running firmware
+ struct VersionTest {
+ const char * settings;
+ const char * current;
+ const char * expected; // "upgrade", "downgrade" or "same"
+ };
+
+ const VersionTest tests[] = {
+ // identical versions
+ {"3.9.0", "3.9.0", "same"},
+ {"3.9.0-dev.12", "3.9.0-dev.12", "same"},
+
+ // numeric upgrades (patch, minor, major)
+ {"3.9.0", "3.9.1", "upgrade"},
+ {"3.8.5", "3.9.0", "upgrade"},
+ {"2.10.9", "3.0.0", "upgrade"},
+
+ // numeric downgrades
+ {"3.9.1", "3.9.0", "downgrade"},
+ {"3.9.0", "3.8.5", "downgrade"},
+ {"3.0.0", "2.10.9", "downgrade"},
+
+ // prerelease (dev) sequences on the same base version
+ {"3.9.0-dev.12", "3.9.0-dev.13", "upgrade"},
+ {"3.9.0-dev.13", "3.9.0-dev.12", "downgrade"},
+ {"3.9.0-dev.9", "3.9.0-dev.12", "upgrade"}, // single vs double digit dev number
+ {"3.9.0-dev.8", "3.9.0-dev.12", "upgrade"}, // regression: was reported as a downgrade
+
+ // prerelease vs release on the same base version (semver: prerelease < release)
+ {"3.9.0-dev.12", "3.9.0", "upgrade"},
+ {"3.9.0", "3.9.0-dev.12", "downgrade"},
+
+ // prerelease vs a different base version
+ {"3.9.0-dev.12", "3.9.1", "upgrade"},
+ {"3.9.1", "3.9.0-dev.12", "downgrade"},
+ {"3.8.5", "3.9.0-dev.12", "upgrade"},
+
+ // mixed prerelease tags
+ {"3.5.0-b13", "3.9.0-dev.12", "upgrade"},
+
+ // partial version strings are shorter than 5 chars, so check_upgrade() treats them as missing (3.5.0)
+ {"3.9", "3.9.0", "upgrade"},
+ {"3.9", "3.9.1", "upgrade"},
+
+ // build metadata after '+' is ignored
+ {"3.9.0+abc123", "3.9.0", "same"},
+
+ // numeric prerelease identifiers compare numerically, so leading zeros are equivalent
+ {"3.9.0-dev.01", "3.9.0-dev.1", "same"},
+ {"3.9.0-dev.012", "3.9.0-dev.12", "same"},
+
+ // missing/short version: check_upgrade() assumes 3.5.0
+ {"", "3.9.0", "upgrade"},
+ {"1.0", "3.9.0", "upgrade"},
+ };
+
+ uint8_t failed = 0;
+ for (const auto & test : tests) {
+ // replicate check_upgrade()'s handling of a missing version
+ std::string settingsVersion = test.settings;
+ if (settingsVersion.length() < 5) {
+ settingsVersion = "3.5.0";
+ }
+
+ FirmwareVersion settings_version(settingsVersion);
+ FirmwareVersion this_version(test.current);
+
+ const char * actual;
+ if (this_version > settings_version) {
+ actual = "upgrade";
+ } else if (this_version < settings_version) {
+ actual = "downgrade";
+ } else {
+ actual = "same";
+ }
+
+ bool pass = (strcmp(actual, test.expected) == 0);
+ if (!pass) {
+ failed++;
+ }
+ shell.printfln("%s %-14s -> %-14s expected %-9s got %-9s", pass ? "PASS" : "FAIL", test.settings, test.current, test.expected, actual);
+ }
+
+ if (failed) {
+ shell.printfln("%d test(s) FAILED", failed);
+ } else {
+ shell.printfln("All version tests passed");
+ }
+
+ ok = true;
+ }
+
if (command == "mqtt2") {
shell.printfln("Testing MQTT large payloads...");
diff --git a/src/test/test.h b/src/test/test.h
index 9ca860f51..4d44962e8 100644
--- a/src/test/test.h
+++ b/src/test/test.h
@@ -64,7 +64,8 @@ namespace emsesp {
// #define EMSESP_DEBUG_DEFAULT "hpmode"
// #define EMSESP_DEBUG_DEFAULT "shuntingyard"
// #define EMSESP_DEBUG_DEFAULT "src"
-#define EMSESP_DEBUG_DEFAULT "led"
+// #define EMSESP_DEBUG_DEFAULT "led"
+// #define EMSESP_DEBUG_DEFAULT "version"
#ifndef EMSESP_DEBUG_DEFAULT
#define EMSESP_DEBUG_DEFAULT "general"
diff --git a/src/web/WebCommandService.cpp b/src/web/WebCommandService.cpp
new file mode 100644
index 000000000..8da74bb35
--- /dev/null
+++ b/src/web/WebCommandService.cpp
@@ -0,0 +1,458 @@
+/*
+ * EMS-ESP - https://github.com/emsesp/EMS-ESP
+ * Copyright 2020-2025 emsesp.org
+ *
+ * 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
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#include "emsesp.h"
+#include "WebCommandService.h"
+
+#include "shuntingYard.h"
+
+namespace emsesp {
+
+#ifndef EMSESP_STANDALONE
+QueueHandle_t WebCommandService::commandQueue_ = nullptr;
+#endif
+
+WebCommandService::WebCommandService(AsyncWebServer * server, FS * fs, SecurityManager * securityManager)
+ : _httpEndpoint(WebCommands::read, WebCommands::update, this, server, EMSESP_COMMANDS_SERVICE_PATH, securityManager, AuthenticationPredicates::IS_AUTHENTICATED)
+ , _fsPersistence(WebCommands::read, WebCommands::update, this, fs, EMSESP_COMMANDS_FILE) {
+}
+
+void WebCommandService::begin() {
+ _fsPersistence.readFromFS();
+
+ EMSESP::webCommandService.read([&](WebCommands & webCommands) { commandItems_ = &webCommands.commandItems; });
+
+ EMSESP::logger().info("Starting Commands service");
+ char topic[Mqtt::MQTT_TOPIC_MAX_SIZE];
+ snprintf(topic, sizeof(topic), "%s/#", F_(commands));
+ Mqtt::subscribe(EMSdevice::DeviceType::COMMAND, topic, nullptr);
+
+#ifndef EMSESP_STANDALONE
+ // when PSRAM is available, run command execution (potentially blocking, e.g. HTTP) in its own task
+ // so it never stalls the main loop. Without PSRAM we execute inline (see queueCommand()).
+ if (EMSESP::system_.PSram()) {
+ commandQueue_ = xQueueCreate(EMSESP_COMMAND_QUEUE_SIZE, sizeof(CommandJob *));
+ if (commandQueue_ != nullptr) {
+#if defined(CONFIG_FREERTOS_UNICORE) || (EMSESP_COMMAND_RUNNING_CORE < 0)
+ xTaskCreate((TaskFunction_t)command_task, "command_task", EMSESP_COMMAND_STACKSIZE, NULL, EMSESP_COMMAND_PRIORITY, NULL);
+#else
+ xTaskCreatePinnedToCore(
+ (TaskFunction_t)command_task, "command_task", EMSESP_COMMAND_STACKSIZE, NULL, EMSESP_COMMAND_PRIORITY, NULL, EMSESP_COMMAND_RUNNING_CORE);
+#endif
+ }
+ }
+#endif
+
+#if defined(EMSESP_TEST)
+ load_test_data();
+#endif
+}
+
+// enqueue a command for the worker task. Fire-and-forget: returns true when the job was queued
+// (or executed inline when no worker exists). It does NOT report the command's success/failure.
+bool WebCommandService::queueCommand(const char * name, const char * value) {
+#ifndef EMSESP_STANDALONE
+ if (commandQueue_ != nullptr) {
+ CommandJob * job = new CommandJob();
+ if (job == nullptr) {
+ return false;
+ }
+ job->name = name ? name : "";
+ job->has_value = (value != nullptr);
+ job->value = value ? value : "";
+ if (xQueueSend(commandQueue_, &job, 0) != pdPASS) {
+ EMSESP::logger().warning("Command queue full, dropping '%s'", job->name.c_str());
+ delete job;
+ return false;
+ }
+ return true;
+ }
+#endif
+ // no worker task available (no PSRAM or standalone build) - run synchronously
+ return executeCommand(name, value);
+}
+
+// true if the command definition is a HTTP/URL command (JSON with a http(s):// url),
+// i.e. one that will do a blocking TCP/TLS request when executed
+bool WebCommandService::isUrlCommand(const std::string & command) {
+ JsonDocument doc;
+ if (deserializeJson(doc, command) != DeserializationError::Ok) {
+ return false;
+ }
+ std::string url = doc["url"] | "";
+ auto lower_url = Helpers::toLower(url.c_str());
+ return lower_url.starts_with("http://") || lower_url.starts_with("https://");
+}
+
+// true if a value expression contains an embedded {"url":...} JSON snippet, which compute()
+// will resolve with a blocking HTTP request. Mirrors the scan compute() does in shuntingYard.cpp
+bool WebCommandService::valueContainsUrl(const std::string & value) {
+ auto f = value.find_first_of('{');
+ while (f != std::string::npos) {
+ // find the matching closing brace, like compute() does
+ auto e = f + 1;
+ for (uint8_t i = 1; i > 0; e++) {
+ if (e >= value.length()) {
+ return false; // unbalanced braces, compute() will give up too
+ } else if (value[e] == '}') {
+ i--;
+ } else if (value[e] == '{') {
+ i++;
+ }
+ }
+ JsonDocument doc;
+ if (deserializeJson(doc, value.substr(f, e - f)) == DeserializationError::Ok) {
+ for (JsonPairConst p : doc.as()) {
+ if (Helpers::toLower(p.key().c_str()) == "url") {
+ return true;
+ }
+ }
+ }
+ f = value.find_first_of('{', e);
+ }
+ return false;
+}
+
+// smart dispatch: commands that will do a blocking HTTP/TLS request - either a URL command or an
+// internal command whose value embeds a {url} fetch - are offloaded to the worker task so they
+// can't stall the caller (main loop for MQTT, async_tcp task for the web API). Everything else
+// runs synchronously, so the caller still gets the command's real success/failure.
+// for queued commands the return value only means "dispatched".
+bool WebCommandService::dispatchCommand(const char * name, const char * value) {
+#ifndef EMSESP_STANDALONE
+ if (commandQueue_ != nullptr) {
+ const CommandItem * ci = find(name);
+ if (ci != nullptr) {
+ if (isUrlCommand(ci->cmd.c_str())) {
+ return queueCommand(name, value);
+ }
+ // system/message defers evaluation of its value (via the scheduler's raw_value),
+ // so executing it never blocks - keep it synchronous even if the value has a {url}
+ if (Helpers::toLower(ci->cmd.c_str()) != "system/message") {
+ // the effective value is the override if given, else the command's stored default
+ const std::string effective_value = value ? value : std::string(ci->value.c_str());
+ if (valueContainsUrl(effective_value)) {
+ return queueCommand(name, value);
+ }
+ }
+ }
+ }
+#endif
+ return executeCommand(name, value);
+}
+
+#ifndef EMSESP_STANDALONE
+// worker task: blocks on the queue and executes each command in turn, off the main loop
+void WebCommandService::command_task(void * pvParameters) {
+ CommandJob * job = nullptr;
+ while (1) {
+ if (xQueueReceive(commandQueue_, &job, portMAX_DELAY) == pdPASS && job != nullptr) {
+ EMSESP::webCommandService.executeCommand(job->name.c_str(), job->has_value ? job->value.c_str() : nullptr);
+ delete job;
+ job = nullptr;
+ }
+ }
+}
+#endif
+
+void WebCommands::read(WebCommands & webCommands, JsonObject root) {
+ JsonArray items = root["commands"].to();
+ uint8_t counter = 1;
+ for (const CommandItem & ci : webCommands.commandItems) {
+ JsonObject obj = items.add();
+ obj["id"] = counter++;
+ obj["cmd"] = ci.cmd;
+ obj["value"] = ci.value;
+ obj["name"] = (const char *)ci.name;
+ }
+}
+
+StateUpdateResult WebCommands::update(JsonObject root, WebCommands & webCommands) {
+ Command::erase_device_commands(EMSdevice::DeviceType::COMMAND);
+ webCommands.commandItems.clear();
+
+ auto items = root["commands"].as();
+ for (const JsonObject item : items) {
+ auto ci = CommandItem();
+ ci.cmd = item["cmd"].as();
+ ci.value = item["value"].as();
+ strlcpy(ci.name, item["name"].as(), sizeof(ci.name));
+
+ webCommands.commandItems.push_back(ci);
+ Command::add(
+ EMSdevice::DeviceType::COMMAND,
+ webCommands.commandItems.back().name,
+ [name = std::string(webCommands.commandItems.back().name)](const char * value, const int8_t id, JsonObject output) {
+ return EMSESP::webCommandService.dispatchCommand(name.c_str(), value); // value is optional
+ },
+ FL_(command_cmd),
+ CommandFlag::ADMIN_ONLY);
+ }
+ return StateUpdateResult::CHANGED;
+}
+
+// find a command item by name (case-insensitive)
+const CommandItem * WebCommandService::find(const char * name) {
+ if (name == nullptr || name[0] == '\0') {
+ return nullptr;
+ }
+ auto lower_name = Helpers::toLower(name);
+ for (const CommandItem & ci : *commandItems_) {
+ if (ci.name[0] != '\0' && Helpers::toLower(ci.name) == lower_name) {
+ return &ci;
+ }
+ }
+ return nullptr;
+}
+
+// execute a named command — looks up by name and runs it
+bool WebCommandService::executeCommand(const char * name, const char * value) {
+ const CommandItem * ci = find(name);
+ if (!ci) {
+ EMSESP::logger().warning("Command '%s' not found", name ? name : "");
+ return false;
+ }
+ // if there is a value use it, otherwise use the command's default value
+ std::string cmd_value = value ? value : ci->value.c_str();
+ return executeCommand(ci->name, std::string(ci->cmd.c_str()), cmd_value);
+}
+
+// execute a command with explicit cmd and value strings
+// handles both HTTP URLs (JSON format) and internal API commands
+bool WebCommandService::executeCommand(const char * name, const std::string & command, const std::string & data) {
+ std::string cmd = Helpers::toLower(command);
+
+ // run the value through the shunting-yard calculator so expressions like "custom/heatcnt + 1"
+ // are resolved (entity references replaced by their values, then computed). Plain values pass
+ // through unchanged. Applies to both URL and internal commands, like the old scheduler code
+ // which computed the value before executing. system/message evaluates its own argument later
+ // (deferred via the scheduler's raw_value), so pre-computing it would run it twice - pass raw.
+ std::string computed_data = data;
+ if (!data.empty() && cmd != "system/message") {
+ computed_data = compute(data);
+ if (computed_data.empty()) {
+ EMSESP::logger().warning("Command '%s': cannot compute value '%s'", name, data.c_str());
+ return false;
+ }
+ }
+
+ // handle HTTP commands (JSON with url/method/value)
+ JsonDocument doc;
+ if (deserializeJson(doc, cmd) == DeserializationError::Ok) {
+ std::string url = doc["url"] | "";
+ auto q = url.find_first_of('?');
+ if (q != std::string::npos) {
+ auto s = url.substr(q + 1);
+ auto l = s.length();
+ commands(s, false);
+ url.replace(q + 1, l, s);
+ }
+ // the cmd's embedded value only gets entity substitution (commands), the passed value is fully computed
+ std::string value = doc["value"] | computed_data;
+ std::string method = doc["method"] | "GET";
+ commands(value, false);
+ auto lower_url = Helpers::toLower(url.c_str());
+ if (lower_url.starts_with("http://") || lower_url.starts_with("https://")) {
+ std::string result;
+ int httpResult = http_request(url, method, value, doc["header"].as(), result);
+ if (httpResult != 200) {
+ EMSESP::logger().warning("Command '%s': URL command failed with http code %d", name, httpResult);
+ return false;
+ }
+#if defined(EMSESP_DEBUG)
+ EMSESP::logger().debug("Command '%s': URL '%s' successful with http code %d", name, url.c_str(), httpResult);
+#endif
+ return true;
+ }
+ }
+
+ // handle internal API commands
+ doc.clear();
+ JsonObject input = doc.to();
+ if (!computed_data.empty()) {
+ input["data"] = computed_data;
+ }
+
+ JsonDocument doc_output;
+ JsonObject output = doc_output.to();
+
+ char command_str[COMMAND_MAX_LENGTH];
+ snprintf(command_str, sizeof(command_str), "/api/%s", cmd.c_str());
+
+ uint8_t return_code = Command::process(command_str, true, input, output);
+ if (return_code == CommandRet::OK) {
+#if defined(EMSESP_DEBUG)
+ EMSESP::logger().debug("Command '%s' (%s with data '%s') was successful", name, cmd.c_str(), data.c_str());
+#endif
+ if (data.empty() && output.size()) {
+ Mqtt::queue_publish("response", output);
+ }
+ return true;
+ }
+
+ char error[100];
+ if (output.size()) {
+ snprintf(error, sizeof(error), "Command '%s': %s", name ? name : "", (const char *)output["message"]);
+ } else {
+ snprintf(error, sizeof(error), "Command '%s': %s failed with error %s", name, cmd.c_str(), Command::return_code_string(return_code));
+ }
+ EMSESP::logger().warning(error);
+ return false;
+}
+
+bool WebCommandService::get_value_info(JsonObject output, const char * cmd) {
+ if (commandItems_->empty()) {
+ return true;
+ }
+
+ if (!strlen(cmd) || !strcmp(cmd, F_(values)) || !strcmp(cmd, F_(info))) {
+ for (const CommandItem & ci : *commandItems_) {
+ if (ci.name[0] != '\0') {
+ output[(const char *)ci.name] = ci.cmd;
+ }
+ }
+ return true;
+ }
+
+ if (!strcmp(cmd, F_(entities))) {
+ for (const CommandItem & ci : *commandItems_) {
+ if (ci.name[0] != '\0') {
+ get_value_json(output[ci.name].to(), ci);
+ }
+ }
+ return true;
+ }
+
+ if (!strcmp(cmd, F_(metrics))) {
+ std::string metrics = get_metrics_prometheus();
+ if (!metrics.empty()) {
+ output["api_data"] = metrics;
+ return true;
+ }
+ return false;
+ }
+
+ // look up specific command by name
+ const char * attribute_s = Command::get_attribute(cmd);
+ for (const CommandItem & ci : *commandItems_) {
+ if (Helpers::toLower(ci.name) == cmd) {
+ get_value_json(output, ci);
+ return Command::get_attribute(output, cmd, attribute_s);
+ }
+ }
+
+ return false;
+}
+
+std::string WebCommandService::get_metrics_prometheus() {
+ std::string result;
+ result.reserve(commandItems_->size() * 100);
+ for (const CommandItem & ci : *commandItems_) {
+ if (ci.name[0] == '\0') {
+ continue;
+ }
+ result += (std::string) "# HELP emsesp_cmd_" + ci.name + " " + ci.name + "\n";
+ result += (std::string) "# TYPE emsesp_cmd_" + ci.name + " gauge\n";
+ result += (std::string) "emsesp_cmd_" + ci.name + " 1\n";
+ }
+ return result;
+}
+
+void WebCommandService::get_value_json(JsonObject output, const CommandItem & ci) {
+ output["name"] = (const char *)ci.name;
+ // output["fullname"] = (const char *)ci.name;
+ // output["type"] = "command";
+ output["command"] = ci.cmd;
+ output["value"] = ci.value;
+ // bool hasName = ci.name[0] != '\0';
+ // output["readable"] = hasName;
+ // output["writeable"] = hasName;
+ // output["visible"] = hasName;
+}
+
+void WebCommandService::publish(const bool force) {
+ if (!Mqtt::enabled() || commandItems_->empty()) {
+ return;
+ }
+ if (force && !EMSESP::mqtt_.get_publish_onchange(EMSdevice::DeviceType::SYSTEM)) {
+ return;
+ }
+
+ JsonDocument doc(PSRAM_DOC);
+ JsonObject output = doc.to();
+ for (const CommandItem & ci : *commandItems_) {
+ if (ci.name[0] != '\0' && !output[ci.name].is()) {
+ output[(const char *)ci.name] = ci.cmd;
+ }
+ }
+
+ if (!doc.isNull()) {
+ char topic[Mqtt::MQTT_TOPIC_MAX_SIZE];
+ snprintf(topic, sizeof(topic), "%s_data", F_(commands));
+ Mqtt::queue_publish(topic, output);
+ }
+}
+
+uint8_t WebCommandService::count_entities() {
+ return static_cast(commandItems_ ? commandItems_->size() : 0);
+}
+
+#if defined(EMSESP_TEST)
+void WebCommandService::load_test_data() {
+ Command::erase_device_commands(EMSdevice::DeviceType::COMMAND);
+ update([&](WebCommands & webCommands) {
+ webCommands.commandItems.clear();
+
+ auto ci = CommandItem();
+ ci.cmd = "system/fetch";
+ ci.value = "10";
+ strcpy(ci.name, "fetch_values");
+ webCommands.commandItems.push_back(ci);
+
+ ci = CommandItem();
+ ci.cmd = "system/message";
+ ci.value = "hello";
+ strcpy(ci.name, "send_message");
+ webCommands.commandItems.push_back(ci);
+
+ ci = CommandItem();
+ ci.cmd = "system/message";
+ ci.value = "{\"url\":\"http://emsesp.org/versions.json\"}";
+ strcpy(ci.name, "get_versions");
+ webCommands.commandItems.push_back(ci);
+
+ // manually add the commands
+ for (const auto & item : webCommands.commandItems) {
+ if (item.name[0] != '\0') {
+ Command::add(
+ EMSdevice::DeviceType::COMMAND,
+ item.name,
+ [name = std::string(item.name)](const char * value, const int8_t id, JsonObject output) {
+ return EMSESP::webCommandService.dispatchCommand(name.c_str(), value);
+ },
+ FL_(command_cmd),
+ CommandFlag::ADMIN_ONLY);
+ }
+ }
+
+ return StateUpdateResult::CHANGED;
+ });
+}
+#endif
+
+} // namespace emsesp
diff --git a/src/web/WebCommandService.h b/src/web/WebCommandService.h
new file mode 100644
index 000000000..4077a39cf
--- /dev/null
+++ b/src/web/WebCommandService.h
@@ -0,0 +1,113 @@
+/*
+ * EMS-ESP - https://github.com/emsesp/EMS-ESP
+ * Copyright 2020-2025 emsesp.org
+ *
+ * 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
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#include
+
+#ifndef EMSESP_STANDALONE
+#include
+#include
+#include
+#endif
+
+#ifndef WebCommandService_h
+#define WebCommandService_h
+
+#define EMSESP_COMMANDS_FILE "/config/emsespCommands.json"
+#define EMSESP_COMMANDS_SERVICE_PATH "/rest/commands" // GET and POST
+
+#ifndef EMSESP_COMMAND_RUNNING_CORE
+#define EMSESP_COMMAND_RUNNING_CORE 1
+#endif
+#ifndef EMSESP_COMMAND_STACKSIZE
+#define EMSESP_COMMAND_STACKSIZE 8192 // needed for TLS
+#endif
+#ifndef EMSESP_COMMAND_PRIORITY
+#define EMSESP_COMMAND_PRIORITY 1
+#endif
+#ifndef EMSESP_COMMAND_QUEUE_SIZE
+#define EMSESP_COMMAND_QUEUE_SIZE 10
+#endif
+
+namespace emsesp {
+
+class CommandItem {
+ public:
+ stringPSRAM cmd;
+ stringPSRAM value;
+ char name[20];
+};
+
+// a single unit of work handed to the command worker task
+class CommandJob {
+ public:
+ std::string name;
+ std::string value;
+ bool has_value;
+};
+
+class WebCommands {
+ public:
+ std::list> commandItems;
+
+ static void read(WebCommands & webCommands, JsonObject root);
+ static StateUpdateResult update(JsonObject root, WebCommands & webCommands);
+};
+
+class WebCommandService : public StatefulService {
+ public:
+ WebCommandService(AsyncWebServer * server, FS * fs, SecurityManager * securityManager);
+
+ void begin();
+ void publish(const bool force = false);
+ bool get_value_info(JsonObject output, const char * cmd);
+ void get_value_json(JsonObject output, const CommandItem & commandItem);
+
+ bool executeCommand(const char * name, const char * value = nullptr);
+ bool executeCommand(const char * name, const std::string & cmd, const std::string & value);
+
+ bool queueCommand(const char * name, const char * value = nullptr);
+ bool dispatchCommand(const char * name, const char * value = nullptr);
+
+ static bool isUrlCommand(const std::string & command); // true if the command definition is a HTTP/URL command
+ static bool valueContainsUrl(const std::string & value); // true if a value embeds a {"url":...} compute() will fetch
+
+ const CommandItem * find(const char * name);
+
+ uint8_t count_entities();
+
+ std::string get_metrics_prometheus();
+
+#if defined(EMSESP_TEST)
+ void load_test_data();
+#endif
+
+ private:
+#ifndef EMSESP_STANDALONE
+ static void command_task(void * pvParameters);
+ static QueueHandle_t commandQueue_;
+#endif
+
+ HttpEndpoint _httpEndpoint;
+ FSPersistence _fsPersistence;
+
+ std::list> * commandItems_;
+};
+
+} // namespace emsesp
+
+#endif
diff --git a/src/web/WebCustomEntityService.cpp b/src/web/WebCustomEntityService.cpp
index 416091c19..a5ae53aa3 100644
--- a/src/web/WebCustomEntityService.cpp
+++ b/src/web/WebCustomEntityService.cpp
@@ -145,8 +145,8 @@ StateUpdateResult WebCustomEntity::update(JsonObject root, WebCustomEntity & web
Command::add(
EMSdevice::DeviceType::CUSTOM,
webCustomEntity.customEntityItems.back().name,
- [webCustomEntity](const char * value, const int8_t id) {
- return EMSESP::webCustomEntityService.command_setvalue(value, id, webCustomEntity.customEntityItems.back().name);
+ [name = std::string(webCustomEntity.customEntityItems.back().name)](const char * value, const int8_t id, JsonObject output) {
+ return EMSESP::webCustomEntityService.command_setvalue(value, id, name.c_str());
},
FL_(entity_cmd),
CommandFlag::ADMIN_ONLY);
@@ -234,7 +234,7 @@ bool WebCustomEntityService::command_setvalue(const char * value, const int8_t i
}
publish_single(entityItem);
- if (EMSESP::mqtt_.get_publish_onchange(0)) {
+ if (EMSESP::mqtt_.get_publish_onchange(EMSdevice::DeviceType::SYSTEM)) {
publish();
}
char cmd[COMMAND_MAX_LENGTH];
@@ -415,13 +415,13 @@ std::string WebCustomEntityService::get_metrics_prometheus() {
// build the json for specific entity
void WebCustomEntityService::get_value_json(JsonObject output, CustomEntityItem const & entity) {
- output["name"] = (const char *)entity.name;
- output["fullname"] = (const char *)entity.name;
- output["storage"] = entity.ram == 1 ? "ram" : entity.ram == 2 ? "nvs" : "ems";
- output["type"] = entity.value_type == DeviceValueType::BOOL ? "boolean" : entity.value_type == DeviceValueType::STRING ? "string" : F_(number);
- output["readable"] = true;
+ output["name"] = (const char *)entity.name;
+ output["fullname"] = (const char *)entity.name;
+ output["storage"] = entity.ram == 1 ? "ram" : entity.ram == 2 ? "nvs" : "ems";
+ output["type"] = entity.value_type == DeviceValueType::BOOL ? "boolean" : entity.value_type == DeviceValueType::STRING ? "string" : F_(number);
+ // output["readable"] = true;
output["writeable"] = entity.writeable;
- output["visible"] = true;
+ // output["visible"] = true;
if (entity.ram == 0) {
output["device_id"] = Helpers::hextoa(entity.device_id);
@@ -470,7 +470,7 @@ void WebCustomEntityService::publish(const bool force) {
publish_single(entityItem);
}
return;
- } else if (!EMSESP::mqtt_.get_publish_onchange(0)) {
+ } else if (!EMSESP::mqtt_.get_publish_onchange(EMSdevice::DeviceType::SYSTEM)) {
return; // wait for first time period
}
}
@@ -729,7 +729,7 @@ bool WebCustomEntityService::get_value(const std::shared_ptr & t
entity.data = data.c_str();
if (Mqtt::publish_single()) {
publish_single(entity);
- } else if (EMSESP::mqtt_.get_publish_onchange(0)) {
+ } else if (EMSESP::mqtt_.get_publish_onchange(EMSdevice::DeviceType::SYSTEM)) {
has_change = true;
}
char cmd[COMMAND_MAX_LENGTH];
@@ -751,7 +751,7 @@ bool WebCustomEntityService::get_value(const std::shared_ptr & t
entity.value = value;
if (Mqtt::publish_single()) {
publish_single(entity);
- } else if (EMSESP::mqtt_.get_publish_onchange(0)) {
+ } else if (EMSESP::mqtt_.get_publish_onchange(EMSdevice::DeviceType::SYSTEM)) {
has_change = true;
}
char cmd[COMMAND_MAX_LENGTH];
@@ -796,8 +796,8 @@ void WebCustomEntityService::load_test_data() {
Command::add(
EMSdevice::DeviceType::CUSTOM,
webCustomEntity.customEntityItems.back().name,
- [webCustomEntity](const char * value, const int8_t id) {
- return EMSESP::webCustomEntityService.command_setvalue(value, id, webCustomEntity.customEntityItems.back().name);
+ [name = std::string(webCustomEntity.customEntityItems.back().name)](const char * value, const int8_t id, JsonObject output) {
+ return EMSESP::webCustomEntityService.command_setvalue(value, id, name.c_str());
},
FL_(entity_cmd),
CommandFlag::ADMIN_ONLY);
@@ -832,8 +832,8 @@ void WebCustomEntityService::load_test_data() {
Command::add(
EMSdevice::DeviceType::CUSTOM,
webCustomEntity.customEntityItems.back().name,
- [webCustomEntity](const char * value, const int8_t id) {
- return EMSESP::webCustomEntityService.command_setvalue(value, id, webCustomEntity.customEntityItems.back().name);
+ [name = std::string(webCustomEntity.customEntityItems.back().name)](const char * value, const int8_t id, JsonObject output) {
+ return EMSESP::webCustomEntityService.command_setvalue(value, id, name.c_str());
},
FL_(entity_cmd),
CommandFlag::ADMIN_ONLY);
@@ -855,8 +855,8 @@ void WebCustomEntityService::load_test_data() {
Command::add(
EMSdevice::DeviceType::CUSTOM,
webCustomEntity.customEntityItems.back().name,
- [webCustomEntity](const char * value, const int8_t id) {
- return EMSESP::webCustomEntityService.command_setvalue(value, id, webCustomEntity.customEntityItems.back().name);
+ [name = std::string(webCustomEntity.customEntityItems.back().name)](const char * value, const int8_t id, JsonObject output) {
+ return EMSESP::webCustomEntityService.command_setvalue(value, id, name.c_str());
},
FL_(entity_cmd),
CommandFlag::ADMIN_ONLY);
diff --git a/src/web/WebDataService.cpp b/src/web/WebDataService.cpp
index d8a8a5195..e2f5c4469 100644
--- a/src/web/WebDataService.cpp
+++ b/src/web/WebDataService.cpp
@@ -250,6 +250,9 @@ void WebDataService::write_device_value(AsyncWebServerRequest * request, JsonVar
case EMSdevice::DeviceTypeUniqueID::SCHEDULER_UID:
device_type = EMSdevice::DeviceType::SCHEDULER;
break;
+ case EMSdevice::DeviceTypeUniqueID::COMMAND_UID:
+ device_type = EMSdevice::DeviceType::COMMAND;
+ break;
case EMSdevice::DeviceTypeUniqueID::TEMPERATURESENSOR_UID:
device_type = EMSdevice::DeviceType::TEMPERATURESENSOR;
break;
@@ -478,11 +481,11 @@ void WebDataService::dashboard_data(AsyncWebServerRequest * request) {
}
}
- // show scheduler items
+ // show scheduler items (active state toggles)
if (EMSESP::webSchedulerService.count_entities()) {
JsonObject obj = nodes.add();
- obj["id"] = EMSdevice::DeviceTypeUniqueID::SCHEDULER_UID; // it's unique id
- obj["t"] = EMSdevice::DeviceType::SCHEDULER; // device type number
+ obj["id"] = EMSdevice::DeviceTypeUniqueID::SCHEDULER_UID;
+ obj["t"] = EMSdevice::DeviceType::SCHEDULER;
JsonArray nodes = obj["nodes"].to();
uint8_t count = 0;
@@ -495,14 +498,31 @@ void WebDataService::dashboard_data(AsyncWebServerRequest * request) {
dv["id"] = std::string("00") + scheduleItem.name;
dv["c"] = scheduleItem.name;
- // for immediate schedules, we don't show the active/inactive state or on/off options
- if (scheduleItem.flags != SCHEDULEFLAG_SCHEDULE_IMMEDIATE) {
- char s[12];
- dv["v"] = Helpers::render_boolean(s, scheduleItem.active, true);
- JsonArray l = dv["l"].to();
- l.add(Helpers::render_boolean(s, false, true)); // False option
- l.add(Helpers::render_boolean(s, true, true)); // True option
- }
+ char s[12];
+ dv["v"] = Helpers::render_boolean(s, scheduleItem.active, true);
+ JsonArray l = dv["l"].to();
+ l.add(Helpers::render_boolean(s, false, true));
+ l.add(Helpers::render_boolean(s, true, true));
+ }
+ });
+ }
+
+ // show command items (executable from dashboard)
+ if (EMSESP::webCommandService.count_entities()) {
+ JsonObject obj = nodes.add();
+ obj["id"] = EMSdevice::DeviceTypeUniqueID::COMMAND_UID;
+ obj["t"] = EMSdevice::DeviceType::COMMAND;
+ JsonArray nodes = obj["nodes"].to();
+ uint8_t count = 0;
+
+ EMSESP::webCommandService.read([&](const WebCommands & webCommands) {
+ for (const CommandItem & ci : webCommands.commandItems) {
+ JsonObject node = nodes.add();
+ node["id"] = (EMSdevice::DeviceTypeUniqueID::COMMAND_UID * 100) + count++;
+
+ JsonObject dv = node["dv"].to();
+ dv["id"] = std::string("00") + ci.name;
+ dv["c"] = ci.name;
}
});
}
diff --git a/src/web/WebSchedulerService.cpp b/src/web/WebSchedulerService.cpp
index 0fdf60984..79c3fe097 100644
--- a/src/web/WebSchedulerService.cpp
+++ b/src/web/WebSchedulerService.cpp
@@ -39,15 +39,9 @@ void WebSchedulerService::begin() {
char topic[Mqtt::MQTT_TOPIC_MAX_SIZE];
snprintf(topic, sizeof(topic), "%s/#", F_(scheduler));
Mqtt::subscribe(EMSdevice::DeviceType::SCHEDULER, topic, nullptr); // use empty function callback
-#ifndef EMSESP_STANDALONE
- if (EMSESP::system_.PSram()) {
-#if defined(CONFIG_FREERTOS_UNICORE) || (EMSESP_SCHEDULER_RUNNING_CORE < 0)
- xTaskCreate((TaskFunction_t)scheduler_task, "scheduler_task", EMSESP_SCHEDULER_STACKSIZE, NULL, EMSESP_SCHEDULER_PRIORITY, NULL);
-#else
- xTaskCreatePinnedToCore(
- (TaskFunction_t)scheduler_task, "scheduler_task", EMSESP_SCHEDULER_STACKSIZE, NULL, EMSESP_SCHEDULER_PRIORITY, NULL, EMSESP_SCHEDULER_RUNNING_CORE);
-#endif
- }
+
+#if defined(EMSESP_TEST)
+ load_test_data();
#endif
}
@@ -57,21 +51,18 @@ void WebScheduler::read(WebScheduler & webScheduler, JsonObject root) {
JsonArray schedule = root["schedule"].to();
uint8_t counter = 1;
for (const ScheduleItem & scheduleItem : webScheduler.scheduleItems) {
- JsonObject si = schedule.add();
- si["id"] = counter++; // id is only used to render the table and must be unique. 0 is for Dashboard
- si["flags"] = scheduleItem.flags;
- si["active"] = scheduleItem.flags != SCHEDULEFLAG_SCHEDULE_IMMEDIATE ? scheduleItem.active : false;
- si["time"] = scheduleItem.flags != SCHEDULEFLAG_SCHEDULE_IMMEDIATE ? scheduleItem.time : "";
- si["cmd"] = scheduleItem.cmd;
- si["value"] = scheduleItem.value;
- si["name"] = (const char *)scheduleItem.name;
+ JsonObject si = schedule.add();
+ si["id"] = counter++;
+ si["flags"] = scheduleItem.flags;
+ si["active"] = scheduleItem.active;
+ si["time"] = scheduleItem.time;
+ si["cmd_name"] = scheduleItem.cmd_name;
+ si["name"] = (const char *)scheduleItem.name;
}
}
// call on initialization and also when the Schedule web page is saved
-// this loads the data into the internal class
StateUpdateResult WebScheduler::update(JsonObject root, WebScheduler & webScheduler) {
- // reset the list
Command::erase_device_commands(EMSdevice::DeviceType::SCHEDULER);
for (ScheduleItem & scheduleItem : webScheduler.scheduleItems) {
char key[sizeof(scheduleItem.name) + 2];
@@ -83,44 +74,41 @@ StateUpdateResult WebScheduler::update(JsonObject root, WebScheduler & webSchedu
webScheduler.scheduleItems.clear();
EMSESP::webSchedulerService.ha_reset();
- // build up the list of schedule items
auto scheduleItems = root["schedule"].as();
for (const JsonObject schedule : scheduleItems) {
// create each schedule item, overwriting any previous settings
// ignore the id (as this is only used in the web for table rendering)
- auto si = ScheduleItem();
- si.active = schedule["active"];
- si.flags = schedule["flags"];
- si.time = si.flags == SCHEDULEFLAG_SCHEDULE_IMMEDIATE ? "" : schedule["time"].as();
- si.cmd = schedule["cmd"].as();
- si.value = schedule["value"].as();
+ auto si = ScheduleItem();
+ si.active = schedule["active"];
+ si.flags = schedule["flags"];
+ si.time = schedule["time"].as();
+ si.cmd_name = schedule["cmd_name"].as();
strlcpy(si.name, schedule["name"].as(), sizeof(si.name));
// calculated elapsed minutes
si.elapsed_min = Helpers::string2minutes(si.time.c_str());
- si.retry_cnt = 0xFF; // no startup retries
+ si.retry_cnt = 0xFF;
- webScheduler.scheduleItems.push_back(si); // add to list
- if (webScheduler.scheduleItems.back().name[0] != '\0') {
- char key[sizeof(webScheduler.scheduleItems.back().name) + 2];
- snprintf(key, sizeof(key), "s:%s", webScheduler.scheduleItems.back().name);
- if (EMSESP::nvs_.isKey(key) && webScheduler.scheduleItems.back().flags != SCHEDULEFLAG_SCHEDULE_IMMEDIATE) {
- webScheduler.scheduleItems.back().active = EMSESP::nvs_.getBool(key);
- }
- Command::add(
- EMSdevice::DeviceType::SCHEDULER,
- webScheduler.scheduleItems.back().name,
- [webScheduler](const char * value, const int8_t id) {
- return EMSESP::webSchedulerService.command_setvalue(value, id, webScheduler.scheduleItems.back().name);
- },
- FL_(schedule_cmd),
- CommandFlag::ADMIN_ONLY);
+ webScheduler.scheduleItems.push_back(si);
+ char key[sizeof(webScheduler.scheduleItems.back().name) + 2];
+ snprintf(key, sizeof(key), "s:%s", webScheduler.scheduleItems.back().name);
+ if (EMSESP::nvs_.isKey(key)) {
+ webScheduler.scheduleItems.back().active = EMSESP::nvs_.getBool(key);
}
+ Command::add(
+ EMSdevice::DeviceType::SCHEDULER,
+ webScheduler.scheduleItems.back().name,
+ [name = std::string(webScheduler.scheduleItems.back().name)](const char * value, const int8_t id, JsonObject output) {
+ return EMSESP::webSchedulerService.command_setvalue(value, id, name.c_str());
+ },
+ FL_(schedule_cmd),
+ CommandFlag::ADMIN_ONLY);
}
return StateUpdateResult::CHANGED;
}
// set active by api command
+// value is a boolean to enable/disable the schedule item
bool WebSchedulerService::command_setvalue(const char * value, const int8_t id, const char * name) {
bool v;
if (!Helpers::value2bool(value, v)) {
@@ -136,16 +124,13 @@ bool WebSchedulerService::command_setvalue(const char * value, const int8_t id,
scheduleItem.active = v;
publish_single(name, v);
- if (EMSESP::mqtt_.get_publish_onchange(0)) {
+ if (EMSESP::mqtt_.get_publish_onchange(EMSdevice::DeviceType::SYSTEM)) {
publish();
}
- // save new state to nvs #2946
- if (scheduleItem.flags != SCHEDULEFLAG_SCHEDULE_IMMEDIATE) {
- char key[sizeof(scheduleItem.name) + 2];
- snprintf(key, sizeof(key), "s:%s", scheduleItem.name);
- EMSESP::nvs_.putBool(key, scheduleItem.active);
- }
+ char key[sizeof(scheduleItem.name) + 2];
+ snprintf(key, sizeof(key), "s:%s", scheduleItem.name);
+ EMSESP::nvs_.putBool(key, scheduleItem.active);
return true;
}
}
@@ -218,22 +203,21 @@ void WebSchedulerService::get_value_json(JsonObject output, const ScheduleItem &
output["name"] = (const char *)scheduleItem.name;
output["fullname"] = (const char *)scheduleItem.name;
output["type"] = "boolean";
- Mqtt::add_value_bool(output, "value", scheduleItem.active);
+ Mqtt::add_value_bool(output, "active", scheduleItem.active);
if (scheduleItem.flags == SCHEDULEFLAG_SCHEDULE_CONDITION) {
output["condition"] = scheduleItem.time;
} else if (scheduleItem.flags == SCHEDULEFLAG_SCHEDULE_ONCHANGE) {
output["onchange"] = scheduleItem.time;
} else if (scheduleItem.flags == SCHEDULEFLAG_SCHEDULE_TIMER) {
output["timer"] = scheduleItem.time;
- } else if (scheduleItem.flags != SCHEDULEFLAG_SCHEDULE_IMMEDIATE) {
+ } else {
output["time"] = scheduleItem.time;
}
- output["command"] = scheduleItem.cmd;
- output["cmd_data"] = scheduleItem.value;
- bool hasName = scheduleItem.name[0] != '\0';
- output["readable"] = hasName;
- output["writeable"] = hasName;
- output["visible"] = hasName;
+ output["cmd_name"] = scheduleItem.cmd_name;
+ // bool hasName = scheduleItem.name[0] != '\0';
+ // output["readable"] = hasName;
+ // output["writeable"] = hasName;
+ // output["visible"] = hasName;
}
// publish single value
@@ -264,7 +248,7 @@ void WebSchedulerService::publish(const bool force) {
publish_single(scheduleItem.name, scheduleItem.active);
}
return;
- } else if (!EMSESP::mqtt_.get_publish_onchange(0)) {
+ } else if (!EMSESP::mqtt_.get_publish_onchange(EMSdevice::DeviceType::SYSTEM)) {
return; // wait for first time period
}
}
@@ -290,7 +274,6 @@ void WebSchedulerService::publish(const bool force) {
snprintf(val_obj, sizeof(val_obj), "value_json['%s']", scheduleItem.name);
snprintf(val_cond, sizeof(val_cond), "%s is defined", val_obj);
- // Optimized: use stack buffer instead of string concatenation to avoid heap allocations
char val_tpl[150];
if (Mqtt::discovery_type() == Mqtt::discoveryType::HOMEASSISTANT) {
snprintf(val_tpl, sizeof(val_tpl), "{{%s if %s}}", val_obj, val_cond);
@@ -304,7 +287,7 @@ void WebSchedulerService::publish(const bool force) {
config["uniq_id"] = uniq_s;
config["name"] = (const char *)scheduleItem.name;
- // Optimized: use stack buffer instead of string concatenation
+
char def_ent_id[80];
snprintf(def_ent_id, sizeof(def_ent_id), "switch.%s", uniq_s);
config["def_ent_id"] = def_ent_id;
@@ -339,84 +322,17 @@ uint8_t WebSchedulerService::count_entities() {
return static_cast(scheduleItems_ ? scheduleItems_->size() : 0);
}
-// execute scheduled command
-// return true if successful, false if not
-bool WebSchedulerService::command(const char * name, const std::string & command, const std::string & data) {
- std::string cmd = Helpers::toLower(command);
-
- // check http commands. e.g.
- // tasmota(get): http:///cm?cmnd=power%20ON
- // shelly(get): http:///relais/0?turn=on
- // parse json
- JsonDocument doc;
- if (deserializeJson(doc, cmd) == DeserializationError::Ok) {
- std::string url = doc["url"] | "";
- // for a GET with parameters replace commands with values
- // don't search the complete url, it may contain a devicename in path
- auto q = url.find_first_of('?');
- if (q != std::string::npos) {
- auto s = url.substr(q + 1); // copy only parameters
- auto l = s.length();
- commands(s, false);
- url.replace(q + 1, l, s);
- }
- std::string value = doc["value"] | data; // extract value if its in the command, or take the data
- std::string method = doc["method"] | "GET"; // default GET
- commands(value, false);
- auto lower_url = Helpers::toLower(url.c_str());
- if (lower_url.starts_with("http://") || lower_url.starts_with("https://")) {
- std::string result;
- int httpResult = http_request(url, method, value, doc["header"].as(), result);
- if (httpResult != 200) {
- EMSESP::logger().warning("Schedule '%s': URL command failed with http code %d", name, httpResult);
- return false;
- }
-#if defined(EMSESP_DEBUG)
- EMSESP::logger().debug("Schedule %s: URL '%s' command successful with http code %d", name, url.c_str(), httpResult);
-#endif
- return true;
- }
- // we can add other json tests here
+// execute the command associated with a schedule item
+// looks up the named command in WebCommandService and runs it
+bool WebSchedulerService::runScheduleCommand(const ScheduleItem & si) {
+ if (si.cmd_name.empty()) {
+ EMSESP::logger().warning("Schedule '%s': no command assigned", si.name);
+ return false;
}
-
- doc.clear();
- JsonObject input = doc.to();
- if (!data.empty()) { // empty data queries a value
- input["data"] = data;
- }
-
- JsonDocument doc_output; // only for commands without output
- JsonObject output = doc_output.to();
-
- // prefix "api/" to command string
- char command_str[COMMAND_MAX_LENGTH];
- snprintf(command_str, sizeof(command_str), "/api/%s", cmd.c_str());
-
- uint8_t return_code = Command::process(command_str, true, input, output); // admin set
- if (return_code == CommandRet::OK) {
-#if defined(EMSESP_DEBUG)
- EMSESP::logger().debug("Schedule command '%s' with data '%s' was successful", cmd.c_str(), data.c_str());
-#endif
- if (data.empty() && output.size()) {
- Mqtt::queue_publish("response", output);
- }
- return true;
- }
-
- char error[100];
- if (output.size()) {
- // check for empty name
- snprintf(error, sizeof(error), "Schedule %s: %s", name ? name : "", (const char *)output["message"]); // use error message if we have it
- } else {
- snprintf(error, sizeof(error), "Schedule %s: command %s failed with error %s", name, cmd.c_str(), Command::return_code_string(return_code));
- }
-
- EMSESP::logger().warning(error);
- return false;
+ return EMSESP::webCommandService.dispatchCommand(si.cmd_name.c_str());
}
-// called from emsesp.cpp on every entity-change
-// queue schedules to be handled executed in scheduler-loop
+// queue schedules to be handled executed in WebSchedulerService::loop() called from emsesp.cpp
bool WebSchedulerService::onChange(const char * cmd) {
for (ScheduleItem & scheduleItem : *scheduleItems_) {
if (scheduleItem.active && scheduleItem.flags == SCHEDULEFLAG_SCHEDULE_ONCHANGE && Helpers::toLower(scheduleItem.time.c_str()) == Helpers::toLower(cmd)) {
@@ -427,31 +343,16 @@ bool WebSchedulerService::onChange(const char * cmd) {
return false;
}
-// system/message evaluates its own argument later (deferred via raw_value, computed in loop()),
-// so pre-computing it here would make any {url} or expression inside it run twice. Pass
-// system/message its value raw; compute() everything else as before.
-// templated because ScheduleItem's strings use a PSRAM allocator, not std::string.
-template
-static std::string compute_cmd_value(const C & cmd, const V & value) {
- if (Helpers::toLower(cmd.c_str()) == "system/message") {
- return std::string(value.c_str());
- }
- return compute(value.c_str());
-}
-
// handle condition schedules, parse string stored in schedule.time field
void WebSchedulerService::condition() {
for (ScheduleItem & scheduleItem : *scheduleItems_) {
if (scheduleItem.active && scheduleItem.flags == SCHEDULEFLAG_SCHEDULE_CONDITION) {
auto match = compute(scheduleItem.time.c_str());
-#ifdef EMESESP_DEBUG
- // EMSESP::logger().debug("condition match: %s", match.c_str());
-#endif
if (match.length() == 1 && match[0] == '1' && scheduleItem.retry_cnt == 0xFF) {
- scheduleItem.retry_cnt = command(scheduleItem.name, scheduleItem.cmd.c_str(), compute_cmd_value(scheduleItem.cmd, scheduleItem.value)) ? 1 : 0xFF;
+ scheduleItem.retry_cnt = runScheduleCommand(scheduleItem) ? 1 : 0xFF;
} else if (match.length() == 1 && match[0] == '0' && scheduleItem.retry_cnt == 1) {
scheduleItem.retry_cnt = 0xFF;
- } else if (match.length() != 1) { // the match is not boolean
+ } else if (match.length() != 1) {
#if defined(EMSESP_DEBUG)
EMSESP::logger().debug("condition result: %s", match.c_str());
#endif
@@ -462,17 +363,15 @@ void WebSchedulerService::condition() {
// process any scheduled jobs
void WebSchedulerService::loop() {
- // initialize static value on startup
static int8_t last_tm_min = -2; // invalid value also used for startup commands
static uint32_t last_uptime_min = 0;
static uint32_t last_uptime_sec = 0;
- if (!raw_value.empty()) { // process a value from system/message command
+ if (!raw_value.empty()) {
computed_value = compute(raw_value);
raw_value.clear();
}
- // get list of scheduler events and exit if it's empty
if (scheduleItems_->empty()) {
return;
}
@@ -480,21 +379,10 @@ void WebSchedulerService::loop() {
// check if we have onChange events
while (!cmd_changed_.empty()) {
ScheduleItem si = *cmd_changed_.front();
- command(si.name, si.cmd.c_str(), compute_cmd_value(si.cmd, si.value));
+ runScheduleCommand(si);
cmd_changed_.pop_front();
}
- for (ScheduleItem & scheduleItem : *scheduleItems_) {
- if (scheduleItem.active && scheduleItem.flags == SCHEDULEFLAG_SCHEDULE_IMMEDIATE) {
- command(scheduleItem.name, scheduleItem.cmd.c_str(), compute_cmd_value(scheduleItem.cmd, scheduleItem.value));
- scheduleItem.active = false;
- publish_single(scheduleItem.name, false);
- if (EMSESP::mqtt_.get_publish_onchange(0)) {
- publish();
- }
- }
- }
-
// check conditions every 10 seconds, start after one minute
uint32_t uptime_sec = uuid::get_uptime_sec() / 10;
if (last_uptime_sec != uptime_sec && uptime_sec > 5) {
@@ -506,106 +394,87 @@ void WebSchedulerService::loop() {
if (last_tm_min == -2) {
for (ScheduleItem & scheduleItem : *scheduleItems_) {
if (scheduleItem.active && scheduleItem.flags == SCHEDULEFLAG_SCHEDULE_TIMER && scheduleItem.elapsed_min == 0) {
- scheduleItem.retry_cnt = command(scheduleItem.name, scheduleItem.cmd.c_str(), compute_cmd_value(scheduleItem.cmd, scheduleItem.value)) ? 0xFF : 0;
+ scheduleItem.retry_cnt = runScheduleCommand(scheduleItem) ? 0xFF : 0;
}
}
- last_tm_min = -1; // startup done, now use for RTC
+ last_tm_min = -1;
}
// check timer every minute, sync to EMS-ESP clock
uint32_t uptime_min = uuid::get_uptime_sec() / 60;
if (last_uptime_min != uptime_min) {
for (ScheduleItem & scheduleItem : *scheduleItems_) {
- // retry startup commands not yet executed
if (scheduleItem.active && scheduleItem.flags == SCHEDULEFLAG_SCHEDULE_TIMER && scheduleItem.elapsed_min == 0
&& scheduleItem.retry_cnt < MAX_STARTUP_RETRIES) {
- scheduleItem.retry_cnt = command(scheduleItem.name, scheduleItem.cmd.c_str(), scheduleItem.value.c_str()) ? 0xFF : scheduleItem.retry_cnt + 1;
+ scheduleItem.retry_cnt = runScheduleCommand(scheduleItem) ? 0xFF : scheduleItem.retry_cnt + 1;
}
- // scheduled timer commands
if (scheduleItem.active && scheduleItem.flags == SCHEDULEFLAG_SCHEDULE_TIMER && scheduleItem.elapsed_min > 0
&& (uptime_min % scheduleItem.elapsed_min == 0)) {
- command(scheduleItem.name, scheduleItem.cmd.c_str(), compute_cmd_value(scheduleItem.cmd, scheduleItem.value));
+ runScheduleCommand(scheduleItem);
}
}
last_uptime_min = uptime_min;
}
- // check calender, sync to RTC, only execute if year is valid
+ // check calendar, sync to RTC, only execute if year is valid
time_t now = time(nullptr);
tm * tm = localtime(&now);
if (tm->tm_min != last_tm_min && tm->tm_year > 120) {
- // find the real dow and minute from RTC
- uint8_t real_dow = 1 << tm->tm_wday; // 1 is Sunday
+ uint8_t real_dow = 1 << tm->tm_wday;
uint16_t real_min = tm->tm_hour * 60 + tm->tm_min;
for (const ScheduleItem & scheduleItem : *scheduleItems_) {
uint8_t dow = scheduleItem.flags & SCHEDULEFLAG_SCHEDULE_TIMER ? 0 : scheduleItem.flags;
if (scheduleItem.active && (real_dow & dow) && real_min == scheduleItem.elapsed_min) {
- command(scheduleItem.name, scheduleItem.cmd.c_str(), compute_cmd_value(scheduleItem.cmd, scheduleItem.value));
+ runScheduleCommand(scheduleItem);
}
}
last_tm_min = tm->tm_min;
}
}
-// execute a schedule item immediately
-bool WebSchedulerService::executeSchedule(const char * name) {
- for (ScheduleItem & scheduleItem : *scheduleItems_) {
- if (scheduleItem.flags == SCHEDULEFLAG_SCHEDULE_IMMEDIATE && strcmp(scheduleItem.name, name) == 0) {
- EMSESP::logger().info("Executing schedule '%s'", name);
- return command(scheduleItem.name, scheduleItem.cmd.c_str(), compute_cmd_value(scheduleItem.cmd, scheduleItem.value));
- }
- }
- EMSESP::logger().warning("Schedule '%s' not found", name);
- return false; // not found
-}
-
-// process schedules async
-void WebSchedulerService::scheduler_task(void * pvParameters) {
- while (1) {
- delay(10); // no need to hurry
- if (EMSESP::system_.systemStatus() == SYSTEM_STATUS::SYSTEM_STATUS_NORMAL) {
- EMSESP::webSchedulerService.loop();
- }
- }
-#ifndef EMSESP_STANDALONE
- vTaskDelete(NULL);
-#endif
-}
-
-// hard coded tests
#if defined(EMSESP_TEST)
void WebSchedulerService::load_test_data() {
+ Command::erase_device_commands(EMSdevice::DeviceType::SCHEDULER);
update([&](WebScheduler & webScheduler) {
- webScheduler.scheduleItems.clear(); // delete all existing schedules
+ webScheduler.scheduleItems.clear();
- // test 1
- auto si = ScheduleItem();
- si.active = true;
- si.flags = 1; // day schedule
- si.time = "12:00";
- si.cmd = "system/fetch";
- si.value = "10";
+ auto si = ScheduleItem();
+ si.active = true;
+ si.flags = 1; // day schedule
+ si.time = "12:00";
+ si.cmd_name = "fetch_values";
strcpy(si.name, "test_scheduler1");
si.elapsed_min = 0;
- si.retry_cnt = 0xFF; // no startup retries
+ si.retry_cnt = 0xFF;
webScheduler.scheduleItems.push_back(si);
- // test 2
- si = ScheduleItem();
- si.active = false;
- si.flags = SCHEDULEFLAG_SCHEDULE_IMMEDIATE; // immediate
- si.time = "13:00";
- si.cmd = "system/message";
- si.value = "20";
- strcpy(si.name, "test_scheduler2"); // to make sure its excluded from Dashboard
- si.elapsed_min = 0;
- si.retry_cnt = 0xFF; // no startup retries
+ si = ScheduleItem();
+ si.active = true;
+ si.flags = SCHEDULEFLAG_SCHEDULE_TIMER;
+ si.time = "01:00";
+ si.cmd_name = "send_message";
+ strcpy(si.name, "test_scheduler2");
+ si.elapsed_min = 60;
+ si.retry_cnt = 0xFF;
webScheduler.scheduleItems.push_back(si);
- return StateUpdateResult::CHANGED; // persist the changes
+ for (const auto & item : webScheduler.scheduleItems) {
+ if (item.name[0] != '\0') {
+ Command::add(
+ EMSdevice::DeviceType::SCHEDULER,
+ item.name,
+ [name = std::string(item.name)](const char * value, const int8_t id, JsonObject output) {
+ return EMSESP::webSchedulerService.command_setvalue(value, id, name.c_str());
+ },
+ FL_(schedule_cmd),
+ CommandFlag::ADMIN_ONLY);
+ }
+ }
+
+ return StateUpdateResult::CHANGED;
});
}
#endif
diff --git a/src/web/WebSchedulerService.h b/src/web/WebSchedulerService.h
index 281f44a0b..db21f38cf 100644
--- a/src/web/WebSchedulerService.h
+++ b/src/web/WebSchedulerService.h
@@ -24,28 +24,14 @@
#define EMSESP_SCHEDULER_FILE "/config/emsespScheduler.json"
#define EMSESP_SCHEDULER_SERVICE_PATH "/rest/schedule" // GET and POST
-#ifndef EMSESP_SCHEDULER_RUNNING_CORE
-#define EMSESP_SCHEDULER_RUNNING_CORE 1
-#endif
-
-#ifndef EMSESP_SCHEDULER_STACKSIZE
-#define EMSESP_SCHEDULER_STACKSIZE 5120
-#endif
-
-#ifndef EMSESP_SCHEDULER_PRIORITY
-#define EMSESP_SCHEDULER_PRIORITY 1
-#endif
-
// bit flags for the schedule items. Matches those in interface/src/app/main/SchedulerDialog.tsx
// 0-127 (0->0x7F) is day schedule
// 128 (0x80) is timer
// 129 (0x81) is on change
// 130 (0x82) is on condition
-// 132 (0x84) is immediate
#define SCHEDULEFLAG_SCHEDULE_TIMER 0x80 // 7th bit for Timer
#define SCHEDULEFLAG_SCHEDULE_ONCHANGE 0x81 // 7th+1st bit for OnChange
#define SCHEDULEFLAG_SCHEDULE_CONDITION 0x82 // 7th+2nd bit for Condition
-#define SCHEDULEFLAG_SCHEDULE_IMMEDIATE 0x84 // 7th+3rd bit for Immediate
#define MAX_STARTUP_RETRIES 3 // retry the start-up commands x times
@@ -53,12 +39,11 @@ namespace emsesp {
class ScheduleItem {
public:
- boolean active;
+ boolean active; // on or off
uint8_t flags; // bit flags, see SCHEDULEFLAG_* defines
uint16_t elapsed_min; // total mins from 00:00
stringPSRAM time; // HH:MM
- stringPSRAM cmd;
- stringPSRAM value;
+ stringPSRAM cmd_name; // references a named command from WebCommandService
char name[20];
uint8_t retry_cnt;
};
@@ -88,8 +73,6 @@ class WebSchedulerService : public StatefulService {
uint8_t count_entities();
bool onChange(const char * cmd);
- bool executeSchedule(const char * name);
-
std::string get_metrics_prometheus();
std::string raw_value;
@@ -103,9 +86,7 @@ class WebSchedulerService : public StatefulService {
#ifndef EMSESP_STANDALONE
private:
#endif
- static void scheduler_task(void * pvParameters);
-
- bool command(const char * name, const std::string & cmd, const std::string & data);
+ bool runScheduleCommand(const ScheduleItem & si);
void condition();
HttpEndpoint _httpEndpoint;
diff --git a/src/web/WebStatusService.cpp b/src/web/WebStatusService.cpp
index 8cb934cdf..c351eb771 100644
--- a/src/web/WebStatusService.cpp
+++ b/src/web/WebStatusService.cpp
@@ -232,8 +232,8 @@ void WebStatusService::action(AsyncWebServerRequest * request, JsonVariant json)
EMSESP::mqtt_.reset_mqtt();
} else if (action == "upgradeImportantMessages") {
root["upgradeImportantMessageType"] = upgradeImportantMessages(param);
- } else if (action == "executeSchedule") {
- ok = EMSESP::webSchedulerService.executeSchedule(param.c_str());
+ } else if (action == "executeCommand") {
+ ok = EMSESP::webCommandService.dispatchCommand(param.c_str()); // command worker task (ok = dispatched); fast internal commands run inline (ok = real result)
}
#if defined(EMSESP_STANDALONE) && !defined(EMSESP_UNITY)
diff --git a/test/test_api/package.json b/test/test_api/package.json
deleted file mode 100644
index c6bf7266a..000000000
--- a/test/test_api/package.json
+++ /dev/null
@@ -1,5 +0,0 @@
-{
- "dependencies": {
- "axios": "^1.13.2"
- }
-}
diff --git a/test/test_api/test_api.h b/test/test_api/test_api.h
index c11b7e08e..9efe5727d 100644
--- a/test/test_api/test_api.h
+++ b/test/test_api/test_api.h
@@ -95,7 +95,7 @@ void test_19() {
}
void test_20() {
- auto expected_response = "[{\"name\":\"test_seltemp\",\"fullname\":\"test_seltemp\",\"storage\":\"ram\",\"type\":\"number\",\"readable\":true,\"writeable\":true,\"visible\":true,\"ent_cat\":\"diagnostic\",\"value\":\"14\"}]";
+ auto expected_response = "[{\"name\":\"test_seltemp\",\"fullname\":\"test_seltemp\",\"storage\":\"ram\",\"type\":\"number\",\"writeable\":true,\"ent_cat\":\"diagnostic\",\"value\":\"14\"}]";
TEST_ASSERT_EQUAL_STRING(expected_response, call_url("/api/custom/test_seltemp"));
}
@@ -105,22 +105,22 @@ void test_21() {
}
void test_22() {
- auto expected_response = "[{\"name\":\"test_custom\",\"fullname\":\"test_custom\",\"storage\":\"ems\",\"type\":\"number\",\"readable\":true,\"writeable\":true,\"visible\":true,\"device_id\":\"0x08\",\"type_id\":\"0x18\",\"offset\":0,\"factor\":1,\"ent_cat\":\"diagnostic\",\"uom\":\"°C\",\"state_class\":\"measurement\",\"device_class\":\"temperature\",\"value\":0}]";
+ auto expected_response = "[{\"name\":\"test_custom\",\"fullname\":\"test_custom\",\"storage\":\"ems\",\"type\":\"number\",\"writeable\":true,\"device_id\":\"0x08\",\"type_id\":\"0x18\",\"offset\":0,\"factor\":1,\"ent_cat\":\"diagnostic\",\"uom\":\"°C\",\"state_class\":\"measurement\",\"device_class\":\"temperature\",\"value\":0}]";
TEST_ASSERT_EQUAL_STRING(expected_response, call_url("/api/custom/test_custom"));
}
void test_23() {
- auto expected_response = "[{\"system\":{\"version\":\"dev\",\"uptime\":\"000+00:00:00.000\",\"uptimeSec\":0,\"resetReason\":\"Unknown / Unknown\",\"txpause\":false,\"gpios_allowed\":\"0, 2, 5, 18, 23\",\"gpios_in_use\":\"0, 2, 5, 18, 23\",\"gpios_available\":\"\"},\"network\":{\"network\":\"WiFi\",\"hostname\":\"ems-esp\",\"RSSI\":-23,\"TxPowerSetting\":0,\"staticIP\":false,\"lowBandwidth\":false,\"disableSleep\":true,\"enableMDNS\":true,\"enableCORS\":false},\"ntp\":{\"NTPstatus\":\"disconnected\",\"enabled\":true,\"server\":\"pool.ntp.org\",\"tzLabel\":\"Europe/London\",\"NTPStatus\":\"disconnected\"},\"ap\":{\"provisionMode\":\"always\",\"ssid\":\"ems-esp\"},\"mqtt\":{\"MQTTStatus\":\"disconnected\",\"MQTTPublishes\":0,\"MQTTQueued\":0,\"MQTTPublishFails\":0,\"MQTTReconnects\":0,\"enabled\":true,\"clientID\":\"ems-esp\",\"keepAlive\":60,\"cleanSession\":false,\"entityFormat\":1,\"base\":\"ems-esp\",\"discoveryPrefix\":\"homeassistant\",\"discoveryType\":0,\"nestedFormat\":1,\"haEnabled\":true,\"mqttQos\":0,\"mqttRetain\":false,\"publishTimeHeartbeat\":60,\"publishTimeBoiler\":10,\"publishTimeThermostat\":10,\"publishTimeSolar\":10,\"publishTimeMixer\":10,\"publishTimeWater\":0,\"publishTimeOther\":10,\"publishTimeSensor\":10,\"publishSingle\":false,\"publish2command\":false,\"sendResponse\":false},\"syslog\":{\"enabled\":false},\"modbus\":{\"enabled\":false},\"sensor\":{\"temperatureSensors\":3,\"temperatureSensorReads\":0,\"temperatureSensorFails\":0},\"analog\":{\"enabled\":true,\"analogSensors\":5,\"analogSensorReads\":0,\"analogSensorFails\":0},\"api\":{\"APICalls\":0,\"APIFails\":0},\"bus\":{\"busStatus\":\"connected\",\"busProtocol\":\"Buderus\",\"busTelegramsReceived\":8,\"busReads\":0,\"busWrites\":0,\"busIncompleteTelegrams\":0,\"busReadsFailed\":0,\"busWritesFailed\":0,\"busRxLineQuality\":100,\"busTxLineQuality\":100},\"settings\":{\"boardProfile\":\"S32\",\"locale\":\"en\",\"txMode\":5,\"emsBusID\":73,\"showerTimer\":false,\"showerMinDuration\":180,\"showerAlert\":false,\"hideLed\":false,\"noTokenApi\":false,\"readonlyMode\":false,\"fahrenheit\":false,\"dallasParasite\":false,\"boolFormat\":1,\"boolDashboard\":1,\"enumFormat\":1,\"analogEnabled\":true,\"telnetEnabled\":true,\"maxWebLogBuffer\":25,\"modbusEnabled\":false,\"forceHeatingOff\":false,\"developerMode\":false},\"devices\":[{\"type\":\"boiler\",\"name\":\"My Custom Boiler\",\"deviceID\":\"0x08\",\"productID\":123,\"brand\":\"\",\"version\":\"01.00\",\"entities\":39,\"handlersReceived\":\"0x18\",\"handlersFetched\":\"0x14 0x33\",\"handlersPending\":\"0xBF 0x10 0x11 0xC2 0xC6 0x15 0x1C 0x19 0x1A 0x35 0x34 0x2A 0xD1 0xE3 0xE4 0xE5 0xE9 0x02E0 0x2E 0x3B\"},{\"type\":\"thermostat\",\"name\":\"FW120\",\"deviceID\":\"0x10\",\"productID\":192,\"brand\":\"\",\"version\":\"01.00\",\"entities\":12,\"handlersReceived\":\"0x016F\",\"handlersFetched\":\"0x0170 0x0171\",\"handlersPending\":\"0xA3 0x06 0xA2 0x12 0x13 0x0172 0x0165 0x0168\"},{\"type\":\"temperaturesensor\",\"name\":\"temperaturesensor\",\"entities\":3},{\"type\":\"analogsensor\",\"name\":\"analogsensor\",\"entities\":5},{\"type\":\"scheduler\",\"name\":\"scheduler\",\"entities\":2},{\"type\":\"custom\",\"name\":\"custom\",\"entities\":4}]}]";
+ auto expected_response = "[{\"system\":{\"version\":\"dev\",\"uptime\":\"000+00:00:00.000\",\"uptimeSec\":0,\"resetReason\":\"Unknown / Unknown\",\"txpause\":false,\"gpios_allowed\":\"0, 2, 5, 18, 23\",\"gpios_in_use\":\"0, 2, 5, 18, 23\",\"gpios_available\":\"\"},\"network\":{\"network\":\"WiFi\",\"hostname\":\"ems-esp\",\"RSSI\":-23,\"TxPowerSetting\":0,\"staticIP\":false,\"lowBandwidth\":false,\"disableSleep\":true,\"enableMDNS\":true,\"enableCORS\":false},\"ntp\":{\"NTPstatus\":\"disconnected\",\"enabled\":true,\"server\":\"pool.ntp.org\",\"tzLabel\":\"Europe/London\",\"NTPStatus\":\"disconnected\"},\"ap\":{\"provisionMode\":\"always\",\"ssid\":\"ems-esp\"},\"mqtt\":{\"MQTTStatus\":\"disconnected\",\"MQTTPublishes\":0,\"MQTTQueued\":0,\"MQTTPublishFails\":0,\"MQTTReconnects\":0,\"enabled\":true,\"clientID\":\"ems-esp\",\"keepAlive\":60,\"cleanSession\":false,\"entityFormat\":1,\"base\":\"ems-esp\",\"discoveryPrefix\":\"homeassistant\",\"discoveryType\":0,\"nestedFormat\":1,\"haEnabled\":true,\"mqttQos\":0,\"mqttRetain\":false,\"publishTimeHeartbeat\":60,\"publishTimeBoiler\":10,\"publishTimeThermostat\":10,\"publishTimeSolar\":10,\"publishTimeMixer\":10,\"publishTimeWater\":0,\"publishTimeOther\":10,\"publishTimeSensor\":10,\"publishSingle\":false,\"publish2command\":false,\"sendResponse\":false},\"syslog\":{\"enabled\":false},\"modbus\":{\"enabled\":false},\"sensor\":{\"temperatureSensors\":3,\"temperatureSensorReads\":0,\"temperatureSensorFails\":0},\"analog\":{\"enabled\":true,\"analogSensors\":5,\"analogSensorReads\":0,\"analogSensorFails\":0},\"api\":{\"APICalls\":0,\"APIFails\":0},\"bus\":{\"busStatus\":\"connected\",\"busProtocol\":\"Buderus\",\"busTelegramsReceived\":8,\"busReads\":0,\"busWrites\":0,\"busIncompleteTelegrams\":0,\"busReadsFailed\":0,\"busWritesFailed\":0,\"busRxLineQuality\":100,\"busTxLineQuality\":100},\"settings\":{\"boardProfile\":\"S32\",\"locale\":\"en\",\"txMode\":5,\"emsBusID\":73,\"showerTimer\":false,\"showerMinDuration\":180,\"showerAlert\":false,\"hideLed\":false,\"noTokenApi\":false,\"readonlyMode\":false,\"fahrenheit\":false,\"dallasParasite\":false,\"boolFormat\":1,\"boolDashboard\":1,\"enumFormat\":1,\"analogEnabled\":true,\"telnetEnabled\":true,\"maxWebLogBuffer\":25,\"modbusEnabled\":false,\"forceHeatingOff\":false,\"developerMode\":false},\"devices\":[{\"type\":\"boiler\",\"name\":\"My Custom Boiler\",\"deviceID\":\"0x08\",\"productID\":123,\"brand\":\"\",\"version\":\"01.00\",\"entities\":39,\"handlersReceived\":\"0x18\",\"handlersFetched\":\"0x14 0x33\",\"handlersPending\":\"0xBF 0x10 0x11 0xC2 0xC6 0x15 0x1C 0x19 0x1A 0x35 0x34 0x2A 0xD1 0xE3 0xE4 0xE5 0xE9 0x02E0 0x2E 0x3B\"},{\"type\":\"thermostat\",\"name\":\"FW120\",\"deviceID\":\"0x10\",\"productID\":192,\"brand\":\"\",\"version\":\"01.00\",\"entities\":12,\"handlersReceived\":\"0x016F\",\"handlersFetched\":\"0x0170 0x0171\",\"handlersPending\":\"0xA3 0x06 0xA2 0x12 0x13 0x0172 0x0165 0x0168\"},{\"type\":\"temperaturesensor\",\"name\":\"temperaturesensor\",\"entities\":3},{\"type\":\"analogsensor\",\"name\":\"analogsensor\",\"entities\":5},{\"type\":\"scheduler\",\"name\":\"scheduler\",\"entities\":2},{\"type\":\"commands\",\"name\":\"commands\",\"entities\":3},{\"type\":\"custom\",\"name\":\"custom\",\"entities\":4}]}]";
TEST_ASSERT_EQUAL_STRING(expected_response, call_url("/api/system"));
}
void test_24() {
- auto expected_response = "[{\"system\":{\"version\":\"dev\",\"uptime\":\"000+00:00:00.000\",\"uptimeSec\":0,\"resetReason\":\"Unknown / Unknown\",\"txpause\":false,\"gpios_allowed\":\"0, 2, 5, 18, 23\",\"gpios_in_use\":\"0, 2, 5, 18, 23\",\"gpios_available\":\"\"},\"network\":{\"network\":\"WiFi\",\"hostname\":\"ems-esp\",\"RSSI\":-23,\"TxPowerSetting\":0,\"staticIP\":false,\"lowBandwidth\":false,\"disableSleep\":true,\"enableMDNS\":true,\"enableCORS\":false},\"ntp\":{\"NTPstatus\":\"disconnected\",\"enabled\":true,\"server\":\"pool.ntp.org\",\"tzLabel\":\"Europe/London\",\"NTPStatus\":\"disconnected\"},\"ap\":{\"provisionMode\":\"always\",\"ssid\":\"ems-esp\"},\"mqtt\":{\"MQTTStatus\":\"disconnected\",\"MQTTPublishes\":0,\"MQTTQueued\":0,\"MQTTPublishFails\":0,\"MQTTReconnects\":0,\"enabled\":true,\"clientID\":\"ems-esp\",\"keepAlive\":60,\"cleanSession\":false,\"entityFormat\":1,\"base\":\"ems-esp\",\"discoveryPrefix\":\"homeassistant\",\"discoveryType\":0,\"nestedFormat\":1,\"haEnabled\":true,\"mqttQos\":0,\"mqttRetain\":false,\"publishTimeHeartbeat\":60,\"publishTimeBoiler\":10,\"publishTimeThermostat\":10,\"publishTimeSolar\":10,\"publishTimeMixer\":10,\"publishTimeWater\":0,\"publishTimeOther\":10,\"publishTimeSensor\":10,\"publishSingle\":false,\"publish2command\":false,\"sendResponse\":false},\"syslog\":{\"enabled\":false},\"modbus\":{\"enabled\":false},\"sensor\":{\"temperatureSensors\":3,\"temperatureSensorReads\":0,\"temperatureSensorFails\":0},\"analog\":{\"enabled\":true,\"analogSensors\":5,\"analogSensorReads\":0,\"analogSensorFails\":0},\"api\":{\"APICalls\":0,\"APIFails\":0},\"bus\":{\"busStatus\":\"connected\",\"busProtocol\":\"Buderus\",\"busTelegramsReceived\":8,\"busReads\":0,\"busWrites\":0,\"busIncompleteTelegrams\":0,\"busReadsFailed\":0,\"busWritesFailed\":0,\"busRxLineQuality\":100,\"busTxLineQuality\":100},\"settings\":{\"boardProfile\":\"S32\",\"locale\":\"en\",\"txMode\":5,\"emsBusID\":73,\"showerTimer\":false,\"showerMinDuration\":180,\"showerAlert\":false,\"hideLed\":false,\"noTokenApi\":false,\"readonlyMode\":false,\"fahrenheit\":false,\"dallasParasite\":false,\"boolFormat\":1,\"boolDashboard\":1,\"enumFormat\":1,\"analogEnabled\":true,\"telnetEnabled\":true,\"maxWebLogBuffer\":25,\"modbusEnabled\":false,\"forceHeatingOff\":false,\"developerMode\":false},\"devices\":[{\"type\":\"boiler\",\"name\":\"My Custom Boiler\",\"deviceID\":\"0x08\",\"productID\":123,\"brand\":\"\",\"version\":\"01.00\",\"entities\":39,\"handlersReceived\":\"0x18\",\"handlersFetched\":\"0x14 0x33\",\"handlersPending\":\"0xBF 0x10 0x11 0xC2 0xC6 0x15 0x1C 0x19 0x1A 0x35 0x34 0x2A 0xD1 0xE3 0xE4 0xE5 0xE9 0x02E0 0x2E 0x3B\"},{\"type\":\"thermostat\",\"name\":\"FW120\",\"deviceID\":\"0x10\",\"productID\":192,\"brand\":\"\",\"version\":\"01.00\",\"entities\":12,\"handlersReceived\":\"0x016F\",\"handlersFetched\":\"0x0170 0x0171\",\"handlersPending\":\"0xA3 0x06 0xA2 0x12 0x13 0x0172 0x0165 0x0168\"},{\"type\":\"temperaturesensor\",\"name\":\"temperaturesensor\",\"entities\":3},{\"type\":\"analogsensor\",\"name\":\"analogsensor\",\"entities\":5},{\"type\":\"scheduler\",\"name\":\"scheduler\",\"entities\":2},{\"type\":\"custom\",\"name\":\"custom\",\"entities\":4}]}]";
+ auto expected_response = "[{\"system\":{\"version\":\"dev\",\"uptime\":\"000+00:00:00.000\",\"uptimeSec\":0,\"resetReason\":\"Unknown / Unknown\",\"txpause\":false,\"gpios_allowed\":\"0, 2, 5, 18, 23\",\"gpios_in_use\":\"0, 2, 5, 18, 23\",\"gpios_available\":\"\"},\"network\":{\"network\":\"WiFi\",\"hostname\":\"ems-esp\",\"RSSI\":-23,\"TxPowerSetting\":0,\"staticIP\":false,\"lowBandwidth\":false,\"disableSleep\":true,\"enableMDNS\":true,\"enableCORS\":false},\"ntp\":{\"NTPstatus\":\"disconnected\",\"enabled\":true,\"server\":\"pool.ntp.org\",\"tzLabel\":\"Europe/London\",\"NTPStatus\":\"disconnected\"},\"ap\":{\"provisionMode\":\"always\",\"ssid\":\"ems-esp\"},\"mqtt\":{\"MQTTStatus\":\"disconnected\",\"MQTTPublishes\":0,\"MQTTQueued\":0,\"MQTTPublishFails\":0,\"MQTTReconnects\":0,\"enabled\":true,\"clientID\":\"ems-esp\",\"keepAlive\":60,\"cleanSession\":false,\"entityFormat\":1,\"base\":\"ems-esp\",\"discoveryPrefix\":\"homeassistant\",\"discoveryType\":0,\"nestedFormat\":1,\"haEnabled\":true,\"mqttQos\":0,\"mqttRetain\":false,\"publishTimeHeartbeat\":60,\"publishTimeBoiler\":10,\"publishTimeThermostat\":10,\"publishTimeSolar\":10,\"publishTimeMixer\":10,\"publishTimeWater\":0,\"publishTimeOther\":10,\"publishTimeSensor\":10,\"publishSingle\":false,\"publish2command\":false,\"sendResponse\":false},\"syslog\":{\"enabled\":false},\"modbus\":{\"enabled\":false},\"sensor\":{\"temperatureSensors\":3,\"temperatureSensorReads\":0,\"temperatureSensorFails\":0},\"analog\":{\"enabled\":true,\"analogSensors\":5,\"analogSensorReads\":0,\"analogSensorFails\":0},\"api\":{\"APICalls\":0,\"APIFails\":0},\"bus\":{\"busStatus\":\"connected\",\"busProtocol\":\"Buderus\",\"busTelegramsReceived\":8,\"busReads\":0,\"busWrites\":0,\"busIncompleteTelegrams\":0,\"busReadsFailed\":0,\"busWritesFailed\":0,\"busRxLineQuality\":100,\"busTxLineQuality\":100},\"settings\":{\"boardProfile\":\"S32\",\"locale\":\"en\",\"txMode\":5,\"emsBusID\":73,\"showerTimer\":false,\"showerMinDuration\":180,\"showerAlert\":false,\"hideLed\":false,\"noTokenApi\":false,\"readonlyMode\":false,\"fahrenheit\":false,\"dallasParasite\":false,\"boolFormat\":1,\"boolDashboard\":1,\"enumFormat\":1,\"analogEnabled\":true,\"telnetEnabled\":true,\"maxWebLogBuffer\":25,\"modbusEnabled\":false,\"forceHeatingOff\":false,\"developerMode\":false},\"devices\":[{\"type\":\"boiler\",\"name\":\"My Custom Boiler\",\"deviceID\":\"0x08\",\"productID\":123,\"brand\":\"\",\"version\":\"01.00\",\"entities\":39,\"handlersReceived\":\"0x18\",\"handlersFetched\":\"0x14 0x33\",\"handlersPending\":\"0xBF 0x10 0x11 0xC2 0xC6 0x15 0x1C 0x19 0x1A 0x35 0x34 0x2A 0xD1 0xE3 0xE4 0xE5 0xE9 0x02E0 0x2E 0x3B\"},{\"type\":\"thermostat\",\"name\":\"FW120\",\"deviceID\":\"0x10\",\"productID\":192,\"brand\":\"\",\"version\":\"01.00\",\"entities\":12,\"handlersReceived\":\"0x016F\",\"handlersFetched\":\"0x0170 0x0171\",\"handlersPending\":\"0xA3 0x06 0xA2 0x12 0x13 0x0172 0x0165 0x0168\"},{\"type\":\"temperaturesensor\",\"name\":\"temperaturesensor\",\"entities\":3},{\"type\":\"analogsensor\",\"name\":\"analogsensor\",\"entities\":5},{\"type\":\"scheduler\",\"name\":\"scheduler\",\"entities\":2},{\"type\":\"commands\",\"name\":\"commands\",\"entities\":3},{\"type\":\"custom\",\"name\":\"custom\",\"entities\":4}]}]";
TEST_ASSERT_EQUAL_STRING(expected_response, call_url("/api/system/info"));
}
void test_25() {
- auto expected_response = "[{\"api_data\":\"# HELP emsesp_system_uptimesec uptimeSec\\n# TYPE emsesp_system_uptimesec gauge\\nemsesp_system_uptimesec 0\\n# HELP emsesp_system_txpause txpause\\n# TYPE emsesp_system_txpause gauge\\nemsesp_system_txpause 0\\n# HELP emsesp_system_info info\\n# TYPE emsesp_system_info gauge\\nemsesp_system_info{version=\\\"dev\\\", resetreason=\\\"Unknown / Unknown\\\", gpios_allowed=\\\"0, 2, 5, 18, 23\\\", gpios_in_use=\\\"0, 2, 5, 18, 23\\\"} 1\\n# HELP emsesp_network_rssi RSSI\\n# TYPE emsesp_network_rssi gauge\\nemsesp_network_rssi -23\\n# HELP emsesp_network_txpowersetting TxPowerSetting\\n# TYPE emsesp_network_txpowersetting gauge\\nemsesp_network_txpowersetting 0\\n# HELP emsesp_network_staticip staticIP\\n# TYPE emsesp_network_staticip gauge\\nemsesp_network_staticip 0\\n# HELP emsesp_network_lowbandwidth lowBandwidth\\n# TYPE emsesp_network_lowbandwidth gauge\\nemsesp_network_lowbandwidth 0\\n# HELP emsesp_network_disablesleep disableSleep\\n# TYPE emsesp_network_disablesleep gauge\\nemsesp_network_disablesleep 1\\n# HELP emsesp_network_enablemdns enableMDNS\\n# TYPE emsesp_network_enablemdns gauge\\nemsesp_network_enablemdns 1\\n# HELP emsesp_network_enablecors enableCORS\\n# TYPE emsesp_network_enablecors gauge\\nemsesp_network_enablecors 0\\n# HELP emsesp_network_info info\\n# TYPE emsesp_network_info gauge\\nemsesp_network_info{network=\\\"WiFi\\\", hostname=\\\"ems-esp\\\"} 1\\n# HELP emsesp_ntp_enabled enabled\\n# TYPE emsesp_ntp_enabled gauge\\nemsesp_ntp_enabled 1\\n# HELP emsesp_ntp_info info\\n# TYPE emsesp_ntp_info gauge\\nemsesp_ntp_info{ntpstatus=\\\"disconnected\\\", server=\\\"pool.ntp.org\\\", tzlabel=\\\"Europe/London\\\"} 1\\n# HELP emsesp_ap_info info\\n# TYPE emsesp_ap_info gauge\\nemsesp_ap_info{provisionmode=\\\"always\\\", ssid=\\\"ems-esp\\\"} 1\\n# HELP emsesp_mqtt_mqttpublishes MQTTPublishes\\n# TYPE emsesp_mqtt_mqttpublishes gauge\\nemsesp_mqtt_mqttpublishes 0\\n# HELP emsesp_mqtt_mqttqueued MQTTQueued\\n# TYPE emsesp_mqtt_mqttqueued gauge\\nemsesp_mqtt_mqttqueued 0\\n# HELP emsesp_mqtt_mqttpublishfails MQTTPublishFails\\n# TYPE emsesp_mqtt_mqttpublishfails gauge\\nemsesp_mqtt_mqttpublishfails 0\\n# HELP emsesp_mqtt_mqttreconnects MQTTReconnects\\n# TYPE emsesp_mqtt_mqttreconnects gauge\\nemsesp_mqtt_mqttreconnects 0\\n# HELP emsesp_mqtt_enabled enabled\\n# TYPE emsesp_mqtt_enabled gauge\\nemsesp_mqtt_enabled 1\\n# HELP emsesp_mqtt_keepalive keepAlive\\n# TYPE emsesp_mqtt_keepalive gauge\\nemsesp_mqtt_keepalive 60\\n# HELP emsesp_mqtt_cleansession cleanSession\\n# TYPE emsesp_mqtt_cleansession gauge\\nemsesp_mqtt_cleansession 0\\n# HELP emsesp_mqtt_entityformat entityFormat\\n# TYPE emsesp_mqtt_entityformat gauge\\nemsesp_mqtt_entityformat 1\\n# HELP emsesp_mqtt_discoverytype discoveryType\\n# TYPE emsesp_mqtt_discoverytype gauge\\nemsesp_mqtt_discoverytype 0\\n# HELP emsesp_mqtt_nestedformat nestedFormat\\n# TYPE emsesp_mqtt_nestedformat gauge\\nemsesp_mqtt_nestedformat 1\\n# HELP emsesp_mqtt_haenabled haEnabled\\n# TYPE emsesp_mqtt_haenabled gauge\\nemsesp_mqtt_haenabled 1\\n# HELP emsesp_mqtt_mqttqos mqttQos\\n# TYPE emsesp_mqtt_mqttqos gauge\\nemsesp_mqtt_mqttqos 0\\n# HELP emsesp_mqtt_mqttretain mqttRetain\\n# TYPE emsesp_mqtt_mqttretain gauge\\nemsesp_mqtt_mqttretain 0\\n# HELP emsesp_mqtt_publishtimeheartbeat publishTimeHeartbeat\\n# TYPE emsesp_mqtt_publishtimeheartbeat gauge\\nemsesp_mqtt_publishtimeheartbeat 60\\n# HELP emsesp_mqtt_publishtimeboiler publishTimeBoiler\\n# TYPE emsesp_mqtt_publishtimeboiler gauge\\nemsesp_mqtt_publishtimeboiler 10\\n# HELP emsesp_mqtt_publishtimethermostat publishTimeThermostat\\n# TYPE emsesp_mqtt_publishtimethermostat gauge\\nemsesp_mqtt_publishtimethermostat 10\\n# HELP emsesp_mqtt_publishtimesolar publishTimeSolar\\n# TYPE emsesp_mqtt_publishtimesolar gauge\\nemsesp_mqtt_publishtimesolar 10\\n# HELP emsesp_mqtt_publishtimemixer publishTimeMixer\\n# TYPE emsesp_mqtt_publishtimemixer gauge\\nemsesp_mqtt_publishtimemixer 10\\n# HELP emsesp_mqtt_publishtimewater publishTimeWater\\n# TYPE emsesp_mqtt_publishtimewater gauge\\nemsesp_mqtt_publishtimewater 0\\n# HELP emsesp_mqtt_publishtimeother publishTimeOther\\n# TYPE emsesp_mqtt_publishtimeother gauge\\nemsesp_mqtt_publishtimeother 10\\n# HELP emsesp_mqtt_publishtimesensor publishTimeSensor\\n# TYPE emsesp_mqtt_publishtimesensor gauge\\nemsesp_mqtt_publishtimesensor 10\\n# HELP emsesp_mqtt_publishsingle publishSingle\\n# TYPE emsesp_mqtt_publishsingle gauge\\nemsesp_mqtt_publishsingle 0\\n# HELP emsesp_mqtt_publish2command publish2command\\n# TYPE emsesp_mqtt_publish2command gauge\\nemsesp_mqtt_publish2command 0\\n# HELP emsesp_mqtt_sendresponse sendResponse\\n# TYPE emsesp_mqtt_sendresponse gauge\\nemsesp_mqtt_sendresponse 0\\n# HELP emsesp_mqtt_info info\\n# TYPE emsesp_mqtt_info gauge\\nemsesp_mqtt_info{mqttstatus=\\\"disconnected\\\", clientid=\\\"ems-esp\\\", base=\\\"ems-esp\\\", discoveryprefix=\\\"homeassistant\\\"} 1\\n# HELP emsesp_syslog_enabled enabled\\n# TYPE emsesp_syslog_enabled gauge\\nemsesp_syslog_enabled 0\\n# HELP emsesp_modbus_enabled enabled\\n# TYPE emsesp_modbus_enabled gauge\\nemsesp_modbus_enabled 0\\n# HELP emsesp_sensor_temperaturesensors temperatureSensors\\n# TYPE emsesp_sensor_temperaturesensors gauge\\nemsesp_sensor_temperaturesensors 3\\n# HELP emsesp_sensor_temperaturesensorreads temperatureSensorReads\\n# TYPE emsesp_sensor_temperaturesensorreads gauge\\nemsesp_sensor_temperaturesensorreads 0\\n# HELP emsesp_sensor_temperaturesensorfails temperatureSensorFails\\n# TYPE emsesp_sensor_temperaturesensorfails gauge\\nemsesp_sensor_temperaturesensorfails 0\\n# HELP emsesp_analog_enabled enabled\\n# TYPE emsesp_analog_enabled gauge\\nemsesp_analog_enabled 1\\n# HELP emsesp_analog_analogsensors analogSensors\\n# TYPE emsesp_analog_analogsensors gauge\\nemsesp_analog_analogsensors 5\\n# HELP emsesp_analog_analogsensorreads analogSensorReads\\n# TYPE emsesp_analog_analogsensorreads gauge\\nemsesp_analog_analogsensorreads 0\\n# HELP emsesp_analog_analogsensorfails analogSensorFails\\n# TYPE emsesp_analog_analogsensorfails gauge\\nemsesp_analog_analogsensorfails 0\\n# HELP emsesp_api_apicalls APICalls\\n# TYPE emsesp_api_apicalls gauge\\nemsesp_api_apicalls 0\\n# HELP emsesp_api_apifails APIFails\\n# TYPE emsesp_api_apifails gauge\\nemsesp_api_apifails 0\\n# HELP emsesp_bus_bustelegramsreceived busTelegramsReceived\\n# TYPE emsesp_bus_bustelegramsreceived gauge\\nemsesp_bus_bustelegramsreceived 8\\n# HELP emsesp_bus_busreads busReads\\n# TYPE emsesp_bus_busreads gauge\\nemsesp_bus_busreads 0\\n# HELP emsesp_bus_buswrites busWrites\\n# TYPE emsesp_bus_buswrites gauge\\nemsesp_bus_buswrites 0\\n# HELP emsesp_bus_busincompletetelegrams busIncompleteTelegrams\\n# TYPE emsesp_bus_busincompletetelegrams gauge\\nemsesp_bus_busincompletetelegrams 0\\n# HELP emsesp_bus_busreadsfailed busReadsFailed\\n# TYPE emsesp_bus_busreadsfailed gauge\\nemsesp_bus_busreadsfailed 0\\n# HELP emsesp_bus_buswritesfailed busWritesFailed\\n# TYPE emsesp_bus_buswritesfailed gauge\\nemsesp_bus_buswritesfailed 0\\n# HELP emsesp_bus_busrxlinequality busRxLineQuality\\n# TYPE emsesp_bus_busrxlinequality gauge\\nemsesp_bus_busrxlinequality 100\\n# HELP emsesp_bus_bustxlinequality busTxLineQuality\\n# TYPE emsesp_bus_bustxlinequality gauge\\nemsesp_bus_bustxlinequality 100\\n# HELP emsesp_bus_info info\\n# TYPE emsesp_bus_info gauge\\nemsesp_bus_info{busstatus=\\\"connected\\\", busprotocol=\\\"Buderus\\\"} 1\\n# HELP emsesp_settings_txmode txMode\\n# TYPE emsesp_settings_txmode gauge\\nemsesp_settings_txmode 5\\n# HELP emsesp_settings_emsbusid emsBusID\\n# TYPE emsesp_settings_emsbusid gauge\\nemsesp_settings_emsbusid 73\\n# HELP emsesp_settings_showertimer showerTimer\\n# TYPE emsesp_settings_showertimer gauge\\nemsesp_settings_showertimer 0\\n# HELP emsesp_settings_showerminduration showerMinDuration\\n# TYPE emsesp_settings_showerminduration gauge\\nemsesp_settings_showerminduration 180\\n# HELP emsesp_settings_showeralert showerAlert\\n# TYPE emsesp_settings_showeralert gauge\\nemsesp_settings_showeralert 0\\n# HELP emsesp_settings_hideled hideLed\\n# TYPE emsesp_settings_hideled gauge\\nemsesp_settings_hideled 0\\n# HELP emsesp_settings_notokenapi noTokenApi\\n# TYPE emsesp_settings_notokenapi gauge\\nemsesp_settings_notokenapi 0\\n# HELP emsesp_settings_readonlymode readonlyMode\\n# TYPE emsesp_settings_readonlymode gauge\\nemsesp_settings_readonlymode 0\\n# HELP emsesp_settings_fahrenheit fahrenheit\\n# TYPE emsesp_settings_fahrenheit gauge\\nemsesp_settings_fahrenheit 0\\n# HELP emsesp_settings_dallasparasite dallasParasite\\n# TYPE emsesp_settings_dallasparasite gauge\\nemsesp_settings_dallasparasite 0\\n# HELP emsesp_settings_boolformat boolFormat\\n# TYPE emsesp_settings_boolformat gauge\\nemsesp_settings_boolformat 1\\n# HELP emsesp_settings_booldashboard boolDashboard\\n# TYPE emsesp_settings_booldashboard gauge\\nemsesp_settings_booldashboard 1\\n# HELP emsesp_settings_enumformat enumFormat\\n# TYPE emsesp_settings_enumformat gauge\\nemsesp_settings_enumformat 1\\n# HELP emsesp_settings_analogenabled analogEnabled\\n# TYPE emsesp_settings_analogenabled gauge\\nemsesp_settings_analogenabled 1\\n# HELP emsesp_settings_telnetenabled telnetEnabled\\n# TYPE emsesp_settings_telnetenabled gauge\\nemsesp_settings_telnetenabled 1\\n# HELP emsesp_settings_maxweblogbuffer maxWebLogBuffer\\n# TYPE emsesp_settings_maxweblogbuffer gauge\\nemsesp_settings_maxweblogbuffer 25\\n# HELP emsesp_settings_modbusenabled modbusEnabled\\n# TYPE emsesp_settings_modbusenabled gauge\\nemsesp_settings_modbusenabled 0\\n# HELP emsesp_settings_forceheatingoff forceHeatingOff\\n# TYPE emsesp_settings_forceheatingoff gauge\\nemsesp_settings_forceheatingoff 0\\n# HELP emsesp_settings_developermode developerMode\\n# TYPE emsesp_settings_developermode gauge\\nemsesp_settings_developermode 0\\n# HELP emsesp_settings_info info\\n# TYPE emsesp_settings_info gauge\\nemsesp_settings_info{boardprofile=\\\"S32\\\", locale=\\\"en\\\"} 1\\n# HELP emsesp_device_productid productID\\n# TYPE emsesp_device_productid gauge\\nemsesp_device_productid{type=\\\"boiler\\\", name=\\\"My Custom Boiler\\\", deviceid=\\\"0x08\\\", version=\\\"01.00\\\"} 123\\n# HELP emsesp_device_entities entities\\n# TYPE emsesp_device_entities gauge\\nemsesp_device_entities{type=\\\"boiler\\\", name=\\\"My Custom Boiler\\\", deviceid=\\\"0x08\\\", version=\\\"01.00\\\"} 39\\nemsesp_device_productid{type=\\\"thermostat\\\", name=\\\"FW120\\\", deviceid=\\\"0x10\\\", version=\\\"01.00\\\"} 192\\nemsesp_device_entities{type=\\\"thermostat\\\", name=\\\"FW120\\\", deviceid=\\\"0x10\\\", version=\\\"01.00\\\"} 12\\nemsesp_device_entities{type=\\\"temperaturesensor\\\", name=\\\"temperaturesensor\\\"} 3\\nemsesp_device_entities{type=\\\"analogsensor\\\", name=\\\"analogsensor\\\"} 5\\nemsesp_device_entities{type=\\\"scheduler\\\", name=\\\"scheduler\\\"} 2\\nemsesp_device_entities{type=\\\"custom\\\", name=\\\"custom\\\"} 4\\n\"}]";
+ auto expected_response = "[{\"api_data\":\"# HELP emsesp_system_uptimesec uptimeSec\\n# TYPE emsesp_system_uptimesec gauge\\nemsesp_system_uptimesec 0\\n# HELP emsesp_system_txpause txpause\\n# TYPE emsesp_system_txpause gauge\\nemsesp_system_txpause 0\\n# HELP emsesp_system_info info\\n# TYPE emsesp_system_info gauge\\nemsesp_system_info{version=\\\"dev\\\", resetreason=\\\"Unknown / Unknown\\\", gpios_allowed=\\\"0, 2, 5, 18, 23\\\", gpios_in_use=\\\"0, 2, 5, 18, 23\\\"} 1\\n# HELP emsesp_network_rssi RSSI\\n# TYPE emsesp_network_rssi gauge\\nemsesp_network_rssi -23\\n# HELP emsesp_network_txpowersetting TxPowerSetting\\n# TYPE emsesp_network_txpowersetting gauge\\nemsesp_network_txpowersetting 0\\n# HELP emsesp_network_staticip staticIP\\n# TYPE emsesp_network_staticip gauge\\nemsesp_network_staticip 0\\n# HELP emsesp_network_lowbandwidth lowBandwidth\\n# TYPE emsesp_network_lowbandwidth gauge\\nemsesp_network_lowbandwidth 0\\n# HELP emsesp_network_disablesleep disableSleep\\n# TYPE emsesp_network_disablesleep gauge\\nemsesp_network_disablesleep 1\\n# HELP emsesp_network_enablemdns enableMDNS\\n# TYPE emsesp_network_enablemdns gauge\\nemsesp_network_enablemdns 1\\n# HELP emsesp_network_enablecors enableCORS\\n# TYPE emsesp_network_enablecors gauge\\nemsesp_network_enablecors 0\\n# HELP emsesp_network_info info\\n# TYPE emsesp_network_info gauge\\nemsesp_network_info{network=\\\"WiFi\\\", hostname=\\\"ems-esp\\\"} 1\\n# HELP emsesp_ntp_enabled enabled\\n# TYPE emsesp_ntp_enabled gauge\\nemsesp_ntp_enabled 1\\n# HELP emsesp_ntp_info info\\n# TYPE emsesp_ntp_info gauge\\nemsesp_ntp_info{ntpstatus=\\\"disconnected\\\", server=\\\"pool.ntp.org\\\", tzlabel=\\\"Europe/London\\\"} 1\\n# HELP emsesp_ap_info info\\n# TYPE emsesp_ap_info gauge\\nemsesp_ap_info{provisionmode=\\\"always\\\", ssid=\\\"ems-esp\\\"} 1\\n# HELP emsesp_mqtt_mqttpublishes MQTTPublishes\\n# TYPE emsesp_mqtt_mqttpublishes gauge\\nemsesp_mqtt_mqttpublishes 0\\n# HELP emsesp_mqtt_mqttqueued MQTTQueued\\n# TYPE emsesp_mqtt_mqttqueued gauge\\nemsesp_mqtt_mqttqueued 0\\n# HELP emsesp_mqtt_mqttpublishfails MQTTPublishFails\\n# TYPE emsesp_mqtt_mqttpublishfails gauge\\nemsesp_mqtt_mqttpublishfails 0\\n# HELP emsesp_mqtt_mqttreconnects MQTTReconnects\\n# TYPE emsesp_mqtt_mqttreconnects gauge\\nemsesp_mqtt_mqttreconnects 0\\n# HELP emsesp_mqtt_enabled enabled\\n# TYPE emsesp_mqtt_enabled gauge\\nemsesp_mqtt_enabled 1\\n# HELP emsesp_mqtt_keepalive keepAlive\\n# TYPE emsesp_mqtt_keepalive gauge\\nemsesp_mqtt_keepalive 60\\n# HELP emsesp_mqtt_cleansession cleanSession\\n# TYPE emsesp_mqtt_cleansession gauge\\nemsesp_mqtt_cleansession 0\\n# HELP emsesp_mqtt_entityformat entityFormat\\n# TYPE emsesp_mqtt_entityformat gauge\\nemsesp_mqtt_entityformat 1\\n# HELP emsesp_mqtt_discoverytype discoveryType\\n# TYPE emsesp_mqtt_discoverytype gauge\\nemsesp_mqtt_discoverytype 0\\n# HELP emsesp_mqtt_nestedformat nestedFormat\\n# TYPE emsesp_mqtt_nestedformat gauge\\nemsesp_mqtt_nestedformat 1\\n# HELP emsesp_mqtt_haenabled haEnabled\\n# TYPE emsesp_mqtt_haenabled gauge\\nemsesp_mqtt_haenabled 1\\n# HELP emsesp_mqtt_mqttqos mqttQos\\n# TYPE emsesp_mqtt_mqttqos gauge\\nemsesp_mqtt_mqttqos 0\\n# HELP emsesp_mqtt_mqttretain mqttRetain\\n# TYPE emsesp_mqtt_mqttretain gauge\\nemsesp_mqtt_mqttretain 0\\n# HELP emsesp_mqtt_publishtimeheartbeat publishTimeHeartbeat\\n# TYPE emsesp_mqtt_publishtimeheartbeat gauge\\nemsesp_mqtt_publishtimeheartbeat 60\\n# HELP emsesp_mqtt_publishtimeboiler publishTimeBoiler\\n# TYPE emsesp_mqtt_publishtimeboiler gauge\\nemsesp_mqtt_publishtimeboiler 10\\n# HELP emsesp_mqtt_publishtimethermostat publishTimeThermostat\\n# TYPE emsesp_mqtt_publishtimethermostat gauge\\nemsesp_mqtt_publishtimethermostat 10\\n# HELP emsesp_mqtt_publishtimesolar publishTimeSolar\\n# TYPE emsesp_mqtt_publishtimesolar gauge\\nemsesp_mqtt_publishtimesolar 10\\n# HELP emsesp_mqtt_publishtimemixer publishTimeMixer\\n# TYPE emsesp_mqtt_publishtimemixer gauge\\nemsesp_mqtt_publishtimemixer 10\\n# HELP emsesp_mqtt_publishtimewater publishTimeWater\\n# TYPE emsesp_mqtt_publishtimewater gauge\\nemsesp_mqtt_publishtimewater 0\\n# HELP emsesp_mqtt_publishtimeother publishTimeOther\\n# TYPE emsesp_mqtt_publishtimeother gauge\\nemsesp_mqtt_publishtimeother 10\\n# HELP emsesp_mqtt_publishtimesensor publishTimeSensor\\n# TYPE emsesp_mqtt_publishtimesensor gauge\\nemsesp_mqtt_publishtimesensor 10\\n# HELP emsesp_mqtt_publishsingle publishSingle\\n# TYPE emsesp_mqtt_publishsingle gauge\\nemsesp_mqtt_publishsingle 0\\n# HELP emsesp_mqtt_publish2command publish2command\\n# TYPE emsesp_mqtt_publish2command gauge\\nemsesp_mqtt_publish2command 0\\n# HELP emsesp_mqtt_sendresponse sendResponse\\n# TYPE emsesp_mqtt_sendresponse gauge\\nemsesp_mqtt_sendresponse 0\\n# HELP emsesp_mqtt_info info\\n# TYPE emsesp_mqtt_info gauge\\nemsesp_mqtt_info{mqttstatus=\\\"disconnected\\\", clientid=\\\"ems-esp\\\", base=\\\"ems-esp\\\", discoveryprefix=\\\"homeassistant\\\"} 1\\n# HELP emsesp_syslog_enabled enabled\\n# TYPE emsesp_syslog_enabled gauge\\nemsesp_syslog_enabled 0\\n# HELP emsesp_modbus_enabled enabled\\n# TYPE emsesp_modbus_enabled gauge\\nemsesp_modbus_enabled 0\\n# HELP emsesp_sensor_temperaturesensors temperatureSensors\\n# TYPE emsesp_sensor_temperaturesensors gauge\\nemsesp_sensor_temperaturesensors 3\\n# HELP emsesp_sensor_temperaturesensorreads temperatureSensorReads\\n# TYPE emsesp_sensor_temperaturesensorreads gauge\\nemsesp_sensor_temperaturesensorreads 0\\n# HELP emsesp_sensor_temperaturesensorfails temperatureSensorFails\\n# TYPE emsesp_sensor_temperaturesensorfails gauge\\nemsesp_sensor_temperaturesensorfails 0\\n# HELP emsesp_analog_enabled enabled\\n# TYPE emsesp_analog_enabled gauge\\nemsesp_analog_enabled 1\\n# HELP emsesp_analog_analogsensors analogSensors\\n# TYPE emsesp_analog_analogsensors gauge\\nemsesp_analog_analogsensors 5\\n# HELP emsesp_analog_analogsensorreads analogSensorReads\\n# TYPE emsesp_analog_analogsensorreads gauge\\nemsesp_analog_analogsensorreads 0\\n# HELP emsesp_analog_analogsensorfails analogSensorFails\\n# TYPE emsesp_analog_analogsensorfails gauge\\nemsesp_analog_analogsensorfails 0\\n# HELP emsesp_api_apicalls APICalls\\n# TYPE emsesp_api_apicalls gauge\\nemsesp_api_apicalls 0\\n# HELP emsesp_api_apifails APIFails\\n# TYPE emsesp_api_apifails gauge\\nemsesp_api_apifails 0\\n# HELP emsesp_bus_bustelegramsreceived busTelegramsReceived\\n# TYPE emsesp_bus_bustelegramsreceived gauge\\nemsesp_bus_bustelegramsreceived 8\\n# HELP emsesp_bus_busreads busReads\\n# TYPE emsesp_bus_busreads gauge\\nemsesp_bus_busreads 0\\n# HELP emsesp_bus_buswrites busWrites\\n# TYPE emsesp_bus_buswrites gauge\\nemsesp_bus_buswrites 0\\n# HELP emsesp_bus_busincompletetelegrams busIncompleteTelegrams\\n# TYPE emsesp_bus_busincompletetelegrams gauge\\nemsesp_bus_busincompletetelegrams 0\\n# HELP emsesp_bus_busreadsfailed busReadsFailed\\n# TYPE emsesp_bus_busreadsfailed gauge\\nemsesp_bus_busreadsfailed 0\\n# HELP emsesp_bus_buswritesfailed busWritesFailed\\n# TYPE emsesp_bus_buswritesfailed gauge\\nemsesp_bus_buswritesfailed 0\\n# HELP emsesp_bus_busrxlinequality busRxLineQuality\\n# TYPE emsesp_bus_busrxlinequality gauge\\nemsesp_bus_busrxlinequality 100\\n# HELP emsesp_bus_bustxlinequality busTxLineQuality\\n# TYPE emsesp_bus_bustxlinequality gauge\\nemsesp_bus_bustxlinequality 100\\n# HELP emsesp_bus_info info\\n# TYPE emsesp_bus_info gauge\\nemsesp_bus_info{busstatus=\\\"connected\\\", busprotocol=\\\"Buderus\\\"} 1\\n# HELP emsesp_settings_txmode txMode\\n# TYPE emsesp_settings_txmode gauge\\nemsesp_settings_txmode 5\\n# HELP emsesp_settings_emsbusid emsBusID\\n# TYPE emsesp_settings_emsbusid gauge\\nemsesp_settings_emsbusid 73\\n# HELP emsesp_settings_showertimer showerTimer\\n# TYPE emsesp_settings_showertimer gauge\\nemsesp_settings_showertimer 0\\n# HELP emsesp_settings_showerminduration showerMinDuration\\n# TYPE emsesp_settings_showerminduration gauge\\nemsesp_settings_showerminduration 180\\n# HELP emsesp_settings_showeralert showerAlert\\n# TYPE emsesp_settings_showeralert gauge\\nemsesp_settings_showeralert 0\\n# HELP emsesp_settings_hideled hideLed\\n# TYPE emsesp_settings_hideled gauge\\nemsesp_settings_hideled 0\\n# HELP emsesp_settings_notokenapi noTokenApi\\n# TYPE emsesp_settings_notokenapi gauge\\nemsesp_settings_notokenapi 0\\n# HELP emsesp_settings_readonlymode readonlyMode\\n# TYPE emsesp_settings_readonlymode gauge\\nemsesp_settings_readonlymode 0\\n# HELP emsesp_settings_fahrenheit fahrenheit\\n# TYPE emsesp_settings_fahrenheit gauge\\nemsesp_settings_fahrenheit 0\\n# HELP emsesp_settings_dallasparasite dallasParasite\\n# TYPE emsesp_settings_dallasparasite gauge\\nemsesp_settings_dallasparasite 0\\n# HELP emsesp_settings_boolformat boolFormat\\n# TYPE emsesp_settings_boolformat gauge\\nemsesp_settings_boolformat 1\\n# HELP emsesp_settings_booldashboard boolDashboard\\n# TYPE emsesp_settings_booldashboard gauge\\nemsesp_settings_booldashboard 1\\n# HELP emsesp_settings_enumformat enumFormat\\n# TYPE emsesp_settings_enumformat gauge\\nemsesp_settings_enumformat 1\\n# HELP emsesp_settings_analogenabled analogEnabled\\n# TYPE emsesp_settings_analogenabled gauge\\nemsesp_settings_analogenabled 1\\n# HELP emsesp_settings_telnetenabled telnetEnabled\\n# TYPE emsesp_settings_telnetenabled gauge\\nemsesp_settings_telnetenabled 1\\n# HELP emsesp_settings_maxweblogbuffer maxWebLogBuffer\\n# TYPE emsesp_settings_maxweblogbuffer gauge\\nemsesp_settings_maxweblogbuffer 25\\n# HELP emsesp_settings_modbusenabled modbusEnabled\\n# TYPE emsesp_settings_modbusenabled gauge\\nemsesp_settings_modbusenabled 0\\n# HELP emsesp_settings_forceheatingoff forceHeatingOff\\n# TYPE emsesp_settings_forceheatingoff gauge\\nemsesp_settings_forceheatingoff 0\\n# HELP emsesp_settings_developermode developerMode\\n# TYPE emsesp_settings_developermode gauge\\nemsesp_settings_developermode 0\\n# HELP emsesp_settings_info info\\n# TYPE emsesp_settings_info gauge\\nemsesp_settings_info{boardprofile=\\\"S32\\\", locale=\\\"en\\\"} 1\\n# HELP emsesp_device_productid productID\\n# TYPE emsesp_device_productid gauge\\nemsesp_device_productid{type=\\\"boiler\\\", name=\\\"My Custom Boiler\\\", deviceid=\\\"0x08\\\", version=\\\"01.00\\\"} 123\\n# HELP emsesp_device_entities entities\\n# TYPE emsesp_device_entities gauge\\nemsesp_device_entities{type=\\\"boiler\\\", name=\\\"My Custom Boiler\\\", deviceid=\\\"0x08\\\", version=\\\"01.00\\\"} 39\\nemsesp_device_productid{type=\\\"thermostat\\\", name=\\\"FW120\\\", deviceid=\\\"0x10\\\", version=\\\"01.00\\\"} 192\\nemsesp_device_entities{type=\\\"thermostat\\\", name=\\\"FW120\\\", deviceid=\\\"0x10\\\", version=\\\"01.00\\\"} 12\\nemsesp_device_entities{type=\\\"temperaturesensor\\\", name=\\\"temperaturesensor\\\"} 3\\nemsesp_device_entities{type=\\\"analogsensor\\\", name=\\\"analogsensor\\\"} 5\\nemsesp_device_entities{type=\\\"scheduler\\\", name=\\\"scheduler\\\"} 2\\nemsesp_device_entities{type=\\\"commands\\\", name=\\\"commands\\\"} 3\\nemsesp_device_entities{type=\\\"custom\\\", name=\\\"custom\\\"} 4\\n\"}]";
TEST_ASSERT_EQUAL_STRING(expected_response, call_url("/api/system/metrics"));
}
@@ -140,12 +140,12 @@ void test_28() {
}
void test_29() {
- auto expected_response = "[{\"test_scheduler1\":\"on\",\"test_scheduler2\":\"off\"}]";
+ auto expected_response = "[{\"test_scheduler1\":\"on\",\"test_scheduler2\":\"on\"}]";
TEST_ASSERT_EQUAL_STRING(expected_response, call_url("/api/scheduler"));
}
void test_30() {
- auto expected_response = "[{\"test_scheduler1\":\"on\",\"test_scheduler2\":\"off\"}]";
+ auto expected_response = "[{\"test_scheduler1\":\"on\",\"test_scheduler2\":\"on\"}]";
TEST_ASSERT_EQUAL_STRING(expected_response, call_url("/api/scheduler/info"));
}
@@ -230,7 +230,7 @@ void test_46() {
}
void test_47() {
- auto expected_response = "[{\"name\":\"test_scheduler2\",\"fullname\":\"test_scheduler2\",\"type\":\"boolean\",\"value\":\"off\",\"command\":\"system/message\",\"cmd_data\":\"20\",\"readable\":true,\"writeable\":true,\"visible\":true}]";
+ auto expected_response = "[{\"name\":\"test_scheduler2\",\"fullname\":\"test_scheduler2\",\"type\":\"boolean\",\"active\":\"on\",\"timer\":\"01:00\",\"cmd_name\":\"send_message\"}]";
TEST_ASSERT_EQUAL_STRING(expected_response, call_url("/api/scheduler/test_scheduler2"));
}
@@ -240,7 +240,7 @@ void test_48() {
}
void test_49() {
- auto expected_response = "[{\"message\":\"no attribute 'val2' in test_scheduler2\"}]";
+ auto expected_response = "[{\"message\":\"Command test_scheduler2 failed (Error)\"}]";
TEST_ASSERT_EQUAL_STRING(expected_response, call_url("/api/scheduler/test_scheduler2/val2"));
}