diff --git a/.changes/ci-repository-server-only-deploy.md b/.changes/ci-repository-server-only-deploy.md new file mode 100644 index 00000000..88f2b5f7 --- /dev/null +++ b/.changes/ci-repository-server-only-deploy.md @@ -0,0 +1,11 @@ +--- +kind: chore +--- +Deploy firmware through the repository server only, and drop CI's direct CDN access + +## Release Note +CI no longer uploads firmware to the CDN or advances the channel pointer files itself. The whole deployment is now three calls to the repository server — start a release, upload every board's artifacts, publish — and the run fails on the first one that does not succeed. Where artifacts are stored, which hostname serves them and the moment a channel starts offering a version are decisions the server makes behind one API call, so CI no longer holds a storage credential and can no longer leave objects, pointer files and the release index disagreeing about what is live. + +What remains of that path moved out of YAML into `.github/scripts/repo_server_publish.py` and `.github/scripts/repo_server_token.py`, joining `get-vars.py` and the build scripts. All three now share `.github/scripts/gha.py` for workflow commands, step outputs and the interpreter floor. HTTP and multipart are `requests`, and `get-vars.py` parses tags with the `semver` package instead of a local regex and comparison class. Dependencies are pinned by digest in `.github/scripts/requirements.txt` and installed with `--require-hashes`, so the job holding the publish credential cannot pick up whatever the index happens to serve. The staged release is a resource with a lifetime, and a `try/finally` expresses that directly — where the four YAML steps it replaces had to coordinate through `GITHUB_ENV` and an `always()` cleanup step to get the same guarantee. + +The `cdn-deploy` and `cdn-bump` jobs are gone, along with the `cdn-upload-firmware`, `cdn-upload-version-info`, `cdn-bump-version` and `sftp-mirror` composite actions and the `cdn` environment they read their Bunny credentials from. Publishing is now driven by the deploy gate instead of the `OPENSHOCK_REPO_SERVER_PUBLISH` variable, which is retired: a release tag or a nightly/manual develop deploy publishes, and every other build does the same init-upload-discard dry run as before. The staged-draft-release check that guarded the CDN upload now guards the publish. The GitHub release and the API announcement still run last, in that order, each chained on the publish succeeding. diff --git a/.changes/dns-server-hardening.md b/.changes/dns-server-hardening.md new file mode 100644 index 00000000..cf547228 --- /dev/null +++ b/.changes/dns-server-hardening.md @@ -0,0 +1,11 @@ +--- +kind: fixed +--- +Harden the captive-portal DNS responder against malformed queries + +## Release Note +Fixed a flaw in the captive-portal DNS responder. A specially crafted DNS query sent to the hub's setup network could corrupt memory and crash it, or make it leak a small amount of adjacent memory back to the sender. Answering a query now checks that the reply actually fits before building it. + +The responder is also stricter about what it will reply to: it ignores DNS replies (rather than answering them, which let two hubs talk past each other indefinitely), ignores anything that isn't a standard single-question lookup, and rejects malformed or over-long names. Answers now carry a one-minute cache lifetime instead of zero, so devices stop re-asking the same question during setup. + +Setup-network addresses are also validated properly now, so a malformed address is rejected instead of being silently truncated into a different one. Leaving setup mode shuts the responder down cleanly, and a network error while it's running no longer risks pinning a CPU core. diff --git a/.changes/esp-idf-6-migration.md b/.changes/esp-idf-6-migration.md new file mode 100644 index 00000000..b36723f0 --- /dev/null +++ b/.changes/esp-idf-6-migration.md @@ -0,0 +1,14 @@ +--- +kind: changed +breaking: true +--- +Rebuild the firmware on the native ESP-IDF platform + +## Release Note +This is one of the biggest updates the firmware has ever had: the whole thing has been rebuilt from the ground up on Espressif's native platform, leaving the old Arduino foundation behind. + +You won't see a pile of new buttons from this release — most of the work is under the hood. What you get is a firmware that's faster to start up, lighter on memory, and far more stable, with a much cleaner codebase that makes future features quicker and safer to add. It also brings a real security upgrade: your hub now checks that it's genuinely talking to OpenShock's servers before it trusts them, so the connection between your device and the network is properly protected. + +Just about every part of the firmware was touched along the way — WiFi and networking, the way settings are stored, the radio that talks to your shockers, the status LEDs, and the serial console. Everything has been retested and put back together on the new foundation. + +Because this rework runs so deep, we're treating it as a major release. It flashes and runs like any other update, and your existing setup carries over. diff --git a/.claude/hooks/clang-format.sh b/.claude/hooks/clang-format.sh deleted file mode 100755 index 8323c4a7..00000000 --- a/.claude/hooks/clang-format.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/bash -FILE_PATH=$(jq -r '.tool_input.file_path // empty') - -if [[ "$FILE_PATH" =~ \.(cpp|c|h|hpp|cc|cxx)$ ]] && [[ "$FILE_PATH" != */_fbs/* ]]; then - npx clang-format -i "$FILE_PATH" -fi - -exit 0 diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index 79e67f80..00000000 --- a/.claude/settings.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "hooks": { - "PostToolUse": [ - { - "matcher": "Edit|Write", - "hooks": [ - { - "type": "command", - "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/clang-format.sh" - } - ] - } - ] - } -} diff --git a/.env b/.env deleted file mode 100644 index b9c8220a..00000000 --- a/.env +++ /dev/null @@ -1,5 +0,0 @@ -OPENSHOCK_API_DOMAIN=api.openshock.app -OPENSHOCK_FW_CDN_DOMAIN=firmware.openshock.org -OPENSHOCK_FW_HOSTNAME=OpenShock -OPENSHOCK_FW_AP_PREFIX=OpenShock- -OPENSHOCK_URI_BUFFER_SIZE=256 diff --git a/.env.development b/.env.development deleted file mode 100644 index 2048dfea..00000000 --- a/.env.development +++ /dev/null @@ -1,3 +0,0 @@ -# Included by frontend/ (DO NOT RENAME THIS FILE!) -# Intended for development builds (locally). -LOG_LEVEL=VERBOSE diff --git a/.env.production b/.env.production deleted file mode 100644 index 019df3bf..00000000 --- a/.env.production +++ /dev/null @@ -1,3 +0,0 @@ -# Included by frontend/ (DO NOT RENAME THIS FILE!) -# Intended for all CI firmware builds. -LOG_LEVEL=INFO diff --git a/.gitattributes b/.gitattributes index c4c1ab34..c963241f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,4 @@ * text=auto eol=lf *.txt text eol=lf +*.exe filter=lfs diff=lfs merge=lfs -text +scripts/flatc filter=lfs diff=lfs merge=lfs -text \ No newline at end of file diff --git a/.github/actions/build-compilationdb/action.yml b/.github/actions/build-compilationdb/action.yml index aca3196f..d43febcf 100644 --- a/.github/actions/build-compilationdb/action.yml +++ b/.github/actions/build-compilationdb/action.yml @@ -1,32 +1,51 @@ name: build-compilationdb -description: Builds the compilation database for the firmware for code analysis +description: >- + Emit build/OpenShock-Core-V2/compile_commands.json with native ESP-IDF, using + OpenShock-Core-V2 / esp32s3 as the canonical config. `mode: configure` stops at the + CMake configure that writes the database; `mode: build` also compiles, which is what + CodeQL needs in order to trace the C/C++. inputs: version: required: true description: 'Current firmware version' + mode: + required: false + default: configure + description: >- + configure - run CMake only, which is all compile_commands.json requires. + build - configure and compile, for consumers that must observe real compiler + invocations. runs: using: composite steps: - - uses: ./.github/actions/pio-cache - with: - env: ci-build + - uses: ./.github/actions/setup-esp-idf - - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - cache: 'pip' - - - name: Install python dependencies + - name: Validate mode shell: bash - run: pip install -r requirements.txt + run: | + set -euo pipefail + case "${{ inputs.mode }}" in + configure|build) ;; + *) echo "::error::mode must be 'configure' or 'build', got '${{ inputs.mode }}'"; exit 1 ;; + esac - - name: Build firmware - working-directory: . + # compile_commands.json is written by CMake at configure time, before a single object + # is compiled, so a consumer that only reads the database never needs the compile. + # `build` remains available for CodeQL, whose tracer has to see the compiler run. + # ccache stays off in both modes: it would hide invocations from that tracer. + - name: Generate compilation database (OpenShock-Core-V2 / esp32s3) shell: bash run: | - export OPENSHOCK_FW_BUILD_DATE="$(git show -s --format=%cI HEAD 2>/dev/null || date -u +%Y-%m-%dT%H:%M:%SZ)" - pio run -e ci-build -t compiledb + set -euo pipefail + if [ "${{ inputs.mode }}" = "build" ]; then + python3 scripts/build.py OpenShock-Core-V2 app + else + python3 scripts/build.py OpenShock-Core-V2 reconfigure + fi + test -f build/OpenShock-Core-V2/compile_commands.json \ + || { echo "::error::compile_commands.json was not generated"; exit 1; } env: OPENSHOCK_FW_VERSION: ${{ inputs.version }} OPENSHOCK_FW_GIT_REF: ${{ github.ref }} - OPENSHOCK_FW_GIT_COMMIT: ${{ github.sha }} \ No newline at end of file + OPENSHOCK_FW_GIT_COMMIT: ${{ github.sha }} diff --git a/.github/actions/build-firmware/action.yml b/.github/actions/build-firmware/action.yml index 397ce579..f158b522 100644 --- a/.github/actions/build-firmware/action.yml +++ b/.github/actions/build-firmware/action.yml @@ -1,46 +1,117 @@ name: build-firmware -description: Builds the firmware partitions and uploads them as an artifact +description: >- + Compile one board's C++ firmware with native ESP-IDF and upload its discrete + partition images (app, bootloader, partition-table). Builds only the code + targets - not the static0 web-assets image - so it runs in parallel with + build-staticfs; the merge-partitions job links them together. inputs: board: required: true - description: 'Board name to build' + description: 'Board name to build (matches boards/.defaults)' version: required: true description: 'Current firmware version' + cache-group: + required: true + description: >- + Identifier of the ccache store this board shares, computed by get-vars.py as the + chip plus a digest of the resolved sdkconfig. Boards with identical sdkconfig + compile identical ESP-IDF objects, so they share one store. + idf-cache-writer: + required: false + default: 'false' + description: >- + Whether this job writes the shared ESP-IDF cache. get-vars nominates one board; + every other job restores it read-only. runs: using: composite steps: - - uses: ./.github/actions/pio-cache + - uses: ./.github/actions/setup-esp-idf with: - env: ${{ inputs.board }} + save-cache: ${{ inputs.idf-cache-writer }} - - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + # ccache is keyed on the sdkconfig identity, not the board: 13 boards resolve to 6 + # stores, so same-chip/same-flash boards reuse each other's ESP-IDF objects. + # + # The key deliberately does NOT contain the commit SHA. Cache entries are immutable, + # so a key that always misses always writes a full new copy - 13 fresh entries per + # push was what pushed this repository over the 10 GB cap. The SHA is only a save + # discriminator here; restore-keys does the real work by taking the newest store for + # the group. + - name: Restore ccache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - cache: 'pip' - - - name: Install python dependencies - shell: bash - run: pip install -r requirements.txt + path: ~/.cache/ccache + key: ccache-${{ runner.os }}-${{ inputs.cache-group }}-${{ github.sha }} + restore-keys: | + ccache-${{ runner.os }}-${{ inputs.cache-group }}- + # Build only the code targets (app + bootloader + partition-table). This + # skips the static0 image (built separately by build-staticfs), so no + # frontend is needed here. scripts/build.py resolves the target from + # boards/.defaults, sets OPENSHOCK_BOARD, and layers the sdkconfig. - name: Build firmware - working-directory: . shell: bash - # head_commit.timestamp only exists on push events, so derive the build - # date from the checked-out commit (falling back to now) - otherwise - # schedule/dispatch builds bake in an empty date. run: | - export OPENSHOCK_FW_BUILD_DATE="$(git show -s --format=%cI HEAD 2>/dev/null || date -u +%Y-%m-%dT%H:%M:%SZ)" - pio run -e ${{ inputs.board }} + set -euo pipefail + export IDF_CCACHE_ENABLE=1 + ccache --zero-stats >/dev/null 2>&1 || true + python3 scripts/build.py "${{ inputs.board }}" app bootloader partition-table env: OPENSHOCK_FW_VERSION: ${{ inputs.version }} + # One board per job, so a fixed build directory is safe here - and necessary. + # The directory path appears on every compile command line, so building each + # board under build/ gave every board a different command line and no two + # boards in a cache group could share an object. + OPENSHOCK_BUILD_DIR: build/firmware OPENSHOCK_FW_GIT_REF: ${{ github.ref }} OPENSHOCK_FW_GIT_COMMIT: ${{ github.sha }} + # content, not the default mtime: the toolchain is restored from a cache with + # fresh timestamps every run, which would otherwise invalidate the whole store. + CCACHE_COMPILERCHECK: content + # ccache defaults to 5 GB. One store holds a single sdkconfig's objects, so this + # is generous - it exists to stop a store growing without bound between evictions. + CCACHE_MAXSIZE: 500M + + # The hit rate is the thing to watch after a change to the generated headers: a + # near-zero rate on an unchanged tree means something is being force-included that + # varies per commit again. + - name: ccache statistics + shell: bash + run: ccache --show-stats --verbose || ccache -s + + # Saved on pull requests too, deliberately. ci-build only runs `push` for develop, + # beta and master, so a feature branch only ever builds as a PR - skipping the save + # there meant the store was never written and every PR push started from nothing. + # + # The entry count this costs is bounded now in a way it was not before: 6 stores per + # push rather than 13, each measured at roughly 30 MB for one board and capped by + # CCACHE_MAXSIZE, and PR-scoped entries age out with the branch. + - name: Save ccache + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.cache/ccache + key: ccache-${{ runner.os }}-${{ inputs.cache-group }}-${{ github.sha }} + + # Flat, canonically-named images. app.bin/staticfs.bin are the names the + # device OTA pulls (OtaUpdateManager.cpp); merge_image.py and the CDN upload + # consume these too. `project(firmware)` names the app image firmware.bin. + - name: Stage partition images + shell: bash + env: + B: build/firmware + run: | + set -euo pipefail + mkdir -p dist + cp "$B/firmware.bin" dist/app.bin + cp "$B/bootloader/bootloader.bin" dist/bootloader.bin + cp "$B/partition_table/partition-table.bin" dist/partition-table.bin - - name: Upload build artifacts - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + - name: Upload partition images + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: firmware_build_${{ inputs.board }} - path: .pio/build/${{ inputs.board }}/*.bin - retention-days: 1 + path: dist/*.bin + retention-days: 7 if-no-files-found: error diff --git a/.github/actions/build-frontend/action.yml b/.github/actions/build-frontend/action.yml index 63afaf64..02a972a2 100644 --- a/.github/actions/build-frontend/action.yml +++ b/.github/actions/build-frontend/action.yml @@ -4,11 +4,11 @@ description: Builds the frontend and uploads it as an artifact runs: using: composite steps: - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version-file: ./frontend/.nvmrc + node-version-file: ./frontend/package.json - - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 name: Install pnpm with: cache: true @@ -41,7 +41,7 @@ runs: run: pnpm run build - name: Upload artifacts - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: frontend path: frontend/build/* diff --git a/.github/actions/build-staticfs/action.yml b/.github/actions/build-staticfs/action.yml index 05e6f2e3..44955312 100644 --- a/.github/actions/build-staticfs/action.yml +++ b/.github/actions/build-staticfs/action.yml @@ -1,51 +1,52 @@ name: build-staticfs -description: Builds the static filesystem partition and uploads it as an artifact +description: >- + Pack the captive-portal web assets into the static0 littlefs image once + (board-independent) via scripts/gen_staticfs.py. Runs in parallel with the + per-board C++ build; the merge job combines the two. inputs: version: - required: true - description: 'Current firmware version' + description: 'Current firmware version (unused in the image, kept for parity)' + required: false + default: '' runs: using: composite steps: - - uses: ./.github/actions/pio-cache - with: - env: fs - - - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: cache: 'pip' - - name: Install dependencies + - name: Install Python dependencies shell: bash run: pip install -r requirements.txt - - name: Download built frontend - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + # The frontend is built once in build-frontend and downloaded here so + # gen_staticfs.py packs the prebuilt assets rather than rebuilding them. + - name: Download frontend + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: frontend - path: frontend/build/ + path: frontend/build - - name: Build filesystem partition + # The static0 size comes from partitions/ota_4mb.csv (the table every board uses); + # the image is board-independent, so one build serves every board. Geometry + # (4 KiB blocks, 128-byte read/prog) matches the runtime mount in + # components/fs/src/LfsPartition.cpp and the CMake build. + - name: Build static filesystem image shell: bash - # head_commit.timestamp only exists on push events; derive from the commit - # (falling back to now) so schedule/dispatch builds don't get an empty date. run: | - export OPENSHOCK_FW_BUILD_DATE="$(git show -s --format=%cI HEAD 2>/dev/null || date -u +%Y-%m-%dT%H:%M:%SZ)" - pio run --target buildfs -e fs - env: - OPENSHOCK_FW_VERSION: ${{ inputs.version }} - OPENSHOCK_FW_GIT_REF: ${{ github.ref }} - OPENSHOCK_FW_GIT_COMMIT: ${{ github.sha }} - - - name: Rename partition binary - shell: bash - run: mv .pio/build/fs/littlefs.bin staticfs.bin + python scripts/gen_staticfs.py \ + --frontend-dir frontend \ + --staging-dir staticfs \ + --output staticfs.bin \ + --partition-csv partitions/ota_4mb.csv \ + --block-size 4096 --read-size 128 --prog-size 128 \ + --skip-frontend-build - - name: Upload internal filesystem artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + - name: Upload static filesystem artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: firmware_staticfs path: staticfs.bin - retention-days: 1 + retention-days: 7 if-no-files-found: error diff --git a/.github/actions/cdn-bump-version/action.yml b/.github/actions/cdn-bump-version/action.yml deleted file mode 100644 index a6dd4e56..00000000 --- a/.github/actions/cdn-bump-version/action.yml +++ /dev/null @@ -1,142 +0,0 @@ -name: cdn-bump-version -description: > - Advance the channel version pointers on the CDN. A release cascades to every - less-stable channel (stable -> beta -> develop), and each pointer is only moved - forward when this version is actually newer by semver. -inputs: - bunny-stor-hostname: - description: Bunny SFTP Hostname - required: true - bunny-stor-username: - description: Bunny SFTP Username - required: true - bunny-stor-password: - description: Bunny SFTP Password - required: true - bunny-api-key: - description: Bunny API key - required: true - bunny-cdn-url: - description: Bunny pull zone base url e.g. https://firmware.openshock.org (no trailing slash) - required: true - version: - description: 'Version of the release' - required: true - release-channel: - description: 'Release channel that describes this upload (stable, beta, develop)' - required: true - -runs: - using: composite - steps: - - name: Install tools - shell: bash - run: | - if ! command -v lftp >/dev/null 2>&1; then - sudo apt-get update -qq - sudo apt-get install -y -qq lftp - fi - # semver CLI: correct precedence (1.6.0 > 1.6.0-rc.2), which `sort -V` - # gets wrong. Installed once so the loop below shells out to it cheaply. - npm install -g semver@7 >/dev/null 2>&1 - - - name: Bump channel pointers - id: bump - shell: bash - env: - SFTP_HOST: ${{ inputs.bunny-stor-hostname }} - SFTP_USER: ${{ inputs.bunny-stor-username }} - SFTP_PASS: ${{ inputs.bunny-stor-password }} - VERSION: ${{ inputs.version }} - RELEASE_CHANNEL: ${{ inputs.release-channel }} - run: | - set -euo pipefail - - # Which channel pointers this release may advance. A more-stable release - # is acceptable to every less-stable channel, so a stable release moves - # beta and develop forward as well. Each pointer is still guarded by the - # semver check below, so a stable patch never drags a channel that is - # already ahead (e.g. develop on the next minor) backwards. - case "$RELEASE_CHANNEL" in - stable) channels="stable beta develop" ;; - beta) channels="beta develop" ;; - develop) channels="develop" ;; - *) channels="$RELEASE_CHANNEL" ;; - esac - - sftp_run() { - lftp -u "$SFTP_USER,$SFTP_PASS" "sftp://$SFTP_HOST" \ - -e "set sftp:auto-confirm yes; $1; bye" - } - - # newer NEW CUR -> succeeds when NEW is a strictly greater semver than - # CUR. -p keeps prereleases eligible so e.g. 1.7.0-develop counts as - # newer than 1.6.0. - newer() { - [ -n "$(semver -r ">$2" -p "$1" 2>/dev/null)" ] - } - - updated="" - for ch in $channels; do - file="version-$ch.txt" - - current="" - if sftp_run "get /$file -o current.txt" >/dev/null 2>&1 && [ -s current.txt ]; then - current="$(tr -d ' \t\r\n' < current.txt)" - fi - rm -f current.txt - - # A pointer that is missing or holds a non-semver value is always - # (re)written; otherwise only advance when VERSION is strictly newer. - # - # develop is special: its versions are X.Y.Z-develop+ and - # consecutive builds differ only in the + build metadata, which - # semver precedence ignores - so `newer` can't order them. When a - # develop build deploys its OWN channel we order by git commit date - # instead, so the pointer only moves forward to a newer commit and - # never regresses on a re-run or an out-of-order build. Cascades from - # stable/beta into develop still use the semver guard below. - if [ "$RELEASE_CHANNEL" = "develop" ] && [ "$ch" = "develop" ]; then - case "$current" in - *-develop+*) - cur_sha="${current##*+}" - new_ct="$(git show -s --format=%ct "$GITHUB_SHA" 2>/dev/null || true)" - cur_ct="$(git show -s --format=%ct "$cur_sha" 2>/dev/null || true)" - # Only hold when we can prove the current commit is newer than - # or equal to this one. If either date is unresolvable (e.g. the - # commit was garbage-collected or history was rewritten) fall - # through and advance rather than freezing the channel. - if [ -n "$new_ct" ] && [ -n "$cur_ct" ] && [ "$new_ct" -le "$cur_ct" ]; then - echo "$file: keeping $current (its commit is newer than or equal to $VERSION)" - continue - fi - ;; - esac - elif [ "$RELEASE_CHANNEL" != "develop" ] \ - && [ -n "$current" ] && semver "$current" >/dev/null 2>&1 && ! newer "$VERSION" "$current"; then - echo "$file: keeping $current (not older than $VERSION)" - continue - fi - - printf '%s\n' "$VERSION" > "$file" - sftp_run "put $file -o /$file" - echo "$file: set to $VERSION (was ${current:-})" - updated="$updated $file" - done - - echo "updated=${updated# }" >> "$GITHUB_OUTPUT" - - - name: Purge CDN cache for updated pointers - if: steps.bump.outputs.updated != '' - shell: bash - env: - BUNNY_API_KEY: ${{ inputs.bunny-api-key }} - BUNNY_CDN_URL: ${{ inputs.bunny-cdn-url }} - UPDATED: ${{ steps.bump.outputs.updated }} - run: | - set -euo pipefail - for file in $UPDATED; do - curl -fsSL -X POST "https://api.bunny.net/purge?url=$BUNNY_CDN_URL/$file&async=false" \ - -H "Content-Type: application/json" \ - -H "AccessKey: $BUNNY_API_KEY" - done diff --git a/.github/actions/cdn-upload-firmware/action.yml b/.github/actions/cdn-upload-firmware/action.yml deleted file mode 100644 index bc0886c9..00000000 --- a/.github/actions/cdn-upload-firmware/action.yml +++ /dev/null @@ -1,66 +0,0 @@ -name: cdn-upload-firmware -description: Uploads firmware partitions and merged binaries for every board to CDN along with SHA256 checksums -inputs: - bunny-stor-hostname: - description: Bunny SFTP Hostname - required: true - bunny-stor-username: - description: Bunny SFTP Username - required: true - bunny-stor-password: - description: Bunny SFTP Password - required: true - fw-version: - description: Firmware version - required: true - boards: - description: 'Newline-separated list of boards to upload' - required: true - -runs: - using: composite - steps: - # Grab every firmware artifact in one shot: the shared static filesystem - # partition plus each board's build partitions and merged binary. They land - # under artifacts// so the loop below can pick them per board. - - name: Download firmware artifacts - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - path: artifacts - pattern: firmware_* - - # Assemble the full // tree locally, one directory per board, so it - # can be pushed in a single mirror. Each board directory keeps the previous - # per-board layout: static + build partitions, the merged firmware.bin, and - # md5/sha256 checksums over every .bin. - - name: Assemble upload tree - shell: bash - env: - BOARDS: ${{ inputs.boards }} - run: | - set -euo pipefail - rm -rf upload - while IFS= read -r board; do - [ -z "$board" ] && continue - dest="upload/$board" - mkdir -p "$dest" - cp artifacts/firmware_staticfs/*.bin "$dest"/ - cp "artifacts/firmware_build_$board"/*.bin "$dest"/ - cp "artifacts/firmware_merged_$board"/OpenShock_*.bin "$dest"/firmware.bin - ( - cd "$dest" - find . -type f -name '*.bin' -exec md5sum {} \; > hashes.md5.txt - find . -type f -name '*.bin' -exec sha256sum {} \; > hashes.sha256.txt - ) - done <<< "$BOARDS" - - # A single lftp mirror pushes the whole tree (every board directory) in one - # command/connection, replacing the old one-upload-per-board matrix. - - name: Upload artifacts to CDN - uses: ./.github/actions/sftp-mirror - with: - host: ${{ inputs.bunny-stor-hostname }} - username: ${{ inputs.bunny-stor-username }} - password: ${{ inputs.bunny-stor-password }} - local-path: upload - remote-path: /${{ inputs.fw-version }}/ diff --git a/.github/actions/cdn-upload-version-info/action.yml b/.github/actions/cdn-upload-version-info/action.yml deleted file mode 100644 index ada9ea2a..00000000 --- a/.github/actions/cdn-upload-version-info/action.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: cdn-upload-version-info -description: Uploads version specific info to CDN -inputs: - bunny-stor-hostname: - description: Bunny SFTP Hostname - required: true - bunny-stor-username: - description: Bunny SFTP Username - required: true - bunny-stor-password: - description: Bunny SFTP Password - required: true - fw-version: - description: Firmware version - required: true - release-channel: - description: 'Release channel that describes this upload' - required: true - boards: - description: 'List of boards, separated by newlines' - required: true - -runs: - using: composite - steps: - - - name: Prepare Upload Folder - shell: bash - run: | - rm -rf upload/ - mkdir -p upload/ - - - name: Create boards.txt - shell: bash - env: - BOARDS: ${{ inputs.boards }} - run: | - printf '%s\n' "$BOARDS" >> upload/boards.txt - - - name: Upload artifacts to CDN - uses: ./.github/actions/sftp-mirror - with: - host: ${{ inputs.bunny-stor-hostname }} - username: ${{ inputs.bunny-stor-username }} - password: ${{ inputs.bunny-stor-password }} - local-path: upload - remote-path: /${{ inputs.fw-version }}/ \ No newline at end of file diff --git a/.github/actions/merge-partitions/action.yml b/.github/actions/merge-partitions/action.yml index 6fb42cf9..eeb71870 100644 --- a/.github/actions/merge-partitions/action.yml +++ b/.github/actions/merge-partitions/action.yml @@ -1,44 +1,77 @@ name: merge-partitions -description: Merges multiple partitions into a single flashable binary +description: >- + Link every board's C++ partition images (from build-firmware) with the shared + static0 image (from build-staticfs) into one flashable binary per board via + scripts/merge_image.py. Needs esptool only - no full ESP-IDF install. inputs: version: - description: 'Version of the firmware' + description: 'Firmware version' required: true - board: - description: 'Board name to merge partitions for' + boards: + description: 'Newline-separated board names to merge' required: true runs: using: composite steps: - - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: cache: 'pip' - - name: Install dependencies + - name: Install Python dependencies shell: bash run: pip install -r requirements.txt - - name: Download static filesystem partition - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + # Every board at once, each into its own directory. + # merge-multiple would flatten them, and the partition images share filenames across boards, so they would overwrite each other. + - name: Download C++ partition images + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: firmware_staticfs + pattern: firmware_build_* + path: images - - name: Download firmware partitions - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + # download-artifact only makes per-artifact directories when the pattern matches more than + # one artifact; a single match (the pull request canary) lands straight in images/. + # Put it where --bindir expects it. + - name: Normalize a single-board download + shell: bash + env: + BOARDS: ${{ inputs.boards }} + run: | + set -euo pipefail + mapfile -t boards < <(printf '%s\n' "$BOARDS" | sed '/^[[:space:]]*$/d') + if [ "${#boards[@]}" -eq 1 ] && [ ! -d "images/firmware_build_${boards[0]}" ]; then + dir="images/firmware_build_${boards[0]}" + mkdir -p "$dir" + find images -maxdepth 1 -type f -exec mv -t "$dir" {} + + echo "Moved the single downloaded artifact into $dir" + fi + + - name: Download static filesystem image + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: firmware_build_${{ inputs.board }} + name: firmware_staticfs + path: staticfs + # One invocation for every board, merged in parallel across the runner's CPUs. + # merge_image.py expands {board} into --bindir and --output itself and reports every board that failed, so there is no loop to write here. - name: Merge partitions shell: bash + env: + BOARDS: ${{ inputs.boards }} + VERSION: ${{ inputs.version }} run: | - python scripts/merge_image.py ${{ inputs.board }} - mv merged.bin OpenShock_${{ inputs.board }}_${{ inputs.version }}.bin + set -euo pipefail + python scripts/merge_image.py \ + --board "$BOARDS" \ + --bindir 'images/firmware_build_{board}' \ + --staticfs staticfs/staticfs.bin \ + --output "merged/OpenShock_{board}_${VERSION}.bin" - - name: Upload merged firmware binary - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + - name: Upload merged firmware binaries + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: firmware_merged_${{ inputs.board }} - path: OpenShock_${{ inputs.board }}_*.bin + name: firmware_merged + path: merged/OpenShock_*.bin retention-days: 7 if-no-files-found: error diff --git a/.github/actions/pio-cache/action.yml b/.github/actions/pio-cache/action.yml deleted file mode 100644 index 26d23579..00000000 --- a/.github/actions/pio-cache/action.yml +++ /dev/null @@ -1,56 +0,0 @@ -name: pio-cache -description: >- - Restore/save PlatformIO caches, split by what actually varies per board so the - matrix does not blow past the Actions cache storage limit. -inputs: - env: - required: true - description: 'PlatformIO environment (the env:) being built' - -runs: - using: composite - steps: - # The only thing that differs across boards in ~/.platformio is the toolchain - # + precompiled framework libs, and those track the *chip*, not the board. - # Resolve the chip for this env so packages can be keyed per-chip (shared by - # every board of that chip) instead of per-board. Uses the runner's system - # python3 so it can run before setup-python. - - name: Resolve chip for cache key - id: chip - shell: bash - env: - PIO_ENV: ${{ inputs.env }} - run: | - chip=$(python3 - <<'PY' - import configparser, os - cp = configparser.ConfigParser(interpolation=None, inline_comment_prefixes=(';',)) - cp.read('platformio.ini') - print(cp.get(f"env:{os.environ['PIO_ENV']}", 'custom_openshock.chip', fallback='unknown').strip()) - PY - ) - echo "chip=$chip" >> "$GITHUB_OUTPUT" - echo "env ${PIO_ENV} -> chip ${chip}" - - # Platform is identical for every board; packages (toolchain + framework libs) - # vary per chip. Keying by chip means N boards on one chip share a single - # entry instead of each storing its own copy. ~/.platformio/.cache (the - # downloaded archives) is deliberately NOT cached: the extracted packages - # cache already avoids re-downloads, and caching it roughly doubled the size. - - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: | - ~/.platformio/platforms - ~/.platformio/packages - key: pio-pkgs-${{ runner.os }}-${{ steps.chip.outputs.chip }}-${{ hashFiles('platformio.ini', 'requirements.txt') }} - restore-keys: | - pio-pkgs-${{ runner.os }}-${{ steps.chip.outputs.chip }}- - - # Library sources live in per-env directories. lib_deps is global so the - # contents are identical across boards, but PlatformIO stores them under - # .pio/libdeps/, so key them per-env. This slice is small. - - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: .pio/libdeps - key: pio-libdeps-${{ runner.os }}-${{ inputs.env }}-${{ hashFiles('platformio.ini') }} - restore-keys: | - pio-libdeps-${{ runner.os }}-${{ inputs.env }}- diff --git a/.github/actions/repo-server-publish/action.yml b/.github/actions/repo-server-publish/action.yml index 617825c4..9ba9fa40 100644 --- a/.github/actions/repo-server-publish/action.yml +++ b/.github/actions/repo-server-publish/action.yml @@ -1,149 +1,81 @@ name: repo-server-publish description: >- - Publishes a firmware release to the OpenShock repository server: initialises the release, uploads - every board's artifacts, and publishes it. The server hashes and stores the binaries itself, so this - is the authoritative ingestion path — the CDN is written by the server, not by CI. + Drives one phase of a firmware release on the repository server. + `init` opens the release and returns its id; `publish` and `dry-run` upload every board's artifacts and then promote or discard; `abort` closes a release whose build never got that far. + Nothing before the ending differs between `publish` and `dry-run`, so a dry run exercises the whole publish path. + +outputs: + release-id: + description: 'The staged release id, set by `init` and passed to a later `publish`, `dry-run` or `abort`.' + value: ${{ steps.run.outputs.release-id }} inputs: - repo-server-url: - description: Base URL of the repository server, e.g. https://repo.openshock.org + server-url: + description: 'Base URL of the repository server' required: true - audience: - description: >- - OIDC audience the repository server validates. Must match its CiCd:Audience setting. - required: false - default: openshock-repository-server - fw-version: - description: Firmware version (exact semver, must match OPENSHOCK_FW_VERSION in the binaries) - required: true - release-channel: - description: stable, beta or develop + token: + description: 'OIDC token from the repo-server-token action' required: true + version: + description: 'Firmware version, strict semver. Required by `init`.' + required: false + channel: + description: 'Release channel: stable, beta or develop. Required by `init`.' + required: false boards: - description: Newline-separated list of board names (PlatformIO env names) - required: true - changelog: + description: 'Newline-separated boards to publish. Required by `init`, `publish` and `dry-run`.' + required: false + release-id: + description: 'Release opened by an earlier `init`. Required by `publish`, `dry-run` and `abort`.' + required: false + mode: + description: '`init` opens the release; `publish` promotes it; `dry-run` aborts it, leaving nothing staged; `abort` closes one the build never reached.' + required: false + default: 'dry-run' + changelog-file: + description: 'Markdown file to take the newest release-notes section from' + required: false + default: 'CHANGELOG.md' + nofail: description: >- - Markdown changelog, parsed server-side per firmware-api-spec.md §5.3. An unparseable changelog - fails the release rather than publishing one with empty notes. - required: true + Pass ?nofail to init, so an unparseable changelog parks the release in `editing` instead of failing the build. + required: false + default: 'false' runs: using: composite steps: - # Same artifact set the CDN upload used: the shared static filesystem partition, each board's - # build partitions, and each board's merged image. + - name: Download firmware artifacts - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + if: ${{ inputs.mode == 'publish' || inputs.mode == 'dry-run' }} + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: path: artifacts pattern: firmware_* - - name: Publish release to the repository server + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version-file: .github/scripts/.python-version + cache: 'pip' + cache-dependency-path: .github/scripts/requirements.txt + + # This job holds the publish credential, so pip installs are pinned by digest. + - name: Install script dependencies + shell: bash + run: pip install --require-hashes -r .github/scripts/requirements.txt + + - name: Run the ${{ inputs.mode }} phase + id: run shell: bash env: - REPO_SERVER_URL: ${{ inputs.repo-server-url }} - AUDIENCE: ${{ inputs.audience }} - FW_VERSION: ${{ inputs.fw-version }} - RELEASE_CHANNEL: ${{ inputs.release-channel }} + SERVER_URL: ${{ inputs.server-url }} + TOKEN: ${{ inputs.token }} + VERSION: ${{ inputs.version }} + CHANNEL: ${{ inputs.channel }} BOARDS: ${{ inputs.boards }} - CHANGELOG: ${{ inputs.changelog }} - run: | - set -euo pipefail - - BASE="${REPO_SERVER_URL%/}" - - # ---- OIDC ------------------------------------------------------------------------------- - # The token proves a workflow in this repository is calling. The server additionally requires - # the repository to be registered in its allowlist; an unregistered repo is rejected, so a - # 401 here usually means onboarding has not been done (admin PUT /2/firmware/admin/repositories). - OIDC_TOKEN=$(curl -sSL --fail-with-body \ - -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ - "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=$AUDIENCE" | jq -r .value) - if [ -z "$OIDC_TOKEN" ] || [ "$OIDC_TOKEN" = "null" ]; then - echo "::error::Failed to fetch a GitHub OIDC token for audience '$AUDIENCE'" - exit 1 - fi - - auth=(-H "Authorization: Bearer $OIDC_TOKEN") - - # ---- Init ------------------------------------------------------------------------------- - # Board names are sent as-is: they are the PlatformIO env names, which is exactly what the - # server knows boards by and what a hub compiles in as OPENSHOCK_FW_BOARD. - boards_json=$(printf '%s\n' "$BOARDS" | jq -R . | jq -sc 'map(select(length > 0))') - - init_body=$(jq -nc \ - --arg version "$FW_VERSION" \ - --arg channel "$RELEASE_CHANNEL" \ - --arg date "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ - --argjson boards "$boards_json" \ - --arg changelog "$CHANGELOG" \ - '{version: $version, channel: $channel, releaseDate: $date, boards: $boards, changelog: $changelog}') - - echo "==> Initialising release $FW_VERSION on $RELEASE_CHANNEL" - init_response=$(curl -sS --fail-with-body -X POST "$BASE/2/firmware/releases" \ - "${auth[@]}" -H "Content-Type: application/json" --data-binary "$init_body") - - RELEASE_ID=$(printf '%s' "$init_response" | jq -r .id) - STATUS=$(printf '%s' "$init_response" | jq -r .status) - if [ -z "$RELEASE_ID" ] || [ "$RELEASE_ID" = "null" ]; then - echo "::error::Release init did not return an id: $init_response" - exit 1 - fi - echo " release $RELEASE_ID (status: $STATUS)" - - # From here on, any failure leaves a staged release behind. Abort it so the version is not - # blocked until its TTL expires — the server keys staged bytes under the release id, so an - # abort cannot touch anything already published. - cleanup() { - if [ "${PUBLISHED:-0}" != "1" ]; then - echo "::warning::Aborting staged release $RELEASE_ID" - curl -sS -X DELETE "$BASE/2/firmware/releases/$RELEASE_ID" "${auth[@]}" >/dev/null || true - fi - } - trap cleanup EXIT - - # ---- Upload ----------------------------------------------------------------------------- - # Field names are the server's artifact types. The sha256 manifest must name exactly the - # files uploaded — the server rejects a mismatch in either direction — so both are built - # from the same scan. - while IFS= read -r board; do - [ -z "$board" ] && continue - - workdir=$(mktemp -d) - cp artifacts/firmware_staticfs/*.bin "$workdir"/ - cp "artifacts/firmware_build_$board"/*.bin "$workdir"/ - cp "artifacts/firmware_merged_$board"/OpenShock_*.bin "$workdir/firmware.bin" - - form=() - manifest='{}' - for pair in "app:app.bin" "staticfs:staticfs.bin" "merged:firmware.bin" \ - "bootloader:bootloader.bin" "partitions:partitions.bin"; do - field="${pair%%:*}" - file="${pair##*:}" - [ -f "$workdir/$file" ] || continue - form+=(-F "$field=@$workdir/$file") - hash=$(sha256sum "$workdir/$file" | cut -d' ' -f1) - manifest=$(printf '%s' "$manifest" | jq -c --arg k "$field" --arg v "$hash" '. + {($k): $v}') - done - - if [ ${#form[@]} -eq 0 ]; then - echo "::error::No artifacts found for board $board" - exit 1 - fi - - echo "==> Uploading $board" - curl -sS --fail-with-body -X PUT "$BASE/2/firmware/releases/$RELEASE_ID/boards/$board" \ - "${auth[@]}" "${form[@]}" -F "sha256=$manifest" >/dev/null - - rm -rf "$workdir" - done <<< "$BOARDS" - - # ---- Publish ---------------------------------------------------------------------------- - # Promotes every staged artifact to its published key and makes the version visible to hubs. - echo "==> Publishing $FW_VERSION" - curl -sS --fail-with-body -X POST "$BASE/2/firmware/releases/$RELEASE_ID/publish" \ - "${auth[@]}" >/dev/null - - PUBLISHED=1 - echo "==> Published $FW_VERSION to $RELEASE_CHANNEL" + RELEASE_ID: ${{ inputs.release-id }} + MODE: ${{ inputs.mode }} + CHANGELOG_FILE: ${{ inputs.changelog-file }} + NOFAIL: ${{ inputs.nofail }} + ARTIFACTS_DIR: artifacts + run: python .github/scripts/repo_server_publish.py diff --git a/.github/actions/repo-server-token/action.yml b/.github/actions/repo-server-token/action.yml new file mode 100644 index 00000000..2086fbe8 --- /dev/null +++ b/.github/actions/repo-server-token/action.yml @@ -0,0 +1,34 @@ +name: repo-server-token +description: >- + Mints a GitHub Actions OIDC token for the repository server, so the audience has one definition. + +inputs: + audience: + description: 'OIDC audience, must match OPENSHOCK__CICD__AUDIENCE on the server' + required: false + default: 'openshock-repository-server' + +outputs: + token: + description: 'The OIDC bearer token. Masked in logs, but still a credential.' + value: ${{ steps.mint.outputs.token }} + +runs: + using: composite + steps: + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version-file: .github/scripts/.python-version + cache: 'pip' + cache-dependency-path: .github/scripts/requirements.txt + + # Same pinned set as repo-server-publish; pip no-ops when the job already has them. + - name: Install script dependencies + shell: bash + run: pip install --require-hashes -r .github/scripts/requirements.txt + + - id: mint + shell: bash + env: + AUDIENCE: ${{ inputs.audience }} + run: python .github/scripts/repo_server_token.py diff --git a/.github/actions/setup-esp-idf/action.yml b/.github/actions/setup-esp-idf/action.yml new file mode 100644 index 00000000..fe97a08a --- /dev/null +++ b/.github/actions/setup-esp-idf/action.yml @@ -0,0 +1,226 @@ +name: setup-esp-idf +description: >- + Install a pinned native ESP-IDF toolchain and put its `idf.py` wrapper on PATH. + scripts/build.py invokes that wrapper, which activates the tool + venv + environment itself. Shared by every job that runs idf.py. +inputs: + idf-version: + description: 'ESP-IDF version to install (EIM version tag).' + required: false + default: 'v6.1' + lookup-only: + description: >- + Probe mode. The restore only checks whether the cache entry exists instead of + downloading it, so a job that just wants to guarantee the cache is populated costs + seconds when it already is. On a miss the install, prune and save run as normal. + Nothing is activated in this mode, so the caller cannot build afterwards. + required: false + default: 'false' + save-cache: + description: >- + Whether this job may write the ESP-IDF cache. The install is identical for every + board, so when the key changes all 13 matrix jobs would otherwise tar and upload + ~3.5 GB simultaneously; one wins the reservation and the rest waste ~20 s each and + log "Cache save failed". ci-build nominates a single writer instead. + required: false + default: 'true' + +runs: + using: composite + steps: + # Cache the whole ESP-IDF install so a warm run skips the multi-minute IDF source + # clone + submodule fetch + toolchain download. On Linux this action installs the + # source (and its submodules) under /tmp/esp/idf and the tools / Python venv under + # /tmp/esp (older layouts used /opt/esp or ~/.espressif, so cache all three - missing + # paths are skipped). Keyed by version; bump the trailing number to invalidate. + # + # Split into restore + save so the prune below lands between them, and the cache + # stores the trimmed install rather than the full one. + - name: Restore ESP-IDF install + id: idf-cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + /tmp/esp + /opt/esp + ~/.espressif + # The one definition of this key; the warm-esp-idf job reaches it through + # lookup-only rather than repeating it. Bump the trailing number to invalidate. + key: esp-idf-${{ runner.os }}-${{ inputs.idf-version }}-5 + restore-keys: | + esp-idf-${{ runner.os }}-${{ inputs.idf-version }}- + lookup-only: ${{ inputs.lookup-only }} + + # Only on a cache miss. The action re-runs `apt-get install` (git, cmake, + # ninja-build, ccache, ...) and re-prepares the IDF clone on every invocation, even + # when the restored tree is already complete. Measured with 13 matrix jobs running at + # once, that took 867 s in a job whose actual firmware build took 128 s - about 700 s + # of it apt fetching cmake at roughly 35 KB/s while every other job did the same. The + # cached tree is self-contained, so a warm run activates it directly below instead. + # + # v1 is a moving branch (no release tags), pinned by SHA. + - uses: espressif/install-esp-idf-action@8fc05d1470d5591417e7a3707a1f2bec178db4ae # v1 + if: ${{ steps.idf-cache.outputs.cache-hit != 'true' }} + with: + version: ${{ inputs.idf-version }} + + # The warm path. EIM writes an activate_idf_.sh next to the install with + # absolute paths baked in, and the whole tree is cached, so those paths are still + # valid on restore. Sourcing it yields a working idf.py without the action. + # PATH entries it adds for pruned tools (esp-clang, the GDBs, OpenOCD, the ULP + # toolchain) simply do not resolve, which is harmless. + - name: Activate cached ESP-IDF + if: ${{ steps.idf-cache.outputs.cache-hit == 'true' && inputs.lookup-only != 'true' }} + shell: bash + run: | + set -euo pipefail + # A candidate directory with no match leaves the glob unexpanded, so guard with + # -f and keep the test out of an && chain, which would trip `set -e`. + act="" + for c in /tmp/esp /opt/esp "$HOME/.espressif" "$HOME/.espressif/tools"; do + for f in "$c"/activate_idf_*.sh; do + if [ -f "$f" ]; then + act="$f" + break 2 + fi + done + done + if [ -z "$act" ]; then + echo "::error::ESP-IDF cache was restored but no activate_idf_*.sh was found in it." + exit 1 + fi + echo "activating $act" + + # `-e` is the script's own non-interactive mode: it prints KEY=VALUE lines instead + # of mutating the shell. Sourcing it is not an option here - it refuses to run + # unless $0 is a shell name, which it is not inside a GitHub step - and it also + # reads $ZSH_VERSION without a default, which trips `set -u`. + env_dump="$(sh "$act" -e)" + if [ -z "$env_dump" ]; then + echo "::error::$act produced no environment." + exit 1 + fi + + # Its PATH= line carries only the tool directories to prepend; SYSTEM_PATH is the + # caller's own PATH and must not be echoed back. + printf '%s\n' "$env_dump" | while IFS= read -r line; do + case "$line" in + PATH=*) + printf '%s\n' "${line#PATH=}" | tr ':' '\n' | grep -v '^$' >> "$GITHUB_PATH" + ;; + SYSTEM_PATH=*|'') ;; + *=*) + echo "$line" >> "$GITHUB_ENV" + ;; + esac + done + + idf_path="$(printf '%s\n' "$env_dump" | sed -n 's/^IDF_PATH=//p' | head -1)" + if [ -z "$idf_path" ]; then + echo "::error::$act did not report IDF_PATH." + exit 1 + fi + # idf.py lives in $IDF_PATH/tools and is not on the tool PATH the script emits. + # scripts/build.py prefers the wrapper when it can find it there. + echo "$idf_path/tools" >> "$GITHUB_PATH" + echo "activated ESP-IDF at $idf_path" + + # ccache is the one build prerequisite the IDF tree does not carry - cmake, ninja and + # python all come from it. Installed only when absent, so the warm path normally + # touches apt not at all, and at worst fetches 592 KB rather than the action's ~15 MB. + - name: Ensure ccache is present + if: ${{ inputs.lookup-only != 'true' }} + shell: bash + run: | + set -euo pipefail + if command -v ccache >/dev/null 2>&1; then + echo "ccache present: $(ccache --version | head -1)" + else + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends ccache + fi + + # Report what the install actually weighs, per directory. This is the ground truth + # for the prune list below: if a warm run shows the install step taking minutes + # again, the installer is re-fetching something pruned here and that entry should be + # taken off the list. + - name: Measure ESP-IDF install + if: ${{ steps.idf-cache.outputs.cache-hit != 'true' }} + shell: bash + run: | + set -uo pipefail + for root in /tmp/esp /opt/esp "$HOME/.espressif"; do + [ -d "$root" ] || continue + echo "::group::$root" + du -sh "$root" 2>/dev/null || true + du -sh "$root"/* 2>/dev/null | sort -rh | head -20 || true + if [ -d "$root/tools" ]; then + echo "-- tools --" + du -sh "$root"/tools/* 2>/dev/null | sort -rh | head -20 || true + fi + echo "::endgroup::" + done + + # Trim the install before it is cached. Only reached on a cache miss, which is also + # the only run that saves. + # + # Measured on a runner, the install is 7.7 GB under /tmp/esp plus 1.1 GB of archives + # under ~/.espressif. What is removed, and why it is safe: + # + # dist/ 1.1 GB of download archives, already extracted into the tool + # directories. idf_tools ships --remove-archives for exactly this, + # and decides whether a tool needs installing from the extracted + # / directory, not from dist/. + # esp-clang 1.9 GB, plus 335 MB of esp-clang-libs. Only clang-tidy and + # `idf.py clang-check` use it; cpp-linter runs with tidy-checks '-*' + # and the firmware is built with gcc, so nothing invokes it. + # *-gdb 247 MB of debuggers; nothing debugs on a runner. + # openocd-esp32 on-target flashing/debugging; CI never touches hardware. + # esp32ulp-elf ULP coprocessor toolchain. No board enables the ULP - if one ever + # does, drop it from the list or its build will fail to find the + # assembler. + # + # The gcc toolchains are NOT pruned: xtensa-esp-elf builds 12 of the 13 boards and + # riscv32-esp-elf builds the ESP32-C3. + # + # Tool directories sit directly under the install root on a runner + # (/tmp/esp/esp-clang) but under a tools/ subdirectory in a local EIM install + # (~/.espressif/tools/esp-clang), so both layouts are checked. By name rather than by + # glob, so xtensa-esp-elf-gdb can be removed without ever putting xtensa-esp-elf at + # risk. + - name: Prune unused toolchains before caching + if: ${{ steps.idf-cache.outputs.cache-hit != 'true' }} + shell: bash + run: | + set -uo pipefail + total=0 + drop() { + [ -e "$1" ] || return 0 + local kb + kb=$(du -sk "$1" 2>/dev/null | cut -f1) || kb=0 + rm -rf "$1" + total=$((total + kb)) + echo " removed $(printf '%6s' "$((kb / 1024))MB") $1" + } + for root in /tmp/esp /opt/esp "$HOME/.espressif"; do + [ -d "$root" ] || continue + echo "pruning $root" + # Download archives, already extracted into the tool directories. + drop "$root/dist" + for name in esp-clang esp-clang-libs openocd-esp32 esp32ulp-elf \ + xtensa-esp-elf-gdb riscv32-esp-elf-gdb; do + drop "$root/$name" + drop "$root/tools/$name" + done + done + echo "pruned $((total / 1024)) MB in total" + + - name: Save ESP-IDF install + if: ${{ steps.idf-cache.outputs.cache-hit != 'true' && inputs.save-cache == 'true' }} + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + /tmp/esp + /opt/esp + ~/.espressif + key: esp-idf-${{ runner.os }}-${{ inputs.idf-version }}-5 diff --git a/.github/actions/sftp-mirror/action.yml b/.github/actions/sftp-mirror/action.yml deleted file mode 100644 index 4cbc91d3..00000000 --- a/.github/actions/sftp-mirror/action.yml +++ /dev/null @@ -1,69 +0,0 @@ -name: sftp-mirror -description: Mirror a local directory to an SFTP server using lftp. Replaces milanmk/actions-file-deployer. -inputs: - host: - description: SFTP hostname - required: true - username: - description: SFTP username - required: true - password: - description: SFTP password. Must not contain a comma (lftp -u splits on comma). - required: true - local-path: - description: Local source directory - required: true - remote-path: - description: Target remote path - required: true - delete-extra: - description: If 'true', remove remote files that are not present locally. - required: false - default: 'false' - parallel: - description: > - Number of files to transfer concurrently (lftp mirror --parallel). Each - transfer opens one SFTP connection; Bunny allows ~100 concurrent per zone - and 100-200 per IP. 'auto' uses min(nproc, 25). - required: false - default: 'auto' - -runs: - using: composite - steps: - - name: Install lftp - shell: bash - run: | - if ! command -v lftp >/dev/null 2>&1; then - sudo apt-get update -qq - sudo apt-get install -y -qq lftp - fi - - - name: Mirror to SFTP - shell: bash - env: - SFTP_HOST: ${{ inputs.host }} - SFTP_USER: ${{ inputs.username }} - SFTP_PASS: ${{ inputs.password }} - LOCAL_PATH: ${{ inputs.local-path }} - REMOTE_PATH: ${{ inputs.remote-path }} - DELETE_EXTRA: ${{ inputs.delete-extra }} - PARALLEL: ${{ inputs.parallel }} - run: | - set -euo pipefail - # 'auto' -> min(nproc, 25): scale with the runner, capped at Bunny's - # per-IP connection limit. - if [ "$PARALLEL" = "auto" ]; then - cores=$(nproc) - PARALLEL=$(( cores < 25 ? cores : 25 )) - fi - # --parallel transfers N files at once; lftp mirror is serial otherwise. - mirror_flags="-R --verbose --parallel=$PARALLEL" - if [ "$DELETE_EXTRA" = "true" ]; then - mirror_flags="$mirror_flags --delete" - fi - # `mirror -R ` appends 's basename to the target, so - # `mirror -R upload /1.2.3/` would upload into /1.2.3/upload/ instead of - # /1.2.3/. lcd into the source and mirror `.` so its CONTENTS land - # directly under REMOTE_PATH. - lftp -u "$SFTP_USER,$SFTP_PASS" "sftp://$SFTP_HOST" -e "set sftp:auto-confirm yes; lcd $LOCAL_PATH; mirror $mirror_flags . $REMOTE_PATH; bye" diff --git a/.github/scripts/.gitignore b/.github/scripts/.gitignore deleted file mode 100644 index 3c3629e6..00000000 --- a/.github/scripts/.gitignore +++ /dev/null @@ -1 +0,0 @@ -node_modules diff --git a/.github/scripts/.python-version b/.github/scripts/.python-version new file mode 100644 index 00000000..e4fba218 --- /dev/null +++ b/.github/scripts/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/.github/scripts/ci_build_gate.py b/.github/scripts/ci_build_gate.py new file mode 100644 index 00000000..9aed8bee --- /dev/null +++ b/.github/scripts/ci_build_gate.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python3 +"""Decide what a ci-build run does: build, deploy, publish, which server, which boards. + +build governs the build matrix. +deploy additionally cuts a GitHub release, and implies build. +publish governs whether the build reaches a repository server at all, and server names which one. +Building is not publishing: a pull request compiles and goes no further. + +Reads the event from the GITHUB_* environment and the event payload, and asks the GitHub API only for a nightly: when this branch last built, and whether anything since then can change the firmware. +Those two calls sit behind the Api protocol so the tests can answer them without a network. +""" + +import json +import re +from dataclasses import dataclass +from datetime import datetime +from typing import Protocol + +import requests + +from gha import env, notice, require_env, set_output, warn +from repo_server_policy import API, API_VERSION + +FULL_BUILD_LABEL = 'ci:full-build' +CANARY_BOARD = 'OpenShock-Core-V2' +NIGHTLY_BRANCHES = ('develop', 'beta', 'master') + +# A comparison reports at most this many files; a full page may be truncated, so it counts as a change. +COMPARE_LIMIT = 300 + +# Paths that cannot change a firmware image or its build. The nightly skips when nothing else changed. +# .changes/ and .github/ count as source: change files set the version, and a CI change should +# meet a nightly before it meets a release tag. +NON_SOURCE = [ + re.compile(r'^(?!\.changes/).*\.md$'), + re.compile(r'^docs/'), + re.compile(r'^LICENSE$'), + re.compile(r'(^|/)\.gitkeep$'), + re.compile(r'^\.claude/'), + re.compile(r'^\.github/ISSUE_TEMPLATE/'), + re.compile(r'^\.github/dependabot\.yml$'), + # Dev-only schema submodule and its codegen helpers; builds use the generated sources. + re.compile(r'^schemas(/|$)'), + re.compile(r'^scripts/(fetch_flatc\.py|generate_schemas\.py|flatc|flatc\.exe)$'), +] + + +class Api(Protocol): + """The two GitHub API reads the nightly needs. Either may raise; the gate decides what a failure means.""" + + def latest_successful_run(self, event: str, branch: str) -> dict | None: + """The newest successful ci-build run of this event on this branch, or None.""" + ... + + def compare_files(self, base: str, head: str) -> list[dict]: + """The files changed between base and head, as the compare API reports them.""" + ... + + +class GitHubApi: + def __init__(self, repo: str, token: str) -> None: + self.repo = repo + self.session = requests.Session() + self.session.headers.update( + { + 'Authorization': f'Bearer {token}', + 'Accept': 'application/vnd.github+json', + 'X-GitHub-Api-Version': API_VERSION, + } + ) + + def _get(self, path: str, params: dict) -> dict: + resp = self.session.get(f'{API}/repos/{self.repo}{path}', params=params, timeout=(10, 30)) + if resp.status_code != 200: + raise RuntimeError(f'HTTP {resp.status_code}: {(resp.text or "").strip()[:300]}') + return resp.json() + + def latest_successful_run(self, event: str, branch: str) -> dict | None: + data = self._get( + '/actions/workflows/ci-build.yml/runs', + {'event': event, 'branch': branch, 'status': 'success', 'per_page': 1}, + ) + runs = data.get('workflow_runs') or [] + return runs[0] if runs else None + + def compare_files(self, base: str, head: str) -> list[dict]: + return self._get(f'/compare/{base}...{head}', {'per_page': COMPARE_LIMIT}).get('files') or [] + + +@dataclass +class Decision: + build: bool = False + deploy: bool = False + publish: bool = False + server: str = 'dev' + # Empty builds every board. + boards: str = '' + + @property + def warm_idf(self) -> bool: + """Whether to warm the ESP-IDF cache before the matrix. + + Only worth it when several boards would otherwise miss the cache at once; a single board's job writes the cache itself as the nominated writer. + """ + return self.build and len(self.boards.split()) != 1 + + +def _created_at(run: dict) -> datetime: + return datetime.fromisoformat(run['created_at']) + + +def last_build_sha(api: Api, branch: str) -> str: + """HEAD of this branch's last successful nightly or manual run. + + 'schedule' covers the develop nightlies from before nightly.yml dispatched them. + Returns '' on error so a hiccup rebuilds rather than wrongly skipping. + """ + try: + runs = [r for r in (api.latest_successful_run(e, branch) for e in ('schedule', 'workflow_dispatch')) if r] + if not runs: + return '' + runs.sort(key=_created_at, reverse=True) + return runs[0].get('head_sha') or '' + except Exception as err: + warn(f'Could not read the last {branch} build: {err}') + return '' + + +def is_source(name: str) -> bool: + return not any(p.search(name) for p in NON_SOURCE) + + +def source_changed_since(api: Api, base: str, sha: str) -> bool: + """Whether anything between base and sha can change the firmware. + + Errs towards true: an unreadable or truncated comparison builds rather than skips. + """ + try: + files = api.compare_files(base, sha) + if len(files) >= COMPARE_LIMIT: + return True + names = [n for f in files for n in (f.get('filename'), f.get('previous_filename')) if n] + source = [n for n in names if is_source(n)] + if source: + print(f"Firmware sources changed: {', '.join(source[:20])}") + return bool(source) + except Exception as err: + warn(f'Could not compare {base}...{sha}: {err}') + return True + + +def decide(event_name: str, ref: str, sha: str, payload: dict, api: Api) -> Decision: + d = Decision() + branch = ref.removeprefix('refs/heads/') if ref.startswith('refs/heads/') else '' + inputs = payload.get('inputs') or {} + # The payload carries a boolean input as either true or 'true', depending on who dispatched it. + nightly = event_name == 'workflow_dispatch' and inputs.get('nightly') in (True, 'true') + + if event_name == 'pull_request': + label = (payload.get('label') or {}).get('name') + if payload.get('action') == 'labeled' and label != FULL_BUILD_LABEL: + notice(f'Label "{label}" does not affect the build.') + else: + d.build = True + labels = (payload.get('pull_request') or {}).get('labels') or [] + if not any(lbl.get('name') == FULL_BUILD_LABEL for lbl in labels): + d.boards = CANARY_BOARD + notice(f'Canary build of {CANARY_BOARD} only; add the {FULL_BUILD_LABEL} label to build every board.') + elif event_name == 'push': + # Only release tags trigger a push run. + d.build = True + d.deploy = True + elif event_name == 'workflow_dispatch': + if nightly: + if branch not in NIGHTLY_BRANCHES: + warn(f'Nightly requested on {ref}; only develop, beta and master have nightlies.') + else: + base = last_build_sha(api, branch) + if base == sha: + notice(f'{branch} unchanged since its last build ({sha}); nothing to build.') + elif base != '' and not source_changed_since(api, base, sha): + notice(f'No firmware sources changed on {branch} since its last build ({base}); nothing to build.') + else: + d.build = True + # Only develop's nightly is a release; beta and master publish from tags. + d.deploy = branch == 'develop' + else: + d.build = True + d.deploy = ref == 'refs/heads/develop' + else: + d.build = True + + # Publishing is never a side effect of a build. + # A pull request compiles and stops, so merging changes nothing on any server until someone says to. + # The develop nightly and a release tag publish to prod, because both are already the deliberate act. + # The beta and master nightlies only build: those channels are published from tags. + # Everything else reaches a server only by being dispatched, which is where the channel and the ref are chosen by hand. + is_tag = ref.startswith('refs/tags/') + + if nightly: + d.publish = d.build and branch == 'develop' + d.server = 'prod' + elif event_name == 'workflow_dispatch': + d.publish = True + d.server = 'prod' if inputs.get('server') == 'prod' else 'dev' + # The ref picker offers tags as well as branches, so reaching prod by hand still means running against the tag being released. + if d.server == 'prod' and not is_tag: + warn(f'prod was requested on {ref}; only a tag may dispatch to prod, publishing to dev instead.') + d.server = 'dev' + elif event_name == 'push' and is_tag: + d.publish = True + d.server = 'prod' + + return d + + +def main() -> int: + require_env('GITHUB_EVENT_NAME', 'GITHUB_REF', 'GITHUB_SHA', 'GITHUB_REPOSITORY', 'GITHUB_EVENT_PATH') + + with open(env('GITHUB_EVENT_PATH'), encoding='utf-8') as f: + payload = json.load(f) + + api = GitHubApi(env('GITHUB_REPOSITORY'), env('GITHUB_TOKEN')) + d = decide(env('GITHUB_EVENT_NAME'), env('GITHUB_REF'), env('GITHUB_SHA'), payload, api) + + def flag(value: bool) -> str: + return 'true' if value else 'false' + + set_output('build', flag(d.build)) + set_output('deploy', flag(d.deploy)) + set_output('publish', flag(d.publish)) + set_output('server', d.server) + set_output('boards', d.boards) + set_output('warm-idf', flag(d.warm_idf)) + print( + f'decision: build={flag(d.build)} deploy={flag(d.deploy)} publish={flag(d.publish)} ' + f'server={d.server} boards={d.boards or "all"} warm-idf={flag(d.warm_idf)}' + ) + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/.github/scripts/get-vars.js b/.github/scripts/get-vars.js deleted file mode 100644 index 0f567659..00000000 --- a/.github/scripts/get-vars.js +++ /dev/null @@ -1,165 +0,0 @@ -import { setFailed, setOutput } from '@actions/core'; -import ini from 'ini'; -import child_process from 'node:child_process'; -import fs from 'node:fs'; -import semver from 'semver'; - -// Get branch name -const gitRef = process.env.GITHUB_REF; -if (gitRef === undefined) { - setFailed('Environment variable "GITHUB_REF" not found'); - process.exit(); -} - -const isGitTag = gitRef.startsWith('refs/tags/'); -const isGitBranch = gitRef.startsWith('refs/heads/'); -const isGitPullRequest = gitRef.startsWith('refs/pull/') && gitRef.endsWith('/merge'); - -if (!isGitTag && !isGitBranch && !isGitPullRequest) { - setFailed(`Git ref "${gitRef}" is not a valid branch, tag or pull request`); - process.exit(); -} - -const gitCommitHash = process.env.GITHUB_SHA; -if (gitCommitHash === undefined) { - setFailed('Environment variable "GITHUB_SHA" not found'); - process.exit(); -} -const gitShortCommitHash = gitCommitHash.substring(0, 8); - -const gitHeadRefName = isGitPullRequest ? process.env.GITHUB_HEAD_REF : gitRef.split('/')[2]; -if (gitHeadRefName === undefined) { - setFailed('Failed to get git head ref name'); - process.exit(); -} - -const gitTagsList = child_process - .execSync('git for-each-ref --sort=-creatordate --format "%(refname:short)" refs/tags') - .toString() - .trim(); - -// The current tag on a tag build is authoritative: an unparseable value here is -// a hard failure, since we cannot derive a version from it. -function parseCurrentTag(tag) { - const parsed = semver.parse(tag); - if (parsed === null || parsed.loose) { - setFailed(`Git tag "${tag}" is not a valid semver version`); - process.exit(); - } - - return parsed; -} - -const gitTagsArray = gitTagsList - .split('\n') - .map((tag) => tag.trim()) - .filter((tag) => tag !== ''); - -// Existing repo tags are best-effort: silently skip any that are not strict -// semver (legacy or malformed tags) rather than failing the entire build, which -// convertGitTagToSemver used to do on the first bad tag it mapped over. -const releasesArray = gitTagsArray - .map((tag) => semver.parse(tag)) - .filter((parsed) => parsed !== null && !parsed.loose); - -// Fall back to 0.0.0 when there are no valid tags yet (fresh repo / all tags -// filtered out). -const latestRelease = isGitTag - ? parseCurrentTag(gitRef.split('/')[2]) - : (releasesArray[0] ?? semver.parse('0.0.0')); - -function isStableRelease(release) { - return release.prerelease.length === 0 || release.prerelease[0] === 'stable'; -} -function isBetaRelease(release) { - return release.prerelease.length > 0 && ['rc', 'beta'].includes(release.prerelease[0]); -} -function isDevRelease(release) { - return release.prerelease.length > 0 && ['dev', 'develop'].includes(release.prerelease[0]); -} - -// Build version string. -// -// The base MAJOR.MINOR.PATCH comes from release-tool (status mode), which is the -// single source of truth for the version bump: it reads .changes/ and returns -// the *next* version. For branch/PR builds we label the artifact as a -// pre-release of that upcoming version (e.g. 1.6.0-develop+sha) rather than the -// last released tag. We fall back to the latest tag when there are no pending -// changes (RELEASE_SKIP=true) or the value is absent, and always for tag builds -// (the tag itself is authoritative there). -const nextVersion = process.env.RELEASE_NEXT_VERSION; -const releaseSkip = process.env.RELEASE_SKIP === 'true'; -const latestBase = `${latestRelease.major}.${latestRelease.minor}.${latestRelease.patch}`; - -let currentVersion = - !isGitTag && nextVersion && !releaseSkip ? nextVersion : latestBase; -if (!isGitTag) { - // Get last part of branch name and replace all non-alphanumeric characters with dashes - let sanitizedGitHeadRefName = gitHeadRefName - .split('/') - .pop() - .replace(/[^a-zA-Z0-9-]/g, '-'); - - // Remove leading and trailing dashes - sanitizedGitHeadRefName = sanitizedGitHeadRefName.replace(/^\-+|\-+$/g, ''); - - if (sanitizedGitHeadRefName.length > 0) { - currentVersion += `-${sanitizedGitHeadRefName}`; - } - - // Add the git commit hash to the version string - currentVersion += `+${gitShortCommitHash}`; -} else { - if (latestRelease.prerelease.length > 0) { - currentVersion += `-${latestRelease.prerelease.join('.')}`; - } - if (latestRelease.build.length > 0) { - currentVersion += `+${latestRelease.build.join('.')}`; - } -} - -// Get the channel to deploy to -let currentChannel; -if (gitHeadRefName === 'master' || (isGitTag && isStableRelease(latestRelease))) { - currentChannel = 'stable'; -} else if (gitHeadRefName === 'beta' || (isGitTag && isBetaRelease(latestRelease))) { - currentChannel = 'beta'; -} else if (gitHeadRefName === 'develop' || (isGitTag && isDevRelease(latestRelease))) { - currentChannel = 'develop'; -} else { - currentChannel = gitHeadRefName.replace(/[^a-zA-Z0-9-]/g, '-').replace(/^\-+|\-+$/g, ''); -} - -// CHANGELOG.md validation lives in release-tool (mode: check, see -// check-changes.yml); get-vars only derives the version, channel and boards. - -// Make sure we have the files we need -if (!fs.existsSync('platformio.ini')) { - setFailed('File "platformio.ini" not found'); - process.exit(); -} - -// Read files -const platformioIniStr = fs.readFileSync('platformio.ini', 'utf8').trim(); - -// Parse platformio.ini and extract the different boards -const platformioIni = ini.parse(platformioIniStr); - -// Get every key that starts with "env:", and that isnt "env:fs" (which is the filesystem) or "env:ci-build" (which is for CI CodeQL and cppcheck) -const boards = Object.keys(platformioIni) - .filter((key) => key.startsWith('env:') && key !== 'env:fs' && key !== 'env:ci-build') - .reduce((arr, key) => { - arr.push(key.substring(4)); - return arr; - }, []); - -console.log('Version: ' + currentVersion); -console.log('Channel: ' + currentChannel); -console.log('Boards: ' + boards.join(', ')); -console.log('Tags: ' + gitTagsArray.join(', ')); - -// Set outputs -setOutput('version', currentVersion); -setOutput('release-channel', currentChannel); -setOutput('board-list', boards.join('\n')); -setOutput('board-matrix', JSON.stringify({ board: boards })); diff --git a/.github/scripts/get-vars.py b/.github/scripts/get-vars.py new file mode 100644 index 00000000..d08de358 --- /dev/null +++ b/.github/scripts/get-vars.py @@ -0,0 +1,247 @@ +#!/usr/bin/env python3 +"""Derive the CI build variables: firmware version, release channel, board matrix. + +Python port of the former get-vars.js (stdlib only, no node/pnpm toolchain). +Reads the git ref/sha from the GITHUB_* env, the pending version from release-tool (RELEASE_NEXT_VERSION / RELEASE_SKIP), the repo tags via git, and the buildable boards from boards/*.defaults, then writes GitHub Action outputs. +""" + +import hashlib +import json +import os +import re +import subprocess +import sys +from pathlib import Path + +import semver + +from gha import fail, notice, set_output + +# The channels the repository server's enum knows. +# A feature branch derives its own name, which is not one of them. +KNOWN_CHANNELS = ('stable', 'beta', 'develop') + +def parse_version(tag: str) -> semver.Version | None: + """Strict semver, except for the historical `v` prefix - the repo still carries a `v0.8.1` tag that the spec (and therefore the parser) rejects. + Returns None rather than raising, since scanning the tag list is best-effort. + """ + try: + return semver.Version.parse(tag.strip().removeprefix('v')) + except ValueError: + return None + + +def prerelease_id(version: semver.Version) -> str: + """First dot-separated prerelease identifier: `rc` out of `1.5.0-rc.2`, '' when the version is final. + This is what names the channel. + """ + return (version.prerelease or '').split('.')[0] + + +def is_stable(version: semver.Version) -> bool: + return prerelease_id(version) in ('', 'stable') + + +def is_beta(version: semver.Version) -> bool: + return prerelease_id(version) in ('rc', 'beta') + + +def is_dev(version: semver.Version) -> bool: + return prerelease_id(version) in ('dev', 'develop') + + +def sanitize(name: str) -> str: + return re.sub(r'^-+|-+$', '', re.sub(r'-+', '-', re.sub(r'[^a-zA-Z0-9-]', '-', name))) + + +def sdkconfig_lines(path: Path) -> list[str]: + """The CONFIG_* assignments of an sdkconfig fragment, normalized and sorted. + + Comments, blank lines and the bare OPENSHOCK_* board-pin assignments are dropped: + the first two carry no build meaning, and the pins are deliberately not Kconfig + (see scripts/build.py), so they must not influence the digest below. The one + comment form that does mean something, `# CONFIG_X is not set` (it disables a + bool), is kept. + """ + lines = [] + for raw in path.read_text(encoding='utf-8').splitlines(): + line = raw.strip() + if line.startswith('CONFIG_') or (line.startswith('# CONFIG_') and line.endswith(' is not set')): + lines.append(line) + return sorted(lines) + + +def cache_group(fragment: Path, target: str, shared: Path) -> str: + """Name the ccache store this board shares with its peers. + + Two boards compile byte-identical ESP-IDF objects exactly when their resolved + sdkconfig is identical, so the store is keyed on precisely that: the chip, plus a + digest of every sdkconfig input. Board pins live outside Kconfig, so the five + ESP32/4 MB boards - which differ only in pins - collapse into a single group. + + The digest is derived rather than hardcoded as chip+flash-size on purpose: a board + that later changes any other sdkconfig setting (PSRAM, a different partition table) + splits into its own group by itself, instead of silently sharing a store it can + never hit. dependencies.lock is deliberately excluded - a component bump should + invalidate the affected objects inside the store, not abandon the whole store. + """ + digest = hashlib.sha256() + for line in sdkconfig_lines(shared) + sdkconfig_lines(fragment): + digest.update(line.encode('utf-8')) + digest.update(b'\n') + return f'{target}-{digest.hexdigest()[:10]}' + + +def idf_target(fragment: Path) -> str: + m = re.search(r'^\s*CONFIG_IDF_TARGET\s*=\s*"([^"]+)"', fragment.read_text(encoding='utf-8'), re.M) + if m is None: + fail(f'{fragment} does not set CONFIG_IDF_TARGET') + return m.group(1) + + +def main() -> int: + git_ref = os.environ.get('GITHUB_REF') + if git_ref is None: + fail('Environment variable "GITHUB_REF" not found') + + is_git_tag = git_ref.startswith('refs/tags/') + is_git_branch = git_ref.startswith('refs/heads/') + is_git_pr = git_ref.startswith('refs/pull/') and git_ref.endswith('/merge') + if not (is_git_tag or is_git_branch or is_git_pr): + fail(f'Git ref "{git_ref}" is not a valid branch, tag or pull request') + + git_sha = os.environ.get('GITHUB_SHA') + if git_sha is None: + fail('Environment variable "GITHUB_SHA" not found') + short_sha = git_sha[:8] + + head_ref = os.environ.get('GITHUB_HEAD_REF') if is_git_pr else git_ref.split('/', 2)[2] + if not head_ref: + fail('Failed to get git head ref name') + + try: + tags_raw = subprocess.check_output( + ['git', 'for-each-ref', '--sort=-creatordate', + '--format=%(refname:short)', 'refs/tags'], + text=True, + ).strip() + except (subprocess.CalledProcessError, FileNotFoundError) as e: + fail(f'Failed to read git tags: {e}') + tags = [t.strip() for t in tags_raw.split('\n') if t.strip()] + + # Existing repo tags are best-effort: skip any that aren't strict semver. + releases = [v for v in (parse_version(t) for t in tags) if v is not None] + + if is_git_tag: + # A tag build's own tag is authoritative; an unparseable value is fatal. + latest = parse_version(git_ref.split('/')[2]) + if latest is None: + fail(f'Git tag "{git_ref.split("/")[2]}" is not a valid semver version') + else: + latest = releases[0] if releases else semver.Version(0, 0, 0) + + # Version: release-tool's next version for branch/PR builds (labelled as a pre-release of the upcoming version), else the latest tag base. + next_version = os.environ.get('RELEASE_NEXT_VERSION') + release_skip = os.environ.get('RELEASE_SKIP') == 'true' + + if not is_git_tag and next_version and not release_skip: + version = next_version + else: + version = str(latest.finalize_version()) + + if not is_git_tag: + suffix = sanitize(head_ref.split('/')[-1]) + if suffix: + version += f'-{suffix}' + version += f'+{short_sha}' + else: + if latest.prerelease: + version += f'-{latest.prerelease}' + if latest.build: + version += f'+{latest.build}' + + # Release channel. + if head_ref == 'master' or (is_git_tag and is_stable(latest)): + channel = 'stable' + elif head_ref == 'beta' or (is_git_tag and is_beta(latest)): + channel = 'beta' + elif head_ref == 'develop' or (is_git_tag and is_dev(latest)): + channel = 'develop' + elif is_git_tag: + fail(f'Tag "{head_ref}" has an unrecognized prerelease channel') + else: + channel = sanitize(head_ref) + + # A dispatch may name the channel; otherwise the ref decides. + # Resolving it here rather than at publish time keeps one answer for the whole run, so what a build reports about itself is what it is published as. + channel_input = os.environ.get('CHANNEL_INPUT', '').strip() + if channel_input: + if channel_input not in KNOWN_CHANNELS: + fail(f'Channel "{channel_input}" is not one of: {", ".join(KNOWN_CHANNELS)}') + if channel_input != channel: + notice(f'Channel overridden by dispatch: {channel} -> {channel_input}') + channel = channel_input + elif channel not in KNOWN_CHANNELS: + notice(f'Channel "{channel}" is not one the server knows; using develop.') + channel = 'develop' + + # Board matrix: one entry per boards/.defaults fragment. + boards_dir = Path('boards') + if not boards_dir.is_dir(): + fail('Directory "boards" not found') + boards = sorted(p.stem for p in boards_dir.glob('*.defaults')) + if not boards: + fail('No boards/*.defaults files found in "boards"') + + # A caller may narrow the matrix (the pull request canary builds one board); empty means all. + boards_input = [b.strip() for b in os.environ.get('BOARDS_INPUT', '').replace(',', '\n').split('\n') if b.strip()] + if boards_input: + unknown = sorted(set(boards_input) - set(boards)) + if unknown: + fail(f'Unknown board(s) requested: {", ".join(unknown)}') + boards = [b for b in boards if b in boards_input] + + shared = Path('sdkconfig.defaults') + if not shared.is_file(): + fail('sdkconfig.defaults not found') + + # Each entry carries the ccache store the board belongs to, so build-firmware can key + # its cache on the sdkconfig identity rather than on the board name. + # `include` alone is the whole matrix - there are no other axes to combine it with. + # The ESP-IDF install is identical for every board, so exactly one job is nominated to + # write its cache. Without this, a changed key has all 13 jobs tar and upload ~3.5 GB + # at once; one wins the reservation and the rest waste the effort and log a warning. + entries = [] + for index, board in enumerate(boards): + fragment = boards_dir / f'{board}.defaults' + target = idf_target(fragment) + entries.append( + { + 'board': board, + 'chip': target, + 'cache-group': cache_group(fragment, target, shared), + 'idf-cache-writer': index == 0, + } + ) + + groups: dict[str, list[str]] = {} + for entry in entries: + groups.setdefault(entry['cache-group'], []).append(entry['board']) + + print('Version: ' + version) + print('Channel: ' + channel) + print('Boards: ' + ', '.join(boards)) + print('Tags: ' + ', '.join(tags)) + print(f'ccache stores: {len(groups)} for {len(boards)} boards') + for group, members in sorted(groups.items()): + print(f' {group} {", ".join(members)}') + + set_output('version', version) + set_output('release-channel', channel) + set_output('board-list', '\n'.join(boards)) + set_output('board-matrix', json.dumps({'include': entries})) + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.github/scripts/gha.py b/.github/scripts/gha.py new file mode 100644 index 00000000..6c22445d --- /dev/null +++ b/.github/scripts/gha.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Shared GitHub Actions plumbing: workflow commands, step outputs, version check. + +Importing applies the version check. +Stdlib only, and conservative in syntax: this has to import on an interpreter too old to run its callers, or the check fails as a SyntaxError instead of the sentence explaining what to install. +""" + +import os +import sys +from typing import NoReturn + +# ESP-IDF's own tooling wants 3.9+; this floor is higher because these scripts use 3.12 typing syntax. +# The runners ship well past it - the check is here for the person running a script locally, not for CI. +MIN_PYTHON = (3, 12, 3) + +if sys.version_info < MIN_PYTHON: + raise SystemExit( + '%s requires Python >= %s, got %s' + % ( + os.path.basename(sys.argv[0]) or 'this script', + '.'.join(map(str, MIN_PYTHON)), + sys.version.split()[0], + ) + ) + + +def fail(msg: str) -> NoReturn: + """Emit an Actions error annotation and stop the step.""" + print(f'::error::{msg}', flush=True) + sys.exit(1) + + +def warn(msg: str) -> None: + print(f'::warning::{msg}', flush=True) + + +def notice(msg: str) -> None: + print(f'::notice::{msg}', flush=True) + + +def mask(value: str) -> None: + """Register a value as a secret, so the runner redacts it from the log. + + Call before the value can reach any other output. + """ + print(f'::add-mask::{value}', flush=True) + + +def set_output(name: str, value: str, *, secret: bool = False) -> None: + """Write a step output, using the heredoc form when the value spans lines. + + Falls back to printing when GITHUB_OUTPUT is unset, so a script run locally says what it would have produced instead of failing on a missing environment. + Pass secret=True for a credential: the local fallback then reports the name only, since there is no runner to apply the mask outside CI. + """ + gh_output = os.environ.get('GITHUB_OUTPUT') + if not gh_output: + print(f"[output] {name}={'' if secret else value}") + return + with open(gh_output, 'a', encoding='utf-8') as f: + if '\n' in value: + delim = f'__EOF_{name}__' + f.write(f'{name}<<{delim}\n{value}\n{delim}\n') + else: + f.write(f'{name}={value}\n') + + +def env(name: str, default: str = '') -> str: + """An environment variable, stripped. + Actions inputs arrive with stray whitespace often enough - a YAML folded scalar leaves a trailing newline - that reading them raw is a bug waiting for the one input somebody wrote across two lines. + """ + return os.environ.get(name, default).strip() + + +def require_env(*names: str) -> None: + """Fail naming every missing variable at once, rather than one per re-run.""" + missing = [n for n in names if not env(n)] + if missing: + fail(f"Missing required environment variable(s): {', '.join(missing)}") diff --git a/.github/scripts/package.json b/.github/scripts/package.json deleted file mode 100644 index dab7d5b4..00000000 --- a/.github/scripts/package.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "get-variables", - "version": "1.0.0", - "private": true, - "type": "module", - "main": "src/index.ts", - "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" - }, - "dependencies": { - "@actions/core": "^3.0.1", - "ini": "^7.0.0", - "semver": "^7.8.5" - }, - "engines": { - "node": "^24.13.0", - "pnpm": "^11.10.0" - }, - "packageManager": "pnpm@11.10.0" -} diff --git a/.github/scripts/pnpm-lock.yaml b/.github/scripts/pnpm-lock.yaml deleted file mode 100644 index 9085b51b..00000000 --- a/.github/scripts/pnpm-lock.yaml +++ /dev/null @@ -1,76 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - '@actions/core': - specifier: ^3.0.1 - version: 3.0.1 - ini: - specifier: ^7.0.0 - version: 7.0.0 - semver: - specifier: ^7.8.5 - version: 7.8.5 - -packages: - - '@actions/core@3.0.1': - resolution: {integrity: sha512-a6d/Nwahm9fliVGRhdhofo40HjHQasUPusmc7vBfyky+7Z+P2A1J68zyFVaNcEclc/Se+eO595oAr5nwEIoIUA==} - - '@actions/exec@3.0.0': - resolution: {integrity: sha512-6xH/puSoNBXb72VPlZVm7vQ+svQpFyA96qdDBvhB8eNZOE8LtPf9L4oAsfzK/crCL8YZ+19fKYVnM63Sl+Xzlw==} - - '@actions/http-client@4.0.1': - resolution: {integrity: sha512-+Nvd1ImaOZBSoPbsUtEhv+1z99H12xzncCkz0a3RuehINE81FZSe2QTj3uvAPTcJX/SCzUQHQ0D1GrPMbrPitg==} - - '@actions/io@3.0.2': - resolution: {integrity: sha512-nRBchcMM+QK1pdjO7/idu86rbJI5YHUKCvKs0KxnSYbVe3F51UfGxuZX4Qy/fWlp6l7gWFwIkrOzN+oUK03kfw==} - - ini@7.0.0: - resolution: {integrity: sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w==} - engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} - - semver@7.8.5: - resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} - engines: {node: '>=10'} - hasBin: true - - tunnel@0.0.6: - resolution: {integrity: sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==} - engines: {node: '>=0.6.11 <=0.7.0 || >=0.7.3'} - - undici@6.27.0: - resolution: {integrity: sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==} - engines: {node: '>=18.17'} - -snapshots: - - '@actions/core@3.0.1': - dependencies: - '@actions/exec': 3.0.0 - '@actions/http-client': 4.0.1 - - '@actions/exec@3.0.0': - dependencies: - '@actions/io': 3.0.2 - - '@actions/http-client@4.0.1': - dependencies: - tunnel: 0.0.6 - undici: 6.27.0 - - '@actions/io@3.0.2': {} - - ini@7.0.0: {} - - semver@7.8.5: {} - - tunnel@0.0.6: {} - - undici@6.27.0: {} diff --git a/.github/scripts/repo_server_policy.py b/.github/scripts/repo_server_policy.py new file mode 100644 index 00000000..430dbc63 --- /dev/null +++ b/.github/scripts/repo_server_policy.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +"""Refuse a run that may not publish the version and channel get-vars resolved. + +get-vars settles what this run is before anything builds, so nothing here overrides it - a build that reports one version must not be published as another. + +These are the repository's rules, not the server's. The server rejects an unregistered repo, a bad hash or an unknown board on its own, and checking those here would only give them a second place to be wrong. What it cannot know is that a stable release descends from master, or that a maintainer staged a draft for it, because those are facts about this repository. + +Everything here applies to prod only. A dev publish reaches no device, so constraining it buys nothing and costs the ability to test. +""" + +import time + +import requests + +from gha import env, fail, notice, require_env + +API = 'https://api.github.com' + +# Pinned rather than left to the default, so a new default cannot change what these checks see. +# GitHub names its supported versions in the 400 it returns for an unknown one, which is how to find the next. +API_VERSION = '2026-03-10' +# The branch each channel is cut from. +# develop is absent because the nightly is already the only thing publishing it, and a tag is not required to descend from it. +# Branch a commit must be contained in to publish to the channel on prod. +CHANNEL_BRANCH = {'stable': 'master', 'beta': 'beta', 'develop': 'develop'} +# Channels whose prod publishes also need a staged draft release (develop builds have no release). +DRAFT_CHANNELS = ('stable', 'beta') + +DRAFT_ATTEMPTS = 12 +DRAFT_INTERVAL = 5 + + +def get(path: str, *, allow_404: bool = False) -> dict | None: + """One GET against the API, or None when allow_404 and the thing is not there.""" + try: + resp = requests.get( + f'{API}{path}', + headers={ + 'Authorization': f"Bearer {env('GITHUB_TOKEN')}", + 'Accept': 'application/vnd.github+json', + 'X-GitHub-Api-Version': API_VERSION, + }, + timeout=(10, 30), + ) + except requests.RequestException as err: + fail(f'Could not reach the GitHub API for {path}: {err}') + + if resp.status_code == 404 and allow_404: + return None + if resp.status_code != 200: + fail(f'GitHub returned HTTP {resp.status_code} for {path}: {(resp.text or "").strip()[:300]}') + return resp.json() + + +def require_branch_contains(repo: str, channel: str, sha: str) -> None: + """A prod publish onto beta or stable has to come from the branch that channel is cut from. + + A tag is not a branch, so the test is containment rather than equality: compare reports head relative to base, so an ancestor of the branch reads as behind or identical. + Without this, tagging any commit would put arbitrary work on a channel the fleet follows. + """ + branch = CHANNEL_BRANCH[channel] + status = get(f'/repos/{repo}/compare/{branch}...{sha}')['status'] + if status not in ('identical', 'behind'): + fail(f'{sha} is not contained in {branch} (compare says {status}), so it cannot publish to the {channel} channel on prod.') + print(f'{sha} is contained in {branch}.') + + +def require_staged_draft(repo: str, version: str) -> None: + """release.yml stages a draft for the version before it may go live. + + Otherwise a stray tag could put firmware on a channel devices follow. + A draft is not reachable by tag name, since GitHub only associates a release with its tag once published, so this reads the list. + release.yml pushes the tag that triggers this run and only then stages the draft, so poll rather than failing on the window between the two. + """ + for attempt in range(1, DRAFT_ATTEMPTS + 1): + releases = get(f'/repos/{repo}/releases?per_page=100') or [] + match = next((r for r in releases if r.get('tag_name') == version), None) + if match and match.get('draft'): + print(f'Draft release for {version} is staged.') + return + state = 'published' if match else 'missing' + print(f'Draft for {version} not staged yet (state: {state}); retry {attempt}/{DRAFT_ATTEMPTS}...', flush=True) + time.sleep(DRAFT_INTERVAL) + + fail(f'No draft release for {version} after ~{DRAFT_ATTEMPTS * DRAFT_INTERVAL}s. release.yml must stage the draft before the publish runs.') + + +def main() -> int: + require_env('SERVER', 'REPO', 'SHA', 'VERSION', 'CHANNEL', 'GITHUB_TOKEN') + + server = env('SERVER') + repo = env('REPO') + sha = env('SHA') + version = env('VERSION') + channel = env('CHANNEL') + + print(f'Publishing {version} to the {channel} channel on {server}.') + + if server != 'prod': + notice('Publishing to dev, so none of the production rules apply.') + return 0 + + if channel not in CHANNEL_BRANCH: + fail(f'Channel "{channel}" has no publishing rules for prod.') + + require_branch_contains(repo, channel, sha) + if channel in DRAFT_CHANNELS: + require_staged_draft(repo, version) + + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/.github/scripts/repo_server_publish.py b/.github/scripts/repo_server_publish.py new file mode 100644 index 00000000..6fc66f9b --- /dev/null +++ b/.github/scripts/repo_server_publish.py @@ -0,0 +1,331 @@ +#!/usr/bin/env python3 +"""Drive one phase of a firmware release on the repository server. + +CI's whole contract: submit each blob with the hash it computed and believe the server about everything else. + +`MODE` picks the phase, because the phases run in different jobs. +`init` opens the release before anything is built, so the server learns a release is coming while the build is still starting and refuses it now rather than half an hour from now. +`publish` and `dry-run` upload every board and then promote or discard; `abort` discards a release the build never got to. + +The staged release is a resource whose lifetime now outlives this process: init hands the id to the workflow, and ci-build's abort job is the `finally` that closes it. +""" + +import hashlib +import json +import time +from contextlib import ExitStack +from datetime import datetime, timezone +from pathlib import Path + +import requests + +from gha import env, fail, require_env, set_output, warn + +VALID_MODES = ('init', 'publish', 'dry-run', 'abort') +# The server parses this into an enum; anything else is a 400 several steps later, after artifacts have been downloaded. +# Branch builds derive a channel from the branch name, so this catches a feature branch being published by accident. +VALID_CHANNELS = ('stable', 'beta', 'develop') + +# Server field name -> the artifact it carries. +# app and staticfs are what an OTA update needs; the rest are for the flashtool, which writes a whole chip rather than updating one. +# The manifest keys and the file field names come from this one table, so they cannot drift apart. +BUILD_ARTIFACTS = { + 'app': 'app.bin', + 'bootloader': 'bootloader.bin', + 'partitions': 'partition-table.bin', +} +STATICFS_FIELD = 'staticfs' +MERGED_FIELD = 'merged' + +UPLOAD_RETRIES = 3 +UPLOAD_BACKOFF_SECONDS = 5 +CHUNK = 1 << 20 + + +def session_for(token: str) -> requests.Session: + """One session for the whole run, so the bearer token is attached in a single place and the connection to the server is reused across every board. + """ + s = requests.Session() + s.headers.update({'Authorization': f'Bearer {token}', 'Accept': 'application/json'}) + return s + + +def show_response(resp: requests.Response) -> None: + """Print the server's problem details. + It names its own causes better than any guess made from the status code alone, so the body is worth the log lines. + """ + text = (resp.text or '').strip() + if text: + print(text, flush=True) + + +def extract_changelog(path: Path) -> str: + """The newest `# ` section, which is the release being built on a tag and the previous release on a branch. + Either way it is a real changelog going through the real parser, so a grammar the server rejects surfaces on an ordinary build. + """ + if not path.is_file(): + fail(f"Changelog file '{path}' not found.") + lines = path.read_text(encoding='utf-8').splitlines() + section: list[str] = [] + for line in lines: + if line.startswith('# '): + if section: + break + section.append(line) + elif section: + section.append(line) + text = '\n'.join(section).strip() + if not text: + fail(f"No '# ' heading found in '{path}'; init would reject an empty changelog.") + print(f'Changelog section: {section[0]} ({len(section)} lines)', flush=True) + return text + + +def sha256_of(path: Path) -> str: + digest = hashlib.sha256() + with path.open('rb') as fh: + while chunk := fh.read(CHUNK): + digest.update(chunk) + return digest.hexdigest() + + +def resolve_artifacts(artifacts: Path, board: str) -> dict[str, Path]: + """Every artifact the server wants for one board, or a hard failure naming the first one that is missing. + """ + build = artifacts / f'firmware_build_{board}' + found: dict[str, Path] = {} + + for field, filename in BUILD_ARTIFACTS.items(): + found[field] = build / filename + found[STATICFS_FIELD] = artifacts / 'firmware_staticfs' / 'staticfs.bin' + + # merge-partitions is one job for every board, so the merged binaries share a directory and are told apart by the board in their filename. + merged_glob = f'OpenShock_{board}_*.bin' + merged = sorted((artifacts / 'firmware_merged').glob(merged_glob)) + if merged: + found[MERGED_FIELD] = merged[0] + + for field in (*BUILD_ARTIFACTS, STATICFS_FIELD, MERGED_FIELD): + path = found.get(field) + if path is None or not path.is_file(): + expected = path if path else f'{artifacts}/firmware_merged/{merged_glob}' + fail(f'{board} is missing its {field} artifact (expected {expected})') + return found + + +def upload_board(session: requests.Session, server: str, release_id: str, board: str, artifacts: Path) -> None: + """One request per board, carrying that board's artifacts and a sha256 manifest the server re-computes before writing anything. + + Retried on transport errors and 5xx: the request is a whole-board PUT, so a repeat replaces the same artifacts rather than appending anything. + init and publish below are deliberately not retried - they are POSTs whose effect a timeout leaves unknown. + The file handles are reopened per attempt, since a retry has to send the body from the start and the previous attempt consumed the streams. + """ + paths = resolve_artifacts(artifacts, board) + manifest = {field: sha256_of(path) for field, path in paths.items()} + + url = f'{server}/2/firmware/releases/{release_id}/boards/{board}' + for attempt in range(1, UPLOAD_RETRIES + 1): + try: + with ExitStack() as stack: + files = { + field: (path.name, stack.enter_context(path.open('rb')), 'application/octet-stream') + for field, path in paths.items() + } + resp = session.put( + url, + files=files, + data={'sha256': json.dumps(manifest)}, + timeout=(30, 300), + ) + except requests.RequestException as err: + if attempt == UPLOAD_RETRIES: + fail(f'Upload failed for {board} after {attempt} attempts: {err}') + warn(f'Upload for {board} failed ({err}); retrying {attempt}/{UPLOAD_RETRIES - 1}...') + time.sleep(UPLOAD_BACKOFF_SECONDS) + continue + + if resp.status_code == 200: + print(f' {board}: {len(resp.json())} artifacts accepted', flush=True) + return + + if resp.status_code >= 500 and attempt < UPLOAD_RETRIES: + warn(f'Upload for {board} returned HTTP {resp.status_code}; retrying {attempt}/{UPLOAD_RETRIES - 1}...') + show_response(resp) + time.sleep(UPLOAD_BACKOFF_SECONDS) + continue + + print(f'::error::Upload failed for {board} (HTTP {resp.status_code})', flush=True) + show_response(resp) + raise SystemExit(1) + + +def init_release(session: requests.Session, server: str, *, version: str, channel: str, boards: list[str], changelog: str, nofail: bool) -> str: + """Stage the release. + There is no preflight: an unregistered repository, a missing scope and an unknown board are all non-success responses from here, before a single artifact is uploaded, and each names its own cause. + """ + resp = session.post( + f'{server}/2/firmware/releases' + ('?nofail' if nofail else ''), + json={ + 'version': version, + 'channel': channel, + 'releaseDate': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), + 'boards': boards, + 'changelog': changelog, + }, + timeout=(30, 60), + ) + if resp.status_code != 201: + print(f'::error::Init failed (HTTP {resp.status_code})', flush=True) + show_response(resp) + raise SystemExit(1) + + data = resp.json() + release_id = data['id'] + print(f"Release {release_id} staged as {data.get('status')} for {version} on {channel}", flush=True) + return release_id + + +class PublishOutcomeUnknown(Exception): + """The publish request was sent but no answer came back, so the release may or may not be live.""" + + +def promote(session: requests.Session, server: str, release_id: str) -> None: + try: + resp = session.post( + f'{server}/2/firmware/releases/{release_id}/publish', + json={}, + timeout=(30, 120), + ) + except requests.RequestException as e: + print(f'::error::Publish request for {release_id} got no response ({e}); the release may already be live', flush=True) + raise PublishOutcomeUnknown(release_id) from e + if resp.status_code != 201: + print(f'::error::Publish failed (HTTP {resp.status_code})', flush=True) + show_response(resp) + raise SystemExit(1) + print(f'Published {release_id}', flush=True) + + +def discard(session: requests.Session, server: str, release_id: str, mode: str) -> None: + """A staged release that is never aborted holds its version against a later attempt (init returns ReleaseAlreadyStaging) until the server's TTL job reaps it, so a failed run that left one behind would block the retry that was meant to fix it. + + Never raises: this runs from a finally, where the failure being cleaned up after is the one worth reporting. + """ + try: + resp = session.delete(f'{server}/2/firmware/releases/{release_id}', timeout=(30, 60)) + except requests.RequestException as err: + warn(f'Could not reach the server to discard release {release_id} ({err}). It will be reaped by the server TTL job.') + return + + if resp.status_code == 204: + if mode == 'dry-run': + print('Dry run complete: release staged, verified and discarded. Nothing was published.', flush=True) + else: + print('The run failed; the staged release was discarded so a retry can reuse this version.', flush=True) + return + + # The workflow's abort job is a backstop for a publish that never ran, so it also fires after one that ran and cleaned up after itself. + # Finding the release already gone is that job succeeding, not failing. + if mode == 'abort' and resp.status_code in (404, 409): + print(f'Release {release_id} was already closed; nothing to abort.', flush=True) + return + # Not fatal on its own - the TTL job reaps abandoned releases - but it means the next attempt at this version will be refused until that happens, so say so loudly. + warn(f'Could not discard release {release_id} (HTTP {resp.status_code}). It will be reaped by the server TTL job.') + show_response(resp) + + +def read_boards() -> list[str]: + return [b.strip() for b in env('BOARDS').splitlines() if b.strip()] + + +def do_init(session: requests.Session, server: str) -> int: + """Open the release and hand its id back, leaving it staged on purpose. + + Nothing is uploaded here, so every check the server makes at init - semver, channel, a version already published, a concurrent release holding the same one, an unknown board - lands before the build burns its half hour. + """ + require_env('VERSION', 'CHANNEL') + version = env('VERSION') + channel = env('CHANNEL') + boards = read_boards() + + if channel not in VALID_CHANNELS: + fail(f"CHANNEL must be one of {'/'.join(VALID_CHANNELS)}, got '{channel}'") + if not boards: + fail('BOARDS is empty; there is nothing to declare.') + + release_id = init_release( + session, + server, + version=version, + channel=channel, + boards=boards, + changelog=extract_changelog(Path(env('CHANGELOG_FILE', 'CHANGELOG.md'))), + nofail=env('NOFAIL').lower() == 'true', + ) + set_output('release-id', release_id) + return 0 + + +def do_upload(session: requests.Session, server: str, mode: str) -> int: + """Upload every board into the release init opened, then promote or discard it.""" + require_env('RELEASE_ID') + release_id = env('RELEASE_ID') + boards = read_boards() + artifacts = Path(env('ARTIFACTS_DIR', 'artifacts')) + + if not boards: + fail('BOARDS is empty; there is nothing to upload.') + + published = False + outcome_unknown = False + try: + for board in boards: + upload_board(session, server, release_id, board, artifacts) + if mode == 'publish': + promote(session, server, release_id) + published = True + except PublishOutcomeUnknown: + # Discarding now could delete a release that did go live; leave it for a human to check. + outcome_unknown = True + print(f'::error::Not discarding {release_id}: verify on the repository server whether it was published.', flush=True) + raise SystemExit(1) + finally: + if not published and not outcome_unknown: + discard(session, server, release_id, mode) + + return 0 + + +def do_abort(session: requests.Session, server: str) -> int: + """Close a release whose build never reached the upload. + + Runs from the workflow's always() job, so it reports rather than fails: the build failure it is cleaning up after is the one worth surfacing. + """ + release_id = env('RELEASE_ID') + if not release_id: + print('No release was staged, so there is nothing to abort.', flush=True) + return 0 + + discard(session, server, release_id, 'abort') + return 0 + + +def main() -> int: + require_env('SERVER_URL', 'TOKEN') + server = env('SERVER_URL').rstrip('/') + mode = env('MODE', 'dry-run') + + if mode not in VALID_MODES: + fail(f"MODE must be one of {'/'.join(VALID_MODES)}, got '{mode}'") + + session = session_for(env('TOKEN')) + + if mode == 'init': + return do_init(session, server) + if mode == 'abort': + return do_abort(session, server) + return do_upload(session, server, mode) + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/.github/scripts/repo_server_token.py b/.github/scripts/repo_server_token.py new file mode 100644 index 00000000..d17556cf --- /dev/null +++ b/.github/scripts/repo_server_token.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +"""Mint a GitHub Actions OIDC token for the repository server. + +Every call into the server goes through a token from here, so there is one definition of the audience and one place where a malformed mint is caught. +""" + +import base64 +import json + +import requests + +from gha import env, fail, mask, require_env, set_output + +# The claims the server keys off. +# Printed so a later rejection can be read against what was actually sent, rather than what the workflow assumes it sent. +# The token itself is masked; these five claims are not secret. +ECHOED_CLAIMS = ('aud', 'repository', 'repository_owner', 'ref', 'run_id') + + +def decode_claims(token: str) -> dict: + """The payload segment of a JWT: base64url with its padding stripped.""" + try: + payload = token.split('.')[1] + raw = base64.urlsafe_b64decode(payload + '=' * (-len(payload) % 4)) + return json.loads(raw) + except (IndexError, ValueError) as err: + fail(f'OIDC token is not a readable JWT: {err}') + + +def main() -> int: + require_env('AUDIENCE') + audience = env('AUDIENCE') + + # id-token: write is granted per job, not per action, so a caller that forgets it gets an unset request URL rather than a failed request. + # Say which it was. + request_url = env('ACTIONS_ID_TOKEN_REQUEST_URL') + if not request_url: + fail('No OIDC request URL. The calling job needs permissions: id-token: write.') + + try: + resp = requests.get( + f'{request_url}&audience={audience}', + headers={ + 'Authorization': f"bearer {env('ACTIONS_ID_TOKEN_REQUEST_TOKEN')}", + 'Accept': 'application/json', + }, + timeout=(10, 30), + ) + except requests.RequestException as err: + fail(f'Could not reach the OIDC token endpoint: {err}') + + if resp.status_code != 200: + detail = (resp.text or '').strip() + fail(f'GitHub refused the OIDC mint for audience {audience!r} (HTTP {resp.status_code}): {detail}') + + token = (resp.json().get('value') or '').strip() + if not token: + fail(f'GitHub returned no OIDC token for audience {audience!r}.') + + # Masked before it can reach an output file or a log line. + mask(token) + set_output('token', token, secret=True) + + claims = decode_claims(token) + print(json.dumps({c: claims.get(c) for c in ECHOED_CLAIMS}, indent=2), flush=True) + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/.github/scripts/requirements-dev.txt b/.github/scripts/requirements-dev.txt new file mode 100644 index 00000000..2e004680 --- /dev/null +++ b/.github/scripts/requirements-dev.txt @@ -0,0 +1,39 @@ +# Test dependencies for the CI scripts in this directory, on top of the runtime set. +# +# Kept out of requirements.txt so the jobs that hold the repository-server publish +# credential do not install a test runner they never use. Hashed for the same reason +# requirements.txt is; refresh the same way. +# +# python -m venv .venv +# .venv/bin/pip install --require-hashes -r .github/scripts/requirements-dev.txt +# .venv/bin/python -m pytest .github/scripts/tests + +-r requirements.txt + +# --- direct --- + +pytest==9.1.1 \ + --hash=sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c \ + --hash=sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313 + +# --- transitive (pytest) --- + +colorama==0.4.6 ; sys_platform == "win32" \ + --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 \ + --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 + +iniconfig==2.3.0 \ + --hash=sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12 \ + --hash=sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730 + +packaging==26.3 \ + --hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c \ + --hash=sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79 + +pluggy==1.6.0 \ + --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 \ + --hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 + +pygments==2.21.0 \ + --hash=sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9 \ + --hash=sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c diff --git a/.github/scripts/requirements.txt b/.github/scripts/requirements.txt new file mode 100644 index 00000000..aee191ad --- /dev/null +++ b/.github/scripts/requirements.txt @@ -0,0 +1,45 @@ +# Dependencies for the CI scripts in this directory. +# +# Installed with --require-hashes, so every distribution pip may choose has to be +# listed here by hash. That is why the transitive dependencies appear too, and why +# each entry carries several hashes: the pure-python wheel, the sdist, and for +# charset-normalizer the compiled wheels for the interpreters the runners ship. +# An unpinned install would be arbitrary third-party code running in the same job +# as the repository-server publish credential. +# +# Refreshing after a version bump: +# pip install pip-tools +# pip-compile --generate-hashes --output-file requirements.txt requirements.in +# or re-query the PyPI JSON API for the new release's digests. + +# --- direct --- + +requests==2.34.2 \ + --hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0 \ + --hash=sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed + +semver==3.1.0 \ + --hash=sha256:14bc073439513d7773662a338f4db9829cf16c12b74b9568e2d2689975fbd7fc \ + --hash=sha256:6e4998f8b4762acdfea85f9bfd5b4032e0bb50038dc04bf82011b42d5d1145c5 + +# --- transitive (requests) --- + +certifi==2026.7.22 \ + --hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 \ + --hash=sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55 + +charset-normalizer==3.5.2 \ + --hash=sha256:34276fd796040bf0993ab33a369aa572e6979c7aab225a88893667ad8eac8f7a \ + --hash=sha256:39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef \ + --hash=sha256:3d31298449090ab8d47b7b1b2a555ff73cac7ed438a08b7ac160980c7ebed649 \ + --hash=sha256:6bd128f206a7752ae1f2ab6c61bf8a24ba28913a10df8b14c2637b973ff97a80 \ + --hash=sha256:7218e8f32b0956cfcd048fd42d9d5779809745ca1d86113ca56f66e7ae1549c4 \ + --hash=sha256:b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685 + +idna==3.20 \ + --hash=sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44 \ + --hash=sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c + +urllib3==2.8.0 \ + --hash=sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3 \ + --hash=sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63 diff --git a/.github/scripts/tests/conftest.py b/.github/scripts/tests/conftest.py new file mode 100644 index 00000000..ca6cce9f --- /dev/null +++ b/.github/scripts/tests/conftest.py @@ -0,0 +1,5 @@ +import sys +from pathlib import Path + +# The scripts import each other as top-level modules, the way CI runs them. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) diff --git a/.github/scripts/tests/test_build_scripts.py b/.github/scripts/tests/test_build_scripts.py new file mode 100644 index 00000000..3e853ba2 --- /dev/null +++ b/.github/scripts/tests/test_build_scripts.py @@ -0,0 +1,91 @@ +"""Tests for the build helpers in scripts/ (partition table reading, reproducible staticfs staging).""" + +import os +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(ROOT / 'scripts')) + +import gen_staticfs # noqa: E402 +from utils import partitions # noqa: E402 + + +def test_reads_the_repository_partition_table(): + table = partitions.read_partitions(ROOT / 'partitions' / 'ota_4mb.csv') + + assert table['static0']['offset'] == 0x353000 + assert table['static0']['size'] == 0x9D000 + assert table['config']['size'] == 0x3000 + assert partitions.first_app_partition(table)['offset'] == 0x10000 + + +def test_every_board_resolves_a_partition_table(): + for fragment in sorted((ROOT / 'boards').glob('*.defaults')): + board = fragment.stem + csv = partitions.partition_csv_for(board) + assert csv.is_file(), board + assert 'static0' in partitions.read_partitions(csv), board + assert partitions.partition_table_offset(board) == 0x8000, board + + +def test_sizes_with_suffixes_and_comments(tmp_path): + csv = tmp_path / 'table.csv' + csv.write_text( + '# Name, Type, SubType, Offset, Size\n' + 'nvs, data, nvs, 0x9000, 20K # trailing comment\n' + 'app0, app, ota_0, 0x10000, 1M\n', + encoding='utf-8', + ) + table = partitions.read_partitions(csv) + assert table['nvs']['size'] == 20 * 1024 + assert table['app0']['size'] == 1024 * 1024 + + +def test_entries_without_an_offset_are_refused(tmp_path): + csv = tmp_path / 'table.csv' + csv.write_text('nvs, data, nvs, , 0x5000\n', encoding='utf-8') + with pytest.raises(SystemExit): + partitions.read_partitions(csv) + + +def test_staged_assets_are_byte_identical_across_runs(tmp_path): + build = tmp_path / 'build' + (build / 'assets').mkdir(parents=True) + (build / 'index.html').write_text('x', encoding='utf-8') + (build / 'assets' / 'app.js').write_text('console.log(1)', encoding='utf-8') + + def stage(name): + staging = tmp_path / name + gen_staticfs.stage_assets(str(build), str(staging)) + return {p.relative_to(staging): p.read_bytes() for p in staging.rglob('*') if p.is_file()} + + first = stage('a') + # Touch the sources: gzip used to embed the mtime, so the output changed on every build. + for p in build.rglob('*'): + if p.is_file(): + p.touch() + second = stage('b') + + assert first == second + assert Path('www') / 'index.html.gz' in first + + +def test_frontend_build_is_current_tracks_source_changes(tmp_path): + frontend = tmp_path / 'frontend' + (frontend / 'src').mkdir(parents=True) + (frontend / 'build').mkdir() + source = frontend / 'src' / 'main.ts' + index = frontend / 'build' / 'index.html' + + source.write_text('a', encoding='utf-8') + index.write_text('built', encoding='utf-8') + + os.utime(source, (1000, 1000)) + os.utime(index, (2000, 2000)) + assert gen_staticfs.frontend_build_is_current(str(frontend), str(frontend / 'build')) + + os.utime(source, (3000, 3000)) + assert not gen_staticfs.frontend_build_is_current(str(frontend), str(frontend / 'build')) diff --git a/.github/scripts/tests/test_ci_build_gate.py b/.github/scripts/tests/test_ci_build_gate.py new file mode 100644 index 00000000..53d82328 --- /dev/null +++ b/.github/scripts/tests/test_ci_build_gate.py @@ -0,0 +1,334 @@ +import json + +import pytest + +import ci_build_gate +from ci_build_gate import CANARY_BOARD, FULL_BUILD_LABEL, decide + +SHA = 'c' * 40 +BASE = 'b' * 40 + + +class FakeApi: + """Answers the gate's two API reads from canned data, and records what it was asked.""" + + def __init__(self, runs=None, files=None, runs_error=None, compare_error=None): + self.runs = runs or {} + self.files = files or [] + self.runs_error = runs_error + self.compare_error = compare_error + self.run_queries = [] + self.compares = [] + + def latest_successful_run(self, event, branch): + self.run_queries.append((event, branch)) + if self.runs_error: + raise self.runs_error + return self.runs.get(event) + + def compare_files(self, base, head): + self.compares.append((base, head)) + if self.compare_error: + raise self.compare_error + return [{'filename': f} if isinstance(f, str) else f for f in self.files] + + +def run(head_sha, created_at='2026-10-01T03:00:00Z'): + return {'head_sha': head_sha, 'created_at': created_at} + + +def outcome(d): + return (d.build, d.deploy, d.publish, d.server, d.boards) + + +def pr(action='synchronize', labels=(), label=None): + payload = {'action': action, 'pull_request': {'labels': [{'name': n} for n in labels]}} + if label is not None: + payload['label'] = {'name': label} + return payload + + +def nightly(branch, api, inputs=None): + payload = {'inputs': {'nightly': 'true', **(inputs or {})}} + return decide('workflow_dispatch', f'refs/heads/{branch}', SHA, payload, api) + + +# --- pull requests --- + + +@pytest.mark.parametrize('action', ['opened', 'reopened', 'synchronize']) +def test_pr_without_label_builds_the_canary(action): + d = decide('pull_request', 'refs/pull/7/merge', SHA, pr(action), FakeApi()) + assert outcome(d) == (True, False, False, 'dev', CANARY_BOARD) + assert not d.warm_idf + + +def test_pr_with_full_build_label_builds_every_board(): + d = decide('pull_request', 'refs/pull/7/merge', SHA, pr(labels=['bug', FULL_BUILD_LABEL]), FakeApi()) + assert outcome(d) == (True, False, False, 'dev', '') + assert d.warm_idf + + +def test_pr_labelled_full_build_builds_every_board(): + payload = pr('labeled', labels=[FULL_BUILD_LABEL], label=FULL_BUILD_LABEL) + d = decide('pull_request', 'refs/pull/7/merge', SHA, payload, FakeApi()) + assert outcome(d) == (True, False, False, 'dev', '') + + +def test_pr_labelled_with_another_label_does_nothing(capsys): + payload = pr('labeled', labels=['enhancement'], label='enhancement') + d = decide('pull_request', 'refs/pull/7/merge', SHA, payload, FakeApi()) + assert outcome(d) == (False, False, False, 'dev', '') + assert not d.warm_idf + assert 'Label "enhancement" does not affect the build.' in capsys.readouterr().out + + +def test_pr_labelled_with_another_label_while_carrying_full_build_still_does_nothing(): + # The full build was already started by the labeled event for ci:full-build itself. + payload = pr('labeled', labels=[FULL_BUILD_LABEL, 'bug'], label='bug') + d = decide('pull_request', 'refs/pull/7/merge', SHA, payload, FakeApi()) + assert outcome(d) == (False, False, False, 'dev', '') + + +# --- release tags --- + + +@pytest.mark.parametrize('tag', ['1.5.0', '1.5.0-rc.1', '1.5.0-develop.3']) +def test_tag_push_builds_deploys_and_publishes_to_prod(tag): + api = FakeApi() + d = decide('push', f'refs/tags/{tag}', SHA, {}, api) + assert outcome(d) == (True, True, True, 'prod', '') + assert d.warm_idf + assert api.run_queries == [] and api.compares == [] + + +# --- nightlies --- + + +@pytest.mark.parametrize( + 'branch, deploy, publish', + [('develop', True, True), ('beta', False, False), ('master', False, False)], +) +def test_nightly_with_source_changes_builds(branch, deploy, publish): + api = FakeApi(runs={'workflow_dispatch': run(BASE)}, files=['README.md', 'components/rf/src/Foo.cpp']) + d = nightly(branch, api) + assert outcome(d) == (True, deploy, publish, 'prod', '') + assert d.warm_idf + assert api.run_queries == [('schedule', branch), ('workflow_dispatch', branch)] + assert api.compares == [(BASE, SHA)] + + +@pytest.mark.parametrize('branch', ['develop', 'beta', 'master']) +def test_nightly_with_unchanged_sha_skips(branch): + api = FakeApi(runs={'workflow_dispatch': run(SHA)}) + d = nightly(branch, api) + assert outcome(d) == (False, False, False, 'prod', '') + assert not d.warm_idf + assert api.compares == [] + + +NON_SOURCE_CHANGES = [ + 'README.md', + 'components/rf/README.md', + 'docs/flashing.png', + 'LICENSE', + 'components/fs/data/.gitkeep', + '.gitkeep', + '.claude/settings.json', + '.github/ISSUE_TEMPLATE/bug_report.yml', + '.github/dependabot.yml', + 'schemas', + 'schemas/HubToGatewayMessage.fbs', + 'scripts/fetch_flatc.py', + 'scripts/generate_schemas.py', + 'scripts/flatc', + 'scripts/flatc.exe', +] + + +@pytest.mark.parametrize('branch', ['develop', 'beta', 'master']) +def test_nightly_with_only_non_source_changes_skips(branch): + api = FakeApi(runs={'schedule': run(BASE)}, files=NON_SOURCE_CHANGES) + d = nightly(branch, api) + assert outcome(d) == (False, False, False, 'prod', '') + + +@pytest.mark.parametrize('name', NON_SOURCE_CHANGES) +def test_non_source_paths(name): + assert not ci_build_gate.is_source(name) + + +@pytest.mark.parametrize( + 'name', + [ + '.changes/feat-rf.md', + '.github/workflows/ci-build.yml', + '.github/scripts/get-vars.py', + '.github/ISSUE_TEMPLATE', + 'components/rf/src/Foo.cpp', + 'docs', + 'LICENSE.txt', + 'mydocs/x.txt', + 'schemas.txt', + 'scripts/build.py', + 'scripts/flatc.sh', + 'sub/scripts/fetch_flatc.py', + 'CHANGELOG.mdx', + ], +) +def test_source_paths(name): + assert ci_build_gate.is_source(name) + + +@pytest.mark.parametrize('changed', [['.changes/feat-rf.md'], ['.github/workflows/ci-build.yml']]) +def test_nightly_counts_changes_and_workflows_as_source(changed): + api = FakeApi(runs={'workflow_dispatch': run(BASE)}, files=changed) + assert outcome(nightly('develop', api)) == (True, True, True, 'prod', '') + + +def test_nightly_counts_a_rename_from_source_as_source(): + api = FakeApi( + runs={'workflow_dispatch': run(BASE)}, + files=[{'filename': 'docs/old-notes.txt', 'previous_filename': 'components/rf/notes.txt'}], + ) + assert nightly('develop', api).build + + +def test_first_ever_nightly_builds_without_comparing(): + api = FakeApi() + d = nightly('develop', api) + assert outcome(d) == (True, True, True, 'prod', '') + assert api.compares == [] + + +def test_nightly_compares_against_the_newer_of_schedule_and_dispatch(): + older, newer = 'a' * 40, 'd' * 40 + api = FakeApi( + runs={ + 'schedule': run(older, '2026-09-01T03:00:00Z'), + 'workflow_dispatch': run(newer, '2026-10-01T03:00:00Z'), + }, + files=['README.md'], + ) + nightly('develop', api) + assert api.compares == [(newer, SHA)] + + api.runs = {'schedule': run(newer, '2026-10-02T03:00:00Z'), 'workflow_dispatch': run(older, '2026-10-01T03:00:00Z')} + api.compares = [] + nightly('develop', api) + assert api.compares == [(newer, SHA)] + + +def test_nightly_run_listing_failure_builds(capsys): + api = FakeApi(runs_error=RuntimeError('HTTP 502'), files=['README.md']) + d = nightly('develop', api) + assert outcome(d) == (True, True, True, 'prod', '') + assert api.compares == [] + assert '::warning::Could not read the last develop build: HTTP 502' in capsys.readouterr().out + + +def test_nightly_compare_failure_builds(capsys): + api = FakeApi(runs={'workflow_dispatch': run(BASE)}, compare_error=RuntimeError('HTTP 404')) + d = nightly('beta', api) + assert outcome(d) == (True, False, False, 'prod', '') + assert f'::warning::Could not compare {BASE}...{SHA}: HTTP 404' in capsys.readouterr().out + + +@pytest.mark.parametrize('count', [300, 301]) +def test_nightly_with_a_truncated_comparison_builds(count): + api = FakeApi(runs={'workflow_dispatch': run(BASE)}, files=[f'docs/{i}.md' for i in range(count)]) + assert nightly('master', api).build + + +def test_nightly_with_299_non_source_files_skips(): + api = FakeApi(runs={'workflow_dispatch': run(BASE)}, files=[f'docs/{i}.md' for i in range(299)]) + assert not nightly('master', api).build + + +def test_nightly_on_a_feature_branch_is_refused(capsys): + api = FakeApi() + d = nightly('feat/arduino-3.0', api) + assert outcome(d) == (False, False, False, 'prod', '') + assert api.run_queries == [] + assert 'Nightly requested on refs/heads/feat/arduino-3.0' in capsys.readouterr().out + + +def test_nightly_on_a_tag_is_refused(): + api = FakeApi() + d = decide('workflow_dispatch', 'refs/tags/1.5.0', SHA, {'inputs': {'nightly': 'true'}}, api) + assert outcome(d) == (False, False, False, 'prod', '') + assert api.run_queries == [] + + +def test_nightly_ignores_server_and_channel(): + api = FakeApi(runs={'workflow_dispatch': run(BASE)}, files=['main/main.cpp']) + d = nightly('beta', api, {'server': 'dev', 'channel': 'stable'}) + assert outcome(d) == (True, False, False, 'prod', '') + + +def test_boolean_nightly_input_counts(): + api = FakeApi() + d = decide('workflow_dispatch', 'refs/heads/develop', SHA, {'inputs': {'nightly': True}}, api) + assert d.server == 'prod' and api.run_queries + + +# --- manual dispatch --- + + +@pytest.mark.parametrize('nightly_input', ['false', False, None]) +def test_dispatch_on_develop_publishes_to_dev(nightly_input): + inputs = {'server': 'dev', 'channel': 'develop'} + if nightly_input is not None: + inputs['nightly'] = nightly_input + api = FakeApi() + d = decide('workflow_dispatch', 'refs/heads/develop', SHA, {'inputs': inputs}, api) + assert outcome(d) == (True, True, True, 'dev', '') + assert api.run_queries == [] + + +def test_dispatch_on_master_with_prod_is_downgraded_to_dev(capsys): + payload = {'inputs': {'server': 'prod', 'channel': 'stable', 'nightly': 'false'}} + d = decide('workflow_dispatch', 'refs/heads/master', SHA, payload, FakeApi()) + assert outcome(d) == (True, False, True, 'dev', '') + assert 'prod was requested on refs/heads/master' in capsys.readouterr().out + + +def test_dispatch_on_develop_with_prod_is_downgraded_to_dev(): + payload = {'inputs': {'server': 'prod', 'nightly': 'false'}} + d = decide('workflow_dispatch', 'refs/heads/develop', SHA, payload, FakeApi()) + assert outcome(d) == (True, True, True, 'dev', '') + + +def test_dispatch_on_a_tag_may_publish_to_prod(): + payload = {'inputs': {'server': 'prod', 'channel': 'beta', 'nightly': 'false'}} + d = decide('workflow_dispatch', 'refs/tags/1.5.0-rc.1', SHA, payload, FakeApi()) + assert outcome(d) == (True, False, True, 'prod', '') + + +def test_dispatch_without_inputs_publishes_to_dev(): + d = decide('workflow_dispatch', 'refs/heads/feat/x', SHA, {}, FakeApi()) + assert outcome(d) == (True, False, True, 'dev', '') + + +# --- main --- + + +def test_main_writes_every_output(tmp_path, monkeypatch): + event = tmp_path / 'event.json' + event.write_text(json.dumps(pr()), encoding='utf-8') + out = tmp_path / 'output' + monkeypatch.setenv('GITHUB_EVENT_NAME', 'pull_request') + monkeypatch.setenv('GITHUB_REF', 'refs/pull/7/merge') + monkeypatch.setenv('GITHUB_SHA', SHA) + monkeypatch.setenv('GITHUB_REPOSITORY', 'OpenShock/Firmware') + monkeypatch.setenv('GITHUB_EVENT_PATH', str(event)) + monkeypatch.setenv('GITHUB_OUTPUT', str(out)) + assert ci_build_gate.main() == 0 + assert out.read_text(encoding='utf-8').splitlines() == [ + 'build=true', + 'deploy=false', + 'publish=false', + 'server=dev', + f'boards={CANARY_BOARD}', + 'warm-idf=false', + ] diff --git a/.github/scripts/tests/test_get_vars.py b/.github/scripts/tests/test_get_vars.py new file mode 100644 index 00000000..74e9709b --- /dev/null +++ b/.github/scripts/tests/test_get_vars.py @@ -0,0 +1,85 @@ +import importlib.util +from pathlib import Path + +import pytest + +# get-vars.py has a hyphen in its name, so it can't be imported with a plain import statement. +_spec = importlib.util.spec_from_file_location('get_vars', Path(__file__).resolve().parent.parent / 'get-vars.py') +get_vars = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(get_vars) + + +@pytest.mark.parametrize( + 'tag, expected', + [ + ('1.5.0', '1.5.0'), + ('v0.8.1', '0.8.1'), # historical v prefix + ('1.5.0-rc.2', '1.5.0-rc.2'), + ], +) +def test_parse_version_accepts(tag, expected): + assert str(get_vars.parse_version(tag)) == expected + + +@pytest.mark.parametrize('tag', ['', 'latest', '1.5', 'v1.x.0']) +def test_parse_version_rejects(tag): + assert get_vars.parse_version(tag) is None + + +@pytest.mark.parametrize( + 'tag, stable, beta, dev', + [ + ('1.5.0', True, False, False), + ('1.5.0-rc.1', False, True, False), + ('1.5.0-beta.3', False, True, False), + ('1.5.0-develop.4', False, False, True), + ('1.5.0-dev.4', False, False, True), + ('1.5.0-feature.1', False, False, False), + ], +) +def test_channel_of_version(tag, stable, beta, dev): + version = get_vars.parse_version(tag) + assert get_vars.is_stable(version) is stable + assert get_vars.is_beta(version) is beta + assert get_vars.is_dev(version) is dev + + +@pytest.mark.parametrize( + 'name, expected', + [ + ('feature/new-thing', 'feature-new-thing'), + ('--weird__name--', 'weird-name'), + ('a///b', 'a-b'), + ], +) +def test_sanitize(name, expected): + assert get_vars.sanitize(name) == expected + + +def test_sdkconfig_lines_keeps_settings_and_disabled_bools_only(tmp_path): + fragment = tmp_path / 'board.defaults' + fragment.write_text( + '# A comment\n' + '\n' + 'CONFIG_IDF_TARGET="esp32"\n' + 'OPENSHOCK_RF_TX_GPIO=15\n' + '# CONFIG_FOO is not set\n' + 'CONFIG_BAR=y\n', + encoding='utf-8', + ) + assert get_vars.sdkconfig_lines(fragment) == sorted(['CONFIG_IDF_TARGET="esp32"', '# CONFIG_FOO is not set', 'CONFIG_BAR=y']) + + +def test_cache_group_ignores_pins_but_not_disabled_bools(tmp_path): + shared = tmp_path / 'sdkconfig.defaults' + shared.write_text('CONFIG_X=y\n', encoding='utf-8') + + def group(content): + fragment = tmp_path / 'board.defaults' + fragment.write_text(content, encoding='utf-8') + return get_vars.cache_group(fragment, 'esp32', shared) + + base = group('CONFIG_IDF_TARGET="esp32"\n') + assert group('CONFIG_IDF_TARGET="esp32"\nOPENSHOCK_LED_GPIO=2\n') == base + assert group('CONFIG_IDF_TARGET="esp32"\n# CONFIG_X is not set\n') != base + assert base.startswith('esp32-') diff --git a/.github/scripts/tests/test_repo_server_policy.py b/.github/scripts/tests/test_repo_server_policy.py new file mode 100644 index 00000000..f9348731 --- /dev/null +++ b/.github/scripts/tests/test_repo_server_policy.py @@ -0,0 +1,51 @@ +import pytest + +import repo_server_policy + +ENV = { + 'REPO': 'OpenShock/Firmware', + 'SHA': 'a' * 40, + 'VERSION': '1.5.0', + 'GITHUB_TOKEN': 'token', +} + + +@pytest.fixture +def checks(monkeypatch): + """Replaces the GitHub API checks with recorders.""" + calls = {'branch': [], 'draft': []} + monkeypatch.setattr(repo_server_policy, 'require_branch_contains', lambda repo, channel, sha: calls['branch'].append(channel)) + monkeypatch.setattr(repo_server_policy, 'require_staged_draft', lambda repo, version: calls['draft'].append(version)) + for name, value in ENV.items(): + monkeypatch.setenv(name, value) + return calls + + +def run(monkeypatch, server, channel): + monkeypatch.setenv('SERVER', server) + monkeypatch.setenv('CHANNEL', channel) + return repo_server_policy.main() + + +def test_dev_server_skips_every_check(monkeypatch, checks): + assert run(monkeypatch, 'dev', 'stable') == 0 + assert checks == {'branch': [], 'draft': []} + + +@pytest.mark.parametrize('channel', ['stable', 'beta']) +def test_prod_release_channels_need_branch_and_draft(monkeypatch, checks, channel): + assert run(monkeypatch, 'prod', channel) == 0 + assert checks['branch'] == [channel] + assert checks['draft'] == ['1.5.0'] + + +def test_prod_develop_needs_the_develop_branch_but_no_draft(monkeypatch, checks): + assert run(monkeypatch, 'prod', 'develop') == 0 + assert checks['branch'] == ['develop'] + assert checks['draft'] == [] + + +def test_prod_unknown_channel_is_refused(monkeypatch, checks): + with pytest.raises(SystemExit): + run(monkeypatch, 'prod', 'feature-x') + assert checks['branch'] == [] diff --git a/.github/scripts/tests/test_repo_server_publish.py b/.github/scripts/tests/test_repo_server_publish.py new file mode 100644 index 00000000..9c0a5a8e --- /dev/null +++ b/.github/scripts/tests/test_repo_server_publish.py @@ -0,0 +1,65 @@ +import pytest +import requests + +import repo_server_publish + + +class FakeResponse: + def __init__(self, status_code): + self.status_code = status_code + self.text = '' + + def json(self): + return {} + + +class FakeSession: + """Answers publish/delete calls; records which were made.""" + + def __init__(self, publish): + self.publish = publish # a status code, or an exception to raise + self.deleted = [] + + def post(self, url, **kwargs): + if isinstance(self.publish, Exception): + raise self.publish + return FakeResponse(self.publish) + + def delete(self, url, **kwargs): + self.deleted.append(url) + return FakeResponse(204) + + +@pytest.fixture(autouse=True) +def upload_env(monkeypatch): + monkeypatch.setenv('RELEASE_ID', 'rel-1') + monkeypatch.setenv('BOARDS', 'BoardA\nBoardB') + monkeypatch.setattr(repo_server_publish, 'upload_board', lambda *args, **kwargs: None) + monkeypatch.setattr(repo_server_publish, 'show_response', lambda resp: None) + + +def test_successful_publish_does_not_discard(): + session = FakeSession(publish=201) + assert repo_server_publish.do_upload(session, 'https://repo', 'publish') == 0 + assert session.deleted == [] + + +def test_rejected_publish_discards_the_release(): + session = FakeSession(publish=500) + with pytest.raises(SystemExit): + repo_server_publish.do_upload(session, 'https://repo', 'publish') + assert len(session.deleted) == 1 + + +def test_publish_without_a_response_is_not_discarded(): + # The release may already be live, so deleting it would be worse than leaving it for a human to check. + session = FakeSession(publish=requests.ConnectionError('timed out')) + with pytest.raises(SystemExit): + repo_server_publish.do_upload(session, 'https://repo', 'publish') + assert session.deleted == [] + + +def test_dry_run_always_discards(): + session = FakeSession(publish=201) + assert repo_server_publish.do_upload(session, 'https://repo', 'dry-run') == 0 + assert len(session.deleted) == 1 diff --git a/.github/workflows/check-changes.yml b/.github/workflows/check-changes.yml index 86e982bc..ca0d3687 100644 --- a/.github/workflows/check-changes.yml +++ b/.github/workflows/check-changes.yml @@ -17,12 +17,12 @@ jobs: !github.event.pull_request.draft && !contains(github.event.pull_request.labels.*.name, 'no-changelog') steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - name: Run change file check - uses: OpenShock/release-tool@ea651e8c10372a10b6f452dcb34c38e60a72dc1f # v0.5.1 + uses: OpenShock/release-tool@ea651e8c10372a10b6f452dcb34c38e60a72dc1f # v0.5.1 with: mode: check base-ref: ${{ github.event.pull_request.base.ref }} @@ -31,7 +31,7 @@ jobs: - name: Upload verdict if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: release-check path: release-check.json diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index 64315f75..c237c324 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -1,73 +1,67 @@ +# When the firmware is compiled. A full 13-board build is too heavy to run per commit, so: +# - release tags (beta rc / stable) build every board and publish to prod; +# - develop, beta and master build every board nightly (nightly.yml dispatches one run per branch, +# which keeps each branch's status badge current), only when firmware sources changed since +# that branch's last nightly; develop's nightly also publishes to prod, beta and master only build; +# - a pull request builds one canary board, or every board once it carries the ci:full-build label; +# - a dispatch builds every board on demand. +# Branch pushes do not build at all; the cheap per-commit checks live in checks.yml. +# Fork pull requests run only after a maintainer approves them: the repository requires approval +# for every workflow run from an external contributor. on: - # develop, beta and master all build on every push (paths-ignore skips - # docs/meta-only changes). develop also keeps a nightly schedule. Release - # tags always build. push: - branches: - - develop - - beta - - master tags: - '[0-9]+.[0-9]+.[0-9]+' - '[0-9]+.[0-9]+.[0-9]+-*' - # paths-ignore applies to branch pushes only; GitHub does not evaluate it - # for tag pushes, so release tags always build. A develop push is skipped - # only when EVERY changed file matches one of these (docs, tooling, tests). - paths-ignore: - - '**/*.md' - - 'docs/**' - - 'LICENSE' - - '**/.gitkeep' - - '.claude/**' - - '.github/ISSUE_TEMPLATE/**' - - '.github/dependabot.yml' - # schemas is a dev-only submodule used to regenerate committed code via the - # scripts/ tooling below; no CI build checks it out, so a pointer bump alone - # changes nothing. Regenerated output is committed elsewhere and still builds. - - 'schemas' - - 'schemas/**' - - 'scripts/fetch_flatc.py' - - 'scripts/generate_schemas.py' - - 'scripts/flatc' - - 'scripts/flatc.exe' pull_request: branches: - master - beta - develop - types: [opened, reopened, synchronize] - workflow_dispatch: # Manually invoked, e.g. to force a develop build. - schedule: - - cron: '0 3 * * *' # Nightly develop build; the gate job skips it if idle. + # labeled so that adding ci:full-build upgrades the PR's build; the gate ignores every other label. + types: [opened, reopened, synchronize, labeled] + workflow_dispatch: + inputs: + server: + description: 'Repository server to publish to (prod is honoured only when running against a tag)' + required: false + type: choice + default: dev + options: [dev, prod] + channel: + description: 'Channel to publish onto (derive = the channel the ref maps to)' + required: false + type: choice + default: derive + options: [derive, develop, beta, stable] + nightly: + description: 'Run as the nightly build of this branch (set by nightly.yml): skip when no firmware sources changed; server and channel are ignored' + required: false + type: boolean + default: false name: ci-build -# Per-ref: different branches (and tags) build and deploy concurrently. A PR -# cancels its own superseded runs; branch/tag runs queue per ref. The only thing -# that must not run concurrently across refs - advancing the shared channel -# pointer files - is serialized separately in its own global-concurrency job -# (cdn-bump), so uploads to distinct // paths stay parallel. +# Per-ref. +# No cross-ref group is needed: the only state two runs could contend for is a release on the server, which refuses a second staging of a version. +# Labelling a PR with anything but ci:full-build gets its own group, so the no-op run it triggers +# does not cancel the build already running for that PR. concurrency: - group: ${{ github.workflow }}-${{ github.ref }} + group: >- + ${{ github.workflow }}-${{ github.ref }}${{ + github.event.action == 'labeled' && github.event.label.name != 'ci:full-build' + && format('-label-{0}', github.event.label.name) || '' }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} -# Least-privilege default for every job. Jobs that need more (actions:read on the -# gate, contents:write + id-token:write on release/announce) opt up explicitly. +# Least-privilege default; jobs needing more opt up explicitly. +# No job holds a storage or CDN credential, because no job writes to either. permissions: contents: read -env: - PYTHON_VERSION: 3.13 - jobs: - # Decides what this run does: - # build - run the build matrix (any branch push, PRs, release tags) - # deploy - run cdn-deploy (release tags; nightly/manual develop deploy) - # develop/beta/master pushes build but do not deploy. schedule (develop) and a - # manual dispatch on develop rebuild and deploy; the nightly is skipped when - # develop has not advanced since the last one. deploy always implies build. + # Decides build, deploy, publish, the server and the boards; .github/scripts/ci_build_gate.py says how. gate: - runs-on: ubuntu-slim # lightweight: one github-script step, no checkout/install + runs-on: ubuntu-slim timeout-minutes: 3 permissions: contents: read @@ -75,99 +69,132 @@ jobs: outputs: build: ${{ steps.decide.outputs.build }} deploy: ${{ steps.decide.outputs.deploy }} + publish: ${{ steps.decide.outputs.publish }} + server: ${{ steps.decide.outputs.server }} + boards: ${{ steps.decide.outputs.boards }} + warm-idf: ${{ steps.decide.outputs.warm-idf }} steps: - # Decide what this run does. Inlined as github-script (which preloads - # octokit + core) so the gate needs no checkout, no pnpm install and no - # node_modules on the critical path of every push/PR. - # push (branch) -> build; push (tag) -> build + deploy - # pull_request -> build - # schedule -> rebuild + deploy develop, only when it advanced - # since the last develop deploy - # workflow_dispatch -> build; also deploy when run on develop - # deploy always implies build. - - name: Decide build vs deploy - id: decide - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + sparse-checkout: .github + + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: - script: | - const { eventName, ref, sha } = context; - const { owner, repo } = context.repo; - - // HEAD deployed by the most recent develop deploy - a nightly - // (schedule) or a manual dispatch of develop - or '' (also on API - // error, so a transient hiccup degrades to "treat as changed" and - // deploys rather than wrongly skipping). Both event types are - // considered so a manual deploy is not invisible to the next nightly. - async function lastDeploySha() { - const latestRunFor = async (event) => { - const { data } = await github.rest.actions.listWorkflowRuns({ - owner, - repo, - workflow_id: 'ci-build.yml', - event, - branch: 'develop', - status: 'success', - per_page: 1, - }); - return data.workflow_runs[0]; - }; - try { - const runs = ( - await Promise.all([latestRunFor('schedule'), latestRunFor('workflow_dispatch')]) - ).filter(Boolean); - if (runs.length === 0) return ''; - runs.sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); - return runs[0].head_sha ?? ''; - } catch (err) { - core.warning(`Could not read the last develop deploy: ${err.message}`); - return ''; - } - } - - let build = false; - let deploy = false; - - switch (eventName) { - case 'pull_request': - build = true; - break; - case 'push': - build = true; - if (ref.startsWith('refs/tags/')) deploy = true; - break; - case 'schedule': - if (sha !== (await lastDeploySha())) { - build = true; - deploy = true; - } else { - core.notice(`develop unchanged since last deploy (${sha}); nothing to deploy.`); - } - break; - case 'workflow_dispatch': - build = true; - if (ref === 'refs/heads/develop') deploy = true; - break; - default: - build = true; - } - - core.setOutput('build', build); - core.setOutput('deploy', deploy); - core.info(`decision: build=${build} deploy=${deploy}`); + python-version-file: .github/scripts/.python-version + cache: 'pip' + cache-dependency-path: .github/scripts/requirements.txt + + - name: Install script dependencies + shell: bash + run: pip install --require-hashes -r .github/scripts/requirements.txt + + - name: Decide build, deploy and publish + id: decide + env: + GITHUB_TOKEN: ${{ github.token }} + run: python .github/scripts/ci_build_gate.py getvars: needs: [gate] if: ${{ needs.gate.outputs.build == 'true' || needs.gate.outputs.deploy == 'true' }} uses: ./.github/workflows/get-vars.yml + # Set only on a manual dispatch; empty otherwise, nightlies included, so get-vars derives the channel from the ref. + with: + channel: ${{ !inputs.nightly && inputs.channel != 'derive' && inputs.channel || '' }} + boards: ${{ needs.gate.outputs.boards }} + + # Opens the release the moment get-vars has settled what this run is, before anything is built. + # Everything the server checks at init - semver, the channel enum, a version already published, another run holding the same one, an unknown board - answers here in a minute rather than after the matrix. + # It also reserves the version: a second run for it is refused now, instead of after both have spent half an hour building. + stage: + name: Stage release on ${{ needs.gate.outputs.server }} + runs-on: ubuntu-latest + needs: [gate, getvars] + timeout-minutes: 10 + # A fork PR cannot mint an OIDC token here, so it would fail on something the contributor cannot fix. + if: ${{ needs.gate.outputs.publish == 'true' && github.event.pull_request.head.repo.fork != true }} + # The environment carries OPENSHOCK_REPO_SERVER_URL, so which instance is published to is settings rather than workflow logic. + # The required reviewer on repo-server-prod also takes effect here rather than at the publish, so a production release is approved before it spends the build rather than after. + environment: repo-server-${{ needs.gate.outputs.server }} + permissions: + contents: read + id-token: write + outputs: + release-id: ${{ steps.stage.outputs.release-id }} + # The publish and abort jobs run outside the environment, so they cannot read its vars themselves. + # This is a hostname, not a credential. + server-url: ${{ steps.resolve.outputs.server-url }} + + steps: + - name: Resolve server configuration + id: resolve + env: + SERVER_URL: ${{ vars.OPENSHOCK_REPO_SERVER_URL }} + TARGET: ${{ needs.gate.outputs.server }} + run: | + set -euo pipefail + if [ -z "$SERVER_URL" ]; then + echo "::error::OPENSHOCK_REPO_SERVER_URL is unset on environment repo-server-$TARGET, so this build has nowhere to publish to." + exit 1 + fi + echo "server-url=$SERVER_URL" >> "$GITHUB_OUTPUT" + echo "Publishing to the $TARGET repository server." + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + sparse-checkout: | + .github + CHANGELOG.md + + # Same pinned set the repo-server actions install, so this runs before a credential is minted rather than after. + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version-file: .github/scripts/.python-version + cache: 'pip' + cache-dependency-path: .github/scripts/requirements.txt + + - name: Install script dependencies + shell: bash + run: pip install --require-hashes -r .github/scripts/requirements.txt + + # Refuses the run if this repository's rules say it may not publish what get-vars resolved. + # The rules are all about prod, and all about things the server cannot know: that a stable release descends from master, that a maintainer staged a draft for it. + - name: Apply repository publishing policy + env: + GITHUB_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + SHA: ${{ github.sha }} + SERVER: ${{ needs.gate.outputs.server }} + CHANNEL: ${{ needs.getvars.outputs.release-channel }} + VERSION: ${{ needs.getvars.outputs.version }} + run: python .github/scripts/repo_server_policy.py + + - uses: ./.github/actions/repo-server-token + id: token + + - uses: ./.github/actions/repo-server-publish + id: stage + with: + server-url: ${{ steps.resolve.outputs.server-url }} + token: ${{ steps.token.outputs.token }} + version: ${{ needs.getvars.outputs.version }} + channel: ${{ needs.getvars.outputs.release-channel }} + boards: ${{ needs.getvars.outputs.board-list }} + mode: init build-frontend: - needs: [gate] - if: ${{ needs.gate.outputs.build == 'true' }} + # Waits on stage so the reviewer on a prod release approves before the compute is spent rather than while it burns. + # stage is skipped on anything that does not publish, and a skip is not a failure, so a PR still builds. + needs: [gate, stage] + if: >- + ${{ !cancelled() + && needs.gate.outputs.build == 'true' + && needs.stage.result != 'failure' }} runs-on: ubuntu-latest timeout-minutes: 5 steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: recursive sparse-checkout: | @@ -176,203 +203,243 @@ jobs: - uses: ./.github/actions/build-frontend + # Pack the captive-portal assets into the static0 image once (board-independent). + # Runs in parallel with the per-board C++ build below; merge-partitions links them. build-staticfs: runs-on: ubuntu-latest needs: [getvars, build-frontend] + # `stage` is skipped on every run that does not publish, and GitHub propagates that + # skip down the needs graph to any job that does not override it. build-frontend and + # build-firmware each carry their own condition, so they run; this job did not, so it + # skipped on pull requests - taking merge-partitions and checkpoint-build with it, and + # leaving a PR with no flashable images. It builds on dispatch, which is why it went + # unnoticed. + if: >- + ${{ !cancelled() + && needs.getvars.result == 'success' + && needs.build-frontend.result == 'success' }} timeout-minutes: 5 steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: ./.github/actions/build-staticfs with: version: ${{ needs.getvars.outputs.version }} + # Populate the ESP-IDF cache once, before the matrix fans out. + # + # setup-esp-idf only runs the installer when the cache misses, so on a warm key every + # board skips it. The problem is the cold key: without this job all 13 boards miss at + # the same moment and all 13 run the installer concurrently, which measured 867s per job + # - some 700s of it apt fetching cmake at ~35 KB/s while twelve siblings did the same. + # One job paying that once, and thirteen then hitting a warm cache, is strictly cheaper. + # + # A lookup-only probe first, so the usual case - the cache already exists - costs a few + # seconds and does not download the entry just to prove it is there. + # + # Skipped when a single board builds (the pull request canary): that board's job is the + # nominated writer, so it warms the cache itself and a separate job would only delay it. + warm-esp-idf: + name: Warm the ESP-IDF cache + runs-on: ubuntu-latest + needs: [gate] + if: ${{ !cancelled() && needs.gate.outputs.warm-idf == 'true' }} + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + sparse-checkout: .github + + # lookup-only so the cache key lives in exactly one place, inside the action. The + # probe checks for the entry without downloading it, so the usual case - already + # populated - costs seconds; on a miss the action installs, prunes and saves. + - uses: ./.github/actions/setup-esp-idf + with: + lookup-only: 'true' + + # Compile each board's C++ firmware. + # Builds only the code partitions (no static0 image, no frontend), so it runs in parallel with build-staticfs. build-firmware: + # The matrix carries chip and cache-group alongside the board, which would otherwise + # all end up in the generated job name. + name: build-firmware (${{ matrix.board }}) runs-on: ubuntu-latest - needs: [gate, getvars] - if: ${{ needs.gate.outputs.build == 'true' }} - timeout-minutes: 10 + needs: [gate, getvars, stage, warm-esp-idf] + # getvars is named explicitly: without it, !cancelled() would let the matrix start on an empty board-matrix when get-vars failed. + # warm-esp-idf is skipped on a single-board build, and !cancelled() keeps that skip from propagating here, so only its failure stops the matrix. + if: >- + ${{ !cancelled() + && needs.gate.outputs.build == 'true' + && needs.getvars.result == 'success' + && needs.stage.result != 'failure' + && (needs.warm-esp-idf.result == 'success' || needs.warm-esp-idf.result == 'skipped') }} + timeout-minutes: 30 strategy: fail-fast: false matrix: ${{ fromJSON(needs.getvars.outputs.board-matrix) }} steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # flatbuffers runtime lives in a submodule (components/flatbuffers). + submodules: recursive - uses: ./.github/actions/build-firmware with: board: ${{ matrix.board }} version: ${{ needs.getvars.outputs.version }} + cache-group: ${{ matrix.cache-group }} + idf-cache-writer: ${{ matrix.idf-cache-writer }} + # Link every board's C++ partitions with the shared static0 image into one flashable binary each. + # Waits on both parallel build legs. + # One job rather than a matrix: the merge is seconds of esptool per board, so thirteen runners spent more on checkout, pip and artifact downloads than on the work. merge-partitions: runs-on: ubuntu-latest needs: [getvars, build-staticfs, build-firmware] - timeout-minutes: 5 - strategy: - fail-fast: false - matrix: ${{ fromJSON(needs.getvars.outputs.board-matrix) }} + # Explicit for the same reason as build-staticfs above: this job sits downstream of + # the same chain, so it must not inherit a skip from it. + if: >- + ${{ !cancelled() + && needs.getvars.result == 'success' + && needs.build-staticfs.result == 'success' + && needs.build-firmware.result == 'success' }} + timeout-minutes: 10 steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: sparse-checkout: | .github scripts boards - chips + partitions - uses: ./.github/actions/merge-partitions with: version: ${{ needs.getvars.outputs.version }} - board: ${{ matrix.board }} + boards: ${{ needs.getvars.outputs.board-list }} checkpoint-build: - runs-on: ubuntu-slim # lightweight: barrier job, just an echo + runs-on: ubuntu-slim needs: [merge-partitions] timeout-minutes: 1 steps: - run: echo "Builds checkpoint reached" - # A single job owns the whole CDN deployment. Every job that declares - # `environment:` produces one GitHub deployment record, and matrix legs each - # count separately, so the old split (matrix upload + version-info + bump) - # created `boards + 2` deployments per run. Collapsing the work into one - # non-matrix job yields exactly one deployment per run. The CDN secrets/vars - # are environment-scoped, so this job must keep `environment: cdn` to read - # them; the environment's only protection rule is a branch policy, so nothing - # is gated away by merging the jobs. - cdn-deploy: + # Upload every board's artifacts with the hash CI computed for each, into the release stage opened, then publish. + # Fail on the first non-success; that is the whole contract. + # Where artifacts are stored and which hostname serves them are the server's decisions, and CI holds no storage credential to second-guess them with. + # + # Every build publishes, against the development instance. + # Publishing consumes a version and only a maintainer can undo it, at /admin/firmware/releases. + # Branch builds carry + so pushes do not collide; re-running a build on a published commit does. + # No environment here: the reviewer already approved this release at stage, and nothing can be promoted that was not staged there. + repo-server: + name: Repository server (publish to ${{ needs.gate.outputs.server }}) runs-on: ubuntu-latest - needs: [gate, getvars, checkpoint-build] + needs: [gate, getvars, stage, checkpoint-build] timeout-minutes: 20 - # Deploy on release tags and on nightly/manual develop deploys. deploy always - # implies build, so checkpoint-build has run and gates this via needs. - if: ${{ needs.gate.outputs.deploy == 'true' }} - environment: cdn + if: ${{ needs.gate.outputs.publish == 'true' && github.event.pull_request.head.repo.fork != true }} permissions: contents: read + id-token: write steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: sparse-checkout: | .github + CHANGELOG.md - # A beta/master deploy must have a draft release staged by release.yml for - # this version; otherwise a stray tag push could publish firmware to the - # CDN. develop has no releases, so it is exempt. - - name: Require a staged draft release (beta/master only) - if: ${{ needs.getvars.outputs.release-channel != 'develop' }} - env: - GH_TOKEN: ${{ github.token }} - REPO: ${{ github.repository }} - TAG: ${{ needs.getvars.outputs.version }} - run: | - set -euo pipefail - # release.yml pushes the tag (which triggers this run) and only then - # stages the draft, so poll for up to ~60s rather than failing on the - # brief window before the draft exists. - for attempt in $(seq 1 12); do - isDraft=$(gh release view "$TAG" --repo "$REPO" --json isDraft --jq '.isDraft' 2>/dev/null || echo "missing") - if [ "$isDraft" = "true" ]; then - echo "Draft release for $TAG is staged." - exit 0 - fi - echo "Draft for $TAG not staged yet (state: $isDraft); retry $attempt/12..." - sleep 5 - done - echo "::error::No draft release for $TAG after ~60s. release.yml must stage the draft before the CDN upload runs." - exit 1 - - # Upload every board's firmware to the CDN. - - uses: ./.github/actions/cdn-upload-firmware + # Same pinned set the repo-server actions install, so this runs before a credential is minted rather than after. + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: - bunny-stor-hostname: ${{ vars.BUNNY_STOR_HOSTNAME }} - bunny-stor-username: ${{ secrets.BUNNY_STOR_USERNAME }} - bunny-stor-password: ${{ secrets.BUNNY_STOR_PASSWORD }} - fw-version: ${{ needs.getvars.outputs.version }} - boards: ${{ needs.getvars.outputs.board-list }} + python-version-file: .github/scripts/.python-version + cache: 'pip' + cache-dependency-path: .github/scripts/requirements.txt + + - name: Install script dependencies + shell: bash + run: pip install --require-hashes -r .github/scripts/requirements.txt - # Upload the version manifest (boards.txt) alongside the firmware. - - uses: ./.github/actions/cdn-upload-version-info + - uses: ./.github/actions/repo-server-token + id: token + + - uses: ./.github/actions/repo-server-publish with: - bunny-stor-hostname: ${{ vars.BUNNY_STOR_HOSTNAME }} - bunny-stor-username: ${{ secrets.BUNNY_STOR_USERNAME }} - bunny-stor-password: ${{ secrets.BUNNY_STOR_PASSWORD }} - fw-version: ${{ needs.getvars.outputs.version }} - release-channel: ${{ needs.getvars.outputs.release-channel }} + server-url: ${{ needs.stage.outputs.server-url }} + token: ${{ steps.token.outputs.token }} + release-id: ${{ needs.stage.outputs.release-id }} boards: ${{ needs.getvars.outputs.board-list }} + mode: publish - # Advancing the channel pointer files (version-.txt) is a read-modify- - # write on state shared across every branch and channel, so it must never run - # concurrently with another deploy. Its own global concurrency group serializes - # every bump repo-wide, independent of the per-ref build/deploy above. - cdn-bump: + # The finally for the release stage opened. + # That lifetime now spans jobs, so nothing inside a single script can close it: if the build fails, is cancelled, or never reaches the publish, this is what hands the version back. + # The server's TTL sweeper is the backstop rather than the mechanism - waiting for it would hold the version against the very retry meant to fix the failure. + abort: + name: Abort staged release runs-on: ubuntu-latest - needs: [getvars, cdn-deploy, release] - timeout-minutes: 5 - # Advance the pointer (the actual rollout to devices) only after the release - # is finalized: cdn-deploy uploaded the firmware, and on a tag the release - # job attached the binaries and published. release is skipped on develop - # deploys, which must still bump - hence the skipped branch. - if: >- - !cancelled() - && needs.cdn-deploy.result == 'success' - && (needs.release.result == 'success' || needs.release.result == 'skipped') - environment: cdn + needs: [gate, stage, repo-server] + timeout-minutes: 10 + if: ${{ always() && needs.stage.outputs.release-id != '' && needs.repo-server.result != 'success' }} permissions: contents: read - concurrency: - group: cdn-version-bump - cancel-in-progress: false + id-token: write steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - # Full history so cdn-bump-version can resolve the commit date of the - # version currently pinned on the develop channel (X.Y.Z-develop+) - # and only advance the pointer to a strictly newer commit. - fetch-depth: 0 - sparse-checkout: | - .github + sparse-checkout: .github - - uses: ./.github/actions/cdn-bump-version + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: - bunny-stor-hostname: ${{ vars.BUNNY_STOR_HOSTNAME }} - bunny-stor-username: ${{ secrets.BUNNY_STOR_USERNAME }} - bunny-stor-password: ${{ secrets.BUNNY_STOR_PASSWORD }} - bunny-api-key: ${{ secrets.BUNNY_APIKEY }} - bunny-cdn-url: ${{ vars.BUNNY_CDN_URL }} - version: ${{ needs.getvars.outputs.version }} - release-channel: ${{ needs.getvars.outputs.release-channel }} + python-version-file: .github/scripts/.python-version + cache: 'pip' + cache-dependency-path: .github/scripts/requirements.txt + + - name: Install script dependencies + shell: bash + run: pip install --require-hashes -r .github/scripts/requirements.txt + - uses: ./.github/actions/repo-server-token + id: token + + - uses: ./.github/actions/repo-server-publish + with: + server-url: ${{ needs.stage.outputs.server-url }} + token: ${{ steps.token.outputs.token }} + release-id: ${{ needs.stage.outputs.release-id }} + mode: abort + + # Mirrors the binaries onto the GitHub release, for people rather than devices. + # Runs after the publish, with no always() guard, so a release never advertises what no device can fetch. + # Stays on the deploy gate: every build publishes, not every build cuts a release. release: runs-on: ubuntu-latest - needs: [getvars, cdn-deploy] + needs: [gate, getvars, repo-server] timeout-minutes: 5 - if: (needs.getvars.outputs.release-channel == 'stable' || needs.getvars.outputs.release-channel == 'beta') + # A GitHub release exists only for a tag, and only because release.yml staged the draft. + # Testing the ref rather than the channel, since a dispatch may now name a channel the ref has no release for. + if: >- + needs.gate.outputs.deploy == 'true' + && startsWith(github.ref, 'refs/tags/') permissions: contents: write steps: - # Only the merged, flashable binaries are needed here; downloading every - # artifact (frontend, per-board partitions, staticfs) just to glob for the - # merged images is wasteful. + # Only the merged, flashable binaries are needed here; downloading every artifact (frontend, per-board partitions, staticfs) just to glob for the merged images is wasteful. - name: Download release artifacts - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - pattern: firmware_merged_* + name: firmware_merged - name: Display artifacts run: ls -R - # release.yml staged the draft release for this tag with the title and - # notes. Here we attach the freshly built binaries and then publish the - # draft - so the GitHub release goes live only once its binaries exist and - # the build/deploy succeeded. The API announcement is deliberately deferred - # to the `announce` job, which runs only after cdn-bump advances the device - # channel pointer, so the API is never told about a release before devices - # can actually pull it. + # release.yml staged the draft release for this tag with the title and notes. + # Here we attach the freshly built binaries and then publish the draft - so the GitHub release goes live only once its binaries exist and the repository server is already serving the version. - name: Upload binaries to staged release env: GH_TOKEN: ${{ github.token }} @@ -387,10 +454,7 @@ jobs: fi gh release upload "$TAG" "${files[@]}" --clobber - # Solidify: binaries are attached and firmware is on the CDN - now publish - # the draft. cdn-bump then advances the legacy channel pointer, and only - # then does repo-server-publish ingest the release into the repository - # server, so the two rollout paths never disagree about what is current. + # Solidify: the version is live on the repository server and the binaries are attached - now publish the draft, and only then announce. - name: Publish the release env: GH_TOKEN: ${{ github.token }} @@ -398,70 +462,37 @@ jobs: TAG: ${{ needs.getvars.outputs.version }} run: gh release edit "$TAG" --draft=false - # Publish the release to the repository server, which is the authoritative - # ingestion path: it hashes and stores every binary itself and owns the - # device-facing view of what is current. - # - # This replaces the old `announce` job, which POSTed release.json to - # $OPENSHOCK_API_URL/1/public/releases with audience `openshock-release-ingest` - # - an endpoint that does not exist on either side, so the payload went nowhere. - # - # The legacy cdn-deploy/cdn-bump jobs above deliberately still run. Hubs in the - # field predate the JSON API client and can only update via the flat files - # (version-.txt, boards.txt, hashes.sha256.txt). Retiring those jobs - # before the fleet has moved would strand every un-migrated hub with no path to - # any future firmware - including the firmware that would migrate it. Remove - # them only once telemetry shows the old client is gone. - repo-server-publish: - runs-on: ubuntu-latest - needs: [getvars, cdn-bump] - timeout-minutes: 20 - if: >- - !cancelled() - && needs.cdn-bump.result == 'success' - && vars.OPENSHOCK_REPO_SERVER_URL != '' + # Announce the release to the OpenShock API last of all - after the repository server has published it, so the API never leads the rollout (a device asked to update by the API could otherwise be pointed at a version the server is not yet serving). + # Chaining on `release` inherits its conditions: only a stable/beta deploy gets this far, which is also the only case that produced a release.json to send. + announce: + runs-on: ubuntu-slim # lightweight: gh + curl only + needs: [getvars, release] + timeout-minutes: 5 + if: ${{ vars.OPENSHOCK_API_URL != '' }} permissions: contents: read id-token: write steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - sparse-checkout: | - .github - - # The release body is the changelog the server parses into structured notes. develop builds - # have no GitHub release, so they carry a synthetic one - the server rejects a changelog it - # cannot parse rather than publishing a release with empty notes. - - name: Resolve changelog - id: changelog + # release.json was staged as a release asset by release.yml. + - name: Announce release to OpenShock API env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} + API_URL: ${{ vars.OPENSHOCK_API_URL }} TAG: ${{ needs.getvars.outputs.version }} - CHANNEL: ${{ needs.getvars.outputs.release-channel }} - SHA: ${{ github.sha }} run: | set -euo pipefail - if [ "$CHANNEL" = "develop" ]; then - body="### Info"$'\n'"- Development build from ${SHA}" - else - body=$(gh release view "$TAG" --json body --jq '.body') - if [ -z "$body" ]; then - echo "::error::Release $TAG has an empty body; the repository server needs a changelog." - exit 1 - fi + gh release download "$TAG" --pattern release.json --dir . + OIDC_TOKEN=$(curl -sLS \ + -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ + "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=openshock-release-ingest" \ + | jq -r .value) + if [ -z "$OIDC_TOKEN" ] || [ "$OIDC_TOKEN" = "null" ]; then + echo "::error::Failed to fetch GitHub OIDC token" + exit 1 fi - { - echo "body<> "$GITHUB_OUTPUT" - - - uses: ./.github/actions/repo-server-publish - with: - repo-server-url: ${{ vars.OPENSHOCK_REPO_SERVER_URL }} - fw-version: ${{ needs.getvars.outputs.version }} - release-channel: ${{ needs.getvars.outputs.release-channel }} - boards: ${{ needs.getvars.outputs.board-list }} - changelog: ${{ steps.changelog.outputs.body }} + curl -fsSL --retry 3 --retry-delay 5 -X POST "$API_URL/1/public/releases" \ + -H "Authorization: Bearer $OIDC_TOKEN" \ + -H "Content-Type: application/json" \ + --data-binary @release.json diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index c1bfcde8..d8127869 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -7,6 +7,7 @@ on: branches: ['master', 'develop'] schedule: - cron: '0 6 * * 1' + workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -16,7 +17,6 @@ env: OPENSHOCK_API_DOMAIN: api.openshock.app OPENSHOCK_FW_GIT_REF: ${{ github.ref }} OPENSHOCK_FW_GIT_COMMIT: ${{ github.sha }} - OPENSHOCK_FW_BUILD_DATE: ${{ github.event.head_commit.timestamp }} jobs: # CodeQL only needs to compile the code for analysis; the exact version string @@ -42,28 +42,31 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: recursive # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@e58424170fb0262c8d7ed60a2e84b9bffe205c67 # codeql-bundle-v2.26.1 + uses: github/codeql-action/init@416ff0dea110f80f0f56f0046500dbf7420e4bb0 # codeql-bundle-v2.27.1 with: languages: ${{ matrix.language }} - name: Autobuild - uses: github/codeql-action/autobuild@e58424170fb0262c8d7ed60a2e84b9bffe205c67 # codeql-bundle-v2.26.1 + uses: github/codeql-action/autobuild@416ff0dea110f80f0f56f0046500dbf7420e4bb0 # codeql-bundle-v2.27.1 # Build stuff here - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@e58424170fb0262c8d7ed60a2e84b9bffe205c67 # codeql-bundle-v2.26.1 + uses: github/codeql-action/analyze@416ff0dea110f80f0f56f0046500dbf7420e4bb0 # codeql-bundle-v2.27.1 with: category: '/language:${{matrix.language}}' + # Weekly (or on demand) only: the C/C++ analysis has to compile the firmware, which is too + # heavy for every push and pull request. JS/Python above need no compile and stay per-change. analyze-cpp: name: Analyze C/C++ + if: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }} runs-on: 'ubuntu-latest' timeout-minutes: 15 permissions: @@ -77,19 +80,23 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # flatbuffers runtime lives in a submodule (components/flatbuffers). + submodules: recursive - name: Initialize CodeQL - uses: github/codeql-action/init@e58424170fb0262c8d7ed60a2e84b9bffe205c67 # codeql-bundle-v2.26.1 + uses: github/codeql-action/init@416ff0dea110f80f0f56f0046500dbf7420e4bb0 # codeql-bundle-v2.27.1 with: languages: ${{ env.language }} - - uses: ./.github/actions/build-firmware + # build, not configure: CodeQL's tracer only sees the C/C++ if the compiler actually runs. + - uses: ./.github/actions/build-compilationdb with: - board: ci-build version: 0.0.0-codeql+build + mode: build - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@e58424170fb0262c8d7ed60a2e84b9bffe205c67 # codeql-bundle-v2.26.1 + uses: github/codeql-action/analyze@416ff0dea110f80f0f56f0046500dbf7420e4bb0 # codeql-bundle-v2.27.1 with: category: '/language:${{ env.language }}' diff --git a/.github/workflows/cpp-linter.yml b/.github/workflows/cpp-linter.yml index 345174bc..e9b183b1 100644 --- a/.github/workflows/cpp-linter.yml +++ b/.github/workflows/cpp-linter.yml @@ -5,8 +5,8 @@ on: types: [opened, reopened, synchronize] paths: ['**.c', '**.h', '**.cpp', '**.hpp', '.clang-format', '.github/workflows/cpp-linter.yml'] push: - # Branch-scoped: path filters are ignored for tag pushes, so without this a - # release tag would trigger a full compiledb build + hard-failing lint. + # Branch-scoped: path filters are ignored for tag pushes, so without this every + # release tag would re-lint. branches: [develop, beta, master] paths: ['**.c', '**.h', '**.cpp', '**.hpp', '.clang-format', '.github/workflows/cpp-linter.yml'] workflow_dispatch: # Manually invoked by user. @@ -27,12 +27,10 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: ./.github/actions/build-compilationdb - with: - version: 0.0.0-test+build # Doesn't matter, just need the compilation database - + # No ESP-IDF and no compile database: with clang-tidy off (below) the linter only + # runs clang-format, which needs neither. Vendored submodules are ignored anyway. - uses: cpp-linter/cpp-linter-action@8120e05c7fbd25fa09fa93424ec49f55f7b304ab # v2.23.1 id: linter env: @@ -40,9 +38,20 @@ jobs: with: style: file version: 21 - ignore: | - .github - include/serialization/_fbs + # clang-tidy is disabled: the native ESP-IDF compile DB is generated by + # xtensa-esp32s3-elf-gcc, whose flags (-mlongcalls, -fstrict-volatile-bitfields, + # ...) LLVM clang-tidy rejects, and standalone headers have no compile command + # so their ESP-IDF includes don't resolve - both surface as spurious + # clang-diagnostic-errors. clang-format still gates style, and CodeQL provides + # deep static analysis. Re-enabling it means restoring the + # build-compilationdb step and `database:` once the DB is clang-toolchain compatible. + tidy-checks: '-*' + # Walk the source tree instead of the PR diff. GitHub returns 406 for very + # large diffs (the Arduino migration PR is ~27k lines), which drops cpp-linter + # into a paginated fallback that crashes on files GitHub omits a patch for. + # Scanning the tree sidesteps the diff-size limit entirely. + files-changed-only: false + ignore: '.github|build|managed_components|components/littlefs|components/flatbuffers|components/serialization/include/serialization/_fbs' # Commit comments (push events) need contents:write, which this job # deliberately does not grant; only post thread comments on PRs, where # pull-requests:write applies. Otherwise the action 403s and fails the diff --git a/.github/workflows/frontend-checks.yml b/.github/workflows/frontend-checks.yml new file mode 100644 index 00000000..46dcb36b --- /dev/null +++ b/.github/workflows/frontend-checks.yml @@ -0,0 +1,40 @@ +# Per-commit captive-portal check: svelte-check, unit tests and a production build. +on: + push: + branches: ['**'] + paths: + - 'frontend/**' + - '.github/actions/build-frontend/**' + - '.github/workflows/frontend-checks.yml' + # A same-repo PR branch is already covered by its push run; pull_request is for forks, whose + # pushes do not run here. Fork runs need a maintainer's approval (repository setting). + pull_request: + paths: + - 'frontend/**' + - '.github/actions/build-frontend/**' + - '.github/workflows/frontend-checks.yml' + workflow_dispatch: + +name: frontend-checks + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + frontend: + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork }} + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + submodules: recursive + sparse-checkout: | + .github + frontend + + - uses: ./.github/actions/build-frontend diff --git a/.github/workflows/get-vars.yml b/.github/workflows/get-vars.yml index 59a1af01..bdac50a7 100644 --- a/.github/workflows/get-vars.yml +++ b/.github/workflows/get-vars.yml @@ -1,12 +1,21 @@ -# This is a bit of a silly workflow, but Github Workflow definitions -# do not let us easily reuse the strategy matrix used to trigger jobs -# per-board. This is a workaround to define everything in one file, and -# use the output in the multiple places we need it. +# This is a bit of a silly workflow, but Github Workflow definitions do not let us easily reuse the strategy matrix used to trigger jobs per-board. +# This is a workaround to define everything in one file, and use the output in the multiple places we need it. # # Source: https://github.com/orgs/community/discussions/26284#discussioncomment-6701976 on: workflow_call: + inputs: + channel: + description: 'Channel to publish onto, overriding the one the ref implies. Empty means derive it.' + required: false + type: string + default: '' + boards: + description: 'Boards to build, newline- or comma-separated. Empty means every boards/*.defaults.' + required: false + type: string + default: '' outputs: version: description: 'The current version of the project, e.g. 1.0.0-rc.1+build.1' @@ -34,36 +43,30 @@ jobs: board-matrix: ${{ steps.get-vars.outputs.board-matrix }} steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 sparse-checkout: | .github .changes + boards - # Pin Node to the range declared in the scripts package (engines.node), - # rather than relying on whatever the runner image happens to ship. - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + # get-vars.py parses tags with the semver package rather than a local regex, so it needs the pinned dependency set. + # gha.py still self-checks the interpreter floor and fails clearly if the image ever ships something older. + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: - node-version-file: .github/scripts/package.json + python-version-file: .github/scripts/.python-version + cache: 'pip' + cache-dependency-path: .github/scripts/requirements.txt - - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 - name: Install pnpm - with: - cache: true - cache_dependency_path: .github/scripts/pnpm-lock.yaml - package_json_file: .github/scripts/package.json - run_install: | - - cwd: .github/scripts - args: [--frozen-lockfile, --strict-peer-dependencies] + - name: Install script dependencies + shell: bash + run: pip install --require-hashes -r .github/scripts/requirements.txt - # Single source of truth for the version bump: release-tool computes the - # next version from .changes/. status mode works on every branch (no - # branch-config dependency) and creates no tag, so dev/CI builds get - # labelled as a pre-release of the upcoming version instead of the last tag. + # Single source of truth for the version bump: release-tool computes the next version from .changes/. status mode works on every branch (no branch-config dependency) and creates no tag, so dev/CI builds get labelled as a pre-release of the upcoming version instead of the last tag. - name: Compute next version id: relmeta - uses: OpenShock/release-tool@ea651e8c10372a10b6f452dcb34c38e60a72dc1f # v0.5.1 + uses: OpenShock/release-tool@ea651e8c10372a10b6f452dcb34c38e60a72dc1f # v0.5.1 with: mode: status @@ -73,4 +76,6 @@ jobs: env: RELEASE_NEXT_VERSION: ${{ steps.relmeta.outputs.next-version }} RELEASE_SKIP: ${{ steps.relmeta.outputs.skip }} - run: node .github/scripts/get-vars.js + CHANNEL_INPUT: ${{ inputs.channel }} + BOARDS_INPUT: ${{ inputs.boards }} + run: python .github/scripts/get-vars.py diff --git a/.github/workflows/host-tests.yml b/.github/workflows/host-tests.yml new file mode 100644 index 00000000..e6f2c23b --- /dev/null +++ b/.github/workflows/host-tests.yml @@ -0,0 +1,96 @@ +# Per-commit firmware check: build and run the host (linux target) Unity suites. +# Cheap next to compiling the firmware, which happens nightly, on release tags and as a PR canary (ci-build.yml). +on: + push: + branches: ['**'] + paths: + - 'components/**' + - 'main/**' + - 'test/**' + - 'partitions/**' + - 'CMakeLists.txt' + - 'sdkconfig.defaults' + - 'dependencies.lock' + - '.gitmodules' + - '.github/actions/setup-esp-idf/**' + - '.github/workflows/host-tests.yml' + # A same-repo PR branch is already covered by its push run; pull_request is for forks, whose + # pushes do not run here. Fork runs need a maintainer's approval (repository setting). + pull_request: + paths: + - 'components/**' + - 'main/**' + - 'test/**' + - 'partitions/**' + - 'CMakeLists.txt' + - 'sdkconfig.defaults' + - 'dependencies.lock' + - '.gitmodules' + - '.github/actions/setup-esp-idf/**' + - '.github/workflows/host-tests.yml' + workflow_dispatch: + +name: host-tests + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + host-tests: + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork }} + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + # The config suite includes the flatbuffers runtime, which lives in a submodule. + # Fetch only that one; the others (schemas, frontend) are not needed here. + - name: Fetch flatbuffers submodule + run: git submodule update --init --depth 1 components/flatbuffers/flatbuffers + + # The linux target links libbsd. ESP-IDF generates the CMock mocks the suites use + # (tools/mocks/*) with Ruby; ubuntu-latest ships it, listed so that stays true. + - name: Install host build dependencies + run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libbsd-dev ruby + + # Restore-only: ci-build's single nominated writer owns the cache entry. + - uses: ./.github/actions/setup-esp-idf + with: + save-cache: 'false' + + # Each components//host_test is its own linux-target ESP-IDF project. They + # share test/host_support (stand-ins for Logging.h, openshock_board.h, ...) and + # use ESP-IDF's tools/mocks and linux ports; the config suite also reads the + # firmware's partitions/ota_4mb.csv. The ELF's exit code is the Unity failure count. + - name: Build and run host test suites + shell: bash + run: | + set -euo pipefail + # The IDF environment puts each toolchain's inner /bin on PATH, which holds + # unprefixed cross binutils (as, ld, ...). The linux target builds with the host gcc, + # which looks up `as` on PATH and would get esp32ulp-elf's or riscv32's instead + # ("as: unrecognized option '--64'"). The firmware only calls the prefixed tools. + PATH="$(printf '%s' "$PATH" | tr ':' '\n' | grep -vE -- '-elf/[^/]*-elf/bin/?$' | paste -sd: -)" + export PATH + echo "as: $(command -v as)" + failed=() + for dir in components/*/host_test; do + name="$(basename "$(dirname "$dir")")" + echo "::group::$name" + if idf.py -C "$dir" --preview set-target linux build \ + && "$dir"/build/*_host_test.elf; then + echo "::endgroup::" + else + echo "::endgroup::" + echo "::error::$name host tests failed" + failed+=("$name") + fi + done + if [ "${#failed[@]}" -gt 0 ]; then + echo "Failed: ${failed[*]}" + exit 1 + fi diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml new file mode 100644 index 00000000..3484c933 --- /dev/null +++ b/.github/workflows/nightly.yml @@ -0,0 +1,42 @@ +# Starts the nightly firmware build on each long-lived branch. +# A schedule only ever runs on the default branch, and a branch's status badge only reflects runs +# on that branch, so this dispatches ci-build once per branch instead of building here. ci-build's +# gate skips a branch whose firmware sources have not changed since its last build, and decides +# what each nightly publishes (develop to prod; beta and master build only). +on: + schedule: + - cron: '0 3 * * *' + workflow_dispatch: + +name: nightly + +permissions: + contents: read + +jobs: + dispatch: + runs-on: ubuntu-slim + timeout-minutes: 5 + permissions: + # Dispatching a workflow is the one event GITHUB_TOKEN may trigger. + actions: write + strategy: + fail-fast: false + matrix: + branch: [develop, beta, master] + steps: + # The branch's own ci-build.yml runs, so it must already define the nightly input. + - name: Dispatch ci-build on ${{ matrix.branch }} + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + BRANCH: ${{ matrix.branch }} + with: + script: | + await github.rest.actions.createWorkflowDispatch({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id: 'ci-build.yml', + ref: process.env.BRANCH, + inputs: { nightly: 'true' }, + }); + core.info(`Dispatched the ${process.env.BRANCH} nightly.`); diff --git a/.github/workflows/pinned-cert-audit.yml b/.github/workflows/pinned-cert-audit.yml new file mode 100644 index 00000000..dc5798b5 --- /dev/null +++ b/.github/workflows/pinned-cert-audit.yml @@ -0,0 +1,81 @@ +name: pinned-cert-audit + +# Audits certificates/pinned_certs/*.pem against reality (certificates/cert_bundle.py audit): +# - probes api.openshock.app / api.openshock.dev and retires any pinned root no live +# endpoint chains through anymore (opens a PR that deletes it + regenerates cacert-merged.pem); +# - fails the job (→ notifies) when a still-needed pinned root is expired or nearing expiry. +on: + schedule: + - cron: '17 6 * * 1' # Weekly, Monday 06:17 UTC + workflow_dispatch: # Manually invoked. + push: + branches: [develop, beta, master] + paths: ['certificates/pinned_certs/**', 'certificates/cert_bundle.py', '.github/workflows/pinned-cert-audit.yml'] + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +permissions: + contents: write # push the retire-pin branch + pull-requests: write # open the retire-pin PR + +jobs: + audit: + name: Audit pinned certs + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Install cryptography + # Same pin as requirements.txt: this job holds write permissions, so don't pull whatever is latest + run: python3 -m pip install --user cryptography==50.0.2 + + # Probes the live endpoints + checks expiry. Never fails the step itself (so the + # follow-up steps always see its outputs); the expiry alert is raised at the end. + - name: Audit + id: audit + continue-on-error: true + working-directory: certificates + run: python3 cert_bundle.py audit + + # A pinned root that no monitored endpoint chains through anymore: delete it, + # regenerate cacert-merged.pem, and open a PR. Runs only when the audit positively found + # removable pins (empty when any probe failed — fail-safe). + - name: Open PR to retire unneeded pinned root(s) + if: steps.audit.outputs.removable != '' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + branch="chore/retire-pinned-certs-${{ github.run_id }}" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git checkout -b "$branch" + for f in ${{ steps.audit.outputs.removable }}; do + echo "Retiring certificates/$f" + git rm "certificates/$f" + done + # Regenerate the base + packed bundle (verified curl base + remaining pinned certs). + ( cd certificates && python3 cert_bundle.py generate ) + git add certificates/cacert-curl.pem certificates/cacert-merged.pem + git commit -m "chore(certs): retire pinned root(s) no longer needed by live endpoints" + git push origin "$branch" + gh pr create \ + --base "${{ github.ref_name }}" \ + --head "$branch" \ + --title "chore(certs): retire unneeded pinned root(s)" \ + --body "Automated by \`pinned-cert-audit\`: \`cert_bundle.py audit\` found pinned root(s) that no monitored endpoint (api.openshock.app / api.openshock.dev) chains through anymore. Deleted them from \`certificates/pinned_certs/\` and regenerated \`certificates/cacert-merged.pem\`. + + Merge only after confirming the endpoints still verify against the base trust store (i.e. the server chain was intentionally trimmed)." + + # Fail last so the retire-PR step above still runs. A still-needed pinned root that + # is expired / nearing expiry means we must rotate it. + - name: Fail on expiry alert + if: steps.audit.outputs.alert == 'true' + run: | + echo "::error::A still-needed pinned certificate is expired or nearing expiry — rotate it in certificates/pinned_certs/ and regenerate cacert-merged.pem." + exit 1 diff --git a/.github/workflows/pr-check-comment.yml b/.github/workflows/pr-check-comment.yml index 5abcbda8..81ebd9f2 100644 --- a/.github/workflows/pr-check-comment.yml +++ b/.github/workflows/pr-check-comment.yml @@ -14,4 +14,4 @@ jobs: if: github.event.workflow_run.event == 'pull_request' steps: - name: Check PR comment - uses: OpenShock/actions/pr-check-comment@21ca2d511bca92cf24fb502fa302f82700822584 # v1.0.0 + uses: OpenShock/actions/pr-check-comment@21ca2d511bca92cf24fb502fa302f82700822584 # v1.1.1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c12427ee..fc8f805d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,13 +27,13 @@ jobs: # triggers ci-build; the default GITHUB_TOKEN does not trigger downstream # workflows. Using an App (instead of a personal PAT) keeps the token # short-lived, least-privilege, and owned by the org rather than a person. - - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 id: app-token with: app-id: ${{ secrets.RELEASE_APP_ID }} private-key: ${{ secrets.RELEASE_APP_KEY }} - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 token: ${{ steps.app-token.outputs.token }} @@ -51,7 +51,7 @@ jobs: - name: Run release tool id: meta - uses: OpenShock/release-tool@ea651e8c10372a10b6f452dcb34c38e60a72dc1f # v0.5.1 + uses: OpenShock/release-tool@ea651e8c10372a10b6f452dcb34c38e60a72dc1f # v0.5.1 with: mode: release output: release.json @@ -69,7 +69,7 @@ jobs: exit 1 - name: Push commit and tag - uses: OpenShock/actions/publish-tag@21ca2d511bca92cf24fb502fa302f82700822584 # v1.1.1 + uses: OpenShock/actions/publish-tag@21ca2d511bca92cf24fb502fa302f82700822584 # v1.1.1 with: tag: ${{ steps.meta.outputs.tag }} diff --git a/.github/workflows/script-tests.yml b/.github/workflows/script-tests.yml new file mode 100644 index 00000000..7e287d24 --- /dev/null +++ b/.github/workflows/script-tests.yml @@ -0,0 +1,50 @@ +# Unit tests for the CI/release scripts (.github/scripts) and the build helpers in scripts/. +on: + push: + branches: ['**'] + paths: + - '.github/scripts/**' + - 'scripts/**' + - 'partitions/**' + - 'boards/**' + - 'sdkconfig.defaults' + - '.github/workflows/script-tests.yml' + # Same-repo PR branches are covered by their push run; pull_request is for forks. + pull_request: + paths: + - '.github/scripts/**' + - 'scripts/**' + - 'partitions/**' + - 'boards/**' + - 'sdkconfig.defaults' + - '.github/workflows/script-tests.yml' + workflow_dispatch: + +name: script-tests + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + pytest: + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork }} + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version-file: .github/scripts/.python-version + cache: 'pip' + cache-dependency-path: .github/scripts/requirements-dev.txt + + - name: Install test dependencies + run: pip install --require-hashes -r .github/scripts/requirements-dev.txt + + - name: Run tests + run: python -m pytest .github/scripts/tests diff --git a/.gitignore b/.gitignore index f4b99765..cc7835c4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,26 +1,28 @@ -# PlatformIO build -/.pio/ -*.bin - -# data/www is autogenerated by the frontend build (scripts/build_frontend.py) -/data/www/ -!/data/**/*.bin - # ESP-IDF build /build/ /managed_components/ -/staticfs/www/ +*.bin + +# sdkconfig and sdkconfig.old are generated ("DO NOT EDIT") from +# sdkconfig.defaults[.]. Track only the editable defaults. +/sdkconfig +/sdkconfig.* +!/sdkconfig.defaults +!/sdkconfig.defaults.* + +# Staging directory of scripts/gen_staticfs.py when run from the repository root (the CI +# build-staticfs action); CMake builds stage under build//staticfs instead. +/staticfs/ + +# PlatformIO build +/.pio/ # Python __pycache__/ *.py[cod] # Editors & IDEs -.vscode/.browse.c_cpp.db* -.vscode/c_cpp_properties.json -.vscode/extensions.json -.vscode/launch.json -.vscode/ipch/ +.vscode/ .vs/ *.sln /packages/ diff --git a/.gitmodules b/.gitmodules index 1811e74e..153edf04 100644 --- a/.gitmodules +++ b/.gitmodules @@ -5,3 +5,6 @@ [submodule "frontend/packages/svelte-core"] path = frontend/packages/svelte-core url = git@github.com:OpenShock/svelte-core.git +[submodule "components/flatbuffers/flatbuffers"] + path = components/flatbuffers/flatbuffers + url = https://github.com/google/flatbuffers.git diff --git a/.mcp.json b/.mcp.json deleted file mode 100644 index 209568c4..00000000 --- a/.mcp.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "mcpServers": { - "github": { - "type": "http", - "url": "https://api.githubcopilot.com/mcp/" - } - } -} diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 91960c15..00000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,102 +0,0 @@ -{ - "C_Cpp.errorSquiggles": "disabled", - "files.associations": { - "array": "cpp", - "atomic": "cpp", - "*.tcc": "cpp", - "bitset": "cpp", - "cctype": "cpp", - "clocale": "cpp", - "cmath": "cpp", - "cstdarg": "cpp", - "cstddef": "cpp", - "cstdint": "cpp", - "cstdio": "cpp", - "cstdlib": "cpp", - "cstring": "cpp", - "ctime": "cpp", - "cwchar": "cpp", - "cwctype": "cpp", - "deque": "cpp", - "unordered_map": "cpp", - "unordered_set": "cpp", - "vector": "cpp", - "exception": "cpp", - "algorithm": "cpp", - "functional": "cpp", - "iterator": "cpp", - "map": "cpp", - "memory": "cpp", - "memory_resource": "cpp", - "numeric": "cpp", - "optional": "cpp", - "random": "cpp", - "string": "cpp", - "string_view": "cpp", - "system_error": "cpp", - "tuple": "cpp", - "type_traits": "cpp", - "utility": "cpp", - "fstream": "cpp", - "initializer_list": "cpp", - "iomanip": "cpp", - "iosfwd": "cpp", - "istream": "cpp", - "limits": "cpp", - "new": "cpp", - "ostream": "cpp", - "sstream": "cpp", - "stdexcept": "cpp", - "streambuf": "cpp", - "cinttypes": "cpp", - "typeinfo": "cpp", - "networks.c": "cpp", - "regex": "cpp", - "iostream": "cpp", - "chrono": "cpp", - "codecvt": "cpp", - "condition_variable": "cpp", - "list": "cpp", - "ratio": "cpp", - "set": "cpp", - "thread": "cpp" - }, - "[cpp]": { - "editor.defaultFormatter": "xaver.clang-format", - "editor.formatOnSave": true - }, - "[html]": { - "editor.defaultFormatter": "esbenp.prettier-vscode", - "editor.formatOnSave": true - }, - "[css]": { - "editor.defaultFormatter": "esbenp.prettier-vscode", - "editor.formatOnSave": true - }, - "[javascript]": { - "editor.defaultFormatter": "esbenp.prettier-vscode", - "editor.formatOnSave": true - }, - "[typescript]": { - "editor.defaultFormatter": "esbenp.prettier-vscode", - "editor.formatOnSave": true - }, - "[svelte]": { - "editor.defaultFormatter": "svelte.svelte-vscode", - "editor.formatOnSave": true - }, - "[json]": { - "editor.defaultFormatter": "esbenp.prettier-vscode", - "editor.formatOnSave": true - }, - "[python]": { - "editor.defaultFormatter": "ms-python.black-formatter", - "editor.formatOnSave": true - }, - "[markdown]": { - "editor.defaultFormatter": "esbenp.prettier-vscode", - "editor.formatOnSave": true - }, - "python.analysis.typeCheckingMode": "basic", - "black-formatter.args": ["--line-length", "120", "--skip-string-normalization"] -} diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 00000000..fadaab36 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,136 @@ +cmake_minimum_required(VERSION 3.16.0) + +# --- OpenShock generated build headers --------------------------------------- +# Two headers are generated into ${CMAKE_BINARY_DIR}/config, the directory ESP-IDF +# already writes sdkconfig.h into and puts on the global include path: +# +# openshock_board.h board/chip identity, build mode, log level, GPIO assignments +# openshock_version.h firmware version and git commit +# +# Neither is force-included. openshock_board.h is included by OpenShock.h, so only +# OpenShock's own components see it; openshock_version.h is included by the few +# translation units that actually report a version. +# +# They used to be one header, force-included into every translation unit in the project +# through idf_build_set_property(COMPILE_OPTIONS "-include..."). Because it carried the +# git commit and a version string ending in +, every commit changed the +# preprocessed text of every source file in the build - all of ESP-IDF included - and +# ccache could never reuse a single object between CI runs. +# Cached so an automatic reconfigure (e.g. from a plain `ninja` run without the env var) keeps generating the +# headers for the same board instead of silently dropping them. +if(NOT DEFINED OPENSHOCK_BOARD AND DEFINED ENV{OPENSHOCK_BOARD}) + set(OPENSHOCK_BOARD $ENV{OPENSHOCK_BOARD} CACHE STRING "OpenShock board (boards/.defaults)") +endif() + +# idf.py passes the interpreter of the IDF Python environment as PYTHON; fall back to the one on PATH. +if(DEFINED PYTHON) + set(_os_python ${PYTHON}) +else() + set(_os_python python) +endif() + +if(DEFINED OPENSHOCK_BOARD) + # Map the IDF target to an OpenShock chip label for OPENSHOCK_FW_CHIP. + if(DEFINED IDF_TARGET) + set(_os_target ${IDF_TARGET}) + elseif(DEFINED ENV{IDF_TARGET}) + set(_os_target $ENV{IDF_TARGET}) + else() + set(_os_target "esp32") + endif() + if(_os_target STREQUAL "esp32s3") + set(_os_chip "ESP32-S3") + elseif(_os_target STREQUAL "esp32s2") + set(_os_chip "ESP32-S2") + elseif(_os_target STREQUAL "esp32c3") + set(_os_chip "ESP32-C3") + else() + set(_os_chip "ESP32") + endif() + + set(OPENSHOCK_CONFIG_DIR ${CMAKE_BINARY_DIR}/config) + set(OPENSHOCK_VERSION_HEADER ${OPENSHOCK_CONFIG_DIR}/openshock_version.h) + set(_os_gen_command + ${_os_python} ${CMAKE_CURRENT_LIST_DIR}/scripts/gen_env_header.py + --out-dir ${OPENSHOCK_CONFIG_DIR} + --board ${OPENSHOCK_BOARD} + --chip ${_os_chip} + --fragment ${CMAKE_CURRENT_LIST_DIR}/boards/${OPENSHOCK_BOARD}.defaults) + + execute_process(COMMAND ${_os_gen_command} --emit both RESULT_VARIABLE _os_env_rc) + if(NOT _os_env_rc EQUAL 0) + message(FATAL_ERROR "gen_env_header.py failed (rc=${_os_env_rc})") + endif() + + # openshock_board.h (GPIO pins etc.) is generated at configure time from the board fragment, so editing the + # fragment must trigger a reconfigure. + set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS ${CMAKE_CURRENT_LIST_DIR}/boards/${OPENSHOCK_BOARD}.defaults) + set(OPENSHOCK_NATIVE_ENV TRUE) +endif() + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) + +project(firmware) + +if(OPENSHOCK_NATIVE_ENV) + # The version and commit come from git and the environment, which CMake has no + # dependency on, so refresh the version header on every build rather than only on a + # reconfigure. Generating it at configure time alone left an incremental build + # reporting whatever version the build directory was first configured with. + # + # The generator writes only when the contents differ, so an unchanged version leaves + # the file - and its mtime - alone, and nothing downstream is rebuilt. + add_custom_target(openshock_version_header ALL + BYPRODUCTS ${OPENSHOCK_VERSION_HEADER} + COMMAND ${_os_gen_command} --emit version + COMMENT "Refreshing openshock_version.h" + VERBATIM) + + # Order the refresh ahead of the components that include the header. Without this the + # ALL target is unordered against them and a build could compile the previous version. + foreach(_os_consumer common) # Version.cpp is the only translation unit including it + idf_component_get_property(_os_lib ${_os_consumer} COMPONENT_LIB) + if(_os_lib) + add_dependencies(${_os_lib} openshock_version_header) + endif() + endforeach() +endif() + +# --- Static filesystem image (captive-portal web assets) --------------------- +# Build the SvelteKit frontend, gzip it, and pack it into a littlefs image whose +# geometry matches the runtime mount in components/fs/src/LfsPartition.cpp (4 KiB +# blocks, 128-byte read/prog). `idf.py flash` writes it to the `static0` +# partition. Replaces PlatformIO's build_frontend.py post-action + mklittlefs. +# +# The partition only exists in the OpenShock custom partition CSV; if the active +# partition table has no `static0` (e.g. a bare default-table build), skip the +# image instead of failing the whole build. +partition_table_get_partition_info(_staticfs_offset "--partition-name static0" "offset") +partition_table_get_partition_info(_staticfs_size "--partition-name static0" "size") + +if(_staticfs_offset STREQUAL "" OR _staticfs_size STREQUAL "") + message(WARNING "No `static0` partition in the active partition table — " + "skipping static filesystem image generation.") +else() + set(_staticfs_image ${CMAKE_BINARY_DIR}/staticfs.bin) + + # ALL target so a normal `idf.py build` regenerates the image. The generator only + # runs pnpm when frontend/build is older than the frontend sources (never in CI, + # where the frontend is downloaded as an artifact), so this stays cheap on + # incremental builds. Staging lives in the build directory so concurrent board + # builds don't share (and wipe) one directory. + add_custom_target(staticfs ALL + BYPRODUCTS ${_staticfs_image} + COMMAND ${_os_python} ${CMAKE_CURRENT_LIST_DIR}/scripts/gen_staticfs.py + --frontend-dir ${CMAKE_CURRENT_LIST_DIR}/frontend + --staging-dir ${CMAKE_BINARY_DIR}/staticfs + --output ${_staticfs_image} + --partition-size ${_staticfs_size} + --block-size 4096 --read-size 128 --prog-size 128 + COMMENT "Building static filesystem image (staticfs.bin) for static0" + VERBATIM USES_TERMINAL) + + # Register the image with the flasher so `idf.py flash` writes it to the + # static0 offset alongside the app and partition table. + esptool_py_flash_target_image(flash "static0" "${_staticfs_offset}" "${_staticfs_image}") +endif() diff --git a/FLATBUFFERS_CHANGES.md b/FLATBUFFERS_CHANGES.md deleted file mode 100644 index d7bf3ce0..00000000 --- a/FLATBUFFERS_CHANGES.md +++ /dev/null @@ -1,32 +0,0 @@ -# FlatBuffers Schema Changes - -**Repository:** https://github.com/OpenShock/flatbuffers-schemas -**Branch:** `local-comms-revamp` - -These schema changes have been applied to the submodule and are tracked by this branch. They are breaking changes that accompany the WS-to-REST migration. - -## Applied Changes - -### `HubToLocalMessage.fbs` -- Removed all command result types (`AccountLinkCommandResult`, `SetRfTxPinCommandResult`, `SetEstopPinCommandResult`, `SetEstopEnabledCommandResult`, `SetGPIOResultCode`, `AccountLinkResultCode`) -- Removed all WS command types from `HubToLocalMessagePayload` union that were migrated to REST -- Added `AccountLinkStatusEvent` — broadcast when auth token is validated on gateway connect -- Added `has_standardized_pins` field to `ReadyMessage` - -### `LocalToHubMessage.fbs` -- Removed all command types except `Common_ShockerCommandList` (WiFi, OTA, Account, GPIO commands moved to REST) -- `ShockerCommandList` moved to `Common` namespace, shared between gateway and local - -### `HubConfig.fbs` -- Added `MacAddress` struct (6-byte fixed-size array) -- Added `auth_mode` (WifiAuthMode enum) and `bssid` (MacAddress) fields to `WiFiCredentials` for security pinning - -### `Common/ShockerCommand.fbs` (new) -- Extracted `ShockerCommand` and `ShockerCommandList` tables into shared Common namespace - -### `GatewayToHubMessage.fbs` -- Updated `ShockerCommandList` reference to `Common_ShockerCommandList` - -## Pending -- Push `local-comms-revamp` branch to schemas repo and create PR -- After merge, update submodule pointer in firmware repo diff --git a/boards/DFRobot-Firebeetle2-ESP32E.defaults b/boards/DFRobot-Firebeetle2-ESP32E.defaults new file mode 100644 index 00000000..859fe902 --- /dev/null +++ b/boards/DFRobot-Firebeetle2-ESP32E.defaults @@ -0,0 +1,16 @@ +# DFRobot-Firebeetle2-ESP32E — auto-ported from platformio.ini. Board-specific deltas only; +# shared config lives in sdkconfig.defaults. + +CONFIG_IDF_TARGET="esp32" +CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y + +# OpenShock uses a single 4 MB OTA partition layout (fits every board's flash). +CONFIG_PARTITION_TABLE_CUSTOM=y +CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions/ota_4mb.csv" + +# Hardware pins. Not Kconfig: bare OPENSHOCK_* lines, read by scripts/gen_env_header.py +# into openshock_board.h and stripped from the sdkconfig fragment by scripts/build.py, +# so boards sharing a chip and flash size compile identical ESP-IDF objects. +OPENSHOCK_RF_TX_GPIO=13 +OPENSHOCK_LED_WS2812B=5 +OPENSHOCK_LED_GPIO=2 diff --git a/boards/NodeMCU-32S.defaults b/boards/NodeMCU-32S.defaults new file mode 100644 index 00000000..81336d8a --- /dev/null +++ b/boards/NodeMCU-32S.defaults @@ -0,0 +1,14 @@ +# NodeMCU-32S — auto-ported from platformio.ini. Board-specific deltas only; +# shared config lives in sdkconfig.defaults. + +CONFIG_IDF_TARGET="esp32" +CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y + +# OpenShock uses a single 4 MB OTA partition layout (fits every board's flash). +CONFIG_PARTITION_TABLE_CUSTOM=y +CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions/ota_4mb.csv" + +# Hardware pins. Not Kconfig: bare OPENSHOCK_* lines, read by scripts/gen_env_header.py +# into openshock_board.h and stripped from the sdkconfig fragment by scripts/build.py, +# so boards sharing a chip and flash size compile identical ESP-IDF objects. +OPENSHOCK_LED_GPIO=2 diff --git a/boards/OpenShock-Core-V1.defaults b/boards/OpenShock-Core-V1.defaults new file mode 100644 index 00000000..b18f26f0 --- /dev/null +++ b/boards/OpenShock-Core-V1.defaults @@ -0,0 +1,17 @@ +# OpenShock-Core-V1 — auto-ported from platformio.ini. Board-specific deltas only; +# shared config lives in sdkconfig.defaults. + +CONFIG_IDF_TARGET="esp32s3" +CONFIG_ESPTOOLPY_FLASHSIZE_8MB=y + +# OpenShock uses a single 4 MB OTA partition layout (fits every board's flash). +CONFIG_PARTITION_TABLE_CUSTOM=y +CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions/ota_4mb.csv" + +# Hardware pins. Not Kconfig: bare OPENSHOCK_* lines, read by scripts/gen_env_header.py +# into openshock_board.h and stripped from the sdkconfig fragment by scripts/build.py, +# so boards sharing a chip and flash size compile identical ESP-IDF objects. +OPENSHOCK_LED_WS2812B=48 +OPENSHOCK_LED_GPIO=35 +OPENSHOCK_RF_TX_GPIO=15 +OPENSHOCK_ESTOP_PIN=13 diff --git a/boards/OpenShock-Core-V2.defaults b/boards/OpenShock-Core-V2.defaults new file mode 100644 index 00000000..6f9885dd --- /dev/null +++ b/boards/OpenShock-Core-V2.defaults @@ -0,0 +1,22 @@ +# OpenShock Core V2 — ESP32-S3, 8 MB flash, no PSRAM. +# Loaded on top of the shared sdkconfig.defaults; only board-specific deltas here. + +CONFIG_IDF_TARGET="esp32s3" +CONFIG_ESPTOOLPY_FLASHSIZE_8MB=y + +# OpenShock custom OTA partition layout. There is no dedicated 8 MB table yet, so +# reuse the 4 MB OTA layout — it fits within 8 MB (upper flash currently unused). +CONFIG_PARTITION_TABLE_CUSTOM=y +CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions/ota_4mb.csv" + +# Native USB only (no USB-UART bridge): console on the built-in USB-Serial-JTAG, so the +# serial console reads commands from the port the host actually sees. +CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y + +# Hardware pins. Not Kconfig: bare OPENSHOCK_* lines, read by scripts/gen_env_header.py +# into openshock_board.h and stripped from the sdkconfig fragment by scripts/build.py, +# so boards sharing a chip and flash size compile identical ESP-IDF objects. +OPENSHOCK_LED_WS2812B=14 +OPENSHOCK_LED_GPIO=13 +OPENSHOCK_RF_TX_GPIO=1 +OPENSHOCK_ESTOP_PIN=38 diff --git a/boards/Pishock-2023.defaults b/boards/Pishock-2023.defaults new file mode 100644 index 00000000..e4bb9dd4 --- /dev/null +++ b/boards/Pishock-2023.defaults @@ -0,0 +1,15 @@ +# Pishock-2023 — auto-ported from platformio.ini. Board-specific deltas only; +# shared config lives in sdkconfig.defaults. + +CONFIG_IDF_TARGET="esp32" +CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y + +# OpenShock uses a single 4 MB OTA partition layout (fits every board's flash). +CONFIG_PARTITION_TABLE_CUSTOM=y +CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions/ota_4mb.csv" + +# Hardware pins. Not Kconfig: bare OPENSHOCK_* lines, read by scripts/gen_env_header.py +# into openshock_board.h and stripped from the sdkconfig fragment by scripts/build.py, +# so boards sharing a chip and flash size compile identical ESP-IDF objects. +OPENSHOCK_LED_GPIO=2 +OPENSHOCK_RF_TX_GPIO=12 diff --git a/boards/Pishock-Lite-2021.defaults b/boards/Pishock-Lite-2021.defaults new file mode 100644 index 00000000..6b6742e9 --- /dev/null +++ b/boards/Pishock-Lite-2021.defaults @@ -0,0 +1,15 @@ +# Pishock-Lite-2021 — auto-ported from platformio.ini. Board-specific deltas only; +# shared config lives in sdkconfig.defaults. + +CONFIG_IDF_TARGET="esp32" +CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y + +# OpenShock uses a single 4 MB OTA partition layout (fits every board's flash). +CONFIG_PARTITION_TABLE_CUSTOM=y +CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions/ota_4mb.csv" + +# Hardware pins. Not Kconfig: bare OPENSHOCK_* lines, read by scripts/gen_env_header.py +# into openshock_board.h and stripped from the sdkconfig fragment by scripts/build.py, +# so boards sharing a chip and flash size compile identical ESP-IDF objects. +OPENSHOCK_LED_GPIO=2 +OPENSHOCK_RF_TX_GPIO=15 diff --git a/boards/Seeed-Xiao-ESP32C3.defaults b/boards/Seeed-Xiao-ESP32C3.defaults new file mode 100644 index 00000000..bf06a3bd --- /dev/null +++ b/boards/Seeed-Xiao-ESP32C3.defaults @@ -0,0 +1,13 @@ +# Seeed-Xiao-ESP32C3 — auto-ported from platformio.ini. Board-specific deltas only; +# shared config lives in sdkconfig.defaults. + +CONFIG_IDF_TARGET="esp32c3" +CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y + +# OpenShock uses a single 4 MB OTA partition layout (fits every board's flash). +CONFIG_PARTITION_TABLE_CUSTOM=y +CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions/ota_4mb.csv" + +# Native USB only (no USB-UART bridge): console on the built-in USB-Serial-JTAG, so the +# serial console reads commands from the port the host actually sees. +CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y diff --git a/boards/Seeed-Xiao-ESP32S3.defaults b/boards/Seeed-Xiao-ESP32S3.defaults new file mode 100644 index 00000000..b21cb171 --- /dev/null +++ b/boards/Seeed-Xiao-ESP32S3.defaults @@ -0,0 +1,18 @@ +# Seeed-Xiao-ESP32S3 — auto-ported from platformio.ini. Board-specific deltas only; +# shared config lives in sdkconfig.defaults. + +CONFIG_IDF_TARGET="esp32s3" +CONFIG_ESPTOOLPY_FLASHSIZE_8MB=y + +# OpenShock uses a single 4 MB OTA partition layout (fits every board's flash). +CONFIG_PARTITION_TABLE_CUSTOM=y +CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions/ota_4mb.csv" + +# Native USB only (no USB-UART bridge): console on the built-in USB-Serial-JTAG, so the +# serial console reads commands from the port the host actually sees. +CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y + +# Hardware pins. Not Kconfig: bare OPENSHOCK_* lines, read by scripts/gen_env_header.py +# into openshock_board.h and stripped from the sdkconfig fragment by scripts/build.py, +# so boards sharing a chip and flash size compile identical ESP-IDF objects. +OPENSHOCK_LED_GPIO=21 diff --git a/boards/Waveshare_esp32_s3_zero.defaults b/boards/Waveshare_esp32_s3_zero.defaults new file mode 100644 index 00000000..e6ea98d4 --- /dev/null +++ b/boards/Waveshare_esp32_s3_zero.defaults @@ -0,0 +1,20 @@ +# Waveshare_esp32_s3_zero — auto-ported from platformio.ini. Board-specific deltas only; +# shared config lives in sdkconfig.defaults. + +CONFIG_IDF_TARGET="esp32s3" +CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y + +# OpenShock uses a single 4 MB OTA partition layout (fits every board's flash). +CONFIG_PARTITION_TABLE_CUSTOM=y +CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions/ota_4mb.csv" + +# Native USB only (no USB-UART bridge): console on the built-in USB-Serial-JTAG, so the +# serial console reads commands from the port the host actually sees. +CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y + +# Hardware pins. Not Kconfig: bare OPENSHOCK_* lines, read by scripts/gen_env_header.py +# into openshock_board.h and stripped from the sdkconfig fragment by scripts/build.py, +# so boards sharing a chip and flash size compile identical ESP-IDF objects. +OPENSHOCK_RF_TX_GPIO=1 +OPENSHOCK_LED_WS2812B=21 +OPENSHOCK_LED_SWAP_RG_CHANNELS=y diff --git a/boards/Wemos-D1-Mini-ESP32.defaults b/boards/Wemos-D1-Mini-ESP32.defaults new file mode 100644 index 00000000..1eb71168 --- /dev/null +++ b/boards/Wemos-D1-Mini-ESP32.defaults @@ -0,0 +1,15 @@ +# Wemos-D1-Mini-ESP32 — auto-ported from platformio.ini. Board-specific deltas only; +# shared config lives in sdkconfig.defaults. + +CONFIG_IDF_TARGET="esp32" +CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y + +# OpenShock uses a single 4 MB OTA partition layout (fits every board's flash). +CONFIG_PARTITION_TABLE_CUSTOM=y +CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions/ota_4mb.csv" + +# Hardware pins. Not Kconfig: bare OPENSHOCK_* lines, read by scripts/gen_env_header.py +# into openshock_board.h and stripped from the sdkconfig fragment by scripts/build.py, +# so boards sharing a chip and flash size compile identical ESP-IDF objects. +OPENSHOCK_LED_GPIO=2 +OPENSHOCK_RF_TX_GPIO=15 diff --git a/boards/Wemos-D1-Mini-ESP32.json b/boards/Wemos-D1-Mini-ESP32.json deleted file mode 100644 index 66cbfbf3..00000000 --- a/boards/Wemos-D1-Mini-ESP32.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "build": { - "arduino": { - "ldscript": "esp32_out.ld" - }, - "core": "esp32", - "extra_flags": "-DARDUINO_D1_MINI32", - "f_cpu": "240000000L", - "f_flash": "40000000L", - "flash_mode": "dio", - "mcu": "esp32", - "variant": "d1_mini32" - }, - "connectivity": [ - "wifi", - "bluetooth", - "ethernet", - "can" - ], - "debug": { - "openocd_board": "esp-wroom-32.cfg" - }, - "frameworks": [ - "arduino", - "espidf" - ], - "name": "WEMOS D1 MINI ESP32", - "upload": { - "flash_size": "4MB", - "maximum_ram_size": 327680, - "maximum_size": 4194304, - "require_upload_port": true, - "speed": 460800 - }, - "url": "https://www.wemos.cc", - "vendor": "WEMOS" -} \ No newline at end of file diff --git a/boards/Wemos-Lolin-S2-Mini.defaults b/boards/Wemos-Lolin-S2-Mini.defaults new file mode 100644 index 00000000..9a2cdb22 --- /dev/null +++ b/boards/Wemos-Lolin-S2-Mini.defaults @@ -0,0 +1,18 @@ +# Wemos-Lolin-S2-Mini — auto-ported from platformio.ini. Board-specific deltas only; +# shared config lives in sdkconfig.defaults. + +CONFIG_IDF_TARGET="esp32s2" +CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y + +# OpenShock uses a single 4 MB OTA partition layout (fits every board's flash). +CONFIG_PARTITION_TABLE_CUSTOM=y +CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions/ota_4mb.csv" + +# Native USB only (no USB-UART bridge, and the S2 has no USB-Serial-JTAG): console on the +# ROM USB-OTG CDC, so the serial console reads commands from the port the host sees. +CONFIG_ESP_CONSOLE_USB_CDC=y + +# Hardware pins. Not Kconfig: bare OPENSHOCK_* lines, read by scripts/gen_env_header.py +# into openshock_board.h and stripped from the sdkconfig fragment by scripts/build.py, +# so boards sharing a chip and flash size compile identical ESP-IDF objects. +OPENSHOCK_LED_GPIO=15 diff --git a/boards/Wemos-Lolin-S2-Mini.json b/boards/Wemos-Lolin-S2-Mini.json deleted file mode 100644 index 11cd46f4..00000000 --- a/boards/Wemos-Lolin-S2-Mini.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "build": { - "arduino": { - "ldscript": "esp32s2_out.ld" - }, - "core": "esp32", - "extra_flags": [ - "-DARDUINO_LOLIN_S2_MINI", - "-DBOARD_HAS_PSRAM", - "-DARDUINO_USB_CDC_ON_BOOT=1" - ], - "f_cpu": "240000000L", - "f_flash": "80000000L", - "flash_mode": "dio", - "hwids": [ - [ - "0X303A", - "0x80C2" - ] - ], - "mcu": "esp32s2", - "variant": "lolin_s2_mini" - }, - "connectivity": [ - "wifi" - ], - "debug": { - "openocd_target": "esp32s2.cfg" - }, - "frameworks": [ - "arduino", - "espidf" - ], - "name": "WEMOS LOLIN S2 Mini", - "upload": { - "flash_size": "4MB", - "maximum_ram_size": 327680, - "maximum_size": 4194304, - "use_1200bps_touch": true, - "wait_for_upload_port": true, - "require_upload_port": true, - "speed": 921600 - }, - "url": "https://www.wemos.cc/en/latest/s2/s2_mini.html", - "vendor": "WEMOS" -} \ No newline at end of file diff --git a/boards/Wemos-Lolin-S3-Mini.defaults b/boards/Wemos-Lolin-S3-Mini.defaults new file mode 100644 index 00000000..7c82d510 --- /dev/null +++ b/boards/Wemos-Lolin-S3-Mini.defaults @@ -0,0 +1,19 @@ +# Wemos-Lolin-S3-Mini — auto-ported from platformio.ini. Board-specific deltas only; +# shared config lives in sdkconfig.defaults. + +CONFIG_IDF_TARGET="esp32s3" +CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y + +# OpenShock uses a single 4 MB OTA partition layout (fits every board's flash). +CONFIG_PARTITION_TABLE_CUSTOM=y +CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions/ota_4mb.csv" + +# Native USB only (no USB-UART bridge): console on the built-in USB-Serial-JTAG, so the +# serial console reads commands from the port the host actually sees. +CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y + +# Hardware pins. Not Kconfig: bare OPENSHOCK_* lines, read by scripts/gen_env_header.py +# into openshock_board.h and stripped from the sdkconfig fragment by scripts/build.py, +# so boards sharing a chip and flash size compile identical ESP-IDF objects. +OPENSHOCK_LED_WS2812B=47 +OPENSHOCK_LED_SWAP_RG_CHANNELS=y diff --git a/boards/Wemos-Lolin-S3.defaults b/boards/Wemos-Lolin-S3.defaults new file mode 100644 index 00000000..51ab79c1 --- /dev/null +++ b/boards/Wemos-Lolin-S3.defaults @@ -0,0 +1,14 @@ +# Wemos-Lolin-S3 — auto-ported from platformio.ini. Board-specific deltas only; +# shared config lives in sdkconfig.defaults. + +CONFIG_IDF_TARGET="esp32s3" +CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y + +# OpenShock uses a single 4 MB OTA partition layout (fits every board's flash). +CONFIG_PARTITION_TABLE_CUSTOM=y +CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions/ota_4mb.csv" + +# Hardware pins. Not Kconfig: bare OPENSHOCK_* lines, read by scripts/gen_env_header.py +# into openshock_board.h and stripped from the sdkconfig fragment by scripts/build.py, +# so boards sharing a chip and flash size compile identical ESP-IDF objects. +OPENSHOCK_LED_WS2812B=38 diff --git a/boards/Wemos-Lolin-S3.json b/boards/Wemos-Lolin-S3.json deleted file mode 100644 index 82b81be5..00000000 --- a/boards/Wemos-Lolin-S3.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "build": { - "arduino": { - "ldscript": "esp32s3_out.ld", - "partitions": "default_16MB.csv", - "memory_type": "qio_opi" - }, - "core": "esp32", - "extra_flags": ["-DBOARD_HAS_PSRAM", "-DARDUINO_LOLIN_S3", "-DARDUINO_USB_MODE=1"], - "f_cpu": "240000000L", - "f_flash": "80000000L", - "flash_mode": "qio", - "hwids": [["0x303A", "0x1001"]], - "mcu": "esp32s3", - "variant": "lolin_s3" - }, - "connectivity": ["wifi", "bluetooth"], - "debug": { - "openocd_target": "esp32s3.cfg" - }, - "frameworks": ["arduino", "espidf"], - "name": "WEMOS LOLIN S3", - "upload": { - "flash_size": "16MB", - "maximum_ram_size": 327680, - "maximum_size": 16777216, - "use_1200bps_touch": true, - "wait_for_upload_port": true, - "require_upload_port": true, - "speed": 460800 - }, - "url": "https://www.wemos.cc/en/latest/s3/index.html", - "vendor": "WEMOS" -} diff --git a/certificates/.gitignore b/certificates/.gitignore index 15c59972..116c6b4b 100644 --- a/certificates/.gitignore +++ b/certificates/.gitignore @@ -10,3 +10,7 @@ custom_certs/ *.pem.key *.p12 *.pfx + +# Python bytecode cache from cert_bundle.py +__pycache__/ +*.pyc diff --git a/certificates/README.md b/certificates/README.md index 67b4069e..386de439 100644 --- a/certificates/README.md +++ b/certificates/README.md @@ -1,106 +1,154 @@ # Certificates used for HTTPS -This project uses a curated CA certificate bundle for TLS/SSL verification on the ESP32. +This project compiles a curated CA trust store into the firmware for TLS/SSL verification on +the ESP32. The build packs `cacert-merged.pem` into the flash image via ESP-IDF's +certificate-bundle step, and the ESP32 verifies every TLS server (gateway WebSocket, HTTP/OTA) +against it. -The bundle is sourced from **curl’s official CA extract** and can optionally be extended with **user-provided custom CA certificates**. +`certificates/cert_bundle.py` manages the store. It has two subcommands: + +```sh +python3 cert_bundle.py generate # (default) rebuild cacert-curl.pem + cacert-merged.pem +python3 cert_bundle.py audit # audit expiry + live-endpoint relevance (used by CI) +``` + +## The two files + +| File | What it is | Packed into firmware? | +|---|---|---| +| `cacert-curl.pem` | **Verbatim** curl/Mozilla CA extract, exactly as downloaded. Its `sha256sum` matches curl's published hash. | No — reference/base only | +| `cacert-merged.pem` | Generated: the base **plus** `pinned_certs/*.pem` (plus any local `custom_certs/*.pem`), de-duplicated, with an OpenShock header banner instead of curl's. | **Yes** | + +Both are tracked and committed. `cacert-merged.pem` is what actually ships; `cacert-curl.pem` +exists so the base can be independently checksum-verified against curl. + +### Why pinned roots exist + +esp_crt_bundle does **not** do certificate-path building. It trusts the chain exactly as a +server presents it and looks up the **issuer of the topmost presented cert** among the trusted +roots. If a server sends a legacy cross-signed root at the top of its chain, the anchor it +"requires" is that cross-signer — which a modern (Mozilla-derived) store may no longer include. + +Concretely: `api.openshock.app` presents `leaf → GTS WE1 → GTS Root R4`, where R4 is the +**cross-signed** copy issued by `GlobalSign Root CA` (R1). Mozilla/curl retired R1, so without +it esp_crt_bundle reports *"No matching trusted root certificate found"*. `pinned_certs/` carries +R1 so that chain verifies. (The cleaner long-term fix is trimming the server chain so it +validates against `GTS Root R4`, which every store already ships — see **Auditing** below.) ## Source of Trust -- The base CA bundle comes from: - [https://curl.se/docs/caextract.html](https://curl.se/docs/caextract.html) -- `cacert.pem` is downloaded directly from curl.se -- A SHA-256 checksum is computed locally and compared against curl’s published checksum -- All certificates are parsed and validated using `cryptography` -- Any parsing error, mismatch, or invalid certificate **aborts generation** +- The base CA bundle comes from: [https://curl.se/docs/caextract.html](https://curl.se/docs/caextract.html) +- `cacert-curl.pem` is the base PEM downloaded from curl.se; its SHA-256 is compared against + curl's published checksum +- Every certificate (base, pinned, custom) is parsed and validated with `cryptography` +- Pinned/custom certs must be CA certificates (BasicConstraints CA=TRUE) +- The merged output is **de-duplicated** by certificate DER, and duplicate subject names are + rejected (they would break esp_crt_bundle's subject-name lookup) +- Any parsing error, checksum mismatch, or an already-expired pinned root **aborts generation** > ⚠️ **Trust warning** -> The PEM file and its checksum are fetched from the same domain. -> If curl.se were compromised or TLS trust failed, both could be malicious but internally consistent. -> **Manual verification against curl’s website is therefore mandatory.** +> The base PEM and its checksum are fetched from the same domain. If curl.se were compromised +> or TLS trust failed, both could be malicious but internally consistent. +> **Manual verification against curl's website is therefore mandatory.** ## Updating the Bundle ### 1. Generate -Run: - ```sh -python3 gen_crt_bundle.py +python3 cert_bundle.py generate ``` The script will: -- Download `cacert.pem` -- Compute its SHA-256 hash -- Download curl’s published checksum -- Verify the PEM matches the computed checksum -- Optionally include custom certificates (see below) -- Abort on any error or inconsistency +- Download the curl base PEM and compute its SHA-256 +- Download curl's published checksum and verify the download matches it +- Parse/validate the base + `pinned_certs/*.pem` (+ any `custom_certs/*.pem`) +- Reject non-CA pins, duplicate subjects, and already-expired pins; de-dup identical certs +- Write `cacert-curl.pem` (verbatim base) and `cacert-merged.pem` (base + pinned + custom) ### 2. **Mandatory Manual Verification** ⚠️ **Do not commit anything before completing the steps below.** +`cacert-curl.pem` is the verbatim curl extract, so verify it directly: + 1. Open: [https://curl.se/docs/caextract.html](https://curl.se/docs/caextract.html) 2. Locate the latest published SHA-256 checksum -3. Manually compare it against: +3. Compare it against: ```sh -sha256sum cacert.pem +sha256sum cacert-curl.pem ``` -Both values **must match exactly**. - -If they do not: - -- Do not commit -- Treat the bundle as untrusted - -> Automatic checks ensure internal consistency. -> Manual verification establishes the external trust boundary. +Both values **must match exactly**. If they do not: do not commit; treat the bundle as untrusted. ### 3. Commit After successful manual verification, commit: -- `cacert.pem` -- `x509_crt_bundle` +- `cacert-curl.pem` +- `cacert-merged.pem` +- any change under `pinned_certs/` -❌ **Never commit anything under `custom_certs/`.** +`cacert-merged.pem` is the packed artifact. The script does **not** emit a packed +`x509_crt_bundle` — the firmware build converts and packs `cacert-merged.pem` into flash itself +via ESP-IDF's certificate-bundle step (`CONFIG_MBEDTLS_CUSTOM_CERTIFICATE_BUNDLE_PATH` in +`sdkconfig.defaults`). -## Custom Certificates (Optional) +> ⚠️ **Build gotcha:** the ESP-IDF cert-bundle step does not always detect a changed +> `cacert-merged.pem`. After regenerating, delete `.pio/build//x509_crt_bundle*` (or do a +> clean build) or the firmware may silently ship the previous bundle. -Self-hosted setups may require trusting additional Certificate Authorities (e.g. internal PKI or private reverse proxies). +## Pinned Certificates (`pinned_certs/`) -Custom CA certificates can be added locally and merged into the bundle. +Roots we intentionally ship on top of the Mozilla base. **Tracked and committed.** Each file +must contain exactly one PEM certificate block (an optional leading label/comment line, as in +curl's bundle, is allowed) and must be a CA certificate. -### How to add +Before adding a pinned root, fingerprint-verify it out-of-band against at least one independent +source (e.g. the CA's own repository plus Cloudflare's `cfssl_trust`), then drop the PEM into +`pinned_certs/` and run `cert_bundle.py generate`. -Place certificates in: +### Auditing -```text -custom_certs/ -``` +`cert_bundle.py audit` (run weekly by the `pinned-cert-audit` CI workflow): -Only files ending in `.pem` are considered. +- **Expiry** — checks every cert in `cacert-merged.pem`. A still-needed pinned root that is + expired or within the warn window (`PINNED_CERT_EXPIRY_WARN_DAYS`, default 90) fails the job + so we rotate it in time. +- **Relevance** — probes `api.openshock.app` / `api.openshock.dev` and works out which anchor + each requires. A pinned root that **no** reachable endpoint chains through anymore is dead + weight; the workflow opens a PR that deletes it and regenerates the bundle. (Fail-safe: if a + probe is unreachable, nothing is retired.) -### Requirements +This is what makes the pins self-cleaning: once the servers stop sending a legacy cross-signed +root, the corresponding pin is automatically proposed for removal. + +## Custom Certificates (Optional) -Each custom certificate file must contain exactly one PEM certificate block and nothing else. +Self-hosted setups may need to trust additional CAs (internal PKI, private reverse proxies). +Drop them in `custom_certs/` locally — this directory is **git-ignored** and never committed. -The script will parse and validate them as CA certificates and reject anything invalid or duplicated. +- Only `*.pem` files are considered; each must contain exactly one CA certificate (an optional + leading label/comment line is allowed, but no trailing data or private keys). +- The script validates them and rejects anything invalid or duplicated. -> ⚠️ Adding custom CAs expands the ESP32’s trust boundary. -> Only add certificates you fully trust and control. +> ⚠️ Adding custom CAs expands the ESP32's trust boundary. Only add certificates you fully +> trust and control. -If `custom_certs/` does not exist or doesn't contain any .pem certificates, only curl’s CA bundle is used. +> **Note:** custom-cert support is currently **dormant** in shipped builds — the committed +> `cacert-merged.pem` is generated without any `custom_certs/`, so custom certs only reach the +> trust store in a local rebuild. ## Trust Model Summary - Automatic verification protects against corruption and mismatched downloads -- Manual verification anchors trust outside the update mechanism -- Custom certificates explicitly extend trust and are opt-in only -- The generated bundle is deterministic for a given input set +- Manual verification (`sha256sum cacert-curl.pem` vs curl) anchors trust outside the update mechanism +- Pinned roots are explicit, fingerprint-verified additions, and are expiry/relevance-audited +- The merged store is de-duplicated and cannot contain the same certificate twice +- Custom certificates explicitly extend trust and are opt-in, local-only +- `cacert-merged.pem` = verified curl base + `pinned_certs/` (+ local `custom_certs/`) ## References diff --git a/certificates/cacert.pem b/certificates/cacert-curl.pem similarity index 83% rename from certificates/cacert.pem rename to certificates/cacert-curl.pem index 65be891e..3e158b86 100644 --- a/certificates/cacert.pem +++ b/certificates/cacert-curl.pem @@ -1,7 +1,7 @@ ## ## Bundle of CA Root Certificates ## -## Certificate data from Mozilla as of: Tue Dec 2 04:12:02 2025 GMT +## Certificate data from Mozilla as of: Thu May 14 03:12:02 2026 GMT ## ## Find updated versions here: https://curl.se/docs/caextract.html ## @@ -15,8 +15,8 @@ ## an Apache+mod_ssl webserver for SSL client authentication. ## Just configure this file as the SSLCACertificateFile. ## -## Conversion done with mk-ca-bundle.pl version 1.30. -## SHA256: a903b3cd05231e39332515ef7ebe37e697262f39515a52015c23c62805b73cd0 +## Conversion done with mk-ca-bundle.pl version 1.33. +## SHA256: 77130ef91213772844561fbd3aa31d413b25c2ac7f576fea3bc3bbff7ef93489 ## @@ -46,237 +46,6 @@ W3iDVuycNsMm4hH2Z0kdkquM++v/eu6FSqdQgPCnXEqULl8FmTxSQeDNtGPPAUO6nIPcj2A781q0 tHuu2guQOHXvgR1m0vdXcDazv/wor3ElhVsT/h5/WrQ8 -----END CERTIFICATE----- -QuoVadis Root CA 2 -================== ------BEGIN CERTIFICATE----- -MIIFtzCCA5+gAwIBAgICBQkwDQYJKoZIhvcNAQEFBQAwRTELMAkGA1UEBhMCQk0xGTAXBgNVBAoT -EFF1b1ZhZGlzIExpbWl0ZWQxGzAZBgNVBAMTElF1b1ZhZGlzIFJvb3QgQ0EgMjAeFw0wNjExMjQx -ODI3MDBaFw0zMTExMjQxODIzMzNaMEUxCzAJBgNVBAYTAkJNMRkwFwYDVQQKExBRdW9WYWRpcyBM -aW1pdGVkMRswGQYDVQQDExJRdW9WYWRpcyBSb290IENBIDIwggIiMA0GCSqGSIb3DQEBAQUAA4IC -DwAwggIKAoICAQCaGMpLlA0ALa8DKYrwD4HIrkwZhR0In6spRIXzL4GtMh6QRr+jhiYaHv5+HBg6 -XJxgFyo6dIMzMH1hVBHL7avg5tKifvVrbxi3Cgst/ek+7wrGsxDp3MJGF/hd/aTa/55JWpzmM+Yk -lvc/ulsrHHo1wtZn/qtmUIttKGAr79dgw8eTvI02kfN/+NsRE8Scd3bBrrcCaoF6qUWD4gXmuVbB -lDePSHFjIuwXZQeVikvfj8ZaCuWw419eaxGrDPmF60Tp+ARz8un+XJiM9XOva7R+zdRcAitMOeGy -lZUtQofX1bOQQ7dsE/He3fbE+Ik/0XX1ksOR1YqI0JDs3G3eicJlcZaLDQP9nL9bFqyS2+r+eXyt -66/3FsvbzSUr5R/7mp/iUcw6UwxI5g69ybR2BlLmEROFcmMDBOAENisgGQLodKcftslWZvB1Jdxn -wQ5hYIizPtGo/KPaHbDRsSNU30R2be1B2MGyIrZTHN81Hdyhdyox5C315eXbyOD/5YDXC2Og/zOh -D7osFRXql7PSorW+8oyWHhqPHWykYTe5hnMz15eWniN9gqRMgeKh0bpnX5UHoycR7hYQe7xFSkyy -BNKr79X9DFHOUGoIMfmR2gyPZFwDwzqLID9ujWc9Otb+fVuIyV77zGHcizN300QyNQliBJIWENie -J0f7OyHj+OsdWwIDAQABo4GwMIGtMA8GA1UdEwEB/wQFMAMBAf8wCwYDVR0PBAQDAgEGMB0GA1Ud -DgQWBBQahGK8SEwzJQTU7tD2A8QZRtGUazBuBgNVHSMEZzBlgBQahGK8SEwzJQTU7tD2A8QZRtGU -a6FJpEcwRTELMAkGA1UEBhMCQk0xGTAXBgNVBAoTEFF1b1ZhZGlzIExpbWl0ZWQxGzAZBgNVBAMT -ElF1b1ZhZGlzIFJvb3QgQ0EgMoICBQkwDQYJKoZIhvcNAQEFBQADggIBAD4KFk2fBluornFdLwUv -Z+YTRYPENvbzwCYMDbVHZF34tHLJRqUDGCdViXh9duqWNIAXINzng/iN/Ae42l9NLmeyhP3ZRPx3 -UIHmfLTJDQtyU/h2BwdBR5YM++CCJpNVjP4iH2BlfF/nJrP3MpCYUNQ3cVX2kiF495V5+vgtJodm -VjB3pjd4M1IQWK4/YY7yarHvGH5KWWPKjaJW1acvvFYfzznB4vsKqBUsfU16Y8Zsl0Q80m/DShcK -+JDSV6IZUaUtl0HaB0+pUNqQjZRG4T7wlP0QADj1O+hA4bRuVhogzG9Yje0uRY/W6ZM/57Es3zrW -IozchLsib9D45MY56QSIPMO661V6bYCZJPVsAfv4l7CUW+v90m/xd2gNNWQjrLhVoQPRTUIZ3Ph1 -WVaj+ahJefivDrkRoHy3au000LYmYjgahwz46P0u05B/B5EqHdZ+XIWDmbA4CD/pXvk1B+TJYm5X -f6dQlfe6yJvmjqIBxdZmv3lh8zwc4bmCXF2gw+nYSL0ZohEUGW6yhhtoPkg3Goi3XZZenMfvJ2II -4pEZXNLxId26F0KCl3GBUzGpn/Z9Yr9y4aOTHcyKJloJONDO1w2AFrR4pTqHTI2KpdVGl/IsELm8 -VCLAAVBpQ570su9t+Oza8eOx79+Rj1QqCyXBJhnEUhAFZdWCEOrCMc0u ------END CERTIFICATE----- - -QuoVadis Root CA 3 -================== ------BEGIN CERTIFICATE----- -MIIGnTCCBIWgAwIBAgICBcYwDQYJKoZIhvcNAQEFBQAwRTELMAkGA1UEBhMCQk0xGTAXBgNVBAoT -EFF1b1ZhZGlzIExpbWl0ZWQxGzAZBgNVBAMTElF1b1ZhZGlzIFJvb3QgQ0EgMzAeFw0wNjExMjQx -OTExMjNaFw0zMTExMjQxOTA2NDRaMEUxCzAJBgNVBAYTAkJNMRkwFwYDVQQKExBRdW9WYWRpcyBM -aW1pdGVkMRswGQYDVQQDExJRdW9WYWRpcyBSb290IENBIDMwggIiMA0GCSqGSIb3DQEBAQUAA4IC -DwAwggIKAoICAQDMV0IWVJzmmNPTTe7+7cefQzlKZbPoFog02w1ZkXTPkrgEQK0CSzGrvI2RaNgg -DhoB4hp7Thdd4oq3P5kazethq8Jlph+3t723j/z9cI8LoGe+AaJZz3HmDyl2/7FWeUUrH556VOij -KTVopAFPD6QuN+8bv+OPEKhyq1hX51SGyMnzW9os2l2ObjyjPtr7guXd8lyyBTNvijbO0BNO/79K -DDRMpsMhvVAEVeuxu537RR5kFd5VAYwCdrXLoT9CabwvvWhDFlaJKjdhkf2mrk7AyxRllDdLkgbv -BNDInIjbC3uBr7E9KsRlOni27tyAsdLTmZw67mtaa7ONt9XOnMK+pUsvFrGeaDsGb659n/je7Mwp -p5ijJUMv7/FfJuGITfhebtfZFG4ZM2mnO4SJk8RTVROhUXhA+LjJou57ulJCg54U7QVSWllWp5f8 -nT8KKdjcT5EOE7zelaTfi5m+rJsziO+1ga8bxiJTyPbH7pcUsMV8eFLI8M5ud2CEpukqdiDtWAEX -MJPpGovgc2PZapKUSU60rUqFxKMiMPwJ7Wgic6aIDFUhWMXhOp8q3crhkODZc6tsgLjoC2SToJyM -Gf+z0gzskSaHirOi4XCPLArlzW1oUevaPwV/izLmE1xr/l9A4iLItLRkT9a6fUg+qGkM17uGcclz -uD87nSVL2v9A6wIDAQABo4IBlTCCAZEwDwYDVR0TAQH/BAUwAwEB/zCB4QYDVR0gBIHZMIHWMIHT -BgkrBgEEAb5YAAMwgcUwgZMGCCsGAQUFBwICMIGGGoGDQW55IHVzZSBvZiB0aGlzIENlcnRpZmlj -YXRlIGNvbnN0aXR1dGVzIGFjY2VwdGFuY2Ugb2YgdGhlIFF1b1ZhZGlzIFJvb3QgQ0EgMyBDZXJ0 -aWZpY2F0ZSBQb2xpY3kgLyBDZXJ0aWZpY2F0aW9uIFByYWN0aWNlIFN0YXRlbWVudC4wLQYIKwYB -BQUHAgEWIWh0dHA6Ly93d3cucXVvdmFkaXNnbG9iYWwuY29tL2NwczALBgNVHQ8EBAMCAQYwHQYD -VR0OBBYEFPLAE+CCQz777i9nMpY1XNu4ywLQMG4GA1UdIwRnMGWAFPLAE+CCQz777i9nMpY1XNu4 -ywLQoUmkRzBFMQswCQYDVQQGEwJCTTEZMBcGA1UEChMQUXVvVmFkaXMgTGltaXRlZDEbMBkGA1UE -AxMSUXVvVmFkaXMgUm9vdCBDQSAzggIFxjANBgkqhkiG9w0BAQUFAAOCAgEAT62gLEz6wPJv92ZV -qyM07ucp2sNbtrCD2dDQ4iH782CnO11gUyeim/YIIirnv6By5ZwkajGxkHon24QRiSemd1o417+s -hvzuXYO8BsbRd2sPbSQvS3pspweWyuOEn62Iix2rFo1bZhfZFvSLgNLd+LJ2w/w4E6oM3kJpK27z -POuAJ9v1pkQNn1pVWQvVDVJIxa6f8i+AxeoyUDUSly7B4f/xI4hROJ/yZlZ25w9Rl6VSDE1JUZU2 -Pb+iSwwQHYaZTKrzchGT5Or2m9qoXadNt54CrnMAyNojA+j56hl0YgCUyyIgvpSnWbWCar6ZeXqp -8kokUvd0/bpO5qgdAm6xDYBEwa7TIzdfu4V8K5Iu6H6li92Z4b8nby1dqnuH/grdS/yO9SbkbnBC -bjPsMZ57k8HkyWkaPcBrTiJt7qtYTcbQQcEr6k8Sh17rRdhs9ZgC06DYVYoGmRmioHfRMJ6szHXu -g/WwYjnPbFfiTNKRCw51KBuav/0aQ/HKd/s7j2G4aSgWQgRecCocIdiP4b0jWy10QJLZYxkNc91p -vGJHvOB0K7Lrfb5BG7XARsWhIstfTsEokt4YutUqKLsRixeTmJlglFwjz1onl14LBQaTNx47aTbr -qZ5hHY8y2o4M1nQ+ewkk2gF3R8Q7zTSMmfXK4SVhM7JZG+Ju1zdXtg2pEto= ------END CERTIFICATE----- - -DigiCert Assured ID Root CA -=========================== ------BEGIN CERTIFICATE----- -MIIDtzCCAp+gAwIBAgIQDOfg5RfYRv6P5WD8G/AwOTANBgkqhkiG9w0BAQUFADBlMQswCQYDVQQG -EwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSQw -IgYDVQQDExtEaWdpQ2VydCBBc3N1cmVkIElEIFJvb3QgQ0EwHhcNMDYxMTEwMDAwMDAwWhcNMzEx -MTEwMDAwMDAwWjBlMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQL -ExB3d3cuZGlnaWNlcnQuY29tMSQwIgYDVQQDExtEaWdpQ2VydCBBc3N1cmVkIElEIFJvb3QgQ0Ew -ggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCtDhXO5EOAXLGH87dg+XESpa7cJpSIqvTO -9SA5KFhgDPiA2qkVlTJhPLWxKISKityfCgyDF3qPkKyK53lTXDGEKvYPmDI2dsze3Tyoou9q+yHy -UmHfnyDXH+Kx2f4YZNISW1/5WBg1vEfNoTb5a3/UsDg+wRvDjDPZ2C8Y/igPs6eD1sNuRMBhNZYW -/lmci3Zt1/GiSw0r/wty2p5g0I6QNcZ4VYcgoc/lbQrISXwxmDNsIumH0DJaoroTghHtORedmTpy -oeb6pNnVFzF1roV9Iq4/AUaG9ih5yLHa5FcXxH4cDrC0kqZWs72yl+2qp/C3xag/lRbQ/6GW6whf -GHdPAgMBAAGjYzBhMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRF -66Kv9JLLgjEtUYunpyGd823IDzAfBgNVHSMEGDAWgBRF66Kv9JLLgjEtUYunpyGd823IDzANBgkq -hkiG9w0BAQUFAAOCAQEAog683+Lt8ONyc3pklL/3cmbYMuRCdWKuh+vy1dneVrOfzM4UKLkNl2Bc -EkxY5NM9g0lFWJc1aRqoR+pWxnmrEthngYTffwk8lOa4JiwgvT2zKIn3X/8i4peEH+ll74fg38Fn -SbNd67IJKusm7Xi+fT8r87cmNW1fiQG2SVufAQWbqz0lwcy2f8Lxb4bG+mRo64EtlOtCt/qMHt1i -8b5QZ7dsvfPxH2sMNgcWfzd8qVttevESRmCD1ycEvkvOl77DZypoEd+A5wwzZr8TDRRu838fYxAe -+o0bJW1sj6W3YQGx0qMmoRBxna3iw/nDmVG3KwcIzi7mULKn+gpFL6Lw8g== ------END CERTIFICATE----- - -DigiCert Global Root CA -======================= ------BEGIN CERTIFICATE----- -MIIDrzCCApegAwIBAgIQCDvgVpBCRrGhdWrJWZHHSjANBgkqhkiG9w0BAQUFADBhMQswCQYDVQQG -EwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSAw -HgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBDQTAeFw0wNjExMTAwMDAwMDBaFw0zMTExMTAw -MDAwMDBaMGExCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3 -dy5kaWdpY2VydC5jb20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IENBMIIBIjANBgkq -hkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4jvhEXLeqKTTo1eqUKKPC3eQyaKl7hLOllsBCSDMAZOn -TjC3U/dDxGkAV53ijSLdhwZAAIEJzs4bg7/fzTtxRuLWZscFs3YnFo97nh6Vfe63SKMI2tavegw5 -BmV/Sl0fvBf4q77uKNd0f3p4mVmFaG5cIzJLv07A6Fpt43C/dxC//AH2hdmoRBBYMql1GNXRor5H -4idq9Joz+EkIYIvUX7Q6hL+hqkpMfT7PT19sdl6gSzeRntwi5m3OFBqOasv+zbMUZBfHWymeMr/y -7vrTC0LUq7dBMtoM1O/4gdW7jVg/tRvoSSiicNoxBN33shbyTApOB6jtSj1etX+jkMOvJwIDAQAB -o2MwYTAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUA95QNVbRTLtm -8KPiGxvDl7I90VUwHwYDVR0jBBgwFoAUA95QNVbRTLtm8KPiGxvDl7I90VUwDQYJKoZIhvcNAQEF -BQADggEBAMucN6pIExIK+t1EnE9SsPTfrgT1eXkIoyQY/EsrhMAtudXH/vTBH1jLuG2cenTnmCmr -EbXjcKChzUyImZOMkXDiqw8cvpOp/2PV5Adg06O/nVsJ8dWO41P0jmP6P6fbtGbfYmbW0W5BjfIt -tep3Sp+dWOIrWcBAI+0tKIJFPnlUkiaY4IBIqDfv8NZ5YBberOgOzW6sRBc4L0na4UU+Krk2U886 -UAb3LujEV0lsYSEY1QSteDwsOoBrp+uvFRTp2InBuThs4pFsiv9kuXclVzDAGySj4dzp30d8tbQk -CAUw7C29C79Fv1C5qfPrmAESrciIxpg0X40KPMbp1ZWVbd4= ------END CERTIFICATE----- - -DigiCert High Assurance EV Root CA -================================== ------BEGIN CERTIFICATE----- -MIIDxTCCAq2gAwIBAgIQAqxcJmoLQJuPC3nyrkYldzANBgkqhkiG9w0BAQUFADBsMQswCQYDVQQG -EwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSsw -KQYDVQQDEyJEaWdpQ2VydCBIaWdoIEFzc3VyYW5jZSBFViBSb290IENBMB4XDTA2MTExMDAwMDAw -MFoXDTMxMTExMDAwMDAwMFowbDELMAkGA1UEBhMCVVMxFTATBgNVBAoTDERpZ2lDZXJ0IEluYzEZ -MBcGA1UECxMQd3d3LmRpZ2ljZXJ0LmNvbTErMCkGA1UEAxMiRGlnaUNlcnQgSGlnaCBBc3N1cmFu -Y2UgRVYgUm9vdCBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMbM5XPm+9S75S0t -Mqbf5YE/yc0lSbZxKsPVlDRnogocsF9ppkCxxLeyj9CYpKlBWTrT3JTWPNt0OKRKzE0lgvdKpVMS -OO7zSW1xkX5jtqumX8OkhPhPYlG++MXs2ziS4wblCJEMxChBVfvLWokVfnHoNb9Ncgk9vjo4UFt3 -MRuNs8ckRZqnrG0AFFoEt7oT61EKmEFBIk5lYYeBQVCmeVyJ3hlKV9Uu5l0cUyx+mM0aBhakaHPQ -NAQTXKFx01p8VdteZOE3hzBWBOURtCmAEvF5OYiiAhF8J2a3iLd48soKqDirCmTCv2ZdlYTBoSUe -h10aUAsgEsxBu24LUTi4S8sCAwEAAaNjMGEwDgYDVR0PAQH/BAQDAgGGMA8GA1UdEwEB/wQFMAMB -Af8wHQYDVR0OBBYEFLE+w2kD+L9HAdSYJhoIAu9jZCvDMB8GA1UdIwQYMBaAFLE+w2kD+L9HAdSY -JhoIAu9jZCvDMA0GCSqGSIb3DQEBBQUAA4IBAQAcGgaX3NecnzyIZgYIVyHbIUf4KmeqvxgydkAQ -V8GK83rZEWWONfqe/EW1ntlMMUu4kehDLI6zeM7b41N5cdblIZQB2lWHmiRk9opmzN6cN82oNLFp -myPInngiK3BD41VHMWEZ71jFhS9OMPagMRYjyOfiZRYzy78aG6A9+MpeizGLYAiJLQwGXFK3xPkK -mNEVX58Svnw2Yzi9RKR/5CYrCsSXaQ3pjOLAEFe4yHYSkVXySGnYvCoCWw9E1CAx2/S6cCZdkGCe -vEsXCS+0yx5DaMkHJ8HSXPfqIbloEpw8nL+e/IBcm2PN7EeqJSdnoDfzAIJ9VNep+OkuE6N36B9K ------END CERTIFICATE----- - -SwissSign Gold CA - G2 -====================== ------BEGIN CERTIFICATE----- -MIIFujCCA6KgAwIBAgIJALtAHEP1Xk+wMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNVBAYTAkNIMRUw -EwYDVQQKEwxTd2lzc1NpZ24gQUcxHzAdBgNVBAMTFlN3aXNzU2lnbiBHb2xkIENBIC0gRzIwHhcN -MDYxMDI1MDgzMDM1WhcNMzYxMDI1MDgzMDM1WjBFMQswCQYDVQQGEwJDSDEVMBMGA1UEChMMU3dp -c3NTaWduIEFHMR8wHQYDVQQDExZTd2lzc1NpZ24gR29sZCBDQSAtIEcyMIICIjANBgkqhkiG9w0B -AQEFAAOCAg8AMIICCgKCAgEAr+TufoskDhJuqVAtFkQ7kpJcyrhdhJJCEyq8ZVeCQD5XJM1QiyUq -t2/876LQwB8CJEoTlo8jE+YoWACjR8cGp4QjK7u9lit/VcyLwVcfDmJlD909Vopz2q5+bbqBHH5C -jCA12UNNhPqE21Is8w4ndwtrvxEvcnifLtg+5hg3Wipy+dpikJKVyh+c6bM8K8vzARO/Ws/BtQpg -vd21mWRTuKCWs2/iJneRjOBiEAKfNA+k1ZIzUd6+jbqEemA8atufK+ze3gE/bk3lUIbLtK/tREDF -ylqM2tIrfKjuvqblCqoOpd8FUrdVxyJdMmqXl2MT28nbeTZ7hTpKxVKJ+STnnXepgv9VHKVxaSvR -AiTysybUa9oEVeXBCsdtMDeQKuSeFDNeFhdVxVu1yzSJkvGdJo+hB9TGsnhQ2wwMC3wLjEHXuend -jIj3o02yMszYF9rNt85mndT9Xv+9lz4pded+p2JYryU0pUHHPbwNUMoDAw8IWh+Vc3hiv69yFGkO -peUDDniOJihC8AcLYiAQZzlG+qkDzAQ4embvIIO1jEpWjpEA/I5cgt6IoMPiaG59je883WX0XaxR -7ySArqpWl2/5rX3aYT+YdzylkbYcjCbaZaIJbcHiVOO5ykxMgI93e2CaHt+28kgeDrpOVG2Y4OGi -GqJ3UM/EY5LsRxmd6+ZrzsECAwEAAaOBrDCBqTAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUw -AwEB/zAdBgNVHQ4EFgQUWyV7lqRlUX64OfPAeGZe6Drn8O4wHwYDVR0jBBgwFoAUWyV7lqRlUX64 -OfPAeGZe6Drn8O4wRgYDVR0gBD8wPTA7BglghXQBWQECAQEwLjAsBggrBgEFBQcCARYgaHR0cDov -L3JlcG9zaXRvcnkuc3dpc3NzaWduLmNvbS8wDQYJKoZIhvcNAQEFBQADggIBACe645R88a7A3hfm -5djV9VSwg/S7zV4Fe0+fdWavPOhWfvxyeDgD2StiGwC5+OlgzczOUYrHUDFu4Up+GC9pWbY9ZIEr -44OE5iKHjn3g7gKZYbge9LgriBIWhMIxkziWMaa5O1M/wySTVltpkuzFwbs4AOPsF6m43Md8AYOf -Mke6UiI0HTJ6CVanfCU2qT1L2sCCbwq7EsiHSycR+R4tx5M/nttfJmtS2S6K8RTGRI0Vqbe/vd6m -Gu6uLftIdxf+u+yvGPUqUfA5hJeVbG4bwyvEdGB5JbAKJ9/fXtI5z0V9QkvfsywexcZdylU6oJxp -mo/a77KwPJ+HbBIrZXAVUjEaJM9vMSNQH4xPjyPDdEFjHFWoFN0+4FFQz/EbMFYOkrCChdiDyyJk -vC24JdVUorgG6q2SpCSgwYa1ShNqR88uC1aVVMvOmttqtKay20EIhid392qgQmwLOM7XdVAyksLf -KzAiSNDVQTglXaTpXZ/GlHXQRf0wl0OPkKsKx4ZzYEppLd6leNcG2mqeSz53OiATIgHQv2ieY2Br -NU0LbbqhPcCT4H8js1WtciVORvnSFu+wZMEBnunKoGqYDs/YYPIvSbjkQuE4NRb0yG5P94FW6Lqj -viOvrv1vA+ACOzB2+httQc8Bsem4yWb02ybzOqR08kkkW8mw0FfB+j564ZfJ ------END CERTIFICATE----- - -SecureTrust CA -============== ------BEGIN CERTIFICATE----- -MIIDuDCCAqCgAwIBAgIQDPCOXAgWpa1Cf/DrJxhZ0DANBgkqhkiG9w0BAQUFADBIMQswCQYDVQQG -EwJVUzEgMB4GA1UEChMXU2VjdXJlVHJ1c3QgQ29ycG9yYXRpb24xFzAVBgNVBAMTDlNlY3VyZVRy -dXN0IENBMB4XDTA2MTEwNzE5MzExOFoXDTI5MTIzMTE5NDA1NVowSDELMAkGA1UEBhMCVVMxIDAe -BgNVBAoTF1NlY3VyZVRydXN0IENvcnBvcmF0aW9uMRcwFQYDVQQDEw5TZWN1cmVUcnVzdCBDQTCC -ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKukgeWVzfX2FI7CT8rU4niVWJxB4Q2ZQCQX -OZEzZum+4YOvYlyJ0fwkW2Gz4BERQRwdbvC4u/jep4G6pkjGnx29vo6pQT64lO0pGtSO0gMdA+9t -DWccV9cGrcrI9f4Or2YlSASWC12juhbDCE/RRvgUXPLIXgGZbf2IzIaowW8xQmxSPmjL8xk037uH -GFaAJsTQ3MBv396gwpEWoGQRS0S8Hvbn+mPeZqx2pHGj7DaUaHp3pLHnDi+BeuK1cobvomuL8A/b -01k/unK8RCSc43Oz969XL0Imnal0ugBS8kvNU3xHCzaFDmapCJcWNFfBZveA4+1wVMeT4C4oFVmH -ursCAwEAAaOBnTCBmjATBgkrBgEEAYI3FAIEBh4EAEMAQTALBgNVHQ8EBAMCAYYwDwYDVR0TAQH/ -BAUwAwEB/zAdBgNVHQ4EFgQUQjK2FvoE/f5dS3rD/fdMQB1aQ68wNAYDVR0fBC0wKzApoCegJYYj -aHR0cDovL2NybC5zZWN1cmV0cnVzdC5jb20vU1RDQS5jcmwwEAYJKwYBBAGCNxUBBAMCAQAwDQYJ -KoZIhvcNAQEFBQADggEBADDtT0rhWDpSclu1pqNlGKa7UTt36Z3q059c4EVlew3KW+JwULKUBRSu -SceNQQcSc5R+DCMh/bwQf2AQWnL1mA6s7Ll/3XpvXdMc9P+IBWlCqQVxyLesJugutIxq/3HcuLHf -mbx8IVQr5Fiiu1cprp6poxkmD5kuCLDv/WnPmRoJjeOnnyvJNjR7JLN4TJUXpAYmHrZkUjZfYGfZ -nMUFdAvnZyPSCPyI6a6Lf+Ew9Dd+/cYy2i2eRDAwbO4H3tI0/NL/QPZL9GZGBlSm8jIKYyYwa5vR -3ItHuuG51WLQoqD0ZwV4KWMabwTW+MZMo5qxN7SN5ShLHZ4swrhovO0C7jE= ------END CERTIFICATE----- - -Secure Global CA -================ ------BEGIN CERTIFICATE----- -MIIDvDCCAqSgAwIBAgIQB1YipOjUiolN9BPI8PjqpTANBgkqhkiG9w0BAQUFADBKMQswCQYDVQQG -EwJVUzEgMB4GA1UEChMXU2VjdXJlVHJ1c3QgQ29ycG9yYXRpb24xGTAXBgNVBAMTEFNlY3VyZSBH -bG9iYWwgQ0EwHhcNMDYxMTA3MTk0MjI4WhcNMjkxMjMxMTk1MjA2WjBKMQswCQYDVQQGEwJVUzEg -MB4GA1UEChMXU2VjdXJlVHJ1c3QgQ29ycG9yYXRpb24xGTAXBgNVBAMTEFNlY3VyZSBHbG9iYWwg -Q0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCvNS7YrGxVaQZx5RNoJLNP2MwhR/jx -YDiJiQPpvepeRlMJ3Fz1Wuj3RSoC6zFh1ykzTM7HfAo3fg+6MpjhHZevj8fcyTiW89sa/FHtaMbQ -bqR8JNGuQsiWUGMu4P51/pinX0kuleM5M2SOHqRfkNJnPLLZ/kG5VacJjnIFHovdRIWCQtBJwB1g -8NEXLJXr9qXBkqPFwqcIYA1gBBCWeZ4WNOaptvolRTnIHmX5k/Wq8VLcmZg9pYYaDDUz+kulBAYV -HDGA76oYa8J719rO+TMg1fW9ajMtgQT7sFzUnKPiXB3jqUJ1XnvUd+85VLrJChgbEplJL4hL/VBi -0XPnj3pDAgMBAAGjgZ0wgZowEwYJKwYBBAGCNxQCBAYeBABDAEEwCwYDVR0PBAQDAgGGMA8GA1Ud -EwEB/wQFMAMBAf8wHQYDVR0OBBYEFK9EBMJBfkiD2045AuzshHrmzsmkMDQGA1UdHwQtMCswKaAn -oCWGI2h0dHA6Ly9jcmwuc2VjdXJldHJ1c3QuY29tL1NHQ0EuY3JsMBAGCSsGAQQBgjcVAQQDAgEA -MA0GCSqGSIb3DQEBBQUAA4IBAQBjGghAfaReUw132HquHw0LURYD7xh8yOOvaliTFGCRsoTciE6+ -OYo68+aCiV0BN7OrJKQVDpI1WkpEXk5X+nXOH0jOZvQ8QCaSmGwb7iRGDBezUqXbpZGRzzfTb+cn -CDpOGR86p1hcF895P4vkp9MmI50mD1hp/Ed+stCNi5O/KU9DaXR2Z0vPB4zmAve14bRDtUstFJ/5 -3CYNv6ZHdAbYiNE6KTCEztI5gGIbqMdXSbxqVVFnFUq+NQfk1XWYN3kwFNspnWzFacxHVaIw98xc -f8LDmBxrThaA63p4ZUWiABqvDA1VZDRIuJK58bRQKfJPIx/abKwfROHdI3hRW8cW ------END CERTIFICATE----- - -COMODO Certification Authority -============================== ------BEGIN CERTIFICATE----- -MIIEHTCCAwWgAwIBAgIQToEtioJl4AsC7j41AkblPTANBgkqhkiG9w0BAQUFADCBgTELMAkGA1UE -BhMCR0IxGzAZBgNVBAgTEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UEBxMHU2FsZm9yZDEaMBgG -A1UEChMRQ09NT0RPIENBIExpbWl0ZWQxJzAlBgNVBAMTHkNPTU9ETyBDZXJ0aWZpY2F0aW9uIEF1 -dGhvcml0eTAeFw0wNjEyMDEwMDAwMDBaFw0yOTEyMzEyMzU5NTlaMIGBMQswCQYDVQQGEwJHQjEb -MBkGA1UECBMSR3JlYXRlciBNYW5jaGVzdGVyMRAwDgYDVQQHEwdTYWxmb3JkMRowGAYDVQQKExFD -T01PRE8gQ0EgTGltaXRlZDEnMCUGA1UEAxMeQ09NT0RPIENlcnRpZmljYXRpb24gQXV0aG9yaXR5 -MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0ECLi3LjkRv3UcEbVASY06m/weaKXTuH -+7uIzg3jLz8GlvCiKVCZrts7oVewdFFxze1CkU1B/qnI2GqGd0S7WWaXUF601CxwRM/aN5VCaTww -xHGzUvAhTaHYujl8HJ6jJJ3ygxaYqhZ8Q5sVW7euNJH+1GImGEaaP+vB+fGQV+useg2L23IwambV -4EajcNxo2f8ESIl33rXp+2dtQem8Ob0y2WIC8bGoPW43nOIv4tOiJovGuFVDiOEjPqXSJDlqR6sA -1KGzqSX+DT+nHbrTUcELpNqsOO9VUCQFZUaTNE8tja3G1CEZ0o7KBWFxB3NH5YoZEr0ETc5OnKVI -rLsm9wIDAQABo4GOMIGLMB0GA1UdDgQWBBQLWOWLxkwVN6RAqTCpIb5HNlpW/zAOBgNVHQ8BAf8E -BAMCAQYwDwYDVR0TAQH/BAUwAwEB/zBJBgNVHR8EQjBAMD6gPKA6hjhodHRwOi8vY3JsLmNvbW9k -b2NhLmNvbS9DT01PRE9DZXJ0aWZpY2F0aW9uQXV0aG9yaXR5LmNybDANBgkqhkiG9w0BAQUFAAOC -AQEAPpiem/Yb6dc5t3iuHXIYSdOH5EOC6z/JqvWote9VfCFSZfnVDeFs9D6Mk3ORLgLETgdxb8CP -OGEIqB6BCsAvIC9Bi5HcSEW88cbeunZrM8gALTFGTO3nnc+IlP8zwFboJIYmuNg4ON8qa90SzMc/ -RxdMosIGlgnW2/4/PEZB31jiVg88O8EckzXZOFKs7sjsLjBOlDW0JB9LeGna8gI4zJVSk/BwJVmc -IGfE7vmLV2H0knZ9P4SNVbfo5azV8fUZVqZa+5Acr5Pr5RzUZ5ddBA6+C4OmF4O5MBKgxTMVBbkN -+8cFduPYSo38NBejxiEovjBFMR7HeL5YYTisO+IBZQ== ------END CERTIFICATE----- - COMODO ECC Certification Authority ================================== -----BEGIN CERTIFICATE----- @@ -294,28 +63,6 @@ FAkK+qDmfQjGGoe9GKhzvSbKYAydzpmfz1wPMOG+FDHqAjAU9JM8SaczepBGR7NjfRObTrdvGDeA U/7dIOA1mjbRxwG55tzd8/8dLDoWV9mSOdY= -----END CERTIFICATE----- -Certigna -======== ------BEGIN CERTIFICATE----- -MIIDqDCCApCgAwIBAgIJAP7c4wEPyUj/MA0GCSqGSIb3DQEBBQUAMDQxCzAJBgNVBAYTAkZSMRIw -EAYDVQQKDAlEaGlteW90aXMxETAPBgNVBAMMCENlcnRpZ25hMB4XDTA3MDYyOTE1MTMwNVoXDTI3 -MDYyOTE1MTMwNVowNDELMAkGA1UEBhMCRlIxEjAQBgNVBAoMCURoaW15b3RpczERMA8GA1UEAwwI -Q2VydGlnbmEwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDIaPHJ1tazNHUmgh7stL7q -XOEm7RFHYeGifBZ4QCHkYJ5ayGPhxLGWkv8YbWkj4Sti993iNi+RB7lIzw7sebYs5zRLcAglozyH -GxnygQcPOJAZ0xH+hrTy0V4eHpbNgGzOOzGTtvKg0KmVEn2lmsxryIRWijOp5yIVUxbwzBfsV1/p -ogqYCd7jX5xv3EjjhQsVWqa6n6xI4wmy9/Qy3l40vhx4XUJbzg4ij02Q130yGLMLLGq/jj8UEYkg -DncUtT2UCIf3JR7VsmAA7G8qKCVuKj4YYxclPz5EIBb2JsglrgVKtOdjLPOMFlN+XPsRGgjBRmKf -Irjxwo1p3Po6WAbfAgMBAAGjgbwwgbkwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUGu3+QTmQ -tCRZvgHyUtVF9lo53BEwZAYDVR0jBF0wW4AUGu3+QTmQtCRZvgHyUtVF9lo53BGhOKQ2MDQxCzAJ -BgNVBAYTAkZSMRIwEAYDVQQKDAlEaGlteW90aXMxETAPBgNVBAMMCENlcnRpZ25hggkA/tzjAQ/J -SP8wDgYDVR0PAQH/BAQDAgEGMBEGCWCGSAGG+EIBAQQEAwIABzANBgkqhkiG9w0BAQUFAAOCAQEA -hQMeknH2Qq/ho2Ge6/PAD/Kl1NqV5ta+aDY9fm4fTIrv0Q8hbV6lUmPOEvjvKtpv6zf+EwLHyzs+ -ImvaYS5/1HI93TDhHkxAGYwP15zRgzB7mFncfca5DClMoTOi62c6ZYTTluLtdkVwj7Ur3vkj1klu -PBS1xp81HlDQwY9qcEQCYsuuHWhBp6pX6FOqB9IG9tUUBguRA3UsbHK1YZWaDYu5Def131TN3ubY -1gkIl2PlwS6wt0QmwCbAr1UwnjvVNioZBPRcHv/PLLf/0P2HQBHVESO7SMAhqaQoLf0V+LBOK/Qw -WyH8EZE0vkHve52Xdf+XlcCWWC/qu0bXu+TZLg== ------END CERTIFICATE----- - ePKI Root Certification Authority ================================= -----BEGIN CERTIFICATE----- @@ -347,26 +94,6 @@ sP6SHhYKGvzZ8/gntsm+HbRsZJB/9OTEW9c3rkIO3aQab3yIVMUWbuF6aC74Or8NpDyJO3inTmOD BCEIZ43ygknQW/2xzQ+DhNQ+IIX3Sj0rnP0qCglN6oH4EZw= -----END CERTIFICATE----- -certSIGN ROOT CA -================ ------BEGIN CERTIFICATE----- -MIIDODCCAiCgAwIBAgIGIAYFFnACMA0GCSqGSIb3DQEBBQUAMDsxCzAJBgNVBAYTAlJPMREwDwYD -VQQKEwhjZXJ0U0lHTjEZMBcGA1UECxMQY2VydFNJR04gUk9PVCBDQTAeFw0wNjA3MDQxNzIwMDRa -Fw0zMTA3MDQxNzIwMDRaMDsxCzAJBgNVBAYTAlJPMREwDwYDVQQKEwhjZXJ0U0lHTjEZMBcGA1UE -CxMQY2VydFNJR04gUk9PVCBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALczuX7I -JUqOtdu0KBuqV5Do0SLTZLrTk+jUrIZhQGpgV2hUhE28alQCBf/fm5oqrl0Hj0rDKH/v+yv6efHH -rfAQUySQi2bJqIirr1qjAOm+ukbuW3N7LBeCgV5iLKECZbO9xSsAfsT8AzNXDe3i+s5dRdY4zTW2 -ssHQnIFKquSyAVwdj1+ZxLGt24gh65AIgoDzMKND5pCCrlUoSe1b16kQOA7+j0xbm0bqQfWwCHTD -0IgztnzXdN/chNFDDnU5oSVAKOp4yw4sLjmdjItuFhwvJoIQ4uNllAoEwF73XVv4EOLQunpL+943 -AAAaWyjj0pxzPjKHmKHJUS/X3qwzs08CAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8B -Af8EBAMCAcYwHQYDVR0OBBYEFOCMm9slSbPxfIbWskKHC9BroNnkMA0GCSqGSIb3DQEBBQUAA4IB -AQA+0hyJLjX8+HXd5n9liPRyTMks1zJO890ZeUe9jjtbkw9QSSQTaxQGcu8J06Gh40CEyecYMnQ8 -SG4Pn0vU9x7Tk4ZkVJdjclDVVc/6IJMCopvDI5NOFlV2oHB5bc0hH88vLbwZ44gx+FkagQnIl6Z0 -x2DEW8xXjrJ1/RsCCdtZb3KTafcxQdaIOL+Hsr0Wefmq5L6IJd1hJyMctTEHBDa0GpC9oHRxUIlt -vBTjD4au8as+x6AJzKNI0eDbZOeStc+vckNwi/nDhDwTqn6Sm1dTk/pwwpEOMfmbZ13pljheX7Nz -TogVZ96edhBiIL5VaZVDADlN9u6wWk5JRFRYX0KD ------END CERTIFICATE----- - NetLock Arany (Class Gold) Főtanúsítvány ======================================== -----BEGIN CERTIFICATE----- @@ -536,90 +263,6 @@ iEDPfUYd/x7H4c7/I9vG+o1VTqkC50cRRj70/b17KSa7qWFiNyi2LSr2EIZkyXCn0q23KXB56jza YyWf/Wi3MOxw+3WKt21gZ7IeyLnp2KhvAotnDU0mV3HaIPzBSlCNsSi6 -----END CERTIFICATE----- -AffirmTrust Commercial -====================== ------BEGIN CERTIFICATE----- -MIIDTDCCAjSgAwIBAgIId3cGJyapsXwwDQYJKoZIhvcNAQELBQAwRDELMAkGA1UEBhMCVVMxFDAS -BgNVBAoMC0FmZmlybVRydXN0MR8wHQYDVQQDDBZBZmZpcm1UcnVzdCBDb21tZXJjaWFsMB4XDTEw -MDEyOTE0MDYwNloXDTMwMTIzMTE0MDYwNlowRDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC0FmZmly -bVRydXN0MR8wHQYDVQQDDBZBZmZpcm1UcnVzdCBDb21tZXJjaWFsMIIBIjANBgkqhkiG9w0BAQEF -AAOCAQ8AMIIBCgKCAQEA9htPZwcroRX1BiLLHwGy43NFBkRJLLtJJRTWzsO3qyxPxkEylFf6Eqdb -DuKPHx6GGaeqtS25Xw2Kwq+FNXkyLbscYjfysVtKPcrNcV/pQr6U6Mje+SJIZMblq8Yrba0F8PrV -C8+a5fBQpIs7R6UjW3p6+DM/uO+Zl+MgwdYoic+U+7lF7eNAFxHUdPALMeIrJmqbTFeurCA+ukV6 -BfO9m2kVrn1OIGPENXY6BwLJN/3HR+7o8XYdcxXyl6S1yHp52UKqK39c/s4mT6NmgTWvRLpUHhww -MmWd5jyTXlBOeuM61G7MGvv50jeuJCqrVwMiKA1JdX+3KNp1v47j3A55MQIDAQABo0IwQDAdBgNV -HQ4EFgQUnZPGU4teyq8/nx4P5ZmVvCT2lI8wDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMC -AQYwDQYJKoZIhvcNAQELBQADggEBAFis9AQOzcAN/wr91LoWXym9e2iZWEnStB03TX8nfUYGXUPG -hi4+c7ImfU+TqbbEKpqrIZcUsd6M06uJFdhrJNTxFq7YpFzUf1GO7RgBsZNjvbz4YYCanrHOQnDi -qX0GJX0nof5v7LMeJNrjS1UaADs1tDvZ110w/YETifLCBivtZ8SOyUOyXGsViQK8YvxO8rUzqrJv -0wqiUOP2O+guRMLbZjipM1ZI8W0bM40NjD9gN53Tym1+NH4Nn3J2ixufcv1SNUFFApYvHLKac0kh -sUlHRUe072o0EclNmsxZt9YCnlpOZbWUrhvfKbAW8b8Angc6F2S1BLUjIZkKlTuXfO8= ------END CERTIFICATE----- - -AffirmTrust Networking -====================== ------BEGIN CERTIFICATE----- -MIIDTDCCAjSgAwIBAgIIfE8EORzUmS0wDQYJKoZIhvcNAQEFBQAwRDELMAkGA1UEBhMCVVMxFDAS -BgNVBAoMC0FmZmlybVRydXN0MR8wHQYDVQQDDBZBZmZpcm1UcnVzdCBOZXR3b3JraW5nMB4XDTEw -MDEyOTE0MDgyNFoXDTMwMTIzMTE0MDgyNFowRDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC0FmZmly -bVRydXN0MR8wHQYDVQQDDBZBZmZpcm1UcnVzdCBOZXR3b3JraW5nMIIBIjANBgkqhkiG9w0BAQEF -AAOCAQ8AMIIBCgKCAQEAtITMMxcua5Rsa2FSoOujz3mUTOWUgJnLVWREZY9nZOIG41w3SfYvm4SE -Hi3yYJ0wTsyEheIszx6e/jarM3c1RNg1lho9Nuh6DtjVR6FqaYvZ/Ls6rnla1fTWcbuakCNrmreI -dIcMHl+5ni36q1Mr3Lt2PpNMCAiMHqIjHNRqrSK6mQEubWXLviRmVSRLQESxG9fhwoXA3hA/Pe24 -/PHxI1Pcv2WXb9n5QHGNfb2V1M6+oF4nI979ptAmDgAp6zxG8D1gvz9Q0twmQVGeFDdCBKNwV6gb -h+0t+nvujArjqWaJGctB+d1ENmHP4ndGyH329JKBNv3bNPFyfvMMFr20FQIDAQABo0IwQDAdBgNV -HQ4EFgQUBx/S55zawm6iQLSwelAQUHTEyL0wDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMC -AQYwDQYJKoZIhvcNAQEFBQADggEBAIlXshZ6qML91tmbmzTCnLQyFE2npN/svqe++EPbkTfOtDIu -UFUaNU52Q3Eg75N3ThVwLofDwR1t3Mu1J9QsVtFSUzpE0nPIxBsFZVpikpzuQY0x2+c06lkh1QF6 -12S4ZDnNye2v7UsDSKegmQGA3GWjNq5lWUhPgkvIZfFXHeVZLgo/bNjR9eUJtGxUAArgFU2HdW23 -WJZa3W3SAKD0m0i+wzekujbgfIeFlxoVot4uolu9rxj5kFDNcFn4J2dHy8egBzp90SxdbBk6ZrV9 -/ZFvgrG+CJPbFEfxojfHRZ48x3evZKiT3/Zpg4Jg8klCNO1aAFSFHBY2kgxc+qatv9s= ------END CERTIFICATE----- - -AffirmTrust Premium -=================== ------BEGIN CERTIFICATE----- -MIIFRjCCAy6gAwIBAgIIbYwURrGmCu4wDQYJKoZIhvcNAQEMBQAwQTELMAkGA1UEBhMCVVMxFDAS -BgNVBAoMC0FmZmlybVRydXN0MRwwGgYDVQQDDBNBZmZpcm1UcnVzdCBQcmVtaXVtMB4XDTEwMDEy -OTE0MTAzNloXDTQwMTIzMTE0MTAzNlowQTELMAkGA1UEBhMCVVMxFDASBgNVBAoMC0FmZmlybVRy -dXN0MRwwGgYDVQQDDBNBZmZpcm1UcnVzdCBQcmVtaXVtMIICIjANBgkqhkiG9w0BAQEFAAOCAg8A -MIICCgKCAgEAxBLfqV/+Qd3d9Z+K4/as4Tx4mrzY8H96oDMq3I0gW64tb+eT2TZwamjPjlGjhVtn -BKAQJG9dKILBl1fYSCkTtuG+kU3fhQxTGJoeJKJPj/CihQvL9Cl/0qRY7iZNyaqoe5rZ+jjeRFcV -5fiMyNlI4g0WJx0eyIOFJbe6qlVBzAMiSy2RjYvmia9mx+n/K+k8rNrSs8PhaJyJ+HoAVt70VZVs -+7pk3WKL3wt3MutizCaam7uqYoNMtAZ6MMgpv+0GTZe5HMQxK9VfvFMSF5yZVylmd2EhMQcuJUmd -GPLu8ytxjLW6OQdJd/zvLpKQBY0tL3d770O/Nbua2Plzpyzy0FfuKE4mX4+QaAkvuPjcBukumj5R -p9EixAqnOEhss/n/fauGV+O61oV4d7pD6kh/9ti+I20ev9E2bFhc8e6kGVQa9QPSdubhjL08s9NI -S+LI+H+SqHZGnEJlPqQewQcDWkYtuJfzt9WyVSHvutxMAJf7FJUnM7/oQ0dG0giZFmA7mn7S5u04 -6uwBHjxIVkkJx0w3AJ6IDsBz4W9m6XJHMD4Q5QsDyZpCAGzFlH5hxIrff4IaC1nEWTJ3s7xgaVY5 -/bQGeyzWZDbZvUjthB9+pSKPKrhC9IK31FOQeE4tGv2Bb0TXOwF0lkLgAOIua+rF7nKsu7/+6qqo -+Nz2snmKtmcCAwEAAaNCMEAwHQYDVR0OBBYEFJ3AZ6YMItkm9UWrpmVSESfYRaxjMA8GA1UdEwEB -/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMA0GCSqGSIb3DQEBDAUAA4ICAQCzV00QYk465KzquByv -MiPIs0laUZx2KI15qldGF9X1Uva3ROgIRL8YhNILgM3FEv0AVQVhh0HctSSePMTYyPtwni94loMg -Nt58D2kTiKV1NpgIpsbfrM7jWNa3Pt668+s0QNiigfV4Py/VpfzZotReBA4Xrf5B8OWycvpEgjNC -6C1Y91aMYj+6QrCcDFx+LmUmXFNPALJ4fqENmS2NuB2OosSw/WDQMKSOyARiqcTtNd56l+0OOF6S -L5Nwpamcb6d9Ex1+xghIsV5n61EIJenmJWtSKZGc0jlzCFfemQa0W50QBuHCAKi4HEoCChTQwUHK -+4w1IX2COPKpVJEZNZOUbWo6xbLQu4mGk+ibyQ86p3q4ofB4Rvr8Ny/lioTz3/4E2aFooC8k4gmV -BtWVyuEklut89pMFu+1z6S3RdTnX5yTb2E5fQ4+e0BQ5v1VwSJlXMbSc7kqYA5YwH2AG7hsj/oFg -IxpHYoWlzBk0gG+zrBrjn/B7SK3VAdlntqlyk+otZrWyuOQ9PLLvTIzq6we/qzWaVYa8GKa1qF60 -g2xraUDTn9zxw2lrueFtCfTxqlB2Cnp9ehehVZZCmTEJ3WARjQUwfuaORtGdFNrHF+QFlozEJLUb -zxQHskD4o55BhrwE0GuWyCqANP2/7waj3VjFhT0+j/6eKeC2uAloGRwYQw== ------END CERTIFICATE----- - -AffirmTrust Premium ECC -======================= ------BEGIN CERTIFICATE----- -MIIB/jCCAYWgAwIBAgIIdJclisc/elQwCgYIKoZIzj0EAwMwRTELMAkGA1UEBhMCVVMxFDASBgNV -BAoMC0FmZmlybVRydXN0MSAwHgYDVQQDDBdBZmZpcm1UcnVzdCBQcmVtaXVtIEVDQzAeFw0xMDAx -MjkxNDIwMjRaFw00MDEyMzExNDIwMjRaMEUxCzAJBgNVBAYTAlVTMRQwEgYDVQQKDAtBZmZpcm1U -cnVzdDEgMB4GA1UEAwwXQWZmaXJtVHJ1c3QgUHJlbWl1bSBFQ0MwdjAQBgcqhkjOPQIBBgUrgQQA -IgNiAAQNMF4bFZ0D0KF5Nbc6PJJ6yhUczWLznCZcBz3lVPqj1swS6vQUX+iOGasvLkjmrBhDeKzQ -N8O9ss0s5kfiGuZjuD0uL3jET9v0D6RoTFVya5UdThhClXjMNzyR4ptlKymjQjBAMB0GA1UdDgQW -BBSaryl6wBE1NSZRMADDav5A1a7WPDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAK -BggqhkjOPQQDAwNnADBkAjAXCfOHiFBar8jAQr9HX/VsaobgxCd05DhT1wV/GzTjxi+zygk8N53X -57hG8f2h4nECMEJZh0PUUd+60wkyWs6Iflc9nF9Ca/UHLbXwgpP5WW+uZPpY5Yse42O+tYHNbwKM -eQ== ------END CERTIFICATE----- - Certum Trusted Network CA ========================= -----BEGIN CERTIFICATE----- @@ -946,35 +589,6 @@ EHiGG3njxPPyBJUgriOCxLM6AGK/5jYk4Ve6xx6QddVfP5VhK8E7zeWzaGHQRiapIVJpLesux+t3 zqY6tQMzT3bR51xUAV3LePTJDL/PEo4XLSNolOer/qmyKwbQBM0= -----END CERTIFICATE----- -TeliaSonera Root CA v1 -====================== ------BEGIN CERTIFICATE----- -MIIFODCCAyCgAwIBAgIRAJW+FqD3LkbxezmCcvqLzZYwDQYJKoZIhvcNAQEFBQAwNzEUMBIGA1UE -CgwLVGVsaWFTb25lcmExHzAdBgNVBAMMFlRlbGlhU29uZXJhIFJvb3QgQ0EgdjEwHhcNMDcxMDE4 -MTIwMDUwWhcNMzIxMDE4MTIwMDUwWjA3MRQwEgYDVQQKDAtUZWxpYVNvbmVyYTEfMB0GA1UEAwwW -VGVsaWFTb25lcmEgUm9vdCBDQSB2MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAMK+ -6yfwIaPzaSZVfp3FVRaRXP3vIb9TgHot0pGMYzHw7CTww6XScnwQbfQ3t+XmfHnqjLWCi65ItqwA -3GV17CpNX8GH9SBlK4GoRz6JI5UwFpB/6FcHSOcZrr9FZ7E3GwYq/t75rH2D+1665I+XZ75Ljo1k -B1c4VWk0Nj0TSO9P4tNmHqTPGrdeNjPUtAa9GAH9d4RQAEX1jF3oI7x+/jXh7VB7qTCNGdMJjmhn -Xb88lxhTuylixcpecsHHltTbLaC0H2kD7OriUPEMPPCs81Mt8Bz17Ww5OXOAFshSsCPN4D7c3TxH -oLs1iuKYaIu+5b9y7tL6pe0S7fyYGKkmdtwoSxAgHNN/Fnct7W+A90m7UwW7XWjH1Mh1Fj+JWov3 -F0fUTPHSiXk+TT2YqGHeOh7S+F4D4MHJHIzTjU3TlTazN19jY5szFPAtJmtTfImMMsJu7D0hADnJ -oWjiUIMusDor8zagrC/kb2HCUQk5PotTubtn2txTuXZZNp1D5SDgPTJghSJRt8czu90VL6R4pgd7 -gUY2BIbdeTXHlSw7sKMXNeVzH7RcWe/a6hBle3rQf5+ztCo3O3CLm1u5K7fsslESl1MpWtTwEhDc -TwK7EpIvYtQ/aUN8Ddb8WHUBiJ1YFkveupD/RwGJBmr2X7KQarMCpgKIv7NHfirZ1fpoeDVNAgMB -AAGjPzA9MA8GA1UdEwEB/wQFMAMBAf8wCwYDVR0PBAQDAgEGMB0GA1UdDgQWBBTwj1k4ALP1j5qW -DNXr+nuqF+gTEjANBgkqhkiG9w0BAQUFAAOCAgEAvuRcYk4k9AwI//DTDGjkk0kiP0Qnb7tt3oNm -zqjMDfz1mgbldxSR651Be5kqhOX//CHBXfDkH1e3damhXwIm/9fH907eT/j3HEbAek9ALCI18Bmx -0GtnLLCo4MBANzX2hFxc469CeP6nyQ1Q6g2EdvZR74NTxnr/DlZJLo961gzmJ1TjTQpgcmLNkQfW -pb/ImWvtxBnmq0wROMVvMeJuScg/doAmAyYp4Db29iBT4xdwNBedY2gea+zDTYa4EzAvXUYNR0PV -G6pZDrlcjQZIrXSHX8f8MVRBE+LHIQ6e4B4N4cB7Q4WQxYpYxmUKeFfyxiMPAdkgS94P+5KFdSpc -c41teyWRyu5FrgZLAMzTsVlQ2jqIOylDRl6XK1TOU2+NSueW+r9xDkKLfP0ooNBIytrEgUy7onOT -JsjrDNYmiLbAJM+7vVvrdX3pCI6GMyx5dwlppYn8s3CQh3aP0yK7Qs69cwsgJirQmz1wHiRszYd2 -qReWt88NkvuOGKmYSdGe/mBEciG5Ge3C9THxOUiIkCR1VBatzvT4aRRkOfujuLpwQMcnHL/EVlP6 -Y2XQ8xwOFvVrhlhNGNTkDY6lnVuR3HYkUD/GKvvZt5y11ubQ2egZixVxSK236thZiNSQvxaz2ems -WWFUyBy6ysHK4bkgTI86k4mloMy/0/Z1pHWWbVY= ------END CERTIFICATE----- - T-TeleSec GlobalRoot Class 2 ============================ -----BEGIN CERTIFICATE----- @@ -1371,50 +985,6 @@ ZHTetBXZ9FRUGi8c15dxVJCO2SCdUyt/q4/i6jC8UDfv8Ue1fXwsBOxonbRJRBD0ckscZOf85muQ 3Wl9af0AVqW3rLatt8o+Ae+c -----END CERTIFICATE----- -Entrust Root Certification Authority - G2 -========================================= ------BEGIN CERTIFICATE----- -MIIEPjCCAyagAwIBAgIESlOMKDANBgkqhkiG9w0BAQsFADCBvjELMAkGA1UEBhMCVVMxFjAUBgNV -BAoTDUVudHJ1c3QsIEluYy4xKDAmBgNVBAsTH1NlZSB3d3cuZW50cnVzdC5uZXQvbGVnYWwtdGVy -bXMxOTA3BgNVBAsTMChjKSAyMDA5IEVudHJ1c3QsIEluYy4gLSBmb3IgYXV0aG9yaXplZCB1c2Ug -b25seTEyMDAGA1UEAxMpRW50cnVzdCBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5IC0gRzIw -HhcNMDkwNzA3MTcyNTU0WhcNMzAxMjA3MTc1NTU0WjCBvjELMAkGA1UEBhMCVVMxFjAUBgNVBAoT -DUVudHJ1c3QsIEluYy4xKDAmBgNVBAsTH1NlZSB3d3cuZW50cnVzdC5uZXQvbGVnYWwtdGVybXMx -OTA3BgNVBAsTMChjKSAyMDA5IEVudHJ1c3QsIEluYy4gLSBmb3IgYXV0aG9yaXplZCB1c2Ugb25s -eTEyMDAGA1UEAxMpRW50cnVzdCBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5IC0gRzIwggEi -MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC6hLZy254Ma+KZ6TABp3bqMriVQRrJ2mFOWHLP -/vaCeb9zYQYKpSfYs1/TRU4cctZOMvJyig/3gxnQaoCAAEUesMfnmr8SVycco2gvCoe9amsOXmXz -HHfV1IWNcCG0szLni6LVhjkCsbjSR87kyUnEO6fe+1R9V77w6G7CebI6C1XiUJgWMhNcL3hWwcKU -s/Ja5CeanyTXxuzQmyWC48zCxEXFjJd6BmsqEZ+pCm5IO2/b1BEZQvePB7/1U1+cPvQXLOZprE4y -TGJ36rfo5bs0vBmLrpxR57d+tVOxMyLlbc9wPBr64ptntoP0jaWvYkxN4FisZDQSA/i2jZRjJKRx -AgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRqciZ6 -0B7vfec7aVHUbI2fkBJmqzANBgkqhkiG9w0BAQsFAAOCAQEAeZ8dlsa2eT8ijYfThwMEYGprmi5Z -iXMRrEPR9RP/jTkrwPK9T3CMqS/qF8QLVJ7UG5aYMzyorWKiAHarWWluBh1+xLlEjZivEtRh2woZ -Rkfz6/djwUAFQKXSt/S1mja/qYh2iARVBCuch38aNzx+LaUa2NSJXsq9rD1s2G2v1fN2D807iDgi -nWyTmsQ9v4IbZT+mD12q/OWyFcq1rca8PdCE6OoGcrBNOTJ4vz4RnAuknZoh8/CbCzB428Hch0P+ -vGOaysXCHMnHjf87ElgI5rY97HosTvuDls4MPGmHVHOkc8KT/1EQrBVUAdj8BbGJoX90g5pJ19xO -e4pIb4tF9g== ------END CERTIFICATE----- - -Entrust Root Certification Authority - EC1 -========================================== ------BEGIN CERTIFICATE----- -MIIC+TCCAoCgAwIBAgINAKaLeSkAAAAAUNCR+TAKBggqhkjOPQQDAzCBvzELMAkGA1UEBhMCVVMx -FjAUBgNVBAoTDUVudHJ1c3QsIEluYy4xKDAmBgNVBAsTH1NlZSB3d3cuZW50cnVzdC5uZXQvbGVn -YWwtdGVybXMxOTA3BgNVBAsTMChjKSAyMDEyIEVudHJ1c3QsIEluYy4gLSBmb3IgYXV0aG9yaXpl -ZCB1c2Ugb25seTEzMDEGA1UEAxMqRW50cnVzdCBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5 -IC0gRUMxMB4XDTEyMTIxODE1MjUzNloXDTM3MTIxODE1NTUzNlowgb8xCzAJBgNVBAYTAlVTMRYw -FAYDVQQKEw1FbnRydXN0LCBJbmMuMSgwJgYDVQQLEx9TZWUgd3d3LmVudHJ1c3QubmV0L2xlZ2Fs -LXRlcm1zMTkwNwYDVQQLEzAoYykgMjAxMiBFbnRydXN0LCBJbmMuIC0gZm9yIGF1dGhvcml6ZWQg -dXNlIG9ubHkxMzAxBgNVBAMTKkVudHJ1c3QgUm9vdCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eSAt -IEVDMTB2MBAGByqGSM49AgEGBSuBBAAiA2IABIQTydC6bUF74mzQ61VfZgIaJPRbiWlH47jCffHy -AsWfoPZb1YsGGYZPUxBtByQnoaD41UcZYUx9ypMn6nQM72+WCf5j7HBdNq1nd67JnXxVRDqiY1Ef -9eNi1KlHBz7MIKNCMEAwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYE -FLdj5xrdjekIplWDpOBqUEFlEUJJMAoGCCqGSM49BAMDA2cAMGQCMGF52OVCR98crlOZF7ZvHH3h -vxGU0QOIdeSNiaSKd0bebWHvAvX7td/M/k7//qnmpwIwW5nXhTcGtXsI/esni0qU+eH6p44mCOh8 -kmhtc9hvJqwhAriZtyZBWyVgrtBIGu4G ------END CERTIFICATE----- - CFCA EV ROOT ============ -----BEGIN CERTIFICATE----- @@ -2197,71 +1767,6 @@ NMn5X7azKFGnpyuqSfqNZSlO42sTp5SjLVFteAxEy9/eCG/Oo2Sr05WE1LlSVHJ7liXMvGnjSG4N 0MedJ5qq+BOS3R7fY581qRY27Iy4g/Q9iY/NtBde17MXQRBdJ3NghVdJIgc= -----END CERTIFICATE----- -Trustwave Global Certification Authority -======================================== ------BEGIN CERTIFICATE----- -MIIF2jCCA8KgAwIBAgIMBfcOhtpJ80Y1LrqyMA0GCSqGSIb3DQEBCwUAMIGIMQswCQYDVQQGEwJV -UzERMA8GA1UECAwISWxsaW5vaXMxEDAOBgNVBAcMB0NoaWNhZ28xITAfBgNVBAoMGFRydXN0d2F2 -ZSBIb2xkaW5ncywgSW5jLjExMC8GA1UEAwwoVHJ1c3R3YXZlIEdsb2JhbCBDZXJ0aWZpY2F0aW9u -IEF1dGhvcml0eTAeFw0xNzA4MjMxOTM0MTJaFw00MjA4MjMxOTM0MTJaMIGIMQswCQYDVQQGEwJV -UzERMA8GA1UECAwISWxsaW5vaXMxEDAOBgNVBAcMB0NoaWNhZ28xITAfBgNVBAoMGFRydXN0d2F2 -ZSBIb2xkaW5ncywgSW5jLjExMC8GA1UEAwwoVHJ1c3R3YXZlIEdsb2JhbCBDZXJ0aWZpY2F0aW9u -IEF1dGhvcml0eTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBALldUShLPDeS0YLOvR29 -zd24q88KPuFd5dyqCblXAj7mY2Hf8g+CY66j96xz0XznswuvCAAJWX/NKSqIk4cXGIDtiLK0thAf -LdZfVaITXdHG6wZWiYj+rDKd/VzDBcdu7oaJuogDnXIhhpCujwOl3J+IKMujkkkP7NAP4m1ET4Bq -stTnoApTAbqOl5F2brz81Ws25kCI1nsvXwXoLG0R8+eyvpJETNKXpP7ScoFDB5zpET71ixpZfR9o -WN0EACyW80OzfpgZdNmcc9kYvkHHNHnZ9GLCQ7mzJ7Aiy/k9UscwR7PJPrhq4ufogXBeQotPJqX+ -OsIgbrv4Fo7NDKm0G2x2EOFYeUY+VM6AqFcJNykbmROPDMjWLBz7BegIlT1lRtzuzWniTY+HKE40 -Cz7PFNm73bZQmq131BnW2hqIyE4bJ3XYsgjxroMwuREOzYfwhI0Vcnyh78zyiGG69Gm7DIwLdVcE -uE4qFC49DxweMqZiNu5m4iK4BUBjECLzMx10coos9TkpoNPnG4CELcU9402x/RpvumUHO1jsQkUm -+9jaJXLE9gCxInm943xZYkqcBW89zubWR2OZxiRvchLIrH+QtAuRcOi35hYQcRfO3gZPSEF9NUqj -ifLJS3tBEW1ntwiYTOURGa5CgNz7kAXU+FDKvuStx8KU1xad5hePrzb7AgMBAAGjQjBAMA8GA1Ud -EwEB/wQFMAMBAf8wHQYDVR0OBBYEFJngGWcNYtt2s9o9uFvo/ULSMQ6HMA4GA1UdDwEB/wQEAwIB -BjANBgkqhkiG9w0BAQsFAAOCAgEAmHNw4rDT7TnsTGDZqRKGFx6W0OhUKDtkLSGm+J1WE2pIPU/H -PinbbViDVD2HfSMF1OQc3Og4ZYbFdada2zUFvXfeuyk3QAUHw5RSn8pk3fEbK9xGChACMf1KaA0H -ZJDmHvUqoai7PF35owgLEQzxPy0QlG/+4jSHg9bP5Rs1bdID4bANqKCqRieCNqcVtgimQlRXtpla -4gt5kNdXElE1GYhBaCXUNxeEFfsBctyV3lImIJgm4nb1J2/6ADtKYdkNy1GTKv0WBpanI5ojSP5R -vbbEsLFUzt5sQa0WZ37b/TjNuThOssFgy50X31ieemKyJo90lZvkWx3SD92YHJtZuSPTMaCm/zjd -zyBP6VhWOmfD0faZmZ26NraAL4hHT4a/RDqA5Dccprrql5gR0IRiR2Qequ5AvzSxnI9O4fKSTx+O -856X3vOmeWqJcU9LJxdI/uz0UA9PSX3MReO9ekDFQdxhVicGaeVyQYHTtgGJoC86cnn+OjC/QezH -Yj6RS8fZMXZC+fc8Y+wmjHMMfRod6qh8h6jCJ3zhM0EPz8/8AKAigJ5Kp28AsEFFtyLKaEjFQqKu -3R3y4G5OBVixwJAWKqQ9EEC+j2Jjg6mcgn0tAumDMHzLJ8n9HmYAsC7TIS+OMxZsmO0QqAfWzJPP -29FpHOTKyeC2nOnOcXHebD8WpHk= ------END CERTIFICATE----- - -Trustwave Global ECC P256 Certification Authority -================================================= ------BEGIN CERTIFICATE----- -MIICYDCCAgegAwIBAgIMDWpfCD8oXD5Rld9dMAoGCCqGSM49BAMCMIGRMQswCQYDVQQGEwJVUzER -MA8GA1UECBMISWxsaW5vaXMxEDAOBgNVBAcTB0NoaWNhZ28xITAfBgNVBAoTGFRydXN0d2F2ZSBI -b2xkaW5ncywgSW5jLjE6MDgGA1UEAxMxVHJ1c3R3YXZlIEdsb2JhbCBFQ0MgUDI1NiBDZXJ0aWZp -Y2F0aW9uIEF1dGhvcml0eTAeFw0xNzA4MjMxOTM1MTBaFw00MjA4MjMxOTM1MTBaMIGRMQswCQYD -VQQGEwJVUzERMA8GA1UECBMISWxsaW5vaXMxEDAOBgNVBAcTB0NoaWNhZ28xITAfBgNVBAoTGFRy -dXN0d2F2ZSBIb2xkaW5ncywgSW5jLjE6MDgGA1UEAxMxVHJ1c3R3YXZlIEdsb2JhbCBFQ0MgUDI1 -NiBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABH77bOYj -43MyCMpg5lOcunSNGLB4kFKA3TjASh3RqMyTpJcGOMoNFWLGjgEqZZ2q3zSRLoHB5DOSMcT9CTqm -P62jQzBBMA8GA1UdEwEB/wQFMAMBAf8wDwYDVR0PAQH/BAUDAwcGADAdBgNVHQ4EFgQUo0EGrJBt -0UrrdaVKEJmzsaGLSvcwCgYIKoZIzj0EAwIDRwAwRAIgB+ZU2g6gWrKuEZ+Hxbb/ad4lvvigtwjz -RM4q3wghDDcCIC0mA6AFvWvR9lz4ZcyGbbOcNEhjhAnFjXca4syc4XR7 ------END CERTIFICATE----- - -Trustwave Global ECC P384 Certification Authority -================================================= ------BEGIN CERTIFICATE----- -MIICnTCCAiSgAwIBAgIMCL2Fl2yZJ6SAaEc7MAoGCCqGSM49BAMDMIGRMQswCQYDVQQGEwJVUzER -MA8GA1UECBMISWxsaW5vaXMxEDAOBgNVBAcTB0NoaWNhZ28xITAfBgNVBAoTGFRydXN0d2F2ZSBI -b2xkaW5ncywgSW5jLjE6MDgGA1UEAxMxVHJ1c3R3YXZlIEdsb2JhbCBFQ0MgUDM4NCBDZXJ0aWZp -Y2F0aW9uIEF1dGhvcml0eTAeFw0xNzA4MjMxOTM2NDNaFw00MjA4MjMxOTM2NDNaMIGRMQswCQYD -VQQGEwJVUzERMA8GA1UECBMISWxsaW5vaXMxEDAOBgNVBAcTB0NoaWNhZ28xITAfBgNVBAoTGFRy -dXN0d2F2ZSBIb2xkaW5ncywgSW5jLjE6MDgGA1UEAxMxVHJ1c3R3YXZlIEdsb2JhbCBFQ0MgUDM4 -NCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTB2MBAGByqGSM49AgEGBSuBBAAiA2IABGvaDXU1CDFH -Ba5FmVXxERMuSvgQMSOjfoPTfygIOiYaOs+Xgh+AtycJj9GOMMQKmw6sWASr9zZ9lCOkmwqKi6vr -/TklZvFe/oyujUF5nQlgziip04pt89ZF1PKYhDhloKNDMEEwDwYDVR0TAQH/BAUwAwEB/zAPBgNV -HQ8BAf8EBQMDBwYAMB0GA1UdDgQWBBRVqYSJ0sEyvRjLbKYHTsjnnb6CkDAKBggqhkjOPQQDAwNn -ADBkAjA3AZKXRRJ+oPM+rRk6ct30UJMDEr5E0k9BpIycnR+j9sKS50gU/k6bpZFXrsY3crsCMGcl -CrEMXu6pY5Jv5ZAL/mYiykf9ijH3g/56vxC+GCsej/YpHpRZ744hN8tRmKVuSw== ------END CERTIFICATE----- - NAVER Global Root Certification Authority ========================================= -----BEGIN CERTIFICATE----- @@ -2354,36 +1859,6 @@ vLtoURMMA/cVi4RguYv/Uo7njLwcAjA8+RHUjE7AwWHCFUyqqx0LMV87HOIAl0Qx5v5zli/altP+ CAezNIm8BZ/3Hobui3A= -----END CERTIFICATE----- -GLOBALTRUST 2020 -================ ------BEGIN CERTIFICATE----- -MIIFgjCCA2qgAwIBAgILWku9WvtPilv6ZeUwDQYJKoZIhvcNAQELBQAwTTELMAkGA1UEBhMCQVQx -IzAhBgNVBAoTGmUtY29tbWVyY2UgbW9uaXRvcmluZyBHbWJIMRkwFwYDVQQDExBHTE9CQUxUUlVT -VCAyMDIwMB4XDTIwMDIxMDAwMDAwMFoXDTQwMDYxMDAwMDAwMFowTTELMAkGA1UEBhMCQVQxIzAh -BgNVBAoTGmUtY29tbWVyY2UgbW9uaXRvcmluZyBHbWJIMRkwFwYDVQQDExBHTE9CQUxUUlVTVCAy -MDIwMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAri5WrRsc7/aVj6B3GyvTY4+ETUWi -D59bRatZe1E0+eyLinjF3WuvvcTfk0Uev5E4C64OFudBc/jbu9G4UeDLgztzOG53ig9ZYybNpyrO -VPu44sB8R85gfD+yc/LAGbaKkoc1DZAoouQVBGM+uq/ufF7MpotQsjj3QWPKzv9pj2gOlTblzLmM -CcpL3TGQlsjMH/1WljTbjhzqLL6FLmPdqqmV0/0plRPwyJiT2S0WR5ARg6I6IqIoV6Lr/sCMKKCm -fecqQjuCgGOlYx8ZzHyyZqjC0203b+J+BlHZRYQfEs4kUmSFC0iAToexIiIwquuuvuAC4EDosEKA -A1GqtH6qRNdDYfOiaxaJSaSjpCuKAsR49GiKweR6NrFvG5Ybd0mN1MkGco/PU+PcF4UgStyYJ9OR -JitHHmkHr96i5OTUawuzXnzUJIBHKWk7buis/UDr2O1xcSvy6Fgd60GXIsUf1DnQJ4+H4xj04KlG -DfV0OoIu0G4skaMxXDtG6nsEEFZegB31pWXogvziB4xiRfUg3kZwhqG8k9MedKZssCz3AwyIDMvU -clOGvGBG85hqwvG/Q/lwIHfKN0F5VVJjjVsSn8VoxIidrPIwq7ejMZdnrY8XD2zHc+0klGvIg5rQ -mjdJBKuxFshsSUktq6HQjJLyQUp5ISXbY9e2nKd+Qmn7OmMCAwEAAaNjMGEwDwYDVR0TAQH/BAUw -AwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFNwuH9FhN3nkq9XVsxJxaD1qaJwiMB8GA1Ud -IwQYMBaAFNwuH9FhN3nkq9XVsxJxaD1qaJwiMA0GCSqGSIb3DQEBCwUAA4ICAQCR8EICaEDuw2jA -VC/f7GLDw56KoDEoqoOOpFaWEhCGVrqXctJUMHytGdUdaG/7FELYjQ7ztdGl4wJCXtzoRlgHNQIw -4Lx0SsFDKv/bGtCwr2zD/cuz9X9tAy5ZVp0tLTWMstZDFyySCstd6IwPS3BD0IL/qMy/pJTAvoe9 -iuOTe8aPmxadJ2W8esVCgmxcB9CpwYhgROmYhRZf+I/KARDOJcP5YBugxZfD0yyIMaK9MOzQ0MAS -8cE54+X1+NZK3TTN+2/BT+MAi1bikvcoskJ3ciNnxz8RFbLEAwW+uxF7Cr+obuf/WEPPm2eggAe2 -HcqtbepBEX4tdJP7wry+UUTF72glJ4DjyKDUEuzZpTcdN3y0kcra1LGWge9oXHYQSa9+pTeAsRxS -vTOBTI/53WXZFM2KJVj04sWDpQmQ1GwUY7VA3+vA/MRYfg0UFodUJ25W5HCEuGwyEn6CMUO+1918 -oa2u1qsgEu8KwxCMSZY13At1XrFP1U80DhEgB3VDRemjEdqso5nCtnkn4rnvyOL2NSl6dPrFf4IF -YqYK6miyeUcGbvJXqBUzxvd4Sj1Ce2t+/vdG6tHrju+IaFvowdlxfv1k7/9nR4hYJS8+hge9+6jl -gqispdNpQ80xiEmEU5LAsTkbOYMBMMTyqfrQA71yN2BWHzZ8vTmR9W0Nv3vXkg== ------END CERTIFICATE----- - ANF Secure Server Root CA ========================= -----BEGIN CERTIFICATE----- @@ -2708,36 +2183,6 @@ FL//2wmUspO8IFgV6dtxQ/PeEMMA3KgqlbbC1j+Qa3bbbP6MvPJwNQzcmRk13NfIRmPVNnGuV/u3 gm3c -----END CERTIFICATE----- -GTS Root R2 -=========== ------BEGIN CERTIFICATE----- -MIIFVzCCAz+gAwIBAgINAgPlrsWNBCUaqxElqjANBgkqhkiG9w0BAQwFADBHMQswCQYDVQQGEwJV -UzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3Qg -UjIwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAwMDAwWjBHMQswCQYDVQQGEwJVUzEiMCAGA1UE -ChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjIwggIiMA0G -CSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDO3v2m++zsFDQ8BwZabFn3GTXd98GdVarTzTukk3Lv -CvptnfbwhYBboUhSnznFt+4orO/LdmgUud+tAWyZH8QiHZ/+cnfgLFuv5AS/T3KgGjSY6Dlo7JUl -e3ah5mm5hRm9iYz+re026nO8/4Piy33B0s5Ks40FnotJk9/BW9BuXvAuMC6C/Pq8tBcKSOWIm8Wb -a96wyrQD8Nr0kLhlZPdcTK3ofmZemde4wj7I0BOdre7kRXuJVfeKH2JShBKzwkCX44ofR5GmdFrS -+LFjKBC4swm4VndAoiaYecb+3yXuPuWgf9RhD1FLPD+M2uFwdNjCaKH5wQzpoeJ/u1U8dgbuak7M -kogwTZq9TwtImoS1mKPV+3PBV2HdKFZ1E66HjucMUQkQdYhMvI35ezzUIkgfKtzra7tEscszcTJG -r61K8YzodDqs5xoic4DSMPclQsciOzsSrZYuxsN2B6ogtzVJV+mSSeh2FnIxZyuWfoqjx5RWIr9q -S34BIbIjMt/kmkRtWVtd9QCgHJvGeJeNkP+byKq0rxFROV7Z+2et1VsRnTKaG73VululycslaVNV -J1zgyjbLiGH7HrfQy+4W+9OmTN6SpdTi3/UGVN4unUu0kzCqgc7dGtxRcw1PcOnlthYhGXmy5okL -dWTK1au8CcEYof/UVKGFPP0UJAOyh9OktwIDAQABo0IwQDAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0T -AQH/BAUwAwEB/zAdBgNVHQ4EFgQUu//KjiOfT5nK2+JopqUVJxce2Q4wDQYJKoZIhvcNAQEMBQAD -ggIBAB/Kzt3HvqGf2SdMC9wXmBFqiN495nFWcrKeGk6c1SuYJF2ba3uwM4IJvd8lRuqYnrYb/oM8 -0mJhwQTtzuDFycgTE1XnqGOtjHsB/ncw4c5omwX4Eu55MaBBRTUoCnGkJE+M3DyCB19m3H0Q/gxh -swWV7uGugQ+o+MePTagjAiZrHYNSVc61LwDKgEDg4XSsYPWHgJ2uNmSRXbBoGOqKYcl3qJfEycel -/FVL8/B/uWU9J2jQzGv6U53hkRrJXRqWbTKH7QMgyALOWr7Z6v2yTcQvG99fevX4i8buMTolUVVn -jWQye+mew4K6Ki3pHrTgSAai/GevHyICc/sgCq+dVEuhzf9gR7A/Xe8bVr2XIZYtCtFenTgCR2y5 -9PYjJbigapordwj6xLEokCZYCDzifqrXPW+6MYgKBesntaFJ7qBFVHvmJ2WZICGoo7z7GJa7Um8M -7YNRTOlZ4iBgxcJlkoKM8xAfDoqXvneCbT+PHV28SSe9zE8P4c52hgQjxcCMElv924SgJPFI/2R8 -0L5cFtHvma3AH/vLrrw4IgYmZNralw4/KBVEqE8AyvCazM90arQ+POuV7LXTWtiBmelDGDfrs7vR -WGJB82bSj6p4lVQgw1oudCvV0b4YacCs1aTPObpRhANl6WLAYv7YTVWW4tAR+kg0Eeye7QUd5MjW -HYbL ------END CERTIFICATE----- - GTS Root R3 =========== -----BEGIN CERTIFICATE----- @@ -3214,23 +2659,6 @@ HVlNjM7IDiPCtyaaEBRx/pOyiriA8A4QntOoUAw3gi/q4Iqd4Sw5/7W0cwDk90imc6y/st53BIe0 o82bNSQ3+pCTE4FCxpgmdTdmQRCsu/WU48IxK63nI1bMNSWSs1A= -----END CERTIFICATE----- -FIRMAPROFESIONAL CA ROOT-A WEB -============================== ------BEGIN CERTIFICATE----- -MIICejCCAgCgAwIBAgIQMZch7a+JQn81QYehZ1ZMbTAKBggqhkjOPQQDAzBuMQswCQYDVQQGEwJF -UzEcMBoGA1UECgwTRmlybWFwcm9mZXNpb25hbCBTQTEYMBYGA1UEYQwPVkFURVMtQTYyNjM0MDY4 -MScwJQYDVQQDDB5GSVJNQVBST0ZFU0lPTkFMIENBIFJPT1QtQSBXRUIwHhcNMjIwNDA2MDkwMTM2 -WhcNNDcwMzMxMDkwMTM2WjBuMQswCQYDVQQGEwJFUzEcMBoGA1UECgwTRmlybWFwcm9mZXNpb25h -bCBTQTEYMBYGA1UEYQwPVkFURVMtQTYyNjM0MDY4MScwJQYDVQQDDB5GSVJNQVBST0ZFU0lPTkFM -IENBIFJPT1QtQSBXRUIwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAARHU+osEaR3xyrq89Zfe9MEkVz6 -iMYiuYMQYneEMy3pA4jU4DP37XcsSmDq5G+tbbT4TIqk5B/K6k84Si6CcyvHZpsKjECcfIr28jlg -st7L7Ljkb+qbXbdTkBgyVcUgt5SjYzBhMA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAUk+FD -Y1w8ndYn81LsF7Kpryz3dvgwHQYDVR0OBBYEFJPhQ2NcPJ3WJ/NS7Beyqa8s93b4MA4GA1UdDwEB -/wQEAwIBBjAKBggqhkjOPQQDAwNoADBlAjAdfKR7w4l1M+E7qUW/Runpod3JIha3RxEL2Jq68cgL -cFBTApFwhVmpHqTm6iMxoAACMQD94vizrxa5HnPEluPBMBnYfubDl94cT7iJLzPrSA8Z94dGXSaQ -pYXFuXqUPoeovQA= ------END CERTIFICATE----- - TWCA CYBER Root CA ================== -----BEGIN CERTIFICATE----- @@ -3480,8 +2908,8 @@ SM49BAMDA2kAMGYCMQCpKjAd0MKfkFFRQD6VVCHNFmb3U2wIFjnQEnx/Yxvf4zgAOdktUyBFCxxg ZzFDJe0CMQCSia7pXGKDYmH5LVerVrkR3SW+ak5KGoJr3M/TvEqzPNcum9v4KGm8ay3sMaE641c= -----END CERTIFICATE----- - OISTE Server Root RSA G1 -========================= +OISTE Server Root RSA G1 +======================== -----BEGIN CERTIFICATE----- MIIFgzCCA2ugAwIBAgIQVaXZZ5Qoxu0M+ifdWwFNGDANBgkqhkiG9w0BAQwFADBLMQswCQYDVQQG EwJDSDEZMBcGA1UECgwQT0lTVEUgRm91bmRhdGlvbjEhMB8GA1UEAwwYT0lTVEUgU2VydmVyIFJv @@ -3509,3 +2937,21 @@ msuY33OhkKCgxeDoAaijFJzIwZqsFVAzje18KotzlUBDJvyBpCpfOZC3J8tRd/iWkx7P8nd9H0aT olkelUTFLXVksNb54Dxp6gS1HAviRkRNQzuXSXERvSS2wq1yVAb+axj5d9spLFKebXd7Yv0PTY6Y MjAwcRLWJTXjn/hvnLXrahut6hDTlhZyBiElxky8j3C7DOReIoMt0r7+hVu05L0= -----END CERTIFICATE----- + +e-Szigno TLS Root CA 2023 +========================= +-----BEGIN CERTIFICATE----- +MIICzzCCAjGgAwIBAgINAOhvGHvWOWuYSkmYCjAKBggqhkjOPQQDBDB1MQswCQYDVQQGEwJIVTER +MA8GA1UEBwwIQnVkYXBlc3QxFjAUBgNVBAoMDU1pY3Jvc2VjIEx0ZC4xFzAVBgNVBGEMDlZBVEhV +LTIzNTg0NDk3MSIwIAYDVQQDDBllLVN6aWdubyBUTFMgUm9vdCBDQSAyMDIzMB4XDTIzMDcxNzE0 +MDAwMFoXDTM4MDcxNzE0MDAwMFowdTELMAkGA1UEBhMCSFUxETAPBgNVBAcMCEJ1ZGFwZXN0MRYw +FAYDVQQKDA1NaWNyb3NlYyBMdGQuMRcwFQYDVQRhDA5WQVRIVS0yMzU4NDQ5NzEiMCAGA1UEAwwZ +ZS1Temlnbm8gVExTIFJvb3QgQ0EgMjAyMzCBmzAQBgcqhkjOPQIBBgUrgQQAIwOBhgAEAGgP36J8 +PKp0iGEKjcJMpQEiFNT3YHdCnAo4YKGMZz6zY+n6kbCLS+Y53wLCMAFSAL/fjO1ZrTJlqwlZULUZ +wmgcAOAFX9pQJhzDrAQixTpN7+lXWDajwRlTEArRzT/vSzUaQ49CE0y5LBqcvjC2xN7cS53kpDzL +Ltmt3999Cd8ukv+ho2MwYTAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4E +FgQUWYQCYlpGePVd3I8KECgj3NXW+0UwHwYDVR0jBBgwFoAUWYQCYlpGePVd3I8KECgj3NXW+0Uw +CgYIKoZIzj0EAwQDgYsAMIGHAkIBLdqu9S54tma4n7Zwf2Z0z+yOfP7AAXmazlIC58PRDHpty7Ve +7hekm9sEdu4pKeiv+62sUvTXK9Z3hBC9xdIoaDQCQTV2WnXzkoYI9bIeCvZlC9p2x1L/Cx6AcCIw +wzPbGO2E14vs7dOoY4G1VnxHx1YwlGhza9IuqbnZLBwpvQy6uWWL +-----END CERTIFICATE----- diff --git a/certificates/cacert-merged.pem b/certificates/cacert-merged.pem new file mode 100644 index 00000000..5f0e712c --- /dev/null +++ b/certificates/cacert-merged.pem @@ -0,0 +1,2963 @@ +## +## OpenShock firmware TLS trust store -- MERGED / GENERATED, do not edit by hand. +## +## Produced by certificates/cert_bundle.py. This is what the firmware packs: +## the verbatim curl/Mozilla CA extract (see cacert-curl.pem) + certificates/pinned_certs/. +## Its sha256 intentionally differs from curl's published hash. To change it, edit +## pinned_certs/ and re-run `cert_bundle.py generate` -- never hand-edit this file. +## +-----BEGIN CERTIFICATE----- +MIIEkTCCA3mgAwIBAgIERWtQVDANBgkqhkiG9w0BAQUFADCBsDELMAkGA1UEBhMCVVMxFjAUBgNV +BAoTDUVudHJ1c3QsIEluYy4xOTA3BgNVBAsTMHd3dy5lbnRydXN0Lm5ldC9DUFMgaXMgaW5jb3Jw +b3JhdGVkIGJ5IHJlZmVyZW5jZTEfMB0GA1UECxMWKGMpIDIwMDYgRW50cnVzdCwgSW5jLjEtMCsG +A1UEAxMkRW50cnVzdCBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5MB4XDTA2MTEyNzIwMjM0 +MloXDTI2MTEyNzIwNTM0MlowgbAxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1FbnRydXN0LCBJbmMu +MTkwNwYDVQQLEzB3d3cuZW50cnVzdC5uZXQvQ1BTIGlzIGluY29ycG9yYXRlZCBieSByZWZlcmVu +Y2UxHzAdBgNVBAsTFihjKSAyMDA2IEVudHJ1c3QsIEluYy4xLTArBgNVBAMTJEVudHJ1c3QgUm9v +dCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB +ALaVtkNC+sZtKm9I35RMOVcF7sN5EUFoNu3s/poBj6E4KPz3EEZmLk0eGrEaTsbRwJWIsMn/MYsz +A9u3g3s+IIRe7bJWKKf44LlAcTfFy0cOlypowCKVYhXbR9n10Cv/gkvJrT7eTNuQgFA/CYqEAOww +Cj0Yzfv9KlmaI5UXLEWeH25DeW0MXJj+SKfFI0dcXv1u5x609mhF0YaDW6KKjbHjKYD+JXGIrb68 +j6xSlkuqUY3kEzEZ6E5Nn9uss2rVvDlUccp6en+Q3X0dgNmBu1kmwhH+5pPi94DkZfs0Nw4pgHBN +rziGLp5/V6+eF67rHMsoIV+2HNjnogQi+dPa2MsCAwEAAaOBsDCBrTAOBgNVHQ8BAf8EBAMCAQYw +DwYDVR0TAQH/BAUwAwEB/zArBgNVHRAEJDAigA8yMDA2MTEyNzIwMjM0MlqBDzIwMjYxMTI3MjA1 +MzQyWjAfBgNVHSMEGDAWgBRokORnpKZTgMeGZqTx90tD+4S9bTAdBgNVHQ4EFgQUaJDkZ6SmU4DH +hmak8fdLQ/uEvW0wHQYJKoZIhvZ9B0EABBAwDhsIVjcuMTo0LjADAgSQMA0GCSqGSIb3DQEBBQUA +A4IBAQCT1DCw1wMgKtD5Y+iRDAUgqV8ZyntyTtSx29CW+1RaGSwMCPeyvIWonX9tO1KzKtvn1ISM +Y/YPyyYBkVBs9F8U4pN0wBOeMDpQ47RgxRzwIkSNcUesyBrJ6ZuaAGAT/3B+XxFNSRuzFVJ7yVTa +v52Vr2ua2J7p8eRDjeIRRDq/r72DQnNSi6q7pynP9WQcCk3RvKqsnyrQ/39/2n3qse0wJcGE2jTS +W3iDVuycNsMm4hH2Z0kdkquM++v/eu6FSqdQgPCnXEqULl8FmTxSQeDNtGPPAUO6nIPcj2A781q0 +tHuu2guQOHXvgR1m0vdXcDazv/wor3ElhVsT/h5/WrQ8 +-----END CERTIFICATE----- + +COMODO ECC Certification Authority +================================== +-----BEGIN CERTIFICATE----- +MIICiTCCAg+gAwIBAgIQH0evqmIAcFBUTAGem2OZKjAKBggqhkjOPQQDAzCBhTELMAkGA1UEBhMC +R0IxGzAZBgNVBAgTEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UEBxMHU2FsZm9yZDEaMBgGA1UE +ChMRQ09NT0RPIENBIExpbWl0ZWQxKzApBgNVBAMTIkNPTU9ETyBFQ0MgQ2VydGlmaWNhdGlvbiBB +dXRob3JpdHkwHhcNMDgwMzA2MDAwMDAwWhcNMzgwMTE4MjM1OTU5WjCBhTELMAkGA1UEBhMCR0Ix +GzAZBgNVBAgTEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UEBxMHU2FsZm9yZDEaMBgGA1UEChMR +Q09NT0RPIENBIExpbWl0ZWQxKzApBgNVBAMTIkNPTU9ETyBFQ0MgQ2VydGlmaWNhdGlvbiBBdXRo +b3JpdHkwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQDR3svdcmCFYX7deSRFtSrYpn1PlILBs5BAH+X +4QokPB0BBO490o0JlwzgdeT6+3eKKvUDYEs2ixYjFq0JcfRK9ChQtP6IHG4/bC8vCVlbpVsLM5ni +wz2J+Wos77LTBumjQjBAMB0GA1UdDgQWBBR1cacZSBm8nZ3qQUfflMRId5nTeTAOBgNVHQ8BAf8E +BAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAwNoADBlAjEA7wNbeqy3eApyt4jf/7VG +FAkK+qDmfQjGGoe9GKhzvSbKYAydzpmfz1wPMOG+FDHqAjAU9JM8SaczepBGR7NjfRObTrdvGDeA +U/7dIOA1mjbRxwG55tzd8/8dLDoWV9mSOdY= +-----END CERTIFICATE----- + +ePKI Root Certification Authority +================================= +-----BEGIN CERTIFICATE----- +MIIFsDCCA5igAwIBAgIQFci9ZUdcr7iXAF7kBtK8nTANBgkqhkiG9w0BAQUFADBeMQswCQYDVQQG +EwJUVzEjMCEGA1UECgwaQ2h1bmdod2EgVGVsZWNvbSBDby4sIEx0ZC4xKjAoBgNVBAsMIWVQS0kg +Um9vdCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTAeFw0wNDEyMjAwMjMxMjdaFw0zNDEyMjAwMjMx +MjdaMF4xCzAJBgNVBAYTAlRXMSMwIQYDVQQKDBpDaHVuZ2h3YSBUZWxlY29tIENvLiwgTHRkLjEq +MCgGA1UECwwhZVBLSSBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5MIICIjANBgkqhkiG9w0B +AQEFAAOCAg8AMIICCgKCAgEA4SUP7o3biDN1Z82tH306Tm2d0y8U82N0ywEhajfqhFAHSyZbCUNs +IZ5qyNUD9WBpj8zwIuQf5/dqIjG3LBXy4P4AakP/h2XGtRrBp0xtInAhijHyl3SJCRImHJ7K2RKi +lTza6We/CKBk49ZCt0Xvl/T29de1ShUCWH2YWEtgvM3XDZoTM1PRYfl61dd4s5oz9wCGzh1NlDiv +qOx4UXCKXBCDUSH3ET00hl7lSM2XgYI1TBnsZfZrxQWh7kcT1rMhJ5QQCtkkO7q+RBNGMD+XPNjX +12ruOzjjK9SXDrkb5wdJfzcq+Xd4z1TtW0ado4AOkUPB1ltfFLqfpo0kR0BZv3I4sjZsN/+Z0V0O +WQqraffAsgRFelQArr5T9rXn4fg8ozHSqf4hUmTFpmfwdQcGlBSBVcYn5AGPF8Fqcde+S/uUWH1+ +ETOxQvdibBjWzwloPn9s9h6PYq2lY9sJpx8iQkEeb5mKPtf5P0B6ebClAZLSnT0IFaUQAS2zMnao +lQ2zepr7BxB4EW/hj8e6DyUadCrlHJhBmd8hh+iVBmoKs2pHdmX2Os+PYhcZewoozRrSgx4hxyy/ +vv9haLdnG7t4TY3OZ+XkwY63I2binZB1NJipNiuKmpS5nezMirH4JYlcWrYvjB9teSSnUmjDhDXi +Zo1jDiVN1Rmy5nk3pyKdVDECAwEAAaNqMGgwHQYDVR0OBBYEFB4M97Zn8uGSJglFwFU5Lnc/Qkqi +MAwGA1UdEwQFMAMBAf8wOQYEZyoHAAQxMC8wLQIBADAJBgUrDgMCGgUAMAcGBWcqAwAABBRFsMLH +ClZ87lt4DJX5GFPBphzYEDANBgkqhkiG9w0BAQUFAAOCAgEACbODU1kBPpVJufGBuvl2ICO1J2B0 +1GqZNF5sAFPZn/KmsSQHRGoqxqWOeBLoR9lYGxMqXnmbnwoqZ6YlPwZpVnPDimZI+ymBV3QGypzq +KOg4ZyYr8dW1P2WT+DZdjo2NQCCHGervJ8A9tDkPJXtoUHRVnAxZfVo9QZQlUgjgRywVMRnVvwdV +xrsStZf0X4OFunHB2WyBEXYKCrC/gpf36j36+uwtqSiUO1bd0lEursC9CBWMd1I0ltabrNMdjmEP +NXubrjlpC2JgQCA2j6/7Nu4tCEoduL+bXPjqpRugc6bY+G7gMwRfaKonh+3ZwZCc7b3jajWvY9+r +GNm65ulK6lCKD2GTHuItGeIwlDWSXQ62B68ZgI9HkFFLLk3dheLSClIKF5r8GrBQAuUBo2M3IUxE +xJtRmREOc5wGj1QupyheRDmHVi03vYVElOEMSyycw5KFNGHLD7ibSkNS/jQ6fbjpKdx2qcgw+BRx +gMYeNkh0IkFch4LoGHGLQYlE535YW6i4jRPpp2zDR+2zGp1iro2C6pSe3VkQw63d4k3jMdXH7Ojy +sP6SHhYKGvzZ8/gntsm+HbRsZJB/9OTEW9c3rkIO3aQab3yIVMUWbuF6aC74Or8NpDyJO3inTmOD +BCEIZ43ygknQW/2xzQ+DhNQ+IIX3Sj0rnP0qCglN6oH4EZw= +-----END CERTIFICATE----- + +NetLock Arany (Class Gold) Főtanúsítvány +======================================== +-----BEGIN CERTIFICATE----- +MIIEFTCCAv2gAwIBAgIGSUEs5AAQMA0GCSqGSIb3DQEBCwUAMIGnMQswCQYDVQQGEwJIVTERMA8G +A1UEBwwIQnVkYXBlc3QxFTATBgNVBAoMDE5ldExvY2sgS2Z0LjE3MDUGA1UECwwuVGFuw7pzw610 +dsOhbnlraWFkw7NrIChDZXJ0aWZpY2F0aW9uIFNlcnZpY2VzKTE1MDMGA1UEAwwsTmV0TG9jayBB +cmFueSAoQ2xhc3MgR29sZCkgRsWRdGFuw7pzw610dsOhbnkwHhcNMDgxMjExMTUwODIxWhcNMjgx +MjA2MTUwODIxWjCBpzELMAkGA1UEBhMCSFUxETAPBgNVBAcMCEJ1ZGFwZXN0MRUwEwYDVQQKDAxO +ZXRMb2NrIEtmdC4xNzA1BgNVBAsMLlRhbsO6c8OtdHbDoW55a2lhZMOzayAoQ2VydGlmaWNhdGlv +biBTZXJ2aWNlcykxNTAzBgNVBAMMLE5ldExvY2sgQXJhbnkgKENsYXNzIEdvbGQpIEbFkXRhbsO6 +c8OtdHbDoW55MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxCRec75LbRTDofTjl5Bu +0jBFHjzuZ9lk4BqKf8owyoPjIMHj9DrTlF8afFttvzBPhCf2nx9JvMaZCpDyD/V/Q4Q3Y1GLeqVw +/HpYzY6b7cNGbIRwXdrzAZAj/E4wqX7hJ2Pn7WQ8oLjJM2P+FpD/sLj916jAwJRDC7bVWaaeVtAk +H3B5r9s5VA1lddkVQZQBr17s9o3x/61k/iCa11zr/qYfCGSji3ZVrR47KGAuhyXoqq8fxmRGILdw +fzzeSNuWU7c5d+Qa4scWhHaXWy+7GRWF+GmF9ZmnqfI0p6m2pgP8b4Y9VHx2BJtr+UBdADTHLpl1 +neWIA6pN+APSQnbAGwIDAKiLo0UwQzASBgNVHRMBAf8ECDAGAQH/AgEEMA4GA1UdDwEB/wQEAwIB +BjAdBgNVHQ4EFgQUzPpnk/C2uNClwB7zU/2MU9+D15YwDQYJKoZIhvcNAQELBQADggEBAKt/7hwW +qZw8UQCgwBEIBaeZ5m8BiFRhbvG5GK1Krf6BQCOUL/t1fC8oS2IkgYIL9WHxHG64YTjrgfpioTta +YtOUZcTh5m2C+C8lcLIhJsFyUR+MLMOEkMNaj7rP9KdlpeuY0fsFskZ1FSNqb4VjMIDw1Z4fKRzC +bLBQWV2QWzuoDTDPv31/zvGdg73JRm4gpvlhUbohL3u+pRVjodSVh/GeufOJ8z2FuLjbvrW5Kfna +NwUASZQDhETnv0Mxz3WLJdH0pmT1kvarBes96aULNmLazAZfNou2XjG4Kvte9nHfRCaexOYNkbQu +dZWAUWpLMKawYqGT8ZvYzsRjdT9ZR7E= +-----END CERTIFICATE----- + +Microsec e-Szigno Root CA 2009 +============================== +-----BEGIN CERTIFICATE----- +MIIECjCCAvKgAwIBAgIJAMJ+QwRORz8ZMA0GCSqGSIb3DQEBCwUAMIGCMQswCQYDVQQGEwJIVTER +MA8GA1UEBwwIQnVkYXBlc3QxFjAUBgNVBAoMDU1pY3Jvc2VjIEx0ZC4xJzAlBgNVBAMMHk1pY3Jv +c2VjIGUtU3ppZ25vIFJvb3QgQ0EgMjAwOTEfMB0GCSqGSIb3DQEJARYQaW5mb0BlLXN6aWduby5o +dTAeFw0wOTA2MTYxMTMwMThaFw0yOTEyMzAxMTMwMThaMIGCMQswCQYDVQQGEwJIVTERMA8GA1UE +BwwIQnVkYXBlc3QxFjAUBgNVBAoMDU1pY3Jvc2VjIEx0ZC4xJzAlBgNVBAMMHk1pY3Jvc2VjIGUt +U3ppZ25vIFJvb3QgQ0EgMjAwOTEfMB0GCSqGSIb3DQEJARYQaW5mb0BlLXN6aWduby5odTCCASIw +DQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAOn4j/NjrdqG2KfgQvvPkd6mJviZpWNwrZuuyjNA +fW2WbqEORO7hE52UQlKavXWFdCyoDh2Tthi3jCyoz/tccbna7P7ofo/kLx2yqHWH2Leh5TvPmUpG +0IMZfcChEhyVbUr02MelTTMuhTlAdX4UfIASmFDHQWe4oIBhVKZsTh/gnQ4H6cm6M+f+wFUoLAKA +pxn1ntxVUwOXewdI/5n7N4okxFnMUBBjjqqpGrCEGob5X7uxUG6k0QrM1XF+H6cbfPVTbiJfyyvm +1HxdrtbCxkzlBQHZ7Vf8wSN5/PrIJIOV87VqUQHQd9bpEqH5GoP7ghu5sJf0dgYzQ0mg/wu1+rUC +AwEAAaOBgDB+MA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBTLD8bf +QkPMPcu1SCOhGnqmKrs0aDAfBgNVHSMEGDAWgBTLD8bfQkPMPcu1SCOhGnqmKrs0aDAbBgNVHREE +FDASgRBpbmZvQGUtc3ppZ25vLmh1MA0GCSqGSIb3DQEBCwUAA4IBAQDJ0Q5eLtXMs3w+y/w9/w0o +lZMEyL/azXm4Q5DwpL7v8u8hmLzU1F0G9u5C7DBsoKqpyvGvivo/C3NqPuouQH4frlRheesuCDfX +I/OMn74dseGkddug4lQUsbocKaQY9hK6ohQU4zE1yED/t+AFdlfBHFny+L/k7SViXITwfn4fs775 +tyERzAMBVnCnEJIeGzSBHq2cGsMEPO0CYdYeBvNfOofyK/FFh+U9rNHHV4S9a67c2Pm2G2JwCz02 +yULyMtd6YebS2z3PyKnJm9zbWETXbzivf3jTo60adbocwTZ8jx5tHMN1Rq41Bab2XD0h7lbwyYIi +LXpUq3DDfSJlgnCW +-----END CERTIFICATE----- + +GlobalSign Root CA - R3 +======================= +-----BEGIN CERTIFICATE----- +MIIDXzCCAkegAwIBAgILBAAAAAABIVhTCKIwDQYJKoZIhvcNAQELBQAwTDEgMB4GA1UECxMXR2xv +YmFsU2lnbiBSb290IENBIC0gUjMxEzARBgNVBAoTCkdsb2JhbFNpZ24xEzARBgNVBAMTCkdsb2Jh +bFNpZ24wHhcNMDkwMzE4MTAwMDAwWhcNMjkwMzE4MTAwMDAwWjBMMSAwHgYDVQQLExdHbG9iYWxT +aWduIFJvb3QgQ0EgLSBSMzETMBEGA1UEChMKR2xvYmFsU2lnbjETMBEGA1UEAxMKR2xvYmFsU2ln +bjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMwldpB5BngiFvXAg7aEyiie/QV2EcWt +iHL8RgJDx7KKnQRfJMsuS+FggkbhUqsMgUdwbN1k0ev1LKMPgj0MK66X17YUhhB5uzsTgHeMCOFJ +0mpiLx9e+pZo34knlTifBtc+ycsmWQ1z3rDI6SYOgxXG71uL0gRgykmmKPZpO/bLyCiR5Z2KYVc3 +rHQU3HTgOu5yLy6c+9C7v/U9AOEGM+iCK65TpjoWc4zdQQ4gOsC0p6Hpsk+QLjJg6VfLuQSSaGjl +OCZgdbKfd/+RFO+uIEn8rUAVSNECMWEZXriX7613t2Saer9fwRPvm2L7DWzgVGkWqQPabumDk3F2 +xmmFghcCAwEAAaNCMEAwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYE +FI/wS3+oLkUkrk1Q+mOai97i3Ru8MA0GCSqGSIb3DQEBCwUAA4IBAQBLQNvAUKr+yAzv95ZURUm7 +lgAJQayzE4aGKAczymvmdLm6AC2upArT9fHxD4q/c2dKg8dEe3jgr25sbwMpjjM5RcOO5LlXbKr8 +EpbsU8Yt5CRsuZRj+9xTaGdWPoO4zzUhw8lo/s7awlOqzJCK6fBdRoyV3XpYKBovHd7NADdBj+1E +bddTKJd+82cEHhXXipa0095MJ6RMG3NzdvQXmcIfeg7jLQitChws/zyrVQ4PkX4268NXSb7hLi18 +YIvDQVETI53O9zJrlAGomecsMx86OyXShkDOOyyGeMlhLxS67ttVb9+E7gUJTb0o2HLO02JQZR7r +kpeDMdmztcpHWD9f +-----END CERTIFICATE----- + +Izenpe.com +========== +-----BEGIN CERTIFICATE----- +MIIF8TCCA9mgAwIBAgIQALC3WhZIX7/hy/WL1xnmfTANBgkqhkiG9w0BAQsFADA4MQswCQYDVQQG +EwJFUzEUMBIGA1UECgwLSVpFTlBFIFMuQS4xEzARBgNVBAMMCkl6ZW5wZS5jb20wHhcNMDcxMjEz +MTMwODI4WhcNMzcxMjEzMDgyNzI1WjA4MQswCQYDVQQGEwJFUzEUMBIGA1UECgwLSVpFTlBFIFMu +QS4xEzARBgNVBAMMCkl6ZW5wZS5jb20wggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDJ +03rKDx6sp4boFmVqscIbRTJxldn+EFvMr+eleQGPicPK8lVx93e+d5TzcqQsRNiekpsUOqHnJJAK +ClaOxdgmlOHZSOEtPtoKct2jmRXagaKH9HtuJneJWK3W6wyyQXpzbm3benhB6QiIEn6HLmYRY2xU ++zydcsC8Lv/Ct90NduM61/e0aL6i9eOBbsFGb12N4E3GVFWJGjMxCrFXuaOKmMPsOzTFlUFpfnXC +PCDFYbpRR6AgkJOhkEvzTnyFRVSa0QUmQbC1TR0zvsQDyCV8wXDbO/QJLVQnSKwv4cSsPsjLkkxT +OTcj7NMB+eAJRE1NZMDhDVqHIrytG6P+JrUV86f8hBnp7KGItERphIPzidF0BqnMC9bC3ieFUCbK +F7jJeodWLBoBHmy+E60QrLUk9TiRodZL2vG70t5HtfG8gfZZa88ZU+mNFctKy6lvROUbQc/hhqfK +0GqfvEyNBjNaooXlkDWgYlwWTvDjovoDGrQscbNYLN57C9saD+veIR8GdwYDsMnvmfzAuU8Lhij+ +0rnq49qlw0dpEuDb8PYZi+17cNcC1u2HGCgsBCRMd+RIihrGO5rUD8r6ddIBQFqNeb+Lz0vPqhbB +leStTIo+F5HUsWLlguWABKQDfo2/2n+iD5dPDNMN+9fR5XJ+HMh3/1uaD7euBUbl8agW7EekFwID +AQABo4H2MIHzMIGwBgNVHREEgagwgaWBD2luZm9AaXplbnBlLmNvbaSBkTCBjjFHMEUGA1UECgw+ +SVpFTlBFIFMuQS4gLSBDSUYgQTAxMzM3MjYwLVJNZXJjLlZpdG9yaWEtR2FzdGVpeiBUMTA1NSBG +NjIgUzgxQzBBBgNVBAkMOkF2ZGEgZGVsIE1lZGl0ZXJyYW5lbyBFdG9yYmlkZWEgMTQgLSAwMTAx +MCBWaXRvcmlhLUdhc3RlaXowDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0O +BBYEFB0cZQ6o8iV7tJHP5LGx5r1VdGwFMA0GCSqGSIb3DQEBCwUAA4ICAQB4pgwWSp9MiDrAyw6l +Fn2fuUhfGI8NYjb2zRlrrKvV9pF9rnHzP7MOeIWblaQnIUdCSnxIOvVFfLMMjlF4rJUT3sb9fbga +kEyrkgPH7UIBzg/YsfqikuFgba56awmqxinuaElnMIAkejEWOVt+8Rwu3WwJrfIxwYJOubv5vr8q +hT/AQKM6WfxZSzwoJNu0FXWuDYi6LnPAvViH5ULy617uHjAimcs30cQhbIHsvm0m5hzkQiCeR7Cs +g1lwLDXWrzY0tM07+DKo7+N4ifuNRSzanLh+QBxh5z6ikixL8s36mLYp//Pye6kfLqCTVyvehQP5 +aTfLnnhqBbTFMXiJ7HqnheG5ezzevh55hM6fcA5ZwjUukCox2eRFekGkLhObNA5me0mrZJfQRsN5 +nXJQY6aYWwa9SG3YOYNw6DXwBdGqvOPbyALqfP2C2sJbUjWumDqtujWTI6cfSN01RpiyEGjkpTHC +ClguGYEQyVB1/OpaFs4R1+7vUIgtYf8/QnMFlEPVjjxOAToZpR9GTnfQXeWBIiGH/pR9hNiTrdZo +Q0iy2+tzJOeRf1SktoA+naM8THLCV8Sg1Mw4J87VBp6iSNnpn86CcDaTmjvfliHjWbcM2pE38P1Z +WrOZyGlsQyYBNWNgVYkDOnXYukrZVP/u3oDYLdE41V4tC5h9Pmzb/CaIxw== +-----END CERTIFICATE----- + +Go Daddy Root Certificate Authority - G2 +======================================== +-----BEGIN CERTIFICATE----- +MIIDxTCCAq2gAwIBAgIBADANBgkqhkiG9w0BAQsFADCBgzELMAkGA1UEBhMCVVMxEDAOBgNVBAgT +B0FyaXpvbmExEzARBgNVBAcTClNjb3R0c2RhbGUxGjAYBgNVBAoTEUdvRGFkZHkuY29tLCBJbmMu +MTEwLwYDVQQDEyhHbyBEYWRkeSBSb290IENlcnRpZmljYXRlIEF1dGhvcml0eSAtIEcyMB4XDTA5 +MDkwMTAwMDAwMFoXDTM3MTIzMTIzNTk1OVowgYMxCzAJBgNVBAYTAlVTMRAwDgYDVQQIEwdBcml6 +b25hMRMwEQYDVQQHEwpTY290dHNkYWxlMRowGAYDVQQKExFHb0RhZGR5LmNvbSwgSW5jLjExMC8G +A1UEAxMoR28gRGFkZHkgUm9vdCBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkgLSBHMjCCASIwDQYJKoZI +hvcNAQEBBQADggEPADCCAQoCggEBAL9xYgjx+lk09xvJGKP3gElY6SKDE6bFIEMBO4Tx5oVJnyfq +9oQbTqC023CYxzIBsQU+B07u9PpPL1kwIuerGVZr4oAH/PMWdYA5UXvl+TW2dE6pjYIT5LY/qQOD ++qK+ihVqf94Lw7YZFAXK6sOoBJQ7RnwyDfMAZiLIjWltNowRGLfTshxgtDj6AozO091GB94KPutd +fMh8+7ArU6SSYmlRJQVhGkSBjCypQ5Yj36w6gZoOKcUcqeldHraenjAKOc7xiID7S13MMuyFYkMl +NAJWJwGRtDtwKj9useiciAF9n9T521NtYJ2/LOdYq7hfRvzOxBsDPAnrSTFcaUaz4EcCAwEAAaNC +MEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFDqahQcQZyi27/a9 +BUFuIMGU2g/eMA0GCSqGSIb3DQEBCwUAA4IBAQCZ21151fmXWWcDYfF+OwYxdS2hII5PZYe096ac +vNjpL9DbWu7PdIxztDhC2gV7+AJ1uP2lsdeu9tfeE8tTEH6KRtGX+rcuKxGrkLAngPnon1rpN5+r +5N9ss4UXnT3ZJE95kTXWXwTrgIOrmgIttRD02JDHBHNA7XIloKmf7J6raBKZV8aPEjoJpL1E/QYV +N8Gb5DKj7Tjo2GTzLH4U/ALqn83/B2gX2yKQOC16jdFU8WnjXzPKej17CuPKf1855eJ1usV2GDPO +LPAvTK33sefOT6jEm0pUBsV/fdUID+Ic/n4XuKxe9tQWskMJDE32p2u0mYRlynqI4uJEvlz36hz1 +-----END CERTIFICATE----- + +Starfield Root Certificate Authority - G2 +========================================= +-----BEGIN CERTIFICATE----- +MIID3TCCAsWgAwIBAgIBADANBgkqhkiG9w0BAQsFADCBjzELMAkGA1UEBhMCVVMxEDAOBgNVBAgT +B0FyaXpvbmExEzARBgNVBAcTClNjb3R0c2RhbGUxJTAjBgNVBAoTHFN0YXJmaWVsZCBUZWNobm9s +b2dpZXMsIEluYy4xMjAwBgNVBAMTKVN0YXJmaWVsZCBSb290IENlcnRpZmljYXRlIEF1dGhvcml0 +eSAtIEcyMB4XDTA5MDkwMTAwMDAwMFoXDTM3MTIzMTIzNTk1OVowgY8xCzAJBgNVBAYTAlVTMRAw +DgYDVQQIEwdBcml6b25hMRMwEQYDVQQHEwpTY290dHNkYWxlMSUwIwYDVQQKExxTdGFyZmllbGQg +VGVjaG5vbG9naWVzLCBJbmMuMTIwMAYDVQQDEylTdGFyZmllbGQgUm9vdCBDZXJ0aWZpY2F0ZSBB +dXRob3JpdHkgLSBHMjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAL3twQP89o/8ArFv +W59I2Z154qK3A2FWGMNHttfKPTUuiUP3oWmb3ooa/RMgnLRJdzIpVv257IzdIvpy3Cdhl+72WoTs +bhm5iSzchFvVdPtrX8WJpRBSiUZV9Lh1HOZ/5FSuS/hVclcCGfgXcVnrHigHdMWdSL5stPSksPNk +N3mSwOxGXn/hbVNMYq/NHwtjuzqd+/x5AJhhdM8mgkBj87JyahkNmcrUDnXMN/uLicFZ8WJ/X7Nf +ZTD4p7dNdloedl40wOiWVpmKs/B/pM293DIxfJHP4F8R+GuqSVzRmZTRouNjWwl2tVZi4Ut0HZbU +JtQIBFnQmA4O5t78w+wfkPECAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMC +AQYwHQYDVR0OBBYEFHwMMh+n2TB/xH1oo2Kooc6rB1snMA0GCSqGSIb3DQEBCwUAA4IBAQARWfol +TwNvlJk7mh+ChTnUdgWUXuEok21iXQnCoKjUsHU48TRqneSfioYmUeYs0cYtbpUgSpIB7LiKZ3sx +4mcujJUDJi5DnUox9g61DLu34jd/IroAow57UvtruzvE03lRTs2Q9GcHGcg8RnoNAX3FWOdt5oUw +F5okxBDgBPfg8n/Uqgr/Qh037ZTlZFkSIHc40zI+OIF1lnP6aI+xy84fxez6nH7PfrHxBy22/L/K +pL/QlwVKvOoYKAKQvVR4CSFx09F9HdkWsKlhPdAKACL8x3vLCWRFCztAgfd9fDL1mMpYjn0q7pBZ +c2T5NnReJaH1ZgUufzkVqSr7UIuOhWn0 +-----END CERTIFICATE----- + +Starfield Services Root Certificate Authority - G2 +================================================== +-----BEGIN CERTIFICATE----- +MIID7zCCAtegAwIBAgIBADANBgkqhkiG9w0BAQsFADCBmDELMAkGA1UEBhMCVVMxEDAOBgNVBAgT +B0FyaXpvbmExEzARBgNVBAcTClNjb3R0c2RhbGUxJTAjBgNVBAoTHFN0YXJmaWVsZCBUZWNobm9s +b2dpZXMsIEluYy4xOzA5BgNVBAMTMlN0YXJmaWVsZCBTZXJ2aWNlcyBSb290IENlcnRpZmljYXRl +IEF1dGhvcml0eSAtIEcyMB4XDTA5MDkwMTAwMDAwMFoXDTM3MTIzMTIzNTk1OVowgZgxCzAJBgNV +BAYTAlVTMRAwDgYDVQQIEwdBcml6b25hMRMwEQYDVQQHEwpTY290dHNkYWxlMSUwIwYDVQQKExxT +dGFyZmllbGQgVGVjaG5vbG9naWVzLCBJbmMuMTswOQYDVQQDEzJTdGFyZmllbGQgU2VydmljZXMg +Um9vdCBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkgLSBHMjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC +AQoCggEBANUMOsQq+U7i9b4Zl1+OiFOxHz/Lz58gE20pOsgPfTz3a3Y4Y9k2YKibXlwAgLIvWX/2 +h/klQ4bnaRtSmpDhcePYLQ1Ob/bISdm28xpWriu2dBTrz/sm4xq6HZYuajtYlIlHVv8loJNwU4Pa +hHQUw2eeBGg6345AWh1KTs9DkTvnVtYAcMtS7nt9rjrnvDH5RfbCYM8TWQIrgMw0R9+53pBlbQLP +LJGmpufehRhJfGZOozptqbXuNC66DQO4M99H67FrjSXZm86B0UVGMpZwh94CDklDhbZsc7tk6mFB +rMnUVN+HL8cisibMn1lUaJ/8viovxFUcdUBgF4UCVTmLfwUCAwEAAaNCMEAwDwYDVR0TAQH/BAUw +AwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFJxfAN+qAdcwKziIorhtSpzyEZGDMA0GCSqG +SIb3DQEBCwUAA4IBAQBLNqaEd2ndOxmfZyMIbw5hyf2E3F/YNoHN2BtBLZ9g3ccaaNnRbobhiCPP +E95Dz+I0swSdHynVv/heyNXBve6SbzJ08pGCL72CQnqtKrcgfU28elUSwhXqvfdqlS5sdJ/PHLTy +xQGjhdByPq1zqwubdQxtRbeOlKyWN7Wg0I8VRw7j6IPdj/3vQQF3zCepYoUz8jcI73HPdwbeyBkd +iEDPfUYd/x7H4c7/I9vG+o1VTqkC50cRRj70/b17KSa7qWFiNyi2LSr2EIZkyXCn0q23KXB56jza +YyWf/Wi3MOxw+3WKt21gZ7IeyLnp2KhvAotnDU0mV3HaIPzBSlCNsSi6 +-----END CERTIFICATE----- + +Certum Trusted Network CA +========================= +-----BEGIN CERTIFICATE----- +MIIDuzCCAqOgAwIBAgIDBETAMA0GCSqGSIb3DQEBBQUAMH4xCzAJBgNVBAYTAlBMMSIwIAYDVQQK +ExlVbml6ZXRvIFRlY2hub2xvZ2llcyBTLkEuMScwJQYDVQQLEx5DZXJ0dW0gQ2VydGlmaWNhdGlv +biBBdXRob3JpdHkxIjAgBgNVBAMTGUNlcnR1bSBUcnVzdGVkIE5ldHdvcmsgQ0EwHhcNMDgxMDIy +MTIwNzM3WhcNMjkxMjMxMTIwNzM3WjB+MQswCQYDVQQGEwJQTDEiMCAGA1UEChMZVW5pemV0byBU +ZWNobm9sb2dpZXMgUy5BLjEnMCUGA1UECxMeQ2VydHVtIENlcnRpZmljYXRpb24gQXV0aG9yaXR5 +MSIwIAYDVQQDExlDZXJ0dW0gVHJ1c3RlZCBOZXR3b3JrIENBMIIBIjANBgkqhkiG9w0BAQEFAAOC +AQ8AMIIBCgKCAQEA4/t9o3K6wvDJFIf1awFO4W5AB7ptJ11/91sts1rHUV+rpDKmYYe2bg+G0jAC +l/jXaVehGDldamR5xgFZrDwxSjh80gTSSyjoIF87B6LMTXPb865Px1bVWqeWifrzq2jUI4ZZJ88J +J7ysbnKDHDBy3+Ci6dLhdHUZvSqeexVUBBvXQzmtVSjF4hq79MDkrjhJM8x2hZ85RdKknvISjFH4 +fOQtf/WsX+sWn7Et0brMkUJ3TCXJkDhv2/DM+44el1k+1WBO5gUo7Ul5E0u6SNsv+XLTOcr+H9g0 +cvW0QM8xAcPs3hEtF10fuFDRXhmnad4HMyjKUJX5p1TLVIZQRan5SQIDAQABo0IwQDAPBgNVHRMB +Af8EBTADAQH/MB0GA1UdDgQWBBQIds3LB/8k9sXN7buQvOKEN0Z19zAOBgNVHQ8BAf8EBAMCAQYw +DQYJKoZIhvcNAQEFBQADggEBAKaorSLOAT2mo/9i0Eidi15ysHhE49wcrwn9I0j6vSrEuVUEtRCj +jSfeC4Jj0O7eDDd5QVsisrCaQVymcODU0HfLI9MA4GxWL+FpDQ3Zqr8hgVDZBqWo/5U30Kr+4rP1 +mS1FhIrlQgnXdAIv94nYmem8J9RHjboNRhx3zxSkHLmkMcScKHQDNP8zGSal6Q10tz6XxnboJ5aj +Zt3hrvJBW8qYVoNzcOSGGtIxQbovvi0TWnZvTuhOgQ4/WwMioBK+ZlgRSssDxLQqKi2WF+A5VLxI +03YnnZotBqbJ7DnSq9ufmgsnAjUpsUCV5/nonFWIGUbWtzT1fs45mtk48VH3Tyw= +-----END CERTIFICATE----- + +TWCA Root Certification Authority +================================= +-----BEGIN CERTIFICATE----- +MIIDezCCAmOgAwIBAgIBATANBgkqhkiG9w0BAQUFADBfMQswCQYDVQQGEwJUVzESMBAGA1UECgwJ +VEFJV0FOLUNBMRAwDgYDVQQLDAdSb290IENBMSowKAYDVQQDDCFUV0NBIFJvb3QgQ2VydGlmaWNh +dGlvbiBBdXRob3JpdHkwHhcNMDgwODI4MDcyNDMzWhcNMzAxMjMxMTU1OTU5WjBfMQswCQYDVQQG +EwJUVzESMBAGA1UECgwJVEFJV0FOLUNBMRAwDgYDVQQLDAdSb290IENBMSowKAYDVQQDDCFUV0NB +IFJvb3QgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK +AoIBAQCwfnK4pAOU5qfeCTiRShFAh6d8WWQUe7UREN3+v9XAu1bihSX0NXIP+FPQQeFEAcK0HMMx +QhZHhTMidrIKbw/lJVBPhYa+v5guEGcevhEFhgWQxFnQfHgQsIBct+HHK3XLfJ+utdGdIzdjp9xC +oi2SBBtQwXu4PhvJVgSLL1KbralW6cH/ralYhzC2gfeXRfwZVzsrb+RH9JlF/h3x+JejiB03HFyP +4HYlmlD4oFT/RJB2I9IyxsOrBr/8+7/zrX2SYgJbKdM1o5OaQ2RgXbL6Mv87BK9NQGr5x+PvI/1r +y+UPizgN7gr8/g+YnzAx3WxSZfmLgb4i4RxYA7qRG4kHAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIB +BjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRqOFsmjd6LWvJPelSDGRjjCDWmujANBgkqhkiG +9w0BAQUFAAOCAQEAPNV3PdrfibqHDAhUaiBQkr6wQT25JmSDCi/oQMCXKCeCMErJk/9q56YAf4lC +mtYR5VPOL8zy2gXE/uJQxDqGfczafhAJO5I1KlOy/usrBdlsXebQ79NqZp4VKIV66IIArB6nCWlW +QtNoURi+VJq/REG6Sb4gumlc7rh3zc5sH62Dlhh9DrUUOYTxKOkto557HnpyWoOzeW/vtPzQCqVY +T0bf+215WfKEIlKuD8z7fDvnaspHYcN6+NOSBB+4IIThNlQWx0DeO4pz3N/GCUzf7Nr/1FNCocny +Yh0igzyXxfkZYiesZSLX0zzG5Y6yU8xJzrww/nsOM5D77dIUkR8Hrw== +-----END CERTIFICATE----- + +Security Communication RootCA2 +============================== +-----BEGIN CERTIFICATE----- +MIIDdzCCAl+gAwIBAgIBADANBgkqhkiG9w0BAQsFADBdMQswCQYDVQQGEwJKUDElMCMGA1UEChMc +U0VDT00gVHJ1c3QgU3lzdGVtcyBDTy4sTFRELjEnMCUGA1UECxMeU2VjdXJpdHkgQ29tbXVuaWNh +dGlvbiBSb290Q0EyMB4XDTA5MDUyOTA1MDAzOVoXDTI5MDUyOTA1MDAzOVowXTELMAkGA1UEBhMC +SlAxJTAjBgNVBAoTHFNFQ09NIFRydXN0IFN5c3RlbXMgQ08uLExURC4xJzAlBgNVBAsTHlNlY3Vy +aXR5IENvbW11bmljYXRpb24gUm9vdENBMjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB +ANAVOVKxUrO6xVmCxF1SrjpDZYBLx/KWvNs2l9amZIyoXvDjChz335c9S672XewhtUGrzbl+dp++ ++T42NKA7wfYxEUV0kz1XgMX5iZnK5atq1LXaQZAQwdbWQonCv/Q4EpVMVAX3NuRFg3sUZdbcDE3R +3n4MqzvEFb46VqZab3ZpUql6ucjrappdUtAtCms1FgkQhNBqyjoGADdH5H5XTz+L62e4iKrFvlNV +spHEfbmwhRkGeC7bYRr6hfVKkaHnFtWOojnflLhwHyg/i/xAXmODPIMqGplrz95Zajv8bxbXH/1K +EOtOghY6rCcMU/Gt1SSwawNQwS08Ft1ENCcadfsCAwEAAaNCMEAwHQYDVR0OBBYEFAqFqXdlBZh8 +QIH4D5csOPEK7DzPMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEB +CwUAA4IBAQBMOqNErLlFsceTfsgLCkLfZOoc7llsCLqJX2rKSpWeeo8HxdpFcoJxDjrSzG+ntKEj +u/Ykn8sX/oymzsLS28yN/HH8AynBbF0zX2S2ZTuJbxh2ePXcokgfGT+Ok+vx+hfuzU7jBBJV1uXk +3fs+BXziHV7Gp7yXT2g69ekuCkO2r1dcYmh8t/2jioSgrGK+KwmHNPBqAbubKVY8/gA3zyNs8U6q +tnRGEmyR7jTV7JqR50S+kDFy1UkC9gLl9B/rfNmWVan/7Ir5mUf/NVoCqgTLiluHcSmRvaS0eg29 +mvVXIwAHIRc/SjnRBUkLp7Y3gaVdjKozXoEofKd9J+sAro03 +-----END CERTIFICATE----- + +Actalis Authentication Root CA +============================== +-----BEGIN CERTIFICATE----- +MIIFuzCCA6OgAwIBAgIIVwoRl0LE48wwDQYJKoZIhvcNAQELBQAwazELMAkGA1UEBhMCSVQxDjAM +BgNVBAcMBU1pbGFuMSMwIQYDVQQKDBpBY3RhbGlzIFMucC5BLi8wMzM1ODUyMDk2NzEnMCUGA1UE +AwweQWN0YWxpcyBBdXRoZW50aWNhdGlvbiBSb290IENBMB4XDTExMDkyMjExMjIwMloXDTMwMDky +MjExMjIwMlowazELMAkGA1UEBhMCSVQxDjAMBgNVBAcMBU1pbGFuMSMwIQYDVQQKDBpBY3RhbGlz +IFMucC5BLi8wMzM1ODUyMDk2NzEnMCUGA1UEAwweQWN0YWxpcyBBdXRoZW50aWNhdGlvbiBSb290 +IENBMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAp8bEpSmkLO/lGMWwUKNvUTufClrJ +wkg4CsIcoBh/kbWHuUA/3R1oHwiD1S0eiKD4j1aPbZkCkpAW1V8IbInX4ay8IMKx4INRimlNAJZa +by/ARH6jDuSRzVju3PvHHkVH3Se5CAGfpiEd9UEtL0z9KK3giq0itFZljoZUj5NDKd45RnijMCO6 +zfB9E1fAXdKDa0hMxKufgFpbOr3JpyI/gCczWw63igxdBzcIy2zSekciRDXFzMwujt0q7bd9Zg1f +YVEiVRvjRuPjPdA1YprbrxTIW6HMiRvhMCb8oJsfgadHHwTrozmSBp+Z07/T6k9QnBn+locePGX2 +oxgkg4YQ51Q+qDp2JE+BIcXjDwL4k5RHILv+1A7TaLndxHqEguNTVHnd25zS8gebLra8Pu2Fbe8l +EfKXGkJh90qX6IuxEAf6ZYGyojnP9zz/GPvG8VqLWeICrHuS0E4UT1lF9gxeKF+w6D9Fz8+vm2/7 +hNN3WpVvrJSEnu68wEqPSpP4RCHiMUVhUE4Q2OM1fEwZtN4Fv6MGn8i1zeQf1xcGDXqVdFUNaBr8 +EBtiZJ1t4JWgw5QHVw0U5r0F+7if5t+L4sbnfpb2U8WANFAoWPASUHEXMLrmeGO89LKtmyuy/uE5 +jF66CyCU3nuDuP/jVo23Eek7jPKxwV2dpAtMK9myGPW1n0sCAwEAAaNjMGEwHQYDVR0OBBYEFFLY +iDrIn3hm7YnzezhwlMkCAjbQMA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAUUtiIOsifeGbt +ifN7OHCUyQICNtAwDgYDVR0PAQH/BAQDAgEGMA0GCSqGSIb3DQEBCwUAA4ICAQALe3KHwGCmSUyI +WOYdiPcUZEim2FgKDk8TNd81HdTtBjHIgT5q1d07GjLukD0R0i70jsNjLiNmsGe+b7bAEzlgqqI0 +JZN1Ut6nna0Oh4lScWoWPBkdg/iaKWW+9D+a2fDzWochcYBNy+A4mz+7+uAwTc+G02UQGRjRlwKx +K3JCaKygvU5a2hi/a5iB0P2avl4VSM0RFbnAKVy06Ij3Pjaut2L9HmLecHgQHEhb2rykOLpn7VU+ +Xlff1ANATIGk0k9jpwlCCRT8AKnCgHNPLsBA2RF7SOp6AsDT6ygBJlh0wcBzIm2Tlf05fbsq4/aC +4yyXX04fkZT6/iyj2HYauE2yOE+b+h1IYHkm4vP9qdCa6HCPSXrW5b0KDtst842/6+OkfcvHlXHo +2qN8xcL4dJIEG4aspCJTQLas/kx2z/uUMsA1n3Y/buWQbqCmJqK4LL7RK4X9p2jIugErsWx0Hbhz +lefut8cl8ABMALJ+tguLHPPAUJ4lueAI3jZm/zel0btUZCzJJ7VLkn5l/9Mt4blOvH+kQSGQQXem +OR/qnuOf0GZvBeyqdn6/axag67XH/JJULysRJyU3eExRarDzzFhdFPFqSBX/wge2sY0PjlxQRrM9 +vwGYT7JZVEc+NHt4bVaTLnPqZih4zR0Uv6CPLy64Lo7yFIrM6bV8+2ydDKXhlg== +-----END CERTIFICATE----- + +Buypass Class 2 Root CA +======================= +-----BEGIN CERTIFICATE----- +MIIFWTCCA0GgAwIBAgIBAjANBgkqhkiG9w0BAQsFADBOMQswCQYDVQQGEwJOTzEdMBsGA1UECgwU +QnV5cGFzcyBBUy05ODMxNjMzMjcxIDAeBgNVBAMMF0J1eXBhc3MgQ2xhc3MgMiBSb290IENBMB4X +DTEwMTAyNjA4MzgwM1oXDTQwMTAyNjA4MzgwM1owTjELMAkGA1UEBhMCTk8xHTAbBgNVBAoMFEJ1 +eXBhc3MgQVMtOTgzMTYzMzI3MSAwHgYDVQQDDBdCdXlwYXNzIENsYXNzIDIgUm9vdCBDQTCCAiIw +DQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANfHXvfBB9R3+0Mh9PT1aeTuMgHbo4Yf5FkNuud1 +g1Lr6hxhFUi7HQfKjK6w3Jad6sNgkoaCKHOcVgb/S2TwDCo3SbXlzwx87vFKu3MwZfPVL4O2fuPn +9Z6rYPnT8Z2SdIrkHJasW4DptfQxh6NR/Md+oW+OU3fUl8FVM5I+GC911K2GScuVr1QGbNgGE41b +/+EmGVnAJLqBcXmQRFBoJJRfuLMR8SlBYaNByyM21cHxMlAQTn/0hpPshNOOvEu/XAFOBz3cFIqU +CqTqc/sLUegTBxj6DvEr0VQVfTzh97QZQmdiXnfgolXsttlpF9U6r0TtSsWe5HonfOV116rLJeff +awrbD02TTqigzXsu8lkBarcNuAeBfos4GzjmCleZPe4h6KP1DBbdi+w0jpwqHAAVF41og9JwnxgI +zRFo1clrUs3ERo/ctfPYV3Me6ZQ5BL/T3jjetFPsaRyifsSP5BtwrfKi+fv3FmRmaZ9JUaLiFRhn +Bkp/1Wy1TbMz4GHrXb7pmA8y1x1LPC5aAVKRCfLf6o3YBkBjqhHk/sM3nhRSP/TizPJhk9H9Z2vX +Uq6/aKtAQ6BXNVN48FP4YUIHZMbXb5tMOA1jrGKvNouicwoN9SG9dKpN6nIDSdvHXx1iY8f93ZHs +M+71bbRuMGjeyNYmsHVee7QHIJihdjK4TWxPAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wHQYD +VR0OBBYEFMmAd+BikoL1RpzzuvdMw964o605MA4GA1UdDwEB/wQEAwIBBjANBgkqhkiG9w0BAQsF +AAOCAgEAU18h9bqwOlI5LJKwbADJ784g7wbylp7ppHR/ehb8t/W2+xUbP6umwHJdELFx7rxP462s +A20ucS6vxOOto70MEae0/0qyexAQH6dXQbLArvQsWdZHEIjzIVEpMMpghq9Gqx3tOluwlN5E40EI +osHsHdb9T7bWR9AUC8rmyrV7d35BH16Dx7aMOZawP5aBQW9gkOLo+fsicdl9sz1Gv7SEr5AcD48S +aq/v7h56rgJKihcrdv6sVIkkLE8/trKnToyokZf7KcZ7XC25y2a2t6hbElGFtQl+Ynhw/qlqYLYd +DnkM/crqJIByw5c/8nerQyIKx+u2DISCLIBrQYoIwOula9+ZEsuK1V6ADJHgJgg2SMX6OBE1/yWD +LfJ6v9r9jv6ly0UsH8SIU653DtmadsWOLB2jutXsMq7Aqqz30XpN69QH4kj3Io6wpJ9qzo6ysmD0 +oyLQI+uUWnpp3Q+/QFesa1lQ2aOZ4W7+jQF5JyMV3pKdewlNWudLSDBaGOYKbeaP4NK75t98biGC +wWg5TbSYWGZizEqQXsP6JwSxeRV0mcy+rSDeJmAc61ZRpqPq5KM/p/9h3PFaTWwyI0PurKju7koS +CTxdccK+efrCh2gdC/1cacwG0Jp9VJkqyTkaGa9LKkPzY11aWOIv4x3kqdbQCtCev9eBCfHJxyYN +rJgWVqA= +-----END CERTIFICATE----- + +Buypass Class 3 Root CA +======================= +-----BEGIN CERTIFICATE----- +MIIFWTCCA0GgAwIBAgIBAjANBgkqhkiG9w0BAQsFADBOMQswCQYDVQQGEwJOTzEdMBsGA1UECgwU +QnV5cGFzcyBBUy05ODMxNjMzMjcxIDAeBgNVBAMMF0J1eXBhc3MgQ2xhc3MgMyBSb290IENBMB4X +DTEwMTAyNjA4Mjg1OFoXDTQwMTAyNjA4Mjg1OFowTjELMAkGA1UEBhMCTk8xHTAbBgNVBAoMFEJ1 +eXBhc3MgQVMtOTgzMTYzMzI3MSAwHgYDVQQDDBdCdXlwYXNzIENsYXNzIDMgUm9vdCBDQTCCAiIw +DQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAKXaCpUWUOOV8l6ddjEGMnqb8RB2uACatVI2zSRH +sJ8YZLya9vrVediQYkwiL944PdbgqOkcLNt4EemOaFEVcsfzM4fkoF0LXOBXByow9c3EN3coTRiR +5r/VUv1xLXA+58bEiuPwKAv0dpihi4dVsjoT/Lc+JzeOIuOoTyrvYLs9tznDDgFHmV0ST9tD+leh +7fmdvhFHJlsTmKtdFoqwNxxXnUX/iJY2v7vKB3tvh2PX0DJq1l1sDPGzbjniazEuOQAnFN44wOwZ +ZoYS6J1yFhNkUsepNxz9gjDthBgd9K5c/3ATAOux9TN6S9ZV+AWNS2mw9bMoNlwUxFFzTWsL8TQH +2xc519woe2v1n/MuwU8XKhDzzMro6/1rqy6any2CbgTUUgGTLT2G/H783+9CHaZr77kgxve9oKeV +/afmiSTYzIw0bOIjL9kSGiG5VZFvC5F5GQytQIgLcOJ60g7YaEi7ghM5EFjp2CoHxhLbWNvSO1UQ +RwUVZ2J+GGOmRj8JDlQyXr8NYnon74Do29lLBlo3WiXQCBJ31G8JUJc9yB3D34xFMFbG02SrZvPA +Xpacw8Tvw3xrizp5f7NJzz3iiZ+gMEuFuZyUJHmPfWupRWgPK9Dx2hzLabjKSWJtyNBjYt1gD1iq +j6G8BaVmos8bdrKEZLFMOVLAMLrwjEsCsLa3AgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wHQYD +VR0OBBYEFEe4zf/lb+74suwvTg75JbCOPGvDMA4GA1UdDwEB/wQEAwIBBjANBgkqhkiG9w0BAQsF +AAOCAgEAACAjQTUEkMJAYmDv4jVM1z+s4jSQuKFvdvoWFqRINyzpkMLyPPgKn9iB5btb2iUspKdV +cSQy9sgL8rxq+JOssgfCX5/bzMiKqr5qb+FJEMwx14C7u8jYog5kV+qi9cKpMRXSIGrs/CIBKM+G +uIAeqcwRpTzyFrNHnfzSgCHEy9BHcEGhyoMZCCxt8l13nIoUE9Q2HJLw5QY33KbmkJs4j1xrG0aG +Q0JfPgEHU1RdZX33inOhmlRaHylDFCfChQ+1iHsaO5S3HWCntZznKWlXWpuTekMwGwPXYshApqr8 +ZORK15FTAaggiG6cX0S5y2CBNOxv033aSF/rtJC8LakcC6wc1aJoIIAE1vyxjy+7SjENSoYc6+I2 +KSb12tjE8nVhz36udmNKekBlk4f4HoCMhuWG1o8O/FMsYOgWYRqiPkN7zTlgVGr18okmAWiDSKIz +6MkEkbIRNBE+6tBDGR8Dk5AM/1E9V/RBbuHLoL7ryWPNbczk+DaqaJ3tvV2XcEQNtg413OEMXbug +UZTLfhbrES+jkkXITHHZvMmZUldGL1DPvTVp9D0VzgalLA8+9oG6lLvDu79leNKGef9JOxqDDPDe +eOzI8k1MGt6CKfjBWtrt7uYnXuhF0J0cUahoq0Tj0Itq4/g7u9xN12TyUb7mqqta6THuBrxzvxNi +Cp/HuZc= +-----END CERTIFICATE----- + +T-TeleSec GlobalRoot Class 3 +============================ +-----BEGIN CERTIFICATE----- +MIIDwzCCAqugAwIBAgIBATANBgkqhkiG9w0BAQsFADCBgjELMAkGA1UEBhMCREUxKzApBgNVBAoM +IlQtU3lzdGVtcyBFbnRlcnByaXNlIFNlcnZpY2VzIEdtYkgxHzAdBgNVBAsMFlQtU3lzdGVtcyBU +cnVzdCBDZW50ZXIxJTAjBgNVBAMMHFQtVGVsZVNlYyBHbG9iYWxSb290IENsYXNzIDMwHhcNMDgx +MDAxMTAyOTU2WhcNMzMxMDAxMjM1OTU5WjCBgjELMAkGA1UEBhMCREUxKzApBgNVBAoMIlQtU3lz +dGVtcyBFbnRlcnByaXNlIFNlcnZpY2VzIEdtYkgxHzAdBgNVBAsMFlQtU3lzdGVtcyBUcnVzdCBD +ZW50ZXIxJTAjBgNVBAMMHFQtVGVsZVNlYyBHbG9iYWxSb290IENsYXNzIDMwggEiMA0GCSqGSIb3 +DQEBAQUAA4IBDwAwggEKAoIBAQC9dZPwYiJvJK7genasfb3ZJNW4t/zN8ELg63iIVl6bmlQdTQyK +9tPPcPRStdiTBONGhnFBSivwKixVA9ZIw+A5OO3yXDw/RLyTPWGrTs0NvvAgJ1gORH8EGoel15YU +NpDQSXuhdfsaa3Ox+M6pCSzyU9XDFES4hqX2iys52qMzVNn6chr3IhUciJFrf2blw2qAsCTz34ZF +iP0Zf3WHHx+xGwpzJFu5ZeAsVMhg02YXP+HMVDNzkQI6pn97djmiH5a2OK61yJN0HZ65tOVgnS9W +0eDrXltMEnAMbEQgqxHY9Bn20pxSN+f6tsIxO0rUFJmtxxr1XV/6B7h8DR/Wgx6zAgMBAAGjQjBA +MA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBS1A/d2O2GCahKqGFPr +AyGUv/7OyjANBgkqhkiG9w0BAQsFAAOCAQEAVj3vlNW92nOyWL6ukK2YJ5f+AbGwUgC4TeQbIXQb +fsDuXmkqJa9c1h3a0nnJ85cp4IaH3gRZD/FZ1GSFS5mvJQQeyUapl96Cshtwn5z2r3Ex3XsFpSzT +ucpH9sry9uetuUg/vBa3wW306gmv7PO15wWeph6KU1HWk4HMdJP2udqmJQV0eVp+QD6CSyYRMG7h +P0HHRwA11fXT91Q+gT3aSWqas+8QPebrb9HIIkfLzM8BMZLZGOMivgkeGj5asuRrDFR6fUNOuIml +e9eiPZaGzPImNC1qkp2aGtAw4l1OBLBfiyB+d8E9lYLRRpo7PHi4b6HQDWSieB4pTpPDpFQUWw== +-----END CERTIFICATE----- + +D-TRUST Root Class 3 CA 2 2009 +============================== +-----BEGIN CERTIFICATE----- +MIIEMzCCAxugAwIBAgIDCYPzMA0GCSqGSIb3DQEBCwUAME0xCzAJBgNVBAYTAkRFMRUwEwYDVQQK +DAxELVRydXN0IEdtYkgxJzAlBgNVBAMMHkQtVFJVU1QgUm9vdCBDbGFzcyAzIENBIDIgMjAwOTAe +Fw0wOTExMDUwODM1NThaFw0yOTExMDUwODM1NThaME0xCzAJBgNVBAYTAkRFMRUwEwYDVQQKDAxE +LVRydXN0IEdtYkgxJzAlBgNVBAMMHkQtVFJVU1QgUm9vdCBDbGFzcyAzIENBIDIgMjAwOTCCASIw +DQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANOySs96R+91myP6Oi/WUEWJNTrGa9v+2wBoqOAD +ER03UAifTUpolDWzU9GUY6cgVq/eUXjsKj3zSEhQPgrfRlWLJ23DEE0NkVJD2IfgXU42tSHKXzlA +BF9bfsyjxiupQB7ZNoTWSPOSHjRGICTBpFGOShrvUD9pXRl/RcPHAY9RySPocq60vFYJfxLLHLGv +KZAKyVXMD9O0Gu1HNVpK7ZxzBCHQqr0ME7UAyiZsxGsMlFqVlNpQmvH/pStmMaTJOKDfHR+4CS7z +p+hnUquVH+BGPtikw8paxTGA6Eian5Rp/hnd2HN8gcqW3o7tszIFZYQ05ub9VxC1X3a/L7AQDcUC +AwEAAaOCARowggEWMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFP3aFMSfMN4hvR5COfyrYyNJ +4PGEMA4GA1UdDwEB/wQEAwIBBjCB0wYDVR0fBIHLMIHIMIGAoH6gfIZ6bGRhcDovL2RpcmVjdG9y +eS5kLXRydXN0Lm5ldC9DTj1ELVRSVVNUJTIwUm9vdCUyMENsYXNzJTIwMyUyMENBJTIwMiUyMDIw +MDksTz1ELVRydXN0JTIwR21iSCxDPURFP2NlcnRpZmljYXRlcmV2b2NhdGlvbmxpc3QwQ6BBoD+G +PWh0dHA6Ly93d3cuZC10cnVzdC5uZXQvY3JsL2QtdHJ1c3Rfcm9vdF9jbGFzc18zX2NhXzJfMjAw +OS5jcmwwDQYJKoZIhvcNAQELBQADggEBAH+X2zDI36ScfSF6gHDOFBJpiBSVYEQBrLLpME+bUMJm +2H6NMLVwMeniacfzcNsgFYbQDfC+rAF1hM5+n02/t2A7nPPKHeJeaNijnZflQGDSNiH+0LS4F9p0 +o3/U37CYAqxva2ssJSRyoWXuJVrl5jLn8t+rSfrzkGkj2wTZ51xY/GXUl77M/C4KzCUqNQT4YJEV +dT1B/yMfGchs64JTBKbkTCJNjYy6zltz7GRUUG3RnFX7acM2w4y8PIWmawomDeCTmGCufsYkl4ph +X5GOZpIJhzbNi5stPvZR1FDUWSi9g/LMKHtThm3YJohw1+qRzT65ysCQblrGXnRl11z+o+I= +-----END CERTIFICATE----- + +D-TRUST Root Class 3 CA 2 EV 2009 +================================= +-----BEGIN CERTIFICATE----- +MIIEQzCCAyugAwIBAgIDCYP0MA0GCSqGSIb3DQEBCwUAMFAxCzAJBgNVBAYTAkRFMRUwEwYDVQQK +DAxELVRydXN0IEdtYkgxKjAoBgNVBAMMIUQtVFJVU1QgUm9vdCBDbGFzcyAzIENBIDIgRVYgMjAw +OTAeFw0wOTExMDUwODUwNDZaFw0yOTExMDUwODUwNDZaMFAxCzAJBgNVBAYTAkRFMRUwEwYDVQQK +DAxELVRydXN0IEdtYkgxKjAoBgNVBAMMIUQtVFJVU1QgUm9vdCBDbGFzcyAzIENBIDIgRVYgMjAw +OTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAJnxhDRwui+3MKCOvXwEz75ivJn9gpfS +egpnljgJ9hBOlSJzmY3aFS3nBfwZcyK3jpgAvDw9rKFs+9Z5JUut8Mxk2og+KbgPCdM03TP1YtHh +zRnp7hhPTFiu4h7WDFsVWtg6uMQYZB7jM7K1iXdODL/ZlGsTl28So/6ZqQTMFexgaDbtCHu39b+T +7WYxg4zGcTSHThfqr4uRjRxWQa4iN1438h3Z0S0NL2lRp75mpoo6Kr3HGrHhFPC+Oh25z1uxav60 +sUYgovseO3Dvk5h9jHOW8sXvhXCtKSb8HgQ+HKDYD8tSg2J87otTlZCpV6LqYQXY+U3EJ/pure35 +11H3a6UCAwEAAaOCASQwggEgMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFNOUikxiEyoZLsyv +cop9NteaHNxnMA4GA1UdDwEB/wQEAwIBBjCB3QYDVR0fBIHVMIHSMIGHoIGEoIGBhn9sZGFwOi8v +ZGlyZWN0b3J5LmQtdHJ1c3QubmV0L0NOPUQtVFJVU1QlMjBSb290JTIwQ2xhc3MlMjAzJTIwQ0El +MjAyJTIwRVYlMjAyMDA5LE89RC1UcnVzdCUyMEdtYkgsQz1ERT9jZXJ0aWZpY2F0ZXJldm9jYXRp +b25saXN0MEagRKBChkBodHRwOi8vd3d3LmQtdHJ1c3QubmV0L2NybC9kLXRydXN0X3Jvb3RfY2xh +c3NfM19jYV8yX2V2XzIwMDkuY3JsMA0GCSqGSIb3DQEBCwUAA4IBAQA07XtaPKSUiO8aEXUHL7P+ +PPoeUSbrh/Yp3uDx1MYkCenBz1UbtDDZzhr+BlGmFaQt77JLvyAoJUnRpjZ3NOhk31KxEcdzes05 +nsKtjHEh8lprr988TlWvsoRlFIm5d8sqMb7Po23Pb0iUMkZv53GMoKaEGTcH8gNFCSuGdXzfX2lX +ANtu2KZyIktQ1HWYVt+3GP9DQ1CuekR78HlR10M9p9OB0/DJT7naxpeG0ILD5EJt/rDiZE4OJudA +NCa1CInXCGNjOCd1HjPqbqjdn5lPdE2BiYBL3ZqXKVwvvoFBuYz/6n1gBp7N1z3TLqMVvKjmJuVv +w9y4AyHqnxbxLFS1 +-----END CERTIFICATE----- + +CA Disig Root R2 +================ +-----BEGIN CERTIFICATE----- +MIIFaTCCA1GgAwIBAgIJAJK4iNuwisFjMA0GCSqGSIb3DQEBCwUAMFIxCzAJBgNVBAYTAlNLMRMw +EQYDVQQHEwpCcmF0aXNsYXZhMRMwEQYDVQQKEwpEaXNpZyBhLnMuMRkwFwYDVQQDExBDQSBEaXNp +ZyBSb290IFIyMB4XDTEyMDcxOTA5MTUzMFoXDTQyMDcxOTA5MTUzMFowUjELMAkGA1UEBhMCU0sx +EzARBgNVBAcTCkJyYXRpc2xhdmExEzARBgNVBAoTCkRpc2lnIGEucy4xGTAXBgNVBAMTEENBIERp +c2lnIFJvb3QgUjIwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCio8QACdaFXS1tFPbC +w3OeNcJxVX6B+6tGUODBfEl45qt5WDza/3wcn9iXAng+a0EE6UG9vgMsRfYvZNSrXaNHPWSb6Wia +xswbP7q+sos0Ai6YVRn8jG+qX9pMzk0DIaPY0jSTVpbLTAwAFjxfGs3Ix2ymrdMxp7zo5eFm1tL7 +A7RBZckQrg4FY8aAamkw/dLukO8NJ9+flXP04SXabBbeQTg06ov80egEFGEtQX6sx3dOy1FU+16S +GBsEWmjGycT6txOgmLcRK7fWV8x8nhfRyyX+hk4kLlYMeE2eARKmK6cBZW58Yh2EhN/qwGu1pSqV +g8NTEQxzHQuyRpDRQjrOQG6Vrf/GlK1ul4SOfW+eioANSW1z4nuSHsPzwfPrLgVv2RvPN3YEyLRa +5Beny912H9AZdugsBbPWnDTYltxhh5EF5EQIM8HauQhl1K6yNg3ruji6DOWbnuuNZt2Zz9aJQfYE +koopKW1rOhzndX0CcQ7zwOe9yxndnWCywmZgtrEE7snmhrmaZkCo5xHtgUUDi/ZnWejBBhG93c+A +Ak9lQHhcR1DIm+YfgXvkRKhbhZri3lrVx/k6RGZL5DJUfORsnLMOPReisjQS1n6yqEm70XooQL6i +Fh/f5DcfEXP7kAplQ6INfPgGAVUzfbANuPT1rqVCV3w2EYx7XsQDnYx5nQIDAQABo0IwQDAPBgNV +HRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUtZn4r7CU9eMg1gqtzk5WpC5u +Qu0wDQYJKoZIhvcNAQELBQADggIBACYGXnDnZTPIgm7ZnBc6G3pmsgH2eDtpXi/q/075KMOYKmFM +tCQSin1tERT3nLXK5ryeJ45MGcipvXrA1zYObYVybqjGom32+nNjf7xueQgcnYqfGopTpti72TVV +sRHFqQOzVju5hJMiXn7B9hJSi+osZ7z+Nkz1uM/Rs0mSO9MpDpkblvdhuDvEK7Z4bLQjb/D907Je +dR+Zlais9trhxTF7+9FGs9K8Z7RiVLoJ92Owk6Ka+elSLotgEqv89WBW7xBci8QaQtyDW2QOy7W8 +1k/BfDxujRNt+3vrMNDcTa/F1balTFtxyegxvug4BkihGuLq0t4SOVga/4AOgnXmt8kHbA7v/zjx +mHHEt38OFdAlab0inSvtBfZGR6ztwPDUO+Ls7pZbkBNOHlY667DvlruWIxG68kOGdGSVyCh13x01 +utI3gzhTODY7z2zp+WsO0PsE6E9312UBeIYMej4hYvF/Y3EMyZ9E26gnonW+boE+18DrG5gPcFw0 +sorMwIUY6256s/daoQe/qUKS82Ail+QUoQebTnbAjn39pCXHR+3/H3OszMOl6W8KjptlwlCFtaOg +UxLMVYdh84GuEEZhvUQhuMI9dM9+JDX6HAcOmz0iyu8xL4ysEr3vQCj8KWefshNPZiTEUxnpHikV +7+ZtsH8tZ/3zbBt1RqPlShfppNcL +-----END CERTIFICATE----- + +ACCVRAIZ1 +========= +-----BEGIN CERTIFICATE----- +MIIH0zCCBbugAwIBAgIIXsO3pkN/pOAwDQYJKoZIhvcNAQEFBQAwQjESMBAGA1UEAwwJQUNDVlJB +SVoxMRAwDgYDVQQLDAdQS0lBQ0NWMQ0wCwYDVQQKDARBQ0NWMQswCQYDVQQGEwJFUzAeFw0xMTA1 +MDUwOTM3MzdaFw0zMDEyMzEwOTM3MzdaMEIxEjAQBgNVBAMMCUFDQ1ZSQUlaMTEQMA4GA1UECwwH +UEtJQUNDVjENMAsGA1UECgwEQUNDVjELMAkGA1UEBhMCRVMwggIiMA0GCSqGSIb3DQEBAQUAA4IC +DwAwggIKAoICAQCbqau/YUqXry+XZpp0X9DZlv3P4uRm7x8fRzPCRKPfmt4ftVTdFXxpNRFvu8gM +jmoYHtiP2Ra8EEg2XPBjs5BaXCQ316PWywlxufEBcoSwfdtNgM3802/J+Nq2DoLSRYWoG2ioPej0 +RGy9ocLLA76MPhMAhN9KSMDjIgro6TenGEyxCQ0jVn8ETdkXhBilyNpAlHPrzg5XPAOBOp0KoVdD +aaxXbXmQeOW1tDvYvEyNKKGno6e6Ak4l0Squ7a4DIrhrIA8wKFSVf+DuzgpmndFALW4ir50awQUZ +0m/A8p/4e7MCQvtQqR0tkw8jq8bBD5L/0KIV9VMJcRz/RROE5iZe+OCIHAr8Fraocwa48GOEAqDG +WuzndN9wrqODJerWx5eHk6fGioozl2A3ED6XPm4pFdahD9GILBKfb6qkxkLrQaLjlUPTAYVtjrs7 +8yM2x/474KElB0iryYl0/wiPgL/AlmXz7uxLaL2diMMxs0Dx6M/2OLuc5NF/1OVYm3z61PMOm3WR +5LpSLhl+0fXNWhn8ugb2+1KoS5kE3fj5tItQo05iifCHJPqDQsGH+tUtKSpacXpkatcnYGMN285J +9Y0fkIkyF/hzQ7jSWpOGYdbhdQrqeWZ2iE9x6wQl1gpaepPluUsXQA+xtrn13k/c4LOsOxFwYIRK +Q26ZIMApcQrAZQIDAQABo4ICyzCCAscwfQYIKwYBBQUHAQEEcTBvMEwGCCsGAQUFBzAChkBodHRw +Oi8vd3d3LmFjY3YuZXMvZmlsZWFkbWluL0FyY2hpdm9zL2NlcnRpZmljYWRvcy9yYWl6YWNjdjEu +Y3J0MB8GCCsGAQUFBzABhhNodHRwOi8vb2NzcC5hY2N2LmVzMB0GA1UdDgQWBBTSh7Tj3zcnk1X2 +VuqB5TbMjB4/vTAPBgNVHRMBAf8EBTADAQH/MB8GA1UdIwQYMBaAFNKHtOPfNyeTVfZW6oHlNsyM +Hj+9MIIBcwYDVR0gBIIBajCCAWYwggFiBgRVHSAAMIIBWDCCASIGCCsGAQUFBwICMIIBFB6CARAA +QQB1AHQAbwByAGkAZABhAGQAIABkAGUAIABDAGUAcgB0AGkAZgBpAGMAYQBjAGkA8wBuACAAUgBh +AO0AegAgAGQAZQAgAGwAYQAgAEEAQwBDAFYAIAAoAEEAZwBlAG4AYwBpAGEAIABkAGUAIABUAGUA +YwBuAG8AbABvAGcA7QBhACAAeQAgAEMAZQByAHQAaQBmAGkAYwBhAGMAaQDzAG4AIABFAGwAZQBj +AHQAcgDzAG4AaQBjAGEALAAgAEMASQBGACAAUQA0ADYAMAAxADEANQA2AEUAKQAuACAAQwBQAFMA +IABlAG4AIABoAHQAdABwADoALwAvAHcAdwB3AC4AYQBjAGMAdgAuAGUAczAwBggrBgEFBQcCARYk +aHR0cDovL3d3dy5hY2N2LmVzL2xlZ2lzbGFjaW9uX2MuaHRtMFUGA1UdHwROMEwwSqBIoEaGRGh0 +dHA6Ly93d3cuYWNjdi5lcy9maWxlYWRtaW4vQXJjaGl2b3MvY2VydGlmaWNhZG9zL3JhaXphY2N2 +MV9kZXIuY3JsMA4GA1UdDwEB/wQEAwIBBjAXBgNVHREEEDAOgQxhY2N2QGFjY3YuZXMwDQYJKoZI +hvcNAQEFBQADggIBAJcxAp/n/UNnSEQU5CmH7UwoZtCPNdpNYbdKl02125DgBS4OxnnQ8pdpD70E +R9m+27Up2pvZrqmZ1dM8MJP1jaGo/AaNRPTKFpV8M9xii6g3+CfYCS0b78gUJyCpZET/LtZ1qmxN +YEAZSUNUY9rizLpm5U9EelvZaoErQNV/+QEnWCzI7UiRfD+mAM/EKXMRNt6GGT6d7hmKG9Ww7Y49 +nCrADdg9ZuM8Db3VlFzi4qc1GwQA9j9ajepDvV+JHanBsMyZ4k0ACtrJJ1vnE5Bc5PUzolVt3OAJ +TS+xJlsndQAJxGJ3KQhfnlmstn6tn1QwIgPBHnFk/vk4CpYY3QIUrCPLBhwepH2NDd4nQeit2hW3 +sCPdK6jT2iWH7ehVRE2I9DZ+hJp4rPcOVkkO1jMl1oRQQmwgEh0q1b688nCBpHBgvgW1m54ERL5h +I6zppSSMEYCUWqKiuUnSwdzRp+0xESyeGabu4VXhwOrPDYTkF7eifKXeVSUG7szAh1xA2syVP1Xg +Nce4hL60Xc16gwFy7ofmXx2utYXGJt/mwZrpHgJHnyqobalbz+xFd3+YJ5oyXSrjhO7FmGYvliAd +3djDJ9ew+f7Zfc3Qn48LFFhRny+Lwzgt3uiP1o2HpPVWQxaZLPSkVrQ0uGE3ycJYgBugl6H8WY3p +EfbRD0tVNEYqi4Y7 +-----END CERTIFICATE----- + +TWCA Global Root CA +=================== +-----BEGIN CERTIFICATE----- +MIIFQTCCAymgAwIBAgICDL4wDQYJKoZIhvcNAQELBQAwUTELMAkGA1UEBhMCVFcxEjAQBgNVBAoT +CVRBSVdBTi1DQTEQMA4GA1UECxMHUm9vdCBDQTEcMBoGA1UEAxMTVFdDQSBHbG9iYWwgUm9vdCBD +QTAeFw0xMjA2MjcwNjI4MzNaFw0zMDEyMzExNTU5NTlaMFExCzAJBgNVBAYTAlRXMRIwEAYDVQQK +EwlUQUlXQU4tQ0ExEDAOBgNVBAsTB1Jvb3QgQ0ExHDAaBgNVBAMTE1RXQ0EgR2xvYmFsIFJvb3Qg +Q0EwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCwBdvI64zEbooh745NnHEKH1Jw7W2C +nJfF10xORUnLQEK1EjRsGcJ0pDFfhQKX7EMzClPSnIyOt7h52yvVavKOZsTuKwEHktSz0ALfUPZV +r2YOy+BHYC8rMjk1Ujoog/h7FsYYuGLWRyWRzvAZEk2tY/XTP3VfKfChMBwqoJimFb3u/Rk28OKR +Q4/6ytYQJ0lM793B8YVwm8rqqFpD/G2Gb3PpN0Wp8DbHzIh1HrtsBv+baz4X7GGqcXzGHaL3SekV +tTzWoWH1EfcFbx39Eb7QMAfCKbAJTibc46KokWofwpFFiFzlmLhxpRUZyXx1EcxwdE8tmx2RRP1W +KKD+u4ZqyPpcC1jcxkt2yKsi2XMPpfRaAok/T54igu6idFMqPVMnaR1sjjIsZAAmY2E2TqNGtz99 +sy2sbZCilaLOz9qC5wc0GZbpuCGqKX6mOL6OKUohZnkfs8O1CWfe1tQHRvMq2uYiN2DLgbYPoA/p +yJV/v1WRBXrPPRXAb94JlAGD1zQbzECl8LibZ9WYkTunhHiVJqRaCPgrdLQABDzfuBSO6N+pjWxn +kjMdwLfS7JLIvgm/LCkFbwJrnu+8vyq8W8BQj0FwcYeyTbcEqYSjMq+u7msXi7Kx/mzhkIyIqJdI +zshNy/MGz19qCkKxHh53L46g5pIOBvwFItIm4TFRfTLcDwIDAQABoyMwITAOBgNVHQ8BAf8EBAMC +AQYwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAgEAXzSBdu+WHdXltdkCY4QWwa6g +cFGn90xHNcgL1yg9iXHZqjNB6hQbbCEAwGxCGX6faVsgQt+i0trEfJdLjbDorMjupWkEmQqSpqsn +LhpNgb+E1HAerUf+/UqdM+DyucRFCCEK2mlpc3INvjT+lIutwx4116KD7+U4x6WFH6vPNOw/KP4M +8VeGTslV9xzU2KV9Bnpv1d8Q34FOIWWxtuEXeZVFBs5fzNxGiWNoRI2T9GRwoD2dKAXDOXC4Ynsg +/eTb6QihuJ49CcdP+yz4k3ZB3lLg4VfSnQO8d57+nile98FRYB/e2guyLXW3Q0iT5/Z5xoRdgFlg +lPx4mI88k1HtQJAH32RjJMtOcQWh15QaiDLxInQirqWm2BJpTGCjAu4r7NRjkgtevi92a6O2JryP +A9gK8kxkRr05YuWW6zRjESjMlfGt7+/cgFhI6Uu46mWs6fyAtbXIRfmswZ/ZuepiiI7E8UuDEq3m +i4TWnsLrgxifarsbJGAzcMzs9zLzXNl5fe+epP7JI8Mk7hWSsT2RTyaGvWZzJBPqpK5jwa19hAM8 +EHiGG3njxPPyBJUgriOCxLM6AGK/5jYk4Ve6xx6QddVfP5VhK8E7zeWzaGHQRiapIVJpLesux+t3 +zqY6tQMzT3bR51xUAV3LePTJDL/PEo4XLSNolOer/qmyKwbQBM0= +-----END CERTIFICATE----- + +T-TeleSec GlobalRoot Class 2 +============================ +-----BEGIN CERTIFICATE----- +MIIDwzCCAqugAwIBAgIBATANBgkqhkiG9w0BAQsFADCBgjELMAkGA1UEBhMCREUxKzApBgNVBAoM +IlQtU3lzdGVtcyBFbnRlcnByaXNlIFNlcnZpY2VzIEdtYkgxHzAdBgNVBAsMFlQtU3lzdGVtcyBU +cnVzdCBDZW50ZXIxJTAjBgNVBAMMHFQtVGVsZVNlYyBHbG9iYWxSb290IENsYXNzIDIwHhcNMDgx +MDAxMTA0MDE0WhcNMzMxMDAxMjM1OTU5WjCBgjELMAkGA1UEBhMCREUxKzApBgNVBAoMIlQtU3lz +dGVtcyBFbnRlcnByaXNlIFNlcnZpY2VzIEdtYkgxHzAdBgNVBAsMFlQtU3lzdGVtcyBUcnVzdCBD +ZW50ZXIxJTAjBgNVBAMMHFQtVGVsZVNlYyBHbG9iYWxSb290IENsYXNzIDIwggEiMA0GCSqGSIb3 +DQEBAQUAA4IBDwAwggEKAoIBAQCqX9obX+hzkeXaXPSi5kfl82hVYAUdAqSzm1nzHoqvNK38DcLZ +SBnuaY/JIPwhqgcZ7bBcrGXHX+0CfHt8LRvWurmAwhiCFoT6ZrAIxlQjgeTNuUk/9k9uN0goOA/F +vudocP05l03Sx5iRUKrERLMjfTlH6VJi1hKTXrcxlkIF+3anHqP1wvzpesVsqXFP6st4vGCvx970 +2cu+fjOlbpSD8DT6IavqjnKgP6TeMFvvhk1qlVtDRKgQFRzlAVfFmPHmBiiRqiDFt1MmUUOyCxGV +WOHAD3bZwI18gfNycJ5v/hqO2V81xrJvNHy+SE/iWjnX2J14np+GPgNeGYtEotXHAgMBAAGjQjBA +MA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBS/WSA2AHmgoCJrjNXy +YdK4LMuCSjANBgkqhkiG9w0BAQsFAAOCAQEAMQOiYQsfdOhyNsZt+U2e+iKo4YFWz827n+qrkRk4 +r6p8FU3ztqONpfSO9kSpp+ghla0+AGIWiPACuvxhI+YzmzB6azZie60EI4RYZeLbK4rnJVM3YlNf +vNoBYimipidx5joifsFvHZVwIEoHNN/q/xWA5brXethbdXwFeilHfkCoMRN3zUA7tFFHei4R40cR +3p1m0IvVVGb6g1XqfMIpiRvpb7PO4gWEyS8+eIVibslfwXhjdFjASBgMmTnrpMwatXlajRWc2BQN +9noHV8cigwUtPJslJj0Ys6lDfMjIq2SPDqO/nBudMNva0Bkuqjzx+zOAduTNrRlPBSeOE6Fuwg== +-----END CERTIFICATE----- + +Atos TrustedRoot 2011 +===================== +-----BEGIN CERTIFICATE----- +MIIDdzCCAl+gAwIBAgIIXDPLYixfszIwDQYJKoZIhvcNAQELBQAwPDEeMBwGA1UEAwwVQXRvcyBU +cnVzdGVkUm9vdCAyMDExMQ0wCwYDVQQKDARBdG9zMQswCQYDVQQGEwJERTAeFw0xMTA3MDcxNDU4 +MzBaFw0zMDEyMzEyMzU5NTlaMDwxHjAcBgNVBAMMFUF0b3MgVHJ1c3RlZFJvb3QgMjAxMTENMAsG +A1UECgwEQXRvczELMAkGA1UEBhMCREUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCV +hTuXbyo7LjvPpvMpNb7PGKw+qtn4TaA+Gke5vJrf8v7MPkfoepbCJI419KkM/IL9bcFyYie96mvr +54rMVD6QUM+A1JX76LWC1BTFtqlVJVfbsVD2sGBkWXppzwO3bw2+yj5vdHLqqjAqc2K+SZFhyBH+ +DgMq92og3AIVDV4VavzjgsG1xZ1kCWyjWZgHJ8cblithdHFsQ/H3NYkQ4J7sVaE3IqKHBAUsR320 +HLliKWYoyrfhk/WklAOZuXCFteZI6o1Q/NnezG8HDt0Lcp2AMBYHlT8oDv3FdU9T1nSatCQujgKR +z3bFmx5VdJx4IbHwLfELn8LVlhgf8FQieowHAgMBAAGjfTB7MB0GA1UdDgQWBBSnpQaxLKYJYO7R +l+lwrrw7GWzbITAPBgNVHRMBAf8EBTADAQH/MB8GA1UdIwQYMBaAFKelBrEspglg7tGX6XCuvDsZ +bNshMBgGA1UdIAQRMA8wDQYLKwYBBAGwLQMEAQEwDgYDVR0PAQH/BAQDAgGGMA0GCSqGSIb3DQEB +CwUAA4IBAQAmdzTblEiGKkGdLD4GkGDEjKwLVLgfuXvTBznk+j57sj1O7Z8jvZfza1zv7v1Apt+h +k6EKhqzvINB5Ab149xnYJDE0BAGmuhWawyfc2E8PzBhj/5kPDpFrdRbhIfzYJsdHt6bPWHJxfrrh +TZVHO8mvbaG0weyJ9rQPOLXiZNwlz6bb65pcmaHFCN795trV1lpFDMS3wrUU77QR/w4VtfX128a9 +61qn8FYiqTxlVMYVqL2Gns2Dlmh6cYGJ4Qvh6hEbaAjMaZ7snkGeRDImeuKHCnE96+RapNLbxc3G +3mB/ufNPRJLvKrcYPqcZ2Qt9sTdBQrC6YB3y/gkRsPCHe6ed +-----END CERTIFICATE----- + +QuoVadis Root CA 1 G3 +===================== +-----BEGIN CERTIFICATE----- +MIIFYDCCA0igAwIBAgIUeFhfLq0sGUvjNwc1NBMotZbUZZMwDQYJKoZIhvcNAQELBQAwSDELMAkG +A1UEBhMCQk0xGTAXBgNVBAoTEFF1b1ZhZGlzIExpbWl0ZWQxHjAcBgNVBAMTFVF1b1ZhZGlzIFJv +b3QgQ0EgMSBHMzAeFw0xMjAxMTIxNzI3NDRaFw00MjAxMTIxNzI3NDRaMEgxCzAJBgNVBAYTAkJN +MRkwFwYDVQQKExBRdW9WYWRpcyBMaW1pdGVkMR4wHAYDVQQDExVRdW9WYWRpcyBSb290IENBIDEg +RzMwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCgvlAQjunybEC0BJyFuTHK3C3kEakE +PBtVwedYMB0ktMPvhd6MLOHBPd+C5k+tR4ds7FtJwUrVu4/sh6x/gpqG7D0DmVIB0jWerNrwU8lm +PNSsAgHaJNM7qAJGr6Qc4/hzWHa39g6QDbXwz8z6+cZM5cOGMAqNF34168Xfuw6cwI2H44g4hWf6 +Pser4BOcBRiYz5P1sZK0/CPTz9XEJ0ngnjybCKOLXSoh4Pw5qlPafX7PGglTvF0FBM+hSo+LdoIN +ofjSxxR3W5A2B4GbPgb6Ul5jxaYA/qXpUhtStZI5cgMJYr2wYBZupt0lwgNm3fME0UDiTouG9G/l +g6AnhF4EwfWQvTA9xO+oabw4m6SkltFi2mnAAZauy8RRNOoMqv8hjlmPSlzkYZqn0ukqeI1RPToV +7qJZjqlc3sX5kCLliEVx3ZGZbHqfPT2YfF72vhZooF6uCyP8Wg+qInYtyaEQHeTTRCOQiJ/GKubX +9ZqzWB4vMIkIG1SitZgj7Ah3HJVdYdHLiZxfokqRmu8hqkkWCKi9YSgxyXSthfbZxbGL0eUQMk1f +iyA6PEkfM4VZDdvLCXVDaXP7a3F98N/ETH3Goy7IlXnLc6KOTk0k+17kBL5yG6YnLUlamXrXXAkg +t3+UuU/xDRxeiEIbEbfnkduebPRq34wGmAOtzCjvpUfzUwIDAQABo0IwQDAPBgNVHRMBAf8EBTAD +AQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUo5fW816iEOGrRZ88F2Q87gFwnMwwDQYJKoZI +hvcNAQELBQADggIBABj6W3X8PnrHX3fHyt/PX8MSxEBd1DKquGrX1RUVRpgjpeaQWxiZTOOtQqOC +MTaIzen7xASWSIsBx40Bz1szBpZGZnQdT+3Btrm0DWHMY37XLneMlhwqI2hrhVd2cDMT/uFPpiN3 +GPoajOi9ZcnPP/TJF9zrx7zABC4tRi9pZsMbj/7sPtPKlL92CiUNqXsCHKnQO18LwIE6PWThv6ct +Tr1NxNgpxiIY0MWscgKCP6o6ojoilzHdCGPDdRS5YCgtW2jgFqlmgiNR9etT2DGbe+m3nUvriBbP ++V04ikkwj+3x6xn0dxoxGE1nVGwvb2X52z3sIexe9PSLymBlVNFxZPT5pqOBMzYzcfCkeF9OrYMh +3jRJjehZrJ3ydlo28hP0r+AJx2EqbPfgna67hkooby7utHnNkDPDs3b69fBsnQGQ+p6Q9pxyz0fa +wx/kNSBT8lTR32GDpgLiJTjehTItXnOQUl1CxM49S+H5GYQd1aJQzEH7QRTDvdbJWqNjZgKAvQU6 +O0ec7AAmTPWIUb+oI38YB7AL7YsmoWTTYUrrXJ/es69nA7Mf3W1daWhpq1467HxpvMc7hU6eFbm0 +FU/DlXpY18ls6Wy58yljXrQs8C097Vpl4KlbQMJImYFtnh8GKjwStIsPm6Ik8KaN1nrgS7ZklmOV +hMJKzRwuJIczYOXD +-----END CERTIFICATE----- + +QuoVadis Root CA 2 G3 +===================== +-----BEGIN CERTIFICATE----- +MIIFYDCCA0igAwIBAgIURFc0JFuBiZs18s64KztbpybwdSgwDQYJKoZIhvcNAQELBQAwSDELMAkG +A1UEBhMCQk0xGTAXBgNVBAoTEFF1b1ZhZGlzIExpbWl0ZWQxHjAcBgNVBAMTFVF1b1ZhZGlzIFJv +b3QgQ0EgMiBHMzAeFw0xMjAxMTIxODU5MzJaFw00MjAxMTIxODU5MzJaMEgxCzAJBgNVBAYTAkJN +MRkwFwYDVQQKExBRdW9WYWRpcyBMaW1pdGVkMR4wHAYDVQQDExVRdW9WYWRpcyBSb290IENBIDIg +RzMwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQChriWyARjcV4g/Ruv5r+LrI3HimtFh +ZiFfqq8nUeVuGxbULX1QsFN3vXg6YOJkApt8hpvWGo6t/x8Vf9WVHhLL5hSEBMHfNrMWn4rjyduY +NM7YMxcoRvynyfDStNVNCXJJ+fKH46nafaF9a7I6JaltUkSs+L5u+9ymc5GQYaYDFCDy54ejiK2t +oIz/pgslUiXnFgHVy7g1gQyjO/Dh4fxaXc6AcW34Sas+O7q414AB+6XrW7PFXmAqMaCvN+ggOp+o +MiwMzAkd056OXbxMmO7FGmh77FOm6RQ1o9/NgJ8MSPsc9PG/Srj61YxxSscfrf5BmrODXfKEVu+l +V0POKa2Mq1W/xPtbAd0jIaFYAI7D0GoT7RPjEiuA3GfmlbLNHiJuKvhB1PLKFAeNilUSxmn1uIZo +L1NesNKqIcGY5jDjZ1XHm26sGahVpkUG0CM62+tlXSoREfA7T8pt9DTEceT/AFr2XK4jYIVz8eQQ +sSWu1ZK7E8EM4DnatDlXtas1qnIhO4M15zHfeiFuuDIIfR0ykRVKYnLP43ehvNURG3YBZwjgQQvD +6xVu+KQZ2aKrr+InUlYrAoosFCT5v0ICvybIxo/gbjh9Uy3l7ZizlWNof/k19N+IxWA1ksB8aRxh +lRbQ694Lrz4EEEVlWFA4r0jyWbYW8jwNkALGcC4BrTwV1wIDAQABo0IwQDAPBgNVHRMBAf8EBTAD +AQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQU7edvdlq/YOxJW8ald7tyFnGbxD0wDQYJKoZI +hvcNAQELBQADggIBAJHfgD9DCX5xwvfrs4iP4VGyvD11+ShdyLyZm3tdquXK4Qr36LLTn91nMX66 +AarHakE7kNQIXLJgapDwyM4DYvmL7ftuKtwGTTwpD4kWilhMSA/ohGHqPHKmd+RCroijQ1h5fq7K +pVMNqT1wvSAZYaRsOPxDMuHBR//47PERIjKWnML2W2mWeyAMQ0GaW/ZZGYjeVYg3UQt4XAoeo0L9 +x52ID8DyeAIkVJOviYeIyUqAHerQbj5hLja7NQ4nlv1mNDthcnPxFlxHBlRJAHpYErAK74X9sbgz +dWqTHBLmYF5vHX/JHyPLhGGfHoJE+V+tYlUkmlKY7VHnoX6XOuYvHxHaU4AshZ6rNRDbIl9qxV6X +U/IyAgkwo1jwDQHVcsaxfGl7w/U2Rcxhbl5MlMVerugOXou/983g7aEOGzPuVBj+D77vfoRrQ+Nw +mNtddbINWQeFFSM51vHfqSYP1kjHs6Yi9TM3WpVHn3u6GBVv/9YUZINJ0gpnIdsPNWNgKCLjsZWD +zYWm3S8P52dSbrsvhXz1SnPnxT7AvSESBT/8twNJAlvIJebiVDj1eYeMHVOyToV7BjjHLPj4sHKN +JeV3UvQDHEimUF+IIDBu8oJDqz2XhOdT+yHBTw8imoa4WSr2Rz0ZiC3oheGe7IUIarFsNMkd7Egr +O3jtZsSOeWmD3n+M +-----END CERTIFICATE----- + +QuoVadis Root CA 3 G3 +===================== +-----BEGIN CERTIFICATE----- +MIIFYDCCA0igAwIBAgIULvWbAiin23r/1aOp7r0DoM8Sah0wDQYJKoZIhvcNAQELBQAwSDELMAkG +A1UEBhMCQk0xGTAXBgNVBAoTEFF1b1ZhZGlzIExpbWl0ZWQxHjAcBgNVBAMTFVF1b1ZhZGlzIFJv +b3QgQ0EgMyBHMzAeFw0xMjAxMTIyMDI2MzJaFw00MjAxMTIyMDI2MzJaMEgxCzAJBgNVBAYTAkJN +MRkwFwYDVQQKExBRdW9WYWRpcyBMaW1pdGVkMR4wHAYDVQQDExVRdW9WYWRpcyBSb290IENBIDMg +RzMwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCzyw4QZ47qFJenMioKVjZ/aEzHs286 +IxSR/xl/pcqs7rN2nXrpixurazHb+gtTTK/FpRp5PIpM/6zfJd5O2YIyC0TeytuMrKNuFoM7pmRL +Mon7FhY4futD4tN0SsJiCnMK3UmzV9KwCoWdcTzeo8vAMvMBOSBDGzXRU7Ox7sWTaYI+FrUoRqHe +6okJ7UO4BUaKhvVZR74bbwEhELn9qdIoyhA5CcoTNs+cra1AdHkrAj80//ogaX3T7mH1urPnMNA3 +I4ZyYUUpSFlob3emLoG+B01vr87ERRORFHAGjx+f+IdpsQ7vw4kZ6+ocYfx6bIrc1gMLnia6Et3U +VDmrJqMz6nWB2i3ND0/kA9HvFZcba5DFApCTZgIhsUfei5pKgLlVj7WiL8DWM2fafsSntARE60f7 +5li59wzweyuxwHApw0BiLTtIadwjPEjrewl5qW3aqDCYz4ByA4imW0aucnl8CAMhZa634RylsSqi +Md5mBPfAdOhx3v89WcyWJhKLhZVXGqtrdQtEPREoPHtht+KPZ0/l7DxMYIBpVzgeAVuNVejH38DM +dyM0SXV89pgR6y3e7UEuFAUCf+D+IOs15xGsIs5XPd7JMG0QA4XN8f+MFrXBsj6IbGB/kE+V9/Yt +rQE5BwT6dYB9v0lQ7e/JxHwc64B+27bQ3RP+ydOc17KXqQIDAQABo0IwQDAPBgNVHRMBAf8EBTAD +AQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUxhfQvKjqAkPyGwaZXSuQILnXnOQwDQYJKoZI +hvcNAQELBQADggIBADRh2Va1EodVTd2jNTFGu6QHcrxfYWLopfsLN7E8trP6KZ1/AvWkyaiTt3px +KGmPc+FSkNrVvjrlt3ZqVoAh313m6Tqe5T72omnHKgqwGEfcIHB9UqM+WXzBusnIFUBhynLWcKzS +t/Ac5IYp8M7vaGPQtSCKFWGafoaYtMnCdvvMujAWzKNhxnQT5WvvoxXqA/4Ti2Tk08HS6IT7SdEQ +TXlm66r99I0xHnAUrdzeZxNMgRVhvLfZkXdxGYFgu/BYpbWcC/ePIlUnwEsBbTuZDdQdm2NnL9Du +DcpmvJRPpq3t/O5jrFc/ZSXPsoaP0Aj/uHYUbt7lJ+yreLVTubY/6CD50qi+YUbKh4yE8/nxoGib +Ih6BJpsQBJFxwAYf3KDTuVan45gtf4Od34wrnDKOMpTwATwiKp9Dwi7DmDkHOHv8XgBCH/MyJnmD +hPbl8MFREsALHgQjDFSlTC9JxUrRtm5gDWv8a4uFJGS3iQ6rJUdbPM9+Sb3H6QrG2vd+DhcI00iX +0HGS8A85PjRqHH3Y8iKuu2n0M7SmSFXRDw4m6Oy2Cy2nhTXN/VnIn9HNPlopNLk9hM6xZdRZkZFW +dSHBd575euFgndOtBBj0fOtek49TSiIp+EgrPk2GrFt/ywaZWWDYWGWVjUTR939+J399roD1B0y2 +PpxxVJkES/1Y+Zj0 +-----END CERTIFICATE----- + +DigiCert Assured ID Root G2 +=========================== +-----BEGIN CERTIFICATE----- +MIIDljCCAn6gAwIBAgIQC5McOtY5Z+pnI7/Dr5r0SzANBgkqhkiG9w0BAQsFADBlMQswCQYDVQQG +EwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSQw +IgYDVQQDExtEaWdpQ2VydCBBc3N1cmVkIElEIFJvb3QgRzIwHhcNMTMwODAxMTIwMDAwWhcNMzgw +MTE1MTIwMDAwWjBlMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQL +ExB3d3cuZGlnaWNlcnQuY29tMSQwIgYDVQQDExtEaWdpQ2VydCBBc3N1cmVkIElEIFJvb3QgRzIw +ggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDZ5ygvUj82ckmIkzTz+GoeMVSAn61UQbVH +35ao1K+ALbkKz3X9iaV9JPrjIgwrvJUXCzO/GU1BBpAAvQxNEP4HteccbiJVMWWXvdMX0h5i89vq +bFCMP4QMls+3ywPgym2hFEwbid3tALBSfK+RbLE4E9HpEgjAALAcKxHad3A2m67OeYfcgnDmCXRw +VWmvo2ifv922ebPynXApVfSr/5Vh88lAbx3RvpO704gqu52/clpWcTs/1PPRCv4o76Pu2ZmvA9OP +YLfykqGxvYmJHzDNw6YuYjOuFgJ3RFrngQo8p0Quebg/BLxcoIfhG69Rjs3sLPr4/m3wOnyqi+Rn +lTGNAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMB0GA1UdDgQWBBTO +w0q5mVXyuNtgv6l+vVa1lzan1jANBgkqhkiG9w0BAQsFAAOCAQEAyqVVjOPIQW5pJ6d1Ee88hjZv +0p3GeDgdaZaikmkuOGybfQTUiaWxMTeKySHMq2zNixya1r9I0jJmwYrA8y8678Dj1JGG0VDjA9tz +d29KOVPt3ibHtX2vK0LRdWLjSisCx1BL4GnilmwORGYQRI+tBev4eaymG+g3NJ1TyWGqolKvSnAW +hsI6yLETcDbYz+70CjTVW0z9B5yiutkBclzzTcHdDrEcDcRjvq30FPuJ7KJBDkzMyFdA0G4Dqs0M +jomZmWzwPDCvON9vvKO+KSAnq3T/EyJ43pdSVR6DtVQgA+6uwE9W3jfMw3+qBCe703e4YtsXfJwo +IhNzbM8m9Yop5w== +-----END CERTIFICATE----- + +DigiCert Assured ID Root G3 +=========================== +-----BEGIN CERTIFICATE----- +MIICRjCCAc2gAwIBAgIQC6Fa+h3foLVJRK/NJKBs7DAKBggqhkjOPQQDAzBlMQswCQYDVQQGEwJV +UzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSQwIgYD +VQQDExtEaWdpQ2VydCBBc3N1cmVkIElEIFJvb3QgRzMwHhcNMTMwODAxMTIwMDAwWhcNMzgwMTE1 +MTIwMDAwWjBlMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 +d3cuZGlnaWNlcnQuY29tMSQwIgYDVQQDExtEaWdpQ2VydCBBc3N1cmVkIElEIFJvb3QgRzMwdjAQ +BgcqhkjOPQIBBgUrgQQAIgNiAAQZ57ysRGXtzbg/WPuNsVepRC0FFfLvC/8QdJ+1YlJfZn4f5dwb +RXkLzMZTCp2NXQLZqVneAlr2lSoOjThKiknGvMYDOAdfVdp+CW7if17QRSAPWXYQ1qAk8C3eNvJs +KTmjQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMB0GA1UdDgQWBBTL0L2p4ZgF +UaFNN6KDec6NHSrkhDAKBggqhkjOPQQDAwNnADBkAjAlpIFFAmsSS3V0T8gj43DydXLefInwz5Fy +YZ5eEJJZVrmDxxDnOOlYJjZ91eQ0hjkCMHw2U/Aw5WJjOpnitqM7mzT6HtoQknFekROn3aRukswy +1vUhZscv6pZjamVFkpUBtA== +-----END CERTIFICATE----- + +DigiCert Global Root G2 +======================= +-----BEGIN CERTIFICATE----- +MIIDjjCCAnagAwIBAgIQAzrx5qcRqaC7KGSxHQn65TANBgkqhkiG9w0BAQsFADBhMQswCQYDVQQG +EwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSAw +HgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBHMjAeFw0xMzA4MDExMjAwMDBaFw0zODAxMTUx +MjAwMDBaMGExCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3 +dy5kaWdpY2VydC5jb20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IEcyMIIBIjANBgkq +hkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuzfNNNx7a8myaJCtSnX/RrohCgiN9RlUyfuI2/Ou8jqJ +kTx65qsGGmvPrC3oXgkkRLpimn7Wo6h+4FR1IAWsULecYxpsMNzaHxmx1x7e/dfgy5SDN67sH0NO +3Xss0r0upS/kqbitOtSZpLYl6ZtrAGCSYP9PIUkY92eQq2EGnI/yuum06ZIya7XzV+hdG82MHauV +BJVJ8zUtluNJbd134/tJS7SsVQepj5WztCO7TG1F8PapspUwtP1MVYwnSlcUfIKdzXOS0xZKBgyM +UNGPHgm+F6HmIcr9g+UQvIOlCsRnKPZzFBQ9RnbDhxSJITRNrw9FDKZJobq7nMWxM4MphQIDAQAB +o0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjAdBgNVHQ4EFgQUTiJUIBiV5uNu +5g/6+rkS7QYXjzkwDQYJKoZIhvcNAQELBQADggEBAGBnKJRvDkhj6zHd6mcY1Yl9PMWLSn/pvtsr +F9+wX3N3KjITOYFnQoQj8kVnNeyIv/iPsGEMNKSuIEyExtv4NeF22d+mQrvHRAiGfzZ0JFrabA0U +WTW98kndth/Jsw1HKj2ZL7tcu7XUIOGZX1NGFdtom/DzMNU+MeKNhJ7jitralj41E6Vf8PlwUHBH +QRFXGU7Aj64GxJUTFy8bJZ918rGOmaFvE7FBcf6IKshPECBV1/MUReXgRPTqh5Uykw7+U0b6LJ3/ +iyK5S9kJRaTepLiaWN0bfVKfjllDiIGknibVb63dDcY3fe0Dkhvld1927jyNxF1WW6LZZm6zNTfl +MrY= +-----END CERTIFICATE----- + +DigiCert Global Root G3 +======================= +-----BEGIN CERTIFICATE----- +MIICPzCCAcWgAwIBAgIQBVVWvPJepDU1w6QP1atFcjAKBggqhkjOPQQDAzBhMQswCQYDVQQGEwJV +UzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSAwHgYD +VQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBHMzAeFw0xMzA4MDExMjAwMDBaFw0zODAxMTUxMjAw +MDBaMGExCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5k +aWdpY2VydC5jb20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IEczMHYwEAYHKoZIzj0C +AQYFK4EEACIDYgAE3afZu4q4C/sLfyHS8L6+c/MzXRq8NOrexpu80JX28MzQC7phW1FGfp4tn+6O +YwwX7Adw9c+ELkCDnOg/QW07rdOkFFk2eJ0DQ+4QE2xy3q6Ip6FrtUPOZ9wj/wMco+I+o0IwQDAP +BgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjAdBgNVHQ4EFgQUs9tIpPmhxdiuNkHMEWNp +Yim8S8YwCgYIKoZIzj0EAwMDaAAwZQIxAK288mw/EkrRLTnDCgmXc/SINoyIJ7vmiI1Qhadj+Z4y +3maTD/HMsQmP3Wyr+mt/oAIwOWZbwmSNuJ5Q3KjVSaLtx9zRSX8XAbjIho9OjIgrqJqpisXRAL34 +VOKa5Vt8sycX +-----END CERTIFICATE----- + +DigiCert Trusted Root G4 +======================== +-----BEGIN CERTIFICATE----- +MIIFkDCCA3igAwIBAgIQBZsbV56OITLiOQe9p3d1XDANBgkqhkiG9w0BAQwFADBiMQswCQYDVQQG +EwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSEw +HwYDVQQDExhEaWdpQ2VydCBUcnVzdGVkIFJvb3QgRzQwHhcNMTMwODAxMTIwMDAwWhcNMzgwMTE1 +MTIwMDAwWjBiMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 +d3cuZGlnaWNlcnQuY29tMSEwHwYDVQQDExhEaWdpQ2VydCBUcnVzdGVkIFJvb3QgRzQwggIiMA0G +CSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC/5pBzaN675F1KPDAiMGkz7MKnJS7JIT3yithZwuEp +pz1Yq3aaza57G4QNxDAf8xukOBbrVsaXbR2rsnnyyhHS5F/WBTxSD1Ifxp4VpX6+n6lXFllVcq9o +k3DCsrp1mWpzMpTREEQQLt+C8weE5nQ7bXHiLQwb7iDVySAdYyktzuxeTsiT+CFhmzTrBcZe7Fsa +vOvJz82sNEBfsXpm7nfISKhmV1efVFiODCu3T6cw2Vbuyntd463JT17lNecxy9qTXtyOj4DatpGY +QJB5w3jHtrHEtWoYOAMQjdjUN6QuBX2I9YI+EJFwq1WCQTLX2wRzKm6RAXwhTNS8rhsDdV14Ztk6 +MUSaM0C/CNdaSaTC5qmgZ92kJ7yhTzm1EVgX9yRcRo9k98FpiHaYdj1ZXUJ2h4mXaXpI8OCiEhtm +mnTK3kse5w5jrubU75KSOp493ADkRSWJtppEGSt+wJS00mFt6zPZxd9LBADMfRyVw4/3IbKyEbe7 +f/LVjHAsQWCqsWMYRJUadmJ+9oCw++hkpjPRiQfhvbfmQ6QYuKZ3AeEPlAwhHbJUKSWJbOUOUlFH +dL4mrLZBdd56rF+NP8m800ERElvlEFDrMcXKchYiCd98THU/Y+whX8QgUWtvsauGi0/C1kVfnSD8 +oR7FwI+isX4KJpn15GkvmB0t9dmpsh3lGwIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1Ud +DwEB/wQEAwIBhjAdBgNVHQ4EFgQU7NfjgtJxXWRM3y5nP+e6mK4cD08wDQYJKoZIhvcNAQEMBQAD +ggIBALth2X2pbL4XxJEbw6GiAI3jZGgPVs93rnD5/ZpKmbnJeFwMDF/k5hQpVgs2SV1EY+CtnJYY +ZhsjDT156W1r1lT40jzBQ0CuHVD1UvyQO7uYmWlrx8GnqGikJ9yd+SeuMIW59mdNOj6PWTkiU0Tr +yF0Dyu1Qen1iIQqAyHNm0aAFYF/opbSnr6j3bTWcfFqK1qI4mfN4i/RN0iAL3gTujJtHgXINwBQy +7zBZLq7gcfJW5GqXb5JQbZaNaHqasjYUegbyJLkJEVDXCLG4iXqEI2FCKeWjzaIgQdfRnGTZ6iah +ixTXTBmyUEFxPT9NcCOGDErcgdLMMpSEDQgJlxxPwO5rIHQw0uA5NBCFIRUBCOhVMt5xSdkoF1BN +5r5N0XWs0Mr7QbhDparTwwVETyw2m+L64kW4I1NsBm9nVX9GtUw/bihaeSbSpKhil9Ie4u1Ki7wb +/UdKDd9nZn6yW0HQO+T0O/QEY+nvwlQAUaCKKsnOeMzV6ocEGLPOr0mIr/OSmbaz5mEP0oUA51Aa +5BuVnRmhuZyxm7EAHu/QD09CbMkKvO5D+jpxpchNJqU1/YldvIViHTLSoCtU7ZpXwdv6EM8Zt4tK +G48BtieVU+i2iW1bvGjUI+iLUaJW+fCmgKDWHrO8Dw9TdSmq6hN35N6MgSGtBxBHEa2HPQfRdbzP +82Z+ +-----END CERTIFICATE----- + +COMODO RSA Certification Authority +================================== +-----BEGIN CERTIFICATE----- +MIIF2DCCA8CgAwIBAgIQTKr5yttjb+Af907YWwOGnTANBgkqhkiG9w0BAQwFADCBhTELMAkGA1UE +BhMCR0IxGzAZBgNVBAgTEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UEBxMHU2FsZm9yZDEaMBgG +A1UEChMRQ09NT0RPIENBIExpbWl0ZWQxKzApBgNVBAMTIkNPTU9ETyBSU0EgQ2VydGlmaWNhdGlv +biBBdXRob3JpdHkwHhcNMTAwMTE5MDAwMDAwWhcNMzgwMTE4MjM1OTU5WjCBhTELMAkGA1UEBhMC +R0IxGzAZBgNVBAgTEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UEBxMHU2FsZm9yZDEaMBgGA1UE +ChMRQ09NT0RPIENBIExpbWl0ZWQxKzApBgNVBAMTIkNPTU9ETyBSU0EgQ2VydGlmaWNhdGlvbiBB +dXRob3JpdHkwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCR6FSS0gpWsawNJN3Fz0Rn +dJkrN6N9I3AAcbxT38T6KhKPS38QVr2fcHK3YX/JSw8Xpz3jsARh7v8Rl8f0hj4K+j5c+ZPmNHrZ +FGvnnLOFoIJ6dq9xkNfs/Q36nGz637CC9BR++b7Epi9Pf5l/tfxnQ3K9DADWietrLNPtj5gcFKt+ +5eNu/Nio5JIk2kNrYrhV/erBvGy2i/MOjZrkm2xpmfh4SDBF1a3hDTxFYPwyllEnvGfDyi62a+pG +x8cgoLEfZd5ICLqkTqnyg0Y3hOvozIFIQ2dOciqbXL1MGyiKXCJ7tKuY2e7gUYPDCUZObT6Z+pUX +2nwzV0E8jVHtC7ZcryxjGt9XyD+86V3Em69FmeKjWiS0uqlWPc9vqv9JWL7wqP/0uK3pN/u6uPQL +OvnoQ0IeidiEyxPx2bvhiWC4jChWrBQdnArncevPDt09qZahSL0896+1DSJMwBGB7FY79tOi4lu3 +sgQiUpWAk2nojkxl8ZEDLXB0AuqLZxUpaVICu9ffUGpVRr+goyhhf3DQw6KqLCGqR84onAZFdr+C +GCe01a60y1Dma/RMhnEw6abfFobg2P9A3fvQQoh/ozM6LlweQRGBY84YcWsr7KaKtzFcOmpH4MN5 +WdYgGq/yapiqcrxXStJLnbsQ/LBMQeXtHT1eKJ2czL+zUdqnR+WEUwIDAQABo0IwQDAdBgNVHQ4E +FgQUu69+Aj36pvE8hI6t7jiY7NkyMtQwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8w +DQYJKoZIhvcNAQEMBQADggIBAArx1UaEt65Ru2yyTUEUAJNMnMvlwFTPoCWOAvn9sKIN9SCYPBMt +rFaisNZ+EZLpLrqeLppysb0ZRGxhNaKatBYSaVqM4dc+pBroLwP0rmEdEBsqpIt6xf4FpuHA1sj+ +nq6PK7o9mfjYcwlYRm6mnPTXJ9OV2jeDchzTc+CiR5kDOF3VSXkAKRzH7JsgHAckaVd4sjn8OoSg +tZx8jb8uk2IntznaFxiuvTwJaP+EmzzV1gsD41eeFPfR60/IvYcjt7ZJQ3mFXLrrkguhxuhoqEwW +sRqZCuhTLJK7oQkYdQxlqHvLI7cawiiFwxv/0Cti76R7CZGYZ4wUAc1oBmpjIXUDgIiKboHGhfKp +pC3n9KUkEEeDys30jXlYsQab5xoq2Z0B15R97QNKyvDb6KkBPvVWmckejkk9u+UJueBPSZI9FoJA +zMxZxuY67RIuaTxslbH9qh17f4a+Hg4yRvv7E491f0yLS0Zj/gA0QHDBw7mh3aZw4gSzQbzpgJHq +ZJx64SIDqZxubw5lT2yHh17zbqD5daWbQOhTsiedSrnAdyGN/4fy3ryM7xfft0kL0fJuMAsaDk52 +7RH89elWsn2/x20Kk4yl0MC2Hb46TpSi125sC8KKfPog88Tk5c0NqMuRkrF8hey1FGlmDoLnzc7I +LaZRfyHBNVOFBkpdn627G190 +-----END CERTIFICATE----- + +USERTrust RSA Certification Authority +===================================== +-----BEGIN CERTIFICATE----- +MIIF3jCCA8agAwIBAgIQAf1tMPyjylGoG7xkDjUDLTANBgkqhkiG9w0BAQwFADCBiDELMAkGA1UE +BhMCVVMxEzARBgNVBAgTCk5ldyBKZXJzZXkxFDASBgNVBAcTC0plcnNleSBDaXR5MR4wHAYDVQQK +ExVUaGUgVVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNVBAMTJVVTRVJUcnVzdCBSU0EgQ2VydGlmaWNh +dGlvbiBBdXRob3JpdHkwHhcNMTAwMjAxMDAwMDAwWhcNMzgwMTE4MjM1OTU5WjCBiDELMAkGA1UE +BhMCVVMxEzARBgNVBAgTCk5ldyBKZXJzZXkxFDASBgNVBAcTC0plcnNleSBDaXR5MR4wHAYDVQQK +ExVUaGUgVVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNVBAMTJVVTRVJUcnVzdCBSU0EgQ2VydGlmaWNh +dGlvbiBBdXRob3JpdHkwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCAEmUXNg7D2wiz +0KxXDXbtzSfTTK1Qg2HiqiBNCS1kCdzOiZ/MPans9s/B3PHTsdZ7NygRK0faOca8Ohm0X6a9fZ2j +Y0K2dvKpOyuR+OJv0OwWIJAJPuLodMkYtJHUYmTbf6MG8YgYapAiPLz+E/CHFHv25B+O1ORRxhFn +RghRy4YUVD+8M/5+bJz/Fp0YvVGONaanZshyZ9shZrHUm3gDwFA66Mzw3LyeTP6vBZY1H1dat//O ++T23LLb2VN3I5xI6Ta5MirdcmrS3ID3KfyI0rn47aGYBROcBTkZTmzNg95S+UzeQc0PzMsNT79uq +/nROacdrjGCT3sTHDN/hMq7MkztReJVni+49Vv4M0GkPGw/zJSZrM233bkf6c0Plfg6lZrEpfDKE +Y1WJxA3Bk1QwGROs0303p+tdOmw1XNtB1xLaqUkL39iAigmTYo61Zs8liM2EuLE/pDkP2QKe6xJM +lXzzawWpXhaDzLhn4ugTncxbgtNMs+1b/97lc6wjOy0AvzVVdAlJ2ElYGn+SNuZRkg7zJn0cTRe8 +yexDJtC/QV9AqURE9JnnV4eeUB9XVKg+/XRjL7FQZQnmWEIuQxpMtPAlR1n6BB6T1CZGSlCBst6+ +eLf8ZxXhyVeEHg9j1uliutZfVS7qXMYoCAQlObgOK6nyTJccBz8NUvXt7y+CDwIDAQABo0IwQDAd +BgNVHQ4EFgQUU3m/WqorSs9UgOHYm8Cd8rIDZsswDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQF +MAMBAf8wDQYJKoZIhvcNAQEMBQADggIBAFzUfA3P9wF9QZllDHPFUp/L+M+ZBn8b2kMVn54CVVeW +FPFSPCeHlCjtHzoBN6J2/FNQwISbxmtOuowhT6KOVWKR82kV2LyI48SqC/3vqOlLVSoGIG1VeCkZ +7l8wXEskEVX/JJpuXior7gtNn3/3ATiUFJVDBwn7YKnuHKsSjKCaXqeYalltiz8I+8jRRa8YFWSQ +Eg9zKC7F4iRO/Fjs8PRF/iKz6y+O0tlFYQXBl2+odnKPi4w2r78NBc5xjeambx9spnFixdjQg3IM +8WcRiQycE0xyNN+81XHfqnHd4blsjDwSXWXavVcStkNr/+XeTWYRUc+ZruwXtuhxkYzeSf7dNXGi +FSeUHM9h4ya7b6NnJSFd5t0dCy5oGzuCr+yDZ4XUmFF0sbmZgIn/f3gZXHlKYC6SQK5MNyosycdi +yA5d9zZbyuAlJQG03RoHnHcAP9Dc1ew91Pq7P8yF1m9/qS3fuQL39ZeatTXaw2ewh0qpKJ4jjv9c +J2vhsE/zB+4ALtRZh8tSQZXq9EfX7mRBVXyNWQKV3WKdwrnuWih0hKWbt5DHDAff9Yk2dDLWKMGw +sAvgnEzDHNb842m1R0aBL6KCq9NjRHDEjf8tM7qtj3u1cIiuPhnPQCjY/MiQu12ZIvVS5ljFH4gx +Q+6IHdfGjjxDah2nGN59PRbxYvnKkKj9 +-----END CERTIFICATE----- + +USERTrust ECC Certification Authority +===================================== +-----BEGIN CERTIFICATE----- +MIICjzCCAhWgAwIBAgIQXIuZxVqUxdJxVt7NiYDMJjAKBggqhkjOPQQDAzCBiDELMAkGA1UEBhMC +VVMxEzARBgNVBAgTCk5ldyBKZXJzZXkxFDASBgNVBAcTC0plcnNleSBDaXR5MR4wHAYDVQQKExVU +aGUgVVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNVBAMTJVVTRVJUcnVzdCBFQ0MgQ2VydGlmaWNhdGlv +biBBdXRob3JpdHkwHhcNMTAwMjAxMDAwMDAwWhcNMzgwMTE4MjM1OTU5WjCBiDELMAkGA1UEBhMC +VVMxEzARBgNVBAgTCk5ldyBKZXJzZXkxFDASBgNVBAcTC0plcnNleSBDaXR5MR4wHAYDVQQKExVU +aGUgVVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNVBAMTJVVTRVJUcnVzdCBFQ0MgQ2VydGlmaWNhdGlv +biBBdXRob3JpdHkwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQarFRaqfloI+d61SRvU8Za2EurxtW2 +0eZzca7dnNYMYf3boIkDuAUU7FfO7l0/4iGzzvfUinngo4N+LZfQYcTxmdwlkWOrfzCjtHDix6Ez +nPO/LlxTsV+zfTJ/ijTjeXmjQjBAMB0GA1UdDgQWBBQ64QmG1M8ZwpZ2dEl23OA1xmNjmjAOBgNV +HQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAwNoADBlAjA2Z6EWCNzklwBB +HU6+4WMBzzuqQhFkoJ2UOQIReVx7Hfpkue4WQrO/isIJxOzksU0CMQDpKmFHjFJKS04YcPbWRNZu +9YO6bVi9JNlWSOrvxKJGgYhqOkbRqZtNyWHa0V1Xahg= +-----END CERTIFICATE----- + +GlobalSign ECC Root CA - R5 +=========================== +-----BEGIN CERTIFICATE----- +MIICHjCCAaSgAwIBAgIRYFlJ4CYuu1X5CneKcflK2GwwCgYIKoZIzj0EAwMwUDEkMCIGA1UECxMb +R2xvYmFsU2lnbiBFQ0MgUm9vdCBDQSAtIFI1MRMwEQYDVQQKEwpHbG9iYWxTaWduMRMwEQYDVQQD +EwpHbG9iYWxTaWduMB4XDTEyMTExMzAwMDAwMFoXDTM4MDExOTAzMTQwN1owUDEkMCIGA1UECxMb +R2xvYmFsU2lnbiBFQ0MgUm9vdCBDQSAtIFI1MRMwEQYDVQQKEwpHbG9iYWxTaWduMRMwEQYDVQQD +EwpHbG9iYWxTaWduMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAER0UOlvt9Xb/pOdEh+J8LttV7HpI6 +SFkc8GIxLcB6KP4ap1yztsyX50XUWPrRd21DosCHZTQKH3rd6zwzocWdTaRvQZU4f8kehOvRnkmS +h5SHDDqFSmafnVmTTZdhBoZKo0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAd +BgNVHQ4EFgQUPeYpSJvqB8ohREom3m7e0oPQn1kwCgYIKoZIzj0EAwMDaAAwZQIxAOVpEslu28Yx +uglB4Zf4+/2a4n0Sye18ZNPLBSWLVtmg515dTguDnFt2KaAJJiFqYgIwcdK1j1zqO+F4CYWodZI7 +yFz9SO8NdCKoCOJuxUnOxwy8p2Fp8fc74SrL+SvzZpA3 +-----END CERTIFICATE----- + +IdenTrust Commercial Root CA 1 +============================== +-----BEGIN CERTIFICATE----- +MIIFYDCCA0igAwIBAgIQCgFCgAAAAUUjyES1AAAAAjANBgkqhkiG9w0BAQsFADBKMQswCQYDVQQG +EwJVUzESMBAGA1UEChMJSWRlblRydXN0MScwJQYDVQQDEx5JZGVuVHJ1c3QgQ29tbWVyY2lhbCBS +b290IENBIDEwHhcNMTQwMTE2MTgxMjIzWhcNMzQwMTE2MTgxMjIzWjBKMQswCQYDVQQGEwJVUzES +MBAGA1UEChMJSWRlblRydXN0MScwJQYDVQQDEx5JZGVuVHJ1c3QgQ29tbWVyY2lhbCBSb290IENB +IDEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCnUBneP5k91DNG8W9RYYKyqU+PZ4ld +hNlT3Qwo2dfw/66VQ3KZ+bVdfIrBQuExUHTRgQ18zZshq0PirK1ehm7zCYofWjK9ouuU+ehcCuz/ +mNKvcbO0U59Oh++SvL3sTzIwiEsXXlfEU8L2ApeN2WIrvyQfYo3fw7gpS0l4PJNgiCL8mdo2yMKi +1CxUAGc1bnO/AljwpN3lsKImesrgNqUZFvX9t++uP0D1bVoE/c40yiTcdCMbXTMTEl3EASX2MN0C +XZ/g1Ue9tOsbobtJSdifWwLziuQkkORiT0/Br4sOdBeo0XKIanoBScy0RnnGF7HamB4HWfp1IYVl +3ZBWzvurpWCdxJ35UrCLvYf5jysjCiN2O/cz4ckA82n5S6LgTrx+kzmEB/dEcH7+B1rlsazRGMzy +NeVJSQjKVsk9+w8YfYs7wRPCTY/JTw436R+hDmrfYi7LNQZReSzIJTj0+kuniVyc0uMNOYZKdHzV +WYfCP04MXFL0PfdSgvHqo6z9STQaKPNBiDoT7uje/5kdX7rL6B7yuVBgwDHTc+XvvqDtMwt0viAg +xGds8AgDelWAf0ZOlqf0Hj7h9tgJ4TNkK2PXMl6f+cB7D3hvl7yTmvmcEpB4eoCHFddydJxVdHix +uuFucAS6T6C6aMN7/zHwcz09lCqxC0EOoP5NiGVreTO01wIDAQABo0IwQDAOBgNVHQ8BAf8EBAMC +AQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQU7UQZwNPwBovupHu+QucmVMiONnYwDQYJKoZI +hvcNAQELBQADggIBAA2ukDL2pkt8RHYZYR4nKM1eVO8lvOMIkPkp165oCOGUAFjvLi5+U1KMtlwH +6oi6mYtQlNeCgN9hCQCTrQ0U5s7B8jeUeLBfnLOic7iPBZM4zY0+sLj7wM+x8uwtLRvM7Kqas6pg +ghstO8OEPVeKlh6cdbjTMM1gCIOQ045U8U1mwF10A0Cj7oV+wh93nAbowacYXVKV7cndJZ5t+qnt +ozo00Fl72u1Q8zW/7esUTTHHYPTa8Yec4kjixsU3+wYQ+nVZZjFHKdp2mhzpgq7vmrlR94gjmmmV +YjzlVYA211QC//G5Xc7UI2/YRYRKW2XviQzdFKcgyxilJbQN+QHwotL0AMh0jqEqSI5l2xPE4iUX +feu+h1sXIFRRk0pTAwvsXcoz7WL9RccvW9xYoIA55vrX/hMUpu09lEpCdNTDd1lzzY9GvlU47/ro +kTLql1gEIt44w8y8bckzOmoKaT+gyOpyj4xjhiO9bTyWnpXgSUyqorkqG5w2gXjtw+hG4iZZRHUe +2XWJUc0QhJ1hYMtd+ZciTY6Y5uN/9lu7rs3KSoFrXgvzUeF0K+l+J6fZmUlO+KWA2yUPHGNiiskz +Z2s8EIPGrd6ozRaOjfAHN3Gf8qv8QfXBi+wAN10J5U6A7/qxXDgGpRtK4dw4LTzcqx+QGtVKnO7R +cGzM7vRX+Bi6hG6H +-----END CERTIFICATE----- + +IdenTrust Public Sector Root CA 1 +================================= +-----BEGIN CERTIFICATE----- +MIIFZjCCA06gAwIBAgIQCgFCgAAAAUUjz0Z8AAAAAjANBgkqhkiG9w0BAQsFADBNMQswCQYDVQQG +EwJVUzESMBAGA1UEChMJSWRlblRydXN0MSowKAYDVQQDEyFJZGVuVHJ1c3QgUHVibGljIFNlY3Rv +ciBSb290IENBIDEwHhcNMTQwMTE2MTc1MzMyWhcNMzQwMTE2MTc1MzMyWjBNMQswCQYDVQQGEwJV +UzESMBAGA1UEChMJSWRlblRydXN0MSowKAYDVQQDEyFJZGVuVHJ1c3QgUHVibGljIFNlY3RvciBS +b290IENBIDEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC2IpT8pEiv6EdrCvsnduTy +P4o7ekosMSqMjbCpwzFrqHd2hCa2rIFCDQjrVVi7evi8ZX3yoG2LqEfpYnYeEe4IFNGyRBb06tD6 +Hi9e28tzQa68ALBKK0CyrOE7S8ItneShm+waOh7wCLPQ5CQ1B5+ctMlSbdsHyo+1W/CD80/HLaXI +rcuVIKQxKFdYWuSNG5qrng0M8gozOSI5Cpcu81N3uURF/YTLNiCBWS2ab21ISGHKTN9T0a9SvESf +qy9rg3LvdYDaBjMbXcjaY8ZNzaxmMc3R3j6HEDbhuaR672BQssvKplbgN6+rNBM5Jeg5ZuSYeqoS +mJxZZoY+rfGwyj4GD3vwEUs3oERte8uojHH01bWRNszwFcYr3lEXsZdMUD2xlVl8BX0tIdUAvwFn +ol57plzy9yLxkA2T26pEUWbMfXYD62qoKjgZl3YNa4ph+bz27nb9cCvdKTz4Ch5bQhyLVi9VGxyh +LrXHFub4qjySjmm2AcG1hp2JDws4lFTo6tyePSW8Uybt1as5qsVATFSrsrTZ2fjXctscvG29ZV/v +iDUqZi/u9rNl8DONfJhBaUYPQxxp+pu10GFqzcpL2UyQRqsVWaFHVCkugyhfHMKiq3IXAAaOReyL +4jM9f9oZRORicsPfIsbyVtTdX5Vy7W1f90gDW/3FKqD2cyOEEBsB5wIDAQABo0IwQDAOBgNVHQ8B +Af8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQU43HgntinQtnbcZFrlJPrw6PRFKMw +DQYJKoZIhvcNAQELBQADggIBAEf63QqwEZE4rU1d9+UOl1QZgkiHVIyqZJnYWv6IAcVYpZmxI1Qj +t2odIFflAWJBF9MJ23XLblSQdf4an4EKwt3X9wnQW3IV5B4Jaj0z8yGa5hV+rVHVDRDtfULAj+7A +mgjVQdZcDiFpboBhDhXAuM/FSRJSzL46zNQuOAXeNf0fb7iAaJg9TaDKQGXSc3z1i9kKlT/YPyNt +GtEqJBnZhbMX73huqVjRI9PHE+1yJX9dsXNw0H8GlwmEKYBhHfpe/3OsoOOJuBxxFcbeMX8S3OFt +m6/n6J91eEyrRjuazr8FGF1NFTwWmhlQBJqymm9li1JfPFgEKCXAZmExfrngdbkaqIHWchezxQMx +NRF4eKLg6TCMf4DfWN88uieW4oA0beOY02QnrEh+KHdcxiVhJfiFDGX6xDIvpZgF5PgLZxYWxoK4 +Mhn5+bl53B/N66+rDt0b20XkeucC4pVd/GnwU2lhlXV5C15V5jgclKlZM57IcXR5f1GJtshquDDI +ajjDbp7hNxbqBWJMWxJH7ae0s1hWx0nzfxJoCTFx8G34Tkf71oXuxVhAGaQdp/lLQzfcaFpPz+vC +ZHTetBXZ9FRUGi8c15dxVJCO2SCdUyt/q4/i6jC8UDfv8Ue1fXwsBOxonbRJRBD0ckscZOf85muQ +3Wl9af0AVqW3rLatt8o+Ae+c +-----END CERTIFICATE----- + +CFCA EV ROOT +============ +-----BEGIN CERTIFICATE----- +MIIFjTCCA3WgAwIBAgIEGErM1jANBgkqhkiG9w0BAQsFADBWMQswCQYDVQQGEwJDTjEwMC4GA1UE +CgwnQ2hpbmEgRmluYW5jaWFsIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MRUwEwYDVQQDDAxDRkNB +IEVWIFJPT1QwHhcNMTIwODA4MDMwNzAxWhcNMjkxMjMxMDMwNzAxWjBWMQswCQYDVQQGEwJDTjEw +MC4GA1UECgwnQ2hpbmEgRmluYW5jaWFsIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MRUwEwYDVQQD +DAxDRkNBIEVWIFJPT1QwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDXXWvNED8fBVnV +BU03sQ7smCuOFR36k0sXgiFxEFLXUWRwFsJVaU2OFW2fvwwbwuCjZ9YMrM8irq93VCpLTIpTUnrD +7i7es3ElweldPe6hL6P3KjzJIx1qqx2hp/Hz7KDVRM8Vz3IvHWOX6Jn5/ZOkVIBMUtRSqy5J35DN +uF++P96hyk0g1CXohClTt7GIH//62pCfCqktQT+x8Rgp7hZZLDRJGqgG16iI0gNyejLi6mhNbiyW +ZXvKWfry4t3uMCz7zEasxGPrb382KzRzEpR/38wmnvFyXVBlWY9ps4deMm/DGIq1lY+wejfeWkU7 +xzbh72fROdOXW3NiGUgthxwG+3SYIElz8AXSG7Ggo7cbcNOIabla1jj0Ytwli3i/+Oh+uFzJlU9f +py25IGvPa931DfSCt/SyZi4QKPaXWnuWFo8BGS1sbn85WAZkgwGDg8NNkt0yxoekN+kWzqotaK8K +gWU6cMGbrU1tVMoqLUuFG7OA5nBFDWteNfB/O7ic5ARwiRIlk9oKmSJgamNgTnYGmE69g60dWIol +hdLHZR4tjsbftsbhf4oEIRUpdPA+nJCdDC7xij5aqgwJHsfVPKPtl8MeNPo4+QgO48BdK4PRVmrJ +tqhUUy54Mmc9gn900PvhtgVguXDbjgv5E1hvcWAQUhC5wUEJ73IfZzF4/5YFjQIDAQABo2MwYTAf +BgNVHSMEGDAWgBTj/i39KNALtbq2osS/BqoFjJP7LzAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB +/wQEAwIBBjAdBgNVHQ4EFgQU4/4t/SjQC7W6tqLEvwaqBYyT+y8wDQYJKoZIhvcNAQELBQADggIB +ACXGumvrh8vegjmWPfBEp2uEcwPenStPuiB/vHiyz5ewG5zz13ku9Ui20vsXiObTej/tUxPQ4i9q +ecsAIyjmHjdXNYmEwnZPNDatZ8POQQaIxffu2Bq41gt/UP+TqhdLjOztUmCypAbqTuv0axn96/Ua +4CUqmtzHQTb3yHQFhDmVOdYLO6Qn+gjYXB74BGBSESgoA//vU2YApUo0FmZ8/Qmkrp5nGm9BC2sG +E5uPhnEFtC+NiWYzKXZUmhH4J/qyP5Hgzg0b8zAarb8iXRvTvyUFTeGSGn+ZnzxEk8rUQElsgIfX +BDrDMlI1Dlb4pd19xIsNER9Tyx6yF7Zod1rg1MvIB671Oi6ON7fQAUtDKXeMOZePglr4UeWJoBjn +aH9dCi77o0cOPaYjesYBx4/IXr9tgFa+iiS6M+qf4TIRnvHST4D2G0CvOJ4RUHlzEhLN5mydLIhy +PDCBBpEi6lmt2hkuIsKNuYyH4Ga8cyNfIWRjgEj1oDwYPZTISEEdQLpe/v5WOaHIz16eGWRGENoX +kbcFgKyLmZJ956LYBws2J+dIeWCKw9cTXPhyQN9Ky8+ZAAoACxGV2lZFA4gKn2fQ1XmxqI1AbQ3C +ekD6819kR5LLU7m7Wc5P/dAVUwHY3+vZ5nbv0CO7O6l5s9UCKc2Jo5YPSjXnTkLAdc0Hz+Ys63su +-----END CERTIFICATE----- + +OISTE WISeKey Global Root GB CA +=============================== +-----BEGIN CERTIFICATE----- +MIIDtTCCAp2gAwIBAgIQdrEgUnTwhYdGs/gjGvbCwDANBgkqhkiG9w0BAQsFADBtMQswCQYDVQQG +EwJDSDEQMA4GA1UEChMHV0lTZUtleTEiMCAGA1UECxMZT0lTVEUgRm91bmRhdGlvbiBFbmRvcnNl +ZDEoMCYGA1UEAxMfT0lTVEUgV0lTZUtleSBHbG9iYWwgUm9vdCBHQiBDQTAeFw0xNDEyMDExNTAw +MzJaFw0zOTEyMDExNTEwMzFaMG0xCzAJBgNVBAYTAkNIMRAwDgYDVQQKEwdXSVNlS2V5MSIwIAYD +VQQLExlPSVNURSBGb3VuZGF0aW9uIEVuZG9yc2VkMSgwJgYDVQQDEx9PSVNURSBXSVNlS2V5IEds +b2JhbCBSb290IEdCIENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2Be3HEokKtaX +scriHvt9OO+Y9bI5mE4nuBFde9IllIiCFSZqGzG7qFshISvYD06fWvGxWuR51jIjK+FTzJlFXHtP +rby/h0oLS5daqPZI7H17Dc0hBt+eFf1Biki3IPShehtX1F1Q/7pn2COZH8g/497/b1t3sWtuuMlk +9+HKQUYOKXHQuSP8yYFfTvdv37+ErXNku7dCjmn21HYdfp2nuFeKUWdy19SouJVUQHMD9ur06/4o +Qnc/nSMbsrY9gBQHTC5P99UKFg29ZkM3fiNDecNAhvVMKdqOmq0NpQSHiB6F4+lT1ZvIiwNjeOvg +GUpuuy9rM2RYk61pv48b74JIxwIDAQABo1EwTzALBgNVHQ8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB +/zAdBgNVHQ4EFgQUNQ/INmNe4qPs+TtmFc5RUuORmj0wEAYJKwYBBAGCNxUBBAMCAQAwDQYJKoZI +hvcNAQELBQADggEBAEBM+4eymYGQfp3FsLAmzYh7KzKNbrghcViXfa43FK8+5/ea4n32cZiZBKpD +dHij40lhPnOMTZTg+XHEthYOU3gf1qKHLwI5gSk8rxWYITD+KJAAjNHhy/peyP34EEY7onhCkRd0 +VQreUGdNZtGn//3ZwLWoo4rOZvUPQ82nK1d7Y0Zqqi5S2PTt4W2tKZB4SLrhI6qjiey1q5bAtEui +HZeeevJuQHHfaPFlTc58Bd9TZaml8LGXBHAVRgOY1NK/VLSgWH1Sb9pWJmLU2NuJMW8c8CLC02Ic +Nc1MaRVUGpCY3useX8p3x8uOPUNpnJpY0CQ73xtAln41rYHHTnG6iBM= +-----END CERTIFICATE----- + +SZAFIR ROOT CA2 +=============== +-----BEGIN CERTIFICATE----- +MIIDcjCCAlqgAwIBAgIUPopdB+xV0jLVt+O2XwHrLdzk1uQwDQYJKoZIhvcNAQELBQAwUTELMAkG +A1UEBhMCUEwxKDAmBgNVBAoMH0tyYWpvd2EgSXpiYSBSb3psaWN6ZW5pb3dhIFMuQS4xGDAWBgNV +BAMMD1NaQUZJUiBST09UIENBMjAeFw0xNTEwMTkwNzQzMzBaFw0zNTEwMTkwNzQzMzBaMFExCzAJ +BgNVBAYTAlBMMSgwJgYDVQQKDB9LcmFqb3dhIEl6YmEgUm96bGljemVuaW93YSBTLkEuMRgwFgYD +VQQDDA9TWkFGSVIgUk9PVCBDQTIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC3vD5Q +qEvNQLXOYeeWyrSh2gwisPq1e3YAd4wLz32ohswmUeQgPYUM1ljj5/QqGJ3a0a4m7utT3PSQ1hNK +DJA8w/Ta0o4NkjrcsbH/ON7Dui1fgLkCvUqdGw+0w8LBZwPd3BucPbOw3gAeqDRHu5rr/gsUvTaE +2g0gv/pby6kWIK05YO4vdbbnl5z5Pv1+TW9NL++IDWr63fE9biCloBK0TXC5ztdyO4mTp4CEHCdJ +ckm1/zuVnsHMyAHs6A6KCpbns6aH5db5BSsNl0BwPLqsdVqc1U2dAgrSS5tmS0YHF2Wtn2yIANwi +ieDhZNRnvDF5YTy7ykHNXGoAyDw4jlivAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0P +AQH/BAQDAgEGMB0GA1UdDgQWBBQuFqlKGLXLzPVvUPMjX/hd56zwyDANBgkqhkiG9w0BAQsFAAOC +AQEAtXP4A9xZWx126aMqe5Aosk3AM0+qmrHUuOQn/6mWmc5G4G18TKI4pAZw8PRBEew/R40/cof5 +O/2kbytTAOD/OblqBw7rHRz2onKQy4I9EYKL0rufKq8h5mOGnXkZ7/e7DDWQw4rtTw/1zBLZpD67 +oPwglV9PJi8RI4NOdQcPv5vRtB3pEAT+ymCPoky4rc/hkA/NrgrHXXu3UNLUYfrVFdvXn4dRVOul +4+vJhaAlIDf7js4MNIThPIGyd05DpYhfhmehPea0XGG2Ptv+tyjFogeutcrKjSoS75ftwjCkySp6 ++/NNIxuZMzSgLvWpCz/UXeHPhJ/iGcJfitYgHuNztw== +-----END CERTIFICATE----- + +Certum Trusted Network CA 2 +=========================== +-----BEGIN CERTIFICATE----- +MIIF0jCCA7qgAwIBAgIQIdbQSk8lD8kyN/yqXhKN6TANBgkqhkiG9w0BAQ0FADCBgDELMAkGA1UE +BhMCUEwxIjAgBgNVBAoTGVVuaXpldG8gVGVjaG5vbG9naWVzIFMuQS4xJzAlBgNVBAsTHkNlcnR1 +bSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTEkMCIGA1UEAxMbQ2VydHVtIFRydXN0ZWQgTmV0d29y +ayBDQSAyMCIYDzIwMTExMDA2MDgzOTU2WhgPMjA0NjEwMDYwODM5NTZaMIGAMQswCQYDVQQGEwJQ +TDEiMCAGA1UEChMZVW5pemV0byBUZWNobm9sb2dpZXMgUy5BLjEnMCUGA1UECxMeQ2VydHVtIENl +cnRpZmljYXRpb24gQXV0aG9yaXR5MSQwIgYDVQQDExtDZXJ0dW0gVHJ1c3RlZCBOZXR3b3JrIENB +IDIwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC9+Xj45tWADGSdhhuWZGc/IjoedQF9 +7/tcZ4zJzFxrqZHmuULlIEub2pt7uZld2ZuAS9eEQCsn0+i6MLs+CRqnSZXvK0AkwpfHp+6bJe+o +CgCXhVqqndwpyeI1B+twTUrWwbNWuKFBOJvR+zF/j+Bf4bE/D44WSWDXBo0Y+aomEKsq09DRZ40b +Rr5HMNUuctHFY9rnY3lEfktjJImGLjQ/KUxSiyqnwOKRKIm5wFv5HdnnJ63/mgKXwcZQkpsCLL2p +uTRZCr+ESv/f/rOf69me4Jgj7KZrdxYq28ytOxykh9xGc14ZYmhFV+SQgkK7QtbwYeDBoz1mo130 +GO6IyY0XRSmZMnUCMe4pJshrAua1YkV/NxVaI2iJ1D7eTiew8EAMvE0Xy02isx7QBlrd9pPPV3WZ +9fqGGmd4s7+W/jTcvedSVuWz5XV710GRBdxdaeOVDUO5/IOWOZV7bIBaTxNyxtd9KXpEulKkKtVB +Rgkg/iKgtlswjbyJDNXXcPiHUv3a76xRLgezTv7QCdpw75j6VuZt27VXS9zlLCUVyJ4ueE742pye +hizKV/Ma5ciSixqClnrDvFASadgOWkaLOusm+iPJtrCBvkIApPjW/jAux9JG9uWOdf3yzLnQh1vM +BhBgu4M1t15n3kfsmUjxpKEV/q2MYo45VU85FrmxY53/twIDAQABo0IwQDAPBgNVHRMBAf8EBTAD +AQH/MB0GA1UdDgQWBBS2oVQ5AsOgP46KvPrU+Bym0ToO/TAOBgNVHQ8BAf8EBAMCAQYwDQYJKoZI +hvcNAQENBQADggIBAHGlDs7k6b8/ONWJWsQCYftMxRQXLYtPU2sQF/xlhMcQSZDe28cmk4gmb3DW +Al45oPePq5a1pRNcgRRtDoGCERuKTsZPpd1iHkTfCVn0W3cLN+mLIMb4Ck4uWBzrM9DPhmDJ2vuA +L55MYIR4PSFk1vtBHxgP58l1cb29XN40hz5BsA72udY/CROWFC/emh1auVbONTqwX3BNXuMp8SMo +clm2q8KMZiYcdywmdjWLKKdpoPk79SPdhRB0yZADVpHnr7pH1BKXESLjokmUbOe3lEu6LaTaM4tM +pkT/WjzGHWTYtTHkpjx6qFcL2+1hGsvxznN3Y6SHb0xRONbkX8eftoEq5IVIeVheO/jbAoJnwTnb +w3RLPTYe+SmTiGhbqEQZIfCn6IENLOiTNrQ3ssqwGyZ6miUfmpqAnksqP/ujmv5zMnHCnsZy4Ypo +J/HkD7TETKVhk/iXEAcqMCWpuchxuO9ozC1+9eB+D4Kob7a6bINDd82Kkhehnlt4Fj1F4jNy3eFm +ypnTycUm/Q1oBEauttmbjL4ZvrHG8hnjXALKLNhvSgfZyTXaQHXyxKcZb55CEJh15pWLYLztxRLX +is7VmFxWlgPF7ncGNf/P5O4/E2Hu29othfDNrp2yGAlFw5Khchf8R7agCyzxxN5DaAhqXzvwdmP7 +zAYspsbiDrW5viSP +-----END CERTIFICATE----- + +Hellenic Academic and Research Institutions RootCA 2015 +======================================================= +-----BEGIN CERTIFICATE----- +MIIGCzCCA/OgAwIBAgIBADANBgkqhkiG9w0BAQsFADCBpjELMAkGA1UEBhMCR1IxDzANBgNVBAcT +BkF0aGVuczFEMEIGA1UEChM7SGVsbGVuaWMgQWNhZGVtaWMgYW5kIFJlc2VhcmNoIEluc3RpdHV0 +aW9ucyBDZXJ0LiBBdXRob3JpdHkxQDA+BgNVBAMTN0hlbGxlbmljIEFjYWRlbWljIGFuZCBSZXNl +YXJjaCBJbnN0aXR1dGlvbnMgUm9vdENBIDIwMTUwHhcNMTUwNzA3MTAxMTIxWhcNNDAwNjMwMTAx +MTIxWjCBpjELMAkGA1UEBhMCR1IxDzANBgNVBAcTBkF0aGVuczFEMEIGA1UEChM7SGVsbGVuaWMg +QWNhZGVtaWMgYW5kIFJlc2VhcmNoIEluc3RpdHV0aW9ucyBDZXJ0LiBBdXRob3JpdHkxQDA+BgNV +BAMTN0hlbGxlbmljIEFjYWRlbWljIGFuZCBSZXNlYXJjaCBJbnN0aXR1dGlvbnMgUm9vdENBIDIw +MTUwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDC+Kk/G4n8PDwEXT2QNrCROnk8Zlrv +bTkBSRq0t89/TSNTt5AA4xMqKKYx8ZEA4yjsriFBzh/a/X0SWwGDD7mwX5nh8hKDgE0GPt+sr+eh +iGsxr/CL0BgzuNtFajT0AoAkKAoCFZVedioNmToUW/bLy1O8E00BiDeUJRtCvCLYjqOWXjrZMts+ +6PAQZe104S+nfK8nNLspfZu2zwnI5dMK/IhlZXQK3HMcXM1AsRzUtoSMTFDPaI6oWa7CJ06CojXd +FPQf/7J31Ycvqm59JCfnxssm5uX+Zwdj2EUN3TpZZTlYepKZcj2chF6IIbjV9Cz82XBST3i4vTwr +i5WY9bPRaM8gFH5MXF/ni+X1NYEZN9cRCLdmvtNKzoNXADrDgfgXy5I2XdGj2HUb4Ysn6npIQf1F +GQatJ5lOwXBH3bWfgVMS5bGMSF0xQxfjjMZ6Y5ZLKTBOhE5iGV48zpeQpX8B653g+IuJ3SWYPZK2 +fu/Z8VFRfS0myGlZYeCsargqNhEEelC9MoS+L9xy1dcdFkfkR2YgP/SWxa+OAXqlD3pk9Q0Yh9mu +iNX6hME6wGkoLfINaFGq46V3xqSQDqE3izEjR8EJCOtu93ib14L8hCCZSRm2Ekax+0VVFqmjZayc +Bw/qa9wfLgZy7IaIEuQt218FL+TwA9MmM+eAws1CoRc0CwIDAQABo0IwQDAPBgNVHRMBAf8EBTAD +AQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUcRVnyMjJvXVdctA4GGqd83EkVAswDQYJKoZI +hvcNAQELBQADggIBAHW7bVRLqhBYRjTyYtcWNl0IXtVsyIe9tC5G8jH4fOpCtZMWVdyhDBKg2mF+ +D1hYc2Ryx+hFjtyp8iY/xnmMsVMIM4GwVhO+5lFc2JsKT0ucVlMC6U/2DWDqTUJV6HwbISHTGzrM +d/K4kPFox/la/vot9L/J9UUbzjgQKjeKeaO04wlshYaT/4mWJ3iBj2fjRnRUjtkNaeJK9E10A/+y +d+2VZ5fkscWrv2oj6NSU4kQoYsRL4vDY4ilrGnB+JGGTe08DMiUNRSQrlrRGar9KC/eaj8GsGsVn +82800vpzY4zvFrCopEYq+OsS7HK07/grfoxSwIuEVPkvPuNVqNxmsdnhX9izjFk0WaSrT2y7Hxjb +davYy5LNlDhhDgcGH0tGEPEVvo2FXDtKK4F5D7Rpn0lQl033DlZdwJVqwjbDG2jJ9SrcR5q+ss7F +Jej6A7na+RZukYT1HCjI/CbM1xyQVqdfbzoEvM14iQuODy+jqk+iGxI9FghAD/FGTNeqewjBCvVt +J94Cj8rDtSvK6evIIVM4pcw72Hc3MKJP2W/R8kCtQXoXxdZKNYm3QdV8hn9VTYNKpXMgwDqvkPGa +JI7ZjnHKe7iG2rKPmT4dEw0SEe7Uq/DpFXYC5ODfqiAeW2GFZECpkJcNrVPSWh2HagCXZWK0vm9q +p/UsQu0yrbYhnr68 +-----END CERTIFICATE----- + +Hellenic Academic and Research Institutions ECC RootCA 2015 +=========================================================== +-----BEGIN CERTIFICATE----- +MIICwzCCAkqgAwIBAgIBADAKBggqhkjOPQQDAjCBqjELMAkGA1UEBhMCR1IxDzANBgNVBAcTBkF0 +aGVuczFEMEIGA1UEChM7SGVsbGVuaWMgQWNhZGVtaWMgYW5kIFJlc2VhcmNoIEluc3RpdHV0aW9u +cyBDZXJ0LiBBdXRob3JpdHkxRDBCBgNVBAMTO0hlbGxlbmljIEFjYWRlbWljIGFuZCBSZXNlYXJj +aCBJbnN0aXR1dGlvbnMgRUNDIFJvb3RDQSAyMDE1MB4XDTE1MDcwNzEwMzcxMloXDTQwMDYzMDEw +MzcxMlowgaoxCzAJBgNVBAYTAkdSMQ8wDQYDVQQHEwZBdGhlbnMxRDBCBgNVBAoTO0hlbGxlbmlj +IEFjYWRlbWljIGFuZCBSZXNlYXJjaCBJbnN0aXR1dGlvbnMgQ2VydC4gQXV0aG9yaXR5MUQwQgYD +VQQDEztIZWxsZW5pYyBBY2FkZW1pYyBhbmQgUmVzZWFyY2ggSW5zdGl0dXRpb25zIEVDQyBSb290 +Q0EgMjAxNTB2MBAGByqGSM49AgEGBSuBBAAiA2IABJKgQehLgoRc4vgxEZmGZE4JJS+dQS8KrjVP +dJWyUWRrjWvmP3CV8AVER6ZyOFB2lQJajq4onvktTpnvLEhvTCUp6NFxW98dwXU3tNf6e3pCnGoK +Vlp8aQuqgAkkbH7BRqNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0O +BBYEFLQiC4KZJAEOnLvkDv2/+5cgk5kqMAoGCCqGSM49BAMCA2cAMGQCMGfOFmI4oqxiRaeplSTA +GiecMjvAwNW6qef4BENThe5SId6d9SWDPp5YSy/XZxMOIQIwBeF1Ad5o7SofTUwJCA3sS61kFyjn +dc5FZXIhF8siQQ6ME5g4mlRtm8rifOoCWCKR +-----END CERTIFICATE----- + +ISRG Root X1 +============ +-----BEGIN CERTIFICATE----- +MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAwTzELMAkGA1UE +BhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2VhcmNoIEdyb3VwMRUwEwYDVQQD +EwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQG +EwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMT +DElTUkcgUm9vdCBYMTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54r +Vygch77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+0TM8ukj1 +3Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6UA5/TR5d8mUgjU+g4rk8K +b4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sWT8KOEUt+zwvo/7V3LvSye0rgTBIlDHCN +Aymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyHB5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ +4Q7e2RCOFvu396j3x+UCB5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf +1b0SHzUvKBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWnOlFu +hjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTnjh8BCNAw1FtxNrQH +usEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbwqHyGO0aoSCqI3Haadr8faqU9GY/r +OPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CIrU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4G +A1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY +9umbbjANBgkqhkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL +ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ3BebYhtF8GaV +0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KKNFtY2PwByVS5uCbMiogziUwt +hDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJw +TdwJx4nLCgdNbOhdjsnvzqvHu7UrTkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nx +e5AW0wdeRlN8NwdCjNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZA +JzVcoyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq4RgqsahD +YVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPAmRGunUHBcnWEvgJBQl9n +JEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57demyPxgcYxn/eR44/KJ4EBs+lVDR3veyJ +m+kXQ99b21/+jh5Xos1AnX5iItreGCc= +-----END CERTIFICATE----- + +AC RAIZ FNMT-RCM +================ +-----BEGIN CERTIFICATE----- +MIIFgzCCA2ugAwIBAgIPXZONMGc2yAYdGsdUhGkHMA0GCSqGSIb3DQEBCwUAMDsxCzAJBgNVBAYT +AkVTMREwDwYDVQQKDAhGTk1ULVJDTTEZMBcGA1UECwwQQUMgUkFJWiBGTk1ULVJDTTAeFw0wODEw +MjkxNTU5NTZaFw0zMDAxMDEwMDAwMDBaMDsxCzAJBgNVBAYTAkVTMREwDwYDVQQKDAhGTk1ULVJD +TTEZMBcGA1UECwwQQUMgUkFJWiBGTk1ULVJDTTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoC +ggIBALpxgHpMhm5/yBNtwMZ9HACXjywMI7sQmkCpGreHiPibVmr75nuOi5KOpyVdWRHbNi63URcf +qQgfBBckWKo3Shjf5TnUV/3XwSyRAZHiItQDwFj8d0fsjz50Q7qsNI1NOHZnjrDIbzAzWHFctPVr +btQBULgTfmxKo0nRIBnuvMApGGWn3v7v3QqQIecaZ5JCEJhfTzC8PhxFtBDXaEAUwED653cXeuYL +j2VbPNmaUtu1vZ5Gzz3rkQUCwJaydkxNEJY7kvqcfw+Z374jNUUeAlz+taibmSXaXvMiwzn15Cou +08YfxGyqxRxqAQVKL9LFwag0Jl1mpdICIfkYtwb1TplvqKtMUejPUBjFd8g5CSxJkjKZqLsXF3mw +WsXmo8RZZUc1g16p6DULmbvkzSDGm0oGObVo/CK67lWMK07q87Hj/LaZmtVC+nFNCM+HHmpxffnT +tOmlcYF7wk5HlqX2doWjKI/pgG6BU6VtX7hI+cL5NqYuSf+4lsKMB7ObiFj86xsc3i1w4peSMKGJ +47xVqCfWS+2QrYv6YyVZLag13cqXM7zlzced0ezvXg5KkAYmY6252TUtB7p2ZSysV4999AeU14EC +ll2jB0nVetBX+RvnU0Z1qrB5QstocQjpYL05ac70r8NWQMetUqIJ5G+GR4of6ygnXYMgrwTJbFaa +i0b1AgMBAAGjgYMwgYAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYE +FPd9xf3E6Jobd2Sn9R2gzL+HYJptMD4GA1UdIAQ3MDUwMwYEVR0gADArMCkGCCsGAQUFBwIBFh1o +dHRwOi8vd3d3LmNlcnQuZm5tdC5lcy9kcGNzLzANBgkqhkiG9w0BAQsFAAOCAgEAB5BK3/MjTvDD +nFFlm5wioooMhfNzKWtN/gHiqQxjAb8EZ6WdmF/9ARP67Jpi6Yb+tmLSbkyU+8B1RXxlDPiyN8+s +D8+Nb/kZ94/sHvJwnvDKuO+3/3Y3dlv2bojzr2IyIpMNOmqOFGYMLVN0V2Ue1bLdI4E7pWYjJ2cJ +j+F3qkPNZVEI7VFY/uY5+ctHhKQV8Xa7pO6kO8Rf77IzlhEYt8llvhjho6Tc+hj507wTmzl6NLrT +Qfv6MooqtyuGC2mDOL7Nii4LcK2NJpLuHvUBKwrZ1pebbuCoGRw6IYsMHkCtA+fdZn71uSANA+iW ++YJF1DngoABd15jmfZ5nc8OaKveri6E6FO80vFIOiZiaBECEHX5FaZNXzuvO+FB8TxxuBEOb+dY7 +Ixjp6o7RTUaN8Tvkasq6+yO3m/qZASlaWFot4/nUbQ4mrcFuNLwy+AwF+mWj2zs3gyLp1txyM/1d +8iC9djwj2ij3+RvrWWTV3F9yfiD8zYm1kGdNYno/Tq0dwzn+evQoFt9B9kiABdcPUXmsEKvU7ANm +5mqwujGSQkBqvjrTcuFqN1W8rB2Vt2lh8kORdOag0wokRqEIr9baRRmW1FMdW4R58MD3R++Lj8UG +rp1MYp3/RgT408m2ECVAdf4WqslKYIYvuu8wd+RU4riEmViAqhOLUTpPSPaLtrM= +-----END CERTIFICATE----- + +Amazon Root CA 1 +================ +-----BEGIN CERTIFICATE----- +MIIDQTCCAimgAwIBAgITBmyfz5m/jAo54vB4ikPmljZbyjANBgkqhkiG9w0BAQsFADA5MQswCQYD +VQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6b24gUm9vdCBDQSAxMB4XDTE1 +MDUyNjAwMDAwMFoXDTM4MDExNzAwMDAwMFowOTELMAkGA1UEBhMCVVMxDzANBgNVBAoTBkFtYXpv +bjEZMBcGA1UEAxMQQW1hem9uIFJvb3QgQ0EgMTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC +ggEBALJ4gHHKeNXjca9HgFB0fW7Y14h29Jlo91ghYPl0hAEvrAIthtOgQ3pOsqTQNroBvo3bSMgH +FzZM9O6II8c+6zf1tRn4SWiw3te5djgdYZ6k/oI2peVKVuRF4fn9tBb6dNqcmzU5L/qwIFAGbHrQ +gLKm+a/sRxmPUDgH3KKHOVj4utWp+UhnMJbulHheb4mjUcAwhmahRWa6VOujw5H5SNz/0egwLX0t +dHA114gk957EWW67c4cX8jJGKLhD+rcdqsq08p8kDi1L93FcXmn/6pUCyziKrlA4b9v7LWIbxcce +VOF34GfID5yHI9Y/QCB/IIDEgEw+OyQmjgSubJrIqg0CAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB +/zAOBgNVHQ8BAf8EBAMCAYYwHQYDVR0OBBYEFIQYzIU07LwMlJQuCFmcx7IQTgoIMA0GCSqGSIb3 +DQEBCwUAA4IBAQCY8jdaQZChGsV2USggNiMOruYou6r4lK5IpDB/G/wkjUu0yKGX9rbxenDIU5PM +CCjjmCXPI6T53iHTfIUJrU6adTrCC2qJeHZERxhlbI1Bjjt/msv0tadQ1wUsN+gDS63pYaACbvXy +8MWy7Vu33PqUXHeeE6V/Uq2V8viTO96LXFvKWlJbYK8U90vvo/ufQJVtMVT8QtPHRh8jrdkPSHCa +2XV4cdFyQzR1bldZwgJcJmApzyMZFo6IQ6XU5MsI+yMRQ+hDKXJioaldXgjUkK642M4UwtBV8ob2 +xJNDd2ZhwLnoQdeXeGADbkpyrqXRfboQnoZsG4q5WTP468SQvvG5 +-----END CERTIFICATE----- + +Amazon Root CA 2 +================ +-----BEGIN CERTIFICATE----- +MIIFQTCCAymgAwIBAgITBmyf0pY1hp8KD+WGePhbJruKNzANBgkqhkiG9w0BAQwFADA5MQswCQYD +VQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6b24gUm9vdCBDQSAyMB4XDTE1 +MDUyNjAwMDAwMFoXDTQwMDUyNjAwMDAwMFowOTELMAkGA1UEBhMCVVMxDzANBgNVBAoTBkFtYXpv +bjEZMBcGA1UEAxMQQW1hem9uIFJvb3QgQ0EgMjCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoC +ggIBAK2Wny2cSkxKgXlRmeyKy2tgURO8TW0G/LAIjd0ZEGrHJgw12MBvIITplLGbhQPDW9tK6Mj4 +kHbZW0/jTOgGNk3Mmqw9DJArktQGGWCsN0R5hYGCrVo34A3MnaZMUnbqQ523BNFQ9lXg1dKmSYXp +N+nKfq5clU1Imj+uIFptiJXZNLhSGkOQsL9sBbm2eLfq0OQ6PBJTYv9K8nu+NQWpEjTj82R0Yiw9 +AElaKP4yRLuH3WUnAnE72kr3H9rN9yFVkE8P7K6C4Z9r2UXTu/Bfh+08LDmG2j/e7HJV63mjrdvd +fLC6HM783k81ds8P+HgfajZRRidhW+mez/CiVX18JYpvL7TFz4QuK/0NURBs+18bvBt+xa47mAEx +kv8LV/SasrlX6avvDXbR8O70zoan4G7ptGmh32n2M8ZpLpcTnqWHsFcQgTfJU7O7f/aS0ZzQGPSS +btqDT6ZjmUyl+17vIWR6IF9sZIUVyzfpYgwLKhbcAS4y2j5L9Z469hdAlO+ekQiG+r5jqFoz7Mt0 +Q5X5bGlSNscpb/xVA1wf+5+9R+vnSUeVC06JIglJ4PVhHvG/LopyboBZ/1c6+XUyo05f7O0oYtlN +c/LMgRdg7c3r3NunysV+Ar3yVAhU/bQtCSwXVEqY0VThUWcI0u1ufm8/0i2BWSlmy5A5lREedCf+ +3euvAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMB0GA1UdDgQWBBSw +DPBMMPQFWAJI/TPlUq9LhONmUjANBgkqhkiG9w0BAQwFAAOCAgEAqqiAjw54o+Ci1M3m9Zh6O+oA +A7CXDpO8Wqj2LIxyh6mx/H9z/WNxeKWHWc8w4Q0QshNabYL1auaAn6AFC2jkR2vHat+2/XcycuUY ++gn0oJMsXdKMdYV2ZZAMA3m3MSNjrXiDCYZohMr/+c8mmpJ5581LxedhpxfL86kSk5Nrp+gvU5LE +YFiwzAJRGFuFjWJZY7attN6a+yb3ACfAXVU3dJnJUH/jWS5E4ywl7uxMMne0nxrpS10gxdr9HIcW +xkPo1LsmmkVwXqkLN1PiRnsn/eBG8om3zEK2yygmbtmlyTrIQRNg91CMFa6ybRoVGld45pIq2WWQ +gj9sAq+uEjonljYE1x2igGOpm/HlurR8FLBOybEfdF849lHqm/osohHUqS0nGkWxr7JOcQ3AWEbW +aQbLU8uz/mtBzUF+fUwPfHJ5elnNXkoOrJupmHN5fLT0zLm4BwyydFy4x2+IoZCn9Kr5v2c69BoV +Yh63n749sSmvZ6ES8lgQGVMDMBu4Gon2nL2XA46jCfMdiyHxtN/kHNGfZQIG6lzWE7OE76KlXIx3 +KadowGuuQNKotOrN8I1LOJwZmhsoVLiJkO/KdYE+HvJkJMcYr07/R54H9jVlpNMKVv/1F2Rs76gi +JUmTtt8AF9pYfl3uxRuw0dFfIRDH+fO6AgonB8Xx1sfT4PsJYGw= +-----END CERTIFICATE----- + +Amazon Root CA 3 +================ +-----BEGIN CERTIFICATE----- +MIIBtjCCAVugAwIBAgITBmyf1XSXNmY/Owua2eiedgPySjAKBggqhkjOPQQDAjA5MQswCQYDVQQG +EwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6b24gUm9vdCBDQSAzMB4XDTE1MDUy +NjAwMDAwMFoXDTQwMDUyNjAwMDAwMFowOTELMAkGA1UEBhMCVVMxDzANBgNVBAoTBkFtYXpvbjEZ +MBcGA1UEAxMQQW1hem9uIFJvb3QgQ0EgMzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABCmXp8ZB +f8ANm+gBG1bG8lKlui2yEujSLtf6ycXYqm0fc4E7O5hrOXwzpcVOho6AF2hiRVd9RFgdszflZwjr +Zt6jQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMB0GA1UdDgQWBBSrttvXBp43 +rDCGB5Fwx5zEGbF4wDAKBggqhkjOPQQDAgNJADBGAiEA4IWSoxe3jfkrBqWTrBqYaGFy+uGh0Psc +eGCmQ5nFuMQCIQCcAu/xlJyzlvnrxir4tiz+OpAUFteMYyRIHN8wfdVoOw== +-----END CERTIFICATE----- + +Amazon Root CA 4 +================ +-----BEGIN CERTIFICATE----- +MIIB8jCCAXigAwIBAgITBmyf18G7EEwpQ+Vxe3ssyBrBDjAKBggqhkjOPQQDAzA5MQswCQYDVQQG +EwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6b24gUm9vdCBDQSA0MB4XDTE1MDUy +NjAwMDAwMFoXDTQwMDUyNjAwMDAwMFowOTELMAkGA1UEBhMCVVMxDzANBgNVBAoTBkFtYXpvbjEZ +MBcGA1UEAxMQQW1hem9uIFJvb3QgQ0EgNDB2MBAGByqGSM49AgEGBSuBBAAiA2IABNKrijdPo1MN +/sGKe0uoe0ZLY7Bi9i0b2whxIdIA6GO9mif78DluXeo9pcmBqqNbIJhFXRbb/egQbeOc4OO9X4Ri +83BkM6DLJC9wuoihKqB1+IGuYgbEgds5bimwHvouXKNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNV +HQ8BAf8EBAMCAYYwHQYDVR0OBBYEFNPsxzplbszh2naaVvuc84ZtV+WBMAoGCCqGSM49BAMDA2gA +MGUCMDqLIfG9fhGt0O9Yli/W651+kI0rz2ZVwyzjKKlwCkcO8DdZEv8tmZQoTipPNU0zWgIxAOp1 +AE47xDqUEpHJWEadIRNyp4iciuRMStuW1KyLa2tJElMzrdfkviT8tQp21KW8EA== +-----END CERTIFICATE----- + +TUBITAK Kamu SM SSL Kok Sertifikasi - Surum 1 +============================================= +-----BEGIN CERTIFICATE----- +MIIEYzCCA0ugAwIBAgIBATANBgkqhkiG9w0BAQsFADCB0jELMAkGA1UEBhMCVFIxGDAWBgNVBAcT +D0dlYnplIC0gS29jYWVsaTFCMEAGA1UEChM5VHVya2l5ZSBCaWxpbXNlbCB2ZSBUZWtub2xvamlr +IEFyYXN0aXJtYSBLdXJ1bXUgLSBUVUJJVEFLMS0wKwYDVQQLEyRLYW11IFNlcnRpZmlrYXN5b24g +TWVya2V6aSAtIEthbXUgU00xNjA0BgNVBAMTLVRVQklUQUsgS2FtdSBTTSBTU0wgS29rIFNlcnRp +ZmlrYXNpIC0gU3VydW0gMTAeFw0xMzExMjUwODI1NTVaFw00MzEwMjUwODI1NTVaMIHSMQswCQYD +VQQGEwJUUjEYMBYGA1UEBxMPR2ViemUgLSBLb2NhZWxpMUIwQAYDVQQKEzlUdXJraXllIEJpbGlt +c2VsIHZlIFRla25vbG9qaWsgQXJhc3Rpcm1hIEt1cnVtdSAtIFRVQklUQUsxLTArBgNVBAsTJEth +bXUgU2VydGlmaWthc3lvbiBNZXJrZXppIC0gS2FtdSBTTTE2MDQGA1UEAxMtVFVCSVRBSyBLYW11 +IFNNIFNTTCBLb2sgU2VydGlmaWthc2kgLSBTdXJ1bSAxMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A +MIIBCgKCAQEAr3UwM6q7a9OZLBI3hNmNe5eA027n/5tQlT6QlVZC1xl8JoSNkvoBHToP4mQ4t4y8 +6Ij5iySrLqP1N+RAjhgleYN1Hzv/bKjFxlb4tO2KRKOrbEz8HdDc72i9z+SqzvBV96I01INrN3wc +wv61A+xXzry0tcXtAA9TNypN9E8Mg/uGz8v+jE69h/mniyFXnHrfA2eJLJ2XYacQuFWQfw4tJzh0 +3+f92k4S400VIgLI4OD8D62K18lUUMw7D8oWgITQUVbDjlZ/iSIzL+aFCr2lqBs23tPcLG07xxO9 +WSMs5uWk99gL7eqQQESolbuT1dCANLZGeA4fAJNG4e7p+exPFwIDAQABo0IwQDAdBgNVHQ4EFgQU +ZT/HiobGPN08VFw1+DrtUgxHV8gwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wDQYJ +KoZIhvcNAQELBQADggEBACo/4fEyjq7hmFxLXs9rHmoJ0iKpEsdeV31zVmSAhHqT5Am5EM2fKifh +AHe+SMg1qIGf5LgsyX8OsNJLN13qudULXjS99HMpw+0mFZx+CFOKWI3QSyjfwbPfIPP54+M638yc +lNhOT8NrF7f3cuitZjO1JVOr4PhMqZ398g26rrnZqsZr+ZO7rqu4lzwDGrpDxpa5RXI4s6ehlj2R +e37AIVNMh+3yC1SVUZPVIqUNivGTDj5UDrDYyU7c8jEyVupk+eq1nRZmQnLzf9OxMUP8pI4X8W0j +q5Rm+K37DwhuJi1/FwcJsoz7UMCflo3Ptv0AnVoUmr8CRPXBwp8iXqIPoeM= +-----END CERTIFICATE----- + +GDCA TrustAUTH R5 ROOT +====================== +-----BEGIN CERTIFICATE----- +MIIFiDCCA3CgAwIBAgIIfQmX/vBH6nowDQYJKoZIhvcNAQELBQAwYjELMAkGA1UEBhMCQ04xMjAw +BgNVBAoMKUdVQU5HIERPTkcgQ0VSVElGSUNBVEUgQVVUSE9SSVRZIENPLixMVEQuMR8wHQYDVQQD +DBZHRENBIFRydXN0QVVUSCBSNSBST09UMB4XDTE0MTEyNjA1MTMxNVoXDTQwMTIzMTE1NTk1OVow +YjELMAkGA1UEBhMCQ04xMjAwBgNVBAoMKUdVQU5HIERPTkcgQ0VSVElGSUNBVEUgQVVUSE9SSVRZ +IENPLixMVEQuMR8wHQYDVQQDDBZHRENBIFRydXN0QVVUSCBSNSBST09UMIICIjANBgkqhkiG9w0B +AQEFAAOCAg8AMIICCgKCAgEA2aMW8Mh0dHeb7zMNOwZ+Vfy1YI92hhJCfVZmPoiC7XJjDp6L3TQs +AlFRwxn9WVSEyfFrs0yw6ehGXTjGoqcuEVe6ghWinI9tsJlKCvLriXBjTnnEt1u9ol2x8kECK62p +OqPseQrsXzrj/e+APK00mxqriCZ7VqKChh/rNYmDf1+uKU49tm7srsHwJ5uu4/Ts765/94Y9cnrr +pftZTqfrlYwiOXnhLQiPzLyRuEH3FMEjqcOtmkVEs7LXLM3GKeJQEK5cy4KOFxg2fZfmiJqwTTQJ +9Cy5WmYqsBebnh52nUpmMUHfP/vFBu8btn4aRjb3ZGM74zkYI+dndRTVdVeSN72+ahsmUPI2JgaQ +xXABZG12ZuGR224HwGGALrIuL4xwp9E7PLOR5G62xDtw8mySlwnNR30YwPO7ng/Wi64HtloPzgsM +R6flPri9fcebNaBhlzpBdRfMK5Z3KpIhHtmVdiBnaM8Nvd/WHwlqmuLMc3GkL30SgLdTMEZeS1SZ +D2fJpcjyIMGC7J0R38IC+xo70e0gmu9lZJIQDSri3nDxGGeCjGHeuLzRL5z7D9Ar7Rt2ueQ5Vfj4 +oR24qoAATILnsn8JuLwwoC8N9VKejveSswoAHQBUlwbgsQfZxw9cZX08bVlX5O2ljelAU58VS6Bx +9hoh49pwBiFYFIeFd3mqgnkCAwEAAaNCMEAwHQYDVR0OBBYEFOLJQJ9NzuiaoXzPDj9lxSmIahlR +MA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMA0GCSqGSIb3DQEBCwUAA4ICAQDRSVfg +p8xoWLoBDysZzY2wYUWsEe1jUGn4H3++Fo/9nesLqjJHdtJnJO29fDMylyrHBYZmDRd9FBUb1Ov9 +H5r2XpdptxolpAqzkT9fNqyL7FeoPueBihhXOYV0GkLH6VsTX4/5COmSdI31R9KrO9b7eGZONn35 +6ZLpBN79SWP8bfsUcZNnL0dKt7n/HipzcEYwv1ryL3ml4Y0M2fmyYzeMN2WFcGpcWwlyua1jPLHd ++PwyvzeG5LuOmCd+uh8W4XAR8gPfJWIyJyYYMoSf/wA6E7qaTfRPuBRwIrHKK5DOKcFw9C+df/KQ +HtZa37dG/OaG+svgIHZ6uqbL9XzeYqWxi+7egmaKTjowHz+Ay60nugxe19CxVsp3cbK1daFQqUBD +F8Io2c9Si1vIY9RCPqAzekYu9wogRlR+ak8x8YF+QnQ4ZXMn7sZ8uI7XpTrXmKGcjBBV09tL7ECQ +8s1uV9JiDnxXk7Gnbc2dg7sq5+W2O3FYrf3RRbxake5TFW/TRQl1brqQXR4EzzffHqhmsYzmIGrv +/EhOdJhCrylvLmrH+33RZjEizIYAfmaDDEL0vTSSwxrqT8p+ck0LcIymSLumoRT2+1hEmRSuqguT +aaApJUqlyyvdimYHFngVV3Eb7PVHhPOeMTd61X8kreS8/f3MboPoDKi3QWwH3b08hpcv0g== +-----END CERTIFICATE----- + +SSL.com Root Certification Authority RSA +======================================== +-----BEGIN CERTIFICATE----- +MIIF3TCCA8WgAwIBAgIIeyyb0xaAMpkwDQYJKoZIhvcNAQELBQAwfDELMAkGA1UEBhMCVVMxDjAM +BgNVBAgMBVRleGFzMRAwDgYDVQQHDAdIb3VzdG9uMRgwFgYDVQQKDA9TU0wgQ29ycG9yYXRpb24x +MTAvBgNVBAMMKFNTTC5jb20gUm9vdCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eSBSU0EwHhcNMTYw +MjEyMTczOTM5WhcNNDEwMjEyMTczOTM5WjB8MQswCQYDVQQGEwJVUzEOMAwGA1UECAwFVGV4YXMx +EDAOBgNVBAcMB0hvdXN0b24xGDAWBgNVBAoMD1NTTCBDb3Jwb3JhdGlvbjExMC8GA1UEAwwoU1NM +LmNvbSBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5IFJTQTCCAiIwDQYJKoZIhvcNAQEBBQAD +ggIPADCCAgoCggIBAPkP3aMrfcvQKv7sZ4Wm5y4bunfh4/WvpOz6Sl2RxFdHaxh3a3by/ZPkPQ/C +Fp4LZsNWlJ4Xg4XOVu/yFv0AYvUiCVToZRdOQbngT0aXqhvIuG5iXmmxX9sqAn78bMrzQdjt0Oj8 +P2FI7bADFB0QDksZ4LtO7IZl/zbzXmcCC52GVWH9ejjt/uIZALdvoVBidXQ8oPrIJZK0bnoix/ge +oeOy3ZExqysdBP+lSgQ36YWkMyv94tZVNHwZpEpox7Ko07fKoZOI68GXvIz5HdkihCR0xwQ9aqkp +k8zruFvh/l8lqjRYyMEjVJ0bmBHDOJx+PYZspQ9AhnwC9FwCTyjLrnGfDzrIM/4RJTXq/LrFYD3Z +fBjVsqnTdXgDciLKOsMf7yzlLqn6niy2UUb9rwPW6mBo6oUWNmuF6R7As93EJNyAKoFBbZQ+yODJ +gUEAnl6/f8UImKIYLEJAs/lvOCdLToD0PYFH4Ih86hzOtXVcUS4cK38acijnALXRdMbX5J+tB5O2 +UzU1/Dfkw/ZdFr4hc96SCvigY2q8lpJqPvi8ZVWb3vUNiSYE/CUapiVpy8JtynziWV+XrOvvLsi8 +1xtZPCvM8hnIk2snYxnP/Okm+Mpxm3+T/jRnhE6Z6/yzeAkzcLpmpnbtG3PrGqUNxCITIJRWCk4s +bE6x/c+cCbqiM+2HAgMBAAGjYzBhMB0GA1UdDgQWBBTdBAkHovV6fVJTEpKV7jiAJQ2mWTAPBgNV +HRMBAf8EBTADAQH/MB8GA1UdIwQYMBaAFN0ECQei9Xp9UlMSkpXuOIAlDaZZMA4GA1UdDwEB/wQE +AwIBhjANBgkqhkiG9w0BAQsFAAOCAgEAIBgRlCn7Jp0cHh5wYfGVcpNxJK1ok1iOMq8bs3AD/CUr +dIWQPXhq9LmLpZc7tRiRux6n+UBbkflVma8eEdBcHadm47GUBwwyOabqG7B52B2ccETjit3E+ZUf +ijhDPwGFpUenPUayvOUiaPd7nNgsPgohyC0zrL/FgZkxdMF1ccW+sfAjRfSda/wZY52jvATGGAsl +u1OJD7OAUN5F7kR/q5R4ZJjT9ijdh9hwZXT7DrkT66cPYakylszeu+1jTBi7qUD3oFRuIIhxdRjq +erQ0cuAjJ3dctpDqhiVAq+8zD8ufgr6iIPv2tS0a5sKFsXQP+8hlAqRSAUfdSSLBv9jra6x+3uxj +MxW3IwiPxg+NQVrdjsW5j+VFP3jbutIbQLH+cU0/4IGiul607BXgk90IH37hVZkLId6Tngr75qNJ +vTYw/ud3sqB1l7UtgYgXZSD32pAAn8lSzDLKNXz1PQ/YK9f1JmzJBjSWFupwWRoyeXkLtoh/D1JI +Pb9s2KJELtFOt3JY04kTlf5Eq/jXixtunLwsoFvVagCvXzfh1foQC5ichucmj87w7G6KVwuA406y +wKBjYZC6VWg3dGq2ktufoYYitmUnDuy2n0Jg5GfCtdpBC8TTi2EbvPofkSvXRAdeuims2cXp71NI +WuuA8ShYIc2wBlX7Jz9TkHCpBB5XJ7k= +-----END CERTIFICATE----- + +SSL.com Root Certification Authority ECC +======================================== +-----BEGIN CERTIFICATE----- +MIICjTCCAhSgAwIBAgIIdebfy8FoW6gwCgYIKoZIzj0EAwIwfDELMAkGA1UEBhMCVVMxDjAMBgNV +BAgMBVRleGFzMRAwDgYDVQQHDAdIb3VzdG9uMRgwFgYDVQQKDA9TU0wgQ29ycG9yYXRpb24xMTAv +BgNVBAMMKFNTTC5jb20gUm9vdCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eSBFQ0MwHhcNMTYwMjEy +MTgxNDAzWhcNNDEwMjEyMTgxNDAzWjB8MQswCQYDVQQGEwJVUzEOMAwGA1UECAwFVGV4YXMxEDAO +BgNVBAcMB0hvdXN0b24xGDAWBgNVBAoMD1NTTCBDb3Jwb3JhdGlvbjExMC8GA1UEAwwoU1NMLmNv +bSBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5IEVDQzB2MBAGByqGSM49AgEGBSuBBAAiA2IA +BEVuqVDEpiM2nl8ojRfLliJkP9x6jh3MCLOicSS6jkm5BBtHllirLZXI7Z4INcgn64mMU1jrYor+ +8FsPazFSY0E7ic3s7LaNGdM0B9y7xgZ/wkWV7Mt/qCPgCemB+vNH06NjMGEwHQYDVR0OBBYEFILR +hXMw5zUE044CkvvlpNHEIejNMA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAUgtGFczDnNQTT +jgKS++Wk0cQh6M0wDgYDVR0PAQH/BAQDAgGGMAoGCCqGSM49BAMCA2cAMGQCMG/n61kRpGDPYbCW +e+0F+S8Tkdzt5fxQaxFGRrMcIQBiu77D5+jNB5n5DQtdcj7EqgIwH7y6C+IwJPt8bYBVCpk+gA0z +5Wajs6O7pdWLjwkspl1+4vAHCGht0nxpbl/f5Wpl +-----END CERTIFICATE----- + +SSL.com EV Root Certification Authority RSA R2 +============================================== +-----BEGIN CERTIFICATE----- +MIIF6zCCA9OgAwIBAgIIVrYpzTS8ePYwDQYJKoZIhvcNAQELBQAwgYIxCzAJBgNVBAYTAlVTMQ4w +DAYDVQQIDAVUZXhhczEQMA4GA1UEBwwHSG91c3RvbjEYMBYGA1UECgwPU1NMIENvcnBvcmF0aW9u +MTcwNQYDVQQDDC5TU0wuY29tIEVWIFJvb3QgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkgUlNBIFIy +MB4XDTE3MDUzMTE4MTQzN1oXDTQyMDUzMDE4MTQzN1owgYIxCzAJBgNVBAYTAlVTMQ4wDAYDVQQI +DAVUZXhhczEQMA4GA1UEBwwHSG91c3RvbjEYMBYGA1UECgwPU1NMIENvcnBvcmF0aW9uMTcwNQYD +VQQDDC5TU0wuY29tIEVWIFJvb3QgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkgUlNBIFIyMIICIjAN +BgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAjzZlQOHWTcDXtOlG2mvqM0fNTPl9fb69LT3w23jh +hqXZuglXaO1XPqDQCEGD5yhBJB/jchXQARr7XnAjssufOePPxU7Gkm0mxnu7s9onnQqG6YE3Bf7w +cXHswxzpY6IXFJ3vG2fThVUCAtZJycxa4bH3bzKfydQ7iEGonL3Lq9ttewkfokxykNorCPzPPFTO +Zw+oz12WGQvE43LrrdF9HSfvkusQv1vrO6/PgN3B0pYEW3p+pKk8OHakYo6gOV7qd89dAFmPZiw+ +B6KjBSYRaZfqhbcPlgtLyEDhULouisv3D5oi53+aNxPN8k0TayHRwMwi8qFG9kRpnMphNQcAb9Zh +CBHqurj26bNg5U257J8UZslXWNvNh2n4ioYSA0e/ZhN2rHd9NCSFg83XqpyQGp8hLH94t2S42Oim +9HizVcuE0jLEeK6jj2HdzghTreyI/BXkmg3mnxp3zkyPuBQVPWKchjgGAGYS5Fl2WlPAApiiECto +RHuOec4zSnaqW4EWG7WK2NAAe15itAnWhmMOpgWVSbooi4iTsjQc2KRVbrcc0N6ZVTsj9CLg+Slm +JuwgUHfbSguPvuUCYHBBXtSuUDkiFCbLsjtzdFVHB3mBOagwE0TlBIqulhMlQg+5U8Sb/M3kHN48 ++qvWBkofZ6aYMBzdLNvcGJVXZsb/XItW9XcCAwEAAaNjMGEwDwYDVR0TAQH/BAUwAwEB/zAfBgNV +HSMEGDAWgBT5YLvU49U09rj1BoAlp3PbRmmonjAdBgNVHQ4EFgQU+WC71OPVNPa49QaAJadz20Zp +qJ4wDgYDVR0PAQH/BAQDAgGGMA0GCSqGSIb3DQEBCwUAA4ICAQBWs47LCp1Jjr+kxJG7ZhcFUZh1 +++VQLHqe8RT6q9OKPv+RKY9ji9i0qVQBDb6Thi/5Sm3HXvVX+cpVHBK+Rw82xd9qt9t1wkclf7nx +Y/hoLVUE0fKNsKTPvDxeH3jnpaAgcLAExbf3cqfeIg29MyVGjGSSJuM+LmOW2puMPfgYCdcDzH2G +guDKBAdRUNf/ktUM79qGn5nX67evaOI5JpS6aLe/g9Pqemc9YmeuJeVy6OLk7K4S9ksrPJ/psEDz +OFSz/bdoyNrGj1E8svuR3Bznm53htw1yj+KkxKl4+esUrMZDBcJlOSgYAsOCsp0FvmXtll9ldDz7 +CTUue5wT/RsPXcdtgTpWD8w74a8CLyKsRspGPKAcTNZEtF4uXBVmCeEmKf7GUmG6sXP/wwyc5Wxq +lD8UykAWlYTzWamsX0xhk23RO8yilQwipmdnRC652dKKQbNmC1r7fSOl8hqw/96bg5Qu0T/fkreR +rwU7ZcegbLHNYhLDkBvjJc40vG93drEQw/cFGsDWr3RiSBd3kmmQYRzelYB0VI8YHMPzA9C/pEN1 +hlMYegouCRw2n5H9gooiS9EOUCXdywMMF8mDAAhONU2Ki+3wApRmLER/y5UnlhetCTCstnEXbosX +9hwJ1C07mKVx01QT2WDz9UtmT/rx7iASjbSsV7FFY6GsdqnC+w== +-----END CERTIFICATE----- + +SSL.com EV Root Certification Authority ECC +=========================================== +-----BEGIN CERTIFICATE----- +MIIClDCCAhqgAwIBAgIILCmcWxbtBZUwCgYIKoZIzj0EAwIwfzELMAkGA1UEBhMCVVMxDjAMBgNV +BAgMBVRleGFzMRAwDgYDVQQHDAdIb3VzdG9uMRgwFgYDVQQKDA9TU0wgQ29ycG9yYXRpb24xNDAy +BgNVBAMMK1NTTC5jb20gRVYgUm9vdCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eSBFQ0MwHhcNMTYw +MjEyMTgxNTIzWhcNNDEwMjEyMTgxNTIzWjB/MQswCQYDVQQGEwJVUzEOMAwGA1UECAwFVGV4YXMx +EDAOBgNVBAcMB0hvdXN0b24xGDAWBgNVBAoMD1NTTCBDb3Jwb3JhdGlvbjE0MDIGA1UEAwwrU1NM +LmNvbSBFViBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5IEVDQzB2MBAGByqGSM49AgEGBSuB +BAAiA2IABKoSR5CYG/vvw0AHgyBO8TCCogbR8pKGYfL2IWjKAMTH6kMAVIbc/R/fALhBYlzccBYy +3h+Z1MzFB8gIH2EWB1E9fVwHU+M1OIzfzZ/ZLg1KthkuWnBaBu2+8KGwytAJKaNjMGEwHQYDVR0O +BBYEFFvKXuXe0oGqzagtZFG22XKbl+ZPMA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAUW8pe +5d7SgarNqC1kUbbZcpuX5k8wDgYDVR0PAQH/BAQDAgGGMAoGCCqGSM49BAMCA2gAMGUCMQCK5kCJ +N+vp1RPZytRrJPOwPYdGWBrssd9v+1a6cGvHOMzosYxPD/fxZ3YOg9AeUY8CMD32IygmTMZgh5Mm +m7I1HrrW9zzRHM76JTymGoEVW/MSD2zuZYrJh6j5B+BimoxcSg== +-----END CERTIFICATE----- + +GlobalSign Root CA - R6 +======================= +-----BEGIN CERTIFICATE----- +MIIFgzCCA2ugAwIBAgIORea7A4Mzw4VlSOb/RVEwDQYJKoZIhvcNAQEMBQAwTDEgMB4GA1UECxMX +R2xvYmFsU2lnbiBSb290IENBIC0gUjYxEzARBgNVBAoTCkdsb2JhbFNpZ24xEzARBgNVBAMTCkds +b2JhbFNpZ24wHhcNMTQxMjEwMDAwMDAwWhcNMzQxMjEwMDAwMDAwWjBMMSAwHgYDVQQLExdHbG9i +YWxTaWduIFJvb3QgQ0EgLSBSNjETMBEGA1UEChMKR2xvYmFsU2lnbjETMBEGA1UEAxMKR2xvYmFs +U2lnbjCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAJUH6HPKZvnsFMp7PPcNCPG0RQss +grRIxutbPK6DuEGSMxSkb3/pKszGsIhrxbaJ0cay/xTOURQh7ErdG1rG1ofuTToVBu1kZguSgMpE +3nOUTvOniX9PeGMIyBJQbUJmL025eShNUhqKGoC3GYEOfsSKvGRMIRxDaNc9PIrFsmbVkJq3MQbF +vuJtMgamHvm566qjuL++gmNQ0PAYid/kD3n16qIfKtJwLnvnvJO7bVPiSHyMEAc4/2ayd2F+4OqM +PKq0pPbzlUoSB239jLKJz9CgYXfIWHSw1CM69106yqLbnQneXUQtkPGBzVeS+n68UARjNN9rkxi+ +azayOeSsJDa38O+2HBNXk7besvjihbdzorg1qkXy4J02oW9UivFyVm4uiMVRQkQVlO6jxTiWm05O +WgtH8wY2SXcwvHE35absIQh1/OZhFj931dmRl4QKbNQCTXTAFO39OfuD8l4UoQSwC+n+7o/hbguy +CLNhZglqsQY6ZZZZwPA1/cnaKI0aEYdwgQqomnUdnjqGBQCe24DWJfncBZ4nWUx2OVvq+aWh2IMP +0f/fMBH5hc8zSPXKbWQULHpYT9NLCEnFlWQaYw55PfWzjMpYrZxCRXluDocZXFSxZba/jJvcE+kN +b7gu3GduyYsRtYQUigAZcIN5kZeR1BonvzceMgfYFGM8KEyvAgMBAAGjYzBhMA4GA1UdDwEB/wQE +AwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBSubAWjkxPioufi1xzWx/B/yGdToDAfBgNV +HSMEGDAWgBSubAWjkxPioufi1xzWx/B/yGdToDANBgkqhkiG9w0BAQwFAAOCAgEAgyXt6NH9lVLN +nsAEoJFp5lzQhN7craJP6Ed41mWYqVuoPId8AorRbrcWc+ZfwFSY1XS+wc3iEZGtIxg93eFyRJa0 +lV7Ae46ZeBZDE1ZXs6KzO7V33EByrKPrmzU+sQghoefEQzd5Mr6155wsTLxDKZmOMNOsIeDjHfrY +BzN2VAAiKrlNIC5waNrlU/yDXNOd8v9EDERm8tLjvUYAGm0CuiVdjaExUd1URhxN25mW7xocBFym +Fe944Hn+Xds+qkxV/ZoVqW/hpvvfcDDpw+5CRu3CkwWJ+n1jez/QcYF8AOiYrg54NMMl+68KnyBr +3TsTjxKM4kEaSHpzoHdpx7Zcf4LIHv5YGygrqGytXm3ABdJ7t+uA/iU3/gKbaKxCXcPu9czc8FB1 +0jZpnOZ7BN9uBmm23goJSFmH63sUYHpkqmlD75HHTOwY3WzvUy2MmeFe8nI+z1TIvWfspA9MRf/T +uTAjB0yPEL+GltmZWrSZVxykzLsViVO6LAUP5MSeGbEYNNVMnbrt9x+vJJUEeKgDu+6B5dpffItK +oZB0JaezPkvILFa9x8jvOOJckvB595yEunQtYQEgfn7R8k8HWV+LLUNS60YMlOH1Zkd5d9VUWx+t +JDfLRVpOoERIyNiwmcUVhAn21klJwGW45hpxbqCo8YLoRT5s1gLXCmeDBVrJpBA= +-----END CERTIFICATE----- + +OISTE WISeKey Global Root GC CA +=============================== +-----BEGIN CERTIFICATE----- +MIICaTCCAe+gAwIBAgIQISpWDK7aDKtARb8roi066jAKBggqhkjOPQQDAzBtMQswCQYDVQQGEwJD +SDEQMA4GA1UEChMHV0lTZUtleTEiMCAGA1UECxMZT0lTVEUgRm91bmRhdGlvbiBFbmRvcnNlZDEo +MCYGA1UEAxMfT0lTVEUgV0lTZUtleSBHbG9iYWwgUm9vdCBHQyBDQTAeFw0xNzA1MDkwOTQ4MzRa +Fw00MjA1MDkwOTU4MzNaMG0xCzAJBgNVBAYTAkNIMRAwDgYDVQQKEwdXSVNlS2V5MSIwIAYDVQQL +ExlPSVNURSBGb3VuZGF0aW9uIEVuZG9yc2VkMSgwJgYDVQQDEx9PSVNURSBXSVNlS2V5IEdsb2Jh +bCBSb290IEdDIENBMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAETOlQwMYPchi82PG6s4nieUqjFqdr +VCTbUf/q9Akkwwsin8tqJ4KBDdLArzHkdIJuyiXZjHWd8dvQmqJLIX4Wp2OQ0jnUsYd4XxiWD1Ab +NTcPasbc2RNNpI6QN+a9WzGRo1QwUjAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAd +BgNVHQ4EFgQUSIcUrOPDnpBgOtfKie7TrYy0UGYwEAYJKwYBBAGCNxUBBAMCAQAwCgYIKoZIzj0E +AwMDaAAwZQIwJsdpW9zV57LnyAyMjMPdeYwbY9XJUpROTYJKcx6ygISpJcBMWm1JKWB4E+J+SOtk +AjEA2zQgMgj/mkkCtojeFK9dbJlxjRo/i9fgojaGHAeCOnZT/cKi7e97sIBPWA9LUzm9 +-----END CERTIFICATE----- + +UCA Global G2 Root +================== +-----BEGIN CERTIFICATE----- +MIIFRjCCAy6gAwIBAgIQXd+x2lqj7V2+WmUgZQOQ7zANBgkqhkiG9w0BAQsFADA9MQswCQYDVQQG +EwJDTjERMA8GA1UECgwIVW5pVHJ1c3QxGzAZBgNVBAMMElVDQSBHbG9iYWwgRzIgUm9vdDAeFw0x +NjAzMTEwMDAwMDBaFw00MDEyMzEwMDAwMDBaMD0xCzAJBgNVBAYTAkNOMREwDwYDVQQKDAhVbmlU +cnVzdDEbMBkGA1UEAwwSVUNBIEdsb2JhbCBHMiBSb290MIICIjANBgkqhkiG9w0BAQEFAAOCAg8A +MIICCgKCAgEAxeYrb3zvJgUno4Ek2m/LAfmZmqkywiKHYUGRO8vDaBsGxUypK8FnFyIdK+35KYmT +oni9kmugow2ifsqTs6bRjDXVdfkX9s9FxeV67HeToI8jrg4aA3++1NDtLnurRiNb/yzmVHqUwCoV +8MmNsHo7JOHXaOIxPAYzRrZUEaalLyJUKlgNAQLx+hVRZ2zA+te2G3/RVogvGjqNO7uCEeBHANBS +h6v7hn4PJGtAnTRnvI3HLYZveT6OqTwXS3+wmeOwcWDcC/Vkw85DvG1xudLeJ1uK6NjGruFZfc8o +LTW4lVYa8bJYS7cSN8h8s+1LgOGN+jIjtm+3SJUIsUROhYw6AlQgL9+/V087OpAh18EmNVQg7Mc/ +R+zvWr9LesGtOxdQXGLYD0tK3Cv6brxzks3sx1DoQZbXqX5t2Okdj4q1uViSukqSKwxW/YDrCPBe +KW4bHAyvj5OJrdu9o54hyokZ7N+1wxrrFv54NkzWbtA+FxyQF2smuvt6L78RHBgOLXMDj6DlNaBa +4kx1HXHhOThTeEDMg5PXCp6dW4+K5OXgSORIskfNTip1KnvyIvbJvgmRlld6iIis7nCs+dwp4wwc +OxJORNanTrAmyPPZGpeRaOrvjUYG0lZFWJo8DA+DuAUlwznPO6Q0ibd5Ei9Hxeepl2n8pndntd97 +8XplFeRhVmUCAwEAAaNCMEAwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0O +BBYEFIHEjMz15DD/pQwIX4wVZyF0Ad/fMA0GCSqGSIb3DQEBCwUAA4ICAQATZSL1jiutROTL/7lo +5sOASD0Ee/ojL3rtNtqyzm325p7lX1iPyzcyochltq44PTUbPrw7tgTQvPlJ9Zv3hcU2tsu8+Mg5 +1eRfB70VVJd0ysrtT7q6ZHafgbiERUlMjW+i67HM0cOU2kTC5uLqGOiiHycFutfl1qnN3e92mI0A +Ds0b+gO3joBYDic/UvuUospeZcnWhNq5NXHzJsBPd+aBJ9J3O5oUb3n09tDh05S60FdRvScFDcH9 +yBIw7m+NESsIndTUv4BFFJqIRNow6rSn4+7vW4LVPtateJLbXDzz2K36uGt/xDYotgIVilQsnLAX +c47QN6MUPJiVAAwpBVueSUmxX8fjy88nZY41F7dXyDDZQVu5FLbowg+UMaeUmMxq67XhJ/UQqAHo +jhJi6IjMtX9Gl8CbEGY4GjZGXyJoPd/JxhMnq1MGrKI8hgZlb7F+sSlEmqO6SWkoaY/X5V+tBIZk +bxqgDMUIYs6Ao9Dz7GjevjPHF1t/gMRMTLGmhIrDO7gJzRSBuhjjVFc2/tsvfEehOjPI+Vg7RE+x +ygKJBJYoaMVLuCaJu9YzL1DV/pqJuhgyklTGW+Cd+V7lDSKb9triyCGyYiGqhkCyLmTTX8jjfhFn +RR8F/uOi77Oos/N9j/gMHyIfLXC0uAE0djAA5SN4p1bXUB+K+wb1whnw0A== +-----END CERTIFICATE----- + +UCA Extended Validation Root +============================ +-----BEGIN CERTIFICATE----- +MIIFWjCCA0KgAwIBAgIQT9Irj/VkyDOeTzRYZiNwYDANBgkqhkiG9w0BAQsFADBHMQswCQYDVQQG +EwJDTjERMA8GA1UECgwIVW5pVHJ1c3QxJTAjBgNVBAMMHFVDQSBFeHRlbmRlZCBWYWxpZGF0aW9u +IFJvb3QwHhcNMTUwMzEzMDAwMDAwWhcNMzgxMjMxMDAwMDAwWjBHMQswCQYDVQQGEwJDTjERMA8G +A1UECgwIVW5pVHJ1c3QxJTAjBgNVBAMMHFVDQSBFeHRlbmRlZCBWYWxpZGF0aW9uIFJvb3QwggIi +MA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCpCQcoEwKwmeBkqh5DFnpzsZGgdT6o+uM4AHrs +iWogD4vFsJszA1qGxliG1cGFu0/GnEBNyr7uaZa4rYEwmnySBesFK5pI0Lh2PpbIILvSsPGP2KxF +Rv+qZ2C0d35qHzwaUnoEPQc8hQ2E0B92CvdqFN9y4zR8V05WAT558aopO2z6+I9tTcg1367r3CTu +eUWnhbYFiN6IXSV8l2RnCdm/WhUFhvMJHuxYMjMR83dksHYf5BA1FxvyDrFspCqjc/wJHx4yGVMR +59mzLC52LqGj3n5qiAno8geK+LLNEOfic0CTuwjRP+H8C5SzJe98ptfRr5//lpr1kXuYC3fUfugH +0mK1lTnj8/FtDw5lhIpjVMWAtuCeS31HJqcBCF3RiJ7XwzJE+oJKCmhUfzhTA8ykADNkUVkLo4KR +el7sFsLzKuZi2irbWWIQJUoqgQtHB0MGcIfS+pMRKXpITeuUx3BNr2fVUbGAIAEBtHoIppB/TuDv +B0GHr2qlXov7z1CymlSvw4m6WC31MJixNnI5fkkE/SmnTHnkBVfblLkWU41Gsx2VYVdWf6/wFlth +WG82UBEL2KwrlRYaDh8IzTY0ZRBiZtWAXxQgXy0MoHgKaNYs1+lvK9JKBZP8nm9rZ/+I8U6laUpS +NwXqxhaN0sSZ0YIrO7o1dfdRUVjzyAfd5LQDfwIDAQABo0IwQDAdBgNVHQ4EFgQU2XQ65DA9DfcS +3H5aBZ8eNJr34RQwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAYYwDQYJKoZIhvcNAQEL +BQADggIBADaNl8xCFWQpN5smLNb7rhVpLGsaGvdftvkHTFnq88nIua7Mui563MD1sC3AO6+fcAUR +ap8lTwEpcOPlDOHqWnzcSbvBHiqB9RZLcpHIojG5qtr8nR/zXUACE/xOHAbKsxSQVBcZEhrxH9cM +aVr2cXj0lH2RC47skFSOvG+hTKv8dGT9cZr4QQehzZHkPJrgmzI5c6sq1WnIeJEmMX3ixzDx/BR4 +dxIOE/TdFpS/S2d7cFOFyrC78zhNLJA5wA3CXWvp4uXViI3WLL+rG761KIcSF3Ru/H38j9CHJrAb ++7lsq+KePRXBOy5nAliRn+/4Qh8st2j1da3Ptfb/EX3C8CSlrdP6oDyp+l3cpaDvRKS+1ujl5BOW +F3sGPjLtx7dCvHaj2GU4Kzg1USEODm8uNBNA4StnDG1KQTAYI1oyVZnJF+A83vbsea0rWBmirSwi +GpWOvpaQXUJXxPkUAzUrHC1RVwinOt4/5Mi0A3PCwSaAuwtCH60NryZy2sy+s6ODWA2CxR9GUeOc +GMyNm43sSet1UNWMKFnKdDTajAshqx7qG+XH/RU+wBeq+yNuJkbL+vmxcmtpzyKEC2IPrNkZAJSi +djzULZrtBJ4tBmIQN1IchXIbJ+XMxjHsN+xjWZsLHXbMfjKaiJUINlK73nZfdklJrX+9ZSCyycEr +dhh2n1ax +-----END CERTIFICATE----- + +Certigna Root CA +================ +-----BEGIN CERTIFICATE----- +MIIGWzCCBEOgAwIBAgIRAMrpG4nxVQMNo+ZBbcTjpuEwDQYJKoZIhvcNAQELBQAwWjELMAkGA1UE +BhMCRlIxEjAQBgNVBAoMCURoaW15b3RpczEcMBoGA1UECwwTMDAwMiA0ODE0NjMwODEwMDAzNjEZ +MBcGA1UEAwwQQ2VydGlnbmEgUm9vdCBDQTAeFw0xMzEwMDEwODMyMjdaFw0zMzEwMDEwODMyMjda +MFoxCzAJBgNVBAYTAkZSMRIwEAYDVQQKDAlEaGlteW90aXMxHDAaBgNVBAsMEzAwMDIgNDgxNDYz +MDgxMDAwMzYxGTAXBgNVBAMMEENlcnRpZ25hIFJvb3QgQ0EwggIiMA0GCSqGSIb3DQEBAQUAA4IC +DwAwggIKAoICAQDNGDllGlmx6mQWDoyUJJV8g9PFOSbcDO8WV43X2KyjQn+Cyu3NW9sOty3tRQgX +stmzy9YXUnIo245Onoq2C/mehJpNdt4iKVzSs9IGPjA5qXSjklYcoW9MCiBtnyN6tMbaLOQdLNyz +KNAT8kxOAkmhVECe5uUFoC2EyP+YbNDrihqECB63aCPuI9Vwzm1RaRDuoXrC0SIxwoKF0vJVdlB8 +JXrJhFwLrN1CTivngqIkicuQstDuI7pmTLtipPlTWmR7fJj6o0ieD5Wupxj0auwuA0Wv8HT4Ks16 +XdG+RCYyKfHx9WzMfgIhC59vpD++nVPiz32pLHxYGpfhPTc3GGYo0kDFUYqMwy3OU4gkWGQwFsWq +4NYKpkDfePb1BHxpE4S80dGnBs8B92jAqFe7OmGtBIyT46388NtEbVncSVmurJqZNjBBe3YzIoej +wpKGbvlw7q6Hh5UbxHq9MfPU0uWZ/75I7HX1eBYdpnDBfzwboZL7z8g81sWTCo/1VTp2lc5ZmIoJ +lXcymoO6LAQ6l73UL77XbJuiyn1tJslV1c/DeVIICZkHJC1kJWumIWmbat10TWuXekG9qxf5kBdI +jzb5LdXF2+6qhUVB+s06RbFo5jZMm5BX7CO5hwjCxAnxl4YqKE3idMDaxIzb3+KhF1nOJFl0Mdp/ +/TBt2dzhauH8XwIDAQABo4IBGjCCARYwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYw +HQYDVR0OBBYEFBiHVuBud+4kNTxOc5of1uHieX4rMB8GA1UdIwQYMBaAFBiHVuBud+4kNTxOc5of +1uHieX4rMEQGA1UdIAQ9MDswOQYEVR0gADAxMC8GCCsGAQUFBwIBFiNodHRwczovL3d3d3cuY2Vy +dGlnbmEuZnIvYXV0b3JpdGVzLzBtBgNVHR8EZjBkMC+gLaArhilodHRwOi8vY3JsLmNlcnRpZ25h +LmZyL2NlcnRpZ25hcm9vdGNhLmNybDAxoC+gLYYraHR0cDovL2NybC5kaGlteW90aXMuY29tL2Nl +cnRpZ25hcm9vdGNhLmNybDANBgkqhkiG9w0BAQsFAAOCAgEAlLieT/DjlQgi581oQfccVdV8AOIt +OoldaDgvUSILSo3L6btdPrtcPbEo/uRTVRPPoZAbAh1fZkYJMyjhDSSXcNMQH+pkV5a7XdrnxIxP +TGRGHVyH41neQtGbqH6mid2PHMkwgu07nM3A6RngatgCdTer9zQoKJHyBApPNeNgJgH60BGM+RFq +7q89w1DTj18zeTyGqHNFkIwgtnJzFyO+B2XleJINugHA64wcZr+shncBlA2c5uk5jR+mUYyZDDl3 +4bSb+hxnV29qao6pK0xXeXpXIs/NX2NGjVxZOob4Mkdio2cNGJHc+6Zr9UhhcyNZjgKnvETq9Emd +8VRY+WCv2hikLyhF3HqgiIZd8zvn/yk1gPxkQ5Tm4xxvvq0OKmOZK8l+hfZx6AYDlf7ej0gcWtSS +6Cvu5zHbugRqh5jnxV/vfaci9wHYTfmJ0A6aBVmknpjZbyvKcL5kwlWj9Omvw5Ip3IgWJJk8jSaY +tlu3zM63Nwf9JtmYhST/WSMDmu2dnajkXjjO11INb9I/bbEFa0nOipFGc/T2L/Coc3cOZayhjWZS +aX5LaAzHHjcng6WMxwLkFM1JAbBzs/3GkDpv0mztO+7skb6iQ12LAEpmJURw3kAP+HwV96LOPNde +E4yBFxgX0b3xdxA61GU5wSesVywlVP+i2k+KYTlerj1KjL0= +-----END CERTIFICATE----- + +emSign Root CA - G1 +=================== +-----BEGIN CERTIFICATE----- +MIIDlDCCAnygAwIBAgIKMfXkYgxsWO3W2DANBgkqhkiG9w0BAQsFADBnMQswCQYDVQQGEwJJTjET +MBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRl +ZDEcMBoGA1UEAxMTZW1TaWduIFJvb3QgQ0EgLSBHMTAeFw0xODAyMTgxODMwMDBaFw00MzAyMTgx +ODMwMDBaMGcxCzAJBgNVBAYTAklOMRMwEQYDVQQLEwplbVNpZ24gUEtJMSUwIwYDVQQKExxlTXVk +aHJhIFRlY2hub2xvZ2llcyBMaW1pdGVkMRwwGgYDVQQDExNlbVNpZ24gUm9vdCBDQSAtIEcxMIIB +IjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAk0u76WaK7p1b1TST0Bsew+eeuGQzf2N4aLTN +LnF115sgxk0pvLZoYIr3IZpWNVrzdr3YzZr/k1ZLpVkGoZM0Kd0WNHVO8oG0x5ZOrRkVUkr+PHB1 +cM2vK6sVmjM8qrOLqs1D/fXqcP/tzxE7lM5OMhbTI0Aqd7OvPAEsbO2ZLIvZTmmYsvePQbAyeGHW +DV/D+qJAkh1cF+ZwPjXnorfCYuKrpDhMtTk1b+oDafo6VGiFbdbyL0NVHpENDtjVaqSW0RM8LHhQ +6DqS0hdW5TUaQBw+jSztOd9C4INBdN+jzcKGYEho42kLVACL5HZpIQ15TjQIXhTCzLG3rdd8cIrH +hQIDAQABo0IwQDAdBgNVHQ4EFgQU++8Nhp6w492pufEhF38+/PB3KxowDgYDVR0PAQH/BAQDAgEG +MA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAFn/8oz1h31xPaOfG1vR2vjTnGs2 +vZupYeveFix0PZ7mddrXuqe8QhfnPZHr5X3dPpzxz5KsbEjMwiI/aTvFthUvozXGaCocV685743Q +NcMYDHsAVhzNixl03r4PEuDQqqE/AjSxcM6dGNYIAwlG7mDgfrbESQRRfXBgvKqy/3lyeqYdPV8q ++Mri/Tm3R7nrft8EI6/6nAYH6ftjk4BAtcZsCjEozgyfz7MjNYBBjWzEN3uBL4ChQEKF6dk4jeih +U80Bv2noWgbyRQuQ+q7hv53yrlc8pa6yVvSLZUDp/TGBLPQ5Cdjua6e0ph0VpZj3AYHYhX3zUVxx +iN66zB+Afko= +-----END CERTIFICATE----- + +emSign ECC Root CA - G3 +======================= +-----BEGIN CERTIFICATE----- +MIICTjCCAdOgAwIBAgIKPPYHqWhwDtqLhDAKBggqhkjOPQQDAzBrMQswCQYDVQQGEwJJTjETMBEG +A1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRlZDEg +MB4GA1UEAxMXZW1TaWduIEVDQyBSb290IENBIC0gRzMwHhcNMTgwMjE4MTgzMDAwWhcNNDMwMjE4 +MTgzMDAwWjBrMQswCQYDVQQGEwJJTjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11 +ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRlZDEgMB4GA1UEAxMXZW1TaWduIEVDQyBSb290IENBIC0g +RzMwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQjpQy4LRL1KPOxst3iAhKAnjlfSU2fySU0WXTsuwYc +58Byr+iuL+FBVIcUqEqy6HyC5ltqtdyzdc6LBtCGI79G1Y4PPwT01xySfvalY8L1X44uT6EYGQIr +MgqCZH0Wk9GjQjBAMB0GA1UdDgQWBBR8XQKEE9TMipuBzhccLikenEhjQjAOBgNVHQ8BAf8EBAMC +AQYwDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAwNpADBmAjEAvvNhzwIQHWSVB7gYboiFBS+D +CBeQyh+KTOgNG3qxrdWBCUfvO6wIBHxcmbHtRwfSAjEAnbpV/KlK6O3t5nYBQnvI+GDZjVGLVTv7 +jHvrZQnD+JbNR6iC8hZVdyR+EhCVBCyj +-----END CERTIFICATE----- + +emSign Root CA - C1 +=================== +-----BEGIN CERTIFICATE----- +MIIDczCCAlugAwIBAgILAK7PALrEzzL4Q7IwDQYJKoZIhvcNAQELBQAwVjELMAkGA1UEBhMCVVMx +EzARBgNVBAsTCmVtU2lnbiBQS0kxFDASBgNVBAoTC2VNdWRocmEgSW5jMRwwGgYDVQQDExNlbVNp +Z24gUm9vdCBDQSAtIEMxMB4XDTE4MDIxODE4MzAwMFoXDTQzMDIxODE4MzAwMFowVjELMAkGA1UE +BhMCVVMxEzARBgNVBAsTCmVtU2lnbiBQS0kxFDASBgNVBAoTC2VNdWRocmEgSW5jMRwwGgYDVQQD +ExNlbVNpZ24gUm9vdCBDQSAtIEMxMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAz+up +ufGZBczYKCFK83M0UYRWEPWgTywS4/oTmifQz/l5GnRfHXk5/Fv4cI7gklL35CX5VIPZHdPIWoU/ +Xse2B+4+wM6ar6xWQio5JXDWv7V7Nq2s9nPczdcdioOl+yuQFTdrHCZH3DspVpNqs8FqOp099cGX +OFgFixwR4+S0uF2FHYP+eF8LRWgYSKVGczQ7/g/IdrvHGPMF0Ybzhe3nudkyrVWIzqa2kbBPrH4V +I5b2P/AgNBbeCsbEBEV5f6f9vtKppa+cxSMq9zwhbL2vj07FOrLzNBL834AaSaTUqZX3noleooms +lMuoaJuvimUnzYnu3Yy1aylwQ6BpC+S5DwIDAQABo0IwQDAdBgNVHQ4EFgQU/qHgcB4qAzlSWkK+ +XJGFehiqTbUwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQAD +ggEBAMJKVvoVIXsoounlHfv4LcQ5lkFMOycsxGwYFYDGrK9HWS8mC+M2sO87/kOXSTKZEhVb3xEp +/6tT+LvBeA+snFOvV71ojD1pM/CjoCNjO2RnIkSt1XHLVip4kqNPEjE2NuLe/gDEo2APJ62gsIq1 +NnpSob0n9CAnYuhNlCQT5AoE6TyrLshDCUrGYQTlSTR+08TI9Q/Aqum6VF7zYytPT1DU/rl7mYw9 +wC68AivTxEDkigcxHpvOJpkT+xHqmiIMERnHXhuBUDDIlhJu58tBf5E7oke3VIAb3ADMmpDqw8NQ +BmIMMMAVSKeoWXzhriKi4gp6D/piq1JM4fHfyr6DDUI= +-----END CERTIFICATE----- + +emSign ECC Root CA - C3 +======================= +-----BEGIN CERTIFICATE----- +MIICKzCCAbGgAwIBAgIKe3G2gla4EnycqDAKBggqhkjOPQQDAzBaMQswCQYDVQQGEwJVUzETMBEG +A1UECxMKZW1TaWduIFBLSTEUMBIGA1UEChMLZU11ZGhyYSBJbmMxIDAeBgNVBAMTF2VtU2lnbiBF +Q0MgUm9vdCBDQSAtIEMzMB4XDTE4MDIxODE4MzAwMFoXDTQzMDIxODE4MzAwMFowWjELMAkGA1UE +BhMCVVMxEzARBgNVBAsTCmVtU2lnbiBQS0kxFDASBgNVBAoTC2VNdWRocmEgSW5jMSAwHgYDVQQD +ExdlbVNpZ24gRUNDIFJvb3QgQ0EgLSBDMzB2MBAGByqGSM49AgEGBSuBBAAiA2IABP2lYa57JhAd +6bciMK4G9IGzsUJxlTm801Ljr6/58pc1kjZGDoeVjbk5Wum739D+yAdBPLtVb4OjavtisIGJAnB9 +SMVK4+kiVCJNk7tCDK93nCOmfddhEc5lx/h//vXyqaNCMEAwHQYDVR0OBBYEFPtaSNCAIEDyqOkA +B2kZd6fmw/TPMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MAoGCCqGSM49BAMDA2gA +MGUCMQC02C8Cif22TGK6Q04ThHK1rt0c3ta13FaPWEBaLd4gTCKDypOofu4SQMfWh0/434UCMBwU +ZOR8loMRnLDRWmFLpg9J0wD8ofzkpf9/rdcw0Md3f76BB1UwUCAU9Vc4CqgxUQ== +-----END CERTIFICATE----- + +Hongkong Post Root CA 3 +======================= +-----BEGIN CERTIFICATE----- +MIIFzzCCA7egAwIBAgIUCBZfikyl7ADJk0DfxMauI7gcWqQwDQYJKoZIhvcNAQELBQAwbzELMAkG +A1UEBhMCSEsxEjAQBgNVBAgTCUhvbmcgS29uZzESMBAGA1UEBxMJSG9uZyBLb25nMRYwFAYDVQQK +Ew1Ib25na29uZyBQb3N0MSAwHgYDVQQDExdIb25na29uZyBQb3N0IFJvb3QgQ0EgMzAeFw0xNzA2 +MDMwMjI5NDZaFw00MjA2MDMwMjI5NDZaMG8xCzAJBgNVBAYTAkhLMRIwEAYDVQQIEwlIb25nIEtv +bmcxEjAQBgNVBAcTCUhvbmcgS29uZzEWMBQGA1UEChMNSG9uZ2tvbmcgUG9zdDEgMB4GA1UEAxMX +SG9uZ2tvbmcgUG9zdCBSb290IENBIDMwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCz +iNfqzg8gTr7m1gNt7ln8wlffKWihgw4+aMdoWJwcYEuJQwy51BWy7sFOdem1p+/l6TWZ5Mwc50tf +jTMwIDNT2aa71T4Tjukfh0mtUC1Qyhi+AViiE3CWu4mIVoBc+L0sPOFMV4i707mV78vH9toxdCim +5lSJ9UExyuUmGs2C4HDaOym71QP1mbpV9WTRYA6ziUm4ii8F0oRFKHyPaFASePwLtVPLwpgchKOe +sL4jpNrcyCse2m5FHomY2vkALgbpDDtw1VAliJnLzXNg99X/NWfFobxeq81KuEXryGgeDQ0URhLj +0mRiikKYvLTGCAj4/ahMZJx2Ab0vqWwzD9g/KLg8aQFChn5pwckGyuV6RmXpwtZQQS4/t+TtbNe/ +JgERohYpSms0BpDsE9K2+2p20jzt8NYt3eEV7KObLyzJPivkaTv/ciWxNoZbx39ri1UbSsUgYT2u +y1DhCDq+sI9jQVMwCFk8mB13umOResoQUGC/8Ne8lYePl8X+l2oBlKN8W4UdKjk60FSh0Tlxnf0h ++bV78OLgAo9uliQlLKAeLKjEiafv7ZkGL7YKTE/bosw3Gq9HhS2KX8Q0NEwA/RiTZxPRN+ZItIsG +xVd7GYYKecsAyVKvQv83j+GjHno9UKtjBucVtT+2RTeUN7F+8kjDf8V1/peNRY8apxpyKBpADwID +AQABo2MwYTAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAfBgNVHSMEGDAWgBQXnc0e +i9Y5K3DTXNSguB+wAPzFYTAdBgNVHQ4EFgQUF53NHovWOStw01zUoLgfsAD8xWEwDQYJKoZIhvcN +AQELBQADggIBAFbVe27mIgHSQpsY1Q7XZiNc4/6gx5LS6ZStS6LG7BJ8dNVI0lkUmcDrudHr9Egw +W62nV3OZqdPlt9EuWSRY3GguLmLYauRwCy0gUCCkMpXRAJi70/33MvJJrsZ64Ee+bs7Lo3I6LWld +y8joRTnU+kLBEUx3XZL7av9YROXrgZ6voJmtvqkBZss4HTzfQx/0TW60uhdG/H39h4F5ag0zD/ov ++BS5gLNdTaqX4fnkGMX41TiMJjz98iji7lpJiCzfeT2OnpA8vUFKOt1b9pq0zj8lMH8yfaIDlNDc +eqFS3m6TjRgm/VWsvY+b0s+v54Ysyx8Jb6NvqYTUc79NoXQbTiNg8swOqn+knEwlqLJmOzj/2ZQw +9nKEvmhVEA/GcywWaZMH/rFF7buiVWqw2rVKAiUnhde3t4ZEFolsgCs+l6mc1X5VTMbeRRAc6uk7 +nwNT7u56AQIWeNTowr5GdogTPyK7SBIdUgC0An4hGh6cJfTzPV4e0hz5sy229zdcxsshTrD3mUcY +hcErulWuBurQB7Lcq9CClnXO0lD+mefPL5/ndtFhKvshuzHQqp9HpLIiyhY6UFfEW0NnxWViA0kB +60PZ2Pierc+xYw5F9KBaLJstxabArahH9CdMOA0uG0k7UvToiIMrVCjU8jVStDKDYmlkDJGcn5fq +dBb9HxEGmpv0 +-----END CERTIFICATE----- + +Microsoft ECC Root Certificate Authority 2017 +============================================= +-----BEGIN CERTIFICATE----- +MIICWTCCAd+gAwIBAgIQZvI9r4fei7FK6gxXMQHC7DAKBggqhkjOPQQDAzBlMQswCQYDVQQGEwJV +UzEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMTYwNAYDVQQDEy1NaWNyb3NvZnQgRUND +IFJvb3QgQ2VydGlmaWNhdGUgQXV0aG9yaXR5IDIwMTcwHhcNMTkxMjE4MjMwNjQ1WhcNNDIwNzE4 +MjMxNjA0WjBlMQswCQYDVQQGEwJVUzEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMTYw +NAYDVQQDEy1NaWNyb3NvZnQgRUNDIFJvb3QgQ2VydGlmaWNhdGUgQXV0aG9yaXR5IDIwMTcwdjAQ +BgcqhkjOPQIBBgUrgQQAIgNiAATUvD0CQnVBEyPNgASGAlEvaqiBYgtlzPbKnR5vSmZRogPZnZH6 +thaxjG7efM3beaYvzrvOcS/lpaso7GMEZpn4+vKTEAXhgShC48Zo9OYbhGBKia/teQ87zvH2RPUB +eMCjVDBSMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBTIy5lycFIM ++Oa+sgRXKSrPQhDtNTAQBgkrBgEEAYI3FQEEAwIBADAKBggqhkjOPQQDAwNoADBlAjBY8k3qDPlf +Xu5gKcs68tvWMoQZP3zVL8KxzJOuULsJMsbG7X7JNpQS5GiFBqIb0C8CMQCZ6Ra0DvpWSNSkMBaR +eNtUjGUBiudQZsIxtzm6uBoiB078a1QWIP8rtedMDE2mT3M= +-----END CERTIFICATE----- + +Microsoft RSA Root Certificate Authority 2017 +============================================= +-----BEGIN CERTIFICATE----- +MIIFqDCCA5CgAwIBAgIQHtOXCV/YtLNHcB6qvn9FszANBgkqhkiG9w0BAQwFADBlMQswCQYDVQQG +EwJVUzEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMTYwNAYDVQQDEy1NaWNyb3NvZnQg +UlNBIFJvb3QgQ2VydGlmaWNhdGUgQXV0aG9yaXR5IDIwMTcwHhcNMTkxMjE4MjI1MTIyWhcNNDIw +NzE4MjMwMDIzWjBlMQswCQYDVQQGEwJVUzEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9u +MTYwNAYDVQQDEy1NaWNyb3NvZnQgUlNBIFJvb3QgQ2VydGlmaWNhdGUgQXV0aG9yaXR5IDIwMTcw +ggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDKW76UM4wplZEWCpW9R2LBifOZNt9GkMml +7Xhqb0eRaPgnZ1AzHaGm++DlQ6OEAlcBXZxIQIJTELy/xztokLaCLeX0ZdDMbRnMlfl7rEqUrQ7e +S0MdhweSE5CAg2Q1OQT85elss7YfUJQ4ZVBcF0a5toW1HLUX6NZFndiyJrDKxHBKrmCk3bPZ7Pw7 +1VdyvD/IybLeS2v4I2wDwAW9lcfNcztmgGTjGqwu+UcF8ga2m3P1eDNbx6H7JyqhtJqRjJHTOoI+ +dkC0zVJhUXAoP8XFWvLJjEm7FFtNyP9nTUwSlq31/niol4fX/V4ggNyhSyL71Imtus5Hl0dVe49F +yGcohJUcaDDv70ngNXtk55iwlNpNhTs+VcQor1fznhPbRiefHqJeRIOkpcrVE7NLP8TjwuaGYaRS +MLl6IE9vDzhTyzMMEyuP1pq9KsgtsRx9S1HKR9FIJ3Jdh+vVReZIZZ2vUpC6W6IYZVcSn2i51BVr +lMRpIpj0M+Dt+VGOQVDJNE92kKz8OMHY4Xu54+OU4UZpyw4KUGsTuqwPN1q3ErWQgR5WrlcihtnJ +0tHXUeOrO8ZV/R4O03QK0dqq6mm4lyiPSMQH+FJDOvTKVTUssKZqwJz58oHhEmrARdlns87/I6KJ +ClTUFLkqqNfs+avNJVgyeY+QW5g5xAgGwax/Dj0ApQIDAQABo1QwUjAOBgNVHQ8BAf8EBAMCAYYw +DwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUCctZf4aycI8awznjwNnpv7tNsiMwEAYJKwYBBAGC +NxUBBAMCAQAwDQYJKoZIhvcNAQEMBQADggIBAKyvPl3CEZaJjqPnktaXFbgToqZCLgLNFgVZJ8og +6Lq46BrsTaiXVq5lQ7GPAJtSzVXNUzltYkyLDVt8LkS/gxCP81OCgMNPOsduET/m4xaRhPtthH80 +dK2Jp86519efhGSSvpWhrQlTM93uCupKUY5vVau6tZRGrox/2KJQJWVggEbbMwSubLWYdFQl3JPk ++ONVFT24bcMKpBLBaYVu32TxU5nhSnUgnZUP5NbcA/FZGOhHibJXWpS2qdgXKxdJ5XbLwVaZOjex +/2kskZGT4d9Mozd2TaGf+G0eHdP67Pv0RR0Tbc/3WeUiJ3IrhvNXuzDtJE3cfVa7o7P4NHmJweDy +AmH3pvwPuxwXC65B2Xy9J6P9LjrRk5Sxcx0ki69bIImtt2dmefU6xqaWM/5TkshGsRGRxpl/j8nW +ZjEgQRCHLQzWwa80mMpkg/sTV9HB8Dx6jKXB/ZUhoHHBk2dxEuqPiAppGWSZI1b7rCoucL5mxAyE +7+WL85MB+GqQk2dLsmijtWKP6T+MejteD+eMuMZ87zf9dOLITzNy4ZQ5bb0Sr74MTnB8G2+NszKT +c0QWbej09+CVgI+WXTik9KveCjCHk9hNAHFiRSdLOkKEW39lt2c0Ui2cFmuqqNh7o0JMcccMyj6D +5KbvtwEwXlGjefVwaaZBRA+GsCyRxj3qrg+E +-----END CERTIFICATE----- + +e-Szigno Root CA 2017 +===================== +-----BEGIN CERTIFICATE----- +MIICQDCCAeWgAwIBAgIMAVRI7yH9l1kN9QQKMAoGCCqGSM49BAMCMHExCzAJBgNVBAYTAkhVMREw +DwYDVQQHDAhCdWRhcGVzdDEWMBQGA1UECgwNTWljcm9zZWMgTHRkLjEXMBUGA1UEYQwOVkFUSFUt +MjM1ODQ0OTcxHjAcBgNVBAMMFWUtU3ppZ25vIFJvb3QgQ0EgMjAxNzAeFw0xNzA4MjIxMjA3MDZa +Fw00MjA4MjIxMjA3MDZaMHExCzAJBgNVBAYTAkhVMREwDwYDVQQHDAhCdWRhcGVzdDEWMBQGA1UE +CgwNTWljcm9zZWMgTHRkLjEXMBUGA1UEYQwOVkFUSFUtMjM1ODQ0OTcxHjAcBgNVBAMMFWUtU3pp +Z25vIFJvb3QgQ0EgMjAxNzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJbcPYrYsHtvxie+RJCx +s1YVe45DJH0ahFnuY2iyxl6H0BVIHqiQrb1TotreOpCmYF9oMrWGQd+HWyx7xf58etqjYzBhMA8G +A1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBSHERUI0arBeAyxr87GyZDv +vzAEwDAfBgNVHSMEGDAWgBSHERUI0arBeAyxr87GyZDvvzAEwDAKBggqhkjOPQQDAgNJADBGAiEA +tVfd14pVCzbhhkT61NlojbjcI4qKDdQvfepz7L9NbKgCIQDLpbQS+ue16M9+k/zzNY9vTlp8tLxO +svxyqltZ+efcMQ== +-----END CERTIFICATE----- + +certSIGN Root CA G2 +=================== +-----BEGIN CERTIFICATE----- +MIIFRzCCAy+gAwIBAgIJEQA0tk7GNi02MA0GCSqGSIb3DQEBCwUAMEExCzAJBgNVBAYTAlJPMRQw +EgYDVQQKEwtDRVJUU0lHTiBTQTEcMBoGA1UECxMTY2VydFNJR04gUk9PVCBDQSBHMjAeFw0xNzAy +MDYwOTI3MzVaFw00MjAyMDYwOTI3MzVaMEExCzAJBgNVBAYTAlJPMRQwEgYDVQQKEwtDRVJUU0lH +TiBTQTEcMBoGA1UECxMTY2VydFNJR04gUk9PVCBDQSBHMjCCAiIwDQYJKoZIhvcNAQEBBQADggIP +ADCCAgoCggIBAMDFdRmRfUR0dIf+DjuW3NgBFszuY5HnC2/OOwppGnzC46+CjobXXo9X69MhWf05 +N0IwvlDqtg+piNguLWkh59E3GE59kdUWX2tbAMI5Qw02hVK5U2UPHULlj88F0+7cDBrZuIt4Imfk +abBoxTzkbFpG583H+u/E7Eu9aqSs/cwoUe+StCmrqzWaTOTECMYmzPhpn+Sc8CnTXPnGFiWeI8Mg +wT0PPzhAsP6CRDiqWhqKa2NYOLQV07YRaXseVO6MGiKscpc/I1mbySKEwQdPzH/iV8oScLumZfNp +dWO9lfsbl83kqK/20U6o2YpxJM02PbyWxPFsqa7lzw1uKA2wDrXKUXt4FMMgL3/7FFXhEZn91Qqh +ngLjYl/rNUssuHLoPj1PrCy7Lobio3aP5ZMqz6WryFyNSwb/EkaseMsUBzXgqd+L6a8VTxaJW732 +jcZZroiFDsGJ6x9nxUWO/203Nit4ZoORUSs9/1F3dmKh7Gc+PoGD4FapUB8fepmrY7+EF3fxDTvf +95xhszWYijqy7DwaNz9+j5LP2RIUZNoQAhVB/0/E6xyjyfqZ90bp4RjZsbgyLcsUDFDYg2WD7rlc +z8sFWkz6GZdr1l0T08JcVLwyc6B49fFtHsufpaafItzRUZ6CeWRgKRM+o/1Pcmqr4tTluCRVLERL +iohEnMqE0yo7AgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0GA1Ud +DgQWBBSCIS1mxteg4BXrzkwJd8RgnlRuAzANBgkqhkiG9w0BAQsFAAOCAgEAYN4auOfyYILVAzOB +ywaK8SJJ6ejqkX/GM15oGQOGO0MBzwdw5AgeZYWR5hEit/UCI46uuR59H35s5r0l1ZUa8gWmr4UC +b6741jH/JclKyMeKqdmfS0mbEVeZkkMR3rYzpMzXjWR91M08KCy0mpbqTfXERMQlqiCA2ClV9+BB +/AYm/7k29UMUA2Z44RGx2iBfRgB4ACGlHgAoYXhvqAEBj500mv/0OJD7uNGzcgbJceaBxXntC6Z5 +8hMLnPddDnskk7RI24Zf3lCGeOdA5jGokHZwYa+cNywRtYK3qq4kNFtyDGkNzVmf9nGvnAvRCjj5 +BiKDUyUM/FHE5r7iOZULJK2v0ZXkltd0ZGtxTgI8qoXzIKNDOXZbbFD+mpwUHmUUihW9o4JFWklW +atKcsWMy5WHgUyIOpwpJ6st+H6jiYoD2EEVSmAYY3qXNL3+q1Ok+CHLsIwMCPKaq2LxndD0UF/tU +Sxfj03k9bWtJySgOLnRQvwzZRjoQhsmnP+mg7H/rpXdYaXHmgwo38oZJar55CJD2AhZkPuXaTH4M +NMn5X7azKFGnpyuqSfqNZSlO42sTp5SjLVFteAxEy9/eCG/Oo2Sr05WE1LlSVHJ7liXMvGnjSG4N +0MedJ5qq+BOS3R7fY581qRY27Iy4g/Q9iY/NtBde17MXQRBdJ3NghVdJIgc= +-----END CERTIFICATE----- + +NAVER Global Root Certification Authority +========================================= +-----BEGIN CERTIFICATE----- +MIIFojCCA4qgAwIBAgIUAZQwHqIL3fXFMyqxQ0Rx+NZQTQ0wDQYJKoZIhvcNAQEMBQAwaTELMAkG +A1UEBhMCS1IxJjAkBgNVBAoMHU5BVkVSIEJVU0lORVNTIFBMQVRGT1JNIENvcnAuMTIwMAYDVQQD +DClOQVZFUiBHbG9iYWwgUm9vdCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTAeFw0xNzA4MTgwODU4 +NDJaFw0zNzA4MTgyMzU5NTlaMGkxCzAJBgNVBAYTAktSMSYwJAYDVQQKDB1OQVZFUiBCVVNJTkVT +UyBQTEFURk9STSBDb3JwLjEyMDAGA1UEAwwpTkFWRVIgR2xvYmFsIFJvb3QgQ2VydGlmaWNhdGlv +biBBdXRob3JpdHkwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC21PGTXLVAiQqrDZBb +UGOukJR0F0Vy1ntlWilLp1agS7gvQnXp2XskWjFlqxcX0TM62RHcQDaH38dq6SZeWYp34+hInDEW ++j6RscrJo+KfziFTowI2MMtSAuXaMl3Dxeb57hHHi8lEHoSTGEq0n+USZGnQJoViAbbJAh2+g1G7 +XNr4rRVqmfeSVPc0W+m/6imBEtRTkZazkVrd/pBzKPswRrXKCAfHcXLJZtM0l/aM9BhK4dA9WkW2 +aacp+yPOiNgSnABIqKYPszuSjXEOdMWLyEz59JuOuDxp7W87UC9Y7cSw0BwbagzivESq2M0UXZR4 +Yb8ObtoqvC8MC3GmsxY/nOb5zJ9TNeIDoKAYv7vxvvTWjIcNQvcGufFt7QSUqP620wbGQGHfnZ3z +VHbOUzoBppJB7ASjjw2i1QnK1sua8e9DXcCrpUHPXFNwcMmIpi3Ua2FzUCaGYQ5fG8Ir4ozVu53B +A0K6lNpfqbDKzE0K70dpAy8i+/Eozr9dUGWokG2zdLAIx6yo0es+nPxdGoMuK8u180SdOqcXYZai +cdNwlhVNt0xz7hlcxVs+Qf6sdWA7G2POAN3aCJBitOUt7kinaxeZVL6HSuOpXgRM6xBtVNbv8ejy +YhbLgGvtPe31HzClrkvJE+2KAQHJuFFYwGY6sWZLxNUxAmLpdIQM201GLQIDAQABo0IwQDAdBgNV +HQ4EFgQU0p+I36HNLL3s9TsBAZMzJ7LrYEswDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMB +Af8wDQYJKoZIhvcNAQEMBQADggIBADLKgLOdPVQG3dLSLvCkASELZ0jKbY7gyKoNqo0hV4/GPnrK +21HUUrPUloSlWGB/5QuOH/XcChWB5Tu2tyIvCZwTFrFsDDUIbatjcu3cvuzHV+YwIHHW1xDBE1UB +jCpD5EHxzzp6U5LOogMFDTjfArsQLtk70pt6wKGm+LUx5vR1yblTmXVHIloUFcd4G7ad6Qz4G3bx +hYTeodoS76TiEJd6eN4MUZeoIUCLhr0N8F5OSza7OyAfikJW4Qsav3vQIkMsRIz75Sq0bBwcupTg +E34h5prCy8VCZLQelHsIJchxzIdFV4XTnyliIoNRlwAYl3dqmJLJfGBs32x9SuRwTMKeuB330DTH +D8z7p/8Dvq1wkNoL3chtl1+afwkyQf3NosxabUzyqkn+Zvjp2DXrDige7kgvOtB5CTh8piKCk5XQ +A76+AqAF3SAi428diDRgxuYKuQl1C/AH6GmWNcf7I4GOODm4RStDeKLRLBT/DShycpWbXgnbiUSY +qqFJu3FS8r/2/yehNq+4tneI3TqkbZs0kNwUXTC/t+sX5Ie3cdCh13cV1ELX8vMxmV2b3RZtP+oG +I/hGoiLtk/bdmuYqh7GYVPEi92tF4+KOdh2ajcQGjTa3FPOdVGm3jjzVpG2Tgbet9r1ke8LJaDmg +kpzNNIaRkPpkUZ3+/uul9XXeifdy +-----END CERTIFICATE----- + +AC RAIZ FNMT-RCM SERVIDORES SEGUROS +=================================== +-----BEGIN CERTIFICATE----- +MIICbjCCAfOgAwIBAgIQYvYybOXE42hcG2LdnC6dlTAKBggqhkjOPQQDAzB4MQswCQYDVQQGEwJF +UzERMA8GA1UECgwIRk5NVC1SQ00xDjAMBgNVBAsMBUNlcmVzMRgwFgYDVQRhDA9WQVRFUy1RMjgy +NjAwNEoxLDAqBgNVBAMMI0FDIFJBSVogRk5NVC1SQ00gU0VSVklET1JFUyBTRUdVUk9TMB4XDTE4 +MTIyMDA5MzczM1oXDTQzMTIyMDA5MzczM1oweDELMAkGA1UEBhMCRVMxETAPBgNVBAoMCEZOTVQt +UkNNMQ4wDAYDVQQLDAVDZXJlczEYMBYGA1UEYQwPVkFURVMtUTI4MjYwMDRKMSwwKgYDVQQDDCNB +QyBSQUlaIEZOTVQtUkNNIFNFUlZJRE9SRVMgU0VHVVJPUzB2MBAGByqGSM49AgEGBSuBBAAiA2IA +BPa6V1PIyqvfNkpSIeSX0oNnnvBlUdBeh8dHsVnyV0ebAAKTRBdp20LHsbI6GA60XYyzZl2hNPk2 +LEnb80b8s0RpRBNm/dfF/a82Tc4DTQdxz69qBdKiQ1oKUm8BA06Oi6NCMEAwDwYDVR0TAQH/BAUw +AwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFAG5L++/EYZg8k/QQW6rcx/n0m5JMAoGCCqG +SM49BAMDA2kAMGYCMQCuSuMrQMN0EfKVrRYj3k4MGuZdpSRea0R7/DjiT8ucRRcRTBQnJlU5dUoD +zBOQn5ICMQD6SmxgiHPz7riYYqnOK8LZiqZwMR2vsJRM60/G49HzYqc8/5MuB1xJAWdpEgJyv+c= +-----END CERTIFICATE----- + +GlobalSign Root R46 +=================== +-----BEGIN CERTIFICATE----- +MIIFWjCCA0KgAwIBAgISEdK7udcjGJ5AXwqdLdDfJWfRMA0GCSqGSIb3DQEBDAUAMEYxCzAJBgNV +BAYTAkJFMRkwFwYDVQQKExBHbG9iYWxTaWduIG52LXNhMRwwGgYDVQQDExNHbG9iYWxTaWduIFJv +b3QgUjQ2MB4XDTE5MDMyMDAwMDAwMFoXDTQ2MDMyMDAwMDAwMFowRjELMAkGA1UEBhMCQkUxGTAX +BgNVBAoTEEdsb2JhbFNpZ24gbnYtc2ExHDAaBgNVBAMTE0dsb2JhbFNpZ24gUm9vdCBSNDYwggIi +MA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCsrHQy6LNl5brtQyYdpokNRbopiLKkHWPd08Es +CVeJOaFV6Wc0dwxu5FUdUiXSE2te4R2pt32JMl8Nnp8semNgQB+msLZ4j5lUlghYruQGvGIFAha/ +r6gjA7aUD7xubMLL1aa7DOn2wQL7Id5m3RerdELv8HQvJfTqa1VbkNud316HCkD7rRlr+/fKYIje +2sGP1q7Vf9Q8g+7XFkyDRTNrJ9CG0Bwta/OrffGFqfUo0q3v84RLHIf8E6M6cqJaESvWJ3En7YEt +bWaBkoe0G1h6zD8K+kZPTXhc+CtI4wSEy132tGqzZfxCnlEmIyDLPRT5ge1lFgBPGmSXZgjPjHvj +K8Cd+RTyG/FWaha/LIWFzXg4mutCagI0GIMXTpRW+LaCtfOW3T3zvn8gdz57GSNrLNRyc0NXfeD4 +12lPFzYE+cCQYDdF3uYM2HSNrpyibXRdQr4G9dlkbgIQrImwTDsHTUB+JMWKmIJ5jqSngiCNI/on +ccnfxkF0oE32kRbcRoxfKWMxWXEM2G/CtjJ9++ZdU6Z+Ffy7dXxd7Pj2Fxzsx2sZy/N78CsHpdls +eVR2bJ0cpm4O6XkMqCNqo98bMDGfsVR7/mrLZqrcZdCinkqaByFrgY/bxFn63iLABJzjqls2k+g9 +vXqhnQt2sQvHnf3PmKgGwvgqo6GDoLclcqUC4wIDAQABo0IwQDAOBgNVHQ8BAf8EBAMCAYYwDwYD +VR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUA1yrc4GHqMywptWU4jaWSf8FmSwwDQYJKoZIhvcNAQEM +BQADggIBAHx47PYCLLtbfpIrXTncvtgdokIzTfnvpCo7RGkerNlFo048p9gkUbJUHJNOxO97k4Vg +JuoJSOD1u8fpaNK7ajFxzHmuEajwmf3lH7wvqMxX63bEIaZHU1VNaL8FpO7XJqti2kM3S+LGteWy +gxk6x9PbTZ4IevPuzz5i+6zoYMzRx6Fcg0XERczzF2sUyQQCPtIkpnnpHs6i58FZFZ8d4kuaPp92 +CC1r2LpXFNqD6v6MVenQTqnMdzGxRBF6XLE+0xRFFRhiJBPSy03OXIPBNvIQtQ6IbbjhVp+J3pZm +OUdkLG5NrmJ7v2B0GbhWrJKsFjLtrWhV/pi60zTe9Mlhww6G9kuEYO4Ne7UyWHmRVSyBQ7N0H3qq +JZ4d16GLuc1CLgSkZoNNiTW2bKg2SnkheCLQQrzRQDGQob4Ez8pn7fXwgNNgyYMqIgXQBztSvwye +qiv5u+YfjyW6hY0XHgL+XVAEV8/+LbzvXMAaq7afJMbfc2hIkCwU9D9SGuTSyxTDYWnP4vkYxboz +nxSjBF25cfe1lNj2M8FawTSLfJvdkzrnE6JwYZ+vj+vYxXX4M2bUdGc6N3ec592kD3ZDZopD8p/7 +DEJ4Y9HiD2971KE9dJeFt0g5QdYg/NA6s/rob8SKunE3vouXsXgxT7PntgMTzlSdriVZzH81Xwj3 +QEUxeCp6 +-----END CERTIFICATE----- + +GlobalSign Root E46 +=================== +-----BEGIN CERTIFICATE----- +MIICCzCCAZGgAwIBAgISEdK7ujNu1LzmJGjFDYQdmOhDMAoGCCqGSM49BAMDMEYxCzAJBgNVBAYT +AkJFMRkwFwYDVQQKExBHbG9iYWxTaWduIG52LXNhMRwwGgYDVQQDExNHbG9iYWxTaWduIFJvb3Qg +RTQ2MB4XDTE5MDMyMDAwMDAwMFoXDTQ2MDMyMDAwMDAwMFowRjELMAkGA1UEBhMCQkUxGTAXBgNV +BAoTEEdsb2JhbFNpZ24gbnYtc2ExHDAaBgNVBAMTE0dsb2JhbFNpZ24gUm9vdCBFNDYwdjAQBgcq +hkjOPQIBBgUrgQQAIgNiAAScDrHPt+ieUnd1NPqlRqetMhkytAepJ8qUuwzSChDH2omwlwxwEwkB +jtjqR+q+soArzfwoDdusvKSGN+1wCAB16pMLey5SnCNoIwZD7JIvU4Tb+0cUB+hflGddyXqBPCCj +QjBAMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBQxCpCPtsad0kRL +gLWi5h+xEk8blTAKBggqhkjOPQQDAwNoADBlAjEA31SQ7Zvvi5QCkxeCmb6zniz2C5GMn0oUsfZk +vLtoURMMA/cVi4RguYv/Uo7njLwcAjA8+RHUjE7AwWHCFUyqqx0LMV87HOIAl0Qx5v5zli/altP+ +CAezNIm8BZ/3Hobui3A= +-----END CERTIFICATE----- + +ANF Secure Server Root CA +========================= +-----BEGIN CERTIFICATE----- +MIIF7zCCA9egAwIBAgIIDdPjvGz5a7EwDQYJKoZIhvcNAQELBQAwgYQxEjAQBgNVBAUTCUc2MzI4 +NzUxMDELMAkGA1UEBhMCRVMxJzAlBgNVBAoTHkFORiBBdXRvcmlkYWQgZGUgQ2VydGlmaWNhY2lv +bjEUMBIGA1UECxMLQU5GIENBIFJhaXoxIjAgBgNVBAMTGUFORiBTZWN1cmUgU2VydmVyIFJvb3Qg +Q0EwHhcNMTkwOTA0MTAwMDM4WhcNMzkwODMwMTAwMDM4WjCBhDESMBAGA1UEBRMJRzYzMjg3NTEw +MQswCQYDVQQGEwJFUzEnMCUGA1UEChMeQU5GIEF1dG9yaWRhZCBkZSBDZXJ0aWZpY2FjaW9uMRQw +EgYDVQQLEwtBTkYgQ0EgUmFpejEiMCAGA1UEAxMZQU5GIFNlY3VyZSBTZXJ2ZXIgUm9vdCBDQTCC +AiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANvrayvmZFSVgpCjcqQZAZ2cC4Ffc0m6p6zz +BE57lgvsEeBbphzOG9INgxwruJ4dfkUyYA8H6XdYfp9qyGFOtibBTI3/TO80sh9l2Ll49a2pcbnv +T1gdpd50IJeh7WhM3pIXS7yr/2WanvtH2Vdy8wmhrnZEE26cLUQ5vPnHO6RYPUG9tMJJo8gN0pcv +B2VSAKduyK9o7PQUlrZXH1bDOZ8rbeTzPvY1ZNoMHKGESy9LS+IsJJ1tk0DrtSOOMspvRdOoiXse +zx76W0OLzc2oD2rKDF65nkeP8Nm2CgtYZRczuSPkdxl9y0oukntPLxB3sY0vaJxizOBQ+OyRp1RM +VwnVdmPF6GUe7m1qzwmd+nxPrWAI/VaZDxUse6mAq4xhj0oHdkLePfTdsiQzW7i1o0TJrH93PB0j +7IKppuLIBkwC/qxcmZkLLxCKpvR/1Yd0DVlJRfbwcVw5Kda/SiOL9V8BY9KHcyi1Swr1+KuCLH5z +JTIdC2MKF4EA/7Z2Xue0sUDKIbvVgFHlSFJnLNJhiQcND85Cd8BEc5xEUKDbEAotlRyBr+Qc5RQe +8TZBAQIvfXOn3kLMTOmJDVb3n5HUA8ZsyY/b2BzgQJhdZpmYgG4t/wHFzstGH6wCxkPmrqKEPMVO +Hj1tyRRM4y5Bu8o5vzY8KhmqQYdOpc5LMnndkEl/AgMBAAGjYzBhMB8GA1UdIwQYMBaAFJxf0Gxj +o1+TypOYCK2Mh6UsXME3MB0GA1UdDgQWBBScX9BsY6Nfk8qTmAitjIelLFzBNzAOBgNVHQ8BAf8E +BAMCAYYwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAgEATh65isagmD9uw2nAalxJ +UqzLK114OMHVVISfk/CHGT0sZonrDUL8zPB1hT+L9IBdeeUXZ701guLyPI59WzbLWoAAKfLOKyzx +j6ptBZNscsdW699QIyjlRRA96Gejrw5VD5AJYu9LWaL2U/HANeQvwSS9eS9OICI7/RogsKQOLHDt +dD+4E5UGUcjohybKpFtqFiGS3XNgnhAY3jyB6ugYw3yJ8otQPr0R4hUDqDZ9MwFsSBXXiJCZBMXM +5gf0vPSQ7RPi6ovDj6MzD8EpTBNO2hVWcXNyglD2mjN8orGoGjR0ZVzO0eurU+AagNjqOknkJjCb +5RyKqKkVMoaZkgoQI1YS4PbOTOK7vtuNknMBZi9iPrJyJ0U27U1W45eZ/zo1PqVUSlJZS2Db7v54 +EX9K3BR5YLZrZAPbFYPhor72I5dQ8AkzNqdxliXzuUJ92zg/LFis6ELhDtjTO0wugumDLmsx2d1H +hk9tl5EuT+IocTUW0fJz/iUrB0ckYyfI+PbZa/wSMVYIwFNCr5zQM378BvAxRAMU8Vjq8moNqRGy +g77FGr8H6lnco4g175x2MjxNBiLOFeXdntiP2t7SxDnlF4HPOEfrf4htWRvfn0IUrn7PqLBmZdo3 +r5+qPeoott7VMVgWglvquxl1AnMaykgaIZOQCo6ThKd9OyMYkomgjaw= +-----END CERTIFICATE----- + +Certum EC-384 CA +================ +-----BEGIN CERTIFICATE----- +MIICZTCCAeugAwIBAgIQeI8nXIESUiClBNAt3bpz9DAKBggqhkjOPQQDAzB0MQswCQYDVQQGEwJQ +TDEhMB8GA1UEChMYQXNzZWNvIERhdGEgU3lzdGVtcyBTLkEuMScwJQYDVQQLEx5DZXJ0dW0gQ2Vy +dGlmaWNhdGlvbiBBdXRob3JpdHkxGTAXBgNVBAMTEENlcnR1bSBFQy0zODQgQ0EwHhcNMTgwMzI2 +MDcyNDU0WhcNNDMwMzI2MDcyNDU0WjB0MQswCQYDVQQGEwJQTDEhMB8GA1UEChMYQXNzZWNvIERh +dGEgU3lzdGVtcyBTLkEuMScwJQYDVQQLEx5DZXJ0dW0gQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkx +GTAXBgNVBAMTEENlcnR1bSBFQy0zODQgQ0EwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAATEKI6rGFtq +vm5kN2PkzeyrOvfMobgOgknXhimfoZTy42B4mIF4Bk3y7JoOV2CDn7TmFy8as10CW4kjPMIRBSqn +iBMY81CE1700LCeJVf/OTOffph8oxPBUw7l8t1Ot68KjQjBAMA8GA1UdEwEB/wQFMAMBAf8wHQYD +VR0OBBYEFI0GZnQkdjrzife81r1HfS+8EF9LMA4GA1UdDwEB/wQEAwIBBjAKBggqhkjOPQQDAwNo +ADBlAjADVS2m5hjEfO/JUG7BJw+ch69u1RsIGL2SKcHvlJF40jocVYli5RsJHrpka/F2tNQCMQC0 +QoSZ/6vnnvuRlydd3LBbMHHOXjgaatkl5+r3YZJW+OraNsKHZZYuciUvf9/DE8k= +-----END CERTIFICATE----- + +Certum Trusted Root CA +====================== +-----BEGIN CERTIFICATE----- +MIIFwDCCA6igAwIBAgIQHr9ZULjJgDdMBvfrVU+17TANBgkqhkiG9w0BAQ0FADB6MQswCQYDVQQG +EwJQTDEhMB8GA1UEChMYQXNzZWNvIERhdGEgU3lzdGVtcyBTLkEuMScwJQYDVQQLEx5DZXJ0dW0g +Q2VydGlmaWNhdGlvbiBBdXRob3JpdHkxHzAdBgNVBAMTFkNlcnR1bSBUcnVzdGVkIFJvb3QgQ0Ew +HhcNMTgwMzE2MTIxMDEzWhcNNDMwMzE2MTIxMDEzWjB6MQswCQYDVQQGEwJQTDEhMB8GA1UEChMY +QXNzZWNvIERhdGEgU3lzdGVtcyBTLkEuMScwJQYDVQQLEx5DZXJ0dW0gQ2VydGlmaWNhdGlvbiBB +dXRob3JpdHkxHzAdBgNVBAMTFkNlcnR1bSBUcnVzdGVkIFJvb3QgQ0EwggIiMA0GCSqGSIb3DQEB +AQUAA4ICDwAwggIKAoICAQDRLY67tzbqbTeRn06TpwXkKQMlzhyC93yZn0EGze2jusDbCSzBfN8p +fktlL5On1AFrAygYo9idBcEq2EXxkd7fO9CAAozPOA/qp1x4EaTByIVcJdPTsuclzxFUl6s1wB52 +HO8AU5853BSlLCIls3Jy/I2z5T4IHhQqNwuIPMqw9MjCoa68wb4pZ1Xi/K1ZXP69VyywkI3C7Te2 +fJmItdUDmj0VDT06qKhF8JVOJVkdzZhpu9PMMsmN74H+rX2Ju7pgE8pllWeg8xn2A1bUatMn4qGt +g/BKEiJ3HAVz4hlxQsDsdUaakFjgao4rpUYwBI4Zshfjvqm6f1bxJAPXsiEodg42MEx51UGamqi4 +NboMOvJEGyCI98Ul1z3G4z5D3Yf+xOr1Uz5MZf87Sst4WmsXXw3Hw09Omiqi7VdNIuJGmj8PkTQk +fVXjjJU30xrwCSss0smNtA0Aq2cpKNgB9RkEth2+dv5yXMSFytKAQd8FqKPVhJBPC/PgP5sZ0jeJ +P/J7UhyM9uH3PAeXjA6iWYEMspA90+NZRu0PqafegGtaqge2Gcu8V/OXIXoMsSt0Puvap2ctTMSY +njYJdmZm/Bo/6khUHL4wvYBQv3y1zgD2DGHZ5yQD4OMBgQ692IU0iL2yNqh7XAjlRICMb/gv1SHK +HRzQ+8S1h9E6Tsd2tTVItQIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBSM+xx1 +vALTn04uSNn5YFSqxLNP+jAOBgNVHQ8BAf8EBAMCAQYwDQYJKoZIhvcNAQENBQADggIBAEii1QAL +LtA/vBzVtVRJHlpr9OTy4EA34MwUe7nJ+jW1dReTagVphZzNTxl4WxmB82M+w85bj/UvXgF2Ez8s +ALnNllI5SW0ETsXpD4YN4fqzX4IS8TrOZgYkNCvozMrnadyHncI013nR03e4qllY/p0m+jiGPp2K +h2RX5Rc64vmNueMzeMGQ2Ljdt4NR5MTMI9UGfOZR0800McD2RrsLrfw9EAUqO0qRJe6M1ISHgCq8 +CYyqOhNf6DR5UMEQGfnTKB7U0VEwKbOukGfWHwpjscWpxkIxYxeU72nLL/qMFH3EQxiJ2fAyQOaA +4kZf5ePBAFmo+eggvIksDkc0C+pXwlM2/KfUrzHN/gLldfq5Jwn58/U7yn2fqSLLiMmq0Uc9Nneo +WWRrJ8/vJ8HjJLWG965+Mk2weWjROeiQWMODvA8s1pfrzgzhIMfatz7DP78v3DSk+yshzWePS/Tj +6tQ/50+6uaWTRRxmHyH6ZF5v4HaUMst19W7l9o/HuKTMqJZ9ZPskWkoDbGs4xugDQ5r3V7mzKWmT +OPQD8rv7gmsHINFSH5pkAnuYZttcTVoP0ISVoDwUQwbKytu4QTbaakRnh6+v40URFWkIsr4WOZck +bxJF0WddCajJFdr60qZfE2Efv4WstK2tBZQIgx51F9NxO5NQI1mg7TyRVJ12AMXDuDjb +-----END CERTIFICATE----- + +TunTrust Root CA +================ +-----BEGIN CERTIFICATE----- +MIIFszCCA5ugAwIBAgIUEwLV4kBMkkaGFmddtLu7sms+/BMwDQYJKoZIhvcNAQELBQAwYTELMAkG +A1UEBhMCVE4xNzA1BgNVBAoMLkFnZW5jZSBOYXRpb25hbGUgZGUgQ2VydGlmaWNhdGlvbiBFbGVj +dHJvbmlxdWUxGTAXBgNVBAMMEFR1blRydXN0IFJvb3QgQ0EwHhcNMTkwNDI2MDg1NzU2WhcNNDQw +NDI2MDg1NzU2WjBhMQswCQYDVQQGEwJUTjE3MDUGA1UECgwuQWdlbmNlIE5hdGlvbmFsZSBkZSBD +ZXJ0aWZpY2F0aW9uIEVsZWN0cm9uaXF1ZTEZMBcGA1UEAwwQVHVuVHJ1c3QgUm9vdCBDQTCCAiIw +DQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAMPN0/y9BFPdDCA61YguBUtB9YOCfvdZn56eY+hz +2vYGqU8ftPkLHzmMmiDQfgbU7DTZhrx1W4eI8NLZ1KMKsmwb60ksPqxd2JQDoOw05TDENX37Jk0b +bjBU2PWARZw5rZzJJQRNmpA+TkBuimvNKWfGzC3gdOgFVwpIUPp6Q9p+7FuaDmJ2/uqdHYVy7BG7 +NegfJ7/Boce7SBbdVtfMTqDhuazb1YMZGoXRlJfXyqNlC/M4+QKu3fZnz8k/9YosRxqZbwUN/dAd +gjH8KcwAWJeRTIAAHDOFli/LQcKLEITDCSSJH7UP2dl3RxiSlGBcx5kDPP73lad9UKGAwqmDrViW +VSHbhlnUr8a83YFuB9tgYv7sEG7aaAH0gxupPqJbI9dkxt/con3YS7qC0lH4Zr8GRuR5KiY2eY8f +Tpkdso8MDhz/yV3A/ZAQprE38806JG60hZC/gLkMjNWb1sjxVj8agIl6qeIbMlEsPvLfe/ZdeikZ +juXIvTZxi11Mwh0/rViizz1wTaZQmCXcI/m4WEEIcb9PuISgjwBUFfyRbVinljvrS5YnzWuioYas +DXxU5mZMZl+QviGaAkYt5IPCgLnPSz7ofzwB7I9ezX/SKEIBlYrilz0QIX32nRzFNKHsLA4KUiwS +VXAkPcvCFDVDXSdOvsC9qnyW5/yeYa1E0wCXAgMBAAGjYzBhMB0GA1UdDgQWBBQGmpsfU33x9aTI +04Y+oXNZtPdEITAPBgNVHRMBAf8EBTADAQH/MB8GA1UdIwQYMBaAFAaamx9TffH1pMjThj6hc1m0 +90QhMA4GA1UdDwEB/wQEAwIBBjANBgkqhkiG9w0BAQsFAAOCAgEAqgVutt0Vyb+zxiD2BkewhpMl +0425yAA/l/VSJ4hxyXT968pk21vvHl26v9Hr7lxpuhbI87mP0zYuQEkHDVneixCwSQXi/5E/S7fd +Ao74gShczNxtr18UnH1YeA32gAm56Q6XKRm4t+v4FstVEuTGfbvE7Pi1HE4+Z7/FXxttbUcoqgRY +YdZ2vyJ/0Adqp2RT8JeNnYA/u8EH22Wv5psymsNUk8QcCMNE+3tjEUPRahphanltkE8pjkcFwRJp +adbGNjHh/PqAulxPxOu3Mqz4dWEX1xAZufHSCe96Qp1bWgvUxpVOKs7/B9dPfhgGiPEZtdmYu65x +xBzndFlY7wyJz4sfdZMaBBSSSFCp61cpABbjNhzI+L/wM9VBD8TMPN3pM0MBkRArHtG5Xc0yGYuP +jCB31yLEQtyEFpslbei0VXF/sHyz03FJuc9SpAQ/3D2gu68zngowYI7bnV2UqL1g52KAdoGDDIzM +MEZJ4gzSqK/rYXHv5yJiqfdcZGyfFoxnNidF9Ql7v/YQCvGwjVRDjAS6oz/v4jXH+XTgbzRB0L9z +ZVcg+ZtnemZoJE6AZb0QmQZZ8mWvuMZHu/2QeItBcy6vVR/cO5JyboTT0GFMDcx2V+IthSIVNg3r +AZ3r2OvEhJn7wAzMMujjd9qDRIueVSjAi1jTkD5OGwDxFa2DK5o= +-----END CERTIFICATE----- + +HARICA TLS RSA Root CA 2021 +=========================== +-----BEGIN CERTIFICATE----- +MIIFpDCCA4ygAwIBAgIQOcqTHO9D88aOk8f0ZIk4fjANBgkqhkiG9w0BAQsFADBsMQswCQYDVQQG +EwJHUjE3MDUGA1UECgwuSGVsbGVuaWMgQWNhZGVtaWMgYW5kIFJlc2VhcmNoIEluc3RpdHV0aW9u +cyBDQTEkMCIGA1UEAwwbSEFSSUNBIFRMUyBSU0EgUm9vdCBDQSAyMDIxMB4XDTIxMDIxOTEwNTUz +OFoXDTQ1MDIxMzEwNTUzN1owbDELMAkGA1UEBhMCR1IxNzA1BgNVBAoMLkhlbGxlbmljIEFjYWRl +bWljIGFuZCBSZXNlYXJjaCBJbnN0aXR1dGlvbnMgQ0ExJDAiBgNVBAMMG0hBUklDQSBUTFMgUlNB +IFJvb3QgQ0EgMjAyMTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAIvC569lmwVnlskN +JLnQDmT8zuIkGCyEf3dRywQRNrhe7Wlxp57kJQmXZ8FHws+RFjZiPTgE4VGC/6zStGndLuwRo0Xu +a2s7TL+MjaQenRG56Tj5eg4MmOIjHdFOY9TnuEFE+2uva9of08WRiFukiZLRgeaMOVig1mlDqa2Y +Ulhu2wr7a89o+uOkXjpFc5gH6l8Cct4MpbOfrqkdtx2z/IpZ525yZa31MJQjB/OCFks1mJxTuy/K +5FrZx40d/JiZ+yykgmvwKh+OC19xXFyuQnspiYHLA6OZyoieC0AJQTPb5lh6/a6ZcMBaD9YThnEv +dmn8kN3bLW7R8pv1GmuebxWMevBLKKAiOIAkbDakO/IwkfN4E8/BPzWr8R0RI7VDIp4BkrcYAuUR +0YLbFQDMYTfBKnya4dC6s1BG7oKsnTH4+yPiAwBIcKMJJnkVU2DzOFytOOqBAGMUuTNe3QvboEUH +GjMJ+E20pwKmafTCWQWIZYVWrkvL4N48fS0ayOn7H6NhStYqE613TBoYm5EPWNgGVMWX+Ko/IIqm +haZ39qb8HOLubpQzKoNQhArlT4b4UEV4AIHrW2jjJo3Me1xR9BQsQL4aYB16cmEdH2MtiKrOokWQ +CPxrvrNQKlr9qEgYRtaQQJKQCoReaDH46+0N0x3GfZkYVVYnZS6NRcUk7M7jAgMBAAGjQjBAMA8G +A1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFApII6ZgpJIKM+qTW8VX6iVNvRLuMA4GA1UdDwEB/wQE +AwIBhjANBgkqhkiG9w0BAQsFAAOCAgEAPpBIqm5iFSVmewzVjIuJndftTgfvnNAUX15QvWiWkKQU +EapobQk1OUAJ2vQJLDSle1mESSmXdMgHHkdt8s4cUCbjnj1AUz/3f5Z2EMVGpdAgS1D0NTsY9FVq +QRtHBmg8uwkIYtlfVUKqrFOFrJVWNlar5AWMxajaH6NpvVMPxP/cyuN+8kyIhkdGGvMA9YCRotxD +QpSbIPDRzbLrLFPCU3hKTwSUQZqPJzLB5UkZv/HywouoCjkxKLR9YjYsTewfM7Z+d21+UPCfDtcR +j88YxeMn/ibvBZ3PzzfF0HvaO7AWhAw6k9a+F9sPPg4ZeAnHqQJyIkv3N3a6dcSFA1pj1bF1BcK5 +vZStjBWZp5N99sXzqnTPBIWUmAD04vnKJGW/4GKvyMX6ssmeVkjaef2WdhW+o45WxLM0/L5H9MG0 +qPzVMIho7suuyWPEdr6sOBjhXlzPrjoiUevRi7PzKzMHVIf6tLITe7pTBGIBnfHAT+7hOtSLIBD6 +Alfm78ELt5BGnBkpjNxvoEppaZS3JGWg/6w/zgH7IS79aPib8qXPMThcFarmlwDB31qlpzmq6YR/ +PFGoOtmUW4y/Twhx5duoXNTSpv4Ao8YWxw/ogM4cKGR0GQjTQuPOAF1/sdwTsOEFy9EgqoZ0njnn +kf3/W9b3raYvAwtt41dU63ZTGI0RmLo= +-----END CERTIFICATE----- + +HARICA TLS ECC Root CA 2021 +=========================== +-----BEGIN CERTIFICATE----- +MIICVDCCAdugAwIBAgIQZ3SdjXfYO2rbIvT/WeK/zjAKBggqhkjOPQQDAzBsMQswCQYDVQQGEwJH +UjE3MDUGA1UECgwuSGVsbGVuaWMgQWNhZGVtaWMgYW5kIFJlc2VhcmNoIEluc3RpdHV0aW9ucyBD +QTEkMCIGA1UEAwwbSEFSSUNBIFRMUyBFQ0MgUm9vdCBDQSAyMDIxMB4XDTIxMDIxOTExMDExMFoX +DTQ1MDIxMzExMDEwOVowbDELMAkGA1UEBhMCR1IxNzA1BgNVBAoMLkhlbGxlbmljIEFjYWRlbWlj +IGFuZCBSZXNlYXJjaCBJbnN0aXR1dGlvbnMgQ0ExJDAiBgNVBAMMG0hBUklDQSBUTFMgRUNDIFJv +b3QgQ0EgMjAyMTB2MBAGByqGSM49AgEGBSuBBAAiA2IABDgI/rGgltJ6rK9JOtDA4MM7KKrxcm1l +AEeIhPyaJmuqS7psBAqIXhfyVYf8MLA04jRYVxqEU+kw2anylnTDUR9YSTHMmE5gEYd103KUkE+b +ECUqqHgtvpBBWJAVcqeht6NCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUyRtTgRL+BNUW +0aq8mm+3oJUZbsowDgYDVR0PAQH/BAQDAgGGMAoGCCqGSM49BAMDA2cAMGQCMBHervjcToiwqfAi +rcJRQO9gcS3ujwLEXQNwSaSS6sUUiHCm0w2wqsosQJz76YJumgIwK0eaB8bRwoF8yguWGEEbo/Qw +CZ61IygNnxS2PFOiTAZpffpskcYqSUXm7LcT4Tps +-----END CERTIFICATE----- + +Autoridad de Certificacion Firmaprofesional CIF A62634068 +========================================================= +-----BEGIN CERTIFICATE----- +MIIGFDCCA/ygAwIBAgIIG3Dp0v+ubHEwDQYJKoZIhvcNAQELBQAwUTELMAkGA1UEBhMCRVMxQjBA +BgNVBAMMOUF1dG9yaWRhZCBkZSBDZXJ0aWZpY2FjaW9uIEZpcm1hcHJvZmVzaW9uYWwgQ0lGIEE2 +MjYzNDA2ODAeFw0xNDA5MjMxNTIyMDdaFw0zNjA1MDUxNTIyMDdaMFExCzAJBgNVBAYTAkVTMUIw +QAYDVQQDDDlBdXRvcmlkYWQgZGUgQ2VydGlmaWNhY2lvbiBGaXJtYXByb2Zlc2lvbmFsIENJRiBB +NjI2MzQwNjgwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDKlmuO6vj78aI14H9M2uDD +Utd9thDIAl6zQyrET2qyyhxdKJp4ERppWVevtSBC5IsP5t9bpgOSL/UR5GLXMnE42QQMcas9UX4P +B99jBVzpv5RvwSmCwLTaUbDBPLutN0pcyvFLNg4kq7/DhHf9qFD0sefGL9ItWY16Ck6WaVICqjaY +7Pz6FIMMNx/Jkjd/14Et5cS54D40/mf0PmbR0/RAz15iNA9wBj4gGFrO93IbJWyTdBSTo3OxDqqH +ECNZXyAFGUftaI6SEspd/NYrspI8IM/hX68gvqB2f3bl7BqGYTM+53u0P6APjqK5am+5hyZvQWyI +plD9amML9ZMWGxmPsu2bm8mQ9QEM3xk9Dz44I8kvjwzRAv4bVdZO0I08r0+k8/6vKtMFnXkIoctX +MbScyJCyZ/QYFpM6/EfY0XiWMR+6KwxfXZmtY4laJCB22N/9q06mIqqdXuYnin1oKaPnirjaEbsX +LZmdEyRG98Xi2J+Of8ePdG1asuhy9azuJBCtLxTa/y2aRnFHvkLfuwHb9H/TKI8xWVvTyQKmtFLK +bpf7Q8UIJm+K9Lv9nyiqDdVF8xM6HdjAeI9BZzwelGSuewvF6NkBiDkal4ZkQdU7hwxu+g/GvUgU +vzlN1J5Bto+WHWOWk9mVBngxaJ43BjuAiUVhOSPHG0SjFeUc+JIwuwIDAQABo4HvMIHsMB0GA1Ud +DgQWBBRlzeurNR4APn7VdMActHNHDhpkLzASBgNVHRMBAf8ECDAGAQH/AgEBMIGmBgNVHSAEgZ4w +gZswgZgGBFUdIAAwgY8wLwYIKwYBBQUHAgEWI2h0dHA6Ly93d3cuZmlybWFwcm9mZXNpb25hbC5j +b20vY3BzMFwGCCsGAQUFBwICMFAeTgBQAGEAcwBlAG8AIABkAGUAIABsAGEAIABCAG8AbgBhAG4A +bwB2AGEAIAA0ADcAIABCAGEAcgBjAGUAbABvAG4AYQAgADAAOAAwADEANzAOBgNVHQ8BAf8EBAMC +AQYwDQYJKoZIhvcNAQELBQADggIBAHSHKAIrdx9miWTtj3QuRhy7qPj4Cx2Dtjqn6EWKB7fgPiDL +4QjbEwj4KKE1soCzC1HA01aajTNFSa9J8OA9B3pFE1r/yJfY0xgsfZb43aJlQ3CTkBW6kN/oGbDb +LIpgD7dvlAceHabJhfa9NPhAeGIQcDq+fUs5gakQ1JZBu/hfHAsdCPKxsIl68veg4MSPi3i1O1il +I45PVf42O+AMt8oqMEEgtIDNrvx2ZnOorm7hfNoD6JQg5iKj0B+QXSBTFCZX2lSX3xZEEAEeiGaP +cjiT3SC3NL7X8e5jjkd5KAb881lFJWAiMxujX6i6KtoaPc1A6ozuBRWV1aUsIC+nmCjuRfzxuIgA +LI9C2lHVnOUTaHFFQ4ueCyE8S1wF3BqfmI7avSKecs2tCsvMo2ebKHTEm9caPARYpoKdrcd7b/+A +lun4jWq9GJAd/0kakFI3ky88Al2CdgtR5xbHV/g4+afNmyJU72OwFW1TZQNKXkqgsqeOSQBZONXH +9IBk9W6VULgRfhVwOEqwf9DEMnDAGf/JOC0ULGb0QkTmVXYbgBVX/8Cnp6o5qtjTcNAuuuuUavpf +NIbnYrX9ivAwhZTJryQCL2/W3Wf+47BVTwSYT6RBVuKT0Gro1vP7ZeDOdcQxWQzugsgMYDNKGbqE +ZycPvEJdvSRUDewdcAZfpLz6IHxV +-----END CERTIFICATE----- + +vTrus ECC Root CA +================= +-----BEGIN CERTIFICATE----- +MIICDzCCAZWgAwIBAgIUbmq8WapTvpg5Z6LSa6Q75m0c1towCgYIKoZIzj0EAwMwRzELMAkGA1UE +BhMCQ04xHDAaBgNVBAoTE2lUcnVzQ2hpbmEgQ28uLEx0ZC4xGjAYBgNVBAMTEXZUcnVzIEVDQyBS +b290IENBMB4XDTE4MDczMTA3MjY0NFoXDTQzMDczMTA3MjY0NFowRzELMAkGA1UEBhMCQ04xHDAa +BgNVBAoTE2lUcnVzQ2hpbmEgQ28uLEx0ZC4xGjAYBgNVBAMTEXZUcnVzIEVDQyBSb290IENBMHYw +EAYHKoZIzj0CAQYFK4EEACIDYgAEZVBKrox5lkqqHAjDo6LN/llWQXf9JpRCux3NCNtzslt188+c +ToL0v/hhJoVs1oVbcnDS/dtitN9Ti72xRFhiQgnH+n9bEOf+QP3A2MMrMudwpremIFUde4BdS49n +TPEQo0IwQDAdBgNVHQ4EFgQUmDnNvtiyjPeyq+GtJK97fKHbH88wDwYDVR0TAQH/BAUwAwEB/zAO +BgNVHQ8BAf8EBAMCAQYwCgYIKoZIzj0EAwMDaAAwZQIwV53dVvHH4+m4SVBrm2nDb+zDfSXkV5UT +QJtS0zvzQBm8JsctBp61ezaf9SXUY2sAAjEA6dPGnlaaKsyh2j/IZivTWJwghfqrkYpwcBE4YGQL +YgmRWAD5Tfs0aNoJrSEGGJTO +-----END CERTIFICATE----- + +vTrus Root CA +============= +-----BEGIN CERTIFICATE----- +MIIFVjCCAz6gAwIBAgIUQ+NxE9izWRRdt86M/TX9b7wFjUUwDQYJKoZIhvcNAQELBQAwQzELMAkG +A1UEBhMCQ04xHDAaBgNVBAoTE2lUcnVzQ2hpbmEgQ28uLEx0ZC4xFjAUBgNVBAMTDXZUcnVzIFJv +b3QgQ0EwHhcNMTgwNzMxMDcyNDA1WhcNNDMwNzMxMDcyNDA1WjBDMQswCQYDVQQGEwJDTjEcMBoG +A1UEChMTaVRydXNDaGluYSBDby4sTHRkLjEWMBQGA1UEAxMNdlRydXMgUm9vdCBDQTCCAiIwDQYJ +KoZIhvcNAQEBBQADggIPADCCAgoCggIBAL1VfGHTuB0EYgWgrmy3cLRB6ksDXhA/kFocizuwZots +SKYcIrrVQJLuM7IjWcmOvFjai57QGfIvWcaMY1q6n6MLsLOaXLoRuBLpDLvPbmyAhykUAyyNJJrI +ZIO1aqwTLDPxn9wsYTwaP3BVm60AUn/PBLn+NvqcwBauYv6WTEN+VRS+GrPSbcKvdmaVayqwlHeF +XgQPYh1jdfdr58tbmnDsPmcF8P4HCIDPKNsFxhQnL4Z98Cfe/+Z+M0jnCx5Y0ScrUw5XSmXX+6KA +YPxMvDVTAWqXcoKv8R1w6Jz1717CbMdHflqUhSZNO7rrTOiwCcJlwp2dCZtOtZcFrPUGoPc2BX70 +kLJrxLT5ZOrpGgrIDajtJ8nU57O5q4IikCc9Kuh8kO+8T/3iCiSn3mUkpF3qwHYw03dQ+A0Em5Q2 +AXPKBlim0zvc+gRGE1WKyURHuFE5Gi7oNOJ5y1lKCn+8pu8fA2dqWSslYpPZUxlmPCdiKYZNpGvu +/9ROutW04o5IWgAZCfEF2c6Rsffr6TlP9m8EQ5pV9T4FFL2/s1m02I4zhKOQUqqzApVg+QxMaPnu +1RcN+HFXtSXkKe5lXa/R7jwXC1pDxaWG6iSe4gUH3DRCEpHWOXSuTEGC2/KmSNGzm/MzqvOmwMVO +9fSddmPmAsYiS8GVP1BkLFTltvA8Kc9XAgMBAAGjQjBAMB0GA1UdDgQWBBRUYnBj8XWEQ1iO0RYg +scasGrz2iTAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjANBgkqhkiG9w0BAQsFAAOC +AgEAKbqSSaet8PFww+SX8J+pJdVrnjT+5hpk9jprUrIQeBqfTNqK2uwcN1LgQkv7bHbKJAs5EhWd +nxEt/Hlk3ODg9d3gV8mlsnZwUKT+twpw1aA08XXXTUm6EdGz2OyC/+sOxL9kLX1jbhd47F18iMjr +jld22VkE+rxSH0Ws8HqA7Oxvdq6R2xCOBNyS36D25q5J08FsEhvMKar5CKXiNxTKsbhm7xqC5PD4 +8acWabfbqWE8n/Uxy+QARsIvdLGx14HuqCaVvIivTDUHKgLKeBRtRytAVunLKmChZwOgzoy8sHJn +xDHO2zTlJQNgJXtxmOTAGytfdELSS8VZCAeHvsXDf+eW2eHcKJfWjwXj9ZtOyh1QRwVTsMo554Wg +icEFOwE30z9J4nfrI8iIZjs9OXYhRvHsXyO466JmdXTBQPfYaJqT4i2pLr0cox7IdMakLXogqzu4 +sEb9b91fUlV1YvCXoHzXOP0l382gmxDPi7g4Xl7FtKYCNqEeXxzP4padKar9mK5S4fNBUvupLnKW +nyfjqnN9+BojZns7q2WwMgFLFT49ok8MKzWixtlnEjUwzXYuFrOZnk1PTi07NEPhmg4NpGaXutIc +SkwsKouLgU9xGqndXHt7CMUADTdA43x7VF8vhV929vensBxXVsFy6K2ir40zSbofitzmdHxghm+H +l3s= +-----END CERTIFICATE----- + +ISRG Root X2 +============ +-----BEGIN CERTIFICATE----- +MIICGzCCAaGgAwIBAgIQQdKd0XLq7qeAwSxs6S+HUjAKBggqhkjOPQQDAzBPMQswCQYDVQQGEwJV +UzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElT +UkcgUm9vdCBYMjAeFw0yMDA5MDQwMDAwMDBaFw00MDA5MTcxNjAwMDBaME8xCzAJBgNVBAYTAlVT +MSkwJwYDVQQKEyBJbnRlcm5ldCBTZWN1cml0eSBSZXNlYXJjaCBHcm91cDEVMBMGA1UEAxMMSVNS +RyBSb290IFgyMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEzZvVn4CDCuwJSvMWSj5cz3es3mcFDR0H +ttwW+1qLFNvicWDEukWVEYmO6gbf9yoWHKS5xcUy4APgHoIYOIvXRdgKam7mAHf7AlF9ItgKbppb +d9/w+kHsOdx1ymgHDB/qo0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNV +HQ4EFgQUfEKWrt5LSDv6kviejM9ti6lyN5UwCgYIKoZIzj0EAwMDaAAwZQIwe3lORlCEwkSHRhtF +cP9Ymd70/aTSVaYgLXTWNLxBo1BfASdWtL4ndQavEi51mI38AjEAi/V3bNTIZargCyzuFJ0nN6T5 +U6VR5CmD1/iQMVtCnwr1/q4AaOeMSQ+2b1tbFfLn +-----END CERTIFICATE----- + +HiPKI Root CA - G1 +================== +-----BEGIN CERTIFICATE----- +MIIFajCCA1KgAwIBAgIQLd2szmKXlKFD6LDNdmpeYDANBgkqhkiG9w0BAQsFADBPMQswCQYDVQQG +EwJUVzEjMCEGA1UECgwaQ2h1bmdod2EgVGVsZWNvbSBDby4sIEx0ZC4xGzAZBgNVBAMMEkhpUEtJ +IFJvb3QgQ0EgLSBHMTAeFw0xOTAyMjIwOTQ2MDRaFw0zNzEyMzExNTU5NTlaME8xCzAJBgNVBAYT +AlRXMSMwIQYDVQQKDBpDaHVuZ2h3YSBUZWxlY29tIENvLiwgTHRkLjEbMBkGA1UEAwwSSGlQS0kg +Um9vdCBDQSAtIEcxMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA9B5/UnMyDHPkvRN0 +o9QwqNCuS9i233VHZvR85zkEHmpwINJaR3JnVfSl6J3VHiGh8Ge6zCFovkRTv4354twvVcg3Px+k +wJyz5HdcoEb+d/oaoDjq7Zpy3iu9lFc6uux55199QmQ5eiY29yTw1S+6lZgRZq2XNdZ1AYDgr/SE +YYwNHl98h5ZeQa/rh+r4XfEuiAU+TCK72h8q3VJGZDnzQs7ZngyzsHeXZJzA9KMuH5UHsBffMNsA +GJZMoYFL3QRtU6M9/Aes1MU3guvklQgZKILSQjqj2FPseYlgSGDIcpJQ3AOPgz+yQlda22rpEZfd +hSi8MEyr48KxRURHH+CKFgeW0iEPU8DtqX7UTuybCeyvQqww1r/REEXgphaypcXTT3OUM3ECoWqj +1jOXTyFjHluP2cFeRXF3D4FdXyGarYPM+l7WjSNfGz1BryB1ZlpK9p/7qxj3ccC2HTHsOyDry+K4 +9a6SsvfhhEvyovKTmiKe0xRvNlS9H15ZFblzqMF8b3ti6RZsR1pl8w4Rm0bZ/W3c1pzAtH2lsN0/ +Vm+h+fbkEkj9Bn8SV7apI09bA8PgcSojt/ewsTu8mL3WmKgMa/aOEmem8rJY5AIJEzypuxC00jBF +8ez3ABHfZfjcK0NVvxaXxA/VLGGEqnKG/uY6fsI/fe78LxQ+5oXdUG+3Se0CAwEAAaNCMEAwDwYD +VR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQU8ncX+l6o/vY9cdVouslGDDjYr7AwDgYDVR0PAQH/BAQD +AgGGMA0GCSqGSIb3DQEBCwUAA4ICAQBQUfB13HAE4/+qddRxosuej6ip0691x1TPOhwEmSKsxBHi +7zNKpiMdDg1H2DfHb680f0+BazVP6XKlMeJ45/dOlBhbQH3PayFUhuaVevvGyuqcSE5XCV0vrPSl +tJczWNWseanMX/mF+lLFjfiRFOs6DRfQUsJ748JzjkZ4Bjgs6FzaZsT0pPBWGTMpWmWSBUdGSquE +wx4noR8RkpkndZMPvDY7l1ePJlsMu5wP1G4wB9TcXzZoZjmDlicmisjEOf6aIW/Vcobpf2Lll07Q +JNBAsNB1CI69aO4I1258EHBGG3zgiLKecoaZAeO/n0kZtCW+VmWuF2PlHt/o/0elv+EmBYTksMCv +5wiZqAxeJoBF1PhoL5aPruJKHJwWDBNvOIf2u8g0X5IDUXlwpt/L9ZlNec1OvFefQ05rLisY+Gpz +jLrFNe85akEez3GoorKGB1s6yeHvP2UEgEcyRHCVTjFnanRbEEV16rCf0OY1/k6fi8wrkkVbbiVg +hUbN0aqwdmaTd5a+g744tiROJgvM7XpWGuDpWsZkrUx6AEhEL7lAuxM+vhV4nYWBSipX3tUZQ9rb +yltHhoMLP7YNdnhzeSJesYAfz77RP1YQmCuVh6EfnWQUYDksswBVLuT1sw5XxJFBAJw/6KXf6vb/ +yPCtbVKoF6ubYfwSUTXkJf2vqmqGOQ== +-----END CERTIFICATE----- + +GlobalSign ECC Root CA - R4 +=========================== +-----BEGIN CERTIFICATE----- +MIIB3DCCAYOgAwIBAgINAgPlfvU/k/2lCSGypjAKBggqhkjOPQQDAjBQMSQwIgYDVQQLExtHbG9i +YWxTaWduIEVDQyBSb290IENBIC0gUjQxEzARBgNVBAoTCkdsb2JhbFNpZ24xEzARBgNVBAMTCkds +b2JhbFNpZ24wHhcNMTIxMTEzMDAwMDAwWhcNMzgwMTE5MDMxNDA3WjBQMSQwIgYDVQQLExtHbG9i +YWxTaWduIEVDQyBSb290IENBIC0gUjQxEzARBgNVBAoTCkdsb2JhbFNpZ24xEzARBgNVBAMTCkds +b2JhbFNpZ24wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAS4xnnTj2wlDp8uORkcA6SumuU5BwkW +ymOxuYb4ilfBV85C+nOh92VC/x7BALJucw7/xyHlGKSq2XE/qNS5zowdo0IwQDAOBgNVHQ8BAf8E +BAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUVLB7rUW44kB/+wpu+74zyTyjhNUwCgYI +KoZIzj0EAwIDRwAwRAIgIk90crlgr/HmnKAWBVBfw147bmF0774BxL4YSFlhgjICICadVGNA3jdg +UM/I2O2dgq43mLyjj0xMqTQrbO/7lZsm +-----END CERTIFICATE----- + +GTS Root R1 +=========== +-----BEGIN CERTIFICATE----- +MIIFVzCCAz+gAwIBAgINAgPlk28xsBNJiGuiFzANBgkqhkiG9w0BAQwFADBHMQswCQYDVQQGEwJV +UzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3Qg +UjEwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAwMDAwWjBHMQswCQYDVQQGEwJVUzEiMCAGA1UE +ChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjEwggIiMA0G +CSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC2EQKLHuOhd5s73L+UPreVp0A8of2C+X0yBoJx9vaM +f/vo27xqLpeXo4xL+Sv2sfnOhB2x+cWX3u+58qPpvBKJXqeqUqv4IyfLpLGcY9vXmX7wCl7raKb0 +xlpHDU0QM+NOsROjyBhsS+z8CZDfnWQpJSMHobTSPS5g4M/SCYe7zUjwTcLCeoiKu7rPWRnWr4+w +B7CeMfGCwcDfLqZtbBkOtdh+JhpFAz2weaSUKK0PfyblqAj+lug8aJRT7oM6iCsVlgmy4HqMLnXW +nOunVmSPlk9orj2XwoSPwLxAwAtcvfaHszVsrBhQf4TgTM2S0yDpM7xSma8ytSmzJSq0SPly4cpk +9+aCEI3oncKKiPo4Zor8Y/kB+Xj9e1x3+naH+uzfsQ55lVe0vSbv1gHR6xYKu44LtcXFilWr06zq +kUspzBmkMiVOKvFlRNACzqrOSbTqn3yDsEB750Orp2yjj32JgfpMpf/VjsPOS+C12LOORc92wO1A +K/1TD7Cn1TsNsYqiA94xrcx36m97PtbfkSIS5r762DL8EGMUUXLeXdYWk70paDPvOmbsB4om3xPX +V2V4J95eSRQAogB/mqghtqmxlbCluQ0WEdrHbEg8QOB+DVrNVjzRlwW5y0vtOUucxD/SVRNuJLDW +cfr0wbrM7Rv1/oFB2ACYPTrIrnqYNxgFlQIDAQABo0IwQDAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0T +AQH/BAUwAwEB/zAdBgNVHQ4EFgQU5K8rJnEaK0gnhS9SZizv8IkTcT4wDQYJKoZIhvcNAQEMBQAD +ggIBAJ+qQibbC5u+/x6Wki4+omVKapi6Ist9wTrYggoGxval3sBOh2Z5ofmmWJyq+bXmYOfg6LEe +QkEzCzc9zolwFcq1JKjPa7XSQCGYzyI0zzvFIoTgxQ6KfF2I5DUkzps+GlQebtuyh6f88/qBVRRi +ClmpIgUxPoLW7ttXNLwzldMXG+gnoot7TiYaelpkttGsN/H9oPM47HLwEXWdyzRSjeZ2axfG34ar +J45JK3VmgRAhpuo+9K4l/3wV3s6MJT/KYnAK9y8JZgfIPxz88NtFMN9iiMG1D53Dn0reWVlHxYci +NuaCp+0KueIHoI17eko8cdLiA6EfMgfdG+RCzgwARWGAtQsgWSl4vflVy2PFPEz0tv/bal8xa5me +LMFrUKTX5hgUvYU/Z6tGn6D/Qqc6f1zLXbBwHSs09dR2CQzreExZBfMzQsNhFRAbd03OIozUhfJF +fbdT6u9AWpQKXCBfTkBdYiJ23//OYb2MI3jSNwLgjt7RETeJ9r/tSQdirpLsQBqvFAnZ0E6yove+ +7u7Y/9waLd64NnHi/Hm3lCXRSHNboTXns5lndcEZOitHTtNCjv0xyBZm2tIMPNuzjsmhDYAPexZ3 +FL//2wmUspO8IFgV6dtxQ/PeEMMA3KgqlbbC1j+Qa3bbbP6MvPJwNQzcmRk13NfIRmPVNnGuV/u3 +gm3c +-----END CERTIFICATE----- + +GTS Root R3 +=========== +-----BEGIN CERTIFICATE----- +MIICCTCCAY6gAwIBAgINAgPluILrIPglJ209ZjAKBggqhkjOPQQDAzBHMQswCQYDVQQGEwJVUzEi +MCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjMw +HhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAwMDAwWjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZ +R29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjMwdjAQBgcqhkjO +PQIBBgUrgQQAIgNiAAQfTzOHMymKoYTey8chWEGJ6ladK0uFxh1MJ7x/JlFyb+Kf1qPKzEUURout +736GjOyxfi//qXGdGIRFBEFVbivqJn+7kAHjSxm65FSWRQmx1WyRRK2EE46ajA2ADDL24CejQjBA +MA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBTB8Sa6oC2uhYHP0/Eq +Er24Cmf9vDAKBggqhkjOPQQDAwNpADBmAjEA9uEglRR7VKOQFhG/hMjqb2sXnh5GmCCbn9MN2azT +L818+FsuVbu/3ZL3pAzcMeGiAjEA/JdmZuVDFhOD3cffL74UOO0BzrEXGhF16b0DjyZ+hOXJYKaV +11RZt+cRLInUue4X +-----END CERTIFICATE----- + +GTS Root R4 +=========== +-----BEGIN CERTIFICATE----- +MIICCTCCAY6gAwIBAgINAgPlwGjvYxqccpBQUjAKBggqhkjOPQQDAzBHMQswCQYDVQQGEwJVUzEi +MCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjQw +HhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAwMDAwWjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZ +R29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjQwdjAQBgcqhkjO +PQIBBgUrgQQAIgNiAATzdHOnaItgrkO4NcWBMHtLSZ37wWHO5t5GvWvVYRg1rkDdc/eJkTBa6zzu +hXyiQHY7qca4R9gq55KRanPpsXI5nymfopjTX15YhmUPoYRlBtHci8nHc8iMai/lxKvRHYqjQjBA +MA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBSATNbrdP9JNqPV2Py1 +PsVq8JQdjDAKBggqhkjOPQQDAwNpADBmAjEA6ED/g94D9J+uHXqnLrmvT/aDHQ4thQEd0dlq7A/C +r8deVl5c1RxYIigL9zC2L7F8AjEA8GE8p/SgguMh1YQdc4acLa/KNJvxn7kjNuK8YAOdgLOaVsjh +4rsUecrNIdSUtUlD +-----END CERTIFICATE----- + +Telia Root CA v2 +================ +-----BEGIN CERTIFICATE----- +MIIFdDCCA1ygAwIBAgIPAWdfJ9b+euPkrL4JWwWeMA0GCSqGSIb3DQEBCwUAMEQxCzAJBgNVBAYT +AkZJMRowGAYDVQQKDBFUZWxpYSBGaW5sYW5kIE95ajEZMBcGA1UEAwwQVGVsaWEgUm9vdCBDQSB2 +MjAeFw0xODExMjkxMTU1NTRaFw00MzExMjkxMTU1NTRaMEQxCzAJBgNVBAYTAkZJMRowGAYDVQQK +DBFUZWxpYSBGaW5sYW5kIE95ajEZMBcGA1UEAwwQVGVsaWEgUm9vdCBDQSB2MjCCAiIwDQYJKoZI +hvcNAQEBBQADggIPADCCAgoCggIBALLQPwe84nvQa5n44ndp586dpAO8gm2h/oFlH0wnrI4AuhZ7 +6zBqAMCzdGh+sq/H1WKzej9Qyow2RCRj0jbpDIX2Q3bVTKFgcmfiKDOlyzG4OiIjNLh9vVYiQJ3q +9HsDrWj8soFPmNB06o3lfc1jw6P23pLCWBnglrvFxKk9pXSW/q/5iaq9lRdU2HhE8Qx3FZLgmEKn +pNaqIJLNwaCzlrI6hEKNfdWV5Nbb6WLEWLN5xYzTNTODn3WhUidhOPFZPY5Q4L15POdslv5e2QJl +tI5c0BE0312/UqeBAMN/mUWZFdUXyApT7GPzmX3MaRKGwhfwAZ6/hLzRUssbkmbOpFPlob/E2wnW +5olWK8jjfN7j/4nlNW4o6GwLI1GpJQXrSPjdscr6bAhR77cYbETKJuFzxokGgeWKrLDiKca5JLNr +RBH0pUPCTEPlcDaMtjNXepUugqD0XBCzYYP2AgWGLnwtbNwDRm41k9V6lS/eINhbfpSQBGq6WT0E +BXWdN6IOLj3rwaRSg/7Qa9RmjtzG6RJOHSpXqhC8fF6CfaamyfItufUXJ63RDolUK5X6wK0dmBR4 +M0KGCqlztft0DbcbMBnEWg4cJ7faGND/isgFuvGqHKI3t+ZIpEYslOqodmJHixBTB0hXbOKSTbau +BcvcwUpej6w9GU7C7WB1K9vBykLVAgMBAAGjYzBhMB8GA1UdIwQYMBaAFHKs5DN5qkWH9v2sHZ7W +xy+G2CQ5MB0GA1UdDgQWBBRyrOQzeapFh/b9rB2e1scvhtgkOTAOBgNVHQ8BAf8EBAMCAQYwDwYD +VR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAgEAoDtZpwmUPjaE0n4vOaWWl/oRrfxn83EJ +8rKJhGdEr7nv7ZbsnGTbMjBvZ5qsfl+yqwE2foH65IRe0qw24GtixX1LDoJt0nZi0f6X+J8wfBj5 +tFJ3gh1229MdqfDBmgC9bXXYfef6xzijnHDoRnkDry5023X4blMMA8iZGok1GTzTyVR8qPAs5m4H +eW9q4ebqkYJpCh3DflminmtGFZhb069GHWLIzoBSSRE/yQQSwxN8PzuKlts8oB4KtItUsiRnDe+C +y748fdHif64W1lZYudogsYMVoe+KTTJvQS8TUoKU1xrBeKJR3Stwbbca+few4GeXVtt8YVMJAygC +QMez2P2ccGrGKMOF6eLtGpOg3kuYooQ+BXcBlj37tCAPnHICehIv1aO6UXivKitEZU61/Qrowc15 +h2Er3oBXRb9n8ZuRXqWk7FlIEA04x7D6w0RtBPV4UBySllva9bguulvP5fBqnUsvWHMtTy3EHD70 +sz+rFQ47GUGKpMFXEmZxTPpT41frYpUJnlTd0cI8Vzy9OK2YZLe4A5pTVmBds9hCG1xLEooc6+t9 +xnppxyd/pPiL8uSUZodL6ZQHCRJ5irLrdATczvREWeAWysUsWNc8e89ihmpQfTU2Zqf7N+cox9jQ +raVplI/owd8k+BsHMYeB2F326CjYSlKArBPuUBQemMc= +-----END CERTIFICATE----- + +D-TRUST BR Root CA 1 2020 +========================= +-----BEGIN CERTIFICATE----- +MIIC2zCCAmCgAwIBAgIQfMmPK4TX3+oPyWWa00tNljAKBggqhkjOPQQDAzBIMQswCQYDVQQGEwJE +RTEVMBMGA1UEChMMRC1UcnVzdCBHbWJIMSIwIAYDVQQDExlELVRSVVNUIEJSIFJvb3QgQ0EgMSAy +MDIwMB4XDTIwMDIxMTA5NDUwMFoXDTM1MDIxMTA5NDQ1OVowSDELMAkGA1UEBhMCREUxFTATBgNV +BAoTDEQtVHJ1c3QgR21iSDEiMCAGA1UEAxMZRC1UUlVTVCBCUiBSb290IENBIDEgMjAyMDB2MBAG +ByqGSM49AgEGBSuBBAAiA2IABMbLxyjR+4T1mu9CFCDhQ2tuda38KwOE1HaTJddZO0Flax7mNCq7 +dPYSzuht56vkPE4/RAiLzRZxy7+SmfSk1zxQVFKQhYN4lGdnoxwJGT11NIXe7WB9xwy0QVK5buXu +QqOCAQ0wggEJMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFHOREKv/VbNafAkl1bK6CKBrqx9t +MA4GA1UdDwEB/wQEAwIBBjCBxgYDVR0fBIG+MIG7MD6gPKA6hjhodHRwOi8vY3JsLmQtdHJ1c3Qu +bmV0L2NybC9kLXRydXN0X2JyX3Jvb3RfY2FfMV8yMDIwLmNybDB5oHegdYZzbGRhcDovL2RpcmVj +dG9yeS5kLXRydXN0Lm5ldC9DTj1ELVRSVVNUJTIwQlIlMjBSb290JTIwQ0ElMjAxJTIwMjAyMCxP +PUQtVHJ1c3QlMjBHbWJILEM9REU/Y2VydGlmaWNhdGVyZXZvY2F0aW9ubGlzdDAKBggqhkjOPQQD +AwNpADBmAjEAlJAtE/rhY/hhY+ithXhUkZy4kzg+GkHaQBZTQgjKL47xPoFWwKrY7RjEsK70Pvom +AjEA8yjixtsrmfu3Ubgko6SUeho/5jbiA1czijDLgsfWFBHVdWNbFJWcHwHP2NVypw87 +-----END CERTIFICATE----- + +D-TRUST EV Root CA 1 2020 +========================= +-----BEGIN CERTIFICATE----- +MIIC2zCCAmCgAwIBAgIQXwJB13qHfEwDo6yWjfv/0DAKBggqhkjOPQQDAzBIMQswCQYDVQQGEwJE +RTEVMBMGA1UEChMMRC1UcnVzdCBHbWJIMSIwIAYDVQQDExlELVRSVVNUIEVWIFJvb3QgQ0EgMSAy +MDIwMB4XDTIwMDIxMTEwMDAwMFoXDTM1MDIxMTA5NTk1OVowSDELMAkGA1UEBhMCREUxFTATBgNV +BAoTDEQtVHJ1c3QgR21iSDEiMCAGA1UEAxMZRC1UUlVTVCBFViBSb290IENBIDEgMjAyMDB2MBAG +ByqGSM49AgEGBSuBBAAiA2IABPEL3YZDIBnfl4XoIkqbz52Yv7QFJsnL46bSj8WeeHsxiamJrSc8 +ZRCC/N/DnU7wMyPE0jL1HLDfMxddxfCxivnvubcUyilKwg+pf3VlSSowZ/Rk99Yad9rDwpdhQntJ +raOCAQ0wggEJMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFH8QARY3OqQo5FD4pPfsazK2/umL +MA4GA1UdDwEB/wQEAwIBBjCBxgYDVR0fBIG+MIG7MD6gPKA6hjhodHRwOi8vY3JsLmQtdHJ1c3Qu +bmV0L2NybC9kLXRydXN0X2V2X3Jvb3RfY2FfMV8yMDIwLmNybDB5oHegdYZzbGRhcDovL2RpcmVj +dG9yeS5kLXRydXN0Lm5ldC9DTj1ELVRSVVNUJTIwRVYlMjBSb290JTIwQ0ElMjAxJTIwMjAyMCxP +PUQtVHJ1c3QlMjBHbWJILEM9REU/Y2VydGlmaWNhdGVyZXZvY2F0aW9ubGlzdDAKBggqhkjOPQQD +AwNpADBmAjEAyjzGKnXCXnViOTYAYFqLwZOZzNnbQTs7h5kXO9XMT8oi96CAy/m0sRtW9XLS/BnR +AjEAkfcwkz8QRitxpNA7RJvAKQIFskF3UfN5Wp6OFKBOQtJbgfM0agPnIjhQW+0ZT0MW +-----END CERTIFICATE----- + +DigiCert TLS ECC P384 Root G5 +============================= +-----BEGIN CERTIFICATE----- +MIICGTCCAZ+gAwIBAgIQCeCTZaz32ci5PhwLBCou8zAKBggqhkjOPQQDAzBOMQswCQYDVQQGEwJV +UzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xJjAkBgNVBAMTHURpZ2lDZXJ0IFRMUyBFQ0MgUDM4 +NCBSb290IEc1MB4XDTIxMDExNTAwMDAwMFoXDTQ2MDExNDIzNTk1OVowTjELMAkGA1UEBhMCVVMx +FzAVBgNVBAoTDkRpZ2lDZXJ0LCBJbmMuMSYwJAYDVQQDEx1EaWdpQ2VydCBUTFMgRUNDIFAzODQg +Um9vdCBHNTB2MBAGByqGSM49AgEGBSuBBAAiA2IABMFEoc8Rl1Ca3iOCNQfN0MsYndLxf3c1Tzvd +lHJS7cI7+Oz6e2tYIOyZrsn8aLN1udsJ7MgT9U7GCh1mMEy7H0cKPGEQQil8pQgO4CLp0zVozptj +n4S1mU1YoI71VOeVyaNCMEAwHQYDVR0OBBYEFMFRRVBZqz7nLFr6ICISB4CIfBFqMA4GA1UdDwEB +/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MAoGCCqGSM49BAMDA2gAMGUCMQCJao1H5+z8blUD2Wds +Jk6Dxv3J+ysTvLd6jLRl0mlpYxNjOyZQLgGheQaRnUi/wr4CMEfDFXuxoJGZSZOoPHzoRgaLLPIx +AJSdYsiJvRmEFOml+wG4DXZDjC5Ty3zfDBeWUA== +-----END CERTIFICATE----- + +DigiCert TLS RSA4096 Root G5 +============================ +-----BEGIN CERTIFICATE----- +MIIFZjCCA06gAwIBAgIQCPm0eKj6ftpqMzeJ3nzPijANBgkqhkiG9w0BAQwFADBNMQswCQYDVQQG +EwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xJTAjBgNVBAMTHERpZ2lDZXJ0IFRMUyBSU0E0 +MDk2IFJvb3QgRzUwHhcNMjEwMTE1MDAwMDAwWhcNNDYwMTE0MjM1OTU5WjBNMQswCQYDVQQGEwJV +UzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xJTAjBgNVBAMTHERpZ2lDZXJ0IFRMUyBSU0E0MDk2 +IFJvb3QgRzUwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCz0PTJeRGd/fxmgefM1eS8 +7IE+ajWOLrfn3q/5B03PMJ3qCQuZvWxX2hhKuHisOjmopkisLnLlvevxGs3npAOpPxG02C+JFvuU +AT27L/gTBaF4HI4o4EXgg/RZG5Wzrn4DReW+wkL+7vI8toUTmDKdFqgpwgscONyfMXdcvyej/Ces +tyu9dJsXLfKB2l2w4SMXPohKEiPQ6s+d3gMXsUJKoBZMpG2T6T867jp8nVid9E6P/DsjyG244gXa +zOvswzH016cpVIDPRFtMbzCe88zdH5RDnU1/cHAN1DrRN/BsnZvAFJNY781BOHW8EwOVfH/jXOnV +DdXifBBiqmvwPXbzP6PosMH976pXTayGpxi0KcEsDr9kvimM2AItzVwv8n/vFfQMFawKsPHTDU9q +TXeXAaDxZre3zu/O7Oyldcqs4+Fj97ihBMi8ez9dLRYiVu1ISf6nL3kwJZu6ay0/nTvEF+cdLvvy +z6b84xQslpghjLSR6Rlgg/IwKwZzUNWYOwbpx4oMYIwo+FKbbuH2TbsGJJvXKyY//SovcfXWJL5/ +MZ4PbeiPT02jP/816t9JXkGPhvnxd3lLG7SjXi/7RgLQZhNeXoVPzthwiHvOAbWWl9fNff2C+MIk +wcoBOU+NosEUQB+cZtUMCUbW8tDRSHZWOkPLtgoRObqME2wGtZ7P6wIDAQABo0IwQDAdBgNVHQ4E +FgQUUTMc7TZArxfTJc1paPKvTiM+s0EwDgYDVR0PAQH/BAQDAgGGMA8GA1UdEwEB/wQFMAMBAf8w +DQYJKoZIhvcNAQEMBQADggIBAGCmr1tfV9qJ20tQqcQjNSH/0GEwhJG3PxDPJY7Jv0Y02cEhJhxw +GXIeo8mH/qlDZJY6yFMECrZBu8RHANmfGBg7sg7zNOok992vIGCukihfNudd5N7HPNtQOa27PShN +lnx2xlv0wdsUpasZYgcYQF+Xkdycx6u1UQ3maVNVzDl92sURVXLFO4uJ+DQtpBflF+aZfTCIITfN +MBc9uPK8qHWgQ9w+iUuQrm0D4ByjoJYJu32jtyoQREtGBzRj7TG5BO6jm5qu5jF49OokYTurWGT/ +u4cnYiWB39yhL/btp/96j1EuMPikAdKFOV8BmZZvWltwGUb+hmA+rYAQCd05JS9Yf7vSdPD3Rh9G +OUrYU9DzLjtxpdRv/PNn5AeP3SYZ4Y1b+qOTEZvpyDrDVWiakuFSdjjo4bq9+0/V77PnSIMx8IIh +47a+p6tv75/fTM8BuGJqIz3nCU2AG3swpMPdB380vqQmsvZB6Akd4yCYqjdP//fx4ilwMUc/dNAU +FvohigLVigmUdy7yWSiLfFCSCmZ4OIN1xLVaqBHG5cGdZlXPU8Sv13WFqUITVuwhd4GTWgzqltlJ +yqEI8pc7bZsEGCREjnwB8twl2F6GmrE52/WRMmrRpnCKovfepEWFJqgejF0pW8hL2JpqA15w8oVP +bEtoL8pU9ozaMv7Da4M/OMZ+ +-----END CERTIFICATE----- + +Certainly Root R1 +================= +-----BEGIN CERTIFICATE----- +MIIFRzCCAy+gAwIBAgIRAI4P+UuQcWhlM1T01EQ5t+AwDQYJKoZIhvcNAQELBQAwPTELMAkGA1UE +BhMCVVMxEjAQBgNVBAoTCUNlcnRhaW5seTEaMBgGA1UEAxMRQ2VydGFpbmx5IFJvb3QgUjEwHhcN +MjEwNDAxMDAwMDAwWhcNNDYwNDAxMDAwMDAwWjA9MQswCQYDVQQGEwJVUzESMBAGA1UEChMJQ2Vy +dGFpbmx5MRowGAYDVQQDExFDZXJ0YWlubHkgUm9vdCBSMTCCAiIwDQYJKoZIhvcNAQEBBQADggIP +ADCCAgoCggIBANA21B/q3avk0bbm+yLA3RMNansiExyXPGhjZjKcA7WNpIGD2ngwEc/csiu+kr+O +5MQTvqRoTNoCaBZ0vrLdBORrKt03H2As2/X3oXyVtwxwhi7xOu9S98zTm/mLvg7fMbedaFySpvXl +8wo0tf97ouSHocavFwDvA5HtqRxOcT3Si2yJ9HiG5mpJoM610rCrm/b01C7jcvk2xusVtyWMOvwl +DbMicyF0yEqWYZL1LwsYpfSt4u5BvQF5+paMjRcCMLT5r3gajLQ2EBAHBXDQ9DGQilHFhiZ5shGI +XsXwClTNSaa/ApzSRKft43jvRl5tcdF5cBxGX1HpyTfcX35pe0HfNEXgO4T0oYoKNp43zGJS4YkN +KPl6I7ENPT2a/Z2B7yyQwHtETrtJ4A5KVpK8y7XdeReJkd5hiXSSqOMyhb5OhaRLWcsrxXiOcVTQ +AjeZjOVJ6uBUcqQRBi8LjMFbvrWhsFNunLhgkR9Za/kt9JQKl7XsxXYDVBtlUrpMklZRNaBA2Cnb +rlJ2Oy0wQJuK0EJWtLeIAaSHO1OWzaMWj/Nmqhexx2DgwUMFDO6bW2BvBlyHWyf5QBGenDPBt+U1 +VwV/J84XIIwc/PH72jEpSe31C4SnT8H2TsIonPru4K8H+zMReiFPCyEQtkA6qyI6BJyLm4SGcprS +p6XEtHWRqSsjAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1Ud +DgQWBBTgqj8ljZ9EXME66C6ud0yEPmcM9DANBgkqhkiG9w0BAQsFAAOCAgEAuVevuBLaV4OPaAsz +HQNTVfSVcOQrPbA56/qJYv331hgELyE03fFo8NWWWt7CgKPBjcZq91l3rhVkz1t5BXdm6ozTaw3d +8VkswTOlMIAVRQdFGjEitpIAq5lNOo93r6kiyi9jyhXWx8bwPWz8HA2YEGGeEaIi1wrykXprOQ4v +MMM2SZ/g6Q8CRFA3lFV96p/2O7qUpUzpvD5RtOjKkjZUbVwlKNrdrRT90+7iIgXr0PK3aBLXWopB +GsaSpVo7Y0VPv+E6dyIvXL9G+VoDhRNCX8reU9ditaY1BMJH/5n9hN9czulegChB8n3nHpDYT3Y+ +gjwN/KUD+nsa2UUeYNrEjvn8K8l7lcUq/6qJ34IxD3L/DCfXCh5WAFAeDJDBlrXYFIW7pw0WwfgH +JBu6haEaBQmAupVjyTrsJZ9/nbqkRxWbRHDxakvWOF5D8xh+UG7pWijmZeZ3Gzr9Hb4DJqPb1OG7 +fpYnKx3upPvaJVQTA945xsMfTZDsjxtK0hzthZU4UHlG1sGQUDGpXJpuHfUzVounmdLyyCwzk5Iw +x06MZTMQZBf9JBeW0Y3COmor6xOLRPIh80oat3df1+2IpHLlOR+Vnb5nwXARPbv0+Em34yaXOp/S +X3z7wJl8OSngex2/DaeP0ik0biQVy96QXr8axGbqwua6OV+KmalBWQewLK8= +-----END CERTIFICATE----- + +Certainly Root E1 +================= +-----BEGIN CERTIFICATE----- +MIIB9zCCAX2gAwIBAgIQBiUzsUcDMydc+Y2aub/M+DAKBggqhkjOPQQDAzA9MQswCQYDVQQGEwJV +UzESMBAGA1UEChMJQ2VydGFpbmx5MRowGAYDVQQDExFDZXJ0YWlubHkgUm9vdCBFMTAeFw0yMTA0 +MDEwMDAwMDBaFw00NjA0MDEwMDAwMDBaMD0xCzAJBgNVBAYTAlVTMRIwEAYDVQQKEwlDZXJ0YWlu +bHkxGjAYBgNVBAMTEUNlcnRhaW5seSBSb290IEUxMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE3m/4 +fxzf7flHh4axpMCK+IKXgOqPyEpeKn2IaKcBYhSRJHpcnqMXfYqGITQYUBsQ3tA3SybHGWCA6TS9 +YBk2QNYphwk8kXr2vBMj3VlOBF7PyAIcGFPBMdjaIOlEjeR2o0IwQDAOBgNVHQ8BAf8EBAMCAQYw +DwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQU8ygYy2R17ikq6+2uI1g4hevIIgcwCgYIKoZIzj0E +AwMDaAAwZQIxALGOWiDDshliTd6wT99u0nCK8Z9+aozmut6Dacpps6kFtZaSF4fC0urQe87YQVt8 +rgIwRt7qy12a7DLCZRawTDBcMPPaTnOGBtjOiQRINzf43TNRnXCve1XYAS59BWQOhriR +-----END CERTIFICATE----- + +Security Communication ECC RootCA1 +================================== +-----BEGIN CERTIFICATE----- +MIICODCCAb6gAwIBAgIJANZdm7N4gS7rMAoGCCqGSM49BAMDMGExCzAJBgNVBAYTAkpQMSUwIwYD +VQQKExxTRUNPTSBUcnVzdCBTeXN0ZW1zIENPLixMVEQuMSswKQYDVQQDEyJTZWN1cml0eSBDb21t +dW5pY2F0aW9uIEVDQyBSb290Q0ExMB4XDTE2MDYxNjA1MTUyOFoXDTM4MDExODA1MTUyOFowYTEL +MAkGA1UEBhMCSlAxJTAjBgNVBAoTHFNFQ09NIFRydXN0IFN5c3RlbXMgQ08uLExURC4xKzApBgNV +BAMTIlNlY3VyaXR5IENvbW11bmljYXRpb24gRUNDIFJvb3RDQTEwdjAQBgcqhkjOPQIBBgUrgQQA +IgNiAASkpW9gAwPDvTH00xecK4R1rOX9PVdu12O/5gSJko6BnOPpR27KkBLIE+CnnfdldB9sELLo +5OnvbYUymUSxXv3MdhDYW72ixvnWQuRXdtyQwjWpS4g8EkdtXP9JTxpKULGjQjBAMB0GA1UdDgQW +BBSGHOf+LaVKiwj+KBH6vqNm+GBZLzAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAK +BggqhkjOPQQDAwNoADBlAjAVXUI9/Lbu9zuxNuie9sRGKEkz0FhDKmMpzE2xtHqiuQ04pV1IKv3L +snNdo4gIxwwCMQDAqy0Obe0YottT6SXbVQjgUMzfRGEWgqtJsLKB7HOHeLRMsmIbEvoWTSVLY70e +N9k= +-----END CERTIFICATE----- + +BJCA Global Root CA1 +==================== +-----BEGIN CERTIFICATE----- +MIIFdDCCA1ygAwIBAgIQVW9l47TZkGobCdFsPsBsIDANBgkqhkiG9w0BAQsFADBUMQswCQYDVQQG +EwJDTjEmMCQGA1UECgwdQkVJSklORyBDRVJUSUZJQ0FURSBBVVRIT1JJVFkxHTAbBgNVBAMMFEJK +Q0EgR2xvYmFsIFJvb3QgQ0ExMB4XDTE5MTIxOTAzMTYxN1oXDTQ0MTIxMjAzMTYxN1owVDELMAkG +A1UEBhMCQ04xJjAkBgNVBAoMHUJFSUpJTkcgQ0VSVElGSUNBVEUgQVVUSE9SSVRZMR0wGwYDVQQD +DBRCSkNBIEdsb2JhbCBSb290IENBMTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAPFm +CL3ZxRVhy4QEQaVpN3cdwbB7+sN3SJATcmTRuHyQNZ0YeYjjlwE8R4HyDqKYDZ4/N+AZspDyRhyS +sTphzvq3Rp4Dhtczbu33RYx2N95ulpH3134rhxfVizXuhJFyV9xgw8O558dnJCNPYwpj9mZ9S1Wn +P3hkSWkSl+BMDdMJoDIwOvqfwPKcxRIqLhy1BDPapDgRat7GGPZHOiJBhyL8xIkoVNiMpTAK+BcW +yqw3/XmnkRd4OJmtWO2y3syJfQOcs4ll5+M7sSKGjwZteAf9kRJ/sGsciQ35uMt0WwfCyPQ10WRj +eulumijWML3mG90Vr4TqnMfK9Q7q8l0ph49pczm+LiRvRSGsxdRpJQaDrXpIhRMsDQa4bHlW/KNn +MoH1V6XKV0Jp6VwkYe/iMBhORJhVb3rCk9gZtt58R4oRTklH2yiUAguUSiz5EtBP6DF+bHq/pj+b +OT0CFqMYs2esWz8sgytnOYFcuX6U1WTdno9uruh8W7TXakdI136z1C2OVnZOz2nxbkRs1CTqjSSh +GL+9V/6pmTW12xB3uD1IutbB5/EjPtffhZ0nPNRAvQoMvfXnjSXWgXSHRtQpdaJCbPdzied9v3pK +H9MiyRVVz99vfFXQpIsHETdfg6YmV6YBW37+WGgHqel62bno/1Afq8K0wM7o6v0PvY1NuLxxAgMB +AAGjQjBAMB0GA1UdDgQWBBTF7+3M2I0hxkjk49cULqcWk+WYATAPBgNVHRMBAf8EBTADAQH/MA4G +A1UdDwEB/wQEAwIBBjANBgkqhkiG9w0BAQsFAAOCAgEAUoKsITQfI/Ki2Pm4rzc2IInRNwPWaZ+4 +YRC6ojGYWUfo0Q0lHhVBDOAqVdVXUsv45Mdpox1NcQJeXyFFYEhcCY5JEMEE3KliawLwQ8hOnThJ +dMkycFRtwUf8jrQ2ntScvd0g1lPJGKm1Vrl2i5VnZu69mP6u775u+2D2/VnGKhs/I0qUJDAnyIm8 +60Qkmss9vk/Ves6OF8tiwdneHg56/0OGNFK8YT88X7vZdrRTvJez/opMEi4r89fO4aL/3Xtw+zuh +TaRjAv04l5U/BXCga99igUOLtFkNSoxUnMW7gZ/NfaXvCyUeOiDbHPwfmGcCCtRzRBPbUYQaVQNW +4AB+dAb/OMRyHdOoP2gxXdMJxy6MW2Pg6Nwe0uxhHvLe5e/2mXZgLR6UcnHGCyoyx5JO1UbXHfmp +GQrI+pXObSOYqgs4rZpWDW+N8TEAiMEXnM0ZNjX+VVOg4DwzX5Ze4jLp3zO7Bkqp2IRzznfSxqxx +4VyjHQy7Ct9f4qNx2No3WqB4K/TUfet27fJhcKVlmtOJNBir+3I+17Q9eVzYH6Eze9mCUAyTF6ps +3MKCuwJXNq+YJyo5UOGwifUll35HaBC07HPKs5fRJNz2YqAo07WjuGS3iGJCz51TzZm+ZGiPTx4S +SPfSKcOYKMryMguTjClPPGAyzQWWYezyr/6zcCwupvI= +-----END CERTIFICATE----- + +BJCA Global Root CA2 +==================== +-----BEGIN CERTIFICATE----- +MIICJTCCAaugAwIBAgIQLBcIfWQqwP6FGFkGz7RK6zAKBggqhkjOPQQDAzBUMQswCQYDVQQGEwJD +TjEmMCQGA1UECgwdQkVJSklORyBDRVJUSUZJQ0FURSBBVVRIT1JJVFkxHTAbBgNVBAMMFEJKQ0Eg +R2xvYmFsIFJvb3QgQ0EyMB4XDTE5MTIxOTAzMTgyMVoXDTQ0MTIxMjAzMTgyMVowVDELMAkGA1UE +BhMCQ04xJjAkBgNVBAoMHUJFSUpJTkcgQ0VSVElGSUNBVEUgQVVUSE9SSVRZMR0wGwYDVQQDDBRC +SkNBIEdsb2JhbCBSb290IENBMjB2MBAGByqGSM49AgEGBSuBBAAiA2IABJ3LgJGNU2e1uVCxA/jl +SR9BIgmwUVJY1is0j8USRhTFiy8shP8sbqjV8QnjAyEUxEM9fMEsxEtqSs3ph+B99iK++kpRuDCK +/eHeGBIK9ke35xe/J4rUQUyWPGCWwf0VHKNCMEAwHQYDVR0OBBYEFNJKsVF/BvDRgh9Obl+rg/xI +1LCRMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMAoGCCqGSM49BAMDA2gAMGUCMBq8 +W9f+qdJUDkpd0m2xQNz0Q9XSSpkZElaA94M04TVOSG0ED1cxMDAtsaqdAzjbBgIxAMvMh1PLet8g +UXOQwKhbYdDFUDn9hf7B43j4ptZLvZuHjw/l1lOWqzzIQNph91Oj9w== +-----END CERTIFICATE----- + +Sectigo Public Server Authentication Root E46 +============================================= +-----BEGIN CERTIFICATE----- +MIICOjCCAcGgAwIBAgIQQvLM2htpN0RfFf51KBC49DAKBggqhkjOPQQDAzBfMQswCQYDVQQGEwJH +QjEYMBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMTYwNAYDVQQDEy1TZWN0aWdvIFB1YmxpYyBTZXJ2 +ZXIgQXV0aGVudGljYXRpb24gUm9vdCBFNDYwHhcNMjEwMzIyMDAwMDAwWhcNNDYwMzIxMjM1OTU5 +WjBfMQswCQYDVQQGEwJHQjEYMBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMTYwNAYDVQQDEy1TZWN0 +aWdvIFB1YmxpYyBTZXJ2ZXIgQXV0aGVudGljYXRpb24gUm9vdCBFNDYwdjAQBgcqhkjOPQIBBgUr +gQQAIgNiAAR2+pmpbiDt+dd34wc7qNs9Xzjoq1WmVk/WSOrsfy2qw7LFeeyZYX8QeccCWvkEN/U0 +NSt3zn8gj1KjAIns1aeibVvjS5KToID1AZTc8GgHHs3u/iVStSBDHBv+6xnOQ6OjQjBAMB0GA1Ud +DgQWBBTRItpMWfFLXyY4qp3W7usNw/upYTAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB +/zAKBggqhkjOPQQDAwNnADBkAjAn7qRaqCG76UeXlImldCBteU/IvZNeWBj7LRoAasm4PdCkT0RH +lAFWovgzJQxC36oCMB3q4S6ILuH5px0CMk7yn2xVdOOurvulGu7t0vzCAxHrRVxgED1cf5kDW21U +SAGKcw== +-----END CERTIFICATE----- + +Sectigo Public Server Authentication Root R46 +============================================= +-----BEGIN CERTIFICATE----- +MIIFijCCA3KgAwIBAgIQdY39i658BwD6qSWn4cetFDANBgkqhkiG9w0BAQwFADBfMQswCQYDVQQG +EwJHQjEYMBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMTYwNAYDVQQDEy1TZWN0aWdvIFB1YmxpYyBT +ZXJ2ZXIgQXV0aGVudGljYXRpb24gUm9vdCBSNDYwHhcNMjEwMzIyMDAwMDAwWhcNNDYwMzIxMjM1 +OTU5WjBfMQswCQYDVQQGEwJHQjEYMBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMTYwNAYDVQQDEy1T +ZWN0aWdvIFB1YmxpYyBTZXJ2ZXIgQXV0aGVudGljYXRpb24gUm9vdCBSNDYwggIiMA0GCSqGSIb3 +DQEBAQUAA4ICDwAwggIKAoICAQCTvtU2UnXYASOgHEdCSe5jtrch/cSV1UgrJnwUUxDaef0rty2k +1Cz66jLdScK5vQ9IPXtamFSvnl0xdE8H/FAh3aTPaE8bEmNtJZlMKpnzSDBh+oF8HqcIStw+Kxwf +GExxqjWMrfhu6DtK2eWUAtaJhBOqbchPM8xQljeSM9xfiOefVNlI8JhD1mb9nxc4Q8UBUQvX4yMP +FF1bFOdLvt30yNoDN9HWOaEhUTCDsG3XME6WW5HwcCSrv0WBZEMNvSE6Lzzpng3LILVCJ8zab5vu +ZDCQOc2TZYEhMbUjUDM3IuM47fgxMMxF/mL50V0yeUKH32rMVhlATc6qu/m1dkmU8Sf4kaWD5Qaz +Yw6A3OASVYCmO2a0OYctyPDQ0RTp5A1NDvZdV3LFOxxHVp3i1fuBYYzMTYCQNFu31xR13NgESJ/A +wSiItOkcyqex8Va3e0lMWeUgFaiEAin6OJRpmkkGj80feRQXEgyDet4fsZfu+Zd4KKTIRJLpfSYF +plhym3kT2BFfrsU4YjRosoYwjviQYZ4ybPUHNs2iTG7sijbt8uaZFURww3y8nDnAtOFr94MlI1fZ +EoDlSfB1D++N6xybVCi0ITz8fAr/73trdf+LHaAZBav6+CuBQug4urv7qv094PPK306Xlynt8xhW +6aWWrL3DkJiy4Pmi1KZHQ3xtzwIDAQABo0IwQDAdBgNVHQ4EFgQUVnNYZJX5khqwEioEYnmhQBWI +IUkwDgYDVR0PAQH/BAQDAgGGMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQEMBQADggIBAC9c +mTz8Bl6MlC5w6tIyMY208FHVvArzZJ8HXtXBc2hkeqK5Duj5XYUtqDdFqij0lgVQYKlJfp/imTYp +E0RHap1VIDzYm/EDMrraQKFz6oOht0SmDpkBm+S8f74TlH7Kph52gDY9hAaLMyZlbcp+nv4fjFg4 +exqDsQ+8FxG75gbMY/qB8oFM2gsQa6H61SilzwZAFv97fRheORKkU55+MkIQpiGRqRxOF3yEvJ+M +0ejf5lG5Nkc/kLnHvALcWxxPDkjBJYOcCj+esQMzEhonrPcibCTRAUH4WAP+JWgiH5paPHxsnnVI +84HxZmduTILA7rpXDhjvLpr3Etiga+kFpaHpaPi8TD8SHkXoUsCjvxInebnMMTzD9joiFgOgyY9m +pFuiTdaBJQbpdqQACj7LzTWb4OE4y2BThihCQRxEV+ioratF4yUQvNs+ZUH7G6aXD+u5dHn5Hrwd +Vw1Hr8Mvn4dGp+smWg9WY7ViYG4A++MnESLn/pmPNPW56MORcr3Ywx65LvKRRFHQV80MNNVIIb/b +E/FmJUNS0nAiNs2fxBx1IK1jcmMGDw4nztJqDby1ORrp0XZ60Vzk50lJLVU3aPAaOpg+VBeHVOmm +J1CJeyAvP/+/oYtKR5j/K3tJPsMpRmAYQqszKbrAKbkTidOIijlBO8n9pu0f9GBj39ItVQGL +-----END CERTIFICATE----- + +SSL.com TLS RSA Root CA 2022 +============================ +-----BEGIN CERTIFICATE----- +MIIFiTCCA3GgAwIBAgIQb77arXO9CEDii02+1PdbkTANBgkqhkiG9w0BAQsFADBOMQswCQYDVQQG +EwJVUzEYMBYGA1UECgwPU1NMIENvcnBvcmF0aW9uMSUwIwYDVQQDDBxTU0wuY29tIFRMUyBSU0Eg +Um9vdCBDQSAyMDIyMB4XDTIyMDgyNTE2MzQyMloXDTQ2MDgxOTE2MzQyMVowTjELMAkGA1UEBhMC +VVMxGDAWBgNVBAoMD1NTTCBDb3Jwb3JhdGlvbjElMCMGA1UEAwwcU1NMLmNvbSBUTFMgUlNBIFJv +b3QgQ0EgMjAyMjCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANCkCXJPQIgSYT41I57u +9nTPL3tYPc48DRAokC+X94xI2KDYJbFMsBFMF3NQ0CJKY7uB0ylu1bUJPiYYf7ISf5OYt6/wNr/y +7hienDtSxUcZXXTzZGbVXcdotL8bHAajvI9AI7YexoS9UcQbOcGV0insS657Lb85/bRi3pZ7Qcac +oOAGcvvwB5cJOYF0r/c0WRFXCsJbwST0MXMwgsadugL3PnxEX4MN8/HdIGkWCVDi1FW24IBydm5M +R7d1VVm0U3TZlMZBrViKMWYPHqIbKUBOL9975hYsLfy/7PO0+r4Y9ptJ1O4Fbtk085zx7AGL0SDG +D6C1vBdOSHtRwvzpXGk3R2azaPgVKPC506QVzFpPulJwoxJF3ca6TvvC0PeoUidtbnm1jPx7jMEW +TO6Af77wdr5BUxIzrlo4QqvXDz5BjXYHMtWrifZOZ9mxQnUjbvPNQrL8VfVThxc7wDNY8VLS+YCk +8OjwO4s4zKTGkH8PnP2L0aPP2oOnaclQNtVcBdIKQXTbYxE3waWglksejBYSd66UNHsef8JmAOSq +g+qKkK3ONkRN0VHpvB/zagX9wHQfJRlAUW7qglFA35u5CCoGAtUjHBPW6dvbxrB6y3snm/vg1UYk +7RBLY0ulBY+6uB0rpvqR4pJSvezrZ5dtmi2fgTIFZzL7SAg/2SW4BCUvAgMBAAGjYzBhMA8GA1Ud +EwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAU+y437uOEeicuzRk1sTN8/9REQrkwHQYDVR0OBBYEFPsu +N+7jhHonLs0ZNbEzfP/UREK5MA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQsFAAOCAgEAjYlt +hEUY8U+zoO9opMAdrDC8Z2awms22qyIZZtM7QbUQnRC6cm4pJCAcAZli05bg4vsMQtfhWsSWTVTN +j8pDU/0quOr4ZcoBwq1gaAafORpR2eCNJvkLTqVTJXojpBzOCBvfR4iyrT7gJ4eLSYwfqUdYe5by +iB0YrrPRpgqU+tvT5TgKa3kSM/tKWTcWQA673vWJDPFs0/dRa1419dvAJuoSc06pkZCmF8NsLzjU +o3KUQyxi4U5cMj29TH0ZR6LDSeeWP4+a0zvkEdiLA9z2tmBVGKaBUfPhqBVq6+AL8BQx1rmMRTqo +ENjwuSfr98t67wVylrXEj5ZzxOhWc5y8aVFjvO9nHEMaX3cZHxj4HCUp+UmZKbaSPaKDN7Egkaib +MOlqbLQjk2UEqxHzDh1TJElTHaE/nUiSEeJ9DU/1172iWD54nR4fK/4huxoTtrEoZP2wAgDHbICi +vRZQIA9ygV/MlP+7mea6kMvq+cYMwq7FGc4zoWtcu358NFcXrfA/rs3qr5nsLFR+jM4uElZI7xc7 +P0peYNLcdDa8pUNjyw9bowJWCZ4kLOGGgYz+qxcs+sjiMho6/4UIyYOf8kpIEFR3N+2ivEC+5BB0 +9+Rbu7nzifmPQdjH5FCQNYA+HLhNkNPU98OwoX6EyneSMSy4kLGCenROmxMmtNVQZlR4rmA= +-----END CERTIFICATE----- + +SSL.com TLS ECC Root CA 2022 +============================ +-----BEGIN CERTIFICATE----- +MIICOjCCAcCgAwIBAgIQFAP1q/s3ixdAW+JDsqXRxDAKBggqhkjOPQQDAzBOMQswCQYDVQQGEwJV +UzEYMBYGA1UECgwPU1NMIENvcnBvcmF0aW9uMSUwIwYDVQQDDBxTU0wuY29tIFRMUyBFQ0MgUm9v +dCBDQSAyMDIyMB4XDTIyMDgyNTE2MzM0OFoXDTQ2MDgxOTE2MzM0N1owTjELMAkGA1UEBhMCVVMx +GDAWBgNVBAoMD1NTTCBDb3Jwb3JhdGlvbjElMCMGA1UEAwwcU1NMLmNvbSBUTFMgRUNDIFJvb3Qg +Q0EgMjAyMjB2MBAGByqGSM49AgEGBSuBBAAiA2IABEUpNXP6wrgjzhR9qLFNoFs27iosU8NgCTWy +JGYmacCzldZdkkAZDsalE3D07xJRKF3nzL35PIXBz5SQySvOkkJYWWf9lCcQZIxPBLFNSeR7T5v1 +5wj4A4j3p8OSSxlUgaNjMGEwDwYDVR0TAQH/BAUwAwEB/zAfBgNVHSMEGDAWgBSJjy+j6CugFFR7 +81a4Jl9nOAuc0DAdBgNVHQ4EFgQUiY8vo+groBRUe/NWuCZfZzgLnNAwDgYDVR0PAQH/BAQDAgGG +MAoGCCqGSM49BAMDA2gAMGUCMFXjIlbp15IkWE8elDIPDAI2wv2sdDJO4fscgIijzPvX6yv/N33w +7deedWo1dlJF4AIxAMeNb0Igj762TVntd00pxCAgRWSGOlDGxK0tk/UYfXLtqc/ErFc2KAhl3zx5 +Zn6g6g== +-----END CERTIFICATE----- + +Atos TrustedRoot Root CA ECC TLS 2021 +===================================== +-----BEGIN CERTIFICATE----- +MIICFTCCAZugAwIBAgIQPZg7pmY9kGP3fiZXOATvADAKBggqhkjOPQQDAzBMMS4wLAYDVQQDDCVB +dG9zIFRydXN0ZWRSb290IFJvb3QgQ0EgRUNDIFRMUyAyMDIxMQ0wCwYDVQQKDARBdG9zMQswCQYD +VQQGEwJERTAeFw0yMTA0MjIwOTI2MjNaFw00MTA0MTcwOTI2MjJaMEwxLjAsBgNVBAMMJUF0b3Mg +VHJ1c3RlZFJvb3QgUm9vdCBDQSBFQ0MgVExTIDIwMjExDTALBgNVBAoMBEF0b3MxCzAJBgNVBAYT +AkRFMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEloZYKDcKZ9Cg3iQZGeHkBQcfl+3oZIK59sRxUM6K +DP/XtXa7oWyTbIOiaG6l2b4siJVBzV3dscqDY4PMwL502eCdpO5KTlbgmClBk1IQ1SQ4AjJn8ZQS +b+/Xxd4u/RmAo0IwQDAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBR2KCXWfeBmmnoJsmo7jjPX +NtNPojAOBgNVHQ8BAf8EBAMCAYYwCgYIKoZIzj0EAwMDaAAwZQIwW5kp85wxtolrbNa9d+F851F+ +uDrNozZffPc8dz7kUK2o59JZDCaOMDtuCCrCp1rIAjEAmeMM56PDr9NJLkaCI2ZdyQAUEv049OGY +a3cpetskz2VAv9LcjBHo9H1/IISpQuQo +-----END CERTIFICATE----- + +Atos TrustedRoot Root CA RSA TLS 2021 +===================================== +-----BEGIN CERTIFICATE----- +MIIFZDCCA0ygAwIBAgIQU9XP5hmTC/srBRLYwiqipDANBgkqhkiG9w0BAQwFADBMMS4wLAYDVQQD +DCVBdG9zIFRydXN0ZWRSb290IFJvb3QgQ0EgUlNBIFRMUyAyMDIxMQ0wCwYDVQQKDARBdG9zMQsw +CQYDVQQGEwJERTAeFw0yMTA0MjIwOTIxMTBaFw00MTA0MTcwOTIxMDlaMEwxLjAsBgNVBAMMJUF0 +b3MgVHJ1c3RlZFJvb3QgUm9vdCBDQSBSU0EgVExTIDIwMjExDTALBgNVBAoMBEF0b3MxCzAJBgNV +BAYTAkRFMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAtoAOxHm9BYx9sKOdTSJNy/BB +l01Z4NH+VoyX8te9j2y3I49f1cTYQcvyAh5x5en2XssIKl4w8i1mx4QbZFc4nXUtVsYvYe+W/CBG +vevUez8/fEc4BKkbqlLfEzfTFRVOvV98r61jx3ncCHvVoOX3W3WsgFWZkmGbzSoXfduP9LVq6hdK +ZChmFSlsAvFr1bqjM9xaZ6cF4r9lthawEO3NUDPJcFDsGY6wx/J0W2tExn2WuZgIWWbeKQGb9Cpt +0xU6kGpn8bRrZtkh68rZYnxGEFzedUlnnkL5/nWpo63/dgpnQOPF943HhZpZnmKaau1Fh5hnstVK +PNe0OwANwI8f4UDErmwh3El+fsqyjW22v5MvoVw+j8rtgI5Y4dtXz4U2OLJxpAmMkokIiEjxQGMY +sluMWuPD0xeqqxmjLBvk1cbiZnrXghmmOxYsL3GHX0WelXOTwkKBIROW1527k2gV+p2kHYzygeBY +Br3JtuP2iV2J+axEoctr+hbxx1A9JNr3w+SH1VbxT5Aw+kUJWdo0zuATHAR8ANSbhqRAvNncTFd+ +rrcztl524WWLZt+NyteYr842mIycg5kDcPOvdO3GDjbnvezBc6eUWsuSZIKmAMFwoW4sKeFYV+xa +fJlrJaSQOoD0IJ2azsct+bJLKZWD6TWNp0lIpw9MGZHQ9b8Q4HECAwEAAaNCMEAwDwYDVR0TAQH/ +BAUwAwEB/zAdBgNVHQ4EFgQUdEmZ0f+0emhFdcN+tNzMzjkz2ggwDgYDVR0PAQH/BAQDAgGGMA0G +CSqGSIb3DQEBDAUAA4ICAQAjQ1MkYlxt/T7Cz1UAbMVWiLkO3TriJQ2VSpfKgInuKs1l+NsW4AmS +4BjHeJi78+xCUvuppILXTdiK/ORO/auQxDh1MoSf/7OwKwIzNsAQkG8dnK/haZPso0UvFJ/1TCpl +Q3IM98P4lYsU84UgYt1UU90s3BiVaU+DR3BAM1h3Egyi61IxHkzJqM7F78PRreBrAwA0JrRUITWX +AdxfG/F851X6LWh3e9NpzNMOa7pNdkTWwhWaJuywxfW70Xp0wmzNxbVe9kzmWy2B27O3Opee7c9G +slA9hGCZcbUztVdF5kJHdWoOsAgMrr3e97sPWD2PAzHoPYJQyi9eDF20l74gNAf0xBLh7tew2Vkt +afcxBPTy+av5EzH4AXcOPUIjJsyacmdRIXrMPIWo6iFqO9taPKU0nprALN+AnCng33eU0aKAQv9q +TFsR0PXNor6uzFFcw9VUewyu1rkGd4Di7wcaaMxZUa1+XGdrudviB0JbuAEFWDlN5LuYo7Ey7Nmj +1m+UI/87tyll5gfp77YZ6ufCOB0yiJA8EytuzO+rdwY0d4RPcuSBhPm5dDTedk+SKlOxJTnbPP/l +PqYO5Wue/9vsL3SD3460s6neFE3/MaNFcyT6lSnMEpcEoji2jbDwN/zIIX8/syQbPYtuzE2wFg2W +HYMfRsCbvUOZ58SWLs5fyQ== +-----END CERTIFICATE----- + +TrustAsia Global Root CA G3 +=========================== +-----BEGIN CERTIFICATE----- +MIIFpTCCA42gAwIBAgIUZPYOZXdhaqs7tOqFhLuxibhxkw8wDQYJKoZIhvcNAQEMBQAwWjELMAkG +A1UEBhMCQ04xJTAjBgNVBAoMHFRydXN0QXNpYSBUZWNobm9sb2dpZXMsIEluYy4xJDAiBgNVBAMM +G1RydXN0QXNpYSBHbG9iYWwgUm9vdCBDQSBHMzAeFw0yMTA1MjAwMjEwMTlaFw00NjA1MTkwMjEw +MTlaMFoxCzAJBgNVBAYTAkNOMSUwIwYDVQQKDBxUcnVzdEFzaWEgVGVjaG5vbG9naWVzLCBJbmMu +MSQwIgYDVQQDDBtUcnVzdEFzaWEgR2xvYmFsIFJvb3QgQ0EgRzMwggIiMA0GCSqGSIb3DQEBAQUA +A4ICDwAwggIKAoICAQDAMYJhkuSUGwoqZdC+BqmHO1ES6nBBruL7dOoKjbmzTNyPtxNST1QY4Sxz +lZHFZjtqz6xjbYdT8PfxObegQ2OwxANdV6nnRM7EoYNl9lA+sX4WuDqKAtCWHwDNBSHvBm3dIZwZ +Q0WhxeiAysKtQGIXBsaqvPPW5vxQfmZCHzyLpnl5hkA1nyDvP+uLRx+PjsXUjrYsyUQE49RDdT/V +P68czH5GX6zfZBCK70bwkPAPLfSIC7Epqq+FqklYqL9joDiR5rPmd2jE+SoZhLsO4fWvieylL1Ag +dB4SQXMeJNnKziyhWTXAyB1GJ2Faj/lN03J5Zh6fFZAhLf3ti1ZwA0pJPn9pMRJpxx5cynoTi+jm +9WAPzJMshH/x/Gr8m0ed262IPfN2dTPXS6TIi/n1Q1hPy8gDVI+lhXgEGvNz8teHHUGf59gXzhqc +D0r83ERoVGjiQTz+LISGNzzNPy+i2+f3VANfWdP3kXjHi3dqFuVJhZBFcnAvkV34PmVACxmZySYg +WmjBNb9Pp1Hx2BErW+Canig7CjoKH8GB5S7wprlppYiU5msTf9FkPz2ccEblooV7WIQn3MSAPmea +mseaMQ4w7OYXQJXZRe0Blqq/DPNL0WP3E1jAuPP6Z92bfW1K/zJMtSU7/xxnD4UiWQWRkUF3gdCF +TIcQcf+eQxuulXUtgQIDAQABo2MwYTAPBgNVHRMBAf8EBTADAQH/MB8GA1UdIwQYMBaAFEDk5PIj +7zjKsK5Xf/IhMBY027ySMB0GA1UdDgQWBBRA5OTyI+84yrCuV3/yITAWNNu8kjAOBgNVHQ8BAf8E +BAMCAQYwDQYJKoZIhvcNAQEMBQADggIBACY7UeFNOPMyGLS0XuFlXsSUT9SnYaP4wM8zAQLpw6o1 +D/GUE3d3NZ4tVlFEbuHGLige/9rsR82XRBf34EzC4Xx8MnpmyFq2XFNFV1pF1AWZLy4jVe5jaN/T +G3inEpQGAHUNcoTpLrxaatXeL1nHo+zSh2bbt1S1JKv0Q3jbSwTEb93mPmY+KfJLaHEih6D4sTNj +duMNhXJEIlU/HHzp/LgV6FL6qj6jITk1dImmasI5+njPtqzn59ZW/yOSLlALqbUHM/Q4X6RJpstl +cHboCoWASzY9M/eVVHUl2qzEc4Jl6VL1XP04lQJqaTDFHApXB64ipCz5xUG3uOyfT0gA+QEEVcys ++TIxxHWVBqB/0Y0n3bOppHKH/lmLmnp0Ft0WpWIp6zqW3IunaFnT63eROfjXy9mPX1onAX1daBli +2MjN9LdyR75bl87yraKZk62Uy5P2EgmVtqvXO9A/EcswFi55gORngS1d7XB4tmBZrOFdRWOPyN9y +aFvqHbgB8X7754qz41SgOAngPN5C8sLtLpvzHzW2NtjjgKGLzZlkD8Kqq7HK9W+eQ42EVJmzbsAS +ZthwEPEGNTNDqJwuuhQxzhB/HIbjj9LV+Hfsm6vxL2PZQl/gZ4FkkfGXL/xuJvYz+NO1+MRiqzFR +JQJ6+N1rZdVtTTDIZbpoFGWsJwt0ivKH +-----END CERTIFICATE----- + +TrustAsia Global Root CA G4 +=========================== +-----BEGIN CERTIFICATE----- +MIICVTCCAdygAwIBAgIUTyNkuI6XY57GU4HBdk7LKnQV1tcwCgYIKoZIzj0EAwMwWjELMAkGA1UE +BhMCQ04xJTAjBgNVBAoMHFRydXN0QXNpYSBUZWNobm9sb2dpZXMsIEluYy4xJDAiBgNVBAMMG1Ry +dXN0QXNpYSBHbG9iYWwgUm9vdCBDQSBHNDAeFw0yMTA1MjAwMjEwMjJaFw00NjA1MTkwMjEwMjJa +MFoxCzAJBgNVBAYTAkNOMSUwIwYDVQQKDBxUcnVzdEFzaWEgVGVjaG5vbG9naWVzLCBJbmMuMSQw +IgYDVQQDDBtUcnVzdEFzaWEgR2xvYmFsIFJvb3QgQ0EgRzQwdjAQBgcqhkjOPQIBBgUrgQQAIgNi +AATxs8045CVD5d4ZCbuBeaIVXxVjAd7Cq92zphtnS4CDr5nLrBfbK5bKfFJV4hrhPVbwLxYI+hW8 +m7tH5j/uqOFMjPXTNvk4XatwmkcN4oFBButJ+bAp3TPsUKV/eSm4IJijYzBhMA8GA1UdEwEB/wQF +MAMBAf8wHwYDVR0jBBgwFoAUpbtKl86zK3+kMd6Xg1mDpm9xy94wHQYDVR0OBBYEFKW7SpfOsyt/ +pDHel4NZg6ZvccveMA4GA1UdDwEB/wQEAwIBBjAKBggqhkjOPQQDAwNnADBkAjBe8usGzEkxn0AA +bbd+NvBNEU/zy4k6LHiRUKNbwMp1JvK/kF0LgoxgKJ/GcJpo5PECMFxYDlZ2z1jD1xCMuo6u47xk +dUfFVZDj/bpV6wfEU6s3qe4hsiFbYI89MvHVI5TWWA== +-----END CERTIFICATE----- + +Telekom Security TLS ECC Root 2020 +================================== +-----BEGIN CERTIFICATE----- +MIICQjCCAcmgAwIBAgIQNjqWjMlcsljN0AFdxeVXADAKBggqhkjOPQQDAzBjMQswCQYDVQQGEwJE +RTEnMCUGA1UECgweRGV1dHNjaGUgVGVsZWtvbSBTZWN1cml0eSBHbWJIMSswKQYDVQQDDCJUZWxl +a29tIFNlY3VyaXR5IFRMUyBFQ0MgUm9vdCAyMDIwMB4XDTIwMDgyNTA3NDgyMFoXDTQ1MDgyNTIz +NTk1OVowYzELMAkGA1UEBhMCREUxJzAlBgNVBAoMHkRldXRzY2hlIFRlbGVrb20gU2VjdXJpdHkg +R21iSDErMCkGA1UEAwwiVGVsZWtvbSBTZWN1cml0eSBUTFMgRUNDIFJvb3QgMjAyMDB2MBAGByqG +SM49AgEGBSuBBAAiA2IABM6//leov9Wq9xCazbzREaK9Z0LMkOsVGJDZos0MKiXrPk/OtdKPD/M1 +2kOLAoC+b1EkHQ9rK8qfwm9QMuU3ILYg/4gND21Ju9sGpIeQkpT0CdDPf8iAC8GXs7s1J8nCG6NC +MEAwHQYDVR0OBBYEFONyzG6VmUex5rNhTNHLq+O6zd6fMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0P +AQH/BAQDAgEGMAoGCCqGSM49BAMDA2cAMGQCMHVSi7ekEE+uShCLsoRbQuHmKjYC2qBuGT8lv9pZ +Mo7k+5Dck2TOrbRBR2Diz6fLHgIwN0GMZt9Ba9aDAEH9L1r3ULRn0SyocddDypwnJJGDSA3PzfdU +ga/sf+Rn27iQ7t0l +-----END CERTIFICATE----- + +Telekom Security TLS RSA Root 2023 +================================== +-----BEGIN CERTIFICATE----- +MIIFszCCA5ugAwIBAgIQIZxULej27HF3+k7ow3BXlzANBgkqhkiG9w0BAQwFADBjMQswCQYDVQQG +EwJERTEnMCUGA1UECgweRGV1dHNjaGUgVGVsZWtvbSBTZWN1cml0eSBHbWJIMSswKQYDVQQDDCJU +ZWxla29tIFNlY3VyaXR5IFRMUyBSU0EgUm9vdCAyMDIzMB4XDTIzMDMyODEyMTY0NVoXDTQ4MDMy +NzIzNTk1OVowYzELMAkGA1UEBhMCREUxJzAlBgNVBAoMHkRldXRzY2hlIFRlbGVrb20gU2VjdXJp +dHkgR21iSDErMCkGA1UEAwwiVGVsZWtvbSBTZWN1cml0eSBUTFMgUlNBIFJvb3QgMjAyMzCCAiIw +DQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAO01oYGA88tKaVvC+1GDrib94W7zgRJ9cUD/h3VC +KSHtgVIs3xLBGYSJwb3FKNXVS2xE1kzbB5ZKVXrKNoIENqil/Cf2SfHVcp6R+SPWcHu79ZvB7JPP +GeplfohwoHP89v+1VmLhc2o0mD6CuKyVU/QBoCcHcqMAU6DksquDOFczJZSfvkgdmOGjup5czQRx +UX11eKvzWarE4GC+j4NSuHUaQTXtvPM6Y+mpFEXX5lLRbtLevOP1Czvm4MS9Q2QTps70mDdsipWo +l8hHD/BeEIvnHRz+sTugBTNoBUGCwQMrAcjnj02r6LX2zWtEtefdi+zqJbQAIldNsLGyMcEWzv/9 +FIS3R/qy8XDe24tsNlikfLMR0cN3f1+2JeANxdKz+bi4d9s3cXFH42AYTyS2dTd4uaNir73Jco4v +zLuu2+QVUhkHM/tqty1LkCiCc/4YizWN26cEar7qwU02OxY2kTLvtkCJkUPg8qKrBC7m8kwOFjQg +rIfBLX7JZkcXFBGk8/ehJImr2BrIoVyxo/eMbcgByU/J7MT8rFEz0ciD0cmfHdRHNCk+y7AO+oML +KFjlKdw/fKifybYKu6boRhYPluV75Gp6SG12mAWl3G0eQh5C2hrgUve1g8Aae3g1LDj1H/1Joy7S +WWO/gLCMk3PLNaaZlSJhZQNg+y+TS/qanIA7AgMBAAGjYzBhMA4GA1UdDwEB/wQEAwIBBjAdBgNV +HQ4EFgQUtqeXgj10hZv3PJ+TmpV5dVKMbUcwDwYDVR0TAQH/BAUwAwEB/zAfBgNVHSMEGDAWgBS2 +p5eCPXSFm/c8n5OalXl1UoxtRzANBgkqhkiG9w0BAQwFAAOCAgEAqMxhpr51nhVQpGv7qHBFfLp+ +sVr8WyP6Cnf4mHGCDG3gXkaqk/QeoMPhk9tLrbKmXauw1GLLXrtm9S3ul0A8Yute1hTWjOKWi0Fp +kzXmuZlrYrShF2Y0pmtjxrlO8iLpWA1WQdH6DErwM807u20hOq6OcrXDSvvpfeWxm4bu4uB9tPcy +/SKE8YXJN3nptT+/XOR0so8RYgDdGGah2XsjX/GO1WfoVNpbOms2b/mBsTNHM3dA+VKq3dSDz4V4 +mZqTuXNnQkYRIer+CqkbGmVps4+uFrb2S1ayLfmlyOw7YqPta9BO1UAJpB+Y1zqlklkg5LB9zVtz +aL1txKITDmcZuI1CfmwMmm6gJC3VRRvcxAIU/oVbZZfKTpBQCHpCNfnqwmbU+AGuHrS+w6jv/naa +oqYfRvaE7fzbzsQCzndILIyy7MMAo+wsVRjBfhnu4S/yrYObnqsZ38aKL4x35bcF7DvB7L6Gs4a8 +wPfc5+pbrrLMtTWGS9DiP7bY+A4A7l3j941Y/8+LN+ljX273CXE2whJdV/LItM3z7gLfEdxquVeE +HVlNjM7IDiPCtyaaEBRx/pOyiriA8A4QntOoUAw3gi/q4Iqd4Sw5/7W0cwDk90imc6y/st53BIe0 +o82bNSQ3+pCTE4FCxpgmdTdmQRCsu/WU48IxK63nI1bMNSWSs1A= +-----END CERTIFICATE----- + +TWCA CYBER Root CA +================== +-----BEGIN CERTIFICATE----- +MIIFjTCCA3WgAwIBAgIQQAE0jMIAAAAAAAAAATzyxjANBgkqhkiG9w0BAQwFADBQMQswCQYDVQQG +EwJUVzESMBAGA1UEChMJVEFJV0FOLUNBMRAwDgYDVQQLEwdSb290IENBMRswGQYDVQQDExJUV0NB +IENZQkVSIFJvb3QgQ0EwHhcNMjIxMTIyMDY1NDI5WhcNNDcxMTIyMTU1OTU5WjBQMQswCQYDVQQG +EwJUVzESMBAGA1UEChMJVEFJV0FOLUNBMRAwDgYDVQQLEwdSb290IENBMRswGQYDVQQDExJUV0NB +IENZQkVSIFJvb3QgQ0EwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDG+Moe2Qkgfh1s +Ts6P40czRJzHyWmqOlt47nDSkvgEs1JSHWdyKKHfi12VCv7qze33Kc7wb3+szT3vsxxFavcokPFh +V8UMxKNQXd7UtcsZyoC5dc4pztKFIuwCY8xEMCDa6pFbVuYdHNWdZsc/34bKS1PE2Y2yHer43CdT +o0fhYcx9tbD47nORxc5zb87uEB8aBs/pJ2DFTxnk684iJkXXYJndzk834H/nY62wuFm40AZoNWDT +Nq5xQwTxaWV4fPMf88oon1oglWa0zbfuj3ikRRjpJi+NmykosaS3Om251Bw4ckVYsV7r8Cibt4LK +/c/WMw+f+5eesRycnupfXtuq3VTpMCEobY5583WSjCb+3MX2w7DfRFlDo7YDKPYIMKoNM+HvnKkH +IuNZW0CP2oi3aQiotyMuRAlZN1vH4xfyIutuOVLF3lSnmMlLIJXcRolftBL5hSmO68gnFSDAS9TM +fAxsNAwmmyYxpjyn9tnQS6Jk/zuZQXLB4HCX8SS7K8R0IrGsayIyJNN4KsDAoS/xUgXJP+92ZuJF +2A09rZXIx4kmyA+upwMu+8Ff+iDhcK2wZSA3M2Cw1a/XDBzCkHDXShi8fgGwsOsVHkQGzaRP6AzR +wyAQ4VRlnrZR0Bp2a0JaWHY06rc3Ga4udfmW5cFZ95RXKSWNOkyrTZpB0F8mAwIDAQABo2MwYTAO +BgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAfBgNVHSMEGDAWgBSdhWEUfMFib5do5E83 +QOGt4A1WNzAdBgNVHQ4EFgQUnYVhFHzBYm+XaORPN0DhreANVjcwDQYJKoZIhvcNAQEMBQADggIB +AGSPesRiDrWIzLjHhg6hShbNcAu3p4ULs3a2D6f/CIsLJc+o1IN1KriWiLb73y0ttGlTITVX1olN +c79pj3CjYcya2x6a4CD4bLubIp1dhDGaLIrdaqHXKGnK/nZVekZn68xDiBaiA9a5F/gZbG0jAn/x +X9AKKSM70aoK7akXJlQKTcKlTfjF/biBzysseKNnTKkHmvPfXvt89YnNdJdhEGoHK4Fa0o635yDR +IG4kqIQnoVesqlVYL9zZyvpoBJ7tRCT5dEA7IzOrg1oYJkK2bVS1FmAwbLGg+LhBoF1JSdJlBTrq +/p1hvIbZv97Tujqxf36SNI7JAG7cmL3c7IAFrQI932XtCwP39xaEBDG6k5TY8hL4iuO/Qq+n1M0R +FxbIQh0UqEL20kCGoE8jypZFVmAGzbdVAaYBlGX+bgUJurSkquLvWL69J1bY73NxW0Qz8ppy6rBe +Pm6pUlvscG21h483XjyMnM7k8M4MZ0HMzvaAq07MTFb1wWFZk7Q+ptq4NxKfKjLji7gh7MMrZQzv +It6IKTtM1/r+t+FHvpw+PoP7UV31aPcuIYXcv/Fa4nzXxeSDwWrruoBa3lwtcHb4yOWHh8qgnaHl +IhInD0Q9HWzq1MKLL295q39QpsQZp6F6t5b5wR9iWqJDB0BeJsas7a5wFsWqynKKTbDPAYsDP27X +-----END CERTIFICATE----- + +SecureSign Root CA12 +==================== +-----BEGIN CERTIFICATE----- +MIIDcjCCAlqgAwIBAgIUZvnHwa/swlG07VOX5uaCwysckBYwDQYJKoZIhvcNAQELBQAwUTELMAkG +A1UEBhMCSlAxIzAhBgNVBAoTGkN5YmVydHJ1c3QgSmFwYW4gQ28uLCBMdGQuMR0wGwYDVQQDExRT +ZWN1cmVTaWduIFJvb3QgQ0ExMjAeFw0yMDA0MDgwNTM2NDZaFw00MDA0MDgwNTM2NDZaMFExCzAJ +BgNVBAYTAkpQMSMwIQYDVQQKExpDeWJlcnRydXN0IEphcGFuIENvLiwgTHRkLjEdMBsGA1UEAxMU +U2VjdXJlU2lnbiBSb290IENBMTIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC6OcE3 +emhFKxS06+QT61d1I02PJC0W6K6OyX2kVzsqdiUzg2zqMoqUm048luT9Ub+ZyZN+v/mtp7JIKwcc +J/VMvHASd6SFVLX9kHrko+RRWAPNEHl57muTH2SOa2SroxPjcf59q5zdJ1M3s6oYwlkm7Fsf0uZl +fO+TvdhYXAvA42VvPMfKWeP+bl+sg779XSVOKik71gurFzJ4pOE+lEa+Ym6b3kaosRbnhW70CEBF +EaCeVESE99g2zvVQR9wsMJvuwPWW0v4JhscGWa5Pro4RmHvzC1KqYiaqId+OJTN5lxZJjfU+1Uef +NzFJM3IFTQy2VYzxV4+Kh9GtxRESOaCtAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0P +AQH/BAQDAgEGMB0GA1UdDgQWBBRXNPN0zwRL1SXm8UC2LEzZLemgrTANBgkqhkiG9w0BAQsFAAOC +AQEAPrvbFxbS8hQBICw4g0utvsqFepq2m2um4fylOqyttCg6r9cBg0krY6LdmmQOmFxv3Y67ilQi +LUoT865AQ9tPkbeGGuwAtEGBpE/6aouIs3YIcipJQMPTw4WJmBClnW8Zt7vPemVV2zfrPIpyMpce +mik+rY3moxtt9XUa5rBouVui7mlHJzWhhpmA8zNL4WukJsPvdFlseqJkth5Ew1DgDzk9qTPxpfPS +vWKErI4cqc1avTc7bgoitPQV55FYxTpE05Uo2cBl6XLK0A+9H7MV2anjpEcJnuDLN/v9vZfVvhga +aaI5gdka9at/yOPiZwud9AzqVN/Ssq+xIvEg37xEHA== +-----END CERTIFICATE----- + +SecureSign Root CA14 +==================== +-----BEGIN CERTIFICATE----- +MIIFcjCCA1qgAwIBAgIUZNtaDCBO6Ncpd8hQJ6JaJ90t8sswDQYJKoZIhvcNAQEMBQAwUTELMAkG +A1UEBhMCSlAxIzAhBgNVBAoTGkN5YmVydHJ1c3QgSmFwYW4gQ28uLCBMdGQuMR0wGwYDVQQDExRT +ZWN1cmVTaWduIFJvb3QgQ0ExNDAeFw0yMDA0MDgwNzA2MTlaFw00NTA0MDgwNzA2MTlaMFExCzAJ +BgNVBAYTAkpQMSMwIQYDVQQKExpDeWJlcnRydXN0IEphcGFuIENvLiwgTHRkLjEdMBsGA1UEAxMU +U2VjdXJlU2lnbiBSb290IENBMTQwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDF0nqh +1oq/FjHQmNE6lPxauG4iwWL3pwon71D2LrGeaBLwbCRjOfHw3xDG3rdSINVSW0KZnvOgvlIfX8xn +bacuUKLBl422+JX1sLrcneC+y9/3OPJH9aaakpUqYllQC6KxNedlsmGy6pJxaeQp8E+BgQQ8sqVb +1MWoWWd7VRxJq3qdwudzTe/NCcLEVxLbAQ4jeQkHO6Lo/IrPj8BGJJw4J+CDnRugv3gVEOuGTgpa +/d/aLIJ+7sr2KeH6caH3iGicnPCNvg9JkdjqOvn90Ghx2+m1K06Ckm9mH+Dw3EzsytHqunQG+bOE +kJTRX45zGRBdAuVwpcAQ0BB8b8VYSbSwbprafZX1zNoCr7gsfXmPvkPx+SgojQlD+Ajda8iLLCSx +jVIHvXiby8posqTdDEx5YMaZ0ZPxMBoH064iwurO8YQJzOAUbn8/ftKChazcqRZOhaBgy/ac18iz +ju3Gm5h1DVXoX+WViwKkrkMpKBGk5hIwAUt1ax5mnXkvpXYvHUC0bcl9eQjs0Wq2XSqypWa9a4X0 +dFbD9ed1Uigspf9mR6XU/v6eVL9lfgHWMI+lNpyiUBzuOIABSMbHdPTGrMNASRZhdCyvjG817XsY +AFs2PJxQDcqSMxDxJklt33UkN4Ii1+iW/RVLApY+B3KVfqs9TC7XyvDf4Fg/LS8EmjijAQIDAQAB +o0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUBpOjCl4oaTeq +YR3r6/wtbyPk86AwDQYJKoZIhvcNAQEMBQADggIBAJaAcgkGfpzMkwQWu6A6jZJOtxEaCnFxEM0E +rX+lRVAQZk5KQaID2RFPeje5S+LGjzJmdSX7684/AykmjbgWHfYfM25I5uj4V7Ibed87hwriZLoA +ymzvftAj63iP/2SbNDefNWWipAA9EiOWWF3KY4fGoweITedpdopTzfFP7ELyk+OZpDc8h7hi2/Ds +Hzc/N19DzFGdtfCXwreFamgLRB7lUe6TzktuhsHSDCRZNhqfLJGP4xjblJUK7ZGqDpncllPjYYPG +FrojutzdfhrGe0K22VoF3Jpf1d+42kd92jjbrDnVHmtsKheMYc2xbXIBw8MgAGJoFjHVdqqGuw6q +nsb58Nn4DSEC5MUoFlkRudlpcyqSeLiSV5sI8jrlL5WwWLdrIBRtFO8KvH7YVdiI2i/6GaX7i+B/ +OfVyK4XELKzvGUWSTLNhB9xNH27SgRNcmvMSZ4PPmz+Ln52kuaiWA3rF7iDeM9ovnhp6dB7h7sxa +OgTdsxoEqBRjrLdHEoOabPXm6RUVkRqEGQ6UROcSjiVbgGcZ3GOTEAtlLor6CZpO2oYofaphNdgO +pygau1LgePhsumywbrmHXumZNTfxPWQrqaA0k89jL9WB365jJ6UeTo3cKXhZ+PmhIIynJkBugnLN +eLLIjzwec+fBH7/PzqUqm9tEZDKgu39cJRNItX+S +-----END CERTIFICATE----- + +SecureSign Root CA15 +==================== +-----BEGIN CERTIFICATE----- +MIICIzCCAamgAwIBAgIUFhXHw9hJp75pDIqI7fBw+d23PocwCgYIKoZIzj0EAwMwUTELMAkGA1UE +BhMCSlAxIzAhBgNVBAoTGkN5YmVydHJ1c3QgSmFwYW4gQ28uLCBMdGQuMR0wGwYDVQQDExRTZWN1 +cmVTaWduIFJvb3QgQ0ExNTAeFw0yMDA0MDgwODMyNTZaFw00NTA0MDgwODMyNTZaMFExCzAJBgNV +BAYTAkpQMSMwIQYDVQQKExpDeWJlcnRydXN0IEphcGFuIENvLiwgTHRkLjEdMBsGA1UEAxMUU2Vj +dXJlU2lnbiBSb290IENBMTUwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQLUHSNZDKZmbPSYAi4Io5G +dCx4wCtELW1fHcmuS1Iggz24FG1Th2CeX2yF2wYUleDHKP+dX+Sq8bOLbe1PL0vJSpSRZHX+AezB +2Ot6lHhWGENfa4HL9rzatAy2KZMIaY+jQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQD +AgEGMB0GA1UdDgQWBBTrQciu/NWeUUj1vYv0hyCTQSvT9DAKBggqhkjOPQQDAwNoADBlAjEA2S6J +fl5OpBEHvVnCB96rMjhTKkZEBhd6zlHp4P9mLQlO4E/0BdGF9jVg3PVys0Z9AjBEmEYagoUeYWmJ +SwdLZrWeqrqgHkHZAXQ6bkU6iYAZezKYVWOr62Nuk22rGwlgMU4= +-----END CERTIFICATE----- + +D-TRUST BR Root CA 2 2023 +========================= +-----BEGIN CERTIFICATE----- +MIIFqTCCA5GgAwIBAgIQczswBEhb2U14LnNLyaHcZjANBgkqhkiG9w0BAQ0FADBIMQswCQYDVQQG +EwJERTEVMBMGA1UEChMMRC1UcnVzdCBHbWJIMSIwIAYDVQQDExlELVRSVVNUIEJSIFJvb3QgQ0Eg +MiAyMDIzMB4XDTIzMDUwOTA4NTYzMVoXDTM4MDUwOTA4NTYzMFowSDELMAkGA1UEBhMCREUxFTAT +BgNVBAoTDEQtVHJ1c3QgR21iSDEiMCAGA1UEAxMZRC1UUlVTVCBCUiBSb290IENBIDIgMjAyMzCC +AiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK7/CVmRgApKaOYkP7in5Mg6CjoWzckjYaCT +cfKri3OPoGdlYNJUa2NRb0kz4HIHE304zQaSBylSa053bATTlfrdTIzZXcFhfUvnKLNEgXtRr90z +sWh81k5M/itoucpmacTsXld/9w3HnDY25QdgrMBM6ghs7wZ8T1soegj8k12b9py0i4a6Ibn08OhZ +WiihNIQaJZG2tY/vsvmA+vk9PBFy2OMvhnbFeSzBqZCTRphny4NqoFAjpzv2gTng7fC5v2Xx2Mt6 +++9zA84A9H3X4F07ZrjcjrqDy4d2A/wl2ecjbwb9Z/Pg/4S8R7+1FhhGaRTMBffb00msa8yr5LUL +QyReS2tNZ9/WtT5PeB+UcSTq3nD88ZP+npNa5JRal1QMNXtfbO4AHyTsA7oC9Xb0n9Sa7YUsOCIv +x9gvdhFP/Wxc6PWOJ4d/GUohR5AdeY0cW/jPSoXk7bNbjb7EZChdQcRurDhaTyN0dKkSw/bSuREV +MweR2Ds3OmMwBtHFIjYoYiMQ4EbMl6zWK11kJNXuHA7e+whadSr2Y23OC0K+0bpwHJwh5Q8xaRfX +/Aq03u2AnMuStIv13lmiWAmlY0cL4UEyNEHZmrHZqLAbWt4NDfTisl01gLmB1IRpkQLLddCNxbU9 +CZEJjxShFHR5PtbJFR2kWVki3PaKRT08EtY+XTIvAgMBAAGjgY4wgYswDwYDVR0TAQH/BAUwAwEB +/zAdBgNVHQ4EFgQUZ5Dw1t61GNVGKX5cq/ieCLxklRAwDgYDVR0PAQH/BAQDAgEGMEkGA1UdHwRC +MEAwPqA8oDqGOGh0dHA6Ly9jcmwuZC10cnVzdC5uZXQvY3JsL2QtdHJ1c3RfYnJfcm9vdF9jYV8y +XzIwMjMuY3JsMA0GCSqGSIb3DQEBDQUAA4ICAQA097N3U9swFrktpSHxQCF16+tIFoE9c+CeJyrr +d6kTpGoKWloUMz1oH4Guaf2Mn2VsNELZLdB/eBaxOqwjMa1ef67nriv6uvw8l5VAk1/DLQOj7aRv +U9f6QA4w9QAgLABMjDu0ox+2v5Eyq6+SmNMW5tTRVFxDWy6u71cqqLRvpO8NVhTaIasgdp4D/Ca4 +nj8+AybmTNudX0KEPUUDAxxZiMrcLmEkWqTqJwtzEr5SswrPMhfiHocaFpVIbVrg0M8JkiZmkdij +YQ6qgYF/6FKC0ULn4B0Y+qSFNueG4A3rvNTJ1jxD8V1Jbn6Bm2m1iWKPiFLY1/4nwSPFyysCu7Ff +/vtDhQNGvl3GyiEm/9cCnnRK3PgTFbGBVzbLZVzRHTF36SXDw7IyN9XxmAnkbWOACKsGkoHU6XCP +pz+y7YaMgmo1yEJagtFSGkUPFaUA8JR7ZSdXOUPPfH/mvTWze/EZTN46ls/pdu4D58JDUjxqgejB +WoC9EV2Ta/vH5mQ/u2kc6d0li690yVRAysuTEwrt+2aSEcr1wPrYg1UDfNPFIkZ1cGt5SAYqgpq/ +5usWDiJFAbzdNpQ0qTUmiteXue4Icr80knCDgKs4qllo3UCkGJCy89UDyibK79XH4I9TjvAA46jt +n/mtd+ArY0+ew+43u3gJhJ65bvspmZDogNOfJA== +-----END CERTIFICATE----- + +TrustAsia TLS ECC Root CA +========================= +-----BEGIN CERTIFICATE----- +MIICMTCCAbegAwIBAgIUNnThTXxlE8msg1UloD5Sfi9QaMcwCgYIKoZIzj0EAwMwWDELMAkGA1UE +BhMCQ04xJTAjBgNVBAoTHFRydXN0QXNpYSBUZWNobm9sb2dpZXMsIEluYy4xIjAgBgNVBAMTGVRy +dXN0QXNpYSBUTFMgRUNDIFJvb3QgQ0EwHhcNMjQwNTE1MDU0MTU2WhcNNDQwNTE1MDU0MTU1WjBY +MQswCQYDVQQGEwJDTjElMCMGA1UEChMcVHJ1c3RBc2lhIFRlY2hub2xvZ2llcywgSW5jLjEiMCAG +A1UEAxMZVHJ1c3RBc2lhIFRMUyBFQ0MgUm9vdCBDQTB2MBAGByqGSM49AgEGBSuBBAAiA2IABLh/ +pVs/AT598IhtrimY4ZtcU5nb9wj/1WrgjstEpvDBjL1P1M7UiFPoXlfXTr4sP/MSpwDpguMqWzJ8 +S5sUKZ74LYO1644xST0mYekdcouJtgq7nDM1D9rs3qlKH8kzsaNCMEAwDwYDVR0TAQH/BAUwAwEB +/zAdBgNVHQ4EFgQULIVTu7FDzTLqnqOH/qKYqKaT6RAwDgYDVR0PAQH/BAQDAgEGMAoGCCqGSM49 +BAMDA2gAMGUCMFRH18MtYYZI9HlaVQ01L18N9mdsd0AaRuf4aFtOJx24mH1/k78ITcTaRTChD15K +eAIxAKORh/IRM4PDwYqROkwrULG9IpRdNYlzg8WbGf60oenUoWa2AaU2+dhoYSi3dOGiMQ== +-----END CERTIFICATE----- + +TrustAsia TLS RSA Root CA +========================= +-----BEGIN CERTIFICATE----- +MIIFgDCCA2igAwIBAgIUHBjYz+VTPyI1RlNUJDxsR9FcSpwwDQYJKoZIhvcNAQEMBQAwWDELMAkG +A1UEBhMCQ04xJTAjBgNVBAoTHFRydXN0QXNpYSBUZWNobm9sb2dpZXMsIEluYy4xIjAgBgNVBAMT +GVRydXN0QXNpYSBUTFMgUlNBIFJvb3QgQ0EwHhcNMjQwNTE1MDU0MTU3WhcNNDQwNTE1MDU0MTU2 +WjBYMQswCQYDVQQGEwJDTjElMCMGA1UEChMcVHJ1c3RBc2lhIFRlY2hub2xvZ2llcywgSW5jLjEi +MCAGA1UEAxMZVHJ1c3RBc2lhIFRMUyBSU0EgUm9vdCBDQTCCAiIwDQYJKoZIhvcNAQEBBQADggIP +ADCCAgoCggIBAMMWuBtqpERz5dZO9LnPWwvB0ZqB9WOwj0PBuwhaGnrhB3YmH49pVr7+NmDQDIPN +lOrnxS1cLwUWAp4KqC/lYCZUlviYQB2srp10Zy9U+5RjmOMmSoPGlbYJQ1DNDX3eRA5gEk9bNb2/ +mThtfWza4mhzH/kxpRkQcwUqwzIZheo0qt1CHjCNP561HmHVb70AcnKtEj+qpklz8oYVlQwQX1Fk +zv93uMltrOXVmPGZLmzjyUT5tUMnCE32ft5EebuyjBza00tsLtbDeLdM1aTk2tyKjg7/D8OmYCYo +zza/+lcK7Fs/6TAWe8TbxNRkoDD75f0dcZLdKY9BWN4ArTr9PXwaqLEX8E40eFgl1oUh63kd0Nyr +z2I8sMeXi9bQn9P+PN7F4/w6g3CEIR0JwqH8uyghZVNgepBtljhb//HXeltt08lwSUq6HTrQUNoy +IBnkiz/r1RYmNzz7dZ6wB3C4FGB33PYPXFIKvF1tjVEK2sUYyJtt3LCDs3+jTnhMmCWr8n4uIF6C +FabW2I+s5c0yhsj55NqJ4js+k8UTav/H9xj8Z7XvGCxUq0DTbE3txci3OE9kxJRMT6DNrqXGJyV1 +J23G2pyOsAWZ1SgRxSHUuPzHlqtKZFlhaxP8S8ySpg+kUb8OWJDZgoM5pl+z+m6Ss80zDoWo8SnT +q1mt1tve1CuBAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFLgHkXlcBvRG/XtZ +ylomkadFK/hTMA4GA1UdDwEB/wQEAwIBBjANBgkqhkiG9w0BAQwFAAOCAgEAIZtqBSBdGBanEqT3 +Rz/NyjuujsCCztxIJXgXbODgcMTWltnZ9r96nBO7U5WS/8+S4PPFJzVXqDuiGev4iqME3mmL5Dw8 +veWv0BIb5Ylrc5tvJQJLkIKvQMKtuppgJFqBTQUYo+IzeXoLH5Pt7DlK9RME7I10nYEKqG/odv6L +TytpEoYKNDbdgptvT+Bz3Ul/KD7JO6NXBNiT2Twp2xIQaOHEibgGIOcberyxk2GaGUARtWqFVwHx +tlotJnMnlvm5P1vQiJ3koP26TpUJg3933FEFlJ0gcXax7PqJtZwuhfG5WyRasQmr2soaB82G39tp +27RIGAAtvKLEiUUjpQ7hRGU+isFqMB3iYPg6qocJQrmBktwliJiJ8Xw18WLK7nn4GS/+X/jbh87q +qA8MpugLoDzga5SYnH+tBuYc6kIQX+ImFTw3OffXvO645e8D7r0i+yiGNFjEWn9hongPXvPKnbwb +PKfILfanIhHKA9jnZwqKDss1jjQ52MjqjZ9k4DewbNfFj8GQYSbbJIweSsCI3zWQzj8C9GRh3sfI +B5XeMhg6j6JCQCTl1jNdfK7vsU1P1FeQNWrcrgSXSYk0ly4wBOeY99sLAZDBHwo/+ML+TvrbmnNz +FrwFuHnYWa8G5z9nODmxfKuU4CkUpijy323imttUQ/hHWKNddBWcwauwxzQ= +-----END CERTIFICATE----- + +D-TRUST EV Root CA 2 2023 +========================= +-----BEGIN CERTIFICATE----- +MIIFqTCCA5GgAwIBAgIQaSYJfoBLTKCnjHhiU19abzANBgkqhkiG9w0BAQ0FADBIMQswCQYDVQQG +EwJERTEVMBMGA1UEChMMRC1UcnVzdCBHbWJIMSIwIAYDVQQDExlELVRSVVNUIEVWIFJvb3QgQ0Eg +MiAyMDIzMB4XDTIzMDUwOTA5MTAzM1oXDTM4MDUwOTA5MTAzMlowSDELMAkGA1UEBhMCREUxFTAT +BgNVBAoTDEQtVHJ1c3QgR21iSDEiMCAGA1UEAxMZRC1UUlVTVCBFViBSb290IENBIDIgMjAyMzCC +AiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANiOo4mAC7JXUtypU0w3uX9jFxPvp1sjW2l1 +sJkKF8GLxNuo4MwxusLyzV3pt/gdr2rElYfXR8mV2IIEUD2BCP/kPbOx1sWy/YgJ25yE7CUXFId/ +MHibaljJtnMoPDT3mfd/06b4HEV8rSyMlD/YZxBTfiLNTiVR8CUkNRFeEMbsh2aJgWi6zCudR3Mf +vc2RpHJqnKIbGKBv7FD0fUDCqDDPvXPIEysQEx6Lmqg6lHPTGGkKSv/BAQP/eX+1SH977ugpbzZM +lWGG2Pmic4ruri+W7mjNPU0oQvlFKzIbRlUWaqZLKfm7lVa/Rh3sHZMdwGWyH6FDrlaeoLGPaxK3 +YG14C8qKXO0elg6DpkiVjTujIcSuWMYAsoS0I6SWhjW42J7YrDRJmGOVxcttSEfi8i4YHtAxq910 +7PncjLgcjmgjutDzUNzPZY9zOjLHfP7KgiJPvo5iR2blzYfi6NUPGJ/lBHJLRjwQ8kTCZFZxTnXo +nMkmdMV9WdEKWw9t/p51HBjGGjp82A0EzM23RWV6sY+4roRIPrN6TagD4uJ+ARZZaBhDM7DS3LAa +QzXupdqpRlyuhoFBAUp0JuyfBr/CBTdkdXgpaP3F9ev+R/nkhbDhezGdpn9yo7nELC7MmVcOIQxF +AZRl62UJxmMiCzNJkkg8/M3OsD6Onov4/knFNXJHAgMBAAGjgY4wgYswDwYDVR0TAQH/BAUwAwEB +/zAdBgNVHQ4EFgQUqvyREBuHkV8Wub9PS5FeAByxMoAwDgYDVR0PAQH/BAQDAgEGMEkGA1UdHwRC +MEAwPqA8oDqGOGh0dHA6Ly9jcmwuZC10cnVzdC5uZXQvY3JsL2QtdHJ1c3RfZXZfcm9vdF9jYV8y +XzIwMjMuY3JsMA0GCSqGSIb3DQEBDQUAA4ICAQCTy6UfmRHsmg1fLBWTxj++EI14QvBukEdHjqOS +Mo1wj/Zbjb6JzkcBahsgIIlbyIIQbODnmaprxiqgYzWRaoUlrRc4pZt+UPJ26oUFKidBK7GB0aL2 +QHWpDsvxVUjY7NHss+jOFKE17MJeNRqrphYBBo7q3C+jisosketSjl8MmxfPy3MHGcRqwnNU73xD +UmPBEcrCRbH0O1P1aa4846XerOhUt7KR/aypH/KH5BfGSah82ApB9PI+53c0BFLd6IHyTS9URZ0V +4U/M5d40VxDJI3IXcI1QcB9WbMy5/zpaT2N6w25lBx2Eof+pDGOJbbJAiDnXH3dotfyc1dZnaVuo +dNv8ifYbMvekJKZ2t0dT741Jj6m2g1qllpBFYfXeA08mD6iL8AOWsKwV0HFaanuU5nCT2vFp4LJi +TZ6P/4mdm13NRemUAiKN4DV/6PEEeXFsVIP4M7kFMhtYVRFP0OUnR3Hs7dpn1mKmS00PaaLJvOwi +S5THaJQXfuKOKD62xur1NGyfN4gHONuGcfrNlUhDbqNPgofXNJhuS5N5YHVpD/Aa1VP6IQzCP+k/ +HxiMkl14p3ZnGbuy6n/pcAlWVqOwDAstNl7F6cTVg8uGF5csbBNvh1qvSaYd2804BC5f4ko1Di1L ++KIkBI3Y4WNeApI02phhXBxvWHZks/wCuPWdCg== +-----END CERTIFICATE----- + +SwissSign RSA TLS Root CA 2022 - 1 +================================== +-----BEGIN CERTIFICATE----- +MIIFkzCCA3ugAwIBAgIUQ/oMX04bgBhE79G0TzUfRPSA7cswDQYJKoZIhvcNAQELBQAwUTELMAkG +A1UEBhMCQ0gxFTATBgNVBAoTDFN3aXNzU2lnbiBBRzErMCkGA1UEAxMiU3dpc3NTaWduIFJTQSBU +TFMgUm9vdCBDQSAyMDIyIC0gMTAeFw0yMjA2MDgxMTA4MjJaFw00NzA2MDgxMTA4MjJaMFExCzAJ +BgNVBAYTAkNIMRUwEwYDVQQKEwxTd2lzc1NpZ24gQUcxKzApBgNVBAMTIlN3aXNzU2lnbiBSU0Eg +VExTIFJvb3QgQ0EgMjAyMiAtIDEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDLKmji +C8NXvDVjvHClO/OMPE5Xlm7DTjak9gLKHqquuN6orx122ro10JFwB9+zBvKK8i5VUXu7LCTLf5Im +gKO0lPaCoaTo+nUdWfMHamFk4saMla+ju45vVs9xzF6BYQ1t8qsCLqSX5XH8irCRIFucdFJtrhUn +WXjyCcplDn/L9Ovn3KlMd/YrFgSVrpxxpT8q2kFC5zyEEPThPYxr4iuRR1VPuFa+Rd4iUU1OKNlf +GUEGjw5NBuBwQCMBauTLE5tzrE0USJIt/m2n+IdreXXhvhCxqohAWVTXz8TQm0SzOGlkjIHRI36q +OTw7D59Ke4LKa2/KIj4x0LDQKhySio/YGZxH5D4MucLNvkEM+KRHBdvBFzA4OmnczcNpI/2aDwLO +EGrOyvi5KaM2iYauC8BPY7kGWUleDsFpswrzd34unYyzJ5jSmY0lpx+Gs6ZUcDj8fV3oT4MM0ZPl +EuRU2j7yrTrePjxF8CgPBrnh25d7mUWe3f6VWQQvdT/TromZhqwUtKiE+shdOxtYk8EXlFXIC+OC +eYSf8wCENO7cMdWP8vpPlkwGqnj73mSiI80fPsWMvDdUDrtaclXvyFu1cvh43zcgTFeRc5JzrBh3 +Q4IgaezprClG5QtO+DdziZaKHG29777YtvTKwP1H8K4LWCDFyB02rpeNUIMmJCn3nTsPBQIDAQAB +o2MwYTAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAfBgNVHSMEGDAWgBRvjmKLk0Ow +4UD2p8P98Q+4DxU4pTAdBgNVHQ4EFgQUb45ii5NDsOFA9qfD/fEPuA8VOKUwDQYJKoZIhvcNAQEL +BQADggIBAKwsKUF9+lz1GpUYvyypiqkkVHX1uECry6gkUSsYP2OprphWKwVDIqO310aewCoSPY6W +lkDfDDOLazeROpW7OSltwAJsipQLBwJNGD77+3v1dj2b9l4wBlgzHqp41eZUBDqyggmNzhYzWUUo +8aWjlw5DI/0LIICQ/+Mmz7hkkeUFjxOgdg3XNwwQiJb0Pr6VvfHDffCjw3lHC1ySFWPtUnWK50Zp +y1FVCypM9fJkT6lc/2cyjlUtMoIcgC9qkfjLvH4YoiaoLqNTKIftV+Vlek4ASltOU8liNr3Cjlvr +zG4ngRhZi0Rjn9UMZfQpZX+RLOV/fuiJz48gy20HQhFRJjKKLjpHE7iNvUcNCfAWpO2Whi4Z2L6M +OuhFLhG6rlrnub+xzI/goP+4s9GFe3lmozm1O2bYQL7Pt2eLSMkZJVX8vY3PXtpOpvJpzv1/THfQ +wUY1mFwjmwJFQ5Ra3bxHrSL+ul4vkSkphnsh3m5kt8sNjzdbowhq6/TdAo9QAwKxuDdollDruF/U +KIqlIgyKhPBZLtU30WHlQnNYKoH3dtvi4k0NX/a3vgW0rk4N3hY9A4GzJl5LuEsAz/+MF7psYC0n +hzck5npgL7XTgwSqT0N1osGDsieYK7EOgLrAhV5Cud+xYJHT6xh+cHiudoO+cVrQkOPKwRYlZ0rw +tnu64ZzZ +-----END CERTIFICATE----- + +OISTE Server Root ECC G1 +======================== +-----BEGIN CERTIFICATE----- +MIICNTCCAbqgAwIBAgIQI/nD1jWvjyhLH/BU6n6XnTAKBggqhkjOPQQDAzBLMQswCQYDVQQGEwJD +SDEZMBcGA1UECgwQT0lTVEUgRm91bmRhdGlvbjEhMB8GA1UEAwwYT0lTVEUgU2VydmVyIFJvb3Qg +RUNDIEcxMB4XDTIzMDUzMTE0NDIyOFoXDTQ4MDUyNDE0NDIyN1owSzELMAkGA1UEBhMCQ0gxGTAX +BgNVBAoMEE9JU1RFIEZvdW5kYXRpb24xITAfBgNVBAMMGE9JU1RFIFNlcnZlciBSb290IEVDQyBH +MTB2MBAGByqGSM49AgEGBSuBBAAiA2IABBcv+hK8rBjzCvRE1nZCnrPoH7d5qVi2+GXROiFPqOuj +vqQycvO2Ackr/XeFblPdreqqLiWStukhEaivtUwL85Zgmjvn6hp4LrQ95SjeHIC6XG4N2xml4z+c +KrhAS93mT6NjMGEwDwYDVR0TAQH/BAUwAwEB/zAfBgNVHSMEGDAWgBQ3TYhlz/w9itWj8UnATgwQ +b0K0nDAdBgNVHQ4EFgQUN02IZc/8PYrVo/FJwE4MEG9CtJwwDgYDVR0PAQH/BAQDAgGGMAoGCCqG +SM49BAMDA2kAMGYCMQCpKjAd0MKfkFFRQD6VVCHNFmb3U2wIFjnQEnx/Yxvf4zgAOdktUyBFCxxg +ZzFDJe0CMQCSia7pXGKDYmH5LVerVrkR3SW+ak5KGoJr3M/TvEqzPNcum9v4KGm8ay3sMaE641c= +-----END CERTIFICATE----- + +OISTE Server Root RSA G1 +======================== +-----BEGIN CERTIFICATE----- +MIIFgzCCA2ugAwIBAgIQVaXZZ5Qoxu0M+ifdWwFNGDANBgkqhkiG9w0BAQwFADBLMQswCQYDVQQG +EwJDSDEZMBcGA1UECgwQT0lTVEUgRm91bmRhdGlvbjEhMB8GA1UEAwwYT0lTVEUgU2VydmVyIFJv +b3QgUlNBIEcxMB4XDTIzMDUzMTE0MzcxNloXDTQ4MDUyNDE0MzcxNVowSzELMAkGA1UEBhMCQ0gx +GTAXBgNVBAoMEE9JU1RFIEZvdW5kYXRpb24xITAfBgNVBAMMGE9JU1RFIFNlcnZlciBSb290IFJT +QSBHMTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAKqu9KuCz/vlNwvn1ZatkOhLKdxV +YOPMvLO8LZK55KN68YG0nnJyQ98/qwsmtO57Gmn7KNByXEptaZnwYx4M0rH/1ow00O7brEi56rAU +jtgHqSSY3ekJvqgiG1k50SeH3BzN+Puz6+mTeO0Pzjd8JnduodgsIUzkik/HEzxux9UTl7Ko2yRp +g1bTacuCErudG/L4NPKYKyqOBGf244ehHa1uzjZ0Dl4zO8vbUZeUapU8zhhabkvG/AePLhq5Svdk +NCncpo1Q4Y2LS+VIG24ugBA/5J8bZT8RtOpXaZ+0AOuFJJkk9SGdl6r7NH8CaxWQrbueWhl/pIzY ++m0o/DjH40ytas7ZTpOSjswMZ78LS5bOZmdTaMsXEY5Z96ycG7mOaES3GK/m5Q9l3JUJsJMStR8+ +lKXHiHUhsd4JJCpM4rzsTGdHwimIuQq6+cF0zowYJmXa92/GjHtoXAvuY8BeS/FOzJ8vD+HomnqT +8eDI278n5mUpezbgMxVz8p1rhAhoKzYHKyfMeNhqhw5HdPSqoBNdZH702xSu+zrkL8Fl47l6QGzw +Brd7KJvX4V84c5Ss2XCTLdyEr0YconosP4EmQufU2MVshGYRi3drVByjtdgQ8K4p92cIiBdcuJd5 +z+orKu5YM+Vt6SmqZQENghPsJQtdLEByFSnTkCz3GkPVavBpAgMBAAGjYzBhMA8GA1UdEwEB/wQF +MAMBAf8wHwYDVR0jBBgwFoAU8snBDw1jALvsRQ5KH7WxszbNDo0wHQYDVR0OBBYEFPLJwQ8NYwC7 +7EUOSh+1sbM2zQ6NMA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQwFAAOCAgEANGd5sjrG5T33 +I3K5Ce+SrScfoE4KsvXaFwyihdJ+klH9FWXXXGtkFu6KRcoMQzZENdl//nk6HOjG5D1rd9QhEOP2 +8yBOqb6J8xycqd+8MDoX0TJD0KqKchxRKEzdNsjkLWd9kYccnbz8qyiWXmFcuCIzGEgWUOrKL+ml +Sdx/PKQZvDatkuK59EvV6wit53j+F8Bdh3foZ3dPAGav9LEDOr4SfEE15fSmG0eLy3n31r8Xbk5l +8PjaV8GUgeV6Vg27Rn9vkf195hfkgSe7BYhW3SCl95gtkRlpMV+bMPKZrXJAlszYd2abtNUOshD+ +FKrDgHGdPY3ofRRsYWSGRqbXVMW215AWRqWFyp464+YTFrYVI8ypKVL9AMb2kI5Wj4kI3Zaq5tNq +qYY19tVFeEJKRvwDyF7YZvZFZSS0vod7VSCd9521Kvy5YhnLbDuv0204bKt7ph6N/Ome/msVuduC +msuY33OhkKCgxeDoAaijFJzIwZqsFVAzje18KotzlUBDJvyBpCpfOZC3J8tRd/iWkx7P8nd9H0aT +olkelUTFLXVksNb54Dxp6gS1HAviRkRNQzuXSXERvSS2wq1yVAb+axj5d9spLFKebXd7Yv0PTY6Y +MjAwcRLWJTXjn/hvnLXrahut6hDTlhZyBiElxky8j3C7DOReIoMt0r7+hVu05L0= +-----END CERTIFICATE----- + +e-Szigno TLS Root CA 2023 +========================= +-----BEGIN CERTIFICATE----- +MIICzzCCAjGgAwIBAgINAOhvGHvWOWuYSkmYCjAKBggqhkjOPQQDBDB1MQswCQYDVQQGEwJIVTER +MA8GA1UEBwwIQnVkYXBlc3QxFjAUBgNVBAoMDU1pY3Jvc2VjIEx0ZC4xFzAVBgNVBGEMDlZBVEhV +LTIzNTg0NDk3MSIwIAYDVQQDDBllLVN6aWdubyBUTFMgUm9vdCBDQSAyMDIzMB4XDTIzMDcxNzE0 +MDAwMFoXDTM4MDcxNzE0MDAwMFowdTELMAkGA1UEBhMCSFUxETAPBgNVBAcMCEJ1ZGFwZXN0MRYw +FAYDVQQKDA1NaWNyb3NlYyBMdGQuMRcwFQYDVQRhDA5WQVRIVS0yMzU4NDQ5NzEiMCAGA1UEAwwZ +ZS1Temlnbm8gVExTIFJvb3QgQ0EgMjAyMzCBmzAQBgcqhkjOPQIBBgUrgQQAIwOBhgAEAGgP36J8 +PKp0iGEKjcJMpQEiFNT3YHdCnAo4YKGMZz6zY+n6kbCLS+Y53wLCMAFSAL/fjO1ZrTJlqwlZULUZ +wmgcAOAFX9pQJhzDrAQixTpN7+lXWDajwRlTEArRzT/vSzUaQ49CE0y5LBqcvjC2xN7cS53kpDzL +Ltmt3999Cd8ukv+ho2MwYTAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4E +FgQUWYQCYlpGePVd3I8KECgj3NXW+0UwHwYDVR0jBBgwFoAUWYQCYlpGePVd3I8KECgj3NXW+0Uw +CgYIKoZIzj0EAwQDgYsAMIGHAkIBLdqu9S54tma4n7Zwf2Z0z+yOfP7AAXmazlIC58PRDHpty7Ve +7hekm9sEdu4pKeiv+62sUvTXK9Z3hBC9xdIoaDQCQTV2WnXzkoYI9bIeCvZlC9p2x1L/Cx6AcCIw +wzPbGO2E14vs7dOoY4G1VnxHx1YwlGhza9IuqbnZLBwpvQy6uWWL +-----END CERTIFICATE----- + +-----BEGIN CERTIFICATE----- +MIIDdTCCAl2gAwIBAgILBAAAAAABFUtaw5QwDQYJKoZIhvcNAQEFBQAwVzELMAkG +A1UEBhMCQkUxGTAXBgNVBAoTEEdsb2JhbFNpZ24gbnYtc2ExEDAOBgNVBAsTB1Jv +b3QgQ0ExGzAZBgNVBAMTEkdsb2JhbFNpZ24gUm9vdCBDQTAeFw05ODA5MDExMjAw +MDBaFw0yODAxMjgxMjAwMDBaMFcxCzAJBgNVBAYTAkJFMRkwFwYDVQQKExBHbG9i +YWxTaWduIG52LXNhMRAwDgYDVQQLEwdSb290IENBMRswGQYDVQQDExJHbG9iYWxT +aWduIFJvb3QgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDaDuaZ +jc6j40+Kfvvxi4Mla+pIH/EqsLmVEQS98GPR4mdmzxzdzxtIK+6NiY6arymAZavp +xy0Sy6scTHAHoT0KMM0VjU/43dSMUBUc71DuxC73/OlS8pF94G3VNTCOXkNz8kHp +1Wrjsok6Vjk4bwY8iGlbKk3Fp1S4bInMm/k8yuX9ifUSPJJ4ltbcdG6TRGHRjcdG +snUOhugZitVtbNV4FpWi6cgKOOvyJBNPc1STE4U6G7weNLWLBYy5d4ux2x8gkasJ +U26Qzns3dLlwR5EiUWMWea6xrkEmCMgZK9FGqkjWZCrXgzT/LCrBbBlDSgeF59N8 +9iFo7+ryUp9/k5DPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8E +BTADAQH/MB0GA1UdDgQWBBRge2YaRQ2XyolQL30EzTSo//z9SzANBgkqhkiG9w0B +AQUFAAOCAQEA1nPnfE920I2/7LqivjTFKDK1fPxsnCwrvQmeU79rXqoRSLblCKOz +yj1hTdNGCbM+w6DjY1Ub8rrvrTnhQ7k4o+YviiY776BQVvnGCv04zcQLcFGUl5gE +38NflNUVyRRBnMRddWQVDf9VMOyGj/8N7yy5Y0b2qvzfvGn9LhJIZJrglfCm7ymP +AbEVtQwdpf5pLGkkeB6zpxxxYu7KyJesF12KwvhHhm4qxFYxldBniYUr+WymXUad +DKqC5JlR3XC321Y9YeRq4VzW9v493kHMB65jUr9TU/Qr6cf9tveCX4XSQRjbgbME +HMUfpIBvFSDJ3gyICh3WZlXi/EjJKSZp4A== +-----END CERTIFICATE----- diff --git a/certificates/cert_bundle.py b/certificates/cert_bundle.py new file mode 100644 index 00000000..0ba42de3 --- /dev/null +++ b/certificates/cert_bundle.py @@ -0,0 +1,734 @@ +#!/usr/bin/env python3 +# +# OpenShock CA trust store manager. Two subcommands: +# +# generate (default) — download curl's CA bundle, SHA-256-verify it, and write two files: +# cacert-curl.pem (the verbatim verified base) and cacert-merged.pem +# (base + pinned_certs/*.pem + local custom_certs/*.pem). Does NOT +# emit a packed x509_crt_bundle; the firmware build converts and +# packs cacert-merged.pem into flash via ESP-IDF's certificate-bundle step. +# audit — audit every cert already in cacert-merged.pem (base + pinned + custom): +# flag expiry, and for pinned roots probe the live endpoints to +# decide which are still needed. Reports pins no endpoint chains +# through as removable, and raises an alert on a still-needed cert +# that is expired/expiring. Used by CI. +# +# Based on Espressif's original certificate bundle generation utility, extended to +# add SHA-256 verification, deprecation-warning surfacing, atomic writes, pinned/custom +# roots, and the audit subcommand. +# +# Copyright 2018-2019 Espressif Systems (Shanghai) PTE LTD +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +from __future__ import annotations + +import base64 +import datetime +import hashlib +import os +import re +import subprocess +import sys +import tempfile +import urllib.request +import ssl +import warnings +from pathlib import Path + +try: + from cryptography import x509 + from cryptography.hazmat.primitives import serialization + from cryptography.utils import CryptographyDeprecationWarning +except ImportError: + print( + 'cert_bundle.py: cryptography package not installed ' '(pip install cryptography)', + file=sys.stderr, + ) + raise + +URL_PEM = 'https://curl.se/ca/cacert.pem' +URL_SHA256 = 'https://curl.se/ca/cacert.pem.sha256' +MANUAL_HASH_PAGE = 'https://curl.se/docs/caextract.html' + +# The verbatim curl/Mozilla CA extract, saved exactly as downloaded so `sha256sum` of +# this file matches curl's published checksum (manual verification, see README). +BASE_PEM_FILE = 'cacert-curl.pem' + +# The merged trust store packed into firmware (sdkconfig.defaults: +# CONFIG_MBEDTLS_CUSTOM_CERTIFICATE_BUNDLE_PATH): the verified curl base followed by every +# pinned (and any local custom) cert. +MERGED_PEM_FILE = 'cacert-merged.pem' + +# Banner placed at the top of the merged bundle, in place of curl's own header, so it is +# never mistaken for the verbatim curl extract (its sha256 intentionally differs). +MERGED_BANNER = ( + b'##\n' + b'## OpenShock firmware TLS trust store -- MERGED / GENERATED, do not edit by hand.\n' + b'##\n' + b'## Produced by certificates/cert_bundle.py. This is what the firmware packs:\n' + b'## the verbatim curl/Mozilla CA extract (see cacert-curl.pem) + certificates/pinned_certs/.\n' + b'## Its sha256 intentionally differs from curl\'s published hash. To change it, edit\n' + b'## pinned_certs/ and re-run `cert_bundle.py generate` -- never hand-edit this file.\n' + b'##\n' +) + +# Pinned certs (tracked, shipped to every device, expiry-monitored). Roots the +# Mozilla/curl base has retired but our infrastructure still chains through -- e.g. +# GlobalSign Root CA R1, which cross-signs GTS Root R4 in the api.openshock.app chain. +PINNED_CERTS_DIR = 'pinned_certs' +PINNED_CERTS_GLOB = '*.pem' + +# Custom certs (self-hosted/internal PKI). Local-only, git-ignored. +CUSTOM_CERTS_DIR = 'custom_certs' # optional; if missing -> ignored +CUSTOM_CERTS_GLOB = '*.pem' # required extension + +# A pinned cert this close to (or past) expiry is a problem: generation hard-fails on an +# already-expired pin; the `audit` subcommand / the CI workflow raise an alert once a +# still-needed pin is within this window. Override with PINNED_CERT_EXPIRY_WARN_DAYS. +EXPIRY_WARN_DAYS_DEFAULT = 90 + +PEM_BLOCK_RE = re.compile( + br'-----BEGIN CERTIFICATE-----\s*[\s\S]+?\s*-----END CERTIFICATE-----\s*', + re.MULTILINE, +) + + +class InputError(RuntimeError): + pass + + +def critical(msg: str) -> None: + sys.stderr.write('cert_bundle.py: ' + msg + '\n') + + +def download_bytes(url: str, timeout: int = 30) -> bytes: + ctx = ssl.create_default_context() + req = urllib.request.Request( + url, + headers={ + 'User-Agent': 'esp32-gen-crt-bundle/strict', + 'Accept': 'text/plain,*/*', + }, + method='GET', + ) + with urllib.request.urlopen(req, context=ctx, timeout=timeout) as resp: + # urllib responses vary; handle both .status and .getcode() + status = getattr(resp, 'status', None) + if status is None: + try: + status = resp.getcode() + except Exception: + status = 200 + if status != 200: + raise InputError(f'Download failed: HTTP {status} for {url}') + data = resp.read() + if not data: + raise InputError(f'Download failed: empty response from {url}') + return data + + +def parse_sha256_file(content: bytes, *, expect_filename: str | None = None) -> str: + """ + Parse a .sha256 file. + Common formats include: + - ' cacert.pem' + - '' + We accept the first line and extract a leading 64-hex hash. + If expect_filename is provided and a filename appears on the line, + we sanity-check that it matches. + """ + text = content.decode('utf-8', errors='replace').strip() + if not text: + raise InputError('Downloaded sha256 file is empty') + + first = text.splitlines()[0].strip() + m = re.match(r'^([0-9a-fA-F]{64})(?:\s+[* ]?(.+))?$', first) + if not m: + raise InputError(f'Could not parse SHA-256 from sha256 file: {first!r}') + + h = m.group(1).lower() + fn = (m.group(2) or '').strip() + + if expect_filename and fn: + # Some sha256sum formats include full paths; compare basename. + if os.path.basename(fn) != expect_filename: + raise InputError(f'SHA-256 file refers to unexpected filename {fn!r} (expected {expect_filename!r})') + + return h + + +def extract_pem_cert_blocks(pem_bytes: bytes, *, source: str) -> list[bytes]: + blocks = PEM_BLOCK_RE.findall(pem_bytes) + if not blocks: + raise InputError(f'No PEM certificate blocks found in {source}') + return blocks + + +def _load_single_pem_cert_block_from_bytes_strict(data: bytes, *, source: str) -> bytes: + # Hard fail if file contains any private key material. + if b'PRIVATE KEY' in data: + raise InputError(f'Custom cert file contains private key material (PRIVATE KEY): {source}') + + begin = b'-----BEGIN CERTIFICATE-----' + end = b'-----END CERTIFICATE-----' + + begin_idx = data.find(begin) + end_idx = data.find(end) + if begin_idx == -1 or end_idx == -1: + raise InputError(f'Custom cert file must contain exactly 1 certificate: {source}') + + end_idx += len(end) + + # Allow leading label / comment lines before the certificate (curl's bundle prefixes + # each cert with a human-readable name), but nothing may follow it. Private-key material + # is rejected globally above, and the single-block checks below reject extra certs. + if data[end_idx:].strip(b' \t\r\n'): + raise InputError(f'Non-whitespace data after certificate in {source}') + + # Exactly one cert block (no extra PEM blocks) + if data.count(begin) != 1 or data.count(end) != 1: + raise InputError(f'Custom cert file must contain exactly 1 certificate: {source}') + tmp = data.replace(begin, b'') + if b'-----BEGIN ' in tmp: + raise InputError(f'Custom cert file contains additional PEM blocks besides the certificate: {source}') + + # Validate base64 payload decodes cleanly + pem = data[begin_idx:end_idx] + lines = pem.splitlines() + if not lines or lines[0].strip() != begin or lines[-1].strip() != end: + raise InputError(f'Malformed CERTIFICATE PEM boundaries in {source}') + + b64_lines = [ln.strip() for ln in lines[1:-1] if ln.strip()] + if not b64_lines: + raise InputError(f'Empty certificate payload in {source}') + + b64_blob = b''.join(b64_lines) + try: + der = base64.b64decode(b64_blob, validate=True) + except Exception as e: + raise InputError(f'Invalid base64 payload in certificate: {source}') from e + if not der or len(der) < 64: + raise InputError(f'Certificate DER payload too small / invalid: {source}') + + # Canonicalize output PEM (stable formatting) + b64_canon = base64.b64encode(der) + out_lines = [b64_canon[i : i + 64] for i in range(0, len(b64_canon), 64)] + return b'-----BEGIN CERTIFICATE-----\n' + b'\n'.join(out_lines) + b'\n-----END CERTIFICATE-----\n' + + +def _load_single_pem_cert_block_from_file(path: Path) -> bytes: + try: + data = path.read_bytes() + except Exception as e: + raise InputError(f'Failed to read custom cert file: {path}') from e + + return _load_single_pem_cert_block_from_bytes_strict(data, source=f'custom cert file: {path}') + + +def load_dir_cert_blocks(dirname: str, glob: str) -> tuple[list[bytes], list[Path]]: + """ + Load one-cert-per-file PEM blocks from a directory. + + Returns: (blocks, paths) + + Behavior: + - If the directory does not exist -> return ([], []) + - Only files matching `glob` are considered + - If the directory exists but contains no matches -> return ([], []) + - Each file must contain exactly one certificate + """ + d = Path(dirname) + if not d.exists(): + return ([], []) + + if not d.is_dir(): + raise InputError(f'{dirname} exists but is not a directory') + + paths = sorted(d.glob(glob)) + if not paths: + return ([], []) + + critical(f'Loading certificates from: {dirname}/ ({len(paths)} file(s))') + + blocks: list[bytes] = [] + for p in paths: + if p.suffix.lower() != '.pem': + # Should not happen due to glob, but keep strict. + raise InputError(f'Cert file must have .pem extension: {p}') + blocks.append(_load_single_pem_cert_block_from_file(p)) + + return (blocks, paths) + + +def load_pinned_cert_blocks() -> tuple[list[bytes], list[Path]]: + """Load tracked, shipped pinned CA certs from pinned_certs/*.pem.""" + return load_dir_cert_blocks(PINNED_CERTS_DIR, PINNED_CERTS_GLOB) + + +def load_custom_cert_blocks() -> tuple[list[bytes], list[Path]]: + """Load local-only custom CA certs from custom_certs/*.pem.""" + return load_dir_cert_blocks(CUSTOM_CERTS_DIR, CUSTOM_CERTS_GLOB) + + +def _cert_not_after_utc(cert: x509.Certificate) -> datetime.datetime: + # cryptography >= 42 exposes tz-aware not_valid_after_utc; older exposes naive UTC. + try: + return cert.not_valid_after_utc + except AttributeError: + return cert.not_valid_after.replace(tzinfo=datetime.timezone.utc) + + +def check_certs_expiry( + certs: list[x509.Certificate], + origins: list[str], + warn_days: int, +) -> tuple[list[str], list[str]]: + """ + Classify certs by expiry against `warn_days`. + + Returns (expired, expiring) as human-readable descriptions: + - expired: notAfter is in the past + - expiring: notAfter is within `warn_days` from now (but not yet expired) + Emits a diagnostic line for each problem cert. + """ + now = datetime.datetime.now(datetime.timezone.utc) + expired: list[str] = [] + expiring: list[str] = [] + + for idx, cert in enumerate(certs): + origin = origins[idx] if idx < len(origins) else '' + not_after = _cert_not_after_utc(cert) + days_left = (not_after - now).days + desc = f'{cert.subject.rfc4514_string()} (origin: {origin}; notAfter: {not_after:%Y-%m-%d}; {days_left}d left)' + + if days_left < 0: + critical(f'EXPIRED certificate: {desc}') + expired.append(desc) + elif days_left <= warn_days: + critical(f'WARNING: certificate expires within {warn_days}d: {desc}') + expiring.append(desc) + + return (expired, expiring) + + +def _expiry_warn_days() -> int: + raw = os.environ.get('PINNED_CERT_EXPIRY_WARN_DAYS') + if not raw: + return EXPIRY_WARN_DAYS_DEFAULT + try: + val = int(raw) + except ValueError: + raise InputError(f'PINNED_CERT_EXPIRY_WARN_DAYS must be an integer, got {raw!r}') + if val < 0: + raise InputError('PINNED_CERT_EXPIRY_WARN_DAYS must be >= 0') + return val + + +def _reject_non_ca(certs: list[x509.Certificate], origins: list[str], *, kind: str) -> None: + non_ca = [(c, o) for (c, o) in zip(certs, origins) if not _is_ca_certificate(c)] + if not non_ca: + return + critical(f'FATAL: One or more {kind} certificates are not CA certificates (BasicConstraints CA=TRUE missing)') + for c, origin in non_ca: + critical(f' Origin : {origin}') + critical(f' Subject: {c.subject.rfc4514_string()}') + critical(f' Issuer : {c.issuer.rfc4514_string()}') + raise InputError(f'Refusing to include non-CA {kind} certificates') + + +# -------------------------------------------------------------------------------------- +# audit — expiry of every shipped cert + live-endpoint relevance for pinned roots +# -------------------------------------------------------------------------------------- + +# Endpoints whose live chains decide whether a pinned root still earns its place. esp_crt_bundle +# does not do path building: it trusts the chain as the server sends it and looks up the issuer +# of the topmost presented cert among the trusted roots. So the anchor an endpoint "requires" is +# precisely the issuer of the last cert in its presented chain. +MONITORED_DOMAINS = ['api.openshock.app', 'api.openshock.dev'] +PROBE_TIMEOUT_S = 20 + + +def _openssl_name(pem_or_der: bytes, field: str) -> str: + """Canonical (rfc2253) -subject/-issuer string for a single cert, via openssl.""" + proc = subprocess.run( + ['openssl', 'x509', '-noout', f'-{field}', '-nameopt', 'rfc2253'], + input=pem_or_der, + capture_output=True, + ) + if proc.returncode != 0: + raise InputError(f'openssl x509 -{field} failed: {proc.stderr.decode(errors="replace")}') + out = proc.stdout.decode(errors='replace').strip() + return out.split('=', 1)[1].strip() if '=' in out else out + + +def _rfc2253_subject(cert: x509.Certificate) -> str: + return _openssl_name(cert.public_bytes(serialization.Encoding.PEM), 'subject') + + +def required_anchor_subject(domain: str) -> str | None: + """ + rfc2253 subject of the anchor `domain` requires (issuer of the topmost cert it + presents), or None if the endpoint is unreachable. + """ + try: + proc = subprocess.run( + ['openssl', 's_client', '-connect', f'{domain}:443', '-servername', domain, '-showcerts'], + input=b'', + capture_output=True, + timeout=PROBE_TIMEOUT_S, + ) + except (subprocess.TimeoutExpired, FileNotFoundError) as e: + critical(f' probe error for {domain}: {e}') + return None + + chain = re.findall(rb'-----BEGIN CERTIFICATE-----.+?-----END CERTIFICATE-----', proc.stdout, re.S) + if not chain: + critical(f' {domain}: no certificates presented (unreachable / handshake failed)') + return None + return _openssl_name(chain[-1], 'issuer') + + +def _emit_gh_outputs(*, removable: list[str], alert: bool, probe_ok: bool) -> None: + gh_out = os.environ.get('GITHUB_OUTPUT') + if not gh_out: + return + with open(gh_out, 'a', encoding='utf-8') as f: + f.write(f'removable={" ".join(removable)}\n') + f.write(f'alert={"true" if alert else "false"}\n') + f.write(f'probe_ok={"true" if probe_ok else "false"}\n') + + +def audit() -> int: + """ + Audit every cert currently in the shipped bundle (cacert-merged.pem = base + pinned + custom): + - expiry of all of them; + - for pinned roots, probe the live endpoints to decide which are still needed. + + Emits GitHub outputs (removable / alert / probe_ok). Returns exit code 1 on a genuine + rotate-now alert (a still-needed cert expired/expiring); removal of unneeded pins is + left to the workflow's PR step keyed on the `removable` output. Fail-safe: if any + monitored endpoint is unreachable, nothing is proposed for removal. + """ + warn_days = _expiry_warn_days() + + # The bundle as actually shipped. + try: + bundle_bytes = Path(MERGED_PEM_FILE).read_bytes() + except OSError as e: + raise InputError(f'Cannot read {MERGED_PEM_FILE}: {e}') from e + bundle_blocks = extract_pem_cert_blocks(bundle_bytes, source=MERGED_PEM_FILE) + bundle_certs = load_all_certs(bundle_blocks, label='bundle') + + # Pinned roots, keyed by canonical subject, so we can tell them apart from the curl + # base within the merged bundle and know which file to retire. + pinned_blocks, pinned_paths = load_pinned_cert_blocks() + pinned_by_subject: dict[str, str] = {} + for blk, path in zip(pinned_blocks, pinned_paths): + pinned_by_subject[_openssl_name(blk, 'subject')] = path.as_posix() + + critical(f'Auditing {len(bundle_certs)} certificate(s) in {MERGED_PEM_FILE} ' + f'({len(pinned_by_subject)} pinned); warn window {warn_days}d') + + # Which anchors do the live endpoints actually require? + required: set[str] = set() + probe_ok = True + for domain in MONITORED_DOMAINS: + subj = required_anchor_subject(domain) + if subj is None: + probe_ok = False + critical(f' {domain}: UNREACHABLE (will not retire any pin this run)') + continue + required.add(subj) + critical(f' {domain}: requires anchor -> {subj}') + + now = datetime.datetime.now(datetime.timezone.utc) + removable: list[str] = [] + alert = False + + for cert in bundle_certs: + subject = _rfc2253_subject(cert) + days_left = (_cert_not_after_utc(cert) - now).days + pinned_path = pinned_by_subject.get(subject) + + if pinned_path is not None: + needed = subject in required + if not needed: + if probe_ok: + critical(f'REMOVABLE pin (no endpoint chains through it): {pinned_path} [{subject}]') + removable.append(pinned_path) + else: + critical(f'unused pin, but a probe failed -> keeping (fail-safe): {pinned_path}') + continue + # Still needed -> expiry is an operational risk. + if days_left < 0: + critical(f'ALERT: pinned root EXPIRED and still required: {pinned_path} [{subject}]') + alert = True + elif days_left <= warn_days: + critical(f'ALERT: pinned root expires in {days_left}d and still required: {pinned_path} [{subject}]') + alert = True + else: + # Curl/base (or committed custom) root. We can't retire these individually, but a + # shipped root that is already expired is still worth failing on. + if days_left < 0: + critical(f'ALERT: shipped root EXPIRED: {subject}') + alert = True + elif days_left <= warn_days: + critical(f'note: base root expires in {days_left}d (curl refresh will handle it): {subject}') + + critical('') + critical(f'Summary: {len(removable)} removable pin(s), alert={alert}, probe_ok={probe_ok}') + _emit_gh_outputs(removable=removable, alert=alert, probe_ok=probe_ok) + return 1 if alert else 0 + + +def _is_ca_certificate(cert: x509.Certificate) -> bool: + """ + Best-effort CA check: + - Requires BasicConstraints CA=TRUE + (We keep this strict to avoid adding leaf/server certs by mistake.) + """ + try: + bc = cert.extensions.get_extension_for_class(x509.BasicConstraints).value + except Exception: + return False + return bool(getattr(bc, 'ca', False)) + + +def load_all_certs( + blocks: list[bytes], + *, + label: str = 'CA', + origins: list[str] | None = None, +) -> list[x509.Certificate]: + certs: list[x509.Certificate] = [] + + for idx, b in enumerate(blocks, start=1): + origin = '' + if origins and 0 <= (idx - 1) < len(origins): + origin = origins[idx - 1] + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter('always', CryptographyDeprecationWarning) + + try: + cert = x509.load_pem_x509_certificate(b) + except Exception as e: + critical(f'FATAL: Invalid certificate at index {idx} ({label})') + if origin: + critical(f' Origin : {origin}') + critical(f' Reason: {e}') + critical(' Offending PEM:') + critical(b.decode('ascii', errors='replace').rstrip()) + raise InputError('Aborting due to invalid CA certificate') from e + + for warn in w: + if issubclass(warn.category, CryptographyDeprecationWarning): + subject = cert.subject.rfc4514_string() + issuer = cert.issuer.rfc4514_string() + + critical('WARNING: CA certificate triggers CryptographyDeprecationWarning') + critical(' This may become fatal in future cryptography releases.') + critical(f' Index : {idx} ({label})') + if origin: + critical(f' Origin : {origin}') + critical(f' Subject : {subject}') + critical(f' Issuer : {issuer}') + critical(f' Reason : {warn.message}') + critical(' Offending PEM:') + critical(b.decode('ascii', errors='replace').rstrip()) + + certs.append(cert) + + return certs + + +def validate_unique_subjects(certs: list[x509.Certificate]) -> None: + """ + esp_crt_bundle (the ESP-IDF build step that packs cacert-merged.pem into flash) looks + certificates up by their DER-encoded subject name, so two certs sharing a subject + would make that lookup ambiguous. Reject duplicates here. + """ + seen: dict[bytes, x509.Certificate] = {} + for crt in certs: + subject_der = crt.subject.public_bytes(serialization.Encoding.DER) + if subject_der in seen: + raise InputError( + 'Duplicate certificate subject name detected; this can make ' + f'esp_crt_bundle subject-name lookup ambiguous: {crt.subject.rfc4514_string()}' + ) + seen[subject_der] = crt + + +def atomic_write(path: str, data: bytes) -> None: + out_dir = os.path.dirname(os.path.abspath(path)) or '.' + os.makedirs(out_dir, exist_ok=True) + + fd = None + tmp_path = None + try: + fd, tmp_path = tempfile.mkstemp(prefix='.tmp_crt_bundle_', dir=out_dir) + with os.fdopen(fd, 'wb') as f: + f.write(data) + f.flush() + os.fsync(f.fileno()) + fd = None + + os.replace(tmp_path, path) + tmp_path = None + finally: + try: + if tmp_path and os.path.exists(tmp_path): + os.remove(tmp_path) + except Exception: + pass + + +def _append_pem_blocks(base: bytes, blocks: list[bytes]) -> bytes: + out = bytearray(base) + for blk in blocks: + if out and not out.endswith(b'\n'): + out += b'\n' + out += b'\n' + blk + if not out.endswith(b'\n'): + out += b'\n' + return bytes(out) + + +def generate() -> int: + warn_days = _expiry_warn_days() + + critical(f'Downloading CA bundle from: {URL_PEM}') + pem = download_bytes(URL_PEM) + + got_hash = hashlib.sha256(pem).hexdigest().lower() + critical(f'Downloaded {len(pem)} bytes') + critical(f'SHA-256(curl download) = {got_hash}') + + critical(f'Downloading published hash from: {URL_SHA256}') + sha_file = download_bytes(URL_SHA256) + expected_hash = parse_sha256_file(sha_file, expect_filename='cacert.pem') + critical(f'Published SHA-256 = {expected_hash}') + + critical('') + critical('MANUAL VERIFICATION REQUIRED') + critical(' Compare the "SHA-256(curl download)" hash above (the upstream bytes, computed') + critical(' BEFORE pinned/custom certs are appended) against:') + critical(f' {MANUAL_HASH_PAGE}') + critical('') + + if got_hash != expected_hash: + raise InputError('SHA-256 mismatch against cacert.pem.sha256 — refusing to proceed') + + critical('Automatic SHA-256 validation passed') + + # Curl/Mozilla base. + blocks = extract_pem_cert_blocks(pem, source='downloaded curl CA bundle') + curl_certs = load_all_certs(blocks, label='CA', origins=['curl bundle'] * len(blocks)) + critical(f'Found {len(blocks)} certificate blocks in curl bundle') + + # Pinned roots (tracked, shipped, appended into cacert-merged.pem, expiry-monitored). + pinned_blocks, pinned_paths = load_pinned_cert_blocks() + pinned_certs: list[x509.Certificate] = [] + if pinned_blocks: + pinned_origins = [f'{PINNED_CERTS_DIR}/{p.name}' for p in pinned_paths] + pinned_certs = load_all_certs(pinned_blocks, label='pinned', origins=pinned_origins) + _reject_non_ca(pinned_certs, pinned_origins, kind='pinned') + + # Never ship an already-dead anchor. + expired, expiring = check_certs_expiry(pinned_certs, pinned_origins, warn_days) + if expired: + raise InputError(f'Refusing to ship {len(expired)} expired pinned certificate(s)') + if expiring: + critical(f'NOTE: {len(expiring)} pinned cert(s) expire within {warn_days}d — rotate soon') + critical(f'Found {len(pinned_blocks)} pinned certificate(s)') + else: + critical(f'No pinned certificates found ({PINNED_CERTS_DIR}/*.pem)') + + # Custom roots (local-only, git-ignored self-hosted PKI). + custom_blocks, custom_paths = load_custom_cert_blocks() + custom_certs: list[x509.Certificate] = [] + if custom_blocks: + custom_origins = [f'custom cert file: {p}' for p in custom_paths] + custom_certs = load_all_certs(custom_blocks, label='custom', origins=custom_origins) + _reject_non_ca(custom_certs, custom_origins, kind='custom') + critical(f'Found {len(custom_blocks)} custom certificate(s)') + else: + critical('No custom certificates found (custom_certs/*.pem)') + + # Deduplicate: drop any pinned/custom cert whose DER is already present (in the base + # or an earlier pin/custom) so the merged output never contains the same cert twice. + seen_der = {c.public_bytes(serialization.Encoding.DER) for c in curl_certs} + extra_blocks: list[bytes] = [] + extra_certs: list[x509.Certificate] = [] + for blk, cert, origin in zip( + list(pinned_blocks) + list(custom_blocks), + pinned_certs + custom_certs, + [f'{PINNED_CERTS_DIR}/{p.name}' for p in pinned_paths] + [f'custom_certs/{p.name}' for p in custom_paths], + ): + der = cert.public_bytes(serialization.Encoding.DER) + if der in seen_der: + critical(f'Skipping duplicate certificate (already in bundle): {cert.subject.rfc4514_string()} [{origin}]') + continue + seen_der.add(der) + extra_blocks.append(blk) + extra_certs.append(cert) + + # Validate the deduplicated set: unique subject names so esp_crt_bundle's subject-name + # lookup stays unambiguous (this also catches two *different* certs sharing a subject). + certs = curl_certs + extra_certs + critical(f'Bundle: {len(curl_certs)} base + {len(extra_certs)} pinned/custom = {len(certs)} certs') + validate_unique_subjects(certs) + + # Save the verbatim curl base (so `sha256sum {BASE_PEM_FILE}` matches curl's published + # hash) and the merged store the firmware actually packs. The firmware build converts + # and packs cacert-merged.pem via ESP-IDF's certificate-bundle step; this script does not emit + # the packed x509 bundle itself. + critical(f'Saving {BASE_PEM_FILE} (verbatim curl base, {len(blocks)} certs, atomic)') + atomic_write(BASE_PEM_FILE, pem) + + # Merged file gets our own banner in place of curl's leading header, then all certs. + base_no_header = pem[pem.find(b'-----BEGIN CERTIFICATE-----') :] + combined = MERGED_BANNER + _append_pem_blocks(base_no_header, extra_blocks) + critical(f'Saving {MERGED_PEM_FILE} ({len(certs)} certs, atomic)') + atomic_write(MERGED_PEM_FILE, combined) + + critical('Done') + return 0 + + +USAGE = 'usage: cert_bundle.py [generate|audit]' + + +def main() -> int: + args = sys.argv[1:] + if not args or args == ['generate']: + return generate() + if args == ['audit']: + return audit() + if args in (['-h'], ['--help']): + critical(USAGE) + critical(' generate (default) download + verify; write cacert-curl.pem (base) + cacert-merged.pem (base+pinned+custom)') + critical(' audit audit expiry of every shipped cert + probe endpoints for pinned') + critical(' relevance; exits 1 on a still-needed expired/expiring cert') + return 0 + raise InputError(USAGE) + + +if __name__ == '__main__': + try: + raise SystemExit(main()) + except InputError as e: + critical(str(e)) + raise SystemExit(2) diff --git a/certificates/gen_crt_bundle.py b/certificates/gen_crt_bundle.py deleted file mode 100644 index b8aed29b..00000000 --- a/certificates/gen_crt_bundle.py +++ /dev/null @@ -1,427 +0,0 @@ -#!/usr/bin/env python3 -# -# ESP32 x509 certificate bundle download and conversion script with SHA-256 Verification -# -# This file is based on Espressif's original certificate bundle -# generation utility, but has been modified to: -# - Automatically download and convert curl's CA certificate bundle -# - Perform automatic SHA-256 integrity verification of downloads -# - Emit warnings for certificates that trigger cryptography deprecation notices -# - Write the output bundle atomically to avoid partial updates -# - Optionally include user-provided custom CA certificates from custom_certs/*.pem -# -# Copyright 2018-2019 Espressif Systems (Shanghai) PTE LTD -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - - -from __future__ import annotations - -import base64 -import hashlib -import os -import re -import struct -import sys -import tempfile -import urllib.request -import ssl -import warnings -from pathlib import Path - -try: - from cryptography import x509 - from cryptography.hazmat.primitives import serialization - from cryptography.utils import CryptographyDeprecationWarning -except ImportError: - print( - 'gen_crt_bundle.py: cryptography package not installed ' '(pip install cryptography)', - file=sys.stderr, - ) - raise - -URL_PEM = 'https://curl.se/ca/cacert.pem' -URL_SHA256 = 'https://curl.se/ca/cacert.pem.sha256' -MANUAL_HASH_PAGE = 'https://curl.se/docs/caextract.html' -OUTPUT_FILE = 'x509_crt_bundle' -LOCAL_PEM_FILE = 'cacert.pem' - -# Custom certs (self-hosted/internal PKI) -CUSTOM_CERTS_DIR = 'custom_certs' # optional; if missing -> ignored -CUSTOM_CERTS_GLOB = '*.pem' # required extension - -PEM_BLOCK_RE = re.compile( - br'-----BEGIN CERTIFICATE-----\s*[\s\S]+?\s*-----END CERTIFICATE-----\s*', - re.MULTILINE, -) - - -class InputError(RuntimeError): - pass - - -def critical(msg: str) -> None: - sys.stderr.write('gen_crt_bundle.py: ' + msg + '\n') - - -def download_bytes(url: str, timeout: int = 30) -> bytes: - ctx = ssl.create_default_context() - req = urllib.request.Request( - url, - headers={ - 'User-Agent': 'esp32-gen-crt-bundle/strict', - 'Accept': 'text/plain,*/*', - }, - method='GET', - ) - with urllib.request.urlopen(req, context=ctx, timeout=timeout) as resp: - # urllib responses vary; handle both .status and .getcode() - status = getattr(resp, 'status', None) - if status is None: - try: - status = resp.getcode() - except Exception: - status = 200 - if status != 200: - raise InputError(f'Download failed: HTTP {status} for {url}') - data = resp.read() - if not data: - raise InputError(f'Download failed: empty response from {url}') - return data - - -def parse_sha256_file(content: bytes, *, expect_filename: str | None = None) -> str: - """ - Parse a .sha256 file. - Common formats include: - - ' cacert.pem' - - '' - We accept the first line and extract a leading 64-hex hash. - If expect_filename is provided and a filename appears on the line, - we sanity-check that it matches. - """ - text = content.decode('utf-8', errors='replace').strip() - if not text: - raise InputError('Downloaded sha256 file is empty') - - first = text.splitlines()[0].strip() - m = re.match(r'^([0-9a-fA-F]{64})(?:\s+[* ]?(.+))?$', first) - if not m: - raise InputError(f'Could not parse SHA-256 from sha256 file: {first!r}') - - h = m.group(1).lower() - fn = (m.group(2) or '').strip() - - if expect_filename and fn: - # Some sha256sum formats include full paths; compare basename. - if os.path.basename(fn) != expect_filename: - raise InputError(f'SHA-256 file refers to unexpected filename {fn!r} (expected {expect_filename!r})') - - return h - - -def extract_pem_cert_blocks(pem_bytes: bytes, *, source: str) -> list[bytes]: - blocks = PEM_BLOCK_RE.findall(pem_bytes) - if not blocks: - raise InputError(f'No PEM certificate blocks found in {source}') - return blocks - - -def _load_single_pem_cert_block_from_bytes_strict(data: bytes, *, source: str) -> bytes: - # Hard fail if file contains any private key material. - if b'PRIVATE KEY' in data: - raise InputError(f'Custom cert file contains private key material (PRIVATE KEY): {source}') - - begin = b'-----BEGIN CERTIFICATE-----' - end = b'-----END CERTIFICATE-----' - - begin_idx = data.find(begin) - end_idx = data.find(end) - if begin_idx == -1 or end_idx == -1: - raise InputError(f'Custom cert file must contain exactly 1 certificate: {source}') - - end_idx += len(end) - - # Disallow any non-whitespace bytes before/after the public certificate - if data[:begin_idx].strip(b' \t\r\n'): - raise InputError(f'Non-whitespace data before certificate in {source}') - if data[end_idx:].strip(b' \t\r\n'): - raise InputError(f'Non-whitespace data after certificate in {source}') - - # Exactly one cert block (no extra PEM blocks) - if data.count(begin) != 1 or data.count(end) != 1: - raise InputError(f'Custom cert file must contain exactly 1 certificate: {source}') - tmp = data.replace(begin, b'') - if b'-----BEGIN ' in tmp: - raise InputError(f'Custom cert file contains additional PEM blocks besides the certificate: {source}') - - # Validate base64 payload decodes cleanly - pem = data[begin_idx:end_idx] - lines = pem.splitlines() - if not lines or lines[0].strip() != begin or lines[-1].strip() != end: - raise InputError(f'Malformed CERTIFICATE PEM boundaries in {source}') - - b64_lines = [ln.strip() for ln in lines[1:-1] if ln.strip()] - if not b64_lines: - raise InputError(f'Empty certificate payload in {source}') - - b64_blob = b''.join(b64_lines) - try: - der = base64.b64decode(b64_blob, validate=True) - except Exception as e: - raise InputError(f'Invalid base64 payload in certificate: {source}') from e - if not der or len(der) < 64: - raise InputError(f'Certificate DER payload too small / invalid: {source}') - - # Canonicalize output PEM (stable formatting) - b64_canon = base64.b64encode(der) - out_lines = [b64_canon[i : i + 64] for i in range(0, len(b64_canon), 64)] - return b'-----BEGIN CERTIFICATE-----\n' + b'\n'.join(out_lines) + b'\n-----END CERTIFICATE-----\n' - - -def _load_single_pem_cert_block_from_file(path: Path) -> bytes: - try: - data = path.read_bytes() - except Exception as e: - raise InputError(f'Failed to read custom cert file: {path}') from e - - return _load_single_pem_cert_block_from_bytes_strict(data, source=f'custom cert file: {path}') - - -def load_custom_cert_blocks() -> tuple[list[bytes], list[Path]]: - """ - Load user-provided custom CA certificate PEM blocks from custom_certs/*.pem. - - Returns: (blocks, paths) - - Behavior: - - If custom_certs/ does not exist -> return ([], []) - - Only *.pem files are considered - - If the directory exists but contains no *.pem -> return ([], []) - - Non-.pem files are ignored (we do not scan them) - - Each .pem must contain exactly one certificate - """ - d = Path(CUSTOM_CERTS_DIR) - if not d.exists(): - return ([], []) - - if not d.is_dir(): - raise InputError(f'{CUSTOM_CERTS_DIR} exists but is not a directory') - - paths = sorted(d.glob(CUSTOM_CERTS_GLOB)) - if not paths: - return ([], []) - - critical(f'Loading custom certificates from: {CUSTOM_CERTS_DIR}/ ({len(paths)} file(s))') - - blocks: list[bytes] = [] - for p in paths: - if p.suffix.lower() != '.pem': - # Should not happen due to glob, but keep strict. - raise InputError(f'Custom cert file must have .pem extension: {p}') - blocks.append(_load_single_pem_cert_block_from_file(p)) - - return (blocks, paths) - - -def _is_ca_certificate(cert: x509.Certificate) -> bool: - """ - Best-effort CA check: - - Requires BasicConstraints CA=TRUE - (We keep this strict to avoid adding leaf/server certs by mistake.) - """ - try: - bc = cert.extensions.get_extension_for_class(x509.BasicConstraints).value - except Exception: - return False - return bool(getattr(bc, 'ca', False)) - - -def load_all_certs( - blocks: list[bytes], - *, - label: str = 'CA', - origins: list[str] | None = None, -) -> list[x509.Certificate]: - certs: list[x509.Certificate] = [] - - for idx, b in enumerate(blocks, start=1): - origin = '' - if origins and 0 <= (idx - 1) < len(origins): - origin = origins[idx - 1] - - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter('always', CryptographyDeprecationWarning) - - try: - cert = x509.load_pem_x509_certificate(b) - except Exception as e: - critical(f'FATAL: Invalid certificate at index {idx} ({label})') - if origin: - critical(f' Origin : {origin}') - critical(f' Reason: {e}') - critical(' Offending PEM:') - critical(b.decode('ascii', errors='replace').rstrip()) - raise InputError('Aborting due to invalid CA certificate') from e - - for warn in w: - if issubclass(warn.category, CryptographyDeprecationWarning): - subject = cert.subject.rfc4514_string() - issuer = cert.issuer.rfc4514_string() - - critical('WARNING: CA certificate triggers CryptographyDeprecationWarning') - critical(' This may become fatal in future cryptography releases.') - critical(f' Index : {idx} ({label})') - if origin: - critical(f' Origin : {origin}') - critical(f' Subject : {subject}') - critical(f' Issuer : {issuer}') - critical(f' Reason : {warn.message}') - critical(' Offending PEM:') - critical(b.decode('ascii', errors='replace').rstrip()) - - certs.append(cert) - - return certs - - -def create_bundle(certs: list[x509.Certificate]) -> bytes: - def subject_der(c: x509.Certificate) -> bytes: - return c.subject.public_bytes(serialization.Encoding.DER) - - certs_sorted = sorted(certs, key=subject_der) - - for i in range(1, len(certs_sorted)): - if subject_der(certs_sorted[i - 1]) == subject_der(certs_sorted[i]): - raise InputError( - 'Duplicate certificate subject name detected in bundle; ' 'this can make subject-name lookup ambiguous' - ) - - bundle = struct.pack('>H', len(certs_sorted)) - - for crt in certs_sorted: - pub_key_der = crt.public_key().public_bytes( - serialization.Encoding.DER, - serialization.PublicFormat.SubjectPublicKeyInfo, - ) - sub_name_der = subject_der(crt) - - if len(sub_name_der) > 0xFFFF or len(pub_key_der) > 0xFFFF: - raise InputError('Subject name or public key too large for bundle format') - - bundle += struct.pack('>HH', len(sub_name_der), len(pub_key_der)) - bundle += sub_name_der - bundle += pub_key_der - - return bundle - - -def atomic_write(path: str, data: bytes) -> None: - out_dir = os.path.dirname(os.path.abspath(path)) or '.' - os.makedirs(out_dir, exist_ok=True) - - fd = None - tmp_path = None - try: - fd, tmp_path = tempfile.mkstemp(prefix='.tmp_crt_bundle_', dir=out_dir) - with os.fdopen(fd, 'wb') as f: - f.write(data) - f.flush() - os.fsync(f.fileno()) - fd = None - - os.replace(tmp_path, path) - tmp_path = None - finally: - try: - if tmp_path and os.path.exists(tmp_path): - os.remove(tmp_path) - except Exception: - pass - - -def main() -> int: - if len(sys.argv) != 1: - raise InputError('This script takes no arguments') - - critical(f'Downloading CA bundle from: {URL_PEM}') - pem = download_bytes(URL_PEM) - - got_hash = hashlib.sha256(pem).hexdigest().lower() - critical(f'Downloaded {len(pem)} bytes') - critical(f'SHA-256(cacert.pem) = {got_hash}') - - critical(f'Downloading published hash from: {URL_SHA256}') - sha_file = download_bytes(URL_SHA256) - expected_hash = parse_sha256_file(sha_file, expect_filename=os.path.basename(LOCAL_PEM_FILE)) - critical(f'Published SHA-256 = {expected_hash}') - - critical('') - critical('MANUAL VERIFICATION REQUIRED') - critical(' Compare the above SHA-256 hash against:') - critical(f' {MANUAL_HASH_PAGE}') - critical('') - - if got_hash != expected_hash: - raise InputError('SHA-256 mismatch against cacert.pem.sha256 — refusing to proceed') - - critical('Automatic SHA-256 validation passed') - - # Only write cacert.pem after verification passes - critical(f'Saving verified PEM to {LOCAL_PEM_FILE} (atomic)') - atomic_write(LOCAL_PEM_FILE, pem) - - blocks = extract_pem_cert_blocks(pem, source='downloaded curl CA bundle (cacert.pem)') - critical(f'Found {len(blocks)} certificate blocks in curl bundle') - - (custom_blocks, custom_paths) = load_custom_cert_blocks() - custom_certs: list[x509.Certificate] = [] - if custom_blocks: - critical(f'Found {len(custom_blocks)} custom certificate(s)') - custom_origins = [f'custom cert file: {p}' for p in custom_paths] - custom_certs = load_all_certs(custom_blocks, label='custom', origins=custom_origins) - - # Enforce that custom certs are CA certs (best-effort) and prevent accidental leaf addition. - non_ca = [(c, o) for (c, o) in zip(custom_certs, custom_origins) if not _is_ca_certificate(c)] - if non_ca: - critical( - 'FATAL: One or more custom certificates are not CA certificates (BasicConstraints CA=TRUE missing)' - ) - for c, origin in non_ca: - critical(f' Origin : {origin}') - critical(f' Subject: {c.subject.rfc4514_string()}') - critical(f' Issuer : {c.issuer.rfc4514_string()}') - raise InputError('Refusing to include non-CA custom certificates') - else: - critical('No custom certificates found (custom_certs/*.pem)') - - curl_origins = ['curl bundle'] * len(blocks) - curl_certs = load_all_certs(blocks, label='CA', origins=curl_origins) - certs = curl_certs + custom_certs - - critical(f'Parsed {len(certs)} certificates total') - - bundle = create_bundle(certs) - critical(f'Writing bundle to {OUTPUT_FILE} (atomic)') - atomic_write(OUTPUT_FILE, bundle) - - critical('Done') - return 0 - - -if __name__ == '__main__': - try: - raise SystemExit(main()) - except InputError as e: - critical(str(e)) - raise SystemExit(2) diff --git a/certificates/pinned_certs/GlobalSign_Root_CA_R1.pem b/certificates/pinned_certs/GlobalSign_Root_CA_R1.pem new file mode 100644 index 00000000..b82159be --- /dev/null +++ b/certificates/pinned_certs/GlobalSign_Root_CA_R1.pem @@ -0,0 +1,23 @@ +GlobalSign Root CA R1 +===================== +-----BEGIN CERTIFICATE----- +MIIDdTCCAl2gAwIBAgILBAAAAAABFUtaw5QwDQYJKoZIhvcNAQEFBQAwVzELMAkG +A1UEBhMCQkUxGTAXBgNVBAoTEEdsb2JhbFNpZ24gbnYtc2ExEDAOBgNVBAsTB1Jv +b3QgQ0ExGzAZBgNVBAMTEkdsb2JhbFNpZ24gUm9vdCBDQTAeFw05ODA5MDExMjAw +MDBaFw0yODAxMjgxMjAwMDBaMFcxCzAJBgNVBAYTAkJFMRkwFwYDVQQKExBHbG9i +YWxTaWduIG52LXNhMRAwDgYDVQQLEwdSb290IENBMRswGQYDVQQDExJHbG9iYWxT +aWduIFJvb3QgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDaDuaZ +jc6j40+Kfvvxi4Mla+pIH/EqsLmVEQS98GPR4mdmzxzdzxtIK+6NiY6arymAZavp +xy0Sy6scTHAHoT0KMM0VjU/43dSMUBUc71DuxC73/OlS8pF94G3VNTCOXkNz8kHp +1Wrjsok6Vjk4bwY8iGlbKk3Fp1S4bInMm/k8yuX9ifUSPJJ4ltbcdG6TRGHRjcdG +snUOhugZitVtbNV4FpWi6cgKOOvyJBNPc1STE4U6G7weNLWLBYy5d4ux2x8gkasJ +U26Qzns3dLlwR5EiUWMWea6xrkEmCMgZK9FGqkjWZCrXgzT/LCrBbBlDSgeF59N8 +9iFo7+ryUp9/k5DPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8E +BTADAQH/MB0GA1UdDgQWBBRge2YaRQ2XyolQL30EzTSo//z9SzANBgkqhkiG9w0B +AQUFAAOCAQEA1nPnfE920I2/7LqivjTFKDK1fPxsnCwrvQmeU79rXqoRSLblCKOz +yj1hTdNGCbM+w6DjY1Ub8rrvrTnhQ7k4o+YviiY776BQVvnGCv04zcQLcFGUl5gE +38NflNUVyRRBnMRddWQVDf9VMOyGj/8N7yy5Y0b2qvzfvGn9LhJIZJrglfCm7ymP +AbEVtQwdpf5pLGkkeB6zpxxxYu7KyJesF12KwvhHhm4qxFYxldBniYUr+WymXUad +DKqC5JlR3XC321Y9YeRq4VzW9v493kHMB65jUr9TU/Qr6cf9tveCX4XSQRjbgbME +HMUfpIBvFSDJ3gyICh3WZlXi/EjJKSZp4A== +-----END CERTIFICATE----- diff --git a/certificates/x509_crt_bundle b/certificates/x509_crt_bundle deleted file mode 100644 index 76e5585c..00000000 Binary files a/certificates/x509_crt_bundle and /dev/null differ diff --git a/chips/ESP32-C3/4MB/merge-image.py b/chips/ESP32-C3/4MB/merge-image.py deleted file mode 100644 index 8e76a390..00000000 --- a/chips/ESP32-C3/4MB/merge-image.py +++ /dev/null @@ -1,16 +0,0 @@ -#!/bin/python3 - -import esptool - -# fmt: off -# Note: Bootloader for esp32-s3 starts at 0x0000, unlike several other ESP32 variants that start at 0x1000. -esptool.main([ - '--chip', 'esp32s3', - 'merge_bin', '-o', 'merged.bin', - '--flash_size', '4MB', - '0x0', './bootloader.bin', - '0x8000', './partitions.bin', - '0x10000', './app.bin', - '0x353000', './staticfs.bin' # This is littlefs.bin, the github CI/CD pipeline renames it to staticfs.bin -]) -# fmt: on diff --git a/chips/ESP32-S2/4MB/merge-image.py b/chips/ESP32-S2/4MB/merge-image.py deleted file mode 100644 index bfe06d40..00000000 --- a/chips/ESP32-S2/4MB/merge-image.py +++ /dev/null @@ -1,15 +0,0 @@ -#!/bin/python3 - -import esptool - -# fmt: off -esptool.main([ - '--chip', 'esp32', - 'merge_bin', '-o', 'merged.bin', - '--flash_size', '4MB', - '0x1000', './bootloader.bin', - '0x8000', './partitions.bin', - '0x10000', './app.bin', - '0x353000', './staticfs.bin' # This is littlefs.bin, the github CI/CD pipeline renames it to staticfs.bin -]) -# fmt: on diff --git a/chips/ESP32-S3/4MB/merge-image.py b/chips/ESP32-S3/4MB/merge-image.py deleted file mode 100644 index 8e76a390..00000000 --- a/chips/ESP32-S3/4MB/merge-image.py +++ /dev/null @@ -1,16 +0,0 @@ -#!/bin/python3 - -import esptool - -# fmt: off -# Note: Bootloader for esp32-s3 starts at 0x0000, unlike several other ESP32 variants that start at 0x1000. -esptool.main([ - '--chip', 'esp32s3', - 'merge_bin', '-o', 'merged.bin', - '--flash_size', '4MB', - '0x0', './bootloader.bin', - '0x8000', './partitions.bin', - '0x10000', './app.bin', - '0x353000', './staticfs.bin' # This is littlefs.bin, the github CI/CD pipeline renames it to staticfs.bin -]) -# fmt: on diff --git a/chips/ESP32/4MB/merge-image.py b/chips/ESP32/4MB/merge-image.py deleted file mode 100644 index bfe06d40..00000000 --- a/chips/ESP32/4MB/merge-image.py +++ /dev/null @@ -1,15 +0,0 @@ -#!/bin/python3 - -import esptool - -# fmt: off -esptool.main([ - '--chip', 'esp32', - 'merge_bin', '-o', 'merged.bin', - '--flash_size', '4MB', - '0x1000', './bootloader.bin', - '0x8000', './partitions.bin', - '0x10000', './app.bin', - '0x353000', './staticfs.bin' # This is littlefs.bin, the github CI/CD pipeline renames it to staticfs.bin -]) -# fmt: on diff --git a/components/.gitignore b/components/.gitignore new file mode 100644 index 00000000..cfa76853 --- /dev/null +++ b/components/.gitignore @@ -0,0 +1,11 @@ +# Generated by `idf.py` in the per-component host test projects +# (components/*/host_test). The root .gitignore only covers the firmware project's +# own /build/, /managed_components/ and sdkconfig at the repo root. +build/ +sdkconfig +sdkconfig.old +# Component-manager output for the suites with an idf_component.yml (jsmn, +# json_generator). Not committed: the suites resolve the same version ranges the +# firmware components declare. +*/host_test/managed_components/ +*/host_test/dependencies.lock diff --git a/components/captive_portal/CMakeLists.txt b/components/captive_portal/CMakeLists.txt new file mode 100644 index 00000000..21ef1c62 --- /dev/null +++ b/components/captive_portal/CMakeLists.txt @@ -0,0 +1,22 @@ +# OpenShock captive portal: the setup SoftAP, its HTTP/WebSocket server (static UI, +# JSON setup endpoints, local flatbuffer messages), the wildcard DNS responder and the +# RFC 8908 handlers, plus the manager task that decides when the portal is open. +# +# Extracted out of `core` and layered above it: the portal drives wifi, the gateway +# link and the local message handlers in core, while core reaches back only through +# the AppHooks callbacks registered in CaptivePortal::Init(). The public surface is just +# captiveportal/Manager.h (std types only); CaptivePortalInstance.h stays in src/ so +# esp_http_server and friends do not leak into consumers. +idf_component_register( + SRCS "src/captiveportal/CaptivePortalInstance.cpp" + "src/captiveportal/Manager.cpp" + INCLUDE_DIRS "include" + PRIV_INCLUDE_DIRS "src" + PRIV_REQUIRES core config device_control # wifi/gateway/message handlers/AppHooks · Config:: · CommandHandler/EStopManager + common chipset logging temporal osjson serialization # foundation utilities, json, HubToLocalMessage fbs + dns_server fs http hwutil rfc8908 # DNSServer, StaticFs, ContentTypes, PartitionUtils, RFC8908Handler + esp_http_server esp_netif esp_timer esp_wifi +) + +# Match core: IDF compiles application components with -Werror, the legacy build did not. +target_compile_options(${COMPONENT_LIB} PRIVATE -Wno-error) diff --git a/include/captiveportal/Manager.h b/components/captive_portal/include/captiveportal/Manager.h similarity index 58% rename from include/captiveportal/Manager.h rename to components/captive_portal/include/captiveportal/Manager.h index 37d338ad..0e75b19f 100644 --- a/include/captiveportal/Manager.h +++ b/components/captive_portal/include/captiveportal/Manager.h @@ -1,10 +1,9 @@ #pragma once #include +#include #include -#include "span.h" - namespace OpenShock::CaptivePortal { [[nodiscard]] bool Init(); @@ -14,13 +13,18 @@ namespace OpenShock::CaptivePortal { /// @brief Signal that the user has completed setup. The portal will close once the device is fully online. void SetUserDone(); + /// @brief Stops the portal and keeps it closed until ReleaseForceClose(). Returns once fully torn down. bool ForceClose(uint32_t timeoutMs); + void ReleaseForceClose(); bool IsRunning(); + /// @brief The fixed IPv4 address the captive portal AP serves from (e.g. "4.3.2.1"). + const char* ApIPv4String(); + bool SendMessageTXT(uint8_t socketId, std::string_view data); - bool SendMessageBIN(uint8_t socketId, tcb::span data); + bool SendMessageBIN(uint8_t socketId, std::span data); bool BroadcastMessageTXT(std::string_view data); - bool BroadcastMessageBIN(tcb::span data); + bool BroadcastMessageBIN(std::span data); } // namespace OpenShock::CaptivePortal diff --git a/components/captive_portal/src/captiveportal/CaptivePortalInstance.cpp b/components/captive_portal/src/captiveportal/CaptivePortalInstance.cpp new file mode 100644 index 00000000..4388ad5f --- /dev/null +++ b/components/captive_portal/src/captiveportal/CaptivePortalInstance.cpp @@ -0,0 +1,1120 @@ +#include +#include + +#include "captiveportal/CaptivePortalInstance.h" + +const char* const TAG = "CaptivePortalInstance"; + +#include "AppHooks.h" +#include "captiveportal/Manager.h" +#include "Chipset.h" +#include "CommandHandler.h" +#include "config/Config.h" +#include "Convert.h" +#include "enums/OtaUpdateChannel.h" +#include "GatewayConnectionManager.h" +#include "http/ContentTypes.h" +#include "hwutil/PartitionUtils.h" +#include "Logging.h" +#include "message_handlers/WebSocket.h" +#include "RateLimiter.h" +#include "rfc8908/RFC8908Handler.h" +#include "serialization/WSLocal.h" +#include "util/HexUtils.h" +#include "util/TaskUtils.h" +#include "wifi/WiFiManager.h" +#include "wifi/WiFiScanManager.h" + +#include "serialization/_fbs/HubToLocalMessage_generated.h" + +#include "json/Json.h" + +#include +#include +#include +#include +#include + +// Board GPIO assignments (no longer Kconfig; see scripts/gen_env_header.py). +#include "openshock_board.h" + +const uint16_t HTTP_PORT = 80; + +// Largest inbound WebSocket message we'll accept (local FlatBuffer commands are tiny). +// Nothing larger is accepted by the local message handler, so don't buffer more than that. +static constexpr size_t MAX_WS_MSG = OpenShock::MessageHandlers::WebSocket::MaxLocalMessageSize; + +// HTTP status lines (esp_http_server needs the full "code reason" string, and stores +// the pointer rather than copying — so these must have static storage duration). +static constexpr const char* S200 = "200 OK"; +static constexpr const char* S304 = "304 Not Modified"; +static constexpr const char* S400 = "400 Bad Request"; +static constexpr const char* S429 = "429 Too Many Requests"; +static constexpr const char* S500 = "500 Internal Server Error"; +static constexpr const char* S503 = "503 Service Unavailable"; + +static const char* const JSON_ERR_INTERNAL = "{\"error\":\"InternalError\"}"; +static const char* const JSON_ERR_MISSING_PARAM = "{\"error\":\"MissingParam\"}"; +static const char* const JSON_ERR_INVALID_PARAM = "{\"error\":\"InvalidParam\"}"; +static const char* const JSON_ERR_INVALID_PIN = "{\"error\":\"InvalidPin\"}"; +static const char* const JSON_ERR_PIN_IN_USE = "{\"error\":\"PinInUse\"}"; +static const char* const JSON_ERR_MISSING_SSID = "{\"error\":\"MissingSsid\"}"; +static const char* const JSON_ERR_INVALID_SSID = "{\"error\":\"InvalidSsid\"}"; +static const char* const JSON_ERR_PASSWORD_SHORT = "{\"error\":\"PasswordTooShort\"}"; +static const char* const JSON_ERR_PASSWORD_LONG = "{\"error\":\"PasswordTooLong\"}"; +static const char* const JSON_ERR_CODE_REQUIRED = "{\"error\":\"CodeRequired\"}"; +static const char* const JSON_ERR_INVALID_CHANNEL = "{\"error\":\"InvalidChannel\"}"; +static const char* const JSON_ERR_RATE_LIMITED = "{\"error\":\"RateLimited\"}"; + +using namespace OpenShock; + +static OpenShock::RateLimiter& getAccountLinkRateLimiter() +{ + static OpenShock::RateLimiter* rl = nullptr; + if (rl == nullptr) { + rl = new OpenShock::RateLimiter(); + rl->addLimit(60'000, 5); // 5 attempts per minute + rl->addLimit(300'000, 10); // 10 attempts per 5 minutes + } + return *rl; +} + +static const char* getPartitionHash() +{ + const esp_partition_t* partition = FindStaticPartition(); + if (partition == nullptr) { + return nullptr; + } + + // Hashing the whole partition takes a noticeable time and its content only changes through OTA, which restarts the + // device, so compute it once per boot. Only called from the portal manager task. + static char hash[65]; + static bool hashValid = false; + if (!hashValid) { + hashValid = OpenShock::TryGetPartitionHash(partition, hash); + } + + return hashValid ? hash : nullptr; +} + +// --------------------------------------------------------------------------- +// Small HTTP helpers (replace the ESPAsyncWebServer request API) +// --------------------------------------------------------------------------- + +static esp_err_t sendResp(httpd_req_t* req, const char* status, const char* type, std::string_view body) +{ + httpd_resp_set_status(req, status); + if (type != nullptr) { + httpd_resp_set_type(req, type); + } + return httpd_resp_send(req, body.data(), body.size()); +} + +// Returns false for an encoded NUL ("%00"), which would silently truncate the value at every c_str() use. +static bool urlDecode(const char* s, std::string& out) +{ + out.clear(); + for (size_t i = 0; s[i] != '\0'; ++i) { + char c = s[i]; + uint8_t decoded; + if (c == '+') { + out += ' '; + } else if (c == '%' && s[i + 1] != '\0' && HexUtils::TryParseHexPair(s[i + 1], s[i + 2], decoded)) { + if (decoded == 0) { + return false; + } + out += static_cast(decoded); + i += 2; + } else { + out += c; + } + } + return true; +} + +// Read and URL-decode a query-string parameter. httpd does not decode for us. +static bool getQueryParam(httpd_req_t* req, const char* key, std::string& out) +{ + size_t qlen = httpd_req_get_url_query_len(req); + if (qlen == 0) { + return false; + } + + std::string query; + query.resize(qlen + 1); + if (httpd_req_get_url_query_str(req, query.data(), query.size()) != ESP_OK) { + return false; + } + + char val[256]; + if (httpd_query_key_value(query.c_str(), key, val, sizeof(val)) != ESP_OK) { + return false; + } + + return urlDecode(val, out); +} + +enum class ParamResult { + Ok, + Missing, + Invalid, +}; + +// The web UI sends booleans as "1"/"0"; "true"/"false" are accepted too. +static bool parseBool(std::string_view str, bool& out) +{ + if (str == "1") { + out = true; + return true; + } + if (str == "0") { + out = false; + return true; + } + return Convert::ToBool(str, out); +} + +static ParamResult getBoolQueryParam(httpd_req_t* req, const char* key, bool& out) +{ + std::string str; + if (!getQueryParam(req, key, str)) { + return ParamResult::Missing; + } + return parseBool(str, out) ? ParamResult::Ok : ParamResult::Invalid; +} + +static esp_err_t sendParamError(httpd_req_t* req, ParamResult result) +{ + return sendResp(req, S400, HTTP::ContentType::JSON, result == ParamResult::Missing ? JSON_ERR_MISSING_PARAM : JSON_ERR_INVALID_PARAM); +} + +// Read a urlencoded request body (application/x-www-form-urlencoded). +static bool recvBody(httpd_req_t* req, std::string& out) +{ + int total = req->content_len; + if (total <= 0 || total > 4096) { + return false; + } + + // A client that announces a body and then stalls must not hold the (single) httpd task forever. + static constexpr int kMaxRecvTimeouts = 3; + + out.resize(total); + int off = 0; + int timeouts = 0; + while (off < total) { + int r = httpd_req_recv(req, out.data() + off, total - off); + if (r == HTTPD_SOCK_ERR_TIMEOUT) { + if (++timeouts >= kMaxRecvTimeouts) { + return false; + } + continue; + } + if (r <= 0) { + return false; + } + off += r; + } + return true; +} + +static bool getFormParam(const std::string& body, const char* key, std::string& out) +{ + char val[256]; + if (httpd_query_key_value(body.c_str(), key, val, sizeof(val)) != ESP_OK) { + return false; + } + return urlDecode(val, out); +} + +static const char* contentTypeForPath(const std::string& path) +{ + auto endsWith = [&](const char* ext) { + size_t n = strlen(ext); + return path.size() >= n && path.compare(path.size() - n, n, ext) == 0; + }; + + if (endsWith(".html")) return HTTP::ContentType::TextHTML; + if (endsWith(".js")) return HTTP::ContentType::JavaScript; + if (endsWith(".css")) return HTTP::ContentType::CSS; + if (endsWith(".svg")) return HTTP::ContentType::SVG; + if (endsWith(".png")) return HTTP::ContentType::PNG; + if (endsWith(".ico")) return HTTP::ContentType::Icon; + if (endsWith(".json")) return HTTP::ContentType::JSON; + if (endsWith(".woff2")) return HTTP::ContentType::WOFF2; + if (endsWith(".txt")) return HTTP::ContentType::TextPlain; + return HTTP::ContentType::OctetStream; +} + +// --------------------------------------------------------------------------- +// API handlers (ported 1:1 from the ESPAsyncWebServer lambdas) +// --------------------------------------------------------------------------- + +static esp_err_t apiBoard(httpd_req_t* req) +{ + bool hasPredefinedPins = OPENSHOCK_RF_TX_GPIO != OPENSHOCK_GPIO_INVALID; + return sendResp(req, S200, HTTP::ContentType::JSON, hasPredefinedPins ? "{\"has_predefined_pins\":true}" : "{\"has_predefined_pins\":false}"); +} + +static esp_err_t apiPortalClose(httpd_req_t* req) +{ + CaptivePortal::SetUserDone(); + return sendResp(req, S200, nullptr, {}); +} + +static esp_err_t apiWifiScan(httpd_req_t* req) +{ + bool run = true; + if (auto result = getBoolQueryParam(req, "run", run); result == ParamResult::Invalid) { + return sendParamError(req, result); + } + if (run) { + WiFiScanManager::StartScan(); + } else { + WiFiScanManager::AbortScan(); + } + return sendResp(req, S200, nullptr, {}); +} + +static esp_err_t apiWifiNetworksDelete(httpd_req_t* req) +{ + std::string ssid; + if (!getQueryParam(req, "ssid", ssid)) { + return sendResp(req, S400, HTTP::ContentType::JSON, JSON_ERR_MISSING_SSID); + } + if (!WiFiManager::Forget(ssid.c_str())) { + return sendResp(req, S500, HTTP::ContentType::JSON, JSON_ERR_INTERNAL); + } + return sendResp(req, S200, nullptr, {}); +} + +static esp_err_t sendAccountLinkResult(httpd_req_t* req, OpenShock::AccountLinkResultCode result) +{ + if (result == OpenShock::AccountLinkResultCode::Success) { + return sendResp(req, S200, nullptr, {}); + } + const char* error = OpenShock::AccountLinkResultCodeToString(result); + OpenShock::JSON::StringWriter writer; + json_gen_str_t* gen = writer.gen(); + json_gen_start_object(gen); + OpenShock::JSON::objSetString(gen, "error", error); + json_gen_end_object(gen); + std::string json = writer.finish(); + return sendResp(req, S400, HTTP::ContentType::JSON, json); +} + +namespace { + struct AccountLinkJob { + httpd_req_t* req; // async copy, owned until httpd_req_async_handler_complete + std::string code; + }; + + std::atomic s_accountLinkInProgress = false; + + // Linking does DNS + TLS + an HTTPS round trip (seconds); running it here keeps the single httpd task free to + // serve the page, assets and WebSocket meanwhile. + void accountLinkTask(void* arg) + { + auto* job = static_cast(arg); + auto result = GatewayConnectionManager::Link(std::string_view(job->code)); + + sendAccountLinkResult(job->req, result); + httpd_req_async_handler_complete(job->req); + + delete job; + s_accountLinkInProgress.store(false); + vTaskDelete(nullptr); + } +} // namespace + +static esp_err_t apiAccountLink(httpd_req_t* req) +{ + if (!getAccountLinkRateLimiter().tryRequest()) { + return sendResp(req, S429, HTTP::ContentType::JSON, JSON_ERR_RATE_LIMITED); + } + std::string code; + if (!getQueryParam(req, "code", code)) { + return sendResp(req, S400, HTTP::ContentType::JSON, JSON_ERR_CODE_REQUIRED); + } + + if (s_accountLinkInProgress.exchange(true)) { + return sendResp(req, S429, HTTP::ContentType::JSON, JSON_ERR_RATE_LIMITED); + } + + httpd_req_t* asyncReq = nullptr; + if (httpd_req_async_handler_begin(req, &asyncReq) != ESP_OK) { + s_accountLinkInProgress.store(false); + return sendResp(req, S500, HTTP::ContentType::JSON, JSON_ERR_INTERNAL); + } + + auto* job = new AccountLinkJob {asyncReq, std::move(code)}; + // TLS needs a large stack + if (TaskUtils::TaskCreateExpensive(accountLinkTask, "AccountLink", 8192, job, 1, nullptr) != pdPASS) { + OS_LOGE(TAG, "Failed to create account link task"); + delete job; + esp_err_t err = sendResp(asyncReq, S500, HTTP::ContentType::JSON, JSON_ERR_INTERNAL); + httpd_req_async_handler_complete(asyncReq); + s_accountLinkInProgress.store(false); + return err; + } + + return ESP_OK; +} + +static esp_err_t apiAccountDelete(httpd_req_t* req) +{ + GatewayConnectionManager::UnLink(); + return sendResp(req, S200, nullptr, {}); +} + +static esp_err_t apiConfigRfPin(httpd_req_t* req) +{ + std::string pinStr; + if (!getQueryParam(req, "pin", pinStr)) { + return sendResp(req, S400, HTTP::ContentType::JSON, JSON_ERR_INVALID_PIN); + } + gpio_num_t pin; + if (!Convert::ToGpioNum(pinStr, pin)) { + return sendResp(req, S400, HTTP::ContentType::JSON, JSON_ERR_INVALID_PIN); + } + auto result = CommandHandler::SetRfTxPin(pin); + using ResultCode = OpenShock::SetGPIOResultCode; + if (result != ResultCode::Success) { + const char* error = result == ResultCode::InvalidPin ? JSON_ERR_INVALID_PIN : result == ResultCode::PinInUse ? JSON_ERR_PIN_IN_USE : JSON_ERR_INTERNAL; + return sendResp(req, S400, HTTP::ContentType::JSON, error); + } + OpenShock::JSON::StringWriter writer; + json_gen_str_t* gen = writer.gen(); + json_gen_start_object(gen); + json_gen_obj_set_int(gen, "pin", pin); + json_gen_end_object(gen); + std::string json = writer.finish(); + return sendResp(req, S200, HTTP::ContentType::JSON, json); +} + +static esp_err_t apiWifiNetworksAdd(httpd_req_t* req) +{ + std::string body; + if (!recvBody(req, body)) { + return sendResp(req, S400, HTTP::ContentType::JSON, JSON_ERR_MISSING_SSID); + } + + std::string ssid; + if (!getFormParam(body, "ssid", ssid) || ssid.empty() || ssid.length() > 32) { + return sendResp(req, S400, HTTP::ContentType::JSON, ssid.empty() ? JSON_ERR_MISSING_SSID : JSON_ERR_INVALID_SSID); + } + + std::string password; + if (getFormParam(body, "password", password) && !password.empty()) { + if (password.length() < 8) { + return sendResp(req, S400, HTTP::ContentType::JSON, JSON_ERR_PASSWORD_SHORT); + } + if (password.length() > 63) { + return sendResp(req, S400, HTTP::ContentType::JSON, JSON_ERR_PASSWORD_LONG); + } + } + + bool connect = true; + std::string connectStr; + if (getFormParam(body, "connect", connectStr) && !parseBool(connectStr, connect)) { + return sendResp(req, S400, HTTP::ContentType::JSON, JSON_ERR_INVALID_PARAM); + } + + wifi_auth_mode_t authMode = WIFI_AUTH_MAX; + std::string securityStr; + if (getFormParam(body, "security", securityStr)) { + uint8_t sec; + if (!Convert::ToUint8(securityStr, sec) || sec > static_cast(WIFI_AUTH_MAX)) { + return sendResp(req, S400, HTTP::ContentType::JSON, JSON_ERR_INVALID_PARAM); + } + authMode = static_cast(sec); + } + + if (!WiFiManager::Save(ssid.c_str(), std::string_view(password), connect, authMode)) { + return sendResp(req, S500, HTTP::ContentType::JSON, JSON_ERR_INTERNAL); + } + return sendResp(req, S200, nullptr, {}); +} + +static esp_err_t apiWifiConnect(httpd_req_t* req) +{ + std::string body; + std::string ssid; + if (!recvBody(req, body) || !getFormParam(body, "ssid", ssid)) { + return sendResp(req, S400, HTTP::ContentType::JSON, JSON_ERR_MISSING_SSID); + } + if (!WiFiManager::Connect(ssid.c_str())) { + return sendResp(req, S500, HTTP::ContentType::JSON, JSON_ERR_INTERNAL); + } + return sendResp(req, S200, nullptr, {}); +} + +static esp_err_t apiWifiDisconnect(httpd_req_t* req) +{ + WiFiManager::Disconnect(); + return sendResp(req, S200, nullptr, {}); +} + +// PUT /api/ota/settings?enabled=&channel=&interval=&allow=&require= +// Any subset of the OTA settings in one request, applied together in a single config write (and none at all when +// nothing changed), instead of one handler and one flash write per setting. +static esp_err_t apiOtaSettings(httpd_req_t* req) +{ + Config::OtaUpdateConfig cfg; + if (!Config::GetOtaUpdateConfig(cfg)) { + return sendResp(req, S500, HTTP::ContentType::JSON, JSON_ERR_INTERNAL); + } + + bool present = false; + bool changed = false; + + // Applies one optional boolean parameter; returns false (after responding) on an invalid value. + auto applyBool = [&](const char* key, bool& field, esp_err_t& response) { + bool value; + ParamResult result = getBoolQueryParam(req, key, value); + if (result == ParamResult::Missing) return true; + if (result == ParamResult::Invalid) { + response = sendParamError(req, result); + return false; + } + present = true; + changed |= field != value; + field = value; + return true; + }; + + esp_err_t response = ESP_OK; + if (!applyBool("enabled", cfg.isEnabled, response) || !applyBool("allow", cfg.allowBackendManagement, response) || !applyBool("require", cfg.requireManualApproval, response)) { + return response; + } + + if (std::string channelStr; getQueryParam(req, "channel", channelStr)) { + OtaUpdateChannel channel; + if (!TryParseOtaUpdateChannel(channel, channelStr.c_str())) { + return sendResp(req, S400, HTTP::ContentType::JSON, JSON_ERR_INVALID_CHANNEL); + } + present = true; + changed |= cfg.updateChannel != channel; + cfg.updateChannel = channel; + } + + if (std::string intervalStr; getQueryParam(req, "interval", intervalStr)) { + // Minutes; 0 would make periodic checks fire on every OTA task wake-up + uint16_t interval; + if (!Convert::ToUint16(intervalStr, interval) || interval == 0) { + return sendResp(req, S400, HTTP::ContentType::JSON, JSON_ERR_INVALID_PARAM); + } + present = true; + changed |= cfg.checkInterval != interval; + cfg.checkInterval = interval; + } + + if (!present) { + return sendResp(req, S400, HTTP::ContentType::JSON, JSON_ERR_MISSING_PARAM); + } + + if (changed && !Config::SetOtaUpdateConfig(cfg)) { + return sendResp(req, S500, HTTP::ContentType::JSON, JSON_ERR_INTERNAL); + } + return sendResp(req, S200, nullptr, {}); +} + +static esp_err_t apiOtaCheck(httpd_req_t* req) +{ + // Checks the configured channel; the update task reports progress over the gateway as usual. + if (!AppHooks::OtaRequestUpdateCheck()) { + return sendResp(req, S503, HTTP::ContentType::JSON, JSON_ERR_INTERNAL); + } + return sendResp(req, S200, nullptr, {}); +} + +// --------------------------------------------------------------------------- +// Static file serving (gzipped assets from the raw littlefs static0 partition) +// --------------------------------------------------------------------------- + +esp_err_t CaptivePortal::CaptivePortalInstance::staticFileHandler(httpd_req_t* req) +{ + auto* self = static_cast(req->user_ctx); + + if (!self->m_staticFs.isMounted()) { + // Filesystem image was never uploaded — serve the help page for any request. + return sendResp( + req, + S200, + HTTP::ContentType::TextPlain, + // Raw string literal (1+ to remove the first newline) + 1 + R"( +You probably forgot to upload the Filesystem with PlatformIO! +Go to PlatformIO -> Platform -> Upload Filesystem Image! +If this happened with a file we provided or you just need help, come to the Discord! + +discord.gg/OpenShock +)" + ); + } + + std::string uri(req->uri); + auto qpos = uri.find('?'); + if (qpos != std::string::npos) { + uri.resize(qpos); + } + + // Reject path traversal, then fall through to the captive redirect. + if (uri.find("..") != std::string::npos) { + return RFC8908::EmitRedirect(req); + } + + if (uri == "/") { + uri = "/index.html"; + } + + // All assets are pre-gzipped, stored at /www/.gz on the static0 partition. + std::string path = "/www" + uri + ".gz"; + + if (!self->m_staticFs.exists(path.c_str())) { + // Unknown path → captive redirect (matches serveStatic default-file fallthrough). + return RFC8908::EmitRedirect(req); + } + + // ETag / conditional request. Keep the quoted hash alive until after send. + std::string etag; + if (self->m_fsHash != nullptr) { + etag = std::string("\"") + self->m_fsHash + "\""; + char inm[80]; + if (httpd_req_get_hdr_value_str(req, "If-None-Match", inm, sizeof(inm)) == ESP_OK && etag == inm) { + httpd_resp_set_status(req, S304); + return httpd_resp_send(req, nullptr, 0); + } + } + + httpd_resp_set_type(req, contentTypeForPath(uri)); + httpd_resp_set_hdr(req, "Content-Encoding", "gzip"); + httpd_resp_set_hdr(req, "Cache-Control", "max-age=3600"); + if (!etag.empty()) { + httpd_resp_set_hdr(req, "ETag", etag.c_str()); + } + + bool ok = self->m_staticFs.readFile(path.c_str(), [req](std::span chunk) { return httpd_resp_send_chunk(req, reinterpret_cast(chunk.data()), chunk.size()) == ESP_OK; }); + if (!ok) { + // Headers/chunks may already be on the wire — can't cleanly redirect now. + return ESP_FAIL; + } + return httpd_resp_send_chunk(req, nullptr, 0); +} + +// --------------------------------------------------------------------------- +// WebSocket +// --------------------------------------------------------------------------- + +namespace { + // Per-session context so httpd's free callback can reap the fd->id slot on close. + struct WsSessCtx { + OpenShock::CaptivePortal::CaptivePortalInstance* self; + int fd; + }; + + // Deferred WS send marshalled onto the httpd task via httpd_queue_work. + struct WsSendJob { + httpd_handle_t hd; + int fd; + bool broadcast; + bool binary; + std::vector data; + }; + + void sendToWsClient(httpd_handle_t hd, int fd, httpd_ws_frame_t& frame) + { + if (httpd_ws_send_frame_async(hd, fd, &frame) != ESP_OK) { + // A client that can't take data (gone, asleep, buffers full) would otherwise stall every later send for the + // full send timeout; drop it, the web UI reconnects. + httpd_sess_trigger_close(hd, fd); + return; + } + + // httpd only counts inbound requests as activity, so a push-only WebSocket would look idle to the LRU purge. + httpd_sess_update_lru_counter(hd, fd); + } + + void wsSendWork(void* arg) + { + auto* job = static_cast(arg); + + httpd_ws_frame_t frame = {}; + frame.final = true; + frame.type = job->binary ? HTTPD_WS_TYPE_BINARY : HTTPD_WS_TYPE_TEXT; + frame.payload = job->data.data(); + frame.len = job->data.size(); + + if (job->broadcast) { + size_t count = 8; + int fds[8]; + if (httpd_get_client_list(job->hd, &count, fds) == ESP_OK) { + for (size_t i = 0; i < count; ++i) { + if (httpd_ws_get_fd_info(job->hd, fds[i]) == HTTPD_WS_CLIENT_WEBSOCKET) { + sendToWsClient(job->hd, fds[i], frame); + } + } + } + } else { + sendToWsClient(job->hd, job->fd, frame); + } + + delete job; + } +} // namespace + +int CaptivePortal::CaptivePortalInstance::idForFd(int fd) +{ + ScopedLock lock__(&m_clientsMutex); + for (uint8_t i = 0; i < MAX_WS_CLIENTS; ++i) { + if (m_clients[i].used && m_clients[i].fd == fd) { + return i; + } + } + return -1; +} + +int CaptivePortal::CaptivePortalInstance::fdForId(uint8_t socketId) +{ + ScopedLock lock__(&m_clientsMutex); + if (socketId < MAX_WS_CLIENTS && m_clients[socketId].used) { + return m_clients[socketId].fd; + } + return -1; +} + +uint8_t CaptivePortal::CaptivePortalInstance::onWsOpen(int fd) +{ + ScopedLock lock__(&m_clientsMutex); + + // Idempotent: an fd already tracked keeps its id. + for (uint8_t i = 0; i < MAX_WS_CLIENTS; ++i) { + if (m_clients[i].used && m_clients[i].fd == fd) { + return i; + } + } + + for (uint8_t i = 0; i < MAX_WS_CLIENTS; ++i) { + if (!m_clients[i].used) { + m_clients[i].used = true; + m_clients[i].fd = fd; + m_clients[i].reasmActive = false; + m_clients[i].reasmType = WebSocketMessageType::Binary; + m_clients[i].reasm.clear(); + return i; + } + } + + return 0xFF; // full +} + +void CaptivePortal::CaptivePortalInstance::onWsClose(int fd) +{ + int id = -1; + { + ScopedLock lock__(&m_clientsMutex); + for (uint8_t i = 0; i < MAX_WS_CLIENTS; ++i) { + if (m_clients[i].used && m_clients[i].fd == fd) { + m_clients[i].used = false; + m_clients[i].reasmActive = false; + m_clients[i].reasm.clear(); + id = i; + break; + } + } + } + + if (id >= 0) { + handleWebSocketClientDisconnected(static_cast(id)); + } +} + +void CaptivePortal::CaptivePortalInstance::wsSessCtxFree(void* ctx) +{ + auto* c = static_cast(ctx); + if (c == nullptr) { + return; + } + c->self->onWsClose(c->fd); + delete c; +} + +// httpd does not call the URI handler for the handshake (esp_http_server's httpd_uri.c), only this callback, right +// after it has switched the socket to WebSocket: allocate an id, arm the disconnect hook and greet the client. +esp_err_t CaptivePortal::CaptivePortalInstance::wsPostHandshake(httpd_req_t* req) +{ + auto* self = static_cast(req->user_ctx); + int fd = httpd_req_to_sockfd(req); + + uint8_t id = self->onWsOpen(fd); + if (id == 0xFF) { + OS_LOGW(TAG, "WebSocket client table full, rejecting fd %d", fd); + httpd_sess_trigger_close(self->m_server, fd); + return ESP_OK; + } + + auto* sessCtx = new WsSessCtx {self, fd}; + httpd_sess_set_ctx(self->m_server, fd, sessCtx, &CaptivePortalInstance::wsSessCtxFree); + + self->handleWebSocketClientConnected(req, id); + return ESP_OK; +} + +esp_err_t CaptivePortal::CaptivePortalInstance::wsHandler(httpd_req_t* req) +{ + auto* self = static_cast(req->user_ctx); + int fd = httpd_req_to_sockfd(req); + + // Data frame: two-call recv (length first, then payload). + httpd_ws_frame_t frame = {}; + esp_err_t ret = httpd_ws_recv_frame(req, &frame, 0); + if (ret != ESP_OK) { + return ret; + } + + if (frame.len == 0) { + self->onWsFrame(fd, frame.type, frame.final, {}); + return ESP_OK; + } + + if (frame.len > MAX_WS_MSG) { + OS_LOGE(TAG, "WebSocket frame too large (%u bytes), dropping", static_cast(frame.len)); + return ESP_FAIL; + } + + std::vector buf(frame.len); + frame.payload = buf.data(); + ret = httpd_ws_recv_frame(req, &frame, frame.len); + if (ret != ESP_OK) { + return ret; + } + + self->onWsFrame(fd, frame.type, frame.final, {buf.data(), frame.len}); + return ESP_OK; +} + +void CaptivePortal::CaptivePortalInstance::onWsFrame(int fd, httpd_ws_type_t opcode, bool final, std::span payload) +{ + int idx = idForFd(fd); + if (idx < 0) { + // Defensive: wsPostHandshake() registers every client, so a frame from an unknown fd should not happen. + uint8_t id = onWsOpen(fd); + if (id == 0xFF) { + return; + } + idx = id; + } + uint8_t socketId = static_cast(idx); + + switch (opcode) { + case HTTPD_WS_TYPE_TEXT: + case HTTPD_WS_TYPE_BINARY: + { + WebSocketMessageType type = (opcode == HTTPD_WS_TYPE_TEXT) ? WebSocketMessageType::Text : WebSocketMessageType::Binary; + { + ScopedLock lock__(&m_clientsMutex); + if (m_clients[idx].reasmActive) { + // A new message started before the previous fragmented one finished; drop the stale fragments + OS_LOGW(TAG, "WebSocket client %u started a new message mid-fragment, dropping the old one", socketId); + m_clients[idx].reasmActive = false; + m_clients[idx].reasm.clear(); + } + if (!final) { + m_clients[idx].reasmActive = true; + m_clients[idx].reasmType = type; + m_clients[idx].reasm.assign(payload.begin(), payload.end()); + break; + } + } + dispatchWsMessage(socketId, type, payload); + break; + } + case HTTPD_WS_TYPE_CONTINUE: + { + WebSocketMessageType type; + std::vector full; + { + ScopedLock lock__(&m_clientsMutex); + if (!m_clients[idx].reasmActive) { + OS_LOGW(TAG, "WebSocket client %u sent a continuation frame without a message start, dropping it", socketId); + break; + } + // MAX_WS_MSG only bounds single frames; also bound the reassembled message + if (m_clients[idx].reasm.size() + payload.size() > MAX_WS_MSG) { + OS_LOGE(TAG, "WebSocket client %u fragmented message exceeds %u bytes, closing", socketId, static_cast(MAX_WS_MSG)); + m_clients[idx].reasmActive = false; + m_clients[idx].reasm.clear(); + m_clients[idx].reasm.shrink_to_fit(); + httpd_sess_trigger_close(m_server, fd); + break; + } + m_clients[idx].reasm.insert(m_clients[idx].reasm.end(), payload.begin(), payload.end()); + if (!final) { + break; + } + type = m_clients[idx].reasmType; + full = std::move(m_clients[idx].reasm); + m_clients[idx].reasmActive = false; + m_clients[idx].reasm.clear(); + } + dispatchWsMessage(socketId, type, full); + break; + } + default: + // PING/PONG/CLOSE are handled by httpd (handle_ws_control_frames = false). + break; + } +} + +void CaptivePortal::CaptivePortalInstance::dispatchWsMessage(uint8_t socketId, WebSocketMessageType type, std::span payload) +{ + switch (type) { + case WebSocketMessageType::Binary: + MessageHandlers::WebSocket::HandleLocalBinary(socketId, payload); + break; + case WebSocketMessageType::Text: + OS_LOGE(TAG, "Text messages are not supported"); + break; + default: + break; + } +} + +void CaptivePortal::CaptivePortalInstance::handleWebSocketClientConnected(httpd_req_t* req, uint8_t socketId) +{ + OS_LOGD(TAG, "WebSocket client #%u connected (fd %d)", socketId, httpd_req_to_sockfd(req)); + + // We're on the httpd task with a live req — send directly, no copy/queue needed. + auto sendBin = [req](std::span data) -> bool { + httpd_ws_frame_t frame = {}; + frame.final = true; + frame.type = HTTPD_WS_TYPE_BINARY; + frame.payload = const_cast(data.data()); + frame.len = data.size(); + return httpd_ws_send_frame(req, &frame) == ESP_OK; + }; + + WiFiNetwork connectedNetwork; + WiFiNetwork* connectedNetworkPtr = nullptr; + if (WiFiManager::GetConnectedNetwork(connectedNetwork)) { + connectedNetworkPtr = &connectedNetwork; + } + + Serialization::Local::SerializeReadyMessage(connectedNetworkPtr, GatewayConnectionManager::IsLinked(), sendBin); + + // Send all previously scanned wifi networks + auto networks = OpenShock::WiFiManager::GetDiscoveredWiFiNetworks(); + Serialization::Local::SerializeWiFiNetworksEvent(Serialization::Types::WifiNetworkEventType::Discovered, networks, sendBin); + + // Nothing scanned yet (e.g. connected at boot, so no auto-scan ran): start one so the list isn't empty + if (networks.empty() && !WiFiScanManager::IsScanning()) { + WiFiScanManager::StartScan(); + } +} + +void CaptivePortal::CaptivePortalInstance::handleWebSocketClientDisconnected(uint8_t socketId) +{ + OS_LOGD(TAG, "WebSocket client #%u disconnected", socketId); +} + +// --------------------------------------------------------------------------- +// Public WS send API (called from arbitrary tasks → must copy + queue) +// --------------------------------------------------------------------------- + +bool CaptivePortal::CaptivePortalInstance::queueSend(int fd, bool broadcast, bool binary, std::span data) +{ + if (m_server == nullptr) { + return false; + } + + auto* job = new WsSendJob; + job->hd = m_server; + job->fd = fd; + job->broadcast = broadcast; + job->binary = binary; + job->data.assign(data.begin(), data.end()); + + if (httpd_queue_work(m_server, wsSendWork, job) != ESP_OK) { + delete job; + return false; + } + return true; +} + +bool CaptivePortal::CaptivePortalInstance::sendMessageTXT(uint8_t socketId, std::string_view data) +{ + int fd = fdForId(socketId); + if (fd < 0) { + return false; + } + return queueSend(fd, false, false, {reinterpret_cast(data.data()), data.size()}); +} + +bool CaptivePortal::CaptivePortalInstance::sendMessageBIN(uint8_t socketId, std::span data) +{ + int fd = fdForId(socketId); + if (fd < 0) { + return false; + } + return queueSend(fd, false, true, data); +} + +bool CaptivePortal::CaptivePortalInstance::broadcastMessageTXT(std::string_view data) +{ + return queueSend(-1, true, false, {reinterpret_cast(data.data()), data.size()}); +} + +bool CaptivePortal::CaptivePortalInstance::broadcastMessageBIN(std::span data) +{ + return queueSend(-1, true, true, data); +} + +bool CaptivePortal::CaptivePortalInstance::hasClients() +{ + ScopedLock lock__(&m_clientsMutex); + for (uint8_t i = 0; i < MAX_WS_CLIENTS; ++i) { + if (m_clients[i].used) { + return true; + } + } + return false; +} + +// --------------------------------------------------------------------------- +// HTTP server setup +// --------------------------------------------------------------------------- + +void CaptivePortal::CaptivePortalInstance::registerHandlers() +{ + auto reg = [this](const char* uri, httpd_method_t method, esp_err_t (*handler)(httpd_req_t*)) { + httpd_uri_t def = {}; + def.uri = uri; + def.method = method; + def.handler = handler; + def.user_ctx = this; + esp_err_t err = httpd_register_uri_handler(m_server, &def); + if (err != ESP_OK) { + OS_LOGE(TAG, "Failed to register handler %s: %s", uri, esp_err_to_name(err)); + } + }; + + // API endpoints (registered before the static wildcard so they take priority). + reg("/api/board", HTTP_GET, apiBoard); + reg("/api/portal/close", HTTP_POST, apiPortalClose); + reg("/api/wifi/scan", HTTP_POST, apiWifiScan); + reg("/api/wifi/networks", HTTP_DELETE, apiWifiNetworksDelete); + reg("/api/wifi/networks", HTTP_POST, apiWifiNetworksAdd); + reg("/api/wifi/connect", HTTP_POST, apiWifiConnect); + reg("/api/wifi/disconnect", HTTP_POST, apiWifiDisconnect); + reg("/api/account/link", HTTP_POST, apiAccountLink); + reg("/api/account", HTTP_DELETE, apiAccountDelete); + reg("/api/config/rf/pin", HTTP_PUT, apiConfigRfPin); + // No /api/config/estop/*: the portal is an open, unauthenticated AP, so the E-Stop is configured over serial only. + // No /api/ota/domain: the portal is an open, unauthenticated AP, and OTA trusts the hashes served by that domain, so + // changing it would let anyone in range install firmware. It can only be changed over serial (jsonconfig). + reg("/api/ota/settings", HTTP_PUT, apiOtaSettings); + reg("/api/ota/check", HTTP_POST, apiOtaCheck); + + // OS captive-detection probes + RFC 8908 endpoint + 404 redirect (rfc8908 component). + // Registered before the static wildcard so the specific probe paths take priority. + RFC8908::RegisterProbeHandlers(m_server, CaptivePortal::ApIPv4String()); + + // WebSocket. + httpd_uri_t ws = {}; + ws.uri = "/ws"; + ws.method = HTTP_GET; + ws.handler = &CaptivePortalInstance::wsHandler; + ws.user_ctx = this; + ws.is_websocket = true; + ws.handle_ws_control_frames = false; + ws.supported_subprotocol = "flatbuffers"; + ws.ws_post_handshake_cb = &CaptivePortalInstance::wsPostHandshake; + if (httpd_register_uri_handler(m_server, &ws) != ESP_OK) { + OS_LOGE(TAG, "Failed to register WebSocket handler"); + } + + // Static files — catch-all, registered LAST so specific routes win. + reg("/*", HTTP_GET, &CaptivePortalInstance::staticFileHandler); +} + +bool CaptivePortal::CaptivePortalInstance::startHttpServer() +{ + httpd_config_t config = HTTPD_DEFAULT_CONFIG(); + config.server_port = HTTP_PORT; + config.max_uri_handlers = 40; + // Browsers open several connections per page load; with only 4 slots the LRU purge evicted the (server-push, + // therefore "idle" looking) WebSocket. Needs CONFIG_LWIP_MAX_SOCKETS >= max_open_sockets + 3 (+ DNS/HTTP client). + config.max_open_sockets = 8; + config.lru_purge_enable = true; + config.stack_size = 8192; + // Shorter than the default 10 s: one stalled or sleeping client blocks the single httpd task for this long. + config.recv_wait_timeout = 5; + config.send_wait_timeout = 5; + config.uri_match_fn = httpd_uri_match_wildcard; + + esp_err_t err = httpd_start(&m_server, &config); + if (err != ESP_OK) { + OS_LOGE(TAG, "Failed to start HTTP server: %s", esp_err_to_name(err)); + m_server = nullptr; + return false; + } + + registerHandlers(); + return true; +} + +// --------------------------------------------------------------------------- +// Lifecycle +// --------------------------------------------------------------------------- + +CaptivePortal::CaptivePortalInstance::CaptivePortalInstance() + : m_server(nullptr) + , m_staticFs() + , m_fsHash(nullptr) + , m_dnsServer() + , m_clients {} + , m_clientsMutex() +{ + // Mount the static filesystem (gzipped portal assets) read-only via raw littlefs. + const esp_partition_t* partition = FindStaticPartition(); + if (partition == nullptr) { + OS_LOGE(TAG, "Failed to find static filesystem partition"); + } else if (!m_staticFs.mount(partition)) { + OS_LOGE(TAG, "Failed to mount static filesystem"); + } else if (!m_staticFs.exists("/www/index.html.gz")) { + OS_LOGE(TAG, "/www/index.html.gz not found — serving error page"); + } else { + m_fsHash = getPartitionHash(); + OS_LOGI(TAG, "Serving files from littlefs (hash: %s)", m_fsHash != nullptr ? m_fsHash : "?"); + } + + // Start the combined HTTP + WebSocket server on port 80. + if (!startHttpServer()) { + return; + } + + // Start the wildcard DNS responder (all A queries → the portal AP IP). + m_dnsStarted = m_dnsServer.start(CaptivePortal::ApIPv4String()); + if (!m_dnsStarted) { + OS_LOGE(TAG, "Failed to start DNS server"); + } +} + +CaptivePortal::CaptivePortalInstance::~CaptivePortalInstance() +{ + m_dnsServer.stop(); + + // An in-flight account link still owns an async request on this server; let it answer first (bounded by the HTTP + // client timeout) so it never sends on a stopped server. + for (int i = 0; i < 150 && s_accountLinkInProgress.load(); ++i) { + vTaskDelay(pdMS_TO_TICKS(100)); + } + + // Stop the server (closes all WS sockets, firing the session free callbacks). + if (m_server != nullptr) { + httpd_stop(m_server); + m_server = nullptr; + } + + m_staticFs.unmount(); +} diff --git a/components/captive_portal/src/captiveportal/CaptivePortalInstance.h b/components/captive_portal/src/captiveportal/CaptivePortalInstance.h new file mode 100644 index 00000000..c555c454 --- /dev/null +++ b/components/captive_portal/src/captiveportal/CaptivePortalInstance.h @@ -0,0 +1,85 @@ +#pragma once + +#include +#include + +#include "dns_server/DNSServer.h" +#include "enums/WebSocketMessageType.h" +#include "fs/StaticFs.h" +#include "OpenShock.h" +#include "SimpleMutex.h" + +#include + +#include +#include +#include +#include + +namespace OpenShock::CaptivePortal { + class CaptivePortalInstance { + DISABLE_COPY(CaptivePortalInstance); + DISABLE_MOVE(CaptivePortalInstance); + + public: + CaptivePortalInstance(); + ~CaptivePortalInstance(); + + // WS sends invoked from arbitrary tasks (WiFiManager, GatewayConnectionManager, + // the captive-portal manager task). httpd frames may only be written from the + // httpd task, so these copy the payload and marshal it via httpd_queue_work. + bool sendMessageTXT(uint8_t socketId, std::string_view data); + bool sendMessageBIN(uint8_t socketId, std::span data); + bool broadcastMessageTXT(std::string_view data); + bool broadcastMessageBIN(std::span data); + bool hasClients(); + + /// @brief Whether the HTTP and DNS servers came up; a failed instance must not be published. + inline bool ok() const { return m_server != nullptr && m_dnsStarted; } + + private: + static constexpr uint8_t MAX_WS_CLIENTS = 4; // matches AP max_connection + + struct WsClient { + bool used; + int fd; + bool reasmActive; // a fragmented message is in progress + WebSocketMessageType reasmType; // opcode of an in-progress fragmented message + std::vector reasm; // reassembly buffer for continuation frames + }; + + bool m_dnsStarted = false; + + // --- HTTP handlers that need instance state (recovered via req->user_ctx) --- + static esp_err_t wsPostHandshake(httpd_req_t* req); + static esp_err_t wsHandler(httpd_req_t* req); + static esp_err_t staticFileHandler(httpd_req_t* req); + + bool startHttpServer(); + void registerHandlers(); + + // --- WebSocket plumbing --- + uint8_t onWsOpen(int fd); + void onWsClose(int fd); + void onWsFrame(int fd, httpd_ws_type_t opcode, bool final, std::span payload); + void dispatchWsMessage(uint8_t socketId, WebSocketMessageType type, std::span payload); + void handleWebSocketClientConnected(httpd_req_t* req, uint8_t socketId); + void handleWebSocketClientDisconnected(uint8_t socketId); + + int fdForId(uint8_t socketId); + int idForFd(int fd); // -1 if not found + + bool queueSend(int fd, bool broadcast, bool binary, std::span data); + + // Per-session free callback fired by httpd on socket close/LRU/timeout. + static void wsSessCtxFree(void* ctx); + + httpd_handle_t m_server; + StaticFs m_staticFs; + const char* m_fsHash; + DNSServer m_dnsServer; + + WsClient m_clients[MAX_WS_CLIENTS]; + SimpleMutex m_clientsMutex; + }; +} // namespace OpenShock::CaptivePortal diff --git a/components/captive_portal/src/captiveportal/Manager.cpp b/components/captive_portal/src/captiveportal/Manager.cpp new file mode 100644 index 00000000..e114678d --- /dev/null +++ b/components/captive_portal/src/captiveportal/Manager.cpp @@ -0,0 +1,476 @@ +#include +#include +#include + +#include "captiveportal/Manager.h" + +const char* const TAG = "CaptivePortal"; + +#include "AppHooks.h" +#include "captiveportal/CaptivePortalInstance.h" +#include "CommandHandler.h" +#include "config/Config.h" +#include "FormatHelpers.h" +#include "GatewayConnectionManager.h" +#include "Logging.h" +#include "Temporal.h" + +#include +#include +#include +#include +#include + +#include "SimpleMutex.h" +#include "util/RetiredList.h" +#include "util/TaskUtils.h" + +#include +#include +#include + +using namespace OpenShock; + +using CaptivePortalInstance = CaptivePortal::CaptivePortalInstance; + +// The manager task owns the portal's lifecycle and all of its state. Other tasks never touch that state: they send +// commands as task-notification bits, which also wake the task, and the 500 ms timer sends kCmdTick. +enum : uint32_t { + kCmdTick = 1 << 0, + kCmdAlwaysEnabledSet = 1 << 1, // s_alwaysEnabled was changed; take over its value + kCmdCloseWhenOnline = 1 << 2, + kCmdForceClose = 1 << 3, + kCmdReleaseForceClose = 1 << 4, +}; + +// Only ever touched by the manager task. +struct ManagerState { + bool alwaysEnabled = false; + bool closeWhenOnline = false; // Setup finished or the backend disabled the portal: close it once online + bool forceClosed = false; // OTA is flashing the static filesystem; cleared by ReleaseForceClose() + bool forceCloseAckOwed = false; // ForceClose() is waiting for s_forceCloseDone + int64_t startupGraceEnd = 0; // esp_timer time until which the portal must not open; 0 = no grace period + int64_t autoCloseAt = 0; // esp_timer time at which an idle portal closes; 0 = not armed +}; + +static constexpr int64_t STARTUP_GRACE_PERIOD_US = 30LL * 1'000'000; // 30 seconds +static constexpr int64_t AUTO_CLOSE_DELAY_US = 5LL * 60 * 1'000'000; // 5 minutes + +// The requested always-enabled setting, and what IsAlwaysEnabled() reports. Written by SetAlwaysEnabled(). +static std::atomic s_alwaysEnabled = false; + +static TaskHandle_t s_managerTask = nullptr; +static esp_timer_handle_t s_captivePortalUpdateLoopTimer = nullptr; +static SemaphoreHandle_t s_forceCloseDone = nullptr; // Given by the manager task once force-closed + +// The running instance, read by any task that sends through it; only the manager task creates or retires it. +static SimpleMutex s_instanceMutex; +static std::shared_ptr s_instance = nullptr; +// Instances taken down whose destruction waits for other tasks to drop their references; destroyed by the manager task. +static RetiredList s_retiredInstances; +static esp_netif_t* s_apNetif = nullptr; + +// The captive portal AP always serves from this fixed address; the DNS server and +// RFC8908 handler reference it too (see CaptivePortal::ApIPv4String()). +static const char* const CAPTIVE_PORTAL_AP_IP = "4.3.2.1"; + +static void sendCommand(uint32_t command) +{ + if (s_managerTask != nullptr) { + xTaskNotify(s_managerTask, command, eSetBits); + } +} + +static bool isDeviceFullyConfigured() +{ + std::vector credentialsList; + if (!Config::GetWiFiCredentials(credentialsList) || credentialsList.empty()) { + return false; + } + return Config::HasBackendAuthToken(); +} + +static std::shared_ptr GetInstance() +{ + ScopedLock lock__(&s_instanceMutex); + return s_instance; +} +static bool CreateInstance() +{ + auto instance = std::make_shared(); + if (!instance->ok()) { + OS_LOGE(TAG, "Captive portal servers failed to start"); + return false; // `instance` is destroyed here, nothing was published + } + + ScopedLock lock__(&s_instanceMutex); + s_instance = std::move(instance); + return true; +} +// Unpublishes the instance and destroys it on this task. Other tasks may hold a copy for a moment while sending, so +// wait briefly for them to let go. Returns false if one still holds it; it is then destroyed on a later tick, still on +// this task, once released. +static bool DestroyInstance() +{ + { + ScopedLock lock__(&s_instanceMutex); + s_retiredInstances.retire(std::move(s_instance)); // Leaves s_instance null + } + + for (int i = 0; i < 200; ++i) { // up to ~2 s + if (s_retiredInstances.reap()) { + return true; + } + vTaskDelay(pdMS_TO_TICKS(10)); + } + + OS_LOGW(TAG, "Captive portal instance still referenced elsewhere, destroying it once released"); + return false; +} + +static bool captiveportal_start() +{ + if (GetInstance() != nullptr) { + OS_LOGD(TAG, "Already started"); + return true; + } + + OS_LOGI(TAG, "Starting captive portal"); + + // esp_wifi is already initialized by WiFiManager; create the AP netif once and + // pin it to a fixed IP so the DNS/HTTP portal has a stable address. + if (s_apNetif == nullptr) { + s_apNetif = esp_netif_create_default_wifi_ap(); + if (s_apNetif == nullptr) { + OS_LOGE(TAG, "Failed to create AP netif"); + return false; + } + + esp_netif_ip_info_t ipInfo = {}; + esp_netif_str_to_ip4(CAPTIVE_PORTAL_AP_IP, &ipInfo.ip); + esp_netif_str_to_ip4(CAPTIVE_PORTAL_AP_IP, &ipInfo.gw); + esp_netif_str_to_ip4("255.255.255.0", &ipInfo.netmask); + + esp_netif_dhcps_stop(s_apNetif); + esp_netif_set_ip_info(s_apNetif, &ipInfo); + + // DHCP option 114 (RFC 8910): points clients straight at the RFC 8908 captive-portal API, so modern Android/iOS + // can open the portal without waiting for their connectivity probes to be redirected. esp_netif keeps the + // pointer, so the string must outlive the netif. + static char captivePortalUri[64]; + snprintf(captivePortalUri, sizeof(captivePortalUri), "http://%s/captive-portal/api", CAPTIVE_PORTAL_AP_IP); // RFC8908Handler's API path + esp_err_t uriErr = esp_netif_dhcps_option(s_apNetif, ESP_NETIF_OP_SET, ESP_NETIF_CAPTIVEPORTAL_URI, captivePortalUri, strlen(captivePortalUri)); + if (uriErr != ESP_OK) { + OS_LOGW(TAG, "Failed to set DHCP captive portal URI: %s", esp_err_to_name(uriErr)); + } + + esp_netif_dhcps_start(s_apNetif); + } + + // AP SSID = prefix + this device's STA MAC (matches the old Arduino naming). + uint8_t mac[6]; + esp_read_mac(mac, ESP_MAC_WIFI_STA); + + wifi_config_t apConfig = {}; + snprintf(reinterpret_cast(apConfig.ap.ssid), sizeof(apConfig.ap.ssid), "%s" BSSID_FMT, CONFIG_OPENSHOCK_FW_AP_PREFIX, BSSID_ARG(mac)); + apConfig.ap.ssid_len = strlen(reinterpret_cast(apConfig.ap.ssid)); + apConfig.ap.channel = 1; + apConfig.ap.authmode = WIFI_AUTH_OPEN; + apConfig.ap.max_connection = 4; + apConfig.ap.beacon_interval = 100; + + esp_err_t err = esp_wifi_set_mode(WIFI_MODE_APSTA); + if (err != ESP_OK) { + OS_LOGE(TAG, "Failed to enable AP mode: %s", esp_err_to_name(err)); + return false; + } + + err = esp_wifi_set_config(WIFI_IF_AP, &apConfig); + if (err != ESP_OK) { + OS_LOGE(TAG, "Failed to configure AP: %s", esp_err_to_name(err)); + esp_wifi_set_mode(WIFI_MODE_STA); + return false; + } + + if (!CreateInstance()) { + esp_wifi_set_mode(WIFI_MODE_STA); + return false; + } + + return true; +} +static void captiveportal_stop(ManagerState& state) +{ + state.closeWhenOnline = false; + state.autoCloseAt = 0; + + if (GetInstance() == nullptr) { + OS_LOGD(TAG, "Already stopped"); + return; + } + + OS_LOGI(TAG, "Stopping captive portal"); + + DestroyInstance(); + + // Drop the AP but keep the STA connection alive. + esp_wifi_set_mode(WIFI_MODE_STA); +} + +static void applyCommands(ManagerState& state, uint32_t commands) +{ + if ((commands & kCmdAlwaysEnabledSet) != 0) { + state.alwaysEnabled = s_alwaysEnabled.load(std::memory_order_relaxed); + // Disabled from the backend: close once online rather than after the idle timeout + state.closeWhenOnline = !state.alwaysEnabled; + } + + if ((commands & kCmdCloseWhenOnline) != 0) { + state.closeWhenOnline = true; + } + + if ((commands & kCmdForceClose) != 0) { + state.forceClosed = true; + state.forceCloseAckOwed = true; + } + + // Handled after kCmdForceClose: a release always comes after the force-close it ends. + if ((commands & kCmdReleaseForceClose) != 0) { + state.forceClosed = false; + } +} + +// Runs the captive-portal state machine once. This does heavy work (starting the portal constructs +// CaptivePortalInstance, which mounts LittleFS and spins up the HTTP/WS/DNS servers), so it runs on the dedicated +// manager task, never on the esp_timer task, whose 3.5KB stack would overflow. +static void captiveportal_tick(ManagerState& state) +{ + // Destroy instances an earlier stop had to leave behind once their last other holder lets go. + bool allDestroyed = s_retiredInstances.reap(); + + // ForceClose() returns only once the portal is fully torn down, so answer it before anything else. + if (state.forceClosed) { + if (GetInstance() != nullptr) { + OS_LOGD(TAG, "Force-closing captive portal"); + captiveportal_stop(state); + allDestroyed = s_retiredInstances.reap(); + } + if (state.forceCloseAckOwed && allDestroyed) { + state.forceCloseAckOwed = false; + xSemaphoreGive(s_forceCloseDone); + } + return; + } + + int64_t now = esp_timer_get_time(); + bool connected = GatewayConnectionManager::IsConnected(); + + // Startup grace period: device is fully configured, wait for the gateway connection before opening the portal. + if (state.startupGraceEnd != 0) { + if (!connected && now < state.startupGraceEnd) { + return; + } + state.startupGraceEnd = 0; // Connected (the portal stays closed) or grace expired (it opens normally) + if (connected) { + return; + } + } + + bool running = false; + bool hasClients = false; + if (auto instance = GetInstance(); instance != nullptr) { + // Only sample it: holding the reference across captiveportal_stop() would stall DestroyInstance() + running = true; + hasClients = instance->hasClients(); + } + + if (state.closeWhenOnline && connected) { + if (running) { + OS_LOGI(TAG, "Setup completed or portal disabled, closing captive portal"); + } + captiveportal_stop(state); + return; + } + + // Auto-close: no clients connected, WiFi + gateway are up, 5 minutes elapsed + if (running && !state.alwaysEnabled && connected && !hasClients) { + if (state.autoCloseAt == 0) { + state.autoCloseAt = now + AUTO_CLOSE_DELAY_US; + } else if (now >= state.autoCloseAt) { + OS_LOGI(TAG, "Auto-closing captive portal AP (no clients for 5 minutes)"); + captiveportal_stop(state); + return; + } + } else { + state.autoCloseAt = 0; + } + + // Open portal if not running and device needs setup. Not while a retired instance still holds the static + // filesystem and ports 80/53: the new one would fail to start and flap the AP on every tick. + if (!running && allDestroyed && (state.alwaysEnabled || !CommandHandler::Ok() || !isDeviceFullyConfigured())) { + captiveportal_start(); + } +} + +static void captiveportal_managertask(void* arg) +{ + ManagerState state = *static_cast(arg); + delete static_cast(arg); + + for (;;) { + uint32_t commands = 0; + xTaskNotifyWait(0, UINT32_MAX, &commands, portMAX_DELAY); + + applyCommands(state, commands); + captiveportal_tick(state); + } +} + +// esp_timer callback (runs on the esp_timer task, 3.5KB stack). Must stay trivial: +// it only kicks the manager task, which does the real work on its own stack. +static void captiveportal_timernotify(void*) +{ + sendCommand(kCmdTick); +} + +bool CaptivePortal::Init() +{ + auto* initialState = new ManagerState(); + + // Restore the persisted always-enabled setting before the hook can change it, so it survives a reboot. + Config::CaptivePortalConfig config; + if (Config::GetCaptivePortalConfig(config)) { + initialState->alwaysEnabled = config.alwaysEnabled; + s_alwaysEnabled = config.alwaysEnabled; + } else { + OS_LOGE(TAG, "Failed to get captive portal config"); + } + + // If device is already fully configured, set a startup grace period before opening portal + if (isDeviceFullyConfigured()) { + initialState->startupGraceEnd = esp_timer_get_time() + STARTUP_GRACE_PERIOD_US; + OS_LOGI(TAG, "Device fully configured, startup grace period of 30s before opening portal"); + } + + s_forceCloseDone = xSemaphoreCreateBinary(); + if (s_forceCloseDone == nullptr) { + OS_LOGE(TAG, "Failed to create captive portal force-close semaphore"); + delete initialState; + return false; + } + + if (TaskUtils::TaskCreateExpensive(captiveportal_managertask, "CaptivePortalManager", 6144, initialState, 1, &s_managerTask) != pdPASS) { + OS_LOGE(TAG, "Failed to create captive portal manager task"); + delete initialState; + return false; + } + + AppHooks::RegisterCaptivePortal(CaptivePortal::BroadcastMessageBIN, CaptivePortal::SetAlwaysEnabled); + + esp_timer_create_args_t args = { + .callback = captiveportal_timernotify, + .arg = nullptr, + .dispatch_method = ESP_TIMER_TASK, + .name = "captive_portal_update", + .skip_unhandled_events = true, + }; + + esp_err_t err; + + err = esp_timer_create(&args, &s_captivePortalUpdateLoopTimer); + if (err != ESP_OK) { + OS_LOGE(TAG, "Failed to create captive portal update timer"); + return false; + } + + err = esp_timer_start_periodic(s_captivePortalUpdateLoopTimer, 500'000); // 500ms + if (err != ESP_OK) { + OS_LOGE(TAG, "Failed to start captive portal update timer"); + return false; + } + + return true; +} + +void CaptivePortal::SetUserDone() +{ + sendCommand(kCmdCloseWhenOnline); +} + +void CaptivePortal::SetAlwaysEnabled(bool alwaysEnabled) +{ + s_alwaysEnabled = alwaysEnabled; + Config::SetCaptivePortalConfig(Config::CaptivePortalConfig(alwaysEnabled)); + sendCommand(kCmdAlwaysEnabledSet); +} +bool CaptivePortal::IsAlwaysEnabled() +{ + return s_alwaysEnabled; +} + +bool CaptivePortal::ForceClose(uint32_t timeoutMs) +{ + if (s_managerTask == nullptr) { + return GetInstance() == nullptr; + } + + xSemaphoreTake(s_forceCloseDone, 0); // Drop an acknowledgement left over from an earlier, timed-out call + sendCommand(kCmdForceClose); + + // The manager task stops the portal and answers once it is fully torn down (filesystem unmounted). It stays + // force-closed even if this times out. + return xSemaphoreTake(s_forceCloseDone, pdMS_TO_TICKS(timeoutMs)) == pdTRUE; +} + +void CaptivePortal::ReleaseForceClose() +{ + sendCommand(kCmdReleaseForceClose); +} + +bool CaptivePortal::IsRunning() +{ + return GetInstance() != nullptr; +} + +const char* CaptivePortal::ApIPv4String() +{ + return CAPTIVE_PORTAL_AP_IP; +} + +bool CaptivePortal::SendMessageTXT(uint8_t socketId, std::string_view data) +{ + auto instance = GetInstance(); + if (instance == nullptr) return false; + + instance->sendMessageTXT(socketId, data); + + return true; +} +bool CaptivePortal::SendMessageBIN(uint8_t socketId, std::span data) +{ + auto instance = GetInstance(); + if (instance == nullptr) return false; + + instance->sendMessageBIN(socketId, data); + + return true; +} + +bool CaptivePortal::BroadcastMessageTXT(std::string_view data) +{ + auto instance = GetInstance(); + if (instance == nullptr) return false; + + instance->broadcastMessageTXT(data); + + return true; +} +bool CaptivePortal::BroadcastMessageBIN(std::span data) +{ + auto instance = GetInstance(); + if (instance == nullptr) return false; + + instance->broadcastMessageBIN(data); + + return true; +} diff --git a/components/chipset/CMakeLists.txt b/components/chipset/CMakeLists.txt new file mode 100644 index 00000000..7d9e8586 --- /dev/null +++ b/components/chipset/CMakeLists.txt @@ -0,0 +1,16 @@ +# OpenShock chipset utilities — GPIO validation, compile-time pin assertions. +# +# Public surface: +# Chipset.h — OpenShock::IsValidInputPin / IsValidOutputPin per IDF_TARGET +# +# CompatibilityChecks.cpp contains static_asserts that fail the build if any +# board pin (OPENSHOCK_ESTOP_PIN, OPENSHOCK_RF_TX_GPIO, ... from openshock_board.h) resolves to +# an invalid pin for the current chip. It has to be in the link for the +# asserts to run; no runtime code. + +idf_component_register( + SRCS "src/CompatibilityChecks.cpp" + INCLUDE_DIRS "include" + REQUIRES common # Common.h + esp_driver_gpio # driver/gpio.h +) diff --git a/include/Chipset.h b/components/chipset/include/Chipset.h similarity index 91% rename from include/Chipset.h rename to components/chipset/include/Chipset.h index 61ffbfde..071016ef 100644 --- a/include/Chipset.h +++ b/components/chipset/include/Chipset.h @@ -1,6 +1,6 @@ #pragma once -#include "Common.h" +#include "OpenShock.h" #include @@ -73,13 +73,14 @@ // GPIO1, GPIO3 is used for UART0 RXD/TXD. // GPIO0, GPIO2 is used to control the boot mode of the chip. // GPIO5, GPIO15 is used for SDIO slave timing selection. -// GPIO6, GPIO7, GPIO8, GPIO9, GPIO11, GPIO16, GPIO17 is used for SPI flash connection. (DO NOT TOUCH) +// GPIO12 (MTDI) selects the flash voltage at boot; an external pull-up selects 1.8 V and the board won't boot. +// GPIO6, GPIO7, GPIO8, GPIO9, GPIO10, GPIO11, GPIO16, GPIO17 is used for SPI flash connection (GPIO10 as SD_DATA3 in QIO mode). (DO NOT TOUCH) // // See: ESP32 Series Datasheet Version 4.3 Section 2.2 Pin Overview // See: ESP32 Series Datasheet Version 4.3 Section 2.4 Strapping Pins #define CHIP_UNSAFE_GPIO(pin) \ - ((pin) == GPIO_NUM_1 || (pin) == GPIO_NUM_3 || (pin) == GPIO_NUM_0 || (pin) == GPIO_NUM_2 || (pin) == GPIO_NUM_5 || (pin) == GPIO_NUM_15 || (pin) == GPIO_NUM_6 || (pin) == GPIO_NUM_7 || (pin) == GPIO_NUM_8 || (pin) == GPIO_NUM_9 || (pin) == GPIO_NUM_11 \ - || (pin) == GPIO_NUM_16 || (pin) == GPIO_NUM_17) + ((pin) == GPIO_NUM_1 || (pin) == GPIO_NUM_3 || (pin) == GPIO_NUM_0 || (pin) == GPIO_NUM_2 || (pin) == GPIO_NUM_5 || (pin) == GPIO_NUM_15 || (pin) == GPIO_NUM_12 || (pin) == GPIO_NUM_6 || (pin) == GPIO_NUM_7 || (pin) == GPIO_NUM_8 || (pin) == GPIO_NUM_9 \ + || (pin) == GPIO_NUM_10 || (pin) == GPIO_NUM_11 || (pin) == GPIO_NUM_16 || (pin) == GPIO_NUM_17) #endif // ESP32-PICO-D4 @@ -90,11 +91,12 @@ #ifdef OPENSHOCK_FW_CHIP_ESP32PICOD4 #define OPENSHOCK_FW_CHIP_DEFINED // GPIO3, GPIO1 is used for UART0 RXD/TXD. -// GPIO25, GPIO27, GPIO29, GPIO30, GPIO31, GPIO32, GPIO33 is used for SPI flash connection. (DO NOT TOUCH) +// GPIO6, GPIO7, GPIO8, GPIO9, GPIO10, GPIO11, GPIO16, GPIO17 are used for the embedded SPI flash connection. (DO NOT TOUCH) +// (The datasheet lists these by package pin number, 25/27/29-33, not GPIO number.) // GPIO12, GPIO0, GPIO2, GPIO15, and GPIO5 are used for boot mode and SDIO slave timing selection. -#define CHIP_UNSAFE_GPIO(pin) \ - ((pin) == GPIO_NUM_3 || (pin) == GPIO_NUM_1 || (pin) == GPIO_NUM_25 || (pin) == GPIO_NUM_27 || (pin) == GPIO_NUM_29 || (pin) == GPIO_NUM_30 || (pin) == GPIO_NUM_31 || (pin) == GPIO_NUM_32 || (pin) == GPIO_NUM_33 || (pin) == GPIO_NUM_12 \ - || (pin) == GPIO_NUM_0 || (pin) == GPIO_NUM_2 || (pin) == GPIO_NUM_15 || (pin) == GPIO_NUM_5) +#define CHIP_UNSAFE_GPIO(pin) \ + ((pin) == GPIO_NUM_3 || (pin) == GPIO_NUM_1 || (pin) == GPIO_NUM_6 || (pin) == GPIO_NUM_7 || (pin) == GPIO_NUM_8 || (pin) == GPIO_NUM_9 || (pin) == GPIO_NUM_10 || (pin) == GPIO_NUM_11 || (pin) == GPIO_NUM_16 || (pin) == GPIO_NUM_17 \ + || (pin) == GPIO_NUM_12 || (pin) == GPIO_NUM_0 || (pin) == GPIO_NUM_2 || (pin) == GPIO_NUM_15 || (pin) == GPIO_NUM_5) #endif // ESP32-PICO-V3 @@ -295,26 +297,6 @@ namespace OpenShock { } return pins; } - constexpr GPIOPinSet GetValidInputPins() - { - GPIOPinSet pins; - for (uint8_t i = 0; i < GPIO_NUM_MAX; i++) { - if (IsValidInputPin(i)) { - pins.set(i); - } - } - return pins; - } - constexpr GPIOPinSet GetValidOutputPins() - { - GPIOPinSet pins; - for (uint8_t i = 0; i < GPIO_NUM_MAX; i++) { - if (IsValidOutputPin(i)) { - pins.set(i); - } - } - return pins; - } inline std::vector GetValidInputPinsVector() { std::vector pins; diff --git a/components/chipset/src/CompatibilityChecks.cpp b/components/chipset/src/CompatibilityChecks.cpp new file mode 100644 index 00000000..4f377fb3 --- /dev/null +++ b/components/chipset/src/CompatibilityChecks.cpp @@ -0,0 +1,18 @@ +#include "Chipset.h" +#include "OpenShock.h" + +// Board pins come from openshock_board.h (OPENSHOCK_*, generated from boards/.defaults) and always have a value +// (-1 == OPENSHOCK_GPIO_INVALID when the board has no such pin). A pin is valid +// if it's a usable GPIO or explicitly bypassed with INVALID; anything else is a +// misconfiguration and fails the build. +const bool kIsValidOrUndefinedRfTxPin = OpenShock::IsValidOutputPin(OPENSHOCK_RF_TX_GPIO) || OPENSHOCK_RF_TX_GPIO == OPENSHOCK_GPIO_INVALID; +static_assert(kIsValidOrUndefinedRfTxPin, "OPENSHOCK_RF_TX_GPIO is not a valid output GPIO, and is not declared as bypassed by board specific definitions, refusing to compile"); + +const bool kIsValidOrUndefinedEStopPin = OpenShock::IsValidInputPin(OPENSHOCK_ESTOP_PIN) || OPENSHOCK_ESTOP_PIN == OPENSHOCK_GPIO_INVALID; +static_assert(kIsValidOrUndefinedEStopPin, "OPENSHOCK_ESTOP_PIN is not a valid input GPIO, and is not declared as bypassed by board specific definitions, refusing to compile"); + +const bool kIsValidOrUndefinedLedPin = OpenShock::IsValidOutputPin(OPENSHOCK_LED_GPIO) || OPENSHOCK_LED_GPIO == OPENSHOCK_GPIO_INVALID; +static_assert(kIsValidOrUndefinedLedPin, "OPENSHOCK_LED_GPIO is not a valid output GPIO, and is not declared as bypassed by board specific definitions, refusing to compile"); + +const bool kIsValidOrUndefinedWs2812bPin = OpenShock::IsValidOutputPin(OPENSHOCK_LED_WS2812B) || OPENSHOCK_LED_WS2812B == OPENSHOCK_GPIO_INVALID; +static_assert(kIsValidOrUndefinedWs2812bPin, "OPENSHOCK_LED_WS2812B is not a valid output GPIO, and is not declared as bypassed by board specific definitions, refusing to compile"); diff --git a/components/common/CMakeLists.txt b/components/common/CMakeLists.txt new file mode 100644 index 00000000..70108e44 --- /dev/null +++ b/components/common/CMakeLists.txt @@ -0,0 +1,28 @@ +# OpenShock foundation utilities — language helpers, format conversions, +# containers, string helpers, and the FreeRTOS-backed concurrency primitives. +# Absorbed the former `util` and `concurrency` components. The mbedTLS-dependent +# helpers (Hashing.h, Base64) live in the separate `crypto` component. +# +# Public surface (include/): +# Common.h / FormatHelpers.h / OpenShock.h — basic defines and macros +# enums/*.h — shared enums (consumers include with the enums/ prefix) +# Convert.h, SemVer.h, Checksum.h, RateLimiter.h, TinyVec.h, +# StringHelpers.h, SimpleMutex.h, ReadWriteMutex.h, util/*.h + +idf_component_register( + SRCS "src/Convert.cpp" + "src/DigitCounter.cpp" + "src/RateLimiter.cpp" + "src/ReadWriteMutex.cpp" + "src/SemVer.cpp" + "src/SimpleMutex.cpp" + "src/StringHelpers.cpp" + "src/TaskUtils.cpp" + # The only TU that sees openshock_version.h, so a new commit rebuilds + # this file rather than the whole project. + "src/Version.cpp" + INCLUDE_DIRS "include" + REQUIRES logging # Logging.h / OS_PANIC* (TinyVec.h) in public headers + temporal # Temporal.h (RateLimiter.h) in public header + freertos # semphr.h in SimpleMutex.h / ReadWriteMutex.h public headers +) diff --git a/components/common/host_test/CMakeLists.txt b/components/common/host_test/CMakeLists.txt new file mode 100644 index 00000000..31fb361a --- /dev/null +++ b/components/common/host_test/CMakeLists.txt @@ -0,0 +1,15 @@ +cmake_minimum_required(VERSION 3.16) + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) + +# Only build 'main'; the common headers are pulled in by include path (they are +# header-only, so no firmware component needs to be linked). +set(COMPONENTS main) + +# linux-target freertos mock, so unity's IDF integration has its dependencies, and +# the shared host-test stand-ins (Logging.h, openshock_board.h, CONFIG_OPENSHOCK_*). +list(APPEND EXTRA_COMPONENT_DIRS + "$ENV{IDF_PATH}/tools/mocks/freertos/" + "${CMAKE_CURRENT_LIST_DIR}/../../../test/host_support") + +project(common_host_test) diff --git a/components/common/host_test/main/CMakeLists.txt b/components/common/host_test/main/CMakeLists.txt new file mode 100644 index 00000000..54228c63 --- /dev/null +++ b/components/common/host_test/main/CMakeLists.txt @@ -0,0 +1,27 @@ +# Compile common's PURE sources directly; Logging.h and the generated +# openshock_board.h that OpenShock.h includes come from test/host_support, which +# also supplies the real CONFIG_OPENSHOCK_* Kconfig values. Absorbed the former +# `util` host test after util was merged into common. +idf_component_register( + SRCS "test_main.cpp" + "test_enums.cpp" + "test_stringhelpers.cpp" + "test_stringutils.cpp" + "test_convert.cpp" + "test_hexutils.cpp" + "test_digitcounter.cpp" + "test_semver.cpp" + "test_checksum.cpp" + "test_tinyvec.cpp" + "test_fnproxy.cpp" + "../../src/StringHelpers.cpp" # StringSplit/StringIContains/FormatToString (out-of-line) + "../../src/Convert.cpp" + "../../src/DigitCounter.cpp" + "../../src/SemVer.cpp" + INCLUDE_DIRS "." + "../../include" # common's headers (enums/*, Convert.h, SemVer.h, util/*.h) + REQUIRES unity + host_support # Logging.h, openshock_board.h (test/host_support) + esp_hal_gpio # Convert.h -> hal/gpio_types.h (moved out of `hal` in IDF 6.0) + WHOLE_ARCHIVE # keep the auto-registered TEST_CASEs from being stripped +) diff --git a/components/common/host_test/main/test_checksum.cpp b/components/common/host_test/main/test_checksum.cpp new file mode 100644 index 00000000..9924425c --- /dev/null +++ b/components/common/host_test/main/test_checksum.cpp @@ -0,0 +1,53 @@ +// Checksum::Sum8 (byte sum) backs the RF protocol frames; ReverseNibble / +// ReverseInverseNibble are lookup-table bit reversals used by some encoders. +#include "unity.h" + +#include "Checksum.h" + +#include + +using namespace OpenShock; + +// Independent 4-bit reversal for cross-checking the table-driven versions. +static uint8_t rev4(uint8_t b) +{ + return ((b & 1) << 3) | ((b & 2) << 1) | ((b & 4) >> 1) | ((b & 8) >> 3); +} + +TEST_CASE("Sum8 over a byte span wraps at 8 bits", "[util][checksum]") +{ + const uint8_t d[] = {1, 2, 3, 4, 250}; // 260 -> 0x04 + TEST_ASSERT_EQUAL_UINT8(4, Checksum::Sum8(d, sizeof(d))); +} + +TEST_CASE("Sum8 over an integral sums its bytes", "[util][checksum]") +{ + TEST_ASSERT_EQUAL_UINT8(10, Checksum::Sum8(static_cast(0x01020304))); // 1+2+3+4 + TEST_ASSERT_EQUAL_UINT8(0xFF, Checksum::Sum8(static_cast(0xFF))); + TEST_ASSERT_EQUAL_UINT8(0, Checksum::Sum8(static_cast(0))); +} + +TEST_CASE("Sum8 over a trivially copyable struct", "[util][checksum]") +{ + struct Packed { + uint8_t a, b, c, d; + } p {10, 20, 30, 40}; + TEST_ASSERT_EQUAL_UINT8(100, Checksum::Sum8(p)); + + // Usable in constant expressions + static_assert(Checksum::Sum8(Packed {1, 2, 3, 4}) == 10); +} + +TEST_CASE("ReverseNibble reverses the low nibble bit order", "[util][checksum]") +{ + for (uint8_t b = 0; b < 16; b++) { + TEST_ASSERT_EQUAL_UINT8(rev4(b), Checksum::ReverseNibble(b)); + } +} + +TEST_CASE("ReverseInverseNibble reverses the inverted nibble", "[util][checksum]") +{ + for (uint8_t b = 0; b < 16; b++) { + TEST_ASSERT_EQUAL_UINT8(rev4((~b) & 0xF), Checksum::ReverseInverseNibble(b)); + } +} diff --git a/components/common/host_test/main/test_convert.cpp b/components/common/host_test/main/test_convert.cpp new file mode 100644 index 00000000..8efbf4dc --- /dev/null +++ b/components/common/host_test/main/test_convert.cpp @@ -0,0 +1,77 @@ +// Convert::To* string -> integer/bool parsers (used for serial args, config, +// backend responses). Range enforcement matters: an out-of-range shocker id or +// intensity must be rejected, not truncated. +#include "unity.h" + +#include "Convert.h" + +#include + +using namespace OpenShock; + +TEST_CASE("Convert::ToUint8 range", "[util][convert]") +{ + uint8_t v = 0; + TEST_ASSERT_TRUE(Convert::ToUint8("0", v)); + TEST_ASSERT_EQUAL_UINT8(0, v); + TEST_ASSERT_TRUE(Convert::ToUint8("255", v)); + TEST_ASSERT_EQUAL_UINT8(255, v); + TEST_ASSERT_TRUE(Convert::ToUint8("42", v)); + TEST_ASSERT_EQUAL_UINT8(42, v); + + TEST_ASSERT_FALSE(Convert::ToUint8("256", v)); // overflow + TEST_ASSERT_FALSE(Convert::ToUint8("-1", v)); // negative + TEST_ASSERT_FALSE(Convert::ToUint8("", v)); // empty + TEST_ASSERT_FALSE(Convert::ToUint8("abc", v)); // not a number + TEST_ASSERT_FALSE(Convert::ToUint8("12x", v)); // trailing junk +} + +TEST_CASE("Convert::ToInt8 signed range", "[util][convert]") +{ + int8_t v = 0; + TEST_ASSERT_TRUE(Convert::ToInt8("-128", v)); + TEST_ASSERT_EQUAL_INT8(-128, v); + TEST_ASSERT_TRUE(Convert::ToInt8("127", v)); + TEST_ASSERT_EQUAL_INT8(127, v); + + TEST_ASSERT_FALSE(Convert::ToInt8("128", v)); + TEST_ASSERT_FALSE(Convert::ToInt8("-129", v)); +} + +TEST_CASE("Convert::ToUint16 / ToInt32 boundaries", "[util][convert]") +{ + uint16_t u16 = 0; + TEST_ASSERT_TRUE(Convert::ToUint16("65535", u16)); + TEST_ASSERT_EQUAL_UINT16(65535, u16); + TEST_ASSERT_FALSE(Convert::ToUint16("65536", u16)); + + int32_t i32 = 0; + TEST_ASSERT_TRUE(Convert::ToInt32("-2147483648", i32)); + TEST_ASSERT_EQUAL_INT32(INT32_MIN, i32); + TEST_ASSERT_TRUE(Convert::ToInt32("2147483647", i32)); + TEST_ASSERT_EQUAL_INT32(INT32_MAX, i32); + TEST_ASSERT_FALSE(Convert::ToInt32("2147483648", i32)); +} + +TEST_CASE("Convert::ToUint64 large values", "[util][convert]") +{ + uint64_t v = 0; + TEST_ASSERT_TRUE(Convert::ToUint64("18446744073709551615", v)); // UINT64_MAX + TEST_ASSERT_EQUAL_UINT64(UINT64_MAX, v); + TEST_ASSERT_FALSE(Convert::ToUint64("18446744073709551616", v)); // overflow +} + +TEST_CASE("Convert::ToBool", "[util][convert]") +{ + bool b = false; + TEST_ASSERT_TRUE(Convert::ToBool("true", b)); + TEST_ASSERT_TRUE(b); + TEST_ASSERT_TRUE(Convert::ToBool("false", b)); + TEST_ASSERT_FALSE(b); + TEST_ASSERT_TRUE(Convert::ToBool("TRUE", b)); // case-insensitive + TEST_ASSERT_TRUE(b); + + TEST_ASSERT_FALSE(Convert::ToBool("yes", b)); + TEST_ASSERT_FALSE(Convert::ToBool("1", b)); + TEST_ASSERT_FALSE(Convert::ToBool("", b)); +} diff --git a/components/common/host_test/main/test_digitcounter.cpp b/components/common/host_test/main/test_digitcounter.cpp new file mode 100644 index 00000000..f178e3af --- /dev/null +++ b/components/common/host_test/main/test_digitcounter.cpp @@ -0,0 +1,27 @@ +// Util::Digits10Count - base-10 digit count (incl. sign) used to size buffers. +#include "unity.h" + +#include "util/DigitCounter.h" + +#include + +using namespace OpenShock; + +TEST_CASE("Digits10Count unsigned", "[util][digits]") +{ + TEST_ASSERT_EQUAL_size_t(1, Util::Digits10Count(0)); + TEST_ASSERT_EQUAL_size_t(1, Util::Digits10Count(9)); + TEST_ASSERT_EQUAL_size_t(2, Util::Digits10Count(10)); + TEST_ASSERT_EQUAL_size_t(3, Util::Digits10Count(999)); + TEST_ASSERT_EQUAL_size_t(4, Util::Digits10Count(1000)); + TEST_ASSERT_EQUAL_size_t(20, Util::Digits10Count(UINT64_MAX)); +} + +TEST_CASE("Digits10Count signed counts the sign", "[util][digits]") +{ + TEST_ASSERT_EQUAL_size_t(1, Util::Digits10Count(0)); + TEST_ASSERT_EQUAL_size_t(1, Util::Digits10Count(7)); + TEST_ASSERT_EQUAL_size_t(2, Util::Digits10Count(-1)); // '-' + '1' + TEST_ASSERT_EQUAL_size_t(4, Util::Digits10Count(-123)); + TEST_ASSERT_EQUAL_size_t(11, Util::Digits10Count(INT32_MIN)); // -2147483648 +} diff --git a/components/common/host_test/main/test_enums.cpp b/components/common/host_test/main/test_enums.cpp new file mode 100644 index 00000000..990188fe --- /dev/null +++ b/components/common/host_test/main/test_enums.cpp @@ -0,0 +1,163 @@ +// Parser coverage for the shared enum headers. These decide how user/backend +// strings map to shocker models, commands, OTA channels/steps and boot types - +// getting them wrong misroutes real commands, so pin every accepted spelling. +#include "unity.h" + +#include "enums/FirmwareBootType.h" +#include "enums/OtaUpdateChannel.h" +#include "enums/OtaUpdateStep.h" +#include "enums/ShockerCommandType.h" +#include "enums/ShockerModelType.h" + +using namespace OpenShock; + +// ---- ShockerModelType ------------------------------------------------------ + +TEST_CASE("ShockerModelType: all accepted spellings", "[common][enums]") +{ + ShockerModelType m; + TEST_ASSERT_TRUE(TryParseShockerModelType(m, "caixianlin")); + TEST_ASSERT_EQUAL(ShockerModelType::CaiXianlin, m); + TEST_ASSERT_TRUE(TryParseShockerModelType(m, "cai-xianlin")); + TEST_ASSERT_EQUAL(ShockerModelType::CaiXianlin, m); + TEST_ASSERT_TRUE(TryParseShockerModelType(m, "petrainer")); + TEST_ASSERT_EQUAL(ShockerModelType::Petrainer, m); + TEST_ASSERT_TRUE(TryParseShockerModelType(m, "petrainer998dr")); + TEST_ASSERT_EQUAL(ShockerModelType::Petrainer998DR, m); + TEST_ASSERT_TRUE(TryParseShockerModelType(m, "wellturnt330")); + TEST_ASSERT_EQUAL(ShockerModelType::WellturnT330, m); + TEST_ASSERT_TRUE(TryParseShockerModelType(m, "t330")); + TEST_ASSERT_EQUAL(ShockerModelType::WellturnT330, m); + TEST_ASSERT_TRUE(TryParseShockerModelType(m, "d80")); + TEST_ASSERT_EQUAL(ShockerModelType::D80, m); +} + +TEST_CASE("ShockerModelType: case-insensitive", "[common][enums]") +{ + ShockerModelType m; + TEST_ASSERT_TRUE(TryParseShockerModelType(m, "CaiXianlin")); + TEST_ASSERT_EQUAL(ShockerModelType::CaiXianlin, m); + TEST_ASSERT_TRUE(TryParseShockerModelType(m, "PETRAINER")); + TEST_ASSERT_EQUAL(ShockerModelType::Petrainer, m); + TEST_ASSERT_TRUE(TryParseShockerModelType(m, "T330")); + TEST_ASSERT_EQUAL(ShockerModelType::WellturnT330, m); +} + +TEST_CASE("ShockerModelType: the 'pettrainer' typo only with allowTypo", "[common][enums]") +{ + ShockerModelType m; + TEST_ASSERT_FALSE(TryParseShockerModelType(m, "pettrainer")); // default: rejected + TEST_ASSERT_FALSE(TryParseShockerModelType(m, "pettrainer998dr")); + + TEST_ASSERT_TRUE(TryParseShockerModelType(m, "pettrainer", true)); // allowTypo: accepted + TEST_ASSERT_EQUAL(ShockerModelType::Petrainer, m); + TEST_ASSERT_TRUE(TryParseShockerModelType(m, "pettrainer998dr", true)); + TEST_ASSERT_EQUAL(ShockerModelType::Petrainer998DR, m); +} + +TEST_CASE("ShockerModelType: rejects unknown and empty", "[common][enums]") +{ + ShockerModelType m; + TEST_ASSERT_FALSE(TryParseShockerModelType(m, "")); + TEST_ASSERT_FALSE(TryParseShockerModelType(m, "nope")); + TEST_ASSERT_FALSE(TryParseShockerModelType(m, "cai")); // prefix, not a full match +} + +// ---- ShockerCommandType ---------------------------------------------------- + +TEST_CASE("ShockerCommandType: all commands + case-insensitive", "[common][enums]") +{ + ShockerCommandType c; + TEST_ASSERT_TRUE(TryParseShockerCommandType(c, "stop")); + TEST_ASSERT_EQUAL(ShockerCommandType::Stop, c); + TEST_ASSERT_TRUE(TryParseShockerCommandType(c, "shock")); + TEST_ASSERT_EQUAL(ShockerCommandType::Shock, c); + TEST_ASSERT_TRUE(TryParseShockerCommandType(c, "vibrate")); + TEST_ASSERT_EQUAL(ShockerCommandType::Vibrate, c); + TEST_ASSERT_TRUE(TryParseShockerCommandType(c, "sound")); + TEST_ASSERT_EQUAL(ShockerCommandType::Sound, c); + TEST_ASSERT_TRUE(TryParseShockerCommandType(c, "LIGHT")); + TEST_ASSERT_EQUAL(ShockerCommandType::Light, c); +} + +TEST_CASE("ShockerCommandType: rejects unknown/empty", "[common][enums]") +{ + ShockerCommandType c; + TEST_ASSERT_FALSE(TryParseShockerCommandType(c, "")); + TEST_ASSERT_FALSE(TryParseShockerCommandType(c, "zap")); +} + +// ---- OtaUpdateChannel (string_view API) ------------------------------------ + +TEST_CASE("OtaUpdateChannel: stable/beta/develop/dev + case", "[common][enums]") +{ + OtaUpdateChannel ch; + TEST_ASSERT_TRUE(TryParseOtaUpdateChannel(ch, "stable")); + TEST_ASSERT_EQUAL(OtaUpdateChannel::Stable, ch); + TEST_ASSERT_TRUE(TryParseOtaUpdateChannel(ch, "Beta")); + TEST_ASSERT_EQUAL(OtaUpdateChannel::Beta, ch); + TEST_ASSERT_TRUE(TryParseOtaUpdateChannel(ch, "develop")); + TEST_ASSERT_EQUAL(OtaUpdateChannel::Develop, ch); + TEST_ASSERT_TRUE(TryParseOtaUpdateChannel(ch, "DEV")); + TEST_ASSERT_EQUAL(OtaUpdateChannel::Develop, ch); + + TEST_ASSERT_FALSE(TryParseOtaUpdateChannel(ch, "")); + TEST_ASSERT_FALSE(TryParseOtaUpdateChannel(ch, "nightly")); + TEST_ASSERT_FALSE(TryParseOtaUpdateChannel(ch, "stabl")); // prefix, not full +} + +// ---- OtaUpdateStep --------------------------------------------------------- + +TEST_CASE("OtaUpdateStep: every step + rejects unknown", "[common][enums]") +{ + OtaUpdateStep s; + TEST_ASSERT_TRUE(TryParseOtaUpdateStep(s, "none")); + TEST_ASSERT_EQUAL(OtaUpdateStep::None, s); + TEST_ASSERT_TRUE(TryParseOtaUpdateStep(s, "updating")); + TEST_ASSERT_EQUAL(OtaUpdateStep::Updating, s); + TEST_ASSERT_TRUE(TryParseOtaUpdateStep(s, "updated")); + TEST_ASSERT_EQUAL(OtaUpdateStep::Updated, s); + TEST_ASSERT_TRUE(TryParseOtaUpdateStep(s, "validating")); + TEST_ASSERT_EQUAL(OtaUpdateStep::Validating, s); + TEST_ASSERT_TRUE(TryParseOtaUpdateStep(s, "validated")); + TEST_ASSERT_EQUAL(OtaUpdateStep::Validated, s); + TEST_ASSERT_TRUE(TryParseOtaUpdateStep(s, "RollingBack")); + TEST_ASSERT_EQUAL(OtaUpdateStep::RollingBack, s); + + TEST_ASSERT_FALSE(TryParseOtaUpdateStep(s, "")); + TEST_ASSERT_FALSE(TryParseOtaUpdateStep(s, "done")); +} + +// ---- FirmwareBootType (const char* API) ------------------------------------ + +TEST_CASE("FirmwareBootType: normal/newfirmware/new_firmware/rollback", "[common][enums]") +{ + FirmwareBootType b; + TEST_ASSERT_TRUE(TryParseFirmwareBootType(b, "normal")); + TEST_ASSERT_EQUAL(FirmwareBootType::Normal, b); + TEST_ASSERT_TRUE(TryParseFirmwareBootType(b, "newfirmware")); + TEST_ASSERT_EQUAL(FirmwareBootType::NewFirmware, b); + TEST_ASSERT_TRUE(TryParseFirmwareBootType(b, "new_firmware")); + TEST_ASSERT_EQUAL(FirmwareBootType::NewFirmware, b); + TEST_ASSERT_TRUE(TryParseFirmwareBootType(b, "ROLLBACK")); + TEST_ASSERT_EQUAL(FirmwareBootType::Rollback, b); + + TEST_ASSERT_FALSE(TryParseFirmwareBootType(b, "")); + TEST_ASSERT_FALSE(TryParseFirmwareBootType(b, "reboot")); +} + +TEST_CASE("InferFirmwareBootType: maps every OTA step to a boot type", "[common][enums]") +{ + // Only 'Updated' means we just flashed new firmware this boot. + TEST_ASSERT_EQUAL(FirmwareBootType::NewFirmware, InferFirmwareBootType(OtaUpdateStep::Updated)); + + // A crash while validating, or an explicit rolling-back step, means this boot is + // the previous image after a rollback. + TEST_ASSERT_EQUAL(FirmwareBootType::Rollback, InferFirmwareBootType(OtaUpdateStep::Validating)); + TEST_ASSERT_EQUAL(FirmwareBootType::Rollback, InferFirmwareBootType(OtaUpdateStep::RollingBack)); + + // Everything else is a normal boot. + TEST_ASSERT_EQUAL(FirmwareBootType::Normal, InferFirmwareBootType(OtaUpdateStep::None)); + TEST_ASSERT_EQUAL(FirmwareBootType::Normal, InferFirmwareBootType(OtaUpdateStep::Updating)); + TEST_ASSERT_EQUAL(FirmwareBootType::Normal, InferFirmwareBootType(OtaUpdateStep::Validated)); +} diff --git a/components/common/host_test/main/test_fnproxy.cpp b/components/common/host_test/main/test_fnproxy.cpp new file mode 100644 index 00000000..757af596 --- /dev/null +++ b/components/common/host_test/main/test_fnproxy.cpp @@ -0,0 +1,41 @@ +// FnProxy<&Class::method> yields a plain R(*)(void*, Ts...) trampoline used to +// hand C++ member functions to C-style callback APIs (FreeRTOS tasks, esp_event). +// Verify it forwards args and returns for the const/non-const overloads. +#include "unity.h" + +#include "util/FnProxy.h" + +#include + +using OpenShock::Util::FnProxy; + +namespace { + struct Counter { + int value = 0; + int add(int x) { return value += x; } + int get() const noexcept { return value; } + }; +} // namespace + +// noexcept propagates onto the function-pointer type; plain methods stay plain. +static_assert(std::is_same_v)>, int (*)(void*, int)>); +static_assert(std::is_same_v)>, int (*)(void*) noexcept>); + +TEST_CASE("FnProxy forwards to a non-const member", "[util][fnproxy]") +{ + Counter c; + auto fn = FnProxy<&Counter::add>; // int(*)(void*, int) + + TEST_ASSERT_EQUAL_INT(5, fn(&c, 5)); + TEST_ASSERT_EQUAL_INT(8, fn(&c, 3)); + TEST_ASSERT_EQUAL_INT(8, c.value); +} + +TEST_CASE("FnProxy forwards to a const noexcept member", "[util][fnproxy]") +{ + Counter c; + c.value = 42; + auto fn = FnProxy<&Counter::get>; // int(*)(void*) + + TEST_ASSERT_EQUAL_INT(42, fn(&c)); +} diff --git a/components/common/host_test/main/test_hexutils.cpp b/components/common/host_test/main/test_hexutils.cpp new file mode 100644 index 00000000..982ec634 --- /dev/null +++ b/components/common/host_test/main/test_hexutils.cpp @@ -0,0 +1,91 @@ +// HexUtils: byte<->hex conversions used for BSSID, partition hashes, etc. +#include "unity.h" + +#include "util/HexUtils.h" + +#include +#include +#include + +using namespace OpenShock; + +TEST_CASE("ToHex single byte, upper and lower", "[util][hex]") +{ + char buf[3] = {0}; + HexUtils::ToHex(0xAB, buf); + TEST_ASSERT_EQUAL_STRING("AB", buf); + HexUtils::ToHex(0x0F, buf); + TEST_ASSERT_EQUAL_STRING("0F", buf); + HexUtils::ToHex(0xAB, buf, false); // lower + TEST_ASSERT_EQUAL_STRING("ab", buf); + HexUtils::ToHex(0x00, buf); + TEST_ASSERT_EQUAL_STRING("00", buf); +} + +TEST_CASE("ToHex array -> array", "[util][hex]") +{ + const uint8_t mac[6] = {0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x23}; + auto out = HexUtils::ToHex<6>(mac); + TEST_ASSERT_EQUAL_STRING("DEADBEEF0123", out.data()); +} + +TEST_CASE("TryParseHex round-trips", "[util][hex]") +{ + uint8_t out[3] = {0}; + TEST_ASSERT_EQUAL_size_t(3, HexUtils::TryParseHex("aBcDeF", 6, out, 3)); + TEST_ASSERT_EQUAL_HEX8(0xAB, out[0]); + TEST_ASSERT_EQUAL_HEX8(0xCD, out[1]); + TEST_ASSERT_EQUAL_HEX8(0xEF, out[2]); + + // odd length / bad chars / small buffer -> 0 + TEST_ASSERT_EQUAL_size_t(0, HexUtils::TryParseHex("ABC", 3, out, 3)); // odd + TEST_ASSERT_EQUAL_size_t(0, HexUtils::TryParseHex("ABGH", 4, out, 3)); // non-hex + TEST_ASSERT_EQUAL_size_t(0, HexUtils::TryParseHex("ABCDEF", 6, out, 2)); // buffer too small +} + +TEST_CASE("TryParseHexPair", "[util][hex]") +{ + uint8_t v = 0; + TEST_ASSERT_TRUE(HexUtils::TryParseHexPair('A', 'b', v)); + TEST_ASSERT_EQUAL_HEX8(0xAB, v); + TEST_ASSERT_TRUE(HexUtils::TryParseHexPair('0', '0', v)); + TEST_ASSERT_EQUAL_HEX8(0x00, v); + TEST_ASSERT_FALSE(HexUtils::TryParseHexPair('G', '0', v)); // invalid high nibble + TEST_ASSERT_FALSE(HexUtils::TryParseHexPair('0', 'Z', v)); // invalid low nibble +} + +TEST_CASE("TryParseHexMac", "[util][hex]") +{ + uint8_t out[6] = {0}; + const char* mac = "DE:AD:BE:EF:01:23"; + TEST_ASSERT_EQUAL_size_t(6, HexUtils::TryParseHexMac(mac, std::strlen(mac), out, 6)); + TEST_ASSERT_EQUAL_HEX8(0xDE, out[0]); + TEST_ASSERT_EQUAL_HEX8(0x23, out[5]); + + const char* bad = "DE-AD-BE"; // wrong separator + TEST_ASSERT_EQUAL_size_t(0, HexUtils::TryParseHexMac(bad, std::strlen(bad), out, 6)); +} + +TEST_CASE("TryParseHexMac edge cases", "[util][hex]") +{ + uint8_t out[6] = {0}; + + TEST_ASSERT_EQUAL_size_t(0, HexUtils::TryParseHexMac("", 0, out, 6)); // empty + TEST_ASSERT_EQUAL_size_t(1, HexUtils::TryParseHexMac("AB", 2, out, 6)); // a single pair + TEST_ASSERT_EQUAL_HEX8(0xAB, out[0]); + TEST_ASSERT_EQUAL_size_t(0, HexUtils::TryParseHexMac("DE:AD:BE", 8, out, 2)); // output too small + TEST_ASSERT_EQUAL_size_t(0, HexUtils::TryParseHexMac("DE:AD:", 6, out, 6)); // trailing separator + TEST_ASSERT_EQUAL_size_t(0, HexUtils::TryParseHexMac("DE:XY:BE", 8, out, 6)); // bad hex digit +} + +TEST_CASE("TryParseHex rejects odd lengths, small buffers and bad digits", "[util][hex]") +{ + uint8_t out[4] = {0}; + + TEST_ASSERT_EQUAL_size_t(0, HexUtils::TryParseHex("ABC", 3, out, 4)); // odd length + TEST_ASSERT_EQUAL_size_t(0, HexUtils::TryParseHex("AABBCCDDEE", 10, out, 4)); // output too small + TEST_ASSERT_EQUAL_size_t(0, HexUtils::TryParseHex("AAZZ", 4, out, 4)); // bad digit + TEST_ASSERT_EQUAL_size_t(2, HexUtils::TryParseHex("aaBB", 4, out, 4)); // mixed case + TEST_ASSERT_EQUAL_HEX8(0xAA, out[0]); + TEST_ASSERT_EQUAL_HEX8(0xBB, out[1]); +} diff --git a/components/common/host_test/main/test_main.cpp b/components/common/host_test/main/test_main.cpp new file mode 100644 index 00000000..bc9d814b --- /dev/null +++ b/components/common/host_test/main/test_main.cpp @@ -0,0 +1,10 @@ +// Host-only (linux target) test binary for the common component's header-only +// logic. FreeRTOS is mocked, so there is no ESP startup - drive Unity directly. +#include "unity.h" + +extern "C" int main(void) +{ + UNITY_BEGIN(); + unity_run_all_tests(); + return UNITY_END(); +} diff --git a/components/common/host_test/main/test_semver.cpp b/components/common/host_test/main/test_semver.cpp new file mode 100644 index 00000000..30496699 --- /dev/null +++ b/components/common/host_test/main/test_semver.cpp @@ -0,0 +1,143 @@ +// SemVer parsing + ordering - used for OTA/firmware version comparisons. +#include "unity.h" + +#include "SemVer.h" + +#include + +using namespace OpenShock; + +TEST_CASE("TryParseSemVer basic", "[util][semver]") +{ + SemVer v; + TEST_ASSERT_TRUE(TryParseSemVer("1.2.3", v)); + TEST_ASSERT_EQUAL_UINT16(1, v.major); + TEST_ASSERT_EQUAL_UINT16(2, v.minor); + TEST_ASSERT_EQUAL_UINT16(3, v.patch); + TEST_ASSERT_TRUE(v.prerelease.empty()); + TEST_ASSERT_TRUE(v.build.empty()); + TEST_ASSERT_TRUE(v.isValid()); +} + +TEST_CASE("TryParseSemVer with dotted prerelease and build", "[util][semver]") +{ + // OpenShock's own RC tag format, e.g. 1.6.0-rc.1 / 1.5.0-rc.7. + SemVer v; + TEST_ASSERT_TRUE(TryParseSemVer("1.5.0-rc.7+build.9", v)); + TEST_ASSERT_EQUAL_UINT16(1, v.major); + TEST_ASSERT_EQUAL_UINT16(5, v.minor); + TEST_ASSERT_EQUAL_UINT16(0, v.patch); + TEST_ASSERT_TRUE(v.prerelease == "rc.7"); + TEST_ASSERT_TRUE(v.build == "build.9"); +} + +TEST_CASE("TryParseSemVer rejects malformed", "[util][semver]") +{ + SemVer v; + TEST_ASSERT_FALSE(TryParseSemVer("1.2", v)); + TEST_ASSERT_FALSE(TryParseSemVer("", v)); + TEST_ASSERT_FALSE(TryParseSemVer("abc", v)); + TEST_ASSERT_FALSE(TryParseSemVer("1.2.x", v)); + TEST_ASSERT_FALSE(TryParseSemVer("1.2.3.4", v)); // core has too many parts +} + +TEST_CASE("SemVer ordering: core precedence", "[util][semver]") +{ + SemVer a, b; + TEST_ASSERT_TRUE(TryParseSemVer("1.2.3", a)); + TEST_ASSERT_TRUE(TryParseSemVer("1.2.4", b)); + TEST_ASSERT_TRUE(a < b); + TEST_ASSERT_TRUE(b > a); + TEST_ASSERT_TRUE(a != b); + + SemVer major1, major2; + TEST_ASSERT_TRUE(TryParseSemVer("2.0.0", major2)); + TEST_ASSERT_TRUE(TryParseSemVer("1.99.99", major1)); + TEST_ASSERT_TRUE(major1 < major2); +} + +TEST_CASE("SemVer ordering: a prerelease is below its release", "[util][semver]") +{ + SemVer rel, pre; + TEST_ASSERT_TRUE(TryParseSemVer("1.0.0", rel)); + TEST_ASSERT_TRUE(TryParseSemVer("1.0.0-rc1", pre)); + TEST_ASSERT_TRUE(pre < rel); + TEST_ASSERT_TRUE(rel > pre); +} + +TEST_CASE("SemVer ordering: dotted RCs sort numerically", "[util][semver]") +{ + // The bug this replaces: 1.5.0-rc.6 and 1.5.0-rc.7 used to parse equal. + SemVer rc6, rc7; + TEST_ASSERT_TRUE(TryParseSemVer("1.5.0-rc.6", rc6)); + TEST_ASSERT_TRUE(TryParseSemVer("1.5.0-rc.7", rc7)); + TEST_ASSERT_TRUE(rc6 < rc7); + TEST_ASSERT_TRUE(rc6 != rc7); + + // numeric identifiers rank below alphanumeric (rc.9 < rc.beta) + SemVer rc9, rcbeta; + TEST_ASSERT_TRUE(TryParseSemVer("1.0.0-rc.9", rc9)); + TEST_ASSERT_TRUE(TryParseSemVer("1.0.0-rc.beta", rcbeta)); + TEST_ASSERT_TRUE(rc9 < rcbeta); + + // more identifiers outrank fewer when the prefix is equal (rc < rc.1) + SemVer rc, rc1; + TEST_ASSERT_TRUE(TryParseSemVer("1.0.0-rc", rc)); + TEST_ASSERT_TRUE(TryParseSemVer("1.0.0-rc.1", rc1)); + TEST_ASSERT_TRUE(rc < rc1); +} + +TEST_CASE("SemVer: build metadata is ignored for precedence", "[util][semver]") +{ + SemVer a, b; + TEST_ASSERT_TRUE(TryParseSemVer("1.0.0+build1", a)); + TEST_ASSERT_TRUE(TryParseSemVer("1.0.0+build2", b)); + TEST_ASSERT_FALSE(a < b); + TEST_ASSERT_FALSE(b < a); +} + +TEST_CASE("SemVer toString round-trips with dots", "[util][semver]") +{ + SemVer v; + TEST_ASSERT_TRUE(TryParseSemVer("3.1.4-beta.2+build.9", v)); + TEST_ASSERT_TRUE(v.toString() == "3.1.4-beta.2+build.9"); +} + +TEST_CASE("SemVer isValid accepts dotted prerelease and build", "[util][semver]") +{ + TEST_ASSERT_TRUE(SemVer(1, 5, 0, "rc.7", "build.9").isValid()); + TEST_ASSERT_FALSE(SemVer(1, 5, 0, "rc..7", "").isValid()); +} + +TEST_CASE("TryParseSemVer rejects an empty prerelease or build and leaves the output untouched", "[util][semver]") +{ + SemVer v(9, 9, 9); + TEST_ASSERT_FALSE(TryParseSemVer("1.0.0-", v)); + TEST_ASSERT_FALSE(TryParseSemVer("1.0.0+", v)); + TEST_ASSERT_FALSE(TryParseSemVer("1.0.0-+", v)); + TEST_ASSERT_FALSE(TryParseSemVer("1.2.x", v)); + TEST_ASSERT_TRUE(v == SemVer(9, 9, 9)); +} + +TEST_CASE("SemVer ordering operators agree when only build metadata differs", "[util][semver]") +{ + SemVer a, b; + TEST_ASSERT_TRUE(TryParseSemVer("1.0.0+x", a)); + TEST_ASSERT_TRUE(TryParseSemVer("1.0.0+y", b)); + TEST_ASSERT_FALSE(a > b); + TEST_ASSERT_FALSE(b > a); + TEST_ASSERT_TRUE(a <= b); + TEST_ASSERT_TRUE(a >= b); + TEST_ASSERT_TRUE(a != b); // equality still includes build metadata +} + +TEST_CASE("SemVer string comparisons fail closed on garbage", "[util][semver]") +{ + SemVer v(1, 0, 0); + TEST_ASSERT_FALSE(v < "garbage"); + TEST_ASSERT_FALSE(v <= "garbage"); + TEST_ASSERT_FALSE(v > "garbage"); + TEST_ASSERT_FALSE(v >= "garbage"); + TEST_ASSERT_FALSE(v == "garbage"); + TEST_ASSERT_TRUE(v > "0.9.0"); +} diff --git a/components/common/host_test/main/test_stringhelpers.cpp b/components/common/host_test/main/test_stringhelpers.cpp new file mode 100644 index 00000000..fcf4bff4 --- /dev/null +++ b/components/common/host_test/main/test_stringhelpers.cpp @@ -0,0 +1,43 @@ +// StringHelpers::StringIEquals - the shared case-insensitive compare used by the +// enum parsers and util. Length-aware, so it must be correct on non-terminated +// views too. +#include "unity.h" + +#include "StringHelpers.h" + +#include + +using namespace OpenShock; + +TEST_CASE("StringIEquals: equal regardless of case", "[common][strhelpers]") +{ + TEST_ASSERT_TRUE(StringIEquals("hello", "hello")); + TEST_ASSERT_TRUE(StringIEquals("Hello", "hELLo")); + TEST_ASSERT_TRUE(StringIEquals("ABC123", "abc123")); + TEST_ASSERT_TRUE(StringIEquals("", "")); +} + +TEST_CASE("StringIEquals: unequal content or length", "[common][strhelpers]") +{ + TEST_ASSERT_FALSE(StringIEquals("hello", "world")); + TEST_ASSERT_FALSE(StringIEquals("hello", "hell")); // different length + TEST_ASSERT_FALSE(StringIEquals("hell", "hello")); + TEST_ASSERT_FALSE(StringIEquals("", "x")); + TEST_ASSERT_FALSE(StringIEquals("x", "")); + TEST_ASSERT_FALSE(StringIEquals("abc", "abd")); +} + +TEST_CASE("StringIEquals: only ASCII letters are folded", "[common][strhelpers]") +{ + // digits/punctuation compare exactly; case folding is A-Z / a-z only + TEST_ASSERT_TRUE(StringIEquals("a-b_c", "A-B_C")); + TEST_ASSERT_FALSE(StringIEquals("a-b", "a_b")); +} + +TEST_CASE("StringIEquals: works on non-NUL-terminated sub-views", "[common][strhelpers]") +{ + std::string_view full = "prefixHELLOsuffix"; + std::string_view mid = full.substr(6, 5); // "HELLO", not NUL-terminated + TEST_ASSERT_TRUE(StringIEquals(mid, "hello")); + TEST_ASSERT_FALSE(StringIEquals(mid, "hell")); +} diff --git a/components/common/host_test/main/test_stringutils.cpp b/components/common/host_test/main/test_stringutils.cpp new file mode 100644 index 00000000..fbd6e13f --- /dev/null +++ b/components/common/host_test/main/test_stringutils.cpp @@ -0,0 +1,119 @@ +// StringHelpers: trimming, prefix/suffix, splitting. All std::string_view based. +#include "unity.h" + +#include "StringHelpers.h" + +#include +#include + +using namespace OpenShock; + +TEST_CASE("StringTrim variants", "[common][strhelpers]") +{ + TEST_ASSERT_TRUE(StringTrim(" hi ") == "hi"); + TEST_ASSERT_TRUE(StringTrimLeft(" hi ") == "hi "); + TEST_ASSERT_TRUE(StringTrimRight(" hi ") == " hi"); + TEST_ASSERT_TRUE(StringTrim("") == ""); + TEST_ASSERT_TRUE(StringTrim(" ") == ""); + TEST_ASSERT_TRUE(StringTrim("nospace") == "nospace"); + TEST_ASSERT_TRUE(StringTrim("\t\n x \r\n") == "x"); +} + +TEST_CASE("StringHasPrefix / HasSuffix", "[common][strhelpers]") +{ + TEST_ASSERT_TRUE(StringHasPrefix("hello", 'h')); + TEST_ASSERT_FALSE(StringHasPrefix("hello", 'x')); + TEST_ASSERT_TRUE(StringHasPrefix("hello", std::string_view("hel"))); + TEST_ASSERT_FALSE(StringHasPrefix("hello", std::string_view("world"))); + TEST_ASSERT_FALSE(StringHasPrefix("hi", std::string_view("hello"))); // longer than view +} + +TEST_CASE("StringSplitByFirst / ByLast", "[common][strhelpers]") +{ + auto [a, b] = StringSplitByFirst("key=value=extra", '='); + TEST_ASSERT_TRUE(a == "key"); + TEST_ASSERT_TRUE(b == "value=extra"); + + auto [c, d] = StringSplitByLast("a.b.c", '.'); + TEST_ASSERT_TRUE(c == "a.b"); + TEST_ASSERT_TRUE(d == "c"); + + // no delimiter -> first half is the whole string + auto [e, f] = StringSplitByFirst("nodelim", '='); + TEST_ASSERT_TRUE(e == "nodelim"); +} + +TEST_CASE("TryStringSplit into a fixed array", "[common][strhelpers]") +{ + std::string_view parts[4]; + TEST_ASSERT_TRUE(TryStringSplit("192.168.0.1", '.', parts)); + TEST_ASSERT_TRUE(parts[0] == "192"); + TEST_ASSERT_TRUE(parts[1] == "168"); + TEST_ASSERT_TRUE(parts[2] == "0"); + TEST_ASSERT_TRUE(parts[3] == "1"); + + std::string_view three[4]; + TEST_ASSERT_FALSE(TryStringSplit("1.2.3", '.', three)); // too few -> false + + // "too many": the last slot keeps the whole remainder (nothing dropped), so a + // caller like IPAddressUtils then rejects it via Convert::ToUint8("4.5"). + std::string_view five[4]; + TEST_ASSERT_TRUE(TryStringSplit("1.2.3.4.5", '.', five)); + TEST_ASSERT_TRUE(five[3] == "4.5"); +} + +TEST_CASE("StringSplit into a vector", "[common][strhelpers]") +{ + auto v = StringSplit("a,b,,c", ','); + TEST_ASSERT_EQUAL_size_t(4, v.size()); + TEST_ASSERT_TRUE(v[0] == "a"); + TEST_ASSERT_TRUE(v[2] == ""); // empty field preserved + TEST_ASSERT_TRUE(v[3] == "c"); +} + +TEST_CASE("StringIContains / StringHasPrefixIC", "[common][strhelpers]") +{ + TEST_ASSERT_TRUE(StringIContains("Hello World", "LO WO")); + TEST_ASSERT_FALSE(StringIContains("Hello", "xyz")); + TEST_ASSERT_TRUE(StringHasPrefixIC("HELLO", "hel")); + TEST_ASSERT_FALSE(StringHasPrefixIC("hello", "world")); +} + +TEST_CASE("StringTrimRight on all-whitespace input", "[util][string]") +{ + TEST_ASSERT_TRUE(StringTrimRight(" ").empty()); + TEST_ASSERT_TRUE(StringTrimLeft(" ").empty()); + TEST_ASSERT_TRUE(StringTrim(" \t\r\n ").empty()); +} + +TEST_CASE("StringRemoveSuffix removes the suffix", "[util][string]") +{ + TEST_ASSERT_TRUE(StringRemoveSuffix("file.bin", std::string_view(".bin")) == "file"); + TEST_ASSERT_TRUE(StringRemoveSuffix("file.bin", std::string_view(".txt")) == "file.bin"); +} + +TEST_CASE("Last-delimiter helpers treat a string delimiter as a substring", "[util][string]") +{ + TEST_ASSERT_TRUE(StringAfterLast("a=>b>c", std::string_view("=>")) == "b>c"); + TEST_ASSERT_TRUE(StringBeforeLast("a=>b>c", std::string_view("=>")) == "a"); + auto [head, tail] = StringSplitByLast("a=>b>c", std::string_view("=>")); + TEST_ASSERT_TRUE(head == "a"); + TEST_ASSERT_TRUE(tail == "b>c"); +} + +TEST_CASE("StringSplit edge cases", "[util][string]") +{ + TEST_ASSERT_TRUE(StringSplit("", ',').empty()); + + auto parts = StringSplitWhiteSpace("a b c d", 2); + TEST_ASSERT_EQUAL_size_t(3, parts.size()); + TEST_ASSERT_TRUE(parts[0] == "a"); + TEST_ASSERT_TRUE(parts[1] == "b"); + TEST_ASSERT_TRUE(parts[2] == "c d"); +} + +TEST_CASE("Case-insensitive helpers are safe on non-ASCII bytes", "[util][string]") +{ + TEST_ASSERT_TRUE(StringIEquals("Caf\xC3\xA9", "CAF\xC3\xA9")); + TEST_ASSERT_FALSE(StringIEquals("\xC3\xA9", "\xC3\x89")); // only ASCII letters fold +} diff --git a/components/common/host_test/main/test_tinyvec.cpp b/components/common/host_test/main/test_tinyvec.cpp new file mode 100644 index 00000000..a6c526a1 --- /dev/null +++ b/components/common/host_test/main/test_tinyvec.cpp @@ -0,0 +1,82 @@ +// TinyVec is the firmware's POD-only dynamic array (manual malloc/realloc growth). +// It underpins buffer assembly across the codebase, so exercise its growth, +// zero-fill, replace and move semantics. +#include "unity.h" + +#include "TinyVec.h" + +#include +#include +#include + +TEST_CASE("TinyVec append grows and preserves data", "[util][tinyvec]") +{ + TinyVec v; + TEST_ASSERT_TRUE(v.empty()); + + const uint16_t a[] = {1, 2, 3}; + v.append(a, 3); + TEST_ASSERT_EQUAL_UINT32(3, v.size()); + TEST_ASSERT_GREATER_OR_EQUAL_UINT32(3, v.capacity()); + + const uint16_t b[] = {4, 5}; + v.append(b, 2); + TEST_ASSERT_EQUAL_UINT32(5, v.size()); + for (uint16_t i = 0; i < 5; i++) { + TEST_ASSERT_EQUAL_UINT16(i + 1, v[i]); + } +} + +TEST_CASE("TinyVec resize zero-fills the grown region", "[util][tinyvec]") +{ + TinyVec v; + const uint8_t a[] = {0xAA, 0xBB}; + v.append(a, 2); + + v.resize(5); + TEST_ASSERT_EQUAL_UINT32(5, v.size()); + TEST_ASSERT_EQUAL_UINT8(0xAA, v[0]); + TEST_ASSERT_EQUAL_UINT8(0xBB, v[1]); + TEST_ASSERT_EQUAL_UINT8(0, v[2]); + TEST_ASSERT_EQUAL_UINT8(0, v[4]); +} + +TEST_CASE("TinyVec assign replaces the contents", "[util][tinyvec]") +{ + TinyVec v; + const int a[] = {7, 8, 9, 10}; + v.append(a, 4); + + const int b[] = {1, 2}; + v.assign(b, 2); + TEST_ASSERT_EQUAL_UINT32(2, v.size()); + TEST_ASSERT_EQUAL_INT(1, v[0]); + TEST_ASSERT_EQUAL_INT(2, v[1]); + + v.clear(); + TEST_ASSERT_TRUE(v.empty()); +} + +TEST_CASE("TinyVec move transfers ownership", "[util][tinyvec]") +{ + TinyVec v; + const uint32_t a[] = {100, 200}; + v.append(a, 2); + + TinyVec w(std::move(v)); + TEST_ASSERT_EQUAL_UINT32(2, w.size()); + TEST_ASSERT_EQUAL_UINT32(100, w[0]); + TEST_ASSERT_EQUAL_UINT32(0, v.size()); // moved-from is emptied +} + +TEST_CASE("TinyVec constructs from a buffer and converts to span", "[util][tinyvec]") +{ + const uint8_t a[] = {5, 6, 7}; + TinyVec v(a, 3); + TEST_ASSERT_EQUAL_UINT32(3, v.size()); + TEST_ASSERT_EQUAL_UINT8(6, v[1]); + + std::span s = v; + TEST_ASSERT_EQUAL_UINT32(3, s.size()); + TEST_ASSERT_EQUAL_UINT8(7, s[2]); +} diff --git a/components/common/host_test/sdkconfig.defaults b/components/common/host_test/sdkconfig.defaults new file mode 100644 index 00000000..c3d7cf2c --- /dev/null +++ b/components/common/host_test/sdkconfig.defaults @@ -0,0 +1,4 @@ +CONFIG_IDF_TARGET="linux" +CONFIG_IDF_TARGET_LINUX=y +CONFIG_COMPILER_CXX_EXCEPTIONS=y +CONFIG_UNITY_ENABLE_IDF_TEST_RUNNER=y diff --git a/components/common/include/Checksum.h b/components/common/include/Checksum.h new file mode 100644 index 00000000..c18eb947 --- /dev/null +++ b/components/common/include/Checksum.h @@ -0,0 +1,67 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace OpenShock::Checksum { + + // ------------------------------------------------------------ + // Raw byte span checksum + // ------------------------------------------------------------ + constexpr uint8_t Sum8(const uint8_t* data, std::size_t size) + { + uint8_t checksum = 0; + for (std::size_t i = 0; i < size; ++i) { + checksum += data[i]; + } + return checksum; + } + + // ------------------------------------------------------------ + // Generic integral overload (C++20 concepts) + // ------------------------------------------------------------ + template + constexpr uint8_t Sum8(T value) + { + // Shift on the unsigned representation; shifting signed values is implementation-defined for negatives + std::make_unsigned_t bits = static_cast>(value); + + uint8_t result = 0; + + for (std::size_t i = 0; i < sizeof(T); ++i) { + result += static_cast((bits >> (i * 8)) & 0xFF); + } + + return result; + } + + // ------------------------------------------------------------ + // Generic trivially copyable object overload + // ------------------------------------------------------------ + // std::bit_cast keeps this usable in constant expressions (reinterpret_cast is not). Constant evaluation still + // rejects types containing pointers, unions or padding, which have no fixed byte representation. + template + requires(!std::integral && std::is_trivially_copyable_v) + constexpr uint8_t Sum8(const T& data) + { + const auto bytes = std::bit_cast>(data); + return Sum8(bytes.data(), bytes.size()); + } + + /// @brief Reverses the bit order of the low nibble of `b` (lookup table packed into a 64-bit constant). + /// Only the low nibble is used, so the shift can never reach 64 bits. + constexpr uint8_t ReverseNibble(uint8_t b) + { + return (0xF7B3D591E6A2C480ull >> ((b & 0xF) * 4)) & 0xF; + } + + /// @brief Reverses and inverts the bits of the low nibble of `b`. Only the low nibble is used. + constexpr uint8_t ReverseInverseNibble(uint8_t b) + { + return (0x084C2A6E195D3B7Full >> ((b & 0xF) * 4)) & 0xF; + } +} // namespace OpenShock::Checksum diff --git a/include/Convert.h b/components/common/include/Convert.h similarity index 93% rename from include/Convert.h rename to components/common/include/Convert.h index 26d9834e..a552a0f4 100644 --- a/include/Convert.h +++ b/components/common/include/Convert.h @@ -6,7 +6,7 @@ #include #include -namespace OpenShock::Convert { // TODO: C++23 make this use std::from_chars instead +namespace OpenShock::Convert { void FromInt8(int8_t val, std::string& str); void FromUint8(uint8_t val, std::string& str); void FromInt16(int16_t val, std::string& str); diff --git a/include/FormatHelpers.h b/components/common/include/FormatHelpers.h similarity index 86% rename from include/FormatHelpers.h rename to components/common/include/FormatHelpers.h index 03fe31ed..15dd6eca 100644 --- a/include/FormatHelpers.h +++ b/components/common/include/FormatHelpers.h @@ -2,12 +2,9 @@ #define BSSID_FMT "%02X:%02X:%02X:%02X:%02X:%02X" #define BSSID_ARG(bssid) bssid[0], bssid[1], bssid[2], bssid[3], bssid[4], bssid[5] -#define BSSID_FMT_LEN 18 #define IPV4ADDR_FMT "%hhu.%hhu.%hhu.%hhu" #define IPV4ADDR_ARG(addr) addr[0], addr[1], addr[2], addr[3] -#define IPV4ADDR_FMT_LEN 15 #define IPV6ADDR_FMT "%02x%02x:%02x%02x:%02x%02x:%02x%02x:%02x%02x:%02x%02x:%02x%02x:%02x%02x" #define IPV6ADDR_ARG(addr) addr[0], addr[1], addr[2], addr[3], addr[4], addr[5], addr[6], addr[7], addr[8], addr[9], addr[10], addr[11], addr[12], addr[13], addr[14], addr[15] -#define IPV6ADDR_FMT_LEN 39 diff --git a/components/common/include/OpenShock.h b/components/common/include/OpenShock.h new file mode 100644 index 00000000..e230f71a --- /dev/null +++ b/components/common/include/OpenShock.h @@ -0,0 +1,47 @@ +#pragma once + +#define DISABLE_DEFAULT(TypeName) TypeName() = delete +#define DISABLE_COPY(TypeName) \ + TypeName(const TypeName&) = delete; \ + TypeName& operator=(const TypeName&) = delete +#define DISABLE_MOVE(TypeName) \ + TypeName(TypeName&&) = delete; \ + TypeName& operator=(TypeName&&) = delete + +// Config comes from two places: Kconfig (CONFIG_OPENSHOCK_*, via sdkconfig.h - +// domains, hostname, AP prefix, buffer sizes; see components/core/Kconfig.projbuild) +// and the generated board header (OPENSHOCK_FW_BOARD, OPENSHOCK_FW_CHIP, the GPIO +// assignments, ...; see scripts/gen_env_header.py). +#include "sdkconfig.h" + +// Board identity and GPIO assignments. Deliberately included here rather than +// force-included into the whole build, so ESP-IDF's own sources never see it. +#include "openshock_board.h" + +#ifndef OPENSHOCK_FW_BOARD +#error "OPENSHOCK_FW_BOARD must be defined (generated by scripts/gen_env_header.py)" +#endif +#ifndef OPENSHOCK_FW_CHIP +#error "OPENSHOCK_FW_CHIP must be defined (generated by scripts/gen_env_header.py)" +#endif + +#define OPENSHOCK_FW_CDN_URL(path) "https://" CONFIG_OPENSHOCK_FW_CDN_DOMAIN path + +#define OPENSHOCK_GPIO_INVALID -1 + +namespace OpenShock { + struct SemVer; +} // namespace OpenShock + +namespace OpenShock::Constants { + // Defined in Version.cpp, which is the only translation unit that includes + // openshock_version.h. Building these here would have put the firmware version - + // and so the commit SHA - into every file that includes this header, rebuilding all of + // them on every commit for strings almost none of them use. + extern const char* const FW_USERAGENT; + extern const char* const FW_VERSION; // e.g. "1.5.0-rc.7+abc1234" + extern const char* const FW_GIT_COMMIT; // full SHA + + /// @brief The running firmware version, parsed once. + const OpenShock::SemVer& FirmwareVersion(); +} // namespace OpenShock::Constants diff --git a/include/RateLimiter.h b/components/common/include/RateLimiter.h similarity index 96% rename from include/RateLimiter.h rename to components/common/include/RateLimiter.h index 906943c7..d25461df 100644 --- a/include/RateLimiter.h +++ b/components/common/include/RateLimiter.h @@ -1,6 +1,6 @@ #pragma once -#include "Common.h" +#include "OpenShock.h" #include "SimpleMutex.h" #include diff --git a/include/ReadWriteMutex.h b/components/common/include/ReadWriteMutex.h similarity index 98% rename from include/ReadWriteMutex.h rename to components/common/include/ReadWriteMutex.h index 70768e45..3eb79f40 100644 --- a/include/ReadWriteMutex.h +++ b/components/common/include/ReadWriteMutex.h @@ -2,7 +2,7 @@ #include -#include "Common.h" +#include "OpenShock.h" namespace OpenShock { class ReadWriteMutex { diff --git a/include/SemVer.h b/components/common/include/SemVer.h similarity index 68% rename from include/SemVer.h rename to components/common/include/SemVer.h index 5e710787..b851b099 100644 --- a/include/SemVer.h +++ b/components/common/include/SemVer.h @@ -37,19 +37,22 @@ namespace OpenShock { { } + // == / != compare every field, build metadata included. The ordering operators compare precedence (semver 11), + // which ignores build metadata, and are all derived from operator< so they stay consistent with each other. bool operator==(const SemVer& other) const; inline bool operator!=(const SemVer& other) const { return !(*this == other); } bool operator<(const SemVer& other) const; - inline bool operator<=(const SemVer& other) const { return *this < other || *this == other; } - inline bool operator>(const SemVer& other) const { return !(*this <= other); } + inline bool operator<=(const SemVer& other) const { return !(other < *this); } + inline bool operator>(const SemVer& other) const { return other < *this; } inline bool operator>=(const SemVer& other) const { return !(*this < other); } + // String comparisons parse `other` first; if it is not a valid semver every comparison except != is false. bool operator==(std::string_view other) const; inline bool operator!=(std::string_view other) const { return !(*this == other); } bool operator<(std::string_view other) const; - inline bool operator<=(std::string_view other) const { return *this < other || *this == other; } - inline bool operator>(std::string_view other) const { return !(*this <= other); } - inline bool operator>=(std::string_view other) const { return !(*this < other); } + bool operator<=(std::string_view other) const; + bool operator>(std::string_view other) const; + bool operator>=(std::string_view other) const; bool isValid() const; diff --git a/include/SimpleMutex.h b/components/common/include/SimpleMutex.h similarity index 98% rename from include/SimpleMutex.h rename to components/common/include/SimpleMutex.h index bd6df741..08cfc45d 100644 --- a/include/SimpleMutex.h +++ b/components/common/include/SimpleMutex.h @@ -2,7 +2,7 @@ #include -#include "Common.h" +#include "OpenShock.h" namespace OpenShock { class SimpleMutex { diff --git a/include/util/StringUtils.h b/components/common/include/StringHelpers.h similarity index 71% rename from include/util/StringUtils.h rename to components/common/include/StringHelpers.h index 022689fe..10a09d09 100644 --- a/include/util/StringUtils.h +++ b/components/common/include/StringHelpers.h @@ -1,7 +1,6 @@ #pragma once -#include - +#include #include #include #include @@ -10,16 +9,22 @@ #include namespace OpenShock { - bool FormatToString(std::string& out, const char* format, ...); + bool FormatToString(std::string& out, const char* format, ...) __attribute__((format(printf, 2, 3))); - constexpr std::string_view StringTrimLeft(std::string_view view) + // ASCII-only, locale-independent and safe for bytes >= 0x80 (unlike isspace/tolower on a plain, signed char). + constexpr bool CharIsSpace(char c) { - if (view.empty()) { - return view; - } + return c == ' ' || c == '\t' || c == '\n' || c == '\v' || c == '\f' || c == '\r'; + } + constexpr char CharToLower(char c) + { + return (c >= 'A' && c <= 'Z') ? static_cast(c - 'A' + 'a') : c; + } + constexpr std::string_view StringTrimLeft(std::string_view view) + { std::size_t pos = 0; - while (pos < view.size() && isspace(view[pos]) != 0) { + while (pos < view.size() && CharIsSpace(view[pos])) { ++pos; } @@ -27,16 +32,12 @@ namespace OpenShock { } constexpr std::string_view StringTrimRight(std::string_view view) { - if (view.empty()) { - return view; - } - - std::size_t pos = view.size() - 1; - while (pos > 0 && isspace(view[pos]) != 0) { - --pos; + std::size_t len = view.size(); + while (len > 0 && CharIsSpace(view[len - 1])) { + --len; } - return view.substr(0, pos + 1); + return view.substr(0, len); } constexpr std::string_view StringTrim(std::string_view view) { @@ -77,7 +78,7 @@ namespace OpenShock { } constexpr std::string_view StringRemoveSuffix(std::string_view view, std::string_view suffix) { - if (StringHasSuffix(view, suffix)) view.remove_prefix(suffix.length()); + if (StringHasSuffix(view, suffix)) view.remove_suffix(suffix.length()); return view; } @@ -95,13 +96,13 @@ namespace OpenShock { } constexpr std::string_view StringBeforeLast(std::string_view view, char delimiter) { - size_t pos = view.find_last_of(delimiter); + size_t pos = view.rfind(delimiter); if (pos == std::string_view::npos) return view; return view.substr(0, pos); } constexpr std::string_view StringBeforeLast(std::string_view view, std::string_view delimiter) { - size_t pos = view.find_last_of(delimiter); + size_t pos = view.rfind(delimiter); if (pos == std::string_view::npos) return view; return view.substr(0, pos); } @@ -119,36 +120,45 @@ namespace OpenShock { } constexpr std::string_view StringAfterLast(std::string_view view, char delimiter) { - size_t pos = view.find_last_of(delimiter); + size_t pos = view.rfind(delimiter); if (pos == std::string_view::npos) return view; return view.substr(pos + 1); } constexpr std::string_view StringAfterLast(std::string_view view, std::string_view delimiter) { - size_t pos = view.find_last_of(delimiter); + size_t pos = view.rfind(delimiter); if (pos == std::string_view::npos) return view; return view.substr(pos + delimiter.length()); } + // Splits `view` into N parts on `delimiter`. The first N-1 parts are the + // delimited fields; the last part is the entire remainder (delimiters and + // all), so nothing is silently dropped. Returns false only if there are too + // few parts (fewer than N-1 delimiters). Callers that need each part to be a + // single field validate the remainder themselves (e.g. Convert::ToUint8). template constexpr bool TryStringSplit(std::string_view view, char delimiter, std::string_view (&out)[N]) { + static_assert(N > 0, "TryStringSplit needs at least one output slot"); + std::size_t pos = 0; - std::size_t idx = 0; - while (pos < view.size() && idx < N) { + for (std::size_t idx = 0; idx < N - 1; ++idx) { std::size_t nextPos = view.find(delimiter, pos); if (nextPos == std::string_view::npos) { - nextPos = view.size(); + return false; // too few parts } - out[idx] = view.substr(pos, nextPos - pos); pos = nextPos + 1; - ++idx; } - return idx == N; + out[N - 1] = view.substr(pos); // remainder (may still contain delimiters) + return true; } + // Splits on every `delimiter`, keeping empty fields between delimiters ("a,,b" -> a, "", b). After `maxSplits` + // splits the rest is returned as one final part. An empty input, or a trailing delimiter, adds no empty field. std::vector StringSplit(std::string_view view, char delimiter, std::size_t maxSplits = std::numeric_limits::max()); + // Splits on runs of characters matching `predicate`, dropping empty fields. After `maxSplits` fields the rest + // (from the start of the next field) is returned as one final part. std::vector StringSplit(std::string_view view, bool (*predicate)(char delimiter), std::size_t maxSplits = std::numeric_limits::max()); std::vector StringSplitNewLines(std::string_view view, std::size_t maxSplits = std::numeric_limits::max()); std::vector StringSplitWhiteSpace(std::string_view view, std::size_t maxSplits = std::numeric_limits::max()); @@ -164,18 +174,18 @@ namespace OpenShock { } constexpr std::pair StringSplitByLast(std::string_view view, char delimiter) { - size_t pos = view.find_last_of(delimiter); + size_t pos = view.rfind(delimiter); return std::make_pair(view.substr(0, pos), pos == std::string_view::npos ? std::string_view() : view.substr(pos + 1)); } constexpr std::pair StringSplitByLast(std::string_view view, std::string_view delimiter) { - size_t pos = view.find_last_of(delimiter); + size_t pos = view.rfind(delimiter); return std::make_pair(view.substr(0, pos), pos == std::string_view::npos ? std::string_view() : view.substr(pos + delimiter.length())); } - bool StringIEquals(std::string_view a, std::string_view b); - bool StringIContains(std::string_view haystack, std::string_view needle); - bool StringHasPrefixIC(std::string_view view, std::string_view prefix); - - String StringToArduinoString(std::string_view view); + // Case-insensitive ASCII comparisons. Length-aware, so they are safe on non-NUL-terminated views (e.g. a value + // straight out of a JSON/jsmn parser). + bool StringIEquals(std::string_view a, std::string_view b) noexcept; + bool StringIContains(std::string_view haystack, std::string_view needle) noexcept; + bool StringHasPrefixIC(std::string_view view, std::string_view prefix) noexcept; } // namespace OpenShock diff --git a/include/TinyVec.h b/components/common/include/TinyVec.h similarity index 78% rename from include/TinyVec.h rename to components/common/include/TinyVec.h index 54027e3a..d5cf4572 100644 --- a/include/TinyVec.h +++ b/components/common/include/TinyVec.h @@ -1,14 +1,14 @@ #pragma once -#include "Common.h" #include "Logging.h" +#include "OpenShock.h" #include #include #include #include #include -#include +#include #include #include @@ -21,10 +21,12 @@ class TinyVec { public: TinyVec() noexcept = default; + // Zero-initialized, like resize() TinyVec(SizeType count) { if (count == 0) return; reserve(count); + memset(_data, 0, size_t(count) * sizeof(T)); _len = count; } TinyVec(const T* src, SizeType count) @@ -68,6 +70,14 @@ class TinyVec { T& operator[](SizeType i) noexcept { return _data[i]; } const T& operator[](SizeType i) const noexcept { return _data[i]; } + T* begin() noexcept { return _data; } + const T* begin() const noexcept { return _data; } + T* end() noexcept { return _data + _len; } + const T* end() const noexcept { return _data + _len; } + + operator std::span() noexcept { return {_data, static_cast(_len)}; } + operator std::span() const noexcept { return {_data, static_cast(_len)}; } + void reserve(SizeType new_cap) { if (new_cap <= _cap) return; @@ -98,6 +108,7 @@ class TinyVec { void append(const T* src, SizeType count) { if (!src || count == 0) return; + if (count > std::numeric_limits::max() - _len) OS_PANIC_INSTANT("TVEC", "Length overflow!"); SizeType new_len = _len + count; if (new_len > _cap) reserve(next_cap(new_len)); memcpy(_data + _len, src, size_t(count) * sizeof(T)); @@ -120,7 +131,9 @@ class TinyVec { private: SizeType next_cap(SizeType min_needed) const noexcept { - SizeType newcap = _cap ? (_cap + _cap / 2 + 1) : 4; + // Grow by 1.5x, saturating instead of wrapping around near the SizeType limit + constexpr SizeType kMax = std::numeric_limits::max(); + SizeType newcap = _cap == 0 ? 4 : (_cap > (kMax - 1) / 3 * 2 ? kMax : SizeType(_cap + _cap / 2 + 1)); if (newcap < min_needed) newcap = min_needed; return newcap; } diff --git a/components/common/include/enums/AccountLinkResultCode.h b/components/common/include/enums/AccountLinkResultCode.h new file mode 100644 index 00000000..2258fc8b --- /dev/null +++ b/components/common/include/enums/AccountLinkResultCode.h @@ -0,0 +1,53 @@ +#pragma once + +#include + +namespace OpenShock { + enum class AccountLinkResultCode : uint8_t { + Success = 0, + CodeRequired = 1, + InvalidCodeLength = 2, + NoInternetConnection = 3, + InvalidCode = 4, + RateLimited = 5, + InternalError = 6, + // More descriptive failure causes (previously all reported as InternalError) + RequestFailed = 7, // Could not start/complete the HTTP request (DNS, TLS, connection refused, ...) + RequestTimedOut = 8, // The request to the backend timed out + ServerError = 9, // The backend returned an unexpected response code + InvalidResponse = 10, // The backend response could not be parsed or was missing the auth token + ConfigSaveFailed = 11, // Failed to persist the auth token to flash + }; + + /// @brief The code's name, as used for the captive portal's JSON error codes. + constexpr const char* AccountLinkResultCodeToString(AccountLinkResultCode code) + { + switch (code) { + case AccountLinkResultCode::Success: + return "Success"; + case AccountLinkResultCode::CodeRequired: + return "CodeRequired"; + case AccountLinkResultCode::InvalidCodeLength: + return "InvalidCodeLength"; + case AccountLinkResultCode::NoInternetConnection: + return "NoInternetConnection"; + case AccountLinkResultCode::InvalidCode: + return "InvalidCode"; + case AccountLinkResultCode::RateLimited: + return "RateLimited"; + case AccountLinkResultCode::RequestFailed: + return "RequestFailed"; + case AccountLinkResultCode::RequestTimedOut: + return "RequestTimedOut"; + case AccountLinkResultCode::ServerError: + return "ServerError"; + case AccountLinkResultCode::InvalidResponse: + return "InvalidResponse"; + case AccountLinkResultCode::ConfigSaveFailed: + return "ConfigSaveFailed"; + case AccountLinkResultCode::InternalError: + default: + return "InternalError"; + } + } +} // namespace OpenShock diff --git a/components/common/include/enums/FirmwareBootType.h b/components/common/include/enums/FirmwareBootType.h new file mode 100644 index 00000000..454ef281 --- /dev/null +++ b/components/common/include/enums/FirmwareBootType.h @@ -0,0 +1,52 @@ +#pragma once + +#include "enums/OtaUpdateStep.h" +#include "StringHelpers.h" + +#include +#include + +namespace OpenShock { + enum class FirmwareBootType : uint8_t { + Normal, + NewFirmware, + Rollback + }; + + inline bool TryParseFirmwareBootType(FirmwareBootType& bootType, std::string_view str) + { + if (StringIEquals(str, "normal")) { + bootType = FirmwareBootType::Normal; + return true; + } + + if (StringIEquals(str, "newfirmware") || StringIEquals(str, "new_firmware")) { + bootType = FirmwareBootType::NewFirmware; + return true; + } + + if (StringIEquals(str, "rollback")) { + bootType = FirmwareBootType::Rollback; + return true; + } + + return false; + } + + // Maps the OTA update step persisted in config (read once at boot) to the boot + // type this startup represents. + constexpr FirmwareBootType InferFirmwareBootType(OtaUpdateStep step) + { + switch (step) { + case OtaUpdateStep::Updated: + return FirmwareBootType::NewFirmware; + // Validating means we crashed mid-validation of the new firmware, so this + // boot is the rollback to the previous image. + case OtaUpdateStep::Validating: + case OtaUpdateStep::RollingBack: + return FirmwareBootType::Rollback; + default: + return FirmwareBootType::Normal; + } + } +} // namespace OpenShock diff --git a/include/GatewayClientState.h b/components/common/include/enums/GatewayClientState.h similarity index 100% rename from include/GatewayClientState.h rename to components/common/include/enums/GatewayClientState.h diff --git a/include/OtaUpdateChannel.h b/components/common/include/enums/OtaUpdateChannel.h similarity index 53% rename from include/OtaUpdateChannel.h rename to components/common/include/enums/OtaUpdateChannel.h index 3ab942b3..9172b27d 100644 --- a/include/OtaUpdateChannel.h +++ b/components/common/include/enums/OtaUpdateChannel.h @@ -1,26 +1,30 @@ #pragma once -#include "serialization/_fbs/HubConfig_generated.h" +#include "StringHelpers.h" #include -#include +#include namespace OpenShock { - typedef OpenShock::Serialization::Configuration::OtaUpdateChannel OtaUpdateChannel; + enum class OtaUpdateChannel : uint8_t { + Stable, + Beta, + Develop + }; - inline bool TryParseOtaUpdateChannel(OtaUpdateChannel& channel, const char* str) + inline bool TryParseOtaUpdateChannel(OtaUpdateChannel& channel, std::string_view str) { - if (strcasecmp(str, "stable") == 0) { + if (StringIEquals(str, "stable")) { channel = OtaUpdateChannel::Stable; return true; } - if (strcasecmp(str, "beta") == 0) { + if (StringIEquals(str, "beta")) { channel = OtaUpdateChannel::Beta; return true; } - if (strcasecmp(str, "develop") == 0 || strcasecmp(str, "dev") == 0) { + if (StringIEquals(str, "develop") || StringIEquals(str, "dev")) { channel = OtaUpdateChannel::Develop; return true; } diff --git a/include/OtaUpdateStep.h b/components/common/include/enums/OtaUpdateStep.h similarity index 50% rename from include/OtaUpdateStep.h rename to components/common/include/enums/OtaUpdateStep.h index 8fae996f..68ef3908 100644 --- a/include/OtaUpdateStep.h +++ b/components/common/include/enums/OtaUpdateStep.h @@ -1,41 +1,48 @@ #pragma once -#include "serialization/_fbs/HubConfig_generated.h" +#include "StringHelpers.h" #include -#include +#include namespace OpenShock { - typedef OpenShock::Serialization::Configuration::OtaUpdateStep OtaUpdateStep; - - inline bool TryParseOtaUpdateStep(OtaUpdateStep& channel, const char* str) + enum class OtaUpdateStep : uint8_t { + None, + Updating, + Updated, + Validating, + Validated, + RollingBack + }; + + inline bool TryParseOtaUpdateStep(OtaUpdateStep& channel, std::string_view str) { - if (strcasecmp(str, "none") == 0) { + if (StringIEquals(str, "none")) { channel = OtaUpdateStep::None; return true; } - if (strcasecmp(str, "updating") == 0) { + if (StringIEquals(str, "updating")) { channel = OtaUpdateStep::Updating; return true; } - if (strcasecmp(str, "updated") == 0) { + if (StringIEquals(str, "updated")) { channel = OtaUpdateStep::Updated; return true; } - if (strcasecmp(str, "validating") == 0) { + if (StringIEquals(str, "validating")) { channel = OtaUpdateStep::Validating; return true; } - if (strcasecmp(str, "validated") == 0) { + if (StringIEquals(str, "validated")) { channel = OtaUpdateStep::Validated; return true; } - if (strcasecmp(str, "rollingback") == 0) { + if (StringIEquals(str, "rollingback")) { channel = OtaUpdateStep::RollingBack; return true; } diff --git a/include/SetGPIOResultCode.h b/components/common/include/enums/SetGPIOResultCode.h similarity index 89% rename from include/SetGPIOResultCode.h rename to components/common/include/enums/SetGPIOResultCode.h index 0dcdf443..1fb01d82 100644 --- a/include/SetGPIOResultCode.h +++ b/components/common/include/enums/SetGPIOResultCode.h @@ -7,5 +7,6 @@ namespace OpenShock { Success = 0, InvalidPin = 1, InternalError = 2, + PinInUse = 3, }; } // namespace OpenShock diff --git a/include/ShockerCommandType.h b/components/common/include/enums/ShockerCommandType.h similarity index 59% rename from include/ShockerCommandType.h rename to components/common/include/enums/ShockerCommandType.h index f23f34a6..d1f9dd23 100644 --- a/include/ShockerCommandType.h +++ b/components/common/include/enums/ShockerCommandType.h @@ -1,7 +1,9 @@ #pragma once +#include "StringHelpers.h" + #include -#include +#include namespace OpenShock { enum class ShockerCommandType : uint8_t { @@ -12,21 +14,21 @@ namespace OpenShock { Light }; - inline bool ShockerCommandTypeFromString(const char* str, ShockerCommandType& out) + inline bool TryParseShockerCommandType(ShockerCommandType& out, std::string_view str) { - if (strcasecmp(str, "stop") == 0) { + if (StringIEquals(str, "stop")) { out = ShockerCommandType::Stop; return true; - } else if (strcasecmp(str, "shock") == 0) { + } else if (StringIEquals(str, "shock")) { out = ShockerCommandType::Shock; return true; - } else if (strcasecmp(str, "vibrate") == 0) { + } else if (StringIEquals(str, "vibrate")) { out = ShockerCommandType::Vibrate; return true; - } else if (strcasecmp(str, "sound") == 0) { + } else if (StringIEquals(str, "sound")) { out = ShockerCommandType::Sound; return true; - } else if (strcasecmp(str, "light") == 0) { + } else if (StringIEquals(str, "light")) { out = ShockerCommandType::Light; return true; } else { diff --git a/include/ShockerModelType.h b/components/common/include/enums/ShockerModelType.h similarity index 55% rename from include/ShockerModelType.h rename to components/common/include/enums/ShockerModelType.h index 016d52a0..2bccaf6d 100644 --- a/include/ShockerModelType.h +++ b/components/common/include/enums/ShockerModelType.h @@ -1,7 +1,9 @@ #pragma once +#include "StringHelpers.h" + #include -#include +#include namespace OpenShock { enum class ShockerModelType : uint8_t { @@ -12,39 +14,39 @@ namespace OpenShock { D80 }; - inline bool ShockerModelTypeFromString(const char* str, ShockerModelType& out, bool allowTypo = false) + inline bool TryParseShockerModelType(ShockerModelType& out, std::string_view str, bool allowTypo = false) { - if (strcasecmp(str, "caixianlin") == 0 || strcasecmp(str, "cai-xianlin") == 0) { + if (StringIEquals(str, "caixianlin") || StringIEquals(str, "cai-xianlin")) { out = ShockerModelType::CaiXianlin; return true; } - if (strcasecmp(str, "petrainer") == 0) { + if (StringIEquals(str, "petrainer")) { out = ShockerModelType::Petrainer; return true; } - if (allowTypo && strcasecmp(str, "pettrainer") == 0) { + if (allowTypo && StringIEquals(str, "pettrainer")) { out = ShockerModelType::Petrainer; return true; } - if (strcasecmp(str, "petrainer998dr") == 0) { + if (StringIEquals(str, "petrainer998dr")) { out = ShockerModelType::Petrainer998DR; return true; } - if (allowTypo && strcasecmp(str, "pettrainer998dr") == 0) { + if (allowTypo && StringIEquals(str, "pettrainer998dr")) { out = ShockerModelType::Petrainer998DR; return true; } - if (strcasecmp(str, "wellturnt330") == 0 || strcasecmp(str, "t330") == 0) { + if (StringIEquals(str, "wellturnt330") || StringIEquals(str, "t330")) { out = ShockerModelType::WellturnT330; return true; } - if (strcasecmp(str, "d80") == 0) { + if (StringIEquals(str, "d80")) { out = ShockerModelType::D80; return true; } diff --git a/include/WebSocketMessageType.h b/components/common/include/enums/WebSocketMessageType.h similarity index 100% rename from include/WebSocketMessageType.h rename to components/common/include/enums/WebSocketMessageType.h diff --git a/components/common/include/util/Backoff.h b/components/common/include/util/Backoff.h new file mode 100644 index 00000000..f00d81ea --- /dev/null +++ b/components/common/include/util/Backoff.h @@ -0,0 +1,40 @@ +#pragma once + +#include +#include + +namespace OpenShock { + /// @brief When the next attempt at something may be made, backing off after failures. + /// + /// Each backOff() pushes the next attempt out by a delay that starts at `initialMs` and doubles up to `maxMs` + /// (equal values give a fixed hold-off); reset() allows the next attempt immediately. Times are in milliseconds + /// (OpenShock::millis()). Not thread-safe: owned by one task. + class Backoff { + public: + constexpr Backoff(int64_t initialMs, int64_t maxMs) + : m_initialMs(initialMs) + , m_maxMs(maxMs) + { + } + + bool ready(int64_t nowMs) const { return nowMs >= m_nextAttemptMs; } + + void backOff(int64_t nowMs) + { + m_delayMs = m_delayMs == 0 ? m_initialMs : std::min(m_delayMs * 2, m_maxMs); + m_nextAttemptMs = nowMs + m_delayMs; + } + + void reset() + { + m_delayMs = 0; + m_nextAttemptMs = 0; + } + + private: + int64_t m_initialMs; + int64_t m_maxMs; + int64_t m_delayMs = 0; + int64_t m_nextAttemptMs = 0; + }; +} // namespace OpenShock diff --git a/components/common/include/util/DigitCounter.h b/components/common/include/util/DigitCounter.h new file mode 100644 index 00000000..1ae63fb7 --- /dev/null +++ b/components/common/include/util/DigitCounter.h @@ -0,0 +1,40 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace OpenShock::Util { + // Maximum number of base-10 characters T can render to: its digit count plus a '-' for signed types. + template + inline constexpr int Digits10CountMax = std::numeric_limits::digits10 + (std::is_signed_v ? 2 : 1); + + // Number of base-10 characters val renders to, counting a leading '-' when negative. + template + constexpr std::size_t Digits10Count(T val) + { + std::size_t count = 1; + std::make_unsigned_t mag; + + if constexpr (std::is_signed_v) { + if (val < 0) { + // the leading '-' + ++count; + mag = std::make_unsigned_t(0) - static_cast>(val); // magnitude via modular negation + } else { + mag = static_cast>(val); + } + } else { + mag = val; + } + + while (mag >= 10) { + mag /= 10; + ++count; + } + + return count; + } +} // namespace OpenShock::Util diff --git a/components/common/include/util/FnProxy.h b/components/common/include/util/FnProxy.h new file mode 100644 index 00000000..07a2345c --- /dev/null +++ b/components/common/include/util/FnProxy.h @@ -0,0 +1,45 @@ +#pragma once + +#include + +namespace OpenShock::Util { + + namespace detail { + // NX carries the source method's noexcept-ness onto the trampoline (and thus + // onto the resulting function-pointer type); the const specializations bind C + // as `const C` so the cast matches the method's constness. + template + struct Body { + static R call(void* self, A... args) noexcept(NX) { return (static_cast(self)->*MF)(std::forward(args)...); } + }; + template + struct Deduce; + template + struct Deduce : Body { }; + template + struct Deduce : Body { }; + template + struct Deduce : Body { }; + template + struct Deduce : Body { }; + // (ref-qualified &/&& members omitted, meaningless for a void* callback.) + } // namespace detail + + /// Variable template: FnProxy<&Class::method> is a plain function pointer, + /// R(*)(void*, Args...), that casts the void* back to Class* and calls the + /// method. It exists to hand C++ member functions to C callback APIs that pass + /// context as a void* (FreeRTOS tasks, esp_event handlers). + /// + /// Usage: + /// auto cb = FnProxy<&Sensor::read>; // R(*)(void*, float) + /// Sensor s; + /// cb(&s, 1.5f); + /// + /// Works for any non-ref-qualified member function. The four Deduce + /// specializations cover the const/noexcept combinations, each forwarding the + /// deduced signature to the single Body::call trampoline whose address becomes + /// the resulting function pointer. A noexcept method yields a noexcept function + /// pointer, which still converts to a plain one for C APIs. + template + constexpr auto FnProxy = &detail::Deduce::call; +} // namespace OpenShock::Util diff --git a/include/util/HexUtils.h b/components/common/include/util/HexUtils.h similarity index 91% rename from include/util/HexUtils.h rename to components/common/include/util/HexUtils.h index 8faef9c0..9bb1504f 100644 --- a/include/util/HexUtils.h +++ b/components/common/include/util/HexUtils.h @@ -174,30 +174,4 @@ namespace OpenShock::HexUtils { { return TryParseHex(str, strlen(str), out, outLen); } - - template - constexpr bool TryParseHexToInt(const char* str, std::size_t strLen, T& out) - { - if (strLen == 0 || strLen > sizeof(T) * 2) { - return false; - } - - out = 0; - - const char* end = str + strLen; - while (str < end) { - char c = *str++; - if (c >= '0' && c <= '9') { - out = (out << 4) | (c - '0'); - } else if (c >= 'a' && c <= 'f') { - out = (out << 4) | (c - 'a' + 10); - } else if (c >= 'A' && c <= 'F') { - out = (out << 4) | (c - 'A' + 10); - } else { - return false; - } - } - - return true; - } } // namespace OpenShock::HexUtils diff --git a/components/common/include/util/ManagedTask.h b/components/common/include/util/ManagedTask.h new file mode 100644 index 00000000..528611e4 --- /dev/null +++ b/components/common/include/util/ManagedTask.h @@ -0,0 +1,144 @@ +#pragma once + +#include "OpenShock.h" +#include "SimpleMutex.h" +#include "util/TaskUtils.h" + +#include +#include + +#include +#include +#include +#include + +namespace OpenShock { + /// @brief Owns one FreeRTOS task: its handle, its stop request and its exit signal. + /// + /// The body is an ordinary function that returns when it is done. Returning destroys its locals before the task + /// deletes itself, and the exit is published through TaskUtils::TaskExiting(), so the body never calls + /// vTaskDelete() and never touches the exit flag. A long-running body polls stopRequested() at its wait points. + /// + /// stop() requests the stop, wakes the task and waits for it to exit (force-killing it after the timeout). The + /// default wake is a task notification, which ends a ulTaskNotifyTake() wait; a task that blocks on something else + /// (a queue) passes its own wake, e.g. one that posts a sentinel. + /// + /// start() and stop() must be serialized by the owner; notify(), running() and stopRequested() may be called from + /// any task. Owners must call stop() in their own destructor before releasing anything the body uses; the destructor + /// here is only a safety net. + class ManagedTask { + DISABLE_COPY(ManagedTask); + DISABLE_MOVE(ManagedTask); + + public: + ManagedTask(const char* tag, const char* description) + : m_tag(tag) + , m_description(description) + { + } + ~ManagedTask() { stop(); } + + /// @brief Creates the task on `core` (-1 for any core). Returns true if it is already running. + bool start(const char* name, uint32_t stackSize, UBaseType_t priority, BaseType_t core, std::function body) + { + ScopedLock lock__(&m_handleMutex); + + if (m_handle != nullptr) { + return true; + } + + m_body = std::move(body); + m_stopRequested.store(false, std::memory_order_relaxed); + m_handleReleased.store(false, std::memory_order_relaxed); + m_exited.store(false, std::memory_order_relaxed); + + TaskHandle_t handle = nullptr; + if (TaskUtils::TaskCreateUniversal(&ManagedTask::entry, name, stackSize, this, priority, &handle, core) != pdPASS) { + m_body = nullptr; + return false; + } + m_handle = handle; + return true; + } + + /// @brief Same as start(), on the core that does expensive work (see TaskUtils::TaskCreateExpensive). + bool startExpensive(const char* name, uint32_t stackSize, UBaseType_t priority, std::function body) { return start(name, stackSize, priority, 1, std::move(body)); } + + /// @brief Requests a stop, wakes the task with a task notification and waits for it to exit. + void stop(TickType_t timeout = pdMS_TO_TICKS(1000)) + { + stop([this] { xTaskNotifyGive(m_handle); }, timeout); + } + + /// @brief Requests a stop, wakes the task with `wake` and waits for it to exit. + template + void stop(Wake&& wake, TickType_t timeout = pdMS_TO_TICKS(1000)) + { + TaskHandle_t handle; + { + ScopedLock lock__(&m_handleMutex); + + handle = m_handle; + if (handle == nullptr) { + return; + } + + m_stopRequested.store(true, std::memory_order_relaxed); + wake(); + + // From here on nothing touches the handle except a force-kill of a task that never exited, so the task may + // now delete itself (see entry()). notify() checks m_stopRequested under the same lock, so it can't either. + m_handleReleased.store(true, std::memory_order_release); + } + + TaskUtils::StopTask(handle, m_exited, m_tag, m_description, timeout); + + ScopedLock lock__(&m_handleMutex); + m_handle = nullptr; + m_body = nullptr; + } + + /// @brief Wakes a task blocked in ulTaskNotifyTake() without stopping it. Safe from any task. + void notify() + { + ScopedLock lock__(&m_handleMutex); + if (m_handle != nullptr && !m_stopRequested.load(std::memory_order_relaxed)) { + xTaskNotifyGive(m_handle); + } + } + + bool running() const + { + ScopedLock lock__(&m_handleMutex); + return m_handle != nullptr; + } + + /// @brief For the body: true once stop() has been called. + bool stopRequested() const { return m_stopRequested.load(std::memory_order_relaxed); } + + private: + static void entry(void* arg) + { + auto* self = static_cast(arg); + self->m_body(); + + // A task that polls stopRequested() can see the request and get here while stop() is still about to wake it + // through the handle. Deleting itself now would let the idle task free the TCB under that wake, so hold on + // until stop() is done with the handle. A body that returns without a stop request waits here for stop() too. + while (!self->m_handleReleased.load(std::memory_order_acquire)) { + vTaskDelay(pdMS_TO_TICKS(10)); + } + + TaskUtils::TaskExiting(self->m_exited); + } + + const char* m_tag; + const char* m_description; + std::function m_body; + mutable SimpleMutex m_handleMutex; // Guards m_handle against notify()/running() on other tasks during start()/stop() + TaskHandle_t m_handle = nullptr; + TaskUtils::TaskExitFlag m_exited {false}; + std::atomic m_stopRequested {false}; + std::atomic m_handleReleased {false}; // Set by stop() once it no longer touches the handle + }; +} // namespace OpenShock diff --git a/components/common/include/util/RetiredList.h b/components/common/include/util/RetiredList.h new file mode 100644 index 00000000..be1fe6dd --- /dev/null +++ b/components/common/include/util/RetiredList.h @@ -0,0 +1,60 @@ +#pragma once + +#include "OpenShock.h" +#include "SimpleMutex.h" + +#include +#include + +namespace OpenShock { + /// @brief Objects taken out of service whose destruction must run on one owning task. + /// + /// Other tasks may still hold a std::shared_ptr copy for a moment (e.g. while sending through it). Dropping that copy + /// would otherwise run the destructor on whichever task happened to hold it last, and destructors that stop servers + /// or tasks must not run on an event-loop, httpd or websocket task. Retired objects stay referenced here until the + /// owner calls reap(), which destroys, on the calling task, every one that nobody else holds any more. + template + class RetiredList { + DISABLE_COPY(RetiredList); + DISABLE_MOVE(RetiredList); + + public: + RetiredList() = default; + + void retire(std::shared_ptr object) + { + if (object == nullptr) { + return; + } + + ScopedLock lock__(&m_mutex); + m_objects.push_back(std::move(object)); + } + + /// @brief Destroys the retired objects no other task still references. Returns true once none are left. + bool reap() + { + std::vector> reapable; + bool empty; + { + ScopedLock lock__(&m_mutex); + for (auto it = m_objects.begin(); it != m_objects.end();) { + // Nothing hands out new references to a retired object, so a use_count of 1 means this list is the sole owner. + if (it->use_count() == 1) { + reapable.push_back(std::move(*it)); + it = m_objects.erase(it); + } else { + ++it; + } + } + empty = m_objects.empty(); + } + // `reapable` goes out of scope here, outside the lock, running the destructors on this task. + return empty; + } + + private: + SimpleMutex m_mutex; + std::vector> m_objects; + }; +} // namespace OpenShock diff --git a/components/common/include/util/TaskUtils.h b/components/common/include/util/TaskUtils.h new file mode 100644 index 00000000..c11e9bf8 --- /dev/null +++ b/components/common/include/util/TaskUtils.h @@ -0,0 +1,34 @@ +#pragma once + +#include + +#include +#include + +// For a task that is started and stopped, prefer util/ManagedTask.h, which wraps TaskExiting() and StopTask(). +namespace OpenShock::TaskUtils { + /// @brief A task's exit signal, owned by whoever owns the task. + /// The task publishes it through TaskExiting() immediately before deleting + /// itself, and StopTask() waits on it. It exists because a self-deleted + /// task's handle cannot be inspected: vTaskDelete() hands the TCB to the + /// idle task, which frees it, so eTaskGetState() on that handle is a + /// use-after-free once the idle task has run. + using TaskExitFlag = std::atomic; + + /// @brief Create a task on the specified core, or the default core if the specified core is invalid + BaseType_t TaskCreateUniversal(TaskFunction_t pvTaskCode, const char* const pcName, const uint32_t usStackDepth, void* const pvParameters, UBaseType_t uxPriority, TaskHandle_t* const pvCreatedTask, const BaseType_t xCoreID); + + /// @brief Create a task on the core that does expensive work, this should not run on the core that handles WiFi + BaseType_t TaskCreateExpensive(TaskFunction_t pvTaskCode, const char* const pcName, const uint32_t usStackDepth, void* const pvParameters, UBaseType_t uxPriority, TaskHandle_t* const pvCreatedTask); + + /// @brief Ends the calling task. Use this at the end of a task function instead of + /// vTaskDelete(nullptr), so StopTask() can tell that the task is gone without + /// touching its handle. Does not return. + void TaskExiting(TaskExitFlag& exited); + + /// @brief Waits for a task to exit within the given timeout. Force-kills it if it doesn't exit in time. + /// The caller is responsible for signaling the task to stop before calling this. + /// `exited` must be the same flag the task passes to TaskExiting(), and must be + /// cleared before the task is created. + void StopTask(TaskHandle_t taskHandle, TaskExitFlag& exited, const char* tag, const char* taskName, TickType_t timeout = pdMS_TO_TICKS(1000)); +} // namespace OpenShock::TaskUtils diff --git a/src/Convert.cpp b/components/common/src/Convert.cpp similarity index 99% rename from src/Convert.cpp rename to components/common/src/Convert.cpp index 8aa694d6..477ef505 100644 --- a/src/Convert.cpp +++ b/components/common/src/Convert.cpp @@ -1,7 +1,7 @@ #include "Convert.h" +#include "StringHelpers.h" #include "util/DigitCounter.h" -#include "util/StringUtils.h" #include #include diff --git a/src/util/DigitCounter.cpp b/components/common/src/DigitCounter.cpp similarity index 100% rename from src/util/DigitCounter.cpp rename to components/common/src/DigitCounter.cpp diff --git a/src/RateLimiter.cpp b/components/common/src/RateLimiter.cpp similarity index 90% rename from src/RateLimiter.cpp rename to components/common/src/RateLimiter.cpp index 594fd97d..8f042e96 100644 --- a/src/RateLimiter.cpp +++ b/components/common/src/RateLimiter.cpp @@ -2,7 +2,7 @@ #include "RateLimiter.h" -#include "Core.h" +#include "Temporal.h" #include @@ -50,18 +50,15 @@ bool OpenShock::RateLimiter::tryRequest() if (m_limits.empty()) { return true; } - if (m_requests.empty()) { - m_requests.push_back(now); - return true; - } // Cleanup based on longest limit if (m_nextCleanup <= now) { int64_t longestLimit = m_limits.back().durationMs; int64_t expiresAt = now - longestLimit; - // erase everything that’s expired - auto firstAlive = std::upper_bound(m_requests.begin(), m_requests.end(), expiresAt); + // Erase everything that left the longest window. A request exactly at the window start still counts (see the + // `*it < windowStart` check below), so only strictly older ones are dropped. + auto firstAlive = std::lower_bound(m_requests.begin(), m_requests.end(), expiresAt); m_requests.erase(m_requests.begin(), firstAlive); if (!m_requests.empty()) { diff --git a/src/ReadWriteMutex.cpp b/components/common/src/ReadWriteMutex.cpp similarity index 85% rename from src/ReadWriteMutex.cpp rename to components/common/src/ReadWriteMutex.cpp index f893a14a..fc98efd7 100644 --- a/src/ReadWriteMutex.cpp +++ b/components/common/src/ReadWriteMutex.cpp @@ -7,10 +7,13 @@ const char* const TAG = "ReadWriteMutex"; #include "Logging.h" OpenShock::ReadWriteMutex::ReadWriteMutex() - : m_mutex(xSemaphoreCreateMutex()) + // Binary semaphore, not a mutex: the first reader takes it but the last reader (possibly another task) gives it back, + // which an owned FreeRTOS mutex does not allow. + : m_mutex(xSemaphoreCreateBinary()) , m_readSem(xSemaphoreCreateBinary()) , m_readers(0) { + xSemaphoreGive(m_mutex); xSemaphoreGive(m_readSem); } diff --git a/src/SemVer.cpp b/components/common/src/SemVer.cpp similarity index 51% rename from src/SemVer.cpp rename to components/common/src/SemVer.cpp index 1ac4a064..c20149d6 100644 --- a/src/SemVer.cpp +++ b/components/common/src/SemVer.cpp @@ -4,8 +4,8 @@ const char* const TAG = "SemVer"; #include "Convert.h" #include "Logging.h" +#include "StringHelpers.h" #include "util/DigitCounter.h" -#include "util/StringUtils.h" using namespace OpenShock; @@ -156,75 +156,19 @@ static constexpr bool semverIsDotSeperatedPreleaseIdentifiers(std::string_view s } // For readability -#define semverIsPatch semverIsNumericIdentifier -#define semverIsMinor semverIsNumericIdentifier -#define semverIsMajor semverIsNumericIdentifier #define semverIsPrerelease semverIsDotSeperatedPreleaseIdentifiers #define semverIsBuild semverIsDotSeperatedBuildIdentifiers -static constexpr bool semverIsVersionCore(std::string_view str) -{ - if (str.empty()) { - return false; - } - - std::string_view parts[3]; - if (!OpenShock::TryStringSplit(str, '.', parts)) { - return false; - } - - return semverIsMajor(parts[0]) && semverIsMinor(parts[1]) && semverIsPatch(parts[2]); -} -static constexpr bool semverIsSemver(std::string_view str) -{ - if (str.empty()) { - return false; - } - - auto dashPos = str.find('-'); - auto plusPos = str.find('+'); - - if (dashPos == std::string_view::npos && plusPos == std::string_view::npos) { - return semverIsVersionCore(str); - } - - if (dashPos != std::string_view::npos && plusPos != std::string_view::npos) { - if (dashPos > plusPos) { - return false; - } - - auto core = str.substr(0, dashPos); - auto prerelease = str.substr(dashPos + 1, plusPos - dashPos - 1); - auto build = str.substr(plusPos + 1); - - return semverIsVersionCore(core) && semverIsPrerelease(prerelease) && semverIsBuild(build); - } - - if (dashPos != std::string_view::npos) { - auto core = str.substr(0, dashPos); - auto prerelease = str.substr(dashPos + 1); - - return semverIsVersionCore(core) && semverIsPrerelease(prerelease); - } - - if (plusPos != std::string_view::npos) { - auto core = str.substr(0, plusPos); - auto build = str.substr(plusPos + 1); - - return semverIsVersionCore(core) && semverIsBuild(build); - } - - return false; -} #pragma endregion bool SemVer::isValid() const { - if (!this->prerelease.empty() && !semverIsPrereleaseIdentifier(this->prerelease)) { + // Same dot-separated grammar TryParseSemVer accepts (e.g. "rc.7") + if (!this->prerelease.empty() && !semverIsPrerelease(this->prerelease)) { return false; } - if (!this->build.empty() && !semverIsBuildIdentifier(this->build)) { + if (!this->build.empty() && !semverIsBuild(this->build)) { return false; } @@ -268,37 +212,71 @@ bool SemVer::operator==(const SemVer& other) const return major == other.major && minor == other.minor && patch == other.patch && prerelease == other.prerelease && build == other.build; } -bool SemVer::operator<(const SemVer& other) const +static bool semverIdentifierIsNumeric(std::string_view id) { - if (major < other.major) { - return true; - } - if (major > other.major) { + if (id.empty()) { return false; } - - if (minor < other.minor) { - return true; - } - if (minor > other.minor) { - return false; + for (char c : id) { + if (c < '0' || c > '9') { + return false; + } } + return true; +} - if (patch < other.patch) { - return true; - } - if (patch > other.patch) { - return false; +// Compare two non-empty dot-separated prerelease strings per semver 11.4: +// numeric identifiers compare numerically and rank below alphanumeric ones; +// alphanumeric compare in ASCII order; a larger set of identifiers outranks a +// smaller one when all preceding identifiers are equal. Returns <0 / 0 / >0. +static int semverComparePrerelease(std::string_view a, std::string_view b) +{ + while (!a.empty() || !b.empty()) { + if (a.empty()) return -1; // a ran out first -> fewer identifiers -> lower + if (b.empty()) return 1; + + size_t ad = a.find('.'); + size_t bd = b.find('.'); + std::string_view aid = a.substr(0, ad); + std::string_view bid = b.substr(0, bd); + + bool an = semverIdentifierIsNumeric(aid); + bool bn = semverIdentifierIsNumeric(bid); + if (an && bn) { + if (aid.length() != bid.length()) return aid.length() < bid.length() ? -1 : 1; // no leading zeros + int c = aid.compare(bid); + if (c != 0) return c < 0 ? -1 : 1; + } else if (an != bn) { + return an ? -1 : 1; // numeric ranks below alphanumeric + } else { + int c = aid.compare(bid); + if (c != 0) return c < 0 ? -1 : 1; + } + + a = (ad == std::string_view::npos) ? std::string_view {} : a.substr(ad + 1); + b = (bd == std::string_view::npos) ? std::string_view {} : b.substr(bd + 1); } + return 0; +} - if (prerelease < other.prerelease) { - return true; +bool SemVer::operator<(const SemVer& other) const +{ + if (major != other.major) return major < other.major; + if (minor != other.minor) return minor < other.minor; + if (patch != other.patch) return patch < other.patch; + + // Prerelease precedence (semver 11.3): a version WITH a prerelease is lower + // than the associated normal version. Build metadata is ignored (semver 10). + bool thisPre = !prerelease.empty(); + bool otherPre = !other.prerelease.empty(); + if (thisPre != otherPre) { + return thisPre; // this has a prerelease, other is the release -> this < other } - if (prerelease > other.prerelease) { - return false; + if (!thisPre) { + return false; // both normal versions of the same core -> equal precedence } - return build < other.build; + return semverComparePrerelease(prerelease, other.prerelease) < 0; } bool SemVer::operator==(std::string_view other) const @@ -314,67 +292,83 @@ bool SemVer::operator==(std::string_view other) const bool SemVer::operator<(std::string_view other) const { SemVer otherSemVer; - if (!OpenShock::TryParseSemVer(other, otherSemVer)) { - return false; - } + return OpenShock::TryParseSemVer(other, otherSemVer) && *this < otherSemVer; +} - return *this < otherSemVer; +bool SemVer::operator<=(std::string_view other) const +{ + SemVer otherSemVer; + return OpenShock::TryParseSemVer(other, otherSemVer) && *this <= otherSemVer; } -bool OpenShock::TryParseSemVer(std::string_view semverStr, SemVer& semver) +bool SemVer::operator>(std::string_view other) const { - std::string_view parts[3]; - if (!OpenShock::TryStringSplit(semverStr, '.', parts)) { - OS_LOGE(TAG, "Failed to split version string: %.*s", semverStr.length(), semverStr.data()); - return false; - } + SemVer otherSemVer; + return OpenShock::TryParseSemVer(other, otherSemVer) && *this > otherSemVer; +} - std::string_view majorStr = parts[0], minorStr = parts[1], patchStr = parts[2]; +bool SemVer::operator>=(std::string_view other) const +{ + SemVer otherSemVer; + return OpenShock::TryParseSemVer(other, otherSemVer) && *this >= otherSemVer; +} - size_t plusIdx = patchStr.find('+'); - size_t dashIdx = patchStr.find('-'); +bool OpenShock::TryParseSemVer(std::string_view semverStr, SemVer& out) +{ + // Parse into a local and assign on success only, so a failed parse leaves `out` untouched. + SemVer semver; + + // Peel off build (after the first '+') then prerelease (after the first '-'), + // leaving the bare major.minor.patch core. Splitting the whole string by '.' + // up front would corrupt a dotted prerelease/build such as "-rc.7". + std::string_view rest = semverStr; + + // A separator must be followed by at least one identifier ("1.0.0-" and "1.0.0+" are invalid). + std::string_view build; + size_t plusIdx = rest.find('+'); + if (plusIdx != std::string_view::npos) { + build = rest.substr(plusIdx + 1); + rest = rest.substr(0, plusIdx); + if (build.empty()) { + OS_LOGE(TAG, "Empty build metadata"); + return false; + } + } - std::string_view restStr; - if (dashIdx != std::string_view::npos || plusIdx != std::string_view::npos) { - restStr = patchStr.substr(std::min(dashIdx, plusIdx)); - patchStr.remove_suffix(restStr.length()); + std::string_view prerelease; + size_t dashIdx = rest.find('-'); + if (dashIdx != std::string_view::npos) { + prerelease = rest.substr(dashIdx + 1); + rest = rest.substr(0, dashIdx); + if (prerelease.empty()) { + OS_LOGE(TAG, "Empty prerelease"); + return false; + } } - if (!Convert::ToUint16(majorStr, semver.major)) { - OS_LOGE(TAG, "Invalid major version: %.*s", majorStr.length(), majorStr.data()); + std::string_view parts[3]; + if (!OpenShock::TryStringSplit(rest, '.', parts)) { + OS_LOGE(TAG, "Failed to split version core: %.*s", static_cast(rest.length()), rest.data()); return false; } - if (!Convert::ToUint16(minorStr, semver.minor)) { - OS_LOGE(TAG, "Invalid minor version: %.*s", minorStr.length(), minorStr.data()); + if (!Convert::ToUint16(parts[0], semver.major) || !Convert::ToUint16(parts[1], semver.minor) || !Convert::ToUint16(parts[2], semver.patch)) { + OS_LOGE(TAG, "Invalid version core: %.*s", static_cast(rest.length()), rest.data()); return false; } - if (!Convert::ToUint16(patchStr, semver.patch)) { - OS_LOGE(TAG, "Invalid patch version: %.*s", patchStr.length(), patchStr.data()); + if (!prerelease.empty() && !semverIsPrerelease(prerelease)) { + OS_LOGE(TAG, "Invalid prerelease: %.*s", static_cast(prerelease.length()), prerelease.data()); return false; } - - if (!restStr.empty()) { - if (plusIdx != std::string_view::npos) { - semver.build = restStr.substr((plusIdx - patchStr.length()) + 1); - restStr.remove_suffix(semver.build.length() + 1); - - if (!semver.build.empty() && !semverIsBuild(semver.build)) { - OS_LOGE(TAG, "Invalid build: %s", semver.build.c_str()); - return false; - } - } - - if (dashIdx != std::string_view::npos) { - semver.prerelease = restStr.substr(1); - - if (!semver.prerelease.empty() && !semverIsPrerelease(semver.prerelease)) { - OS_LOGE(TAG, "Invalid prerelease: %s", semver.prerelease.c_str()); - return false; - } - } + if (!build.empty() && !semverIsBuild(build)) { + OS_LOGE(TAG, "Invalid build: %.*s", static_cast(build.length()), build.data()); + return false; } + semver.prerelease.assign(prerelease); + semver.build.assign(build); + + out = std::move(semver); return true; } diff --git a/src/SimpleMutex.cpp b/components/common/src/SimpleMutex.cpp similarity index 100% rename from src/SimpleMutex.cpp rename to components/common/src/SimpleMutex.cpp diff --git a/src/util/StringUtils.cpp b/components/common/src/StringHelpers.cpp similarity index 56% rename from src/util/StringUtils.cpp rename to components/common/src/StringHelpers.cpp index ed5d8bc9..1d1076a5 100644 --- a/src/util/StringUtils.cpp +++ b/components/common/src/StringHelpers.cpp @@ -1,8 +1,4 @@ -#include "util/StringUtils.h" - -const char* const TAG = "StringUtils"; - -#include "Logging.h" +#include "StringHelpers.h" #include #include @@ -10,54 +6,24 @@ const char* const TAG = "StringUtils"; bool OpenShock::FormatToString(std::string& out, const char* format, ...) { - const std::size_t STACK_BUFFER_SIZE = 128; - - char buffer[STACK_BUFFER_SIZE]; - char* bufferPtr = buffer; - va_list args; - // Try format with stack buffer. + // Measure, then format straight into the string (C++11 guarantees the trailing NUL slot). va_start(args, format); - int result = vsnprintf(buffer, STACK_BUFFER_SIZE, format, args); + int length = vsnprintf(nullptr, 0, format, args); va_end(args); - // If result is negative, something went wrong. - if (result < 0) { - OS_LOGE(TAG, "Failed to format string"); + if (length < 0) { return false; } - if (result >= STACK_BUFFER_SIZE) { - // Account for null terminator. - result += 1; - - // Allocate heap buffer. - bufferPtr = new char[result]; - - // Try format with heap buffer. - va_start(args, format); - result = vsnprintf(bufferPtr, result, format, args); - va_end(args); - - // If we still fail, something is wrong. - // Free heap buffer and return false. - if (result < 0) { - delete[] bufferPtr; - OS_LOGE(TAG, "Failed to format string"); - return false; - } - } - - // Set output string. - out = std::string(bufferPtr, result); + out.resize(static_cast(length)); - // Free heap buffer if we used it. - if (bufferPtr != buffer) { - delete[] bufferPtr; - } + va_start(args, format); + int written = vsnprintf(out.data(), out.size() + 1, format, args); + va_end(args); - return true; + return written == length; } std::vector OpenShock::StringSplit(std::string_view view, char delimiter, std::size_t maxSplits) @@ -105,6 +71,9 @@ std::vector OpenShock::StringSplit(std::string_view view, bool } } else if (start == nullptr) { start = ptr; + if (result.size() >= maxSplits) { + break; // Remainder becomes the final part below + } } } @@ -122,28 +91,27 @@ std::vector OpenShock::StringSplitNewLines(std::string_view vi std::vector OpenShock::StringSplitWhiteSpace(std::string_view view, std::size_t maxSplits) { - return StringSplit(view, [](char c) { return isspace(c) != 0; }, maxSplits); + return StringSplit(view, [](char c) { return CharIsSpace(c); }, maxSplits); +} + +static bool lowercaseEqual(char a, char b) +{ + return OpenShock::CharToLower(a) == OpenShock::CharToLower(b); } -bool OpenShock::StringIEquals(std::string_view a, std::string_view b) +bool OpenShock::StringIEquals(std::string_view a, std::string_view b) noexcept { - if (a.size() != b.size()) return false; - return strncasecmp(a.data(), b.data(), a.size()) == 0; + return std::ranges::equal(a, b, lowercaseEqual); } -bool OpenShock::StringIContains(std::string_view haystack, std::string_view needle) +bool OpenShock::StringIContains(std::string_view haystack, std::string_view needle) noexcept { if (haystack.size() < needle.size()) return false; - if (haystack.size() == needle.size()) return StringIEquals(haystack, needle); + if (haystack.size() == needle.size()) return std::ranges::equal(haystack, needle, lowercaseEqual); - return std::search(haystack.begin(), haystack.end(), needle.begin(), needle.end(), [](char hc, char nc) { return tolower(hc) == tolower(nc); }) != haystack.end(); + return std::search(haystack.begin(), haystack.end(), needle.begin(), needle.end(), lowercaseEqual) != haystack.end(); } -bool OpenShock::StringHasPrefixIC(std::string_view view, std::string_view prefix) +bool OpenShock::StringHasPrefixIC(std::string_view view, std::string_view prefix) noexcept { if (view.size() < prefix.size()) return false; - return StringIEquals(view.substr(0, prefix.size()), prefix); -} - -String OpenShock::StringToArduinoString(std::string_view view) -{ - return String(view.data(), view.size()); + return std::ranges::equal(view.substr(0, prefix.size()), prefix, lowercaseEqual); } diff --git a/src/util/TaskUtils.cpp b/components/common/src/TaskUtils.cpp similarity index 69% rename from src/util/TaskUtils.cpp rename to components/common/src/TaskUtils.cpp index 32bc0b86..0a4acd27 100644 --- a/src/util/TaskUtils.cpp +++ b/components/common/src/TaskUtils.cpp @@ -27,15 +27,27 @@ BaseType_t TaskUtils::TaskCreateExpensive(TaskFunction_t pvTaskCode, const char* return TaskCreateUniversal(pvTaskCode, pcName, usStackDepth, pvParameters, uxPriority, pvCreatedTask, 1); } -void TaskUtils::StopTask(TaskHandle_t taskHandle, const char* tag, const char* taskName, TickType_t timeout) +void TaskUtils::TaskExiting(TaskExitFlag& exited) +{ + // Publish before deleting. Once vTaskDelete() runs, the idle task is free to + // reclaim this task's TCB, so the handle StopTask() holds may already point at + // freed memory — the flag is the only thing it can safely read. + exited.store(true, std::memory_order_release); + vTaskDelete(nullptr); +} + +void TaskUtils::StopTask(TaskHandle_t taskHandle, TaskExitFlag& exited, const char* tag, const char* taskName, TickType_t timeout) { const TickType_t tickInterval = pdMS_TO_TICKS(10); TickType_t elapsed = 0; - while (eTaskGetState(taskHandle) != eDeleted && elapsed < timeout) { + while (!exited.load(std::memory_order_acquire) && elapsed < timeout) { vTaskDelay(tickInterval); elapsed += tickInterval; } - if (eTaskGetState(taskHandle) != eDeleted) { + + if (!exited.load(std::memory_order_acquire)) { + // The task never reached TaskExiting(), so it has not deleted itself and the + // handle is still valid to kill. OS_LOGW(tag, "%s did not exit gracefully, killing task", taskName); vTaskDelete(taskHandle); } diff --git a/components/common/src/Version.cpp b/components/common/src/Version.cpp new file mode 100644 index 00000000..d4d9a3cd --- /dev/null +++ b/components/common/src/Version.cpp @@ -0,0 +1,39 @@ +#include "OpenShock.h" + +#include "SemVer.h" + +// The one translation unit that sees the firmware version and git commit. +// +// openshock_version.h is regenerated on every build (see the root CMakeLists.txt), so +// everything including it recompiles whenever the commit changes. Keeping that to this +// file, and exposing the result as a linker symbol, means a new commit rebuilds one +// object instead of every object in the project. +#include "openshock_version.h" + +#if !defined(OPENSHOCK_FW_VERSION) || !defined(OPENSHOCK_FW_VERSION_MAJOR) || !defined(OPENSHOCK_FW_VERSION_MINOR) || !defined(OPENSHOCK_FW_VERSION_PATCH) || !defined(OPENSHOCK_FW_GIT_COMMIT) +#error "openshock_version.h is incomplete (generated by scripts/gen_env_header.py)" +#endif +#ifndef OPENSHOCK_FW_VERSION_PRERELEASE +#define OPENSHOCK_FW_VERSION_PRERELEASE "" +#endif +#ifndef OPENSHOCK_FW_VERSION_BUILD +#define OPENSHOCK_FW_VERSION_BUILD "" +#endif + +// Allow the user agent to be overridden through compiler flags; otherwise build it from +// the hostname, version, board and chip. +#ifndef OPENSHOCK_FW_USERAGENT +#define OPENSHOCK_FW_USERAGENT CONFIG_OPENSHOCK_FW_HOSTNAME "/" OPENSHOCK_FW_VERSION " (ESP-IDF; " OPENSHOCK_FW_BOARD "; " OPENSHOCK_FW_CHIP "; Espressif)" +#endif + +namespace OpenShock::Constants { + const char* const FW_USERAGENT = OPENSHOCK_FW_USERAGENT; + const char* const FW_VERSION = OPENSHOCK_FW_VERSION; + const char* const FW_GIT_COMMIT = OPENSHOCK_FW_GIT_COMMIT; + + const OpenShock::SemVer& FirmwareVersion() + { + static const OpenShock::SemVer version(OPENSHOCK_FW_VERSION_MAJOR, OPENSHOCK_FW_VERSION_MINOR, OPENSHOCK_FW_VERSION_PATCH, OPENSHOCK_FW_VERSION_PRERELEASE, OPENSHOCK_FW_VERSION_BUILD); + return version; + } +} // namespace OpenShock::Constants diff --git a/components/config/CMakeLists.txt b/components/config/CMakeLists.txt new file mode 100644 index 00000000..888f6a18 --- /dev/null +++ b/components/config/CMakeLists.txt @@ -0,0 +1,20 @@ +# OpenShock configuration model — typed config structs with JSON + flatbuffer +# (de)serialization, persisted to littlefs via the fs component. +# +# Extracted out of `core` into a low-layer component: it depends only on the +# generated wire schema (serialization), JSON, the config filesystem and a few +# foundation utilities — nothing in core. This is what lets device_control (estop/ +# radio/visual/CommandHandler, which read Config::) leave core in a later step +# without a config <-> core cycle. +FILE(GLOB_RECURSE config_sources + ${CMAKE_CURRENT_SOURCE_DIR}/src/*.cpp) + +idf_component_register( + SRCS ${config_sources} + INCLUDE_DIRS "include" + REQUIRES common osjson serialization flatbuffers # in public headers (enums, json/Json.h, HubConfig fbs, TinyVec, flatbuffers::Offset) + esp_wifi esp_hal_gpio # WiFiCredentials.h -> esp_wifi_types.h (wifi_auth_mode_t); RFConfig.h -> hal/gpio_types.h (gpio_num_t) + PRIV_REQUIRES chipset fs logging # .cpp only (GPIO validation, ConfigFs, OS_LOG*/OS_PANIC; ReadWriteMutex via common) +) + +target_compile_options(${COMPONENT_LIB} PRIVATE -Wno-error) diff --git a/components/config/host_test/CMakeLists.txt b/components/config/host_test/CMakeLists.txt new file mode 100644 index 00000000..bf115068 --- /dev/null +++ b/components/config/host_test/CMakeLists.txt @@ -0,0 +1,22 @@ +cmake_minimum_required(VERSION 3.16) + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) + +# main compiles config's sources (and the few osjson/common sources they need) +# directly, so no firmware component needs to be registered. +set(COMPONENTS main) + +# No freertos mock: this suite runs on ESP-IDF's real FreeRTOS POSIX port, so the real +# ReadWriteMutex guards the Config:: store exactly as on the device. The driver mock +# provides for the real Chipset.h, the esp_wifi mock the real +# (wifi_auth_mode_t) - neither has a linux implementation; the +# esp_wifi mock in turn needs the esp_netif and lwip mocks. Plus the shared host-test +# stand-ins (Logging.h, openshock_board.h, CONFIG_OPENSHOCK_*). +list(APPEND EXTRA_COMPONENT_DIRS + "$ENV{IDF_PATH}/tools/mocks/driver/" + "$ENV{IDF_PATH}/tools/mocks/esp_wifi/" + "$ENV{IDF_PATH}/tools/mocks/esp_netif/" + "$ENV{IDF_PATH}/tools/mocks/lwip/" + "${CMAKE_CURRENT_LIST_DIR}/../../../test/host_support") + +project(config_host_test) diff --git a/components/config/host_test/legacy/gen_legacy_config.cpp b/components/config/host_test/legacy/gen_legacy_config.cpp new file mode 100644 index 00000000..35a934e7 --- /dev/null +++ b/components/config/host_test/legacy/gen_legacy_config.cpp @@ -0,0 +1,116 @@ +// Generator for main/legacy_fixtures.h. NOT part of the host test build. +// +// Serializes a hub config exactly the way the Arduino firmware did: the bodies of its +// RootConfig / *Config::ToFlatbuffers (same Create* calls in the same order), then +// FinishHubConfigBuffer as in its Config.cpp trySaveConfig. Build it against the old +// firmware's generated schema header and pinned flatbuffers, e.g. from the firmware +// repo root (any host g++): +// +// mkdir -p /tmp/gen/v152/serialization/_fbs /tmp/gen/dev/serialization/_fbs /tmp/gen/fb152 /tmp/gen/fbdev +// git show 1.5.2:include/serialization/_fbs/HubConfig_generated.h > /tmp/gen/v152/serialization/_fbs/HubConfig_generated.h +// git show develop:include/serialization/_fbs/HubConfig_generated.h > /tmp/gen/dev/serialization/_fbs/HubConfig_generated.h +// git show develop:include/serialization/_fbs/WifiAuthMode_generated.h > /tmp/gen/dev/serialization/_fbs/WifiAuthMode_generated.h +// git -C components/flatbuffers/flatbuffers archive 8b02fe6178427b96aea25396b53ea4ae8cadd7d8 include | tar -x -C /tmp/gen/fb152 +// git -C components/flatbuffers/flatbuffers archive 81edeb17d9118143f2c81caf27edfb0df401279e include | tar -x -C /tmp/gen/fbdev +// g++ -std=c++17 -I/tmp/gen/v152 -I/tmp/gen/fb152/include gen_legacy_config.cpp -o /tmp/gen/v152.out && /tmp/gen/v152.out +// g++ -std=c++17 -DLEGACY_DEVELOP -I/tmp/gen/dev -I/tmp/gen/dev/serialization/_fbs -I/tmp/gen/fbdev/include gen_legacy_config.cpp -o /tmp/gen/dev.out && /tmp/gen/dev.out +// +// The flatbuffers commits are the ones each tag's platformio.ini pinned. +#include "serialization/_fbs/HubConfig_generated.h" + +#include +#include +#include + +using namespace OpenShock::Serialization; +namespace C = OpenShock::Serialization::Configuration; + +struct Creds { + uint8_t id; + std::string ssid, password; +#ifdef LEGACY_DEVELOP + Types::WifiAuthMode authMode; + bool pinned; + uint8_t bssid[6]; +#endif +}; + +static void emit(const char* name, bool keepAlive) +{ + flatbuffers::FlatBufferBuilder builder; + const bool withSensitiveData = true; + + // RootConfig::ToFlatbuffers order: rf, wifi, captivePortal, backend, serialInput, otaUpdate, estop + auto rfOffset = C::CreateRFConfig(builder, 15, keepAlive); + + std::vector credentialsList = { +#ifdef LEGACY_DEVELOP + {1, "HomeNet", "hunter22", Types::WifiAuthMode::WPA2_PSK, true, {0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc}}, + {2, "Guest", "", Types::WifiAuthMode::Open, false, {}}, + {3, "Office", "s3cret!", Types::WifiAuthMode::UNKNOWN, false, {}}, +#else + {1, "HomeNet", "hunter22"}, + {2, "Guest", ""}, + {3, "Office", "s3cret!"}, +#endif + }; + std::vector> fbsCredentialsList; + fbsCredentialsList.reserve(credentialsList.size()); + for (auto& c : credentialsList) { + auto ssidOffset = builder.CreateString(c.ssid); + flatbuffers::Offset passwordOffset; + if (withSensitiveData) { + passwordOffset = builder.CreateString(c.password); + } else { + passwordOffset = 0; + } +#ifdef LEGACY_DEVELOP + const C::MacAddress* bssidPtr = nullptr; + C::MacAddress bssidStruct; + if (c.pinned) { + bssidStruct = C::MacAddress(flatbuffers::span(c.bssid, 6)); + bssidPtr = &bssidStruct; + } + fbsCredentialsList.push_back(C::CreateWiFiCredentials(builder, c.id, ssidOffset, passwordOffset, c.authMode, bssidPtr)); +#else + fbsCredentialsList.push_back(C::CreateWiFiCredentials(builder, c.id, ssidOffset, passwordOffset)); +#endif + } + std::string accessPointSSID = "MyHubAP", hostname = "my-hub"; + auto wifiOffset = C::CreateWiFiConfig(builder, builder.CreateString(accessPointSSID), builder.CreateString(hostname), builder.CreateVector(fbsCredentialsList)); + + auto captivePortalOffset = C::CreateCaptivePortalConfig(builder, true); + + auto domainOffset = builder.CreateString(std::string("api.example.org")); + auto authTokenOffset = builder.CreateString(std::string("tok_0123456789abcdef")); + auto backendOffset = C::CreateBackendConfig(builder, domainOffset, authTokenOffset); + + auto serialInputOffset = C::CreateSerialInputConfig(builder, false); + + auto otaUpdateOffset = C::CreateOtaUpdateConfig(builder, false, builder.CreateString(std::string("fw.example.org")), C::OtaUpdateChannel::Beta, true, true, 120, false, true, 4242, C::OtaUpdateStep::Validating); + + auto estopOffset = C::CreateEStopConfig(builder, true, 13); + + auto root = C::CreateHubConfig(builder, rfOffset, wifiOffset, captivePortalOffset, backendOffset, serialInputOffset, otaUpdateOffset, estopOffset); + C::FinishHubConfigBuffer(builder, root); + + const uint8_t* p = builder.GetBufferPointer(); + std::size_t n = builder.GetSize(); + printf(" // %s (%zu bytes)\n static const uint8_t %s[] = {\n", name, n, name); + for (std::size_t i = 0; i < n; i += 16) { + printf(" "); + for (std::size_t j = i; j < n && j < i + 16; ++j) printf(" 0x%02x,", p[j]); + printf("\n"); + } + printf(" };\n"); +} + +int main() +{ +#ifdef LEGACY_DEVELOP + emit("kDevelopConfig", false); +#else + emit("kV152Config", true); + emit("kV152ConfigKeepAliveOff", false); +#endif +} diff --git a/components/config/host_test/main/CMakeLists.txt b/components/config/host_test/main/CMakeLists.txt new file mode 100644 index 00000000..5b670caf --- /dev/null +++ b/components/config/host_test/main/CMakeLists.txt @@ -0,0 +1,53 @@ +# Compile config's sources directly (not via the config component, whose logging +# dependency pulls in app_update, which has no linux port). Config.cpp is included, +# so the tests drive the real Config:: store (Init, SetRaw, GetAsJSON / SaveFromJSON) +# under the real ReadWriteMutex, and Config::Init finds its partition in the +# firmware's real partition table through ESP-IDF's linux esp_partition. Only the +# filesystem is faked: stubs/fs/ConfigFs.h is an in-memory file store the tests seed +# and inspect. osjson's Json.cpp and the common sources config needs are compiled in +# as well (jsmn / json_generator come from idf_component.yml). +idf_component_register( + SRCS "test_main.cpp" + "config_test_helpers.cpp" + "test_roundtrip.cpp" # flatbuffer + jsonconfig round trips through the store + "test_defaults.cpp" # missing file / sections / fields + "test_legacy.cpp" # configs written by the Arduino firmware (legacy_fixtures.h) + "test_corrupt.cpp" # truncated / garbage / tampered input + "../../src/BackendConfig.cpp" + "../../src/CaptivePortalConfig.cpp" + "../../src/Config.cpp" + "../../src/EStopConfig.cpp" + "../../src/OtaUpdateConfig.cpp" + "../../src/RFConfig.cpp" + "../../src/RootConfig.cpp" + "../../src/SerialInputConfig.cpp" + "../../src/WiFiConfig.cpp" + "../../src/WiFiCredentials.cpp" + "../../src/internal/utils.cpp" + "../../../osjson/src/Json.cpp" + "../../../common/src/Convert.cpp" # Json.cpp strict string->int + "../../../common/src/DigitCounter.cpp" + "../../../common/src/ReadWriteMutex.cpp" # Config.cpp's store lock + "../../../common/src/StringHelpers.cpp" + INCLUDE_DIRS "." + "stubs" # fs/ConfigFs.h (in-memory) + "../../include" # config/*.h + "../../../chipset/include" # Chipset.h + "../../../common/include" # OpenShock.h, ReadWriteMutex.h, TinyVec.h, enums/*, util/HexUtils.h + "../../../osjson/include" # json/Json.h + "../../../serialization/include" # serialization/_fbs/HubConfig_generated.h + "../../../flatbuffers/flatbuffers/include" + REQUIRES unity + host_support # Logging.h, openshock_board.h, ESP32 GPIO caps (test/host_support) + freertos # ReadWriteMutex (real POSIX port) + esp_partition # Config::Init's partition lookup (linux flash emulation) + driver # Chipset.h -> driver/gpio.h (tools/mocks/driver) + esp_wifi # WiFiCredentials.h -> esp_wifi_types.h (tools/mocks/esp_wifi) + esp_hal_gpio # hal/gpio_types.h (gpio_num_t) + espressif__jsmn + espressif__json_generator + WHOLE_ARCHIVE # keep the auto-registered TEST_CASEs from being stripped +) + +# Same as the config component itself. +target_compile_options(${COMPONENT_LIB} PRIVATE -Wno-error) diff --git a/components/config/host_test/main/config_test_helpers.cpp b/components/config/host_test/main/config_test_helpers.cpp new file mode 100644 index 00000000..fc29eab2 --- /dev/null +++ b/components/config/host_test/main/config_test_helpers.cpp @@ -0,0 +1,134 @@ +#include "config_test_helpers.h" + +#include "unity.h" + +#include +#include + +using namespace OpenShock; + +namespace ConfigTest { + Config::RootConfig MakeSampleConfig() + { + Config::RootConfig config; + + config.rf = Config::RFConfig(GPIO_NUM_25, false); + + Config::WiFiCredentials home(1, "HomeNet", "hunter22", WIFI_AUTH_WPA2_PSK); + home.bssid = {0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc}; + Config::WiFiCredentials guest(2, "Guest", "", WIFI_AUTH_OPEN); + Config::WiFiCredentials office(7, "Office \"5G\"", "p@ss w0rd/\\", WIFI_AUTH_WPA3_PSK); + config.wifi = Config::WiFiConfig("MyHubAP", "my-hub", {home, guest, office}); + + config.captivePortal = Config::CaptivePortalConfig(true); + config.backend = Config::BackendConfig("api.example.org", "tok_0123456789abcdef"); + config.serialInput = Config::SerialInputConfig(false); + config.otaUpdate = Config::OtaUpdateConfig(false, "fw.example.org", OtaUpdateChannel::Develop, true, true, 120, false, true, 4242, OtaUpdateStep::Validating); + config.estop = Config::EStopConfig(true, GPIO_NUM_13); + + return config; + } + + std::vector Serialize(const Config::RootConfig& config, bool withSensitiveData) + { + flatbuffers::FlatBufferBuilder builder; + auto root = config.ToFlatbuffers(builder, withSensitiveData); + Serialization::Configuration::FinishHubConfigBuffer(builder, root); + return std::vector(builder.GetBufferPointer(), builder.GetBufferPointer() + builder.GetSize()); + } + + bool Deserialize(const uint8_t* data, std::size_t size, Config::RootConfig& out) + { + if (size > Config::MaxConfigSize) { + return false; // the Verifier constructor would assert + } + flatbuffers::Verifier::Options options { + .max_size = Config::MaxConfigSize + 1, + }; + flatbuffers::Verifier verifier(data, size, options); + if (!verifier.VerifyBuffer()) { + return false; + } + return out.FromFlatbuffers(flatbuffers::GetRoot(data)); + } + + bool InitFrom(const uint8_t* data, std::size_t size) + { + HostConfigFs::reset(); + if (data != nullptr) { + HostConfigFs::files()[kConfigPath].assign(data, data + size); + } + Config::Init(); + return HostConfigFs::writeCount == 0; + } + + void InitDefault() + { + InitFrom(nullptr, 0); + HostConfigFs::writeCount = 0; + } + + std::vector StoredFile() + { + auto it = HostConfigFs::files().find(kConfigPath); + return it == HostConfigFs::files().end() ? std::vector() : it->second; + } + + Config::RootConfig Snapshot() + { + Config::RootConfig config; + TEST_ASSERT_TRUE(Config::GetRFConfig(config.rf)); + TEST_ASSERT_TRUE(Config::GetWiFiConfig(config.wifi)); + TEST_ASSERT_TRUE(Config::GetCaptivePortalConfig(config.captivePortal)); + TEST_ASSERT_TRUE(Config::GetBackendConfig(config.backend)); + TEST_ASSERT_TRUE(Config::GetSerialInputConfig(config.serialInput)); + TEST_ASSERT_TRUE(Config::GetOtaUpdateConfig(config.otaUpdate)); + TEST_ASSERT_TRUE(Config::GetEStopConfig(config.estop)); + return config; + } + + void AssertCredentialsEqual(const Config::WiFiCredentials& expected, const Config::WiFiCredentials& actual, bool withSensitiveData) + { + TEST_ASSERT_EQUAL_UINT8_MESSAGE(expected.id, actual.id, "wifi credentials id"); + TEST_ASSERT_EQUAL_STRING_MESSAGE(expected.ssid.c_str(), actual.ssid.c_str(), "wifi credentials ssid"); + TEST_ASSERT_EQUAL_STRING_MESSAGE(withSensitiveData ? expected.password.c_str() : "", actual.password.c_str(), "wifi credentials password"); + TEST_ASSERT_EQUAL_INT_MESSAGE(expected.authMode, actual.authMode, "wifi credentials authMode"); + TEST_ASSERT_EQUAL_UINT8_ARRAY_MESSAGE(expected.bssid.data(), actual.bssid.data(), 6, "wifi credentials bssid"); + } + + void AssertConfigEqual(const Config::RootConfig& expected, const Config::RootConfig& actual, bool withSensitiveData) + { + TEST_ASSERT_EQUAL_INT_MESSAGE(expected.rf.txPin, actual.rf.txPin, "rf.txPin"); + TEST_ASSERT_EQUAL_MESSAGE(expected.rf.keepAliveEnabled, actual.rf.keepAliveEnabled, "rf.keepAliveEnabled"); + + TEST_ASSERT_EQUAL_STRING_MESSAGE(expected.wifi.accessPointSSID.c_str(), actual.wifi.accessPointSSID.c_str(), "wifi.accessPointSSID"); + TEST_ASSERT_EQUAL_STRING_MESSAGE(expected.wifi.hostname.c_str(), actual.wifi.hostname.c_str(), "wifi.hostname"); + TEST_ASSERT_EQUAL_size_t_MESSAGE(expected.wifi.credentialsList.size(), actual.wifi.credentialsList.size(), "wifi.credentialsList size"); + for (std::size_t i = 0; i < expected.wifi.credentialsList.size() && i < actual.wifi.credentialsList.size(); ++i) { + AssertCredentialsEqual(expected.wifi.credentialsList[i], actual.wifi.credentialsList[i], withSensitiveData); + } + + TEST_ASSERT_EQUAL_MESSAGE(expected.captivePortal.alwaysEnabled, actual.captivePortal.alwaysEnabled, "captivePortal.alwaysEnabled"); + + TEST_ASSERT_EQUAL_STRING_MESSAGE(expected.backend.domain.c_str(), actual.backend.domain.c_str(), "backend.domain"); + TEST_ASSERT_EQUAL_STRING_MESSAGE(withSensitiveData ? expected.backend.authToken.c_str() : "", actual.backend.authToken.c_str(), "backend.authToken"); + + TEST_ASSERT_EQUAL_MESSAGE(expected.serialInput.echoEnabled, actual.serialInput.echoEnabled, "serialInput.echoEnabled"); + + const auto& eo = expected.otaUpdate; + const auto& ao = actual.otaUpdate; + TEST_ASSERT_EQUAL_MESSAGE(eo.isEnabled, ao.isEnabled, "otaUpdate.isEnabled"); + TEST_ASSERT_EQUAL_STRING_MESSAGE(eo.cdnDomain.c_str(), ao.cdnDomain.c_str(), "otaUpdate.cdnDomain"); + TEST_ASSERT_EQUAL_UINT8_MESSAGE(static_cast(eo.updateChannel), static_cast(ao.updateChannel), "otaUpdate.updateChannel"); + TEST_ASSERT_EQUAL_MESSAGE(eo.checkOnStartup, ao.checkOnStartup, "otaUpdate.checkOnStartup"); + TEST_ASSERT_EQUAL_MESSAGE(eo.checkPeriodically, ao.checkPeriodically, "otaUpdate.checkPeriodically"); + TEST_ASSERT_EQUAL_UINT16_MESSAGE(eo.checkInterval, ao.checkInterval, "otaUpdate.checkInterval"); + TEST_ASSERT_EQUAL_MESSAGE(eo.allowBackendManagement, ao.allowBackendManagement, "otaUpdate.allowBackendManagement"); + TEST_ASSERT_EQUAL_MESSAGE(eo.requireManualApproval, ao.requireManualApproval, "otaUpdate.requireManualApproval"); + TEST_ASSERT_EQUAL_INT32_MESSAGE(eo.updateId, ao.updateId, "otaUpdate.updateId"); + TEST_ASSERT_EQUAL_UINT8_MESSAGE(static_cast(eo.updateStep), static_cast(ao.updateStep), "otaUpdate.updateStep"); + + TEST_ASSERT_EQUAL_MESSAGE(expected.estop.enabled, actual.estop.enabled, "estop.enabled"); + TEST_ASSERT_EQUAL_INT_MESSAGE(expected.estop.gpioPin, actual.estop.gpioPin, "estop.gpioPin"); + } +} // namespace ConfigTest diff --git a/components/config/host_test/main/config_test_helpers.h b/components/config/host_test/main/config_test_helpers.h new file mode 100644 index 00000000..24a86152 --- /dev/null +++ b/components/config/host_test/main/config_test_helpers.h @@ -0,0 +1,51 @@ +#pragma once + +// Shared helpers for the config host tests: a sample config with every field moved +// off its default, field-by-field comparison, and driving the real Config:: store +// (Config.cpp) through the in-memory ConfigFs stub. +#include "config/Config.h" +#include "config/RootConfig.h" + +#include "fs/ConfigFs.h" + +#include +#include +#include + +namespace ConfigTest { + // Path Config.cpp reads/writes inside the config partition. The Arduino firmware + // used the same file ("/config" on the "config" littlefs partition). + constexpr const char* kConfigPath = "/config"; + + // Every field differs from RootConfig's defaults. + OpenShock::Config::RootConfig MakeSampleConfig(); + + // Serializes like Config.cpp's trySaveConfig (ToFlatbuffers + FinishHubConfigBuffer). + std::vector Serialize(const OpenShock::Config::RootConfig& config, bool withSensitiveData = true); + + // Verifies (same options as Config.cpp) and deserializes a stored buffer. + bool Deserialize(const uint8_t* data, std::size_t size, OpenShock::Config::RootConfig& out); + + // Seeds the stored config file (nullptr = no file), runs Config::Init, and returns + // true when Init loaded that file as-is (it rewrites the file only when it fell + // back to defaults). + bool InitFrom(const uint8_t* data, std::size_t size); + inline bool InitFrom(const std::vector& data) + { + return InitFrom(data.data(), data.size()); + } + + // Resets the store to a freshly written default config. + void InitDefault(); + + // The bytes currently stored in the config file (empty if there is none). + std::vector StoredFile(); + + // Reads back every section through the public Config:: getters. + OpenShock::Config::RootConfig Snapshot(); + + // TEST_ASSERTs that every field of every section matches. When withSensitiveData + // is false, passwords and the backend auth token are expected to be empty. + void AssertConfigEqual(const OpenShock::Config::RootConfig& expected, const OpenShock::Config::RootConfig& actual, bool withSensitiveData = true); + void AssertCredentialsEqual(const OpenShock::Config::WiFiCredentials& expected, const OpenShock::Config::WiFiCredentials& actual, bool withSensitiveData = true); +} // namespace ConfigTest diff --git a/components/config/host_test/main/idf_component.yml b/components/config/host_test/main/idf_component.yml new file mode 100644 index 00000000..aafedf2d --- /dev/null +++ b/components/config/host_test/main/idf_component.yml @@ -0,0 +1,5 @@ +# osjson's Json.cpp is compiled into main (see CMakeLists.txt), so main needs the +# same espressif components osjson declares. +dependencies: + espressif/jsmn: "^1.2.0" + espressif/json_generator: "^2.0.0" diff --git a/components/config/host_test/main/legacy_fixtures.h b/components/config/host_test/main/legacy_fixtures.h new file mode 100644 index 00000000..592e0b86 --- /dev/null +++ b/components/config/host_test/main/legacy_fixtures.h @@ -0,0 +1,133 @@ +#pragma once + +// Config files exactly as the Arduino firmware wrote them to the "config" littlefs +// partition, used to check that an upgraded hub keeps its settings. +// +// How these bytes were produced: ../legacy/gen_legacy_config.cpp (host_test/legacy) +// contains the RootConfig / *Config::ToFlatbuffers bodies of the old firmware, +// transcribed verbatim, followed by FinishHubConfigBuffer as in its Config.cpp +// trySaveConfig. It was compiled against the old firmware's own generated schema +// header and the flatbuffers commit that firmware pinned in platformio.ini: +// +// git show :include/serialization/_fbs/HubConfig_generated.h (and WifiAuthMode_generated.h for develop) +// git -C components/flatbuffers/flatbuffers archive include +// +// 1.5.2 (latest stable release): flatbuffers 8b02fe6178427b96aea25396b53ea4ae8cadd7d8 +// develop (d9a47e53, same schema as 1.6.0-rc.1): flatbuffers 81edeb17d9118143f2c81caf27edfb0df401279e, built with -DLEGACY_DEVELOP +// +// All three hold the same settings (see test_legacy.cpp for the expected values): +// rf txPin 15, keepAlive true (kV152ConfigKeepAliveOff / kDevelopConfig: false) +// wifi AP SSID "MyHubAP", hostname "my-hub", credentials +// 1 "HomeNet" / "hunter22" (develop: WPA2_PSK, BSSID 12:34:56:78:9a:bc) +// 2 "Guest" / "" (develop: Open) +// 3 "Office" / "s3cret!" (develop: auth mode UNKNOWN) +// captive alwaysEnabled true +// backend domain "api.example.org", authToken "tok_0123456789abcdef" +// serialInput echo false +// otaUpdate disabled, cdn "fw.example.org", channel Beta, checkOnStartup, checkPeriodically, +// interval 120, no backend management, manual approval, updateId 4242, step Validating +// estop enabled, GPIO13 +// 1.5.2's schema has no WiFiCredentials auth_mode / bssid, and declares RFConfig +// keepalive_enabled with default false (develop: true). + +#include + +namespace ConfigTest::Legacy { + // clang-format off + // kV152Config (436 bytes) + inline constexpr uint8_t kV152Config[] = { + 0x18, 0x00, 0x00, 0x00, 0x00, 0x00, 0x12, 0x00, 0x20, 0x00, 0x04, 0x00, 0x08, 0x00, 0x0c, 0x00, + 0x10, 0x00, 0x14, 0x00, 0x18, 0x00, 0x1c, 0x00, 0x12, 0x00, 0x00, 0x00, 0x90, 0x01, 0x00, 0x00, + 0xcc, 0x00, 0x00, 0x00, 0xb8, 0x00, 0x00, 0x00, 0x70, 0x00, 0x00, 0x00, 0x5c, 0x00, 0x00, 0x00, + 0x28, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x94, 0xfe, 0xff, 0xff, 0x00, 0x00, 0x01, 0x0d, + 0x18, 0x00, 0x14, 0x00, 0x00, 0x00, 0x0c, 0x00, 0x05, 0x00, 0x06, 0x00, 0x07, 0x00, 0x0a, 0x00, + 0x00, 0x00, 0x08, 0x00, 0x10, 0x00, 0x09, 0x00, 0x18, 0x00, 0x00, 0x00, 0x00, 0x01, 0x01, 0x01, + 0x01, 0x03, 0x78, 0x00, 0x08, 0x00, 0x00, 0x00, 0x92, 0x10, 0x00, 0x00, 0x0e, 0x00, 0x00, 0x00, + 0x66, 0x77, 0x2e, 0x65, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x2e, 0x6f, 0x72, 0x67, 0x00, 0x00, + 0x00, 0x00, 0x06, 0x00, 0x08, 0x00, 0x07, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x08, 0x00, 0x0c, 0x00, 0x04, 0x00, 0x08, 0x00, 0x08, 0x00, 0x00, 0x00, 0x24, 0x00, 0x00, 0x00, + 0x04, 0x00, 0x00, 0x00, 0x14, 0x00, 0x00, 0x00, 0x74, 0x6f, 0x6b, 0x5f, 0x30, 0x31, 0x32, 0x33, + 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x00, 0x00, 0x00, 0x00, + 0x0f, 0x00, 0x00, 0x00, 0x61, 0x70, 0x69, 0x2e, 0x65, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x2e, + 0x6f, 0x72, 0x67, 0x00, 0x00, 0x00, 0x06, 0x00, 0x06, 0x00, 0x05, 0x00, 0x06, 0x00, 0x00, 0x00, + 0x00, 0x01, 0x0a, 0x00, 0x10, 0x00, 0x04, 0x00, 0x08, 0x00, 0x0c, 0x00, 0x0a, 0x00, 0x00, 0x00, + 0x0c, 0x00, 0x00, 0x00, 0x14, 0x00, 0x00, 0x00, 0x1c, 0x00, 0x00, 0x00, 0x07, 0x00, 0x00, 0x00, + 0x4d, 0x79, 0x48, 0x75, 0x62, 0x41, 0x50, 0x00, 0x06, 0x00, 0x00, 0x00, 0x6d, 0x79, 0x2d, 0x68, + 0x75, 0x62, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x60, 0x00, 0x00, 0x00, 0x30, 0x00, 0x00, 0x00, + 0x04, 0x00, 0x00, 0x00, 0xb6, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x03, 0x14, 0x00, 0x00, 0x00, + 0x04, 0x00, 0x00, 0x00, 0x07, 0x00, 0x00, 0x00, 0x73, 0x33, 0x63, 0x72, 0x65, 0x74, 0x21, 0x00, + 0x06, 0x00, 0x00, 0x00, 0x4f, 0x66, 0x66, 0x69, 0x63, 0x65, 0x00, 0x00, 0xde, 0xff, 0xff, 0xff, + 0x00, 0x00, 0x00, 0x02, 0x10, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x05, 0x00, 0x00, 0x00, 0x47, 0x75, 0x65, 0x73, 0x74, 0x00, 0x0a, 0x00, + 0x10, 0x00, 0x07, 0x00, 0x08, 0x00, 0x0c, 0x00, 0x0a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, + 0x18, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x68, 0x75, 0x6e, 0x74, + 0x65, 0x72, 0x32, 0x32, 0x00, 0x00, 0x00, 0x00, 0x07, 0x00, 0x00, 0x00, 0x48, 0x6f, 0x6d, 0x65, + 0x4e, 0x65, 0x74, 0x00, 0x08, 0x00, 0x08, 0x00, 0x06, 0x00, 0x07, 0x00, 0x08, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x0f, 0x01, + }; + // kV152ConfigKeepAliveOff (436 bytes) + inline constexpr uint8_t kV152ConfigKeepAliveOff[] = { + 0x18, 0x00, 0x00, 0x00, 0x00, 0x00, 0x12, 0x00, 0x20, 0x00, 0x04, 0x00, 0x08, 0x00, 0x0c, 0x00, + 0x10, 0x00, 0x14, 0x00, 0x18, 0x00, 0x1c, 0x00, 0x12, 0x00, 0x00, 0x00, 0x90, 0x01, 0x00, 0x00, + 0xcc, 0x00, 0x00, 0x00, 0xb8, 0x00, 0x00, 0x00, 0x70, 0x00, 0x00, 0x00, 0x5c, 0x00, 0x00, 0x00, + 0x30, 0x00, 0x00, 0x00, 0x0c, 0x00, 0x00, 0x00, 0x08, 0x00, 0x08, 0x00, 0x06, 0x00, 0x07, 0x00, + 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x0d, 0x18, 0x00, 0x14, 0x00, 0x00, 0x00, 0x0c, 0x00, + 0x05, 0x00, 0x06, 0x00, 0x07, 0x00, 0x0a, 0x00, 0x00, 0x00, 0x08, 0x00, 0x10, 0x00, 0x09, 0x00, + 0x18, 0x00, 0x00, 0x00, 0x00, 0x01, 0x01, 0x01, 0x01, 0x03, 0x78, 0x00, 0x08, 0x00, 0x00, 0x00, + 0x92, 0x10, 0x00, 0x00, 0x0e, 0x00, 0x00, 0x00, 0x66, 0x77, 0x2e, 0x65, 0x78, 0x61, 0x6d, 0x70, + 0x6c, 0x65, 0x2e, 0x6f, 0x72, 0x67, 0x00, 0x00, 0xe2, 0xfe, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, + 0x08, 0x00, 0x0c, 0x00, 0x04, 0x00, 0x08, 0x00, 0x08, 0x00, 0x00, 0x00, 0x24, 0x00, 0x00, 0x00, + 0x04, 0x00, 0x00, 0x00, 0x14, 0x00, 0x00, 0x00, 0x74, 0x6f, 0x6b, 0x5f, 0x30, 0x31, 0x32, 0x33, + 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x00, 0x00, 0x00, 0x00, + 0x0f, 0x00, 0x00, 0x00, 0x61, 0x70, 0x69, 0x2e, 0x65, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x2e, + 0x6f, 0x72, 0x67, 0x00, 0x00, 0x00, 0x06, 0x00, 0x06, 0x00, 0x05, 0x00, 0x06, 0x00, 0x00, 0x00, + 0x00, 0x01, 0x0a, 0x00, 0x10, 0x00, 0x04, 0x00, 0x08, 0x00, 0x0c, 0x00, 0x0a, 0x00, 0x00, 0x00, + 0x0c, 0x00, 0x00, 0x00, 0x14, 0x00, 0x00, 0x00, 0x1c, 0x00, 0x00, 0x00, 0x07, 0x00, 0x00, 0x00, + 0x4d, 0x79, 0x48, 0x75, 0x62, 0x41, 0x50, 0x00, 0x06, 0x00, 0x00, 0x00, 0x6d, 0x79, 0x2d, 0x68, + 0x75, 0x62, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x60, 0x00, 0x00, 0x00, 0x30, 0x00, 0x00, 0x00, + 0x04, 0x00, 0x00, 0x00, 0xb6, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x03, 0x14, 0x00, 0x00, 0x00, + 0x04, 0x00, 0x00, 0x00, 0x07, 0x00, 0x00, 0x00, 0x73, 0x33, 0x63, 0x72, 0x65, 0x74, 0x21, 0x00, + 0x06, 0x00, 0x00, 0x00, 0x4f, 0x66, 0x66, 0x69, 0x63, 0x65, 0x00, 0x00, 0xde, 0xff, 0xff, 0xff, + 0x00, 0x00, 0x00, 0x02, 0x10, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x05, 0x00, 0x00, 0x00, 0x47, 0x75, 0x65, 0x73, 0x74, 0x00, 0x0a, 0x00, + 0x10, 0x00, 0x07, 0x00, 0x08, 0x00, 0x0c, 0x00, 0x0a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, + 0x18, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x68, 0x75, 0x6e, 0x74, + 0x65, 0x72, 0x32, 0x32, 0x00, 0x00, 0x00, 0x00, 0x07, 0x00, 0x00, 0x00, 0x48, 0x6f, 0x6d, 0x65, + 0x4e, 0x65, 0x74, 0x00, 0x00, 0x00, 0x06, 0x00, 0x08, 0x00, 0x07, 0x00, 0x06, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x0f, + }; + // kDevelopConfig (476 bytes) + inline constexpr uint8_t kDevelopConfig[] = { + 0x18, 0x00, 0x00, 0x00, 0x00, 0x00, 0x12, 0x00, 0x20, 0x00, 0x04, 0x00, 0x08, 0x00, 0x0c, 0x00, + 0x10, 0x00, 0x14, 0x00, 0x18, 0x00, 0x1c, 0x00, 0x12, 0x00, 0x00, 0x00, 0xb8, 0x01, 0x00, 0x00, + 0xd0, 0x00, 0x00, 0x00, 0xbc, 0x00, 0x00, 0x00, 0x74, 0x00, 0x00, 0x00, 0x60, 0x00, 0x00, 0x00, + 0x28, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x6c, 0xfe, 0xff, 0xff, 0x00, 0x00, 0x01, 0x0d, + 0x18, 0x00, 0x18, 0x00, 0x07, 0x00, 0x10, 0x00, 0x08, 0x00, 0x09, 0x00, 0x0a, 0x00, 0x0e, 0x00, + 0x0b, 0x00, 0x0c, 0x00, 0x14, 0x00, 0x0d, 0x00, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x01, 0x01, 0x01, 0x00, 0x01, 0x03, 0x78, 0x00, 0x08, 0x00, 0x00, 0x00, 0x92, 0x10, 0x00, 0x00, + 0x0e, 0x00, 0x00, 0x00, 0x66, 0x77, 0x2e, 0x65, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x2e, 0x6f, + 0x72, 0x67, 0x00, 0x00, 0x00, 0x00, 0x06, 0x00, 0x08, 0x00, 0x07, 0x00, 0x06, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x08, 0x00, 0x0c, 0x00, 0x04, 0x00, 0x08, 0x00, 0x08, 0x00, 0x00, 0x00, + 0x24, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x14, 0x00, 0x00, 0x00, 0x74, 0x6f, 0x6b, 0x5f, + 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66, + 0x00, 0x00, 0x00, 0x00, 0x0f, 0x00, 0x00, 0x00, 0x61, 0x70, 0x69, 0x2e, 0x65, 0x78, 0x61, 0x6d, + 0x70, 0x6c, 0x65, 0x2e, 0x6f, 0x72, 0x67, 0x00, 0x00, 0x00, 0x06, 0x00, 0x06, 0x00, 0x05, 0x00, + 0x06, 0x00, 0x00, 0x00, 0x00, 0x01, 0x0a, 0x00, 0x10, 0x00, 0x04, 0x00, 0x08, 0x00, 0x0c, 0x00, + 0x0a, 0x00, 0x00, 0x00, 0x0c, 0x00, 0x00, 0x00, 0x14, 0x00, 0x00, 0x00, 0x1c, 0x00, 0x00, 0x00, + 0x07, 0x00, 0x00, 0x00, 0x4d, 0x79, 0x48, 0x75, 0x62, 0x41, 0x50, 0x00, 0x06, 0x00, 0x00, 0x00, + 0x6d, 0x79, 0x2d, 0x68, 0x75, 0x62, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x7c, 0x00, 0x00, 0x00, + 0x48, 0x00, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0a, 0x00, 0x10, 0x00, 0x07, 0x00, + 0x08, 0x00, 0x0c, 0x00, 0x0a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x14, 0x00, 0x00, 0x00, + 0x04, 0x00, 0x00, 0x00, 0x07, 0x00, 0x00, 0x00, 0x73, 0x33, 0x63, 0x72, 0x65, 0x74, 0x21, 0x00, + 0x06, 0x00, 0x00, 0x00, 0x4f, 0x66, 0x66, 0x69, 0x63, 0x65, 0x00, 0x00, 0x0c, 0x00, 0x10, 0x00, + 0x06, 0x00, 0x08, 0x00, 0x0c, 0x00, 0x07, 0x00, 0x0c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, + 0x10, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x05, 0x00, 0x00, 0x00, 0x47, 0x75, 0x65, 0x73, 0x74, 0x00, 0x0e, 0x00, 0x18, 0x00, 0x06, 0x00, + 0x08, 0x00, 0x0c, 0x00, 0x07, 0x00, 0x12, 0x00, 0x0e, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x03, + 0x20, 0x00, 0x00, 0x00, 0x0c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, + 0x08, 0x00, 0x00, 0x00, 0x68, 0x75, 0x6e, 0x74, 0x65, 0x72, 0x32, 0x32, 0x00, 0x00, 0x00, 0x00, + 0x07, 0x00, 0x00, 0x00, 0x48, 0x6f, 0x6d, 0x65, 0x4e, 0x65, 0x74, 0x00, 0x08, 0x00, 0x08, 0x00, + 0x06, 0x00, 0x07, 0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0f, 0x00, + }; + // clang-format on +} // namespace ConfigTest::Legacy diff --git a/components/config/host_test/main/stubs/fs/ConfigFs.h b/components/config/host_test/main/stubs/fs/ConfigFs.h new file mode 100644 index 00000000..3c81bfce --- /dev/null +++ b/components/config/host_test/main/stubs/fs/ConfigFs.h @@ -0,0 +1,66 @@ +#pragma once + +// Host-test stub for fs/ConfigFs.h: an in-memory file store instead of littlefs on +// a flash partition. Every ConfigFs instance shares one store, so tests can seed +// the file Config::Init loads and inspect what Config writes (see HostConfigFs). +#include "OpenShock.h" + +#include + +#include +#include +#include +#include +#include + +namespace OpenShock { + struct HostConfigFs { + static std::map>& files() + { + static std::map> s_files; + return s_files; + } + static inline bool failWrites = false; + static inline int writeCount = 0; + + static void reset() + { + files().clear(); + failWrites = false; + writeCount = 0; + } + }; + + class ConfigFs { + DISABLE_COPY(ConfigFs); + DISABLE_MOVE(ConfigFs); + + public: + ConfigFs() = default; + + bool mount(const esp_partition_t* partition) { return partition != nullptr; } + void unmount() { } + bool isMounted() const { return true; } + + bool exists(const char* path) { return HostConfigFs::files().contains(path); } + bool read(const char* path, std::vector& out) + { + auto it = HostConfigFs::files().find(path); + if (it == HostConfigFs::files().end()) { + return false; + } + out = it->second; + return true; + } + bool write(const char* path, std::span data) + { + if (HostConfigFs::failWrites) { + return false; + } + ++HostConfigFs::writeCount; + HostConfigFs::files()[path].assign(data.begin(), data.end()); + return true; + } + bool remove(const char* path) { return HostConfigFs::files().erase(path) != 0; } + }; +} // namespace OpenShock diff --git a/components/config/host_test/main/test_corrupt.cpp b/components/config/host_test/main/test_corrupt.cpp new file mode 100644 index 00000000..961edcd6 --- /dev/null +++ b/components/config/host_test/main/test_corrupt.cpp @@ -0,0 +1,330 @@ +// Corrupt input: truncated, garbage or tampered config buffers (from flash or from +// rawconfig/jsonconfig over serial) must be rejected without crashing, and must not +// replace the stored config. +#include "unity.h" + +#include "config_test_helpers.h" +#include "legacy_fixtures.h" + +#include +#include +#include +#include +#include + +using namespace OpenShock; +using namespace ConfigTest; + +namespace { + // Small deterministic PRNG (xorshift32) so failures are reproducible. + uint32_t NextRandom(uint32_t& state) + { + state ^= state << 13; + state ^= state >> 17; + state ^= state << 5; + return state; + } + + // Loads a buffer through every path a corrupt config can arrive by. Returns + // whether SetRaw accepted it; never crashes (that is the point of the callers). + bool TryAllLoadPaths(const std::vector& data) + { + InitDefault(); + const bool accepted = Config::SetRaw(data.data(), data.size()); + if (!accepted) { + TEST_ASSERT_EQUAL_INT_MESSAGE(0, HostConfigFs::writeCount, "rejected SetRaw must not write"); + } + + // Booting with it stored: Init either loads it or falls back to defaults. + InitFrom(data); + Snapshot(); + return accepted; + } +} // namespace + +TEST_CASE("SetRaw rejects null and too-short buffers", "[config][corrupt]") +{ + InitDefault(); + const std::vector before = StoredFile(); + + const uint8_t tiny[3] = {0x04, 0x00, 0x00}; + TEST_ASSERT_FALSE(Config::SetRaw(nullptr, 0)); + TEST_ASSERT_FALSE(Config::SetRaw(nullptr, 128)); + TEST_ASSERT_FALSE(Config::SetRaw(tiny, 0)); + TEST_ASSERT_FALSE(Config::SetRaw(tiny, sizeof(tiny))); + + TEST_ASSERT_EQUAL_INT(0, HostConfigFs::writeCount); + TEST_ASSERT_EQUAL_size_t(before.size(), StoredFile().size()); +} + +TEST_CASE("Every truncation of a legacy config is rejected", "[config][corrupt]") +{ + for (std::size_t len = 0; len < sizeof(Legacy::kDevelopConfig); ++len) { + std::vector data(Legacy::kDevelopConfig, Legacy::kDevelopConfig + len); + char msg[48]; + snprintf(msg, sizeof(msg), "truncated to %zu bytes", len); + TEST_ASSERT_FALSE_MESSAGE(TryAllLoadPaths(data), msg); + } +} + +TEST_CASE("Truncated config on flash boots with defaults and rewrites the file", "[config][corrupt]") +{ + const std::vector truncated(Legacy::kDevelopConfig, Legacy::kDevelopConfig + sizeof(Legacy::kDevelopConfig) / 2); + TEST_ASSERT_FALSE(InitFrom(truncated)); // fell back + AssertConfigEqual(Config::RootConfig(), Snapshot()); + + // The rewritten file is a valid default config. + TEST_ASSERT_TRUE(InitFrom(StoredFile())); + AssertConfigEqual(Config::RootConfig(), Snapshot()); +} + +TEST_CASE("Random garbage is rejected", "[config][corrupt]") +{ + uint32_t state = 0x12345678; + for (int round = 0; round < 300; ++round) { + std::vector data(4 + NextRandom(state) % 600); + for (auto& b : data) b = static_cast(NextRandom(state)); + char msg[48]; + snprintf(msg, sizeof(msg), "garbage round %d", round); + TEST_ASSERT_FALSE_MESSAGE(TryAllLoadPaths(data), msg); + } +} + +TEST_CASE("Bit flips in a legacy config never crash the loader", "[config][corrupt]") +{ + // A flipped byte may still form a valid (different) config; what matters is that + // verification keeps every read in bounds (run under ASan/UBSan to see it). + for (std::size_t i = 0; i < sizeof(Legacy::kDevelopConfig); ++i) { + for (uint8_t mask : {0x01, 0x80, 0xFF}) { + std::vector data(Legacy::kDevelopConfig, Legacy::kDevelopConfig + sizeof(Legacy::kDevelopConfig)); + data[i] ^= mask; + TryAllLoadPaths(data); + } + } +} + +TEST_CASE("Out-of-range offsets in the root table are rejected", "[config][corrupt]") +{ + std::vector data(Legacy::kDevelopConfig, Legacy::kDevelopConfig + sizeof(Legacy::kDevelopConfig)); + + // Root offset pointing past the end of the buffer. + data[0] = 0xF0; + data[1] = 0xFF; + TEST_ASSERT_FALSE(TryAllLoadPaths(data)); + + // Root offset pointing at the last byte. + const uint32_t last = static_cast(data.size() - 1); + data[0] = static_cast(last); + data[1] = static_cast(last >> 8); + TEST_ASSERT_FALSE(TryAllLoadPaths(data)); +} + +TEST_CASE("Unterminated and oversized strings are rejected", "[config][corrupt]") +{ + // Make the length prefix of the backend domain string ("api.example.org") absurd. + std::vector data(Legacy::kDevelopConfig, Legacy::kDevelopConfig + sizeof(Legacy::kDevelopConfig)); + const std::string needle = "api.example.org"; + auto it = std::search(data.begin(), data.end(), needle.begin(), needle.end()); + TEST_ASSERT_TRUE(it != data.end()); + const std::size_t lenPos = static_cast(it - data.begin()) - 4; + TEST_ASSERT_EQUAL_UINT8(needle.size(), data[lenPos]); + + data[lenPos + 1] = 0x7F; // ~32 KiB long + TEST_ASSERT_FALSE(TryAllLoadPaths(data)); + + // Correct length, but the terminating NUL overwritten. + data[lenPos + 1] = 0x00; + data[lenPos + 4 + needle.size()] = 'X'; + TEST_ASSERT_FALSE(TryAllLoadPaths(data)); +} + +TEST_CASE("jsonconfig set rejects malformed JSON without touching the store", "[config][corrupt][json]") +{ + const char* inputs[] = { + "", + " ", + "not json", + "{", + "{\"rf\":", + "[1,2,3]", + "\"string\"", + "42", + "null", + "{\"rf\":5}", + "{\"rf\":{\"txPin\":13}", + "{\"wifi\":{\"credentials\":[{\"id\":1,\"ssid\":\"x\"}]", + "{\"estop\":{\"enabled\":\"yes\"}}", + "{\"captivePortal\":[]}", + }; + + for (const char* input : inputs) { + TEST_ASSERT_TRUE(InitFrom(Serialize(MakeSampleConfig()))); + const std::vector before = StoredFile(); + + TEST_ASSERT_FALSE_MESSAGE(Config::SaveFromJSON(input), input); + TEST_ASSERT_EQUAL_INT_MESSAGE(0, HostConfigFs::writeCount, input); + TEST_ASSERT_EQUAL_size_t(before.size(), StoredFile().size()); + TEST_ASSERT_EQUAL_UINT8_ARRAY(before.data(), StoredFile().data(), before.size()); + } +} + +TEST_CASE("Rejected jsonconfig set leaves the running config unchanged", "[config][corrupt][json]") +{ + const Config::RootConfig sample = MakeSampleConfig(); + TEST_ASSERT_TRUE(InitFrom(Serialize(sample))); + + // rf parses, then estop fails: the call must be all-or-nothing. + TEST_ASSERT_FALSE(Config::SaveFromJSON(R"({"rf":{"txPin":13,"keepAliveEnabled":true},"estop":{"enabled":"yes"}})")); + TEST_ASSERT_EQUAL_INT(0, HostConfigFs::writeCount); + + // Sections parsed before the failing one (rf) must not be applied either. + AssertConfigEqual(sample, Snapshot()); +} + +TEST_CASE("jsonconfig set that cannot be stored leaves the running config unchanged", "[config][corrupt][json]") +{ + const Config::RootConfig sample = MakeSampleConfig(); + TEST_ASSERT_TRUE(InitFrom(Serialize(sample))); + const std::vector before = StoredFile(); + + HostConfigFs::failWrites = true; + TEST_ASSERT_FALSE(Config::SaveFromJSON(R"({"rf":{"txPin":13,"keepAliveEnabled":true}})")); + HostConfigFs::failWrites = false; + + AssertConfigEqual(sample, Snapshot()); + TEST_ASSERT_TRUE(before == StoredFile()); +} + +TEST_CASE("Adding WiFi credentials that cannot be stored changes nothing", "[config][corrupt]") +{ + const Config::RootConfig sample = MakeSampleConfig(); + TEST_ASSERT_TRUE(InitFrom(Serialize(sample))); + + HostConfigFs::failWrites = true; + TEST_ASSERT_EQUAL_UINT8(0, Config::AddWiFiCredentials("NewNet", "password1", WIFI_AUTH_WPA2_PSK)); // new SSID + TEST_ASSERT_EQUAL_UINT8(0, Config::AddWiFiCredentials("HomeNet", "changed!", WIFI_AUTH_WPA3_PSK)); // existing SSID + HostConfigFs::failWrites = false; + + AssertConfigEqual(sample, Snapshot()); +} + +TEST_CASE("Setters that cannot be stored leave the running config unchanged", "[config][corrupt]") +{ + const Config::RootConfig sample = MakeSampleConfig(); + TEST_ASSERT_TRUE(InitFrom(Serialize(sample))); + TEST_ASSERT_FALSE(sample.wifi.credentialsList.empty()); + + HostConfigFs::failWrites = true; + TEST_ASSERT_FALSE(Config::SetBackendAuthToken("replaced-token")); + TEST_ASSERT_FALSE(Config::ClearBackendAuthToken()); + TEST_ASSERT_FALSE(Config::SetWiFiHostname("other-host")); + TEST_ASSERT_FALSE(Config::SetRFConfigKeepAliveEnabled(!sample.rf.keepAliveEnabled)); + TEST_ASSERT_FALSE(Config::SetEStopEnabled(!sample.estop.enabled)); + TEST_ASSERT_FALSE(Config::ClearWiFiCredentials()); + TEST_ASSERT_FALSE(Config::RemoveWiFiCredentials(sample.wifi.credentialsList.front().id)); + HostConfigFs::failWrites = false; + + AssertConfigEqual(sample, Snapshot()); +} + +TEST_CASE("A malformed JSON bssid does not pin a partial address", "[config][corrupt][json]") +{ + InitDefault(); + TEST_ASSERT_TRUE(Config::SaveFromJSON(R"({"wifi":{"credentials":[{"id":1,"ssid":"net","bssid":"123456789aZZ"}]}})")); + + const Config::RootConfig loaded = Snapshot(); + TEST_ASSERT_EQUAL_size_t(1, loaded.wifi.credentialsList.size()); + TEST_ASSERT_FALSE(loaded.wifi.credentialsList[0].HasPinnedBSSID()); +} + +TEST_CASE("An unknown OTA channel in JSON falls back to the default, not the running value", "[config][corrupt][json]") +{ + Config::RootConfig sample = MakeSampleConfig(); + sample.otaUpdate.updateChannel = OtaUpdateChannel::Develop; + TEST_ASSERT_TRUE(InitFrom(Serialize(sample))); + + TEST_ASSERT_TRUE(Config::SaveFromJSON(R"({"otaUpdate":{"updateChannel":"nightly","updateStep":42}})")); + + const Config::RootConfig loaded = Snapshot(); + TEST_ASSERT_EQUAL_UINT8(static_cast(OtaUpdateChannel::Stable), static_cast(loaded.otaUpdate.updateChannel)); + TEST_ASSERT_EQUAL_UINT8(static_cast(OtaUpdateStep::None), static_cast(loaded.otaUpdate.updateStep)); +} + +TEST_CASE("Schema fields the firmware does not model are dropped on save", "[config][corrupt]") +{ + // estop.active / estop.latching (and wifi AP/LAN fields) exist in the schema but nothing in the firmware reads + // them, so they are intentionally not round-tripped. This pins that behaviour. + namespace Fbs = Serialization::Configuration; + flatbuffers::FlatBufferBuilder builder; + auto estop = Fbs::CreateEStopConfig(builder, false, -1, /*active=*/true, /*latching=*/true); + Fbs::FinishHubConfigBuffer(builder, Fbs::CreateHubConfig(builder, 0, 0, 0, 0, 0, 0, estop)); + TEST_ASSERT_TRUE(InitFrom(builder.GetBufferPointer(), builder.GetSize())); + + TEST_ASSERT_TRUE(Config::SetSerialInputConfigEchoEnabled(false)); // any save rewrites the file + + const std::vector stored = StoredFile(); + const auto* root = Fbs::GetHubConfig(stored.data()); + TEST_ASSERT_NOT_NULL(root->estop()); + TEST_ASSERT_FALSE(root->estop()->active()); + TEST_ASSERT_FALSE(root->estop()->latching()); +} + +TEST_CASE("RemoveWiFiCredentials reports unknown IDs", "[config]") +{ + InitDefault(); + TEST_ASSERT_FALSE(Config::RemoveWiFiCredentials(42)); +} + +TEST_CASE("Out-of-range E-Stop GPIO in a stored config falls back to the default", "[config][corrupt]") +{ + // gpio_pin is an int8 on flash; values outside gpio_num_t must not be cast to it + // (undefined behaviour, flagged by UBSan) and mean a corrupt section. + namespace Fbs = Serialization::Configuration; + for (int8_t pin : {static_cast(-128), static_cast(-2), static_cast(GPIO_NUM_MAX), static_cast(127)}) { + flatbuffers::FlatBufferBuilder builder; + auto estop = Fbs::CreateEStopConfig(builder, true, pin); + auto root = Fbs::CreateHubConfig(builder, 0, 0, 0, 0, 0, 0, estop); + Fbs::FinishHubConfigBuffer(builder, root); + + char msg[32]; + snprintf(msg, sizeof(msg), "gpio_pin %d", pin); + TEST_ASSERT_TRUE_MESSAGE(InitFrom(builder.GetBufferPointer(), builder.GetSize()), msg); + + Config::EStopConfig estopConfig; + TEST_ASSERT_TRUE(Config::GetEStopConfig(estopConfig)); + TEST_ASSERT_EQUAL_INT_MESSAGE(Config::EStopConfig().gpioPin, estopConfig.gpioPin, msg); + TEST_ASSERT_EQUAL_MESSAGE(Config::EStopConfig().enabled, estopConfig.enabled, msg); + } +} + +TEST_CASE("jsonconfig set survives deeply nested and huge input", "[config][corrupt][json]") +{ + InitDefault(); + + std::string nested; + for (int i = 0; i < 2000; ++i) nested += "{\"a\":"; + nested += "1"; + for (int i = 0; i < 2000; ++i) nested += "}"; + Config::SaveFromJSON(nested); // must not crash; result does not matter + + std::string bigArray = "{\"wifi\":{\"credentials\":["; + for (int i = 0; i < 2000; ++i) bigArray += (i ? ",1" : "1"); + bigArray += "]}}"; + Config::SaveFromJSON(bigArray); + + Snapshot(); +} + +TEST_CASE("Out-of-range JSON values are not stored", "[config][corrupt][json]") +{ + InitDefault(); + + // txPin outside uint8 / not a real GPIO -> board default; id outside uint8 -> credentials rejected. + TEST_ASSERT_TRUE(Config::SaveFromJSON(R"({"rf":{"txPin":300},"wifi":{"credentials":[{"id":1,"ssid":"ok"},{"id":"x","ssid":""}]},"otaUpdate":{"checkInterval":70000,"updateChannel":"nightly"}})")); + + const Config::RootConfig loaded = Snapshot(); + TEST_ASSERT_EQUAL_INT(OPENSHOCK_RF_TX_GPIO, loaded.rf.txPin); + TEST_ASSERT_EQUAL_size_t(1, loaded.wifi.credentialsList.size()); + TEST_ASSERT_EQUAL_STRING("ok", loaded.wifi.credentialsList[0].ssid.c_str()); + TEST_ASSERT_EQUAL_UINT16(30, loaded.otaUpdate.checkInterval); +} diff --git a/components/config/host_test/main/test_defaults.cpp b/components/config/host_test/main/test_defaults.cpp new file mode 100644 index 00000000..d8c06156 --- /dev/null +++ b/components/config/host_test/main/test_defaults.cpp @@ -0,0 +1,200 @@ +// Defaults: no stored config, an empty one, or one missing sections/fields must give +// usable defaults rather than a failed load (which would wipe the whole file). +#include "unity.h" + +#include "config_test_helpers.h" + +#include + +using namespace OpenShock; +using namespace ConfigTest; +namespace Fbs = OpenShock::Serialization::Configuration; + +namespace { + std::vector Finish(flatbuffers::FlatBufferBuilder& builder, flatbuffers::Offset root) + { + Fbs::FinishHubConfigBuffer(builder, root); + return std::vector(builder.GetBufferPointer(), builder.GetBufferPointer() + builder.GetSize()); + } +} // namespace + +TEST_CASE("Default values come from Kconfig and the board header", "[config][defaults]") +{ + const Config::RootConfig config; + + TEST_ASSERT_EQUAL_INT(OPENSHOCK_RF_TX_GPIO, config.rf.txPin); + TEST_ASSERT_TRUE(config.rf.keepAliveEnabled); + TEST_ASSERT_EQUAL_STRING(CONFIG_OPENSHOCK_FW_AP_PREFIX, config.wifi.accessPointSSID.c_str()); + TEST_ASSERT_EQUAL_STRING(CONFIG_OPENSHOCK_FW_HOSTNAME, config.wifi.hostname.c_str()); + TEST_ASSERT_TRUE(config.wifi.credentialsList.empty()); + TEST_ASSERT_FALSE(config.captivePortal.alwaysEnabled); + TEST_ASSERT_EQUAL_STRING(CONFIG_OPENSHOCK_API_DOMAIN, config.backend.domain.c_str()); + TEST_ASSERT_TRUE(config.backend.authToken.empty()); + TEST_ASSERT_TRUE(config.serialInput.echoEnabled); + TEST_ASSERT_TRUE(config.otaUpdate.isEnabled); + TEST_ASSERT_EQUAL_STRING(CONFIG_OPENSHOCK_FW_CDN_DOMAIN, config.otaUpdate.cdnDomain.c_str()); + TEST_ASSERT_EQUAL_UINT8(static_cast(OtaUpdateChannel::Stable), static_cast(config.otaUpdate.updateChannel)); + TEST_ASSERT_EQUAL_UINT16(30, config.otaUpdate.checkInterval); + TEST_ASSERT_TRUE(config.otaUpdate.allowBackendManagement); + TEST_ASSERT_EQUAL_INT32(0, config.otaUpdate.updateId); + TEST_ASSERT_EQUAL_UINT8(static_cast(OtaUpdateStep::None), static_cast(config.otaUpdate.updateStep)); + // The host-test board has no E-Stop pin, so E-Stop defaults to off. + TEST_ASSERT_EQUAL_INT(OPENSHOCK_ESTOP_PIN, config.estop.gpioPin); + TEST_ASSERT_FALSE(config.estop.enabled); +} + +TEST_CASE("ToDefault resets every section to the constructed defaults", "[config][defaults]") +{ + Config::RootConfig config = MakeSampleConfig(); + config.ToDefault(); + AssertConfigEqual(Config::RootConfig(), config); +} + +TEST_CASE("Missing config file: Init writes a default config that reloads", "[config][defaults]") +{ + TEST_ASSERT_FALSE(InitFrom(nullptr, 0)); // falls back to defaults and writes them + TEST_ASSERT_EQUAL_INT(1, HostConfigFs::writeCount); + AssertConfigEqual(Config::RootConfig(), Snapshot()); + + const std::vector written = StoredFile(); + TEST_ASSERT_FALSE(written.empty()); + TEST_ASSERT_TRUE(InitFrom(written)); + AssertConfigEqual(Config::RootConfig(), Snapshot()); +} + +TEST_CASE("HubConfig with no sections loads as defaults", "[config][defaults]") +{ + flatbuffers::FlatBufferBuilder builder; + const std::vector bytes = Finish(builder, Fbs::CreateHubConfig(builder)); + + TEST_ASSERT_TRUE(InitFrom(bytes)); + AssertConfigEqual(Config::RootConfig(), Snapshot()); +} + +TEST_CASE("Missing sections default while present ones are kept", "[config][defaults]") +{ + flatbuffers::FlatBufferBuilder builder; + auto backend = Fbs::CreateBackendConfigDirect(builder, "api.example.org", "tok"); + auto estop = Fbs::CreateEStopConfig(builder, true, 13); + const std::vector bytes = Finish(builder, Fbs::CreateHubConfig(builder, 0, 0, 0, backend, 0, 0, estop)); + + TEST_ASSERT_TRUE(InitFrom(bytes)); + + Config::RootConfig expected; + expected.backend = Config::BackendConfig("api.example.org", "tok"); + expected.estop = Config::EStopConfig(true, GPIO_NUM_13); + AssertConfigEqual(expected, Snapshot()); +} + +TEST_CASE("Missing fields inside a section take their defaults", "[config][defaults]") +{ + flatbuffers::FlatBufferBuilder builder; + + Fbs::RFConfigBuilder rf(builder); + rf.add_tx_pin(13); + auto rfOffset = rf.Finish(); // keepalive_enabled absent + + auto hostname = builder.CreateString("my-hub"); + Fbs::WiFiConfigBuilder wifi(builder); + wifi.add_hostname(hostname); + auto wifiOffset = wifi.Finish(); // no AP SSID, no credentials + + auto domain = builder.CreateString("api.example.org"); + Fbs::BackendConfigBuilder backend(builder); + backend.add_domain(domain); + auto backendOffset = backend.Finish(); // no auth token + + Fbs::OtaUpdateConfigBuilder ota(builder); + ota.add_update_id(77); + auto otaOffset = ota.Finish(); // no CDN domain, rest at schema defaults + + const std::vector bytes = Finish(builder, Fbs::CreateHubConfig(builder, rfOffset, wifiOffset, 0, backendOffset, 0, otaOffset, 0)); + TEST_ASSERT_TRUE(InitFrom(bytes)); + const Config::RootConfig loaded = Snapshot(); + + TEST_ASSERT_EQUAL_INT(GPIO_NUM_13, loaded.rf.txPin); + TEST_ASSERT_TRUE(loaded.rf.keepAliveEnabled); + TEST_ASSERT_EQUAL_STRING(CONFIG_OPENSHOCK_FW_AP_PREFIX, loaded.wifi.accessPointSSID.c_str()); + TEST_ASSERT_EQUAL_STRING("my-hub", loaded.wifi.hostname.c_str()); + TEST_ASSERT_TRUE(loaded.wifi.credentialsList.empty()); + TEST_ASSERT_EQUAL_STRING("api.example.org", loaded.backend.domain.c_str()); + TEST_ASSERT_TRUE(loaded.backend.authToken.empty()); + TEST_ASSERT_TRUE(loaded.otaUpdate.isEnabled); + TEST_ASSERT_EQUAL_STRING(CONFIG_OPENSHOCK_FW_CDN_DOMAIN, loaded.otaUpdate.cdnDomain.c_str()); + TEST_ASSERT_TRUE(loaded.otaUpdate.allowBackendManagement); + TEST_ASSERT_EQUAL_INT32(77, loaded.otaUpdate.updateId); +} + +TEST_CASE("Credentials without an SSID are dropped, the rest kept", "[config][defaults]") +{ + flatbuffers::FlatBufferBuilder builder; + std::vector> creds { + Fbs::CreateWiFiCredentialsDirect(builder, 1, "HomeNet", "hunter22"), + Fbs::CreateWiFiCredentialsDirect(builder, 2, nullptr, "orphan"), + Fbs::CreateWiFiCredentialsDirect(builder, 3, "", "empty"), + Fbs::CreateWiFiCredentialsDirect(builder, 4, "Office", nullptr), + }; + auto wifi = Fbs::CreateWiFiConfig(builder, 0, 0, builder.CreateVector(creds)); + TEST_ASSERT_TRUE(InitFrom(Finish(builder, Fbs::CreateHubConfig(builder, 0, wifi)))); + + std::vector loaded; + TEST_ASSERT_TRUE(Config::GetWiFiCredentials(loaded)); + TEST_ASSERT_EQUAL_size_t(2, loaded.size()); + AssertCredentialsEqual(Config::WiFiCredentials(1, "HomeNet", "hunter22"), loaded[0]); + AssertCredentialsEqual(Config::WiFiCredentials(4, "Office", ""), loaded[1]); +} + +TEST_CASE("Invalid stored GPIO pins fall back safely", "[config][defaults]") +{ + flatbuffers::FlatBufferBuilder builder; + auto rf = Fbs::CreateRFConfig(builder, 24, true); // GPIO24 does not exist on ESP32 + auto estop = Fbs::CreateEStopConfig(builder, true, 0); // strapping pin: not a valid E-Stop input + TEST_ASSERT_TRUE(InitFrom(Finish(builder, Fbs::CreateHubConfig(builder, rf, 0, 0, 0, 0, 0, estop)))); + + gpio_num_t txPin; + TEST_ASSERT_TRUE(Config::GetRFConfigTxPin(txPin)); + TEST_ASSERT_EQUAL_INT(OPENSHOCK_RF_TX_GPIO, txPin); + + bool estopEnabled = true; + TEST_ASSERT_TRUE(Config::GetEStopEnabled(estopEnabled)); + TEST_ASSERT_FALSE(estopEnabled); + + // Negative pin (the old "invalid" marker) also falls back. + flatbuffers::FlatBufferBuilder builder2; + auto rf2 = Fbs::CreateRFConfig(builder2, -1, true); + TEST_ASSERT_TRUE(InitFrom(Finish(builder2, Fbs::CreateHubConfig(builder2, rf2)))); + TEST_ASSERT_TRUE(Config::GetRFConfigTxPin(txPin)); + TEST_ASSERT_EQUAL_INT(OPENSHOCK_RF_TX_GPIO, txPin); +} + +TEST_CASE("jsonconfig set with an empty object resets to defaults", "[config][defaults][json]") +{ + TEST_ASSERT_TRUE(InitFrom(Serialize(MakeSampleConfig()))); + TEST_ASSERT_TRUE(Config::SaveFromJSON("{}")); + AssertConfigEqual(Config::RootConfig(), Snapshot()); + + TEST_ASSERT_TRUE(InitFrom(StoredFile())); + AssertConfigEqual(Config::RootConfig(), Snapshot()); +} + +TEST_CASE("jsonconfig set with partial sections fills in defaults", "[config][defaults][json]") +{ + InitDefault(); + TEST_ASSERT_TRUE(Config::SaveFromJSON(R"({"rf":{"txPin":13},"wifi":{"hostname":"my-hub","credentials":[]},"backend":{"domain":"api.example.org"},"otaUpdate":{"updateChannel":"develop"}})")); + + Config::RootConfig expected; + expected.rf.txPin = GPIO_NUM_13; + expected.wifi.hostname = "my-hub"; + expected.backend.domain = "api.example.org"; + expected.otaUpdate.updateChannel = OtaUpdateChannel::Develop; + AssertConfigEqual(expected, Snapshot()); +} + +TEST_CASE("jsonconfig set requires the wifi credentials array when wifi is given", "[config][defaults][json]") +{ + // Same rule as the develop firmware: a wifi object without "credentials" is rejected. + InitDefault(); + TEST_ASSERT_FALSE(Config::SaveFromJSON(R"({"wifi":{"hostname":"my-hub"}})")); + TEST_ASSERT_FALSE(Config::SaveFromJSON(R"({"wifi":{"hostname":"my-hub","credentials":{}}})")); + TEST_ASSERT_EQUAL_INT(0, HostConfigFs::writeCount); +} diff --git a/components/config/host_test/main/test_legacy.cpp b/components/config/host_test/main/test_legacy.cpp new file mode 100644 index 00000000..52f35727 --- /dev/null +++ b/components/config/host_test/main/test_legacy.cpp @@ -0,0 +1,265 @@ +// Upgrade safety: hubs flashed from the Arduino firmware (1.5.x releases and the +// develop branch) must keep their config. The fixtures are the exact bytes those +// firmwares stored in the "config" partition (see legacy_fixtures.h). +#include "unity.h" + +#include "config_test_helpers.h" +#include "legacy_fixtures.h" + +#include +#include +#include + +using namespace OpenShock; +using namespace ConfigTest; + +namespace { + // The settings every legacy fixture holds (see legacy_fixtures.h). + Config::RootConfig ExpectedLegacyConfig(bool hasAuthModeAndBssid, bool keepAlive) + { + Config::RootConfig config; + config.rf = Config::RFConfig(GPIO_NUM_15, keepAlive); + + Config::WiFiCredentials home(1, "HomeNet", "hunter22"); + Config::WiFiCredentials guest(2, "Guest", ""); + Config::WiFiCredentials office(3, "Office", "s3cret!"); + if (hasAuthModeAndBssid) { + home.authMode = WIFI_AUTH_WPA2_PSK; + home.bssid = {0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc}; + guest.authMode = WIFI_AUTH_OPEN; + } + config.wifi = Config::WiFiConfig("MyHubAP", "my-hub", {home, guest, office}); + + config.captivePortal = Config::CaptivePortalConfig(true); + config.backend = Config::BackendConfig("api.example.org", "tok_0123456789abcdef"); + config.serialInput = Config::SerialInputConfig(false); + config.otaUpdate = Config::OtaUpdateConfig(false, "fw.example.org", OtaUpdateChannel::Beta, true, true, 120, false, true, 4242, OtaUpdateStep::Validating); + config.estop = Config::EStopConfig(true, GPIO_NUM_13); + return config; + } + + // develop's `jsonconfig` output (cJSON_PrintUnformatted of RootConfig::ToJSON) for + // kDevelopConfig: same key order and number/bool/bssid formatting as its ToJSON bodies. + constexpr const char* kDevelopJson = R"({"rf":{"txPin":15,"keepAliveEnabled":false},)" + R"("wifi":{"accessPointSSID":"MyHubAP","hostname":"my-hub","credentials":[)" + R"({"id":1,"ssid":"HomeNet","password":"hunter22","authMode":3,"bssid":"123456789ABC"},)" + R"({"id":2,"ssid":"Guest","password":"","authMode":0},)" + R"({"id":3,"ssid":"Office","password":"s3cret!","authMode":9}]},)" + R"("captivePortal":{"alwaysEnabled":true},)" + R"("backend":{"domain":"api.example.org","authToken":"tok_0123456789abcdef"},)" + R"("serialInput":{"echoEnabled":false},)" + R"("otaUpdate":{"isEnabled":false,"cdnDomain":"fw.example.org","updateChannel":"Beta","checkOnStartup":true,"checkPeriodically":true,)" + R"("checkInterval":120,"allowBackendManagement":false,"requireManualApproval":true,"updateId":4242,"updateStep":"Validating"},)" + R"("estop":{"enabled":true,"gpioPin":13}})"; + + // 1.5.2's `jsonconfig` output for kV152Config (its credentials had no authMode/bssid). + constexpr const char* kV152Json = R"({"rf":{"txPin":15,"keepAliveEnabled":true},)" + R"("wifi":{"accessPointSSID":"MyHubAP","hostname":"my-hub","credentials":[)" + R"({"id":1,"ssid":"HomeNet","password":"hunter22"},)" + R"({"id":2,"ssid":"Guest","password":""},)" + R"({"id":3,"ssid":"Office","password":"s3cret!"}]},)" + R"("captivePortal":{"alwaysEnabled":true},)" + R"("backend":{"domain":"api.example.org","authToken":"tok_0123456789abcdef"},)" + R"("serialInput":{"echoEnabled":false},)" + R"("otaUpdate":{"isEnabled":false,"cdnDomain":"fw.example.org","updateChannel":"Beta","checkOnStartup":true,"checkPeriodically":true,)" + R"("checkInterval":120,"allowBackendManagement":false,"requireManualApproval":true,"updateId":4242,"updateStep":"Validating"},)" + R"("estop":{"enabled":true,"gpioPin":13}})"; +} // namespace + +TEST_CASE("Config stored by develop (Arduino) firmware loads unchanged", "[config][legacy]") +{ + TEST_ASSERT_TRUE_MESSAGE(InitFrom(Legacy::kDevelopConfig, sizeof(Legacy::kDevelopConfig)), "Init fell back to defaults"); + AssertConfigEqual(ExpectedLegacyConfig(true, false), Snapshot()); + + // Loading alone must not rewrite the file. + TEST_ASSERT_EQUAL_size_t(sizeof(Legacy::kDevelopConfig), StoredFile().size()); + TEST_ASSERT_EQUAL_UINT8_ARRAY(Legacy::kDevelopConfig, StoredFile().data(), sizeof(Legacy::kDevelopConfig)); +} + +TEST_CASE("Config stored by 1.5.2 release firmware loads unchanged", "[config][legacy]") +{ + TEST_ASSERT_TRUE_MESSAGE(InitFrom(Legacy::kV152Config, sizeof(Legacy::kV152Config)), "Init fell back to defaults"); + + // 1.5.2 stored no auth mode / BSSID: they read back as unknown / not pinned. + // OTA updates and backend management are off in this file, which 1.5.2 encoded + // by omitting the fields (its schema default was false); the stored keepalive + // true shows the file uses that convention, so they must not read as true. + AssertConfigEqual(ExpectedLegacyConfig(false, true), Snapshot()); +} + +TEST_CASE("1.5.2 booleans stored as false keep their value", "[config][legacy]") +{ + // kV152ConfigKeepAliveOff has rf.keepalive_enabled, ota.is_enabled and + // ota.allow_backend_management all false. + TEST_ASSERT_TRUE(InitFrom(Legacy::kV152ConfigKeepAliveOff, sizeof(Legacy::kV152ConfigKeepAliveOff))); + + bool allOn; + { + // Scoped: TEST_IGNORE longjmps out, skipping destructors. + const Config::RootConfig loaded = Snapshot(); + allOn = loaded.rf.keepAliveEnabled && loaded.otaUpdate.isEnabled && loaded.otaUpdate.allowBackendManagement; + } + if (allOn) { + // 1.5.2's schema gave these three fields default false, so flatbuffers never + // wrote a false value; the current schema defaults them to true. With all three + // false, 1.5.2 omitted all three, which is byte for byte how 1.6.0-beta/rc and + // develop store all three true (they omit their default), and no other field + // differs between those writers. Without a version marker this file cannot be + // told apart, so it keeps the current default. Files where any of the three is + // stored as true are resolved (see the tests above and below). Pinned so other + // changes still fail. + TEST_IGNORE_MESSAGE("KNOWN ISSUE: 1.5.x with keepalive, OTA and backend management all off reads back all on (ambiguous with 1.6 defaults)"); + } + AssertConfigEqual(ExpectedLegacyConfig(false, false), Snapshot()); +} + +namespace { + namespace Fbs = Serialization::Configuration; + + // An rf + ota_update config where each of the three flipped-default booleans is + // stored with the given value, or omitted (std::nullopt), as a flatbuffer writer + // under either schema would leave it. + std::vector FlippedDefaultsConfig(std::optional keepAlive, std::optional otaEnabled, std::optional backendManagement) + { + flatbuffers::FlatBufferBuilder builder; + + Fbs::RFConfigBuilder rf(builder); + rf.add_tx_pin(15); + if (keepAlive) builder.AddElement(Fbs::RFConfig::VT_KEEPALIVE_ENABLED, *keepAlive); + auto rfOffset = rf.Finish(); + + Fbs::OtaUpdateConfigBuilder ota(builder); + ota.add_check_interval(30); + if (otaEnabled) builder.AddElement(Fbs::OtaUpdateConfig::VT_IS_ENABLED, *otaEnabled); + if (backendManagement) builder.AddElement(Fbs::OtaUpdateConfig::VT_ALLOW_BACKEND_MANAGEMENT, *backendManagement); + auto otaOffset = ota.Finish(); + + Fbs::FinishHubConfigBuffer(builder, Fbs::CreateHubConfig(builder, rfOffset, 0, 0, 0, 0, otaOffset)); + return std::vector(builder.GetBufferPointer(), builder.GetBufferPointer() + builder.GetSize()); + } + + void AssertFlippedDefaults(const std::vector& file, bool keepAlive, bool otaEnabled, bool backendManagement) + { + TEST_ASSERT_TRUE(InitFrom(file)); + const Config::RootConfig loaded = Snapshot(); + TEST_ASSERT_EQUAL_MESSAGE(keepAlive, loaded.rf.keepAliveEnabled, "rf.keepAliveEnabled"); + TEST_ASSERT_EQUAL_MESSAGE(otaEnabled, loaded.otaUpdate.isEnabled, "otaUpdate.isEnabled"); + TEST_ASSERT_EQUAL_MESSAGE(backendManagement, loaded.otaUpdate.allowBackendManagement, "otaUpdate.allowBackendManagement"); + } +} // namespace + +TEST_CASE("Omitted flipped-default booleans follow the convention the file shows", "[config][legacy]") +{ + // 1.5.x convention (default false): only true is stored, so an omitted field is false. + AssertFlippedDefaults(FlippedDefaultsConfig(true, std::nullopt, std::nullopt), true, false, false); + AssertFlippedDefaults(FlippedDefaultsConfig(std::nullopt, true, std::nullopt), false, true, false); + AssertFlippedDefaults(FlippedDefaultsConfig(std::nullopt, std::nullopt, true), false, false, true); + AssertFlippedDefaults(FlippedDefaultsConfig(true, true, std::nullopt), true, true, false); + + // 1.6+ convention (default true): only false is stored, so an omitted field is true. + AssertFlippedDefaults(FlippedDefaultsConfig(false, std::nullopt, std::nullopt), false, true, true); + AssertFlippedDefaults(FlippedDefaultsConfig(std::nullopt, false, false), true, false, false); + + // Explicit values are always taken as stored. + AssertFlippedDefaults(FlippedDefaultsConfig(true, false, true), true, false, true); + AssertFlippedDefaults(FlippedDefaultsConfig(false, true, false), false, true, false); + + // All omitted: ambiguous, keeps the current schema default. + AssertFlippedDefaults(FlippedDefaultsConfig(std::nullopt, std::nullopt, std::nullopt), true, true, true); +} + +TEST_CASE("This firmware stores the flipped-default booleans explicitly", "[config][legacy]") +{ + // So its files read back the same under any schema (a downgrade to 1.5.x reads + // an omitted field as false) and are never ambiguous. + for (bool value : {true, false}) { + Config::RootConfig config; + config.rf.keepAliveEnabled = value; + config.otaUpdate.isEnabled = value; + config.otaUpdate.allowBackendManagement = value; + const std::vector bytes = Serialize(config); + const Fbs::HubConfig* fbs = Fbs::GetHubConfig(bytes.data()); + // Generated tables derive privately from flatbuffers::Table (a view of the same bytes). + const auto* rf = static_cast(static_cast(fbs->rf())); + const auto* ota = static_cast(static_cast(fbs->ota_update())); + TEST_ASSERT_TRUE(rf->CheckField(Fbs::RFConfig::VT_KEEPALIVE_ENABLED)); + TEST_ASSERT_TRUE(ota->CheckField(Fbs::OtaUpdateConfig::VT_IS_ENABLED)); + TEST_ASSERT_TRUE(ota->CheckField(Fbs::OtaUpdateConfig::VT_ALLOW_BACKEND_MANAGEMENT)); + + Config::RootConfig loaded; + TEST_ASSERT_TRUE(Deserialize(bytes.data(), bytes.size(), loaded)); + AssertConfigEqual(config, loaded); + } +} + +TEST_CASE("Legacy config survives the first save by the new firmware", "[config][legacy]") +{ + TEST_ASSERT_TRUE(InitFrom(Legacy::kDevelopConfig, sizeof(Legacy::kDevelopConfig))); + + // Any setter rewrites the whole file in the new firmware's encoding. + TEST_ASSERT_TRUE(Config::SetOtaUpdateStep(OtaUpdateStep::Validated)); + Config::RootConfig expected = ExpectedLegacyConfig(true, false); + expected.otaUpdate.updateStep = OtaUpdateStep::Validated; + + TEST_ASSERT_TRUE(InitFrom(StoredFile())); + AssertConfigEqual(expected, Snapshot()); +} + +TEST_CASE("rawconfig set accepts a config backed up from legacy firmware", "[config][legacy]") +{ + InitDefault(); + TEST_ASSERT_TRUE(Config::SetRaw(Legacy::kV152Config, sizeof(Legacy::kV152Config))); + TEST_ASSERT_TRUE(Config::SetRaw(Legacy::kDevelopConfig, sizeof(Legacy::kDevelopConfig))); + + TEST_ASSERT_TRUE(InitFrom(StoredFile())); + AssertConfigEqual(ExpectedLegacyConfig(true, false), Snapshot()); +} + +TEST_CASE("jsonconfig set accepts JSON exported by develop firmware", "[config][legacy][json]") +{ + InitDefault(); + TEST_ASSERT_TRUE(Config::SaveFromJSON(kDevelopJson)); + AssertConfigEqual(ExpectedLegacyConfig(true, false), Snapshot()); + + TEST_ASSERT_TRUE(InitFrom(StoredFile())); + AssertConfigEqual(ExpectedLegacyConfig(true, false), Snapshot()); +} + +TEST_CASE("jsonconfig set accepts JSON exported by 1.5.2 firmware", "[config][legacy][json]") +{ + InitDefault(); + TEST_ASSERT_TRUE(Config::SaveFromJSON(kV152Json)); + AssertConfigEqual(ExpectedLegacyConfig(false, true), Snapshot()); +} + +TEST_CASE("jsonconfig get emits the same document develop did", "[config][legacy][json]") +{ + TEST_ASSERT_TRUE(InitFrom(Legacy::kDevelopConfig, sizeof(Legacy::kDevelopConfig))); + TEST_ASSERT_EQUAL_STRING(kDevelopJson, Config::GetAsJSON(true).c_str()); +} + +TEST_CASE("Fields from a newer schema are tolerated on load", "[config][legacy]") +{ + // The schema has grown fields this firmware does not read yet (wifi AP password / + // disable flags, LAN config). A file carrying them must still load. + namespace Fbs = Serialization::Configuration; + flatbuffers::FlatBufferBuilder builder; + auto creds = builder.CreateVector(std::vector> {Fbs::CreateWiFiCredentialsDirect(builder, 1, "HomeNet", "hunter22")}); + auto wifi = Fbs::CreateWiFiConfig(builder, builder.CreateString("MyHubAP"), builder.CreateString("my-hub"), creds, builder.CreateString("ap-secret"), true, true, true); + auto lan = Fbs::CreateLanConfigDirect(builder, true, "lan-key"); + auto backend = Fbs::CreateBackendConfigDirect(builder, "api.example.org", "tok"); + auto root = Fbs::CreateHubConfig(builder, 0, wifi, 0, backend, 0, 0, 0, lan); + Fbs::FinishHubConfigBuffer(builder, root); + + TEST_ASSERT_TRUE(InitFrom(builder.GetBufferPointer(), builder.GetSize())); + + Config::WiFiConfig wifiConfig; + TEST_ASSERT_TRUE(Config::GetWiFiConfig(wifiConfig)); + TEST_ASSERT_EQUAL_STRING("my-hub", wifiConfig.hostname.c_str()); + TEST_ASSERT_EQUAL_size_t(1, wifiConfig.credentialsList.size()); + TEST_ASSERT_EQUAL_STRING("hunter22", wifiConfig.credentialsList[0].password.c_str()); + + std::string domain; + TEST_ASSERT_TRUE(Config::GetBackendDomain(domain)); + TEST_ASSERT_EQUAL_STRING("api.example.org", domain.c_str()); +} diff --git a/components/config/host_test/main/test_main.cpp b/components/config/host_test/main/test_main.cpp new file mode 100644 index 00000000..8f08317f --- /dev/null +++ b/components/config/host_test/main/test_main.cpp @@ -0,0 +1,15 @@ +// Host-only (linux target) test binary for the config component: the typed config +// sections, their flatbuffer/JSON (de)serialization, and the Config:: store on top +// of an in-memory ConfigFs. Runs on ESP-IDF's FreeRTOS POSIX port, which starts the +// scheduler and calls app_main from its main task, so the store's ReadWriteMutex is +// the real one. The exit code is the Unity failure count. +#include "unity.h" + +#include + +extern "C" void app_main(void) +{ + UNITY_BEGIN(); + unity_run_all_tests(); + std::exit(UNITY_END()); +} diff --git a/components/config/host_test/main/test_roundtrip.cpp b/components/config/host_test/main/test_roundtrip.cpp new file mode 100644 index 00000000..1e115e69 --- /dev/null +++ b/components/config/host_test/main/test_roundtrip.cpp @@ -0,0 +1,319 @@ +// Round trips: a config written by this firmware must read back unchanged, both as +// the stored flatbuffer and through the jsonconfig (GetAsJSON / SaveFromJSON) path. +#include "unity.h" + +#include "config_test_helpers.h" + +#include + +using namespace OpenShock; +using namespace ConfigTest; + +TEST_CASE("RootConfig flatbuffer round trip keeps every section", "[config][roundtrip]") +{ + const Config::RootConfig sample = MakeSampleConfig(); + const std::vector bytes = Serialize(sample); + + Config::RootConfig loaded; + TEST_ASSERT_TRUE(Deserialize(bytes.data(), bytes.size(), loaded)); + AssertConfigEqual(sample, loaded); +} + +TEST_CASE("Stored config is loaded unchanged by Config::Init", "[config][roundtrip]") +{ + const Config::RootConfig sample = MakeSampleConfig(); + + TEST_ASSERT_TRUE(InitFrom(Serialize(sample))); + AssertConfigEqual(sample, Snapshot()); +} + +TEST_CASE("Serializing without sensitive data drops only the secrets", "[config][roundtrip]") +{ + const Config::RootConfig sample = MakeSampleConfig(); + const std::vector bytes = Serialize(sample, false); + + Config::RootConfig loaded; + TEST_ASSERT_TRUE(Deserialize(bytes.data(), bytes.size(), loaded)); + AssertConfigEqual(sample, loaded, false); +} + +TEST_CASE("Values set through the Config API survive a reload", "[config][roundtrip]") +{ + InitDefault(); + + TEST_ASSERT_TRUE(Config::SetRFConfigTxPin(GPIO_NUM_25)); + TEST_ASSERT_TRUE(Config::SetRFConfigKeepAliveEnabled(false)); + TEST_ASSERT_EQUAL_UINT8(1, Config::AddWiFiCredentials("HomeNet", "hunter22", WIFI_AUTH_WPA2_PSK)); + TEST_ASSERT_EQUAL_UINT8(2, Config::AddWiFiCredentials("Guest", "", WIFI_AUTH_OPEN)); + const uint8_t bssid[6] = {0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc}; + TEST_ASSERT_TRUE(Config::PinWiFiCredentialsBSSID(1, bssid)); + TEST_ASSERT_TRUE(Config::SetWiFiHostname("my-hub")); + TEST_ASSERT_TRUE(Config::SetCaptivePortalConfig(Config::CaptivePortalConfig(true))); + TEST_ASSERT_TRUE(Config::SetBackendDomain("api.example.org")); + TEST_ASSERT_TRUE(Config::SetBackendAuthToken("tok_0123456789abcdef")); + TEST_ASSERT_TRUE(Config::SetSerialInputConfigEchoEnabled(false)); + TEST_ASSERT_TRUE(Config::SetOtaUpdateId(4242)); + TEST_ASSERT_TRUE(Config::SetOtaUpdateStep(OtaUpdateStep::Updated)); + TEST_ASSERT_TRUE(Config::SetEStopGpioPin(GPIO_NUM_13)); + TEST_ASSERT_TRUE(Config::SetEStopEnabled(true)); + + const Config::RootConfig before = Snapshot(); + TEST_ASSERT_EQUAL_UINT8_ARRAY(bssid, before.wifi.credentialsList[0].bssid.data(), 6); + + // Reboot: load the file the setters wrote. + TEST_ASSERT_TRUE(InitFrom(StoredFile())); + AssertConfigEqual(before, Snapshot()); + + std::string token; + TEST_ASSERT_TRUE(Config::HasBackendAuthToken()); + TEST_ASSERT_TRUE(Config::GetBackendAuthToken(token)); + TEST_ASSERT_EQUAL_STRING("tok_0123456789abcdef", token.c_str()); + + Config::WiFiCredentials creds; + TEST_ASSERT_TRUE(Config::TryGetWiFiCredentialsBySSID("Guest", creds)); + TEST_ASSERT_EQUAL_UINT8(2, creds.id); + TEST_ASSERT_EQUAL_UINT8(2, Config::GetWiFiCredentialsIDbySSID("Guest")); +} + +TEST_CASE("Whole-section setters round trip through the store", "[config][roundtrip]") +{ + const Config::RootConfig sample = MakeSampleConfig(); + InitDefault(); + + TEST_ASSERT_TRUE(Config::SetRFConfig(sample.rf)); + TEST_ASSERT_TRUE(Config::SetWiFiConfig(sample.wifi)); + TEST_ASSERT_TRUE(Config::SetCaptivePortalConfig(sample.captivePortal)); + TEST_ASSERT_TRUE(Config::SetBackendConfig(sample.backend)); + TEST_ASSERT_TRUE(Config::SetSerialInputConfig(sample.serialInput)); + TEST_ASSERT_TRUE(Config::SetOtaUpdateConfig(sample.otaUpdate)); + TEST_ASSERT_TRUE(Config::SetEStopConfig(sample.estop)); + AssertConfigEqual(sample, Snapshot()); + + TEST_ASSERT_TRUE(InitFrom(StoredFile())); + AssertConfigEqual(sample, Snapshot()); +} + +TEST_CASE("GetAsFlatBuffer and SaveFromFlatBuffer round trip", "[config][roundtrip]") +{ + const Config::RootConfig sample = MakeSampleConfig(); + TEST_ASSERT_TRUE(InitFrom(Serialize(sample))); + + flatbuffers::FlatBufferBuilder builder; + Serialization::Configuration::FinishHubConfigBuffer(builder, Config::GetAsFlatBuffer(builder, true)); + std::vector exported(builder.GetBufferPointer(), builder.GetBufferPointer() + builder.GetSize()); + + InitDefault(); + TEST_ASSERT_TRUE(Config::SaveFromFlatBuffer(Serialization::Configuration::GetHubConfig(exported.data()))); + AssertConfigEqual(sample, Snapshot()); + + TEST_ASSERT_TRUE(InitFrom(StoredFile())); + AssertConfigEqual(sample, Snapshot()); +} + +TEST_CASE("SetRaw stores a valid buffer verbatim and GetRaw returns it", "[config][roundtrip]") +{ + const std::vector bytes = Serialize(MakeSampleConfig()); + InitDefault(); + + TEST_ASSERT_TRUE(Config::SetRaw(bytes.data(), bytes.size())); + TEST_ASSERT_EQUAL_size_t(bytes.size(), StoredFile().size()); + TEST_ASSERT_EQUAL_UINT8_ARRAY(bytes.data(), StoredFile().data(), bytes.size()); + + TinyVec raw; + TEST_ASSERT_TRUE(Config::GetRaw(raw)); + TEST_ASSERT_EQUAL_size_t(bytes.size(), raw.size()); + TEST_ASSERT_EQUAL_UINT8_ARRAY(bytes.data(), raw.data(), bytes.size()); + + // SetRaw takes effect immediately and survives a restart. + AssertConfigEqual(MakeSampleConfig(), Snapshot()); + TEST_ASSERT_TRUE(InitFrom(StoredFile())); + AssertConfigEqual(MakeSampleConfig(), Snapshot()); +} + +TEST_CASE("A setter after SetRaw keeps the imported config", "[config][roundtrip]") +{ + const std::vector bytes = Serialize(MakeSampleConfig()); + InitDefault(); + + TEST_ASSERT_TRUE(Config::SetRaw(bytes.data(), bytes.size())); + TEST_ASSERT_TRUE(Config::SetSerialInputConfigEchoEnabled(!MakeSampleConfig().serialInput.echoEnabled)); + + Config::RootConfig expected = MakeSampleConfig(); + expected.serialInput.echoEnabled = !expected.serialInput.echoEnabled; + TEST_ASSERT_TRUE(InitFrom(StoredFile())); + AssertConfigEqual(expected, Snapshot()); +} + +TEST_CASE("Many max-length WiFi credentials survive a reload", "[config][roundtrip]") +{ + InitDefault(); + for (int i = 0; i < 16; ++i) { + std::string ssid(32, static_cast('A' + i)); // 802.11 SSID maximum + std::string password(63, static_cast('a' + i)); // WPA2 passphrase maximum + TEST_ASSERT_EQUAL_UINT8(i + 1, Config::AddWiFiCredentials(ssid, password, WIFI_AUTH_WPA2_PSK)); + } + const Config::RootConfig before = Snapshot(); + + TEST_ASSERT_TRUE_MESSAGE(InitFrom(StoredFile()), "Init fell back to defaults"); + AssertConfigEqual(before, Snapshot()); +} + +TEST_CASE("A stored config is never too large to load again", "[config][roundtrip]") +{ + // AddWiFiCredentials hands out up to 255 IDs, but the config partition holds at + // most Config::MaxConfigSize bytes: once another network would not fit, adding + // is refused instead of storing a file that cannot be loaded again. + InitDefault(); + int added = 0; + for (int i = 0; i < 40; ++i) { + std::string ssid = "Network-" + std::to_string(i) + std::string(20, 'x'); + const Config::RootConfig beforeAdd = Snapshot(); + const std::vector fileBefore = StoredFile(); + const int writesBefore = HostConfigFs::writeCount; + + if (Config::AddWiFiCredentials(ssid, std::string(63, 'p'), WIFI_AUTH_WPA2_PSK) == 0) { + // Refused: nothing changed, in memory or on flash. + AssertConfigEqual(beforeAdd, Snapshot()); + TEST_ASSERT_EQUAL_INT(writesBefore, HostConfigFs::writeCount); + TEST_ASSERT_TRUE(fileBefore == StoredFile()); + break; + } + ++added; + } + TEST_ASSERT_TRUE_MESSAGE(added >= 16, "far fewer credentials fit than expected"); + TEST_ASSERT_TRUE_MESSAGE(added < 40, "40 such credentials cannot fit in MaxConfigSize"); + TEST_ASSERT_LESS_OR_EQUAL_size_t(Config::MaxConfigSize, StoredFile().size()); + + const Config::RootConfig before = Snapshot(); + TEST_ASSERT_EQUAL_size_t(added, before.wifi.credentialsList.size()); + TEST_ASSERT_TRUE_MESSAGE(InitFrom(StoredFile()), "Init fell back to defaults"); + AssertConfigEqual(before, Snapshot()); +} + +namespace { + // A valid config whose serialized form is exactly `size` bytes (padded through the hostname). + std::vector SerializedOfSize(std::size_t size, Config::RootConfig& config) + { + config = MakeSampleConfig(); + for (std::size_t len = 0; len < size; ++len) { + config.wifi.hostname.assign(len, 'h'); + std::vector bytes = Serialize(config); + if (bytes.size() == size) { + return bytes; + } + } + TEST_FAIL_MESSAGE("no hostname length gives that size"); + return {}; + } +} // namespace + +TEST_CASE("A config of exactly MaxConfigSize bytes loads", "[config][roundtrip]") +{ + Config::RootConfig sample; + const std::vector bytes = SerializedOfSize(Config::MaxConfigSize, sample); + + TEST_ASSERT_TRUE_MESSAGE(InitFrom(bytes), "Init fell back to defaults"); + AssertConfigEqual(sample, Snapshot()); + + InitDefault(); + TEST_ASSERT_TRUE(Config::SetRaw(bytes.data(), bytes.size())); + TEST_ASSERT_TRUE(InitFrom(StoredFile())); + AssertConfigEqual(sample, Snapshot()); +} + +TEST_CASE("A config larger than MaxConfigSize is rejected, not aborted on", "[config][roundtrip]") +{ + // A well-formed flatbuffer, just too large: before the fix the Verifier + // constructor asserted (size < max_size) and aborted the hub on every boot. + Config::RootConfig sample; + const std::vector bytes = SerializedOfSize(Config::MaxConfigSize + 4, sample); + + InitDefault(); + TEST_ASSERT_FALSE(Config::SetRaw(bytes.data(), bytes.size())); + TEST_ASSERT_EQUAL_INT(0, HostConfigFs::writeCount); + + TEST_ASSERT_FALSE(InitFrom(bytes)); // fell back to defaults and rewrote the file + AssertConfigEqual(Config::RootConfig(), Snapshot()); + TEST_ASSERT_TRUE(InitFrom(StoredFile())); + + // Setters cannot store an oversized config either, and keep the old file. + InitDefault(); + const std::vector before = StoredFile(); + Config::WiFiConfig wifi = sample.wifi; + wifi.hostname.append(Config::MaxConfigSize, 'h'); + TEST_ASSERT_FALSE(Config::SetWiFiConfig(wifi)); + TEST_ASSERT_TRUE(before == StoredFile()); +} + +TEST_CASE("jsonconfig get/set round trip keeps every section", "[config][roundtrip][json]") +{ + Config::RootConfig sample = MakeSampleConfig(); + // Strings that need JSON escaping are covered separately below. + sample.wifi.credentialsList[2] = Config::WiFiCredentials(7, "Office", "s3cret!", WIFI_AUTH_WPA3_PSK); + TEST_ASSERT_TRUE(InitFrom(Serialize(sample))); + const std::string json = Config::GetAsJSON(true); + TEST_ASSERT_FALSE(json.empty()); + + InitDefault(); + TEST_ASSERT_TRUE(Config::SaveFromJSON(json)); + AssertConfigEqual(sample, Snapshot()); + + // SaveFromJSON persists: the stored file reloads to the same config. + TEST_ASSERT_TRUE(InitFrom(StoredFile())); + AssertConfigEqual(sample, Snapshot()); + + // And exporting again yields identical JSON. + TEST_ASSERT_EQUAL_STRING(json.c_str(), Config::GetAsJSON(true).c_str()); +} + +TEST_CASE("jsonconfig round trip keeps strings that need escaping", "[config][roundtrip][json]") +{ + Config::RootConfig sample = MakeSampleConfig(); + TEST_ASSERT_TRUE(InitFrom(Serialize(sample))); + const std::string json = Config::GetAsJSON(true); + + InitDefault(); + TEST_ASSERT_TRUE(Config::SaveFromJSON(json)); + + // Escaped exactly once on the way out (as the cJSON firmware did), decoded on the way in. + TEST_ASSERT_NOT_EQUAL(std::string::npos, json.find(R"("Office \"5G\"")")); + TEST_ASSERT_NOT_EQUAL(std::string::npos, json.find(R"("p@ss w0rd/\\")")); + + Config::WiFiCredentials office; + TEST_ASSERT_TRUE(Config::TryGetWiFiCredentialsByID(7, office)); + TEST_ASSERT_EQUAL_STRING("Office \"5G\"", office.ssid.c_str()); + TEST_ASSERT_EQUAL_STRING("p@ss w0rd/\\", office.password.c_str()); + + // Repeated get/set cycles are stable. + TEST_ASSERT_TRUE(Config::SaveFromJSON(Config::GetAsJSON(true))); + TEST_ASSERT_EQUAL_STRING(json.c_str(), Config::GetAsJSON(true).c_str()); +} + +TEST_CASE("jsonconfig set decodes \\u escapes", "[config][roundtrip][json]") +{ + InitDefault(); + TEST_ASSERT_TRUE(Config::SaveFromJSON(R"({"wifi":{"hostname":"hub\u002d1","credentials":[{"id":1,"ssid":"Caf\u00e9 \ud83d\ude00","password":"a\tb"}]}})")); + + Config::WiFiConfig wifi; + TEST_ASSERT_TRUE(Config::GetWiFiConfig(wifi)); + TEST_ASSERT_EQUAL_STRING("hub-1", wifi.hostname.c_str()); + TEST_ASSERT_EQUAL_size_t(1, wifi.credentialsList.size()); + TEST_ASSERT_EQUAL_STRING("Caf\xc3\xa9 \xf0\x9f\x98\x80", wifi.credentialsList[0].ssid.c_str()); + TEST_ASSERT_EQUAL_STRING("a\tb", wifi.credentialsList[0].password.c_str()); +} + +TEST_CASE("GetAsJSON without sensitive data omits secrets", "[config][roundtrip][json]") +{ + TEST_ASSERT_TRUE(InitFrom(Serialize(MakeSampleConfig()))); + + const std::string full = Config::GetAsJSON(true); + const std::string redacted = Config::GetAsJSON(false); + + TEST_ASSERT_NOT_EQUAL(std::string::npos, full.find("hunter22")); + TEST_ASSERT_NOT_EQUAL(std::string::npos, full.find("tok_0123456789abcdef")); + TEST_ASSERT_EQUAL(std::string::npos, redacted.find("hunter22")); + TEST_ASSERT_EQUAL(std::string::npos, redacted.find("password")); + TEST_ASSERT_EQUAL(std::string::npos, redacted.find("authToken")); + TEST_ASSERT_NOT_EQUAL(std::string::npos, redacted.find("\"HomeNet\"")); + TEST_ASSERT_NOT_EQUAL(std::string::npos, redacted.find("\"api.example.org\"")); +} diff --git a/components/config/host_test/sdkconfig.defaults b/components/config/host_test/sdkconfig.defaults new file mode 100644 index 00000000..60da2c64 --- /dev/null +++ b/components/config/host_test/sdkconfig.defaults @@ -0,0 +1,22 @@ +CONFIG_IDF_TARGET="linux" +CONFIG_IDF_TARGET_LINUX=y +CONFIG_COMPILER_CXX_EXCEPTIONS=y +CONFIG_UNITY_ENABLE_IDF_TEST_RUNNER=y + +# Same tick rate as the firmware (sdkconfig.defaults at the repo root). +CONFIG_FREERTOS_HZ=1000 + +# The firmware's own partition table (every board uses it), so Config::Init's +# esp_partition_find_first() looks up the real "config" partition. ESP-IDF's linux +# esp_partition emulates the flash in a file. +CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y +CONFIG_PARTITION_TABLE_CUSTOM=y +CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="../../../partitions/ota_4mb.csv" + +# app_main runs the whole suite on the main task. The 3584-byte default is sized for +# the target; x86-64 frames (flatbuffers builders, std::string, Unity) need more. +# Keep it modest so unbounded recursion still overflows here rather than hiding. +CONFIG_ESP_MAIN_TASK_STACK_SIZE=32768 + +# Match the firmware's sdkconfig.defaults +CONFIG_JSMN_STRICT=y diff --git a/include/config/BackendConfig.h b/components/config/include/config/BackendConfig.h similarity index 82% rename from include/config/BackendConfig.h rename to components/config/include/config/BackendConfig.h index 629e90ba..0b312985 100644 --- a/include/config/BackendConfig.h +++ b/components/config/include/config/BackendConfig.h @@ -18,7 +18,7 @@ namespace OpenShock::Config { bool FromFlatbuffers(const Serialization::Configuration::BackendConfig* config) override; [[nodiscard]] flatbuffers::Offset ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const override; - bool FromJSON(const cJSON* json) override; - [[nodiscard]] cJSON* ToJSON(bool withSensitiveData) const override; + bool FromJSON(JSON::JsonView json) override; + void ToJSON(json_gen_str_t* gen, const char* name, bool withSensitiveData) const override; }; } // namespace OpenShock::Config diff --git a/include/config/CaptivePortalConfig.h b/components/config/include/config/CaptivePortalConfig.h similarity index 81% rename from include/config/CaptivePortalConfig.h rename to components/config/include/config/CaptivePortalConfig.h index 359321ca..75105c56 100644 --- a/include/config/CaptivePortalConfig.h +++ b/components/config/include/config/CaptivePortalConfig.h @@ -14,7 +14,7 @@ namespace OpenShock::Config { bool FromFlatbuffers(const Serialization::Configuration::CaptivePortalConfig* config) override; [[nodiscard]] flatbuffers::Offset ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const override; - bool FromJSON(const cJSON* json) override; - [[nodiscard]] cJSON* ToJSON(bool withSensitiveData) const override; + bool FromJSON(JSON::JsonView json) override; + void ToJSON(json_gen_str_t* gen, const char* name, bool withSensitiveData) const override; }; } // namespace OpenShock::Config diff --git a/include/config/Config.h b/components/config/include/config/Config.h similarity index 83% rename from include/config/Config.h rename to components/config/include/config/Config.h index 3f70db73..4b09616f 100644 --- a/include/config/Config.h +++ b/components/config/include/config/Config.h @@ -12,11 +12,17 @@ #include +#include #include #include #include namespace OpenShock::Config { + /* Largest config file that can be stored and loaded back. The "config" partition (partitions/ota_4mb.csv) is 12 KiB of littlefs with + * 4 KiB blocks; two blocks hold the metadata pair, leaving a single block for file data. Saves of a larger config are refused, and a + * larger file or rawconfig buffer is rejected without being loaded. */ + inline constexpr std::size_t MaxConfigSize = 4096; + void Init(); /* GetAsJSON and SaveFromJSON are used for Reading/Writing the config file in its human-readable form. */ @@ -44,7 +50,7 @@ namespace OpenShock::Config { bool GetBackendConfig(BackendConfig& out); bool GetSerialInputConfig(SerialInputConfig& out); bool GetOtaUpdateConfig(OtaUpdateConfig& out); - bool GetEStop(EStopConfig& out); + bool GetEStopConfig(EStopConfig& out); bool SetRFConfig(const RFConfig& config); bool SetWiFiConfig(const WiFiConfig& config); @@ -52,10 +58,12 @@ namespace OpenShock::Config { bool SetBackendConfig(const BackendConfig& config); bool SetSerialInputConfig(const SerialInputConfig& config); bool SetOtaUpdateConfig(const OtaUpdateConfig& config); - bool SetEStop(const EStopConfig& config); + bool SetEStopConfig(const EStopConfig& config); bool GetWiFiCredentials(std::vector& out); - bool GetWiFiCredentials(cJSON* array, bool withSensitiveData); + // Emits each stored credential as an object into an already-open JSON array + // (the caller opens/closes it with json_gen_{start,end}_array). + bool GetWiFiCredentials(json_gen_str_t* gen, bool withSensitiveData); bool SetWiFiCredentials(const std::vector& credentials); bool GetRFConfigTxPin(gpio_num_t& out); diff --git a/include/config/ConfigBase.h b/components/config/include/config/ConfigBase.h similarity index 53% rename from include/config/ConfigBase.h rename to components/config/include/config/ConfigBase.h index 3bd7abc7..3b43e0f5 100644 --- a/include/config/ConfigBase.h +++ b/components/config/include/config/ConfigBase.h @@ -2,7 +2,9 @@ #include "serialization/_fbs/HubConfig_generated.h" -#include +#include "json/Json.h" + +#include namespace OpenShock::Config { template @@ -12,8 +14,11 @@ namespace OpenShock::Config { virtual bool FromFlatbuffers(const T* config) = 0; [[nodiscard]] virtual flatbuffers::Offset ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const = 0; - virtual bool FromJSON(const cJSON* json) = 0; - [[nodiscard]] virtual cJSON* ToJSON(bool withSensitiveData) const = 0; + virtual bool FromJSON(JSON::JsonView json) = 0; + // Emits this config as its own JSON object. When `name` is non-null the object + // is added as a named member of the enclosing object; when null it is anonymous + // (document root or array element). See JSON::objBegin/objEnd. + virtual void ToJSON(json_gen_str_t* gen, const char* name, bool withSensitiveData) const = 0; }; } // namespace OpenShock::Config diff --git a/include/config/EStopConfig.h b/components/config/include/config/EStopConfig.h similarity index 61% rename from include/config/EStopConfig.h rename to components/config/include/config/EStopConfig.h index de726734..7bec4f1c 100644 --- a/include/config/EStopConfig.h +++ b/components/config/include/config/EStopConfig.h @@ -5,6 +5,7 @@ #include namespace OpenShock::Config { + // The schema's `active` and `latching` fields are not modelled: nothing reads them, so they are dropped on save. struct EStopConfig : public ConfigBase { EStopConfig(); EStopConfig(bool enabled, gpio_num_t gpioPin); @@ -14,10 +15,13 @@ namespace OpenShock::Config { void ToDefault() override; + /// @brief Disables the E-Stop when its pin can't be used as an input, so every input path stores the same thing. + void Normalize(); + bool FromFlatbuffers(const Serialization::Configuration::EStopConfig* config) override; flatbuffers::Offset ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const override; - bool FromJSON(const cJSON* json) override; - cJSON* ToJSON(bool withSensitiveData) const override; + bool FromJSON(JSON::JsonView json) override; + void ToJSON(json_gen_str_t* gen, const char* name, bool withSensitiveData) const override; }; } // namespace OpenShock::Config diff --git a/include/config/OtaUpdateConfig.h b/components/config/include/config/OtaUpdateConfig.h similarity index 81% rename from include/config/OtaUpdateConfig.h rename to components/config/include/config/OtaUpdateConfig.h index 26fb5f87..d81241b6 100644 --- a/include/config/OtaUpdateConfig.h +++ b/components/config/include/config/OtaUpdateConfig.h @@ -1,9 +1,9 @@ #pragma once #include "config/ConfigBase.h" -#include "FirmwareBootType.h" -#include "OtaUpdateChannel.h" -#include "OtaUpdateStep.h" +#include "enums/FirmwareBootType.h" +#include "enums/OtaUpdateChannel.h" +#include "enums/OtaUpdateStep.h" #include @@ -30,7 +30,7 @@ namespace OpenShock::Config { bool FromFlatbuffers(const Serialization::Configuration::OtaUpdateConfig* config) override; [[nodiscard]] flatbuffers::Offset ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const override; - bool FromJSON(const cJSON* json) override; - [[nodiscard]] cJSON* ToJSON(bool withSensitiveData) const override; + bool FromJSON(JSON::JsonView json) override; + void ToJSON(json_gen_str_t* gen, const char* name, bool withSensitiveData) const override; }; } // namespace OpenShock::Config diff --git a/include/config/RFConfig.h b/components/config/include/config/RFConfig.h similarity index 81% rename from include/config/RFConfig.h rename to components/config/include/config/RFConfig.h index cf766e69..49cf370d 100644 --- a/include/config/RFConfig.h +++ b/components/config/include/config/RFConfig.h @@ -17,7 +17,7 @@ namespace OpenShock::Config { bool FromFlatbuffers(const Serialization::Configuration::RFConfig* config) override; [[nodiscard]] flatbuffers::Offset ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const override; - bool FromJSON(const cJSON* json) override; - [[nodiscard]] cJSON* ToJSON(bool withSensitiveData) const override; + bool FromJSON(JSON::JsonView json) override; + void ToJSON(json_gen_str_t* gen, const char* name, bool withSensitiveData) const override; }; } // namespace OpenShock::Config diff --git a/include/config/RootConfig.h b/components/config/include/config/RootConfig.h similarity index 88% rename from include/config/RootConfig.h rename to components/config/include/config/RootConfig.h index d182a66c..946f3078 100644 --- a/include/config/RootConfig.h +++ b/components/config/include/config/RootConfig.h @@ -26,7 +26,7 @@ namespace OpenShock::Config { bool FromFlatbuffers(const Serialization::Configuration::HubConfig* config) override; [[nodiscard]] flatbuffers::Offset ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const override; - bool FromJSON(const cJSON* json) override; - [[nodiscard]] cJSON* ToJSON(bool withSensitiveData) const override; + bool FromJSON(JSON::JsonView json) override; + void ToJSON(json_gen_str_t* gen, const char* name, bool withSensitiveData) const override; }; } // namespace OpenShock::Config diff --git a/include/config/SerialInputConfig.h b/components/config/include/config/SerialInputConfig.h similarity index 81% rename from include/config/SerialInputConfig.h rename to components/config/include/config/SerialInputConfig.h index a9ac7746..8742aa8d 100644 --- a/include/config/SerialInputConfig.h +++ b/components/config/include/config/SerialInputConfig.h @@ -14,7 +14,7 @@ namespace OpenShock::Config { bool FromFlatbuffers(const Serialization::Configuration::SerialInputConfig* config) override; [[nodiscard]] flatbuffers::Offset ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const override; - bool FromJSON(const cJSON* json) override; - [[nodiscard]] cJSON* ToJSON(bool withSensitiveData) const override; + bool FromJSON(JSON::JsonView json) override; + void ToJSON(json_gen_str_t* gen, const char* name, bool withSensitiveData) const override; }; } // namespace OpenShock::Config diff --git a/components/config/include/config/WiFiAuthMode.h b/components/config/include/config/WiFiAuthMode.h new file mode 100644 index 00000000..cb255b80 --- /dev/null +++ b/components/config/include/config/WiFiAuthMode.h @@ -0,0 +1,63 @@ +#pragma once + +#include "serialization/_fbs/WifiAuthMode_generated.h" + +#include + +namespace OpenShock::Config { + // The one mapping between ESP-IDF's wifi_auth_mode_t and the FlatBuffers WifiAuthMode used by the stored config + // and the local (captive portal) protocol. Modes without a FlatBuffers counterpart map to UNKNOWN / WIFI_AUTH_MAX. + constexpr Serialization::Types::WifiAuthMode ToFbsAuthMode(wifi_auth_mode_t mode) + { + using FbsAuthMode = Serialization::Types::WifiAuthMode; + switch (mode) { + case WIFI_AUTH_OPEN: + return FbsAuthMode::Open; + case WIFI_AUTH_WEP: + return FbsAuthMode::WEP; + case WIFI_AUTH_WPA_PSK: + return FbsAuthMode::WPA_PSK; + case WIFI_AUTH_WPA2_PSK: + return FbsAuthMode::WPA2_PSK; + case WIFI_AUTH_WPA_WPA2_PSK: + return FbsAuthMode::WPA_WPA2_PSK; + case WIFI_AUTH_WPA2_ENTERPRISE: + return FbsAuthMode::WPA2_ENTERPRISE; + case WIFI_AUTH_WPA3_PSK: + return FbsAuthMode::WPA3_PSK; + case WIFI_AUTH_WPA2_WPA3_PSK: + return FbsAuthMode::WPA2_WPA3_PSK; + case WIFI_AUTH_WAPI_PSK: + return FbsAuthMode::WAPI_PSK; + default: + return FbsAuthMode::UNKNOWN; + } + } + + constexpr wifi_auth_mode_t FromFbsAuthMode(Serialization::Types::WifiAuthMode mode) + { + using FbsAuthMode = Serialization::Types::WifiAuthMode; + switch (mode) { + case FbsAuthMode::Open: + return WIFI_AUTH_OPEN; + case FbsAuthMode::WEP: + return WIFI_AUTH_WEP; + case FbsAuthMode::WPA_PSK: + return WIFI_AUTH_WPA_PSK; + case FbsAuthMode::WPA2_PSK: + return WIFI_AUTH_WPA2_PSK; + case FbsAuthMode::WPA_WPA2_PSK: + return WIFI_AUTH_WPA_WPA2_PSK; + case FbsAuthMode::WPA2_ENTERPRISE: + return WIFI_AUTH_WPA2_ENTERPRISE; + case FbsAuthMode::WPA3_PSK: + return WIFI_AUTH_WPA3_PSK; + case FbsAuthMode::WPA2_WPA3_PSK: + return WIFI_AUTH_WPA2_WPA3_PSK; + case FbsAuthMode::WAPI_PSK: + return WIFI_AUTH_WAPI_PSK; + default: + return WIFI_AUTH_MAX; + } + } +} // namespace OpenShock::Config diff --git a/include/config/WiFiConfig.h b/components/config/include/config/WiFiConfig.h similarity index 85% rename from include/config/WiFiConfig.h rename to components/config/include/config/WiFiConfig.h index f3671608..ca16b5b7 100644 --- a/include/config/WiFiConfig.h +++ b/components/config/include/config/WiFiConfig.h @@ -21,7 +21,7 @@ namespace OpenShock::Config { bool FromFlatbuffers(const Serialization::Configuration::WiFiConfig* config) override; [[nodiscard]] flatbuffers::Offset ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const override; - bool FromJSON(const cJSON* json) override; - [[nodiscard]] cJSON* ToJSON(bool withSensitiveData) const override; + bool FromJSON(JSON::JsonView json) override; + void ToJSON(json_gen_str_t* gen, const char* name, bool withSensitiveData) const override; }; } // namespace OpenShock::Config diff --git a/include/config/WiFiCredentials.h b/components/config/include/config/WiFiCredentials.h similarity index 72% rename from include/config/WiFiCredentials.h rename to components/config/include/config/WiFiCredentials.h index cc545a1d..b79b64e9 100644 --- a/include/config/WiFiCredentials.h +++ b/components/config/include/config/WiFiCredentials.h @@ -2,8 +2,8 @@ #include "config/ConfigBase.h" +#include #include -#include #include #include @@ -18,8 +18,8 @@ namespace OpenShock::Config { uint8_t id; std::string ssid; std::string password; - wifi_auth_mode_t authMode; // Auth mode when saved, WIFI_AUTH_MAX = unknown - std::array bssid; // Pinned BSSID, all zeros = no pin + wifi_auth_mode_t authMode; // Auth mode when saved, WIFI_AUTH_MAX = unknown + std::array bssid; // Pinned BSSID, all zeros = no pin bool HasPinnedBSSID() const; @@ -28,7 +28,7 @@ namespace OpenShock::Config { bool FromFlatbuffers(const Serialization::Configuration::WiFiCredentials* config) override; [[nodiscard]] flatbuffers::Offset ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const override; - bool FromJSON(const cJSON* json) override; - [[nodiscard]] cJSON* ToJSON(bool withSensitiveData) const override; + bool FromJSON(JSON::JsonView json) override; + void ToJSON(json_gen_str_t* gen, const char* name, bool withSensitiveData) const override; }; } // namespace OpenShock::Config diff --git a/components/config/include/config/internal/utils.h b/components/config/include/config/internal/utils.h new file mode 100644 index 00000000..a8aff4dd --- /dev/null +++ b/components/config/include/config/internal/utils.h @@ -0,0 +1,73 @@ +#pragma once + +#include "config/ConfigBase.h" + +#include "json/Json.h" + +#include + +#include +#include + +namespace OpenShock::Config::Internal::Utils { + // Accepts GPIO_NUM_NC (-1, "no pin") or any GPIO number valid on this chip; used for every stored/imported pin. + bool FromIntGpioNum(gpio_num_t& val, int32_t intVal); + + void FromFbsStr(std::string& str, const flatbuffers::String* fbsStr, const char* defaultStr); + + bool FromJsonGpioNum(gpio_num_t& val, JSON::JsonView json, std::string_view name); + + template // T inherits from ConfigBase + void FromFbsVec(std::vector& vec, const flatbuffers::Vector>* fbsVec) + { + vec.clear(); + + if (fbsVec == nullptr) { + return; + } + + for (auto fbsItem : *fbsVec) { + T item; + if (item.FromFlatbuffers(fbsItem)) { + vec.push_back(std::move(item)); + } + } + } + template // T inherits from ConfigBase + bool FromJsonStrParsed(T& val, JSON::JsonView json, std::string_view name, bool (*StringParser)(T&, std::string_view), T defaultVal) + { + JSON::JsonView jsonVal = json[name]; + if (!jsonVal.valid()) { + val = defaultVal; + return true; + } + + // Wrong type or unknown value: fall back to the default like every other field, rather than keeping + // whatever value `val` held before (the current running config when importing JSON). + std::string str; + if (!jsonVal.tryGetStr(str) || !StringParser(val, str)) { + val = defaultVal; + return false; + } + + return true; + } + template // T inherits from ConfigBase + bool FromJsonArray(std::vector& vec, JSON::JsonView jsonArray) + { + vec.clear(); + if (!jsonArray.isArray()) { + return true; + } + + const int count = jsonArray.count(); + for (int i = 0; i < count; ++i) { + T item; + if (item.FromJSON(jsonArray.at(i))) { + vec.push_back(std::move(item)); + } + } + + return true; + } +} // namespace OpenShock::Config::Internal::Utils diff --git a/src/config/BackendConfig.cpp b/components/config/src/BackendConfig.cpp similarity index 68% rename from src/config/BackendConfig.cpp rename to components/config/src/BackendConfig.cpp index ac49a2e2..9596740a 100644 --- a/src/config/BackendConfig.cpp +++ b/components/config/src/BackendConfig.cpp @@ -8,7 +8,7 @@ const char* const TAG = "Config::BackendConfig"; using namespace OpenShock::Config; BackendConfig::BackendConfig() - : domain(OPENSHOCK_API_DOMAIN) + : domain(CONFIG_OPENSHOCK_API_DOMAIN) , authToken() { } @@ -21,7 +21,7 @@ BackendConfig::BackendConfig(std::string_view domain, std::string_view authToken void BackendConfig::ToDefault() { - domain = OPENSHOCK_API_DOMAIN; + domain = CONFIG_OPENSHOCK_API_DOMAIN; authToken.clear(); } @@ -33,7 +33,7 @@ bool BackendConfig::FromFlatbuffers(const Serialization::Configuration::BackendC return true; } - Internal::Utils::FromFbsStr(domain, config->domain(), OPENSHOCK_API_DOMAIN); + Internal::Utils::FromFbsStr(domain, config->domain(), CONFIG_OPENSHOCK_API_DOMAIN); Internal::Utils::FromFbsStr(authToken, config->auth_token(), ""); return true; @@ -53,34 +53,33 @@ flatbuffers::Offset Back return Serialization::Configuration::CreateBackendConfig(builder, domainOffset, authTokenOffset); } -bool BackendConfig::FromJSON(const cJSON* json) +bool BackendConfig::FromJSON(JSON::JsonView json) { - if (json == nullptr) { + if (!json.valid()) { OS_LOGW(TAG, "Config is null, setting to default"); ToDefault(); return true; } - if (cJSON_IsObject(json) == 0) { + if (!json.isObject()) { OS_LOGE(TAG, "json is not an object"); return false; } - Internal::Utils::FromJsonStr(domain, json, "domain", OPENSHOCK_API_DOMAIN); - Internal::Utils::FromJsonStr(authToken, json, "authToken", ""); + if (!json["domain"].tryGetStr(domain)) domain = CONFIG_OPENSHOCK_API_DOMAIN; + + if (!json["authToken"].tryGetStr(authToken)) authToken.clear(); return true; } -cJSON* BackendConfig::ToJSON(bool withSensitiveData) const +void BackendConfig::ToJSON(json_gen_str_t* gen, const char* name, bool withSensitiveData) const { - cJSON* root = cJSON_CreateObject(); - - cJSON_AddStringToObject(root, "domain", domain.c_str()); + JSON::objBegin(gen, name); + JSON::objSetString(gen, "domain", domain); if (withSensitiveData) { - cJSON_AddStringToObject(root, "authToken", authToken.c_str()); + JSON::objSetString(gen, "authToken", authToken); } - - return root; + JSON::objEnd(gen, name); } diff --git a/src/config/CaptivePortalConfig.cpp b/components/config/src/CaptivePortalConfig.cpp similarity index 74% rename from src/config/CaptivePortalConfig.cpp rename to components/config/src/CaptivePortalConfig.cpp index eefe1730..e31f8a23 100644 --- a/src/config/CaptivePortalConfig.cpp +++ b/components/config/src/CaptivePortalConfig.cpp @@ -40,29 +40,27 @@ flatbuffers::Offset -#include +#include "json/Json.h" -#include +#include "fs/ConfigFs.h" +#include #include +#include +#include +#include +#include using namespace OpenShock; -static fs::LittleFSFS _configFS; +// littlefs partition holding the persisted config blob. The Arduino LittleFSFS +// stored it as the file "config" at the root of the partition labelled "config" +// (VFS "/config/config" resolved to lfs path "/config"); the raw-lfs ConfigFs opens +// that same path so existing devices keep loading their config after the migration. +static constexpr const char* CONFIG_PARTITION_LABEL = "config"; +static constexpr const char* CONFIG_FILE_PATH = "/config"; + +static OpenShock::ConfigFs _configFs; static Config::RootConfig _configData; static ReadWriteMutex _configMutex; @@ -44,24 +56,17 @@ static ReadWriteMutex _configMutex; static bool tryDeserializeConfig(const uint8_t* buffer, std::size_t bufferLen, OpenShock::Config::RootConfig& config) { - if (buffer == nullptr || bufferLen < sizeof(flatbuffers::uoffset_t)) { - OS_LOGE(TAG, "Buffer is null or too small"); + if (buffer == nullptr) { + OS_LOGE(TAG, "Buffer is null"); return false; } - // Validate buffer before accessing - flatbuffers::Verifier::Options verifierOptions { - .max_size = 4096, // Should be enough - }; - flatbuffers::Verifier verifier(buffer, bufferLen, verifierOptions); - if (!verifier.VerifyBuffer()) { - OS_LOGE(TAG, "Failed to verify config file integrity"); + auto fbsConfig = Serialization::GetVerifiedRoot(std::span(buffer, bufferLen), Config::MaxConfigSize); + if (fbsConfig == nullptr) { + OS_LOGE(TAG, "Config failed verification (%zu bytes, max %zu)", bufferLen, Config::MaxConfigSize); return false; } - // Deserialize (safe after verification) - auto fbsConfig = flatbuffers::GetRoot(buffer); - // Read config if (!config.FromFlatbuffers(fbsConfig)) { OS_LOGE(TAG, "Failed to read config file"); @@ -70,73 +75,73 @@ static bool tryDeserializeConfig(const uint8_t* buffer, std::size_t bufferLen, O return true; } -static bool tryLoadConfig(TinyVec& buffer) -{ - File file = _configFS.open("/config", "rb"); - if (!file) { - OS_LOGE(TAG, "Failed to open config file for reading"); - return false; - } - - // Get file size - std::size_t size = file.size(); - - // Resize buffer - buffer.resize(size); - - // Read file - if (file.read(buffer.data(), buffer.size()) != buffer.size()) { - OS_LOGE(TAG, "Failed to read config file, size mismatch"); - return false; - } - - file.close(); - - return true; -} static bool tryLoadConfig() { - TinyVec buffer; - if (!tryLoadConfig(buffer)) { + std::vector data; + if (!_configFs.read(CONFIG_FILE_PATH, data)) { + OS_LOGE(TAG, "Failed to read config file"); return false; } - return tryDeserializeConfig(buffer.data(), buffer.size(), _configData); + return tryDeserializeConfig(data.data(), data.size(), _configData); } static bool trySaveConfig(const uint8_t* data, std::size_t dataLen) { - File file = _configFS.open("/config", "wb"); - if (!file) { - OS_LOGE(TAG, "Failed to open config file for writing"); + if (dataLen > Config::MaxConfigSize) { + OS_LOGE(TAG, "Config is too large to store (%zu bytes, max %zu)", dataLen, Config::MaxConfigSize); return false; } - // Write file - if (file.write(data, dataLen) != dataLen) { + if (!_configFs.write(CONFIG_FILE_PATH, std::span(data, dataLen))) { OS_LOGE(TAG, "Failed to write config file"); return false; } - file.close(); - return true; } -static bool trySaveConfig() +static bool trySaveConfig(const OpenShock::Config::RootConfig& config) { flatbuffers::FlatBufferBuilder builder; - auto fbsConfig = _configData.ToFlatbuffers(builder, true); + auto fbsConfig = config.ToFlatbuffers(builder, true); Serialization::Configuration::FinishHubConfigBuffer(builder, fbsConfig); return trySaveConfig(builder.GetBufferPointer(), builder.GetSize()); } +static bool trySaveConfig() +{ + return trySaveConfig(_configData); +} + +// Requires the write lock. Applies `mutate` to a copy, persists the copy and only then commits it, so a failed +// write leaves the running config identical to what is on flash. `mutate` returns false to abort without saving. +template +static bool mutateAndSave(Fn&& mutate) +{ + OpenShock::Config::RootConfig updated = _configData; + if (!mutate(updated)) { + return false; + } + + if (!trySaveConfig(updated)) { + return false; + } + + _configData = std::move(updated); + return true; +} void Config::Init() { CONFIG_LOCK_WRITE(); - if (!_configFS.begin(true, "/config", 3, "config")) { + const esp_partition_t* partition = esp_partition_find_first(ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_SPIFFS, CONFIG_PARTITION_LABEL); + if (partition == nullptr) { + OS_PANIC(TAG, "Unable to find config partition!"); + } + + if (!_configFs.mount(partition)) { OS_PANIC(TAG, "Unable to mount config LittleFS partition!"); } @@ -153,51 +158,42 @@ void Config::Init() } } -static cJSON* getAsCJSON(bool withSensitiveData) -{ - CONFIG_LOCK_READ(nullptr); - - return _configData.ToJSON(withSensitiveData); -} - std::string Config::GetAsJSON(bool withSensitiveData) { - cJSON* root = getAsCJSON(withSensitiveData); - if (root == nullptr) { - OS_LOGE(TAG, "Failed to get config as JSON"); - return {}; - } + CONFIG_LOCK_READ({}); - char* json = cJSON_PrintUnformatted(root); + JSON::StringWriter writer; + json_gen_str_t* gen = writer.gen(); - std::string result(json); + _configData.ToJSON(gen, nullptr, withSensitiveData); - free(json); - - cJSON_Delete(root); - - return result; + return writer.finish(); } bool Config::SaveFromJSON(std::string_view json) { - cJSON* root = cJSON_ParseWithLength(json.data(), json.size()); - if (root == nullptr) { - OS_LOGE(TAG, "Failed to parse JSON: %s", cJSON_GetErrorPtr()); + JSON::JsonDocument doc; + if (!doc.parse(json)) { + OS_LOGE(TAG, "Failed to parse JSON"); return false; } - CONFIG_LOCK_WRITE_ACTION(false, cJSON_Delete(root)); - - bool result = _configData.FromJSON(root); - - cJSON_Delete(root); + CONFIG_LOCK_WRITE(false); - if (!result) { + // Parse into a copy and commit only once every section parsed and the result is + // stored: RootConfig::FromJSON fills in section by section, so a failure part way + // through would otherwise leave the earlier sections applied. + OpenShock::Config::RootConfig config = _configData; + if (!config.FromJSON(doc.root())) { OS_LOGE(TAG, "Failed to read JSON"); return false; } - return trySaveConfig(); + if (!trySaveConfig(config)) { + return false; + } + + _configData = std::move(config); + return true; } flatbuffers::Offset Config::GetAsFlatBuffer(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) @@ -211,19 +207,35 @@ bool Config::SaveFromFlatBuffer(const Serialization::Configuration::HubConfig* c { CONFIG_LOCK_WRITE(false); - if (!_configData.FromFlatbuffers(config)) { + // Same all-or-nothing handling as SaveFromJSON. + OpenShock::Config::RootConfig parsed = _configData; + if (!parsed.FromFlatbuffers(config)) { OS_LOGE(TAG, "Failed to read config file"); return false; } - return trySaveConfig(); + if (!trySaveConfig(parsed)) { + return false; + } + + _configData = std::move(parsed); + return true; } bool Config::GetRaw(TinyVec& buffer) { CONFIG_LOCK_READ(false); - return tryLoadConfig(buffer); + // Serialize the live config rather than reading flash: no filesystem access under the shared read lock, and it is + // what the device is actually running with. + flatbuffers::FlatBufferBuilder builder; + auto fbsConfig = _configData.ToFlatbuffers(builder, true); + Serialization::Configuration::FinishHubConfigBuffer(builder, fbsConfig); + + buffer.resize(builder.GetSize()); + memcpy(buffer.data(), builder.GetBufferPointer(), builder.GetSize()); + + return true; } bool Config::SetRaw(const uint8_t* buffer, std::size_t size) @@ -236,7 +248,13 @@ bool Config::SetRaw(const uint8_t* buffer, std::size_t size) return false; } - return trySaveConfig(buffer, size); + if (!trySaveConfig(buffer, size)) { + return false; + } + + // Keep memory in sync with flash, or the next setter would write the old config back over the import. + _configData = std::move(config); + return true; } void Config::FactoryReset() @@ -245,7 +263,7 @@ void Config::FactoryReset() _configData.ToDefault(); - if (!_configFS.remove("/config") && _configFS.exists("/config")) { + if (!_configFs.remove(CONFIG_FILE_PATH) && _configFs.exists(CONFIG_FILE_PATH)) { OS_PANIC(TAG, "Failed to remove existing config file for factory reset. Reccomend formatting microcontroller and re-flashing firmware"); } @@ -310,7 +328,7 @@ bool Config::GetOtaUpdateConfig(Config::OtaUpdateConfig& out) return true; } -bool Config::GetEStop(Config::EStopConfig& out) +bool Config::GetEStopConfig(Config::EStopConfig& out) { CONFIG_LOCK_READ(false); @@ -323,56 +341,71 @@ bool Config::SetRFConfig(const Config::RFConfig& config) { CONFIG_LOCK_WRITE(false); - _configData.rf = config; - return trySaveConfig(); + return mutateAndSave([&](Config::RootConfig& root) { + root.rf = config; + return true; + }); } bool Config::SetWiFiConfig(const Config::WiFiConfig& config) { CONFIG_LOCK_WRITE(false); - _configData.wifi = config; - return trySaveConfig(); + return mutateAndSave([&](Config::RootConfig& root) { + root.wifi = config; + return true; + }); } bool Config::SetCaptivePortalConfig(const Config::CaptivePortalConfig& config) { CONFIG_LOCK_WRITE(false); - _configData.captivePortal = config; - return trySaveConfig(); + return mutateAndSave([&](Config::RootConfig& root) { + root.captivePortal = config; + return true; + }); } bool Config::SetBackendConfig(const Config::BackendConfig& config) { CONFIG_LOCK_WRITE(false); - _configData.backend = config; - return trySaveConfig(); + return mutateAndSave([&](Config::RootConfig& root) { + root.backend = config; + return true; + }); } bool Config::SetSerialInputConfig(const Config::SerialInputConfig& config) { CONFIG_LOCK_WRITE(false); - _configData.serialInput = config; - return trySaveConfig(); + return mutateAndSave([&](Config::RootConfig& root) { + root.serialInput = config; + return true; + }); } bool Config::SetOtaUpdateConfig(const Config::OtaUpdateConfig& config) { CONFIG_LOCK_WRITE(false); - _configData.otaUpdate = config; - return trySaveConfig(); + return mutateAndSave([&](Config::RootConfig& root) { + root.otaUpdate = config; + return true; + }); } -bool Config::SetEStop(const Config::EStopConfig& config) +bool Config::SetEStopConfig(const Config::EStopConfig& config) { CONFIG_LOCK_WRITE(false); - _configData.estop = config; - return trySaveConfig(); + return mutateAndSave([&](Config::RootConfig& root) { + root.estop = config; + root.estop.Normalize(); + return true; + }); } bool Config::GetWiFiCredentials(std::vector& out) @@ -384,31 +417,56 @@ bool Config::GetWiFiCredentials(std::vector& out) return true; } -bool Config::GetWiFiCredentials(cJSON* array, bool withSensitiveData) +bool Config::GetWiFiCredentials(json_gen_str_t* gen, bool withSensitiveData) { CONFIG_LOCK_READ(false); for (auto& creds : _configData.wifi.credentialsList) { - cJSON* jsonCreds = creds.ToJSON(withSensitiveData); - - cJSON_AddItemToArray(array, jsonCreds); + creds.ToJSON(gen, nullptr, withSensitiveData); } return true; } -bool Config::SetWiFiCredentials(const std::vector& credentials) +// 802.11 SSIDs are 1-32 bytes; WPA passphrases are up to 63 characters, or 64 for a raw hex PSK. +static bool isValidWiFiCredentials(std::string_view ssid, std::string_view password) { - bool foundZeroId = std::any_of(credentials.begin(), credentials.end(), [](const Config::WiFiCredentials& creds) { return creds.id == 0; }); - if (foundZeroId) { - OS_LOGE(TAG, "Cannot set WiFi credentials: credential ID cannot be 0"); + if (ssid.empty() || ssid.size() > 32) { + OS_LOGE(TAG, "Invalid SSID length %zu", ssid.size()); return false; } + if (password.size() > 64) { + OS_LOGE(TAG, "Invalid password length %zu", password.size()); + return false; + } + return true; +} + +bool Config::SetWiFiCredentials(const std::vector& credentials) +{ + std::bitset<256> seenIds; + for (const auto& creds : credentials) { + if (creds.id == 0) { + OS_LOGE(TAG, "Cannot set WiFi credentials: credential ID cannot be 0"); + return false; + } + if (seenIds[creds.id]) { + OS_LOGE(TAG, "Cannot set WiFi credentials: duplicate credential ID %u", creds.id); + return false; + } + seenIds[creds.id] = true; + + if (!isValidWiFiCredentials(creds.ssid, creds.password)) { + return false; + } + } CONFIG_LOCK_WRITE(false); - _configData.wifi.credentialsList = credentials; - return trySaveConfig(); + return mutateAndSave([&](Config::RootConfig& root) { + root.wifi.credentialsList = credentials; + return true; + }); } bool Config::GetRFConfigTxPin(gpio_num_t& out) @@ -424,8 +482,10 @@ bool Config::SetRFConfigTxPin(gpio_num_t txPin) { CONFIG_LOCK_WRITE(false); - _configData.rf.txPin = txPin; - return trySaveConfig(); + return mutateAndSave([&](Config::RootConfig& root) { + root.rf.txPin = txPin; + return true; + }); } bool Config::GetRFConfigKeepAliveEnabled(bool& out) @@ -441,8 +501,10 @@ bool Config::SetRFConfigKeepAliveEnabled(bool enabled) { CONFIG_LOCK_WRITE(false); - _configData.rf.keepAliveEnabled = enabled; - return trySaveConfig(); + return mutateAndSave([&](Config::RootConfig& root) { + root.rf.keepAliveEnabled = enabled; + return true; + }); } bool Config::AnyWiFiCredentials(std::function predicate) @@ -456,6 +518,10 @@ bool Config::AnyWiFiCredentials(std::function(ssid.size()), ssid.data()); + creds = previous; return 0; } return creds.id; @@ -502,7 +571,14 @@ uint8_t Config::AddWiFiCredentials(std::string_view ssid, std::string_view passw } _configData.wifi.credentialsList.emplace_back(id, ssid, password, authMode); - trySaveConfig(); + + // Only keep credentials that made it to flash. This is also what caps the list: + // once the config would outgrow MaxConfigSize, trySaveConfig refuses it. + if (!trySaveConfig()) { + OS_LOGE(TAG, "Failed to persist new WiFi credentials for SSID %.*s", static_cast(ssid.size()), ssid.data()); + _configData.wifi.credentialsList.pop_back(); + return 0; + } return id; } @@ -552,38 +628,40 @@ bool Config::PinWiFiCredentialsBSSID(uint8_t id, const uint8_t (&bssid)[6]) { CONFIG_LOCK_WRITE(false); - for (auto& creds : _configData.wifi.credentialsList) { - if (creds.id == id) { - memcpy(creds.bssid.data(), bssid, 6); - return trySaveConfig(); + return mutateAndSave([&](Config::RootConfig& root) { + for (auto& creds : root.wifi.credentialsList) { + if (creds.id == id) { + memcpy(creds.bssid.data(), bssid, 6); + return true; + } } - } - - return false; + return false; + }); } bool Config::RemoveWiFiCredentials(uint8_t id) { CONFIG_LOCK_WRITE(false); - for (auto it = _configData.wifi.credentialsList.begin(); it != _configData.wifi.credentialsList.end(); ++it) { - if (it->id == id) { - _configData.wifi.credentialsList.erase(it); - trySaveConfig(); - return true; + return mutateAndSave([id](Config::RootConfig& root) { + auto& list = root.wifi.credentialsList; + auto it = std::find_if(list.begin(), list.end(), [id](const Config::WiFiCredentials& creds) { return creds.id == id; }); + if (it == list.end()) { + return false; } - } - - return false; + list.erase(it); + return true; + }); } bool Config::ClearWiFiCredentials() { CONFIG_LOCK_WRITE(false); - _configData.wifi.credentialsList.clear(); - - return trySaveConfig(); + return mutateAndSave([](Config::RootConfig& root) { + root.wifi.credentialsList.clear(); + return true; + }); } bool Config::GetWiFiHostname(std::string& out) @@ -599,9 +677,10 @@ bool Config::SetWiFiHostname(std::string hostname) { CONFIG_LOCK_WRITE(false); - _configData.wifi.hostname = std::move(hostname); - - return trySaveConfig(); + return mutateAndSave([&](Config::RootConfig& root) { + root.wifi.hostname = std::move(hostname); + return true; + }); } bool Config::GetBackendDomain(std::string& out) @@ -617,8 +696,10 @@ bool Config::SetBackendDomain(std::string domain) { CONFIG_LOCK_WRITE(false); - _configData.backend.domain = std::move(domain); - return trySaveConfig(); + return mutateAndSave([&](Config::RootConfig& root) { + root.backend.domain = std::move(domain); + return true; + }); } bool Config::HasBackendAuthToken() @@ -641,16 +722,20 @@ bool Config::SetBackendAuthToken(std::string token) { CONFIG_LOCK_WRITE(false); - _configData.backend.authToken = std::move(token); - return trySaveConfig(); + return mutateAndSave([&](Config::RootConfig& root) { + root.backend.authToken = std::move(token); + return true; + }); } bool Config::ClearBackendAuthToken() { CONFIG_LOCK_WRITE(false); - _configData.backend.authToken.clear(); - return trySaveConfig(); + return mutateAndSave([](Config::RootConfig& root) { + root.backend.authToken.clear(); + return true; + }); } bool Config::GetSerialInputConfigEchoEnabled(bool& out) @@ -665,8 +750,10 @@ bool Config::SetSerialInputConfigEchoEnabled(bool enabled) { CONFIG_LOCK_WRITE(false); - _configData.serialInput.echoEnabled = enabled; - return trySaveConfig(); + return mutateAndSave([&](Config::RootConfig& root) { + root.serialInput.echoEnabled = enabled; + return true; + }); } bool Config::GetOtaUpdateId(int32_t& out) @@ -686,8 +773,10 @@ bool Config::SetOtaUpdateId(int32_t updateId) return true; } - _configData.otaUpdate.updateId = updateId; - return trySaveConfig(); + return mutateAndSave([&](Config::RootConfig& root) { + root.otaUpdate.updateId = updateId; + return true; + }); } bool Config::GetOtaUpdateStep(OtaUpdateStep& out) @@ -707,8 +796,10 @@ bool Config::SetOtaUpdateStep(OtaUpdateStep updateStep) return true; } - _configData.otaUpdate.updateStep = updateStep; - return trySaveConfig(); + return mutateAndSave([&](Config::RootConfig& root) { + root.otaUpdate.updateStep = updateStep; + return true; + }); } bool Config::GetEStopEnabled(bool& out) @@ -724,8 +815,10 @@ bool Config::SetEStopEnabled(bool enabled) { CONFIG_LOCK_WRITE(false); - _configData.estop.enabled = enabled; - return trySaveConfig(); + return mutateAndSave([&](Config::RootConfig& root) { + root.estop.enabled = enabled; + return true; + }); } bool Config::GetEStopGpioPin(gpio_num_t& out) @@ -746,6 +839,8 @@ bool Config::SetEStopGpioPin(gpio_num_t gpioPin) return false; } - _configData.estop.gpioPin = gpioPin; - return trySaveConfig(); + return mutateAndSave([&](Config::RootConfig& root) { + root.estop.gpioPin = gpioPin; + return true; + }); } diff --git a/components/config/src/EStopConfig.cpp b/components/config/src/EStopConfig.cpp new file mode 100644 index 00000000..e6866be0 --- /dev/null +++ b/components/config/src/EStopConfig.cpp @@ -0,0 +1,100 @@ +#include "config/EStopConfig.h" + +#include "Chipset.h" +#include "config/internal/utils.h" +#include "Logging.h" +#include "OpenShock.h" + +const char* const TAG = "Config::EStopConfig"; + +using namespace OpenShock::Config; + +EStopConfig::EStopConfig() + : enabled(OpenShock::IsValidInputPin(OPENSHOCK_ESTOP_PIN)) + , gpioPin(static_cast(OPENSHOCK_ESTOP_PIN)) +{ +} + +EStopConfig::EStopConfig(bool enabled, gpio_num_t gpioPin) + : enabled(enabled) + , gpioPin(gpioPin) +{ +} + +void EStopConfig::ToDefault() +{ + enabled = OpenShock::IsValidInputPin(OPENSHOCK_ESTOP_PIN); + gpioPin = static_cast(OPENSHOCK_ESTOP_PIN); +} + +bool EStopConfig::FromFlatbuffers(const Serialization::Configuration::EStopConfig* config) +{ + if (config == nullptr) { + ToDefault(); // Set to default if config is null + return true; + } + + // gpio_pin is a raw int8 from flash or rawconfig; anything that isn't "no pin" or a real GPIO means a + // corrupt section, so fall back to the default. + if (!Internal::Utils::FromIntGpioNum(gpioPin, config->gpio_pin())) { + OS_LOGW(TAG, "Invalid E-Stop GPIO pin %d, using default", config->gpio_pin()); + ToDefault(); + return true; + } + + enabled = config->enabled(); + Normalize(); + + return true; +} + +flatbuffers::Offset EStopConfig::ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const +{ + return Serialization::Configuration::CreateEStopConfig(builder, enabled, gpioPin); +} + +bool EStopConfig::FromJSON(JSON::JsonView json) +{ + if (!json.valid()) { + ToDefault(); // Set to default if config is null + return true; + } + + if (!json.isObject()) { + OS_LOGE(TAG, "json is not an object"); + return false; + } + + if (!Internal::Utils::FromJsonGpioNum(gpioPin, json, "gpioPin")) { + gpioPin = static_cast(OPENSHOCK_ESTOP_PIN); + } + + if (JSON::JsonView enabledJson = json["enabled"]; enabledJson.valid()) { + if (!enabledJson.tryGetBool(enabled)) { + OS_LOGE(TAG, "Failed to parse enabled"); + return false; + } + } else { + enabled = OpenShock::IsValidInputPin(gpioPin); + } + Normalize(); + + return true; +} + +void EStopConfig::Normalize() +{ + // An E-Stop on a pin that can't be read as an input would never trigger; store it as disabled on every path. + if (enabled && !OpenShock::IsValidInputPin(gpioPin)) { + OS_LOGW(TAG, "E-Stop pin %d is not a valid input pin, disabling E-Stop", gpioPin); + enabled = false; + } +} + +void EStopConfig::ToJSON(json_gen_str_t* gen, const char* name, bool withSensitiveData) const +{ + JSON::objBegin(gen, name); + json_gen_obj_set_bool(gen, "enabled", enabled); + json_gen_obj_set_int(gen, "gpioPin", gpioPin); + JSON::objEnd(gen, name); +} diff --git a/components/config/src/OtaUpdateConfig.cpp b/components/config/src/OtaUpdateConfig.cpp new file mode 100644 index 00000000..ba2dfd96 --- /dev/null +++ b/components/config/src/OtaUpdateConfig.cpp @@ -0,0 +1,149 @@ +#include "config/OtaUpdateConfig.h" + +const char* const TAG = "Config::OtaUpdateConfig"; + +#include "config/internal/utils.h" +#include "Logging.h" + +using namespace OpenShock::Config; +using namespace std::string_view_literals; + +OtaUpdateConfig::OtaUpdateConfig() + : isEnabled(true) + , cdnDomain(CONFIG_OPENSHOCK_FW_CDN_DOMAIN) + , updateChannel(OtaUpdateChannel::Stable) + , checkOnStartup(false) + , checkPeriodically(false) + , checkInterval(30) + , allowBackendManagement(true) + , requireManualApproval(false) + , updateId(0) + , updateStep(OtaUpdateStep::None) +{ +} + +OtaUpdateConfig::OtaUpdateConfig( + bool isEnabled, std::string cdnDomain, OtaUpdateChannel updateChannel, bool checkOnStartup, bool checkPeriodically, uint16_t checkInterval, bool allowBackendManagement, bool requireManualApproval, int32_t updateId, OtaUpdateStep updateStep +) + : isEnabled(isEnabled) + , cdnDomain(std::move(cdnDomain)) + , updateChannel(updateChannel) + , checkOnStartup(checkOnStartup) + , checkPeriodically(checkPeriodically) + , checkInterval(checkInterval) + , allowBackendManagement(allowBackendManagement) + , requireManualApproval(requireManualApproval) + , updateId(updateId) + , updateStep(updateStep) +{ +} + +void OtaUpdateConfig::ToDefault() +{ + isEnabled = true; + cdnDomain = CONFIG_OPENSHOCK_FW_CDN_DOMAIN; + updateChannel = OtaUpdateChannel::Stable; + checkOnStartup = false; + checkPeriodically = false; + checkInterval = 30; // 30 minutes + allowBackendManagement = true; + requireManualApproval = false; + updateId = 0; + updateStep = OtaUpdateStep::None; +} + +bool OtaUpdateConfig::FromFlatbuffers(const Serialization::Configuration::OtaUpdateConfig* config) +{ + if (config == nullptr) { + OS_LOGW(TAG, "Config is null, setting to default"); + ToDefault(); + return true; + } + + isEnabled = config->is_enabled(); + Internal::Utils::FromFbsStr(cdnDomain, config->cdn_domain(), CONFIG_OPENSHOCK_FW_CDN_DOMAIN); + // Out-of-range enum values (bit flips, configs from newer firmware) fall back to the defaults + auto fbsChannel = static_cast(config->update_channel()); + updateChannel = fbsChannel <= static_cast(OtaUpdateChannel::Develop) ? static_cast(fbsChannel) : OtaUpdateChannel::Stable; + checkOnStartup = config->check_on_startup(); + checkPeriodically = config->check_periodically(); + checkInterval = config->check_interval(); + allowBackendManagement = config->allow_backend_management(); + requireManualApproval = config->require_manual_approval(); + updateId = config->update_id(); + auto fbsStep = static_cast(config->update_step()); + updateStep = fbsStep <= static_cast(OtaUpdateStep::RollingBack) ? static_cast(fbsStep) : OtaUpdateStep::None; + + return true; +} + +flatbuffers::Offset OtaUpdateConfig::ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const +{ + namespace Fbs = Serialization::Configuration; + + auto cdnDomainOffset = builder.CreateString(cdnDomain); + + // Same field order as CreateOtaUpdateConfig, except that is_enabled and + // allow_backend_management are stored even when they equal the schema default, so + // they read back the same under every schema version (up to 1.5.x both defaulted + // to false) and RootConfig::FromFlatbuffers never has to infer them. + Fbs::OtaUpdateConfigBuilder otaBuilder(builder); + otaBuilder.add_update_id(updateId); + otaBuilder.add_cdn_domain(cdnDomainOffset); + otaBuilder.add_check_interval(checkInterval); + otaBuilder.add_update_step(static_cast(updateStep)); + otaBuilder.add_require_manual_approval(requireManualApproval); + builder.AddElement(Fbs::OtaUpdateConfig::VT_ALLOW_BACKEND_MANAGEMENT, static_cast(allowBackendManagement)); + otaBuilder.add_check_periodically(checkPeriodically); + otaBuilder.add_check_on_startup(checkOnStartup); + otaBuilder.add_update_channel(static_cast(updateChannel)); + builder.AddElement(Fbs::OtaUpdateConfig::VT_IS_ENABLED, static_cast(isEnabled)); + return otaBuilder.Finish(); +} + +bool OtaUpdateConfig::FromJSON(JSON::JsonView json) +{ + if (!json.valid()) { + OS_LOGW(TAG, "Config is null, setting to default"); + ToDefault(); + return true; + } + + if (!json.isObject()) { + OS_LOGE(TAG, "json is not an object"); + return false; + } + + if (!json["isEnabled"].tryGetBool(isEnabled)) isEnabled = true; + + if (!json["cdnDomain"].tryGetStr(cdnDomain)) cdnDomain = CONFIG_OPENSHOCK_FW_CDN_DOMAIN; + + Internal::Utils::FromJsonStrParsed(updateChannel, json, "updateChannel"sv, OpenShock::TryParseOtaUpdateChannel, OpenShock::OtaUpdateChannel::Stable); + + if (!json["checkOnStartup"].tryGetBool(checkOnStartup)) checkOnStartup = false; + if (!json["checkPeriodically"].tryGetBool(checkPeriodically)) checkPeriodically = false; + if (!json["checkInterval"].tryGetU16(checkInterval)) checkInterval = 30; + if (!json["allowBackendManagement"].tryGetBool(allowBackendManagement)) allowBackendManagement = true; + if (!json["requireManualApproval"].tryGetBool(requireManualApproval)) requireManualApproval = false; + if (!json["updateId"].tryGetI32(updateId)) updateId = 0; + + Internal::Utils::FromJsonStrParsed(updateStep, json, "updateStep"sv, OpenShock::TryParseOtaUpdateStep, OpenShock::OtaUpdateStep::None); + + return true; +} + +void OtaUpdateConfig::ToJSON(json_gen_str_t* gen, const char* name, bool withSensitiveData) const +{ + JSON::objBegin(gen, name); + json_gen_obj_set_bool(gen, "isEnabled", isEnabled); + JSON::objSetString(gen, "cdnDomain", cdnDomain); + JSON::objSetString(gen, "updateChannel", OpenShock::Serialization::Configuration::EnumNameOtaUpdateChannel(static_cast(updateChannel))); + json_gen_obj_set_bool(gen, "checkOnStartup", checkOnStartup); + json_gen_obj_set_bool(gen, "checkPeriodically", checkPeriodically); + json_gen_obj_set_int(gen, "checkInterval", checkInterval); + json_gen_obj_set_bool(gen, "allowBackendManagement", allowBackendManagement); + json_gen_obj_set_bool(gen, "requireManualApproval", requireManualApproval); + json_gen_obj_set_int(gen, "updateId", updateId); + JSON::objSetString(gen, "updateStep", OpenShock::Serialization::Configuration::EnumNameOtaUpdateStep(static_cast(updateStep))); + JSON::objEnd(gen, name); +} diff --git a/components/config/src/RFConfig.cpp b/components/config/src/RFConfig.cpp new file mode 100644 index 00000000..4dc0101f --- /dev/null +++ b/components/config/src/RFConfig.cpp @@ -0,0 +1,86 @@ +#include "config/RFConfig.h" + +const char* const TAG = "Config::RFConfig"; + +#include "config/internal/utils.h" +#include "Logging.h" +#include "OpenShock.h" + +using namespace OpenShock::Config; + +RFConfig::RFConfig() + : txPin(static_cast(OPENSHOCK_RF_TX_GPIO)) + , keepAliveEnabled(true) +{ +} + +RFConfig::RFConfig(gpio_num_t txPin, bool keepAliveEnabled) + : txPin(txPin) + , keepAliveEnabled(keepAliveEnabled) +{ +} + +void RFConfig::ToDefault() +{ + txPin = static_cast(OPENSHOCK_RF_TX_GPIO); + keepAliveEnabled = true; +} + +bool RFConfig::FromFlatbuffers(const Serialization::Configuration::RFConfig* config) +{ + if (config == nullptr) { + OS_LOGW(TAG, "Config is null, setting to default"); + ToDefault(); + return true; + } + + // "No pin" (-1) is not a usable TX pin either; both fall back to the board default. + if (!Internal::Utils::FromIntGpioNum(txPin, config->tx_pin()) || txPin == GPIO_NUM_NC) { + txPin = static_cast(OPENSHOCK_RF_TX_GPIO); + } + keepAliveEnabled = config->keepalive_enabled(); + + return true; +} + +flatbuffers::Offset RFConfig::ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const +{ + namespace Fbs = Serialization::Configuration; + + // keepalive_enabled is stored even when it equals the schema default, so the value + // reads back the same under every schema version (up to 1.5.x it defaulted to + // false) and RootConfig::FromFlatbuffers never has to infer it. + Fbs::RFConfigBuilder rfBuilder(builder); + rfBuilder.add_tx_pin(txPin); + builder.AddElement(Fbs::RFConfig::VT_KEEPALIVE_ENABLED, static_cast(keepAliveEnabled)); + return rfBuilder.Finish(); +} + +bool RFConfig::FromJSON(JSON::JsonView json) +{ + if (!json.valid()) { + OS_LOGW(TAG, "Config is null, setting to default"); + ToDefault(); + return true; + } + + if (!json.isObject()) { + OS_LOGE(TAG, "json is not an object"); + return false; + } + + if (!Internal::Utils::FromJsonGpioNum(txPin, json, "txPin") || txPin == GPIO_NUM_NC) { + txPin = static_cast(OPENSHOCK_RF_TX_GPIO); + } + if (!json["keepAliveEnabled"].tryGetBool(keepAliveEnabled)) keepAliveEnabled = true; + + return true; +} + +void RFConfig::ToJSON(json_gen_str_t* gen, const char* name, bool withSensitiveData) const +{ + JSON::objBegin(gen, name); + json_gen_obj_set_int(gen, "txPin", static_cast(txPin)); + json_gen_obj_set_bool(gen, "keepAliveEnabled", keepAliveEnabled); + JSON::objEnd(gen, name); +} diff --git a/components/config/src/RootConfig.cpp b/components/config/src/RootConfig.cpp new file mode 100644 index 00000000..75a9b86b --- /dev/null +++ b/components/config/src/RootConfig.cpp @@ -0,0 +1,201 @@ +#include "config/RootConfig.h" + +const char* const TAG = "Config::RootConfig"; + +#include "Logging.h" + +using namespace OpenShock::Config; + +namespace Fbs = OpenShock::Serialization::Configuration; + +// Whether `field` is physically present in `table` (flatbuffers omits a scalar equal +// to its schema default). The generated tables derive privately from +// flatbuffers::Table, which is only a view of the same bytes. +static bool isStored(const void* table, flatbuffers::voffset_t field) +{ + return table != nullptr && static_cast(table)->CheckField(field); +} + +// Whether `field` is present in `table` with a non-zero value, ignoring the schema default. +static bool storedTrue(const void* table, flatbuffers::voffset_t field) +{ + return isStored(table, field) && static_cast(table)->GetField(field, 0) != 0; +} + +// Up to 1.5.x the schema declared rf.keepalive_enabled, ota_update.is_enabled and +// ota_update.allow_backend_management with default false; since 1.6 they default to +// true. Flatbuffers omits a field equal to its default, so a field omitted by a 1.5.x +// hub (false) reads back as true under the current schema. +// +// Which convention wrote a file shows only when at least one of the three is stored: +// a stored true can only come from the old schema (1.6+ omits true, and this firmware +// stores all three, so it omits none). When that is the case, the omitted ones meant +// false. A file that omits all three is ambiguous (1.5.x with all three off, or 1.6+ +// with all three on) and keeps the current schema's default (true). +static void applyLegacyBoolDefaults(const Fbs::HubConfig* config, RFConfig& rf, OtaUpdateConfig& otaUpdate) +{ + const Fbs::RFConfig* fbsRf = config->rf(); + const Fbs::OtaUpdateConfig* fbsOta = config->ota_update(); + + const bool legacyWriter = storedTrue(fbsRf, Fbs::RFConfig::VT_KEEPALIVE_ENABLED) || storedTrue(fbsOta, Fbs::OtaUpdateConfig::VT_IS_ENABLED) || storedTrue(fbsOta, Fbs::OtaUpdateConfig::VT_ALLOW_BACKEND_MANAGEMENT); + if (!legacyWriter) { + return; + } + + if (fbsRf != nullptr && !isStored(fbsRf, Fbs::RFConfig::VT_KEEPALIVE_ENABLED)) { + rf.keepAliveEnabled = false; + } + if (fbsOta != nullptr && !isStored(fbsOta, Fbs::OtaUpdateConfig::VT_IS_ENABLED)) { + otaUpdate.isEnabled = false; + } + if (fbsOta != nullptr && !isStored(fbsOta, Fbs::OtaUpdateConfig::VT_ALLOW_BACKEND_MANAGEMENT)) { + otaUpdate.allowBackendManagement = false; + } +} + +RootConfig::RootConfig() + : rf() + , wifi() + , captivePortal() + , backend() + , serialInput() + , otaUpdate() + , estop() +{ +} + +void RootConfig::ToDefault() +{ + rf.ToDefault(); + wifi.ToDefault(); + captivePortal.ToDefault(); + backend.ToDefault(); + serialInput.ToDefault(); + otaUpdate.ToDefault(); + estop.ToDefault(); +} + +bool RootConfig::FromFlatbuffers(const Serialization::Configuration::HubConfig* config) +{ + if (config == nullptr) { + OS_LOGW(TAG, "Config is null, setting to default"); + ToDefault(); + return true; + } + + if (!rf.FromFlatbuffers(config->rf())) { + OS_LOGE(TAG, "Unable to load rf config"); + return false; + } + + if (!wifi.FromFlatbuffers(config->wifi())) { + OS_LOGE(TAG, "Unable to load wifi config"); + return false; + } + + if (!captivePortal.FromFlatbuffers(config->captive_portal())) { + OS_LOGE(TAG, "Unable to load captive portal config"); + return false; + } + + if (!backend.FromFlatbuffers(config->backend())) { + OS_LOGE(TAG, "Unable to load backend config"); + return false; + } + + if (!serialInput.FromFlatbuffers(config->serial_input())) { + OS_LOGE(TAG, "Unable to load serial input config"); + return false; + } + + if (!otaUpdate.FromFlatbuffers(config->ota_update())) { + OS_LOGE(TAG, "Unable to load ota update config"); + return false; + } + + if (!estop.FromFlatbuffers(config->estop())) { + OS_LOGE(TAG, "Unable to load estop config"); + return false; + } + + applyLegacyBoolDefaults(config, rf, otaUpdate); + + return true; +} + +flatbuffers::Offset RootConfig::ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const +{ + auto rfOffset = rf.ToFlatbuffers(builder, withSensitiveData); + auto wifiOffset = wifi.ToFlatbuffers(builder, withSensitiveData); + auto captivePortalOffset = captivePortal.ToFlatbuffers(builder, withSensitiveData); + auto backendOffset = backend.ToFlatbuffers(builder, withSensitiveData); + auto serialInputOffset = serialInput.ToFlatbuffers(builder, withSensitiveData); + auto otaUpdateOffset = otaUpdate.ToFlatbuffers(builder, withSensitiveData); + auto estopOffset = estop.ToFlatbuffers(builder, withSensitiveData); + + return Serialization::Configuration::CreateHubConfig(builder, rfOffset, wifiOffset, captivePortalOffset, backendOffset, serialInputOffset, otaUpdateOffset, estopOffset); +} + +bool RootConfig::FromJSON(JSON::JsonView json) +{ + if (!json.valid()) { + OS_LOGW(TAG, "Config is null, setting to default"); + ToDefault(); + return true; + } + + if (!json.isObject()) { + OS_LOGE(TAG, "json is not an object"); + return false; + } + + if (!rf.FromJSON(json["rf"])) { + OS_LOGE(TAG, "Unable to load rf config"); + return false; + } + + if (!wifi.FromJSON(json["wifi"])) { + OS_LOGE(TAG, "Unable to load wifi config"); + return false; + } + + if (!captivePortal.FromJSON(json["captivePortal"])) { + OS_LOGE(TAG, "Unable to load captive portal config"); + return false; + } + + if (!backend.FromJSON(json["backend"])) { + OS_LOGE(TAG, "Unable to load backend config"); + return false; + } + + if (!serialInput.FromJSON(json["serialInput"])) { + OS_LOGE(TAG, "Unable to load serial input config"); + return false; + } + + if (!otaUpdate.FromJSON(json["otaUpdate"])) { + OS_LOGE(TAG, "Unable to load ota update config"); + return false; + } + + if (!estop.FromJSON(json["estop"])) { + OS_LOGE(TAG, "Unable to load estop config"); + return false; + } + + return true; +} + +void RootConfig::ToJSON(json_gen_str_t* gen, const char* name, bool withSensitiveData) const +{ + JSON::objBegin(gen, name); + rf.ToJSON(gen, "rf", withSensitiveData); + wifi.ToJSON(gen, "wifi", withSensitiveData); + captivePortal.ToJSON(gen, "captivePortal", withSensitiveData); + backend.ToJSON(gen, "backend", withSensitiveData); + serialInput.ToJSON(gen, "serialInput", withSensitiveData); + otaUpdate.ToJSON(gen, "otaUpdate", withSensitiveData); + estop.ToJSON(gen, "estop", withSensitiveData); + JSON::objEnd(gen, name); +} diff --git a/src/config/SerialInputConfig.cpp b/components/config/src/SerialInputConfig.cpp similarity index 74% rename from src/config/SerialInputConfig.cpp rename to components/config/src/SerialInputConfig.cpp index b851e665..ade9bfb4 100644 --- a/src/config/SerialInputConfig.cpp +++ b/components/config/src/SerialInputConfig.cpp @@ -40,29 +40,27 @@ flatbuffers::Offset return Serialization::Configuration::CreateSerialInputConfig(builder, echoEnabled); } -bool SerialInputConfig::FromJSON(const cJSON* json) +bool SerialInputConfig::FromJSON(JSON::JsonView json) { - if (json == nullptr) { + if (!json.valid()) { OS_LOGW(TAG, "Config is null, setting to default"); ToDefault(); return true; } - if (cJSON_IsObject(json) == 0) { + if (!json.isObject()) { OS_LOGE(TAG, "json is not an object"); return false; } - Internal::Utils::FromJsonBool(echoEnabled, json, "echoEnabled", true); + if (!json["echoEnabled"].tryGetBool(echoEnabled)) echoEnabled = true; return true; } -cJSON* SerialInputConfig::ToJSON(bool withSensitiveData) const +void SerialInputConfig::ToJSON(json_gen_str_t* gen, const char* name, bool withSensitiveData) const { - cJSON* root = cJSON_CreateObject(); - - cJSON_AddBoolToObject(root, "echoEnabled", echoEnabled); - - return root; + JSON::objBegin(gen, name); + json_gen_obj_set_bool(gen, "echoEnabled", echoEnabled); + JSON::objEnd(gen, name); } diff --git a/src/config/WiFiConfig.cpp b/components/config/src/WiFiConfig.cpp similarity index 62% rename from src/config/WiFiConfig.cpp rename to components/config/src/WiFiConfig.cpp index 725f6c9e..7193410f 100644 --- a/src/config/WiFiConfig.cpp +++ b/components/config/src/WiFiConfig.cpp @@ -8,8 +8,8 @@ const char* const TAG = "Config::WiFiConfig"; using namespace OpenShock::Config; WiFiConfig::WiFiConfig() - : accessPointSSID(OPENSHOCK_FW_AP_PREFIX) - , hostname(OPENSHOCK_FW_HOSTNAME) + : accessPointSSID(CONFIG_OPENSHOCK_FW_AP_PREFIX) + , hostname(CONFIG_OPENSHOCK_FW_HOSTNAME) , credentialsList() { } @@ -23,8 +23,8 @@ WiFiConfig::WiFiConfig(std::string_view accessPointSSID, std::string_view hostna void WiFiConfig::ToDefault() { - accessPointSSID = OPENSHOCK_FW_AP_PREFIX; - hostname = OPENSHOCK_FW_HOSTNAME; + accessPointSSID = CONFIG_OPENSHOCK_FW_AP_PREFIX; + hostname = CONFIG_OPENSHOCK_FW_HOSTNAME; credentialsList.clear(); } @@ -36,8 +36,8 @@ bool WiFiConfig::FromFlatbuffers(const Serialization::Configuration::WiFiConfig* return true; } - Internal::Utils::FromFbsStr(accessPointSSID, config->ap_ssid(), OPENSHOCK_FW_AP_PREFIX); - Internal::Utils::FromFbsStr(hostname, config->hostname(), OPENSHOCK_FW_HOSTNAME); + Internal::Utils::FromFbsStr(accessPointSSID, config->ap_ssid(), CONFIG_OPENSHOCK_FW_AP_PREFIX); + Internal::Utils::FromFbsStr(hostname, config->hostname(), CONFIG_OPENSHOCK_FW_HOSTNAME); Internal::Utils::FromFbsVec(credentialsList, config->credentials()); return true; @@ -55,29 +55,30 @@ flatbuffers::Offset WiFiCon return Serialization::Configuration::CreateWiFiConfig(builder, builder.CreateString(accessPointSSID), builder.CreateString(hostname), builder.CreateVector(fbsCredentialsList)); } -bool WiFiConfig::FromJSON(const cJSON* json) +bool WiFiConfig::FromJSON(JSON::JsonView json) { - if (json == nullptr) { + if (!json.valid()) { OS_LOGW(TAG, "Config is null, setting to default"); ToDefault(); return true; } - if (cJSON_IsObject(json) == 0) { + if (!json.isObject()) { OS_LOGE(TAG, "json is not an object"); return false; } - Internal::Utils::FromJsonStr(accessPointSSID, json, "accessPointSSID", OPENSHOCK_FW_AP_PREFIX); - Internal::Utils::FromJsonStr(hostname, json, "hostname", OPENSHOCK_FW_HOSTNAME); + if (!json["accessPointSSID"].tryGetStr(accessPointSSID)) accessPointSSID = CONFIG_OPENSHOCK_FW_AP_PREFIX; - const cJSON* credentialsListJson = cJSON_GetObjectItemCaseSensitive(json, "credentials"); - if (credentialsListJson == nullptr) { + if (!json["hostname"].tryGetStr(hostname)) hostname = CONFIG_OPENSHOCK_FW_HOSTNAME; + + JSON::JsonView credentialsListJson = json["credentials"]; + if (!credentialsListJson.valid()) { OS_LOGE(TAG, "credentials is null"); return false; } - if (cJSON_IsArray(credentialsListJson) == 0) { + if (!credentialsListJson.isArray()) { OS_LOGE(TAG, "credentials is not an array"); return false; } @@ -87,20 +88,16 @@ bool WiFiConfig::FromJSON(const cJSON* json) return true; } -cJSON* WiFiConfig::ToJSON(bool withSensitiveData) const +void WiFiConfig::ToJSON(json_gen_str_t* gen, const char* name, bool withSensitiveData) const { - cJSON* root = cJSON_CreateObject(); - - cJSON_AddStringToObject(root, "accessPointSSID", accessPointSSID.c_str()); - cJSON_AddStringToObject(root, "hostname", hostname.c_str()); - - cJSON* credentialsListJson = cJSON_CreateArray(); + JSON::objBegin(gen, name); + JSON::objSetString(gen, "accessPointSSID", accessPointSSID); + JSON::objSetString(gen, "hostname", hostname); + json_gen_push_array(gen, "credentials"); for (auto& credentials : credentialsList) { - cJSON_AddItemToArray(credentialsListJson, credentials.ToJSON(withSensitiveData)); + credentials.ToJSON(gen, nullptr, withSensitiveData); } - - cJSON_AddItemToObject(root, "credentials", credentialsListJson); - - return root; + json_gen_pop_array(gen); + JSON::objEnd(gen, name); } diff --git a/src/config/WiFiCredentials.cpp b/components/config/src/WiFiCredentials.cpp similarity index 51% rename from src/config/WiFiCredentials.cpp rename to components/config/src/WiFiCredentials.cpp index 3b825640..4c412ae7 100644 --- a/src/config/WiFiCredentials.cpp +++ b/components/config/src/WiFiCredentials.cpp @@ -3,64 +3,16 @@ const char* const TAG = "Config::WiFiCredentials"; #include "config/internal/utils.h" +#include "config/WiFiAuthMode.h" #include "Logging.h" + #include "util/HexUtils.h" +#include + using namespace OpenShock::Config; using FbsAuthMode = OpenShock::Serialization::Types::WifiAuthMode; -static FbsAuthMode toFbsAuthMode(wifi_auth_mode_t mode) -{ - switch (mode) { - case WIFI_AUTH_OPEN: - return FbsAuthMode::Open; - case WIFI_AUTH_WEP: - return FbsAuthMode::WEP; - case WIFI_AUTH_WPA_PSK: - return FbsAuthMode::WPA_PSK; - case WIFI_AUTH_WPA2_PSK: - return FbsAuthMode::WPA2_PSK; - case WIFI_AUTH_WPA_WPA2_PSK: - return FbsAuthMode::WPA_WPA2_PSK; - case WIFI_AUTH_WPA2_ENTERPRISE: - return FbsAuthMode::WPA2_ENTERPRISE; - case WIFI_AUTH_WPA3_PSK: - return FbsAuthMode::WPA3_PSK; - case WIFI_AUTH_WPA2_WPA3_PSK: - return FbsAuthMode::WPA2_WPA3_PSK; - case WIFI_AUTH_WAPI_PSK: - return FbsAuthMode::WAPI_PSK; - default: - return FbsAuthMode::UNKNOWN; - } -} - -static wifi_auth_mode_t fromFbsAuthMode(FbsAuthMode mode) -{ - switch (mode) { - case FbsAuthMode::Open: - return WIFI_AUTH_OPEN; - case FbsAuthMode::WEP: - return WIFI_AUTH_WEP; - case FbsAuthMode::WPA_PSK: - return WIFI_AUTH_WPA_PSK; - case FbsAuthMode::WPA2_PSK: - return WIFI_AUTH_WPA2_PSK; - case FbsAuthMode::WPA_WPA2_PSK: - return WIFI_AUTH_WPA_WPA2_PSK; - case FbsAuthMode::WPA2_ENTERPRISE: - return WIFI_AUTH_WPA2_ENTERPRISE; - case FbsAuthMode::WPA3_PSK: - return WIFI_AUTH_WPA3_PSK; - case FbsAuthMode::WPA2_WPA3_PSK: - return WIFI_AUTH_WPA2_WPA3_PSK; - case FbsAuthMode::WAPI_PSK: - return WIFI_AUTH_WAPI_PSK; - default: - return WIFI_AUTH_MAX; - } -} - WiFiCredentials::WiFiCredentials() : id(0) , ssid() @@ -107,7 +59,7 @@ bool WiFiCredentials::FromFlatbuffers(const Serialization::Configuration::WiFiCr id = config->id(); Internal::Utils::FromFbsStr(ssid, config->ssid(), ""); Internal::Utils::FromFbsStr(password, config->password(), ""); - authMode = fromFbsAuthMode(config->auth_mode()); + authMode = FromFbsAuthMode(config->auth_mode()); auto fbsBssid = config->bssid(); if (fbsBssid != nullptr) { @@ -142,34 +94,41 @@ flatbuffers::Offset Wi bssidPtr = &bssidStruct; } - return Serialization::Configuration::CreateWiFiCredentials(builder, id, ssidOffset, passwordOffset, toFbsAuthMode(authMode), bssidPtr); + return Serialization::Configuration::CreateWiFiCredentials(builder, id, ssidOffset, passwordOffset, ToFbsAuthMode(authMode), bssidPtr); } -bool WiFiCredentials::FromJSON(const cJSON* json) +bool WiFiCredentials::FromJSON(JSON::JsonView json) { - if (json == nullptr) { + if (!json.valid()) { OS_LOGW(TAG, "Config is null, setting to default"); ToDefault(); return true; } - if (cJSON_IsObject(json) == 0) { + if (!json.isObject()) { OS_LOGE(TAG, "json is not an object"); return false; } - Internal::Utils::FromJsonU8(id, json, "id", 0); - Internal::Utils::FromJsonStr(ssid, json, "ssid", ""); - Internal::Utils::FromJsonStr(password, json, "password", ""); + if (!json["id"].tryGetU8(id)) id = 0; + + if (!json["ssid"].tryGetStr(ssid)) ssid.clear(); + + if (!json["password"].tryGetStr(password)) password.clear(); uint8_t authModeVal = static_cast(FbsAuthMode::UNKNOWN); - Internal::Utils::FromJsonU8(authModeVal, json, "authMode", static_cast(FbsAuthMode::UNKNOWN)); - authMode = fromFbsAuthMode(static_cast(authModeVal)); + if (!json["authMode"].tryGetU8(authModeVal)) authModeVal = static_cast(FbsAuthMode::UNKNOWN); + authMode = FromFbsAuthMode(static_cast(authModeVal)); bssid.fill(0); - const cJSON* bssidJson = cJSON_GetObjectItemCaseSensitive(json, "bssid"); - if (cJSON_IsString(bssidJson) && bssidJson->valuestring != nullptr && strlen(bssidJson->valuestring) == 12) { - HexUtils::TryParseHex(bssidJson->valuestring, 12, bssid.data(), bssid.size()); + std::string bssidStr; + if (json["bssid"].tryGetStr(bssidStr) && bssidStr.size() == 12) { + // Parse into a temporary: TryParseHex writes byte by byte and stops at the first bad pair, and a partial + // BSSID would pin the network to an AP that doesn't exist. + std::array parsed {}; + if (HexUtils::TryParseHex(bssidStr.data(), bssidStr.size(), parsed.data(), parsed.size()) == parsed.size()) { + bssid = parsed; + } } if (ssid.empty()) { @@ -180,24 +139,22 @@ bool WiFiCredentials::FromJSON(const cJSON* json) return true; } -cJSON* WiFiCredentials::ToJSON(bool withSensitiveData) const +void WiFiCredentials::ToJSON(json_gen_str_t* gen, const char* name, bool withSensitiveData) const { - cJSON* root = cJSON_CreateObject(); - - cJSON_AddNumberToObject(root, "id", id); //-V2564 - cJSON_AddStringToObject(root, "ssid", ssid.c_str()); + JSON::objBegin(gen, name); + json_gen_obj_set_int(gen, "id", id); + JSON::objSetString(gen, "ssid", ssid); if (withSensitiveData) { - cJSON_AddStringToObject(root, "password", password.c_str()); + JSON::objSetString(gen, "password", password); } - cJSON_AddNumberToObject(root, "authMode", static_cast(toFbsAuthMode(authMode))); + json_gen_obj_set_int(gen, "authMode", static_cast(ToFbsAuthMode(authMode))); if (HasPinnedBSSID()) { char hex[13]; for (std::size_t i = 0; i < bssid.size(); ++i) { HexUtils::ToHex(bssid[i], &hex[i * 2]); } hex[12] = '\0'; - cJSON_AddStringToObject(root, "bssid", hex); + JSON::objSetString(gen, "bssid", hex); } - - return root; + JSON::objEnd(gen, name); } diff --git a/components/config/src/internal/utils.cpp b/components/config/src/internal/utils.cpp new file mode 100644 index 00000000..1afa7aca --- /dev/null +++ b/components/config/src/internal/utils.cpp @@ -0,0 +1,47 @@ +#include "config/internal/utils.h" + +const char* const TAG = "Config::Internal::Utils"; + +#include "Chipset.h" +#include "Logging.h" + +#include +#include + +using namespace OpenShock; + +bool Config::Internal::Utils::FromIntGpioNum(gpio_num_t& val, int32_t intVal) +{ + if (intVal == GPIO_NUM_NC) { + val = GPIO_NUM_NC; + return true; + } + + if (intVal < 0 || intVal >= GPIO_NUM_MAX || !GPIO_IS_VALID_GPIO(intVal)) { + OS_LOGE(TAG, "invalid GPIO number %" PRId32, intVal); + return false; + } + + val = static_cast(intVal); + + return true; +} + +void Config::Internal::Utils::FromFbsStr(std::string& str, const flatbuffers::String* fbsStr, const char* defaultStr) +{ + if (fbsStr != nullptr) { + str = fbsStr->c_str(); + } else { + str = defaultStr; + } +} + +bool Config::Internal::Utils::FromJsonGpioNum(gpio_num_t& val, JSON::JsonView json, std::string_view name) +{ + int32_t intVal; + if (!json[name].tryGetI32(intVal)) { + return false; + } + + return FromIntGpioNum(val, intVal); +} diff --git a/components/core/CMakeLists.txt b/components/core/CMakeLists.txt new file mode 100644 index 00000000..0812a827 --- /dev/null +++ b/components/core/CMakeLists.txt @@ -0,0 +1,29 @@ +# OpenShock application core. +# +# The tightly-interconnected heart of the firmware: wifi, the gateway link and the +# websocket message handlers, plus the JSON/flatbuffer codecs they share. These form a +# single dependency cycle (wifi <-> gateway <-> message handlers) and therefore live +# here together. +# +# The captive portal and the OTA update manager were split out into captive_portal and +# ota, which sit above core. core calls back into them only through AppHooks.h. +FILE(GLOB_RECURSE core_sources + ${CMAKE_CURRENT_SOURCE_DIR}/src/*.c + ${CMAKE_CURRENT_SOURCE_DIR}/src/*.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/src/*.S) + +idf_component_register( + SRCS ${core_sources} + INCLUDE_DIRS "include" + PRIV_INCLUDE_DIRS "src" # internal headers (GatewayClient.h, message_handlers/impl/*, ...) + REQUIRES common temporal logging http chipset led_drivers protocols osjson events esp_driver_rmt esp_driver_gpio esp_netif esp_event esp_wifi esp_timer nvs_flash mbedtls flatbuffers serialization config + # Internal-only now that GatewayClient.h is private: + PRIV_REQUIRES espressif__esp_websocket_client + device_control # estop/CommandHandler/visual used only in core .cpp +) + +# IDF compiles application components with -Werror. The legacy PlatformIO build +# applied -Wno-error to our own sources (build_src_flags), so partial designated +# initializers and similar intentional patterns don't fail the build. Match that +# here; can be tightened later by fixing the individual warnings. +target_compile_options(${COMPONENT_LIB} PRIVATE -Wno-error) diff --git a/components/core/Kconfig.projbuild b/components/core/Kconfig.projbuild new file mode 100644 index 00000000..965136fa --- /dev/null +++ b/components/core/Kconfig.projbuild @@ -0,0 +1,49 @@ +menu "OpenShock Board" + + # The board's GPIO assignments deliberately do NOT live here. + # + # A Kconfig symbol ends up in sdkconfig.h, which most of ESP-IDF includes, so two + # boards that differed only by an LED pin produced a different sdkconfig.h and + # therefore shared no compiled framework objects at all. The pins are plain + # generated macros in openshock_board.h instead: + # + # OPENSHOCK_RF_TX_GPIO OPENSHOCK_ESTOP_PIN OPENSHOCK_LED_GPIO + # OPENSHOCK_LED_WS2812B OPENSHOCK_LED_SWAP_RG_CHANNELS + # + # They are set per board as bare `OPENSHOCK_*=` lines in boards/.defaults, + # read by scripts/gen_env_header.py, and stripped from the sdkconfig fragment by + # scripts/build.py. Only the settings below - identical across every board - remain + # Kconfig, because they cost nothing in shared objects. + + config OPENSHOCK_API_DOMAIN + string "OpenShock API domain" + default "api.openshock.app" + help + Default backend API domain the hub connects to. Overridable at runtime. + + config OPENSHOCK_FW_CDN_DOMAIN + string "Firmware CDN domain" + default "firmware.openshock.org" + help + Default CDN domain used for OTA firmware/version lookups. Overridable at runtime. + + config OPENSHOCK_FW_HOSTNAME + string "Default hostname" + default "OpenShock" + help + Default network hostname; also the base of the default user agent. + + config OPENSHOCK_FW_AP_PREFIX + string "SoftAP SSID prefix" + default "OpenShock-" + help + Prefix for the captive-portal SoftAP SSID (the device MAC is appended). + + config OPENSHOCK_URI_BUFFER_SIZE + int "HTTP URI buffer size (bytes)" + default 256 + help + Stack buffer size for composed request URIs. Increase if a configured + domain plus path can exceed this. + +endmenu diff --git a/components/core/idf_component.yml b/components/core/idf_component.yml new file mode 100644 index 00000000..6a7f5dbe --- /dev/null +++ b/components/core/idf_component.yml @@ -0,0 +1,5 @@ +# esp_websocket_client backs the gateway (LCG) WebSocket client (src/GatewayClient.cpp) +# now that we no longer use the Arduino BadWebSockets library. In IDF 5.x it is a +# managed component rather than a bundled one, so declare it here. +dependencies: + espressif/esp_websocket_client: "^1.8.0" diff --git a/components/core/include/AppHooks.h b/components/core/include/AppHooks.h new file mode 100644 index 00000000..7d7c0c2f --- /dev/null +++ b/components/core/include/AppHooks.h @@ -0,0 +1,28 @@ +#pragma once + +#include "enums/FirmwareBootType.h" +#include "SemVer.h" + +#include +#include + +// core sits below the captive_portal and ota components, so it cannot call them directly. +// They register these callbacks from their Init(); until then each call behaves as if the +// subsystem were not running (broadcasts fail, requests are dropped). +namespace OpenShock::AppHooks { + typedef bool (*BroadcastMessageBINFn)(std::span data); + typedef void (*SetAlwaysEnabledFn)(bool alwaysEnabled); + typedef FirmwareBootType (*GetFirmwareBootTypeFn)(); + typedef bool (*TryStartFirmwareUpdateFn)(const OpenShock::SemVer& version); + typedef bool (*RequestUpdateCheckFn)(); + + void RegisterCaptivePortal(BroadcastMessageBINFn broadcastMessageBIN, SetAlwaysEnabledFn setAlwaysEnabled); + void RegisterOtaUpdateManager(GetFirmwareBootTypeFn getFirmwareBootType, TryStartFirmwareUpdateFn tryStartFirmwareUpdate, RequestUpdateCheckFn requestUpdateCheck); + + bool CaptivePortalBroadcastMessageBIN(std::span data); + void CaptivePortalSetAlwaysEnabled(bool alwaysEnabled); + + FirmwareBootType OtaGetFirmwareBootType(); + bool OtaTryStartFirmwareUpdate(const OpenShock::SemVer& version); + bool OtaRequestUpdateCheck(); +} // namespace OpenShock::AppHooks diff --git a/include/GatewayConnectionManager.h b/components/core/include/GatewayConnectionManager.h similarity index 60% rename from include/GatewayConnectionManager.h rename to components/core/include/GatewayConnectionManager.h index 060c9228..fe6ad01c 100644 --- a/include/GatewayConnectionManager.h +++ b/components/core/include/GatewayConnectionManager.h @@ -1,10 +1,11 @@ #pragma once -#include "AccountLinkResultCode.h" -#include "span.h" +#include "enums/AccountLinkResultCode.h" #include #include +#include +#include #include namespace OpenShock::GatewayConnectionManager { @@ -15,9 +16,11 @@ namespace OpenShock::GatewayConnectionManager { bool IsLinked(); AccountLinkResultCode Link(std::string_view linkCode); void UnLink(); + /// @brief Stores a backend auth token directly (e.g. from the serial console) and reconnects with it. + bool SetAuthToken(std::string authToken); bool SendMessageTXT(std::string_view data); - bool SendMessageBIN(tcb::span data); + bool SendMessageBIN(std::span data); void MarkPingReceived(); diff --git a/include/http/JsonAPI.h b/components/core/include/http/JsonAPI.h similarity index 100% rename from include/http/JsonAPI.h rename to components/core/include/http/JsonAPI.h diff --git a/components/core/include/message_handlers/WebSocket.h b/components/core/include/message_handlers/WebSocket.h new file mode 100644 index 00000000..cf24d9a7 --- /dev/null +++ b/components/core/include/message_handlers/WebSocket.h @@ -0,0 +1,14 @@ +#pragma once + +#include +#include +#include + +namespace OpenShock::MessageHandlers::WebSocket { + // Largest message the handlers accept; transports should not buffer more than this. + inline constexpr std::size_t MaxGatewayMessageSize = 4096; // TODO: Profile this + inline constexpr std::size_t MaxLocalMessageSize = 4096; // TODO: Profile this + + void HandleGatewayBinary(std::span data); + void HandleLocalBinary(uint8_t socketId, std::span data); +} // namespace OpenShock::MessageHandlers::WebSocket diff --git a/include/serialization/CallbackFn.h b/components/core/include/serialization/CallbackFn.h similarity index 60% rename from include/serialization/CallbackFn.h rename to components/core/include/serialization/CallbackFn.h index 9d677c94..664b0305 100644 --- a/include/serialization/CallbackFn.h +++ b/components/core/include/serialization/CallbackFn.h @@ -2,9 +2,8 @@ #include #include - -#include "span.h" +#include namespace OpenShock::Serialization::Common { - typedef std::function data)> SerializationCallbackFn; + typedef std::function data)> SerializationCallbackFn; } diff --git a/include/serialization/JsonAPI.h b/components/core/include/serialization/JsonAPI.h similarity index 50% rename from include/serialization/JsonAPI.h rename to components/core/include/serialization/JsonAPI.h index 322e8c8b..ac9d267a 100644 --- a/include/serialization/JsonAPI.h +++ b/components/core/include/serialization/JsonAPI.h @@ -1,21 +1,13 @@ #pragma once -#include "ShockerModelType.h" - -#include +#include "enums/ShockerModelType.h" +#include "json/Json.h" #include #include #include namespace OpenShock::Serialization::JsonAPI { - struct LcgInstanceDetailsResponse { - std::string name; - std::string version; - std::string currentTime; - std::string countryCode; - std::string fqdn; - }; struct BackendVersionResponse { std::string version; std::string commit; @@ -41,9 +33,8 @@ namespace OpenShock::Serialization::JsonAPI { std::string country; }; - bool ParseLcgInstanceDetailsJsonResponse(int code, const cJSON* root, LcgInstanceDetailsResponse& out); - bool ParseBackendVersionJsonResponse(int code, const cJSON* root, BackendVersionResponse& out); - bool ParseAccountLinkJsonResponse(int code, const cJSON* root, AccountLinkResponse& out); - bool ParseHubInfoJsonResponse(int code, const cJSON* root, HubInfoResponse& out); - bool ParseAssignLcgJsonResponse(int code, const cJSON* root, AssignLcgResponse& out); + bool ParseBackendVersionJsonResponse(int code, JSON::JsonView root, BackendVersionResponse& out); + bool ParseAccountLinkJsonResponse(int code, JSON::JsonView root, AccountLinkResponse& out); + bool ParseHubInfoJsonResponse(int code, JSON::JsonView root, HubInfoResponse& out); + bool ParseAssignLcgJsonResponse(int code, JSON::JsonView root, AssignLcgResponse& out); } // namespace OpenShock::Serialization::JsonAPI diff --git a/include/serialization/JsonSerial.h b/components/core/include/serialization/JsonSerial.h similarity index 65% rename from include/serialization/JsonSerial.h rename to components/core/include/serialization/JsonSerial.h index 9348a8d3..3bbef5b4 100644 --- a/include/serialization/JsonSerial.h +++ b/components/core/include/serialization/JsonSerial.h @@ -1,9 +1,8 @@ #pragma once -#include "ShockerCommandType.h" -#include "ShockerModelType.h" - -#include +#include "enums/ShockerCommandType.h" +#include "enums/ShockerModelType.h" +#include "json/Json.h" #include @@ -16,5 +15,5 @@ namespace OpenShock::Serialization::JsonSerial { uint16_t durationMs; }; - bool ParseShockerCommand(const cJSON* root, ShockerCommand& out); + bool ParseShockerCommand(JSON::JsonView root, ShockerCommand& out); } // namespace OpenShock::Serialization::JsonSerial diff --git a/include/serialization/WSGateway.h b/components/core/include/serialization/WSGateway.h similarity index 95% rename from include/serialization/WSGateway.h rename to components/core/include/serialization/WSGateway.h index 768d7665..8bf83a33 100644 --- a/include/serialization/WSGateway.h +++ b/components/core/include/serialization/WSGateway.h @@ -1,6 +1,6 @@ #pragma once -#include "FirmwareBootType.h" +#include "enums/FirmwareBootType.h" #include "SemVer.h" #include "serialization/CallbackFn.h" diff --git a/include/serialization/WSLocal.h b/components/core/include/serialization/WSLocal.h similarity index 97% rename from include/serialization/WSLocal.h rename to components/core/include/serialization/WSLocal.h index d6f48b86..57f034a4 100644 --- a/include/serialization/WSLocal.h +++ b/components/core/include/serialization/WSLocal.h @@ -5,8 +5,6 @@ #include "serialization/_fbs/WifiNetworkEventType_generated.h" -#include - #include namespace OpenShock { diff --git a/include/wifi/WiFiManager.h b/components/core/include/wifi/WiFiManager.h similarity index 91% rename from include/wifi/WiFiManager.h rename to components/core/include/wifi/WiFiManager.h index 1e90549d..05294773 100644 --- a/include/wifi/WiFiManager.h +++ b/components/core/include/wifi/WiFiManager.h @@ -55,11 +55,6 @@ namespace OpenShock::WiFiManager { /// @return True if the hub is connected to a network bool GetIPAddress(char* ipAddress); - /// @brief Gets the hubs IP address if it's connected to a network (IPv6) - /// @param ipAddress Variable to store the IP address in - /// @return True if the hub is connected to a network - bool GetIPv6Address(char* ipAddress); - /// @brief Gets a copy of the vector of discovered WiFi networks /// @return Vector of discovered WiFiNetworks std::vector GetDiscoveredWiFiNetworks(); diff --git a/include/wifi/WiFiNetwork.h b/components/core/include/wifi/WiFiNetwork.h similarity index 100% rename from include/wifi/WiFiNetwork.h rename to components/core/include/wifi/WiFiNetwork.h diff --git a/include/wifi/WiFiScanManager.h b/components/core/include/wifi/WiFiScanManager.h similarity index 97% rename from include/wifi/WiFiScanManager.h rename to components/core/include/wifi/WiFiScanManager.h index 691c03ba..c6fc4327 100644 --- a/include/wifi/WiFiScanManager.h +++ b/components/core/include/wifi/WiFiScanManager.h @@ -6,6 +6,7 @@ #include #include +#include namespace OpenShock::WiFiScanManager { [[nodiscard]] bool Init(); diff --git a/include/wifi/WiFiScanStatus.h b/components/core/include/wifi/WiFiScanStatus.h similarity index 100% rename from include/wifi/WiFiScanStatus.h rename to components/core/include/wifi/WiFiScanStatus.h diff --git a/components/core/src/AppHooks.cpp b/components/core/src/AppHooks.cpp new file mode 100644 index 00000000..ef2779b3 --- /dev/null +++ b/components/core/src/AppHooks.cpp @@ -0,0 +1,66 @@ +#include "AppHooks.h" + +#include + +using namespace OpenShock; + +namespace { + struct CaptivePortalHooks { + AppHooks::BroadcastMessageBINFn broadcastMessageBIN; + AppHooks::SetAlwaysEnabledFn setAlwaysEnabled; + }; + struct OtaUpdateManagerHooks { + AppHooks::GetFirmwareBootTypeFn getFirmwareBootType; + AppHooks::TryStartFirmwareUpdateFn tryStartFirmwareUpdate; + AppHooks::RequestUpdateCheckFn requestUpdateCheck; + }; +} // namespace + +// Each subsystem registers once, from its Init(). The set is filled in first and then published through one pointer, +// so a caller on another task sees either no hooks or all of them. +static CaptivePortalHooks s_captivePortalHooks; +static OtaUpdateManagerHooks s_otaUpdateManagerHooks; +static std::atomic s_captivePortal = nullptr; +static std::atomic s_otaUpdateManager = nullptr; + +void AppHooks::RegisterCaptivePortal(BroadcastMessageBINFn broadcastMessageBIN, SetAlwaysEnabledFn setAlwaysEnabled) +{ + s_captivePortalHooks = {broadcastMessageBIN, setAlwaysEnabled}; + s_captivePortal.store(&s_captivePortalHooks, std::memory_order_release); +} + +void AppHooks::RegisterOtaUpdateManager(GetFirmwareBootTypeFn getFirmwareBootType, TryStartFirmwareUpdateFn tryStartFirmwareUpdate, RequestUpdateCheckFn requestUpdateCheck) +{ + s_otaUpdateManagerHooks = {getFirmwareBootType, tryStartFirmwareUpdate, requestUpdateCheck}; + s_otaUpdateManager.store(&s_otaUpdateManagerHooks, std::memory_order_release); +} + +bool AppHooks::CaptivePortalBroadcastMessageBIN(std::span data) +{ + auto hooks = s_captivePortal.load(std::memory_order_acquire); + return hooks != nullptr && hooks->broadcastMessageBIN != nullptr && hooks->broadcastMessageBIN(data); +} + +void AppHooks::CaptivePortalSetAlwaysEnabled(bool alwaysEnabled) +{ + auto hooks = s_captivePortal.load(std::memory_order_acquire); + if (hooks != nullptr && hooks->setAlwaysEnabled != nullptr) hooks->setAlwaysEnabled(alwaysEnabled); +} + +FirmwareBootType AppHooks::OtaGetFirmwareBootType() +{ + auto hooks = s_otaUpdateManager.load(std::memory_order_acquire); + return hooks != nullptr && hooks->getFirmwareBootType != nullptr ? hooks->getFirmwareBootType() : FirmwareBootType::Normal; +} + +bool AppHooks::OtaTryStartFirmwareUpdate(const OpenShock::SemVer& version) +{ + auto hooks = s_otaUpdateManager.load(std::memory_order_acquire); + return hooks != nullptr && hooks->tryStartFirmwareUpdate != nullptr && hooks->tryStartFirmwareUpdate(version); +} + +bool AppHooks::OtaRequestUpdateCheck() +{ + auto hooks = s_otaUpdateManager.load(std::memory_order_acquire); + return hooks != nullptr && hooks->requestUpdateCheck != nullptr && hooks->requestUpdateCheck(); +} diff --git a/components/core/src/GatewayClient.cpp b/components/core/src/GatewayClient.cpp new file mode 100644 index 00000000..f248907f --- /dev/null +++ b/components/core/src/GatewayClient.cpp @@ -0,0 +1,284 @@ +#include "GatewayClient.h" + +const char* const TAG = "GatewayClient"; + +#include "AppHooks.h" +#include "config/Config.h" +#include "events/Events.h" +#include "Logging.h" +#include "message_handlers/WebSocket.h" +#include "OpenShock.h" +#include "serialization/WSGateway.h" +#include "Temporal.h" +#include "visual/VisualStateManager.h" + +#include + +#include + +using namespace OpenShock; + +const int64_t GATEWAY_PING_TIMEOUT = 90'000; + +// Message handlers (config reads, flash writes, FlatBuffers verification) run on the websocket task. +const int GATEWAY_TASK_STACK_SIZE = 6 * 1024; + +// Upper bound for a reassembled message: the gateway message handler limit. +const std::size_t GATEWAY_MAX_MESSAGE_SIZE = OpenShock::MessageHandlers::WebSocket::MaxGatewayMessageSize; + +// WebSocket opcodes (RFC 6455) as delivered by esp_websocket_client event data. +static constexpr int WS_OP_TEXT = 0x01; +static constexpr int WS_OP_BINARY = 0x02; + +static bool s_bootStatusSent = false; + +GatewayClient::GatewayClient(const std::string& authToken) + : m_headers() + , m_client(nullptr) + , m_state(GatewayClientState::Disconnected) + , m_lastPingTimestamp(0) + , m_retired(false) + , m_binReasm() + , m_binReasmActive(false) + , m_binReasmDiscard(false) +{ + OS_LOGD(TAG, "Creating GatewayClient"); + + m_headers = std::string("Firmware-Version: ") + OpenShock::Constants::FW_VERSION + "\r\nDevice-Token: " + authToken + "\r\n"; +} +GatewayClient::~GatewayClient() +{ + OS_LOGD(TAG, "Destroying GatewayClient"); + + _setState(GatewayClientState::Disconnected); + + if (m_client != nullptr) { + esp_websocket_client_close(m_client, pdMS_TO_TICKS(1000)); + } + _destroyHandle(); +} + +void GatewayClient::_destroyHandle() +{ + if (m_client == nullptr) { + return; + } + + // Stops the client task if still running and frees the handle; no further events reach `this` afterwards. + esp_websocket_client_destroy(m_client); + m_client = nullptr; +} + +void GatewayClient::connect(const std::string& host, uint16_t port, const std::string& path) +{ + if (m_state != GatewayClientState::Disconnected) { + return; + } + + // A previous connection's task exits on disconnect but its handle stays allocated; free it before reconnecting. + _destroyHandle(); + + _setState(GatewayClientState::Connecting); + + esp_websocket_client_config_t config = {}; + config.host = host.c_str(); + config.port = port; + config.path = path.c_str(); + config.transport = WEBSOCKET_TRANSPORT_OVER_SSL; + config.user_agent = OpenShock::Constants::FW_USERAGENT; + config.headers = m_headers.c_str(); + config.disable_auto_reconnect = true; // GatewayConnectionManager owns reconnection + config.crt_bundle_attach = esp_crt_bundle_attach; // verify server against the compiled-in CA bundle + config.task_stack = GATEWAY_TASK_STACK_SIZE; + + m_client = esp_websocket_client_init(&config); + if (m_client == nullptr) { + OS_LOGE(TAG, "Failed to initialize WebSocket client"); + _setState(GatewayClientState::Disconnected); + return; + } + + esp_websocket_register_events(m_client, WEBSOCKET_EVENT_ANY, &GatewayClient::_eventHandler, this); + + esp_err_t err = esp_websocket_client_start(m_client); + if (err != ESP_OK) { + OS_LOGE(TAG, "Failed to start WebSocket client: %s", esp_err_to_name(err)); + _destroyHandle(); + _setState(GatewayClientState::Disconnected); + return; + } +} + +bool GatewayClient::sendMessageTXT(std::string_view data) +{ + if (m_state != GatewayClientState::Connected || m_client == nullptr) { + return false; + } + + return esp_websocket_client_send_text(m_client, data.data(), data.length(), pdMS_TO_TICKS(10'000)) >= 0; +} + +bool GatewayClient::sendMessageBIN(std::span data) +{ + if (m_state != GatewayClientState::Connected || m_client == nullptr) { + return false; + } + + return esp_websocket_client_send_bin(m_client, reinterpret_cast(data.data()), data.size(), pdMS_TO_TICKS(10'000)) >= 0; +} + +void GatewayClient::markPingReceived() +{ + m_lastPingTimestamp = OpenShock::millis(); +} + +bool GatewayClient::loop() +{ + if (m_state == GatewayClientState::Disconnected) { + return false; + } + + // esp_websocket_client runs its own task; we only enforce the app-level ping timeout. + if (m_state != GatewayClientState::Connected) { + // Still connecting or disconnecting + return true; + } + + // Timestamp is seeded on connect, so a connection that never sees a ping also times out. + if ((OpenShock::millis() - m_lastPingTimestamp.load()) > GATEWAY_PING_TIMEOUT) { + OS_LOGW(TAG, "No ping received from gateway for %lld ms, forcing reconnect", GATEWAY_PING_TIMEOUT); + // The link is presumed dead, so skip the close handshake; destroy stops the client task and frees the handle. + _destroyHandle(); + _setState(GatewayClientState::Disconnected); + return false; + } + + return true; +} + +void GatewayClient::_setState(GatewayClientState state) +{ + if (m_state.exchange(state) == state) { + return; + } + + // Bounded wait: never block the websocket task or the owning task indefinitely on a full event queue. + esp_err_t err = esp_event_post(OPENSHOCK_EVENTS, OPENSHOCK_EVENT_GATEWAY_CLIENT_STATE_CHANGED, &state, sizeof(state), pdMS_TO_TICKS(100)); + if (err != ESP_OK) { + OS_LOGE(TAG, "Failed to post gateway client state change: %s", esp_err_to_name(err)); + } +} + +void GatewayClient::_sendBootStatus() +{ + if (s_bootStatusSent) return; + + OS_LOGV(TAG, "Sending Gateway boot status message"); + + int32_t updateId; + if (!Config::GetOtaUpdateId(updateId)) { + OS_LOGE(TAG, "Failed to get OTA update ID"); + return; + } + + OpenShock::OtaUpdateStep updateStep; + if (!Config::GetOtaUpdateStep(updateStep)) { + OS_LOGE(TAG, "Failed to get OTA firmware boot type"); + return; + } + + s_bootStatusSent = Serialization::Gateway::SerializeBootStatusMessage(updateId, AppHooks::OtaGetFirmwareBootType(), [this](std::span data) { return sendMessageBIN(data); }); + + if (s_bootStatusSent && updateStep != OpenShock::OtaUpdateStep::None) { + if (!Config::SetOtaUpdateStep(OpenShock::OtaUpdateStep::None)) { + OS_LOGE(TAG, "Failed to reset firmware boot type to normal"); + } + } +} + +void GatewayClient::_eventHandler(void* arg, esp_event_base_t /*base*/, int32_t eventId, void* eventData) +{ + auto* self = static_cast(arg); + const auto* data = static_cast(eventData); + + switch (eventId) { + case WEBSOCKET_EVENT_CONNECTED: + self->m_lastPingTimestamp = OpenShock::millis(); + self->_setState(GatewayClientState::Connected); + self->_sendBootStatus(); + break; + case WEBSOCKET_EVENT_DISCONNECTED: + case WEBSOCKET_EVENT_CLOSED: + self->_setState(GatewayClientState::Disconnected); + break; + case WEBSOCKET_EVENT_ERROR: + OS_LOGE(TAG, "Received error from API"); + break; + case WEBSOCKET_EVENT_DATA: + if (!self->m_retired.load()) { + self->_handleData(data); + } + break; + default: + break; + } +} + +void GatewayClient::_handleData(const esp_websocket_event_data_t* data) +{ + if (data == nullptr) { + return; + } + + // Control frames (ping/pong/close) and empty frames carry no application payload. + if (data->op_code == WS_OP_TEXT) { + OS_LOGW(TAG, "Received text from API, JSON parsing is not supported anymore :D"); + return; + } + if (data->op_code != WS_OP_BINARY && data->op_code != 0x00 /* continuation */) { + return; + } + + // esp_websocket_client delivers each frame in chunks (payload_offset/data_len of payload_len), + // and a message may span several frames (continuation opcode, fin on the last one). + bool frameComplete = static_cast(data->payload_offset) + data->data_len >= static_cast(data->payload_len); + bool messageEnd = frameComplete && data->fin; + + // Fast path: a complete single-frame message. + if (data->op_code == WS_OP_BINARY && data->payload_offset == 0 && messageEnd) { + m_binReasmActive = false; + m_binReasmDiscard = false; + MessageHandlers::WebSocket::HandleGatewayBinary(std::span(reinterpret_cast(data->data_ptr), data->data_len)); + return; + } + + if (data->op_code == WS_OP_BINARY && data->payload_offset == 0) { + // Start of a new message + m_binReasm.clear(); + m_binReasmActive = true; + m_binReasmDiscard = false; + } else if (!m_binReasmActive) { + OS_LOGW(TAG, "Dropping continuation data without a message start"); + return; + } + + if (!m_binReasmDiscard) { + if (m_binReasm.size() + data->data_len > GATEWAY_MAX_MESSAGE_SIZE) { + OS_LOGE(TAG, "Fragmented message exceeds %zu bytes, dropping it", GATEWAY_MAX_MESSAGE_SIZE); + m_binReasm.clear(); + m_binReasm.shrink_to_fit(); + m_binReasmDiscard = true; + } else { + m_binReasm.insert(m_binReasm.end(), reinterpret_cast(data->data_ptr), reinterpret_cast(data->data_ptr) + data->data_len); + } + } + + if (messageEnd) { + if (!m_binReasmDiscard) { + MessageHandlers::WebSocket::HandleGatewayBinary(std::span(m_binReasm.data(), m_binReasm.size())); + } + m_binReasm.clear(); + m_binReasmActive = false; + m_binReasmDiscard = false; + } +} diff --git a/components/core/src/GatewayClient.h b/components/core/src/GatewayClient.h new file mode 100644 index 00000000..8070b684 --- /dev/null +++ b/components/core/src/GatewayClient.h @@ -0,0 +1,59 @@ +#pragma once + +#include "enums/GatewayClientState.h" +#include "OpenShock.h" + +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace OpenShock { + class GatewayClient { + DISABLE_COPY(GatewayClient); + DISABLE_MOVE(GatewayClient); + + public: + GatewayClient(const std::string& authToken); + ~GatewayClient(); + + inline GatewayClientState state() const { return m_state; } + + void connect(const std::string& host, uint16_t port, const std::string& path); + + bool sendMessageTXT(std::string_view data); + bool sendMessageBIN(std::span data); + + void markPingReceived(); + + // Stops dispatching received messages; used when the client is taken out of service ahead of destruction. + inline void retire() { m_retired.store(true); } + + bool loop(); + + private: + void _setState(GatewayClientState state); + void _sendBootStatus(); + + static void _eventHandler(void* arg, esp_event_base_t base, int32_t eventId, void* eventData); + void _handleData(const esp_websocket_event_data_t* data); + + void _destroyHandle(); + + std::string m_headers; + esp_websocket_client_handle_t m_client; // Only touched from the owning (main) task + std::atomic m_state; // Written from both the owning task and the websocket task + std::atomic m_lastPingTimestamp; + std::atomic m_retired; + + // Reassembly of fragmented/chunked binary messages, only touched from the websocket task + std::vector m_binReasm; + bool m_binReasmActive; + bool m_binReasmDiscard; + }; +} // namespace OpenShock diff --git a/src/GatewayConnectionManager.cpp b/components/core/src/GatewayConnectionManager.cpp similarity index 65% rename from src/GatewayConnectionManager.cpp rename to components/core/src/GatewayConnectionManager.cpp index e6640378..2391cb2b 100644 --- a/src/GatewayConnectionManager.cpp +++ b/components/core/src/GatewayConnectionManager.cpp @@ -4,33 +4,24 @@ const char* const TAG = "GatewayConnectionManager"; #include "visual/VisualStateManager.h" -#include "captiveportal/Manager.h" +#include "AppHooks.h" #include "config/Config.h" -#include "Core.h" +#include "events/Events.h" #include "GatewayClient.h" #include "http/JsonAPI.h" #include "Logging.h" #include "serialization/WSLocal.h" +#include "Temporal.h" #include "SimpleMutex.h" +#include "util/Backoff.h" +#include "util/RetiredList.h" +#include #include #include #include - -// -// ###### ######## ###### ## ## ######## #### ######## ## ## ######## #### ###### ## ## -// ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## -// ## ## ## ## ## ## ## ## ## #### ## ## ## ## ## ## -// ###### ###### ## ## ## ######## ## ## ## ######## ## ###### ##### -// ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## -// ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## -// ###### ######## ###### ####### ## ## #### ## ## ## ## #### ###### ## ## -// -// TODO: Fix loading CA Certificate bundles, currently fails with "[esp_crt_bundle.c:161] esp_crt_bundle_init(): Unable to allocate memory for bundle" -// This is probably due to the fact that the bundle is too large for the ESP32's heap or the bundle is incorrectly packedy them -// -#warning SSL certificate verification is currently not implemented, by RFC definition this is a security risk, and allows for MITM attacks, but the realistic risk is low +#include const char* const AUTH_TOKEN_FILE = "/authToken"; @@ -40,12 +31,19 @@ const uint8_t FLAG_LINKED = 1 << 1; const uint8_t LINK_CODE_LENGTH = 6; -static std::atomic s_flags = 0; -static std::atomic s_lastAuthFailure = 0; -static std::atomic s_lastConnectionAttempt = 0; -static std::atomic_flag s_isInitializing = ATOMIC_FLAG_INIT; +static std::atomic s_flags = 0; + +// Retry timing, owned by the main task (Update()). Other tasks ask for a reset through s_retryResetRequested. +static OpenShock::Backoff s_authBackoff(300'000, 300'000); // After the backend rejects the token: 5 minutes +static OpenShock::Backoff s_connectBackoff(20'000, 20'000); // Between LCG connection attempts: 20 seconds +static OpenShock::Backoff s_hubInfoBackoff(5'000, 300'000); // After a failed hub info fetch: 5 s doubling up to 5 min +static std::atomic s_retryResetRequested = false; static OpenShock::SimpleMutex s_clientMutex; static std::shared_ptr s_wsClient = nullptr; +// Clients taken out of service but not yet destroyed. Destruction blocks (websocket close + task stop), so it is +// deferred to the main task (Update()) instead of running under s_clientMutex, on the event loop, or on the websocket +// task (where stopping the client from inside its own task is not allowed). +static OpenShock::RetiredList s_retiredClients; static std::shared_ptr GetClient() { @@ -60,33 +58,31 @@ static void CreateClient(const std::string& authToken) static void DestroyClient() { OpenShock::ScopedLock lock__(&s_clientMutex); - s_wsClient = nullptr; -} - -static void evh_gotIP(arduino_event_t* event) -{ - (void)event; - - s_flags.fetch_or(FLAG_HAS_IP, std::memory_order_relaxed); - OS_LOGD(TAG, "Got IP address"); -} - -static void evh_wiFiDisconnected(arduino_event_t* event) -{ - (void)event; - - s_flags.store(FLAG_NONE, std::memory_order_relaxed); - DestroyClient(); - OS_LOGD(TAG, "Lost IP address"); + if (s_wsClient != nullptr) { + s_wsClient->retire(); + s_retiredClients.retire(std::move(s_wsClient)); // Leaves s_wsClient null + } } -static bool checkIsDeAuthRateLimited(int64_t millis) -{ - return s_lastAuthFailure != 0 && (millis - s_lastAuthFailure) < 300'000; // 5 Minutes -} -static bool checkIsConnectionRateLimited(int64_t millis) +static void handleWiFiStateChanged(void* arg, esp_event_base_t base, int32_t id, void* data) { - return s_lastConnectionAttempt != 0 && (millis - s_lastConnectionAttempt) < 20'000; // 20 seconds + (void)arg; + (void)base; + (void)id; + + switch (*static_cast(data)) { + case OPENSHOCK_WIFI_STATE_GOT_IP: + s_flags.fetch_or(FLAG_HAS_IP, std::memory_order_relaxed); + OS_LOGD(TAG, "Got IP address"); + break; + case OPENSHOCK_WIFI_STATE_DISCONNECTED: + s_flags.store(FLAG_NONE, std::memory_order_relaxed); + DestroyClient(); + OS_LOGD(TAG, "Lost IP address"); + break; + default: + break; + } } using namespace OpenShock; @@ -94,9 +90,11 @@ namespace JsonAPI = OpenShock::Serialization::JsonAPI; bool GatewayConnectionManager::Init() { - WiFi.onEvent(evh_gotIP, ARDUINO_EVENT_WIFI_STA_GOT_IP); - WiFi.onEvent(evh_gotIP, ARDUINO_EVENT_WIFI_STA_GOT_IP6); - WiFi.onEvent(evh_wiFiDisconnected, ARDUINO_EVENT_WIFI_STA_DISCONNECTED); + esp_err_t err = esp_event_handler_register(OPENSHOCK_EVENTS, OPENSHOCK_EVENT_WIFI_STATE_CHANGED, handleWiFiStateChanged, nullptr); + if (err != ESP_OK) { + OS_LOGE(TAG, "Failed to register WiFi state event handler: %s", esp_err_to_name(err)); + return false; + } return true; } @@ -122,9 +120,7 @@ AccountLinkResultCode GatewayConnectionManager::Link(std::string_view linkCode) return AccountLinkResultCode::NoInternetConnection; } - DestroyClient(); - - OS_LOGD(TAG, "Attempting to link to account using code %.*s", linkCode.length(), linkCode.data()); + OS_LOGD(TAG, "Attempting to link to account using code %.*s", static_cast(linkCode.length()), linkCode.data()); if (linkCode.length() != LINK_CODE_LENGTH) { OS_LOGE(TAG, "Invalid link code length: expected %zu, got %zu", static_cast(LINK_CODE_LENGTH), linkCode.length()); @@ -173,6 +169,10 @@ AccountLinkResultCode GatewayConnectionManager::Link(std::string_view linkCode) return AccountLinkResultCode::ConfigSaveFailed; } + // Only drop the existing connection once the new token is in place; a failed link keeps the current session. + s_retryResetRequested = true; // A new token deserves an immediate check, whatever the old one's backoff + DestroyClient(); + s_flags.fetch_or(FLAG_LINKED, std::memory_order_relaxed); OS_LOGD(TAG, "Successfully linked to account"); @@ -184,6 +184,19 @@ void GatewayConnectionManager::UnLink() DestroyClient(); Config::ClearBackendAuthToken(); } +bool GatewayConnectionManager::SetAuthToken(std::string authToken) +{ + if (!Config::SetBackendAuthToken(std::move(authToken))) { + return false; + } + + // Drop the session using the old token; the main task verifies the new one and reconnects (and broadcasts the link status). + s_flags.fetch_and(static_cast(~FLAG_LINKED), std::memory_order_relaxed); + s_retryResetRequested = true; // A new token deserves an immediate check + DestroyClient(); + + return true; +} bool GatewayConnectionManager::SendMessageTXT(std::string_view data) { @@ -195,7 +208,7 @@ bool GatewayConnectionManager::SendMessageTXT(std::string_view data) return client->sendMessageTXT(data); } -bool GatewayConnectionManager::SendMessageBIN(tcb::span data) +bool GatewayConnectionManager::SendMessageBIN(std::span data) { auto client = GetClient(); if (client == nullptr) { @@ -215,14 +228,14 @@ void GatewayConnectionManager::MarkPingReceived() client->markPingReceived(); } -bool FetchHubInfo(std::string authToken) +static bool FetchHubInfo(std::string authToken) { // TODO: this function is very slow, should be optimized! if ((s_flags.load(std::memory_order_relaxed) & FLAG_HAS_IP) == 0) { return false; } - if (checkIsDeAuthRateLimited(OpenShock::millis())) { + if (!s_authBackoff.ready(OpenShock::millis())) { return false; } @@ -230,7 +243,7 @@ bool FetchHubInfo(std::string authToken) if (response.code == 401) { OS_LOGD(TAG, "Auth token is invalid, waiting 5 minutes before checking again"); - s_lastAuthFailure = OpenShock::millis(); + s_authBackoff.backOff(OpenShock::millis()); return false; } @@ -251,15 +264,13 @@ bool FetchHubInfo(std::string authToken) OS_LOGI(TAG, "Hub Name: %s", response.data.hubName.c_str()); OS_LOGI(TAG, "Shockers:"); for (auto& shocker : response.data.shockers) { - OS_LOGI(TAG, " [%s] rf=%u model=%u", shocker.id.c_str(), shocker.rfId, shocker.model); + OS_LOGI(TAG, " [%s] rf=%u model=%u", shocker.id.c_str(), shocker.rfId, static_cast(shocker.model)); } - s_flags.fetch_or(FLAG_LINKED, std::memory_order_relaxed); - return true; } -bool StartConnectingToLCG() +static bool StartConnectingToLCG() { auto client = GetClient(); if (client == nullptr) { @@ -268,16 +279,14 @@ bool StartConnectingToLCG() } if (client->state() != GatewayClientState::Disconnected) { - OS_LOGD(TAG, "WebSocketClient is not disconnected, waiting..."); - client->disconnect(); return false; } int64_t msNow = OpenShock::millis(); - if (checkIsDeAuthRateLimited(msNow) || checkIsConnectionRateLimited(msNow)) { + if (!s_authBackoff.ready(msNow) || !s_connectBackoff.ready(msNow)) { return false; } - s_lastConnectionAttempt = msNow; + s_connectBackoff.backOff(msNow); if (!Config::HasBackendAuthToken()) { OS_LOGD(TAG, "No auth token, can't connect to LCG"); @@ -294,7 +303,7 @@ bool StartConnectingToLCG() if (response.code == 401) { OS_LOGD(TAG, "Auth token is invalid, waiting 5 minutes before retrying"); - s_lastAuthFailure = OpenShock::millis(); + s_authBackoff.backOff(OpenShock::millis()); return false; } @@ -317,7 +326,7 @@ bool StartConnectingToLCG() return true; } -void InitializeClient() +static void InitializeClient() { DestroyClient(); @@ -326,6 +335,11 @@ void InitializeClient() return; } + int64_t now = OpenShock::millis(); + if (!s_authBackoff.ready(now) || !s_hubInfoBackoff.ready(now)) { + return; // Checked here so an auth hold-off doesn't also count as a hub info failure and stack the two backoffs + } + std::string authToken; if (!Config::GetBackendAuthToken(authToken)) { OS_LOGE(TAG, "Failed to get auth token"); @@ -333,19 +347,36 @@ void InitializeClient() } if (!FetchHubInfo(authToken)) { + // Back off on failure so an unreachable backend isn't polled on every update tick. + s_hubInfoBackoff.backOff(OpenShock::millis()); + return; + } + s_hubInfoBackoff.reset(); + + // The token may have been cleared or replaced (UnLink / Link) while the request was in flight. + std::string currentToken; + if (!Config::GetBackendAuthToken(currentToken) || currentToken != authToken) { + OS_LOGD(TAG, "Auth token changed while verifying it, discarding result"); return; } s_flags.fetch_or(FLAG_LINKED, std::memory_order_relaxed); OS_LOGD(TAG, "Successfully verified auth token"); - Serialization::Local::SerializeAccountLinkStatusEvent(true, CaptivePortal::BroadcastMessageBIN); + Serialization::Local::SerializeAccountLinkStatusEvent(true, AppHooks::CaptivePortalBroadcastMessageBIN); CreateClient(authToken); } void GatewayConnectionManager::Update() { + s_retiredClients.reap(); + + if (s_retryResetRequested.exchange(false)) { + s_authBackoff.reset(); + s_hubInfoBackoff.reset(); + } + auto client = GetClient(); if (client != nullptr) { // Client exists — run its loop and optionally reconnect @@ -357,12 +388,6 @@ void GatewayConnectionManager::Update() return; } - if (s_isInitializing.test_and_set()) { - OS_LOGE(TAG, "Was about to initialize GatewayClient, but encountered race condition, yielding."); - return; - } - + // Only ever called from the main task, so initialization can't race with itself. InitializeClient(); - - s_isInitializing.clear(); } diff --git a/components/core/src/http/JsonAPI.cpp b/components/core/src/http/JsonAPI.cpp new file mode 100644 index 00000000..c5a61c4a --- /dev/null +++ b/components/core/src/http/JsonAPI.cpp @@ -0,0 +1,78 @@ +#include "http/JsonAPI.h" + +const char* const TAG = "JsonAPI"; + +#include "config/Config.h" +#include "Logging.h" +#include "OpenShock.h" +#include "StringHelpers.h" + +using namespace OpenShock; + +// "https://" +static bool tryGetBackendUri(std::string& uri, std::string_view path) +{ + std::string domain; + if (!Config::GetBackendDomain(domain)) { + return false; + } + + uri.reserve(8 + domain.size() + path.size()); + uri = "https://"; + uri += domain; + uri += path; + return true; +} + +HTTP::Response HTTP::JsonAPI::LinkAccount(std::string_view accountLinkCode) +{ + std::string uri; + if (!tryGetBackendUri(uri, std::string("/1/device/pair/") + std::string(accountLinkCode))) { + return {HTTP::RequestResult::InternalError, 0, {}}; + } + + return HTTP::GetJSON( + uri, + { + {"Accept", "application/json"} + }, + Serialization::JsonAPI::ParseAccountLinkJsonResponse, + std::array {200} + ); +} + +HTTP::Response HTTP::JsonAPI::GetHubInfo(std::string_view hubToken) +{ + std::string uri; + if (!tryGetBackendUri(uri, "/1/device/self")) { + return {HTTP::RequestResult::InternalError, 0, {}}; + } + + return HTTP::GetJSON( + uri, + { + { "Accept", "application/json"}, + {"DeviceToken", std::string(hubToken)} + }, + Serialization::JsonAPI::ParseHubInfoJsonResponse, + std::array {200} + ); +} + +HTTP::Response HTTP::JsonAPI::AssignLcg(std::string_view hubToken) +{ + std::string uri; + if (!tryGetBackendUri(uri, "/2/device/assignLCG?version=2")) { + return {HTTP::RequestResult::InternalError, 0, {}}; + } + + return HTTP::GetJSON( + uri, + { + { "Accept", "application/json"}, + {"DeviceToken", std::string(hubToken)} + }, + Serialization::JsonAPI::ParseAssignLcgJsonResponse, + std::array {200} + ); +} diff --git a/src/message_handlers/ShockerCommandList.cpp b/components/core/src/message_handlers/ShockerCommandList.cpp similarity index 94% rename from src/message_handlers/ShockerCommandList.cpp rename to components/core/src/message_handlers/ShockerCommandList.cpp index 561f50c7..a47249eb 100644 --- a/src/message_handlers/ShockerCommandList.cpp +++ b/components/core/src/message_handlers/ShockerCommandList.cpp @@ -3,8 +3,8 @@ const char* const TAG = "ShockerCommandHandler"; #include "CommandHandler.h" +#include "enums/ShockerModelType.h" #include "Logging.h" -#include "ShockerModelType.h" #include @@ -19,7 +19,7 @@ void OpenShock::MessageHandlers::HandleShockerCommandList(const OpenShock::Seria return; } - OS_LOGV(TAG, "Received command list (%u commands)", commands->size()); + OS_LOGV(TAG, "Received command list (%u commands)", static_cast(commands->size())); for (auto command : *commands) { uint16_t id = command->id(); diff --git a/include/message_handlers/ShockerCommandList.h b/components/core/src/message_handlers/ShockerCommandList.h similarity index 100% rename from include/message_handlers/ShockerCommandList.h rename to components/core/src/message_handlers/ShockerCommandList.h diff --git a/include/message_handlers/impl/WSGateway.h b/components/core/src/message_handlers/impl/WSGateway.h similarity index 100% rename from include/message_handlers/impl/WSGateway.h rename to components/core/src/message_handlers/impl/WSGateway.h diff --git a/include/message_handlers/impl/WSLocal.h b/components/core/src/message_handlers/impl/WSLocal.h similarity index 100% rename from include/message_handlers/impl/WSLocal.h rename to components/core/src/message_handlers/impl/WSLocal.h diff --git a/src/message_handlers/websocket/Gateway.cpp b/components/core/src/message_handlers/websocket/Gateway.cpp similarity index 68% rename from src/message_handlers/websocket/Gateway.cpp rename to components/core/src/message_handlers/websocket/Gateway.cpp index 711604ca..c00be067 100644 --- a/src/message_handlers/websocket/Gateway.cpp +++ b/components/core/src/message_handlers/websocket/Gateway.cpp @@ -7,8 +7,7 @@ const char* const TAG = "ServerMessageHandlers"; #include "Logging.h" #include "serialization/_fbs/GatewayToHubMessage_generated.h" - -#include +#include "serialization/VerifiedRoot.h" #include #include @@ -37,26 +36,14 @@ static std::array s_serverHandlers = []() return handlers; }(); -void MessageHandlers::WebSocket::HandleGatewayBinary(tcb::span data) +void MessageHandlers::WebSocket::HandleGatewayBinary(std::span data) { - if (data.size() < sizeof(flatbuffers::uoffset_t)) { - OS_LOGE(TAG, "Message too small to be a valid FlatBuffer"); - return; - } - - // Validate buffer - flatbuffers::Verifier::Options verifierOptions { - .max_size = 4096, // TODO: Profile this - }; - flatbuffers::Verifier verifier(data.data(), data.size(), verifierOptions); - if (!verifier.VerifyBuffer()) { - OS_LOGE(TAG, "Failed to verify message"); + auto msg = Serialization::GetVerifiedRoot(data, MaxGatewayMessageSize); + if (msg == nullptr) { + OS_LOGE(TAG, "Invalid message (%zu bytes, max %zu)", data.size(), MaxGatewayMessageSize); return; } - // Deserialize (safe after verification) - auto msg = flatbuffers::GetRoot(data.data()); - if (msg->payload_type() < PayloadType::MIN || msg->payload_type() > PayloadType::MAX) { Handlers::HandleInvalidMessage(msg); return; diff --git a/src/message_handlers/websocket/Local.cpp b/components/core/src/message_handlers/websocket/Local.cpp similarity index 66% rename from src/message_handlers/websocket/Local.cpp rename to components/core/src/message_handlers/websocket/Local.cpp index 8b9cebac..5392a9f4 100644 --- a/src/message_handlers/websocket/Local.cpp +++ b/components/core/src/message_handlers/websocket/Local.cpp @@ -6,8 +6,7 @@ const char* const TAG = "LocalMessageHandlers"; #include "message_handlers/impl/WSLocal.h" #include "serialization/_fbs/LocalToHubMessage_generated.h" - -#include +#include "serialization/VerifiedRoot.h" #include #include @@ -31,26 +30,14 @@ static std::array s_localHandlers = []() { return handlers; }(); -void MessageHandlers::WebSocket::HandleLocalBinary(uint8_t socketId, tcb::span data) +void MessageHandlers::WebSocket::HandleLocalBinary(uint8_t socketId, std::span data) { - if (data.size() < sizeof(flatbuffers::uoffset_t)) { - OS_LOGE(TAG, "Message too small to be a valid FlatBuffer"); + auto msg = Serialization::GetVerifiedRoot(data, MaxLocalMessageSize); + if (msg == nullptr) { + OS_LOGE(TAG, "Invalid message (%zu bytes, max %zu)", data.size(), MaxLocalMessageSize); return; } - // Validate buffer - flatbuffers::Verifier::Options verifierOptions { - .max_size = 4096, // TODO: Profile this - }; - flatbuffers::Verifier verifier(data.data(), data.size(), verifierOptions); - if (!verifier.VerifyBuffer()) { - OS_LOGE(TAG, "Failed to verify message"); - return; - } - - // Deserialize (safe after verification) - auto msg = flatbuffers::GetRoot(data.data()); - if (msg->payload_type() < PayloadType::MIN || msg->payload_type() > PayloadType::MAX) { Handlers::HandleInvalidMessage(socketId, msg); return; diff --git a/src/message_handlers/websocket/gateway/OtaUpdateRequest.cpp b/components/core/src/message_handlers/websocket/gateway/OtaUpdateRequest.cpp similarity index 90% rename from src/message_handlers/websocket/gateway/OtaUpdateRequest.cpp rename to components/core/src/message_handlers/websocket/gateway/OtaUpdateRequest.cpp index a990eef6..c8ea4981 100644 --- a/src/message_handlers/websocket/gateway/OtaUpdateRequest.cpp +++ b/components/core/src/message_handlers/websocket/gateway/OtaUpdateRequest.cpp @@ -2,9 +2,8 @@ const char* const TAG = "ServerMessageHandlers"; -#include "captiveportal/Manager.h" +#include "AppHooks.h" #include "Logging.h" -#include "OtaUpdateManager.h" #include @@ -36,7 +35,7 @@ void _Private::HandleOtaUpdateRequest(const OpenShock::Serialization::Gateway::G OS_LOGI(TAG, "OTA update requested for version %s", version.toString().c_str()); // TODO: This is abusing the SemVer::toString() method causing alot of string copies, fix this - if (!OpenShock::OtaUpdateManager::TryStartFirmwareUpdate(version)) { + if (!OpenShock::AppHooks::OtaTryStartFirmwareUpdate(version)) { OS_LOGE(TAG, "Failed to update firmware"); // TODO: Send error message to server return; } diff --git a/src/message_handlers/websocket/gateway/Ping.cpp b/components/core/src/message_handlers/websocket/gateway/Ping.cpp similarity index 100% rename from src/message_handlers/websocket/gateway/Ping.cpp rename to components/core/src/message_handlers/websocket/gateway/Ping.cpp diff --git a/src/message_handlers/websocket/gateway/ShockerCommandList.cpp b/components/core/src/message_handlers/websocket/gateway/ShockerCommandList.cpp similarity index 100% rename from src/message_handlers/websocket/gateway/ShockerCommandList.cpp rename to components/core/src/message_handlers/websocket/gateway/ShockerCommandList.cpp diff --git a/src/message_handlers/websocket/gateway/Trigger.cpp b/components/core/src/message_handlers/websocket/gateway/Trigger.cpp similarity index 88% rename from src/message_handlers/websocket/gateway/Trigger.cpp rename to components/core/src/message_handlers/websocket/gateway/Trigger.cpp index 794d50af..a467226e 100644 --- a/src/message_handlers/websocket/gateway/Trigger.cpp +++ b/components/core/src/message_handlers/websocket/gateway/Trigger.cpp @@ -2,7 +2,7 @@ const char* const TAG = "ServerMessageHandlers"; -#include "captiveportal/Manager.h" +#include "AppHooks.h" #include "estop/EStopManager.h" #include "Logging.h" @@ -37,10 +37,10 @@ void _Private::HandleTrigger(const OpenShock::Serialization::Gateway::GatewayToH EStopManager::SoftwareTrigger(); break; case TriggerType::CaptivePortalEnable: - OpenShock::CaptivePortal::SetAlwaysEnabled(true); + OpenShock::AppHooks::CaptivePortalSetAlwaysEnabled(true); break; case TriggerType::CaptivePortalDisable: - OpenShock::CaptivePortal::SetAlwaysEnabled(false); + OpenShock::AppHooks::CaptivePortalSetAlwaysEnabled(false); break; default: OS_LOGW(TAG, "Got unknown trigger type: %hhu", static_cast(triggerType)); diff --git a/src/message_handlers/websocket/gateway/_InvalidMessage.cpp b/components/core/src/message_handlers/websocket/gateway/_InvalidMessage.cpp similarity index 80% rename from src/message_handlers/websocket/gateway/_InvalidMessage.cpp rename to components/core/src/message_handlers/websocket/gateway/_InvalidMessage.cpp index 15ae23aa..e768d682 100644 --- a/src/message_handlers/websocket/gateway/_InvalidMessage.cpp +++ b/components/core/src/message_handlers/websocket/gateway/_InvalidMessage.cpp @@ -13,5 +13,5 @@ void _Private::HandleInvalidMessage(const OpenShock::Serialization::Gateway::Gat return; } - OS_LOGE(TAG, "Invalid message type: %u", root->payload_type()); + OS_LOGE(TAG, "Invalid message type: %u", static_cast(root->payload_type())); } diff --git a/src/message_handlers/websocket/local/Common_ShockerCommandList.cpp b/components/core/src/message_handlers/websocket/local/Common_ShockerCommandList.cpp similarity index 100% rename from src/message_handlers/websocket/local/Common_ShockerCommandList.cpp rename to components/core/src/message_handlers/websocket/local/Common_ShockerCommandList.cpp diff --git a/src/message_handlers/websocket/local/_InvalidMessage.cpp b/components/core/src/message_handlers/websocket/local/_InvalidMessage.cpp similarity index 81% rename from src/message_handlers/websocket/local/_InvalidMessage.cpp rename to components/core/src/message_handlers/websocket/local/_InvalidMessage.cpp index 8afe35fa..0da06335 100644 --- a/src/message_handlers/websocket/local/_InvalidMessage.cpp +++ b/components/core/src/message_handlers/websocket/local/_InvalidMessage.cpp @@ -15,5 +15,5 @@ void _Private::HandleInvalidMessage(uint8_t socketId, const OpenShock::Serializa return; } - OS_LOGE(TAG, "Invalid message type: %d", root->payload_type()); + OS_LOGE(TAG, "Invalid message type: %u", static_cast(root->payload_type())); } diff --git a/components/core/src/serialization/JsonAPI.cpp b/components/core/src/serialization/JsonAPI.cpp new file mode 100644 index 00000000..40e0da9a --- /dev/null +++ b/components/core/src/serialization/JsonAPI.cpp @@ -0,0 +1,202 @@ +#include "serialization/JsonAPI.h" + +const char* const TAG = "JsonAPI"; + +#include "Logging.h" + +#include + +// Log an invalid-response error together with the offending JSON, zero-copy +// straight out of the parse buffer (jsmn views are not NUL-terminated). +#define ESP_LOGJSONE(err, view) OS_LOGE(TAG, "Invalid JSON response (" err "): %.*s", static_cast((view).raw().size()), (view).raw().data()) + +using namespace OpenShock::Serialization; + +bool JsonAPI::ParseBackendVersionJsonResponse(int code, JSON::JsonView root, JsonAPI::BackendVersionResponse& out) +{ + (void)code; + + if (!root.isObject()) { + ESP_LOGJSONE("not an object", root); + return false; + } + + JSON::JsonView data = root["data"]; + if (!data.isObject()) { + ESP_LOGJSONE("value at 'data' is not an object", root); + return false; + } + + out = {}; + + std::string version; + if (!data["version"].tryGetStr(version)) { + ESP_LOGJSONE("value at 'data.version' is not a string", root); + return false; + } + + std::string commit; + if (!data["commit"].tryGetStr(commit)) { + ESP_LOGJSONE("value at 'data.commit' is not a string", root); + return false; + } + + std::string currentTime; + if (!data["currentTime"].tryGetStr(currentTime)) { + ESP_LOGJSONE("value at 'data.currentTime' is not a string", root); + return false; + } + + out.version.assign(version); + out.commit.assign(commit); + out.currentTime.assign(currentTime); + + return true; +} + +bool JsonAPI::ParseAccountLinkJsonResponse(int code, JSON::JsonView root, JsonAPI::AccountLinkResponse& out) +{ + (void)code; + + if (!root.isObject()) { + ESP_LOGJSONE("not an object", root); + return false; + } + + std::string data; + if (!root["data"].tryGetStr(data)) { + ESP_LOGJSONE("value at 'data' is not a string", root); + return false; + } + + out = {}; + + out.authToken.assign(data); + + return true; +} +bool JsonAPI::ParseHubInfoJsonResponse(int code, JSON::JsonView root, JsonAPI::HubInfoResponse& out) +{ + (void)code; + + if (!root.isObject()) { + ESP_LOGJSONE("not an object", root); + return false; + } + + JSON::JsonView data = root["data"]; + if (!data.isObject()) { + ESP_LOGJSONE("value at 'data' is not an object", root); + return false; + } + + std::string hubId; + if (!data["id"].tryGetStr(hubId)) { + ESP_LOGJSONE("value at 'data.id' is not a string", root); + return false; + } + + std::string hubName; + if (!data["name"].tryGetStr(hubName)) { + ESP_LOGJSONE("value at 'data.name' is not a string", root); + return false; + } + + JSON::JsonView hubShockers = data["shockers"]; + if (!hubShockers.isArray()) { + ESP_LOGJSONE("value at 'data.shockers' is not an array", root); + return false; + } + + out = {}; + + out.hubId.assign(hubId); + out.hubName.assign(hubName); + + if (out.hubId.empty() || out.hubName.empty()) { + ESP_LOGJSONE("value at 'data.id' or 'data.name' is empty", root); + return false; + } + + const int shockerCount = hubShockers.count(); + for (int i = 0; i < shockerCount; ++i) { + JSON::JsonView shocker = hubShockers.at(i); + + std::string shockerId; + if (!shocker["id"].tryGetStr(shockerId)) { + ESP_LOGJSONE("value at 'shocker.id' is not a string", shocker); + return false; + } + if (shockerId.empty()) { + ESP_LOGJSONE("value at 'shocker.id' is empty", shocker); + return false; + } + + int64_t shockerRfId; + if (!shocker["rfId"].tryGetI64(shockerRfId)) { + ESP_LOGJSONE("value at 'shocker.rfId' is not a number", shocker); + return false; + } + if (shockerRfId < 0 || shockerRfId > UINT16_MAX) { + ESP_LOGJSONE("value at 'shocker.rfId' is not a valid uint16_t", shocker); + return false; + } + uint16_t shockerRfIdU16 = static_cast(shockerRfId); + + std::string shockerModel; + if (!shocker["model"].tryGetStr(shockerModel)) { + ESP_LOGJSONE("value at 'shocker.model' is not a string", shocker); + return false; + } + if (shockerModel.empty()) { + ESP_LOGJSONE("value at 'shocker.model' is empty", shocker); + return false; + } + + OpenShock::ShockerModelType shockerModelType; + if (!OpenShock::TryParseShockerModelType(shockerModelType, shockerModel, true)) { // PetTrainer is a typo in the API, we pass true to allow it + ESP_LOGJSONE("value at 'shocker.model' is not a valid shocker model", shocker); + return false; + } + + out.shockers.push_back({.id = std::string(shockerId), .rfId = shockerRfIdU16, .model = shockerModelType}); + } + + return true; +} +bool JsonAPI::ParseAssignLcgJsonResponse(int code, JSON::JsonView root, JsonAPI::AssignLcgResponse& out) +{ + (void)code; + + if (!root.isObject()) { + ESP_LOGJSONE("not an object", root); + return false; + } + + std::string host; + std::string path; + std::string country; + if (!root["host"].tryGetStr(host) || !root["path"].tryGetStr(path) || !root["country"].tryGetStr(country)) { + ESP_LOGJSONE("value at 'host', 'path' or 'country' is not a string", root); + return false; + } + + int64_t port; + if (!root["port"].tryGetI64(port)) { + ESP_LOGJSONE("value at 'port' is not a number", root); + return false; + } + if (port < 0 || port > UINT16_MAX) { + ESP_LOGJSONE("value at 'port' is outside UINT16 bounds", root); + return false; + } + + out = {}; + + out.host.assign(host); + out.port = static_cast(port); + out.path.assign(path); + out.country.assign(country); + + return true; +} diff --git a/src/serialization/JsonSerial.cpp b/components/core/src/serialization/JsonSerial.cpp similarity index 62% rename from src/serialization/JsonSerial.cpp rename to components/core/src/serialization/JsonSerial.cpp index 237b6f1a..e76ebcd2 100644 --- a/src/serialization/JsonSerial.cpp +++ b/components/core/src/serialization/JsonSerial.cpp @@ -4,91 +4,96 @@ const char* const TAG = "JsonSerial"; #include "Logging.h" +#include + using namespace OpenShock::Serialization; -bool JsonSerial::ParseShockerCommand(const cJSON* root, JsonSerial::ShockerCommand& out) +bool JsonSerial::ParseShockerCommand(JSON::JsonView root, JsonSerial::ShockerCommand& out) { - if (cJSON_IsObject(root) == 0) { + if (!root.isObject()) { OS_LOGE(TAG, "not an object"); return false; } - const cJSON* model = cJSON_GetObjectItemCaseSensitive(root, "model"); - if (model == nullptr) { + JSON::JsonView model = root["model"]; + if (!model.valid()) { OS_LOGE(TAG, "missing 'model' field"); return false; } - if (cJSON_IsString(model) == 0) { + std::string modelStr; + if (!model.tryGetStr(modelStr)) { OS_LOGE(TAG, "value at 'model' is not a string"); return false; } ShockerModelType modelType = ShockerModelType::CaiXianlin; - if (!ShockerModelTypeFromString(model->valuestring, modelType)) { + if (!TryParseShockerModelType(modelType, modelStr)) { OS_LOGE(TAG, "value at 'model' is not a valid shocker model (caixianlin, petrainer, petrainer998dr, wellturnt330, d80)"); return false; } - const cJSON* id = cJSON_GetObjectItemCaseSensitive(root, "id"); - if (id == nullptr) { + JSON::JsonView id = root["id"]; + if (!id.valid()) { OS_LOGE(TAG, "missing 'id' field"); return false; } - if (cJSON_IsNumber(id) == 0) { + int64_t idInt; + if (!id.tryGetI64(idInt)) { OS_LOGE(TAG, "value at 'id' is not a number"); return false; } - int idInt = id->valueint; if (idInt < 0 || idInt > UINT16_MAX) { OS_LOGE(TAG, "value at 'id' is out of range (0-65535)"); return false; } uint16_t idU16 = static_cast(idInt); - const cJSON* command = cJSON_GetObjectItemCaseSensitive(root, "type"); - if (command == nullptr) { + JSON::JsonView command = root["type"]; + if (!command.valid()) { OS_LOGE(TAG, "missing 'type' field"); return false; } - if (cJSON_IsString(command) == 0) { + std::string commandStr; + if (!command.tryGetStr(commandStr)) { OS_LOGE(TAG, "value at 'type' is not a string"); return false; } ShockerCommandType commandType = ShockerCommandType::Stop; - if (!ShockerCommandTypeFromString(command->valuestring, commandType)) { + if (!TryParseShockerCommandType(commandType, commandStr)) { OS_LOGE(TAG, "value at 'type' is not a valid shocker command (shock, vibrate, sound, light, stop)"); return false; } - const cJSON* intensity = cJSON_GetObjectItemCaseSensitive(root, "intensity"); - if (intensity == nullptr) { + JSON::JsonView intensity = root["intensity"]; + if (!intensity.valid()) { OS_LOGE(TAG, "missing 'intensity' field"); return false; } - if (cJSON_IsNumber(intensity) == 0) { + int64_t intensityInt; + if (!intensity.tryGetI64(intensityInt)) { OS_LOGE(TAG, "value at 'intensity' is not a number"); return false; } - int intensityInt = intensity->valueint; if (intensityInt < 0 || intensityInt > UINT8_MAX) { OS_LOGE(TAG, "value at 'intensity' is out of range (0-255)"); return false; } uint8_t intensityU8 = static_cast(intensityInt); - const cJSON* durationMs = cJSON_GetObjectItemCaseSensitive(root, "durationMs"); - if (durationMs == nullptr) { + JSON::JsonView durationMs = root["durationMs"]; + if (!durationMs.valid()) { OS_LOGE(TAG, "missing 'durationMs' field"); return false; } - if (cJSON_IsNumber(durationMs) == 0) { + int64_t durationMsInt; + if (!durationMs.tryGetI64(durationMsInt)) { OS_LOGE(TAG, "value at 'durationMs' is not a number"); return false; } - if (durationMs->valueint < 0 || durationMs->valueint > UINT16_MAX) { + if (durationMsInt < 0 || durationMsInt > UINT16_MAX) { OS_LOGE(TAG, "value at 'durationMs' is out of range (0-65535)"); return false; } - uint16_t durationMsU16 = static_cast(durationMs->valueint); + uint16_t durationMsU16 = static_cast(durationMsInt); out = { .model = modelType, diff --git a/src/serialization/WSGateway.cpp b/components/core/src/serialization/WSGateway.cpp similarity index 51% rename from src/serialization/WSGateway.cpp rename to components/core/src/serialization/WSGateway.cpp index cb83e3cf..fe5078f2 100644 --- a/src/serialization/WSGateway.cpp +++ b/components/core/src/serialization/WSGateway.cpp @@ -4,28 +4,22 @@ const char* const TAG = "WSGateway"; #include -#include "config/Config.h" -#include "Core.h" #include "Logging.h" - -#ifndef OPENSHOCK_FW_VERSION_MAJOR -#define OPENSHOCK_FW_VERSION_MAJOR 0 -#endif -#ifndef OPENSHOCK_FW_VERSION_MINOR -#define OPENSHOCK_FW_VERSION_MINOR 0 -#endif -#ifndef OPENSHOCK_FW_VERSION_PATCH -#define OPENSHOCK_FW_VERSION_PATCH 0 -#endif -#ifndef OPENSHOCK_FW_VERSION_PRERELEASE -#define OPENSHOCK_FW_VERSION_PRERELEASE nullptr -#endif -#ifndef OPENSHOCK_FW_VERSION_BUILD -#define OPENSHOCK_FW_VERSION_BUILD nullptr -#endif +#include "OpenShock.h" +#include "SemVer.h" +#include "Temporal.h" using namespace OpenShock::Serialization; +// Wraps a payload in a HubToGatewayMessage, finishes the buffer and hands it to the callback. +template +static bool finishMessage(flatbuffers::FlatBufferBuilder& builder, Gateway::HubToGatewayMessagePayload type, flatbuffers::Offset payload, Common::SerializationCallbackFn callback) +{ + auto msg = Gateway::CreateHubToGatewayMessage(builder, type, payload.Union()); + Gateway::FinishHubToGatewayMessageBuffer(builder, msg); + return callback(builder.GetBufferSpan()); +} + bool Gateway::SerializePongMessage(Common::SerializationCallbackFn callback) { int64_t uptime = OpenShock::millis(); @@ -34,37 +28,31 @@ bool Gateway::SerializePongMessage(Common::SerializationCallbackFn callback) return false; } - int rssi; + // A missing RSSI must not suppress the Pong, or the gateway would treat the hub as unresponsive. + int rssi = 0; esp_err_t err = esp_wifi_sta_get_rssi(&rssi); if (err != ESP_OK) { - OS_LOGE(TAG, "Failed to get WiFi RSSI: %d", err); - return false; + OS_LOGW(TAG, "Failed to get WiFi RSSI: %s", esp_err_to_name(err)); + rssi = 0; } flatbuffers::FlatBufferBuilder builder(64); auto pong = Gateway::CreatePong(builder, static_cast(uptime), static_cast(rssi)); - auto msg = Gateway::CreateHubToGatewayMessage(builder, Gateway::HubToGatewayMessagePayload::Pong, pong.Union()); - - Gateway::FinishHubToGatewayMessageBuffer(builder, msg); - - return callback(builder.GetBufferSpan()); + return finishMessage(builder, Gateway::HubToGatewayMessagePayload::Pong, pong, callback); } bool Gateway::SerializeBootStatusMessage(int32_t updateId, OpenShock::FirmwareBootType bootType, Common::SerializationCallbackFn callback) { flatbuffers::FlatBufferBuilder builder(128); - auto fbsVersion = Types::CreateSemVerDirect(builder, OPENSHOCK_FW_VERSION_MAJOR, OPENSHOCK_FW_VERSION_MINOR, OPENSHOCK_FW_VERSION_PATCH, OPENSHOCK_FW_VERSION_PRERELEASE, OPENSHOCK_FW_VERSION_BUILD); + const OpenShock::SemVer& version = OpenShock::Constants::FirmwareVersion(); + auto fbsVersion = Types::CreateSemVerDirect(builder, version.major, version.minor, version.patch, version.prerelease.empty() ? nullptr : version.prerelease.c_str(), version.build.empty() ? nullptr : version.build.c_str()); - auto fbsBootStatus = Gateway::CreateBootStatus(builder, bootType, fbsVersion, updateId); - - auto msg = Gateway::CreateHubToGatewayMessage(builder, Gateway::HubToGatewayMessagePayload::BootStatus, fbsBootStatus.Union()); - - Gateway::FinishHubToGatewayMessageBuffer(builder, msg); + auto fbsBootStatus = Gateway::CreateBootStatus(builder, static_cast(bootType), fbsVersion, updateId); - return callback(builder.GetBufferSpan()); + return finishMessage(builder, Gateway::HubToGatewayMessagePayload::BootStatus, fbsBootStatus, callback); } bool Gateway::SerializeOtaUpdateStartedMessage(int32_t updateId, const OpenShock::SemVer& version, Common::SerializationCallbackFn callback) @@ -75,11 +63,7 @@ bool Gateway::SerializeOtaUpdateStartedMessage(int32_t updateId, const OpenShock auto otaUpdateStartedOffset = Gateway::CreateOtaUpdateStarted(builder, updateId, versionOffset); - auto msg = Gateway::CreateHubToGatewayMessage(builder, Gateway::HubToGatewayMessagePayload::OtaUpdateStarted, otaUpdateStartedOffset.Union()); - - Gateway::FinishHubToGatewayMessageBuffer(builder, msg); - - return callback(builder.GetBufferSpan()); + return finishMessage(builder, Gateway::HubToGatewayMessagePayload::OtaUpdateStarted, otaUpdateStartedOffset, callback); } bool Gateway::SerializeOtaUpdateProgressMessage(int32_t updateId, Types::OtaUpdateProgressTask task, float progress, Common::SerializationCallbackFn callback) @@ -88,11 +72,7 @@ bool Gateway::SerializeOtaUpdateProgressMessage(int32_t updateId, Types::OtaUpda auto otaUpdateProgressOffset = Gateway::CreateOtaUpdateProgress(builder, updateId, task, progress); - auto msg = Gateway::CreateHubToGatewayMessage(builder, Gateway::HubToGatewayMessagePayload::OtaUpdateProgress, otaUpdateProgressOffset.Union()); - - Gateway::FinishHubToGatewayMessageBuffer(builder, msg); - - return callback(builder.GetBufferSpan()); + return finishMessage(builder, Gateway::HubToGatewayMessagePayload::OtaUpdateProgress, otaUpdateProgressOffset, callback); } bool Gateway::SerializeOtaUpdateFailedMessage(int32_t updateId, std::string_view message, bool fatal, Common::SerializationCallbackFn callback) @@ -103,9 +83,5 @@ bool Gateway::SerializeOtaUpdateFailedMessage(int32_t updateId, std::string_view auto otaUpdateFailedOffset = Gateway::CreateOtaUpdateFailed(builder, updateId, messageOffset, fatal); - auto msg = Gateway::CreateHubToGatewayMessage(builder, Gateway::HubToGatewayMessagePayload::OtaUpdateFailed, otaUpdateFailedOffset.Union()); - - Gateway::FinishHubToGatewayMessageBuffer(builder, msg); - - return callback(builder.GetBufferSpan()); + return finishMessage(builder, Gateway::HubToGatewayMessagePayload::OtaUpdateFailed, otaUpdateFailedOffset, callback); } diff --git a/src/serialization/WSLocal.cpp b/components/core/src/serialization/WSLocal.cpp similarity index 57% rename from src/serialization/WSLocal.cpp rename to components/core/src/serialization/WSLocal.cpp index 23041bad..555c804f 100644 --- a/src/serialization/WSLocal.cpp +++ b/components/core/src/serialization/WSLocal.cpp @@ -4,6 +4,7 @@ const char* const TAG = "WSLocal"; #include "Chipset.h" #include "config/Config.h" +#include "config/WiFiAuthMode.h" #include "Logging.h" #include "util/HexUtils.h" #include "wifi/WiFiNetwork.h" @@ -12,38 +13,19 @@ const char* const TAG = "WSLocal"; using namespace OpenShock::Serialization; -typedef OpenShock::Serialization::Types::WifiAuthMode WiFiAuthMode; - -constexpr WiFiAuthMode GetWiFiAuthModeEnum(wifi_auth_mode_t authMode) +// Wraps a payload in a HubToLocalMessage, finishes the buffer and hands it to the callback. +template +static bool finishMessage(flatbuffers::FlatBufferBuilder& builder, Local::HubToLocalMessagePayload type, flatbuffers::Offset payload, Common::SerializationCallbackFn callback) { - switch (authMode) { - case wifi_auth_mode_t::WIFI_AUTH_OPEN: - return WiFiAuthMode::Open; - case wifi_auth_mode_t::WIFI_AUTH_WEP: - return WiFiAuthMode::WEP; - case wifi_auth_mode_t::WIFI_AUTH_WPA_PSK: - return WiFiAuthMode::WPA_PSK; - case wifi_auth_mode_t::WIFI_AUTH_WPA2_PSK: - return WiFiAuthMode::WPA2_PSK; - case wifi_auth_mode_t::WIFI_AUTH_WPA_WPA2_PSK: - return WiFiAuthMode::WPA_WPA2_PSK; - case wifi_auth_mode_t::WIFI_AUTH_WPA2_ENTERPRISE: - return WiFiAuthMode::WPA2_ENTERPRISE; - case wifi_auth_mode_t::WIFI_AUTH_WPA3_PSK: - return WiFiAuthMode::WPA3_PSK; - case wifi_auth_mode_t::WIFI_AUTH_WPA2_WPA3_PSK: - return WiFiAuthMode::WPA2_WPA3_PSK; - case wifi_auth_mode_t::WIFI_AUTH_WAPI_PSK: - return WiFiAuthMode::WAPI_PSK; - default: - return WiFiAuthMode::UNKNOWN; - } + auto msg = Local::CreateHubToLocalMessage(builder, type, payload.Union()); + Local::FinishHubToLocalMessageBuffer(builder, msg); + return callback(builder.GetBufferSpan()); } static flatbuffers::Offset createWiFiNetwork(flatbuffers::FlatBufferBuilder& builder, const OpenShock::WiFiNetwork& network) { auto bssid = network.GetHexBSSID(); - auto authMode = GetWiFiAuthModeEnum(network.authMode); + auto authMode = OpenShock::Config::ToFbsAuthMode(network.authMode); return Types::CreateWifiNetworkDirect(builder, network.ssid, bssid.data(), network.channel, network.rssi, authMode, network.IsSaved()); } @@ -54,11 +36,7 @@ bool Local::SerializeErrorMessage(std::string_view message, Common::Serializatio auto wrapperOffset = Local::CreateErrorMessage(builder, builder.CreateString(message.data(), message.length())); - auto msg = Local::CreateHubToLocalMessage(builder, Local::HubToLocalMessagePayload::ErrorMessage, wrapperOffset.Union()); - - Serialization::Local::FinishHubToLocalMessageBuffer(builder, msg); - - return callback(builder.GetBufferSpan()); + return finishMessage(builder, Local::HubToLocalMessagePayload::ErrorMessage, wrapperOffset, callback); } bool Local::SerializeReadyMessage(const WiFiNetwork* connectedNetwork, bool accountLinked, Common::SerializationCallbackFn callback) @@ -69,8 +47,6 @@ bool Local::SerializeReadyMessage(const WiFiNetwork* connectedNetwork, bool acco if (connectedNetwork != nullptr) { fbsNetwork = createWiFiNetwork(builder, *connectedNetwork); - } else { - fbsNetwork = 0; } auto configOffset = OpenShock::Config::GetAsFlatBuffer(builder, false); @@ -87,11 +63,7 @@ bool Local::SerializeReadyMessage(const WiFiNetwork* connectedNetwork, bool acco auto readyMessageOffset = Serialization::Local::CreateReadyMessage(builder, true, fbsNetwork, accountLinked, configOffset, inputPinsOffset, outputPinsOffset); - auto msg = Serialization::Local::CreateHubToLocalMessage(builder, Serialization::Local::HubToLocalMessagePayload::ReadyMessage, readyMessageOffset.Union()); - - Serialization::Local::FinishHubToLocalMessageBuffer(builder, msg); - - return callback(builder.GetBufferSpan()); + return finishMessage(builder, Local::HubToLocalMessagePayload::ReadyMessage, readyMessageOffset, callback); } bool Local::SerializeWiFiScanStatusChangedEvent(OpenShock::WiFiScanStatus status, Common::SerializationCallbackFn callback) @@ -100,11 +72,7 @@ bool Local::SerializeWiFiScanStatusChangedEvent(OpenShock::WiFiScanStatus status auto scanStatusOffset = Serialization::Local::CreateWifiScanStatusMessage(builder, status); - auto msg = Serialization::Local::CreateHubToLocalMessage(builder, Serialization::Local::HubToLocalMessagePayload::WifiScanStatusMessage, scanStatusOffset.Union()); - - Serialization::Local::FinishHubToLocalMessageBuffer(builder, msg); - - return callback(builder.GetBufferSpan()); + return finishMessage(builder, Local::HubToLocalMessagePayload::WifiScanStatusMessage, scanStatusOffset, callback); } bool Local::SerializeWiFiNetworkEvent(Types::WifiNetworkEventType eventType, const WiFiNetwork& network, Common::SerializationCallbackFn callback) @@ -115,11 +83,7 @@ bool Local::SerializeWiFiNetworkEvent(Types::WifiNetworkEventType eventType, con auto wrapperOffset = Local::CreateWifiNetworkEvent(builder, eventType, builder.CreateVector(&networkOffset, 1)); // Resulting vector will have 1 element - auto msg = Local::CreateHubToLocalMessage(builder, Local::HubToLocalMessagePayload::WifiNetworkEvent, wrapperOffset.Union()); - - Serialization::Local::FinishHubToLocalMessageBuffer(builder, msg); - - return callback(builder.GetBufferSpan()); + return finishMessage(builder, Local::HubToLocalMessagePayload::WifiNetworkEvent, wrapperOffset, callback); } bool Local::SerializeWiFiNetworksEvent(Types::WifiNetworkEventType eventType, const std::vector& networks, Common::SerializationCallbackFn callback) @@ -135,11 +99,7 @@ bool Local::SerializeWiFiNetworksEvent(Types::WifiNetworkEventType eventType, co auto wrapperOffset = Local::CreateWifiNetworkEvent(builder, eventType, builder.CreateVector(fbsNetworks)); - auto msg = Local::CreateHubToLocalMessage(builder, Local::HubToLocalMessagePayload::WifiNetworkEvent, wrapperOffset.Union()); - - Serialization::Local::FinishHubToLocalMessageBuffer(builder, msg); - - return callback(builder.GetBufferSpan()); + return finishMessage(builder, Local::HubToLocalMessagePayload::WifiNetworkEvent, wrapperOffset, callback); } bool Local::SerializeWiFiGotIpEvent(const char* ip, Common::SerializationCallbackFn callback) @@ -148,11 +108,7 @@ bool Local::SerializeWiFiGotIpEvent(const char* ip, Common::SerializationCallbac auto ipOffset = builder.CreateString(ip); auto eventOffset = Local::CreateWifiGotIpEvent(builder, ipOffset); - auto msg = Local::CreateHubToLocalMessage(builder, Local::HubToLocalMessagePayload::WifiGotIpEvent, eventOffset.Union()); - - Local::FinishHubToLocalMessageBuffer(builder, msg); - - return callback(builder.GetBufferSpan()); + return finishMessage(builder, Local::HubToLocalMessagePayload::WifiGotIpEvent, eventOffset, callback); } bool Local::SerializeAccountLinkStatusEvent(bool linked, Common::SerializationCallbackFn callback) @@ -160,9 +116,5 @@ bool Local::SerializeAccountLinkStatusEvent(bool linked, Common::SerializationCa flatbuffers::FlatBufferBuilder builder(64); auto eventOffset = Local::CreateAccountLinkStatusEvent(builder, linked); - auto msg = Local::CreateHubToLocalMessage(builder, Local::HubToLocalMessagePayload::AccountLinkStatusEvent, eventOffset.Union()); - - Local::FinishHubToLocalMessageBuffer(builder, msg); - - return callback(builder.GetBufferSpan()); + return finishMessage(builder, Local::HubToLocalMessagePayload::AccountLinkStatusEvent, eventOffset, callback); } diff --git a/src/wifi/WiFiManager.cpp b/components/core/src/wifi/WiFiManager.cpp similarity index 59% rename from src/wifi/WiFiManager.cpp rename to components/core/src/wifi/WiFiManager.cpp index 7caad614..cda5afc3 100644 --- a/src/wifi/WiFiManager.cpp +++ b/components/core/src/wifi/WiFiManager.cpp @@ -1,27 +1,30 @@ +#include + #include "wifi/WiFiManager.h" const char* const TAG = "WiFiManager"; -#include "captiveportal/Manager.h" +#include "AppHooks.h" #include "config/Config.h" -#include "Core.h" +#include "events/Events.h" #include "FormatHelpers.h" #include "Logging.h" #include "serialization/WSLocal.h" +#include "Temporal.h" #include "util/TaskUtils.h" -#include "visual/VisualStateManager.h" #include "wifi/WiFiNetwork.h" #include "wifi/WiFiScanManager.h" -#include - +#include #include -#include +#include #include "SimpleMutex.h" +#include #include #include +#include #include using namespace OpenShock; @@ -67,7 +70,7 @@ static const char* wifiDisconnectReason(uint8_t reason) return "Switching access points"; // Timeouts - case WIFI_REASON_ASSOC_EXPIRE: + case WIFI_REASON_DISASSOC_DUE_TO_INACTIVITY: // formerly WIFI_REASON_ASSOC_EXPIRE (renamed in ESP-IDF 6) case WIFI_REASON_TIMEOUT: case WIFI_REASON_MISSING_ACKS: return "Connection timed out"; @@ -84,13 +87,25 @@ enum class WiFiState : uint8_t { Connected = 1 << 1, }; +static esp_netif_t* s_staNetif = nullptr; static std::atomic s_wifiState {WiFiState::Disconnected}; -static uint8_t s_connectedBSSID[6] = {0}; +static uint8_t s_connectedBSSID[6] = {0}; // Guarded by s_networksMutex static std::atomic s_connectedCredentialsID = 0; static std::atomic s_preferredCredentialsID = 0; static OpenShock::SimpleMutex s_networksMutex; static std::vector s_wifiNetworks; +// Broadcast a coarse connectivity change on the OpenShock event bus. Posted with a +// short timeout because this runs on the default event loop task (posting to the +// same loop must never block on a full queue). +static void postWiFiState(OpenShockWiFiState state) +{ + esp_err_t err = esp_event_post(OPENSHOCK_EVENTS, OPENSHOCK_EVENT_WIFI_STATE_CHANGED, &state, sizeof(state), pdMS_TO_TICKS(100)); + if (err != ESP_OK) { + OS_LOGE(TAG, "Failed to post WiFi state event: %s", esp_err_to_name(err)); + } +} + static bool attractivityComparer(const WiFiNetwork& a, const WiFiNetwork& b) { // Networks with credentials sort before those without @@ -109,56 +124,74 @@ static bool isConnectRateLimited(const WiFiNetwork& net) return false; } - int64_t now = OpenShock::millis(); - int64_t diff = now - net.lastConnectAttempt; - if ((net.connectAttempts > 5 && diff < 5000) || (net.connectAttempts > 10 && diff < 10'000) || (net.connectAttempts > 15 && diff < 30'000) || (net.connectAttempts > 20 && diff < 60'000)) { - return true; - } + // Back off from the first failure: 2 s, 4 s, 8 s, ... capped at 60 s. Every attempt is an all-channel scan that + // takes the radio off the captive portal AP's channel, so retrying every second starves portal clients. + int64_t now = OpenShock::millis(); + int64_t diff = now - net.lastConnectAttempt; + uint16_t shift = std::min(net.connectAttempts, 5); + int64_t backoff = std::min(1000LL << shift, 60'000); - return false; + return diff < backoff; } static bool isSaved(std::function predicate) { return Config::AnyWiFiCredentials(predicate); } -static std::vector::iterator findNetwork(std::function predicate, bool sortByAttractivity = true) +// All s_wifiNetworks helpers require s_networksMutex. The list is kept ordered by RSSI (strongest first), so lookups +// return the strongest matching BSSID; nothing else may reorder it. +static std::vector::iterator findNetwork(std::function predicate) { - if (sortByAttractivity) { - std::sort(s_wifiNetworks.begin(), s_wifiNetworks.end(), attractivityComparer); - } return std::find_if(s_wifiNetworks.begin(), s_wifiNetworks.end(), predicate); } -static std::vector::iterator findNetworkBySSID(const char* ssid, bool sortByAttractivity = true) +static std::vector::iterator findNetworkBySSID(const char* ssid) { - return findNetwork([ssid](const WiFiNetwork& net) noexcept { return strcmp(net.ssid, ssid) == 0; }, sortByAttractivity); + return findNetwork([ssid](const WiFiNetwork& net) noexcept { return strcmp(net.ssid, ssid) == 0; }); } static std::vector::iterator findNetworkByBSSID(const uint8_t (&bssid)[6]) { - return findNetwork([bssid](const WiFiNetwork& net) noexcept { return memcmp(net.bssid, bssid, sizeof(bssid)) == 0; }, false); -} -static std::vector::iterator findNetworkByCredentialsID(uint8_t credentialsID, bool sortByAttractivity = true) -{ - return findNetwork([credentialsID](const WiFiNetwork& net) noexcept { return net.credentialsID == credentialsID; }, sortByAttractivity); + return findNetwork([bssid](const WiFiNetwork& net) noexcept { return memcmp(net.bssid, bssid, sizeof(bssid)) == 0; }); } +// Picks the most attractive saved, non-rate-limited network without reordering the list. static bool getNextWiFiNetwork(OpenShock::Config::WiFiCredentials& creds) { - return findNetwork([&creds](const WiFiNetwork& net) { - if (net.credentialsID == 0) { - return false; + const WiFiNetwork* best = nullptr; + for (const WiFiNetwork& net : s_wifiNetworks) { + if (net.credentialsID == 0 || isConnectRateLimited(net)) { + continue; } - if (isConnectRateLimited(net)) { - return false; + if (best == nullptr || attractivityComparer(net, *best)) { + best = &net; } + } - if (!Config::TryGetWiFiCredentialsByID(net.credentialsID, creds)) { - return false; - } + return best != nullptr && Config::TryGetWiFiCredentialsByID(best->credentialsID, creds); +} - return true; - }) != s_wifiNetworks.end(); +// Requires s_networksMutex. Records the attempt and rejects the network if its scanned auth mode is weaker than expected. +// `channel` receives the scanned channel of the strongest BSSID (0 if not scanned). +static bool prepareConnectAttempt(const std::string& ssid, wifi_auth_mode_t expectedAuthMode, uint8_t& channel) +{ + channel = 0; + + auto it = findNetworkBySSID(ssid.c_str()); + if (it == s_wifiNetworks.end()) { + return true; // Not scanned (hidden or out of range); nothing to validate against + } + + // Reject if the AP's auth mode is weaker than expected (evil twin protection) + if (expectedAuthMode != WIFI_AUTH_MAX && it->authMode < expectedAuthMode) { + OS_LOGW(TAG, "Rejecting network %s: auth mode %d is weaker than expected %d", ssid.c_str(), it->authMode, expectedAuthMode); + return false; + } + + it->connectAttempts++; + it->lastConnectAttempt = OpenShock::millis(); + channel = it->channel; + + return true; } static bool getConnectedAPNetwork(WiFiNetwork& network, uint8_t credentialsId) @@ -173,7 +206,9 @@ static bool getConnectedAPNetwork(WiFiNetwork& network, uint8_t credentialsId) return true; } -static bool connectWiFi(const std::string& ssid, const std::string& password, wifi_auth_mode_t expectedAuthMode = WIFI_AUTH_MAX, const uint8_t* pinnedBssid = nullptr) +// Must be called without s_networksMutex held, after prepareConnectAttempt(). A known `channel` (from a scan) lets +// esp_wifi probe just that channel instead of sweeping all of them. +static bool connectWiFi(const std::string& ssid, const std::string& password, const uint8_t* pinnedBssid = nullptr, uint8_t channel = 0) { if (ssid.empty()) { OS_LOGW(TAG, "Cannot connect to network with empty SSID"); @@ -186,65 +221,80 @@ static bool connectWiFi(const std::string& ssid, const std::string& password, wi OS_LOGV(TAG, "Connecting to network %s", ssid.c_str()); } - // Mark as attempted and validate auth mode if we know the network from scanning - auto it = findNetworkBySSID(ssid.c_str()); - if (it != s_wifiNetworks.end()) { - // Reject if the AP's auth mode is weaker than expected (evil twin protection) - if (expectedAuthMode != WIFI_AUTH_MAX && it->authMode < expectedAuthMode) { - OS_LOGW(TAG, "Rejecting network %s: auth mode %d is weaker than expected %d", ssid.c_str(), it->authMode, expectedAuthMode); - return false; - } - it->connectAttempts++; - it->lastConnectAttempt = OpenShock::millis(); + wifi_config_t config = {}; + std::strncpy(reinterpret_cast(config.sta.ssid), ssid.c_str(), sizeof(config.sta.ssid)); + std::strncpy(reinterpret_cast(config.sta.password), password.c_str(), sizeof(config.sta.password)); + // Let esp_wifi pick the strongest matching BSSID and retry a few times before giving up. + config.sta.scan_method = channel != 0 ? WIFI_FAST_SCAN : WIFI_ALL_CHANNEL_SCAN; + config.sta.channel = channel; + config.sta.sort_method = WIFI_CONNECT_AP_BY_SIGNAL; + config.sta.failure_retry_cnt = 3; + if (pinnedBssid != nullptr) { + config.sta.bssid_set = true; + std::memcpy(config.sta.bssid, pinnedBssid, sizeof(config.sta.bssid)); } s_wifiState.store(WiFiState::Connecting, std::memory_order_relaxed); - if (WiFi.begin(ssid.c_str(), password.c_str(), 0, pinnedBssid, true) == WL_CONNECT_FAILED) { + postWiFiState(OPENSHOCK_WIFI_STATE_CONNECTING); + + esp_err_t err = esp_wifi_set_config(WIFI_IF_STA, &config); + if (err != ESP_OK) { + OS_LOGE(TAG, "esp_wifi_set_config failed: %s", esp_err_to_name(err)); s_wifiState.store(WiFiState::Disconnected, std::memory_order_relaxed); + postWiFiState(OPENSHOCK_WIFI_STATE_DISCONNECTED); return false; } - return true; -} - -static bool authenticate(const WiFiNetwork& net, std::string_view password) -{ - uint8_t id = Config::AddWiFiCredentials(net.ssid, password, net.authMode); - if (id == 0) { - Serialization::Local::SerializeErrorMessage("too_many_credentials", CaptivePortal::BroadcastMessageBIN); + err = esp_wifi_connect(); + if (err != ESP_OK) { + OS_LOGE(TAG, "esp_wifi_connect failed: %s", esp_err_to_name(err)); + s_wifiState.store(WiFiState::Disconnected, std::memory_order_relaxed); + postWiFiState(OPENSHOCK_WIFI_STATE_DISCONNECTED); return false; } - Serialization::Local::SerializeWiFiNetworkEvent(Serialization::Types::WifiNetworkEventType::Saved, net, CaptivePortal::BroadcastMessageBIN); - - return connectWiFi(net.ssid, std::string(password)); + return true; } -static void evWiFiConnected(arduino_event_t* event) +static void evWiFiConnected(const wifi_event_sta_connected_t& info) { - auto& info = event->event_info.wifi_sta_connected; - s_wifiState.store(WiFiState::Connected, std::memory_order_relaxed); - memcpy(s_connectedBSSID, info.bssid, sizeof(s_connectedBSSID)); + postWiFiState(OPENSHOCK_WIFI_STATE_CONNECTED); + + // info.ssid is not guaranteed null-terminated; bound by ssid_len. + char ssid[33]; + size_t ssidLen = std::min(static_cast(info.ssid_len), sizeof(ssid) - 1); + memcpy(ssid, info.ssid, ssidLen); + ssid[ssidLen] = '\0'; ScopedLock lock__(&s_networksMutex); + memcpy(s_connectedBSSID, info.bssid, sizeof(s_connectedBSSID)); + + // A successful connection clears the retry throttling for every BSSID of this network + for (WiFiNetwork& net : s_wifiNetworks) { + if (strcmp(net.ssid, ssid) == 0) { + net.connectAttempts = 0; + net.lastConnectAttempt = 0; + } + } + auto it = findNetworkByBSSID(info.bssid); if (it == s_wifiNetworks.end()) { s_connectedCredentialsID.store(0, std::memory_order_relaxed); - OS_LOGW(TAG, "Connected to unscanned network \"%s\", BSSID: " BSSID_FMT, reinterpret_cast(info.ssid), BSSID_ARG(info.bssid)); + OS_LOGW(TAG, "Connected to unscanned network \"%s\", BSSID: " BSSID_FMT, ssid, BSSID_ARG(info.bssid)); uint8_t credentialsId = 0; Config::WiFiCredentials creds; - if (Config::TryGetWiFiCredentialsBySSID(reinterpret_cast(info.ssid), creds)) { + if (Config::TryGetWiFiCredentialsBySSID(ssid, creds)) { credentialsId = creds.id; s_connectedCredentialsID.store(credentialsId, std::memory_order_relaxed); } WiFiNetwork network; if (getConnectedAPNetwork(network, credentialsId)) { - Serialization::Local::SerializeWiFiNetworkEvent(Serialization::Types::WifiNetworkEventType::Connected, network, CaptivePortal::BroadcastMessageBIN); + Serialization::Local::SerializeWiFiNetworkEvent(Serialization::Types::WifiNetworkEventType::Connected, network, AppHooks::CaptivePortalBroadcastMessageBIN); } return; @@ -252,79 +302,113 @@ static void evWiFiConnected(arduino_event_t* event) s_connectedCredentialsID.store(it->credentialsID, std::memory_order_relaxed); - OS_LOGI(TAG, "Connected to network %s (" BSSID_FMT ")", reinterpret_cast(info.ssid), BSSID_ARG(info.bssid)); + OS_LOGI(TAG, "Connected to network %s (" BSSID_FMT ")", ssid, BSSID_ARG(info.bssid)); - Serialization::Local::SerializeWiFiNetworkEvent(Serialization::Types::WifiNetworkEventType::Connected, *it, CaptivePortal::BroadcastMessageBIN); + Serialization::Local::SerializeWiFiNetworkEvent(Serialization::Types::WifiNetworkEventType::Connected, *it, AppHooks::CaptivePortalBroadcastMessageBIN); } -static void evWiFiGotIP(arduino_event_t* event) +static void evWiFiGotIP(const ip_event_got_ip_t& info) { - const auto& info = event->event_info.got_ip; - uint8_t ip[4]; memcpy(ip, &info.ip_info.ip.addr, sizeof(ip)); - OS_LOGI(TAG, "Got IP address " IPV4ADDR_FMT " from network " BSSID_FMT, IPV4ADDR_ARG(ip), BSSID_ARG(s_connectedBSSID)); + OS_LOGI(TAG, "Got IP address " IPV4ADDR_FMT, IPV4ADDR_ARG(ip)); + + postWiFiState(OPENSHOCK_WIFI_STATE_GOT_IP); char ipStr[16]; snprintf(ipStr, sizeof(ipStr), IPV4ADDR_FMT, IPV4ADDR_ARG(ip)); - Serialization::Local::SerializeWiFiGotIpEvent(ipStr, CaptivePortal::BroadcastMessageBIN); + Serialization::Local::SerializeWiFiGotIpEvent(ipStr, AppHooks::CaptivePortalBroadcastMessageBIN); } -static void evWiFiGotIP6(arduino_event_t* event) +static void evWiFiGotIP6(const ip_event_got_ip6_t& info) { - auto& info = event->event_info.got_ip6; + const uint8_t* ip6 = reinterpret_cast(&info.ip6_info.ip.addr); - uint8_t* ip6 = reinterpret_cast(&info.ip6_info.ip.addr); + OS_LOGI(TAG, "Got IPv6 address " IPV6ADDR_FMT, IPV6ADDR_ARG(ip6)); - OS_LOGI(TAG, "Got IPv6 address " IPV6ADDR_FMT " from network " BSSID_FMT, IPV6ADDR_ARG(ip6), BSSID_ARG(s_connectedBSSID)); + postWiFiState(OPENSHOCK_WIFI_STATE_GOT_IP); } -static void evWiFiDisconnected(arduino_event_t* event) +static void evWiFiDisconnected(const wifi_event_sta_disconnected_t& info) { s_wifiState.store(WiFiState::Disconnected, std::memory_order_relaxed); s_connectedCredentialsID.store(0, std::memory_order_relaxed); + postWiFiState(OPENSHOCK_WIFI_STATE_DISCONNECTED); - auto& info = event->event_info.wifi_sta_disconnected; + char ssid[33]; + size_t ssidLen = std::min(static_cast(info.ssid_len), sizeof(ssid) - 1); + memcpy(ssid, info.ssid, ssidLen); + ssid[ssidLen] = '\0'; - OS_LOGI(TAG, "Disconnected from network %s (" BSSID_FMT ")", info.ssid, BSSID_ARG(info.bssid)); + OS_LOGI(TAG, "Disconnected from network %s (" BSSID_FMT ")", ssid, BSSID_ARG(info.bssid)); // Notify the frontend ScopedLock lock__(&s_networksMutex); auto it = findNetworkByBSSID(info.bssid); if (it != s_wifiNetworks.end()) { - Serialization::Local::SerializeWiFiNetworkEvent(Serialization::Types::WifiNetworkEventType::Disconnected, *it, CaptivePortal::BroadcastMessageBIN); + Serialization::Local::SerializeWiFiNetworkEvent(Serialization::Types::WifiNetworkEventType::Disconnected, *it, AppHooks::CaptivePortalBroadcastMessageBIN); } else { // Network not in scan results (forgotten or hidden) — send minimal event WiFiNetwork net; - memset(&net, 0, sizeof(net)); - strncpy(net.ssid, reinterpret_cast(info.ssid), sizeof(net.ssid) - 1); + strncpy(net.ssid, ssid, sizeof(net.ssid) - 1); memcpy(net.bssid, info.bssid, sizeof(net.bssid)); - Serialization::Local::SerializeWiFiNetworkEvent(Serialization::Types::WifiNetworkEventType::Disconnected, net, CaptivePortal::BroadcastMessageBIN); + Serialization::Local::SerializeWiFiNetworkEvent(Serialization::Types::WifiNetworkEventType::Disconnected, net, AppHooks::CaptivePortalBroadcastMessageBIN); } // Send error message for unexpected disconnects (not user-initiated) if (info.reason != WIFI_REASON_ASSOC_LEAVE) { const char* friendlyReason = wifiDisconnectReason(info.reason); if (friendlyReason != nullptr) { - Serialization::Local::SerializeErrorMessage(friendlyReason, CaptivePortal::BroadcastMessageBIN); + Serialization::Local::SerializeErrorMessage(friendlyReason, AppHooks::CaptivePortalBroadcastMessageBIN); } else { char reason[64]; snprintf(reason, sizeof(reason), "Unknown WiFi error (code %d), please contact support", info.reason); - Serialization::Local::SerializeErrorMessage(reason, CaptivePortal::BroadcastMessageBIN); + Serialization::Local::SerializeErrorMessage(reason, AppHooks::CaptivePortalBroadcastMessageBIN); } } } -static void evWiFiScanStarted() + +static void wifiEventHandler(void* arg, esp_event_base_t base, int32_t id, void* data) { + (void)arg; + (void)base; + + switch (id) { + case WIFI_EVENT_STA_CONNECTED: + evWiFiConnected(*static_cast(data)); + break; + case WIFI_EVENT_STA_DISCONNECTED: + evWiFiDisconnected(*static_cast(data)); + break; + default: + break; + } +} +static void ipEventHandler(void* arg, esp_event_base_t base, int32_t id, void* data) +{ + (void)arg; + (void)base; + + switch (id) { + case IP_EVENT_STA_GOT_IP: + evWiFiGotIP(*static_cast(data)); + break; + case IP_EVENT_GOT_IP6: + evWiFiGotIP6(*static_cast(data)); + break; + default: + break; + } } + static void evWiFiScanStatusChanged(OpenShock::WiFiScanStatus status) { ScopedLock lock__(&s_networksMutex); - // If the scan started, remove any networks that have not been seen in 3 scans + // If the scan started, remove any networks that have not been seen in the last 3 scans if (status == OpenShock::WiFiScanStatus::Started) { for (auto it = s_wifiNetworks.begin(); it != s_wifiNetworks.end();) { - if (it->scansMissed++ > 3) { + if (++it->scansMissed > 3) { OS_LOGV(TAG, "Network %s (" BSSID_FMT ") has not been seen in 3 scans, removing from list", it->ssid, BSSID_ARG(it->bssid)); - Serialization::Local::SerializeWiFiNetworkEvent(Serialization::Types::WifiNetworkEventType::Lost, *it, CaptivePortal::BroadcastMessageBIN); + Serialization::Local::SerializeWiFiNetworkEvent(Serialization::Types::WifiNetworkEventType::Lost, *it, AppHooks::CaptivePortalBroadcastMessageBIN); it = s_wifiNetworks.erase(it); } else { ++it; @@ -339,7 +423,7 @@ static void evWiFiScanStatusChanged(OpenShock::WiFiScanStatus status) } // Send the scan status changed event - Serialization::Local::SerializeWiFiScanStatusChangedEvent(status, CaptivePortal::BroadcastMessageBIN); + Serialization::Local::SerializeWiFiScanStatusChangedEvent(status, AppHooks::CaptivePortalBroadcastMessageBIN); } static void evWiFiNetworksDiscovery(const std::vector& records) { @@ -377,20 +461,19 @@ static void evWiFiNetworksDiscovery(const std::vector& } if (!updatedNetworks.empty()) { - Serialization::Local::SerializeWiFiNetworksEvent(Serialization::Types::WifiNetworkEventType::Updated, updatedNetworks, CaptivePortal::BroadcastMessageBIN); + Serialization::Local::SerializeWiFiNetworksEvent(Serialization::Types::WifiNetworkEventType::Updated, updatedNetworks, AppHooks::CaptivePortalBroadcastMessageBIN); } if (!discoveredNetworks.empty()) { - Serialization::Local::SerializeWiFiNetworksEvent(Serialization::Types::WifiNetworkEventType::Discovered, discoveredNetworks, CaptivePortal::BroadcastMessageBIN); + Serialization::Local::SerializeWiFiNetworksEvent(Serialization::Types::WifiNetworkEventType::Discovered, discoveredNetworks, AppHooks::CaptivePortalBroadcastMessageBIN); } } -esp_err_t set_esp_interface_dns(esp_interface_t interface, IPAddress main_dns, IPAddress backup_dns, IPAddress fallback_dns); - static bool tryConnect() { Config::WiFiCredentials creds; + uint8_t channel = 0; - // Select target network under lock, resolve BSSID and mark as attempted, then release before connecting + // Select the target network, validate it and mark it as attempted under lock, then release before connecting { ScopedLock lock__(&s_networksMutex); @@ -403,9 +486,13 @@ static bool tryConnect() } else if (!getNextWiFiNetwork(creds)) { return false; } + + if (!prepareConnectAttempt(creds.ssid, creds.authMode, channel)) { + return false; + } } - return connectWiFi(creds.ssid, creds.password, creds.authMode, creds.HasPinnedBSSID() ? creds.bssid.data() : nullptr); + return connectWiFi(creds.ssid, creds.password, creds.HasPinnedBSSID() ? creds.bssid.data() : nullptr, channel); } static void wifimanagerUpdateTask(void*) @@ -429,10 +516,43 @@ static void wifimanagerUpdateTask(void*) bool WiFiManager::Init() { - WiFi.onEvent(evWiFiConnected, ARDUINO_EVENT_WIFI_STA_CONNECTED); - WiFi.onEvent(evWiFiGotIP, ARDUINO_EVENT_WIFI_STA_GOT_IP); - WiFi.onEvent(evWiFiGotIP6, ARDUINO_EVENT_WIFI_STA_GOT_IP6); - WiFi.onEvent(evWiFiDisconnected, ARDUINO_EVENT_WIFI_STA_DISCONNECTED); + esp_err_t err; + + // Bring up the network stack that Arduino's WiFi class used to init implicitly. + // The default event loop already exists (Events::Init runs earlier in main). + err = esp_netif_init(); + if (err != ESP_OK) { + OS_LOGE(TAG, "esp_netif_init failed: %s", esp_err_to_name(err)); + return false; + } + + s_staNetif = esp_netif_create_default_wifi_sta(); + if (s_staNetif == nullptr) { + OS_LOGE(TAG, "Failed to create default STA netif"); + return false; + } + + wifi_init_config_t initConfig = WIFI_INIT_CONFIG_DEFAULT(); + err = esp_wifi_init(&initConfig); + if (err != ESP_OK) { + OS_LOGE(TAG, "esp_wifi_init failed: %s", esp_err_to_name(err)); + return false; + } + + // Config owns our credentials; don't let esp_wifi persist/auto-connect from NVS. + esp_wifi_set_storage(WIFI_STORAGE_RAM); + + err = esp_event_handler_register(WIFI_EVENT, ESP_EVENT_ANY_ID, wifiEventHandler, nullptr); + if (err != ESP_OK) { + OS_LOGE(TAG, "Failed to register WIFI_EVENT handler: %s", esp_err_to_name(err)); + return false; + } + err = esp_event_handler_register(IP_EVENT, ESP_EVENT_ANY_ID, ipEventHandler, nullptr); + if (err != ESP_OK) { + OS_LOGE(TAG, "Failed to register IP_EVENT handler: %s", esp_err_to_name(err)); + return false; + } + WiFiScanManager::RegisterStatusChangedHandler(evWiFiScanStatusChanged); WiFiScanManager::RegisterNetworksDiscoveredHandler(evWiFiNetworksDiscovery); @@ -444,29 +564,23 @@ bool WiFiManager::Init() std::string hostname; if (!Config::GetWiFiHostname(hostname)) { OS_LOGE(TAG, "Failed to get WiFi hostname, reverting to default"); - hostname = OPENSHOCK_FW_HOSTNAME; + hostname = CONFIG_OPENSHOCK_FW_HOSTNAME; } - WiFi.setAutoConnect(false); - WiFi.setAutoReconnect(false); - WiFi.enableSTA(true); - WiFi.setHostname(hostname.c_str()); - - // If we recognize the network in the ESP's WiFi cache, try to connect to it - wifi_config_t current_conf; - if (esp_wifi_get_config(static_cast(ESP_IF_WIFI_STA), ¤t_conf) == ESP_OK) { - if (current_conf.sta.ssid[0] != '\0') { - if (Config::GetWiFiCredentialsIDbySSID(reinterpret_cast(current_conf.sta.ssid)) != 0) { - WiFi.begin(); - } - } + err = esp_wifi_set_mode(WIFI_MODE_STA); + if (err != ESP_OK) { + OS_LOGE(TAG, "esp_wifi_set_mode failed: %s", esp_err_to_name(err)); + return false; } - if (set_esp_interface_dns(ESP_IF_WIFI_STA, IPAddress(1, 1, 1, 1), IPAddress(8, 8, 8, 8), IPAddress(9, 9, 9, 9)) != ESP_OK) { - OS_LOGE(TAG, "Failed to set DNS servers"); + err = esp_wifi_start(); + if (err != ESP_OK) { + OS_LOGE(TAG, "esp_wifi_start failed: %s", esp_err_to_name(err)); return false; } + esp_netif_set_hostname(s_staNetif, hostname.c_str()); + if (TaskUtils::TaskCreateUniversal(wifimanagerUpdateTask, TAG, 4096, nullptr, 5, nullptr, 1) != pdPASS) { // TODO: Re-profile stack usage OS_LOGE(TAG, "Failed to create WiFiManager update task"); return false; @@ -475,6 +589,15 @@ bool WiFiManager::Init() return true; } +// Hands the connection to the update task, which validates auth mode / BSSID pin and owns the WiFi state. +static void requestConnect(uint8_t credentialsId) +{ + s_preferredCredentialsID.store(credentialsId, std::memory_order_relaxed); + if (s_wifiState.load(std::memory_order_relaxed) != WiFiState::Disconnected) { + esp_wifi_disconnect(); + } +} + bool WiFiManager::Save(const char* ssid, std::string_view password, bool connect, wifi_auth_mode_t authMode) { OS_LOGV(TAG, "Saving network %s (connect=%s)", ssid, connect ? "true" : "false"); @@ -486,15 +609,15 @@ bool WiFiManager::Save(const char* ssid, std::string_view password, bool connect // Network is in scan results — use scanned auth mode (more reliable than user-provided) uint8_t id = Config::AddWiFiCredentials(it->ssid, password, it->authMode); if (id == 0) { - Serialization::Local::SerializeErrorMessage("too_many_credentials", CaptivePortal::BroadcastMessageBIN); + Serialization::Local::SerializeErrorMessage("too_many_credentials", AppHooks::CaptivePortalBroadcastMessageBIN); return false; } it->credentialsID = id; - Serialization::Local::SerializeWiFiNetworkEvent(Serialization::Types::WifiNetworkEventType::Saved, *it, CaptivePortal::BroadcastMessageBIN); + Serialization::Local::SerializeWiFiNetworkEvent(Serialization::Types::WifiNetworkEventType::Saved, *it, AppHooks::CaptivePortalBroadcastMessageBIN); if (connect) { - return connectWiFi(it->ssid, std::string(password)); + requestConnect(id); } return true; } @@ -504,20 +627,19 @@ bool WiFiManager::Save(const char* ssid, std::string_view password, bool connect uint8_t id = Config::AddWiFiCredentials(ssid, password, authMode); if (id == 0) { - Serialization::Local::SerializeErrorMessage("too_many_credentials", CaptivePortal::BroadcastMessageBIN); + Serialization::Local::SerializeErrorMessage("too_many_credentials", AppHooks::CaptivePortalBroadcastMessageBIN); return false; } // Fire Saved event with a minimal WiFiNetwork for the UI WiFiNetwork net; - memset(&net, 0, sizeof(net)); strncpy(net.ssid, ssid, sizeof(net.ssid) - 1); net.authMode = authMode != WIFI_AUTH_MAX ? authMode : WIFI_AUTH_OPEN; net.credentialsID = id; - Serialization::Local::SerializeWiFiNetworkEvent(Serialization::Types::WifiNetworkEventType::Saved, net, CaptivePortal::BroadcastMessageBIN); + Serialization::Local::SerializeWiFiNetworkEvent(Serialization::Types::WifiNetworkEventType::Saved, net, AppHooks::CaptivePortalBroadcastMessageBIN); if (connect) { - s_preferredCredentialsID = id; + requestConnect(id); } return true; @@ -541,7 +663,7 @@ bool WiFiManager::Forget(const char* ssid) // Remove the credentials from the config if (Config::RemoveWiFiCredentials(credsId)) { it->credentialsID = 0; - Serialization::Local::SerializeWiFiNetworkEvent(Serialization::Types::WifiNetworkEventType::Removed, *it, CaptivePortal::BroadcastMessageBIN); + Serialization::Local::SerializeWiFiNetworkEvent(Serialization::Types::WifiNetworkEventType::Removed, *it, AppHooks::CaptivePortalBroadcastMessageBIN); } return true; @@ -567,9 +689,8 @@ bool WiFiManager::Forget(const char* ssid) // Fire Removed event with a minimal WiFiNetwork for the UI WiFiNetwork net; - memset(&net, 0, sizeof(net)); strncpy(net.ssid, ssid, sizeof(net.ssid) - 1); - Serialization::Local::SerializeWiFiNetworkEvent(Serialization::Types::WifiNetworkEventType::Removed, net, CaptivePortal::BroadcastMessageBIN); + Serialization::Local::SerializeWiFiNetworkEvent(Serialization::Types::WifiNetworkEventType::Removed, net, AppHooks::CaptivePortalBroadcastMessageBIN); return true; } @@ -623,7 +744,7 @@ bool WiFiManager::Connect(const char* ssid) void WiFiManager::Disconnect() { - WiFi.disconnect(false); + esp_wifi_disconnect(); } bool WiFiManager::IsConnected() @@ -637,7 +758,8 @@ bool WiFiManager::GetConnectedNetwork(OpenShock::WiFiNetwork& network) if (connectedId != 0) { ScopedLock lock__(&s_networksMutex); - auto it = findNetwork([connectedId](const WiFiNetwork& net) noexcept { return net.credentialsID == connectedId; }); + // Match the associated BSSID: several APs (mesh, 2.4/5 GHz) can share one set of credentials + auto it = findNetworkByBSSID(s_connectedBSSID); if (it != s_wifiNetworks.end()) { network = *it; return true; @@ -658,21 +780,14 @@ bool WiFiManager::GetIPAddress(char* ipAddress) return false; } - IPAddress ip = WiFi.localIP(); - snprintf(ipAddress, IPV4ADDR_FMT_LEN + 1, IPV4ADDR_FMT, IPV4ADDR_ARG(ip)); - - return true; -} - -bool WiFiManager::GetIPv6Address(char* ipAddress) -{ - if (!IsConnected()) { + esp_netif_ip_info_t ipInfo; + if (s_staNetif == nullptr || esp_netif_get_ip_info(s_staNetif, &ipInfo) != ESP_OK) { return false; } - IPv6Address ip = WiFi.localIPv6(); - const uint8_t* ipPtr = ip; // Using the implicit conversion operator of IPv6Address - snprintf(ipAddress, IPV6ADDR_FMT_LEN + 1, IPV6ADDR_FMT, IPV6ADDR_ARG(ipPtr)); + uint8_t ip[4]; + memcpy(ip, &ipInfo.ip.addr, sizeof(ip)); + snprintf(ipAddress, 16, IPV4ADDR_FMT, IPV4ADDR_ARG(ip)); // "255.255.255.255" + NUL return true; } diff --git a/src/wifi/WiFiNetwork.cpp b/components/core/src/wifi/WiFiNetwork.cpp similarity index 100% rename from src/wifi/WiFiNetwork.cpp rename to components/core/src/wifi/WiFiNetwork.cpp diff --git a/components/core/src/wifi/WiFiScanManager.cpp b/components/core/src/wifi/WiFiScanManager.cpp new file mode 100644 index 00000000..69f5954e --- /dev/null +++ b/components/core/src/wifi/WiFiScanManager.cpp @@ -0,0 +1,204 @@ +#include + +#include "wifi/WiFiScanManager.h" + +const char* const TAG = "WiFiScanManager"; + +#include "Logging.h" +#include "SimpleMutex.h" + +#include + +#include +#include +#include + +// Per-channel active-scan dwell time. esp_wifi walks every channel itself, so this is the main knob for scan +// duration. While scanning, the radio is off the AP's channel, so captive-portal clients lose frames for each dwell: +// keep it close to the IDF default (120 ms) rather than 300 ms. +const uint32_t OPENSHOCK_WIFI_SCAN_MIN_MS_PER_CHANNEL = 60; +const uint32_t OPENSHOCK_WIFI_SCAN_MAX_MS_PER_CHANNEL = 120; + +static bool s_initialized = false; +static std::atomic s_scanning = false; +static OpenShock::SimpleMutex s_handlersMutex = {}; +static std::map s_statusChangedHandlers; +static std::map s_networksDiscoveredHandlers; + +using namespace OpenShock; + +static void notifyStatusChangedHandlers(WiFiScanStatus status) +{ + ScopedLock lock__(&s_handlersMutex); + for (auto& it : s_statusChangedHandlers) { + it.second(status); + } +} + +static void notifyNetworksDiscoveredHandlers(const std::vector& records) +{ + ScopedLock lock__(&s_handlersMutex); + for (auto& it : s_networksDiscoveredHandlers) { + it.second(records); + } +} + +// Fetch the scan results esp_wifi buffered for us and fan them out. Runs in the +// esp_event loop task (WIFI_EVENT_SCAN_DONE). +static void handleScanDone() +{ + uint16_t apCount = 0; + esp_err_t err = esp_wifi_scan_get_ap_num(&apCount); + if (err != ESP_OK) { + OS_LOGE(TAG, "esp_wifi_scan_get_ap_num failed: %s", esp_err_to_name(err)); + s_scanning.store(false, std::memory_order_relaxed); + notifyStatusChangedHandlers(WiFiScanStatus::Error); + return; + } + + std::vector records(apCount); + if (apCount > 0) { + err = esp_wifi_scan_get_ap_records(&apCount, records.data()); + if (err != ESP_OK) { + OS_LOGE(TAG, "esp_wifi_scan_get_ap_records failed: %s", esp_err_to_name(err)); + s_scanning.store(false, std::memory_order_relaxed); + notifyStatusChangedHandlers(WiFiScanStatus::Error); + return; + } + records.resize(apCount); + } + + // The handlers expect stable pointers; records[] outlives every synchronous call below. + std::vector recordPtrs; + recordPtrs.reserve(records.size()); + for (const auto& record : records) { + recordPtrs.push_back(&record); + } + + notifyNetworksDiscoveredHandlers(recordPtrs); + + s_scanning.store(false, std::memory_order_relaxed); + notifyStatusChangedHandlers(WiFiScanStatus::Completed); +} + +static void wifiEventHandler(void* arg, esp_event_base_t base, int32_t id, void* data) +{ + (void)arg; + (void)base; + (void)data; + + switch (id) { + case WIFI_EVENT_SCAN_DONE: + // Ignore spurious scan-done events that we didn't initiate (or already handled). + if (s_scanning.load(std::memory_order_relaxed)) { + handleScanDone(); + } + break; + case WIFI_EVENT_STA_STOP: + if (s_scanning.exchange(false, std::memory_order_relaxed)) { + OS_LOGW(TAG, "STA stopped mid-scan, aborting"); + notifyStatusChangedHandlers(WiFiScanStatus::Aborted); + } + break; + default: + break; + } +} + +bool WiFiScanManager::Init() +{ + if (s_initialized) { + OS_LOGW(TAG, "WiFiScanManager is already initialized"); + return true; + } + + esp_err_t err = esp_event_handler_register(WIFI_EVENT, ESP_EVENT_ANY_ID, wifiEventHandler, nullptr); + if (err != ESP_OK) { + OS_LOGE(TAG, "Failed to register WIFI_EVENT handler: %s", esp_err_to_name(err)); + return false; + } + + s_initialized = true; + + return true; +} + +bool WiFiScanManager::IsScanning() +{ + return s_scanning.load(std::memory_order_relaxed); +} + +bool WiFiScanManager::StartScan() +{ + // Claim the scan slot; bail if one is already running. + bool expected = false; + if (!s_scanning.compare_exchange_strong(expected, true, std::memory_order_relaxed)) { + OS_LOGW(TAG, "Cannot start scan: a scan is already running"); + return false; + } + + notifyStatusChangedHandlers(WiFiScanStatus::Started); + + wifi_scan_config_t config = {}; + config.show_hidden = true; + config.scan_type = WIFI_SCAN_TYPE_ACTIVE; + config.scan_time.active.min = OPENSHOCK_WIFI_SCAN_MIN_MS_PER_CHANNEL; + config.scan_time.active.max = OPENSHOCK_WIFI_SCAN_MAX_MS_PER_CHANNEL; + config.home_chan_dwell_time = WIFI_SCAN_HOME_CHANNEL_DWELL_DEFAULT_TIME; // 0 (zero-init) is not the default + + // async: WIFI_EVENT_SCAN_DONE fans out results + esp_err_t err = esp_wifi_scan_start(&config, false); + if (err != ESP_OK) { + OS_LOGE(TAG, "esp_wifi_scan_start failed: %s", esp_err_to_name(err)); + s_scanning.store(false, std::memory_order_relaxed); + notifyStatusChangedHandlers(WiFiScanStatus::Error); + return false; + } + + return true; +} + +bool WiFiScanManager::AbortScan() +{ + if (!s_scanning.exchange(false, std::memory_order_relaxed)) { + OS_LOGW(TAG, "Cannot abort scan: no scan is in progress"); + return false; + } + + esp_err_t err = esp_wifi_scan_stop(); + if (err != ESP_OK) { + OS_LOGE(TAG, "esp_wifi_scan_stop failed: %s", esp_err_to_name(err)); + } + + notifyStatusChangedHandlers(WiFiScanStatus::Aborted); + + return true; +} + +uint64_t WiFiScanManager::RegisterStatusChangedHandler(const WiFiScanManager::StatusChangedHandler& handler) +{ + static uint64_t nextHandle = 0; + ScopedLock lock__(&s_handlersMutex); + uint64_t handle = nextHandle++; + s_statusChangedHandlers[handle] = handler; + return handle; +} +void WiFiScanManager::UnregisterStatusChangedHandler(uint64_t handle) +{ + ScopedLock lock__(&s_handlersMutex); + s_statusChangedHandlers.erase(handle); +} + +uint64_t WiFiScanManager::RegisterNetworksDiscoveredHandler(const WiFiScanManager::NetworksDiscoveredHandler& handler) +{ + static uint64_t nextHandle = 0; + ScopedLock lock__(&s_handlersMutex); + uint64_t handle = nextHandle++; + s_networksDiscoveredHandlers[handle] = handler; + return handle; +} +void WiFiScanManager::UnregisterNetworksDiscoveredHandler(uint64_t handle) +{ + ScopedLock lock__(&s_handlersMutex); + s_networksDiscoveredHandlers.erase(handle); +} diff --git a/components/crypto/CMakeLists.txt b/components/crypto/CMakeLists.txt new file mode 100644 index 00000000..64ff9069 --- /dev/null +++ b/components/crypto/CMakeLists.txt @@ -0,0 +1,14 @@ +# OpenShock crypto helpers — the utilities that pull in mbedTLS, split out of +# `common` so common stays free of the mbedTLS dependency. +# +# Public surface: +# Hashing.h — MD5 / SHA1 / SHA256 hashers (mbedtls/md.h) +# Base64.h — base64 encode/decode (mbedtls/base64.h in the .cpp) + +idf_component_register( + SRCS "src/Base64.cpp" + INCLUDE_DIRS "include" + REQUIRES common # OpenShock.h (Hashing.h), TinyVec.h (Base64.h) in public headers + mbedtls # mbedtls/md.h in Hashing.h public header + PRIV_REQUIRES logging # Logging.h (Base64.cpp) +) diff --git a/components/crypto/host_test/CMakeLists.txt b/components/crypto/host_test/CMakeLists.txt new file mode 100644 index 00000000..b13d16ba --- /dev/null +++ b/components/crypto/host_test/CMakeLists.txt @@ -0,0 +1,14 @@ +cmake_minimum_required(VERSION 3.16) + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) + +# Only build 'main'; the crypto/common headers are pulled in by include path. +set(COMPONENTS main) + +# linux-target freertos mock, so unity's IDF integration has its dependencies, and +# the shared host-test stand-ins (Logging.h, openshock_board.h, CONFIG_OPENSHOCK_*). +list(APPEND EXTRA_COMPONENT_DIRS + "$ENV{IDF_PATH}/tools/mocks/freertos/" + "${CMAKE_CURRENT_LIST_DIR}/../../../test/host_support") + +project(crypto_host_test) diff --git a/components/crypto/host_test/main/CMakeLists.txt b/components/crypto/host_test/main/CMakeLists.txt new file mode 100644 index 00000000..0b9c2eba --- /dev/null +++ b/components/crypto/host_test/main/CMakeLists.txt @@ -0,0 +1,22 @@ +# Compile crypto's PURE sources directly. Base64 pulls in common headers +# (TinyVec.h -> OpenShock.h, Logging.h), included by path below; Logging.h and the +# generated openshock_board.h come from test/host_support. +idf_component_register( + SRCS "test_main.cpp" + "test_base64.cpp" + "../../src/Base64.cpp" + INCLUDE_DIRS "." + "../../include" # crypto headers (Hashing.h, Base64.h) + "../../../common/include" # TinyVec.h, OpenShock.h + REQUIRES unity + host_support # Logging.h, openshock_board.h (test/host_support) + mbedtls # Base64.cpp -> mbedtls/base64.h + WHOLE_ARCHIVE # keep the auto-registered TEST_CASEs from being stripped +) + +# ESP-IDF 6.1 linux-target link order: tf-psa-crypto's platform.c (linked last) +# calls the IDF mbedtls port's esp_mbedtls_mem_calloc/free (port/esp_mem.c), but that +# archive comes earlier on the link line, so nothing has pulled esp_mem.o in yet. +# Mark the two symbols undefined up front, the way IDF itself forces its own +# mbedtls_psa_crypto_init_include_impl. +target_link_libraries(${COMPONENT_LIB} INTERFACE "-u esp_mbedtls_mem_calloc" "-u esp_mbedtls_mem_free") diff --git a/components/crypto/host_test/main/test_base64.cpp b/components/crypto/host_test/main/test_base64.cpp new file mode 100644 index 00000000..7a667385 --- /dev/null +++ b/components/crypto/host_test/main/test_base64.cpp @@ -0,0 +1,81 @@ +// Base64 encode/decode (mbedtls-backed) - used for auth tokens etc. +#include "unity.h" + +#include "Base64.h" +#include "TinyVec.h" + +#include +#include +#include + +using namespace OpenShock; + +TEST_CASE("Base64 encode known vectors", "[util][base64]") +{ + std::string out; + + const uint8_t man[3] = {'M', 'a', 'n'}; + TEST_ASSERT_TRUE(Base64::Encode(std::span(man, 3), out)); + TEST_ASSERT_TRUE(out == "TWFu"); + + // padding cases + const uint8_t m[1] = {'M'}; + TEST_ASSERT_TRUE(Base64::Encode(std::span(m, 1), out)); + TEST_ASSERT_TRUE(out == "TQ=="); + const uint8_t ma[2] = {'M', 'a'}; + TEST_ASSERT_TRUE(Base64::Encode(std::span(ma, 2), out)); + TEST_ASSERT_TRUE(out == "TWE="); +} + +TEST_CASE("Base64 decode known vector", "[util][base64]") +{ + TinyVec out; + TEST_ASSERT_TRUE(Base64::Decode("TWFu", out)); + TEST_ASSERT_EQUAL_size_t(3, out.size()); + TEST_ASSERT_EQUAL_UINT8('M', out[0]); + TEST_ASSERT_EQUAL_UINT8('a', out[1]); + TEST_ASSERT_EQUAL_UINT8('n', out[2]); +} + +TEST_CASE("Base64 decode rejects invalid input", "[util][base64]") +{ + TinyVec out; + TEST_ASSERT_FALSE(Base64::Decode("!!!!", out)); // invalid chars +} + +TEST_CASE("Base64 round-trip", "[util][base64]") +{ + const uint8_t data[5] = {0x00, 0xFF, 0x10, 0x80, 0x7F}; + std::string encoded; + TEST_ASSERT_TRUE(Base64::Encode(std::span(data, 5), encoded)); + + TinyVec decoded; + TEST_ASSERT_TRUE(Base64::Decode(encoded, decoded)); + TEST_ASSERT_EQUAL_size_t(5, decoded.size()); + for (int i = 0; i < 5; ++i) { + TEST_ASSERT_EQUAL_UINT8(data[i], decoded[i]); + } +} + +TEST_CASE("Base64 decode sizes the output exactly, including padded input", "[util][base64]") +{ + TinyVec out; + + TEST_ASSERT_TRUE(Base64::Decode("TQ==", out)); + TEST_ASSERT_EQUAL_size_t(1, out.size()); + TEST_ASSERT_EQUAL_UINT8('M', out[0]); + + TEST_ASSERT_TRUE(Base64::Decode("TWE=", out)); + TEST_ASSERT_EQUAL_size_t(2, out.size()); + + TEST_ASSERT_TRUE(Base64::Decode("", out)); + TEST_ASSERT_EQUAL_size_t(0, out.size()); +} + +TEST_CASE("Base64 failed decode leaves the output empty", "[util][base64]") +{ + TinyVec out; + TEST_ASSERT_TRUE(Base64::Decode("TWFu", out)); + TEST_ASSERT_FALSE(Base64::Decode("TW!u", out)); + TEST_ASSERT_EQUAL_size_t(0, out.size()); +} diff --git a/components/crypto/host_test/main/test_main.cpp b/components/crypto/host_test/main/test_main.cpp new file mode 100644 index 00000000..bfbd91ec --- /dev/null +++ b/components/crypto/host_test/main/test_main.cpp @@ -0,0 +1,10 @@ +// Host-only (linux target) test binary for the crypto component. FreeRTOS is +// mocked, so there is no ESP startup - drive Unity directly. +#include "unity.h" + +extern "C" int main(void) +{ + UNITY_BEGIN(); + unity_run_all_tests(); + return UNITY_END(); +} diff --git a/components/crypto/host_test/sdkconfig.defaults b/components/crypto/host_test/sdkconfig.defaults new file mode 100644 index 00000000..c3d7cf2c --- /dev/null +++ b/components/crypto/host_test/sdkconfig.defaults @@ -0,0 +1,4 @@ +CONFIG_IDF_TARGET="linux" +CONFIG_IDF_TARGET_LINUX=y +CONFIG_COMPILER_CXX_EXCEPTIONS=y +CONFIG_UNITY_ENABLE_IDF_TEST_RUNNER=y diff --git a/components/crypto/include/Base64.h b/components/crypto/include/Base64.h new file mode 100644 index 00000000..d4d34f01 --- /dev/null +++ b/components/crypto/include/Base64.h @@ -0,0 +1,23 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "TinyVec.h" + +namespace OpenShock::Base64 { + /// @brief Encodes a byte array to base64. + /// @param data The data to encode. + /// @param output Receives the encoded text; cleared on failure. + /// @return True on success. + bool Encode(std::span data, std::string& output); + + /// @brief Decodes a base64 string. + /// @param data The base64 text to decode. + /// @param output Receives the decoded bytes (sized to exactly what was decoded); cleared on failure. + /// @return True on success, false on invalid input. + bool Decode(std::string_view data, TinyVec& output) noexcept; +} // namespace OpenShock::Base64 diff --git a/components/crypto/include/Hashing.h b/components/crypto/include/Hashing.h new file mode 100644 index 00000000..9ba3c200 --- /dev/null +++ b/components/crypto/include/Hashing.h @@ -0,0 +1,45 @@ +#pragma once + +#include "OpenShock.h" + +// The low-level per-algorithm headers (mbedtls/md5.h, sha1.h, sha256.h) and their +// mbedtls_md5_* / mbedtls_sha1_* / mbedtls_sha256_* APIs became private in mbedTLS +// 3.6 (shipped with ESP-IDF 6.0). The generic message-digest interface in +// mbedtls/md.h is the public, stable replacement, so all hashers are built on it. +#include + +#include +#include +#include + +namespace OpenShock { + template + class Hasher { + DISABLE_COPY(Hasher); + DISABLE_MOVE(Hasher); + + public: + using Digest = std::array; + + // A setup failure (algorithm not compiled in, out of memory) surfaces as begin() returning false. + Hasher() + { + mbedtls_md_init(&m_ctx); + m_setupOk = mbedtls_md_setup(&m_ctx, mbedtls_md_info_from_type(Type), 0) == 0; + } + ~Hasher() { mbedtls_md_free(&m_ctx); } + + inline bool begin() { return m_setupOk && mbedtls_md_starts(&m_ctx) == 0; } + inline bool update(const uint8_t* data, std::size_t dataLen) { return mbedtls_md_update(&m_ctx, data, dataLen) == 0; } + inline bool update(std::string_view data) { return update(reinterpret_cast(data.data()), data.length()); } + inline bool finish(Digest& hash) { return mbedtls_md_finish(&m_ctx, hash.data()) == 0; } + + private: + mbedtls_md_context_t m_ctx; + bool m_setupOk; + }; + + using MD5 = Hasher; + using SHA1 = Hasher; + using SHA256 = Hasher; +} // namespace OpenShock diff --git a/src/util/Base64Utils.cpp b/components/crypto/src/Base64.cpp similarity index 88% rename from src/util/Base64Utils.cpp rename to components/crypto/src/Base64.cpp index 027350f5..bae55ced 100644 --- a/src/util/Base64Utils.cpp +++ b/components/crypto/src/Base64.cpp @@ -1,6 +1,6 @@ -#include "util/Base64Utils.h" +#include "Base64.h" -const char* const TAG = "Base64Utils"; +const char* const TAG = "Base64"; #include "Logging.h" @@ -18,7 +18,7 @@ constexpr std::size_t CalculateDecodedSize(std::size_t size) noexcept return ((size / 4) * 3) + 3; // +3 guards against missing padding variants } -bool Base64Utils::Encode(tcb::span data, std::string& output) +bool Base64::Encode(std::span data, std::string& output) { std::size_t requiredLen = CalculateEncodedSize(data.size()); @@ -42,7 +42,7 @@ bool Base64Utils::Encode(tcb::span data, std::string& output) return true; } -bool Base64Utils::Decode(std::string_view data, TinyVec& output) noexcept +bool Base64::Decode(std::string_view data, TinyVec& output) noexcept { std::size_t requiredLen = CalculateDecodedSize(data.size()); diff --git a/components/device_control/CMakeLists.txt b/components/device_control/CMakeLists.txt new file mode 100644 index 00000000..3a2d1078 --- /dev/null +++ b/components/device_control/CMakeLists.txt @@ -0,0 +1,19 @@ +# OpenShock device control — the hardware-facing actuators and inputs: estop +# (EStopManager), radio (RFTransmitter), visual status LEDs (VisualStateManager) +# and the shocker CommandHandler that drives the radio. +# +# Extracted out of `core`: its only upstream-core dependency was config, which is +# now its own low-layer component, so device_control depends only on config + the +# foundation/driver layer and nothing else in core. core PRIV_REQUIRES it one-way. +FILE(GLOB_RECURSE device_control_sources + ${CMAKE_CURRENT_SOURCE_DIR}/src/*.cpp) + +idf_component_register( + SRCS ${device_control_sources} + INCLUDE_DIRS "include" + REQUIRES common esp_hal_gpio esp_driver_rmt freertos # public headers (enums, gpio_types, rmt_tx/encoder, TaskHandle/QueueHandle) + PRIV_REQUIRES config chipset events led_drivers logging temporal protocols # concurrency/util folded into common (public REQUIRE) + esp_driver_gpio esp_wifi esp_event esp_netif soc +) + +target_compile_options(${COMPONENT_LIB} PRIVATE -Wno-error) diff --git a/components/device_control/host_test/CMakeLists.txt b/components/device_control/host_test/CMakeLists.txt new file mode 100644 index 00000000..285a3c84 --- /dev/null +++ b/components/device_control/host_test/CMakeLists.txt @@ -0,0 +1,22 @@ +cmake_minimum_required(VERSION 3.16) + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) + +# Only build 'main'; device_control's sources are compiled straight into it (no +# firmware component needs to be linked). +set(COMPONENTS main) + +# ESP-IDF's CMock mocks for everything device_control calls outside the component: +# FreeRTOS queues/tasks/semaphores, the RMT driver (and for the real +# Chipset.h), esp_event and esp_timer. host_fakes.cpp installs callbacks on them that +# route to deterministic fakes, so a test can step the transmit / keep-alive task loops +# against a fake clock. Plus the shared host-test stand-ins (Logging.h, +# openshock_board.h, ESP32 SoC caps, CONFIG_OPENSHOCK_*). +list(APPEND EXTRA_COMPONENT_DIRS + "$ENV{IDF_PATH}/tools/mocks/freertos/" + "$ENV{IDF_PATH}/tools/mocks/driver/" + "$ENV{IDF_PATH}/tools/mocks/esp_event/" + "$ENV{IDF_PATH}/tools/mocks/esp_timer/" + "${CMAKE_CURRENT_LIST_DIR}/../../../test/host_support") + +project(device_control_host_test) diff --git a/components/device_control/host_test/main/CMakeLists.txt b/components/device_control/host_test/main/CMakeLists.txt new file mode 100644 index 00000000..eb931c8f --- /dev/null +++ b/components/device_control/host_test/main/CMakeLists.txt @@ -0,0 +1,46 @@ +# Compile device_control's CommandHandler + RFTransmitter (and what they use from +# protocols, common and events) directly, against the real OpenShock and ESP-IDF +# headers. The IDF APIs are CMock mocks whose callbacks (host_fakes.cpp) step a fake +# clock; config (stubs/config/Config.h), TaskUtils and EStopManager are faked in +# host_fakes.cpp too, so a test can run the transmit/keep-alive task loops +# deterministically. EStopManager's debounce/hold-to-clear logic is tested through +# the pure EStopStateMachine.h. +idf_component_register( + SRCS "test_main.cpp" + "host_fakes.cpp" + "test_estop_state_machine.cpp" + "test_rf_transmitter.cpp" + "test_command_handler.cpp" + "../../src/CommandHandler.cpp" + "../../src/radio/RFTransmitter.cpp" + "../../../common/src/SimpleMutex.cpp" + "../../../events/src/Events.cpp" + "../../../protocols/src/Sequence.cpp" + "../../../protocols/src/CaiXianlinEncoder.cpp" + "../../../protocols/src/D80Encoder.cpp" + "../../../protocols/src/Petrainer998DREncoder.cpp" + "../../../protocols/src/PetrainerEncoder.cpp" + "../../../protocols/src/T330Encoder.cpp" + INCLUDE_DIRS "." + "stubs" # config/Config.h (fake RF/E-Stop settings) + "../../src" # estop/EStopStateMachine.h + "../../include" # CommandHandler.h, estop/*, radio/RFTransmitter.h + "../../../chipset/include" # Chipset.h + "../../../common/include" # OpenShock.h, SimpleMutex.h, enums/*, Checksum.h, util/* + "../../../events/include" # events/Events.h + "../../../protocols/include" # radio/rmt/Sequence.h + encoders + "../../../protocols/src" # radio/rmt/internal/Shared.h + "../../../temporal/include" # Temporal.h (esp_timer_get_time) + REQUIRES unity + host_support # Logging.h, openshock_board.h, ESP32 SoC caps (test/host_support) + freertos # tools/mocks/freertos + driver # tools/mocks/driver: driver/rmt_*.h, driver/gpio.h + esp_event # tools/mocks/esp_event + esp_timer # tools/mocks/esp_timer + esp_hal_gpio # hal/gpio_types.h + WHOLE_ARCHIVE # keep the auto-registered TEST_CASEs from being stripped +) + +# Same as the firmware component: format-string width warnings (%hhi on gpio_num_t) +# are not errors. +target_compile_options(${COMPONENT_LIB} PRIVATE -Wno-error) diff --git a/components/device_control/host_test/main/frames.h b/components/device_control/host_test/main/frames.h new file mode 100644 index 00000000..7df0a503 --- /dev/null +++ b/components/device_control/host_test/main/frames.h @@ -0,0 +1,51 @@ +#pragma once + +// Reference RF frames, built with the same protocols encoders the transmitter uses, +// to classify what the fake RMT recorded. +#include "unity.h" + +#include "host_fakes.h" +#include "radio/rmt/Sequence.h" + +#include +#include +#include + +namespace TestFrames { + inline std::vector Symbols(const rmt_symbol_word_t* data, size_t count) + { + std::vector out; + for (size_t i = 0; i < count; i++) { + out.push_back(data[i].val); + } + return out; + } + + /// @brief The frame sent while a command is live. + inline std::vector Payload(OpenShock::ShockerModelType model, uint16_t shockerId, OpenShock::ShockerCommandType type, uint8_t intensity) + { + OpenShock::Rmt::Sequence seq(model, shockerId, 0); + TEST_ASSERT_TRUE(seq.is_valid()); + TEST_ASSERT_TRUE(seq.fill(type, intensity)); + return Symbols(seq.payload(), seq.size()); + } + + /// @brief The frame sent after a command ends (or is cut off) to stop the shocker. + inline std::vector Terminator(OpenShock::ShockerModelType model, uint16_t shockerId) + { + OpenShock::Rmt::Sequence seq(model, shockerId, 0); + TEST_ASSERT_TRUE(seq.is_valid()); + return Symbols(seq.terminator(), seq.size()); + } + + inline size_t Count(const std::vector& frame) + { + size_t n = 0; + for (const auto& tx : HostFake::Transmissions) { + if (tx.symbols == frame) { + n++; + } + } + return n; + } +} // namespace TestFrames diff --git a/components/device_control/host_test/main/host_fakes.cpp b/components/device_control/host_test/main/host_fakes.cpp new file mode 100644 index 00000000..c099dbe6 --- /dev/null +++ b/components/device_control/host_test/main/host_fakes.cpp @@ -0,0 +1,478 @@ +// Fakes for everything device_control's CommandHandler/RFTransmitter touch outside +// the component. The ESP-IDF APIs (FreeRTOS queues, tasks and semaphores, RMT, +// esp_event, esp_timer) are ESP-IDF's own CMock mocks with the callbacks below +// installed; config, TaskUtils and EStopManager are plain fake definitions. +#include "host_fakes.h" + +#include "unity.h" + +#include "estop/EStopManager.h" +#include "util/TaskUtils.h" + +#include +#include + +// CMock generates C headers without C++ linkage guards. +extern "C" { +#include "Mockesp_event.h" +#include "Mockesp_timer.h" +#include "Mockqueue.h" +#include "Mockrmt_common.h" +#include "Mockrmt_encoder.h" +#include "Mockrmt_tx.h" +#include "Mocktask.h" +} + +#include +#include +#include +#include +#include + +namespace { + // Behind a QueueHandle_t: either a queue or (xSemaphoreCreateMutex) a mutex. + struct FakeQueue { + bool isMutex; + size_t length; + size_t itemSize; + std::deque> items; + bool held; // Mutex only + }; + + // Behind a TaskHandle_t. + struct FakeTask { + TaskFunction_t fn; + void* arg; + std::string name; + bool stopped; + }; + + FakeQueue* AsQueue(QueueHandle_t handle) + { + return reinterpret_cast(handle); + } + QueueHandle_t AsHandle(FakeQueue* queue) + { + return reinterpret_cast(queue); + } + FakeTask* AsTask(TaskHandle_t handle) + { + return reinterpret_cast(handle); + } + + int64_t TicksToMs(TickType_t ticks) + { + return static_cast(ticks) * portTICK_PERIOD_MS; + } +} // namespace + +namespace HostFake { + int64_t Now = 1'000'000; + bool EStopped = false; + int64_t RmtFrameMs = 10; + bool FailRmtChannel = false; + std::vector Transmissions; + std::function OnIdle; + std::function OnTransmit; + OpenShock::Config::RFConfig RfConfig = {.txPin = static_cast(4), .keepAliveEnabled = false}; + OpenShock::Config::EStopConfig EStopCfg = {.enabled = false, .gpioPin = GPIO_NUM_NC}; + esp_event_handler_t EStopHandler = nullptr; + + static std::vector s_liveQueues; // Queues only; mutexes are not counted + static std::vector> s_tasks; + static FakeTask* s_runningTask = nullptr; // Set while RunTask() runs a task body + static int s_idleBudget = 0; // Only consulted while s_runningTask is set + + void Reset() + { + EStopped = false; + EStopCfg = {.enabled = false, .gpioPin = GPIO_NUM_NC}; + RmtFrameMs = 10; + FailRmtChannel = false; + Transmissions.clear(); + OnIdle = nullptr; + OnTransmit = nullptr; + } + + size_t QueueLength(QueueHandle_t queue) + { + return AsQueue(queue)->items.size(); + } + + size_t LiveQueueCount() + { + return s_liveQueues.size(); + } + + QueueHandle_t NewestQueue() + { + return s_liveQueues.empty() ? nullptr : AsHandle(s_liveQueues.back()); + } + + TaskHandle_t FindTask(const char* namePrefix) + { + for (auto it = s_tasks.rbegin(); it != s_tasks.rend(); ++it) { + if (!(*it)->stopped && (*it)->name.rfind(namePrefix, 0) == 0) { + return reinterpret_cast(it->get()); + } + } + + return nullptr; + } + + bool TaskStopped(TaskHandle_t task) + { + return AsTask(task)->stopped; + } + + bool RunTask(TaskHandle_t task, int idleBudget) + { + FakeTask* fakeTask = AsTask(task); + + s_runningTask = fakeTask; + s_idleBudget = idleBudget; + bool returned = false; + try { + fakeTask->fn(fakeTask->arg); + returned = true; + } + catch (const TaskBlocked&) { + } + s_runningTask = nullptr; + s_idleBudget = 0; + + return returned; + } +} // namespace HostFake + +using namespace HostFake; + +// --- FreeRTOS queues and mutexes (tools/mocks/freertos: Mockqueue.h) --- + +static QueueHandle_t fakeQueueGenericCreate(const UBaseType_t length, const UBaseType_t itemSize, const uint8_t, int) +{ + auto* queue = new FakeQueue {.isMutex = false, .length = length, .itemSize = itemSize, .items = {}, .held = false}; + s_liveQueues.push_back(queue); + return AsHandle(queue); +} + +static QueueHandle_t fakeQueueCreateMutex(const uint8_t, int) +{ + return AsHandle(new FakeQueue {.isMutex = true, .length = 1, .itemSize = 0, .items = {}, .held = false}); +} + +// xQueueSend, and xSemaphoreGive on a mutex. +static BaseType_t fakeQueueGenericSend(QueueHandle_t handle, const void* const item, TickType_t, const BaseType_t, int) +{ + FakeQueue* queue = AsQueue(handle); + if (queue->isMutex) { + if (!queue->held) { + return pdFALSE; // Giving a mutex nobody holds + } + queue->held = false; + return pdTRUE; + } + + if (queue->items.size() >= queue->length) { + return pdFALSE; + } + + const auto* bytes = static_cast(item); + queue->items.emplace_back(bytes, bytes + queue->itemSize); + return pdTRUE; +} + +// xSemaphoreTake. Everything runs on the test thread, so a held mutex can only be +// released by whoever is blocked on it: waiting would deadlock on the device. +static BaseType_t fakeQueueSemaphoreTake(QueueHandle_t handle, TickType_t ticksToWait, int) +{ + FakeQueue* mutex = AsQueue(handle); + if (!mutex->held) { + mutex->held = true; + return pdTRUE; + } + + if (ticksToWait == portMAX_DELAY) { + TEST_FAIL_MESSAGE("xSemaphoreTake(portMAX_DELAY) on a mutex that is already held would deadlock"); + } + + Now += TicksToMs(ticksToWait); + return pdFALSE; +} + +static BaseType_t fakeQueueReceive(QueueHandle_t handle, void* const buffer, TickType_t ticksToWait, int) +{ + FakeQueue* queue = AsQueue(handle); + + if (queue->items.empty()) { + if (s_runningTask == nullptr) { + // The test thread itself (e.g. RFTransmitter::destroy() draining its queue). + if (ticksToWait == portMAX_DELAY) { + TEST_FAIL_MESSAGE("xQueueReceive(portMAX_DELAY) on an empty queue outside a task would block forever"); + } + Now += TicksToMs(ticksToWait); + return pdFALSE; + } + + if (s_idleBudget <= 0) { + throw TaskBlocked {}; + } + s_idleBudget--; + + if (OnIdle) { + OnIdle(); + } + + if (queue->items.empty()) { + if (ticksToWait == portMAX_DELAY) { + throw TaskBlocked {}; + } + + // A zero-timeout poll still takes a moment; without this a polling loop would never see time pass. + Now += ticksToWait == 0 ? 1 : TicksToMs(ticksToWait); + return pdFALSE; + } + } + + std::memcpy(buffer, queue->items.front().data(), queue->itemSize); + queue->items.pop_front(); + return pdTRUE; +} + +static BaseType_t fakeQueueGenericReset(QueueHandle_t handle, BaseType_t, int) +{ + AsQueue(handle)->items.clear(); + return pdPASS; +} + +static void fakeQueueDelete(QueueHandle_t handle, int) +{ + FakeQueue* queue = AsQueue(handle); + s_liveQueues.erase(std::remove(s_liveQueues.begin(), s_liveQueues.end(), queue), s_liveQueues.end()); + delete queue; +} + +// --- FreeRTOS tasks (Mocktask.h). Task creation goes through the TaskUtils fake below. --- + +static void fakeTaskDelay(const TickType_t ticks, int) +{ + Now += TicksToMs(ticks); +} + +// ManagedTask wakes a task it stops with a notification; fake tasks only run inside RunTask(), so there is nothing to wake. +static BaseType_t fakeTaskGenericNotify(TaskHandle_t, UBaseType_t, uint32_t, eNotifyAction, uint32_t*, int) +{ + return pdPASS; +} + +// --- esp_timer (tools/mocks/esp_timer), behind Temporal.h's micros()/millis() --- + +static int64_t fakeTimerGetTime(int) +{ + return Now * 1000; +} + +// --- RMT (tools/mocks/driver) --- + +struct rmt_channel_t { + gpio_num_t pin; +}; + +static esp_err_t fakeRmtNewTxChannel(const rmt_tx_channel_config_t* config, rmt_channel_handle_t* ret_chan, int) +{ + if (FailRmtChannel) { + return ESP_FAIL; + } + + *ret_chan = new rmt_channel_t {.pin = config->gpio_num}; + return ESP_OK; +} + +static esp_err_t fakeRmtEnableDisable(rmt_channel_handle_t, int) +{ + return ESP_OK; +} + +static esp_err_t fakeRmtDelChannel(rmt_channel_handle_t channel, int) +{ + delete channel; + return ESP_OK; +} + +static esp_err_t fakeRmtNewCopyEncoder(const rmt_copy_encoder_config_t*, rmt_encoder_handle_t* ret_encoder, int) +{ + *ret_encoder = new rmt_encoder_t {}; + return ESP_OK; +} + +static esp_err_t fakeRmtDelEncoder(rmt_encoder_handle_t encoder, int) +{ + delete encoder; + return ESP_OK; +} + +static esp_err_t fakeRmtTransmit(rmt_channel_handle_t, rmt_encoder_handle_t, const void* payload, size_t payload_bytes, const rmt_transmit_config_t*, int) +{ + const auto* symbols = static_cast(payload); + + Transmission tx {.at = Now, .symbols = {}}; + for (size_t i = 0; i < payload_bytes / sizeof(rmt_symbol_word_t); i++) { + tx.symbols.push_back(symbols[i].val); + } + Transmissions.push_back(std::move(tx)); + + if (OnTransmit) { + OnTransmit(); + } + + return ESP_OK; +} + +static esp_err_t fakeRmtTxWaitAllDone(rmt_channel_handle_t, int, int) +{ + Now += RmtFrameMs; + return ESP_OK; +} + +// --- esp_event (tools/mocks/esp_event) --- + +static esp_err_t fakeEventHandlerRegister(esp_event_base_t, int32_t event_id, esp_event_handler_t event_handler, void*, int) +{ + if (event_id == OPENSHOCK_EVENT_ESTOP_STATE_CHANGED) { + EStopHandler = event_handler; + } + return ESP_OK; +} + +// Installed before any C++ static constructor runs: CommandHandler.cpp's static +// SimpleMutexes call xSemaphoreCreateMutex during static initialization. +__attribute__((constructor(101))) static void installMockCallbacks() +{ + xQueueGenericCreate_Stub(fakeQueueGenericCreate); + xQueueCreateMutex_Stub(fakeQueueCreateMutex); + xQueueGenericSend_Stub(fakeQueueGenericSend); + xQueueSemaphoreTake_Stub(fakeQueueSemaphoreTake); + xQueueReceive_Stub(fakeQueueReceive); + xQueueGenericReset_Stub(fakeQueueGenericReset); + vQueueDelete_Stub(fakeQueueDelete); + vTaskDelay_Stub(fakeTaskDelay); + xTaskGenericNotify_Stub(fakeTaskGenericNotify); + + esp_timer_get_time_Stub(fakeTimerGetTime); + + rmt_new_tx_channel_Stub(fakeRmtNewTxChannel); + rmt_enable_Stub(fakeRmtEnableDisable); + rmt_disable_Stub(fakeRmtEnableDisable); + rmt_del_channel_Stub(fakeRmtDelChannel); + rmt_new_copy_encoder_Stub(fakeRmtNewCopyEncoder); + rmt_del_encoder_Stub(fakeRmtDelEncoder); + rmt_transmit_Stub(fakeRmtTransmit); + rmt_tx_wait_all_done_Stub(fakeRmtTxWaitAllDone); + + esp_event_handler_register_Stub(fakeEventHandlerRegister); +} + +// --- TaskUtils (common's util/TaskUtils.h): record tasks instead of starting them --- + +BaseType_t OpenShock::TaskUtils::TaskCreateUniversal(TaskFunction_t pvTaskCode, const char* const pcName, const uint32_t, void* const pvParameters, UBaseType_t, TaskHandle_t* const pvCreatedTask, const BaseType_t) +{ + s_tasks.push_back(std::make_unique(FakeTask {.fn = pvTaskCode, .arg = pvParameters, .name = pcName, .stopped = false})); + if (pvCreatedTask != nullptr) { + *pvCreatedTask = reinterpret_cast(s_tasks.back().get()); + } + return pdPASS; +} + +BaseType_t OpenShock::TaskUtils::TaskCreateExpensive(TaskFunction_t pvTaskCode, const char* const pcName, const uint32_t usStackDepth, void* const pvParameters, UBaseType_t uxPriority, TaskHandle_t* const pvCreatedTask) +{ + return TaskCreateUniversal(pvTaskCode, pcName, usStackDepth, pvParameters, uxPriority, pvCreatedTask, 1); +} + +void OpenShock::TaskUtils::TaskExiting(TaskExitFlag& exited) +{ + exited.store(true, std::memory_order_relaxed); +} + +void OpenShock::TaskUtils::StopTask(TaskHandle_t taskHandle, TaskExitFlag& exited, const char*, const char*, TickType_t) +{ + if (taskHandle != nullptr) { + AsTask(taskHandle)->stopped = true; + } + exited.store(true, std::memory_order_relaxed); +} + +// --- Config --- + +bool OpenShock::Config::GetRFConfig(RFConfig& out) +{ + out = RfConfig; + return true; +} + +bool OpenShock::Config::GetRFConfigTxPin(gpio_num_t& out) +{ + out = RfConfig.txPin; + return true; +} + +bool OpenShock::Config::SetRFConfigTxPin(gpio_num_t txPin) +{ + RfConfig.txPin = txPin; + return true; +} + +bool OpenShock::Config::GetRFConfigKeepAliveEnabled(bool& out) +{ + out = RfConfig.keepAliveEnabled; + return true; +} + +bool OpenShock::Config::SetRFConfigKeepAliveEnabled(bool enabled) +{ + RfConfig.keepAliveEnabled = enabled; + return true; +} + +bool OpenShock::Config::GetEStopConfig(EStopConfig& out) +{ + out = EStopCfg; + return true; +} + +bool OpenShock::Config::GetEStopGpioPin(gpio_num_t& out) +{ + out = EStopCfg.gpioPin; + return true; +} + +// --- EStopManager (the real one samples GPIO from its own task; see EStopStateMachine for its logic) --- + +bool OpenShock::EStopManager::Init() +{ + return true; +} + +bool OpenShock::EStopManager::SetEStopEnabled(bool) +{ + return true; +} + +bool OpenShock::EStopManager::SetEStopPin(gpio_num_t) +{ + return true; +} + +bool OpenShock::EStopManager::IsEStopped() +{ + return EStopped; +} + +int64_t OpenShock::EStopManager::LastEStopped() +{ + return EStopped ? Now : 0; +} + +void OpenShock::EStopManager::SoftwareTrigger() +{ + EStopped = true; +} diff --git a/components/device_control/host_test/main/host_fakes.h b/components/device_control/host_test/main/host_fakes.h new file mode 100644 index 00000000..79b3abd4 --- /dev/null +++ b/components/device_control/host_test/main/host_fakes.h @@ -0,0 +1,60 @@ +#pragma once + +// Control surface for the fakes behind ESP-IDF's CMock mocks (implemented in +// host_fakes.cpp, which installs them as the mocks' callbacks before any static +// constructor runs). +// +// Tasks never run on their own: TaskCreate* only records them, and RunTask() calls +// the task body synchronously on the test thread. Inside RunTask(), a fake queue +// receive that would block forever (portMAX_DELAY on an empty queue), or that exceeds +// the run's idle budget, throws TaskBlocked to unwind the task loop back into +// RunTask(). Outside RunTask() (the test thread itself, e.g. a destructor draining a +// queue) an empty receive just times out; one that would block forever fails the test. +// A timed receive on an empty queue advances the fake clock by its timeout (inside a +// task, a zero-timeout poll by 1 ms), and every RMT frame advances it by RmtFrameMs, so +// task loops replay deterministically. +#include "config/Config.h" +#include "events/Events.h" + +#include +#include +#include + +#include +#include +#include +#include + +namespace HostFake { + struct TaskBlocked { }; + + struct Transmission { + int64_t at; // Fake clock when the frame was handed to RMT + std::vector symbols; // rmt_symbol_word_t::val of every symbol in the frame + }; + + extern int64_t Now; // OpenShock::millis() (esp_timer_get_time() / 1000) + extern bool EStopped; // EStopManager::IsEStopped() + extern int64_t RmtFrameMs; // Fake clock advance per transmitted frame + extern bool FailRmtChannel; // Make rmt_new_tx_channel fail (RFTransmitter::ok() == false) + extern std::vector Transmissions; + extern std::function OnIdle; // Called on every empty queue receive inside RunTask() + extern std::function OnTransmit; // Called after every recorded RMT frame + extern OpenShock::Config::RFConfig RfConfig; + extern OpenShock::Config::EStopConfig EStopCfg; + extern esp_event_handler_t EStopHandler; // Handler registered for OPENSHOCK_EVENT_ESTOP_STATE_CHANGED + + /// @brief Clears recorded frames and hooks and releases the e-stop. Queues and tasks are owned by the code under test. + void Reset(); + + size_t QueueLength(QueueHandle_t queue); + size_t LiveQueueCount(); + QueueHandle_t NewestQueue(); + + /// @brief Newest task whose name starts with namePrefix and has not been stopped, or nullptr. + TaskHandle_t FindTask(const char* namePrefix); + bool TaskStopped(TaskHandle_t task); + + /// @brief Runs the task body until it returns (true) or would block (false). idleBudget bounds how many empty queue receives it may make. + bool RunTask(TaskHandle_t task, int idleBudget); +} // namespace HostFake diff --git a/components/device_control/host_test/main/stubs/config/Config.h b/components/device_control/host_test/main/stubs/config/Config.h new file mode 100644 index 00000000..03464c83 --- /dev/null +++ b/components/device_control/host_test/main/stubs/config/Config.h @@ -0,0 +1,26 @@ +#pragma once + +// Host-test stub for config's Config.h: just the RF/EStop accessors device_control +// calls, backed by plain values in host_fakes.cpp (HostFake::RfConfig/EStopCfg). +#include + +namespace OpenShock::Config { + struct RFConfig { + gpio_num_t txPin; + bool keepAliveEnabled; + }; + + struct EStopConfig { + bool enabled; + gpio_num_t gpioPin; + }; + + bool GetRFConfig(RFConfig& out); + bool GetRFConfigTxPin(gpio_num_t& out); + bool SetRFConfigTxPin(gpio_num_t txPin); + bool GetRFConfigKeepAliveEnabled(bool& out); + bool SetRFConfigKeepAliveEnabled(bool enabled); + + bool GetEStopConfig(EStopConfig& out); + bool GetEStopGpioPin(gpio_num_t& out); +} // namespace OpenShock::Config diff --git a/components/device_control/host_test/main/test_command_handler.cpp b/components/device_control/host_test/main/test_command_handler.cpp new file mode 100644 index 00000000..dee16c6b --- /dev/null +++ b/components/device_control/host_test/main/test_command_handler.cpp @@ -0,0 +1,294 @@ +// CommandHandler, the producer half of the e-stop interlock, and the keep-alive task +// it manages. CommandHandler is a process-wide singleton, so every test starts from +// resetCommandHandler(): one Init(), then a fresh transmitter and keep-alive off. +#include "unity.h" + +#include "CommandHandler.h" +#include "estop/EStopState.h" +#include "frames.h" +#include "host_fakes.h" + +#include + +using namespace OpenShock; + +namespace { + const gpio_num_t kTxPin = static_cast(4); + const ShockerModelType kModel = ShockerModelType::Petrainer; + const uint16_t kShockerId = 777; + const int kBudget = 10'000; + const int64_t kKeepAliveMs = 60'000; +} // namespace + +static void resetCommandHandler() +{ + HostFake::Reset(); + + static bool initialized = false; + if (!initialized) { + HostFake::RfConfig = {.txPin = kTxPin, .keepAliveEnabled = false}; + TEST_ASSERT_TRUE(CommandHandler::Init()); + TEST_ASSERT_NOT_NULL(HostFake::EStopHandler); + initialized = true; + } + + TEST_ASSERT_TRUE(CommandHandler::SetKeepAliveEnabled(false)); + TEST_ASSERT_EQUAL(SetGPIOResultCode::Success, CommandHandler::SetRfTxPin(kTxPin)); + TEST_ASSERT_TRUE(CommandHandler::Ok()); +} + +static TaskHandle_t transmitTask() +{ + TaskHandle_t task = HostFake::FindTask("RFTransmitter-4"); + TEST_ASSERT_NOT_NULL(task); + return task; +} + +static void deliverEStopState(EStopState state) +{ + HostFake::EStopHandler(nullptr, OPENSHOCK_EVENTS, OPENSHOCK_EVENT_ESTOP_STATE_CHANGED, &state); +} + +TEST_CASE("CommandHandler transmits commands while not e-stopped", "[device_control][command]") +{ + resetCommandHandler(); + + auto shock = TestFrames::Payload(kModel, kShockerId, ShockerCommandType::Shock, 25); + + TEST_ASSERT_TRUE(CommandHandler::HandleCommand(kModel, kShockerId, ShockerCommandType::Shock, 25, 300)); + HostFake::RunTask(transmitTask(), kBudget); + + TEST_ASSERT_TRUE(TestFrames::Count(shock) > 0); +} + +TEST_CASE("CommandHandler refuses commands while e-stopped", "[device_control][command][estop]") +{ + resetCommandHandler(); + + HostFake::EStopped = true; + TEST_ASSERT_FALSE(CommandHandler::HandleCommand(kModel, kShockerId, ShockerCommandType::Shock, 100, 1000)); + TEST_ASSERT_FALSE(CommandHandler::HandleCommand(kModel, kShockerId, ShockerCommandType::Vibrate, 100, 1000)); + TEST_ASSERT_FALSE(CommandHandler::HandleCommand(kModel, kShockerId, ShockerCommandType::Sound, 0, 1000)); + + // Nothing even reached the transmitter's queue. + HostFake::EStopped = false; + HostFake::RunTask(transmitTask(), kBudget); + TEST_ASSERT_EQUAL(0, HostFake::Transmissions.size()); +} + +TEST_CASE("CommandHandler command accepted just before an e-stop is still dropped by the transmitter", "[device_control][command][estop]") +{ + // Producer passes the check, then the e-stop trips before the RF task dequeues + // it: the consumer-side check must catch it. + resetCommandHandler(); + + TEST_ASSERT_TRUE(CommandHandler::HandleCommand(kModel, kShockerId, ShockerCommandType::Shock, 100, 1000)); + HostFake::EStopped = true; + + HostFake::RunTask(transmitTask(), kBudget); + TEST_ASSERT_EQUAL(0, HostFake::Transmissions.size()); +} + +TEST_CASE("CommandHandler zero-duration commands only send terminators", "[device_control][command]") +{ + resetCommandHandler(); + + auto shock = TestFrames::Payload(kModel, kShockerId, ShockerCommandType::Shock, 100); + + TEST_ASSERT_TRUE(CommandHandler::HandleCommand(kModel, kShockerId, ShockerCommandType::Shock, 100, 0)); + HostFake::RunTask(transmitTask(), kBudget); + + TEST_ASSERT_EQUAL(0, TestFrames::Count(shock)); + TEST_ASSERT_TRUE(TestFrames::Count(TestFrames::Terminator(kModel, kShockerId)) > 0); + TEST_ASSERT_EQUAL(HostFake::Transmissions.size(), TestFrames::Count(TestFrames::Terminator(kModel, kShockerId))); +} + +TEST_CASE("CommandHandler never transmits an unknown shocker model", "[device_control][command]") +{ + resetCommandHandler(); + + // HandleCommand does not validate the model (the encoder lookup in the RF task + // rejects it), so only assert nothing goes on air. + CommandHandler::HandleCommand(static_cast(200), kShockerId, ShockerCommandType::Shock, 100, 1000); + HostFake::RunTask(transmitTask(), kBudget); + + TEST_ASSERT_EQUAL(0, HostFake::Transmissions.size()); +} + +TEST_CASE("CommandHandler refuses commands without a transmitter", "[device_control][command]") +{ + resetCommandHandler(); + + // A pin the (ESP32) chipset accepts, so the RMT channel failure is what is hit. + HostFake::FailRmtChannel = true; + TEST_ASSERT_EQUAL(SetGPIOResultCode::InternalError, CommandHandler::SetRfTxPin(static_cast(13))); + HostFake::FailRmtChannel = false; + + TEST_ASSERT_FALSE(CommandHandler::Ok()); + TEST_ASSERT_FALSE(CommandHandler::HandleCommand(kModel, kShockerId, ShockerCommandType::Shock, 10, 100)); +} + +TEST_CASE("CommandHandler invalid TX pin keeps the current transmitter", "[device_control][command]") +{ + resetCommandHandler(); + + TEST_ASSERT_EQUAL(SetGPIOResultCode::InvalidPin, CommandHandler::SetRfTxPin(static_cast(99))); + TEST_ASSERT_EQUAL(SetGPIOResultCode::InvalidPin, CommandHandler::SetRfTxPin(GPIO_NUM_NC)); + + TEST_ASSERT_TRUE(CommandHandler::Ok()); + TEST_ASSERT_EQUAL(kTxPin, CommandHandler::GetRfTxPin()); + TEST_ASSERT_TRUE(CommandHandler::HandleCommand(kModel, kShockerId, ShockerCommandType::Vibrate, 10, 100)); +} + +TEST_CASE("CommandHandler refuses the E-Stop pin as TX pin and keeps the current transmitter", "[device_control][command][estop]") +{ + resetCommandHandler(); + + // Configured but disabled still counts: enabling the E-Stop later must not find its pin driven by RF. + const gpio_num_t estopPin = static_cast(13); + HostFake::EStopCfg = {.enabled = false, .gpioPin = estopPin}; + + TEST_ASSERT_EQUAL(SetGPIOResultCode::PinInUse, CommandHandler::SetRfTxPin(estopPin)); + + TEST_ASSERT_TRUE(CommandHandler::Ok()); + TEST_ASSERT_EQUAL(kTxPin, CommandHandler::GetRfTxPin()); +} + +TEST_CASE("CommandHandler keep-alive enable/disable starts and stops its task", "[device_control][command][keepalive]") +{ + resetCommandHandler(); + size_t queuesBefore = HostFake::LiveQueueCount(); + TEST_ASSERT_NULL(HostFake::FindTask("KeepAliveTask")); + + TEST_ASSERT_TRUE(CommandHandler::SetKeepAliveEnabled(true)); + TEST_ASSERT_TRUE(HostFake::RfConfig.keepAliveEnabled); + TaskHandle_t keepAlive = HostFake::FindTask("KeepAliveTask"); + TEST_ASSERT_NOT_NULL(keepAlive); + TEST_ASSERT_EQUAL(queuesBefore + 1, HostFake::LiveQueueCount()); + + // Enabling twice does not start a second task. + TEST_ASSERT_TRUE(CommandHandler::SetKeepAliveEnabled(true)); + TEST_ASSERT_TRUE(HostFake::FindTask("KeepAliveTask") == keepAlive); + + TEST_ASSERT_TRUE(CommandHandler::SetKeepAliveEnabled(false)); + TEST_ASSERT_FALSE(HostFake::RfConfig.keepAliveEnabled); + TEST_ASSERT_TRUE(HostFake::TaskStopped(keepAlive)); + TEST_ASSERT_NULL(HostFake::FindTask("KeepAliveTask")); + TEST_ASSERT_EQUAL(queuesBefore, HostFake::LiveQueueCount()); + + // With keep-alive off, commands still work and nothing is queued for it. + TEST_ASSERT_TRUE(CommandHandler::HandleCommand(kModel, kShockerId, ShockerCommandType::Vibrate, 10, 100)); + TEST_ASSERT_EQUAL(queuesBefore, HostFake::LiveQueueCount()); +} + +TEST_CASE("CommandHandler only feeds keep-alive for accepted commands", "[device_control][command][keepalive][estop]") +{ + resetCommandHandler(); + TEST_ASSERT_TRUE(CommandHandler::SetKeepAliveEnabled(true)); + QueueHandle_t keepAliveQueue = HostFake::NewestQueue(); + + TEST_ASSERT_TRUE(CommandHandler::HandleCommand(kModel, kShockerId, ShockerCommandType::Vibrate, 10, 100)); + TEST_ASSERT_EQUAL(1, HostFake::QueueLength(keepAliveQueue)); + + HostFake::EStopped = true; + TEST_ASSERT_FALSE(CommandHandler::HandleCommand(kModel, kShockerId, ShockerCommandType::Vibrate, 10, 100)); + TEST_ASSERT_EQUAL(1, HostFake::QueueLength(keepAliveQueue)); + + HostFake::EStopped = false; + TEST_ASSERT_TRUE(CommandHandler::SetKeepAliveEnabled(false)); +} + +TEST_CASE("CommandHandler keep-alive pings a shocker after a minute of inactivity", "[device_control][command][keepalive]") +{ + resetCommandHandler(); + QueueHandle_t rfQueue = HostFake::NewestQueue(); + TEST_ASSERT_TRUE(CommandHandler::SetKeepAliveEnabled(true)); + QueueHandle_t keepAliveQueue = HostFake::NewestQueue(); + TaskHandle_t keepAlive = HostFake::FindTask("KeepAliveTask"); + + int64_t commandEnd = HostFake::Now + 500; + TEST_ASSERT_TRUE(CommandHandler::HandleCommand(kModel, kShockerId, ShockerCommandType::Vibrate, 10, 500)); + TEST_ASSERT_EQUAL(1, HostFake::QueueLength(keepAliveQueue)); + + // Play the command out so the transmitter's queue is empty again. + HostFake::RunTask(transmitTask(), kBudget); + TEST_ASSERT_EQUAL(0, HostFake::QueueLength(rfQueue)); + HostFake::Transmissions.clear(); + + // Step the keep-alive task until it hands something to the transmitter. + int64_t pingedAt = 0; + HostFake::OnIdle = [&] { + if (HostFake::QueueLength(rfQueue) > 0) { + pingedAt = HostFake::Now; + throw HostFake::TaskBlocked {}; + } + }; + HostFake::RunTask(keepAlive, kBudget); + HostFake::OnIdle = nullptr; + + TEST_ASSERT_NOT_EQUAL(0, pingedAt); + TEST_ASSERT_TRUE(pingedAt > commandEnd + kKeepAliveMs); + TEST_ASSERT_TRUE(pingedAt <= commandEnd + kKeepAliveMs + 10); + TEST_ASSERT_EQUAL(1, HostFake::QueueLength(rfQueue)); + + // The ping is a silent 0-intensity vibrate. + auto ping = TestFrames::Payload(kModel, kShockerId, ShockerCommandType::Vibrate, 0); + HostFake::RunTask(transmitTask(), kBudget); + TEST_ASSERT_FALSE(HostFake::Transmissions.empty()); + TEST_ASSERT_EQUAL(HostFake::Transmissions.size(), TestFrames::Count(ping)); + + TEST_ASSERT_TRUE(CommandHandler::SetKeepAliveEnabled(false)); +} + +TEST_CASE("CommandHandler e-stop events suspend keep-alive until Idle", "[device_control][command][keepalive][estop]") +{ + resetCommandHandler(); + TEST_ASSERT_TRUE(CommandHandler::SetKeepAliveEnabled(true)); + TaskHandle_t keepAlive = HostFake::FindTask("KeepAliveTask"); + TEST_ASSERT_NOT_NULL(keepAlive); + + deliverEStopState(EStopState::Active); + TEST_ASSERT_TRUE(HostFake::TaskStopped(keepAlive)); + TEST_ASSERT_NULL(HostFake::FindTask("KeepAliveTask")); + + deliverEStopState(EStopState::ActiveClearing); + deliverEStopState(EStopState::AwaitingRelease); + TEST_ASSERT_NULL(HostFake::FindTask("KeepAliveTask")); + + deliverEStopState(EStopState::Idle); + TEST_ASSERT_NOT_NULL(HostFake::FindTask("KeepAliveTask")); + + TEST_ASSERT_TRUE(CommandHandler::SetKeepAliveEnabled(false)); +} + +TEST_CASE("CommandHandler clearing an e-stop leaves keep-alive off when disabled in config", "[device_control][command][keepalive][estop]") +{ + resetCommandHandler(); + TEST_ASSERT_FALSE(HostFake::RfConfig.keepAliveEnabled); + TEST_ASSERT_NULL(HostFake::FindTask("KeepAliveTask")); + + deliverEStopState(EStopState::Active); + deliverEStopState(EStopState::Idle); + + TEST_ASSERT_NULL(HostFake::FindTask("KeepAliveTask")); + TEST_ASSERT_FALSE(HostFake::RfConfig.keepAliveEnabled); +} + +TEST_CASE("CommandHandler keep-alive enabled while e-stopped starts only once the e-stop clears", "[device_control][command][keepalive][estop]") +{ + resetCommandHandler(); + + HostFake::EStopped = true; + deliverEStopState(EStopState::Active); + + // The setting is saved, but the task stays off while e-stopped. + TEST_ASSERT_TRUE(CommandHandler::SetKeepAliveEnabled(true)); + TEST_ASSERT_TRUE(HostFake::RfConfig.keepAliveEnabled); + TEST_ASSERT_NULL(HostFake::FindTask("KeepAliveTask")); + + HostFake::EStopped = false; + deliverEStopState(EStopState::Idle); + TEST_ASSERT_NOT_NULL(HostFake::FindTask("KeepAliveTask")); + + TEST_ASSERT_TRUE(CommandHandler::SetKeepAliveEnabled(false)); +} diff --git a/components/device_control/host_test/main/test_estop_state_machine.cpp b/components/device_control/host_test/main/test_estop_state_machine.cpp new file mode 100644 index 00000000..acf0def4 --- /dev/null +++ b/components/device_control/host_test/main/test_estop_state_machine.cpp @@ -0,0 +1,337 @@ +// EStop debounce + hold-to-clear state machine (driven by EStopManager's 200 Hz task). +// IsEStopped() is LastEStopped() != 0, which mirrors activatedAt(), so every state but +// Idle must refuse commands. +#include "unity.h" + +#include "estop/EStopStateMachine.h" + +#include + +using namespace OpenShock; + +namespace { + const int kPressed = 0; // Input is pulled up; the button pulls it low + const int kReleased = 1; + + const int64_t kSampleMs = 5; // EStopManager samples at 200 Hz + + struct Rig { + EStopStateMachine machine; + int64_t now = 1'000'000; + + void sample(int level) + { + now += kSampleMs; + machine.Sample(level, now); + } + + void feed(int level, int count) + { + for (int i = 0; i < count; i++) { + sample(level); + } + } + + // Holds the input at `level` until the clock has advanced by at least `ms`. + void hold(int level, int64_t ms) + { + int64_t until = now + ms; + while (now < until) { + sample(level); + } + } + + // Drives Idle -> Active -> (released) -> ActiveClearing -> AwaitingRelease -> Idle. + void activateAndClear() + { + sample(kPressed); + feed(kReleased, EStopStateMachine::kCheckCount); + sample(kPressed); + hold(kPressed, EStopStateMachine::kHoldToClearTime); + feed(kReleased, EStopStateMachine::kCheckCount); + } + + EStopState state() const { return machine.state(); } + bool estopped() const { return machine.activatedAt() != 0; } + }; +} // namespace + +static void assertState(EStopState expected, const Rig& rig) +{ + TEST_ASSERT_EQUAL_UINT8(static_cast(expected), static_cast(rig.state())); + TEST_ASSERT_EQUAL(expected != EStopState::Idle, rig.estopped()); +} + +TEST_CASE("EStop starts Idle and stays Idle while released", "[device_control][estop]") +{ + Rig rig; + assertState(EStopState::Idle, rig); + + rig.hold(kReleased, 10'000); + assertState(EStopState::Idle, rig); +} + +TEST_CASE("EStop activates on the first pressed sample and records the time", "[device_control][estop]") +{ + Rig rig; + rig.sample(kPressed); + + assertState(EStopState::Active, rig); + TEST_ASSERT_EQUAL_INT64(rig.now, rig.machine.activatedAt()); +} + +TEST_CASE("EStop holding the activating press never clears it", "[device_control][estop]") +{ + // The press that tripped the e-stop is not a press edge in Active, so keeping the + // button down must not start hold-to-clear. + Rig rig; + rig.sample(kPressed); + int64_t activatedAt = rig.machine.activatedAt(); + + rig.hold(kPressed, 3 * EStopStateMachine::kHoldToClearTime); + assertState(EStopState::Active, rig); + + rig.feed(kReleased, EStopStateMachine::kCheckCount); + assertState(EStopState::Active, rig); + TEST_ASSERT_EQUAL_INT64(activatedAt, rig.machine.activatedAt()); +} + +TEST_CASE("EStop release is debounced over kCheckCount samples", "[device_control][estop]") +{ + Rig rig; + rig.sample(kPressed); + + // One short of a full window of released samples still reads as pressed, so a + // new press does not register as an edge. + rig.feed(kReleased, EStopStateMachine::kCheckCount - 1); + rig.sample(kPressed); + assertState(EStopState::Active, rig); + + rig.feed(kReleased, EStopStateMachine::kCheckCount); + rig.sample(kPressed); + assertState(EStopState::ActiveClearing, rig); +} + +TEST_CASE("EStop a bounce while released reads as pressed", "[device_control][estop]") +{ + Rig rig; + rig.sample(kPressed); + rig.feed(kReleased, EStopStateMachine::kCheckCount); + + // A single low sample in the window is enough to count as a press edge. + rig.feed(kReleased, 3); + rig.sample(kPressed); + rig.sample(kReleased); + assertState(EStopState::ActiveClearing, rig); +} + +TEST_CASE("EStop clearing requires holding for kHoldToClearTime", "[device_control][estop]") +{ + Rig rig; + rig.sample(kPressed); + rig.feed(kReleased, EStopStateMachine::kCheckCount); + + rig.sample(kPressed); + int64_t edgeAt = rig.now; + assertState(EStopState::ActiveClearing, rig); + + while (rig.now + kSampleMs < edgeAt + EStopStateMachine::kHoldToClearTime) { + rig.sample(kPressed); + assertState(EStopState::ActiveClearing, rig); + } + + rig.sample(kPressed); + TEST_ASSERT_EQUAL_INT64(edgeAt + EStopStateMachine::kHoldToClearTime, rig.now); + assertState(EStopState::AwaitingRelease, rig); + + // Still e-stopped until the button is let go, however long it is held. + rig.hold(kPressed, 10'000); + assertState(EStopState::AwaitingRelease, rig); +} + +TEST_CASE("EStop releasing before the hold time returns to Active", "[device_control][estop]") +{ + Rig rig; + rig.sample(kPressed); + rig.feed(kReleased, EStopStateMachine::kCheckCount); + + rig.sample(kPressed); + rig.hold(kPressed, EStopStateMachine::kHoldToClearTime - 1000); + assertState(EStopState::ActiveClearing, rig); + + rig.feed(kReleased, EStopStateMachine::kCheckCount); + assertState(EStopState::Active, rig); + + // The aborted attempt does not count towards the next one. + rig.sample(kPressed); + rig.hold(kPressed, EStopStateMachine::kHoldToClearTime - 1000); + assertState(EStopState::ActiveClearing, rig); +} + +TEST_CASE("EStop clears to Idle on debounced release after the hold", "[device_control][estop]") +{ + Rig rig; + rig.sample(kPressed); + rig.feed(kReleased, EStopStateMachine::kCheckCount); + rig.sample(kPressed); + rig.hold(kPressed, EStopStateMachine::kHoldToClearTime); + assertState(EStopState::AwaitingRelease, rig); + + rig.feed(kReleased, EStopStateMachine::kCheckCount - 1); + assertState(EStopState::AwaitingRelease, rig); + + rig.sample(kReleased); + assertState(EStopState::Idle, rig); + TEST_ASSERT_EQUAL_INT64(0, rig.machine.activatedAt()); +} + +TEST_CASE("EStop ignores presses during the rearm grace window", "[device_control][estop]") +{ + Rig rig; + rig.activateAndClear(); + assertState(EStopState::Idle, rig); + int64_t clearedAt = rig.now; + + // Release bounce right after clearing: debounced press lasts kCheckCount samples + // but ends before the grace window does. + rig.sample(kPressed); + rig.feed(kReleased, EStopStateMachine::kCheckCount); + TEST_ASSERT_TRUE(rig.now < clearedAt + EStopStateMachine::kRearmGraceTime); + assertState(EStopState::Idle, rig); + + rig.hold(kReleased, EStopStateMachine::kRearmGraceTime); + assertState(EStopState::Idle, rig); + + // Re-armed: the next press trips it again. + rig.sample(kPressed); + assertState(EStopState::Active, rig); +} + +TEST_CASE("EStop a press held through the grace window trips once it ends", "[device_control][estop]") +{ + Rig rig; + rig.activateAndClear(); + int64_t clearedAt = rig.now; + + rig.sample(kPressed); + while (rig.now + kSampleMs < clearedAt + EStopStateMachine::kRearmGraceTime) { + rig.sample(kPressed); + assertState(EStopState::Idle, rig); + } + + rig.sample(kPressed); + assertState(EStopState::Active, rig); +} + +TEST_CASE("EStop software trigger activates from Idle", "[device_control][estop]") +{ + Rig rig; + rig.now += 123; + rig.machine.Trigger(rig.now); + + assertState(EStopState::Active, rig); + TEST_ASSERT_EQUAL_INT64(rig.now, rig.machine.activatedAt()); + + // Releasing does nothing; it still needs the hold-to-clear sequence. + rig.hold(kReleased, 10'000); + assertState(EStopState::Active, rig); +} + +TEST_CASE("EStop software trigger keeps the original activation time", "[device_control][estop]") +{ + Rig rig; + rig.sample(kPressed); + int64_t activatedAt = rig.machine.activatedAt(); + + rig.hold(kPressed, 1000); + rig.machine.Trigger(rig.now); + TEST_ASSERT_EQUAL_INT64(activatedAt, rig.machine.activatedAt()); +} + +TEST_CASE("EStop software trigger cancels an in-progress clear", "[device_control][estop]") +{ + Rig rig; + rig.sample(kPressed); + rig.feed(kReleased, EStopStateMachine::kCheckCount); + rig.sample(kPressed); + rig.hold(kPressed, 1000); + assertState(EStopState::ActiveClearing, rig); + + rig.machine.Trigger(rig.now); + assertState(EStopState::Active, rig); + + // The button is still held, so there is no new edge to restart clearing. + rig.hold(kPressed, 2 * EStopStateMachine::kHoldToClearTime); + assertState(EStopState::Active, rig); +} + +TEST_CASE("EStop software trigger cancels AwaitingRelease", "[device_control][estop]") +{ + Rig rig; + rig.sample(kPressed); + rig.feed(kReleased, EStopStateMachine::kCheckCount); + rig.sample(kPressed); + rig.hold(kPressed, EStopStateMachine::kHoldToClearTime); + assertState(EStopState::AwaitingRelease, rig); + + rig.machine.Trigger(rig.now); + rig.feed(kReleased, EStopStateMachine::kCheckCount); + assertState(EStopState::Active, rig); +} + +TEST_CASE("EStop software trigger overrides the rearm grace window", "[device_control][estop]") +{ + Rig rig; + rig.activateAndClear(); + assertState(EStopState::Idle, rig); + + rig.sample(kReleased); + rig.machine.Trigger(rig.now); + assertState(EStopState::Active, rig); +} + +TEST_CASE("EStop is e-stopped in every state except Idle", "[device_control][estop]") +{ + // Pseudo-random button noise plus occasional software triggers; the invariant + // behind IsEStopped() must hold after every step. + Rig rig; + uint32_t lcg = 12345; + bool sawState[4] = {}; + int64_t lastActive = 0; + + for (int i = 0; i < 200'000; i++) { + lcg = lcg * 1664525u + 1013904223u; + + uint32_t r = lcg >> 16; + if (r % 5000 == 0) { + rig.now += kSampleMs; + rig.machine.Trigger(rig.now); + } else { + // Long runs of each level so hold-to-clear can actually complete. + int level = ((r >> 4) % 4000) < 1500 ? kPressed : kReleased; + if (((lcg >> 8) & 0x3FF) == 0) { + rig.hold(level, 6000); + } else { + rig.sample(level); + } + } + + EStopState state = rig.state(); + sawState[static_cast(state)] = true; + TEST_ASSERT_EQUAL(state != EStopState::Idle, rig.estopped()); + + if (state != EStopState::Idle) { + // The activation time never moves while the e-stop stays active. + if (lastActive != 0) { + TEST_ASSERT_EQUAL_INT64(lastActive, rig.machine.activatedAt()); + } + lastActive = rig.machine.activatedAt(); + } else { + lastActive = 0; + } + } + + TEST_ASSERT_TRUE(sawState[static_cast(EStopState::Idle)]); + TEST_ASSERT_TRUE(sawState[static_cast(EStopState::Active)]); + TEST_ASSERT_TRUE(sawState[static_cast(EStopState::ActiveClearing)]); +} diff --git a/components/device_control/host_test/main/test_main.cpp b/components/device_control/host_test/main/test_main.cpp new file mode 100644 index 00000000..7dc1e9fc --- /dev/null +++ b/components/device_control/host_test/main/test_main.cpp @@ -0,0 +1,11 @@ +// Host-only (linux target) test binary for device_control's safety logic: the EStop +// state machine and the CommandHandler/RFTransmitter e-stop interlock. FreeRTOS is +// mocked (CMock, see host_fakes.cpp), so there is no ESP startup - drive Unity directly. +#include "unity.h" + +extern "C" int main(void) +{ + UNITY_BEGIN(); + unity_run_all_tests(); + return UNITY_END(); +} diff --git a/components/device_control/host_test/main/test_rf_transmitter.cpp b/components/device_control/host_test/main/test_rf_transmitter.cpp new file mode 100644 index 00000000..e07e29be --- /dev/null +++ b/components/device_control/host_test/main/test_rf_transmitter.cpp @@ -0,0 +1,271 @@ +// RFTransmitter::TransmitTask, the consumer half of the e-stop interlock: commands +// that reach its queue while e-stopped must be dropped, and live commands must be cut +// to their terminator frames the moment the e-stop trips. +#include "unity.h" + +#include "frames.h" +#include "host_fakes.h" +#include "radio/RFTransmitter.h" + +#include +#include + +using namespace OpenShock; + +namespace { + const gpio_num_t kPin = static_cast(5); + const ShockerModelType kModel = ShockerModelType::CaiXianlin; + const uint16_t kShockerId = 4321; + const int kBudget = 10'000; + const ShockerModelType kUnknownModel = static_cast(200); +} // namespace + +static TaskHandle_t transmitTask() +{ + TaskHandle_t task = HostFake::FindTask("RFTransmitter-5"); + TEST_ASSERT_NOT_NULL(task); + return task; +} + +TEST_CASE("RFTransmitter sends the payload for the duration, then terminators", "[device_control][rf]") +{ + HostFake::Reset(); + RFTransmitter tx(kPin); + TEST_ASSERT_TRUE(tx.ok()); + + auto shock = TestFrames::Payload(kModel, kShockerId, ShockerCommandType::Shock, 50); + auto terminator = TestFrames::Terminator(kModel, kShockerId); + + int64_t start = HostFake::Now; + TEST_ASSERT_TRUE(tx.SendCommand(kModel, kShockerId, ShockerCommandType::Shock, 50, 200)); + + // Returns false: the task finished every sequence and went back to waiting. + TEST_ASSERT_FALSE(HostFake::RunTask(transmitTask(), kBudget)); + + TEST_ASSERT_TRUE(TestFrames::Count(shock) > 0); + TEST_ASSERT_TRUE(TestFrames::Count(terminator) > 0); + TEST_ASSERT_EQUAL(HostFake::Transmissions.size(), TestFrames::Count(shock) + TestFrames::Count(terminator)); + + for (const auto& frame : HostFake::Transmissions) { + if (frame.symbols == shock) { + TEST_ASSERT_TRUE(frame.at < start + 200); + } else { + // Terminators follow the payload for the 300 ms terminator window. + TEST_ASSERT_TRUE(frame.at >= start + 200); + TEST_ASSERT_TRUE(frame.at < start + 200 + 300); + } + } +} + +TEST_CASE("RFTransmitter drops commands queued while e-stopped", "[device_control][rf][estop]") +{ + HostFake::Reset(); + RFTransmitter tx(kPin); + + HostFake::EStopped = true; + TEST_ASSERT_TRUE(tx.SendCommand(kModel, kShockerId, ShockerCommandType::Shock, 100, 1000)); + TEST_ASSERT_TRUE(tx.SendCommand(kModel, kShockerId, ShockerCommandType::Vibrate, 100, 1000)); + TEST_ASSERT_TRUE(tx.SendCommand(kModel, kShockerId + 1, ShockerCommandType::Sound, 100, 1000)); + + TEST_ASSERT_FALSE(HostFake::RunTask(transmitTask(), kBudget)); + + TEST_ASSERT_EQUAL(0, HostFake::Transmissions.size()); +} + +TEST_CASE("RFTransmitter cuts live commands to terminators when the e-stop trips", "[device_control][rf][estop]") +{ + HostFake::Reset(); + RFTransmitter tx(kPin); + + auto shock = TestFrames::Payload(kModel, kShockerId, ShockerCommandType::Shock, 80); + auto otherShock = TestFrames::Payload(kModel, kShockerId + 1, ShockerCommandType::Shock, 80); + auto lateCmd = TestFrames::Payload(kModel, kShockerId, ShockerCommandType::Vibrate, 30); + auto terminator = TestFrames::Terminator(kModel, kShockerId); + + TEST_ASSERT_TRUE(tx.SendCommand(kModel, kShockerId, ShockerCommandType::Shock, 80, 10'000)); + TEST_ASSERT_TRUE(tx.SendCommand(kModel, kShockerId + 1, ShockerCommandType::Shock, 80, 10'000)); + + // Trip the e-stop mid-transmission, and have a command race in right behind it. + size_t trippedAfter = 0; + HostFake::OnTransmit = [&] { + if (!HostFake::EStopped && HostFake::Transmissions.size() == 6) { + HostFake::EStopped = true; + trippedAfter = HostFake::Transmissions.size(); + tx.SendCommand(kModel, kShockerId, ShockerCommandType::Vibrate, 30, 10'000); + } + }; + + int64_t start = HostFake::Now; + TEST_ASSERT_FALSE(HostFake::RunTask(transmitTask(), kBudget)); + TEST_ASSERT_EQUAL(6, trippedAfter); + + for (size_t i = 0; i < HostFake::Transmissions.size(); i++) { + const auto& symbols = HostFake::Transmissions[i].symbols; + if (i < trippedAfter) { + TEST_ASSERT_TRUE(symbols == shock || symbols == otherShock); + } else { + TEST_ASSERT_FALSE(symbols == shock); + TEST_ASSERT_FALSE(symbols == otherShock); + TEST_ASSERT_FALSE(symbols == lateCmd); + } + } + + TEST_ASSERT_TRUE(TestFrames::Count(terminator) > 0); + TEST_ASSERT_TRUE(TestFrames::Count(TestFrames::Terminator(kModel, kShockerId + 1)) > 0); + + // Both 10 s commands were finished off within the terminator window, not their duration. + TEST_ASSERT_TRUE(HostFake::Transmissions.back().at < start + 1000); +} + +TEST_CASE("RFTransmitter stops payloads within the round in which the e-stop trips", "[device_control][rf][estop]") +{ + HostFake::Reset(); + RFTransmitter tx(kPin); + + auto first = TestFrames::Payload(kModel, kShockerId, ShockerCommandType::Shock, 70); + auto second = TestFrames::Payload(kModel, kShockerId + 1, ShockerCommandType::Shock, 70); + auto third = TestFrames::Payload(kModel, kShockerId + 2, ShockerCommandType::Shock, 70); + + TEST_ASSERT_TRUE(tx.SendCommand(kModel, kShockerId, ShockerCommandType::Shock, 70, 10'000)); + TEST_ASSERT_TRUE(tx.SendCommand(kModel, kShockerId + 1, ShockerCommandType::Shock, 70, 10'000)); + TEST_ASSERT_TRUE(tx.SendCommand(kModel, kShockerId + 2, ShockerCommandType::Shock, 70, 10'000)); + + // Trip after the first frame of a round (3 sequences per round), not at a round boundary. + size_t trippedAfter = 0; + HostFake::OnTransmit = [&] { + if (!HostFake::EStopped && HostFake::Transmissions.size() == 7) { + HostFake::EStopped = true; + trippedAfter = HostFake::Transmissions.size(); + } + }; + + TEST_ASSERT_FALSE(HostFake::RunTask(transmitTask(), kBudget)); + TEST_ASSERT_EQUAL(7, trippedAfter); + + // The rest of that round already sends terminators instead of the remaining payloads. + for (size_t i = trippedAfter; i < HostFake::Transmissions.size(); i++) { + const auto& symbols = HostFake::Transmissions[i].symbols; + TEST_ASSERT_FALSE(symbols == first); + TEST_ASSERT_FALSE(symbols == second); + TEST_ASSERT_FALSE(symbols == third); + } +} + +TEST_CASE("RFTransmitter sends every shocker a terminator even when rounds outlast the terminator window", "[device_control][rf]") +{ + HostFake::Reset(); + // 4 sequences x 120 ms per frame = 480 ms per round, longer than the 300 ms terminator window. + HostFake::RmtFrameMs = 120; + RFTransmitter tx(kPin); + + for (uint16_t i = 0; i < 4; i++) { + TEST_ASSERT_TRUE(tx.SendCommand(kModel, kShockerId + i, ShockerCommandType::Vibrate, 10, 100)); + } + + TEST_ASSERT_FALSE(HostFake::RunTask(transmitTask(), kBudget)); + + for (uint16_t i = 0; i < 4; i++) { + TEST_ASSERT_TRUE_MESSAGE(TestFrames::Count(TestFrames::Terminator(kModel, kShockerId + i)) > 0, "a shocker never got a terminator"); + } +} + +TEST_CASE("RFTransmitter resumes once the e-stop clears", "[device_control][rf][estop]") +{ + HostFake::Reset(); + RFTransmitter tx(kPin); + + auto dropped = TestFrames::Payload(kModel, kShockerId, ShockerCommandType::Shock, 90); + auto accepted = TestFrames::Payload(kModel, kShockerId, ShockerCommandType::Vibrate, 20); + + HostFake::EStopped = true; + TEST_ASSERT_TRUE(tx.SendCommand(kModel, kShockerId, ShockerCommandType::Shock, 90, 1000)); + + // Once the task is idle, clear the e-stop and send a fresh command. + bool cleared = false; + HostFake::OnIdle = [&] { + if (!cleared) { + cleared = true; + HostFake::EStopped = false; + tx.SendCommand(kModel, kShockerId, ShockerCommandType::Vibrate, 20, 200); + } + }; + + TEST_ASSERT_FALSE(HostFake::RunTask(transmitTask(), kBudget)); + TEST_ASSERT_TRUE(cleared); + + TEST_ASSERT_EQUAL(0, TestFrames::Count(dropped)); + TEST_ASSERT_TRUE(TestFrames::Count(accepted) > 0); +} + +TEST_CASE("RFTransmitter Stop replaces a live command with a short zero-intensity vibrate", "[device_control][rf]") +{ + HostFake::Reset(); + RFTransmitter tx(kPin); + + auto shock = TestFrames::Payload(kModel, kShockerId, ShockerCommandType::Shock, 80); + auto stop = TestFrames::Payload(kModel, kShockerId, ShockerCommandType::Vibrate, 0); + + TEST_ASSERT_TRUE(tx.SendCommand(kModel, kShockerId, ShockerCommandType::Shock, 80, 10'000)); + + size_t stoppedAfter = 0; + HostFake::OnTransmit = [&] { + if (stoppedAfter == 0 && HostFake::Transmissions.size() == 3) { + stoppedAfter = HostFake::Transmissions.size(); + // Intensity/duration are ignored for Stop. + tx.SendCommand(kModel, kShockerId, ShockerCommandType::Stop, 99, 10'000, false); + } + }; + + int64_t start = HostFake::Now; + TEST_ASSERT_FALSE(HostFake::RunTask(transmitTask(), kBudget)); + + for (size_t i = stoppedAfter; i < HostFake::Transmissions.size(); i++) { + TEST_ASSERT_TRUE(HostFake::Transmissions[i].symbols == stop); + } + TEST_ASSERT_EQUAL(stoppedAfter, TestFrames::Count(shock)); + TEST_ASSERT_TRUE(HostFake::Transmissions.back().at < start + 1000); +} + +TEST_CASE("RFTransmitter drops commands for an unknown shocker model", "[device_control][rf]") +{ + HostFake::Reset(); + RFTransmitter tx(kPin); + + auto valid = TestFrames::Payload(kModel, kShockerId, ShockerCommandType::Vibrate, 40); + + tx.SendCommand(kUnknownModel, kShockerId, ShockerCommandType::Shock, 100, 1000); + TEST_ASSERT_TRUE(tx.SendCommand(kModel, kShockerId, ShockerCommandType::Vibrate, 40, 100)); + + TEST_ASSERT_FALSE(HostFake::RunTask(transmitTask(), kBudget)); + + // Only the valid command made it to the air. + TEST_ASSERT_TRUE(TestFrames::Count(valid) > 0); + TEST_ASSERT_EQUAL(HostFake::Transmissions.size(), TestFrames::Count(valid) + TestFrames::Count(TestFrames::Terminator(kModel, kShockerId))); +} + +TEST_CASE("RFTransmitter exits on destroy", "[device_control][rf]") +{ + HostFake::Reset(); + size_t queuesBefore = HostFake::LiveQueueCount(); + TaskHandle_t task; + { + RFTransmitter tx(kPin); + task = transmitTask(); + TEST_ASSERT_EQUAL(queuesBefore + 1, HostFake::LiveQueueCount()); + } + + TEST_ASSERT_TRUE(HostFake::TaskStopped(task)); + TEST_ASSERT_EQUAL(queuesBefore, HostFake::LiveQueueCount()); +} + +TEST_CASE("RFTransmitter is not ok when the RMT channel cannot be created", "[device_control][rf]") +{ + HostFake::Reset(); + HostFake::FailRmtChannel = true; + + RFTransmitter tx(kPin); + TEST_ASSERT_FALSE(tx.ok()); + TEST_ASSERT_FALSE(tx.SendCommand(kModel, kShockerId, ShockerCommandType::Shock, 10, 100)); + + HostFake::Reset(); +} diff --git a/components/device_control/host_test/sdkconfig.defaults b/components/device_control/host_test/sdkconfig.defaults new file mode 100644 index 00000000..58581b61 --- /dev/null +++ b/components/device_control/host_test/sdkconfig.defaults @@ -0,0 +1,8 @@ +CONFIG_IDF_TARGET="linux" +CONFIG_IDF_TARGET_LINUX=y +CONFIG_COMPILER_CXX_EXCEPTIONS=y +CONFIG_UNITY_ENABLE_IDF_TEST_RUNNER=y + +# Same tick rate as the firmware (sdkconfig.defaults at the repo root), so +# pdMS_TO_TICKS in the code under test means the same thing it does on the device. +CONFIG_FREERTOS_HZ=1000 diff --git a/include/CommandHandler.h b/components/device_control/include/CommandHandler.h similarity index 81% rename from include/CommandHandler.h rename to components/device_control/include/CommandHandler.h index 93f751ab..0baef0a2 100644 --- a/include/CommandHandler.h +++ b/components/device_control/include/CommandHandler.h @@ -1,8 +1,8 @@ #pragma once -#include "SetGPIOResultCode.h" -#include "ShockerCommandType.h" -#include "ShockerModelType.h" +#include "enums/SetGPIOResultCode.h" +#include "enums/ShockerCommandType.h" +#include "enums/ShockerModelType.h" #include diff --git a/include/estop/EStopManager.h b/components/device_control/include/estop/EStopManager.h similarity index 62% rename from include/estop/EStopManager.h rename to components/device_control/include/estop/EStopManager.h index 22ae5bb9..16fc7688 100644 --- a/include/estop/EStopManager.h +++ b/components/device_control/include/estop/EStopManager.h @@ -8,6 +8,8 @@ namespace OpenShock::EStopManager { [[nodiscard]] bool Init(); + // Both apply the change to the running E-Stop and then persist it to the config (like CommandHandler::SetRfTxPin), + // so callers don't have to keep the two in sync. They refuse while the E-Stop is active. bool SetEStopEnabled(bool enabled); bool SetEStopPin(gpio_num_t pin); bool IsEStopped(); diff --git a/include/estop/EStopState.h b/components/device_control/include/estop/EStopState.h similarity index 100% rename from include/estop/EStopState.h rename to components/device_control/include/estop/EStopState.h diff --git a/include/radio/RFTransmitter.h b/components/device_control/include/radio/RFTransmitter.h similarity index 64% rename from include/radio/RFTransmitter.h rename to components/device_control/include/radio/RFTransmitter.h index af0f3198..ceb4d6e0 100644 --- a/include/radio/RFTransmitter.h +++ b/components/device_control/include/radio/RFTransmitter.h @@ -1,10 +1,12 @@ #pragma once -#include "Common.h" -#include "ShockerCommandType.h" -#include "ShockerModelType.h" +#include "enums/ShockerCommandType.h" +#include "enums/ShockerModelType.h" +#include "OpenShock.h" +#include "util/ManagedTask.h" -#include +#include +#include #include #include @@ -23,7 +25,7 @@ namespace OpenShock { inline gpio_num_t GetTxPin() const { return m_txPin; } - inline bool ok() const { return m_rmtHandle != nullptr && m_queueHandle != nullptr && m_taskHandle != nullptr; } + inline bool ok() const { return m_txPin != GPIO_NUM_NC && m_queueHandle != nullptr && m_task.running(); } bool SendCommand(ShockerModelType model, uint16_t shockerId, ShockerCommandType type, uint8_t intensity, uint16_t durationMs, bool overwriteExisting = true); void ClearPendingCommands(); @@ -35,8 +37,9 @@ namespace OpenShock { struct Command; gpio_num_t m_txPin; - rmt_obj_t* m_rmtHandle; QueueHandle_t m_queueHandle; - TaskHandle_t m_taskHandle; + rmt_channel_handle_t m_rmtChannel; + rmt_encoder_handle_t m_rmtEncoder; + ManagedTask m_task; }; } // namespace OpenShock diff --git a/include/visual/VisualStateManager.h b/components/device_control/include/visual/VisualStateManager.h similarity index 90% rename from include/visual/VisualStateManager.h rename to components/device_control/include/visual/VisualStateManager.h index 041ac279..445fc85b 100644 --- a/include/visual/VisualStateManager.h +++ b/components/device_control/include/visual/VisualStateManager.h @@ -1,6 +1,5 @@ #pragma once -#include "estop/EStopManager.h" #include namespace OpenShock::VisualStateManager { diff --git a/src/CommandHandler.cpp b/components/device_control/src/CommandHandler.cpp similarity index 70% rename from src/CommandHandler.cpp rename to components/device_control/src/CommandHandler.cpp index deec3e23..d9b1889e 100644 --- a/src/CommandHandler.cpp +++ b/components/device_control/src/CommandHandler.cpp @@ -5,19 +5,22 @@ const char* const TAG = "CommandHandler"; #include "Chipset.h" -#include "Common.h" #include "config/Config.h" -#include "Core.h" #include "estop/EStopManager.h" #include "estop/EStopState.h" #include "events/Events.h" #include "Logging.h" + +#include "OpenShock.h" #include "radio/RFTransmitter.h" #include "SimpleMutex.h" -#include "util/TaskUtils.h" +#include "Temporal.h" +#include "util/ManagedTask.h" +#include #include +#include #include #include @@ -27,7 +30,7 @@ const uint16_t KEEP_ALIVE_DURATION = 300; static uint32_t calculateEepyTime(int64_t timeToKeepAlive) { int64_t now = OpenShock::millis(); - return static_cast(std::clamp(timeToKeepAlive - now, 0LL, KEEP_ALIVE_INTERVAL)); + return static_cast(std::clamp(timeToKeepAlive - now, 0, KEEP_ALIVE_INTERVAL)); } struct KnownShocker { @@ -64,18 +67,16 @@ static void DestroyTransmitter() static OpenShock::SimpleMutex s_keepAliveMutex = {}; static QueueHandle_t s_keepAliveQueue = nullptr; -static TaskHandle_t s_keepAliveTaskHandle = nullptr; +static OpenShock::ManagedTask s_keepAliveTask(TAG, "Keep-alive task"); using namespace OpenShock; -static void commandhandler_keepalivetask(void* arg) +static void commandhandler_keepalivetask() { - (void)arg; - int64_t timeToKeepAlive = KEEP_ALIVE_INTERVAL; - // Map of shocker IDs to time of next keep-alive - std::unordered_map activityMap; + // Map of (model, shocker ID) to its last activity; IDs are only unique per model + std::unordered_map activityMap; while (true) { // Calculate eepyTime based on the timeToKeepAlive @@ -85,10 +86,10 @@ static void commandhandler_keepalivetask(void* arg) while (xQueueReceive(s_keepAliveQueue, &cmd, pdMS_TO_TICKS(eepyTime)) == pdTRUE) { if (cmd.killTask) { OS_LOGI(TAG, "Received kill command, exiting keep-alive task"); - goto exit; // Break out of nested loop so locals destruct before vTaskDelete + return; } - activityMap[cmd.shockerId] = cmd; + activityMap[(static_cast(cmd.model) << 16) | cmd.shockerId] = cmd; eepyTime = calculateEepyTime(std::min(timeToKeepAlive, cmd.lastActivityTimestamp + KEEP_ALIVE_INTERVAL)); } @@ -122,21 +123,19 @@ static void commandhandler_keepalivetask(void* arg) timeToKeepAlive = std::min(timeToKeepAlive, cmdRef.lastActivityTimestamp + KEEP_ALIVE_INTERVAL); } } - -exit: // Locals (activityMap) destruct here before task deletion - vTaskDelete(nullptr); } static bool internalSetKeepAliveEnabled(bool enabled) { - bool wasEnabled = s_keepAliveQueue != nullptr && s_keepAliveTaskHandle != nullptr; + // Called from the event loop (EStop changes) and from serial/portal tasks; the check must be under the lock. + ScopedLock lock__(&s_keepAliveMutex); + + bool wasEnabled = s_keepAliveQueue != nullptr && s_keepAliveTask.running(); if (enabled == wasEnabled) { return true; } - ScopedLock lock__(&s_keepAliveMutex); - if (enabled) { OS_LOGV(TAG, "Enabling keep-alive task"); @@ -146,7 +145,7 @@ static bool internalSetKeepAliveEnabled(bool enabled) return false; } - if (TaskUtils::TaskCreateExpensive(commandhandler_keepalivetask, "KeepAliveTask", 4096, nullptr, 1, &s_keepAliveTaskHandle) != pdPASS) { // PROFILED: 1.5KB stack usage + if (!s_keepAliveTask.startExpensive("KeepAliveTask", 4096, 1, commandhandler_keepalivetask)) { // PROFILED: 1.5KB stack usage OS_LOGE(TAG, "Failed to create keep-alive task"); vQueueDelete(s_keepAliveQueue); @@ -156,15 +155,18 @@ static bool internalSetKeepAliveEnabled(bool enabled) } } else { OS_LOGV(TAG, "Disabling keep-alive task"); - if (s_keepAliveTaskHandle != nullptr && s_keepAliveQueue != nullptr) { - // Send kill command + wait for task to exit - KnownShocker cmd; - memset(&cmd, 0, sizeof(cmd)); - cmd.killTask = true; - xQueueSend(s_keepAliveQueue, &cmd, pdMS_TO_TICKS(10)); - - TaskUtils::StopTask(s_keepAliveTaskHandle, TAG, "Keep-alive task"); - s_keepAliveTaskHandle = nullptr; + if (s_keepAliveTask.running() && s_keepAliveQueue != nullptr) { + // The task blocks on its queue, so wake it with a kill command. Drop pending activity first so it always fits. + s_keepAliveTask.stop([] { + xQueueReset(s_keepAliveQueue); + + KnownShocker cmd; + memset(&cmd, 0, sizeof(cmd)); + cmd.killTask = true; + if (xQueueSend(s_keepAliveQueue, &cmd, pdMS_TO_TICKS(100)) != pdTRUE) { + OS_LOGE(TAG, "Failed to queue keep-alive kill command"); + } + }); vQueueDelete(s_keepAliveQueue); s_keepAliveQueue = nullptr; } else { @@ -183,7 +185,19 @@ static void commandhandler_handleestopstatechange(void* event_handler_arg, esp_e EStopState state = *static_cast(event_data); - internalSetKeepAliveEnabled(state == EStopState::Idle); + if (state != EStopState::Idle) { + internalSetKeepAliveEnabled(false); + return; + } + + // E-Stop cleared: restore the configured keep-alive setting instead of forcing it on. + bool keepAliveEnabled; + if (!Config::GetRFConfigKeepAliveEnabled(keepAliveEnabled)) { + OS_LOGE(TAG, "Failed to get keep-alive enabled from config"); + return; + } + + internalSetKeepAliveEnabled(keepAliveEnabled); } bool CommandHandler::Init() @@ -195,6 +209,14 @@ bool CommandHandler::Init() OS_LOGW(TAG, "RF Transmitter and EStopManager are already initialized?"); return true; } + + // Register first so EStop changes gate the keep-alive task even if the transmitter can't be created yet + // (it can still be brought up later through SetRfTxPin). + err = esp_event_handler_register(OPENSHOCK_EVENTS, OPENSHOCK_EVENT_ESTOP_STATE_CHANGED, commandhandler_handleestopstatechange, nullptr); + if (err != ESP_OK) { + OS_LOGE(TAG, "Failed to register event handler for OPENSHOCK_EVENTS: %s", esp_err_to_name(err)); + return false; + } initialized = true; Config::RFConfig rfConfig; @@ -203,6 +225,10 @@ bool CommandHandler::Init() return false; } + if (rfConfig.keepAliveEnabled && !EStopManager::IsEStopped()) { + internalSetKeepAliveEnabled(true); + } + gpio_num_t txPin = rfConfig.txPin; if (!OpenShock::IsValidOutputPin(txPin)) { if (!OpenShock::IsValidOutputPin(OPENSHOCK_RF_TX_GPIO)) { @@ -225,24 +251,6 @@ bool CommandHandler::Init() return false; } - if (rfConfig.keepAliveEnabled) { - internalSetKeepAliveEnabled(true); - } - - Config::EStopConfig estopConfig; - if (!Config::GetEStop(estopConfig)) { - OS_LOGE(TAG, "Failed to get EStop config"); - return false; - } - - err = esp_event_handler_register(OPENSHOCK_EVENTS, OPENSHOCK_EVENT_ESTOP_STATE_CHANGED, commandhandler_handleestopstatechange, nullptr); - if (err != ESP_OK) { - OS_LOGE(TAG, "Failed to register event handler for OPENSHOCK_EVENTS: %s", esp_err_to_name(err)); - return false; - } - - // TODO: Implement EStopManager pin change logic - return true; } @@ -257,13 +265,37 @@ SetGPIOResultCode CommandHandler::SetRfTxPin(gpio_num_t txPin) return SetGPIOResultCode::InvalidPin; } - DestroyTransmitter(); - - OS_LOGV(TAG, "Creating new RF transmitter"); - if (!TryCreateTransmitter(txPin)) { - OS_LOGE(TAG, "Failed to initialize RF transmitter"); + // Driving the E-Stop input as the RF output would defeat the kill switch. Checked against the configured pin even + // while the E-Stop is disabled, so enabling it later can't collide either. + gpio_num_t estopPin = GPIO_NUM_NC; + if (!Config::GetEStopGpioPin(estopPin)) { return SetGPIOResultCode::InternalError; } + if (estopPin != GPIO_NUM_NC && txPin == estopPin) { + return SetGPIOResultCode::PinInUse; + } + + gpio_num_t oldPin = static_cast(OPENSHOCK_GPIO_INVALID); + if (auto current = GetTransmitter(); current != nullptr) { + oldPin = current->GetTxPin(); + } + + if (oldPin != txPin) { + // Free the old RMT channel first: two live channels can exhaust TX channels on smaller chips. + DestroyTransmitter(); + + OS_LOGV(TAG, "Creating new RF transmitter"); + if (!TryCreateTransmitter(txPin)) { + OS_LOGE(TAG, "Failed to initialize RF transmitter"); + + // Keep RF working on the previous pin rather than leaving it down + if (OpenShock::IsValidOutputPin(oldPin) && !TryCreateTransmitter(oldPin)) { + OS_LOGE(TAG, "Failed to restore RF transmitter on previous pin %hhi", oldPin); + } + + return SetGPIOResultCode::InternalError; + } + } if (!Config::SetRFConfigTxPin(txPin)) { OS_LOGE(TAG, "Failed to set RF TX pin in config"); @@ -275,7 +307,8 @@ SetGPIOResultCode CommandHandler::SetRfTxPin(gpio_num_t txPin) bool CommandHandler::SetKeepAliveEnabled(bool enabled) { - if (!internalSetKeepAliveEnabled(enabled)) { + // While e-stopped the task stays off; clearing the e-stop starts it from the saved setting. + if (!internalSetKeepAliveEnabled(enabled && !EStopManager::IsEStopped())) { return false; } diff --git a/components/device_control/src/EStopManager.cpp b/components/device_control/src/EStopManager.cpp new file mode 100644 index 00000000..f70d51b1 --- /dev/null +++ b/components/device_control/src/EStopManager.cpp @@ -0,0 +1,338 @@ +#include + +#include "estop/EStopManager.h" + +const char* const TAG = "EStopManager"; + +#include "Chipset.h" +#include "config/Config.h" +#include "estop/EStopStateMachine.h" +#include "events/Events.h" +#include "Logging.h" +#include "SimpleMutex.h" +#include "Temporal.h" +#include "util/ManagedTask.h" + +#include +#include +#include +#include + +#include +#include + +using namespace OpenShock; + +const uint32_t k_estopUpdateRate = 5; // 200 Hz (debounce/hold timings live in EStopStateMachine) + +const uint32_t k_estopTaskStackSize = 4096; // TODO: profile and tune +const UBaseType_t k_estopTaskPriority = 5; + +static OpenShock::SimpleMutex s_estopMutex = {}; +// Guarded via Mutex +static ManagedTask s_estopTask(TAG, "EStop task"); +static gpio_num_t s_estopPin = GPIO_NUM_NC; // Captured by value when the task starts + +// Wrapped in atomics as they're read (or set via public methods) by Tasks potentially running on other cores. +static std::atomic s_estopActivatedAt = 0; // When == 0, EStop not active. When != 0, EStop is active. +static std::atomic s_externallyTriggered = false; + +static bool s_estopInitialized = false; + +static void estopmgr_publishState(EStopState state, EStopState& lastState, TickType_t timeout = pdMS_TO_TICKS(750)) +{ + if (state == lastState) { + return; // No state change -> no event + } + + // Post the current state as the event payload + esp_err_t err = esp_event_post(OPENSHOCK_EVENTS, OPENSHOCK_EVENT_ESTOP_STATE_CHANGED, &state, sizeof(state), timeout); + + if (err == ESP_OK) { + lastState = state; + } else { + OS_LOGE(TAG, "Failed to publish EStop event: %s", esp_err_to_name(err)); + } +} + +static void estopmgr_latchWithoutTask(); + +// Samples the estop at a fixed rate and updates internal state + events +static void estopmgr_managerTask(gpio_num_t estopPin) +{ + EStopStateMachine machine; + EStopState lastPublishedState = EStopState::Idle; + + // Carry over an E-Stop latched while no task was running (software trigger); it must be cleared with the button. + int64_t latchedAt = s_estopActivatedAt.load(std::memory_order_relaxed); + if (latchedAt != 0) { + machine.Trigger(latchedAt); + lastPublishedState = EStopState::Active; // Already published when it was latched + } + + // Check if killing manager was requested, continue looping otherwise + while (!s_estopTask.stopRequested()) { + // Sleep for the update rate + vTaskDelay(pdMS_TO_TICKS(k_estopUpdateRate)); + + // Get current time + int64_t now = OpenShock::millis(); + + // Handle external trigger: forcibly set the E-Stop active, otherwise sample the EStop input. + if (s_externallyTriggered.exchange(false, std::memory_order_relaxed)) { + machine.Trigger(now); + } else { + machine.Sample(gpio_get_level(estopPin), now); + } + + // This task is the only writer while it runs; mirror before publishing so consumers see the new state. + s_estopActivatedAt.store(machine.activatedAt(), std::memory_order_relaxed); + + estopmgr_publishState(machine.state(), lastPublishedState); + } + + // Broke out of main loop, set global variables to Idle state. + // Use a blocking publish so downstream consumers (keep-alive gating, visuals) + // are guaranteed to observe the Idle transition even under event-loop pressure. + estopmgr_publishState(EStopState::Idle, lastPublishedState, portMAX_DELAY); + s_estopActivatedAt.store(0, std::memory_order_relaxed); +} + +// Validates and configures `pin` as an EStop input. Does not touch s_estopPin +// or any other pin; caller owns the s_estopPin assignment and old-pin release. +static bool estopmgr_configurePin(gpio_num_t pin) +{ + if (!OpenShock::IsValidInputPin(pin)) { + OS_LOGE(TAG, "Invalid EStop pin: %hhi", static_cast(pin)); + return false; + } + + // Configure the new pin + gpio_config_t io_conf = { + .pin_bit_mask = 1ULL << pin, + .mode = GPIO_MODE_INPUT, + .pull_up_en = GPIO_PULLUP_ENABLE, + .pull_down_en = GPIO_PULLDOWN_DISABLE, + .intr_type = GPIO_INTR_DISABLE, + }; + + esp_err_t err = gpio_config(&io_conf); + if (err != ESP_OK) { + OS_LOGE(TAG, "Failed to configure EStop pin: %s", esp_err_to_name(err)); + return false; + } + + return true; +} + +static void estopmgr_releasePin(gpio_num_t pin) +{ + if (pin == GPIO_NUM_NC) { + return; + } + + esp_err_t err = gpio_reset_pin(pin); + if (err != ESP_OK) { + OS_LOGE(TAG, "Failed to reset old EStop pin: %s", esp_err_to_name(err)); + } +} + +static bool estopmgr_taskStart() +{ + if (s_estopTask.running()) { + OS_LOGW(TAG, "Tried to enable EStop manager, but was already running"); + return true; + } + + if (s_estopPin == GPIO_NUM_NC) { + gpio_num_t pin = GPIO_NUM_NC; + if (!OpenShock::Config::GetEStopGpioPin(pin)) { + OS_LOGE(TAG, "Failed to get EStop pin from config"); + return false; + } + + if (pin == GPIO_NUM_NC) { + OS_LOGW(TAG, "No valid pin is defined, refusing to start task"); + return false; + } + + if (!estopmgr_configurePin(pin)) { + return false; + } + + s_estopPin = pin; + } + + // The pin is captured by value, so the task keeps sampling the pin it was started with even if s_estopPin changes. + gpio_num_t pin = s_estopPin; + if (!s_estopTask.start(TAG, k_estopTaskStackSize, k_estopTaskPriority, 1, [pin] { estopmgr_managerTask(pin); })) { + OS_LOGE(TAG, "Failed to create EStop event handler task"); + return false; + } + + return true; +} + +static bool estopmgr_taskStop() +{ + if (!s_estopTask.running()) { + OS_LOGW(TAG, "Tried to kill EStop manager, but was not running"); + return true; + } + + s_estopTask.stop(); + + // Disable E-Stop after task has stopped to ensure that the task didn't get it stuck in enabled state + s_estopActivatedAt.store(0, std::memory_order_relaxed); + + // A software trigger that arrived while the task was shutting down was never consumed; don't lose it. + if (s_externallyTriggered.exchange(false, std::memory_order_relaxed)) { + estopmgr_latchWithoutTask(); + } + + return true; +} + +bool EStopManager::Init() +{ + if (s_estopInitialized) { + return true; + } + s_estopInitialized = true; + + Config::EStopConfig cfg; + if (!OpenShock::Config::GetEStopConfig(cfg)) { + OS_LOGE(TAG, "Failed to get EStop pin from config"); + return false; + } + + if (!cfg.enabled) { + return true; + } + + OpenShock::ScopedLock lock__(&s_estopMutex); + + if (!estopmgr_configurePin(cfg.gpioPin)) { + return false; + } + + s_estopPin = cfg.gpioPin; + + return estopmgr_taskStart(); +} + +bool EStopManager::SetEStopEnabled(bool enabled) +{ + OpenShock::ScopedLock lock__(&s_estopMutex); + + if (enabled) { + if (!estopmgr_taskStart()) { + return false; + } + } else { + // Stopping the task resets the activation, which would release an active EStop without the hold-to-clear. + if (EStopManager::IsEStopped()) { + OS_LOGW(TAG, "Refusing to disable EStop while it is active"); + return false; + } + + if (!estopmgr_taskStop()) { + return false; + } + } + + if (!Config::SetEStopEnabled(enabled)) { + OS_LOGE(TAG, "Failed to save EStop enabled state to config"); + return false; + } + + return true; +} + +bool EStopManager::SetEStopPin(gpio_num_t pin) +{ + OpenShock::ScopedLock lock__(&s_estopMutex); + + if (s_estopPin == pin) { + return true; + } + + // Restarting the task resets the activation, which would release an active EStop without the hold-to-clear. + if (EStopManager::IsEStopped()) { + OS_LOGW(TAG, "Refusing to change EStop pin while EStop is active"); + return false; + } + + // Configure the new pin before touching anything else. If this fails the + // running task keeps sampling the old pin and the manager stays healthy. + if (!estopmgr_configurePin(pin)) { + return false; + } + + gpio_num_t oldPin = s_estopPin; + bool wasRunning = s_estopTask.running(); + + // Stop the task before swapping s_estopPin so the global can't disagree + // with what the task is actually reading. + if (wasRunning && !estopmgr_taskStop()) { + // Roll back the configuration we just did; old pin and task are untouched. + estopmgr_releasePin(pin); + return false; + } + + s_estopPin = pin; + + if (wasRunning && !estopmgr_taskStart()) { + // Task is gone and we can't bring it back. The old pin is no longer being + // sampled so we release it, but the manager is left inactive. + estopmgr_releasePin(oldPin); + return false; + } + + estopmgr_releasePin(oldPin); + + if (!Config::SetEStopGpioPin(pin)) { + OS_LOGE(TAG, "Failed to save EStop pin to config"); + return false; + } + + return true; +} + +bool EStopManager::IsEStopped() +{ + return EStopManager::LastEStopped() != 0; +} + +int64_t EStopManager::LastEStopped() +{ + return s_estopActivatedAt.load(std::memory_order_relaxed); +} + +// Requires s_estopMutex. Activates the E-Stop directly when no manager task is running to pick up the trigger. +// Without a task there is no button to clear it, so it stays active until reboot or until a task (with a pin) +// takes it over and the button is held to clear it. +static void estopmgr_latchWithoutTask() +{ + int64_t expected = 0; + if (!s_estopActivatedAt.compare_exchange_strong(expected, OpenShock::millis(), std::memory_order_relaxed)) { + return; // Already active + } + + EStopState lastState = EStopState::Idle; + estopmgr_publishState(EStopState::Active, lastState, portMAX_DELAY); +} + +void EStopManager::SoftwareTrigger() +{ + OpenShock::ScopedLock lock__(&s_estopMutex); + + if (s_estopTask.running()) { + // Picked up by the manager task on its next tick + s_externallyTriggered.store(true, std::memory_order_relaxed); + return; + } + + OS_LOGW(TAG, "Software EStop triggered without an EStop input configured, latching until reboot"); + estopmgr_latchWithoutTask(); +} diff --git a/components/device_control/src/estop/EStopStateMachine.h b/components/device_control/src/estop/EStopStateMachine.h new file mode 100644 index 00000000..cefe3b59 --- /dev/null +++ b/components/device_control/src/estop/EStopStateMachine.h @@ -0,0 +1,118 @@ +#pragma once + +#include "estop/EStopState.h" + +#include + +namespace OpenShock { + /// @brief The EStop debounce + hold-to-clear state machine, free of FreeRTOS/GPIO so it can be host tested. + /// EStopManager's task feeds it one GPIO sample per tick (or a software trigger) and mirrors activatedAt() into + /// the atomic that IsEStopped() reads. activatedAt() is non-zero in every state except Idle. + class EStopStateMachine { + public: + static constexpr int64_t kHoldToClearTime = 5000; + static constexpr uint32_t kCheckCount = 13; // 65 ms at 200 Hz + static constexpr uint16_t kCheckMask = 0xFFFF >> ((sizeof(uint16_t) * 8) - kCheckCount); // Mask to check only last kCheckCount bits within history + + // Grace period after deactivation (prevents immediate re-trigger on release bounce/EMI) + static constexpr int64_t kRearmGraceTime = 250; // tune as needed + + EStopState state() const noexcept { return m_state; } + int64_t activatedAt() const noexcept { return m_activatedAt; } + + /// @brief Software trigger: forcibly set the E-Stop active. Keeps the original activation time if already active. + void Trigger(int64_t now) noexcept + { + if (m_activatedAt == 0) { + m_activatedAt = now; + } + + m_state = EStopState::Active; + m_rearmBlocked = false; + + // Do not modify history/lastBtnState here; rely on physical button state + // on subsequent samples. + } + + /// @brief Feed one sample of the EStop input. The input is pulled up, so level 0 means pressed. + void Sample(int level, int64_t now) noexcept + { + m_history = static_cast((m_history << 1) | level); + + // Debounce: + // If all recent bits are 1 -> fully released. + // If any bit is 0 -> pressed (or bouncing toward pressed). + bool btnState = (m_history & kCheckMask) != kCheckMask; // true == pressed + bool pressedEdge = (btnState && !m_lastBtnState); + m_lastBtnState = btnState; + + switch (m_state) { + case EStopState::Idle: + // Rearm grace: after clearing, ignore presses for a short window. + // After the window ends, any press activates again (fail-safe: no released state is required first). + if (m_rearmBlocked) { + if (now < m_rearmAt) { + // Still in grace window: ignore any press. Track input to avoid phantom edges later. + break; + } + + // Grace window ended: re-armed. + m_rearmBlocked = false; + } + + if (btnState) { + m_state = EStopState::Active; + m_activatedAt = now; + } + break; + + case EStopState::Active: + // Once active, if the input gets pressed, start hold-to-clear timing. + if (pressedEdge) { + m_state = EStopState::ActiveClearing; + m_deactivatesAt = now + kHoldToClearTime; + } + break; + + case EStopState::ActiveClearing: + if (!btnState) { // released before hold time -> go back to Active + m_state = EStopState::Active; + } else if (now >= m_deactivatesAt) { + // Hold complete -> now wait for release edge to fully clear + m_state = EStopState::AwaitingRelease; + } + break; + + case EStopState::AwaitingRelease: + if (!btnState) { // fully released -> clear E-Stop + m_state = EStopState::Idle; + m_activatedAt = 0; + + // Start grace period to prevent immediate re-trigger. + m_rearmBlocked = true; + m_rearmAt = now + kRearmGraceTime; + } + break; + + default: + // Should never happen + break; + } + } + + private: + EStopState m_state = EStopState::Idle; + int64_t m_activatedAt = 0; // When == 0, EStop not active. When != 0, EStop is active. + + uint16_t m_history = 0xFFFF; // Bit history of samples, 0 is pressed + + int64_t m_deactivatesAt = 0; + + // Rearm grace state + int64_t m_rearmAt = 0; + bool m_rearmBlocked = false; + + // Debounced button state: true == pressed, false == released + bool m_lastBtnState = false; + }; +} // namespace OpenShock diff --git a/src/radio/RFTransmitter.cpp b/components/device_control/src/radio/RFTransmitter.cpp similarity index 58% rename from src/radio/RFTransmitter.cpp rename to components/device_control/src/radio/RFTransmitter.cpp index 1cca6ff8..303f5f34 100644 --- a/src/radio/RFTransmitter.cpp +++ b/components/device_control/src/radio/RFTransmitter.cpp @@ -4,12 +4,15 @@ const char* const TAG = "RFTransmitter"; -#include "Core.h" #include "estop/EStopManager.h" #include "Logging.h" + #include "radio/rmt/Sequence.h" -#include "util/FnProxy.h" -#include "util/TaskUtils.h" +#include "Temporal.h" +#include + +#include +#include #include @@ -18,11 +21,10 @@ const char* const TAG = "RFTransmitter"; const UBaseType_t kQueueSize = 64; const BaseType_t kTaskPriority = 1; const uint32_t kTaskStackSize = 4096; // PROFILED: 1.4KB stack usage -const float kTickrateNs = 1000; const int64_t kTerminatorDurationMs = 300; +const int32_t kRmtTimeoutMs = 100; const uint8_t kFlagOverwrite = 1 << 0; const uint8_t kFlagDeleteTask = 1 << 1; -const TickType_t kTaskIdleDelay = pdMS_TO_TICKS(5); using namespace OpenShock; @@ -37,21 +39,42 @@ struct RFTransmitter::Command { RFTransmitter::RFTransmitter(gpio_num_t gpioPin) : m_txPin(gpioPin) - , m_rmtHandle(nullptr) , m_queueHandle(nullptr) - , m_taskHandle(nullptr) + , m_rmtChannel(nullptr) + , m_rmtEncoder(nullptr) + , m_task(TAG, "RFTransmitter task") { OS_LOGD(TAG, "[pin-%hhi] Creating RFTransmitter", m_txPin); - m_rmtHandle = rmtInit(static_cast(m_txPin), RMT_TX_MODE, RMT_MEM_64); - if (m_rmtHandle == nullptr) { - OS_LOGE(TAG, "[pin-%hhi] Failed to create rmt object", m_txPin); + // 1 MHz resolution => 1 us tick (RF protocol timings are in microseconds). + rmt_tx_channel_config_t channelConfig = { + .gpio_num = gpioPin, + .clk_src = RMT_CLK_SRC_DEFAULT, + .resolution_hz = 1'000'000, + .mem_block_symbols = SOC_RMT_MEM_WORDS_PER_CHANNEL, // exactly 1 memory block + .trans_queue_depth = 4, + }; + esp_err_t err = rmt_new_tx_channel(&channelConfig, &m_rmtChannel); + if (err != ESP_OK) { + OS_LOGE(TAG, "[pin-%hhi] Failed to create RMT TX channel: %s", m_txPin, esp_err_to_name(err)); destroy(); return; } - float realTick = rmtSetTick(m_rmtHandle, kTickrateNs); - OS_LOGD(TAG, "[pin-%hhi] real tick set to: %fns", m_txPin, realTick); + rmt_copy_encoder_config_t encoderConfig = {}; + err = rmt_new_copy_encoder(&encoderConfig, &m_rmtEncoder); + if (err != ESP_OK) { + OS_LOGE(TAG, "[pin-%hhi] Failed to create RMT copy encoder: %s", m_txPin, esp_err_to_name(err)); + destroy(); + return; + } + + err = rmt_enable(m_rmtChannel); + if (err != ESP_OK) { + OS_LOGE(TAG, "[pin-%hhi] Failed to enable RMT channel: %s", m_txPin, esp_err_to_name(err)); + destroy(); + return; + } m_queueHandle = xQueueCreate(kQueueSize, sizeof(Command)); if (m_queueHandle == nullptr) { @@ -63,7 +86,7 @@ RFTransmitter::RFTransmitter(gpio_num_t gpioPin) char name[32]; snprintf(name, sizeof(name), "RFTransmitter-%u", m_txPin); - if (TaskUtils::TaskCreateExpensive(Util::FnProxy<&RFTransmitter::TransmitTask>, name, kTaskStackSize, this, kTaskPriority, &m_taskHandle) != pdPASS) { + if (!m_task.startExpensive(name, kTaskStackSize, kTaskPriority, [this] { TransmitTask(); })) { OS_LOGE(TAG, "[pin-%hhi] Failed to create task", m_txPin); destroy(); return; @@ -91,7 +114,7 @@ bool RFTransmitter::SendCommand(ShockerModelType model, uint16_t shockerId, Shoc durationMs = 300; overwriteExisting = true; } else { - OS_LOGD(TAG, "Command received: %u %u %u %u", model, shockerId, type, intensity); + OS_LOGD(TAG, "Command received: %u %u %u %u", static_cast(model), shockerId, static_cast(type), intensity); } Command cmd = Command {.transmitEnd = OpenShock::millis() + durationMs, .modelType = model, .type = type, .shockerId = shockerId, .intensity = intensity, .flags = overwriteExisting ? kFlagOverwrite : (uint8_t)0}; @@ -120,31 +143,38 @@ void RFTransmitter::ClearPendingCommands() void RFTransmitter::destroy() { - if (m_taskHandle != nullptr) { + if (m_task.running()) { OS_LOGD(TAG, "[pin-%hhi] Stopping task", m_txPin); - // Send kill command + wait for task to exit - Command cmd; - memset(&cmd, 0, sizeof(cmd)); - cmd.flags = kFlagDeleteTask; - xQueueSend(m_queueHandle, &cmd, pdMS_TO_TICKS(10)); + // The task blocks on its queue, so wake it with a kill command. Drop pending commands first so it always fits. + m_task.stop([this] { + ClearPendingCommands(); - TaskUtils::StopTask(m_taskHandle, TAG, "RFTransmitter task"); + Command cmd; + memset(&cmd, 0, sizeof(cmd)); + cmd.flags = kFlagDeleteTask; + if (xQueueSendToFront(m_queueHandle, &cmd, pdMS_TO_TICKS(100)) != pdTRUE) { + OS_LOGE(TAG, "[pin-%hhi] Failed to queue task kill command", m_txPin); + } + }); OS_LOGD(TAG, "[pin-%hhi] Task stopped", m_txPin); // Clear the queue ClearPendingCommands(); - - m_taskHandle = nullptr; } if (m_queueHandle != nullptr) { vQueueDelete(m_queueHandle); m_queueHandle = nullptr; } - if (m_rmtHandle != nullptr) { - rmtDeinit(m_rmtHandle); - m_rmtHandle = nullptr; + if (m_rmtChannel != nullptr) { + rmt_disable(m_rmtChannel); + rmt_del_channel(m_rmtChannel); + m_rmtChannel = nullptr; + } + if (m_rmtEncoder != nullptr) { + rmt_del_encoder(m_rmtEncoder); + m_rmtEncoder = nullptr; } } @@ -173,27 +203,53 @@ static bool modifySequence(std::vector& sequences, ShockerModelTy return false; } -static void writeSequences(rmt_obj_t* rmt_handle, std::vector& sequences) +static void transmitSymbols(rmt_channel_handle_t channel, rmt_encoder_handle_t encoder, const rmt_symbol_word_t* symbols, size_t count) +{ + rmt_transmit_config_t txConfig = {}; + txConfig.flags.eot_level = 0; + + esp_err_t err = rmt_transmit(channel, encoder, symbols, count * sizeof(rmt_symbol_word_t), &txConfig); + if (err != ESP_OK) { + OS_LOGE(TAG, "rmt_transmit failed: %s", esp_err_to_name(err)); + return; + } + + err = rmt_tx_wait_all_done(channel, kRmtTimeoutMs); + if (err != ESP_OK) { + OS_LOGE(TAG, "rmt_tx_wait_all_done failed: %s", esp_err_to_name(err)); + } +} + +static void writeSequences(rmt_channel_handle_t channel, rmt_encoder_handle_t encoder, std::vector& sequences) { // Send queued commands for (auto seq = sequences.begin(); seq != sequences.end();) { - int64_t timeToLive = seq->transmitEnd() - OpenShock::millis(); + int64_t now = OpenShock::millis(); + + // A round can take several frames; re-check EStop per sequence so no payload goes out after activation. + if (seq->transmitEnd() > now && OpenShock::EStopManager::IsEStopped()) { + seq->setTransmitEnd(now); + } + + int64_t timeToLive = seq->transmitEnd() - now; if (timeToLive > 0) { // Send the command - rmtWriteBlocking(rmt_handle, seq->payload(), seq->size()); - } else { - // Remove command if it has sent out its termination sequence for long enough - if (timeToLive <= -kTerminatorDurationMs) { - seq = sequences.erase(seq); - continue; - } + transmitSymbols(channel, encoder, seq->payload(), seq->size()); + ++seq; + continue; + } - // Send the termination sequence to stop the shocker - rmtWriteBlocking(rmt_handle, seq->terminator(), seq->size()); + // Remove command once it has sent out its termination sequence for long enough. A sequence that never got a + // terminator out (a slow round skipped past the whole window, or a failed fill zeroed its end) sends one first. + if (timeToLive <= -kTerminatorDurationMs && seq->terminatorSent()) { + seq = sequences.erase(seq); + continue; } - // Move to the next command + // Send the termination sequence to stop the shocker + transmitSymbols(channel, encoder, seq->terminator(), seq->size()); + seq->markTerminatorSent(); ++seq; } } @@ -210,7 +266,7 @@ void RFTransmitter::TransmitTask() while (xQueueReceive(m_queueHandle, &cmd, sequences.empty() ? portMAX_DELAY : 0) == pdTRUE) { // Destroy task if we receive destroy command if ((cmd.flags & kFlagDeleteTask) != 0) { - goto exit; // Break out of nested loop so locals destruct before vTaskDelete + return; } // Discard any command received while estopped @@ -224,6 +280,9 @@ void RFTransmitter::TransmitTask() continue; } + // EStop is clear when accepting a command; keep wasEstopped in sync so a re-trip is seen as a new edge. + wasEstopped = false; + if ((cmd.flags & kFlagOverwrite) != 0) { // Replace the sequence if it already exists if (modifySequence(sequences, cmd.modelType, cmd.shockerId, cmd.type, cmd.intensity, cmd.transmitEnd)) { @@ -250,9 +309,6 @@ void RFTransmitter::TransmitTask() } } - writeSequences(m_rmtHandle, sequences); + writeSequences(m_rmtChannel, m_rmtEncoder, sequences); } - -exit: // Locals (sequences) destruct here before task deletion - vTaskDelete(nullptr); } diff --git a/src/visual/VisualStateManager.cpp b/components/device_control/src/visual/VisualStateManager.cpp similarity index 78% rename from src/visual/VisualStateManager.cpp rename to components/device_control/src/visual/VisualStateManager.cpp index 66419274..8d668657 100644 --- a/src/visual/VisualStateManager.cpp +++ b/components/device_control/src/visual/VisualStateManager.cpp @@ -4,22 +4,25 @@ const char* const TAG = "VisualStateManager"; +#include "enums/GatewayClientState.h" #include "estop/EStopState.h" #include "events/Events.h" -#include "GatewayClientState.h" +#include "led_drivers/MonoLedDriver.h" +#include "led_drivers/RgbLedDriver.h" #include "Logging.h" -#include "visual/MonoLedDriver.h" -#include "visual/RgbLedDriver.h" + +#include +#include #include #include -#ifndef OPENSHOCK_LED_GPIO -#define OPENSHOCK_LED_GPIO GPIO_NUM_NC -#endif // OPENSHOCK_LED_GPIO -#ifndef OPENSHOCK_LED_WS2812B -#define OPENSHOCK_LED_WS2812B GPIO_NUM_NC -#endif // OPENSHOCK_LED_WS2812B +// Status LED pins are Kconfig options (CONFIG_OPENSHOCK_*, see +// openshock_board.h); -1 (== GPIO_NUM_NC) means the board has no such LED. +#include "sdkconfig.h" + +// Board GPIO assignments (no longer Kconfig; see scripts/gen_env_header.py). +#include "openshock_board.h" const uint64_t kCriticalErrorFlag = 1 << 0; const uint64_t kEmergencyStoppedFlag = 1 << 1; @@ -34,21 +37,16 @@ const uint64_t kWiFiScanningFlag = 1 << 7; const uint64_t kStatusOKMask = kWebSocketConnectedFlag | kHasIpAddressFlag | kWiFiConnectedFlag; static std::atomic s_stateFlags = 0; +static std::atomic s_ledTestActive = false; // While set, the LED test owns the LEDs; flags keep tracking live state static std::unique_ptr s_monoLedDriver; static std::unique_ptr s_rgbLedDriver; -static inline void setStateFlag(uint64_t flag, bool state) -{ - if (state) { - s_stateFlags.fetch_or(flag, std::memory_order_relaxed); - } else { - s_stateFlags.fetch_and(~flag, std::memory_order_relaxed); - } -} - -static inline bool isStateFlagSet(uint64_t flag) +// Sets or clears `flag` and returns whether that changed it. Atomic per flag, so concurrent setters (the event loop, +// SetCriticalError, SetScanningStarted) each see their own change and none is missed. +static inline bool setStateFlag(uint64_t flag, bool state) { - return s_stateFlags.load(std::memory_order_relaxed) & flag; + uint64_t previous = state ? s_stateFlags.fetch_or(flag, std::memory_order_relaxed) : s_stateFlags.fetch_and(~flag, std::memory_order_relaxed); + return ((previous & flag) != 0) != state; } using namespace OpenShock; @@ -127,7 +125,7 @@ const RgbLedDriver::RGBState kWiFiConnectedWithoutWSRGBPattern[] = { { 0, 0, 0, 700} }; -const MonoLedDriver::State kPingNoResponsePattern[] = { +const MonoLedDriver::State kWiFiScanningPattern[] = { { true, 100}, {false, 100}, { true, 100}, @@ -137,7 +135,7 @@ const MonoLedDriver::State kPingNoResponsePattern[] = { { true, 100}, {false, 700} }; -const RgbLedDriver::RGBState kPingNoResponseRGBPattern[] = { +const RgbLedDriver::RGBState kWiFiScanningRGBPattern[] = { {0, 50, 255, 100}, {0, 0, 0, 100}, {0, 50, 255, 100}, @@ -198,12 +196,12 @@ static inline void updateVisualStateGPIO(const MonoLedDriver::State (&override)[ // Check-Set-Return pattern for setting a pattern based on a flag #define CSR_PATTERN(manager, flag, pattern) \ - if (isStateFlagSet(flag)) { \ + if ((flags & (flag)) != 0) { \ manager->SetPattern(pattern); \ return; \ } -static void updateVisualStateGPIO() +static void updateVisualStateGPIO(uint64_t flags) { CSR_PATTERN(s_monoLedDriver, kCriticalErrorFlag, kCriticalErrorPattern); CSR_PATTERN(s_monoLedDriver, kEmergencyStopAwaitingReleaseFlag, kEmergencyStopAwaitingReleasePattern); @@ -211,12 +209,12 @@ static void updateVisualStateGPIO() CSR_PATTERN(s_monoLedDriver, kEmergencyStoppedFlag, kEmergencyStoppedPattern); CSR_PATTERN(s_monoLedDriver, kWebSocketConnectedFlag, kWebSocketConnectedPattern); CSR_PATTERN(s_monoLedDriver, kHasIpAddressFlag, kWiFiConnectedWithoutWSPattern); - CSR_PATTERN(s_monoLedDriver, kWiFiScanningFlag, kPingNoResponsePattern); + CSR_PATTERN(s_monoLedDriver, kWiFiScanningFlag, kWiFiScanningPattern); s_monoLedDriver->SetPattern(kWiFiDisconnectedPattern); } -static void updateVisualStateRGB() +static void updateVisualStateRGB(uint64_t flags) { CSR_PATTERN(s_rgbLedDriver, kCriticalErrorFlag, kCriticalErrorRGBPattern); CSR_PATTERN(s_rgbLedDriver, kEmergencyStopAwaitingReleaseFlag, kEmergencyStopAwaitingReleaseRGBPattern); @@ -224,63 +222,72 @@ static void updateVisualStateRGB() CSR_PATTERN(s_rgbLedDriver, kEmergencyStoppedFlag, kEmergencyStoppedRGBPattern); CSR_PATTERN(s_rgbLedDriver, kWebSocketConnectedFlag, kWebSocketConnectedRGBPattern); CSR_PATTERN(s_rgbLedDriver, kHasIpAddressFlag, kWiFiConnectedWithoutWSRGBPattern); - CSR_PATTERN(s_rgbLedDriver, kWiFiScanningFlag, kPingNoResponseRGBPattern); + CSR_PATTERN(s_rgbLedDriver, kWiFiScanningFlag, kWiFiScanningRGBPattern); s_rgbLedDriver->SetPattern(kWiFiDisconnectedRGBPattern); } -static void updateVisualState() +static void applyVisualState(uint64_t flags) { bool gpioActive = s_monoLedDriver != nullptr; bool rgbActive = s_rgbLedDriver != nullptr; if (gpioActive && rgbActive) { - if (s_stateFlags == kStatusOKMask) { + if (flags == kStatusOKMask) { updateVisualStateGPIO(kSolidOnPattern); } else { updateVisualStateGPIO(kSolidOffPattern); } - updateVisualStateRGB(); + updateVisualStateRGB(flags); return; } if (gpioActive) { - updateVisualStateGPIO(); + updateVisualStateGPIO(flags); return; } if (rgbActive) { - updateVisualStateRGB(); + updateVisualStateRGB(flags); return; } OS_LOGW(TAG, "Trying to update visual state, but no LED is active!"); } +static void updateVisualState() +{ + if (s_ledTestActive.load(std::memory_order_relaxed)) { + return; // Re-applied from the live flags when the test finishes + } + + applyVisualState(s_stateFlags.load(std::memory_order_relaxed)); +} + static void handleEspWiFiEvent(void* event_handler_arg, esp_event_base_t event_base, int32_t event_id, void* event_data) { (void)event_handler_arg; (void)event_base; (void)event_data; - uint64_t oldState = s_stateFlags; + bool changed = false; switch (event_id) { case WIFI_EVENT_STA_CONNECTED: - setStateFlag(kWiFiConnectedFlag, true); + changed = setStateFlag(kWiFiConnectedFlag, true); break; case WIFI_EVENT_STA_DISCONNECTED: - setStateFlag(kWiFiConnectedFlag, false); - setStateFlag(kHasIpAddressFlag, false); + changed = setStateFlag(kWiFiConnectedFlag, false); + changed |= setStateFlag(kHasIpAddressFlag, false); break; case WIFI_EVENT_SCAN_DONE: - setStateFlag(kWiFiScanningFlag, false); + changed = setStateFlag(kWiFiScanningFlag, false); break; default: return; } - if (oldState != s_stateFlags) { + if (changed) { updateVisualState(); } } @@ -291,43 +298,44 @@ static void handleEspIpEvent(void* event_handler_arg, esp_event_base_t event_bas (void)event_base; (void)event_data; - uint64_t oldState = s_stateFlags; + bool changed = false; switch (event_id) { case IP_EVENT_GOT_IP6: case IP_EVENT_STA_GOT_IP: case IP_EVENT_ETH_GOT_IP: case IP_EVENT_PPP_GOT_IP: - setStateFlag(kHasIpAddressFlag, true); + changed = setStateFlag(kHasIpAddressFlag, true); break; case IP_EVENT_STA_LOST_IP: case IP_EVENT_ETH_LOST_IP: case IP_EVENT_PPP_LOST_IP: - setStateFlag(kHasIpAddressFlag, false); + changed = setStateFlag(kHasIpAddressFlag, false); break; default: return; } - if (oldState != s_stateFlags) { + if (changed) { updateVisualState(); } } -static void handleOpenShockEStopStateChanged(void* event_data) +static bool handleOpenShockEStopStateChanged(void* event_data) { auto state = *static_cast(event_data); - setStateFlag(kEmergencyStoppedFlag, state != EStopState::Idle); - setStateFlag(kEmergencyStopActiveClearingFlag, state == EStopState::ActiveClearing); - setStateFlag(kEmergencyStopAwaitingReleaseFlag, state == EStopState::AwaitingRelease); + bool changed = setStateFlag(kEmergencyStoppedFlag, state != EStopState::Idle); + changed |= setStateFlag(kEmergencyStopActiveClearingFlag, state == EStopState::ActiveClearing); + changed |= setStateFlag(kEmergencyStopAwaitingReleaseFlag, state == EStopState::AwaitingRelease); + return changed; } -static void handleOpenShockGatewayStateChanged(void* event_data) +static bool handleOpenShockGatewayStateChanged(void* event_data) { auto state = *static_cast(event_data); - setStateFlag(kWebSocketConnectedFlag, state == GatewayClientState::Connected); + return setStateFlag(kWebSocketConnectedFlag, state == GatewayClientState::Connected); } static void handleOpenShockEvent(void* event_handler_arg, esp_event_base_t event_base, int32_t event_id, void* event_data) @@ -335,21 +343,24 @@ static void handleOpenShockEvent(void* event_handler_arg, esp_event_base_t event (void)event_handler_arg; (void)event_base; - uint64_t oldState = s_stateFlags; + bool changed = false; switch (event_id) { case OPENSHOCK_EVENT_ESTOP_STATE_CHANGED: - handleOpenShockEStopStateChanged(event_data); + changed = handleOpenShockEStopStateChanged(event_data); break; case OPENSHOCK_EVENT_GATEWAY_CLIENT_STATE_CHANGED: - handleOpenShockGatewayStateChanged(event_data); + changed = handleOpenShockGatewayStateChanged(event_data); break; + case OPENSHOCK_EVENT_WIFI_STATE_CHANGED: + // WiFi state is tracked from the IDF WIFI_EVENT/IP_EVENT handlers above. + return; default: - OS_LOGW(TAG, "Received unknown event ID: %i", event_id); + OS_LOGW(TAG, "Received unknown event ID: %d", static_cast(event_id)); return; } - if (oldState != s_stateFlags) { + if (changed) { updateVisualState(); } } @@ -410,30 +421,25 @@ bool VisualStateManager::Init() void VisualStateManager::SetCriticalError() { - uint64_t oldState = s_stateFlags; - - setStateFlag(kCriticalErrorFlag, true); - - if (oldState != s_stateFlags) { + if (setStateFlag(kCriticalErrorFlag, true)) { updateVisualState(); } } void VisualStateManager::SetScanningStarted() { - uint64_t oldState = s_stateFlags; - - setStateFlag(kWiFiScanningFlag, true); - - if (oldState != s_stateFlags) { + if (setStateFlag(kWiFiScanningFlag, true)) { updateVisualState(); } } void VisualStateManager::RunLedTest() { - // Save current state - uint64_t savedFlags = s_stateFlags.load(std::memory_order_relaxed); + // Take over the LEDs; state events keep updating s_stateFlags but don't touch the LEDs until the test ends + if (s_ledTestActive.exchange(true, std::memory_order_relaxed)) { + OS_LOGW(TAG, "LED test already running"); + return; + } OS_LOGI(TAG, "=== LED Test Started ==="); @@ -552,13 +558,12 @@ void VisualStateManager::RunLedTest() OS_LOGI(TAG, " State patterns:"); for (const auto& step : steps) { OS_LOGI(TAG, " %s", step.name); - s_stateFlags.store(step.flags, std::memory_order_relaxed); - updateVisualState(); + applyVisualState(step.flags); vTaskDelay(pdMS_TO_TICKS(step.durationMs)); } - // Restore original state + // Show the live state again (including anything that changed during the test, e.g. an E-Stop) OS_LOGI(TAG, "=== LED Test Complete, restoring state ==="); - s_stateFlags.store(savedFlags, std::memory_order_relaxed); + s_ledTestActive.store(false, std::memory_order_relaxed); updateVisualState(); } diff --git a/components/dns_server/CMakeLists.txt b/components/dns_server/CMakeLists.txt new file mode 100644 index 00000000..a71474a1 --- /dev/null +++ b/components/dns_server/CMakeLists.txt @@ -0,0 +1,8 @@ +# Minimal wildcard DNS responder used by the captive portal (all A queries → a fixed +# IPv4 address). Standalone component so it can be reused independently of the portal. +idf_component_register( + SRCS "src/DNSServer.cpp" "src/DNSPacket.cpp" + INCLUDE_DIRS "include" + REQUIRES common freertos # OpenShock.h + freertos/* in public header + PRIV_REQUIRES logging lwip # .cpp-only (common is already a public requirement) +) diff --git a/components/dns_server/host_test/CMakeLists.txt b/components/dns_server/host_test/CMakeLists.txt new file mode 100644 index 00000000..d27477a0 --- /dev/null +++ b/components/dns_server/host_test/CMakeLists.txt @@ -0,0 +1,15 @@ +cmake_minimum_required(VERSION 3.16) + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) + +# Only build 'main'; it compiles dns_server's pure packet logic directly (see +# main/CMakeLists.txt), so no firmware component needs to be registered. +set(COMPONENTS main) + +# linux-target freertos mock, so unity's IDF integration has its dependencies, and +# the shared host-test stand-ins (Logging.h, openshock_board.h, CONFIG_OPENSHOCK_*). +list(APPEND EXTRA_COMPONENT_DIRS + "$ENV{IDF_PATH}/tools/mocks/freertos/" + "${CMAKE_CURRENT_LIST_DIR}/../../../test/host_support") + +project(dns_server_host_test) diff --git a/components/dns_server/host_test/main/CMakeLists.txt b/components/dns_server/host_test/main/CMakeLists.txt new file mode 100644 index 00000000..79277cd8 --- /dev/null +++ b/components/dns_server/host_test/main/CMakeLists.txt @@ -0,0 +1,13 @@ +# Compile dns_server's PURE packet parse/build source directly (not via the +# dns_server component) so we avoid its socket/task deps (lwip, freertos tasks). +# Logging.h comes from test/host_support. +idf_component_register( + SRCS "test_main.cpp" + "test_dnspacket.cpp" + "../../src/DNSPacket.cpp" + INCLUDE_DIRS "." + "../../src" # DNSPacket.h (private to the component) + REQUIRES unity + host_support # Logging.h (test/host_support) + WHOLE_ARCHIVE # keep the auto-registered TEST_CASEs from being stripped +) diff --git a/components/dns_server/host_test/main/test_dnspacket.cpp b/components/dns_server/host_test/main/test_dnspacket.cpp new file mode 100644 index 00000000..40a5961a --- /dev/null +++ b/components/dns_server/host_test/main/test_dnspacket.cpp @@ -0,0 +1,514 @@ +// DNSPacket::BuildResponse is the captive-portal responder's whole parse/build +// step: it turns a received query into a reply in place or tells the task to drop +// it. These tests feed it crafted packets in buffers sized exactly to the given +// capacity (so a sanitizer build catches any write past the end), and pad the +// bytes past the packet with garbage the parser must never look at. +#include "unity.h" + +#include "DNSPacket.h" + +#include +#include +#include +#include +#include +#include + +using namespace OpenShock; + +namespace { + constexpr uint8_t kIp[4] = {10, 10, 10, 1}; + + constexpr size_t kHeaderSize = 12; + constexpr size_t kAnswerSize = 16; + + constexpr uint16_t kFlagQR = 0x8000; + constexpr uint16_t kFlagAA = 0x0400; + constexpr uint16_t kFlagTC = 0x0200; + constexpr uint16_t kFlagRD = 0x0100; + constexpr uint16_t kFlagRA = 0x0080; + constexpr uint16_t kFlagAD = 0x0020; + constexpr uint16_t kFlagCD = 0x0010; + + constexpr uint16_t kTypeA = 1; + constexpr uint16_t kTypeAAAA = 28; + constexpr uint16_t kTypeANY = 255; + constexpr uint16_t kClassIN = 1; + constexpr uint16_t kClassCH = 3; + + void PutU16(std::vector& out, uint16_t v) + { + out.push_back(static_cast(v >> 8)); + out.push_back(static_cast(v)); + } + + uint16_t GetU16(const uint8_t* p) + { + return static_cast((p[0] << 8) | p[1]); + } + + // Encodes a dotted name ("a.bc") as length-prefixed labels plus the terminator. + std::vector EncodeName(const std::string& dotted) + { + std::vector out; + size_t start = 0; + while (start < dotted.size()) { + size_t dot = dotted.find('.', start); + if (dot == std::string::npos) { + dot = dotted.size(); + } + out.push_back(static_cast(dot - start)); + out.insert(out.end(), dotted.begin() + start, dotted.begin() + dot); + start = dot + 1; + } + out.push_back(0); + return out; + } + + std::vector Header(uint16_t id, uint16_t flags, uint16_t qdcount, uint16_t arcount = 0) + { + std::vector out; + PutU16(out, id); + PutU16(out, flags); + PutU16(out, qdcount); + PutU16(out, 0); // ANCOUNT + PutU16(out, 0); // NSCOUNT + PutU16(out, arcount); + return out; + } + + std::vector Query(const std::vector& name, uint16_t qtype = kTypeA, uint16_t qclass = kClassIN, uint16_t flags = kFlagRD, uint16_t qdcount = 1, uint16_t id = 0xBEEF) + { + std::vector out = Header(id, flags, qdcount); + out.insert(out.end(), name.begin(), name.end()); + PutU16(out, qtype); + PutU16(out, qclass); + return out; + } + + std::vector Query(const char* dotted, uint16_t qtype = kTypeA, uint16_t qclass = kClassIN, uint16_t flags = kFlagRD, uint16_t qdcount = 1, uint16_t id = 0xBEEF) + { + return Query(EncodeName(dotted), qtype, qclass, flags, qdcount, id); + } + + // A heap buffer of exactly `capacity` bytes holding `pkt`, with the rest filled + // with `pad` so a parser that reads past the packet sees non-zero junk. + std::vector Buffer(const std::vector& pkt, size_t capacity, uint8_t pad = 0xA5) + { + std::vector buf(capacity, pad); + std::copy_n(pkt.begin(), pkt.size() < capacity ? pkt.size() : capacity, buf.begin()); + return buf; + } + + size_t Run(std::vector& buf, size_t len) + { + return DNSPacket::BuildResponse(buf.data(), len, buf.size(), kIp); + } + + // Asserts the packet is dropped and the buffer is left untouched. + void AssertIgnored(const std::vector& pkt, size_t capacity = DNSPacket::MAX_PACKET) + { + std::vector buf = Buffer(pkt, capacity); + std::vector before = buf; + TEST_ASSERT_EQUAL_UINT(0, Run(buf, pkt.size())); + TEST_ASSERT_EQUAL_MEMORY(before.data(), buf.data(), buf.size()); + } + + // A name of `total` encoded bytes (labels plus terminator) built from labels + // of at most 63 bytes, the longest a label may be. + std::vector NameOfLength(size_t total) + { + std::vector out; + size_t remaining = total - 1; // the terminator + while (remaining > 0) { + size_t label = remaining - 1 > 63 ? 63 : remaining - 1; + if (remaining - (label + 1) == 1) { + label--; // a lone byte left over could not hold a label, so share it out + } + out.push_back(static_cast(label)); + out.insert(out.end(), label, 'a'); + remaining -= label + 1; + } + out.push_back(0); + return out; + } +} // namespace + +TEST_CASE("A query is answered with the AP address", "[dns_server][answer]") +{ + std::vector pkt = Query("connectivitycheck.gstatic.com"); + std::vector buf = Buffer(pkt, DNSPacket::MAX_PACKET); + + size_t respLen = Run(buf, pkt.size()); + TEST_ASSERT_EQUAL_UINT(pkt.size() + kAnswerSize, respLen); + + // Header: ID echoed, QR|AA set, RD carried over, counts rewritten. + TEST_ASSERT_EQUAL_HEX16(0xBEEF, GetU16(&buf[0])); + TEST_ASSERT_EQUAL_HEX16(kFlagQR | kFlagAA | kFlagRD, GetU16(&buf[2])); + TEST_ASSERT_EQUAL_UINT16(1, GetU16(&buf[4])); // QDCOUNT + TEST_ASSERT_EQUAL_UINT16(1, GetU16(&buf[6])); // ANCOUNT + TEST_ASSERT_EQUAL_UINT16(0, GetU16(&buf[8])); // NSCOUNT + TEST_ASSERT_EQUAL_UINT16(0, GetU16(&buf[10])); // ARCOUNT + + // Question copied unchanged. + TEST_ASSERT_EQUAL_MEMORY(&pkt[kHeaderSize], &buf[kHeaderSize], pkt.size() - kHeaderSize); + + // Answer: pointer to the question, A/IN, 60 s TTL, 4-byte RDATA of the AP address. + const uint8_t expected[kAnswerSize] = {0xC0, 0x0C, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x3C, 0x00, 0x04, kIp[0], kIp[1], kIp[2], kIp[3]}; + TEST_ASSERT_EQUAL_HEX8_ARRAY(expected, &buf[pkt.size()], kAnswerSize); +} + +TEST_CASE("Reply flags carry only RD from the query", "[dns_server][answer]") +{ + std::vector pkt = Query("example.com", kTypeA, kClassIN, 0); + std::vector buf = Buffer(pkt, DNSPacket::MAX_PACKET); + TEST_ASSERT_EQUAL_UINT(pkt.size() + kAnswerSize, Run(buf, pkt.size())); + TEST_ASSERT_EQUAL_HEX16(kFlagQR | kFlagAA, GetU16(&buf[2])); + + // TC/RA/AD/CD and a non-zero RCODE in the query must not leak into the reply. + pkt = Query("example.com", kTypeA, kClassIN, kFlagTC | kFlagRD | kFlagRA | kFlagAD | kFlagCD | 0x000F); + buf = Buffer(pkt, DNSPacket::MAX_PACKET); + TEST_ASSERT_EQUAL_UINT(pkt.size() + kAnswerSize, Run(buf, pkt.size())); + TEST_ASSERT_EQUAL_HEX16(kFlagQR | kFlagAA | kFlagRD, GetU16(&buf[2])); +} + +TEST_CASE("Root name query is answered", "[dns_server][answer]") +{ + std::vector pkt = Query(std::vector {0}); + std::vector buf = Buffer(pkt, DNSPacket::MAX_PACKET); + TEST_ASSERT_EQUAL_UINT(kHeaderSize + 1 + 4 + kAnswerSize, Run(buf, pkt.size())); + TEST_ASSERT_EQUAL_UINT16(1, GetU16(&buf[6])); +} + +TEST_CASE("Non-A or non-IN questions get an empty answer", "[dns_server][answer]") +{ + const uint16_t cases[][2] = { + {kTypeAAAA, kClassIN}, + { kTypeANY, kClassIN}, + { kTypeA, kClassCH}, + }; + + for (const auto& c : cases) { + std::vector pkt = Query("example.com", c[0], c[1]); + std::vector buf = Buffer(pkt, DNSPacket::MAX_PACKET); + + TEST_ASSERT_EQUAL_UINT(pkt.size(), Run(buf, pkt.size())); + TEST_ASSERT_EQUAL_HEX16(kFlagQR | kFlagAA | kFlagRD, GetU16(&buf[2])); + TEST_ASSERT_EQUAL_UINT16(0, GetU16(&buf[6])); // ANCOUNT + TEST_ASSERT_EQUAL_MEMORY(&pkt[kHeaderSize], &buf[kHeaderSize], pkt.size() - kHeaderSize); + } +} + +TEST_CASE("Records after the question are dropped from the reply", "[dns_server][answer]") +{ + // An EDNS0 OPT record in the additional section, as most stub resolvers send. + std::vector pkt = Query("example.com"); + pkt[11] = 1; // ARCOUNT + size_t questionEnd = pkt.size(); + const uint8_t opt[] = {0x00, 0x00, 0x29, 0x04, 0xD0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; + pkt.insert(pkt.end(), std::begin(opt), std::end(opt)); + + std::vector buf = Buffer(pkt, DNSPacket::MAX_PACKET); + TEST_ASSERT_EQUAL_UINT(questionEnd + kAnswerSize, Run(buf, pkt.size())); + TEST_ASSERT_EQUAL_UINT16(0, GetU16(&buf[10])); // ARCOUNT + TEST_ASSERT_EQUAL_HEX8(0xC0, buf[questionEnd]); + TEST_ASSERT_EQUAL_HEX8(0x0C, buf[questionEnd + 1]); +} + +TEST_CASE("Maximum-length name is accepted, one byte more is not", "[dns_server][name]") +{ + // 255 encoded bytes, terminator included, is the longest a name may be. + std::vector longest = NameOfLength(255); + TEST_ASSERT_EQUAL_UINT(255, longest.size()); + std::vector pkt = Query(longest); + std::vector buf = Buffer(pkt, DNSPacket::MAX_PACKET); + TEST_ASSERT_EQUAL_UINT(pkt.size() + kAnswerSize, Run(buf, pkt.size())); + + std::vector tooLong = NameOfLength(256); + TEST_ASSERT_EQUAL_UINT(256, tooLong.size()); + AssertIgnored(Query(tooLong)); +} + +TEST_CASE("63-byte labels are accepted", "[dns_server][name]") +{ + std::vector name {63}; + name.insert(name.end(), 63, 'x'); + name.push_back(0); + std::vector pkt = Query(name); + std::vector buf = Buffer(pkt, DNSPacket::MAX_PACKET); + TEST_ASSERT_EQUAL_UINT(pkt.size() + kAnswerSize, Run(buf, pkt.size())); +} + +TEST_CASE("Runt and truncated packets are ignored", "[dns_server][reject]") +{ + std::vector pkt = Query("example.com"); + + // Zero-length datagram and every truncated header. + for (size_t len = 0; len < kHeaderSize; len++) { + AssertIgnored(std::vector(pkt.begin(), pkt.begin() + len)); + } + + // Header only, no question at all. + AssertIgnored(std::vector(pkt.begin(), pkt.begin() + kHeaderSize)); + + // Cut anywhere in the name or the QTYPE/QCLASS that follows it. + for (size_t len = kHeaderSize + 1; len < pkt.size(); len++) { + AssertIgnored(std::vector(pkt.begin(), pkt.begin() + len)); + } +} + +TEST_CASE("A datagram that fills the buffer is ignored", "[dns_server][reject]") +{ + // recvfrom truncates silently, so a full buffer means a question we never fully saw. + std::vector pkt = Query("example.com"); + AssertIgnored(pkt, pkt.size()); + + // One spare byte is enough to take it at face value (an empty answer fits). + pkt = Query("example.com", kTypeAAAA); + std::vector buf = Buffer(pkt, pkt.size() + 1); + TEST_ASSERT_EQUAL_UINT(pkt.size(), Run(buf, pkt.size())); +} + +TEST_CASE("A reply that would not fit is not built", "[dns_server][reject]") +{ + std::vector pkt = Query("example.com"); + + for (size_t capacity = pkt.size() + 1; capacity < pkt.size() + kAnswerSize; capacity++) { + AssertIgnored(pkt, capacity); + } + + std::vector buf = Buffer(pkt, pkt.size() + kAnswerSize); + TEST_ASSERT_EQUAL_UINT(buf.size(), Run(buf, pkt.size())); +} + +TEST_CASE("Responses are ignored", "[dns_server][reject]") +{ + AssertIgnored(Query("example.com", kTypeA, kClassIN, kFlagQR)); + AssertIgnored(Query("example.com", kTypeA, kClassIN, kFlagQR | kFlagAA | kFlagRD)); +} + +TEST_CASE("Non-standard opcodes are ignored", "[dns_server][reject]") +{ + for (uint16_t opcode = 1; opcode < 16; opcode++) { + AssertIgnored(Query("example.com", kTypeA, kClassIN, static_cast(kFlagRD | (opcode << 11)))); + } +} + +TEST_CASE("Anything but exactly one question is ignored", "[dns_server][reject]") +{ + AssertIgnored(Query("example.com", kTypeA, kClassIN, kFlagRD, 0)); + AssertIgnored(Query("example.com", kTypeA, kClassIN, kFlagRD, 2)); + AssertIgnored(Query("example.com", kTypeA, kClassIN, kFlagRD, 0x0100)); + AssertIgnored(Query("example.com", kTypeA, kClassIN, kFlagRD, 0xFFFF)); + + // Two well-formed questions back to back are still two questions. + std::vector pkt = Query("a.example", kTypeA, kClassIN, kFlagRD, 2); + std::vector second = Query("b.example"); + pkt.insert(pkt.end(), second.begin() + kHeaderSize, second.end()); + AssertIgnored(pkt); +} + +TEST_CASE("Compression pointers and reserved label types are rejected", "[dns_server][name]") +{ + // Pointer as the whole name (back at the header). + AssertIgnored(Query(std::vector {0xC0, 0x0C})); + // Pointer after an ordinary label. + AssertIgnored(Query(std::vector {3, 'w', 'w', 'w', 0xC0, 0x0C})); + // Pointer to itself. + AssertIgnored(Query(std::vector {0xC0, 0x0C, 0x00})); + // Reserved 0b01 and 0b10 label types. + AssertIgnored(Query(std::vector {0x40, 'a', 0})); + AssertIgnored(Query(std::vector {0x80, 'a', 0})); + AssertIgnored(Query(std::vector {1, 'a', 0x41, 0})); +} + +TEST_CASE("Labels running past the packet are rejected", "[dns_server][name]") +{ + // Label claims 20 bytes but the packet ends after 3; the padding beyond it + // in the buffer must not be read as the rest of the label. + std::vector pkt = Header(0x1234, kFlagRD, 1); + const uint8_t name[] = {20, 'a', 'b', 'c'}; + pkt.insert(pkt.end(), std::begin(name), std::end(name)); + AssertIgnored(pkt); + + // Label ends exactly at the end of the packet, with no room for a terminator. + pkt = Header(0x1234, kFlagRD, 1); + const uint8_t exact[] = {3, 'a', 'b', 'c'}; + pkt.insert(pkt.end(), std::begin(exact), std::end(exact)); + AssertIgnored(pkt); +} + +TEST_CASE("A name with no terminator is rejected", "[dns_server][name]") +{ + // Labels fill the rest of the packet; the buffer past it is zero, which must + // not be taken as the terminator. + std::vector pkt = Header(0x1234, kFlagRD, 1); + const uint8_t name[] = {3, 'w', 'w', 'w', 7, 'e', 'x', 'a', 'm', 'p', 'l', 'e'}; + pkt.insert(pkt.end(), std::begin(name), std::end(name)); + + std::vector buf = Buffer(pkt, DNSPacket::MAX_PACKET, 0x00); + std::vector before = buf; + TEST_ASSERT_EQUAL_UINT(0, Run(buf, pkt.size())); + TEST_ASSERT_EQUAL_MEMORY(before.data(), buf.data(), buf.size()); +} + +TEST_CASE("A name with no room for QTYPE/QCLASS is rejected", "[dns_server][name]") +{ + // QTYPE/QCLASS bytes present in the buffer but past the packet length. + std::vector pkt = Query("example.com"); + for (size_t cut = 1; cut <= 4; cut++) { + std::vector buf = Buffer(pkt, DNSPacket::MAX_PACKET); + std::vector before = buf; + TEST_ASSERT_EQUAL_UINT(0, Run(buf, pkt.size() - cut)); + TEST_ASSERT_EQUAL_MEMORY(before.data(), buf.data(), buf.size()); + } +} + +namespace { + // Parses a reply independently and checks it against the query it came from. + void AssertWellFormedReply(const std::vector& query, const std::vector& buf, size_t respLen) + { + TEST_ASSERT_LESS_OR_EQUAL_UINT(buf.size(), respLen); + TEST_ASSERT_GREATER_OR_EQUAL_UINT(kHeaderSize + 1 + 4, respLen); + + uint16_t flags = GetU16(&buf[2]); + TEST_ASSERT_EQUAL_HEX16(kFlagQR | kFlagAA | (GetU16(&query[2]) & kFlagRD), flags); + TEST_ASSERT_EQUAL_HEX16(GetU16(&query[0]), GetU16(&buf[0])); + TEST_ASSERT_EQUAL_UINT16(1, GetU16(&buf[4])); + + // Find the question end in the query, the same way a strict resolver would. + size_t off = kHeaderSize; + while (query[off] != 0) { + off += query[off] + 1u; + } + size_t questionEnd = off + 1 + 4; + TEST_ASSERT_LESS_OR_EQUAL_UINT(query.size(), questionEnd); + TEST_ASSERT_LESS_OR_EQUAL_UINT(255, off + 1 - kHeaderSize); + TEST_ASSERT_EQUAL_MEMORY(&query[kHeaderSize], &buf[kHeaderSize], questionEnd - kHeaderSize); + + bool answered = GetU16(&query[off + 1]) == kTypeA && GetU16(&query[off + 3]) == kClassIN; + TEST_ASSERT_EQUAL_UINT16(answered ? 1 : 0, GetU16(&buf[6])); + TEST_ASSERT_EQUAL_UINT(questionEnd + (answered ? kAnswerSize : 0), respLen); + if (answered) { + TEST_ASSERT_EQUAL_HEX8(kIp[3], buf[respLen - 1]); + } + } + + // One fuzz iteration: run `pkt` in a buffer of `capacity` twice, with two + // different paddings past the packet, and require identical, bounded results. + // Returns the reply length. + size_t FuzzOne(const std::vector& pkt, size_t capacity) + { + std::vector a = Buffer(pkt, capacity, 0x00); + std::vector b = Buffer(pkt, capacity, 0xFF); + + size_t lenA = Run(a, pkt.size()); + size_t lenB = Run(b, pkt.size()); + + TEST_ASSERT_LESS_OR_EQUAL_UINT(capacity, lenA); + TEST_ASSERT_EQUAL_UINT(lenA, lenB); // never depends on bytes past the packet + + if (lenA == 0) { + // Dropped packets leave the buffer as it was. + if (!pkt.empty()) { + TEST_ASSERT_EQUAL_MEMORY(pkt.data(), a.data(), pkt.size()); + } + return 0; + } + + TEST_ASSERT_EQUAL_MEMORY(a.data(), b.data(), lenA); + AssertWellFormedReply(pkt, a, lenA); + return lenA; + } +} // namespace + +TEST_CASE("Fuzz: mutated queries never produce an out-of-bounds or malformed reply", "[dns_server][fuzz]") +{ + std::mt19937 rng(0x0D115EED); // fixed seed, so a failure reproduces + + const std::vector seeds[] = { + Query("connectivitycheck.gstatic.com"), + Query("captive.apple.com", kTypeAAAA), + Query(std::vector {0}), + Query(NameOfLength(255)), + Query("a.b.c.d.e.f.g.h", kTypeA, kClassCH), + }; + + int replies = 0; + + for (int iter = 0; iter < 200000; iter++) { + std::vector pkt = seeds[rng() % (sizeof(seeds) / sizeof(seeds[0]))]; + + int mutations = 1 + static_cast(rng() % 4); + for (int m = 0; m < mutations; m++) { + switch (rng() % 6) { + case 0: // flip a bit + if (!pkt.empty()) { + pkt[rng() % pkt.size()] ^= static_cast(1u << (rng() % 8)); + } + break; + case 1: // overwrite a byte + if (!pkt.empty()) { + pkt[rng() % pkt.size()] = static_cast(rng()); + } + break; + case 2: // truncate + pkt.resize(rng() % (pkt.size() + 1)); + break; + case 3: // append junk + for (size_t n = rng() % 32; n > 0; n--) { + pkt.push_back(static_cast(rng())); + } + break; + case 4: // set a label length byte in the question to something interesting + if (pkt.size() > kHeaderSize) { + static const uint8_t interesting[] = {0x00, 0x01, 0x3F, 0x40, 0x7F, 0x80, 0xBF, 0xC0, 0xFF}; + pkt[kHeaderSize + rng() % (pkt.size() - kHeaderSize)] = interesting[rng() % sizeof(interesting)]; + } + break; + case 5: // make it a valid-looking single standard query again + if (pkt.size() >= kHeaderSize) { + pkt[2] &= 0x07; + pkt[4] = 0; + pkt[5] = 1; + } + break; + } + } + + // Capacity from "just over the packet" up to the real buffer, so the + // reply-fits check is exercised at every margin. + size_t minCapacity = pkt.size() + 1; + size_t capacity = minCapacity + rng() % (kAnswerSize + 8); + if (rng() % 2 == 0 && minCapacity <= DNSPacket::MAX_PACKET) { + capacity = DNSPacket::MAX_PACKET; + } + + if (FuzzOne(pkt, capacity) != 0) { + replies++; + } + } + + // The mutations must leave enough queries intact to exercise the build path. + TEST_ASSERT_GREATER_THAN_INT(10000, replies); +} + +TEST_CASE("Fuzz: random bytes never produce an out-of-bounds reply", "[dns_server][fuzz]") +{ + std::mt19937 rng(0xC0FFEE); + + for (int iter = 0; iter < 100000; iter++) { + std::vector pkt(rng() % 300); + for (uint8_t& byte : pkt) { + byte = static_cast(rng()); + } + // Bias towards the interesting path: mostly a standard single-question query. + if (pkt.size() >= kHeaderSize && rng() % 4 != 0) { + pkt[2] &= 0x07; + pkt[4] = 0; + pkt[5] = 1; + } + + FuzzOne(pkt, pkt.size() + 1 + rng() % 64); + } +} diff --git a/components/dns_server/host_test/main/test_main.cpp b/components/dns_server/host_test/main/test_main.cpp new file mode 100644 index 00000000..b961b178 --- /dev/null +++ b/components/dns_server/host_test/main/test_main.cpp @@ -0,0 +1,10 @@ +// Host-only (linux target) test binary for the dns_server component. FreeRTOS is +// mocked, so there is no ESP startup - drive Unity directly. +#include "unity.h" + +extern "C" int main(void) +{ + UNITY_BEGIN(); + unity_run_all_tests(); + return UNITY_END(); +} diff --git a/components/dns_server/host_test/sdkconfig.defaults b/components/dns_server/host_test/sdkconfig.defaults new file mode 100644 index 00000000..c3d7cf2c --- /dev/null +++ b/components/dns_server/host_test/sdkconfig.defaults @@ -0,0 +1,4 @@ +CONFIG_IDF_TARGET="linux" +CONFIG_IDF_TARGET_LINUX=y +CONFIG_COMPILER_CXX_EXCEPTIONS=y +CONFIG_UNITY_ENABLE_IDF_TEST_RUNNER=y diff --git a/components/dns_server/include/dns_server/DNSServer.h b/components/dns_server/include/dns_server/DNSServer.h new file mode 100644 index 00000000..fa30331e --- /dev/null +++ b/components/dns_server/include/dns_server/DNSServer.h @@ -0,0 +1,44 @@ +#pragma once + +#include +#include + +#include "OpenShock.h" +#include "util/ManagedTask.h" + +#include + +namespace OpenShock { + // Minimal captive-portal DNS responder: answers every A query with a fixed IPv4 + // address (and returns an empty answer for other query types) so client operating + // systems resolve their connectivity-probe hosts to the portal and enter captive + // mode. Runs a single UDP socket on its own task; not a general-purpose resolver. + // + // start() and stop() are not thread-safe against each other or themselves: the + // socket and task handle are plain members, so the owner must serialize them. + // The only caller, CaptivePortalInstance, does so by construction (start from + // its constructor, stop from its destructor). + class DNSServer { + DISABLE_COPY(DNSServer); + DISABLE_MOVE(DNSServer); + + public: + DNSServer(); + ~DNSServer(); + + /// Start answering A queries with responseIpv4 (dotted-decimal, e.g. "4.3.2.1"). + /// Idempotent, first call wins: if the server is already running this returns + /// true and keeps the address and port it was originally started with. + bool start(const char* responseIpv4, uint16_t port = 53); + void stop(); + + bool isRunning() const { return m_task.running(); } + + private: + void task(); + + int m_socket; + uint8_t m_ip[4]; + ManagedTask m_task; + }; +} // namespace OpenShock diff --git a/components/dns_server/src/DNSPacket.cpp b/components/dns_server/src/DNSPacket.cpp new file mode 100644 index 00000000..c977098e --- /dev/null +++ b/components/dns_server/src/DNSPacket.cpp @@ -0,0 +1,156 @@ +#include "DNSPacket.h" + +const char* const TAG = "DNSPacket"; + +#include "Logging.h" + +#include + +namespace { + constexpr size_t DNS_HEADER_SIZE = 12; + constexpr size_t DNS_MAX_NAME_LEN = 255; + constexpr size_t DNS_ANSWER_SIZE = 16; + constexpr uint32_t DNS_ANSWER_TTL = 60; // seconds; a zero TTL makes clients re-query on every probe + + // Header flag bits, as a big-endian uint16 read from bytes [2..3]. + constexpr uint16_t DNS_FLAG_QR = 0x8000; // set on responses + constexpr uint16_t DNS_FLAG_AA = 0x0400; // authoritative answer + constexpr uint16_t DNS_FLAG_RD = 0x0100; // recursion desired + constexpr uint16_t DNS_OPCODE_MASK = 0x7800; + + constexpr uint16_t DNS_TYPE_A = 1; + constexpr uint16_t DNS_CLASS_IN = 1; + + // The answer's owner name is a compression pointer to the question, which + // always begins immediately after the header. + static_assert(DNS_HEADER_SIZE == 0x0C, "the answer's 0xC00C pointer assumes the question starts at offset 12"); + + // Walks the QNAME at `data` and returns its encoded length including the + // terminating zero byte, or 0 if the name is malformed, compressed, or + // longer than a name may legally be. + // + // Compression pointers are rejected rather than accepted: in a question + // there is nothing valid for one to point back at, and echoing it would + // leave the answer's own 0xC00C pointer aimed at a dangling chain. + size_t ParseQName(const uint8_t* data, size_t avail) + { + size_t offset = 0; + + while (offset < avail) { + uint8_t label = data[offset]; + + if (label == 0) { + return offset + 1; // consume the terminator + } + + // Only ordinary labels are accepted: 0b11 marks a compression pointer + // and 0b01/0b10 are reserved, neither of which is a length. + if ((label & 0xC0) != 0) { + return 0; + } + + if (offset + 1 + label > avail) { + return 0; // label overruns the packet + } + + offset += static_cast(label) + 1; + + // Leave room for the terminator, so the encoded name including it stays + // within DNS_MAX_NAME_LEN. + if (offset >= DNS_MAX_NAME_LEN) { + return 0; + } + } + + return 0; // ran off the end without a terminator + } +} // namespace + +size_t OpenShock::DNSPacket::BuildResponse(uint8_t* packet, size_t len, size_t capacity, const uint8_t (&ip)[4]) +{ + if (len < DNS_HEADER_SIZE) { + return 0; // runt packet + } + + // A datagram that fills the buffer was almost certainly truncated by + // recvfrom, and we would be answering a question we never fully saw. No + // legitimate query comes close: 12 + 255 + 4 is the most one can be. + if (len >= capacity) { + return 0; + } + + uint16_t flags = static_cast((packet[2] << 8) | packet[3]); + + // Ignore responses: answering one lets two of these servers keep each + // other busy indefinitely, and makes us usable as a reflector. + if ((flags & DNS_FLAG_QR) != 0) { + return 0; + } + + // Only standard queries. Any other opcode would otherwise get a NOERROR + // reply, carrying its own opcode back, for a request we never handled. + if ((flags & DNS_OPCODE_MASK) != 0) { + return 0; + } + + // A captive portal only ever needs to answer a single question. + uint16_t qdcount = static_cast((packet[4] << 8) | packet[5]); + if (qdcount != 1) { + return 0; + } + + size_t qnameLen = ParseQName(packet + DNS_HEADER_SIZE, len - DNS_HEADER_SIZE); + if (qnameLen == 0) { + return 0; // malformed, compressed or oversized name + } + + // QTYPE(2) and QCLASS(2) follow the name and must both be present. + if (len - DNS_HEADER_SIZE - qnameLen < 4) { + return 0; + } + size_t questionEnd = DNS_HEADER_SIZE + qnameLen + 4; + + const uint8_t* qfixed = packet + DNS_HEADER_SIZE + qnameLen; + uint16_t qtype = static_cast((qfixed[0] << 8) | qfixed[1]); + uint16_t qclass = static_cast((qfixed[2] << 8) | qfixed[3]); + + bool answering = qtype == DNS_TYPE_A && qclass == DNS_CLASS_IN; + + // A question that fills the buffer leaves no room for the answer; without + // this the memcpy below would run past the end of packet. + size_t respLen = questionEnd + (answering ? DNS_ANSWER_SIZE : 0); + if (respLen > capacity) { + OS_LOGW(TAG, "Query leaves no room for a response (%zu bytes), ignoring", respLen); + return 0; + } + + // Reply in place: the transaction ID and question are already correct. + uint16_t respFlags = DNS_FLAG_QR | DNS_FLAG_AA | (flags & DNS_FLAG_RD); + packet[2] = static_cast(respFlags >> 8); + packet[3] = static_cast(respFlags); + packet[6] = 0x00; // ANCOUNT hi + packet[7] = answering ? 0x01 : 0x00; + packet[8] = 0x00; // NSCOUNT + packet[9] = 0x00; + packet[10] = 0x00; // ARCOUNT + packet[11] = 0x00; + + if (answering) { + // clang-format off + const uint8_t answer[DNS_ANSWER_SIZE] = { + 0xC0, 0x0C, // name pointer to offset 12 (the question) + 0x00, 0x01, // TYPE A + 0x00, 0x01, // CLASS IN + static_cast(DNS_ANSWER_TTL >> 24), // TTL + static_cast(DNS_ANSWER_TTL >> 16), + static_cast(DNS_ANSWER_TTL >> 8), + static_cast(DNS_ANSWER_TTL), + 0x00, 0x04, // RDLENGTH 4 + ip[0], ip[1], ip[2], ip[3], // RDATA: the fixed response IP + }; + // clang-format on + memcpy(packet + questionEnd, answer, sizeof(answer)); + } + + return respLen; +} diff --git a/components/dns_server/src/DNSPacket.h b/components/dns_server/src/DNSPacket.h new file mode 100644 index 00000000..24686845 --- /dev/null +++ b/components/dns_server/src/DNSPacket.h @@ -0,0 +1,21 @@ +#pragma once + +#include +#include + +// Pure (socket-free) half of the captive-portal DNS responder, split out of the +// DNSServer task so the parse/build logic can be host-tested with crafted packets. +namespace OpenShock::DNSPacket { + constexpr size_t MAX_PACKET = 512; + + /// Turns the query in packet[0..len) into a reply, in place, answering an A/IN + /// question with `ip` (network order, i.e. a.b.c.d) and any other type/class with + /// an empty answer. capacity is the size of the buffer behind packet. + /// + /// Returns the reply length (never more than capacity), or 0 if the packet must + /// be ignored: runt or possibly truncated datagrams, responses, non-standard + /// opcodes, anything but exactly one question, malformed, compressed or + /// over-long names, a missing QTYPE/QCLASS, or no room left for the reply. The + /// buffer is only written when a reply length is returned. + size_t BuildResponse(uint8_t* packet, size_t len, size_t capacity, const uint8_t (&ip)[4]); +} // namespace OpenShock::DNSPacket diff --git a/components/dns_server/src/DNSServer.cpp b/components/dns_server/src/DNSServer.cpp new file mode 100644 index 00000000..6c0142a6 --- /dev/null +++ b/components/dns_server/src/DNSServer.cpp @@ -0,0 +1,145 @@ +#include +#include + +#include "dns_server/DNSServer.h" +#include "DNSPacket.h" + +const char* const TAG = "DNSServer"; + +#include "Logging.h" + +#include +#include + +#include +#include +#include + +using namespace OpenShock; + +DNSServer::DNSServer() + : m_socket(-1) + , m_ip {} + , m_task(TAG, "DNSServer task") +{ +} + +DNSServer::~DNSServer() +{ + stop(); +} + +bool DNSServer::start(const char* responseIpv4, uint16_t port) +{ + if (m_task.running()) { + return true; // already running + } + + // Parse the dotted-decimal address into raw bytes for the answer RDATA. + // inet_pton rejects out-of-range components and trailing garbage, which a + // scanf of four ints silently accepts and truncates. + struct in_addr parsed = {}; + if (responseIpv4 == nullptr || inet_pton(AF_INET, responseIpv4, &parsed) != 1) { + OS_LOGE(TAG, "Invalid response IPv4: %s", responseIpv4 != nullptr ? responseIpv4 : "(null)"); + return false; + } + static_assert(sizeof(parsed.s_addr) == sizeof(m_ip), "s_addr and m_ip must be the same size"); + memcpy(m_ip, &parsed.s_addr, sizeof(m_ip)); // already in network order, i.e. a.b.c.d + + m_socket = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); + if (m_socket < 0) { + OS_LOGE(TAG, "Failed to create DNS socket"); + return false; + } + + // A receive timeout is the *only* way the task ever gets to check for a stop request: + // lwIP's shutdown() rejects non-TCP sockets with EOPNOTSUPP, and close() + // under a blocked recvfrom is undefined without LWIP_NETCONN_FULLDUPLEX. + // Without this the task would block forever, so a failure here is fatal. + struct timeval tv = {}; + tv.tv_usec = 250 * 1000; + if (setsockopt(m_socket, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)) < 0) { + OS_LOGE(TAG, "Failed to set DNS socket receive timeout"); + close(m_socket); + m_socket = -1; + return false; + } + + struct sockaddr_in addr = {}; + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_ANY); + addr.sin_port = htons(port); + + if (bind(m_socket, reinterpret_cast(&addr), sizeof(addr)) < 0) { + OS_LOGE(TAG, "Failed to bind DNS socket to port %u", port); + close(m_socket); + m_socket = -1; + return false; + } + + // Above the other application tasks (priority 1): a phone joining the AP sends a burst of DNS queries, and the + // small UDP receive mailbox drops them if this task is starved, which delays captive-portal detection by seconds. + // Still below httpd and the E-Stop task (5). + constexpr UBaseType_t kTaskPriority = 4; + if (!m_task.startExpensive("DNSServer", 3072, kTaskPriority, [this] { task(); })) { + OS_LOGE(TAG, "Failed to create DNS task"); + close(m_socket); + m_socket = -1; + return false; + } + + return true; +} + +void DNSServer::stop() +{ + // The task checks for a stop every time the receive times out, so give it margin + // over that 250 ms timeout before resorting to a force-kill. + m_task.stop(pdMS_TO_TICKS(2000)); + + // Only now is nobody using the fd. Closing it earlier would let lwIP hand + // the number to another socket while the task was still between calls. + if (m_socket >= 0) { + close(m_socket); + m_socket = -1; + } +} + +void DNSServer::task() +{ + // The reply is the request echoed back with an answer appended, so one + // buffer serves both directions. + uint8_t packet[DNSPacket::MAX_PACKET]; + + while (!m_task.stopRequested()) { + struct sockaddr_in from = {}; + socklen_t fromLen = sizeof(from); + + ssize_t n = recvfrom(m_socket, packet, sizeof(packet), 0, reinterpret_cast(&from), &fromLen); + + // Don't answer anything once shutdown has begun. + if (m_task.stopRequested()) { + break; + } + + if (n < 0) { + if (errno == EAGAIN || errno == EWOULDBLOCK) { + continue; // receive timeout, which is how we get here to check for a stop + } + // A socket can enter a persistent error state when the AP interface goes + // down. Back off so that can't spin the core at full tilt. + OS_LOGW(TAG, "DNS recvfrom failed: errno=%d", errno); + vTaskDelay(pdMS_TO_TICKS(100)); + continue; + } + + size_t respLen = DNSPacket::BuildResponse(packet, static_cast(n), sizeof(packet), m_ip); + if (respLen == 0) { + continue; // not a query we answer, or one we cannot safely answer + } + + if (sendto(m_socket, packet, respLen, 0, reinterpret_cast(&from), fromLen) < 0) { + OS_LOGW(TAG, "DNS sendto failed: errno=%d", errno); // e.g. ENOMEM when TX buffers are exhausted + } + } +} diff --git a/components/events/CMakeLists.txt b/components/events/CMakeLists.txt new file mode 100644 index 00000000..e310c05b --- /dev/null +++ b/components/events/CMakeLists.txt @@ -0,0 +1,14 @@ +# OpenShock application event bus: the OPENSHOCK_EVENTS esp_event base, the event +# id enum, and the POD payload structs posted with each event. Kept as its own +# component so any layer can depend on the event contract without pulling in the +# emitters/consumers. +# +# Public surface: +# include/events/Events.h — OPENSHOCK_EVENTS base, event ids, payload structs, Init() + +idf_component_register( + SRCS "src/Events.cpp" + INCLUDE_DIRS "include" + REQUIRES esp_event # OPENSHOCK_EVENTS base + default event loop (public header) + PRIV_REQUIRES logging # Logging.h +) diff --git a/components/events/include/events/Events.h b/components/events/include/events/Events.h new file mode 100644 index 00000000..7f1a75d8 --- /dev/null +++ b/components/events/include/events/Events.h @@ -0,0 +1,31 @@ +#pragma once + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +ESP_EVENT_DECLARE_BASE(OPENSHOCK_EVENTS); + +enum { + OPENSHOCK_EVENT_ESTOP_STATE_CHANGED, // Event for when the EStop activation state changes + OPENSHOCK_EVENT_GATEWAY_CLIENT_STATE_CHANGED, // Event for when the gateway connection state changes + OPENSHOCK_EVENT_WIFI_STATE_CHANGED, // Event for when the WiFi station connectivity state changes +}; + +// Coarse WiFi station connectivity, posted as the OPENSHOCK_EVENT_WIFI_STATE_CHANGED payload. +typedef enum { + OPENSHOCK_WIFI_STATE_DISCONNECTED = 0, // Not associated to an AP + OPENSHOCK_WIFI_STATE_CONNECTING, // Association / auth in progress + OPENSHOCK_WIFI_STATE_CONNECTED, // Associated (L2), no IP yet + OPENSHOCK_WIFI_STATE_GOT_IP, // Associated and has an IP address (L3, usable) +} OpenShockWiFiState; + +#ifdef __cplusplus +} +#endif + +namespace OpenShock::Events { + bool Init(); +} diff --git a/src/events/Events.cpp b/components/events/src/Events.cpp similarity index 100% rename from src/events/Events.cpp rename to components/events/src/Events.cpp diff --git a/components/flatbuffers/CMakeLists.txt b/components/flatbuffers/CMakeLists.txt new file mode 100644 index 00000000..53761d3d --- /dev/null +++ b/components/flatbuffers/CMakeLists.txt @@ -0,0 +1,4 @@ +# Header-only FlatBuffers runtime consumed by the generated _fbs headers +# (#include "flatbuffers/flatbuffers.h"). Wraps the google/flatbuffers submodule, +# pinned to the same commit the PlatformIO lib_deps entry used. +idf_component_register(INCLUDE_DIRS flatbuffers/include) diff --git a/components/flatbuffers/flatbuffers b/components/flatbuffers/flatbuffers new file mode 160000 index 00000000..b8431fbc --- /dev/null +++ b/components/flatbuffers/flatbuffers @@ -0,0 +1 @@ +Subproject commit b8431fbcd7a5c71817f314e18b332c0648554efa diff --git a/components/fs/CMakeLists.txt b/components/fs/CMakeLists.txt new file mode 100644 index 00000000..70cfa31f --- /dev/null +++ b/components/fs/CMakeLists.txt @@ -0,0 +1,15 @@ +# littlefs-over-esp_partition filesystem layer (no VFS), built on the vendored littlefs +# core. Provides the shared LfsPartition mount kernel plus two façades: +# - StaticFs: read-only, streams pre-built image assets (e.g. the captive portal UI) +# - ConfigFs: read/write blob store for configuration (atomic writes, wear leveling) +# and FsCheck: non-destructive helpers to validate a partition holds a mountable image. +# +# littlefs is a PRIVATE dependency: lfs.h is confined to LfsPartition.cpp so it never +# leaks into consumers of this component's headers. +idf_component_register( + SRCS "src/LfsPartition.cpp" + "src/FsCheck.cpp" + INCLUDE_DIRS "include" + REQUIRES common esp_partition + PRIV_REQUIRES littlefs logging +) diff --git a/components/fs/host_test/CMakeLists.txt b/components/fs/host_test/CMakeLists.txt new file mode 100644 index 00000000..9a06aada --- /dev/null +++ b/components/fs/host_test/CMakeLists.txt @@ -0,0 +1,13 @@ +cmake_minimum_required(VERSION 3.16) + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) + +# Only build 'main'; it compiles the vendored littlefs core directly against a RAM block +# device (see main/CMakeLists.txt), so no firmware component needs to be registered. +set(COMPONENTS main) + +# linux-target freertos mock, so unity's IDF integration has its dependencies. +list(APPEND EXTRA_COMPONENT_DIRS + "$ENV{IDF_PATH}/tools/mocks/freertos/") + +project(fs_host_test) diff --git a/components/fs/host_test/main/CMakeLists.txt b/components/fs/host_test/main/CMakeLists.txt new file mode 100644 index 00000000..1c7cc876 --- /dev/null +++ b/components/fs/host_test/main/CMakeLists.txt @@ -0,0 +1,14 @@ +# Runs the vendored littlefs core over a RAM block device with the exact geometry and +# mount parameters LfsPartition uses, to check how the small config partition behaves. +idf_component_register( + SRCS "test_main.cpp" + "test_config_partition.cpp" + "../../../littlefs/src/lfs.c" + "../../../littlefs/src/lfs_util.c" + INCLUDE_DIRS "." + "../../../littlefs/include" + REQUIRES unity + WHOLE_ARCHIVE # keep the auto-registered TEST_CASEs from being stripped +) + +target_compile_definitions(${COMPONENT_LIB} PRIVATE LFS_NO_DEBUG LFS_NO_WARN LFS_NO_ERROR LFS_NO_ASSERT) diff --git a/components/fs/host_test/main/test_config_partition.cpp b/components/fs/host_test/main/test_config_partition.cpp new file mode 100644 index 00000000..48074196 --- /dev/null +++ b/components/fs/host_test/main/test_config_partition.cpp @@ -0,0 +1,173 @@ +// The config partition is 3 x 4 KiB (partitions/ota_4mb.csv). littlefs keeps its root metadata pair in two of those +// blocks, and ConfigFs::writeAll writes ".tmp" and renames it over the old file. A file too large to be +// inlined in the metadata needs its own data block while the old copy still occupies the only free one, so the +// review suspected that larger configs can't be rewritten at all (REVIEW_ACTIONS.md, X01). +// +// These tests run the real littlefs core with LfsPartition's exact mount parameters over a RAM block device and +// replay ConfigFs's write sequence, so they answer that directly. The "large config" cases describe the behaviour +// the firmware needs. X01 is real: anything over the 512-byte inline limit saves once and then fails with +// LFS_ERR_NOSPC, so those cases are ignored as KNOWN until the config storage is fixed. Remove the +// TEST_IGNORE_MESSAGE lines to check a fix. +#include "unity.h" + +#include "lfs.h" + +#include +#include +#include +#include +#include + +namespace { + // Must match components/fs/src/LfsPartition.cpp and the config partition size. + constexpr lfs_size_t kBlockSize = 4096; + constexpr lfs_size_t kBlockCount = 0x3000 / kBlockSize; + constexpr lfs_size_t kReadSize = 128; + constexpr lfs_size_t kProgSize = 128; + constexpr lfs_size_t kCacheSize = 512; + constexpr lfs_size_t kLookahead = 128; + constexpr int32_t kBlockCyclesRw = 512; + constexpr const char* kConfigPath = "/config"; + + std::vector s_flash; + + int bdRead(const struct lfs_config* c, lfs_block_t block, lfs_off_t off, void* buffer, lfs_size_t size) + { + std::memcpy(buffer, &s_flash[block * c->block_size + off], size); + return 0; + } + int bdProg(const struct lfs_config* c, lfs_block_t block, lfs_off_t off, const void* buffer, lfs_size_t size) + { + std::memcpy(&s_flash[block * c->block_size + off], buffer, size); + return 0; + } + int bdErase(const struct lfs_config* c, lfs_block_t block) + { + std::memset(&s_flash[block * c->block_size], 0xFF, c->block_size); + return 0; + } + int bdSync(const struct lfs_config*) + { + return 0; + } + + struct Fs { + lfs_t lfs {}; + lfs_config cfg {}; + + Fs() + { + s_flash.assign(kBlockSize * kBlockCount, 0xFF); + + cfg.read = bdRead; + cfg.prog = bdProg; + cfg.erase = bdErase; + cfg.sync = bdSync; + cfg.read_size = kReadSize; + cfg.prog_size = kProgSize; + cfg.block_size = kBlockSize; + cfg.block_count = kBlockCount; + cfg.block_cycles = kBlockCyclesRw; + cfg.cache_size = kCacheSize; + cfg.lookahead_size = kLookahead; + + TEST_ASSERT_EQUAL(0, lfs_format(&lfs, &cfg)); + TEST_ASSERT_EQUAL(0, lfs_mount(&lfs, &cfg)); + } + ~Fs() { lfs_unmount(&lfs); } + + // The same sequence as LfsPartition::writeAll: write .tmp, then rename it over . + int writeAll(const std::vector& data) + { + std::string tmp = std::string(kConfigPath) + ".tmp"; + + lfs_file_t file; + int err = lfs_file_open(&lfs, &file, tmp.c_str(), LFS_O_WRONLY | LFS_O_CREAT | LFS_O_TRUNC); + if (err < 0) return err; + + lfs_ssize_t written = lfs_file_write(&lfs, &file, data.data(), data.size()); + int closeErr = lfs_file_close(&lfs, &file); + if (written < 0 || closeErr < 0 || static_cast(written) != data.size()) { + lfs_remove(&lfs, tmp.c_str()); + return written < 0 ? static_cast(written) : (closeErr < 0 ? closeErr : LFS_ERR_IO); + } + + err = lfs_rename(&lfs, tmp.c_str(), kConfigPath); + if (err < 0) lfs_remove(&lfs, tmp.c_str()); + return err; + } + + bool readBack(std::vector& out) + { + lfs_file_t file; + if (lfs_file_open(&lfs, &file, kConfigPath, LFS_O_RDONLY) < 0) return false; + lfs_soff_t size = lfs_file_size(&lfs, &file); + out.resize(size < 0 ? 0 : static_cast(size)); + lfs_ssize_t r = lfs_file_read(&lfs, &file, out.data(), out.size()); + lfs_file_close(&lfs, &file); + return r == static_cast(out.size()); + } + }; + + std::vector blob(size_t size, uint8_t seed) + { + std::vector v(size); + for (size_t i = 0; i < size; i++) v[i] = static_cast(seed + i * 31); + return v; + } + + // Writes `size` bytes `times` times (as repeated config saves would), checking each write reads back. + void assertRewritable(size_t size, int times) + { + Fs fs; + for (int i = 0; i < times; i++) { + auto data = blob(size, static_cast(i)); + char msg[96]; + int err = fs.writeAll(data); + std::snprintf(msg, sizeof(msg), "write #%d of a %zu-byte config failed (lfs error %d)", i + 1, size, err); + TEST_ASSERT_EQUAL_MESSAGE(0, err, msg); + + std::vector back; + TEST_ASSERT_TRUE(fs.readBack(back)); + TEST_ASSERT_TRUE_MESSAGE(back == data, "config read back differs from what was written"); + } + } +} // namespace + +TEST_CASE("A small (inlined) config can be rewritten repeatedly", "[fs][config]") +{ + assertRewritable(256, 50); +} + +TEST_CASE("A config just over the inline limit can be rewritten repeatedly (X01)", "[fs][config]") +{ + TEST_IGNORE_MESSAGE("KNOWN ISSUE (X01): configs over the 512-byte inline limit can't be rewritten on the 3-block partition"); + assertRewritable(600, 50); +} + +TEST_CASE("A config of a few saved networks plus a token (~1.5 KiB) can be rewritten repeatedly (X01)", "[fs][config]") +{ + TEST_IGNORE_MESSAGE("KNOWN ISSUE (X01): configs over the 512-byte inline limit can't be rewritten on the 3-block partition"); + assertRewritable(1536, 50); +} + +TEST_CASE("Reports the largest config size that survives repeated rewrites", "[fs][config][info]") +{ + // Not a pass/fail check: prints where the partition stops accepting rewrites, to size MaxConfigSize / the + // partition. Binary search over sizes, each candidate written 20 times on a fresh filesystem. + size_t lo = 0, hi = 4096; + while (lo < hi) { + size_t mid = (lo + hi + 1) / 2; + Fs fs; + bool ok = true; + for (int i = 0; i < 20 && ok; i++) { + ok = fs.writeAll(blob(mid, static_cast(i))) == 0; + } + if (ok) { + lo = mid; + } else { + hi = mid - 1; + } + } + std::printf("[fs] largest config rewritable 20 times on the 3-block partition: %zu bytes\n", lo); +} diff --git a/components/fs/host_test/main/test_main.cpp b/components/fs/host_test/main/test_main.cpp new file mode 100644 index 00000000..1b5161df --- /dev/null +++ b/components/fs/host_test/main/test_main.cpp @@ -0,0 +1,10 @@ +// Host-only (linux target) test binary for the fs component. FreeRTOS is +// mocked, so there is no ESP startup - drive Unity directly. +#include "unity.h" + +extern "C" int main(void) +{ + UNITY_BEGIN(); + unity_run_all_tests(); + return UNITY_END(); +} diff --git a/components/fs/host_test/sdkconfig.defaults b/components/fs/host_test/sdkconfig.defaults new file mode 100644 index 00000000..c3d7cf2c --- /dev/null +++ b/components/fs/host_test/sdkconfig.defaults @@ -0,0 +1,4 @@ +CONFIG_IDF_TARGET="linux" +CONFIG_IDF_TARGET_LINUX=y +CONFIG_COMPILER_CXX_EXCEPTIONS=y +CONFIG_UNITY_ENABLE_IDF_TEST_RUNNER=y diff --git a/components/fs/include/fs/ConfigFs.h b/components/fs/include/fs/ConfigFs.h new file mode 100644 index 00000000..c00b2971 --- /dev/null +++ b/components/fs/include/fs/ConfigFs.h @@ -0,0 +1,39 @@ +#pragma once + +#include "fs/LfsPartition.h" + +#include "OpenShock.h" + +#include + +#include +#include +#include + +namespace OpenShock { + // Read/write view of a littlefs partition tailored for a configuration store: whole + // small blobs, atomic replace (temp file + rename), wear leveling enabled, formats a + // fresh partition on first mount. + // + // Deals only in raw bytes — it has no knowledge of the config serialization format + // (flatbuffers). Serialization and any cross-task locking belong to the caller. + class ConfigFs { + DISABLE_COPY(ConfigFs); + DISABLE_MOVE(ConfigFs); + + public: + ConfigFs() = default; + + bool mount(const esp_partition_t* partition) { return m_fs.mount(partition, true, /*formatOnFail=*/true); } + void unmount() { m_fs.unmount(); } + bool isMounted() const { return m_fs.isMounted(); } + + bool exists(const char* path) { return m_fs.exists(path); } + bool read(const char* path, std::vector& out) { return m_fs.readAll(path, out); } + bool write(const char* path, std::span data) { return m_fs.writeAll(path, data); } + bool remove(const char* path) { return m_fs.remove(path); } + + private: + LfsPartition m_fs; + }; +} // namespace OpenShock diff --git a/components/fs/include/fs/FsCheck.h b/components/fs/include/fs/FsCheck.h new file mode 100644 index 00000000..3ba27801 --- /dev/null +++ b/components/fs/include/fs/FsCheck.h @@ -0,0 +1,18 @@ +#pragma once + +#include + +namespace OpenShock { + // Verify that `partition` holds a valid, mountable littlefs image by mounting it and + // immediately unmounting. Returns false for a null partition or any mount failure. + // + // Non-destructive: `writable` is passed through to the mount (a read-only mount can + // never write), and a corrupt image is never reformatted (formatOnFail=false), so + // this stays a pure check regardless of `writable`. Use it to validate a freshly + // flashed filesystem image before committing to it. + bool TestFilesystem(const esp_partition_t* partition, bool writable = false); + + // Convenience overload: find a data partition by label (any subtype) and test it. + // Returns false if no such partition exists or it fails to mount. + bool TestFilesystem(const char* partitionLabel, bool writable = false); +} // namespace OpenShock diff --git a/components/fs/include/fs/LfsPartition.h b/components/fs/include/fs/LfsPartition.h new file mode 100644 index 00000000..38ea36a3 --- /dev/null +++ b/components/fs/include/fs/LfsPartition.h @@ -0,0 +1,59 @@ +#pragma once + +#include "OpenShock.h" + +#include + +#include +#include +#include +#include + +namespace OpenShock { + namespace detail { + struct LfsState; // defined in LfsPartition.cpp — keeps lfs.h out of public headers + } + + // Mounts a littlefs filesystem directly over a raw esp_partition using the vendored + // littlefs core — no VFS registration. This is the shared kernel behind StaticFs + // (read-only) and ConfigFs (read/write); most callers should use one of those + // façades rather than this class directly. + // + // Not thread-safe: callers that share a partition across tasks must serialize access + // themselves. + class LfsPartition { + DISABLE_COPY(LfsPartition); + DISABLE_MOVE(LfsPartition); + + public: + LfsPartition(); + ~LfsPartition(); + + // Mount `partition`. When `writable` is false, prog/erase are refused and wear + // leveling is disabled (correct for a pre-built, read-only image). When `writable` + // is true and the mount fails, the partition is formatted first if `formatOnFail`. + bool mount(const esp_partition_t* partition, bool writable, bool formatOnFail = false); + void unmount(); + bool isMounted() const { return m_mounted; } + + bool exists(const char* path); + + // Stream a file to `sink` in chunks. Returns false if the file is missing/unreadable + // or if `sink` returns false (which aborts the stream). + bool readStream(const char* path, const std::function)>& sink); + + // Read a whole file into `out`. Returns false (and clears `out`) on any error. + bool readAll(const char* path, std::vector& out); + + // Atomically replace `path` with `data` (write to a temp file, then rename). + // Writable mounts only. + bool writeAll(const char* path, std::span data); + + // Delete `path`. Writable mounts only. + bool remove(const char* path); + + private: + detail::LfsState* m_state; + bool m_mounted; + }; +} // namespace OpenShock diff --git a/components/fs/include/fs/StaticFs.h b/components/fs/include/fs/StaticFs.h new file mode 100644 index 00000000..3ce92aa0 --- /dev/null +++ b/components/fs/include/fs/StaticFs.h @@ -0,0 +1,34 @@ +#pragma once + +#include "fs/LfsPartition.h" + +#include "OpenShock.h" + +#include + +#include +#include +#include + +namespace OpenShock { + // Read-only view of a littlefs partition — for pre-built images (e.g. the captive + // portal's gzipped web assets) that are flashed once and only read at runtime. + // Streams files out to a caller-provided sink; never writes. + class StaticFs { + DISABLE_COPY(StaticFs); + DISABLE_MOVE(StaticFs); + + public: + StaticFs() = default; + + bool mount(const esp_partition_t* partition) { return m_fs.mount(partition, false); } + void unmount() { m_fs.unmount(); } + bool isMounted() const { return m_fs.isMounted(); } + + bool exists(const char* path) { return m_fs.exists(path); } + bool readFile(const char* path, const std::function)>& sink) { return m_fs.readStream(path, sink); } + + private: + LfsPartition m_fs; + }; +} // namespace OpenShock diff --git a/components/fs/src/FsCheck.cpp b/components/fs/src/FsCheck.cpp new file mode 100644 index 00000000..59265c58 --- /dev/null +++ b/components/fs/src/FsCheck.cpp @@ -0,0 +1,26 @@ +#include "fs/FsCheck.h" + +#include "fs/LfsPartition.h" + +using namespace OpenShock; + +bool OpenShock::TestFilesystem(const esp_partition_t* partition, bool writable) +{ + if (partition == nullptr) { + return false; + } + + LfsPartition fs; + if (!fs.mount(partition, writable, /*formatOnFail=*/false)) { + return false; + } + + fs.unmount(); + return true; +} + +bool OpenShock::TestFilesystem(const char* partitionLabel, bool writable) +{ + const esp_partition_t* partition = esp_partition_find_first(ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_ANY, partitionLabel); + return TestFilesystem(partition, writable); +} diff --git a/components/fs/src/LfsPartition.cpp b/components/fs/src/LfsPartition.cpp new file mode 100644 index 00000000..a22bc1c4 --- /dev/null +++ b/components/fs/src/LfsPartition.cpp @@ -0,0 +1,262 @@ +#include "fs/LfsPartition.h" + +const char* const TAG = "LfsPartition"; + +#include "Logging.h" + +#include "lfs.h" + +#include +#include +#include +#include + +using namespace OpenShock; + +// Mount parameters must be compatible with how the image is built (see the +// CONFIG_LITTLEFS_* defaults / mklittlefs): 4 KB erase blocks, 128-byte read/prog. +// littlefs only fixes block_size + block_count on disk (validated against the +// superblock at mount); the rest are runtime buffer sizes. +static constexpr lfs_size_t LFS_BLOCK_SIZE = 4096; +static constexpr lfs_size_t LFS_READ_SIZE = 128; +static constexpr lfs_size_t LFS_PROG_SIZE = 128; +static constexpr lfs_size_t LFS_CACHE_SIZE = 512; +static constexpr lfs_size_t LFS_LOOKAHEAD_SIZE = 128; +static constexpr int32_t LFS_BLOCK_CYCLES_RW = 512; // wear leveling for read/write mounts + +// All littlefs state lives here so lfs.h stays out of the public headers. The block +// device callbacks recover it via lfs_config::context. +struct OpenShock::detail::LfsState { + lfs_t lfs; + struct lfs_config cfg; + const esp_partition_t* partition; + bool writable; +}; + +using OpenShock::detail::LfsState; + +static int bdRead(const struct lfs_config* c, lfs_block_t block, lfs_off_t off, void* buffer, lfs_size_t size) +{ + auto* st = static_cast(c->context); + size_t addr = static_cast(block) * c->block_size + off; + return esp_partition_read(st->partition, addr, buffer, size) == ESP_OK ? 0 : LFS_ERR_IO; +} + +static int bdProg(const struct lfs_config* c, lfs_block_t block, lfs_off_t off, const void* buffer, lfs_size_t size) +{ + auto* st = static_cast(c->context); + if (!st->writable) { + return LFS_ERR_IO; + } + size_t addr = static_cast(block) * c->block_size + off; + return esp_partition_write(st->partition, addr, buffer, size) == ESP_OK ? 0 : LFS_ERR_IO; +} + +static int bdErase(const struct lfs_config* c, lfs_block_t block) +{ + auto* st = static_cast(c->context); + if (!st->writable) { + return LFS_ERR_IO; + } + size_t addr = static_cast(block) * c->block_size; + return esp_partition_erase_range(st->partition, addr, c->block_size) == ESP_OK ? 0 : LFS_ERR_IO; +} + +static int bdSync(const struct lfs_config*) +{ + return 0; // esp_partition writes are synchronous +} + +LfsPartition::LfsPartition() + : m_state(new LfsState {}) + , m_mounted(false) +{ +} + +LfsPartition::~LfsPartition() +{ + unmount(); + delete m_state; +} + +bool LfsPartition::mount(const esp_partition_t* partition, bool writable, bool formatOnFail) +{ + if (m_mounted) { + return true; + } + if (partition == nullptr) { + return false; + } + + m_state->partition = partition; + m_state->writable = writable; + + struct lfs_config& cfg = m_state->cfg; + memset(&cfg, 0, sizeof(cfg)); + cfg.context = m_state; + cfg.read = &bdRead; + cfg.prog = &bdProg; + cfg.erase = &bdErase; + cfg.sync = &bdSync; + cfg.read_size = LFS_READ_SIZE; + cfg.prog_size = LFS_PROG_SIZE; + cfg.block_size = LFS_BLOCK_SIZE; + cfg.block_count = partition->size / LFS_BLOCK_SIZE; + cfg.block_cycles = writable ? LFS_BLOCK_CYCLES_RW : -1; // -1 disables wear leveling + cfg.cache_size = LFS_CACHE_SIZE; + cfg.lookahead_size = LFS_LOOKAHEAD_SIZE; + + int err = lfs_mount(&m_state->lfs, &cfg); + if (err < 0) { + if (writable && formatOnFail) { + OS_LOGW(TAG, "Mount failed (lfs error %d), formatting partition", err); + if (lfs_format(&m_state->lfs, &cfg) < 0 || lfs_mount(&m_state->lfs, &cfg) < 0) { + OS_LOGE(TAG, "Failed to format and mount partition"); + m_state->partition = nullptr; + return false; + } + } else { + OS_LOGE(TAG, "Failed to mount partition (lfs error %d)", err); + m_state->partition = nullptr; + return false; + } + } + + m_mounted = true; + return true; +} + +void LfsPartition::unmount() +{ + if (m_mounted) { + lfs_unmount(&m_state->lfs); + m_mounted = false; + } + m_state->partition = nullptr; +} + +bool LfsPartition::exists(const char* path) +{ + if (!m_mounted) { + return false; + } + struct lfs_info info; + return lfs_stat(&m_state->lfs, path, &info) >= 0; +} + +bool LfsPartition::readStream(const char* path, const std::function)>& sink) +{ + if (!m_mounted) { + return false; + } + + lfs_file_t file; + if (lfs_file_open(&m_state->lfs, &file, path, LFS_O_RDONLY) < 0) { + return false; + } + + // Larger chunks mean far fewer downstream writes (an httpd chunk costs several socket sends and an event post); + // heap-allocated so callers on small task stacks are not affected. + constexpr std::size_t kChunkSize = 4096; + std::unique_ptr buf(new (std::nothrow) uint8_t[kChunkSize]); + if (buf == nullptr) { + lfs_file_close(&m_state->lfs, &file); + return false; + } + + bool ok = true; + for (;;) { + lfs_ssize_t r = lfs_file_read(&m_state->lfs, &file, buf.get(), kChunkSize); + if (r < 0) { + ok = false; + break; + } + if (r == 0) { + break; // EOF + } + if (!sink(std::span(buf.get(), static_cast(r)))) { + ok = false; + break; + } + } + + lfs_file_close(&m_state->lfs, &file); + return ok; +} + +bool LfsPartition::readAll(const char* path, std::vector& out) +{ + out.clear(); + if (!m_mounted) { + return false; + } + + lfs_file_t file; + if (lfs_file_open(&m_state->lfs, &file, path, LFS_O_RDONLY) < 0) { + return false; + } + + bool ok = true; + lfs_soff_t size = lfs_file_size(&m_state->lfs, &file); + if (size < 0) { + ok = false; + } else if (size > 0) { + out.resize(static_cast(size)); + lfs_ssize_t r = lfs_file_read(&m_state->lfs, &file, out.data(), static_cast(size)); + if (r < 0 || static_cast(r) != static_cast(size)) { + ok = false; + } + } + + lfs_file_close(&m_state->lfs, &file); + if (!ok) { + out.clear(); + } + return ok; +} + +bool LfsPartition::writeAll(const char* path, std::span data) +{ + if (!m_mounted || !m_state->writable) { + return false; + } + + // Write to a temp file then rename, so a power loss can't leave a half-written file. + std::string tmp = std::string(path) + ".tmp"; + + lfs_file_t file; + if (lfs_file_open(&m_state->lfs, &file, tmp.c_str(), LFS_O_WRONLY | LFS_O_CREAT | LFS_O_TRUNC) < 0) { + return false; + } + + bool ok = true; + if (!data.empty()) { + lfs_ssize_t w = lfs_file_write(&m_state->lfs, &file, data.data(), data.size()); + if (w < 0 || static_cast(w) != data.size()) { + ok = false; + } + } + if (lfs_file_close(&m_state->lfs, &file) < 0) { + ok = false; + } + + if (!ok) { + lfs_remove(&m_state->lfs, tmp.c_str()); + return false; + } + + if (lfs_rename(&m_state->lfs, tmp.c_str(), path) < 0) { + lfs_remove(&m_state->lfs, tmp.c_str()); + return false; + } + + return true; +} + +bool LfsPartition::remove(const char* path) +{ + if (!m_mounted || !m_state->writable) { + return false; + } + return lfs_remove(&m_state->lfs, path) >= 0; +} diff --git a/components/http/CMakeLists.txt b/components/http/CMakeLists.txt new file mode 100644 index 00000000..332f1ec6 --- /dev/null +++ b/components/http/CMakeLists.txt @@ -0,0 +1,19 @@ +# OpenShock HTTP client manager + content type helpers. +# +# Public surface: +# include/http/HTTPRequestManager.h — rate-limited HTTP/HTTPS client +# include/http/ContentTypes.h — MIME type constants +# +# The backend endpoint wrappers (http/JsonAPI.h) live in core, not here, because they +# depend on config/ and the core serialization codecs. + +idf_component_register( + SRCS "src/HTTPRequestManager.cpp" + INCLUDE_DIRS "include" + REQUIRES osjson # json/Json.h in public header + PRIV_REQUIRES common # Common.h, RateLimiter, StringHelpers, SimpleMutex (internal) + temporal # Temporal.h (internal) + logging # Logging.h (internal) + esp_http_client # esp_http_client.h (internal) + mbedtls # esp_crt_bundle.h (internal) +) diff --git a/components/http/include/http/ContentTypes.h b/components/http/include/http/ContentTypes.h new file mode 100644 index 00000000..7913db28 --- /dev/null +++ b/components/http/include/http/ContentTypes.h @@ -0,0 +1,14 @@ +#pragma once + +namespace OpenShock::HTTP::ContentType { + constexpr const char* JSON = "application/json"; + constexpr const char* TextPlain = "text/plain"; + constexpr const char* TextHTML = "text/html"; + constexpr const char* JavaScript = "text/javascript"; + constexpr const char* CSS = "text/css"; + constexpr const char* SVG = "image/svg+xml"; + constexpr const char* PNG = "image/png"; + constexpr const char* Icon = "image/x-icon"; + constexpr const char* WOFF2 = "font/woff2"; + constexpr const char* OctetStream = "application/octet-stream"; +} // namespace OpenShock::HTTP::ContentType diff --git a/components/http/include/http/HTTPRequestManager.h b/components/http/include/http/HTTPRequestManager.h new file mode 100644 index 00000000..7dca10d5 --- /dev/null +++ b/components/http/include/http/HTTPRequestManager.h @@ -0,0 +1,154 @@ +#pragma once + +#include "json/Json.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +// Forward declarations so this public header doesn't pull in . +// These match the real typedefs exactly, so including the real header elsewhere +// in the same translation unit is fine. +typedef int esp_err_t; +typedef struct esp_http_client* esp_http_client_handle_t; +typedef struct esp_http_client_event esp_http_client_event_t; + +namespace OpenShock::HTTP { + enum class RequestResult : uint8_t { + InternalError, // Internal error + InvalidURL, // Invalid URL + RequestFailed, // Failed to start request + TimedOut, // Request timed out + RateLimited, // Rate limited (can be both local and global) + CodeRejected, // Request completed, but response code was not OK + ParseFailed, // Request completed, but JSON parsing failed + Cancelled, // Request was cancelled + Success, // Request completed successfully + }; + + template + struct [[nodiscard]] Response { + RequestResult result; + int code; + T data; + + Response(RequestResult r, int c, T d) + : result(r) + , code(c) + , data(std::move(d)) + { + } + + inline const char* ResultToString() const + { + switch (result) { + case RequestResult::InternalError: + return "Internal error"; + case RequestResult::InvalidURL: + return "Requested url was invalid"; + case RequestResult::RequestFailed: + return "Request failed"; + case RequestResult::TimedOut: + return "Request timed out"; + case RequestResult::RateLimited: + return "Client was ratelimited"; + case RequestResult::CodeRejected: + return "Unexpected response code"; + case RequestResult::ParseFailed: + return "Parsing the response failed"; + case RequestResult::Cancelled: + return "Request was cancelled"; + case RequestResult::Success: + return "Success"; + default: + return "Unknown reason"; + } + } + }; + + template + using JsonParser = std::function; + using GotContentLengthCallback = std::function; + using DownloadCallback = std::function; + + // A reusable HTTP client. Sequential requests on the same instance reuse the + // underlying TCP/TLS connection (HTTP keep-alive) when they target the same + // host, and transparently reconnect when the host changes or the connection + // drops. The caller owns the lifetime: construct one where several requests + // are issued back-to-back (e.g. an OTA update) so they share a connection. + // + // Not thread-safe. Use a separate Client per task. + class Client { + public: + Client() noexcept; + ~Client(); + + Client(const Client&) = delete; + Client& operator=(const Client&) = delete; + Client(Client&&) = delete; + Client& operator=(Client&&) = delete; + + Response + Download(std::string_view url, const std::map& headers, GotContentLengthCallback contentLengthCallback, DownloadCallback downloadCallback, std::span acceptedCodes, uint32_t timeoutMs = 10'000); + Response GetString(std::string_view url, const std::map& headers, std::span acceptedCodes, uint32_t timeoutMs = 10'000); + + template + Response GetJSON(std::string_view url, const std::map& headers, JsonParser jsonParser, std::span acceptedCodes, uint32_t timeoutMs = 10'000) + { + auto response = GetString(url, headers, acceptedCodes, timeoutMs); + if (response.result != RequestResult::Success) { + return {response.result, response.code, {}}; + } + + // The parsed views point into response.data, which outlives this call; the + // parser copies out everything it needs before we return. + JSON::JsonDocument doc; + if (!doc.parse(response.data)) { + return {RequestResult::ParseFailed, response.code, {}}; + } + + T data; + if (!jsonParser(response.code, doc.root(), data)) { + return {RequestResult::ParseFailed, response.code, {}}; + } + + return {response.result, response.code, std::move(data)}; + } + + private: + static esp_err_t eventHandler(esp_http_client_event_t* evt); + void drop(); + + esp_http_client_handle_t m_handle; + std::vector m_headerKeys; // headers set on the last request, cleared before reuse + std::string m_retryAfter; // "Retry-After" response header, captured by eventHandler + bool m_connectionClose; // server sent "Connection: close" + }; + + // One-shot helpers: perform a single request on a temporary client. Use a + // Client directly when issuing several requests to the same host. + inline Response + Download(std::string_view url, const std::map& headers, GotContentLengthCallback contentLengthCallback, DownloadCallback downloadCallback, std::span acceptedCodes, uint32_t timeoutMs = 10'000) + { + Client client; + return client.Download(url, headers, std::move(contentLengthCallback), std::move(downloadCallback), acceptedCodes, timeoutMs); + } + + inline Response GetString(std::string_view url, const std::map& headers, std::span acceptedCodes, uint32_t timeoutMs = 10'000) + { + Client client; + return client.GetString(url, headers, acceptedCodes, timeoutMs); + } + + template + Response GetJSON(std::string_view url, const std::map& headers, JsonParser jsonParser, std::span acceptedCodes, uint32_t timeoutMs = 10'000) + { + Client client; + return client.GetJSON(url, headers, std::move(jsonParser), acceptedCodes, timeoutMs); + } +} // namespace OpenShock::HTTP diff --git a/components/http/src/HTTPRequestManager.cpp b/components/http/src/HTTPRequestManager.cpp new file mode 100644 index 00000000..132496e9 --- /dev/null +++ b/components/http/src/HTTPRequestManager.cpp @@ -0,0 +1,363 @@ +#include + +#include "http/HTTPRequestManager.h" + +const char* const TAG = "HTTPRequestManager"; + +#include "Logging.h" + +#include "OpenShock.h" +#include "RateLimiter.h" +#include "SimpleMutex.h" +#include "StringHelpers.h" +#include "Temporal.h" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace std::string_view_literals; + +const std::size_t HTTP_BUFFER_SIZE = 4096LLU; +const int HTTP_DOWNLOAD_SIZE_LIMIT = 200 * 1024 * 1024; // 200 MB + +static OpenShock::SimpleMutex s_rateLimitsMutex = {}; +static std::unordered_map> s_rateLimits = {}; + +using namespace OpenShock; + +// Rate limits are kept per host, so the API host gets its own limits independent of CDN/firmware hosts. +static std::string_view getHostFromURL(std::string_view url) +{ + if (url.empty()) { + return {}; + } + + // Remove the protocol eg. "https://api.example.com:443/path" -> "api.example.com:443/path" + auto seperator = url.find("://"); + if (seperator != std::string_view::npos) { + url = url.substr(seperator + 3); + } + + // Remove the path eg. "api.example.com:443/path" -> "api.example.com:443" + seperator = url.find('/'); + if (seperator != std::string_view::npos) { + url = url.substr(0, seperator); + } + + // Remove the port eg. "api.example.com:443" -> "api.example.com" + seperator = url.rfind(':'); + if (seperator != std::string_view::npos) { + url = url.substr(0, seperator); + } + + return url; +} + +static std::shared_ptr createRateLimiterForDomain(std::string_view domain) +{ + auto rateLimit = std::make_shared(); + + // Add default limits + rateLimit->addLimit(1000, 5); // 5 per second + rateLimit->addLimit(10 * 1000, 10); // 10 per 10 seconds + + // per-domain limits + if (domain == CONFIG_OPENSHOCK_API_DOMAIN) { + rateLimit->addLimit(60 * 1000, 12); // 12 per minute + rateLimit->addLimit(60 * 60 * 1000, 120); // 120 per hour + } + + return rateLimit; +} + +static std::shared_ptr createRateLimiterForURL(std::string_view url) +{ + auto domain = std::string(getHostFromURL(url)); + if (domain.empty()) { + return nullptr; + } + + OpenShock::ScopedLock lock__(&s_rateLimitsMutex); + + auto it = s_rateLimits.find(domain); + if (it == s_rateLimits.end()) { + it = s_rateLimits.emplace(domain, createRateLimiterForDomain(domain)).first; + } + + return it->second; +} + +HTTP::Client::Client() noexcept + : m_handle(nullptr) + , m_headerKeys() + , m_retryAfter() + , m_connectionClose(false) +{ +} + +HTTP::Client::~Client() +{ + drop(); +} + +// esp_http_client only exposes response headers through the event stream, so we +// capture the ones we care about here. user_data points at the owning Client. +esp_err_t HTTP::Client::eventHandler(esp_http_client_event_t* evt) +{ + if (evt->event_id != HTTP_EVENT_ON_HEADER) { + return ESP_OK; + } + + auto* self = static_cast(evt->user_data); + if (self == nullptr) { + return ESP_OK; + } + + if (OpenShock::StringIEquals(evt->header_key, "Retry-After")) { + self->m_retryAfter.assign(evt->header_value); + } else if (OpenShock::StringIEquals(evt->header_key, "Connection")) { + self->m_connectionClose = OpenShock::StringIEquals(evt->header_value, "close"); + } + + return ESP_OK; +} + +void HTTP::Client::drop() +{ + if (m_handle != nullptr) { + esp_http_client_close(m_handle); + esp_http_client_cleanup(m_handle); + m_handle = nullptr; + } + m_headerKeys.clear(); +} + +HTTP::Response + HTTP::Client::Download(std::string_view url, const std::map& headers, HTTP::GotContentLengthCallback contentLengthCallback, HTTP::DownloadCallback downloadCallback, std::span acceptedCodes, uint32_t timeoutMs) +{ + std::shared_ptr rateLimiter = createRateLimiterForURL(url); + if (rateLimiter == nullptr) { + return {RequestResult::InvalidURL, 0, 0}; + } + + if (!rateLimiter->tryRequest()) { + return {RequestResult::RateLimited, 0, 0}; + } + + std::string urlStr(url); + m_retryAfter.clear(); + m_connectionClose = false; + + int64_t begin = OpenShock::millis(); + + // Open the connection, reusing the kept-alive handle when possible. If a + // reused (kept-alive) socket has been closed by the server, drop it and + // reconnect fresh once. HTTPS servers are verified against the compiled-in + // CA bundle via config.crt_bundle_attach below. + esp_err_t err = ESP_FAIL; + int64_t contentLength = -1; + for (int attempt = 0; attempt < 2; ++attempt) { + bool reused = m_handle != nullptr; + + if (m_handle == nullptr) { + esp_http_client_config_t config = {}; + config.url = urlStr.c_str(); + config.user_agent = OpenShock::Constants::FW_USERAGENT; + config.method = HTTP_METHOD_GET; + config.timeout_ms = static_cast(timeoutMs); + config.keep_alive_enable = true; + config.event_handler = &Client::eventHandler; + config.user_data = this; + config.crt_bundle_attach = esp_crt_bundle_attach; // verify HTTPS servers against the compiled-in CA bundle + + m_handle = esp_http_client_init(&config); + if (m_handle == nullptr) { + OS_LOGE(TAG, "Failed to initialize HTTP client"); + return {RequestResult::RequestFailed, 0, 0}; + } + } else { + esp_http_client_set_url(m_handle, urlStr.c_str()); + esp_http_client_set_method(m_handle, HTTP_METHOD_GET); + esp_http_client_set_timeout_ms(m_handle, static_cast(timeoutMs)); + } + + // Clear headers left over from a previous request on this reused handle. + for (const auto& key : m_headerKeys) { + esp_http_client_delete_header(m_handle, key.c_str()); + } + m_headerKeys.clear(); + + for (const auto& header : headers) { + esp_http_client_set_header(m_handle, header.first.c_str(), header.second.c_str()); + m_headerKeys.push_back(header.first); + } + + err = esp_http_client_open(m_handle, 0); + if (err == ESP_OK) { + // A server-closed keep-alive socket usually accepts the request write and only fails here. + contentLength = esp_http_client_fetch_headers(m_handle); + if (contentLength >= 0) { + break; + } + err = ESP_FAIL; + } + + drop(); + if (!reused) { + OS_LOGE(TAG, "Failed to send HTTP request: %s", esp_err_to_name(err)); + return {RequestResult::RequestFailed, 0, 0}; + } + OS_LOGD(TAG, "Reused connection failed, reconnecting"); + } + if (err != ESP_OK) { + return {RequestResult::RequestFailed, 0, 0}; + } + + // Tears down the connection when it can't be safely reused. + auto fail = [&](HTTP::RequestResult result, int code, std::size_t written) -> HTTP::Response { + drop(); + return {result, code, written}; + }; + + int responseCode = esp_http_client_get_status_code(m_handle); + + if (begin + timeoutMs < OpenShock::millis()) { + OS_LOGW(TAG, "Request timed out"); + return fail(RequestResult::TimedOut, responseCode, 0); + } + + if (responseCode == 429) { // Too Many Requests + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After + long retryAfter = 0; + if (!m_retryAfter.empty() && std::all_of(m_retryAfter.begin(), m_retryAfter.end(), [](char c) { return std::isdigit(static_cast(c)) != 0; })) { + retryAfter = strtol(m_retryAfter.c_str(), nullptr, 10); + } + + // If header missing/unparseable, default to 15 seconds; cap at an hour (also keeps the ms conversion in range) + if (retryAfter <= 0) { + retryAfter = 15; + } else if (retryAfter > 3600) { + retryAfter = 3600; + } + + rateLimiter->blockFor(static_cast(retryAfter) * 1000); + + return fail(RequestResult::RateLimited, responseCode, 0); // body not drained + } + + if (responseCode == 418) { + OS_LOGW(TAG, "The server refused to brew coffee because it is, permanently, a teapot."); + } + + if (std::find(acceptedCodes.begin(), acceptedCodes.end(), responseCode) == acceptedCodes.end()) { + OS_LOGD(TAG, "Received unexpected response code %d", responseCode); + return fail(RequestResult::CodeRejected, responseCode, 0); // body not drained + } + + bool chunked = esp_http_client_is_chunked_response(m_handle); + + if (contentLength > HTTP_DOWNLOAD_SIZE_LIMIT) { + OS_LOGE(TAG, "Content-Length too large"); + return fail(RequestResult::RequestFailed, responseCode, 0); + } + + if (contentLength > 0) { + if (!contentLengthCallback(static_cast(contentLength))) { + OS_LOGW(TAG, "Request cancelled by callback"); + return fail(RequestResult::Cancelled, responseCode, 0); + } + } else if (!chunked) { + // No Content-Length and not chunked => empty body. Nothing to drain. + if (m_connectionClose) { + drop(); + } + return {RequestResult::Success, responseCode, 0}; + } + + uint8_t* buffer = static_cast(malloc(HTTP_BUFFER_SIZE)); + if (buffer == nullptr) { + OS_LOGE(TAG, "Failed to allocate HTTP buffer"); + return fail(RequestResult::InternalError, responseCode, 0); + } + + std::size_t totalWritten = 0; + HTTP::RequestResult result = HTTP::RequestResult::Success; + + // esp_http_client_read transparently decodes chunked transfer-encoding. + int read; + while ((read = esp_http_client_read(m_handle, reinterpret_cast(buffer), static_cast(HTTP_BUFFER_SIZE))) > 0) { + if (begin + timeoutMs < OpenShock::millis()) { + OS_LOGW(TAG, "Request timed out"); + result = HTTP::RequestResult::TimedOut; + break; + } + + if (!downloadCallback(totalWritten, buffer, static_cast(read))) { + OS_LOGW(TAG, "Request cancelled by callback"); + result = HTTP::RequestResult::Cancelled; + break; + } + + totalWritten += static_cast(read); + } + + free(buffer); + + if (result == HTTP::RequestResult::Success && (read < 0 || !esp_http_client_is_complete_data_received(m_handle))) { + OS_LOGW(TAG, "Response body was not fully received"); + result = HTTP::RequestResult::RequestFailed; + } + + if (result != HTTP::RequestResult::Success) { + return fail(result, responseCode, totalWritten); // connection state uncertain + } + + // Success: keep the connection alive for the next request unless the server closed it. + if (m_connectionClose) { + drop(); + } + + return {result, responseCode, totalWritten}; +} + +HTTP::Response HTTP::Client::GetString(std::string_view url, const std::map& headers, std::span acceptedCodes, uint32_t timeoutMs) +{ + // String responses are small JSON documents; refuse anything that would only exhaust the heap. + static constexpr std::size_t kMaxStringResponseSize = 64 * 1024; + + std::string result; + + auto allocator = [&result](std::size_t contentLength) { + if (contentLength > kMaxStringResponseSize) { + OS_LOGE(TAG, "Response too large (%zu bytes)", contentLength); + return false; + } + result.reserve(contentLength); + return true; + }; + auto writer = [&result](std::size_t offset, const uint8_t* data, std::size_t len) { + if (result.size() + len > kMaxStringResponseSize) { + OS_LOGE(TAG, "Response exceeds %zu bytes", kMaxStringResponseSize); + return false; + } + result.append(reinterpret_cast(data), len); + return true; + }; + + auto response = Download(url, headers, allocator, writer, acceptedCodes, timeoutMs); + if (response.result != RequestResult::Success) { + return {response.result, response.code, {}}; + } + + return {response.result, response.code, std::move(result)}; +} diff --git a/components/hwutil/CMakeLists.txt b/components/hwutil/CMakeLists.txt new file mode 100644 index 00000000..2013dcf2 --- /dev/null +++ b/components/hwutil/CMakeLists.txt @@ -0,0 +1,17 @@ +# Hardware-dependent utilities split out of `util` so that `util` stays +# pure std-C++ and host-testable. These pull in firmware (esp_partition, HTTP +# OTA) types that do not exist on the linux host target. +# +# Public surface: +# include/hwutil/PartitionUtils.h — hash / flash an esp_partition from a URL + +idf_component_register( + SRCS "src/PartitionUtils.cpp" + INCLUDE_DIRS "include" + REQUIRES esp_partition # esp_partition_t in public header + PRIV_REQUIRES common # Convert, StringHelpers, HexUtils + crypto # Hashing + temporal # Temporal.h + http # HTTPRequestManager (URL flashing) + logging # Logging.h +) diff --git a/components/hwutil/include/hwutil/PartitionUtils.h b/components/hwutil/include/hwutil/PartitionUtils.h new file mode 100644 index 00000000..15384870 --- /dev/null +++ b/components/hwutil/include/hwutil/PartitionUtils.h @@ -0,0 +1,19 @@ +#pragma once + +#include + +#include +#include +#include + +namespace OpenShock { + namespace HTTP { + class Client; + } + + /// @brief The `static0` partition holding the captive portal's web assets (served by the portal, rewritten by OTA). + const esp_partition_t* FindStaticPartition(); + + bool TryGetPartitionHash(const esp_partition_t* partition, char (&hash)[65]); + bool FlashPartitionFromUrl(HTTP::Client& client, const esp_partition_t* partition, std::string_view remoteUrl, const uint8_t (&remoteHash)[32], std::function progressCallback = nullptr); +} // namespace OpenShock diff --git a/src/util/ParitionUtils.cpp b/components/hwutil/src/PartitionUtils.cpp similarity index 74% rename from src/util/ParitionUtils.cpp rename to components/hwutil/src/PartitionUtils.cpp index eede3572..4481684f 100644 --- a/src/util/ParitionUtils.cpp +++ b/components/hwutil/src/PartitionUtils.cpp @@ -1,13 +1,21 @@ -#include "util/PartitionUtils.h" +#include "hwutil/PartitionUtils.h" const char* const TAG = "PartitionUtils"; -#include "Core.h" #include "Hashing.h" #include "http/HTTPRequestManager.h" #include "Logging.h" + +#include "Temporal.h" #include "util/HexUtils.h" +#include + +const esp_partition_t* OpenShock::FindStaticPartition() +{ + return esp_partition_find_first(ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_SPIFFS, "static0"); +} + bool OpenShock::TryGetPartitionHash(const esp_partition_t* partition, char (&hash)[65]) { uint8_t buffer[32]; @@ -23,7 +31,7 @@ bool OpenShock::TryGetPartitionHash(const esp_partition_t* partition, char (&has return true; } -bool OpenShock::FlashPartitionFromUrl(const esp_partition_t* partition, std::string_view remoteUrl, const uint8_t (&remoteHash)[32], std::function progressCallback) +bool OpenShock::FlashPartitionFromUrl(HTTP::Client& client, const esp_partition_t* partition, std::string_view remoteUrl, const uint8_t (&remoteHash)[32], std::function progressCallback) { OpenShock::SHA256 sha256; if (!sha256.begin()) { @@ -50,7 +58,7 @@ bool OpenShock::FlashPartitionFromUrl(const esp_partition_t* partition, std::str contentLength = size; lastProgress = OpenShock::millis(); - progressCallback(0, contentLength, 0.0f); + if (progressCallback) progressCallback(0, contentLength, 0.0f); return true; }; @@ -70,21 +78,21 @@ bool OpenShock::FlashPartitionFromUrl(const esp_partition_t* partition, std::str int64_t now = OpenShock::millis(); if (now - lastProgress >= 500) { // Send progress every 500ms lastProgress = now; - progressCallback(contentWritten, contentLength, static_cast(contentWritten) / static_cast(contentLength)); + if (progressCallback) progressCallback(contentWritten, contentLength, static_cast(contentWritten) / static_cast(contentLength)); } return true; }; // Start streaming binary to app partition. - auto appBinaryResponse = OpenShock::HTTP::Download( + auto appBinaryResponse = client.Download( remoteUrl, { {"Accept", "application/octet-stream"} }, sizeValidator, dataWriter, - std::array {200, 304}, + std::array {200}, 180'000 ); // 3 minutes if (appBinaryResponse.result != OpenShock::HTTP::RequestResult::Success) { @@ -92,8 +100,8 @@ bool OpenShock::FlashPartitionFromUrl(const esp_partition_t* partition, std::str return false; } - progressCallback(contentLength, contentLength, 1.0f); - OS_LOGD(TAG, "Wrote %u bytes to partition", appBinaryResponse.data); + if (progressCallback) progressCallback(contentLength, contentLength, 1.0f); + OS_LOGD(TAG, "Wrote %zu bytes to partition", appBinaryResponse.data); std::array localHash; if (!sha256.finish(localHash)) { diff --git a/components/led_drivers/CMakeLists.txt b/components/led_drivers/CMakeLists.txt new file mode 100644 index 00000000..eed9a417 --- /dev/null +++ b/components/led_drivers/CMakeLists.txt @@ -0,0 +1,21 @@ +# OpenShock LED drivers — both mono (PWM via LEDC) and RGB (WS2812B via the +# ESP-IDF RMT driver, esp_driver_rmt). +# +# Public surface: +# led_drivers/MonoLedDriver.h +# led_drivers/RgbLedDriver.h +# +# Note: this is driver-level only. The VisualStateManager that picks patterns +# based on estop/gateway/wifi state is a separate concern and lives in main. + +idf_component_register( + SRCS "src/MonoLedDriver.cpp" + "src/RgbLedDriver.cpp" + INCLUDE_DIRS "include" + REQUIRES common # Common.h, SimpleMutex in public headers + freertos # TaskHandle_t in public headers + esp_driver_rmt # driver/rmt_tx.h, rmt_encoder.h in RgbLedDriver.h + PRIV_REQUIRES chipset # IsValidOutputPin + logging # OS_LOG* + esp_driver_ledc # driver/ledc.h (mono PWM) # FnProxy/TaskUtils via common +) diff --git a/components/led_drivers/include/led_drivers/MonoLedDriver.h b/components/led_drivers/include/led_drivers/MonoLedDriver.h new file mode 100644 index 00000000..24562fd0 --- /dev/null +++ b/components/led_drivers/include/led_drivers/MonoLedDriver.h @@ -0,0 +1,39 @@ +#pragma once + +#include "led_drivers/PatternPlayer.h" +#include "OpenShock.h" + +#include + +#include +#include + +namespace OpenShock { + struct MonoLedState { + bool level; + uint32_t duration; + }; + + class MonoLedDriver : public PatternPlayer { + DISABLE_DEFAULT(MonoLedDriver); + DISABLE_COPY(MonoLedDriver); + DISABLE_MOVE(MonoLedDriver); + + public: + using State = MonoLedState; + + MonoLedDriver(gpio_num_t gpioPin); + ~MonoLedDriver() override; + + bool IsValid() const { return m_gpioPin != GPIO_NUM_NC; } + + void SetBrightness(uint8_t brightness) { m_brightness.store(brightness); } + + protected: + void apply(const State& state) override; + + private: + gpio_num_t m_gpioPin; + std::atomic m_brightness; + }; +} // namespace OpenShock diff --git a/components/led_drivers/include/led_drivers/PatternPlayer.h b/components/led_drivers/include/led_drivers/PatternPlayer.h new file mode 100644 index 00000000..e2ea3cab --- /dev/null +++ b/components/led_drivers/include/led_drivers/PatternPlayer.h @@ -0,0 +1,96 @@ +#pragma once + +#include "OpenShock.h" +#include "SimpleMutex.h" +#include "util/ManagedTask.h" + +#include +#include + +#include +#include + +namespace OpenShock { + /// @brief Plays a looping pattern of LED states on one long-lived task. + /// + /// SetPattern() swaps the pattern and wakes the task through a task notification, so a change takes effect right + /// away and the caller (often the default event loop) never waits for a task to be torn down and recreated. + /// `State` must have a `uint32_t duration` (milliseconds); the derived driver writes a state to the hardware in + /// apply(). Derived destructors must call stopTask() before releasing anything apply() uses. + template + class PatternPlayer { + DISABLE_COPY(PatternPlayer); + DISABLE_MOVE(PatternPlayer); + + public: + void SetPattern(const State* pattern, std::size_t patternLength) + { + { + ScopedLock lock__(&m_patternMutex); + m_pattern.assign(pattern, pattern + patternLength); + ++m_generation; + } + wake(); + } + template + inline void SetPattern(const State (&pattern)[N]) + { + SetPattern(pattern, N); + } + + /// @brief Stops playing; the LED keeps its last state. + void ClearPattern() { SetPattern(nullptr, 0); } + + protected: + PatternPlayer() = default; + virtual ~PatternPlayer() { stopTask(); } + + virtual void apply(const State& state) = 0; + + bool startTask(const char* name, uint32_t stackSize) + { + return m_task.startExpensive(name, stackSize, 1, [this] { run(); }); + } + + void stopTask() { m_task.stop(); } + + private: + void wake() { m_task.notify(); } + + void run() + { + std::vector pattern; + uint32_t generation = 0; + + while (!m_task.stopRequested()) { + { + ScopedLock lock__(&m_patternMutex); + if (generation != m_generation) { + pattern = m_pattern; + generation = m_generation; + } + } + + if (pattern.empty()) { + ulTaskNotifyTake(pdTRUE, portMAX_DELAY); // Nothing to play until the next SetPattern / stop + continue; + } + + for (const State& state : pattern) { + apply(state); + + // Sleep for the state's duration, but wake immediately on a new pattern or a stop request + if (ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(state.duration)) != 0) { + break; + } + } + } + } + + SimpleMutex m_patternMutex; + std::vector m_pattern; // Guarded by m_patternMutex + uint32_t m_generation = 0; // Guarded by m_patternMutex; bumped on every SetPattern + + ManagedTask m_task {"PatternPlayer", "LED pattern task"}; + }; +} // namespace OpenShock diff --git a/components/led_drivers/include/led_drivers/RgbLedDriver.h b/components/led_drivers/include/led_drivers/RgbLedDriver.h new file mode 100644 index 00000000..3ce22951 --- /dev/null +++ b/components/led_drivers/include/led_drivers/RgbLedDriver.h @@ -0,0 +1,47 @@ +#pragma once + +#include "led_drivers/PatternPlayer.h" +#include "OpenShock.h" + +#include + +#include +#include + +#include +#include + +namespace OpenShock { + struct RgbLedState { + uint8_t red; + uint8_t green; + uint8_t blue; + uint32_t duration; + }; + + class RgbLedDriver : public PatternPlayer { + DISABLE_DEFAULT(RgbLedDriver); + DISABLE_COPY(RgbLedDriver); + DISABLE_MOVE(RgbLedDriver); + + public: + using RGBState = RgbLedState; + + RgbLedDriver(gpio_num_t gpioPin); + ~RgbLedDriver() override; + + bool IsValid() const { return m_gpioPin != GPIO_NUM_NC; } + + // Range: 0-255 + void SetBrightness(uint8_t brightness) { m_brightness.store(brightness); } + + protected: + void apply(const RGBState& state) override; + + private: + gpio_num_t m_gpioPin; + std::atomic m_brightness; + rmt_channel_handle_t m_rmtChannel; + rmt_encoder_handle_t m_rmtEncoder; + }; +} // namespace OpenShock diff --git a/components/led_drivers/src/MonoLedDriver.cpp b/components/led_drivers/src/MonoLedDriver.cpp new file mode 100644 index 00000000..c0806028 --- /dev/null +++ b/components/led_drivers/src/MonoLedDriver.cpp @@ -0,0 +1,87 @@ +#include + +#include "led_drivers/MonoLedDriver.h" + +const char* const TAG = "MonoLedDriver"; + +#include "Chipset.h" +#include "Logging.h" + +#include + +#define OS_LEDC_TIMER LEDC_TIMER_0 +#ifdef SOC_LEDC_SUPPORT_HS_MODE +#define OS_LEDC_SPEED LEDC_HIGH_SPEED_MODE +#else +#define OS_LEDC_SPEED LEDC_LOW_SPEED_MODE +#endif +#define OS_LEDC_CHANNEL LEDC_CHANNEL_0 +#define OS_LEDC_RESOLUTION LEDC_TIMER_8_BIT +#define OS_LEDC_FREQUENCY 4000 // https://en.wikipedia.org/wiki/Flicker_fusion_threshold + +using namespace OpenShock; + +MonoLedDriver::MonoLedDriver(gpio_num_t gpioPin) + : m_gpioPin(GPIO_NUM_NC) + , m_brightness(255) +{ + if (gpioPin == GPIO_NUM_NC) { + OS_LOGE(TAG, "Pin is not set"); + return; + } + + if (!IsValidOutputPin(gpioPin)) { + OS_LOGE(TAG, "Pin %d is not a valid output pin", gpioPin); + return; + } + + ledc_timer_config_t ledc_config = { + .speed_mode = OS_LEDC_SPEED, + .duty_resolution = OS_LEDC_RESOLUTION, + .timer_num = OS_LEDC_TIMER, + .freq_hz = OS_LEDC_FREQUENCY, + .clk_cfg = LEDC_AUTO_CLK, + .deconfigure = false, + }; + + ledc_timer_config(&ledc_config); // TODO: Error handling + + ledc_channel_config_t ledc_channel = { + .gpio_num = gpioPin, + .speed_mode = OS_LEDC_SPEED, + .channel = OS_LEDC_CHANNEL, + .intr_type = LEDC_INTR_DISABLE, + .timer_sel = OS_LEDC_TIMER, + .duty = 0, + .hpoint = 0, + .sleep_mode = LEDC_SLEEP_MODE_NO_ALIVE_NO_PD, // default: no output during light-sleep + .flags = {}, + .deconfigure = false, + }; + + ledc_channel_config(&ledc_channel); // TODO: Error handling + + char name[32]; + snprintf(name, sizeof(name), "MonoLedDriver-%d", gpioPin); + if (!startTask(name, 1536)) { // PROFILED (old per-pattern task): 0.5KB stack usage + OS_LOGE(TAG, "[pin-%d] Failed to create task", gpioPin); + ledc_stop(OS_LEDC_SPEED, OS_LEDC_CHANNEL, 0); + return; + } + + m_gpioPin = gpioPin; +} + +MonoLedDriver::~MonoLedDriver() +{ + // Before the LEDC channel goes away + stopTask(); + + ledc_stop(OS_LEDC_SPEED, OS_LEDC_CHANNEL, 0); // TODO: Error handling +} + +void MonoLedDriver::apply(const State& state) +{ + ledc_set_duty(OS_LEDC_SPEED, OS_LEDC_CHANNEL, state.level ? m_brightness.load() : 0); + ledc_update_duty(OS_LEDC_SPEED, OS_LEDC_CHANNEL); +} diff --git a/components/led_drivers/src/RgbLedDriver.cpp b/components/led_drivers/src/RgbLedDriver.cpp new file mode 100644 index 00000000..5d3d1798 --- /dev/null +++ b/components/led_drivers/src/RgbLedDriver.cpp @@ -0,0 +1,147 @@ +#include + +#include "led_drivers/RgbLedDriver.h" + +const char* const TAG = "RGBLedDriver"; + +#include "Chipset.h" +#include "Logging.h" + +#include +#include + +#include + +// R/G channel swap is a board setting, generated into openshock_board.h by +// scripts/gen_env_header.py rather than coming from Kconfig - keeping it out of +// sdkconfig.h is what lets boards sharing a chip reuse ESP-IDF objects. It is always +// defined, as 0 or 1, so the #if below reads it directly. +#include "openshock_board.h" +#include "sdkconfig.h" + +using namespace OpenShock; + +// Currently this assumes a single WS2812B LED +// TODO: Support multiple LEDs ? +// TODO: Support other LED types ? + +RgbLedDriver::RgbLedDriver(gpio_num_t gpioPin) + : m_gpioPin(GPIO_NUM_NC) + , m_brightness(255) + , m_rmtChannel(nullptr) + , m_rmtEncoder(nullptr) +{ + if (gpioPin == GPIO_NUM_NC) { + OS_LOGE(TAG, "Pin is not set"); + return; + } + + if (!OpenShock::IsValidOutputPin(gpioPin)) { + OS_LOGE(TAG, "Pin %hhi is not a valid output pin", gpioPin); + return; + } + + // 10 MHz resolution => 100 ns tick (WS2812B timing). + rmt_tx_channel_config_t channelConfig = { + .gpio_num = gpioPin, + .clk_src = RMT_CLK_SRC_DEFAULT, + .resolution_hz = 10'000'000, + .mem_block_symbols = SOC_RMT_MEM_WORDS_PER_CHANNEL, // exactly 1 memory block + .trans_queue_depth = 4, + .intr_priority = 0, // 0 => driver picks a low/medium priority + .flags = {}, + }; + esp_err_t err = rmt_new_tx_channel(&channelConfig, &m_rmtChannel); + if (err != ESP_OK) { + OS_LOGE(TAG, "Failed to create RMT TX channel for pin %hhi: %s", gpioPin, esp_err_to_name(err)); + m_rmtChannel = nullptr; + return; + } + + rmt_copy_encoder_config_t encoderConfig = {}; + err = rmt_new_copy_encoder(&encoderConfig, &m_rmtEncoder); + if (err != ESP_OK) { + OS_LOGE(TAG, "Failed to create RMT copy encoder for pin %hhi: %s", gpioPin, esp_err_to_name(err)); + rmt_del_channel(m_rmtChannel); + m_rmtChannel = nullptr; + m_rmtEncoder = nullptr; + return; + } + + err = rmt_enable(m_rmtChannel); + if (err != ESP_OK) { + OS_LOGE(TAG, "Failed to enable RMT channel for pin %hhi: %s", gpioPin, esp_err_to_name(err)); + rmt_del_encoder(m_rmtEncoder); + rmt_del_channel(m_rmtChannel); + m_rmtChannel = nullptr; + m_rmtEncoder = nullptr; + return; + } + + if (!startTask(TAG, 4096)) { // PROFILED (old per-pattern task): 1.7KB stack usage + OS_LOGE(TAG, "[pin-%hhi] Failed to create task", gpioPin); + rmt_disable(m_rmtChannel); + rmt_del_encoder(m_rmtEncoder); + rmt_del_channel(m_rmtChannel); + m_rmtChannel = nullptr; + m_rmtEncoder = nullptr; + return; + } + + m_gpioPin = gpioPin; +} + +RgbLedDriver::~RgbLedDriver() +{ + stopTask(); // before the RMT channel goes away + + if (m_rmtChannel != nullptr) { + rmt_disable(m_rmtChannel); + rmt_del_channel(m_rmtChannel); + m_rmtChannel = nullptr; + } + if (m_rmtEncoder != nullptr) { + rmt_del_encoder(m_rmtEncoder); + m_rmtEncoder = nullptr; + } +} + +void RgbLedDriver::apply(const RGBState& state) +{ + std::array led_data; // 24 bits per LED (8 bits per color * 3 colors) + + // WS2812B usually takes commands in GRB order + // https://cdn-shop.adafruit.com/datasheets/WS2812B.pdf - Page 5 + // But some actually expect RGB! + const uint16_t brightness = m_brightness.load(); + + uint8_t r = static_cast(static_cast(state.red) * brightness / 255); + uint8_t g = static_cast(static_cast(state.green) * brightness / 255); + uint8_t b = static_cast(static_cast(state.blue) * brightness / 255); +#if OPENSHOCK_LED_SWAP_RG_CHANNELS + std::swap(r, g); +#endif + + const uint32_t colors = (static_cast(g) << 16) | (static_cast(r) << 8) | static_cast(b); + + // Encode the data + for (std::size_t bit = 0; bit < 24; bit++) { + if (colors & (1 << (23 - bit))) { + led_data[bit].level0 = 1; + led_data[bit].duration0 = 8; + led_data[bit].level1 = 0; + led_data[bit].duration1 = 4; + } else { + led_data[bit].level0 = 1; + led_data[bit].duration0 = 4; + led_data[bit].level1 = 0; + led_data[bit].duration1 = 8; + } + } + + // Send the data (blocking, wait up to 10ms per frame) + rmt_transmit_config_t txConfig = {}; + txConfig.flags.eot_level = 0; + rmt_transmit(m_rmtChannel, m_rmtEncoder, led_data.data(), led_data.size() * sizeof(rmt_symbol_word_t), &txConfig); + rmt_tx_wait_all_done(m_rmtChannel, 10); +} diff --git a/components/littlefs/CMakeLists.txt b/components/littlefs/CMakeLists.txt new file mode 100644 index 00000000..7f71ec2e --- /dev/null +++ b/components/littlefs/CMakeLists.txt @@ -0,0 +1,17 @@ +# Vendored littlefs core (upstream ARM littlefs, BSD-3-Clause). This is the plain +# filesystem library only — no VFS layer. OpenShock mounts partitions against it +# directly via an esp_partition-backed block device (see the captive portal's +# StaticFs helper). Sources copied from github.com/littlefs-project/littlefs. +idf_component_register( + SRCS "src/lfs.c" + "src/lfs_util.c" + INCLUDE_DIRS "include" +) + +# Quiet the library: it otherwise emits printf-based trace/debug/warn/error output. +target_compile_definitions(${COMPONENT_LIB} PUBLIC + LFS_NO_DEBUG + LFS_NO_WARN + LFS_NO_ERROR + LFS_NO_ASSERT +) diff --git a/components/littlefs/LICENSE.md b/components/littlefs/LICENSE.md new file mode 100644 index 00000000..a4c01b28 --- /dev/null +++ b/components/littlefs/LICENSE.md @@ -0,0 +1,25 @@ +Copyright (c) 2022, The littlefs authors. +Copyright (c) 2017, Arm Limited. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +- Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. +- Redistributions in binary form must reproduce the above copyright notice, this + list of conditions and the following disclaimer in the documentation and/or + other materials provided with the distribution. +- Neither the name of ARM nor the names of its contributors may be used to + endorse or promote products derived from this software without specific prior + written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/components/littlefs/include/lfs.h b/components/littlefs/include/lfs.h new file mode 100644 index 00000000..8fbd5b47 --- /dev/null +++ b/components/littlefs/include/lfs.h @@ -0,0 +1,802 @@ +/* + * The little filesystem + * + * Copyright (c) 2022, The littlefs authors. + * Copyright (c) 2017, Arm Limited. All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + */ +#ifndef LFS_H +#define LFS_H + +#include "lfs_util.h" + +#ifdef __cplusplus +extern "C" +{ +#endif + + +/// Version info /// + +// Software library version +// Major (top-nibble), incremented on backwards incompatible changes +// Minor (bottom-nibble), incremented on feature additions +#define LFS_VERSION 0x0002000b +#define LFS_VERSION_MAJOR (0xffff & (LFS_VERSION >> 16)) +#define LFS_VERSION_MINOR (0xffff & (LFS_VERSION >> 0)) + +// Version of On-disk data structures +// Major (top-nibble), incremented on backwards incompatible changes +// Minor (bottom-nibble), incremented on feature additions +#define LFS_DISK_VERSION 0x00020001 +#define LFS_DISK_VERSION_MAJOR (0xffff & (LFS_DISK_VERSION >> 16)) +#define LFS_DISK_VERSION_MINOR (0xffff & (LFS_DISK_VERSION >> 0)) + + +/// Definitions /// + +// Type definitions +typedef uint32_t lfs_size_t; +typedef uint32_t lfs_off_t; + +typedef int32_t lfs_ssize_t; +typedef int32_t lfs_soff_t; + +typedef uint32_t lfs_block_t; + +// Maximum name size in bytes, may be redefined to reduce the size of the +// info struct. Limited to <= 1022. Stored in superblock and must be +// respected by other littlefs drivers. +#ifndef LFS_NAME_MAX +#define LFS_NAME_MAX 255 +#endif + +// Maximum size of a file in bytes, may be redefined to limit to support other +// drivers. Limited on disk to <= 2147483647. Stored in superblock and must be +// respected by other littlefs drivers. +#ifndef LFS_FILE_MAX +#define LFS_FILE_MAX 2147483647 +#endif + +// Maximum size of custom attributes in bytes, may be redefined, but there is +// no real benefit to using a smaller LFS_ATTR_MAX. Limited to <= 1022. Stored +// in superblock and must be respected by other littlefs drivers. +#ifndef LFS_ATTR_MAX +#define LFS_ATTR_MAX 1022 +#endif + +// Possible error codes, these are negative to allow +// valid positive return values +enum lfs_error { + LFS_ERR_OK = 0, // No error + LFS_ERR_IO = -5, // Error during device operation + LFS_ERR_CORRUPT = -84, // Corrupted + LFS_ERR_NOENT = -2, // No directory entry + LFS_ERR_EXIST = -17, // Entry already exists + LFS_ERR_NOTDIR = -20, // Entry is not a dir + LFS_ERR_ISDIR = -21, // Entry is a dir + LFS_ERR_NOTEMPTY = -39, // Dir is not empty + LFS_ERR_BADF = -9, // Bad file number + LFS_ERR_FBIG = -27, // File too large + LFS_ERR_INVAL = -22, // Invalid parameter + LFS_ERR_NOSPC = -28, // No space left on device + LFS_ERR_NOMEM = -12, // No more memory available + LFS_ERR_NOATTR = -61, // No data/attr available + LFS_ERR_NAMETOOLONG = -36, // File name too long +}; + +// File types +enum lfs_type { + // file types + LFS_TYPE_REG = 0x001, + LFS_TYPE_DIR = 0x002, + + // internally used types + LFS_TYPE_SPLICE = 0x400, + LFS_TYPE_NAME = 0x000, + LFS_TYPE_STRUCT = 0x200, + LFS_TYPE_USERATTR = 0x300, + LFS_TYPE_FROM = 0x100, + LFS_TYPE_TAIL = 0x600, + LFS_TYPE_GLOBALS = 0x700, + LFS_TYPE_CRC = 0x500, + + // internally used type specializations + LFS_TYPE_CREATE = 0x401, + LFS_TYPE_DELETE = 0x4ff, + LFS_TYPE_SUPERBLOCK = 0x0ff, + LFS_TYPE_DIRSTRUCT = 0x200, + LFS_TYPE_CTZSTRUCT = 0x202, + LFS_TYPE_INLINESTRUCT = 0x201, + LFS_TYPE_SOFTTAIL = 0x600, + LFS_TYPE_HARDTAIL = 0x601, + LFS_TYPE_MOVESTATE = 0x7ff, + LFS_TYPE_CCRC = 0x500, + LFS_TYPE_FCRC = 0x5ff, + + // internal chip sources + LFS_FROM_NOOP = 0x000, + LFS_FROM_MOVE = 0x101, + LFS_FROM_USERATTRS = 0x102, +}; + +// File open flags +enum lfs_open_flags { + // open flags + LFS_O_RDONLY = 1, // Open a file as read only +#ifndef LFS_READONLY + LFS_O_WRONLY = 2, // Open a file as write only + LFS_O_RDWR = 3, // Open a file as read and write + LFS_O_CREAT = 0x0100, // Create a file if it does not exist + LFS_O_EXCL = 0x0200, // Fail if a file already exists + LFS_O_TRUNC = 0x0400, // Truncate the existing file to zero size + LFS_O_APPEND = 0x0800, // Move to end of file on every write +#endif + + // internally used flags +#ifndef LFS_READONLY + LFS_F_DIRTY = 0x00010000, // File does not match storage due to write + LFS_F_DUSTY = 0x00020000, // File does not match storage due to desync + LFS_F_WRITING = 0x00040000, // File has been written since last flush +#endif + LFS_F_READING = 0x00080000, // File has been read since last flush +#ifndef LFS_READONLY + LFS_F_ERRED = 0x00100000, // An error occurred during write +#endif + LFS_F_INLINE = 0x01000000, // Currently inlined in directory entry +}; + +// File seek flags +enum lfs_whence_flags { + LFS_SEEK_SET = 0, // Seek relative to an absolute position + LFS_SEEK_CUR = 1, // Seek relative to the current file position + LFS_SEEK_END = 2, // Seek relative to the end of the file +}; + + +// Configuration provided during initialization of the littlefs +struct lfs_config { + // Opaque user provided context that can be used to pass + // information to the block device operations + void *context; + + // Read a region in a block. Negative error codes are propagated + // to the user. + int (*read)(const struct lfs_config *c, lfs_block_t block, + lfs_off_t off, void *buffer, lfs_size_t size); + + // Program a region in a block. The block must have previously + // been erased. Negative error codes are propagated to the user. + // May return LFS_ERR_CORRUPT if the block should be considered bad. + int (*prog)(const struct lfs_config *c, lfs_block_t block, + lfs_off_t off, const void *buffer, lfs_size_t size); + + // Erase a block. A block must be erased before being programmed. + // The state of an erased block is undefined. Negative error codes + // are propagated to the user. + // May return LFS_ERR_CORRUPT if the block should be considered bad. + int (*erase)(const struct lfs_config *c, lfs_block_t block); + + // Sync the state of the underlying block device. Negative error codes + // are propagated to the user. + int (*sync)(const struct lfs_config *c); + +#ifdef LFS_THREADSAFE + // Lock the underlying block device. Negative error codes + // are propagated to the user. + int (*lock)(const struct lfs_config *c); + + // Unlock the underlying block device. Negative error codes + // are propagated to the user. + int (*unlock)(const struct lfs_config *c); +#endif + + // Minimum size of a block read in bytes. All read operations will be a + // multiple of this value. + lfs_size_t read_size; + + // Minimum size of a block program in bytes. All program operations will be + // a multiple of this value. + lfs_size_t prog_size; + + // Size of an erasable block in bytes. This does not impact ram consumption + // and may be larger than the physical erase size. However, non-inlined + // files take up at minimum one block. Must be a multiple of the read and + // program sizes. + lfs_size_t block_size; + + // Number of erasable blocks on the device. Defaults to block_count stored + // on disk when zero. + lfs_size_t block_count; + + // Number of erase cycles before littlefs evicts metadata logs and moves + // the metadata to another block. Suggested values are in the + // range 100-1000, with large values having better performance at the cost + // of less consistent wear distribution. + // + // Set to -1 to disable block-level wear-leveling. + int32_t block_cycles; + + // Size of block caches in bytes. Each cache buffers a portion of a block in + // RAM. The littlefs needs a read cache, a program cache, and one additional + // cache per file. Larger caches can improve performance by storing more + // data and reducing the number of disk accesses. Must be a multiple of the + // read and program sizes, and a factor of the block size. + lfs_size_t cache_size; + + // Size of the lookahead buffer in bytes. A larger lookahead buffer + // increases the number of blocks found during an allocation pass. The + // lookahead buffer is stored as a compact bitmap, so each byte of RAM + // can track 8 blocks. + lfs_size_t lookahead_size; + + // Threshold for metadata compaction during lfs_fs_gc in bytes. Metadata + // pairs that exceed this threshold will be compacted during lfs_fs_gc. + // Defaults to ~88% block_size when zero, though the default may change + // in the future. + // + // Note this only affects lfs_fs_gc. Normal compactions still only occur + // when full. + // + // Set to -1 to disable metadata compaction during lfs_fs_gc. + lfs_size_t compact_thresh; + + // Optional statically allocated read buffer. Must be cache_size. + // By default lfs_malloc is used to allocate this buffer. + void *read_buffer; + + // Optional statically allocated program buffer. Must be cache_size. + // By default lfs_malloc is used to allocate this buffer. + void *prog_buffer; + + // Optional statically allocated lookahead buffer. Must be lookahead_size. + // By default lfs_malloc is used to allocate this buffer. + void *lookahead_buffer; + + // Optional upper limit on length of file names in bytes. No downside for + // larger names except the size of the info struct which is controlled by + // the LFS_NAME_MAX define. Defaults to LFS_NAME_MAX or name_max stored on + // disk when zero. + lfs_size_t name_max; + + // Optional upper limit on files in bytes. No downside for larger files + // but must be <= LFS_FILE_MAX. Defaults to LFS_FILE_MAX or file_max stored + // on disk when zero. + lfs_size_t file_max; + + // Optional upper limit on custom attributes in bytes. No downside for + // larger attributes size but must be <= LFS_ATTR_MAX. Defaults to + // LFS_ATTR_MAX or attr_max stored on disk when zero. + lfs_size_t attr_max; + + // Optional upper limit on total space given to metadata pairs in bytes. On + // devices with large blocks (e.g. 128kB) setting this to a low size (2-8kB) + // can help bound the metadata compaction time. Must be <= block_size. + // Defaults to block_size when zero. + lfs_size_t metadata_max; + + // Optional upper limit on inlined files in bytes. Inlined files live in + // metadata and decrease storage requirements, but may be limited to + // improve metadata-related performance. Must be <= cache_size, <= + // attr_max, and <= block_size/8. Defaults to the largest possible + // inline_max when zero. + // + // Set to -1 to disable inlined files. + lfs_size_t inline_max; + +#ifdef LFS_MULTIVERSION + // On-disk version to use when writing in the form of 16-bit major version + // + 16-bit minor version. This limiting metadata to what is supported by + // older minor versions. Note that some features will be lost. Defaults to + // to the most recent minor version when zero. + uint32_t disk_version; +#endif +}; + +// File info structure +struct lfs_info { + // Type of the file, either LFS_TYPE_REG or LFS_TYPE_DIR + uint8_t type; + + // Size of the file, only valid for REG files. Limited to 32-bits. + lfs_size_t size; + + // Name of the file stored as a null-terminated string. Limited to + // LFS_NAME_MAX+1, which can be changed by redefining LFS_NAME_MAX to + // reduce RAM. LFS_NAME_MAX is stored in superblock and must be + // respected by other littlefs drivers. + char name[LFS_NAME_MAX+1]; +}; + +// Filesystem info structure +struct lfs_fsinfo { + // On-disk version. + uint32_t disk_version; + + // Size of a logical block in bytes. + lfs_size_t block_size; + + // Number of logical blocks in filesystem. + lfs_size_t block_count; + + // Upper limit on the length of file names in bytes. + lfs_size_t name_max; + + // Upper limit on the size of files in bytes. + lfs_size_t file_max; + + // Upper limit on the size of custom attributes in bytes. + lfs_size_t attr_max; +}; + +// Custom attribute structure, used to describe custom attributes +// committed atomically during file writes. +struct lfs_attr { + // 8-bit type of attribute, provided by user and used to + // identify the attribute + uint8_t type; + + // Pointer to buffer containing the attribute + void *buffer; + + // Size of attribute in bytes, limited to LFS_ATTR_MAX + lfs_size_t size; +}; + +// Optional configuration provided during lfs_file_opencfg +struct lfs_file_config { + // Optional statically allocated file buffer. Must be cache_size. + // By default lfs_malloc is used to allocate this buffer. + void *buffer; + + // Optional list of custom attributes related to the file. If the file + // is opened with read access, these attributes will be read from disk + // during the open call. If the file is opened with write access, the + // attributes will be written to disk every file sync or close. This + // write occurs atomically with update to the file's contents. + // + // Custom attributes are uniquely identified by an 8-bit type and limited + // to LFS_ATTR_MAX bytes. When read, if the stored attribute is smaller + // than the buffer, it will be padded with zeros. If the stored attribute + // is larger, then it will be silently truncated. If the attribute is not + // found, it will be created implicitly. + struct lfs_attr *attrs; + + // Number of custom attributes in the list + lfs_size_t attr_count; +}; + + +/// internal littlefs data structures /// +typedef struct lfs_cache { + lfs_block_t block; + lfs_off_t off; + lfs_size_t size; + uint8_t *buffer; +} lfs_cache_t; + +typedef struct lfs_mdir { + lfs_block_t pair[2]; + uint32_t rev; + lfs_off_t off; + uint32_t etag; + uint16_t count; + bool erased; + bool split; + lfs_block_t tail[2]; +} lfs_mdir_t; + +// littlefs directory type +typedef struct lfs_dir { + struct lfs_dir *next; + uint16_t id; + uint8_t type; + lfs_mdir_t m; + + lfs_off_t pos; + lfs_block_t head[2]; +} lfs_dir_t; + +// littlefs file type +typedef struct lfs_file { + struct lfs_file *next; + uint16_t id; + uint8_t type; + lfs_mdir_t m; + + struct lfs_ctz { + lfs_block_t head; + lfs_size_t size; + } ctz; + + uint32_t flags; + lfs_off_t pos; + lfs_block_t block; + lfs_off_t off; + lfs_cache_t cache; + + const struct lfs_file_config *cfg; +} lfs_file_t; + +typedef struct lfs_superblock { + uint32_t version; + lfs_size_t block_size; + lfs_size_t block_count; + lfs_size_t name_max; + lfs_size_t file_max; + lfs_size_t attr_max; +} lfs_superblock_t; + +typedef struct lfs_gstate { + uint32_t tag; + lfs_block_t pair[2]; +} lfs_gstate_t; + +// The littlefs filesystem type +typedef struct lfs { + lfs_cache_t rcache; + lfs_cache_t pcache; + + lfs_block_t root[2]; + struct lfs_mlist { + struct lfs_mlist *next; + uint16_t id; + uint8_t type; + lfs_mdir_t m; + } *mlist; + uint32_t seed; + + lfs_gstate_t gstate; + lfs_gstate_t gdisk; + lfs_gstate_t gdelta; + + struct lfs_lookahead { + lfs_block_t start; + lfs_block_t size; + lfs_block_t next; + lfs_block_t ckpoint; + uint8_t *buffer; + } lookahead; + + const struct lfs_config *cfg; + lfs_size_t block_count; + lfs_size_t name_max; + lfs_size_t file_max; + lfs_size_t attr_max; + lfs_size_t inline_max; + +#ifdef LFS_MIGRATE + struct lfs1 *lfs1; +#endif +} lfs_t; + + +/// Filesystem functions /// + +#ifndef LFS_READONLY +// Format a block device with the littlefs +// +// Requires a littlefs object and config struct. This clobbers the littlefs +// object, and does not leave the filesystem mounted. The config struct must +// be zeroed for defaults and backwards compatibility. +// +// Returns a negative error code on failure. +int lfs_format(lfs_t *lfs, const struct lfs_config *config); +#endif + +// Mounts a littlefs +// +// Requires a littlefs object and config struct. Multiple filesystems +// may be mounted simultaneously with multiple littlefs objects. Both +// lfs and config must be allocated while mounted. The config struct must +// be zeroed for defaults and backwards compatibility. +// +// Returns a negative error code on failure. +int lfs_mount(lfs_t *lfs, const struct lfs_config *config); + +// Unmounts a littlefs +// +// Does nothing besides releasing any allocated resources. +// Returns a negative error code on failure. +int lfs_unmount(lfs_t *lfs); + +/// General operations /// + +#ifndef LFS_READONLY +// Removes a file or directory +// +// If removing a directory, the directory must be empty. +// Returns a negative error code on failure. +int lfs_remove(lfs_t *lfs, const char *path); +#endif + +#ifndef LFS_READONLY +// Rename or move a file or directory +// +// If the destination exists, it must match the source in type. +// If the destination is a directory, the directory must be empty. +// +// Returns a negative error code on failure. +int lfs_rename(lfs_t *lfs, const char *oldpath, const char *newpath); +#endif + +// Find info about a file or directory +// +// Fills out the info structure, based on the specified file or directory. +// Returns a negative error code on failure. +int lfs_stat(lfs_t *lfs, const char *path, struct lfs_info *info); + +// Get a custom attribute +// +// Custom attributes are uniquely identified by an 8-bit type and limited +// to LFS_ATTR_MAX bytes. When read, if the stored attribute is smaller than +// the buffer, it will be padded with zeros. If the stored attribute is larger, +// then it will be silently truncated. If no attribute is found, the error +// LFS_ERR_NOATTR is returned and the buffer is filled with zeros. +// +// Returns the size of the attribute, or a negative error code on failure. +// Note, the returned size is the size of the attribute on disk, irrespective +// of the size of the buffer. This can be used to dynamically allocate a buffer +// or check for existence. +lfs_ssize_t lfs_getattr(lfs_t *lfs, const char *path, + uint8_t type, void *buffer, lfs_size_t size); + +#ifndef LFS_READONLY +// Set custom attributes +// +// Custom attributes are uniquely identified by an 8-bit type and limited +// to LFS_ATTR_MAX bytes. If an attribute is not found, it will be +// implicitly created. +// +// Returns a negative error code on failure. +int lfs_setattr(lfs_t *lfs, const char *path, + uint8_t type, const void *buffer, lfs_size_t size); +#endif + +#ifndef LFS_READONLY +// Removes a custom attribute +// +// If an attribute is not found, nothing happens. +// +// Returns a negative error code on failure. +int lfs_removeattr(lfs_t *lfs, const char *path, uint8_t type); +#endif + + +/// File operations /// + +#ifndef LFS_NO_MALLOC +// Open a file +// +// The mode that the file is opened in is determined by the flags, which +// are values from the enum lfs_open_flags that are bitwise-ored together. +// +// Returns a negative error code on failure. +int lfs_file_open(lfs_t *lfs, lfs_file_t *file, + const char *path, int flags); + +// if LFS_NO_MALLOC is defined, lfs_file_open() will fail with LFS_ERR_NOMEM +// thus use lfs_file_opencfg() with config.buffer set. +#endif + +// Open a file with extra configuration +// +// The mode that the file is opened in is determined by the flags, which +// are values from the enum lfs_open_flags that are bitwise-ored together. +// +// The config struct provides additional config options per file as described +// above. The config struct must remain allocated while the file is open, and +// the config struct must be zeroed for defaults and backwards compatibility. +// +// Returns a negative error code on failure. +int lfs_file_opencfg(lfs_t *lfs, lfs_file_t *file, + const char *path, int flags, + const struct lfs_file_config *config); + +// Close a file +// +// Any pending writes are written out to storage as though +// sync had been called and releases any allocated resources. +// +// Returns a negative error code on failure. +int lfs_file_close(lfs_t *lfs, lfs_file_t *file); + +// Synchronize a file on storage +// +// Any pending writes are written out to storage. +// Returns a negative error code on failure. +int lfs_file_sync(lfs_t *lfs, lfs_file_t *file); + +// Read data from file +// +// Takes a buffer and size indicating where to store the read data. +// Returns the number of bytes read, or a negative error code on failure. +lfs_ssize_t lfs_file_read(lfs_t *lfs, lfs_file_t *file, + void *buffer, lfs_size_t size); + +#ifndef LFS_READONLY +// Write data to file +// +// Takes a buffer and size indicating the data to write. The file will not +// actually be updated on the storage until either sync or close is called. +// +// Returns the number of bytes written, or a negative error code on failure. +lfs_ssize_t lfs_file_write(lfs_t *lfs, lfs_file_t *file, + const void *buffer, lfs_size_t size); +#endif + +// Change the position of the file +// +// The change in position is determined by the offset and whence flag. +// Returns the new position of the file, or a negative error code on failure. +lfs_soff_t lfs_file_seek(lfs_t *lfs, lfs_file_t *file, + lfs_soff_t off, int whence); + +#ifndef LFS_READONLY +// Truncates the size of the file to the specified size +// +// Returns a negative error code on failure. +int lfs_file_truncate(lfs_t *lfs, lfs_file_t *file, lfs_off_t size); +#endif + +// Return the position of the file +// +// Equivalent to lfs_file_seek(lfs, file, 0, LFS_SEEK_CUR) +// Returns the position of the file, or a negative error code on failure. +lfs_soff_t lfs_file_tell(lfs_t *lfs, lfs_file_t *file); + +// Change the position of the file to the beginning of the file +// +// Equivalent to lfs_file_seek(lfs, file, 0, LFS_SEEK_SET) +// Returns a negative error code on failure. +int lfs_file_rewind(lfs_t *lfs, lfs_file_t *file); + +// Return the size of the file +// +// Similar to lfs_file_seek(lfs, file, 0, LFS_SEEK_END) +// Returns the size of the file, or a negative error code on failure. +lfs_soff_t lfs_file_size(lfs_t *lfs, lfs_file_t *file); + + +/// Directory operations /// + +#ifndef LFS_READONLY +// Create a directory +// +// Returns a negative error code on failure. +int lfs_mkdir(lfs_t *lfs, const char *path); +#endif + +// Open a directory +// +// Once open a directory can be used with read to iterate over files. +// Returns a negative error code on failure. +int lfs_dir_open(lfs_t *lfs, lfs_dir_t *dir, const char *path); + +// Close a directory +// +// Releases any allocated resources. +// Returns a negative error code on failure. +int lfs_dir_close(lfs_t *lfs, lfs_dir_t *dir); + +// Read an entry in the directory +// +// Fills out the info structure, based on the specified file or directory. +// Returns a positive value on success, 0 at the end of directory, +// or a negative error code on failure. +int lfs_dir_read(lfs_t *lfs, lfs_dir_t *dir, struct lfs_info *info); + +// Change the position of the directory +// +// The new off must be a value previous returned from tell and specifies +// an absolute offset in the directory seek. +// +// Returns a negative error code on failure. +int lfs_dir_seek(lfs_t *lfs, lfs_dir_t *dir, lfs_off_t off); + +// Return the position of the directory +// +// The returned offset is only meant to be consumed by seek and may not make +// sense, but does indicate the current position in the directory iteration. +// +// Returns the position of the directory, or a negative error code on failure. +lfs_soff_t lfs_dir_tell(lfs_t *lfs, lfs_dir_t *dir); + +// Change the position of the directory to the beginning of the directory +// +// Returns a negative error code on failure. +int lfs_dir_rewind(lfs_t *lfs, lfs_dir_t *dir); + + +/// Filesystem-level filesystem operations + +// Find on-disk info about the filesystem +// +// Fills out the fsinfo structure based on the filesystem found on-disk. +// Returns a negative error code on failure. +int lfs_fs_stat(lfs_t *lfs, struct lfs_fsinfo *fsinfo); + +// Finds the current size of the filesystem +// +// Note: Result is best effort. If files share COW structures, the returned +// size may be larger than the filesystem actually is. +// +// Returns the number of allocated blocks, or a negative error code on failure. +lfs_ssize_t lfs_fs_size(lfs_t *lfs); + +// Traverse through all blocks in use by the filesystem +// +// The provided callback will be called with each block address that is +// currently in use by the filesystem. This can be used to determine which +// blocks are in use or how much of the storage is available. +// +// Returns a negative error code on failure. +int lfs_fs_traverse(lfs_t *lfs, int (*cb)(void*, lfs_block_t), void *data); + +#ifndef LFS_READONLY +// Attempt to make the filesystem consistent and ready for writing +// +// Calling this function is not required, consistency will be implicitly +// enforced on the first operation that writes to the filesystem, but this +// function allows the work to be performed earlier and without other +// filesystem changes. +// +// Returns a negative error code on failure. +int lfs_fs_mkconsistent(lfs_t *lfs); +#endif + +#ifndef LFS_READONLY +// Attempt any janitorial work +// +// This currently: +// 1. Calls mkconsistent if not already consistent +// 2. Compacts metadata > compact_thresh +// 3. Populates the block allocator +// +// Though additional janitorial work may be added in the future. +// +// Calling this function is not required, but may allow the offloading of +// expensive janitorial work to a less time-critical code path. +// +// Returns a negative error code on failure. Accomplishing nothing is not +// an error. +int lfs_fs_gc(lfs_t *lfs); +#endif + +#ifndef LFS_READONLY +// Grows the filesystem to a new size, updating the superblock with the new +// block count. +// +// If LFS_SHRINKNONRELOCATING is defined, this function will also accept +// block_counts smaller than the current configuration, after checking +// that none of the blocks that are being removed are in use. +// Note that littlefs's pseudorandom block allocation means that +// this is very unlikely to work in the general case. +// +// Returns a negative error code on failure. +int lfs_fs_grow(lfs_t *lfs, lfs_size_t block_count); +#endif + +#ifndef LFS_READONLY +#ifdef LFS_MIGRATE +// Attempts to migrate a previous version of littlefs +// +// Behaves similarly to the lfs_format function. Attempts to mount +// the previous version of littlefs and update the filesystem so it can be +// mounted with the current version of littlefs. +// +// Requires a littlefs object and config struct. This clobbers the littlefs +// object, and does not leave the filesystem mounted. The config struct must +// be zeroed for defaults and backwards compatibility. +// +// Returns a negative error code on failure. +int lfs_migrate(lfs_t *lfs, const struct lfs_config *cfg); +#endif +#endif + + +#ifdef __cplusplus +} /* extern "C" */ +#endif + +#endif diff --git a/components/littlefs/include/lfs_util.h b/components/littlefs/include/lfs_util.h new file mode 100644 index 00000000..c1999fa9 --- /dev/null +++ b/components/littlefs/include/lfs_util.h @@ -0,0 +1,273 @@ +/* + * lfs utility functions + * + * Copyright (c) 2022, The littlefs authors. + * Copyright (c) 2017, Arm Limited. All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + */ +#ifndef LFS_UTIL_H +#define LFS_UTIL_H + +#define LFS_STRINGIZE(x) LFS_STRINGIZE2(x) +#define LFS_STRINGIZE2(x) #x + +// Users can override lfs_util.h with their own configuration by defining +// LFS_CONFIG as a header file to include (-DLFS_CONFIG=lfs_config.h). +// +// If LFS_CONFIG is used, none of the default utils will be emitted and must be +// provided by the config file. To start, I would suggest copying lfs_util.h +// and modifying as needed. +#ifdef LFS_CONFIG +#include LFS_STRINGIZE(LFS_CONFIG) +#else + +// Alternatively, users can provide a header file which defines +// macros and other things consumed by littlefs. +// +// For example, provide my_defines.h, which contains +// something like: +// +// #include +// extern void *my_malloc(size_t sz); +// #define LFS_MALLOC(sz) my_malloc(sz) +// +// And build littlefs with the header by defining LFS_DEFINES. +// (-DLFS_DEFINES=my_defines.h) + +#ifdef LFS_DEFINES +#include LFS_STRINGIZE(LFS_DEFINES) +#endif + +// System includes +#include +#include +#include +#include + +#ifndef LFS_NO_MALLOC +#include +#endif +#ifndef LFS_NO_ASSERT +#include +#endif +#if !defined(LFS_NO_DEBUG) || \ + !defined(LFS_NO_WARN) || \ + !defined(LFS_NO_ERROR) || \ + defined(LFS_YES_TRACE) +#include +#endif + +#ifdef __cplusplus +extern "C" +{ +#endif + + +// Macros, may be replaced by system specific wrappers. Arguments to these +// macros must not have side-effects as the macros can be removed for a smaller +// code footprint + +// Logging functions +#ifndef LFS_TRACE +#ifdef LFS_YES_TRACE +#define LFS_TRACE_(fmt, ...) \ + printf("%s:%d:trace: " fmt "%s\n", __FILE__, __LINE__, __VA_ARGS__) +#define LFS_TRACE(...) LFS_TRACE_(__VA_ARGS__, "") +#else +#define LFS_TRACE(...) +#endif +#endif + +#ifndef LFS_DEBUG +#ifndef LFS_NO_DEBUG +#define LFS_DEBUG_(fmt, ...) \ + printf("%s:%d:debug: " fmt "%s\n", __FILE__, __LINE__, __VA_ARGS__) +#define LFS_DEBUG(...) LFS_DEBUG_(__VA_ARGS__, "") +#else +#define LFS_DEBUG(...) +#endif +#endif + +#ifndef LFS_WARN +#ifndef LFS_NO_WARN +#define LFS_WARN_(fmt, ...) \ + printf("%s:%d:warn: " fmt "%s\n", __FILE__, __LINE__, __VA_ARGS__) +#define LFS_WARN(...) LFS_WARN_(__VA_ARGS__, "") +#else +#define LFS_WARN(...) +#endif +#endif + +#ifndef LFS_ERROR +#ifndef LFS_NO_ERROR +#define LFS_ERROR_(fmt, ...) \ + printf("%s:%d:error: " fmt "%s\n", __FILE__, __LINE__, __VA_ARGS__) +#define LFS_ERROR(...) LFS_ERROR_(__VA_ARGS__, "") +#else +#define LFS_ERROR(...) +#endif +#endif + +// Runtime assertions +#ifndef LFS_ASSERT +#ifndef LFS_NO_ASSERT +#define LFS_ASSERT(test) assert(test) +#else +#define LFS_ASSERT(test) +#endif +#endif + + +// Builtin functions, these may be replaced by more efficient +// toolchain-specific implementations. LFS_NO_INTRINSICS falls back to a more +// expensive basic C implementation for debugging purposes + +// Min/max functions for unsigned 32-bit numbers +static inline uint32_t lfs_max(uint32_t a, uint32_t b) { + return (a > b) ? a : b; +} + +static inline uint32_t lfs_min(uint32_t a, uint32_t b) { + return (a < b) ? a : b; +} + +// Align to nearest multiple of a size +static inline uint32_t lfs_aligndown(uint32_t a, uint32_t alignment) { + return a - (a % alignment); +} + +static inline uint32_t lfs_alignup(uint32_t a, uint32_t alignment) { + return lfs_aligndown(a + alignment-1, alignment); +} + +// Find the smallest power of 2 greater than or equal to a +static inline uint32_t lfs_npw2(uint32_t a) { +#if !defined(LFS_NO_INTRINSICS) && (defined(__GNUC__) || defined(__CC_ARM)) + return 32 - __builtin_clz(a-1); +#else + uint32_t r = 0; + uint32_t s; + a -= 1; + s = (a > 0xffff) << 4; a >>= s; r |= s; + s = (a > 0xff ) << 3; a >>= s; r |= s; + s = (a > 0xf ) << 2; a >>= s; r |= s; + s = (a > 0x3 ) << 1; a >>= s; r |= s; + return (r | (a >> 1)) + 1; +#endif +} + +// Count the number of trailing binary zeros in a +// lfs_ctz(0) may be undefined +static inline uint32_t lfs_ctz(uint32_t a) { +#if !defined(LFS_NO_INTRINSICS) && defined(__GNUC__) + return __builtin_ctz(a); +#else + return lfs_npw2((a & -a) + 1) - 1; +#endif +} + +// Count the number of binary ones in a +static inline uint32_t lfs_popc(uint32_t a) { +#if !defined(LFS_NO_INTRINSICS) && (defined(__GNUC__) || defined(__CC_ARM)) + return __builtin_popcount(a); +#else + a = a - ((a >> 1) & 0x55555555); + a = (a & 0x33333333) + ((a >> 2) & 0x33333333); + return (((a + (a >> 4)) & 0xf0f0f0f) * 0x1010101) >> 24; +#endif +} + +// Find the sequence comparison of a and b, this is the distance +// between a and b ignoring overflow +static inline int lfs_scmp(uint32_t a, uint32_t b) { + return (int)(unsigned)(a - b); +} + +// Convert between 32-bit little-endian and native order +static inline uint32_t lfs_fromle32(uint32_t a) { +#if (defined( BYTE_ORDER ) && defined( ORDER_LITTLE_ENDIAN ) && BYTE_ORDER == ORDER_LITTLE_ENDIAN ) || \ + (defined(__BYTE_ORDER ) && defined(__ORDER_LITTLE_ENDIAN ) && __BYTE_ORDER == __ORDER_LITTLE_ENDIAN ) || \ + (defined(__BYTE_ORDER__) && defined(__ORDER_LITTLE_ENDIAN__) && __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__) + return a; +#elif !defined(LFS_NO_INTRINSICS) && ( \ + (defined( BYTE_ORDER ) && defined( ORDER_BIG_ENDIAN ) && BYTE_ORDER == ORDER_BIG_ENDIAN ) || \ + (defined(__BYTE_ORDER ) && defined(__ORDER_BIG_ENDIAN ) && __BYTE_ORDER == __ORDER_BIG_ENDIAN ) || \ + (defined(__BYTE_ORDER__) && defined(__ORDER_BIG_ENDIAN__) && __BYTE_ORDER__ == __ORDER_BIG_ENDIAN__)) + return __builtin_bswap32(a); +#else + return ((uint32_t)((uint8_t*)&a)[0] << 0) | + ((uint32_t)((uint8_t*)&a)[1] << 8) | + ((uint32_t)((uint8_t*)&a)[2] << 16) | + ((uint32_t)((uint8_t*)&a)[3] << 24); +#endif +} + +static inline uint32_t lfs_tole32(uint32_t a) { + return lfs_fromle32(a); +} + +// Convert between 32-bit big-endian and native order +static inline uint32_t lfs_frombe32(uint32_t a) { +#if !defined(LFS_NO_INTRINSICS) && ( \ + (defined( BYTE_ORDER ) && defined( ORDER_LITTLE_ENDIAN ) && BYTE_ORDER == ORDER_LITTLE_ENDIAN ) || \ + (defined(__BYTE_ORDER ) && defined(__ORDER_LITTLE_ENDIAN ) && __BYTE_ORDER == __ORDER_LITTLE_ENDIAN ) || \ + (defined(__BYTE_ORDER__) && defined(__ORDER_LITTLE_ENDIAN__) && __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__)) + return __builtin_bswap32(a); +#elif (defined( BYTE_ORDER ) && defined( ORDER_BIG_ENDIAN ) && BYTE_ORDER == ORDER_BIG_ENDIAN ) || \ + (defined(__BYTE_ORDER ) && defined(__ORDER_BIG_ENDIAN ) && __BYTE_ORDER == __ORDER_BIG_ENDIAN ) || \ + (defined(__BYTE_ORDER__) && defined(__ORDER_BIG_ENDIAN__) && __BYTE_ORDER__ == __ORDER_BIG_ENDIAN__) + return a; +#else + return ((uint32_t)((uint8_t*)&a)[0] << 24) | + ((uint32_t)((uint8_t*)&a)[1] << 16) | + ((uint32_t)((uint8_t*)&a)[2] << 8) | + ((uint32_t)((uint8_t*)&a)[3] << 0); +#endif +} + +static inline uint32_t lfs_tobe32(uint32_t a) { + return lfs_frombe32(a); +} + +// Calculate CRC-32 with polynomial = 0x04c11db7 +#ifdef LFS_CRC +static inline uint32_t lfs_crc(uint32_t crc, const void *buffer, size_t size) { + return LFS_CRC(crc, buffer, size); +} +#else +uint32_t lfs_crc(uint32_t crc, const void *buffer, size_t size); +#endif + +// Allocate memory, only used if buffers are not provided to littlefs +// +// littlefs current has no alignment requirements, as it only allocates +// byte-level buffers. +static inline void *lfs_malloc(size_t size) { +#if defined(LFS_MALLOC) + return LFS_MALLOC(size); +#elif !defined(LFS_NO_MALLOC) + return malloc(size); +#else + (void)size; + return NULL; +#endif +} + +// Deallocate memory, only used if buffers are not provided to littlefs +static inline void lfs_free(void *p) { +#if defined(LFS_FREE) + LFS_FREE(p); +#elif !defined(LFS_NO_MALLOC) + free(p); +#else + (void)p; +#endif +} + + +#ifdef __cplusplus +} /* extern "C" */ +#endif + +#endif +#endif diff --git a/components/littlefs/src/lfs.c b/components/littlefs/src/lfs.c new file mode 100644 index 00000000..c83c7e0b --- /dev/null +++ b/components/littlefs/src/lfs.c @@ -0,0 +1,6558 @@ +/* + * The little filesystem + * + * Copyright (c) 2022, The littlefs authors. + * Copyright (c) 2017, Arm Limited. All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + */ +#include "lfs.h" +#include "lfs_util.h" + + +// some constants used throughout the code +#define LFS_BLOCK_NULL ((lfs_block_t)-1) +#define LFS_BLOCK_INLINE ((lfs_block_t)-2) + +enum { + LFS_OK_RELOCATED = 1, + LFS_OK_DROPPED = 2, + LFS_OK_ORPHANED = 3, +}; + +enum { + LFS_CMP_EQ = 0, + LFS_CMP_LT = 1, + LFS_CMP_GT = 2, +}; + + +/// Caching block device operations /// + +static inline void lfs_cache_drop(lfs_t *lfs, lfs_cache_t *rcache) { + // do not zero, cheaper if cache is readonly or only going to be + // written with identical data (during relocates) + (void)lfs; + rcache->block = LFS_BLOCK_NULL; +} + +static inline void lfs_cache_zero(lfs_t *lfs, lfs_cache_t *pcache) { + // zero to avoid information leak + memset(pcache->buffer, 0xff, lfs->cfg->cache_size); + pcache->block = LFS_BLOCK_NULL; +} + +static int lfs_bd_read(lfs_t *lfs, + const lfs_cache_t *pcache, lfs_cache_t *rcache, lfs_size_t hint, + lfs_block_t block, lfs_off_t off, + void *buffer, lfs_size_t size) { + uint8_t *data = buffer; + if (off+size > lfs->cfg->block_size + || (lfs->block_count && block >= lfs->block_count)) { + return LFS_ERR_CORRUPT; + } + + while (size > 0) { + lfs_size_t diff = size; + + if (pcache && block == pcache->block && + off < pcache->off + pcache->size) { + if (off >= pcache->off) { + // is already in pcache? + diff = lfs_min(diff, pcache->size - (off-pcache->off)); + memcpy(data, &pcache->buffer[off-pcache->off], diff); + + data += diff; + off += diff; + size -= diff; + continue; + } + + // pcache takes priority + diff = lfs_min(diff, pcache->off-off); + } + + if (block == rcache->block && + off < rcache->off + rcache->size) { + if (off >= rcache->off) { + // is already in rcache? + diff = lfs_min(diff, rcache->size - (off-rcache->off)); + memcpy(data, &rcache->buffer[off-rcache->off], diff); + + data += diff; + off += diff; + size -= diff; + continue; + } + + // rcache takes priority + diff = lfs_min(diff, rcache->off-off); + } + + if (size >= hint && off % lfs->cfg->read_size == 0 && + size >= lfs->cfg->read_size) { + // bypass cache? + diff = lfs_aligndown(diff, lfs->cfg->read_size); + int err = lfs->cfg->read(lfs->cfg, block, off, data, diff); + LFS_ASSERT(err <= 0); + if (err) { + return err; + } + + data += diff; + off += diff; + size -= diff; + continue; + } + + // load to cache, first condition can no longer fail + LFS_ASSERT(!lfs->block_count || block < lfs->block_count); + rcache->block = block; + rcache->off = lfs_aligndown(off, lfs->cfg->read_size); + rcache->size = lfs_min( + lfs_min( + lfs_alignup(off+hint, lfs->cfg->read_size), + lfs->cfg->block_size) + - rcache->off, + lfs->cfg->cache_size); + int err = lfs->cfg->read(lfs->cfg, rcache->block, + rcache->off, rcache->buffer, rcache->size); + LFS_ASSERT(err <= 0); + if (err) { + return err; + } + } + + return 0; +} + +static int lfs_bd_cmp(lfs_t *lfs, + const lfs_cache_t *pcache, lfs_cache_t *rcache, lfs_size_t hint, + lfs_block_t block, lfs_off_t off, + const void *buffer, lfs_size_t size) { + const uint8_t *data = buffer; + lfs_size_t diff = 0; + + for (lfs_off_t i = 0; i < size; i += diff) { + uint8_t dat[8]; + + diff = lfs_min(size-i, sizeof(dat)); + int err = lfs_bd_read(lfs, + pcache, rcache, hint-i, + block, off+i, &dat, diff); + if (err) { + return err; + } + + int res = memcmp(dat, data + i, diff); + if (res) { + return res < 0 ? LFS_CMP_LT : LFS_CMP_GT; + } + } + + return LFS_CMP_EQ; +} + +static int lfs_bd_crc(lfs_t *lfs, + const lfs_cache_t *pcache, lfs_cache_t *rcache, lfs_size_t hint, + lfs_block_t block, lfs_off_t off, lfs_size_t size, uint32_t *crc) { + lfs_size_t diff = 0; + + for (lfs_off_t i = 0; i < size; i += diff) { + uint8_t dat[8]; + diff = lfs_min(size-i, sizeof(dat)); + int err = lfs_bd_read(lfs, + pcache, rcache, hint-i, + block, off+i, &dat, diff); + if (err) { + return err; + } + + *crc = lfs_crc(*crc, &dat, diff); + } + + return 0; +} + +#ifndef LFS_READONLY +static int lfs_bd_flush(lfs_t *lfs, + lfs_cache_t *pcache, lfs_cache_t *rcache, bool validate) { + if (pcache->block != LFS_BLOCK_NULL && pcache->block != LFS_BLOCK_INLINE) { + LFS_ASSERT(pcache->block < lfs->block_count); + lfs_size_t diff = lfs_alignup(pcache->size, lfs->cfg->prog_size); + int err = lfs->cfg->prog(lfs->cfg, pcache->block, + pcache->off, pcache->buffer, diff); + LFS_ASSERT(err <= 0); + if (err) { + return err; + } + + if (validate) { + // check data on disk + lfs_cache_drop(lfs, rcache); + int res = lfs_bd_cmp(lfs, + NULL, rcache, diff, + pcache->block, pcache->off, pcache->buffer, diff); + if (res < 0) { + return res; + } + + if (res != LFS_CMP_EQ) { + return LFS_ERR_CORRUPT; + } + } + + lfs_cache_zero(lfs, pcache); + } + + return 0; +} +#endif + +#ifndef LFS_READONLY +static int lfs_bd_sync(lfs_t *lfs, + lfs_cache_t *pcache, lfs_cache_t *rcache, bool validate) { + lfs_cache_drop(lfs, rcache); + + int err = lfs_bd_flush(lfs, pcache, rcache, validate); + if (err) { + return err; + } + + err = lfs->cfg->sync(lfs->cfg); + LFS_ASSERT(err <= 0); + return err; +} +#endif + +#ifndef LFS_READONLY +static int lfs_bd_prog(lfs_t *lfs, + lfs_cache_t *pcache, lfs_cache_t *rcache, bool validate, + lfs_block_t block, lfs_off_t off, + const void *buffer, lfs_size_t size) { + const uint8_t *data = buffer; + LFS_ASSERT(block == LFS_BLOCK_INLINE || block < lfs->block_count); + LFS_ASSERT(off + size <= lfs->cfg->block_size); + + while (size > 0) { + if (block == pcache->block && + off >= pcache->off && + off < pcache->off + lfs->cfg->cache_size) { + // already fits in pcache? + lfs_size_t diff = lfs_min(size, + lfs->cfg->cache_size - (off-pcache->off)); + memcpy(&pcache->buffer[off-pcache->off], data, diff); + + data += diff; + off += diff; + size -= diff; + + pcache->size = lfs_max(pcache->size, off - pcache->off); + if (pcache->size == lfs->cfg->cache_size) { + // eagerly flush out pcache if we fill up + int err = lfs_bd_flush(lfs, pcache, rcache, validate); + if (err) { + return err; + } + } + + continue; + } + + // pcache must have been flushed, either by programming an + // entire block or manually flushing the pcache + LFS_ASSERT(pcache->block == LFS_BLOCK_NULL); + + // prepare pcache, first condition can no longer fail + pcache->block = block; + pcache->off = lfs_aligndown(off, lfs->cfg->prog_size); + pcache->size = 0; + } + + return 0; +} +#endif + +#ifndef LFS_READONLY +static int lfs_bd_erase(lfs_t *lfs, lfs_block_t block) { + LFS_ASSERT(block < lfs->block_count); + int err = lfs->cfg->erase(lfs->cfg, block); + LFS_ASSERT(err <= 0); + return err; +} +#endif + + +/// Small type-level utilities /// + +// some operations on paths +static inline lfs_size_t lfs_path_namelen(const char *path) { + return (lfs_size_t)strcspn(path, "/"); +} + +static inline bool lfs_path_islast(const char *path) { + lfs_size_t namelen = lfs_path_namelen(path); + return path[namelen + strspn(path + namelen, "/")] == '\0'; +} + +static inline bool lfs_path_isdir(const char *path) { + return path[lfs_path_namelen(path)] != '\0'; +} + +// operations on block pairs +static inline void lfs_pair_swap(lfs_block_t pair[2]) { + lfs_block_t t = pair[0]; + pair[0] = pair[1]; + pair[1] = t; +} + +static inline bool lfs_pair_isnull(const lfs_block_t pair[2]) { + return pair[0] == LFS_BLOCK_NULL || pair[1] == LFS_BLOCK_NULL; +} + +static inline int lfs_pair_cmp( + const lfs_block_t paira[2], + const lfs_block_t pairb[2]) { + return !(paira[0] == pairb[0] || paira[1] == pairb[1] || + paira[0] == pairb[1] || paira[1] == pairb[0]); +} + +static inline bool lfs_pair_issync( + const lfs_block_t paira[2], + const lfs_block_t pairb[2]) { + return (paira[0] == pairb[0] && paira[1] == pairb[1]) || + (paira[0] == pairb[1] && paira[1] == pairb[0]); +} + +static inline void lfs_pair_fromle32(lfs_block_t pair[2]) { + pair[0] = lfs_fromle32(pair[0]); + pair[1] = lfs_fromle32(pair[1]); +} + +#ifndef LFS_READONLY +static inline void lfs_pair_tole32(lfs_block_t pair[2]) { + pair[0] = lfs_tole32(pair[0]); + pair[1] = lfs_tole32(pair[1]); +} +#endif + +// operations on 32-bit entry tags +typedef uint32_t lfs_tag_t; +typedef int32_t lfs_stag_t; + +#define LFS_MKTAG(type, id, size) \ + (((lfs_tag_t)(type) << 20) | ((lfs_tag_t)(id) << 10) | (lfs_tag_t)(size)) + +#define LFS_MKTAG_IF(cond, type, id, size) \ + ((cond) ? LFS_MKTAG(type, id, size) : LFS_MKTAG(LFS_FROM_NOOP, 0, 0)) + +#define LFS_MKTAG_IF_ELSE(cond, type1, id1, size1, type2, id2, size2) \ + ((cond) ? LFS_MKTAG(type1, id1, size1) : LFS_MKTAG(type2, id2, size2)) + +static inline bool lfs_tag_isvalid(lfs_tag_t tag) { + return !(tag & 0x80000000); +} + +static inline bool lfs_tag_isdelete(lfs_tag_t tag) { + return ((int32_t)(tag << 22) >> 22) == -1; +} + +static inline uint16_t lfs_tag_type1(lfs_tag_t tag) { + return (tag & 0x70000000) >> 20; +} + +static inline uint16_t lfs_tag_type2(lfs_tag_t tag) { + return (tag & 0x78000000) >> 20; +} + +static inline uint16_t lfs_tag_type3(lfs_tag_t tag) { + return (tag & 0x7ff00000) >> 20; +} + +static inline uint8_t lfs_tag_chunk(lfs_tag_t tag) { + return (tag & 0x0ff00000) >> 20; +} + +static inline int8_t lfs_tag_splice(lfs_tag_t tag) { + return (int8_t)lfs_tag_chunk(tag); +} + +static inline uint16_t lfs_tag_id(lfs_tag_t tag) { + return (tag & 0x000ffc00) >> 10; +} + +static inline lfs_size_t lfs_tag_size(lfs_tag_t tag) { + return tag & 0x000003ff; +} + +static inline lfs_size_t lfs_tag_dsize(lfs_tag_t tag) { + return sizeof(tag) + lfs_tag_size(tag + lfs_tag_isdelete(tag)); +} + +// operations on attributes in attribute lists +struct lfs_mattr { + lfs_tag_t tag; + const void *buffer; +}; + +struct lfs_diskoff { + lfs_block_t block; + lfs_off_t off; +}; + +#define LFS_MKATTRS(...) \ + (struct lfs_mattr[]){__VA_ARGS__}, \ + sizeof((struct lfs_mattr[]){__VA_ARGS__}) / sizeof(struct lfs_mattr) + +// operations on global state +static inline void lfs_gstate_xor(lfs_gstate_t *a, const lfs_gstate_t *b) { + a->tag ^= b->tag; + a->pair[0] ^= b->pair[0]; + a->pair[1] ^= b->pair[1]; +} + +static inline bool lfs_gstate_iszero(const lfs_gstate_t *a) { + return a->tag == 0 + && a->pair[0] == 0 + && a->pair[1] == 0; +} + +#ifndef LFS_READONLY +static inline bool lfs_gstate_hasorphans(const lfs_gstate_t *a) { + return lfs_tag_size(a->tag); +} + +static inline uint8_t lfs_gstate_getorphans(const lfs_gstate_t *a) { + return lfs_tag_size(a->tag) & 0x1ff; +} + +static inline bool lfs_gstate_hasmove(const lfs_gstate_t *a) { + return lfs_tag_type1(a->tag); +} +#endif + +static inline bool lfs_gstate_needssuperblock(const lfs_gstate_t *a) { + return lfs_tag_size(a->tag) >> 9; +} + +static inline bool lfs_gstate_hasmovehere(const lfs_gstate_t *a, + const lfs_block_t *pair) { + return lfs_tag_type1(a->tag) && lfs_pair_cmp(a->pair, pair) == 0; +} + +static inline void lfs_gstate_fromle32(lfs_gstate_t *a) { + a->tag = lfs_fromle32(a->tag); + a->pair[0] = lfs_fromle32(a->pair[0]); + a->pair[1] = lfs_fromle32(a->pair[1]); +} + +#ifndef LFS_READONLY +static inline void lfs_gstate_tole32(lfs_gstate_t *a) { + a->tag = lfs_tole32(a->tag); + a->pair[0] = lfs_tole32(a->pair[0]); + a->pair[1] = lfs_tole32(a->pair[1]); +} +#endif + +// operations on forward-CRCs used to track erased state +struct lfs_fcrc { + lfs_size_t size; + uint32_t crc; +}; + +static void lfs_fcrc_fromle32(struct lfs_fcrc *fcrc) { + fcrc->size = lfs_fromle32(fcrc->size); + fcrc->crc = lfs_fromle32(fcrc->crc); +} + +#ifndef LFS_READONLY +static void lfs_fcrc_tole32(struct lfs_fcrc *fcrc) { + fcrc->size = lfs_tole32(fcrc->size); + fcrc->crc = lfs_tole32(fcrc->crc); +} +#endif + +// other endianness operations +static void lfs_ctz_fromle32(struct lfs_ctz *ctz) { + ctz->head = lfs_fromle32(ctz->head); + ctz->size = lfs_fromle32(ctz->size); +} + +#ifndef LFS_READONLY +static void lfs_ctz_tole32(struct lfs_ctz *ctz) { + ctz->head = lfs_tole32(ctz->head); + ctz->size = lfs_tole32(ctz->size); +} +#endif + +static inline void lfs_superblock_fromle32(lfs_superblock_t *superblock) { + superblock->version = lfs_fromle32(superblock->version); + superblock->block_size = lfs_fromle32(superblock->block_size); + superblock->block_count = lfs_fromle32(superblock->block_count); + superblock->name_max = lfs_fromle32(superblock->name_max); + superblock->file_max = lfs_fromle32(superblock->file_max); + superblock->attr_max = lfs_fromle32(superblock->attr_max); +} + +#ifndef LFS_READONLY +static inline void lfs_superblock_tole32(lfs_superblock_t *superblock) { + superblock->version = lfs_tole32(superblock->version); + superblock->block_size = lfs_tole32(superblock->block_size); + superblock->block_count = lfs_tole32(superblock->block_count); + superblock->name_max = lfs_tole32(superblock->name_max); + superblock->file_max = lfs_tole32(superblock->file_max); + superblock->attr_max = lfs_tole32(superblock->attr_max); +} +#endif + +#ifndef LFS_NO_ASSERT +static bool lfs_mlist_isopen(struct lfs_mlist *head, + struct lfs_mlist *node) { + for (struct lfs_mlist **p = &head; *p; p = &(*p)->next) { + if (*p == (struct lfs_mlist*)node) { + return true; + } + } + + return false; +} +#endif + +static void lfs_mlist_remove(lfs_t *lfs, struct lfs_mlist *mlist) { + for (struct lfs_mlist **p = &lfs->mlist; *p; p = &(*p)->next) { + if (*p == mlist) { + *p = (*p)->next; + break; + } + } +} + +static void lfs_mlist_append(lfs_t *lfs, struct lfs_mlist *mlist) { + mlist->next = lfs->mlist; + lfs->mlist = mlist; +} + +// some other filesystem operations +static uint32_t lfs_fs_disk_version(lfs_t *lfs) { + (void)lfs; +#ifdef LFS_MULTIVERSION + if (lfs->cfg->disk_version) { + return lfs->cfg->disk_version; + } else +#endif + { + return LFS_DISK_VERSION; + } +} + +static uint16_t lfs_fs_disk_version_major(lfs_t *lfs) { + return 0xffff & (lfs_fs_disk_version(lfs) >> 16); + +} + +static uint16_t lfs_fs_disk_version_minor(lfs_t *lfs) { + return 0xffff & (lfs_fs_disk_version(lfs) >> 0); +} + + +/// Internal operations predeclared here /// +#ifndef LFS_READONLY +static int lfs_dir_commit(lfs_t *lfs, lfs_mdir_t *dir, + const struct lfs_mattr *attrs, int attrcount); +static int lfs_dir_compact(lfs_t *lfs, + lfs_mdir_t *dir, const struct lfs_mattr *attrs, int attrcount, + lfs_mdir_t *source, uint16_t begin, uint16_t end); +static lfs_ssize_t lfs_file_flushedwrite(lfs_t *lfs, lfs_file_t *file, + const void *buffer, lfs_size_t size); +static lfs_ssize_t lfs_file_write_(lfs_t *lfs, lfs_file_t *file, + const void *buffer, lfs_size_t size); +static int lfs_file_sync_(lfs_t *lfs, lfs_file_t *file); +static int lfs_file_outline(lfs_t *lfs, lfs_file_t *file); +static int lfs_file_flush(lfs_t *lfs, lfs_file_t *file); + +static int lfs_fs_deorphan(lfs_t *lfs, bool powerloss); +static int lfs_fs_preporphans(lfs_t *lfs, int8_t orphans); +static void lfs_fs_prepmove(lfs_t *lfs, + uint16_t id, const lfs_block_t pair[2]); +static int lfs_fs_pred(lfs_t *lfs, const lfs_block_t dir[2], + lfs_mdir_t *pdir); +static lfs_stag_t lfs_fs_parent(lfs_t *lfs, const lfs_block_t dir[2], + lfs_mdir_t *parent); +static int lfs_fs_forceconsistency(lfs_t *lfs); +#endif + +static void lfs_fs_prepsuperblock(lfs_t *lfs, bool needssuperblock); + +#ifdef LFS_MIGRATE +static int lfs1_traverse(lfs_t *lfs, + int (*cb)(void*, lfs_block_t), void *data); +#endif + +static int lfs_dir_rewind_(lfs_t *lfs, lfs_dir_t *dir); + +static lfs_ssize_t lfs_file_flushedread(lfs_t *lfs, lfs_file_t *file, + void *buffer, lfs_size_t size); +static lfs_ssize_t lfs_file_read_(lfs_t *lfs, lfs_file_t *file, + void *buffer, lfs_size_t size); +static int lfs_file_close_(lfs_t *lfs, lfs_file_t *file); +static lfs_soff_t lfs_file_size_(lfs_t *lfs, lfs_file_t *file); + +static lfs_ssize_t lfs_fs_size_(lfs_t *lfs); +static int lfs_fs_traverse_(lfs_t *lfs, + int (*cb)(void *data, lfs_block_t block), void *data, + bool includeorphans); + +static int lfs_deinit(lfs_t *lfs); +static int lfs_unmount_(lfs_t *lfs); + + +/// Block allocator /// + +// allocations should call this when all allocated blocks are committed to +// the filesystem +// +// after a checkpoint, the block allocator may realloc any untracked blocks +static void lfs_alloc_ckpoint(lfs_t *lfs) { + lfs->lookahead.ckpoint = lfs->block_count; +} + +// drop the lookahead buffer, this is done during mounting and failed +// traversals in order to avoid invalid lookahead state +static void lfs_alloc_drop(lfs_t *lfs) { + lfs->lookahead.size = 0; + lfs->lookahead.next = 0; + lfs_alloc_ckpoint(lfs); +} + +#ifndef LFS_READONLY +static int lfs_alloc_lookahead(void *p, lfs_block_t block) { + lfs_t *lfs = (lfs_t*)p; + lfs_block_t off = ((block - lfs->lookahead.start) + + lfs->block_count) % lfs->block_count; + + if (off < lfs->lookahead.size) { + lfs->lookahead.buffer[off / 8] |= 1U << (off % 8); + } + + return 0; +} +#endif + +#ifndef LFS_READONLY +static int lfs_alloc_scan(lfs_t *lfs) { + // move lookahead buffer to the first unused block + // + // note we limit the lookahead buffer to at most the amount of blocks + // checkpointed, this prevents the math in lfs_alloc from underflowing + lfs->lookahead.start = (lfs->lookahead.start + lfs->lookahead.next) + % lfs->block_count; + lfs->lookahead.next = 0; + lfs->lookahead.size = lfs_min( + 8*lfs->cfg->lookahead_size, + lfs->lookahead.ckpoint); + + // find mask of free blocks from tree + memset(lfs->lookahead.buffer, 0, lfs->cfg->lookahead_size); + int err = lfs_fs_traverse_(lfs, lfs_alloc_lookahead, lfs, true); + if (err) { + lfs_alloc_drop(lfs); + return err; + } + + return 0; +} +#endif + +#ifndef LFS_READONLY +static int lfs_alloc(lfs_t *lfs, lfs_block_t *block) { + while (true) { + // scan our lookahead buffer for free blocks + while (lfs->lookahead.next < lfs->lookahead.size) { + if (!(lfs->lookahead.buffer[lfs->lookahead.next / 8] + & (1U << (lfs->lookahead.next % 8)))) { + // found a free block + *block = (lfs->lookahead.start + lfs->lookahead.next) + % lfs->block_count; + + // eagerly find next free block to maximize how many blocks + // lfs_alloc_ckpoint makes available for scanning + while (true) { + lfs->lookahead.next += 1; + lfs->lookahead.ckpoint -= 1; + + if (lfs->lookahead.next >= lfs->lookahead.size + || !(lfs->lookahead.buffer[lfs->lookahead.next / 8] + & (1U << (lfs->lookahead.next % 8)))) { + return 0; + } + } + } + + lfs->lookahead.next += 1; + lfs->lookahead.ckpoint -= 1; + } + + // In order to keep our block allocator from spinning forever when our + // filesystem is full, we mark points where there are no in-flight + // allocations with a checkpoint before starting a set of allocations. + // + // If we've looked at all blocks since the last checkpoint, we report + // the filesystem as out of storage. + // + if (lfs->lookahead.ckpoint <= 0) { + LFS_ERROR("No more free space 0x%"PRIx32, + (lfs->lookahead.start + lfs->lookahead.next) + % lfs->block_count); + return LFS_ERR_NOSPC; + } + + // No blocks in our lookahead buffer, we need to scan the filesystem for + // unused blocks in the next lookahead window. + int err = lfs_alloc_scan(lfs); + if(err) { + return err; + } + } +} +#endif + +/// Metadata pair and directory operations /// +static lfs_stag_t lfs_dir_getslice(lfs_t *lfs, const lfs_mdir_t *dir, + lfs_tag_t gmask, lfs_tag_t gtag, + lfs_off_t goff, void *gbuffer, lfs_size_t gsize) { + lfs_off_t off = dir->off; + lfs_tag_t ntag = dir->etag; + lfs_stag_t gdiff = 0; + + // synthetic moves + if (lfs_gstate_hasmovehere(&lfs->gdisk, dir->pair) && + lfs_tag_id(gmask) != 0) { + if (lfs_tag_id(lfs->gdisk.tag) == lfs_tag_id(gtag)) { + return LFS_ERR_NOENT; + } else if (lfs_tag_id(lfs->gdisk.tag) < lfs_tag_id(gtag)) { + gdiff -= LFS_MKTAG(0, 1, 0); + } + } + + // iterate over dir block backwards (for faster lookups) + while (off >= sizeof(lfs_tag_t) + lfs_tag_dsize(ntag)) { + off -= lfs_tag_dsize(ntag); + lfs_tag_t tag = ntag; + int err = lfs_bd_read(lfs, + NULL, &lfs->rcache, sizeof(ntag), + dir->pair[0], off, &ntag, sizeof(ntag)); + LFS_ASSERT(err <= 0); + if (err) { + return err; + } + + ntag = (lfs_frombe32(ntag) ^ tag) & 0x7fffffff; + + if (lfs_tag_id(gmask) != 0 && + lfs_tag_type1(tag) == LFS_TYPE_SPLICE && + lfs_tag_id(tag) <= lfs_tag_id(gtag - gdiff)) { + if (tag == (LFS_MKTAG(LFS_TYPE_CREATE, 0, 0) | + (LFS_MKTAG(0, 0x3ff, 0) & (gtag - gdiff)))) { + // found where we were created + return LFS_ERR_NOENT; + } + + // move around splices + gdiff += LFS_MKTAG(0, lfs_tag_splice(tag), 0); + } + + if ((gmask & tag) == (gmask & (gtag - gdiff))) { + if (lfs_tag_isdelete(tag)) { + return LFS_ERR_NOENT; + } + + lfs_size_t diff = lfs_min(lfs_tag_size(tag), gsize); + err = lfs_bd_read(lfs, + NULL, &lfs->rcache, diff, + dir->pair[0], off+sizeof(tag)+goff, gbuffer, diff); + LFS_ASSERT(err <= 0); + if (err) { + return err; + } + + memset((uint8_t*)gbuffer + diff, 0, gsize - diff); + + return tag + gdiff; + } + } + + return LFS_ERR_NOENT; +} + +static lfs_stag_t lfs_dir_get(lfs_t *lfs, const lfs_mdir_t *dir, + lfs_tag_t gmask, lfs_tag_t gtag, void *buffer) { + return lfs_dir_getslice(lfs, dir, + gmask, gtag, + 0, buffer, lfs_tag_size(gtag)); +} + +static int lfs_dir_getread(lfs_t *lfs, const lfs_mdir_t *dir, + const lfs_cache_t *pcache, lfs_cache_t *rcache, lfs_size_t hint, + lfs_tag_t gmask, lfs_tag_t gtag, + lfs_off_t off, void *buffer, lfs_size_t size) { + uint8_t *data = buffer; + if (off+size > lfs->cfg->block_size) { + return LFS_ERR_CORRUPT; + } + + while (size > 0) { + lfs_size_t diff = size; + + if (pcache && pcache->block == LFS_BLOCK_INLINE && + off < pcache->off + pcache->size) { + if (off >= pcache->off) { + // is already in pcache? + diff = lfs_min(diff, pcache->size - (off-pcache->off)); + memcpy(data, &pcache->buffer[off-pcache->off], diff); + + data += diff; + off += diff; + size -= diff; + continue; + } + + // pcache takes priority + diff = lfs_min(diff, pcache->off-off); + } + + if (rcache->block == LFS_BLOCK_INLINE && + off < rcache->off + rcache->size) { + if (off >= rcache->off) { + // is already in rcache? + diff = lfs_min(diff, rcache->size - (off-rcache->off)); + memcpy(data, &rcache->buffer[off-rcache->off], diff); + + data += diff; + off += diff; + size -= diff; + continue; + } + } + + // load to cache, first condition can no longer fail + rcache->block = LFS_BLOCK_INLINE; + rcache->off = lfs_aligndown(off, lfs->cfg->read_size); + rcache->size = lfs_min(lfs_alignup(off+hint, lfs->cfg->read_size), + lfs->cfg->cache_size); + int err = lfs_dir_getslice(lfs, dir, gmask, gtag, + rcache->off, rcache->buffer, rcache->size); + if (err < 0) { + return err; + } + } + + return 0; +} + +#ifndef LFS_READONLY +static int lfs_dir_traverse_filter(void *p, + lfs_tag_t tag, const void *buffer) { + lfs_tag_t *filtertag = p; + (void)buffer; + + // which mask depends on unique bit in tag structure + uint32_t mask = (tag & LFS_MKTAG(0x100, 0, 0)) + ? LFS_MKTAG(0x7ff, 0x3ff, 0) + : LFS_MKTAG(0x700, 0x3ff, 0); + + // check for redundancy + if ((mask & tag) == (mask & *filtertag) || + lfs_tag_isdelete(*filtertag) || + (LFS_MKTAG(0x7ff, 0x3ff, 0) & tag) == ( + LFS_MKTAG(LFS_TYPE_DELETE, 0, 0) | + (LFS_MKTAG(0, 0x3ff, 0) & *filtertag))) { + *filtertag = LFS_MKTAG(LFS_FROM_NOOP, 0, 0); + return true; + } + + // check if we need to adjust for created/deleted tags + if (lfs_tag_type1(tag) == LFS_TYPE_SPLICE && + lfs_tag_id(tag) <= lfs_tag_id(*filtertag)) { + *filtertag += LFS_MKTAG(0, lfs_tag_splice(tag), 0); + } + + return false; +} +#endif + +#ifndef LFS_READONLY +// maximum recursive depth of lfs_dir_traverse, the deepest call: +// +// traverse with commit +// '-> traverse with move +// '-> traverse with filter +// +#define LFS_DIR_TRAVERSE_DEPTH 3 + +struct lfs_dir_traverse { + const lfs_mdir_t *dir; + lfs_off_t off; + lfs_tag_t ptag; + const struct lfs_mattr *attrs; + int attrcount; + + lfs_tag_t tmask; + lfs_tag_t ttag; + uint16_t begin; + uint16_t end; + int16_t diff; + + int (*cb)(void *data, lfs_tag_t tag, const void *buffer); + void *data; + + lfs_tag_t tag; + const void *buffer; + struct lfs_diskoff disk; +}; + +static int lfs_dir_traverse(lfs_t *lfs, + const lfs_mdir_t *dir, lfs_off_t off, lfs_tag_t ptag, + const struct lfs_mattr *attrs, int attrcount, + lfs_tag_t tmask, lfs_tag_t ttag, + uint16_t begin, uint16_t end, int16_t diff, + int (*cb)(void *data, lfs_tag_t tag, const void *buffer), void *data) { + // This function in inherently recursive, but bounded. To allow tool-based + // analysis without unnecessary code-cost we use an explicit stack + struct lfs_dir_traverse stack[LFS_DIR_TRAVERSE_DEPTH-1]; + unsigned sp = 0; + int res; + + // iterate over directory and attrs + lfs_tag_t tag; + const void *buffer; + struct lfs_diskoff disk = {0}; + while (true) { + { + if (off+lfs_tag_dsize(ptag) < dir->off) { + off += lfs_tag_dsize(ptag); + int err = lfs_bd_read(lfs, + NULL, &lfs->rcache, sizeof(tag), + dir->pair[0], off, &tag, sizeof(tag)); + if (err) { + return err; + } + + tag = (lfs_frombe32(tag) ^ ptag) | 0x80000000; + disk.block = dir->pair[0]; + disk.off = off+sizeof(lfs_tag_t); + buffer = &disk; + ptag = tag; + } else if (attrcount > 0) { + tag = attrs[0].tag; + buffer = attrs[0].buffer; + attrs += 1; + attrcount -= 1; + } else { + // finished traversal, pop from stack? + res = 0; + break; + } + + // do we need to filter? + lfs_tag_t mask = LFS_MKTAG(0x7ff, 0, 0); + if ((mask & tmask & tag) != (mask & tmask & ttag)) { + continue; + } + + if (lfs_tag_id(tmask) != 0) { + LFS_ASSERT(sp < LFS_DIR_TRAVERSE_DEPTH); + // recurse, scan for duplicates, and update tag based on + // creates/deletes + stack[sp] = (struct lfs_dir_traverse){ + .dir = dir, + .off = off, + .ptag = ptag, + .attrs = attrs, + .attrcount = attrcount, + .tmask = tmask, + .ttag = ttag, + .begin = begin, + .end = end, + .diff = diff, + .cb = cb, + .data = data, + .tag = tag, + .buffer = buffer, + .disk = disk, + }; + sp += 1; + + tmask = 0; + ttag = 0; + begin = 0; + end = 0; + diff = 0; + cb = lfs_dir_traverse_filter; + data = &stack[sp-1].tag; + continue; + } + } + +popped: + // in filter range? + if (lfs_tag_id(tmask) != 0 && + !(lfs_tag_id(tag) >= begin && lfs_tag_id(tag) < end)) { + continue; + } + + // handle special cases for mcu-side operations + if (lfs_tag_type3(tag) == LFS_FROM_NOOP) { + // do nothing + } else if (lfs_tag_type3(tag) == LFS_FROM_MOVE) { + // Without this condition, lfs_dir_traverse can exhibit an + // extremely expensive O(n^3) of nested loops when renaming. + // This happens because lfs_dir_traverse tries to filter tags by + // the tags in the source directory, triggering a second + // lfs_dir_traverse with its own filter operation. + // + // traverse with commit + // '-> traverse with filter + // '-> traverse with move + // '-> traverse with filter + // + // However we don't actually care about filtering the second set of + // tags, since duplicate tags have no effect when filtering. + // + // This check skips this unnecessary recursive filtering explicitly, + // reducing this runtime from O(n^3) to O(n^2). + if (cb == lfs_dir_traverse_filter) { + continue; + } + + // recurse into move + stack[sp] = (struct lfs_dir_traverse){ + .dir = dir, + .off = off, + .ptag = ptag, + .attrs = attrs, + .attrcount = attrcount, + .tmask = tmask, + .ttag = ttag, + .begin = begin, + .end = end, + .diff = diff, + .cb = cb, + .data = data, + .tag = LFS_MKTAG(LFS_FROM_NOOP, 0, 0), + }; + sp += 1; + + uint16_t fromid = lfs_tag_size(tag); + uint16_t toid = lfs_tag_id(tag); + dir = buffer; + off = 0; + ptag = 0xffffffff; + attrs = NULL; + attrcount = 0; + tmask = LFS_MKTAG(0x600, 0x3ff, 0); + ttag = LFS_MKTAG(LFS_TYPE_STRUCT, 0, 0); + begin = fromid; + end = fromid+1; + diff = toid-fromid+diff; + } else if (lfs_tag_type3(tag) == LFS_FROM_USERATTRS) { + for (unsigned i = 0; i < lfs_tag_size(tag); i++) { + const struct lfs_attr *a = buffer; + res = cb(data, LFS_MKTAG(LFS_TYPE_USERATTR + a[i].type, + lfs_tag_id(tag) + diff, a[i].size), a[i].buffer); + if (res < 0) { + return res; + } + + if (res) { + break; + } + } + } else { + res = cb(data, tag + LFS_MKTAG(0, diff, 0), buffer); + if (res < 0) { + return res; + } + + if (res) { + break; + } + } + } + + if (sp > 0) { + // pop from the stack and return, fortunately all pops share + // a destination + dir = stack[sp-1].dir; + off = stack[sp-1].off; + ptag = stack[sp-1].ptag; + attrs = stack[sp-1].attrs; + attrcount = stack[sp-1].attrcount; + tmask = stack[sp-1].tmask; + ttag = stack[sp-1].ttag; + begin = stack[sp-1].begin; + end = stack[sp-1].end; + diff = stack[sp-1].diff; + cb = stack[sp-1].cb; + data = stack[sp-1].data; + tag = stack[sp-1].tag; + buffer = stack[sp-1].buffer; + disk = stack[sp-1].disk; + sp -= 1; + goto popped; + } else { + return res; + } +} +#endif + +static lfs_stag_t lfs_dir_fetchmatch(lfs_t *lfs, + lfs_mdir_t *dir, const lfs_block_t pair[2], + lfs_tag_t fmask, lfs_tag_t ftag, uint16_t *id, + int (*cb)(void *data, lfs_tag_t tag, const void *buffer), void *data) { + // we can find tag very efficiently during a fetch, since we're already + // scanning the entire directory + lfs_stag_t besttag = -1; + + // if either block address is invalid we return LFS_ERR_CORRUPT here, + // otherwise later writes to the pair could fail + if (lfs->block_count + && (pair[0] >= lfs->block_count || pair[1] >= lfs->block_count)) { + return LFS_ERR_CORRUPT; + } + + // find the block with the most recent revision + uint32_t revs[2] = {0, 0}; + int r = 0; + for (int i = 0; i < 2; i++) { + int err = lfs_bd_read(lfs, + NULL, &lfs->rcache, sizeof(revs[i]), + pair[i], 0, &revs[i], sizeof(revs[i])); + revs[i] = lfs_fromle32(revs[i]); + if (err && err != LFS_ERR_CORRUPT) { + return err; + } + + if (err != LFS_ERR_CORRUPT && + lfs_scmp(revs[i], revs[(i+1)%2]) > 0) { + r = i; + } + } + + dir->pair[0] = pair[(r+0)%2]; + dir->pair[1] = pair[(r+1)%2]; + dir->rev = revs[(r+0)%2]; + dir->off = 0; // nonzero = found some commits + + // now scan tags to fetch the actual dir and find possible match + for (int i = 0; i < 2; i++) { + lfs_off_t off = 0; + lfs_tag_t ptag = 0xffffffff; + + uint16_t tempcount = 0; + lfs_block_t temptail[2] = {LFS_BLOCK_NULL, LFS_BLOCK_NULL}; + bool tempsplit = false; + lfs_stag_t tempbesttag = besttag; + + // assume not erased until proven otherwise + bool maybeerased = false; + bool hasfcrc = false; + struct lfs_fcrc fcrc; + + dir->rev = lfs_tole32(dir->rev); + uint32_t crc = lfs_crc(0xffffffff, &dir->rev, sizeof(dir->rev)); + dir->rev = lfs_fromle32(dir->rev); + + while (true) { + // extract next tag + lfs_tag_t tag; + off += lfs_tag_dsize(ptag); + int err = lfs_bd_read(lfs, + NULL, &lfs->rcache, lfs->cfg->block_size, + dir->pair[0], off, &tag, sizeof(tag)); + if (err) { + if (err == LFS_ERR_CORRUPT) { + // can't continue? + break; + } + return err; + } + + crc = lfs_crc(crc, &tag, sizeof(tag)); + tag = lfs_frombe32(tag) ^ ptag; + + // next commit not yet programmed? + if (!lfs_tag_isvalid(tag)) { + // we only might be erased if the last tag was a crc + maybeerased = (lfs_tag_type2(ptag) == LFS_TYPE_CCRC); + break; + // out of range? + } else if (off + lfs_tag_dsize(tag) > lfs->cfg->block_size) { + break; + } + + ptag = tag; + + if (lfs_tag_type2(tag) == LFS_TYPE_CCRC) { + // check the crc attr + uint32_t dcrc; + err = lfs_bd_read(lfs, + NULL, &lfs->rcache, lfs->cfg->block_size, + dir->pair[0], off+sizeof(tag), &dcrc, sizeof(dcrc)); + if (err) { + if (err == LFS_ERR_CORRUPT) { + break; + } + return err; + } + dcrc = lfs_fromle32(dcrc); + + if (crc != dcrc) { + break; + } + + // reset the next bit if we need to + ptag ^= (lfs_tag_t)(lfs_tag_chunk(tag) & 1U) << 31; + + // toss our crc into the filesystem seed for + // pseudorandom numbers, note we use another crc here + // as a collection function because it is sufficiently + // random and convenient + lfs->seed = lfs_crc(lfs->seed, &crc, sizeof(crc)); + + // update with what's found so far + besttag = tempbesttag; + dir->off = off + lfs_tag_dsize(tag); + dir->etag = ptag; + dir->count = tempcount; + dir->tail[0] = temptail[0]; + dir->tail[1] = temptail[1]; + dir->split = tempsplit; + + // reset crc, hasfcrc + crc = 0xffffffff; + continue; + } + + // crc the entry first, hopefully leaving it in the cache + err = lfs_bd_crc(lfs, + NULL, &lfs->rcache, lfs->cfg->block_size, + dir->pair[0], off+sizeof(tag), + lfs_tag_dsize(tag)-sizeof(tag), &crc); + if (err) { + if (err == LFS_ERR_CORRUPT) { + break; + } + return err; + } + + // directory modification tags? + if (lfs_tag_type1(tag) == LFS_TYPE_NAME) { + // increase count of files if necessary + if (lfs_tag_id(tag) >= tempcount) { + tempcount = lfs_tag_id(tag) + 1; + } + } else if (lfs_tag_type1(tag) == LFS_TYPE_SPLICE) { + tempcount += lfs_tag_splice(tag); + + if (tag == (LFS_MKTAG(LFS_TYPE_DELETE, 0, 0) | + (LFS_MKTAG(0, 0x3ff, 0) & tempbesttag))) { + tempbesttag |= 0x80000000; + } else if (tempbesttag != -1 && + lfs_tag_id(tag) <= lfs_tag_id(tempbesttag)) { + tempbesttag += LFS_MKTAG(0, lfs_tag_splice(tag), 0); + } + } else if (lfs_tag_type1(tag) == LFS_TYPE_TAIL) { + tempsplit = (lfs_tag_chunk(tag) & 1); + + err = lfs_bd_read(lfs, + NULL, &lfs->rcache, lfs->cfg->block_size, + dir->pair[0], off+sizeof(tag), &temptail, 8); + if (err) { + if (err == LFS_ERR_CORRUPT) { + break; + } + return err; + } + lfs_pair_fromle32(temptail); + } else if (lfs_tag_type3(tag) == LFS_TYPE_FCRC) { + err = lfs_bd_read(lfs, + NULL, &lfs->rcache, lfs->cfg->block_size, + dir->pair[0], off+sizeof(tag), + &fcrc, sizeof(fcrc)); + if (err) { + if (err == LFS_ERR_CORRUPT) { + break; + } + return err; + } + + lfs_fcrc_fromle32(&fcrc); + hasfcrc = true; + } + + // found a match for our fetcher? + if ((fmask & tag) == (fmask & ftag)) { + LFS_ASSERT(cb != NULL); + int res = cb(data, tag, &(struct lfs_diskoff){ + dir->pair[0], off+sizeof(tag)}); + if (res < 0) { + if (res == LFS_ERR_CORRUPT) { + break; + } + return res; + } + + if (res == LFS_CMP_EQ) { + // found a match + tempbesttag = tag; + } else if ((LFS_MKTAG(0x7ff, 0x3ff, 0) & tag) == + (LFS_MKTAG(0x7ff, 0x3ff, 0) & tempbesttag)) { + // found an identical tag, but contents didn't match + // this must mean that our besttag has been overwritten + tempbesttag = -1; + } else if (res == LFS_CMP_GT && + lfs_tag_id(tag) <= lfs_tag_id(tempbesttag)) { + // found a greater match, keep track to keep things sorted + tempbesttag = tag | 0x80000000; + } + } + } + + // found no valid commits? + if (dir->off == 0) { + // try the other block? + lfs_pair_swap(dir->pair); + dir->rev = revs[(r+1)%2]; + continue; + } + + // did we end on a valid commit? we may have an erased block + dir->erased = false; + if (maybeerased && dir->off % lfs->cfg->prog_size == 0) { + #ifdef LFS_MULTIVERSION + // note versions < lfs2.1 did not have fcrc tags, if + // we're < lfs2.1 treat missing fcrc as erased data + // + // we don't strictly need to do this, but otherwise writing + // to lfs2.0 disks becomes very inefficient + if (lfs_fs_disk_version(lfs) < 0x00020001) { + dir->erased = true; + + } else + #endif + if (hasfcrc) { + // check for an fcrc matching the next prog's erased state, if + // this failed most likely a previous prog was interrupted, we + // need a new erase + uint32_t fcrc_ = 0xffffffff; + int err = lfs_bd_crc(lfs, + NULL, &lfs->rcache, lfs->cfg->block_size, + dir->pair[0], dir->off, fcrc.size, &fcrc_); + if (err && err != LFS_ERR_CORRUPT) { + return err; + } + + // found beginning of erased part? + dir->erased = (fcrc_ == fcrc.crc); + } + } + + // synthetic move + if (lfs_gstate_hasmovehere(&lfs->gdisk, dir->pair)) { + if (lfs_tag_id(lfs->gdisk.tag) == lfs_tag_id(besttag)) { + besttag |= 0x80000000; + } else if (besttag != -1 && + lfs_tag_id(lfs->gdisk.tag) < lfs_tag_id(besttag)) { + besttag -= LFS_MKTAG(0, 1, 0); + } + } + + // found tag? or found best id? + if (id) { + *id = lfs_min(lfs_tag_id(besttag), dir->count); + } + + if (lfs_tag_isvalid(besttag)) { + return besttag; + } else if (lfs_tag_id(besttag) < dir->count) { + return LFS_ERR_NOENT; + } else { + return 0; + } + } + + LFS_ERROR("Corrupted dir pair at {0x%"PRIx32", 0x%"PRIx32"}", + dir->pair[0], dir->pair[1]); + return LFS_ERR_CORRUPT; +} + +static int lfs_dir_fetch(lfs_t *lfs, + lfs_mdir_t *dir, const lfs_block_t pair[2]) { + // note, mask=-1, tag=-1 can never match a tag since this + // pattern has the invalid bit set + return (int)lfs_dir_fetchmatch(lfs, dir, pair, + (lfs_tag_t)-1, (lfs_tag_t)-1, NULL, NULL, NULL); +} + +static int lfs_dir_getgstate(lfs_t *lfs, const lfs_mdir_t *dir, + lfs_gstate_t *gstate) { + lfs_gstate_t temp; + lfs_stag_t res = lfs_dir_get(lfs, dir, LFS_MKTAG(0x7ff, 0, 0), + LFS_MKTAG(LFS_TYPE_MOVESTATE, 0, sizeof(temp)), &temp); + if (res < 0 && res != LFS_ERR_NOENT) { + return res; + } + + if (res != LFS_ERR_NOENT) { + // xor together to find resulting gstate + lfs_gstate_fromle32(&temp); + lfs_gstate_xor(gstate, &temp); + } + + return 0; +} + +static int lfs_dir_getinfo(lfs_t *lfs, lfs_mdir_t *dir, + uint16_t id, struct lfs_info *info) { + if (id == 0x3ff) { + // special case for root + strcpy(info->name, "/"); + info->type = LFS_TYPE_DIR; + return 0; + } + + lfs_stag_t tag = lfs_dir_get(lfs, dir, LFS_MKTAG(0x780, 0x3ff, 0), + LFS_MKTAG(LFS_TYPE_NAME, id, lfs->name_max+1), info->name); + if (tag < 0) { + return (int)tag; + } + + info->type = lfs_tag_type3(tag); + + struct lfs_ctz ctz; + tag = lfs_dir_get(lfs, dir, LFS_MKTAG(0x700, 0x3ff, 0), + LFS_MKTAG(LFS_TYPE_STRUCT, id, sizeof(ctz)), &ctz); + if (tag < 0) { + return (int)tag; + } + lfs_ctz_fromle32(&ctz); + + if (lfs_tag_type3(tag) == LFS_TYPE_CTZSTRUCT) { + info->size = ctz.size; + } else if (lfs_tag_type3(tag) == LFS_TYPE_INLINESTRUCT) { + info->size = lfs_tag_size(tag); + } + + return 0; +} + +struct lfs_dir_find_match { + lfs_t *lfs; + const void *name; + lfs_size_t size; +}; + +static int lfs_dir_find_match(void *data, + lfs_tag_t tag, const void *buffer) { + struct lfs_dir_find_match *name = data; + lfs_t *lfs = name->lfs; + const struct lfs_diskoff *disk = buffer; + + // compare with disk + lfs_size_t diff = lfs_min(name->size, lfs_tag_size(tag)); + int res = lfs_bd_cmp(lfs, + NULL, &lfs->rcache, diff, + disk->block, disk->off, name->name, diff); + if (res != LFS_CMP_EQ) { + return res; + } + + // only equal if our size is still the same + if (name->size != lfs_tag_size(tag)) { + return (name->size < lfs_tag_size(tag)) ? LFS_CMP_LT : LFS_CMP_GT; + } + + // found a match! + return LFS_CMP_EQ; +} + +// lfs_dir_find tries to set path and id even if file is not found +// +// returns: +// - 0 if file is found +// - LFS_ERR_NOENT if file or parent is not found +// - LFS_ERR_NOTDIR if parent is not a dir +static lfs_stag_t lfs_dir_find(lfs_t *lfs, lfs_mdir_t *dir, + const char **path, uint16_t *id) { + // we reduce path to a single name if we can find it + const char *name = *path; + + // default to root dir + lfs_stag_t tag = LFS_MKTAG(LFS_TYPE_DIR, 0x3ff, 0); + dir->tail[0] = lfs->root[0]; + dir->tail[1] = lfs->root[1]; + + // empty paths are not allowed + if (*name == '\0') { + return LFS_ERR_INVAL; + } + + while (true) { +nextname: + // skip slashes if we're a directory + if (lfs_tag_type3(tag) == LFS_TYPE_DIR) { + name += strspn(name, "/"); + } + lfs_size_t namelen = (lfs_size_t)strcspn(name, "/"); + + // skip '.' + if (namelen == 1 && memcmp(name, ".", 1) == 0) { + name += namelen; + goto nextname; + } + + // error on unmatched '..', trying to go above root? + if (namelen == 2 && memcmp(name, "..", 2) == 0) { + return LFS_ERR_INVAL; + } + + // skip if matched by '..' in name + const char *suffix = name + namelen; + lfs_size_t sufflen; + int depth = 1; + while (true) { + suffix += strspn(suffix, "/"); + sufflen = (lfs_size_t)strcspn(suffix, "/"); + if (sufflen == 0) { + break; + } + + if (sufflen == 1 && memcmp(suffix, ".", 1) == 0) { + // noop + } else if (sufflen == 2 && memcmp(suffix, "..", 2) == 0) { + depth -= 1; + if (depth == 0) { + name = suffix + sufflen; + goto nextname; + } + } else { + depth += 1; + } + + suffix += sufflen; + } + + // found path + if (*name == '\0') { + return tag; + } + + // update what we've found so far + *path = name; + + // only continue if we're a directory + if (lfs_tag_type3(tag) != LFS_TYPE_DIR) { + return LFS_ERR_NOTDIR; + } + + // grab the entry data + if (lfs_tag_id(tag) != 0x3ff) { + lfs_stag_t res = lfs_dir_get(lfs, dir, LFS_MKTAG(0x700, 0x3ff, 0), + LFS_MKTAG(LFS_TYPE_STRUCT, lfs_tag_id(tag), 8), dir->tail); + if (res < 0) { + return res; + } + lfs_pair_fromle32(dir->tail); + } + + // find entry matching name + while (true) { + tag = lfs_dir_fetchmatch(lfs, dir, dir->tail, + LFS_MKTAG(0x780, 0, 0), + LFS_MKTAG(LFS_TYPE_NAME, 0, namelen), + id, + lfs_dir_find_match, &(struct lfs_dir_find_match){ + lfs, name, namelen}); + if (tag < 0) { + return tag; + } + + if (tag) { + break; + } + + if (!dir->split) { + return LFS_ERR_NOENT; + } + } + + // to next name + name += namelen; + } +} + +// commit logic +struct lfs_commit { + lfs_block_t block; + lfs_off_t off; + lfs_tag_t ptag; + uint32_t crc; + + lfs_off_t begin; + lfs_off_t end; +}; + +#ifndef LFS_READONLY +static int lfs_dir_commitprog(lfs_t *lfs, struct lfs_commit *commit, + const void *buffer, lfs_size_t size) { + int err = lfs_bd_prog(lfs, + &lfs->pcache, &lfs->rcache, false, + commit->block, commit->off , + (const uint8_t*)buffer, size); + if (err) { + return err; + } + + commit->crc = lfs_crc(commit->crc, buffer, size); + commit->off += size; + return 0; +} +#endif + +#ifndef LFS_READONLY +static int lfs_dir_commitattr(lfs_t *lfs, struct lfs_commit *commit, + lfs_tag_t tag, const void *buffer) { + // check if we fit + lfs_size_t dsize = lfs_tag_dsize(tag); + if (commit->off + dsize > commit->end) { + return LFS_ERR_NOSPC; + } + + // write out tag + lfs_tag_t ntag = lfs_tobe32((tag & 0x7fffffff) ^ commit->ptag); + int err = lfs_dir_commitprog(lfs, commit, &ntag, sizeof(ntag)); + if (err) { + return err; + } + + if (!(tag & 0x80000000)) { + // from memory + err = lfs_dir_commitprog(lfs, commit, buffer, dsize-sizeof(tag)); + if (err) { + return err; + } + } else { + // from disk + const struct lfs_diskoff *disk = buffer; + for (lfs_off_t i = 0; i < dsize-sizeof(tag); i++) { + // rely on caching to make this efficient + uint8_t dat; + err = lfs_bd_read(lfs, + NULL, &lfs->rcache, dsize-sizeof(tag)-i, + disk->block, disk->off+i, &dat, 1); + if (err) { + return err; + } + + err = lfs_dir_commitprog(lfs, commit, &dat, 1); + if (err) { + return err; + } + } + } + + commit->ptag = tag & 0x7fffffff; + return 0; +} +#endif + +#ifndef LFS_READONLY + +static int lfs_dir_commitcrc(lfs_t *lfs, struct lfs_commit *commit) { + // align to program units + // + // this gets a bit complex as we have two types of crcs: + // - 5-word crc with fcrc to check following prog (middle of block) + // - 2-word crc with no following prog (end of block) + const lfs_off_t end = lfs_alignup( + lfs_min(commit->off + 5*sizeof(uint32_t), lfs->cfg->block_size), + lfs->cfg->prog_size); + + lfs_off_t off1 = 0; + uint32_t crc1 = 0; + + // create crc tags to fill up remainder of commit, note that + // padding is not crced, which lets fetches skip padding but + // makes committing a bit more complicated + while (commit->off < end) { + lfs_off_t noff = ( + lfs_min(end - (commit->off+sizeof(lfs_tag_t)), 0x3fe) + + (commit->off+sizeof(lfs_tag_t))); + // too large for crc tag? need padding commits + if (noff < end) { + noff = lfs_min(noff, end - 5*sizeof(uint32_t)); + } + + // space for fcrc? + uint8_t eperturb = (uint8_t)-1; + if (noff >= end && noff <= lfs->cfg->block_size - lfs->cfg->prog_size) { + // first read the leading byte, this always contains a bit + // we can perturb to avoid writes that don't change the fcrc + int err = lfs_bd_read(lfs, + NULL, &lfs->rcache, lfs->cfg->prog_size, + commit->block, noff, &eperturb, 1); + if (err && err != LFS_ERR_CORRUPT) { + return err; + } + + #ifdef LFS_MULTIVERSION + // unfortunately fcrcs break mdir fetching < lfs2.1, so only write + // these if we're a >= lfs2.1 filesystem + if (lfs_fs_disk_version(lfs) <= 0x00020000) { + // don't write fcrc + } else + #endif + { + // find the expected fcrc, don't bother avoiding a reread + // of the eperturb, it should still be in our cache + struct lfs_fcrc fcrc = { + .size = lfs->cfg->prog_size, + .crc = 0xffffffff + }; + err = lfs_bd_crc(lfs, + NULL, &lfs->rcache, lfs->cfg->prog_size, + commit->block, noff, fcrc.size, &fcrc.crc); + if (err && err != LFS_ERR_CORRUPT) { + return err; + } + + lfs_fcrc_tole32(&fcrc); + err = lfs_dir_commitattr(lfs, commit, + LFS_MKTAG(LFS_TYPE_FCRC, 0x3ff, sizeof(struct lfs_fcrc)), + &fcrc); + if (err) { + return err; + } + } + } + + // build commit crc + struct { + lfs_tag_t tag; + uint32_t crc; + } ccrc; + lfs_tag_t ntag = LFS_MKTAG( + LFS_TYPE_CCRC + (((uint8_t)~eperturb) >> 7), 0x3ff, + noff - (commit->off+sizeof(lfs_tag_t))); + ccrc.tag = lfs_tobe32(ntag ^ commit->ptag); + commit->crc = lfs_crc(commit->crc, &ccrc.tag, sizeof(lfs_tag_t)); + ccrc.crc = lfs_tole32(commit->crc); + + int err = lfs_bd_prog(lfs, + &lfs->pcache, &lfs->rcache, false, + commit->block, commit->off, &ccrc, sizeof(ccrc)); + if (err) { + return err; + } + + // keep track of non-padding checksum to verify + if (off1 == 0) { + off1 = commit->off + sizeof(lfs_tag_t); + crc1 = commit->crc; + } + + commit->off = noff; + // perturb valid bit? + commit->ptag = ntag ^ ((lfs_tag_t)(0x80 & ~eperturb) << 24); + // reset crc for next commit + commit->crc = 0xffffffff; + + // manually flush here since we don't prog the padding, this confuses + // the caching layer + if (noff >= end || noff >= lfs->pcache.off + lfs->cfg->cache_size) { + // flush buffers + int err = lfs_bd_sync(lfs, &lfs->pcache, &lfs->rcache, false); + if (err) { + return err; + } + } + } + + // successful commit, check checksums to make sure + // + // note that we don't need to check padding commits, worst + // case if they are corrupted we would have had to compact anyways + lfs_off_t off = commit->begin; + uint32_t crc = 0xffffffff; + int err = lfs_bd_crc(lfs, + NULL, &lfs->rcache, off1+sizeof(uint32_t), + commit->block, off, off1-off, &crc); + if (err) { + return err; + } + + // check non-padding commits against known crc + if (crc != crc1) { + return LFS_ERR_CORRUPT; + } + + // make sure to check crc in case we happen to pick + // up an unrelated crc (frozen block?) + err = lfs_bd_crc(lfs, + NULL, &lfs->rcache, sizeof(uint32_t), + commit->block, off1, sizeof(uint32_t), &crc); + if (err) { + return err; + } + + if (crc != 0) { + return LFS_ERR_CORRUPT; + } + + return 0; +} +#endif + +#ifndef LFS_READONLY +static int lfs_dir_alloc(lfs_t *lfs, lfs_mdir_t *dir) { + // allocate pair of dir blocks (backwards, so we write block 1 first) + for (int i = 0; i < 2; i++) { + int err = lfs_alloc(lfs, &dir->pair[(i+1)%2]); + if (err) { + return err; + } + } + + // zero for reproducibility in case initial block is unreadable + dir->rev = 0; + + // rather than clobbering one of the blocks we just pretend + // the revision may be valid + int err = lfs_bd_read(lfs, + NULL, &lfs->rcache, sizeof(dir->rev), + dir->pair[0], 0, &dir->rev, sizeof(dir->rev)); + dir->rev = lfs_fromle32(dir->rev); + if (err && err != LFS_ERR_CORRUPT) { + return err; + } + + // to make sure we don't immediately evict, align the new revision count + // to our block_cycles modulus, see lfs_dir_compact for why our modulus + // is tweaked this way + if (lfs->cfg->block_cycles > 0) { + dir->rev = lfs_alignup(dir->rev, ((lfs->cfg->block_cycles+1)|1)); + } + + // set defaults + dir->off = sizeof(dir->rev); + dir->etag = 0xffffffff; + dir->count = 0; + dir->tail[0] = LFS_BLOCK_NULL; + dir->tail[1] = LFS_BLOCK_NULL; + dir->erased = false; + dir->split = false; + + // don't write out yet, let caller take care of that + return 0; +} +#endif + +#ifndef LFS_READONLY +static int lfs_dir_drop(lfs_t *lfs, lfs_mdir_t *dir, lfs_mdir_t *tail) { + // steal state + int err = lfs_dir_getgstate(lfs, tail, &lfs->gdelta); + if (err) { + return err; + } + + // steal tail + lfs_pair_tole32(tail->tail); + err = lfs_dir_commit(lfs, dir, LFS_MKATTRS( + {LFS_MKTAG(LFS_TYPE_TAIL + tail->split, 0x3ff, 8), tail->tail})); + lfs_pair_fromle32(tail->tail); + if (err) { + return err; + } + + return 0; +} +#endif + +#ifndef LFS_READONLY +static int lfs_dir_split(lfs_t *lfs, + lfs_mdir_t *dir, const struct lfs_mattr *attrs, int attrcount, + lfs_mdir_t *source, uint16_t split, uint16_t end) { + // create tail metadata pair + lfs_mdir_t tail; + int err = lfs_dir_alloc(lfs, &tail); + if (err) { + return err; + } + + tail.split = dir->split; + tail.tail[0] = dir->tail[0]; + tail.tail[1] = dir->tail[1]; + + // note we don't care about LFS_OK_RELOCATED + int res = lfs_dir_compact(lfs, &tail, attrs, attrcount, source, split, end); + if (res < 0) { + return res; + } + + dir->tail[0] = tail.pair[0]; + dir->tail[1] = tail.pair[1]; + dir->split = true; + + // update root if needed + if (lfs_pair_cmp(dir->pair, lfs->root) == 0 && split == 0) { + lfs->root[0] = tail.pair[0]; + lfs->root[1] = tail.pair[1]; + } + + return 0; +} +#endif + +#ifndef LFS_READONLY +static int lfs_dir_commit_size(void *p, lfs_tag_t tag, const void *buffer) { + lfs_size_t *size = p; + (void)buffer; + + *size += lfs_tag_dsize(tag); + return 0; +} +#endif + +#ifndef LFS_READONLY +struct lfs_dir_commit_commit { + lfs_t *lfs; + struct lfs_commit *commit; +}; +#endif + +#ifndef LFS_READONLY +static int lfs_dir_commit_commit(void *p, lfs_tag_t tag, const void *buffer) { + struct lfs_dir_commit_commit *commit = p; + return lfs_dir_commitattr(commit->lfs, commit->commit, tag, buffer); +} +#endif + +#ifndef LFS_READONLY +static bool lfs_dir_needsrelocation(lfs_t *lfs, lfs_mdir_t *dir) { + // If our revision count == n * block_cycles, we should force a relocation, + // this is how littlefs wear-levels at the metadata-pair level. Note that we + // actually use (block_cycles+1)|1, this is to avoid two corner cases: + // 1. block_cycles = 1, which would prevent relocations from terminating + // 2. block_cycles = 2n, which, due to aliasing, would only ever relocate + // one metadata block in the pair, effectively making this useless + return (lfs->cfg->block_cycles > 0 + && ((dir->rev + 1) % ((lfs->cfg->block_cycles+1)|1) == 0)); +} +#endif + +#ifndef LFS_READONLY +static int lfs_dir_compact(lfs_t *lfs, + lfs_mdir_t *dir, const struct lfs_mattr *attrs, int attrcount, + lfs_mdir_t *source, uint16_t begin, uint16_t end) { + // save some state in case block is bad + bool relocated = false; + bool tired = lfs_dir_needsrelocation(lfs, dir); + + // increment revision count + dir->rev += 1; + + // do not proactively relocate blocks during migrations, this + // can cause a number of failure states such: clobbering the + // v1 superblock if we relocate root, and invalidating directory + // pointers if we relocate the head of a directory. On top of + // this, relocations increase the overall complexity of + // lfs_migration, which is already a delicate operation. +#ifdef LFS_MIGRATE + if (lfs->lfs1) { + tired = false; + } +#endif + + if (tired && lfs_pair_cmp(dir->pair, (const lfs_block_t[2]){0, 1}) != 0) { + // we're writing too much, time to relocate + goto relocate; + } + + // begin loop to commit compaction to blocks until a compact sticks + while (true) { + { + // setup commit state + struct lfs_commit commit = { + .block = dir->pair[1], + .off = 0, + .ptag = 0xffffffff, + .crc = 0xffffffff, + + .begin = 0, + .end = (lfs->cfg->metadata_max ? + lfs->cfg->metadata_max : lfs->cfg->block_size) - 8, + }; + + // erase block to write to + int err = lfs_bd_erase(lfs, dir->pair[1]); + if (err) { + if (err == LFS_ERR_CORRUPT) { + goto relocate; + } + return err; + } + + // write out header + dir->rev = lfs_tole32(dir->rev); + err = lfs_dir_commitprog(lfs, &commit, + &dir->rev, sizeof(dir->rev)); + dir->rev = lfs_fromle32(dir->rev); + if (err) { + if (err == LFS_ERR_CORRUPT) { + goto relocate; + } + return err; + } + + // traverse the directory, this time writing out all unique tags + err = lfs_dir_traverse(lfs, + source, 0, 0xffffffff, attrs, attrcount, + LFS_MKTAG(0x400, 0x3ff, 0), + LFS_MKTAG(LFS_TYPE_NAME, 0, 0), + begin, end, -begin, + lfs_dir_commit_commit, &(struct lfs_dir_commit_commit){ + lfs, &commit}); + if (err) { + if (err == LFS_ERR_CORRUPT) { + goto relocate; + } + return err; + } + + // commit tail, which may be new after last size check + if (!lfs_pair_isnull(dir->tail)) { + lfs_pair_tole32(dir->tail); + err = lfs_dir_commitattr(lfs, &commit, + LFS_MKTAG(LFS_TYPE_TAIL + dir->split, 0x3ff, 8), + dir->tail); + lfs_pair_fromle32(dir->tail); + if (err) { + if (err == LFS_ERR_CORRUPT) { + goto relocate; + } + return err; + } + } + + // bring over gstate? + lfs_gstate_t delta = {0}; + if (!relocated) { + lfs_gstate_xor(&delta, &lfs->gdisk); + lfs_gstate_xor(&delta, &lfs->gstate); + } + lfs_gstate_xor(&delta, &lfs->gdelta); + delta.tag &= ~LFS_MKTAG(0, 0, 0x3ff); + + err = lfs_dir_getgstate(lfs, dir, &delta); + if (err) { + return err; + } + + if (!lfs_gstate_iszero(&delta)) { + lfs_gstate_tole32(&delta); + err = lfs_dir_commitattr(lfs, &commit, + LFS_MKTAG(LFS_TYPE_MOVESTATE, 0x3ff, + sizeof(delta)), &delta); + if (err) { + if (err == LFS_ERR_CORRUPT) { + goto relocate; + } + return err; + } + } + + // complete commit with crc + err = lfs_dir_commitcrc(lfs, &commit); + if (err) { + if (err == LFS_ERR_CORRUPT) { + goto relocate; + } + return err; + } + + // successful compaction, swap dir pair to indicate most recent + LFS_ASSERT(commit.off % lfs->cfg->prog_size == 0); + lfs_pair_swap(dir->pair); + dir->count = end - begin; + dir->off = commit.off; + dir->etag = commit.ptag; + // update gstate + lfs->gdelta = (lfs_gstate_t){0}; + if (!relocated) { + lfs->gdisk = lfs->gstate; + } + } + break; + +relocate: + // commit was corrupted, drop caches and prepare to relocate block + relocated = true; + lfs_cache_drop(lfs, &lfs->pcache); + if (!tired) { + LFS_DEBUG("Bad block at 0x%"PRIx32, dir->pair[1]); + } + + // can't relocate superblock, filesystem is now frozen + if (lfs_pair_cmp(dir->pair, (const lfs_block_t[2]){0, 1}) == 0) { + LFS_WARN("Superblock 0x%"PRIx32" has become unwritable", + dir->pair[1]); + return LFS_ERR_NOSPC; + } + + // relocate half of pair + int err = lfs_alloc(lfs, &dir->pair[1]); + if (err && (err != LFS_ERR_NOSPC || !tired)) { + return err; + } + + tired = false; + continue; + } + + return relocated ? LFS_OK_RELOCATED : 0; +} +#endif + +#ifndef LFS_READONLY +static int lfs_dir_splittingcompact(lfs_t *lfs, lfs_mdir_t *dir, + const struct lfs_mattr *attrs, int attrcount, + lfs_mdir_t *source, uint16_t begin, uint16_t end) { + while (true) { + // find size of first split, we do this by halving the split until + // the metadata is guaranteed to fit + // + // Note that this isn't a true binary search, we never increase the + // split size. This may result in poorly distributed metadata but isn't + // worth the extra code size or performance hit to fix. + lfs_size_t split = begin; + while (end - split > 1) { + lfs_size_t size = 0; + int err = lfs_dir_traverse(lfs, + source, 0, 0xffffffff, attrs, attrcount, + LFS_MKTAG(0x400, 0x3ff, 0), + LFS_MKTAG(LFS_TYPE_NAME, 0, 0), + split, end, -split, + lfs_dir_commit_size, &size); + if (err) { + return err; + } + + // space is complicated, we need room for: + // + // - tail: 4+2*4 = 12 bytes + // - gstate: 4+3*4 = 16 bytes + // - move delete: 4 = 4 bytes + // - crc: 4+4 = 8 bytes + // total = 40 bytes + // + // And we cap at half a block to avoid degenerate cases with + // nearly-full metadata blocks. + // + lfs_size_t metadata_max = (lfs->cfg->metadata_max) + ? lfs->cfg->metadata_max + : lfs->cfg->block_size; + if (end - split < 0xff + && size <= lfs_min( + metadata_max - 40, + lfs_alignup( + metadata_max/2, + lfs->cfg->prog_size))) { + break; + } + + split = split + ((end - split) / 2); + } + + if (split == begin) { + // no split needed + break; + } + + // split into two metadata pairs and continue + int err = lfs_dir_split(lfs, dir, attrs, attrcount, + source, split, end); + if (err && err != LFS_ERR_NOSPC) { + return err; + } + + if (err) { + // we can't allocate a new block, try to compact with degraded + // performance + LFS_WARN("Unable to split {0x%"PRIx32", 0x%"PRIx32"}", + dir->pair[0], dir->pair[1]); + break; + } else { + end = split; + } + } + + if (lfs_dir_needsrelocation(lfs, dir) + && lfs_pair_cmp(dir->pair, (const lfs_block_t[2]){0, 1}) == 0) { + // oh no! we're writing too much to the superblock, + // should we expand? + lfs_ssize_t size = lfs_fs_size_(lfs); + if (size < 0) { + return size; + } + + // littlefs cannot reclaim expanded superblocks, so expand cautiously + // + // if our filesystem is more than ~88% full, don't expand, this is + // somewhat arbitrary + if (lfs->block_count - size > lfs->block_count/8) { + LFS_DEBUG("Expanding superblock at rev %"PRIu32, dir->rev); + int err = lfs_dir_split(lfs, dir, attrs, attrcount, + source, begin, end); + if (err && err != LFS_ERR_NOSPC) { + return err; + } + + if (err) { + // welp, we tried, if we ran out of space there's not much + // we can do, we'll error later if we've become frozen + LFS_WARN("Unable to expand superblock"); + } else { + // duplicate the superblock entry into the new superblock + end = 1; + } + } + } + + return lfs_dir_compact(lfs, dir, attrs, attrcount, source, begin, end); +} +#endif + +#ifndef LFS_READONLY +static int lfs_dir_relocatingcommit(lfs_t *lfs, lfs_mdir_t *dir, + const lfs_block_t pair[2], + const struct lfs_mattr *attrs, int attrcount, + lfs_mdir_t *pdir) { + int state = 0; + + // calculate changes to the directory + bool hasdelete = false; + for (int i = 0; i < attrcount; i++) { + if (lfs_tag_type3(attrs[i].tag) == LFS_TYPE_CREATE) { + dir->count += 1; + } else if (lfs_tag_type3(attrs[i].tag) == LFS_TYPE_DELETE) { + LFS_ASSERT(dir->count > 0); + dir->count -= 1; + hasdelete = true; + } else if (lfs_tag_type1(attrs[i].tag) == LFS_TYPE_TAIL) { + dir->tail[0] = ((lfs_block_t*)attrs[i].buffer)[0]; + dir->tail[1] = ((lfs_block_t*)attrs[i].buffer)[1]; + dir->split = (lfs_tag_chunk(attrs[i].tag) & 1); + lfs_pair_fromle32(dir->tail); + } + } + + // should we actually drop the directory block? + if (hasdelete && dir->count == 0) { + LFS_ASSERT(pdir); + int err = lfs_fs_pred(lfs, dir->pair, pdir); + if (err && err != LFS_ERR_NOENT) { + return err; + } + + if (err != LFS_ERR_NOENT && pdir->split) { + state = LFS_OK_DROPPED; + goto fixmlist; + } + } + + if (dir->erased && dir->count < 0xff) { + // try to commit + struct lfs_commit commit = { + .block = dir->pair[0], + .off = dir->off, + .ptag = dir->etag, + .crc = 0xffffffff, + + .begin = dir->off, + .end = (lfs->cfg->metadata_max ? + lfs->cfg->metadata_max : lfs->cfg->block_size) - 8, + }; + + // traverse attrs that need to be written out + lfs_pair_tole32(dir->tail); + int err = lfs_dir_traverse(lfs, + dir, dir->off, dir->etag, attrs, attrcount, + 0, 0, 0, 0, 0, + lfs_dir_commit_commit, &(struct lfs_dir_commit_commit){ + lfs, &commit}); + lfs_pair_fromle32(dir->tail); + if (err) { + if (err == LFS_ERR_NOSPC || err == LFS_ERR_CORRUPT) { + goto compact; + } + return err; + } + + // commit any global diffs if we have any + lfs_gstate_t delta = {0}; + lfs_gstate_xor(&delta, &lfs->gstate); + lfs_gstate_xor(&delta, &lfs->gdisk); + lfs_gstate_xor(&delta, &lfs->gdelta); + delta.tag &= ~LFS_MKTAG(0, 0, 0x3ff); + if (!lfs_gstate_iszero(&delta)) { + err = lfs_dir_getgstate(lfs, dir, &delta); + if (err) { + return err; + } + + lfs_gstate_tole32(&delta); + err = lfs_dir_commitattr(lfs, &commit, + LFS_MKTAG(LFS_TYPE_MOVESTATE, 0x3ff, + sizeof(delta)), &delta); + if (err) { + if (err == LFS_ERR_NOSPC || err == LFS_ERR_CORRUPT) { + goto compact; + } + return err; + } + } + + // finalize commit with the crc + err = lfs_dir_commitcrc(lfs, &commit); + if (err) { + if (err == LFS_ERR_NOSPC || err == LFS_ERR_CORRUPT) { + goto compact; + } + return err; + } + + // successful commit, update dir + LFS_ASSERT(commit.off % lfs->cfg->prog_size == 0); + dir->off = commit.off; + dir->etag = commit.ptag; + // and update gstate + lfs->gdisk = lfs->gstate; + lfs->gdelta = (lfs_gstate_t){0}; + + goto fixmlist; + } + +compact: + // fall back to compaction + lfs_cache_drop(lfs, &lfs->pcache); + + state = lfs_dir_splittingcompact(lfs, dir, attrs, attrcount, + dir, 0, dir->count); + if (state < 0) { + return state; + } + + goto fixmlist; + +fixmlist:; + // this complicated bit of logic is for fixing up any active + // metadata-pairs that we may have affected + // + // note we have to make two passes since the mdir passed to + // lfs_dir_commit could also be in this list, and even then + // we need to copy the pair so they don't get clobbered if we refetch + // our mdir. + lfs_block_t oldpair[2] = {pair[0], pair[1]}; + for (struct lfs_mlist *d = lfs->mlist; d; d = d->next) { + if (lfs_pair_cmp(d->m.pair, oldpair) == 0) { + d->m = *dir; + if (d->m.pair != pair) { + for (int i = 0; i < attrcount; i++) { + if (lfs_tag_type3(attrs[i].tag) == LFS_TYPE_DELETE && + d->id == lfs_tag_id(attrs[i].tag) && + d->type != LFS_TYPE_DIR) { + d->m.pair[0] = LFS_BLOCK_NULL; + d->m.pair[1] = LFS_BLOCK_NULL; + } else if (lfs_tag_type3(attrs[i].tag) == LFS_TYPE_DELETE && + d->id > lfs_tag_id(attrs[i].tag)) { + d->id -= 1; + if (d->type == LFS_TYPE_DIR) { + ((lfs_dir_t*)d)->pos -= 1; + } + } else if (lfs_tag_type3(attrs[i].tag) == LFS_TYPE_CREATE && + d->id >= lfs_tag_id(attrs[i].tag)) { + d->id += 1; + if (d->type == LFS_TYPE_DIR) { + ((lfs_dir_t*)d)->pos += 1; + } + } + } + } + + while (d->id >= d->m.count && d->m.split) { + // we split and id is on tail now + if (lfs_pair_cmp(d->m.tail, lfs->root) != 0) { + d->id -= d->m.count; + } + int err = lfs_dir_fetch(lfs, &d->m, d->m.tail); + if (err) { + return err; + } + } + } + } + + return state; +} +#endif + +#ifndef LFS_READONLY +static int lfs_dir_orphaningcommit(lfs_t *lfs, lfs_mdir_t *dir, + const struct lfs_mattr *attrs, int attrcount) { + // check for any inline files that aren't RAM backed and + // forcefully evict them, needed for filesystem consistency + for (lfs_file_t *f = (lfs_file_t*)lfs->mlist; f; f = f->next) { + if (dir != &f->m && lfs_pair_cmp(f->m.pair, dir->pair) == 0 && + f->type == LFS_TYPE_REG && (f->flags & LFS_F_INLINE) && + f->ctz.size > lfs->cfg->cache_size) { + int err = lfs_file_outline(lfs, f); + if (err) { + return err; + } + + err = lfs_file_flush(lfs, f); + if (err) { + return err; + } + } + } + + lfs_block_t lpair[2] = {dir->pair[0], dir->pair[1]}; + lfs_mdir_t ldir = *dir; + lfs_mdir_t pdir; + int state = lfs_dir_relocatingcommit(lfs, &ldir, dir->pair, + attrs, attrcount, &pdir); + if (state < 0) { + return state; + } + + // update if we're not in mlist, note we may have already been + // updated if we are in mlist + if (lfs_pair_cmp(dir->pair, lpair) == 0) { + *dir = ldir; + } + + // commit was successful, but may require other changes in the + // filesystem, these would normally be tail recursive, but we have + // flattened them here avoid unbounded stack usage + + // need to drop? + if (state == LFS_OK_DROPPED) { + // steal state + int err = lfs_dir_getgstate(lfs, dir, &lfs->gdelta); + if (err) { + return err; + } + + // steal tail, note that this can't create a recursive drop + lpair[0] = pdir.pair[0]; + lpair[1] = pdir.pair[1]; + lfs_pair_tole32(dir->tail); + state = lfs_dir_relocatingcommit(lfs, &pdir, lpair, LFS_MKATTRS( + {LFS_MKTAG(LFS_TYPE_TAIL + dir->split, 0x3ff, 8), + dir->tail}), + NULL); + lfs_pair_fromle32(dir->tail); + if (state < 0) { + return state; + } + + ldir = pdir; + } + + // need to relocate? + bool orphans = false; + while (state == LFS_OK_RELOCATED) { + LFS_DEBUG("Relocating {0x%"PRIx32", 0x%"PRIx32"} " + "-> {0x%"PRIx32", 0x%"PRIx32"}", + lpair[0], lpair[1], ldir.pair[0], ldir.pair[1]); + state = 0; + + // update internal root + if (lfs_pair_cmp(lpair, lfs->root) == 0) { + lfs->root[0] = ldir.pair[0]; + lfs->root[1] = ldir.pair[1]; + } + + // update internally tracked dirs + for (struct lfs_mlist *d = lfs->mlist; d; d = d->next) { + if (lfs_pair_cmp(lpair, d->m.pair) == 0) { + d->m.pair[0] = ldir.pair[0]; + d->m.pair[1] = ldir.pair[1]; + } + + if (d->type == LFS_TYPE_DIR && + lfs_pair_cmp(lpair, ((lfs_dir_t*)d)->head) == 0) { + ((lfs_dir_t*)d)->head[0] = ldir.pair[0]; + ((lfs_dir_t*)d)->head[1] = ldir.pair[1]; + } + } + + // find parent + lfs_stag_t tag = lfs_fs_parent(lfs, lpair, &pdir); + if (tag < 0 && tag != LFS_ERR_NOENT) { + return tag; + } + + bool hasparent = (tag != LFS_ERR_NOENT); + if (tag != LFS_ERR_NOENT) { + // note that if we have a parent, we must have a pred, so this will + // always create an orphan + int err = lfs_fs_preporphans(lfs, +1); + if (err) { + return err; + } + + // fix pending move in this pair? this looks like an optimization but + // is in fact _required_ since relocating may outdate the move. + uint16_t moveid = 0x3ff; + if (lfs_gstate_hasmovehere(&lfs->gstate, pdir.pair)) { + moveid = lfs_tag_id(lfs->gstate.tag); + LFS_DEBUG("Fixing move while relocating " + "{0x%"PRIx32", 0x%"PRIx32"} 0x%"PRIx16"\n", + pdir.pair[0], pdir.pair[1], moveid); + lfs_fs_prepmove(lfs, 0x3ff, NULL); + if (moveid < lfs_tag_id(tag)) { + tag -= LFS_MKTAG(0, 1, 0); + } + } + + lfs_block_t ppair[2] = {pdir.pair[0], pdir.pair[1]}; + lfs_pair_tole32(ldir.pair); + state = lfs_dir_relocatingcommit(lfs, &pdir, ppair, LFS_MKATTRS( + {LFS_MKTAG_IF(moveid != 0x3ff, + LFS_TYPE_DELETE, moveid, 0), NULL}, + {tag, ldir.pair}), + NULL); + lfs_pair_fromle32(ldir.pair); + if (state < 0) { + return state; + } + + if (state == LFS_OK_RELOCATED) { + lpair[0] = ppair[0]; + lpair[1] = ppair[1]; + ldir = pdir; + orphans = true; + continue; + } + } + + // find pred + int err = lfs_fs_pred(lfs, lpair, &pdir); + if (err && err != LFS_ERR_NOENT) { + return err; + } + LFS_ASSERT(!(hasparent && err == LFS_ERR_NOENT)); + + // if we can't find dir, it must be new + if (err != LFS_ERR_NOENT) { + if (lfs_gstate_hasorphans(&lfs->gstate)) { + // next step, clean up orphans + err = lfs_fs_preporphans(lfs, -(int8_t)hasparent); + if (err) { + return err; + } + } + + // fix pending move in this pair? this looks like an optimization + // but is in fact _required_ since relocating may outdate the move. + uint16_t moveid = 0x3ff; + if (lfs_gstate_hasmovehere(&lfs->gstate, pdir.pair)) { + moveid = lfs_tag_id(lfs->gstate.tag); + LFS_DEBUG("Fixing move while relocating " + "{0x%"PRIx32", 0x%"PRIx32"} 0x%"PRIx16"\n", + pdir.pair[0], pdir.pair[1], moveid); + lfs_fs_prepmove(lfs, 0x3ff, NULL); + } + + // replace bad pair, either we clean up desync, or no desync occured + lpair[0] = pdir.pair[0]; + lpair[1] = pdir.pair[1]; + lfs_pair_tole32(ldir.pair); + state = lfs_dir_relocatingcommit(lfs, &pdir, lpair, LFS_MKATTRS( + {LFS_MKTAG_IF(moveid != 0x3ff, + LFS_TYPE_DELETE, moveid, 0), NULL}, + {LFS_MKTAG(LFS_TYPE_TAIL + pdir.split, 0x3ff, 8), + ldir.pair}), + NULL); + lfs_pair_fromle32(ldir.pair); + if (state < 0) { + return state; + } + + ldir = pdir; + } + } + + return orphans ? LFS_OK_ORPHANED : 0; +} +#endif + +#ifndef LFS_READONLY +static int lfs_dir_commit(lfs_t *lfs, lfs_mdir_t *dir, + const struct lfs_mattr *attrs, int attrcount) { + int orphans = lfs_dir_orphaningcommit(lfs, dir, attrs, attrcount); + if (orphans < 0) { + return orphans; + } + + if (orphans) { + // make sure we've removed all orphans, this is a noop if there + // are none, but if we had nested blocks failures we may have + // created some + int err = lfs_fs_deorphan(lfs, false); + if (err) { + return err; + } + } + + return 0; +} +#endif + + +/// Top level directory operations /// +#ifndef LFS_READONLY +static int lfs_mkdir_(lfs_t *lfs, const char *path) { + // deorphan if we haven't yet, needed at most once after poweron + int err = lfs_fs_forceconsistency(lfs); + if (err) { + return err; + } + + struct lfs_mlist cwd; + cwd.next = lfs->mlist; + uint16_t id; + err = lfs_dir_find(lfs, &cwd.m, &path, &id); + if (!(err == LFS_ERR_NOENT && lfs_path_islast(path))) { + return (err < 0) ? err : LFS_ERR_EXIST; + } + + // check that name fits + lfs_size_t nlen = lfs_path_namelen(path); + if (nlen > lfs->name_max) { + return LFS_ERR_NAMETOOLONG; + } + + // build up new directory + lfs_alloc_ckpoint(lfs); + lfs_mdir_t dir; + err = lfs_dir_alloc(lfs, &dir); + if (err) { + return err; + } + + // find end of list + lfs_mdir_t pred = cwd.m; + while (pred.split) { + err = lfs_dir_fetch(lfs, &pred, pred.tail); + if (err) { + return err; + } + } + + // setup dir + lfs_pair_tole32(pred.tail); + err = lfs_dir_commit(lfs, &dir, LFS_MKATTRS( + {LFS_MKTAG(LFS_TYPE_SOFTTAIL, 0x3ff, 8), pred.tail})); + lfs_pair_fromle32(pred.tail); + if (err) { + return err; + } + + // current block not end of list? + if (cwd.m.split) { + // update tails, this creates a desync + err = lfs_fs_preporphans(lfs, +1); + if (err) { + return err; + } + + // it's possible our predecessor has to be relocated, and if + // our parent is our predecessor's predecessor, this could have + // caused our parent to go out of date, fortunately we can hook + // ourselves into littlefs to catch this + cwd.type = 0; + cwd.id = 0; + lfs->mlist = &cwd; + + lfs_pair_tole32(dir.pair); + err = lfs_dir_commit(lfs, &pred, LFS_MKATTRS( + {LFS_MKTAG(LFS_TYPE_SOFTTAIL, 0x3ff, 8), dir.pair})); + lfs_pair_fromle32(dir.pair); + if (err) { + lfs->mlist = cwd.next; + return err; + } + + lfs->mlist = cwd.next; + err = lfs_fs_preporphans(lfs, -1); + if (err) { + return err; + } + } + + // now insert into our parent block + lfs_pair_tole32(dir.pair); + err = lfs_dir_commit(lfs, &cwd.m, LFS_MKATTRS( + {LFS_MKTAG(LFS_TYPE_CREATE, id, 0), NULL}, + {LFS_MKTAG(LFS_TYPE_DIR, id, nlen), path}, + {LFS_MKTAG(LFS_TYPE_DIRSTRUCT, id, 8), dir.pair}, + {LFS_MKTAG_IF(!cwd.m.split, + LFS_TYPE_SOFTTAIL, 0x3ff, 8), dir.pair})); + lfs_pair_fromle32(dir.pair); + if (err) { + return err; + } + + return 0; +} +#endif + +static int lfs_dir_open_(lfs_t *lfs, lfs_dir_t *dir, const char *path) { + lfs_stag_t tag = lfs_dir_find(lfs, &dir->m, &path, NULL); + if (tag < 0) { + return tag; + } + + if (lfs_tag_type3(tag) != LFS_TYPE_DIR) { + return LFS_ERR_NOTDIR; + } + + lfs_block_t pair[2]; + if (lfs_tag_id(tag) == 0x3ff) { + // handle root dir separately + pair[0] = lfs->root[0]; + pair[1] = lfs->root[1]; + } else { + // get dir pair from parent + lfs_stag_t res = lfs_dir_get(lfs, &dir->m, LFS_MKTAG(0x700, 0x3ff, 0), + LFS_MKTAG(LFS_TYPE_STRUCT, lfs_tag_id(tag), 8), pair); + if (res < 0) { + return res; + } + lfs_pair_fromle32(pair); + } + + // fetch first pair + int err = lfs_dir_fetch(lfs, &dir->m, pair); + if (err) { + return err; + } + + // setup entry + dir->head[0] = dir->m.pair[0]; + dir->head[1] = dir->m.pair[1]; + dir->id = 0; + dir->pos = 0; + + // add to list of mdirs + dir->type = LFS_TYPE_DIR; + lfs_mlist_append(lfs, (struct lfs_mlist *)dir); + + return 0; +} + +static int lfs_dir_close_(lfs_t *lfs, lfs_dir_t *dir) { + // remove from list of mdirs + lfs_mlist_remove(lfs, (struct lfs_mlist *)dir); + + return 0; +} + +static int lfs_dir_read_(lfs_t *lfs, lfs_dir_t *dir, struct lfs_info *info) { + memset(info, 0, sizeof(*info)); + + // special offset for '.' and '..' + if (dir->pos == 0) { + info->type = LFS_TYPE_DIR; + strcpy(info->name, "."); + dir->pos += 1; + return true; + } else if (dir->pos == 1) { + info->type = LFS_TYPE_DIR; + strcpy(info->name, ".."); + dir->pos += 1; + return true; + } + + while (true) { + if (dir->id == dir->m.count) { + if (!dir->m.split) { + return false; + } + + int err = lfs_dir_fetch(lfs, &dir->m, dir->m.tail); + if (err) { + return err; + } + + dir->id = 0; + } + + int err = lfs_dir_getinfo(lfs, &dir->m, dir->id, info); + if (err && err != LFS_ERR_NOENT) { + return err; + } + + dir->id += 1; + if (err != LFS_ERR_NOENT) { + break; + } + } + + dir->pos += 1; + return true; +} + +static int lfs_dir_seek_(lfs_t *lfs, lfs_dir_t *dir, lfs_off_t off) { + // simply walk from head dir + int err = lfs_dir_rewind_(lfs, dir); + if (err) { + return err; + } + + // first two for ./.. + dir->pos = lfs_min(2, off); + off -= dir->pos; + + // skip superblock entry + dir->id = (off > 0 && lfs_pair_cmp(dir->head, lfs->root) == 0); + + while (off > 0) { + if (dir->id == dir->m.count) { + if (!dir->m.split) { + return LFS_ERR_INVAL; + } + + err = lfs_dir_fetch(lfs, &dir->m, dir->m.tail); + if (err) { + return err; + } + + dir->id = 0; + } + + int diff = lfs_min(dir->m.count - dir->id, off); + dir->id += diff; + dir->pos += diff; + off -= diff; + } + + return 0; +} + +static lfs_soff_t lfs_dir_tell_(lfs_t *lfs, lfs_dir_t *dir) { + (void)lfs; + return dir->pos; +} + +static int lfs_dir_rewind_(lfs_t *lfs, lfs_dir_t *dir) { + // reload the head dir + int err = lfs_dir_fetch(lfs, &dir->m, dir->head); + if (err) { + return err; + } + + dir->id = 0; + dir->pos = 0; + return 0; +} + + +/// File index list operations /// +static int lfs_ctz_index(lfs_t *lfs, lfs_off_t *off) { + lfs_off_t size = *off; + lfs_off_t b = lfs->cfg->block_size - 2*4; + lfs_off_t i = size / b; + if (i == 0) { + return 0; + } + + i = (size - 4*(lfs_popc(i-1)+2)) / b; + *off = size - b*i - 4*lfs_popc(i); + return i; +} + +static int lfs_ctz_find(lfs_t *lfs, + const lfs_cache_t *pcache, lfs_cache_t *rcache, + lfs_block_t head, lfs_size_t size, + lfs_size_t pos, lfs_block_t *block, lfs_off_t *off) { + if (size == 0) { + *block = LFS_BLOCK_NULL; + *off = 0; + return 0; + } + + lfs_off_t current = lfs_ctz_index(lfs, &(lfs_off_t){size-1}); + lfs_off_t target = lfs_ctz_index(lfs, &pos); + + while (current > target) { + lfs_size_t skip = lfs_min( + lfs_npw2(current-target+1) - 1, + lfs_ctz(current)); + + int err = lfs_bd_read(lfs, + pcache, rcache, sizeof(head), + head, 4*skip, &head, sizeof(head)); + head = lfs_fromle32(head); + if (err) { + return err; + } + + current -= 1 << skip; + } + + *block = head; + *off = pos; + return 0; +} + +#ifndef LFS_READONLY +static int lfs_ctz_extend(lfs_t *lfs, + lfs_cache_t *pcache, lfs_cache_t *rcache, + lfs_block_t head, lfs_size_t size, + lfs_block_t *block, lfs_off_t *off) { + while (true) { + // go ahead and grab a block + lfs_block_t nblock; + int err = lfs_alloc(lfs, &nblock); + if (err) { + return err; + } + + { + err = lfs_bd_erase(lfs, nblock); + if (err) { + if (err == LFS_ERR_CORRUPT) { + goto relocate; + } + return err; + } + + if (size == 0) { + *block = nblock; + *off = 0; + return 0; + } + + lfs_size_t noff = size - 1; + lfs_off_t index = lfs_ctz_index(lfs, &noff); + noff = noff + 1; + + // just copy out the last block if it is incomplete + if (noff != lfs->cfg->block_size) { + for (lfs_off_t i = 0; i < noff; i++) { + uint8_t data; + err = lfs_bd_read(lfs, + NULL, rcache, noff-i, + head, i, &data, 1); + if (err) { + return err; + } + + err = lfs_bd_prog(lfs, + pcache, rcache, true, + nblock, i, &data, 1); + if (err) { + if (err == LFS_ERR_CORRUPT) { + goto relocate; + } + return err; + } + } + + *block = nblock; + *off = noff; + return 0; + } + + // append block + index += 1; + lfs_size_t skips = lfs_ctz(index) + 1; + lfs_block_t nhead = head; + for (lfs_off_t i = 0; i < skips; i++) { + nhead = lfs_tole32(nhead); + err = lfs_bd_prog(lfs, pcache, rcache, true, + nblock, 4*i, &nhead, 4); + nhead = lfs_fromle32(nhead); + if (err) { + if (err == LFS_ERR_CORRUPT) { + goto relocate; + } + return err; + } + + if (i != skips-1) { + err = lfs_bd_read(lfs, + NULL, rcache, sizeof(nhead), + nhead, 4*i, &nhead, sizeof(nhead)); + nhead = lfs_fromle32(nhead); + if (err) { + return err; + } + } + } + + *block = nblock; + *off = 4*skips; + return 0; + } + +relocate: + LFS_DEBUG("Bad block at 0x%"PRIx32, nblock); + + // just clear cache and try a new block + lfs_cache_drop(lfs, pcache); + } +} +#endif + +static int lfs_ctz_traverse(lfs_t *lfs, + const lfs_cache_t *pcache, lfs_cache_t *rcache, + lfs_block_t head, lfs_size_t size, + int (*cb)(void*, lfs_block_t), void *data) { + if (size == 0) { + return 0; + } + + lfs_off_t index = lfs_ctz_index(lfs, &(lfs_off_t){size-1}); + + while (true) { + int err = cb(data, head); + if (err) { + return err; + } + + if (index == 0) { + return 0; + } + + lfs_block_t heads[2]; + int count = 2 - (index & 1); + err = lfs_bd_read(lfs, + pcache, rcache, count*sizeof(head), + head, 0, &heads, count*sizeof(head)); + heads[0] = lfs_fromle32(heads[0]); + heads[1] = lfs_fromle32(heads[1]); + if (err) { + return err; + } + + for (int i = 0; i < count-1; i++) { + err = cb(data, heads[i]); + if (err) { + return err; + } + } + + head = heads[count-1]; + index -= count; + } +} + + +/// Top level file operations /// +static int lfs_file_opencfg_(lfs_t *lfs, lfs_file_t *file, + const char *path, int flags, + const struct lfs_file_config *cfg) { +#ifndef LFS_READONLY + // deorphan if we haven't yet, needed at most once after poweron + if ((flags & LFS_O_WRONLY) == LFS_O_WRONLY) { + int err = lfs_fs_forceconsistency(lfs); + if (err) { + return err; + } + } +#else + LFS_ASSERT((flags & LFS_O_RDONLY) == LFS_O_RDONLY); +#endif + + // setup simple file details + int err; + file->cfg = cfg; + file->flags = flags; + file->pos = 0; + file->off = 0; + file->cache.buffer = NULL; + + // allocate entry for file if it doesn't exist + lfs_stag_t tag = lfs_dir_find(lfs, &file->m, &path, &file->id); + if (tag < 0 && !(tag == LFS_ERR_NOENT && lfs_path_islast(path))) { + err = tag; + goto cleanup; + } + + // get id, add to list of mdirs to catch update changes + file->type = LFS_TYPE_REG; + lfs_mlist_append(lfs, (struct lfs_mlist *)file); + +#ifdef LFS_READONLY + if (tag == LFS_ERR_NOENT) { + err = LFS_ERR_NOENT; + goto cleanup; +#else + if (tag == LFS_ERR_NOENT) { + if (!(flags & LFS_O_CREAT)) { + err = LFS_ERR_NOENT; + goto cleanup; + } + + // don't allow trailing slashes + if (lfs_path_isdir(path)) { + err = LFS_ERR_NOTDIR; + goto cleanup; + } + + // check that name fits + lfs_size_t nlen = lfs_path_namelen(path); + if (nlen > lfs->name_max) { + err = LFS_ERR_NAMETOOLONG; + goto cleanup; + } + + // get next slot and create entry to remember name + err = lfs_dir_commit(lfs, &file->m, LFS_MKATTRS( + {LFS_MKTAG(LFS_TYPE_CREATE, file->id, 0), NULL}, + {LFS_MKTAG(LFS_TYPE_REG, file->id, nlen), path}, + {LFS_MKTAG(LFS_TYPE_INLINESTRUCT, file->id, 0), NULL})); + + // it may happen that the file name doesn't fit in the metadata blocks, e.g., a 256 byte file name will + // not fit in a 128 byte block. + err = (err == LFS_ERR_NOSPC) ? LFS_ERR_NAMETOOLONG : err; + if (err) { + goto cleanup; + } + + tag = LFS_MKTAG(LFS_TYPE_INLINESTRUCT, 0, 0); + } else if (flags & LFS_O_EXCL) { + err = LFS_ERR_EXIST; + goto cleanup; +#endif + } else if (lfs_tag_type3(tag) != LFS_TYPE_REG) { + err = LFS_ERR_ISDIR; + goto cleanup; +#ifndef LFS_READONLY + } else if (flags & LFS_O_TRUNC) { + // truncate if requested + tag = LFS_MKTAG(LFS_TYPE_INLINESTRUCT, file->id, 0); + file->flags |= LFS_F_DIRTY; +#endif + } else { + // try to load what's on disk, if it's inlined we'll fix it later + tag = lfs_dir_get(lfs, &file->m, LFS_MKTAG(0x700, 0x3ff, 0), + LFS_MKTAG(LFS_TYPE_STRUCT, file->id, 8), &file->ctz); + if (tag < 0) { + err = tag; + goto cleanup; + } + lfs_ctz_fromle32(&file->ctz); + } + + // fetch attrs + for (unsigned i = 0; i < file->cfg->attr_count; i++) { + // if opened for read / read-write operations + if ((file->flags & LFS_O_RDONLY) == LFS_O_RDONLY) { + lfs_stag_t res = lfs_dir_get(lfs, &file->m, + LFS_MKTAG(0x7ff, 0x3ff, 0), + LFS_MKTAG(LFS_TYPE_USERATTR + file->cfg->attrs[i].type, + file->id, file->cfg->attrs[i].size), + file->cfg->attrs[i].buffer); + if (res < 0 && res != LFS_ERR_NOENT) { + err = res; + goto cleanup; + } + } + +#ifndef LFS_READONLY + // if opened for write / read-write operations + if ((file->flags & LFS_O_WRONLY) == LFS_O_WRONLY) { + if (file->cfg->attrs[i].size > lfs->attr_max) { + err = LFS_ERR_NOSPC; + goto cleanup; + } + + file->flags |= LFS_F_DIRTY; + } +#endif + } + + // allocate buffer if needed + if (file->cfg->buffer) { + file->cache.buffer = file->cfg->buffer; + } else { + file->cache.buffer = lfs_malloc(lfs->cfg->cache_size); + if (!file->cache.buffer) { + err = LFS_ERR_NOMEM; + goto cleanup; + } + } + + // zero to avoid information leak + lfs_cache_zero(lfs, &file->cache); + + if (lfs_tag_type3(tag) == LFS_TYPE_INLINESTRUCT) { + // load inline files + file->ctz.head = LFS_BLOCK_INLINE; + file->ctz.size = lfs_tag_size(tag); + file->flags |= LFS_F_INLINE; + file->cache.block = file->ctz.head; + file->cache.off = 0; + file->cache.size = lfs->cfg->cache_size; + + // don't always read (may be new/trunc file) + if (file->ctz.size > 0) { + lfs_stag_t res = lfs_dir_get(lfs, &file->m, + LFS_MKTAG(0x700, 0x3ff, 0), + LFS_MKTAG(LFS_TYPE_STRUCT, file->id, + lfs_min(file->cache.size, 0x3fe)), + file->cache.buffer); + if (res < 0) { + err = res; + goto cleanup; + } + } + } + + return 0; + +cleanup: + // clean up lingering resources +#ifndef LFS_READONLY + file->flags |= LFS_F_ERRED; +#endif + lfs_file_close_(lfs, file); + return err; +} + +#ifndef LFS_NO_MALLOC +static int lfs_file_open_(lfs_t *lfs, lfs_file_t *file, + const char *path, int flags) { + static const struct lfs_file_config defaults = {0}; + int err = lfs_file_opencfg_(lfs, file, path, flags, &defaults); + return err; +} +#endif + +static int lfs_file_close_(lfs_t *lfs, lfs_file_t *file) { + int err = 0; +#ifndef LFS_READONLY + // it's not safe to do anything if our file errored + if (!(file->flags & LFS_F_ERRED)) { + err = lfs_file_sync_(lfs, file); + } +#endif + + // remove from list of mdirs + lfs_mlist_remove(lfs, (struct lfs_mlist*)file); + + // clean up memory + if (!file->cfg->buffer) { + lfs_free(file->cache.buffer); + } + + return err; +} + + +#ifndef LFS_READONLY +static int lfs_file_relocate(lfs_t *lfs, lfs_file_t *file) { + while (true) { + // just relocate what exists into new block + lfs_block_t nblock; + int err = lfs_alloc(lfs, &nblock); + if (err) { + return err; + } + + err = lfs_bd_erase(lfs, nblock); + if (err) { + if (err == LFS_ERR_CORRUPT) { + goto relocate; + } + return err; + } + + // either read from dirty cache or disk + for (lfs_off_t i = 0; i < file->off; i++) { + uint8_t data; + if (file->flags & LFS_F_INLINE) { + err = lfs_dir_getread(lfs, &file->m, + // note we evict inline files before they can be dirty + NULL, &file->cache, file->off-i, + LFS_MKTAG(0xfff, 0x1ff, 0), + LFS_MKTAG(LFS_TYPE_INLINESTRUCT, file->id, 0), + i, &data, 1); + if (err) { + return err; + } + } else { + err = lfs_bd_read(lfs, + &file->cache, &lfs->rcache, file->off-i, + file->block, i, &data, 1); + if (err) { + return err; + } + } + + err = lfs_bd_prog(lfs, + &lfs->pcache, &lfs->rcache, true, + nblock, i, &data, 1); + if (err) { + if (err == LFS_ERR_CORRUPT) { + goto relocate; + } + return err; + } + } + + // copy over new state of file + memcpy(file->cache.buffer, lfs->pcache.buffer, lfs->cfg->cache_size); + file->cache.block = lfs->pcache.block; + file->cache.off = lfs->pcache.off; + file->cache.size = lfs->pcache.size; + lfs_cache_zero(lfs, &lfs->pcache); + + file->block = nblock; + file->flags |= LFS_F_WRITING; + return 0; + +relocate: + LFS_DEBUG("Bad block at 0x%"PRIx32, nblock); + + // just clear cache and try a new block + lfs_cache_drop(lfs, &lfs->pcache); + } +} +#endif + +#ifndef LFS_READONLY +static int lfs_file_outline(lfs_t *lfs, lfs_file_t *file) { + file->off = file->pos; + lfs_alloc_ckpoint(lfs); + int err = lfs_file_relocate(lfs, file); + if (err) { + return err; + } + + file->flags &= ~LFS_F_INLINE; + return 0; +} +#endif + +static int lfs_file_flush(lfs_t *lfs, lfs_file_t *file) { + if (file->flags & LFS_F_READING) { + if (!(file->flags & LFS_F_INLINE)) { + lfs_cache_drop(lfs, &file->cache); + } + file->flags &= ~LFS_F_READING; + } + +#ifndef LFS_READONLY + if (file->flags & LFS_F_WRITING) { + lfs_off_t pos = file->pos; + + if (!(file->flags & LFS_F_INLINE)) { + // copy over anything after current branch + lfs_file_t orig = { + .ctz.head = file->ctz.head, + .ctz.size = file->ctz.size, + .flags = LFS_O_RDONLY, + .pos = file->pos, + .cache = lfs->rcache, + }; + lfs_cache_drop(lfs, &lfs->rcache); + + while (file->pos < file->ctz.size) { + // copy over a byte at a time, leave it up to caching + // to make this efficient + uint8_t data; + lfs_ssize_t res = lfs_file_flushedread(lfs, &orig, &data, 1); + if (res < 0) { + return res; + } + + res = lfs_file_flushedwrite(lfs, file, &data, 1); + if (res < 0) { + return res; + } + + // keep our reference to the rcache in sync + if (lfs->rcache.block != LFS_BLOCK_NULL) { + lfs_cache_drop(lfs, &orig.cache); + lfs_cache_drop(lfs, &lfs->rcache); + } + } + + // write out what we have + while (true) { + int err = lfs_bd_flush(lfs, &file->cache, &lfs->rcache, true); + if (err) { + if (err == LFS_ERR_CORRUPT) { + goto relocate; + } + return err; + } + + break; + +relocate: + LFS_DEBUG("Bad block at 0x%"PRIx32, file->block); + err = lfs_file_relocate(lfs, file); + if (err) { + return err; + } + } + } else { + file->pos = lfs_max(file->pos, file->ctz.size); + } + + // actual file updates + file->ctz.head = file->block; + file->ctz.size = file->pos; + file->flags &= ~LFS_F_WRITING; + file->flags |= LFS_F_DIRTY; + + file->pos = pos; + } +#endif + + return 0; +} + +#ifndef LFS_READONLY +static int lfs_file_sync_(lfs_t *lfs, lfs_file_t *file) { + int err = lfs_file_flush(lfs, file); + if (err) { + file->flags |= LFS_F_ERRED; + return err; + } + + if ((file->flags & LFS_F_DIRTY) && + !lfs_pair_isnull(file->m.pair)) { + // before we commit metadata, we need sync the disk to make sure + // data writes don't complete after metadata writes + if (!(file->flags & LFS_F_INLINE)) { + err = lfs_bd_sync(lfs, &lfs->pcache, &lfs->rcache, false); + if (err) { + return err; + } + } + + // update dir entry + uint16_t type; + const void *buffer; + lfs_size_t size; + struct lfs_ctz ctz; + if (file->flags & LFS_F_INLINE) { + // inline the whole file + type = LFS_TYPE_INLINESTRUCT; + buffer = file->cache.buffer; + size = file->ctz.size; + } else { + // update the ctz reference + type = LFS_TYPE_CTZSTRUCT; + // copy ctz so alloc will work during a relocate + ctz = file->ctz; + lfs_ctz_tole32(&ctz); + buffer = &ctz; + size = sizeof(ctz); + } + + // commit file data and attributes + err = lfs_dir_commit(lfs, &file->m, LFS_MKATTRS( + {LFS_MKTAG(type, file->id, size), buffer}, + {LFS_MKTAG(LFS_FROM_USERATTRS, file->id, + file->cfg->attr_count), file->cfg->attrs})); + if (err) { + file->flags |= LFS_F_ERRED; + return err; + } + + file->flags &= ~LFS_F_DIRTY; + } + + // mark any other file handles as dirty + desync + for (lfs_file_t *f = (lfs_file_t*)lfs->mlist; f; f = f->next) { + if (file != f + && f->type == LFS_TYPE_REG + && lfs_pair_cmp(f->m.pair, file->m.pair) == 0 + && f->id == file->id) { + f->flags |= LFS_F_DUSTY; + } + } + + file->flags &= ~LFS_F_ERRED & ~LFS_F_DUSTY; + return 0; +} +#endif + +static lfs_ssize_t lfs_file_flushedread(lfs_t *lfs, lfs_file_t *file, + void *buffer, lfs_size_t size) { + uint8_t *data = buffer; + lfs_size_t nsize = size; + + if (file->pos >= file->ctz.size) { + // eof if past end + return 0; + } + + size = lfs_min(size, file->ctz.size - file->pos); + nsize = size; + + while (nsize > 0) { + // check if we need a new block + if (!(file->flags & LFS_F_READING) || + file->off == lfs->cfg->block_size) { + if (!(file->flags & LFS_F_INLINE)) { + int err = lfs_ctz_find(lfs, NULL, &file->cache, + file->ctz.head, file->ctz.size, + file->pos, &file->block, &file->off); + if (err) { + return err; + } + } else { + file->block = LFS_BLOCK_INLINE; + file->off = file->pos; + } + + file->flags |= LFS_F_READING; + } + + // read as much as we can in current block + lfs_size_t diff = lfs_min(nsize, lfs->cfg->block_size - file->off); + if (file->flags & LFS_F_INLINE) { + int err = lfs_dir_getread(lfs, &file->m, + NULL, &file->cache, lfs->cfg->block_size, + LFS_MKTAG(0xfff, 0x1ff, 0), + LFS_MKTAG(LFS_TYPE_INLINESTRUCT, file->id, 0), + file->off, data, diff); + if (err) { + return err; + } + } else { + int err = lfs_bd_read(lfs, + NULL, &file->cache, lfs->cfg->block_size, + file->block, file->off, data, diff); + if (err) { + return err; + } + } + + file->pos += diff; + file->off += diff; + data += diff; + nsize -= diff; + } + + return size; +} + +static lfs_ssize_t lfs_file_read_(lfs_t *lfs, lfs_file_t *file, + void *buffer, lfs_size_t size) { + LFS_ASSERT((file->flags & LFS_O_RDONLY) == LFS_O_RDONLY); + +#ifndef LFS_READONLY + if (file->flags & LFS_F_WRITING) { + // flush out any writes + int err = lfs_file_flush(lfs, file); + if (err) { + return err; + } + } +#endif + + return lfs_file_flushedread(lfs, file, buffer, size); +} + + +#ifndef LFS_READONLY +static lfs_ssize_t lfs_file_flushedwrite(lfs_t *lfs, lfs_file_t *file, + const void *buffer, lfs_size_t size) { + const uint8_t *data = buffer; + lfs_size_t nsize = size; + + if ((file->flags & LFS_F_INLINE) && + lfs_max(file->pos+nsize, file->ctz.size) > lfs->inline_max) { + // inline file doesn't fit anymore + int err = lfs_file_outline(lfs, file); + if (err) { + file->flags |= LFS_F_ERRED; + return err; + } + } + + while (nsize > 0) { + // check if we need a new block + if (!(file->flags & LFS_F_WRITING) || + file->off == lfs->cfg->block_size) { + if (!(file->flags & LFS_F_INLINE)) { + if (!(file->flags & LFS_F_WRITING) && file->pos > 0) { + // find out which block we're extending from + int err = lfs_ctz_find(lfs, NULL, &file->cache, + file->ctz.head, file->ctz.size, + file->pos-1, &file->block, &(lfs_off_t){0}); + if (err) { + file->flags |= LFS_F_ERRED; + return err; + } + + // mark cache as dirty since we may have read data into it + lfs_cache_zero(lfs, &file->cache); + } + + // extend file with new blocks + lfs_alloc_ckpoint(lfs); + int err = lfs_ctz_extend(lfs, &file->cache, &lfs->rcache, + file->block, file->pos, + &file->block, &file->off); + if (err) { + file->flags |= LFS_F_ERRED; + return err; + } + } else { + file->block = LFS_BLOCK_INLINE; + file->off = file->pos; + } + + file->flags |= LFS_F_WRITING; + } + + // program as much as we can in current block + lfs_size_t diff = lfs_min(nsize, lfs->cfg->block_size - file->off); + while (true) { + int err = lfs_bd_prog(lfs, &file->cache, &lfs->rcache, true, + file->block, file->off, data, diff); + if (err) { + if (err == LFS_ERR_CORRUPT) { + goto relocate; + } + file->flags |= LFS_F_ERRED; + return err; + } + + break; +relocate: + err = lfs_file_relocate(lfs, file); + if (err) { + file->flags |= LFS_F_ERRED; + return err; + } + } + + file->pos += diff; + file->off += diff; + data += diff; + nsize -= diff; + + lfs_alloc_ckpoint(lfs); + } + + return size; +} + +static lfs_ssize_t lfs_file_write_(lfs_t *lfs, lfs_file_t *file, + const void *buffer, lfs_size_t size) { + LFS_ASSERT((file->flags & LFS_O_WRONLY) == LFS_O_WRONLY); + + if (file->flags & LFS_F_READING) { + // drop any reads + int err = lfs_file_flush(lfs, file); + if (err) { + return err; + } + } + + if ((file->flags & LFS_O_APPEND) && file->pos < file->ctz.size) { + file->pos = file->ctz.size; + } + + if (file->pos + size > lfs->file_max) { + // Larger than file limit? + return LFS_ERR_FBIG; + } + + if (!(file->flags & LFS_F_WRITING) && file->pos > file->ctz.size) { + // fill with zeros + lfs_off_t pos = file->pos; + file->pos = file->ctz.size; + + while (file->pos < pos) { + lfs_ssize_t res = lfs_file_flushedwrite(lfs, file, &(uint8_t){0}, 1); + if (res < 0) { + return res; + } + } + } + + lfs_ssize_t nsize = lfs_file_flushedwrite(lfs, file, buffer, size); + if (nsize < 0) { + return nsize; + } + + file->flags &= ~LFS_F_ERRED & ~LFS_F_DUSTY; + return nsize; +} +#endif + +static lfs_soff_t lfs_file_seek_(lfs_t *lfs, lfs_file_t *file, + lfs_soff_t off, int whence) { + // find new pos + // + // fortunately for us, littlefs is limited to 31-bit file sizes, so we + // don't have to worry too much about integer overflow + lfs_off_t npos = file->pos; + if (whence == LFS_SEEK_SET) { + npos = off; + } else if (whence == LFS_SEEK_CUR) { + npos = file->pos + (lfs_off_t)off; + } else if (whence == LFS_SEEK_END) { + npos = (lfs_off_t)lfs_file_size_(lfs, file) + (lfs_off_t)off; + } + + if (npos > lfs->file_max) { + // file position out of range + return LFS_ERR_INVAL; + } + + if (file->pos == npos) { + // noop - position has not changed + return npos; + } + + // if we're only reading and our new offset is still in the file's cache + // we can avoid flushing and needing to reread the data + if ((file->flags & LFS_F_READING) + && file->off != lfs->cfg->block_size) { + int oindex = lfs_ctz_index(lfs, &(lfs_off_t){file->pos}); + lfs_off_t noff = npos; + int nindex = lfs_ctz_index(lfs, &noff); + if (oindex == nindex + && noff >= file->cache.off + && noff < file->cache.off + file->cache.size) { + file->pos = npos; + file->off = noff; + return npos; + } + } + + // write out everything beforehand, may be noop if rdonly + int err = lfs_file_flush(lfs, file); + if (err) { + return err; + } + + // update pos + file->pos = npos; + return npos; +} + +#ifndef LFS_READONLY +static int lfs_file_truncate_(lfs_t *lfs, lfs_file_t *file, lfs_off_t size) { + LFS_ASSERT((file->flags & LFS_O_WRONLY) == LFS_O_WRONLY); + + if (size > LFS_FILE_MAX) { + return LFS_ERR_INVAL; + } + + lfs_off_t pos = file->pos; + lfs_off_t oldsize = lfs_file_size_(lfs, file); + if (size < oldsize) { + // revert to inline file? + if (size <= lfs->inline_max) { + // flush+seek to head + lfs_soff_t res = lfs_file_seek_(lfs, file, 0, LFS_SEEK_SET); + if (res < 0) { + return (int)res; + } + + // read our data into rcache temporarily + lfs_cache_drop(lfs, &lfs->rcache); + res = lfs_file_flushedread(lfs, file, + lfs->rcache.buffer, size); + if (res < 0) { + return (int)res; + } + + file->ctz.head = LFS_BLOCK_INLINE; + file->ctz.size = size; + file->flags |= LFS_F_DIRTY | LFS_F_READING | LFS_F_INLINE; + file->cache.block = file->ctz.head; + file->cache.off = 0; + file->cache.size = lfs->cfg->cache_size; + memcpy(file->cache.buffer, lfs->rcache.buffer, size); + + } else { + // need to flush since directly changing metadata + int err = lfs_file_flush(lfs, file); + if (err) { + return err; + } + + // lookup new head in ctz skip list + err = lfs_ctz_find(lfs, NULL, &file->cache, + file->ctz.head, file->ctz.size, + size-1, &file->block, &(lfs_off_t){0}); + if (err) { + return err; + } + + // need to set pos/block/off consistently so seeking back to + // the old position does not get confused + file->pos = size; + file->ctz.head = file->block; + file->ctz.size = size; + file->flags |= LFS_F_DIRTY | LFS_F_READING; + } + } else if (size > oldsize) { + // flush+seek if not already at end + lfs_soff_t res = lfs_file_seek_(lfs, file, 0, LFS_SEEK_END); + if (res < 0) { + return (int)res; + } + + // fill with zeros + while (file->pos < size) { + res = lfs_file_write_(lfs, file, &(uint8_t){0}, 1); + if (res < 0) { + return (int)res; + } + } + } + + // restore pos + lfs_soff_t res = lfs_file_seek_(lfs, file, pos, LFS_SEEK_SET); + if (res < 0) { + return (int)res; + } + + return 0; +} +#endif + +static lfs_soff_t lfs_file_tell_(lfs_t *lfs, lfs_file_t *file) { + (void)lfs; + return file->pos; +} + +static int lfs_file_rewind_(lfs_t *lfs, lfs_file_t *file) { + lfs_soff_t res = lfs_file_seek_(lfs, file, 0, LFS_SEEK_SET); + if (res < 0) { + return (int)res; + } + + return 0; +} + +static lfs_soff_t lfs_file_size_(lfs_t *lfs, lfs_file_t *file) { + (void)lfs; + +#ifndef LFS_READONLY + if (file->flags & LFS_F_WRITING) { + return lfs_max(file->pos, file->ctz.size); + } +#endif + + return file->ctz.size; +} + + +/// General fs operations /// +static int lfs_stat_(lfs_t *lfs, const char *path, struct lfs_info *info) { + lfs_mdir_t cwd; + lfs_stag_t tag = lfs_dir_find(lfs, &cwd, &path, NULL); + if (tag < 0) { + return (int)tag; + } + + // only allow trailing slashes on dirs + if (strchr(path, '/') != NULL + && lfs_tag_type3(tag) != LFS_TYPE_DIR) { + return LFS_ERR_NOTDIR; + } + + return lfs_dir_getinfo(lfs, &cwd, lfs_tag_id(tag), info); +} + +#ifndef LFS_READONLY +static int lfs_remove_(lfs_t *lfs, const char *path) { + // deorphan if we haven't yet, needed at most once after poweron + int err = lfs_fs_forceconsistency(lfs); + if (err) { + return err; + } + + lfs_mdir_t cwd; + lfs_stag_t tag = lfs_dir_find(lfs, &cwd, &path, NULL); + if (tag < 0 || lfs_tag_id(tag) == 0x3ff) { + return (tag < 0) ? (int)tag : LFS_ERR_INVAL; + } + + struct lfs_mlist dir; + dir.next = lfs->mlist; + if (lfs_tag_type3(tag) == LFS_TYPE_DIR) { + // must be empty before removal + lfs_block_t pair[2]; + lfs_stag_t res = lfs_dir_get(lfs, &cwd, LFS_MKTAG(0x700, 0x3ff, 0), + LFS_MKTAG(LFS_TYPE_STRUCT, lfs_tag_id(tag), 8), pair); + if (res < 0) { + return (int)res; + } + lfs_pair_fromle32(pair); + + err = lfs_dir_fetch(lfs, &dir.m, pair); + if (err) { + return err; + } + + if (dir.m.count > 0 || dir.m.split) { + return LFS_ERR_NOTEMPTY; + } + + // mark fs as orphaned + err = lfs_fs_preporphans(lfs, +1); + if (err) { + return err; + } + + // I know it's crazy but yes, dir can be changed by our parent's + // commit (if predecessor is child) + dir.type = 0; + dir.id = 0; + lfs->mlist = &dir; + } + + // delete the entry + err = lfs_dir_commit(lfs, &cwd, LFS_MKATTRS( + {LFS_MKTAG(LFS_TYPE_DELETE, lfs_tag_id(tag), 0), NULL})); + if (err) { + lfs->mlist = dir.next; + return err; + } + + lfs->mlist = dir.next; + if (lfs_gstate_hasorphans(&lfs->gstate)) { + LFS_ASSERT(lfs_tag_type3(tag) == LFS_TYPE_DIR); + + // fix orphan + err = lfs_fs_preporphans(lfs, -1); + if (err) { + return err; + } + + err = lfs_fs_pred(lfs, dir.m.pair, &cwd); + if (err) { + return err; + } + + err = lfs_dir_drop(lfs, &cwd, &dir.m); + if (err) { + return err; + } + } + + return 0; +} +#endif + +#ifndef LFS_READONLY +static int lfs_rename_(lfs_t *lfs, const char *oldpath, const char *newpath) { + // deorphan if we haven't yet, needed at most once after poweron + int err = lfs_fs_forceconsistency(lfs); + if (err) { + return err; + } + + // find old entry + lfs_mdir_t oldcwd; + lfs_stag_t oldtag = lfs_dir_find(lfs, &oldcwd, &oldpath, NULL); + if (oldtag < 0 || lfs_tag_id(oldtag) == 0x3ff) { + return (oldtag < 0) ? (int)oldtag : LFS_ERR_INVAL; + } + + // find new entry + lfs_mdir_t newcwd; + uint16_t newid; + lfs_stag_t prevtag = lfs_dir_find(lfs, &newcwd, &newpath, &newid); + if ((prevtag < 0 || lfs_tag_id(prevtag) == 0x3ff) && + !(prevtag == LFS_ERR_NOENT && lfs_path_islast(newpath))) { + return (prevtag < 0) ? (int)prevtag : LFS_ERR_INVAL; + } + + // if we're in the same pair there's a few special cases... + bool samepair = (lfs_pair_cmp(oldcwd.pair, newcwd.pair) == 0); + uint16_t newoldid = lfs_tag_id(oldtag); + + struct lfs_mlist prevdir; + prevdir.next = lfs->mlist; + if (prevtag == LFS_ERR_NOENT) { + // if we're a file, don't allow trailing slashes + if (lfs_path_isdir(newpath) + && lfs_tag_type3(oldtag) != LFS_TYPE_DIR) { + return LFS_ERR_NOTDIR; + } + + // check that name fits + lfs_size_t nlen = lfs_path_namelen(newpath); + if (nlen > lfs->name_max) { + return LFS_ERR_NAMETOOLONG; + } + + // there is a small chance we are being renamed in the same + // directory/ to an id less than our old id, the global update + // to handle this is a bit messy + if (samepair && newid <= newoldid) { + newoldid += 1; + } + } else if (lfs_tag_type3(prevtag) != lfs_tag_type3(oldtag)) { + return (lfs_tag_type3(prevtag) == LFS_TYPE_DIR) + ? LFS_ERR_ISDIR + : LFS_ERR_NOTDIR; + } else if (samepair && newid == newoldid) { + // we're renaming to ourselves?? + return 0; + } else if (lfs_tag_type3(prevtag) == LFS_TYPE_DIR) { + // must be empty before removal + lfs_block_t prevpair[2]; + lfs_stag_t res = lfs_dir_get(lfs, &newcwd, LFS_MKTAG(0x700, 0x3ff, 0), + LFS_MKTAG(LFS_TYPE_STRUCT, newid, 8), prevpair); + if (res < 0) { + return (int)res; + } + lfs_pair_fromle32(prevpair); + + // must be empty before removal + err = lfs_dir_fetch(lfs, &prevdir.m, prevpair); + if (err) { + return err; + } + + if (prevdir.m.count > 0 || prevdir.m.split) { + return LFS_ERR_NOTEMPTY; + } + + // mark fs as orphaned + err = lfs_fs_preporphans(lfs, +1); + if (err) { + return err; + } + + // I know it's crazy but yes, dir can be changed by our parent's + // commit (if predecessor is child) + prevdir.type = 0; + prevdir.id = 0; + lfs->mlist = &prevdir; + } + + if (!samepair) { + lfs_fs_prepmove(lfs, newoldid, oldcwd.pair); + } + + // move over all attributes + err = lfs_dir_commit(lfs, &newcwd, LFS_MKATTRS( + {LFS_MKTAG_IF(prevtag != LFS_ERR_NOENT, + LFS_TYPE_DELETE, newid, 0), NULL}, + {LFS_MKTAG(LFS_TYPE_CREATE, newid, 0), NULL}, + {LFS_MKTAG(lfs_tag_type3(oldtag), + newid, lfs_path_namelen(newpath)), newpath}, + {LFS_MKTAG(LFS_FROM_MOVE, newid, lfs_tag_id(oldtag)), &oldcwd}, + {LFS_MKTAG_IF(samepair, + LFS_TYPE_DELETE, newoldid, 0), NULL})); + if (err) { + lfs->mlist = prevdir.next; + return err; + } + + // let commit clean up after move (if we're different! otherwise move + // logic already fixed it for us) + if (!samepair && lfs_gstate_hasmove(&lfs->gstate)) { + // prep gstate and delete move id + lfs_fs_prepmove(lfs, 0x3ff, NULL); + err = lfs_dir_commit(lfs, &oldcwd, LFS_MKATTRS( + {LFS_MKTAG(LFS_TYPE_DELETE, lfs_tag_id(oldtag), 0), NULL})); + if (err) { + lfs->mlist = prevdir.next; + return err; + } + } + + lfs->mlist = prevdir.next; + if (lfs_gstate_hasorphans(&lfs->gstate)) { + LFS_ASSERT(prevtag != LFS_ERR_NOENT + && lfs_tag_type3(prevtag) == LFS_TYPE_DIR); + + // fix orphan + err = lfs_fs_preporphans(lfs, -1); + if (err) { + return err; + } + + err = lfs_fs_pred(lfs, prevdir.m.pair, &newcwd); + if (err) { + return err; + } + + err = lfs_dir_drop(lfs, &newcwd, &prevdir.m); + if (err) { + return err; + } + } + + return 0; +} +#endif + +static lfs_ssize_t lfs_getattr_(lfs_t *lfs, const char *path, + uint8_t type, void *buffer, lfs_size_t size) { + lfs_mdir_t cwd; + lfs_stag_t tag = lfs_dir_find(lfs, &cwd, &path, NULL); + if (tag < 0) { + return tag; + } + + uint16_t id = lfs_tag_id(tag); + if (id == 0x3ff) { + // special case for root + id = 0; + int err = lfs_dir_fetch(lfs, &cwd, lfs->root); + if (err) { + return err; + } + } + + tag = lfs_dir_get(lfs, &cwd, LFS_MKTAG(0x7ff, 0x3ff, 0), + LFS_MKTAG(LFS_TYPE_USERATTR + type, + id, lfs_min(size, lfs->attr_max)), + buffer); + if (tag < 0) { + if (tag == LFS_ERR_NOENT) { + return LFS_ERR_NOATTR; + } + + return tag; + } + + return lfs_tag_size(tag); +} + +#ifndef LFS_READONLY +static int lfs_commitattr(lfs_t *lfs, const char *path, + uint8_t type, const void *buffer, lfs_size_t size) { + lfs_mdir_t cwd; + lfs_stag_t tag = lfs_dir_find(lfs, &cwd, &path, NULL); + if (tag < 0) { + return tag; + } + + uint16_t id = lfs_tag_id(tag); + if (id == 0x3ff) { + // special case for root + id = 0; + int err = lfs_dir_fetch(lfs, &cwd, lfs->root); + if (err) { + return err; + } + } + + return lfs_dir_commit(lfs, &cwd, LFS_MKATTRS( + {LFS_MKTAG(LFS_TYPE_USERATTR + type, id, size), buffer})); +} +#endif + +#ifndef LFS_READONLY +static int lfs_setattr_(lfs_t *lfs, const char *path, + uint8_t type, const void *buffer, lfs_size_t size) { + if (size > lfs->attr_max) { + return LFS_ERR_NOSPC; + } + + return lfs_commitattr(lfs, path, type, buffer, size); +} +#endif + +#ifndef LFS_READONLY +static int lfs_removeattr_(lfs_t *lfs, const char *path, uint8_t type) { + return lfs_commitattr(lfs, path, type, NULL, 0x3ff); +} +#endif + + +/// Filesystem operations /// + +// compile time checks, see lfs.h for why these limits exist +#if LFS_NAME_MAX > 1022 +#error "Invalid LFS_NAME_MAX, must be <= 1022" +#endif + +#if LFS_FILE_MAX > 2147483647 +#error "Invalid LFS_FILE_MAX, must be <= 2147483647" +#endif + +#if LFS_ATTR_MAX > 1022 +#error "Invalid LFS_ATTR_MAX, must be <= 1022" +#endif + +// common filesystem initialization +static int lfs_init(lfs_t *lfs, const struct lfs_config *cfg) { + lfs->cfg = cfg; + lfs->block_count = cfg->block_count; // May be 0 + int err = 0; + +#ifdef LFS_MULTIVERSION + // this driver only supports minor version < current minor version + LFS_ASSERT(!lfs->cfg->disk_version || ( + (0xffff & (lfs->cfg->disk_version >> 16)) + == LFS_DISK_VERSION_MAJOR + && (0xffff & (lfs->cfg->disk_version >> 0)) + <= LFS_DISK_VERSION_MINOR)); +#endif + + // check that bool is a truthy-preserving type + // + // note the most common reason for this failure is a before-c99 compiler, + // which littlefs currently does not support + LFS_ASSERT((bool)0x80000000); + + // check that the required io functions are provided + LFS_ASSERT(lfs->cfg->read != NULL); +#ifndef LFS_READONLY + LFS_ASSERT(lfs->cfg->prog != NULL); + LFS_ASSERT(lfs->cfg->erase != NULL); + LFS_ASSERT(lfs->cfg->sync != NULL); +#endif + + // validate that the lfs-cfg sizes were initiated properly before + // performing any arithmetic logics with them + LFS_ASSERT(lfs->cfg->read_size != 0); + LFS_ASSERT(lfs->cfg->prog_size != 0); + LFS_ASSERT(lfs->cfg->cache_size != 0); + + // check that block size is a multiple of cache size is a multiple + // of prog and read sizes + LFS_ASSERT(lfs->cfg->cache_size % lfs->cfg->read_size == 0); + LFS_ASSERT(lfs->cfg->cache_size % lfs->cfg->prog_size == 0); + LFS_ASSERT(lfs->cfg->block_size % lfs->cfg->cache_size == 0); + + // check that the block size is large enough to fit all ctz pointers + LFS_ASSERT(lfs->cfg->block_size >= 128); + // this is the exact calculation for all ctz pointers, if this fails + // and the simpler assert above does not, math must be broken + LFS_ASSERT(4*lfs_npw2(0xffffffff / (lfs->cfg->block_size-2*4)) + <= lfs->cfg->block_size); + + // block_cycles = 0 is no longer supported. + // + // block_cycles is the number of erase cycles before littlefs evicts + // metadata logs as a part of wear leveling. Suggested values are in the + // range of 100-1000, or set block_cycles to -1 to disable block-level + // wear-leveling. + LFS_ASSERT(lfs->cfg->block_cycles != 0); + + // check that compact_thresh makes sense + // + // metadata can't be compacted below block_size/2, and metadata can't + // exceed a block_size + LFS_ASSERT(lfs->cfg->compact_thresh == 0 + || lfs->cfg->compact_thresh >= lfs->cfg->block_size/2); + LFS_ASSERT(lfs->cfg->compact_thresh == (lfs_size_t)-1 + || lfs->cfg->compact_thresh <= lfs->cfg->block_size); + + // check that metadata_max is a multiple of read_size and prog_size, + // and a factor of the block_size + LFS_ASSERT(!lfs->cfg->metadata_max + || lfs->cfg->metadata_max % lfs->cfg->read_size == 0); + LFS_ASSERT(!lfs->cfg->metadata_max + || lfs->cfg->metadata_max % lfs->cfg->prog_size == 0); + LFS_ASSERT(!lfs->cfg->metadata_max + || lfs->cfg->block_size % lfs->cfg->metadata_max == 0); + + // setup read cache + if (lfs->cfg->read_buffer) { + lfs->rcache.buffer = lfs->cfg->read_buffer; + } else { + lfs->rcache.buffer = lfs_malloc(lfs->cfg->cache_size); + if (!lfs->rcache.buffer) { + err = LFS_ERR_NOMEM; + goto cleanup; + } + } + + // setup program cache + if (lfs->cfg->prog_buffer) { + lfs->pcache.buffer = lfs->cfg->prog_buffer; + } else { + lfs->pcache.buffer = lfs_malloc(lfs->cfg->cache_size); + if (!lfs->pcache.buffer) { + err = LFS_ERR_NOMEM; + goto cleanup; + } + } + + // zero to avoid information leaks + lfs_cache_zero(lfs, &lfs->rcache); + lfs_cache_zero(lfs, &lfs->pcache); + + // setup lookahead buffer, note mount finishes initializing this after + // we establish a decent pseudo-random seed + LFS_ASSERT(lfs->cfg->lookahead_size > 0); + if (lfs->cfg->lookahead_buffer) { + lfs->lookahead.buffer = lfs->cfg->lookahead_buffer; + } else { + lfs->lookahead.buffer = lfs_malloc(lfs->cfg->lookahead_size); + if (!lfs->lookahead.buffer) { + err = LFS_ERR_NOMEM; + goto cleanup; + } + } + + // check that the size limits are sane + LFS_ASSERT(lfs->cfg->name_max <= LFS_NAME_MAX); + lfs->name_max = lfs->cfg->name_max; + if (!lfs->name_max) { + lfs->name_max = LFS_NAME_MAX; + } + + LFS_ASSERT(lfs->cfg->file_max <= LFS_FILE_MAX); + lfs->file_max = lfs->cfg->file_max; + if (!lfs->file_max) { + lfs->file_max = LFS_FILE_MAX; + } + + LFS_ASSERT(lfs->cfg->attr_max <= LFS_ATTR_MAX); + lfs->attr_max = lfs->cfg->attr_max; + if (!lfs->attr_max) { + lfs->attr_max = LFS_ATTR_MAX; + } + + LFS_ASSERT(lfs->cfg->metadata_max <= lfs->cfg->block_size); + + LFS_ASSERT(lfs->cfg->inline_max == (lfs_size_t)-1 + || lfs->cfg->inline_max <= lfs->cfg->cache_size); + LFS_ASSERT(lfs->cfg->inline_max == (lfs_size_t)-1 + || lfs->cfg->inline_max <= lfs->attr_max); + LFS_ASSERT(lfs->cfg->inline_max == (lfs_size_t)-1 + || lfs->cfg->inline_max <= ((lfs->cfg->metadata_max) + ? lfs->cfg->metadata_max + : lfs->cfg->block_size)/8); + lfs->inline_max = lfs->cfg->inline_max; + if (lfs->inline_max == (lfs_size_t)-1) { + lfs->inline_max = 0; + } else if (lfs->inline_max == 0) { + lfs->inline_max = lfs_min( + lfs->cfg->cache_size, + lfs_min( + lfs->attr_max, + ((lfs->cfg->metadata_max) + ? lfs->cfg->metadata_max + : lfs->cfg->block_size)/8)); + } + + // setup default state + lfs->root[0] = LFS_BLOCK_NULL; + lfs->root[1] = LFS_BLOCK_NULL; + lfs->mlist = NULL; + lfs->seed = 0; + lfs->gdisk = (lfs_gstate_t){0}; + lfs->gstate = (lfs_gstate_t){0}; + lfs->gdelta = (lfs_gstate_t){0}; +#ifdef LFS_MIGRATE + lfs->lfs1 = NULL; +#endif + + return 0; + +cleanup: + lfs_deinit(lfs); + return err; +} + +static int lfs_deinit(lfs_t *lfs) { + // free allocated memory + if (!lfs->cfg->read_buffer) { + lfs_free(lfs->rcache.buffer); + } + + if (!lfs->cfg->prog_buffer) { + lfs_free(lfs->pcache.buffer); + } + + if (!lfs->cfg->lookahead_buffer) { + lfs_free(lfs->lookahead.buffer); + } + + return 0; +} + + + +#ifndef LFS_READONLY +static int lfs_format_(lfs_t *lfs, const struct lfs_config *cfg) { + int err = 0; + { + err = lfs_init(lfs, cfg); + if (err) { + return err; + } + + LFS_ASSERT(cfg->block_count != 0); + + // create free lookahead + memset(lfs->lookahead.buffer, 0, lfs->cfg->lookahead_size); + lfs->lookahead.start = 0; + lfs->lookahead.size = lfs_min(8*lfs->cfg->lookahead_size, + lfs->block_count); + lfs->lookahead.next = 0; + lfs_alloc_ckpoint(lfs); + + // create root dir + lfs_mdir_t root; + err = lfs_dir_alloc(lfs, &root); + if (err) { + goto cleanup; + } + + // write one superblock + lfs_superblock_t superblock = { + .version = lfs_fs_disk_version(lfs), + .block_size = lfs->cfg->block_size, + .block_count = lfs->block_count, + .name_max = lfs->name_max, + .file_max = lfs->file_max, + .attr_max = lfs->attr_max, + }; + + lfs_superblock_tole32(&superblock); + err = lfs_dir_commit(lfs, &root, LFS_MKATTRS( + {LFS_MKTAG(LFS_TYPE_CREATE, 0, 0), NULL}, + {LFS_MKTAG(LFS_TYPE_SUPERBLOCK, 0, 8), "littlefs"}, + {LFS_MKTAG(LFS_TYPE_INLINESTRUCT, 0, sizeof(superblock)), + &superblock})); + if (err) { + goto cleanup; + } + + // force compaction to prevent accidentally mounting any + // older version of littlefs that may live on disk + root.erased = false; + err = lfs_dir_commit(lfs, &root, NULL, 0); + if (err) { + goto cleanup; + } + + // sanity check that fetch works + err = lfs_dir_fetch(lfs, &root, (const lfs_block_t[2]){0, 1}); + if (err) { + goto cleanup; + } + } + +cleanup: + lfs_deinit(lfs); + return err; + +} +#endif + +struct lfs_tortoise_t { + lfs_block_t pair[2]; + lfs_size_t i; + lfs_size_t period; +}; + +static int lfs_tortoise_detectcycles( + const lfs_mdir_t *dir, struct lfs_tortoise_t *tortoise) { + // detect cycles with Brent's algorithm + if (lfs_pair_issync(dir->tail, tortoise->pair)) { + LFS_WARN("Cycle detected in tail list"); + return LFS_ERR_CORRUPT; + } + if (tortoise->i == tortoise->period) { + tortoise->pair[0] = dir->tail[0]; + tortoise->pair[1] = dir->tail[1]; + tortoise->i = 0; + tortoise->period *= 2; + } + tortoise->i += 1; + + return LFS_ERR_OK; +} + +static int lfs_mount_(lfs_t *lfs, const struct lfs_config *cfg) { + int err = lfs_init(lfs, cfg); + if (err) { + return err; + } + + // scan directory blocks for superblock and any global updates + lfs_mdir_t dir = {.tail = {0, 1}}; + struct lfs_tortoise_t tortoise = { + .pair = {LFS_BLOCK_NULL, LFS_BLOCK_NULL}, + .i = 1, + .period = 1, + }; + while (!lfs_pair_isnull(dir.tail)) { + err = lfs_tortoise_detectcycles(&dir, &tortoise); + if (err < 0) { + goto cleanup; + } + + // fetch next block in tail list + lfs_stag_t tag = lfs_dir_fetchmatch(lfs, &dir, dir.tail, + LFS_MKTAG(0x7ff, 0x3ff, 0), + LFS_MKTAG(LFS_TYPE_SUPERBLOCK, 0, 8), + NULL, + lfs_dir_find_match, &(struct lfs_dir_find_match){ + lfs, "littlefs", 8}); + if (tag < 0) { + err = tag; + goto cleanup; + } + + // has superblock? + if (tag && !lfs_tag_isdelete(tag)) { + // update root + lfs->root[0] = dir.pair[0]; + lfs->root[1] = dir.pair[1]; + + // grab superblock + lfs_superblock_t superblock; + tag = lfs_dir_get(lfs, &dir, LFS_MKTAG(0x7ff, 0x3ff, 0), + LFS_MKTAG(LFS_TYPE_INLINESTRUCT, 0, sizeof(superblock)), + &superblock); + if (tag < 0) { + err = tag; + goto cleanup; + } + lfs_superblock_fromle32(&superblock); + + // check version + uint16_t major_version = (0xffff & (superblock.version >> 16)); + uint16_t minor_version = (0xffff & (superblock.version >> 0)); + if (major_version != lfs_fs_disk_version_major(lfs) + || minor_version > lfs_fs_disk_version_minor(lfs)) { + LFS_ERROR("Invalid version " + "v%"PRIu16".%"PRIu16" != v%"PRIu16".%"PRIu16, + major_version, + minor_version, + lfs_fs_disk_version_major(lfs), + lfs_fs_disk_version_minor(lfs)); + err = LFS_ERR_INVAL; + goto cleanup; + } + + // found older minor version? set an in-device only bit in the + // gstate so we know we need to rewrite the superblock before + // the first write + bool needssuperblock = false; + if (minor_version < lfs_fs_disk_version_minor(lfs)) { + LFS_DEBUG("Found older minor version " + "v%"PRIu16".%"PRIu16" < v%"PRIu16".%"PRIu16, + major_version, + minor_version, + lfs_fs_disk_version_major(lfs), + lfs_fs_disk_version_minor(lfs)); + needssuperblock = true; + } + // note this bit is reserved on disk, so fetching more gstate + // will not interfere here + lfs_fs_prepsuperblock(lfs, needssuperblock); + + // check superblock configuration + if (superblock.name_max) { + if (superblock.name_max > lfs->name_max) { + LFS_ERROR("Unsupported name_max (%"PRIu32" > %"PRIu32")", + superblock.name_max, lfs->name_max); + err = LFS_ERR_INVAL; + goto cleanup; + } + + lfs->name_max = superblock.name_max; + } + + if (superblock.file_max) { + if (superblock.file_max > lfs->file_max) { + LFS_ERROR("Unsupported file_max (%"PRIu32" > %"PRIu32")", + superblock.file_max, lfs->file_max); + err = LFS_ERR_INVAL; + goto cleanup; + } + + lfs->file_max = superblock.file_max; + } + + if (superblock.attr_max) { + if (superblock.attr_max > lfs->attr_max) { + LFS_ERROR("Unsupported attr_max (%"PRIu32" > %"PRIu32")", + superblock.attr_max, lfs->attr_max); + err = LFS_ERR_INVAL; + goto cleanup; + } + + lfs->attr_max = superblock.attr_max; + + // we also need to update inline_max in case attr_max changed + lfs->inline_max = lfs_min(lfs->inline_max, lfs->attr_max); + } + + // this is where we get the block_count from disk if block_count=0 + if (lfs->cfg->block_count + && superblock.block_count != lfs->cfg->block_count) { + LFS_ERROR("Invalid block count (%"PRIu32" != %"PRIu32")", + superblock.block_count, lfs->cfg->block_count); + err = LFS_ERR_INVAL; + goto cleanup; + } + + lfs->block_count = superblock.block_count; + + if (superblock.block_size != lfs->cfg->block_size) { + LFS_ERROR("Invalid block size (%"PRIu32" != %"PRIu32")", + superblock.block_size, lfs->cfg->block_size); + err = LFS_ERR_INVAL; + goto cleanup; + } + } + + // has gstate? + err = lfs_dir_getgstate(lfs, &dir, &lfs->gstate); + if (err) { + goto cleanup; + } + } + + // update littlefs with gstate + if (!lfs_gstate_iszero(&lfs->gstate)) { + LFS_DEBUG("Found pending gstate 0x%08"PRIx32"%08"PRIx32"%08"PRIx32, + lfs->gstate.tag, + lfs->gstate.pair[0], + lfs->gstate.pair[1]); + } + lfs->gstate.tag += !lfs_tag_isvalid(lfs->gstate.tag); + lfs->gdisk = lfs->gstate; + + // setup free lookahead, to distribute allocations uniformly across + // boots, we start the allocator at a random location + lfs->lookahead.start = lfs->seed % lfs->block_count; + lfs_alloc_drop(lfs); + + return 0; + +cleanup: + lfs_unmount_(lfs); + return err; +} + +static int lfs_unmount_(lfs_t *lfs) { + return lfs_deinit(lfs); +} + + +/// Filesystem filesystem operations /// +static int lfs_fs_stat_(lfs_t *lfs, struct lfs_fsinfo *fsinfo) { + // if the superblock is up-to-date, we must be on the most recent + // minor version of littlefs + if (!lfs_gstate_needssuperblock(&lfs->gstate)) { + fsinfo->disk_version = lfs_fs_disk_version(lfs); + + // otherwise we need to read the minor version on disk + } else { + // fetch the superblock + lfs_mdir_t dir; + int err = lfs_dir_fetch(lfs, &dir, lfs->root); + if (err) { + return err; + } + + lfs_superblock_t superblock; + lfs_stag_t tag = lfs_dir_get(lfs, &dir, LFS_MKTAG(0x7ff, 0x3ff, 0), + LFS_MKTAG(LFS_TYPE_INLINESTRUCT, 0, sizeof(superblock)), + &superblock); + if (tag < 0) { + return tag; + } + lfs_superblock_fromle32(&superblock); + + // read the on-disk version + fsinfo->disk_version = superblock.version; + } + + // filesystem geometry + fsinfo->block_size = lfs->cfg->block_size; + fsinfo->block_count = lfs->block_count; + + // other on-disk configuration, we cache all of these for internal use + fsinfo->name_max = lfs->name_max; + fsinfo->file_max = lfs->file_max; + fsinfo->attr_max = lfs->attr_max; + + return 0; +} + +int lfs_fs_traverse_(lfs_t *lfs, + int (*cb)(void *data, lfs_block_t block), void *data, + bool includeorphans) { + // iterate over metadata pairs + lfs_mdir_t dir = {.tail = {0, 1}}; + +#ifdef LFS_MIGRATE + // also consider v1 blocks during migration + if (lfs->lfs1) { + int err = lfs1_traverse(lfs, cb, data); + if (err) { + return err; + } + + dir.tail[0] = lfs->root[0]; + dir.tail[1] = lfs->root[1]; + } +#endif + + struct lfs_tortoise_t tortoise = { + .pair = {LFS_BLOCK_NULL, LFS_BLOCK_NULL}, + .i = 1, + .period = 1, + }; + int err = LFS_ERR_OK; + while (!lfs_pair_isnull(dir.tail)) { + err = lfs_tortoise_detectcycles(&dir, &tortoise); + if (err < 0) { + return LFS_ERR_CORRUPT; + } + + for (int i = 0; i < 2; i++) { + int err = cb(data, dir.tail[i]); + if (err) { + return err; + } + } + + // iterate through ids in directory + int err = lfs_dir_fetch(lfs, &dir, dir.tail); + if (err) { + return err; + } + + for (uint16_t id = 0; id < dir.count; id++) { + struct lfs_ctz ctz; + lfs_stag_t tag = lfs_dir_get(lfs, &dir, LFS_MKTAG(0x700, 0x3ff, 0), + LFS_MKTAG(LFS_TYPE_STRUCT, id, sizeof(ctz)), &ctz); + if (tag < 0) { + if (tag == LFS_ERR_NOENT) { + continue; + } + return tag; + } + lfs_ctz_fromle32(&ctz); + + if (lfs_tag_type3(tag) == LFS_TYPE_CTZSTRUCT) { + err = lfs_ctz_traverse(lfs, NULL, &lfs->rcache, + ctz.head, ctz.size, cb, data); + if (err) { + return err; + } + } else if (includeorphans && + lfs_tag_type3(tag) == LFS_TYPE_DIRSTRUCT) { + for (int i = 0; i < 2; i++) { + err = cb(data, (&ctz.head)[i]); + if (err) { + return err; + } + } + } + } + } + +#ifndef LFS_READONLY + // iterate over any open files + for (lfs_file_t *f = (lfs_file_t*)lfs->mlist; f; f = f->next) { + if (f->type != LFS_TYPE_REG) { + continue; + } + + if (((f->flags & LFS_F_DIRTY) || (f->flags & LFS_F_DUSTY)) + && !(f->flags & LFS_F_INLINE)) { + int err = lfs_ctz_traverse(lfs, &f->cache, &lfs->rcache, + f->ctz.head, f->ctz.size, cb, data); + if (err) { + return err; + } + } + + if ((f->flags & LFS_F_WRITING) && !(f->flags & LFS_F_INLINE)) { + int err = lfs_ctz_traverse(lfs, &f->cache, &lfs->rcache, + f->block, f->pos, cb, data); + if (err) { + return err; + } + } + } +#endif + + return 0; +} + +#ifndef LFS_READONLY +static int lfs_fs_pred(lfs_t *lfs, + const lfs_block_t pair[2], lfs_mdir_t *pdir) { + // iterate over all directory directory entries + pdir->tail[0] = 0; + pdir->tail[1] = 1; + struct lfs_tortoise_t tortoise = { + .pair = {LFS_BLOCK_NULL, LFS_BLOCK_NULL}, + .i = 1, + .period = 1, + }; + int err = LFS_ERR_OK; + while (!lfs_pair_isnull(pdir->tail)) { + err = lfs_tortoise_detectcycles(pdir, &tortoise); + if (err < 0) { + return LFS_ERR_CORRUPT; + } + + if (lfs_pair_cmp(pdir->tail, pair) == 0) { + return 0; + } + + int err = lfs_dir_fetch(lfs, pdir, pdir->tail); + if (err) { + return err; + } + } + + return LFS_ERR_NOENT; +} +#endif + +#ifndef LFS_READONLY +struct lfs_fs_parent_match { + lfs_t *lfs; + const lfs_block_t pair[2]; +}; +#endif + +#ifndef LFS_READONLY +static int lfs_fs_parent_match(void *data, + lfs_tag_t tag, const void *buffer) { + struct lfs_fs_parent_match *find = data; + lfs_t *lfs = find->lfs; + const struct lfs_diskoff *disk = buffer; + (void)tag; + + lfs_block_t child[2]; + int err = lfs_bd_read(lfs, + &lfs->pcache, &lfs->rcache, lfs->cfg->block_size, + disk->block, disk->off, &child, sizeof(child)); + if (err) { + return err; + } + + lfs_pair_fromle32(child); + return (lfs_pair_cmp(child, find->pair) == 0) ? LFS_CMP_EQ : LFS_CMP_LT; +} +#endif + +#ifndef LFS_READONLY +static lfs_stag_t lfs_fs_parent(lfs_t *lfs, const lfs_block_t pair[2], + lfs_mdir_t *parent) { + // use fetchmatch with callback to find pairs + parent->tail[0] = 0; + parent->tail[1] = 1; + struct lfs_tortoise_t tortoise = { + .pair = {LFS_BLOCK_NULL, LFS_BLOCK_NULL}, + .i = 1, + .period = 1, + }; + int err = LFS_ERR_OK; + while (!lfs_pair_isnull(parent->tail)) { + err = lfs_tortoise_detectcycles(parent, &tortoise); + if (err < 0) { + return err; + } + + lfs_stag_t tag = lfs_dir_fetchmatch(lfs, parent, parent->tail, + LFS_MKTAG(0x7ff, 0, 0x3ff), + LFS_MKTAG(LFS_TYPE_DIRSTRUCT, 0, 8), + NULL, + lfs_fs_parent_match, &(struct lfs_fs_parent_match){ + lfs, {pair[0], pair[1]}}); + if (tag && tag != LFS_ERR_NOENT) { + return tag; + } + } + + return LFS_ERR_NOENT; +} +#endif + +static void lfs_fs_prepsuperblock(lfs_t *lfs, bool needssuperblock) { + lfs->gstate.tag = (lfs->gstate.tag & ~LFS_MKTAG(0, 0, 0x200)) + | (uint32_t)needssuperblock << 9; +} + +#ifndef LFS_READONLY +static int lfs_fs_preporphans(lfs_t *lfs, int8_t orphans) { + LFS_ASSERT(lfs_tag_size(lfs->gstate.tag) > 0x000 || orphans >= 0); + LFS_ASSERT(lfs_tag_size(lfs->gstate.tag) < 0x1ff || orphans <= 0); + lfs->gstate.tag += orphans; + lfs->gstate.tag = ((lfs->gstate.tag & ~LFS_MKTAG(0x800, 0, 0)) | + ((uint32_t)lfs_gstate_hasorphans(&lfs->gstate) << 31)); + + return 0; +} +#endif + +#ifndef LFS_READONLY +static void lfs_fs_prepmove(lfs_t *lfs, + uint16_t id, const lfs_block_t pair[2]) { + lfs->gstate.tag = ((lfs->gstate.tag & ~LFS_MKTAG(0x7ff, 0x3ff, 0)) | + ((id != 0x3ff) ? LFS_MKTAG(LFS_TYPE_DELETE, id, 0) : 0)); + lfs->gstate.pair[0] = (id != 0x3ff) ? pair[0] : 0; + lfs->gstate.pair[1] = (id != 0x3ff) ? pair[1] : 0; +} +#endif + +#ifndef LFS_READONLY +static int lfs_fs_desuperblock(lfs_t *lfs) { + if (!lfs_gstate_needssuperblock(&lfs->gstate)) { + return 0; + } + + LFS_DEBUG("Rewriting superblock {0x%"PRIx32", 0x%"PRIx32"}", + lfs->root[0], + lfs->root[1]); + + lfs_mdir_t root; + int err = lfs_dir_fetch(lfs, &root, lfs->root); + if (err) { + return err; + } + + // write a new superblock + lfs_superblock_t superblock = { + .version = lfs_fs_disk_version(lfs), + .block_size = lfs->cfg->block_size, + .block_count = lfs->block_count, + .name_max = lfs->name_max, + .file_max = lfs->file_max, + .attr_max = lfs->attr_max, + }; + + lfs_superblock_tole32(&superblock); + err = lfs_dir_commit(lfs, &root, LFS_MKATTRS( + {LFS_MKTAG(LFS_TYPE_INLINESTRUCT, 0, sizeof(superblock)), + &superblock})); + if (err) { + return err; + } + + lfs_fs_prepsuperblock(lfs, false); + return 0; +} +#endif + +#ifndef LFS_READONLY +static int lfs_fs_demove(lfs_t *lfs) { + if (!lfs_gstate_hasmove(&lfs->gdisk)) { + return 0; + } + + // Fix bad moves + LFS_DEBUG("Fixing move {0x%"PRIx32", 0x%"PRIx32"} 0x%"PRIx16, + lfs->gdisk.pair[0], + lfs->gdisk.pair[1], + lfs_tag_id(lfs->gdisk.tag)); + + // no other gstate is supported at this time, so if we found something else + // something most likely went wrong in gstate calculation + LFS_ASSERT(lfs_tag_type3(lfs->gdisk.tag) == LFS_TYPE_DELETE); + + // fetch and delete the moved entry + lfs_mdir_t movedir; + int err = lfs_dir_fetch(lfs, &movedir, lfs->gdisk.pair); + if (err) { + return err; + } + + // prep gstate and delete move id + uint16_t moveid = lfs_tag_id(lfs->gdisk.tag); + lfs_fs_prepmove(lfs, 0x3ff, NULL); + err = lfs_dir_commit(lfs, &movedir, LFS_MKATTRS( + {LFS_MKTAG(LFS_TYPE_DELETE, moveid, 0), NULL})); + if (err) { + return err; + } + + return 0; +} +#endif + +#ifndef LFS_READONLY +static int lfs_fs_deorphan(lfs_t *lfs, bool powerloss) { + if (!lfs_gstate_hasorphans(&lfs->gstate)) { + return 0; + } + + // Check for orphans in two separate passes: + // - 1 for half-orphans (relocations) + // - 2 for full-orphans (removes/renames) + // + // Two separate passes are needed as half-orphans can contain outdated + // references to full-orphans, effectively hiding them from the deorphan + // search. + // + int pass = 0; + while (pass < 2) { + // Fix any orphans + lfs_mdir_t pdir = {.split = true, .tail = {0, 1}}; + lfs_mdir_t dir; + bool moreorphans = false; + + // iterate over all directory directory entries + while (!lfs_pair_isnull(pdir.tail)) { + int err = lfs_dir_fetch(lfs, &dir, pdir.tail); + if (err) { + return err; + } + + // check head blocks for orphans + if (!pdir.split) { + // check if we have a parent + lfs_mdir_t parent; + lfs_stag_t tag = lfs_fs_parent(lfs, pdir.tail, &parent); + if (tag < 0 && tag != LFS_ERR_NOENT) { + return tag; + } + + if (pass == 0 && tag != LFS_ERR_NOENT) { + lfs_block_t pair[2]; + lfs_stag_t state = lfs_dir_get(lfs, &parent, + LFS_MKTAG(0x7ff, 0x3ff, 0), tag, pair); + if (state < 0) { + return state; + } + lfs_pair_fromle32(pair); + + if (!lfs_pair_issync(pair, pdir.tail)) { + // we have desynced + LFS_DEBUG("Fixing half-orphan " + "{0x%"PRIx32", 0x%"PRIx32"} " + "-> {0x%"PRIx32", 0x%"PRIx32"}", + pdir.tail[0], pdir.tail[1], pair[0], pair[1]); + + // fix pending move in this pair? this looks like an + // optimization but is in fact _required_ since + // relocating may outdate the move. + uint16_t moveid = 0x3ff; + if (lfs_gstate_hasmovehere(&lfs->gstate, pdir.pair)) { + moveid = lfs_tag_id(lfs->gstate.tag); + LFS_DEBUG("Fixing move while fixing orphans " + "{0x%"PRIx32", 0x%"PRIx32"} 0x%"PRIx16"\n", + pdir.pair[0], pdir.pair[1], moveid); + lfs_fs_prepmove(lfs, 0x3ff, NULL); + } + + lfs_pair_tole32(pair); + state = lfs_dir_orphaningcommit(lfs, &pdir, LFS_MKATTRS( + {LFS_MKTAG_IF(moveid != 0x3ff, + LFS_TYPE_DELETE, moveid, 0), NULL}, + {LFS_MKTAG(LFS_TYPE_SOFTTAIL, 0x3ff, 8), + pair})); + lfs_pair_fromle32(pair); + if (state < 0) { + return state; + } + + // did our commit create more orphans? + if (state == LFS_OK_ORPHANED) { + moreorphans = true; + } + + // refetch tail + continue; + } + } + + // note we only check for full orphans if we may have had a + // power-loss, otherwise orphans are created intentionally + // during operations such as lfs_mkdir + if (pass == 1 && tag == LFS_ERR_NOENT && powerloss) { + // we are an orphan + LFS_DEBUG("Fixing orphan {0x%"PRIx32", 0x%"PRIx32"}", + pdir.tail[0], pdir.tail[1]); + + // steal state + err = lfs_dir_getgstate(lfs, &dir, &lfs->gdelta); + if (err) { + return err; + } + + // steal tail + lfs_pair_tole32(dir.tail); + int state = lfs_dir_orphaningcommit(lfs, &pdir, LFS_MKATTRS( + {LFS_MKTAG(LFS_TYPE_TAIL + dir.split, 0x3ff, 8), + dir.tail})); + lfs_pair_fromle32(dir.tail); + if (state < 0) { + return state; + } + + // did our commit create more orphans? + if (state == LFS_OK_ORPHANED) { + moreorphans = true; + } + + // refetch tail + continue; + } + } + + pdir = dir; + } + + pass = moreorphans ? 0 : pass+1; + } + + // mark orphans as fixed + return lfs_fs_preporphans(lfs, -lfs_gstate_getorphans(&lfs->gstate)); +} +#endif + +#ifndef LFS_READONLY +static int lfs_fs_forceconsistency(lfs_t *lfs) { + int err = lfs_fs_desuperblock(lfs); + if (err) { + return err; + } + + err = lfs_fs_demove(lfs); + if (err) { + return err; + } + + err = lfs_fs_deorphan(lfs, true); + if (err) { + return err; + } + + return 0; +} +#endif + +#ifndef LFS_READONLY +static int lfs_fs_mkconsistent_(lfs_t *lfs) { + // lfs_fs_forceconsistency does most of the work here + int err = lfs_fs_forceconsistency(lfs); + if (err) { + return err; + } + + // do we have any pending gstate? + lfs_gstate_t delta = {0}; + lfs_gstate_xor(&delta, &lfs->gdisk); + lfs_gstate_xor(&delta, &lfs->gstate); + if (!lfs_gstate_iszero(&delta)) { + // lfs_dir_commit will implicitly write out any pending gstate + lfs_mdir_t root; + err = lfs_dir_fetch(lfs, &root, lfs->root); + if (err) { + return err; + } + + err = lfs_dir_commit(lfs, &root, NULL, 0); + if (err) { + return err; + } + } + + return 0; +} +#endif + +static int lfs_fs_size_count(void *p, lfs_block_t block) { + (void)block; + lfs_size_t *size = p; + *size += 1; + return 0; +} + +static lfs_ssize_t lfs_fs_size_(lfs_t *lfs) { + lfs_size_t size = 0; + int err = lfs_fs_traverse_(lfs, lfs_fs_size_count, &size, false); + if (err) { + return err; + } + + return size; +} + +// explicit garbage collection +#ifndef LFS_READONLY +static int lfs_fs_gc_(lfs_t *lfs) { + // force consistency, even if we're not necessarily going to write, + // because this function is supposed to take care of janitorial work + // isn't it? + int err = lfs_fs_forceconsistency(lfs); + if (err) { + return err; + } + + // try to compact metadata pairs, note we can't really accomplish + // anything if compact_thresh doesn't at least leave a prog_size + // available + if (lfs->cfg->compact_thresh + < lfs->cfg->block_size - lfs->cfg->prog_size) { + // iterate over all mdirs + lfs_mdir_t mdir = {.tail = {0, 1}}; + while (!lfs_pair_isnull(mdir.tail)) { + err = lfs_dir_fetch(lfs, &mdir, mdir.tail); + if (err) { + return err; + } + + // not erased? exceeds our compaction threshold? + if (!mdir.erased || ((lfs->cfg->compact_thresh == 0) + ? mdir.off > lfs->cfg->block_size - lfs->cfg->block_size/8 + : mdir.off > lfs->cfg->compact_thresh)) { + // the easiest way to trigger a compaction is to mark + // the mdir as unerased and add an empty commit + mdir.erased = false; + err = lfs_dir_commit(lfs, &mdir, NULL, 0); + if (err) { + return err; + } + } + } + } + + // try to populate the lookahead buffer, unless it's already full + if (lfs->lookahead.size < lfs_min( + 8 * lfs->cfg->lookahead_size, + lfs->block_count)) { + err = lfs_alloc_scan(lfs); + if (err) { + return err; + } + } + + return 0; +} +#endif + +#ifndef LFS_READONLY +#ifdef LFS_SHRINKNONRELOCATING +static int lfs_shrink_checkblock(void *data, lfs_block_t block) { + lfs_size_t threshold = *((lfs_size_t*)data); + if (block >= threshold) { + return LFS_ERR_NOTEMPTY; + } + return 0; +} +#endif + +static int lfs_fs_grow_(lfs_t *lfs, lfs_size_t block_count) { + int err; + + if (block_count == lfs->block_count) { + return 0; + } + + +#ifndef LFS_SHRINKNONRELOCATING + // shrinking is not supported + LFS_ASSERT(block_count >= lfs->block_count); +#endif +#ifdef LFS_SHRINKNONRELOCATING + if (block_count < lfs->block_count) { + err = lfs_fs_traverse_(lfs, lfs_shrink_checkblock, &block_count, true); + if (err) { + return err; + } + } +#endif + + lfs->block_count = block_count; + + // fetch the root + lfs_mdir_t root; + err = lfs_dir_fetch(lfs, &root, lfs->root); + if (err) { + return err; + } + + // update the superblock + lfs_superblock_t superblock; + lfs_stag_t tag = lfs_dir_get(lfs, &root, LFS_MKTAG(0x7ff, 0x3ff, 0), + LFS_MKTAG(LFS_TYPE_INLINESTRUCT, 0, sizeof(superblock)), + &superblock); + if (tag < 0) { + return tag; + } + lfs_superblock_fromle32(&superblock); + + superblock.block_count = lfs->block_count; + + lfs_superblock_tole32(&superblock); + err = lfs_dir_commit(lfs, &root, LFS_MKATTRS( + {tag, &superblock})); + if (err) { + return err; + } + return 0; +} +#endif + +#ifdef LFS_MIGRATE +////// Migration from littelfs v1 below this ////// + +/// Version info /// + +// Software library version +// Major (top-nibble), incremented on backwards incompatible changes +// Minor (bottom-nibble), incremented on feature additions +#define LFS1_VERSION 0x00010007 +#define LFS1_VERSION_MAJOR (0xffff & (LFS1_VERSION >> 16)) +#define LFS1_VERSION_MINOR (0xffff & (LFS1_VERSION >> 0)) + +// Version of On-disk data structures +// Major (top-nibble), incremented on backwards incompatible changes +// Minor (bottom-nibble), incremented on feature additions +#define LFS1_DISK_VERSION 0x00010001 +#define LFS1_DISK_VERSION_MAJOR (0xffff & (LFS1_DISK_VERSION >> 16)) +#define LFS1_DISK_VERSION_MINOR (0xffff & (LFS1_DISK_VERSION >> 0)) + + +/// v1 Definitions /// + +// File types +enum lfs1_type { + LFS1_TYPE_REG = 0x11, + LFS1_TYPE_DIR = 0x22, + LFS1_TYPE_SUPERBLOCK = 0x2e, +}; + +typedef struct lfs1 { + lfs_block_t root[2]; +} lfs1_t; + +typedef struct lfs1_entry { + lfs_off_t off; + + struct lfs1_disk_entry { + uint8_t type; + uint8_t elen; + uint8_t alen; + uint8_t nlen; + union { + struct { + lfs_block_t head; + lfs_size_t size; + } file; + lfs_block_t dir[2]; + } u; + } d; +} lfs1_entry_t; + +typedef struct lfs1_dir { + struct lfs1_dir *next; + lfs_block_t pair[2]; + lfs_off_t off; + + lfs_block_t head[2]; + lfs_off_t pos; + + struct lfs1_disk_dir { + uint32_t rev; + lfs_size_t size; + lfs_block_t tail[2]; + } d; +} lfs1_dir_t; + +typedef struct lfs1_superblock { + lfs_off_t off; + + struct lfs1_disk_superblock { + uint8_t type; + uint8_t elen; + uint8_t alen; + uint8_t nlen; + lfs_block_t root[2]; + uint32_t block_size; + uint32_t block_count; + uint32_t version; + char magic[8]; + } d; +} lfs1_superblock_t; + + +/// Low-level wrappers v1->v2 /// +static void lfs1_crc(uint32_t *crc, const void *buffer, size_t size) { + *crc = lfs_crc(*crc, buffer, size); +} + +static int lfs1_bd_read(lfs_t *lfs, lfs_block_t block, + lfs_off_t off, void *buffer, lfs_size_t size) { + // if we ever do more than writes to alternating pairs, + // this may need to consider pcache + return lfs_bd_read(lfs, &lfs->pcache, &lfs->rcache, size, + block, off, buffer, size); +} + +static int lfs1_bd_crc(lfs_t *lfs, lfs_block_t block, + lfs_off_t off, lfs_size_t size, uint32_t *crc) { + for (lfs_off_t i = 0; i < size; i++) { + uint8_t c; + int err = lfs1_bd_read(lfs, block, off+i, &c, 1); + if (err) { + return err; + } + + lfs1_crc(crc, &c, 1); + } + + return 0; +} + + +/// Endian swapping functions /// +static void lfs1_dir_fromle32(struct lfs1_disk_dir *d) { + d->rev = lfs_fromle32(d->rev); + d->size = lfs_fromle32(d->size); + d->tail[0] = lfs_fromle32(d->tail[0]); + d->tail[1] = lfs_fromle32(d->tail[1]); +} + +static void lfs1_dir_tole32(struct lfs1_disk_dir *d) { + d->rev = lfs_tole32(d->rev); + d->size = lfs_tole32(d->size); + d->tail[0] = lfs_tole32(d->tail[0]); + d->tail[1] = lfs_tole32(d->tail[1]); +} + +static void lfs1_entry_fromle32(struct lfs1_disk_entry *d) { + d->u.dir[0] = lfs_fromle32(d->u.dir[0]); + d->u.dir[1] = lfs_fromle32(d->u.dir[1]); +} + +static void lfs1_entry_tole32(struct lfs1_disk_entry *d) { + d->u.dir[0] = lfs_tole32(d->u.dir[0]); + d->u.dir[1] = lfs_tole32(d->u.dir[1]); +} + +static void lfs1_superblock_fromle32(struct lfs1_disk_superblock *d) { + d->root[0] = lfs_fromle32(d->root[0]); + d->root[1] = lfs_fromle32(d->root[1]); + d->block_size = lfs_fromle32(d->block_size); + d->block_count = lfs_fromle32(d->block_count); + d->version = lfs_fromle32(d->version); +} + + +///// Metadata pair and directory operations /// +static inline lfs_size_t lfs1_entry_size(const lfs1_entry_t *entry) { + return 4 + entry->d.elen + entry->d.alen + entry->d.nlen; +} + +static int lfs1_dir_fetch(lfs_t *lfs, + lfs1_dir_t *dir, const lfs_block_t pair[2]) { + // copy out pair, otherwise may be aliasing dir + const lfs_block_t tpair[2] = {pair[0], pair[1]}; + bool valid = false; + + // check both blocks for the most recent revision + for (int i = 0; i < 2; i++) { + struct lfs1_disk_dir test; + int err = lfs1_bd_read(lfs, tpair[i], 0, &test, sizeof(test)); + lfs1_dir_fromle32(&test); + if (err) { + if (err == LFS_ERR_CORRUPT) { + continue; + } + return err; + } + + if (valid && lfs_scmp(test.rev, dir->d.rev) < 0) { + continue; + } + + if ((0x7fffffff & test.size) < sizeof(test)+4 || + (0x7fffffff & test.size) > lfs->cfg->block_size) { + continue; + } + + uint32_t crc = 0xffffffff; + lfs1_dir_tole32(&test); + lfs1_crc(&crc, &test, sizeof(test)); + lfs1_dir_fromle32(&test); + err = lfs1_bd_crc(lfs, tpair[i], sizeof(test), + (0x7fffffff & test.size) - sizeof(test), &crc); + if (err) { + if (err == LFS_ERR_CORRUPT) { + continue; + } + return err; + } + + if (crc != 0) { + continue; + } + + valid = true; + + // setup dir in case it's valid + dir->pair[0] = tpair[(i+0) % 2]; + dir->pair[1] = tpair[(i+1) % 2]; + dir->off = sizeof(dir->d); + dir->d = test; + } + + if (!valid) { + LFS_ERROR("Corrupted dir pair at {0x%"PRIx32", 0x%"PRIx32"}", + tpair[0], tpair[1]); + return LFS_ERR_CORRUPT; + } + + return 0; +} + +static int lfs1_dir_next(lfs_t *lfs, lfs1_dir_t *dir, lfs1_entry_t *entry) { + while (dir->off + sizeof(entry->d) > (0x7fffffff & dir->d.size)-4) { + if (!(0x80000000 & dir->d.size)) { + entry->off = dir->off; + return LFS_ERR_NOENT; + } + + int err = lfs1_dir_fetch(lfs, dir, dir->d.tail); + if (err) { + return err; + } + + dir->off = sizeof(dir->d); + dir->pos += sizeof(dir->d) + 4; + } + + int err = lfs1_bd_read(lfs, dir->pair[0], dir->off, + &entry->d, sizeof(entry->d)); + lfs1_entry_fromle32(&entry->d); + if (err) { + return err; + } + + entry->off = dir->off; + dir->off += lfs1_entry_size(entry); + dir->pos += lfs1_entry_size(entry); + return 0; +} + +/// littlefs v1 specific operations /// +int lfs1_traverse(lfs_t *lfs, int (*cb)(void*, lfs_block_t), void *data) { + if (lfs_pair_isnull(lfs->lfs1->root)) { + return 0; + } + + // iterate over metadata pairs + lfs1_dir_t dir; + lfs1_entry_t entry; + lfs_block_t cwd[2] = {0, 1}; + + while (true) { + for (int i = 0; i < 2; i++) { + int err = cb(data, cwd[i]); + if (err) { + return err; + } + } + + int err = lfs1_dir_fetch(lfs, &dir, cwd); + if (err) { + return err; + } + + // iterate over contents + while (dir.off + sizeof(entry.d) <= (0x7fffffff & dir.d.size)-4) { + err = lfs1_bd_read(lfs, dir.pair[0], dir.off, + &entry.d, sizeof(entry.d)); + lfs1_entry_fromle32(&entry.d); + if (err) { + return err; + } + + dir.off += lfs1_entry_size(&entry); + if ((0x70 & entry.d.type) == (0x70 & LFS1_TYPE_REG)) { + err = lfs_ctz_traverse(lfs, NULL, &lfs->rcache, + entry.d.u.file.head, entry.d.u.file.size, cb, data); + if (err) { + return err; + } + } + } + + // we also need to check if we contain a threaded v2 directory + lfs_mdir_t dir2 = {.split=true, .tail={cwd[0], cwd[1]}}; + while (dir2.split) { + err = lfs_dir_fetch(lfs, &dir2, dir2.tail); + if (err) { + break; + } + + for (int i = 0; i < 2; i++) { + err = cb(data, dir2.pair[i]); + if (err) { + return err; + } + } + } + + cwd[0] = dir.d.tail[0]; + cwd[1] = dir.d.tail[1]; + + if (lfs_pair_isnull(cwd)) { + break; + } + } + + return 0; +} + +static int lfs1_moved(lfs_t *lfs, const void *e) { + if (lfs_pair_isnull(lfs->lfs1->root)) { + return 0; + } + + // skip superblock + lfs1_dir_t cwd; + int err = lfs1_dir_fetch(lfs, &cwd, (const lfs_block_t[2]){0, 1}); + if (err) { + return err; + } + + // iterate over all directory directory entries + lfs1_entry_t entry; + while (!lfs_pair_isnull(cwd.d.tail)) { + err = lfs1_dir_fetch(lfs, &cwd, cwd.d.tail); + if (err) { + return err; + } + + while (true) { + err = lfs1_dir_next(lfs, &cwd, &entry); + if (err && err != LFS_ERR_NOENT) { + return err; + } + + if (err == LFS_ERR_NOENT) { + break; + } + + if (!(0x80 & entry.d.type) && + memcmp(&entry.d.u, e, sizeof(entry.d.u)) == 0) { + return true; + } + } + } + + return false; +} + +/// Filesystem operations /// +static int lfs1_mount(lfs_t *lfs, struct lfs1 *lfs1, + const struct lfs_config *cfg) { + int err = 0; + { + err = lfs_init(lfs, cfg); + if (err) { + return err; + } + + lfs->lfs1 = lfs1; + lfs->lfs1->root[0] = LFS_BLOCK_NULL; + lfs->lfs1->root[1] = LFS_BLOCK_NULL; + + // setup free lookahead + lfs->lookahead.start = 0; + lfs->lookahead.size = 0; + lfs->lookahead.next = 0; + lfs_alloc_ckpoint(lfs); + + // load superblock + lfs1_dir_t dir; + lfs1_superblock_t superblock; + err = lfs1_dir_fetch(lfs, &dir, (const lfs_block_t[2]){0, 1}); + if (err && err != LFS_ERR_CORRUPT) { + goto cleanup; + } + + if (!err) { + err = lfs1_bd_read(lfs, dir.pair[0], sizeof(dir.d), + &superblock.d, sizeof(superblock.d)); + lfs1_superblock_fromle32(&superblock.d); + if (err) { + goto cleanup; + } + + lfs->lfs1->root[0] = superblock.d.root[0]; + lfs->lfs1->root[1] = superblock.d.root[1]; + } + + if (err || memcmp(superblock.d.magic, "littlefs", 8) != 0) { + LFS_ERROR("Invalid superblock at {0x%"PRIx32", 0x%"PRIx32"}", + 0, 1); + err = LFS_ERR_CORRUPT; + goto cleanup; + } + + uint16_t major_version = (0xffff & (superblock.d.version >> 16)); + uint16_t minor_version = (0xffff & (superblock.d.version >> 0)); + if ((major_version != LFS1_DISK_VERSION_MAJOR || + minor_version > LFS1_DISK_VERSION_MINOR)) { + LFS_ERROR("Invalid version v%d.%d", major_version, minor_version); + err = LFS_ERR_INVAL; + goto cleanup; + } + + return 0; + } + +cleanup: + lfs_deinit(lfs); + return err; +} + +static int lfs1_unmount(lfs_t *lfs) { + return lfs_deinit(lfs); +} + +/// v1 migration /// +static int lfs_migrate_(lfs_t *lfs, const struct lfs_config *cfg) { + struct lfs1 lfs1; + + // Indeterminate filesystem size not allowed for migration. + LFS_ASSERT(cfg->block_count != 0); + + int err = lfs1_mount(lfs, &lfs1, cfg); + if (err) { + return err; + } + + { + // iterate through each directory, copying over entries + // into new directory + lfs1_dir_t dir1; + lfs_mdir_t dir2; + dir1.d.tail[0] = lfs->lfs1->root[0]; + dir1.d.tail[1] = lfs->lfs1->root[1]; + while (!lfs_pair_isnull(dir1.d.tail)) { + // iterate old dir + err = lfs1_dir_fetch(lfs, &dir1, dir1.d.tail); + if (err) { + goto cleanup; + } + + // create new dir and bind as temporary pretend root + err = lfs_dir_alloc(lfs, &dir2); + if (err) { + goto cleanup; + } + + dir2.rev = dir1.d.rev; + dir1.head[0] = dir1.pair[0]; + dir1.head[1] = dir1.pair[1]; + lfs->root[0] = dir2.pair[0]; + lfs->root[1] = dir2.pair[1]; + + err = lfs_dir_commit(lfs, &dir2, NULL, 0); + if (err) { + goto cleanup; + } + + while (true) { + lfs1_entry_t entry1; + err = lfs1_dir_next(lfs, &dir1, &entry1); + if (err && err != LFS_ERR_NOENT) { + goto cleanup; + } + + if (err == LFS_ERR_NOENT) { + break; + } + + // check that entry has not been moved + if (entry1.d.type & 0x80) { + int moved = lfs1_moved(lfs, &entry1.d.u); + if (moved < 0) { + err = moved; + goto cleanup; + } + + if (moved) { + continue; + } + + entry1.d.type &= ~0x80; + } + + // also fetch name + char name[LFS_NAME_MAX+1]; + memset(name, 0, sizeof(name)); + err = lfs1_bd_read(lfs, dir1.pair[0], + entry1.off + 4+entry1.d.elen+entry1.d.alen, + name, entry1.d.nlen); + if (err) { + goto cleanup; + } + + bool isdir = (entry1.d.type == LFS1_TYPE_DIR); + + // create entry in new dir + err = lfs_dir_fetch(lfs, &dir2, lfs->root); + if (err) { + goto cleanup; + } + + uint16_t id; + err = lfs_dir_find(lfs, &dir2, &(const char*){name}, &id); + if (!(err == LFS_ERR_NOENT && id != 0x3ff)) { + err = (err < 0) ? err : LFS_ERR_EXIST; + goto cleanup; + } + + lfs1_entry_tole32(&entry1.d); + err = lfs_dir_commit(lfs, &dir2, LFS_MKATTRS( + {LFS_MKTAG(LFS_TYPE_CREATE, id, 0), NULL}, + {LFS_MKTAG_IF_ELSE(isdir, + LFS_TYPE_DIR, id, entry1.d.nlen, + LFS_TYPE_REG, id, entry1.d.nlen), + name}, + {LFS_MKTAG_IF_ELSE(isdir, + LFS_TYPE_DIRSTRUCT, id, sizeof(entry1.d.u), + LFS_TYPE_CTZSTRUCT, id, sizeof(entry1.d.u)), + &entry1.d.u})); + lfs1_entry_fromle32(&entry1.d); + if (err) { + goto cleanup; + } + } + + if (!lfs_pair_isnull(dir1.d.tail)) { + // find last block and update tail to thread into fs + err = lfs_dir_fetch(lfs, &dir2, lfs->root); + if (err) { + goto cleanup; + } + + while (dir2.split) { + err = lfs_dir_fetch(lfs, &dir2, dir2.tail); + if (err) { + goto cleanup; + } + } + + lfs_pair_tole32(dir2.pair); + err = lfs_dir_commit(lfs, &dir2, LFS_MKATTRS( + {LFS_MKTAG(LFS_TYPE_SOFTTAIL, 0x3ff, 8), dir1.d.tail})); + lfs_pair_fromle32(dir2.pair); + if (err) { + goto cleanup; + } + } + + // Copy over first block to thread into fs. Unfortunately + // if this fails there is not much we can do. + LFS_DEBUG("Migrating {0x%"PRIx32", 0x%"PRIx32"} " + "-> {0x%"PRIx32", 0x%"PRIx32"}", + lfs->root[0], lfs->root[1], dir1.head[0], dir1.head[1]); + + err = lfs_bd_erase(lfs, dir1.head[1]); + if (err) { + goto cleanup; + } + + err = lfs_dir_fetch(lfs, &dir2, lfs->root); + if (err) { + goto cleanup; + } + + for (lfs_off_t i = 0; i < dir2.off; i++) { + uint8_t dat; + err = lfs_bd_read(lfs, + NULL, &lfs->rcache, dir2.off, + dir2.pair[0], i, &dat, 1); + if (err) { + goto cleanup; + } + + err = lfs_bd_prog(lfs, + &lfs->pcache, &lfs->rcache, true, + dir1.head[1], i, &dat, 1); + if (err) { + goto cleanup; + } + } + + err = lfs_bd_flush(lfs, &lfs->pcache, &lfs->rcache, true); + if (err) { + goto cleanup; + } + } + + // Create new superblock. This marks a successful migration! + err = lfs1_dir_fetch(lfs, &dir1, (const lfs_block_t[2]){0, 1}); + if (err) { + goto cleanup; + } + + dir2.pair[0] = dir1.pair[0]; + dir2.pair[1] = dir1.pair[1]; + dir2.rev = dir1.d.rev; + dir2.off = sizeof(dir2.rev); + dir2.etag = 0xffffffff; + dir2.count = 0; + dir2.tail[0] = lfs->lfs1->root[0]; + dir2.tail[1] = lfs->lfs1->root[1]; + dir2.erased = false; + dir2.split = true; + + lfs_superblock_t superblock = { + .version = LFS_DISK_VERSION, + .block_size = lfs->cfg->block_size, + .block_count = lfs->cfg->block_count, + .name_max = lfs->name_max, + .file_max = lfs->file_max, + .attr_max = lfs->attr_max, + }; + + lfs_superblock_tole32(&superblock); + err = lfs_dir_commit(lfs, &dir2, LFS_MKATTRS( + {LFS_MKTAG(LFS_TYPE_CREATE, 0, 0), NULL}, + {LFS_MKTAG(LFS_TYPE_SUPERBLOCK, 0, 8), "littlefs"}, + {LFS_MKTAG(LFS_TYPE_INLINESTRUCT, 0, sizeof(superblock)), + &superblock})); + if (err) { + goto cleanup; + } + + // sanity check that fetch works + err = lfs_dir_fetch(lfs, &dir2, (const lfs_block_t[2]){0, 1}); + if (err) { + goto cleanup; + } + + // force compaction to prevent accidentally mounting v1 + dir2.erased = false; + err = lfs_dir_commit(lfs, &dir2, NULL, 0); + if (err) { + goto cleanup; + } + } + +cleanup: + lfs1_unmount(lfs); + return err; +} + +#endif + + +/// Public API wrappers /// + +// Here we can add tracing/thread safety easily + +// Thread-safe wrappers if enabled +#ifdef LFS_THREADSAFE +#define LFS_LOCK(cfg) cfg->lock(cfg) +#define LFS_UNLOCK(cfg) cfg->unlock(cfg) +#else +#define LFS_LOCK(cfg) ((void)cfg, 0) +#define LFS_UNLOCK(cfg) ((void)cfg) +#endif + +// Public API +#ifndef LFS_READONLY +int lfs_format(lfs_t *lfs, const struct lfs_config *cfg) { + int err = LFS_LOCK(cfg); + if (err) { + return err; + } + LFS_TRACE("lfs_format(%p, %p {.context=%p, " + ".read=%p, .prog=%p, .erase=%p, .sync=%p, " + ".read_size=%"PRIu32", .prog_size=%"PRIu32", " + ".block_size=%"PRIu32", .block_count=%"PRIu32", " + ".block_cycles=%"PRId32", .cache_size=%"PRIu32", " + ".lookahead_size=%"PRIu32", .read_buffer=%p, " + ".prog_buffer=%p, .lookahead_buffer=%p, " + ".name_max=%"PRIu32", .file_max=%"PRIu32", " + ".attr_max=%"PRIu32"})", + (void*)lfs, (void*)cfg, cfg->context, + (void*)(uintptr_t)cfg->read, (void*)(uintptr_t)cfg->prog, + (void*)(uintptr_t)cfg->erase, (void*)(uintptr_t)cfg->sync, + cfg->read_size, cfg->prog_size, cfg->block_size, cfg->block_count, + cfg->block_cycles, cfg->cache_size, cfg->lookahead_size, + cfg->read_buffer, cfg->prog_buffer, cfg->lookahead_buffer, + cfg->name_max, cfg->file_max, cfg->attr_max); + + err = lfs_format_(lfs, cfg); + + LFS_TRACE("lfs_format -> %d", err); + LFS_UNLOCK(cfg); + return err; +} +#endif + +int lfs_mount(lfs_t *lfs, const struct lfs_config *cfg) { + int err = LFS_LOCK(cfg); + if (err) { + return err; + } + LFS_TRACE("lfs_mount(%p, %p {.context=%p, " + ".read=%p, .prog=%p, .erase=%p, .sync=%p, " + ".read_size=%"PRIu32", .prog_size=%"PRIu32", " + ".block_size=%"PRIu32", .block_count=%"PRIu32", " + ".block_cycles=%"PRId32", .cache_size=%"PRIu32", " + ".lookahead_size=%"PRIu32", .read_buffer=%p, " + ".prog_buffer=%p, .lookahead_buffer=%p, " + ".name_max=%"PRIu32", .file_max=%"PRIu32", " + ".attr_max=%"PRIu32"})", + (void*)lfs, (void*)cfg, cfg->context, + (void*)(uintptr_t)cfg->read, (void*)(uintptr_t)cfg->prog, + (void*)(uintptr_t)cfg->erase, (void*)(uintptr_t)cfg->sync, + cfg->read_size, cfg->prog_size, cfg->block_size, cfg->block_count, + cfg->block_cycles, cfg->cache_size, cfg->lookahead_size, + cfg->read_buffer, cfg->prog_buffer, cfg->lookahead_buffer, + cfg->name_max, cfg->file_max, cfg->attr_max); + + err = lfs_mount_(lfs, cfg); + + LFS_TRACE("lfs_mount -> %d", err); + LFS_UNLOCK(cfg); + return err; +} + +int lfs_unmount(lfs_t *lfs) { + int err = LFS_LOCK(lfs->cfg); + if (err) { + return err; + } + LFS_TRACE("lfs_unmount(%p)", (void*)lfs); + + err = lfs_unmount_(lfs); + + LFS_TRACE("lfs_unmount -> %d", err); + LFS_UNLOCK(lfs->cfg); + return err; +} + +#ifndef LFS_READONLY +int lfs_remove(lfs_t *lfs, const char *path) { + int err = LFS_LOCK(lfs->cfg); + if (err) { + return err; + } + LFS_TRACE("lfs_remove(%p, \"%s\")", (void*)lfs, path); + + err = lfs_remove_(lfs, path); + + LFS_TRACE("lfs_remove -> %d", err); + LFS_UNLOCK(lfs->cfg); + return err; +} +#endif + +#ifndef LFS_READONLY +int lfs_rename(lfs_t *lfs, const char *oldpath, const char *newpath) { + int err = LFS_LOCK(lfs->cfg); + if (err) { + return err; + } + LFS_TRACE("lfs_rename(%p, \"%s\", \"%s\")", (void*)lfs, oldpath, newpath); + + err = lfs_rename_(lfs, oldpath, newpath); + + LFS_TRACE("lfs_rename -> %d", err); + LFS_UNLOCK(lfs->cfg); + return err; +} +#endif + +int lfs_stat(lfs_t *lfs, const char *path, struct lfs_info *info) { + int err = LFS_LOCK(lfs->cfg); + if (err) { + return err; + } + LFS_TRACE("lfs_stat(%p, \"%s\", %p)", (void*)lfs, path, (void*)info); + + err = lfs_stat_(lfs, path, info); + + LFS_TRACE("lfs_stat -> %d", err); + LFS_UNLOCK(lfs->cfg); + return err; +} + +lfs_ssize_t lfs_getattr(lfs_t *lfs, const char *path, + uint8_t type, void *buffer, lfs_size_t size) { + int err = LFS_LOCK(lfs->cfg); + if (err) { + return err; + } + LFS_TRACE("lfs_getattr(%p, \"%s\", %"PRIu8", %p, %"PRIu32")", + (void*)lfs, path, type, buffer, size); + + lfs_ssize_t res = lfs_getattr_(lfs, path, type, buffer, size); + + LFS_TRACE("lfs_getattr -> %"PRId32, res); + LFS_UNLOCK(lfs->cfg); + return res; +} + +#ifndef LFS_READONLY +int lfs_setattr(lfs_t *lfs, const char *path, + uint8_t type, const void *buffer, lfs_size_t size) { + int err = LFS_LOCK(lfs->cfg); + if (err) { + return err; + } + LFS_TRACE("lfs_setattr(%p, \"%s\", %"PRIu8", %p, %"PRIu32")", + (void*)lfs, path, type, buffer, size); + + err = lfs_setattr_(lfs, path, type, buffer, size); + + LFS_TRACE("lfs_setattr -> %d", err); + LFS_UNLOCK(lfs->cfg); + return err; +} +#endif + +#ifndef LFS_READONLY +int lfs_removeattr(lfs_t *lfs, const char *path, uint8_t type) { + int err = LFS_LOCK(lfs->cfg); + if (err) { + return err; + } + LFS_TRACE("lfs_removeattr(%p, \"%s\", %"PRIu8")", (void*)lfs, path, type); + + err = lfs_removeattr_(lfs, path, type); + + LFS_TRACE("lfs_removeattr -> %d", err); + LFS_UNLOCK(lfs->cfg); + return err; +} +#endif + +#ifndef LFS_NO_MALLOC +int lfs_file_open(lfs_t *lfs, lfs_file_t *file, const char *path, int flags) { + int err = LFS_LOCK(lfs->cfg); + if (err) { + return err; + } + LFS_TRACE("lfs_file_open(%p, %p, \"%s\", %x)", + (void*)lfs, (void*)file, path, (unsigned)flags); + LFS_ASSERT(!lfs_mlist_isopen(lfs->mlist, (struct lfs_mlist*)file)); + + err = lfs_file_open_(lfs, file, path, flags); + + LFS_TRACE("lfs_file_open -> %d", err); + LFS_UNLOCK(lfs->cfg); + return err; +} +#endif + +int lfs_file_opencfg(lfs_t *lfs, lfs_file_t *file, + const char *path, int flags, + const struct lfs_file_config *cfg) { + int err = LFS_LOCK(lfs->cfg); + if (err) { + return err; + } + LFS_TRACE("lfs_file_opencfg(%p, %p, \"%s\", %x, %p {" + ".buffer=%p, .attrs=%p, .attr_count=%"PRIu32"})", + (void*)lfs, (void*)file, path, (unsigned)flags, + (void*)cfg, cfg->buffer, (void*)cfg->attrs, cfg->attr_count); + LFS_ASSERT(!lfs_mlist_isopen(lfs->mlist, (struct lfs_mlist*)file)); + + err = lfs_file_opencfg_(lfs, file, path, flags, cfg); + + LFS_TRACE("lfs_file_opencfg -> %d", err); + LFS_UNLOCK(lfs->cfg); + return err; +} + +int lfs_file_close(lfs_t *lfs, lfs_file_t *file) { + int err = LFS_LOCK(lfs->cfg); + if (err) { + return err; + } + LFS_TRACE("lfs_file_close(%p, %p)", (void*)lfs, (void*)file); + LFS_ASSERT(lfs_mlist_isopen(lfs->mlist, (struct lfs_mlist*)file)); + + err = lfs_file_close_(lfs, file); + + LFS_TRACE("lfs_file_close -> %d", err); + LFS_UNLOCK(lfs->cfg); + return err; +} + +#ifndef LFS_READONLY +int lfs_file_sync(lfs_t *lfs, lfs_file_t *file) { + int err = LFS_LOCK(lfs->cfg); + if (err) { + return err; + } + LFS_TRACE("lfs_file_sync(%p, %p)", (void*)lfs, (void*)file); + LFS_ASSERT(lfs_mlist_isopen(lfs->mlist, (struct lfs_mlist*)file)); + + err = lfs_file_sync_(lfs, file); + + LFS_TRACE("lfs_file_sync -> %d", err); + LFS_UNLOCK(lfs->cfg); + return err; +} +#endif + +lfs_ssize_t lfs_file_read(lfs_t *lfs, lfs_file_t *file, + void *buffer, lfs_size_t size) { + int err = LFS_LOCK(lfs->cfg); + if (err) { + return err; + } + LFS_TRACE("lfs_file_read(%p, %p, %p, %"PRIu32")", + (void*)lfs, (void*)file, buffer, size); + LFS_ASSERT(lfs_mlist_isopen(lfs->mlist, (struct lfs_mlist*)file)); + + lfs_ssize_t res = lfs_file_read_(lfs, file, buffer, size); + + LFS_TRACE("lfs_file_read -> %"PRId32, res); + LFS_UNLOCK(lfs->cfg); + return res; +} + +#ifndef LFS_READONLY +lfs_ssize_t lfs_file_write(lfs_t *lfs, lfs_file_t *file, + const void *buffer, lfs_size_t size) { + int err = LFS_LOCK(lfs->cfg); + if (err) { + return err; + } + LFS_TRACE("lfs_file_write(%p, %p, %p, %"PRIu32")", + (void*)lfs, (void*)file, buffer, size); + LFS_ASSERT(lfs_mlist_isopen(lfs->mlist, (struct lfs_mlist*)file)); + + lfs_ssize_t res = lfs_file_write_(lfs, file, buffer, size); + + LFS_TRACE("lfs_file_write -> %"PRId32, res); + LFS_UNLOCK(lfs->cfg); + return res; +} +#endif + +lfs_soff_t lfs_file_seek(lfs_t *lfs, lfs_file_t *file, + lfs_soff_t off, int whence) { + int err = LFS_LOCK(lfs->cfg); + if (err) { + return err; + } + LFS_TRACE("lfs_file_seek(%p, %p, %"PRId32", %d)", + (void*)lfs, (void*)file, off, whence); + LFS_ASSERT(lfs_mlist_isopen(lfs->mlist, (struct lfs_mlist*)file)); + + lfs_soff_t res = lfs_file_seek_(lfs, file, off, whence); + + LFS_TRACE("lfs_file_seek -> %"PRId32, res); + LFS_UNLOCK(lfs->cfg); + return res; +} + +#ifndef LFS_READONLY +int lfs_file_truncate(lfs_t *lfs, lfs_file_t *file, lfs_off_t size) { + int err = LFS_LOCK(lfs->cfg); + if (err) { + return err; + } + LFS_TRACE("lfs_file_truncate(%p, %p, %"PRIu32")", + (void*)lfs, (void*)file, size); + LFS_ASSERT(lfs_mlist_isopen(lfs->mlist, (struct lfs_mlist*)file)); + + err = lfs_file_truncate_(lfs, file, size); + + LFS_TRACE("lfs_file_truncate -> %d", err); + LFS_UNLOCK(lfs->cfg); + return err; +} +#endif + +lfs_soff_t lfs_file_tell(lfs_t *lfs, lfs_file_t *file) { + int err = LFS_LOCK(lfs->cfg); + if (err) { + return err; + } + LFS_TRACE("lfs_file_tell(%p, %p)", (void*)lfs, (void*)file); + LFS_ASSERT(lfs_mlist_isopen(lfs->mlist, (struct lfs_mlist*)file)); + + lfs_soff_t res = lfs_file_tell_(lfs, file); + + LFS_TRACE("lfs_file_tell -> %"PRId32, res); + LFS_UNLOCK(lfs->cfg); + return res; +} + +int lfs_file_rewind(lfs_t *lfs, lfs_file_t *file) { + int err = LFS_LOCK(lfs->cfg); + if (err) { + return err; + } + LFS_TRACE("lfs_file_rewind(%p, %p)", (void*)lfs, (void*)file); + + err = lfs_file_rewind_(lfs, file); + + LFS_TRACE("lfs_file_rewind -> %d", err); + LFS_UNLOCK(lfs->cfg); + return err; +} + +lfs_soff_t lfs_file_size(lfs_t *lfs, lfs_file_t *file) { + int err = LFS_LOCK(lfs->cfg); + if (err) { + return err; + } + LFS_TRACE("lfs_file_size(%p, %p)", (void*)lfs, (void*)file); + LFS_ASSERT(lfs_mlist_isopen(lfs->mlist, (struct lfs_mlist*)file)); + + lfs_soff_t res = lfs_file_size_(lfs, file); + + LFS_TRACE("lfs_file_size -> %"PRIu32, res); + LFS_UNLOCK(lfs->cfg); + return res; +} + +#ifndef LFS_READONLY +int lfs_mkdir(lfs_t *lfs, const char *path) { + int err = LFS_LOCK(lfs->cfg); + if (err) { + return err; + } + LFS_TRACE("lfs_mkdir(%p, \"%s\")", (void*)lfs, path); + + err = lfs_mkdir_(lfs, path); + + LFS_TRACE("lfs_mkdir -> %d", err); + LFS_UNLOCK(lfs->cfg); + return err; +} +#endif + +int lfs_dir_open(lfs_t *lfs, lfs_dir_t *dir, const char *path) { + int err = LFS_LOCK(lfs->cfg); + if (err) { + return err; + } + LFS_TRACE("lfs_dir_open(%p, %p, \"%s\")", (void*)lfs, (void*)dir, path); + LFS_ASSERT(!lfs_mlist_isopen(lfs->mlist, (struct lfs_mlist*)dir)); + + err = lfs_dir_open_(lfs, dir, path); + + LFS_TRACE("lfs_dir_open -> %d", err); + LFS_UNLOCK(lfs->cfg); + return err; +} + +int lfs_dir_close(lfs_t *lfs, lfs_dir_t *dir) { + int err = LFS_LOCK(lfs->cfg); + if (err) { + return err; + } + LFS_TRACE("lfs_dir_close(%p, %p)", (void*)lfs, (void*)dir); + + err = lfs_dir_close_(lfs, dir); + + LFS_TRACE("lfs_dir_close -> %d", err); + LFS_UNLOCK(lfs->cfg); + return err; +} + +int lfs_dir_read(lfs_t *lfs, lfs_dir_t *dir, struct lfs_info *info) { + int err = LFS_LOCK(lfs->cfg); + if (err) { + return err; + } + LFS_TRACE("lfs_dir_read(%p, %p, %p)", + (void*)lfs, (void*)dir, (void*)info); + + err = lfs_dir_read_(lfs, dir, info); + + LFS_TRACE("lfs_dir_read -> %d", err); + LFS_UNLOCK(lfs->cfg); + return err; +} + +int lfs_dir_seek(lfs_t *lfs, lfs_dir_t *dir, lfs_off_t off) { + int err = LFS_LOCK(lfs->cfg); + if (err) { + return err; + } + LFS_TRACE("lfs_dir_seek(%p, %p, %"PRIu32")", + (void*)lfs, (void*)dir, off); + + err = lfs_dir_seek_(lfs, dir, off); + + LFS_TRACE("lfs_dir_seek -> %d", err); + LFS_UNLOCK(lfs->cfg); + return err; +} + +lfs_soff_t lfs_dir_tell(lfs_t *lfs, lfs_dir_t *dir) { + int err = LFS_LOCK(lfs->cfg); + if (err) { + return err; + } + LFS_TRACE("lfs_dir_tell(%p, %p)", (void*)lfs, (void*)dir); + + lfs_soff_t res = lfs_dir_tell_(lfs, dir); + + LFS_TRACE("lfs_dir_tell -> %"PRId32, res); + LFS_UNLOCK(lfs->cfg); + return res; +} + +int lfs_dir_rewind(lfs_t *lfs, lfs_dir_t *dir) { + int err = LFS_LOCK(lfs->cfg); + if (err) { + return err; + } + LFS_TRACE("lfs_dir_rewind(%p, %p)", (void*)lfs, (void*)dir); + + err = lfs_dir_rewind_(lfs, dir); + + LFS_TRACE("lfs_dir_rewind -> %d", err); + LFS_UNLOCK(lfs->cfg); + return err; +} + +int lfs_fs_stat(lfs_t *lfs, struct lfs_fsinfo *fsinfo) { + int err = LFS_LOCK(lfs->cfg); + if (err) { + return err; + } + LFS_TRACE("lfs_fs_stat(%p, %p)", (void*)lfs, (void*)fsinfo); + + err = lfs_fs_stat_(lfs, fsinfo); + + LFS_TRACE("lfs_fs_stat -> %d", err); + LFS_UNLOCK(lfs->cfg); + return err; +} + +lfs_ssize_t lfs_fs_size(lfs_t *lfs) { + int err = LFS_LOCK(lfs->cfg); + if (err) { + return err; + } + LFS_TRACE("lfs_fs_size(%p)", (void*)lfs); + + lfs_ssize_t res = lfs_fs_size_(lfs); + + LFS_TRACE("lfs_fs_size -> %"PRId32, res); + LFS_UNLOCK(lfs->cfg); + return res; +} + +int lfs_fs_traverse(lfs_t *lfs, int (*cb)(void *, lfs_block_t), void *data) { + int err = LFS_LOCK(lfs->cfg); + if (err) { + return err; + } + LFS_TRACE("lfs_fs_traverse(%p, %p, %p)", + (void*)lfs, (void*)(uintptr_t)cb, data); + + err = lfs_fs_traverse_(lfs, cb, data, true); + + LFS_TRACE("lfs_fs_traverse -> %d", err); + LFS_UNLOCK(lfs->cfg); + return err; +} + +#ifndef LFS_READONLY +int lfs_fs_mkconsistent(lfs_t *lfs) { + int err = LFS_LOCK(lfs->cfg); + if (err) { + return err; + } + LFS_TRACE("lfs_fs_mkconsistent(%p)", (void*)lfs); + + err = lfs_fs_mkconsistent_(lfs); + + LFS_TRACE("lfs_fs_mkconsistent -> %d", err); + LFS_UNLOCK(lfs->cfg); + return err; +} +#endif + +#ifndef LFS_READONLY +int lfs_fs_gc(lfs_t *lfs) { + int err = LFS_LOCK(lfs->cfg); + if (err) { + return err; + } + LFS_TRACE("lfs_fs_gc(%p)", (void*)lfs); + + err = lfs_fs_gc_(lfs); + + LFS_TRACE("lfs_fs_gc -> %d", err); + LFS_UNLOCK(lfs->cfg); + return err; +} +#endif + +#ifndef LFS_READONLY +int lfs_fs_grow(lfs_t *lfs, lfs_size_t block_count) { + int err = LFS_LOCK(lfs->cfg); + if (err) { + return err; + } + LFS_TRACE("lfs_fs_grow(%p, %"PRIu32")", (void*)lfs, block_count); + + err = lfs_fs_grow_(lfs, block_count); + + LFS_TRACE("lfs_fs_grow -> %d", err); + LFS_UNLOCK(lfs->cfg); + return err; +} +#endif + +#ifdef LFS_MIGRATE +int lfs_migrate(lfs_t *lfs, const struct lfs_config *cfg) { + int err = LFS_LOCK(cfg); + if (err) { + return err; + } + LFS_TRACE("lfs_migrate(%p, %p {.context=%p, " + ".read=%p, .prog=%p, .erase=%p, .sync=%p, " + ".read_size=%"PRIu32", .prog_size=%"PRIu32", " + ".block_size=%"PRIu32", .block_count=%"PRIu32", " + ".block_cycles=%"PRId32", .cache_size=%"PRIu32", " + ".lookahead_size=%"PRIu32", .read_buffer=%p, " + ".prog_buffer=%p, .lookahead_buffer=%p, " + ".name_max=%"PRIu32", .file_max=%"PRIu32", " + ".attr_max=%"PRIu32"})", + (void*)lfs, (void*)cfg, cfg->context, + (void*)(uintptr_t)cfg->read, (void*)(uintptr_t)cfg->prog, + (void*)(uintptr_t)cfg->erase, (void*)(uintptr_t)cfg->sync, + cfg->read_size, cfg->prog_size, cfg->block_size, cfg->block_count, + cfg->block_cycles, cfg->cache_size, cfg->lookahead_size, + cfg->read_buffer, cfg->prog_buffer, cfg->lookahead_buffer, + cfg->name_max, cfg->file_max, cfg->attr_max); + + err = lfs_migrate_(lfs, cfg); + + LFS_TRACE("lfs_migrate -> %d", err); + LFS_UNLOCK(cfg); + return err; +} +#endif + diff --git a/components/littlefs/src/lfs_util.c b/components/littlefs/src/lfs_util.c new file mode 100644 index 00000000..dac72abc --- /dev/null +++ b/components/littlefs/src/lfs_util.c @@ -0,0 +1,37 @@ +/* + * lfs util functions + * + * Copyright (c) 2022, The littlefs authors. + * Copyright (c) 2017, Arm Limited. All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + */ +#include "lfs_util.h" + +// Only compile if user does not provide custom config +#ifndef LFS_CONFIG + + +// If user provides their own CRC impl we don't need this +#ifndef LFS_CRC +// Software CRC implementation with small lookup table +uint32_t lfs_crc(uint32_t crc, const void *buffer, size_t size) { + static const uint32_t rtable[16] = { + 0x00000000, 0x1db71064, 0x3b6e20c8, 0x26d930ac, + 0x76dc4190, 0x6b6b51f4, 0x4db26158, 0x5005713c, + 0xedb88320, 0xf00f9344, 0xd6d6a3e8, 0xcb61b38c, + 0x9b64c2b0, 0x86d3d2d4, 0xa00ae278, 0xbdbdf21c, + }; + + const uint8_t *data = buffer; + + for (size_t i = 0; i < size; i++) { + crc = (crc >> 4) ^ rtable[(crc ^ (data[i] >> 0)) & 0xf]; + crc = (crc >> 4) ^ rtable[(crc ^ (data[i] >> 4)) & 0xf]; + } + + return crc; +} +#endif + + +#endif diff --git a/components/logging/CMakeLists.txt b/components/logging/CMakeLists.txt new file mode 100644 index 00000000..2752ca50 --- /dev/null +++ b/components/logging/CMakeLists.txt @@ -0,0 +1,16 @@ +# OpenShock logging + panic macros — OS_LOG*, OS_PANIC*. +# +# openshock_log_printf (Logging.cpp) writes OS_LOG* output to the low-level `serial` +# transport (raw bytes to the console port), not C stdio. The OS_PANIC* macros +# (folded in from the former `panic` component) restart the device, so Logging.h +# pulls in esp_ota_ops / esp_system / freertos and is firmware-only. + +idf_component_register( + SRCS "src/Logging.cpp" + INCLUDE_DIRS "include" + REQUIRES temporal # Temporal.h -> OpenShock::millis() (public header) + esp_system # esp_restart (OS_PANIC* in public header) + app_update # esp_ota_mark_app_invalid_rollback_and_reboot (OS_PANIC_OTA) + freertos # vTaskDelay (OS_PANIC / OS_PANIC_OTA) + PRIV_REQUIRES serial # Serial::Write (Logging.cpp only) +) diff --git a/include/Logging.h b/components/logging/include/Logging.h similarity index 58% rename from include/Logging.h rename to components/logging/include/Logging.h index 2f2aa38f..47801325 100644 --- a/include/Logging.h +++ b/components/logging/include/Logging.h @@ -1,14 +1,20 @@ #pragma once -#include "Core.h" +#include "Temporal.h" -#include - -#include #include #include -extern "C" int log_printf(const char* fmt, ...); +#include +#include + +// openshock_log_printf forwards to vprintf (the IDF console: UART0 + any secondary +// USB-Serial-JTAG/CDC output). Defined in Logging.cpp so the logging path carries +// no hardware include of its own. The OS_PANIC* macros below, however, pull in +// esp_ota_ops / esp_system / freertos, so this header is firmware-only (host tests +// shadow it with a no-op stub). +// Named distinctly from the framework's own log_printf to avoid a symbol clash. +extern "C" int openshock_log_printf(const char* fmt, ...) __attribute__((format(printf, 1, 2))); template constexpr const char* openshockPathToFileName(const char (&path)[N]) @@ -30,10 +36,10 @@ constexpr const char* openshockPathToFileName(const char (&path)[N]) #define OPENSHOCK_LOG_LEVEL_DEBUG (4) #define OPENSHOCK_LOG_LEVEL_VERBOSE (5) -#define OPENSHOCK_LOG_FORMAT(letter, format) "[%lli][" #letter "][%s:%u] %s(): " format "\r\n", OpenShock::millis(), openshockPathToFileName(__FILE__), __LINE__, __FUNCTION__ +#define OPENSHOCK_LOG_FORMAT(letter, format) "[%lli][" #letter "][%s:%d] %s(): " format "\r\n", OpenShock::millis(), openshockPathToFileName(__FILE__), __LINE__, __FUNCTION__ #if OPENSHOCK_LOG_LEVEL >= OPENSHOCK_LOG_LEVEL_VERBOSE -#define OS_LOGV(TAG, format, ...) log_printf(OPENSHOCK_LOG_FORMAT(V, "[%s] " format), TAG, ##__VA_ARGS__) +#define OS_LOGV(TAG, format, ...) openshock_log_printf(OPENSHOCK_LOG_FORMAT(V, "[%s] " format), TAG, ##__VA_ARGS__) #else #define OS_LOGV(TAG, format, ...) \ do { \ @@ -41,7 +47,7 @@ constexpr const char* openshockPathToFileName(const char (&path)[N]) #endif #if OPENSHOCK_LOG_LEVEL >= OPENSHOCK_LOG_LEVEL_DEBUG -#define OS_LOGD(TAG, format, ...) log_printf(OPENSHOCK_LOG_FORMAT(D, "[%s] " format), TAG, ##__VA_ARGS__) +#define OS_LOGD(TAG, format, ...) openshock_log_printf(OPENSHOCK_LOG_FORMAT(D, "[%s] " format), TAG, ##__VA_ARGS__) #else #define OS_LOGD(TAG, format, ...) \ do { \ @@ -49,7 +55,7 @@ constexpr const char* openshockPathToFileName(const char (&path)[N]) #endif #if OPENSHOCK_LOG_LEVEL >= OPENSHOCK_LOG_LEVEL_INFO -#define OS_LOGI(TAG, format, ...) log_printf(OPENSHOCK_LOG_FORMAT(I, "[%s] " format), TAG, ##__VA_ARGS__) +#define OS_LOGI(TAG, format, ...) openshock_log_printf(OPENSHOCK_LOG_FORMAT(I, "[%s] " format), TAG, ##__VA_ARGS__) #else #define OS_LOGI(TAG, format, ...) \ do { \ @@ -57,7 +63,7 @@ constexpr const char* openshockPathToFileName(const char (&path)[N]) #endif #if OPENSHOCK_LOG_LEVEL >= OPENSHOCK_LOG_LEVEL_WARN -#define OS_LOGW(TAG, format, ...) log_printf(OPENSHOCK_LOG_FORMAT(W, "[%s] " format), TAG, ##__VA_ARGS__) +#define OS_LOGW(TAG, format, ...) openshock_log_printf(OPENSHOCK_LOG_FORMAT(W, "[%s] " format), TAG, ##__VA_ARGS__) #else #define OS_LOGW(TAG, format, ...) \ do { \ @@ -65,40 +71,38 @@ constexpr const char* openshockPathToFileName(const char (&path)[N]) #endif #if OPENSHOCK_LOG_LEVEL >= OPENSHOCK_LOG_LEVEL_ERROR -#define OS_LOGE(TAG, format, ...) log_printf(OPENSHOCK_LOG_FORMAT(E, "[%s] " format), TAG, ##__VA_ARGS__) +#define OS_LOGE(TAG, format, ...) openshock_log_printf(OPENSHOCK_LOG_FORMAT(E, "[%s] " format), TAG, ##__VA_ARGS__) #else #define OS_LOGE(TAG, format, ...) \ do { \ } while (0) #endif -#if OPENSHOCK_LOG_LEVEL >= OPENSHOCK_LOG_LEVEL_NONE -#define OS_LOGN(TAG, format, ...) log_printf(OPENSHOCK_LOG_FORMAT(E, "[%s] " format), TAG, ##__VA_ARGS__) -#else -#define OS_LOGN(TAG, format, ...) \ - do { \ - } while (0) -#endif +// Always compiled in: NONE is the lowest level, so no OPENSHOCK_LOG_LEVEL disables it. +#define OS_LOGN(TAG, format, ...) openshock_log_printf(OPENSHOCK_LOG_FORMAT(N, "[%s] " format), TAG, ##__VA_ARGS__) +// Panic/abort macros. These log at error level, then restart the device; they +// pull in esp_ota_ops / esp_system / freertos (included above), so anything that +// includes Logging.h is firmware-only. #define OS_PANIC_PRINT(TAG, format, ...) OS_LOGE(TAG, "PANIC: " format, ##__VA_ARGS__) #define OS_PANIC(TAG, format, ...) \ - { \ + do { \ OS_PANIC_PRINT(TAG, format ", restarting in 5 seconds...", ##__VA_ARGS__); \ vTaskDelay(pdMS_TO_TICKS(5000)); \ esp_restart(); \ - } + } while (0) #define OS_PANIC_OTA(TAG, format, ...) \ - { \ + do { \ OS_PANIC_PRINT(TAG, format ", invalidating update partition and restarting in 5 seconds...", ##__VA_ARGS__); \ vTaskDelay(pdMS_TO_TICKS(5000)); \ esp_ota_mark_app_invalid_rollback_and_reboot(); \ esp_restart(); \ - } + } while (0) #define OS_PANIC_INSTANT(TAG, format, ...) \ - { \ + do { \ OS_PANIC_PRINT(TAG, format, ##__VA_ARGS__); \ esp_restart(); \ - } + } while (0) diff --git a/components/logging/src/Logging.cpp b/components/logging/src/Logging.cpp new file mode 100644 index 00000000..65ba4d7e --- /dev/null +++ b/components/logging/src/Logging.cpp @@ -0,0 +1,18 @@ +#include "Logging.h" + +#include "serial/Serial.h" + +#include + +// Forwards OpenShock's OS_LOG* output to the low-level serial transport, which formats the line and writes the raw +// bytes to the console port. This bypasses C stdio (no vprintf/stdout); before the serial driver is installed, +// Serial::Write falls back to the ROM serial output so early-boot logs survive. +extern "C" int openshock_log_printf(const char* fmt, ...) +{ + va_list args; + va_start(args, fmt); + int len = OpenShock::Serial::VWritef(fmt, args); + va_end(args); + + return len; +} diff --git a/components/osjson/CMakeLists.txt b/components/osjson/CMakeLists.txt new file mode 100644 index 00000000..ef58553f --- /dev/null +++ b/components/osjson/CMakeLists.txt @@ -0,0 +1,17 @@ +# OpenShock JSON handling. +# +# Zero-copy parsing built on raw jsmn (values are std::string_view into the +# source buffer — no NUL-termination, no strlen) plus streaming generation via +# json_generator. Deliberately not named "json" to avoid shadowing IDF's +# built-in cJSON component. +# +# Public surface: +# json/Json.h — OpenShock::JSON::{JsonView, JsonDocument, StringWriter} + +idf_component_register( + SRCS "src/Json.cpp" + INCLUDE_DIRS "include" + REQUIRES espressif__jsmn # jsmn.h types in public header + espressif__json_generator # json_generator.h in public header + PRIV_REQUIRES common # OpenShock::Convert strict string->int (Json.cpp) +) diff --git a/components/osjson/host_test/CMakeLists.txt b/components/osjson/host_test/CMakeLists.txt new file mode 100644 index 00000000..af0c84fa --- /dev/null +++ b/components/osjson/host_test/CMakeLists.txt @@ -0,0 +1,14 @@ +cmake_minimum_required(VERSION 3.16) + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) + +# Only build 'main'; it compiles osjson's Json.cpp directly (see main/CMakeLists.txt). +set(COMPONENTS main) + +# linux-target freertos mock, so unity's IDF integration has its dependencies, and +# the shared host-test stand-ins (Logging.h, openshock_board.h, CONFIG_OPENSHOCK_*). +list(APPEND EXTRA_COMPONENT_DIRS + "$ENV{IDF_PATH}/tools/mocks/freertos/" + "${CMAKE_CURRENT_LIST_DIR}/../../../test/host_support") + +project(osjson_host_test) diff --git a/components/osjson/host_test/main/CMakeLists.txt b/components/osjson/host_test/main/CMakeLists.txt new file mode 100644 index 00000000..a5802e25 --- /dev/null +++ b/components/osjson/host_test/main/CMakeLists.txt @@ -0,0 +1,25 @@ +# Compile osjson's Json.cpp (and the common sources it uses for strict string->int +# conversion) directly, rather than linking the osjson component: osjson -> common -> +# logging -> app_update, which has no linux port. jsmn / json_generator come from +# idf_component.yml, the same versions osjson declares. +idf_component_register( + SRCS "test_json.cpp" # main() entry + smoke tests + "test_parse.cpp" # JsonDocument::parse + root() + "test_getters.cpp" # tryGetStr/Bool/I64/Double + predicates + "test_navigation.cpp" # operator[], at(), count(), raw(), skip() + "test_generate.cpp" # StringWriter / json_generator output + "test_stress.cpp" # scale, token-cap, deep nesting + "../../src/Json.cpp" + "../../../common/src/Convert.cpp" + "../../../common/src/DigitCounter.cpp" + "../../../common/src/StringHelpers.cpp" + INCLUDE_DIRS "." + "../../include" # json/Json.h + "../../../common/include" # Convert.h, StringHelpers.h, util/DigitCounter.h + REQUIRES unity + host_support # Logging.h, openshock_board.h (test/host_support) + esp_hal_gpio # Convert.h -> hal/gpio_types.h + espressif__jsmn + espressif__json_generator + WHOLE_ARCHIVE # keep the auto-registered TEST_CASEs from being stripped +) diff --git a/components/osjson/host_test/main/idf_component.yml b/components/osjson/host_test/main/idf_component.yml new file mode 100644 index 00000000..aafedf2d --- /dev/null +++ b/components/osjson/host_test/main/idf_component.yml @@ -0,0 +1,5 @@ +# osjson's Json.cpp is compiled into main (see CMakeLists.txt), so main needs the +# same espressif components osjson declares. +dependencies: + espressif/jsmn: "^1.2.0" + espressif/json_generator: "^2.0.0" diff --git a/components/osjson/host_test/main/test_generate.cpp b/components/osjson/host_test/main/test_generate.cpp new file mode 100644 index 00000000..58c4d905 --- /dev/null +++ b/components/osjson/host_test/main/test_generate.cpp @@ -0,0 +1,319 @@ +// StringWriter / json_generator output: shape, escaping, flush boundary. +#include "unity.h" + +#include "json/Json.h" + +#include +#include + +using namespace OpenShock; + +TEST_CASE("generate: empty object and empty array", "[osjson][gen]") +{ + { + JSON::StringWriter w; + json_gen_start_object(w.gen()); + json_gen_end_object(w.gen()); + TEST_ASSERT_TRUE(w.finish() == "{}"); + } + { + JSON::StringWriter w; + json_gen_start_array(w.gen()); + json_gen_end_array(w.gen()); + TEST_ASSERT_TRUE(w.finish() == "[]"); + } +} + +TEST_CASE("generate: flat object with mixed scalar types", "[osjson][gen]") +{ + JSON::StringWriter w; + json_gen_str_t* g = w.gen(); + json_gen_start_object(g); + json_gen_obj_set_string(g, "s", "hi"); + json_gen_obj_set_int(g, "n", -7); + json_gen_obj_set_bool(g, "b", true); + json_gen_obj_set_null(g, "z"); + json_gen_end_object(g); + + TEST_ASSERT_TRUE(w.finish() == R"({"s":"hi","n":-7,"b":true,"z":null})"); +} + +TEST_CASE("generate: nested objects and arrays", "[osjson][gen]") +{ + JSON::StringWriter w; + json_gen_str_t* g = w.gen(); + json_gen_start_object(g); + json_gen_push_object(g, "outer"); + json_gen_obj_set_int(g, "x", 1); + json_gen_push_array(g, "list"); + json_gen_arr_set_int(g, 10); + json_gen_arr_set_int(g, 20); + json_gen_pop_array(g); + json_gen_pop_object(g); + json_gen_end_object(g); + + TEST_ASSERT_TRUE(w.finish() == R"({"outer":{"x":1,"list":[10,20]}})"); +} + +TEST_CASE("generate: array of objects", "[osjson][gen]") +{ + JSON::StringWriter w; + json_gen_str_t* g = w.gen(); + json_gen_start_array(g); + for (int i = 1; i <= 3; ++i) { + json_gen_start_object(g); + json_gen_obj_set_int(g, "id", i); + json_gen_end_object(g); + } + json_gen_end_array(g); + + TEST_ASSERT_TRUE(w.finish() == R"([{"id":1},{"id":2},{"id":3}])"); +} + +// json_generator 2.x escapes string values itself (RFC 8259 section 7), which is +// why JSON::objSetString / arrSetString must hand it the plain text: escaping +// there as well would escape twice. The two tests below pin the library side. +TEST_CASE("generate: raw json_gen_obj_set_string escapes quotes/backslashes", "[osjson][gen][raw]") +{ + JSON::StringWriter w; + json_gen_str_t* g = w.gen(); + json_gen_start_object(g); + json_gen_obj_set_string(g, "k", R"(a"b\c)"); // value bytes: a " b \ c + json_gen_end_object(g); + + TEST_ASSERT_TRUE(w.finish() == R"({"k":"a\"b\\c"})"); +} + +TEST_CASE("generate: raw json_gen_obj_set_string escapes control chars", "[osjson][gen][raw]") +{ + JSON::StringWriter w; + json_gen_str_t* g = w.gen(); + json_gen_start_object(g); + json_gen_obj_set_string(g, "k", "line1\nline2\ttab"); + json_gen_end_object(g); + + TEST_ASSERT_TRUE(w.finish() == R"({"k":"line1\nline2\ttab"})"); +} + +// ---- JSON::objSetString / arrSetString: escaped exactly once ---- + +TEST_CASE("escape: objSetString escapes quotes and backslashes", "[osjson][gen][escape]") +{ + JSON::StringWriter w; + json_gen_str_t* g = w.gen(); + json_gen_start_object(g); + JSON::objSetString(g, "k", R"(a"b\c)"); // value bytes: a " b \ c + json_gen_end_object(g); + + std::string out = w.finish(); + TEST_ASSERT_TRUE(out == R"({"k":"a\"b\\c"})"); + + // escaped exactly once: valid JSON that decodes back to the original bytes + JSON::JsonDocument doc; + TEST_ASSERT_TRUE(doc.parse(out)); + TEST_ASSERT_TRUE(doc.root().isObject()); + std::string back; + TEST_ASSERT_TRUE(doc.root()["k"].tryGetStr(back)); + TEST_ASSERT_TRUE(back == R"(a"b\c)"); +} + +TEST_CASE("escape: objSetString escapes the short-form control chars", "[osjson][gen][escape]") +{ + JSON::StringWriter w; + json_gen_str_t* g = w.gen(); + json_gen_start_object(g); + JSON::objSetString(g, "k", "\n\t\r\b\f"); // LF TAB CR BS FF + json_gen_end_object(g); + + TEST_ASSERT_TRUE(w.finish() == R"({"k":"\n\t\r\b\f"})"); +} + +TEST_CASE("escape: objSetString \\u-escapes other control chars", "[osjson][gen][escape]") +{ + std::string value; + value += '\x01'; + value += '\x1f'; + + JSON::StringWriter w; + json_gen_str_t* g = w.gen(); + json_gen_start_object(g); + JSON::objSetString(g, "k", value); + json_gen_end_object(g); + + TEST_ASSERT_TRUE(w.finish() == "{\"k\":\"\\u0001\\u001f\"}"); +} + +TEST_CASE("escape: RFC 8259 boundaries (NUL, solidus, DEL, 0x1F/0x20)", "[osjson][gen][escape]") +{ + // U+0000 must be escaped as 0000 (embedded NUL survives via string_view). + { + std::string v; + v += '\0'; + JSON::StringWriter w; + json_gen_str_t* g = w.gen(); + json_gen_start_object(g); + JSON::objSetString(g, "k", v); + json_gen_end_object(g); + TEST_ASSERT_TRUE(w.finish() == "{\"k\":\"\\u0000\"}"); + } + // '/' (0x2F) is NOT required to be escaped -> passthrough. + { + JSON::StringWriter w; + json_gen_str_t* g = w.gen(); + json_gen_start_object(g); + JSON::objSetString(g, "k", "a/b"); + json_gen_end_object(g); + TEST_ASSERT_TRUE(w.finish() == R"({"k":"a/b"})"); + } + // 0x1F is the last control char (escaped); 0x20 (space) and 0x7F (DEL) are + // both in the RFC `unescaped` set -> passthrough. + { + std::string v; + v += '\x1f'; + v += ' '; + v += '\x7f'; + JSON::StringWriter w; + json_gen_str_t* g = w.gen(); + json_gen_start_object(g); + JSON::objSetString(g, "k", v); + json_gen_end_object(g); + TEST_ASSERT_TRUE(w.finish() == "{\"k\":\"\\u001f \x7f\"}"); + } +} + +TEST_CASE("escape: plain and UTF-8 strings pass through unchanged", "[osjson][gen][escape]") +{ + { + JSON::StringWriter w; + json_gen_str_t* g = w.gen(); + json_gen_start_object(g); + JSON::objSetString(g, "k", "hello world"); + json_gen_end_object(g); + TEST_ASSERT_TRUE(w.finish() == R"({"k":"hello world"})"); + } + { + // UTF-8 multibyte (bytes >= 0x20) must not be touched. + JSON::StringWriter w; + json_gen_str_t* g = w.gen(); + json_gen_start_object(g); + JSON::objSetString(g, "k", "caf\xc3\xa9"); // "café" + json_gen_end_object(g); + TEST_ASSERT_TRUE(w.finish() == "{\"k\":\"caf\xc3\xa9\"}"); + } +} + +TEST_CASE("escape: arrSetString escapes array string elements", "[osjson][gen][escape]") +{ + JSON::StringWriter w; + json_gen_str_t* g = w.gen(); + json_gen_start_array(g); + JSON::arrSetString(g, R"(a"b)"); + JSON::arrSetString(g, "plain"); + json_gen_end_array(g); + + std::string out = w.finish(); + TEST_ASSERT_TRUE(out == R"(["a\"b","plain"])"); + + JSON::JsonDocument doc; + TEST_ASSERT_TRUE(doc.parse(out)); + TEST_ASSERT_EQUAL_INT(2, doc.root().count()); +} + +TEST_CASE("escape: a WiFi password with a quote serializes to valid JSON", "[osjson][gen][escape]") +{ + // The real-world motivation: a password containing a double quote. + JSON::StringWriter w; + json_gen_str_t* g = w.gen(); + json_gen_start_object(g); + JSON::objSetString(g, "ssid", "Home"); + JSON::objSetString(g, "password", R"(p@ss"word\)"); + json_gen_end_object(g); + std::string out = w.finish(); + + JSON::JsonDocument doc; + TEST_ASSERT_TRUE(doc.parse(out)); // would be malformed via the raw call + JSON::JsonView root = doc.root(); + + std::string ssid; + TEST_ASSERT_TRUE(root["ssid"].tryGetStr(ssid)); + TEST_ASSERT_TRUE(ssid == "Home"); + // the password decodes back to exactly what was written + std::string pw; + TEST_ASSERT_TRUE(root["password"].tryGetStr(pw)); + TEST_ASSERT_TRUE(pw == R"(p@ss"word\)"); +} + +TEST_CASE("generate: value larger than the 256-byte flush buffer", "[osjson][gen]") +{ + // Forces StringWriter::flushCb to run several times; output must be intact. + std::string big(1000, 'x'); + + JSON::StringWriter w; + json_gen_str_t* g = w.gen(); + json_gen_start_object(g); + json_gen_obj_set_string(g, "data", big.c_str()); + json_gen_end_object(g); + std::string out = w.finish(); + + std::string expected = "{\"data\":\"" + big + "\"}"; + TEST_ASSERT_EQUAL_size_t(expected.size(), out.size()); + TEST_ASSERT_TRUE(out == expected); +} + +TEST_CASE("generate -> parse round-trip preserves scalar values", "[osjson][gen]") +{ + std::string out; + { + JSON::StringWriter w; + json_gen_str_t* g = w.gen(); + json_gen_start_object(g); + json_gen_obj_set_string(g, "ssid", "net"); + json_gen_obj_set_int(g, "id", 7); + json_gen_obj_set_bool(g, "on", true); + json_gen_push_array(g, "nums"); + json_gen_arr_set_int(g, 1); + json_gen_arr_set_int(g, 2); + json_gen_arr_set_int(g, 3); + json_gen_pop_array(g); + json_gen_end_object(g); + out = w.finish(); + } + + JSON::JsonDocument doc; + TEST_ASSERT_TRUE(doc.parse(out)); + JSON::JsonView root = doc.root(); + + std::string ssid; + TEST_ASSERT_TRUE(root["ssid"].tryGetStr(ssid)); + TEST_ASSERT_TRUE(ssid == "net"); + + int64_t id = 0; + TEST_ASSERT_TRUE(root["id"].tryGetI64(id)); + TEST_ASSERT_EQUAL_INT64(7, id); + + bool on = false; + TEST_ASSERT_TRUE(root["on"].tryGetBool(on)); + TEST_ASSERT_TRUE(on); + + TEST_ASSERT_EQUAL_INT(3, root["nums"].count()); + int64_t sum = 0, v = 0; + for (int i = 0; i < root["nums"].count(); ++i) { + TEST_ASSERT_TRUE(root["nums"].at(i).tryGetI64(v)); + sum += v; + } + TEST_ASSERT_EQUAL_INT64(6, sum); +} + +TEST_CASE("finish: malformed UTF-8 yields an empty string, never a truncated document", "[osjson][gen]") +{ + JSON::StringWriter w; + json_gen_str_t* gen = w.gen(); + json_gen_start_object(gen); + // Long enough that earlier chunks are flushed before the bad byte is reached. + JSON::objSetString(gen, "pad", std::string(600, 'x')); + JSON::objSetString(gen, "ssid", std::string_view("Caf\xE9", 4)); // Latin-1, not UTF-8 + json_gen_end_object(gen); + + TEST_ASSERT_TRUE(w.finish().empty()); + TEST_ASSERT_TRUE(w.failed()); +} diff --git a/components/osjson/host_test/main/test_getters.cpp b/components/osjson/host_test/main/test_getters.cpp new file mode 100644 index 00000000..ed68f40c --- /dev/null +++ b/components/osjson/host_test/main/test_getters.cpp @@ -0,0 +1,366 @@ +// Typed getters (tryGetStr/Bool/I64/Double) and type predicates. +#include "unity.h" + +#include "json/Json.h" + +#include +#include +#include +#include + +using namespace OpenShock; + +// ---- tryGetStr ------------------------------------------------------------- + +TEST_CASE("tryGetStr: strings succeed, non-strings fail", "[osjson][getters]") +{ + static const char json[] = R"({"s":"hi","n":5,"b":true,"z":null,"o":{},"a":[]})"; + JSON::JsonDocument doc; + TEST_ASSERT_TRUE(doc.parse(json)); + JSON::JsonView root = doc.root(); + + std::string sv; + TEST_ASSERT_TRUE(root["s"].tryGetStr(sv)); + TEST_ASSERT_TRUE(sv == "hi"); + + TEST_ASSERT_FALSE(root["n"].tryGetStr(sv)); + TEST_ASSERT_FALSE(root["b"].tryGetStr(sv)); + TEST_ASSERT_FALSE(root["z"].tryGetStr(sv)); + TEST_ASSERT_FALSE(root["o"].tryGetStr(sv)); + TEST_ASSERT_FALSE(root["a"].tryGetStr(sv)); + TEST_ASSERT_FALSE(root["missing"].tryGetStr(sv)); +} + +TEST_CASE("tryGetStr: empty string value", "[osjson][getters]") +{ + JSON::JsonDocument doc; + TEST_ASSERT_TRUE(doc.parse(R"({"s":""})")); + std::string sv = "stale"; + TEST_ASSERT_TRUE(doc.root()["s"].tryGetStr(sv)); + TEST_ASSERT_EQUAL_size_t(0, sv.size()); + TEST_ASSERT_TRUE(sv.empty()); +} + +TEST_CASE("tryGetStr: escape sequences are decoded, raw() keeps them", "[osjson][getters][unescape]") +{ + static const char json[] = R"({"s":"a\nb\"c"})"; + JSON::JsonDocument doc; + TEST_ASSERT_TRUE(doc.parse(json)); + std::string s; + TEST_ASSERT_TRUE(doc.root()["s"].tryGetStr(s)); + TEST_ASSERT_TRUE(s == "a\nb\"c"); // a real newline and quote + TEST_ASSERT_EQUAL_size_t(5, s.size()); + TEST_ASSERT_TRUE(doc.root()["s"].raw() == R"(a\nb\"c)"); // the token text, untouched +} + +TEST_CASE("tryGetStr: every two-character escape", "[osjson][getters][unescape]") +{ + JSON::JsonDocument doc; + TEST_ASSERT_TRUE(doc.parse(R"({"s":"\"\\\/\b\f\n\r\t"})")); + std::string s; + TEST_ASSERT_TRUE(doc.root()["s"].tryGetStr(s)); + TEST_ASSERT_TRUE(s == "\"\\/\b\f\n\r\t"); +} + +TEST_CASE("tryGetStr: \\u escapes decode to UTF-8", "[osjson][getters][unescape]") +{ + struct Case { + const char* json; + std::string expected; + }; + // clang-format off + const Case cases[] = { + {"{\"s\":\"\\u0041\"}", "A"}, // 1 byte + {"{\"s\":\"\\u00e9\"}", "\xc3\xa9"}, // 2 bytes, lowercase hex + {"{\"s\":\"\\u00E9\"}", "\xc3\xa9"}, // uppercase hex + {"{\"s\":\"\\u20AC\"}", "\xe2\x82\xac"}, // 3 bytes (euro sign) + {"{\"s\":\"\\uFFFF\"}", "\xef\xbf\xbf"}, // top of the BMP + {"{\"s\":\"\\u001f\"}", "\x1f"}, // control character + {"{\"s\":\"x\\u0022y\"}", "x\"y"}, // quote spelled as \u + {"{\"s\":\"\\ud83d\\ude00\"}", "\xf0\x9f\x98\x80"}, // surrogate pair, U+1F600 + {"{\"s\":\"\\uDBFF\\uDFFF\"}", "\xf4\x8f\xbf\xbf"}, // highest pair, U+10FFFF + {"{\"s\":\"caf\\u00e9 \\u20ac5\"}", "caf\xc3\xa9 \xe2\x82\xac\x35"}, + }; + // clang-format on + + for (const Case& c : cases) { + JSON::JsonDocument doc; + TEST_ASSERT_TRUE_MESSAGE(doc.parse(c.json), c.json); + std::string s; + TEST_ASSERT_TRUE_MESSAGE(doc.root()["s"].tryGetStr(s), c.json); + TEST_ASSERT_TRUE_MESSAGE(s == c.expected, c.json); + } +} + +TEST_CASE("tryGetStr: \\u0000 decodes to an embedded NUL", "[osjson][getters][unescape]") +{ + JSON::JsonDocument doc; + TEST_ASSERT_TRUE(doc.parse(R"({"s":"a\u0000b"})")); + std::string s; + TEST_ASSERT_TRUE(doc.root()["s"].tryGetStr(s)); + TEST_ASSERT_EQUAL_size_t(3, s.size()); + TEST_ASSERT_TRUE(s == std::string("a\0b", 3)); +} + +TEST_CASE("tryGetStr: unpaired surrogates decode to U+FFFD", "[osjson][getters][unescape]") +{ + struct Case { + const char* json; + std::string expected; + }; + // clang-format off + const Case cases[] = { + {"{\"s\":\"\\ud83d\"}", "\xef\xbf\xbd"}, // high surrogate at the end + {"{\"s\":\"\\ud83dx\"}", "\xef\xbf\xbd\x78"}, // followed by a plain character ('x') + {"{\"s\":\"\\ud83d\\u0041\"}", "\xef\xbf\xbd\x41"}, // followed by a non-surrogate escape ('A') + {"{\"s\":\"\\ude00\"}", "\xef\xbf\xbd"}, // low surrogate on its own + {"{\"s\":\"\\ud83d\\ud83d\"}", "\xef\xbf\xbd\xef\xbf\xbd"}, // two high surrogates + }; + // clang-format on + + for (const Case& c : cases) { + JSON::JsonDocument doc; + TEST_ASSERT_TRUE_MESSAGE(doc.parse(c.json), c.json); + std::string s; + TEST_ASSERT_TRUE_MESSAGE(doc.root()["s"].tryGetStr(s), c.json); + TEST_ASSERT_TRUE_MESSAGE(s == c.expected, c.json); + } +} + +TEST_CASE("tryGetStr: malformed escapes fail and leave the output untouched", "[osjson][getters][unescape]") +{ + // jsmn's default (non-strict) mode may tokenize these; the decoder must reject them. + const char* inputs[] = { + R"({"s":"\x41"})", + R"({"s":"\u12G4"})", + R"({"s":"\u12"})", + }; + + for (const char* input : inputs) { + JSON::JsonDocument doc; + if (!doc.parse(input)) { + continue; // already rejected by the tokenizer, which is fine too + } + std::string s = "unchanged"; + TEST_ASSERT_FALSE_MESSAGE(doc.root()["s"].tryGetStr(s), input); + TEST_ASSERT_TRUE_MESSAGE(s == "unchanged", input); + } +} + +TEST_CASE("operator[]: keys written with escapes still match", "[osjson][getters][unescape]") +{ + JSON::JsonDocument doc; + TEST_ASSERT_TRUE(doc.parse(R"({"\u0069d":7,"a\"b":"q"})")); + int64_t id = 0; + TEST_ASSERT_TRUE(doc.root()["id"].tryGetI64(id)); + TEST_ASSERT_EQUAL_INT64(7, id); + std::string s; + TEST_ASSERT_TRUE(doc.root()["a\"b"].tryGetStr(s)); + TEST_ASSERT_TRUE(s == "q"); +} + +TEST_CASE("generate -> parse round trip restores every byte value", "[osjson][getters][unescape]") +{ + // All of ASCII (control characters, quote, backslash, DEL) plus multi-byte UTF-8. + std::string value; + for (int c = 0; c < 0x80; ++c) value += static_cast(c); + value += "caf\xc3\xa9 \xe2\x82\xac \xf0\x9f\x98\x80"; + + JSON::StringWriter w; + json_gen_str_t* g = w.gen(); + json_gen_start_object(g); + JSON::objSetString(g, "k", value); + json_gen_end_object(g); + const std::string out = w.finish(); + + JSON::JsonDocument doc; + TEST_ASSERT_TRUE(doc.parse(out)); + std::string back; + TEST_ASSERT_TRUE(doc.root()["k"].tryGetStr(back)); + TEST_ASSERT_EQUAL_size_t(value.size(), back.size()); + TEST_ASSERT_TRUE(back == value); +} + +// ---- tryGetBool ------------------------------------------------------------ + +TEST_CASE("tryGetBool: true/false succeed, everything else fails", "[osjson][getters]") +{ + static const char json[] = R"({"t":true,"f":false,"n":1,"s":"true","z":null})"; + JSON::JsonDocument doc; + TEST_ASSERT_TRUE(doc.parse(json)); + JSON::JsonView root = doc.root(); + + bool b = false; + TEST_ASSERT_TRUE(root["t"].tryGetBool(b)); + TEST_ASSERT_TRUE(b); + TEST_ASSERT_TRUE(root["f"].tryGetBool(b)); + TEST_ASSERT_FALSE(b); + + TEST_ASSERT_FALSE(root["n"].tryGetBool(b)); // number + TEST_ASSERT_FALSE(root["s"].tryGetBool(b)); // the STRING "true" + TEST_ASSERT_FALSE(root["z"].tryGetBool(b)); // null + TEST_ASSERT_FALSE(root["missing"].tryGetBool(b)); +} + +// ---- tryGetI64 ------------------------------------------------------------- + +TEST_CASE("tryGetI64: valid integers including boundaries", "[osjson][getters]") +{ + static const char json[] = R"({"zero":0,"pos":123,"neg":-123,"i32over":2147483648,)" + R"("max":9223372036854775807,"min":-9223372036854775808})"; + JSON::JsonDocument doc; + TEST_ASSERT_TRUE(doc.parse(json)); + JSON::JsonView root = doc.root(); + + int64_t v = 0; + TEST_ASSERT_TRUE(root["zero"].tryGetI64(v)); + TEST_ASSERT_EQUAL_INT64(0, v); + TEST_ASSERT_TRUE(root["pos"].tryGetI64(v)); + TEST_ASSERT_EQUAL_INT64(123, v); + TEST_ASSERT_TRUE(root["neg"].tryGetI64(v)); + TEST_ASSERT_EQUAL_INT64(-123, v); + TEST_ASSERT_TRUE(root["i32over"].tryGetI64(v)); + TEST_ASSERT_EQUAL_INT64(2147483648LL, v); + TEST_ASSERT_TRUE(root["max"].tryGetI64(v)); + TEST_ASSERT_EQUAL_INT64(INT64_MAX, v); + TEST_ASSERT_TRUE(root["min"].tryGetI64(v)); + TEST_ASSERT_EQUAL_INT64(INT64_MIN, v); +} + +TEST_CASE("tryGetI64: rejects non-integers and partial parses", "[osjson][getters]") +{ + static const char json[] = R"({"flt":1.5,"exp":"1e3","over":9223372036854775808,)" + R"("plus":"+5","hex":"0x1F","word":"abc","s":"42","b":true,"z":null})"; + // NOTE: exp/plus/hex/word/s are quoted so they are valid JSON string tokens; + // tryGetI64 must still reject them because they are not numbers. + JSON::JsonDocument doc; + TEST_ASSERT_TRUE(doc.parse(json)); + JSON::JsonView root = doc.root(); + + int64_t v = 0; + TEST_ASSERT_FALSE(root["flt"].tryGetI64(v)); // 1.5 -> '.' is not a digit + TEST_ASSERT_FALSE(root["over"].tryGetI64(v)); // overflows int64 + TEST_ASSERT_FALSE(root["exp"].tryGetI64(v)); // string, not a number + TEST_ASSERT_FALSE(root["plus"].tryGetI64(v)); + TEST_ASSERT_FALSE(root["hex"].tryGetI64(v)); + TEST_ASSERT_FALSE(root["word"].tryGetI64(v)); + TEST_ASSERT_FALSE(root["s"].tryGetI64(v)); + TEST_ASSERT_FALSE(root["b"].tryGetI64(v)); + TEST_ASSERT_FALSE(root["z"].tryGetI64(v)); + TEST_ASSERT_FALSE(root["missing"].tryGetI64(v)); +} + +// ---- tryGetDouble ---------------------------------------------------------- + +static bool nearly(double a, double b) +{ + return std::fabs(a - b) < 1e-9; +} + +TEST_CASE("tryGetDouble: accepts ints, decimals and exponents", "[osjson][getters]") +{ + static const char json[] = R"({"i":42,"d":3.14,"neg":-2.5,"e":1e3,"eneg":1.5e-2,"zero":0.0})"; + JSON::JsonDocument doc; + TEST_ASSERT_TRUE(doc.parse(json)); + JSON::JsonView root = doc.root(); + + double d = 0; + TEST_ASSERT_TRUE(root["i"].tryGetDouble(d)); + TEST_ASSERT_TRUE(nearly(42.0, d)); + TEST_ASSERT_TRUE(root["d"].tryGetDouble(d)); + TEST_ASSERT_TRUE(nearly(3.14, d)); + TEST_ASSERT_TRUE(root["neg"].tryGetDouble(d)); + TEST_ASSERT_TRUE(nearly(-2.5, d)); + TEST_ASSERT_TRUE(root["e"].tryGetDouble(d)); + TEST_ASSERT_TRUE(nearly(1000.0, d)); + TEST_ASSERT_TRUE(root["eneg"].tryGetDouble(d)); + TEST_ASSERT_TRUE(nearly(0.015, d)); + TEST_ASSERT_TRUE(root["zero"].tryGetDouble(d)); + TEST_ASSERT_TRUE(nearly(0.0, d)); +} + +TEST_CASE("tryGetDouble: rejects non-numbers", "[osjson][getters]") +{ + static const char json[] = R"({"s":"3.14","b":true,"z":null,"o":{},"a":[]})"; + JSON::JsonDocument doc; + TEST_ASSERT_TRUE(doc.parse(json)); + JSON::JsonView root = doc.root(); + + double d = 0; + TEST_ASSERT_FALSE(root["s"].tryGetDouble(d)); + TEST_ASSERT_FALSE(root["b"].tryGetDouble(d)); + TEST_ASSERT_FALSE(root["z"].tryGetDouble(d)); + TEST_ASSERT_FALSE(root["o"].tryGetDouble(d)); + TEST_ASSERT_FALSE(root["a"].tryGetDouble(d)); + TEST_ASSERT_FALSE(root["missing"].tryGetDouble(d)); +} + +TEST_CASE("tryGetDouble: number at/over the 64-byte stack-copy limit", "[osjson][getters]") +{ + // 63-char number fits the internal buf[64]; 64-char number is rejected. + std::string n63(63, '9'); + std::string n64(64, '9'); + std::string json = "{\"a\":" + n63 + ",\"b\":" + n64 + "}"; + + JSON::JsonDocument doc; + TEST_ASSERT_TRUE(doc.parse(json)); + JSON::JsonView root = doc.root(); + + double d = 0; + TEST_ASSERT_TRUE(root["a"].tryGetDouble(d)); // fits + TEST_ASSERT_FALSE(root["b"].tryGetDouble(d)); // too long -> rejected, not truncated +} + +// ---- type predicates ------------------------------------------------------- + +TEST_CASE("predicates: exactly one container/leaf kind per value", "[osjson][getters]") +{ + static const char json[] = R"({"o":{},"a":[],"s":"x","n":5,"t":true,"z":null})"; + JSON::JsonDocument doc; + TEST_ASSERT_TRUE(doc.parse(json)); + JSON::JsonView root = doc.root(); + + TEST_ASSERT_TRUE(root["o"].isObject()); + TEST_ASSERT_TRUE(root["a"].isArray()); + TEST_ASSERT_TRUE(root["s"].isString()); + + // number: primitive + isNumber, but not null/bool-ish + TEST_ASSERT_TRUE(root["n"].isPrimitive()); + TEST_ASSERT_TRUE(root["n"].isNumber()); + TEST_ASSERT_FALSE(root["n"].isNull()); + + // true: primitive but NOT a number + TEST_ASSERT_TRUE(root["t"].isPrimitive()); + TEST_ASSERT_FALSE(root["t"].isNumber()); + TEST_ASSERT_FALSE(root["t"].isNull()); + + // null: primitive, isNull, not a number + TEST_ASSERT_TRUE(root["z"].isPrimitive()); + TEST_ASSERT_TRUE(root["z"].isNull()); + TEST_ASSERT_FALSE(root["z"].isNumber()); +} + +TEST_CASE("predicates: an invalid/default view answers false everywhere", "[osjson][getters]") +{ + JSON::JsonView v; // default-constructed + TEST_ASSERT_FALSE(v.valid()); + TEST_ASSERT_FALSE(v.isObject()); + TEST_ASSERT_FALSE(v.isArray()); + TEST_ASSERT_FALSE(v.isString()); + TEST_ASSERT_FALSE(v.isPrimitive()); + TEST_ASSERT_FALSE(v.isNumber()); + TEST_ASSERT_FALSE(v.isNull()); + TEST_ASSERT_EQUAL_INT(0, v.count()); + TEST_ASSERT_FALSE(v.at(0).valid()); + TEST_ASSERT_FALSE(v["k"].valid()); + TEST_ASSERT_TRUE(v.raw().empty()); + + std::string sv; + int64_t i; + double d; + bool b; + TEST_ASSERT_FALSE(v.tryGetStr(sv)); + TEST_ASSERT_FALSE(v.tryGetI64(i)); + TEST_ASSERT_FALSE(v.tryGetDouble(d)); + TEST_ASSERT_FALSE(v.tryGetBool(b)); +} diff --git a/components/osjson/host_test/main/test_json.cpp b/components/osjson/host_test/main/test_json.cpp new file mode 100644 index 00000000..0e1c2871 --- /dev/null +++ b/components/osjson/host_test/main/test_json.cpp @@ -0,0 +1,135 @@ +// Host (linux target) unit tests for the osjson component. +// +// cd components/osjson/host_test +// idf.py --preview set-target linux +// idf.py build +// ./build/osjson_host_test.elf +// +#include "unity.h" + +#include "json/Json.h" + +#include +#include +#include + +using namespace OpenShock; + +TEST_CASE("parse scalar object", "[osjson]") +{ + static const char json[] = R"({"name":"hello","n":42,"flag":true,"neg":-7})"; + + JSON::JsonDocument doc; + TEST_ASSERT_TRUE(doc.parse(json)); + + JSON::JsonView root = doc.root(); + TEST_ASSERT_TRUE(root.isObject()); + + std::string s; + TEST_ASSERT_TRUE(root["name"].tryGetStr(s)); + TEST_ASSERT_EQUAL_size_t(5, s.size()); + TEST_ASSERT_TRUE(s == "hello"); + // Zero-copy: raw() must point straight into the source buffer. + std::string_view raw = root["name"].raw(); + TEST_ASSERT_TRUE(raw.data() >= json && raw.data() < json + sizeof(json)); + + int64_t n = 0; + TEST_ASSERT_TRUE(root["n"].tryGetI64(n)); + TEST_ASSERT_EQUAL_INT64(42, n); + + int64_t neg = 0; + TEST_ASSERT_TRUE(root["neg"].tryGetI64(neg)); + TEST_ASSERT_EQUAL_INT64(-7, neg); + + bool flag = false; + TEST_ASSERT_TRUE(root["flag"].tryGetBool(flag)); + TEST_ASSERT_TRUE(flag); +} + +TEST_CASE("missing key and type mismatch", "[osjson]") +{ + static const char json[] = R"({"name":"x","n":5})"; + + JSON::JsonDocument doc; + TEST_ASSERT_TRUE(doc.parse(json)); + JSON::JsonView root = doc.root(); + + TEST_ASSERT_FALSE(root["missing"].valid()); + + bool b = false; + TEST_ASSERT_FALSE(root["name"].tryGetBool(b)); // string, not bool + int64_t n = 0; + TEST_ASSERT_FALSE(root["name"].tryGetI64(n)); // string, not number + std::string s; + TEST_ASSERT_FALSE(root["n"].tryGetStr(s)); // number, not string +} + +TEST_CASE("nested objects and arrays", "[osjson]") +{ + static const char json[] = R"({"obj":{"x":1,"y":2},"arr":[10,20,30]})"; + + JSON::JsonDocument doc; + TEST_ASSERT_TRUE(doc.parse(json)); + JSON::JsonView root = doc.root(); + + int64_t y = 0; + TEST_ASSERT_TRUE(root["obj"]["y"].tryGetI64(y)); + TEST_ASSERT_EQUAL_INT64(2, y); + + JSON::JsonView arr = root["arr"]; + TEST_ASSERT_TRUE(arr.isArray()); + TEST_ASSERT_EQUAL_INT(3, arr.count()); + + int64_t v = 0; + TEST_ASSERT_TRUE(arr.at(1).tryGetI64(v)); + TEST_ASSERT_EQUAL_INT64(20, v); + TEST_ASSERT_FALSE(arr.at(3).valid()); // out of range +} + +TEST_CASE("malformed input is rejected", "[osjson]") +{ + JSON::JsonDocument doc; + TEST_ASSERT_FALSE(doc.parse("{ not valid json")); + TEST_ASSERT_FALSE(doc.parse("")); + TEST_ASSERT_FALSE(doc.root().valid()); +} + +TEST_CASE("generate then parse round-trip", "[osjson]") +{ + std::string out; + { + JSON::StringWriter writer; + json_gen_str_t* gen = writer.gen(); + json_gen_start_object(gen); + json_gen_obj_set_string(gen, "ssid", "net"); + json_gen_obj_set_int(gen, "id", 7); + json_gen_obj_set_bool(gen, "on", true); + json_gen_end_object(gen); + out = writer.finish(); + } + + JSON::JsonDocument doc; + TEST_ASSERT_TRUE(doc.parse(out)); + JSON::JsonView root = doc.root(); + + std::string ssid; + TEST_ASSERT_TRUE(root["ssid"].tryGetStr(ssid)); + TEST_ASSERT_TRUE(ssid == "net"); + + int64_t id = 0; + TEST_ASSERT_TRUE(root["id"].tryGetI64(id)); + TEST_ASSERT_EQUAL_INT64(7, id); + + bool on = false; + TEST_ASSERT_TRUE(root["on"].tryGetBool(on)); + TEST_ASSERT_TRUE(on); +} + +// Host-only (linux target) test binary. FreeRTOS is mocked, so there is no ESP +// startup to call app_main - provide a plain main() that drives Unity directly. +extern "C" int main(void) +{ + UNITY_BEGIN(); + unity_run_all_tests(); + return UNITY_END(); +} diff --git a/components/osjson/host_test/main/test_navigation.cpp b/components/osjson/host_test/main/test_navigation.cpp new file mode 100644 index 00000000..d0dd70b0 --- /dev/null +++ b/components/osjson/host_test/main/test_navigation.cpp @@ -0,0 +1,185 @@ +// Object member lookup, array indexing, count(), raw(), and skip() correctness. +#include "unity.h" + +#include "json/Json.h" + +#include +#include +#include + +using namespace OpenShock; + +TEST_CASE("operator[]: present/missing keys and non-objects", "[osjson][nav]") +{ + static const char json[] = R"({"a":1,"b":2})"; + JSON::JsonDocument doc; + TEST_ASSERT_TRUE(doc.parse(json)); + JSON::JsonView root = doc.root(); + + TEST_ASSERT_TRUE(root["a"].valid()); + TEST_ASSERT_TRUE(root["b"].valid()); + TEST_ASSERT_FALSE(root["c"].valid()); + TEST_ASSERT_FALSE(root[""].valid()); + + // indexing into a non-object yields an invalid view (no crash) + TEST_ASSERT_FALSE(root["a"]["nested"].valid()); +} + +TEST_CASE("operator[]: case-sensitive and prefix-distinct keys", "[osjson][nav]") +{ + static const char json[] = R"({"name":1,"Name":2,"n":3,"nam":4})"; + JSON::JsonDocument doc; + TEST_ASSERT_TRUE(doc.parse(json)); + JSON::JsonView root = doc.root(); + + int64_t v = 0; + TEST_ASSERT_TRUE(root["name"].tryGetI64(v)); + TEST_ASSERT_EQUAL_INT64(1, v); + TEST_ASSERT_TRUE(root["Name"].tryGetI64(v)); // different case -> different key + TEST_ASSERT_EQUAL_INT64(2, v); + TEST_ASSERT_TRUE(root["n"].tryGetI64(v)); // must not match "name"/"nam" + TEST_ASSERT_EQUAL_INT64(3, v); + TEST_ASSERT_TRUE(root["nam"].tryGetI64(v)); + TEST_ASSERT_EQUAL_INT64(4, v); +} + +TEST_CASE("operator[]: empty-string key can be looked up", "[osjson][nav]") +{ + JSON::JsonDocument doc; + TEST_ASSERT_TRUE(doc.parse(R"({"":42})")); + int64_t v = 0; + TEST_ASSERT_TRUE(doc.root()[""].tryGetI64(v)); + TEST_ASSERT_EQUAL_INT64(42, v); +} + +TEST_CASE("operator[]: duplicate keys resolve to the first occurrence", "[osjson][nav]") +{ + JSON::JsonDocument doc; + TEST_ASSERT_TRUE(doc.parse(R"({"k":1,"k":2})")); + int64_t v = 0; + TEST_ASSERT_TRUE(doc.root()["k"].tryGetI64(v)); + TEST_ASSERT_EQUAL_INT64(1, v); +} + +TEST_CASE("operator[]: lookup skips over object- and array-valued members", "[osjson][nav]") +{ + // The value before "target" is a nested structure; skip() must jump the whole + // subtree so the following key is found correctly. + static const char json[] = R"({"big":{"x":[1,2,{"y":3}],"z":{"w":4}},"arr":[9,8,7],"target":123})"; + JSON::JsonDocument doc; + TEST_ASSERT_TRUE(doc.parse(json)); + JSON::JsonView root = doc.root(); + + int64_t v = 0; + TEST_ASSERT_TRUE(root["target"].tryGetI64(v)); + TEST_ASSERT_EQUAL_INT64(123, v); + + // and the nested paths themselves resolve + TEST_ASSERT_TRUE(root["big"]["z"]["w"].tryGetI64(v)); + TEST_ASSERT_EQUAL_INT64(4, v); + TEST_ASSERT_TRUE(root["big"]["x"].at(2)["y"].tryGetI64(v)); + TEST_ASSERT_EQUAL_INT64(3, v); +} + +TEST_CASE("count(): arrays, objects, primitives, invalid", "[osjson][nav]") +{ + JSON::JsonDocument doc; + TEST_ASSERT_TRUE(doc.parse(R"({"a":[1,2,3,4],"o":{"x":1,"y":2},"n":5})")); + JSON::JsonView root = doc.root(); + + TEST_ASSERT_EQUAL_INT(3, root.count()); // object: member count + TEST_ASSERT_EQUAL_INT(4, root["a"].count()); // array length + TEST_ASSERT_EQUAL_INT(2, root["o"].count()); // nested object members + TEST_ASSERT_EQUAL_INT(0, root["n"].count()); // primitive + TEST_ASSERT_EQUAL_INT(0, root["missing"].count()); +} + +TEST_CASE("at(): valid indices, out of range, and non-arrays", "[osjson][nav]") +{ + JSON::JsonDocument doc; + TEST_ASSERT_TRUE(doc.parse(R"([10,20,30])")); + JSON::JsonView arr = doc.root(); + + int64_t v = 0; + TEST_ASSERT_TRUE(arr.at(0).tryGetI64(v)); + TEST_ASSERT_EQUAL_INT64(10, v); + TEST_ASSERT_TRUE(arr.at(2).tryGetI64(v)); + TEST_ASSERT_EQUAL_INT64(30, v); + + TEST_ASSERT_FALSE(arr.at(3).valid()); // past the end + TEST_ASSERT_FALSE(arr.at(-1).valid()); // negative + TEST_ASSERT_FALSE(arr.at(1000).valid()); + + // at() on an object is invalid (only arrays are indexable) + JSON::JsonDocument obj; + TEST_ASSERT_TRUE(obj.parse(R"({"a":1})")); + TEST_ASSERT_FALSE(obj.root().at(0).valid()); +} + +TEST_CASE("arrays: nested and mixed-type elements", "[osjson][nav]") +{ + static const char json[] = R"([[1,2],[3,4],"five",true,null,{"k":6},[]])"; + JSON::JsonDocument doc; + TEST_ASSERT_TRUE(doc.parse(json)); + JSON::JsonView root = doc.root(); + + TEST_ASSERT_EQUAL_INT(7, root.count()); + + int64_t v = 0; + TEST_ASSERT_TRUE(root.at(0).isArray()); + TEST_ASSERT_TRUE(root.at(0).at(1).tryGetI64(v)); + TEST_ASSERT_EQUAL_INT64(2, v); + TEST_ASSERT_TRUE(root.at(1).at(0).tryGetI64(v)); + TEST_ASSERT_EQUAL_INT64(3, v); + + std::string sv; + TEST_ASSERT_TRUE(root.at(2).tryGetStr(sv)); + TEST_ASSERT_TRUE(sv == "five"); + + bool b = false; + TEST_ASSERT_TRUE(root.at(3).tryGetBool(b)); + TEST_ASSERT_TRUE(b); + + TEST_ASSERT_TRUE(root.at(4).isNull()); + + TEST_ASSERT_TRUE(root.at(5).isObject()); + TEST_ASSERT_TRUE(root.at(5)["k"].tryGetI64(v)); + TEST_ASSERT_EQUAL_INT64(6, v); + + TEST_ASSERT_TRUE(root.at(6).isArray()); + TEST_ASSERT_EQUAL_INT(0, root.at(6).count()); +} + +TEST_CASE("raw(): zero-copy token text for strings, numbers, containers", "[osjson][nav]") +{ + static const char json[] = R"({"s":"hi","n":-12.5,"a":[1,2]})"; + JSON::JsonDocument doc; + TEST_ASSERT_TRUE(doc.parse(json)); + JSON::JsonView root = doc.root(); + + // string raw() is the content WITHOUT the surrounding quotes + std::string_view s = root["s"].raw(); + TEST_ASSERT_TRUE(s == "hi"); + TEST_ASSERT_TRUE(s.data() >= json && s.data() < json + sizeof(json)); + + // primitive raw() is the literal text + TEST_ASSERT_TRUE(root["n"].raw() == "-12.5"); + + // container raw() spans the whole array text + std::string_view a = root["a"].raw(); + TEST_ASSERT_TRUE(a.size() >= 2); + TEST_ASSERT_EQUAL_INT('[', a.front()); + TEST_ASSERT_EQUAL_INT(']', a.back()); +} + +TEST_CASE("chaining on invalid views never dereferences", "[osjson][nav]") +{ + JSON::JsonDocument doc; + TEST_ASSERT_TRUE(doc.parse(R"({"a":1})")); + JSON::JsonView root = doc.root(); + + // deep chain through missing keys and bad indices stays invalid, no crash + TEST_ASSERT_FALSE(root["nope"]["deeper"].at(3)["evenmore"].valid()); + int64_t v = 0; + TEST_ASSERT_FALSE(root["nope"].at(0)["x"].tryGetI64(v)); +} diff --git a/components/osjson/host_test/main/test_parse.cpp b/components/osjson/host_test/main/test_parse.cpp new file mode 100644 index 00000000..b7f4dcdd --- /dev/null +++ b/components/osjson/host_test/main/test_parse.cpp @@ -0,0 +1,149 @@ +// JsonDocument::parse + root() edge cases. +#include "unity.h" + +#include "json/Json.h" + +#include +#include + +using namespace OpenShock; + +TEST_CASE("parse: empty and whitespace-only inputs are rejected", "[osjson][parse]") +{ + JSON::JsonDocument doc; + TEST_ASSERT_FALSE(doc.parse("")); + TEST_ASSERT_FALSE(doc.ok()); + TEST_ASSERT_FALSE(doc.root().valid()); + + TEST_ASSERT_FALSE(doc.parse(" ")); + TEST_ASSERT_FALSE(doc.parse("\t\n\r ")); +} + +TEST_CASE("parse: root() is invalid before any parse", "[osjson][parse]") +{ + JSON::JsonDocument doc; + TEST_ASSERT_FALSE(doc.ok()); + TEST_ASSERT_FALSE(doc.root().valid()); +} + +TEST_CASE("parse: malformed documents are rejected", "[osjson][parse]") +{ + JSON::JsonDocument doc; + TEST_ASSERT_FALSE(doc.parse("{")); + TEST_ASSERT_FALSE(doc.parse("[")); + TEST_ASSERT_FALSE(doc.parse("[1,2")); + TEST_ASSERT_FALSE(doc.parse("{\"a\":")); + TEST_ASSERT_FALSE(doc.parse("{\"a\"")); // key with no value + TEST_ASSERT_FALSE(doc.parse("\"unterminated")); // unterminated string + TEST_ASSERT_FALSE(doc.parse("{ not valid json")); +} + +TEST_CASE("parse: a failed parse leaves root() invalid", "[osjson][parse]") +{ + JSON::JsonDocument doc; + TEST_ASSERT_TRUE(doc.parse(R"({"a":1})")); + TEST_ASSERT_TRUE(doc.root().valid()); + + TEST_ASSERT_FALSE(doc.parse("{bad")); + TEST_ASSERT_FALSE(doc.ok()); + TEST_ASSERT_FALSE(doc.root().valid()); +} + +TEST_CASE("parse: object / array roots report the right container type", "[osjson][parse]") +{ + JSON::JsonDocument obj; + TEST_ASSERT_TRUE(obj.parse(R"({"a":1})")); + TEST_ASSERT_TRUE(obj.root().isObject()); + TEST_ASSERT_FALSE(obj.root().isArray()); + + JSON::JsonDocument arr; + TEST_ASSERT_TRUE(arr.parse(R"([1,2,3])")); + TEST_ASSERT_TRUE(arr.root().isArray()); + TEST_ASSERT_FALSE(arr.root().isObject()); +} + +TEST_CASE("parse: empty object and empty array", "[osjson][parse]") +{ + JSON::JsonDocument obj; + TEST_ASSERT_TRUE(obj.parse("{}")); + TEST_ASSERT_TRUE(obj.root().isObject()); + TEST_ASSERT_EQUAL_INT(0, obj.root().count()); + TEST_ASSERT_FALSE(obj.root()["anything"].valid()); + + JSON::JsonDocument arr; + TEST_ASSERT_TRUE(arr.parse("[]")); + TEST_ASSERT_TRUE(arr.root().isArray()); + TEST_ASSERT_EQUAL_INT(0, arr.root().count()); + TEST_ASSERT_FALSE(arr.root().at(0).valid()); +} + +TEST_CASE("parse: scalar document at the root", "[osjson][parse]") +{ + JSON::JsonDocument num; + TEST_ASSERT_TRUE(num.parse("42")); + TEST_ASSERT_TRUE(num.root().isNumber()); + int64_t n = 0; + TEST_ASSERT_TRUE(num.root().tryGetI64(n)); + TEST_ASSERT_EQUAL_INT64(42, n); + + JSON::JsonDocument str; + TEST_ASSERT_TRUE(str.parse("\"hello\"")); + TEST_ASSERT_TRUE(str.root().isString()); + std::string sv; + TEST_ASSERT_TRUE(str.root().tryGetStr(sv)); + TEST_ASSERT_TRUE(sv == "hello"); + + JSON::JsonDocument boolean; + TEST_ASSERT_TRUE(boolean.parse("true")); + bool b = false; + TEST_ASSERT_TRUE(boolean.root().tryGetBool(b)); + TEST_ASSERT_TRUE(b); + + JSON::JsonDocument null; + TEST_ASSERT_TRUE(null.parse("null")); + TEST_ASSERT_TRUE(null.root().isNull()); +} + +TEST_CASE("parse: re-parsing the same document reuses state cleanly", "[osjson][parse]") +{ + JSON::JsonDocument doc; + + TEST_ASSERT_TRUE(doc.parse(R"({"a":1,"b":2,"c":3})")); + TEST_ASSERT_EQUAL_INT(3, doc.root().count()); + + TEST_ASSERT_TRUE(doc.parse(R"({"x":9})")); + TEST_ASSERT_EQUAL_INT(1, doc.root().count()); + int64_t x = 0; + TEST_ASSERT_TRUE(doc.root()["x"].tryGetI64(x)); + TEST_ASSERT_EQUAL_INT64(9, x); + TEST_ASSERT_FALSE(doc.root()["a"].valid()); // key from the previous parse is gone +} + +TEST_CASE("parse: source buffer must outlive the document (zero-copy contract)", "[osjson][parse]") +{ + std::string src = R"({"name":"value"})"; + + JSON::JsonDocument doc; + TEST_ASSERT_TRUE(doc.parse(src)); + + // raw() must point straight into src, not into a copy. + std::string_view sv = doc.root()["name"].raw(); + TEST_ASSERT_TRUE(sv.data() >= src.data() && sv.data() < src.data() + src.size()); + TEST_ASSERT_TRUE(sv == "value"); +} + +TEST_CASE("parse: structurally invalid JSON is rejected (CONFIG_JSMN_STRICT)", "[osjson][parse]") +{ + const char* inputs[] = { + R"({a:1})", // unquoted key + R"([1 2])", // missing comma + R"({"a":1} trailing)", // garbage after the value + R"({"x":{"a"},"b":1})", // key without a value (lenient mode misread "b" as the value of "a") + R"({"a":007})", // number with a leading zero + }; + + for (const char* input : inputs) { + JSON::JsonDocument doc; + TEST_ASSERT_FALSE_MESSAGE(doc.parse(input), input); + } +} diff --git a/components/osjson/host_test/main/test_stress.cpp b/components/osjson/host_test/main/test_stress.cpp new file mode 100644 index 00000000..e9d06597 --- /dev/null +++ b/components/osjson/host_test/main/test_stress.cpp @@ -0,0 +1,173 @@ +// Scale / limits: token-buffer growth, the parse token cap, deep nesting, +// and large generate->parse round-trips. +#include "unity.h" + +#include "json/Json.h" + +#include +#include +#include + +using namespace OpenShock; + +TEST_CASE("stress: large flat array forces the token buffer to grow", "[osjson][stress]") +{ + // 1000 elements -> ~1001 tokens, well past the initial capacity of 32, so the + // grow-and-retry path in parse() runs several times. + const int N = 1000; + std::string json = "["; + for (int i = 0; i < N; ++i) { + if (i) json += ','; + json += std::to_string(i); + } + json += ']'; + + JSON::JsonDocument doc; + TEST_ASSERT_TRUE(doc.parse(json)); + JSON::JsonView root = doc.root(); + TEST_ASSERT_TRUE(root.isArray()); + TEST_ASSERT_EQUAL_INT(N, root.count()); + + int64_t v = 0; + TEST_ASSERT_TRUE(root.at(0).tryGetI64(v)); + TEST_ASSERT_EQUAL_INT64(0, v); + TEST_ASSERT_TRUE(root.at(500).tryGetI64(v)); + TEST_ASSERT_EQUAL_INT64(500, v); + TEST_ASSERT_TRUE(root.at(N - 1).tryGetI64(v)); + TEST_ASSERT_EQUAL_INT64(N - 1, v); + TEST_ASSERT_FALSE(root.at(N).valid()); +} + +TEST_CASE("stress: object with many members, every key resolvable", "[osjson][stress]") +{ + const int N = 300; + std::string json = "{"; + for (int i = 0; i < N; ++i) { + if (i) json += ','; + json += "\"k" + std::to_string(i) + "\":" + std::to_string(i * 2); + } + json += '}'; + + JSON::JsonDocument doc; + TEST_ASSERT_TRUE(doc.parse(json)); + JSON::JsonView root = doc.root(); + TEST_ASSERT_EQUAL_INT(N, root.count()); + + // spot-check first, middle, last, and a miss + int64_t v = 0; + TEST_ASSERT_TRUE(root["k0"].tryGetI64(v)); + TEST_ASSERT_EQUAL_INT64(0, v); + TEST_ASSERT_TRUE(root["k150"].tryGetI64(v)); + TEST_ASSERT_EQUAL_INT64(300, v); + TEST_ASSERT_TRUE(root["k299"].tryGetI64(v)); + TEST_ASSERT_EQUAL_INT64(598, v); + TEST_ASSERT_FALSE(root["k300"].valid()); +} + +TEST_CASE("stress: document needing > 16384 tokens is rejected by the cap", "[osjson][stress]") +{ + // Array of 20000 numbers => 20001 tokens > cap => parse() bails out to false + // rather than growing unboundedly. + const int N = 20000; + std::string json = "["; + for (int i = 0; i < N; ++i) { + if (i) json += ','; + json += '1'; + } + json += ']'; + + JSON::JsonDocument doc; + TEST_ASSERT_FALSE(doc.parse(json)); + TEST_ASSERT_FALSE(doc.root().valid()); +} + +TEST_CASE("stress: just under the cap still parses", "[osjson][stress]") +{ + // 16000 elements -> 16001 tokens, needs capacity 32768? No: capacity doubles + // 32,64,...,16384. 16001 <= 16384 so it fits without tripping the cap. + const int N = 16000; + std::string json = "["; + for (int i = 0; i < N; ++i) { + if (i) json += ','; + json += '7'; + } + json += ']'; + + JSON::JsonDocument doc; + TEST_ASSERT_TRUE(doc.parse(json)); + TEST_ASSERT_EQUAL_INT(N, doc.root().count()); + int64_t v = 0; + TEST_ASSERT_TRUE(doc.root().at(N - 1).tryGetI64(v)); + TEST_ASSERT_EQUAL_INT64(7, v); +} + +TEST_CASE("stress: moderately deep nesting navigates correctly", "[osjson][stress]") +{ + // Build {"a":{"a":{...{"v":depth}...}}} and walk back down to the leaf. + const int depth = 40; + std::string json; + for (int i = 0; i < depth; ++i) json += "{\"a\":"; + json += std::to_string(depth); + for (int i = 0; i < depth; ++i) json += "}"; + + JSON::JsonDocument doc; + TEST_ASSERT_TRUE(doc.parse(json)); + + JSON::JsonView v = doc.root(); + for (int i = 0; i < depth; ++i) { + TEST_ASSERT_TRUE(v.isObject()); + v = v["a"]; + } + int64_t leaf = 0; + TEST_ASSERT_TRUE(v.tryGetI64(leaf)); + TEST_ASSERT_EQUAL_INT64(depth, leaf); +} + +TEST_CASE("stress: skipping a very deep subtree does not recurse", "[osjson][stress]") +{ + // {"deep":[[[...1...]]],"after":7}: finding "after" must step over the whole deep + // subtree. Recursing once per level would overflow the default main-task stack. + const int depth = 5000; + std::string json = "{\"deep\":"; + for (int i = 0; i < depth; ++i) json += "["; + json += "1"; + for (int i = 0; i < depth; ++i) json += "]"; + json += ",\"after\":7}"; + + JSON::JsonDocument doc; + TEST_ASSERT_TRUE(doc.parse(json)); + + int64_t v = 0; + TEST_ASSERT_TRUE(doc.root()["after"].tryGetI64(v)); + TEST_ASSERT_EQUAL_INT64(7, v); +} + +TEST_CASE("stress: large generate -> parse round-trip", "[osjson][stress]") +{ + const int N = 2000; + + std::string out; + { + JSON::StringWriter w; + json_gen_str_t* g = w.gen(); + json_gen_start_array(g); + for (int i = 0; i < N; ++i) { + json_gen_start_object(g); + json_gen_obj_set_int(g, "i", i); + json_gen_end_object(g); + } + json_gen_end_array(g); + out = w.finish(); + } + + JSON::JsonDocument doc; + TEST_ASSERT_TRUE(doc.parse(out)); + JSON::JsonView root = doc.root(); + TEST_ASSERT_EQUAL_INT(N, root.count()); + + int64_t v = 0; + TEST_ASSERT_TRUE(root.at(0)["i"].tryGetI64(v)); + TEST_ASSERT_EQUAL_INT64(0, v); + TEST_ASSERT_TRUE(root.at(N - 1)["i"].tryGetI64(v)); + TEST_ASSERT_EQUAL_INT64(N - 1, v); +} diff --git a/components/osjson/host_test/sdkconfig.defaults b/components/osjson/host_test/sdkconfig.defaults new file mode 100644 index 00000000..a3065843 --- /dev/null +++ b/components/osjson/host_test/sdkconfig.defaults @@ -0,0 +1,7 @@ +CONFIG_IDF_TARGET="linux" +CONFIG_IDF_TARGET_LINUX=y +CONFIG_COMPILER_CXX_EXCEPTIONS=y +CONFIG_UNITY_ENABLE_IDF_TEST_RUNNER=y + +# Match the firmware's sdkconfig.defaults +CONFIG_JSMN_STRICT=y diff --git a/components/osjson/idf_component.yml b/components/osjson/idf_component.yml new file mode 100644 index 00000000..600ae0bc --- /dev/null +++ b/components/osjson/idf_component.yml @@ -0,0 +1,6 @@ +# OpenShock JSON handling (util/Json.h) is built on these espressif components. +# Declared here explicitly rather than relying on them being pulled in as +# transitive private dependencies of other components. +dependencies: + espressif/jsmn: "^1.2.0" + espressif/json_generator: "^2.0.0" diff --git a/components/osjson/include/json/Json.h b/components/osjson/include/json/Json.h new file mode 100644 index 00000000..d989d684 --- /dev/null +++ b/components/osjson/include/json/Json.h @@ -0,0 +1,130 @@ +#pragma once + +// jsmn is header-only: the parser implementation is compiled exactly once, in +// Json.cpp (which includes *without* JSMN_HEADER before this header). +// Everywhere else we only want the type/enum declarations. +#ifndef JSMN_HEADER +#define JSMN_HEADER +#endif +#include + +#include + +#include +#include +#include +#include + +namespace OpenShock::JSON { + // Read-only view over a single node of a parsed jsmn token tree. + // + // Navigation and number parsing are zero-copy (raw() is a std::string_view + // straight into the source buffer, not NUL-terminated). String values are + // decoded (JSON escapes resolved) into an owned std::string by tryGetStr. The + // source buffer must outlive every JsonView derived from it. + class JsonView { + public: + JsonView() noexcept = default; + JsonView(const char* json, const jsmntok_t* tokens, int count, int index) noexcept + : m_json(json) + , m_tokens(tokens) + , m_count(count) + , m_index(index) + { + } + + [[nodiscard]] bool valid() const noexcept { return m_tokens != nullptr && m_index >= 0 && m_index < m_count; } + [[nodiscard]] bool isObject() const noexcept { return valid() && m_tokens[m_index].type == JSMN_OBJECT; } + [[nodiscard]] bool isArray() const noexcept { return valid() && m_tokens[m_index].type == JSMN_ARRAY; } + [[nodiscard]] bool isString() const noexcept { return valid() && m_tokens[m_index].type == JSMN_STRING; } + [[nodiscard]] bool isPrimitive() const noexcept { return valid() && m_tokens[m_index].type == JSMN_PRIMITIVE; } + [[nodiscard]] bool isNull() const noexcept; + [[nodiscard]] bool isNumber() const noexcept; // primitive that is not true/false/null + + // Raw token text, zero-copy into the source buffer. For a string this is the + // body between the quotes with its escapes still in place. + [[nodiscard]] std::string_view raw() const noexcept; + + // Typed getters. Return false if this node is not of the requested kind. + // tryGetStr decodes the string's escapes (\uXXXX and surrogate pairs to UTF-8) + // and returns false for a malformed escape; `out` is left unchanged on failure. + [[nodiscard]] bool tryGetStr(std::string& out) const; + [[nodiscard]] bool tryGetBool(bool& out) const noexcept; + [[nodiscard]] bool tryGetU8(uint8_t& out) const noexcept; + [[nodiscard]] bool tryGetU16(uint16_t& out) const noexcept; + [[nodiscard]] bool tryGetI32(int32_t& out) const noexcept; + [[nodiscard]] bool tryGetI64(int64_t& out) const noexcept; + [[nodiscard]] bool tryGetDouble(double& out) const noexcept; + + // Object member lookup (keys compare after decoding escapes). Returns an + // invalid view if not found or not an object. + [[nodiscard]] JsonView operator[](std::string_view key) const; + + // Array access (also reports object member count). + [[nodiscard]] int count() const noexcept; + [[nodiscard]] JsonView at(int index) const noexcept; + + private: + [[nodiscard]] int skip(int index) const noexcept; // index just past the subtree rooted at `index` + + const char* m_json = nullptr; + const jsmntok_t* m_tokens = nullptr; + int m_count = 0; + int m_index = -1; + }; + + // Owns the jsmn token array for a parsed document. Does NOT own the JSON text; + // the buffer passed to parse() must outlive the document and its views. + class JsonDocument { + public: + [[nodiscard]] bool parse(std::string_view json); + [[nodiscard]] bool ok() const noexcept { return m_ok; } + [[nodiscard]] JsonView root() const noexcept; + + private: + std::string_view m_json; + std::vector m_tokens; + bool m_ok = false; + }; + + // json_generator wrapper that accumulates output into a std::string. + // Non-copyable/non-movable (the generator holds a pointer to the inline buffer). + class StringWriter { + public: + StringWriter(); + StringWriter(const StringWriter&) = delete; + StringWriter& operator=(const StringWriter&) = delete; + + [[nodiscard]] json_gen_str_t* gen() noexcept { return &m_gen; } + + // Finalizes the JSON and returns the accumulated string. Call once. + // Returns an empty string (and failed() becomes true) if generation failed, e.g. on malformed UTF-8. + [[nodiscard]] std::string finish(); + [[nodiscard]] bool failed() const noexcept { return m_failed; } + + private: + static void flushCb(char* buf, void* priv); + + std::string m_out; + char m_buf[256]; + json_gen_str_t m_gen; + bool m_failed = false; + }; + + // Add a string value with an explicit length (embedded NULs and non-terminated + // views are fine). json_generator 2.x escapes the value per RFC 8259 and fails + // the document on malformed UTF-8, so pass the plain, unescaped text. + // + // Keys/names are NUL-terminated C strings (json_generator escapes them too). + // Return value matches the underlying json_gen_* call. + int objSetString(json_gen_str_t* gen, const char* name, std::string_view value); + int arrSetString(json_gen_str_t* gen, std::string_view value); + + // Opens/closes an object on the generator. When `name` is non-null the object + // is added as a named member of the enclosing object (push/pop); when it is + // null the object is anonymous (start/end), e.g. the document root or an array + // element. Config ToJSON() implementations wrap their members in these so each + // config owns its own object, mirroring the source firmware's cJSON approach. + void objBegin(json_gen_str_t* gen, const char* name); + void objEnd(json_gen_str_t* gen, const char* name); +} // namespace OpenShock::JSON diff --git a/components/osjson/src/Json.cpp b/components/osjson/src/Json.cpp new file mode 100644 index 00000000..44effabb --- /dev/null +++ b/components/osjson/src/Json.cpp @@ -0,0 +1,431 @@ +// Compile the jsmn implementation here (and only here): include +// WITHOUT JSMN_HEADER, before any header that pulls it in declaration-only. +#include + +#include "json/Json.h" + +#include "Convert.h" + +#include +#include +#include + +using namespace OpenShock; + +// json_generator 2.x escapes string values itself (RFC 8259 section 7: quote, +// backslash and U+0000-U+001F) and rejects malformed UTF-8, so these only hand the +// value over with an explicit length. Escaping here as well would escape twice. +// The length also lets an embedded NUL through (emitted as \u0000). +int JSON::objSetString(json_gen_str_t* gen, const char* name, std::string_view value) +{ + return json_gen_obj_set_string_len(gen, name, value.data(), value.size()); +} + +int JSON::arrSetString(json_gen_str_t* gen, std::string_view value) +{ + return json_gen_arr_set_string_len(gen, value.data(), value.size()); +} + +// Parses the 4 hex digits of a \uXXXX escape starting at in[pos]. +static bool readHex4(std::string_view in, std::size_t pos, uint32_t& out) +{ + if (pos > in.size() || in.size() - pos < 4) { + return false; + } + + uint32_t value = 0; + for (std::size_t i = pos; i < pos + 4; ++i) { + const char c = in[i]; + value <<= 4; + if (c >= '0' && c <= '9') { + value |= static_cast(c - '0'); + } else if (c >= 'a' && c <= 'f') { + value |= static_cast(c - 'a' + 10); + } else if (c >= 'A' && c <= 'F') { + value |= static_cast(c - 'A' + 10); + } else { + return false; + } + } + + out = value; + return true; +} + +static void appendUtf8(std::string& out, uint32_t cp) +{ + if (cp < 0x80) { + out += static_cast(cp); + } else if (cp < 0x800) { + out += static_cast(0xC0 | (cp >> 6)); + out += static_cast(0x80 | (cp & 0x3F)); + } else if (cp < 0x10000) { + out += static_cast(0xE0 | (cp >> 12)); + out += static_cast(0x80 | ((cp >> 6) & 0x3F)); + out += static_cast(0x80 | (cp & 0x3F)); + } else { + out += static_cast(0xF0 | (cp >> 18)); + out += static_cast(0x80 | ((cp >> 12) & 0x3F)); + out += static_cast(0x80 | ((cp >> 6) & 0x3F)); + out += static_cast(0x80 | (cp & 0x3F)); + } +} + +// Decodes the body of a JSON string token (RFC 8259 section 7) to UTF-8: +// +// \" \\ \/ \b \f \n \r \t -> the character they stand for +// \uXXXX -> that code point, UTF-8 encoded (\u0000 gives a NUL byte) +// \uD8xx\uDCxx -> one supplementary code point (surrogate pair) +// unpaired surrogate -> U+FFFD (a lone surrogate has no UTF-8 encoding) +// +// All other bytes are copied unchanged. Returns false on a malformed escape (unknown +// escape character, truncated or non-hex \u), which jsmn's default (non-strict) +// mode does not reject. +static bool jsonUnescape(std::string_view in, std::string& out) +{ + out.clear(); + out.reserve(in.size()); + + std::size_t i = 0; + while (i < in.size()) { + char c = in[i++]; + if (c != '\\') { + out += c; + continue; + } + + if (i >= in.size()) { + return false; + } + + c = in[i++]; + switch (c) { + case '"': + case '\\': + case '/': + out += c; + break; + case 'b': + out += '\b'; + break; + case 'f': + out += '\f'; + break; + case 'n': + out += '\n'; + break; + case 'r': + out += '\r'; + break; + case 't': + out += '\t'; + break; + case 'u': + { + uint32_t cp; + if (!readHex4(in, i, cp)) { + return false; + } + i += 4; + + if (cp >= 0xD800 && cp <= 0xDBFF) { + // High surrogate: forms a code point only together with a following \uDC00-\uDFFF. + uint32_t low; + if (in.size() - i >= 6 && in[i] == '\\' && in[i + 1] == 'u' && readHex4(in, i + 2, low) && low >= 0xDC00 && low <= 0xDFFF) { + i += 6; + cp = 0x10000 + ((cp - 0xD800) << 10) + (low - 0xDC00); + } else { + cp = 0xFFFD; + } + } else if (cp >= 0xDC00 && cp <= 0xDFFF) { + cp = 0xFFFD; + } + + appendUtf8(out, cp); + break; + } + default: + return false; + } + } + + return true; +} + +void JSON::objBegin(json_gen_str_t* gen, const char* name) +{ + if (name != nullptr) { + json_gen_push_object(gen, name); + } else { + json_gen_start_object(gen); + } +} + +void JSON::objEnd(json_gen_str_t* gen, const char* name) +{ + if (name != nullptr) { + json_gen_pop_object(gen); + } else { + json_gen_end_object(gen); + } +} + +// Iterative rather than recursive: input can nest thousands of levels deep (parse +// accepts up to 16384 tokens), which would overflow a task stack one frame per level. +int JSON::JsonView::skip(int index) const noexcept +{ + // Tokens still to consume in this subtree: an object owes a key and a value per + // member, an array one value per element, a leaf nothing. + long pending = 1; + int j = index; + while (pending > 0 && j < m_count) { + const jsmntok_t& tok = m_tokens[j++]; + --pending; + if (tok.type == JSMN_OBJECT) { + pending += 2L * tok.size; + } else if (tok.type == JSMN_ARRAY) { + pending += tok.size; + } + } + return j; +} + +std::string_view JSON::JsonView::raw() const noexcept +{ + if (!valid()) { + return {}; + } + const jsmntok_t& tok = m_tokens[m_index]; + if (tok.end < tok.start) { + return {}; + } + return std::string_view(m_json + tok.start, static_cast(tok.end - tok.start)); +} + +bool JSON::JsonView::isNull() const noexcept +{ + return isPrimitive() && raw() == "null"; +} + +bool JSON::JsonView::isNumber() const noexcept +{ + if (!isPrimitive()) { + return false; + } + std::string_view s = raw(); + return s != "true" && s != "false" && s != "null"; +} + +bool JSON::JsonView::tryGetStr(std::string& out) const +{ + if (!isString()) { + return false; + } + + std::string_view s = raw(); + if (s.find('\\') == std::string_view::npos) { + out.assign(s); // nothing to decode + return true; + } + + std::string decoded; + if (!jsonUnescape(s, decoded)) { + return false; + } + out = std::move(decoded); + return true; +} + +bool JSON::JsonView::tryGetBool(bool& out) const noexcept +{ + if (!isPrimitive()) { + return false; + } + std::string_view s = raw(); + if (s == "true") { + out = true; + return true; + } + if (s == "false") { + out = false; + return true; + } + return false; +} + +bool JSON::JsonView::tryGetU8(uint8_t& out) const noexcept +{ + if (!isNumber()) { + return false; + } + return Convert::ToUint8(raw(), out); +} + +bool JSON::JsonView::tryGetU16(uint16_t& out) const noexcept +{ + if (!isNumber()) { + return false; + } + return Convert::ToUint16(raw(), out); +} + +bool JSON::JsonView::tryGetI32(int32_t& out) const noexcept +{ + if (!isNumber()) { + return false; + } + return Convert::ToInt32(raw(), out); +} + +bool JSON::JsonView::tryGetI64(int64_t& out) const noexcept +{ + if (!isNumber()) { + return false; + } + return Convert::ToInt64(raw(), out); +} + +bool JSON::JsonView::tryGetDouble(double& out) const noexcept +{ + if (!isNumber()) { + return false; + } + std::string_view s = raw(); + + // strtod needs a NUL-terminated string; JSON numbers are short, so a small + // stack copy is fine and avoids touching the (non-terminated) source buffer. + char buf[64]; + if (s.size() >= sizeof(buf)) { + return false; + } + std::memcpy(buf, s.data(), s.size()); + buf[s.size()] = '\0'; + + char* end = nullptr; + double value = std::strtod(buf, &end); + if (end != buf + s.size()) { + return false; + } + out = value; + return true; +} + +JSON::JsonView JSON::JsonView::operator[](std::string_view key) const +{ + if (!isObject()) { + return {}; + } + + const int members = m_tokens[m_index].size; + int j = m_index + 1; + for (int m = 0; m < members; ++m) { + const int keyIdx = j; + const int valIdx = keyIdx + 1; + + const jsmntok_t& k = m_tokens[keyIdx]; + if (k.type == JSMN_STRING) { + std::string_view keyView(m_json + k.start, static_cast(k.end - k.start)); + if (keyView == key) { + return JsonView(m_json, m_tokens, m_count, valIdx); + } + + // A key written with escapes ("id" for "id") is still the same key. + std::string decodedKey; + if (keyView.find('\\') != std::string_view::npos && jsonUnescape(keyView, decodedKey) && decodedKey == key) { + return JsonView(m_json, m_tokens, m_count, valIdx); + } + } + + j = skip(valIdx); // advance past this member's value to the next key + } + + return {}; +} + +int JSON::JsonView::count() const noexcept +{ + if (!isObject() && !isArray()) { + return 0; + } + return m_tokens[m_index].size; +} + +JSON::JsonView JSON::JsonView::at(int index) const noexcept +{ + if (!isArray()) { + return {}; + } + + const int elements = m_tokens[m_index].size; + if (index < 0 || index >= elements) { + return {}; + } + + int j = m_index + 1; + for (int e = 0; e < index; ++e) { + j = skip(j); + } + return JsonView(m_json, m_tokens, m_count, j); +} + +bool JSON::JsonDocument::parse(std::string_view json) +{ + m_json = json; + m_ok = false; + + // Count tokens first (jsmn does this without a buffer), then allocate exactly once. Growing by doubling briefly + // held both the old and the new buffer, which for a large untrusted body meant ~384 KiB with exceptions disabled. + static constexpr int kMaxTokens = 16384; + + jsmn_parser parser; + jsmn_init(&parser); + int count = jsmn_parse(&parser, json.data(), json.size(), nullptr, 0); + if (count <= 0 || count > kMaxTokens) { + m_tokens.clear(); + return false; // malformed, empty, or unreasonably large + } + + m_tokens.resize(static_cast(count)); + + jsmn_init(&parser); + int result = jsmn_parse(&parser, json.data(), json.size(), m_tokens.data(), static_cast(count)); + if (result != count) { + m_tokens.clear(); + return false; + } + + m_ok = true; + return true; +} + +JSON::JsonView JSON::JsonDocument::root() const noexcept +{ + if (!m_ok || m_tokens.empty()) { + return {}; + } + return JsonView(m_json.data(), m_tokens.data(), static_cast(m_tokens.size()), 0); +} + +void JSON::StringWriter::flushCb(char* buf, void* priv) +{ + static_cast(priv)->append(buf); +} + +JSON::StringWriter::StringWriter() +{ + json_gen_str_start(&m_gen, m_buf, sizeof(m_buf), &StringWriter::flushCb, &m_out); +} + +std::string JSON::StringWriter::finish() +{ + // On error (e.g. malformed UTF-8 in a value) json_generator stops generating, but chunks flushed before the error + // are already in m_out; never hand out that truncated, invalid document. + int result = json_gen_str_end(&m_gen); + if (result < 0) { + m_failed = true; + m_out.clear(); + return {}; + } + + return std::move(m_out); +} diff --git a/components/ota/CMakeLists.txt b/components/ota/CMakeLists.txt new file mode 100644 index 00000000..32fe4029 --- /dev/null +++ b/components/ota/CMakeLists.txt @@ -0,0 +1,18 @@ +# OpenShock OTA update manager: boot-type tracking and app validation/rollback, plus +# the update task that fetches release metadata from the firmware repository and +# flashes the app and static filesystem partitions. +# +# Extracted out of `core` and layered above it (and above captive_portal, which it +# closes before flashing). core reaches back only through the AppHooks callbacks +# registered in OtaUpdateManager::Init(). +idf_component_register( + SRCS "src/OtaUpdateManager.cpp" + INCLUDE_DIRS "include" + REQUIRES common # enums/FirmwareBootType.h, enums/OtaUpdateChannel.h, SemVer.h in public header + PRIV_REQUIRES core captive_portal config # gateway/wifi/WSGateway/AppHooks · CaptivePortal::ForceClose · Config:: + crypto fs http hwutil logging osjson temporal # Hashing, FsCheck, HTTPRequestManager, PartitionUtils, json + app_update esp_event esp_netif esp_wifi # esp_ota_ops.h, WiFi/IP event handlers +) + +# Match core: IDF compiles application components with -Werror, the legacy build did not. +target_compile_options(${COMPONENT_LIB} PRIVATE -Wno-error) diff --git a/include/OtaUpdateManager.h b/components/ota/include/OtaUpdateManager.h similarity index 67% rename from include/OtaUpdateManager.h rename to components/ota/include/OtaUpdateManager.h index 85082a01..4cf92bc1 100644 --- a/include/OtaUpdateManager.h +++ b/components/ota/include/OtaUpdateManager.h @@ -1,7 +1,7 @@ #pragma once -#include "FirmwareBootType.h" -#include "OtaUpdateChannel.h" +#include "enums/FirmwareBootType.h" +#include "enums/OtaUpdateChannel.h" #include "SemVer.h" #include @@ -9,6 +9,10 @@ #include #include +namespace OpenShock::HTTP { + class Client; +} + namespace OpenShock::OtaUpdateManager { [[nodiscard]] bool Init(); @@ -22,13 +26,16 @@ namespace OpenShock::OtaUpdateManager { /// @brief Asks the repository server for the head of a channel for this board. /// @param version Receives the version to install. When the hub is already current the server answers /// 204 and this is set to the running version, so an equality check skips the update. - bool TryGetFirmwareVersion(OtaUpdateChannel channel, OpenShock::SemVer& version); + bool TryGetFirmwareVersion(HTTP::Client& client, OtaUpdateChannel channel, OpenShock::SemVer& version); /// @brief Fetches the app and staticfs artifacts for a specific version of this board. - bool TryGetFirmwareRelease(const OpenShock::SemVer& version, FirmwareRelease& release); + bool TryGetFirmwareRelease(HTTP::Client& client, const OpenShock::SemVer& version, FirmwareRelease& release); bool TryStartFirmwareUpdate(const OpenShock::SemVer& version); + /// @brief Asks the update task to check the configured channel for a new version as soon as it can. + bool RequestUpdateCheck(); + FirmwareBootType GetFirmwareBootType(); bool IsValidatingApp(); diff --git a/src/OtaUpdateManager.cpp b/components/ota/src/OtaUpdateManager.cpp similarity index 71% rename from src/OtaUpdateManager.cpp rename to components/ota/src/OtaUpdateManager.cpp index 905398fd..2084a264 100644 --- a/src/OtaUpdateManager.cpp +++ b/components/ota/src/OtaUpdateManager.cpp @@ -2,35 +2,42 @@ const char* const TAG = "OtaUpdateManager"; +#include "AppHooks.h" #include "captiveportal/Manager.h" -#include "Common.h" #include "config/Config.h" -#include "Core.h" +#include "fs/FsCheck.h" #include "GatewayConnectionManager.h" #include "Hashing.h" #include "http/HTTPRequestManager.h" +#include "hwutil/PartitionUtils.h" #include "Logging.h" +#include "OpenShock.h" #include "SemVer.h" #include "serialization/WSGateway.h" #include "SimpleMutex.h" +#include "StringHelpers.h" +#include "Temporal.h" #include "util/HexUtils.h" -#include "util/PartitionUtils.h" -#include "util/StringUtils.h" #include "util/TaskUtils.h" #include "wifi/WiFiManager.h" +#include "json/Json.h" +#include +#include #include +#include #include +#include -#include -#include - -#include - -#include +#include +#include #include +#include #include +// Firmware version / commit. Regenerated every build, so this file is one of the +// few that recompile on a new commit. + using namespace std::string_view_literals; /// Repository server firmware API root. Paths per firmware-api-spec.md §4. @@ -38,25 +45,16 @@ using namespace std::string_view_literals; /// @brief Latest release for this board on a channel, with the hub's current version attached so the /// server can answer 204 when there is nothing to do. See spec §4.2. -#define OPENSHOCK_FW_REPO_LATEST_URL_FORMAT "https://%s" OPENSHOCK_FW_REPO_API_PREFIX "/latest/%s/" OPENSHOCK_FW_BOARD "?version=" OPENSHOCK_FW_VERSION +#define OPENSHOCK_FW_REPO_LATEST_URL_FORMAT "https://%s" OPENSHOCK_FW_REPO_API_PREFIX "/latest/%s/" OPENSHOCK_FW_BOARD "?version=%s" /// @brief Artifacts for one specific version of this board — the directed-update path. See spec §4.4. #define OPENSHOCK_FW_REPO_VERSION_URL_FORMAT "https://%s" OPENSHOCK_FW_REPO_API_PREFIX "/versions/%s/" OPENSHOCK_FW_BOARD -/// @brief Stops initArduino() from handling OTA rollbacks -/// @todo Get rid of Arduino entirely. >:( -/// -/// @see .platformio/packages/framework-arduinoespressif32/cores/esp32/esp32-hal-misc.c -/// @return true -bool verifyRollbackLater() -{ - return true; -} - enum OtaTaskEventFlag : uint32_t { OTA_TASK_EVENT_UPDATE_REQUESTED = 1 << 0, - OTA_TASK_EVENT_WIFI_DISCONNECTED = 1 << 1, // If both connected and disconnected are set, disconnected takes priority. + OTA_TASK_EVENT_WIFI_DISCONNECTED = 1 << 1, // If both connected and disconnected are set, the current link state decides. OTA_TASK_EVENT_WIFI_CONNECTED = 1 << 2, + OTA_TASK_EVENT_CHECK_REQUESTED = 1 << 3, }; static esp_ota_img_states_t _otaImageState; @@ -75,7 +73,7 @@ static bool otaum_try_notify_task(uint32_t eventFlag) } if (xTaskNotify(_taskHandle, eventFlag, eSetBits) != pdPASS) { - OS_LOGE(TAG, "Failed to notify OTA task (event: 0x%08x)", eventFlag); + OS_LOGE(TAG, "Failed to notify OTA task (event: 0x%08x)", static_cast(eventFlag)); return false; } @@ -172,7 +170,7 @@ static bool _sendFailureMessage(std::string_view message, bool fatal = false) return true; } -static bool otaum_flash_app_partition(const esp_partition_t* partition, std::string_view remoteUrl, const uint8_t (&remoteHash)[32]) +static bool otaum_flash_app_partition(OpenShock::HTTP::Client& client, const esp_partition_t* partition, std::string_view remoteUrl, const uint8_t (&remoteHash)[32]) { OS_LOGD(TAG, "Flashing app partition"); @@ -188,7 +186,7 @@ static bool otaum_flash_app_partition(const esp_partition_t* partition, std::str return true; }; - if (!OpenShock::FlashPartitionFromUrl(partition, remoteUrl, remoteHash, onProgress)) { + if (!OpenShock::FlashPartitionFromUrl(client, partition, remoteUrl, remoteHash, onProgress)) { OS_LOGE(TAG, "Failed to flash app partition"); _sendFailureMessage("Failed to flash app partition"sv); return false; @@ -208,7 +206,7 @@ static bool otaum_flash_app_partition(const esp_partition_t* partition, std::str return true; } -static bool otaum_flash_fs_partition(const esp_partition_t* parition, std::string_view remoteUrl, const uint8_t (&remoteHash)[32]) +static bool otaum_flash_fs_partition(OpenShock::HTTP::Client& client, const esp_partition_t* parition, std::string_view remoteUrl, const uint8_t (&remoteHash)[32]) { if (!otaum_send_progress_msg(Serialization::Types::OtaUpdateProgressTask::PreparingForUpdate, 0.0f)) { return false; @@ -235,7 +233,7 @@ static bool otaum_flash_fs_partition(const esp_partition_t* parition, std::strin return true; }; - if (!OpenShock::FlashPartitionFromUrl(parition, remoteUrl, remoteHash, onProgress)) { + if (!OpenShock::FlashPartitionFromUrl(client, parition, remoteUrl, remoteHash, onProgress)) { OS_LOGE(TAG, "Failed to flash filesystem partition"); _sendFailureMessage("Failed to flash filesystem partition"sv); return false; @@ -245,21 +243,51 @@ static bool otaum_flash_fs_partition(const esp_partition_t* parition, std::strin return false; } - // Attempt to mount filesystem. - fs::LittleFSFS test; - if (!test.begin(false, "/static", 10, "static0")) { + // Verify the freshly flashed image mounts cleanly. + if (!OpenShock::TestFilesystem(parition)) { OS_LOGE(TAG, "Failed to mount filesystem"); _sendFailureMessage("Failed to mount filesystem"sv); return false; } - test.end(); return true; } +// Only the timeout is changed; idle-core mask and panic behaviour stay as configured in sdkconfig. +static esp_err_t otaum_set_wdt_timeout(uint32_t timeoutSeconds) +{ + uint32_t idleCoreMask = 0; +#if CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0 + idleCoreMask |= 1U << 0; +#endif +#if CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1 + idleCoreMask |= 1U << 1; +#endif + + esp_task_wdt_config_t twdt_config = { + .timeout_ms = timeoutSeconds * 1000U, + .idle_core_mask = idleCoreMask, +#if CONFIG_ESP_TASK_WDT_PANIC + .trigger_panic = true, +#else + .trigger_panic = false, +#endif + }; + // ESP-IDF initializes the TWDT at startup (CONFIG_ESP_TASK_WDT_INIT, default y), so + // reconfigure it instead of re-initializing. esp_task_wdt_reconfigure() returns + // ESP_ERR_INVALID_STATE if the TWDT isn't initialized yet, in which case we fall back to init. + esp_err_t err = esp_task_wdt_reconfigure(&twdt_config); + if (err == ESP_ERR_INVALID_STATE) { + // Not yet initialized, fall back to init + return esp_task_wdt_init(&twdt_config); + } + + return err; +} + static void otaum_restore_wdt_timeout() { - if (esp_task_wdt_init(5, true) != ESP_OK) { + if (otaum_set_wdt_timeout(CONFIG_ESP_TASK_WDT_TIMEOUT_S) != ESP_OK) { OS_LOGE(TAG, "Failed to restore task watchdog timeout"); } }; @@ -272,6 +300,7 @@ static void otaum_updatetask(void* arg) bool connected = false; bool updateRequested = false; + bool checkRequested = false; int64_t lastUpdateCheck = 0; // Update task loop. @@ -281,14 +310,19 @@ static void otaum_updatetask(void* arg) xTaskNotifyWait(0, UINT32_MAX, &eventBits, pdMS_TO_TICKS(5000)); // TODO: wait for rest time updateRequested |= (eventBits & OTA_TASK_EVENT_UPDATE_REQUESTED) != 0; + checkRequested |= (eventBits & OTA_TASK_EVENT_CHECK_REQUESTED) != 0; + + bool gotConnected = (eventBits & OTA_TASK_EVENT_WIFI_CONNECTED) != 0; + bool gotDisconnected = (eventBits & OTA_TASK_EVENT_WIFI_DISCONNECTED) != 0; - if ((eventBits & OTA_TASK_EVENT_WIFI_DISCONNECTED) != 0) { + if (gotConnected && gotDisconnected) { + // Both edges were coalesced into one wait, so their order is unknown; ask for the current link state. + connected = WiFiManager::IsConnected(); + OS_LOGD(TAG, "WiFi connection changed, now %s", connected ? "connected" : "disconnected"); + } else if (gotDisconnected) { OS_LOGD(TAG, "WiFi disconnected"); connected = false; - continue; // No further processing needed. - } - - if ((eventBits & OTA_TASK_EVENT_WIFI_CONNECTED) != 0 && !connected) { + } else if (gotConnected && !connected) { OS_LOGD(TAG, "WiFi connected"); connected = true; } @@ -311,6 +345,11 @@ static void otaum_updatetask(void* arg) continue; } + if (updateRequested && !config.allowBackendManagement) { + OS_LOGW(TAG, "Ignoring backend update request, backend management is disabled"); + updateRequested = false; + } + bool firstCheck = lastUpdateCheck == 0; int64_t diff = now - lastUpdateCheck; int64_t diffMins = diff / 60'000LL; @@ -319,12 +358,14 @@ static void otaum_updatetask(void* arg) check |= config.checkOnStartup && firstCheck; // On startup check |= config.checkPeriodically && diffMins >= config.checkInterval; // Periodically check |= updateRequested && (firstCheck || diffMins >= 1); // Update requested + check |= checkRequested; // Check requested by the user if (!check) { continue; } lastUpdateCheck = now; + checkRequested = false; if (config.requireManualApproval) { OS_LOGD(TAG, "Manual approval required, skipping update check"); @@ -332,6 +373,10 @@ static void otaum_updatetask(void* arg) continue; } + // Reuse a single connection for this update's sequential CDN requests + // (version -> release -> filesystem binary -> app binary). + OpenShock::HTTP::Client client; + OpenShock::SemVer version; if (updateRequested) { updateRequested = false; @@ -344,7 +389,7 @@ static void otaum_updatetask(void* arg) OS_LOGD(TAG, "Checking for updates"); // Fetch current version. - if (!OtaUpdateManager::TryGetFirmwareVersion(config.updateChannel, version)) { + if (!OtaUpdateManager::TryGetFirmwareVersion(client, config.updateChannel, version)) { OS_LOGE(TAG, "Failed to fetch firmware version"); continue; } @@ -352,12 +397,12 @@ static void otaum_updatetask(void* arg) std::string versionStr = version.toString(); // TODO: This is abusing the SemVer::toString() method causing alot of string copies, fix this - if (versionStr == OPENSHOCK_FW_VERSION ""sv) { + if (versionStr == OpenShock::Constants::FW_VERSION) { OS_LOGI(TAG, "Requested version is already installed"); continue; } - OS_LOGD(TAG, "Updating to version: %.*s", versionStr.length(), versionStr.data()); + OS_LOGD(TAG, "Updating to version: %.*s", static_cast(versionStr.length()), versionStr.data()); // Generate random int32_t for this update. int32_t updateId = static_cast(esp_random()); @@ -381,7 +426,7 @@ static void otaum_updatetask(void* arg) // Fetch current release. OtaUpdateManager::FirmwareRelease release; - if (!OtaUpdateManager::TryGetFirmwareRelease(version, release)) { + if (!OtaUpdateManager::TryGetFirmwareRelease(client, version, release)) { OS_LOGE(TAG, "Failed to fetch firmware release"); // TODO: Send error message to server _sendFailureMessage("Failed to fetch firmware release"sv); continue; @@ -389,10 +434,10 @@ static void otaum_updatetask(void* arg) // Print release. OS_LOGD(TAG, "Firmware release:"); - OS_LOGD(TAG, " Version: %.*s", versionStr.length(), versionStr.data()); - OS_LOGD(TAG, " App binary URL: %.*s", release.appBinaryUrl.length(), release.appBinaryUrl.data()); + OS_LOGD(TAG, " Version: %.*s", static_cast(versionStr.length()), versionStr.data()); + OS_LOGD(TAG, " App binary URL: %.*s", static_cast(release.appBinaryUrl.length()), release.appBinaryUrl.data()); OS_LOGD(TAG, " App binary hash: %s", HexUtils::ToHex<32>(release.appBinaryHash).data()); - OS_LOGD(TAG, " Filesystem binary URL: %.*s", release.filesystemBinaryUrl.length(), release.filesystemBinaryUrl.data()); + OS_LOGD(TAG, " Filesystem binary URL: %.*s", static_cast(release.filesystemBinaryUrl.length()), release.filesystemBinaryUrl.data()); OS_LOGD(TAG, " Filesystem binary hash: %s", HexUtils::ToHex<32>(release.filesystemBinaryHash).data()); // Get available app update partition. @@ -404,7 +449,7 @@ static void otaum_updatetask(void* arg) } // Get filesystem partition. - const esp_partition_t* filesystemPartition = esp_partition_find_first(ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_SPIFFS, "static0"); + const esp_partition_t* filesystemPartition = FindStaticPartition(); if (filesystemPartition == nullptr) { OS_LOGE(TAG, "Failed to find filesystem partition"); _sendFailureMessage("Failed to find filesystem partition"sv); @@ -413,18 +458,28 @@ static void otaum_updatetask(void* arg) // Increase task watchdog timeout. // Prevents panics on some ESP32s when clearing large partitions. - if (esp_task_wdt_init(15, true) != ESP_OK) { + if (otaum_set_wdt_timeout(15) != ESP_OK) { OS_LOGE(TAG, "Failed to increase task watchdog timeout"); _sendFailureMessage("Failed to increase task watchdog timeout"sv); continue; } + // Flashing the filesystem force-closes the captive portal; on any failure (`continue`) it must be allowed to + // open again. On success the portal stays closed until the restart below. + struct ReopenPortalOnFailure { + bool succeeded = false; + ~ReopenPortalOnFailure() + { + if (!succeeded) CaptivePortal::ReleaseForceClose(); + } + } reopenPortalOnFailure; + // Flash app and filesystem partitions. - if (!otaum_flash_fs_partition(filesystemPartition, release.filesystemBinaryUrl, release.filesystemBinaryHash)) { + if (!otaum_flash_fs_partition(client, filesystemPartition, release.filesystemBinaryUrl, release.filesystemBinaryHash)) { otaum_restore_wdt_timeout(); continue; } - if (!otaum_flash_app_partition(appPartition, release.appBinaryUrl, release.appBinaryHash)) { + if (!otaum_flash_app_partition(client, appPartition, release.appBinaryUrl, release.appBinaryHash)) { otaum_restore_wdt_timeout(); continue; } @@ -445,6 +500,7 @@ static void otaum_updatetask(void* arg) // Reboot into new firmware. OS_LOGI(TAG, "Restarting into new firmware..."); + reopenPortalOnFailure.succeeded = true; vTaskDelay(pdMS_TO_TICKS(200)); break; } @@ -453,11 +509,6 @@ static void otaum_updatetask(void* arg) esp_restart(); } -struct CJsonDeleter { - void operator()(cJSON* json) const { cJSON_Delete(json); } -}; -using CJsonPtr = std::unique_ptr; - static const char* otaum_channel_name(OpenShock::OtaUpdateChannel channel) { switch (channel) { @@ -496,11 +547,12 @@ static bool otaum_try_get_repo_domain(std::string& domain) } /// @brief Issues a GET against the repository API and parses the body as JSON. +/// @param body Receives the response body. JsonView is zero-copy, so this must outlive `doc`. /// @param acceptedCodes Response codes to treat as success. 204 is handled by the caller and must not /// reach the parser — an empty body is not valid JSON. -static bool otaum_try_get_json(std::string_view url, tcb::span acceptedCodes, CJsonPtr& out, int& code) +static bool otaum_try_get_json(HTTP::Client& client, std::string_view url, std::span acceptedCodes, std::string& body, JSON::JsonDocument& doc, int& code) { - auto response = OpenShock::HTTP::GetString( + auto response = client.GetString( url, { {"Accept", "application/json"} @@ -513,15 +565,14 @@ static bool otaum_try_get_json(std::string_view url, tcb::span a } code = response.code; + body = std::move(response.data); // 204 carries no body; the caller decides what that means for the endpoint it called. - if (response.code == 204) { - out.reset(); + if (code == 204) { return true; } - out.reset(cJSON_ParseWithLength(response.data.c_str(), response.data.length())); - if (out == nullptr) { + if (!doc.parse(body)) { OS_LOGE(TAG, "Failed to parse response JSON"); return false; } @@ -531,9 +582,11 @@ static bool otaum_try_get_json(std::string_view url, tcb::span a bool OtaUpdateManager::Init() { + AppHooks::RegisterOtaUpdateManager(OtaUpdateManager::GetFirmwareBootType, OtaUpdateManager::TryStartFirmwareUpdate, OtaUpdateManager::RequestUpdateCheck); + esp_err_t err; - OS_LOGN(TAG, "Fetching current partition"); + OS_LOGD(TAG, "Fetching current partition"); // Fetch current partition info. const esp_partition_t* partition = esp_ota_get_running_partition(); @@ -559,18 +612,7 @@ bool OtaUpdateManager::Init() } // Infer boot type from update step. - switch (updateStep) { - case OtaUpdateStep::Updated: - _bootType = FirmwareBootType::NewFirmware; - break; - case OtaUpdateStep::Validating: // If the update step is validating, we have failed in the middle of validating the new firmware, meaning this is a rollback. - case OtaUpdateStep::RollingBack: - _bootType = FirmwareBootType::Rollback; - break; - default: - _bootType = FirmwareBootType::Normal; - break; - } + _bootType = OpenShock::InferFirmwareBootType(updateStep); if (updateStep == OtaUpdateStep::Updated) { if (!Config::SetOtaUpdateStep(OtaUpdateStep::Validating)) { @@ -599,11 +641,11 @@ bool OtaUpdateManager::Init() return true; } -bool OtaUpdateManager::TryGetFirmwareVersion(OtaUpdateChannel channel, OpenShock::SemVer& version) +bool OtaUpdateManager::TryGetFirmwareVersion(HTTP::Client& client, OtaUpdateChannel channel, OpenShock::SemVer& version) { const char* channelName = otaum_channel_name(channel); if (channelName == nullptr) { - OS_LOGE(TAG, "Unknown channel: %u", channel); + OS_LOGE(TAG, "Unknown channel: %u", static_cast(channel)); return false; } @@ -612,18 +654,18 @@ bool OtaUpdateManager::TryGetFirmwareVersion(OtaUpdateChannel channel, OpenShock return false; } - char uri[OPENSHOCK_URI_BUFFER_SIZE]; - int written = snprintf(uri, sizeof(uri), OPENSHOCK_FW_REPO_LATEST_URL_FORMAT, domain.c_str(), channelName); - if (written < 0 || static_cast(written) >= sizeof(uri)) { - OS_LOGE(TAG, "URI truncated for TryGetFirmwareVersion"); + std::string uri; + if (!FormatToString(uri, OPENSHOCK_FW_REPO_LATEST_URL_FORMAT, domain.c_str(), channelName, OpenShock::Constants::FW_VERSION)) { + OS_LOGE(TAG, "Failed to format URL"); return false; } - OS_LOGD(TAG, "Fetching latest firmware version from %s", uri); + OS_LOGD(TAG, "Fetching latest firmware version from %s", uri.c_str()); - CJsonPtr json; + std::string body; + JSON::JsonDocument doc; int code = 0; - if (!otaum_try_get_json(uri, std::array {200, 204, 304}, json, code)) { + if (!otaum_try_get_json(client, uri, std::array {200, 204}, body, doc, code)) { return false; } @@ -631,17 +673,18 @@ bool OtaUpdateManager::TryGetFirmwareVersion(OtaUpdateChannel channel, OpenShock // Report the running version back so the caller's "already installed" check short-circuits. if (code == 204) { OS_LOGD(TAG, "Already on the latest version for this channel"); - return OpenShock::TryParseSemVer(OPENSHOCK_FW_VERSION ""sv, version); + version = OpenShock::Constants::FirmwareVersion(); + return true; } - const cJSON* versionField = cJSON_GetObjectItemCaseSensitive(json.get(), "version"); - if (!cJSON_IsString(versionField) || versionField->valuestring == nullptr) { + std::string versionStr; + if (!doc.root()["version"].tryGetStr(versionStr)) { OS_LOGE(TAG, "Response is missing a 'version' string"); return false; } - if (!OpenShock::TryParseSemVer(versionField->valuestring, version)) { - OS_LOGE(TAG, "Failed to parse firmware version: %s", versionField->valuestring); + if (!OpenShock::TryParseSemVer(versionStr, version)) { + OS_LOGE(TAG, "Failed to parse firmware version: %.*s", static_cast(versionStr.size()), versionStr.data()); return false; } @@ -656,7 +699,7 @@ static bool _tryParseIntoHash(std::string_view hash, uint8_t (&hashBytes)[32]) } if (HexUtils::TryParseHex(hash.data(), hash.size(), hashBytes, 32) != 32) { - OS_LOGE(TAG, "Failed to parse hash: %.*s", hash.size(), hash.data()); + OS_LOGE(TAG, "Failed to parse hash: %.*s", static_cast(hash.size()), hash.data()); return false; } @@ -669,48 +712,44 @@ static bool _tryParseIntoHash(std::string_view hash, uint8_t (&hashBytes)[32]) /// ignored: it is a full-flash esptool image (bootloader at 0x1000, partition table at 0x8000, app at /// 0x10000, filesystem at its offset) meant for USB flashing a blank device. Writing it into an OTA app /// slot would put a bootloader image where the app belongs. See firmware-api-spec.md §9.2. -static bool otaum_parse_board_artifacts(const cJSON* root, OtaUpdateManager::FirmwareRelease& release) +static bool otaum_parse_board_artifacts(JSON::JsonView root, OtaUpdateManager::FirmwareRelease& release) { - const cJSON* artifacts = cJSON_GetObjectItemCaseSensitive(root, "artifacts"); - if (!cJSON_IsArray(artifacts)) { + auto artifacts = root["artifacts"]; + if (!artifacts.isArray()) { OS_LOGE(TAG, "Response is missing an 'artifacts' array"); return false; } bool foundApp = false, foundFilesystem = false; - const cJSON* artifact = nullptr; - cJSON_ArrayForEach(artifact, artifacts) - { - const cJSON* type = cJSON_GetObjectItemCaseSensitive(artifact, "type"); - const cJSON* url = cJSON_GetObjectItemCaseSensitive(artifact, "url"); - const cJSON* hash = cJSON_GetObjectItemCaseSensitive(artifact, "sha256Hash"); + int count = artifacts.count(); + for (int i = 0; i < count; i++) { + auto artifact = artifacts.at(i); - if (!cJSON_IsString(type) || !cJSON_IsString(url) || !cJSON_IsString(hash)) { + std::string typeStr, urlStr, hashStr; + if (!artifact["type"].tryGetStr(typeStr) || !artifact["url"].tryGetStr(urlStr) || !artifact["sha256Hash"].tryGetStr(hashStr)) { continue; } - std::string_view typeStr = type->valuestring; - if (typeStr == "app"sv) { if (foundApp) { OS_LOGE(TAG, "Duplicate 'app' artifact"); return false; } - if (!_tryParseIntoHash(hash->valuestring, release.appBinaryHash)) { + if (!_tryParseIntoHash(hashStr, release.appBinaryHash)) { return false; } - release.appBinaryUrl = url->valuestring; + release.appBinaryUrl = std::move(urlStr); foundApp = true; } else if (typeStr == "staticfs"sv) { if (foundFilesystem) { OS_LOGE(TAG, "Duplicate 'staticfs' artifact"); return false; } - if (!_tryParseIntoHash(hash->valuestring, release.filesystemBinaryHash)) { + if (!_tryParseIntoHash(hashStr, release.filesystemBinaryHash)) { return false; } - release.filesystemBinaryUrl = url->valuestring; + release.filesystemBinaryUrl = std::move(urlStr); foundFilesystem = true; } } @@ -728,7 +767,7 @@ static bool otaum_parse_board_artifacts(const cJSON* root, OtaUpdateManager::Fir return true; } -bool OtaUpdateManager::TryGetFirmwareRelease(const OpenShock::SemVer& version, FirmwareRelease& release) +bool OtaUpdateManager::TryGetFirmwareRelease(HTTP::Client& client, const OpenShock::SemVer& version, FirmwareRelease& release) { auto versionStr = version.toString(); // TODO: This is abusing the SemVer::toString() method causing alot of string copies, fix this @@ -737,22 +776,22 @@ bool OtaUpdateManager::TryGetFirmwareRelease(const OpenShock::SemVer& version, F return false; } - char uri[OPENSHOCK_URI_BUFFER_SIZE]; - int written = snprintf(uri, sizeof(uri), OPENSHOCK_FW_REPO_VERSION_URL_FORMAT, domain.c_str(), versionStr.c_str()); - if (written < 0 || static_cast(written) >= sizeof(uri)) { - OS_LOGE(TAG, "URI truncated for TryGetFirmwareRelease"); + std::string uri; + if (!FormatToString(uri, OPENSHOCK_FW_REPO_VERSION_URL_FORMAT, domain.c_str(), versionStr.c_str())) { + OS_LOGE(TAG, "Failed to format URL"); return false; } - OS_LOGD(TAG, "Fetching firmware release from %s", uri); + OS_LOGD(TAG, "Fetching firmware release from %s", uri.c_str()); - CJsonPtr json; + std::string body; + JSON::JsonDocument doc; int code = 0; - if (!otaum_try_get_json(uri, std::array {200, 304}, json, code)) { + if (!otaum_try_get_json(client, uri, std::array {200}, body, doc, code)) { return false; } - return otaum_parse_board_artifacts(json.get(), release); + return otaum_parse_board_artifacts(doc.root(), release); } bool OtaUpdateManager::TryStartFirmwareUpdate(const OpenShock::SemVer& version) @@ -762,6 +801,11 @@ bool OtaUpdateManager::TryStartFirmwareUpdate(const OpenShock::SemVer& version) return otaum_try_queue_update_request(version); } +bool OtaUpdateManager::RequestUpdateCheck() +{ + return otaum_try_notify_task(OTA_TASK_EVENT_CHECK_REQUESTED); +} + FirmwareBootType OtaUpdateManager::GetFirmwareBootType() { return _bootType; diff --git a/components/protocols/CMakeLists.txt b/components/protocols/CMakeLists.txt new file mode 100644 index 00000000..98dca806 --- /dev/null +++ b/components/protocols/CMakeLists.txt @@ -0,0 +1,23 @@ +# OpenShock RF shocker protocol encoders (RMT-based). +# +# Public surface: +# radio/rmt/Sequence.h — RMT sequence builder +# radio/rmt/CaiXianlinEncoder.h — CaiXianlin protocol +# radio/rmt/PetrainerEncoder.h — Petrainer protocol +# radio/rmt/Petrainer998DREncoder.h +# radio/rmt/D80Encoder.h +# radio/rmt/T330Encoder.h + +idf_component_register( + SRCS "src/CaiXianlinEncoder.cpp" + "src/D80Encoder.cpp" + "src/Petrainer998DREncoder.cpp" + "src/PetrainerEncoder.cpp" + "src/Sequence.cpp" + "src/T330Encoder.cpp" + INCLUDE_DIRS "include" + PRIV_INCLUDE_DIRS "src" # radio/rmt/internal/Shared.h (private) + REQUIRES common # enums/*, OpenShock.h in public headers; also Checksum.h internally + esp_driver_rmt # driver/rmt_types.h (rmt_symbol_word_t) in public headers + PRIV_REQUIRES logging # OS_LOG* (internal) +) diff --git a/components/protocols/host_test/CMakeLists.txt b/components/protocols/host_test/CMakeLists.txt new file mode 100644 index 00000000..7a079865 --- /dev/null +++ b/components/protocols/host_test/CMakeLists.txt @@ -0,0 +1,18 @@ +cmake_minimum_required(VERSION 3.16) + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) + +# main compiles protocols' pure encoder sources directly (see main/CMakeLists.txt), +# so no firmware component needs to be registered. +set(COMPONENTS main) + +# linux-target mocks: freertos (unity's IDF integration), and driver, whose include +# dirs carry the real / (rmt_symbol_word_t) on +# the linux target. Plus the shared host-test stand-ins (Logging.h, openshock_board.h, +# CONFIG_OPENSHOCK_*). +list(APPEND EXTRA_COMPONENT_DIRS + "$ENV{IDF_PATH}/tools/mocks/freertos/" + "$ENV{IDF_PATH}/tools/mocks/driver/" + "${CMAKE_CURRENT_LIST_DIR}/../../../test/host_support") + +project(protocols_host_test) diff --git a/components/protocols/host_test/main/CMakeLists.txt b/components/protocols/host_test/main/CMakeLists.txt new file mode 100644 index 00000000..2c2847fe --- /dev/null +++ b/components/protocols/host_test/main/CMakeLists.txt @@ -0,0 +1,25 @@ +# Compile protocols' PURE encoder sources directly (not via the protocols component) +# so we avoid its firmware-only transitive deps (common -> logging -> app_update has +# no linux port). rmt_symbol_word_t comes from ESP-IDF's own RMT headers, through the +# driver mock; Logging.h and openshock_board.h from test/host_support. +idf_component_register( + SRCS "test_main.cpp" + "test_encodebits.cpp" + "test_caixianlin.cpp" + "test_encoders.cpp" + "test_sequence.cpp" + "../../src/CaiXianlinEncoder.cpp" + "../../src/D80Encoder.cpp" + "../../src/Petrainer998DREncoder.cpp" + "../../src/PetrainerEncoder.cpp" + "../../src/T330Encoder.cpp" + "../../src/Sequence.cpp" + INCLUDE_DIRS "." + "../../include" # radio/rmt/*Encoder.h, Sequence.h + "../../src" # radio/rmt/internal/Shared.h (EncodeBits) + "../../../common/include" # enums/*, OpenShock.h, Checksum.h + REQUIRES unity + host_support # Logging.h, openshock_board.h (test/host_support) + driver # (tools/mocks/driver on linux) + WHOLE_ARCHIVE # keep the auto-registered TEST_CASEs from being stripped +) diff --git a/components/protocols/host_test/main/test_caixianlin.cpp b/components/protocols/host_test/main/test_caixianlin.cpp new file mode 100644 index 00000000..b6c3ea39 --- /dev/null +++ b/components/protocols/host_test/main/test_caixianlin.cpp @@ -0,0 +1,88 @@ +// CaiXianlin frame assembly: [preamble][shockerId:16][channel:4][type:4] +// [intensity:8][checksum:8][postamble:3]. These tests decode the emitted RMT +// symbols back to bits and check the layout, the command-type codes, intensity +// clamping, and the Sound-mutes-intensity quirk. +#include "unity.h" + +#include "Checksum.h" +#include "radio/rmt/CaiXianlinEncoder.h" + +#include + +using namespace OpenShock; + +static bool bitOf(const rmt_symbol_word_t& s) +{ + // One vs zero symbols differ by the leading pulse width (750 vs 250). + return s.duration0 > 500; +} + +// Decode n data symbols (MSB-first) back into an integer. +static uint64_t decode(const rmt_symbol_word_t* seq, size_t n) +{ + uint64_t v = 0; + for (size_t i = 0; i < n; i++) { + v = (v << 1) | (bitOf(seq[i]) ? 1U : 0U); + } + return v; +} + +TEST_CASE("CaiXianlin buffer is 44 symbols", "[protocols][caixianlin]") +{ + TEST_ASSERT_EQUAL(44, Rmt::CaiXianlinEncoder::GetBufferSize()); +} + +TEST_CASE("CaiXianlin rejects unsupported command types", "[protocols][caixianlin]") +{ + rmt_symbol_word_t seq[44]; + TEST_ASSERT_FALSE(Rmt::CaiXianlinEncoder::FillBuffer(seq, 0x1234, 5, ShockerCommandType::Stop, 42)); +} + +TEST_CASE("CaiXianlin encodes id/channel/type/intensity + checksum", "[protocols][caixianlin]") +{ + const uint16_t id = 0x1234; + const uint8_t channel = 5; + const uint8_t intensity = 42; + + rmt_symbol_word_t seq[44]; + TEST_ASSERT_TRUE(Rmt::CaiXianlinEncoder::FillBuffer(seq, id, channel, ShockerCommandType::Shock, intensity)); + + // Symbol 0 is the preamble (1400us high / 750us low). + TEST_ASSERT_EQUAL_UINT16(1400, seq[0].duration0); + TEST_ASSERT_EQUAL_UINT16(1, seq[0].level0); + TEST_ASSERT_EQUAL_UINT16(750, seq[0].duration1); + + // Symbols 1..43 carry 43 data bits: (payload<<8 | checksum) << 3. + const uint64_t tx = decode(seq + 1, 43); + TEST_ASSERT_EQUAL_UINT32(0, tx & 0x7); // 3-bit zero postamble + + const uint64_t frame = tx >> 3; // payload<<8 | checksum + const uint8_t checksum = frame & 0xFF; + const uint32_t payload = static_cast(frame >> 8); + + TEST_ASSERT_EQUAL_UINT16(id, (payload >> 16) & 0xFFFF); + TEST_ASSERT_EQUAL_UINT8(channel, (payload >> 12) & 0xF); + TEST_ASSERT_EQUAL_UINT8(0x01, (payload >> 8) & 0xF); // Shock -> 0x01 + TEST_ASSERT_EQUAL_UINT8(intensity, payload & 0xFF); + TEST_ASSERT_EQUAL_UINT8(Checksum::Sum8(payload), checksum); +} + +TEST_CASE("CaiXianlin clamps intensity to 99", "[protocols][caixianlin]") +{ + rmt_symbol_word_t seq[44]; + TEST_ASSERT_TRUE(Rmt::CaiXianlinEncoder::FillBuffer(seq, 1, 0, ShockerCommandType::Vibrate, 200)); + + const uint32_t payload = static_cast((decode(seq + 1, 43) >> 3) >> 8); + TEST_ASSERT_EQUAL_UINT8(99, payload & 0xFF); + TEST_ASSERT_EQUAL_UINT8(0x02, (payload >> 8) & 0xF); // Vibrate -> 0x02 +} + +TEST_CASE("CaiXianlin forces Sound intensity to zero", "[protocols][caixianlin]") +{ + rmt_symbol_word_t seq[44]; + TEST_ASSERT_TRUE(Rmt::CaiXianlinEncoder::FillBuffer(seq, 1, 0, ShockerCommandType::Sound, 55)); + + const uint32_t payload = static_cast((decode(seq + 1, 43) >> 3) >> 8); + TEST_ASSERT_EQUAL_UINT8(0x03, (payload >> 8) & 0xF); // Sound -> 0x03 + TEST_ASSERT_EQUAL_UINT8(0, payload & 0xFF); // intensity muted +} diff --git a/components/protocols/host_test/main/test_encodebits.cpp b/components/protocols/host_test/main/test_encodebits.cpp new file mode 100644 index 00000000..50cee5ce --- /dev/null +++ b/components/protocols/host_test/main/test_encodebits.cpp @@ -0,0 +1,52 @@ +// EncodeBits is the shared primitive behind every RF encoder: it maps the N +// most-significant meaningful bits of an integer to RMT one/zero symbols, +// MSB-first. A regression here silently corrupts every shocker command. +#include "unity.h" + +#include "radio/rmt/internal/Shared.h" + +#include + +using OpenShock::Rmt::Internal::EncodeBits; + +// Distinct one/zero symbols; only duration0 is inspected to classify a bit. +static const rmt_symbol_word_t ONE = {750, 1, 250, 0}; +static const rmt_symbol_word_t ZERO = {250, 1, 750, 0}; + +static bool isOne(const rmt_symbol_word_t& s) +{ + return s.duration0 == ONE.duration0; +} + +TEST_CASE("EncodeBits emits all N bits MSB-first", "[protocols][encodebits]") +{ + rmt_symbol_word_t seq[8]; + EncodeBits<8>(seq, static_cast(0b10110010), ONE, ZERO); + + const bool expected[8] = {1, 0, 1, 1, 0, 0, 1, 0}; + for (int i = 0; i < 8; i++) { + TEST_ASSERT_EQUAL(expected[i], isOne(seq[i])); + } +} + +TEST_CASE("EncodeBits takes the low N bits, MSB-first", "[protocols][encodebits]") +{ + // N=4 over a uint8: only the low nibble is transmitted, top nibble ignored. + rmt_symbol_word_t seq[4]; + EncodeBits<4>(seq, static_cast(0b1111'1010), ONE, ZERO); + + const bool expected[4] = {1, 0, 1, 0}; + for (int i = 0; i < 4; i++) { + TEST_ASSERT_EQUAL(expected[i], isOne(seq[i])); + } +} + +TEST_CASE("EncodeBits selects the correct symbol object", "[protocols][encodebits]") +{ + rmt_symbol_word_t seq[2]; + EncodeBits<2>(seq, static_cast(0b10), ONE, ZERO); + + // Bit 1 -> ONE symbol verbatim, bit 0 -> ZERO symbol verbatim. + TEST_ASSERT_EQUAL_UINT32(ONE.val, seq[0].val); + TEST_ASSERT_EQUAL_UINT32(ZERO.val, seq[1].val); +} diff --git a/components/protocols/host_test/main/test_encoders.cpp b/components/protocols/host_test/main/test_encoders.cpp new file mode 100644 index 00000000..eb6c0c3a --- /dev/null +++ b/components/protocols/host_test/main/test_encoders.cpp @@ -0,0 +1,246 @@ +// Frame layout tests for the Petrainer, Petrainer 998DR, Wellturn T330 and D80 encoders. Each test decodes the +// emitted RMT symbols back into bits and checks every field of the documented payload layout, so a refactor of the +// encoders (or of EncodeBits) can't silently change what goes on air. +#include "unity.h" + +#include "Checksum.h" +#include "radio/rmt/D80Encoder.h" +#include "radio/rmt/Petrainer998DREncoder.h" +#include "radio/rmt/PetrainerEncoder.h" +#include "radio/rmt/T330Encoder.h" + +#include + +using namespace OpenShock; + +// Decode `n` data symbols (MSB first); `isOne` tells a one symbol from a zero symbol for the protocol. +template +static uint64_t decode(const rmt_symbol_word_t* seq, size_t n, IsOne isOne) +{ + uint64_t v = 0; + for (size_t i = 0; i < n; i++) { + v = (v << 1) | (isOne(seq[i]) ? 1U : 0U); + } + return v; +} + +// --------------------------------------------------------------------------- +// Petrainer: [preamble][type:8][shockerId:16][intensity:8][typeSum:8][postamble] +// type = 0x80 | (1 << n), typeSum = ~(0x01 | (0x80 >> n)), n = 0/1/2 for shock/vibrate/sound +// --------------------------------------------------------------------------- +static bool petrainerOne(const rmt_symbol_word_t& s) +{ + return s.duration1 > 1000; // one = 200/1500, zero = 200/750 +} + +TEST_CASE("Petrainer frame layout", "[protocols][petrainer]") +{ + TEST_ASSERT_EQUAL(42, Rmt::PetrainerEncoder::GetBufferSize()); + + rmt_symbol_word_t seq[42]; + TEST_ASSERT_TRUE(Rmt::PetrainerEncoder::FillBuffer(seq, 0x1234, ShockerCommandType::Shock, 42)); + + TEST_ASSERT_EQUAL_UINT16(750, seq[0].duration0); // preamble + TEST_ASSERT_EQUAL_UINT16(7000, seq[41].duration1); // postamble + + const uint64_t data = decode(seq + 1, 40, petrainerOne); + TEST_ASSERT_EQUAL_UINT8(0x81, (data >> 32) & 0xFF); // 0x80 | 1<<0 + TEST_ASSERT_EQUAL_UINT16(0x1234, (data >> 16) & 0xFFFF); + TEST_ASSERT_EQUAL_UINT8(42, (data >> 8) & 0xFF); + TEST_ASSERT_EQUAL_UINT8(0x7E, data & 0xFF); // ~(0x01 | 0x80) +} + +TEST_CASE("Petrainer type codes, clamping and unsupported types", "[protocols][petrainer]") +{ + rmt_symbol_word_t seq[42]; + + TEST_ASSERT_TRUE(Rmt::PetrainerEncoder::FillBuffer(seq, 1, ShockerCommandType::Vibrate, 200)); + uint64_t data = decode(seq + 1, 40, petrainerOne); + TEST_ASSERT_EQUAL_UINT8(0x82, (data >> 32) & 0xFF); // 0x80 | 1<<1 + TEST_ASSERT_EQUAL_UINT8(100, (data >> 8) & 0xFF); // clamped + TEST_ASSERT_EQUAL_UINT8(0xBE, data & 0xFF); // ~(0x01 | 0x40) + + TEST_ASSERT_TRUE(Rmt::PetrainerEncoder::FillBuffer(seq, 1, ShockerCommandType::Sound, 10)); + data = decode(seq + 1, 40, petrainerOne); + TEST_ASSERT_EQUAL_UINT8(0x84, (data >> 32) & 0xFF); // 0x80 | 1<<2 + TEST_ASSERT_EQUAL_UINT8(0xDE, data & 0xFF); // ~(0x01 | 0x20) + + TEST_ASSERT_FALSE(Rmt::PetrainerEncoder::FillBuffer(seq, 1, ShockerCommandType::Light, 10)); + TEST_ASSERT_FALSE(Rmt::PetrainerEncoder::FillBuffer(seq, 1, ShockerCommandType::Stop, 10)); +} + +// --------------------------------------------------------------------------- +// Petrainer 998DR: [preamble][channel:4][type:4][shockerId:16][intensity:8][typeInvert:4][channelInvert:4][postamble] +// channel = 0b1000 (CH1); *Invert = bit-reversed, inverted nibble +// --------------------------------------------------------------------------- +static bool p998drOne(const rmt_symbol_word_t& s) +{ + return s.duration0 > 500; // one = 750/250, zero = 250/750 +} + +TEST_CASE("Petrainer 998DR frame layout", "[protocols][998dr]") +{ + TEST_ASSERT_EQUAL(42, Rmt::Petrainer998DREncoder::GetBufferSize()); + + rmt_symbol_word_t seq[42]; + TEST_ASSERT_TRUE(Rmt::Petrainer998DREncoder::FillBuffer(seq, 0xBEEF, ShockerCommandType::Shock, 55)); + + TEST_ASSERT_EQUAL_UINT16(1500, seq[0].duration0); // preamble + TEST_ASSERT_EQUAL_UINT16(3750, seq[41].duration1); // postamble + + const uint64_t data = decode(seq + 1, 40, p998drOne); + TEST_ASSERT_EQUAL_UINT8(0b1000, (data >> 36) & 0xF); // channel + TEST_ASSERT_EQUAL_UINT8(0b0001, (data >> 32) & 0xF); // shock + TEST_ASSERT_EQUAL_UINT16(0xBEEF, (data >> 16) & 0xFFFF); + TEST_ASSERT_EQUAL_UINT8(55, (data >> 8) & 0xFF); + TEST_ASSERT_EQUAL_UINT8(0b0111, (data >> 4) & 0xF); // reverse(0001)=1000, inverted + TEST_ASSERT_EQUAL_UINT8(0b1110, data & 0xF); // reverse(1000)=0001, inverted +} + +TEST_CASE("Petrainer 998DR type codes and clamping", "[protocols][998dr]") +{ + rmt_symbol_word_t seq[42]; + + struct Case { + ShockerCommandType type; + uint8_t typeVal; + uint8_t typeInvert; + }; + const Case cases[] = { + {ShockerCommandType::Vibrate, 0b0010, 0b1011}, + { ShockerCommandType::Sound, 0b0100, 0b1101}, + { ShockerCommandType::Light, 0b1000, 0b1110}, + }; + for (const Case& c : cases) { + TEST_ASSERT_TRUE(Rmt::Petrainer998DREncoder::FillBuffer(seq, 1, c.type, 250)); + const uint64_t data = decode(seq + 1, 40, p998drOne); + TEST_ASSERT_EQUAL_UINT8(c.typeVal, (data >> 32) & 0xF); + TEST_ASSERT_EQUAL_UINT8(100, (data >> 8) & 0xFF); // clamped + TEST_ASSERT_EQUAL_UINT8(c.typeInvert, (data >> 4) & 0xF); + } + + TEST_ASSERT_FALSE(Rmt::Petrainer998DREncoder::FillBuffer(seq, 1, ShockerCommandType::Stop, 10)); +} + +// --------------------------------------------------------------------------- +// Wellturn T330: [preamble][channel:4][typeHigh:4][shockerId:16][intensity:8][typeLow:4][channel:4][0][postamble] +// --------------------------------------------------------------------------- +static bool t330One(const rmt_symbol_word_t& s) +{ + return s.duration1 > 800; // one = 220/980, zero = 220/580 +} + +TEST_CASE("Wellturn T330 frame layout", "[protocols][t330]") +{ + TEST_ASSERT_EQUAL(43, Rmt::WellturnT330Encoder::GetBufferSize()); + + rmt_symbol_word_t seq[43]; + TEST_ASSERT_TRUE(Rmt::WellturnT330Encoder::FillBuffer(seq, 0x0F0F, ShockerCommandType::Vibrate, 77)); + + TEST_ASSERT_EQUAL_UINT16(960, seq[0].duration0); // preamble + TEST_ASSERT_EQUAL_UINT16(135, seq[42].duration1); // postamble + + const uint64_t bits = decode(seq + 1, 41, t330One); + TEST_ASSERT_EQUAL_UINT8(0, bits & 1); // trailing zero bit + const uint64_t data = bits >> 1; + TEST_ASSERT_EQUAL_UINT8(0, (data >> 36) & 0xF); // channel + TEST_ASSERT_EQUAL_UINT8(0x7, (data >> 32) & 0xF); // vibrate 0b0111'0010, high nibble + TEST_ASSERT_EQUAL_UINT16(0x0F0F, (data >> 16) & 0xFFFF); + TEST_ASSERT_EQUAL_UINT8(77, (data >> 8) & 0xFF); + TEST_ASSERT_EQUAL_UINT8(0x2, (data >> 4) & 0xF); // low nibble + TEST_ASSERT_EQUAL_UINT8(0, data & 0xF); // channel again +} + +TEST_CASE("Wellturn T330 shock/sound codes, clamping and unsupported types", "[protocols][t330]") +{ + rmt_symbol_word_t seq[43]; + + TEST_ASSERT_TRUE(Rmt::WellturnT330Encoder::FillBuffer(seq, 1, ShockerCommandType::Shock, 150)); + uint64_t data = decode(seq + 1, 41, t330One) >> 1; + TEST_ASSERT_EQUAL_UINT8(0x6, (data >> 32) & 0xF); // 0b0110'0001 + TEST_ASSERT_EQUAL_UINT8(0x1, (data >> 4) & 0xF); + TEST_ASSERT_EQUAL_UINT8(100, (data >> 8) & 0xFF); // clamped + + TEST_ASSERT_TRUE(Rmt::WellturnT330Encoder::FillBuffer(seq, 1, ShockerCommandType::Sound, 50)); + data = decode(seq + 1, 41, t330One) >> 1; + TEST_ASSERT_EQUAL_UINT8(0x8, (data >> 32) & 0xF); // 0b1000'0100 + TEST_ASSERT_EQUAL_UINT8(0x4, (data >> 4) & 0xF); + TEST_ASSERT_EQUAL_UINT8(0, (data >> 8) & 0xFF); // sound always sends 0 + + TEST_ASSERT_FALSE(Rmt::WellturnT330Encoder::FillBuffer(seq, 1, ShockerCommandType::Light, 10)); +} + +// --------------------------------------------------------------------------- +// D80: [preamble][0x04:8][shockerId:16][type:2][channel:2][intensity:4][checksum:8][postamble] +// intensity is scaled 0-100 -> 0-15 (non-zero never rounds down to 0); channel = 1 +// --------------------------------------------------------------------------- +static bool d80One(const rmt_symbol_word_t& s) +{ + return s.duration0 > 600; // one = 900/300, zero = 300/900 +} + +static uint32_t d80Payload(const rmt_symbol_word_t* seq, uint8_t* checksumOut) +{ + const uint64_t data = decode(seq + 1, 40, d80One); + *checksumOut = data & 0xFF; + return static_cast(data >> 8); +} + +TEST_CASE("D80 frame layout and checksum", "[protocols][d80]") +{ + TEST_ASSERT_EQUAL(42, Rmt::D80Encoder::GetBufferSize()); + + rmt_symbol_word_t seq[42]; + TEST_ASSERT_TRUE(Rmt::D80Encoder::FillBuffer(seq, 0x5A5A, ShockerCommandType::Shock, 100)); + + TEST_ASSERT_EQUAL_UINT16(1900, seq[0].duration0); // preamble + TEST_ASSERT_EQUAL_UINT16(2200, seq[41].duration1); // postamble + + uint8_t checksum; + const uint32_t payload = d80Payload(seq, &checksum); + TEST_ASSERT_EQUAL_UINT8(0x04, (payload >> 24) & 0xFF); + TEST_ASSERT_EQUAL_UINT16(0x5A5A, (payload >> 8) & 0xFFFF); + TEST_ASSERT_EQUAL_UINT8(0x1, (payload >> 6) & 0x3); // shock + TEST_ASSERT_EQUAL_UINT8(0x1, (payload >> 4) & 0x3); // channel + TEST_ASSERT_EQUAL_UINT8(15, payload & 0xF); // 100% -> 15 + TEST_ASSERT_EQUAL_UINT8(Checksum::Sum8(payload), checksum); +} + +TEST_CASE("D80 intensity scaling and clamping", "[protocols][d80]") +{ + rmt_symbol_word_t seq[42]; + uint8_t checksum; + + struct Case { + uint8_t in; + uint8_t out; + }; + const Case cases[] = { + { 0, 0}, + { 1, 1}, // never rounds a non-zero intensity down to off + { 50, 7}, + {100, 15}, + {107, 15}, // clamped (used to wrap to 0) + {255, 15}, // clamped (used to wrap to 6) + }; + for (const Case& c : cases) { + TEST_ASSERT_TRUE(Rmt::D80Encoder::FillBuffer(seq, 1, ShockerCommandType::Vibrate, c.in)); + const uint32_t payload = d80Payload(seq, &checksum); + TEST_ASSERT_EQUAL_UINT8_MESSAGE(c.out, payload & 0xF, "intensity"); + TEST_ASSERT_EQUAL_UINT8(0x2, (payload >> 6) & 0x3); // vibrate + } +} + +TEST_CASE("D80 sound sends zero intensity; unsupported types are rejected", "[protocols][d80]") +{ + rmt_symbol_word_t seq[42]; + uint8_t checksum; + + TEST_ASSERT_TRUE(Rmt::D80Encoder::FillBuffer(seq, 1, ShockerCommandType::Sound, 80)); + const uint32_t payload = d80Payload(seq, &checksum); + TEST_ASSERT_EQUAL_UINT8(0x3, (payload >> 6) & 0x3); + TEST_ASSERT_EQUAL_UINT8(0, payload & 0xF); + + TEST_ASSERT_FALSE(Rmt::D80Encoder::FillBuffer(seq, 1, ShockerCommandType::Light, 10)); + TEST_ASSERT_FALSE(Rmt::D80Encoder::FillBuffer(seq, 1, ShockerCommandType::Stop, 10)); +} diff --git a/components/protocols/host_test/main/test_main.cpp b/components/protocols/host_test/main/test_main.cpp new file mode 100644 index 00000000..7c6c46fc --- /dev/null +++ b/components/protocols/host_test/main/test_main.cpp @@ -0,0 +1,10 @@ +// Host-only (linux target) test binary for the protocols RF bit-encoders. +// FreeRTOS is mocked, so there is no ESP startup - drive Unity directly. +#include "unity.h" + +extern "C" int main(void) +{ + UNITY_BEGIN(); + unity_run_all_tests(); + return UNITY_END(); +} diff --git a/components/protocols/host_test/main/test_sequence.cpp b/components/protocols/host_test/main/test_sequence.cpp new file mode 100644 index 00000000..dbff04dc --- /dev/null +++ b/components/protocols/host_test/main/test_sequence.cpp @@ -0,0 +1,47 @@ +// Sequence is the model-dispatch layer over the per-protocol encoders: it sizes +// and allocates the RMT buffer for a shocker model and fills command frames. These +// tests exercise the dispatch for every supported model (a missing case would +// return a zero-size, invalid sequence). +#include "unity.h" + +#include "radio/rmt/Sequence.h" + +#include + +using namespace OpenShock; + +TEST_CASE("Sequence builds and fills a CaiXianlin buffer", "[protocols][sequence]") +{ + Rmt::Sequence seq(ShockerModelType::CaiXianlin, 0x4321, 0); + + TEST_ASSERT_TRUE(seq.is_valid()); + TEST_ASSERT_EQUAL(44, seq.size()); + TEST_ASSERT_EQUAL_INT(static_cast(ShockerModelType::CaiXianlin), static_cast(seq.shockerModel())); + TEST_ASSERT_EQUAL_UINT16(0x4321, seq.shockerId()); + TEST_ASSERT_TRUE(seq.fill(ShockerCommandType::Vibrate, 30)); +} + +TEST_CASE("Sequence dispatches every supported model", "[protocols][sequence]") +{ + const ShockerModelType models[] = { + ShockerModelType::CaiXianlin, + ShockerModelType::Petrainer, + ShockerModelType::Petrainer998DR, + ShockerModelType::WellturnT330, + ShockerModelType::D80, + }; + + for (ShockerModelType model : models) { + Rmt::Sequence seq(model, 1, 0); + TEST_ASSERT_TRUE(seq.is_valid()); + TEST_ASSERT_GREATER_THAN(0, seq.size()); + TEST_ASSERT_TRUE(seq.fill(ShockerCommandType::Shock, 50)); + } +} + +TEST_CASE("Default-constructed Sequence is invalid", "[protocols][sequence]") +{ + Rmt::Sequence seq; + TEST_ASSERT_FALSE(seq.is_valid()); + TEST_ASSERT_EQUAL(0, seq.size()); +} diff --git a/components/protocols/host_test/sdkconfig.defaults b/components/protocols/host_test/sdkconfig.defaults new file mode 100644 index 00000000..c3d7cf2c --- /dev/null +++ b/components/protocols/host_test/sdkconfig.defaults @@ -0,0 +1,4 @@ +CONFIG_IDF_TARGET="linux" +CONFIG_IDF_TARGET_LINUX=y +CONFIG_COMPILER_CXX_EXCEPTIONS=y +CONFIG_UNITY_ENABLE_IDF_TEST_RUNNER=y diff --git a/components/protocols/include/radio/rmt/CaiXianlinEncoder.h b/components/protocols/include/radio/rmt/CaiXianlinEncoder.h new file mode 100644 index 00000000..1514fd6f --- /dev/null +++ b/components/protocols/include/radio/rmt/CaiXianlinEncoder.h @@ -0,0 +1,12 @@ +#pragma once + +#include "enums/ShockerCommandType.h" + +#include + +#include + +namespace OpenShock::Rmt::CaiXianlinEncoder { + size_t GetBufferSize(); + bool FillBuffer(rmt_symbol_word_t* data, uint16_t shockerId, uint8_t channelId, ShockerCommandType type, uint8_t intensity); +} diff --git a/components/protocols/include/radio/rmt/D80Encoder.h b/components/protocols/include/radio/rmt/D80Encoder.h new file mode 100644 index 00000000..dc11aba3 --- /dev/null +++ b/components/protocols/include/radio/rmt/D80Encoder.h @@ -0,0 +1,12 @@ +#pragma once + +#include "enums/ShockerCommandType.h" + +#include + +#include + +namespace OpenShock::Rmt::D80Encoder { + size_t GetBufferSize(); + bool FillBuffer(rmt_symbol_word_t* data, uint16_t shockerId, ShockerCommandType type, uint8_t intensity); +} diff --git a/components/protocols/include/radio/rmt/Petrainer998DREncoder.h b/components/protocols/include/radio/rmt/Petrainer998DREncoder.h new file mode 100644 index 00000000..021a5863 --- /dev/null +++ b/components/protocols/include/radio/rmt/Petrainer998DREncoder.h @@ -0,0 +1,12 @@ +#pragma once + +#include "enums/ShockerCommandType.h" + +#include + +#include + +namespace OpenShock::Rmt::Petrainer998DREncoder { + size_t GetBufferSize(); + bool FillBuffer(rmt_symbol_word_t* data, uint16_t shockerId, ShockerCommandType type, uint8_t intensity); +} diff --git a/components/protocols/include/radio/rmt/PetrainerEncoder.h b/components/protocols/include/radio/rmt/PetrainerEncoder.h new file mode 100644 index 00000000..aa7094c8 --- /dev/null +++ b/components/protocols/include/radio/rmt/PetrainerEncoder.h @@ -0,0 +1,12 @@ +#pragma once + +#include "enums/ShockerCommandType.h" + +#include + +#include + +namespace OpenShock::Rmt::PetrainerEncoder { + size_t GetBufferSize(); + bool FillBuffer(rmt_symbol_word_t* data, uint16_t shockerId, ShockerCommandType type, uint8_t intensity); +} diff --git a/include/radio/rmt/Sequence.h b/components/protocols/include/radio/rmt/Sequence.h similarity index 53% rename from include/radio/rmt/Sequence.h rename to components/protocols/include/radio/rmt/Sequence.h index ba043258..84e65607 100644 --- a/include/radio/rmt/Sequence.h +++ b/components/protocols/include/radio/rmt/Sequence.h @@ -1,10 +1,10 @@ #pragma once -#include "Common.h" -#include "ShockerCommandType.h" -#include "ShockerModelType.h" +#include "enums/ShockerCommandType.h" +#include "enums/ShockerModelType.h" +#include "OpenShock.h" -#include +#include #include @@ -19,6 +19,7 @@ namespace OpenShock::Rmt { , m_transmitEnd(0) , m_shockerId(0) , m_shockerModel() + , m_terminatorSent(false) { } Sequence(ShockerModelType shockerModel, uint16_t shockerId, int64_t transmitEnd); @@ -28,6 +29,7 @@ namespace OpenShock::Rmt { , m_transmitEnd(other.m_transmitEnd) , m_shockerId(other.m_shockerId) , m_shockerModel(other.m_shockerModel) + , m_terminatorSent(other.m_terminatorSent) { other.reset(); } @@ -41,10 +43,14 @@ namespace OpenShock::Rmt { inline int64_t transmitEnd() const noexcept { return m_transmitEnd; } inline void setTransmitEnd(int64_t transmitEnd) noexcept { m_transmitEnd = transmitEnd; } - inline rmt_data_t* payload() noexcept { return m_data; } - inline const rmt_data_t* payload() const noexcept { return m_data; } - inline rmt_data_t* terminator() noexcept { return m_data + m_size; } - inline const rmt_data_t* terminator() const noexcept { return m_data + m_size; } + // Whether at least one terminator frame went out; a sequence must not be dropped before that. + inline bool terminatorSent() const noexcept { return m_terminatorSent; } + inline void markTerminatorSent() noexcept { m_terminatorSent = true; } + + inline rmt_symbol_word_t* payload() noexcept { return m_data; } + inline const rmt_symbol_word_t* payload() const noexcept { return m_data; } + inline rmt_symbol_word_t* terminator() noexcept { return m_data + m_size; } + inline const rmt_symbol_word_t* terminator() const noexcept { return m_data + m_size; } inline size_t size() const noexcept { return m_size; } bool fill(ShockerCommandType commandType, uint8_t intensity); @@ -55,11 +61,12 @@ namespace OpenShock::Rmt { free(m_data); - m_data = other.m_data; - m_size = other.m_size; - m_transmitEnd = other.m_transmitEnd; - m_shockerId = other.m_shockerId; - m_shockerModel = other.m_shockerModel; + m_data = other.m_data; + m_size = other.m_size; + m_transmitEnd = other.m_transmitEnd; + m_shockerId = other.m_shockerId; + m_shockerModel = other.m_shockerModel; + m_terminatorSent = other.m_terminatorSent; other.reset(); @@ -69,17 +76,19 @@ namespace OpenShock::Rmt { private: void reset() { - m_data = nullptr; - m_size = 0; - m_transmitEnd = 0; - m_shockerId = 0; - m_shockerModel = static_cast(0); + m_data = nullptr; + m_size = 0; + m_transmitEnd = 0; + m_shockerId = 0; + m_shockerModel = static_cast(0); + m_terminatorSent = false; } - rmt_data_t* m_data; + rmt_symbol_word_t* m_data; size_t m_size; int64_t m_transmitEnd; uint16_t m_shockerId; ShockerModelType m_shockerModel; + bool m_terminatorSent; }; } // namespace OpenShock::Rmt diff --git a/components/protocols/include/radio/rmt/T330Encoder.h b/components/protocols/include/radio/rmt/T330Encoder.h new file mode 100644 index 00000000..688e3573 --- /dev/null +++ b/components/protocols/include/radio/rmt/T330Encoder.h @@ -0,0 +1,12 @@ +#pragma once + +#include "enums/ShockerCommandType.h" + +#include + +#include + +namespace OpenShock::Rmt::WellturnT330Encoder { + size_t GetBufferSize(); + bool FillBuffer(rmt_symbol_word_t* data, uint16_t shockerId, ShockerCommandType type, uint8_t intensity); +} diff --git a/src/radio/rmt/CaiXianlinEncoder.cpp b/components/protocols/src/CaiXianlinEncoder.cpp similarity index 84% rename from src/radio/rmt/CaiXianlinEncoder.cpp rename to components/protocols/src/CaiXianlinEncoder.cpp index 72077146..45114367 100644 --- a/src/radio/rmt/CaiXianlinEncoder.cpp +++ b/components/protocols/src/CaiXianlinEncoder.cpp @@ -11,9 +11,9 @@ // It is based on the following documentation: // https://wiki.openshock.org/hardware/shockers/caixianlin/#rf-specification -const rmt_data_t kRmtPreamble = {1400, 1, 750, 0}; -const rmt_data_t kRmtOne = {750, 1, 250, 0}; -const rmt_data_t kRmtZero = {250, 1, 750, 0}; +const rmt_symbol_word_t kRmtPreamble = {1400, 1, 750, 0}; +const rmt_symbol_word_t kRmtOne = {750, 1, 250, 0}; +const rmt_symbol_word_t kRmtZero = {250, 1, 750, 0}; using namespace OpenShock; @@ -22,7 +22,7 @@ size_t Rmt::CaiXianlinEncoder::GetBufferSize() return 44; } -bool Rmt::CaiXianlinEncoder::FillBuffer(rmt_data_t* sequence, uint16_t shockerId, uint8_t channelId, ShockerCommandType type, uint8_t intensity) +bool Rmt::CaiXianlinEncoder::FillBuffer(rmt_symbol_word_t* sequence, uint16_t shockerId, uint8_t channelId, ShockerCommandType type, uint8_t intensity) { // Intensity must be between 0 and 99 intensity = std::min(intensity, static_cast(99)); diff --git a/src/radio/rmt/D80Encoder.cpp b/components/protocols/src/D80Encoder.cpp similarity index 80% rename from src/radio/rmt/D80Encoder.cpp rename to components/protocols/src/D80Encoder.cpp index b015eaef..8ca77ef9 100644 --- a/src/radio/rmt/D80Encoder.cpp +++ b/components/protocols/src/D80Encoder.cpp @@ -6,10 +6,10 @@ #include -const rmt_data_t kRmtPreamble = {1900, 1, 4000, 0}; -const rmt_data_t kRmtOne = {900, 1, 300, 0}; -const rmt_data_t kRmtZero = {300, 1, 900, 0}; -const rmt_data_t kRmtPostamble = {200, 1, 2200, 0}; +const rmt_symbol_word_t kRmtPreamble = {1900, 1, 4000, 0}; +const rmt_symbol_word_t kRmtOne = {900, 1, 300, 0}; +const rmt_symbol_word_t kRmtZero = {300, 1, 900, 0}; +const rmt_symbol_word_t kRmtPostamble = {200, 1, 2200, 0}; using namespace OpenShock; @@ -18,10 +18,11 @@ size_t Rmt::D80Encoder::GetBufferSize() return 42; } -bool Rmt::D80Encoder::FillBuffer(rmt_data_t* sequence, uint16_t shockerId, ShockerCommandType type, uint8_t intensity) +bool Rmt::D80Encoder::FillBuffer(rmt_symbol_word_t* sequence, uint16_t shockerId, ShockerCommandType type, uint8_t intensity) { // Intensity must be between 0 and 15, this should mimic the rounding of the original remote which // allows you to select from 1-99 when the protocol only has 4 bits for intensity (0-15). + intensity = std::min(intensity, static_cast(100)); if (intensity > 0) intensity = std::max((intensity * 15) / 100, 1); uint8_t typeVal = 0; diff --git a/src/radio/rmt/Petrainer998DREncoder.cpp b/components/protocols/src/Petrainer998DREncoder.cpp similarity index 76% rename from src/radio/rmt/Petrainer998DREncoder.cpp rename to components/protocols/src/Petrainer998DREncoder.cpp index 2572bb8b..ecf82fb6 100644 --- a/src/radio/rmt/Petrainer998DREncoder.cpp +++ b/components/protocols/src/Petrainer998DREncoder.cpp @@ -6,10 +6,10 @@ #include -const rmt_data_t kRmtPreamble = {1500, 1, 750, 0}; -const rmt_data_t kRmtOne = {750, 1, 250, 0}; -const rmt_data_t kRmtZero = {250, 1, 750, 0}; -const rmt_data_t kRmtPostamble = {250, 1, 3750, 0}; // Some subvariants expect a quiet period between commands, this is a last 1 bit followed by a very long pause +const rmt_symbol_word_t kRmtPreamble = {1500, 1, 750, 0}; +const rmt_symbol_word_t kRmtOne = {750, 1, 250, 0}; +const rmt_symbol_word_t kRmtZero = {250, 1, 750, 0}; +const rmt_symbol_word_t kRmtPostamble = {250, 1, 3750, 0}; // Some subvariants expect a quiet period between commands, this is a last 1 bit followed by a very long pause using namespace OpenShock; @@ -18,7 +18,7 @@ size_t Rmt::Petrainer998DREncoder::GetBufferSize() return 42; } -bool Rmt::Petrainer998DREncoder::FillBuffer(rmt_data_t* sequence, uint16_t shockerId, ShockerCommandType type, uint8_t intensity) +bool Rmt::Petrainer998DREncoder::FillBuffer(rmt_symbol_word_t* sequence, uint16_t shockerId, ShockerCommandType type, uint8_t intensity) { // Intensity must be between 0 and 100 intensity = std::min(intensity, static_cast(100)); diff --git a/src/radio/rmt/PetrainerEncoder.cpp b/components/protocols/src/PetrainerEncoder.cpp similarity index 77% rename from src/radio/rmt/PetrainerEncoder.cpp rename to components/protocols/src/PetrainerEncoder.cpp index 64afa549..57ffd0d1 100644 --- a/src/radio/rmt/PetrainerEncoder.cpp +++ b/components/protocols/src/PetrainerEncoder.cpp @@ -4,10 +4,10 @@ #include -const rmt_data_t kRmtPreamble = {750, 1, 750, 0}; -const rmt_data_t kRmtOne = {200, 1, 1500, 0}; -const rmt_data_t kRmtZero = {200, 1, 750, 0}; -const rmt_data_t kRmtPostamble = {200, 1, 7000, 0}; +const rmt_symbol_word_t kRmtPreamble = {750, 1, 750, 0}; +const rmt_symbol_word_t kRmtOne = {200, 1, 1500, 0}; +const rmt_symbol_word_t kRmtZero = {200, 1, 750, 0}; +const rmt_symbol_word_t kRmtPostamble = {200, 1, 7000, 0}; using namespace OpenShock; @@ -16,7 +16,7 @@ size_t Rmt::PetrainerEncoder::GetBufferSize() return 42; } -bool Rmt::PetrainerEncoder::FillBuffer(rmt_data_t* sequence, uint16_t shockerId, ShockerCommandType type, uint8_t intensity) +bool Rmt::PetrainerEncoder::FillBuffer(rmt_symbol_word_t* sequence, uint16_t shockerId, ShockerCommandType type, uint8_t intensity) { // Intensity must be between 0 and 100 intensity = std::min(intensity, static_cast(100)); diff --git a/components/protocols/src/Sequence.cpp b/components/protocols/src/Sequence.cpp new file mode 100644 index 00000000..0b497147 --- /dev/null +++ b/components/protocols/src/Sequence.cpp @@ -0,0 +1,84 @@ +#include "radio/rmt/Sequence.h" + +const char* const TAG = "Sequence"; + +#include "Logging.h" +#include "radio/rmt/CaiXianlinEncoder.h" +#include "radio/rmt/D80Encoder.h" +#include "radio/rmt/Petrainer998DREncoder.h" +#include "radio/rmt/PetrainerEncoder.h" +#include "radio/rmt/T330Encoder.h" + +using namespace OpenShock; + +namespace { + // One entry per supported model; adding a model means adding one line here. + struct EncoderInfo { + size_t (*bufferSize)(); + bool (*fill)(rmt_symbol_word_t* data, uint16_t shockerId, ShockerCommandType commandType, uint8_t intensity); + }; + + const EncoderInfo* findEncoder(ShockerModelType modelType) + { + static constexpr EncoderInfo kCaiXianlin { + Rmt::CaiXianlinEncoder::GetBufferSize, + [](rmt_symbol_word_t* data, uint16_t shockerId, ShockerCommandType commandType, uint8_t intensity) { return Rmt::CaiXianlinEncoder::FillBuffer(data, shockerId, 0, commandType, intensity); }, + }; + static constexpr EncoderInfo kPetrainer {Rmt::PetrainerEncoder::GetBufferSize, Rmt::PetrainerEncoder::FillBuffer}; + static constexpr EncoderInfo kPetrainer998DR {Rmt::Petrainer998DREncoder::GetBufferSize, Rmt::Petrainer998DREncoder::FillBuffer}; + static constexpr EncoderInfo kWellturnT330 {Rmt::WellturnT330Encoder::GetBufferSize, Rmt::WellturnT330Encoder::FillBuffer}; + static constexpr EncoderInfo kD80 {Rmt::D80Encoder::GetBufferSize, Rmt::D80Encoder::FillBuffer}; + + switch (modelType) { + case ShockerModelType::CaiXianlin: + return &kCaiXianlin; + case ShockerModelType::Petrainer: + return &kPetrainer; + case ShockerModelType::Petrainer998DR: + return &kPetrainer998DR; + case ShockerModelType::WellturnT330: + return &kWellturnT330; + case ShockerModelType::D80: + return &kD80; + default: + OS_LOGE(TAG, "Unknown shocker model: %u", static_cast(modelType)); + return nullptr; + } + } +} // namespace + +Rmt::Sequence::Sequence(ShockerModelType shockerModel, uint16_t shockerId, int64_t transmitEnd) + : m_data(nullptr) + , m_size(0) + , m_transmitEnd(transmitEnd) + , m_shockerId(shockerId) + , m_shockerModel(shockerModel) + , m_terminatorSent(false) +{ + const EncoderInfo* encoder = findEncoder(shockerModel); + if (encoder == nullptr) return; + + m_size = encoder->bufferSize(); + if (m_size == 0) return; + + m_data = static_cast(malloc(m_size * 2 * sizeof(rmt_symbol_word_t))); + if (m_data == nullptr) { + m_size = 0; + return; + } + + if (!encoder->fill(terminator(), m_shockerId, ShockerCommandType::Vibrate, 0)) { + free(m_data); + m_data = nullptr; + m_size = 0; + return; + } +} + +bool Rmt::Sequence::fill(ShockerCommandType commandType, uint8_t intensity) +{ + m_terminatorSent = false; // A new payload needs its own terminator again + + const EncoderInfo* encoder = findEncoder(m_shockerModel); + return encoder != nullptr && m_data != nullptr && encoder->fill(payload(), m_shockerId, commandType, intensity); +} diff --git a/src/radio/rmt/T330Encoder.cpp b/components/protocols/src/T330Encoder.cpp similarity index 80% rename from src/radio/rmt/T330Encoder.cpp rename to components/protocols/src/T330Encoder.cpp index 59896daa..8c4481e8 100644 --- a/src/radio/rmt/T330Encoder.cpp +++ b/components/protocols/src/T330Encoder.cpp @@ -4,10 +4,10 @@ #include -const rmt_data_t kRmtPreamble = {960, 1, 790, 0}; -const rmt_data_t kRmtOne = {220, 1, 980, 0}; -const rmt_data_t kRmtZero = {220, 1, 580, 0}; -const rmt_data_t kRmtPostamble = {220, 1, 135, 0}; +const rmt_symbol_word_t kRmtPreamble = {960, 1, 790, 0}; +const rmt_symbol_word_t kRmtOne = {220, 1, 980, 0}; +const rmt_symbol_word_t kRmtZero = {220, 1, 580, 0}; +const rmt_symbol_word_t kRmtPostamble = {220, 1, 135, 0}; using namespace OpenShock; @@ -16,7 +16,7 @@ size_t Rmt::WellturnT330Encoder::GetBufferSize() return 43; } -bool Rmt::WellturnT330Encoder::FillBuffer(rmt_data_t* sequence, uint16_t shockerId, ShockerCommandType type, uint8_t intensity) +bool Rmt::WellturnT330Encoder::FillBuffer(rmt_symbol_word_t* sequence, uint16_t shockerId, ShockerCommandType type, uint8_t intensity) { // Intensity must be between 0 and 100 intensity = std::min(intensity, static_cast(100)); diff --git a/include/radio/rmt/internal/Shared.h b/components/protocols/src/radio/rmt/internal/Shared.h similarity index 81% rename from include/radio/rmt/internal/Shared.h rename to components/protocols/src/radio/rmt/internal/Shared.h index a62f73b8..db924fa3 100644 --- a/include/radio/rmt/internal/Shared.h +++ b/components/protocols/src/radio/rmt/internal/Shared.h @@ -1,6 +1,6 @@ #pragma once -#include +#include #include #include @@ -8,7 +8,7 @@ namespace OpenShock::Rmt::Internal { template - constexpr void EncodeBits(rmt_data_t* sequence, T data, const rmt_data_t& rmtOne, const rmt_data_t& rmtZero) + constexpr void EncodeBits(rmt_symbol_word_t* sequence, T data, const rmt_symbol_word_t& rmtOne, const rmt_symbol_word_t& rmtZero) { static_assert(std::is_unsigned_v, "T must be an unsigned integer"); static_assert(N > 0, "N must be greater than 0"); diff --git a/components/rfc8908/CMakeLists.txt b/components/rfc8908/CMakeLists.txt new file mode 100644 index 00000000..c75f0428 --- /dev/null +++ b/components/rfc8908/CMakeLists.txt @@ -0,0 +1,9 @@ +# RFC 8908 + classic captive-detection HTTP handlers for esp_http_server: OS probe +# redirects and the machine-readable /captive-portal/api endpoint. Standalone so it's +# independent of the portal's app logic and the DNS responder. +idf_component_register( + SRCS "src/RFC8908Handler.cpp" + INCLUDE_DIRS "include" + REQUIRES esp_http_server # esp_http_server.h in public header + PRIV_REQUIRES logging osjson # .cpp-only (common was unused) +) diff --git a/components/rfc8908/include/rfc8908/RFC8908Handler.h b/components/rfc8908/include/rfc8908/RFC8908Handler.h new file mode 100644 index 00000000..360c9c89 --- /dev/null +++ b/components/rfc8908/include/rfc8908/RFC8908Handler.h @@ -0,0 +1,21 @@ +#pragma once + +#include + +namespace OpenShock::RFC8908 { + // Registers the OS captive-detection surface on an esp_http_server instance: + // - well-known probe URLs (/gen_204, /hotspot-detect*, /ncsi.txt, ...) → 302 to the portal + // - the RFC 8908 endpoint /captive-portal/api → application/captive+json + // - a 404 fallback that redirects any other unknown request to the portal + // + // `apIpv4` is the portal AP address (e.g. "4.3.2.1"); the pointer must remain valid + // for the lifetime of the server (pass a string literal or long-lived buffer). + // + // The server must be configured with httpd_uri_match_wildcard for the prefix probe + // paths to match. + esp_err_t RegisterProbeHandlers(httpd_handle_t server, const char* apIpv4); + + // Emit the standard captive redirect (302 → http:///). Exposed so a static + // file handler can share the exact same fallthrough behaviour on unknown paths. + esp_err_t EmitRedirect(httpd_req_t* req); +} // namespace OpenShock::RFC8908 diff --git a/components/rfc8908/src/RFC8908Handler.cpp b/components/rfc8908/src/RFC8908Handler.cpp new file mode 100644 index 00000000..b5541e85 --- /dev/null +++ b/components/rfc8908/src/RFC8908Handler.cpp @@ -0,0 +1,106 @@ +#include "rfc8908/RFC8908Handler.h" + +const char* const TAG = "RFC8908Handler"; + +#include "Logging.h" + +#include "json/Json.h" + +#include + +using namespace OpenShock; + +// The portal AP IP, captured at registration. Handlers are static C callbacks, so we +// keep it here; there is only ever one captive portal server at a time. +static const char* s_apIpv4 = "4.3.2.1"; + +static const char* const captivePortalApiPath = "/captive-portal/api"; + +esp_err_t RFC8908::EmitRedirect(httpd_req_t* req) +{ + std::string location = std::string("http://") + s_apIpv4 + "/"; + + httpd_resp_set_status(req, "302 Found"); + httpd_resp_set_hdr(req, "Location", location.c_str()); + httpd_resp_set_hdr(req, "Cache-Control", "no-store, no-cache, must-revalidate"); + httpd_resp_set_hdr(req, "Pragma", "no-cache"); + httpd_resp_set_hdr(req, "Expires", "0"); + return httpd_resp_send(req, nullptr, 0); +} + +// Catch-all handler for OS connectivity probes. Operating systems attempt to detect +// internet access by requesting well-known URLs; redirecting these to the portal root +// intentionally fails the connectivity check and activates captive-portal mode. +static esp_err_t probeHandler(httpd_req_t* req) +{ + return RFC8908::EmitRedirect(req); +} + +// RFC 8908 Captive Portal API endpoint — machine-readable captive state. Complements +// the classic redirect-based detection used by Android, iOS/macOS, and Windows. +static esp_err_t captiveApiHandler(httpd_req_t* req) +{ + OS_LOGI(TAG, "Got Captive API request"); + + std::string portalUrl = std::string("http://") + s_apIpv4 + "/"; + + OpenShock::JSON::StringWriter writer; + json_gen_str_t* gen = writer.gen(); + json_gen_start_object(gen); + json_gen_obj_set_bool(gen, "captive", true); + JSON::objSetString(gen, "user-portal-url", portalUrl.c_str()); + JSON::objSetString(gen, "venue-info-url", "https://openshock.org"); + json_gen_end_object(gen); + std::string jsonStr = writer.finish(); + + httpd_resp_set_status(req, "200 OK"); + httpd_resp_set_type(req, "application/captive+json"); + httpd_resp_set_hdr(req, "Cache-Control", "private"); + return httpd_resp_send(req, jsonStr.data(), jsonStr.size()); +} + +static esp_err_t err404Handler(httpd_req_t* req, httpd_err_code_t) +{ + return RFC8908::EmitRedirect(req); +} + +esp_err_t RFC8908::RegisterProbeHandlers(httpd_handle_t server, const char* apIpv4) +{ + if (apIpv4 != nullptr) { + s_apIpv4 = apIpv4; + } + + // Well-known OS probe paths. Prefix forms (e.g. "/hotspot-detect*") require the + // server to be configured with httpd_uri_match_wildcard. + static const char* const probePaths[] = { + captivePortalApiPath, // handled specially below + "/gen_204", + "/generate_204", + "/ncsi.txt", + "/hotspot-detect*", + "/success*", + "/connecttest*", + "/check_network_status*", + "/canonical*", + }; + + esp_err_t result = ESP_OK; + for (const char* path : probePaths) { + httpd_uri_t def = {}; + def.uri = path; + def.method = HTTP_GET; + def.handler = (path == captivePortalApiPath) ? captiveApiHandler : probeHandler; + def.user_ctx = nullptr; + + esp_err_t err = httpd_register_uri_handler(server, &def); + if (err != ESP_OK) { + OS_LOGE(TAG, "Failed to register probe handler %s: %s", path, esp_err_to_name(err)); + result = err; + } + } + + // Any other unknown request also redirects to the portal. + httpd_register_err_handler(server, HTTPD_404_NOT_FOUND, err404Handler); + + return result; +} diff --git a/components/serial/CMakeLists.txt b/components/serial/CMakeLists.txt new file mode 100644 index 00000000..57f36b6c --- /dev/null +++ b/components/serial/CMakeLists.txt @@ -0,0 +1,11 @@ +# OpenShock low-level serial transport — raw byte I/O to the device console +# (UART0 / USB-Serial-JTAG / USB-OTG CDC, selected by the ESP-IDF console sdkconfig). +# +# Foundation layer: depends only on the IDF console drivers so `logging` can write +# through it (Serial::Write) without a dependency cycle. It must NOT depend on +# `logging` — hence no OS_LOG here; Init() reports failure via its return value. +idf_component_register( + SRCS "src/Serial.cpp" + INCLUDE_DIRS "include" + REQUIRES esp_driver_uart esp_driver_usb_serial_jtag esp_usb_cdc_rom_console +) diff --git a/components/serial/include/serial/Serial.h b/components/serial/include/serial/Serial.h new file mode 100644 index 00000000..393576f6 --- /dev/null +++ b/components/serial/include/serial/Serial.h @@ -0,0 +1,49 @@ +#pragma once + +#include +#include +#include + +// Low-level serial transport for the device console (UART0 or USB-Serial-JTAG, +// selected by the ESP-IDF console sdkconfig). This is a foundation-layer +// component: it owns the raw byte I/O to the port and depends on nothing above +// the IDF drivers, so `logging` can write through it without a dependency cycle. +namespace OpenShock::Serial { + /** + * @brief Installs the console driver (UART0 / USB-Serial-JTAG) and routes stdout + * through it, unbuffered. + * Idempotent. + * + * @return true on success, false if the driver could not be installed. + */ + [[nodiscard]] bool Init(); + + /** + * @brief Non-blocking read of up to @p len bytes from the console. + * + * @param buffer Destination buffer. + * @param len Maximum number of bytes to read. + * @return Number of bytes read (0 if none available). + */ + int Read(uint8_t* buffer, std::size_t len); + + /** + * @brief Writes @p len raw bytes to the console. Before Init() the bytes go + * straight to the ROM serial output (early-boot logging), never C stdio. + * After Init() concurrent calls are serialized, so each call's bytes go + * out contiguously. Not callable from an ISR. + * + * @param data Source buffer. + * @param len Number of bytes to write. + * @return Number of bytes written. + */ + int Write(const uint8_t* data, std::size_t len); + + /** + * @brief printf-style Write(). Formats into a stack buffer, and only for long output (e.g. a config dump) into a + * heap buffer; if that allocation fails the output is truncated rather than dropped. + * + * @return Number of bytes written, or a negative value if formatting failed. + */ + int VWritef(const char* format, va_list args) __attribute__((format(printf, 1, 0))); +} // namespace OpenShock::Serial diff --git a/components/serial/src/Serial.cpp b/components/serial/src/Serial.cpp new file mode 100644 index 00000000..f2584fca --- /dev/null +++ b/components/serial/src/Serial.cpp @@ -0,0 +1,203 @@ +#include +#include + +#include "serial/Serial.h" + +#include + +#include +#include + +#include +#include +#include +#include + +// Select the console backend from the configured primary console. +#if defined(CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG) +#define OS_CONSOLE_USJ 1 +#include +#include +#elif defined(CONFIG_ESP_CONSOLE_USB_CDC) +// ROM USB-OTG CDC console (ESP32-S2, which has no USB-Serial-JTAG). Driven through +// the low-level console API rather than the cdcacm VFS, which would rewrite the +// "\r\n" our log lines already carry into "\r\r\n"; UART and USJ write raw too. +#define OS_CONSOLE_CDC 1 +#include +#elif defined(CONFIG_ESP_CONSOLE_UART) || defined(CONFIG_ESP_CONSOLE_UART_DEFAULT) || defined(CONFIG_ESP_CONSOLE_UART_CUSTOM) +#define OS_CONSOLE_UART 1 +#include +#include +#endif + +using namespace OpenShock; + +static constexpr int k_consoleBufferSize = 256; + +// Published with release ordering after s_writeMutex is created, so a writer on another core that sees it set also +// sees the mutex. +static std::atomic s_initialized = false; + +// Serializes writers (the logger and the console) so one Write() never +// interleaves with another, even when it is split into several driver calls. +static StaticSemaphore_t s_writeMutexStorage; +static SemaphoreHandle_t s_writeMutex = nullptr; + +// Byte-for-byte write to the ROM serial output. Used before the driver is +// installed (early-boot logging) or when no console driver is configured. This +// is a raw serial write, not C stdio. +static void romWrite(const uint8_t* data, std::size_t len) +{ + for (std::size_t i = 0; i < len; i++) { + esp_rom_output_putc(static_cast(data[i])); + } +} + +bool Serial::Init() +{ + if (s_initialized) { + return true; + } + +#if defined(OS_CONSOLE_USJ) + usb_serial_jtag_driver_config_t cfg = {.tx_buffer_size = k_consoleBufferSize, .rx_buffer_size = k_consoleBufferSize}; + + esp_err_t err = usb_serial_jtag_driver_install(&cfg); + if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) { + return false; + } + usb_serial_jtag_vfs_use_driver(); +#elif defined(OS_CONSOLE_UART) + // With a TX ring buffer, uart_write_bytes returns once the bytes are queued instead of waiting ~9 ms per 100-byte + // line at 115200 baud, so logging no longer stalls the caller (event loop, httpd, ...). OS_PANIC waits 5 s before + // restarting, which lets the buffer drain; only OS_PANIC_INSTANT can lose its last line. + static constexpr int k_uartTxBufferSize = 2048; + esp_err_t err = uart_driver_install(static_cast(CONFIG_ESP_CONSOLE_UART_NUM), k_consoleBufferSize, k_uartTxBufferSize, 0, nullptr, 0); + if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) { + return false; + } + uart_vfs_dev_use_driver(CONFIG_ESP_CONSOLE_UART_NUM); +#endif + // OS_CONSOLE_CDC: the startup code already brought the ROM CDC console up. + + s_writeMutex = xSemaphoreCreateMutexStatic(&s_writeMutexStorage); + + // OpenShock's own output goes through Write(); keep stdout unbuffered so anything + // still printing through C stdio (e.g. ESP-IDF's logs) isn't held back either. + std::setvbuf(stdout, nullptr, _IONBF, 0); + + s_initialized.store(true, std::memory_order_release); + return true; +} + +int Serial::Read(uint8_t* buffer, std::size_t len) +{ + if (buffer == nullptr || len == 0) { + return 0; + } + +#if defined(OS_CONSOLE_USJ) + int read = usb_serial_jtag_read_bytes(buffer, static_cast(len), 0); +#elif defined(OS_CONSOLE_UART) + int read = uart_read_bytes(static_cast(CONFIG_ESP_CONSOLE_UART_NUM), buffer, static_cast(len), 0); +#elif defined(OS_CONSOLE_CDC) + int read = static_cast(esp_usb_console_read_buf(reinterpret_cast(buffer), len)); +#else + int read = 0; +#endif + + return read < 0 ? 0 : read; +} + +// Writes to the installed console driver. Called with s_writeMutex held. +static int driverWrite(const uint8_t* data, std::size_t len) +{ +#if defined(OS_CONSOLE_USJ) + // The driver queues each call into its TX ring buffer as a single item and rejects + // anything larger than the buffer outright, so feed it buffer-sized chunks. + std::size_t sent = 0; + while (sent < len) { + std::size_t chunk = std::min(len - sent, static_cast(k_consoleBufferSize)); + int n = usb_serial_jtag_write_bytes(data + sent, chunk, pdMS_TO_TICKS(50)); + if (n <= 0) { + break; + } + sent += static_cast(n); + } + return static_cast(sent); +#elif defined(OS_CONSOLE_UART) + return uart_write_bytes(static_cast(CONFIG_ESP_CONSOLE_UART_NUM), data, len); +#elif defined(OS_CONSOLE_CDC) + // write_buf never blocks: it takes what fits in its TX buffer and returns 0 while the + // host isn't draining it. Retry for up to 50 ms (as the USJ path waits), then flush so + // prompts without a trailing newline go out too. + // Elapsed-time check: comparing against an absolute deadline breaks when the tick counter wraps. + std::size_t sent = 0; + TickType_t started = xTaskGetTickCount(); + while (sent < len) { + ssize_t n = esp_usb_console_write_buf(reinterpret_cast(data + sent), len - sent); + if (n < 0) { + break; + } + if (n == 0) { + if (xTaskGetTickCount() - started >= pdMS_TO_TICKS(50)) { + break; + } + vTaskDelay(1); + continue; + } + sent += static_cast(n); + } + esp_usb_console_flush(); + return static_cast(sent); +#else + romWrite(data, len); + return static_cast(len); +#endif +} + +int Serial::VWritef(const char* format, va_list args) +{ + char stackBuf[256]; + + // vsnprintf consumes the va_list, and a long line needs a second pass + va_list retryArgs; + va_copy(retryArgs, args); + + int len = vsnprintf(stackBuf, sizeof(stackBuf), format, args); + if (len <= 0 || static_cast(len) < sizeof(stackBuf)) { + va_end(retryArgs); + return len <= 0 ? len : Write(reinterpret_cast(stackBuf), static_cast(len)); + } + + char* heapBuf = static_cast(malloc(static_cast(len) + 1)); + if (heapBuf == nullptr) { + va_end(retryArgs); + return Write(reinterpret_cast(stackBuf), sizeof(stackBuf) - 1); + } + + vsnprintf(heapBuf, static_cast(len) + 1, format, retryArgs); + va_end(retryArgs); + + int written = Write(reinterpret_cast(heapBuf), static_cast(len)); + free(heapBuf); + return written; +} + +int Serial::Write(const uint8_t* data, std::size_t len) +{ + if (data == nullptr || len == 0) { + return 0; + } + + if (!s_initialized.load(std::memory_order_acquire)) { + romWrite(data, len); + return static_cast(len); + } + + xSemaphoreTake(s_writeMutex, portMAX_DELAY); + int written = driverWrite(data, len); + xSemaphoreGive(s_writeMutex); + + return written < 0 ? 0 : written; +} diff --git a/components/serial_console/CMakeLists.txt b/components/serial_console/CMakeLists.txt new file mode 100644 index 00000000..2bda522e --- /dev/null +++ b/components/serial_console/CMakeLists.txt @@ -0,0 +1,17 @@ +# OpenShock interactive console — SerialInputHandler + command handlers. +# +# The app-level command console: reads lines from the low-level `serial` transport +# and dispatches them against `core` (config, wifi, RF, OTA, ...). Entry-layer; +# nothing in core depends on it (only the top-level app entry does). Its public +# surface is just SerialInputHandler.h, so all deps are private. +FILE(GLOB_RECURSE console_sources + ${CMAKE_CURRENT_SOURCE_DIR}/src/*.cpp) + +idf_component_register( + SRCS ${console_sources} + INCLUDE_DIRS "include" + PRIV_REQUIRES core serial device_control crypto # core: wifi/gateway/http/serialization · serial: Serial::Init/Read · device_control: estop/CommandHandler/visual/radio · crypto: Base64 + common config logging temporal osjson chipset # used directly; previously only reachable through core's public REQUIRES +) + +target_compile_options(${COMPONENT_LIB} PRIVATE -Wno-error) diff --git a/components/serial_console/include/serial_console/SerialInputHandler.h b/components/serial_console/include/serial_console/SerialInputHandler.h new file mode 100644 index 00000000..153b096e --- /dev/null +++ b/components/serial_console/include/serial_console/SerialInputHandler.h @@ -0,0 +1,32 @@ +#pragma once + +#include +#include + +// Serial console output. TX goes through the raw serial transport +// (Serial::Write), the same path the logger uses, never C stdio: the IDF stdout +// VFS rewrites "\n" to "\r\n", which would double the CRLF every console line +// already carries. Each call issues a single Serial::Write, so a console line is +// never split by a concurrent log line, and lines without a trailing newline +// (prompts) are written out immediately. +// +// OS_SERIAL_PRINT takes a runtime string (never a format string, so '%' in the +// data is safe); OS_SERIAL_PRINTF takes a literal format + args; OS_SERIAL_PRINTLN +// takes an optional string literal (the "" concatenation appends the CRLF). +#define OS_SERIAL_PRINT(str) OpenShock::SerialInputHandler::Print(str) +#define OS_SERIAL_PRINTF(...) OpenShock::SerialInputHandler::Printf(__VA_ARGS__) +#define OS_SERIAL_PRINTLN(...) OpenShock::SerialInputHandler::Print("" __VA_ARGS__ "\r\n") + +namespace OpenShock::SerialInputHandler { + [[nodiscard]] bool Init(); + + bool SerialEchoEnabled(); + void SetSerialEchoEnabled(bool enabled); + + void PrintWelcomeHeader(); + void PrintVersionInfo(); + + // Raw console output; use the OS_SERIAL_* macros above. + void Print(std::string_view str); + void Printf(const char* format, ...) __attribute__((format(printf, 1, 2))); +} // namespace OpenShock::SerialInputHandler diff --git a/include/serial/command_handlers/CommandEntry.h b/components/serial_console/include/serial_console/command_handlers/CommandEntry.h similarity index 96% rename from include/serial/command_handlers/CommandEntry.h rename to components/serial_console/include/serial_console/command_handlers/CommandEntry.h index 4c254336..040e6096 100644 --- a/include/serial/command_handlers/CommandEntry.h +++ b/components/serial_console/include/serial_console/command_handlers/CommandEntry.h @@ -3,7 +3,7 @@ #include #include -namespace OpenShock::Serial { +namespace OpenShock::SerialCmds { typedef void (*CommandHandler)(std::string_view arg, bool isAutomated); class CommandArgument { @@ -53,4 +53,4 @@ namespace OpenShock::Serial { std::string_view m_name; std::vector m_commands; }; -} // namespace OpenShock::Serial +} // namespace OpenShock::SerialCmds diff --git a/components/serial_console/include/serial_console/command_handlers/common.h b/components/serial_console/include/serial_console/command_handlers/common.h new file mode 100644 index 00000000..06f479b1 --- /dev/null +++ b/components/serial_console/include/serial_console/command_handlers/common.h @@ -0,0 +1,14 @@ +#pragma once + +#include "serial_console/command_handlers/index.h" +#include "serial_console/SerialInputHandler.h" + +#include "Logging.h" + +#define SERPR_SYS(format, ...) OS_SERIAL_PRINTF("$SYS$|" format "\r\n", ##__VA_ARGS__) + +#define SERPR_RESPONSE(format, ...) SERPR_SYS("Response|" format, ##__VA_ARGS__) +#define SERPR_SUCCESS(format, ...) SERPR_SYS("Success|" format, ##__VA_ARGS__) +#define SERPR_ERROR(format, ...) SERPR_SYS("Error|" format, ##__VA_ARGS__) + +using namespace std::string_view_literals; diff --git a/components/serial_console/include/serial_console/command_handlers/index.h b/components/serial_console/include/serial_console/command_handlers/index.h new file mode 100644 index 00000000..b8ebde50 --- /dev/null +++ b/components/serial_console/include/serial_console/command_handlers/index.h @@ -0,0 +1,48 @@ +#pragma once + +#include "serial_console/command_handlers/CommandEntry.h" + +#include + +namespace OpenShock::SerialCmds::CommandHandlers { + OpenShock::SerialCmds::CommandGroup VersionHandler(); + OpenShock::SerialCmds::CommandGroup RestartHandler(); + OpenShock::SerialCmds::CommandGroup SysInfoHandler(); + OpenShock::SerialCmds::CommandGroup EchoHandler(); + OpenShock::SerialCmds::CommandGroup ValidGpiosHandler(); + OpenShock::SerialCmds::CommandGroup RfTxPinHandler(); + OpenShock::SerialCmds::CommandGroup EStopHandler(); + OpenShock::SerialCmds::CommandGroup DomainHandler(); + OpenShock::SerialCmds::CommandGroup AuthTokenHandler(); + OpenShock::SerialCmds::CommandGroup HostnameHandler(); + OpenShock::SerialCmds::CommandGroup NetworksHandler(); + OpenShock::SerialCmds::CommandGroup KeepAliveHandler(); + OpenShock::SerialCmds::CommandGroup JsonConfigHandler(); + OpenShock::SerialCmds::CommandGroup RawConfigHandler(); + OpenShock::SerialCmds::CommandGroup RfTransmitHandler(); + OpenShock::SerialCmds::CommandGroup FactoryResetHandler(); + OpenShock::SerialCmds::CommandGroup LedTestHandler(); + + inline std::vector AllCommandHandlers() + { + return { + VersionHandler(), + RestartHandler(), + SysInfoHandler(), + EchoHandler(), + ValidGpiosHandler(), + RfTxPinHandler(), + EStopHandler(), + DomainHandler(), + AuthTokenHandler(), + HostnameHandler(), + NetworksHandler(), + KeepAliveHandler(), + JsonConfigHandler(), + RawConfigHandler(), + RfTransmitHandler(), + FactoryResetHandler(), + LedTestHandler(), + }; + } +} // namespace OpenShock::SerialCmds::CommandHandlers diff --git a/src/serial/SerialInputHandler.cpp b/components/serial_console/src/SerialInputHandler.cpp similarity index 62% rename from src/serial/SerialInputHandler.cpp rename to components/serial_console/src/SerialInputHandler.cpp index 5682d5a5..4d77aa5c 100644 --- a/src/serial/SerialInputHandler.cpp +++ b/components/serial_console/src/SerialInputHandler.cpp @@ -1,59 +1,55 @@ -#include "serial/SerialInputHandler.h" +#include "serial_console/SerialInputHandler.h" const char* const TAG = "SerialInputHandler"; +#include "Base64.h" #include "Chipset.h" #include "CommandHandler.h" #include "config/Config.h" #include "config/SerialInputConfig.h" #include "Convert.h" -#include "Core.h" #include "estop/EStopManager.h" #include "FormatHelpers.h" #include "http/HTTPRequestManager.h" #include "Logging.h" -#include "serial/command_handlers/CommandEntry.h" -#include "serial/command_handlers/common.h" -#include "serial/command_handlers/index.h" +#include "OpenShock.h" +#include "serial/Serial.h" +#include "serial_console/command_handlers/CommandEntry.h" +#include "serial_console/command_handlers/common.h" +#include "serial_console/command_handlers/index.h" #include "serialization/JsonAPI.h" #include "serialization/JsonSerial.h" -#include "util/Base64Utils.h" -#include "util/StringUtils.h" +#include "StringHelpers.h" +#include "Temporal.h" #include "util/TaskUtils.h" #include "wifi/WiFiManager.h" -#include - -#include -#include - +#include +#include +#include #include #include #include -namespace std { +namespace { + // Case-insensitive hash/equality for the command lookup table. struct hash_ci { std::size_t operator()(std::string_view str) const noexcept { std::size_t hash = 7; - for (int i = 0; i < str.size(); ++i) { - hash = hash * 31 + tolower(str[i]); + for (char c : str) { + hash = hash * 31 + tolower(static_cast(c)); } return hash; } }; - template<> - struct less { - bool operator()(std::string_view a, std::string_view b) const { return a < b; } - }; - struct equals_ci { bool operator()(std::string_view a, std::string_view b) const { return OpenShock::StringIEquals(a, b); } }; -} // namespace std +} // namespace using namespace std::string_view_literals; @@ -63,8 +59,8 @@ const int64_t PASTE_INTERVAL_THRESHOLD_MS = 20; const std::size_t SERIAL_BUFFER_MAX_CAPACITY = 4096; static bool s_echoEnabled = true; -static std::vector s_commandGroups; -static std::unordered_map s_commandHandlers; +static std::vector s_commandGroups; +static std::unordered_map s_commandHandlers; static void printCompleteHelp() { @@ -124,10 +120,10 @@ static void printCompleteHelp() SerialInputHandler::PrintWelcomeHeader(); - OS_SERIAL_PRINT(buffer.data()); + OS_SERIAL_PRINT(buffer); } -static void printCommandHelp(Serial::CommandGroup& group) +static void printCommandHelp(SerialCmds::CommandGroup& group) { std::size_t size = 0; for (const auto& command : group.commands()) { @@ -257,7 +253,7 @@ static void printCommandHelp(Serial::CommandGroup& group) buffer.push_back('\r'); buffer.push_back('\n'); - OS_SERIAL_PRINT(buffer.data()); + OS_SERIAL_PRINT(buffer); } static void handleHelpCommand(std::string_view arg, bool isAutomated) @@ -275,10 +271,10 @@ static void handleHelpCommand(std::string_view arg, bool isAutomated) return; } - SERPR_ERROR("Command \"%.*s\" not found", arg.length(), arg.data()); + SERPR_ERROR("Command \"%.*s\" not found", static_cast(arg.length()), arg.data()); } -void RegisterCommandHandler(const OpenShock::Serial::CommandGroup& handler) +void RegisterCommandHandler(const OpenShock::SerialCmds::CommandGroup& handler) { s_commandHandlers[handler.name()] = handler; } @@ -309,13 +305,13 @@ class SerialBuffer { constexpr std::size_t capacity() const noexcept { return m_capacity; } constexpr bool empty() const noexcept { return m_size == 0; } - constexpr void clear() noexcept { m_size = 0; } - inline void destroy() + // Set when a line outgrew SERIAL_BUFFER_MAX_CAPACITY; the rest of that line is discarded and it is never executed. + constexpr bool overflowed() const noexcept { return m_overflowed; } + + constexpr void clear() noexcept { - delete[] m_data; - m_data = nullptr; - m_size = 0; - m_capacity = 0; + m_size = 0; + m_overflowed = false; } inline void reserve(std::size_t size) @@ -323,9 +319,10 @@ class SerialBuffer { size = (size + 31) & ~31; // Align to 32 bytes if (size > SERIAL_BUFFER_MAX_CAPACITY) { - OS_LOGE(TAG, "Refused to reserve %zu bytes, clearing buffer", size); - size = SERIAL_BUFFER_MAX_CAPACITY; - m_size = 0; + OS_LOGE(TAG, "Line exceeds %zu bytes, discarding it", SERIAL_BUFFER_MAX_CAPACITY); + m_size = 0; + m_overflowed = true; + return; } if (size <= m_capacity) { return; @@ -342,13 +339,21 @@ class SerialBuffer { m_data[m_capacity - 1] = 0; } - inline void push_back(char c) + inline void append(const char* data, std::size_t len) { - if (m_size >= m_capacity) { - reserve(m_size + 16); + if (len == 0 || m_overflowed) { + return; + } + + if (m_size + len > m_capacity) { + reserve(m_size + len); + if (m_overflowed) { + return; + } } - m_data[m_size++] = c; + std::memcpy(m_data + m_size, data, len); + m_size += len; } constexpr void pop_back() noexcept @@ -364,6 +369,7 @@ class SerialBuffer { char* m_data; std::size_t m_size; std::size_t m_capacity; + bool m_overflowed = false; }; enum class SerialReadResult { @@ -373,133 +379,124 @@ enum class SerialReadResult { AutoCompleteRequest, }; -static SerialReadResult tryReadSerialLine(SerialBuffer& buffer) +// Staging buffer: the console is read in chunks (Serial::Read returns the +// number of bytes read) into this buffer, and the parser scans the chunk in +// place. Bytes left over after an early return (line end / autocomplete) are +// carried across polls via s_rxStagingHead. +static uint8_t s_rxStaging[128]; +static std::size_t s_rxStagingHead = 0; +static std::size_t s_rxStagingLen = 0; + +// Ensures the staging buffer holds unconsumed bytes, refilling from the console +// when it has been drained. Returns false if no bytes are available. +static bool fillRxStaging() { - // Check if there's any data available - int available = OS_SERIAL.available(); - if (available <= 0) { - return SerialReadResult::NoData; + if (s_rxStagingHead < s_rxStagingLen) { + return true; } - // Reserve space for the new data - buffer.reserve(buffer.size() + available); - - // Read the data into the buffer - while (available-- > 0) { - char c = OS_SERIAL.read(); - - // Handle backspace - if (c == '\b') { - buffer.pop_back(); // Remove the last character from the buffer if it exists - continue; - } - - // Handle newline - if (c == '\r' || c == '\n') { - if (!buffer.empty()) { - return SerialReadResult::LineEnd; - } - continue; - } - - // Handle leading whitespace - if (c == ' ' && buffer.empty()) { - continue; - } - - if (c == '\t') { - return SerialReadResult::AutoCompleteRequest; - } - - // If character is printable, add it to the buffer - if (c > 31 && c < 127) { - buffer.push_back(c); - } + int read = Serial::Read(s_rxStaging, sizeof(s_rxStaging)); + if (read <= 0) { + return false; } - return SerialReadResult::Data; + s_rxStagingHead = 0; + s_rxStagingLen = static_cast(read); + return true; } -static void skipSerialWhitespaces(SerialBuffer& buffer) +static SerialReadResult tryReadSerialLine(SerialBuffer& buffer) { - int available = OS_SERIAL.available(); + bool gotData = false; - while (available-- > 0) { - char c = OS_SERIAL.read(); + while (fillRxStaging()) { + gotData = true; - if (c != ' ' && c != '\r' && c != '\n') { - buffer.push_back(c); - break; - } - } -} + const char* chunk = reinterpret_cast(s_rxStaging); + const std::size_t end = s_rxStagingLen; + std::size_t i = s_rxStagingHead; -#if ARDUINO_USB_MODE -static SerialReadResult tryReadUSBSerialLine(SerialBuffer& buffer) -{ - // Check if there's any data available - int available = OS_SERIAL_USB.available(); - if (available <= 0) { - return SerialReadResult::NoData; - } + while (i < end) { + char c = chunk[i]; - // Reserve space for the new data - buffer.reserve(buffer.size() + available); + // Backspace (BS, or DEL as most terminals send it): erase the last buffered character. + if (c == '\b' || c == '\x7F') { + buffer.pop_back(); + ++i; + continue; + } - // Read the data into the buffer - while (available-- > 0) { - char c = OS_SERIAL_USB.read(); + // Line end: dispatch the line, or swallow blank lines. + if (c == '\r' || c == '\n') { + ++i; + if (!buffer.empty() || buffer.overflowed()) { + s_rxStagingHead = i; + return SerialReadResult::LineEnd; + } + continue; + } - // Handle backspace - if (c == '\b') { - buffer.pop_back(); // Remove the last character from the buffer if it exists - continue; - } + // Tab: autocomplete request. + if (c == '\t') { + ++i; + s_rxStagingHead = i; + return SerialReadResult::AutoCompleteRequest; + } - // Handle newline - if (c == '\r' || c == '\n') { - if (!buffer.empty()) { - return SerialReadResult::LineEnd; + // Strip leading whitespace. + if (c == ' ' && buffer.empty()) { + ++i; + continue; } - continue; - } - // Handle leading whitespace - if (c == ' ' && buffer.empty()) { - continue; - } + // Bulk-append the maximal run of printable characters. Bytes we don't + // handle above (control codes, >= 0x7F) are dropped one at a time. + std::size_t runStart = i; + while (i < end) { + char d = chunk[i]; + if (d <= 31 || d >= 127) { + break; + } + ++i; + } - if (c == '\t') { - return SerialReadResult::AutoCompleteRequest; + if (i > runStart) { + buffer.append(chunk + runStart, i - runStart); + } else { + ++i; // drop the unhandled byte + } } - // If character is printable, add it to the buffer - if (c > 31 && c < 127) { - buffer.push_back(c); - } + s_rxStagingHead = i; // chunk fully consumed } - return SerialReadResult::Data; + return gotData ? SerialReadResult::Data : SerialReadResult::NoData; } -static void skipUSBSerialWhitespaces(SerialBuffer& buffer) +// Skips whitespace between lines; the first other byte is left in staging for tryReadSerialLine to filter. +static void skipSerialWhitespaces() { - int available = OS_SERIAL_USB.available(); - - while (available-- > 0) { - char c = OS_SERIAL_USB.read(); - - if (c != ' ' && c != '\r' && c != '\n') { - buffer.push_back(c); - break; + while (fillRxStaging()) { + const char* chunk = reinterpret_cast(s_rxStaging); + const std::size_t end = s_rxStagingLen; + std::size_t i = s_rxStagingHead; + + while (i < end) { + char c = chunk[i]; + if (c != ' ' && c != '\r' && c != '\n') { + s_rxStagingHead = i; + return; + } + ++i; } + + s_rxStagingHead = i; // drained; loop refills } } -#endif static void echoBuffer(std::string_view buffer) { - OS_SERIAL_PRINTF(CLEAR_LINE "> %.*s", buffer.size(), buffer.data()); + OS_SERIAL_PRINTF(CLEAR_LINE "> %.*s", static_cast(buffer.size()), buffer.data()); } static void echoHandleSerialInput(std::string_view buffer, bool hasData) @@ -549,9 +546,15 @@ static void processSerialLine(std::string_view line) OS_SERIAL_PRINTLN(); } + // Line may be just "$" (or "$" plus whitespace) + line = OpenShock::StringTrim(line); + if (line.empty()) { + return; + } + auto parts = OpenShock::StringSplit(line, ' ', 1); std::string_view command = OpenShock::StringTrim(parts[0]); - std::string_view arguments = parts.size() > 1 ? parts[1] : std::string_view(); + std::string_view arguments = parts.size() > 1 ? OpenShock::StringTrim(parts[1]) : std::string_view(); if (command == "help"sv) { handleHelpCommand(arguments, isAutomated); @@ -560,44 +563,47 @@ static void processSerialLine(std::string_view line) auto it = s_commandHandlers.find(command); if (it == s_commandHandlers.end()) { - SERPR_ERROR("Command \"%.*s\" not found", command.size(), command.data()); + SERPR_ERROR("Command \"%.*s\" not found", static_cast(command.size()), command.data()); return; } - // Get potential subcommand + // Get potential subcommand (arguments are trimmed, so the first token is never empty) std::string_view firstArg; - parts = OpenShock::StringSplit(arguments, ' '); - if (parts.size() > 1) { - firstArg = OpenShock::StringTrim(parts[0]); - } else { - firstArg = arguments; + std::string_view subArguments; + if (!arguments.empty()) { + parts = OpenShock::StringSplit(arguments, ' ', 1); + firstArg = parts[0]; + subArguments = parts.size() > 1 ? OpenShock::StringTrim(parts[1]) : std::string_view(); } // If the first argument is not empty, try to find a subcommand that matches if (!firstArg.empty()) { - for (Serial::CommandEntry& cmd : it->second.commands()) { + bool nameMatched = false; + for (SerialCmds::CommandEntry& cmd : it->second.commands()) { // Check subcommand name if (cmd.name() != firstArg) { continue; } + nameMatched = true; - // Check if the subcommand requires arguments - if (cmd.arguments().size() > 1 && parts.size() < 2) { - printCommandHelp(it->second); - return; + // A getter and a setter can share a name; skip variants that need arguments when none were given + if (cmd.arguments().size() > 0 && subArguments.empty()) { + continue; } - // Command found, remove the subcommand from the arguments - arguments = OpenShock::StringTrim(arguments.substr(firstArg.size())); - // Execute the subcommand - cmd.commandHandler()(arguments, isAutomated); + cmd.commandHandler()(subArguments, isAutomated); + return; + } + + if (nameMatched) { + printCommandHelp(it->second); return; } } // If no subcommand was found, try to find a default command - for (Serial::CommandEntry& cmd : it->second.commands()) { + for (SerialCmds::CommandEntry& cmd : it->second.commands()) { // Skip subcommands if (!cmd.name().empty()) { continue; @@ -614,7 +620,7 @@ static void processSerialLine(std::string_view line) return; } - SERPR_ERROR("Command \"%.*s\" not found", command.size(), command.data()); + SERPR_ERROR("Command \"%.*s\" not found", static_cast(command.size()), command.data()); } static void serialRxTask(void*) @@ -624,46 +630,19 @@ static void serialRxTask(void*) while (true) { switch (tryReadSerialLine(buffer)) { case SerialReadResult::LineEnd: - processSerialLine(buffer); - - // Deallocate memory if the buffer is too large - if (buffer.capacity() > SERIAL_BUFFER_MAX_CAPACITY) { - buffer.destroy(); + if (buffer.overflowed()) { + SERPR_ERROR("Line too long (max %zu bytes), ignored", SERIAL_BUFFER_MAX_CAPACITY); } else { - buffer.clear(); + processSerialLine(buffer); } - // Skip any remaining trailing whitespaces - skipSerialWhitespaces(buffer); - break; - case SerialReadResult::AutoCompleteRequest: - OS_SERIAL_PRINTF(CLEAR_LINE "> %.*s [AutoComplete is not implemented]", buffer.size(), buffer.data()); - break; - case SerialReadResult::Data: - echoHandleSerialInput(buffer, true); - break; - default: - echoHandleSerialInput(buffer, false); - break; - } - -#if ARDUINO_USB_MODE - switch (tryReadUSBSerialLine(buffer)) { - case SerialReadResult::LineEnd: - processSerialLine(buffer); - - // Deallocate memory if the buffer is too large - if (buffer.capacity() > SERIAL_BUFFER_MAX_CAPACITY) { - buffer.destroy(); - } else { - buffer.clear(); - } + buffer.clear(); // Skip any remaining trailing whitespaces - skipUSBSerialWhitespaces(buffer); + skipSerialWhitespaces(); break; case SerialReadResult::AutoCompleteRequest: - OS_SERIAL_PRINTF(CLEAR_LINE "> %.*s [AutoComplete is not implemented]", buffer.size(), buffer.data()); + OS_SERIAL_PRINTF(CLEAR_LINE "> %.*s [AutoComplete is not implemented]", static_cast(buffer.size()), buffer.data()); break; case SerialReadResult::Data: echoHandleSerialInput(buffer, true); @@ -672,7 +651,6 @@ static void serialRxTask(void*) echoHandleSerialInput(buffer, false); break; } -#endif vTaskDelay(pdMS_TO_TICKS(20)); // 50 Hz update rate } @@ -682,15 +660,21 @@ bool SerialInputHandler::Init() { static bool s_initialized = false; if (s_initialized) { - OS_LOGW(TAG, "Serial input handler already initialized"); + OS_LOGW(TAG, "SerialCmds input handler already initialized"); return false; } s_initialized = true; + // Install the console driver (RX here, and the raw TX path used by OS_SERIAL_*). + if (!Serial::Init()) { + OS_LOGE(TAG, "Failed to initialize serial console"); + return false; + } + // Register command handlers - s_commandGroups = OpenShock::Serial::CommandHandlers::AllCommandHandlers(); + s_commandGroups = OpenShock::SerialCmds::CommandHandlers::AllCommandHandlers(); for (const auto& handler : s_commandGroups) { - OS_LOGV(TAG, "Registering command handler: %.*s", handler.name().size(), handler.name().data()); + OS_LOGV(TAG, "Registering command handler: %.*s", static_cast(handler.name().size()), handler.name().data()); RegisterCommandHandler(handler); } @@ -720,6 +704,21 @@ void SerialInputHandler::SetSerialEchoEnabled(bool enabled) s_echoEnabled = enabled; } +void SerialInputHandler::Print(std::string_view str) +{ + Serial::Write(reinterpret_cast(str.data()), str.size()); +} + +// Formats the whole message first and hands it to Serial::Write in one call (as +// the logger does), so it is not interleaved with log output mid-line. +void SerialInputHandler::Printf(const char* format, ...) +{ + va_list args; + va_start(args, format); + Serial::VWritef(format, args); + va_end(args); +} + void SerialInputHandler::PrintWelcomeHeader() { OS_SERIAL_PRINTLN("\ @@ -733,11 +732,13 @@ void SerialInputHandler::PrintWelcomeHeader() void SerialInputHandler::PrintVersionInfo() { - OS_SERIAL_PRINT("\ - Version: " OPENSHOCK_FW_VERSION "\r\n\ - Build: " OPENSHOCK_FW_MODE "\r\n\ - Commit: " OPENSHOCK_FW_GIT_COMMIT "\r\n\ - Board: " OPENSHOCK_FW_BOARD "\r\n\ - Chip: " OPENSHOCK_FW_CHIP "\r\n\ -"); + OS_SERIAL_PRINTF( + " Version: %s\r\n" + " Build: " OPENSHOCK_FW_MODE "\r\n" + " Commit: %s\r\n" + " Board: " OPENSHOCK_FW_BOARD "\r\n" + " Chip: " OPENSHOCK_FW_CHIP "\r\n", + OpenShock::Constants::FW_VERSION, + OpenShock::Constants::FW_GIT_COMMIT + ); } diff --git a/src/serial/command_handlers/CommandEntry.cpp b/components/serial_console/src/command_handlers/CommandEntry.cpp similarity index 92% rename from src/serial/command_handlers/CommandEntry.cpp rename to components/serial_console/src/command_handlers/CommandEntry.cpp index 67080b46..7a882266 100644 --- a/src/serial/command_handlers/CommandEntry.cpp +++ b/components/serial_console/src/command_handlers/CommandEntry.cpp @@ -1,6 +1,6 @@ -#include "serial/command_handlers/CommandEntry.h" +#include "serial_console/command_handlers/CommandEntry.h" -using namespace OpenShock::Serial; +using namespace OpenShock::SerialCmds; CommandEntry::CommandEntry(std::string_view description, CommandHandler commandHandler) noexcept : m_description(description) diff --git a/src/serial/command_handlers/authtoken.cpp b/components/serial_console/src/command_handlers/authtoken.cpp similarity index 71% rename from src/serial/command_handlers/authtoken.cpp rename to components/serial_console/src/command_handlers/authtoken.cpp index 28d878d3..f2bb3c7d 100644 --- a/src/serial/command_handlers/authtoken.cpp +++ b/components/serial_console/src/command_handlers/authtoken.cpp @@ -1,6 +1,7 @@ -#include "serial/command_handlers/common.h" +#include "serial_console/command_handlers/common.h" #include "config/Config.h" +#include "GatewayConnectionManager.h" #include "http/JsonAPI.h" #include @@ -27,7 +28,7 @@ static void handleAuthtokenCommand(std::string_view arg, bool isAutomated) // If we have some other kind of request fault just set it anyway, we probably arent connected to a network - bool result = OpenShock::Config::SetBackendAuthToken(std::string(arg)); + bool result = OpenShock::GatewayConnectionManager::SetAuthToken(std::string(arg)); if (result) { SERPR_SUCCESS("Saved config"); @@ -36,11 +37,11 @@ static void handleAuthtokenCommand(std::string_view arg, bool isAutomated) } } -OpenShock::Serial::CommandGroup OpenShock::Serial::CommandHandlers::AuthTokenHandler() +OpenShock::SerialCmds::CommandGroup OpenShock::SerialCmds::CommandHandlers::AuthTokenHandler() { - auto group = OpenShock::Serial::CommandGroup("authtoken"sv); + auto group = OpenShock::SerialCmds::CommandGroup("authtoken"sv); - auto& getCommand = group.addCommand("Get the backend auth token"sv, handleAuthtokenCommand); + group.addCommand("Get the backend auth token"sv, handleAuthtokenCommand); auto& setCommand = group.addCommand("Set the auth token"sv, handleAuthtokenCommand); setCommand.addArgument("token"sv, "must be a string"sv, "mytoken"sv); diff --git a/src/serial/command_handlers/domain.cpp b/components/serial_console/src/command_handlers/domain.cpp similarity index 60% rename from src/serial/command_handlers/domain.cpp rename to components/serial_console/src/command_handlers/domain.cpp index 4d292691..e34fb85e 100644 --- a/src/serial/command_handlers/domain.cpp +++ b/components/serial_console/src/command_handlers/domain.cpp @@ -1,4 +1,4 @@ -#include "serial/command_handlers/common.h" +#include "serial_console/command_handlers/common.h" #include "config/Config.h" #include "http/HTTPRequestManager.h" @@ -6,9 +6,10 @@ #include +#include #include -const char* const TAG = "Serial::CommandHandlers::Domain"; +const char* const TAG = "SerialCmds::CommandHandlers::Domain"; static void handleDomainCommand(std::string_view arg, bool isAutomated) { @@ -24,15 +25,13 @@ static void handleDomainCommand(std::string_view arg, bool isAutomated) return; } - // Check if the domain is too long - // TODO: Remove magic number - if (arg.length() + 40 >= OPENSHOCK_URI_BUFFER_SIZE) { - SERPR_ERROR("Domain name too long, please try increasing the \"OPENSHOCK_URI_BUFFER_SIZE\" constant in source code"); + // 253 characters is the DNS limit for a full domain name + if (arg.length() > 253) { + SERPR_ERROR("Domain name too long (max 253 characters)"); return; } - char uri[OPENSHOCK_URI_BUFFER_SIZE]; - sprintf(uri, "https://%.*s/1", arg.length(), arg.data()); + std::string uri = "https://" + std::string(arg) + "/1"; auto resp = OpenShock::HTTP::GetJSON( uri, @@ -44,11 +43,11 @@ static void handleDomainCommand(std::string_view arg, bool isAutomated) ); if (resp.result != OpenShock::HTTP::RequestResult::Success) { - SERPR_ERROR("Tried to connect to \"%.*s\", but failed with status [%d] (%s), refusing to save domain to config", arg.length(), arg.data(), resp.code, resp.ResultToString()); + SERPR_ERROR("Tried to connect to \"%.*s\", but failed with status [%d] (%s), refusing to save domain to config", static_cast(arg.length()), arg.data(), resp.code, resp.ResultToString()); return; } - OS_LOGI(TAG, "Successfully connected to \"%.*s\", version: %s, commit: %s, current time: %s", arg.length(), arg.data(), resp.data.version.c_str(), resp.data.commit.c_str(), resp.data.currentTime.c_str()); + OS_LOGI(TAG, "Successfully connected to \"%.*s\", version: %s, commit: %s, current time: %s", static_cast(arg.length()), arg.data(), resp.data.version.c_str(), resp.data.commit.c_str(), resp.data.currentTime.c_str()); bool result = OpenShock::Config::SetBackendDomain(std::string(arg)); @@ -63,11 +62,11 @@ static void handleDomainCommand(std::string_view arg, bool isAutomated) esp_restart(); } -OpenShock::Serial::CommandGroup OpenShock::Serial::CommandHandlers::DomainHandler() +OpenShock::SerialCmds::CommandGroup OpenShock::SerialCmds::CommandHandlers::DomainHandler() { - auto group = OpenShock::Serial::CommandGroup("domain"sv); + auto group = OpenShock::SerialCmds::CommandGroup("domain"sv); - auto& getCommand = group.addCommand("Get the backend domain."sv, handleDomainCommand); + group.addCommand("Get the backend domain."sv, handleDomainCommand); auto& setCommand = group.addCommand("Set the backend domain."sv, handleDomainCommand); setCommand.addArgument("domain"sv, "must be a string"sv, "api.shocklink.net"sv); diff --git a/src/serial/command_handlers/echo.cpp b/components/serial_console/src/command_handlers/echo.cpp similarity index 72% rename from src/serial/command_handlers/echo.cpp rename to components/serial_console/src/command_handlers/echo.cpp index 935329cb..b858a7f8 100644 --- a/src/serial/command_handlers/echo.cpp +++ b/components/serial_console/src/command_handlers/echo.cpp @@ -1,10 +1,10 @@ -#include "serial/command_handlers/common.h" +#include "serial_console/command_handlers/common.h" -#include "serial/SerialInputHandler.h" +#include "serial_console/SerialInputHandler.h" #include "config/Config.h" #include "Convert.h" -#include "util/StringUtils.h" +#include "StringHelpers.h" static void handleSerialEchoCommand(std::string_view arg, bool isAutomated) { @@ -30,11 +30,11 @@ static void handleSerialEchoCommand(std::string_view arg, bool isAutomated) } } -OpenShock::Serial::CommandGroup OpenShock::Serial::CommandHandlers::EchoHandler() +OpenShock::SerialCmds::CommandGroup OpenShock::SerialCmds::CommandHandlers::EchoHandler() { - auto group = OpenShock::Serial::CommandGroup("echo"sv); + auto group = OpenShock::SerialCmds::CommandGroup("echo"sv); - auto& getCommand = group.addCommand("Get the serial echo status"sv, handleSerialEchoCommand); + group.addCommand("Get the serial echo status"sv, handleSerialEchoCommand); auto& setCommand = group.addCommand("Enable/disable serial echo"sv, handleSerialEchoCommand); setCommand.addArgument("enabled"sv, "must be a boolean"sv, "true"sv); diff --git a/src/serial/command_handlers/estop.cpp b/components/serial_console/src/command_handlers/estop.cpp similarity index 69% rename from src/serial/command_handlers/estop.cpp rename to components/serial_console/src/command_handlers/estop.cpp index 859bc63b..db3d60fe 100644 --- a/src/serial/command_handlers/estop.cpp +++ b/components/serial_console/src/command_handlers/estop.cpp @@ -1,4 +1,4 @@ -#include "serial/command_handlers/common.h" +#include "serial_console/command_handlers/common.h" #include "config/Config.h" #include "Convert.h" @@ -24,12 +24,7 @@ static void handleEStopEnabledCommand(std::string_view arg, bool isAutomated) } if (!OpenShock::EStopManager::SetEStopEnabled(enabled)) { - SERPR_ERROR("Failed to set EStop enabled"); - return; - } - - if (!OpenShock::Config::SetEStopEnabled(enabled)) { - SERPR_ERROR("Failed to save config"); + SERPR_ERROR("Failed to set EStop enabled (refused while the EStop is active)"); return; } @@ -56,27 +51,22 @@ static void handleEStopPinCommand(std::string_view arg, bool isAutomated) } if (!OpenShock::EStopManager::SetEStopPin(estopPin)) { - SERPR_ERROR("Failed to set EStop pin"); - return; - } - - if (!OpenShock::Config::SetEStopGpioPin(estopPin)) { - SERPR_ERROR("Failed to save config"); + SERPR_ERROR("Failed to set EStop pin (invalid pin, or refused while the EStop is active)"); return; } SERPR_SUCCESS("Saved config"); } -OpenShock::Serial::CommandGroup OpenShock::Serial::CommandHandlers::EStopHandler() +OpenShock::SerialCmds::CommandGroup OpenShock::SerialCmds::CommandHandlers::EStopHandler() { - auto group = OpenShock::Serial::CommandGroup("estop"sv); + auto group = OpenShock::SerialCmds::CommandGroup("estop"sv); - auto& getEnabledCommand = group.addCommand("enabled"sv, "Get the E-Stop enabled state."sv, handleEStopEnabledCommand); + group.addCommand("enabled"sv, "Get the E-Stop enabled state."sv, handleEStopEnabledCommand); auto& setEnabledCommand = group.addCommand("enabled"sv, "Set the E-Stop enabled state."sv, handleEStopEnabledCommand); setEnabledCommand.addArgument("enabled"sv, "must be a boolean"sv, "true"sv); - auto& getPinCommand = group.addCommand("pin"sv, "Get the GPIO pin used for the E-Stop."sv, handleEStopPinCommand); + group.addCommand("pin"sv, "Get the GPIO pin used for the E-Stop."sv, handleEStopPinCommand); auto& setPinCommand = group.addCommand("pin"sv, "Set the GPIO pin used for the E-Stop."sv, handleEStopPinCommand); setPinCommand.addArgument("pin"sv, "must be a number"sv, "4"sv); diff --git a/components/serial_console/src/command_handlers/factoryreset.cpp b/components/serial_console/src/command_handlers/factoryreset.cpp new file mode 100644 index 00000000..8aaf91b9 --- /dev/null +++ b/components/serial_console/src/command_handlers/factoryreset.cpp @@ -0,0 +1,25 @@ +#include "serial_console/command_handlers/common.h" +#include "serial_console/SerialInputHandler.h" + +#include "config/Config.h" + +#include + +static void handleFactoryResetCommand(std::string_view arg, bool isAutomated) +{ + (void)arg; + + OS_SERIAL_PRINTLN("Resetting to factory defaults..."); + OpenShock::Config::FactoryReset(); + OS_SERIAL_PRINTLN("Restarting..."); + esp_restart(); +} + +OpenShock::SerialCmds::CommandGroup OpenShock::SerialCmds::CommandHandlers::FactoryResetHandler() +{ + auto group = OpenShock::SerialCmds::CommandGroup("factoryreset"sv); + + group.addCommand("Reset the hub to factory defaults and restart"sv, handleFactoryResetCommand); + + return group; +} diff --git a/components/serial_console/src/command_handlers/hostname.cpp b/components/serial_console/src/command_handlers/hostname.cpp new file mode 100644 index 00000000..f51007aa --- /dev/null +++ b/components/serial_console/src/command_handlers/hostname.cpp @@ -0,0 +1,65 @@ +#include "serial_console/command_handlers/common.h" + +#include "config/Config.h" + +#include + +#include + +const char* const TAG = "SerialCmds::CommandHandlers::Hostname"; + +// RFC 1123 label: 1-32 letters, digits and hyphens (esp_netif limit), not starting or ending with a hyphen. +static bool isValidHostname(std::string_view hostname) +{ + if (hostname.empty() || hostname.size() > 32 || hostname.front() == '-' || hostname.back() == '-') { + return false; + } + + for (char c : hostname) { + bool ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-'; + if (!ok) { + return false; + } + } + + return true; +} + +static void handleHostnameCommand(std::string_view arg, bool isAutomated) +{ + if (arg.empty()) { + std::string hostname; + if (!OpenShock::Config::GetWiFiHostname(hostname)) { + SERPR_ERROR("Failed to get hostname from config"); + return; + } + // Get hostname + SERPR_RESPONSE("Hostname|%s", hostname.c_str()); + return; + } + + if (!isValidHostname(arg)) { + SERPR_ERROR("Invalid hostname: use 1-32 letters, digits or hyphens, not starting or ending with a hyphen"); + return; + } + + bool result = OpenShock::Config::SetWiFiHostname(std::string(arg)); + if (result) { + SERPR_SUCCESS("Saved config, restarting..."); + esp_restart(); + } else { + SERPR_ERROR("Failed to save config"); + } +} + +OpenShock::SerialCmds::CommandGroup OpenShock::SerialCmds::CommandHandlers::HostnameHandler() +{ + auto group = OpenShock::SerialCmds::CommandGroup("hostname"sv); + + group.addCommand("Get the network hostname."sv, handleHostnameCommand); + + auto& setCommand = group.addCommand("Set the network hostname."sv, handleHostnameCommand); + setCommand.addArgument("hostname"sv, "letters, digits and hyphens, max 32 characters"sv, "OpenShock"sv); + + return group; +} diff --git a/src/serial/command_handlers/jsonconfig.cpp b/components/serial_console/src/command_handlers/jsonconfig.cpp similarity index 70% rename from src/serial/command_handlers/jsonconfig.cpp rename to components/serial_console/src/command_handlers/jsonconfig.cpp index e090ee89..7460259b 100644 --- a/src/serial/command_handlers/jsonconfig.cpp +++ b/components/serial_console/src/command_handlers/jsonconfig.cpp @@ -1,4 +1,4 @@ -#include "serial/command_handlers/common.h" +#include "serial_console/command_handlers/common.h" #include "config/Config.h" @@ -24,11 +24,11 @@ static void handleJsonConfigCommand(std::string_view arg, bool isAutomated) esp_restart(); } -OpenShock::Serial::CommandGroup OpenShock::Serial::CommandHandlers::JsonConfigHandler() +OpenShock::SerialCmds::CommandGroup OpenShock::SerialCmds::CommandHandlers::JsonConfigHandler() { - auto group = OpenShock::Serial::CommandGroup("jsonconfig"sv); + auto group = OpenShock::SerialCmds::CommandGroup("jsonconfig"sv); - auto& getCommand = group.addCommand("Get the configuration as JSON"sv, handleJsonConfigCommand); + group.addCommand("Get the configuration as JSON"sv, handleJsonConfigCommand); auto& setCommand = group.addCommand("Set the configuration from JSON, and restart"sv, handleJsonConfigCommand); setCommand.addArgument("json"sv, "must be a valid JSON object"sv, "{ ... }"sv); diff --git a/src/serial/command_handlers/keepalive.cpp b/components/serial_console/src/command_handlers/keepalive.cpp similarity index 75% rename from src/serial/command_handlers/keepalive.cpp rename to components/serial_console/src/command_handlers/keepalive.cpp index baebdb58..b662b479 100644 --- a/src/serial/command_handlers/keepalive.cpp +++ b/components/serial_console/src/command_handlers/keepalive.cpp @@ -1,9 +1,9 @@ -#include "serial/command_handlers/common.h" +#include "serial_console/command_handlers/common.h" #include "CommandHandler.h" #include "config/Config.h" #include "Convert.h" -#include "util/StringUtils.h" +#include "StringHelpers.h" static void handleKeepAliveCommand(std::string_view arg, bool isAutomated) { @@ -34,11 +34,11 @@ static void handleKeepAliveCommand(std::string_view arg, bool isAutomated) } } -OpenShock::Serial::CommandGroup OpenShock::Serial::CommandHandlers::KeepAliveHandler() +OpenShock::SerialCmds::CommandGroup OpenShock::SerialCmds::CommandHandlers::KeepAliveHandler() { - auto group = OpenShock::Serial::CommandGroup("keepalive"sv); + auto group = OpenShock::SerialCmds::CommandGroup("keepalive"sv); - auto& getCommand = group.addCommand("Get the shocker keep-alive status"sv, handleKeepAliveCommand); + group.addCommand("Get the shocker keep-alive status"sv, handleKeepAliveCommand); auto& setCommand = group.addCommand("Enable/disable shocker keep-alive"sv, handleKeepAliveCommand); setCommand.addArgument("enabled"sv, "must be a boolean"sv, "true"sv); diff --git a/src/serial/command_handlers/ledtest.cpp b/components/serial_console/src/command_handlers/ledtest.cpp similarity index 67% rename from src/serial/command_handlers/ledtest.cpp rename to components/serial_console/src/command_handlers/ledtest.cpp index 3d902d54..c01a2639 100644 --- a/src/serial/command_handlers/ledtest.cpp +++ b/components/serial_console/src/command_handlers/ledtest.cpp @@ -1,4 +1,4 @@ -#include "serial/command_handlers/common.h" +#include "serial_console/command_handlers/common.h" #include "visual/VisualStateManager.h" @@ -14,9 +14,9 @@ static void handleSerialLedTestCommand(std::string_view arg, bool isAutomated) SERPR_SUCCESS("LedTest|Complete"); } -OpenShock::Serial::CommandGroup OpenShock::Serial::CommandHandlers::LedTestHandler() +OpenShock::SerialCmds::CommandGroup OpenShock::SerialCmds::CommandHandlers::LedTestHandler() { - auto group = OpenShock::Serial::CommandGroup("ledtest"sv); + auto group = OpenShock::SerialCmds::CommandGroup("ledtest"sv); group.addCommand("Cycle through all LED patterns for visual verification"sv, handleSerialLedTestCommand); diff --git a/components/serial_console/src/command_handlers/networks.cpp b/components/serial_console/src/command_handlers/networks.cpp new file mode 100644 index 00000000..687f63ca --- /dev/null +++ b/components/serial_console/src/command_handlers/networks.cpp @@ -0,0 +1,104 @@ +#include "serial_console/command_handlers/common.h" + +#include "config/Config.h" +#include "wifi/WiFiManager.h" +#include "json/Json.h" + +#include +#include + +const char* const TAG = "SerialCmds::CommandHandlers::Networks"; + +static void handleNetworksCommand(std::string_view arg, bool isAutomated) +{ + if (arg.empty()) { + OpenShock::JSON::StringWriter writer; + json_gen_str_t* gen = writer.gen(); + + json_gen_start_array(gen); + if (!OpenShock::Config::GetWiFiCredentials(gen, true)) { + SERPR_ERROR("Failed to get WiFi credentials from config"); + return; + } + json_gen_end_array(gen); + + std::string out = writer.finish(); + + SERPR_RESPONSE("Networks|%s", out.c_str()); + return; + } + + OpenShock::JSON::JsonDocument doc; + if (!doc.parse(arg)) { + SERPR_ERROR("Failed to parse JSON"); + return; + } + + OpenShock::JSON::JsonView root = doc.root(); + if (!root.isArray()) { + SERPR_ERROR("Invalid argument (not an array)"); + return; + } + + std::vector creds; + + const int count = root.count(); + std::bitset<256> usedIds; + for (int i = 0; i < count; ++i) { + OpenShock::Config::WiFiCredentials cred; + + if (!cred.FromJSON(root.at(i))) { + SERPR_ERROR("Failed to parse network"); + return; + } + + usedIds[cred.id] = true; + creds.push_back(std::move(cred)); + } + + // Give networks without an ID the lowest IDs not already taken by the ones that have one + uint16_t nextId = 1; + for (auto& cred : creds) { + if (cred.id == 0) { + while (nextId < 256 && usedIds[nextId]) ++nextId; + if (nextId >= 256) { + SERPR_ERROR("Too many networks"); + return; + } + cred.id = static_cast(nextId); + usedIds[nextId] = true; + } + + OS_LOGI(TAG, "Adding network \"%s\" to config, id=%u", cred.ssid.c_str(), cred.id); + } + + if (!OpenShock::Config::SetWiFiCredentials(creds)) { + SERPR_ERROR("Failed to save config"); + return; + } + + SERPR_SUCCESS("Saved config"); + + OpenShock::WiFiManager::RefreshNetworkCredentials(); +} + +OpenShock::SerialCmds::CommandGroup OpenShock::SerialCmds::CommandHandlers::NetworksHandler() +{ + auto group = OpenShock::SerialCmds::CommandGroup("networks"sv); + + group.addCommand("Get all saved networks."sv, handleNetworksCommand); + + auto& setCommand = group.addCommand("Set all saved networks."sv, handleNetworksCommand); + setCommand.addArgument( + "json"sv, + "must be a array of objects with the following fields:"sv, + "[{\"ssid\":\"myssid\",\"password\":\"mypassword\"}]"sv, + { + "ssid (string) SSID of the network"sv, + "password (string) Password of the network"sv, + "id (number) ID of the network (optional)"sv, + } + ); + + return group; +} diff --git a/src/serial/command_handlers/rawconfig.cpp b/components/serial_console/src/command_handlers/rawconfig.cpp similarity index 70% rename from src/serial/command_handlers/rawconfig.cpp rename to components/serial_console/src/command_handlers/rawconfig.cpp index 4fbd1921..33b1dc85 100644 --- a/src/serial/command_handlers/rawconfig.cpp +++ b/components/serial_console/src/command_handlers/rawconfig.cpp @@ -1,8 +1,8 @@ -#include "serial/command_handlers/common.h" +#include "serial_console/command_handlers/common.h" +#include "Base64.h" #include "config/Config.h" #include "TinyVec.h" -#include "util/Base64Utils.h" #include @@ -20,7 +20,7 @@ static void handleRawConfigCommand(std::string_view arg, bool isAutomated) } std::string base64; - if (!OpenShock::Base64Utils::Encode(buffer, base64)) { + if (!OpenShock::Base64::Encode(buffer, base64)) { SERPR_ERROR("Failed to encode raw config to base64"); return; } @@ -30,7 +30,7 @@ static void handleRawConfigCommand(std::string_view arg, bool isAutomated) } TinyVec buffer; - if (!OpenShock::Base64Utils::Decode(arg, buffer)) { + if (!OpenShock::Base64::Decode(arg, buffer)) { SERPR_ERROR("Failed to decode base64"); return; } @@ -45,11 +45,11 @@ static void handleRawConfigCommand(std::string_view arg, bool isAutomated) esp_restart(); } -OpenShock::Serial::CommandGroup OpenShock::Serial::CommandHandlers::RawConfigHandler() +OpenShock::SerialCmds::CommandGroup OpenShock::SerialCmds::CommandHandlers::RawConfigHandler() { - auto group = OpenShock::Serial::CommandGroup("rawconfig"sv); + auto group = OpenShock::SerialCmds::CommandGroup("rawconfig"sv); - auto& getCommand = group.addCommand("Get the raw binary config"sv, handleRawConfigCommand); + group.addCommand("Get the raw binary config"sv, handleRawConfigCommand); auto& setCommand = group.addCommand("Set the raw binary config, and restart"sv, handleRawConfigCommand); setCommand.addArgument("base64"sv, "must be a base64 encoded string"sv, "(base64 encoded binary data)"sv); diff --git a/components/serial_console/src/command_handlers/restart.cpp b/components/serial_console/src/command_handlers/restart.cpp new file mode 100644 index 00000000..7e6f92e1 --- /dev/null +++ b/components/serial_console/src/command_handlers/restart.cpp @@ -0,0 +1,21 @@ +#include "serial_console/command_handlers/common.h" +#include "serial_console/SerialInputHandler.h" + +#include + +static void handleRestartCommand(std::string_view arg, bool isAutomated) +{ + (void)arg; + + OS_SERIAL_PRINTLN("Restarting ESP..."); + esp_restart(); +} + +OpenShock::SerialCmds::CommandGroup OpenShock::SerialCmds::CommandHandlers::RestartHandler() +{ + auto group = OpenShock::SerialCmds::CommandGroup("restart"sv); + + group.addCommand("Restart the board"sv, handleRestartCommand); + + return group; +} diff --git a/src/serial/command_handlers/rftransmit.cpp b/components/serial_console/src/command_handlers/rftransmit.cpp similarity index 78% rename from src/serial/command_handlers/rftransmit.cpp rename to components/serial_console/src/command_handlers/rftransmit.cpp index 3a4f86db..b5c99efd 100644 --- a/src/serial/command_handlers/rftransmit.cpp +++ b/components/serial_console/src/command_handlers/rftransmit.cpp @@ -1,7 +1,8 @@ -#include "serial/command_handlers/common.h" +#include "serial_console/command_handlers/common.h" #include "CommandHandler.h" #include "serialization/JsonSerial.h" +#include "json/Json.h" static void handleRFTransmitCommand(std::string_view arg, bool isAutomated) { @@ -9,16 +10,14 @@ static void handleRFTransmitCommand(std::string_view arg, bool isAutomated) SERPR_ERROR("No command"); return; } - cJSON* root = cJSON_ParseWithLength(arg.data(), arg.length()); - if (root == nullptr) { - SERPR_ERROR("Failed to parse JSON: %s", cJSON_GetErrorPtr()); + OpenShock::JSON::JsonDocument doc; + if (!doc.parse(arg)) { + SERPR_ERROR("Failed to parse JSON"); return; } OpenShock::Serialization::JsonSerial::ShockerCommand cmd; - bool parsed = OpenShock::Serialization::JsonSerial::ParseShockerCommand(root, cmd); - - cJSON_Delete(root); + bool parsed = OpenShock::Serialization::JsonSerial::ParseShockerCommand(doc.root(), cmd); if (!parsed) { SERPR_ERROR("Failed to parse shocker command"); @@ -33,9 +32,9 @@ static void handleRFTransmitCommand(std::string_view arg, bool isAutomated) SERPR_SUCCESS("Command sent"); } -OpenShock::Serial::CommandGroup OpenShock::Serial::CommandHandlers::RfTransmitHandler() +OpenShock::SerialCmds::CommandGroup OpenShock::SerialCmds::CommandHandlers::RfTransmitHandler() { - auto group = OpenShock::Serial::CommandGroup("rftransmit"sv); + auto group = OpenShock::SerialCmds::CommandGroup("rftransmit"sv); auto& cmd = group.addCommand("Transmit a RF command"sv, handleRFTransmitCommand); cmd.addArgument( diff --git a/src/serial/command_handlers/rftxpin.cpp b/components/serial_console/src/command_handlers/rftxpin.cpp similarity index 73% rename from src/serial/command_handlers/rftxpin.cpp rename to components/serial_console/src/command_handlers/rftxpin.cpp index 2e6dec8c..0de55f9f 100644 --- a/src/serial/command_handlers/rftxpin.cpp +++ b/components/serial_console/src/command_handlers/rftxpin.cpp @@ -1,9 +1,9 @@ -#include "serial/command_handlers/common.h" +#include "serial_console/command_handlers/common.h" #include "CommandHandler.h" #include "config/Config.h" #include "Convert.h" -#include "SetGPIOResultCode.h" +#include "enums/SetGPIOResultCode.h" static void handleRfTxPinCommand(std::string_view arg, bool isAutomated) { @@ -22,6 +22,7 @@ static void handleRfTxPinCommand(std::string_view arg, bool isAutomated) if (!OpenShock::Convert::ToGpioNum(arg, txPin)) { SERPR_ERROR("Invalid argument (number invalid or out of range)"); + return; } OpenShock::SetGPIOResultCode result = OpenShock::CommandHandler::SetRfTxPin(txPin); @@ -35,6 +36,10 @@ static void handleRfTxPinCommand(std::string_view arg, bool isAutomated) SERPR_ERROR("Internal error while setting RF TX pin"); break; + case OpenShock::SetGPIOResultCode::PinInUse: + SERPR_ERROR("Pin is in use by the E-Stop"); + break; + case OpenShock::SetGPIOResultCode::Success: SERPR_SUCCESS("Saved config"); break; @@ -45,11 +50,11 @@ static void handleRfTxPinCommand(std::string_view arg, bool isAutomated) } } -OpenShock::Serial::CommandGroup OpenShock::Serial::CommandHandlers::RfTxPinHandler() +OpenShock::SerialCmds::CommandGroup OpenShock::SerialCmds::CommandHandlers::RfTxPinHandler() { - auto group = OpenShock::Serial::CommandGroup("rftxpin"sv); + auto group = OpenShock::SerialCmds::CommandGroup("rftxpin"sv); - auto& getCommand = group.addCommand("Get the GPIO pin used for the radio transmitter"sv, handleRfTxPinCommand); + group.addCommand("Get the GPIO pin used for the radio transmitter"sv, handleRfTxPinCommand); auto& setCommand = group.addCommand("Set the GPIO pin used for the radio transmitter"sv, handleRfTxPinCommand); setCommand.addArgument("pin"sv, "must be a number"sv, "15"sv); diff --git a/src/serial/command_handlers/sysinfo.cpp b/components/serial_console/src/command_handlers/sysinfo.cpp similarity index 73% rename from src/serial/command_handlers/sysinfo.cpp rename to components/serial_console/src/command_handlers/sysinfo.cpp index 3d224388..7b2cd1a4 100644 --- a/src/serial/command_handlers/sysinfo.cpp +++ b/components/serial_console/src/command_handlers/sysinfo.cpp @@ -1,7 +1,9 @@ -#include "serial/command_handlers/common.h" +#include + +#include "serial_console/command_handlers/common.h" -#include "Core.h" #include "FormatHelpers.h" +#include "Temporal.h" #include "wifi/WiFiManager.h" #include "wifi/WiFiNetwork.h" @@ -31,16 +33,14 @@ static void handleDebugInfoCommand(std::string_view arg, bool isAutomated) char ipAddressBuffer[64]; OpenShock::WiFiManager::GetIPAddress(ipAddressBuffer); SERPR_RESPONSE("WiFiInfo|IPv4|%s", ipAddressBuffer); - OpenShock::WiFiManager::GetIPv6Address(ipAddressBuffer); - SERPR_RESPONSE("WiFiInfo|IPv6|%s", ipAddressBuffer); } } -OpenShock::Serial::CommandGroup OpenShock::Serial::CommandHandlers::SysInfoHandler() +OpenShock::SerialCmds::CommandGroup OpenShock::SerialCmds::CommandHandlers::SysInfoHandler() { - auto group = OpenShock::Serial::CommandGroup("sysinfo"sv); + auto group = OpenShock::SerialCmds::CommandGroup("sysinfo"sv); - auto& cmd = group.addCommand("Get system information from RTOS, WiFi, etc."sv, handleDebugInfoCommand); + group.addCommand("Get system information from RTOS, WiFi, etc."sv, handleDebugInfoCommand); return group; } diff --git a/src/serial/command_handlers/validgpios.cpp b/components/serial_console/src/command_handlers/validgpios.cpp similarity index 67% rename from src/serial/command_handlers/validgpios.cpp rename to components/serial_console/src/command_handlers/validgpios.cpp index 3c5676d2..9803a19c 100644 --- a/src/serial/command_handlers/validgpios.cpp +++ b/components/serial_console/src/command_handlers/validgpios.cpp @@ -1,4 +1,4 @@ -#include "serial/command_handlers/common.h" +#include "serial_console/command_handlers/common.h" #include "Chipset.h" @@ -30,11 +30,11 @@ static void handleValidGpiosCommand(std::string_view arg, bool isAutomated) SERPR_RESPONSE("ValidGPIOs|%s", buffer.c_str()); } -OpenShock::Serial::CommandGroup OpenShock::Serial::CommandHandlers::ValidGpiosHandler() +OpenShock::SerialCmds::CommandGroup OpenShock::SerialCmds::CommandHandlers::ValidGpiosHandler() { - auto group = OpenShock::Serial::CommandGroup("validgpios"sv); + auto group = OpenShock::SerialCmds::CommandGroup("validgpios"sv); - auto& cmd = group.addCommand("List all valid GPIO pins"sv, handleValidGpiosCommand); + group.addCommand("List all valid GPIO pins"sv, handleValidGpiosCommand); return group; } diff --git a/components/serial_console/src/command_handlers/version.cpp b/components/serial_console/src/command_handlers/version.cpp new file mode 100644 index 00000000..0b0f8c51 --- /dev/null +++ b/components/serial_console/src/command_handlers/version.cpp @@ -0,0 +1,22 @@ +#include "serial_console/command_handlers/common.h" + +#include "serial_console/SerialInputHandler.h" + +#include + +static void handleVersionCommand(std::string_view arg, bool isAutomated) +{ + (void)arg; + + OS_SERIAL_PRINTLN(); + OpenShock::SerialInputHandler::PrintVersionInfo(); +} + +OpenShock::SerialCmds::CommandGroup OpenShock::SerialCmds::CommandHandlers::VersionHandler() +{ + auto group = OpenShock::SerialCmds::CommandGroup("version"sv); + + group.addCommand("Print version information"sv, handleVersionCommand); + + return group; +} diff --git a/components/serialization/CMakeLists.txt b/components/serialization/CMakeLists.txt new file mode 100644 index 00000000..f5d1ca30 --- /dev/null +++ b/components/serialization/CMakeLists.txt @@ -0,0 +1,11 @@ +# OpenShock serialization layer. +# +# For now this is the generated flatbuffer wire types (serialization/_fbs/*_generated.h, +# produced by scripts/generate_schemas.py from schemas/*.fbs) — a single flatbuffers-only +# layer that config, wifi, the message codecs and the handlers all depend on one-way. +# Extracting these out of `core` is what lets config (and later the codecs themselves: +# JsonAPI/JsonSerial/WSGateway/WSLocal) leave core without a dependency cycle. +idf_component_register( + INCLUDE_DIRS "include" + REQUIRES flatbuffers # flatbuffers/flatbuffers.h in the generated headers +) diff --git a/components/serialization/include/serialization/VerifiedRoot.h b/components/serialization/include/serialization/VerifiedRoot.h new file mode 100644 index 00000000..9196bee4 --- /dev/null +++ b/components/serialization/include/serialization/VerifiedRoot.h @@ -0,0 +1,31 @@ +#pragma once + +#include "flatbuffers/flatbuffers.h" + +#include +#include +#include + +namespace OpenShock::Serialization { + /// @brief Verifies an untrusted FlatBuffer and returns its root, or nullptr if it is too small, larger than + /// `maxSize`, or fails verification. + /// The size is checked before constructing the Verifier: its constructor asserts size < max_size, so an oversized + /// buffer would abort instead of being rejected. + template + const T* GetVerifiedRoot(std::span data, std::size_t maxSize) + { + if (data.size() < sizeof(flatbuffers::uoffset_t) || data.size() > maxSize) { + return nullptr; + } + + flatbuffers::Verifier::Options options { + .max_size = maxSize + 1, // Verifier requires size < max_size + }; + flatbuffers::Verifier verifier(data.data(), data.size(), options); + if (!verifier.VerifyBuffer()) { + return nullptr; + } + + return flatbuffers::GetRoot(data.data()); + } +} // namespace OpenShock::Serialization diff --git a/include/serialization/_fbs/FirmwareBootType_generated.h b/components/serialization/include/serialization/_fbs/FirmwareBootType_generated.h similarity index 100% rename from include/serialization/_fbs/FirmwareBootType_generated.h rename to components/serialization/include/serialization/_fbs/FirmwareBootType_generated.h diff --git a/include/serialization/_fbs/GatewayToHubMessage_generated.h b/components/serialization/include/serialization/_fbs/GatewayToHubMessage_generated.h similarity index 100% rename from include/serialization/_fbs/GatewayToHubMessage_generated.h rename to components/serialization/include/serialization/_fbs/GatewayToHubMessage_generated.h diff --git a/include/serialization/_fbs/HubConfig_generated.h b/components/serialization/include/serialization/_fbs/HubConfig_generated.h similarity index 100% rename from include/serialization/_fbs/HubConfig_generated.h rename to components/serialization/include/serialization/_fbs/HubConfig_generated.h diff --git a/include/serialization/_fbs/HubToGatewayMessage_generated.h b/components/serialization/include/serialization/_fbs/HubToGatewayMessage_generated.h similarity index 100% rename from include/serialization/_fbs/HubToGatewayMessage_generated.h rename to components/serialization/include/serialization/_fbs/HubToGatewayMessage_generated.h diff --git a/include/serialization/_fbs/HubToLocalMessage_generated.h b/components/serialization/include/serialization/_fbs/HubToLocalMessage_generated.h similarity index 100% rename from include/serialization/_fbs/HubToLocalMessage_generated.h rename to components/serialization/include/serialization/_fbs/HubToLocalMessage_generated.h diff --git a/include/serialization/_fbs/LocalToHubMessage_generated.h b/components/serialization/include/serialization/_fbs/LocalToHubMessage_generated.h similarity index 100% rename from include/serialization/_fbs/LocalToHubMessage_generated.h rename to components/serialization/include/serialization/_fbs/LocalToHubMessage_generated.h diff --git a/include/serialization/_fbs/OtaUpdateProgressTask_generated.h b/components/serialization/include/serialization/_fbs/OtaUpdateProgressTask_generated.h similarity index 100% rename from include/serialization/_fbs/OtaUpdateProgressTask_generated.h rename to components/serialization/include/serialization/_fbs/OtaUpdateProgressTask_generated.h diff --git a/include/serialization/_fbs/SemVer_generated.h b/components/serialization/include/serialization/_fbs/SemVer_generated.h similarity index 100% rename from include/serialization/_fbs/SemVer_generated.h rename to components/serialization/include/serialization/_fbs/SemVer_generated.h diff --git a/include/serialization/_fbs/ShockerCommandType_generated.h b/components/serialization/include/serialization/_fbs/ShockerCommandType_generated.h similarity index 100% rename from include/serialization/_fbs/ShockerCommandType_generated.h rename to components/serialization/include/serialization/_fbs/ShockerCommandType_generated.h diff --git a/include/serialization/_fbs/ShockerCommand_generated.h b/components/serialization/include/serialization/_fbs/ShockerCommand_generated.h similarity index 100% rename from include/serialization/_fbs/ShockerCommand_generated.h rename to components/serialization/include/serialization/_fbs/ShockerCommand_generated.h diff --git a/include/serialization/_fbs/ShockerModelType_generated.h b/components/serialization/include/serialization/_fbs/ShockerModelType_generated.h similarity index 100% rename from include/serialization/_fbs/ShockerModelType_generated.h rename to components/serialization/include/serialization/_fbs/ShockerModelType_generated.h diff --git a/include/serialization/_fbs/WifiAuthMode_generated.h b/components/serialization/include/serialization/_fbs/WifiAuthMode_generated.h similarity index 100% rename from include/serialization/_fbs/WifiAuthMode_generated.h rename to components/serialization/include/serialization/_fbs/WifiAuthMode_generated.h diff --git a/include/serialization/_fbs/WifiNetworkEventType_generated.h b/components/serialization/include/serialization/_fbs/WifiNetworkEventType_generated.h similarity index 100% rename from include/serialization/_fbs/WifiNetworkEventType_generated.h rename to components/serialization/include/serialization/_fbs/WifiNetworkEventType_generated.h diff --git a/include/serialization/_fbs/WifiNetwork_generated.h b/components/serialization/include/serialization/_fbs/WifiNetwork_generated.h similarity index 100% rename from include/serialization/_fbs/WifiNetwork_generated.h rename to components/serialization/include/serialization/_fbs/WifiNetwork_generated.h diff --git a/include/serialization/_fbs/WifiScanStatus_generated.h b/components/serialization/include/serialization/_fbs/WifiScanStatus_generated.h similarity index 100% rename from include/serialization/_fbs/WifiScanStatus_generated.h rename to components/serialization/include/serialization/_fbs/WifiScanStatus_generated.h diff --git a/components/temporal/CMakeLists.txt b/components/temporal/CMakeLists.txt new file mode 100644 index 00000000..dd9a6a1a --- /dev/null +++ b/components/temporal/CMakeLists.txt @@ -0,0 +1,9 @@ +# OpenShock time helpers — OpenShock::millis(), OpenShock::micros(). +# +# Thin header-only wrapper over esp_timer. Split from common so that logging +# and other components can depend on it without pulling everything else in. + +idf_component_register( + INCLUDE_DIRS "include" + REQUIRES esp_timer +) diff --git a/include/Core.h b/components/temporal/include/Temporal.h similarity index 100% rename from include/Core.h rename to components/temporal/include/Temporal.h diff --git a/dependencies.lock b/dependencies.lock new file mode 100644 index 00000000..f0899d69 --- /dev/null +++ b/dependencies.lock @@ -0,0 +1,39 @@ +dependencies: + espressif/esp_websocket_client: + component_hash: dc00233af846f3a744ada283a300e0d586040aadc3ee67361645ecdce4523672 + dependencies: + - name: idf + require: private + version: '>=5.0' + source: + registry_url: https://components.espressif.com/ + type: service + version: 1.8.0 + espressif/jsmn: + component_hash: 9b2f6ba1e0fdac1a25e8f1b5a03bfbc8069b43980c62bba225d521ee6cefc6d0 + dependencies: + - name: idf + require: private + version: '>=4.3' + source: + registry_url: https://components.espressif.com/ + type: service + version: 1.2.0 + espressif/json_generator: + component_hash: c24b782de6b3681540ae5a50cdd5212eb09cefec78b374fbe417e7d1fcf5a23d + dependencies: [] + source: + registry_url: https://components.espressif.com/ + type: service + version: 2.0.0 + idf: + source: + type: idf + version: 6.1.0 +direct_dependencies: +- espressif/esp_websocket_client +- espressif/jsmn +- espressif/json_generator +manifest_hash: fdbd37009830b0ba06da199c3db943a10aaa1182b311045c502121c502d74dd5 +target: esp32s3 +version: 3.0.0 diff --git a/frontend/index.html b/frontend/index.html index fcf7e0c3..a39692e0 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,7 @@ - + OpenShock Captive Portal diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 48fb79be..8a2f3d86 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -357,7 +357,7 @@ importers: version: 6.0.3 typescript-eslint: specifier: ^8.70.1 - version: 8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3) + version: 8.71.0(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3) vaul-svelte: specifier: 1.0.0-next.7 version: 1.0.0-next.7(svelte@5.57.1(@typescript-eslint/types@8.70.1)) @@ -948,14 +948,6 @@ packages: '@types/node@26.6.4': resolution: {integrity: sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg==} - '@typescript-eslint/eslint-plugin@8.70.1': - resolution: {integrity: sha512-nDNrUQ/4ruSNYbu749TRY7cfrzPtoLHEXSNBI8aaNY32LlZCajixqRf3FqcKC4p5Cam4VOHYx/t+i5+nKXvrqA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - '@typescript-eslint/parser': ^8.70.1 - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/eslint-plugin@8.71.0': resolution: {integrity: sha512-pqcS9c1HxZTHt7End4nXqd0s5lJrrFzrgCkKFJrsbUnaL6M3+6oBFZaslg6Gjsl3argl2DDRFROnXARaZ2e4Nw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -964,13 +956,6 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.70.1': - resolution: {integrity: sha512-nO974WLllwhSFWQXnMLj6nDGa8f0khKEz1JzpPJ1u7Vm/4X1X6ZHajpoknU4bb41vJyMB0HHVyS2GqdhWfIXZw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.71.0': resolution: {integrity: sha512-CG4nPk1f2zc8yw4pALqHsFYH2hdo+h1T9daSp21+Hnxi9LOE3GT9hAfTKJCBXVNM2GmYs1eMEP615wPoeOgk3A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -978,45 +963,22 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.70.1': - resolution: {integrity: sha512-62xOgboPfwc3/IgPSX/W6oQR3ZbF04194FPGUGH8HL8iLFHbt/456/8Ph1wLNUgVF+s94FlHoipBsz+v7+LMnA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.71.0': resolution: {integrity: sha512-aABjw5rjBacYONVPaPiWOCjJu0vEF4a25iQuodlmQYL1trtLZ0X/y+2Vzl3BKI1odM4LnwLE1oUDXYp1wzx1TQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/scope-manager@8.70.1': - resolution: {integrity: sha512-Pa0EeSeAusQc1WbjQMac+YfenewYTBu0KjgYvkUKwhXaHUKbFog23Dm/rp0DX/6tyYOQ3Xl1a+3EcFNZynGHCw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/scope-manager@8.71.0': resolution: {integrity: sha512-gWF0BhUcnjZxSpLE8ngS/59n2SB0J3YqRxvX1+2aoRJk9hNtHSLOV+TcarFiOr5ipXm3yc1QrI4c9YZc8zyCxw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.70.1': - resolution: {integrity: sha512-jumze1fPI+sDOaM2TWGQdn39PDxTr7TZGeuyLkAbNyx2vtMT3uRnVKChN0hfht5V2TugphJzF6bYXvBcE09qqg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/tsconfig-utils@8.71.0': resolution: {integrity: sha512-Z1UlWHADEK2Mlb9NpWfDeSjqoZ5EyrOv4R3eQpbkzqn/EwaIdOpXXupEA1+0ZIOSJSZZDBHG0BrQyN8zUG6Pwg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.70.1': - resolution: {integrity: sha512-7zKTnyvaVWqzLZHPFQtX1hVHqgkMC+WebPWakNCSyrQVbIP1AM0L0TlBZtACldIRb6PptI8Odk+jyZ5kP3B1VA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.71.0': resolution: {integrity: sha512-i8uO1qbdxeKgRnS5sCRt6On3/nfo2d2DwQe3Yvjx543zLy7r8ySqRuPPiIIXAhS03U0v5NfAFx+rUgxFzKKwNw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1032,25 +994,12 @@ packages: resolution: {integrity: sha512-cJ4OoxPGWvFnBTnSZyaU+qJzGTqPTGJY+gDchj6cRyLRdmIdt4rcsE4twj+zPfrNiWuVi38wijHzShL++Z9atQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.70.1': - resolution: {integrity: sha512-TU8PwyGN0PQJUcE96mw8eCQ44SmxGdQlJmlWakHaHQ15eIuuvye5yNtmh/i6oS88jzXVQB71xdNkbkB/fMwL0g==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/typescript-estree@8.71.0': resolution: {integrity: sha512-PEEF4G5sLLWAS5BpPrUvms4ySZkiBQQZM4z+3ReI46axK5Vqr/vXBQatJQIZZOYdGyPUAKTtsrWzpqKuU+3DEw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@8.70.1': - resolution: {integrity: sha512-Esgul8MsnKnRLdYU2Eb2cRV9bS5HJYtKj1ByJnOzzG2M58DGdSUQ1jUuILxipqcpB2h9WLrbD5GijIWUjX/Tqw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@8.71.0': resolution: {integrity: sha512-pKR/tEMVrXZG23UFKUn5BQf3zfmfk7KQceI2cGzywZ5nxM5Eu3hEJU1utjWzydtzBbcJAQhHN8iPCxobHpPcZQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1058,10 +1007,6 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/visitor-keys@8.70.1': - resolution: {integrity: sha512-Vwj9lUIW5Xq3wQ9w6gv3R86g1hMK8f2zNOdGTAgeXUMMXFK78G9ruCjjqutHMNJc0+CH7LYRnHeUB9IT8wFmcw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/visitor-keys@8.71.0': resolution: {integrity: sha512-8eQ9R218XORK+KLosnf4bu/QsUXvUyVwTbArg7/0NMB1Pu87OJKvj4nhFblkYE8gQV73mW1dx1ptlPCkwRGa7A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2021,10 +1966,6 @@ packages: resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} engines: {node: '>=8.6'} - picomatch@4.0.5: - resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} - engines: {node: '>=12'} - picomatch@4.0.7: resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} engines: {node: '>=12'} @@ -2443,13 +2384,6 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} - typescript-eslint@8.70.1: - resolution: {integrity: sha512-AcWG7KDjZ2THNXsgwttMaGmzVi0VFRlFYfqFHYQRbDpF3owuYbuiL8c7UUrd2k8s3PoSfIQrWfrGXfcElrWLYA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' - typescript-eslint@8.71.0: resolution: {integrity: sha512-fBdHYiqQ14RW6mOMXD14Svn82ZsCYAoQSzGRzyEjR59S5A2Krh/l7fGTOQ7iCr8gGy/mHVXtEF7s5fgjEdV0Pw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -3082,22 +3016,6 @@ snapshots: dependencies: undici-types: 8.9.0 - '@typescript-eslint/eslint-plugin@8.70.1(@typescript-eslint/parser@8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3)': - dependencies: - '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/scope-manager': 8.70.1 - '@typescript-eslint/type-utils': 8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/utils': 8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/visitor-keys': 8.70.1 - eslint: 10.11.0(jiti@2.7.0) - ignore: 7.0.10 - natural-compare: 1.4.0 - ts-api-utils: 2.5.0(typescript@6.0.3) - typescript: 6.0.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/eslint-plugin@8.71.0(@typescript-eslint/parser@8.71.0(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -3114,18 +3032,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3)': - dependencies: - '@typescript-eslint/scope-manager': 8.70.1 - '@typescript-eslint/types': 8.70.1 - '@typescript-eslint/typescript-estree': 8.70.1(typescript@6.0.3) - '@typescript-eslint/visitor-keys': 8.70.1 - debug: 4.4.3 - eslint: 10.11.0(jiti@2.7.0) - typescript: 6.0.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/parser@8.71.0(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@typescript-eslint/scope-manager': 8.71.0 @@ -3138,15 +3044,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.70.1(typescript@6.0.3)': - dependencies: - '@typescript-eslint/tsconfig-utils': 8.70.1(typescript@6.0.3) - '@typescript-eslint/types': 8.70.1 - debug: 4.4.3 - typescript: 6.0.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/project-service@8.71.0(typescript@6.0.3)': dependencies: '@typescript-eslint/tsconfig-utils': 8.71.0(typescript@6.0.3) @@ -3156,36 +3053,15 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.70.1': - dependencies: - '@typescript-eslint/types': 8.70.1 - '@typescript-eslint/visitor-keys': 8.70.1 - '@typescript-eslint/scope-manager@8.71.0': dependencies: '@typescript-eslint/types': 8.71.0 '@typescript-eslint/visitor-keys': 8.71.0 - '@typescript-eslint/tsconfig-utils@8.70.1(typescript@6.0.3)': - dependencies: - typescript: 6.0.3 - '@typescript-eslint/tsconfig-utils@8.71.0(typescript@6.0.3)': dependencies: typescript: 6.0.3 - '@typescript-eslint/type-utils@8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3)': - dependencies: - '@typescript-eslint/types': 8.70.1 - '@typescript-eslint/typescript-estree': 8.70.1(typescript@6.0.3) - '@typescript-eslint/utils': 8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3) - debug: 4.4.3 - eslint: 10.11.0(jiti@2.7.0) - ts-api-utils: 2.5.0(typescript@6.0.3) - typescript: 6.0.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/type-utils@8.71.0(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@typescript-eslint/types': 8.71.0 @@ -3198,25 +3074,11 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.70.1': {} + '@typescript-eslint/types@8.70.1': + optional: true '@typescript-eslint/types@8.71.0': {} - '@typescript-eslint/typescript-estree@8.70.1(typescript@6.0.3)': - dependencies: - '@typescript-eslint/project-service': 8.70.1(typescript@6.0.3) - '@typescript-eslint/tsconfig-utils': 8.70.1(typescript@6.0.3) - '@typescript-eslint/types': 8.70.1 - '@typescript-eslint/visitor-keys': 8.70.1 - debug: 4.4.3 - minimatch: 10.2.6 - semver: 7.8.5 - tinyglobby: 0.2.17 - ts-api-utils: 2.5.0(typescript@6.0.3) - typescript: 6.0.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/typescript-estree@8.71.0(typescript@6.0.3)': dependencies: '@typescript-eslint/project-service': 8.71.0(typescript@6.0.3) @@ -3232,17 +3094,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3)': - dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.11.0(jiti@2.7.0)) - '@typescript-eslint/scope-manager': 8.70.1 - '@typescript-eslint/types': 8.70.1 - '@typescript-eslint/typescript-estree': 8.70.1(typescript@6.0.3) - eslint: 10.11.0(jiti@2.7.0) - typescript: 6.0.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/utils@8.71.0(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@eslint-community/eslint-utils': 4.10.1(eslint@10.11.0(jiti@2.7.0)) @@ -3254,11 +3105,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.70.1': - dependencies: - '@typescript-eslint/types': 8.70.1 - eslint-visitor-keys: 5.0.1 - '@typescript-eslint/visitor-keys@8.71.0': dependencies: '@typescript-eslint/types': 8.71.0 @@ -4081,8 +3927,6 @@ snapshots: picomatch@2.3.2: {} - picomatch@4.0.5: {} - picomatch@4.0.7: {} playwright-core@1.63.0: {} @@ -4488,17 +4332,6 @@ snapshots: dependencies: prelude-ls: 1.2.1 - typescript-eslint@8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3): - dependencies: - '@typescript-eslint/eslint-plugin': 8.70.1(@typescript-eslint/parser@8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/parser': 8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/typescript-estree': 8.70.1(typescript@6.0.3) - '@typescript-eslint/utils': 8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3) - eslint: 10.11.0(jiti@2.7.0) - typescript: 6.0.3 - transitivePeerDependencies: - - supports-color - typescript-eslint@8.71.0(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3): dependencies: '@typescript-eslint/eslint-plugin': 8.71.0(@typescript-eslint/parser@8.71.0(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3) diff --git a/frontend/src/lib/MessageHandlers/WifiNetworkEventHandler.ts b/frontend/src/lib/MessageHandlers/WifiNetworkEventHandler.ts index d74bfc9c..1ec784dd 100644 --- a/frontend/src/lib/MessageHandlers/WifiNetworkEventHandler.ts +++ b/frontend/src/lib/MessageHandlers/WifiNetworkEventHandler.ts @@ -21,6 +21,8 @@ export function mapWifiNetwork( }; } +// Hidden networks are reported with an empty SSID. They are not listed (connecting needs the SSID, which the hidden +// network dialog asks for), so the `!ssid` checks below intentionally skip them. function handleInvalidEvent() { console.warn('[WS] Received invalid event type'); } @@ -63,13 +65,8 @@ function handleSavedEvent(fbsNetwork: FbsWifiNetwork) { return; } - const bssid = fbsNetwork.bssid(); - if (bssid) { - hubState.updateWifiNetwork(bssid, (network) => { - network.saved = true; - return network; - }); - } + // Credentials are per SSID, so every scanned BSSID of it is now saved + hubState.markWifiNetworksSaved(ssid); // Update config credentials so savedOnlySSIDs stays in sync if (hubState.config && !hubState.config.wifi.credentials.some((c) => c.ssid === ssid)) { diff --git a/frontend/src/lib/MessageHandlers/index.ts b/frontend/src/lib/MessageHandlers/index.ts index 1d1abb0e..64f85198 100644 --- a/frontend/src/lib/MessageHandlers/index.ts +++ b/frontend/src/lib/MessageHandlers/index.ts @@ -5,7 +5,6 @@ import { HubToLocalMessage } from '#lib/_fbs/open-shock/serialization/local/hub- import { ReadyMessage } from '#lib/_fbs/open-shock/serialization/local/ready-message.js'; import { WifiGotIpEvent } from '#lib/_fbs/open-shock/serialization/local/wifi-got-ip-event.js'; import { WifiScanStatusMessage } from '#lib/_fbs/open-shock/serialization/local/wifi-scan-status-message.js'; -import { stopWifiScan } from '#lib/api.js'; import { mapConfig } from '#lib/mappers/ConfigMapper.js'; import { hubState } from '#lib/stores/index.js'; import type { WebSocketClient } from '#lib/WebSocketClient.js'; @@ -28,6 +27,11 @@ PayloadHandlers[HubToLocalMessagePayload.ReadyMessage] = (cli, msg) => { const payload = new ReadyMessage(); msg.payload(payload); + // A (re)connect starts from a clean slate: the hub re-sends its full network list after Ready, and anything + // that changed while the socket was down would otherwise linger. + hubState.clearWifiNetworks(); + hubState.wifiScanStatus = null; + const connectedWifi = payload.connectedWifi(); const connectedSSID = connectedWifi?.ssid(); const connectedBSSID = connectedWifi?.bssid(); @@ -51,8 +55,6 @@ PayloadHandlers[HubToLocalMessagePayload.ReadyMessage] = (cli, msg) => { console.log('[WS] Updated hub state: ', hubState); - stopWifiScan(); - toast.success('Websocket connection established'); }; @@ -100,5 +102,11 @@ export function WebSocketMessageBinaryHandler(cli: WebSocketClient, data: ArrayB return; } - PayloadHandlers[payloadType](cli, msg); + try { + PayloadHandlers[payloadType](cli, msg); + } catch (e) { + // A malformed message must not take the whole UI down (e.g. leave the config unset forever) + console.error('[WS] ERROR: Failed to handle message: ', e); + toast.error('Received an invalid message from the hub'); + } } diff --git a/frontend/src/lib/WebSocketClient.test.ts b/frontend/src/lib/WebSocketClient.test.ts deleted file mode 100644 index ad554473..00000000 --- a/frontend/src/lib/WebSocketClient.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -// Logic extracted from WebSocketClient — pure function, no DOM deps -function resolveWebSocketHostname(hostname: string): string { - switch (hostname) { - case 'localhost': - case '127.0.0.1': - return '10.10.10.10'; - default: - return hostname; - } -} - -describe('resolveWebSocketHostname', () => { - it('maps localhost to the device IP', () => { - expect(resolveWebSocketHostname('localhost')).toBe('10.10.10.10'); - }); - - it('maps 127.0.0.1 to the device IP', () => { - expect(resolveWebSocketHostname('127.0.0.1')).toBe('10.10.10.10'); - }); - - it('passes through the captive portal IP unchanged', () => { - expect(resolveWebSocketHostname('10.10.10.10')).toBe('10.10.10.10'); - }); - - it('passes through arbitrary hostnames unchanged', () => { - expect(resolveWebSocketHostname('192.168.1.100')).toBe('192.168.1.100'); - expect(resolveWebSocketHostname('openshock.local')).toBe('openshock.local'); - }); -}); diff --git a/frontend/src/lib/WebSocketClient.ts b/frontend/src/lib/WebSocketClient.ts index 07dd08a6..681a6471 100644 --- a/frontend/src/lib/WebSocketClient.ts +++ b/frontend/src/lib/WebSocketClient.ts @@ -12,6 +12,9 @@ export enum ConnectionState { export type ConnectionStateChangeHandler = (state: ConnectionState) => void; +const RECONNECT_DELAY_MIN_MS = 200; +const RECONNECT_DELAY_MAX_MS = 5000; + export class WebSocketClient { public static readonly Instance = new WebSocketClient(); @@ -39,6 +42,11 @@ export class WebSocketClient { } #autoReconnect = false; + #reconnectDelayMs = RECONNECT_DELAY_MIN_MS; + #reconnectTimer: ReturnType | null = null; + #abortTimer: ReturnType | null = null; + #outageReported = false; + public Connect() { const connectionState = this.ConnectionState; if ( @@ -48,6 +56,7 @@ export class WebSocketClient { return; } + this.clearTimers(); this.AbortWebSocket(); this.#autoReconnect = true; @@ -60,7 +69,8 @@ export class WebSocketClient { return; } - this.#socket = new WebSocket(`ws://${hostname}:81/ws`); + // The firmware serves /ws from the same HTTP server as the page (port 80). + this.#socket = new WebSocket(`ws://${hostname}/ws`, 'flatbuffers'); this.#socket.binaryType = 'arraybuffer'; this.#socket.onopen = this.handleOpen.bind(this); this.#socket.onclose = this.handleClose.bind(this); @@ -69,6 +79,8 @@ export class WebSocketClient { } public Disconnect() { this.#autoReconnect = false; + this.clearTimers(); + const connectionState = this.ConnectionState; if ( connectionState === ConnectionState.DISCONNECTED || @@ -81,18 +93,38 @@ export class WebSocketClient { if (this.#socket) { try { this.#socket.close(); - setTimeout(this.AbortWebSocket.bind(this), 1000); + this.#abortTimer = setTimeout(() => { + this.#abortTimer = null; + this.AbortWebSocket(); + }, 1000); } catch { console.warn('[WS] Failed to gracefully close WebSocket connection, forcing close'); this.AbortWebSocket(); } } } + private clearTimers() { + if (this.#reconnectTimer !== null) { + clearTimeout(this.#reconnectTimer); + this.#reconnectTimer = null; + } + if (this.#abortTimer !== null) { + clearTimeout(this.#abortTimer); + this.#abortTimer = null; + } + } private ReconnectIfWanted() { this.AbortWebSocket(); - if (this.#autoReconnect) { - setTimeout(this.Connect.bind(this), 200); + if (!this.#autoReconnect || this.#reconnectTimer !== null) { + return; } + + const delay = this.#reconnectDelayMs; + this.#reconnectDelayMs = Math.min(this.#reconnectDelayMs * 2, RECONNECT_DELAY_MAX_MS); + this.#reconnectTimer = setTimeout(() => { + this.#reconnectTimer = null; + this.Connect(); + }, delay); } public Send(data: string | Blob | BufferSource): boolean { @@ -110,11 +142,19 @@ export class WebSocketClient { this.ReconnectIfWanted(); return; } + + this.#reconnectDelayMs = RECONNECT_DELAY_MIN_MS; + this.#outageReported = false; + this.ConnectionState = ConnectionState.CONNECTED; } private handleClose(ev: CloseEvent) { if (!ev.wasClean) { console.error('[WS] ERROR: Connection closed unexpectedly'); - toast.error('Websocket connection closed unexpectedly'); + // Report each outage once, not every failed reconnect attempt. + if (!this.#outageReported) { + this.#outageReported = true; + toast.error('Websocket connection closed unexpectedly'); + } } else { console.log('[WS] Received disconnect: ', ev.reason); } @@ -145,11 +185,11 @@ export class WebSocketClient { private AbortWebSocket() { if (this.#socket) { try { - this.#socket.close(); this.#socket.onclose = null; this.#socket.onerror = null; this.#socket.onmessage = null; this.#socket.onopen = null; + this.#socket.close(); } catch (e) { console.error(e); } diff --git a/frontend/src/lib/_fbs/open-shock/serialization/types/firmware-boot-type.ts b/frontend/src/lib/_fbs/open-shock/serialization/types/firmware-boot-type.ts index fea6e2a8..81b9d6d4 100644 --- a/frontend/src/lib/_fbs/open-shock/serialization/types/firmware-boot-type.ts +++ b/frontend/src/lib/_fbs/open-shock/serialization/types/firmware-boot-type.ts @@ -1,9 +1,9 @@ -// automatically generated by the FlatBuffers compiler, do not modify - -/* eslint-disable @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */ - -export enum FirmwareBootType { - Normal = 0, - NewFirmware = 1, - Rollback = 2 -} +// automatically generated by the FlatBuffers compiler, do not modify + +/* eslint-disable @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */ + +export enum FirmwareBootType { + Normal = 0, + NewFirmware = 1, + Rollback = 2 +} diff --git a/frontend/src/lib/_fbs/open-shock/serialization/types/shocker-command-type.ts b/frontend/src/lib/_fbs/open-shock/serialization/types/shocker-command-type.ts index a6c77901..e911db4b 100644 --- a/frontend/src/lib/_fbs/open-shock/serialization/types/shocker-command-type.ts +++ b/frontend/src/lib/_fbs/open-shock/serialization/types/shocker-command-type.ts @@ -1,10 +1,10 @@ -// automatically generated by the FlatBuffers compiler, do not modify - -/* eslint-disable @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */ - -export enum ShockerCommandType { - Stop = 0, - Shock = 1, - Vibrate = 2, - Sound = 3 -} +// automatically generated by the FlatBuffers compiler, do not modify + +/* eslint-disable @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */ + +export enum ShockerCommandType { + Stop = 0, + Shock = 1, + Vibrate = 2, + Sound = 3 +} diff --git a/frontend/src/lib/_fbs/open-shock/serialization/types/shocker-model-type.ts b/frontend/src/lib/_fbs/open-shock/serialization/types/shocker-model-type.ts index 0425443e..67790813 100644 --- a/frontend/src/lib/_fbs/open-shock/serialization/types/shocker-model-type.ts +++ b/frontend/src/lib/_fbs/open-shock/serialization/types/shocker-model-type.ts @@ -1,10 +1,10 @@ -// automatically generated by the FlatBuffers compiler, do not modify - -/* eslint-disable @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */ - -export enum ShockerModelType { - CaiXianlin = 0, - Petrainer = 1, - Petrainer998DR = 2, - WellturnT330 = 3 -} +// automatically generated by the FlatBuffers compiler, do not modify + +/* eslint-disable @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */ + +export enum ShockerModelType { + CaiXianlin = 0, + Petrainer = 1, + Petrainer998DR = 2, + WellturnT330 = 3 +} diff --git a/frontend/src/lib/_fbs/open-shock/serialization/types/wifi-auth-mode.ts b/frontend/src/lib/_fbs/open-shock/serialization/types/wifi-auth-mode.ts index e048d0e3..ce4f3f82 100644 --- a/frontend/src/lib/_fbs/open-shock/serialization/types/wifi-auth-mode.ts +++ b/frontend/src/lib/_fbs/open-shock/serialization/types/wifi-auth-mode.ts @@ -1,16 +1,16 @@ -// automatically generated by the FlatBuffers compiler, do not modify - -/* eslint-disable @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */ - -export enum WifiAuthMode { - Open = 0, - WEP = 1, - WPA_PSK = 2, - WPA2_PSK = 3, - WPA_WPA2_PSK = 4, - WPA2_ENTERPRISE = 5, - WPA3_PSK = 6, - WPA2_WPA3_PSK = 7, - WAPI_PSK = 8, - UNKNOWN = 9 -} +// automatically generated by the FlatBuffers compiler, do not modify + +/* eslint-disable @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */ + +export enum WifiAuthMode { + Open = 0, + WEP = 1, + WPA_PSK = 2, + WPA2_PSK = 3, + WPA_WPA2_PSK = 4, + WPA2_ENTERPRISE = 5, + WPA3_PSK = 6, + WPA2_WPA3_PSK = 7, + WAPI_PSK = 8, + UNKNOWN = 9 +} diff --git a/frontend/src/lib/_fbs/open-shock/serialization/types/wifi-network-event-type.ts b/frontend/src/lib/_fbs/open-shock/serialization/types/wifi-network-event-type.ts index cd7b1ee0..33a821bd 100644 --- a/frontend/src/lib/_fbs/open-shock/serialization/types/wifi-network-event-type.ts +++ b/frontend/src/lib/_fbs/open-shock/serialization/types/wifi-network-event-type.ts @@ -1,13 +1,13 @@ -// automatically generated by the FlatBuffers compiler, do not modify - -/* eslint-disable @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */ - -export enum WifiNetworkEventType { - Discovered = 0, - Updated = 1, - Lost = 2, - Saved = 3, - Removed = 4, - Connected = 5, - Disconnected = 6 -} +// automatically generated by the FlatBuffers compiler, do not modify + +/* eslint-disable @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */ + +export enum WifiNetworkEventType { + Discovered = 0, + Updated = 1, + Lost = 2, + Saved = 3, + Removed = 4, + Connected = 5, + Disconnected = 6 +} diff --git a/frontend/src/lib/_fbs/open-shock/serialization/types/wifi-scan-status.ts b/frontend/src/lib/_fbs/open-shock/serialization/types/wifi-scan-status.ts index 0037d639..29fe5e64 100644 --- a/frontend/src/lib/_fbs/open-shock/serialization/types/wifi-scan-status.ts +++ b/frontend/src/lib/_fbs/open-shock/serialization/types/wifi-scan-status.ts @@ -1,12 +1,12 @@ -// automatically generated by the FlatBuffers compiler, do not modify - -/* eslint-disable @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */ - -export enum WifiScanStatus { - Started = 0, - InProgress = 1, - Completed = 2, - TimedOut = 3, - Aborted = 4, - Error = 5 -} +// automatically generated by the FlatBuffers compiler, do not modify + +/* eslint-disable @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */ + +export enum WifiScanStatus { + Started = 0, + InProgress = 1, + Completed = 2, + TimedOut = 3, + Aborted = 4, + Error = 5 +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index ca2721b1..c9bccb0f 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -8,10 +8,41 @@ function apiFetch(path: string, init?: RequestInit): Promise { return fetch(getApiBaseUrl() + path, init); } +// Error codes returned by the hub's HTTP API, mapped to user-facing text. Unknown codes are shown as-is. +const _errorMessages: Record = { + // Account linking + CodeRequired: 'Code required', + InvalidCodeLength: 'Invalid code length', + NoInternetConnection: 'No internet connection', + InvalidCode: 'Invalid code', + RequestFailed: 'Could not reach the server (check DNS/TLS/connection)', + RequestTimedOut: 'Request to the server timed out', + ServerError: 'Server returned an unexpected response', + InvalidResponse: 'Server sent an invalid response', + ConfigSaveFailed: 'Failed to save the auth token to the device', + // GPIO + InvalidPin: 'Invalid pin', + PinInUse: 'Pin is used by the E-Stop', + // WiFi + MissingSsid: 'Network name is required', + InvalidSsid: 'Network name must be 1-32 bytes', + PasswordTooShort: 'Password must be at least 8 characters', + PasswordTooLong: 'Password must be at most 63 characters', + // OTA + InvalidChannel: 'Invalid update channel', + // Generic + MissingParam: 'Missing parameter', + InvalidParam: 'Invalid value', + RateLimited: 'Too many requests', + InternalError: 'Internal error', +}; + async function getErrorMessage(res: Response): Promise { try { const data = await res.json(); - return data.error ?? 'Unknown error'; + const code: unknown = data?.error; + if (typeof code !== 'string') return 'Unknown error'; + return _errorMessages[code] ?? code; } catch { return 'Unknown error'; } @@ -22,6 +53,7 @@ async function getErrorMessage(res: Response): Promise { export async function fetchBoardInfo(): Promise { try { const res = await apiFetch('/api/board'); + if (!res.ok) return; // Non-fatal, see below const data = await res.json(); hubState.hasPredefinedPins = data.has_predefined_pins ?? false; } catch { @@ -58,9 +90,8 @@ export async function forgetWifiNetwork(ssid: string): Promise { const res = await apiFetch('/api/wifi/networks?' + new URLSearchParams({ ssid }), { method: 'DELETE', }); - if (res.ok) { - toast.success('Forgot network: ' + ssid); - } else { + // Success is announced by the hub's "Removed" WiFi event + if (!res.ok) { toast.error('Failed to forget network: ' + (await getErrorMessage(res))); } } catch { @@ -70,20 +101,6 @@ export async function forgetWifiNetwork(ssid: string): Promise { // Account -const _accountLinkErrorMessages: Record = { - CodeRequired: 'Code required', - InvalidCodeLength: 'Invalid code length', - NoInternetConnection: 'No internet connection', - InvalidCode: 'Invalid code', - RateLimited: 'Too many requests', - RequestFailed: 'Could not reach the server (check DNS/TLS/connection)', - RequestTimedOut: 'Request to the server timed out', - ServerError: 'Server returned an unexpected response', - InvalidResponse: 'Server sent an invalid response', - ConfigSaveFailed: 'Failed to save the auth token to the device', - InternalError: 'Internal error', -}; - export async function linkAccount(code: string): Promise { try { const res = await apiFetch('/api/account/link?' + new URLSearchParams({ code }), { @@ -94,9 +111,7 @@ export async function linkAccount(code: string): Promise { toast.success('Account linked successfully'); return true; } else { - const error = await getErrorMessage(res); - const reason = _accountLinkErrorMessages[error] ?? 'Unknown error'; - toast.error('Failed to link account: ' + reason); + toast.error('Failed to link account: ' + (await getErrorMessage(res))); return false; } } catch { @@ -121,11 +136,6 @@ export async function unlinkAccount(): Promise { // Config - RF -const _gpioErrorMessages: Record = { - InvalidPin: 'Invalid pin', - InternalError: 'Internal error', -}; - export async function setRfTxPin(pin: number): Promise { try { const res = await apiFetch('/api/config/rf/pin?' + new URLSearchParams({ pin: String(pin) }), { @@ -137,8 +147,7 @@ export async function setRfTxPin(pin: number): Promise { toast.success('Changed RF TX pin to: ' + data.pin); return true; } else { - const error = await getErrorMessage(res); - toast.error('Failed to change RF TX pin: ' + (_gpioErrorMessages[error] ?? 'Unknown error')); + toast.error('Failed to change RF TX pin: ' + (await getErrorMessage(res))); return false; } } catch { @@ -147,52 +156,6 @@ export async function setRfTxPin(pin: number): Promise { } } -// Config - EStop - -export async function setEstopPin(pin: number): Promise { - try { - const res = await apiFetch( - '/api/config/estop/pin?' + new URLSearchParams({ pin: String(pin) }), - { - method: 'PUT', - } - ); - if (res.ok) { - const data = await res.json(); - if (hubState.config) hubState.config.estop.gpioPin = data.pin; - toast.success('Changed EStop pin to: ' + data.pin); - return true; - } else { - const error = await getErrorMessage(res); - toast.error('Failed to change EStop pin: ' + (_gpioErrorMessages[error] ?? 'Unknown error')); - return false; - } - } catch { - toast.error('Failed to change EStop pin'); - return false; - } -} - -export async function setEstopEnabled(enabled: boolean): Promise { - try { - const res = await apiFetch( - '/api/config/estop/enabled?' + new URLSearchParams({ enabled: enabled ? '1' : '0' }), - { method: 'PUT' } - ); - if (res.ok) { - if (hubState.config) hubState.config.estop.enabled = enabled; - toast.success('Changed EStop enabled to: ' + enabled); - return true; - } else { - toast.error('Failed to change EStop enabled'); - return false; - } - } catch { - toast.error('Failed to change EStop enabled'); - return false; - } -} - // WiFi network management export async function saveWifiNetwork( @@ -277,57 +240,54 @@ async function otaRequest( } } -export function setOtaEnabled(isEnabled: boolean): Promise { - return otaRequest( - `enabled?enabled=${isEnabled ? '1' : '0'}`, - 'PUT', - 'Failed to update OTA setting', - { isEnabled } - ); +// All OTA settings go through PUT /api/ota/settings, which applies any subset in one config write. +function setOtaSettings( + params: Record, + failure: string, + patch: Partial +): Promise { + return otaRequest('settings?' + new URLSearchParams(params), 'PUT', failure, patch); } -export function setOtaDomain(cdnDomain: string): Promise { - return otaRequest( - 'domain?' + new URLSearchParams({ domain: cdnDomain }), - 'PUT', - 'Failed to update OTA domain', - { cdnDomain } - ); +export function setOtaEnabled(isEnabled: boolean): Promise { + return setOtaSettings({ enabled: isEnabled ? '1' : '0' }, 'Failed to update OTA setting', { + isEnabled, + }); } export function setOtaChannel(updateChannel: OtaUpdateChannel): Promise { - return otaRequest( - 'channel?' + new URLSearchParams({ channel: _otaChannelNames[updateChannel] }), - 'PUT', + return setOtaSettings( + { channel: _otaChannelNames[updateChannel] }, 'Failed to update OTA channel', { updateChannel } ); } export function setOtaCheckInterval(checkInterval: number): Promise { - return otaRequest( - `check-interval?interval=${checkInterval}`, - 'PUT', + return setOtaSettings( + { interval: String(checkInterval) }, 'Failed to update OTA check interval', { checkInterval } ); } export function setOtaAllowBackendManagement(allowBackendManagement: boolean): Promise { - return otaRequest( - `allow-backend-management?allow=${allowBackendManagement ? '1' : '0'}`, - 'PUT', + return setOtaSettings( + { allow: allowBackendManagement ? '1' : '0' }, 'Failed to update OTA backend management setting', - { allowBackendManagement } + { + allowBackendManagement, + } ); } export function setOtaRequireManualApproval(requireManualApproval: boolean): Promise { - return otaRequest( - `require-manual-approval?require=${requireManualApproval ? '1' : '0'}`, - 'PUT', + return setOtaSettings( + { require: requireManualApproval ? '1' : '0' }, 'Failed to update OTA manual approval setting', - { requireManualApproval } + { + requireManualApproval, + } ); } diff --git a/frontend/src/lib/components/AddHiddenNetworkDialog.svelte b/frontend/src/lib/components/AddHiddenNetworkDialog.svelte index 455a39b3..7c2a6cb1 100644 --- a/frontend/src/lib/components/AddHiddenNetworkDialog.svelte +++ b/frontend/src/lib/components/AddHiddenNetworkDialog.svelte @@ -1,5 +1,7 @@ diff --git a/frontend/src/lib/components/GpioPinSelector.svelte b/frontend/src/lib/components/GpioPinSelector.svelte index dc7226b5..1b7b52b2 100644 --- a/frontend/src/lib/components/GpioPinSelector.svelte +++ b/frontend/src/lib/components/GpioPinSelector.svelte @@ -1,28 +1,26 @@ - -
- - -
- - -

- The backend domain is read-only here. Use serial commands to change it. -

-
-
diff --git a/frontend/src/lib/components/sections/CaptivePortalSection.svelte b/frontend/src/lib/components/sections/CaptivePortalSection.svelte deleted file mode 100644 index 0b3fc004..00000000 --- a/frontend/src/lib/components/sections/CaptivePortalSection.svelte +++ /dev/null @@ -1,30 +0,0 @@ - - -
- - - - - {#if !alwaysEnabled} -
- -

- The captive portal will close automatically when the hub connects to the gateway. Enable - "Always Enabled" via serial commands to keep it running. -

-
- {/if} -
diff --git a/frontend/src/lib/components/sections/OtaSection.svelte b/frontend/src/lib/components/sections/OtaSection.svelte index 2850da8c..f091f4fe 100644 --- a/frontend/src/lib/components/sections/OtaSection.svelte +++ b/frontend/src/lib/components/sections/OtaSection.svelte @@ -5,7 +5,6 @@ import { OtaUpdateChannel } from '#lib/_fbs/open-shock/serialization/configuration/ota-update-channel.js'; import { setOtaEnabled, - setOtaDomain, setOtaChannel, setOtaCheckInterval, setOtaAllowBackendManagement, @@ -25,25 +24,16 @@ let otaConfig = $derived(hubState.config?.otaUpdate); // Unsaved edits; undefined shows the hub's stored value. A cleared number input binds null. - let cdnDomainEdit = $state(); let checkIntervalEdit = $state(); - let cdnDomain = $derived( - cdnDomainEdit !== undefined ? cdnDomainEdit : (otaConfig?.cdnDomain ?? '') - ); let checkInterval = $derived( checkIntervalEdit !== undefined ? checkIntervalEdit : (otaConfig?.checkInterval ?? 0) ); - let canSaveDomain = $derived( - cdnDomainEdit !== undefined && - cdnDomain.trim().length > 0 && - cdnDomain.trim() !== otaConfig?.cdnDomain - ); let intervalValid = $derived( checkInterval !== null && Number.isInteger(checkInterval) && - checkInterval >= 0 && + checkInterval >= 1 && // minutes; the hub rejects 0 checkInterval <= 65535 ); let canSaveInterval = $derived( @@ -52,14 +42,6 @@ let saving = $state(false); - async function saveDomain(e: SubmitEvent) { - e.preventDefault(); - if (!canSaveDomain || saving) return; - saving = true; - if (await setOtaDomain(cdnDomain.trim())) cdnDomainEdit = undefined; - saving = false; - } - async function saveCheckInterval(e: SubmitEvent) { e.preventDefault(); if (!canSaveInterval || saving) return; @@ -97,17 +79,10 @@
-
- cdnDomain, (v) => (cdnDomainEdit = v)} - placeholder="cdn.openshock.app" - /> - -
+ +

+ Firmware is downloaded from this domain, so it can only be changed over the serial console. +

diff --git a/frontend/src/lib/components/steps/AccountStep.svelte b/frontend/src/lib/components/steps/AccountStep.svelte index 50d1c3d1..e6f73a5c 100644 --- a/frontend/src/lib/components/steps/AccountStep.svelte +++ b/frontend/src/lib/components/steps/AccountStep.svelte @@ -21,7 +21,8 @@ let accountLinked = $derived(hubState.accountLinked); let wifiConnected = $derived(hubState.wifiConnectedBSSID !== null); let linking = $state(false); - let canLink = $derived(linkCodeValid && linkCode.length >= 6 && !linking); + // Pair codes are exactly 6 digits; the hub rejects any other length + let canLink = $derived(linkCodeValid && linkCode.length === 6 && !linking); async function handleLinkAccount(e: SubmitEvent) { e.preventDefault(); @@ -88,6 +89,7 @@ id="account-link-code" inputmode="numeric" pattern="[0-9]*" + maxlength={6} placeholder="Enter pair code" bind:value={linkCode} /> diff --git a/frontend/src/lib/components/steps/HardwareStep.svelte b/frontend/src/lib/components/steps/HardwareStep.svelte index 815ae348..723e1707 100644 --- a/frontend/src/lib/components/steps/HardwareStep.svelte +++ b/frontend/src/lib/components/steps/HardwareStep.svelte @@ -1,9 +1,19 @@
@@ -20,6 +30,7 @@
@@ -28,18 +39,15 @@

The emergency stop pin provides a hardware kill switch for all shocker output.

- - +
+
Status
+
{estop ? (estop.enabled ? 'Enabled' : 'Disabled') : 'Loading...'}
+
Pin
+
{estop ? (estopHasPin ? estop.gpioPin : 'None') : 'Loading...'}
+
+

+ The E-Stop can only be configured over the serial console. +

diff --git a/frontend/src/lib/mappers/ConfigMapper.test.ts b/frontend/src/lib/mappers/ConfigMapper.test.ts new file mode 100644 index 00000000..83eb4a4a --- /dev/null +++ b/frontend/src/lib/mappers/ConfigMapper.test.ts @@ -0,0 +1,108 @@ +import { BackendConfig } from '#lib/_fbs/open-shock/serialization/configuration/backend-config.js'; +import { CaptivePortalConfig } from '#lib/_fbs/open-shock/serialization/configuration/captive-portal-config.js'; +import { EStopConfig } from '#lib/_fbs/open-shock/serialization/configuration/estop-config.js'; +import { HubConfig } from '#lib/_fbs/open-shock/serialization/configuration/hub-config.js'; +import { OtaUpdateChannel } from '#lib/_fbs/open-shock/serialization/configuration/ota-update-channel.js'; +import { OtaUpdateConfig } from '#lib/_fbs/open-shock/serialization/configuration/ota-update-config.js'; +import { OtaUpdateStep } from '#lib/_fbs/open-shock/serialization/configuration/ota-update-step.js'; +import { RFConfig } from '#lib/_fbs/open-shock/serialization/configuration/rfconfig.js'; +import { SerialInputConfig } from '#lib/_fbs/open-shock/serialization/configuration/serial-input-config.js'; +import { WiFiConfig } from '#lib/_fbs/open-shock/serialization/configuration/wi-fi-config.js'; +import { WiFiCredentials } from '#lib/_fbs/open-shock/serialization/configuration/wi-fi-credentials.js'; +import { Builder, ByteBuffer } from 'flatbuffers'; +import { describe, expect, it } from 'vitest'; +import { mapConfig } from './ConfigMapper'; + +interface Strings { + apSsid: string; + hostname: string; + domain: string; + cdnDomain: string; +} + +// Builds a HubConfig the way the hub serializes it (every section present). +function buildHubConfig(strings: Strings): HubConfig { + const b = new Builder(512); + + const ssid = b.createString('HomeNet'); + WiFiCredentials.startWiFiCredentials(b); + WiFiCredentials.addId(b, 1); + WiFiCredentials.addSsid(b, ssid); + const cred = WiFiCredentials.endWiFiCredentials(b); + + const wifi = WiFiConfig.createWiFiConfig( + b, + b.createString(strings.apSsid), + b.createString(strings.hostname), + WiFiConfig.createCredentialsVector(b, [cred]), + 0, + false, + false, + false + ); + const rf = RFConfig.createRFConfig(b, 15, true); + const portal = CaptivePortalConfig.createCaptivePortalConfig(b, false); + const backend = BackendConfig.createBackendConfig(b, b.createString(strings.domain), 0); + const serial = SerialInputConfig.createSerialInputConfig(b, true); + const ota = OtaUpdateConfig.createOtaUpdateConfig( + b, + true, + b.createString(strings.cdnDomain), + OtaUpdateChannel.Beta, + true, + false, + 30, + true, + false, + 0, + OtaUpdateStep.None + ); + const estop = EStopConfig.createEStopConfig(b, true, 13, false, false); + + HubConfig.startHubConfig(b); + HubConfig.addRf(b, rf); + HubConfig.addWifi(b, wifi); + HubConfig.addCaptivePortal(b, portal); + HubConfig.addBackend(b, backend); + HubConfig.addSerialInput(b, serial); + HubConfig.addOtaUpdate(b, ota); + HubConfig.addEstop(b, estop); + HubConfig.finishHubConfigBuffer(b, HubConfig.endHubConfig(b)); + + return HubConfig.getRootAsHubConfig(new ByteBuffer(b.asUint8Array())); +} + +describe('mapConfig', () => { + it('returns null for a missing config', () => { + expect(mapConfig(null)).toBeNull(); + }); + + it('maps every section', () => { + const config = mapConfig( + buildHubConfig({ + apSsid: 'OpenShock-', + hostname: 'hub', + domain: 'api.example.org', + cdnDomain: 'fw.example.org', + }) + ); + + expect(config?.rf).toEqual({ txPin: 15, keepaliveEnabled: true }); + expect(config?.wifi.hostname).toBe('hub'); + expect(config?.wifi.credentials).toEqual([{ id: 1, ssid: 'HomeNet', password: null }]); + expect(config?.backend.domain).toBe('api.example.org'); + expect(config?.otaUpdate.cdnDomain).toBe('fw.example.org'); + expect(config?.otaUpdate.updateChannel).toBe(OtaUpdateChannel.Beta); + expect(config?.estop).toEqual({ enabled: true, gpioPin: 13 }); + }); + + it('accepts empty strings the hub can legitimately store', () => { + const config = mapConfig( + buildHubConfig({ apSsid: '', hostname: '', domain: '', cdnDomain: '' }) + ); + + expect(config?.wifi.apSsid).toBe(''); + expect(config?.backend.domain).toBe(''); + expect(config?.otaUpdate.cdnDomain).toBe(''); + }); +}); diff --git a/frontend/src/lib/mappers/ConfigMapper.ts b/frontend/src/lib/mappers/ConfigMapper.ts index 0428673c..3e38f3f1 100644 --- a/frontend/src/lib/mappers/ConfigMapper.ts +++ b/frontend/src/lib/mappers/ConfigMapper.ts @@ -78,8 +78,8 @@ function mapWifiConfig(hubConfig: HubConfig): WifiConfig { const apSsid = wifi.apSsid(); const hostname = wifi.hostname(); - if (!apSsid) throw new Error('wifi.apSsid is null'); - if (!hostname) throw new Error('wifi.hostname is null'); + if (apSsid === null) throw new Error('wifi.apSsid is null'); + if (hostname === null) throw new Error('wifi.hostname is null'); const credentials: WifiCredentials[] = []; const credentialsLength = wifi.credentialsLength(); @@ -92,7 +92,7 @@ function mapWifiConfig(hubConfig: HubConfig): WifiConfig { const password = cred.password(); if (!id) throw new Error('cred.id is null'); - if (!ssid) throw new Error('cred.ssid is null'); + if (ssid === null) throw new Error('cred.ssid is null'); credentials.push({ id, @@ -126,7 +126,7 @@ function mapBackendConfig(hubConfig: HubConfig): BackendConfig { const domain = backend.domain(); const authToken = backend.authToken(); - if (!domain) throw new Error('backend.domain is null'); + if (domain === null) throw new Error('backend.domain is null'); return { domain, @@ -157,7 +157,7 @@ function mapOtaUpdateConfig(hubConfig: HubConfig): OtaUpdateConfig { const allowBackendManagement = otaUpdate.allowBackendManagement(); const requireManualApproval = otaUpdate.requireManualApproval(); - if (!cdnDomain) throw new Error('otaUpdate.cdnDomain is null'); + if (cdnDomain === null) throw new Error('otaUpdate.cdnDomain is null'); return { isEnabled, diff --git a/frontend/src/lib/stores/HubStateStore.svelte.ts b/frontend/src/lib/stores/HubStateStore.svelte.ts index 377d94f8..0bef1f21 100644 --- a/frontend/src/lib/stores/HubStateStore.svelte.ts +++ b/frontend/src/lib/stores/HubStateStore.svelte.ts @@ -2,7 +2,7 @@ import { WifiAuthMode } from '#lib/_fbs/open-shock/serialization/types/wifi-auth import type { WifiScanStatus } from '#lib/_fbs/open-shock/serialization/types/wifi-scan-status.js'; import type { Config } from '#lib/mappers/ConfigMapper.js'; import type { WiFiNetwork, WiFiNetworkGroup } from '#lib/types/index.js'; -import { SvelteMap, SvelteSet } from 'svelte/reactivity'; +import { SvelteMap } from 'svelte/reactivity'; function insertSorted(array: T[], value: T, compare: (a: T, b: T) => number) { let low = 0, @@ -19,10 +19,10 @@ function insertSorted(array: T[], value: T, compare: (a: T, b: T) => number) } function ssidMapReducer( - groups: SvelteMap, + groups: Map, [, value]: [string, WiFiNetwork] -): SvelteMap { - const key = `${value.ssid || value.bssid}_${WifiAuthMode[value.security]}`; +): Map { + const key = `${value.ssid}_${WifiAuthMode[value.security]}`; const group = groups.get(key) ?? @@ -39,16 +39,19 @@ function ssidMapReducer( return groups; } -class HubStateStore { +export class HubStateStore { wifiConnectedBSSID = $state(null); wifiScanStatus = $state(null); wifiNetworks = new SvelteMap(); - wifiNetworkGroups = $derived.by>(() => - Array.from(this.wifiNetworks.entries()).reduce(ssidMapReducer, new SvelteMap()) + // Rebuilt from scratch on every change, so plain (non-reactive) collections are enough here + wifiNetworkGroups = $derived.by>(() => + // eslint-disable-next-line svelte/prefer-svelte-reactivity -- rebuilt on every change, never mutated afterwards + Array.from(this.wifiNetworks.entries()).reduce(ssidMapReducer, new Map()) ); // Saved SSIDs from config that aren't visible in scan results savedOnlySSIDs = $derived.by(() => { - const scannedSavedSSIDs = new SvelteSet(); + // eslint-disable-next-line svelte/prefer-svelte-reactivity -- local to this derivation + const scannedSavedSSIDs = new Set(); for (const [, group] of this.wifiNetworkGroups) { if (group.saved) scannedSavedSSIDs.add(group.ssid); } @@ -63,6 +66,7 @@ class HubStateStore { gpioValidOutputs = $state(new Int8Array()); // BSSIDs only listed because the hub is connected to them, not because a scan found them + // eslint-disable-next-line svelte/prefer-svelte-reactivity -- private bookkeeping, never read by the UI #connectionOnlyBSSIDs = new Set(); setWifiNetwork(network: WiFiNetwork) { @@ -88,17 +92,19 @@ class HubStateStore { this.wifiConnectedBSSID = network?.bssid ?? null; } - updateWifiNetwork(bssid: string, updater: (network: WiFiNetwork) => WiFiNetwork) { - const network = this.wifiNetworks.get(bssid); - if (network) { - this.wifiNetworks.set(bssid, updater(network)); - } + // Network objects are replaced, never mutated: SvelteMap only notifies when the stored value changes identity. + markWifiNetworksSaved(ssid: string) { + this.#setWifiNetworksSaved(ssid, true); } markWifiNetworksUnsaved(ssid: string) { + this.#setWifiNetworksSaved(ssid, false); + } + + #setWifiNetworksSaved(ssid: string, saved: boolean) { for (const [bssid, network] of this.wifiNetworks) { - if (network.ssid === ssid && network.saved) { - this.wifiNetworks.set(bssid, { ...network, saved: false }); + if (network.ssid === ssid && network.saved !== saved) { + this.wifiNetworks.set(bssid, { ...network, saved }); } } } diff --git a/frontend/src/lib/stores/UsedPinsStore.svelte.ts b/frontend/src/lib/stores/UsedPinsStore.svelte.ts index ec32b922..e95d55cf 100644 --- a/frontend/src/lib/stores/UsedPinsStore.svelte.ts +++ b/frontend/src/lib/stores/UsedPinsStore.svelte.ts @@ -1,5 +1,8 @@ -class UsedPinsStore { - #pins = $state>(new Map()); +import { SvelteMap } from 'svelte/reactivity'; + +export class UsedPinsStore { + // $state does not proxy a Map; SvelteMap makes has() reactive for the pin selectors + #pins = new SvelteMap(); has(pin: number) { return this.#pins.has(pin); diff --git a/frontend/src/lib/stores/stores.test.ts b/frontend/src/lib/stores/stores.test.ts index 46098e23..1d55c809 100644 --- a/frontend/src/lib/stores/stores.test.ts +++ b/frontend/src/lib/stores/stores.test.ts @@ -1,8 +1,9 @@ import { WifiAuthMode } from '#lib/_fbs/open-shock/serialization/types/wifi-auth-mode.js'; import type { WiFiNetwork } from '#lib/types/index.js'; import { describe, expect, it } from 'vitest'; +import { HubStateStore } from './HubStateStore.svelte'; +import { UsedPinsStore } from './UsedPinsStore.svelte'; -// Import the store freshly for each test to avoid shared state function makeNetwork(overrides: Partial = {}): WiFiNetwork { return { ssid: 'TestNet', @@ -15,28 +16,9 @@ function makeNetwork(overrides: Partial = {}): WiFiNetwork { }; } -// UsedPinsStore is a class, so we can instantiate fresh copies for tests -class UsedPinsStore { - #pins = new Map(); - - has(pin: number) { - return this.#pins.has(pin); - } - - markPinUsed(pin: number, name: string) { - for (const [key, value] of this.#pins) { - if (key === pin || value === name) { - this.#pins.delete(key); - } - } - this.#pins.set(pin, name); - } -} - describe('UsedPinsStore', () => { it('reports a pin as not used initially', () => { - const store = new UsedPinsStore(); - expect(store.has(4)).toBe(false); + expect(new UsedPinsStore().has(4)).toBe(false); }); it('marks a pin as used', () => { @@ -45,17 +27,10 @@ describe('UsedPinsStore', () => { expect(store.has(4)).toBe(true); }); - it('replaces the name when the same pin is re-registered', () => { - const store = new UsedPinsStore(); - store.markPinUsed(4, 'RF TX'); - store.markPinUsed(4, 'EStop'); // same pin, new name — old entry evicted, new one added - expect(store.has(4)).toBe(true); - }); - - it('removes the old pin when the same name is reused', () => { + it('removes the old pin when the same name moves to another pin', () => { const store = new UsedPinsStore(); store.markPinUsed(4, 'RF TX'); - store.markPinUsed(5, 'RF TX'); // same name, different pin — should evict pin 4 + store.markPinUsed(5, 'RF TX'); expect(store.has(4)).toBe(false); expect(store.has(5)).toBe(true); }); @@ -70,28 +45,53 @@ describe('UsedPinsStore', () => { }); }); -describe('WiFiNetwork helpers', () => { - it('makeNetwork produces a valid network with defaults', () => { - const n = makeNetwork(); - expect(n.ssid).toBe('TestNet'); - expect(n.security).toBe(WifiAuthMode.WPA2_PSK); - expect(n.saved).toBe(false); +describe('HubStateStore WiFi grouping', () => { + it('groups BSSIDs of one SSID and security, strongest first', () => { + const store = new HubStateStore(); + store.setWifiNetwork(makeNetwork({ bssid: '01:00:00:00:00:00', rssi: -80 })); + store.setWifiNetwork(makeNetwork({ bssid: '02:00:00:00:00:00', rssi: -40 })); + store.setWifiNetwork(makeNetwork({ bssid: '03:00:00:00:00:00', ssid: 'Other' })); + + const groups = Array.from(store.wifiNetworkGroups.values()); + expect(groups).toHaveLength(2); + + const testNet = groups.find((g) => g.ssid === 'TestNet'); + expect(testNet?.networks.map((n) => n.rssi)).toEqual([-40, -80]); }); - it('makeNetwork applies overrides', () => { - const n = makeNetwork({ ssid: 'Hidden', saved: true, rssi: -80 }); - expect(n.ssid).toBe('Hidden'); - expect(n.saved).toBe(true); - expect(n.rssi).toBe(-80); + it('marks every BSSID of an SSID as saved, replacing the stored objects', () => { + const store = new HubStateStore(); + const original = makeNetwork({ bssid: '01:00:00:00:00:00' }); + store.setWifiNetwork(original); + store.setWifiNetwork(makeNetwork({ bssid: '02:00:00:00:00:00' })); + + store.markWifiNetworksSaved('TestNet'); + + expect(store.wifiNetworks.get('01:00:00:00:00:00')?.saved).toBe(true); + expect(store.wifiNetworks.get('02:00:00:00:00:00')?.saved).toBe(true); + // A new object, so the reactive map notifies (mutating in place did not) + expect(store.wifiNetworks.get('01:00:00:00:00:00')).not.toBe(original); + expect(original.saved).toBe(false); + + store.markWifiNetworksUnsaved('TestNet'); + expect(store.wifiNetworks.get('01:00:00:00:00:00')?.saved).toBe(false); + }); + + it('lists the connected network even when it was not scanned, until it disconnects', () => { + const store = new HubStateStore(); + const connected = makeNetwork({ bssid: '09:00:00:00:00:00' }); + + store.setConnectedWifiNetwork(connected); + expect(store.wifiNetworks.has('09:00:00:00:00:00')).toBe(true); + + store.setConnectedWifiNetwork(null); + expect(store.wifiNetworks.has('09:00:00:00:00:00')).toBe(false); }); - it('networks sort by RSSI descending (stronger signal first)', () => { - const nets = [ - makeNetwork({ bssid: 'AA:AA:AA:AA:AA:AA', rssi: -80 }), - makeNetwork({ bssid: 'BB:BB:BB:BB:BB:BB', rssi: -40 }), - makeNetwork({ bssid: 'CC:CC:CC:CC:CC:CC', rssi: -60 }), - ]; - nets.sort((a, b) => b.rssi - a.rssi); - expect(nets.map((n) => n.rssi)).toEqual([-40, -60, -80]); + it('clearWifiNetworks removes everything', () => { + const store = new HubStateStore(); + store.setWifiNetwork(makeNetwork()); + store.clearWifiNetworks(); + expect(store.wifiNetworks.size).toBe(0); }); }); diff --git a/frontend/src/lib/types/HubState.ts b/frontend/src/lib/types/HubState.ts deleted file mode 100644 index 35938a7e..00000000 --- a/frontend/src/lib/types/HubState.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { WifiScanStatus } from '#lib/_fbs/open-shock/serialization/types/wifi-scan-status.js'; -import type { Config } from '#lib/mappers/ConfigMapper.js'; -import type { WiFiNetwork, WiFiNetworkGroup } from './'; - -export type HubState = { - wifiConnectedBSSID: string | null; - wifiScanStatus: WifiScanStatus | null; - wifiNetworks: Map; - wifiNetworkGroups: Map; - accountLinked: boolean; - config: Config | null; - gpioValidInputs: Int8Array; - gpioValidOutputs: Int8Array; -}; diff --git a/frontend/src/lib/types/index.ts b/frontend/src/lib/types/index.ts index b4ba867b..ae154b14 100644 --- a/frontend/src/lib/types/index.ts +++ b/frontend/src/lib/types/index.ts @@ -1,3 +1,2 @@ -export * from './HubState'; export * from './WiFiNetwork'; export * from './WiFiNetworkGroup'; diff --git a/frontend/src/lib/utils/localRedirect.test.ts b/frontend/src/lib/utils/localRedirect.test.ts new file mode 100644 index 00000000..10bc42ae --- /dev/null +++ b/frontend/src/lib/utils/localRedirect.test.ts @@ -0,0 +1,35 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { getApiBaseUrl, getDeviceHostname } from './localRedirect'; + +function setHostname(hostname: string) { + vi.stubGlobal('window', { location: { hostname } }); +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('getDeviceHostname', () => { + it('maps localhost and 127.0.0.1 to the device host', () => { + setHostname('localhost'); + expect(getDeviceHostname()).toBe('4.3.2.1'); + setHostname('127.0.0.1'); + expect(getDeviceHostname()).toBe('4.3.2.1'); + }); + + it('passes the device and other hostnames through unchanged', () => { + setHostname('4.3.2.1'); + expect(getDeviceHostname()).toBe('4.3.2.1'); + setHostname('openshock.local'); + expect(getDeviceHostname()).toBe('openshock.local'); + }); +}); + +describe('getApiBaseUrl', () => { + it('uses an absolute device URL only when served locally', () => { + setHostname('localhost'); + expect(getApiBaseUrl()).toBe('http://4.3.2.1'); + setHostname('4.3.2.1'); + expect(getApiBaseUrl()).toBe(''); + }); +}); diff --git a/frontend/src/lib/utils/wifiValidation.test.ts b/frontend/src/lib/utils/wifiValidation.test.ts new file mode 100644 index 00000000..63573a98 --- /dev/null +++ b/frontend/src/lib/utils/wifiValidation.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { isValidWifiPassword, isValidWifiSsid } from './wifiValidation'; + +describe('isValidWifiSsid', () => { + it('accepts 1 to 32 bytes', () => { + expect(isValidWifiSsid('a')).toBe(true); + expect(isValidWifiSsid('a'.repeat(32))).toBe(true); + }); + + it('rejects empty and over-long SSIDs', () => { + expect(isValidWifiSsid('')).toBe(false); + expect(isValidWifiSsid('a'.repeat(33))).toBe(false); + }); + + it('counts UTF-8 bytes, not characters', () => { + // 'é' is 2 bytes: 16 of them are 32 bytes, 17 are 34 + expect(isValidWifiSsid('é'.repeat(16))).toBe(true); + expect(isValidWifiSsid('é'.repeat(17))).toBe(false); + }); +}); + +describe('isValidWifiPassword', () => { + it('accepts 8 to 63 characters', () => { + expect(isValidWifiPassword('12345678')).toBe(true); + expect(isValidWifiPassword('a'.repeat(63))).toBe(true); + }); + + it('rejects short and over-long passwords', () => { + expect(isValidWifiPassword('1234567')).toBe(false); + expect(isValidWifiPassword('a'.repeat(64))).toBe(false); + }); +}); diff --git a/frontend/src/lib/utils/wifiValidation.ts b/frontend/src/lib/utils/wifiValidation.ts new file mode 100644 index 00000000..5899380c --- /dev/null +++ b/frontend/src/lib/utils/wifiValidation.ts @@ -0,0 +1,18 @@ +// Mirrors the hub's checks (CaptivePortalInstance apiWifiNetworksAdd): lengths are in bytes, not UTF-16 units. +const encoder = new TextEncoder(); + +function byteLength(value: string): number { + return encoder.encode(value).length; +} + +/** 802.11 SSIDs are 1-32 bytes. */ +export function isValidWifiSsid(ssid: string): boolean { + const length = byteLength(ssid); + return length >= 1 && length <= 32; +} + +/** WPA passphrases are 8-63 characters. */ +export function isValidWifiPassword(password: string): boolean { + const length = byteLength(password); + return length >= 8 && length <= 63; +} diff --git a/frontend/src/lib/views/Guided.svelte b/frontend/src/lib/views/Guided.svelte index b7f09ad2..6db2813a 100644 --- a/frontend/src/lib/views/Guided.svelte +++ b/frontend/src/lib/views/Guided.svelte @@ -28,7 +28,11 @@ let isDIY = $derived(!hubState.hasPredefinedPins); let wifiConnected = $derived(hubState.wifiConnectedBSSID !== null); - let pinConfigured = $derived(hubState.config?.rf?.txPin != null); + // An unset pin arrives as -1, so check it is a real output pin rather than just present + let pinConfigured = $derived.by(() => { + const txPin = hubState.config?.rf?.txPin; + return txPin != null && txPin >= 0 && hubState.gpioValidOutputs.includes(txPin); + }); // Steps: DIY = Pins, Test, WiFi, Account (4) | Prebuilt = Test, WiFi, Account (3) let totalSteps = $derived(isDIY ? 4 : 3); @@ -38,6 +42,11 @@ let wifiStep = $derived(isDIY ? 3 : 2); let accountStep = $derived(isDIY ? 4 : 3); + // Board info loads asynchronously and can drop the Pins step after the wizard opened; keep the step in range + $effect(() => { + if (currentStep > totalSteps) currentStep = totalSteps; + }); + let canAdvance = $derived.by(() => { if (isDIY && currentStep === 1) return pinConfigured; // Test step: always advanceable (testing is optional) diff --git a/frontend/static/favicon.ico b/frontend/static/favicon.ico deleted file mode 100644 index 66eeb745..00000000 Binary files a/frontend/static/favicon.ico and /dev/null differ diff --git a/include/AccountLinkResultCode.h b/include/AccountLinkResultCode.h deleted file mode 100644 index 91d22f42..00000000 --- a/include/AccountLinkResultCode.h +++ /dev/null @@ -1,21 +0,0 @@ -#pragma once - -#include - -namespace OpenShock { - enum class AccountLinkResultCode : uint8_t { - Success = 0, - CodeRequired = 1, - InvalidCodeLength = 2, - NoInternetConnection = 3, - InvalidCode = 4, - RateLimited = 5, - InternalError = 6, - // More descriptive failure causes (previously all reported as InternalError) - RequestFailed = 7, // Could not start/complete the HTTP request (DNS, TLS, connection refused, ...) - RequestTimedOut = 8, // The request to the backend timed out - ServerError = 9, // The backend returned an unexpected response code - InvalidResponse = 10, // The backend response could not be parsed or was missing the auth token - ConfigSaveFailed = 11, // Failed to persist the auth token to flash - }; -} // namespace OpenShock diff --git a/include/Checksum.h b/include/Checksum.h deleted file mode 100644 index 99842699..00000000 --- a/include/Checksum.h +++ /dev/null @@ -1,61 +0,0 @@ -#pragma once - -#include -#include - -#if __cplusplus >= 202'002L -#error Take into use C++20 std::integral instead of this macro logic -#endif - -#define SUM8_INT_FN(Type) \ - constexpr uint8_t Sum8(Type data) \ - { \ - uint8_t result = 0; \ - for (int i = 0; i < sizeof(Type); ++i) { \ - result += static_cast((data >> (i * 8)) & 0xFF); \ - } \ - return result; \ - } - -namespace OpenShock::Checksum { - constexpr uint8_t Sum8(const uint8_t* data, std::size_t size) - { - uint8_t checksum = 0; - for (std::size_t i = 0; i < size; ++i) { - checksum += data[i]; - } - return checksum; - } - template - constexpr uint8_t Sum8(const T& data) - { - static_assert(std::is_trivially_copyable_v, "Sum8 only supports trivially copyable types"); - - return Sum8(reinterpret_cast(std::addressof(data)), sizeof(T)); - } - - SUM8_INT_FN(int16_t) - SUM8_INT_FN(uint16_t) - SUM8_INT_FN(int32_t) - SUM8_INT_FN(uint32_t) - SUM8_INT_FN(int64_t) - SUM8_INT_FN(uint64_t) - - /** - * Make sure the uint8 only has its high bits (0x0F) set before using this function - */ - constexpr uint8_t ReverseNibble(uint8_t b) - { - return (0xF7B3D591E6A2C480ull >> (b * 4)) & 0xF; // Trust me bro - } - - /** - * Make sure the uint8 only has its high bits (0x0F) set before using this function - */ - constexpr uint8_t ReverseInverseNibble(uint8_t b) - { - return (0x084C2A6E195D3B7Full >> (b * 4)) & 0xF; // Trust me bro - } -} // namespace OpenShock::Checksum - -#undef SUM8_INT_FN diff --git a/include/Common.h b/include/Common.h deleted file mode 100644 index 435565f4..00000000 --- a/include/Common.h +++ /dev/null @@ -1,68 +0,0 @@ -#pragma once - -#include -#include - -#define DISABLE_DEFAULT(TypeName) TypeName() = delete -#define DISABLE_COPY(TypeName) \ - TypeName(const TypeName&) = delete; \ - TypeName& operator=(const TypeName&) = delete -#define DISABLE_MOVE(TypeName) \ - TypeName(TypeName&&) = delete; \ - TypeName& operator=(TypeName&&) = delete - -#ifndef OPENSHOCK_API_DOMAIN -#error "OPENSHOCK_API_DOMAIN must be defined" -#endif -#ifndef OPENSHOCK_FW_CDN_DOMAIN -#error "OPENSHOCK_FW_CDN_DOMAIN must be defined" -#endif -#ifndef OPENSHOCK_FW_VERSION -#error "OPENSHOCK_FW_VERSION must be defined" -#endif - -#define OPENSHOCK_FW_CDN_URL(path) "https://" OPENSHOCK_FW_CDN_DOMAIN path - -#define OPENSHOCK_GPIO_INVALID -1 - -#ifndef OPENSHOCK_RF_TX_GPIO -#warning "OPENSHOCK_RF_TX_GPIO is not defined, setting to OPENSHOCK_GPIO_INVALID" -#define OPENSHOCK_RF_TX_GPIO OPENSHOCK_GPIO_INVALID -#endif - -#ifndef OPENSHOCK_ESTOP_PIN -#define OPENSHOCK_ESTOP_PIN OPENSHOCK_GPIO_INVALID -#endif - -// Check if OPENSHOCK_FW_USERAGENT is overridden trough compiler flags, if not, generate a default useragent. -#ifndef OPENSHOCK_FW_USERAGENT -#define OPENSHOCK_FW_USERAGENT OPENSHOCK_FW_HOSTNAME "/" OPENSHOCK_FW_VERSION " (arduino-esp32; " OPENSHOCK_FW_BOARD "; " OPENSHOCK_FW_CHIP "; Espressif)" -#endif - -// Check if Arduino.h exists, if not instruct the developer to remove "arduino-esp32" from the useragent and replace it with "ESP-IDF", after which the developer may remove this warning. -#if defined(__has_include) && !__has_include("Arduino.h") -#warning \ - "Let it be known that Arduino hath finally been cast aside in favor of the noble ESP-IDF! I beseech thee, kind sir or madam, wouldst thou kindly partake in the honors of expunging 'arduino-esp32' from yonder useragent aloft, and in its stead, bestow the illustrious 'ESP-IDF'?" -#endif - -#if __cplusplus >= 202302L -#warning "C++23 compiler detected" -#elif __cplusplus >= 202002L -#warning "C++20 compiler detected" -#elif __cplusplus >= 201703L -// C++17 :3 -#elif __cplusplus >= 201402L -#error "C++14 compiler detected, OpenShock requires a C++17 compliant compiler" -#elif __cplusplus >= 201103L -#error "C++11 compiler detected, OpenShock requires a C++17 compliant compiler" -#elif __cplusplus >= 199711L -#error "C++98 compiler detected, OpenShock requires a C++17 compliant compiler" -#elif __cplusplus == 1 -#error "Pre-C++98 compiler detected, OpenShock requires a C++17 compliant compiler" -#else -#error "Unknown C++ standard detected, OpenShock requires a C++17 compliant compiler" -#endif - -namespace OpenShock::Constants { - const char* const FW_USERAGENT = OPENSHOCK_FW_USERAGENT; -} // namespace OpenShock::Constants diff --git a/include/FirmwareBootType.h b/include/FirmwareBootType.h deleted file mode 100644 index c13aedcf..00000000 --- a/include/FirmwareBootType.h +++ /dev/null @@ -1,30 +0,0 @@ -#pragma once - -#include "serialization/_fbs/FirmwareBootType_generated.h" - -#include -#include - -namespace OpenShock { - typedef OpenShock::Serialization::Types::FirmwareBootType FirmwareBootType; - - inline bool TryParseFirmwareBootType(FirmwareBootType& bootType, const char* str) - { - if (strcasecmp(str, "normal") == 0) { - bootType = FirmwareBootType::Normal; - return true; - } - - if (strcasecmp(str, "newfirmware") == 0 || strcasecmp(str, "new_firmware") == 0) { - bootType = FirmwareBootType::NewFirmware; - return true; - } - - if (strcasecmp(str, "rollback") == 0) { - bootType = FirmwareBootType::Rollback; - return true; - } - - return false; - } -} // namespace OpenShock diff --git a/include/GatewayClient.h b/include/GatewayClient.h deleted file mode 100644 index 49458438..00000000 --- a/include/GatewayClient.h +++ /dev/null @@ -1,44 +0,0 @@ -#pragma once - -#include "Common.h" -#include "GatewayClientState.h" -#include "span.h" - -#include - -#include -#include -#include -#include - -namespace OpenShock { - class GatewayClient { - DISABLE_COPY(GatewayClient); - DISABLE_MOVE(GatewayClient); - - public: - GatewayClient(const std::string& authToken); - ~GatewayClient(); - - inline GatewayClientState state() const { return m_state; } - - void connect(const std::string& host, uint16_t port, const std::string& path); - void disconnect(); - - bool sendMessageTXT(std::string_view data); - bool sendMessageBIN(tcb::span data); - - void markPingReceived(); - - bool loop(); - - private: - void _setState(GatewayClientState state); - void _sendBootStatus(); - void _handleEvent(WStype_t type, uint8_t* payload, std::size_t length); - - WebSocketsClient m_webSocket; - GatewayClientState m_state; - int64_t m_lastPingTimestamp; - }; -} // namespace OpenShock diff --git a/include/Hashing.h b/include/Hashing.h deleted file mode 100644 index 709cab69..00000000 --- a/include/Hashing.h +++ /dev/null @@ -1,62 +0,0 @@ -#pragma once - -#include "Common.h" - -#include -#include -#include - -#include // TODO: When we use C++20, change this to -#include -#include - -namespace OpenShock { - class MD5 { - DISABLE_COPY(MD5); - DISABLE_MOVE(MD5); - - public: - MD5() { mbedtls_md5_init(&ctx); } - ~MD5() { mbedtls_md5_free(&ctx); } - - inline bool begin() { return mbedtls_md5_starts_ret(&ctx) == 0; } - inline bool update(const uint8_t* data, std::size_t dataLen) { return mbedtls_md5_update_ret(&ctx, data, dataLen) == 0; } - inline bool update(std::string_view data) { return update(reinterpret_cast(data.data()), data.length()); } - inline bool finish(std::array& hash) { return mbedtls_md5_finish_ret(&ctx, hash.data()) == 0; } - - private: - mbedtls_md5_context ctx; - }; - class SHA1 { - DISABLE_COPY(SHA1); - DISABLE_MOVE(SHA1); - - public: - SHA1() { mbedtls_sha1_init(&ctx); } - ~SHA1() { mbedtls_sha1_free(&ctx); } - - inline bool begin() { return mbedtls_sha1_starts_ret(&ctx) == 0; } - inline bool update(const uint8_t* data, std::size_t dataLen) { return mbedtls_sha1_update_ret(&ctx, data, dataLen) == 0; } - inline bool update(std::string_view data) { return update(reinterpret_cast(data.data()), data.length()); } - inline bool finish(std::array& hash) { return mbedtls_sha1_finish_ret(&ctx, hash.data()) == 0; } - - private: - mbedtls_sha1_context ctx; - }; - class SHA256 { - DISABLE_COPY(SHA256); - DISABLE_MOVE(SHA256); - - public: - SHA256() { mbedtls_sha256_init(&ctx); } - ~SHA256() { mbedtls_sha256_free(&ctx); } - - inline bool begin() { return mbedtls_sha256_starts_ret(&ctx, 0) == 0; } - inline bool update(const uint8_t* data, std::size_t dataLen) { return mbedtls_sha256_update_ret(&ctx, data, dataLen) == 0; } - inline bool update(std::string_view data) { return update(reinterpret_cast(data.data()), data.length()); } - inline bool finish(std::array& hash) { return mbedtls_sha256_finish_ret(&ctx, hash.data()) == 0; } - - private: - mbedtls_sha256_context ctx; - }; -} // namespace OpenShock diff --git a/include/WebSocketDeFragger.h b/include/WebSocketDeFragger.h deleted file mode 100644 index 2954a1a7..00000000 --- a/include/WebSocketDeFragger.h +++ /dev/null @@ -1,43 +0,0 @@ -#pragma once - -#include "Common.h" -#include "span.h" -#include "TinyVec.h" -#include "WebSocketMessageType.h" - -#include - -#include -#include -#include - -namespace OpenShock { - class WebSocketDeFragger { - DISABLE_COPY(WebSocketDeFragger); - DISABLE_MOVE(WebSocketDeFragger); - - public: - typedef std::function data)> EventCallback; - - WebSocketDeFragger(EventCallback callback); - ~WebSocketDeFragger(); - - void handler(uint8_t socketId, WStype_t type, const uint8_t* payload, std::size_t length); - void onEvent(const EventCallback& callback); - void clear(uint8_t socketId); - void clear(); - - private: - void start(uint8_t socketId, WebSocketMessageType type, const uint8_t* data, uint32_t length); - void append(uint8_t socketId, const uint8_t* data, uint32_t length); - void finish(uint8_t socketId, const uint8_t* data, uint32_t length); - - struct Message { - TinyVec data; - WebSocketMessageType type; - }; - - std::map m_messages; - EventCallback m_callback; - }; -} // namespace OpenShock diff --git a/include/captiveportal/CaptivePortalInstance.h b/include/captiveportal/CaptivePortalInstance.h deleted file mode 100644 index 09f3a53c..00000000 --- a/include/captiveportal/CaptivePortalInstance.h +++ /dev/null @@ -1,47 +0,0 @@ -#pragma once - -#include "Common.h" -#include "span.h" -#include "WebSocketDeFragger.h" - -#include -#include -#include -#include - -#include - -#include -#include -#include - -namespace OpenShock::CaptivePortal { - class CaptivePortalInstance { - DISABLE_COPY(CaptivePortalInstance); - DISABLE_MOVE(CaptivePortalInstance); - - public: - CaptivePortalInstance(); - ~CaptivePortalInstance(); - - bool sendMessageTXT(uint8_t socketId, std::string_view data) { return m_socketServer.sendTXT(socketId, data.data(), data.length()); } - bool sendMessageBIN(uint8_t socketId, tcb::span data) { return m_socketServer.sendBIN(socketId, data.data(), data.size()); } - bool broadcastMessageTXT(std::string_view data) { return m_socketServer.broadcastTXT(data.data(), data.length()); } - bool broadcastMessageBIN(tcb::span data) { return m_socketServer.broadcastBIN(data.data(), data.size()); } - bool hasClients() { return m_socketServer.connectedClients() > 0; } - - private: - void task(); - void handleWebSocketClientConnected(uint8_t socketId); - void handleWebSocketClientDisconnected(uint8_t socketId); - void handleWebSocketEvent(uint8_t socketId, WebSocketMessageType type, tcb::span payload); - - AsyncWebServer m_webServer; - WebSocketsServer m_socketServer; - WebSocketDeFragger m_socketDeFragger; - fs::LittleFSFS m_fileSystem; - DNSServer m_dnsServer; - TaskHandle_t m_taskHandle; - std::atomic m_stopRequested {false}; - }; -} // namespace OpenShock::CaptivePortal diff --git a/include/captiveportal/RFC8908Handler.h b/include/captiveportal/RFC8908Handler.h deleted file mode 100644 index 36c6e2c6..00000000 --- a/include/captiveportal/RFC8908Handler.h +++ /dev/null @@ -1,17 +0,0 @@ -#pragma once - -#include - -namespace OpenShock::CaptivePortal { - class RFC8908Handler : public AsyncWebHandler { - public: - RFC8908Handler() = default; - virtual ~RFC8908Handler() = default; - - // Static helper for fallback NotFound behavior - static void CatchAll(AsyncWebServerRequest* request); - - bool canHandle(AsyncWebServerRequest* request) const override; - void handleRequest(AsyncWebServerRequest* request) override; - }; -} // namespace OpenShock::CaptivePortal diff --git a/include/config/internal/utils.h b/include/config/internal/utils.h deleted file mode 100644 index cb0144cc..00000000 --- a/include/config/internal/utils.h +++ /dev/null @@ -1,86 +0,0 @@ -#pragma once - -#include "config/ConfigBase.h" - -#include -#include -#include - -#include -#include - -namespace OpenShock::Config::Internal::Utils { - bool FromU8GpioNum(gpio_num_t& val, uint8_t u8Val); - void FromU8GpioNum(gpio_num_t& val, uint8_t u8Val, gpio_num_t defaultVal); - - void FromFbsStr(std::string& str, const flatbuffers::String* fbsStr, const char* defaultStr); - bool FromFbsIPAddress(IPAddress& ip, const flatbuffers::String* fbsIP, const IPAddress& defaultIP); - bool FromJsonBool(bool& val, const cJSON* json, const char* name, bool defaultVal); - bool FromJsonU8(uint8_t& val, const cJSON* json, const char* name, uint8_t defaultVal); - bool FromJsonU16(uint16_t& val, const cJSON* json, const char* name, uint16_t defaultVal); - bool FromJsonI32(int32_t& val, const cJSON* json, const char* name, int32_t defaultVal); - - bool FromJsonStr(std::string& str, const cJSON* json, const char* name); - void FromJsonStr(std::string& str, const cJSON* json, const char* name, const char* defaultStr); - - bool FromJsonIPAddress(IPAddress& ip, const cJSON* json, const char* name); - void FromJsonIPAddress(IPAddress& ip, const cJSON* json, const char* name, const IPAddress& defaultIP); - - bool FromJsonGpioNum(gpio_num_t& val, const cJSON* json, const char* name); - void FromJsonGpioNum(gpio_num_t& val, const cJSON* json, const char* name, gpio_num_t defaultVal); - - template // T inherits from ConfigBase - void FromFbsVec(std::vector& vec, const flatbuffers::Vector>* fbsVec) - { - vec.clear(); - - if (fbsVec == nullptr) { - return; - } - - for (auto fbsItem : *fbsVec) { - T item; - if (item.FromFlatbuffers(fbsItem)) { - vec.push_back(std::move(item)); - } - } - } - template // T inherits from ConfigBase - bool FromJsonStrParsed(T& val, const cJSON* json, const char* name, bool (*StringParser)(T&, const char*), T defaultVal) - { - const cJSON* jsonVal = cJSON_GetObjectItemCaseSensitive(json, name); - if (jsonVal == nullptr) { - val = defaultVal; - return true; - } - - if (cJSON_IsString(jsonVal) == 0) { - return false; - } - - if (!StringParser(val, jsonVal->valuestring)) { - return false; - } - - return true; - } - template // T inherits from ConfigBase - bool FromJsonArray(std::vector& vec, const cJSON* jsonArray) - { - vec.clear(); - if (jsonArray == nullptr) { - return true; - } - - const cJSON* jsonItem = nullptr; - cJSON_ArrayForEach(jsonItem, jsonArray) - { - T item; - if (item.FromJSON(jsonItem)) { - vec.push_back(std::move(item)); - } - } - - return true; - } -} // namespace OpenShock::Config::Internal::Utils diff --git a/include/events/Events.h b/include/events/Events.h deleted file mode 100644 index a47706a3..00000000 --- a/include/events/Events.h +++ /dev/null @@ -1,22 +0,0 @@ -#pragma once - -#include - -#ifdef __cplusplus -extern "C" { -#endif - -ESP_EVENT_DECLARE_BASE(OPENSHOCK_EVENTS); - -enum { - OPENSHOCK_EVENT_ESTOP_STATE_CHANGED, // Event for when the EStop activation state changes - OPENSHOCK_EVENT_GATEWAY_CLIENT_STATE_CHANGED, // Event for when the gateway connection state changes -}; - -#ifdef __cplusplus -} -#endif - -namespace OpenShock::Events { - bool Init(); -} diff --git a/include/http/ContentTypes.h b/include/http/ContentTypes.h deleted file mode 100644 index 84b48da6..00000000 --- a/include/http/ContentTypes.h +++ /dev/null @@ -1,7 +0,0 @@ -#pragma once - -namespace OpenShock::HTTP::ContentType { - constexpr const char* JSON = "application/json"; - constexpr const char* TextPlain = "text/plain"; - constexpr const char* TextHTML = "text/html"; -} // namespace OpenShock::HTTP::ContentType diff --git a/include/http/HTTPRequestManager.h b/include/http/HTTPRequestManager.h deleted file mode 100644 index bed93fd9..00000000 --- a/include/http/HTTPRequestManager.h +++ /dev/null @@ -1,90 +0,0 @@ -#pragma once - -#include - -#include - -#include -#include -#include - -#include "span.h" - -namespace OpenShock::HTTP { - enum class RequestResult : uint8_t { - InternalError, // Internal error - InvalidURL, // Invalid URL - RequestFailed, // Failed to start request - TimedOut, // Request timed out - RateLimited, // Rate limited (can be both local and global) - CodeRejected, // Request completed, but response code was not OK - ParseFailed, // Request completed, but JSON parsing failed - Cancelled, // Request was cancelled - Success, // Request completed successfully - }; - - template - struct [[nodiscard]] Response { - RequestResult result; - int code; - T data; - - inline const char* ResultToString() const - { - switch (result) { - case RequestResult::InternalError: - return "Internal error"; - case RequestResult::InvalidURL: - return "Requested url was invalid"; - case RequestResult::RequestFailed: - return "Request failed"; - case RequestResult::TimedOut: - return "Request timed out"; - case RequestResult::RateLimited: - return "Client was ratelimited"; - case RequestResult::CodeRejected: - return "Unexpected response code"; - case RequestResult::ParseFailed: - return "Parsing the response failed"; - case RequestResult::Cancelled: - return "Request was cancelled"; - case RequestResult::Success: - return "Success"; - default: - return "Unknown reason"; - } - } - }; - - template - using JsonParser = std::function; - using GotContentLengthCallback = std::function; - using DownloadCallback = std::function; - - Response Download(std::string_view url, const std::map& headers, GotContentLengthCallback contentLengthCallback, DownloadCallback downloadCallback, tcb::span acceptedCodes, uint32_t timeoutMs = 10'000); - Response GetString(std::string_view url, const std::map& headers, tcb::span acceptedCodes, uint32_t timeoutMs = 10'000); - - template - Response GetJSON(std::string_view url, const std::map& headers, JsonParser jsonParser, tcb::span acceptedCodes, uint32_t timeoutMs = 10'000) - { - auto response = GetString(url, headers, acceptedCodes, timeoutMs); - if (response.result != RequestResult::Success) { - return {response.result, response.code, {}}; - } - - cJSON* json = cJSON_ParseWithLength(response.data.c_str(), response.data.length()); - if (json == nullptr) { - return {RequestResult::ParseFailed, response.code, {}}; - } - - T data; - if (!jsonParser(response.code, json, data)) { - cJSON_Delete(json); - return {RequestResult::ParseFailed, response.code, {}}; - } - - cJSON_Delete(json); - - return {response.result, response.code, std::move(data)}; - } -} // namespace OpenShock::HTTP diff --git a/include/message_handlers/WebSocket.h b/include/message_handlers/WebSocket.h deleted file mode 100644 index 5b00c4c5..00000000 --- a/include/message_handlers/WebSocket.h +++ /dev/null @@ -1,10 +0,0 @@ -#pragma once - -#include "span.h" - -#include - -namespace OpenShock::MessageHandlers::WebSocket { - void HandleGatewayBinary(tcb::span data); - void HandleLocalBinary(uint8_t socketId, tcb::span data); -} diff --git a/include/radio/rmt/CaiXianlinEncoder.h b/include/radio/rmt/CaiXianlinEncoder.h deleted file mode 100644 index 36ef4da6..00000000 --- a/include/radio/rmt/CaiXianlinEncoder.h +++ /dev/null @@ -1,12 +0,0 @@ -#pragma once - -#include "ShockerCommandType.h" - -#include - -#include - -namespace OpenShock::Rmt::CaiXianlinEncoder { - size_t GetBufferSize(); - bool FillBuffer(rmt_data_t* data, uint16_t shockerId, uint8_t channelId, ShockerCommandType type, uint8_t intensity); -} diff --git a/include/radio/rmt/D80Encoder.h b/include/radio/rmt/D80Encoder.h deleted file mode 100644 index e3c313c8..00000000 --- a/include/radio/rmt/D80Encoder.h +++ /dev/null @@ -1,12 +0,0 @@ -#pragma once - -#include "ShockerCommandType.h" - -#include - -#include - -namespace OpenShock::Rmt::D80Encoder { - size_t GetBufferSize(); - bool FillBuffer(rmt_data_t* data, uint16_t shockerId, ShockerCommandType type, uint8_t intensity); -} diff --git a/include/radio/rmt/Petrainer998DREncoder.h b/include/radio/rmt/Petrainer998DREncoder.h deleted file mode 100644 index 717966fd..00000000 --- a/include/radio/rmt/Petrainer998DREncoder.h +++ /dev/null @@ -1,12 +0,0 @@ -#pragma once - -#include "ShockerCommandType.h" - -#include - -#include - -namespace OpenShock::Rmt::Petrainer998DREncoder { - size_t GetBufferSize(); - bool FillBuffer(rmt_data_t* data, uint16_t shockerId, ShockerCommandType type, uint8_t intensity); -} diff --git a/include/radio/rmt/PetrainerEncoder.h b/include/radio/rmt/PetrainerEncoder.h deleted file mode 100644 index e55b8275..00000000 --- a/include/radio/rmt/PetrainerEncoder.h +++ /dev/null @@ -1,12 +0,0 @@ -#pragma once - -#include "ShockerCommandType.h" - -#include - -#include - -namespace OpenShock::Rmt::PetrainerEncoder { - size_t GetBufferSize(); - bool FillBuffer(rmt_data_t* data, uint16_t shockerId, ShockerCommandType type, uint8_t intensity); -} diff --git a/include/radio/rmt/T330Encoder.h b/include/radio/rmt/T330Encoder.h deleted file mode 100644 index 782a8eaa..00000000 --- a/include/radio/rmt/T330Encoder.h +++ /dev/null @@ -1,12 +0,0 @@ -#pragma once - -#include "ShockerCommandType.h" - -#include - -#include - -namespace OpenShock::Rmt::WellturnT330Encoder { - size_t GetBufferSize(); - bool FillBuffer(rmt_data_t* data, uint16_t shockerId, ShockerCommandType type, uint8_t intensity); -} diff --git a/include/serial/SerialInputHandler.h b/include/serial/SerialInputHandler.h deleted file mode 100644 index 99aeaa38..00000000 --- a/include/serial/SerialInputHandler.h +++ /dev/null @@ -1,47 +0,0 @@ -#pragma once - -#include - -// Making sense of the ESP32 Arduino core #defines -#if ARDUINO_USB_MODE && ARDUINO_USB_CDC_ON_BOOT -#define OS_SERIAL ::Serial0 -#define OS_SERIAL_USB ::Serial -#elif ARDUINO_USB_MODE -#define OS_SERIAL ::Serial -#define OS_SERIAL_USB ::USBSerial -#else -#define OS_SERIAL ::Serial -// Variant lacks USB Serial -#endif - -#if ARDUINO_USB_MODE -#define OS_SERIAL_PRINT(...) \ - { \ - OS_SERIAL.print(__VA_ARGS__); \ - OS_SERIAL_USB.print(__VA_ARGS__); \ - } -#define OS_SERIAL_PRINTF(...) \ - { \ - OS_SERIAL.printf(__VA_ARGS__); \ - OS_SERIAL_USB.printf(__VA_ARGS__); \ - } -#define OS_SERIAL_PRINTLN(...) \ - { \ - OS_SERIAL.println(__VA_ARGS__); \ - OS_SERIAL_USB.println(__VA_ARGS__); \ - } -#else -#define OS_SERIAL_PRINT(...) OS_SERIAL.print(__VA_ARGS__) -#define OS_SERIAL_PRINTF(...) OS_SERIAL.printf(__VA_ARGS__) -#define OS_SERIAL_PRINTLN(...) OS_SERIAL.println(__VA_ARGS__) -#endif - -namespace OpenShock::SerialInputHandler { - [[nodiscard]] bool Init(); - - bool SerialEchoEnabled(); - void SetSerialEchoEnabled(bool enabled); - - void PrintWelcomeHeader(); - void PrintVersionInfo(); -} // namespace OpenShock::SerialInputHandler diff --git a/include/serial/command_handlers/common.h b/include/serial/command_handlers/common.h deleted file mode 100644 index 17ab3563..00000000 --- a/include/serial/command_handlers/common.h +++ /dev/null @@ -1,24 +0,0 @@ -#pragma once - -#include "serial/command_handlers/index.h" -#include "serial/SerialInputHandler.h" - -#include "Logging.h" - -#include - -#if ARDUINO_USB_MODE -#define SERPR_SYS(format, ...) \ - { \ - OS_SERIAL.printf("$SYS$|" format "\r\n", ##__VA_ARGS__); \ - OS_SERIAL_USB.printf("$SYS$|" format "\r\n", ##__VA_ARGS__); \ - } -#else -#define SERPR_SYS(format, ...) OS_SERIAL.printf("$SYS$|" format "\r\n", ##__VA_ARGS__) -#endif - -#define SERPR_RESPONSE(format, ...) SERPR_SYS("Response|" format, ##__VA_ARGS__) -#define SERPR_SUCCESS(format, ...) SERPR_SYS("Success|" format, ##__VA_ARGS__) -#define SERPR_ERROR(format, ...) SERPR_SYS("Error|" format, ##__VA_ARGS__) - -using namespace std::string_view_literals; diff --git a/include/serial/command_handlers/index.h b/include/serial/command_handlers/index.h deleted file mode 100644 index ecfdac5e..00000000 --- a/include/serial/command_handlers/index.h +++ /dev/null @@ -1,48 +0,0 @@ -#pragma once - -#include "serial/command_handlers/CommandEntry.h" - -#include - -namespace OpenShock::Serial::CommandHandlers { - OpenShock::Serial::CommandGroup VersionHandler(); - OpenShock::Serial::CommandGroup RestartHandler(); - OpenShock::Serial::CommandGroup SysInfoHandler(); - OpenShock::Serial::CommandGroup EchoHandler(); - OpenShock::Serial::CommandGroup ValidGpiosHandler(); - OpenShock::Serial::CommandGroup RfTxPinHandler(); - OpenShock::Serial::CommandGroup EStopHandler(); - OpenShock::Serial::CommandGroup DomainHandler(); - OpenShock::Serial::CommandGroup AuthTokenHandler(); - OpenShock::Serial::CommandGroup HostnameHandler(); - OpenShock::Serial::CommandGroup NetworksHandler(); - OpenShock::Serial::CommandGroup KeepAliveHandler(); - OpenShock::Serial::CommandGroup JsonConfigHandler(); - OpenShock::Serial::CommandGroup RawConfigHandler(); - OpenShock::Serial::CommandGroup RfTransmitHandler(); - OpenShock::Serial::CommandGroup FactoryResetHandler(); - OpenShock::Serial::CommandGroup LedTestHandler(); - - inline std::vector AllCommandHandlers() - { - return { - VersionHandler(), - RestartHandler(), - SysInfoHandler(), - EchoHandler(), - ValidGpiosHandler(), - RfTxPinHandler(), - EStopHandler(), - DomainHandler(), - AuthTokenHandler(), - HostnameHandler(), - NetworksHandler(), - KeepAliveHandler(), - JsonConfigHandler(), - RawConfigHandler(), - RfTransmitHandler(), - FactoryResetHandler(), - LedTestHandler(), - }; - } -} // namespace OpenShock::Serial::CommandHandlers diff --git a/include/span.h b/include/span.h deleted file mode 100644 index c26b7f5e..00000000 --- a/include/span.h +++ /dev/null @@ -1,539 +0,0 @@ -#pragma once - -/* -This is an implementation of C++20's std::span -http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2019/n4820.pdf -*/ - -// Copyright Tristan Brindle 2018. -// Distributed under the Boost Software License, Version 1.0. -// (See accompanying file ../../LICENSE_1_0.txt or copy at -// https://www.boost.org/LICENSE_1_0.txt) - -#include -#include -#include -#include - -#ifndef TCB_SPAN_NO_EXCEPTIONS -// Attempt to discover whether we're being compiled with exception support -#if !(defined(__cpp_exceptions) || defined(__EXCEPTIONS) || defined(_CPPUNWIND)) -#define TCB_SPAN_NO_EXCEPTIONS -#endif -#endif - -#ifndef TCB_SPAN_NO_EXCEPTIONS -#include -#include -#endif - -// Various feature test macros - -#ifndef TCB_SPAN_NAMESPACE_NAME -#define TCB_SPAN_NAMESPACE_NAME tcb -#endif - -#if __cplusplus >= 201'703L || (defined(_MSVC_LANG) && _MSVC_LANG >= 201'703L) -#define TCB_SPAN_HAVE_CPP17 -#endif - -#if __cplusplus >= 201'402L || (defined(_MSVC_LANG) && _MSVC_LANG >= 201'402L) -#define TCB_SPAN_HAVE_CPP14 -#endif - -namespace TCB_SPAN_NAMESPACE_NAME { - -// Establish default contract checking behavior -#if !defined(TCB_SPAN_THROW_ON_CONTRACT_VIOLATION) && !defined(TCB_SPAN_TERMINATE_ON_CONTRACT_VIOLATION) && !defined(TCB_SPAN_NO_CONTRACT_CHECKING) -#if defined(NDEBUG) || !defined(TCB_SPAN_HAVE_CPP14) -#define TCB_SPAN_NO_CONTRACT_CHECKING -#else -#define TCB_SPAN_TERMINATE_ON_CONTRACT_VIOLATION -#endif -#endif - -#if defined(TCB_SPAN_THROW_ON_CONTRACT_VIOLATION) - struct contract_violation_error : std::logic_error { - explicit contract_violation_error(const char* msg) - : std::logic_error(msg) - { - } - }; - - inline void contract_violation(const char* msg) - { - throw contract_violation_error(msg); - } - -#elif defined(TCB_SPAN_TERMINATE_ON_CONTRACT_VIOLATION) - [[noreturn]] inline void contract_violation(const char* /*unused*/) - { - std::terminate(); - } -#endif - -#if !defined(TCB_SPAN_NO_CONTRACT_CHECKING) -#define TCB_SPAN_STRINGIFY(cond) #cond -#define TCB_SPAN_EXPECT(cond) cond ? (void)0 : contract_violation("Expected " TCB_SPAN_STRINGIFY(cond)) -#else -#define TCB_SPAN_EXPECT(cond) -#endif - -#if defined(TCB_SPAN_HAVE_CPP17) || defined(__cpp_inline_variables) -#define TCB_SPAN_INLINE_VAR inline -#else -#define TCB_SPAN_INLINE_VAR -#endif - -#if defined(TCB_SPAN_HAVE_CPP14) || (defined(__cpp_constexpr) && __cpp_constexpr >= 201'304) -#define TCB_SPAN_HAVE_CPP14_CONSTEXPR -#endif - -#if defined(TCB_SPAN_HAVE_CPP14_CONSTEXPR) -#define TCB_SPAN_CONSTEXPR14 constexpr -#else -#define TCB_SPAN_CONSTEXPR14 -#endif - -#if defined(TCB_SPAN_HAVE_CPP14_CONSTEXPR) && (!defined(_MSC_VER) || _MSC_VER > 1900) -#define TCB_SPAN_CONSTEXPR_ASSIGN constexpr -#else -#define TCB_SPAN_CONSTEXPR_ASSIGN -#endif - -#if defined(TCB_SPAN_NO_CONTRACT_CHECKING) -#define TCB_SPAN_CONSTEXPR11 constexpr -#else -#define TCB_SPAN_CONSTEXPR11 TCB_SPAN_CONSTEXPR14 -#endif - -#if defined(TCB_SPAN_HAVE_CPP17) || defined(__cpp_deduction_guides) -#define TCB_SPAN_HAVE_DEDUCTION_GUIDES -#endif - -#if defined(TCB_SPAN_HAVE_CPP17) || defined(__cpp_lib_byte) -#define TCB_SPAN_HAVE_STD_BYTE -#endif - -#if defined(TCB_SPAN_HAVE_CPP17) || defined(__cpp_lib_array_constexpr) -#define TCB_SPAN_HAVE_CONSTEXPR_STD_ARRAY_ETC -#endif - -#if defined(TCB_SPAN_HAVE_CONSTEXPR_STD_ARRAY_ETC) -#define TCB_SPAN_ARRAY_CONSTEXPR constexpr -#else -#define TCB_SPAN_ARRAY_CONSTEXPR -#endif - -#ifdef TCB_SPAN_HAVE_STD_BYTE - using byte = std::byte; -#else - using byte = unsigned char; -#endif - -#if defined(TCB_SPAN_HAVE_CPP17) -#define TCB_SPAN_NODISCARD [[nodiscard]] -#else -#define TCB_SPAN_NODISCARD -#endif - - TCB_SPAN_INLINE_VAR constexpr std::size_t dynamic_extent = SIZE_MAX; - - template - class span; - - namespace detail { - - template - struct span_storage { - constexpr span_storage() noexcept = default; - - constexpr span_storage(E* p_ptr, std::size_t /*unused*/) noexcept - : ptr(p_ptr) - { - } - - E* ptr = nullptr; - static constexpr std::size_t size = S; - }; - - template - struct span_storage { - constexpr span_storage() noexcept = default; - - constexpr span_storage(E* p_ptr, std::size_t p_size) noexcept - : ptr(p_ptr) - , size(p_size) - { - } - - E* ptr = nullptr; - std::size_t size = 0; - }; - -// Reimplementation of C++17 std::size() and std::data() -#if defined(TCB_SPAN_HAVE_CPP17) || defined(__cpp_lib_nonmember_container_access) - using std::data; - using std::size; -#else - template - constexpr auto size(const C& c) -> decltype(c.size()) - { - return c.size(); - } - - template - constexpr std::size_t size(const T (&)[N]) noexcept - { - return N; - } - - template - constexpr auto data(C& c) -> decltype(c.data()) - { - return c.data(); - } - - template - constexpr auto data(const C& c) -> decltype(c.data()) - { - return c.data(); - } - - template - constexpr T* data(T (&array)[N]) noexcept - { - return array; - } - - template - constexpr const E* data(std::initializer_list il) noexcept - { - return il.begin(); - } -#endif // TCB_SPAN_HAVE_CPP17 - -#if defined(TCB_SPAN_HAVE_CPP17) || defined(__cpp_lib_void_t) - using std::void_t; -#else - template - using void_t = void; -#endif - - template - using uncvref_t = typename std::remove_cv::type>::type; - - template - struct is_span : std::false_type { }; - - template - struct is_span> : std::true_type { }; - - template - struct is_std_array : std::false_type { }; - - template - struct is_std_array> : std::true_type { }; - - template - struct has_size_and_data : std::false_type { }; - - template - struct has_size_and_data())), decltype(detail::data(std::declval()))>> : std::true_type { }; - - template> - struct is_container { - static constexpr bool value = !is_span::value && !is_std_array::value && !std::is_array::value && has_size_and_data::value; - }; - - template - using remove_pointer_t = typename std::remove_pointer::type; - - template - struct is_container_element_type_compatible : std::false_type { }; - - template - struct is_container_element_type_compatible< - T, - E, - typename std::enable_if()))>::type, void>::value && std::is_convertible()))> (*)[], E (*)[]>::value>::type> - : std::true_type { }; - - template - struct is_complete : std::false_type { }; - - template - struct is_complete : std::true_type { }; - - } // namespace detail - - template - class span { - static_assert( - std::is_object::value, - "A span's ElementType must be an object type (not a " - "reference type or void)" - ); - static_assert( - detail::is_complete::value, - "A span's ElementType must be a complete type (not a forward " - "declaration)" - ); - static_assert(!std::is_abstract::value, "A span's ElementType cannot be an abstract class type"); - - using storage_type = detail::span_storage; - - public: - // constants and types - using element_type = ElementType; - using value_type = typename std::remove_cv::type; - using size_type = std::size_t; - using difference_type = std::ptrdiff_t; - using pointer = element_type*; - using const_pointer = const element_type*; - using reference = element_type&; - using const_reference = const element_type&; - using iterator = pointer; - using reverse_iterator = std::reverse_iterator; - - static constexpr size_type extent = Extent; - - // [span.cons], span constructors, copy, assignment, and destructor - template::type = 0> - constexpr span() noexcept - { - } - - TCB_SPAN_CONSTEXPR11 span(pointer ptr, size_type count) - : storage_(ptr, count) - { - TCB_SPAN_EXPECT(extent == dynamic_extent || count == extent); - } - - TCB_SPAN_CONSTEXPR11 span(pointer first_elem, pointer last_elem) - : storage_(first_elem, last_elem - first_elem) - { - TCB_SPAN_EXPECT(extent == dynamic_extent || last_elem - first_elem == static_cast(extent)); - } - - template::value, int>::type = 0> - constexpr span(element_type (&arr)[N]) noexcept - : storage_(arr, N) - { - } - - template&, ElementType>::value, int>::type = 0> - TCB_SPAN_ARRAY_CONSTEXPR span(std::array& arr) noexcept - : storage_(arr.data(), N) - { - } - - template&, ElementType>::value, int>::type = 0> - TCB_SPAN_ARRAY_CONSTEXPR span(const std::array& arr) noexcept - : storage_(arr.data(), N) - { - } - - template::value && detail::is_container_element_type_compatible::value, int>::type = 0> - constexpr span(Container& cont) - : storage_(detail::data(cont), detail::size(cont)) - { - } - - template::value && detail::is_container_element_type_compatible::value, int>::type = 0> - constexpr span(const Container& cont) - : storage_(detail::data(cont), detail::size(cont)) - { - } - - constexpr span(const span& other) noexcept = default; - - template< - typename OtherElementType, - std::size_t OtherExtent, - typename std::enable_if<(Extent == dynamic_extent || OtherExtent == dynamic_extent || Extent == OtherExtent) && std::is_convertible::value, int>::type = 0> - constexpr span(const span& other) noexcept - : storage_(other.data(), other.size()) - { - } - - ~span() noexcept = default; - - TCB_SPAN_CONSTEXPR_ASSIGN span& operator=(const span& other) noexcept = default; - - // [span.sub], span subviews - template - TCB_SPAN_CONSTEXPR11 span first() const - { - TCB_SPAN_EXPECT(Count <= size()); - return {data(), Count}; - } - - template - TCB_SPAN_CONSTEXPR11 span last() const - { - TCB_SPAN_EXPECT(Count <= size()); - return {data() + (size() - Count), Count}; - } - - template - using subspan_return_t = span; - - template - TCB_SPAN_CONSTEXPR11 subspan_return_t subspan() const - { - TCB_SPAN_EXPECT(Offset <= size() && (Count == dynamic_extent || Offset + Count <= size())); - return {data() + Offset, Count != dynamic_extent ? Count : size() - Offset}; - } - - TCB_SPAN_CONSTEXPR11 span first(size_type count) const - { - TCB_SPAN_EXPECT(count <= size()); - return {data(), count}; - } - - TCB_SPAN_CONSTEXPR11 span last(size_type count) const - { - TCB_SPAN_EXPECT(count <= size()); - return {data() + (size() - count), count}; - } - - TCB_SPAN_CONSTEXPR11 span subspan(size_type offset, size_type count = dynamic_extent) const - { - TCB_SPAN_EXPECT(offset <= size() && (count == dynamic_extent || offset + count <= size())); - return {data() + offset, count == dynamic_extent ? size() - offset : count}; - } - - // [span.obs], span observers - constexpr size_type size() const noexcept { return storage_.size; } - - constexpr size_type size_bytes() const noexcept { return size() * sizeof(element_type); } - - TCB_SPAN_NODISCARD constexpr bool empty() const noexcept { return size() == 0; } - - // [span.elem], span element access - TCB_SPAN_CONSTEXPR11 reference operator[](size_type idx) const - { - TCB_SPAN_EXPECT(idx < size()); - return *(data() + idx); - } - - TCB_SPAN_CONSTEXPR11 reference front() const - { - TCB_SPAN_EXPECT(!empty()); - return *data(); - } - - TCB_SPAN_CONSTEXPR11 reference back() const - { - TCB_SPAN_EXPECT(!empty()); - return *(data() + (size() - 1)); - } - - constexpr pointer data() const noexcept { return storage_.ptr; } - - // [span.iterators], span iterator support - constexpr iterator begin() const noexcept { return data(); } - - constexpr iterator end() const noexcept { return data() + size(); } - - TCB_SPAN_ARRAY_CONSTEXPR reverse_iterator rbegin() const noexcept { return reverse_iterator(end()); } - - TCB_SPAN_ARRAY_CONSTEXPR reverse_iterator rend() const noexcept { return reverse_iterator(begin()); } - - private: - storage_type storage_ {}; - }; - -#ifdef TCB_SPAN_HAVE_DEDUCTION_GUIDES - - /* Deduction Guides */ - template - span(T (&)[N]) -> span; - - template - span(std::array&) -> span; - - template - span(const std::array&) -> span; - - template - span(Container&) -> span()))>::type>; - - template - span(const Container&) -> span; - -#endif // TCB_HAVE_DEDUCTION_GUIDES - - template - constexpr span make_span(span s) noexcept - { - return s; - } - - template - constexpr span make_span(T (&arr)[N]) noexcept - { - return {arr}; - } - - template - TCB_SPAN_ARRAY_CONSTEXPR span make_span(std::array& arr) noexcept - { - return {arr}; - } - - template - TCB_SPAN_ARRAY_CONSTEXPR span make_span(const std::array& arr) noexcept - { - return {arr}; - } - - template - constexpr span()))>::type> make_span(Container& cont) - { - return {cont}; - } - - template - constexpr span make_span(const Container& cont) - { - return {cont}; - } - - template - span as_bytes(span s) noexcept - { - return {reinterpret_cast(s.data()), s.size_bytes()}; - } - - template::value, int>::type = 0> - span as_writable_bytes(span s) noexcept - { - return {reinterpret_cast(s.data()), s.size_bytes()}; - } - - template - constexpr auto get(span s) -> decltype(s[N]) - { - return s[N]; - } - -} // namespace TCB_SPAN_NAMESPACE_NAME - -namespace std { - - template - class tuple_size> : public integral_constant { }; - - template - class tuple_size>; // not defined - - template - class tuple_element> { - public: - static_assert(Extent != TCB_SPAN_NAMESPACE_NAME::dynamic_extent && I < Extent, ""); - using type = ElementType; - }; - -} // end namespace std diff --git a/include/util/Base64Utils.h b/include/util/Base64Utils.h deleted file mode 100644 index cb94cc29..00000000 --- a/include/util/Base64Utils.h +++ /dev/null @@ -1,25 +0,0 @@ -#pragma once - -#include -#include -#include -#include - -#include "span.h" -#include "TinyVec.h" - -namespace OpenShock::Base64Utils { - /// @brief Encodes a byte array to base64. - /// @param data The data to encode. - /// @param dataLen The size of the data to encode. - /// @param output The output string to write to. - /// @return The amount of bytes written to the output buffer. - bool Encode(tcb::span data, std::string& output); - - /// @brief Decodes a base64 string. - /// @param data The data to decode. - /// @param dataLen The size of the data to decode. - /// @param output The output buffer to write to. - /// @return The amount of bytes written to the output buffer. - bool Decode(std::string_view data, TinyVec& output) noexcept; -} // namespace OpenShock::Base64Utils diff --git a/include/util/DigitCounter.h b/include/util/DigitCounter.h deleted file mode 100644 index 31d792a5..00000000 --- a/include/util/DigitCounter.h +++ /dev/null @@ -1,44 +0,0 @@ -#pragma once - -#include -#include -#include - -namespace OpenShock::Util { - template - inline constexpr int Digits10CountMax = std::numeric_limits::digits10 + (std::is_signed_v ? 2 : 1); - - template - constexpr std::size_t Digits10Count(T val) - { - using Decayed = std::remove_cv_t>; - static_assert(std::is_integral_v); - - using U = std::make_unsigned_t; - - std::size_t digits = 1; - U u; - - if constexpr (std::is_signed_v) { - if (val < 0) { - // Account for the sign - digits++; - - // Safe magnitude via unsigned modular negation - u = U(0) - static_cast(val); - } else { - u = static_cast(val); - } - } else { - u = static_cast(val); - } - - // Now count digits of u (magnitude), base-10 - while (u >= 10) { - u /= 10; - digits++; - } - - return digits; - } -} // namespace OpenShock::Util diff --git a/include/util/FnProxy.h b/include/util/FnProxy.h deleted file mode 100644 index 926832e0..00000000 --- a/include/util/FnProxy.h +++ /dev/null @@ -1,47 +0,0 @@ -#pragma once - -#include - -namespace OpenShock::Util { - - namespace detail { - - template - struct FnProxyImpl; - - // Non-const, non-noexcept - template - struct FnProxyImpl { - static R Call(void* p, Ts... args) noexcept(noexcept((static_cast(p)->*MF)(std::move(args)...))) { return (static_cast(p)->*MF)(std::move(args)...); } - }; - - // Const, non-noexcept - template - struct FnProxyImpl { - static R Call(void* p, Ts... args) noexcept(noexcept((static_cast(p)->*MF)(std::move(args)...))) { return (static_cast(p)->*MF)(std::move(args)...); } - }; - - // Non-const, noexcept - template - struct FnProxyImpl { - static R Call(void* p, Ts... args) noexcept { return (static_cast(p)->*MF)(std::move(args)...); } - }; - - // Const, noexcept - template - struct FnProxyImpl { - static R Call(void* p, Ts... args) noexcept { return (static_cast(p)->*MF)(std::move(args)...); } - }; - - } // namespace detail - - /// Variable template: FnProxy<&Class::method> is directly a function pointer. - /// - /// Usage: - /// auto cb = FnProxy<&Sensor::read>; // R(*)(void*, Ts...) - /// Sensor s; - /// cb(&s, 1.5f); - template - inline constexpr auto FnProxy = &detail::FnProxyImpl::Call; - -} // namespace OpenShock::Util diff --git a/include/util/IPAddressUtils.h b/include/util/IPAddressUtils.h deleted file mode 100644 index d9cfe9c9..00000000 --- a/include/util/IPAddressUtils.h +++ /dev/null @@ -1,9 +0,0 @@ -#pragma once - -#include - -#include - -namespace OpenShock { - bool IPV4AddressFromStringView(IPAddress& ip, std::string_view sv); -} diff --git a/include/util/PartitionUtils.h b/include/util/PartitionUtils.h deleted file mode 100644 index 22669020..00000000 --- a/include/util/PartitionUtils.h +++ /dev/null @@ -1,12 +0,0 @@ -#pragma once - -#include - -#include -#include -#include - -namespace OpenShock { - bool TryGetPartitionHash(const esp_partition_t* partition, char (&hash)[65]); - bool FlashPartitionFromUrl(const esp_partition_t* partition, std::string_view remoteUrl, const uint8_t (&remoteHash)[32], std::function progressCallback = nullptr); -} diff --git a/include/util/TaskUtils.h b/include/util/TaskUtils.h deleted file mode 100644 index ce18f3f1..00000000 --- a/include/util/TaskUtils.h +++ /dev/null @@ -1,17 +0,0 @@ -#pragma once - -#include - -#include - -namespace OpenShock::TaskUtils { - /// @brief Create a task on the specified core, or the default core if the specified core is invalid - BaseType_t TaskCreateUniversal(TaskFunction_t pvTaskCode, const char* const pcName, const uint32_t usStackDepth, void* const pvParameters, UBaseType_t uxPriority, TaskHandle_t* const pvCreatedTask, const BaseType_t xCoreID); - - /// @brief Create a task on the core that does expensive work, this should not run on the core that handles WiFi - BaseType_t TaskCreateExpensive(TaskFunction_t pvTaskCode, const char* const pcName, const uint32_t usStackDepth, void* const pvParameters, UBaseType_t uxPriority, TaskHandle_t* const pvCreatedTask); - - /// @brief Waits for a task to self-delete within the given timeout. Force-kills it if it doesn't exit in time. - /// The caller is responsible for signaling the task to stop before calling this. - void StopTask(TaskHandle_t taskHandle, const char* tag, const char* taskName, TickType_t timeout = pdMS_TO_TICKS(1000)); -} // namespace OpenShock::TaskUtils diff --git a/include/visual/MonoLedDriver.h b/include/visual/MonoLedDriver.h deleted file mode 100644 index 6c9acbd8..00000000 --- a/include/visual/MonoLedDriver.h +++ /dev/null @@ -1,52 +0,0 @@ -#pragma once - -#include "Common.h" -#include "SimpleMutex.h" - -#include - -#include - -#include -#include -#include - -namespace OpenShock { - class MonoLedDriver { - DISABLE_DEFAULT(MonoLedDriver); - DISABLE_COPY(MonoLedDriver); - DISABLE_MOVE(MonoLedDriver); - - public: - struct State { - bool level; - uint32_t duration; - }; - - MonoLedDriver(gpio_num_t gpioPin); - ~MonoLedDriver(); - - bool IsValid() const { return m_gpioPin != GPIO_NUM_NC; } - - void SetPattern(const State* pattern, std::size_t patternLength); - template - inline void SetPattern(const State (&pattern)[N]) - { - SetPattern(pattern, N); - } - void ClearPattern(); - - void SetBrightness(uint8_t brightness); - - private: - void ClearPatternInternal(); - void RunPattern(); - - gpio_num_t m_gpioPin; - uint8_t m_brightness; - std::vector m_pattern; - TaskHandle_t m_taskHandle; - SimpleMutex m_taskMutex; - std::atomic m_stopRequested {false}; - }; -} // namespace OpenShock diff --git a/include/visual/RgbLedDriver.h b/include/visual/RgbLedDriver.h deleted file mode 100644 index 131f6690..00000000 --- a/include/visual/RgbLedDriver.h +++ /dev/null @@ -1,57 +0,0 @@ -#pragma once - -#include "Common.h" -#include "SimpleMutex.h" - -#include - -#include - -#include - -#include -#include -#include - -namespace OpenShock { - class RgbLedDriver { - DISABLE_DEFAULT(RgbLedDriver); - DISABLE_COPY(RgbLedDriver); - DISABLE_MOVE(RgbLedDriver); - - public: - RgbLedDriver(gpio_num_t gpioPin); - ~RgbLedDriver(); - - bool IsValid() const { return m_gpioPin != GPIO_NUM_NC; } - - struct RGBState { - uint8_t red; - uint8_t green; - uint8_t blue; - uint32_t duration; - }; - - void SetPattern(const RGBState* pattern, std::size_t patternLength); - template - inline void SetPattern(const RGBState (&pattern)[N]) - { - SetPattern(pattern, N); - } - void ClearPattern(); - - void SetBrightness(uint8_t brightness); - - private: - void ClearPatternInternal(); - void RunPattern(); - - gpio_num_t m_gpioPin; - uint8_t m_brightness; // 0-255 - std::vector m_pattern; - rmt_obj_t* m_rmtHandle; - TaskHandle_t m_taskHandle; - SimpleMutex m_taskMutex; - std::atomic m_stopRequested {false}; - }; -} // namespace OpenShock diff --git a/lib/README b/lib/README deleted file mode 100644 index 6debab1e..00000000 --- a/lib/README +++ /dev/null @@ -1,46 +0,0 @@ - -This directory is intended for project specific (private) libraries. -PlatformIO will compile them to static libraries and link into executable file. - -The source code of each library should be placed in a an own separate directory -("lib/your_library_name/[here are source files]"). - -For example, see a structure of the following two libraries `Foo` and `Bar`: - -|--lib -| | -| |--Bar -| | |--docs -| | |--examples -| | |--src -| | |- Bar.c -| | |- Bar.h -| | |- library.json (optional, custom build options, etc) https://docs.platformio.org/page/librarymanager/config.html -| | -| |--Foo -| | |- Foo.c -| | |- Foo.h -| | -| |- README --> THIS FILE -| -|- platformio.ini -|--src - |- main.c - -and a contents of `src/main.c`: -``` -#include -#include - -int main (void) -{ - ... -} - -``` - -PlatformIO Library Dependency Finder will find automatically dependent -libraries scanning project source files. - -More information about PlatformIO Library Dependency Finder -- https://docs.platformio.org/page/librarymanager/ldf.html diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt new file mode 100644 index 00000000..960c7bae --- /dev/null +++ b/main/CMakeLists.txt @@ -0,0 +1,10 @@ +# Application entry point only. All firmware logic lives in components/core (the +# interconnected subsystem tangle), components/captive_portal, components/ota and +# components/serial_console (the interactive console); main just wires them together +# in app_main. +idf_component_register( + SRCS "main.cpp" + REQUIRES core captive_portal ota serial_console device_control events logging common nvs_flash config +) + +target_compile_options(${COMPONENT_LIB} PRIVATE -Wno-error) diff --git a/src/main.cpp b/main/main.cpp similarity index 66% rename from src/main.cpp rename to main/main.cpp index 70b6d53f..a67fdb6c 100644 --- a/src/main.cpp +++ b/main/main.cpp @@ -4,25 +4,22 @@ const char* const TAG = "main"; #include "captiveportal/Manager.h" #include "CommandHandler.h" -#include "Common.h" #include "config/Config.h" #include "estop/EStopManager.h" #include "events/Events.h" #include "GatewayConnectionManager.h" #include "Logging.h" +#include "OpenShock.h" #include "OtaUpdateManager.h" -#include "serial/SerialInputHandler.h" -#include "util/TaskUtils.h" +#include "serial_console/SerialInputHandler.h" #include "visual/VisualStateManager.h" #include "wifi/WiFiManager.h" -#include "wifi/WiFiScanManager.h" -#include - -#include +#include +#include // Internal setup function, returns true if setup succeeded, false otherwise. -bool trySetup() +static bool trySetup() { if (!OpenShock::VisualStateManager::Init()) { OS_LOGE(TAG, "Unable to initialize VisualStateManager"); @@ -40,7 +37,7 @@ bool trySetup() } if (!OpenShock::CommandHandler::Init()) { - OS_LOGW(TAG, "Unable to initialize CommandHandler"); + OS_LOGE(TAG, "Unable to initialize CommandHandler"); return false; } @@ -63,7 +60,7 @@ bool trySetup() } // OTA setup is the same as normal setup, but we invalidate the currently running app, and roll back if it fails. -void otaSetup() +static void otaSetup() { OS_LOGI(TAG, "Validating OTA app"); @@ -80,7 +77,7 @@ void otaSetup() } // App setup is the same as normal setup, but we restart if it fails. -void appSetup() +static void appSetup() { if (!trySetup()) { OS_LOGI(TAG, "Restarting in 5 seconds..."); @@ -89,14 +86,25 @@ void appSetup() } } -// Arduino setup function -void setup() +// ESP-IDF application entry point. Runs the one-time setup, then stays in the +// update loop below for the lifetime of the firmware (it never returns). +extern "C" void app_main() { - OS_SERIAL.begin(115'200); - -#if ARDUINO_USB_MODE - OS_SERIAL_USB.begin(115'200); -#endif + // WiFi persists calibration/config in the default NVS partition, so NVS must be + // initialized before esp_wifi_init() (or any other NVS user) runs. Arduino used + // to do this implicitly during startup; under the IDF entry point we do it + // explicitly. If the partition is from an incompatible/older layout, reformat it. + esp_err_t nvsResult = nvs_flash_init(); + if (nvsResult == ESP_ERR_NVS_NO_FREE_PAGES || nvsResult == ESP_ERR_NVS_NEW_VERSION_FOUND) { + OS_LOGW(TAG, "NVS partition unusable (%s), erasing and reformatting", esp_err_to_name(nvsResult)); + if (nvs_flash_erase() != ESP_OK) { + OS_PANIC(TAG, "Failed to erase NVS partition"); + } + nvsResult = nvs_flash_init(); + } + if (nvsResult != ESP_OK) { + OS_PANIC(TAG, "Failed to initialize NVS: %s", esp_err_to_name(nvsResult)); + } OpenShock::Config::Init(); @@ -113,25 +121,10 @@ void setup() } else { appSetup(); } -} -void main_app(void* arg) -{ while (true) { OpenShock::GatewayConnectionManager::Update(); vTaskDelay(5); // 5 ticks update interval } } - -void loop() -{ - // Start the main task - if (OpenShock::TaskUtils::TaskCreateExpensive(main_app, "main_app", 8192, nullptr, 1, nullptr) != pdPASS) { // PROFILED: 6KB stack usage - OS_PANIC(TAG, "Failed to create main_app task"); - return; - } - - // Kill the loop task (Arduino is stinky) - vTaskDelete(nullptr); -} diff --git a/chips/partitions_4MB_OTA.csv b/partitions/ota_4mb.csv similarity index 100% rename from chips/partitions_4MB_OTA.csv rename to partitions/ota_4mb.csv diff --git a/chips/partitions_4MB.csv b/partitions/single_4mb.csv similarity index 100% rename from chips/partitions_4MB.csv rename to partitions/single_4mb.csv diff --git a/platformio.ini b/platformio.ini deleted file mode 100644 index 42c9be42..00000000 --- a/platformio.ini +++ /dev/null @@ -1,205 +0,0 @@ -; PlatformIO Project Configuration File -; -; Build options: build flags, source filter -; Upload options: custom upload port, speed and extra flags -; Library options: dependencies, extra library storages -; Advanced options: extra scripting -; -; Please visit documentation for the other options and examples -; https://docs.platformio.org/page/projectconf.html - -[env] -platform = espressif32 @ 7.0.1 -board = az-delivery-devkit-v4 ; Overridden per board -framework = arduino - -build_flags = - -std=c++2a - -std=gnu++2a - -fno-exceptions - -DCONFIG_ASYNC_TCP_QUEUE_SIZE=256 -build_unflags = - -std=gnu++11 - -; Most warnings here are commented out because the compiler detects warnings outside own code, build_src_flags is meant to only be for our code but doesnt seem to work in this instance... -build_src_flags = - -Wall - -Wextra - ;-Wpedantic - ;-Werror - ;-Wcast-align - ;-Wcast-qual - ;-Wctor-dtor-privacy - -Wdisabled-optimization - -Wformat=2 - -Winit-self - -Wlogical-op - ;-Wmissing-declarations - -Wmissing-include-dirs - -Wnoexcept - ;-Wold-style-cast - ;-Woverloaded-virtual - ;-Wredundant-decls - ;-Wshadow - ;-Wsign-conversion - -Wsign-promo - -Wstrict-null-sentinel - -Wstrict-overflow=5 - -Wswitch-default - ;-Wundef - -Wno-unused - -Wno-unknown-pragmas - ;-Weffc++ - -lib_deps = - https://github.com/google/flatbuffers#81edeb17d9118143f2c81caf27edfb0df401279e - https://github.com/OpenShock/ESPAsyncWebServer#7a13c34784ae385aabd45cc02f2e1d174ccc0601 - https://github.com/OpenShock/BadWebSockets#c2ad2c21d39781748af9b4675eb65f7a5c51f40c -custom_openshock.flash_size = 4MB; Can be overridden per board -board_build.filesystem = littlefs -board_build.embed_files = certificates/x509_crt_bundle -extra_scripts = - pre:scripts/install_dependencies.py - pre:scripts/use_openshock_params.py - pre:scripts/embed_env_vars.py - post:scripts/build_frontend.py - -; Serial Monitor options -upload_speed = 921600 -monitor_speed = 115200 -monitor_filters = esp32_exception_decoder - -; Static code analysis -check_tool = cppcheck -check_skip_packages = true -check_flags = - cppcheck: --std=c++20 -j 8 --suppress=*:*/.pio/* --suppress=*:*/include/serialization/_fbs/* - -; https://docs.platformio.org/en/stable/boards/espressif32/wemos_d1_mini32.html -[env:Wemos-D1-Mini-ESP32] -board = Wemos-D1-Mini-ESP32 -custom_openshock.chip = ESP32 -build_flags = ${env.build_flags} - -DOPENSHOCK_LED_GPIO=2 - -DOPENSHOCK_RF_TX_GPIO=15 - -; https://docs.platformio.org/en/latest/boards/espressif32/lolin_s2_mini.html -[env:Wemos-Lolin-S2-Mini] -board = Wemos-Lolin-S2-Mini ; override -custom_openshock.chip = ESP32-S2 -build_flags = ${env.build_flags} - -DOPENSHOCK_LED_GPIO=15 - -; https://docs.platformio.org/en/latest/boards/espressif32/lolin_s3.html -[env:Wemos-Lolin-S3] -board = Wemos-Lolin-S3 ; override -custom_openshock.chip = ESP32-S3 -custom_openshock.flash_size = 16MB -build_flags = ${env.build_flags} - -DOPENSHOCK_LED_WS2812B=38 - -; https://docs.platformio.org/en/latest/boards/espressif32/lolin_s3_mini.html -[env:Wemos-Lolin-S3-Mini] -board = lolin_s3_mini ; builtin -custom_openshock.chip = ESP32-S3 -custom_openshock.flash_size = 4MB -build_flags = ${env.build_flags} - -DOPENSHOCK_LED_WS2812B=47 - -DOPENSHOCK_LED_SWAP_RG_CHANNELS=1 - -DARDUINO_USB_CDC_ON_BOOT=1 - -; https://www.waveshare.com/wiki/ESP32-S3-Zero -[env:Waveshare_esp32_s3_zero] -board = esp32-s3-devkitc-1 -custom_openshock.chip = ESP32-S3 -custom_openshock.flash_size = 4MB -build_flags = ${env.build_flags} - -DOPENSHOCK_RF_TX_GPIO=1 - -DOPENSHOCK_LED_WS2812B=21 - -DOPENSHOCK_LED_SWAP_RG_CHANNELS=1 - -DARDUINO_USB_CDC_ON_BOOT=1 - -[env:Pishock-2023] -board = Wemos-D1-Mini-ESP32 ; override -custom_openshock.chip = ESP32 -build_flags = ${env.build_flags} - -DOPENSHOCK_LED_GPIO=2 - -DOPENSHOCK_RF_TX_GPIO=12 - -[env:Pishock-Lite-2021] -board = Wemos-D1-Mini-ESP32 ; override -custom_openshock.chip = ESP32 -build_flags = ${env.build_flags} - -DOPENSHOCK_LED_GPIO=2 - -DOPENSHOCK_RF_TX_GPIO=15 - -; https://docs.platformio.org/en/latest//boards/espressif32/seeed_xiao_esp32c3.html -[env:Seeed-Xiao-ESP32C3] -board = seeed_xiao_esp32c3 ; builtin -custom_openshock.chip = ESP32-C3 -custom_openshock.flash_size = 4MB - -; https://docs.platformio.org/en/latest//boards/espressif32/seeed_xiao_esp32s3.html -[env:Seeed-Xiao-ESP32S3] -board = seeed_xiao_esp32s3 ; builtin -custom_openshock.chip = ESP32-S3 -custom_openshock.flash_size = 8MB -build_flags = ${env.build_flags} - -DOPENSHOCK_LED_GPIO=21 - -; https://docs.platformio.org/en/latest//boards/espressif32/dfrobot_firebeetle2_esp32e.html -[env:DFRobot-Firebeetle2-ESP32E] -board = dfrobot_firebeetle2_esp32e ; builtin -custom_openshock.chip = ESP32 -custom_openshock.flash_size = 4MB -build_flags = ${env.build_flags} - -DOPENSHOCK_RF_TX_GPIO=13 - -DOPENSHOCK_LED_WS2812B=5 - -DOPENSHOCK_LED_GPIO=2 - -; https://github.com/OpenShock/Hardware/tree/main/Core -; 8MB Flash, assume no PSRAM. -; Uses now-blacklisted pins for RF TX and Status LED. -[env:OpenShock-Core-V1] -board = esp32-s3-devkitc-1 ; builtin -custom_openshock.chip = ESP32-S3 -custom_openshock.flash_size = 8MB -build_flags = ${env.build_flags} - -DOPENSHOCK_LED_WS2812B=48 - -DOPENSHOCK_LED_GPIO=35 - -DOPENSHOCK_RF_TX_GPIO=15 - -DOPENSHOCK_ESTOP_PIN=13 - -; https://github.com/OpenShock/Hardware/tree/main/Core%20v2 -; 8MB Flash, assume no PSRAM. -[env:OpenShock-Core-V2] -board = esp32-s3-devkitc-1 ; builtin -custom_openshock.chip = ESP32-S3 -custom_openshock.flash_size = 8MB -build_flags = ${env.build_flags} - -DOPENSHOCK_LED_WS2812B=14 - -DOPENSHOCK_LED_GPIO=13 - -DOPENSHOCK_RF_TX_GPIO=1 - -DOPENSHOCK_ESTOP_PIN=38 - -; https://docs.platformio.org/en/stable/boards/espressif32/nodemcu-32s.html -[env:NodeMCU-32S] -board = nodemcu-32s -custom_openshock.chip = ESP32 -build_flags = ${env.build_flags} - -DOPENSHOCK_LED_GPIO=2 - -; TODO: -; https://docs.platformio.org/en/latest/boards/espressif32/upesy_wroom.html;upesy-esp32-wroom-devkit - -[env:fs] -custom_openshock.chip = ESP32 -custom_openshock.flash_size = 4MB -; This exists so we don't build individual filesystems per board. - -; Build for CI CodeQL and cppcheck -[env:ci-build] -board = esp32-s3-devkitc-1 ; builtin -custom_openshock.chip = ESP32-S3 -custom_openshock.flash_size = 8MB -build_flags = ${env:OpenShock-Core-V2.build_flags} diff --git a/requirements.txt b/requirements.txt index 8966df7e..2e1f181a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,5 @@ -platformio==6.2.0 +cryptography==50.0.2 esptool==5.4.0 -cryptography==50.0.1 +# scripts/gen_staticfs.py packs the captive-portal assets into a littlefs image +# matching the runtime mount geometry (components/fs/src/LfsPartition.cpp). +littlefs-python==0.19.0 diff --git a/scripts/.gitignore b/scripts/.gitignore deleted file mode 100644 index eeb8a6ec..00000000 --- a/scripts/.gitignore +++ /dev/null @@ -1 +0,0 @@ -**/__pycache__ diff --git a/scripts/build.py b/scripts/build.py new file mode 100644 index 00000000..74d392aa --- /dev/null +++ b/scripts/build.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +"""Native ESP-IDF build driver. + +Usage: + python scripts/build.py [idf.py targets/args ...] + +Resolves the IDF target from boards/.defaults, sets OPENSHOCK_BOARD (so the +root CMakeLists generates the env header), layers sdkconfig.defaults + the board +fragment, and runs idf.py into build//. Replaces the PlatformIO per-env +build so CI and local builds share one entry point. + +Examples: + python scripts/build.py OpenShock-Core-V2 # full build + python scripts/build.py OpenShock-Core-V2 app bootloader partition-table +""" + +import os +import re +import shutil +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + + +def board_fragment(board: str) -> Path: + fragment = ROOT / 'boards' / f'{board}.defaults' + if not fragment.is_file(): + avail = ', '.join(sorted(p.stem for p in (ROOT / 'boards').glob('*.defaults'))) + raise SystemExit(f'error: unknown board {board!r}\n available: {avail}') + return fragment + + +def resolve_target(fragment: Path) -> str: + for line in fragment.read_text(encoding='utf-8').splitlines(): + m = re.match(r'\s*CONFIG_IDF_TARGET\s*=\s*"([^"]+)"', line) + if m: + return m.group(1) + raise SystemExit(f'error: {fragment} does not set CONFIG_IDF_TARGET') + + +def resolve_build_dir(board: str) -> Path: + """Where to build. ``build/`` locally, overridable with OPENSHOCK_BUILD_DIR. + + The build directory path ends up on the compiler command line (``-I/config`` + and the ``@/toolchain/cflags`` response file), so two boards built in + differently-named directories produce different command lines and share nothing in + ccache - even when their sdkconfig, and therefore every object, is identical. + + Locally the per-board directory is what you want, so several boards can coexist. Each + CI job builds exactly one board, so it sets OPENSHOCK_BUILD_DIR to a fixed path and + every board in a cache group then compiles byte-identical objects. + """ + override = os.environ.get('OPENSHOCK_BUILD_DIR') + if override: + path = Path(override) + return path if path.is_absolute() else ROOT / path + return ROOT / 'build' / board + + +def write_sdkconfig_fragment(fragment: Path, build_dir: Path) -> Path: + """Strip the board's GPIO assignments out of the fragment before idf.py sees it. + + boards/.defaults holds two kinds of line: CONFIG_* settings, which are real + Kconfig and belong in sdkconfig, and bare OPENSHOCK_* GPIO assignments, which are + not. The latter are read by scripts/gen_env_header.py into openshock_board.h. + + Keeping the pins out of Kconfig is what lets boards sharing a chip and flash size + produce byte-identical ESP-IDF objects: as Kconfig they landed in sdkconfig.h, which + most of the framework includes, so a different LED pin invalidated everything. + + Kconfig would reject the bare lines outright, so the filtered copy is what idf.py is + pointed at. + """ + out = build_dir / 'board.defaults' + kept = [ + line + for line in fragment.read_text(encoding='utf-8').splitlines() + if not re.match(r'\s*OPENSHOCK_[A-Z0-9_]+\s*=', line) + ] + body = ( + f'# Generated from {fragment.as_posix()} by scripts/build.py - do not edit.\n' + '# The board GPIO assignments are stripped here; they are not Kconfig.\n' + + '\n'.join(kept) + + '\n' + ) + out.parent.mkdir(parents=True, exist_ok=True) + # Write only on change, so touching it does not force a CMake reconfigure. + if not out.is_file() or out.read_text(encoding='utf-8') != body: + out.write_text(body, encoding='utf-8') + return out + + +def main(argv: list[str]) -> int: + if not argv: + raise SystemExit(__doc__) + + board = argv[0] + targets = argv[1:] or ['build'] + + fragment = board_fragment(board) + target = resolve_target(fragment) + build_dir = resolve_build_dir(board) + frag_rel = write_sdkconfig_fragment(fragment, build_dir).relative_to(ROOT).as_posix() + + idf_args = [ + '-C', str(ROOT), + '-B', str(build_dir), + f'-DIDF_TARGET={target}', + f'-DSDKCONFIG_DEFAULTS=sdkconfig.defaults;{frag_rel}', + f'-DSDKCONFIG={build_dir / "sdkconfig"}', + *targets, + ] + + # Prefer an executable `idf.py` wrapper on PATH. install-esp-idf-action (and + # an activated ESP-IDF shell) provide one that sets up the tool + venv + # environment itself - the EIM install is not visible to a plain + # `python tools/idf.py`. Restricted to POSIX: on Windows a bare idf.py isn't + # directly executable, so fall through to the interpreter form there. + idf_wrapper = shutil.which('idf.py') if os.name == 'posix' else None + if idf_wrapper: + cmd = [idf_wrapper, *idf_args] + else: + # Fall back to running tools/idf.py with the current interpreter, which + # requires the ESP-IDF venv to already be active (IDF_PATH set). + idf_path = os.environ.get('IDF_PATH') + if not idf_path: + raise SystemExit('error: IDF_PATH is not set - run the ESP-IDF export script first') + cmd = [sys.executable, str(Path(idf_path) / 'tools' / 'idf.py'), *idf_args] + + env = dict(os.environ, OPENSHOCK_BOARD=board) + print(f'+ OPENSHOCK_BOARD={board} {" ".join(cmd)}', flush=True) + return subprocess.call(cmd, env=env) + + +if __name__ == '__main__': + raise SystemExit(main(sys.argv[1:])) diff --git a/scripts/build_frontend.py b/scripts/build_frontend.py deleted file mode 100644 index 9dda64fe..00000000 --- a/scripts/build_frontend.py +++ /dev/null @@ -1,149 +0,0 @@ -import os -import gzip -import shutil -import hashlib -from utils import sysenv - -Import('env') # type: ignore - - -def dir_ensure(dir): - if not os.path.exists(dir): - os.makedirs(dir) - - -def dir_delete(dir): - if os.path.exists(dir): - shutil.rmtree(dir) - - -def file_delete(file): - if os.path.exists(file): - os.remove(file) - - -def file_gzip(file_path, gzip_path): - size_before = os.path.getsize(file_path) - dir_ensure(os.path.dirname(gzip_path)) - file_delete(gzip_path) - with open(file_path, 'rb') as f_in, gzip.open(gzip_path, 'wb') as f_out: - f_out.write(f_in.read()) - size_after = os.path.getsize(gzip_path) - print(f'Gzipped {file_path}: {size_before} => {size_after} bytes') - - -def file_write_bin(file, data): - try: - with open(file, 'wb') as f: - f.write(data) - return True - except: - print('Error writing to ' + file) - return False - - -def file_read_text(file): - try: - with open(file, 'r', encoding='utf-8') as f: - return (f.read(), 'utf-8') - except: - pass - try: - with open(file, 'r') as f: - return (f.read(), None) - except: - print('Error reading ' + file) - return (None, None) - - -def file_write_text(file, text, enc): - try: - with open(file, 'w', encoding=enc) as f: - f.write(text) - return True - except: - print('Error writing to ' + file) - return False - - -def build_frontend(source, target, env): - # Change working directory to frontend. - os.chdir('frontend') - - # Build the frontend only if it wasn't already built. - # This is to avoid rebuilding the frontend every time the firmware is built. - # This could also lead to some annoying behaviour where the frontend is not updated when the firmware is built. - if not sysenv.get_bool('CI', False): - print('Building frontend...') - os.system('pnpm i') - os.system('pnpm run build') - print('Frontend build complete.') - - # Change working directory back to root. - os.chdir('..') - - # Shorten all the filenames in the data/www/_app/immutable directory. - copy_actions = [] - for root, _, files in os.walk('frontend/build'): - root = root.replace('\\', '/') - newroot = root.replace('frontend/build', 'data/www') - - for filename in files: - filepath = os.path.join(root, filename) - - newfilepath = os.path.join(newroot, filename) - - copy_actions.append((filepath, newfilepath)) - - # Delete the data/www directory if it exists. - dir_delete('data/www') - - for src_path, dst_path in copy_actions: - dir_ensure(os.path.dirname(dst_path)) - if src_path.endswith('.gz'): - shutil.copyfile(src_path, dst_path) - else: - file_gzip(src_path, dst_path + '.gz') - - -def hash_file(filepath): - md5 = hashlib.md5() - sha1 = hashlib.sha1() - sha256 = hashlib.sha256() - - with open(filepath, 'rb') as f: - while True: - chunk = f.read(65536) - if not chunk: - break - md5.update(chunk) - sha1.update(chunk) - sha256.update(chunk) - - return { - 'MD5': md5.hexdigest(), - 'SHA1': sha1.hexdigest(), - 'SHA256': sha256.hexdigest(), - } - - -def process_littlefs_binary(source, target, env): - nTargets = len(target) - if nTargets != 1: - raise Exception(f'Expected 1 target, got {nTargets}') - - # Get the path to the binary and its directory. - littlefs_path = target[0].get_abspath() - - bin_size = os.path.getsize(littlefs_path) - bin_hashes = hash_file(littlefs_path) - - print(f'FileSystem Size: {bin_size} bytes') - print('FileSystem Hashes:') - print('MD5: ' + bin_hashes['MD5']) - print('SHA1: ' + bin_hashes['SHA1']) - print('SHA256: ' + bin_hashes['SHA256']) - - -env.AddPreAction('$BUILD_DIR/littlefs.bin', build_frontend) -env.AddPostAction('$BUILD_DIR/littlefs.bin', process_littlefs_binary) diff --git a/scripts/embed_env_vars.py b/scripts/embed_env_vars.py deleted file mode 100644 index 11d3aadf..00000000 --- a/scripts/embed_env_vars.py +++ /dev/null @@ -1,270 +0,0 @@ -from typing import Mapping -from utils import pioenv, sysenv, dotenv, shorthands -import git -import re - -# This file is invoked by PlatformIO during build. -# See 'extra_scripts' in 'platformio.ini'. - -####################################################### -# DETERMINING THE ENVIRONMENT # -####################################################### - -# Import the PlatformIO env and initialize our PioEnv wrapper. -Import('env') # type: ignore -pio = pioenv.PioEnv(env) # type: ignore - -# Get PIO variables. -project_dir = pio.get_string('PROJECT_DIR') - -# By default, the build is run in DEVELOP mode. -# If we are running in CI and either building the master branch, beta branch, or a tag, then we are in RELEASE mode. -is_ci = shorthands.is_github_ci() -branch_name = shorthands.get_github_ref_name() -is_stable = is_ci and ( - branch_name == 'master' - or shorthands.is_github_pr_into('master') -) -is_beta = is_ci and ( - branch_name == 'beta' - or shorthands.is_github_pr_into('beta') -) -is_tag = is_ci and shorthands.is_github_tag() - -is_release_build = is_stable or is_beta or is_tag - -# Get the build type string. -pio_build_type = 'release' if is_release_build else 'debug' -dotenv_type = 'production' if is_release_build else 'development' - -# Read the correct .env file. -dot = dotenv.DotEnv(project_dir, dotenv_type) - -def get_git_repo(): - try: - return git.Repo(search_parent_directories=True) - except git.exc.InvalidGitRepositoryError: - return None - -def sort_semver(versions): - if not versions or len(versions) == 0: - return [] - - def parse_semver(v): - # Split version into main, prerelease, and build metadata parts - match = re.match(r'^v?(\d+(?:\.\d+)*)(?:-([0-9A-Za-z-.]+))?(?:\+([0-9A-Za-z-.]+))?$', v) - if not match: - raise ValueError(f"Invalid version: {v}") - main_version, prerelease, build = match.groups() - - return (main_version, prerelease, build) - - def version_key(version): - main_version, _, _ = parse_semver(version) - - # Convert the main version into a tuple of integers for natural sorting - main_version_tuple = tuple(map(int, main_version.split('.'))) - - return main_version_tuple - - # Remove versions with prerelease/build suffixes - clean_only = [v for v in versions if not ('-' in v or '+' in v)] - - # Sort by version key - return sorted(clean_only, key=lambda v: version_key(v)) - -def last_element(arr): - return arr[-1] if len(arr) > 0 else None - -git_repo = get_git_repo() -git_commit = git_repo.head.object.hexsha if git_repo is not None else None -git_tags = [tag.name for tag in git_repo.tags] if git_repo is not None else [] -git_latest_clean_tag = last_element(sort_semver(git_tags)) - -# Find env variables based on only the pioenv and sysenv. -def get_pio_firmware_vars() -> dict[str, str | int | bool]: - fw_board = pio.get_string('PIOENV') - fw_chip = pio.get_string('BOARD_MCU') - - def macroify(s: str) -> str: - return s.upper().replace('-', '').replace('_', '') - - vars = {} - vars['OPENSHOCK_FW_BOARD'] = fw_board - vars['OPENSHOCK_FW_BOARD_' + macroify(fw_board)] = True # Used for conditional compilation. - vars['OPENSHOCK_FW_CHIP'] = fw_chip.upper() - vars['OPENSHOCK_FW_CHIP_' + macroify(fw_chip)] = True # Used for conditional compilation. - vars['OPENSHOCK_FW_MODE'] = pio_build_type - if git_commit is not None: - vars['OPENSHOCK_FW_GIT_COMMIT'] = git_commit - return vars - - -####################################################### -# UPDATING BUILD PARAMETERS # -####################################################### - - -# Parse PIO build flags. -# All CPP Defines, i.e. flags starting with "-D", are parsed for key/value and stored in a dictionary (first value). -# All other build flags are returned in a list (second value) -def parse_pio_build_flags(raw_flags: list[str]) -> tuple[dict[str, str | int | bool], list[str]]: - flag_dict = {} - leftover_flags = [] - for flag in raw_flags: - if flag.startswith('-D'): - flag = flag[2::] - if '=' in flag: - (k, v) = flag.split('=', 1) - flag_dict[k] = v - else: - flag_dict[flag] = True - else: - leftover_flags.append(flag) - return (flag_dict, leftover_flags) - - -def transform_cpp_define_string(k: str, v: str) -> str: - # Remove surrounding quotes if present. - if v.startswith('"') and v.endswith('"'): - v = v[1:-1] - - return env.StringifyMacro(v) - - -def serialize_cpp_define(k: str, v: str | int | bool) -> str | int: - # Special case for variables that can sometimes be fully made up of numeric characters, breaking builds in specific situations. - if k in ['OPENSHOCK_FW_GIT_COMMIT', 'OPENSHOCK_FW_VERSION_BUILD']: - return transform_cpp_define_string(k, str(v)) - - try: - return int(v) - except ValueError: - pass - if isinstance(v, str): - return transform_cpp_define_string(k, v) - - # TODO: Handle booleans. - - return v - - -def split_semver(version): - # Match the semver pattern (with optional 'v' prefix) - pattern = r'^v?(?P\d+)\.(?P\d+)\.(?P\d+)(?:-(?P[0-9A-Za-z.-]+))?(?:\+(?P[0-9A-Za-z.-]+))?$' - match = re.match(pattern, version) - - if not match: - raise ValueError("Invalid semver format") - - # Extract components and convert major, minor, patch to integers - major = int(match.group('major')) - minor = int(match.group('minor')) - patch = int(match.group('patch')) - prerelease = match.group('prerelease') # None if not present - build = match.group('build') # None if not present - - return major, minor, patch, prerelease, build - - -# Serialize CPP Defines. -# Strings are escaped to be a correct CPP macro. -# Booleans are turned into integers, True => 1 and False => 0. -# Integers are kept as-is. -def serialize_cpp_defines(raw_defines: dict[str, str | int | bool]) -> dict[str, str | int]: - result_defines = {} - for k, v in raw_defines.items(): - result_defines[k] = serialize_cpp_define(k, v) - return result_defines - - -# Copy key/value pairs from "src" into "dest" only if those keys don't exist in "dest" yet. -def merge_missing_keys(dest: dict[str, str | int | bool], src: Mapping[str, str | int | bool]): - for k, v in src.items(): - if k not in dest: - dest[k] = v - - -# Print a dictionary for debugging purposes. -def print_dump(name: str, map: Mapping[str, str | int | bool]) -> None: - print('%s:' % name) - for k, v in map.items(): - print(' %s = %s' % (k, v)) - - -# Fetch the current build flags and group them into (CPP Defines, Other Flags). -raw_build_flags = pio.get_string_array('BUILD_FLAGS', []) -(cpp_defines, remaining_build_flags) = parse_pio_build_flags(raw_build_flags) - -# Gets all the environment variables prefixed with 'OPENSHOCK_' and add them as CPP Defines. -sys_openshock_vars = sysenv.get_all_prefixed('OPENSHOCK_') -pio_openshock_vars = get_pio_firmware_vars() -dot_openshock_vars = dot.get_all_prefixed('OPENSHOCK_') - -print_dump('Sys OpenShock vars', sys_openshock_vars) -print_dump('PIO OpenShock vars', pio_openshock_vars) -print_dump('Dotenv OpenShock vars', dot_openshock_vars) - -merge_missing_keys(cpp_defines, sys_openshock_vars) -merge_missing_keys(cpp_defines, pio_openshock_vars) -merge_missing_keys(cpp_defines, dot_openshock_vars) - -# Check if OPENSHOCK_FW_VERSION is set. -if 'OPENSHOCK_FW_VERSION' not in cpp_defines: - if is_ci: - raise ValueError('OPENSHOCK_FW_VERSION must be set in environment variables for CI builds.') - - # If latest_git_tag is set, use it (stripped of 'v' prefix), else use 0.0.0, assign to variable. - clean_tag = git_latest_clean_tag.lstrip('v') if git_latest_clean_tag is not None else '0.0.0' - version = clean_tag + '-local' - - # If git_commit is set, append it to the version. - if git_commit is not None: - version += '+' + git_commit[0:7] - - # If not set, get the latest tag. - cpp_defines['OPENSHOCK_FW_VERSION'] = version - - -version_major, version_minor, version_patch, version_prerelease, version_build = split_semver( - cpp_defines['OPENSHOCK_FW_VERSION'] -) - -cpp_defines['OPENSHOCK_FW_VERSION_MAJOR'] = version_major -cpp_defines['OPENSHOCK_FW_VERSION_MINOR'] = version_minor -cpp_defines['OPENSHOCK_FW_VERSION_PATCH'] = version_patch -if version_prerelease is not None: - cpp_defines['OPENSHOCK_FW_VERSION_PRERELEASE'] = version_prerelease -if version_build is not None: - cpp_defines['OPENSHOCK_FW_VERSION_BUILD'] = version_build - -# Gets the log level from environment variables. -# TODO: Delete get_loglevel and use... something more generic. -log_level_int = dot.get_loglevel('LOG_LEVEL') -if log_level_int is None: - raise ValueError('LOG_LEVEL must be set in environment variables.') - -# Beta builds are release builds but with debug-level logging. -if is_beta: - log_level_int = 4 # Debug level. - -cpp_defines['OPENSHOCK_LOG_LEVEL'] = log_level_int -cpp_defines['CORE_DEBUG_LEVEL'] = log_level_int if not is_release_build or is_beta else 2 # Warning level for release, match log level for dev/beta. - -# Serialize and inject CPP Defines. -print_dump('CPP Defines', cpp_defines) - -cpp_defines = serialize_cpp_defines(cpp_defines) - -print('Build type: ' + pio_build_type + (' (beta, debug logging)' if is_beta else '')) - -# Set PIO variables. -env['BUILD_TYPE'] = pio_build_type -env['BUILD_FLAGS'] = remaining_build_flags -env.Append(CPPDEFINES=list(cpp_defines.items())) -env.Append(CXXFLAGS=['-fno-rtti', '-fno-threadsafe-statics', '-fno-use-cxa-atexit']) - -# Rename the firmware.bin to app.bin. -env.Replace(PROGNAME='app') - -print(env.Dump()) \ No newline at end of file diff --git a/scripts/fetch_flatc.py b/scripts/fetch_flatc.py index d85e98da..d2b9b56f 100644 --- a/scripts/fetch_flatc.py +++ b/scripts/fetch_flatc.py @@ -1,73 +1,147 @@ -import os -import requests -import zipfile +"""Download the flatc compiler binaries (Linux + Windows) used by generate_schemas.py. + +flatc must match the vendored flatbuffers runtime (the components/flatbuffers/flatbuffers submodule): generated code +static_asserts the exact flatbuffers version it was produced with. So by default this fetches the release whose +version the submodule's base.h declares. + + fetch_flatc.py fetch flatc matching the submodule + fetch_flatc.py --upgrade move the submodule to the latest flatbuffers release, then fetch its flatc + (aliases: --update, --latest) + +Each download is checked against the SHA-256 digest GitHub publishes for the release asset. +""" + +import argparse +import hashlib +import re +import subprocess +import sys import tempfile +import zipfile from pathlib import Path +import requests -def fetch_latest_flatbuffers_release(output_dir): - # API URL for the latest release - api_url = "https://api.github.com/repos/google/flatbuffers/releases/latest" - - try: - # Fetch the latest release data - response = requests.get(api_url) +ROOT = Path(__file__).resolve().parent.parent +SUBMODULE = ROOT / 'components' / 'flatbuffers' / 'flatbuffers' +BASE_H = SUBMODULE / 'include' / 'flatbuffers' / 'base.h' + +RELEASES_API_URL = 'https://api.github.com/repos/google/flatbuffers/releases' +ASSET_MARKERS = ('Linux.flatc.binary.clang', 'Windows.flatc.binary') +TIMEOUT = 60 + + +def submodule_version() -> str: + """The runtime version the submodule declares, e.g. '25.12.19'.""" + text = BASE_H.read_text(encoding='utf-8') + parts = [] + for name in ('MAJOR', 'MINOR', 'REVISION'): + m = re.search(rf'#define\s+FLATBUFFERS_VERSION_{name}\s+(\d+)', text) + if not m: + raise SystemExit(f'error: FLATBUFFERS_VERSION_{name} not found in {BASE_H}') + parts.append(m.group(1)) + return '.'.join(parts) + + +def release_for_version(version: str) -> dict: + """The GitHub release for `version`. Tags look like 'v25.12.19' or 'v25.12.19-2026-02-06-03fffb2'.""" + tag_pattern = re.compile(rf'^v{re.escape(version)}($|-)') + page = 1 + while True: + response = requests.get(RELEASES_API_URL, params={'per_page': 100, 'page': page}, timeout=TIMEOUT) response.raise_for_status() - release_data = response.json() + releases = response.json() + if not releases: + raise SystemExit(f'error: no flatbuffers release found for version {version}') + for release in releases: + if tag_pattern.match(release['tag_name']): + return release + page += 1 - # Extract assets matching the desired filenames - assets = release_data.get("assets", []) - linux_asset = next((asset for asset in assets if "Linux.flatc.binary.clang" in asset["name"]), None) - windows_asset = next((asset for asset in assets if "Windows.flatc.binary" in asset["name"]), None) - if not linux_asset or not windows_asset: - raise ValueError("Required assets not found in the latest release.") +def latest_release() -> dict: + response = requests.get(f'{RELEASES_API_URL}/latest', timeout=TIMEOUT) + response.raise_for_status() + return response.json() - # Create a temporary directory for downloads - with tempfile.TemporaryDirectory() as temp_dir: - temp_dir_path = Path(temp_dir) - # Download and extract Linux asset - linux_zip_path = temp_dir_path / linux_asset["name"] - download_file(linux_asset["browser_download_url"], linux_zip_path) - extract_zip(linux_zip_path, output_dir) +def git(*args: str) -> None: + subprocess.run(['git', '-C', str(SUBMODULE), *args], check=True) - # Download and extract Windows asset - windows_zip_path = temp_dir_path / windows_asset["name"] - download_file(windows_asset["browser_download_url"], windows_zip_path) - extract_zip(windows_zip_path, output_dir) - print(f"Files have been successfully downloaded, extracted to {output_dir}, and temporary files deleted.") +def bump_submodule(tag: str) -> None: + print(f'Moving the flatbuffers submodule to {tag}') + git('fetch', '--tags', 'origin') + git('checkout', '--detach', tag) - except requests.RequestException as e: - print(f"Error fetching release data: {e}") - except Exception as e: - print(f"An error occurred: {e}") + +def find_asset(release: dict, marker: str) -> dict: + asset = next((a for a in release.get('assets', []) if marker in a['name']), None) + if asset is None: + raise SystemExit(f'error: no asset matching {marker!r} in flatbuffers release {release["tag_name"]}') + return asset -def download_file(url, dest_path): - """Download a file from a URL to the specified destination.""" - response = requests.get(url, stream=True) +def download_verified(asset: dict, dest_path: Path) -> None: + response = requests.get(asset['browser_download_url'], stream=True, timeout=TIMEOUT) response.raise_for_status() - with open(dest_path, "wb") as f: + sha256 = hashlib.sha256() + with open(dest_path, 'wb') as f: for chunk in response.iter_content(chunk_size=8192): f.write(chunk) - print(f"Downloaded: {dest_path}") + sha256.update(chunk) + + expected = (asset.get('digest') or '').removeprefix('sha256:') + if not expected: + raise SystemExit(f'error: GitHub published no digest for {asset["name"]}; refusing to use it unverified') + if sha256.hexdigest() != expected: + raise SystemExit(f'error: SHA-256 mismatch for {asset["name"]} (got {sha256.hexdigest()}, expected {expected})') + + print(f'Downloaded and verified: {asset["name"]}') -def extract_zip(zip_path, extract_to): - """Extract a ZIP file to the specified directory.""" - with zipfile.ZipFile(zip_path, "r") as zip_ref: - zip_ref.extractall(extract_to) - print(f"Extracted: {zip_path} to {extract_to}") +def fetch_binaries(release: dict, output_dir: Path) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + for marker in ASSET_MARKERS: + asset = find_asset(release, marker) + zip_path = Path(temp_dir) / asset['name'] + download_verified(asset, zip_path) + with zipfile.ZipFile(zip_path, 'r') as zip_ref: + zip_ref.extractall(output_dir) + print(f'Extracted {asset["name"]} to {output_dir}') -if __name__ == "__main__": - # Specify the output directory for the extracted files - output_directory = Path(input("Enter the directory to extract the files to: ")).resolve() +def main(): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument('output_dir', nargs='?', default=str(Path(__file__).resolve().parent), + help='directory to extract flatc into (default: scripts/)') + parser.add_argument('--upgrade', '--update', '--latest', dest='upgrade', action='store_true', + help='move the flatbuffers submodule to the latest release and fetch its flatc') + args = parser.parse_args() + + output_dir = Path(args.output_dir).resolve() + output_dir.mkdir(parents=True, exist_ok=True) + + try: + if args.upgrade: + release = latest_release() + bump_submodule(release['tag_name']) + else: + version = submodule_version() + print(f'flatbuffers submodule is at {version}') + release = release_for_version(version) + + fetch_binaries(release, output_dir) + except requests.RequestException as e: + sys.exit(f'error: download failed: {e}') + except subprocess.CalledProcessError as e: + sys.exit(f'error: git failed: {e}') + + if args.upgrade: + print(f'\nflatbuffers is now at {release["tag_name"]}. Next: run scripts/generate_schemas.py so the generated code ' + 'matches, then commit the submodule bump together with the regenerated sources.') - if not output_directory.exists(): - output_directory.mkdir(parents=True) - fetch_latest_flatbuffers_release(output_directory) +if __name__ == '__main__': + main() diff --git a/scripts/flatc b/scripts/flatc index dc55abcd..63f734db 100755 Binary files a/scripts/flatc and b/scripts/flatc differ diff --git a/scripts/flatc.exe b/scripts/flatc.exe index 183b26c5..0d666d7b 100644 Binary files a/scripts/flatc.exe and b/scripts/flatc.exe differ diff --git a/scripts/gen_dep_graph.py b/scripts/gen_dep_graph.py new file mode 100644 index 00000000..e2dc077d --- /dev/null +++ b/scripts/gen_dep_graph.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python3 +"""Generate a Mermaid dependency graph from the ESP-IDF component manifests. + +Scans every `components//CMakeLists.txt`, parses the REQUIRES and +PRIV_REQUIRES lists out of its idf_component_register() call, and emits a Mermaid +`flowchart` where each edge points from a dependency to the component that needs +it (i.e. `X --> Y` reads "Y requires X"). + +By default only edges between local components (those with a directory under +components/) are drawn. Pass --external to also include external components +(IDF built-ins, managed_components, etc.) as nodes. + +Usage: + python scripts/gen_dep_graph.py # local components only + python scripts/gen_dep_graph.py --external # include IDF/managed deps + python scripts/gen_dep_graph.py -o graph.mmd # write to a file + +Paste the output into any Mermaid renderer (GitHub, mermaid.live, VS Code). +""" + +import argparse +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + +# idf_component_register() argument keywords. A token equal to one of these ends +# the current value list and (for REQUIRES/PRIV_REQUIRES) starts a new one. +CMAKE_KEYWORDS = { + 'SRCS', 'SRC_DIRS', 'EXCLUDE_SRCS', + 'INCLUDE_DIRS', 'PRIV_INCLUDE_DIRS', + 'REQUIRES', 'PRIV_REQUIRES', 'REQUIRED_IDF_TARGETS', + 'EMBED_FILES', 'EMBED_TXTFILES', 'LDFRAGMENTS', + 'KCONFIG', 'KCONFIG_PROJBUILD', 'WHOLE_ARCHIVE', +} + +# Component name -> display label. Anything not listed falls back to PascalCase +# of the snake_case directory name (e.g. led_drivers -> LedDrivers). +LABEL_OVERRIDES = { + 'osjson': 'OSJson', + 'http': 'HTTP', + 'fs': 'FS', + 'dns_server': 'DnsServer', + 'rfc8908': 'RFC8908', + 'hwutil': 'HwUtil', +} + + +def label_for(name: str) -> str: + if name in LABEL_OVERRIDES: + return LABEL_OVERRIDES[name] + return ''.join(word.capitalize() for word in name.split('_')) + + +def col_id(index: int) -> str: + """Spreadsheet-style id: 0->A, 25->Z, 26->AA, ...""" + s = '' + index += 1 + while index > 0: + index, rem = divmod(index - 1, 26) + s = chr(ord('A') + rem) + s + return s + + +def strip_comments(text: str) -> str: + return '\n'.join(line.split('#', 1)[0] for line in text.splitlines()) + + +def extract_register_block(text: str) -> str: + """Return the contents inside the idf_component_register( ... ) call.""" + m = re.search(r'idf_component_register\s*\(', text) + if not m: + return '' + depth = 0 + start = m.end() - 1 # at the '(' + for i in range(start, len(text)): + c = text[i] + if c == '(': + depth += 1 + elif c == ')': + depth -= 1 + if depth == 0: + return text[start + 1:i] + return text[start + 1:] # unbalanced; take the rest + + +def parse_requires(cmake_text: str, include_priv: bool) -> list[str]: + """Pull the REQUIRES (+ optionally PRIV_REQUIRES) token lists.""" + block = extract_register_block(strip_comments(cmake_text)) + tokens = block.split() + + deps: list[str] = [] + active = None # None | 'collect' + for tok in tokens: + if tok in CMAKE_KEYWORDS: + if tok == 'REQUIRES' or (tok == 'PRIV_REQUIRES' and include_priv): + active = 'collect' + else: + active = None + continue + if active == 'collect': + deps.append(tok) + + # de-dupe while preserving order + seen = set() + ordered = [] + for d in deps: + if d not in seen: + seen.add(d) + ordered.append(d) + return ordered + + +def topo_order(nodes: set[str], edges: set[tuple[str, str]]) -> list[str]: + """Dependencies first. Edge (dep, dependent). Alphabetical tie-break; any + nodes left in a cycle are appended in sorted order so output stays total.""" + indeg = {n: 0 for n in nodes} + adj = {n: [] for n in nodes} + for dep, dependent in edges: + adj[dep].append(dependent) + indeg[dependent] += 1 + + ready = sorted(n for n in nodes if indeg[n] == 0) + order = [] + while ready: + n = ready.pop(0) + order.append(n) + for m in sorted(adj[n]): + indeg[m] -= 1 + if indeg[m] == 0: + ready.append(m) + ready.sort() + + if len(order) < len(nodes): + order.extend(sorted(nodes - set(order))) + return order + + +def main(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument('--components-dir', default=str(ROOT / 'components'), + help='directory holding the component subdirs (default: ./components)') + parser.add_argument('-e', '--external', action='store_true', + help='also include external components (IDF/managed) as nodes') + parser.add_argument('--no-priv', action='store_true', + help='ignore PRIV_REQUIRES, use only public REQUIRES') + parser.add_argument('--direction', default='TD', choices=['TD', 'TB', 'LR', 'RL', 'BT'], + help='Mermaid flowchart direction (default: TD)') + parser.add_argument('-o', '--output', help='write to this file instead of stdout') + args = parser.parse_args(argv) + + comp_dir = Path(args.components_dir) + if not comp_dir.is_dir(): + raise SystemExit(f'error: {comp_dir} is not a directory') + + # Local components: direct children of components/ with a CMakeLists.txt. + local = {} + for cml in sorted(comp_dir.glob('*/CMakeLists.txt')): + name = cml.parent.name + local[name] = parse_requires(cml.read_text(encoding='utf-8'), + include_priv=not args.no_priv) + + edges: set[tuple[str, str]] = set() + nodes: set[str] = set(local) + for name, deps in local.items(): + for dep in deps: + is_local = dep in local + if not is_local and not args.external: + continue + edges.add((dep, name)) # dependency -> dependent + nodes.add(dep) + + order = topo_order(nodes, edges) + ids = {name: col_id(i) for i, name in enumerate(order)} + + lines = [f'flowchart {args.direction}'] + for name in order: + lines.append(f' {ids[name]}({label_for(name)})') + # Edges grouped by source in node order for stable, readable output. + for src in order: + for dst in order: + if (src, dst) in edges: + lines.append(f' {ids[src]} --> {ids[dst]}') + + text = '\n'.join(lines) + '\n' + if args.output: + Path(args.output).write_text(text, encoding='utf-8') + print(f'wrote {args.output} ({len(nodes)} nodes, {len(edges)} edges)', file=sys.stderr) + else: + sys.stdout.write(text) + return 0 + + +if __name__ == '__main__': + raise SystemExit(main(sys.argv[1:])) diff --git a/scripts/gen_env_header.py b/scripts/gen_env_header.py new file mode 100644 index 00000000..30e22180 --- /dev/null +++ b/scripts/gen_env_header.py @@ -0,0 +1,288 @@ +#!/usr/bin/env python3 +"""Generate the OpenShock build headers. + +Native ESP-IDF replacement for the PlatformIO ``embed_env_vars.py`` extra_script. +Two headers are written, split by how often their contents change: + +``openshock_board.h`` + Board and chip identity, build mode, log level, and the board's GPIO + assignments. Changes when the board changes, not when the tree does. + +``openshock_version.h`` + Firmware version and git commit. Changes on *every* commit. + +The split exists for ccache. Both headers used to be one file that the build +force-included into every translation unit, so a new commit changed the +preprocessed text of every source in the project - all of ESP-IDF included - and +nothing could ever be reused. Neither header is force-included now: +``openshock_board.h`` is pulled in by ``OpenShock.h`` (so only OpenShock's own +components see it), and ``openshock_version.h`` by the handful of translation +units that actually report a version. + +The GPIO assignments live here rather than in Kconfig for the same reason. As +Kconfig symbols they landed in ``sdkconfig.h``, which most of ESP-IDF includes, +so two boards that differed only by an LED pin shared no compiled objects at +all. Keeping them out of ``sdkconfig.h`` lets every board with the same chip and +flash size compile byte-identical framework objects. + +Values come from (highest priority first): + 1. ``OPENSHOCK_*`` process environment variables (CI provides the version etc.) + 2. the board fragment (``boards/.defaults``), for the GPIO assignments + 3. computed defaults (local dev version fallback, board/chip macros) +""" + +import argparse +import os +import re +import subprocess +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from utils import shorthands # noqa: E402 + +# Keys whose value must always be emitted as a C string, even when it happens to +# be all-numeric (a bare numeric macro breaks string concatenation / #if usage). +FORCE_STRING_KEYS = { + 'OPENSHOCK_FW_GIT_COMMIT', + 'OPENSHOCK_FW_VERSION_BUILD', + # Semver allows purely-numeric prerelease identifiers (e.g. 1.2.3-1); keep it + # a string so it isn't emitted as a bare int macro. + 'OPENSHOCK_FW_VERSION_PRERELEASE', +} + +# The board's GPIO assignments, with the defaults a board fragment may override. +# `int` entries are emitted verbatim; `bool` entries as 0/1, since RgbLedDriver.cpp +# tests one with `#if`. Every key is always emitted: CompatibilityChecks.cpp puts the +# pins through static_assert, so an undefined macro is a compile error rather than a +# silently disabled feature. +BOARD_PINS: dict[str, tuple[str, int]] = { + 'OPENSHOCK_RF_TX_GPIO': ('int', -1), + 'OPENSHOCK_ESTOP_PIN': ('int', -1), + 'OPENSHOCK_LED_GPIO': ('int', -1), + 'OPENSHOCK_LED_WS2812B': ('int', -1), + 'OPENSHOCK_LED_SWAP_RG_CHANNELS': ('bool', 0), +} + + +def macroify(s: str) -> str: + return s.upper().replace('-', '').replace('_', '') + + +def split_semver(version: str): + pattern = ( + r'^v?(?P\d+)\.(?P\d+)\.(?P\d+)' + r'(?:-(?P[0-9A-Za-z.-]+))?(?:\+(?P[0-9A-Za-z.-]+))?$' + ) + m = re.match(pattern, version) + if not m: + raise ValueError(f'Invalid semver version: {version!r}') + return ( + int(m.group('major')), + int(m.group('minor')), + int(m.group('patch')), + m.group('prerelease'), + m.group('build'), + ) + + +def c_string(value: str) -> str: + escaped = value.replace('\\', '\\\\').replace('"', '\\"') + return f'"{escaped}"' + + +def serialize(key: str, value) -> str: + """Render a macro value: raw int when numeric, else a C string.""" + if key in FORCE_STRING_KEYS: + return c_string(str(value)) + s = str(value) + try: + return str(int(s)) + except ValueError: + return c_string(s) + + +def git_commit() -> str | None: + commit = os.environ.get('OPENSHOCK_FW_GIT_COMMIT') + if commit: + return commit + try: + return subprocess.check_output( + ['git', 'rev-parse', 'HEAD'], text=True, stderr=subprocess.DEVNULL + ).strip() + except Exception: + return None + + +def resolve_version() -> str: + version = os.environ.get('OPENSHOCK_FW_VERSION') + if version: + return version + if shorthands.is_github_ci(): + raise SystemExit('error: OPENSHOCK_FW_VERSION must be set for CI builds') + # Local dev fallback - a valid semver so split_semver() is happy. + commit = git_commit() + return '0.0.0-local' + (f'+{commit[:7]}' if commit else '') + + +def read_board_pins(fragment: Path) -> dict[str, int]: + """Read the bare ``OPENSHOCK_*=`` assignments out of a board fragment. + + These sit alongside the ``CONFIG_*`` lines but are deliberately not Kconfig, so + scripts/build.py strips them before handing the fragment to idf.py. Anything + unrecognised is an error rather than a silent no-op - a typo in a pin name would + otherwise leave the board on the default and be very hard to spot. + """ + values = {key: default for key, (_, default) in BOARD_PINS.items()} + if not fragment.is_file(): + raise SystemExit(f'error: board fragment {fragment} not found') + + for lineno, raw in enumerate(fragment.read_text(encoding='utf-8').splitlines(), 1): + line = raw.strip() + if not line or line.startswith('#') or line.startswith('CONFIG_'): + continue + if '=' not in line: + raise SystemExit(f'error: {fragment}:{lineno}: expected KEY=VALUE, got {line!r}') + key, _, value = line.partition('=') + key, value = key.strip(), value.strip() + if key not in BOARD_PINS: + known = ', '.join(sorted(BOARD_PINS)) + raise SystemExit(f'error: {fragment}:{lineno}: unknown board setting {key!r}\n known: {known}') + + kind, _ = BOARD_PINS[key] + if kind == 'bool': + if value not in ('y', 'n'): + raise SystemExit(f'error: {fragment}:{lineno}: {key} is a boolean, expected y or n, got {value!r}') + values[key] = 1 if value == 'y' else 0 + else: + try: + values[key] = int(value) + except ValueError: + raise SystemExit(f'error: {fragment}:{lineno}: {key} expects an integer GPIO, got {value!r}') + + return values + + +def render(title: str, defines: dict[str, object]) -> str: + lines = [ + '// Generated by scripts/gen_env_header.py - do not edit.', + f'// {title}', + '#pragma once', + '', + ] + for key in sorted(defines): + lines.append(f'#define {key} {serialize(key, defines[key])}') + lines.append('') + return '\n'.join(lines) + + +def write_if_changed(path: Path, content: str) -> bool: + """Write only when the bytes differ. + + CMake re-runs this generator on every configure. Rewriting an identical header + would still move its mtime, which pointlessly disturbs ninja and ccache on + incremental builds. Returns True when the file was actually written. + """ + path.parent.mkdir(parents=True, exist_ok=True) + if path.is_file() and path.read_text(encoding='utf-8') == content: + return False + path.write_text(content, encoding='utf-8') + return True + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument('--out-dir', required=True, help='directory to write the headers into') + ap.add_argument('--board', required=True, help='board name, e.g. OpenShock-Core-V2') + ap.add_argument('--chip', required=True, help='chip, e.g. ESP32-S3') + ap.add_argument('--fragment', required=True, help='path to boards/.defaults') + ap.add_argument( + '--emit', + choices=('board', 'version', 'both'), + default='both', + help=( + 'which headers to write. The board header is generated once at CMake configure ' + 'time; the version header is regenerated on every build, so that a new commit ' + 'is picked up without a reconfigure (see the root CMakeLists.txt).' + ), + ) + args = ap.parse_args() + + # Build mode: release for CI builds of master/beta/tags, debug otherwise. + is_ci = shorthands.is_github_ci() + is_release = is_ci and ( + shorthands.get_github_ref_name() in ('master', 'beta') + or shorthands.is_github_pr_into('master') + or shorthands.is_github_pr_into('beta') + or shorthands.is_github_tag() + ) + # A release-candidate tag (e.g. 1.5.0-rc.7) is a beta-channel build too, so it gets beta's log level. + ref_name = shorthands.get_github_ref_name() or '' + tag_prerelease = ref_name.split('-', 1)[1].lower() if shorthands.is_github_tag() and '-' in ref_name else '' + is_beta = is_ci and ( + ref_name == 'beta' + or shorthands.is_github_pr_into('beta') + or tag_prerelease.startswith(('rc', 'beta')) + ) + mode = 'release' if is_release else 'debug' + + # --- openshock_board.h --------------------------------------------------- + board: dict[str, object] = {} + + board['OPENSHOCK_FW_BOARD'] = args.board + board['OPENSHOCK_FW_BOARD_' + macroify(args.board)] = 1 + board['OPENSHOCK_FW_CHIP'] = args.chip.upper() + board['OPENSHOCK_FW_CHIP_' + macroify(args.chip)] = 1 + board['OPENSHOCK_FW_MODE'] = mode + + # Log level by build mode (formerly .env.development / .env.production): + # debug -> VERBOSE(5), release -> INFO(3), beta -> DEBUG(4). + if is_beta: + log_level = 4 + elif is_release: + log_level = 3 + else: + log_level = 5 + board['OPENSHOCK_LOG_LEVEL'] = log_level + + board.update(read_board_pins(Path(args.fragment))) + + # --- openshock_version.h ------------------------------------------------- + version_defines: dict[str, object] = {} + + version = resolve_version() + major, minor, patch, prerelease, build = split_semver(version) + version_defines['OPENSHOCK_FW_VERSION'] = version + version_defines['OPENSHOCK_FW_VERSION_MAJOR'] = major + version_defines['OPENSHOCK_FW_VERSION_MINOR'] = minor + version_defines['OPENSHOCK_FW_VERSION_PATCH'] = patch + if prerelease is not None: + version_defines['OPENSHOCK_FW_VERSION_PRERELEASE'] = prerelease + if build is not None: + version_defines['OPENSHOCK_FW_VERSION_BUILD'] = build + + commit = git_commit() + if commit is not None: + version_defines['OPENSHOCK_FW_GIT_COMMIT'] = commit + + out_dir = Path(args.out_dir) + wanted = {'board': ('board',), 'version': ('version',), 'both': ('board', 'version')}[args.emit] + written = [] + for which, name, title, defines in ( + ('board', 'openshock_board.h', 'Board identity, build mode and GPIO assignments.', board), + ('version', 'openshock_version.h', 'Firmware version and git commit. Changes every commit.', version_defines), + ): + if which not in wanted: + continue + if write_if_changed(out_dir / name, render(title, defines)): + written.append(name) + + if written: + print(f'gen_env_header: wrote {", ".join(written)} to {out_dir}') + else: + print(f'gen_env_header: {out_dir} already up to date') + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/scripts/gen_staticfs.py b/scripts/gen_staticfs.py new file mode 100644 index 00000000..39dc6c7e --- /dev/null +++ b/scripts/gen_staticfs.py @@ -0,0 +1,198 @@ +#!/usr/bin/env python3 +"""Build the captive-portal static filesystem image (native ESP-IDF). + +Pipeline: + 1. Build the SvelteKit frontend (pnpm) when its sources are newer than the last build (never in CI). + 2. Gzip every built asset into /www/.gz. + 3. Pack /www into a littlefs image sized for the `static0` partition. + +The output is reproducible: gzip headers carry no timestamp and files are packed in sorted order, so an unchanged +frontend gives a byte-identical staticfs.bin (and the same hash, so devices don't re-flash it). + +The littlefs geometry MUST match how the firmware mounts the partition at +runtime (components/fs/src/LfsPartition.cpp): 4 KiB erase blocks, 128-byte +read/prog. Only block_size and block_count are fixed on-disk and validated +against the superblock at mount; the rest are runtime buffer sizes. + +Invoked by CMake (see the root CMakeLists.txt). This replaces the old +PlatformIO SCons extra_script scripts/build_frontend.py. +""" + +import argparse +import gzip +import os +import shutil +import subprocess +import sys + + +def log(msg): + print(f'[gen_staticfs] {msg}', flush=True) + + +# Inputs of the frontend build; a change to any of them makes frontend/build stale. +FRONTEND_INPUTS = ('src', 'static', 'packages', 'index.html', 'package.json', 'pnpm-lock.yaml', 'vite.config.ts', 'tsconfig.json') +FRONTEND_IGNORED_DIRS = {'node_modules', 'build', 'dist', '.svelte-kit'} + + +def newest_mtime(paths): + newest = 0.0 + for path in paths: + if os.path.isfile(path): + newest = max(newest, os.path.getmtime(path)) + continue + for root, dirs, files in os.walk(path): + dirs[:] = [d for d in dirs if d not in FRONTEND_IGNORED_DIRS] + for name in files: + newest = max(newest, os.path.getmtime(os.path.join(root, name))) + return newest + + +def frontend_build_is_current(frontend_dir, build_dir): + index = os.path.join(build_dir, 'index.html') + if not os.path.isfile(index): + return False + inputs = [os.path.join(frontend_dir, p) for p in FRONTEND_INPUTS if os.path.exists(os.path.join(frontend_dir, p))] + return newest_mtime(inputs) <= os.path.getmtime(index) + + +def build_frontend(frontend_dir): + """Run the SvelteKit production build when frontend/build is missing or older than its sources. + + In CI the frontend is built once in a separate job and downloaded as an + artifact into frontend/build, so we must not rebuild it here. + """ + build_dir = os.path.join(frontend_dir, 'build') + + if os.environ.get('CI'): + if not os.path.isdir(build_dir): + raise SystemExit(f'CI build: expected prebuilt frontend at {build_dir}') + log('CI detected — using prebuilt frontend/build') + return build_dir + + if frontend_build_is_current(frontend_dir, build_dir): + log('frontend/build is up to date, skipping pnpm') + return build_dir + + log('Building frontend (pnpm i && pnpm run build)...') + subprocess.run(['pnpm', 'i'], cwd=frontend_dir, check=True, shell=os.name == 'nt') + subprocess.run(['pnpm', 'run', 'build'], cwd=frontend_dir, check=True, shell=os.name == 'nt') + log('Frontend build complete') + return build_dir + + +def stage_assets(build_dir, staging_dir): + """Mirror build_dir into /www, gzipping everything that isn't already .gz.""" + www_dir = os.path.join(staging_dir, 'www') + if os.path.exists(www_dir): + shutil.rmtree(www_dir) + + count = 0 + for root, dirs, files in os.walk(build_dir): + dirs.sort() + rel = os.path.relpath(root, build_dir) + dst_root = www_dir if rel == '.' else os.path.join(www_dir, rel) + os.makedirs(dst_root, exist_ok=True) + + for name in sorted(files): + src = os.path.join(root, name) + if name.endswith('.gz'): + dst = os.path.join(dst_root, name) + shutil.copyfile(src, dst) + else: + dst = os.path.join(dst_root, name + '.gz') + # mtime=0 and no filename in the header: the gzip bytes depend only on the content + with open(src, 'rb') as f_in, open(dst, 'wb') as raw_out: + with gzip.GzipFile(filename='', mode='wb', fileobj=raw_out, mtime=0) as f_out: + f_out.write(f_in.read()) + count += 1 + + log(f'Staged {count} gzipped assets into {www_dir}') + if not os.path.exists(os.path.join(www_dir, 'index.html.gz')): + raise SystemExit(f'{www_dir}/index.html.gz missing — frontend build produced no index.html') + return www_dir + + +def pack_image(staging_dir, output, partition_size, block_size, read_size, prog_size): + """Pack /www into a littlefs image of exactly partition_size bytes (as /www/...).""" + try: + from littlefs import LittleFS + except ImportError: + raise SystemExit("littlefs-python is required (pip install -r requirements.txt)") + + if partition_size % block_size != 0: + raise SystemExit(f'partition size {partition_size} is not a multiple of block size {block_size}') + block_count = partition_size // block_size + + # cache_size must be a multiple of read/prog and divide block_size; 512 matches + # the runtime mount (LfsPartition.cpp). lookahead just needs to be a multiple of 8. + fs = LittleFS( + block_size=block_size, + block_count=block_count, + read_size=read_size, + prog_size=prog_size, + cache_size=512, + lookahead_size=128, + ) + + # Only www/ is packed, so stray files in the staging directory never end up in the image. + file_count = 0 + for root, dirs, files in os.walk(os.path.join(staging_dir, 'www')): + dirs.sort() + rel_root = os.path.relpath(root, staging_dir).replace(os.sep, '/') + fs.makedirs(rel_root, exist_ok=True) + for name in sorted(files): + rel_path = name if rel_root == '.' else f'{rel_root}/{name}' + with open(os.path.join(root, name), 'rb') as f_in: + with fs.open(rel_path, 'wb') as f_out: + f_out.write(f_in.read()) + file_count += 1 + + os.makedirs(os.path.dirname(os.path.abspath(output)), exist_ok=True) + with open(output, 'wb') as f: + f.write(bytes(fs.context.buffer)) + + log(f'Packed {file_count} files into {output} ' + f'({partition_size} bytes, {block_count} x {block_size}-byte blocks)') + + +def main(): + p = argparse.ArgumentParser(description='Build the OpenShock static filesystem image') + p.add_argument('--frontend-dir', required=True, help='Path to the frontend/ project') + p.add_argument('--staging-dir', required=True, help='Where to stage gzipped assets (contains www/)') + p.add_argument('--output', required=True, help='Output littlefs image path') + size = p.add_mutually_exclusive_group(required=True) + size.add_argument('--partition-size', type=lambda v: int(v, 0), help='static0 partition size') + size.add_argument('--partition-csv', help='partition table CSV to read the static0 size from') + p.add_argument('--block-size', type=int, default=4096) + p.add_argument('--read-size', type=int, default=128) + p.add_argument('--prog-size', type=int, default=128) + p.add_argument('--skip-frontend-build', action='store_true', + help='Reuse existing frontend/build without invoking pnpm') + args = p.parse_args() + + if args.skip_frontend_build: + build_dir = os.path.join(args.frontend_dir, 'build') + if not os.path.isdir(build_dir): + raise SystemExit(f'--skip-frontend-build set but {build_dir} does not exist') + else: + build_dir = build_frontend(args.frontend_dir) + + partition_size = args.partition_size + if partition_size is None: + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + from utils import partitions # noqa: E402 + from pathlib import Path + + table = partitions.read_partitions(Path(args.partition_csv)) + if 'static0' not in table: + raise SystemExit(f'{args.partition_csv} has no static0 partition') + partition_size = table['static0']['size'] + + stage_assets(build_dir, args.staging_dir) + pack_image(args.staging_dir, args.output, partition_size, + args.block_size, args.read_size, args.prog_size) + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/scripts/generate_schemas.py b/scripts/generate_schemas.py index 1d809d00..045dac34 100644 --- a/scripts/generate_schemas.py +++ b/scripts/generate_schemas.py @@ -1,15 +1,15 @@ import os -import sys import shutil import subprocess +import sys +import tempfile -def flatc_test(path: str): - return os.system(path + ' --version') == 0 - - -def exec_silent(args: list[str]): - return subprocess.check_output(args, shell=True).decode('utf-8').strip() +def flatc_works(path: str) -> bool: + try: + return subprocess.run([path, '--version'], capture_output=True).returncode == 0 + except OSError: + return False def get_flatc_path(): @@ -21,81 +21,88 @@ def get_flatc_path(): # Check in the working directory, then the scripts directory. for directory in [os.getcwd(), script_dir]: path = os.path.join(directory, flatc_name) - if os.path.exists(path) and flatc_test(path): + if os.path.exists(path) and flatc_works(path): return path # Fall back to whatever is on PATH. - if flatc_test(flatc_name): + if flatc_works(flatc_name): return flatc_name return None -flatc_path = get_flatc_path() -if flatc_path is None: - print('flatc not found. Please install flatc and make sure it is in your PATH.') - exit(1) - - -def run_flatc(args): - # Run flatc. - return os.system(flatc_path + ' ' + args) - - def resolve_path(path): - cwd = os.getcwd() script_path = os.path.dirname(os.path.realpath(__file__)) - return os.path.relpath(os.path.join(script_path, path).replace('\\', '/'), cwd) - - -# resolve paths -schemas_path = resolve_path('../schemas') -ts_output_path = resolve_path('../frontend/src/lib/_fbs') -cpp_output_path = resolve_path('../include/serialization/_fbs') - -# Get all the schema files. -schema_files = [] -for root, dirs, files in os.walk(schemas_path): - root = root.replace('\\', '/') - for filename in files: - filepath = os.path.join(root, filename) - if filename.endswith('.fbs') and not filename.startswith('Deprecated'): - schema_files.append(filepath) - -# Compile the schemas for C++ and TypeScript. -flatc_args_ts = [ - # Compile for TypeScript. - '--ts', - # Don't add .ts extension to imports. - '--ts-no-import-ext', - # Output directory. - '-o ' + ts_output_path, -] + schema_files -flatc_args_cpp = [ - # Compile for C++. - '--cpp', - # Don't prefix enum values in generated C++ by their enum type. - '--no-prefix', - # Use C++11 style scoped and strongly typed enums in generated C++. This also implies --no-prefix. - '--scoped-enums', - # Generate type name functions for C++. - '--gen-name-strings', - # Don't construct custom string types by passing std::string from Flatbuffers, but (char* + length). This allows efficient construction of custom string types, including zero-copy construction. - '--cpp-str-flex-ctor', - # use C++17 features in generated code (experimental). - '--cpp-std c++17', - # Output directory. - '-o ' + cpp_output_path, -] + schema_files - -if os.path.exists(ts_output_path): - print('Deleting old TypeScript schemas') - shutil.rmtree(ts_output_path) -print('Compiling schemas for TypeScript') -run_flatc(' '.join(flatc_args_ts)) - -if os.path.exists(cpp_output_path): - print('Deleting old C++ schemas') - shutil.rmtree(cpp_output_path) -print('Compiling schemas for C++') -run_flatc(' '.join(flatc_args_cpp)) + return os.path.normpath(os.path.join(script_path, path)) + + +def generate(flatc_path, language_args, schema_files, output_path, label): + """Generate into a temporary directory and only replace output_path once flatc succeeded, + so a failing flatc never leaves the tree without generated sources.""" + parent = os.path.dirname(output_path) + os.makedirs(parent, exist_ok=True) + + tmp = tempfile.mkdtemp(dir=parent) + try: + print(f'Compiling schemas for {label}') + result = subprocess.run([flatc_path, *language_args, '-o', tmp, *schema_files]) + if result.returncode != 0: + sys.exit(f'flatc failed for {label} (exit code {result.returncode}); {output_path} left unchanged') + + if os.path.exists(output_path): + print(f'Replacing old {label} schemas') + shutil.rmtree(output_path) + shutil.move(tmp, output_path) + tmp = None + finally: + if tmp is not None and os.path.exists(tmp): + shutil.rmtree(tmp) + + +def main(): + flatc_path = get_flatc_path() + if flatc_path is None: + sys.exit('flatc not found. Please install flatc and make sure it is in your PATH.') + + schemas_path = resolve_path('../schemas') + ts_output_path = resolve_path('../frontend/src/lib/_fbs') + cpp_output_path = resolve_path('../components/serialization/include/serialization/_fbs') + + # Get all the schema files. + schema_files = [] + for root, _, files in os.walk(schemas_path): + for filename in sorted(files): + if filename.endswith('.fbs') and not filename.startswith('Deprecated'): + schema_files.append(os.path.join(root, filename)) + + if not schema_files: + sys.exit(f'No .fbs schemas found in {schemas_path}') + + ts_args = [ + # Compile for TypeScript. + '--ts', + # Don't add .ts extension to imports. + '--ts-no-import-ext', + ] + cpp_args = [ + # Compile for C++. + '--cpp', + # Don't prefix enum values in generated C++ by their enum type. + '--no-prefix', + # Use C++11 style scoped and strongly typed enums in generated C++. This also implies --no-prefix. + '--scoped-enums', + # Generate type name functions for C++. + '--gen-name-strings', + # Don't construct custom string types by passing std::string from Flatbuffers, but (char* + length). This allows efficient construction of custom string types, including zero-copy construction. + '--cpp-str-flex-ctor', + # use C++17 features in generated code (experimental). + '--cpp-std', + 'c++17', + ] + + generate(flatc_path, ts_args, schema_files, ts_output_path, 'TypeScript') + generate(flatc_path, cpp_args, schema_files, cpp_output_path, 'C++') + + +if __name__ == '__main__': + main() diff --git a/scripts/install_dependencies.py b/scripts/install_dependencies.py deleted file mode 100644 index 60d9960d..00000000 --- a/scripts/install_dependencies.py +++ /dev/null @@ -1,59 +0,0 @@ -import importlib.util -import sys -import subprocess - -class Package: - def __init__(self, pip_name, pip_options, import_name): - self.pip_name = pip_name - self.pip_options = pip_options - self.import_name = import_name - - def pip_name(self): - return self.pip_name - - def pip_package(self): - if len(self.pip_options) == 0: - return self.pip_name - - return self.pip_name + '[' + ','.join(self.pip_options) + ']' - - def import_name(self): - return self.import_name - - def is_installed(self): - if self.pip_name in sys.modules: - return True - - if importlib.util.find_spec(self.pip_name) is not None: - return True - - try: - __import__(self.import_name) - return True - except ImportError: - pass - - return False - -# List of packages to install (pip name, package name) -required = [ - # Captive Portal building - Package('fonttools', ['woff', 'unicode'], 'fontTools'), - Package('brotli', [], 'brotli'), - Package('gitpython', [], 'git'), - # esptool (while using custom version) - Package('intelhex', [], "IntelHex"), -] - -# Get all packages that are not installed -to_install = [ p.pip_package() for p in required if not p.is_installed() ] - -# Install all packages that are not installed -if len(to_install) > 0: - print('Installing: ' + ', '.join(to_install)) - - # Get the python executable path - python_path = sys.executable - - # Install the packages - subprocess.check_call([python_path, '-m', 'pip', 'install', *to_install]) diff --git a/scripts/merge_image.py b/scripts/merge_image.py index 58b0ad10..5c7e0495 100644 --- a/scripts/merge_image.py +++ b/scripts/merge_image.py @@ -1,41 +1,190 @@ -#!/bin/python3 +#!/usr/bin/env python3 +"""Merge each board's partition images into one flashable binary (native ESP-IDF). +Combines the discrete images produced by the C++ build (app, bootloader, +partition table) with the shared static0 web-assets image into a single binary +flashable at offset 0 - the web-flasher / GitHub-release artifact. Runs esptool +directly so the merge CI job needs esptool only, not a full ESP-IDF install. + +Takes one board or many. --bindir and --output are templates expanded per board, +so a whole build merges in one invocation, in parallel, rather than one process +per board driven from the outside. + +The partition table, app and static0 offsets are read from the partition CSV the +board's sdkconfig selects; the bootloader offset is the only chip-specific value. The +flash size written into the image header comes from the board's sdkconfig, so an +8 MB board's merged image does not tell its bootloader it has 4 MB. +Replaces the old chips//merge-image.py scripts. +""" + +import argparse +import io import os +import re import sys +from concurrent.futures import ProcessPoolExecutor +from contextlib import redirect_stdout, redirect_stderr from pathlib import Path -from configparser import ConfigParser -from utils.boardconf import BoardConf, from_pio_file, validate, print_header, print_footer +import esptool + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from utils import partitions as partition_table # noqa: E402 + +ROOT = Path(__file__).resolve().parent.parent + +# The 2nd-stage bootloader flashes at 0x0 on chips whose ROM loader expects it +# there, and at 0x1000 on the original ESP32 and ESP32-S2. +BOOTLOADER_OFFSET = { + 'esp32': '0x1000', + 'esp32s2': '0x1000', + 'esp32s3': '0x0', + 'esp32c3': '0x0', +} + + +# ESP-IDF's own default when no sdkconfig input picks a CONFIG_ESPTOOLPY_FLASHSIZE_* choice. +DEFAULT_FLASH_SIZE = '4MB' + + +def board_fragment(board: str) -> Path: + fragment = ROOT / 'boards' / f'{board}.defaults' + if not fragment.is_file(): + raise SystemExit(f'error: unknown board {board!r} ({fragment} missing)') + return fragment + + +def resolve_target(board: str) -> str: + fragment = board_fragment(board) + for line in fragment.read_text(encoding='utf-8').splitlines(): + m = re.match(r'\s*CONFIG_IDF_TARGET\s*=\s*"([^"]+)"', line) + if m: + return m.group(1) + raise SystemExit(f'error: {fragment} does not set CONFIG_IDF_TARGET') + + +def parse_flash_size(text: str) -> str | None: + """The flash size an sdkconfig fragment selects, e.g. '8MB', or None when it picks none. + + It is a Kconfig choice, so the selected option reads CONFIG_ESPTOOLPY_FLASHSIZE_=y. + The last selection wins, as it would when sdkconfig layers the fragment. + """ + size = None + for line in text.splitlines(): + m = re.match(r'\s*CONFIG_ESPTOOLPY_FLASHSIZE_(\d+MB)\s*=\s*y\s*$', line) + if m: + size = m.group(1) + return size + + +def resolve_flash_size(board: str) -> str: + """The board's flash size, layered the way scripts/build.py layers sdkconfig: shared defaults, then the board.""" + size = None + for path in (ROOT / 'sdkconfig.defaults', board_fragment(board)): + if path.is_file(): + size = parse_flash_size(path.read_text(encoding='utf-8')) or size + return size or DEFAULT_FLASH_SIZE + + +def merge_one(board: str, bindir: str, staticfs: str, output: str) -> tuple[str, bool, str]: + """Merge one board, returning its captured output rather than printing it. + + Runs in a worker process: esptool keeps module-level state and exits the + interpreter on failure, so a thread would take the whole run down with it. + Output is captured so parallel boards do not interleave their logs into an + unreadable braid; the caller prints each board's in one piece. + """ + buf = io.StringIO() + try: + with redirect_stdout(buf), redirect_stderr(buf): + chip = resolve_target(board) + flash_size = resolve_flash_size(board) + boot_offset = BOOTLOADER_OFFSET.get(chip) + if boot_offset is None: + raise SystemExit(f'error: no bootloader offset known for chip {chip!r} - add it to merge_image.py') + + table = partition_table.read_partitions(partition_table.partition_csv_for(board)) + if 'static0' not in table: + raise SystemExit(f'error: the partition table for {board} has no static0 partition') + table_offset = hex(partition_table.partition_table_offset(board)) + app_offset = hex(partition_table.first_app_partition(table)['offset']) + staticfs_offset = hex(table['static0']['offset']) + + binpath = Path(bindir) + argv = [ + '--chip', chip, + 'merge-bin', + '--output', output, + '--flash-size', flash_size, + boot_offset, str(binpath / 'bootloader.bin'), + table_offset, str(binpath / 'partition-table.bin'), + app_offset, str(binpath / 'app.bin'), + staticfs_offset, staticfs, + ] + print(f'+ esptool {" ".join(argv)}', flush=True) + esptool.main(argv) + except SystemExit as e: + # esptool exits rather than raising; a zero code is still a success. + # A string code is already the message, which is how resolve_target reports an unknown board. + if e.code not in (0, None): + detail = e.code if isinstance(e.code, str) else f'exited with {e.code}' + return board, False, buf.getvalue() + f'\n{detail}' + except Exception as e: + return board, False, buf.getvalue() + f'\n{type(e).__name__}: {e}' + + return board, True, buf.getvalue() + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument('--board', required=True, action='append', + help='board name matching boards/.defaults. Repeatable, and a single ' + 'value may hold several names separated by whitespace or newlines.') + ap.add_argument('--bindir', required=True, + help='dir with app.bin, bootloader.bin, partition-table.bin. {board} is expanded per board.') + ap.add_argument('--staticfs', required=True, help='path to the static0 image (staticfs.bin)') + ap.add_argument('--output', required=True, help='merged output binary. {board} is expanded per board.') + ap.add_argument('-j', '--jobs', type=int, default=0, + help='boards to merge at once. 0 (the default) uses one per CPU.') + args = ap.parse_args() -def call_script(file): - print('Calling script: %s' % file) - print() - with open(file) as f: - exec(f.read()) - sys.exit(0) + boards = [b for value in args.board for b in value.split()] + if not boards: + raise SystemExit('error: --board matched no board names') + duplicates = {b for b in boards if boards.count(b) > 1} + if duplicates: + raise SystemExit(f'error: repeated board(s): {", ".join(sorted(duplicates))}') -# Get pio env name from CLI args -env_name = sys.argv[1] + # Templates are expanded here so an unknown placeholder fails before any work starts. + try: + jobs = [(b, args.bindir.format(board=b), args.staticfs, args.output.format(board=b)) for b in boards] + except KeyError as e: + raise SystemExit(f'error: unknown placeholder {e} in --bindir or --output; only {{board}} is expanded') -# Read board configuration from platformio.ini -boardconf: BoardConf = from_pio_file(env_name) + for _, _, _, output in jobs: + Path(output).parent.mkdir(parents=True, exist_ok=True) + workers = args.jobs if args.jobs > 0 else min(len(jobs), os.cpu_count() or 1) -def merge_image(): - if not validate(boardconf): - return + if workers == 1 or len(jobs) == 1: + results = [merge_one(*job) for job in jobs] + else: + with ProcessPoolExecutor(max_workers=workers) as pool: + results = list(pool.map(merge_one, *zip(*jobs))) - # If the board exists directly, call `merge-image.py` in that directory. - if not boardconf.does_merge_script_exist(): - print('ERROR: MERGE SCRIPT DOES NOT EXIST: %s' % boardconf.get_merge_script()) - print('FAILED TO FIND merge-image.py') - sys.exit(1) + failed = [] + for board, ok, output in results: + print(f'--- {board} ---', flush=True) + if output: + print(output.rstrip(), flush=True) + if not ok: + failed.append(board) - # Call the chips/{chip}/merge-image.py script - call_script(boardconf.get_merge_script()) + if failed: + print(f'error: failed to merge: {", ".join(failed)}', file=sys.stderr, flush=True) + raise SystemExit(1) -print_header() -merge_image() -print_footer() +if __name__ == '__main__': + main() diff --git a/scripts/use_openshock_params.py b/scripts/use_openshock_params.py deleted file mode 100644 index 75e10ec3..00000000 --- a/scripts/use_openshock_params.py +++ /dev/null @@ -1,57 +0,0 @@ -import os -from pathlib import Path -from utils.boardconf import BoardConf, from_pio_env, validate, print_header, print_footer, fallback_flash_size -import csv - -# -# This file is responsible for processing the "custom_openshock.*" -# parameters declared in `platformio.ini`. -# -# And for updating the `partitions.csv` file to include the -# `config` partition. -# -# This file is invoked by PlatformIO during build. -# See 'extra_scripts' in 'platformio.ini'. -# - -Import('env') # type: ignore -env = env # type: ignore - -# Parse the board/chip specific configuration from the current pio `env` variable. -boardconf: BoardConf = from_pio_env(env) - -# In case we need to blacklist certain flash sizes from OTA support. -blacklisted_OTA_sizes = [ - # '4MB' - ] - -def use_openshock_params(): - if not validate(boardconf): - print('Failed to validate OpenShock board configuration.') - return - - # Handle partitions file not existing - if not boardconf.do_partitions_files_exists(boardconf.get_flash_size()): - if boardconf.do_partitions_files_exists(fallback_flash_size): - print(f'WARNING: PARTITIONS FILES DON\'T EXIST FOR THIS FLASH SIZE, FALLING BACK TO {fallback_flash_size}.') - boardconf.override_flash_size(fallback_flash_size) - - # Set partition file to chip dir. - # https://docs.platformio.org/en/latest/scripting/examples/override_board_configuration.html - board_config = env.BoardConfig() - - partitions, partitions_ota = boardconf.get_partitions_files(boardconf.get_flash_size()) - - if boardconf.get_flash_size() in blacklisted_OTA_sizes: - print('WARNING: OTA NOT SUPPORTED FOR THIS FLASH SIZE.') - print('Using Non-OTA partition layout.') - board_config.update('build.partitions', str(partitions)) - board_config.update('upload.flash_size', boardconf.get_flash_size()) - else: - board_config.update('build.partitions', str(partitions_ota)) - board_config.update('upload.flash_size', boardconf.get_flash_size()) - - -print_header() -use_openshock_params() -print_footer() diff --git a/scripts/utils/boardconf.py b/scripts/utils/boardconf.py deleted file mode 100644 index 6fe35397..00000000 --- a/scripts/utils/boardconf.py +++ /dev/null @@ -1,148 +0,0 @@ -import os -from pathlib import Path -from configparser import ConfigParser - -fallback_flash_size = '4MB' - -class BoardConf: - def __init__(self, chip: str, flash_size: str): - self.chip = chip - self.flash_size = flash_size - if flash_size == '': - self.flash_size = fallback_flash_size - - # Set dirs - self.root_dir = Path().absolute() - self.chips_dir = self.root_dir / 'chips' - self.chip_base_dir = self.chips_dir / self.chip - self.flash_size_dir = ( - (self.chip_base_dir / self.flash_size) if self.flash_size != '' else self.chip_base_dir - ) - - def get_chip(self) -> str: - return self.chip - - def get_flash_size(self) -> str: - return self.flash_size - - def get_chips_dir(self) -> Path: - return self.chips_dir - - def get_chip_dir(self) -> Path: - return self.chip_base_dir - - def get_flash_size_dir(self) -> Path: - return self.flash_size_dir - - def override_flash_size(self, flash_size: str): - self.flash_size = flash_size - self.flash_size_dir = self.chip_base_dir / self.flash_size - - def get_merge_script(self) -> Path: - return self.flash_size_dir / 'merge-image.py' - - def get_partitions_files(self, size: str) -> tuple[Path, Path]: - return self.chips_dir / f'partitions_{size}.csv', self.chips_dir / f'partitions_{size}_OTA.csv', - - def is_chip_specified(self) -> bool: - return self.chip != '' - - def is_flash_size_specified(self) -> bool: - return self.flash_size != '' - - # Whether the chip folder exists. - def does_chip_exist(self) -> bool: - return os.path.exists(self.get_chip_dir()) - - # Whether the flash size folder exists. - def does_flash_size_exist(self, size: str = "") -> bool: - if size == "": - return os.path.exists(self.get_flash_size_dir()) - else: - return os.path.exists(self.chip_base_dir / size) - - # Whether the merge script for the current flash size exists. - def does_merge_script_exist(self) -> bool: - return os.path.exists(self.get_merge_script()) - - # Whether the partitions file for the current flash size exists. - def do_partitions_files_exists(self, size: str) -> bool: - partitions, partitions_ota = self.get_partitions_files(size) - return os.path.exists(partitions) and os.path.exists(partitions_ota) - - -def from_pio_file(env_name: str) -> BoardConf: - config = ConfigParser() - config.read('platformio.ini') - - # Grab env - env = config['env:' + env_name] - - # Grab strings from platformio.ini env declaration - # TODO: Simplify platformio.ini to only have one env (Flash Size) - # Need to either pass in or figure out chip - chip = env.get('custom_openshock.chip', '') - flash_size = env.get('custom_openshock.flash_size', '') - - return BoardConf(chip, flash_size) - - -def from_pio_env(env): - # Grab strings from current pio env - # TODO: Simplify platformio.ini to only have one env (Flash Size) - # env.BoardConfig().get('build.mcu') - # can be used to get chip - chip = env.GetProjectOption('custom_openshock.chip', '') - flash_size = env.GetProjectOption('custom_openshock.flash_size', '') - - return BoardConf(chip, flash_size) - - -def print_header(): - print('========================') - print(' OpenShock ') - print('========================') - print('') - - -def print_footer(): - print('========================') - print('') - - -def validate(boardconf) -> bool: - # Handle missing chip declaration. - if not boardconf.is_chip_specified(): - print('No chip specified.') - print('Skipping further chip/flash size-specific initialization.') - return False - - # Print info about determined chip/flash size. - print('Chip Variant: %s' % boardconf.get_chip()) - print('Flash Size: %s' % boardconf.get_flash_size()) - print('') - - # Handle chip not existing. - if not boardconf.does_chip_exist(): - print('Chip directory does not exist: %s' % boardconf.get_chip_dir()) - print('Did you make a typo?') - return False - - # Handle specified flash size not existing. - if not boardconf.does_flash_size_exist(): - print('Flash size directory does not exist: %s' % boardconf.get_flash_size_dir()) - # Attempt to try fallback flash size. - if boardconf.does_flash_size_exist(fallback_flash_size): - print(f'Falling back to {fallback_flash_size} flash size.') - boardconf.override_flash_size(fallback_flash_size) - else: - print(f'Failed to fallback to {fallback_flash_size} flash size.') - print('Did you make a typo?') - return False - - # Print expected location of merge script and partitions file - print('Determined merge script: %s' % boardconf.get_merge_script()) - print('Potential partition files: %s' % str(boardconf.get_partitions_files(boardconf.get_flash_size()))) - print('') - - return True diff --git a/scripts/utils/dotenv.py b/scripts/utils/dotenv.py deleted file mode 100644 index 6f0ed63e..00000000 --- a/scripts/utils/dotenv.py +++ /dev/null @@ -1,141 +0,0 @@ -from pathlib import Path -from typing import Mapping - -LOGLEVEL_MAP: dict[str, tuple[int, str]] = { - 'none': (0, 'LOG_NONE'), - 'log_none': (0, 'LOG_NONE'), - 'error': (1, 'LOG_ERROR'), - 'log_error': (1, 'LOG_ERROR'), - 'warn': (2, 'LOG_WARN'), - 'log_warn': (2, 'LOG_WARN'), - 'warning': (2, 'LOG_WARN'), - 'log_warning': (2, 'LOG_WARN'), - 'info': (3, 'LOG_INFO'), - 'log_info': (3, 'LOG_INFO'), - 'debug': (4, 'LOG_DEBUG'), - 'log_debug': (4, 'LOG_DEBUG'), - 'verbose': (5, 'LOG_VERBOSE'), - 'log_verbose': (5, 'LOG_VERBOSE'), -} - - -def read_text_with_fallback( - path: str | Path, - encodings: list[str] | tuple[str, ...] | None = None, -) -> str: - """ - Read a text file using multiple attempted encodings in order. - - Handles BOM automatically via utf-8-sig and utf-16 encodings. - Raises a clean, descriptive error if all encodings fail. - """ - - if encodings is None: - # You can reorder these depending on what you expect most commonly. - encodings = [ - 'utf-8-sig', # handles UTF-8 BOM automatically - 'utf-16', # auto-detects LE/BE with BOM - 'utf-16-le', - 'utf-16-be', - 'latin-1', # fallback that never fails (for decoding) - ] - - path = Path(path) - raw = path.read_bytes() - - last_error: UnicodeError | None = None - - for encoding in encodings: - try: - text = raw.decode(encoding) - return text - except UnicodeError as e: - last_error = e - continue - - # If we reach here, all decoding attempts failed (only possible if latin-1 is not in encodings). - raise UnicodeDecodeError( - 'multi-encoding-reader', - raw, - 0, - len(raw), - f"failed to decode file '{path}' using encodings: {', '.join(encodings)}", - ) from last_error - - -class DotEnv: - def __read_dotenv(self, path: str | Path): - text_data = read_text_with_fallback(path) - - for line in text_data.splitlines(): - line = line.strip() - - # Skip empty lines and comments - if not line or line.startswith('#'): - continue - - # Ignore lines that don't contain '=' instead of raising - if '=' not in line: - continue - - key, value = line.split('=', 1) - key = key.strip() - value = value.strip() - - # Skip lines with empty keys - if not key: - continue - # Strip optional surrounding quotes (must match) - if len(value) >= 2: - if (value[0] == '"' and value[-1] == '"') or (value[0] == "'" and value[-1] == "'"): - value = value[1:-1] - - self.dotenv_vars[key] = value - - def __init__(self, path: str | Path, environment: str): - self.dotenv_vars: dict[str, str] = {} - - if isinstance(path, str): - path = Path(path) - - # Read .env file(s) into environment variables, start from the root of the drive and work our way down. - paths = list(path.parents[::-1]) + [path] - - # Get the environment specific name. (e.g. .env.develop / .env.production / .env.release) - env_specific_name = '.env.' + environment - - # Read the .env files. - for base in paths: - env_file = base / '.env' - if env_file.exists(): - self.__read_dotenv(env_file) - - env_file = base / env_specific_name - if env_file.exists(): - self.__read_dotenv(env_file) - - env_file = base / '.env.local' - if env_file.exists(): - self.__read_dotenv(env_file) - - def get_string(self, key: str) -> str | None: - return self.dotenv_vars.get(key) - - def get_all_prefixed(self, prefix: str) -> Mapping[str, str]: - return {k: v for k, v in self.dotenv_vars.items() if k.startswith(prefix)} - - def get_loglevel(self, key: str) -> int | None: - value = self.get_string(key) - if value is None: - return None - - normalized = value.strip().lower() - tup = LOGLEVEL_MAP.get(normalized) - if tup is None: - raise ValueError(f'Environment variable {key} ({value}) is not a valid log level.') - - return tup[0] - - -def read(workdir: str | Path, environment_name: str) -> DotEnv: - return DotEnv(workdir, environment=environment_name) diff --git a/scripts/utils/partitions.py b/scripts/utils/partitions.py new file mode 100644 index 00000000..d787f342 --- /dev/null +++ b/scripts/utils/partitions.py @@ -0,0 +1,70 @@ +"""Read the ESP-IDF partition table CSV a board builds with, so tools don't hard-code offsets and sizes.""" + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent.parent + +# ESP-IDF's default CONFIG_PARTITION_TABLE_OFFSET. +DEFAULT_PARTITION_TABLE_OFFSET = 0x8000 + + +def _sdkconfig_value(board: str | None, key: str) -> str | None: + """Last value of `key` in sdkconfig.defaults, then the board fragment (the order scripts/build.py layers them).""" + paths = [ROOT / 'sdkconfig.defaults'] + if board is not None: + paths.append(ROOT / 'boards' / f'{board}.defaults') + + value = None + for path in paths: + if not path.is_file(): + continue + for line in path.read_text(encoding='utf-8').splitlines(): + m = re.match(rf'\s*{re.escape(key)}\s*=\s*"?([^"]*)"?\s*$', line) + if m: + value = m.group(1) + return value + + +def partition_csv_for(board: str | None) -> Path: + name = _sdkconfig_value(board, 'CONFIG_PARTITION_TABLE_CUSTOM_FILENAME') + if not name: + raise SystemExit(f'error: no CONFIG_PARTITION_TABLE_CUSTOM_FILENAME for board {board!r}') + return ROOT / name + + +def partition_table_offset(board: str | None) -> int: + value = _sdkconfig_value(board, 'CONFIG_PARTITION_TABLE_OFFSET') + return int(value, 0) if value else DEFAULT_PARTITION_TABLE_OFFSET + + +def read_partitions(csv_path: Path) -> dict[str, dict]: + """Partitions by name: {'type', 'subtype', 'offset', 'size'}. Every entry must give an explicit offset.""" + partitions = {} + for raw in csv_path.read_text(encoding='utf-8').splitlines(): + line = raw.split('#', 1)[0].strip() + if not line: + continue + fields = [f.strip() for f in line.split(',')] + if len(fields) < 5: + raise SystemExit(f'error: malformed partition line in {csv_path}: {raw!r}') + name, ptype, subtype, offset, size = fields[:5] + if not offset: + raise SystemExit(f'error: partition {name!r} in {csv_path} has no explicit offset') + partitions[name] = {'type': ptype, 'subtype': subtype, 'offset': int(offset, 0), 'size': _parse_size(size)} + return partitions + + +def _parse_size(size: str) -> int: + m = re.fullmatch(r'(0x[0-9a-fA-F]+|\d+)([KM]?)', size) + if not m: + raise SystemExit(f'error: unsupported partition size {size!r}') + value = int(m.group(1), 0) + return value * {'': 1, 'K': 1024, 'M': 1024 * 1024}[m.group(2)] + + +def first_app_partition(partitions: dict[str, dict]) -> dict: + apps = [p for p in partitions.values() if p['type'] == 'app'] + if not apps: + raise SystemExit('error: partition table has no app partition') + return min(apps, key=lambda p: p['offset']) diff --git a/scripts/utils/pioenv.py b/scripts/utils/pioenv.py deleted file mode 100644 index 2bc15ad3..00000000 --- a/scripts/utils/pioenv.py +++ /dev/null @@ -1,52 +0,0 @@ -from . import conv - -# def __parse_pio_file(): -# leftover_flags = [] -# for flag in build_flags: -# if flag.startswith('-D'): -# flag = flag[2::] -# if '=' in flag: -# (k, v) = flag.split('=', 1) - -# cpp_defines[k] = v -# else: -# cpp_defines[flag] = True -# else: -# leftover_flags.append(flag) - - -class PioEnv: - def __init__(self, env: dict): - self.env = env - - def get(self, key: str): - return self.env[key] # type: ignore - - def set(self, key: str, value): - self.env[key] = value # type: ignore - - def get_bool(self, key: str, default: bool | None = None) -> bool: - try: - return conv.to_bool(self.get(key)) - except Exception as ex: - if default != None: - return default - raise ValueError('Failed to get pio config boolean: %s' % key) - - # Get a string from the pio env, falling back to the default if provided. - def get_string(self, key: str, default: str | None = None) -> str: - try: - return conv.to_string(self.get(key)) - except Exception as ex: - if default != None: - return default - raise ValueError('Failed to get pio string: %s' % key) from ex - - # Get a string array from the pio env. - def get_string_array(self, key: str, default: list[str] | None = None) -> list[str]: - strings = self.get(key) or default - if strings == None or not isinstance(strings, list): - raise ValueError('Failed to get pio string array: %s' % key) - if any([not isinstance(v, str) for v in strings]): - raise ValueError('Failed to get pio string array (found non-string values): %s' % key) - return strings diff --git a/sdkconfig.defaults b/sdkconfig.defaults new file mode 100644 index 00000000..f9dabe28 --- /dev/null +++ b/sdkconfig.defaults @@ -0,0 +1,86 @@ +# Tick rate. Originally raised for Arduino-as-IDF-component; kept because the existing +# timing code assumes a 1 ms tick. +CONFIG_FREERTOS_HZ=1000 + +# Keep the main task large enough for the init chain. +CONFIG_ESP_MAIN_TASK_STACK_SIZE=16384 + +# WiFi/IP event handlers (WiFiManager, GatewayConnectionManager) now run directly +# on the default event loop task instead of Arduino's separate 4096-byte event +# task. They do flatbuffer serialization + websocket broadcast, so restore that +# headroom here (default is 2304). +CONFIG_ESP_SYSTEM_EVENT_TASK_STACK_SIZE=4096 + +# Arduino expects these selected via ENABLE_ARDUINO_DEPENDS, but be explicit +CONFIG_LWIP_SO_RCVBUF=y + +# Let application use exceptions-free C++ (matches build_flags -fno-exceptions) +CONFIG_COMPILER_CXX_EXCEPTIONS=n + +# Arduino NetworkClientSecure (pulled in transitively by WebSockets/WiFiClientSecure) +# refuses to compile its ssl_client.cpp body unless at least one PSK exchange is +# enabled — otherwise all ssl_* symbols become unresolved at link time. We don't +# actually use PSK, but we need MBEDTLS_KEY_EXCHANGE_SOME_PSK_ENABLED to be defined. +CONFIG_MBEDTLS_PSK_MODES=y +CONFIG_MBEDTLS_KEY_EXCHANGE_PSK=y + +# Captive portal serves HTTP + WebSocket from a single esp_http_server. WebSocket +# support is compiled out of esp_http_server by default; enable it or the httpd_ws_* +# symbols don't exist. +CONFIG_HTTPD_WS_SUPPORT=y +# The URI handler is not called for the handshake itself, so greeting a new client (the Ready message with the config +# and valid GPIO pins) needs the post-handshake callback. +CONFIG_HTTPD_WS_POST_HANDSHAKE_CB_SUPPORT=y + +# TLS server-cert verification uses the mbedTLS certificate bundle. We compile in +# our own curated trust store (certificates/cacert-merged.pem) instead of IDF's built-in +# Mozilla list, so DEFAULT_NONE + a custom bundle. esp_http_client and +# esp_websocket_client attach it via config.crt_bundle_attach = esp_crt_bundle_attach. +# +# cacert-merged.pem is generated by certificates/cert_bundle.py: the verbatim curl/Mozilla +# base (certificates/cacert-curl.pem, checksum-verified on download) followed by +# certificates/pinned_certs/*.pem. Pinned roots cover anchors the Mozilla base has retired +# but our infra still chains through (e.g. GlobalSign Root CA R1, which cross-signs GTS +# Root R4). `cert_bundle.py audit` (the pinned-cert-audit CI workflow) monitors their +# expiry and relevance. +CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y +CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_NONE=y +CONFIG_MBEDTLS_CUSTOM_CERTIFICATE_BUNDLE=y +CONFIG_MBEDTLS_CUSTOM_CERTIFICATE_BUNDLE_PATH="certificates/cacert-merged.pem" + +# Partition table — OpenShock uses a custom one; existing partitions stay valid +# (leave PARTITION_TABLE_* at IDF defaults; per-env sdkconfigs can override) + +# The CONFIG_ARDUINO_SELECTIVE_* block that used to live here is gone. Arduino is no +# longer a component of this project - it is in neither dependencies.lock nor any +# idf_component.yml - so kconfgen reported every one of those symbols as unknown and +# discarded it. They were fourteen lines that did nothing. +# +# Still to review as part of finishing the de-Arduino migration: CONFIG_MBEDTLS_PSK_MODES +# and CONFIG_MBEDTLS_KEY_EXCHANGE_PSK above were only needed to make Arduino's +# NetworkClientSecure link, and CONFIG_LWIP_SO_RCVBUF was an Arduino dependency. All +# three are real settings that still take effect, so they are left alone here rather +# than changed as a side effect of a build-caching pass. + +# Boot a freshly flashed OTA image as PENDING_VERIFY so otaSetup() validates it and a +# broken image rolls back to the previous slot instead of boot-looping. Needs the +# bootloader built with this option; devices that only receive OTA app updates keep +# their old bootloader and boot new images without the verify step. +CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y + +# Parse JSON as RFC 8259 only. In lenient mode jsmn accepts input like {a:1} or {"x":{"a"},"b":1} and the +# navigation code can then return the wrong value for a key. +CONFIG_JSMN_STRICT=y + +# Keep crash dumps in the (otherwise unused) coredump partition so field crashes can be read back over serial. +CONFIG_ESP_COREDUMP_ENABLE_TO_FLASH=y + +# Captive portal httpd uses max_open_sockets (8) + 3 internal sockets, plus DNS server, HTTP client and gateway +# websocket sockets; the default of 10 would make httpd_start fail. +CONFIG_LWIP_MAX_SOCKETS=16 + +# The gateway websocket and the kept-alive HTTP client each hold a TLS session. With static +# buffers every session keeps a 16 KB input + 4 KB output record buffer for its whole life; +# dynamic buffers allocate them per record and shrink while idle, lowering the heap peak +# (e.g. while the captive portal links an account). Verify heap and throughput on a device. +CONFIG_MBEDTLS_DYNAMIC_BUFFER=y diff --git a/src/CompatibilityChecks.cpp b/src/CompatibilityChecks.cpp deleted file mode 100644 index 4e77a35b..00000000 --- a/src/CompatibilityChecks.cpp +++ /dev/null @@ -1,16 +0,0 @@ -#include "Chipset.h" -#include "Common.h" - -const bool kIsValidOrUndefinedRfTxPin = OpenShock::IsValidOutputPin(OPENSHOCK_RF_TX_GPIO) || OPENSHOCK_RF_TX_GPIO == OPENSHOCK_GPIO_INVALID; -static_assert(kIsValidOrUndefinedRfTxPin, "OPENSHOCK_RF_TX_GPIO is not a valid output GPIO, and is not declared as bypassed by board specific definitions, refusing to compile"); - -const bool kIsValidOrUndefinedEStopPin = OpenShock::IsValidInputPin(OPENSHOCK_ESTOP_PIN) || OPENSHOCK_ESTOP_PIN == OPENSHOCK_GPIO_INVALID; -static_assert(kIsValidOrUndefinedEStopPin, "OPENSHOCK_ESTOP_PIN is not a valid input GPIO, and is not declared as bypassed by board specific definitions, refusing to compile"); - -#ifdef OPENSHOCK_LED_GPIO -static_assert(OpenShock::IsValidOutputPin(OPENSHOCK_LED_GPIO), "OPENSHOCK_LED_GPIO is not a valid output GPIO, and is not declared as bypassed by board specific definitions, refusing to compile"); -#endif - -#ifdef OPENSHOCK_LED_WS2812B -static_assert(OpenShock::IsValidOutputPin(OPENSHOCK_LED_WS2812B), "OPENSHOCK_LED_WS2812B is not a valid output GPIO, and is not declared as bypassed by board specific definitions, refusing to compile"); -#endif diff --git a/src/EStopManager.cpp b/src/EStopManager.cpp deleted file mode 100644 index 4c1d79d2..00000000 --- a/src/EStopManager.cpp +++ /dev/null @@ -1,383 +0,0 @@ -#include - -#include "estop/EStopManager.h" - -const char* const TAG = "EStopManager"; - -#include "Chipset.h" -#include "config/Config.h" -#include "Core.h" -#include "events/Events.h" -#include "Logging.h" -#include "SimpleMutex.h" -#include "util/TaskUtils.h" - -#include -#include -#include -#include - -#include -#include - -using namespace OpenShock; - -const uint32_t k_estopHoldToClearTime = 5000; -const uint32_t k_estopUpdateRate = 5; // 200 Hz -const uint32_t k_estopCheckCount = 13; // 65 ms at 200 Hz -const uint16_t k_estopCheckMask = 0xFFFF >> ((sizeof(uint16_t) * 8) - k_estopCheckCount); // Mask to check only last k_estopCheckCount bits within history - -// Grace period after deactivation (prevents immediate re-trigger on release bounce/EMI) -const uint32_t k_estopRearmGraceTime = 250; // tune as needed - -const uint32_t k_estopTaskStackSize = 4096; // TODO: profile and tune -const UBaseType_t k_estopTaskPriority = 5; - -static OpenShock::SimpleMutex s_estopMutex = {}; -// Guarded via Mutex -static TaskHandle_t s_estopTask = nullptr; -static gpio_num_t s_estopPin = GPIO_NUM_NC; // Passed to task via pointer argument - -// Wrapped in atomics as they're read (or set via public methods) by Tasks potentially running on other cores. -static std::atomic s_estopActivatedAt = 0; // When == 0, EStop not active. When != 0, EStop is active. -static std::atomic s_externallyTriggered = false; -static std::atomic s_killEStopManagerRequested = false; - -static bool s_estopInitialized = false; - -static void estopmgr_publishState(EStopState state, EStopState& lastState, TickType_t timeout = pdMS_TO_TICKS(750)) -{ - if (state == lastState) { - return; // No state change -> no event - } - - // Post the current state as the event payload - esp_err_t err = esp_event_post(OPENSHOCK_EVENTS, OPENSHOCK_EVENT_ESTOP_STATE_CHANGED, &state, sizeof(state), timeout); - - if (err == ESP_OK) { - lastState = state; - } else { - OS_LOGE(TAG, "Failed to publish EStop event: %s", esp_err_to_name(err)); - } -} - -// Samples the estop at a fixed rate and updates internal state + events -static void estopmgr_managerTask(void* pvParameters) -{ - // Pin is being passed as a pointer, cast it back to gpio number type to get pin value - gpio_num_t estopPin = static_cast(reinterpret_cast(pvParameters)); - - // Ensure known initial state - s_estopActivatedAt.store(0, std::memory_order_relaxed); - - EStopState state = EStopState::Idle; - EStopState lastPublishedState = EStopState::Idle; - - uint16_t history = 0xFFFF; // Bit history of samples, 0 is pressed - - int64_t deactivatesAt = 0; - - // Rearm grace state - int64_t rearmAt = 0; - bool rearmBlocked = false; - - // Debounced button state: true == pressed, false == released - bool lastBtnState = false; - - // Check if killing manager was requested, continue looping otherwise - while (!s_killEStopManagerRequested.load(std::memory_order_relaxed)) { - // Sleep for the update rate - vTaskDelay(pdMS_TO_TICKS(k_estopUpdateRate)); - - // Get current time - int64_t now = OpenShock::millis(); - - // Handle external trigger: forcibly set the E-Stop active. - if (s_externallyTriggered.exchange(false, std::memory_order_relaxed)) { - int64_t zero = 0; - s_estopActivatedAt.compare_exchange_strong(zero, now, std::memory_order_relaxed); - - state = EStopState::Active; - rearmBlocked = false; - - estopmgr_publishState(state, lastPublishedState); - - // Do not modify history/lastBtnState here; rely on physical button state - // on subsequent iterations. - continue; - } - - // Sample the EStop input - history = static_cast((history << 1) | gpio_get_level(estopPin)); - - // Debounce: - // If all recent bits are 1 -> fully released. - // If any bit is 0 -> pressed (or bouncing toward pressed). - bool btnState = (history & k_estopCheckMask) != k_estopCheckMask; // true == pressed - bool pressedEdge = (btnState && !lastBtnState); - lastBtnState = btnState; - - switch (state) { - case EStopState::Idle: - // Rearm grace: after clearing, ignore presses for a short window. - // After the window ends, require a released state before re-arming. - if (rearmBlocked) { - if (now < rearmAt) { - // Still in grace window: ignore any press. Track input to avoid phantom edges later. - break; - } - - // Grace window ended: only re-arm once we see released. - rearmBlocked = false; - } - - if (btnState) { - state = EStopState::Active; - s_estopActivatedAt.store(now, std::memory_order_relaxed); - } - break; - - case EStopState::Active: - // Once active, if the input gets pressed, start hold-to-clear timing. - if (pressedEdge) { - state = EStopState::ActiveClearing; - deactivatesAt = now + k_estopHoldToClearTime; - } - break; - - case EStopState::ActiveClearing: - if (!btnState) { // released before hold time -> go back to Active - state = EStopState::Active; - } else if (now >= deactivatesAt) { - // Hold complete -> now wait for release edge to fully clear - state = EStopState::AwaitingRelease; - } - break; - - case EStopState::AwaitingRelease: - if (!btnState) { // fully released -> clear E-Stop - state = EStopState::Idle; - s_estopActivatedAt.store(0, std::memory_order_relaxed); - - // Start grace period to prevent immediate re-trigger. - rearmBlocked = true; - rearmAt = now + k_estopRearmGraceTime; - } - break; - - default: - // Should never happen - break; - } - - estopmgr_publishState(state, lastPublishedState); - } - - // Broke out of main loop, set global variables to Idle state. - // Use a blocking publish so downstream consumers (keep-alive gating, visuals) - // are guaranteed to observe the Idle transition even under event-loop pressure. - estopmgr_publishState(EStopState::Idle, lastPublishedState, portMAX_DELAY); - s_estopActivatedAt.store(0, std::memory_order_relaxed); - - vTaskDelete(nullptr); -} - -// Validates and configures `pin` as an EStop input. Does not touch s_estopPin -// or any other pin; caller owns the s_estopPin assignment and old-pin release. -static bool estopmgr_configurePin(gpio_num_t pin) -{ - if (!OpenShock::IsValidInputPin(pin)) { - OS_LOGE(TAG, "Invalid EStop pin: %hhi", static_cast(pin)); - return false; - } - - // Configure the new pin - gpio_config_t io_conf = { - .pin_bit_mask = 1ULL << pin, - .mode = GPIO_MODE_INPUT, - .pull_up_en = GPIO_PULLUP_ENABLE, - .pull_down_en = GPIO_PULLDOWN_DISABLE, - .intr_type = GPIO_INTR_DISABLE, - }; - - esp_err_t err = gpio_config(&io_conf); - if (err != ESP_OK) { - OS_LOGE(TAG, "Failed to configure EStop pin: %s", esp_err_to_name(err)); - return false; - } - - return true; -} - -static void estopmgr_releasePin(gpio_num_t pin) -{ - if (pin == GPIO_NUM_NC) { - return; - } - - esp_err_t err = gpio_reset_pin(pin); - if (err != ESP_OK) { - OS_LOGE(TAG, "Failed to reset old EStop pin: %s", esp_err_to_name(err)); - } -} - -static bool estopmgr_taskStart() -{ - if (s_estopTask != nullptr) { - OS_LOGW(TAG, "Tried to enable EStop manager, but was already running"); - return true; - } - - if (s_estopPin == GPIO_NUM_NC) { - gpio_num_t pin = GPIO_NUM_NC; - if (!OpenShock::Config::GetEStopGpioPin(pin)) { - OS_LOGE(TAG, "Failed to get EStop pin from config"); - return false; - } - - if (pin == GPIO_NUM_NC) { - OS_LOGW(TAG, "No valid pin is defined, refusing to start task"); - return false; - } - - if (!estopmgr_configurePin(pin)) { - return false; - } - - s_estopPin = pin; - } - - s_killEStopManagerRequested.store(false, std::memory_order_relaxed); - - // Tiny hack; - // We are passing pin as a pointer, the pointer memory address being the value of the pin. - // - // A pointer after all is just a integer with a special meaning, arg pointer isn't being used for anything so we can use it for this. - // - // This also proves to be safer than using atomics for this since we know for sure that the pin we intended the task to run with - // will not change between creating the task and it freezing its local copy of the value. - // - // This enables us to use no allocations or atomic operations - static_assert(sizeof(void*) >= sizeof(gpio_num_t), "void* is smaller than gpio_num_t, value embedding trick won't work"); // Just to be safe - void* argPtr = reinterpret_cast(static_cast(s_estopPin)); - - if (TaskUtils::TaskCreateUniversal(estopmgr_managerTask, TAG, k_estopTaskStackSize, argPtr, k_estopTaskPriority, &s_estopTask, 1) != pdPASS) { - OS_LOGE(TAG, "Failed to create EStop event handler task"); - s_estopTask = nullptr; - return false; - } - - return true; -} - -static bool estopmgr_taskStop() -{ - if (s_estopTask == nullptr) { - OS_LOGW(TAG, "Tried to kill EStop manager, but was not running"); - return true; - } - - s_killEStopManagerRequested.store(true, std::memory_order_relaxed); - - TaskUtils::StopTask(s_estopTask, TAG, "EStop task"); - s_estopTask = nullptr; - - // Disable E-Stop after task has stopped to ensure that the task didn't get it stuck in enabled state - s_estopActivatedAt.store(0, std::memory_order_relaxed); - - return true; -} - -bool EStopManager::Init() -{ - if (s_estopInitialized) { - return true; - } - s_estopInitialized = true; - - Config::EStopConfig cfg; - if (!OpenShock::Config::GetEStop(cfg)) { - OS_LOGE(TAG, "Failed to get EStop pin from config"); - return false; - } - - if (!cfg.enabled) { - return true; - } - - OpenShock::ScopedLock lock__(&s_estopMutex); - - if (!estopmgr_configurePin(cfg.gpioPin)) { - return false; - } - - s_estopPin = cfg.gpioPin; - - return estopmgr_taskStart(); -} - -bool EStopManager::SetEStopEnabled(bool enabled) -{ - OpenShock::ScopedLock lock__(&s_estopMutex); - - if (enabled) { - return estopmgr_taskStart(); - } - - return estopmgr_taskStop(); -} - -bool EStopManager::SetEStopPin(gpio_num_t pin) -{ - OpenShock::ScopedLock lock__(&s_estopMutex); - - if (s_estopPin == pin) { - return true; - } - - // Configure the new pin before touching anything else. If this fails the - // running task keeps sampling the old pin and the manager stays healthy. - if (!estopmgr_configurePin(pin)) { - return false; - } - - gpio_num_t oldPin = s_estopPin; - bool wasRunning = s_estopTask != nullptr; - - // Stop the task before swapping s_estopPin so the global can't disagree - // with what the task is actually reading. - if (wasRunning && !estopmgr_taskStop()) { - // Roll back the configuration we just did; old pin and task are untouched. - estopmgr_releasePin(pin); - return false; - } - - s_estopPin = pin; - - if (wasRunning && !estopmgr_taskStart()) { - // Task is gone and we can't bring it back. The old pin is no longer being - // sampled so we release it, but the manager is left inactive. - estopmgr_releasePin(oldPin); - return false; - } - - estopmgr_releasePin(oldPin); - - return true; -} - -bool EStopManager::IsEStopped() -{ - return EStopManager::LastEStopped() != 0; -} - -int64_t EStopManager::LastEStopped() -{ - return s_estopActivatedAt.load(std::memory_order_relaxed); -} - -void EStopManager::SoftwareTrigger() -{ - // This will be picked up by the checker task and lead to an E-Stop activation - s_externallyTriggered.store(true, std::memory_order_relaxed); -} diff --git a/src/GatewayClient.cpp b/src/GatewayClient.cpp deleted file mode 100644 index 4eca8c26..00000000 --- a/src/GatewayClient.cpp +++ /dev/null @@ -1,210 +0,0 @@ -#include "GatewayClient.h" - -const char* const TAG = "GatewayClient"; - -#include "Common.h" -#include "config/Config.h" -#include "Core.h" -#include "events/Events.h" -#include "Logging.h" -#include "message_handlers/WebSocket.h" -#include "OtaUpdateManager.h" -#include "serialization/WSGateway.h" -#include "visual/VisualStateManager.h" - -using namespace OpenShock; - -const int64_t GATEWAY_PING_TIMEOUT = 90'000; - -static bool s_bootStatusSent = false; - -GatewayClient::GatewayClient(const std::string& authToken) - : m_webSocket() - , m_state(GatewayClientState::Disconnected) - , m_lastPingTimestamp(0) -{ - OS_LOGD(TAG, "Creating GatewayClient"); - - std::string headers = "Firmware-Version: " OPENSHOCK_FW_VERSION "\r\n" - "Device-Token: " - + authToken; - - m_webSocket.setUserAgent(OpenShock::Constants::FW_USERAGENT); - m_webSocket.setExtraHeaders(headers.c_str()); - m_webSocket.onEvent(std::bind(&GatewayClient::_handleEvent, this, std::placeholders::_1, std::placeholders::_2, std::placeholders::_3)); -} -GatewayClient::~GatewayClient() -{ - _setState(GatewayClientState::Disconnected); - - OS_LOGD(TAG, "Destroying GatewayClient"); - m_webSocket.disconnect(); -} - -void GatewayClient::connect(const std::string& host, uint16_t port, const std::string& path) -{ - if (m_state != GatewayClientState::Disconnected) { - return; - } - - _setState(GatewayClientState::Connecting); - -// -// ###### ######## ###### ## ## ######## #### ######## ## ## ######## #### ###### ## ## -// ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## -// ## ## ## ## ## ## ## ## ## #### ## ## ## ## ## ## -// ###### ###### ## ## ## ######## ## ## ## ######## ## ###### ##### -// ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## -// ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## -// ###### ######## ###### ####### ## ## #### ## ## ## ## #### ###### ## ## -// -// TODO: Implement certificate verification -// -#warning SSL certificate verification is currently not implemented, by RFC definition this is a security risk, and allows for MITM attacks, but the realistic risk is low - - m_webSocket.beginSSL(host.c_str(), port, path.c_str()); - OS_LOGW(TAG, "WEBSOCKET CONNECTION BY RFC DEFINITION IS INSECURE, remote endpoint can not be verified due to lack of CA verification support, theoretically this is a security risk and allows for MITM attacks, but the realistic risk is low"); -} - -void GatewayClient::disconnect() -{ - if (m_state != GatewayClientState::Connected) { - return; - } - _setState(GatewayClientState::Disconnecting); - m_webSocket.disconnect(); -} - -bool GatewayClient::sendMessageTXT(std::string_view data) -{ - if (m_state != GatewayClientState::Connected) { - return false; - } - - return m_webSocket.sendTXT(data.data(), data.length()); -} - -bool GatewayClient::sendMessageBIN(tcb::span data) -{ - if (m_state != GatewayClientState::Connected) { - return false; - } - - return m_webSocket.sendBIN(data.data(), data.size()); -} - -void GatewayClient::markPingReceived() -{ - m_lastPingTimestamp = OpenShock::millis(); -} - -bool GatewayClient::loop() -{ - if (m_state == GatewayClientState::Disconnected) { - return false; - } - - m_webSocket.loop(); - - // We are still in the process of connecting or disconnecting - if (m_state != GatewayClientState::Connected) { - // return true to indicate that we are still busy - return true; - } - - if (m_lastPingTimestamp != 0 && (OpenShock::millis() - m_lastPingTimestamp) > GATEWAY_PING_TIMEOUT) { - OS_LOGW(TAG, "No ping received from gateway for %lld ms, forcing reconnect", GATEWAY_PING_TIMEOUT); - m_webSocket.disconnect(); - _setState(GatewayClientState::Disconnected); - return false; - } - - return true; -} - -void GatewayClient::_setState(GatewayClientState state) -{ - if (m_state == state) { - return; - } - - m_state = state; - - ESP_ERROR_CHECK(esp_event_post(OPENSHOCK_EVENTS, OPENSHOCK_EVENT_GATEWAY_CLIENT_STATE_CHANGED, &m_state, sizeof(m_state), portMAX_DELAY)); -} - -void GatewayClient::_sendBootStatus() -{ - if (s_bootStatusSent) return; - - OS_LOGV(TAG, "Sending Gateway boot status message"); - - int32_t updateId; - if (!Config::GetOtaUpdateId(updateId)) { - OS_LOGE(TAG, "Failed to get OTA update ID"); - return; - } - - OpenShock::OtaUpdateStep updateStep; - if (!Config::GetOtaUpdateStep(updateStep)) { - OS_LOGE(TAG, "Failed to get OTA firmware boot type"); - return; - } - - using namespace std::string_view_literals; - - OpenShock::SemVer version; - if (!OpenShock::TryParseSemVer(OPENSHOCK_FW_VERSION ""sv, version)) { - OS_LOGE(TAG, "Failed to parse firmware version"); - return; - } - - s_bootStatusSent = Serialization::Gateway::SerializeBootStatusMessage(updateId, OtaUpdateManager::GetFirmwareBootType(), [this](tcb::span data) { return m_webSocket.sendBIN(data.data(), data.size()); }); - - if (s_bootStatusSent && updateStep != OpenShock::OtaUpdateStep::None) { - if (!Config::SetOtaUpdateStep(OpenShock::OtaUpdateStep::None)) { - OS_LOGE(TAG, "Failed to reset firmware boot type to normal"); - } - } -} - -void GatewayClient::_handleEvent(WStype_t type, uint8_t* payload, std::size_t length) -{ - switch (type) { - case WStype_DISCONNECTED: - _setState(GatewayClientState::Disconnected); - break; - case WStype_CONNECTED: - m_lastPingTimestamp = 0; - _setState(GatewayClientState::Connected); - _sendBootStatus(); - break; - case WStype_TEXT: - OS_LOGW(TAG, "Received text from API, JSON parsing is not supported anymore :D"); - break; - case WStype_ERROR: - OS_LOGE(TAG, "Received error from API"); - break; - case WStype_FRAGMENT_TEXT_START: - OS_LOGD(TAG, "Received fragment text start from API"); - break; - case WStype_FRAGMENT: - OS_LOGD(TAG, "Received fragment from API"); - break; - case WStype_FRAGMENT_FIN: - OS_LOGD(TAG, "Received fragment fin from API"); - break; - case WStype_BIN: - MessageHandlers::WebSocket::HandleGatewayBinary(tcb::span(payload, length)); - break; - case WStype_FRAGMENT_BIN_START: - OS_LOGE(TAG, "Received binary fragment start from API, this is not supported!"); - break; - case WStype_PING: - case WStype_PONG: - break; - default: - OS_LOGE(TAG, "Received unknown event from API"); - break; - } -} diff --git a/src/WebSocketDeFragger.cpp b/src/WebSocketDeFragger.cpp deleted file mode 100644 index ba6cb227..00000000 --- a/src/WebSocketDeFragger.cpp +++ /dev/null @@ -1,125 +0,0 @@ -#include "WebSocketDeFragger.h" - -const char* const TAG = "WebSocketDeFragger"; - -#include "Logging.h" - -#include - -using namespace OpenShock; - -WebSocketDeFragger::WebSocketDeFragger(EventCallback callback) - : m_messages() - , m_callback(callback) -{ -} - -WebSocketDeFragger::~WebSocketDeFragger() -{ - clear(); -} - -void WebSocketDeFragger::handler(uint8_t socketId, WStype_t type, const uint8_t* payload, std::size_t length) -{ - switch (type) { - case WStype_FRAGMENT_BIN_START: - start(socketId, WebSocketMessageType::Binary, payload, length); - return; - case WStype_FRAGMENT_TEXT_START: - start(socketId, WebSocketMessageType::Text, payload, length); - return; - case WStype_FRAGMENT: - append(socketId, payload, length); - return; - case WStype_FRAGMENT_FIN: - finish(socketId, payload, length); - return; - [[likely]] default: - clear(socketId); - break; - } - - WebSocketMessageType messageType; - switch (type) { - case WStype_DISCONNECTED: - messageType = WebSocketMessageType::Disconnected; - break; - case WStype_CONNECTED: - messageType = WebSocketMessageType::Connected; - break; - case WStype_TEXT: - messageType = WebSocketMessageType::Text; - break; - [[likely]] case WStype_BIN: - messageType = WebSocketMessageType::Binary; - break; - case WStype_PING: - messageType = WebSocketMessageType::Ping; - break; - case WStype_PONG: - messageType = WebSocketMessageType::Pong; - break; - [[unlikely]] default: - OS_LOGE(TAG, "WebSocket client #%u error %u: %s", socketId, length, reinterpret_cast(payload)); - return; - } - - m_callback(socketId, messageType, tcb::span(payload, length)); -} - -void WebSocketDeFragger::onEvent(const EventCallback& callback) -{ - m_callback = callback; -} - -void WebSocketDeFragger::clear(uint8_t socketId) -{ - auto it = m_messages.find(socketId); - if (it != m_messages.end()) { - it->second.data.clear(); - m_messages.erase(it); - } -} - -void WebSocketDeFragger::clear() -{ - m_messages.clear(); -} - -void WebSocketDeFragger::start(uint8_t socketId, WebSocketMessageType type, const uint8_t* data, uint32_t length) -{ - auto it = m_messages.find(socketId); - if (it != m_messages.end()) { - it->second.data.assign(data, length); - it->second.type = type; - return; - } - - m_messages.insert(std::make_pair(socketId, Message {.data = TinyVec(data, length), .type = type})); -} - -void WebSocketDeFragger::append(uint8_t socketId, const uint8_t* data, uint32_t length) -{ - auto it = m_messages.find(socketId); - if (it == m_messages.end()) { - return; - } - - it->second.data.append(data, length); -} - -void WebSocketDeFragger::finish(uint8_t socketId, const uint8_t* data, uint32_t length) -{ - auto it = m_messages.find(socketId); - if (it == m_messages.end()) { - return; - } - - auto& message = it->second; - message.data.append(data, length); - - m_callback(socketId, message.type, message.data); - - message.data.clear(); - m_messages.erase(it); -} diff --git a/src/captiveportal/CaptivePortalInstance.cpp b/src/captiveportal/CaptivePortalInstance.cpp deleted file mode 100644 index d6e3a390..00000000 --- a/src/captiveportal/CaptivePortalInstance.cpp +++ /dev/null @@ -1,595 +0,0 @@ -#include - -#include "captiveportal/CaptivePortalInstance.h" - -const char* const TAG = "CaptivePortalInstance"; - -#include "captiveportal/Manager.h" -#include "captiveportal/RFC8908Handler.h" -#include "Chipset.h" -#include "CommandHandler.h" -#include "config/Config.h" -#include "estop/EStopManager.h" -#include "GatewayConnectionManager.h" -#include "http/ContentTypes.h" -#include "Logging.h" -#include "RateLimiter.h" -#include "message_handlers/WebSocket.h" -#include "OtaUpdateChannel.h" -#include "serialization/WSLocal.h" -#include "util/FnProxy.h" -#include "util/HexUtils.h" -#include "util/PartitionUtils.h" -#include "util/TaskUtils.h" -#include "wifi/WiFiManager.h" -#include "wifi/WiFiScanManager.h" - -#include "serialization/_fbs/HubToLocalMessage_generated.h" - -#include -#include - -const uint16_t HTTP_PORT = 80; -const uint16_t WEBSOCKET_PORT = 81; -const uint16_t DNS_PORT = 53; -const uint32_t WEBSOCKET_PING_INTERVAL = 10'000; -const uint32_t WEBSOCKET_PING_TIMEOUT = 1000; -const uint8_t WEBSOCKET_PING_RETRIES = 3; -const uint32_t WEBSOCKET_UPDATE_INTERVAL = 10; // 10ms / 100Hz - -static const char* const JSON_ERR_INTERNAL = "{\"error\":\"InternalError\"}"; -static const char* const JSON_ERR_MISSING_PARAM = "{\"error\":\"MissingParam\"}"; -static const char* const JSON_ERR_INVALID_PIN = "{\"error\":\"InvalidPin\"}"; -static const char* const JSON_ERR_MISSING_SSID = "{\"error\":\"MissingSsid\"}"; -static const char* const JSON_ERR_INVALID_SSID = "{\"error\":\"InvalidSsid\"}"; -static const char* const JSON_ERR_PASSWORD_SHORT = "{\"error\":\"PasswordTooShort\"}"; -static const char* const JSON_ERR_PASSWORD_LONG = "{\"error\":\"PasswordTooLong\"}"; -static const char* const JSON_ERR_CODE_REQUIRED = "{\"error\":\"CodeRequired\"}"; -static const char* const JSON_ERR_INVALID_CHANNEL = "{\"error\":\"InvalidChannel\"}"; -static const char* const JSON_ERR_RATE_LIMITED = "{\"error\":\"RateLimited\"}"; - -static OpenShock::RateLimiter& getAccountLinkRateLimiter() -{ - static OpenShock::RateLimiter* rl = nullptr; - if (rl == nullptr) { - rl = new OpenShock::RateLimiter(); - rl->addLimit(60'000, 5); // 5 attempts per minute - rl->addLimit(300'000, 10); // 10 attempts per 5 minutes - } - return *rl; -} - -using namespace OpenShock; - -static const esp_partition_t* getStaticPartition() -{ - const esp_partition_t* partition = esp_partition_find_first(ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_SPIFFS, "static0"); - if (partition != nullptr) { - return partition; - } - - partition = esp_partition_find_first(ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_SPIFFS, "static1"); - if (partition != nullptr) { - return partition; - } - - return nullptr; -} - -static const char* getPartitionHash() -{ - const esp_partition_t* partition = getStaticPartition(); - if (partition == nullptr) { - return nullptr; - } - - static char hash[65]; - if (!OpenShock::TryGetPartitionHash(partition, hash)) { - return nullptr; - } - - return hash; -} - -CaptivePortal::CaptivePortalInstance::CaptivePortalInstance() - : m_webServer(HTTP_PORT) - , m_socketServer(WEBSOCKET_PORT, "/ws", "flatbuffers") // Sec-WebSocket-Protocol = flatbuffers - , m_socketDeFragger(std::bind(&CaptivePortalInstance::handleWebSocketEvent, this, std::placeholders::_1, std::placeholders::_2, std::placeholders::_3)) - , m_fileSystem() - , m_dnsServer() - , m_taskHandle(nullptr) -{ - m_socketServer.onEvent(std::bind(&WebSocketDeFragger::handler, &m_socketDeFragger, std::placeholders::_1, std::placeholders::_2, std::placeholders::_3, std::placeholders::_4)); - m_socketServer.begin(); - - m_socketServer.enableHeartbeat(WEBSOCKET_PING_INTERVAL, WEBSOCKET_PING_TIMEOUT, WEBSOCKET_PING_RETRIES); - - OS_LOGI(TAG, "Setting up DNS server"); - bool dnsStarted = m_dnsServer.start(DNS_PORT, "*", WiFi.softAPIP()); - - if (!dnsStarted) { - OS_LOGE(TAG, "Failed to start DNS server!"); - } - - bool fsOk = true; - - // Get the hash of the filesystem - const char* fsHash = getPartitionHash(); - if (fsHash == nullptr) { - OS_LOGE(TAG, "Failed to get filesystem hash"); - fsOk = false; - } - - if (fsOk) { - // Mounting LittleFS - if (!m_fileSystem.begin(false, "/static", 10U, "static0")) { - OS_LOGE(TAG, "Failed to mount LittleFS"); - fsOk = false; - } else { - fsOk = m_fileSystem.exists("/www/index.html.gz"); - } - } - - if (fsOk) { - OS_LOGI(TAG, "Serving files from LittleFS"); - OS_LOGI(TAG, "Filesystem hash: %s", fsHash); - - m_webServer.addHandler(new RFC8908Handler()); - - // API endpoints — must be registered before serveStatic so they take priority - m_webServer.on("/api/board", HTTP_GET, [](AsyncWebServerRequest* request) { - bool hasPredefinedPins = OPENSHOCK_RF_TX_GPIO != OPENSHOCK_GPIO_INVALID; - request->send(200, HTTP::ContentType::JSON, hasPredefinedPins ? "{\"has_predefined_pins\":true}" : "{\"has_predefined_pins\":false}"); - }); - - m_webServer.on("/api/portal/close", HTTP_POST, [](AsyncWebServerRequest* request) { - CaptivePortal::SetUserDone(); - request->send(200); - }); - - m_webServer.on("/api/wifi/scan", HTTP_POST, [](AsyncWebServerRequest* request) { - bool run = true; - if (request->hasParam("run")) { - run = request->getParam("run")->value().toInt() != 0; - } - if (run) { - WiFiScanManager::StartScan(); - } else { - WiFiScanManager::AbortScan(); - } - request->send(200); - }); - - m_webServer.on("/api/wifi/networks", HTTP_DELETE, [](AsyncWebServerRequest* request) { - if (!request->hasParam("ssid")) { - request->send(400, HTTP::ContentType::JSON, JSON_ERR_MISSING_SSID); - return; - } - String ssid = request->getParam("ssid")->value(); - if (!WiFiManager::Forget(ssid.c_str())) { - request->send(500, HTTP::ContentType::JSON, JSON_ERR_INTERNAL); - return; - } - request->send(200); - }); - - m_webServer.on("/api/account/link", HTTP_POST, [](AsyncWebServerRequest* request) { - if (!getAccountLinkRateLimiter().tryRequest()) { - request->send(429, HTTP::ContentType::JSON, JSON_ERR_RATE_LIMITED); - return; - } - if (!request->hasParam("code")) { - request->send(400, HTTP::ContentType::JSON, JSON_ERR_CODE_REQUIRED); - return; - } - String code = request->getParam("code")->value(); - auto result = GatewayConnectionManager::Link(std::string_view(code.c_str(), code.length())); - using ResultCode = OpenShock::AccountLinkResultCode; - if (result == ResultCode::Success) { - request->send(200); - return; - } - const char* error; - switch (result) { - case ResultCode::CodeRequired: - error = "CodeRequired"; - break; - case ResultCode::InvalidCodeLength: - error = "InvalidCodeLength"; - break; - case ResultCode::NoInternetConnection: - error = "NoInternetConnection"; - break; - case ResultCode::InvalidCode: - error = "InvalidCode"; - break; - case ResultCode::RateLimited: - error = "RateLimited"; - break; - case ResultCode::RequestFailed: - error = "RequestFailed"; - break; - case ResultCode::RequestTimedOut: - error = "RequestTimedOut"; - break; - case ResultCode::ServerError: - error = "ServerError"; - break; - case ResultCode::InvalidResponse: - error = "InvalidResponse"; - break; - case ResultCode::ConfigSaveFailed: - error = "ConfigSaveFailed"; - break; - default: - error = "InternalError"; - break; - } - cJSON* root = cJSON_CreateObject(); - cJSON_AddStringToObject(root, "error", error); - char* json = cJSON_PrintUnformatted(root); - cJSON_Delete(root); - if (json == nullptr) { - request->send(500, HTTP::ContentType::JSON, JSON_ERR_INTERNAL); - } else { - request->send(400, HTTP::ContentType::JSON, json); - cJSON_free(json); - } - }); - - m_webServer.on("/api/account", HTTP_DELETE, [](AsyncWebServerRequest* request) { - GatewayConnectionManager::UnLink(); - request->send(200); - }); - - m_webServer.on("/api/config/rf/pin", HTTP_PUT, [](AsyncWebServerRequest* request) { - if (!request->hasParam("pin")) { - request->send(400, HTTP::ContentType::JSON, JSON_ERR_INVALID_PIN); - return; - } - int pin = request->getParam("pin")->value().toInt(); - auto result = CommandHandler::SetRfTxPin(static_cast(pin)); - using ResultCode = OpenShock::SetGPIOResultCode; - if (result != ResultCode::Success) { - request->send(400, HTTP::ContentType::JSON, (result == ResultCode::InvalidPin) ? JSON_ERR_INVALID_PIN : JSON_ERR_INTERNAL); - return; - } - cJSON* root = cJSON_CreateObject(); - cJSON_AddNumberToObject(root, "pin", pin); - char* json = cJSON_PrintUnformatted(root); - cJSON_Delete(root); - if (json == nullptr) { - request->send(500, HTTP::ContentType::JSON, JSON_ERR_INTERNAL); - } else { - request->send(200, HTTP::ContentType::JSON, json); - cJSON_free(json); - } - }); - - m_webServer.on("/api/config/estop/pin", HTTP_PUT, [](AsyncWebServerRequest* request) { - if (!request->hasParam("pin")) { - request->send(400, HTTP::ContentType::JSON, JSON_ERR_INVALID_PIN); - return; - } - int8_t pin = static_cast(request->getParam("pin")->value().toInt()); - if (!IsValidInputPin(pin)) { - request->send(400, HTTP::ContentType::JSON, JSON_ERR_INVALID_PIN); - return; - } - if (!EStopManager::SetEStopPin(static_cast(pin)) || !Config::SetEStopGpioPin(static_cast(pin))) { - request->send(500, HTTP::ContentType::JSON, JSON_ERR_INTERNAL); - return; - } - cJSON* root = cJSON_CreateObject(); - cJSON_AddNumberToObject(root, "pin", pin); - char* json = cJSON_PrintUnformatted(root); - cJSON_Delete(root); - if (json == nullptr) { - request->send(500, HTTP::ContentType::JSON, JSON_ERR_INTERNAL); - } else { - request->send(200, HTTP::ContentType::JSON, json); - cJSON_free(json); - } - }); - - m_webServer.on("/api/config/estop/enabled", HTTP_PUT, [](AsyncWebServerRequest* request) { - if (!request->hasParam("enabled")) { - request->send(400, HTTP::ContentType::JSON, JSON_ERR_INTERNAL); - return; - } - bool enabled = request->getParam("enabled")->value().toInt() != 0; - bool success = EStopManager::SetEStopEnabled(enabled) && Config::SetEStopEnabled(enabled); - if (success) { - request->send(200); - } else { - request->send(500, HTTP::ContentType::JSON, JSON_ERR_INTERNAL); - } - }); - - m_webServer.on("/api/wifi/networks", HTTP_POST, [](AsyncWebServerRequest* request) { - if (!request->hasParam("ssid", true)) { - request->send(400, HTTP::ContentType::JSON, JSON_ERR_MISSING_SSID); - return; - } - String ssid = request->getParam("ssid", true)->value(); - if (ssid.length() == 0 || ssid.length() > 31) { - request->send(400, HTTP::ContentType::JSON, JSON_ERR_INVALID_SSID); - return; - } - String password; - if (request->hasParam("password", true)) { - password = request->getParam("password", true)->value(); - if (password.length() > 0 && password.length() < 8) { - request->send(400, HTTP::ContentType::JSON, JSON_ERR_PASSWORD_SHORT); - return; - } - if (password.length() > 63) { - request->send(400, HTTP::ContentType::JSON, JSON_ERR_PASSWORD_LONG); - return; - } - } - bool connect = true; - if (request->hasParam("connect", true)) { - connect = request->getParam("connect", true)->value().toInt() != 0; - } - wifi_auth_mode_t authMode = WIFI_AUTH_MAX; - if (request->hasParam("security", true)) { - int sec = request->getParam("security", true)->value().toInt(); - if (sec >= 0 && sec <= static_cast(WIFI_AUTH_MAX)) { - authMode = static_cast(sec); - } - } - if (!WiFiManager::Save(ssid.c_str(), std::string_view(password.c_str(), password.length()), connect, authMode)) { - request->send(500, HTTP::ContentType::JSON, JSON_ERR_INTERNAL); - return; - } - request->send(200); - }); - - m_webServer.on("/api/wifi/connect", HTTP_POST, [](AsyncWebServerRequest* request) { - if (!request->hasParam("ssid", true)) { - request->send(400, HTTP::ContentType::JSON, JSON_ERR_MISSING_SSID); - return; - } - String ssid = request->getParam("ssid", true)->value(); - if (!WiFiManager::Connect(ssid.c_str())) { - request->send(500, HTTP::ContentType::JSON, JSON_ERR_INTERNAL); - return; - } - request->send(200); - }); - - m_webServer.on("/api/wifi/disconnect", HTTP_POST, [](AsyncWebServerRequest* request) { - WiFiManager::Disconnect(); - request->send(200); - }); - - m_webServer.on("/api/ota/enabled", HTTP_PUT, [](AsyncWebServerRequest* request) { - if (!request->hasParam("enabled")) { - request->send(400, HTTP::ContentType::JSON, JSON_ERR_MISSING_PARAM); - return; - } - bool enabled = request->getParam("enabled")->value().toInt() != 0; - Config::OtaUpdateConfig cfg; - if (!Config::GetOtaUpdateConfig(cfg)) { - request->send(500, HTTP::ContentType::JSON, JSON_ERR_INTERNAL); - return; - } - cfg.isEnabled = enabled; - if (!Config::SetOtaUpdateConfig(cfg)) { - request->send(500, HTTP::ContentType::JSON, JSON_ERR_INTERNAL); - return; - } - request->send(200); - }); - - m_webServer.on("/api/ota/domain", HTTP_PUT, [](AsyncWebServerRequest* request) { - if (!request->hasParam("domain")) { - request->send(400, HTTP::ContentType::JSON, JSON_ERR_MISSING_PARAM); - return; - } - String domain = request->getParam("domain")->value(); - Config::OtaUpdateConfig cfg; - if (!Config::GetOtaUpdateConfig(cfg)) { - request->send(500, HTTP::ContentType::JSON, JSON_ERR_INTERNAL); - return; - } - cfg.cdnDomain = std::string(domain.c_str(), domain.length()); - if (!Config::SetOtaUpdateConfig(cfg)) { - request->send(500, HTTP::ContentType::JSON, JSON_ERR_INTERNAL); - return; - } - request->send(200); - }); - - m_webServer.on("/api/ota/channel", HTTP_PUT, [](AsyncWebServerRequest* request) { - if (!request->hasParam("channel")) { - request->send(400, HTTP::ContentType::JSON, JSON_ERR_MISSING_PARAM); - return; - } - String channelStr = request->getParam("channel")->value(); - OtaUpdateChannel channel; - if (!TryParseOtaUpdateChannel(channel, channelStr.c_str())) { - request->send(400, HTTP::ContentType::JSON, JSON_ERR_INVALID_CHANNEL); - return; - } - Config::OtaUpdateConfig cfg; - if (!Config::GetOtaUpdateConfig(cfg)) { - request->send(500, HTTP::ContentType::JSON, JSON_ERR_INTERNAL); - return; - } - cfg.updateChannel = channel; - if (!Config::SetOtaUpdateConfig(cfg)) { - request->send(500, HTTP::ContentType::JSON, JSON_ERR_INTERNAL); - return; - } - request->send(200); - }); - - m_webServer.on("/api/ota/check-interval", HTTP_PUT, [](AsyncWebServerRequest* request) { - if (!request->hasParam("interval")) { - request->send(400, HTTP::ContentType::JSON, JSON_ERR_MISSING_PARAM); - return; - } - uint16_t interval = static_cast(request->getParam("interval")->value().toInt()); - Config::OtaUpdateConfig cfg; - if (!Config::GetOtaUpdateConfig(cfg)) { - request->send(500, HTTP::ContentType::JSON, JSON_ERR_INTERNAL); - return; - } - cfg.checkInterval = interval; - if (!Config::SetOtaUpdateConfig(cfg)) { - request->send(500, HTTP::ContentType::JSON, JSON_ERR_INTERNAL); - return; - } - request->send(200); - }); - - m_webServer.on("/api/ota/allow-backend-management", HTTP_PUT, [](AsyncWebServerRequest* request) { - if (!request->hasParam("allow")) { - request->send(400, HTTP::ContentType::JSON, JSON_ERR_MISSING_PARAM); - return; - } - bool allow = request->getParam("allow")->value().toInt() != 0; - Config::OtaUpdateConfig cfg; - if (!Config::GetOtaUpdateConfig(cfg)) { - request->send(500, HTTP::ContentType::JSON, JSON_ERR_INTERNAL); - return; - } - cfg.allowBackendManagement = allow; - if (!Config::SetOtaUpdateConfig(cfg)) { - request->send(500, HTTP::ContentType::JSON, JSON_ERR_INTERNAL); - return; - } - request->send(200); - }); - - m_webServer.on("/api/ota/require-manual-approval", HTTP_PUT, [](AsyncWebServerRequest* request) { - if (!request->hasParam("require")) { - request->send(400, HTTP::ContentType::JSON, JSON_ERR_MISSING_PARAM); - return; - } - bool require = request->getParam("require")->value().toInt() != 0; - Config::OtaUpdateConfig cfg; - if (!Config::GetOtaUpdateConfig(cfg)) { - request->send(500, HTTP::ContentType::JSON, JSON_ERR_INTERNAL); - return; - } - cfg.requireManualApproval = require; - if (!Config::SetOtaUpdateConfig(cfg)) { - request->send(500, HTTP::ContentType::JSON, JSON_ERR_INTERNAL); - return; - } - request->send(200); - }); - - m_webServer.on("/api/ota/check", HTTP_POST, [](AsyncWebServerRequest* request) { - // TODO: trigger OTA check - OtaUpdateManager does not yet expose a CheckForUpdates method - request->send(200); - }); - - // Serving the captive portal files from LittleFS - m_webServer.serveStatic("/", m_fileSystem, "/www/", "max-age=3600").setDefaultFile("index.html").setSharedEtag(fsHash); - - m_webServer.onNotFound(&RFC8908Handler::CatchAll); - - } else { - OS_LOGE(TAG, "/www/index.html or hash files not found, serving error page"); - - m_webServer.onNotFound([](AsyncWebServerRequest* request) { - request->send( - 200, - HTTP::ContentType::TextPlain, - // Raw string literal (1+ to remove the first newline) - 1 + R"( -You probably forgot to upload the Filesystem with PlatformIO! -Go to PlatformIO -> Platform -> Upload Filesystem Image! -If this happened with a file we provided or you just need help, come to the Discord! - -discord.gg/OpenShock -)" - ); - }); - } - - m_webServer.begin(); - - if (fsOk) { - m_stopRequested.store(false, std::memory_order_relaxed); - if (TaskUtils::TaskCreateExpensive(Util::FnProxy<&CaptivePortal::CaptivePortalInstance::task>, TAG, 8192, this, 1, &m_taskHandle) != pdPASS) { // PROFILED: 4-6KB stack usage - OS_LOGE(TAG, "Failed to create task"); - } - } -} - -CaptivePortal::CaptivePortalInstance::~CaptivePortalInstance() -{ - m_stopRequested.store(true, std::memory_order_relaxed); - if (m_taskHandle != nullptr) { - TaskUtils::StopTask(m_taskHandle, TAG, "CaptivePortal task"); - m_taskHandle = nullptr; - } - m_webServer.end(); - m_socketServer.close(); - m_fileSystem.end(); - m_dnsServer.stop(); -} - -void CaptivePortal::CaptivePortalInstance::task() -{ - while (!m_stopRequested.load(std::memory_order_relaxed)) { - m_socketServer.loop(); - m_dnsServer.processNextRequest(); - vTaskDelay(pdMS_TO_TICKS(WEBSOCKET_UPDATE_INTERVAL)); - } - vTaskDelete(nullptr); -} - -void CaptivePortal::CaptivePortalInstance::handleWebSocketClientConnected(uint8_t socketId) -{ - OS_LOGD(TAG, "WebSocket client #%u connected from %s", socketId, m_socketServer.remoteIP(socketId).toString().c_str()); - - WiFiNetwork connectedNetwork; - WiFiNetwork* connectedNetworkPtr = nullptr; - if (WiFiManager::GetConnectedNetwork(connectedNetwork)) { - connectedNetworkPtr = &connectedNetwork; - } - - Serialization::Local::SerializeReadyMessage(connectedNetworkPtr, GatewayConnectionManager::IsLinked(), std::bind(&CaptivePortal::CaptivePortalInstance::sendMessageBIN, this, socketId, std::placeholders::_1)); - - // Send all previously scanned wifi networks - auto networks = OpenShock::WiFiManager::GetDiscoveredWiFiNetworks(); - - Serialization::Local::SerializeWiFiNetworksEvent(Serialization::Types::WifiNetworkEventType::Discovered, networks, std::bind(&CaptivePortal::CaptivePortalInstance::sendMessageBIN, this, socketId, std::placeholders::_1)); -} - -void CaptivePortal::CaptivePortalInstance::handleWebSocketClientDisconnected(uint8_t socketId) -{ - OS_LOGD(TAG, "WebSocket client #%u disconnected", socketId); -} - -void CaptivePortal::CaptivePortalInstance::handleWebSocketEvent(uint8_t socketId, WebSocketMessageType type, tcb::span payload) -{ - switch (type) { - case WebSocketMessageType::Connected: - handleWebSocketClientConnected(socketId); - break; - case WebSocketMessageType::Disconnected: - handleWebSocketClientDisconnected(socketId); - break; - case WebSocketMessageType::Text: - OS_LOGE(TAG, "Message type is not supported"); - break; - case WebSocketMessageType::Binary: - MessageHandlers::WebSocket::HandleLocalBinary(socketId, payload); - break; - case WebSocketMessageType::Ping: - case WebSocketMessageType::Pong: - // Do nothing - break; - default: - m_socketDeFragger.clear(); - OS_LOGE(TAG, "Unknown WebSocket event type: %u", type); - break; - } -} diff --git a/src/captiveportal/Manager.cpp b/src/captiveportal/Manager.cpp deleted file mode 100644 index ea21ed5e..00000000 --- a/src/captiveportal/Manager.cpp +++ /dev/null @@ -1,297 +0,0 @@ -#include - -#include "captiveportal/Manager.h" - -const char* const TAG = "CaptivePortal"; - -#include "captiveportal/CaptivePortalInstance.h" -#include "CommandHandler.h" -#include "config/Config.h" -#include "Core.h" -#include "GatewayConnectionManager.h" -#include "Logging.h" - -#include -#include -#include - -#include - -#include "SimpleMutex.h" - -#include -#include - -using namespace OpenShock; - -static std::atomic s_alwaysEnabled = false; -static std::atomic s_forceClosed = false; -static std::atomic s_userDone = false; -static esp_timer_handle_t s_captivePortalUpdateLoopTimer = nullptr; -static SimpleMutex s_instanceMutex; -static std::shared_ptr s_instance = nullptr; - -// Absolute esp_timer timestamps (microseconds). 0 = not armed. -static std::atomic s_startupGraceExpiry = 0; // Don't open portal until this time passes -static std::atomic s_autoCloseExpiry = 0; // Auto-close AP when no clients connected and device is online - -static constexpr int64_t STARTUP_GRACE_PERIOD_US = 30LL * 1'000'000; // 30 seconds -static constexpr int64_t AUTO_CLOSE_DELAY_US = 5LL * 60 * 1'000'000; // 5 minutes - -static bool isDeviceFullyConfigured() -{ - std::vector credentialsList; - if (!Config::GetWiFiCredentials(credentialsList) || credentialsList.empty()) { - return false; - } - return Config::HasBackendAuthToken(); -} - -static std::shared_ptr GetInstance() -{ - ScopedLock lock__(&s_instanceMutex); - return s_instance; -} -static void CreateInstance() -{ - ScopedLock lock__(&s_instanceMutex); - s_instance = std::make_shared(); -} -static void DestroyInstance() -{ - ScopedLock lock__(&s_instanceMutex); - s_instance = nullptr; -} - -static bool captiveportal_start() -{ - if (GetInstance() != nullptr) { - OS_LOGD(TAG, "Already started"); - return true; - } - - OS_LOGI(TAG, "Starting captive portal"); - - if (!WiFi.enableAP(true)) { - OS_LOGE(TAG, "Failed to enable AP mode"); - return false; - } - - if (!WiFi.softAP((OPENSHOCK_FW_AP_PREFIX + WiFi.macAddress()).c_str())) { - OS_LOGE(TAG, "Failed to start AP"); - WiFi.enableAP(false); - return false; - } - - IPAddress apIP(4, 3, 2, 1); - if (!WiFi.softAPConfig(apIP, apIP, IPAddress(255, 255, 255, 0))) { - OS_LOGE(TAG, "Failed to configure AP"); - WiFi.softAPdisconnect(true); - return false; - } - - std::string hostname; - if (!Config::GetWiFiHostname(hostname)) { - OS_LOGE(TAG, "Failed to get WiFi hostname, reverting to default"); - hostname = OPENSHOCK_FW_HOSTNAME; - } - - CreateInstance(); - - return true; -} -static void captiveportal_stop() -{ - if (GetInstance() == nullptr) { - OS_LOGD(TAG, "Already stopped"); - return; - } - - OS_LOGI(TAG, "Stopping captive portal"); - - DestroyInstance(); - s_userDone = false; - - WiFi.softAPdisconnect(true); -} - -static void captiveportal_updateloop(void*) -{ - int64_t now = esp_timer_get_time(); - - // Startup grace period: device is fully configured, wait for gateway connection - int64_t graceExpiry = s_startupGraceExpiry.load(std::memory_order_relaxed); - if (graceExpiry != 0) { - if (GatewayConnectionManager::IsConnected()) { - // Gateway connected during grace — clear grace, never open portal - s_startupGraceExpiry.store(0, std::memory_order_relaxed); - return; - } - if (now < graceExpiry) { - // Still within grace period, don't open portal yet - return; - } - // Grace expired without gateway connection — open portal normally - s_startupGraceExpiry.store(0, std::memory_order_relaxed); - } - - // Force-closed by user (via /api/portal/close) - if (s_forceClosed) { - if (GetInstance() != nullptr) { - OS_LOGD(TAG, "Force-closing captive portal"); - captiveportal_stop(); - } - return; - } - - // User completed setup — close portal once device is fully online - if (s_userDone && GatewayConnectionManager::IsConnected()) { - if (GetInstance() != nullptr) { - OS_LOGI(TAG, "User completed setup, closing captive portal"); - captiveportal_stop(); - } - return; - } - - // Auto-close: no clients connected, WiFi + gateway are up, 5 minutes elapsed - auto instance = GetInstance(); - if (instance != nullptr && !s_alwaysEnabled && GatewayConnectionManager::IsConnected()) { - if (instance->hasClients()) { - // Clients still connected — reset timer - s_autoCloseExpiry.store(0, std::memory_order_relaxed); - } else { - int64_t expiry = s_autoCloseExpiry.load(std::memory_order_relaxed); - if (expiry == 0) { - s_autoCloseExpiry.store(now + AUTO_CLOSE_DELAY_US, std::memory_order_relaxed); - } else if (now >= expiry) { - OS_LOGI(TAG, "Auto-closing captive portal AP (no clients for 5 minutes)"); - captiveportal_stop(); - return; - } - } - } else { - s_autoCloseExpiry.store(0, std::memory_order_relaxed); - } - - // Open portal if not running and device needs setup - if (instance == nullptr) { - bool commandHandlerOk = CommandHandler::Ok(); - bool shouldStart = s_alwaysEnabled || !commandHandlerOk || !isDeviceFullyConfigured(); - if (shouldStart) { - OS_LOGD(TAG, "Starting captive portal"); - captiveportal_start(); - } - } -} - -bool CaptivePortal::Init() -{ - // If device is already fully configured, set a startup grace period before opening portal - if (isDeviceFullyConfigured()) { - s_startupGraceExpiry.store(esp_timer_get_time() + STARTUP_GRACE_PERIOD_US, std::memory_order_relaxed); - OS_LOGI(TAG, "Device fully configured, startup grace period of 30s before opening portal"); - } - - esp_timer_create_args_t args = { - .callback = captiveportal_updateloop, - .arg = nullptr, - .dispatch_method = ESP_TIMER_TASK, - .name = "captive_portal_update", - .skip_unhandled_events = true, - }; - - esp_err_t err; - - err = esp_timer_create(&args, &s_captivePortalUpdateLoopTimer); - if (err != ESP_OK) { - OS_LOGE(TAG, "Failed to create captive portal update timer"); - return false; - } - - err = esp_timer_start_periodic(s_captivePortalUpdateLoopTimer, 500'000); // 500ms - if (err != ESP_OK) { - OS_LOGE(TAG, "Failed to start captive portal update timer"); - return false; - } - - return true; -} - -void CaptivePortal::SetUserDone() -{ - s_userDone = true; -} - -void CaptivePortal::SetAlwaysEnabled(bool alwaysEnabled) -{ - s_alwaysEnabled = alwaysEnabled; - Config::SetCaptivePortalConfig({ - .alwaysEnabled = alwaysEnabled, - }); -} -bool CaptivePortal::IsAlwaysEnabled() -{ - return s_alwaysEnabled; -} - -bool CaptivePortal::ForceClose(uint32_t timeoutMs) -{ - s_forceClosed = true; - - if (GetInstance() == nullptr) return true; - - while (timeoutMs > 0) { - uint32_t delay = std::min(timeoutMs, static_cast(10U)); - - vTaskDelay(pdMS_TO_TICKS(delay)); - - timeoutMs -= delay; - - if (GetInstance() == nullptr) return true; - } - - return false; -} - -bool CaptivePortal::IsRunning() -{ - return GetInstance() != nullptr; -} - -bool CaptivePortal::SendMessageTXT(uint8_t socketId, std::string_view data) -{ - auto instance = GetInstance(); - if (instance == nullptr) return false; - - instance->sendMessageTXT(socketId, data); - - return true; -} -bool CaptivePortal::SendMessageBIN(uint8_t socketId, tcb::span data) -{ - auto instance = GetInstance(); - if (instance == nullptr) return false; - - instance->sendMessageBIN(socketId, data); - - return true; -} - -bool CaptivePortal::BroadcastMessageTXT(std::string_view data) -{ - auto instance = GetInstance(); - if (instance == nullptr) return false; - - instance->broadcastMessageTXT(data); - - return true; -} -bool CaptivePortal::BroadcastMessageBIN(tcb::span data) -{ - auto instance = GetInstance(); - if (instance == nullptr) return false; - - instance->broadcastMessageBIN(data); - - return true; -} diff --git a/src/captiveportal/RFC8908Handler.cpp b/src/captiveportal/RFC8908Handler.cpp deleted file mode 100644 index ed275436..00000000 --- a/src/captiveportal/RFC8908Handler.cpp +++ /dev/null @@ -1,108 +0,0 @@ -#include "captiveportal/RFC8908Handler.h" - -const char* const TAG = "RFC8908Handler"; - -#include "http/ContentTypes.h" -#include "Logging.h" - -#include - -#include - -#include - -using namespace OpenShock; - -static const char* const captivePortalApiPath = "/captive-portal/api"; - -static const char* const probeRequestrefixes[] = {"/gen_204", "/generate_204", "/hotspot-detect.", "/success.", "/connecttest.", "/check_network_status.", "/canonical.", "/ncsi.txt", captivePortalApiPath}; - -static String GetCaptivePortalUrl() -{ - return String("http://") + WiFi.softAPIP().toString() + "/"; -} - -// Catch-all handler for OS connectivity probes. -// Operating systems attempt to detect internet access by requesting -// well-known URLs. Redirecting these requests to the portal root -// intentionally fails connectivity checks and activates captive -// portal mode. -// -// This mechanism complements the IETF CAPPORT architecture (RFC 8908), -// which provides a machine-readable API for captive state, while this -// handler ensures broad OS compatibility. -void CaptivePortal::RFC8908Handler::CatchAll(AsyncWebServerRequest* request) -{ - AsyncWebServerResponse* response = request->beginResponse(302); - response->addHeader(asyncsrv::T_LOCATION, GetCaptivePortalUrl()); - response->addHeader(asyncsrv::T_Cache_Control, "no-store, no-cache, must-revalidate"); - response->addHeader("Pragma", "no-cache"); - response->addHeader("Expires", "0"); - request->send(response); -} - -bool CaptivePortal::RFC8908Handler::canHandle(AsyncWebServerRequest* request) const -{ - if (!request->isHTTP() || request->method() != WebRequestMethod::HTTP_GET) { - return false; - } - - auto& url = request->url(); - - for (auto& prefix : probeRequestrefixes) { - if (url.startsWith(prefix)) return true; - } - - return false; -} -void CaptivePortal::RFC8908Handler::handleRequest(AsyncWebServerRequest* request) -{ - auto& url = request->url(); - - // Captive Portal API endpoint (RFC 8908) - // - // Provides a machine-readable JSON interface for clients to query - // their captive portal state. This complements the classic redirect-based - // captive portal detection used by Android, iOS/macOS, and Windows. - // - // Minimal required fields for RFC compliance: - // - "captive": boolean indicating whether the client is still captive - // - "user-portal-url": URL of the portal page the client should visit - // - // Optional fields that can be added later: - // - "venue-info-url", "can-extend-session", "seconds-remaining", "bytes-remaining" - // - // Clients accessing this endpoint should respect "Cache-Control: no-store" - // and only rely on this data over HTTPS if possible. On embedded devices, - // HTTPS may be omitted for practicality, but the redirect-based captive - // portal ensures broad OS compatibility. - if (url.equals(captivePortalApiPath)) { - OS_LOGI(TAG, "Got Captive API request"); - - auto portalUrl = GetCaptivePortalUrl(); - - cJSON* doc = cJSON_CreateObject(); - cJSON_AddBoolToObject(doc, "captive", true); - cJSON_AddStringToObject(doc, "user-portal-url", portalUrl.c_str()); - cJSON_AddStringToObject(doc, "venue-info-url", "https://openshock.org"); - - char* jsonStr = cJSON_Print(doc); - cJSON_Delete(doc); - - if (jsonStr == nullptr) { - OS_LOGE(TAG, "Failed to serialize captive portal JSON response"); - request->send(500, OpenShock::HTTP::ContentType::TextPlain, "Internal Server Error"); - return; - } - - AsyncWebServerResponse* response = request->beginResponse(200, "application/captive+json", jsonStr); - cJSON_free(jsonStr); - - response->addHeader("Cache-Control", "private"); - request->send(response); - return; - } - - // Fallback to 302 - CatchAll(request); -} diff --git a/src/config/EStopConfig.cpp b/src/config/EStopConfig.cpp deleted file mode 100644 index df78b092..00000000 --- a/src/config/EStopConfig.cpp +++ /dev/null @@ -1,83 +0,0 @@ -#include "config/EStopConfig.h" - -#include "Chipset.h" -#include "Common.h" -#include "config/internal/utils.h" -#include "Logging.h" - -const char* const TAG = "Config::EStopConfig"; - -using namespace OpenShock::Config; - -EStopConfig::EStopConfig() - : enabled(OpenShock::IsValidInputPin(OPENSHOCK_ESTOP_PIN)) - , gpioPin(static_cast(OPENSHOCK_ESTOP_PIN)) -{ -} - -EStopConfig::EStopConfig(bool enabled, gpio_num_t gpioPin) - : enabled(enabled) - , gpioPin(gpioPin) -{ -} - -void EStopConfig::ToDefault() -{ - enabled = OpenShock::IsValidInputPin(OPENSHOCK_ESTOP_PIN); - gpioPin = static_cast(OPENSHOCK_ESTOP_PIN); -} - -bool EStopConfig::FromFlatbuffers(const Serialization::Configuration::EStopConfig* config) -{ - if (config == nullptr) { - ToDefault(); // Set to default if config is null - return true; - } - - gpioPin = static_cast(config->gpio_pin()); - - if (OpenShock::IsValidInputPin(static_cast(gpioPin))) { - enabled = config->enabled(); - } else { - enabled = false; - } - - return true; -} - -flatbuffers::Offset EStopConfig::ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const -{ - return Serialization::Configuration::CreateEStopConfig(builder, enabled, gpioPin); -} - -bool EStopConfig::FromJSON(const cJSON* json) -{ - if (json == nullptr) { - ToDefault(); // Set to default if config is null - return true; - } - - if (cJSON_IsObject(json) == 0) { - OS_LOGE(TAG, "json is not an object"); - return false; - } - - Internal::Utils::FromJsonGpioNum(gpioPin, json, "gpioPin", static_cast(OPENSHOCK_ESTOP_PIN)); - - if (!Internal::Utils::FromJsonBool(enabled, json, "enabled", OpenShock::IsValidInputPin(gpioPin))) { - OS_LOGE(TAG, "Failed to parse enabled"); - return false; - } - - return true; -} - -cJSON* EStopConfig::ToJSON(bool withSensitiveData) const -{ - cJSON* root = cJSON_CreateObject(); - - cJSON_AddBoolToObject(root, "enabled", enabled); - cJSON_AddNumberToObject(root, "gpioPin", gpioPin); - - return root; -} diff --git a/src/config/OtaUpdateConfig.cpp b/src/config/OtaUpdateConfig.cpp deleted file mode 100644 index 4ae0735a..00000000 --- a/src/config/OtaUpdateConfig.cpp +++ /dev/null @@ -1,124 +0,0 @@ -#include "config/OtaUpdateConfig.h" - -const char* const TAG = "Config::OtaUpdateConfig"; - -#include "config/internal/utils.h" -#include "Logging.h" - -using namespace OpenShock::Config; - -OtaUpdateConfig::OtaUpdateConfig() - : isEnabled(true) - , cdnDomain(OPENSHOCK_FW_CDN_DOMAIN) - , updateChannel(OtaUpdateChannel::Stable) - , checkOnStartup(false) - , checkPeriodically(false) - , checkInterval(30) - , allowBackendManagement(true) - , requireManualApproval(false) - , updateId(0) - , updateStep(OtaUpdateStep::None) -{ -} - -OtaUpdateConfig::OtaUpdateConfig( - bool isEnabled, std::string cdnDomain, OtaUpdateChannel updateChannel, bool checkOnStartup, bool checkPeriodically, uint16_t checkInterval, bool allowBackendManagement, bool requireManualApproval, int32_t updateId, OtaUpdateStep updateStep -) - : isEnabled(isEnabled) - , cdnDomain(std::move(cdnDomain)) - , updateChannel(updateChannel) - , checkOnStartup(checkOnStartup) - , checkPeriodically(checkPeriodically) - , checkInterval(checkInterval) - , allowBackendManagement(allowBackendManagement) - , requireManualApproval(requireManualApproval) - , updateId(updateId) - , updateStep(updateStep) -{ -} - -void OtaUpdateConfig::ToDefault() -{ - isEnabled = true; - cdnDomain = OPENSHOCK_FW_CDN_DOMAIN; - updateChannel = OtaUpdateChannel::Stable; - checkOnStartup = false; - checkPeriodically = false; - checkInterval = 30; // 30 minutes - allowBackendManagement = true; - requireManualApproval = false; - updateId = 0; - updateStep = OtaUpdateStep::None; -} - -bool OtaUpdateConfig::FromFlatbuffers(const Serialization::Configuration::OtaUpdateConfig* config) -{ - if (config == nullptr) { - OS_LOGW(TAG, "Config is null, setting to default"); - ToDefault(); - return true; - } - - isEnabled = config->is_enabled(); - Internal::Utils::FromFbsStr(cdnDomain, config->cdn_domain(), OPENSHOCK_FW_CDN_DOMAIN); - updateChannel = config->update_channel(); - checkOnStartup = config->check_on_startup(); - checkPeriodically = config->check_periodically(); - checkInterval = config->check_interval(); - allowBackendManagement = config->allow_backend_management(); - requireManualApproval = config->require_manual_approval(); - updateId = config->update_id(); - updateStep = config->update_step(); - - return true; -} - -flatbuffers::Offset OtaUpdateConfig::ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const -{ - return Serialization::Configuration::CreateOtaUpdateConfig(builder, isEnabled, builder.CreateString(cdnDomain), updateChannel, checkOnStartup, checkPeriodically, checkInterval, allowBackendManagement, requireManualApproval, updateId, updateStep); -} - -bool OtaUpdateConfig::FromJSON(const cJSON* json) -{ - if (json == nullptr) { - OS_LOGW(TAG, "Config is null, setting to default"); - ToDefault(); - return true; - } - - if (!cJSON_IsObject(json)) { - OS_LOGE(TAG, "json is not an object"); - return false; - } - - Internal::Utils::FromJsonBool(isEnabled, json, "isEnabled", true); - Internal::Utils::FromJsonStr(cdnDomain, json, "cdnDomain", OPENSHOCK_FW_CDN_DOMAIN); - Internal::Utils::FromJsonStrParsed(updateChannel, json, "updateChannel", OpenShock::TryParseOtaUpdateChannel, OpenShock::OtaUpdateChannel::Stable); - Internal::Utils::FromJsonBool(checkOnStartup, json, "checkOnStartup", false); - Internal::Utils::FromJsonBool(checkPeriodically, json, "checkPeriodically", false); - Internal::Utils::FromJsonU16(checkInterval, json, "checkInterval", 30); - Internal::Utils::FromJsonBool(allowBackendManagement, json, "allowBackendManagement", true); - Internal::Utils::FromJsonBool(requireManualApproval, json, "requireManualApproval", false); - Internal::Utils::FromJsonI32(updateId, json, "updateId", 0); - Internal::Utils::FromJsonStrParsed(updateStep, json, "updateStep", OpenShock::TryParseOtaUpdateStep, OpenShock::OtaUpdateStep::None); - - return true; -} - -cJSON* OtaUpdateConfig::ToJSON(bool withSensitiveData) const -{ - cJSON* root = cJSON_CreateObject(); - - cJSON_AddBoolToObject(root, "isEnabled", isEnabled); - cJSON_AddStringToObject(root, "cdnDomain", cdnDomain.c_str()); - cJSON_AddStringToObject(root, "updateChannel", OpenShock::Serialization::Configuration::EnumNameOtaUpdateChannel(updateChannel)); - cJSON_AddBoolToObject(root, "checkOnStartup", checkOnStartup); - cJSON_AddBoolToObject(root, "checkPeriodically", checkPeriodically); - cJSON_AddNumberToObject(root, "checkInterval", checkInterval); - cJSON_AddBoolToObject(root, "allowBackendManagement", allowBackendManagement); - cJSON_AddBoolToObject(root, "requireManualApproval", requireManualApproval); - cJSON_AddNumberToObject(root, "updateId", updateId); - cJSON_AddStringToObject(root, "updateStep", OpenShock::Serialization::Configuration::EnumNameOtaUpdateStep(updateStep)); - - return root; -} diff --git a/src/config/RFConfig.cpp b/src/config/RFConfig.cpp deleted file mode 100644 index 81d5080b..00000000 --- a/src/config/RFConfig.cpp +++ /dev/null @@ -1,75 +0,0 @@ -#include "config/RFConfig.h" - -const char* const TAG = "Config::RFConfig"; - -#include "Common.h" -#include "config/internal/utils.h" -#include "Logging.h" - -using namespace OpenShock::Config; - -RFConfig::RFConfig() - : txPin(static_cast(OPENSHOCK_RF_TX_GPIO)) - , keepAliveEnabled(true) -{ -} - -RFConfig::RFConfig(gpio_num_t txPin, bool keepAliveEnabled) - : txPin(txPin) - , keepAliveEnabled(keepAliveEnabled) -{ -} - -void RFConfig::ToDefault() -{ - txPin = static_cast(OPENSHOCK_RF_TX_GPIO); - keepAliveEnabled = true; -} - -bool RFConfig::FromFlatbuffers(const Serialization::Configuration::RFConfig* config) -{ - if (config == nullptr) { - OS_LOGW(TAG, "Config is null, setting to default"); - ToDefault(); - return true; - } - - Internal::Utils::FromU8GpioNum(txPin, config->tx_pin(), static_cast(OPENSHOCK_RF_TX_GPIO)); - keepAliveEnabled = config->keepalive_enabled(); - - return true; -} - -flatbuffers::Offset RFConfig::ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const -{ - return Serialization::Configuration::CreateRFConfig(builder, txPin, keepAliveEnabled); -} - -bool RFConfig::FromJSON(const cJSON* json) -{ - if (json == nullptr) { - OS_LOGW(TAG, "Config is null, setting to default"); - ToDefault(); - return true; - } - - if (cJSON_IsObject(json) == 0) { - OS_LOGE(TAG, "json is not an object"); - return false; - } - - Internal::Utils::FromJsonGpioNum(txPin, json, "txPin", static_cast(OPENSHOCK_RF_TX_GPIO)); - Internal::Utils::FromJsonBool(keepAliveEnabled, json, "keepAliveEnabled", true); - - return true; -} - -cJSON* RFConfig::ToJSON(bool withSensitiveData) const -{ - cJSON* root = cJSON_CreateObject(); - - cJSON_AddNumberToObject(root, "txPin", static_cast(txPin)); //-V2564 - cJSON_AddBoolToObject(root, "keepAliveEnabled", keepAliveEnabled); - - return root; -} diff --git a/src/config/RootConfig.cpp b/src/config/RootConfig.cpp deleted file mode 100644 index 9babc0b9..00000000 --- a/src/config/RootConfig.cpp +++ /dev/null @@ -1,154 +0,0 @@ -#include "config/RootConfig.h" - -const char* const TAG = "Config::RootConfig"; - -#include "Logging.h" - -using namespace OpenShock::Config; - -RootConfig::RootConfig() - : rf() - , wifi() - , captivePortal() - , backend() - , serialInput() - , otaUpdate() - , estop() -{ -} - -void RootConfig::ToDefault() -{ - rf.ToDefault(); - wifi.ToDefault(); - captivePortal.ToDefault(); - backend.ToDefault(); - serialInput.ToDefault(); - otaUpdate.ToDefault(); - estop.ToDefault(); -} - -bool RootConfig::FromFlatbuffers(const Serialization::Configuration::HubConfig* config) -{ - if (config == nullptr) { - OS_LOGW(TAG, "Config is null, setting to default"); - ToDefault(); - return true; - } - - if (!rf.FromFlatbuffers(config->rf())) { - OS_LOGE(TAG, "Unable to load rf config"); - return false; - } - - if (!wifi.FromFlatbuffers(config->wifi())) { - OS_LOGE(TAG, "Unable to load wifi config"); - return false; - } - - if (!captivePortal.FromFlatbuffers(config->captive_portal())) { - OS_LOGE(TAG, "Unable to load captive portal config"); - return false; - } - - if (!backend.FromFlatbuffers(config->backend())) { - OS_LOGE(TAG, "Unable to load backend config"); - return false; - } - - if (!serialInput.FromFlatbuffers(config->serial_input())) { - OS_LOGE(TAG, "Unable to load serial input config"); - return false; - } - - if (!otaUpdate.FromFlatbuffers(config->ota_update())) { - OS_LOGE(TAG, "Unable to load ota update config"); - return false; - } - - if (!estop.FromFlatbuffers(config->estop())) { - OS_LOGE(TAG, "Unable to load estop config"); - return false; - } - - return true; -} - -flatbuffers::Offset RootConfig::ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const -{ - auto rfOffset = rf.ToFlatbuffers(builder, withSensitiveData); - auto wifiOffset = wifi.ToFlatbuffers(builder, withSensitiveData); - auto captivePortalOffset = captivePortal.ToFlatbuffers(builder, withSensitiveData); - auto backendOffset = backend.ToFlatbuffers(builder, withSensitiveData); - auto serialInputOffset = serialInput.ToFlatbuffers(builder, withSensitiveData); - auto otaUpdateOffset = otaUpdate.ToFlatbuffers(builder, withSensitiveData); - auto estopOffset = estop.ToFlatbuffers(builder, withSensitiveData); - - return Serialization::Configuration::CreateHubConfig(builder, rfOffset, wifiOffset, captivePortalOffset, backendOffset, serialInputOffset, otaUpdateOffset, estopOffset); -} - -bool RootConfig::FromJSON(const cJSON* json) -{ - if (json == nullptr) { - OS_LOGW(TAG, "Config is null, setting to default"); - ToDefault(); - return true; - } - - if (cJSON_IsObject(json) == 0) { - OS_LOGE(TAG, "json is not an object"); - return false; - } - - if (!rf.FromJSON(cJSON_GetObjectItemCaseSensitive(json, "rf"))) { - OS_LOGE(TAG, "Unable to load rf config"); - return false; - } - - if (!wifi.FromJSON(cJSON_GetObjectItemCaseSensitive(json, "wifi"))) { - OS_LOGE(TAG, "Unable to load wifi config"); - return false; - } - - if (!captivePortal.FromJSON(cJSON_GetObjectItemCaseSensitive(json, "captivePortal"))) { - OS_LOGE(TAG, "Unable to load captive portal config"); - return false; - } - - if (!backend.FromJSON(cJSON_GetObjectItemCaseSensitive(json, "backend"))) { - OS_LOGE(TAG, "Unable to load backend config"); - return false; - } - - if (!serialInput.FromJSON(cJSON_GetObjectItemCaseSensitive(json, "serialInput"))) { - OS_LOGE(TAG, "Unable to load serial input config"); - return false; - } - - if (!otaUpdate.FromJSON(cJSON_GetObjectItemCaseSensitive(json, "otaUpdate"))) { - OS_LOGE(TAG, "Unable to load ota update config"); - return false; - } - - if (!estop.FromJSON(cJSON_GetObjectItemCaseSensitive(json, "estop"))) { - OS_LOGE(TAG, "Unable to load estop config"); - return false; - } - - return true; -} - -cJSON* RootConfig::ToJSON(bool withSensitiveData) const -{ - cJSON* root = cJSON_CreateObject(); - - cJSON_AddItemToObject(root, "rf", rf.ToJSON(withSensitiveData)); - cJSON_AddItemToObject(root, "wifi", wifi.ToJSON(withSensitiveData)); - cJSON_AddItemToObject(root, "captivePortal", captivePortal.ToJSON(withSensitiveData)); - cJSON_AddItemToObject(root, "backend", backend.ToJSON(withSensitiveData)); - cJSON_AddItemToObject(root, "serialInput", serialInput.ToJSON(withSensitiveData)); - cJSON_AddItemToObject(root, "otaUpdate", otaUpdate.ToJSON(withSensitiveData)); - cJSON_AddItemToObject(root, "estop", estop.ToJSON(withSensitiveData)); - - return root; -} diff --git a/src/config/internal/utils.cpp b/src/config/internal/utils.cpp deleted file mode 100644 index 37966a97..00000000 --- a/src/config/internal/utils.cpp +++ /dev/null @@ -1,191 +0,0 @@ -#include "config/internal/utils.h" - -const char* const TAG = "Config::Internal::Utils"; - -#include "Chipset.h" -#include "Logging.h" -#include "util/IPAddressUtils.h" - -using namespace OpenShock; - -template -static bool utilFromJsonInt(T& val, const cJSON* json, const char* name, T defaultVal, int minVal, int maxVal) -{ - static_assert(std::is_integral_v, "T must be an integral type"); - - const cJSON* jsonVal = cJSON_GetObjectItemCaseSensitive(json, name); - if (jsonVal == nullptr) { - return false; - } - - if (cJSON_IsNumber(jsonVal) == 0) { - OS_LOGE(TAG, "value at '%s' is not a number", name); - return false; - } - - int intVal = jsonVal->valueint; - - if (intVal < minVal) { - OS_LOGE(TAG, "value at '%s' is less than %d", name, minVal); - return false; - } - - if (intVal > maxVal) { - OS_LOGE(TAG, "value at '%s' is greater than %d", name, maxVal); - return false; - } - - val = static_cast(intVal); - - return true; -} - -bool Config::Internal::Utils::FromU8GpioNum(gpio_num_t& val, uint8_t u8Val) -{ - if (u8Val >= GPIO_NUM_MAX || !GPIO_IS_VALID_GPIO(u8Val)) { - OS_LOGE(TAG, "invalid GPIO number"); - return false; - } - - val = static_cast(u8Val); - - return true; -} - -void Config::Internal::Utils::FromU8GpioNum(gpio_num_t& val, uint8_t u8Val, gpio_num_t defaultVal) -{ - if (!FromU8GpioNum(val, u8Val)) { - val = defaultVal; - } -} - -void Config::Internal::Utils::FromFbsStr(std::string& str, const flatbuffers::String* fbsStr, const char* defaultStr) -{ - if (fbsStr != nullptr) { - str = fbsStr->c_str(); - } else { - str = defaultStr; - } -} - -bool Config::Internal::Utils::FromFbsIPAddress(IPAddress& ip, const flatbuffers::String* fbsIP, const IPAddress& defaultIP) -{ - if (fbsIP == nullptr) { - ip = defaultIP; - return true; - } - - std::string_view view(*fbsIP); - - if (!OpenShock::IPV4AddressFromStringView(ip, view)) { - OS_LOGE(TAG, "failed to parse IP address"); - return false; - } - - return true; -} - -bool Config::Internal::Utils::FromJsonBool(bool& val, const cJSON* json, const char* name, bool defaultVal) -{ - const cJSON* jsonVal = cJSON_GetObjectItemCaseSensitive(json, name); - if (jsonVal == nullptr) { - val = defaultVal; - return true; - } - - if (cJSON_IsBool(jsonVal) == 0) { - OS_LOGE(TAG, "value at '%s' is not a bool", name); - return false; - } - - val = cJSON_IsTrue(jsonVal); - - return true; -} - -bool Config::Internal::Utils::FromJsonU8(uint8_t& val, const cJSON* json, const char* name, uint8_t defaultVal) -{ - return utilFromJsonInt(val, json, name, defaultVal, 0, UINT8_MAX); -} - -bool Config::Internal::Utils::FromJsonU16(uint16_t& val, const cJSON* json, const char* name, uint16_t defaultVal) -{ - return utilFromJsonInt(val, json, name, defaultVal, 0, UINT16_MAX); -} - -bool Config::Internal::Utils::FromJsonI32(int32_t& val, const cJSON* json, const char* name, int32_t defaultVal) -{ - return utilFromJsonInt(val, json, name, defaultVal, INT32_MIN, INT32_MAX); -} - -bool Config::Internal::Utils::FromJsonStr(std::string& str, const cJSON* json, const char* name) -{ - const cJSON* jsonVal = cJSON_GetObjectItemCaseSensitive(json, name); - if (jsonVal == nullptr) { - OS_LOGE(TAG, "value at '%s' is null", name); - return false; - } - - if (cJSON_IsString(jsonVal) == 0) { - OS_LOGE(TAG, "value at '%s' is not a string", name); - return false; - } - - str = jsonVal->valuestring; - - return true; -} - -void Config::Internal::Utils::FromJsonStr(std::string& str, const cJSON* json, const char* name, const char* defaultStr) -{ - if (!FromJsonStr(str, json, name)) { - str = defaultStr; - } -} - -bool Config::Internal::Utils::FromJsonIPAddress(IPAddress& ip, const cJSON* json, const char* name) -{ - const cJSON* jsonVal = cJSON_GetObjectItemCaseSensitive(json, name); - if (jsonVal == nullptr) { - OS_LOGE(TAG, "value at '%s' is null", name); - return false; - } - - if (cJSON_IsString(jsonVal) == 0) { - OS_LOGE(TAG, "value at '%s' is not a string", name); - return false; - } - - std::string_view view(jsonVal->valuestring); - - if (!OpenShock::IPV4AddressFromStringView(ip, view)) { - OS_LOGE(TAG, "failed to parse IP address at '%s'", name); - return false; - } - - return true; -} - -void Config::Internal::Utils::FromJsonIPAddress(IPAddress& ip, const cJSON* json, const char* name, const IPAddress& defaultIP) -{ - if (!FromJsonIPAddress(ip, json, name)) { - ip = defaultIP; - } -} - -bool Config::Internal::Utils::FromJsonGpioNum(gpio_num_t& val, const cJSON* json, const char* name) -{ - uint8_t u8Val; - if (!FromJsonU8(u8Val, json, name, 0)) { - return false; - } - - return FromU8GpioNum(val, u8Val); -} - -void Config::Internal::Utils::FromJsonGpioNum(gpio_num_t& val, const cJSON* json, const char* name, gpio_num_t defaultVal) -{ - if (!FromJsonGpioNum(val, json, name)) { - val = defaultVal; - } -} diff --git a/src/http/HTTPRequestManager.cpp b/src/http/HTTPRequestManager.cpp deleted file mode 100644 index be18fbae..00000000 --- a/src/http/HTTPRequestManager.cpp +++ /dev/null @@ -1,472 +0,0 @@ -#include "http/HTTPRequestManager.h" - -const char* const TAG = "HTTPRequestManager"; - -#include "Common.h" -#include "Core.h" -#include "Logging.h" -#include "RateLimiter.h" -#include "SimpleMutex.h" -#include "util/HexUtils.h" -#include "util/StringUtils.h" - -#include - -#include -#include -#include -#include -#include - -using namespace std::string_view_literals; - -const std::size_t HTTP_BUFFER_SIZE = 4096LLU; -const int HTTP_DOWNLOAD_SIZE_LIMIT = 200 * 1024 * 1024; // 200 MB - -static OpenShock::SimpleMutex s_rateLimitsMutex = {}; -static std::unordered_map> s_rateLimits = {}; - -using namespace OpenShock; - -static std::string_view getDomainFromURL(std::string_view url) -{ - if (url.empty()) { - return {}; - } - - // Remove the protocol eg. "https://api.example.com:443/path" -> "api.example.com:443/path" - auto seperator = url.find("://"); - if (seperator != std::string_view::npos) { - url = url.substr(seperator + 3); - } - - // Remove the path eg. "api.example.com:443/path" -> "api.example.com:443" - seperator = url.find('/'); - if (seperator != std::string_view::npos) { - url = url.substr(0, seperator); - } - - // Remove the port eg. "api.example.com:443" -> "api.example.com" - seperator = url.rfind(':'); - if (seperator != std::string_view::npos) { - url = url.substr(0, seperator); - } - - // Remove all subdomains eg. "api.example.com" -> "example.com" - seperator = url.rfind('.'); - if (seperator == std::string_view::npos) { - return url; // E.g. "localhost" - } - seperator = url.rfind('.', seperator - 1); - if (seperator != std::string_view::npos) { - url = url.substr(seperator + 1); - } - - return url; -} - -static std::shared_ptr createRateLimiterForDomain(std::string_view domain) -{ - auto rateLimit = std::make_shared(); - - // Add default limits - rateLimit->addLimit(1000, 5); // 5 per second - rateLimit->addLimit(10 * 1000, 10); // 10 per 10 seconds - - // per-domain limits - if (domain == OPENSHOCK_API_DOMAIN) { - rateLimit->addLimit(60 * 1000, 12); // 12 per minute - rateLimit->addLimit(60 * 60 * 1000, 120); // 120 per hour - } - - return rateLimit; -} - -static std::shared_ptr createRateLimiterForURL(std::string_view url) -{ - auto domain = std::string(getDomainFromURL(url)); - if (domain.empty()) { - return nullptr; - } - - OpenShock::ScopedLock lock__(&s_rateLimitsMutex); - - auto it = s_rateLimits.find(domain); - if (it == s_rateLimits.end()) { - it = s_rateLimits.emplace(domain, createRateLimiterForDomain(domain)).first; - } - - return it->second; -} - -struct StreamReaderResult { - HTTP::RequestResult result; - std::size_t nWritten; -}; - -static bool isCRLF(const uint8_t* buffer) -{ - return buffer[0] == '\r' && buffer[1] == '\n'; -} -static bool tryFindCRLF(std::size_t& pos, const uint8_t* buffer, std::size_t len) -{ - const uint8_t* cur = buffer; - const uint8_t* end = buffer + len - 1; - - while (cur < end) { - if (isCRLF(cur)) { - pos = static_cast(cur - buffer); - return true; - } - - ++cur; - } - - return false; -} - -enum class ParserState : uint8_t { - Ok, - NeedMoreData, - Invalid, -}; - -static ParserState tryParseHttpChunkHeader(const uint8_t* buffer, std::size_t bufferLen, std::size_t& headerLen, std::size_t& payloadLen) -{ - if (bufferLen < 5) { // Bare minimum: "0\r\n\r\n" - return ParserState::NeedMoreData; - } - - // Find the first CRLF - if (!tryFindCRLF(headerLen, buffer, bufferLen)) { - return ParserState::NeedMoreData; - } - - // Header must have at least one character - if (headerLen == 0) { - OS_LOGW(TAG, "Invalid chunk header length"); - return ParserState::Invalid; - } - - // Check for end of size field (possibly followed by extensions which is separated by a semicolon) - std::size_t sizeFieldEnd = headerLen; - for (std::size_t i = 0; i < headerLen; ++i) { - if (buffer[i] == ';') { - sizeFieldEnd = i; - break; - } - } - - // Bounds check - if (sizeFieldEnd == 0 || sizeFieldEnd > 16) { - OS_LOGW(TAG, "Invalid chunk size field length"); - return ParserState::Invalid; - } - - std::string_view sizeField(reinterpret_cast(buffer), sizeFieldEnd); - - // Parse the chunk size - if (!HexUtils::TryParseHexToInt(sizeField.data(), sizeField.length(), payloadLen)) { - OS_LOGW(TAG, "Failed to parse chunk size"); - return ParserState::Invalid; - } - - if (payloadLen > HTTP_DOWNLOAD_SIZE_LIMIT) { - OS_LOGW(TAG, "Chunk size too large"); - return ParserState::Invalid; - } - - // Set the header length to the end of the CRLF - headerLen += 2; - - return ParserState::Ok; -} - -static ParserState tryParseHttpChunk(const uint8_t* buffer, std::size_t bufferLen, std::size_t& payloadPos, std::size_t& payloadLen) -{ - if (payloadPos == 0) { - ParserState state = tryParseHttpChunkHeader(buffer, bufferLen, payloadPos, payloadLen); - if (state != ParserState::Ok) { - return state; - } - } - - std::size_t totalLen = payloadPos + payloadLen + 2; // +2 for CRLF - if (bufferLen < totalLen) { - return ParserState::NeedMoreData; - } - - // Check for CRLF - if (!isCRLF(buffer + totalLen - 2)) { - OS_LOGW(TAG, "Invalid chunk payload CRLF"); - return ParserState::Invalid; - } - - return ParserState::Ok; -} - -static void alignHttpChunk(uint8_t* buffer, std::size_t& bufferCursor, std::size_t payloadPos, std::size_t payloadLen) -{ - std::size_t totalLen = payloadPos + payloadLen + 2; // +2 for CRLF - std::size_t remaining = bufferCursor - totalLen; - if (remaining > 0) { - memmove(buffer, buffer + totalLen, remaining); - bufferCursor = remaining; - } else { - bufferCursor = 0; - } -} - -static StreamReaderResult readHttpStreamDataChunked(HTTPClient& client, WiFiClient* stream, HTTP::DownloadCallback downloadCallback, int64_t begin, uint32_t timeoutMs) -{ - std::size_t totalWritten = 0; - HTTP::RequestResult result = HTTP::RequestResult::Success; - - uint8_t* buffer = static_cast(malloc(HTTP_BUFFER_SIZE)); - if (buffer == nullptr) { - OS_LOGE(TAG, "Out of memory"); - return {HTTP::RequestResult::RequestFailed, 0}; - } - - ParserState state = ParserState::NeedMoreData; - std::size_t bufferCursor = 0, payloadPos = 0, payloadSize = 0; - - while (client.connected() && state != ParserState::Invalid) { - if (begin + timeoutMs < OpenShock::millis()) { - OS_LOGW(TAG, "Request timed out"); - result = HTTP::RequestResult::TimedOut; - break; - } - - std::size_t bytesAvailable = stream->available(); - if (bytesAvailable == 0) { - vTaskDelay(pdMS_TO_TICKS(5)); - continue; - } - - std::size_t bytesRead = stream->readBytes(buffer + bufferCursor, HTTP_BUFFER_SIZE - bufferCursor); - if (bytesRead == 0) { - OS_LOGW(TAG, "No bytes read"); - result = HTTP::RequestResult::RequestFailed; - break; - } - - bufferCursor += bytesRead; - - while (bufferCursor > 0) { - state = tryParseHttpChunk(buffer, bufferCursor, payloadPos, payloadSize); - if (state == ParserState::Invalid) { - OS_LOGE(TAG, "Failed to parse chunk"); - result = HTTP::RequestResult::RequestFailed; - state = ParserState::Invalid; // Mark to exit both loops - break; - } - OS_LOGD(TAG, "Chunk parsed: %zu %zu", payloadPos, payloadSize); - - if (state == ParserState::NeedMoreData) { - if (bufferCursor == HTTP_BUFFER_SIZE) { - OS_LOGE(TAG, "Chunk too large"); - result = HTTP::RequestResult::RequestFailed; - state = ParserState::Invalid; // Mark to exit both loops - } - break; // If chunk size good, this only exits one loop - } - - // Check for zero chunk size (end of transfer) - if (payloadSize == 0) { - state = ParserState::Invalid; // Mark to exit both loops - break; - } - - if (!downloadCallback(totalWritten, buffer + payloadPos, payloadSize)) { - result = HTTP::RequestResult::Cancelled; - state = ParserState::Invalid; // Mark to exit both loops - break; - } - - totalWritten += payloadSize; - alignHttpChunk(buffer, bufferCursor, payloadPos, payloadSize); - payloadSize = 0; - payloadPos = 0; - } - - if (state == ParserState::NeedMoreData) { - vTaskDelay(pdMS_TO_TICKS(5)); - } - } - - free(buffer); - - return {result, totalWritten}; -} - -static StreamReaderResult readHttpStreamData(HTTPClient& client, WiFiClient* stream, std::size_t contentLength, HTTP::DownloadCallback downloadCallback, int64_t begin, uint32_t timeoutMs) -{ - std::size_t nWritten = 0; - HTTP::RequestResult result = HTTP::RequestResult::Success; - - uint8_t* buffer = static_cast(malloc(HTTP_BUFFER_SIZE)); - if (buffer == nullptr) { - OS_LOGE(TAG, "Failed to allocate HTTP buffer"); - return {HTTP::RequestResult::InternalError, 0}; - } - - while (client.connected() && nWritten < contentLength) { - if (begin + timeoutMs < OpenShock::millis()) { - OS_LOGW(TAG, "Request timed out"); - result = HTTP::RequestResult::TimedOut; - break; - } - - std::size_t bytesAvailable = stream->available(); - if (bytesAvailable == 0) { - vTaskDelay(pdMS_TO_TICKS(5)); - continue; - } - - std::size_t bytesToRead = std::min(bytesAvailable, HTTP_BUFFER_SIZE); - - std::size_t bytesRead = stream->readBytes(buffer, bytesToRead); - if (bytesRead == 0) { - OS_LOGW(TAG, "No bytes read"); - result = HTTP::RequestResult::RequestFailed; - break; - } - - if (!downloadCallback(nWritten, buffer, bytesRead)) { - OS_LOGW(TAG, "Request cancelled by callback"); - result = HTTP::RequestResult::Cancelled; - break; - } - - nWritten += bytesRead; - - vTaskDelay(pdMS_TO_TICKS(10)); - } - - free(buffer); - - return {result, nWritten}; -} - -HTTP::Response - HTTP::Download(std::string_view url, const std::map& headers, HTTP::GotContentLengthCallback contentLengthCallback, HTTP::DownloadCallback downloadCallback, tcb::span acceptedCodes, uint32_t timeoutMs) -{ - std::shared_ptr rateLimiter = createRateLimiterForURL(url); - if (rateLimiter == nullptr) { - return {RequestResult::InvalidURL, 0, 0}; - } - - if (!rateLimiter->tryRequest()) { - return {RequestResult::RateLimited, 0, 0}; - } - - HTTPClient client; - client.setUserAgent(OpenShock::Constants::FW_USERAGENT); - - int64_t begin = OpenShock::millis(); - - // This method is horribly named, if you call the begin() method with one String parameter its HTTP, but the one with (String, const char*) is HTTPS. - // We pass null here for CAcert parameter to remove erroneous "unexpected protocol: https, expected http" warning, this is what begin(String) does as a fallback. - // This is yet another example of why we need to get rid of Arduino dependency lol - if (!client.begin(OpenShock::StringToArduinoString(url), nullptr)) { - OS_LOGE(TAG, "Failed to begin HTTP request"); - return {HTTP::RequestResult::RequestFailed, 0, 0}; - } - - for (auto& header : headers) { - client.addHeader(header.first, header.second); - } - - int responseCode = client.GET(); - - if (responseCode == HTTP_CODE_REQUEST_TIMEOUT || begin + timeoutMs < OpenShock::millis()) { - OS_LOGW(TAG, "Request timed out"); - return {HTTP::RequestResult::TimedOut, responseCode, 0}; - } - - if (responseCode == HTTP_CODE_TOO_MANY_REQUESTS) { - // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After - - // Get "Retry-After" header - String retryAfterStr = client.header("Retry-After"); - - // Try to parse it as an integer (delay-seconds) - long retryAfter = 0; - if (retryAfterStr.length() > 0 && std::all_of(retryAfterStr.begin(), retryAfterStr.end(), isdigit)) { - retryAfter = retryAfterStr.toInt(); - } - - // If header missing/unparseable, default to 15 seconds - if (retryAfter <= 0) { - retryAfter = 15; - } - - // Apply the block-for time - rateLimiter->blockFor(retryAfter * 1000); - - return {HTTP::RequestResult::RateLimited, responseCode, 0}; - } - - if (responseCode == 418) { - OS_LOGW(TAG, "The server refused to brew coffee because it is, permanently, a teapot."); - } - - if (std::find(acceptedCodes.begin(), acceptedCodes.end(), responseCode) == acceptedCodes.end()) { - OS_LOGD(TAG, "Received unexpected response code %d", responseCode); - return {HTTP::RequestResult::CodeRejected, responseCode, 0}; - } - - int contentLength = client.getSize(); - if (contentLength == 0) { - return {HTTP::RequestResult::Success, responseCode, 0}; - } - - if (contentLength > 0) { - if (contentLength > HTTP_DOWNLOAD_SIZE_LIMIT) { - OS_LOGE(TAG, "Content-Length too large"); - return {HTTP::RequestResult::RequestFailed, responseCode, 0}; - } - - if (!contentLengthCallback(contentLength)) { - OS_LOGW(TAG, "Request cancelled by callback"); - return {HTTP::RequestResult::Cancelled, responseCode, 0}; - } - } - - WiFiClient* stream = client.getStreamPtr(); - if (stream == nullptr) { - OS_LOGE(TAG, "Failed to get stream"); - return {HTTP::RequestResult::RequestFailed, 0, 0}; - } - - StreamReaderResult result; - if (contentLength > 0) { - result = readHttpStreamData(client, stream, contentLength, downloadCallback, begin, timeoutMs); - } else { - result = readHttpStreamDataChunked(client, stream, downloadCallback, begin, timeoutMs); - } - - return {result.result, responseCode, result.nWritten}; -} - -HTTP::Response HTTP::GetString(std::string_view url, const std::map& headers, tcb::span acceptedCodes, uint32_t timeoutMs) -{ - std::string result; - - auto allocator = [&result](std::size_t contentLength) { - result.reserve(contentLength); - return true; - }; - auto writer = [&result](std::size_t offset, const uint8_t* data, std::size_t len) { - result.append(reinterpret_cast(data), len); - return true; - }; - - auto response = Download(url, headers, allocator, writer, acceptedCodes, timeoutMs); - if (response.result != RequestResult::Success) { - return {response.result, response.code, {}}; - } - - return {response.result, response.code, result}; -} diff --git a/src/http/JsonAPI.cpp b/src/http/JsonAPI.cpp deleted file mode 100644 index e2ab0610..00000000 --- a/src/http/JsonAPI.cpp +++ /dev/null @@ -1,84 +0,0 @@ -#include "http/JsonAPI.h" - -const char* const TAG = "JsonAPI"; - -#include "Common.h" -#include "Logging.h" -#include "config/Config.h" -#include "util/StringUtils.h" - -using namespace OpenShock; - -HTTP::Response HTTP::JsonAPI::LinkAccount(std::string_view accountLinkCode) -{ - std::string domain; - if (!Config::GetBackendDomain(domain)) { - return {HTTP::RequestResult::InternalError, 0, {}}; - } - - char uri[OPENSHOCK_URI_BUFFER_SIZE]; - int written = snprintf(uri, sizeof(uri), "https://%s/1/device/pair/%.*s", domain.c_str(), static_cast(accountLinkCode.length()), accountLinkCode.data()); - if (written < 0 || static_cast(written) >= sizeof(uri)) { - OS_LOGE(TAG, "URI truncated for LinkAccount"); - return {HTTP::RequestResult::InternalError, 0, {}}; - } - - return HTTP::GetJSON( - uri, - { - {"Accept", "application/json"} - }, - Serialization::JsonAPI::ParseAccountLinkJsonResponse, - std::array {200} - ); -} - -HTTP::Response HTTP::JsonAPI::GetHubInfo(std::string_view hubToken) -{ - std::string domain; - if (!Config::GetBackendDomain(domain)) { - return {HTTP::RequestResult::InternalError, 0, {}}; - } - - char uri[OPENSHOCK_URI_BUFFER_SIZE]; - int written = snprintf(uri, sizeof(uri), "https://%s/1/device/self", domain.c_str()); - if (written < 0 || static_cast(written) >= sizeof(uri)) { - OS_LOGE(TAG, "URI truncated for GetHubInfo"); - return {HTTP::RequestResult::InternalError, 0, {}}; - } - - return HTTP::GetJSON( - uri, - { - { "Accept", "application/json"}, - {"DeviceToken", OpenShock::StringToArduinoString(hubToken)} - }, - Serialization::JsonAPI::ParseHubInfoJsonResponse, - std::array {200} - ); -} - -HTTP::Response HTTP::JsonAPI::AssignLcg(std::string_view hubToken) -{ - std::string domain; - if (!Config::GetBackendDomain(domain)) { - return {HTTP::RequestResult::InternalError, 0, {}}; - } - - char uri[OPENSHOCK_URI_BUFFER_SIZE]; - int written = snprintf(uri, sizeof(uri), "https://%s/2/device/assignLCG?version=2", domain.c_str()); - if (written < 0 || static_cast(written) >= sizeof(uri)) { - OS_LOGE(TAG, "URI truncated for AssignLcg"); - return {HTTP::RequestResult::InternalError, 0, {}}; - } - - return HTTP::GetJSON( - uri, - { - { "Accept", "application/json"}, - {"DeviceToken", OpenShock::StringToArduinoString(hubToken)} - }, - Serialization::JsonAPI::ParseAssignLcgJsonResponse, - std::array {200} - ); -} diff --git a/src/radio/rmt/Sequence.cpp b/src/radio/rmt/Sequence.cpp deleted file mode 100644 index 9d952cc8..00000000 --- a/src/radio/rmt/Sequence.cpp +++ /dev/null @@ -1,77 +0,0 @@ -#include "radio/rmt/Sequence.h" - -const char* const TAG = "Sequence"; - -#include "Logging.h" -#include "radio/rmt/CaiXianlinEncoder.h" -#include "radio/rmt/D80Encoder.h" -#include "radio/rmt/Petrainer998DREncoder.h" -#include "radio/rmt/PetrainerEncoder.h" -#include "radio/rmt/T330Encoder.h" - -using namespace OpenShock; - -inline static size_t getSequenceBufferSize(ShockerModelType shockerModelType) -{ - switch (shockerModelType) { - case ShockerModelType::CaiXianlin: - return Rmt::CaiXianlinEncoder::GetBufferSize(); - case ShockerModelType::Petrainer998DR: - return Rmt::Petrainer998DREncoder::GetBufferSize(); - case ShockerModelType::Petrainer: - return Rmt::PetrainerEncoder::GetBufferSize(); - case ShockerModelType::WellturnT330: - return Rmt::WellturnT330Encoder::GetBufferSize(); - case ShockerModelType::D80: - return Rmt::D80Encoder::GetBufferSize(); - default: - return 0; - } -} - -inline static bool fillSequenceImpl(rmt_data_t* data, ShockerModelType modelType, uint16_t shockerId, ShockerCommandType commandType, uint8_t intensity) -{ - switch (modelType) { - case ShockerModelType::CaiXianlin: - return Rmt::CaiXianlinEncoder::FillBuffer(data, shockerId, 0, commandType, intensity); - case ShockerModelType::Petrainer: - return Rmt::PetrainerEncoder::FillBuffer(data, shockerId, commandType, intensity); - case ShockerModelType::Petrainer998DR: - return Rmt::Petrainer998DREncoder::FillBuffer(data, shockerId, commandType, intensity); - case ShockerModelType::WellturnT330: - return Rmt::WellturnT330Encoder::FillBuffer(data, shockerId, commandType, intensity); - case ShockerModelType::D80: - return Rmt::D80Encoder::FillBuffer(data, shockerId, commandType, intensity); - default: - OS_LOGE(TAG, "Unknown shocker model: %u", modelType); - return false; - } -} - -Rmt::Sequence::Sequence(ShockerModelType shockerModel, uint16_t shockerId, int64_t transmitEnd) - : m_data(nullptr) - , m_size(getSequenceBufferSize(shockerModel)) - , m_transmitEnd(transmitEnd) - , m_shockerId(shockerId) - , m_shockerModel(shockerModel) -{ - if (m_size == 0) return; - - m_data = static_cast(malloc(m_size * 2 * sizeof(rmt_data_t))); - if (m_data == nullptr) { - m_size = 0; - return; - } - - if (!fillSequenceImpl(terminator(), m_shockerModel, m_shockerId, ShockerCommandType::Vibrate, 0)) { - free(m_data); - m_data = nullptr; - m_size = 0; - return; - } -} - -bool Rmt::Sequence::fill(ShockerCommandType commandType, uint8_t intensity) -{ - return fillSequenceImpl(payload(), m_shockerModel, m_shockerId, commandType, intensity); -} diff --git a/src/serial/command_handlers/factoryreset.cpp b/src/serial/command_handlers/factoryreset.cpp deleted file mode 100644 index e76226e3..00000000 --- a/src/serial/command_handlers/factoryreset.cpp +++ /dev/null @@ -1,25 +0,0 @@ -#include "serial/command_handlers/common.h" -#include "serial/SerialInputHandler.h" - -#include "config/Config.h" - -#include - -static void handleFactoryResetCommand(std::string_view arg, bool isAutomated) -{ - (void)arg; - - OS_SERIAL_PRINTLN("Resetting to factory defaults..."); - OpenShock::Config::FactoryReset(); - OS_SERIAL_PRINTLN("Restarting..."); - esp_restart(); -} - -OpenShock::Serial::CommandGroup OpenShock::Serial::CommandHandlers::FactoryResetHandler() -{ - auto group = OpenShock::Serial::CommandGroup("factoryreset"sv); - - auto& cmd = group.addCommand("Reset the hub to factory defaults and restart"sv, handleFactoryResetCommand); - - return group; -} diff --git a/src/serial/command_handlers/hostname.cpp b/src/serial/command_handlers/hostname.cpp deleted file mode 100644 index 976993c8..00000000 --- a/src/serial/command_handlers/hostname.cpp +++ /dev/null @@ -1,43 +0,0 @@ -#include "serial/command_handlers/common.h" - -#include "config/Config.h" - -#include - -#include - -const char* const TAG = "Serial::CommandHandlers::Domain"; - -static void handleHostnameCommand(std::string_view arg, bool isAutomated) -{ - if (arg.empty()) { - std::string hostname; - if (!OpenShock::Config::GetWiFiHostname(hostname)) { - SERPR_ERROR("Failed to get hostname from config"); - return; - } - // Get hostname - SERPR_RESPONSE("Hostname|%s", hostname.c_str()); - return; - } - - bool result = OpenShock::Config::SetWiFiHostname(std::string(arg)); - if (result) { - SERPR_SUCCESS("Saved config, restarting..."); - esp_restart(); - } else { - SERPR_ERROR("Failed to save config"); - } -} - -OpenShock::Serial::CommandGroup OpenShock::Serial::CommandHandlers::HostnameHandler() -{ - auto group = OpenShock::Serial::CommandGroup("hostname"sv); - - auto& getCommand = group.addCommand("Get the network hostname."sv, handleHostnameCommand); - - auto& setCommand = group.addCommand("Set the network hostname."sv, handleHostnameCommand); - setCommand.addArgument("hostname"sv, "must be a string"sv, "OpenShock"sv); - - return group; -} diff --git a/src/serial/command_handlers/networks.cpp b/src/serial/command_handlers/networks.cpp deleted file mode 100644 index 4ac01233..00000000 --- a/src/serial/command_handlers/networks.cpp +++ /dev/null @@ -1,108 +0,0 @@ -#include "serial/command_handlers/common.h" - -#include "config/Config.h" -#include "wifi/WiFiManager.h" - -#include - -#include - -const char* const TAG = "Serial::CommandHandlers::Networks"; - -static void handleNetworksCommand(std::string_view arg, bool isAutomated) -{ - cJSON* root; - - if (arg.empty()) { - root = cJSON_CreateArray(); - if (root == nullptr) { - SERPR_ERROR("Failed to create JSON array"); - return; - } - - if (!OpenShock::Config::GetWiFiCredentials(root, true)) { - SERPR_ERROR("Failed to get WiFi credentials from config"); - cJSON_Delete(root); - return; - } - - char* out = cJSON_PrintUnformatted(root); - cJSON_Delete(root); - if (out == nullptr) { - SERPR_ERROR("Failed to print JSON"); - return; - } - - SERPR_RESPONSE("Networks|%s", out); - - cJSON_free(out); - return; - } - - root = cJSON_ParseWithLength(arg.data(), arg.length()); - if (root == nullptr) { - SERPR_ERROR("Failed to parse JSON: %s", cJSON_GetErrorPtr()); - return; - } - - if (cJSON_IsArray(root) == 0) { - SERPR_ERROR("Invalid argument (not an array)"); - cJSON_Delete(root); - return; - } - - std::vector creds; - - uint8_t id = 1; - cJSON* network = nullptr; - cJSON_ArrayForEach(network, root) - { - OpenShock::Config::WiFiCredentials cred; - - if (!cred.FromJSON(network)) { - SERPR_ERROR("Failed to parse network"); - cJSON_Delete(root); - return; - } - - if (cred.id == 0) { - cred.id = id++; - } - - OS_LOGI(TAG, "Adding network \"%s\" to config, id=%u", cred.ssid.c_str(), cred.id); - - creds.push_back(std::move(cred)); - } - - cJSON_Delete(root); - - if (!OpenShock::Config::SetWiFiCredentials(creds)) { - SERPR_ERROR("Failed to save config"); - return; - } - - SERPR_SUCCESS("Saved config"); - - OpenShock::WiFiManager::RefreshNetworkCredentials(); -} - -OpenShock::Serial::CommandGroup OpenShock::Serial::CommandHandlers::NetworksHandler() -{ - auto group = OpenShock::Serial::CommandGroup("networks"sv); - - auto& getCommand = group.addCommand("Get all saved networks."sv, handleNetworksCommand); - - auto& setCommand = group.addCommand("Set all saved networks."sv, handleNetworksCommand); - setCommand.addArgument( - "json"sv, - "must be a array of objects with the following fields:"sv, - "[{\"ssid\":\"myssid\",\"password\":\"mypassword\"}]"sv, - { - "ssid (string) SSID of the network"sv, - "password (string) Password of the network"sv, - "id (number) ID of the network (optional)"sv, - } - ); - - return group; -} diff --git a/src/serial/command_handlers/restart.cpp b/src/serial/command_handlers/restart.cpp deleted file mode 100644 index 209ce8ab..00000000 --- a/src/serial/command_handlers/restart.cpp +++ /dev/null @@ -1,21 +0,0 @@ -#include "serial/command_handlers/common.h" -#include "serial/SerialInputHandler.h" - -#include - -static void handleRestartCommand(std::string_view arg, bool isAutomated) -{ - (void)arg; - - OS_SERIAL_PRINTLN("Restarting ESP..."); - esp_restart(); -} - -OpenShock::Serial::CommandGroup OpenShock::Serial::CommandHandlers::RestartHandler() -{ - auto group = OpenShock::Serial::CommandGroup("restart"sv); - - auto& cmd = group.addCommand("Restart the board"sv, handleRestartCommand); - - return group; -} diff --git a/src/serial/command_handlers/version.cpp b/src/serial/command_handlers/version.cpp deleted file mode 100644 index 53e72798..00000000 --- a/src/serial/command_handlers/version.cpp +++ /dev/null @@ -1,22 +0,0 @@ -#include "serial/command_handlers/common.h" - -#include "serial/SerialInputHandler.h" - -#include - -static void handleVersionCommand(std::string_view arg, bool isAutomated) -{ - (void)arg; - - OS_SERIAL_PRINTLN(); - OpenShock::SerialInputHandler::PrintVersionInfo(); -} - -OpenShock::Serial::CommandGroup OpenShock::Serial::CommandHandlers::VersionHandler() -{ - auto group = OpenShock::Serial::CommandGroup("version"sv); - - auto cmd = group.addCommand("Print version information"sv, handleVersionCommand); - - return group; -} diff --git a/src/serialization/JsonAPI.cpp b/src/serialization/JsonAPI.cpp deleted file mode 100644 index 6f0e38f5..00000000 --- a/src/serialization/JsonAPI.cpp +++ /dev/null @@ -1,258 +0,0 @@ -#include "serialization/JsonAPI.h" - -const char* const TAG = "JsonAPI"; - -#include "Logging.h" - -#define ESP_LOGJSONE(err, root) \ - { \ - char* _jsonStr = cJSON_PrintUnformatted(root); \ - OS_LOGE(TAG, "Invalid JSON response (" err "): %s", _jsonStr ? _jsonStr : ""); \ - cJSON_free(_jsonStr); \ - } - -using namespace OpenShock::Serialization; - -bool JsonAPI::ParseLcgInstanceDetailsJsonResponse(int code, const cJSON* root, JsonAPI::LcgInstanceDetailsResponse& out) -{ - (void)code; - - if (cJSON_IsObject(root) == 0) { - ESP_LOGJSONE("not an object", root); - return false; - } - - out = {}; - - const cJSON* name = cJSON_GetObjectItemCaseSensitive(root, "name"); - if (cJSON_IsString(name) == 0) { - ESP_LOGJSONE("value at 'data.name' is not a string", root); - return false; - } - - const cJSON* version = cJSON_GetObjectItemCaseSensitive(root, "version"); - if (cJSON_IsString(version) == 0) { - ESP_LOGJSONE("value at 'data.version' is not a string", root); - return false; - } - - const cJSON* currentTime = cJSON_GetObjectItemCaseSensitive(root, "currentTime"); - if (cJSON_IsString(currentTime) == 0) { - ESP_LOGJSONE("value at 'data.currentTime' is not a string", root); - return false; - } - - const cJSON* countryCode = cJSON_GetObjectItemCaseSensitive(root, "countryCode"); - if (cJSON_IsString(countryCode) == 0) { - ESP_LOGJSONE("value at 'data.countryCode' is not a string", root); - return false; - } - - const cJSON* fqdn = cJSON_GetObjectItemCaseSensitive(root, "fqdn"); - if (cJSON_IsString(fqdn) == 0) { - ESP_LOGJSONE("value at 'data.fqdn' is not a string", root); - return false; - } - - out.name = name->valuestring; - out.version = version->valuestring; - out.currentTime = currentTime->valuestring; - out.countryCode = countryCode->valuestring; - out.fqdn = fqdn->valuestring; - - return true; -} -bool JsonAPI::ParseBackendVersionJsonResponse(int code, const cJSON* root, JsonAPI::BackendVersionResponse& out) -{ - (void)code; - - if (cJSON_IsObject(root) == 0) { - ESP_LOGJSONE("not an object", root); - return false; - } - - const cJSON* data = cJSON_GetObjectItemCaseSensitive(root, "data"); - if (cJSON_IsObject(data) == 0) { - ESP_LOGJSONE("value at 'data' is not an object", root); - return false; - } - - out = {}; - - const cJSON* version = cJSON_GetObjectItemCaseSensitive(data, "version"); - if (cJSON_IsString(version) == 0) { - ESP_LOGJSONE("value at 'data.version' is not a string", root); - return false; - } - - const cJSON* commit = cJSON_GetObjectItemCaseSensitive(data, "commit"); - if (cJSON_IsString(commit) == 0) { - ESP_LOGJSONE("value at 'data.commit' is not a string", root); - return false; - } - - const cJSON* currentTime = cJSON_GetObjectItemCaseSensitive(data, "currentTime"); - if (cJSON_IsString(currentTime) == 0) { - ESP_LOGJSONE("value at 'data.currentTime' is not a string", root); - return false; - } - - out.version = version->valuestring; - out.commit = commit->valuestring; - out.currentTime = currentTime->valuestring; - - return true; -} - -bool JsonAPI::ParseAccountLinkJsonResponse(int code, const cJSON* root, JsonAPI::AccountLinkResponse& out) -{ - (void)code; - - if (cJSON_IsObject(root) == 0) { - ESP_LOGJSONE("not an object", root); - return false; - } - - const cJSON* data = cJSON_GetObjectItemCaseSensitive(root, "data"); - if (cJSON_IsString(data) == 0) { - ESP_LOGJSONE("value at 'data' is not a string", root); - return false; - } - - out = {}; - - out.authToken = data->valuestring; - - return true; -} -bool JsonAPI::ParseHubInfoJsonResponse(int code, const cJSON* root, JsonAPI::HubInfoResponse& out) -{ - (void)code; - - if (cJSON_IsObject(root) == 0) { - ESP_LOGJSONE("not an object", root); - return false; - } - - const cJSON* data = cJSON_GetObjectItemCaseSensitive(root, "data"); - if (cJSON_IsObject(data) == 0) { - ESP_LOGJSONE("value at 'data' is not an object", root); - return false; - } - - const cJSON* hubId = cJSON_GetObjectItemCaseSensitive(data, "id"); - if (cJSON_IsString(hubId) == 0) { - ESP_LOGJSONE("value at 'data.id' is not a string", root); - return false; - } - - const cJSON* hubName = cJSON_GetObjectItemCaseSensitive(data, "name"); - if (cJSON_IsString(hubName) == 0) { - ESP_LOGJSONE("value at 'data.name' is not a string", root); - return false; - } - - const cJSON* hubShockers = cJSON_GetObjectItemCaseSensitive(data, "shockers"); - if (cJSON_IsArray(hubShockers) == 0) { - ESP_LOGJSONE("value at 'data.shockers' is not an array", root); - return false; - } - - out = {}; - - out.hubId = hubId->valuestring; - out.hubName = hubName->valuestring; - - if (out.hubId.empty() || out.hubName.empty()) { - ESP_LOGJSONE("value at 'data.id' or 'data.name' is empty", root); - return false; - } - - cJSON* shocker = nullptr; - cJSON_ArrayForEach(shocker, hubShockers) - { - const cJSON* shockerId = cJSON_GetObjectItemCaseSensitive(shocker, "id"); - if (cJSON_IsString(shockerId) == 0) { - ESP_LOGJSONE("value at 'shocker.id' is not a string", shocker); - return false; - } - const char* shockerIdStr = shockerId->valuestring; - - if (shockerIdStr == nullptr || shockerIdStr[0] == '\0') { - ESP_LOGJSONE("value at 'shocker.id' is empty", shocker); - return false; - } - - const cJSON* shockerRfId = cJSON_GetObjectItemCaseSensitive(shocker, "rfId"); - if (cJSON_IsNumber(shockerRfId) == 0) { - ESP_LOGJSONE("value at 'shocker.rfId' is not a number", shocker); - return false; - } - int shockerRfIdInt = shockerRfId->valueint; - if (shockerRfIdInt < 0 || shockerRfIdInt > UINT16_MAX) { - ESP_LOGJSONE("value at 'shocker.rfId' is not a valid uint16_t", shocker); - return false; - } - uint16_t shockerRfIdU16 = static_cast(shockerRfIdInt); - - const cJSON* shockerModel = cJSON_GetObjectItemCaseSensitive(shocker, "model"); - if (cJSON_IsString(shockerModel) == 0) { - ESP_LOGJSONE("value at 'shocker.model' is not a string", shocker); - return false; - } - const char* shockerModelStr = shockerModel->valuestring; - - if (shockerModelStr == nullptr || shockerModelStr[0] == '\0') { - ESP_LOGJSONE("value at 'shocker.model' is empty", shocker); - return false; - } - - OpenShock::ShockerModelType shockerModelType; - if (!OpenShock::ShockerModelTypeFromString(shockerModelStr, shockerModelType, true)) { // PetTrainer is a typo in the API, we pass true to allow it - ESP_LOGJSONE("value at 'shocker.model' is not a valid shocker model", shocker); - return false; - } - - out.shockers.push_back({.id = shockerIdStr, .rfId = shockerRfIdU16, .model = shockerModelType}); - } - - return true; -} -bool JsonAPI::ParseAssignLcgJsonResponse(int code, const cJSON* root, JsonAPI::AssignLcgResponse& out) -{ - (void)code; - - if (cJSON_IsObject(root) == 0) { - ESP_LOGJSONE("not an object", root); - return false; - } - - const cJSON* host = cJSON_GetObjectItemCaseSensitive(root, "host"); - const cJSON* port = cJSON_GetObjectItemCaseSensitive(root, "port"); - const cJSON* path = cJSON_GetObjectItemCaseSensitive(root, "path"); - const cJSON* country = cJSON_GetObjectItemCaseSensitive(root, "country"); - - if (cJSON_IsString(host) == 0 || cJSON_IsString(path) == 0 || cJSON_IsString(country) == 0) { - ESP_LOGJSONE("value at 'host', 'path' or 'country' is not a string", root); - return false; - } - if (cJSON_IsNumber(port) == 0) { - ESP_LOGJSONE("value at 'port' is not a number", root); - return false; - } - - int portInt = port->valueint; - if (portInt < 0 || portInt > UINT16_MAX) { - ESP_LOGJSONE("value at 'port' is outside UINT16 bounds", root); - return false; - } - - out = {}; - - out.host = host->valuestring; - out.port = static_cast(portInt); - out.path = path->valuestring; - out.country = country->valuestring; - - return true; -} diff --git a/src/util/IPAddressUtils.cpp b/src/util/IPAddressUtils.cpp deleted file mode 100644 index 50b9010d..00000000 --- a/src/util/IPAddressUtils.cpp +++ /dev/null @@ -1,27 +0,0 @@ -#include "util/IPAddressUtils.h" - -#include "Convert.h" -#include "util/StringUtils.h" - -const char* const TAG = "Util::IPAddressUtils"; - -bool OpenShock::IPV4AddressFromStringView(IPAddress& ip, std::string_view sv) -{ - if (sv.empty()) { - return false; - } - - std::string_view parts[4]; - if (!OpenShock::TryStringSplit(sv, '.', parts)) { - return false; // Must have 4 octets - } - - std::uint8_t octets[4]; - if (!Convert::ToUint8(parts[0], octets[0]) || !Convert::ToUint8(parts[1], octets[1]) || !Convert::ToUint8(parts[2], octets[2]) || !Convert::ToUint8(parts[3], octets[3])) { - return false; - } - - ip = IPAddress(octets); - - return true; -} diff --git a/src/visual/MonoLedDriver.cpp b/src/visual/MonoLedDriver.cpp deleted file mode 100644 index d5e6f6d1..00000000 --- a/src/visual/MonoLedDriver.cpp +++ /dev/null @@ -1,145 +0,0 @@ -#include - -#include "visual/MonoLedDriver.h" - -const char* const TAG = "MonoLedDriver"; - -#include "Chipset.h" -#include "Logging.h" -#include "util/FnProxy.h" -#include "util/TaskUtils.h" - -#include - -#define OS_LEDC_TIMER LEDC_TIMER_0 -#ifdef SOC_LEDC_SUPPORT_HS_MODE -#define OS_LEDC_SPEED LEDC_HIGH_SPEED_MODE -#else -#define OS_LEDC_SPEED LEDC_LOW_SPEED_MODE -#endif -#define OS_LEDC_CHANNEL LEDC_CHANNEL_0 -#define OS_LEDC_RESOLUTION LEDC_TIMER_8_BIT -#define OS_LEDC_FREQUENCY 4000 // https://en.wikipedia.org/wiki/Flicker_fusion_threshold - -using namespace OpenShock; - -MonoLedDriver::MonoLedDriver(gpio_num_t gpioPin) - : m_gpioPin(GPIO_NUM_NC) - , m_brightness(255) - , m_pattern() - , m_taskHandle(nullptr) - , m_taskMutex() -{ - if (gpioPin == GPIO_NUM_NC) { - OS_LOGE(TAG, "Pin is not set"); - return; - } - - if (!IsValidOutputPin(gpioPin)) { - OS_LOGE(TAG, "Pin %d is not a valid output pin", gpioPin); - return; - } - - ledc_timer_config_t ledc_config = { - .speed_mode = OS_LEDC_SPEED, - .duty_resolution = OS_LEDC_RESOLUTION, - .timer_num = OS_LEDC_TIMER, - .freq_hz = OS_LEDC_FREQUENCY, - .clk_cfg = LEDC_AUTO_CLK, - }; - - ledc_timer_config(&ledc_config); // TODO: Error handling - - ledc_channel_config_t ledc_channel = { - .gpio_num = gpioPin, - .speed_mode = OS_LEDC_SPEED, - .channel = OS_LEDC_CHANNEL, - .intr_type = LEDC_INTR_DISABLE, - .timer_sel = OS_LEDC_TIMER, - .duty = 0, - .hpoint = 0, - }; - - ledc_channel_config(&ledc_channel); // TODO: Error handling - - m_gpioPin = gpioPin; -} - -MonoLedDriver::~MonoLedDriver() -{ - ClearPattern(); - - ledc_stop(OS_LEDC_SPEED, OS_LEDC_CHANNEL, 0); // TODO: Error handling -} - -void MonoLedDriver::SetPattern(const State* pattern, std::size_t patternLength) -{ - m_taskMutex.lock(portMAX_DELAY); - - ClearPatternInternal(); - - // Set new values - m_pattern.resize(patternLength); - std::copy(pattern, pattern + patternLength, m_pattern.begin()); - - char name[32]; - snprintf(name, sizeof(name), "MonoLedDriver-%d", m_gpioPin); - - // Start the task - m_stopRequested.store(false, std::memory_order_relaxed); - BaseType_t result = TaskUtils::TaskCreateUniversal(Util::FnProxy<&MonoLedDriver::RunPattern>, name, 1024, this, 1, &m_taskHandle, 1); // PROFILED: 0.5KB stack usage - if (result != pdPASS) { - OS_LOGE(TAG, "[pin-%d] Failed to create task: %d", m_gpioPin, result); - - m_taskHandle = nullptr; - m_pattern.clear(); - } - - m_taskMutex.unlock(); -} - -void MonoLedDriver::ClearPattern() -{ - m_taskMutex.lock(portMAX_DELAY); - - ClearPatternInternal(); - - m_taskMutex.unlock(); -} - -void MonoLedDriver::SetBrightness(uint8_t brightness) -{ - m_brightness = brightness; -} - -void MonoLedDriver::ClearPatternInternal() -{ - if (m_taskHandle != nullptr) { - m_stopRequested.store(true, std::memory_order_relaxed); - TaskUtils::StopTask(m_taskHandle, TAG, "MonoLedDriver task"); - m_taskHandle = nullptr; - } - - m_pattern.clear(); -} - -void MonoLedDriver::RunPattern() -{ - while (!m_stopRequested.load(std::memory_order_relaxed)) { - for (const auto& state : m_pattern) { - if (m_stopRequested.load(std::memory_order_relaxed)) break; - ledc_set_duty(OS_LEDC_SPEED, OS_LEDC_CHANNEL, state.level ? m_brightness : 0); - ledc_update_duty(OS_LEDC_SPEED, OS_LEDC_CHANNEL); - - // Chunked delay so cooperative shutdown can interrupt long waits - uint32_t remaining = state.duration; - while (remaining > 0 && !m_stopRequested.load(std::memory_order_relaxed)) { - uint32_t chunk = remaining > 50 ? 50 : remaining; - vTaskDelay(pdMS_TO_TICKS(chunk)); - remaining -= chunk; - } - } - } - - vTaskDelete(nullptr); -} diff --git a/src/visual/RgbLedDriver.cpp b/src/visual/RgbLedDriver.cpp deleted file mode 100644 index f2f3ea79..00000000 --- a/src/visual/RgbLedDriver.cpp +++ /dev/null @@ -1,156 +0,0 @@ -#include - -#include "visual/RgbLedDriver.h" - -const char* const TAG = "RGBLedDriver"; - -#include "Chipset.h" -#include "Logging.h" -#include "util/FnProxy.h" -#include "util/TaskUtils.h" - -#include - -using namespace OpenShock; - -// Currently this assumes a single WS2812B LED -// TODO: Support multiple LEDs ? -// TODO: Support other LED types ? - -RgbLedDriver::RgbLedDriver(gpio_num_t gpioPin) - : m_gpioPin(GPIO_NUM_NC) - , m_brightness(255) - , m_pattern() - , m_rmtHandle(nullptr) - , m_taskHandle(nullptr) - , m_taskMutex() -{ - if (gpioPin == GPIO_NUM_NC) { - OS_LOGE(TAG, "Pin is not set"); - return; - } - - if (!OpenShock::IsValidOutputPin(gpioPin)) { - OS_LOGE(TAG, "Pin %hhi is not a valid output pin", gpioPin); - return; - } - - m_rmtHandle = rmtInit(gpioPin, RMT_TX_MODE, RMT_MEM_64); - if (m_rmtHandle == NULL) { - OS_LOGE(TAG, "Failed to initialize RMT for pin %hhi", gpioPin); - return; - } - - float realTick = rmtSetTick(m_rmtHandle, 100.F); - OS_LOGD(TAG, "RMT tick is %f ns for pin %hhi", realTick, gpioPin); - - m_gpioPin = gpioPin; -} - -RgbLedDriver::~RgbLedDriver() -{ - ClearPattern(); - - rmtDeinit(m_rmtHandle); -} - -void RgbLedDriver::SetPattern(const RGBState* pattern, std::size_t patternLength) -{ - m_taskMutex.lock(portMAX_DELAY); - - ClearPatternInternal(); - - // Set new values - m_pattern.resize(patternLength); - std::copy(pattern, pattern + patternLength, m_pattern.begin()); - - // Start the task - m_stopRequested.store(false, std::memory_order_relaxed); - BaseType_t result = TaskUtils::TaskCreateExpensive(Util::FnProxy<&RgbLedDriver::RunPattern>, TAG, 4096, this, 1, &m_taskHandle); // PROFILED: 1.7KB stack usage - if (result != pdPASS) { - OS_LOGE(TAG, "[pin-%hhi] Failed to create task: %d", m_gpioPin, result); - - m_taskHandle = nullptr; - m_pattern.clear(); - } - - m_taskMutex.unlock(); -} - -void RgbLedDriver::ClearPattern() -{ - m_taskMutex.lock(portMAX_DELAY); - - ClearPatternInternal(); - - m_taskMutex.unlock(); -} - -// Range: 0-255 -void RgbLedDriver::SetBrightness(uint8_t brightness) -{ - m_brightness = brightness; -} - -void RgbLedDriver::ClearPatternInternal() -{ - if (m_taskHandle != nullptr) { - m_stopRequested.store(true, std::memory_order_relaxed); - TaskUtils::StopTask(m_taskHandle, TAG, "RgbLedDriver task"); - m_taskHandle = nullptr; - } - - m_pattern.clear(); -} - -void RgbLedDriver::RunPattern() -{ - std::array led_data; // 24 bits per LED (8 bits per color * 3 colors) - - while (!m_stopRequested.load(std::memory_order_relaxed)) { - for (const auto& state : m_pattern) { - if (m_stopRequested.load(std::memory_order_relaxed)) break; - - // WS2812B usually takes commands in GRB order - // https://cdn-shop.adafruit.com/datasheets/WS2812B.pdf - Page 5 - // But some actually expect RGB! - - uint8_t r = static_cast(static_cast(state.red) * m_brightness / 255); - uint8_t g = static_cast(static_cast(state.green) * m_brightness / 255); - uint8_t b = static_cast(static_cast(state.blue) * m_brightness / 255); -#if OPENSHOCK_LED_SWAP_RG_CHANNELS - std::swap(r, g); -#endif - - const uint32_t colors = (static_cast(g) << 16) | (static_cast(r) << 8) | static_cast(b); - - // Encode the data - for (std::size_t bit = 0; bit < 24; bit++) { - if (colors & (1 << (23 - bit))) { - led_data[bit].level0 = 1; - led_data[bit].duration0 = 8; - led_data[bit].level1 = 0; - led_data[bit].duration1 = 4; - } else { - led_data[bit].level0 = 1; - led_data[bit].duration0 = 4; - led_data[bit].level1 = 0; - led_data[bit].duration1 = 8; - } - } - - // Send the data - rmtWriteBlocking(m_rmtHandle, led_data.data(), led_data.size()); - - // Chunked delay so cooperative shutdown can interrupt long waits - uint32_t remaining = state.duration; - while (remaining > 0 && !m_stopRequested.load(std::memory_order_relaxed)) { - uint32_t chunk = remaining > 50 ? 50 : remaining; - vTaskDelay(pdMS_TO_TICKS(chunk)); - remaining -= chunk; - } - } - } - - vTaskDelete(nullptr); -} diff --git a/src/wifi/WiFiScanManager.cpp b/src/wifi/WiFiScanManager.cpp deleted file mode 100644 index 57dc463c..00000000 --- a/src/wifi/WiFiScanManager.cpp +++ /dev/null @@ -1,313 +0,0 @@ -#include - -#include "wifi/WiFiScanManager.h" - -const char* const TAG = "WiFiScanManager"; - -#include "Logging.h" -#include "SimpleMutex.h" -#include "util/TaskUtils.h" - -#include - -#include - -const uint8_t OPENSHOCK_WIFI_SCAN_MAX_CHANNEL = 13; -const uint32_t OPENSHOCK_WIFI_SCAN_MAX_MS_PER_CHANNEL = 300; // Adjusting this value will affect the scan rate, but may also affect the scan results -const uint32_t OPENSHOCK_WIFI_SCAN_TIMEOUT_MS = 10 * 1000; - -enum WiFiScanTaskNotificationFlags { - CHANNEL_DONE = 1 << 0, - ERROR = 1 << 1, - WIFI_DISABLED = 1 << 2, - CLEAR_FLAGS = CHANNEL_DONE | ERROR -}; - -static bool s_initialized = false; -static TaskHandle_t s_scanTaskHandle = nullptr; -static OpenShock::SimpleMutex s_scanTaskMutex = {}; -static uint8_t s_currentChannel = 0; -static std::map s_statusChangedHandlers; -static std::map s_networksDiscoveredHandlers; - -using namespace OpenShock; - -static bool notifyTask(WiFiScanTaskNotificationFlags flags) -{ - ScopedLock lock__(&s_scanTaskMutex); - - if (s_scanTaskHandle == nullptr) { - return false; - } - - return xTaskNotify(s_scanTaskHandle, flags, eSetBits) == pdPASS; -} - -static void notifyStatusChangedHandlers(OpenShock::WiFiScanStatus status) -{ - for (auto& it : s_statusChangedHandlers) { - it.second(status); - } -} - -static bool isScanError(int16_t retval) -{ - return retval < 0 && retval != WIFI_SCAN_RUNNING; -} - -static void handleScanError(int16_t retval) -{ - if (retval >= 0) return; - - notifyTask(WiFiScanTaskNotificationFlags::ERROR); - - if (retval == WIFI_SCAN_FAILED) { - OS_LOGE(TAG, "Failed to start scan on channel %u", s_currentChannel); - return; - } - - if (retval == WIFI_SCAN_RUNNING) { - OS_LOGE(TAG, "Scan is running on channel %u", s_currentChannel); - return; - } - - OS_LOGE(TAG, "Scan returned an unknown error"); -} - -static int16_t scanChannel(uint8_t channel) -{ - int16_t retval = WiFi.scanNetworks(true, true, false, OPENSHOCK_WIFI_SCAN_MAX_MS_PER_CHANNEL, channel); - if (!isScanError(retval)) { - return retval; - } - - handleScanError(retval); - - return retval; -} - -static WiFiScanStatus scanningTaskImpl() -{ - // Start the scan on the highest channel and work our way down - uint8_t channel = OPENSHOCK_WIFI_SCAN_MAX_CHANNEL; - - notifyStatusChangedHandlers(WiFiScanStatus::Started); - - // Start the scan on the first channel - int16_t retval = scanChannel(channel); - if (isScanError(retval)) { - return WiFiScanStatus::Error; - } - - // Scan each channel until we're done - while (true) { - uint32_t notificationFlags = 0; - - notifyStatusChangedHandlers(WiFiScanStatus::InProgress); - - // Wait for the scan to complete, evScanCompleted will notify us when it's done - if (xTaskNotifyWait(0, WiFiScanTaskNotificationFlags::CLEAR_FLAGS, ¬ificationFlags, pdMS_TO_TICKS(OPENSHOCK_WIFI_SCAN_TIMEOUT_MS)) != pdTRUE) { - OS_LOGE(TAG, "Scan timed out"); - return WiFiScanStatus::TimedOut; - } - - // Check if we were notified of an error or if WiFi was disabled - if (notificationFlags != WiFiScanTaskNotificationFlags::CHANNEL_DONE) { - if (notificationFlags & WiFiScanTaskNotificationFlags::WIFI_DISABLED) { - OS_LOGE(TAG, "Scan task exiting due to being notified that WiFi was disabled"); - return WiFiScanStatus::Aborted; - } - - if (notificationFlags & WiFiScanTaskNotificationFlags::ERROR) { - OS_LOGE(TAG, "Scan task exiting due to being notified of an error"); - return WiFiScanStatus::Error; - } - - return WiFiScanStatus::Error; - } - - // Select the next channel, or break if we're done - if (--channel <= 0) { - break; - } - - // Start the scan on the next channel - retval = scanChannel(channel); - if (isScanError(retval)) { - return WiFiScanStatus::Error; - } - } - - return WiFiScanStatus::Completed; -} - -static void scanningTask(void* arg) -{ - (void)arg; - - // Start the scan - WiFiScanStatus status = scanningTaskImpl(); - - // Notify the status changed handlers of the scan result - notifyStatusChangedHandlers(status); - - s_scanTaskMutex.lock(portMAX_DELAY); - - // Clear the task handle - s_scanTaskHandle = nullptr; - - s_scanTaskMutex.unlock(); - - // Kill this task - vTaskDelete(nullptr); -} - -static void evScanCompleted(arduino_event_id_t event, arduino_event_info_t info) -{ - (void)event; - (void)info; - - int16_t numNetworks = WiFi.scanComplete(); - if (isScanError(numNetworks)) { - handleScanError(numNetworks); - return; - } - - if (numNetworks == WIFI_SCAN_RUNNING) { - OS_LOGE(TAG, "Scan completed but scan is still running... WTF?"); - return; - } - - std::vector networkRecords; - networkRecords.reserve(numNetworks); - - for (int16_t i = 0; i < numNetworks; i++) { - wifi_ap_record_t* record = static_cast(WiFi.getScanInfoByIndex(i)); - if (record == nullptr) { - OS_LOGE(TAG, "Failed to get scan info for network #%d", i); - return; - } - - networkRecords.push_back(record); - } - - // Notify the networks discovered handlers - for (auto& it : s_networksDiscoveredHandlers) { - it.second(networkRecords); - } - - // Notify the scan task that we're done - notifyTask(WiFiScanTaskNotificationFlags::CHANNEL_DONE); -} -static void evSTAStopped(arduino_event_id_t event, arduino_event_info_t info) -{ - (void)event; - (void)info; - - notifyTask(WiFiScanTaskNotificationFlags::WIFI_DISABLED); -} - -bool WiFiScanManager::Init() -{ - if (s_initialized) { - OS_LOGW(TAG, "WiFiScanManager is already initialized"); - return true; - } - - WiFi.onEvent(evScanCompleted, ARDUINO_EVENT_WIFI_SCAN_DONE); - WiFi.onEvent(evSTAStopped, ARDUINO_EVENT_WIFI_STA_STOP); - - s_initialized = true; - - return true; -} - -bool WiFiScanManager::IsScanning() -{ - // Quick check - if (s_scanTaskHandle == nullptr) { - return false; - } - - // It wasnt null, lock and perform proper check - ScopedLock lock__(&s_scanTaskMutex); - - return s_scanTaskHandle != nullptr && eTaskGetState(s_scanTaskHandle) != eDeleted; -} - -bool WiFiScanManager::StartScan() -{ - ScopedLock lock__(&s_scanTaskMutex); - - // Check if a scan is already in progress - if (s_scanTaskHandle != nullptr && eTaskGetState(s_scanTaskHandle) != eDeleted) { - OS_LOGW(TAG, "Cannot start scan: scan task is already running"); - return false; - } - - // Free the TCB - if (s_scanTaskHandle != nullptr) { - vTaskDelete(s_scanTaskHandle); - } - - // Start the scan task - if (TaskUtils::TaskCreateExpensive(scanningTask, "WiFiScanManager", 4096, nullptr, 1, &s_scanTaskHandle) != pdPASS) { // PROFILED: 1.8KB stack usage - OS_LOGE(TAG, "Failed to create scan task"); - return false; - } - - return true; -} -bool WiFiScanManager::AbortScan() -{ - ScopedLock lock__(&s_scanTaskMutex); - - // Check if a scan is in progress - if (s_scanTaskHandle == nullptr || eTaskGetState(s_scanTaskHandle) == eDeleted) { - OS_LOGW(TAG, "Cannot abort scan: no scan is in progress"); - return false; - } - - // Kill the task - vTaskDelete(s_scanTaskHandle); - s_scanTaskHandle = nullptr; - - // Inform the change handlers that the scan was aborted - for (auto& it : s_statusChangedHandlers) { - it.second(WiFiScanStatus::Aborted); - } - - return true; -} - -uint64_t WiFiScanManager::RegisterStatusChangedHandler(const WiFiScanManager::StatusChangedHandler& handler) -{ - static uint64_t nextHandle = 0; - uint64_t handle = nextHandle++; - s_statusChangedHandlers[handle] = handler; - return handle; -} -void WiFiScanManager::UnregisterStatusChangedHandler(uint64_t handle) -{ - auto it = s_statusChangedHandlers.find(handle); - - if (it != s_statusChangedHandlers.end()) { - s_statusChangedHandlers.erase(it); - } -} - -uint64_t WiFiScanManager::RegisterNetworksDiscoveredHandler(const WiFiScanManager::NetworksDiscoveredHandler& handler) -{ - static uint64_t nextHandle = 0; - uint64_t handle = nextHandle++; - s_networksDiscoveredHandlers[handle] = handler; - return handle; -} -void WiFiScanManager::UnregisterNetworksDiscoveredHandler(uint64_t handle) -{ - auto it = s_networksDiscoveredHandlers.find(handle); - - if (it != s_networksDiscoveredHandlers.end()) { - s_networksDiscoveredHandlers.erase(it); - } -} diff --git a/test/README b/test/README deleted file mode 100644 index 9b1e87bc..00000000 --- a/test/README +++ /dev/null @@ -1,11 +0,0 @@ - -This directory is intended for PlatformIO Test Runner and project tests. - -Unit Testing is a software testing method by which individual units of -source code, sets of one or more MCU program modules together with associated -control data, usage procedures, and operating procedures, are tested to -determine whether they are fit for use. Unit testing finds problems early -in the development cycle. - -More information about PlatformIO Unit Testing: -- https://docs.platformio.org/en/latest/advanced/unit-testing/index.html diff --git a/test/host_support/CMakeLists.txt b/test/host_support/CMakeLists.txt new file mode 100644 index 00000000..25f3c4a2 --- /dev/null +++ b/test/host_support/CMakeLists.txt @@ -0,0 +1,35 @@ +# Shared stand-ins for the OpenShock headers the host (linux target) test suites in +# components/*/host_test cannot use as-is, plus the classic ESP32 SoC capabilities +# the linux target's soc headers leave out. Every suite adds this directory to +# EXTRA_COMPONENT_DIRS and lists `host_support` in its REQUIRES. +# +# What is here, and why: +# include/Logging.h - logging's Logging.h pulls in esp_ota_ops.h (app_update has +# no linux port) and the serial console. Log macros compile +# their arguments but print nothing; OS_PANIC* abort. +# include/openshock_board.h - generated by scripts/gen_env_header.py in firmware builds. +# A fixed classic ESP32 board, so the real Chipset.h and +# OpenShock.h work unmodified. +# Kconfig.projbuild - sources core's Kconfig.projbuild, so the suites see the +# real CONFIG_OPENSHOCK_* defaults through sdkconfig.h. +# +# Everything else a suite needs comes from the real OpenShock headers, ESP-IDF's +# linux-target components, or the CMock mocks in $IDF_PATH/tools/mocks. Fakes that +# isolate one suite from a dependency (device_control's config/Config.h, config's +# in-memory fs/ConfigFs.h) stay in that suite's main/stubs. +idf_component_register(INCLUDE_DIRS "include") + +# The linux target's soc_caps.h / clk_tree_defs.h describe no GPIO matrix and no RMT +# peripheral (ESP-IDF's driver mocks only need the types). openshock_board.h declares +# a classic ESP32, so supply that chip's values (components/soc/esp32/include/soc/ +# soc_caps.h) for what OpenShock code reads: hal/gpio_types.h's GPIO_IS_VALID_GPIO / +# GPIO_IS_VALID_OUTPUT_GPIO (Chipset.h), and the RMT block size and default clock +# source (device_control's RFTransmitter). The masks are ESP32's BIT24/BIT28..31 (no +# such pads) and BIT34..39 (input only) exclusions, written out. +target_compile_definitions(${COMPONENT_LIB} INTERFACE + "SOC_GPIO_PIN_COUNT=40" + "SOC_GPIO_VALID_GPIO_MASK=0xFF0EFFFFFFULL" + "SOC_GPIO_VALID_OUTPUT_GPIO_MASK=0x030EFFFFFFULL" + "SOC_RMT_MEM_WORDS_PER_CHANNEL=64" + "RMT_CLK_SRC_DEFAULT=0" +) diff --git a/test/host_support/Kconfig.projbuild b/test/host_support/Kconfig.projbuild new file mode 100644 index 00000000..25b0bb0b --- /dev/null +++ b/test/host_support/Kconfig.projbuild @@ -0,0 +1,4 @@ +# The CONFIG_OPENSHOCK_* settings (domains, hostname, AP prefix, buffer sizes) the +# firmware reads through sdkconfig.h. Sourced from core rather than copied, so the +# host tests always build against the same defaults as the firmware. +rsource "../../components/core/Kconfig.projbuild" diff --git a/test/host_support/include/Logging.h b/test/host_support/include/Logging.h new file mode 100644 index 00000000..9f2d6da0 --- /dev/null +++ b/test/host_support/include/Logging.h @@ -0,0 +1,65 @@ +#pragma once + +// Host-test stand-in for logging's Logging.h, shared by every host_test suite. +// +// The real header cannot be used on the linux target: its OS_PANIC* macros need +// esp_ota_ops.h (app_update has no linux port), and its output goes through the +// firmware's serial console. Same macro surface here: +// - OS_LOG* print nothing, but still compile their arguments (inside a dead branch), +// so a value used only in a log line is still "used" and a log call the firmware +// could not compile does not compile here either. +// - OS_PANIC* print the message to stderr and abort(): a panic is never an expected +// outcome of a test, so it fails the whole run loudly instead of restarting. +// +// sdkconfig.h is included because the real header brings it in (through esp_system.h +// and freertos/FreeRTOS.h), and some sources read CONFIG_* values relying on that. +#include + +#include +#include + +#define OPENSHOCK_LOG_LEVEL_NONE (0) +#define OPENSHOCK_LOG_LEVEL_ERROR (1) +#define OPENSHOCK_LOG_LEVEL_WARN (2) +#define OPENSHOCK_LOG_LEVEL_INFO (3) +#define OPENSHOCK_LOG_LEVEL_DEBUG (4) +#define OPENSHOCK_LOG_LEVEL_VERBOSE (5) + +// Same shape as the firmware's openshock_log_printf (no format attribute), never called. +inline void openshock_host_log_discard(const char*, const char*, ...) +{ +} + +#define OPENSHOCK_HOST_LOG(TAG, format, ...) \ + do { \ + if (false) { \ + openshock_host_log_discard(TAG, format, ##__VA_ARGS__); \ + } \ + } while (0) + +#define OS_LOGV(TAG, format, ...) OPENSHOCK_HOST_LOG(TAG, format, ##__VA_ARGS__) +#define OS_LOGD(TAG, format, ...) OPENSHOCK_HOST_LOG(TAG, format, ##__VA_ARGS__) +#define OS_LOGI(TAG, format, ...) OPENSHOCK_HOST_LOG(TAG, format, ##__VA_ARGS__) +#define OS_LOGW(TAG, format, ...) OPENSHOCK_HOST_LOG(TAG, format, ##__VA_ARGS__) +#define OS_LOGE(TAG, format, ...) OPENSHOCK_HOST_LOG(TAG, format, ##__VA_ARGS__) +#define OS_LOGN(TAG, format, ...) OPENSHOCK_HOST_LOG(TAG, format, ##__VA_ARGS__) + +#define OS_PANIC_PRINT(TAG, format, ...) std::fprintf(stderr, "[%s] PANIC: " format "\n", TAG, ##__VA_ARGS__) + +#define OS_PANIC(TAG, format, ...) \ + do { \ + OS_PANIC_PRINT(TAG, format, ##__VA_ARGS__); \ + std::abort(); \ + } while (0) + +#define OS_PANIC_OTA(TAG, format, ...) \ + do { \ + OS_PANIC_PRINT(TAG, format, ##__VA_ARGS__); \ + std::abort(); \ + } while (0) + +#define OS_PANIC_INSTANT(TAG, format, ...) \ + do { \ + OS_PANIC_PRINT(TAG, format, ##__VA_ARGS__); \ + std::abort(); \ + } while (0) diff --git a/test/host_support/include/openshock_board.h b/test/host_support/include/openshock_board.h new file mode 100644 index 00000000..0828624e --- /dev/null +++ b/test/host_support/include/openshock_board.h @@ -0,0 +1,17 @@ +// Host-test stand-in for the board header scripts/gen_env_header.py generates in +// firmware builds (same keys and value formats). A classic ESP32 board in debug mode, +// with the RF transmitter on GPIO4 and no E-Stop or LED. The ESP32 GPIO capabilities +// the real Chipset.h checks pins against come from this component's CMakeLists.txt. +#pragma once + +#define OPENSHOCK_ESTOP_PIN -1 +#define OPENSHOCK_FW_BOARD "HostTest" +#define OPENSHOCK_FW_BOARD_HOSTTEST 1 +#define OPENSHOCK_FW_CHIP "ESP32" +#define OPENSHOCK_FW_CHIP_ESP32 1 +#define OPENSHOCK_FW_MODE "debug" +#define OPENSHOCK_LED_GPIO -1 +#define OPENSHOCK_LED_SWAP_RG_CHANNELS 0 +#define OPENSHOCK_LED_WS2812B -1 +#define OPENSHOCK_LOG_LEVEL 5 +#define OPENSHOCK_RF_TX_GPIO 4