From 72d364b2931dd75abbf87f336eee539cf9d2ab9c Mon Sep 17 00:00:00 2001 From: Trevor Schirmer <24777085+TrevorSchirmer@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:37:02 -0400 Subject: [PATCH 1/2] Initialize beta --- .github/workflows/autoassign.yml | 19 ++++ .github/workflows/build.yml | 143 +++++++++++++++++++++++++ .github/workflows/ci.yml | 21 ++++ .gitignore | 35 +++++++ Integrations/ESPHome/.gitignore | 5 + Integrations/ESPHome/AIR-1.yaml | 53 ++++++++++ Integrations/ESPHome/Core.yaml | 26 +++++ License.md | 173 +++++++++++++++++++++++++++++++ PULL_REQUEST_TEMPLATE.md | 13 +++ README.md | 23 ++++ index.html | 96 +++++++++++++++++ 11 files changed, 607 insertions(+) create mode 100644 .github/workflows/autoassign.yml create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 Integrations/ESPHome/.gitignore create mode 100644 Integrations/ESPHome/AIR-1.yaml create mode 100644 Integrations/ESPHome/Core.yaml create mode 100644 License.md create mode 100644 PULL_REQUEST_TEMPLATE.md create mode 100644 README.md create mode 100644 index.html diff --git a/.github/workflows/autoassign.yml b/.github/workflows/autoassign.yml new file mode 100644 index 0000000..0158e68 --- /dev/null +++ b/.github/workflows/autoassign.yml @@ -0,0 +1,19 @@ +name: Auto Assign +on: + issues: + types: [opened] + pull_request: + types: [opened] +jobs: + run: + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write + steps: + - name: 'Auto-assign issue' + uses: pozil/auto-assign-issue@v1 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + assignees: TrevorSchirmer + numOfAssignee: 1 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..55e1280 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,143 @@ +name: Build +env: + DEVICE_NAME: plt-1 # TODO: Rename + RELEASE_URL: ${{ github.server_url }}/${{ github.repository }}/releases/latest + +on: + push: + branches: + - main + +jobs: + build: + name: Build And Release + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set uppercase DEVICE_NAME + run: | + echo "UPPERCASE_DEVICE_NAME=$(echo $DEVICE_NAME | tr '[:lower:]' '[:upper:]')" >> $GITHUB_ENV + + - name: Build Firmware + uses: esphome/build-action@v3.0.0 + id: esphome-build + with: + yaml_file: Integrations/ESPHome/${{ env.UPPERCASE_DEVICE_NAME }}.yaml + version: 'latest' + cache: true + + - name: Read version from YAML file + id: read_version + run: | + version=$(awk '/substitutions:/ {found=1} found && /version:/ {print $2; exit}' Integrations/ESPHome/Core.yaml | tr -d '"') + echo "project_version=$version" >> $GITHUB_ENV + + - name: Move generated files to output + run: | + mkdir -p output + mv ${{ steps.esphome-build.outputs.name }}/* output/ + echo ${{ steps.esphome-build.outputs.version }} > output/version + # Extract MD5 checksum of firmware.ota.bin + MD5_CHECKSUM=$(jq -r '.ota.md5' output/manifest.json) + echo "MD5_CHECKSUM=$MD5_CHECKSUM" >> $GITHUB_ENV + + # Create new manifest.json with jq + jq -n --arg name "${{ env.DEVICE_NAME }}" \ + --arg version "${{ env.project_version }}" \ + --arg md5 "$MD5_CHECKSUM" \ + '{name: $name, version: $version, home_assistant_domain: "esphome", new_install_prompt_erase: false, builds: [{chipFamily: "ESP32-C3", parts: [{path: "apollo-${{ env.DEVICE_NAME }}-esp32c3.factory.bin", offset: 0}], ota: {path: "apollo-${{ env.DEVICE_NAME }}-esp32c3.ota.bin", md5: $md5}}]}' > output/manifest.json + + + - uses: actions/upload-artifact@v4.3.3 + with: + path: output/ + retention-days: 1 + + - name: Collect merge commits + id: collect_commits + run: | + MERGE_COMMITS=$(git log --merges --pretty=format:"%h %s" $(git describe --tags --abbrev=0 @^)..@) + echo "MERGE_COMMITS=$MERGE_COMMITS" >> $GITHUB_ENV + + - name: Create Release + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' + id: create_release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.PAT_TOKEN }} + with: + tag_name: ${{ env.project_version }} + release_name: "Release ${{ env.project_version }}" + body: Release of version ${{ env.project_version }} + + draft: false + prerelease: false + + - name: Upload firmware.factory.bin to Release + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.PAT_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: output/apollo-${{ env.DEVICE_NAME }}-esp32c3.factory.bin + asset_name: apollo-${{ env.DEVICE_NAME }}-esp32c3.factory.bin + asset_content_type: application/octet-stream + + - name: Upload firmware.ota.bin to Release + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.PAT_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: output/apollo-${{ env.DEVICE_NAME }}-esp32c3.ota.bin + asset_name: apollo-${{ env.DEVICE_NAME }}-esp32c3.ota.bin + asset_content_type: application/octet-stream + + - name: Upload manifest.json to Release + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.PAT_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: output/manifest.json + asset_name: manifest.json + asset_content_type: application/octet-stream + + + prep: + name: Consolidate firmwares + runs-on: ubuntu-latest + needs: + - build + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4.1.7 + with: + path: output + - run: cp -R static/* output/ + - uses: actions/upload-pages-artifact@v3.0.1 + with: + path: output + retention-days: 1 + + deploy: + if: contains(fromJSON('["workflow_dispatch", "push", "schedule"]'), github.event_name) && github.ref == 'refs/heads/main' + name: Deploy to GitHub Pages + runs-on: ubuntu-latest + needs: prep + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Setup Pages + uses: actions/configure-pages@v5 + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4.0.5 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a697c5a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,21 @@ +name: CI + +on: + pull_request: + +jobs: + ci: + name: Building ${{ matrix.file }} + runs-on: ubuntu-latest + strategy: + matrix: + file: # TODO: Rename + - Integrations/ESPHome/PLT-1.yaml + - Integrations/ESPHome/PLT-1_BLE.yaml + steps: + - name: Checkout source code + uses: actions/checkout@v4.1.7 + - name: Build ESPHome firmware to verify configuration + uses: esphome/build-action@v3.1.0 + with: + yaml_file: ${{ matrix.file }} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0132e07 --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +# Prerequisites +*.d + +# Compiled Object files +*.slo +*.lo +*.o +*.obj + +# Precompiled Headers +*.gch +*.pch + +# Compiled Dynamic libraries +*.so +*.dylib +*.dll + +# Fortran module files +*.mod +*.smod + +# Compiled Static libraries +*.lai +*.la +*.a +*.lib + +# Executables +*.exe +*.out +*.app +Integrations/ESPHome/.DS_Store +Integrations/.DS_Store +.DS_Store diff --git a/Integrations/ESPHome/.gitignore b/Integrations/ESPHome/.gitignore new file mode 100644 index 0000000..d8b4157 --- /dev/null +++ b/Integrations/ESPHome/.gitignore @@ -0,0 +1,5 @@ +# Gitignore settings for ESPHome +# This is an example and may include too much for your use-case. +# You can modify this file to suit your needs. +/.esphome/ +/secrets.yaml diff --git a/Integrations/ESPHome/AIR-1.yaml b/Integrations/ESPHome/AIR-1.yaml new file mode 100644 index 0000000..09d1a25 --- /dev/null +++ b/Integrations/ESPHome/AIR-1.yaml @@ -0,0 +1,53 @@ +esphome: + name: "${name}" + friendly_name: Apollo AIR-1 # TODO: Rename + comment: Apollo AIR-1 # TODO: Rename + name_add_mac_suffix: true + platformio_options: + board_build.flash_mode: dio + + project: + name: "ApolloAutomation.AIR-1" # TODO: Rename + version: "${version}" + + min_version: 2023.11.1 + +dashboard_import: + package_import_url: github://ApolloAutomation/AIR-1/Integrations/ESPHome/AIR-1.yaml + import_full_config: false # TODO: Rename + +improv_serial: + +esp32_improv: + authorizer: none + +ota: + - platform: esphome + id: ota_esphome + - platform: http_request + id: ota_managed + +http_request: + verify_ssl: true + +safe_mode: + +update: + - platform: http_request + id: firmware_update + name: Firmware Update + source: https://apolloautomation.github.io/PLT-1/artifact/manifest.json # TODO: Rename + +wifi: + on_connect: + - delay: 5s + - ble.disable: + on_disconnect: + - ble.enable: + ap: + ssid: "Apollo PLT1 Hotspot" # TODO: Rename + +logger: + +packages: + core: !include Core.yaml diff --git a/Integrations/ESPHome/Core.yaml b/Integrations/ESPHome/Core.yaml new file mode 100644 index 0000000..0540c2d --- /dev/null +++ b/Integrations/ESPHome/Core.yaml @@ -0,0 +1,26 @@ +substitutions: + name: apollo-plt-1 # TODO: Rename + version: "24.8.7.2" + device_description: ${name} made by Apollo Automation - version ${version}. + +esp32: + board: esp32-c3-devkitm-1 + framework: + type: esp-idf + +api: + +globals: + - id: cycleCounter + type: int + restore_value: no + initial_value: '0' + - id: button_press_timestamp + restore_value: no + type: uint32_t + initial_value: '0' + +captive_portal: + +web_server: + port: 80 \ No newline at end of file diff --git a/License.md b/License.md new file mode 100644 index 0000000..2943180 --- /dev/null +++ b/License.md @@ -0,0 +1,173 @@ +# Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International + +Creative Commons Corporation (“Creative Commons”) is not a law firm and does not provide legal services or legal advice. Distribution of Creative Commons public licenses does not create a lawyer-client or other relationship. Creative Commons makes its licenses and related information available on an “as-is” basis. Creative Commons gives no warranties regarding its licenses, any material licensed under their terms and conditions, or any related information. Creative Commons disclaims all liability for damages resulting from their use to the fullest extent possible. + +### Using Creative Commons Public Licenses + +Creative Commons public licenses provide a standard set of terms and conditions that creators and other rights holders may use to share original works of authorship and other material subject to copyright and certain other rights specified in the public license below. The following considerations are for informational purposes only, are not exhaustive, and do not form part of our licenses. + +* __Considerations for licensors:__ Our public licenses are intended for use by those authorized to give the public permission to use material in ways otherwise restricted by copyright and certain other rights. Our licenses are irrevocable. Licensors should read and understand the terms and conditions of the license they choose before applying it. Licensors should also secure all rights necessary before applying our licenses so that the public can reuse the material as expected. Licensors should clearly mark any material not subject to the license. This includes other CC-licensed material, or material used under an exception or limitation to copyright. [More considerations for licensors](http://wiki.creativecommons.org/Considerations_for_licensors_and_licensees#Considerations_for_licensors). + +* __Considerations for the public:__ By using one of our public licenses, a licensor grants the public permission to use the licensed material under specified terms and conditions. If the licensor’s permission is not necessary for any reason–for example, because of any applicable exception or limitation to copyright–then that use is not regulated by the license. Our licenses grant only permissions under copyright and certain other rights that a licensor has authority to grant. Use of the licensed material may still be restricted for other reasons, including because others have copyright or other rights in the material. A licensor may make special requests, such as asking that all changes be marked or described. Although not required by our licenses, you are encouraged to respect those requests where reasonable. [More considerations for the public](http://wiki.creativecommons.org/Considerations_for_licensors_and_licensees#Considerations_for_licensees). + +## Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International Public License + +By exercising the Licensed Rights (defined below), You accept and agree to be bound by the terms and conditions of this Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International Public License ("Public License"). To the extent this Public License may be interpreted as a contract, You are granted the Licensed Rights in consideration of Your acceptance of these terms and conditions, and the Licensor grants You such rights in consideration of benefits the Licensor receives from making the Licensed Material available under these terms and conditions. + +### Section 1 – Definitions. + +a. __Adapted Material__ means material subject to Copyright and Similar Rights that is derived from or based upon the Licensed Material and in which the Licensed Material is translated, altered, arranged, transformed, or otherwise modified in a manner requiring permission under the Copyright and Similar Rights held by the Licensor. For purposes of this Public License, where the Licensed Material is a musical work, performance, or sound recording, Adapted Material is always produced where the Licensed Material is synched in timed relation with a moving image. + +b. __Adapter's License__ means the license You apply to Your Copyright and Similar Rights in Your contributions to Adapted Material in accordance with the terms and conditions of this Public License. + +c. __BY-NC-SA Compatible License__ means a license listed at [creativecommons.org/compatiblelicenses](http://creativecommons.org/compatiblelicenses), approved by Creative Commons as essentially the equivalent of this Public License. + +d. __Copyright and Similar Rights__ means copyright and/or similar rights closely related to copyright including, without limitation, performance, broadcast, sound recording, and Sui Generis Database Rights, without regard to how the rights are labeled or categorized. For purposes of this Public License, the rights specified in Section 2(b)(1)-(2) are not Copyright and Similar Rights. + +e. __Effective Technological Measures__ means those measures that, in the absence of proper authority, may not be circumvented under laws fulfilling obligations under Article 11 of the WIPO Copyright Treaty adopted on December 20, 1996, and/or similar international agreements. + +f. __Exceptions and Limitations__ means fair use, fair dealing, and/or any other exception or limitation to Copyright and Similar Rights that applies to Your use of the Licensed Material. + +g. __License Elements__ means the license attributes listed in the name of a Creative Commons Public License. The License Elements of this Public License are Attribution, NonCommercial, and ShareAlike. + +h. __Licensed Material__ means the artistic or literary work, database, or other material to which the Licensor applied this Public License. + +i. __Licensed Rights__ means the rights granted to You subject to the terms and conditions of this Public License, which are limited to all Copyright and Similar Rights that apply to Your use of the Licensed Material and that the Licensor has authority to license. + +h. __Licensor__ means the individual(s) or entity(ies) granting rights under this Public License. + +i. __NonCommercial__ means not primarily intended for or directed towards commercial advantage or monetary compensation. For purposes of this Public License, the exchange of the Licensed Material for other material subject to Copyright and Similar Rights by digital file-sharing or similar means is NonCommercial provided there is no payment of monetary compensation in connection with the exchange. + +j. __Share__ means to provide material to the public by any means or process that requires permission under the Licensed Rights, such as reproduction, public display, public performance, distribution, dissemination, communication, or importation, and to make material available to the public including in ways that members of the public may access the material from a place and at a time individually chosen by them. + +k. __Sui Generis Database Rights__ means rights other than copyright resulting from Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, as amended and/or succeeded, as well as other essentially equivalent rights anywhere in the world. + +l. __You__ means the individual or entity exercising the Licensed Rights under this Public License. Your has a corresponding meaning. + +### Section 2 – Scope. + +a. ___License grant.___ + + 1. Subject to the terms and conditions of this Public License, the Licensor hereby grants You a worldwide, royalty-free, non-sublicensable, non-exclusive, irrevocable license to exercise the Licensed Rights in the Licensed Material to: + + A. reproduce and Share the Licensed Material, in whole or in part, for NonCommercial purposes only; and + + B. produce, reproduce, and Share Adapted Material for NonCommercial purposes only. + + 2. __Exceptions and Limitations.__ For the avoidance of doubt, where Exceptions and Limitations apply to Your use, this Public License does not apply, and You do not need to comply with its terms and conditions. + + 3. __Term.__ The term of this Public License is specified in Section 6(a). + + 4. __Media and formats; technical modifications allowed.__ The Licensor authorizes You to exercise the Licensed Rights in all media and formats whether now known or hereafter created, and to make technical modifications necessary to do so. The Licensor waives and/or agrees not to assert any right or authority to forbid You from making technical modifications necessary to exercise the Licensed Rights, including technical modifications necessary to circumvent Effective Technological Measures. For purposes of this Public License, simply making modifications authorized by this Section 2(a)(4) never produces Adapted Material. + + 5. __Downstream recipients.__ + + A. __Offer from the Licensor – Licensed Material.__ Every recipient of the Licensed Material automatically receives an offer from the Licensor to exercise the Licensed Rights under the terms and conditions of this Public License. + + B. __Additional offer from the Licensor – Adapted Material.__ Every recipient of Adapted Material from You automatically receives an offer from the Licensor to exercise the Licensed Rights in the Adapted Material under the conditions of the Adapter’s License You apply. + + C. __No downstream restrictions.__ You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, the Licensed Material if doing so restricts exercise of the Licensed Rights by any recipient of the Licensed Material. + + 6. __No endorsement.__ Nothing in this Public License constitutes or may be construed as permission to assert or imply that You are, or that Your use of the Licensed Material is, connected with, or sponsored, endorsed, or granted official status by, the Licensor or others designated to receive attribution as provided in Section 3(a)(1)(A)(i). + +b. ___Other rights.___ + + 1. Moral rights, such as the right of integrity, are not licensed under this Public License, nor are publicity, privacy, and/or other similar personality rights; however, to the extent possible, the Licensor waives and/or agrees not to assert any such rights held by the Licensor to the limited extent necessary to allow You to exercise the Licensed Rights, but not otherwise. + + 2. Patent and trademark rights are not licensed under this Public License. + + 3. To the extent possible, the Licensor waives any right to collect royalties from You for the exercise of the Licensed Rights, whether directly or through a collecting society under any voluntary or waivable statutory or compulsory licensing scheme. In all other cases the Licensor expressly reserves any right to collect such royalties, including when the Licensed Material is used other than for NonCommercial purposes. + +### Section 3 – License Conditions. + +Your exercise of the Licensed Rights is expressly made subject to the following conditions. + +a. ___Attribution.___ + + 1. If You Share the Licensed Material (including in modified form), You must: + + A. retain the following if it is supplied by the Licensor with the Licensed Material: + + i. identification of the creator(s) of the Licensed Material and any others designated to receive attribution, in any reasonable manner requested by the Licensor (including by pseudonym if designated); + + ii. a copyright notice; + + iii. a notice that refers to this Public License; + + iv. a notice that refers to the disclaimer of warranties; + + v. a URI or hyperlink to the Licensed Material to the extent reasonably practicable; + + B. indicate if You modified the Licensed Material and retain an indication of any previous modifications; and + + C. indicate the Licensed Material is licensed under this Public License, and include the text of, or the URI or hyperlink to, this Public License. + + 2. You may satisfy the conditions in Section 3(a)(1) in any reasonable manner based on the medium, means, and context in which You Share the Licensed Material. For example, it may be reasonable to satisfy the conditions by providing a URI or hyperlink to a resource that includes the required information. + + 3. If requested by the Licensor, You must remove any of the information required by Section 3(a)(1)(A) to the extent reasonably practicable. + +b. ___ShareAlike.___ + +In addition to the conditions in Section 3(a), if You Share Adapted Material You produce, the following conditions also apply. + +1. The Adapter’s License You apply must be a Creative Commons license with the same License Elements, this version or later, or a BY-NC-SA Compatible License. + +2. You must include the text of, or the URI or hyperlink to, the Adapter's License You apply. You may satisfy this condition in any reasonable manner based on the medium, means, and context in which You Share Adapted Material. + +3. You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, Adapted Material that restrict exercise of the rights granted under the Adapter's License You apply. + +### Section 4 – Sui Generis Database Rights. + +Where the Licensed Rights include Sui Generis Database Rights that apply to Your use of the Licensed Material: + +a. for the avoidance of doubt, Section 2(a)(1) grants You the right to extract, reuse, reproduce, and Share all or a substantial portion of the contents of the database for NonCommercial purposes only; + +b. if You include all or a substantial portion of the database contents in a database in which You have Sui Generis Database Rights, then the database in which You have Sui Generis Database Rights (but not its individual contents) is Adapted Material, including for purposes of Section 3(b); and + +c. You must comply with the conditions in Section 3(a) if You Share all or a substantial portion of the contents of the database. + +For the avoidance of doubt, this Section 4 supplements and does not replace Your obligations under this Public License where the Licensed Rights include other Copyright and Similar Rights. + +### Section 5 – Disclaimer of Warranties and Limitation of Liability. + +a. __Unless otherwise separately undertaken by the Licensor, to the extent possible, the Licensor offers the Licensed Material as-is and as-available, and makes no representations or warranties of any kind concerning the Licensed Material, whether express, implied, statutory, or other. This includes, without limitation, warranties of title, merchantability, fitness for a particular purpose, non-infringement, absence of latent or other defects, accuracy, or the presence or absence of errors, whether or not known or discoverable. Where disclaimers of warranties are not allowed in full or in part, this disclaimer may not apply to You.__ + +b. __To the extent possible, in no event will the Licensor be liable to You on any legal theory (including, without limitation, negligence) or otherwise for any direct, special, indirect, incidental, consequential, punitive, exemplary, or other losses, costs, expenses, or damages arising out of this Public License or use of the Licensed Material, even if the Licensor has been advised of the possibility of such losses, costs, expenses, or damages. Where a limitation of liability is not allowed in full or in part, this limitation may not apply to You.__ + +c. The disclaimer of warranties and limitation of liability provided above shall be interpreted in a manner that, to the extent possible, most closely approximates an absolute disclaimer and waiver of all liability. + +### Section 6 – Term and Termination. + +a. This Public License applies for the term of the Copyright and Similar Rights licensed here. However, if You fail to comply with this Public License, then Your rights under this Public License terminate automatically. + +b. Where Your right to use the Licensed Material has terminated under Section 6(a), it reinstates: + + 1. automatically as of the date the violation is cured, provided it is cured within 30 days of Your discovery of the violation; or + + 2. upon express reinstatement by the Licensor. + + For the avoidance of doubt, this Section 6(b) does not affect any right the Licensor may have to seek remedies for Your violations of this Public License. + +c. For the avoidance of doubt, the Licensor may also offer the Licensed Material under separate terms or conditions or stop distributing the Licensed Material at any time; however, doing so will not terminate this Public License. + +d. Sections 1, 5, 6, 7, and 8 survive termination of this Public License. + +### Section 7 – Other Terms and Conditions. + +a. The Licensor shall not be bound by any additional or different terms or conditions communicated by You unless expressly agreed. + +b. Any arrangements, understandings, or agreements regarding the Licensed Material not stated herein are separate from and independent of the terms and conditions of this Public License. + +### Section 8 – Interpretation. + +a. For the avoidance of doubt, this Public License does not, and shall not be interpreted to, reduce, limit, restrict, or impose conditions on any use of the Licensed Material that could lawfully be made without permission under this Public License. + +b. To the extent possible, if any provision of this Public License is deemed unenforceable, it shall be automatically reformed to the minimum extent necessary to make it enforceable. If the provision cannot be reformed, it shall be severed from this Public License without affecting the enforceability of the remaining terms and conditions. + +c. No term or condition of this Public License will be waived and no failure to comply consented to unless expressly agreed to by the Licensor. + +d. Nothing in this Public License constitutes or may be interpreted as a limitation upon, or waiver of, any privileges and immunities that apply to the Licensor or You, including from the legal processes of any jurisdiction or authority. + +> Creative Commons is not a party to its public licenses. Notwithstanding, Creative Commons may elect to apply one of its public licenses to material it publishes and in those instances will be considered the “Licensor.” Except for the limited purpose of indicating that material is shared under a Creative Commons public license or as otherwise permitted by the Creative Commons policies published at [creativecommons.org/policies](http://creativecommons.org/policies), Creative Commons does not authorize the use of the trademark “Creative Commons” or any other trademark or logo of Creative Commons without its prior written consent including, without limitation, in connection with any unauthorized modifications to any of its public licenses or any other arrangements, understandings, or agreements concerning use of licensed material. For the avoidance of doubt, this paragraph does not form part of the public licenses. +> +> Creative Commons may be contacted at creativecommons.org diff --git a/PULL_REQUEST_TEMPLATE.md b/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..5f6a192 --- /dev/null +++ b/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,13 @@ +Version: + +Adds: + +Fixes: + +Breaks: + + + +Checks: +- [ ] Documentation Updated +- [ ] Build Number Incremented In Core.yaml diff --git a/README.md b/README.md new file mode 100644 index 0000000..d68fbe8 --- /dev/null +++ b/README.md @@ -0,0 +1,23 @@ +# Apollo AIR-1 + +![Pen no dust](https://github.com/ApolloAutomation/AIR-1/assets/24777085/183af36d-4611-4cb3-af1e-43b84d4d201a) + + +Key Features of the AIR-1 Sensor: + +MiCS-4514 Below have individual gas % readout: CO, C2H5OH (Alcohol), H2, NO2, and NH3 + +SCD40: CO2 and includes temperature and humidity sensing capabilities. + +SEN55: Particulate matter (PM1, PM2.5, PM10), VOCs, NOx, humidity, and temperature. + +DPS310: Barometric air pressure and temperature. + +Dimensions & Design: + +The AIR-1 measures just 61mm x 61mm x 30mm, and we have focused on efficient heat management within this small package to maintain sensor accuracy. This includes a thoughtful PCB layout and case design, incorporating ventilation and strategic component placement. + +Links: \ +Discord (Support/feedback/discussion/future products): [https://discord.gg/8PpS4yUaUh](https://discord.gg/mMNgQPyF94) \ +Shop: [https://apolloautomation.com](https://apolloautomation.com/products/air-1)https://apolloautomation.com/products/air-1 \ +Wiki: [https://wiki.apolloautomation.com](https://wiki.apolloautomation.com/)https://wiki.apolloautomation.com/ diff --git a/index.html b/index.html new file mode 100644 index 0000000..72b7774 --- /dev/null +++ b/index.html @@ -0,0 +1,96 @@ + + + + + Apollo AIR-1 Installer + + + + + + + +
+

Apollo AIR-1 Installer

+ +

+ + +

