mirror of
https://github.com/emsesp/EMS-ESP32.git
synced 2026-10-28 11:14:58 +00:00
Compare commits
186
Commits
09c9e5d53b
..
latest
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13c32192ff | ||
|
|
63935c4748 | ||
|
|
33df92fdba | ||
|
|
8f7049b47a | ||
|
|
e1db103470 | ||
|
|
6dbb53e5af | ||
|
|
dc2823c1de | ||
|
|
304eae34bd | ||
|
|
960e7376fe | ||
|
|
d91cf08b1d | ||
|
|
746e5c5ef3 | ||
|
|
955a6d8c56 | ||
|
|
19187431ce | ||
|
|
cdf6375c71 | ||
|
|
2a72d48468 | ||
|
|
ed0aedcce0 | ||
|
|
fe7bc1c40f | ||
|
|
65385dfa3c | ||
|
|
b95873169e | ||
|
|
90fab51af3 | ||
|
|
ac6de5bb44 | ||
|
|
4cfead4f58 | ||
|
|
f20c064a2c | ||
|
|
d449d62b9f | ||
|
|
8cd23c3265 | ||
|
|
05575a28d5 | ||
|
|
c1395f29c1 | ||
|
|
4fa3319caf | ||
|
|
a52c98e789 | ||
|
|
9ff4dadedd | ||
|
|
93a986be7f | ||
|
|
88be02aae2 | ||
|
|
4d43307e9a | ||
|
|
65fb1ea299 | ||
|
|
d49a6447e3 | ||
|
|
674a554eae | ||
|
|
921807211b | ||
|
|
4d779cd972 | ||
|
|
cbe8ea21af | ||
|
|
66d30570ac | ||
|
|
caedc0709c | ||
|
|
f653ef447b | ||
|
|
b829b2df5b | ||
|
|
d51a2a4481 | ||
|
|
5657aacb8b | ||
|
|
d7b8c72ac2 | ||
|
|
f63dd2a030 | ||
|
|
da6c11197f | ||
|
|
ed4b82b962 | ||
|
|
69a58e64ca | ||
|
|
68d7f6e444 | ||
|
|
8693d27a02 | ||
|
|
4515d1de85 | ||
|
|
b1abbd0f28 | ||
|
|
88239ce0e0 | ||
|
|
6a0e8ebc48 | ||
|
|
bbacd7021a | ||
|
|
e9bb68eeb5 | ||
|
|
cdd64f1de0 | ||
|
|
8ee23dd8dc | ||
|
|
6e9ed5e516 | ||
|
|
4861d50c21 | ||
|
|
705bab92d8 | ||
|
|
540a87f465 | ||
|
|
229ba81301 | ||
|
|
4fb5d366d9 | ||
|
|
9328c946d5 | ||
|
|
f1e158d2a6 | ||
|
|
aad2854e4c | ||
|
|
721866c528 | ||
|
|
77ede5cea2 | ||
|
|
e9b85559e6 | ||
|
|
0e0b75dc6b | ||
|
|
ff2fc3d514 | ||
|
|
de9a082668 | ||
|
|
5ae89714c2 | ||
|
|
84060449cd | ||
|
|
f9c9496c71 | ||
|
|
546e07a534 | ||
|
|
956f7ab098 | ||
|
|
c6157dee22 | ||
|
|
9934d05c5a | ||
|
|
33a38c30fe | ||
|
|
43778328e1 | ||
|
|
e7fd8bcdf0 | ||
|
|
229187d912 | ||
|
|
fe4fe7ef28 | ||
|
|
0094df41d9 | ||
|
|
0c8e496296 | ||
|
|
ec49237bd9 | ||
|
|
b730716791 | ||
|
|
62f2987b56 | ||
|
|
e10983cca1 | ||
|
|
c3894bab5c | ||
|
|
b7c795e320 | ||
|
|
0719340f46 | ||
|
|
8c835a9e8b | ||
|
|
84bc0afeb7 | ||
|
|
04d373b93c | ||
|
|
c959cb1e8a | ||
|
|
09aa40e604 | ||
|
|
e3b1eb6c51 | ||
|
|
673c7462f5 | ||
|
|
e19a45f5b8 | ||
|
|
981b14a768 | ||
|
|
90eee92560 | ||
|
|
037451ace8 | ||
|
|
7fc094d6f7 | ||
|
|
410e92e4d0 | ||
|
|
6472a1a424 | ||
|
|
eaaea504df | ||
|
|
cefd5a14cf | ||
|
|
46f2090b30 | ||
|
|
07514260f4 | ||
|
|
dfe009f5c3 | ||
|
|
c5caedd303 | ||
|
|
30c55c4f0e | ||
|
|
105ef8cf40 | ||
|
|
3f038e5ba0 | ||
|
|
af3c05247c | ||
|
|
6fa08808dd | ||
|
|
613e3c87ae | ||
|
|
988ad0006b | ||
|
|
ec6184333e | ||
|
|
7e96c1ea0a | ||
|
|
7851ae86c7 | ||
|
|
61177d1fa1 | ||
|
|
8856d8f9c9 | ||
|
|
987710ac27 | ||
|
|
a61a0cc64c | ||
|
|
c290543c7f | ||
|
|
49ac5cd9b6 | ||
|
|
92e2a4b6c3 | ||
|
|
965d93661a | ||
|
|
6411b6ffbd | ||
|
|
f8e7dd9244 | ||
|
|
1375f87163 | ||
|
|
4b3f6015ee | ||
|
|
ca79e2e09b | ||
|
|
b51d153dc5 | ||
|
|
79c2e5e8a1 | ||
|
|
597939c896 | ||
|
|
eb4232d6c8 | ||
|
|
3ea25eba43 | ||
|
|
61eaee2ffa | ||
|
|
3666f21d41 | ||
|
|
e4f9d4a96d | ||
|
|
635daa8ab8 | ||
|
|
52b24d5641 | ||
|
|
619fe46a4e | ||
|
|
115f5281f4 | ||
|
|
333e2d83d7 | ||
|
|
3befcb638d | ||
|
|
8a48eb75d2 | ||
|
|
74ff39b22e | ||
|
|
960a7a554e | ||
|
|
4ffe8c04e9 | ||
|
|
ebd85f96a6 | ||
|
|
f768225533 | ||
|
|
25d21f75ab | ||
|
|
1e49bd54a0 | ||
|
|
7fe65b7f7b | ||
|
|
7c6101e819 | ||
|
|
684d6b132d | ||
|
|
15fc5e1333 | ||
|
|
f550503fe9 | ||
|
|
8cdd9f5d79 | ||
|
|
8c496d8f77 | ||
|
|
e5456293aa | ||
|
|
a2f8f35285 | ||
|
|
fb8edfb6a7 | ||
|
|
b7a174ddea | ||
|
|
446738f0d9 | ||
|
|
8b1de52f55 | ||
|
|
491bb01088 | ||
|
|
37d571b6e9 | ||
|
|
f05105861e | ||
|
|
09e8ef5403 | ||
|
|
1d2b916935 | ||
|
|
100edcefe2 | ||
|
|
eddc443d72 | ||
|
|
40c4fdd2d1 | ||
|
|
44cb96a2c4 | ||
|
|
ba2c552276 | ||
|
|
00f648051e | ||
|
|
c3af7aa3ad |
@@ -30,6 +30,7 @@ BreakConstructorInitializersBeforeComma: true # Always break constructor initial
|
||||
ExperimentalAutoDetectBinPacking: false
|
||||
KeepEmptyLinesAtTheStartOfBlocks: false
|
||||
MaxEmptyLinesToKeep: 1
|
||||
InsertNewlineAtEOF: true # clang-format 16+: one newline at end of file if missing
|
||||
PenaltyBreakBeforeFirstCallParameter: 200
|
||||
PenaltyExcessCharacter: 10
|
||||
PointerAlignment: Middle
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
groups:
|
||||
github-actions:
|
||||
patterns:
|
||||
- "*"
|
||||
@@ -11,29 +11,209 @@ on:
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
# Parallel graph (wall-clock is ~max of the firmware jobs, not the sum):
|
||||
# webui ──────┐
|
||||
# ├─► firmware (s_4M / s_16M / s_16M_P / s3_16M_P) ─┐
|
||||
# modbus ─────┤ ├─► release
|
||||
# └─► standalone ───────────────────────────────────┘
|
||||
#
|
||||
# Extra -j on a single runner does not help: PlatformIO already compiles each
|
||||
# environment with all CPUs. The win is separate GitHub runners.
|
||||
|
||||
jobs:
|
||||
pre-release:
|
||||
name: 'Build Dev Release'
|
||||
webui:
|
||||
name: Build webUI
|
||||
if: github.event_name == 'workflow_dispatch' || github.repository == 'emsesp/EMS-ESP32'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
- name: Install python 3.13
|
||||
uses: actions/setup-python@v6
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
python-version: '3.13'
|
||||
package_json_file: interface/package.json
|
||||
|
||||
- name: Install Node.js 26
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 26
|
||||
|
||||
- name: Cache pnpm store
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.local/share/pnpm/store
|
||||
key: ${{ runner.os }}-pnpm-${{ hashFiles('interface/package.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-
|
||||
|
||||
- name: Build webUI
|
||||
env:
|
||||
CI: true
|
||||
working-directory: interface
|
||||
run: |
|
||||
pnpm install
|
||||
pnpm build-webUI
|
||||
|
||||
- name: Upload WWWData.h
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: wwwdata
|
||||
path: src/ESP32React/WWWData.h
|
||||
if-no-files-found: error
|
||||
|
||||
modbus:
|
||||
name: Build modbus
|
||||
if: github.event_name == 'workflow_dispatch' || github.repository == 'emsesp/EMS-ESP32'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Install python 3.13
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
version: "latest"
|
||||
enable-cache: false
|
||||
|
||||
- name: Install pioarduino
|
||||
run: uv pip install --system pioarduino==6.2.0
|
||||
|
||||
- name: Build modbus
|
||||
run: platformio run -e build_modbus
|
||||
|
||||
- name: Upload modbus header
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: modbus-header
|
||||
path: src/core/modbus_entity_parameters.hpp
|
||||
if-no-files-found: error
|
||||
|
||||
standalone:
|
||||
name: Build standalone
|
||||
needs: modbus
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Install python 3.13
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
version: "latest"
|
||||
enable-cache: false
|
||||
|
||||
- name: Install pioarduino
|
||||
run: uv pip install --system pioarduino==6.2.0
|
||||
|
||||
- name: Restore generated modbus header
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: modbus-header
|
||||
path: src/core
|
||||
|
||||
- name: Build standalone
|
||||
run: platformio run -e build_standalone
|
||||
|
||||
- name: Upload generated docs
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: generated-docs
|
||||
path: |
|
||||
docs/dump_entities.csv
|
||||
docs/dump_telegrams.csv
|
||||
docs/Modbus-Entity-Registers.md
|
||||
if-no-files-found: error
|
||||
|
||||
firmware:
|
||||
name: Firmware ${{ matrix.env }}
|
||||
needs: [webui, modbus]
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
# Keep in sync with platformio.ini default_envs
|
||||
env: [s_4M, s_16M, s_16M_P, s3_16M_P]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Install python 3.13
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
version: "latest"
|
||||
enable-cache: false
|
||||
|
||||
- name: Cache PlatformIO packages
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: |
|
||||
~/.platformio/.cache
|
||||
~/.platformio/packages
|
||||
~/.platformio/platforms
|
||||
key: ${{ runner.os }}-pio-${{ matrix.env }}-${{ hashFiles('platformio.ini') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pio-${{ matrix.env }}-
|
||||
${{ runner.os }}-pio-
|
||||
|
||||
- name: Install pioarduino
|
||||
run: uv pip install --system pioarduino==6.2.0
|
||||
|
||||
- name: Restore WWWData.h
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: wwwdata
|
||||
path: src/ESP32React
|
||||
|
||||
- name: Restore generated modbus header
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: modbus-header
|
||||
path: src/core
|
||||
|
||||
- name: Setup ccache
|
||||
uses: hendrikmuhs/ccache-action@v1.2.24
|
||||
with:
|
||||
key: firmware-${{ matrix.env }}
|
||||
restore-keys: firmware-${{ matrix.env }}-
|
||||
max-size: 1G
|
||||
|
||||
- name: Build ${{ matrix.env }}
|
||||
env:
|
||||
CCACHE_COMPILERCHECK: content
|
||||
run: platformio run -e ${{ matrix.env }}
|
||||
|
||||
- name: Upload firmware
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: firmware-${{ matrix.env }}
|
||||
path: build/firmware
|
||||
if-no-files-found: error
|
||||
|
||||
release:
|
||||
name: Create GitHub Release
|
||||
needs: [firmware, standalone]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install pnpm
|
||||
run: npm install -g pnpm
|
||||
|
||||
- name: Get the EMS-ESP version
|
||||
id: build_info
|
||||
@@ -48,27 +228,77 @@ jobs:
|
||||
prev=`git rev-parse -q --verify 'refs/tags/latest^{commit}' || git describe --tags --abbrev=0 --match 'v*'`
|
||||
echo "REF=$prev" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Install PlatformIO
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -U platformio
|
||||
python -m pip install intelhex
|
||||
- name: Restore generated modbus header
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: modbus-header
|
||||
path: src/core
|
||||
|
||||
- name: Build webUI
|
||||
run: |
|
||||
platformio run -e build-webUI
|
||||
- name: Restore generated docs
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: generated-docs
|
||||
path: .
|
||||
|
||||
- name: Build modbus
|
||||
run: |
|
||||
platformio run -e build_modbus
|
||||
- name: Restore firmware binaries
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: firmware-*
|
||||
merge-multiple: true
|
||||
path: build/firmware
|
||||
|
||||
- name: Build standalone
|
||||
run: |
|
||||
platformio run -e build_standalone
|
||||
- name: List firmware files
|
||||
run: ls -la build/firmware
|
||||
|
||||
- name: Build all PIO target environments, from default_envs
|
||||
# the recovery build needs PlatformIO, and pnpm + Node.js to build its own WebUI (recovery/WWWData.h)
|
||||
- name: Install python 3.13
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
version: "latest"
|
||||
enable-cache: false
|
||||
|
||||
# falls back to the firmware jobs' caches, which hold the same toolchains
|
||||
- name: Cache PlatformIO packages
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: |
|
||||
~/.platformio/.cache
|
||||
~/.platformio/packages
|
||||
~/.platformio/platforms
|
||||
key: ${{ runner.os }}-pio-recovery-${{ hashFiles('platformio.ini') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pio-recovery-
|
||||
${{ runner.os }}-pio-
|
||||
|
||||
- name: Install pioarduino
|
||||
run: uv pip install --system pioarduino==6.2.0
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
package_json_file: interface/package.json
|
||||
|
||||
- name: Install Node.js 26
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 26
|
||||
|
||||
- name: Cache pnpm store
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.local/share/pnpm/store
|
||||
key: ${{ runner.os }}-pnpm-${{ hashFiles('interface/package.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-
|
||||
|
||||
- name: Build the recovery firmware for the 16MB boards
|
||||
run: |
|
||||
platformio run
|
||||
platformio run -e recovery_s_16M -e recovery_s_16M_P -e recovery_s3_16M_P
|
||||
|
||||
- name: Commit the generated files
|
||||
uses: stefanzweifel/git-auto-commit-action@v7
|
||||
@@ -97,6 +327,7 @@ jobs:
|
||||
repo_token: '${{ secrets.GITHUB_TOKEN }}'
|
||||
title: Development Build v${{steps.build_info.outputs.VERSION}}
|
||||
body: ${{ steps.notes.outputs.BODY }}
|
||||
generate_notes: false
|
||||
automatic_release_tag: 'latest'
|
||||
prerelease: true
|
||||
files: |
|
||||
|
||||
@@ -11,10 +11,10 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: GitHub Releases To Discord
|
||||
uses: SethCohen/github-releases-to-discord@v1.13.1
|
||||
uses: SethCohen/github-releases-to-discord@v1.20.0
|
||||
with:
|
||||
webhook_url: ${{ secrets.WEBHOOK_URL }}
|
||||
color: '2105893'
|
||||
|
||||
@@ -15,17 +15,17 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Install python 3.13
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Install PlatformIO
|
||||
- name: Install pioarduino
|
||||
run: |
|
||||
pip install wheel
|
||||
pip install -U platformio
|
||||
pip install -U "pioarduino==6.2.0"
|
||||
|
||||
- name: Run unit tests
|
||||
run: |
|
||||
|
||||
@@ -19,15 +19,15 @@ jobs:
|
||||
BUILD_WRAPPER_OUT_DIR: bw-output
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install Build Wrapper
|
||||
uses: SonarSource/sonarqube-scan-action/install-build-wrapper@master
|
||||
uses: SonarSource/sonarqube-scan-action/install-build-wrapper@v8.3.0
|
||||
- name: Run Build Wrapper
|
||||
run: build-wrapper-linux-x86-64 --out-dir ${{ env.BUILD_WRAPPER_OUT_DIR }} make all
|
||||
- name: SonarQube Scan
|
||||
uses: SonarSource/sonarqube-scan-action@master
|
||||
uses: SonarSource/sonarqube-scan-action@v8.3.0
|
||||
env:
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
|
||||
|
||||
@@ -9,28 +9,197 @@ on:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
# Parallel graph (wall-clock is ~max of the firmware jobs, not the sum):
|
||||
# webui ──────┐
|
||||
# ├─► firmware (s_4M / s_16M / s_16M_P / s3_16M_P) ─┐
|
||||
# modbus ─────┤ ├─► release
|
||||
# └─► standalone ───────────────────────────────────┘
|
||||
#
|
||||
# Extra -j on a single runner does not help: PlatformIO already compiles each
|
||||
# environment with all CPUs. The win is separate GitHub runners.
|
||||
|
||||
jobs:
|
||||
tagged-release:
|
||||
name: 'Build Stable Release'
|
||||
webui:
|
||||
name: Build webUI
|
||||
if: github.event_name == 'workflow_dispatch' || github.repository == 'emsesp/EMS-ESP32'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Install python 3.13
|
||||
uses: actions/setup-python@v6
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
python-version: '3.13'
|
||||
package_json_file: interface/package.json
|
||||
|
||||
- name: Install Node.js 26
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 26
|
||||
|
||||
- name: Cache pnpm store
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.local/share/pnpm/store
|
||||
key: ${{ runner.os }}-pnpm-${{ hashFiles('interface/package.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-
|
||||
|
||||
- name: Build webUI
|
||||
env:
|
||||
CI: true
|
||||
working-directory: interface
|
||||
run: |
|
||||
pnpm install
|
||||
pnpm build-webUI
|
||||
|
||||
- name: Upload WWWData.h
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: wwwdata
|
||||
path: src/ESP32React/WWWData.h
|
||||
if-no-files-found: error
|
||||
|
||||
modbus:
|
||||
name: Build modbus
|
||||
if: github.event_name == 'workflow_dispatch' || github.repository == 'emsesp/EMS-ESP32'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Install pnpm
|
||||
run: npm install -g pnpm
|
||||
- name: Install python 3.13
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
version: "latest"
|
||||
enable-cache: false
|
||||
|
||||
- name: Install pioarduino
|
||||
run: uv pip install --system pioarduino==6.2.0
|
||||
|
||||
- name: Build modbus
|
||||
run: platformio run -e build_modbus
|
||||
|
||||
- name: Upload modbus header
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: modbus-header
|
||||
path: src/core/modbus_entity_parameters.hpp
|
||||
if-no-files-found: error
|
||||
|
||||
standalone:
|
||||
name: Build standalone
|
||||
needs: modbus
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Install python 3.13
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
version: "latest"
|
||||
enable-cache: false
|
||||
|
||||
- name: Install pioarduino
|
||||
run: uv pip install --system pioarduino==6.2.0
|
||||
|
||||
- name: Restore generated modbus header
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: modbus-header
|
||||
path: src/core
|
||||
|
||||
- name: Build standalone
|
||||
run: platformio run -e build_standalone
|
||||
|
||||
firmware:
|
||||
name: Firmware ${{ matrix.env }}
|
||||
needs: [webui, modbus]
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
# Keep in sync with platformio.ini default_envs
|
||||
env: [s_4M, s_16M, s_16M_P, s3_16M_P]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Install python 3.13
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
version: "latest"
|
||||
enable-cache: false
|
||||
|
||||
- name: Cache PlatformIO packages
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: |
|
||||
~/.platformio/.cache
|
||||
~/.platformio/packages
|
||||
~/.platformio/platforms
|
||||
key: ${{ runner.os }}-pio-${{ matrix.env }}-${{ hashFiles('platformio.ini') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pio-${{ matrix.env }}-
|
||||
${{ runner.os }}-pio-
|
||||
|
||||
- name: Install pioarduino
|
||||
run: uv pip install --system pioarduino==6.2.0
|
||||
|
||||
- name: Restore WWWData.h
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: wwwdata
|
||||
path: src/ESP32React
|
||||
|
||||
- name: Restore generated modbus header
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: modbus-header
|
||||
path: src/core
|
||||
|
||||
- name: Setup ccache
|
||||
uses: hendrikmuhs/ccache-action@v1.2.24
|
||||
with:
|
||||
key: firmware-${{ matrix.env }}
|
||||
restore-keys: firmware-${{ matrix.env }}-
|
||||
max-size: 1G
|
||||
|
||||
- name: Build ${{ matrix.env }}
|
||||
env:
|
||||
CCACHE_COMPILERCHECK: content
|
||||
run: platformio run -e ${{ matrix.env }}
|
||||
|
||||
- name: Upload firmware
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: firmware-${{ matrix.env }}
|
||||
path: build/firmware
|
||||
if-no-files-found: error
|
||||
|
||||
release:
|
||||
name: Create GitHub Release
|
||||
needs: [firmware, standalone]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Get the EMS-ESP version
|
||||
id: build_info
|
||||
@@ -38,27 +207,71 @@ jobs:
|
||||
version=`grep -E '^#define EMSESP_APP_VERSION' ./src/emsesp_version.h | awk -F'"' '{print $2}'`
|
||||
echo "VERSION=$version" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Install PlatformIO
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -U platformio
|
||||
python -m pip install intelhex
|
||||
- name: Restore generated modbus header
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: modbus-header
|
||||
path: src/core
|
||||
|
||||
- name: Build webUI
|
||||
run: |
|
||||
platformio run -e build-webUI
|
||||
- name: Restore firmware binaries
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: firmware-*
|
||||
merge-multiple: true
|
||||
path: build/firmware
|
||||
|
||||
- name: Build modbus
|
||||
run: |
|
||||
platformio run -e build_modbus
|
||||
# the recovery build needs PlatformIO, and pnpm + Node.js to build its own WebUI (recovery/WWWData.h)
|
||||
- name: Install python 3.13
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Build standalone
|
||||
run: |
|
||||
platformio run -e build_standalone
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
version: "latest"
|
||||
enable-cache: false
|
||||
|
||||
- name: Build all PIO target environments, from default_envs
|
||||
# falls back to the firmware jobs' caches, which hold the same toolchains
|
||||
- name: Cache PlatformIO packages
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: |
|
||||
~/.platformio/.cache
|
||||
~/.platformio/packages
|
||||
~/.platformio/platforms
|
||||
key: ${{ runner.os }}-pio-recovery-${{ hashFiles('platformio.ini') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pio-recovery-
|
||||
${{ runner.os }}-pio-
|
||||
|
||||
- name: Install pioarduino
|
||||
run: uv pip install --system pioarduino==6.2.0
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
package_json_file: interface/package.json
|
||||
|
||||
- name: Install Node.js 26
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 26
|
||||
|
||||
- name: Cache pnpm store
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.local/share/pnpm/store
|
||||
key: ${{ runner.os }}-pnpm-${{ hashFiles('interface/package.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-
|
||||
|
||||
- name: Build the recovery firmware for the 16MB boards
|
||||
run: |
|
||||
platformio run
|
||||
platformio run -e recovery_s_16M -e recovery_s_16M_P -e recovery_s3_16M_P
|
||||
|
||||
- name: List firmware files
|
||||
run: ls -la build/firmware
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: emsesp/action-automatic-releases@v1.0.1
|
||||
|
||||
@@ -10,9 +10,11 @@ on:
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
name: Mark or close stale issues and PRs
|
||||
if: github.repository == 'emsesp/EMS-ESP32'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v10
|
||||
- uses: actions/stale@v11
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
days-before-stale: 30
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
name: 'Build test release'
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- 'test'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
pre-release:
|
||||
name: 'Build Test Release'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
|
||||
- name: Install python 3.13
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Install Node.js 26
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 26
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install pnpm
|
||||
run: npm install -g pnpm
|
||||
|
||||
- name: Get the EMS-ESP version
|
||||
id: build_info
|
||||
run: |
|
||||
version=`grep -E '^#define EMSESP_APP_VERSION' ./src/emsesp_version.h | awk -F'"' '{print $2}'`
|
||||
echo "VERSION=$version" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Install PlatformIO
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -U platformio
|
||||
python -m pip install intelhex
|
||||
|
||||
- name: Build webUI
|
||||
run: |
|
||||
platformio run -e build-webUI
|
||||
|
||||
- name: Build modbus
|
||||
run: |
|
||||
platformio run -e build_modbus
|
||||
|
||||
- name: Build standalone
|
||||
run: |
|
||||
platformio run -e build_standalone
|
||||
|
||||
- name: Build all PIO target environments, from default_envs
|
||||
run: |
|
||||
platformio run
|
||||
|
||||
- name: Create GitHub Release
|
||||
id: 'automatic_releases'
|
||||
uses: emsesp/action-automatic-releases@v1.0.1
|
||||
with:
|
||||
repo_token: '${{ secrets.GITHUB_TOKEN }}'
|
||||
title: Test Build v${{steps.build_info.outputs.VERSION}}
|
||||
automatic_release_tag: 'test'
|
||||
prerelease: true
|
||||
files: |
|
||||
CHANGELOG_LATEST.md
|
||||
./build/firmware/*.*
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Get and Send EMS-ESP version to Cloudflare
|
||||
env:
|
||||
|
||||
@@ -32,6 +32,8 @@ analyse.html
|
||||
interface/vite.config.ts.timestamp*
|
||||
*.local
|
||||
src/ESP32React/WWWData.h
|
||||
recovery/WWWData.h
|
||||
/interface/dist-recovery
|
||||
|
||||
# i18n generated files
|
||||
interface/src/i18n/i18n-react.tsx
|
||||
|
||||
@@ -5,6 +5,30 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [3.9.0] Unreleased
|
||||
|
||||
## Fixed
|
||||
|
||||
- RC300/RC310 `nofrostmode` missing `off` state [#3311](https://github.com/emsesp/EMS-ESP32/issues/3311)
|
||||
|
||||
## [3.8.6] 24 September 2026
|
||||
|
||||
## Added
|
||||
|
||||
- own rgb_led library supporting WS2812B V5 and the newer V6 RGB LEDs
|
||||
|
||||
## [3.8.5] 22 September 2026
|
||||
|
||||
## Added
|
||||
|
||||
- Change webUI message when upgrading to a major version of the firmware, in preparation for the upcoming 3.9.0 release.
|
||||
|
||||
## [3.8.4] 4 August 2026
|
||||
|
||||
## Fixed
|
||||
|
||||
- Allow auto-download and install of firmware versions which are smaller to allow update to the latest 3.9.0-development version.
|
||||
|
||||
## [3.8.3] 1 August 2026
|
||||
|
||||
## Added
|
||||
|
||||
+40
-2
@@ -4,7 +4,7 @@ For more details go to [emsesp.org](https://emsesp.org/).
|
||||
|
||||
## [3.9.0]
|
||||
|
||||
This release is based on the latest Espressif/Arduino core version 3. It brings in many memory and performance optimizations. Note it does require the user to manually migrate settings from 3.8.x to 3.9.0.
|
||||
This release is based on the latest Espressif/Arduino core version 3. It brings in many memory and performance optimizations, for example 25% more available memory and 30% less PSRAM fragmentation.
|
||||
|
||||
## Added
|
||||
|
||||
@@ -19,9 +19,23 @@ This release is based on the latest Espressif/Arduino core version 3. It brings
|
||||
- `show system` reports the Ethernet status on boards with a PHY, so a failed initialisation or a link that never came up is visible instead of Ethernet being omitted completely. Also shows the loop task's lowest free stack, to help diagnose reboots
|
||||
- new Application Setting "Automatically check for firmware updates", off by default. When enabled EMS-ESP asks emsesp.org for the latest version once a day on its own, otherwise it only asks when the WebUI wants to show it, so an unattended system never contacts emsesp.org
|
||||
- Automatically check and convert older 3.8.x filesystem to the new 3.9.0 format
|
||||
- heatpump pool: `poolon` (switch, 0x48A), `hppoolon` (pool heating active, 0x488), MP100 `poolshuntset` (0x5C4) and `poolrequest` (0x5BA), shorter pool names
|
||||
- EMS-ESP Recovery, a small firmware for the factory (`boot`) partition on 16MB boards. It starts a `<AP name>-recovery` access point and connects to the network with the saved EMS-ESP settings (Ethernet if configured, otherwise WiFi, using the static IP if one is set), and has a WebUI to sign in with an existing admin user, upload a new firmware into either OTA partition, or start one of the installed firmwares. It only reads the EMS-ESP settings, never changes them. Released as `EMS-ESP-Recovery-<version>-<chip>-16MB[+].bin`, and installed by uploading it in the EMS-ESP WebUI like a normal firmware, or flashed with `pio run -e recovery_s3_16M_P -t upload`
|
||||
- firmware rollback: a newly installed firmware is only kept once it has run for 2 minutes. If it crashes or restarts before then, the device goes back to the previous firmware, or to the Recovery if there is none
|
||||
- on 16MB boards with the Recovery installed, EMS-ESP switches to it after crashing 5 times in a row. The Recovery shows why it was started and which firmware failed to start
|
||||
- the Version page shows the installed Recovery version on 16MB boards
|
||||
- crash test builds to try out the rollback and recovery, see `[env:crashtest_s3_16M_P]` in `platformio.ini`
|
||||
- 16MB builds use EMS-ESP's own Arduino/IDF libs from [esp32-arduino-lib-builder](https://github.com/emsesp/esp32-arduino-lib-builder) instead of Tasmota's prebuilt cores
|
||||
- optional `ccache` wrapping when it is installed (`scripts/enable_ccache.py`) to speed up CI
|
||||
- heat pump `dhw/disinfectduration`, the time allowed to reach the thermal disinfection temperature (0xEA offset 15, 120-240 min) [#3278](https://github.com/emsesp/EMS-ESP32/issues/3278)
|
||||
|
||||
## Fixed
|
||||
|
||||
- ESP32-C6 boards without a board profile got invalid GPIOs (99), so on every start the settings were rejected and fell back to empty values (blank Board Profile, EMS Tx Mode and Bus ID), and saving any Application setting failed with a 400 error. A new C6 now starts with the CUSTOM profile and pins for the Seeed XIAO ESP32-C6: Rx GPIO20 (D9), Tx GPIO19 (D8), button GPIO9
|
||||
- heat-pump operating state (`hpoperatingstate`: heating/off/cooling) is published for every heating circuit. On BC400/UI800 it was only filled for hc1, because hc2 never answers telegram 0x472 while the state is still broadcast in 0x2A6 offset 20 [#3097](https://github.com/emsesp/EMS-ESP32/issues/3097)
|
||||
- restart from the WebUI (Restart button, firmware/settings upload) only happened while the browser tab stayed open. If the tab was closed the restart was silently dropped after 5 minutes, leaving a new firmware uninstalled. EMS-ESP now restarts on its own if the WebUI hasn't picked up the pending restart within 10 seconds
|
||||
- a firmware upload from a script or `curl` was accepted (`md5_ok`) but EMS-ESP didn't restart into it: the client closing the connection after the upload was treated as an aborted upload, which cancelled the pending restart
|
||||
- EMS-ESP crashed and restarted when an upload was rejected, for example an empty or invalid MD5 file. The error code was kept in the request's `_tempObject`, which the web server frees as heap memory when the request ends
|
||||
- shunting yard show json
|
||||
- `system/sendmail` called from a Command or the Scheduler computed its value twice, which stripped the quotes from literal text and then failed on characters like `!`. Also shunting yard treated a `?` inside a quoted string as a ternary, so text like `"too hot?"` returned nothing
|
||||
- memory leak when using different timezones for ems-esp and thermostat[#3184](https://github.com/emsesp/EMS-ESP32/issues/3184)
|
||||
@@ -36,15 +50,36 @@ This release is based on the latest Espressif/Arduino core version 3. It brings
|
||||
- HA Discovery warning on Uptime after EMS-ESP boot due to NTP not ready
|
||||
- hc/control setting for UI800 thermostats [#3181](https://github.com/emsesp/EMS-ESP32/discussions/3181)
|
||||
- solar module switches [#3223](https://github.com/emsesp/EMS-ESP32/issues/3223)
|
||||
- UI800/HMI800.2 thermostats without telegram 0x470 reported `summertemp` as 0 and lost the per-hc heat/cool delays. `summermode` is now also shown for UI800 heatpumps [#3257](https://github.com/emsesp/EMS-ESP32/issues/3257)
|
||||
- possible crash when the WebUI asked for version information while the versions.json refresh was rewriting the cache from the main loop task
|
||||
- HTTP client could write past a fixed 64 byte buffer when a URL had a hostname of 64 characters or more, could busy-spin for the whole read budget if the socket reported bytes it wouldn't return, and buffered responses without any size limit
|
||||
- the "Ethernet clock mode (GPIO16/17) conflicts with PSRAM" error was never logged because Ethernet had already been skipped by the time the check ran
|
||||
- heat limit entities (`maxheatcomp`, `maxheatheat`, `dhw.maxheat`) read as unknown on heat pumps with an auxiliary heater larger than 9 kW - `enum_maxHeat` was missing the 12 kW and 15 kW steps
|
||||
- add 0kW to heat limits [#3262](https://github.com/emsesp/EMS-ESP32/issues/3262), [#3265](https://github.com/emsesp/EMS-ESP32/issues/3265)
|
||||
- show temperature value for sensor 0x40 [#3272](https://github.com/emsesp/EMS-ESP32/issues/3272)
|
||||
- no EMS bus on custom boards whose interface relies on a pull-up on the Rx line. The newer ESP-IDF no longer enables the internal pull-up on the UART Rx pin, so it is now set explicitly as it was in 3.8 [#3258](https://github.com/emsesp/EMS-ESP32/discussions/3258)
|
||||
- no EMS bus after restart when Rx uses a pin that boots with another IO_MUX function (e.g. GPIO43/44 = UART0 on ESP32-S3). IDF 5 `uart_set_pin()` no longer selects the GPIO function on the Rx pad when routing via the matrix, so the pin is reset before UART setup [#3258](https://github.com/emsesp/EMS-ESP32/discussions/3258)
|
||||
- Tx mode Auto preferred HT3 pacing over EMS+ when the bus used the HT3 address mask. Some Bosch heat pumps (e.g. Compress 7000i AW, CS5800i) need EMS+ timing; Auto now selects EMS+ when boiler monitor 0xE4 is seen (not merely any extended telegram, which Junkers HT3 also uses) [#3283](https://github.com/emsesp/EMS-ESP32/issues/3283), [#3245](https://github.com/emsesp/EMS-ESP32/issues/3245)
|
||||
- Guru Meditation (cache error) on HybridCompile builds when the EMS or console UART ISR ran from flash during a SPI flash read (WiFi power-down, mDNS, NVS). The UART ISR and ringbuf ISR helpers are now in IRAM
|
||||
- compile errors against Arduino 3.3.12 from Tasmota/Arduino headers named `console.h`, `network.h` and `httpClient.h`. Those EMS-ESP headers are now `emsesp_console.h`, `emsesp_network.h` and `emsesp_http_client.h`
|
||||
- HTTP OTA from WSL/mDNS stalled on IPv6 and used tiny write chunks. Uploads now resolve IPv4 and stream in 64 KB blocks (`scripts/upload.py`, `scripts/upload_cli.py`)
|
||||
- BC400/UI800/HMC310 `dhw.circmode` labels: `auto` was "According to DHW time program" and `own prog` was the circulation's own Auto program. Renamed to match the CS5800i UI [#3296](https://github.com/emsesp/EMS-ESP32/issues/3296)
|
||||
- resetting a customized min/max now restores the original limits immediately. The register limits are kept and the custom range is applied only when the limits are read, so a restart is no longer needed
|
||||
- a device's custom name and brand were wiped when saving an entity customization, and the name was gone after the next restart. Saving entities now keeps the name and brand stored in the same record
|
||||
- `dhw/comfdiff`, `dhw/ecodiff` and `dhw/ecoplusdiff` now accept a minimum of 4 K instead of 6 K, matching the CS5800i [#3273](https://github.com/emsesp/EMS-ESP32/issues/3273)
|
||||
- syslog dropped messages at DEBUG because the queue held only 10 lines, less than one bus burst. The queue is now 100. Syslog success and failure counts are included in the MQTT heartbeat, and the Activity page rounds the quality percentage instead of truncating it [#3284](https://github.com/emsesp/EMS-ESP32/issues/3284)
|
||||
- a read or write aimed at EMS-ESP's own bus id was left unanswered, so the sender waited for a timeout. EMS-ESP now sends an empty reply to those reads and acknowledges those writes [#3094](https://github.com/emsesp/EMS-ESP32/issues/3094)
|
||||
- a second boiler monitor `0xE4` carrying `0xFFFF` cleared a valid `pc0Flow`. That empty value is now ignored [#3224](https://github.com/emsesp/EMS-ESP32/issues/3224)
|
||||
- writing a negative custom entity value was always 1 too high because float-to-int rounding always added +0.5. Negative values now round the same way as positive ones [#3297](https://github.com/emsesp/EMS-ESP32/issues/3297)
|
||||
- after a cold boot, SoftAP fallback no longer abandons STA forever. If a home WiFi SSID is configured, EMS-ESP keeps retrying STA while the captive portal is up so a late-booting router (e.g. after a power outage) can still be joined; SoftAP stops once STA gets an IP
|
||||
- NTP time zone strings updated to IANA tz 2026e. About 32 zones had the wrong UTC offset now or within the next year (Mexico no longer uses DST, Egypt does again, Morocco is permanent UTC+0, Kazakhstan, and from 1 November 2026 western Canada stays on permanent offsets). On boot EMS-ESP rewrites the stored POSIX string from the saved IANA label so existing installs pick up the new rules without re-selecting the zone [#3308](https://github.com/emsesp/EMS-ESP32/pull/3308)
|
||||
- MQTT with TLS and a CA certificate crashed on every connect (a boot loop) on boards with PSRAM when the broker's certificate chain is EC, such as Let's Encrypt ISRG Root X2: verifying it needs about 6 KB of stack and the MQTT task only had 5120 bytes. The MQTT task now gets an 8 KB stack when TLS verifies a CA certificate, and keeps 5120 bytes for insecure TLS and plain MQTT
|
||||
|
||||
## Changed
|
||||
|
||||
- various memory optimizations [#3083](https://github.com/emsesp/EMS-ESP32/issues/3083)
|
||||
- Scheduler name is now mandatory
|
||||
- Access Point provision mode label renamed from "When network connection is lost" to "When network connection not found" (all languages)
|
||||
- network fallback to AP only after start [#3090](https://github.com/emsesp/EMS-ESP32/issues/3090)
|
||||
- replaced Web async-validator with custom validator and toast with native snackbar to reduce bundle size
|
||||
- Gateway and Connect devices are shown in the Devices page, but disabled [3126](https://github.com/emsesp/EMS-ESP32/discussions/3126)
|
||||
@@ -56,4 +91,7 @@ This release is based on the latest Espressif/Arduino core version 3. It brings
|
||||
- multiple optimization in network, power down WiFi when Ethernet and WiFi nosleep is instant when selected [#3213](https://github.com/emsesp/EMS-ESP32/pull/3213)
|
||||
- TLS buffers (~17KB per HTTPS request) are taken from PSRAM on boards that have it, instead of fragmenting the internal heap
|
||||
- failed versions.json fetches back off from 5 up to 160 minutes instead of retrying every 5 minutes, as each attempt blocks the main loop for as long as the DNS, connect and read timeouts allow
|
||||
|
||||
- optimized the LED library to support WS2812B V5 and the newer V6 RGB LEDs
|
||||
- hold Tx while a K30RF/KM200 gateway is syncing [#3094](https://github.com/emsesp/EMS-ESP32/issues/3094)
|
||||
- Tasmota platform 2026.05.50 (Arduino 3.3.8 / IDF 5.5.4) to 2026.09.50 (Arduino 3.3.12 / IDF 5.5.5). Building that platform needs PlatformIO Core ≥6.2.0; install `pioarduino==6.2.0` (official `platformio` is still 6.1.19). See CONTRIBUTING.md
|
||||
- CI dev-release firmware jobs run in parallel (webUI, firmware targets, standalone) instead of one serial job
|
||||
|
||||
@@ -10,6 +10,40 @@ Everybody is welcome and invited to contribute to the EMS-ESP Project by:
|
||||
|
||||
This document describes rules that are in effect for this repository, meant for handling issues by contributors in the issue tracker and PRs.
|
||||
|
||||
## Building the firmware
|
||||
|
||||
Arduino/IDF 3.3.12 (Tasmota platform `2026.09.50` in `platformio.ini`) needs **PlatformIO Core ≥6.2.0**. The official `platformio` package on PyPI is still 6.1.19, so `pio upgrade` and `pip install -U platformio` stay on 6.1.19 and you get:
|
||||
|
||||
`IncompatiblePlatform: Development platform 'espressif32' is not compatible with PlatformIO Core v6.1.19 and depends on PlatformIO Core >=6.2.0.`
|
||||
|
||||
Install **pioarduino** 6.2.0 (drop-in Core fork). It provides the same `pio` / `platformio` commands.
|
||||
|
||||
**VS Code / Cursor** (PlatformIO IDE uses `~/.platformio/penv`):
|
||||
|
||||
Linux / macOS:
|
||||
|
||||
```bash
|
||||
~/.platformio/penv/bin/pip install -U "pioarduino==6.2.0"
|
||||
~/.platformio/penv/bin/pio --version
|
||||
```
|
||||
|
||||
Windows (PowerShell):
|
||||
|
||||
```powershell
|
||||
& "$env:USERPROFILE\.platformio\penv\Scripts\pip.exe" install -U "pioarduino==6.2.0"
|
||||
& "$env:USERPROFILE\.platformio\penv\Scripts\pio.exe" --version
|
||||
```
|
||||
|
||||
You should see `PlatformIO Core, version 6.2.0`. Reload the window (or restart the IDE), then build again.
|
||||
|
||||
**CLI-only / CI:**
|
||||
|
||||
```bash
|
||||
pip install -U "pioarduino==6.2.0"
|
||||
# or: uv pip install --system pioarduino==6.2.0
|
||||
platformio --version
|
||||
```
|
||||
|
||||
## Opening New Issues
|
||||
|
||||
1. Opening an issue means that a problem exists in the code and should be addressed by the project contributors.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -21,7 +21,7 @@
|
||||
"arduino",
|
||||
"espidf"
|
||||
],
|
||||
"name": "Tasmota ESP32-S3 32M Flash OPI PSRAM, 4608KB Code/OTA, 2MB FS",
|
||||
"name": "ESP32-S3 32M Flash OPI PSRAM, 4608KB Code/OTA, 2MB FS",
|
||||
"upload": {
|
||||
"flash_size": "32MB",
|
||||
"maximum_ram_size": 327680,
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@
|
||||
"arduino",
|
||||
"espidf"
|
||||
],
|
||||
"name": "Tasmota ESP32 16M Flash, 4608KB Code/OTA, 2MB FS",
|
||||
"name": "ESP32 16M Flash, 4608KB Code/OTA, 2MB FS",
|
||||
"upload": {
|
||||
"flash_size": "16MB",
|
||||
"maximum_ram_size": 327680,
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@
|
||||
"arduino",
|
||||
"espidf"
|
||||
],
|
||||
"name": "Tasmota ESP32 16M Flash DIO PSRAM, 4608KB Code/OTA, 2MB FS",
|
||||
"name": "ESP32 16M Flash DIO PSRAM, 4608KB Code/OTA, 2MB FS",
|
||||
"upload": {
|
||||
"flash_size": "16MB",
|
||||
"maximum_ram_size": 327680,
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@
|
||||
"arduino",
|
||||
"espidf"
|
||||
],
|
||||
"name": "Tasmota ESP32 4M Flash, 4608KB Code/OTA, 2MB FS",
|
||||
"name": "ESP32 4M Flash, 4608KB Code/OTA, 2MB FS",
|
||||
"upload": {
|
||||
"flash_size": "4MB",
|
||||
"maximum_ram_size": 327680,
|
||||
|
||||
+11
-8
@@ -19,18 +19,11 @@
|
||||
"lib/espMqttClient",
|
||||
"analyse.html",
|
||||
"dist",
|
||||
"**/*.csv",
|
||||
"**/*.md",
|
||||
"**/*.py",
|
||||
"locale_translations.h",
|
||||
"TZ.tsx",
|
||||
"**/*.txt",
|
||||
"build/**",
|
||||
"**/i18n/**",
|
||||
"/project-words.txt",
|
||||
"Makefile",
|
||||
"**/*.ini",
|
||||
"**/*.json",
|
||||
"src/core/modbus_entity_parameters.hpp",
|
||||
"sdkconfig.*",
|
||||
"managed_components/**",
|
||||
@@ -39,6 +32,16 @@
|
||||
"lib/esp32-psram/**",
|
||||
"test/test_api/test_api.h",
|
||||
"lib_standalone/**",
|
||||
"**/*.js"
|
||||
"pio_local.ini_example",
|
||||
"tz_zones.h",
|
||||
"**/i18n/**",
|
||||
"**/*.js",
|
||||
"**/*.csv",
|
||||
"**/*.md",
|
||||
"**/*.py",
|
||||
"**/*.sh",
|
||||
"**/*.ini",
|
||||
"**/*.json",
|
||||
"**/*.txt"
|
||||
]
|
||||
}
|
||||
+1724
-1686
File diff suppressed because it is too large
Load Diff
+1723
-1694
File diff suppressed because it is too large
Load Diff
@@ -29,7 +29,7 @@ telegram_type_id,name,is_fetched
|
||||
0x3B,Energy,
|
||||
0x3D,RC35Set,
|
||||
0x3E,RC35Monitor,
|
||||
0x3F,RC35Timer,
|
||||
0x3F,RC30Timer,
|
||||
0x40,RC30Temp,
|
||||
0x41,RC30Monitor,
|
||||
0x42,RC35Timer2,
|
||||
@@ -104,7 +104,7 @@ telegram_type_id,name,is_fetched
|
||||
0x0292,HPMode,fetched
|
||||
0x0293,HPMode,fetched
|
||||
0x0294,HPMode,fetched
|
||||
0x029B,RC300Curves,fetched
|
||||
0x029B,RC300Curves,
|
||||
0x029C,RC300Curves,
|
||||
0x029D,RC300Curves,
|
||||
0x029E,RC300Curves,
|
||||
@@ -112,7 +112,7 @@ telegram_type_id,name,is_fetched
|
||||
0x02A0,RC300Curves,
|
||||
0x02A1,RC300Curves,
|
||||
0x02A2,RC300Curves,
|
||||
0x02A5,RC300Monitor,
|
||||
0x02A5,CRFMonitor,
|
||||
0x02A6,RC300Monitor,
|
||||
0x02A7,RC300Monitor,
|
||||
0x02A8,RC300Monitor,
|
||||
@@ -200,7 +200,7 @@ telegram_type_id,name,is_fetched
|
||||
0x04A2,HpInput,fetched
|
||||
0x04A5,HPFan,fetched
|
||||
0x04A7,HPPowerLimit,fetched
|
||||
0x04AA,HPPower,
|
||||
0x04AA,HPPower2,fetched
|
||||
0x04AE,HPEnergy,fetched
|
||||
0x04AF,HPMeters,fetched
|
||||
0x055C,VentilationSet,fetched
|
||||
@@ -209,6 +209,7 @@ telegram_type_id,name,is_fetched
|
||||
0x0585,Blowerspeed,
|
||||
0x0587,Bypass,
|
||||
0x05BA,HpPoolStatus,fetched
|
||||
0x05C4,HpPoolShunt,
|
||||
0x05D9,Airquality,
|
||||
0x0772,HIUSettings,
|
||||
0x0779,HIUMonitor,
|
||||
@@ -232,4 +233,6 @@ telegram_type_id,name,is_fetched
|
||||
0x099A,HPStarts,
|
||||
0x099B,HPFlowTemp,
|
||||
0x099C,HPComp,
|
||||
0x099D,HPComp2,
|
||||
0x09A0,HPTemperature,
|
||||
0x09A8,HPVolume,
|
||||
|
||||
|
+6072
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,238 @@
|
||||
telegram_type_id,name,is_fetched
|
||||
0x04,UBAFactory,fetched
|
||||
0x06,RCTime,
|
||||
0x0A,EasyMonitor,fetched
|
||||
0x10,UBAErrorMessage1,
|
||||
0x11,UBAErrorMessage2,
|
||||
0x12,RCErrorMessage,
|
||||
0x13,RCErrorMessage2,
|
||||
0x14,UBATotalUptime,fetched
|
||||
0x15,UBAMaintenanceData,
|
||||
0x16,UBAParameters,fetched
|
||||
0x18,UBAMonitorFast,
|
||||
0x19,UBAMonitorSlow,
|
||||
0x1A,UBASetPoints,
|
||||
0x1C,UBAMaintenanceStatus,
|
||||
0x1E,HydrTemp,
|
||||
0x23,JunkersSetMixer,fetched
|
||||
0x27,UBASettingsWW,fetched
|
||||
0x28,WeatherComp,fetched
|
||||
0x2A,MC110Status,
|
||||
0x2E,Meters,
|
||||
0x33,UBAParameterWW,fetched
|
||||
0x34,UBAMonitorWW,
|
||||
0x35,UBAFlags,
|
||||
0x37,WWSettings,fetched
|
||||
0x38,WWTimer,fetched
|
||||
0x39,WWCircTimer,fetched
|
||||
0x3A,RC30WWSettings,fetched
|
||||
0x3B,Energy,
|
||||
0x3D,RC35Set,
|
||||
0x3E,RC35Monitor,
|
||||
0x3F,RC30Timer,
|
||||
0x40,RC30Temp,
|
||||
0x41,RC30Monitor,
|
||||
0x42,RC35Timer2,
|
||||
0x47,RC35Set,
|
||||
0x48,RC35Monitor,
|
||||
0x49,RC35Timer,
|
||||
0x4C,RC35Timer2,
|
||||
0x51,RC35Set,
|
||||
0x52,RC35Monitor,
|
||||
0x53,RC35Timer,
|
||||
0x56,RC35Timer2,
|
||||
0x5B,RC35Set,
|
||||
0x5C,RC35Monitor,
|
||||
0x5D,RC35Timer,
|
||||
0x60,RC35Timer2,
|
||||
0x96,SM10Config,fetched
|
||||
0x97,SM10Monitor,
|
||||
0x9C,WM10MonitorMessage,
|
||||
0x9D,WM10SetMessage,
|
||||
0xA2,RCError,
|
||||
0xA3,RCOutdoorTemp,
|
||||
0xA5,IBASettings,fetched
|
||||
0xA7,RC30Set,
|
||||
0xA9,RC30Vacation,fetched
|
||||
0xAA,MMConfigMessage,fetched
|
||||
0xAB,MMStatusMessage,
|
||||
0xAC,MMSetMessage,
|
||||
0xAF,RC20Remote,
|
||||
0xB0,RC10Set,
|
||||
0xB1,RC10Monitor,
|
||||
0xBB,HybridSettings,fetched
|
||||
0xBF,ErrorMessage,
|
||||
0xC0,RCErrorMessage,
|
||||
0xC2,UBAErrorMessage3,
|
||||
0xC6,UBAErrorMessage3,
|
||||
0xD1,UBAOutdoorTemp,
|
||||
0xE3,UBAMonitorSlowPlus2,
|
||||
0xE4,UBAMonitorFastPlus,
|
||||
0xE5,UBAMonitorSlowPlus,
|
||||
0xE6,UBAParametersPlus,fetched
|
||||
0xE9,UBAMonitorWWPlus,
|
||||
0xEA,UBAParameterWWPlus,fetched
|
||||
0xEB,PumpKick,fetched
|
||||
0x0101,ISM1Set,fetched
|
||||
0x0103,ISM1StatusMessage,fetched
|
||||
0x0104,ISM2StatusMessage,
|
||||
0x010C,IPMStatusMessage,
|
||||
0x011E,JunkersDisp,fetched
|
||||
0x012E,HPEnergy1,
|
||||
0x013B,HPEnergy2,
|
||||
0x0165,JunkersSet,
|
||||
0x0166,JunkersSet,
|
||||
0x0167,JunkersSet,
|
||||
0x0168,JunkersSet,
|
||||
0x016E,Absent,fetched
|
||||
0x016F,JunkersMonitor,
|
||||
0x0170,JunkersMonitor,
|
||||
0x0171,JunkersMonitor,
|
||||
0x0172,JunkersMonitor,
|
||||
0x0179,JunkersSet,
|
||||
0x017A,JunkersSet,
|
||||
0x017B,JunkersSet,
|
||||
0x017C,JunkersSet,
|
||||
0x01D3,JunkersDhw,fetched
|
||||
0x023A,RC300OutdoorTemp,fetched
|
||||
0x023E,PVSettings,fetched
|
||||
0x0240,RC300Settings,fetched
|
||||
0x0241,RC300Settings,fetched
|
||||
0x0267,RC300Floordry,
|
||||
0x0269,RC300Holiday,fetched
|
||||
0x0291,HPMode,fetched
|
||||
0x0292,HPMode,fetched
|
||||
0x0293,HPMode,fetched
|
||||
0x0294,HPMode,fetched
|
||||
0x029B,RC300Curves,
|
||||
0x029C,RC300Curves,
|
||||
0x029D,RC300Curves,
|
||||
0x029E,RC300Curves,
|
||||
0x029F,RC300Curves,
|
||||
0x02A0,RC300Curves,
|
||||
0x02A1,RC300Curves,
|
||||
0x02A2,RC300Curves,
|
||||
0x02A5,CRFMonitor,
|
||||
0x02A6,RC300Monitor,
|
||||
0x02A7,RC300Monitor,
|
||||
0x02A8,RC300Monitor,
|
||||
0x02A9,RC300Monitor,
|
||||
0x02AA,RC300Monitor,
|
||||
0x02AB,RC300Monitor,
|
||||
0x02AC,RC300Monitor,
|
||||
0x02AF,RC300Summer,
|
||||
0x02B0,RC300Summer,
|
||||
0x02B1,RC300Summer,
|
||||
0x02B2,RC300Summer,
|
||||
0x02B3,RC300Summer,
|
||||
0x02B4,RC300Summer,
|
||||
0x02B5,RC300Summer,
|
||||
0x02B6,RC300Summer,
|
||||
0x02B9,RC300Set,
|
||||
0x02BA,RC300Set,
|
||||
0x02BB,RC300Set,
|
||||
0x02BC,RC300Set,
|
||||
0x02BD,RC300Set,
|
||||
0x02BE,RC300Set,
|
||||
0x02BF,RC300Set,
|
||||
0x02C0,RC300Set,
|
||||
0x02CC,HPPressure,fetched
|
||||
0x02CD,MMPLUSConfigMessage,
|
||||
0x02D6,HPPump2,fetched
|
||||
0x02D7,MMPLUSStatusMessage,
|
||||
0x02E0,UBASetPoints,
|
||||
0x02F5,RC300WWmode,fetched
|
||||
0x02F6,RC300WW2mode,fetched
|
||||
0x0313,MMPLUSConfigMessage_WWC,fetched
|
||||
0x031B,RC300WWtemp,fetched
|
||||
0x031D,RC300WWmode2,
|
||||
0x031E,RC300WWmode2,
|
||||
0x0331,MMPLUSStatusMessage_WWC,
|
||||
0x0358,SM100SystemConfig,fetched
|
||||
0x035A,SM100CircuitConfig,fetched
|
||||
0x035C,SM100HeatAssist,fetched
|
||||
0x035D,SM100Circuit2Config,fetched
|
||||
0x035F,SM100Config1,fetched
|
||||
0x0361,SM100Differential,fetched
|
||||
0x0362,SM100Monitor,
|
||||
0x0363,SM100Monitor2,
|
||||
0x0364,SM100Status,
|
||||
0x0366,SM100Config,
|
||||
0x036A,SM100Status2,
|
||||
0x0380,SM100CollectorConfig,fetched
|
||||
0x038E,SM100Energy,fetched
|
||||
0x0391,SM100Time,fetched
|
||||
0x0421,RC300Set2,
|
||||
0x0422,RC300Set2,
|
||||
0x0423,RC300Set2,
|
||||
0x0424,RC300Set2,
|
||||
0x043F,CRHolidays,fetched
|
||||
0x0467,HPSet,
|
||||
0x0468,HPSet,
|
||||
0x0469,HPSet,
|
||||
0x046A,HPSet,
|
||||
0x0470,RC300Summer3,fetched
|
||||
0x0471,RC300Summer2,
|
||||
0x0472,RC300Summer2,
|
||||
0x0473,RC300Summer2,
|
||||
0x0474,RC300Summer2,
|
||||
0x0475,RC300Summer2,
|
||||
0x0476,RC300Summer2,
|
||||
0x0477,RC300Summer2,
|
||||
0x0478,RC300Summer2,
|
||||
0x047B,HP2,
|
||||
0x0484,HPSilentMode,fetched
|
||||
0x0485,HpCooling,fetched
|
||||
0x0486,HpInConfig,fetched
|
||||
0x0488,HPValve,fetched
|
||||
0x0489,HPpassive,
|
||||
0x048A,HpPool,fetched
|
||||
0x048B,HPPumps,fetched
|
||||
0x048D,HpPower,fetched
|
||||
0x048F,HpTemperatures,
|
||||
0x0491,HPAdditionalHeater,fetched
|
||||
0x0492,HpHeaterConfig,fetched
|
||||
0x0494,UBAEnergySupplied,
|
||||
0x0495,UBAInformation,
|
||||
0x0499,HPDhwSettings,fetched
|
||||
0x049C,HPSettings2,fetched
|
||||
0x049D,HPSettings3,fetched
|
||||
0x04A2,HpInput,fetched
|
||||
0x04A5,HPFan,fetched
|
||||
0x04A7,HPPowerLimit,fetched
|
||||
0x04AA,HPPower2,fetched
|
||||
0x04AE,HPEnergy,fetched
|
||||
0x04AF,HPMeters,fetched
|
||||
0x055C,VentilationSet,fetched
|
||||
0x056B,VentilationMode,fetched
|
||||
0x0583,VentilationMonitor,
|
||||
0x0585,Blowerspeed,
|
||||
0x0587,Bypass,
|
||||
0x05BA,HpPoolStatus,fetched
|
||||
0x05C4,HpPoolShunt,
|
||||
0x05D9,Airquality,
|
||||
0x0772,HIUSettings,
|
||||
0x0779,HIUMonitor,
|
||||
0x07A5,SM100wwCirc,fetched
|
||||
0x07A6,SM100wwParam,fetched
|
||||
0x07AA,SM100wwStatus,
|
||||
0x07AB,SM100wwCommand,
|
||||
0x07AC,SM100wwParam1,
|
||||
0x07AD,SM100ValveStatus,
|
||||
0x07AE,SM100wwKeepWarm,fetched
|
||||
0x07D6,SM100wwTemperature,
|
||||
0x07E0,SM100wwStatus2,fetched
|
||||
0x0935,EM100SetMessage,fetched
|
||||
0x0936,EM100OutMessage,
|
||||
0x0937,EM100TempMessage,
|
||||
0x0938,EM100InputMessage,
|
||||
0x0939,EM100MonitorMessage,
|
||||
0x093A,EM100ConfigMessage,
|
||||
0x0998,HPSettings,fetched
|
||||
0x0999,HPFunctionTest,fetched
|
||||
0x099A,HPStarts,
|
||||
0x099B,HPFlowTemp,
|
||||
0x099C,HPComp,
|
||||
0x099D,HPComp2,
|
||||
0x09A0,HPTemperature,
|
||||
0x09A8,HPVolume,
|
||||
|
@@ -1,6 +1,7 @@
|
||||
node_modules/
|
||||
build/
|
||||
dist/
|
||||
dist-recovery/
|
||||
src/i18n/*
|
||||
|
||||
.prettierrc
|
||||
|
||||
@@ -18,6 +18,7 @@ export default defineConfig(
|
||||
{
|
||||
ignores: [
|
||||
'dist/*',
|
||||
'dist-recovery/*',
|
||||
'*.mjs',
|
||||
'build/*',
|
||||
'*.js',
|
||||
|
||||
+15
-13
@@ -18,6 +18,8 @@
|
||||
"standalone": "concurrently -c \"auto\" \"typesafe-i18n\" \"pnpm:mock-rest\" \"vite dev\"",
|
||||
"typesafe-i18n": "typesafe-i18n --no-watch",
|
||||
"build-webUI": "typesafe-i18n --no-watch && vite build && node progmem-generator.js",
|
||||
"recovery": "vite dev --mode recovery",
|
||||
"build-recovery": "vite build --mode recovery && node progmem-generator.js --src ./dist-recovery --out ../recovery/WWWData.h --index recovery.html",
|
||||
"format": "prettier -l -w '**/*.{ts,tsx,js,css,json,md}'",
|
||||
"lint": "eslint . --fix",
|
||||
"standalone-devcontainer": "concurrently -c \"auto\" \"typesafe-i18n\" \"pnpm:mock-rest\" \"vite --host\""
|
||||
@@ -26,37 +28,37 @@
|
||||
"@alova/adapter-xhr": "2.3.3",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@mui/icons-material": "^9.4.0",
|
||||
"@mui/material": "^9.4.0",
|
||||
"@mui/icons-material": "^9.5.0",
|
||||
"@mui/material": "^9.5.0",
|
||||
"@table-library/react-table-library": "4.1.15",
|
||||
"alova": "^3.5.5",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"preact": "^10.29.8",
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0",
|
||||
"preact": "^11.0.2",
|
||||
"react": "^19.3.1",
|
||||
"react-dom": "^19.3.1",
|
||||
"react-icons": "^5.7.0",
|
||||
"react-router": "^8.4.0",
|
||||
"react-router": "^8.5.0",
|
||||
"typesafe-i18n": "^5.27.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@preact/preset-vite": "^2.10.6",
|
||||
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||
"@types/node": "^26.6.2",
|
||||
"@types/node": "^26.6.5",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"concurrently": "^10.0.5",
|
||||
"eslint": "^10.11.0",
|
||||
"concurrently": "^10.0.6",
|
||||
"eslint": "^10.13.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"etag": "^1.8.1",
|
||||
"mime-types": "^3.0.2",
|
||||
"prettier": "^3.9.8",
|
||||
"prettier": "^3.9.10",
|
||||
"rollup-plugin-visualizer": "^7.1.1",
|
||||
"terser": "^5.51.2",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.70.1",
|
||||
"vite": "^8.3.0",
|
||||
"typescript-eslint": "^8.72.1",
|
||||
"vite": "^8.3.5",
|
||||
"vite-plugin-imagemin": "^0.6.1"
|
||||
},
|
||||
"packageManager": "pnpm@12.6.0"
|
||||
"packageManager": "pnpm@12.15.0"
|
||||
}
|
||||
|
||||
Generated
+525
-518
File diff suppressed because it is too large
Load Diff
@@ -10,10 +10,17 @@ import mime from 'mime-types';
|
||||
import { relative, resolve, sep } from 'path';
|
||||
import zlib from 'zlib';
|
||||
|
||||
// optional args: --src <dir> --out <file> --index <html file served as /index.html>
|
||||
const getArg = (name, fallback) => {
|
||||
const i = process.argv.indexOf(name);
|
||||
return i !== -1 && process.argv[i + 1] ? process.argv[i + 1] : fallback;
|
||||
};
|
||||
|
||||
const ARDUINO_INCLUDES = '#include <Arduino.h>\n\n';
|
||||
const INDENT = ' ';
|
||||
const outputPath = '../src/ESP32React/WWWData.h';
|
||||
const sourcePath = './dist';
|
||||
const outputPath = getArg('--out', '../src/ESP32React/WWWData.h');
|
||||
const sourcePath = getArg('--src', './dist');
|
||||
const indexFile = getArg('--index', 'index.html');
|
||||
const bytesPerLine = 20;
|
||||
let totalSize = 0;
|
||||
let bundleStats = {
|
||||
@@ -129,7 +136,10 @@ const writeFile = (relativeFilePath, buffer) => {
|
||||
bundleStats[fileType].compressed += zipBuffer.length;
|
||||
|
||||
fileInfo.push({
|
||||
uri: '/' + relativeFilePath.replace(sep, '/'),
|
||||
uri:
|
||||
relativeFilePath === indexFile
|
||||
? '/index.html'
|
||||
: '/' + relativeFilePath.replace(sep, '/'),
|
||||
mimeType,
|
||||
variable,
|
||||
size,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<link rel="stylesheet" href="/css/roboto.css" />
|
||||
<title>EMS-ESP Recovery</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/recovery/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -11,6 +11,7 @@ import WarningIcon from '@mui/icons-material/Warning';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
@@ -91,6 +92,8 @@ const Customizations = () => {
|
||||
const [deviceEntities, setDeviceEntities] = useState<DeviceEntity[]>([]);
|
||||
const [confirmReset, setConfirmReset] = useState<boolean>(false);
|
||||
const [selectedFilters, setSelectedFilters] = useState<number>(0);
|
||||
const [modifiedOnly, setModifiedOnly] = useState<boolean>(false);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [search, setSearch] = useState('');
|
||||
const [selectedDeviceEntity, setSelectedDeviceEntity] = useState<DeviceEntity>();
|
||||
const [dialogOpen, setDialogOpen] = useState<boolean>(false);
|
||||
@@ -173,7 +176,7 @@ const Customizations = () => {
|
||||
|
||||
const entities_theme = useTheme({
|
||||
Table: `
|
||||
--data-table-library_grid-template-columns: 156px repeat(1, minmax(80px, 1fr)) 45px minmax(45px, auto) minmax(120px, auto);
|
||||
--data-table-library_grid-template-columns: 32px 156px repeat(1, minmax(80px, 1fr)) 45px minmax(45px, auto) minmax(120px, auto);
|
||||
`,
|
||||
BaseRow: `
|
||||
font-size: 14px;
|
||||
@@ -182,10 +185,10 @@ const Customizations = () => {
|
||||
}
|
||||
`,
|
||||
BaseCell: `
|
||||
&:nth-of-type(3) {
|
||||
&:nth-of-type(4) {
|
||||
text-align: right;
|
||||
}
|
||||
&:nth-of-type(4) {
|
||||
&:nth-of-type(5) {
|
||||
text-align: right;
|
||||
}
|
||||
&:last-of-type {
|
||||
@@ -221,13 +224,13 @@ const Customizations = () => {
|
||||
}
|
||||
`,
|
||||
Cell: `
|
||||
&:nth-of-type(2) {
|
||||
&:nth-of-type(3) {
|
||||
padding: 8px;
|
||||
}
|
||||
&:nth-of-type(3) {
|
||||
&:nth-of-type(4) {
|
||||
padding-right: 4px;
|
||||
}
|
||||
&:nth-of-type(4) {
|
||||
&:nth-of-type(5) {
|
||||
padding-right: 4px;
|
||||
}
|
||||
&:last-of-type {
|
||||
@@ -245,6 +248,11 @@ const Customizations = () => {
|
||||
);
|
||||
}
|
||||
|
||||
// Saved customization (c), or an edit that is not applied yet.
|
||||
function isModified(de: DeviceEntity) {
|
||||
return !!de.c || hasEntityChanged(de);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (deviceEntities.length) {
|
||||
const changedEntities = deviceEntities.filter((de) => hasEntityChanged(de));
|
||||
@@ -267,6 +275,7 @@ const Customizations = () => {
|
||||
setSelectedDeviceBrand(device.b);
|
||||
}
|
||||
setNumChanges(0);
|
||||
setSelectedIds(new Set());
|
||||
setRestartNeeded(false);
|
||||
}
|
||||
}
|
||||
@@ -283,6 +292,13 @@ const Customizations = () => {
|
||||
return value as string;
|
||||
}
|
||||
|
||||
function formatLimit(value: number | null | undefined) {
|
||||
if (value === null) {
|
||||
return '';
|
||||
}
|
||||
return formatValue(value);
|
||||
}
|
||||
|
||||
const isCommand = (de: DeviceEntity) => {
|
||||
return de.n && de.n[0] === '!';
|
||||
};
|
||||
@@ -331,24 +347,64 @@ const Customizations = () => {
|
||||
|
||||
const filter_entity = (de: DeviceEntity) =>
|
||||
(de.m & selectedFilters || !selectedFilters) &&
|
||||
(!modifiedOnly || isModified(de)) &&
|
||||
formatName(de, true).toLowerCase().includes(search.toLowerCase());
|
||||
|
||||
// Checked rows when any are checked, otherwise every row the filter is showing.
|
||||
const inBulkTarget = (de: DeviceEntity) =>
|
||||
selectedIds.size > 0 ? selectedIds.has(de.id) : filter_entity(de);
|
||||
|
||||
const maskDisabled = (set: boolean) => {
|
||||
const excludeMask =
|
||||
DeviceEntityMask.DV_API_MQTT_EXCLUDE | DeviceEntityMask.DV_WEB_EXCLUDE;
|
||||
setDeviceEntities((prev) =>
|
||||
prev.map((de) => {
|
||||
if (filter_entity(de)) {
|
||||
const excludeMask =
|
||||
DeviceEntityMask.DV_API_MQTT_EXCLUDE | DeviceEntityMask.DV_WEB_EXCLUDE;
|
||||
return {
|
||||
...de,
|
||||
m: set ? de.m | excludeMask : de.m & ~excludeMask
|
||||
};
|
||||
if (!inBulkTarget(de)) {
|
||||
return de;
|
||||
}
|
||||
return de;
|
||||
return {
|
||||
...de,
|
||||
m: set ? de.m | excludeMask : de.m & ~excludeMask
|
||||
};
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const maskReadonly = (set: boolean) => {
|
||||
setDeviceEntities((prev) =>
|
||||
prev.map((de) => {
|
||||
if (!inBulkTarget(de) || !de.w) {
|
||||
return de;
|
||||
}
|
||||
return {
|
||||
...de,
|
||||
m: set
|
||||
? de.m | DeviceEntityMask.DV_READONLY
|
||||
: de.m & ~DeviceEntityMask.DV_READONLY
|
||||
};
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const resetSelected = () => {
|
||||
setDeviceEntities((prev) =>
|
||||
prev.map((de) => {
|
||||
if (!selectedIds.has(de.id)) {
|
||||
return de;
|
||||
}
|
||||
const next: DeviceEntity = { ...de, m: 0, cn: '', c: false };
|
||||
if (de.mi !== undefined) {
|
||||
next.mi = null;
|
||||
}
|
||||
if (de.ma !== undefined) {
|
||||
next.ma = null;
|
||||
}
|
||||
return next;
|
||||
})
|
||||
);
|
||||
setSelectedIds(new Set());
|
||||
};
|
||||
|
||||
const resetCustomization = async () => {
|
||||
try {
|
||||
await sendResetCustomizations();
|
||||
@@ -414,6 +470,8 @@ const Customizations = () => {
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(LL.CUSTOMIZATIONS_SAVED());
|
||||
// Reload so cleared min/max fall back to the built-in limits and the modified flag matches the file.
|
||||
void sendDeviceEntities(selectedDevice);
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
if (error.message === 'Reboot required') {
|
||||
@@ -541,6 +599,34 @@ const Customizations = () => {
|
||||
);
|
||||
|
||||
const filteredEntities = deviceEntities.filter((de) => filter_entity(de));
|
||||
const allFilteredSelected =
|
||||
filteredEntities.length > 0 &&
|
||||
filteredEntities.every((de) => selectedIds.has(de.id));
|
||||
const someFilteredSelected = filteredEntities.some((de) => selectedIds.has(de.id));
|
||||
|
||||
const toggleSelected = (id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleSelectFiltered = () => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (allFilteredSelected) {
|
||||
filteredEntities.forEach((de) => next.delete(de.id));
|
||||
} else {
|
||||
filteredEntities.forEach((de) => next.add(de.id));
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const renderDeviceData = () => {
|
||||
return (
|
||||
@@ -608,6 +694,17 @@ const Customizations = () => {
|
||||
</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</Grid>
|
||||
<Grid>
|
||||
<Button
|
||||
size="small"
|
||||
sx={{ fontSize: 10 }}
|
||||
variant="outlined"
|
||||
color={modifiedOnly ? 'secondary' : 'inherit'}
|
||||
onClick={() => setModifiedOnly((current) => !current)}
|
||||
>
|
||||
{LL.MODIFIED()}
|
||||
</Button>
|
||||
</Grid>
|
||||
<Grid>
|
||||
<Button
|
||||
size="small"
|
||||
@@ -634,6 +731,43 @@ const Customizations = () => {
|
||||
<OptionIcon type="web_exclude" isSet={true} />
|
||||
</Button>
|
||||
</Grid>
|
||||
<Grid>
|
||||
<Button
|
||||
size="small"
|
||||
sx={{ fontSize: 10 }}
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
onClick={() => maskReadonly(false)}
|
||||
>
|
||||
{LL.SET_ALL()}
|
||||
<OptionIcon type="readonly" isSet={false} />
|
||||
</Button>
|
||||
</Grid>
|
||||
<Grid>
|
||||
<Button
|
||||
size="small"
|
||||
sx={{ fontSize: 10 }}
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
onClick={() => maskReadonly(true)}
|
||||
>
|
||||
{LL.SET_ALL()}
|
||||
<OptionIcon type="readonly" isSet={true} />
|
||||
</Button>
|
||||
</Grid>
|
||||
{selectedIds.size > 0 && (
|
||||
<Grid>
|
||||
<Button
|
||||
size="small"
|
||||
sx={{ fontSize: 10 }}
|
||||
variant="outlined"
|
||||
color="error"
|
||||
onClick={resetSelected}
|
||||
>
|
||||
{LL.RESET_SELECTED()} ({selectedIds.size})
|
||||
</Button>
|
||||
</Grid>
|
||||
)}
|
||||
<Grid>
|
||||
<Typography variant="subtitle2" color="grey">
|
||||
{LL.SHOWING()} {filteredEntities.length}/{deviceEntities.length}
|
||||
@@ -650,6 +784,16 @@ const Customizations = () => {
|
||||
<>
|
||||
<Header>
|
||||
<HeaderRow>
|
||||
<HeaderCell stiff>
|
||||
<Checkbox
|
||||
size="small"
|
||||
sx={{ p: 0, color: 'inherit' }}
|
||||
checked={allFilteredSelected}
|
||||
indeterminate={!allFilteredSelected && someFilteredSelected}
|
||||
disabled={filteredEntities.length === 0}
|
||||
onChange={toggleSelectFiltered}
|
||||
/>
|
||||
</HeaderCell>
|
||||
<HeaderCell stiff>{LL.OPTIONS()}</HeaderCell>
|
||||
<HeaderCell resize>{LL.NAME(1)}</HeaderCell>
|
||||
<HeaderCell stiff>{LL.MIN()}</HeaderCell>
|
||||
@@ -660,6 +804,14 @@ const Customizations = () => {
|
||||
<Body>
|
||||
{tableList.map((de: DeviceEntity) => (
|
||||
<Row key={de.id} item={de} onClick={() => editDeviceEntity(de)}>
|
||||
<Cell stiff onClick={(event) => event.stopPropagation()}>
|
||||
<Checkbox
|
||||
size="small"
|
||||
sx={{ p: 0 }}
|
||||
checked={selectedIds.has(de.id)}
|
||||
onChange={() => toggleSelected(de.id)}
|
||||
/>
|
||||
</Cell>
|
||||
<Cell stiff>
|
||||
<EntityMaskToggle onUpdate={updateDeviceEntity} de={de} />
|
||||
</Cell>
|
||||
@@ -687,10 +839,10 @@ const Customizations = () => {
|
||||
</span>
|
||||
</Cell>
|
||||
<Cell>
|
||||
{!(de.m & DeviceEntityMask.DV_READONLY) && formatValue(de.mi)}
|
||||
{!(de.m & DeviceEntityMask.DV_READONLY) && formatLimit(de.mi)}
|
||||
</Cell>
|
||||
<Cell>
|
||||
{!(de.m & DeviceEntityMask.DV_READONLY) && formatValue(de.ma)}
|
||||
{!(de.m & DeviceEntityMask.DV_READONLY) && formatLimit(de.ma)}
|
||||
</Cell>
|
||||
<Cell>{formatValue(de.v)}</Cell>
|
||||
</Row>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { memo, useEffect, useState } from 'react';
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import DoneIcon from '@mui/icons-material/Done';
|
||||
import SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
@@ -68,6 +69,15 @@ const CustomizationsDialog = ({
|
||||
editItem.w &&
|
||||
!(editItem.m & DeviceEntityMask.DV_READONLY);
|
||||
|
||||
// c is set by the firmware for a saved customization. Mask, name, and edited
|
||||
// limits also count, including changes made in this dialog before they are saved.
|
||||
const showReset =
|
||||
!!editItem.c ||
|
||||
editItem.m !== 0 ||
|
||||
(editItem.cn ?? '') !== '' ||
|
||||
editItem.mi !== editItem.o_mi ||
|
||||
editItem.ma !== editItem.o_ma;
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setError(false);
|
||||
@@ -101,6 +111,17 @@ const CustomizationsDialog = ({
|
||||
setEditItem((prev) => ({ ...prev, m: updatedItem.m }));
|
||||
};
|
||||
|
||||
const remove = () => {
|
||||
const next: DeviceEntity = { ...editItem, m: 0, cn: '', c: false };
|
||||
if (editItem.mi !== undefined) {
|
||||
next.mi = null;
|
||||
}
|
||||
if (editItem.ma !== undefined) {
|
||||
next.ma = null;
|
||||
}
|
||||
onSave(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
|
||||
<DialogTitle>{`${LL.EDIT()} ${LL.ENTITY()}`}</DialogTitle>
|
||||
@@ -141,7 +162,9 @@ const CustomizationsDialog = ({
|
||||
<TextField
|
||||
name="mi"
|
||||
label={LL.MIN()}
|
||||
value={numberValue(editItem.mi)}
|
||||
value={
|
||||
typeof editItem.mi === 'number' ? numberValue(editItem.mi) : ''
|
||||
}
|
||||
sx={{ width: '11ch' }}
|
||||
type="number"
|
||||
onChange={updateFormValue}
|
||||
@@ -151,7 +174,9 @@ const CustomizationsDialog = ({
|
||||
<TextField
|
||||
name="ma"
|
||||
label={LL.MAX()}
|
||||
value={numberValue(editItem.ma)}
|
||||
value={
|
||||
typeof editItem.ma === 'number' ? numberValue(editItem.ma) : ''
|
||||
}
|
||||
sx={{ width: '11ch' }}
|
||||
type="number"
|
||||
onChange={updateFormValue}
|
||||
@@ -169,6 +194,17 @@ const CustomizationsDialog = ({
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
{showReset && (
|
||||
<Button
|
||||
startIcon={<SettingsBackupRestoreIcon />}
|
||||
variant="outlined"
|
||||
onClick={remove}
|
||||
color="error"
|
||||
>
|
||||
{LL.RESET()}
|
||||
</Button>
|
||||
)}
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
<Button
|
||||
startIcon={<CancelIcon />}
|
||||
variant="outlined"
|
||||
|
||||
@@ -174,7 +174,7 @@ const Devices = memo(() => {
|
||||
}
|
||||
`,
|
||||
Table: `
|
||||
--data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) 130px;
|
||||
--data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) 170px;
|
||||
`,
|
||||
HeaderRow: `
|
||||
.th {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { memo, useContext } from 'react';
|
||||
|
||||
import LogoutIcon from '@mui/icons-material/Logout';
|
||||
import PersonIcon from '@mui/icons-material/Person';
|
||||
import {
|
||||
Avatar,
|
||||
@@ -53,6 +54,7 @@ const UserProfileComponent = () => {
|
||||
</Box>
|
||||
<Divider />
|
||||
<Button
|
||||
startIcon={<LogoutIcon />}
|
||||
sx={{ mt: 2 }}
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
|
||||
@@ -172,8 +172,9 @@ export interface DeviceEntity {
|
||||
t?: string; // tag for name
|
||||
m: DeviceEntityMask; // mask
|
||||
w: boolean; // writeable
|
||||
mi?: number; // min value
|
||||
ma?: number; // max value
|
||||
mi?: number | null; // min value; null means cleared and the original returns after restart
|
||||
ma?: number | null; // max value; null means cleared and the original returns after restart
|
||||
c?: boolean; // true when a customization is saved (mask, name, or min/max)
|
||||
o_m?: number; // original mask before edits
|
||||
o_cn?: string; // original cn before edits
|
||||
o_mi?: number; // original min value
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useContext, useEffect, useState } from 'react';
|
||||
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew';
|
||||
import SendIcon from '@mui/icons-material/Send';
|
||||
import WarningIcon from '@mui/icons-material/Warning';
|
||||
import {
|
||||
Box,
|
||||
@@ -500,6 +501,7 @@ const ApplicationSettings = () => {
|
||||
</Grid>
|
||||
<Grid>
|
||||
<Button
|
||||
startIcon={<SendIcon />}
|
||||
sx={{ mt: 3 }}
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
@@ -846,6 +848,7 @@ const ApplicationSettings = () => {
|
||||
sx={{ width: '15ch' }}
|
||||
select
|
||||
>
|
||||
{data.developer_mode && <MenuItem value={0}>{LL.OFF()}</MenuItem>}
|
||||
<MenuItem value={1}>EMS</MenuItem>
|
||||
<MenuItem value={2}>EMS+</MenuItem>
|
||||
<MenuItem value={3}>HT3</MenuItem>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
@@ -42,6 +42,7 @@ import type { ValidateFieldsError } from 'validators/schema';
|
||||
|
||||
import {
|
||||
TIME_ZONES,
|
||||
migrateNtpTimeZones,
|
||||
selectedTimeZone,
|
||||
timeZoneSelectItemsT,
|
||||
useTimeZoneSelectItems
|
||||
@@ -56,6 +57,7 @@ const NTPSettings = () => {
|
||||
origData,
|
||||
dirtyFlags,
|
||||
setDirtyFlags,
|
||||
setOrigData,
|
||||
blocker,
|
||||
saveData,
|
||||
errorMessage
|
||||
@@ -81,6 +83,31 @@ const NTPSettings = () => {
|
||||
const [settingTime, setSettingTime] = useState<boolean>(false);
|
||||
const [processing, setProcessing] = useState<boolean>(false);
|
||||
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
||||
const migratingTz = useRef(false);
|
||||
|
||||
// If firmware has not yet rewritten tz_format from the label (or the user
|
||||
// landed here before reboot), persist the current TIME_ZONES values quietly.
|
||||
useEffect(() => {
|
||||
if (!data || migratingTz.current || dirtyFlags.length > 0) {
|
||||
return;
|
||||
}
|
||||
const migrated = migrateNtpTimeZones(data);
|
||||
if (!migrated) {
|
||||
return;
|
||||
}
|
||||
migratingTz.current = true;
|
||||
void (async () => {
|
||||
try {
|
||||
updateDataValue(migrated);
|
||||
await NTPApi.updateNTPSettings(migrated);
|
||||
setOrigData(migrated);
|
||||
} catch {
|
||||
// Boot-time firmware migration still applies on the next restart.
|
||||
} finally {
|
||||
migratingTz.current = false;
|
||||
}
|
||||
})();
|
||||
}, [data, dirtyFlags.length, setOrigData, updateDataValue]);
|
||||
|
||||
const { send: updateTime } = useRequest(
|
||||
(local_time: Time) => NTPApi.updateTime(local_time),
|
||||
|
||||
+247
-216
@@ -13,20 +13,20 @@ export const TIME_ZONES: Record<string, string> = {
|
||||
'Africa/Blantyre': 'CAT-2',
|
||||
'Africa/Brazzaville': 'WAT-1',
|
||||
'Africa/Bujumbura': 'CAT-2',
|
||||
'Africa/Cairo': 'EET-2',
|
||||
'Africa/Casablanca': 'UNK-1',
|
||||
'Africa/Cairo': 'EET-2EEST,M4.5.5/0,M10.5.4/24',
|
||||
'Africa/Casablanca': '<+00>0',
|
||||
'Africa/Ceuta': 'CET-1CEST,M3.5.0,M10.5.0/3',
|
||||
'Africa/Conakry': 'GMT0',
|
||||
'Africa/Dakar': 'GMT0',
|
||||
'Africa/Dar_es_Salaam': 'EAT-3',
|
||||
'Africa/Djibouti': 'EAT-3',
|
||||
'Africa/Douala': 'WAT-1',
|
||||
'Africa/El_Aaiun': 'UNK-1',
|
||||
'Africa/El_Aaiun': '<+00>0',
|
||||
'Africa/Freetown': 'GMT0',
|
||||
'Africa/Gaborone': 'CAT-2',
|
||||
'Africa/Harare': 'CAT-2',
|
||||
'Africa/Johannesburg': 'SAST-2',
|
||||
'Africa/Juba': 'EAT-3',
|
||||
'Africa/Juba': 'CAT-2',
|
||||
'Africa/Kampala': 'EAT-3',
|
||||
'Africa/Khartoum': 'CAT-2',
|
||||
'Africa/Kigali': 'CAT-2',
|
||||
@@ -57,42 +57,42 @@ export const TIME_ZONES: Record<string, string> = {
|
||||
'America/Anchorage': 'AKST9AKDT,M3.2.0,M11.1.0',
|
||||
'America/Anguilla': 'AST4',
|
||||
'America/Antigua': 'AST4',
|
||||
'America/Araguaina': 'UNK3',
|
||||
'America/Argentina/Buenos_Aires': 'UNK3',
|
||||
'America/Argentina/Catamarca': 'UNK3',
|
||||
'America/Argentina/Cordoba': 'UNK3',
|
||||
'America/Argentina/Jujuy': 'UNK3',
|
||||
'America/Argentina/La_Rioja': 'UNK3',
|
||||
'America/Argentina/Mendoza': 'UNK3',
|
||||
'America/Argentina/Rio_Gallegos': 'UNK3',
|
||||
'America/Argentina/Salta': 'UNK3',
|
||||
'America/Argentina/San_Juan': 'UNK3',
|
||||
'America/Argentina/San_Luis': 'UNK3',
|
||||
'America/Argentina/Tucuman': 'UNK3',
|
||||
'America/Argentina/Ushuaia': 'UNK3',
|
||||
'America/Araguaina': '<-03>3',
|
||||
'America/Argentina/Buenos_Aires': '<-03>3',
|
||||
'America/Argentina/Catamarca': '<-03>3',
|
||||
'America/Argentina/Cordoba': '<-03>3',
|
||||
'America/Argentina/Jujuy': '<-03>3',
|
||||
'America/Argentina/La_Rioja': '<-03>3',
|
||||
'America/Argentina/Mendoza': '<-03>3',
|
||||
'America/Argentina/Rio_Gallegos': '<-03>3',
|
||||
'America/Argentina/Salta': '<-03>3',
|
||||
'America/Argentina/San_Juan': '<-03>3',
|
||||
'America/Argentina/San_Luis': '<-03>3',
|
||||
'America/Argentina/Tucuman': '<-03>3',
|
||||
'America/Argentina/Ushuaia': '<-03>3',
|
||||
'America/Aruba': 'AST4',
|
||||
'America/Asuncion': 'UNK4UNK,M10.1.0/0,M3.4.0/0',
|
||||
'America/Asuncion': '<-03>3',
|
||||
'America/Atikokan': 'EST5',
|
||||
'America/Bahia': 'UNK3',
|
||||
'America/Bahia_Banderas': 'CST6CDT,M4.1.0,M10.5.0',
|
||||
'America/Bahia': '<-03>3',
|
||||
'America/Bahia_Banderas': 'CST6',
|
||||
'America/Barbados': 'AST4',
|
||||
'America/Belem': 'UNK3',
|
||||
'America/Belem': '<-03>3',
|
||||
'America/Belize': 'CST6',
|
||||
'America/Blanc-Sablon': 'AST4',
|
||||
'America/Boa_Vista': 'UNK4',
|
||||
'America/Bogota': 'UNK5',
|
||||
'America/Boa_Vista': '<-04>4',
|
||||
'America/Bogota': '<-05>5',
|
||||
'America/Boise': 'MST7MDT,M3.2.0,M11.1.0',
|
||||
'America/Cambridge_Bay': 'MST7MDT,M3.2.0,M11.1.0',
|
||||
'America/Campo_Grande': 'UNK4',
|
||||
'America/Campo_Grande': '<-04>4',
|
||||
'America/Cancun': 'EST5',
|
||||
'America/Caracas': 'UNK4',
|
||||
'America/Cayenne': 'UNK3',
|
||||
'America/Caracas': '<-04>4',
|
||||
'America/Cayenne': '<-03>3',
|
||||
'America/Cayman': 'EST5',
|
||||
'America/Chicago': 'CST6CDT,M3.2.0,M11.1.0',
|
||||
'America/Chihuahua': 'MST7MDT,M4.1.0,M10.5.0',
|
||||
'America/Chihuahua': 'CST6',
|
||||
'America/Costa_Rica': 'CST6',
|
||||
'America/Creston': 'MST7',
|
||||
'America/Cuiaba': 'UNK4',
|
||||
'America/Cuiaba': '<-04>4',
|
||||
'America/Curacao': 'AST4',
|
||||
'America/Danmarkshavn': 'GMT0',
|
||||
'America/Dawson': 'MST7',
|
||||
@@ -100,20 +100,20 @@ export const TIME_ZONES: Record<string, string> = {
|
||||
'America/Denver': 'MST7MDT,M3.2.0,M11.1.0',
|
||||
'America/Detroit': 'EST5EDT,M3.2.0,M11.1.0',
|
||||
'America/Dominica': 'AST4',
|
||||
'America/Edmonton': 'MST7MDT,M3.2.0,M11.1.0',
|
||||
'America/Eirunepe': 'UNK5',
|
||||
'America/Edmonton': 'CST6',
|
||||
'America/Eirunepe': '<-05>5',
|
||||
'America/El_Salvador': 'CST6',
|
||||
'America/Fort_Nelson': 'MST7',
|
||||
'America/Fortaleza': 'UNK3',
|
||||
'America/Fortaleza': '<-03>3',
|
||||
'America/Glace_Bay': 'AST4ADT,M3.2.0,M11.1.0',
|
||||
'America/Godthab': 'UNK3UNK,M3.5.0/-2,M10.5.0/-1',
|
||||
'America/Godthab': '<-02>2<-01>,M3.5.0/-1,M10.5.0/0',
|
||||
'America/Goose_Bay': 'AST4ADT,M3.2.0,M11.1.0',
|
||||
'America/Grand_Turk': 'EST5EDT,M3.2.0,M11.1.0',
|
||||
'America/Grenada': 'AST4',
|
||||
'America/Guadeloupe': 'AST4',
|
||||
'America/Guatemala': 'CST6',
|
||||
'America/Guayaquil': 'UNK5',
|
||||
'America/Guyana': 'UNK4',
|
||||
'America/Guayaquil': '<-05>5',
|
||||
'America/Guyana': '<-04>4',
|
||||
'America/Halifax': 'AST4ADT,M3.2.0,M11.1.0',
|
||||
'America/Havana': 'CST5CDT,M3.2.0/0,M11.1.0/1',
|
||||
'America/Hermosillo': 'MST7',
|
||||
@@ -125,63 +125,63 @@ export const TIME_ZONES: Record<string, string> = {
|
||||
'America/Indiana/Vevay': 'EST5EDT,M3.2.0,M11.1.0',
|
||||
'America/Indiana/Vincennes': 'EST5EDT,M3.2.0,M11.1.0',
|
||||
'America/Indiana/Winamac': 'EST5EDT,M3.2.0,M11.1.0',
|
||||
'America/Inuvik': 'MST7MDT,M3.2.0,M11.1.0',
|
||||
'America/Inuvik': 'CST6',
|
||||
'America/Iqaluit': 'EST5EDT,M3.2.0,M11.1.0',
|
||||
'America/Jamaica': 'EST5',
|
||||
'America/Juneau': 'AKST9AKDT,M3.2.0,M11.1.0',
|
||||
'America/Kentucky/Louisville': 'EST5EDT,M3.2.0,M11.1.0',
|
||||
'America/Kentucky/Monticello': 'EST5EDT,M3.2.0,M11.1.0',
|
||||
'America/Kralendijk': 'AST4',
|
||||
'America/La_Paz': 'UNK4',
|
||||
'America/Lima': 'UNK5',
|
||||
'America/La_Paz': '<-04>4',
|
||||
'America/Lima': '<-05>5',
|
||||
'America/Los_Angeles': 'PST8PDT,M3.2.0,M11.1.0',
|
||||
'America/Lower_Princes': 'AST4',
|
||||
'America/Maceio': 'UNK3',
|
||||
'America/Maceio': '<-03>3',
|
||||
'America/Managua': 'CST6',
|
||||
'America/Manaus': 'UNK4',
|
||||
'America/Manaus': '<-04>4',
|
||||
'America/Marigot': 'AST4',
|
||||
'America/Martinique': 'AST4',
|
||||
'America/Matamoros': 'CST6CDT,M3.2.0,M11.1.0',
|
||||
'America/Mazatlan': 'MST7MDT,M4.1.0,M10.5.0',
|
||||
'America/Mazatlan': 'MST7',
|
||||
'America/Menominee': 'CST6CDT,M3.2.0,M11.1.0',
|
||||
'America/Merida': 'CST6CDT,M4.1.0,M10.5.0',
|
||||
'America/Merida': 'CST6',
|
||||
'America/Metlakatla': 'AKST9AKDT,M3.2.0,M11.1.0',
|
||||
'America/Mexico_City': 'CST6CDT,M4.1.0,M10.5.0',
|
||||
'America/Miquelon': 'UNK3UNK,M3.2.0,M11.1.0',
|
||||
'America/Mexico_City': 'CST6',
|
||||
'America/Miquelon': '<-03>3<-02>,M3.2.0,M11.1.0',
|
||||
'America/Moncton': 'AST4ADT,M3.2.0,M11.1.0',
|
||||
'America/Monterrey': 'CST6CDT,M4.1.0,M10.5.0',
|
||||
'America/Montevideo': 'UNK3',
|
||||
'America/Monterrey': 'CST6',
|
||||
'America/Montevideo': '<-03>3',
|
||||
'America/Montreal': 'EST5EDT,M3.2.0,M11.1.0',
|
||||
'America/Montserrat': 'AST4',
|
||||
'America/Nassau': 'EST5EDT,M3.2.0,M11.1.0',
|
||||
'America/New_York': 'EST5EDT,M3.2.0,M11.1.0',
|
||||
'America/Nipigon': 'EST5EDT,M3.2.0,M11.1.0',
|
||||
'America/Nome': 'AKST9AKDT,M3.2.0,M11.1.0',
|
||||
'America/Noronha': 'UNK2',
|
||||
'America/Noronha': '<-02>2',
|
||||
'America/North_Dakota/Beulah': 'CST6CDT,M3.2.0,M11.1.0',
|
||||
'America/North_Dakota/Center': 'CST6CDT,M3.2.0,M11.1.0',
|
||||
'America/North_Dakota/New_Salem': 'CST6CDT,M3.2.0,M11.1.0',
|
||||
'America/Ojinaga': 'MST7MDT,M3.2.0,M11.1.0',
|
||||
'America/Ojinaga': 'CST6CDT,M3.2.0,M11.1.0',
|
||||
'America/Panama': 'EST5',
|
||||
'America/Pangnirtung': 'EST5EDT,M3.2.0,M11.1.0',
|
||||
'America/Paramaribo': 'UNK3',
|
||||
'America/Paramaribo': '<-03>3',
|
||||
'America/Phoenix': 'MST7',
|
||||
'America/Port-au-Prince': 'EST5EDT,M3.2.0,M11.1.0',
|
||||
'America/Port_of_Spain': 'AST4',
|
||||
'America/Porto_Velho': 'UNK4',
|
||||
'America/Porto_Velho': '<-04>4',
|
||||
'America/Puerto_Rico': 'AST4',
|
||||
'America/Punta_Arenas': 'UNK3',
|
||||
'America/Rainy_River': 'CST6CDT,M3.2.0,M11.1.0',
|
||||
'America/Punta_Arenas': '<-03>3',
|
||||
'America/Rainy_River': 'EST5',
|
||||
'America/Rankin_Inlet': 'CST6CDT,M3.2.0,M11.1.0',
|
||||
'America/Recife': 'UNK3',
|
||||
'America/Recife': '<-03>3',
|
||||
'America/Regina': 'CST6',
|
||||
'America/Resolute': 'CST6CDT,M3.2.0,M11.1.0',
|
||||
'America/Rio_Branco': 'UNK5',
|
||||
'America/Santarem': 'UNK3',
|
||||
'America/Santiago': 'UNK4UNK,M9.1.6/24,M4.1.6/24',
|
||||
'America/Rio_Branco': '<-05>5',
|
||||
'America/Santarem': '<-03>3',
|
||||
'America/Santiago': '<-04>4<-03>,M9.1.6/24,M4.1.6/24',
|
||||
'America/Santo_Domingo': 'AST4',
|
||||
'America/Sao_Paulo': 'UNK3',
|
||||
'America/Scoresbysund': 'UNK1UNK,M3.5.0/0,M10.5.0/1',
|
||||
'America/Sao_Paulo': '<-03>3',
|
||||
'America/Scoresbysund': '<-02>2<-01>,M3.5.0/-1,M10.5.0/0',
|
||||
'America/Sitka': 'AKST9AKDT,M3.2.0,M11.1.0',
|
||||
'America/St_Barthelemy': 'AST4',
|
||||
'America/St_Johns': 'NST3:30NDT,M3.2.0,M11.1.0',
|
||||
@@ -196,156 +196,156 @@ export const TIME_ZONES: Record<string, string> = {
|
||||
'America/Tijuana': 'PST8PDT,M3.2.0,M11.1.0',
|
||||
'America/Toronto': 'EST5EDT,M3.2.0,M11.1.0',
|
||||
'America/Tortola': 'AST4',
|
||||
'America/Vancouver': 'PST8PDT,M3.2.0,M11.1.0',
|
||||
'America/Vancouver': 'MST7',
|
||||
'America/Whitehorse': 'MST7',
|
||||
'America/Winnipeg': 'CST6CDT,M3.2.0,M11.1.0',
|
||||
'America/Winnipeg': 'EST5',
|
||||
'America/Yakutat': 'AKST9AKDT,M3.2.0,M11.1.0',
|
||||
'America/Yellowknife': 'MST7MDT,M3.2.0,M11.1.0',
|
||||
'Antarctica/Casey': 'UNK-8',
|
||||
'Antarctica/Davis': 'UNK-7',
|
||||
'Antarctica/DumontDUrville': 'UNK-10',
|
||||
'Antarctica/Macquarie': 'UNK-11',
|
||||
'Antarctica/Mawson': 'UNK-5',
|
||||
'America/Yellowknife': 'CST6',
|
||||
'Antarctica/Casey': '<+08>-8',
|
||||
'Antarctica/Davis': '<+07>-7',
|
||||
'Antarctica/DumontDUrville': '<+10>-10',
|
||||
'Antarctica/Macquarie': 'AEST-10AEDT,M10.1.0,M4.1.0/3',
|
||||
'Antarctica/Mawson': '<+05>-5',
|
||||
'Antarctica/McMurdo': 'NZST-12NZDT,M9.5.0,M4.1.0/3',
|
||||
'Antarctica/Palmer': 'UNK3',
|
||||
'Antarctica/Rothera': 'UNK3',
|
||||
'Antarctica/Syowa': 'UNK-3',
|
||||
'Antarctica/Troll': 'UNK0UNK-2,M3.5.0/1,M10.5.0/3',
|
||||
'Antarctica/Vostok': 'UNK-6',
|
||||
'Antarctica/Palmer': '<-03>3',
|
||||
'Antarctica/Rothera': '<-03>3',
|
||||
'Antarctica/Syowa': '<+03>-3',
|
||||
'Antarctica/Troll': '<+00>0<+02>-2,M3.5.0/1,M10.5.0/3',
|
||||
'Antarctica/Vostok': '<+05>-5',
|
||||
'Arctic/Longyearbyen': 'CET-1CEST,M3.5.0,M10.5.0/3',
|
||||
'Asia/Aden': 'UNK-3',
|
||||
'Asia/Almaty': 'UNK-6',
|
||||
'Asia/Amman': 'EET-2EEST,M3.5.4/24,M10.5.5/1',
|
||||
'Asia/Anadyr': 'UNK-12',
|
||||
'Asia/Aqtau': 'UNK-5',
|
||||
'Asia/Aqtobe': 'UNK-5',
|
||||
'Asia/Ashgabat': 'UNK-5',
|
||||
'Asia/Atyrau': 'UNK-5',
|
||||
'Asia/Baghdad': 'UNK-3',
|
||||
'Asia/Bahrain': 'UNK-3',
|
||||
'Asia/Baku': 'UNK-4',
|
||||
'Asia/Bangkok': 'UNK-7',
|
||||
'Asia/Barnaul': 'UNK-7',
|
||||
'Asia/Aden': '<+03>-3',
|
||||
'Asia/Almaty': '<+05>-5',
|
||||
'Asia/Amman': '<+03>-3',
|
||||
'Asia/Anadyr': '<+12>-12',
|
||||
'Asia/Aqtau': '<+05>-5',
|
||||
'Asia/Aqtobe': '<+05>-5',
|
||||
'Asia/Ashgabat': '<+05>-5',
|
||||
'Asia/Atyrau': '<+05>-5',
|
||||
'Asia/Baghdad': '<+03>-3',
|
||||
'Asia/Bahrain': '<+03>-3',
|
||||
'Asia/Baku': '<+04>-4',
|
||||
'Asia/Bangkok': '<+07>-7',
|
||||
'Asia/Barnaul': '<+07>-7',
|
||||
'Asia/Beirut': 'EET-2EEST,M3.5.0/0,M10.5.0/0',
|
||||
'Asia/Bishkek': 'UNK-6',
|
||||
'Asia/Brunei': 'UNK-8',
|
||||
'Asia/Chita': 'UNK-9',
|
||||
'Asia/Choibalsan': 'UNK-8',
|
||||
'Asia/Colombo': 'UNK-5:30',
|
||||
'Asia/Damascus': 'EET-2EEST,M3.5.5/0,M10.5.5/0',
|
||||
'Asia/Dhaka': 'UNK-6',
|
||||
'Asia/Dili': 'UNK-9',
|
||||
'Asia/Dubai': 'UNK-4',
|
||||
'Asia/Dushanbe': 'UNK-5',
|
||||
'Asia/Bishkek': '<+06>-6',
|
||||
'Asia/Brunei': '<+08>-8',
|
||||
'Asia/Chita': '<+09>-9',
|
||||
'Asia/Choibalsan': '<+08>-8',
|
||||
'Asia/Colombo': '<+0530>-5:30',
|
||||
'Asia/Damascus': '<+03>-3',
|
||||
'Asia/Dhaka': '<+06>-6',
|
||||
'Asia/Dili': '<+09>-9',
|
||||
'Asia/Dubai': '<+04>-4',
|
||||
'Asia/Dushanbe': '<+05>-5',
|
||||
'Asia/Famagusta': 'EET-2EEST,M3.5.0/3,M10.5.0/4',
|
||||
'Asia/Gaza': 'EET-2EEST,M3.5.5/0,M10.5.6/1',
|
||||
'Asia/Hebron': 'EET-2EEST,M3.5.5/0,M10.5.6/1',
|
||||
'Asia/Ho_Chi_Minh': 'UNK-7',
|
||||
'Asia/Gaza': 'EET-2EEST,M3.4.4/50,M10.4.4/50',
|
||||
'Asia/Hebron': 'EET-2EEST,M3.4.4/50,M10.4.4/50',
|
||||
'Asia/Ho_Chi_Minh': '<+07>-7',
|
||||
'Asia/Hong_Kong': 'HKT-8',
|
||||
'Asia/Hovd': 'UNK-7',
|
||||
'Asia/Irkutsk': 'UNK-8',
|
||||
'Asia/Hovd': '<+07>-7',
|
||||
'Asia/Irkutsk': '<+08>-8',
|
||||
'Asia/Jakarta': 'WIB-7',
|
||||
'Asia/Jayapura': 'WIT-9',
|
||||
'Asia/Jerusalem': 'IST-2IDT,M3.4.4/26,M10.5.0',
|
||||
'Asia/Kabul': 'UNK-4:30',
|
||||
'Asia/Kamchatka': 'UNK-12',
|
||||
'Asia/Kabul': '<+0430>-4:30',
|
||||
'Asia/Kamchatka': '<+12>-12',
|
||||
'Asia/Karachi': 'PKT-5',
|
||||
'Asia/Kathmandu': 'UNK-5:45',
|
||||
'Asia/Khandyga': 'UNK-9',
|
||||
'Asia/Kathmandu': '<+0545>-5:45',
|
||||
'Asia/Khandyga': '<+09>-9',
|
||||
'Asia/Kolkata': 'IST-5:30',
|
||||
'Asia/Krasnoyarsk': 'UNK-7',
|
||||
'Asia/Kuala_Lumpur': 'UNK-8',
|
||||
'Asia/Kuching': 'UNK-8',
|
||||
'Asia/Kuwait': 'UNK-3',
|
||||
'Asia/Krasnoyarsk': '<+07>-7',
|
||||
'Asia/Kuala_Lumpur': '<+08>-8',
|
||||
'Asia/Kuching': '<+08>-8',
|
||||
'Asia/Kuwait': '<+03>-3',
|
||||
'Asia/Macau': 'CST-8',
|
||||
'Asia/Magadan': 'UNK-11',
|
||||
'Asia/Magadan': '<+11>-11',
|
||||
'Asia/Makassar': 'WITA-8',
|
||||
'Asia/Manila': 'PST-8',
|
||||
'Asia/Muscat': 'UNK-4',
|
||||
'Asia/Muscat': '<+04>-4',
|
||||
'Asia/Nicosia': 'EET-2EEST,M3.5.0/3,M10.5.0/4',
|
||||
'Asia/Novokuznetsk': 'UNK-7',
|
||||
'Asia/Novosibirsk': 'UNK-7',
|
||||
'Asia/Omsk': 'UNK-6',
|
||||
'Asia/Oral': 'UNK-5',
|
||||
'Asia/Phnom_Penh': 'UNK-7',
|
||||
'Asia/Novokuznetsk': '<+07>-7',
|
||||
'Asia/Novosibirsk': '<+07>-7',
|
||||
'Asia/Omsk': '<+06>-6',
|
||||
'Asia/Oral': '<+05>-5',
|
||||
'Asia/Phnom_Penh': '<+07>-7',
|
||||
'Asia/Pontianak': 'WIB-7',
|
||||
'Asia/Pyongyang': 'KST-9',
|
||||
'Asia/Qatar': 'UNK-3',
|
||||
'Asia/Qyzylorda': 'UNK-5',
|
||||
'Asia/Riyadh': 'UNK-3',
|
||||
'Asia/Sakhalin': 'UNK-11',
|
||||
'Asia/Samarkand': 'UNK-5',
|
||||
'Asia/Qatar': '<+03>-3',
|
||||
'Asia/Qyzylorda': '<+05>-5',
|
||||
'Asia/Riyadh': '<+03>-3',
|
||||
'Asia/Sakhalin': '<+11>-11',
|
||||
'Asia/Samarkand': '<+05>-5',
|
||||
'Asia/Seoul': 'KST-9',
|
||||
'Asia/Shanghai': 'CST-8',
|
||||
'Asia/Singapore': 'UNK-8',
|
||||
'Asia/Srednekolymsk': 'UNK-11',
|
||||
'Asia/Singapore': '<+08>-8',
|
||||
'Asia/Srednekolymsk': '<+11>-11',
|
||||
'Asia/Taipei': 'CST-8',
|
||||
'Asia/Tashkent': 'UNK-5',
|
||||
'Asia/Tbilisi': 'UNK-4',
|
||||
'Asia/Tehran': 'UNK-3:30UNK,J79/24,J263/24',
|
||||
'Asia/Thimphu': 'UNK-6',
|
||||
'Asia/Tashkent': '<+05>-5',
|
||||
'Asia/Tbilisi': '<+04>-4',
|
||||
'Asia/Tehran': '<+0330>-3:30',
|
||||
'Asia/Thimphu': '<+06>-6',
|
||||
'Asia/Tokyo': 'JST-9',
|
||||
'Asia/Tomsk': 'UNK-7',
|
||||
'Asia/Ulaanbaatar': 'UNK-8',
|
||||
'Asia/Urumqi': 'UNK-6',
|
||||
'Asia/Ust-Nera': 'UNK-10',
|
||||
'Asia/Vientiane': 'UNK-7',
|
||||
'Asia/Vladivostok': 'UNK-10',
|
||||
'Asia/Yakutsk': 'UNK-9',
|
||||
'Asia/Yangon': 'UNK-6:30',
|
||||
'Asia/Yekaterinburg': 'UNK-5',
|
||||
'Asia/Yerevan': 'UNK-4',
|
||||
'Atlantic/Azores': 'UNK1UNK,M3.5.0/0,M10.5.0/1',
|
||||
'Asia/Tomsk': '<+07>-7',
|
||||
'Asia/Ulaanbaatar': '<+08>-8',
|
||||
'Asia/Urumqi': '<+06>-6',
|
||||
'Asia/Ust-Nera': '<+10>-10',
|
||||
'Asia/Vientiane': '<+07>-7',
|
||||
'Asia/Vladivostok': '<+10>-10',
|
||||
'Asia/Yakutsk': '<+09>-9',
|
||||
'Asia/Yangon': '<+0630>-6:30',
|
||||
'Asia/Yekaterinburg': '<+05>-5',
|
||||
'Asia/Yerevan': '<+04>-4',
|
||||
'Atlantic/Azores': '<-01>1<+00>,M3.5.0/0,M10.5.0/1',
|
||||
'Atlantic/Bermuda': 'AST4ADT,M3.2.0,M11.1.0',
|
||||
'Atlantic/Canary': 'WET0WEST,M3.5.0/1,M10.5.0',
|
||||
'Atlantic/Cape_Verde': 'UNK1',
|
||||
'Atlantic/Cape_Verde': '<-01>1',
|
||||
'Atlantic/Faroe': 'WET0WEST,M3.5.0/1,M10.5.0',
|
||||
'Atlantic/Madeira': 'WET0WEST,M3.5.0/1,M10.5.0',
|
||||
'Atlantic/Reykjavik': 'GMT0',
|
||||
'Atlantic/South_Georgia': 'UNK2',
|
||||
'Atlantic/South_Georgia': '<-02>2',
|
||||
'Atlantic/St_Helena': 'GMT0',
|
||||
'Atlantic/Stanley': 'UNK3',
|
||||
'Atlantic/Stanley': '<-03>3',
|
||||
'Australia/Adelaide': 'ACST-9:30ACDT,M10.1.0,M4.1.0/3',
|
||||
'Australia/Brisbane': 'AEST-10',
|
||||
'Australia/Broken_Hill': 'ACST-9:30ACDT,M10.1.0,M4.1.0/3',
|
||||
'Australia/Currie': 'AEST-10AEDT,M10.1.0,M4.1.0/3',
|
||||
'Australia/Darwin': 'ACST-9:30',
|
||||
'Australia/Eucla': 'UNK-8:45',
|
||||
'Australia/Eucla': '<+0845>-8:45',
|
||||
'Australia/Hobart': 'AEST-10AEDT,M10.1.0,M4.1.0/3',
|
||||
'Australia/Lindeman': 'AEST-10',
|
||||
'Australia/Lord_Howe': 'UNK-10:30UNK-11,M10.1.0,M4.1.0',
|
||||
'Australia/Lord_Howe': '<+1030>-10:30<+11>-11,M10.1.0,M4.1.0',
|
||||
'Australia/Melbourne': 'AEST-10AEDT,M10.1.0,M4.1.0/3',
|
||||
'Australia/Perth': 'AWST-8',
|
||||
'Australia/Sydney': 'AEST-10AEDT,M10.1.0,M4.1.0/3',
|
||||
'Etc/GMT': 'GMT0',
|
||||
'Etc/GMT+0': 'GMT0',
|
||||
'Etc/GMT+1': 'UNK1',
|
||||
'Etc/GMT+10': 'UNK10',
|
||||
'Etc/GMT+11': 'UNK11',
|
||||
'Etc/GMT+12': 'UNK12',
|
||||
'Etc/GMT+2': 'UNK2',
|
||||
'Etc/GMT+3': 'UNK3',
|
||||
'Etc/GMT+4': 'UNK4',
|
||||
'Etc/GMT+5': 'UNK5',
|
||||
'Etc/GMT+6': 'UNK6',
|
||||
'Etc/GMT+7': 'UNK7',
|
||||
'Etc/GMT+8': 'UNK8',
|
||||
'Etc/GMT+9': 'UNK9',
|
||||
'Etc/GMT+1': '<-01>1',
|
||||
'Etc/GMT+10': '<-10>10',
|
||||
'Etc/GMT+11': '<-11>11',
|
||||
'Etc/GMT+12': '<-12>12',
|
||||
'Etc/GMT+2': '<-02>2',
|
||||
'Etc/GMT+3': '<-03>3',
|
||||
'Etc/GMT+4': '<-04>4',
|
||||
'Etc/GMT+5': '<-05>5',
|
||||
'Etc/GMT+6': '<-06>6',
|
||||
'Etc/GMT+7': '<-07>7',
|
||||
'Etc/GMT+8': '<-08>8',
|
||||
'Etc/GMT+9': '<-09>9',
|
||||
'Etc/GMT-0': 'GMT0',
|
||||
'Etc/GMT-1': 'UNK-1',
|
||||
'Etc/GMT-10': 'UNK-10',
|
||||
'Etc/GMT-11': 'UNK-11',
|
||||
'Etc/GMT-12': 'UNK-12',
|
||||
'Etc/GMT-13': 'UNK-13',
|
||||
'Etc/GMT-14': 'UNK-14',
|
||||
'Etc/GMT-2': 'UNK-2',
|
||||
'Etc/GMT-3': 'UNK-3',
|
||||
'Etc/GMT-4': 'UNK-4',
|
||||
'Etc/GMT-5': 'UNK-5',
|
||||
'Etc/GMT-6': 'UNK-6',
|
||||
'Etc/GMT-7': 'UNK-7',
|
||||
'Etc/GMT-8': 'UNK-8',
|
||||
'Etc/GMT-9': 'UNK-9',
|
||||
'Etc/GMT-1': '<+01>-1',
|
||||
'Etc/GMT-10': '<+10>-10',
|
||||
'Etc/GMT-11': '<+11>-11',
|
||||
'Etc/GMT-12': '<+12>-12',
|
||||
'Etc/GMT-13': '<+13>-13',
|
||||
'Etc/GMT-14': '<+14>-14',
|
||||
'Etc/GMT-2': '<+02>-2',
|
||||
'Etc/GMT-3': '<+03>-3',
|
||||
'Etc/GMT-4': '<+04>-4',
|
||||
'Etc/GMT-5': '<+05>-5',
|
||||
'Etc/GMT-6': '<+06>-6',
|
||||
'Etc/GMT-7': '<+07>-7',
|
||||
'Etc/GMT-8': '<+08>-8',
|
||||
'Etc/GMT-9': '<+09>-9',
|
||||
'Etc/GMT0': 'GMT0',
|
||||
'Etc/Greenwich': 'GMT0',
|
||||
'Etc/UCT': 'UTC0',
|
||||
@@ -354,7 +354,7 @@ export const TIME_ZONES: Record<string, string> = {
|
||||
'Etc/Zulu': 'UTC0',
|
||||
'Europe/Amsterdam': 'CET-1CEST,M3.5.0,M10.5.0/3',
|
||||
'Europe/Andorra': 'CET-1CEST,M3.5.0,M10.5.0/3',
|
||||
'Europe/Astrakhan': 'UNK-4',
|
||||
'Europe/Astrakhan': '<+04>-4',
|
||||
'Europe/Athens': 'EET-2EEST,M3.5.0/3,M10.5.0/4',
|
||||
'Europe/Belgrade': 'CET-1CEST,M3.5.0,M10.5.0/3',
|
||||
'Europe/Berlin': 'CET-1CEST,M3.5.0,M10.5.0/3',
|
||||
@@ -363,18 +363,18 @@ export const TIME_ZONES: Record<string, string> = {
|
||||
'Europe/Bucharest': 'EET-2EEST,M3.5.0/3,M10.5.0/4',
|
||||
'Europe/Budapest': 'CET-1CEST,M3.5.0,M10.5.0/3',
|
||||
'Europe/Busingen': 'CET-1CEST,M3.5.0,M10.5.0/3',
|
||||
'Europe/Chisinau': 'EET-2EEST,M3.5.0,M10.5.0/3',
|
||||
'Europe/Chisinau': 'EET-2EEST,M3.5.0/3,M10.5.0/4',
|
||||
'Europe/Copenhagen': 'CET-1CEST,M3.5.0,M10.5.0/3',
|
||||
'Europe/Dublin': 'IST-1GMT0,M10.5.0,M3.5.0/1',
|
||||
'Europe/Gibraltar': 'CET-1CEST,M3.5.0,M10.5.0/3',
|
||||
'Europe/Guernsey': 'GMT0BST,M3.5.0/1,M10.5.0',
|
||||
'Europe/Helsinki': 'EET-2EEST,M3.5.0/3,M10.5.0/4',
|
||||
'Europe/Isle_of_Man': 'GMT0BST,M3.5.0/1,M10.5.0',
|
||||
'Europe/Istanbul': 'UNK-3',
|
||||
'Europe/Istanbul': '<+03>-3',
|
||||
'Europe/Jersey': 'GMT0BST,M3.5.0/1,M10.5.0',
|
||||
'Europe/Kaliningrad': 'EET-2',
|
||||
'Europe/Kiev': 'EET-2EEST,M3.5.0/3,M10.5.0/4',
|
||||
'Europe/Kirov': 'UNK-3',
|
||||
'Europe/Kirov': 'MSK-3',
|
||||
'Europe/Lisbon': 'WET0WEST,M3.5.0/1,M10.5.0',
|
||||
'Europe/Ljubljana': 'CET-1CEST,M3.5.0,M10.5.0/3',
|
||||
'Europe/London': 'GMT0BST,M3.5.0/1,M10.5.0',
|
||||
@@ -382,7 +382,7 @@ export const TIME_ZONES: Record<string, string> = {
|
||||
'Europe/Madrid': 'CET-1CEST,M3.5.0,M10.5.0/3',
|
||||
'Europe/Malta': 'CET-1CEST,M3.5.0,M10.5.0/3',
|
||||
'Europe/Mariehamn': 'EET-2EEST,M3.5.0/3,M10.5.0/4',
|
||||
'Europe/Minsk': 'UNK-3',
|
||||
'Europe/Minsk': '<+03>-3',
|
||||
'Europe/Monaco': 'CET-1CEST,M3.5.0,M10.5.0/3',
|
||||
'Europe/Moscow': 'MSK-3',
|
||||
'Europe/Oslo': 'CET-1CEST,M3.5.0,M10.5.0/3',
|
||||
@@ -391,83 +391,114 @@ export const TIME_ZONES: Record<string, string> = {
|
||||
'Europe/Prague': 'CET-1CEST,M3.5.0,M10.5.0/3',
|
||||
'Europe/Riga': 'EET-2EEST,M3.5.0/3,M10.5.0/4',
|
||||
'Europe/Rome': 'CET-1CEST,M3.5.0,M10.5.0/3',
|
||||
'Europe/Samara': 'UNK-4',
|
||||
'Europe/Samara': '<+04>-4',
|
||||
'Europe/San_Marino': 'CET-1CEST,M3.5.0,M10.5.0/3',
|
||||
'Europe/Sarajevo': 'CET-1CEST,M3.5.0,M10.5.0/3',
|
||||
'Europe/Saratov': 'UNK-4',
|
||||
'Europe/Saratov': '<+04>-4',
|
||||
'Europe/Simferopol': 'MSK-3',
|
||||
'Europe/Skopje': 'CET-1CEST,M3.5.0,M10.5.0/3',
|
||||
'Europe/Sofia': 'EET-2EEST,M3.5.0/3,M10.5.0/4',
|
||||
'Europe/Stockholm': 'CET-1CEST,M3.5.0,M10.5.0/3',
|
||||
'Europe/Tallinn': 'EET-2EEST,M3.5.0/3,M10.5.0/4',
|
||||
'Europe/Tirane': 'CET-1CEST,M3.5.0,M10.5.0/3',
|
||||
'Europe/Ulyanovsk': 'UNK-4',
|
||||
'Europe/Ulyanovsk': '<+04>-4',
|
||||
'Europe/Uzhgorod': 'EET-2EEST,M3.5.0/3,M10.5.0/4',
|
||||
'Europe/Vaduz': 'CET-1CEST,M3.5.0,M10.5.0/3',
|
||||
'Europe/Vatican': 'CET-1CEST,M3.5.0,M10.5.0/3',
|
||||
'Europe/Vienna': 'CET-1CEST,M3.5.0,M10.5.0/3',
|
||||
'Europe/Vilnius': 'EET-2EEST,M3.5.0/3,M10.5.0/4',
|
||||
'Europe/Volgograd': 'UNK-4',
|
||||
'Europe/Volgograd': 'MSK-3',
|
||||
'Europe/Warsaw': 'CET-1CEST,M3.5.0,M10.5.0/3',
|
||||
'Europe/Zagreb': 'CET-1CEST,M3.5.0,M10.5.0/3',
|
||||
'Europe/Zaporozhye': 'EET-2EEST,M3.5.0/3,M10.5.0/4',
|
||||
'Europe/Zurich': 'CET-1CEST,M3.5.0,M10.5.0/3',
|
||||
'Indian/Antananarivo': 'EAT-3',
|
||||
'Indian/Chagos': 'UNK-6',
|
||||
'Indian/Christmas': 'UNK-7',
|
||||
'Indian/Cocos': 'UNK-6:30',
|
||||
'Indian/Chagos': '<+06>-6',
|
||||
'Indian/Christmas': '<+07>-7',
|
||||
'Indian/Cocos': '<+0630>-6:30',
|
||||
'Indian/Comoro': 'EAT-3',
|
||||
'Indian/Kerguelen': 'UNK-5',
|
||||
'Indian/Mahe': 'UNK-4',
|
||||
'Indian/Maldives': 'UNK-5',
|
||||
'Indian/Mauritius': 'UNK-4',
|
||||
'Indian/Kerguelen': '<+05>-5',
|
||||
'Indian/Mahe': '<+04>-4',
|
||||
'Indian/Maldives': '<+05>-5',
|
||||
'Indian/Mauritius': '<+04>-4',
|
||||
'Indian/Mayotte': 'EAT-3',
|
||||
'Indian/Reunion': 'UNK-4',
|
||||
'Pacific/Apia': 'UNK-13UNK,M9.5.0/3,M4.1.0/4',
|
||||
'Indian/Reunion': '<+04>-4',
|
||||
'Pacific/Apia': '<+13>-13',
|
||||
'Pacific/Auckland': 'NZST-12NZDT,M9.5.0,M4.1.0/3',
|
||||
'Pacific/Bougainville': 'UNK-11',
|
||||
'Pacific/Chatham': 'UNK-12:45UNK,M9.5.0/2:45,M4.1.0/3:45',
|
||||
'Pacific/Chuuk': 'UNK-10',
|
||||
'Pacific/Easter': 'UNK6UNK,M9.1.6/22,M4.1.6/22',
|
||||
'Pacific/Efate': 'UNK-11',
|
||||
'Pacific/Enderbury': 'UNK-13',
|
||||
'Pacific/Fakaofo': 'UNK-13',
|
||||
'Pacific/Fiji': 'UNK-12UNK,M11.2.0,M1.2.3/99',
|
||||
'Pacific/Funafuti': 'UNK-12',
|
||||
'Pacific/Galapagos': 'UNK6',
|
||||
'Pacific/Gambier': 'UNK9',
|
||||
'Pacific/Guadalcanal': 'UNK-11',
|
||||
'Pacific/Bougainville': '<+11>-11',
|
||||
'Pacific/Chatham': '<+1245>-12:45<+1345>,M9.5.0/2:45,M4.1.0/3:45',
|
||||
'Pacific/Chuuk': '<+10>-10',
|
||||
'Pacific/Easter': '<-06>6<-05>,M9.1.6/22,M4.1.6/22',
|
||||
'Pacific/Efate': '<+11>-11',
|
||||
'Pacific/Enderbury': '<+13>-13',
|
||||
'Pacific/Fakaofo': '<+13>-13',
|
||||
'Pacific/Fiji': '<+12>-12',
|
||||
'Pacific/Funafuti': '<+12>-12',
|
||||
'Pacific/Galapagos': '<-06>6',
|
||||
'Pacific/Gambier': '<-09>9',
|
||||
'Pacific/Guadalcanal': '<+11>-11',
|
||||
'Pacific/Guam': 'ChST-10',
|
||||
'Pacific/Honolulu': 'HST10',
|
||||
'Pacific/Kiritimati': 'UNK-14',
|
||||
'Pacific/Kosrae': 'UNK-11',
|
||||
'Pacific/Kwajalein': 'UNK-12',
|
||||
'Pacific/Majuro': 'UNK-12',
|
||||
'Pacific/Marquesas': 'UNK9:30',
|
||||
'Pacific/Kiritimati': '<+14>-14',
|
||||
'Pacific/Kosrae': '<+11>-11',
|
||||
'Pacific/Kwajalein': '<+12>-12',
|
||||
'Pacific/Majuro': '<+12>-12',
|
||||
'Pacific/Marquesas': '<-0930>9:30',
|
||||
'Pacific/Midway': 'SST11',
|
||||
'Pacific/Nauru': 'UNK-12',
|
||||
'Pacific/Niue': 'UNK11',
|
||||
'Pacific/Norfolk': 'UNK-11UNK,M10.1.0,M4.1.0/3',
|
||||
'Pacific/Noumea': 'UNK-11',
|
||||
'Pacific/Nauru': '<+12>-12',
|
||||
'Pacific/Niue': '<-11>11',
|
||||
'Pacific/Norfolk': '<+11>-11<+12>,M10.1.0,M4.1.0/3',
|
||||
'Pacific/Noumea': '<+11>-11',
|
||||
'Pacific/Pago_Pago': 'SST11',
|
||||
'Pacific/Palau': 'UNK-9',
|
||||
'Pacific/Pitcairn': 'UNK8',
|
||||
'Pacific/Pohnpei': 'UNK-11',
|
||||
'Pacific/Port_Moresby': 'UNK-10',
|
||||
'Pacific/Rarotonga': 'UNK10',
|
||||
'Pacific/Palau': '<+09>-9',
|
||||
'Pacific/Pitcairn': '<-08>8',
|
||||
'Pacific/Pohnpei': '<+11>-11',
|
||||
'Pacific/Port_Moresby': '<+10>-10',
|
||||
'Pacific/Rarotonga': '<-10>10',
|
||||
'Pacific/Saipan': 'ChST-10',
|
||||
'Pacific/Tahiti': 'UNK10',
|
||||
'Pacific/Tarawa': 'UNK-12',
|
||||
'Pacific/Tongatapu': 'UNK-13',
|
||||
'Pacific/Wake': 'UNK-12',
|
||||
'Pacific/Wallis': 'UNK-12'
|
||||
'Pacific/Tahiti': '<-10>10',
|
||||
'Pacific/Tarawa': '<+12>-12',
|
||||
'Pacific/Tongatapu': '<+13>-13',
|
||||
'Pacific/Wake': '<+12>-12',
|
||||
'Pacific/Wallis': '<+12>-12'
|
||||
};
|
||||
|
||||
// Pre-compute sorted timezone labels for better performance
|
||||
export const TIME_ZONE_LABELS = Object.keys(TIME_ZONES).sort();
|
||||
|
||||
/** Resolve the select value for a saved zone. Prefer the label when it is in
|
||||
* TIME_ZONES so a stale stored tz_format (after a table update) still shows the
|
||||
* correct city; firmware migrates the format from the label on boot. */
|
||||
export function selectedTimeZone(label: string, format: string) {
|
||||
return TIME_ZONES[label] === format ? label : undefined;
|
||||
if (label in TIME_ZONES) {
|
||||
return label;
|
||||
}
|
||||
return Object.keys(TIME_ZONES).find((key) => TIME_ZONES[key] === format);
|
||||
}
|
||||
|
||||
/** Return settings with tz_format / tz_format_t refreshed from TIME_ZONES, or
|
||||
* null if already current (or labels are unknown). */
|
||||
export function migrateNtpTimeZones<
|
||||
T extends {
|
||||
tz_label: string;
|
||||
tz_format: string;
|
||||
tz_label_t: string;
|
||||
tz_format_t: string;
|
||||
}
|
||||
>(settings: T): T | null {
|
||||
const tz_format = TIME_ZONES[settings.tz_label];
|
||||
const tz_format_t = TIME_ZONES[settings.tz_label_t];
|
||||
const next = { ...settings };
|
||||
let changed = false;
|
||||
if (tz_format && tz_format !== settings.tz_format) {
|
||||
next.tz_format = tz_format;
|
||||
changed = true;
|
||||
}
|
||||
if (tz_format_t && tz_format_t !== settings.tz_format_t) {
|
||||
next.tz_format_t = tz_format_t;
|
||||
changed = true;
|
||||
}
|
||||
return changed ? next : null;
|
||||
}
|
||||
|
||||
const precomputedTimeZoneItems = TIME_ZONE_LABELS.map((label) => (
|
||||
|
||||
@@ -3,11 +3,14 @@ import { Link } from 'react-router';
|
||||
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import DescriptionIcon from '@mui/icons-material/Description';
|
||||
import CheckIcon from '@mui/icons-material/Done';
|
||||
import DownloadIcon from '@mui/icons-material/GetApp';
|
||||
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
|
||||
import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import RestartAltIcon from '@mui/icons-material/RestartAlt';
|
||||
import SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore';
|
||||
import SwapHorizIcon from '@mui/icons-material/SwapHoriz';
|
||||
import WarningIcon from '@mui/icons-material/Warning';
|
||||
import {
|
||||
Box,
|
||||
@@ -16,7 +19,6 @@ import {
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Grid,
|
||||
IconButton,
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -52,6 +54,8 @@ const STABLE_RELNOTES_URL =
|
||||
const DEV_URL = 'https://github.com/emsesp/EMS-ESP32/releases/download/latest/';
|
||||
const DEV_RELNOTES_URL =
|
||||
'https://github.com/emsesp/EMS-ESP32/blob/dev/CHANGELOG_LATEST.md';
|
||||
const RELEASES_API_URL =
|
||||
'https://api.github.com/repos/emsesp/EMS-ESP32/releases/tags/';
|
||||
const MAX_VERSION_POLLS = 5; // approx 15 seconds of waiting
|
||||
|
||||
// Types for better type safety
|
||||
@@ -71,10 +75,101 @@ interface VersionData {
|
||||
build_flags?: string;
|
||||
partition: string;
|
||||
partitions: PartitionData[];
|
||||
recovery_version?: string; // only on boards with a factory partition, empty if not installed
|
||||
developer_mode: boolean;
|
||||
disable_reset: boolean;
|
||||
}
|
||||
|
||||
interface RecoveryRelease {
|
||||
url: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
// finds the recovery firmware for this board in the GitHub releases, trying each release tag in turn.
|
||||
// The version is in the filename, e.g. EMS-ESP-Recovery-1_0_0-ESP32S3-16MB+.bin is 1.0.0
|
||||
const findRecovery = async (
|
||||
tags: string[],
|
||||
platform: string
|
||||
): Promise<RecoveryRelease | undefined> => {
|
||||
const prefix = 'ems-esp-recovery-';
|
||||
const suffix = `-${platform}.bin`.toLowerCase();
|
||||
for (const tag of tags) {
|
||||
try {
|
||||
const response = await fetch(`${RELEASES_API_URL}${tag}`);
|
||||
if (!response.ok) {
|
||||
continue;
|
||||
}
|
||||
const release = (await response.json()) as {
|
||||
assets?: { name: string; browser_download_url: string }[];
|
||||
};
|
||||
const asset = release.assets?.find((a) => {
|
||||
const name = a.name.toLowerCase();
|
||||
return name.startsWith(prefix) && name.endsWith(suffix);
|
||||
});
|
||||
if (asset) {
|
||||
return {
|
||||
url: asset.browser_download_url,
|
||||
version: asset.name
|
||||
.slice(prefix.length, asset.name.length - suffix.length)
|
||||
.replaceAll('_', '.')
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// try the next one
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// the GitHub API only allows 60 requests an hour, so only look once per page load for each board and channel
|
||||
const recoveryLookups = new Map<string, Promise<RecoveryRelease | undefined>>();
|
||||
const findRecoveryCached = (tags: string[], platform: string) => {
|
||||
const key = `${tags.join(',')}|${platform}`;
|
||||
let lookup = recoveryLookups.get(key);
|
||||
if (!lookup) {
|
||||
lookup = findRecovery(tags, platform);
|
||||
recoveryLookups.set(key, lookup);
|
||||
void lookup.then((release) => {
|
||||
if (!release) {
|
||||
recoveryLookups.delete(key); // try again next time
|
||||
}
|
||||
});
|
||||
}
|
||||
return lookup;
|
||||
};
|
||||
|
||||
// true if version a is newer than b, like 1.0.1 and 1.0.0. A prerelease, like 1.1.0-dev.1, is older than the release
|
||||
const isNewerVersion = (a: string, b: string) => {
|
||||
const parse = (v: string) => {
|
||||
const dash = v.indexOf('-');
|
||||
const numbers = (text: string) =>
|
||||
text.split('.').map((n) => Number.parseInt(n, 10) || 0);
|
||||
return {
|
||||
main: numbers(dash < 0 ? v : v.slice(0, dash)),
|
||||
pre: dash < 0 ? [] : numbers(v.slice(dash + 1)),
|
||||
isPre: dash >= 0
|
||||
};
|
||||
};
|
||||
const x = parse(a);
|
||||
const y = parse(b);
|
||||
for (let i = 0; i < Math.max(x.main.length, y.main.length); i++) {
|
||||
const diff = (x.main[i] ?? 0) - (y.main[i] ?? 0);
|
||||
if (diff !== 0) {
|
||||
return diff > 0;
|
||||
}
|
||||
}
|
||||
if (x.isPre !== y.isPre) {
|
||||
return !x.isPre;
|
||||
}
|
||||
for (let i = 0; i < Math.max(x.pre.length, y.pre.length); i++) {
|
||||
const diff = (x.pre[i] ?? 0) - (y.pre[i] ?? 0);
|
||||
if (diff !== 0) {
|
||||
return diff > 0;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Memoized components for better performance
|
||||
const VersionInfoDialog = memo(
|
||||
({
|
||||
@@ -235,6 +330,7 @@ const VersionInfoDialog = memo(
|
||||
<DialogActions>
|
||||
{!isPartition && (
|
||||
<Button
|
||||
startIcon={<DescriptionIcon />}
|
||||
variant="outlined"
|
||||
component="a"
|
||||
href={relNotesUrl}
|
||||
@@ -244,7 +340,12 @@ const VersionInfoDialog = memo(
|
||||
Changelog
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outlined" onClick={onClose} color="secondary">
|
||||
<Button
|
||||
startIcon={<CloseIcon />}
|
||||
variant="outlined"
|
||||
onClick={onClose}
|
||||
color="secondary"
|
||||
>
|
||||
{LL.CLOSE()}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
@@ -368,14 +469,16 @@ const InstallPartitionDialog = memo(
|
||||
onClose: () => void;
|
||||
onInstall: (partition: string) => void;
|
||||
}) => {
|
||||
const isRecovery = partition === 'boot';
|
||||
|
||||
return (
|
||||
<Dialog sx={dialogStyle} open={openInstallPartitionDialog} onClose={onClose}>
|
||||
<DialogTitle>
|
||||
{LL.INSTALL()} {LL.STORED_VERSIONS()}
|
||||
</DialogTitle>
|
||||
<DialogTitle>{isRecovery ? LL.RECOVERY_APP() : LL.INSTALL()}</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<Typography sx={{ mb: 2 }}>
|
||||
{LL.INSTALL_VERSION(LL.INSTALL(), version)}
|
||||
{isRecovery
|
||||
? 'This will boot in recovery mode'
|
||||
: LL.INSTALL_VERSION(LL.INSTALL(), version)}
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
@@ -389,12 +492,14 @@ const InstallPartitionDialog = memo(
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
startIcon={<WarningIcon color="warning" />}
|
||||
startIcon={
|
||||
isRecovery ? <RestartAltIcon /> : <WarningIcon color="warning" />
|
||||
}
|
||||
variant="outlined"
|
||||
onClick={() => onInstall(partition)}
|
||||
color="primary"
|
||||
color={isRecovery ? 'success' : 'primary'}
|
||||
>
|
||||
{LL.INSTALL()}
|
||||
{isRecovery ? LL.RESTART() : LL.INSTALL()}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
@@ -412,6 +517,11 @@ const Version = () => {
|
||||
const { me, versions, refreshVersions } = useContext(AuthenticatedContext);
|
||||
|
||||
const [restarting, setRestarting] = useState<boolean>(false);
|
||||
const [findingRecovery, setFindingRecovery] = useState<boolean>(false);
|
||||
const [installingRecovery, setInstallingRecovery] = useState<boolean>(false);
|
||||
const [latestRecovery, setLatestRecovery] = useState<RecoveryRelease | undefined>(
|
||||
undefined
|
||||
);
|
||||
const [confirmFactoryReset, setConfirmFactoryReset] = useState<boolean>(false);
|
||||
const [confirmRestart, setConfirmRestart] = useState<boolean>(false);
|
||||
const [openInstallDialog, setOpenInstallDialog] = useState<boolean>(false);
|
||||
@@ -493,9 +603,6 @@ const Version = () => {
|
||||
|
||||
const platform = data ? getPlatform(data) : '';
|
||||
|
||||
const otherPartitions =
|
||||
data?.partitions.filter((p) => p.partition !== data.partition) ?? [];
|
||||
|
||||
const setPartitionVersionInfo = (partition: string) => {
|
||||
setShowVersionInfo(3);
|
||||
const partitionData = data?.partitions.find((p) => p.partition === partition);
|
||||
@@ -538,6 +645,49 @@ const Version = () => {
|
||||
await doRestart();
|
||||
};
|
||||
|
||||
// the release of the channel we're on first, it may not have a recovery app yet so try the other one too
|
||||
const stableTag = latestVersion ? `v${latestVersion.version}` : '';
|
||||
const recoveryTags = (
|
||||
usingDevVersion ? ['latest', stableTag] : [stableTag, 'latest']
|
||||
).filter(Boolean);
|
||||
const recoveryTagsKey = recoveryTags.join(',');
|
||||
const hasRecoverySlot = data?.recovery_version !== undefined;
|
||||
|
||||
// look for the latest recovery app, to show if there is a newer one
|
||||
useEffect(() => {
|
||||
if (!hasRecoverySlot || !internetLive || !platform) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void findRecoveryCached(recoveryTags, platform).then((release) => {
|
||||
if (!cancelled) {
|
||||
setLatestRecovery(release);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [hasRecoverySlot, internetLive, platform, recoveryTagsKey]);
|
||||
|
||||
// downloads the recovery app from GitHub into the factory partition, EMS-ESP doesn't restart
|
||||
const installRecovery = async () => {
|
||||
setFindingRecovery(true);
|
||||
const release =
|
||||
latestRecovery ?? (await findRecoveryCached(recoveryTags, platform));
|
||||
setFindingRecovery(false);
|
||||
|
||||
if (!release) {
|
||||
toast.error(LL.RECOVERY_NOT_FOUND());
|
||||
return;
|
||||
}
|
||||
|
||||
await sendUploadURL(release.url)
|
||||
.then(() => setInstallingRecovery(true))
|
||||
.catch((error: Error) => {
|
||||
toast.error(error.message);
|
||||
});
|
||||
};
|
||||
|
||||
const installPartitionFirmware = async (partition: string) => {
|
||||
await sendSetPartition(partition).catch((error: Error) => {
|
||||
toast.error(error.message);
|
||||
@@ -601,8 +751,9 @@ const Version = () => {
|
||||
{LL.LATEST_VERSION(usingDevVersion ? LL.DEVELOPMENT() : LL.STABLE())}
|
||||
</span>
|
||||
<Button
|
||||
sx={{ ml: 1 }}
|
||||
sx={{ ml: 3 }}
|
||||
variant="outlined"
|
||||
startIcon={<RefreshIcon />}
|
||||
size="small"
|
||||
onClick={() => showFirmwareDialog(showingDev)}
|
||||
>
|
||||
@@ -618,9 +769,10 @@ const Version = () => {
|
||||
|
||||
return (
|
||||
<Button
|
||||
sx={{ ml: 1 }}
|
||||
sx={{ ml: 3 }}
|
||||
variant="outlined"
|
||||
color={isUpdateAvailable ? 'success' : 'warning'}
|
||||
startIcon={!isUpdateAvailable ? <SwapHorizIcon /> : undefined}
|
||||
size="small"
|
||||
onClick={() => showFirmwareDialog(showingDev)}
|
||||
>
|
||||
@@ -633,7 +785,7 @@ const Version = () => {
|
||||
display: 'inline-block',
|
||||
width: 8,
|
||||
height: 8,
|
||||
ml: 1,
|
||||
ml: 3,
|
||||
verticalAlign: 'middle',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: '#ffeb3b',
|
||||
@@ -649,6 +801,20 @@ const Version = () => {
|
||||
return <SystemMonitor />;
|
||||
}
|
||||
|
||||
if (installingRecovery) {
|
||||
return (
|
||||
<SystemMonitor
|
||||
onDone={(success) => {
|
||||
setInstallingRecovery(false);
|
||||
if (success) {
|
||||
toast.success(LL.RECOVERY_INSTALLED());
|
||||
}
|
||||
void loadData();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<SectionContent>
|
||||
@@ -664,64 +830,118 @@ const Version = () => {
|
||||
{LL.THIS_VERSION()}
|
||||
</Typography>
|
||||
|
||||
<Grid
|
||||
container
|
||||
direction="row"
|
||||
<Box
|
||||
sx={{
|
||||
justifyContent: 'flex-start',
|
||||
alignItems: 'baseline'
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'auto 1fr',
|
||||
gridAutoRows: 'minmax(32px, auto)', // same height for every row
|
||||
columnGap: 3,
|
||||
rowGap: 0.75,
|
||||
alignItems: 'center',
|
||||
// whole pixel line height so the labels and values are centered on the same pixel
|
||||
'& .MuiTypography-root': { lineHeight: '24px' },
|
||||
'& .MuiTypography-caption': { lineHeight: 1 }
|
||||
}}
|
||||
>
|
||||
<Grid size={{ xs: 4, md: 2 }}>
|
||||
<Typography color="secondary">{LL.VERSION()}</Typography>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 8, md: 10 }}>
|
||||
<Typography>
|
||||
<Typography color="secondary">{LL.VERSION()}</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
minWidth: 0
|
||||
}}
|
||||
>
|
||||
<Typography component="span">
|
||||
{data.emsesp_version}
|
||||
{data.build_flags && (
|
||||
<Typography variant="caption">
|
||||
({data.build_flags})
|
||||
<Typography component="span" variant="caption">
|
||||
({data.build_flags})
|
||||
</Typography>
|
||||
)}
|
||||
<IconButton
|
||||
onClick={() => setPartitionVersionInfo(data.partition)}
|
||||
aria-label={LL.FIRMWARE_VERSION_INFO()}
|
||||
>
|
||||
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
</Typography>
|
||||
</Grid>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setPartitionVersionInfo(data.partition)}
|
||||
aria-label={LL.FIRMWARE_VERSION_INFO()}
|
||||
sx={{ p: 0.5 }}
|
||||
>
|
||||
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
<Grid size={{ xs: 4, md: 2 }}>
|
||||
<Typography color="secondary">{LL.PLATFORM()}</Typography>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 8, md: 10 }}>
|
||||
<Typography>
|
||||
{platform}
|
||||
<Typography variant="caption">
|
||||
(
|
||||
{data.psram ? (
|
||||
<CheckIcon
|
||||
color="success"
|
||||
sx={{
|
||||
fontSize: '1.5em',
|
||||
verticalAlign: 'middle'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<CloseIcon
|
||||
color="error"
|
||||
sx={{
|
||||
fontSize: '1.5em',
|
||||
verticalAlign: 'middle'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
PSRAM)
|
||||
</Typography>
|
||||
<Typography color="secondary">{LL.PLATFORM()}</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
minWidth: 0
|
||||
}}
|
||||
>
|
||||
<Typography component="span">{platform}</Typography>
|
||||
<Typography
|
||||
component="span"
|
||||
variant="caption"
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
ml: 0.75
|
||||
}}
|
||||
>
|
||||
(
|
||||
{data.psram ? (
|
||||
<CheckIcon color="success" sx={{ fontSize: 16, mx: 0.25 }} />
|
||||
) : (
|
||||
<CloseIcon color="error" sx={{ fontSize: 16, mx: 0.25 }} />
|
||||
)}
|
||||
PSRAM)
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
|
||||
{data.recovery_version !== undefined && (
|
||||
<>
|
||||
<Typography color="secondary">{LL.RECOVERY_APP()}</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
minWidth: 0
|
||||
}}
|
||||
>
|
||||
<Typography component="span">
|
||||
{data.recovery_version || LL.RECOVERY_NOT_INSTALLED()}
|
||||
</Typography>
|
||||
{!data.recovery_version && me.admin && internetLive && (
|
||||
<Button
|
||||
startIcon={<DownloadIcon />}
|
||||
sx={{ ml: 3 }}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
disabled={findingRecovery}
|
||||
onClick={() => void installRecovery()}
|
||||
>
|
||||
{LL.INSTALL()}
|
||||
</Button>
|
||||
)}
|
||||
{data.recovery_version && me.admin && (
|
||||
<Button
|
||||
startIcon={<RestartAltIcon />}
|
||||
sx={{ ml: 3 }}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
const version = data.recovery_version;
|
||||
if (version) {
|
||||
showPartitionDialog(version, 'boot', '');
|
||||
}
|
||||
}}
|
||||
>
|
||||
{LL.RESTART_INTO_RECOVERY()}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{internetLive ? (
|
||||
<>
|
||||
@@ -729,83 +949,103 @@ const Version = () => {
|
||||
{LL.AVAILABLE_VERSION()}
|
||||
</Typography>
|
||||
|
||||
<Grid
|
||||
container
|
||||
direction="row"
|
||||
rowSpacing={1}
|
||||
<Box
|
||||
sx={{
|
||||
justifyContent: 'flex-start',
|
||||
alignItems: 'baseline'
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'auto 1fr',
|
||||
columnGap: 3,
|
||||
rowGap: 0.75,
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
{otherPartitions.length > 0 && data.developer_mode && (
|
||||
<Typography color="secondary">{LL.STABLE()}</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
minWidth: 0
|
||||
}}
|
||||
>
|
||||
<Typography component="span">{latestVersion?.version}</Typography>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setShowVersionInfo(1)}
|
||||
aria-label={LL.FIRMWARE_VERSION_INFO()}
|
||||
sx={{ p: 0.5 }}
|
||||
>
|
||||
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
{showButtons(false)}
|
||||
</Box>
|
||||
|
||||
<Typography color="secondary">{LL.DEVELOPMENT()}</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
minWidth: 0
|
||||
}}
|
||||
>
|
||||
<Typography component="span">{latestDevVersion?.version}</Typography>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setShowVersionInfo(2)}
|
||||
aria-label={LL.FIRMWARE_VERSION_INFO()}
|
||||
sx={{ p: 0.5 }}
|
||||
>
|
||||
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
{showButtons(true)}
|
||||
</Box>
|
||||
|
||||
{data.recovery_version && latestRecovery && (
|
||||
<>
|
||||
<Grid size={{ xs: 4, md: 2 }}>
|
||||
<Typography color="secondary">{LL.STORED_VERSIONS()}</Typography>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 8, md: 10 }}>
|
||||
{otherPartitions.map((partition) => (
|
||||
<Typography key={partition.partition} sx={{ mb: 1 }}>
|
||||
{partition.version}
|
||||
<IconButton
|
||||
onClick={() =>
|
||||
setPartitionVersionInfo(partition.partition)
|
||||
}
|
||||
aria-label={LL.FIRMWARE_VERSION_INFO()}
|
||||
>
|
||||
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
<Typography color="secondary">{LL.RECOVERY_APP()}</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
minWidth: 0
|
||||
}}
|
||||
>
|
||||
<Typography component="span">
|
||||
{latestRecovery.version}
|
||||
</Typography>
|
||||
{isNewerVersion(
|
||||
latestRecovery.version,
|
||||
data.recovery_version
|
||||
) ? (
|
||||
me.admin && (
|
||||
<Button
|
||||
sx={{ ml: 0 }}
|
||||
startIcon={<DownloadIcon />}
|
||||
sx={{ ml: 3 }}
|
||||
variant="outlined"
|
||||
color="success"
|
||||
size="small"
|
||||
onClick={() =>
|
||||
showPartitionDialog(
|
||||
partition.version,
|
||||
partition.partition,
|
||||
partition.install_date ?? ''
|
||||
)
|
||||
}
|
||||
disabled={findingRecovery}
|
||||
onClick={() => void installRecovery()}
|
||||
>
|
||||
{LL.INSTALL()}
|
||||
</Button>
|
||||
</Typography>
|
||||
))}
|
||||
</Grid>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<CheckIcon
|
||||
color="success"
|
||||
sx={{ verticalAlign: 'middle', ml: 0.5, mr: 0.5 }}
|
||||
/>
|
||||
<span style={{ color: '#66bb6a', fontSize: '0.8em' }}>
|
||||
{LL.LATEST_VERSION(LL.RECOVERY_APP())}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
<Grid size={{ xs: 4, md: 2 }}>
|
||||
<Typography color="secondary">{LL.STABLE()}</Typography>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 8, md: 10 }}>
|
||||
<Typography>
|
||||
{latestVersion?.version}
|
||||
<IconButton
|
||||
onClick={() => setShowVersionInfo(1)}
|
||||
aria-label={LL.FIRMWARE_VERSION_INFO()}
|
||||
>
|
||||
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
{showButtons(false)}
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 4, md: 2 }}>
|
||||
<Typography color="secondary">{LL.DEVELOPMENT()}</Typography>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 8, md: 10 }}>
|
||||
<Typography>
|
||||
{latestDevVersion?.version}
|
||||
<IconButton
|
||||
onClick={() => setShowVersionInfo(2)}
|
||||
aria-label={LL.FIRMWARE_VERSION_INFO()}
|
||||
>
|
||||
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
{showButtons(true)}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<Typography sx={{ mt: 2 }} color="warning">
|
||||
@@ -848,7 +1088,11 @@ const Version = () => {
|
||||
<Typography sx={{ pt: 2, pb: 2 }} variant="h6" color="primary">
|
||||
{LL.UPLOAD()}
|
||||
</Typography>
|
||||
<SingleUpload text={LL.UPLOAD_FIRMWARE_TEXT()} doRestart={doRestart} />
|
||||
<SingleUpload
|
||||
text={LL.UPLOAD_FIRMWARE_TEXT()}
|
||||
doRestart={doRestart}
|
||||
onRecoveryInstalled={() => void loadData()}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
@@ -899,7 +1143,7 @@ const Version = () => {
|
||||
{LL.CANCEL()}
|
||||
</Button>
|
||||
<Button
|
||||
startIcon={<PowerSettingsNewIcon />}
|
||||
startIcon={<RestartAltIcon />}
|
||||
variant="outlined"
|
||||
onClick={doRestart}
|
||||
color="success"
|
||||
@@ -931,7 +1175,7 @@ const Version = () => {
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
startIcon={<PowerSettingsNewIcon />}
|
||||
startIcon={<RestartAltIcon />}
|
||||
variant="outlined"
|
||||
onClick={handleRestartClick}
|
||||
color="success"
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { useContext } from 'react';
|
||||
|
||||
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
||||
import DeviceHubIcon from '@mui/icons-material/DeviceHub';
|
||||
import DirectionsBusIcon from '@mui/icons-material/DirectionsBus';
|
||||
@@ -24,7 +22,6 @@ import { useRequest } from 'alova/client';
|
||||
import { busConnectionStatus } from 'app/main/types';
|
||||
import { FormLoader, SectionContent, useLayoutTitle } from 'components';
|
||||
import ListMenuItem from 'components/layout/ListMenuItem';
|
||||
import { AuthenticatedContext } from 'contexts/authentication';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
import { NTPSyncStatus, NetworkConnectionStatus, SystemStatusCodes } from 'types';
|
||||
import { useInterval } from 'utils';
|
||||
@@ -56,8 +53,6 @@ const SystemStatus = () => {
|
||||
|
||||
useLayoutTitle(LL.STATUS_OF(''));
|
||||
|
||||
const { me } = useContext(AuthenticatedContext);
|
||||
|
||||
const { data, send: loadData, error } = useRequest(readSystemStatus);
|
||||
|
||||
useInterval(() => {
|
||||
@@ -211,7 +206,6 @@ const SystemStatus = () => {
|
||||
</ListItem>
|
||||
|
||||
<ListMenuItem
|
||||
disabled={!me.admin}
|
||||
icon={MemoryIcon}
|
||||
bgcolor="#68374d"
|
||||
label={LL.HARDWARE()}
|
||||
@@ -220,7 +214,6 @@ const SystemStatus = () => {
|
||||
/>
|
||||
|
||||
<ListMenuItem
|
||||
disabled={!me.admin}
|
||||
icon={DirectionsBusIcon}
|
||||
bgcolor={busStatusHighlight}
|
||||
label={LL.DATA_TRAFFIC()}
|
||||
@@ -229,7 +222,6 @@ const SystemStatus = () => {
|
||||
/>
|
||||
|
||||
<ListMenuItem
|
||||
disabled={!me.admin}
|
||||
icon={
|
||||
data.network_status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED
|
||||
? WifiIcon
|
||||
@@ -242,7 +234,6 @@ const SystemStatus = () => {
|
||||
/>
|
||||
|
||||
<ListMenuItem
|
||||
disabled={!me.admin}
|
||||
icon={DeviceHubIcon}
|
||||
bgcolor={activeHighlight(data.mqtt_status)}
|
||||
label="MQTT"
|
||||
@@ -251,7 +242,6 @@ const SystemStatus = () => {
|
||||
/>
|
||||
|
||||
<ListMenuItem
|
||||
disabled={!me.admin}
|
||||
icon={AccessTimeIcon}
|
||||
bgcolor={ntpStatusHighlight}
|
||||
label="NTP"
|
||||
@@ -260,7 +250,6 @@ const SystemStatus = () => {
|
||||
/>
|
||||
|
||||
<ListMenuItem
|
||||
disabled={!me.admin}
|
||||
icon={SettingsInputAntennaIcon}
|
||||
bgcolor={activeHighlight(data.ap_status)}
|
||||
label={LL.ACCESS_POINT(0)}
|
||||
@@ -269,7 +258,6 @@ const SystemStatus = () => {
|
||||
/>
|
||||
|
||||
<ListMenuItem
|
||||
disabled={!me.admin}
|
||||
icon={LogoDevIcon}
|
||||
bgcolor="#40828f"
|
||||
label={LL.LOG_OF(LL.SYSTEM(0))}
|
||||
|
||||
@@ -14,7 +14,12 @@ import { useInterval } from 'utils';
|
||||
|
||||
import { LinearProgressWithLabel } from '../../components/upload/LinearProgressWithLabel';
|
||||
|
||||
const SystemMonitor = () => {
|
||||
interface SystemMonitorProps {
|
||||
// when set the page isn't reloaded when done, e.g. when the firmware was installed without a restart
|
||||
onDone?: (success: boolean) => void;
|
||||
}
|
||||
|
||||
const SystemMonitor = ({ onDone }: SystemMonitorProps) => {
|
||||
const [errorMessage, setErrorMessage] = useState<string>();
|
||||
const hasInitialized = useRef(false);
|
||||
const { LL } = useI18nContext();
|
||||
@@ -42,7 +47,11 @@ const SystemMonitor = () => {
|
||||
event.data.status === SystemStatusCodes.SYSTEM_STATUS_NORMAL ||
|
||||
event.data.status === undefined
|
||||
) {
|
||||
document.location.href = '/';
|
||||
if (onDone) {
|
||||
onDone(true);
|
||||
} else {
|
||||
document.location.href = '/';
|
||||
}
|
||||
} else if (
|
||||
event.data.status === SystemStatusCodes.SYSTEM_STATUS_ERROR_UPLOAD
|
||||
) {
|
||||
@@ -80,7 +89,11 @@ const SystemMonitor = () => {
|
||||
const onCancel = async () => {
|
||||
setErrorMessage(undefined);
|
||||
await setSystemStatus(String(SystemStatusCodes.SYSTEM_STATUS_NORMAL));
|
||||
document.location.href = '/';
|
||||
if (onDone) {
|
||||
onDone(false);
|
||||
} else {
|
||||
document.location.href = '/';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -14,10 +14,15 @@ import { LinearProgressWithLabel } from './LinearProgressWithLabel';
|
||||
|
||||
interface SingleUploadProps {
|
||||
doRestart: () => void;
|
||||
onRecoveryInstalled?: () => void;
|
||||
text: string;
|
||||
}
|
||||
|
||||
const SingleUpload = ({ doRestart, text }: SingleUploadProps) => {
|
||||
const SingleUpload = ({
|
||||
doRestart,
|
||||
onRecoveryInstalled,
|
||||
text
|
||||
}: SingleUploadProps) => {
|
||||
const [md5, setMd5] = useState<string>();
|
||||
const [file, setFile] = useState<File>();
|
||||
const { LL } = useI18nContext();
|
||||
@@ -42,6 +47,12 @@ const SingleUpload = ({ doRestart, text }: SingleUploadProps) => {
|
||||
setMd5((payload as { md5: string }).md5);
|
||||
toast.success(LL.UPLOAD_MD5_RECEIVED());
|
||||
setFile(undefined);
|
||||
} else if (payload && typeof payload === 'object' && 'recovery' in payload) {
|
||||
// written to the factory partition, EMS-ESP keeps running
|
||||
toast.success(LL.RECOVERY_INSTALLED());
|
||||
setMd5(undefined);
|
||||
setFile(undefined);
|
||||
onRecoveryInstalled?.();
|
||||
} else {
|
||||
if (payload && typeof payload === 'object' && 'md5_ok' in payload) {
|
||||
toast.success(LL.UPLOAD_MD5_MATCHED());
|
||||
|
||||
@@ -42,6 +42,7 @@ const cs: Translation = {
|
||||
APPLY_CHANGES: 'Použít změny ({0})',
|
||||
UPDATE: 'Aktualizovat',
|
||||
REMOVE: 'Odebrat',
|
||||
RESET: 'Obnovit',
|
||||
PROBLEM_UPDATING: 'Problém s aktualizací',
|
||||
PROBLEM_LOADING: 'Problém s načítáním',
|
||||
ANALOG_SENSOR: 'Analogový senzor',
|
||||
@@ -141,6 +142,8 @@ const cs: Translation = {
|
||||
CUSTOMIZATIONS_HELP_6: 'odstranit z paměti',
|
||||
SELECT_DEVICE: 'Vyberte zařízení',
|
||||
SET_ALL: 'nastavit vše',
|
||||
MODIFIED: 'upravené',
|
||||
RESET_SELECTED: 'Obnovit vybrané',
|
||||
OPTIONS: 'Možnosti',
|
||||
NAME: 'Název',
|
||||
CUSTOMIZATIONS_RESET: 'Opravdu chcete odstranit všechna přizpůsobení?',
|
||||
@@ -235,7 +238,7 @@ const cs: Translation = {
|
||||
TIME_ZONE: 'Časová zóna',
|
||||
ACCESS_POINT: 'Přístupový bod',
|
||||
AP_PROVIDE: 'Povolit přístupový bod',
|
||||
AP_PROVIDE_TEXT_2: 'Když je síťové připojení stratené',
|
||||
AP_PROVIDE_TEXT_2: 'Když není síťové připojení nalezeno',
|
||||
AP_PROVIDE_TEXT_3: 'Nikdy',
|
||||
AP_PREFERRED_CHANNEL: 'Preferovaný kanál',
|
||||
AP_HIDE_SSID: 'Skrýt SSID',
|
||||
@@ -336,7 +339,6 @@ const cs: Translation = {
|
||||
SWITCH_RELEASE_TYPE: 'Přepnout na {0} verzi',
|
||||
FIRMWARE_VERSION_INFO: 'Informace o verzi firmwaru',
|
||||
USER_PROFILE: 'Uživatelský profil',
|
||||
STORED_VERSIONS: 'Uložené verze',
|
||||
ONLINE_HELP: 'online nápověda',
|
||||
UPGRADE_IMPORTANT_MESSAGES: 'Aktualizovat důležité zprávy',
|
||||
UPGRADE_IMPORTANT_MESSAGES_1: 'Tato aktualizace vyžaduje obnovení továrního nastavení. Ujistěte se, že nejprve stáhnete systémovou zálohu před pokračováním a poté nahrajte tento soubor po instalaci nové verze.',
|
||||
@@ -352,7 +354,12 @@ const cs: Translation = {
|
||||
UPLOAD_BACKUP_TEXT: 'Klikněte, abyste vybrali, nebo drop soubor zálohy (.json) sem',
|
||||
UPLOAD_FIRMWARE_TEXT: 'Klikněte, abyste vybrali, nebo drop soubor firmware .bin/.md5 sem',
|
||||
UPLOAD_MD5_RECEIVED: 'Digest přijat, nyní nahrajte firmware .bin',
|
||||
UPLOAD_MD5_MATCHED: 'MD5 souhlasí, firmware se instaluje...'
|
||||
UPLOAD_MD5_MATCHED: 'MD5 souhlasí, firmware se instaluje...',
|
||||
RECOVERY_APP: 'Aplikace pro obnovu',
|
||||
RECOVERY_NOT_INSTALLED: 'Nenainstalováno',
|
||||
RECOVERY_INSTALLED: 'Firmware pro obnovu nainstalován',
|
||||
RESTART_INTO_RECOVERY: 'Restartovat do aplikace pro obnovu',
|
||||
RECOVERY_NOT_FOUND: 'Aplikace pro obnovu nebyla online nalezena'
|
||||
};
|
||||
|
||||
export default cs;
|
||||
|
||||
@@ -42,6 +42,7 @@ const de: Translation = {
|
||||
APPLY_CHANGES: 'Änderungen anwenden ({0})',
|
||||
UPDATE: 'Aktualisieren',
|
||||
REMOVE: 'Entfernen',
|
||||
RESET: 'Zurücksetzen',
|
||||
PROBLEM_UPDATING: 'Problem beim Aktualisieren',
|
||||
PROBLEM_LOADING: 'Problem beim Laden',
|
||||
ANALOG_SENSOR: 'Analogsensor',
|
||||
@@ -141,6 +142,8 @@ const de: Translation = {
|
||||
CUSTOMIZATIONS_HELP_6: 'Aus dem Speicher entfernen',
|
||||
SELECT_DEVICE: 'Wählen Sie ein Gerät aus',
|
||||
SET_ALL: 'Setzen Sie alle',
|
||||
MODIFIED: 'geändert',
|
||||
RESET_SELECTED: 'Auswahl zurücksetzen',
|
||||
OPTIONS: 'Optionen',
|
||||
NAME: 'Name',
|
||||
CUSTOMIZATIONS_RESET: 'Möchten Sie wirklich alle Anpassungen entfernen?',
|
||||
@@ -235,7 +238,7 @@ const de: Translation = {
|
||||
TIME_ZONE: 'Zeitzone',
|
||||
ACCESS_POINT: 'Zugangspunkt',
|
||||
AP_PROVIDE: 'Aktiviere Zugangspunkt',
|
||||
AP_PROVIDE_TEXT_2: 'Wenn Netzwerkverbindung verloren geht',
|
||||
AP_PROVIDE_TEXT_2: 'Wenn keine Netzwerkverbindung gefunden wird',
|
||||
AP_PROVIDE_TEXT_3: 'Niemals',
|
||||
AP_PREFERRED_CHANNEL: 'Bevorzugter Kanal',
|
||||
AP_HIDE_SSID: 'Verstecke SSID',
|
||||
@@ -336,7 +339,6 @@ const de: Translation = {
|
||||
SWITCH_RELEASE_TYPE: 'Zum {0}-Release wechseln',
|
||||
FIRMWARE_VERSION_INFO: 'Firmware-Versionsinformation',
|
||||
USER_PROFILE: 'Benutzerprofil',
|
||||
STORED_VERSIONS: 'Gespeicherte Versionen',
|
||||
ONLINE_HELP: 'Online-Hilfe',
|
||||
UPGRADE_IMPORTANT_MESSAGES: 'Wichtige Nachrichten aktualisieren',
|
||||
UPGRADE_IMPORTANT_MESSAGES_1: 'Für diese Aktualisierung ist ein Werksreset erforderlich. Stellen Sie sicher, dass Sie zuerst eine Systemsicherung herunterladen, bevor Sie fortfahren, und laden Sie diese Datei dann nach der Installation der neuen Version hoch.',
|
||||
@@ -352,7 +354,12 @@ const de: Translation = {
|
||||
UPLOAD_BACKUP_TEXT: 'Klicken Sie, um eine Sicherungsdatei (.json) auszuwählen, oder drop ein',
|
||||
UPLOAD_FIRMWARE_TEXT: 'Klicken Sie, um eine Firmware-Datei (.bin/.md5) auszuwählen, oder drop ein',
|
||||
UPLOAD_MD5_RECEIVED: 'Prüfsumme empfangen, jetzt die Firmware-Datei (.bin) hochladen',
|
||||
UPLOAD_MD5_MATCHED: 'MD5 stimmt überein, Firmware wird installiert...'
|
||||
UPLOAD_MD5_MATCHED: 'MD5 stimmt überein, Firmware wird installiert...',
|
||||
RECOVERY_APP: 'Wiederherstellungs-App',
|
||||
RECOVERY_NOT_INSTALLED: 'Nicht installiert',
|
||||
RECOVERY_INSTALLED: 'Wiederherstellungs-Firmware installiert',
|
||||
RESTART_INTO_RECOVERY: 'Neustart in die Wiederherstellungs-App',
|
||||
RECOVERY_NOT_FOUND: 'Wiederherstellungs-App online nicht gefunden'
|
||||
};
|
||||
|
||||
export default de;
|
||||
|
||||
@@ -42,6 +42,7 @@ const en: Translation = {
|
||||
APPLY_CHANGES: 'Apply Changes ({0})',
|
||||
UPDATE: 'Update',
|
||||
REMOVE: 'Remove',
|
||||
RESET: 'Reset',
|
||||
PROBLEM_UPDATING: 'Problem updating',
|
||||
PROBLEM_LOADING: 'Problem loading',
|
||||
ANALOG_SENSOR: 'Analog Sensor',
|
||||
@@ -141,6 +142,8 @@ const en: Translation = {
|
||||
CUSTOMIZATIONS_HELP_6: 'remove from memory',
|
||||
SELECT_DEVICE: 'Select a device',
|
||||
SET_ALL: 'set all',
|
||||
MODIFIED: 'modified',
|
||||
RESET_SELECTED: 'Reset selected',
|
||||
OPTIONS: 'Options',
|
||||
NAME: 'Name',
|
||||
CUSTOMIZATIONS_RESET: 'Are you sure you want remove all the customizations?',
|
||||
@@ -235,7 +238,7 @@ const en: Translation = {
|
||||
TIME_ZONE: 'Time Zone',
|
||||
ACCESS_POINT: 'Access Point',
|
||||
AP_PROVIDE: 'Enable Access Point',
|
||||
AP_PROVIDE_TEXT_2: 'When network connection is lost',
|
||||
AP_PROVIDE_TEXT_2: 'When network connection not found',
|
||||
AP_PROVIDE_TEXT_3: 'Never',
|
||||
AP_PREFERRED_CHANNEL: 'Preferred Channel',
|
||||
AP_HIDE_SSID: 'Hide SSID',
|
||||
@@ -336,7 +339,6 @@ const en: Translation = {
|
||||
SWITCH_RELEASE_TYPE: 'Switch to {0} release',
|
||||
FIRMWARE_VERSION_INFO: 'Firmware Version Information',
|
||||
USER_PROFILE: 'User Profile',
|
||||
STORED_VERSIONS: 'Stored Versions',
|
||||
ONLINE_HELP: 'online help',
|
||||
UPGRADE_IMPORTANT_MESSAGES: 'Upgrade Important Messages',
|
||||
UPGRADE_IMPORTANT_MESSAGES_1: 'This upgrade requires a factory reset. Make sure you first download a System Backup before continuing, and then upload this file after the new version is installed.',
|
||||
@@ -352,7 +354,12 @@ const en: Translation = {
|
||||
UPLOAD_BACKUP_TEXT: 'Click to select, or drop a backup file (.json) here',
|
||||
UPLOAD_FIRMWARE_TEXT: 'Click to select, or drop a firmware .bin/.md5 file here',
|
||||
UPLOAD_MD5_RECEIVED: 'Digest received, now upload the firmware .bin file',
|
||||
UPLOAD_MD5_MATCHED: 'MD5 matches, installing firmware...'
|
||||
UPLOAD_MD5_MATCHED: 'MD5 matches, installing firmware...',
|
||||
RECOVERY_APP: 'Recovery App',
|
||||
RECOVERY_NOT_INSTALLED: 'Not installed',
|
||||
RECOVERY_INSTALLED: 'Recovery firmware installed',
|
||||
RESTART_INTO_RECOVERY: 'Restart into Recovery App',
|
||||
RECOVERY_NOT_FOUND: 'Recovery App not found online'
|
||||
};
|
||||
|
||||
export default en;
|
||||
|
||||
@@ -42,6 +42,7 @@ const fr: Translation = {
|
||||
APPLY_CHANGES: 'Appliquer les changements ({0})',
|
||||
UPDATE: 'Update',
|
||||
REMOVE: 'Enlever',
|
||||
RESET: 'Réinitialiser',
|
||||
PROBLEM_UPDATING: 'Problème lors de la mise à jour',
|
||||
PROBLEM_LOADING: 'Problème lors du chargement',
|
||||
ANALOG_SENSOR: 'Capteur analogique',
|
||||
@@ -141,6 +142,8 @@ const fr: Translation = {
|
||||
CUSTOMIZATIONS_HELP_6: 'supprimer de la mémoire',
|
||||
SELECT_DEVICE: 'Sélectionnez un appareil',
|
||||
SET_ALL: 'tout régler',
|
||||
MODIFIED: 'modifié',
|
||||
RESET_SELECTED: 'Réinitialiser la sélection',
|
||||
OPTIONS: 'Options',
|
||||
NAME: 'Nom',
|
||||
CUSTOMIZATIONS_RESET: 'Êtes-vous sûr de vouloir supprimer toutes les personnalisations?',
|
||||
@@ -235,7 +238,7 @@ const fr: Translation = {
|
||||
TIME_ZONE: 'Fuseau horaire',
|
||||
ACCESS_POINT: "Point d'accès",
|
||||
AP_PROVIDE: "Activer le Point d'Accès",
|
||||
AP_PROVIDE_TEXT_2: 'quand la connexion réseau est perdue',
|
||||
AP_PROVIDE_TEXT_2: 'quand la connexion réseau n\'est pas trouvée',
|
||||
AP_PROVIDE_TEXT_3: 'jamais',
|
||||
AP_PREFERRED_CHANNEL: 'Canal préféré',
|
||||
AP_HIDE_SSID: 'Cacher le SSID',
|
||||
@@ -336,7 +339,6 @@ const fr: Translation = {
|
||||
SWITCH_RELEASE_TYPE: 'Passer à la version {0}',
|
||||
FIRMWARE_VERSION_INFO: 'Informations sur la version du firmware',
|
||||
USER_PROFILE: 'Profil utilisateur',
|
||||
STORED_VERSIONS: 'Versions stockées',
|
||||
ONLINE_HELP: 'aide en ligne',
|
||||
UPGRADE_IMPORTANT_MESSAGES: 'Mettre à jour les messages importants',
|
||||
UPGRADE_IMPORTANT_MESSAGES_1: 'Cette mise à jour nécessite une réinitialisation de fabrique. Assurez-vous de télécharger une sauvegarde système avant de continuer, et de la charger après l\'installation de la nouvelle version.',
|
||||
@@ -352,7 +354,12 @@ const fr: Translation = {
|
||||
UPLOAD_BACKUP_TEXT: 'Cliquez pour sélectionner, ou drop un fichier de sauvegarde (.json) ici',
|
||||
UPLOAD_FIRMWARE_TEXT: 'Cliquez pour sélectionner, ou drop un fichier firmware .bin/.md5 ici',
|
||||
UPLOAD_MD5_RECEIVED: 'Empreinte reçue, téléversez maintenant le fichier firmware .bin',
|
||||
UPLOAD_MD5_MATCHED: 'MD5 concordant, installation du firmware...'
|
||||
UPLOAD_MD5_MATCHED: 'MD5 concordant, installation du firmware...',
|
||||
RECOVERY_APP: 'Application de récupération',
|
||||
RECOVERY_NOT_INSTALLED: 'Non installé',
|
||||
RECOVERY_INSTALLED: 'Firmware de récupération installé',
|
||||
RESTART_INTO_RECOVERY: 'Redémarrer dans l\'application de récupération',
|
||||
RECOVERY_NOT_FOUND: 'Application de récupération introuvable en ligne'
|
||||
};
|
||||
|
||||
export default fr;
|
||||
|
||||
@@ -42,6 +42,7 @@ const it: Translation = {
|
||||
APPLY_CHANGES: 'Applica Cambiamenti ({0})',
|
||||
UPDATE: 'Update',
|
||||
REMOVE: 'Elimina',
|
||||
RESET: 'Reimposta',
|
||||
PROBLEM_UPDATING: 'Problema aggiornamento',
|
||||
PROBLEM_LOADING: 'Problema caricamento',
|
||||
ANALOG_SENSOR: 'Sensore Analogico',
|
||||
@@ -141,6 +142,8 @@ const it: Translation = {
|
||||
CUSTOMIZATIONS_HELP_6: 'rimuovi dalla memoria',
|
||||
SELECT_DEVICE: 'Seleziona un dispositivo',
|
||||
SET_ALL: 'imposta tutto',
|
||||
MODIFIED: 'modificato',
|
||||
RESET_SELECTED: 'Reimposta selezionati',
|
||||
OPTIONS: 'Opzioni',
|
||||
NAME: 'Nome',
|
||||
CUSTOMIZATIONS_RESET: 'Sei sicuro di voler rimuovere tutte le personalizzazioni?',
|
||||
@@ -235,7 +238,7 @@ const it: Translation = {
|
||||
TIME_ZONE: 'Fuso orario',
|
||||
ACCESS_POINT: 'Access Point',
|
||||
AP_PROVIDE: 'Abilita Access Point',
|
||||
AP_PROVIDE_TEXT_2: 'quando la connessione di rete è persa',
|
||||
AP_PROVIDE_TEXT_2: 'quando la connessione di rete non è trovata',
|
||||
AP_PROVIDE_TEXT_3: 'mai',
|
||||
AP_PREFERRED_CHANNEL: 'Canale preferito',
|
||||
AP_HIDE_SSID: 'Nascondi SSID',
|
||||
@@ -336,7 +339,6 @@ const it: Translation = {
|
||||
SWITCH_RELEASE_TYPE: 'Cambia in {0} rilascio',
|
||||
FIRMWARE_VERSION_INFO: 'Informazioni sulla versione del firmware',
|
||||
USER_PROFILE: 'Profilo utente',
|
||||
STORED_VERSIONS: 'Versioni memorizzate',
|
||||
ONLINE_HELP: 'aiuto online',
|
||||
UPGRADE_IMPORTANT_MESSAGES: 'Aggiorna Messaggi Importanti',
|
||||
UPGRADE_IMPORTANT_MESSAGES_1: 'Questa aggiornamento richiede un ripristino di fabbrica. Assicurati di prima scaricare un backup del sistema prima di continuare, e poi caricare questo file dopo l\'installazione della nuova versione.',
|
||||
@@ -352,7 +354,12 @@ const it: Translation = {
|
||||
UPLOAD_BACKUP_TEXT: 'Klik per selezionare, o drop un file di backup (.json) qui',
|
||||
UPLOAD_FIRMWARE_TEXT: 'Klik per selezionare, o drop un file firmware .bin/.md5 qui',
|
||||
UPLOAD_MD5_RECEIVED: 'Digest ricevuto, ora caricare il file firmware .bin',
|
||||
UPLOAD_MD5_MATCHED: 'MD5 corrispondente, installazione firmware...'
|
||||
UPLOAD_MD5_MATCHED: 'MD5 corrispondente, installazione firmware...',
|
||||
RECOVERY_APP: 'App di ripristino',
|
||||
RECOVERY_NOT_INSTALLED: 'Non installato',
|
||||
RECOVERY_INSTALLED: 'Firmware di ripristino installato',
|
||||
RESTART_INTO_RECOVERY: 'Riavvia nell\'app di ripristino',
|
||||
RECOVERY_NOT_FOUND: 'App di ripristino non trovata online'
|
||||
};
|
||||
|
||||
export default it;
|
||||
|
||||
@@ -42,6 +42,7 @@ const nl: Translation = {
|
||||
APPLY_CHANGES: 'Aanpassen ({0})',
|
||||
UPDATE: 'Update',
|
||||
REMOVE: 'Verwijderen',
|
||||
RESET: 'Resetten',
|
||||
PROBLEM_UPDATING: 'Probleem met updaten',
|
||||
PROBLEM_LOADING: 'Probleem met laden',
|
||||
ANALOG_SENSOR: 'Analoge sensor',
|
||||
@@ -141,6 +142,8 @@ const nl: Translation = {
|
||||
CUSTOMIZATIONS_HELP_6: 'verwijderen van memory',
|
||||
SELECT_DEVICE: 'Selecteer een apparaat',
|
||||
SET_ALL: 'Alles aanzetten',
|
||||
MODIFIED: 'gewijzigd',
|
||||
RESET_SELECTED: 'Selectie resetten',
|
||||
OPTIONS: 'Opties',
|
||||
NAME: 'Naam',
|
||||
CUSTOMIZATIONS_RESET: 'Weet je zeker dat je alle custom aanpassingen wilt verwijderen?',
|
||||
@@ -235,7 +238,7 @@ const nl: Translation = {
|
||||
TIME_ZONE: 'Tijdzone',
|
||||
ACCESS_POINT: 'Access Point',
|
||||
AP_PROVIDE: 'Activeer Access Point',
|
||||
AP_PROVIDE_TEXT_2: 'als netwerk verbinding verloren gaat',
|
||||
AP_PROVIDE_TEXT_2: 'als netwerkverbinding niet gevonden wordt',
|
||||
AP_PROVIDE_TEXT_3: 'nooit',
|
||||
AP_PREFERRED_CHANNEL: 'Voorkeurskanaal',
|
||||
AP_HIDE_SSID: 'SSID verbergen',
|
||||
@@ -336,7 +339,6 @@ const nl: Translation = {
|
||||
SWITCH_RELEASE_TYPE: 'Switch naar {0} release',
|
||||
FIRMWARE_VERSION_INFO: 'Informatie over firmwareversie',
|
||||
USER_PROFILE: 'Gebruikersprofiel',
|
||||
STORED_VERSIONS: 'Opgeslagen versies',
|
||||
ONLINE_HELP: 'online help',
|
||||
UPGRADE_IMPORTANT_MESSAGES: 'Upgrade Belangrijke Berichten',
|
||||
UPGRADE_IMPORTANT_MESSAGES_1: 'Deze upgrade vereist een fabrieksinstelling. Zorg ervoor dat u eerst een Systeem Backup download voordat u doorgaat, en upload deze file na de installatie van de nieuwe versie.',
|
||||
@@ -352,7 +354,12 @@ const nl: Translation = {
|
||||
UPLOAD_BACKUP_TEXT: 'Klik voor selecteren, of drop een back-upbestand (.json) hier',
|
||||
UPLOAD_FIRMWARE_TEXT: 'Klik voor selecteren, of drop een firmware .bin/.md5 bestand hier',
|
||||
UPLOAD_MD5_RECEIVED: 'Digest ontvangen, upload nu het firmware .bin-bestand',
|
||||
UPLOAD_MD5_MATCHED: 'MD5 komt overeen, firmware wordt geïnstalleerd...'
|
||||
UPLOAD_MD5_MATCHED: 'MD5 komt overeen, firmware wordt geïnstalleerd...',
|
||||
RECOVERY_APP: 'Herstel-app',
|
||||
RECOVERY_NOT_INSTALLED: 'Niet geïnstalleerd',
|
||||
RECOVERY_INSTALLED: 'Herstelfirmware geïnstalleerd',
|
||||
RESTART_INTO_RECOVERY: 'Herstart naar de herstel-app',
|
||||
RECOVERY_NOT_FOUND: 'Herstel-app niet online gevonden'
|
||||
};
|
||||
|
||||
export default nl;
|
||||
|
||||
@@ -42,6 +42,7 @@ const no: Translation = {
|
||||
APPLY_CHANGES: 'Utfør endringer({0})',
|
||||
UPDATE: 'Oppdater',
|
||||
REMOVE: 'Fjern',
|
||||
RESET: 'Tilbakestill',
|
||||
PROBLEM_UPDATING: 'Problem med oppdatering',
|
||||
PROBLEM_LOADING: 'Problem med opplasting',
|
||||
ANALOG_SENSOR: 'Analog Sensor',
|
||||
@@ -141,6 +142,8 @@ const no: Translation = {
|
||||
CUSTOMIZATIONS_HELP_6: 'fjern fra minnet',
|
||||
SELECT_DEVICE: 'Velg en enhet',
|
||||
SET_ALL: 'sett alle',
|
||||
MODIFIED: 'endret',
|
||||
RESET_SELECTED: 'Tilbakestill valgte',
|
||||
OPTIONS: 'Alternativ',
|
||||
NAME: 'Navn',
|
||||
CUSTOMIZATIONS_RESET: 'Er du sikker på att du vil fjerne tilpassninger?',
|
||||
@@ -235,7 +238,7 @@ const no: Translation = {
|
||||
TIME_ZONE: 'Tidssone',
|
||||
ACCESS_POINT: 'Aksesspunkt',
|
||||
AP_PROVIDE: 'Aktiver Aksesspunkt',
|
||||
AP_PROVIDE_TEXT_2: 'når nettverksforbindelsen er utilgjengelig',
|
||||
AP_PROVIDE_TEXT_2: 'når nettverksforbindelsen ikke finnes',
|
||||
AP_PROVIDE_TEXT_3: 'aldri',
|
||||
AP_PREFERRED_CHANNEL: 'Foretrukket kanal',
|
||||
AP_HIDE_SSID: 'Skjul SSID',
|
||||
@@ -336,7 +339,6 @@ const no: Translation = {
|
||||
SWITCH_RELEASE_TYPE: 'Bytt til {0} utgivelse',
|
||||
FIRMWARE_VERSION_INFO: 'Informasjon om firmwareversjon',
|
||||
USER_PROFILE: 'Brukerprofil',
|
||||
STORED_VERSIONS: 'Lagret versjoner',
|
||||
ONLINE_HELP: 'online hjelp',
|
||||
UPGRADE_IMPORTANT_MESSAGES: 'Oppdater viktige meldinger',
|
||||
UPGRADE_IMPORTANT_MESSAGES_1: 'Denne oppdateringen krever en fabriksinstilling. Sørg for at du først lastet ned en System Backup før du fortsetter, og last denne filen etter at den nye versjonen er installert.',
|
||||
@@ -352,7 +354,12 @@ const no: Translation = {
|
||||
UPLOAD_BACKUP_TEXT: 'Klikk for å velge, eller drop en sikkerhetskopi (.json) her',
|
||||
UPLOAD_FIRMWARE_TEXT: 'Klikk for å velge, eller drop en firmware .bin/.md5 her',
|
||||
UPLOAD_MD5_RECEIVED: 'MD5 mottatt, last nå opp firmware .bin-filen',
|
||||
UPLOAD_MD5_MATCHED: 'MD5 stemmer, firmware installeres...'
|
||||
UPLOAD_MD5_MATCHED: 'MD5 stemmer, firmware installeres...',
|
||||
RECOVERY_APP: 'Gjenopprettingsapp',
|
||||
RECOVERY_NOT_INSTALLED: 'Ikke installert',
|
||||
RECOVERY_INSTALLED: 'Gjenopprettingsfirmware installert',
|
||||
RESTART_INTO_RECOVERY: 'Start på nytt i gjenopprettingsappen',
|
||||
RECOVERY_NOT_FOUND: 'Fant ikke gjenopprettingsappen på nettet'
|
||||
};
|
||||
|
||||
export default no;
|
||||
|
||||
@@ -42,6 +42,7 @@ const pl: BaseTranslation = {
|
||||
APPLY_CHANGES: 'Zapisz zmiany ({0})',
|
||||
UPDATE: 'Zmień',
|
||||
REMOVE: 'Usuń',
|
||||
RESET: 'Resetuj',
|
||||
PROBLEM_UPDATING: 'Problem z uaktualnieniem!',
|
||||
PROBLEM_LOADING: 'Problem z załadowaniem!',
|
||||
ANALOG_SENSOR: '{{u|u||ustawienia u|ustawień u}}rządzeni{{a podłączonego do EMS-ESP|e||a podłączonego do EMS-ESP|a podłączonego do EMS-ESP}}',
|
||||
@@ -141,6 +142,8 @@ const pl: BaseTranslation = {
|
||||
CUSTOMIZATIONS_HELP_6: 'usuń z pamięci',
|
||||
SELECT_DEVICE: 'wybierz urządzenie',
|
||||
SET_ALL: 'Ustaw wszystko jako',
|
||||
MODIFIED: 'zmienione',
|
||||
RESET_SELECTED: 'Resetuj zaznaczone',
|
||||
OPTIONS: 'Opcje',
|
||||
NAME: '{{Nazwa|nazwa|}}',
|
||||
CUSTOMIZATIONS_RESET: 'Na pewno chcesz usunąć wszystkie personalizacje?',
|
||||
@@ -235,7 +238,7 @@ const pl: BaseTranslation = {
|
||||
TIME_ZONE: 'Strefa czasowa',
|
||||
ACCESS_POINT: '{{Punkt|punktu|}} {{dostępowy|dostępowego|}}',
|
||||
AP_PROVIDE: 'Punkt dostępowy',
|
||||
AP_PROVIDE_TEXT_2: 'aktywny jeśli brak połączenia z siecią',
|
||||
AP_PROVIDE_TEXT_2: 'gdy nie znaleziono połączenia z siecią',
|
||||
AP_PROVIDE_TEXT_3: 'nieaktywny',
|
||||
AP_PREFERRED_CHANNEL: 'Preferowany kanał',
|
||||
AP_HIDE_SSID: 'Ukryj SSID',
|
||||
@@ -336,7 +339,6 @@ const pl: BaseTranslation = {
|
||||
SWITCH_RELEASE_TYPE: 'Zmień na {0} wydanie',
|
||||
FIRMWARE_VERSION_INFO: 'Informacje o wersji firmware',
|
||||
USER_PROFILE: 'Profil użytkownika',
|
||||
STORED_VERSIONS: 'Zapisane wersje',
|
||||
ONLINE_HELP: 'pomoc online',
|
||||
UPGRADE_IMPORTANT_MESSAGES: 'Aktualizuj ważne wiadomości',
|
||||
UPGRADE_IMPORTANT_MESSAGES_1: 'Ta aktualizacja wymaga resetu fabrycznego. Upewnij się, że najpierw pobierzesz kopię zapasową systemu przed kontynuowaniem, a następnie przesuń tę plik po zainstalowaniu nowej wersji.',
|
||||
@@ -352,7 +354,12 @@ const pl: BaseTranslation = {
|
||||
UPLOAD_BACKUP_TEXT: 'Kliknij, aby wybrać, lub drop plik kopii zapasowej (.json) tutaj',
|
||||
UPLOAD_FIRMWARE_TEXT: 'Kliknij, aby wybrać, lub drop plik firmware .bin/.md5 tutaj',
|
||||
UPLOAD_MD5_RECEIVED: 'Suma MD5 odebrana, teraz wgraj plik firmware .bin',
|
||||
UPLOAD_MD5_MATCHED: 'MD5 zgodne, instalowanie firmware...'
|
||||
UPLOAD_MD5_MATCHED: 'MD5 zgodne, instalowanie firmware...',
|
||||
RECOVERY_APP: 'Aplikacja odzyskiwania',
|
||||
RECOVERY_NOT_INSTALLED: 'Nie zainstalowano',
|
||||
RECOVERY_INSTALLED: 'Zainstalowano firmware odzyskiwania',
|
||||
RESTART_INTO_RECOVERY: 'Uruchom ponownie w aplikacji odzyskiwania',
|
||||
RECOVERY_NOT_FOUND: 'Nie znaleziono aplikacji odzyskiwania online'
|
||||
};
|
||||
|
||||
export default pl;
|
||||
|
||||
@@ -42,6 +42,7 @@ const sk: Translation = {
|
||||
APPLY_CHANGES: 'Aplikovať zmeny ({0})',
|
||||
UPDATE: 'Aktualizovať',
|
||||
REMOVE: 'Odstrániť',
|
||||
RESET: 'Obnoviť',
|
||||
PROBLEM_UPDATING: 'Problém s aktualizáciou',
|
||||
PROBLEM_LOADING: 'Problém s načítaním',
|
||||
ANALOG_SENSOR: 'Analógový snímač',
|
||||
@@ -141,6 +142,8 @@ const sk: Translation = {
|
||||
CUSTOMIZATIONS_HELP_6: 'odstrániť z pamäte',
|
||||
SELECT_DEVICE: 'Zvoliť zariadenie',
|
||||
SET_ALL: 'nastaviť všetko',
|
||||
MODIFIED: 'upravené',
|
||||
RESET_SELECTED: 'Obnoviť vybrané',
|
||||
OPTIONS: 'Možnosti',
|
||||
NAME: 'Názov',
|
||||
CUSTOMIZATIONS_RESET: 'Naozaj chcete odstrániť všetky prispôsobenia?',
|
||||
@@ -235,7 +238,7 @@ const sk: Translation = {
|
||||
TIME_ZONE: 'Časová zóna',
|
||||
ACCESS_POINT: 'Prístupový bod',
|
||||
AP_PROVIDE: 'Povoliť prístupový bod',
|
||||
AP_PROVIDE_TEXT_2: 'keď je sieťové pripojenie stratené',
|
||||
AP_PROVIDE_TEXT_2: 'keď sieťové pripojenie nie je nájdené',
|
||||
AP_PROVIDE_TEXT_3: 'nikdy',
|
||||
AP_PREFERRED_CHANNEL: 'Preferovaný kanál',
|
||||
AP_HIDE_SSID: 'Skryť SSID',
|
||||
@@ -336,7 +339,6 @@ const sk: Translation = {
|
||||
SWITCH_RELEASE_TYPE: 'Prepnúť na {0} verziu',
|
||||
FIRMWARE_VERSION_INFO: 'Informácie o verzii firmware',
|
||||
USER_PROFILE: 'Profil používateľa',
|
||||
STORED_VERSIONS: 'Uložené verzie',
|
||||
ONLINE_HELP: 'online pomoc',
|
||||
UPGRADE_IMPORTANT_MESSAGES: 'Aktualizovať dôležité správy',
|
||||
UPGRADE_IMPORTANT_MESSAGES_1: 'Táto aktualizácia vyžaduje reštart základných nastavení. Uistite sa, že najprv stiahnete systémovú zálohu pred pokračovaním, a potom nahrajte tento súbor po instalácii novej verzie.',
|
||||
@@ -345,14 +347,19 @@ const sk: Translation = {
|
||||
TEST_EMAIL_SUCCESSFUL: 'Test email bol úspešne odoslaný',
|
||||
SYSTEM_NAME: 'Názov systému',
|
||||
COMMANDS: 'Príkazy',
|
||||
COMMANDS_HELP_1: 'Definujte vlastné príkazy pre magistrali EMS',
|
||||
COMMANDS_HELP_1: 'Nižšie definujte opakovane použiteľné pomenované príkazy. Tieto môžu byť spustené z konzoly, API/MQTT alebo odkazované plánovačom',
|
||||
COMMAND_EXECUTED: 'Príkaz vykonaný úspešne',
|
||||
RUN: 'Volať príkaz',
|
||||
INTERNET_CONNECTION_CHECK: 'Kontrola online pre dostupné verzie',
|
||||
UPLOAD_BACKUP_TEXT: 'Kliknite, aby ste vybrali, alebo drop súbor zálohy (.json) sem',
|
||||
UPLOAD_FIRMWARE_TEXT: 'Kliknite, aby ste vybrali, alebo drop súbor firmware .bin/.md5 sem',
|
||||
UPLOAD_MD5_RECEIVED: 'Suma MD5 prijatá, teraz nahrajte firmware .bin',
|
||||
UPLOAD_MD5_MATCHED: 'MD5 súhlasí, firmvér sa inštaluje...'
|
||||
UPLOAD_MD5_MATCHED: 'MD5 súhlasí, firmvér sa inštaluje...',
|
||||
RECOVERY_APP: 'Aplikácia na obnovu',
|
||||
RECOVERY_NOT_INSTALLED: 'Nenainštalované',
|
||||
RECOVERY_INSTALLED: 'Firmvér obnovy nainštalovaný',
|
||||
RESTART_INTO_RECOVERY: 'Reštartovať do aplikácie na obnovu',
|
||||
RECOVERY_NOT_FOUND: 'Aplikácia na obnovu sa online nenašla'
|
||||
};
|
||||
|
||||
export default sk;
|
||||
|
||||
@@ -42,6 +42,7 @@ const sv: Translation = {
|
||||
APPLY_CHANGES: 'Utför ändringar ({0})',
|
||||
UPDATE: 'Uppdatera',
|
||||
REMOVE: 'Ta bort',
|
||||
RESET: 'Återställ',
|
||||
PROBLEM_UPDATING: 'Problem vid uppdatering',
|
||||
PROBLEM_LOADING: 'Problem vid hämtning',
|
||||
ANALOG_SENSOR: 'analog sensor',
|
||||
@@ -141,6 +142,8 @@ const sv: Translation = {
|
||||
CUSTOMIZATIONS_HELP_6: 'Ta bort',
|
||||
SELECT_DEVICE: 'Välj en enhet',
|
||||
SET_ALL: 'ställ in alla',
|
||||
MODIFIED: 'ändrade',
|
||||
RESET_SELECTED: 'Återställ markerade',
|
||||
OPTIONS: 'Alternativ',
|
||||
NAME: 'Namn',
|
||||
CUSTOMIZATIONS_RESET: 'Är du säker på att du vill ta bort alla anpassningar?',
|
||||
@@ -235,7 +238,7 @@ const sv: Translation = {
|
||||
TIME_ZONE: 'Tidszon',
|
||||
ACCESS_POINT: 'Accesspunkt',
|
||||
AP_PROVIDE: 'Aktivera accesspunkt',
|
||||
AP_PROVIDE_TEXT_2: 'när nätverksanslutningen är bortkopplad',
|
||||
AP_PROVIDE_TEXT_2: 'när nätverksanslutningen inte hittas',
|
||||
AP_PROVIDE_TEXT_3: 'aldrig',
|
||||
AP_PREFERRED_CHANNEL: 'Kanal',
|
||||
AP_HIDE_SSID: 'Göm SSID',
|
||||
@@ -336,7 +339,6 @@ const sv: Translation = {
|
||||
SWITCH_RELEASE_TYPE: 'Byt till {0} utgåva',
|
||||
FIRMWARE_VERSION_INFO: 'Information om firmwareversion',
|
||||
USER_PROFILE: 'Användarprofil',
|
||||
STORED_VERSIONS: 'Lagrad versioner',
|
||||
ONLINE_HELP: 'online hjälp',
|
||||
UPGRADE_IMPORTANT_MESSAGES: 'Uppdatera viktiga meddelanden',
|
||||
UPGRADE_IMPORTANT_MESSAGES_1: 'Denna uppdatering kräver en fabriksåterställning. Se till att du först laddar ned en System Backup innan du fortsätter, och ladda upp denna fil efter att den nya versionen är installerad.',
|
||||
@@ -352,7 +354,12 @@ const sv: Translation = {
|
||||
UPLOAD_BACKUP_TEXT: 'Klicka för att välja, eller drop en säkerhetskopia (.json) här',
|
||||
UPLOAD_FIRMWARE_TEXT: 'Klicka för att välja, eller drop en firmware .bin/.md5 här',
|
||||
UPLOAD_MD5_RECEIVED: 'MD5 mottagen, ladda nu upp firmware .bin-filen',
|
||||
UPLOAD_MD5_MATCHED: 'MD5 stämmer, firmware installeras...'
|
||||
UPLOAD_MD5_MATCHED: 'MD5 stämmer, firmware installeras...',
|
||||
RECOVERY_APP: 'Återställningsapp',
|
||||
RECOVERY_NOT_INSTALLED: 'Inte installerad',
|
||||
RECOVERY_INSTALLED: 'Återställningsfirmware installerad',
|
||||
RESTART_INTO_RECOVERY: 'Starta om i återställningsappen',
|
||||
RECOVERY_NOT_FOUND: 'Återställningsappen hittades inte online'
|
||||
};
|
||||
|
||||
export default sv;
|
||||
|
||||
@@ -42,6 +42,7 @@ const tr: Translation = {
|
||||
APPLY_CHANGES: 'Değişiklikleri Uygula ({0})',
|
||||
UPDATE: 'Update',
|
||||
REMOVE: 'Kaldır',
|
||||
RESET: 'Sıfırla',
|
||||
PROBLEM_UPDATING: 'Güncelleme Sorunu',
|
||||
PROBLEM_LOADING: 'Yükleme Sorunu',
|
||||
ANALOG_SENSOR: 'Analog Sensör',
|
||||
@@ -141,6 +142,8 @@ const tr: Translation = {
|
||||
CUSTOMIZATIONS_HELP_6: 'remove from memory',
|
||||
SELECT_DEVICE: 'Bir cihaz seç',
|
||||
SET_ALL: 'hepsini ayarla',
|
||||
MODIFIED: 'değiştirilmiş',
|
||||
RESET_SELECTED: 'Seçilenleri sıfırla',
|
||||
OPTIONS: 'Seçenekler',
|
||||
NAME: 'İsim',
|
||||
CUSTOMIZATIONS_RESET: 'Bütün özelleştirmeleri kaldırmak istediğinizden emin misiniz?',
|
||||
@@ -235,7 +238,7 @@ const tr: Translation = {
|
||||
TIME_ZONE: 'Saat dilimi',
|
||||
ACCESS_POINT: 'Erişim Noktası',
|
||||
AP_PROVIDE: 'Erişim noktasını çalıştır',
|
||||
AP_PROVIDE_TEXT_2: 'Ağ bağlantısı kesildiğinde',
|
||||
AP_PROVIDE_TEXT_2: 'Ağ bağlantısı bulunamadığında',
|
||||
AP_PROVIDE_TEXT_3: 'asla',
|
||||
AP_PREFERRED_CHANNEL: 'Tercih edilen kanal',
|
||||
AP_HIDE_SSID: 'SSID yi gizle',
|
||||
@@ -336,7 +339,6 @@ const tr: Translation = {
|
||||
SWITCH_RELEASE_TYPE: '{0} sürümüne geç',
|
||||
FIRMWARE_VERSION_INFO: 'Firmware Sürüm Bilgisi',
|
||||
USER_PROFILE: 'Kullanıcı Profili',
|
||||
STORED_VERSIONS: 'Kaydedilmiş Sürümler',
|
||||
ONLINE_HELP: 'online yardım',
|
||||
UPGRADE_IMPORTANT_MESSAGES: 'Önemli Mesajları Güncelle',
|
||||
UPGRADE_IMPORTANT_MESSAGES_1: 'Bu güncelleme továrnı ayarlarını gerektirir. Yapılandırmanızı ve ayarlarınızı önce yedekleyin ve ardından yeni sürüm yüklendikten sonra yükleyin.',
|
||||
@@ -352,7 +354,12 @@ const tr: Translation = {
|
||||
UPLOAD_BACKUP_TEXT: 'Bir yedek dosyası (.json), buraya drop yapın',
|
||||
UPLOAD_FIRMWARE_TEXT: 'Bir firmware .bin dosyası, buraya drop yapın',
|
||||
UPLOAD_MD5_RECEIVED: 'Özet alındı, şimdi firmware .bin dosyasını yükleyin',
|
||||
UPLOAD_MD5_MATCHED: 'MD5 eşleşti, firmware yükleniyor...'
|
||||
UPLOAD_MD5_MATCHED: 'MD5 eşleşti, firmware yükleniyor...',
|
||||
RECOVERY_APP: 'Kurtarma Uygulaması',
|
||||
RECOVERY_NOT_INSTALLED: 'Yüklü değil',
|
||||
RECOVERY_INSTALLED: 'Kurtarma firmware yüklendi',
|
||||
RESTART_INTO_RECOVERY: 'Kurtarma uygulamasına yeniden başlat',
|
||||
RECOVERY_NOT_FOUND: 'Kurtarma uygulaması çevrimiçi bulunamadı'
|
||||
};
|
||||
|
||||
export default tr;
|
||||
|
||||
@@ -0,0 +1,517 @@
|
||||
import { type ReactNode, useState } from 'react';
|
||||
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import HealthAndSafetyIcon from '@mui/icons-material/HealthAndSafety';
|
||||
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
|
||||
import LogoutIcon from '@mui/icons-material/Logout';
|
||||
import MemoryIcon from '@mui/icons-material/Memory';
|
||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||
import RestartAltIcon from '@mui/icons-material/RestartAlt';
|
||||
import SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore';
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Divider,
|
||||
IconButton,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemAvatar,
|
||||
ListItemText,
|
||||
MenuItem,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableRow,
|
||||
TextField,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
|
||||
import { dialogStyle } from 'CustomTheme';
|
||||
import ButtonRow from 'components/ButtonRow';
|
||||
import MessageBox from 'components/MessageBox';
|
||||
import SectionContent from 'components/SectionContent';
|
||||
import { toast } from 'components/toast';
|
||||
|
||||
import FirmwareUpload from './FirmwareUpload';
|
||||
import { HttpError, factoryReset, restart, setBootPartition } from './api';
|
||||
import type { RecoveryPartition, RecoveryStatus } from './types';
|
||||
|
||||
const formatMB = (bytes: number) => `${(bytes / 1048576).toFixed(2)} MB`;
|
||||
|
||||
const formatInstallDate = (epoch: number) =>
|
||||
epoch > 0 ? new Date(epoch * 1000).toLocaleString() : 'unknown';
|
||||
|
||||
const formatUptime = (seconds: number) => {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = seconds % 60;
|
||||
return `${h}h ${m}m ${s}s`;
|
||||
};
|
||||
|
||||
const ETH_STATE_TEXT: Record<
|
||||
Exclude<RecoveryStatus['eth_state'], undefined | 'connected'>,
|
||||
string
|
||||
> = {
|
||||
disabled: 'disabled, the clock mode conflicts with PSRAM',
|
||||
failed: 'failed to start, no PHY found',
|
||||
no_link: 'no cable connected',
|
||||
connecting: 'waiting for an IP address'
|
||||
};
|
||||
|
||||
const failedToStart = (p: RecoveryPartition) =>
|
||||
p.ota_state === 'aborted' || p.ota_state === 'invalid';
|
||||
|
||||
// why the device is running the recovery, if it can be worked out
|
||||
const recoveryReason = (status: RecoveryStatus, failed: RecoveryPartition[]) => {
|
||||
if (status.reason === 'crash') {
|
||||
return 'EMS-ESP crashed repeatedly and switched to the recovery.';
|
||||
}
|
||||
if (status.reason === 'request') {
|
||||
return undefined; // started on purpose from EMS-ESP, e.g. a long press of the button
|
||||
}
|
||||
if (failed.length > 0) {
|
||||
return `The firmware in ${failed.map((p) => p.label).join(' and ')} failed to start.`;
|
||||
}
|
||||
if (status.reset_reason === 'panic' || status.reset_reason === 'watchdog') {
|
||||
return 'EMS-ESP crashed while starting.';
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const SectionTitle = ({ children }: { children: ReactNode }) => (
|
||||
<Typography sx={{ pb: 1 }} variant="h6" color="primary">
|
||||
{children}
|
||||
</Typography>
|
||||
);
|
||||
|
||||
// same look as the version info dialog in the EMS-ESP app
|
||||
const InfoRow = ({ label, value }: { label: string; value: ReactNode }) => (
|
||||
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
|
||||
<TableCell
|
||||
component="th"
|
||||
scope="row"
|
||||
sx={{
|
||||
color: 'lightblue',
|
||||
borderBottom: 'none',
|
||||
pr: 1,
|
||||
py: 0.5,
|
||||
fontSize: 13,
|
||||
width: 140
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</TableCell>
|
||||
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
|
||||
{value}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
|
||||
interface DashboardProps {
|
||||
status: RecoveryStatus;
|
||||
onRestarting: (target: string) => void;
|
||||
onSignOut: () => void;
|
||||
}
|
||||
|
||||
const Dashboard = ({ status, onRestarting, onSignOut }: DashboardProps) => {
|
||||
const [confirmBoot, setConfirmBoot] = useState<RecoveryPartition>();
|
||||
const [infoPartition, setInfoPartition] = useState<RecoveryPartition>();
|
||||
const [confirmFactoryReset, setConfirmFactoryReset] = useState(false);
|
||||
const [pickedTarget, setPickedTarget] = useState<string>();
|
||||
const uploadTarget = pickedTarget ?? status.upload_target;
|
||||
|
||||
const handleError = (error: unknown) => {
|
||||
if (error instanceof HttpError && error.status === 401) {
|
||||
toast.warning('Session expired, please sign in again');
|
||||
onSignOut();
|
||||
} else {
|
||||
toast.error((error as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
const doBoot = async (partition: RecoveryPartition) => {
|
||||
setConfirmBoot(undefined);
|
||||
try {
|
||||
await setBootPartition(partition.label);
|
||||
onRestarting(partition.label);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpError && error.status === 400) {
|
||||
toast.error(`${partition.label} does not contain a bootable firmware`);
|
||||
} else {
|
||||
handleError(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const doRestart = async (target: string) => {
|
||||
try {
|
||||
await restart();
|
||||
onRestarting(target);
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
}
|
||||
};
|
||||
|
||||
const doFactoryReset = async () => {
|
||||
setConfirmFactoryReset(false);
|
||||
try {
|
||||
await factoryReset();
|
||||
onRestarting(status.boot);
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
}
|
||||
};
|
||||
|
||||
const otaPartitions = status.partitions.filter((p) => !p.factory);
|
||||
const hasFirmware = otaPartitions.some((p) => p.valid);
|
||||
const reason = recoveryReason(
|
||||
status,
|
||||
otaPartitions.filter((p) => p.valid && failedToStart(p))
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionContent>
|
||||
{reason && <MessageBox sx={{ mb: 2 }} level="error" message={reason} />}
|
||||
<MessageBox level={hasFirmware ? 'info' : 'warning'}>
|
||||
{hasFirmware
|
||||
? 'This device is running the EMS-ESP Recovery. Start one of the installed firmwares, or upload a new one.'
|
||||
: 'No EMS-ESP firmware is installed. Upload a firmware to continue.'}
|
||||
</MessageBox>
|
||||
</SectionContent>
|
||||
|
||||
<SectionContent>
|
||||
<SectionTitle>Firmware</SectionTitle>
|
||||
<List dense>
|
||||
{status.partitions.map((p, index) => (
|
||||
<Box key={p.label}>
|
||||
{index > 0 && <Divider component="li" />}
|
||||
<ListItem
|
||||
secondaryAction={
|
||||
!p.factory &&
|
||||
p.valid && (
|
||||
<Button
|
||||
startIcon={<PlayArrowIcon />}
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
size="small"
|
||||
onClick={() => setConfirmBoot(p)}
|
||||
>
|
||||
Start
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
<ListItemAvatar>
|
||||
<Avatar
|
||||
sx={{
|
||||
bgcolor: p.factory
|
||||
? '#5d89f7'
|
||||
: p.valid
|
||||
? '#2e586a'
|
||||
: '#5f5f5f'
|
||||
}}
|
||||
>
|
||||
{p.factory ? <HealthAndSafetyIcon /> : <MemoryIcon />}
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
flexWrap: 'wrap'
|
||||
}}
|
||||
>
|
||||
<span>{p.factory ? `Recovery (${p.label})` : p.label}</span>
|
||||
{p.label === status.running && (
|
||||
<Chip size="small" color="success" label="running" />
|
||||
)}
|
||||
{p.label === status.boot && p.label !== status.running && (
|
||||
<Chip size="small" color="secondary" label="next boot" />
|
||||
)}
|
||||
{p.label === uploadTarget && (
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
label="upload target"
|
||||
/>
|
||||
)}
|
||||
{!p.valid && (
|
||||
<Chip
|
||||
size="small"
|
||||
color="warning"
|
||||
variant="outlined"
|
||||
label="empty"
|
||||
/>
|
||||
)}
|
||||
{p.valid && failedToStart(p) && (
|
||||
<Chip size="small" color="error" label="failed to start" />
|
||||
)}
|
||||
{p.valid && p.ota_state === 'pending' && (
|
||||
<Chip
|
||||
size="small"
|
||||
color="warning"
|
||||
variant="outlined"
|
||||
label="unconfirmed"
|
||||
/>
|
||||
)}
|
||||
{p.valid && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setInfoPartition(p)}
|
||||
aria-label="Firmware version info"
|
||||
sx={{ p: 0.5 }}
|
||||
>
|
||||
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
secondary={
|
||||
p.valid
|
||||
? `${p.version ? 'v' + p.version + ' · ' : ''}${formatMB(p.image_size)} of ${formatMB(p.size)}`
|
||||
: formatMB(p.size)
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
</Box>
|
||||
))}
|
||||
</List>
|
||||
</SectionContent>
|
||||
|
||||
<SectionContent>
|
||||
<SectionTitle>Upload Firmware</SectionTitle>
|
||||
<Typography sx={{ pb: 2 }} variant="body2" color="text.secondary">
|
||||
The firmware is started automatically after the upload. To enable the MD5
|
||||
check, upload the matching .md5 file first.
|
||||
</Typography>
|
||||
<TextField
|
||||
select
|
||||
size="small"
|
||||
sx={{ mb: 2, minWidth: 260 }}
|
||||
label="Write to"
|
||||
value={uploadTarget}
|
||||
onChange={(e) => setPickedTarget(e.target.value)}
|
||||
>
|
||||
{otaPartitions.map((p) => (
|
||||
<MenuItem key={p.label} value={p.label}>
|
||||
{p.label}
|
||||
{p.valid
|
||||
? ` (replaces ${p.version ? 'v' + p.version : 'installed firmware'})`
|
||||
: ' (empty)'}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<FirmwareUpload
|
||||
target={uploadTarget}
|
||||
chip={status.chip}
|
||||
onUploaded={() => void doRestart(uploadTarget)}
|
||||
onUnauthorized={onSignOut}
|
||||
/>
|
||||
</SectionContent>
|
||||
|
||||
<SectionContent>
|
||||
<SectionTitle>System</SectionTitle>
|
||||
<Table size="small" sx={{ borderCollapse: 'collapse', minWidth: 0 }}>
|
||||
<TableBody>
|
||||
<InfoRow
|
||||
label="Recovery version"
|
||||
value={'v' + status.recovery_version}
|
||||
/>
|
||||
<InfoRow
|
||||
label="Chip"
|
||||
value={`${status.chip}, ${formatMB(status.flash_size)} flash${status.psram_size ? ', ' + formatMB(status.psram_size) + ' PSRAM' : ''}`}
|
||||
/>
|
||||
<InfoRow
|
||||
label="Free heap"
|
||||
value={`${Math.round(status.free_heap / 1024)} KB`}
|
||||
/>
|
||||
<InfoRow label="Uptime" value={formatUptime(status.uptime)} />
|
||||
<InfoRow label="MAC address" value={status.mac} />
|
||||
<InfoRow
|
||||
label="Access point"
|
||||
value={`${status.ap_ssid} on ${status.ap_ip} (${status.ap_clients} connected)`}
|
||||
/>
|
||||
{status.eth_state && (
|
||||
<InfoRow
|
||||
label="Ethernet"
|
||||
value={
|
||||
status.eth_state === 'connected'
|
||||
? `connected, ${status.eth_ip ?? ''}${status.static_ip ? ' (static IP)' : ''}`
|
||||
: ETH_STATE_TEXT[status.eth_state]
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<InfoRow
|
||||
label="WiFi"
|
||||
value={
|
||||
!status.wifi_ssid
|
||||
? 'not configured'
|
||||
: status.wifi_connected
|
||||
? `${status.wifi_ssid}, ${status.wifi_ip ?? ''} (${status.wifi_rssi ?? 0} dBm)${status.static_ip ? ', static IP' : ''}`
|
||||
: !status.wifi_started
|
||||
? status.eth_state === 'connected'
|
||||
? 'not used, Ethernet is connected'
|
||||
: 'waiting for Ethernet'
|
||||
: `connecting to ${status.wifi_ssid}`
|
||||
}
|
||||
/>
|
||||
<InfoRow label="Hostname" value={status.hostname} />
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<ButtonRow>
|
||||
{!status.disable_reset && (
|
||||
<Button
|
||||
startIcon={<SettingsBackupRestoreIcon />}
|
||||
variant="outlined"
|
||||
color="error"
|
||||
onClick={() => setConfirmFactoryReset(true)}
|
||||
>
|
||||
Factory Reset
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
startIcon={<RestartAltIcon />}
|
||||
variant="outlined"
|
||||
color="success"
|
||||
onClick={() => void doRestart(status.boot)}
|
||||
>
|
||||
Restart{status.boot !== status.running ? ` (boots ${status.boot})` : ''}
|
||||
</Button>
|
||||
<Button
|
||||
startIcon={<LogoutIcon />}
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
sx={{ float: 'right' }}
|
||||
onClick={onSignOut}
|
||||
>
|
||||
Sign Out
|
||||
</Button>
|
||||
</ButtonRow>
|
||||
</SectionContent>
|
||||
|
||||
<Dialog
|
||||
sx={dialogStyle}
|
||||
open={confirmFactoryReset}
|
||||
onClose={() => setConfirmFactoryReset(false)}
|
||||
>
|
||||
<DialogTitle>Factory Reset</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
Are you sure you want to reset EMS-ESP to its factory defaults?
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
startIcon={<CancelIcon />}
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={() => setConfirmFactoryReset(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
startIcon={<SettingsBackupRestoreIcon />}
|
||||
variant="outlined"
|
||||
color="error"
|
||||
onClick={() => void doFactoryReset()}
|
||||
>
|
||||
Factory Reset
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
sx={dialogStyle}
|
||||
open={!!confirmBoot}
|
||||
onClose={() => setConfirmBoot(undefined)}
|
||||
>
|
||||
<DialogTitle>Start Firmware</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
Restart and run
|
||||
{confirmBoot?.version ? ` EMS-ESP v${confirmBoot.version}` : ''} from{' '}
|
||||
{confirmBoot?.label}?
|
||||
{confirmBoot && failedToStart(confirmBoot) && (
|
||||
<MessageBox
|
||||
sx={{ mt: 2 }}
|
||||
level="warning"
|
||||
message="This firmware failed to start before. If it fails again the device falls back to the other firmware, or to the recovery."
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
startIcon={<CancelIcon />}
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={() => setConfirmBoot(undefined)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
startIcon={<PlayArrowIcon />}
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={() => confirmBoot && void doBoot(confirmBoot)}
|
||||
>
|
||||
Start
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
sx={dialogStyle}
|
||||
open={!!infoPartition}
|
||||
onClose={() => setInfoPartition(undefined)}
|
||||
>
|
||||
<DialogTitle>Firmware Version Info</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
{infoPartition && (
|
||||
<Table size="small" sx={{ borderCollapse: 'collapse', minWidth: 0 }}>
|
||||
<TableBody>
|
||||
<InfoRow
|
||||
label="Version"
|
||||
value={
|
||||
infoPartition.version ? 'v' + infoPartition.version : 'unknown'
|
||||
}
|
||||
/>
|
||||
<InfoRow
|
||||
label="Type"
|
||||
value={infoPartition.factory ? 'Recovery App' : 'EMS-ESP Firmware'}
|
||||
/>
|
||||
<InfoRow label="Partition" value={infoPartition.label} />
|
||||
<InfoRow
|
||||
label="Size"
|
||||
value={`${formatMB(infoPartition.image_size)} of ${formatMB(infoPartition.size)}`}
|
||||
/>
|
||||
<InfoRow
|
||||
label="Install Date"
|
||||
value={formatInstallDate(infoPartition.install_date)}
|
||||
/>
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={() => setInfoPartition(undefined)}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
@@ -0,0 +1,208 @@
|
||||
import { type ChangeEvent, type DragEvent, useRef, useState } from 'react';
|
||||
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
|
||||
import UploadIcon from '@mui/icons-material/Upload';
|
||||
import { Box, Button, Typography, styled } from '@mui/material';
|
||||
|
||||
import { toast } from 'components/toast';
|
||||
import { LinearProgressWithLabel } from 'components/upload/LinearProgressWithLabel';
|
||||
|
||||
import { HttpError, type UploadHandle, uploadFile } from './api';
|
||||
|
||||
const DocumentUploader = styled(Box)<{ active?: boolean }>(({ theme, active }) => ({
|
||||
border: `2px dashed ${active ? '#6dc24b' : '#4282fe'}`,
|
||||
backgroundColor: '#2e3339',
|
||||
padding: theme.spacing(1.25),
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: theme.spacing(1),
|
||||
cursor: 'pointer',
|
||||
minHeight: '120px',
|
||||
transition: 'border-color 0.2s ease-in-out'
|
||||
}));
|
||||
|
||||
const uploadErrorMessage = (status: number, chip: string) => {
|
||||
switch (status) {
|
||||
case 400:
|
||||
return 'Invalid target partition';
|
||||
case 401:
|
||||
case 403:
|
||||
return 'Not authorized, please sign in again';
|
||||
case 409:
|
||||
return 'This is the recovery firmware, it can only be installed from EMS-ESP';
|
||||
case 503:
|
||||
return `This firmware is not built for the ${chip}`;
|
||||
case 507:
|
||||
return 'The firmware does not fit in the partition';
|
||||
case 500:
|
||||
return 'Upload failed, flash write error or MD5 mismatch';
|
||||
default:
|
||||
return 'Upload failed. Only EMS-ESP firmware .bin and .md5 files are accepted';
|
||||
}
|
||||
};
|
||||
|
||||
interface FirmwareUploadProps {
|
||||
target: string;
|
||||
chip: string;
|
||||
onUploaded: () => void;
|
||||
onUnauthorized: () => void;
|
||||
}
|
||||
|
||||
const FirmwareUpload = ({
|
||||
target,
|
||||
chip,
|
||||
onUploaded,
|
||||
onUnauthorized
|
||||
}: FirmwareUploadProps) => {
|
||||
const [file, setFile] = useState<File>();
|
||||
const [dragged, setDragged] = useState(false);
|
||||
const [md5, setMd5] = useState<string>();
|
||||
const [progress, setProgress] = useState<number>();
|
||||
const uploadRef = useRef<UploadHandle | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const selectFile = (selected?: File) => {
|
||||
if (!selected) return;
|
||||
if (/\.(bin|md5)$/i.test(selected.name)) {
|
||||
setFile(selected);
|
||||
} else {
|
||||
toast.warning('Please select an EMS-ESP firmware .bin or .md5 file');
|
||||
}
|
||||
};
|
||||
|
||||
const onFileChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
selectFile(e.target.files?.[0]);
|
||||
e.target.value = ''; // allow the same file to be selected again
|
||||
};
|
||||
|
||||
const onDrop = (e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setDragged(false);
|
||||
selectFile(e.dataTransfer.files[0]);
|
||||
};
|
||||
|
||||
const startUpload = async () => {
|
||||
if (!file) return;
|
||||
setProgress(0);
|
||||
uploadRef.current = uploadFile(file, target, setProgress);
|
||||
try {
|
||||
const result = await uploadRef.current.promise;
|
||||
if (result.md5) {
|
||||
setMd5(result.md5);
|
||||
toast.success('MD5 received, now upload the firmware .bin');
|
||||
} else {
|
||||
if (result.md5_ok) {
|
||||
toast.success('Firmware MD5 matches');
|
||||
}
|
||||
toast.success(`Firmware written to ${target}`);
|
||||
onUploaded();
|
||||
}
|
||||
} catch (error) {
|
||||
const status = error instanceof HttpError ? error.status : 0;
|
||||
if (status === -1) {
|
||||
toast.warning('Upload aborted');
|
||||
} else {
|
||||
toast.error(uploadErrorMessage(status, chip));
|
||||
if (status === 401 || status === 403) onUnauthorized();
|
||||
}
|
||||
} finally {
|
||||
uploadRef.current = null;
|
||||
setProgress(undefined);
|
||||
setFile(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
if (progress !== undefined) {
|
||||
return (
|
||||
<>
|
||||
<Typography sx={{ mb: 1 }}>
|
||||
Uploading {file?.name} to {target}…
|
||||
</Typography>
|
||||
<LinearProgressWithLabel value={progress} />
|
||||
<Button
|
||||
sx={{ mt: 2 }}
|
||||
startIcon={<CancelIcon />}
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={() => uploadRef.current?.abort()}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DocumentUploader
|
||||
active={!!(file || dragged)}
|
||||
onDrop={onDrop}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDragged(true);
|
||||
}}
|
||||
onDragLeave={() => setDragged(false)}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<CloudUploadIcon sx={{ mr: 4 }} color="primary" fontSize="large" />
|
||||
<Typography>
|
||||
{md5
|
||||
? 'MD5 received. Now drop the firmware .bin file here, or click to browse'
|
||||
: 'Drop an EMS-ESP firmware .bin file here, or click to browse'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<input
|
||||
type="file"
|
||||
hidden
|
||||
ref={inputRef}
|
||||
accept=".bin,.md5"
|
||||
onChange={onFileChange}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
{file && (
|
||||
<>
|
||||
<Typography sx={{ fontSize: 14, color: '#6dc24b', my: 1 }}>
|
||||
{file.name}
|
||||
</Typography>
|
||||
<Box>
|
||||
<Button
|
||||
startIcon={<CancelIcon />}
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setFile(undefined);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
sx={{ ml: 2 }}
|
||||
startIcon={<UploadIcon />}
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void startUpload();
|
||||
}}
|
||||
>
|
||||
Upload
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</DocumentUploader>
|
||||
{md5 && (
|
||||
<Typography sx={{ mt: 2 }} variant="body2" color="success">
|
||||
{'MD5: ' + md5}
|
||||
</Typography>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default FirmwareUpload;
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import {
|
||||
AppBar,
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Toolbar,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
|
||||
import CustomTheme from 'CustomTheme';
|
||||
import MessageBox from 'components/MessageBox';
|
||||
import SectionContent from 'components/SectionContent';
|
||||
import { Toaster } from 'components/toast';
|
||||
|
||||
import Dashboard from './Dashboard';
|
||||
import RestartMonitor from './RestartMonitor';
|
||||
import SignIn from './SignIn';
|
||||
import { getToken, readStatus, setToken } from './api';
|
||||
import type { RecoveryStatus } from './types';
|
||||
|
||||
const STATUS_REFRESH_MS = 5000;
|
||||
|
||||
const RecoveryApp = () => {
|
||||
const [status, setStatus] = useState<RecoveryStatus>();
|
||||
const [error, setError] = useState<string>();
|
||||
const [restartTarget, setRestartTarget] = useState<string>();
|
||||
|
||||
const loadStatus = useCallback(async () => {
|
||||
try {
|
||||
const data = await readStatus();
|
||||
if (!data.authenticated && getToken()) {
|
||||
setToken(null); // stale token, e.g. after the device restarted
|
||||
}
|
||||
setStatus(data);
|
||||
setError(undefined);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadStatus();
|
||||
}, [loadStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!status?.authenticated || restartTarget !== undefined) return;
|
||||
const timer = setInterval(() => void loadStatus(), STATUS_REFRESH_MS);
|
||||
return () => clearInterval(timer);
|
||||
}, [status?.authenticated, restartTarget, loadStatus]);
|
||||
|
||||
const signOut = () => {
|
||||
setToken(null);
|
||||
void loadStatus();
|
||||
};
|
||||
|
||||
let content;
|
||||
if (restartTarget !== undefined && status) {
|
||||
content = (
|
||||
<RestartMonitor
|
||||
target={restartTarget}
|
||||
viaAccessPoint={window.location.hostname === status.ap_ip}
|
||||
hostname={status.hostname}
|
||||
/>
|
||||
);
|
||||
} else if (!status) {
|
||||
content = (
|
||||
<SectionContent>
|
||||
{error ? (
|
||||
<MessageBox level="error" message={error}>
|
||||
<Button
|
||||
sx={{ ml: 2 }}
|
||||
startIcon={<RefreshIcon />}
|
||||
variant="contained"
|
||||
color="error"
|
||||
onClick={() => void loadStatus()}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</MessageBox>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
|
||||
<CircularProgress size={100} />
|
||||
</Box>
|
||||
)}
|
||||
</SectionContent>
|
||||
);
|
||||
} else if (!status.authenticated) {
|
||||
content = <SignIn onSignedIn={() => void loadStatus()} />;
|
||||
} else {
|
||||
content = (
|
||||
<>
|
||||
<AppBar
|
||||
position="fixed"
|
||||
sx={{ boxShadow: 'none', backgroundColor: '#2e586a' }}
|
||||
>
|
||||
<Toolbar>
|
||||
<img
|
||||
src="/app/icon.png"
|
||||
alt="EMS-ESP"
|
||||
style={{ height: 32, marginRight: 16 }}
|
||||
/>
|
||||
<Typography variant="h6">EMS-ESP</Typography>
|
||||
<Typography variant="h6" sx={{ color: '#90caf9' }}>
|
||||
|
|
||||
</Typography>
|
||||
<Typography variant="h6">Recovery</Typography>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
<Box component="main" sx={{ maxWidth: 900, mx: 'auto' }}>
|
||||
<Toolbar />
|
||||
<Dashboard
|
||||
status={status}
|
||||
onRestarting={setRestartTarget}
|
||||
onSignOut={signOut}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CustomTheme>
|
||||
{content}
|
||||
<Toaster />
|
||||
</CustomTheme>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecoveryApp;
|
||||
@@ -0,0 +1,149 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import { Box, Button, LinearProgress, Typography } from '@mui/material';
|
||||
|
||||
import MessageBox from 'components/MessageBox';
|
||||
|
||||
import { STATUS_URL } from './api';
|
||||
|
||||
const POLL_INTERVAL_MS = 1000;
|
||||
const POLL_TIMEOUT_MS = 3000;
|
||||
const FIRST_POLL_DELAY_MS = 1500;
|
||||
// reload anyway if the device never appeared to go down
|
||||
const NO_DOWNTIME_RELOAD_MS = 20000;
|
||||
const GIVE_UP_MS = 90000;
|
||||
|
||||
interface RestartMonitorProps {
|
||||
target: string;
|
||||
viaAccessPoint: boolean;
|
||||
hostname: string;
|
||||
}
|
||||
|
||||
// Waits for the device to go down and come back, then reloads the page. Both the recovery app
|
||||
// and EMS-ESP answer on STATUS_URL (EMS-ESP serves its WebUI for unknown GETs).
|
||||
const RestartMonitor = ({
|
||||
target,
|
||||
viaAccessPoint,
|
||||
hostname
|
||||
}: RestartMonitorProps) => {
|
||||
const [gaveUp, setGaveUp] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const started = Date.now();
|
||||
let seenDown = false;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
let cancelled = false;
|
||||
|
||||
const poll = async () => {
|
||||
let up: boolean;
|
||||
try {
|
||||
const response = await fetch(STATUS_URL, {
|
||||
cache: 'no-store',
|
||||
signal: AbortSignal.timeout(POLL_TIMEOUT_MS)
|
||||
});
|
||||
up = response.ok;
|
||||
} catch {
|
||||
up = false;
|
||||
}
|
||||
if (cancelled) return;
|
||||
|
||||
const elapsed = Date.now() - started;
|
||||
if (!up) {
|
||||
seenDown = true;
|
||||
} else if (seenDown || elapsed > NO_DOWNTIME_RELOAD_MS) {
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
if (elapsed > GIVE_UP_MS) {
|
||||
setGaveUp(true);
|
||||
return;
|
||||
}
|
||||
timer = setTimeout(() => void poll(), POLL_INTERVAL_MS);
|
||||
};
|
||||
|
||||
timer = setTimeout(() => void poll(), FIRST_POLL_DELAY_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: '30%',
|
||||
minWidth: '300px',
|
||||
maxWidth: '500px',
|
||||
backgroundColor: '#393939',
|
||||
border: 2,
|
||||
borderColor: '#565656',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3)',
|
||||
p: 3,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexDirection: 'column'
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src="/app/icon.png"
|
||||
alt="EMS-ESP"
|
||||
style={{ width: '40px', height: '40px', marginBottom: '16px' }}
|
||||
/>
|
||||
<Typography sx={{ textAlign: 'center' }} variant="h6">
|
||||
Restarting{target ? ` from ${target}` : ''}
|
||||
</Typography>
|
||||
|
||||
{gaveUp ? (
|
||||
<>
|
||||
<MessageBox sx={{ mt: 2 }} level="warning">
|
||||
The device has not come back on this address.
|
||||
{viaAccessPoint &&
|
||||
` If you were connected to the recovery access point, reconnect to your normal network and open http://${hostname}.local`}
|
||||
</MessageBox>
|
||||
<Button
|
||||
sx={{ mt: 2 }}
|
||||
startIcon={<RefreshIcon />}
|
||||
variant="outlined"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
Reload
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Typography sx={{ mt: 2, textAlign: 'center' }} variant="h6">
|
||||
Please wait…
|
||||
</Typography>
|
||||
<Box sx={{ width: '100%', mt: 2 }}>
|
||||
<LinearProgress />
|
||||
</Box>
|
||||
{viaAccessPoint && (
|
||||
<Typography
|
||||
sx={{ mt: 2, textAlign: 'center' }}
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
>
|
||||
EMS-ESP does not use the recovery access point. If this page does not
|
||||
reload, reconnect to your normal network and open http://{hostname}
|
||||
.local
|
||||
</Typography>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default RestartMonitor;
|
||||
@@ -0,0 +1,142 @@
|
||||
import { type KeyboardEvent, useState } from 'react';
|
||||
|
||||
import ForwardIcon from '@mui/icons-material/Forward';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
IconButton,
|
||||
InputAdornment,
|
||||
Paper,
|
||||
TextField,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
import type { Theme } from '@mui/material/styles';
|
||||
|
||||
import { toast } from 'components/toast';
|
||||
|
||||
import { HttpError, setToken, signIn } from './api';
|
||||
|
||||
interface SignInProps {
|
||||
onSignedIn: () => void;
|
||||
}
|
||||
|
||||
const SignIn = ({ onSignedIn }: SignInProps) => {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [processing, setProcessing] = useState(false);
|
||||
|
||||
const doSignIn = async () => {
|
||||
if (!username || !password) {
|
||||
toast.warning('Please enter the admin username and password');
|
||||
return;
|
||||
}
|
||||
setProcessing(true);
|
||||
try {
|
||||
const { access_token } = await signIn(username, password);
|
||||
setToken(access_token);
|
||||
onSignedIn();
|
||||
} catch (error) {
|
||||
if (error instanceof HttpError && error.status === 401) {
|
||||
toast.warning('Invalid login details');
|
||||
} else {
|
||||
toast.error('Error: ' + (error as Error).message);
|
||||
}
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Enter') {
|
||||
void doSignIn();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={(theme: Theme) => ({
|
||||
display: 'flex',
|
||||
height: '100vh',
|
||||
margin: 'auto',
|
||||
padding: 2,
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'column',
|
||||
maxWidth: theme.breakpoints.values.sm
|
||||
})}
|
||||
>
|
||||
<Paper
|
||||
sx={(theme) => ({
|
||||
textAlign: 'center',
|
||||
padding: theme.spacing(2),
|
||||
paddingTop: '172px',
|
||||
backgroundImage: 'url("/app/icon.png")',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: '50% ' + theme.spacing(2),
|
||||
width: '100%'
|
||||
})}
|
||||
>
|
||||
<Typography variant="h4">EMS-ESP</Typography>
|
||||
<Typography sx={{ mb: 2 }} variant="h6" color="secondary">
|
||||
Recovery
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1,
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
disabled={processing}
|
||||
sx={{ width: '32ch' }}
|
||||
name="username"
|
||||
label="Username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
slotProps={{ input: { autoCapitalize: 'none', autoCorrect: 'off' } }}
|
||||
/>
|
||||
<TextField
|
||||
disabled={processing}
|
||||
sx={{ width: '32ch' }}
|
||||
name="password"
|
||||
label="Password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
slotProps={{
|
||||
input: {
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
edge="end"
|
||||
>
|
||||
{showPassword ? <VisibilityIcon /> : <VisibilityOffIcon />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
sx={{ mt: 2 }}
|
||||
onClick={doSignIn}
|
||||
disabled={processing}
|
||||
>
|
||||
<ForwardIcon sx={{ mr: 1 }} />
|
||||
Sign In
|
||||
</Button>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default SignIn;
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { RecoveryStatus, UploadResult } from './types';
|
||||
|
||||
const TOKEN_KEY = 'recovery_token';
|
||||
|
||||
export const getToken = (): string | null => sessionStorage.getItem(TOKEN_KEY);
|
||||
|
||||
export const setToken = (token: string | null): void => {
|
||||
if (token) {
|
||||
sessionStorage.setItem(TOKEN_KEY, token);
|
||||
} else {
|
||||
sessionStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
};
|
||||
|
||||
export class HttpError extends Error {
|
||||
readonly status: number;
|
||||
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
const authHeaders = (): Record<string, string> => {
|
||||
const token = getToken();
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
};
|
||||
|
||||
const request = async <T>(
|
||||
method: string,
|
||||
url: string,
|
||||
body?: unknown
|
||||
): Promise<T> => {
|
||||
const init: RequestInit = { method, headers: authHeaders(), cache: 'no-store' };
|
||||
if (body !== undefined) {
|
||||
init.headers = { ...authHeaders(), 'Content-Type': 'application/json' };
|
||||
init.body = JSON.stringify(body);
|
||||
}
|
||||
const response = await fetch(url, init);
|
||||
if (!response.ok) {
|
||||
throw new HttpError(
|
||||
response.status,
|
||||
response.statusText || `HTTP ${response.status}`
|
||||
);
|
||||
}
|
||||
const text = await response.text();
|
||||
return (text ? JSON.parse(text) : undefined) as T;
|
||||
};
|
||||
|
||||
export const STATUS_URL = '/rest/recovery/status';
|
||||
|
||||
export const readStatus = () => request<RecoveryStatus>('GET', STATUS_URL);
|
||||
|
||||
export const signIn = (username: string, password: string) =>
|
||||
request<{ access_token: string }>('POST', '/rest/signIn', { username, password });
|
||||
|
||||
export const setBootPartition = (partition: string) =>
|
||||
request<void>('POST', '/rest/recovery/boot', { partition });
|
||||
|
||||
export const restart = () => request<void>('POST', '/rest/recovery/restart');
|
||||
|
||||
export const factoryReset = () => request<void>('POST', '/rest/recovery/format');
|
||||
|
||||
export interface UploadHandle {
|
||||
promise: Promise<UploadResult>;
|
||||
abort: () => void;
|
||||
}
|
||||
|
||||
// XHR instead of fetch, for upload progress events
|
||||
export const uploadFile = (
|
||||
file: File,
|
||||
partition: string,
|
||||
onProgress: (percent: number) => void
|
||||
): UploadHandle => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
const promise = new Promise<UploadResult>((resolve, reject) => {
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (event.lengthComputable && event.total > 0) {
|
||||
onProgress(Math.round((event.loaded * 100) / event.total));
|
||||
}
|
||||
};
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
try {
|
||||
resolve(
|
||||
(xhr.responseText ? JSON.parse(xhr.responseText) : {}) as UploadResult
|
||||
);
|
||||
} catch {
|
||||
resolve({});
|
||||
}
|
||||
} else {
|
||||
reject(new HttpError(xhr.status, xhr.statusText));
|
||||
}
|
||||
};
|
||||
// the device closes the connection on unsupported files, so there is no status code
|
||||
xhr.onerror = () => reject(new HttpError(0, 'Connection closed'));
|
||||
xhr.onabort = () => reject(new HttpError(-1, 'Aborted'));
|
||||
});
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
xhr.open('POST', `/rest/uploadFile?partition=${encodeURIComponent(partition)}`);
|
||||
for (const [key, value] of Object.entries(authHeaders())) {
|
||||
xhr.setRequestHeader(key, value);
|
||||
}
|
||||
xhr.send(formData);
|
||||
|
||||
return { promise, abort: () => xhr.abort() };
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import RecoveryApp from './RecoveryApp';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<RecoveryApp />
|
||||
</StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,55 @@
|
||||
export interface RecoveryPartition {
|
||||
label: string;
|
||||
factory: boolean;
|
||||
address: number;
|
||||
size: number;
|
||||
valid: boolean;
|
||||
image_size: number;
|
||||
version: string;
|
||||
install_date: number;
|
||||
// bootloader rollback state, empty if the partition has never been selected
|
||||
ota_state: '' | 'new' | 'pending' | 'valid' | 'invalid' | 'aborted';
|
||||
}
|
||||
|
||||
export interface RecoveryStatus {
|
||||
recovery_version: string;
|
||||
authenticated: boolean;
|
||||
hostname: string;
|
||||
chip: string;
|
||||
flash_size: number;
|
||||
psram_size: number;
|
||||
free_heap: number;
|
||||
mac: string;
|
||||
uptime: number;
|
||||
reason: '' | 'crash' | 'request'; // why EMS-ESP switched to the recovery
|
||||
reset_reason:
|
||||
| 'power_on'
|
||||
| 'external'
|
||||
| 'software'
|
||||
| 'panic'
|
||||
| 'watchdog'
|
||||
| 'brownout'
|
||||
| 'other';
|
||||
ap_ssid: string;
|
||||
ap_ip: string;
|
||||
ap_clients: number;
|
||||
wifi_ssid: string;
|
||||
wifi_started: boolean; // false while waiting for Ethernet, or when Ethernet is connected
|
||||
wifi_connected: boolean;
|
||||
wifi_ip?: string;
|
||||
wifi_rssi?: number;
|
||||
static_ip: boolean;
|
||||
disable_reset: boolean; // factory reset is disabled in the EMS-ESP settings
|
||||
// only on boards with Ethernet configured
|
||||
eth_state?: 'disabled' | 'failed' | 'no_link' | 'connecting' | 'connected';
|
||||
eth_ip?: string;
|
||||
running: string;
|
||||
boot: string;
|
||||
upload_target: string;
|
||||
partitions: RecoveryPartition[];
|
||||
}
|
||||
|
||||
export interface UploadResult {
|
||||
md5?: string;
|
||||
md5_ok?: boolean;
|
||||
}
|
||||
@@ -51,7 +51,7 @@ export interface SystemStatus {
|
||||
free_caps: number;
|
||||
model: string;
|
||||
board: string;
|
||||
has_loader: boolean;
|
||||
recovery_version?: string;
|
||||
has_partition: boolean;
|
||||
partitions: {
|
||||
partition: string;
|
||||
|
||||
@@ -12,6 +12,7 @@ const KB_DIVISOR = 1024;
|
||||
const REPEAT_CHAR = '=';
|
||||
const REPEAT_COUNT = 50;
|
||||
const DEFAULT_OUT_DIR = 'dist';
|
||||
const RECOVERY_OUT_DIR = 'dist-recovery';
|
||||
const ES_TARGET = 'es2020';
|
||||
const CHUNK_SIZE_WARNING_LIMIT = 1024;
|
||||
const ASSETS_INLINE_LIMIT = 4096;
|
||||
@@ -205,6 +206,65 @@ const imageOptimizationPlugin = {
|
||||
|
||||
export default defineConfig(
|
||||
async ({ command, mode }: { command: string; mode: string }) => {
|
||||
if (command === 'serve' && mode === 'recovery') {
|
||||
console.log('Preparing Recovery WebUI with mock server');
|
||||
const recoveryMockUrl = pathToFileURL(
|
||||
path.resolve(import.meta.dirname, '../mock-api/recoveryMock.js')
|
||||
).href;
|
||||
const { default: recoveryMock } = (await import(
|
||||
/* @vite-ignore */ recoveryMockUrl
|
||||
)) as { default: () => PluginOption };
|
||||
return {
|
||||
plugins: [...createBasePlugins(true, false), recoveryMock()],
|
||||
resolve: {
|
||||
alias: RESOLVE_ALIASES,
|
||||
extensions: RESOLVE_EXTENSIONS,
|
||||
tsconfigPaths: true
|
||||
},
|
||||
server: {
|
||||
open: '/recovery.html',
|
||||
port: 3001
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (mode === 'recovery') {
|
||||
console.log('Preparing for Recovery WebUI, optimized build');
|
||||
return {
|
||||
plugins: [...createBasePlugins(false, true), imageOptimizationPlugin],
|
||||
resolve: {
|
||||
alias: RESOLVE_ALIASES,
|
||||
extensions: RESOLVE_EXTENSIONS,
|
||||
tsconfigPaths: true
|
||||
},
|
||||
build: {
|
||||
...createBaseBuildConfig(),
|
||||
outDir: RECOVERY_OUT_DIR,
|
||||
emptyOutDir: true,
|
||||
minify: 'terser' as const,
|
||||
terserOptions: createProductionTerserOptions(),
|
||||
rollupOptions: {
|
||||
input: path.resolve(import.meta.dirname, 'recovery.html'),
|
||||
checks: {
|
||||
pluginTimings: false
|
||||
},
|
||||
treeshake: {
|
||||
moduleSideEffects: false,
|
||||
propertyReadSideEffects: false as const,
|
||||
unknownGlobalSideEffects: false
|
||||
},
|
||||
output: {
|
||||
chunkFileNames: 'assets/[name]-[hash].js',
|
||||
entryFileNames: 'assets/[name]-[hash].js',
|
||||
assetFileNames: 'assets/[name]-[hash].[ext]',
|
||||
manualChunks,
|
||||
sourcemap: false
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (command === 'serve') {
|
||||
console.log(`Preparing for standalone build with server, mode=${mode}`);
|
||||
const mockServerUrl = pathToFileURL(
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
#include "OtaUpdater.h"
|
||||
|
||||
#include <Update.h>
|
||||
#include <esp_app_format.h>
|
||||
#include <esp_image_format.h>
|
||||
#ifdef OTA_UPDATER_ANY_PARTITION
|
||||
#include <esp_heap_caps.h>
|
||||
#include <esp_partition.h>
|
||||
#endif
|
||||
|
||||
#include <cstddef>
|
||||
|
||||
static String getFilenameExtension(const String & filename) {
|
||||
const auto pos = filename.lastIndexOf('.');
|
||||
if (pos != -1) {
|
||||
return filename.substring(static_cast<unsigned int>(pos) + 1);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
OtaUpdater::FileType OtaUpdater::classify(const String & filename, size_t filesize, size_t min_firmware_size) {
|
||||
const String extension = getFilenameExtension(filename);
|
||||
if (extension == "bin" && filename.endsWith("littlefs.bin")) {
|
||||
return FileType::FILESYSTEM;
|
||||
}
|
||||
if (extension == "bin") {
|
||||
String lower = filename;
|
||||
lower.toLowerCase();
|
||||
const size_t min_size =
|
||||
lower.indexOf("recovery") >= 0 ? MIN_RECOVERY_FIRMWARE_SIZE : min_firmware_size;
|
||||
if (filesize >= min_size) {
|
||||
return FileType::FIRMWARE;
|
||||
}
|
||||
}
|
||||
if (extension == "json") {
|
||||
return FileType::JSON;
|
||||
}
|
||||
if (extension == "md5") {
|
||||
return FileType::MD5;
|
||||
}
|
||||
return FileType::UNSUPPORTED;
|
||||
}
|
||||
|
||||
bool OtaUpdater::isCompatibleFirmware(const uint8_t * data, size_t len) {
|
||||
// 0xE9 magic at offset 0 indicates an esp bin, chip id at offset 12
|
||||
#if CONFIG_IDF_TARGET_ESP32
|
||||
constexpr uint8_t chip_id = ESP_CHIP_ID_ESP32;
|
||||
#elif CONFIG_IDF_TARGET_ESP32S2
|
||||
constexpr uint8_t chip_id = ESP_CHIP_ID_ESP32S2;
|
||||
#elif CONFIG_IDF_TARGET_ESP32C3
|
||||
constexpr uint8_t chip_id = ESP_CHIP_ID_ESP32C3;
|
||||
#elif CONFIG_IDF_TARGET_ESP32S3
|
||||
constexpr uint8_t chip_id = ESP_CHIP_ID_ESP32S3;
|
||||
#elif CONFIG_IDF_TARGET_ESP32C6
|
||||
constexpr uint8_t chip_id = ESP_CHIP_ID_ESP32C6;
|
||||
#else
|
||||
return true;
|
||||
#endif
|
||||
return len <= 12 || (data[0] == ESP_IMAGE_HEADER_MAGIC && data[12] == chip_id);
|
||||
}
|
||||
|
||||
bool OtaUpdater::isRecoveryFirmware(const uint8_t * data, size_t len) {
|
||||
constexpr size_t offset = sizeof(esp_image_header_t) + sizeof(esp_image_segment_header_t);
|
||||
if (len < offset + offsetof(esp_app_desc_t, time)) {
|
||||
return false;
|
||||
}
|
||||
esp_app_desc_t desc;
|
||||
memcpy(&desc, data + offset, offsetof(esp_app_desc_t, time));
|
||||
return desc.magic_word == ESP_APP_DESC_MAGIC_WORD && strncmp(desc.project_name, RECOVERY_PROJECT_NAME, sizeof(desc.project_name)) == 0;
|
||||
}
|
||||
|
||||
bool OtaUpdater::isRecoveryPartition(const esp_partition_t * partition, esp_app_desc_t * desc) {
|
||||
esp_app_desc_t d;
|
||||
if (partition == nullptr || esp_ota_get_partition_description(partition, &d) != ESP_OK
|
||||
|| strncmp(d.project_name, RECOVERY_PROJECT_NAME, sizeof(d.project_name)) != 0) {
|
||||
return false;
|
||||
}
|
||||
if (desc != nullptr) {
|
||||
*desc = d;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool OtaUpdater::parseMd5Digest(const uint8_t * data, size_t len, std::array<char, 33> & out) {
|
||||
size_t i = 0;
|
||||
while (i < len && (data[i] == ' ' || data[i] == '\t' || data[i] == '\r' || data[i] == '\n')) {
|
||||
++i;
|
||||
}
|
||||
if (len - i < 32) {
|
||||
return false;
|
||||
}
|
||||
for (size_t n = 0; n < 32; n++) {
|
||||
const char c = static_cast<char>(data[i + n]);
|
||||
const bool hex = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F');
|
||||
if (!hex) {
|
||||
return false;
|
||||
}
|
||||
out[n] = (c >= 'A' && c <= 'F') ? static_cast<char>(c - 'A' + 'a') : c;
|
||||
}
|
||||
out[32] = '\0';
|
||||
return true;
|
||||
}
|
||||
|
||||
bool OtaUpdater::confirmRunningApp() {
|
||||
#ifdef CONFIG_APP_ROLLBACK_ENABLE
|
||||
esp_ota_img_states_t state;
|
||||
if (esp_ota_get_state_partition(esp_ota_get_running_partition(), &state) == ESP_OK && state == ESP_OTA_IMG_PENDING_VERIFY) {
|
||||
return esp_ota_mark_app_valid_cancel_rollback() == ESP_OK;
|
||||
}
|
||||
#endif
|
||||
return false;
|
||||
}
|
||||
|
||||
bool OtaUpdater::setMd5(const uint8_t * data, size_t len) {
|
||||
return parseMd5Digest(data, len, _md5);
|
||||
}
|
||||
|
||||
void OtaUpdater::clearMd5() {
|
||||
_md5.front() = '\0';
|
||||
_md5_applied = false;
|
||||
}
|
||||
|
||||
bool OtaUpdater::hasMd5() const {
|
||||
return strlen(_md5.data()) == _md5.size() - 1;
|
||||
}
|
||||
|
||||
#ifdef OTA_UPDATER_ANY_PARTITION
|
||||
void OtaUpdater::setError(esp_err_t err) {
|
||||
snprintf(_error_buf, sizeof(_error_buf), "%s (0x%x)", esp_err_to_name(err), static_cast<unsigned>(err));
|
||||
_error = _error_buf;
|
||||
}
|
||||
|
||||
void OtaUpdater::releaseTarget() {
|
||||
_target = nullptr;
|
||||
heap_caps_free(_dram);
|
||||
_dram = nullptr;
|
||||
}
|
||||
#endif
|
||||
|
||||
bool OtaUpdater::beginFirmware(size_t filesize, const esp_partition_t * target, bool set_boot) {
|
||||
_md5_applied = false;
|
||||
_error = "";
|
||||
|
||||
confirmRunningApp();
|
||||
|
||||
if (target != nullptr) {
|
||||
#ifdef OTA_UPDATER_ANY_PARTITION
|
||||
if (filesize > target->size + 4096) { // allow for the multipart overhead
|
||||
_error = "Firmware does not fit in the partition";
|
||||
return false;
|
||||
}
|
||||
_dram = static_cast<uint8_t *>(heap_caps_malloc(DRAM_CHUNK, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT));
|
||||
if (_dram == nullptr) {
|
||||
_error = "No internal RAM for OTA buffer";
|
||||
return false;
|
||||
}
|
||||
_target = target;
|
||||
_written = 0;
|
||||
_erased = 0;
|
||||
_set_boot = set_boot;
|
||||
if (hasMd5()) {
|
||||
_md5Builder.begin();
|
||||
_md5_applied = true;
|
||||
}
|
||||
return true;
|
||||
#else
|
||||
_error = "Writing to a specific partition is not supported";
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
if (!Update.begin(filesize - sizeof(esp_image_header_t))) {
|
||||
return false;
|
||||
}
|
||||
if (hasMd5()) {
|
||||
Update.setMD5(_md5.data());
|
||||
_md5_applied = true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool OtaUpdater::beginFilesystem() {
|
||||
clearMd5(); // so Update.end() doesn't compare against a stale digest
|
||||
_error = "";
|
||||
// the HTTP content length is the multipart body size, not the file size, so it can exceed
|
||||
// the partition by a few hundred bytes. Let the Update library size against the whole partition.
|
||||
return Update.begin(UPDATE_SIZE_UNKNOWN, U_SPIFFS);
|
||||
}
|
||||
|
||||
bool OtaUpdater::write(const uint8_t * data, size_t len) {
|
||||
#ifdef OTA_UPDATER_ANY_PARTITION
|
||||
if (_target != nullptr) {
|
||||
while (len) {
|
||||
const size_t n = len < DRAM_CHUNK ? len : DRAM_CHUNK;
|
||||
memcpy(_dram, data, n);
|
||||
|
||||
const size_t need = _written + n;
|
||||
if (need > _erased) {
|
||||
size_t erase_to = (need + FLASH_SECTOR - 1) / FLASH_SECTOR * FLASH_SECTOR;
|
||||
if (erase_to > _target->size) {
|
||||
erase_to = _target->size;
|
||||
}
|
||||
const size_t erase_len = erase_to - _erased;
|
||||
if (erase_len > 0) {
|
||||
const esp_err_t err = esp_partition_erase_range(_target, _erased, erase_len);
|
||||
if (err != ESP_OK) {
|
||||
setError(err);
|
||||
return false;
|
||||
}
|
||||
_erased = erase_to;
|
||||
}
|
||||
}
|
||||
|
||||
const esp_err_t err = esp_partition_write(_target, _written, _dram, n);
|
||||
if (err != ESP_OK) {
|
||||
setError(err);
|
||||
return false;
|
||||
}
|
||||
if (_md5_applied) {
|
||||
_md5Builder.add(_dram, n);
|
||||
}
|
||||
_written += n;
|
||||
data += n;
|
||||
len -= n;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
return Update.write(const_cast<uint8_t *>(data), len) == len;
|
||||
}
|
||||
|
||||
bool OtaUpdater::end() {
|
||||
#ifdef OTA_UPDATER_ANY_PARTITION
|
||||
if (_target != nullptr) {
|
||||
const esp_partition_t * target = _target;
|
||||
const bool set_boot = _set_boot;
|
||||
releaseTarget();
|
||||
|
||||
if (_md5_applied) {
|
||||
_md5Builder.calculate();
|
||||
if (strcmp(_md5Builder.toString().c_str(), _md5.data()) != 0) {
|
||||
_error = "MD5 check failed";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
esp_image_metadata_t meta = {};
|
||||
esp_partition_pos_t part_pos = {.offset = target->address, .size = target->size};
|
||||
esp_err_t err = esp_image_verify(ESP_IMAGE_VERIFY_SILENT, &part_pos, &meta);
|
||||
if (err != ESP_OK) {
|
||||
setError(err);
|
||||
return false;
|
||||
}
|
||||
if (set_boot) {
|
||||
err = esp_ota_set_boot_partition(target);
|
||||
if (err != ESP_OK) {
|
||||
setError(err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
return Update.end(true);
|
||||
}
|
||||
|
||||
void OtaUpdater::abort() {
|
||||
#ifdef OTA_UPDATER_ANY_PARTITION
|
||||
if (_target != nullptr) {
|
||||
releaseTarget();
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
if (Update.isRunning()) {
|
||||
Update.abort();
|
||||
}
|
||||
}
|
||||
|
||||
size_t OtaUpdater::progress() const {
|
||||
#ifdef OTA_UPDATER_ANY_PARTITION
|
||||
if (_target != nullptr) {
|
||||
return _written;
|
||||
}
|
||||
#endif
|
||||
return Update.progress();
|
||||
}
|
||||
|
||||
const char * OtaUpdater::errorString() const {
|
||||
return _error[0] != '\0' ? _error : Update.errorString();
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
#ifndef OtaUpdater_h
|
||||
#define OtaUpdater_h
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <esp_ota_ops.h>
|
||||
|
||||
#include <array>
|
||||
|
||||
#ifdef OTA_UPDATER_ANY_PARTITION
|
||||
#include <MD5Builder.h>
|
||||
#endif
|
||||
|
||||
// Validates and streams firmware (.bin), LittleFS images (*littlefs.bin) and MD5 digests (.md5) into flash.
|
||||
// Shared between the EMS-ESP application and the recovery application.
|
||||
class OtaUpdater {
|
||||
public:
|
||||
enum class FileType : uint8_t { UNSUPPORTED, FIRMWARE, FILESYSTEM, JSON, MD5 };
|
||||
|
||||
// min_firmware_size is for the main EMS-ESP image. Filenames containing "recovery"
|
||||
// (case-insensitive) use MIN_RECOVERY_FIRMWARE_SIZE instead.
|
||||
static FileType classify(const String & filename, size_t filesize, size_t min_firmware_size);
|
||||
|
||||
// project name in the app description of the recovery firmware, set by custom_fw_name in platformio.ini
|
||||
static constexpr char RECOVERY_PROJECT_NAME[] = "EMS-ESP-Recovery";
|
||||
|
||||
// recovery firmware is much smaller than the main image (~512 KB–1 MB)
|
||||
static constexpr size_t MIN_RECOVERY_FIRMWARE_SIZE = 512 * 1024;
|
||||
|
||||
// checks the ESP image magic byte and that the chip id matches the chip we're running on
|
||||
static bool isCompatibleFirmware(const uint8_t * data, size_t len);
|
||||
|
||||
// checks the app description at the start of an image, needs the first 112 bytes
|
||||
static bool isRecoveryFirmware(const uint8_t * data, size_t len);
|
||||
// fills in desc if the partition holds the recovery firmware
|
||||
static bool isRecoveryPartition(const esp_partition_t * partition, esp_app_desc_t * desc = nullptr);
|
||||
|
||||
// Accepts a raw 32-char hex digest, optionally with trailing newline or a GNU "hash filename" suffix.
|
||||
static bool parseMd5Digest(const uint8_t * data, size_t len, std::array<char, 33> & out);
|
||||
|
||||
// With bootloader rollback a new image boots as pending verification until confirmed, and the
|
||||
// IDF refuses to start another firmware update until then. Returns true if it was pending.
|
||||
static bool confirmRunningApp();
|
||||
|
||||
bool setMd5(const uint8_t * data, size_t len);
|
||||
void clearMd5();
|
||||
bool hasMd5() const;
|
||||
bool md5Applied() const {
|
||||
return _md5_applied;
|
||||
}
|
||||
const char * md5() const {
|
||||
return _md5.data();
|
||||
}
|
||||
|
||||
// filesize is the HTTP content length, which is slightly bigger than the image.
|
||||
// Without a target the image goes to the next OTA partition after the running one. Writing to a
|
||||
// specific partition needs OTA_UPDATER_ANY_PARTITION, which adds ~1.7KB.
|
||||
bool beginFirmware(size_t filesize, const esp_partition_t * target = nullptr, bool set_boot = true);
|
||||
bool beginFilesystem();
|
||||
bool write(const uint8_t * data, size_t len);
|
||||
bool end(); // on success the new firmware becomes the boot partition, unless set_boot was false
|
||||
void abort();
|
||||
|
||||
size_t progress() const;
|
||||
const char * errorString() const;
|
||||
|
||||
private:
|
||||
std::array<char, 33> _md5{};
|
||||
bool _md5_applied = false;
|
||||
const char * _error = "";
|
||||
|
||||
#ifdef OTA_UPDATER_ANY_PARTITION
|
||||
// explicit target (factory/boot or an OTA slot) is written with the partition API;
|
||||
// esp_ota_begin() returns ESP_ERR_INVALID_STATE (0x102) on the factory partition
|
||||
const esp_partition_t * _target = nullptr;
|
||||
size_t _written = 0;
|
||||
size_t _erased = 0;
|
||||
bool _set_boot = true;
|
||||
MD5Builder _md5Builder;
|
||||
// AsyncTCP may hand us PSRAM pointers; ESP32 flash DMA needs internal RAM
|
||||
uint8_t * _dram = nullptr;
|
||||
static constexpr size_t DRAM_CHUNK = 1024;
|
||||
static constexpr size_t FLASH_SECTOR = 4096;
|
||||
char _error_buf[48]{};
|
||||
|
||||
void setError(esp_err_t err);
|
||||
void releaseTarget();
|
||||
#endif
|
||||
};
|
||||
|
||||
#endif
|
||||
@@ -13,7 +13,7 @@ using espMqttClientInternals::PacketType;
|
||||
using espMqttClientTypes::DisconnectReason;
|
||||
using espMqttClientTypes::Error;
|
||||
|
||||
MqttClient::MqttClient(espMqttClientTypes::UseInternalTask useInternalTask, uint8_t priority, uint8_t core)
|
||||
MqttClient::MqttClient(espMqttClientTypes::UseInternalTask useInternalTask, uint8_t priority, uint8_t core, uint32_t stackSize)
|
||||
: _useInternalTask(useInternalTask)
|
||||
, _transport(nullptr)
|
||||
, _onConnectCallback(nullptr)
|
||||
@@ -55,6 +55,7 @@ MqttClient::MqttClient(espMqttClientTypes::UseInternalTask useInternalTask, uint
|
||||
, _disconnectReason(DisconnectReason::TCP_DISCONNECTED)
|
||||
#if defined(ARDUINO_ARCH_ESP32) && ARDUHAL_LOG_LEVEL >= ARDUHAL_LOG_LEVEL_INFO
|
||||
, _highWaterMark(4294967295)
|
||||
, _stackSize(stackSize)
|
||||
#endif
|
||||
{
|
||||
EMC_GENERATE_CLIENTID(_generatedClientId);
|
||||
@@ -64,12 +65,12 @@ MqttClient::MqttClient(espMqttClientTypes::UseInternalTask useInternalTask, uint
|
||||
if (_useInternalTask == espMqttClientTypes::UseInternalTask::YES) {
|
||||
#ifdef CONFIG_FREERTOS_UNICORE
|
||||
// no core to pin to on single-core targets (S2, C3, C6)
|
||||
xTaskCreate((TaskFunction_t)_loop, "mqttclient", EMC_TASK_STACK_SIZE, this, priority, &_taskHandle);
|
||||
xTaskCreate((TaskFunction_t)_loop, "mqttclient", stackSize, this, priority, &_taskHandle);
|
||||
#else
|
||||
if (core > 1) {
|
||||
xTaskCreate((TaskFunction_t)_loop, "mqttclient", EMC_TASK_STACK_SIZE, this, priority, &_taskHandle);
|
||||
xTaskCreate((TaskFunction_t)_loop, "mqttclient", stackSize, this, priority, &_taskHandle);
|
||||
} else {
|
||||
xTaskCreatePinnedToCore((TaskFunction_t)_loop, "mqttclient", EMC_TASK_STACK_SIZE, this, priority, &_taskHandle, core);
|
||||
xTaskCreatePinnedToCore((TaskFunction_t)_loop, "mqttclient", stackSize, this, priority, &_taskHandle, core);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -77,6 +78,7 @@ MqttClient::MqttClient(espMqttClientTypes::UseInternalTask useInternalTask, uint
|
||||
(void) useInternalTask;
|
||||
(void) priority;
|
||||
(void) core;
|
||||
(void) stackSize;
|
||||
#endif
|
||||
_clientId = _generatedClientId;
|
||||
_core = core;
|
||||
@@ -320,7 +322,7 @@ void MqttClient::loop() {
|
||||
size_t waterMark = uxTaskGetStackHighWaterMark(NULL);
|
||||
if (waterMark < _highWaterMark) {
|
||||
_highWaterMark = waterMark;
|
||||
emc_log_i("Stack usage: %zu/%i", EMC_TASK_STACK_SIZE - _highWaterMark, EMC_TASK_STACK_SIZE);
|
||||
emc_log_i("Stack usage: %zu/%zu", _stackSize - _highWaterMark, _stackSize);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ class MqttClient {
|
||||
}
|
||||
|
||||
protected:
|
||||
explicit MqttClient(espMqttClientTypes::UseInternalTask useInternalTask, uint8_t priority = 1, uint8_t core = 1);
|
||||
explicit MqttClient(espMqttClientTypes::UseInternalTask useInternalTask, uint8_t priority = 1, uint8_t core = 1, uint32_t stackSize = EMC_TASK_STACK_SIZE);
|
||||
espMqttClientTypes::UseInternalTask _useInternalTask;
|
||||
espMqttClientInternals::Transport* _transport;
|
||||
|
||||
@@ -207,6 +207,7 @@ class MqttClient {
|
||||
#if defined(ARDUINO_ARCH_ESP32)
|
||||
#if ARDUHAL_LOG_LEVEL >= ARDUHAL_LOG_LEVEL_INFO
|
||||
size_t _highWaterMark;
|
||||
size_t _stackSize;
|
||||
#endif
|
||||
#endif
|
||||
};
|
||||
|
||||
@@ -208,8 +208,8 @@ class MqttClientSetup : public MqttClient {
|
||||
*/
|
||||
|
||||
protected:
|
||||
explicit MqttClientSetup(espMqttClientTypes::UseInternalTask useInternalTask, uint8_t priority = 1, uint8_t core = 1)
|
||||
: MqttClient(useInternalTask, priority, core) {
|
||||
explicit MqttClientSetup(espMqttClientTypes::UseInternalTask useInternalTask, uint8_t priority = 1, uint8_t core = 1, uint32_t stackSize = EMC_TASK_STACK_SIZE)
|
||||
: MqttClient(useInternalTask, priority, core, stackSize) {
|
||||
#if EMC_MULTIPLE_CALLBACKS
|
||||
_onConnectCallback = [this](bool sessionPresent) {
|
||||
for (auto callback : _onConnectCallbacks) if (callback.first) callback.first(sessionPresent);
|
||||
|
||||
@@ -15,8 +15,8 @@ espMqttClient::espMqttClient(espMqttClientTypes::UseInternalTask useInternalTask
|
||||
_transport = &_client;
|
||||
}
|
||||
|
||||
espMqttClient::espMqttClient(uint8_t priority, uint8_t core)
|
||||
: MqttClientSetup(espMqttClientTypes::UseInternalTask::YES, priority, core)
|
||||
espMqttClient::espMqttClient(uint8_t priority, uint8_t core, uint32_t stackSize)
|
||||
: MqttClientSetup(espMqttClientTypes::UseInternalTask::YES, priority, core, stackSize)
|
||||
, _client() {
|
||||
_transport = &_client;
|
||||
}
|
||||
@@ -27,8 +27,8 @@ espMqttClientSecure::espMqttClientSecure(espMqttClientTypes::UseInternalTask use
|
||||
_transport = &_client;
|
||||
}
|
||||
|
||||
espMqttClientSecure::espMqttClientSecure(uint8_t priority, uint8_t core)
|
||||
: MqttClientSetup(espMqttClientTypes::UseInternalTask::YES, priority, core)
|
||||
espMqttClientSecure::espMqttClientSecure(uint8_t priority, uint8_t core, uint32_t stackSize)
|
||||
: MqttClientSetup(espMqttClientTypes::UseInternalTask::YES, priority, core, stackSize)
|
||||
, _client() {
|
||||
_transport = &_client;
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ class espMqttClientSecure : public MqttClientSetup<espMqttClientSecure> {
|
||||
class espMqttClient : public MqttClientSetup<espMqttClient> {
|
||||
public:
|
||||
explicit espMqttClient(espMqttClientTypes::UseInternalTask useInternalTask);
|
||||
explicit espMqttClient(uint8_t priority = 1, uint8_t core = 1);
|
||||
explicit espMqttClient(uint8_t priority = 1, uint8_t core = 1, uint32_t stackSize = EMC_TASK_STACK_SIZE);
|
||||
|
||||
protected:
|
||||
espMqttClientInternals::ClientSync _client;
|
||||
@@ -57,7 +57,7 @@ class espMqttClient : public MqttClientSetup<espMqttClient> {
|
||||
class espMqttClientSecure : public MqttClientSetup<espMqttClientSecure> {
|
||||
public:
|
||||
explicit espMqttClientSecure(espMqttClientTypes::UseInternalTask useInternalTask);
|
||||
explicit espMqttClientSecure(uint8_t priority = 1, uint8_t core = 1);
|
||||
explicit espMqttClientSecure(uint8_t priority = 1, uint8_t core = 1, uint32_t stackSize = EMC_TASK_STACK_SIZE);
|
||||
espMqttClientSecure & setInsecure();
|
||||
espMqttClientSecure & setCACert(const char * rootCA);
|
||||
espMqttClientSecure & setCertificate(const char * clientCa);
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
#include <sys/time.h>
|
||||
|
||||
namespace uuid {
|
||||
|
||||
static uint64_t now_millis = 0;
|
||||
@@ -41,4 +43,11 @@ uint32_t get_uptime() {
|
||||
return (uint32_t)now_millis;
|
||||
}
|
||||
|
||||
// both clocks are read live and subtracted in us, rounding each to ms or s first makes the offset jitter
|
||||
int64_t get_wall_offset_ms() {
|
||||
struct timeval tv;
|
||||
gettimeofday(&tv, nullptr);
|
||||
return ((int64_t)tv.tv_sec * 1000000 + tv.tv_usec - esp_timer_get_time()) / 1000;
|
||||
}
|
||||
|
||||
} // namespace uuid
|
||||
|
||||
@@ -105,6 +105,9 @@ uint64_t get_uptime_ms();
|
||||
uint32_t get_uptime();
|
||||
uint32_t get_uptime_sec();
|
||||
|
||||
// milliseconds to add to an uptime in ms to get the wall clock time in ms since the epoch
|
||||
int64_t get_wall_offset_ms();
|
||||
|
||||
void set_uptime();
|
||||
|
||||
} // namespace uuid
|
||||
|
||||
@@ -106,14 +106,15 @@ void Shell::output_logs() {
|
||||
}
|
||||
|
||||
while (1) {
|
||||
time_t offset = time(nullptr) - uuid::get_uptime_sec();
|
||||
if (offset < 1500000000L) {
|
||||
int64_t offset_ms = uuid::get_wall_offset_ms();
|
||||
if (offset_ms < 1500000000000LL) {
|
||||
print(uuid::log::format_timestamp_ms(message.content_->uptime_ms, 3));
|
||||
} else {
|
||||
time_t t1 = offset + (time_t)(message.content_->uptime_ms / 1000);
|
||||
char timestr[25];
|
||||
int64_t wall_ms = offset_ms + (int64_t)message.content_->uptime_ms;
|
||||
time_t t1 = (time_t)(wall_ms / 1000);
|
||||
char timestr[25];
|
||||
strftime(timestr, 25, "%FT%T", localtime(&t1));
|
||||
printf("%s.%03d", timestr, (uint16_t)(message.content_->uptime_ms % 1000));
|
||||
printf("%s.%03d", timestr, (int)(wall_ms % 1000));
|
||||
}
|
||||
printf(" %c %lu: [%s] ", uuid::log::format_level_char(message.content_->level), message.id_, message.content_->name);
|
||||
|
||||
|
||||
@@ -148,7 +148,5 @@ double ledcSetup(uint8_t chan, double freq, uint8_t bit_num) {
|
||||
void ledcAttachPin(uint8_t pin, uint8_t chan) {};
|
||||
void ledcAttach(uint8_t pin, uint8_t chan, uint8_t bit_num) {};
|
||||
void ledcWrite(uint8_t chan, uint32_t duty) {};
|
||||
void neopixelWrite(uint8_t pin, uint8_t red_val, uint8_t green_val, uint8_t blue_val) {};
|
||||
void rgbLedWrite(uint8_t pin, uint8_t red_val, uint8_t green_val, uint8_t blue_val) {};
|
||||
|
||||
#endif
|
||||
@@ -83,8 +83,6 @@ double ledcSetup(uint8_t chan, double freq, uint8_t bit_num);
|
||||
void ledcAttachPin(uint8_t pin, uint8_t chan);
|
||||
void ledcAttach(uint8_t pin, uint8_t chan, uint8_t bit_num);
|
||||
void ledcWrite(uint8_t chan, uint32_t duty);
|
||||
void neopixelWrite(uint8_t pin, uint8_t red_val, uint8_t green_val, uint8_t blue_val);
|
||||
void rgbLedWrite(uint8_t pin, uint8_t red_val, uint8_t green_val, uint8_t blue_val);
|
||||
|
||||
inline int rtc_get_reset_reason(int cpu_no) {
|
||||
return 0;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/*
|
||||
* EMS-ESP - https://github.com/emsesp/EMS-ESP
|
||||
* Copyright 2020-2025 emsesp.org
|
||||
* Copyright 2020-2026 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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/*
|
||||
* EMS-ESP - https://github.com/emsesp/EMS-ESP
|
||||
* Copyright 2020-2025 emsesp.org
|
||||
* Copyright 2020-2026 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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/*
|
||||
* EMS-ESP - https://github.com/emsesp/EMS-ESP
|
||||
* Copyright 2020-2025 emsesp.org
|
||||
* Copyright 2020-2026 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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/*
|
||||
* EMS-ESP - https://github.com/emsesp/EMS-ESP
|
||||
* Copyright 2020-2025 emsesp.org
|
||||
* Copyright 2020-2026 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
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||
"prettier": "^3.9.8"
|
||||
"prettier": "^3.9.10"
|
||||
},
|
||||
"packageManager": "pnpm@12.6.0"
|
||||
"packageManager": "pnpm@12.15.0"
|
||||
}
|
||||
|
||||
Generated
+93
-93
@@ -7,153 +7,153 @@ importers:
|
||||
configDependencies: {}
|
||||
packageManagerDependencies:
|
||||
pnpm:
|
||||
specifier: 12.6.0
|
||||
version: 12.6.0
|
||||
specifier: 12.15.0
|
||||
version: 12.15.0
|
||||
|
||||
packages:
|
||||
|
||||
'@pnpm/exe.android-arm64@12.6.0':
|
||||
resolution: {integrity: sha512-kviIHft9h02q+7N2In7tSL9T/HRUSGwYmU74YPtsWU5ee7vgqPyC0JT6RRX6miW+NxX1CvHgQxg69qq1qTLJYQ==}
|
||||
'@pnpm/exe.android-arm64@12.15.0':
|
||||
resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@pnpm/exe.android-x64@12.6.0':
|
||||
resolution: {integrity: sha512-CT8aJKLq2mtZFE71pr4E5Z2xHL8uGnRGr+NclDqsXlVF4SVcQ2QAs14mWi0C8gjT4hbmi+Q0lJRRIkMAWfjjng==}
|
||||
'@pnpm/exe.android-x64@12.15.0':
|
||||
resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==}
|
||||
cpu: [x64]
|
||||
os: [android]
|
||||
|
||||
'@pnpm/exe.darwin-arm64@12.6.0':
|
||||
resolution: {integrity: sha512-rafpVkjzugKBMxSvGQd5wK1x5eOTkD/+lcJIFHeQhvd+BJ8kDisOOvwhQDrpGd4vd2Fx+hhW0P2Ptt638OlhFg==}
|
||||
'@pnpm/exe.darwin-arm64@12.15.0':
|
||||
resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@pnpm/exe.darwin-x64@12.6.0':
|
||||
resolution: {integrity: sha512-72Jpuv1m24gI8zUNcaylYpBWEhBzz1VPhnDIU2GYM2eRP+KsOqVLwV78i+JrihI39zY2NNXmaN4eAKX1inpaDQ==}
|
||||
'@pnpm/exe.darwin-x64@12.15.0':
|
||||
resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@pnpm/exe.freebsd-x64@12.6.0':
|
||||
resolution: {integrity: sha512-LON4QgNy1w/XF4uySZIyC/5hEB0oGdelXsY9tSKqstER+2j2X2oHz67skD11RRd/HWFRtMr64shQZ4I9ZoZ0zg==}
|
||||
'@pnpm/exe.freebsd-x64@12.15.0':
|
||||
resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@pnpm/exe.linux-arm64-musl@12.6.0':
|
||||
resolution: {integrity: sha512-BdDpX+DeaMUc5x6WNcE6FkmFy36NwrBbBvwsZU737Zt3u1fTY+iKFPchjEQNCM4rT6q6xvF2oN3ZwExGsJCkxQ==}
|
||||
'@pnpm/exe.linux-arm64-musl@12.15.0':
|
||||
resolution: {integrity: sha512-pNC38k+O+90FOjbPNSnw0E7apq1PKTmz3fFe1amn+lys/U5kL++uzir8bRiho8ONcyA3d9EU7wdqcY0DKkj+JA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@pnpm/exe.linux-arm64@12.6.0':
|
||||
resolution: {integrity: sha512-8h2sNoIhHDpHDYqZZCh9PXr6krqW10btb0GHVGuw710muDHlFAHMtUG2ogmaHge6zJG1xgY4Y7mwX5g/LWvrYg==}
|
||||
'@pnpm/exe.linux-arm64@12.15.0':
|
||||
resolution: {integrity: sha512-qML/8aCXslh8td/w4U+HJNqUcZ2+m6EfTbWScIzZRmyPsRFN+xB4LP1U/hymxc1iSJiKtG56HJx2fvUErWFIGw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@pnpm/exe.linux-ppc64@12.6.0':
|
||||
resolution: {integrity: sha512-ld6xgcEhFCFrsVsJhbmdmR714PnsEspO/Ij8w0/D7OSiz2hYGu3fZd5tKRxOI5m6H6Gl0/RRFAPncTHbsgXVjQ==}
|
||||
'@pnpm/exe.linux-ppc64@12.15.0':
|
||||
resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@pnpm/exe.linux-riscv64@12.6.0':
|
||||
resolution: {integrity: sha512-HIbImydoC3J8NFt+8MXfhTCf5rO6NjCMyinSAHac0VmV8uCHw+kbQkyAzxV84rzz9ZOzmBOnj0b1N461UIYGsw==}
|
||||
'@pnpm/exe.linux-riscv64@12.15.0':
|
||||
resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@pnpm/exe.linux-s390x@12.6.0':
|
||||
resolution: {integrity: sha512-DjSqT7+BZ/lWcDHwNMuzsRVfyxUimh+D54Xw4tSWbdVVQhtWtNMUyI93LVnwCmsoEpar2gC3SFeAgywx37svKQ==}
|
||||
'@pnpm/exe.linux-s390x@12.15.0':
|
||||
resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@pnpm/exe.linux-x64-musl@12.6.0':
|
||||
resolution: {integrity: sha512-31lKeGPmRE6xfV6I3VjaEGVFRei14pl/zgoFDK4w+UkbJqOuNl2Ht3O1GuqNIg3Gl14qTEb4kZRAYFHXifDx9A==}
|
||||
'@pnpm/exe.linux-x64-musl@12.15.0':
|
||||
resolution: {integrity: sha512-A9J0Jg0Rdx+qQU4qXtwyST5tto9IOu1zi4fDk35nbc6DWahggbcKtFvZPKFwL2qgUXGXOX//hXv6z/sp6emO8A==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@pnpm/exe.linux-x64@12.6.0':
|
||||
resolution: {integrity: sha512-qFWBneHJAJ73W4whtbaFOL1M/7DBC6ILHXuxc7ZPtEhfPuT1zeZiGrmKHoMAfJA+mcm6xhOFljqVTUS+00Jabw==}
|
||||
'@pnpm/exe.linux-x64@12.15.0':
|
||||
resolution: {integrity: sha512-yc+QlZR+9+nr29J4fRzhMoapgxTBDndq/+peyoq1XUufnQFnfbXUMFudDIKzHi545linGqmOmXfn0NoufNgcOg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@pnpm/exe.win32-arm64@12.6.0':
|
||||
resolution: {integrity: sha512-OhfefXEEykZlslSUhR8PPRe6MVPV3Oink/dVfIecE5velpc7j80cZYsHlaDrtRDcm3TPwQHkaRW1SJ1efX9oFg==}
|
||||
'@pnpm/exe.win32-arm64@12.15.0':
|
||||
resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@pnpm/exe.win32-x64@12.6.0':
|
||||
resolution: {integrity: sha512-L2tuyrD2+Imgxs3VK/ST/2L3xD6vDP0ktJQxM0TaGw7TbAQEMq+RMc8iK6Ew7Id579uzjJe8jSfJID2ZGsOHCA==}
|
||||
'@pnpm/exe.win32-x64@12.15.0':
|
||||
resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
pnpm@12.6.0:
|
||||
resolution: {integrity: sha512-PvaPlRyxEawgS0paFvCy3fDaVqluBBPoHYVdnwtV75JnFHCQKOHNAMQFwsX7e56OxNxGd3yAXQNzwvL/AP0g7A==}
|
||||
engines: {node: '>=18.*'}
|
||||
pnpm@12.15.0:
|
||||
resolution: {integrity: sha512-s5UdbW/0qfE9x89WzSjSuOs328ycaOUuHhGRhz6p+CoBcoerULqtxczElPA2xnwOR+sTYIwvo29HcRth6hKOYQ==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
snapshots:
|
||||
|
||||
'@pnpm/exe.android-arm64@12.6.0':
|
||||
'@pnpm/exe.android-arm64@12.15.0':
|
||||
optional: true
|
||||
|
||||
'@pnpm/exe.android-x64@12.6.0':
|
||||
'@pnpm/exe.android-x64@12.15.0':
|
||||
optional: true
|
||||
|
||||
'@pnpm/exe.darwin-arm64@12.6.0':
|
||||
'@pnpm/exe.darwin-arm64@12.15.0':
|
||||
optional: true
|
||||
|
||||
'@pnpm/exe.darwin-x64@12.6.0':
|
||||
'@pnpm/exe.darwin-x64@12.15.0':
|
||||
optional: true
|
||||
|
||||
'@pnpm/exe.freebsd-x64@12.6.0':
|
||||
'@pnpm/exe.freebsd-x64@12.15.0':
|
||||
optional: true
|
||||
|
||||
'@pnpm/exe.linux-arm64-musl@12.6.0':
|
||||
'@pnpm/exe.linux-arm64-musl@12.15.0':
|
||||
optional: true
|
||||
|
||||
'@pnpm/exe.linux-arm64@12.6.0':
|
||||
'@pnpm/exe.linux-arm64@12.15.0':
|
||||
optional: true
|
||||
|
||||
'@pnpm/exe.linux-ppc64@12.6.0':
|
||||
'@pnpm/exe.linux-ppc64@12.15.0':
|
||||
optional: true
|
||||
|
||||
'@pnpm/exe.linux-riscv64@12.6.0':
|
||||
'@pnpm/exe.linux-riscv64@12.15.0':
|
||||
optional: true
|
||||
|
||||
'@pnpm/exe.linux-s390x@12.6.0':
|
||||
'@pnpm/exe.linux-s390x@12.15.0':
|
||||
optional: true
|
||||
|
||||
'@pnpm/exe.linux-x64-musl@12.6.0':
|
||||
'@pnpm/exe.linux-x64-musl@12.15.0':
|
||||
optional: true
|
||||
|
||||
'@pnpm/exe.linux-x64@12.6.0':
|
||||
'@pnpm/exe.linux-x64@12.15.0':
|
||||
optional: true
|
||||
|
||||
'@pnpm/exe.win32-arm64@12.6.0':
|
||||
'@pnpm/exe.win32-arm64@12.15.0':
|
||||
optional: true
|
||||
|
||||
'@pnpm/exe.win32-x64@12.6.0':
|
||||
'@pnpm/exe.win32-x64@12.15.0':
|
||||
optional: true
|
||||
|
||||
pnpm@12.6.0:
|
||||
pnpm@12.15.0:
|
||||
optionalDependencies:
|
||||
'@pnpm/exe.android-arm64': 12.6.0
|
||||
'@pnpm/exe.android-x64': 12.6.0
|
||||
'@pnpm/exe.darwin-arm64': 12.6.0
|
||||
'@pnpm/exe.darwin-x64': 12.6.0
|
||||
'@pnpm/exe.freebsd-x64': 12.6.0
|
||||
'@pnpm/exe.linux-arm64': 12.6.0
|
||||
'@pnpm/exe.linux-arm64-musl': 12.6.0
|
||||
'@pnpm/exe.linux-ppc64': 12.6.0
|
||||
'@pnpm/exe.linux-riscv64': 12.6.0
|
||||
'@pnpm/exe.linux-s390x': 12.6.0
|
||||
'@pnpm/exe.linux-x64': 12.6.0
|
||||
'@pnpm/exe.linux-x64-musl': 12.6.0
|
||||
'@pnpm/exe.win32-arm64': 12.6.0
|
||||
'@pnpm/exe.win32-x64': 12.6.0
|
||||
'@pnpm/exe.android-arm64': 12.15.0
|
||||
'@pnpm/exe.android-x64': 12.15.0
|
||||
'@pnpm/exe.darwin-arm64': 12.15.0
|
||||
'@pnpm/exe.darwin-x64': 12.15.0
|
||||
'@pnpm/exe.freebsd-x64': 12.15.0
|
||||
'@pnpm/exe.linux-arm64': 12.15.0
|
||||
'@pnpm/exe.linux-arm64-musl': 12.15.0
|
||||
'@pnpm/exe.linux-ppc64': 12.15.0
|
||||
'@pnpm/exe.linux-riscv64': 12.15.0
|
||||
'@pnpm/exe.linux-s390x': 12.15.0
|
||||
'@pnpm/exe.linux-x64': 12.15.0
|
||||
'@pnpm/exe.linux-x64-musl': 12.15.0
|
||||
'@pnpm/exe.win32-arm64': 12.15.0
|
||||
'@pnpm/exe.win32-x64': 12.15.0
|
||||
|
||||
---
|
||||
lockfileVersion: '9.0'
|
||||
@@ -175,10 +175,10 @@ importers:
|
||||
devDependencies:
|
||||
'@trivago/prettier-plugin-sort-imports':
|
||||
specifier: ^6.0.2
|
||||
version: 6.0.2(prettier@3.9.8)
|
||||
version: 6.0.2(prettier@3.9.10)
|
||||
prettier:
|
||||
specifier: ^3.9.8
|
||||
version: 3.9.8
|
||||
specifier: ^3.9.10
|
||||
version: 3.9.10
|
||||
|
||||
packages:
|
||||
|
||||
@@ -194,16 +194,16 @@ packages:
|
||||
resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-string-parser@7.29.7':
|
||||
resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
|
||||
'@babel/helper-string-parser@7.29.11':
|
||||
resolution: {integrity: sha512-snZbO7MEDOof+hvNa97EjEY7qD7h2kwvQr5AJC3ZhQudKW5dnej6k3kP9NQV88hJwaS8vDqRLFo9rivLEhle2g==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-validator-identifier@7.29.7':
|
||||
resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/parser@7.29.9':
|
||||
resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==}
|
||||
'@babel/parser@7.29.11':
|
||||
resolution: {integrity: sha512-OZ6+oaK7GMyg3IfCpj8JOmn8n+w2rJbDT0LLfVDTLb1ZFeKDh3AiNELgCMUw175IJHSkLl74hG0M0VLsvx+gag==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
@@ -211,12 +211,12 @@ packages:
|
||||
resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/traverse@7.29.8':
|
||||
resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==}
|
||||
'@babel/traverse@7.29.10':
|
||||
resolution: {integrity: sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/types@7.29.8':
|
||||
resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==}
|
||||
'@babel/types@7.29.11':
|
||||
resolution: {integrity: sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@jridgewell/gen-mapping@0.3.13':
|
||||
@@ -303,8 +303,8 @@ packages:
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
prettier@3.9.8:
|
||||
resolution: {integrity: sha512-WRFq3Wn3WId7LLROfMLdH7xaFr2jR62wU8nLO6rQUOLOxNZUviyJQs1M0iIhLexSFy+L+w0ch66wtoO2jRjG0A==}
|
||||
prettier@3.9.10:
|
||||
resolution: {integrity: sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA==}
|
||||
engines: {node: '>=14'}
|
||||
hasBin: true
|
||||
|
||||
@@ -318,43 +318,43 @@ snapshots:
|
||||
|
||||
'@babel/generator@7.29.8':
|
||||
dependencies:
|
||||
'@babel/parser': 7.29.9
|
||||
'@babel/types': 7.29.8
|
||||
'@babel/parser': 7.29.11
|
||||
'@babel/types': 7.29.11
|
||||
'@jridgewell/gen-mapping': 0.3.13
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
jsesc: 3.1.0
|
||||
|
||||
'@babel/helper-globals@7.29.7': {}
|
||||
|
||||
'@babel/helper-string-parser@7.29.7': {}
|
||||
'@babel/helper-string-parser@7.29.11': {}
|
||||
|
||||
'@babel/helper-validator-identifier@7.29.7': {}
|
||||
|
||||
'@babel/parser@7.29.9':
|
||||
'@babel/parser@7.29.11':
|
||||
dependencies:
|
||||
'@babel/types': 7.29.8
|
||||
'@babel/types': 7.29.11
|
||||
|
||||
'@babel/template@7.29.7':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.7
|
||||
'@babel/parser': 7.29.9
|
||||
'@babel/types': 7.29.8
|
||||
'@babel/parser': 7.29.11
|
||||
'@babel/types': 7.29.11
|
||||
|
||||
'@babel/traverse@7.29.8':
|
||||
'@babel/traverse@7.29.10':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.7
|
||||
'@babel/generator': 7.29.8
|
||||
'@babel/helper-globals': 7.29.7
|
||||
'@babel/parser': 7.29.9
|
||||
'@babel/parser': 7.29.11
|
||||
'@babel/template': 7.29.7
|
||||
'@babel/types': 7.29.8
|
||||
'@babel/types': 7.29.11
|
||||
debug: 4.4.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@babel/types@7.29.8':
|
||||
'@babel/types@7.29.11':
|
||||
dependencies:
|
||||
'@babel/helper-string-parser': 7.29.7
|
||||
'@babel/helper-string-parser': 7.29.11
|
||||
'@babel/helper-validator-identifier': 7.29.7
|
||||
|
||||
'@jridgewell/gen-mapping@0.3.13':
|
||||
@@ -373,17 +373,17 @@ snapshots:
|
||||
|
||||
'@msgpack/msgpack@3.1.3': {}
|
||||
|
||||
'@trivago/prettier-plugin-sort-imports@6.0.2(prettier@3.9.8)':
|
||||
'@trivago/prettier-plugin-sort-imports@6.0.2(prettier@3.9.10)':
|
||||
dependencies:
|
||||
'@babel/generator': 7.29.8
|
||||
'@babel/parser': 7.29.9
|
||||
'@babel/traverse': 7.29.8
|
||||
'@babel/types': 7.29.8
|
||||
'@babel/parser': 7.29.11
|
||||
'@babel/traverse': 7.29.10
|
||||
'@babel/types': 7.29.11
|
||||
javascript-natural-sort: 0.7.1
|
||||
lodash-es: 4.18.1
|
||||
minimatch: 9.0.9
|
||||
parse-imports-exports: 0.2.4
|
||||
prettier: 3.9.8
|
||||
prettier: 3.9.10
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -421,4 +421,4 @@ snapshots:
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
prettier@3.9.8: {}
|
||||
prettier@3.9.10: {}
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
// Vite plugin that mocks the EMS-ESP Recovery firmware REST API, used by `pnpm recovery`
|
||||
// Sign in with admin/admin. Uploading any *.bin >= 512KB "flashes" it into the next OTA partition.
|
||||
// It starts as if an update in app1 failed to start and the bootloader fell back to the recovery.
|
||||
|
||||
const MIN_FIRMWARE_SIZE = 512 * 1024;
|
||||
const RESTART_DOWNTIME_MS = 4000;
|
||||
const TOKEN = 'mock-recovery-token';
|
||||
const MB = 1048576;
|
||||
|
||||
const state = {
|
||||
token: null,
|
||||
boot: 'boot',
|
||||
downUntil: 0,
|
||||
md5: null,
|
||||
partitions: [
|
||||
{
|
||||
label: 'boot',
|
||||
factory: true,
|
||||
address: 0x10000,
|
||||
size: 0x480000,
|
||||
valid: true,
|
||||
image_size: 0.8 * MB,
|
||||
version: '1.0.0',
|
||||
install_date: 0,
|
||||
ota_state: ''
|
||||
},
|
||||
{
|
||||
label: 'app0',
|
||||
factory: false,
|
||||
address: 0x490000,
|
||||
size: 0x490000,
|
||||
valid: true,
|
||||
image_size: 1.86 * MB,
|
||||
version: '3.8.1',
|
||||
install_date: 1767225600,
|
||||
ota_state: 'valid'
|
||||
},
|
||||
{
|
||||
label: 'app1',
|
||||
factory: false,
|
||||
address: 0x920000,
|
||||
size: 0x490000,
|
||||
valid: true,
|
||||
image_size: 1.91 * MB,
|
||||
version: '3.9.0-dev.16',
|
||||
install_date: 1790000000,
|
||||
ota_state: 'aborted'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const failedToStart = (p) => p.ota_state === 'aborted' || p.ota_state === 'invalid';
|
||||
|
||||
const started = Date.now();
|
||||
|
||||
// same rule as the firmware: an empty partition, then a failed one, otherwise the oldest install date
|
||||
const nextUploadTarget = () => {
|
||||
const ota = state.partitions.filter((p) => !p.factory);
|
||||
const rank = (p) => (!p.valid ? 0 : failedToStart(p) ? 1 : 2);
|
||||
return ota.reduce((a, b) =>
|
||||
rank(b) < rank(a) || (rank(b) === rank(a) && b.install_date < a.install_date)
|
||||
? b
|
||||
: a
|
||||
).label;
|
||||
};
|
||||
|
||||
const status = (authenticated) => ({
|
||||
recovery_version: '1.0.0',
|
||||
authenticated,
|
||||
hostname: 'ems-esp',
|
||||
chip: 'ESP32', // a wired E32V2 with a static IP, WiFi is not used
|
||||
flash_size: 16 * MB,
|
||||
psram_size: 8 * MB,
|
||||
free_heap: 245760,
|
||||
mac: '24:0A:C4:00:00:01',
|
||||
uptime: Math.floor((Date.now() - started) / 1000),
|
||||
reason: '',
|
||||
reset_reason: 'panic',
|
||||
ap_ssid: 'ems-esp-recovery',
|
||||
ap_ip: '192.168.4.1',
|
||||
ap_clients: 1,
|
||||
wifi_ssid: 'MyHomeWiFi',
|
||||
wifi_started: false,
|
||||
wifi_connected: false,
|
||||
static_ip: true,
|
||||
disable_reset: false, // set to true to test with the factory reset disabled
|
||||
eth_state: 'connected',
|
||||
eth_ip: '192.168.1.50',
|
||||
running: 'boot',
|
||||
boot: state.boot,
|
||||
upload_target: nextUploadTarget(),
|
||||
partitions: state.partitions
|
||||
});
|
||||
|
||||
const readBody = (req) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
req.on('data', (c) => chunks.push(c));
|
||||
req.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
req.on('error', reject);
|
||||
});
|
||||
|
||||
const send = (res, code, body) => {
|
||||
res.statusCode = code;
|
||||
if (body !== undefined) {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify(body));
|
||||
} else {
|
||||
res.end();
|
||||
}
|
||||
};
|
||||
|
||||
const isAuthorized = (req) =>
|
||||
state.token && req.headers.authorization === `Bearer ${state.token}`;
|
||||
|
||||
const restart = () => {
|
||||
state.token = null;
|
||||
state.downUntil = Date.now() + RESTART_DOWNTIME_MS;
|
||||
};
|
||||
|
||||
const uploadedFilename = (body) => {
|
||||
const match = /filename="([^"]+)"/.exec(body.subarray(0, 1024).toString());
|
||||
return match ? match[1] : '';
|
||||
};
|
||||
|
||||
export default function recoveryMock() {
|
||||
return {
|
||||
name: 'recovery-mock',
|
||||
configureServer(server) {
|
||||
server.middlewares.use(async (req, res, next) => {
|
||||
const [url, query] = req.url.split('?');
|
||||
const params = new URLSearchParams(query ?? '');
|
||||
if (!url.startsWith('/rest/')) {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (Date.now() < state.downUntil) {
|
||||
return send(res, 503);
|
||||
}
|
||||
|
||||
if (url === '/rest/recovery/status' && req.method === 'GET') {
|
||||
return send(res, 200, status(!!isAuthorized(req)));
|
||||
}
|
||||
|
||||
if (url === '/rest/signIn' && req.method === 'POST') {
|
||||
const { username, password } = JSON.parse(
|
||||
(await readBody(req)).toString() || '{}'
|
||||
);
|
||||
if (username === 'admin' && password === 'admin') {
|
||||
state.token = TOKEN;
|
||||
return send(res, 200, { access_token: TOKEN });
|
||||
}
|
||||
return send(res, 401);
|
||||
}
|
||||
|
||||
if (!isAuthorized(req)) {
|
||||
await readBody(req);
|
||||
return send(res, 401);
|
||||
}
|
||||
|
||||
if (url === '/rest/recovery/restart' && req.method === 'POST') {
|
||||
send(res, 200);
|
||||
return restart();
|
||||
}
|
||||
|
||||
if (url === '/rest/recovery/format' && req.method === 'POST') {
|
||||
if (status(true).disable_reset) return send(res, 403);
|
||||
send(res, 200);
|
||||
return restart();
|
||||
}
|
||||
|
||||
if (url === '/rest/recovery/boot' && req.method === 'POST') {
|
||||
const { partition } = JSON.parse((await readBody(req)).toString() || '{}');
|
||||
const p = state.partitions.find((x) => x.label === partition);
|
||||
if (!p) return send(res, 404);
|
||||
if (!p.valid) return send(res, 400);
|
||||
if (!p.factory) p.ota_state = 'new';
|
||||
state.boot = partition;
|
||||
send(res, 200);
|
||||
return restart();
|
||||
}
|
||||
|
||||
if (url === '/rest/uploadFile' && req.method === 'POST') {
|
||||
const body = await readBody(req);
|
||||
const filename = uploadedFilename(body);
|
||||
if (filename.endsWith('.md5')) {
|
||||
state.md5 = '0123456789abcdef0123456789abcdef';
|
||||
return send(res, 200, { md5: state.md5 });
|
||||
}
|
||||
if (
|
||||
!filename.endsWith('.bin') ||
|
||||
filename.endsWith('littlefs.bin') ||
|
||||
body.length < MIN_FIRMWARE_SIZE
|
||||
) {
|
||||
return send(res, 406);
|
||||
}
|
||||
if (filename.startsWith('EMS-ESP-Recovery')) {
|
||||
return send(res, 409);
|
||||
}
|
||||
const target = state.partitions.find(
|
||||
(x) => x.label === (params.get('partition') ?? nextUploadTarget())
|
||||
);
|
||||
if (!target || target.factory) return send(res, 400);
|
||||
Object.assign(target, {
|
||||
valid: true,
|
||||
image_size: body.length,
|
||||
version: '',
|
||||
install_date: Math.floor(Date.now() / 1000),
|
||||
ota_state: 'new'
|
||||
});
|
||||
state.boot = target.label;
|
||||
const md5_ok = !!state.md5;
|
||||
state.md5 = null;
|
||||
return md5_ok ? send(res, 200, { md5_ok }) : send(res, 200);
|
||||
}
|
||||
|
||||
return send(res, 404);
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
+79
-14
@@ -103,7 +103,7 @@ let system_status = {
|
||||
cpu_cores: 2,
|
||||
cpu_freq_mhz: 240,
|
||||
max_alloc_heap: 191,
|
||||
arduino_version: 'Tasmota Arduino v3.3.8',
|
||||
arduino_version: 'Arduino v3.3.8',
|
||||
sdk_version: '5.5.4.260407',
|
||||
partition: 'app0',
|
||||
flash_chip_size: 16384,
|
||||
@@ -116,7 +116,8 @@ let system_status = {
|
||||
psram: true,
|
||||
psram_size: 8189,
|
||||
free_psram: 8166,
|
||||
has_loader: true,
|
||||
recovery_version: '', // upload an EMS-ESP-Recovery-*.bin to set it
|
||||
// recovery_version: '1.0.0',
|
||||
has_partition: true,
|
||||
partitions: [
|
||||
{
|
||||
@@ -151,12 +152,12 @@ let system_status = {
|
||||
let DEV_VERSION_IS_UPGRADEABLE: boolean;
|
||||
let STABLE_VERSION_IS_UPGRADEABLE: boolean;
|
||||
let THIS_VERSION: string;
|
||||
let LATEST_STABLE_VERSION = '3.8.4';
|
||||
let LATEST_STABLE_VERSION = '3.8.6';
|
||||
let LATEST_DEV_VERSION = '3.9.0-dev.1';
|
||||
|
||||
// scenarios for testing versioning
|
||||
let version_test = 0; // on latest stable, or switch to dev
|
||||
// let version_test = 1; // on latest dev, or switch back to stable
|
||||
// let version_test = 0; // on latest stable, or switch to dev
|
||||
let version_test = 1; // on latest dev, or switch back to stable
|
||||
// let version_test = 2; // upgrade an older stable to latest stable or switch to latest dev
|
||||
// let version_test = 3; // upgrade dev to latest, or switch to stable
|
||||
// let version_test = 4; // downgrade to an older dev, or switch back to stable
|
||||
@@ -209,7 +210,7 @@ switch (emulate_esp) {
|
||||
case 'ESP32':
|
||||
system_status.esp_platform = 'ESP32';
|
||||
system_status.cpu_type = 'ESP32-D0WD-V3';
|
||||
system_status.arduino_version = 'Tasmota Arduino v3.3.8';
|
||||
system_status.arduino_version = 'Arduino v3.3.8';
|
||||
system_status.sdk_version = '5.5.4.260407';
|
||||
system_status.psram = true;
|
||||
system_status.psram_size = 4096;
|
||||
@@ -225,7 +226,7 @@ switch (emulate_esp) {
|
||||
default:
|
||||
system_status.esp_platform = 'ESP32S3';
|
||||
system_status.cpu_type = 'ESP32-S3';
|
||||
system_status.arduino_version = 'Tasmota Arduino v3.3.8';
|
||||
system_status.arduino_version = 'Arduino v3.3.8';
|
||||
system_status.sdk_version = '5.5.4.260407';
|
||||
system_status.psram = true;
|
||||
system_status.psram_size = 8189;
|
||||
@@ -3819,7 +3820,7 @@ const emsesp_devicedata_10 = {
|
||||
u: 0,
|
||||
id: '00hc1 nofrost mode',
|
||||
c: 'hc1/nofrostmode',
|
||||
l: ['room', 'outdoor', 'room outdoor']
|
||||
l: ['off', 'room', 'outdoor', 'room outdoor']
|
||||
},
|
||||
{
|
||||
v: 2,
|
||||
@@ -4443,6 +4444,8 @@ const emsesp_deviceentities_4 = [
|
||||
n: 'hc2 selected room temperature',
|
||||
id: 'hc2/seltemp',
|
||||
m: 8,
|
||||
mi: 5,
|
||||
ma: 30,
|
||||
w: true
|
||||
},
|
||||
{
|
||||
@@ -4465,8 +4468,25 @@ const emsesp_deviceentities_7 = [
|
||||
{ u: 0, n: '!reset', id: 'reset', m: 8, w: false },
|
||||
{ v: false, n: 'heating active', id: 'heatingactive', m: 8, w: false },
|
||||
{ v: false, n: 'tapwater active', id: 'tapwateractive', m: 4, w: false },
|
||||
{ v: 5, n: 'selected flow temperature', id: 'selflowtemp', m: 4, w: true },
|
||||
{ v: 0, n: 'burner selected max power', id: 'selburnpow', m: 14, w: true },
|
||||
{
|
||||
v: 5,
|
||||
n: 'selected flow temperature',
|
||||
id: 'selflowtemp',
|
||||
m: 8,
|
||||
mi: 0,
|
||||
ma: 90,
|
||||
c: true,
|
||||
w: true
|
||||
},
|
||||
{
|
||||
v: 0,
|
||||
n: 'burner selected max power',
|
||||
id: 'selburnpow',
|
||||
m: 14,
|
||||
mi: 0,
|
||||
ma: 100,
|
||||
w: true
|
||||
},
|
||||
{ v: 0, n: 'heating pump modulation', id: 'heatingpumpmod', m: 0, w: false },
|
||||
{ n: 'heating pump 2 modulation', id: 'heatingpump2mod', m: 0, w: false },
|
||||
{ n: 'outside temperature', id: 'outdoortemp', m: 0, w: false },
|
||||
@@ -4484,16 +4504,48 @@ const emsesp_deviceentities_7 = [
|
||||
{ v: false, n: 'ignition', id: 'ignwork', m: 0, w: false },
|
||||
{ v: false, n: 'oil preheating', id: 'oilpreheat', m: 0, w: false },
|
||||
{ v: true, n: 'heating activated', id: 'heatingactivated', m: 0, w: false },
|
||||
{ v: 80, n: 'heating temperature', id: 'heatingtemp', m: 0, w: false },
|
||||
{ v: 70, n: 'burner pump max power', id: 'pumpmodmax', m: 0, w: false },
|
||||
{
|
||||
v: 80,
|
||||
n: 'heating temperature',
|
||||
id: 'heatingtemp',
|
||||
m: 0,
|
||||
mi: 30,
|
||||
ma: 82,
|
||||
w: true
|
||||
},
|
||||
{
|
||||
v: 70,
|
||||
n: 'burner pump max power',
|
||||
id: 'pumpmodmax',
|
||||
m: 0,
|
||||
mi: 10,
|
||||
ma: 100,
|
||||
w: true
|
||||
},
|
||||
{ v: 30, n: 'burner pump min power', id: 'pumpmodmin', m: 0, w: false },
|
||||
{ v: 1, n: 'pump delay', id: 'pumpdelay', m: 0, w: false },
|
||||
{ v: 10, n: 'burner min period', id: 'burnminperiod', m: 0, w: false },
|
||||
{ v: 0, n: 'burner min power', id: 'burnminpower', m: 0, w: false },
|
||||
{ v: 50, n: 'burner max power', id: 'burnmaxpower', m: 0, w: false },
|
||||
{
|
||||
v: 50,
|
||||
n: 'burner max power',
|
||||
id: 'burnmaxpower',
|
||||
m: 0,
|
||||
mi: 0,
|
||||
ma: 100,
|
||||
w: true
|
||||
},
|
||||
{ v: -6, n: 'hysteresis on temperature', id: 'boilhyston', m: 0, w: false },
|
||||
{ v: 6, n: 'hysteresis off temperature', id: 'boilhystoff', m: 0, w: false },
|
||||
{ v: 0, n: 'set flow temperature', id: 'setflowtemp', m: 0, w: true },
|
||||
{
|
||||
v: 0,
|
||||
n: 'set flow temperature',
|
||||
id: 'setflowtemp',
|
||||
m: 0,
|
||||
mi: 0,
|
||||
ma: 90,
|
||||
w: true
|
||||
},
|
||||
{ v: 0, n: 'burner set power', id: 'setburnpow', m: 0, w: false },
|
||||
{ v: 0, n: 'burner current power', id: 'curburnpow', m: 0, w: false },
|
||||
{ v: 326323, n: 'burner starts', id: 'burnstarts', m: 0, w: false },
|
||||
@@ -4691,6 +4743,15 @@ router
|
||||
return { md5: digest };
|
||||
}
|
||||
|
||||
// the firmware recognises the recovery image by its app description, the mock by its file name
|
||||
if (fileExtension === 'bin' && fileName.startsWith('EMS-ESP-Recovery')) {
|
||||
const md5_ok = pendingFirmwareMd5;
|
||||
pendingFirmwareMd5 = false;
|
||||
system_status.recovery_version = '1.0.0';
|
||||
console.log('Recovery firmware installed');
|
||||
return md5_ok ? { recovery: true, md5_ok } : { recovery: true };
|
||||
}
|
||||
|
||||
if (fileExtension === 'bin' && pendingFirmwareMd5) {
|
||||
pendingFirmwareMd5 = false;
|
||||
console.log('Firmware MD5 matches');
|
||||
@@ -5437,6 +5498,10 @@ router
|
||||
} else if (action === 'uploadURL') {
|
||||
// upload URL
|
||||
console.log('upload File from URL', content.param);
|
||||
if (String(content.param).toLowerCase().includes('recovery')) {
|
||||
// the recovery app is installed without a restart
|
||||
system_status.recovery_version = '1.0.0';
|
||||
}
|
||||
return status(200);
|
||||
} else if (action === 'resetMQTT') {
|
||||
// reset MQTT
|
||||
|
||||
+51
-22
@@ -2,36 +2,46 @@
|
||||
|
||||
[common]
|
||||
; custom build flags to use in my_build_flags
|
||||
; -DEMSESP_UART_DEBUG ; debugging UART
|
||||
; -DEMSESP_DEBUG ; enables DEBUG to the log. Will generate a lot of extra traffic on Console and Syslog
|
||||
; -DEMSESP_DEFAULT_BOARD_PROFILE=\"NODEMCU\" ; hard code the default board name
|
||||
; -DEMSESP_DEBUG_SENSOR ; additional debug for the sensors
|
||||
; -DCORE_DEBUG_LEVEL=5 ; verbose core level debugging 5=verbose, 4=debug, 3=info
|
||||
; -DEMSESP_TEST ; enable the tests. EN language only
|
||||
; -DEMSESP_DE_ONLY ; only DE translated entity names
|
||||
; -DEMSESP_EN_ONLY ; only EN translated entity names
|
||||
; -DEMSESP_PINGTEST ; send log message every 1/2 second
|
||||
; -D EMSESP_UART_DEBUG ; debugging UART
|
||||
; -D EMSESP_DEBUG ; enables DEBUG to the log. Will generate a lot of extra traffic on Console and Syslog
|
||||
; -D EMSESP_DEFAULT_BOARD_PROFILE=\"NODEMCU\" ; hard code the default board name
|
||||
; -D EMSESP_DEBUG_SENSOR ; additional debug for the sensors
|
||||
; -D CORE_DEBUG_LEVEL=5 ; verbose core level debugging 5=verbose, 4=debug, 3=info
|
||||
; -D EMSESP_TEST ; enable the tests. EN language only
|
||||
; -D EMSESP_DE_ONLY ; only DE translated entity names
|
||||
; -D EMSESP_EN_ONLY ; only EN translated entity names
|
||||
; -D EMSESP_PINGTEST ; send log message every 1/2 second
|
||||
; -D EMSESP_GATEWAY_TX_HOLD ; hold Tx while a K30RF/KM200 gateway is syncing, see issue #3094
|
||||
; -D EMSESP_TX_PRE_DELAY=1000 ; wait this many us before each Tx so we don't overlap the master's break, see issue #3094
|
||||
|
||||
; my_build_flags = -DEMSESP_TEST -DEMSESP_DEBUG
|
||||
; my_build_flags = -DEMSESP_DEBUG -DEMSESP_TEST -DEMSESP_PINGTEST
|
||||
; my_build_flags = -DEMSESP_DEBUG -DCORE_DEBUG_LEVEL=5
|
||||
; my_build_flags = -DEMSESP_EN_ONLY -DEMSESP_TEST -DCORE_DEBUG_LEVEL=5 -DARDUINO_USB_CDC_ON_BOOT=1
|
||||
my_build_flags =
|
||||
; my_build_flags = -DEMSESP_EN_ONLY
|
||||
; my_build_flags = -D EMSESP_GATEWAY_TX_HOLD -D EMSESP_TX_PRE_DELAY=1000
|
||||
my_build_flags = -DEMSESP_DEBUG
|
||||
; my_build_flags =
|
||||
|
||||
[platformio]
|
||||
default_envs = s_16M_P ; BBQKees E32V2
|
||||
; default_envs = build-webUI ; build the web interface only
|
||||
; default_envs = s3_16M_P ; BBQKees S3
|
||||
; default_envs = s_4M ; BBQKees older S32, 4MB no psram
|
||||
; default_envs = s_16M ; BBQKees newer S32 V2, 16MB no psram
|
||||
; default_envs = c6 ; XIAO ESP32C
|
||||
; default_envs = debug
|
||||
default_envs = s_16M_P, recovery_s_16M_P, build-recovery, build-webUI
|
||||
; default_envs = s_16M_P ; BBQKees E32V2
|
||||
; default_envs = s_16M_P_custom ; BBQKees E32V2 with custom Arduino/IDF libs
|
||||
; default_envs = recovery_s_16M_P ; recovery firmware
|
||||
; default_envs = crashtest_s_16M_P ; crash test
|
||||
; default_envs = build-webUI ; build the main ems-esp webUI only
|
||||
; default_envs = build-recovery ; build the recovery WebUI only
|
||||
; default_envs = s3_16M_P ; BBQKees S3 firmware
|
||||
; default_envs = s_4M ; firmware forBBQKees older S32, 4MB no psram
|
||||
; default_envs = s_16M ; firmware for BBQKees newer S32 V2, 16MB no psram
|
||||
; default_envs = c6 ; firmware for XIAO ESP32C
|
||||
; default_envs = debug ; see below
|
||||
|
||||
[env]
|
||||
|
||||
; uncomment if you want to upload the firmware via OTA (must have upload_protocol = custom)
|
||||
extra_scripts =
|
||||
pre:scripts/build_interface.py ; builds the WebUI
|
||||
scripts/rename_fw.py ; renames the firmware .bin file - comment out if not needed
|
||||
post:scripts/rename_fw.py ; renames the firmware .bin file
|
||||
scripts/upload.py ; optionally upload the firmware via OTA (must have upload_protocol = custom)
|
||||
|
||||
; set the username and password for the admin account (default password is admin)
|
||||
@@ -40,13 +50,33 @@ custom_password = admin
|
||||
|
||||
; ** upload settings **
|
||||
; for direct USB, which is the default you can set an optional port for example:
|
||||
; upload_port = /dev/ttyUSB0 or COM3
|
||||
; upload_speed = 921600
|
||||
; upload_port = /dev/ttyUSB0
|
||||
; upload_port = /dev/ttyACM0
|
||||
|
||||
; CH340G bridge tops out here reliably; drop back to 460800 if uploads fail
|
||||
; 2000000 is the max speed for the CH340G bridge
|
||||
upload_speed = 921600
|
||||
|
||||
; for direct via HTTP (OTA) set:
|
||||
; upload_protocol = custom
|
||||
; custom_emsesp_ip = <ip address> or ems-esp.local
|
||||
|
||||
upload_protocol = custom
|
||||
|
||||
custom_emsesp_ip = ems-esp.local ; E32V2.2 (test)
|
||||
; custom_emsesp_ip = ems-espP.local ; E32V2.2 (prod)
|
||||
; custom_emsesp_ip = ems-esps32.local ; S32, 4MB no psramS32
|
||||
; custom_emsesp_ip = ems-espS3.local ; ems-espS3
|
||||
|
||||
; lib_deps =
|
||||
; bblanchon/ArduinoJson @ 7.4.3
|
||||
; ESP32Async/AsyncTCP @ 3.4.10
|
||||
; ESP32Async/ESPAsyncWebServer @ 3.11.0
|
||||
; https://github.com/mobizt/ReadyMail.git @ 0.4.0
|
||||
; https://github.com/mobizt/ESP_SSLClient.git @ 3.1.3
|
||||
; https://github.com/emsesp/EMS-ESP-Modules.git @ 1.0.8
|
||||
; ; file://${PROJECT_DIR}/../modules/EMS-ESP-Modules
|
||||
|
||||
; ** debug settings **
|
||||
; to be used with an ESP32-S3 which has an onboard JTAG, connect the OTG USB port to the PC
|
||||
; if using an external JTAG board like the ESP-PROG set debug_tool = esp-prog, and use zadig to set the correct driver for the USB port
|
||||
@@ -69,4 +99,3 @@ debug_init_break =
|
||||
upload_speed = 2000000
|
||||
monitor_speed = 115200
|
||||
extra_scripts =
|
||||
; debug_build_flags = -Og -g2 -ggdb2
|
||||
+136
-23
@@ -6,10 +6,12 @@
|
||||
; For example: EMS-ESP-3_7_0-dev_31-esp32-16MB+.bin
|
||||
;
|
||||
; boards can be found in https://github.com/pioarduino/platform-espressif32/tree/main/boards
|
||||
; Tasmota platform 2026.09.50 needs PlatformIO Core >=6.2.0. Official platformio is still
|
||||
; 6.1.19 — install pioarduino instead (see CONTRIBUTING.md). `pio upgrade` will not get 6.2.0.
|
||||
|
||||
[platformio]
|
||||
; build all supported targets. Use pio_local.ini to override and build specific targets.
|
||||
default_envs = s_4M, s_16M, s_16M_P, s3_16M_P
|
||||
default_envs = s_4M, s_16M, s_16M_P, s3_16M_P, recovery_s_16M_P, recovery_s3_16M_P
|
||||
|
||||
description = EMS-ESP Firmware for the ESP32
|
||||
src_dir = src
|
||||
@@ -21,8 +23,8 @@ extra_configs =
|
||||
pio_local.ini
|
||||
|
||||
[common]
|
||||
core_build_flags = -std=gnu++20 -O3 -flto=auto -Wno-type-limits -Wall -Wextra -Wno-unused-parameter -Wno-unused-variable -Wno-format -Wno-missing-field-initializers
|
||||
core_unbuild_flags = -std=gnu++11 -std=gnu++14 -std=gnu++17 -fno-lto
|
||||
core_build_flags = -Wall -Wextra -Wno-type-limits -Wno-unused-parameter -Wno-unused-variable -Wno-format -Wno-missing-field-initializers
|
||||
core_unbuild_flags = -O2 -O3 -Og -fno-lto
|
||||
|
||||
my_build_flags =
|
||||
|
||||
@@ -31,25 +33,22 @@ build_flags =
|
||||
${factory_settings.build_flags}
|
||||
${common.my_build_flags}
|
||||
-I./src/core
|
||||
; core 3.3.12 uses spi_host_device_t in ETH.h without including it. A framework
|
||||
; refresh restores that header, so force the declaration on every compile.
|
||||
-include hal/spi_types.h
|
||||
-D ARDUINO_ARCH_ESP32
|
||||
-D ONEWIRE_CRC16=0
|
||||
-D CONFIG_UART_ISR_IN_IRAM
|
||||
; AsyncTCP
|
||||
-D CONFIG_ASYNC_TCP_MAX_ACK_TIME=5000 ; default
|
||||
-D CONFIG_ASYNC_TCP_PRIORITY=10 ; default
|
||||
-D CONFIG_ASYNC_TCP_QUEUE_SIZE=64 ; default
|
||||
-D CONFIG_ASYNC_TCP_RUNNING_CORE=1 ; force async_tcp task to be on same core as Arduino app (default is any core)
|
||||
-D CONFIG_ASYNC_TCP_STACK_SIZE=8192 ; default is 16KB/8192*2
|
||||
; ESPAsyncWebServer
|
||||
; -D WS_MAX_QUEUED_MESSAGES=0 ; not used, default 8
|
||||
-D ENABLE_PSRAM ; incase BOARD_HAS_PSRAM is not set, so boards without PSRAM keep using the internal heap
|
||||
; -D SSE_MAX_QUEUED_MESSAGES=1 ; for log messages, default 32
|
||||
; ESP_SSLClient
|
||||
-D ENABLE_PSRAM ; take the ~17KB of TLS buffers from PSRAM instead of the internal heap. The library
|
||||
; needs BOARD_HAS_PSRAM too, so boards without PSRAM keep using the internal heap
|
||||
-D EMSESP_MQTT_RUNNING_CORE=1 ; default 1
|
||||
; -D EMSESP_MQTT_STACKSIZE=5120 ; default
|
||||
-D EMSESP_UART_RUNNING_CORE=1 ; default any core
|
||||
; -D EMSESP_UART_STACKSIZE=2560 ; default
|
||||
; -D EMSESP_UART_STACKSIZE=2560 ; 5120 is default
|
||||
; -D EMSESP_GATEWAY_TX_HOLD ; hold Tx while a K30RF/KM200 gateway is syncing, see issue #3094
|
||||
; -D EMSESP_TX_PRE_DELAY=1000 ; wait this many us before each Tx so we don't overlap the master's break, see issue #3094
|
||||
|
||||
unbuild_flags =
|
||||
${common.core_unbuild_flags}
|
||||
@@ -58,32 +57,35 @@ unbuild_flags =
|
||||
[espressif32_base_4M]
|
||||
framework = arduino
|
||||
board_build.partitions = partitions/esp32_partition_4M.csv
|
||||
board_upload.flash_size = 4MB
|
||||
board_build.app_partition_name = app0
|
||||
platform = https://github.com/tasmota/platform-espressif32/releases/download/2026.05.50/platform-espressif32.zip ; Tasmota Arduino Core 3.3.8 based on IDF 5.5.4.260407
|
||||
|
||||
; 16MB Flash variants
|
||||
[espressif32_base_16M]
|
||||
framework = arduino
|
||||
board_build.partitions = partitions/esp32_partition_16M.csv
|
||||
board_upload.flash_size = 16MB
|
||||
board_build.app_partition_name = app0
|
||||
platform = https://github.com/tasmota/platform-espressif32/releases/download/2026.05.50/platform-espressif32.zip ; Tasmota Arduino Core 3.3.8 based on IDF 5.5.4.260407
|
||||
build_flags =
|
||||
${common.build_flags}
|
||||
-D EMSESP_HAS_RECOVERY ; installs and falls back to the recovery firmware in the factory partition
|
||||
-D OTA_UPDATER_ANY_PARTITION ; so the recovery firmware can be uploaded into the factory partition
|
||||
|
||||
; 32MB Flash variants
|
||||
[espressif32_base_32M]
|
||||
framework = arduino
|
||||
board_build.partitions = partitions/esp32_partition_32M.csv
|
||||
board_upload.flash_size = 32MB
|
||||
board_build.app_partition_name = app0
|
||||
platform = https://github.com/tasmota/platform-espressif32/releases/download/2026.05.50/platform-espressif32.zip ; Tasmota Arduino Core 3.3.8 based on IDF 5.5.4.260407
|
||||
|
||||
[env]
|
||||
; use own Ardunio libs packages from https://github.com/emsesp/esp32-arduino-lib-builder which is based on Arduino Core 3.3.12 and IDF 5.5.5. Same as s_16M_P_custom
|
||||
; with Tasmota's tool chain (platform)
|
||||
; if we want to go back to Tasmota libs, use the following:
|
||||
; platform = https://github.com/tasmota/platform-espressif32/releases/download/2026.05.50/platform-espressif32.zip ; Tasmota Arduino Core 3.3.8 based on IDF 5.5.4.260407
|
||||
platform = https://github.com/tasmota/platform-espressif32/releases/download/2026.09.50/platform-espressif32.zip
|
||||
platform_packages = framework-arduinoespressif32 @ https://github.com/emsesp/esp32-arduino-lib-builder/releases/download/1410-0850-5.5.5/framework-arduinoespressif32-v5.5.5-a63b839f.tar.xz
|
||||
|
||||
build_flags =
|
||||
${common.build_flags}
|
||||
build_unflags =
|
||||
${common.unbuild_flags}
|
||||
extra_scripts =
|
||||
pre:scripts/enable_ccache.py ; uses ccache when it is installed
|
||||
post:scripts/rename_fw.py ; renames the firmware .bin file
|
||||
monitor_speed = 115200
|
||||
monitor_filters = direct, esp32_exception_decoder
|
||||
@@ -106,6 +108,14 @@ extra_scripts = pre:scripts/build_interface.py
|
||||
; Exclude all source files so the C code doesn't build
|
||||
build_src_filter = -<*>
|
||||
|
||||
; builds the recovery web interface only (recovery/WWWData.h), not the recovery firmware
|
||||
[env:build-recovery]
|
||||
platform = native
|
||||
targets = build
|
||||
extra_scripts = pre:scripts/build_recovery_interface.py
|
||||
; Exclude all source files so the C code doesn't build
|
||||
build_src_filter = -<*>
|
||||
|
||||
;
|
||||
; Builds for different board types
|
||||
; If you're building for a single target environment, we recommend creating a pio_local.ini (see example file)
|
||||
@@ -126,6 +136,50 @@ board = s_16M
|
||||
extends = espressif32_base_16M
|
||||
board = s_16M_P
|
||||
|
||||
[env:s_16M_P_custom]
|
||||
; same as s_16M_P, but the Arduino/IDF libs are rebuilt with HybridCompile using the custom_sdkconfig below
|
||||
; this is same procedure as in the esp32-arduino-lib-builder repository (configs/defconfig.ems-esp)
|
||||
; SPI Ethernet is left enabled: Arduino ETH.cpp needs those types; unused W5500/DM9051 objects are GC'd
|
||||
extends = env:s_16M_P
|
||||
custom_sdkconfig =
|
||||
'# CONFIG_BT_ENABLED is not set'
|
||||
'# CONFIG_LWIP_PPP_SUPPORT is not set'
|
||||
'# CONFIG_LWIP_IP_FORWARD is not set'
|
||||
'# CONFIG_LWIP_IPV4_NAPT is not set'
|
||||
'# CONFIG_LWIP_IPV4_NAPT_PORTMAP is not set'
|
||||
'# CONFIG_ULP_COPROC_ENABLED is not set'
|
||||
'# CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS is not set'
|
||||
'# CONFIG_FREERTOS_USE_TRACE_FACILITY is not set'
|
||||
'# CONFIG_FREERTOS_ENABLE_TASK_SNAPSHOT is not set'
|
||||
'# CONFIG_ESP_MODEM_USE_PPP_MODE is not set'
|
||||
'# CONFIG_ESP_GDBSTUB_ENABLED is not set'
|
||||
'# CONFIG_ESP_NETIF_REPORT_DATA_TRAFFIC is not set'
|
||||
'# CONFIG_MDNS_ENABLE_CONSOLE_CLI is not set'
|
||||
'# CONFIG_MDNS_ENABLE_BROWSE is not set'
|
||||
'# CONFIG_I2S_ISR_IRAM_SAFE is not set'
|
||||
'# CONFIG_RINGBUF_PLACE_ISR_FUNCTIONS_INTO_FLASH is not set'
|
||||
'CONFIG_UART_ISR_IN_IRAM=y'
|
||||
'CONFIG_SPIRAM_MALLOC_ALWAYSINTERNAL=256'
|
||||
'CONFIG_SPIRAM_MALLOC_RESERVE_INTERNAL=1024'
|
||||
'CONFIG_ESP_WIFI_ESPNOW_MAX_ENCRYPT_NUM=0'
|
||||
'CONFIG_ESP_WIFI_STATIC_RX_BUFFER_NUM=6'
|
||||
'CONFIG_ESP_WIFI_DYNAMIC_RX_BUFFER_NUM=16'
|
||||
'CONFIG_ESP_WIFI_DYNAMIC_TX_BUFFER_NUM=16'
|
||||
'CONFIG_ESP_WIFI_MGMT_SBUF_NUM=16'
|
||||
'CONFIG_ESP_WIFI_RX_BA_WIN=4'
|
||||
'CONFIG_ESP_WIFI_TX_BA_WIN=4'
|
||||
'CONFIG_MDNS_MAX_SERVICES=4'
|
||||
'CONFIG_MDNS_ACTION_QUEUE_LEN=8'
|
||||
'CONFIG_ETH_DMA_RX_BUFFER_NUM=6'
|
||||
'CONFIG_ETH_DMA_TX_BUFFER_NUM=6'
|
||||
'CONFIG_LWIP_MAX_ACTIVE_TCP=10'
|
||||
'CONFIG_LWIP_MAX_LISTENING_TCP=8'
|
||||
'CONFIG_LWIP_MAX_UDP_PCBS=8'
|
||||
'CONFIG_LWIP_MAX_RAW_PCBS=4'
|
||||
'CONFIG_LWIP_IPV6_NUM_ADDRESSES=2'
|
||||
'CONFIG_LWIP_LOCAL_HOSTNAME="ems-esp"'
|
||||
'CONFIG_TINYUSB_DESC_CDC_STRING="EMS-ESP CDC Device"'
|
||||
|
||||
[env:s3_16M_P]
|
||||
; 16MB ESP32-S3 - with PSRAM - like a BBQKees S3
|
||||
extends = espressif32_base_16M
|
||||
@@ -158,12 +212,69 @@ build_flags =
|
||||
; XIAO ESP32C - 512KB SRAM & 4MB Flash - https://wiki.seeedstudio.com/xiao_esp32c6_getting_started/
|
||||
[env:c6_xiao_4M]
|
||||
extends = espressif32_base_4M
|
||||
board_build.app_partition_name = app0
|
||||
board = seeed_xiao_esp32c6
|
||||
build_flags =
|
||||
${common.build_flags}
|
||||
-DEMSESP_EN_ONLY
|
||||
|
||||
; Crash test builds for the bootloader rollback and the recovery fallback. Install with the web upload, not over
|
||||
; serial, since a serial flash boots the recovery. Change EMSESP_CRASH_TEST to pick the test:
|
||||
; 1 crashes on every start, so the bootloader rolls back to the previous firmware
|
||||
; 2 runs normally until it's confirmed after 2 minutes, then crashes on every start. After 5 crashes in a row
|
||||
; it switches to the recovery firmware. Without one it keeps crashing until reflashed over serial
|
||||
[env:crashtest_s_16M_P]
|
||||
extends = env:s_16M_P
|
||||
build_flags =
|
||||
${espressif32_base_16M.build_flags}
|
||||
-D EMSESP_CRASH_TEST=1
|
||||
custom_fw_name = EMS-ESP-CrashTest
|
||||
|
||||
; EMS-ESP Recovery - a small app for the factory (boot) partition with an access point and a firmware upload page.
|
||||
; Only for boards with a factory partition (16MB). Sources are in ./recovery, the WebUI is built from ./interface.
|
||||
; build: pio run -e recovery_s3_16M_P
|
||||
; flash into the boot partition: pio run -e recovery_s3_16M_P -t upload
|
||||
; run it from app0 like a normal app instead, add an [env:recovery_s3_16M_P] section to pio_local.ini with custom_app_offset =
|
||||
; develop the WebUI with a mock: cd interface && pnpm recovery
|
||||
[recovery_base]
|
||||
extends = espressif32_base_16M
|
||||
board_build.app_partition_name = boot
|
||||
build_src_filter = -<*> +<../recovery/>
|
||||
build_flags =
|
||||
${factory_settings.build_flags}
|
||||
-std=gnu++20 -Os
|
||||
-D OTA_UPDATER_ANY_PARTITION
|
||||
-D CONFIG_ASYNC_TCP_RUNNING_CORE=1
|
||||
-D CONFIG_ASYNC_TCP_STACK_SIZE=8192
|
||||
build_unflags =
|
||||
${common.core_unbuild_flags}
|
||||
lib_ldf_mode = chain
|
||||
lib_deps =
|
||||
bblanchon/ArduinoJson @ 7.4.3
|
||||
ESP32Async/AsyncTCP @ 3.5.0
|
||||
ESP32Async/ESPAsyncWebServer @ 3.12.1
|
||||
extra_scripts =
|
||||
pre:scripts/build_recovery_interface.py
|
||||
post:scripts/set_app_desc.py
|
||||
post:scripts/rename_fw.py
|
||||
post:scripts/upload_factory.py
|
||||
upload_protocol = esptool
|
||||
custom_app_offset = 0x10000
|
||||
custom_fw_name = EMS-ESP-Recovery
|
||||
custom_version_file = recovery/recovery_version.h
|
||||
custom_version_define = RECOVERY_VERSION
|
||||
|
||||
[env:recovery_s_16M]
|
||||
extends = recovery_base
|
||||
board = s_16M
|
||||
|
||||
[env:recovery_s_16M_P]
|
||||
extends = recovery_base
|
||||
board = s_16M_P
|
||||
|
||||
[env:recovery_s3_16M_P]
|
||||
extends = recovery_base
|
||||
board = s3_16M_P
|
||||
|
||||
; foundation for building and testing natively, standalone without an ESP32
|
||||
; use the `standalone` environment instead of `native` for testing
|
||||
[env:native]
|
||||
@@ -194,6 +305,7 @@ build_src_filter =
|
||||
-<../lib/uuid-syslog>
|
||||
-<../lib/eModbus>
|
||||
-<../lib/OneWire>
|
||||
-<../lib/OtaUpdater>
|
||||
-<../src/core/ModuleLibrary.cpp>
|
||||
lib_ldf_mode = off
|
||||
lib_deps =
|
||||
@@ -237,6 +349,7 @@ build_src_filter =
|
||||
-<../lib/uuid-syslog>
|
||||
-<../lib/eModbus>
|
||||
-<../lib/OneWire>
|
||||
-<../lib/OtaUpdater>
|
||||
-<../src/core/ModuleLibrary.cpp>
|
||||
lib_ldf_mode = off
|
||||
lib_deps = Unity
|
||||
|
||||
+8
-1
@@ -786,7 +786,6 @@ negcond
|
||||
negheat
|
||||
nein
|
||||
nennvolumentstrom
|
||||
neopixel
|
||||
netcmp
|
||||
netflowtemp
|
||||
netif
|
||||
@@ -1352,3 +1351,11 @@ zyxwvutsrqponmlkjihgfedcba
|
||||
öffnen
|
||||
česky
|
||||
živanović
|
||||
neopixel
|
||||
firmwares
|
||||
recognises
|
||||
POWERON
|
||||
crashtest
|
||||
otadata
|
||||
merase
|
||||
Seeed
|
||||
@@ -0,0 +1,932 @@
|
||||
// EMS-ESP Recovery
|
||||
//
|
||||
// Minimal application that lives in the factory ("boot") partition. It brings up a WiFi access point
|
||||
// and connects to the network with the saved EMS-ESP settings (Ethernet, else WiFi, with the static IP
|
||||
// if one is set), and serves a cut-down WebUI to upload a new EMS-ESP firmware into the OTA partitions
|
||||
// or select which OTA partition to boot.
|
||||
//
|
||||
// The EMS-ESP settings in LittleFS are only ever read, never written, except for a factory reset
|
||||
// which erases them.
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <ArduinoJson.h>
|
||||
#include <AsyncJson.h>
|
||||
#include <DNSServer.h>
|
||||
#include <ESPAsyncWebServer.h>
|
||||
#include <ESPmDNS.h>
|
||||
#include <LittleFS.h>
|
||||
#include <OtaUpdater.h>
|
||||
#include <Preferences.h>
|
||||
#include <WiFi.h>
|
||||
|
||||
#if CONFIG_IDF_TARGET_ESP32
|
||||
#include <ETH.h>
|
||||
#endif
|
||||
|
||||
#include <esp_image_format.h>
|
||||
#include <esp_ota_ops.h>
|
||||
#include <esp_partition.h>
|
||||
#include <esp_random.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <vector>
|
||||
|
||||
#include "WWWData.h" // generated by `pnpm build-recovery` in ./interface
|
||||
#include "recovery_version.h"
|
||||
|
||||
#ifndef FACTORY_AP_CHANNEL
|
||||
#define FACTORY_AP_CHANNEL 1
|
||||
#endif
|
||||
|
||||
static constexpr size_t MIN_FIRMWARE_SIZE = 512 * 1024; // smallest .bin accepted by the upload, anything smaller is rejected
|
||||
static constexpr uint32_t RESTART_DELAY_MS = 500; // delay before restarting, so the HTTP response is sent first
|
||||
static constexpr uint32_t ETH_WAIT_MS = 15000; // how long Ethernet gets to obtain an address before WiFi is tried
|
||||
static constexpr char NVS_NAMESPACE[] = "ems-esp";
|
||||
static constexpr char NVS_FRESH_FW[] = "fresh_firmware"; // EMSESP_NVS_BOOT_NEW_FIRMWARE in emsesp.h
|
||||
static constexpr char NVS_REASON[] = "rec_reason"; // EMSESP_NVS_RECOVERY_REASON in emsesp.h
|
||||
|
||||
#define LOG(...) \
|
||||
do { \
|
||||
Serial.print("[recovery] "); \
|
||||
Serial.printf(__VA_ARGS__); \
|
||||
Serial.println(); \
|
||||
} while (0)
|
||||
|
||||
struct AdminUser {
|
||||
String username;
|
||||
String password;
|
||||
};
|
||||
|
||||
struct Settings {
|
||||
String ap_ssid = FACTORY_AP_SSID;
|
||||
String ap_password = FACTORY_AP_PASSWORD;
|
||||
uint8_t ap_channel = FACTORY_AP_CHANNEL;
|
||||
IPAddress ap_ip;
|
||||
IPAddress ap_gateway;
|
||||
IPAddress ap_subnet;
|
||||
String wifi_ssid = FACTORY_WIFI_SSID;
|
||||
String wifi_password = FACTORY_WIFI_PASSWORD;
|
||||
String hostname = FACTORY_NETWORK_HOSTNAME;
|
||||
bool static_ip = false;
|
||||
IPAddress local_ip;
|
||||
IPAddress gateway_ip;
|
||||
IPAddress subnet_mask;
|
||||
IPAddress dns_ip_1;
|
||||
IPAddress dns_ip_2;
|
||||
bool eth_10mbit = false;
|
||||
uint8_t phy_type = 0; // PHY_type in src/core/system.h, 0 is no Ethernet
|
||||
int8_t eth_power = -1;
|
||||
uint8_t eth_phy_addr = 0;
|
||||
uint8_t eth_clock_mode = 0;
|
||||
bool disable_reset = false; // factory reset is disabled in the EMS-ESP settings
|
||||
std::vector<AdminUser> admins;
|
||||
};
|
||||
|
||||
struct PartitionInfo {
|
||||
String label;
|
||||
bool factory;
|
||||
uint32_t address;
|
||||
uint32_t size;
|
||||
bool valid;
|
||||
uint32_t image_size;
|
||||
String version;
|
||||
uint32_t install_date; // UTC epoch, 0 if unknown
|
||||
String ota_state; // bootloader rollback state, empty if the partition has never been selected
|
||||
};
|
||||
|
||||
static AsyncWebServer server(80);
|
||||
static DNSServer dnsServer;
|
||||
static Settings settings;
|
||||
static OtaUpdater ota;
|
||||
|
||||
static std::vector<PartitionInfo> partitions;
|
||||
static uint32_t restart_at = 0;
|
||||
static String session_token;
|
||||
static String ap_ssid;
|
||||
static String recovery_reason; // set by EMS-ESP when it switched to the recovery
|
||||
static const char * eth_state = ""; // "", "disabled", "failed" or "started"
|
||||
static uint32_t wifi_join_at = 0; // when to try WiFi if Ethernet has no address, 0 if not waiting
|
||||
static bool wifi_started = false;
|
||||
|
||||
static bool ethConnected();
|
||||
|
||||
// upload state, only one upload at a time
|
||||
static int upload_error = 0;
|
||||
static bool upload_done = false;
|
||||
static String upload_target;
|
||||
static size_t upload_size = 0; // content length, for the progress
|
||||
static size_t upload_step = 0; // last progress step logged, in tens of percent
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// settings (read-only)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
static bool readJsonFile(const char * path, JsonDocument & doc) {
|
||||
File file = LittleFS.open(path, "r");
|
||||
if (!file) {
|
||||
return false;
|
||||
}
|
||||
const DeserializationError error = deserializeJson(doc, file);
|
||||
file.close();
|
||||
if (error) {
|
||||
LOG("Failed to parse %s: %s", path, error.c_str());
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static IPAddress parseIP(JsonVariantConst value, const char * fallback) {
|
||||
IPAddress ip;
|
||||
if (!ip.fromString(value | fallback)) {
|
||||
ip.fromString(fallback);
|
||||
}
|
||||
return ip;
|
||||
}
|
||||
|
||||
static void loadSettings() {
|
||||
// never format the filesystem, it holds the EMS-ESP settings
|
||||
const bool mounted = LittleFS.begin(false);
|
||||
LOG("LittleFS %s", mounted ? "mounted" : "not available, using factory settings");
|
||||
|
||||
JsonDocument doc;
|
||||
if (mounted && readJsonFile("/config/apSettings.json", doc)) {
|
||||
settings.ap_ssid = doc["ssid"] | FACTORY_AP_SSID;
|
||||
settings.ap_password = doc["password"] | FACTORY_AP_PASSWORD;
|
||||
settings.ap_channel = doc["channel"] | FACTORY_AP_CHANNEL;
|
||||
}
|
||||
settings.ap_ip = parseIP(doc["local_ip"], FACTORY_AP_LOCAL_IP);
|
||||
settings.ap_gateway = parseIP(doc["gateway_ip"], FACTORY_AP_GATEWAY_IP);
|
||||
settings.ap_subnet = parseIP(doc["subnet_mask"], FACTORY_AP_SUBNET_MASK);
|
||||
|
||||
doc.clear();
|
||||
if (mounted && readJsonFile("/config/networkSettings.json", doc)) {
|
||||
settings.wifi_ssid = doc["ssid"] | FACTORY_WIFI_SSID;
|
||||
settings.wifi_password = doc["password"] | FACTORY_WIFI_PASSWORD;
|
||||
settings.hostname = doc["hostname"] | FACTORY_NETWORK_HOSTNAME;
|
||||
settings.eth_10mbit = doc["eth_10mbit"] | false;
|
||||
settings.local_ip.fromString(doc["local_ip"] | "");
|
||||
settings.gateway_ip.fromString(doc["gateway_ip"] | "");
|
||||
settings.subnet_mask.fromString(doc["subnet_mask"] | "");
|
||||
settings.dns_ip_1.fromString(doc["dns_ip_1"] | "");
|
||||
settings.dns_ip_2.fromString(doc["dns_ip_2"] | "");
|
||||
settings.static_ip = (doc["static_ip_config"] | false) && settings.local_ip != IPAddress();
|
||||
}
|
||||
|
||||
// the Ethernet PHY settings come from the board profile, stored with the EMS-ESP application settings
|
||||
doc.clear();
|
||||
if (mounted && readJsonFile("/config/emsespSettings.json", doc)) {
|
||||
settings.phy_type = doc["phy_type"] | 0;
|
||||
settings.eth_power = doc["eth_power"] | -1;
|
||||
settings.eth_phy_addr = doc["eth_phy_addr"] | 0;
|
||||
settings.eth_clock_mode = doc["eth_clock_mode"] | 0;
|
||||
settings.disable_reset = doc["disable_reset"] | false;
|
||||
}
|
||||
|
||||
doc.clear();
|
||||
if (mounted && readJsonFile("/config/securitySettings.json", doc)) {
|
||||
for (JsonObject user : doc["users"].as<JsonArray>()) {
|
||||
if (user["admin"] | false) {
|
||||
settings.admins.push_back({user["username"] | "", user["password"] | ""});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (settings.admins.empty()) {
|
||||
// the admin user couldn't be read, so sign in with the factory login but never allow a factory reset
|
||||
settings.disable_reset = true;
|
||||
settings.admins.push_back({FACTORY_ADMIN_USERNAME, FACTORY_ADMIN_PASSWORD});
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
LittleFS.end();
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// partitions
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
static bool openNvs(Preferences & nvs, bool readOnly) {
|
||||
if (esp_partition_find_first(ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_NVS, "nvs1")) {
|
||||
return nvs.begin(NVS_NAMESPACE, readOnly, "nvs1");
|
||||
}
|
||||
return nvs.begin(NVS_NAMESPACE, readOnly, "nvs");
|
||||
}
|
||||
|
||||
static const char * otaStateName(esp_ota_img_states_t state) {
|
||||
switch (state) {
|
||||
case ESP_OTA_IMG_NEW:
|
||||
return "new";
|
||||
case ESP_OTA_IMG_PENDING_VERIFY:
|
||||
return "pending";
|
||||
case ESP_OTA_IMG_VALID:
|
||||
return "valid";
|
||||
case ESP_OTA_IMG_INVALID:
|
||||
return "invalid";
|
||||
case ESP_OTA_IMG_ABORTED:
|
||||
return "aborted";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
static const char * resetReasonName(esp_reset_reason_t reason) {
|
||||
switch (reason) {
|
||||
case ESP_RST_POWERON:
|
||||
return "power_on";
|
||||
case ESP_RST_EXT:
|
||||
return "external";
|
||||
case ESP_RST_SW:
|
||||
return "software";
|
||||
case ESP_RST_PANIC:
|
||||
return "panic";
|
||||
case ESP_RST_INT_WDT:
|
||||
case ESP_RST_TASK_WDT:
|
||||
case ESP_RST_WDT:
|
||||
return "watchdog";
|
||||
case ESP_RST_BROWNOUT:
|
||||
return "brownout";
|
||||
default:
|
||||
return "other";
|
||||
}
|
||||
}
|
||||
|
||||
// one-shot, so a later restart into the recovery doesn't show a stale reason
|
||||
static void loadRecoveryReason() {
|
||||
Preferences nvs;
|
||||
if (!openNvs(nvs, false)) {
|
||||
return;
|
||||
}
|
||||
if (nvs.isKey(NVS_REASON)) {
|
||||
recovery_reason = nvs.getString(NVS_REASON);
|
||||
nvs.remove(NVS_REASON);
|
||||
LOG("EMS-ESP switched to the recovery: %s", recovery_reason.c_str());
|
||||
}
|
||||
nvs.end();
|
||||
}
|
||||
|
||||
static void loadPartitions() {
|
||||
partitions.clear();
|
||||
|
||||
Preferences nvs;
|
||||
const bool has_nvs = openNvs(nvs, true);
|
||||
|
||||
const esp_partition_t * running = esp_ota_get_running_partition();
|
||||
esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_ANY, nullptr);
|
||||
for (; it != nullptr; it = esp_partition_next(it)) {
|
||||
const esp_partition_t * part = esp_partition_get(it);
|
||||
|
||||
PartitionInfo info;
|
||||
info.label = part->label;
|
||||
info.factory = part->subtype == ESP_PARTITION_SUBTYPE_APP_FACTORY;
|
||||
info.address = part->address;
|
||||
info.size = part->size;
|
||||
info.image_size = 0;
|
||||
info.install_date = 0;
|
||||
|
||||
esp_image_metadata_t meta = {};
|
||||
esp_partition_pos_t pos = {.offset = part->address, .size = part->size};
|
||||
info.valid = esp_image_verify(ESP_IMAGE_VERIFY_SILENT, &pos, &meta) == ESP_OK;
|
||||
if (info.valid) {
|
||||
info.image_size = meta.image_len;
|
||||
}
|
||||
|
||||
esp_ota_img_states_t state;
|
||||
if (!info.factory && esp_ota_get_state_partition(part, &state) == ESP_OK) {
|
||||
info.ota_state = otaStateName(state);
|
||||
}
|
||||
|
||||
if (part == running) {
|
||||
info.version = RECOVERY_VERSION;
|
||||
// EMS-ESP stores the install date when it installs the recovery. Not set if it was flashed over serial
|
||||
if (has_nvs) {
|
||||
info.install_date = nvs.getULong(("d_" + info.label).c_str(), 0);
|
||||
}
|
||||
} else if (info.valid) {
|
||||
// EMS-ESP stores the version and install date of each partition in NVS, keyed by the label
|
||||
if (has_nvs && nvs.isKey(part->label)) {
|
||||
info.version = nvs.getString(part->label);
|
||||
info.install_date = nvs.getULong(("d_" + info.label).c_str(), 0);
|
||||
}
|
||||
esp_app_desc_t desc;
|
||||
if (info.version.isEmpty() && esp_ota_get_partition_description(part, &desc) == ESP_OK) {
|
||||
info.version = desc.version;
|
||||
}
|
||||
}
|
||||
|
||||
LOG("Partition %s: %s%s, %uKB at 0x%x%s%s%s%s",
|
||||
info.label.c_str(),
|
||||
info.factory ? "factory, " : "",
|
||||
info.valid ? "valid" : "empty",
|
||||
static_cast<unsigned>(info.size / 1024),
|
||||
static_cast<unsigned>(info.address),
|
||||
info.version.isEmpty() ? "" : ", v",
|
||||
info.version.c_str(),
|
||||
info.ota_state.isEmpty() ? "" : ", ota state ",
|
||||
info.ota_state.c_str());
|
||||
|
||||
partitions.push_back(info);
|
||||
}
|
||||
esp_partition_iterator_release(it);
|
||||
|
||||
if (has_nvs) {
|
||||
nvs.end();
|
||||
}
|
||||
}
|
||||
|
||||
static const esp_partition_t * findAppPartition(const char * label) {
|
||||
return esp_partition_find_first(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_ANY, label);
|
||||
}
|
||||
|
||||
static bool failedToStart(const PartitionInfo & info) {
|
||||
return info.ota_state == "aborted" || info.ota_state == "invalid";
|
||||
}
|
||||
|
||||
// An empty OTA partition if there is one, then one whose firmware failed to start, so a working firmware
|
||||
// is kept. Otherwise the one with the oldest install date, keeping the most recent firmware as a fallback.
|
||||
static const esp_partition_t * defaultUploadTarget() {
|
||||
const PartitionInfo * best = nullptr;
|
||||
auto rank = [](const PartitionInfo & info) { return !info.valid ? 0 : failedToStart(info) ? 1 : 2; };
|
||||
for (const auto & info : partitions) {
|
||||
if (info.factory) {
|
||||
continue;
|
||||
}
|
||||
if (best == nullptr || rank(info) < rank(*best) || (rank(info) == rank(*best) && info.install_date < best->install_date)) {
|
||||
best = &info;
|
||||
}
|
||||
}
|
||||
return best ? findAppPartition(best->label.c_str()) : esp_ota_get_next_update_partition(nullptr);
|
||||
}
|
||||
|
||||
static const esp_partition_t * uploadTarget(AsyncWebServerRequest * request) {
|
||||
if (!request->hasParam("partition")) {
|
||||
return defaultUploadTarget();
|
||||
}
|
||||
const esp_partition_t * part = findAppPartition(request->getParam("partition")->value().c_str());
|
||||
if (part == nullptr || part->subtype == ESP_PARTITION_SUBTYPE_APP_FACTORY || part == esp_ota_get_running_partition()) {
|
||||
return nullptr;
|
||||
}
|
||||
return part;
|
||||
}
|
||||
|
||||
// tells EMS-ESP it's running a newly flashed firmware, and drops the now stale version info for that partition
|
||||
static void markFreshFirmware(const String & label) {
|
||||
Preferences nvs;
|
||||
if (!openNvs(nvs, false)) {
|
||||
LOG("Cannot open NVS, EMS-ESP won't know it's a new firmware");
|
||||
return;
|
||||
}
|
||||
nvs.putBool(NVS_FRESH_FW, true);
|
||||
if (!label.isEmpty()) {
|
||||
nvs.remove(label.c_str());
|
||||
nvs.remove(("d_" + label).c_str());
|
||||
}
|
||||
nvs.end();
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// authentication
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
static String generateToken() {
|
||||
uint8_t bytes[16];
|
||||
esp_fill_random(bytes, sizeof(bytes));
|
||||
String token;
|
||||
token.reserve(sizeof(bytes) * 2);
|
||||
for (uint8_t b : bytes) {
|
||||
char hex[3];
|
||||
snprintf(hex, sizeof(hex), "%02x", b);
|
||||
token += hex;
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
static bool isAuthorized(AsyncWebServerRequest * request) {
|
||||
const bool ok = !session_token.isEmpty() && request->hasHeader("Authorization") && request->header("Authorization") == "Bearer " + session_token;
|
||||
if (!ok) {
|
||||
LOG("Unauthorized request to %s", request->url().c_str());
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// web handlers
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
class StaticContentHandler : public AsyncWebHandler {
|
||||
public:
|
||||
bool canHandle(AsyncWebServerRequest * request) const override {
|
||||
return request->method() == HTTP_GET || request->method() == HTTP_HEAD;
|
||||
}
|
||||
|
||||
void handleRequest(AsyncWebServerRequest * request) override {
|
||||
const WWWAsset * asset = lookup(request->url().c_str());
|
||||
if (asset == nullptr) {
|
||||
asset = lookup("/index.html"); // SPA fallback, also catches captive portal probes
|
||||
}
|
||||
if (asset == nullptr) {
|
||||
request->send(404);
|
||||
return;
|
||||
}
|
||||
|
||||
const String & inm = request->header(asyncsrv::T_INM);
|
||||
if (inm.length() != 0 && strcmp(inm.c_str(), asset->etag) == 0) {
|
||||
request->send(304);
|
||||
return;
|
||||
}
|
||||
|
||||
AsyncWebServerResponse * response = request->beginResponse(200, asset->contentType, asset->content, asset->len);
|
||||
response->addHeader(asyncsrv::T_Content_Encoding, asyncsrv::T_gzip, false);
|
||||
response->addHeader(asyncsrv::T_ETag, asset->etag, false);
|
||||
request->send(response);
|
||||
}
|
||||
|
||||
private:
|
||||
static const WWWAsset * lookup(const char * url) {
|
||||
for (size_t i = 0; i < WWW_ASSETS_COUNT; i++) {
|
||||
if (strcmp(WWW_ASSETS[i].uri, url) == 0) {
|
||||
return &WWW_ASSETS[i];
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
};
|
||||
|
||||
static void handleStatus(AsyncWebServerRequest * request) {
|
||||
auto * response = new AsyncJsonResponse();
|
||||
JsonObject root = response->getRoot().to<JsonObject>();
|
||||
|
||||
root["recovery_version"] = RECOVERY_VERSION;
|
||||
root["authenticated"] = isAuthorized(request);
|
||||
root["hostname"] = settings.hostname;
|
||||
root["chip"] = ESP.getChipModel();
|
||||
root["flash_size"] = ESP.getFlashChipSize();
|
||||
root["psram_size"] = ESP.getPsramSize();
|
||||
root["free_heap"] = ESP.getFreeHeap();
|
||||
root["mac"] = WiFi.macAddress();
|
||||
root["uptime"] = millis() / 1000;
|
||||
root["reason"] = recovery_reason;
|
||||
root["reset_reason"] = resetReasonName(esp_reset_reason());
|
||||
root["ap_ssid"] = ap_ssid;
|
||||
root["ap_ip"] = WiFi.softAPIP().toString();
|
||||
root["ap_clients"] = WiFi.softAPgetStationNum();
|
||||
root["wifi_ssid"] = settings.wifi_ssid;
|
||||
root["wifi_connected"] = WiFi.isConnected();
|
||||
if (WiFi.isConnected()) {
|
||||
root["wifi_ip"] = WiFi.localIP().toString();
|
||||
root["wifi_rssi"] = WiFi.RSSI();
|
||||
}
|
||||
root["wifi_started"] = wifi_started;
|
||||
root["static_ip"] = settings.static_ip;
|
||||
root["disable_reset"] = settings.disable_reset;
|
||||
|
||||
// only on boards with Ethernet configured
|
||||
if (eth_state[0] != '\0') {
|
||||
#if CONFIG_IDF_TARGET_ESP32
|
||||
if (ethConnected()) {
|
||||
root["eth_state"] = "connected";
|
||||
root["eth_ip"] = ETH.localIP().toString();
|
||||
} else if (strcmp(eth_state, "started") == 0) {
|
||||
root["eth_state"] = ETH.linkUp() ? "connecting" : "no_link";
|
||||
} else
|
||||
#endif
|
||||
{
|
||||
root["eth_state"] = eth_state;
|
||||
}
|
||||
}
|
||||
|
||||
const esp_partition_t * boot = esp_ota_get_boot_partition();
|
||||
const esp_partition_t * running = esp_ota_get_running_partition();
|
||||
const esp_partition_t * target = defaultUploadTarget();
|
||||
root["running"] = running ? running->label : "";
|
||||
root["boot"] = boot ? boot->label : "";
|
||||
root["upload_target"] = target ? target->label : "";
|
||||
|
||||
JsonArray parts = root["partitions"].to<JsonArray>();
|
||||
for (const auto & info : partitions) {
|
||||
JsonObject p = parts.add<JsonObject>();
|
||||
p["label"] = info.label;
|
||||
p["factory"] = info.factory;
|
||||
p["address"] = info.address;
|
||||
p["size"] = info.size;
|
||||
p["valid"] = info.valid;
|
||||
p["image_size"] = info.image_size;
|
||||
p["version"] = info.version;
|
||||
p["install_date"] = info.install_date;
|
||||
p["ota_state"] = info.ota_state;
|
||||
}
|
||||
|
||||
response->setLength();
|
||||
request->send(response);
|
||||
}
|
||||
|
||||
static void handleSignIn(AsyncWebServerRequest * request, JsonVariant & json) {
|
||||
const String username = json["username"] | "";
|
||||
const String password = json["password"] | "";
|
||||
|
||||
for (const auto & admin : settings.admins) {
|
||||
if (admin.username == username && admin.password == password) {
|
||||
session_token = generateToken();
|
||||
|
||||
auto * response = new AsyncJsonResponse();
|
||||
JsonObject root = response->getRoot().to<JsonObject>();
|
||||
root["access_token"] = session_token;
|
||||
response->setLength();
|
||||
request->send(response);
|
||||
LOG("User %s signed in", username.c_str());
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
LOG("Failed sign in for user %s", username.c_str());
|
||||
request->send(401);
|
||||
}
|
||||
|
||||
static void handleSetBoot(AsyncWebServerRequest * request, JsonVariant & json) {
|
||||
if (!isAuthorized(request)) {
|
||||
request->send(401);
|
||||
return;
|
||||
}
|
||||
|
||||
const String label = json["partition"] | "";
|
||||
const esp_partition_t * part = findAppPartition(label.c_str());
|
||||
if (part == nullptr) {
|
||||
LOG("Cannot boot from %s: no such partition", label.c_str());
|
||||
request->send(404);
|
||||
return;
|
||||
}
|
||||
|
||||
// esp_ota_set_boot_partition() validates the image first
|
||||
const esp_err_t err = esp_ota_set_boot_partition(part);
|
||||
if (err != ESP_OK) {
|
||||
LOG("Cannot boot from %s: %s", part->label, esp_err_to_name(err));
|
||||
request->send(400);
|
||||
return;
|
||||
}
|
||||
|
||||
LOG("Next boot from %s, restarting", part->label);
|
||||
request->send(200);
|
||||
restart_at = millis() + RESTART_DELAY_MS;
|
||||
}
|
||||
|
||||
static void handleRestart(AsyncWebServerRequest * request) {
|
||||
if (!isAuthorized(request)) {
|
||||
request->send(401);
|
||||
return;
|
||||
}
|
||||
LOG("Restart requested");
|
||||
request->send(200);
|
||||
restart_at = millis() + RESTART_DELAY_MS;
|
||||
}
|
||||
|
||||
// factory reset, erases all EMS-ESP settings and restarts
|
||||
// only signed in admins are authorized (the recovery only accepts admin users), and only if not disabled in the settings
|
||||
static void handleFactoryReset(AsyncWebServerRequest * request) {
|
||||
if (!isAuthorized(request)) {
|
||||
request->send(401);
|
||||
return;
|
||||
}
|
||||
if (settings.disable_reset) {
|
||||
LOG("Factory reset disabled");
|
||||
request->send(403);
|
||||
return;
|
||||
}
|
||||
|
||||
LOG("Factory reset requested");
|
||||
const bool ok = LittleFS.format(); // the only place the recovery writes to the filesystem
|
||||
LOG("Factory reset %s", ok ? "done" : "failed");
|
||||
if (!ok) {
|
||||
request->send(500);
|
||||
return;
|
||||
}
|
||||
|
||||
request->send(200);
|
||||
restart_at = millis() + RESTART_DELAY_MS;
|
||||
}
|
||||
|
||||
static void uploadFail(AsyncWebServerRequest * request, int code, const char * reason) {
|
||||
if (upload_error) {
|
||||
return;
|
||||
}
|
||||
upload_error = code;
|
||||
LOG("Upload error %d: %s", code, reason);
|
||||
request->send(code);
|
||||
if (code == 406) {
|
||||
request->client()->close();
|
||||
}
|
||||
}
|
||||
|
||||
static void handleUpload(AsyncWebServerRequest * request, const String & filename, size_t index, uint8_t * data, size_t len, bool final) {
|
||||
if (!index) {
|
||||
upload_error = 0;
|
||||
upload_done = false;
|
||||
upload_size = 0;
|
||||
upload_step = 0;
|
||||
upload_target.clear();
|
||||
|
||||
if (!isAuthorized(request)) {
|
||||
uploadFail(request, 403, "not signed in");
|
||||
return;
|
||||
}
|
||||
|
||||
const size_t filesize = request->contentLength();
|
||||
switch (OtaUpdater::classify(filename, filesize, MIN_FIRMWARE_SIZE)) {
|
||||
case OtaUpdater::FileType::FIRMWARE:
|
||||
break;
|
||||
case OtaUpdater::FileType::MD5:
|
||||
if (ota.setMd5(data, len)) {
|
||||
LOG("MD5 digest received (%s)", ota.md5());
|
||||
} else {
|
||||
uploadFail(request, 406, "invalid MD5 digest");
|
||||
}
|
||||
return;
|
||||
default:
|
||||
// the recovery app only flashes firmware, it never touches the settings
|
||||
ota.clearMd5();
|
||||
LOG("Unsupported file %s (%u bytes)", filename.c_str(), static_cast<unsigned>(filesize));
|
||||
uploadFail(request, 406, "unsupported file");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!OtaUpdater::isCompatibleFirmware(data, len)) {
|
||||
uploadFail(request, 503, "not a firmware image for this chip");
|
||||
return;
|
||||
}
|
||||
if (OtaUpdater::isRecoveryFirmware(data, len)) {
|
||||
uploadFail(request, 409, "the recovery firmware can't be installed from the recovery");
|
||||
return;
|
||||
}
|
||||
|
||||
const esp_partition_t * target = uploadTarget(request);
|
||||
if (target == nullptr) {
|
||||
if (request->hasParam("partition")) {
|
||||
LOG("Invalid upload target %s", request->getParam("partition")->value().c_str());
|
||||
}
|
||||
uploadFail(request, 400, "no valid target partition");
|
||||
return;
|
||||
}
|
||||
if (!ota.beginFirmware(filesize, target)) {
|
||||
LOG("Cannot write to %s: %s", target->label, ota.errorString());
|
||||
uploadFail(request, 507, "cannot start the update");
|
||||
return;
|
||||
}
|
||||
upload_target = target->label;
|
||||
upload_size = filesize;
|
||||
LOG("Uploading %s (%uKB) to %s (%s)%s",
|
||||
filename.c_str(),
|
||||
static_cast<unsigned>(filesize / 1024),
|
||||
target->label,
|
||||
request->hasParam("partition") ? "selected" : "default target",
|
||||
ota.md5Applied() ? ", MD5 check enabled" : "");
|
||||
|
||||
request->onDisconnect([] {
|
||||
if (!upload_done && !upload_target.isEmpty()) {
|
||||
LOG("Upload aborted at %uKB, the client disconnected", static_cast<unsigned>(ota.progress() / 1024));
|
||||
ota.abort();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (upload_error || upload_target.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ota.write(data, len)) {
|
||||
LOG("Write failed at %u: %s", static_cast<unsigned>(ota.progress()), ota.errorString());
|
||||
ota.abort();
|
||||
uploadFail(request, 500, "write failed");
|
||||
return;
|
||||
}
|
||||
|
||||
// log the progress every 10%, the content length is a little more than the image so it may stop just short of 100%
|
||||
if (upload_size > 0) {
|
||||
const size_t step = std::min<size_t>(ota.progress() * 10 / upload_size, 10);
|
||||
if (step > upload_step) {
|
||||
upload_step = step;
|
||||
LOG("Uploading to %s, %u%%", upload_target.c_str(), static_cast<unsigned>(step * 10));
|
||||
}
|
||||
}
|
||||
|
||||
if (final) {
|
||||
// on success this also makes the new partition the boot partition
|
||||
if (!ota.end()) {
|
||||
LOG("Upload failed: %s", ota.errorString());
|
||||
uploadFail(request, 500, "image check failed");
|
||||
return;
|
||||
}
|
||||
LOG("Upload finished, the image is verified");
|
||||
upload_done = true;
|
||||
}
|
||||
}
|
||||
|
||||
static void handleUploadComplete(AsyncWebServerRequest * request) {
|
||||
if (upload_error) {
|
||||
return; // response already sent
|
||||
}
|
||||
|
||||
if (upload_done) {
|
||||
LOG("Firmware written to %s", upload_target.c_str());
|
||||
markFreshFirmware(upload_target);
|
||||
loadPartitions();
|
||||
// treat the new upload as the most recent, so it isn't the default target for the next one
|
||||
for (auto & info : partitions) {
|
||||
if (info.label == upload_target) {
|
||||
info.install_date = UINT32_MAX;
|
||||
}
|
||||
}
|
||||
|
||||
if (ota.md5Applied()) {
|
||||
auto * response = new AsyncJsonResponse();
|
||||
JsonObject root = response->getRoot().to<JsonObject>();
|
||||
root["md5_ok"] = true;
|
||||
response->setLength();
|
||||
request->send(response);
|
||||
} else {
|
||||
request->send(200);
|
||||
}
|
||||
ota.clearMd5();
|
||||
return;
|
||||
}
|
||||
|
||||
if (ota.hasMd5()) {
|
||||
auto * response = new AsyncJsonResponse();
|
||||
JsonObject root = response->getRoot().to<JsonObject>();
|
||||
root["md5"] = ota.md5();
|
||||
response->setLength();
|
||||
request->send(response);
|
||||
return;
|
||||
}
|
||||
|
||||
request->send(500);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// setup & loop
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
static void joinWiFi() {
|
||||
wifi_join_at = 0;
|
||||
if (settings.wifi_ssid.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
WiFi.mode(WIFI_AP_STA);
|
||||
if (settings.static_ip) {
|
||||
WiFi.config(settings.local_ip, settings.gateway_ip, settings.subnet_mask, settings.dns_ip_1, settings.dns_ip_2);
|
||||
}
|
||||
WiFi.begin(settings.wifi_ssid.c_str(), settings.wifi_password.c_str());
|
||||
wifi_started = true;
|
||||
LOG("Connecting to WiFi %s%s", settings.wifi_ssid.c_str(), settings.static_ip ? (" with IP " + settings.local_ip.toString()).c_str() : "");
|
||||
}
|
||||
|
||||
// same bring-up as Network::startEthernet() in src/core/network.cpp
|
||||
static bool startEthernet() {
|
||||
#if CONFIG_IDF_TARGET_ESP32
|
||||
if (settings.phy_type == 0) {
|
||||
return false;
|
||||
}
|
||||
const auto clock = static_cast<eth_clock_mode_t>(settings.eth_clock_mode);
|
||||
if (ESP.getPsramSize() && (clock == ETH_CLOCK_GPIO16_OUT || clock == ETH_CLOCK_GPIO17_OUT)) {
|
||||
LOG("Ethernet clock mode %d conflicts with PSRAM, Ethernet disabled", settings.eth_clock_mode);
|
||||
eth_state = "disabled";
|
||||
return false;
|
||||
}
|
||||
|
||||
// reset the PHY, it doesn't always start after a warm boot
|
||||
if (settings.eth_power != -1) {
|
||||
pinMode(settings.eth_power, OUTPUT);
|
||||
digitalWrite(settings.eth_power, LOW);
|
||||
delay(500);
|
||||
digitalWrite(settings.eth_power, HIGH);
|
||||
}
|
||||
|
||||
if (settings.eth_10mbit) {
|
||||
ETH.setAutoNegotiation(false);
|
||||
ETH.setLinkSpeed(10);
|
||||
ETH.setFullDuplex(false);
|
||||
}
|
||||
|
||||
const eth_phy_type_t type = settings.phy_type == 1 ? ETH_PHY_LAN8720 : settings.phy_type == 2 ? ETH_PHY_TLK110 : ETH_PHY_RTL8201;
|
||||
if (!ETH.begin(type, settings.eth_phy_addr, 23, 18, settings.eth_power, clock)) {
|
||||
LOG("Ethernet failed to start, no PHY found");
|
||||
eth_state = "failed";
|
||||
return false;
|
||||
}
|
||||
if (settings.static_ip) {
|
||||
ETH.config(settings.local_ip, settings.gateway_ip, settings.subnet_mask, settings.dns_ip_1, settings.dns_ip_2);
|
||||
}
|
||||
eth_state = "started";
|
||||
LOG("Ethernet started%s", settings.static_ip ? (" with IP " + settings.local_ip.toString()).c_str() : "");
|
||||
return true;
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
static bool ethConnected() {
|
||||
#if CONFIG_IDF_TARGET_ESP32
|
||||
return strcmp(eth_state, "started") == 0 && ETH.hasIP();
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
static void startNetwork() {
|
||||
// the reason is logged once per change, since a missing network disconnects again every few seconds
|
||||
WiFi.onEvent(
|
||||
[](arduino_event_id_t, arduino_event_info_t info) {
|
||||
static uint8_t last_reason = 0;
|
||||
if (info.wifi_sta_disconnected.reason != last_reason) {
|
||||
last_reason = info.wifi_sta_disconnected.reason;
|
||||
LOG("WiFi disconnected, reason %d", last_reason);
|
||||
}
|
||||
},
|
||||
ARDUINO_EVENT_WIFI_STA_DISCONNECTED);
|
||||
|
||||
WiFi.persistent(false);
|
||||
WiFi.setHostname(settings.hostname.c_str()); // also the default for the Ethernet interface
|
||||
WiFi.mode(WIFI_AP);
|
||||
|
||||
// max SSID length is 32
|
||||
ap_ssid = settings.ap_ssid.substring(0, 23) + "-recovery";
|
||||
WiFi.softAPConfig(settings.ap_ip, settings.ap_gateway, settings.ap_subnet);
|
||||
// WPA2 needs at least 8 characters, otherwise fall back to the factory password
|
||||
const String & ap_password = settings.ap_password.length() >= 8 ? settings.ap_password : String(FACTORY_AP_PASSWORD);
|
||||
WiFi.softAP(ap_ssid.c_str(), ap_password.c_str(), settings.ap_channel);
|
||||
LOG("Access point %s started on %s", ap_ssid.c_str(), WiFi.softAPIP().toString().c_str());
|
||||
|
||||
// like EMS-ESP, Ethernet first and WiFi only if Ethernet doesn't get an address, as both share a static IP
|
||||
if (startEthernet()) {
|
||||
wifi_join_at = millis() + ETH_WAIT_MS;
|
||||
} else {
|
||||
joinWiFi();
|
||||
}
|
||||
|
||||
dnsServer.start(53, "*", WiFi.softAPIP());
|
||||
|
||||
if (MDNS.begin(settings.hostname.c_str())) {
|
||||
MDNS.addService("http", "tcp", 80);
|
||||
}
|
||||
}
|
||||
|
||||
static void startWebServer() {
|
||||
server.on("/rest/recovery/status", HTTP_GET, handleStatus);
|
||||
server.on("/rest/recovery/restart", HTTP_POST, handleRestart);
|
||||
server.on("/rest/recovery/format", HTTP_POST, handleFactoryReset);
|
||||
server.on("/rest/uploadFile", HTTP_POST, handleUploadComplete, handleUpload);
|
||||
server.addHandler(new AsyncCallbackJsonWebHandler("/rest/signIn", handleSignIn));
|
||||
server.addHandler(new AsyncCallbackJsonWebHandler("/rest/recovery/boot", handleSetBoot));
|
||||
server.addHandler(new StaticContentHandler()); // must be last
|
||||
server.begin();
|
||||
}
|
||||
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
LOG("EMS-ESP Recovery v%s", RECOVERY_VERSION);
|
||||
|
||||
const esp_partition_t * boot = esp_ota_get_boot_partition();
|
||||
LOG("Reset reason: %s, running %s, next boot %s", resetReasonName(esp_reset_reason()), esp_ota_get_running_partition()->label, boot ? boot->label : "unknown");
|
||||
|
||||
loadSettings();
|
||||
loadRecoveryReason();
|
||||
loadPartitions();
|
||||
startNetwork();
|
||||
startWebServer();
|
||||
}
|
||||
|
||||
void loop() {
|
||||
dnsServer.processNextRequest();
|
||||
|
||||
static bool was_connected = false;
|
||||
if (WiFi.isConnected() != was_connected) {
|
||||
was_connected = WiFi.isConnected();
|
||||
if (was_connected) {
|
||||
LOG("WiFi connected, IP %s", WiFi.localIP().toString().c_str());
|
||||
} else {
|
||||
LOG("WiFi connection lost");
|
||||
}
|
||||
}
|
||||
|
||||
#if CONFIG_IDF_TARGET_ESP32
|
||||
static bool eth_was_connected = false;
|
||||
if (ethConnected() != eth_was_connected) {
|
||||
eth_was_connected = ethConnected();
|
||||
if (eth_was_connected) {
|
||||
LOG("Ethernet connected, IP %s", ETH.localIP().toString().c_str());
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
if (wifi_join_at && (int32_t)(millis() - wifi_join_at) >= 0) {
|
||||
if (ethConnected()) {
|
||||
wifi_join_at = 0;
|
||||
} else {
|
||||
LOG("No Ethernet connection");
|
||||
joinWiFi();
|
||||
}
|
||||
}
|
||||
|
||||
if (restart_at && (int32_t)(millis() - restart_at) >= 0) {
|
||||
ESP.restart();
|
||||
}
|
||||
|
||||
delay(10);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
#define RECOVERY_VERSION "1.0.0"
|
||||
@@ -110,6 +110,12 @@ def build_webUI(*args, **kwargs):
|
||||
env.Exit(0)
|
||||
|
||||
|
||||
# The web interface is only built by the "build" target. Skip any other, e.g. `pio run -t upload` also runs this
|
||||
# environment when it's one of the default_envs, and it has nothing to upload
|
||||
if "build" not in [t.strip() for t in COMMAND_LINE_TARGETS]:
|
||||
print("Skipping the web interface build, the target is not 'build'")
|
||||
env.Exit(0)
|
||||
|
||||
# Create custom target that only runs the script and then exits, without continuing with the pio workflow
|
||||
env.AddCustomTarget(
|
||||
name="build",
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# Builds the Recovery WebUI into recovery/WWWData.h when it's missing or older than its sources.
|
||||
# In the build-recovery environment it only builds the WebUI, always.
|
||||
from pathlib import Path
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
Import("env")
|
||||
|
||||
INTERFACE_DIR = Path("interface")
|
||||
OUTPUT_FILE = Path("recovery") / "WWWData.h"
|
||||
SOURCES = [
|
||||
INTERFACE_DIR / "recovery.html",
|
||||
INTERFACE_DIR / "vite.config.ts",
|
||||
INTERFACE_DIR / "progmem-generator.js",
|
||||
INTERFACE_DIR / "src" / "recovery",
|
||||
INTERFACE_DIR / "src" / "components",
|
||||
INTERFACE_DIR / "src" / "CustomTheme.tsx",
|
||||
INTERFACE_DIR / "public",
|
||||
]
|
||||
|
||||
|
||||
def newest_mtime(paths):
|
||||
newest = 0.0
|
||||
for path in paths:
|
||||
if path.is_dir():
|
||||
for f in path.rglob("*"):
|
||||
if f.is_file():
|
||||
newest = max(newest, f.stat().st_mtime)
|
||||
elif path.exists():
|
||||
newest = max(newest, path.stat().st_mtime)
|
||||
return newest
|
||||
|
||||
|
||||
def is_stale():
|
||||
if not OUTPUT_FILE.exists():
|
||||
return True
|
||||
return newest_mtime(SOURCES) > OUTPUT_FILE.stat().st_mtime
|
||||
|
||||
|
||||
def build():
|
||||
pnpm = next((n for n in ("pnpm", "pnpm.cmd", "pnpm.exe") if shutil.which(n)), None)
|
||||
if pnpm is None:
|
||||
print("Error: pnpm not found in PATH, cannot build the Recovery WebUI")
|
||||
env.Exit(1)
|
||||
|
||||
os.environ["CI"] = "true"
|
||||
for command in (f"{pnpm} install", f"{pnpm} build-recovery"):
|
||||
print(f"Running: {command}")
|
||||
result = subprocess.run(command, shell=True, cwd=INTERFACE_DIR)
|
||||
if result.returncode != 0:
|
||||
print(f"Error: '{command}' failed")
|
||||
env.Exit(1)
|
||||
|
||||
|
||||
if env.get("PIOENV") == "build-recovery":
|
||||
# the WebUI is only built by the "build" target. Skip any other, e.g. `pio run -t upload` also runs this environment
|
||||
# when it's one of the default_envs, and it has nothing to upload
|
||||
if "build" not in [t.strip() for t in COMMAND_LINE_TARGETS]:
|
||||
print("Skipping the Recovery WebUI build, the target is not 'build'")
|
||||
env.Exit(0)
|
||||
|
||||
# `pio run -e build-recovery` only builds the WebUI, always, and exits without building any firmware
|
||||
def build_recovery_webUI(*args, **kwargs):
|
||||
print("Building Recovery WebUI...")
|
||||
build()
|
||||
print("Recovery WebUI build completed successfully!")
|
||||
env.Exit(0)
|
||||
|
||||
env.AddCustomTarget(
|
||||
name="build",
|
||||
dependencies=None,
|
||||
actions=[build_recovery_webUI],
|
||||
title="build recovery web interface",
|
||||
description="installs pnpm packages and builds the recovery web UI into recovery/WWWData.h",
|
||||
always_build=True,
|
||||
)
|
||||
elif is_stale():
|
||||
print("Building Recovery WebUI...")
|
||||
build()
|
||||
else:
|
||||
print("Recovery WebUI is up to date")
|
||||
+124
-17
@@ -1,26 +1,133 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Run like:
|
||||
# ./scripts/do_fresh_install.sh
|
||||
#
|
||||
# Full fresh install of EMS-ESP on an ESP32 16MB board (s_16M_P).
|
||||
#
|
||||
# Flash map (partitions/esp32_partition_16M.csv):
|
||||
# 0x1000 bootloader
|
||||
# 0x8000 partition table
|
||||
# 0xe000 otadata (boot_app0.bin selects app0)
|
||||
# 0x10000 recovery (factory / boot)
|
||||
# 0x490000 EMS-ESP (app0)
|
||||
# 0xdf0000 LittleFS
|
||||
#
|
||||
# Because otadata points at app0, the device boots EMS-ESP. Recovery stays
|
||||
# installed in factory for fallback. Do not write recovery at 0x1000 - that
|
||||
# is the bootloader, and the image overlaps the partition table at 0x8000.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
# Bold ANSI: 36 cyan, 33 yellow, 35 magenta, 32 green, 31 red.
|
||||
step() {
|
||||
local color=$1
|
||||
shift
|
||||
printf '\n\033[1;%sm▶ %s\033[0m\n' "$color" "$*"
|
||||
}
|
||||
note() {
|
||||
printf ' \033[2m%s\033[0m\n' "$*"
|
||||
}
|
||||
|
||||
ESPTOOL=~/.platformio/penv/bin/esptool
|
||||
PYTHON=~/.platformio/penv/bin/python
|
||||
|
||||
PORT=/dev/ttyUSB0
|
||||
BUILD=../.pio/build/s_16M_P
|
||||
BOOT_APP0=~/.platformio/packages/framework-arduinoespressif32/tools/partitions/boot_app0.bin
|
||||
BAUD=${BAUD:-2000000}
|
||||
# Fast is the installer's default: NVS, otadata, and the first 4 KB of every
|
||||
# app partition, so nothing old can boot. About a second. ERASE=full still
|
||||
# wipes the whole chip.
|
||||
ERASE=${ERASE:-fast}
|
||||
|
||||
# erase
|
||||
$ESPTOOL --chip esp32 --port $PORT --after no-reset erase-flash
|
||||
BUILD_DIR=../.pio/build/s_16M_P
|
||||
RECOVERY_DIR=../.pio/build/recovery_s_16M_P
|
||||
FIRMWARE_DIR=../build/firmware
|
||||
|
||||
# create the filesystem
|
||||
~/.platformio/penv/bin/python build_littlefs.py data $BUILD/littlefs.bin
|
||||
# bin files
|
||||
BOOT_APP0_BIN=~/.platformio/packages/framework-arduinoespressif32/tools/partitions/boot_app0.bin
|
||||
FIRMWARE_BIN=$BUILD_DIR/firmware.bin
|
||||
RECOVERY_BIN=$RECOVERY_DIR/firmware.bin
|
||||
BOOTLOADER_BIN=$BUILD_DIR/bootloader.bin
|
||||
PARTITIONS_BIN=$BUILD_DIR/partitions.bin
|
||||
LITTLEFS_BIN=$FIRMWARE_DIR/emsesp_settings_v39.bin
|
||||
|
||||
# upload filesystem (spiffs partition @ 0xdf0000), leave halted so it doesn't boot yet
|
||||
$ESPTOOL --chip esp32 --port $PORT --baud 460800 \
|
||||
--before default-reset --after no-reset \
|
||||
write-flash -z --flash-mode dio --flash-freq 80m --flash-size detect \
|
||||
0xdf0000 $BUILD/littlefs.bin
|
||||
# welcome message
|
||||
step 33 "Welcome to the EMS-ESP fresh install"
|
||||
note "This will install the latest EMS-ESP firmware and LittleFS"
|
||||
|
||||
# flash bootloader + partition table + app (app0 @ 0x490000), then boot
|
||||
$ESPTOOL --chip esp32 --port $PORT --baud 460800 \
|
||||
# check if platformio is installed
|
||||
if [[ ! -f $PYTHON ]]; then
|
||||
printf '\033[1;31mPlatformIO is not installed - please install it first\033[0m\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# check if esptool is installed
|
||||
if [[ ! -f $ESPTOOL ]]; then
|
||||
printf '\033[1;31mESPTool is not installed - please install it first\033[0m\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Build LittleFS from data folder
|
||||
step 33 "Building LittleFS from data folder"
|
||||
note "packs data/pre_load.json -> $LITTLEFS_BIN"
|
||||
$PYTHON build_littlefs.py data $LITTLEFS_BIN
|
||||
|
||||
# Check if all files are present
|
||||
step 33 "Checking images..."
|
||||
for f in \
|
||||
"$BOOTLOADER_BIN" \
|
||||
"$PARTITIONS_BIN" \
|
||||
"$FIRMWARE_BIN" \
|
||||
"$RECOVERY_BIN" \
|
||||
"$BOOT_APP0_BIN" \
|
||||
"$LITTLEFS_BIN"; do
|
||||
if [[ ! -f $f ]]; then
|
||||
printf '\033[1;31mMissing %s - build s_16M_P and recovery_s_16M_P first\033[0m\n' "$f" >&2
|
||||
exit 1
|
||||
fi
|
||||
note "ok $f"
|
||||
done
|
||||
|
||||
# show port, erase and baud rate
|
||||
step 33 "Port: $PORT, Erase: $ERASE, Baud: $BAUD"
|
||||
|
||||
# Fast erase on the 16 MB table: nvs, otadata, nvs1, and the starts of boot,
|
||||
# app0 and app1. Stay in the bootloader for the write that follows.
|
||||
if [[ $ERASE == full ]]; then
|
||||
step 33 "Full erase - wiping the whole chip"
|
||||
$ESPTOOL --chip esp32 --port $PORT --after no-reset erase-flash
|
||||
else
|
||||
step 33 "Fast erase - NVS, otadata, and the first 4 KB of every app"
|
||||
note "nothing old can boot; takes about a second"
|
||||
before=default-reset
|
||||
for spec in \
|
||||
"0x9000 0x5000 nvs" \
|
||||
"0xe000 0x2000 otadata" \
|
||||
"0x10000 0x1000 boot" \
|
||||
"0x490000 0x1000 app0" \
|
||||
"0x920000 0x1000 app1" \
|
||||
"0xdb0000 0x40000 nvs1"; do
|
||||
set -- $spec
|
||||
printf ' \033[33merase-region %s %s (%s)\033[0m\n' "$1" "$2" "$3"
|
||||
$ESPTOOL --chip esp32 --port $PORT --before $before --after no-reset \
|
||||
erase-region "$1" "$2"
|
||||
before=no-reset
|
||||
done
|
||||
fi
|
||||
|
||||
step 33 "Writing flash, then hard-reset into EMS-ESP"
|
||||
note "0x1000 bootloader 0x8000 partitions 0xe000 otadata"
|
||||
note "0x10000 recovery 0x490000 firmware 0xdf0000 LittleFS"
|
||||
$ESPTOOL --chip esp32 --port $PORT --baud $BAUD \
|
||||
--before default-reset --after hard-reset \
|
||||
write-flash -z --flash-mode dio --flash-freq 80m --flash-size detect \
|
||||
0x1000 $BUILD/bootloader.bin \
|
||||
0x8000 $BUILD/partitions.bin \
|
||||
0xe000 $BOOT_APP0 \
|
||||
0x490000 $BUILD/firmware.bin
|
||||
0x1000 $BOOTLOADER_BIN \
|
||||
0x8000 $PARTITIONS_BIN \
|
||||
0xe000 $BOOT_APP0_BIN \
|
||||
0x10000 $RECOVERY_BIN \
|
||||
0x490000 $FIRMWARE_BIN \
|
||||
0xdf0000 $LITTLEFS_BIN
|
||||
|
||||
step 32 "Done - gateway should boot EMS-ESP from app0"
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Wrap the C/C++ compilers with ccache when it is on PATH.
|
||||
# No-op locally unless ccache is installed; CI installs it via ccache-action.
|
||||
Import("env")
|
||||
|
||||
import shutil
|
||||
|
||||
ccache = shutil.which("ccache")
|
||||
if ccache:
|
||||
|
||||
def wrap(tool):
|
||||
cmd = env.get(tool)
|
||||
if not cmd:
|
||||
return
|
||||
resolved = env.subst(cmd) if isinstance(cmd, str) else cmd
|
||||
if isinstance(resolved, str):
|
||||
if resolved.startswith("ccache"):
|
||||
return
|
||||
env[tool] = f"{ccache} {resolved}"
|
||||
return
|
||||
if isinstance(resolved, (list, tuple)):
|
||||
if resolved and "ccache" in str(resolved[0]):
|
||||
return
|
||||
env[tool] = [ccache, *resolved]
|
||||
|
||||
wrap("CC")
|
||||
wrap("CXX")
|
||||
print("ccache enabled")
|
||||
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate src/ESP32React/tz_zones.h from interface/src/app/settings/TZ.tsx.
|
||||
|
||||
Run from the repo root:
|
||||
python3 scripts/generate_tz_zones.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
TZ_TSX = ROOT / "interface" / "src" / "app" / "settings" / "TZ.tsx"
|
||||
OUT_H = ROOT / "src" / "ESP32React" / "tz_zones.h"
|
||||
|
||||
|
||||
def parse_time_zones(text: str) -> list[tuple[str, str]]:
|
||||
match = re.search(
|
||||
r"export const TIME_ZONES: Record<string, string> = \{([\s\S]*?)\n\};",
|
||||
text,
|
||||
)
|
||||
if not match:
|
||||
raise SystemExit("TIME_ZONES table not found in TZ.tsx")
|
||||
entries = re.findall(r"'((?:\\'|[^'])*)': '((?:\\'|[^'])*)'", match.group(1))
|
||||
if not entries:
|
||||
raise SystemExit("no TIME_ZONES entries parsed")
|
||||
return sorted(entries, key=lambda item: item[0])
|
||||
|
||||
|
||||
def c_string(value: str) -> str:
|
||||
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
||||
|
||||
|
||||
def main() -> int:
|
||||
entries = parse_time_zones(TZ_TSX.read_text(encoding="utf-8"))
|
||||
max_format = max(len(fmt) for _, fmt in entries)
|
||||
|
||||
lines = [
|
||||
"/* Auto-generated by scripts/generate_tz_zones.py — do not edit. */",
|
||||
"#pragma once",
|
||||
"",
|
||||
"#include <stdlib.h>",
|
||||
"#include <string.h>",
|
||||
"",
|
||||
"struct EmsespTzZone {",
|
||||
" const char * label;",
|
||||
" const char * format;",
|
||||
"};",
|
||||
"",
|
||||
f"// Longest POSIX TZ value in the table is {max_format} chars.",
|
||||
"static constexpr size_t EMSESP_TZ_ZONE_COUNT = "
|
||||
f"{len(entries)};",
|
||||
"static const EmsespTzZone EMSESP_TZ_ZONES[EMSESP_TZ_ZONE_COUNT] = {",
|
||||
]
|
||||
for label, fmt in entries:
|
||||
lines.append(f" {{{c_string(label)}, {c_string(fmt)}}},")
|
||||
lines += [
|
||||
"};",
|
||||
"",
|
||||
"inline int emsesp_tz_zone_cmp(const void * key, const void * element) {",
|
||||
" return strcmp(static_cast<const char *>(key), static_cast<const EmsespTzZone *>(element)->label);",
|
||||
"}",
|
||||
"",
|
||||
"// Returns the POSIX TZ string for an IANA label, or nullptr if unknown.",
|
||||
"inline const char * emsesp_tz_format_for_label(const char * label) {",
|
||||
" if (label == nullptr || *label == '\\0') {",
|
||||
" return nullptr;",
|
||||
" }",
|
||||
" const auto * found = static_cast<const EmsespTzZone *>(",
|
||||
" bsearch(label, EMSESP_TZ_ZONES, EMSESP_TZ_ZONE_COUNT, sizeof(EmsespTzZone), emsesp_tz_zone_cmp));",
|
||||
" return found != nullptr ? found->format : nullptr;",
|
||||
"}",
|
||||
"",
|
||||
]
|
||||
OUT_H.write_text("\n".join(lines), encoding="utf-8")
|
||||
print(f"Wrote {OUT_H.relative_to(ROOT)} ({len(entries)} zones, max format {max_format})")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -35,7 +35,7 @@ summary=$(awk '
|
||||
}
|
||||
' "${added}" "${CHANGELOG}")
|
||||
|
||||
echo "Development build of EMS-ESP v${VERSION}."
|
||||
echo "Development build of EMS-ESP v${VERSION}"
|
||||
echo
|
||||
|
||||
if [ -n "${summary}" ]; then
|
||||
|
||||
@@ -11,14 +11,16 @@ OUTPUT_DIR = Path("build")
|
||||
def bin_copy(source, target, env):
|
||||
"""Optimized firmware renaming and copying function."""
|
||||
|
||||
# Get the application version from emsesp_version.h
|
||||
version_file = Path('./src/emsesp_version.h')
|
||||
# Get the application version, from emsesp_version.h unless overridden in the env
|
||||
fw_name = env.GetProjectOption("custom_fw_name", "EMS-ESP")
|
||||
version_file = Path(env.GetProjectOption("custom_version_file", "./src/emsesp_version.h"))
|
||||
version_define = env.GetProjectOption("custom_version_define", "EMSESP_APP_VERSION")
|
||||
if not version_file.exists():
|
||||
print("Error: emsesp_version.h not found!")
|
||||
print(f"Error: {version_file} not found!")
|
||||
return
|
||||
|
||||
app_version = None
|
||||
version_pattern = re.compile(r'^#define EMSESP_APP_VERSION\s+"(\S+)"')
|
||||
version_pattern = re.compile(rf'^#define {version_define}\s+"(\S+)"')
|
||||
|
||||
with version_file.open('r') as f:
|
||||
for line in f:
|
||||
@@ -28,7 +30,7 @@ def bin_copy(source, target, env):
|
||||
break
|
||||
|
||||
if not app_version:
|
||||
print("Error: Could not find EMSESP_APP_VERSION in emsesp_version.h!")
|
||||
print(f"Error: Could not find {version_define} in {version_file}!")
|
||||
return
|
||||
|
||||
# Get the chip type, in uppercase
|
||||
@@ -61,7 +63,7 @@ def bin_copy(source, target, env):
|
||||
|
||||
# Convert . to _ so Windows doesn't complain
|
||||
# Format is EMS-ESP-<version>-<mcu>-<flash> with + at the end if it has PSRAM
|
||||
variant = f"EMS-ESP-{app_version.replace('.', '_')}-{mcu}-{flash_mem}{'+' if psram else ''}"
|
||||
variant = f"{fw_name}-{app_version.replace('.', '_')}-{mcu}-{flash_mem}{'+' if psram else ''}"
|
||||
|
||||
# Create output directories
|
||||
firmware_dir = OUTPUT_DIR / "firmware"
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# Writes the project name (custom_fw_name) and version (custom_version_file/custom_version_define) into the
|
||||
# esp_app_desc_t of firmware.bin, which the prebuilt Arduino libraries leave empty. The application can then
|
||||
# identify the image with esp_ota_get_partition_description(), or from the first bytes of an upload.
|
||||
# Must run before rename_fw.py so the renamed copy and its MD5 include the change.
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
import struct
|
||||
from pathlib import Path
|
||||
|
||||
Import("env")
|
||||
|
||||
IMAGE_HEADER_LEN = 24
|
||||
SEGMENT_HEADER_LEN = 8
|
||||
APP_DESC_OFFSET = IMAGE_HEADER_LEN + SEGMENT_HEADER_LEN
|
||||
APP_DESC_MAGIC = 0xABCD5432
|
||||
HASH_APPENDED_OFFSET = 23
|
||||
|
||||
|
||||
def read_version(env):
|
||||
version_file = Path(env.GetProjectOption("custom_version_file", "./src/emsesp_version.h"))
|
||||
version_define = env.GetProjectOption("custom_version_define", "EMSESP_APP_VERSION")
|
||||
pattern = re.compile(rf'^#define {version_define}\s+"(\S+)"')
|
||||
for line in version_file.read_text().splitlines():
|
||||
match = pattern.match(line)
|
||||
if match:
|
||||
return match.group(1)
|
||||
raise ValueError(f"{version_define} not found in {version_file}")
|
||||
|
||||
|
||||
def put_string(image, offset, value):
|
||||
encoded = value.encode()[:31]
|
||||
image[offset:offset + 32] = encoded + b"\0" * (32 - len(encoded))
|
||||
|
||||
|
||||
def set_app_desc(source, target, env):
|
||||
path = Path(target[0].get_abspath())
|
||||
image = bytearray(path.read_bytes())
|
||||
|
||||
(magic,) = struct.unpack_from("<I", image, APP_DESC_OFFSET)
|
||||
if magic != APP_DESC_MAGIC:
|
||||
print(f"Error: no app description found in {path}")
|
||||
env.Exit(1)
|
||||
|
||||
name = env.GetProjectOption("custom_fw_name", "EMS-ESP")
|
||||
version = read_version(env)
|
||||
put_string(image, APP_DESC_OFFSET + 16, version)
|
||||
put_string(image, APP_DESC_OFFSET + 48, name)
|
||||
|
||||
# the description lives in the first segment, so the image checksum and SHA256 need updating
|
||||
checksum = 0xEF
|
||||
pos = IMAGE_HEADER_LEN
|
||||
for _ in range(image[1]):
|
||||
_, length = struct.unpack_from("<II", image, pos)
|
||||
pos += SEGMENT_HEADER_LEN
|
||||
for b in image[pos:pos + length]:
|
||||
checksum ^= b
|
||||
pos += length
|
||||
pos += 15 - (pos % 16)
|
||||
image[pos] = checksum
|
||||
pos += 1
|
||||
if image[HASH_APPENDED_OFFSET] == 1:
|
||||
image[pos:pos + 32] = hashlib.sha256(image[:pos]).digest()
|
||||
|
||||
path.write_bytes(image)
|
||||
print(f"App description set to {name} {version}")
|
||||
|
||||
|
||||
env.AddPostAction("$BUILD_DIR/${PROGNAME}.bin", set_app_desc)
|
||||
+12
-2
@@ -6,18 +6,28 @@
|
||||
# abort on first error so a failed lint/build isn't silently swallowed
|
||||
set -e
|
||||
|
||||
# pnpm refuses to self-update when it's a Corepack shim; Corepack pins the version via packageManager instead
|
||||
update_pnpm() {
|
||||
if readlink "$(command -v pnpm)" | grep -q corepack; then
|
||||
corepack use pnpm@latest
|
||||
else
|
||||
pnpm self-update
|
||||
fi
|
||||
}
|
||||
|
||||
cd interface
|
||||
rm -rf node_modules
|
||||
pnpm self-update
|
||||
update_pnpm
|
||||
pnpm update
|
||||
pnpm install
|
||||
pnpm format
|
||||
pnpm build-webUI
|
||||
pnpm build-recovery
|
||||
pnpm lint
|
||||
|
||||
cd ../mock-api
|
||||
rm -rf node_modules
|
||||
pnpm self-update
|
||||
update_pnpm
|
||||
pnpm update
|
||||
pnpm install
|
||||
pnpm format
|
||||
|
||||
+157
-67
@@ -14,12 +14,17 @@
|
||||
# and
|
||||
# extra_scripts = scripts/upload.py
|
||||
#
|
||||
# Prefer a numeric IP over .local — mDNS (especially under WSL2) adds latency
|
||||
# and can stall on IPv6 before falling back to IPv4.
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import socket
|
||||
import time
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
import hashlib
|
||||
from urllib.parse import urlparse
|
||||
import time
|
||||
import os
|
||||
from requests.adapters import HTTPAdapter
|
||||
|
||||
Import("env")
|
||||
|
||||
@@ -35,6 +40,10 @@ except ImportError:
|
||||
from tqdm import tqdm
|
||||
from termcolor import cprint
|
||||
|
||||
# http.client/urllib3 default is 8–16 KiB, which starves a LAN TCP window.
|
||||
UPLOAD_BLOCK_SIZE = 64 * 1024
|
||||
UPLOAD_SNDBUF = 256 * 1024
|
||||
|
||||
|
||||
def print_success(x):
|
||||
cprint(x, 'green')
|
||||
@@ -44,6 +53,43 @@ def print_fail(x):
|
||||
cprint(f'Error: {x}', 'red')
|
||||
|
||||
|
||||
def resolve_ipv4(host):
|
||||
"""Resolve hostname to IPv4 so we skip mDNS/IPv6 retries on every request."""
|
||||
if host.startswith('['):
|
||||
return host
|
||||
if ':' in host and host.count(':') == 1:
|
||||
host = host.rsplit(':', 1)[0]
|
||||
try:
|
||||
socket.inet_pton(socket.AF_INET, host)
|
||||
return host
|
||||
except OSError:
|
||||
pass
|
||||
infos = socket.getaddrinfo(host, 80, socket.AF_INET, socket.SOCK_STREAM)
|
||||
if not infos:
|
||||
raise socket.gaierror(f'No IPv4 address for {host}')
|
||||
return infos[0][4][0]
|
||||
|
||||
|
||||
class FastHTTPAdapter(HTTPAdapter):
|
||||
"""Larger write chunks and socket send buffer for streaming POSTs."""
|
||||
|
||||
def init_poolmanager(self, connections, maxsize, block=False, **pool_kwargs):
|
||||
socket_options = [
|
||||
(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1),
|
||||
(socket.SOL_SOCKET, socket.SO_SNDBUF, UPLOAD_SNDBUF),
|
||||
]
|
||||
pool_kwargs.setdefault('socket_options', socket_options)
|
||||
pool_kwargs.setdefault('blocksize', UPLOAD_BLOCK_SIZE)
|
||||
super().init_poolmanager(connections, maxsize, block=block, **pool_kwargs)
|
||||
|
||||
|
||||
def make_session():
|
||||
session = requests.Session()
|
||||
adapter = FastHTTPAdapter(pool_connections=2, pool_maxsize=2)
|
||||
session.mount('http://', adapter)
|
||||
return session
|
||||
|
||||
|
||||
def build_headers(host_ip, emsesp_url, content_type='application/json', access_token=None, extra_headers=None):
|
||||
"""Build common HTTP headers with optional overrides."""
|
||||
headers = {
|
||||
@@ -51,21 +97,66 @@ def build_headers(host_ip, emsesp_url, content_type='application/json', access_t
|
||||
'User-Agent': 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/118.0',
|
||||
'Accept': '*/*',
|
||||
'Accept-Language': 'en-US',
|
||||
'Accept-Encoding': 'gzip, deflate',
|
||||
'Referer': emsesp_url,
|
||||
'Content-Type': content_type,
|
||||
'Connection': 'keep-alive'
|
||||
}
|
||||
|
||||
|
||||
if access_token:
|
||||
headers['Authorization'] = f'Bearer {access_token}'
|
||||
|
||||
|
||||
if extra_headers:
|
||||
headers.update(extra_headers)
|
||||
|
||||
|
||||
return headers
|
||||
|
||||
|
||||
def post_file(session, upload_url, host_ip, emsesp_url, access_token, filename, data, progress_desc=None):
|
||||
"""POST one multipart file to /rest/uploadFile (WebUI-compatible)."""
|
||||
encoder = MultipartEncoder(fields={
|
||||
'file': (filename, data, 'application/octet-stream')
|
||||
})
|
||||
|
||||
bar = None
|
||||
if progress_desc is not None:
|
||||
bar = tqdm(
|
||||
desc=progress_desc,
|
||||
total=encoder.len,
|
||||
dynamic_ncols=True,
|
||||
unit='B',
|
||||
unit_scale=True,
|
||||
unit_divisor=1024,
|
||||
mininterval=0.2
|
||||
)
|
||||
monitor = MultipartEncoderMonitor(
|
||||
encoder, lambda m: bar.update(m.bytes_read - bar.n))
|
||||
body = monitor
|
||||
content_type = monitor.content_type
|
||||
content_length = monitor.len
|
||||
else:
|
||||
body = encoder
|
||||
content_type = encoder.content_type
|
||||
content_length = encoder.len
|
||||
|
||||
headers = build_headers(
|
||||
host_ip,
|
||||
emsesp_url,
|
||||
content_type=content_type,
|
||||
access_token=access_token,
|
||||
extra_headers={
|
||||
'Content-Length': str(content_length),
|
||||
'Origin': emsesp_url
|
||||
}
|
||||
)
|
||||
|
||||
started = time.perf_counter()
|
||||
response = session.post(upload_url, data=body, headers=headers, timeout=None)
|
||||
elapsed = time.perf_counter() - started
|
||||
if bar is not None:
|
||||
bar.close()
|
||||
return response, encoder.len, elapsed
|
||||
|
||||
|
||||
def on_upload(source, target, env):
|
||||
|
||||
# make sure we have set the upload_protocol to custom
|
||||
@@ -79,90 +170,89 @@ def on_upload(source, target, env):
|
||||
username = env.GetProjectOption('custom_username')
|
||||
password = env.GetProjectOption('custom_password')
|
||||
emsesp_ip = env.GetProjectOption('custom_emsesp_ip')
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
print_fail(f'Missing settings. Add these to your pio_local.ini file:\n\ncustom_username=username\ncustom_password=password\ncustom_emsesp_ip=ems-esp.local\n')
|
||||
return
|
||||
|
||||
emsesp_url = f"http://{emsesp_ip}"
|
||||
parsed_url = urlparse(emsesp_url)
|
||||
host_ip = parsed_url.netloc
|
||||
|
||||
signon_url = f"{emsesp_url}/rest/signIn"
|
||||
signon_headers = build_headers(host_ip, emsesp_url)
|
||||
|
||||
username_password = {
|
||||
"username": username,
|
||||
"password": password
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
signon_url, json=username_password, headers=signon_headers)
|
||||
|
||||
if response.status_code != 200:
|
||||
print_fail("Authentication with EMS-ESP failed (code " +
|
||||
str(response.status_code) + ")")
|
||||
parsed_url = urlparse(f"http://{emsesp_ip}")
|
||||
host_name = parsed_url.hostname or emsesp_ip
|
||||
try:
|
||||
host_ip = resolve_ipv4(host_name)
|
||||
except OSError as e:
|
||||
print_fail(f"Could not resolve {host_name} to IPv4: {e}")
|
||||
return
|
||||
|
||||
print_success("Authentication with EMS-ESP successful")
|
||||
access_token = response.json().get('access_token')
|
||||
if host_ip != host_name:
|
||||
print_success(f"Resolved {host_name} -> {host_ip}")
|
||||
|
||||
# start the upload
|
||||
firmware_path = str(source[0])
|
||||
emsesp_url = f"http://{host_ip}"
|
||||
session = make_session()
|
||||
|
||||
with open(firmware_path, 'rb') as firmware:
|
||||
md5 = hashlib.md5(firmware.read()).hexdigest()
|
||||
try:
|
||||
signon_url = f"{emsesp_url}/rest/signIn"
|
||||
signon_headers = build_headers(host_ip, emsesp_url)
|
||||
|
||||
firmware.seek(0)
|
||||
response = session.post(
|
||||
signon_url,
|
||||
json={"username": username, "password": password},
|
||||
headers=signon_headers,
|
||||
timeout=15)
|
||||
|
||||
encoder = MultipartEncoder(fields={
|
||||
'MD5': md5,
|
||||
'file': (firmware_path, firmware, 'application/octet-stream')}
|
||||
)
|
||||
if response.status_code != 200:
|
||||
print_fail("Authentication with EMS-ESP failed (code " +
|
||||
str(response.status_code) + ")")
|
||||
return
|
||||
|
||||
bar = tqdm(desc='Upload Progress',
|
||||
total=encoder.len,
|
||||
dynamic_ncols=True,
|
||||
unit='B',
|
||||
unit_scale=True,
|
||||
unit_divisor=1024
|
||||
)
|
||||
print_success("Authentication with EMS-ESP successful")
|
||||
access_token = response.json().get('access_token')
|
||||
|
||||
monitor = MultipartEncoderMonitor(
|
||||
encoder, lambda monitor: bar.update(monitor.bytes_read - bar.n))
|
||||
|
||||
post_headers = build_headers(
|
||||
host_ip,
|
||||
emsesp_url,
|
||||
content_type=monitor.content_type,
|
||||
access_token=access_token,
|
||||
extra_headers={
|
||||
'Content-Length': str(monitor.len),
|
||||
'Origin': emsesp_url
|
||||
}
|
||||
)
|
||||
firmware_path = str(source[0])
|
||||
with open(firmware_path, 'rb') as firmware_file:
|
||||
firmware = firmware_file.read()
|
||||
|
||||
md5 = hashlib.md5(firmware).hexdigest()
|
||||
filename = os.path.basename(firmware_path)
|
||||
# Device enables MD5 check only after a file whose name ends in .md5
|
||||
# (same as the WebUI). An ElegantOTA "MD5" form field is ignored.
|
||||
md5_name = os.path.splitext(filename)[0] + '.md5'
|
||||
upload_url = f"{emsesp_url}/rest/uploadFile"
|
||||
|
||||
response = requests.post(
|
||||
upload_url, data=monitor, headers=post_headers)
|
||||
|
||||
bar.close()
|
||||
time.sleep(0.1)
|
||||
print(f"Uploading MD5 ({md5})")
|
||||
response, _, _ = post_file(
|
||||
session, upload_url, host_ip, emsesp_url, access_token,
|
||||
md5_name, (md5 + '\n').encode('ascii'))
|
||||
if response.status_code != 200:
|
||||
print_fail(f"MD5 upload failed (code {response.status_code}).")
|
||||
return
|
||||
print_success("MD5 accepted")
|
||||
|
||||
print(f"Uploading firmware ({filename})")
|
||||
response, size, elapsed = post_file(
|
||||
session, upload_url, host_ip, emsesp_url, access_token,
|
||||
filename, firmware, progress_desc='Upload Progress')
|
||||
print()
|
||||
|
||||
if response.status_code != 200:
|
||||
print_fail(f"Upload failed (code {response.status_code}).")
|
||||
else:
|
||||
print_success("Upload successful. Rebooting device.")
|
||||
kibs = (size / 1024) / elapsed if elapsed > 0 else 0
|
||||
print_success(
|
||||
f"Upload successful ({kibs:.0f} KiB/s in {elapsed:.1f}s). Rebooting device.")
|
||||
restart_headers = build_headers(
|
||||
host_ip, emsesp_url, access_token=access_token)
|
||||
restart_url = f"{emsesp_url}/api/system/restart"
|
||||
response = requests.get(restart_url, headers=restart_headers)
|
||||
if response.status_code != 200:
|
||||
print_fail(f"Restart failed (code {response.status_code})")
|
||||
try:
|
||||
response = session.get(
|
||||
restart_url, headers=restart_headers, timeout=10)
|
||||
if response.status_code != 200:
|
||||
print_fail(f"Restart failed (code {response.status_code})")
|
||||
except requests.RequestException:
|
||||
# Device often drops the connection as it reboots — that's OK.
|
||||
pass
|
||||
|
||||
print()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
if env.get('UPLOAD_PROTOCOL') == 'custom':
|
||||
|
||||
+131
-69
@@ -9,21 +9,27 @@
|
||||
#
|
||||
# Run using for example:
|
||||
# python3 upload_cli.py -i 10.10.10.175 -f ../build/firmware/EMS-ESP-3_7_0-dev_34-ESP32S3-16MB+.bin
|
||||
#
|
||||
# Prefer a numeric IP over .local — mDNS (especially under WSL2) adds latency
|
||||
# and can stall on IPv6 before falling back to IPv4.
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
from requests.adapters import HTTPAdapter
|
||||
from requests_toolbelt import MultipartEncoder, MultipartEncoderMonitor
|
||||
from termcolor import cprint
|
||||
from tqdm import tqdm
|
||||
|
||||
# Constants
|
||||
USER_AGENT = 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/118.0'
|
||||
CHUNK_SIZE = 8192 # 8KB chunks for MD5 calculation
|
||||
UPLOAD_BLOCK_SIZE = 64 * 1024
|
||||
UPLOAD_SNDBUF = 256 * 1024
|
||||
|
||||
|
||||
def print_success(x):
|
||||
@@ -34,13 +40,34 @@ def print_fail(x):
|
||||
return cprint(x, 'red')
|
||||
|
||||
|
||||
def calculate_md5(file_path):
|
||||
"""Calculate MD5 hash of a file in chunks for memory efficiency."""
|
||||
md5_hash = hashlib.md5()
|
||||
with open(file_path, 'rb') as f:
|
||||
for chunk in iter(lambda: f.read(CHUNK_SIZE), b''):
|
||||
md5_hash.update(chunk)
|
||||
return md5_hash.hexdigest()
|
||||
def resolve_ipv4(host):
|
||||
"""Resolve hostname to IPv4 so we skip mDNS/IPv6 retries on every request."""
|
||||
if host.startswith('['):
|
||||
return host
|
||||
if ':' in host and host.count(':') == 1:
|
||||
host = host.rsplit(':', 1)[0]
|
||||
try:
|
||||
socket.inet_pton(socket.AF_INET, host)
|
||||
return host
|
||||
except OSError:
|
||||
pass
|
||||
infos = socket.getaddrinfo(host, 80, socket.AF_INET, socket.SOCK_STREAM)
|
||||
if not infos:
|
||||
raise socket.gaierror(f'No IPv4 address for {host}')
|
||||
return infos[0][4][0]
|
||||
|
||||
|
||||
class FastHTTPAdapter(HTTPAdapter):
|
||||
"""Larger write chunks and socket send buffer for streaming POSTs."""
|
||||
|
||||
def init_poolmanager(self, connections, maxsize, block=False, **pool_kwargs):
|
||||
socket_options = [
|
||||
(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1),
|
||||
(socket.SOL_SOCKET, socket.SO_SNDBUF, UPLOAD_SNDBUF),
|
||||
]
|
||||
pool_kwargs.setdefault('socket_options', socket_options)
|
||||
pool_kwargs.setdefault('blocksize', UPLOAD_BLOCK_SIZE)
|
||||
super().init_poolmanager(connections, maxsize, block=block, **pool_kwargs)
|
||||
|
||||
|
||||
def create_base_headers(host_ip, emsesp_url):
|
||||
@@ -50,43 +77,89 @@ def create_base_headers(host_ip, emsesp_url):
|
||||
'User-Agent': USER_AGENT,
|
||||
'Accept': '*/*',
|
||||
'Accept-Language': 'en-US',
|
||||
'Accept-Encoding': 'gzip, deflate',
|
||||
'Referer': emsesp_url,
|
||||
'Connection': 'keep-alive'
|
||||
}
|
||||
|
||||
|
||||
def post_file(session, upload_url, host_ip, emsesp_url, access_token, filename, data, progress_desc=None):
|
||||
"""POST one multipart file to /rest/uploadFile (WebUI-compatible)."""
|
||||
encoder = MultipartEncoder(fields={
|
||||
'file': (filename, data, 'application/octet-stream')
|
||||
})
|
||||
|
||||
bar = None
|
||||
if progress_desc is not None:
|
||||
bar = tqdm(
|
||||
desc=progress_desc,
|
||||
total=encoder.len,
|
||||
dynamic_ncols=True,
|
||||
unit='B',
|
||||
unit_scale=True,
|
||||
unit_divisor=1024,
|
||||
mininterval=0.2
|
||||
)
|
||||
monitor = MultipartEncoderMonitor(
|
||||
encoder, lambda m: bar.update(m.bytes_read - bar.n))
|
||||
body = monitor
|
||||
content_type = monitor.content_type
|
||||
content_length = monitor.len
|
||||
else:
|
||||
body = encoder
|
||||
content_type = encoder.content_type
|
||||
content_length = encoder.len
|
||||
|
||||
headers = create_base_headers(host_ip, emsesp_url)
|
||||
headers.update({
|
||||
'Content-Type': content_type,
|
||||
'Content-Length': str(content_length),
|
||||
'Origin': emsesp_url,
|
||||
'Authorization': f'Bearer {access_token}'
|
||||
})
|
||||
|
||||
started = time.perf_counter()
|
||||
response = session.post(upload_url, data=body, headers=headers, timeout=None)
|
||||
elapsed = time.perf_counter() - started
|
||||
if bar is not None:
|
||||
bar.close()
|
||||
return response, encoder.len, elapsed
|
||||
|
||||
|
||||
def upload(file, ip, username, password):
|
||||
"""Upload firmware to EMS-ESP device."""
|
||||
# Print welcome message
|
||||
print()
|
||||
print("EMS-ESP Firmware Upload")
|
||||
|
||||
# Validate file exists
|
||||
file_path = Path(file)
|
||||
if not file_path.exists():
|
||||
print_fail(f"File not found: {file}")
|
||||
return
|
||||
|
||||
# Setup URLs and headers
|
||||
emsesp_url = f"http://{ip}"
|
||||
host_ip = ip
|
||||
|
||||
# Use a session for connection pooling and persistence
|
||||
parsed_url = urlparse(f"http://{ip}")
|
||||
host_name = parsed_url.hostname or ip
|
||||
try:
|
||||
host_ip = resolve_ipv4(host_name)
|
||||
except OSError as e:
|
||||
print_fail(f"Could not resolve {host_name} to IPv4: {e}")
|
||||
return
|
||||
|
||||
if host_ip != host_name:
|
||||
print_success(f"Resolved {host_name} -> {host_ip}")
|
||||
|
||||
emsesp_url = f"http://{host_ip}"
|
||||
session = requests.Session()
|
||||
|
||||
session.mount('http://', FastHTTPAdapter(pool_connections=2, pool_maxsize=2))
|
||||
|
||||
try:
|
||||
# Authentication
|
||||
signon_url = f"{emsesp_url}/rest/signIn"
|
||||
signon_headers = create_base_headers(host_ip, emsesp_url)
|
||||
signon_headers['Content-Type'] = 'application/json'
|
||||
|
||||
username_password = {
|
||||
"username": username,
|
||||
"password": password
|
||||
}
|
||||
|
||||
response = session.post(signon_url, json=username_password, headers=signon_headers)
|
||||
response = session.post(
|
||||
signon_url,
|
||||
json={"username": username, "password": password},
|
||||
headers=signon_headers,
|
||||
timeout=15)
|
||||
|
||||
if response.status_code != 200:
|
||||
print_fail(f"Authentication failed (code {response.status_code})")
|
||||
@@ -95,56 +168,46 @@ def upload(file, ip, username, password):
|
||||
print_success("Authentication successful")
|
||||
access_token = response.json().get('access_token')
|
||||
|
||||
# Calculate MD5 hash
|
||||
print("Calculating MD5 hash...")
|
||||
md5 = calculate_md5(file_path)
|
||||
firmware = file_path.read_bytes()
|
||||
md5 = hashlib.md5(firmware).hexdigest()
|
||||
# Device enables MD5 check only after a file whose name ends in .md5
|
||||
# (same as the WebUI). An ElegantOTA "MD5" form field is ignored.
|
||||
md5_name = file_path.with_suffix('.md5').name
|
||||
upload_url = f"{emsesp_url}/rest/uploadFile"
|
||||
|
||||
# Start the upload
|
||||
with open(file_path, 'rb') as firmware:
|
||||
encoder = MultipartEncoder(fields={
|
||||
'MD5': md5,
|
||||
'file': (file, firmware, 'application/octet-stream')
|
||||
})
|
||||
print(f"Uploading MD5 ({md5})")
|
||||
response, _, _ = post_file(
|
||||
session, upload_url, host_ip, emsesp_url, access_token,
|
||||
md5_name, (md5 + '\n').encode('ascii'))
|
||||
if response.status_code != 200:
|
||||
print_fail(f"MD5 upload failed (code {response.status_code})")
|
||||
return
|
||||
print_success("MD5 accepted")
|
||||
|
||||
bar = tqdm(
|
||||
desc='Upload Progress',
|
||||
total=encoder.len,
|
||||
dynamic_ncols=True,
|
||||
unit='B',
|
||||
unit_scale=True,
|
||||
unit_divisor=1024
|
||||
)
|
||||
print(f"Uploading firmware ({file_path.name})")
|
||||
response, size, elapsed = post_file(
|
||||
session, upload_url, host_ip, emsesp_url, access_token,
|
||||
file_path.name, firmware, progress_desc='Upload Progress')
|
||||
|
||||
monitor = MultipartEncoderMonitor(
|
||||
encoder, lambda monitor: bar.update(monitor.bytes_read - bar.n))
|
||||
|
||||
post_headers = create_base_headers(host_ip, emsesp_url)
|
||||
post_headers.update({
|
||||
'Content-Type': monitor.content_type,
|
||||
'Content-Length': str(monitor.len),
|
||||
'Origin': emsesp_url,
|
||||
if response.status_code != 200:
|
||||
print_fail(f"Upload failed (code {response.status_code})")
|
||||
else:
|
||||
kibs = (size / 1024) / elapsed if elapsed > 0 else 0
|
||||
print_success(
|
||||
f"Upload successful ({kibs:.0f} KiB/s in {elapsed:.1f}s). Rebooting device.")
|
||||
restart_headers = create_base_headers(host_ip, emsesp_url)
|
||||
restart_headers.update({
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': f'Bearer {access_token}'
|
||||
})
|
||||
|
||||
upload_url = f"{emsesp_url}/rest/uploadFile"
|
||||
response = session.post(upload_url, data=monitor, headers=post_headers)
|
||||
|
||||
bar.close()
|
||||
time.sleep(0.1)
|
||||
|
||||
if response.status_code != 200:
|
||||
print_fail(f"Upload failed (code {response.status_code})")
|
||||
else:
|
||||
print_success("Upload successful. Rebooting device.")
|
||||
restart_headers = create_base_headers(host_ip, emsesp_url)
|
||||
restart_headers.update({
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': f'Bearer {access_token}'
|
||||
})
|
||||
restart_url = f"{emsesp_url}/api/system/restart"
|
||||
response = session.get(restart_url, headers=restart_headers)
|
||||
restart_url = f"{emsesp_url}/api/system/restart"
|
||||
try:
|
||||
response = session.get(
|
||||
restart_url, headers=restart_headers, timeout=10)
|
||||
if response.status_code != 200:
|
||||
print_fail(f"Restart failed (code {response.status_code})")
|
||||
except requests.RequestException:
|
||||
pass
|
||||
|
||||
except requests.RequestException as e:
|
||||
print_fail(f"Network error: {e}")
|
||||
@@ -161,7 +224,6 @@ def upload(file, ip, username, password):
|
||||
print()
|
||||
|
||||
|
||||
# main
|
||||
parser = argparse.ArgumentParser(description="EMS-ESP Firmware Upload")
|
||||
parser.add_argument("-f", "--file", metavar="FILE",
|
||||
required=True, type=str, help="firmware file")
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user