├── .clang-format ├── .github ├── ISSUE_TEMPLATE.md ├── labels.yml ├── release-drafter.yml └── workflows │ ├── ci.yml │ ├── labels.yaml │ ├── pr-labels.yaml │ ├── publish-firmware.yml │ ├── publish-pages.yml │ ├── publish.yml_ │ └── release-drafter.yaml ├── .gitignore ├── .vscode └── settings.json ├── README.md ├── logging.sh ├── muino-water-meter-esp32.factory.yaml ├── muino-water-meter-esp32.yaml ├── muino_water_meter.h ├── production_installer.sh └── static ├── _config.yml ├── data_visualiser.html ├── img ├── esphome_adopt.png ├── muino_watermeter.jpg ├── muino_with_case.png └── sensus_620.png └── index.md /.clang-format: -------------------------------------------------------------------------------- 1 | # The following belongs to: Muino 2 | # http://clang.llvm.org/docs/ClangFormatStyleOptions.html 3 | # "style": "File" 4 | BasedOnStyle: LLVM 5 | AccessModifierOffset: -4 6 | AlignAfterOpenBracket: AlwaysBreak 7 | AlignConsecutiveAssignments: true 8 | AlignConsecutiveDeclarations: true 9 | AlignEscapedNewlinesLeft: true 10 | AlignOperands: true 11 | AlignTrailingComments: true 12 | AllowAllParametersOfDeclarationOnNextLine: true 13 | AllowShortBlocksOnASingleLine: false 14 | AllowShortCaseLabelsOnASingleLine: true 15 | AllowShortFunctionsOnASingleLine: false 16 | AllowShortIfStatementsOnASingleLine: false 17 | AllowShortLoopsOnASingleLine: false 18 | AlwaysBreakAfterDefinitionReturnType: None 19 | AlwaysBreakAfterReturnType: None 20 | AlwaysBreakBeforeMultilineStrings: true 21 | # AlwaysBreakTemplateDeclarations (bool) 22 | # BinPackArguments (bool) 23 | BinPackParameters: true 24 | # BraceWrapping (BraceWrappingFlags) 25 | # BreakAfterJavaFieldAnnotations (bool) 26 | # BreakBeforeBinaryOperators (BinaryOperatorStyle) 27 | BreakBeforeBraces: Attach 28 | # BreakBeforeTernaryOperators (bool) 29 | # BreakConstructorInitializersBeforeComma (bool) 30 | # BreakStringLiterals (bool) 31 | ColumnLimit: 1000 32 | # CommentPragmas (std::string) 33 | # ConstructorInitializerAllOnOneLineOrOnePerLine (bool) 34 | # ConstructorInitializerIndentWidth (unsigned) 35 | # ContinuationIndentWidth (unsigned) 36 | # Cpp11BracedListStyle (bool) 37 | DerivePointerAlignment: false 38 | # DisableFormat (bool) 39 | # ExperimentalAutoDetectBinPacking (bool) 40 | # ForEachMacros (std::vector) 41 | # IncludeCategories (std::vector) 42 | # IncludeIsMainRegex (std::string) 43 | IndentCaseLabels: true 44 | IndentWidth: 4 45 | # IndentWrappedFunctionNames (bool) 46 | # JavaScriptQuotes (JavaScriptQuoteStyle) 47 | # KeepEmptyLinesAtTheStartOfBlocks (bool) 48 | # Language (LanguageKind) 49 | # MacroBlockBegin (std::string) 50 | # MacroBlockEnd (std::string) 51 | # MaxEmptyLinesToKeep (unsigned) 52 | NamespaceIndentation: All 53 | # ObjCBlockIndentWidth (unsigned) 54 | # ObjCSpaceAfterProperty (bool) 55 | # ObjCSpaceBeforeProtocolList (bool) 56 | # PenaltyBreakBeforeFirstCallParameter (unsigned) 57 | # PenaltyBreakComment (unsigned) 58 | # PenaltyBreakFirstLessLess (unsigned) 59 | # PenaltyBreakString (unsigned) 60 | # PenaltyExcessCharacter (unsigned) 61 | # PenaltyReturnTypeOnItsOwnLine: 1000 62 | PointerAlignment: Left 63 | # ReflowComments (bool) 64 | SortIncludes: false 65 | # SpaceAfterCStyleCast (bool) 66 | # SpaceAfterTemplateKeyword (bool) 67 | SpaceBeforeAssignmentOperators: true 68 | SpaceBeforeParens: ControlStatements 69 | SpaceInEmptyParentheses: false 70 | # SpacesBeforeTrailingComments (unsigned) 71 | # SpacesInAngles (bool) 72 | SpacesInCStyleCastParentheses: false 73 | # SpacesInContainerLiterals (bool) 74 | SpacesInParentheses: false 75 | SpacesInSquareBrackets: false 76 | # Standard (LanguageStandard) 77 | TabWidth: 4 78 | UseTab: Never -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # Problem/Motivation 2 | 3 | > (Why the issue was filed) 4 | 5 | ## Expected behavior 6 | 7 | > (What you expected to happen) 8 | 9 | ## Actual behavior 10 | 11 | > (What actually happened) 12 | 13 | ## Steps to reproduce 14 | 15 | > (How can someone else make/see it happen) 16 | 17 | ## Proposed changes 18 | 19 | > (If you have a proposed change, workaround or fix, 20 | > describe the rationale behind it) -------------------------------------------------------------------------------- /.github/labels.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: "ESPHome-issue" 3 | color: ee0701 4 | description: "An issue with ESPHome itself." 5 | - name: "breaking-change" 6 | color: ee0701 7 | description: "A breaking change for existing users." 8 | - name: "bugfix" 9 | color: ee0701 10 | description: "Inconsistencies or issues which will cause a problem for users or implementors." 11 | - name: "documentation" 12 | color: 0052cc 13 | description: "Solely about the documentation of the project." 14 | - name: "enhancement" 15 | color: 1d76db 16 | description: "Enhancement of the code, not introducing new features." 17 | - name: "refactor" 18 | color: 1d76db 19 | description: "Improvement of existing code, not introducing new features." 20 | - name: "performance" 21 | color: 1d76db 22 | description: "Improving performance, not introducing new features." 23 | - name: "new-feature" 24 | color: 0e8a16 25 | description: "New features or options." 26 | - name: "maintenance" 27 | color: 2af79e 28 | description: "Generic maintenance tasks." 29 | - name: "ci" 30 | color: 1d76db 31 | description: "Work that improves the continue integration." 32 | - name: "dependencies" 33 | color: 1d76db 34 | description: "Upgrade or downgrade of project dependencies." 35 | - name: "translations" 36 | color: d4c5f9 37 | description: "Impacts translations." 38 | 39 | - name: "in-progress" 40 | color: fbca04 41 | description: "Issue is currently being resolved by a developer." 42 | - name: "stale" 43 | color: fef2c0 44 | description: "There has not been activity on this issue or PR for quite some time." 45 | - name: "no-stale" 46 | color: fef2c0 47 | description: "This issue or PR is exempted from the stable bot." 48 | 49 | - name: "security" 50 | color: ee0701 51 | description: "Marks a security issue that needs to be resolved asap." 52 | - name: "incomplete" 53 | color: fef2c0 54 | description: "Marks a PR or issue that is missing information." 55 | - name: "invalid" 56 | color: fef2c0 57 | description: "Marks a PR or issue that is missing information." 58 | 59 | - name: "priority-critical" 60 | color: ee0701 61 | description: "This should be dealt with ASAP. Not fixing this issue would be a serious error." 62 | - name: "priority-high" 63 | color: b60205 64 | description: "After critical issues are fixed, these should be dealt with before any further issues." 65 | - name: "priority-medium" 66 | color: 0e8a16 67 | description: "This issue may be useful, and needs some attention." 68 | - name: "priority-low" 69 | color: e4ea8a 70 | description: "Nice addition, maybe... someday..." 71 | 72 | - name: "major" 73 | color: b60205 74 | description: "This PR causes a major version bump in the version number." 75 | - name: "minor" 76 | color: 0e8a16 77 | description: "This PR causes a minor version bump in the version number." 78 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name-template: 'v$RESOLVED_VERSION 🌈' 2 | tag-template: 'v$RESOLVED_VERSION' 3 | categories: 4 | - title: '🚀 New Features' 5 | labels: 6 | - 'feature' 7 | - 'enhancement' 8 | - title: '🐛 Bug Fixes' 9 | labels: 10 | - 'fix' 11 | - 'bugfix' 12 | - 'bug' 13 | - title: '🧰 Maintenance' 14 | label: 'chore' 15 | - title: '⬆️ Dependency updates' 16 | label: 'dependencies' 17 | change-template: '- $TITLE @$AUTHOR (#$NUMBER)' 18 | change-title-escapes: '\<*_&' # You can add # and @ to disable mentions, and add ` to disable code blocks. 19 | version-resolver: 20 | major: 21 | labels: 22 | - 'major' 23 | minor: 24 | labels: 25 | - 'minor' 26 | patch: 27 | labels: 28 | - 'patch' 29 | default: patch 30 | template: | 31 | ## Changes 32 | 33 | $CHANGES -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - '*.yaml' 7 | - '.github/workflows/ci.yml' 8 | schedule: 9 | - cron: '0 0 * * *' 10 | 11 | concurrency: 12 | group: ${{ github.workflow }}-${{ github.event.pull_request.number }} 13 | cancel-in-progress: true 14 | 15 | jobs: 16 | ci: 17 | name: Building ${{ matrix.file }} / ${{ matrix.esphome-version }} 18 | runs-on: ubuntu-latest 19 | strategy: 20 | fail-fast: false 21 | max-parallel: 3 22 | matrix: 23 | #### Modify below here to match your project #### 24 | file: 25 | - muino-water-meter-esp32 26 | #### Modify above here to match your project #### 27 | 28 | esphome-version: 29 | - stable 30 | - beta 31 | - dev 32 | steps: 33 | - name: Checkout source code 34 | uses: actions/checkout@v4.1.7 35 | - name: ESPHome ${{ matrix.esphome-version }} 36 | uses: esphome/build-action@v4.0.3 37 | with: 38 | yaml-file: ${{ matrix.file }}.yaml 39 | version: ${{ matrix.esphome-version }} 40 | - name: ESPHome ${{ matrix.esphome-version }} Factory 41 | uses: esphome/build-action@v4.0.3 42 | with: 43 | yaml-file: ${{ matrix.file }}.factory.yaml 44 | version: ${{ matrix.esphome-version }} 45 | -------------------------------------------------------------------------------- /.github/workflows/labels.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Sync labels 3 | 4 | # yamllint disable-line rule:truthy 5 | on: 6 | push: 7 | branches: 8 | - master 9 | paths: 10 | - .github/labels.yml 11 | schedule: 12 | - cron: "34 5 * * *" 13 | workflow_call: 14 | workflow_dispatch: 15 | 16 | jobs: 17 | labels: 18 | name: ♻️ Sync labels 19 | runs-on: ubuntu-latest 20 | steps: 21 | - name: ⤵️ Download latest labels definitions 22 | run: | 23 | curl -s --retry 5 \ 24 | "https://raw.githubusercontent.com/hassio-addons/workflows/main/.github/labels.yml" \ 25 | > labels.yml 26 | - name: 🚀 Run Label Syncer 27 | uses: micnncim/action-label-syncer@v1.3.0 28 | env: 29 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 30 | with: 31 | manifest: labels.yml -------------------------------------------------------------------------------- /.github/workflows/pr-labels.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: PR Labels 3 | 4 | # yamllint disable-line rule:truthy 5 | on: 6 | pull_request_target: 7 | types: 8 | - opened 9 | - labeled 10 | - unlabeled 11 | - synchronize 12 | workflow_call: 13 | 14 | jobs: 15 | pr_labels: 16 | name: Verify 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: 🏷 Verify PR has a valid label 20 | uses: jesusvasquez333/verify-pr-label-action@v1.4.0 21 | with: 22 | pull-request-number: "${{ github.event.pull_request.number }}" 23 | github-token: "${{ secrets.GITHUB_TOKEN }}" 24 | valid-labels: >- 25 | breaking-change, bugfix, documentation, enhancement, refactor, 26 | performance, new-feature, maintenance, ci, dependencies, 27 | translations 28 | disable-reviews: true -------------------------------------------------------------------------------- /.github/workflows/publish-firmware.yml: -------------------------------------------------------------------------------- 1 | name: Publish Firmware 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | permissions: 8 | contents: write 9 | 10 | jobs: 11 | build-firmware: 12 | name: Build Firmware 13 | uses: esphome/workflows/.github/workflows/build.yml@2024.12.0 14 | with: 15 | #### Modify below here to match your project #### 16 | files: | 17 | muino-water-meter-esp32.factory.yaml 18 | esphome-version: 2024.10.3 19 | combined-name: project-template 20 | #### Modify above here to match your project #### 21 | 22 | release-summary: ${{ github.event.release.body }} 23 | release-url: ${{ github.event.release.html_url }} 24 | release-version: ${{ github.event.release.tag_name }} 25 | 26 | upload-to-release: 27 | name: Upload to Release 28 | uses: esphome/workflows/.github/workflows/upload-to-gh-release.yml@2024.12.0 29 | needs: 30 | - build-firmware 31 | with: 32 | version: ${{ github.event.release.tag_name }} 33 | -------------------------------------------------------------------------------- /.github/workflows/publish-pages.yml: -------------------------------------------------------------------------------- 1 | name: Publish Pages 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - 'static/**' 9 | - '.github/workflows/publish-pages.yml' 10 | workflow_run: 11 | workflows: 12 | - Publish Firmware 13 | types: 14 | - completed 15 | pull_request: 16 | paths: 17 | - 'static/**' 18 | - '.github/workflows/publish-pages.yml' 19 | 20 | 21 | concurrency: 22 | group: ${{ github.workflow }}-${{ github.ref }} 23 | cancel-in-progress: true 24 | 25 | jobs: 26 | build: 27 | name: Build 28 | runs-on: ubuntu-latest 29 | steps: 30 | - name: Checkout source code 31 | uses: actions/checkout@v4.2.2 32 | 33 | - run: mkdir -p output/firmware 34 | 35 | - name: Build 36 | uses: actions/jekyll-build-pages@v1.0.13 37 | with: 38 | source: ./static 39 | destination: ./output 40 | 41 | - name: Fetch firmware files 42 | uses: robinraju/release-downloader@v1.11 43 | with: 44 | latest: true 45 | fileName: '*' 46 | out-file-path: output/firmware 47 | 48 | - name: Upload artifact 49 | uses: actions/upload-pages-artifact@v3.0.1 50 | with: 51 | path: output 52 | retention-days: 1 53 | 54 | publish: 55 | if: github.event_name != 'pull_request' 56 | name: Publish 57 | runs-on: ubuntu-latest 58 | needs: 59 | - build 60 | permissions: 61 | pages: write 62 | id-token: write 63 | environment: 64 | name: github-pages 65 | url: ${{ steps.deployment.outputs.page_url }} 66 | steps: 67 | - name: Setup Pages 68 | uses: actions/configure-pages@v5.0.0 69 | 70 | - name: Deploy to GitHub Pages 71 | id: deployment 72 | uses: actions/deploy-pages@v4.0.5 73 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml_: -------------------------------------------------------------------------------- 1 | name: Build and Publish ESPHome firmware and website 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | build: 10 | uses: esphome/workflows/.github/workflows/publish.yml@main 11 | with: 12 | # CHANGEME: Set the filenames of your config files here: 13 | files: muino-water-meter-esp32.yaml 14 | # CHANGEME: Set the name of your project here: 15 | name: Muino water meter 16 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yaml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: 4 | push: 5 | # branches to consider in the event; optional, defaults to all 6 | branches: 7 | - main 8 | # pull_request event is required only for autolabeler 9 | pull_request: 10 | # Only following types are handled by the action, but one can default to all as well 11 | types: [opened, reopened, synchronize] 12 | # pull_request_target event is required for autolabeler to support PRs from forks 13 | # pull_request_target: 14 | # types: [opened, reopened, synchronize] 15 | 16 | permissions: 17 | contents: read 18 | 19 | jobs: 20 | update_release_draft: 21 | permissions: 22 | # write permission is required to create a github release 23 | contents: write 24 | # write permission is required for autolabeler 25 | # otherwise, read permission is required at least 26 | pull-requests: write 27 | runs-on: ubuntu-latest 28 | steps: 29 | # (Optional) GitHub Enterprise requires GHE_HOST variable set 30 | #- name: Set GHE_HOST 31 | # run: | 32 | # echo "GHE_HOST=${GITHUB_SERVER_URL##https:\/\/}" >> $GITHUB_ENV 33 | 34 | # Drafts your next Release notes as Pull Requests are merged into "master" 35 | - uses: release-drafter/release-drafter@v5.22.0 36 | # (Optional) specify config name to use, relative to .github/. Default: release-drafter.yml 37 | # with: 38 | # config-name: my-config.yml 39 | # disable-autolabeler: true 40 | env: 41 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Gitignore settings for ESPHome 2 | # This is an example and may include too much for your use-case. 3 | # You can modify this file to suit your needs. 4 | /.esphome/ 5 | /secrets.yaml 6 | test* 7 | pushmsg 8 | imgui.ini 9 | *.log 10 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.associations": { 3 | "thread": "cpp" 4 | } 5 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Online Water Meter Programmer.. 2 | 3 | [!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/muino) 4 | 5 | 6 | 7 | # Muino Water-Meter Reader - sub-100 millilitre precision 8 | Water meters are devices that measure how much water you use. They have a spinning disk inside them, and each time it spins all the way around, it means you've used one liter of water. Most water meter readers use a simple method: they check if a metal disk is there or not. But the Muino water meter is different. It uses three light sensors to keep track of where the disk is. It uses some smart techniques to do this, and we use calculate with some fine adjustments to get things just right. This helps the Muino water meter measure very accurately, down to almost a millimeter. But remember, the spinning disk doesn't move perfectly like a smooth wave. So, in some parts of its rotation, the measurements might jump a bit more than in other parts. 9 | 10 | ## Why 11 | The Muino Smart Water Meter is a **single-board** device that measures water consumption with **sub-100 millilitre** accuracy. The other big benefit is the **ease of installation**, for friends/family that wanted a similar solution this is easier to use. 12 | # Where to buy? 13 | * If you would like the watermeter go to: [tindie-webshop](https://www.tindie.com/products/muino/smart-water-meter-reader/) or better my [own-webshop](https://muino.nl/product/smart-water-meter-reader) 14 | * You want a sensor to play with the DIY version go to: [tindie-webshop](https://www.tindie.com/products/muino/3-phase-muino-light-sensor-encoder/) or better my [own-webshop](https://muino.nl/product/3d-case-for-the-water-meter-reader) 15 | * For big orders please make a request: [email](mailto:martijnvwezel@muino.nl) 16 | * My webshop is located at [muino.nl](https://muino.nl). I haven't dedicated time to enhancing the visual appeal of my site. However, I prefer nowadays my own website, because some people just buy it without knowing what they buy and using Tindie's protection scheme let me pay for all of the shipping costs. So in the end I am wrong and therefore I request a higher price from tindie webshop than from my own webshop, sorry I have to. 17 | 18 | ### Comfirmed supported devices 19 | * KiWa V200 (Designed for) 20 | * Honeywell v200 (Designed for) 21 | * KiWa R400 (Similar to Sensus 620) 22 | * Sensus 620 (Note: Placement might appear less aesthetically pleasing because of the meters placement. The two middle holes of the Muino reader are aligned over the meter for proper attachment.) 23 | * Elster Honeywell (some) 24 | * Itron Actaris Schlumberger (Aquadis+) with double-sided tape or tie-wraps. 25 | * You can always donate to let me create a watermeter compatibility.. 26 | 27 | 28 | Thank you for buying the Muino Water Meter Reader :). Let me try to explain the steps for your installation! 29 | 30 | ## What do you need 31 | 32 | * USB-C cable that can power the Muino Smart Water Meter 33 | * Device with WiFi for initial connection to your home wifi network 34 | * Access to your Home-assistant 35 | 36 | ## Installation steps 37 | 38 | 1. Place the Muino Smart Water Meter on your water-meter, where applicable use M2.5/M4 screws/bolts to attach. 39 | Screws are intented to fit securely/snuggly in the PCB but *do not* over thighten. Less compatible meters have no or wrong mounting holes, use tie-wraps, tape and creativity... 40 | 3. Connect the USB-C power 41 | 4. Go to your phone/wifi-device and connect to the Muino Smart Water Meter WiFi SSID (if you need a password: `12345678`) 42 | 5. Once the device connected to the Muino Smart Water Meter, go to http://192.168.4.1 and select your prefered WiFi SSID to connect the Muino Smart Water Meter with and enter the SSID passcode. 43 | 6. The Muino Smart Water Meter will try to connect to the selected WiFi SSID, please be patient. After a while, check your home network to find the IP-address of the Espressif Muino Smart Water Meter. 44 | 7. In Home Assistant, go to Settings, add the ESPHome integration, and add IP-address of the Muino Smart Water Meter to adopt it. 45 | 8. In Home Assistant, go to Energy -> Energy Configuration (3 dot menu), add the new sensor (sensor.liters) and potentially the price per cubic meter of water. 46 | 47 | ## Water Sensor Update Protocol 48 | 49 | 1. **After restart**: Upon restart, a zero value is sent to inform the home assistant that the sensor has been reset. 50 | 2. **Calibration**: The sensor calibrates during the first 2 liters of water usage. 51 | 3. **Sending Updates**: After calibration, the sensor sends updates to the home assistant system. It waits until it detects 2 liters of water usage and then pauses for 1 minute before sending the update. This prevents interruptions during activities like showering. 52 | 4. **Speed modus**: For faster updating the live values from the watermeter, what is more noisy and fills the database of your home-assistant. 53 | 5. **Debug modus**: The speed modus will be enabled and the debug json will be filled with values from the meter for debugging purpose only. 54 | #### Don't forget to Add Muino Water-Meter Reader to your HA Energy-dashboard 55 | 56 | # Pro-user & Development 57 | ``` bash 58 | docker run --rm --privileged -v ${PWD}:/config -it ghcr.io/esphome/esphome run --device=/dev/ttyACM0 "muino-water-meter-esp32.yaml" 59 | ``` 60 | ## for windows 61 | ``` bash 62 | # for windows builds are done using WSL 63 | # Open following in admin powershell 64 | usbipd wsl list 65 | 66 | # select the one to connnect 67 | usbipd wsl attach --busid 68 | ``` 69 | -------------------------------------------------------------------------------- /logging.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | while true; do 4 | if [ -e "/dev/ttyACM0" ]; then 5 | 6 | docker run --rm --privileged -v ${PWD}:/config -it ghcr.io/esphome/esphome logs --device=/dev/ttyACM0 "muino-water-meter-esp32.yaml" 7 | fi 8 | sleep 1 # Not needed to 100% check if something is not connected 9 | done 10 | -------------------------------------------------------------------------------- /muino-water-meter-esp32.factory.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | core: !include muino-water-meter-esp32.yaml 3 | 4 | esphome: 5 | project: 6 | name: esphome.muino-water-meter 7 | version: dev # This will be replaced by the github workflows with the `release` version 8 | 9 | dashboard_import: 10 | package_import_url: github://martijnvwezel/watermeter-esphome/muino-water-meter-esp32.yaml@main 11 | 12 | # Sets up Bluetooth LE (Only on ESP32) to allow the user 13 | # to provision wifi credentials to the device. 14 | # esp32_improv: 15 | # authorizer: none 16 | 17 | # Sets up the improv via serial client for Wi-Fi provisioning. 18 | # Handy if your device has a usb port for the user to add credentials when they first get it. 19 | # improv_serial: -------------------------------------------------------------------------------- /muino-water-meter-esp32.yaml: -------------------------------------------------------------------------------- 1 | 2 | 3 | # These substitutions allow the end user to override certain values 4 | substitutions: 5 | name: "muino-water-meter" 6 | 7 | esphome: 8 | name: "${name}" 9 | # Automatically add the mac address to the name 10 | # so you can use a single firmware for all devices 11 | name_add_mac_suffix: true 12 | platformio_options: 13 | board_build.flash_mode: "dio" 14 | 15 | # This will allow for (future) project identification, 16 | # configuration and updates. 17 | # project: 18 | # name: esphome-muino-water-meter 19 | # version: "3.0.4" 20 | 21 | esp32: 22 | board: seeed_xiao_esp32c3 23 | framework: 24 | type: esp-idf 25 | 26 | # Triggers reading adc values and iterating algoritm 27 | interval: 28 | - interval: 100ms 29 | then: 30 | - output.turn_on: sensor_power 31 | - component.update: light_sensor_a_dark 32 | - if: 33 | condition: 34 | lambda: |- 35 | return id(fastupdate).state; // If fastupdate is on, update immediately 36 | then: 37 | - lambda: |- 38 | id(last_reported_liters__mili) = (int)(1000*(id(liters)+id(phase)/6.0)); 39 | id(last_reported_liters) = id(liters); 40 | id(last_water_flow) = millis(); 41 | - if: 42 | condition: 43 | lambda: |- 44 | return id(debugmodus).state; 45 | then: 46 | - text_sensor.template.publish: 47 | id: debug_json 48 | state: !lambda |- 49 | return "{\"liters\":" + to_string(id(liters)) + 50 | ",\"phase\":" + to_string(id(phase)) + 51 | ",\"last_reported_liters\":" + to_string(id(last_reported_liters)) + 52 | ",\"last_reported_liters__mili\":" + to_string(id(last_reported_liters__mili)) + 53 | ",\"aa\":" + to_string(id(aa)) + 54 | ",\"bb\":" + to_string(id(bb)) + 55 | ",\"cc\":" + to_string(id(cc)) + 56 | ",\"max_a\":" + to_string(id(max_a)) + 57 | ",\"max_b\":" + to_string(id(max_b)) + 58 | ",\"max_c\":" + to_string(id(max_c)) + 59 | ",\"min_a\":" + to_string(id(min_a)) + 60 | ",\"min_b\":" + to_string(id(min_b)) + 61 | ",\"min_c\":" + to_string(id(min_c)) + 62 | ",\"upper_bound\":" + to_string(id(upper_bound)) + 63 | ",\"lower_bound\":" + to_string(id(lower_bound)) + 64 | "}"; 65 | else: 66 | - lambda: |- 67 | // If fastupdate is off, only update every 60 seconds 68 | if ((millis() - id(last_water_flow)) >= 60000) { 69 | id(last_reported_liters__mili) = (int)(1000*(id(liters)+id(phase)/6.0)); 70 | id(last_reported_liters) = id(liters); 71 | id(last_water_flow) = millis(); 72 | } 73 | 74 | # - interval: 10s # Check every 10 seconds to reduce load, adjust as needed 75 | # then: 76 | # - lambda: |- 77 | # - interval: 1s 78 | # then: 79 | # # - component.update: report_liters 80 | # # - component.update: report_liters_rounded 81 | # - logger.log: 82 | # level: INFO 83 | # tag: time 84 | # format: "o:%d" 85 | # args: [ 'id(last_water_flow)'] 86 | # # - logger.log: 87 | # level: INFO 88 | # tag: max_average 89 | # format: "a:%d b:%d c:%d" 90 | # args: [ 'id(max_a)', 'id(max_b)' , 'id(max_c)'] 91 | # - logger.log: 92 | # level: INFO 93 | # tag: min_average 94 | # format: "a:%d b:%d c:%d" 95 | # args: [ 'id(min_a)', 'id(min_b)' , 'id(min_c)'] 96 | 97 | # Toggle switch 98 | switch: 99 | - platform: template 100 | optimistic: true 101 | id: fastupdate 102 | name: Speed mode 103 | icon: "mdi:emoticon-cool-outline" 104 | 105 | - platform: template 106 | optimistic: true 107 | id: debugmodus 108 | name: Debug mode 109 | icon: "mdi:bug" 110 | turn_on_action: 111 | - switch.turn_on: fastupdate 112 | turn_off_action: 113 | - switch.turn_off: fastupdate 114 | # for manual calibration or auto 115 | - platform: template 116 | id: calibration_mode 117 | name: "Manual Calibration" 118 | icon: "mdi:tune-vertical" 119 | optimistic: true 120 | restore_mode: RESTORE_DEFAULT_OFF # <--- important/ maybe made here mistake 121 | turn_on_action: 122 | - lambda: |- 123 | id(manual_calibration) = true; 124 | turn_off_action: 125 | - lambda: |- 126 | id(manual_calibration) = false; 127 | 128 | text_sensor: 129 | - platform: template 130 | name: "debug_JSON" 131 | id: debug_json 132 | # No automatic interval, we'll update manually 133 | update_interval: never 134 | 135 | output: 136 | - platform: gpio 137 | pin: 7 138 | id: led 139 | - platform: gpio 140 | pin: 6 141 | id: sensor_power 142 | 143 | sensor: 144 | - platform: adc 145 | pin: 146 | number: 2 147 | allow_other_uses: true 148 | ignore_strapping_warning: true 149 | name: "light_sensor_a_dark" 150 | id: light_sensor_a_dark 151 | raw: true 152 | internal: true 153 | on_value: 154 | then: 155 | component.update: light_sensor_b_dark 156 | - platform: adc 157 | pin: 158 | number: 3 159 | allow_other_uses: true 160 | id: light_sensor_b_dark 161 | name: "light_sensor_b_dark" 162 | raw: true 163 | internal: true 164 | on_value: 165 | then: 166 | component.update: light_sensor_c_dark 167 | 168 | - platform: adc 169 | pin: 170 | number: 4 171 | allow_other_uses: true 172 | id: light_sensor_c_dark 173 | name: "light_sensor_c_dark" 174 | raw: true 175 | internal: true 176 | on_value: 177 | then: 178 | - output.turn_on: led 179 | - delay: 50ms 180 | - component.update: light_sensor_a_light 181 | 182 | - platform: adc 183 | pin: 184 | number: 2 185 | allow_other_uses: true 186 | 187 | ignore_strapping_warning: true 188 | name: "light_sensor_a_light" 189 | id: light_sensor_a_light 190 | raw: true 191 | internal: true 192 | on_value: 193 | then: 194 | component.update: light_sensor_b_light 195 | 196 | - platform: adc 197 | pin: 198 | number: 3 199 | allow_other_uses: true 200 | id: light_sensor_b_light 201 | name: "light_sensor_b_light" 202 | raw: true 203 | internal: true 204 | on_value: 205 | then: 206 | - component.update: light_sensor_c_light 207 | 208 | - platform: adc 209 | pin: 210 | number: 4 211 | allow_other_uses: true 212 | id: light_sensor_c_light 213 | name: "light_sensor_c_light" 214 | raw: true 215 | internal: true 216 | on_value: 217 | then: 218 | - output.turn_off: led 219 | #- output.turn_off: sensor_power 220 | - component.update: phase_coarse 221 | # keep track last time updated 222 | 223 | # dark sensor values 224 | - platform: template 225 | name: "sensa_dark" 226 | force_update: false 227 | internal: true 228 | id: ad 229 | device_class: "ILLUMINANCE" 230 | unit_of_measurement: "lx" 231 | accuracy_decimals: 0 232 | lambda: |- 233 | return id(light_sensor_a_dark).state; 234 | 235 | - platform: template 236 | name: "sensb_dark" 237 | id: bd 238 | force_update: false 239 | internal: true 240 | device_class: "ILLUMINANCE" 241 | unit_of_measurement: "lx" 242 | accuracy_decimals: 0 243 | lambda: |- 244 | return id(light_sensor_b_dark).state; 245 | 246 | - platform: template 247 | name: "sensc_dark" 248 | force_update: false 249 | internal: true 250 | id: cd 251 | device_class: "ILLUMINANCE" 252 | unit_of_measurement: "lx" 253 | accuracy_decimals: 0 254 | lambda: |- 255 | return id(light_sensor_c_dark).state; 256 | 257 | # sensor values 258 | - platform: template 259 | name: "sensa" 260 | force_update: false 261 | internal: true 262 | id: al 263 | device_class: "ILLUMINANCE" 264 | unit_of_measurement: "lx" 265 | accuracy_decimals: 0 266 | lambda: |- 267 | return id(light_sensor_a_light).state; 268 | 269 | - platform: template 270 | name: "sensb" 271 | force_update: false 272 | internal: true 273 | id: bl 274 | device_class: "ILLUMINANCE" 275 | unit_of_measurement: "lx" 276 | accuracy_decimals: 0 277 | lambda: |- 278 | return id(light_sensor_b_light).state; 279 | 280 | - platform: template 281 | name: "sensc" 282 | force_update: false 283 | internal: true 284 | id: cl 285 | device_class: "ILLUMINANCE" 286 | unit_of_measurement: "lx" 287 | accuracy_decimals: 0 288 | lambda: |- 289 | return id(light_sensor_c_light).state; 290 | 291 | 292 | # substracted values 293 | - platform: template 294 | name: "sensa_sub" 295 | id: as 296 | force_update: false 297 | internal: true 298 | device_class: "ILLUMINANCE" 299 | unit_of_measurement: "lx" 300 | accuracy_decimals: 0 301 | lambda: |- 302 | 303 | return id(aa); 304 | 305 | - platform: template 306 | name: "sensb_sub" 307 | force_update: false 308 | internal: true 309 | id: bs 310 | device_class: "ILLUMINANCE" 311 | unit_of_measurement: "lx" 312 | accuracy_decimals: 0 313 | lambda: |- 314 | return id(bb); 315 | 316 | - platform: template 317 | name: "sensc_sub" 318 | force_update: false 319 | internal: true 320 | id: cs 321 | device_class: "ILLUMINANCE" 322 | unit_of_measurement: "lx" 323 | accuracy_decimals: 0 324 | lambda: |- 325 | return id(cc); 326 | 327 | # liters 328 | - platform: template 329 | name: "water_liter_sensor" 330 | id: report_liters 331 | force_update: false 332 | device_class: "water" 333 | unit_of_measurement: "mL" 334 | accuracy_decimals: 0 335 | state_class: total_increasing 336 | lambda: |- 337 | if (id(liters) < 2){ 338 | return 0; 339 | } 340 | return id(last_reported_liters__mili); 341 | 342 | - platform: template 343 | name: "liters" 344 | id: report_liters_rounded 345 | force_update: false 346 | device_class: "water" 347 | unit_of_measurement: "L" 348 | accuracy_decimals: 0 349 | state_class: total_increasing 350 | lambda: |- 351 | if (id(last_reported_liters) < 2){ 352 | return 0; 353 | } 354 | return id(last_reported_liters); 355 | 356 | - platform: template 357 | id: phase_coarse 358 | device_class: "water" 359 | state_class: "total" 360 | internal: true 361 | on_value: 362 | if: 363 | condition: 364 | # Same syntax for is_off 365 | switch.is_on: fastupdate 366 | then: 367 | - component.update: report_liters 368 | - component.update: report_liters_rounded 369 | - component.update: al 370 | - component.update: bl 371 | - component.update: cl 372 | - component.update: ad 373 | - component.update: bd 374 | - component.update: cd 375 | - component.update: as 376 | - component.update: bs 377 | - component.update: cs 378 | lambda: |- 379 | static bool first = true; 380 | 381 | // Read the raw values from the sensors 382 | int a = id(light_sensor_a_light).state - id(light_sensor_a_dark).state; 383 | int b = id(light_sensor_b_light).state - id(light_sensor_b_dark).state; 384 | int c = id(light_sensor_c_light).state - id(light_sensor_c_dark).state; 385 | 386 | // Update history buffers 387 | id(a_history)[id(history_index)] = a; 388 | id(b_history)[id(history_index)] = b; 389 | id(c_history)[id(history_index)] = c; 390 | 391 | // Compute moving averages 392 | id(smoothed_a) = (id(a_history)[0] + id(a_history)[1] + id(a_history)[2]) / 3; 393 | id(smoothed_b) = (id(b_history)[0] + id(b_history)[1] + id(b_history)[2]) / 3; 394 | id(smoothed_c) = (id(c_history)[0] + id(c_history)[1] + id(c_history)[2]) / 3; 395 | 396 | // Move to the next history index 397 | id(history_index) = (id(history_index) + 1) % 3; 398 | 399 | // Use the smoothed values instead of raw values 400 | id(aa) = id(smoothed_a); 401 | id(bb) = id(smoothed_b); 402 | id(cc) = id(smoothed_c); 403 | 404 | int max = id(upper_bound); 405 | int min = id(lower_bound); 406 | 407 | if (a < min || b < min || c < min ){ 408 | ESP_LOGW("light_level", "Too dark, ignoring this measurement"); 409 | return 0; 410 | } 411 | if (a > max || b > max || c > max){ 412 | ESP_LOGW("light_level", "Too bright, ignoring this measurement"); 413 | return 0; 414 | } 415 | if (first){ 416 | id(min_a)= a; 417 | id(min_b)= b; 418 | id(min_c)= c; 419 | id(max_a)= 0; 420 | id(max_b)= 0; 421 | id(max_c)= 0; 422 | first = false; 423 | return 0; 424 | } 425 | 426 | 427 | float alpha_cor = 0.001; 428 | if (id(liters) < 2) { 429 | alpha_cor = 0.1; // when 2 liter not found correct harder 430 | if (id(liters) < 0) { 431 | id(liters) = 0; 432 | } 433 | } 434 | auto mini_average = [](float x, float y, float alpha_cor){ 435 | if ((x + 5) <= y && y > 10) { 436 | return x; 437 | } else { 438 | return (1 - alpha_cor) * x + alpha_cor * y; 439 | } 440 | }; 441 | auto max_average = [](int x, int y, float alpha_cor) { 442 | // ESP_LOGI("main", "x: %d, y: %d, a: %f",x,y,alpha_cor); 443 | if ((x - 5) >= y && y < 2500) { 444 | return x; 445 | } else { 446 | return (int)((1 - alpha_cor) * (float)x + alpha_cor * (float)y); 447 | } 448 | }; 449 | id(min_a)= mini_average(id(min_a), a, alpha_cor); 450 | id(min_b)= mini_average(id(min_b), b, alpha_cor); 451 | id(min_c)= mini_average(id(min_c), c, alpha_cor); 452 | id(max_a)= max_average(id(max_a), a, alpha_cor); 453 | id(max_b)= max_average(id(max_b), b, alpha_cor); 454 | id(max_c)= max_average(id(max_c), c, alpha_cor); 455 | 456 | if (id(manual_calibration)) { 457 | // Manual offsets 458 | a -= id(manual_offset_a); 459 | b -= id(manual_offset_b); 460 | c -= id(manual_offset_c); 461 | } else { 462 | // Auto-calibration offsets 463 | a -= (id(min_a) + id(max_a)) >> 1; 464 | b -= (id(min_b) + id(max_b)) >> 1; 465 | c -= (id(min_c) + id(max_c)) >> 1; 466 | } 467 | 468 | short pn[5]; 469 | if (id(phase) & 1) 470 | pn[0] = a + a - b - c, pn[1] = b + b - a - c, 471 | pn[2] = c + c - a - b; // same 472 | else 473 | pn[0] = b + c - a - a, // less 474 | pn[1] = a + c - b - b, // more 475 | pn[2] = a + b - c - c; // same 476 | pn[3] = pn[0], pn[4] = pn[1]; 477 | 478 | short i = id(phase) > 2 ? id(phase) - 3 : id(phase); 479 | if (pn[i + 2] < pn[i + 1] && pn[i + 2] < pn[i]){ 480 | id(last_water_flow) = millis(); 481 | if (pn[i + 1] > pn[i]) 482 | id(phase)++; 483 | else 484 | id(phase)--; 485 | } 486 | if (id(phase) == 6) 487 | id(liters)++, id(phase) = 0; 488 | else if (id(phase) == -1) 489 | id(liters)--, id(phase) = 5; 490 | 491 | return id(liters); 492 | 493 | 494 | globals: 495 | - id: last_reported_liters 496 | type: int 497 | initial_value: '0' 498 | - id: last_reported_liters__mili 499 | type: int 500 | initial_value: '0' 501 | - id: last_water_flow 502 | type: uint32_t 503 | initial_value: '0' 504 | - id: phase 505 | type: int 506 | initial_value: "0" 507 | - id: liters 508 | type: int 509 | initial_value: "0" 510 | - id: aa 511 | type: int 512 | initial_value: "0" 513 | - id: bb 514 | type: int 515 | initial_value: "0" 516 | - id: cc 517 | type: int 518 | initial_value: "0" 519 | - id: max_a 520 | type: int 521 | - id: max_b 522 | type: int 523 | - id: max_c 524 | type: int 525 | - id: min_a 526 | type: int 527 | - id: min_b 528 | type: int 529 | - id: min_c 530 | type: int 531 | - id: upper_bound 532 | initial_value: "1500" 533 | type: int 534 | - id: lower_bound 535 | type: int 536 | initial_value: "5" 537 | 538 | # Switch that toggles between auto-calibration and manual calibration 539 | - id: manual_calibration 540 | type: bool 541 | restore_value: yes # <--- important 542 | initial_value: "false" 543 | 544 | # Manual offsets for a, b, c 545 | - id: manual_offset_a 546 | type: int 547 | initial_value: "0" 548 | - id: manual_offset_b 549 | type: int 550 | initial_value: "0" 551 | - id: manual_offset_c 552 | type: int 553 | initial_value: "0" 554 | 555 | # Parameters for the moving average of size 3 556 | - id: a_history 557 | type: int[3] 558 | initial_value: "{0, 0, 0}" 559 | - id: b_history 560 | type: int[3] 561 | initial_value: "{0, 0, 0}" 562 | - id: c_history 563 | type: int[3] 564 | initial_value: "{0, 0, 0}" 565 | - id: history_index 566 | type: int 567 | initial_value: "0" 568 | 569 | - id: smoothed_a 570 | type: int 571 | initial_value: "0" 572 | - id: smoothed_b 573 | type: int 574 | initial_value: "0" 575 | - id: smoothed_c 576 | type: int 577 | initial_value: "0" 578 | 579 | number: 580 | - platform: template 581 | id: offset_a_number 582 | name: "Offset A (0-3300)" 583 | restore_value: true # <--- important 584 | optimistic: true 585 | initial_value: 0 586 | min_value: 0 587 | max_value: 3300 588 | step: 1 589 | on_value: 590 | then: 591 | - lambda: |- 592 | id(manual_offset_a) = (int) x; 593 | 594 | - platform: template 595 | id: offset_b_number 596 | name: "Offset B (0-3300)" 597 | restore_value: true # <--- important 598 | optimistic: true 599 | initial_value: 0 600 | min_value: 0 601 | max_value: 3300 602 | step: 1 603 | on_value: 604 | then: 605 | - lambda: |- 606 | id(manual_offset_b) = (int) x; 607 | 608 | - platform: template 609 | id: offset_c_number 610 | name: "Offset C (0-3300)" 611 | restore_value: true # <--- important 612 | optimistic: true 613 | initial_value: 0 614 | min_value: 0 615 | max_value: 3300 616 | step: 1 617 | on_value: 618 | then: 619 | - lambda: |- 620 | id(manual_offset_c) = (int) x; 621 | 622 | 623 | 624 | # Make sure logging is correct for solving platform IO bugs 625 | logger: 626 | hardware_uart: USB_SERIAL_JTAG 627 | level: ERROR 628 | 629 | # API is a requirement of the dashboard import. 630 | api: 631 | 632 | # OTA is required for Over-the-Air updating 633 | ota: 634 | platform: esphome 635 | 636 | # This should point to the public location of this yaml file. 637 | # dashboard_import: 638 | # package_import_url: github://martijnvwezel/watermeter-esphome/muino-water-meter-esp32.yaml@main 639 | 640 | wifi: 641 | # Set up a wifi access point using the device name above 642 | ap: 643 | # manual_ip: 644 | # static_ip: 192.168.1.84 645 | # gateway: 192.168.1.1 646 | # subnet: 255.255.255.0 647 | # In combination with the `ap` this allows the user 648 | # to provision wifi credentials to the device. 649 | captive_portal: 650 | 651 | # Sets up Bluetooth LE (Only on ESP32) to allow the user 652 | # to provision wifi credentials to the device. 653 | # save some energy 654 | # esp32_improv: 655 | # authorizer: none 656 | 657 | -------------------------------------------------------------------------------- /muino_water_meter.h: -------------------------------------------------------------------------------- 1 | #include "esphome.h" 2 | 3 | 4 | // //GPIO 5 | // #define D4 6 6 | // #define D5 7 7 | 8 | // // mapping to match other feathers and also in order 9 | // #define A0 2 10 | // #define A1 3 11 | // #define A2 4 12 | // #define A3 5 13 | 14 | #define SENS_A A0 15 | #define SENS_B A1 16 | #define SENS_C A2 17 | 18 | #define LED D5 19 | #define LIGHT_SEN_ENABLE D4 20 | 21 | #define SMOOTHING_FACTOR 3 // 2 - 10 22 | #define AC_STEPS 16 // given pi/3 coarse estimate of phase, calculate autocorrelation of signals within that pi/3 range 23 | #define ALPHA_COR 0.1 // Value between 0-1 24 | 25 | // * Some calculations help 26 | #define PI_3 1.0471975512 27 | #define PI2_3 2.09439510239 28 | 29 | // #define POLLING_TIME_MSEC (uint32_t) id(polling_time_sec) * 1000 30 | #define POLLING_TIME_MSEC 5*60*1000 // 5min 31 | 32 | 33 | static const char* TAG = "Muino_water_sensor"; 34 | struct state_t { 35 | int8_t phase = 0; 36 | int8_t fine = 0; 37 | int32_t liters = 0; 38 | 39 | int a_min = 2500; 40 | int b_min = 2500; 41 | int c_min = 2500; 42 | 43 | int a_max = 0; 44 | int b_max = 0; 45 | int c_max = 0; 46 | }; 47 | 48 | class MyCustomSensor : public PollingComponent, public Sensor { 49 | public: 50 | Sensor* water_liter_sensor = new Sensor(); 51 | Sensor* sensa = new Sensor(); 52 | Sensor* sensb = new Sensor(); 53 | Sensor* sensc = new Sensor(); 54 | 55 | uint32_t mili_liters_total = 0; // * since boot 56 | float liter = 0.0; 57 | int sender = 0; // * Send only 1 update each 30 seconds 58 | bool not_inited = true; 59 | state_t state; 60 | float bliep = 0.0; 61 | 62 | int32_t sen_a = 0; 63 | int32_t sen_b = 0; 64 | int32_t sen_c = 0; 65 | 66 | MyCustomSensor() : PollingComponent(POLLING_TIME_MSEC) { 67 | 68 | this->state.phase = 0; 69 | this->state.fine = 0; 70 | this->state.liters = 0; 71 | 72 | this->state.a_min = 0; 73 | this->state.b_min = 0; 74 | this->state.c_min = 0; 75 | 76 | this->state.a_max = 0; 77 | this->state.b_max = 0; 78 | this->state.c_max = 0; 79 | } 80 | 81 | void setup() override { 82 | // * Make sure that home assist knows a restart occures 83 | water_liter_sensor->publish_state(0.0); 84 | 85 | pinMode(SENS_A, INPUT); // ADC 0 86 | pinMode(SENS_B, INPUT); // ADC 1 87 | pinMode(SENS_C, INPUT); // ADC 2 88 | 89 | analogSetPinAttenuation(SENS_A, ADC_0db); 90 | analogSetPinAttenuation(SENS_B, ADC_0db); 91 | analogSetPinAttenuation(SENS_C, ADC_0db); 92 | 93 | pinMode(LED, OUTPUT); 94 | digitalWrite(LED, LOW); 95 | 96 | pinMode(LIGHT_SEN_ENABLE, OUTPUT); 97 | digitalWrite(LIGHT_SEN_ENABLE, HIGH); 98 | } 99 | 100 | void loop() override { 101 | 102 | digitalWrite(LED, HIGH); 103 | delay(15); 104 | this->sen_a = analogReadMilliVolts(SENS_A); 105 | this->sen_b = analogReadMilliVolts(SENS_B); 106 | this->sen_c = analogReadMilliVolts(SENS_C); 107 | 108 | // digitalWrite(LED, LOW); 109 | // delay(5); 110 | 111 | int32_t sen_a_zero = 0; // analogReadMilliVolts(SENS_A); 112 | int32_t sen_b_zero = 0; // analogReadMilliVolts(SENS_B); 113 | int32_t sen_c_zero = 0; // analogReadMilliVolts(SENS_C); 114 | 115 | this->sen_a = this->sen_a - sen_a_zero; 116 | this->sen_b = this->sen_b - sen_b_zero; 117 | this->sen_c = this->sen_c - sen_c_zero; 118 | 119 | bool send = magic_code_box(sen_a, sen_b, sen_c); 120 | } 121 | 122 | void update() override { 123 | 124 | water_liter_sensor->publish_state(this->liter); 125 | 126 | sensa->publish_state(this->sen_a); 127 | sensb->publish_state(this->sen_b); 128 | sensc->publish_state(this->sen_c); 129 | } 130 | 131 | bool magic_code_box(int sen_a, int sen_b, int sen_c) { 132 | // * Liter berekening 133 | // * asin^2(σ)+bsin^2(σ+π/3)+c*sin^3(σ-π/3) 134 | // * a⋅sin²(σ±ε)+b⋅sin²(σ±ε+π/3)+c⋅sin²(σ±ε-π/3) 135 | 136 | static bool not_inited = true; // if start of pulse detected ignore liters overcommunicating 137 | 138 | if (not_inited) { 139 | this->state.phase = 0; 140 | this->state.fine = 0; 141 | this->state.liters = 0; 142 | 143 | this->state.a_min = sen_a; 144 | this->state.b_min = sen_b; 145 | this->state.c_min = sen_c; 146 | 147 | this->state.a_max = 0; 148 | this->state.b_max = 0; 149 | this->state.c_max = 0; 150 | not_inited = false; 151 | } 152 | 153 | float alpha_cor = 0.01; 154 | if (this->mili_liters_total < 2) { 155 | alpha_cor = 0.1; // when 2 liter not found correct harder 156 | if (this->state.liters < 0) { 157 | this->state.liters = 0; 158 | } 159 | } 160 | 161 | // * Calculate minimum value 162 | this->state.a_min = mini_average(this->state.a_min, sen_a, alpha_cor); 163 | this->state.b_min = mini_average(this->state.b_min, sen_b, alpha_cor); 164 | this->state.c_min = mini_average(this->state.c_min, sen_c, alpha_cor); 165 | 166 | this->state.a_max = max_average(this->state.a_max, sen_a, alpha_cor); 167 | this->state.b_max = max_average(this->state.b_max, sen_b, alpha_cor); 168 | this->state.c_max = max_average(this->state.c_max, sen_c, alpha_cor); 169 | 170 | int a_zc = (this->state.a_min + this->state.a_max) >> 1; 171 | int b_zc = (this->state.b_min + this->state.b_max) >> 1; 172 | int c_zc = (this->state.c_min + this->state.c_max) >> 1; 173 | 174 | int sa = sen_a - a_zc; 175 | int sb = sen_b - b_zc; 176 | int sc = sen_c - c_zc; 177 | 178 | phase_coarse_iter(sa, sb, sc); 179 | phase_fine_iter(sa, sb, sc); 180 | magnitude_offset_iter(sen_a, sen_b, sen_c); 181 | 182 | float liters_float_coarse = (float)this->state.liters + ((float)this->state.liters / 6); 183 | float liters_float_fine = (float)this->state.liters + ((float)this->state.phase / 6) + ((float)this->state.fine / (16 * 6)); 184 | 185 | uint32_t mililiters = (uint32_t)(liters_float_fine * 1000); 186 | this->mili_liters_total = mililiters; 187 | this->liter = liters_float_fine; 188 | 189 | return 1; 190 | } 191 | 192 | float mini_average(float x, float y, float alpha_cor) { 193 | 194 | if ((x + 5) <= y && y > 10) { 195 | return x; 196 | } else { 197 | return (1 - alpha_cor) * x + alpha_cor * y; 198 | } 199 | } 200 | 201 | float max_average(float x, float y, float alpha_cor) { 202 | if ((x - 5) >= y && y < 2500) { 203 | return x; 204 | } else { 205 | return (1 - alpha_cor) * x + alpha_cor * y; 206 | } 207 | } 208 | 209 | void magnitude_offset_iter(int a, int b, int c) { 210 | int8_t phase = this->state.phase; 211 | if (this->state.fine > 8) { 212 | phase = (phase + 7) % 6; 213 | } 214 | int* a_min = &this->state.a_min; 215 | int* b_min = &this->state.b_min; 216 | int* c_min = &this->state.c_min; 217 | int* a_max = &this->state.a_max; 218 | int* b_max = &this->state.b_max; 219 | int* c_max = &this->state.c_max; 220 | int* u[6] = {a_max, b_min, c_max, a_min, b_max, c_min}; 221 | int* signal[6] = {&a, &b, &c, &a, &b, &c}; 222 | if (this->state.liters > 2) { 223 | if ((this->state.fine > 14) || this->state.fine < 3) 224 | *u[phase] = (((((*u[phase]) << SMOOTHING_FACTOR) - (*u[phase]) + *signal[phase]) >> SMOOTHING_FACTOR) + 1 - (phase & 1)); 225 | } 226 | } 227 | 228 | // check with three 2pi/3 steps peak autocorrelation and adjust towards max by pi/3 steps 229 | // 2cos(0)=2 2cos(pi/3)=1 2cos(2pi/3)=-1 2cos(pi)=-2 2cos(4pi/3)=-1 2cos(5pi/3)=1 230 | void phase_coarse_iter(int a, int b, int c) { 231 | int8_t* phase = &this->state.phase; 232 | int32_t* liters = &this->state.liters; 233 | short pn[5]; 234 | if (*phase & 1) 235 | pn[0] = a + a - b - c, pn[1] = b + b - a - c, 236 | pn[2] = c + c - a - b; // same 237 | else 238 | pn[0] = b + c - a - a, // less 239 | pn[1] = a + c - b - b, // more 240 | pn[2] = a + b - c - c; // same 241 | pn[3] = pn[0], pn[4] = pn[1]; 242 | short i = *phase > 2 ? *phase - 3 : *phase; 243 | if (pn[i + 2] < pn[i + 1] && pn[i + 2] < pn[i]) 244 | if (pn[i + 1] > pn[i]) 245 | (*phase)++; 246 | else 247 | (*phase)--; 248 | if (*phase == 6) 249 | (*liters)++, *phase = 0; 250 | else if (*phase == -1) 251 | (*liters)--, *phase = 5; 252 | } 253 | 254 | int phase_fine_iter(int a, int b, int c) { 255 | const float step = (M_PI) / (3 * AC_STEPS); 256 | float array[AC_STEPS * 3 + 1]; 257 | int largest_index = -AC_STEPS; 258 | float largest = 0; 259 | 260 | for (int i = -AC_STEPS; i <= (AC_STEPS * 2); i++) { 261 | float x = (this->state.phase * M_PI / 3) + (step * i); 262 | float cora = a * cos(x); 263 | float corb = b * cos(x + (PI2_3)); 264 | float corc = c * cos(x - (PI2_3)); 265 | float cor = cora + corb + corc; 266 | array[i + AC_STEPS] = cor; 267 | if (cor > largest) { 268 | largest = cor; 269 | largest_index = i; 270 | this->state.fine = largest_index; 271 | } 272 | } 273 | return largest_index; 274 | } 275 | }; 276 | -------------------------------------------------------------------------------- /production_installer.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | docker run --rm --privileged -v ${PWD}:/config -it ghcr.io/esphome/esphome compile "muino-water-meter-esp32.yaml" 4 | while true; do 5 | if [ -e "/dev/ttyACM0" ]; then 6 | 7 | docker run --rm --privileged -v ${PWD}:/config -it ghcr.io/esphome/esphome upload --device=/dev/ttyACM0 "muino-water-meter-esp32.yaml" 8 | docker run --rm --privileged -v ${PWD}:/config -it ghcr.io/esphome/esphome logs --device=/dev/ttyACM0 "muino-water-meter-esp32.yaml" 9 | fi 10 | sleep 1 # Not needed to 100% check if something is not connected 11 | done 12 | -------------------------------------------------------------------------------- /static/_config.yml: -------------------------------------------------------------------------------- 1 | # CHANGEME: Set these variable to your liking 2 | title: ESPHome Muino Water Reader 3 | description: Powered by ESPHome and ESP Web Tools 4 | theme: jekyll-theme-slate 5 | -------------------------------------------------------------------------------- /static/data_visualiser.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | CSV Plotly Visualizer 7 | 8 | 9 | 10 | 11 |
12 |