+ + + + \ No newline at end of file From 550cbbacead50da549fb9db1a480378eeee18b69 Mon Sep 17 00:00:00 2001 From: Justin Bunton Date: Wed, 30 Sep 2026 14:02:54 -0400 Subject: [PATCH 2/2] Add CAST_PRO-1 firmware: optical out and line-in ADC Port the CAST-1 firmware to the CAST PRO board: - New pinout: W5500 on 11/12/13/10 (INT 5, RST 21), I2S WS 7, BCLK 14, MCLK 17, DOUT 9, DIN 15, SPDIF on 16 - Optical (SPDIF) output alongside the PCM5122, chosen with an "Audio Output" select through a router speaker - PCM1808 line in on the same I2S port as the DAC, run full duplex at 48 kHz stereo in 32-bit slots, with a "Line In" switch that mixes it into the selected output - Vendored i2s_audio (ESPHome 2026.9.0 + full duplex from esphome/esphome#16882, with timing and format fixes); see components/i2s_audio/README.md - Rename CAST-1 to CAST_PRO-1 across configs, OTA manifests and workflows --- .github/PULL_REQUEST_TEMPLATE.md | 47 + .github/dependabot.yml | 15 + .github/release-drafter.yml | 51 ++ .github/workflows/autoassign.yml | 28 +- .github/workflows/build-beta.yml | 108 +++ .github/workflows/build.yml | 161 +--- .github/workflows/ci.yml | 28 +- .github/workflows/label-check.yml | 20 + .github/workflows/weekly.yml | 20 + .gitignore | 3 + Integrations/ESPHome/.gitignore | 1 + Integrations/ESPHome/AIR-1.yaml | 53 -- Integrations/ESPHome/CAST_PRO-1.yaml | 14 + Integrations/ESPHome/Core.yaml | 860 +++++++++++++++++- .../ESPHome/components/i2s_audio/README.md | 19 + .../ESPHome/components/i2s_audio/__init__.py | 352 +++++++ .../components/i2s_audio/i2s_audio.cpp | 339 +++++++ .../ESPHome/components/i2s_audio/i2s_audio.h | 148 +++ .../i2s_audio/media_player/__init__.py | 7 + .../i2s_audio/microphone/__init__.py | 162 ++++ .../microphone/i2s_audio_microphone.cpp | 503 ++++++++++ .../microphone/i2s_audio_microphone.h | 76 ++ .../components/i2s_audio/speaker/__init__.py | 277 ++++++ .../i2s_audio/speaker/i2s_audio_spdif.cpp | 504 ++++++++++ .../i2s_audio/speaker/i2s_audio_spdif.h | 34 + .../i2s_audio/speaker/i2s_audio_speaker.cpp | 405 +++++++++ .../i2s_audio/speaker/i2s_audio_speaker.h | 172 ++++ .../speaker/i2s_audio_speaker_standard.cpp | 525 +++++++++++ .../speaker/i2s_audio_speaker_standard.h | 32 + .../i2s_audio/speaker/spdif_encoder.cpp | 492 ++++++++++ .../i2s_audio/speaker/spdif_encoder.h | 148 +++ Integrations/ESPHome/wizmote.yaml | 491 ++++++++++ 32 files changed, 5876 insertions(+), 219 deletions(-) create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/dependabot.yml create mode 100644 .github/release-drafter.yml create mode 100644 .github/workflows/build-beta.yml create mode 100644 .github/workflows/label-check.yml create mode 100644 .github/workflows/weekly.yml delete mode 100644 Integrations/ESPHome/AIR-1.yaml create mode 100644 Integrations/ESPHome/CAST_PRO-1.yaml create mode 100644 Integrations/ESPHome/components/i2s_audio/README.md create mode 100644 Integrations/ESPHome/components/i2s_audio/__init__.py create mode 100644 Integrations/ESPHome/components/i2s_audio/i2s_audio.cpp create mode 100644 Integrations/ESPHome/components/i2s_audio/i2s_audio.h create mode 100644 Integrations/ESPHome/components/i2s_audio/media_player/__init__.py create mode 100644 Integrations/ESPHome/components/i2s_audio/microphone/__init__.py create mode 100644 Integrations/ESPHome/components/i2s_audio/microphone/i2s_audio_microphone.cpp create mode 100644 Integrations/ESPHome/components/i2s_audio/microphone/i2s_audio_microphone.h create mode 100644 Integrations/ESPHome/components/i2s_audio/speaker/__init__.py create mode 100644 Integrations/ESPHome/components/i2s_audio/speaker/i2s_audio_spdif.cpp create mode 100644 Integrations/ESPHome/components/i2s_audio/speaker/i2s_audio_spdif.h create mode 100644 Integrations/ESPHome/components/i2s_audio/speaker/i2s_audio_speaker.cpp create mode 100644 Integrations/ESPHome/components/i2s_audio/speaker/i2s_audio_speaker.h create mode 100644 Integrations/ESPHome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp create mode 100644 Integrations/ESPHome/components/i2s_audio/speaker/i2s_audio_speaker_standard.h create mode 100644 Integrations/ESPHome/components/i2s_audio/speaker/spdif_encoder.cpp create mode 100644 Integrations/ESPHome/components/i2s_audio/speaker/spdif_encoder.h create mode 100644 Integrations/ESPHome/wizmote.yaml diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..dd858be --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,47 @@ + +Version: + + +## What does this implement/fix? + + + +## Types of changes + + +- [ ] Bugfix (fixed change that fixes an issue) +- [ ] New feature (thanks!) +- [ ] Breaking change (repair/feature that breaks existing functionality) +- [ ] Dependency Update - Does not publish +- [ ] Other - Does not publish +- [ ] Website of github readme file update - Does not publish +- [ ] Github workflows - Does not publish + + +## Checklist / Checklijst: + + + - [ ] The code change has been tested and works locally + - [ ] The code change has not yet been tested + +If user-visible functionality or configuration variables are added/modified: + - [ ] Added/updated documentation for the web page + + diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..2f8e2dd --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,15 @@ +# Action version pins live in ApolloAutomation/Workflows (reusable workflows +# referenced @main), so this config normally has nothing to update. It stays +# as a safety net in case a version-pinned action is added here later. +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" + commit-message: + prefix: "chore(ci): " + groups: + github-actions: + patterns: + - "*" diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml new file mode 100644 index 0000000..a7f7176 --- /dev/null +++ b/.github/release-drafter.yml @@ -0,0 +1,51 @@ +name-template: 'Release v$NEXT_PATCH_VERSION' +tag-template: "$RESOLVED_VERSION" +change-template: "- #$NUMBER $TITLE @$AUTHOR" +sort-direction: ascending + +categories: + - title: "🚨 Breaking changes" + labels: + - "breaking-change" + - title: "✨ New features" + labels: + - "new-feature" + - title: "🐛 Bug fixes" + labels: + - "bugfix" +# - title: "🚀 Enhancements" +# labels: +# - "enhancement" +# - "refactor" +# - "performance" +# - title: "🧰 Maintenance" +# labels: +# - "maintenance" +# - "ci" +# - title: "📚 Documentation" +# labels: +# - "documentation" + - title: "⬆️ Dependency updates" + collapse-after: 5 + labels: + - "dependency-update" +# - title: "🚨🚨 Security Fixes 🚨🚨" +# labels: +# - "security" + + +include-labels: + - "bugfix" + - "new-feature" + - "breaking-change" + +no-changes-template: '- No changes' + +template: | + ## What's Changed + + $CHANGES + + **Full Changelog**: https://github.com/ApolloAutomation/CAST_PRO-1/compare/$PREVIOUS_TAG...$RESOLVED_VERSION.1 + + Be sure to 🌟 this repository for updates! diff --git a/.github/workflows/autoassign.yml b/.github/workflows/autoassign.yml index 0158e68..03b36f5 100644 --- a/.github/workflows/autoassign.yml +++ b/.github/workflows/autoassign.yml @@ -1,19 +1,21 @@ name: Auto Assign + +# pull_request_target (not pull_request) so auto-assign works on +# fork-submitted PRs; fork pull_request runs only get a read-only token. +# Safe because this workflow never checks out or executes PR code. on: issues: types: [opened] - pull_request: + pull_request_target: types: [opened] + +permissions: + issues: write + pull-requests: write + jobs: - run: - runs-on: ubuntu-latest - permissions: - issues: write - pull-requests: write - steps: - - name: 'Auto-assign issue' - uses: pozil/auto-assign-issue@v1 - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - assignees: TrevorSchirmer - numOfAssignee: 1 + auto-assign: + uses: ApolloAutomation/Workflows/.github/workflows/autoassign.yml@main + with: + assignees: bharvey88 + num-of-assignees: 1 diff --git a/.github/workflows/build-beta.yml b/.github/workflows/build-beta.yml new file mode 100644 index 0000000..759795e --- /dev/null +++ b/.github/workflows/build-beta.yml @@ -0,0 +1,108 @@ +name: Build and Publish Beta + +# Builds CAST_PRO-1 firmware from the beta branch and publishes it as assets on a +# rolling "beta" pre-release. The on-device "Firmware Channel" select points OTA +# updates at these assets. Stable firmware is built/published separately by +# build.yml (push to main -> GitHub Pages). +# +# NOTE: the unified firmware's Beta manifest is the manifest.json asset, +# which only exists once this workflow has run after the unified-firmware +# change. Merge to beta (or run this workflow manually) promptly after it +# lands on main — until then, devices switched to the Beta channel get a +# 404 on their update manifest. + +on: + push: + branches: [beta] + paths: + - 'Integrations/ESPHome/**' + workflow_dispatch: + +# Least privilege: read-only by default; only publish-beta is elevated to write. +permissions: + contents: read + +jobs: + version: + name: Read version + runs-on: ubuntu-latest + outputs: + v: ${{ steps.read.outputs.v }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - id: read + run: | + v=$(awk '/substitutions:/ {f=1} f && /version:/ {print $2; exit}' \ + Integrations/ESPHome/Core.yaml | tr -d '"') + echo "v=$v" >> "$GITHUB_OUTPUT" + echo "Beta version: $v" + + build: + name: Build ${{ matrix.name }} + needs: version + strategy: + matrix: + include: + - { yaml: Integrations/ESPHome/CAST_PRO-1.yaml, name: firmware } + uses: esphome/workflows/.github/workflows/build.yml@9f6577fd37b5cf773ab1b9be929714a0dcd15661 # 2026.7.0 + with: + files: ${{ matrix.yaml }} + esphome-version: stable + combined-name: ${{ matrix.name }} + release-version: ${{ needs.version.outputs.v }} + + publish-beta: + name: Publish beta release assets + needs: [version, build] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Download firmware artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + path: fw + pattern: firmware* + + - name: Ensure rolling 'beta-fw' pre-release exists + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release view beta-fw -R "${{ github.repository }}" >/dev/null 2>&1 \ + || gh release create beta-fw -R "${{ github.repository }}" \ + --prerelease --title "Beta (rolling)" \ + --notes "Latest CAST_PRO-1 beta firmware. Auto-updated on every push to the beta branch." + + - name: Rewrite manifests to absolute URLs and upload assets + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + BASE="https://github.com/${{ github.repository }}/releases/download/beta-fw" + man=$(find "fw/firmware" -name manifest.json | head -1) + if [ -z "$man" ]; then + echo "::error::manifest.json not found" + exit 1 + fi + echo "Rewriting $man -> manifest.json" + # Make ota.path and parts[].path absolute release-asset URLs so the + # device never has to resolve a relative path against a redirected URL. + jq --arg base "$BASE" ' + .builds[0].ota.path = ($base + "/" + (.builds[0].ota.path | sub(".*/"; ""))) + | .builds[0].parts |= map(.path = ($base + "/" + (.path | sub(".*/"; "")))) + ' "$man" > "manifest.json" + cat "manifest.json" + gh release upload beta-fw "manifest.json" -R "${{ github.repository }}" --clobber + find "fw/firmware" -name '*.bin' -print -exec \ + gh release upload beta-fw {} -R "${{ github.repository }}" --clobber \; + echo "Beta assets published." + + - name: Point beta-fw tag at the built commit + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # gh release create tags default-branch HEAD, and uploads never move + # the tag, so without this the release's source commit drifts away + # from the assets actually published. + gh api -X PATCH "repos/${{ github.repository }}/git/refs/tags/beta-fw" \ + -f sha="${{ github.sha }}" -F force=true + echo "beta-fw -> ${{ github.sha }}" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 55e1280..00cea9f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,143 +1,38 @@ -name: Build -env: - DEVICE_NAME: plt-1 # TODO: Rename - RELEASE_URL: ${{ github.server_url }}/${{ github.repository }}/releases/latest +name: Build and Publish on: push: branches: - main + workflow_dispatch: + inputs: + bypass-yaml-check: + description: 'Bypass YAML change check (force build/publish)' + type: boolean + required: false + default: false + # release: + # types: [published] jobs: - build: - name: Build And Release - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Set uppercase DEVICE_NAME - run: | - echo "UPPERCASE_DEVICE_NAME=$(echo $DEVICE_NAME | tr '[:lower:]' '[:upper:]')" >> $GITHUB_ENV - - - name: Build Firmware - uses: esphome/build-action@v3.0.0 - id: esphome-build - with: - yaml_file: Integrations/ESPHome/${{ env.UPPERCASE_DEVICE_NAME }}.yaml - version: 'latest' - cache: true - - - name: Read version from YAML file - id: read_version - run: | - version=$(awk '/substitutions:/ {found=1} found && /version:/ {print $2; exit}' Integrations/ESPHome/Core.yaml | tr -d '"') - echo "project_version=$version" >> $GITHUB_ENV - - - name: Move generated files to output - run: | - mkdir -p output - mv ${{ steps.esphome-build.outputs.name }}/* output/ - echo ${{ steps.esphome-build.outputs.version }} > output/version - # Extract MD5 checksum of firmware.ota.bin - MD5_CHECKSUM=$(jq -r '.ota.md5' output/manifest.json) - echo "MD5_CHECKSUM=$MD5_CHECKSUM" >> $GITHUB_ENV - - # Create new manifest.json with jq - jq -n --arg name "${{ env.DEVICE_NAME }}" \ - --arg version "${{ env.project_version }}" \ - --arg md5 "$MD5_CHECKSUM" \ - '{name: $name, version: $version, home_assistant_domain: "esphome", new_install_prompt_erase: false, builds: [{chipFamily: "ESP32-C3", parts: [{path: "apollo-${{ env.DEVICE_NAME }}-esp32c3.factory.bin", offset: 0}], ota: {path: "apollo-${{ env.DEVICE_NAME }}-esp32c3.ota.bin", md5: $md5}}]}' > output/manifest.json - - - - uses: actions/upload-artifact@v4.3.3 - with: - path: output/ - retention-days: 1 - - - name: Collect merge commits - id: collect_commits - run: | - MERGE_COMMITS=$(git log --merges --pretty=format:"%h %s" $(git describe --tags --abbrev=0 @^)..@) - echo "MERGE_COMMITS=$MERGE_COMMITS" >> $GITHUB_ENV - - - name: Create Release - if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' - id: create_release - uses: actions/create-release@v1 - env: - GITHUB_TOKEN: ${{ secrets.PAT_TOKEN }} - with: - tag_name: ${{ env.project_version }} - release_name: "Release ${{ env.project_version }}" - body: Release of version ${{ env.project_version }} - - draft: false - prerelease: false - - - name: Upload firmware.factory.bin to Release - if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.PAT_TOKEN }} - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: output/apollo-${{ env.DEVICE_NAME }}-esp32c3.factory.bin - asset_name: apollo-${{ env.DEVICE_NAME }}-esp32c3.factory.bin - asset_content_type: application/octet-stream - - - name: Upload firmware.ota.bin to Release - if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.PAT_TOKEN }} - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: output/apollo-${{ env.DEVICE_NAME }}-esp32c3.ota.bin - asset_name: apollo-${{ env.DEVICE_NAME }}-esp32c3.ota.bin - asset_content_type: application/octet-stream - - - name: Upload manifest.json to Release - if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.PAT_TOKEN }} - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: output/manifest.json - asset_name: manifest.json - asset_content_type: application/octet-stream - - - prep: - name: Consolidate firmwares - runs-on: ubuntu-latest - needs: - - build - steps: - - uses: actions/checkout@v4 - - uses: actions/download-artifact@v4.1.7 - with: - path: output - - run: cp -R static/* output/ - - uses: actions/upload-pages-artifact@v3.0.1 - with: - path: output - retention-days: 1 - - deploy: - if: contains(fromJSON('["workflow_dispatch", "push", "schedule"]'), github.event_name) && github.ref == 'refs/heads/main' - name: Deploy to GitHub Pages - runs-on: ubuntu-latest - needs: prep + build-and-publish: + uses: ApolloAutomation/Workflows/.github/workflows/build.yml@main permissions: + contents: write pages: write id-token: write - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - steps: - - name: Setup Pages - uses: actions/configure-pages@v5 - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v4.0.5 + pull-requests: write + with: + device-name: cast_pro-1 + yaml-files: | + Integrations/ESPHome/CAST_PRO-1.yaml + # Maps CAST_PRO-1.yaml to the firmware/ output dir (the shared workflow's + # pattern key is everything after the last dash of the filename). + firmware-names: "1:firmware" + core-yaml-path: Integrations/ESPHome/Core.yaml + esphome-version: stable + # Bypass check if manually triggered with bypass option + check-yaml-changes: ${{ !(github.event_name == 'workflow_dispatch' && github.event.inputs.bypass-yaml-check == 'true') }} + publish-to-pages: true + has-installer: true + installer-path: static \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a697c5a..72eed2b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,19 +3,19 @@ name: CI on: pull_request: +permissions: + contents: read + +# Label check lives in label-check.yml on pull_request_target so it can +# label fork-submitted PRs; plain pull_request tokens are read-only there. jobs: ci: - name: Building ${{ matrix.file }} - runs-on: ubuntu-latest - strategy: - matrix: - file: # TODO: Rename - - Integrations/ESPHome/PLT-1.yaml - - Integrations/ESPHome/PLT-1_BLE.yaml - steps: - - name: Checkout source code - uses: actions/checkout@v4.1.7 - - name: Build ESPHome firmware to verify configuration - uses: esphome/build-action@v3.1.0 - with: - yaml_file: ${{ matrix.file }} \ No newline at end of file + name: CI + uses: ApolloAutomation/Workflows/.github/workflows/esphome-ci.yml@main + with: + yaml-files: | + Integrations/ESPHome/CAST_PRO-1.yaml + esphome-versions: | + stable + beta + dev diff --git a/.github/workflows/label-check.yml b/.github/workflows/label-check.yml new file mode 100644 index 0000000..96a9751 --- /dev/null +++ b/.github/workflows/label-check.yml @@ -0,0 +1,20 @@ +name: Label Check + +# pull_request_target (not pull_request) so the job gets a write token on +# fork-submitted PRs too; plain pull_request runs from forks are read-only +# and cannot add labels. Safe because the called workflow only reads the PR +# body and never checks out or executes PR code. The "edited" type re-runs +# the check when the template checkboxes are changed. +on: + pull_request_target: + types: [opened, edited, reopened, synchronize] + +permissions: + pull-requests: write + issues: write + contents: read + +jobs: + label-check: + name: Label Check + uses: ApolloAutomation/Workflows/.github/workflows/label-check.yml@main diff --git a/.github/workflows/weekly.yml b/.github/workflows/weekly.yml new file mode 100644 index 0000000..c616416 --- /dev/null +++ b/.github/workflows/weekly.yml @@ -0,0 +1,20 @@ +name: Weekly Firmware Build + +on: + schedule: + - cron: '0 0 * * 1' + +permissions: + contents: read + +jobs: + build: + name: Weekly Build + uses: ApolloAutomation/Workflows/.github/workflows/esphome-ci.yml@main + with: + yaml-files: | + Integrations/ESPHome/CAST_PRO-1.yaml + # dev excluded until the TemplateSelect .state incompatibility is fixed + esphome-versions: | + stable + beta diff --git a/.gitignore b/.gitignore index 0132e07..dcb93d4 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,6 @@ Integrations/ESPHome/.DS_Store Integrations/.DS_Store .DS_Store + +/.esphome/ +/secrets.yaml diff --git a/Integrations/ESPHome/.gitignore b/Integrations/ESPHome/.gitignore index d8b4157..1bfe7bd 100644 --- a/Integrations/ESPHome/.gitignore +++ b/Integrations/ESPHome/.gitignore @@ -3,3 +3,4 @@ # You can modify this file to suit your needs. /.esphome/ /secrets.yaml +__pycache__/ diff --git a/Integrations/ESPHome/AIR-1.yaml b/Integrations/ESPHome/AIR-1.yaml deleted file mode 100644 index 09d1a25..0000000 --- a/Integrations/ESPHome/AIR-1.yaml +++ /dev/null @@ -1,53 +0,0 @@ -esphome: - name: "${name}" - friendly_name: Apollo AIR-1 # TODO: Rename - comment: Apollo AIR-1 # TODO: Rename - name_add_mac_suffix: true - platformio_options: - board_build.flash_mode: dio - - project: - name: "ApolloAutomation.AIR-1" # TODO: Rename - version: "${version}" - - min_version: 2023.11.1 - -dashboard_import: - package_import_url: github://ApolloAutomation/AIR-1/Integrations/ESPHome/AIR-1.yaml - import_full_config: false # TODO: Rename - -improv_serial: - -esp32_improv: - authorizer: none - -ota: - - platform: esphome - id: ota_esphome - - platform: http_request - id: ota_managed - -http_request: - verify_ssl: true - -safe_mode: - -update: - - platform: http_request - id: firmware_update - name: Firmware Update - source: https://apolloautomation.github.io/PLT-1/artifact/manifest.json # TODO: Rename - -wifi: - on_connect: - - delay: 5s - - ble.disable: - on_disconnect: - - ble.enable: - ap: - ssid: "Apollo PLT1 Hotspot" # TODO: Rename - -logger: - -packages: - core: !include Core.yaml diff --git a/Integrations/ESPHome/CAST_PRO-1.yaml b/Integrations/ESPHome/CAST_PRO-1.yaml new file mode 100644 index 0000000..e243de5 --- /dev/null +++ b/Integrations/ESPHome/CAST_PRO-1.yaml @@ -0,0 +1,14 @@ +# Apollo CAST_PRO-1; device config lives in Core.yaml + +esphome: + name: "apollo-cast-pro-1" + project: + name: "ApolloAutomation.CAST_PRO-1" + version: ${version} + +dashboard_import: + package_import_url: github://ApolloAutomation/CAST_PRO-1/Integrations/ESPHome/CAST_PRO-1.yaml + import_full_config: false + +packages: + core: !include Core.yaml diff --git a/Integrations/ESPHome/Core.yaml b/Integrations/ESPHome/Core.yaml index 0540c2d..f8960ef 100644 --- a/Integrations/ESPHome/Core.yaml +++ b/Integrations/ESPHome/Core.yaml @@ -1,14 +1,248 @@ substitutions: - name: apollo-plt-1 # TODO: Rename - version: "24.8.7.2" - device_description: ${name} made by Apollo Automation - version ${version}. + version: "26.9.21.1" + stable_manifest_base: "https://apolloautomation.github.io/CAST_PRO-1" + beta_manifest_base: "https://github.com/ApolloAutomation/CAST_PRO-1/releases/download/beta-fw" + stable_manifest_dir: "firmware" + beta_manifest_file: "manifest.json" + + +packages: + wizmote: !include wizmote.yaml + +# Full-duplex i2s_audio; see components/i2s_audio/README.md +external_components: + - source: + type: local + path: components + components: [i2s_audio] + +esphome: + friendly_name: Apollo CAST_PRO-1 + comment: Apollo CAST_PRO-1 + name_add_mac_suffix: true + min_version: 2026.8.0 + platformio_options: + board_build.flash_mode: dio + # List form so wizmote.yaml's on_boot entries merge + on_boot: + - priority: 800 + then: + - if: + condition: + - lambda: "return id(runTest);" + then: + - lambda: "id(testScript).execute();" + # WiFi stays off while Ethernet is expected; after globals (800), before wifi (245) + - priority: 600 + then: + - if: + condition: + lambda: "return id(ethernet_expected);" + then: + - lambda: |- + id(wifi_1).set_enable_on_boot(false); + id(espnow_component).set_wifi_channel(1); + # Without a W5500 its task polls a floating bus and panics; after ethernet (250) + - priority: 200 + then: + - if: + condition: + lambda: "return id(ethernet_1).is_failed();" + then: + - lambda: |- + TaskHandle_t orphan = xTaskGetHandle("w5500_tsk"); + if (orphan != nullptr) { + vTaskSuspend(orphan); + ESP_LOGW("ethernet", "No Ethernet HAT detected; parked the W5500 helper task"); + } + - priority: 500 + then: + - text_sensor.template.publish: + id: apollo_firmware_version + state: "${version}" + - priority: -100 + then: + - script.execute: apply_ota_source + - priority: -100 + then: + - if: + condition: + lambda: "return id(ethernet_expected);" + then: + - wait_until: + condition: + ethernet.connected: + timeout: 20s + - if: + condition: + not: + ethernet.connected: + then: + - lambda: "id(ethernet_expected) = false;" + - wifi.enable: + # Line In needs the ADC and mixer set up first + - priority: -100 + then: + - lambda: "id(line_in_ready) = true;" + - script.execute: line_in_apply + - priority: -300 + then: + - if: + condition: + switch.is_on: bluetooth_proxy_switch + then: + - esp32_ble_tracker.start_scan: + continuous: true + else: + - esp32_ble_tracker.stop_scan: + +ota: + - platform: esphome + id: ota_esphome + - platform: http_request + id: ota_managed + +safe_mode: + id: safe_mode_1 + +logger: + id: logger_1 + +update: + - platform: http_request + id: update_http_request + name: Firmware Update + source: ${stable_manifest_base}/${stable_manifest_dir}/manifest.json + +network: + id: network_1 + priority: + - ethernet + - wifi + +ethernet: + id: ethernet_1 + type: W5500 + clock_speed: 40MHz + clk_pin: GPIO12 + mosi_pin: GPIO11 + miso_pin: GPIO13 + cs_pin: GPIO10 + interrupt_pin: GPIO5 + reset_pin: GPIO21 + on_connect: + # Keep WiFi alongside Ethernet unless it has no network + - if: + condition: + wifi.enabled: + then: + - if: + condition: + wifi.ap_active: + then: + - script.execute: wifi_release_radio + else: + - wait_until: + condition: + or: + - wifi.connected: + - wifi.ap_active: + timeout: 30s + - if: + condition: + wifi.connected: + then: + - lambda: "id(ethernet_expected) = false;" + else: + - if: + condition: + ethernet.connected: + then: + - script.execute: wifi_release_radio + else: + - lambda: "id(ethernet_expected) = true;" + - component.update: update_http_request + - component.update: network_connection + on_disconnect: + - lambda: "id(ethernet_expected) = false;" + - wifi.enable: + - component.update: network_connection + +wifi: + id: wifi_1 + on_connect: + - lambda: "id(wifi_provisioned) = true;" + - component.update: update_http_request + on_disconnect: + # Release WiFi before its 90s fallback hotspot can start + - if: + condition: + ethernet.connected: + then: + - wait_until: + condition: + wifi.connected: + timeout: 60s + - if: + condition: + and: + - wifi.enabled: + - not: + wifi.connected: + - ethernet.connected: + then: + - script.execute: wifi_release_radio + # Workaround: roam scans drop active audio + post_connect_roaming: false + ap: + ssid: "Apollo CAST_PRO-1 Hotspot" + power_save_mode: none + +captive_portal: + id: captive_portal_1 + +improv_serial: + id: improv_serial_1 + +esp32_improv: + id: esp32_improv_1 + authorizer: none esp32: - board: esp32-c3-devkitm-1 + variant: ESP32S3 + flash_size: 8MB + cpu_frequency: 240MHz framework: type: esp-idf + version: recommended + sdkconfig_options: + CONFIG_ESP32S3_DATA_CACHE_32KB: "y" + CONFIG_ESP32S3_DATA_CACHE_LINE_64B: "y" + CONFIG_ESP32S3_INSTRUCTION_CACHE_32KB: "y" + + CONFIG_SPIRAM_RODATA: "y" + CONFIG_SPIRAM_FETCH_INSTRUCTIONS: "y" + + # Allocate BT in PSRAM so internal RAM stays free for audio + CONFIG_BT_ALLOCATION_FROM_SPIRAM_FIRST: "y" + CONFIG_BT_BLE_DYNAMIC_ENV_MEMORY: "y" + + CONFIG_MBEDTLS_EXTERNAL_MEM_ALLOC: "y" + CONFIG_MBEDTLS_SSL_PROTO_TLS1_3: "y" + +esp32_ble_tracker: + id: ble_tracker + scan_parameters: + continuous: true + +bluetooth_proxy: + id: bluetooth_proxy_1 api: + id: api_1 + # Sendspin works without an API client, so don't reboot for lack of one + reboot_timeout: 0s + encryption: globals: - id: cycleCounter @@ -19,8 +253,622 @@ globals: restore_value: no type: uint32_t initial_value: '0' + - id: runTest + restore_value: yes + type: bool + initial_value: "true" + - id: testCycleCount + type: int + restore_value: no + initial_value: "0" + - id: announcement_triggered + type: bool + initial_value: 'false' + # WiFi was released for Ethernet; next boot starts WiFi-silent + - id: ethernet_expected + type: bool + restore_value: yes + initial_value: 'false' + - id: line_in_ready + type: bool + restore_value: no + initial_value: 'false' + # WiFi has connected before; the recovery probe never runs otherwise + - id: wifi_provisioned + type: bool + restore_value: yes + initial_value: 'false' -captive_portal: +psram: + id: psram_1 + mode: octal + speed: 80MHz + # Guarantee PSRAM; unlocks fast network tier + ignore_not_found: false web_server: - port: 80 \ No newline at end of file + id: web_server_1 + port: 80 + version: 3 + +http_request: + id: http_request_1 + verify_ssl: true + # Fits GitHub's ~3.6KB CSP redirect header + buffer_size_rx: 5120 + # Fits GitHub's long signed redirect URL + buffer_size_tx: 2048 + +i2c: + sda: GPIO47 + scl: GPIO48 + id: bus_a + +button: + - platform: restart + id: restart_button + icon: mdi:power-cycle + name: "ESP Reboot" + - platform: template + name: "Firmware Update" + id: update_firmware + icon: mdi:cloud-download + entity_category: config + on_press: + - logger.log: "Applying firmware update for the selected channel" + - delay: 3s + - script.execute: apply_ota_source + - script.wait: apply_ota_source + # Give async manifest fetch time to land + - delay: 5s + - lambda: id(update_http_request).perform(true); + +binary_sensor: + - platform: status + name: Online + id: ink_ha_connected + + - platform: gpio + pin: + number: GPIO0 + inverted: true + ignore_strapping_warning: true + mode: + input: true + pullup: true + id: reset_button + on_press: + then: + - lambda: |- + id(button_press_timestamp) = millis(); + on_release: + then: + - lambda: |- + if (millis() - id(button_press_timestamp) >= 8000) { + id(factory_reset_switch).turn_on(); + } + else if (millis() - id(button_press_timestamp) >= 1000) { + // StatusCheck + id(statusCheck).execute(); + delay(3000); + id(testCycleCount) = 0; + id(runTest) = true; + id(testScript).execute(); + } + +sensor: + - platform: internal_temperature + name: "ESP Temperature" + id: sys_esp_temperature + disabled_by_default: true + + - platform: uptime + name: Uptime + id: sys_uptime + update_interval: 60s + +switch: + - platform: factory_reset + disabled_by_default: True + name: "Factory Reset ESP" + id: factory_reset_all + + - platform: factory_reset + id: factory_reset_switch + internal: true + + - platform: template + name: "Line In" + id: line_in_switch + icon: mdi:audio-input-rca + optimistic: true + restore_mode: RESTORE_DEFAULT_OFF + on_state: + - if: + condition: + lambda: "return id(line_in_ready);" + then: + - script.execute: line_in_apply + + - platform: template + name: "Bluetooth Proxy" + id: bluetooth_proxy_switch + icon: mdi:bluetooth + entity_category: "config" + restore_mode: RESTORE_DEFAULT_OFF + optimistic: true + on_turn_on: + - esp32_ble_tracker.start_scan: + continuous: true + on_turn_off: + - esp32_ble_tracker.stop_scan: + +text_sensor: + - platform: template + name: "Network Connection" + id: network_connection + icon: "mdi:network" + entity_category: "diagnostic" + update_interval: 10s + lambda: |- + const bool eth = id(ethernet_1).is_connected(); + const bool sta = !id(wifi_1).is_disabled() && id(wifi_1).is_connected(); + const bool ap = !id(wifi_1).is_disabled() && id(wifi_1).is_ap_active(); + if (eth && sta) return std::string("Ethernet + WiFi"); + if (eth) return std::string("Ethernet"); + if (sta) return std::string("WiFi"); + if (ap) return std::string("Hotspot"); + return std::string("Connecting"); + + - platform: ethernet_info + ip_address: + name: "Ethernet IP Address" + id: eth_ip + entity_category: "diagnostic" + + - platform: wifi_info + ip_address: + name: "WiFi IP Address" + id: wifi_ip + entity_category: "diagnostic" + + - platform: sendspin + id: sendspin_track_title + type: title + name: Track Title + + - platform: sendspin + id: sendspin_artist + type: artist + name: Artist + + - platform: sendspin + id: sendspin_album + type: album + name: Album + + - platform: version + id: esphome_version_sensor + name: "ESPHome Version" + hide_timestamp: true + entity_category: "diagnostic" + + - platform: template + name: "Apollo Firmware Version" + id: apollo_firmware_version + update_interval: never + entity_category: "diagnostic" + +light: + - platform: esp32_rmt_led_strip + id: rgb_light + name: "RGB Light" + pin: GPIO4 + default_transition_length: 0s + chipset: WS2812 + num_leds: 4 + channel_colors: GRB + effects: + - pulse: + name: "Slow Pulse" + transition_length: 1000ms + update_interval: 1000ms + min_brightness: 50% + max_brightness: 100% + - pulse: + name: "Fast Pulse" + transition_length: 100ms + update_interval: 100ms + min_brightness: 50% + max_brightness: 100% + +select: + - platform: template + id: firmware_channel + name: "Firmware Channel" + icon: "mdi:source-branch" + entity_category: config + optimistic: true + restore_value: true + options: + - "Stable" + - "Beta" + initial_option: "Stable" + on_value: + then: + - script.execute: apply_ota_source + + - platform: template + id: audio_output + name: "Audio Output" + icon: "mdi:speaker" + entity_category: config + optimistic: true + restore_value: true + options: + - "Analog" + - "Optical" + initial_option: "Analog" + on_value: + then: + - if: + condition: + lambda: 'return x == "Optical";' + then: + - router.speaker.switch_output: + id: output_router + target_speaker: spdif_speaker + else: + - router.speaker.switch_output: + id: output_router + target_speaker: dac_speaker + +audio_dac: + - platform: pcm5122 + id: external_dac + i2c_id: bus_a + address: 0x4D + bits_per_sample: 32bit + +# DAC and ADC share one port in full duplex (SPDIF has the other): 48 kHz stereo, 32-bit slots +i2s_audio: + - id: i2s_main + i2s_lrclk_pin: GPIO7 + i2s_bclk_pin: GPIO14 + i2s_mclk_pin: GPIO17 + full_duplex: true + - id: i2s_spdif + +speaker: + - platform: i2s_audio + id: dac_speaker + i2s_audio_id: i2s_main + sample_rate: 48000 + i2s_dout_pin: GPIO9 + bits_per_sample: 32bit + dac_type: external + channel: stereo + timeout: never + buffer_duration: 100ms + + - platform: i2s_audio + id: spdif_speaker + i2s_audio_id: i2s_spdif + i2s_dout_pin: GPIO16 + dac_type: external + spdif_mode: true + # Required by validation; the S3 has no APLL + use_apll: true + sample_rate: 48000 + bits_per_sample: 32bit + channel: stereo + timeout: never + buffer_duration: 100ms + + - platform: router + id: output_router + output_speakers: + - dac_speaker + - spdif_speaker + sample_rate: 48000 + bits_per_sample: 32 + num_channels: 2 + + - platform: mixer + id: mixer_speaker_id + output_speaker: output_router + num_channels: 2 + task_stack_in_psram: true + source_speakers: + - id: announcement_mixer_input + timeout: never + - id: media_mixer_input + timeout: never + - id: line_in_mixer_input + timeout: never + + # These stacks must stay in internal RAM, so leave task_stack_in_psram unset + - platform: resampler + id: announcement_resampling_speaker + output_speaker: announcement_mixer_input + sample_rate: 48000 + bits_per_sample: 32 + + - platform: resampler + id: media_resampling_speaker + output_speaker: media_mixer_input + sample_rate: 48000 + bits_per_sample: 32 + + +# PCM1808: MD0/MD1/FMT tied low (slave, I2S) +microphone: + - platform: i2s_audio + id: line_in_adc + i2s_audio_id: i2s_main + adc_type: external + i2s_din_pin: GPIO15 + sample_rate: 48000 + bits_per_sample: 32bit + channel: stereo + on_data: + - lambda: |- + if (id(line_in_switch).state) { + id(line_in_mixer_input)->play(x.data(), x.size(), 0); + } + +sendspin: + id: sendspin_hub + # PSRAM stack skews time sync; keep internal + task_stack_in_psram: false + +media_source: + - platform: sendspin + id: sendspin_source + + - platform: audio_http + id: http_media_source + buffer_size: 500000 + + - platform: audio_http + id: http_announcement_source + buffer_size: 250000 + +media_player: + - platform: sendspin + id: sendspin_group_media_player + name: "Sendspin Player" + + - platform: speaker_source + id: external_media_player + name: "Player" + volume_increment: 0.05 + + announcement_pipeline: + format: FLAC + num_channels: 1 + sample_rate: 48000 + speaker: announcement_resampling_speaker + sources: + - http_announcement_source + + media_pipeline: + speaker: media_resampling_speaker + format: FLAC + num_channels: 2 + sample_rate: 48000 + sources: + - sendspin_source + - http_media_source + + on_announcement: + - if: + condition: + - lambda: return (!id(announcement_triggered)); + then: + - lambda: id(announcement_triggered) = true; + - light.turn_on: + id: rgb_light + blue: 100% + red: 0% + green: 100% + brightness: 50% + - mixer_speaker.apply_ducking: + id: media_mixer_input + decibel_reduction: 40 + duration: 0.0s + - mixer_speaker.apply_ducking: + id: line_in_mixer_input + decibel_reduction: 40 + duration: 0.0s + - wait_until: + media_player.is_announcing: external_media_player + - wait_until: + - not: + media_player.is_announcing: external_media_player + - mixer_speaker.apply_ducking: + id: media_mixer_input + decibel_reduction: 0 + duration: 1.0s + - mixer_speaker.apply_ducking: + id: line_in_mixer_input + decibel_reduction: 0 + duration: 1.0s + - light.turn_off: + id: rgb_light + - lambda: id(announcement_triggered) = false; + + on_state: + # pcm5122 boots muted; unmute (idempotent) + - audio_dac.mute_off: external_dac + - if: + condition: + and: + - lambda: return (!id(announcement_triggered)); + - not: + media_player.is_announcing: external_media_player + then: + - mixer_speaker.apply_ducking: + id: media_mixer_input + decibel_reduction: 0 + duration: 1.0s + - mixer_speaker.apply_ducking: + id: line_in_mixer_input + decibel_reduction: 0 + duration: 1.0s + + + + + +# Retry a released WiFi while on Ethernet +interval: + - id: wifi_recovery_interval + interval: 10min + then: + - if: + condition: + and: + - ethernet.connected: + - not: + wifi.enabled: + - lambda: "return id(wifi_provisioned);" + then: + - script.execute: wifi_recovery_probe + +script: + - id: line_in_apply + then: + - if: + condition: + switch.is_on: line_in_switch + then: + - lambda: |- + id(line_in_mixer_input)->set_audio_stream_info(audio::AudioStreamInfo(32, 2, 48000)); + - microphone.capture: line_in_adc + else: + - microphone.stop_capture: line_in_adc + - lambda: |- + if (!id(line_in_mixer_input)->is_stopped()) { + id(line_in_mixer_input)->stop(); + } + + - id: wifi_recovery_probe + then: + - wifi.enable: + - wait_until: + condition: + wifi.connected: + timeout: 60s + - if: + condition: + wifi.connected: + then: + - lambda: "id(ethernet_expected) = false;" + else: + - if: + condition: + ethernet.connected: + then: + - script.execute: wifi_release_radio + + - id: wifi_release_radio + # Also bounces ESP-NOW so it re-inits standalone for the WizMote + then: + - lambda: "id(ethernet_expected) = true;" + - if: + condition: + wifi.ap_active: + then: + # A started AP can't be relaunched in the same boot, so reboot + - lambda: |- + ESP_LOGW("network", "Rebooting to fully release the active hotspot (one-time)"); + - delay: 500ms + - lambda: "App.safe_reboot();" + else: + - wifi.disable: + - lambda: |- + if (id(captive_portal_1).is_active()) { + id(captive_portal_1).end(); + } + id(espnow_component).disable(); + id(espnow_component).enable(); + + - id: apply_ota_source + then: + - lambda: |- + const bool beta = id(firmware_channel).current_option() == "Beta"; + const char *url = beta + ? "${beta_manifest_base}/${beta_manifest_file}" + : "${stable_manifest_base}/${stable_manifest_dir}/manifest.json"; + ESP_LOGI("firmware", "OTA manifest set to: %s", url); + id(update_http_request).set_source_url(url); + - component.update: update_http_request + - id: statusCheck + then: + - if: + condition: + - lambda: 'return id(ink_ha_connected).state;' + then: + - logger.log: "Apollo Automation: Connected To HA" + - light.turn_on: + id: rgb_light + brightness: 100% + red: 0% + green: 0% + blue: 100% + - delay: 5s + - light.turn_off: rgb_light + + - id: testScript + then: + if: + condition: + - lambda: "return id(runTest) == true;" + then: + - lambda: "id(runTest) = false;" + - lambda: "id(testCycleCount) = 0;" + - delay: 2s + - while: + condition: + - lambda: "return id(testCycleCount) < 5;" + then: + - lambda: "id(runTest) = false;" + - delay: 1s + - lambda: "id(testCycleCount) += 1;" + + ## TODO: Update + - if: + condition: + - lambda: "return true;" + then: + - lambda: "id(runTest) = false;" + - light.turn_on: + id: rgb_light + red: 0% + green: 100% + blue: 0% + - delay: 5s + - light.turn_on: + id: rgb_light + red: 0% + green: 0% + blue: 0% + - light.turn_off: + id: rgb_light + + else: + - lambda: "id(runTest) = false;" + - light.turn_on: + id: rgb_light + red: 100% + green: 0% + blue: 0% + - delay: 5s + - light.turn_on: + id: rgb_light + red: 0% + green: 0% + blue: 0% + - light.turn_off: + id: rgb_light diff --git a/Integrations/ESPHome/components/i2s_audio/README.md b/Integrations/ESPHome/components/i2s_audio/README.md new file mode 100644 index 0000000..0d3bb68 --- /dev/null +++ b/Integrations/ESPHome/components/i2s_audio/README.md @@ -0,0 +1,19 @@ +# i2s_audio (full duplex) + +ESPHome 2026.9.0's `i2s_audio` with full duplex support, so the PCM5122 DAC and +PCM1808 ADC can share one I2S port (common WS/BCLK/MCLK, separate DOUT/DIN). + +Based on esphome/esphome#16882 (head `3db4594`), with these changes: + +- The speaker joins a TX channel the microphone already started without + skewing its playback timestamps (Sendspin sync), and realigns its write + position each session since TX is never reset. +- The DMA ring is fixed at the speaker's 5 x 10 ms, whichever side allocates + first, so stall tolerance doesn't depend on start order. +- TX always auto-clears, so the DAC plays silence rather than looping the last + buffer when the microphone allocated the channels first. +- Both channels get both data pins, whichever side initializes first. +- Mismatched speaker/microphone formats are rejected at config time and at + runtime, instead of playing at the wrong speed. + +Drop this override once full duplex lands upstream. diff --git a/Integrations/ESPHome/components/i2s_audio/__init__.py b/Integrations/ESPHome/components/i2s_audio/__init__.py new file mode 100644 index 0000000..2bea84d --- /dev/null +++ b/Integrations/ESPHome/components/i2s_audio/__init__.py @@ -0,0 +1,352 @@ +from dataclasses import dataclass, field + +from esphome import pins +import esphome.codegen as cg +from esphome.components.esp32 import ( + add_idf_sdkconfig_option, + get_esp32_variant, + include_builtin_idf_component, +) +from esphome.components.esp32.const import ( + VARIANT_ESP32, + VARIANT_ESP32C3, + VARIANT_ESP32C5, + VARIANT_ESP32C6, + VARIANT_ESP32C61, + VARIANT_ESP32H2, + VARIANT_ESP32P4, + VARIANT_ESP32S2, + VARIANT_ESP32S3, +) +import esphome.config_validation as cv +from esphome.const import CONF_BITS_PER_SAMPLE, CONF_CHANNEL, CONF_ID, CONF_SAMPLE_RATE +from esphome.core import CORE +from esphome.cpp_generator import MockObj, MockObjClass +import esphome.final_validate as fv +from esphome.types import ConfigType + +CODEOWNERS = ["@jesserockz"] +DEPENDENCIES = ["esp32"] +MULTI_CONF = True + +CONF_PDM = "pdm" +CONF_PDM_DSR = "pdm_dsr" +CONF_ADC_TYPE = "adc_type" + +CONF_I2S_DOUT_PIN = "i2s_dout_pin" +CONF_I2S_DIN_PIN = "i2s_din_pin" +CONF_I2S_MCLK_PIN = "i2s_mclk_pin" +CONF_I2S_BCLK_PIN = "i2s_bclk_pin" +CONF_I2S_LRCLK_PIN = "i2s_lrclk_pin" + +CONF_I2S_AUDIO = "i2s_audio" +CONF_I2S_AUDIO_ID = "i2s_audio_id" + +CONF_I2S_MODE = "i2s_mode" +CONF_FULL_DUPLEX = "full_duplex" +CONF_PRIMARY = "primary" +CONF_SECONDARY = "secondary" + +CONF_USE_APLL = "use_apll" +CONF_BITS_PER_CHANNEL = "bits_per_channel" +CONF_MCLK_MULTIPLE = "mclk_multiple" +CONF_MONO = "mono" +CONF_LEFT = "left" +CONF_RIGHT = "right" +CONF_STEREO = "stereo" +CONF_BOTH = "both" + +i2s_audio_ns = cg.esphome_ns.namespace("i2s_audio") +I2SAudioComponent = i2s_audio_ns.class_("I2SAudioComponent", cg.Component) +I2SAudioBase = i2s_audio_ns.class_( + "I2SAudioBase", cg.Parented.template(I2SAudioComponent) +) +I2SAudioIn = i2s_audio_ns.class_("I2SAudioIn", I2SAudioBase) +I2SAudioOut = i2s_audio_ns.class_("I2SAudioOut", I2SAudioBase) + +i2s_mode_t = cg.global_ns.enum("i2s_mode_t") +I2S_MODE_OPTIONS = { + CONF_PRIMARY: i2s_mode_t.I2S_MODE_MASTER, # NOLINT + CONF_SECONDARY: i2s_mode_t.I2S_MODE_SLAVE, # NOLINT +} + +i2s_role_t = cg.global_ns.enum("i2s_role_t") +I2S_ROLE_OPTIONS = { + CONF_PRIMARY: i2s_role_t.I2S_ROLE_MASTER, # NOLINT + CONF_SECONDARY: i2s_role_t.I2S_ROLE_SLAVE, # NOLINT +} + +# https://github.com/espressif/esp-idf/blob/master/components/soc/{variant}/include/soc/soc_caps.h (SOC_I2S_NUM) +I2S_PORTS = { + VARIANT_ESP32: 2, + VARIANT_ESP32C3: 1, + VARIANT_ESP32C5: 1, + VARIANT_ESP32C6: 1, + VARIANT_ESP32C61: 1, + VARIANT_ESP32H2: 1, + VARIANT_ESP32P4: 3, + VARIANT_ESP32S2: 1, + VARIANT_ESP32S3: 2, +} + +i2s_channel_fmt_t = cg.global_ns.enum("i2s_channel_fmt_t") +I2S_CHANNELS = { + CONF_MONO: i2s_channel_fmt_t.I2S_CHANNEL_FMT_ALL_LEFT, # left data to both channels + CONF_LEFT: i2s_channel_fmt_t.I2S_CHANNEL_FMT_ONLY_LEFT, # mono data + CONF_RIGHT: i2s_channel_fmt_t.I2S_CHANNEL_FMT_ONLY_RIGHT, # mono data + CONF_STEREO: i2s_channel_fmt_t.I2S_CHANNEL_FMT_RIGHT_LEFT, # stereo data to both channels +} + +i2s_slot_mode_t = cg.global_ns.enum("i2s_slot_mode_t") +I2S_SLOT_MODE = { + CONF_MONO: i2s_slot_mode_t.I2S_SLOT_MODE_MONO, + CONF_STEREO: i2s_slot_mode_t.I2S_SLOT_MODE_STEREO, +} + +i2s_std_slot_mask_t = cg.global_ns.enum("i2s_std_slot_mask_t") +I2S_STD_SLOT_MASK = { + CONF_LEFT: i2s_std_slot_mask_t.I2S_STD_SLOT_LEFT, + CONF_RIGHT: i2s_std_slot_mask_t.I2S_STD_SLOT_RIGHT, + CONF_BOTH: i2s_std_slot_mask_t.I2S_STD_SLOT_BOTH, +} + +i2s_bits_per_sample_t = cg.global_ns.enum("i2s_bits_per_sample_t") +I2S_BITS_PER_SAMPLE = { + 8: i2s_bits_per_sample_t.I2S_BITS_PER_SAMPLE_8BIT, + 16: i2s_bits_per_sample_t.I2S_BITS_PER_SAMPLE_16BIT, + 24: i2s_bits_per_sample_t.I2S_BITS_PER_SAMPLE_24BIT, + 32: i2s_bits_per_sample_t.I2S_BITS_PER_SAMPLE_32BIT, +} + +i2s_bits_per_chan_t = cg.global_ns.enum("i2s_bits_per_chan_t") +I2S_BITS_PER_CHANNEL = { + "default": i2s_bits_per_chan_t.I2S_BITS_PER_CHAN_DEFAULT, + 8: i2s_bits_per_chan_t.I2S_BITS_PER_CHAN_8BIT, + 16: i2s_bits_per_chan_t.I2S_BITS_PER_CHAN_16BIT, + 24: i2s_bits_per_chan_t.I2S_BITS_PER_CHAN_24BIT, + 32: i2s_bits_per_chan_t.I2S_BITS_PER_CHAN_32BIT, +} + +i2s_slot_bit_width_t = cg.global_ns.enum("i2s_slot_bit_width_t") +I2S_SLOT_BIT_WIDTH = { + "default": i2s_slot_bit_width_t.I2S_SLOT_BIT_WIDTH_AUTO, + 8: i2s_slot_bit_width_t.I2S_SLOT_BIT_WIDTH_8BIT, + 16: i2s_slot_bit_width_t.I2S_SLOT_BIT_WIDTH_16BIT, + 24: i2s_slot_bit_width_t.I2S_SLOT_BIT_WIDTH_24BIT, + 32: i2s_slot_bit_width_t.I2S_SLOT_BIT_WIDTH_32BIT, +} + +i2s_mclk_multiple_t = cg.global_ns.enum("i2s_mclk_multiple_t") +I2S_MCLK_MULTIPLE = { + 128: i2s_mclk_multiple_t.I2S_MCLK_MULTIPLE_128, + 256: i2s_mclk_multiple_t.I2S_MCLK_MULTIPLE_256, + 384: i2s_mclk_multiple_t.I2S_MCLK_MULTIPLE_384, + 512: i2s_mclk_multiple_t.I2S_MCLK_MULTIPLE_512, +} + +_validate_bits = cv.float_with_unit("bits", "bit") + + +def validate_mclk_divisible_by_3(config: ConfigType) -> ConfigType: + if config[CONF_BITS_PER_SAMPLE] == 24 and config[CONF_MCLK_MULTIPLE] % 3 != 0: + raise cv.Invalid( + f"{CONF_MCLK_MULTIPLE} must be divisible by 3 when bits per sample is 24" + ) + return config + + +def i2s_audio_component_schema( + class_: MockObjClass, + *, + default_sample_rate: int, + default_channel: str, + default_bits_per_sample: str, +) -> cv.Schema: + return cv.Schema( + { + cv.GenerateID(): cv.declare_id(class_), + cv.GenerateID(CONF_I2S_AUDIO_ID): cv.use_id(I2SAudioComponent), + cv.Optional(CONF_CHANNEL, default=default_channel): cv.one_of( + *I2S_CHANNELS + ), + cv.Optional(CONF_SAMPLE_RATE, default=default_sample_rate): cv.int_range( + min=1 + ), + cv.Optional(CONF_BITS_PER_SAMPLE, default=default_bits_per_sample): cv.All( + _validate_bits, cv.int_, cv.one_of(*I2S_BITS_PER_SAMPLE) + ), + cv.Optional(CONF_I2S_MODE, default=CONF_PRIMARY): cv.one_of( + *I2S_MODE_OPTIONS, lower=True + ), + cv.Optional(CONF_USE_APLL, default=False): cv.boolean, + cv.Optional(CONF_MCLK_MULTIPLE, default=256): cv.one_of(*I2S_MCLK_MULTIPLE), + } + ) + + +async def register_i2s_audio_component(var: MockObj, config: ConfigType) -> None: + await cg.register_parented(var, config[CONF_I2S_AUDIO_ID]) + cg.add(var.set_i2s_role(I2S_ROLE_OPTIONS[config[CONF_I2S_MODE]])) + slot_mode = config[CONF_CHANNEL] + if slot_mode != CONF_STEREO: + slot_mode = CONF_MONO + slot_mask = config[CONF_CHANNEL] + if slot_mask not in [CONF_LEFT, CONF_RIGHT]: + slot_mask = CONF_BOTH + cg.add(var.set_slot_mode(I2S_SLOT_MODE[slot_mode])) + cg.add(var.set_std_slot_mask(I2S_STD_SLOT_MASK[slot_mask])) + cg.add(var.set_slot_bit_width(I2S_SLOT_BIT_WIDTH[config[CONF_BITS_PER_SAMPLE]])) + cg.add(var.set_sample_rate(config[CONF_SAMPLE_RATE])) + cg.add(var.set_use_apll(config[CONF_USE_APLL])) + cg.add(var.set_mclk_multiple(I2S_MCLK_MULTIPLE[config[CONF_MCLK_MULTIPLE]])) + + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(I2SAudioComponent), + cv.Optional(CONF_I2S_LRCLK_PIN): pins.internal_gpio_output_pin_number, + cv.Optional(CONF_I2S_BCLK_PIN): pins.internal_gpio_output_pin_number, + cv.Optional(CONF_I2S_MCLK_PIN): pins.internal_gpio_output_pin_number, + cv.Optional(CONF_FULL_DUPLEX, default=False): cv.boolean, + }, +) + + +@dataclass +class I2SAudioData: + """I2S audio component state stored in CORE.data.""" + + port_map: dict[str, int] = field(default_factory=dict) + + +def _get_data() -> I2SAudioData: + if CONF_I2S_AUDIO not in CORE.data: + CORE.data[CONF_I2S_AUDIO] = I2SAudioData() + return CORE.data[CONF_I2S_AUDIO] + + +def _assign_ports() -> None: + """Assign I2S port numbers, prioritizing instances with microphone children. + + Microphones (especially PDM) require port 0 on most ESP32 variants. + This runs once and stores the mapping in CORE.data. + """ + data = _get_data() + if data.port_map: + return + + full_config = fv.full_config.get() + i2s_configs = full_config[CONF_I2S_AUDIO] + + # Find i2s_audio instances with microphones that require port 0 + # (PDM and internal ADC only work on I2S port 0) + port0_parent_id = None + for mic_config in full_config.get("microphone", []): + if CONF_I2S_AUDIO_ID not in mic_config: + continue + if mic_config.get(CONF_PDM) or mic_config.get(CONF_ADC_TYPE) == "internal": + if port0_parent_id is not None: + raise cv.Invalid( + "Only one PDM/ADC microphone is supported (requires I2S port 0)" + ) + port0_parent_id = str(mic_config[CONF_I2S_AUDIO_ID]) + + # Assign ports: port 0 parent first (if any), rest get sequential + next_port = 0 + if port0_parent_id is not None: + data.port_map[port0_parent_id] = next_port + next_port += 1 + for config in i2s_configs: + config_id = str(config[CONF_ID]) + if config_id != port0_parent_id: + data.port_map[config_id] = next_port + next_port += 1 + + +def _validate_full_duplex(i2s_audio_configs: list[ConfigType]) -> None: + """Full duplex runs TX and RX from one clock and slot layout, so every user must agree on the format.""" + full_config = fv.full_config.get() + for bus in i2s_audio_configs: + if not bus[CONF_FULL_DUPLEX]: + continue + bus_id = str(bus[CONF_ID]) + users = [ + (domain, conf) + for domain in ("speaker", "microphone") + for conf in full_config.get(domain, []) + if conf.get("platform") == CONF_I2S_AUDIO + and str(conf.get(CONF_I2S_AUDIO_ID)) == bus_id + ] + speakers = [conf for domain, conf in users if domain == "speaker"] + microphones = [conf for domain, conf in users if domain == "microphone"] + if len(speakers) > 1 or len(microphones) > 1: + raise cv.Invalid( + f"Full duplex bus '{bus_id}' supports one speaker and one microphone" + ) + if any(conf.get("spdif_mode") for conf in speakers): + raise cv.Invalid(f"Full duplex bus '{bus_id}' does not support SPDIF mode") + if any(conf.get(CONF_PDM) for conf in microphones): + raise cv.Invalid(f"Full duplex bus '{bus_id}' does not support PDM") + # The microphone is always Philips I2S, and both channels share one slot config + if any(conf.get("i2s_comm_fmt", "stand_i2s") != "stand_i2s" for conf in speakers): + raise cv.Invalid( + f"Full duplex bus '{bus_id}' requires 'i2s_comm_fmt: stand_i2s'" + ) + + def _format(conf: ConfigType) -> tuple: + slot_mode = CONF_STEREO if conf[CONF_CHANNEL] == CONF_STEREO else CONF_MONO + return ( + conf[CONF_SAMPLE_RATE], + conf[CONF_BITS_PER_SAMPLE], + slot_mode, + conf[CONF_MCLK_MULTIPLE], + conf[CONF_I2S_MODE], + ) + + formats = {_format(conf) for _, conf in users} + if len(formats) > 1: + raise cv.Invalid( + f"Full duplex bus '{bus_id}' needs the speaker and microphone to share sample_rate, " + f"bits_per_sample, stereo/mono channel layout, mclk_multiple and i2s_mode" + ) + + +def _final_validate(_: ConfigType) -> None: + i2s_audio_configs = fv.full_config.get()[CONF_I2S_AUDIO] + variant = get_esp32_variant() + if variant not in I2S_PORTS: + raise cv.Invalid(f"Unsupported variant {variant}") + if len(i2s_audio_configs) > I2S_PORTS[variant]: + raise cv.Invalid( + f"Only {I2S_PORTS[variant]} I2S audio ports are supported on {variant}" + ) + _validate_full_duplex(i2s_audio_configs) + _assign_ports() + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + + # Assign I2S port from _final_validate computed mapping + data = _get_data() + if (port := data.port_map.get(str(config[CONF_ID]))) is None: + raise ValueError(f"No I2S port assigned for {config[CONF_ID]}") + cg.add(var.set_port(port)) + + # Re-enable ESP-IDF's I2S driver (excluded by default to save compile time) + include_builtin_idf_component("esp_driver_i2s") + + # Helps avoid callbacks being skipped due to processor load + add_idf_sdkconfig_option("CONFIG_I2S_ISR_IRAM_SAFE", True) + + if CONF_I2S_LRCLK_PIN in config: + cg.add(var.set_lrclk_pin(config[CONF_I2S_LRCLK_PIN])) + if CONF_I2S_BCLK_PIN in config: + cg.add(var.set_bclk_pin(config[CONF_I2S_BCLK_PIN])) + if CONF_I2S_MCLK_PIN in config: + cg.add(var.set_mclk_pin(config[CONF_I2S_MCLK_PIN])) + cg.add(var.set_full_duplex(config[CONF_FULL_DUPLEX])) diff --git a/Integrations/ESPHome/components/i2s_audio/i2s_audio.cpp b/Integrations/ESPHome/components/i2s_audio/i2s_audio.cpp new file mode 100644 index 0000000..2303bff --- /dev/null +++ b/Integrations/ESPHome/components/i2s_audio/i2s_audio.cpp @@ -0,0 +1,339 @@ +#include "i2s_audio.h" + +#ifdef USE_ESP32 + +#include "esphome/core/log.h" + +#include +#include +#include + +#include +#include "esp_rom_sys.h" +#include "esp_timer.h" + +namespace esphome::i2s_audio { + +static const char *const TAG = "i2s_audio"; + +// The shared DMA ring is fixed rather than taken from whichever side allocates first, so the speaker keeps its +// usual stall tolerance even when the microphone starts first. Matches the standard speaker's 5 x 10 ms ring. +static constexpr uint32_t FULL_DUPLEX_DMA_BUFFER_DURATION_MS = 10; +static constexpr uint32_t FULL_DUPLEX_DMA_BUFFERS_COUNT = 5; +#if SOC_CACHE_INTERNAL_MEM_VIA_L1CACHE +static constexpr uint32_t FULL_DUPLEX_DMA_BUFFER_MAX_SIZE = DMA_DESCRIPTOR_BUFFER_MAX_SIZE_64B_ALIGNED; +#else +static constexpr uint32_t FULL_DUPLEX_DMA_BUFFER_MAX_SIZE = DMA_DESCRIPTOR_BUFFER_MAX_SIZE_4B_ALIGNED; +#endif +// Long enough for an ISR that fired on_sent before a queue reset to also release its buffer +static constexpr uint32_t FULL_DUPLEX_ISR_SETTLE_US = 50; + +static uint32_t effective_slot_bit_width(const i2s_std_slot_config_t &slot_cfg) { + if (slot_cfg.slot_bit_width == I2S_SLOT_BIT_WIDTH_AUTO) { + return static_cast(slot_cfg.data_bit_width); + } + return static_cast(slot_cfg.slot_bit_width); +} + +esp_err_t I2SAudioComponent::allocate_full_duplex_channels_(const i2s_chan_config_t &chan_cfg, + const i2s_std_config_t &std_cfg) { + if (this->rx_handle_ != nullptr && this->tx_handle_ != nullptr) { + return ESP_OK; + } + if (!this->full_duplex_) { + return ESP_ERR_INVALID_STATE; + } + if (this->din_pin_ == I2S_GPIO_UNUSED || this->dout_pin_ == I2S_GPIO_UNUSED) { + ESP_LOGE(TAG, "Full duplex requires both DIN and DOUT pins on the same I2S bus"); + return ESP_ERR_INVALID_ARG; + } + + // Both channels share this config. If the microphone allocates first, its config has auto clear off, and + // the DAC would then loop the last buffer whenever the speaker stops writing + i2s_chan_config_t duplex_cfg = chan_cfg; + duplex_cfg.auto_clear = true; + const uint32_t frame_bytes = (static_cast(std_cfg.slot_cfg.data_bit_width) / 8) * + (std_cfg.slot_cfg.slot_mode == I2S_SLOT_MODE_STEREO ? 2 : 1); + duplex_cfg.dma_desc_num = FULL_DUPLEX_DMA_BUFFERS_COUNT; + duplex_cfg.dma_frame_num = std::min(std_cfg.clk_cfg.sample_rate_hz * FULL_DUPLEX_DMA_BUFFER_DURATION_MS / 1000, + FULL_DUPLEX_DMA_BUFFER_MAX_SIZE / frame_bytes); + + esp_err_t err = i2s_new_channel(&duplex_cfg, &this->tx_handle_, &this->rx_handle_); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Full duplex channel allocation failed: %s", esp_err_to_name(err)); + this->tx_handle_ = nullptr; + this->rx_handle_ = nullptr; + this->full_duplex_dma_desc_num_ = 0; + } else { + this->full_duplex_dma_desc_num_ = duplex_cfg.dma_desc_num; + } + return err; +} + +esp_err_t I2SAudioComponent::initialize_full_duplex_channels_(const i2s_std_config_t &std_cfg) { + if (this->tx_handle_ == nullptr || this->rx_handle_ == nullptr) { + return ESP_ERR_INVALID_STATE; + } + + if (this->tx_channel_initialized_) { + // Channels keep the first user's clock and slot layout; a different format would play at the wrong speed + if (std_cfg.clk_cfg.sample_rate_hz != this->full_duplex_sample_rate_ || + static_cast(std_cfg.slot_cfg.data_bit_width) != this->full_duplex_data_bit_width_ || + effective_slot_bit_width(std_cfg.slot_cfg) != this->full_duplex_slot_bit_width_ || + std_cfg.slot_cfg.slot_mode != this->full_duplex_slot_mode_) { + ESP_LOGE(TAG, + "Full duplex format mismatch: bus runs %" PRIu32 " Hz, %" PRIu32 "-bit data in %" PRIu32 + "-bit %s slots, requested %" PRIu32 " Hz, %" PRIu32 "-bit data in %" PRIu32 "-bit %s slots", + this->full_duplex_sample_rate_, this->full_duplex_data_bit_width_, this->full_duplex_slot_bit_width_, + this->full_duplex_slot_mode_ == I2S_SLOT_MODE_STEREO ? "stereo" : "mono", + std_cfg.clk_cfg.sample_rate_hz, static_cast(std_cfg.slot_cfg.data_bit_width), + effective_slot_bit_width(std_cfg.slot_cfg), + std_cfg.slot_cfg.slot_mode == I2S_SLOT_MODE_STEREO ? "stereo" : "mono"); + return ESP_ERR_INVALID_ARG; + } + } + + // Each user's pin config only names its own data pin, but both channels are initialized here + i2s_std_config_t duplex_cfg = std_cfg; + duplex_cfg.gpio_cfg = this->get_full_duplex_pin_config(); + + if (!this->tx_channel_initialized_) { + esp_err_t err = i2s_channel_init_std_mode(this->tx_handle_, &duplex_cfg); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to initialize full duplex TX channel: %s", esp_err_to_name(err)); + return err; + } + this->tx_channel_initialized_ = true; + this->full_duplex_sample_rate_ = std_cfg.clk_cfg.sample_rate_hz; + this->full_duplex_data_bit_width_ = static_cast(std_cfg.slot_cfg.data_bit_width); + this->full_duplex_slot_bit_width_ = effective_slot_bit_width(std_cfg.slot_cfg); + this->full_duplex_slot_mode_ = std_cfg.slot_cfg.slot_mode; + } + + if (!this->rx_channel_initialized_) { + esp_err_t err = i2s_channel_init_std_mode(this->rx_handle_, &duplex_cfg); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to initialize full duplex RX channel: %s", esp_err_to_name(err)); + return err; + } + this->rx_channel_initialized_ = true; + } + + return ESP_OK; +} + +esp_err_t I2SAudioComponent::setup_full_duplex_rx_channel(const i2s_chan_config_t &chan_cfg, + const i2s_std_config_t &std_cfg, + i2s_chan_handle_t *rx_handle) { + LockGuard guard(this->full_duplex_lock_); + esp_err_t err = this->allocate_full_duplex_channels_(chan_cfg, std_cfg); + if (err != ESP_OK) { + return err; + } + err = this->initialize_full_duplex_channels_(std_cfg); + if (err == ESP_OK) { + *rx_handle = this->rx_handle_; + } + return err; +} + +esp_err_t I2SAudioComponent::setup_full_duplex_tx_channel(const i2s_chan_config_t &chan_cfg, + const i2s_std_config_t &std_cfg, + i2s_chan_handle_t *tx_handle) { + LockGuard guard(this->full_duplex_lock_); + esp_err_t err = this->allocate_full_duplex_channels_(chan_cfg, std_cfg); + if (err != ESP_OK) { + return err; + } + err = this->initialize_full_duplex_channels_(std_cfg); + if (err == ESP_OK) { + *tx_handle = this->tx_handle_; + } + return err; +} + +size_t I2SAudioComponent::get_full_duplex_dma_buffer_bytes_() { + i2s_chan_info_t chan_info; + if (this->full_duplex_dma_desc_num_ == 0 || i2s_channel_get_info(this->tx_handle_, &chan_info) != ESP_OK) { + return 0; + } + return chan_info.total_dma_buf_size / this->full_duplex_dma_desc_num_; +} + +// Caller holds full_duplex_lock_ +esp_err_t I2SAudioComponent::start_full_duplex_tx_(const uint8_t *silence, size_t buffer_bytes) { + esp_err_t err; + if (!this->full_duplex_tx_callback_registered_) { + // Must be registered before the channel is enabled + const i2s_event_callbacks_t callbacks = {.on_sent = I2SAudioComponent::full_duplex_on_sent_cb}; + err = i2s_channel_register_event_callback(this->tx_handle_, &callbacks, this); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Full duplex TX callback registration failed: %s", esp_err_to_name(err)); + return err; + } + this->full_duplex_tx_callback_registered_ = true; + } + + // Preload every descriptor so the first on_sent events pair with known silence + for (size_t i = 0; i < this->full_duplex_dma_desc_num_; i++) { + size_t bytes_loaded = 0; + err = i2s_channel_preload_data(this->tx_handle_, silence, buffer_bytes, &bytes_loaded); + if (err != ESP_OK || bytes_loaded != buffer_bytes) { + ESP_LOGE(TAG, "Full duplex TX silence preload failed: %s (%u/%u bytes)", esp_err_to_name(err), + (unsigned) bytes_loaded, (unsigned) buffer_bytes); + return err != ESP_OK ? err : ESP_ERR_INVALID_SIZE; + } + } + + err = i2s_channel_enable(this->tx_handle_); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Full duplex TX clock enable failed: %s", esp_err_to_name(err)); + return err; + } + this->full_duplex_tx_enabled_ = true; + ESP_LOGD(TAG, "Full duplex TX clock is running"); + return ESP_OK; +} + +esp_err_t I2SAudioComponent::ensure_full_duplex_tx_running() { + LockGuard guard(this->full_duplex_lock_); + if (!this->full_duplex_ || this->tx_handle_ == nullptr || !this->tx_channel_initialized_) { + return ESP_ERR_INVALID_STATE; + } + if (this->full_duplex_tx_enabled_) { + return ESP_OK; + } + + const size_t buffer_bytes = this->get_full_duplex_dma_buffer_bytes_(); + if (buffer_bytes == 0) { + ESP_LOGE(TAG, "Full duplex TX clock start failed: no DMA buffer size available"); + return ESP_ERR_INVALID_SIZE; + } + + RAMAllocator allocator; + uint8_t *silence = allocator.allocate(buffer_bytes); + if (silence == nullptr) { + return ESP_ERR_NO_MEM; + } + memset(silence, 0, buffer_bytes); + esp_err_t err = this->start_full_duplex_tx_(silence, buffer_bytes); + allocator.deallocate(silence, buffer_bytes); + return err; +} + +esp_err_t I2SAudioComponent::attach_full_duplex_speaker(QueueHandle_t event_queue, QueueHandle_t records_queue, + EventGroupHandle_t event_group, EventBits_t overflow_bits, + const uint8_t *silence, size_t buffer_bytes) { + // Every descriptor finishes once before the speaker's first write reaches the wire, in both paths below + auto seed_records = [&]() -> bool { + const uint32_t zero_real_frames = 0; + for (size_t i = 0; i < this->full_duplex_dma_desc_num_; i++) { + if (xQueueSend(records_queue, &zero_real_frames, 0) != pdTRUE) { + return false; + } + } + return true; + }; + + { + LockGuard guard(this->full_duplex_lock_); + if (!this->full_duplex_ || this->tx_handle_ == nullptr || !this->tx_channel_initialized_) { + return ESP_ERR_INVALID_STATE; + } + + this->full_duplex_tx_event_group_ = event_group; + this->full_duplex_tx_overflow_bits_ = overflow_bits; + + if (!this->full_duplex_tx_enabled_) { + // Fresh start, identical to a simplex speaker: preload silence, then enable + if (!seed_records()) { + return ESP_ERR_NO_MEM; + } + this->full_duplex_tx_event_queue_.store(event_queue, std::memory_order_release); + esp_err_t err = this->start_full_duplex_tx_(silence, buffer_bytes); + if (err != ESP_OK) { + this->full_duplex_tx_event_queue_.store(nullptr, std::memory_order_release); + } + return err; + } + } + + // TX is already clocking the microphone, so its DMA ring holds buffers it has released but nobody refilled. + // A write would land in one of those and play early, skewing timestamps by up to a whole ring. Claim them all + // with silence so the next write waits for the buffer on the wire, the same position as a fresh preload. + // + // First let one full ring play out untouched. That fills the driver's release queue, and a full queue makes + // its next write start a fresh buffer, discarding any partial write position a previous session left behind. + // The lock is released meanwhile so microphone start/stop on the main loop isn't stalled; that's safe because + // TX is never disabled once enabled and only this speaker task writes to it. + vTaskDelay(pdMS_TO_TICKS(this->full_duplex_dma_desc_num_ * FULL_DUPLEX_DMA_BUFFER_DURATION_MS + 1)); + LockGuard guard(this->full_duplex_lock_); + + // Events that arrive meanwhile are discarded; the loop exits once nothing was released since the last reset + this->full_duplex_tx_event_queue_.store(event_queue, std::memory_order_release); + size_t bytes_written = 0; + do { + while (i2s_channel_write(this->tx_handle_, silence, buffer_bytes, &bytes_written, 0) == ESP_OK) { + } + xQueueReset(event_queue); + esp_rom_delay_us(FULL_DUPLEX_ISR_SETTLE_US); + } while (i2s_channel_write(this->tx_handle_, silence, buffer_bytes, &bytes_written, 0) == ESP_OK); + + if (!seed_records()) { + this->full_duplex_tx_event_queue_.store(nullptr, std::memory_order_release); + return ESP_ERR_NO_MEM; + } + return ESP_OK; +} + +void I2SAudioComponent::detach_full_duplex_tx_event_queue(QueueHandle_t queue) { + LockGuard guard(this->full_duplex_lock_); + QueueHandle_t expected = queue; + this->full_duplex_tx_event_queue_.compare_exchange_strong(expected, nullptr, std::memory_order_acq_rel); +} + +bool IRAM_ATTR I2SAudioComponent::full_duplex_on_sent_cb(i2s_chan_handle_t handle, i2s_event_data_t *event, + void *user_ctx) { + (void) handle; + (void) event; + auto *component = static_cast(user_ctx); + QueueHandle_t queue = component->full_duplex_tx_event_queue_.load(std::memory_order_acquire); + if (queue == nullptr) { + return false; + } + + int64_t now = esp_timer_get_time(); + BaseType_t need_yield1 = pdFALSE; + BaseType_t need_yield2 = pdFALSE; + BaseType_t need_yield3 = pdFALSE; + + // Mirrors I2SAudioSpeakerBase::i2s_on_sent_cb: a dropped event desyncs the speaker's records, so it restarts + if (xQueueIsQueueFullFromISR(queue)) { + int64_t dummy; + xQueueReceiveFromISR(queue, &dummy, &need_yield1); + if (component->full_duplex_tx_event_group_ != nullptr && component->full_duplex_tx_overflow_bits_ != 0) { + xEventGroupSetBitsFromISR(component->full_duplex_tx_event_group_, component->full_duplex_tx_overflow_bits_, + &need_yield2); + } + } + + xQueueSendToBackFromISR(queue, &now, &need_yield3); + return need_yield1 | need_yield2 | need_yield3; +} + +void I2SAudioComponent::release_full_duplex_rx_channel(i2s_chan_handle_t rx_handle) { + LockGuard guard(this->full_duplex_lock_); + if (rx_handle == this->rx_handle_ && this->full_duplex_rx_enabled_) { + i2s_channel_disable(this->rx_handle_); + this->full_duplex_rx_enabled_ = false; + } +} + +void I2SAudioComponent::release_full_duplex_tx_channel(i2s_chan_handle_t tx_handle) { + (void) tx_handle; + // TX stays enabled: it clocks the external ADC, and stopping it would stall RX until playback resumes +} + +} // namespace esphome::i2s_audio + +#endif // USE_ESP32 diff --git a/Integrations/ESPHome/components/i2s_audio/i2s_audio.h b/Integrations/ESPHome/components/i2s_audio/i2s_audio.h new file mode 100644 index 0000000..97f6147 --- /dev/null +++ b/Integrations/ESPHome/components/i2s_audio/i2s_audio.h @@ -0,0 +1,148 @@ +#pragma once + +#ifdef USE_ESP32 + +#include "esphome/core/component.h" +#include "esphome/core/defines.h" +#include "esphome/core/helpers.h" +#include +#include +#include +#include +#include + +namespace esphome::i2s_audio { + +class I2SAudioComponent; + +class I2SAudioBase : public Parented { + public: + void set_i2s_role(i2s_role_t role) { this->i2s_role_ = role; } + void set_slot_mode(i2s_slot_mode_t slot_mode) { this->slot_mode_ = slot_mode; } + void set_std_slot_mask(i2s_std_slot_mask_t std_slot_mask) { this->std_slot_mask_ = std_slot_mask; } + void set_slot_bit_width(i2s_slot_bit_width_t slot_bit_width) { this->slot_bit_width_ = slot_bit_width; } + void set_sample_rate(uint32_t sample_rate) { this->sample_rate_ = sample_rate; } + void set_use_apll(uint32_t use_apll) { this->use_apll_ = use_apll; } + void set_mclk_multiple(i2s_mclk_multiple_t mclk_multiple) { this->mclk_multiple_ = mclk_multiple; } + + protected: + i2s_role_t i2s_role_{}; + i2s_slot_mode_t slot_mode_; + i2s_std_slot_mask_t std_slot_mask_; + i2s_slot_bit_width_t slot_bit_width_; + uint32_t sample_rate_; + bool use_apll_; + i2s_mclk_multiple_t mclk_multiple_; +}; + +class I2SAudioIn : public I2SAudioBase {}; + +class I2SAudioOut : public I2SAudioBase {}; + +class I2SAudioComponent final : public Component { + public: + i2s_std_gpio_config_t get_pin_config() const { + return {.mclk = (gpio_num_t) this->mclk_pin_, + .bclk = (gpio_num_t) this->bclk_pin_, + .ws = (gpio_num_t) this->lrclk_pin_, + .dout = I2S_GPIO_UNUSED, // add local ports + .din = I2S_GPIO_UNUSED, + .invert_flags = { + .mclk_inv = false, + .bclk_inv = false, + .ws_inv = false, + }}; + } + i2s_std_gpio_config_t get_full_duplex_pin_config() const { + return {.mclk = (gpio_num_t) this->mclk_pin_, + .bclk = (gpio_num_t) this->bclk_pin_, + .ws = (gpio_num_t) this->lrclk_pin_, + .dout = (gpio_num_t) this->dout_pin_, + .din = (gpio_num_t) this->din_pin_, + .invert_flags = { + .mclk_inv = false, + .bclk_inv = false, + .ws_inv = false, + }}; + } + + void set_mclk_pin(int pin) { this->mclk_pin_ = pin; } + void set_bclk_pin(int pin) { this->bclk_pin_ = pin; } + void set_lrclk_pin(int pin) { this->lrclk_pin_ = pin; } + void set_din_pin(int pin) { this->din_pin_ = pin; } + void set_dout_pin(int pin) { this->dout_pin_ = pin; } + void set_port(int port) { this->port_ = port; } + void set_full_duplex(bool full_duplex) { this->full_duplex_ = full_duplex; } + bool is_full_duplex() const { return this->full_duplex_; } +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) + int get_port() const { return this->port_; } +#else + i2s_port_t get_port() const { return static_cast(this->port_); } +#endif + + void lock() { this->lock_.lock(); } + bool try_lock() { return this->lock_.try_lock(); } + void unlock() { this->lock_.unlock(); } + + // Full duplex: TX and RX are allocated together and share one clock/slot config, so the first user's config + // wins and a mismatched second user is rejected. TX stays running once started, since it clocks the ADC. + esp_err_t setup_full_duplex_rx_channel(const i2s_chan_config_t &chan_cfg, const i2s_std_config_t &std_cfg, + i2s_chan_handle_t *rx_handle); + esp_err_t setup_full_duplex_tx_channel(const i2s_chan_config_t &chan_cfg, const i2s_std_config_t &std_cfg, + i2s_chan_handle_t *tx_handle); + /// Starts the TX clock on silence if it isn't running yet (used by the microphone). + esp_err_t ensure_full_duplex_tx_running(); + /// Joins the TX stream for a speaker task. Leaves `records_queue` holding one zero-frame record per DMA buffer + /// that completes before the speaker's first write can play, then routes on_sent events into `event_queue`. + esp_err_t attach_full_duplex_speaker(QueueHandle_t event_queue, QueueHandle_t records_queue, + EventGroupHandle_t event_group, EventBits_t overflow_bits, + const uint8_t *silence, size_t buffer_bytes); + void detach_full_duplex_tx_event_queue(QueueHandle_t queue); + size_t get_full_duplex_dma_desc_num() const { return this->full_duplex_dma_desc_num_; } + void mark_full_duplex_rx_running() { this->full_duplex_rx_enabled_ = true; } + void release_full_duplex_rx_channel(i2s_chan_handle_t rx_handle); + void release_full_duplex_tx_channel(i2s_chan_handle_t tx_handle); + + protected: + esp_err_t allocate_full_duplex_channels_(const i2s_chan_config_t &chan_cfg, const i2s_std_config_t &std_cfg); + esp_err_t initialize_full_duplex_channels_(const i2s_std_config_t &std_cfg); + esp_err_t start_full_duplex_tx_(const uint8_t *silence, size_t buffer_bytes); + size_t get_full_duplex_dma_buffer_bytes_(); + static bool full_duplex_on_sent_cb(i2s_chan_handle_t handle, i2s_event_data_t *event, void *user_ctx); + + Mutex lock_; + // Serializes full duplex setup between the speaker and microphone tasks; lock_ is unused in full duplex mode + Mutex full_duplex_lock_; + + I2SAudioIn *audio_in_{nullptr}; + I2SAudioOut *audio_out_{nullptr}; + int mclk_pin_{I2S_GPIO_UNUSED}; + int bclk_pin_{I2S_GPIO_UNUSED}; + int din_pin_{I2S_GPIO_UNUSED}; + int dout_pin_{I2S_GPIO_UNUSED}; + int lrclk_pin_; + int port_{}; + bool full_duplex_{false}; + + i2s_chan_handle_t rx_handle_{nullptr}; + i2s_chan_handle_t tx_handle_{nullptr}; + bool rx_channel_initialized_{false}; + bool tx_channel_initialized_{false}; + bool full_duplex_tx_enabled_{false}; + bool full_duplex_rx_enabled_{false}; + bool full_duplex_tx_callback_registered_{false}; + size_t full_duplex_dma_desc_num_{0}; + // Format of the first user, which both channels were initialized with + uint32_t full_duplex_sample_rate_{0}; + uint32_t full_duplex_data_bit_width_{0}; + uint32_t full_duplex_slot_bit_width_{0}; + i2s_slot_mode_t full_duplex_slot_mode_{}; + // Read by the TX ISR; event group and bits are published before the queue + std::atomic full_duplex_tx_event_queue_{nullptr}; + EventGroupHandle_t full_duplex_tx_event_group_{nullptr}; + EventBits_t full_duplex_tx_overflow_bits_{0}; +}; + +} // namespace esphome::i2s_audio + +#endif // USE_ESP32 diff --git a/Integrations/ESPHome/components/i2s_audio/media_player/__init__.py b/Integrations/ESPHome/components/i2s_audio/media_player/__init__.py new file mode 100644 index 0000000..b366d4f --- /dev/null +++ b/Integrations/ESPHome/components/i2s_audio/media_player/__init__.py @@ -0,0 +1,7 @@ +import esphome.config_validation as cv + +CONFIG_SCHEMA = cv.invalid( + "The I2S audio media player has been removed. " + "Use the speaker media player component instead. " + "See https://esphome.io/components/media_player/speaker.html for details." +) diff --git a/Integrations/ESPHome/components/i2s_audio/microphone/__init__.py b/Integrations/ESPHome/components/i2s_audio/microphone/__init__.py new file mode 100644 index 0000000..89e9991 --- /dev/null +++ b/Integrations/ESPHome/components/i2s_audio/microphone/__init__.py @@ -0,0 +1,162 @@ +from esphome import pins +import esphome.codegen as cg +from esphome.components import audio, esp32, microphone +from esphome.components.adc import validate_adc_pin +import esphome.config_validation as cv +from esphome.const import ( + CONF_BITS_PER_SAMPLE, + CONF_CHANNEL, + CONF_ID, + CONF_NUM_CHANNELS, + CONF_SAMPLE_RATE, +) +from esphome.types import ConfigType + +from .. import ( + CONF_ADC_TYPE, + CONF_I2S_AUDIO_ID, + CONF_I2S_DIN_PIN, + CONF_LEFT, + CONF_MONO, + CONF_PDM, + CONF_PDM_DSR, + CONF_RIGHT, + I2SAudioIn, + i2s_audio_component_schema, + i2s_audio_ns, + register_i2s_audio_component, + validate_mclk_divisible_by_3, +) + +CODEOWNERS = ["@jesserockz"] +DEPENDENCIES = ["i2s_audio"] + +CONF_ADC_PIN = "adc_pin" +CONF_CORRECT_DC_OFFSET = "correct_dc_offset" + +I2SAudioMicrophone = i2s_audio_ns.class_( + "I2SAudioMicrophone", I2SAudioIn, microphone.Microphone, cg.Component +) + +INTERNAL_ADC_VARIANTS = [esp32.VARIANT_ESP32] +PDM_VARIANTS = [esp32.VARIANT_ESP32, esp32.VARIANT_ESP32S3, esp32.VARIANT_ESP32P4] + +i2s_pdm_dsr_t = cg.global_ns.enum("i2s_pdm_dsr_t") +I2S_PDM_DSR = { + 8: i2s_pdm_dsr_t.I2S_PDM_DSR_8S, + 16: i2s_pdm_dsr_t.I2S_PDM_DSR_16S, +} + + +def _validate_esp32_variant(config: ConfigType) -> ConfigType: + variant = esp32.get_esp32_variant() + if config[CONF_ADC_TYPE] == "external": + if config[CONF_PDM] and variant not in PDM_VARIANTS: + raise cv.Invalid(f"{variant} does not support PDM") + if ( + variant == esp32.VARIANT_ESP32 + and config.get(CONF_BITS_PER_SAMPLE) == 8 + and config.get(CONF_CHANNEL) in (CONF_LEFT, CONF_RIGHT) + ): + raise cv.Invalid("8-bit mono mode is not supported on ESP32") + return config + if config[CONF_ADC_TYPE] == "internal": + if variant not in INTERNAL_ADC_VARIANTS: + raise cv.Invalid(f"{variant} does not have an internal ADC") + return config + raise NotImplementedError + + +def _validate_channel(config: ConfigType) -> ConfigType: + if config[CONF_CHANNEL] == CONF_MONO: + raise cv.Invalid(f"I2S microphone does not support {CONF_MONO}.") + return config + + +def _set_num_channels_from_config(config: ConfigType) -> ConfigType: + if config[CONF_CHANNEL] in (CONF_LEFT, CONF_RIGHT): + config[CONF_NUM_CHANNELS] = 1 + else: + config[CONF_NUM_CHANNELS] = 2 + + return config + + +def _set_stream_limits(config: ConfigType) -> ConfigType: + audio.set_stream_limits( + min_bits_per_sample=config.get(CONF_BITS_PER_SAMPLE), + max_bits_per_sample=config.get(CONF_BITS_PER_SAMPLE), + min_channels=config.get(CONF_NUM_CHANNELS), + max_channels=config.get(CONF_NUM_CHANNELS), + min_sample_rate=config.get(CONF_SAMPLE_RATE), + max_sample_rate=config.get(CONF_SAMPLE_RATE), + )(config) + + return config + + +BASE_SCHEMA = microphone.MICROPHONE_SCHEMA.extend( + i2s_audio_component_schema( + I2SAudioMicrophone, + default_sample_rate=16000, + default_channel=CONF_RIGHT, + default_bits_per_sample="32bit", + ).extend( + { + cv.Optional(CONF_CORRECT_DC_OFFSET, default=False): cv.boolean, + } + ) +).extend(cv.COMPONENT_SCHEMA) + +CONFIG_SCHEMA = cv.All( + cv.typed_schema( + { + "internal": BASE_SCHEMA.extend( + { + cv.Required(CONF_ADC_PIN): validate_adc_pin, + } + ), + "external": BASE_SCHEMA.extend( + { + cv.Required(CONF_I2S_DIN_PIN): pins.internal_gpio_input_pin_number, + cv.Optional(CONF_PDM, default=False): cv.boolean, + cv.Optional(CONF_PDM_DSR, default=8): cv.enum( + I2S_PDM_DSR, int=True + ), + } + ), + }, + key=CONF_ADC_TYPE, + ), + _validate_esp32_variant, + _validate_channel, + _set_num_channels_from_config, + _set_stream_limits, + validate_mclk_divisible_by_3, +) + + +def _final_validate(config: ConfigType) -> None: + if config[CONF_ADC_TYPE] == "internal": + raise cv.Invalid( + "Internal ADC is no longer supported. Use an external I2S microphone instead." + ) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await register_i2s_audio_component(var, config) + await microphone.register_microphone(var, config) + + cg.add(var.set_din_pin(config[CONF_I2S_DIN_PIN])) + parent = await cg.get_variable(config[CONF_I2S_AUDIO_ID]) + cg.add(parent.set_din_pin(config[CONF_I2S_DIN_PIN])) + cg.add(var.set_pdm(config[CONF_PDM])) + if esp32.get_esp32_variant() in PDM_VARIANTS: + cg.add(var.set_pdm_dsr(config[CONF_PDM_DSR])) + + cg.add(var.set_correct_dc_offset(config[CONF_CORRECT_DC_OFFSET])) diff --git a/Integrations/ESPHome/components/i2s_audio/microphone/i2s_audio_microphone.cpp b/Integrations/ESPHome/components/i2s_audio/microphone/i2s_audio_microphone.cpp new file mode 100644 index 0000000..1ff4494 --- /dev/null +++ b/Integrations/ESPHome/components/i2s_audio/microphone/i2s_audio_microphone.cpp @@ -0,0 +1,503 @@ +#include "i2s_audio_microphone.h" + +#ifdef USE_ESP32 + +#include +#include + +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +#include "esphome/components/audio/audio.h" + +namespace esphome::i2s_audio { + +static const UBaseType_t MAX_LISTENERS = 16; + +static const uint32_t READ_DURATION_MS = 16; + +static const size_t TASK_STACK_SIZE = 4096; +static const ssize_t TASK_PRIORITY = 23; + +static const char *const TAG = "i2s_audio.microphone"; + +enum MicrophoneEventGroupBits : uint32_t { + COMMAND_STOP = (1 << 0), // stops the microphone task, set and cleared by ``loop`` + + TASK_STARTING = (1 << 10), // set by mic task, cleared by ``loop`` + TASK_RUNNING = (1 << 11), // set by mic task, cleared by ``loop`` + TASK_STOPPED = (1 << 13), // set by mic task, cleared by ``loop`` + + ALL_BITS = 0x00FFFFFF, // All valid FreeRTOS event group bits +}; + +void I2SAudioMicrophone::setup() { + this->active_listeners_semaphore_ = xSemaphoreCreateCounting(MAX_LISTENERS, MAX_LISTENERS); + if (this->active_listeners_semaphore_ == nullptr) { + ESP_LOGE(TAG, "Creating semaphore failed"); + this->mark_failed(); + return; + } + + this->event_group_ = xEventGroupCreate(); + if (this->event_group_ == nullptr) { + ESP_LOGE(TAG, "Creating event group failed"); + this->mark_failed(); + return; + } + + this->configure_stream_settings_(); +} + +void I2SAudioMicrophone::dump_config() { + ESP_LOGCONFIG(TAG, + "Microphone:\n" + " Pin: %d\n" + " PDM: %s\n" + " DC offset correction: %s", + static_cast(this->din_pin_), YESNO(this->pdm_), YESNO(this->correct_dc_offset_)); +} + +void I2SAudioMicrophone::configure_stream_settings_() { + uint8_t channel_count = 1; + uint8_t bits_per_sample = 16; + if (this->slot_bit_width_ != I2S_SLOT_BIT_WIDTH_AUTO) { + bits_per_sample = this->slot_bit_width_; + } + + if (this->slot_mode_ == I2S_SLOT_MODE_STEREO) { + channel_count = 2; + } + +#ifdef USE_ESP32_VARIANT_ESP32 + // ESP32 reads audio aligned to a multiple of 2 bytes. For example, if configured for 24 bits per sample, then it will + // produce 32 bits per sample, where the actual data is in the most significant bits. Other ESP32 variants produce 24 + // bits per sample in this situation. + if (bits_per_sample < 16) { + bits_per_sample = 16; + } else if ((bits_per_sample > 16) && (bits_per_sample <= 32)) { + bits_per_sample = 32; + } +#endif + + if (this->pdm_) { + bits_per_sample = 16; // PDM mics are always 16 bits per sample + } + + this->audio_stream_info_ = audio::AudioStreamInfo(bits_per_sample, channel_count, this->sample_rate_); +} + +void I2SAudioMicrophone::start() { + if (this->is_failed()) + return; + + xSemaphoreTake(this->active_listeners_semaphore_, 0); +} + +bool I2SAudioMicrophone::start_driver_() { + if (this->parent_->is_full_duplex()) { + if (this->pdm_) { + ESP_LOGE(TAG, "Full duplex I2S microphone does not support PDM mode"); + return false; + } + + esp_err_t err; + i2s_chan_config_t chan_cfg = { + .id = this->parent_->get_port(), + .role = this->i2s_role_, + .dma_desc_num = 4, + .dma_frame_num = 256, + .auto_clear = false, + .intr_priority = 3, + }; + + i2s_clock_src_t clk_src = I2S_CLK_SRC_DEFAULT; +#if SOC_CLK_APLL_SUPPORTED + if (this->use_apll_) { + clk_src = i2s_clock_src_t::I2S_CLK_SRC_APLL; + } +#endif + + i2s_std_gpio_config_t pin_config = this->parent_->get_full_duplex_pin_config(); + pin_config.din = this->din_pin_; + + i2s_std_clk_config_t clk_cfg = { + .sample_rate_hz = this->sample_rate_, + .clk_src = clk_src, + .mclk_multiple = this->mclk_multiple_, + }; + i2s_std_slot_config_t std_slot_cfg = + I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG((i2s_data_bit_width_t) this->slot_bit_width_, this->slot_mode_); + std_slot_cfg.slot_bit_width = this->slot_bit_width_; + std_slot_cfg.slot_mask = this->std_slot_mask_; + + i2s_std_config_t std_cfg = { + .clk_cfg = clk_cfg, + .slot_cfg = std_slot_cfg, + .gpio_cfg = pin_config, + }; + + err = this->parent_->setup_full_duplex_rx_channel(chan_cfg, std_cfg, &this->rx_handle_); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Error setting up full duplex RX channel: %s", esp_err_to_name(err)); + return false; + } + + err = this->parent_->ensure_full_duplex_tx_running(); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Error starting full duplex TX clock: %s", esp_err_to_name(err)); + return false; + } + + err = i2s_channel_enable(this->rx_handle_); + if (err == ESP_OK || err == ESP_ERR_INVALID_STATE) { + this->parent_->mark_full_duplex_rx_running(); + } else { + ESP_LOGE(TAG, "Enabling full duplex RX failed: %s", esp_err_to_name(err)); + return false; + } + + this->configure_stream_settings_(); + return true; + } + + if (!this->parent_->try_lock()) { + return false; // Waiting for another i2s to return lock + } + this->locked_driver_ = true; + esp_err_t err; + + i2s_chan_config_t chan_cfg = { + .id = this->parent_->get_port(), + .role = this->i2s_role_, + .dma_desc_num = 4, + .dma_frame_num = 256, + .auto_clear = false, + }; + /* Allocate a new RX channel and get the handle of this channel */ + err = i2s_new_channel(&chan_cfg, NULL, &this->rx_handle_); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Error creating channel: %s", esp_err_to_name(err)); + return false; + } + + i2s_clock_src_t clk_src = I2S_CLK_SRC_DEFAULT; +#ifdef I2S_CLK_SRC_APLL + if (this->use_apll_) { + clk_src = I2S_CLK_SRC_APLL; + } +#endif + i2s_std_gpio_config_t pin_config = this->parent_->get_pin_config(); +#if SOC_I2S_SUPPORTS_PDM_RX + if (this->pdm_) { + i2s_pdm_rx_clk_config_t clk_cfg = { + .sample_rate_hz = this->sample_rate_, + .clk_src = clk_src, + .mclk_multiple = this->mclk_multiple_, + .dn_sample_mode = this->pdm_dsr_, + }; + + i2s_pdm_rx_slot_config_t slot_cfg = I2S_PDM_RX_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_16BIT, this->slot_mode_); + switch (this->std_slot_mask_) { + case I2S_STD_SLOT_LEFT: + slot_cfg.slot_mask = I2S_PDM_SLOT_LEFT; + break; + case I2S_STD_SLOT_RIGHT: + slot_cfg.slot_mask = I2S_PDM_SLOT_RIGHT; + break; + case I2S_STD_SLOT_BOTH: + slot_cfg.slot_mask = I2S_PDM_SLOT_BOTH; + break; + } + + /* Init the channel into PDM RX mode */ + i2s_pdm_rx_config_t pdm_rx_cfg = { + .clk_cfg = clk_cfg, + .slot_cfg = slot_cfg, + .gpio_cfg = + { + .clk = pin_config.ws, + .din = this->din_pin_, + .invert_flags = + { + .clk_inv = pin_config.invert_flags.ws_inv, + }, + }, + }; + err = i2s_channel_init_pdm_rx_mode(this->rx_handle_, &pdm_rx_cfg); + } else +#endif + { + i2s_std_clk_config_t clk_cfg = { + .sample_rate_hz = this->sample_rate_, + .clk_src = clk_src, + .mclk_multiple = this->mclk_multiple_, + }; + i2s_std_slot_config_t std_slot_cfg = + I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG((i2s_data_bit_width_t) this->slot_bit_width_, this->slot_mode_); + std_slot_cfg.slot_bit_width = this->slot_bit_width_; + std_slot_cfg.slot_mask = this->std_slot_mask_; + + pin_config.din = this->din_pin_; + + i2s_std_config_t std_cfg = { + .clk_cfg = clk_cfg, + .slot_cfg = std_slot_cfg, + .gpio_cfg = pin_config, + }; + /* Initialize the channel */ + err = i2s_channel_init_std_mode(this->rx_handle_, &std_cfg); + } + if (err != ESP_OK) { + ESP_LOGE(TAG, "Error initializing channel: %s", esp_err_to_name(err)); + return false; + } + + /* Before reading data, start the RX channel first */ + err = i2s_channel_enable(this->rx_handle_); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Enabling failed: %s", esp_err_to_name(err)); + return false; + } + + this->configure_stream_settings_(); // redetermine the settings in case some settings were changed after compilation + + return true; +} + +void I2SAudioMicrophone::stop() { + if (this->state_ == microphone::STATE_STOPPED || this->is_failed()) + return; + + xSemaphoreGive(this->active_listeners_semaphore_); +} + +void I2SAudioMicrophone::stop_driver_() { + // There is no harm continuing to unload the driver if an error is ever returned by the various functions. This + // ensures that we stop/unload the driver when it only partially starts. + + esp_err_t err; + if (this->parent_->is_full_duplex()) { + if (this->rx_handle_ != nullptr) { + this->parent_->release_full_duplex_rx_channel(this->rx_handle_); + this->rx_handle_ = nullptr; + } + return; + } + + if (this->rx_handle_ != nullptr) { + /* Have to stop the channel before deleting it */ + err = i2s_channel_disable(this->rx_handle_); + if (err != ESP_OK) { + ESP_LOGW(TAG, "Error stopping: %s", esp_err_to_name(err)); + } + /* If the handle is not needed any more, delete it to release the channel resources */ + err = i2s_del_channel(this->rx_handle_); + if (err != ESP_OK) { + ESP_LOGW(TAG, "Error deleting channel: %s", esp_err_to_name(err)); + } + this->rx_handle_ = nullptr; + } + if (this->locked_driver_) { + this->parent_->unlock(); + this->locked_driver_ = false; + } +} + +void I2SAudioMicrophone::mic_task(void *params) { + I2SAudioMicrophone *this_microphone = (I2SAudioMicrophone *) params; + xEventGroupSetBits(this_microphone->event_group_, MicrophoneEventGroupBits::TASK_STARTING); + + { // Ensures the samples vector is freed when the task stops + + const size_t bytes_to_read = this_microphone->audio_stream_info_.ms_to_bytes(READ_DURATION_MS); + std::vector samples; + samples.reserve(bytes_to_read); + + xEventGroupSetBits(this_microphone->event_group_, MicrophoneEventGroupBits::TASK_RUNNING); + + while (!(xEventGroupGetBits(this_microphone->event_group_) & MicrophoneEventGroupBits::COMMAND_STOP)) { + if (this_microphone->data_callbacks_.size() > 0) { + samples.resize(bytes_to_read); + size_t bytes_read = this_microphone->read_(samples.data(), bytes_to_read, 2 * READ_DURATION_MS); + samples.resize(bytes_read); + if (this_microphone->correct_dc_offset_) { + this_microphone->fix_dc_offset_(samples); + } + this_microphone->data_callbacks_.call(samples); + } else { + vTaskDelay(pdMS_TO_TICKS(READ_DURATION_MS)); + } + } + } + + xEventGroupSetBits(this_microphone->event_group_, MicrophoneEventGroupBits::TASK_STOPPED); + while (true) { + // Continuously delay until the loop method deletes the task + vTaskDelay(pdMS_TO_TICKS(10)); + } +} + +void I2SAudioMicrophone::fix_dc_offset_(std::vector &data) { + /** + * From https://www.musicdsp.org/en/latest/Filters/135-dc-filter.html: + * + * y(n) = x(n) - x(n-1) + R * y(n-1) + * R = 1 - (pi * 2 * frequency / samplerate) + * + * From https://en.wikipedia.org/wiki/Hearing_range: + * The human range is commonly given as 20Hz up. + * + * From https://en.wikipedia.org/wiki/High-resolution_audio: + * A reasonable upper bound for sample rate seems to be 96kHz. + * + * Calculate R value for 20Hz on a 96kHz sample rate: + * R = 1 - (pi * 2 * 20 / 96000) + * R = 0.9986910031 + * + * Transform floating point to bit-shifting approximation: + * output = input - prev_input + R * prev_output + * output = input - prev_input + (prev_output - (prev_output >> S)) + * + * Approximate bit-shift value S from R: + * R = 1 - (1 >> S) + * R = 1 - (1 / 2^S) + * R = 1 - 2^-S + * 0.9986910031 = 1 - 2^-S + * S = 9.57732 ~= 10 + * + * Actual R from S: + * R = 1 - 2^-10 = 0.9990234375 + * + * Confirm this has effect outside human hearing on 96000kHz sample: + * 0.9990234375 = 1 - (pi * 2 * f / 96000) + * f = 14.9208Hz + * + * Confirm this has effect outside human hearing on PDM 16kHz sample: + * 0.9990234375 = 1 - (pi * 2 * f / 16000) + * f = 2.4868Hz + * + */ + const uint8_t dc_filter_shift = 10; + const size_t bytes_per_sample = this->audio_stream_info_.samples_to_bytes(1); + const uint32_t total_samples = this->audio_stream_info_.bytes_to_samples(data.size()); + for (uint32_t sample_index = 0; sample_index < total_samples; ++sample_index) { + const uint32_t byte_index = sample_index * bytes_per_sample; + int32_t input = audio::unpack_audio_sample_to_q31(&data[byte_index], bytes_per_sample); + int32_t output = input - this->dc_offset_prev_input_ + + (this->dc_offset_prev_output_ - (this->dc_offset_prev_output_ >> dc_filter_shift)); + this->dc_offset_prev_input_ = input; + this->dc_offset_prev_output_ = output; + audio::pack_q31_as_audio_sample(output, &data[byte_index], bytes_per_sample); + } +} + +size_t I2SAudioMicrophone::read_(uint8_t *buf, size_t len, uint32_t timeout_ms) { + size_t bytes_read = 0; + esp_err_t err = i2s_channel_read(this->rx_handle_, buf, len, &bytes_read, timeout_ms); + if ((err != ESP_OK) && ((err != ESP_ERR_TIMEOUT) || (timeout_ms != 0))) { + // Ignore ESP_ERR_TIMEOUT if timeout_ms = 0, as it will read the data on the next call + if (!this->status_has_warning()) { + // Avoid spamming the logs with the error message if its repeated + ESP_LOGW(TAG, "Read error: %s", esp_err_to_name(err)); + } + this->status_set_warning(); + return 0; + } + if ((bytes_read == 0) && (timeout_ms > 0)) { + this->status_set_warning(); + return 0; + } + this->status_clear_warning(); +#ifdef USE_ESP32_VARIANT_ESP32 + // For ESP32 16-bit standard mono mode, adjacent samples need to be swapped. + if (this->slot_mode_ == I2S_SLOT_MODE_MONO && this->slot_bit_width_ == I2S_SLOT_BIT_WIDTH_16BIT && !this->pdm_) { + int16_t *samples = reinterpret_cast(buf); + size_t sample_count = bytes_read / sizeof(int16_t); + for (size_t i = 0; i + 1 < sample_count; i += 2) { + int16_t tmp = samples[i]; + samples[i] = samples[i + 1]; + samples[i + 1] = tmp; + } + } +#endif + return bytes_read; +} + +void I2SAudioMicrophone::loop() { + uint32_t event_group_bits = xEventGroupGetBits(this->event_group_); + + if (event_group_bits & MicrophoneEventGroupBits::TASK_STARTING) { + ESP_LOGV(TAG, "Task started, attempting to allocate buffer"); + xEventGroupClearBits(this->event_group_, MicrophoneEventGroupBits::TASK_STARTING); + } + + if (event_group_bits & MicrophoneEventGroupBits::TASK_RUNNING) { + ESP_LOGV(TAG, "Task is running and reading data"); + + xEventGroupClearBits(this->event_group_, MicrophoneEventGroupBits::TASK_RUNNING); + this->state_ = microphone::STATE_RUNNING; + } + + if ((event_group_bits & MicrophoneEventGroupBits::TASK_STOPPED)) { + ESP_LOGV(TAG, "Task finished, freeing resources and uninstalling driver"); + + vTaskDelete(this->task_handle_); + this->task_handle_ = nullptr; + this->stop_driver_(); + xEventGroupClearBits(this->event_group_, ALL_BITS); + this->status_clear_error(); + + this->state_ = microphone::STATE_STOPPED; + } + + // Start the microphone if any semaphores are taken + if ((uxSemaphoreGetCount(this->active_listeners_semaphore_) < MAX_LISTENERS) && + (this->state_ == microphone::STATE_STOPPED)) { + this->state_ = microphone::STATE_STARTING; + } + + // Stop the microphone if all semaphores are returned + if ((uxSemaphoreGetCount(this->active_listeners_semaphore_) == MAX_LISTENERS) && + (this->state_ == microphone::STATE_RUNNING)) { + this->state_ = microphone::STATE_STOPPING; + } + + switch (this->state_) { + case microphone::STATE_STARTING: + if (this->status_has_error()) { + break; + } + + if (!this->start_driver_()) { + ESP_LOGE(TAG, "Driver failed to start; retrying in 1 second"); + this->status_momentary_error("driver_fail", 1000); + this->stop_driver_(); // Stop/frees whatever possibly started + break; + } + + if (this->task_handle_ == nullptr) { + xTaskCreate(I2SAudioMicrophone::mic_task, "mic_task", TASK_STACK_SIZE, (void *) this, TASK_PRIORITY, + &this->task_handle_); + + if (this->task_handle_ == nullptr) { + ESP_LOGE(TAG, "Task failed to start, retrying in 1 second"); + this->status_momentary_error("task_fail", 1000); + this->stop_driver_(); // Stops the driver to return the lock; will be reloaded in next attempt + } + } + + break; + case microphone::STATE_RUNNING: + break; + case microphone::STATE_STOPPING: + xEventGroupSetBits(this->event_group_, MicrophoneEventGroupBits::COMMAND_STOP); + break; + case microphone::STATE_STOPPED: + break; + } +} + +} // namespace esphome::i2s_audio + +#endif // USE_ESP32 diff --git a/Integrations/ESPHome/components/i2s_audio/microphone/i2s_audio_microphone.h b/Integrations/ESPHome/components/i2s_audio/microphone/i2s_audio_microphone.h new file mode 100644 index 0000000..5cdb526 --- /dev/null +++ b/Integrations/ESPHome/components/i2s_audio/microphone/i2s_audio_microphone.h @@ -0,0 +1,76 @@ +#pragma once + +#ifdef USE_ESP32 + +#include "../i2s_audio.h" + +#include "esphome/components/microphone/microphone.h" +#include "esphome/core/component.h" + +#include +#include +#include +#include + +namespace esphome::i2s_audio { + +class I2SAudioMicrophone final : public I2SAudioIn, public microphone::Microphone, public Component { + public: + void setup() override; + void dump_config() override; + void start() override; + void stop() override; + + void loop() override; + + void set_correct_dc_offset(bool correct_dc_offset) { this->correct_dc_offset_ = correct_dc_offset; } + + void set_din_pin(int8_t pin) { this->din_pin_ = (gpio_num_t) pin; } + + void set_pdm(bool pdm) { this->pdm_ = pdm; } + +#if SOC_I2S_SUPPORTS_PDM_RX + void set_pdm_dsr(i2s_pdm_dsr_t pdm_dsr) { this->pdm_dsr_ = pdm_dsr; } +#endif + + protected: + /// @brief Starts the I2S driver. Updates the ``audio_stream_info_`` member variable with the current setttings. + /// @return True if succesful, false otherwise + bool start_driver_(); + + /// @brief Stops the I2S driver. + void stop_driver_(); + + /// @brief Attempts to correct a microphone DC offset; e.g., a microphones silent level is offset from 0. Applies a + /// correction offset that is updated using an exponential moving average for all samples away from 0. + /// @param data + void fix_dc_offset_(std::vector &data); + + size_t read_(uint8_t *buf, size_t len, uint32_t timeout_ms); + + /// @brief Sets the Microphone ``audio_stream_info_`` member variable to the configured I2S settings. + void configure_stream_settings_(); + + static void mic_task(void *params); + + SemaphoreHandle_t active_listeners_semaphore_{nullptr}; + EventGroupHandle_t event_group_{nullptr}; + + TaskHandle_t task_handle_{nullptr}; + + gpio_num_t din_pin_{I2S_GPIO_UNUSED}; + i2s_chan_handle_t rx_handle_{nullptr}; + bool pdm_{false}; +#if SOC_I2S_SUPPORTS_PDM_RX + i2s_pdm_dsr_t pdm_dsr_{I2S_PDM_DSR_8S}; +#endif + + bool correct_dc_offset_; + bool locked_driver_{false}; + int32_t dc_offset_prev_input_{0}; + int32_t dc_offset_prev_output_{0}; +}; + +} // namespace esphome::i2s_audio + +#endif // USE_ESP32 diff --git a/Integrations/ESPHome/components/i2s_audio/speaker/__init__.py b/Integrations/ESPHome/components/i2s_audio/speaker/__init__.py new file mode 100644 index 0000000..7ba7a5e --- /dev/null +++ b/Integrations/ESPHome/components/i2s_audio/speaker/__init__.py @@ -0,0 +1,277 @@ +from esphome import pins +import esphome.codegen as cg +from esphome.components import audio, esp32, speaker +from esphome.config_helpers import filter_source_files_from_defines +import esphome.config_validation as cv +from esphome.const import ( + CONF_BITS_PER_SAMPLE, + CONF_BUFFER_DURATION, + CONF_CHANNEL, + CONF_ID, + CONF_MODE, + CONF_NEVER, + CONF_NUM_CHANNELS, + CONF_SAMPLE_RATE, + CONF_TIMEOUT, +) +from esphome.types import ConfigType + +from .. import ( + CONF_I2S_AUDIO_ID, + CONF_I2S_DOUT_PIN, + CONF_I2S_MODE, + CONF_LEFT, + CONF_MCLK_MULTIPLE, + CONF_MONO, + CONF_PRIMARY, + CONF_RIGHT, + CONF_STEREO, + CONF_USE_APLL, + I2SAudioOut, + i2s_audio_component_schema, + i2s_audio_ns, + register_i2s_audio_component, + validate_mclk_divisible_by_3, +) + +AUTO_LOAD = ["audio"] +CODEOWNERS = ["@jesserockz", "@kahrendt"] +DEPENDENCIES = ["i2s_audio"] + +I2SAudioSpeakerBase = i2s_audio_ns.class_( + "I2SAudioSpeakerBase", cg.Component, speaker.Speaker, I2SAudioOut +) +I2SAudioSpeaker = i2s_audio_ns.class_("I2SAudioSpeaker", I2SAudioSpeakerBase) + +CONF_DAC_TYPE = "dac_type" +CONF_I2S_COMM_FMT = "i2s_comm_fmt" +CONF_SPDIF_MODE = "spdif_mode" + +I2SAudioSpeakerBase = i2s_audio_ns.class_( + "I2SAudioSpeakerBase", cg.Component, speaker.Speaker, I2SAudioOut +) +I2SAudioSpeaker = i2s_audio_ns.class_("I2SAudioSpeaker", I2SAudioSpeakerBase) +I2SAudioSpeakerSPDIF = i2s_audio_ns.class_("I2SAudioSpeakerSPDIF", I2SAudioSpeakerBase) + +I2SCommFmt = i2s_audio_ns.enum("I2SCommFmt", is_class=True) + +I2SCommFmt = i2s_audio_ns.enum("I2SCommFmt", is_class=True) + +i2s_dac_mode_t = cg.global_ns.enum("i2s_dac_mode_t") +INTERNAL_DAC_OPTIONS = { + CONF_LEFT: i2s_dac_mode_t.I2S_DAC_CHANNEL_LEFT_EN, + CONF_RIGHT: i2s_dac_mode_t.I2S_DAC_CHANNEL_RIGHT_EN, + CONF_STEREO: i2s_dac_mode_t.I2S_DAC_CHANNEL_BOTH_EN, +} + +i2s_comm_format_t = cg.global_ns.enum("i2s_comm_format_t") +I2C_COMM_FMT_OPTIONS = { + "stand_i2s": i2s_comm_format_t.I2S_COMM_FORMAT_STAND_I2S, + "stand_msb": i2s_comm_format_t.I2S_COMM_FORMAT_STAND_MSB, + "stand_pcm_short": i2s_comm_format_t.I2S_COMM_FORMAT_STAND_PCM_SHORT, + "stand_pcm_long": i2s_comm_format_t.I2S_COMM_FORMAT_STAND_PCM_LONG, + "stand_max": i2s_comm_format_t.I2S_COMM_FORMAT_STAND_MAX, + "i2s_msb": i2s_comm_format_t.I2S_COMM_FORMAT_I2S_MSB, + "i2s_lsb": i2s_comm_format_t.I2S_COMM_FORMAT_I2S_LSB, + "pcm": i2s_comm_format_t.I2S_COMM_FORMAT_PCM, + "pcm_short": i2s_comm_format_t.I2S_COMM_FORMAT_PCM_SHORT, + "pcm_long": i2s_comm_format_t.I2S_COMM_FORMAT_PCM_LONG, +} + +INTERNAL_DAC_VARIANTS = [esp32.VARIANT_ESP32] + + +def _set_num_channels_from_config(config: ConfigType) -> ConfigType: + if config[CONF_CHANNEL] in (CONF_MONO, CONF_LEFT, CONF_RIGHT): + config[CONF_NUM_CHANNELS] = 1 + else: + config[CONF_NUM_CHANNELS] = 2 + + return config + + +def _set_stream_limits(config: ConfigType) -> ConfigType: + if config.get(CONF_SPDIF_MODE, False): + # SPDIF mode: 16/24/32-bit audio and stereo at configured sample rate + audio.set_stream_limits( + min_bits_per_sample=16, + max_bits_per_sample=32, + min_channels=2, + max_channels=2, + min_sample_rate=config.get(CONF_SAMPLE_RATE), + max_sample_rate=config.get(CONF_SAMPLE_RATE), + )(config) + return config + + # The original ESP32 cannot lay out sub-16-bit slots that match ESPHome's packed audio, so the smallest + # stream it accepts is 16-bit (see start_i2s_driver); the other variants handle 8-bit. + min_bits_per_sample = 16 if esp32.get_esp32_variant() == esp32.VARIANT_ESP32 else 8 + + # The configured bits per sample sets the I2S slot width, but the speaker narrows wider streams down to it + # in place before clocking them out (see start_i2s_driver). Advertise up to 32-bit so those wider streams + # are accepted rather than forcing an upstream conversion. + max_bits_per_sample = 32 + + if config[CONF_I2S_MODE] == CONF_PRIMARY: + # Primary mode can reconfigure the bus to the incoming sample rate and channel count. + audio.set_stream_limits( + min_bits_per_sample=min_bits_per_sample, + max_bits_per_sample=max_bits_per_sample, + min_channels=1, + max_channels=2, + min_sample_rate=16000, + max_sample_rate=48000, + )(config) + else: + # Secondary mode has unmodifiable min/max sample rates + audio.set_stream_limits( + min_bits_per_sample=min_bits_per_sample, + max_bits_per_sample=max_bits_per_sample, + min_channels=1, + max_channels=2, + min_sample_rate=config.get(CONF_SAMPLE_RATE), + max_sample_rate=config.get(CONF_SAMPLE_RATE), + )(config) + + return config + + +def _select_speaker_class(config: ConfigType) -> ConfigType: + """Override ID type when SPDIF mode is enabled.""" + if config.get(CONF_SPDIF_MODE, False): + config[CONF_ID].type = I2SAudioSpeakerSPDIF + return config + + +def _validate_esp32_variant(config: ConfigType) -> ConfigType: + variant = esp32.get_esp32_variant() + if config[CONF_DAC_TYPE] == "internal": + if variant not in INTERNAL_DAC_VARIANTS: + raise cv.Invalid(f"{variant} does not have an internal DAC") + elif variant == esp32.VARIANT_ESP32 and config[CONF_BITS_PER_SAMPLE] == 8: + # The original ESP32 I2S peripheral packs each sample into a whole number of 16-bit words, so an + # 8-bit slot does not line up with ESPHome's tightly packed audio (see start_i2s_driver). Reject it + # at config time rather than emitting corrupted output at runtime. + raise cv.Invalid("8-bit audio is not supported on the original ESP32") + return config + + +BASE_SCHEMA = ( + speaker.SPEAKER_SCHEMA.extend( + i2s_audio_component_schema( + I2SAudioSpeaker, + default_sample_rate=16000, + default_channel=CONF_MONO, + default_bits_per_sample="16bit", + ) + ) + .extend( + { + cv.Optional( + CONF_BUFFER_DURATION, default="500ms" + ): cv.positive_time_period_milliseconds, + cv.Optional(CONF_TIMEOUT, default="500ms"): cv.Any( + cv.positive_time_period_milliseconds, + cv.one_of(CONF_NEVER, lower=True), + ), + } + ) + .extend(cv.COMPONENT_SCHEMA) +) + + +CONFIG_SCHEMA = cv.All( + cv.typed_schema( + { + "internal": BASE_SCHEMA.extend( + { + cv.Required(CONF_MODE): cv.enum(INTERNAL_DAC_OPTIONS, lower=True), + } + ), + "external": BASE_SCHEMA.extend( + { + cv.Required( + CONF_I2S_DOUT_PIN + ): pins.internal_gpio_output_pin_number, + cv.Optional(CONF_I2S_COMM_FMT, default="stand_i2s"): cv.one_of( + *I2C_COMM_FMT_OPTIONS, lower=True + ), + cv.Optional(CONF_SPDIF_MODE, default=False): cv.boolean, + } + ), + }, + key=CONF_DAC_TYPE, + ), + _validate_esp32_variant, + _set_num_channels_from_config, + _set_stream_limits, + _select_speaker_class, + validate_mclk_divisible_by_3, +) + + +def _final_validate(config: ConfigType) -> None: + if config[CONF_DAC_TYPE] == "internal": + raise cv.Invalid( + "Internal DAC is no longer supported. Use an external I2S DAC instead." + ) + if config[CONF_I2S_COMM_FMT] == "stand_max": + raise cv.Invalid("I2S standard max format is no longer supported.") + + if config.get(CONF_SPDIF_MODE, False): + # SPDIF mode specific validations + if config[CONF_SAMPLE_RATE] not in [44100, 48000]: + raise cv.Invalid( + "SPDIF mode only supports 44100 Hz or 48000 Hz sample rates" + ) + if config[CONF_CHANNEL] != CONF_STEREO: + raise cv.Invalid("SPDIF mode only supports stereo channel configuration") + if not config[CONF_USE_APLL]: + raise cv.Invalid( + "SPDIF mode requires 'use_apll: true' for accurate clock generation" + ) + if config[CONF_I2S_MODE] != CONF_PRIMARY: + raise cv.Invalid("SPDIF mode requires 'i2s_mode: primary'") + if config[CONF_I2S_COMM_FMT] != "stand_i2s": + raise cv.Invalid("SPDIF mode requires 'i2s_comm_fmt: stand_i2s'") + if config[CONF_MCLK_MULTIPLE] != 256: + raise cv.Invalid("SPDIF mode requires 'mclk_multiple: 256'") + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await register_i2s_audio_component(var, config) + await speaker.register_speaker(var, config) + + cg.add(var.set_dout_pin(config[CONF_I2S_DOUT_PIN])) + parent = await cg.get_variable(config[CONF_I2S_AUDIO_ID]) + cg.add(parent.set_dout_pin(config[CONF_I2S_DOUT_PIN])) + + is_spdif = config.get(CONF_SPDIF_MODE, False) + if is_spdif: + cg.add_define("USE_I2S_AUDIO_SPDIF_MODE") + else: + fmt = I2SCommFmt.STANDARD # equals stand_i2s, stand_pcm_long, i2s_msb, pcm_long + if config[CONF_I2S_COMM_FMT] in ["stand_msb", "i2s_lsb"]: + fmt = I2SCommFmt.MSB + elif config[CONF_I2S_COMM_FMT] in ["stand_pcm_short", "pcm_short", "pcm"]: + fmt = I2SCommFmt.PCM + cg.add(var.set_i2s_comm_fmt(fmt)) + + if config[CONF_TIMEOUT] != CONF_NEVER: + cg.add(var.set_timeout(config[CONF_TIMEOUT])) + cg.add(var.set_buffer_duration(config[CONF_BUFFER_DURATION])) + + +# The SPDIF encoder and speaker are fully #ifdef'd on USE_I2S_AUDIO_SPDIF_MODE, +# set only when spdif_mode is enabled. +FILTER_SOURCE_FILES = filter_source_files_from_defines( + { + "spdif_encoder.cpp": "USE_I2S_AUDIO_SPDIF_MODE", + "i2s_audio_spdif.cpp": "USE_I2S_AUDIO_SPDIF_MODE", + } +) diff --git a/Integrations/ESPHome/components/i2s_audio/speaker/i2s_audio_spdif.cpp b/Integrations/ESPHome/components/i2s_audio/speaker/i2s_audio_spdif.cpp new file mode 100644 index 0000000..ed5145d --- /dev/null +++ b/Integrations/ESPHome/components/i2s_audio/speaker/i2s_audio_spdif.cpp @@ -0,0 +1,504 @@ +#include "i2s_audio_spdif.h" + +#if defined(USE_ESP32) && defined(USE_I2S_AUDIO_SPDIF_MODE) + +#include + +#include "esphome/components/audio/audio.h" +#include "esphome/components/audio/audio_transfer_buffer.h" + +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +#include "esp_timer.h" + +namespace esphome::i2s_audio { + +static const char *const TAG = "i2s_audio.spdif"; + +// SPDIF mode adds overhead as each sample is encapsulated in a subframe; +// each DMA buffer can hold only 192 samples (~4ms each vs. ~15ms for standard I2S). +// To match the standard I2S buffering duration, we use more buffers to minimize +// the impact of the overhead, such as stuttering or audio/silence oscillation. +// 15 buffers x 4ms = 60ms of DMA buffering (same as 4 x 15ms for standard) +static constexpr size_t SPDIF_DMA_BUFFERS_COUNT = 15; + +// Number of DMA events between upstream callbacks (~16ms = 4 events x 4ms each). +// Matches non-SPDIF timing to prevent overwhelming upstream sync algorithms. +static constexpr uint32_t SPDIF_DMA_EVENTS_PER_CALLBACK = 4; + +// Brief retry wait used by play() to catch short free-space windows during rapid track transitions. +static constexpr uint32_t SPDIF_PLAY_RETRY_WAIT_MS = 5; + +static constexpr size_t SPDIF_I2S_EVENT_QUEUE_COUNT = 2 * SPDIF_DMA_BUFFERS_COUNT; + +// Static callback functions for SPDIF encoder (avoids std::function overhead) +static esp_err_t spdif_preload_cb(void *user_ctx, uint32_t *data, size_t size, TickType_t ticks_to_wait) { + auto *speaker = static_cast(user_ctx); + size_t bytes_written = 0; + esp_err_t err = i2s_channel_preload_data(speaker->get_tx_handle(), data, size, &bytes_written); + if (err != ESP_OK || bytes_written != size) { + ESP_LOGV(TAG, "Preload failed: %s (wrote %zu/%zu bytes)", esp_err_to_name(err), bytes_written, size); + return (err != ESP_OK) ? err : ESP_ERR_NO_MEM; + } + return ESP_OK; +} + +static esp_err_t spdif_write_cb(void *user_ctx, uint32_t *data, size_t size, TickType_t ticks_to_wait) { + auto *speaker = static_cast(user_ctx); + size_t bytes_written = 0; + esp_err_t err = i2s_channel_write(speaker->get_tx_handle(), data, size, &bytes_written, ticks_to_wait); + if (err != ESP_OK) { + ESP_LOGV(TAG, "I2S write failed: %s (wrote %zu/%zu bytes)", esp_err_to_name(err), bytes_written, size); + } + return err; +} + +void I2SAudioSpeakerSPDIF::setup() { + I2SAudioSpeakerBase::setup(); + if (this->is_failed()) { + return; + } + + this->spdif_encoder_ = new SPDIFEncoder(); + if (!this->spdif_encoder_->setup()) { + ESP_LOGE(TAG, "Encoder setup failed"); + this->mark_failed(); + return; + } + + // Configure channel status block with the sample rate + this->spdif_encoder_->set_sample_rate(this->sample_rate_); + + // Separate callbacks for preload (during underflow recovery) and normal writes + this->spdif_encoder_->set_preload_callback(spdif_preload_cb, this); + this->spdif_encoder_->set_write_callback(spdif_write_cb, this); +} + +void I2SAudioSpeakerSPDIF::dump_config() { + I2SAudioSpeakerBase::dump_config(); + ESP_LOGCONFIG(TAG, + " SPDIF Mode: YES\n" + " Sample Rate: %" PRIu32 " Hz", + this->sample_rate_); +} + +void I2SAudioSpeakerSPDIF::on_task_stopped() { this->spdif_silence_start_ = 0; } + +size_t I2SAudioSpeakerSPDIF::play(const uint8_t *data, size_t length, TickType_t ticks_to_wait) { + if (this->is_failed()) { + ESP_LOGE(TAG, "Setup failed; cannot play audio"); + return 0; + } + + // In SPDIF mode, keep accepting upstream audio while the speaker task is active. + // This avoids transient drops during stop/start transitions. + const bool task_active = (this->speaker_task_handle_ != nullptr); + + if (this->state_ != speaker::STATE_RUNNING && this->state_ != speaker::STATE_STARTING) { + this->start(); + } + + if (!task_active && this->state_ != speaker::STATE_RUNNING) { + // Unable to write data to a running speaker, so delay the max amount of time so it can get ready + vTaskDelay(ticks_to_wait); + ticks_to_wait = 0; + } + + size_t bytes_written = 0; + if (this->state_ == speaker::STATE_RUNNING || task_active) { + std::shared_ptr temp_ring_buffer = this->audio_ring_buffer_.lock(); + if (temp_ring_buffer != nullptr) { + // In SPDIF mode, a tiny wait helps avoid transient 0-byte writes during short backpressure windows. + TickType_t effective_ticks_to_wait = ticks_to_wait; + if (effective_ticks_to_wait == 0) { + effective_ticks_to_wait = pdMS_TO_TICKS(1); + } + bytes_written = temp_ring_buffer->write_without_replacement((void *) data, length, effective_ticks_to_wait); + if (bytes_written == 0 && length > 0) { + // Retry once to catch short free-space windows during rapid seek/track transitions. + bytes_written = + temp_ring_buffer->write_without_replacement((void *) data, length, pdMS_TO_TICKS(SPDIF_PLAY_RETRY_WAIT_MS)); + } + } + } + + return bytes_written; +} + +void I2SAudioSpeakerSPDIF::run_speaker_task() { + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_STARTING); + + // Reset SPDIF encoder at task start to ensure clean state + // (previous task may have left stale data in encoder buffer) + if (this->spdif_encoder_ != nullptr) { + this->spdif_encoder_->reset(); + } + + // Reset lockstep records queue so it starts paired with the (also-reset) i2s_event_queue_. + xQueueReset(this->write_records_queue_); + + // The DMA buffers may have more bits per sample, so calculate buffer sizes based on the input audio stream info + const size_t bytes_per_frame = this->current_stream_info_.frames_to_bytes(1); + + // For SPDIF mode, one DMA buffer = one SPDIF block = 192 PCM frames (~4 ms at 48 kHz), + // not the ~15 ms a standard I2S DMA buffer holds. Derive the DMA floor from actual block size. + const uint32_t frames_to_fill_single_dma_buffer = SPDIF_BLOCK_SAMPLES; + const size_t bytes_to_fill_single_dma_buffer = + this->current_stream_info_.frames_to_bytes(frames_to_fill_single_dma_buffer); + const size_t dma_buffers_floor_bytes = bytes_to_fill_single_dma_buffer * SPDIF_DMA_BUFFERS_COUNT; + + // Round the ring buffer size down to a multiple of bytes_per_frame so the wrap boundary stays frame-aligned and + // avoids unnecessary single-frame splices. Ensure it is at least large enough to cover all DMA buffers. + const size_t requested_ring_buffer_bytes = + (this->current_stream_info_.ms_to_bytes(this->buffer_duration_ms_) / bytes_per_frame) * bytes_per_frame; + const size_t ring_buffer_size = std::max(dma_buffers_floor_bytes, requested_ring_buffer_bytes); + + bool successful_setup = false; + std::unique_ptr audio_source; + + { + std::shared_ptr temp_ring_buffer = ring_buffer::RingBuffer::create(ring_buffer_size); + audio_source = audio::RingBufferAudioSource::create(temp_ring_buffer, bytes_to_fill_single_dma_buffer, + static_cast(bytes_per_frame)); + if (audio_source != nullptr) { + this->audio_ring_buffer_ = temp_ring_buffer; + successful_setup = true; + } + } + + if (!successful_setup) { + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_ESP_NO_MEM); + } else { + // Preload DMA buffers with SPDIF-encoded silence before enabling the channel. + // This ensures the first data transmitted is valid SPDIF (not raw zeros from + // auto_clear) and prevents phantom DMA events before real audio is available. + // Each preloaded block pushes a 0-real-frame record so that the corresponding + // on_sent events drain in lockstep without crediting any audio frames. + this->spdif_encoder_->set_preload_mode(true); + for (size_t i = 0; i < SPDIF_DMA_BUFFERS_COUNT; i++) { + // i2s_channel_preload_data is non-blocking (returns immediately when the preload buffer fills), so no wait. + esp_err_t preload_err = this->spdif_encoder_->flush_with_silence(0); + if (preload_err != ESP_OK) { + break; // DMA preload buffer full or error + } + const uint32_t silence_record = 0; + xQueueSendToBack(this->write_records_queue_, &silence_record, 0); + } + this->spdif_encoder_->set_preload_mode(false); + this->spdif_encoder_->reset(); // Clean encoder state for the main loop + + // Now register the callback and enable the channel + xQueueReset(this->i2s_event_queue_); + const i2s_event_callbacks_t callbacks = {.on_sent = i2s_on_sent_cb}; + i2s_channel_register_event_callback(this->tx_handle_, &callbacks, this); + i2s_channel_enable(this->tx_handle_); + + // Always-fill model: each iteration produces exactly one SPDIF block (= one DMA buffer). + // We drain real PCM up to one block from the ring buffer and silence-pad any remainder. + // Blocking writes pace the loop at the DMA consumption rate. This mirrors the standard + // I2S speaker pattern (PR #16317): fill what you can, then silence-pad whatever is still + // missing to complete the DMA buffer. + const uint32_t block_duration_us = this->current_stream_info_.frames_to_microseconds(SPDIF_BLOCK_SAMPLES); + // Sized to absorb the worst case where every DMA buffer is full when we issue the write. + const TickType_t write_timeout_ticks = + pdMS_TO_TICKS(((block_duration_us * (SPDIF_DMA_BUFFERS_COUNT + 1)) + 999) / 1000); + // Brief read budget when the ring buffer is empty (~half a block). + const TickType_t read_timeout_ticks = pdMS_TO_TICKS(((block_duration_us / 2) + 999) / 1000); + + // SPDIF Callback Decimation: fire every 4th DMA event (~16ms), matching non-SPDIF timing. + uint32_t spdif_pending_frames = 0; + int64_t spdif_pending_timestamp = 0; + uint32_t spdif_dma_event_count = 0; + + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_RUNNING); + + // SPDIF continuous mode: loop runs indefinitely, outputting silence when no audio data + // to keep the receiver synced. Exits only via break (stream info change, silence timeout, + // lockstep desync, dropped event, or partial-write failure). + while (true) { + uint32_t event_group_bits = xEventGroupGetBits(this->event_group_); + + if (event_group_bits & SpeakerEventGroupBits::COMMAND_STOP) { + xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::COMMAND_STOP); + // The ISR pairs COMMAND_STOP with ERR_DROPPED_EVENT when it has to discard a completion + // event; that desyncs the lockstep queues permanently and the only safe recovery is a full + // task restart. + if (event_group_bits & SpeakerEventGroupBits::ERR_DROPPED_EVENT) { + ESP_LOGV(TAG, "Exiting: ISR dropped event, restarting to recover lockstep"); + break; + } + // User-initiated stop. In SPDIF continuous mode, transition to silence output rather + // than tearing the task down. + this->spdif_silence_start_ = millis(); + ESP_LOGV(TAG, "COMMAND_STOP received, continuing in silence mode"); + } + if (event_group_bits & SpeakerEventGroupBits::COMMAND_STOP_GRACEFULLY) { + // SPDIF continuous mode never tears the channel down on graceful stop. Clear the flag and + // let the audio simply drain through the always-fill loop into the silence-timeout path. + xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::COMMAND_STOP_GRACEFULLY); + } + + if (this->audio_stream_info_ != this->current_stream_info_) { + ESP_LOGV(TAG, "Exiting: stream info changed"); + break; + } + + // Drain ISR completion events, popping a matching record for each. + int64_t write_timestamp; + bool lockstep_broken = false; + while (xQueueReceive(this->i2s_event_queue_, &write_timestamp, 0)) { + // Lockstep: pop the matching record (real audio frames packed into this DMA block). + // Records are pushed by the task right after each successful block commit, so the FIFO + // order matches DMA completion order. Empty records queue here means lockstep broke. + uint32_t real_frames = 0; + if (xQueueReceive(this->write_records_queue_, &real_frames, 0) != pdTRUE) { + ESP_LOGV(TAG, "Event without matching write record"); + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_LOCKSTEP_DESYNC); + lockstep_broken = true; + break; + } + + // Per-block timestamp adjustment: shift back by the silence-padding portion of the block + // so the reported timestamp reflects when the last real sample left the wire. + uint32_t frames_sent = real_frames; + if (real_frames < SPDIF_BLOCK_SAMPLES) { + const uint32_t frames_zeroed = SPDIF_BLOCK_SAMPLES - real_frames; + write_timestamp -= this->current_stream_info_.frames_to_microseconds(frames_zeroed); + } + + spdif_dma_event_count++; + // Accumulate frames; keep the latest timestamp so the callback reports when the last + // sample left the wire, not the first. + if (frames_sent > 0) { + spdif_pending_timestamp = write_timestamp; + spdif_pending_frames += frames_sent; + } + + bool decimation_reached = (spdif_dma_event_count >= SPDIF_DMA_EVENTS_PER_CALLBACK); + // Partial blocks mark an end-of-stream boundary (silence-padded tail). Fire immediately + // so the back-shifted timestamp isn't overwritten by a later full audio block landing + // in the same decimation window. + bool partial_flush = (real_frames > 0 && real_frames < SPDIF_BLOCK_SAMPLES); + + if (decimation_reached || partial_flush) { + if (spdif_pending_frames > 0) { + this->audio_output_callback_(spdif_pending_frames, spdif_pending_timestamp); + spdif_pending_frames = 0; + } + spdif_dma_event_count = 0; + } + } + if (lockstep_broken) { + ESP_LOGV(TAG, "Exiting: lockstep desync, restarting task"); + break; + } + + // Always-fill: produce exactly one SPDIF block this iteration. The blocking encoder write + // paces the task at the DMA consumption rate. + uint32_t real_frames_in_block = 0; + bool block_committed = false; + bool partial_write_failure = false; + + if (!this->pause_state_) { + while (real_frames_in_block < SPDIF_BLOCK_SAMPLES) { + if (audio_source->available() == 0) { + size_t bytes_read = audio_source->fill(read_timeout_ticks, false); + if (bytes_read == 0) { + break; // No upstream data within the read budget; silence-pad the remainder. + } + uint8_t *new_data = audio_source->mutable_data(); + this->apply_software_volume_(new_data, bytes_read); + this->swap_esp32_mono_samples_(new_data, bytes_read); + } + + const uint32_t frames_still_needed = SPDIF_BLOCK_SAMPLES - real_frames_in_block; + const size_t bytes_still_needed = this->current_stream_info_.frames_to_bytes(frames_still_needed); + const size_t bytes_to_feed = std::min(audio_source->available(), bytes_still_needed); + + uint32_t blocks_sent = 0; + size_t pcm_consumed = 0; + esp_err_t err = this->spdif_encoder_->write(audio_source->data(), bytes_to_feed, write_timeout_ticks, + &blocks_sent, &pcm_consumed); + if (err != ESP_OK) { + // A failed (or timed-out) send leaves an unsent block in the encoder's stitch buffer; + // resuming would credit the next iteration's bytes against an old block. Bail and + // let loop() restart the task with a clean encoder. + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_PARTIAL_WRITE); + partial_write_failure = true; + break; + } + + if (pcm_consumed > 0) { + audio_source->consume(pcm_consumed); + real_frames_in_block += this->current_stream_info_.bytes_to_frames(pcm_consumed); + } + if (blocks_sent > 0) { + block_committed = true; + break; + } + } + } + + if (partial_write_failure) { + break; + } + + if (!block_committed) { + // Pad whatever real audio we managed to feed (if any) with silence to complete one block, + // or emit a full silence block if the encoder is empty. + esp_err_t err = this->spdif_encoder_->flush_with_silence(write_timeout_ticks); + if (err != ESP_OK) { + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_PARTIAL_WRITE); + break; + } + } + + // One block committed to DMA; push exactly one record carrying its real-audio frame count. + // Failure here means the records queue is full, which violates the lockstep invariant. + if (xQueueSendToBack(this->write_records_queue_, &real_frames_in_block, 0) != pdTRUE) { + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_LOCKSTEP_DESYNC); + break; + } + + // Silence-timeout tracking and graceful-stop reset. + if (real_frames_in_block == 0) { + if (this->spdif_silence_start_ == 0) { + this->spdif_silence_start_ = millis(); + } + + if (this->timeout_.has_value()) { + const uint32_t silence_duration = millis() - this->spdif_silence_start_; + if (silence_duration >= this->timeout_.value()) { + ESP_LOGV(TAG, "Silence timeout reached (%" PRIu32 "ms) - stopping speaker", silence_duration); + break; + } + } + } else if (this->spdif_silence_start_ != 0) { + uint32_t silence_duration = millis() - this->spdif_silence_start_; + if (silence_duration > 100) { + ESP_LOGV(TAG, "Exiting silence mode after %" PRIu32 "ms, have audio data", silence_duration); + } + this->spdif_silence_start_ = 0; + } + } + } + + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_STOPPING); + + // Reset SPDIF encoder state to prevent stale state on next start + if (this->spdif_encoder_ != nullptr) { + this->spdif_encoder_->set_preload_mode(false); + this->spdif_encoder_->reset(); + } + + audio_source.reset(); + + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_STOPPED); + + while (true) { + // Continuously delay until the loop method deletes the task + vTaskDelay(pdMS_TO_TICKS(10)); + } +} + +esp_err_t I2SAudioSpeakerSPDIF::start_i2s_driver(audio::AudioStreamInfo &audio_stream_info) { + this->current_stream_info_ = audio_stream_info; + // SPDIF never narrows the bit depth; the encoder consumes the input format directly. + this->output_stream_info_ = audio_stream_info; + + // SPDIF mode validation + if (this->sample_rate_ != audio_stream_info.get_sample_rate()) { + ESP_LOGE(TAG, "Only supports a single sample rate (configured: %" PRIu32 " Hz, stream: %" PRIu32 " Hz)", + this->sample_rate_, audio_stream_info.get_sample_rate()); + return ESP_ERR_NOT_SUPPORTED; + } + const uint8_t bits_per_sample = audio_stream_info.get_bits_per_sample(); + if (bits_per_sample != 16 && bits_per_sample != 24 && bits_per_sample != 32) { + ESP_LOGE(TAG, "Only supports 16, 24, or 32 bits per sample (got %u)", (unsigned) bits_per_sample); + return ESP_ERR_NOT_SUPPORTED; + } + if (audio_stream_info.get_channels() != 2) { + ESP_LOGE(TAG, "Only supports stereo (2 channels)"); + return ESP_ERR_NOT_SUPPORTED; + } + + // Tell the encoder what input width to expect. 32-bit input is truncated to 24-bit on the wire. + this->spdif_encoder_->set_bytes_per_sample(bits_per_sample / 8); + + if (!this->parent_->try_lock()) { + ESP_LOGE(TAG, "Parent bus is busy"); + return ESP_ERR_INVALID_STATE; + } + + i2s_clock_src_t clk_src = I2S_CLK_SRC_DEFAULT; + +#if SOC_CLK_APLL_SUPPORTED + if (this->use_apll_) { + clk_src = i2s_clock_src_t::I2S_CLK_SRC_APLL; + } +#endif // SOC_CLK_APLL_SUPPORTED + + // SPDIF mode: fixed configuration for BMC encoding + // For new driver, dma_frame_num is in I2S frames (8 bytes each for 32-bit stereo) + uint32_t dma_buffer_length = SPDIF_BLOCK_I2S_FRAMES; // One SPDIF block = 384 I2S frames = 3072 bytes + + // Log DMA configuration for debugging + ESP_LOGV(TAG, "I2S DMA config: %zu buffers x %lu frames = %lu bytes total", (size_t) SPDIF_DMA_BUFFERS_COUNT, + (unsigned long) dma_buffer_length, + (unsigned long) (SPDIF_DMA_BUFFERS_COUNT * dma_buffer_length * 8)); // 8 bytes per frame for 32-bit stereo + + i2s_chan_config_t chan_cfg = { + .id = this->parent_->get_port(), + .role = this->i2s_role_, + .dma_desc_num = SPDIF_DMA_BUFFERS_COUNT, + .dma_frame_num = dma_buffer_length, + .auto_clear = true, + .intr_priority = 3, + }; + + // SPDIF: double sample rate for BMC, 32-bit stereo, only data pin needed + i2s_std_clk_config_t clk_cfg = { + .sample_rate_hz = this->sample_rate_ * 2, + .clk_src = clk_src, + .mclk_multiple = this->mclk_multiple_, + }; + + i2s_std_slot_config_t slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_32BIT, I2S_SLOT_MODE_STEREO); + + i2s_std_gpio_config_t gpio_cfg = { + .mclk = GPIO_NUM_NC, + .bclk = GPIO_NUM_NC, + .ws = GPIO_NUM_NC, + .dout = this->dout_pin_, + .din = GPIO_NUM_NC, + .invert_flags = + { + .mclk_inv = false, + .bclk_inv = false, + .ws_inv = false, + }, + }; + + i2s_std_config_t std_cfg = { + .clk_cfg = clk_cfg, + .slot_cfg = slot_cfg, + .gpio_cfg = gpio_cfg, + }; + + esp_err_t err = this->init_i2s_channel_(chan_cfg, std_cfg, SPDIF_I2S_EVENT_QUEUE_COUNT); + if (err != ESP_OK) { + return err; + } + + // Channel is NOT enabled here. The speaker task will preload DMA buffers + // with SPDIF-encoded silence before enabling, ensuring the first data on + // the wire is valid SPDIF (not raw zeros from auto_clear) and preventing + // phantom DMA events before real audio data is available. + + return ESP_OK; +} + +} // namespace esphome::i2s_audio + +#endif // USE_ESP32 && USE_I2S_AUDIO_SPDIF_MODE diff --git a/Integrations/ESPHome/components/i2s_audio/speaker/i2s_audio_spdif.h b/Integrations/ESPHome/components/i2s_audio/speaker/i2s_audio_spdif.h new file mode 100644 index 0000000..ca77741 --- /dev/null +++ b/Integrations/ESPHome/components/i2s_audio/speaker/i2s_audio_spdif.h @@ -0,0 +1,34 @@ +#pragma once + +#include "esphome/core/defines.h" + +#if defined(USE_ESP32) && defined(USE_I2S_AUDIO_SPDIF_MODE) + +#include "i2s_audio_speaker.h" +#include "spdif_encoder.h" + +namespace esphome::i2s_audio { + +/// @brief SPDIF speaker implementation. +/// Encodes PCM audio into IEC 60958-3 S/PDIF bitstream using BMC encoding, +/// outputting through a single I2S data pin. Maintains continuous output +/// (silence when no audio) to keep SPDIF receivers synchronized. +class I2SAudioSpeakerSPDIF : public I2SAudioSpeakerBase { + public: + void setup() override; + void dump_config() override; + + size_t play(const uint8_t *data, size_t length, TickType_t ticks_to_wait) override; + + protected: + void run_speaker_task() override; + esp_err_t start_i2s_driver(audio::AudioStreamInfo &audio_stream_info) override; + void on_task_stopped() override; + + SPDIFEncoder *spdif_encoder_{nullptr}; + uint32_t spdif_silence_start_{0}; // Timestamp when silence mode started (0 = not in silence) +}; + +} // namespace esphome::i2s_audio + +#endif // USE_ESP32 && USE_I2S_AUDIO_SPDIF_MODE diff --git a/Integrations/ESPHome/components/i2s_audio/speaker/i2s_audio_speaker.cpp b/Integrations/ESPHome/components/i2s_audio/speaker/i2s_audio_speaker.cpp new file mode 100644 index 0000000..62d4538 --- /dev/null +++ b/Integrations/ESPHome/components/i2s_audio/speaker/i2s_audio_speaker.cpp @@ -0,0 +1,405 @@ +#include "i2s_audio_speaker.h" + +#ifdef USE_ESP32 + +#include +#include + +#include "esphome/components/audio/audio.h" +#include "esphome/components/audio/audio_transfer_buffer.h" + +#include "esphome/core/application.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +#include "esp_timer.h" + +// esp-audio-libs +#include + +namespace esphome::i2s_audio { + +static const char *const TAG = "i2s_audio.speaker"; + +// Software volume control maps the user-facing [0.0, 1.0] range to a Q31 scale factor. +// Volumes in (0.0, 1.0) map linearly to a dB reduction in [-49.0, 0.0] dB. +static constexpr float SOFTWARE_VOLUME_MIN_DB = -49.0f; + +void I2SAudioSpeakerBase::setup() { + this->event_group_ = xEventGroupCreate(); + + if (this->event_group_ == nullptr) { + ESP_LOGE(TAG, "Event group creation failed"); + this->mark_failed(); + return; + } + + // Initialize volume control. When audio_dac is configured, this sets the DAC volume. + // When no audio_dac is configured, this initializes software volume control. + this->set_volume(this->volume_); +} + +void I2SAudioSpeakerBase::dump_config() { + ESP_LOGCONFIG(TAG, + "Speaker:\n" + " Pin: %d\n" + " Buffer duration: %" PRIu32, + static_cast(this->dout_pin_), this->buffer_duration_ms_); + if (this->timeout_.has_value()) { + ESP_LOGCONFIG(TAG, " Timeout: %" PRIu32 " ms", this->timeout_.value()); + } +} + +void I2SAudioSpeakerBase::loop() { + uint32_t event_group_bits = xEventGroupGetBits(this->event_group_); + + // A stop that arrives while stopped cancels any start that has not been processed yet + constexpr uint32_t stop_bits = SpeakerEventGroupBits::COMMAND_STOP | SpeakerEventGroupBits::COMMAND_STOP_GRACEFULLY; + if ((event_group_bits & stop_bits) && (this->state_ == speaker::STATE_STOPPED)) { + xEventGroupClearBits(this->event_group_, stop_bits | SpeakerEventGroupBits::COMMAND_START); + event_group_bits &= ~(stop_bits | SpeakerEventGroupBits::COMMAND_START); + } + + if ((event_group_bits & SpeakerEventGroupBits::COMMAND_START) && (this->state_ == speaker::STATE_STOPPED)) { + this->state_ = speaker::STATE_STARTING; + xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::COMMAND_START); + } + + // Handle the task's state + if (event_group_bits & SpeakerEventGroupBits::TASK_STARTING) { + ESP_LOGD(TAG, "Starting"); + xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::TASK_STARTING); + } + if (event_group_bits & SpeakerEventGroupBits::TASK_RUNNING) { + ESP_LOGV(TAG, "Started"); + xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::TASK_RUNNING); + this->state_ = speaker::STATE_RUNNING; + } + if (event_group_bits & SpeakerEventGroupBits::TASK_STOPPING) { + ESP_LOGV(TAG, "Stopping"); + // Lockstep-breaking error bits are latched by the task and cleared along with all other bits + // when TASK_STOPPED is processed; log them here, exactly once, as the task winds down. + if (event_group_bits & SpeakerEventGroupBits::ERR_DROPPED_EVENT) { + ESP_LOGE(TAG, "ISR event queue overflow, restarting speaker task to recover timestamp sync"); + } + if (event_group_bits & SpeakerEventGroupBits::ERR_PARTIAL_WRITE) { + ESP_LOGE(TAG, "Partial DMA write broke buffer alignment, restarting speaker task"); + } + if (event_group_bits & SpeakerEventGroupBits::ERR_LOCKSTEP_DESYNC) { + ESP_LOGE(TAG, "Event/record queues desynced, restarting speaker task"); + } + xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::TASK_STOPPING); + this->state_ = speaker::STATE_STOPPING; + } + if (event_group_bits & SpeakerEventGroupBits::TASK_STOPPED) { + ESP_LOGD(TAG, "Stopped"); + + vTaskDelete(this->speaker_task_handle_); + this->speaker_task_handle_ = nullptr; + + this->stop_i2s_driver_(); + // ALL_BITS includes COMMAND_START. Take the bits from the clear itself, not from the snapshot at + // the top of loop(): the audio source's task can raise a start at any point above, including + // during stop_i2s_driver_(), and nothing would ever re-issue it. + const EventBits_t bits_before_clear = xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::ALL_BITS); + if (bits_before_clear & SpeakerEventGroupBits::COMMAND_START) { + ESP_LOGD(TAG, "Start requested while stopping; keeping the request"); + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::COMMAND_START); + } + this->status_clear_error(); + + this->on_task_stopped(); + + this->state_ = speaker::STATE_STOPPED; + } + + if (event_group_bits & SpeakerEventGroupBits::ERR_ESP_NO_MEM) { + ESP_LOGE(TAG, "Speaker task setup failed (allocation, preload, or channel enable)"); + xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::ERR_ESP_NO_MEM); + } + + // Handle the speaker's state + switch (this->state_) { + case speaker::STATE_STARTING: + if (this->status_has_error()) { + break; + } + + // Still starting up or winding down from a previous run + if ((this->tx_handle_ != nullptr) || (this->speaker_task_handle_ != nullptr)) { + break; + } + + if (this->start_i2s_driver(this->audio_stream_info_) != ESP_OK) { + ESP_LOGE(TAG, "Driver failed to start; retrying in 1 second"); + this->status_momentary_error("driver-failure", 1000); + break; + } + + xTaskCreate(I2SAudioSpeakerBase::speaker_task, "speaker_task", TASK_STACK_SIZE, (void *) this, TASK_PRIORITY, + &this->speaker_task_handle_); + + if (this->speaker_task_handle_ == nullptr) { + ESP_LOGE(TAG, "Task failed to start, retrying in 1 second"); + this->status_momentary_error("task-failure", 1000); + this->stop_i2s_driver_(); // Stops the driver to return the lock; will be reloaded in next attempt + } + break; + case speaker::STATE_RUNNING: // Intentional fallthrough + case speaker::STATE_STOPPING: // Intentional fallthrough + case speaker::STATE_STOPPED: + break; + } +} + +void I2SAudioSpeakerBase::set_volume(float volume) { + this->volume_ = volume; +#ifdef USE_AUDIO_DAC + if (this->audio_dac_ != nullptr) { + if (volume > 0.0f) { + this->audio_dac_->set_mute_off(); + } + this->audio_dac_->set_volume(volume); + } else +#endif // USE_AUDIO_DAC + { + // Fallback to software volume control by using a Q31 fixed point scaling factor. + // At maximum volume (1.0), set to INT32_MAX to bypass volume processing entirely + // and avoid any floating-point precision issues that could cause slight volume reduction. + if (volume >= 1.0f) { + this->q31_volume_factor_ = INT32_MAX; + } else if (volume <= 0.0f) { + this->q31_volume_factor_ = 0; + } else { + this->q31_volume_factor_ = + esp_audio_libs::gain::db_to_q31(remap(volume, 0.0f, 1.0f, SOFTWARE_VOLUME_MIN_DB, 0.0f)); + } + } +} + +void I2SAudioSpeakerBase::set_mute_state(bool mute_state) { + this->mute_state_ = mute_state; +#ifdef USE_AUDIO_DAC + if (this->audio_dac_) { + if (mute_state) { + this->audio_dac_->set_mute_on(); + } else { + this->audio_dac_->set_mute_off(); + } + } else +#endif // USE_AUDIO_DAC + { + if (mute_state) { + // Fallback to software volume control and scale by 0 + this->q31_volume_factor_ = 0; + } else { + // Revert to previous volume when unmuting + this->set_volume(this->volume_); + } + } +} + +size_t I2SAudioSpeakerBase::play(const uint8_t *data, size_t length, TickType_t ticks_to_wait) { + if (this->is_failed()) { + ESP_LOGE(TAG, "Setup failed; cannot play audio"); + return 0; + } + + if (this->state_ != speaker::STATE_RUNNING && this->state_ != speaker::STATE_STARTING) { + this->start(); + } + + if (this->state_ != speaker::STATE_RUNNING) { + // Unable to write data to a running speaker, so delay the max amount of time so it can get ready + vTaskDelay(ticks_to_wait); + ticks_to_wait = 0; + } + + size_t bytes_written = 0; + if (this->state_ == speaker::STATE_RUNNING) { + std::shared_ptr temp_ring_buffer = this->audio_ring_buffer_.lock(); + if (temp_ring_buffer != nullptr) { + // The weak_ptr locks successfully only while the speaker task owns the ring buffer, so it is safe to write + bytes_written = temp_ring_buffer->write_without_replacement((void *) data, length, ticks_to_wait); + } + } + + return bytes_written; +} + +bool I2SAudioSpeakerBase::has_buffered_data() const { + std::shared_ptr temp_ring_buffer = this->audio_ring_buffer_.lock(); + if (temp_ring_buffer != nullptr) { + return temp_ring_buffer->available() > 0; + } + return false; +} + +void I2SAudioSpeakerBase::speaker_task(void *params) { + I2SAudioSpeakerBase *this_speaker = (I2SAudioSpeakerBase *) params; + this_speaker->run_speaker_task(); +} + +void I2SAudioSpeakerBase::start() { + if (!this->is_ready() || this->is_failed() || this->status_has_error()) + return; + if ((this->state_ == speaker::STATE_STARTING) || (this->state_ == speaker::STATE_RUNNING)) + return; + + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::COMMAND_START); +} + +void I2SAudioSpeakerBase::stop() { this->stop_(false); } + +void I2SAudioSpeakerBase::finish() { this->stop_(true); } + +void I2SAudioSpeakerBase::stop_(bool wait_on_empty) { + if (!this->is_ready() || this->is_failed()) + return; + + // Always set the bit, even when stopped, so loop() can cancel a start that is still pending + if (wait_on_empty) { + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::COMMAND_STOP_GRACEFULLY); + } else { + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::COMMAND_STOP); + } +} + +esp_err_t I2SAudioSpeakerBase::init_i2s_channel_(const i2s_chan_config_t &chan_cfg, const i2s_std_config_t &std_cfg, + size_t event_queue_size) { + esp_err_t err = ESP_OK; + if (this->parent_->is_full_duplex()) { + err = this->parent_->setup_full_duplex_tx_channel(chan_cfg, std_cfg, &this->tx_handle_); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Full duplex TX setup failed: %s", esp_err_to_name(err)); + this->tx_handle_ = nullptr; + return err; + } + } else { + err = i2s_new_channel(&chan_cfg, &this->tx_handle_, NULL); + if (err != ESP_OK) { + ESP_LOGE(TAG, "I2S channel allocation failed: %s", esp_err_to_name(err)); + this->parent_->unlock(); + return err; + } + + err = i2s_channel_init_std_mode(this->tx_handle_, &std_cfg); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to initialize I2S channel"); + i2s_del_channel(this->tx_handle_); + this->tx_handle_ = nullptr; + this->parent_->unlock(); + return err; + } + } + + if (this->i2s_event_queue_ == nullptr) { + this->i2s_event_queue_ = xQueueCreate(event_queue_size, sizeof(int64_t)); + } else { + // Reset queue to clear any stale events from previous task + xQueueReset(this->i2s_event_queue_); + } + + // Lockstep records queue. One record per in-flight DMA buffer; sized to match the I2S event queue + // so a fully-saturated DMA pipeline cannot overflow either side before drain. + if (this->write_records_queue_ == nullptr) { + this->write_records_queue_ = xQueueCreate(event_queue_size, sizeof(uint32_t)); + } else { + xQueueReset(this->write_records_queue_); + } + + if (this->i2s_event_queue_ == nullptr || this->write_records_queue_ == nullptr) { + ESP_LOGE(TAG, "Failed to allocate I2S event queue(s)"); + if (this->parent_->is_full_duplex()) { + this->parent_->release_full_duplex_tx_channel(this->tx_handle_); + } else { + i2s_del_channel(this->tx_handle_); + this->parent_->unlock(); + } + this->tx_handle_ = nullptr; + return ESP_ERR_NO_MEM; + } + + return ESP_OK; +} + +void I2SAudioSpeakerBase::stop_i2s_driver_() { + if (this->tx_handle_ != nullptr) { + if (this->parent_->is_full_duplex()) { + this->parent_->release_full_duplex_tx_channel(this->tx_handle_); + this->tx_handle_ = nullptr; + return; + } + + i2s_channel_disable(this->tx_handle_); + i2s_del_channel(this->tx_handle_); + this->tx_handle_ = nullptr; + + // i2s_del_channel() leaves dout wired to this port's data-out signal in the GPIO matrix: it only + // clears an internal reservation mask, never the esp_rom_gpio_connect_out_signal() routing that + // setup installed. If another speaker reuses this port (shared bus), its audio still reaches our + // dout. Detach the pin and drive it low so a stale output stops driving downstream hardware: a + // SPDIF optical transmitter would otherwise stay lit, and an analog DAC would emit noise. + gpio_reset_pin(this->dout_pin_); + gpio_set_direction(this->dout_pin_, GPIO_MODE_OUTPUT); + gpio_set_level(this->dout_pin_, 0); + } + this->parent_->unlock(); +} + +bool IRAM_ATTR I2SAudioSpeakerBase::i2s_on_sent_cb(i2s_chan_handle_t handle, i2s_event_data_t *event, void *user_ctx) { + int64_t now = esp_timer_get_time(); + + BaseType_t need_yield1 = pdFALSE; + BaseType_t need_yield2 = pdFALSE; + BaseType_t need_yield3 = pdFALSE; + + I2SAudioSpeakerBase *this_speaker = (I2SAudioSpeakerBase *) user_ctx; + + if (xQueueIsQueueFullFromISR(this_speaker->i2s_event_queue_)) { + // Queue is full, so discard the oldest event. Once we drop a completion event, ``i2s_event_queue_`` + // and any per-buffer record queue maintained by the task are permanently desynced, so the task + // must restart to recover. Set both ERR_DROPPED_EVENT (so loop() can log it) and COMMAND_STOP + // (so the task bails immediately, closing the race where loop() could clear the error bit + // before the task observes it). + int64_t dummy; + xQueueReceiveFromISR(this_speaker->i2s_event_queue_, &dummy, &need_yield1); + xEventGroupSetBitsFromISR(this_speaker->event_group_, + SpeakerEventGroupBits::ERR_DROPPED_EVENT | SpeakerEventGroupBits::COMMAND_STOP, + &need_yield2); + } + + xQueueSendToBackFromISR(this_speaker->i2s_event_queue_, &now, &need_yield3); + + return need_yield1 | need_yield2 | need_yield3; +} + +void I2SAudioSpeakerBase::apply_software_volume_(uint8_t *data, size_t bytes_read) { + if (this->q31_volume_factor_ == INT32_MAX) { + return; // Max volume, no processing needed + } + + const size_t bytes_per_sample = this->current_stream_info_.samples_to_bytes(1); + const uint32_t len = bytes_read / bytes_per_sample; + + esp_audio_libs::gain::apply(data, data, this->q31_volume_factor_, len, bytes_per_sample); +} + +void I2SAudioSpeakerBase::swap_esp32_mono_samples_(uint8_t *data, size_t bytes_read) { +#ifdef USE_ESP32_VARIANT_ESP32 + // For ESP32 16-bit mono mode, adjacent samples need to be swapped. + if (this->output_stream_info_.get_channels() == 1 && this->output_stream_info_.get_bits_per_sample() == 16) { + int16_t *samples = reinterpret_cast(data); + size_t sample_count = bytes_read / sizeof(int16_t); + for (size_t i = 0; i + 1 < sample_count; i += 2) { + int16_t tmp = samples[i]; + samples[i] = samples[i + 1]; + samples[i + 1] = tmp; + } + } +#endif // USE_ESP32_VARIANT_ESP32 +} + +} // namespace esphome::i2s_audio + +#endif // USE_ESP32 diff --git a/Integrations/ESPHome/components/i2s_audio/speaker/i2s_audio_speaker.h b/Integrations/ESPHome/components/i2s_audio/speaker/i2s_audio_speaker.h new file mode 100644 index 0000000..adb6ca5 --- /dev/null +++ b/Integrations/ESPHome/components/i2s_audio/speaker/i2s_audio_speaker.h @@ -0,0 +1,172 @@ +#pragma once + +#ifdef USE_ESP32 + +#include "../i2s_audio.h" + +#include +#include +#include + +#include "esphome/components/audio/audio.h" +#include "esphome/components/ring_buffer/ring_buffer.h" +#include "esphome/components/speaker/speaker.h" + +#include "esphome/core/component.h" +#include "esphome/core/gpio.h" +#include "esphome/core/helpers.h" + +namespace esphome::i2s_audio { + +// Shared constants used by both standard and SPDIF speaker implementations +static constexpr size_t TASK_STACK_SIZE = 4096; +static constexpr ssize_t TASK_PRIORITY = 19; + +enum SpeakerEventGroupBits : uint32_t { + COMMAND_START = (1 << 0), // indicates loop should start speaker task + COMMAND_STOP = (1 << 1), // stops the speaker task + COMMAND_STOP_GRACEFULLY = (1 << 2), // Stops the speaker task once all data has been written + + TASK_STARTING = (1 << 10), + TASK_RUNNING = (1 << 11), + TASK_STOPPING = (1 << 12), + TASK_STOPPED = (1 << 13), + + ERR_ESP_NO_MEM = (1 << 19), + + ERR_DROPPED_EVENT = (1 << 20), // ISR overflowed the event queue, dropping a completion event + ERR_PARTIAL_WRITE = (1 << 21), // i2s_channel_write returned fewer bytes than requested + ERR_LOCKSTEP_DESYNC = (1 << 22), // i2s_event_queue_ and write_records_queue_ fell out of sync + + ALL_BITS = 0x00FFFFFF, // All valid FreeRTOS event group bits +}; + +/// @brief Abstract base class for I2S audio speaker implementations. +/// Provides shared infrastructure (event groups, ring buffer, volume control, task lifecycle) +/// for derived standard I2S and SPDIF speaker classes. +class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public Component { + public: + float get_setup_priority() const override { return esphome::setup_priority::PROCESSOR; } + + void setup() override; + void dump_config() override; + void loop() override; + + void set_buffer_duration(uint32_t buffer_duration_ms) { this->buffer_duration_ms_ = buffer_duration_ms; } + void set_timeout(uint32_t ms) { this->timeout_ = ms; } + void set_dout_pin(uint8_t pin) { this->dout_pin_ = (gpio_num_t) pin; } + + /// @brief Get the I2S TX channel handle + i2s_chan_handle_t get_tx_handle() const { return this->tx_handle_; } + + void start() override; + void stop() override; + void finish() override; + + void set_pause_state(bool pause_state) override { this->pause_state_ = pause_state; } + bool get_pause_state() const override { return this->pause_state_; } + + /// @brief Plays the provided audio data. + /// Starts the speaker task, if necessary. Writes the audio data to the ring buffer. + /// @param data Audio data in the format set by the parent speaker classes ``set_audio_stream_info`` method. + /// @param length The length of the audio data in bytes. + /// @param ticks_to_wait The FreeRTOS ticks to wait before writing as much data as possible to the ring buffer. + /// @return The number of bytes that were actually written to the ring buffer. + size_t play(const uint8_t *data, size_t length, TickType_t ticks_to_wait) override; + size_t play(const uint8_t *data, size_t length) override { return play(data, length, 0); } + + bool has_buffered_data() const override; + + /// @brief Sets the volume of the speaker. Uses the speaker's configured audio dac component. If unavailble, it is + /// implemented as a software volume control. Overrides the default setter to convert the floating point volume to a + /// Q15 fixed-point factor. + /// @param volume between 0.0 and 1.0 + void set_volume(float volume) override; + + /// @brief Mutes or unmute the speaker. Uses the speaker's configured audio dac component. If unavailble, it is + /// implemented as a software volume control. Overrides the default setter to convert the floating point volume to a + /// Q15 fixed-point factor. + /// @param mute_state true for muting, false for unmuting + void set_mute_state(bool mute_state) override; + + protected: + /// @brief FreeRTOS task entry point. Casts params to I2SAudioSpeakerBase and calls run_speaker_task_(). + /// @param params I2SAudioSpeakerBase component pointer + static void speaker_task(void *params); + + /// @brief The main speaker task loop. Implemented by derived classes for mode-specific behavior. + virtual void run_speaker_task() = 0; + + /// @brief Sends a stop command to the speaker task via ``event_group_``. + /// @param wait_on_empty If false, sends the COMMAND_STOP signal. If true, sends the COMMAND_STOP_GRACEFULLY signal. + void stop_(bool wait_on_empty); + + /// @brief Callback function used to send playback timestamps to the speaker task. + /// @param handle (i2s_chan_handle_t) + /// @param event (i2s_event_data_t) + /// @param user_ctx (void*) User context pointer that the callback accesses + /// @return True if a higher priority task was interrupted + static bool i2s_on_sent_cb(i2s_chan_handle_t handle, i2s_event_data_t *event, void *user_ctx); + + /// @brief Starts the ESP32 I2S driver. Implemented by derived classes for mode-specific configuration. + /// @param audio_stream_info Stream information for the I2S driver. + /// @return ESP_OK if successful, or an error code + virtual esp_err_t start_i2s_driver(audio::AudioStreamInfo &audio_stream_info) = 0; + + /// @brief Shared I2S channel allocation, initialization, and event queue setup. + /// Called by derived start_i2s_driver_() implementations after building mode-specific configs. + /// @param chan_cfg I2S channel configuration + /// @param std_cfg I2S standard mode configuration (clock, slot, GPIO) + /// @param event_queue_size Size of the event queue + /// @return ESP_OK if successful, or an error code. On failure, cleans up channel and unlocks parent. + esp_err_t init_i2s_channel_(const i2s_chan_config_t &chan_cfg, const i2s_std_config_t &std_cfg, + size_t event_queue_size); + + /// @brief Stops the I2S driver and unlocks the I2S port + void stop_i2s_driver_(); + + /// @brief Called in loop() when the task has stopped. Override for mode-specific cleanup. + virtual void on_task_stopped() {} + + /// @brief Apply software volume control using Q15 fixed-point scaling. + /// @param data Pointer to audio sample data (modified in place) + /// @param bytes_read Number of bytes of audio data + void apply_software_volume_(uint8_t *data, size_t bytes_read); + + /// @brief Swap adjacent 16-bit mono samples for ESP32 (non-variant) hardware quirk. + /// Only applies when running on original ESP32 with 16-bit mono output. Operates on the data that is + /// handed to the I2S peripheral, so the check uses the output (post-narrowing) stream info. + /// @param data Pointer to audio sample data (modified in place) + /// @param bytes_read Number of bytes of audio data + void swap_esp32_mono_samples_(uint8_t *data, size_t bytes_read); + + TaskHandle_t speaker_task_handle_{nullptr}; + EventGroupHandle_t event_group_{nullptr}; + + // Lockstepped DMA buffer queues: i2s_event is outgoing, write_records is incoming + QueueHandle_t i2s_event_queue_{nullptr}; + QueueHandle_t write_records_queue_{nullptr}; + + std::weak_ptr audio_ring_buffer_; + + uint32_t buffer_duration_ms_; + + optional timeout_; + + bool pause_state_{false}; + + int32_t q31_volume_factor_{INT32_MAX}; + + audio::AudioStreamInfo current_stream_info_; // Format of the audio in the ring buffer (the I2S input) + // Format actually clocked out of the I2S peripheral. Same channel count and sample rate as + // current_stream_info_, but the bits per sample may be narrower when the incoming stream is wider than + // the speaker's configured slot bit width. Set by start_i2s_driver before the speaker task starts. + audio::AudioStreamInfo output_stream_info_; + + gpio_num_t dout_pin_; + i2s_chan_handle_t tx_handle_{nullptr}; +}; + +} // namespace esphome::i2s_audio + +#endif // USE_ESP32 diff --git a/Integrations/ESPHome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp b/Integrations/ESPHome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp new file mode 100644 index 0000000..a7993e6 --- /dev/null +++ b/Integrations/ESPHome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp @@ -0,0 +1,525 @@ +#include "i2s_audio_speaker_standard.h" + +#ifdef USE_ESP32 + +#include +#include + +#include "esphome/components/audio/audio.h" +#include "esphome/components/audio/audio_transfer_buffer.h" + +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +#include "esp_timer.h" + +// esp-audio-libs +#include + +namespace esphome::i2s_audio { + +static const char *const TAG = "i2s_audio.speaker.std"; + +static constexpr uint32_t DMA_BUFFER_DURATION_MS = 10; +static constexpr size_t DMA_BUFFERS_COUNT = 5; +// ESP-IDF clamps each DMA descriptor to this many bytes when allocating the channel (see i2s_get_buf_size in +// the I2S driver). Mirror its target-dependent selection so the requested dma_frame_num stays in range; the +// speaker task reads the size actually allocated back from the driver rather than relying on this value. +#if SOC_CACHE_INTERNAL_MEM_VIA_L1CACHE +static constexpr size_t I2S_DMA_BUFFER_MAX_SIZE = DMA_DESCRIPTOR_BUFFER_MAX_SIZE_64B_ALIGNED; +#else +static constexpr size_t I2S_DMA_BUFFER_MAX_SIZE = DMA_DESCRIPTOR_BUFFER_MAX_SIZE_4B_ALIGNED; +#endif +// Sized to comfortably absorb scheduling jitter: at most DMA_BUFFERS_COUNT events can be in flight, +// doubled so that a transient backlog never overruns the queue (which would desync the lockstep +// invariant between i2s_event_queue_ and write_records_queue_). +static constexpr size_t I2S_EVENT_QUEUE_COUNT = DMA_BUFFERS_COUNT * 2; +// Generous timeout for ``i2s_channel_write`` blocking. A buffer frees roughly every +// DMA_BUFFER_DURATION_MS, so a multiple of that gives plenty of slack against scheduling jitter +// without masking real failures. +static constexpr TickType_t WRITE_TIMEOUT_TICKS = pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS * (DMA_BUFFERS_COUNT + 1)); + +// Requested frames per DMA buffer for the given stream, clamped so the byte size stays within the ESP-IDF +// maximum DMA descriptor size. This is only the value handed to the channel config: ESP-IDF may still adjust +// it (e.g. cache-line rounding on some targets), so the speaker task reads the size actually allocated back +// from the driver instead of assuming this value. Clamping here keeps the request in range and avoids a +// noisy ESP-IDF "dma frame num is out of dma buffer size" warning at high sample rates or bit depths. +static uint32_t dma_buffer_frames(const audio::AudioStreamInfo &stream_info) { + const uint32_t frames_from_duration = stream_info.ms_to_frames(DMA_BUFFER_DURATION_MS); + const uint32_t max_frames = I2S_DMA_BUFFER_MAX_SIZE / stream_info.frames_to_bytes(1); + return std::min(frames_from_duration, max_frames); +} + +void I2SAudioSpeaker::dump_config() { + I2SAudioSpeakerBase::dump_config(); + const char *fmt_str; + switch (this->i2s_comm_fmt_) { + case I2SCommFmt::PCM: + fmt_str = "pcm"; + break; + case I2SCommFmt::MSB: + fmt_str = "msb"; + break; + default: + fmt_str = "std"; + break; + } + ESP_LOGCONFIG(TAG, " Communication format: %s", fmt_str); + if (this->slot_bit_width_ != I2S_SLOT_BIT_WIDTH_AUTO) { + // The width of each I2S slot. It is also the narrowing ceiling: streams wider than this are narrowed to + // it. A stream narrower than the slot is left at its own width and clocked into the wider slot, so this + // is not necessarily the sample data width (which depends on the incoming stream). + ESP_LOGCONFIG(TAG, " Slot bit width: %u", (unsigned) static_cast(this->slot_bit_width_)); + } +} + +void I2SAudioSpeaker::run_speaker_task() { + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_STARTING); + + const bool full_duplex = this->parent_->is_full_duplex(); + const uint32_t dma_buffers_duration_ms = DMA_BUFFER_DURATION_MS * DMA_BUFFERS_COUNT; + // Ensure ring buffer duration is at least the duration of all DMA buffers + const uint32_t ring_buffer_duration = std::max(dma_buffers_duration_ms, this->buffer_duration_ms_); + + // The ring buffer holds input-format audio (what play() receives), so size it from the input stream info. + const size_t bytes_per_frame = this->current_stream_info_.frames_to_bytes(1); + // Round the ring buffer size down to a multiple of bytes_per_frame so the wrap boundary stays frame-aligned and + // avoids unnecessary single-frame splices. + const size_t ring_buffer_size = + (this->current_stream_info_.ms_to_bytes(ring_buffer_duration) / bytes_per_frame) * bytes_per_frame; + + // Per-frame byte widths and whether the task must narrow the bit depth before writing to the I2S peripheral. + const uint8_t channels = this->current_stream_info_.get_channels(); + const uint8_t input_bytes_per_sample = this->current_stream_info_.get_bits_per_sample() / 8; + const uint8_t output_bytes_per_sample = this->output_stream_info_.get_bits_per_sample() / 8; + const bool narrowing = input_bytes_per_sample != output_bytes_per_sample; + + // ESP-IDF may allocate smaller (or cache-line-rounded) DMA buffers than dma_buffer_frames() requested: it + // clamps each descriptor to the max DMA descriptor size and, on targets that route internal memory through + // the L1 cache (e.g. ESP32-P4), rounds the buffer to the cache line. Read the size the driver actually + // allocated so preload, silence padding, and the write/event lockstep all match it exactly. The channel is + // in the READY state here because start_i2s_driver() initialized it before this task was created. + size_t dma_buffer_bytes = 0; + i2s_chan_info_t chan_info; + if (i2s_channel_get_info(this->tx_handle_, &chan_info) == ESP_OK && chan_info.total_dma_buf_size > 0) { + // total_dma_buf_size spans all descriptors and is an exact multiple of their count. In full duplex the + // microphone may have allocated the shared channel, so its descriptor count applies instead. + const size_t dma_desc_num = + full_duplex ? this->parent_->get_full_duplex_dma_desc_num() : static_cast(DMA_BUFFERS_COUNT); + if (dma_desc_num > 0) { + dma_buffer_bytes = chan_info.total_dma_buf_size / dma_desc_num; + } + } + if (dma_buffer_bytes == 0) { + // Should not happen for a READY channel; fall back to the requested size. + dma_buffer_bytes = this->output_stream_info_.frames_to_bytes(dma_buffer_frames(this->output_stream_info_)); + } + // dma_buffer_bytes counts output-format bytes; convert with the output stream info. + const uint32_t frames_per_dma_buffer = this->output_stream_info_.bytes_to_frames(dma_buffer_bytes); + // Soft cap for each source read: enough input-format bytes to fill one DMA buffer's worth of frames. + const size_t dma_buffer_input_bytes = this->current_stream_info_.frames_to_bytes(frames_per_dma_buffer); + + bool successful_setup = false; + + std::unique_ptr audio_source; + + // Pre-zeroed buffer used to silence-pad each DMA descriptor whenever real audio doesn't fully fill it. + RAMAllocator silence_allocator; + uint8_t *silence_buffer = silence_allocator.allocate(dma_buffer_bytes); + + if (silence_buffer != nullptr) { + memset(silence_buffer, 0, dma_buffer_bytes); + + std::shared_ptr temp_ring_buffer = ring_buffer::RingBuffer::create(ring_buffer_size); + audio_source = audio::RingBufferAudioSource::create(temp_ring_buffer, dma_buffer_input_bytes, + static_cast(bytes_per_frame)); + + if (audio_source != nullptr) { + // audio_source is nullptr if the ring buffer fails to allocate + this->audio_ring_buffer_ = temp_ring_buffer; + successful_setup = true; + } + } + + if (successful_setup && full_duplex) { + // The bus owns the shared TX channel and its on_sent callback, since TX may already be clocking the + // microphone. It seeds the write records to match wherever the DMA ring stands. + if (this->parent_->attach_full_duplex_speaker(this->i2s_event_queue_, this->write_records_queue_, + this->event_group_, + SpeakerEventGroupBits::ERR_DROPPED_EVENT | + SpeakerEventGroupBits::COMMAND_STOP, + silence_buffer, dma_buffer_bytes) != ESP_OK) { + ESP_LOGV(TAG, "Failed to join full duplex TX"); + successful_setup = false; + } + } else if (successful_setup) { + // Preload every DMA descriptor with silence and push a matching zero-real-frames record per buffer. + // This guarantees that every on_sent event has a corresponding write record from the start, so + // ``i2s_event_queue_`` and ``write_records_queue_`` stay in lockstep for the entire task lifetime. + for (size_t i = 0; i < DMA_BUFFERS_COUNT; i++) { + size_t bytes_loaded = 0; + esp_err_t err = i2s_channel_preload_data(this->tx_handle_, silence_buffer, dma_buffer_bytes, &bytes_loaded); + if (err != ESP_OK || bytes_loaded != dma_buffer_bytes) { + ESP_LOGV(TAG, "Failed to preload silence into DMA buffer %u (err=%d, loaded=%u)", (unsigned) i, (int) err, + (unsigned) bytes_loaded); + successful_setup = false; + break; + } + uint32_t zero_real_frames = 0; + if (xQueueSend(this->write_records_queue_, &zero_real_frames, 0) != pdTRUE) { + // Should never happen: the queue was just reset and is sized for DMA_BUFFERS_COUNT * 2 entries. + ESP_LOGV(TAG, "Failed to push preload write record"); + successful_setup = false; + break; + } + } + } + + if (successful_setup && !full_duplex) { + // Register the on_sent callback BEFORE enabling the channel so the very first transmitted buffer + // generates a queued event that pairs with the first preloaded silence record. + const i2s_event_callbacks_t callbacks = {.on_sent = i2s_on_sent_cb}; + i2s_channel_register_event_callback(this->tx_handle_, &callbacks, this); + + if (i2s_channel_enable(this->tx_handle_) != ESP_OK) { + ESP_LOGV(TAG, "Failed to enable I2S channel"); + successful_setup = false; + } + } + + if (!successful_setup) { + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_ESP_NO_MEM); + } else { + bool stop_gracefully = false; + // Number of records currently in ``write_records_queue_`` that carry real audio. Used by graceful + // stop to wait until every real-audio buffer has been confirmed played by an ISR event. + uint32_t pending_real_buffers = 0; + uint32_t last_data_received_time = millis(); + + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_RUNNING); + + // Main speaker task loop. Continues while: + // - Paused, OR + // - No timeout configured, OR + // - Timeout hasn't elapsed since last data + // + // Always-fill model: every iteration writes exactly one DMA buffer's worth, mixing real audio + // and silence padding as needed. The blocking ``i2s_channel_write`` paces the loop at the DMA + // consumption rate, and every buffer write is matched 1:1 with a record on ``write_records_queue_``. + // + // While paused, the real-audio fill is skipped and the entire DMA buffer is filled with silence; + // the same blocking ``i2s_channel_write`` provides natural pacing (one buffer per ~DMA_BUFFER_DURATION_MS), + // so the lockstep invariant is preserved without burning CPU. + while (this->pause_state_ || !this->timeout_.has_value() || + (millis() - last_data_received_time) <= this->timeout_.value()) { + uint32_t event_group_bits = xEventGroupGetBits(this->event_group_); + + if (event_group_bits & SpeakerEventGroupBits::COMMAND_STOP) { + // COMMAND_STOP is set both by user-initiated stop() and by the ISR when it drops a completion + // event (paired with ERR_DROPPED_EVENT so loop() can distinguish the two cases). + xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::COMMAND_STOP); + ESP_LOGV(TAG, "Exiting: COMMAND_STOP received"); + break; + } + if (event_group_bits & SpeakerEventGroupBits::COMMAND_STOP_GRACEFULLY) { + xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::COMMAND_STOP_GRACEFULLY); + stop_gracefully = true; + } + + if (this->audio_stream_info_ != this->current_stream_info_) { + // Audio stream info changed, stop the speaker task so it will restart with the proper settings. + ESP_LOGV(TAG, "Exiting: stream info changed"); + break; + } + + // Drain ISR-stamped completion events. Each event corresponds 1:1 with a write_records_queue_ + // entry by construction (preloaded records at startup, plus exactly one record pushed per + // iteration alongside exactly one DMA-buffer-sized write). + int64_t write_timestamp; + bool lockstep_broken = false; + while (xQueueReceive(this->i2s_event_queue_, &write_timestamp, 0)) { + uint32_t real_frames = 0; + if (xQueueReceive(this->write_records_queue_, &real_frames, 0) != pdTRUE) { + // Should never happen: would indicate the lockstep invariant is broken. + ESP_LOGV(TAG, "Event without matching write record"); + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_LOCKSTEP_DESYNC); + lockstep_broken = true; + break; + } + if (real_frames > 0) { + pending_real_buffers--; + // Real audio is packed at the start of each DMA buffer with any silence padding on the + // tail, so the real audio finished playing earlier than the buffer-completion timestamp + // by the duration of the trailing zeros. + const uint32_t silence_frames = frames_per_dma_buffer - real_frames; + const int64_t adjusted_ts = + write_timestamp - this->current_stream_info_.frames_to_microseconds(silence_frames); + this->audio_output_callback_(real_frames, adjusted_ts); + } + } + if (lockstep_broken) { + break; + } + + // Graceful stop: exit only after the source's exposed chunk is drained, the underlying ring + // buffer has nothing left to hand over, and every real-audio buffer we submitted has been + // confirmed played. ``has_buffered_data()`` returns bytes still sitting in the ring buffer + // awaiting fill(). + if (stop_gracefully && audio_source->available() == 0 && !this->has_buffered_data() && + pending_real_buffers == 0) { + ESP_LOGV(TAG, "Exiting: graceful stop complete"); + break; + } + + // Compose exactly one DMA buffer's worth: drain as much real audio as the source currently + // exposes (may take multiple fill() calls when crossing a ring buffer wrap), then pad any + // remainder with silence. All writes pack into the next free DMA descriptor in order, so the + // descriptor ends up holding [real audio][silence padding]. ``bytes_written_total`` counts + // output-format bytes so it tracks how full the DMA buffer is regardless of any narrowing. + size_t bytes_written_total = 0; + uint32_t real_frames_total = 0; + bool partial_write_failure = false; + + if (!this->pause_state_) { + while (bytes_written_total < dma_buffer_bytes) { + size_t bytes_read = audio_source->fill(pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS) / 2, false); + if (bytes_read > 0) { + // Apply volume at the input bit depth, before any narrowing, so the full precision is scaled. + uint8_t *new_data = audio_source->mutable_data() + audio_source->available() - bytes_read; + this->apply_software_volume_(new_data, bytes_read); + } + + // Convert as many whole frames as fit in the remaining DMA space, bounded by what the source + // currently exposes. Frame counts are shared between input and output; only the byte widths differ. + const uint32_t frames_available = this->current_stream_info_.bytes_to_frames(audio_source->available()); + const uint32_t frames_room = + this->output_stream_info_.bytes_to_frames(dma_buffer_bytes - bytes_written_total); + const uint32_t frames_to_write = std::min(frames_available, frames_room); + if (frames_to_write == 0) { + // Ring buffer has nothing more to hand over right now; pad the rest of this DMA buffer + // with silence so the lockstep invariant (one write per iteration) is preserved. + break; + } + + const size_t input_bytes = this->current_stream_info_.frames_to_bytes(frames_to_write); + const size_t output_bytes = this->output_stream_info_.frames_to_bytes(frames_to_write); + + uint8_t *chunk = audio_source->mutable_data(); + if (narrowing) { + // Narrow the bit depth in place: output exactly aliases input with the same channel count and a + // smaller width, which copy_frames handles as a single forward pass. Only the frames about to be + // consumed are overwritten, so any unprocessed tail stays intact for the next iteration. + esp_audio_libs::pcm_convert::copy_frames(chunk, chunk, input_bytes_per_sample, channels, + output_bytes_per_sample, channels, frames_to_write); + } + this->swap_esp32_mono_samples_(chunk, output_bytes); + + size_t bw = 0; + i2s_channel_write(this->tx_handle_, chunk, output_bytes, &bw, WRITE_TIMEOUT_TICKS); + if (bw != output_bytes) { + // A short real-audio write breaks DMA descriptor alignment for every subsequent event; + // the only safe recovery is to restart the task. + ESP_LOGV(TAG, "Partial real audio write: %u of %u bytes", (unsigned) bw, (unsigned) output_bytes); + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_PARTIAL_WRITE); + partial_write_failure = true; + break; + } + audio_source->consume(input_bytes); + bytes_written_total += output_bytes; + real_frames_total += frames_to_write; + } + if (real_frames_total > 0) { + last_data_received_time = millis(); + } + } + + if (partial_write_failure) { + break; + } + + const size_t silence_bytes = dma_buffer_bytes - bytes_written_total; + if (silence_bytes > 0) { + size_t bw = 0; + i2s_channel_write(this->tx_handle_, silence_buffer, silence_bytes, &bw, WRITE_TIMEOUT_TICKS); + if (bw != silence_bytes) { + // Same descriptor-alignment hazard as a partial real-audio write. + ESP_LOGV(TAG, "Partial silence write: %u of %u bytes", (unsigned) bw, (unsigned) silence_bytes); + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_PARTIAL_WRITE); + break; + } + } + + // Push the matching write record. Capacity headroom in I2S_EVENT_QUEUE_COUNT guarantees this + // succeeds even with a transient backlog of unprocessed events; if it ever fails the lockstep + // invariant is broken and every subsequent timestamp would be silently wrong, so bail. + if (xQueueSend(this->write_records_queue_, &real_frames_total, 0) != pdTRUE) { + ESP_LOGV(TAG, "Exiting: write records queue full"); + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_LOCKSTEP_DESYNC); + break; + } + if (real_frames_total > 0) { + pending_real_buffers++; + } + } + } + + if (full_duplex) { + // TX keeps running for the microphone; stop routing its events to this task + this->parent_->detach_full_duplex_tx_event_queue(this->i2s_event_queue_); + } + + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_STOPPING); + + audio_source.reset(); + + if (silence_buffer != nullptr) { + silence_allocator.deallocate(silence_buffer, dma_buffer_bytes); + silence_buffer = nullptr; + } + + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_STOPPED); + + while (true) { + // Continuously delay until the loop method deletes the task + vTaskDelay(pdMS_TO_TICKS(10)); + } +} + +esp_err_t I2SAudioSpeaker::start_i2s_driver(audio::AudioStreamInfo &audio_stream_info) { + this->current_stream_info_ = audio_stream_info; + + if ((this->i2s_role_ & I2S_ROLE_SLAVE) && (this->sample_rate_ != audio_stream_info.get_sample_rate())) { // NOLINT + // Can't reconfigure I2S bus, so the sample rate must match the configured value + ESP_LOGE(TAG, "Incompatible stream settings"); + return ESP_ERR_NOT_SUPPORTED; + } + + // When the stream is wider than the configured slot bit width, the speaker task narrows each frame in place + // before handing it to the I2S peripheral. Compute the output format here so the driver, DMA buffers, and + // the task's conversion all agree on the clocked-out width. A stream no wider than the slot width is passed + // through unchanged (the slot may still be wider than the data, the existing behavior). + uint8_t output_bits_per_sample = audio_stream_info.get_bits_per_sample(); + if (this->slot_bit_width_ != I2S_SLOT_BIT_WIDTH_AUTO) { + const uint8_t configured_bits = static_cast(this->slot_bit_width_); + if (output_bits_per_sample > configured_bits) { + output_bits_per_sample = configured_bits; + } + } + this->output_stream_info_ = audio::AudioStreamInfo(output_bits_per_sample, audio_stream_info.get_channels(), + audio_stream_info.get_sample_rate()); + +#ifdef USE_ESP32_VARIANT_ESP32 + // The original ESP32 I2S peripheral stores each sample in a whole number of 16-bit words (a 24-bit sample + // occupies 4 bytes in the DMA buffer, an 8-bit sample 2 bytes), but ESPHome's audio pipeline packs samples + // tightly (3 bytes for 24-bit, 1 for 8-bit). The two layouts only line up when the bit depth is a multiple + // of 16. The check is on the output width since that is what reaches the peripheral; a wider input is fine + // as long as it narrows to a 16- or 32-bit slot. + if (output_bits_per_sample % 16 != 0) { + ESP_LOGE(TAG, "ESP32 supports only 16- or 32-bit output, got %u-bit", (unsigned) output_bits_per_sample); + return ESP_ERR_NOT_SUPPORTED; + } +#endif // USE_ESP32_VARIANT_ESP32 + + // Full duplex shares the bus with the microphone instead of taking it exclusively + if (!this->parent_->is_full_duplex() && !this->parent_->try_lock()) { + ESP_LOGE(TAG, "Parent bus is busy"); + return ESP_ERR_INVALID_STATE; + } + + // The DMA buffers hold output-format (post-narrowing) samples, so size them from the output stream info. + uint32_t dma_buffer_length = dma_buffer_frames(this->output_stream_info_); + + i2s_role_t i2s_role = this->i2s_role_; + i2s_clock_src_t clk_src = I2S_CLK_SRC_DEFAULT; + +#if SOC_CLK_APLL_SUPPORTED + if (this->use_apll_) { + clk_src = i2s_clock_src_t::I2S_CLK_SRC_APLL; + } +#endif // SOC_CLK_APLL_SUPPORTED + + // Log DMA configuration for debugging + ESP_LOGV(TAG, "I2S DMA config: %zu buffers x %lu frames", (size_t) DMA_BUFFERS_COUNT, + (unsigned long) dma_buffer_length); + + i2s_chan_config_t chan_cfg = { + .id = this->parent_->get_port(), + .role = i2s_role, + .dma_desc_num = DMA_BUFFERS_COUNT, + .dma_frame_num = dma_buffer_length, + .auto_clear = true, + .intr_priority = 3, + }; + + // Build standard I2S clock/slot/gpio configuration + i2s_std_clk_config_t clk_cfg = { + .sample_rate_hz = audio_stream_info.get_sample_rate(), + .clk_src = clk_src, + .mclk_multiple = this->mclk_multiple_, + }; + + i2s_slot_mode_t slot_mode = this->slot_mode_; + i2s_std_slot_mask_t slot_mask = this->std_slot_mask_; + if (audio_stream_info.get_channels() == 1) { + slot_mode = I2S_SLOT_MODE_MONO; + } else if (audio_stream_info.get_channels() == 2) { + slot_mode = I2S_SLOT_MODE_STEREO; + slot_mask = I2S_STD_SLOT_BOTH; + } + + // Configure the data bit width from the output (post-narrowing) format, which is what is clocked out. + const i2s_data_bit_width_t data_bit_width = (i2s_data_bit_width_t) this->output_stream_info_.get_bits_per_sample(); + i2s_std_slot_config_t slot_cfg; + switch (this->i2s_comm_fmt_) { + case I2SCommFmt::PCM: + slot_cfg = I2S_STD_PCM_SLOT_DEFAULT_CONFIG(data_bit_width, slot_mode); + break; + case I2SCommFmt::MSB: + slot_cfg = I2S_STD_MSB_SLOT_DEFAULT_CONFIG(data_bit_width, slot_mode); + break; + default: + slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(data_bit_width, slot_mode); + break; + } + +#ifdef USE_ESP32_VARIANT_ESP32 + // There seems to be a bug on the ESP32 (non-variant) platform where setting the slot bit width higher than the + // bits per sample causes the audio to play too fast. Setting the ws_width to the configured slot bit width seems + // to make it play at the correct speed while sending more bits per slot. + if (this->slot_bit_width_ != I2S_SLOT_BIT_WIDTH_AUTO) { + uint32_t configured_bit_width = static_cast(this->slot_bit_width_); + slot_cfg.ws_width = configured_bit_width; + if (configured_bit_width > 16) { + slot_cfg.msb_right = false; + } + } +#else + slot_cfg.slot_bit_width = this->slot_bit_width_; + if (this->slot_bit_width_ != I2S_SLOT_BIT_WIDTH_AUTO) { + slot_cfg.ws_width = static_cast(this->slot_bit_width_); + } +#endif // USE_ESP32_VARIANT_ESP32 + slot_cfg.slot_mask = slot_mask; + + i2s_std_gpio_config_t gpio_cfg = this->parent_->get_pin_config(); + gpio_cfg.dout = this->dout_pin_; + + i2s_std_config_t std_cfg = { + .clk_cfg = clk_cfg, + .slot_cfg = slot_cfg, + .gpio_cfg = gpio_cfg, + }; + + esp_err_t err = this->init_i2s_channel_(chan_cfg, std_cfg, I2S_EVENT_QUEUE_COUNT); + if (err != ESP_OK) { + return err; + } + + // The speaker task will enable the channel after preloading. + + return ESP_OK; +} + +} // namespace esphome::i2s_audio + +#endif // USE_ESP32 diff --git a/Integrations/ESPHome/components/i2s_audio/speaker/i2s_audio_speaker_standard.h b/Integrations/ESPHome/components/i2s_audio/speaker/i2s_audio_speaker_standard.h new file mode 100644 index 0000000..4b52dcd --- /dev/null +++ b/Integrations/ESPHome/components/i2s_audio/speaker/i2s_audio_speaker_standard.h @@ -0,0 +1,32 @@ +#pragma once + +#ifdef USE_ESP32 + +#include "i2s_audio_speaker.h" + +namespace esphome::i2s_audio { + +enum class I2SCommFmt : uint8_t { + STANDARD, // Philips / I2S standard + PCM, // PCM short + MSB, // MSB / left-justified +}; + +/// @brief Standard I2S speaker implementation. +/// Outputs PCM audio data directly to an I2S DAC using the standard I2S protocol. +class I2SAudioSpeaker final : public I2SAudioSpeakerBase { + public: + void dump_config() override; + + void set_i2s_comm_fmt(I2SCommFmt fmt) { this->i2s_comm_fmt_ = fmt; } + + protected: + void run_speaker_task() override; + esp_err_t start_i2s_driver(audio::AudioStreamInfo &audio_stream_info) override; + + I2SCommFmt i2s_comm_fmt_{I2SCommFmt::STANDARD}; +}; + +} // namespace esphome::i2s_audio + +#endif // USE_ESP32 diff --git a/Integrations/ESPHome/components/i2s_audio/speaker/spdif_encoder.cpp b/Integrations/ESPHome/components/i2s_audio/speaker/spdif_encoder.cpp new file mode 100644 index 0000000..30146e0 --- /dev/null +++ b/Integrations/ESPHome/components/i2s_audio/speaker/spdif_encoder.cpp @@ -0,0 +1,492 @@ +#include "spdif_encoder.h" + +#if defined(USE_ESP32) && defined(USE_I2S_AUDIO_SPDIF_MODE) + +#include "esphome/core/log.h" + +namespace esphome::i2s_audio { + +static const char *const TAG = "i2s_audio.spdif_encoder"; + +// S/PDIF preamble patterns (8 BMC bits each) +// These are the BMC-encoded sync patterns that violate normal BMC rules for easy detection. +// All preambles end at phase HIGH (last bit = 1), enabling consistent data encoding. +// Preamble is placed at bits 24-31 of word[0] for MSB-first transmission. +static constexpr uint8_t PREAMBLE_B = 0x17; // Block start (left channel, frame 0) +static constexpr uint8_t PREAMBLE_M = 0x1d; // Left channel (not block start) +static constexpr uint8_t PREAMBLE_W = 0x1b; // Right channel + +// BMC encoding of 4 zero bits starting at phase HIGH: 00_11_00_11 = 0x33 +// Used as a constant in the 16-bit subframe path, where bits 4-11 are always zero. +static constexpr uint32_t BMC_ZERO_NIBBLE = 0x33; + +// Constexpr BMC encoder for compile-time LUT generation. +// Encodes with start phase=true (HIGH). The complement property allows phase=false +// via XOR: bmc_encode(v, N, false) == bmc_encode(v, N, true) ^ mask +static constexpr uint16_t bmc_lut_encode(uint32_t data, uint8_t num_bits) { + uint16_t bmc = 0; + bool phase = true; + for (uint8_t i = 0; i < num_bits; i++) { + bool bit = (data >> i) & 1; + uint8_t bmc_pair = phase ? (bit ? 0b01 : 0b00) : (bit ? 0b10 : 0b11); + bmc |= static_cast(bmc_pair) << ((num_bits - 1 - i) * 2); + if (!bit) + phase = !phase; + } + return bmc; +} + +// Compile-time parity helper (constexpr-friendly, runs only at LUT build time). +static constexpr uint32_t bmc_lut_parity(uint32_t value, uint32_t num_bits) { + uint32_t p = 0; + for (uint32_t b = 0; b < num_bits; b++) + p ^= (value >> b) & 1u; + return p; +} + +// Combined BMC + phase-delta lookup tables. +// Each entry packs the BMC pattern (lower bits, phase=high start) together with +// a phase-mask delta in bits 16-31 (0xFFFF if the input has odd parity, else 0). +// XORing the delta into the running phase mask propagates parity across chunks +// without an explicit popcount. + +// 4-bit BMC lookup table: 16 entries x uint32_t = 64 bytes in flash. +// Bits 0-7 : 8-bit BMC pattern (phase=high start) +// Bits 16-31 : phase-mask delta (0xFFFFu if odd parity, else 0) +static constexpr auto BMC_LUT_4 = [] { + std::array t{}; + for (uint32_t i = 0; i < 16; i++) { + uint32_t bmc = bmc_lut_encode(i, 4); + uint32_t delta = bmc_lut_parity(i, 4) ? 0xFFFF0000u : 0u; + t[i] = bmc | delta; + } + return t; +}(); + +// 8-bit BMC lookup table: 256 entries x uint32_t = 1024 bytes in flash. +// Bits 0-15 : 16-bit BMC pattern (phase=high start) +// Bits 16-31 : phase-mask delta (0xFFFFu if odd parity, else 0) +static constexpr auto BMC_LUT_8 = [] { + std::array t{}; + for (uint32_t i = 0; i < 256; i++) { + uint32_t bmc = bmc_lut_encode(i, 8); + uint32_t delta = bmc_lut_parity(i, 8) ? 0xFFFF0000u : 0u; + t[i] = bmc | delta; + } + return t; +}(); + +// Initialize S/PDIF buffer +bool SPDIFEncoder::setup() { + this->spdif_block_buf_ = std::make_unique(SPDIF_BLOCK_SIZE_U32); + if (!this->spdif_block_buf_) { + ESP_LOGE(TAG, "Buffer allocation failed (%zu bytes)", SPDIF_BLOCK_SIZE_BYTES); + return false; + } + ESP_LOGV(TAG, "Buffer allocated (%zu bytes)", SPDIF_BLOCK_SIZE_BYTES); + + // Build initial channel status block with default sample rate and width + this->build_channel_status_(); + + this->reset(); + return true; +} + +void SPDIFEncoder::reset() { + this->spdif_block_ptr_ = this->spdif_block_buf_.get(); + this->frame_in_block_ = 0; + this->block_buf_is_silence_block_ = false; +} + +void SPDIFEncoder::set_sample_rate(uint32_t sample_rate) { + if (this->sample_rate_ != sample_rate) { + this->sample_rate_ = sample_rate; + this->build_channel_status_(); + ESP_LOGD(TAG, "Sample rate set to %lu Hz", (unsigned long) sample_rate); + } +} + +void SPDIFEncoder::set_bytes_per_sample(uint8_t bytes_per_sample) { + if (bytes_per_sample != 2 && bytes_per_sample != 3 && bytes_per_sample != 4) { + ESP_LOGE(TAG, "Unsupported bytes per sample: %u", (unsigned) bytes_per_sample); + return; + } + if (this->bytes_per_sample_ != bytes_per_sample) { + this->bytes_per_sample_ = bytes_per_sample; + this->build_channel_status_(); + // Discard any partial block built at the previous width so we never mix widths on the wire. + this->reset(); + ESP_LOGD(TAG, "Input width set to %u-bit", (unsigned) bytes_per_sample * 8); + } +} + +void SPDIFEncoder::build_channel_status_() { + // IEC 60958-3 Consumer Channel Status Block (192 bits = 24 bytes) + // Transmitted LSB-first within each byte, one bit per frame via C bit. + + // Any cached silence block was built for the previous channel status; it is now stale. + this->block_buf_is_silence_block_ = false; + + this->channel_status_.fill(0); + + // Byte 0: Consumer, PCM audio, no copyright, no pre-emphasis, Mode 0 + // All bits are 0, which is already set + + // Byte 1: Category code = 0x00 (general) + // Already 0 + + // Byte 2: Source/channel unspecified + // Already 0 + + // Byte 3: Sample frequency code (bits 0-3) + clock accuracy (bits 4-5) + // Clock accuracy = 00 (Level II, ±1000 ppm) - appropriate for ESP32 + uint8_t freq_code; + switch (this->sample_rate_) { + case 44100: + freq_code = 0x0; // 0000 + break; + case 48000: + freq_code = 0x2; // 0010 + break; + default: + // Other values are possible but they're not supported by ESPHome + freq_code = 0x1; // 0001 = not indicated + ESP_LOGW(TAG, "Unsupported sample rate %lu Hz, channel status will indicate 'not specified'", + (unsigned long) this->sample_rate_); + break; + } + // Byte 3: freq_code in bits 0-3, clock accuracy (00) in bits 4-5 + this->channel_status_[3] = freq_code; // Clock accuracy bits 4-5 are already 0 + + // Byte 4: Word length encoding (IEC 60958-3 consumer) + // bit 0: max length flag (0 = max 20 bits, 1 = max 24 bits) + // bits 1-3: word length code relative to the max + // For our supported widths: + // 16-bit (max 20): 0b0010 = 0x02 -- "16 bits, max 20" + // 24-bit (max 24): 0b1101 = 0x0D -- "24 bits, max 24" + // 32-bit input is truncated to 24-bit on the wire, so use the 24-bit code. + uint8_t word_length_code; + switch (this->bytes_per_sample_) { + case 2: + word_length_code = 0x02; + break; + case 3: // Shared case + case 4: + word_length_code = 0x0D; + break; + default: + word_length_code = 0x00; // not specified + break; + } + this->channel_status_[4] = word_length_code; +} + +// Extract the C bit for the given frame from channel_status_ and shift it into bit 30 +// so it can be OR'd directly into a raw subframe. +ESPHOME_ALWAYS_INLINE static inline uint32_t c_bit_for_frame(const std::array &channel_status, + uint32_t frame) { + return static_cast((channel_status[frame >> 3] >> (frame & 7)) & 1u) << 30; +} + +// ============================================================================ +// IEC 60958 subframe bit layout +// ============================================================================ +// Bits 0-3: Preamble (handled separately, not in raw_subframe) +// Bits 4-7: Auxiliary audio data / 24-bit audio LSB +// Bits 8-11: Audio LSB extension (zero for 16-bit, low nibble of audio for 24-bit) +// Bits 12-27: Audio sample (16 high bits in 16-bit mode, mid 16 bits in 24-bit mode) +// Bit 28: V (Validity) - 0 = valid audio +// Bit 29: U (User data) - 0 +// Bit 30: C (Channel status) - from channel status block +// Bit 31: P (Parity) - even parity over bits 4-31 +// ============================================================================ + +// Build a raw IEC 60958 subframe from PCM little-endian input of width Bps bytes. +// Caller is responsible for OR-ing in the C bit and parity. +template ESPHOME_ALWAYS_INLINE static inline uint32_t build_raw_subframe(const uint8_t *pcm_sample) { + static_assert(Bps == 2 || Bps == 3 || Bps == 4, "Unsupported bytes per sample"); + if constexpr (Bps == 2) { + // 16-bit input: MSB-aligned in the 20-bit audio field, bits 12-27. + return (static_cast(pcm_sample[1]) << 20) | (static_cast(pcm_sample[0]) << 12); + } else if constexpr (Bps == 3) { + // 24-bit input: full 24-bit audio field, bits 4-27. + return (static_cast(pcm_sample[2]) << 20) | (static_cast(pcm_sample[1]) << 12) | + (static_cast(pcm_sample[0]) << 4); + } else { // Bps == 4 + // 32-bit input truncated to 24-bit: drop the lowest byte. + return (static_cast(pcm_sample[3]) << 20) | (static_cast(pcm_sample[2]) << 12) | + (static_cast(pcm_sample[1]) << 4); + } +} + +// BMC-encode a subframe and write the two output uint32 words to dst. Caller passes +// raw_subframe with the C bit set (bit 30) and the P bit cleared (bit 31 = 0). P is +// derived from the cumulative parity-mask delta of the per-byte LUT lookups. +// +// I2S halfword swap means word[0] transmits as: bits 24-31, 16-23, 8-15, 0-7. +// word[1] transmits as: bits 16-31, 0-15. Within each halfword, MSB-first. +// All preambles end at phase HIGH, so phase=true at the start of bit 4. +// +// P-bit derivation: BMC_LUT_*'s upper half encodes the parity of the input chunk. Each +// chunk's parity delta is shifted down (`lut >> 16`) into a phase_mask that lives in the +// low 16 bits, so the same value can also be XORed against subsequent BMC patterns to +// invert phase. XOR'ing those deltas through all chunks (with bit 31 = 0) yields the +// parity of bits 4-30 in the low bits of phase_mask -- the required value of the P bit +// for even total parity. The BMC of bit 31 lives in bit 0 of the high-byte BMC output +// (i = 7 maps to position (8-1-7)*2 = 0); flipping the source bit flips only the lower +// BMC bit (= phase XOR bit), so applying P is `bmc_24_31 ^= phase_mask & 1u`. +template +ESPHOME_ALWAYS_INLINE static inline void bmc_encode_subframe(uint32_t raw_subframe, uint8_t preamble, uint32_t *dst) { + if constexpr (Bps == 2) { + // 16-bit path: bits 4-11 are zero, encoded inline as BMC_ZERO_NIBBLE constants. + // Eight zero source bits with start phase=HIGH end at phase=HIGH (popcount of zeros is even), + // so encoding of bits 12-15 starts at phase=true. Zeros contribute 0 to parity. + uint32_t nibble = (raw_subframe >> 12) & 0xF; + uint32_t lut_n = BMC_LUT_4[nibble]; + uint32_t bmc_12_15 = lut_n & 0xFFu; + uint32_t phase_mask = lut_n >> 16; // 0xFFFFu if odd parity, else 0 + + uint32_t byte_mid = (raw_subframe >> 16) & 0xFF; + uint32_t lut_m = BMC_LUT_8[byte_mid]; + uint32_t bmc_16_23 = (lut_m & 0xFFFFu) ^ phase_mask; + phase_mask ^= lut_m >> 16; + + uint32_t byte_hi = (raw_subframe >> 24) & 0xFF; // bit 7 (= P) is 0 by precondition + uint32_t lut_h = BMC_LUT_8[byte_hi]; + uint32_t bmc_24_31 = (lut_h & 0xFFFFu) ^ phase_mask; + phase_mask ^= lut_h >> 16; + // phase_mask now reflects parity of bits 4-30. Apply P by flipping bit 0 of bmc_24_31. + bmc_24_31 ^= phase_mask & 1u; + + dst[0] = bmc_12_15 | (BMC_ZERO_NIBBLE << 8) | (BMC_ZERO_NIBBLE << 16) | (static_cast(preamble) << 24); + dst[1] = bmc_24_31 | (bmc_16_23 << 16); + } else { + // 24-bit (and 32-bit truncated) path: bits 4-11 are live audio. + uint32_t byte_lo = (raw_subframe >> 4) & 0xFF; + uint32_t lut_l = BMC_LUT_8[byte_lo]; + uint32_t bmc_4_11 = lut_l & 0xFFFFu; + uint32_t phase_mask = lut_l >> 16; // 0xFFFFu if odd parity, else 0 + + uint32_t nibble = (raw_subframe >> 12) & 0xF; + uint32_t lut_n = BMC_LUT_4[nibble]; + uint32_t bmc_12_15 = (lut_n & 0xFFu) ^ (phase_mask & 0xFFu); + phase_mask ^= lut_n >> 16; + + uint32_t byte_mid = (raw_subframe >> 16) & 0xFF; + uint32_t lut_m = BMC_LUT_8[byte_mid]; + uint32_t bmc_16_23 = (lut_m & 0xFFFFu) ^ phase_mask; + phase_mask ^= lut_m >> 16; + + uint32_t byte_hi = (raw_subframe >> 24) & 0xFF; // bit 7 (= P) is 0 by precondition + uint32_t lut_h = BMC_LUT_8[byte_hi]; + uint32_t bmc_24_31 = (lut_h & 0xFFFFu) ^ phase_mask; + phase_mask ^= lut_h >> 16; + bmc_24_31 ^= phase_mask & 1u; + + // word[0]: bits 24-31 = preamble, bits 8-23 = bmc(4-11), bits 0-7 = bmc(12-15) + // word[1]: bits 16-31 = bmc(16-23), bits 0-15 = bmc(24-31) + dst[0] = bmc_12_15 | (bmc_4_11 << 8) | (static_cast(preamble) << 24); + dst[1] = bmc_24_31 | (bmc_16_23 << 16); + } +} + +template void SPDIFEncoder::encode_silence_frame_() { + static constexpr uint8_t SILENCE[4] = {0, 0, 0, 0}; + uint32_t raw = build_raw_subframe(SILENCE) | c_bit_for_frame(this->channel_status_, this->frame_in_block_); + uint8_t preamble_l = (this->frame_in_block_ == 0) ? PREAMBLE_B : PREAMBLE_M; + bmc_encode_subframe(raw, preamble_l, this->spdif_block_ptr_); + bmc_encode_subframe(raw, PREAMBLE_W, this->spdif_block_ptr_ + 2); + this->spdif_block_ptr_ += 4; + if (++this->frame_in_block_ >= SPDIF_BLOCK_SAMPLES) { + this->frame_in_block_ = 0; + } +} + +esp_err_t SPDIFEncoder::send_block_(TickType_t ticks_to_wait) { + // Use the appropriate callback and context based on preload mode + SPDIFBlockCallback callback; + void *ctx; + + if (this->preload_mode_) { + callback = this->preload_callback_; + ctx = this->preload_callback_ctx_; + } else { + callback = this->write_callback_; + ctx = this->write_callback_ctx_; + } + + if (callback == nullptr) { + return ESP_ERR_INVALID_STATE; + } + + esp_err_t err = callback(ctx, this->spdif_block_buf_.get(), SPDIF_BLOCK_SIZE_BYTES, ticks_to_wait); + + if (err == ESP_OK) { + // Reset pointer for next block; position tracking continues from where it left off + this->spdif_block_ptr_ = this->spdif_block_buf_.get(); + } + + return err; +} + +template +HOT esp_err_t SPDIFEncoder::write_typed_(const uint8_t *src, size_t size, TickType_t ticks_to_wait, + uint32_t *blocks_sent, size_t *bytes_consumed) { + const uint8_t *pcm_data = src; + const uint8_t *const pcm_end = src + size; + uint32_t block_count = 0; + + // Hot state lives in locals so the compiler can keep it in registers across the + // per-frame encoding work; byte writes through block_ptr may alias the member fields, + // which would block register allocation if the encoding read them directly from this->*. + uint32_t *block_ptr = this->spdif_block_ptr_; + uint32_t *const block_buf = this->spdif_block_buf_.get(); + uint32_t *const block_end = block_buf + SPDIF_BLOCK_SIZE_U32; + uint32_t frame = this->frame_in_block_; + const std::array &channel_status = this->channel_status_; + + auto save_state = [&]() { + this->spdif_block_ptr_ = block_ptr; + this->frame_in_block_ = static_cast(frame); + }; + + auto report_out_params = [&]() { + if (blocks_sent != nullptr) + *blocks_sent = block_count; + if (bytes_consumed != nullptr) + *bytes_consumed = pcm_data - src; + }; + + // Send a completed block if the buffer is full, propagating any error. + // send_block_ resets this->spdif_block_ptr_ to block_buf on success and leaves it + // unchanged on error -- mirror both behaviors in our local block_ptr. + auto maybe_send = [&]() -> esp_err_t { + if (block_ptr >= block_end) { + esp_err_t err = this->send_block_(ticks_to_wait); + if (err != ESP_OK) { + save_state(); + report_out_params(); + return err; + } + block_ptr = block_buf; + ++block_count; + } + return ESP_OK; + }; + + // Hot path: encode L+R pairs in two peeled sub-loops. Frame 0 carries the only + // buffer-full check and uses PREAMBLE_B (a block fills exactly when frame wraps from + // 191 back to 0). Frames 1..191 use PREAMBLE_M and need no buffer-full check or + // preamble branch. The encoding body is inlined here so block_ptr lives in a register + // for the duration of the loop. + while (pcm_data + 2 * Bps <= pcm_end) { + if (frame == 0) { + esp_err_t err = maybe_send(); + if (err != ESP_OK) + return err; + + uint32_t c_bit = c_bit_for_frame(channel_status, 0); + uint32_t raw_l = build_raw_subframe(pcm_data) | c_bit; + uint32_t raw_r = build_raw_subframe(pcm_data + Bps) | c_bit; + bmc_encode_subframe(raw_l, PREAMBLE_B, block_ptr); + bmc_encode_subframe(raw_r, PREAMBLE_W, block_ptr + 2); + block_ptr += 4; + frame = 1; + pcm_data += 2 * Bps; + } + + // The inner loop runs until min(SPDIF_BLOCK_SAMPLES, frame + input_frames). The + // input-size bound is folded into end_frame so a single `frame < end_frame` test + // governs termination. + uint32_t input_frames = static_cast(pcm_end - pcm_data) / (2u * Bps); + uint32_t end_frame = SPDIF_BLOCK_SAMPLES; + if (frame + input_frames < end_frame) + end_frame = frame + input_frames; + + while (frame < end_frame) { + uint32_t c_bit = c_bit_for_frame(channel_status, frame); + uint32_t raw_l = build_raw_subframe(pcm_data) | c_bit; + uint32_t raw_r = build_raw_subframe(pcm_data + Bps) | c_bit; + bmc_encode_subframe(raw_l, PREAMBLE_M, block_ptr); + bmc_encode_subframe(raw_r, PREAMBLE_W, block_ptr + 2); + block_ptr += 4; + ++frame; + pcm_data += 2 * Bps; + } + if (frame >= SPDIF_BLOCK_SAMPLES) + frame = 0; + } + + // Send any complete block that was just finished. + if (block_ptr >= block_end) { + esp_err_t err = this->send_block_(ticks_to_wait); + if (err != ESP_OK) { + save_state(); + report_out_params(); + return err; + } + block_ptr = block_buf; + ++block_count; + } + + save_state(); + report_out_params(); + return ESP_OK; +} + +HOT esp_err_t SPDIFEncoder::write(const uint8_t *src, size_t size, TickType_t ticks_to_wait, uint32_t *blocks_sent, + size_t *bytes_consumed) { + if (size > 0) { + // Real PCM is about to be encoded into the buffer, so it is no longer a full-silence block. + this->block_buf_is_silence_block_ = false; + } + switch (this->bytes_per_sample_) { + case 2: + return this->write_typed_<2>(src, size, ticks_to_wait, blocks_sent, bytes_consumed); + case 3: + return this->write_typed_<3>(src, size, ticks_to_wait, blocks_sent, bytes_consumed); + case 4: + return this->write_typed_<4>(src, size, ticks_to_wait, blocks_sent, bytes_consumed); + default: + return ESP_ERR_INVALID_STATE; + } +} + +template esp_err_t SPDIFEncoder::flush_with_silence_typed_(TickType_t ticks_to_wait) { + // If a complete block is already pending (from a previous failed send), emit just that block. + // Otherwise pad the partial block with silence (or generate a full silence block if empty) and + // send. Always emits exactly one block on success. + if (this->spdif_block_ptr_ < &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) { + const bool was_empty = (this->spdif_block_ptr_ == this->spdif_block_buf_.get()); + // Continuous-silence idle case: a full silence block is byte-identical every time for the + // active channel status, so when the buffer already holds one, re-send it as-is. + if (was_empty && this->block_buf_is_silence_block_) { + return this->send_block_(ticks_to_wait); + } + // Pad with silence frames at the configured width. + while (this->spdif_block_ptr_ < &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) { + this->encode_silence_frame_(); + } + // The buffer is a reusable full-silence block only if it was built entirely from silence; a + // partial real-audio block padded out with silence is not. + this->block_buf_is_silence_block_ = was_empty; + } + return this->send_block_(ticks_to_wait); +} + +esp_err_t SPDIFEncoder::flush_with_silence(TickType_t ticks_to_wait) { + switch (this->bytes_per_sample_) { + case 2: + return this->flush_with_silence_typed_<2>(ticks_to_wait); + case 3: + return this->flush_with_silence_typed_<3>(ticks_to_wait); + case 4: + return this->flush_with_silence_typed_<4>(ticks_to_wait); + default: + return ESP_ERR_INVALID_STATE; + } +} + +} // namespace esphome::i2s_audio + +#endif // USE_I2S_AUDIO_SPDIF_MODE diff --git a/Integrations/ESPHome/components/i2s_audio/speaker/spdif_encoder.h b/Integrations/ESPHome/components/i2s_audio/speaker/spdif_encoder.h new file mode 100644 index 0000000..9e23a85 --- /dev/null +++ b/Integrations/ESPHome/components/i2s_audio/speaker/spdif_encoder.h @@ -0,0 +1,148 @@ +#pragma once + +#include "esphome/core/defines.h" + +#if defined(USE_ESP32) && defined(USE_I2S_AUDIO_SPDIF_MODE) + +#include +#include +#include +#include +#include "esp_err.h" +#include "esphome/core/helpers.h" + +namespace esphome::i2s_audio { + +// A SPDIF sample is 64-bits +static constexpr uint8_t SPDIF_BITS_PER_SAMPLE = 64; +// Number of samples in a SPDIF block +static constexpr uint16_t SPDIF_BLOCK_SAMPLES = 192; +// To emulate bi-phase mark code (BMC) (aka differential Manchester encoding) we send twice +// as many bits per sample so that we can generate the transitions this encoding requires. +static constexpr uint8_t EMULATED_BMC_BITS_PER_SAMPLE = SPDIF_BITS_PER_SAMPLE * 2; +static constexpr uint16_t SPDIF_BLOCK_SIZE_BYTES = SPDIF_BLOCK_SAMPLES * (EMULATED_BMC_BITS_PER_SAMPLE / 8); +static constexpr uint32_t SPDIF_BLOCK_SIZE_U32 = SPDIF_BLOCK_SIZE_BYTES / sizeof(uint32_t); // 3072 bytes / 4 = 768 +// I2S frame count for one SPDIF block (for new driver where frame = 8 bytes for 32-bit stereo) +static constexpr uint32_t SPDIF_BLOCK_I2S_FRAMES = SPDIF_BLOCK_SIZE_BYTES / 8; // 3072 / 8 = 384 frames + +/// Callback signature for block completion (raw function pointer for minimal overhead) +/// @param user_ctx User context pointer passed during callback registration +/// @param data Pointer to SPDIF encoded block data +/// @param size Size of the block in bytes (always SPDIF_BLOCK_SIZE_BYTES) +/// @param ticks_to_wait FreeRTOS ticks to wait for write completion +/// @return ESP_OK on success, or an error code +using SPDIFBlockCallback = esp_err_t (*)(void *user_ctx, uint32_t *data, size_t size, TickType_t ticks_to_wait); + +class SPDIFEncoder { + public: + /// @brief Initialize the SPDIF working buffer + /// @return true if setup was successful, false if allocation failed + bool setup(); + + /// @brief Set callback for normal writes (used when channel is running) + /// @param callback Function pointer to call when a block is ready + /// @param user_ctx Context pointer passed to callback (typically 'this' pointer of speaker) + void set_write_callback(SPDIFBlockCallback callback, void *user_ctx) { + this->write_callback_ = callback; + this->write_callback_ctx_ = user_ctx; + } + + /// @brief Set callback for preload writes (used when preloading to DMA before enabling channel) + /// @param callback Function pointer to call when a block is ready for preload + /// @param user_ctx Context pointer passed to callback (typically 'this' pointer of speaker) + void set_preload_callback(SPDIFBlockCallback callback, void *user_ctx) { + this->preload_callback_ = callback; + this->preload_callback_ctx_ = user_ctx; + } + + /// @brief Enable or disable preload mode + /// When in preload mode, completed blocks use the preload callback instead of write callback + void set_preload_mode(bool preload) { this->preload_mode_ = preload; } + + /// @brief Check if currently in preload mode + bool is_preload_mode() const { return this->preload_mode_; } + + /// @brief Set input PCM width: 2 = 16-bit, 3 = 24-bit, 4 = 32-bit (truncated to 24-bit on the wire). + /// Must be called before write() if input width changes from the default (16-bit). Triggers a + /// channel-status rebuild to reflect the new word length. + void set_bytes_per_sample(uint8_t bytes_per_sample); + + /// @brief Get the configured input PCM width in bytes per sample + uint8_t get_bytes_per_sample() const { return this->bytes_per_sample_; } + + /// @brief Convert PCM audio data to SPDIF BMC encoded data + /// @param src Source PCM audio data (stereo, width matches set_bytes_per_sample) + /// @param size Size of source data in bytes + /// @param ticks_to_wait Timeout for blocking writes + /// @param blocks_sent Optional pointer to receive the number of complete SPDIF blocks sent + /// @param bytes_consumed Optional pointer to receive the number of PCM bytes consumed from src + /// @return esp_err_t as returned from the callback + esp_err_t write(const uint8_t *src, size_t size, TickType_t ticks_to_wait, uint32_t *blocks_sent = nullptr, + size_t *bytes_consumed = nullptr); + + /// @brief Emit one complete SPDIF block: pad any pending partial block with silence and send, + /// or send a full silence block if nothing is pending. Always produces exactly one block on success. + /// @param ticks_to_wait Timeout for blocking writes + /// @return esp_err_t as returned from the callback + esp_err_t flush_with_silence(TickType_t ticks_to_wait); + + /// @brief Reset the SPDIF block buffer and position tracking, discarding any partial block + void reset(); + + /// @brief Set the sample rate for Channel Status Block encoding + /// @param sample_rate Sample rate in Hz (e.g., 44100, 48000) + /// Call this before writing audio data to ensure correct channel status. + void set_sample_rate(uint32_t sample_rate); + + /// @brief Get the currently configured sample rate + uint32_t get_sample_rate() const { return this->sample_rate_; } + + protected: + /// @brief Encode a single stereo silence frame at the current block position. + /// @note Used only by flush_with_silence_typed_ to pad; the hot write path inlines the + /// encoding body directly into write_typed_ to keep block_ptr / frame_in_block_ in registers. + template void encode_silence_frame_(); + + /// @brief Templated write loop. Called from the public write() via runtime dispatch on bytes_per_sample_. + template + HOT esp_err_t write_typed_(const uint8_t *src, size_t size, TickType_t ticks_to_wait, uint32_t *blocks_sent, + size_t *bytes_consumed); + + /// @brief Templated flush-with-silence. Pads the pending block with zeros at the configured width + /// (or builds a full silence block when nothing is pending) and sends it. Always emits one block. + template esp_err_t flush_with_silence_typed_(TickType_t ticks_to_wait); + + /// @brief Send the completed block via the appropriate callback + esp_err_t send_block_(TickType_t ticks_to_wait); + + /// @brief Build the channel status block from current configuration + void build_channel_status_(); + + // Member ordering optimized to minimize padding (largest alignment first) + + // 4-byte aligned members (pointers and uint32_t) + SPDIFBlockCallback write_callback_{nullptr}; + SPDIFBlockCallback preload_callback_{nullptr}; + void *write_callback_ctx_{nullptr}; + void *preload_callback_ctx_{nullptr}; + std::unique_ptr spdif_block_buf_; // Working buffer for SPDIF block (heap allocated) + uint32_t *spdif_block_ptr_{nullptr}; // Current position in block buffer + uint32_t sample_rate_{48000}; // Sample rate for Channel Status Block encoding + + // 1-byte aligned members (grouped together to avoid internal padding) + uint8_t bytes_per_sample_{2}; // Input PCM width: 2/3/4 (16/24/32-bit). 32-bit truncates to 24-bit on the wire. + uint8_t frame_in_block_{0}; // 0-191, tracks stereo frame position within block + bool preload_mode_{false}; // Whether to use preload callback vs write callback + // True when spdif_block_buf_ currently holds a complete full-silence block valid for the active + // channel status. A full silence block is deterministic for a given sample rate and word length, + // so when this is set flush_with_silence() can re-send the buffer verbatim instead of re-encoding. + bool block_buf_is_silence_block_{false}; + + // Channel Status Block (192 bits = 24 bytes, transmitted over 192 frames) + // Placed last since std::array has 1-byte alignment + std::array channel_status_{}; +}; + +} // namespace esphome::i2s_audio + +#endif // USE_I2S_AUDIO_SPDIF_MODE diff --git a/Integrations/ESPHome/wizmote.yaml b/Integrations/ESPHome/wizmote.yaml new file mode 100644 index 0000000..37baa50 --- /dev/null +++ b/Integrations/ESPHome/wizmote.yaml @@ -0,0 +1,491 @@ +# WizMote remote (ESP-NOW); included by Core.yaml + +substitutions: + WIZMOTE_BUTTON_ON: "1" + WIZMOTE_BUTTON_OFF: "2" + WIZMOTE_BUTTON_NIGHT: "3" + WIZMOTE_BUTTON_BRIGHT_UP: "9" + WIZMOTE_BUTTON_BRIGHT_DOWN: "8" + WIZMOTE_BUTTON_1: "16" + WIZMOTE_BUTTON_2: "17" + WIZMOTE_BUTTON_3: "18" + WIZMOTE_BUTTON_4: "19" + WIZMOTE_MAC_ADDRESS: "00:00:00:00:00:00" + +globals: + - id: wizmote_last_sequence + type: uint32_t + restore_value: no + initial_value: '0' + - id: wizmote_previous_mac + type: std::string + restore_value: no + initial_value: '""' + - id: wizmote_last_packet_ms + type: uint32_t + restore_value: no + initial_value: '0' + - id: wizmote_scan_channel + type: uint8_t + restore_value: no + initial_value: '1' + +button: + - platform: template + name: "Clear WizMote Pairing" + id: wizmote_clear_button + icon: "mdi:close-circle" + entity_category: config + on_press: + - lambda: |- + std::string mac = id(wizmote_mac_address).state; + if (mac != "00:00:00:00:00:00" && mac != "" && mac.length() == 17) { + std::array mac_bytes; + int parsed = sscanf(mac.c_str(), "%hhx:%hhx:%hhx:%hhx:%hhx:%hhx", + &mac_bytes[0], &mac_bytes[1], &mac_bytes[2], + &mac_bytes[3], &mac_bytes[4], &mac_bytes[5]); + if (parsed == 6) { + id(espnow_component).del_peer(mac_bytes.data()); + } + } + id(wizmote_mac_address).publish_state("00:00:00:00:00:00"); + - lambda: 'id(wizmote_status).update();' + +switch: + - platform: template + name: "WizMote Auto-Discovery" + id: wizmote_discovery_mode + icon: "mdi:radar" + entity_category: config + optimistic: true + restore_mode: ALWAYS_OFF + on_turn_on: + - lambda: 'id(wizmote_status).update();' + on_turn_off: + - lambda: 'id(wizmote_status).update();' + +# Remap buttons from HA; no YAML edits +select: + - platform: template + name: "WizMote On" + id: wizmote_action_on + icon: "mdi:gesture-tap-button" + entity_category: config + optimistic: true + restore_value: true + initial_option: "Play" + options: &wizmote_actions + - "Nothing" + - "Play" + - "Pause" + - "Play / Pause" + - "Next Track" + - "Previous Track" + - "Volume Up" + - "Volume Down" + - "Toggle Light" + - "Send HA Event" + - platform: template + name: "WizMote Off" + id: wizmote_action_off + icon: "mdi:gesture-tap-button" + entity_category: config + optimistic: true + restore_value: true + initial_option: "Pause" + options: *wizmote_actions + - platform: template + name: "WizMote Night" + id: wizmote_action_night + icon: "mdi:gesture-tap-button" + entity_category: config + optimistic: true + restore_value: true + initial_option: "Toggle Light" + options: *wizmote_actions + - platform: template + name: "WizMote Brightness Up" + id: wizmote_action_bright_up + icon: "mdi:gesture-tap-button" + entity_category: config + optimistic: true + restore_value: true + initial_option: "Volume Up" + options: *wizmote_actions + - platform: template + name: "WizMote Brightness Down" + id: wizmote_action_bright_down + icon: "mdi:gesture-tap-button" + entity_category: config + optimistic: true + restore_value: true + initial_option: "Volume Down" + options: *wizmote_actions + - platform: template + name: "WizMote Button 1" + id: wizmote_action_btn1 + icon: "mdi:gesture-tap-button" + entity_category: config + optimistic: true + restore_value: true + initial_option: "Previous Track" + options: *wizmote_actions + - platform: template + name: "WizMote Button 2" + id: wizmote_action_btn2 + icon: "mdi:gesture-tap-button" + entity_category: config + optimistic: true + restore_value: true + initial_option: "Next Track" + options: *wizmote_actions + - platform: template + name: "WizMote Button 3" + id: wizmote_action_btn3 + icon: "mdi:gesture-tap-button" + entity_category: config + optimistic: true + restore_value: true + initial_option: "Send HA Event" + options: *wizmote_actions + - platform: template + name: "WizMote Button 4" + id: wizmote_action_btn4 + icon: "mdi:gesture-tap-button" + entity_category: config + optimistic: true + restore_value: true + initial_option: "Send HA Event" + options: *wizmote_actions + +text_sensor: + - platform: template + name: "WizMote Status" + id: wizmote_status + icon: "mdi:information" + entity_category: diagnostic + update_interval: 300s + lambda: |- + std::string mac = id(wizmote_mac_address).state; + bool discovery = id(wizmote_discovery_mode).state; + if (discovery) { + return std::string("Discovery mode active"); + } else if (mac == "00:00:00:00:00:00" || mac == "") { + return std::string("No WizMote paired"); + } else { + return std::string("Paired: " + mac); + } + +text: + - platform: template + name: "WizMote MAC Address" + id: wizmote_mac_address + icon: "mdi:remote" + entity_category: config + initial_value: "${WIZMOTE_MAC_ADDRESS}" + optimistic: true + restore_value: true + mode: text + min_length: 17 + max_length: 17 + on_value: + - lambda: |- + std::string mac = x; + bool valid = true; + for (int i = 0; i < 17; i++) { + if (i % 3 == 2) { if (mac[i] != ':') { valid = false; break; } } + else { if (!std::isxdigit(mac[i])) { valid = false; break; } } + } + if (!valid) { + ESP_LOGW("wizmote", "Invalid MAC: %s", mac.c_str()); + return; + } + - if: + condition: + lambda: 'return (id(wizmote_previous_mac) != "" && id(wizmote_previous_mac) != "00:00:00:00:00:00");' + then: + - espnow.peer.delete: + address: !lambda |- + std::string mac = id(wizmote_previous_mac); + std::array mac_bytes; + sscanf(mac.c_str(), "%hhx:%hhx:%hhx:%hhx:%hhx:%hhx", + &mac_bytes[0], &mac_bytes[1], &mac_bytes[2], + &mac_bytes[3], &mac_bytes[4], &mac_bytes[5]); + return mac_bytes; + - if: + condition: + lambda: 'return (x != "00:00:00:00:00:00" && x != "");' + then: + - espnow.peer.add: + address: !lambda |- + std::string mac = x; + std::array mac_bytes; + sscanf(mac.c_str(), "%hhx:%hhx:%hhx:%hhx:%hhx:%hhx", + &mac_bytes[0], &mac_bytes[1], &mac_bytes[2], + &mac_bytes[3], &mac_bytes[4], &mac_bytes[5]); + return mac_bytes; + - lambda: 'id(wizmote_previous_mac) = x;' + - lambda: 'id(wizmote_status).update();' + +script: + - id: run_wizmote_action + parameters: + action: string + button: string + then: + - logger.log: + tag: wizmote + format: "button %s -> %s" + args: ['button.c_str()', 'action.c_str()'] + - if: + condition: + lambda: 'return action == "Play";' + then: + - media_player.play: sendspin_group_media_player + - if: + condition: + lambda: 'return action == "Pause";' + then: + - media_player.pause: sendspin_group_media_player + - if: + condition: + lambda: 'return action == "Play / Pause";' + then: + - media_player.toggle: sendspin_group_media_player + - if: + condition: + lambda: 'return action == "Next Track";' + then: + - media_player.next: sendspin_group_media_player + - if: + condition: + lambda: 'return action == "Previous Track";' + then: + - media_player.previous: sendspin_group_media_player + - if: + condition: + lambda: 'return action == "Volume Up";' + then: + - media_player.volume_up: external_media_player + - logger.log: + tag: wizmote + format: " volume now %.0f%%" + args: ['id(external_media_player).volume * 100.0f'] + - if: + condition: + lambda: 'return action == "Volume Down";' + then: + - media_player.volume_down: external_media_player + - logger.log: + tag: wizmote + format: " volume now %.0f%%" + args: ['id(external_media_player).volume * 100.0f'] + - if: + condition: + lambda: 'return action == "Toggle Light";' + then: + - light.toggle: rgb_light + - if: + condition: + lambda: 'return action == "Send HA Event";' + then: + - homeassistant.event: + event: esphome.cast1_wizmote_event + data: + button: !lambda 'return button;' + + - id: process_wizmote_button + parameters: + button: int + then: + - if: + condition: + lambda: 'return button == ${WIZMOTE_BUTTON_ON};' + then: + - script.execute: + id: run_wizmote_action + action: !lambda 'return id(wizmote_action_on).current_option();' + button: "on" + - if: + condition: + lambda: 'return button == ${WIZMOTE_BUTTON_OFF};' + then: + - script.execute: + id: run_wizmote_action + action: !lambda 'return id(wizmote_action_off).current_option();' + button: "off" + - if: + condition: + lambda: 'return button == ${WIZMOTE_BUTTON_NIGHT};' + then: + - script.execute: + id: run_wizmote_action + action: !lambda 'return id(wizmote_action_night).current_option();' + button: "night" + - if: + condition: + lambda: 'return button == ${WIZMOTE_BUTTON_BRIGHT_UP};' + then: + - script.execute: + id: run_wizmote_action + action: !lambda 'return id(wizmote_action_bright_up).current_option();' + button: "brightness_up" + - if: + condition: + lambda: 'return button == ${WIZMOTE_BUTTON_BRIGHT_DOWN};' + then: + - script.execute: + id: run_wizmote_action + action: !lambda 'return id(wizmote_action_bright_down).current_option();' + button: "brightness_down" + - if: + condition: + lambda: 'return button == ${WIZMOTE_BUTTON_1};' + then: + - script.execute: + id: run_wizmote_action + action: !lambda 'return id(wizmote_action_btn1).current_option();' + button: "1" + - if: + condition: + lambda: 'return button == ${WIZMOTE_BUTTON_2};' + then: + - script.execute: + id: run_wizmote_action + action: !lambda 'return id(wizmote_action_btn2).current_option();' + button: "2" + - if: + condition: + lambda: 'return button == ${WIZMOTE_BUTTON_3};' + then: + - script.execute: + id: run_wizmote_action + action: !lambda 'return id(wizmote_action_btn3).current_option();' + button: "3" + - if: + condition: + lambda: 'return button == ${WIZMOTE_BUTTON_4};' + then: + - script.execute: + id: run_wizmote_action + action: !lambda 'return id(wizmote_action_btn4).current_option();' + button: "4" + - if: + condition: + lambda: |- + return button != ${WIZMOTE_BUTTON_ON} && button != ${WIZMOTE_BUTTON_OFF} && + button != ${WIZMOTE_BUTTON_NIGHT} && button != ${WIZMOTE_BUTTON_BRIGHT_UP} && + button != ${WIZMOTE_BUTTON_BRIGHT_DOWN} && button != ${WIZMOTE_BUTTON_1} && + button != ${WIZMOTE_BUTTON_2} && button != ${WIZMOTE_BUTTON_3} && + button != ${WIZMOTE_BUTTON_4}; + then: + - logger.log: + tag: wizmote + level: WARN + format: "unmapped button %d" + args: ['button'] + +# Channel scan; skipped while WiFi pins the channel +interval: + - id: wizmote_channel_scan + interval: 2s + then: + - if: + condition: + and: + - not: + wifi.enabled: + - lambda: |- + std::string mac = id(wizmote_mac_address).state; + bool want = id(wizmote_discovery_mode).state || + (mac != "00:00:00:00:00:00" && mac != ""); + return want && (millis() - id(wizmote_last_packet_ms) > 30000); + then: + - lambda: |- + id(wizmote_scan_channel)++; + if (id(wizmote_scan_channel) > 13) id(wizmote_scan_channel) = 1; + - espnow.set_channel: + channel: !lambda 'return id(wizmote_scan_channel);' + - logger.log: + format: "WizMote channel scan: trying channel %d" + args: ['id(wizmote_scan_channel)'] + +espnow: + id: espnow_component + auto_add_peer: false + enable_on_boot: true + on_unknown_peer: + then: + - lambda: |- + char mac_str[18]; + snprintf(mac_str, sizeof(mac_str), "%02x:%02x:%02x:%02x:%02x:%02x", + info.src_addr[0], info.src_addr[1], info.src_addr[2], + info.src_addr[3], info.src_addr[4], info.src_addr[5]); + std::string sender_mac(mac_str); + + if (size != 13) return; + + uint8_t button = data[12]; + bool discovery_mode = id(wizmote_discovery_mode).state; + + if (discovery_mode) { + ESP_LOGI("wizmote", "Discovery: WizMote MAC: %s, Button: %d", sender_mac.c_str(), button); + std::string current_mac = id(wizmote_mac_address).state; + if (current_mac == "00:00:00:00:00:00" || current_mac == "" || current_mac != sender_mac) { + auto call = id(wizmote_mac_address).make_call(); + call.set_value(sender_mac); + call.perform(); + id(wizmote_discovery_mode).turn_off(); + } + return; + } + + ESP_LOGI("wizmote", "Unknown WizMote: %s (enable discovery mode to pair)", sender_mac.c_str()); + on_broadcast: + then: + - lambda: |- + char mac_str[18]; + snprintf(mac_str, sizeof(mac_str), "%02x:%02x:%02x:%02x:%02x:%02x", + info.src_addr[0], info.src_addr[1], info.src_addr[2], + info.src_addr[3], info.src_addr[4], info.src_addr[5]); + std::string sender_mac(mac_str); + + if (size != 13) return; + + std::string configured_mac = id(wizmote_mac_address).state; + std::transform(configured_mac.begin(), configured_mac.end(), configured_mac.begin(), ::tolower); + + if (configured_mac == "00:00:00:00:00:00" || configured_mac == "" || sender_mac != configured_mac) return; + + // Lets ETH channel-scan lock on + id(wizmote_last_packet_ms) = millis(); + + uint32_t sequence = (data[4] << 24) | (data[3] << 16) | (data[2] << 8) | data[1]; + uint8_t button = data[6]; + + if (sequence == id(wizmote_last_sequence)) return; + id(wizmote_last_sequence) = sequence; + + ESP_LOGI("wizmote", "Button: %d seq=%d from %s", button, sequence, sender_mac.c_str()); + id(process_wizmote_button).execute(button); + +esphome: + on_boot: + - priority: -100 + then: + - delay: 2s + - if: + condition: + lambda: |- + std::string mac = id(wizmote_mac_address).state; + return (mac != "00:00:00:00:00:00" && mac != ""); + then: + - logger.log: "Restoring WizMote pairing on boot..." + - espnow.peer.add: + address: !lambda |- + std::string mac = id(wizmote_mac_address).state; + std::array mac_bytes; + sscanf(mac.c_str(), "%hhx:%hhx:%hhx:%hhx:%hhx:%hhx", + &mac_bytes[0], &mac_bytes[1], &mac_bytes[2], + &mac_bytes[3], &mac_bytes[4], &mac_bytes[5]); + return mac_bytes;