CSV Plotly Visualizer

13 |

Upload your CSV file to visualize its data as line plots.

14 |
15 | 16 |
17 |
18 |
19 | 20 | 111 | 112 | 113 | -------------------------------------------------------------------------------- /static/img/esphome_adopt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/martijnvwezel/watermeter-esphome/465bb86c78009f5231a0ee8821007def65c542cf/static/img/esphome_adopt.png -------------------------------------------------------------------------------- /static/img/muino_watermeter.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/martijnvwezel/watermeter-esphome/465bb86c78009f5231a0ee8821007def65c542cf/static/img/muino_watermeter.jpg -------------------------------------------------------------------------------- /static/img/muino_with_case.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/martijnvwezel/watermeter-esphome/465bb86c78009f5231a0ee8821007def65c542cf/static/img/muino_with_case.png -------------------------------------------------------------------------------- /static/img/sensus_620.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/martijnvwezel/watermeter-esphome/465bb86c78009f5231a0ee8821007def65c542cf/static/img/sensus_620.png -------------------------------------------------------------------------------- /static/index.md: -------------------------------------------------------------------------------- 1 | # Muino Water-Meter Reader - sub-100 millilitre precision 2 | 3 | Water meters are devices that measure how much water you use. They have a spinning disk inside them, and each time it spins all the way around, it means you've used one liter of water. Most water meter readers use a simple method: they check if a metal disk is there or not. But the Muino water meter is different. It uses three light sensors to keep track of where the disk is. It uses some smart techniques to do this, and we use calculate with some fine adjustments to get things just right. This helps the Muino water meter measure very accurately, down to almost a millimeter. But remember, the spinning disk doesn't move perfectly like a smooth wave. So, in some parts of its rotation, the measurements might jump a bit more than in other parts. 4 | 5 | muino watermeter 6 | 7 | ## Why 8 | The Muino Smart Water Meter is a **single-board** device that measures water consumption with **sub-100 millilitre** accuracy. The other big benefit is the **easy of installation**, for friends/family that wanted a similar solution this is easier to use. 9 | 10 | 11 | # Where to buy? 12 | * If you would like the watermeter go to: [tindie-webshop](https://www.tindie.com/products/muino/smart-water-meter-reader/) or better my [own-webshop](https://muino.nl/product/smart-water-meter-reader) 13 | * You want a sensor to play with the DIY version go to: [tindie-webshop](https://www.tindie.com/products/muino/3-phase-muino-light-sensor-encoder/) or better my [own-webshop](https://muino.nl/product/3d-case-for-the-water-meter-reader) 14 | * For big orders please make a request: [email](mailto:martijnvwezel@muino.nl) 15 | * My webshop is located at [muino.nl](https://muino.nl). I haven't dedicated time to enhancing the visual appeal of my site. 16 | 17 | ### Comfirmed supported devices 18 | * KiWa V200 (Designed for) 19 | * Honeywell v200 (Designed for) 20 | * KiWa R400 (Similar to Sensus 620) 21 | * Sensus 620 (Note: Placement might appear less aesthetically pleasing because of the meters placement. The two middle holes of the Muino reader are aligned over the meter for proper attachment.) 22 | * Elster Honeywell (some) 23 | * Itron Actaris Schlumberger (Aquadis+) with double-sided tape or tie-wraps. 24 | * You can always donate to let me create a watermeter compatibility.. 25 | 26 | # First time user 27 | Thank you for buying the Muino Water Meter Reader :). So here I tried to explain the steps what to do for your installation! 28 | 29 | ## What do you need 30 | 31 | * USB-C cable that can power de water-meter 32 | * Some device with WiFi for adopting it your network 33 | * Access to your Home-assistant 34 | 35 | ## Installation steps 36 | 37 | 1. Place the Muino Smart Water Meter on your water-meter, where applicable use M2.5/M4 screws/bolts to attach. 38 | Screws are intented to fit securely/snuggly in the PCB but *do not* over thighten. Less compatible meters have no or wrong mounting holes, use tie-wraps, tape and creativity... 39 | 3. Connect the USB-C power 40 | 4. Go to your phone/wifi-device and connect to the Muino Smart Water Meter WiFi SSID (if you need a password: `12345678`) 41 | 5. Once the device connected to the Muino Smart Water Meter, go to http://192.168.4.1 and select your prefered WiFi SSID to connect the Muino Smart Water Meter with and enter the SSID passcode. 42 | 6. The Muino Smart Water Meter will try to connect to the selected WiFi SSID, please be patient. After a while, check your home network to find the IP-address of the Espressif Muino Smart Water Meter. 43 | 7. In Home Assistant, go to Settings, add the ESPHome integration, and add IP-address of the Muino Smart Water Meter to adopt it. 44 | 8. In Home Assistant, go to Energy -> Energy Configuration (3 dot menu), add the new sensor (sensor.liters) and potentially the price per cubic meter of water. 45 | 46 | 47 | ## Water Sensor Update Protocol 48 | 49 | 1. **After restart**: Upon restart, a zero value is sent to inform the home assistant that the sensor has been reset. 50 | 2. **Calibration**: The sensor calibrates during the first 2 liters of water usage. 51 | 3. **Sending Updates**: After calibration, the sensor sends updates to the home assistant system. It waits until it detects 2 liters of water usage and then pauses for 1 minute before sending the update. This prevents interruptions during activities like showering. 52 | 4. **Speed modus**: For faster updating the live values from the watermeter, what is more noisy and fills the database of your home-assistant. 53 | 5. **Debug modus**: The speed modus will be enabled and the debug json will be filled with values from the meter for debugging purpose only. 54 | 55 | #### Don't forget to Add Muino Water-Meter Reader, to your HA Energy-dashboard 56 | 57 | # Update your watermeter with a clean binary 58 | 59 | You can use the button below to install the pre-built firmware directly to your device via USB from the browser. 60 | 61 | 62 | 63 | 64 | 65 | # Wiki - for unsupported meters or meters that are stubborn 66 | [WIKI](https://github.com/martijnvwezel/watermeter-esphome/wiki) 67 | 68 | # Installations instructions 69 | 70 | ## Sensus 620 71 | muino watermeter 72 | --------------------------------------------------------------------------------