├── .devcontainer ├── configuration.yaml └── devcontainer.json ├── .flake8 ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── issue.md ├── dependabot.yml ├── labels.yml ├── release-drafter.yml └── workflows │ ├── constraints.txt │ ├── labeler.yml │ ├── release-drafter.yml │ └── tests.yaml ├── .gitignore ├── .isort.cfg ├── .pre-commit-config.yaml ├── .vscode ├── launch.json ├── settings.json └── tasks.json ├── Makefile ├── README.md ├── custom_components ├── __init__.py └── victron_ble │ ├── __init__.py │ ├── config_flow.py │ ├── const.py │ ├── device.py │ ├── manifest.json │ ├── sensor.py │ ├── strings.json │ └── translations │ └── en.json ├── hacs.json ├── mypy.ini ├── pytest.ini ├── requirements_dev.txt ├── requirements_test.txt ├── scripts └── develop └── tests ├── __init__.py ├── conftest.py └── test_config_flow.py /.devcontainer/configuration.yaml: -------------------------------------------------------------------------------- 1 | default_config: 2 | 3 | logger: 4 | default: info 5 | logs: 6 | custom_components.cc_ha_cci: debug 7 | # If you need to debug uncommment the line below (doc: https://www.home-assistant.io/integrations/debugpy/) 8 | # debugpy: 9 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // See https://aka.ms/vscode-remote/devcontainer.json for format details. 2 | { 3 | "image": "ghcr.io/ludeeus/devcontainer/integration:stable", 4 | "name": "Home Assistant Custom Component Instance integration development", 5 | "context": "..", 6 | "appPort": ["9123:8123"], 7 | "postCreateCommand": "container install", 8 | "extensions": [ 9 | "ms-python.python", 10 | "github.vscode-pull-request-github", 11 | "ryanluker.vscode-coverage-gutters", 12 | "ms-python.vscode-pylance" 13 | ], 14 | "settings": { 15 | "files.eol": "\n", 16 | "editor.tabSize": 4, 17 | "terminal.integrated.shell.linux": "/bin/bash", 18 | "python.pythonPath": "/usr/bin/python3", 19 | "python.analysis.autoSearchPaths": false, 20 | "python.linting.pylintEnabled": true, 21 | "python.linting.enabled": true, 22 | "python.formatting.provider": "black", 23 | "editor.formatOnPaste": false, 24 | "editor.formatOnSave": true, 25 | "editor.formatOnType": true, 26 | "files.trimTrailingWhitespace": true 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 160 3 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | --- 5 | 6 | **Is your feature request related to a problem? Please describe.** 7 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 8 | 9 | **Describe the solution you'd like** 10 | A clear and concise description of what you want to happen. 11 | 12 | **Describe alternatives you've considered** 13 | A clear and concise description of any alternative solutions or features you've considered. 14 | 15 | **Additional context** 16 | Add any other context or screenshots about the feature request here. 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/issue.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Issue 3 | about: Create a report to help us improve 4 | --- 5 | 6 | 15 | 16 | ## Version of the custom_component 17 | 18 | 21 | 22 | ## Configuration 23 | 24 | ```yaml 25 | Add your logs here. 26 | ``` 27 | 28 | ## Describe the bug 29 | 30 | A clear and concise description of what the bug is. 31 | 32 | ## Debug log 33 | 34 | 35 | 36 | ```text 37 | 38 | Add your logs here. 39 | 40 | ``` 41 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | - package-ecosystem: pip 8 | directory: "/.github/workflows" 9 | schedule: 10 | interval: daily 11 | - package-ecosystem: pip 12 | directory: "/" 13 | schedule: 14 | interval: daily 15 | -------------------------------------------------------------------------------- /.github/labels.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Labels names are important as they are used by Release Drafter to decide 3 | # regarding where to record them in changelog or if to skip them. 4 | # 5 | # The repository labels will be automatically configured using this file and 6 | # the GitHub Action https://github.com/marketplace/actions/github-labeler. 7 | - name: breaking 8 | description: Breaking Changes 9 | color: bfd4f2 10 | - name: bug 11 | description: Something isn't working 12 | color: d73a4a 13 | - name: build 14 | description: Build System and Dependencies 15 | color: bfdadc 16 | - name: ci 17 | description: Continuous Integration 18 | color: 4a97d6 19 | - name: dependencies 20 | description: Pull requests that update a dependency file 21 | color: 0366d6 22 | - name: documentation 23 | description: Improvements or additions to documentation 24 | color: 0075ca 25 | - name: duplicate 26 | description: This issue or pull request already exists 27 | color: cfd3d7 28 | - name: enhancement 29 | description: New feature or request 30 | color: a2eeef 31 | - name: github_actions 32 | description: Pull requests that update Github_actions code 33 | color: "000000" 34 | - name: good first issue 35 | description: Good for newcomers 36 | color: 7057ff 37 | - name: help wanted 38 | description: Extra attention is needed 39 | color: 008672 40 | - name: invalid 41 | description: This doesn't seem right 42 | color: e4e669 43 | - name: performance 44 | description: Performance 45 | color: "016175" 46 | - name: python 47 | description: Pull requests that update Python code 48 | color: 2b67c6 49 | - name: question 50 | description: Further information is requested 51 | color: d876e3 52 | - name: refactoring 53 | description: Refactoring 54 | color: ef67c4 55 | - name: removal 56 | description: Removals and Deprecations 57 | color: 9ae7ea 58 | - name: style 59 | description: Style 60 | color: c120e5 61 | - name: testing 62 | description: Testing 63 | color: b1fc6f 64 | - name: wontfix 65 | description: This will not be worked on 66 | color: ffffff 67 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | categories: 2 | - title: ":boom: Breaking Changes" 3 | label: "breaking" 4 | - title: ":rocket: Features" 5 | label: "enhancement" 6 | - title: ":fire: Removals and Deprecations" 7 | label: "removal" 8 | - title: ":beetle: Fixes" 9 | label: "bug" 10 | - title: ":racehorse: Performance" 11 | label: "performance" 12 | - title: ":rotating_light: Testing" 13 | label: "testing" 14 | - title: ":construction_worker: Continuous Integration" 15 | label: "ci" 16 | - title: ":books: Documentation" 17 | label: "documentation" 18 | - title: ":hammer: Refactoring" 19 | label: "refactoring" 20 | - title: ":lipstick: Style" 21 | label: "style" 22 | - title: ":package: Dependencies" 23 | labels: 24 | - "dependencies" 25 | - "build" 26 | template: | 27 | ## Changes 28 | 29 | $CHANGES 30 | -------------------------------------------------------------------------------- /.github/workflows/constraints.txt: -------------------------------------------------------------------------------- 1 | pip==23.0.1 2 | pre-commit==3.1.1 3 | black==23.1.0 4 | flake8==6.0.0 5 | reorder-python-imports==3.9.0 6 | -------------------------------------------------------------------------------- /.github/workflows/labeler.yml: -------------------------------------------------------------------------------- 1 | name: Manage labels 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - master 8 | 9 | jobs: 10 | labeler: 11 | name: Labeler 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Check out the repository 15 | uses: actions/checkout@v3.3.0 16 | 17 | - name: Run Labeler 18 | uses: crazy-max/ghaction-github-labeler@v4.1.0 19 | with: 20 | skip-delete: true 21 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name: Draft a release note 2 | on: 3 | push: 4 | branches: 5 | - main 6 | - master 7 | jobs: 8 | draft_release: 9 | name: Release Drafter 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Run release-drafter 13 | uses: release-drafter/release-drafter@v5.23.0 14 | env: 15 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 16 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: Linting 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - master 8 | - dev 9 | pull_request: 10 | schedule: 11 | - cron: "0 0 * * *" 12 | 13 | env: 14 | DEFAULT_PYTHON: "3.10" 15 | 16 | jobs: 17 | pre-commit: 18 | runs-on: "ubuntu-latest" 19 | name: Pre-commit 20 | steps: 21 | - name: Check out the repository 22 | uses: actions/checkout@v3.3.0 23 | 24 | - name: Set up Python ${{ env.DEFAULT_PYTHON }} 25 | uses: actions/setup-python@v4.5.0 26 | with: 27 | python-version: ${{ env.DEFAULT_PYTHON }} 28 | 29 | - name: Upgrade pip 30 | run: | 31 | pip install --constraint=.github/workflows/constraints.txt pip 32 | pip --version 33 | 34 | - name: Install Python modules 35 | run: | 36 | pip install --constraint=.github/workflows/constraints.txt pre-commit black flake8 reorder-python-imports 37 | 38 | - name: Run pre-commit on all files 39 | run: | 40 | pre-commit run --all-files --show-diff-on-failure --color=always 41 | 42 | hacs: 43 | runs-on: "ubuntu-latest" 44 | name: HACS 45 | steps: 46 | - name: Check out the repository 47 | uses: "actions/checkout@v3.3.0" 48 | 49 | - name: HACS validation 50 | uses: "hacs/action@22.5.0" 51 | with: 52 | category: "integration" 53 | ignore: brands 54 | 55 | hassfest: 56 | runs-on: "ubuntu-latest" 57 | name: Hassfest 58 | steps: 59 | - name: Check out the repository 60 | uses: "actions/checkout@v3.3.0" 61 | 62 | - name: Hassfest validation 63 | uses: "home-assistant/actions/hassfest@master" 64 | tests: 65 | runs-on: "ubuntu-latest" 66 | name: Run tests 67 | steps: 68 | - name: Check out code from GitHub 69 | uses: "actions/checkout@v3.3.0" 70 | - name: Setup Python ${{ env.DEFAULT_PYTHON }} 71 | uses: "actions/setup-python@v4.5.0" 72 | with: 73 | python-version: ${{ env.DEFAULT_PYTHON }} 74 | - name: Install requirements 75 | run: | 76 | pip install --constraint=.github/workflows/constraints.txt pip 77 | pip install -r requirements_test.txt 78 | - name: Tests suite 79 | run: | 80 | pytest \ 81 | --timeout=9 \ 82 | --durations=10 \ 83 | -n auto \ 84 | -p no:sugar \ 85 | --fixtures tests/ 86 | tests/ 87 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # artifacts 2 | __pycache__ 3 | .pytest* 4 | *.egg-info 5 | */build/* 6 | */dist/* 7 | 8 | 9 | # misc 10 | .venv 11 | .coverage 12 | .vscode 13 | coverage.xml 14 | 15 | 16 | # Home Assistant configuration 17 | .cloud 18 | .HA_VERSION 19 | .storage 20 | automations.yaml 21 | blueprints 22 | configuration.yaml 23 | deps 24 | home-assistant_v2* 25 | home-assistant.log* 26 | tts 27 | scenes.yaml 28 | scripts.yaml 29 | secrets.yaml 30 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [tool.isort] 2 | profile = "black" 3 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v3.3.0 4 | hooks: 5 | - id: check-added-large-files 6 | - id: check-yaml 7 | - id: end-of-file-fixer 8 | - id: trailing-whitespace 9 | - repo: local 10 | hooks: 11 | - id: black 12 | name: black 13 | entry: black 14 | language: system 15 | types: [python] 16 | require_serial: true 17 | - id: flake8 18 | name: flake8 19 | entry: flake8 20 | language: system 21 | types: [python] 22 | require_serial: true 23 | - repo: https://github.com/pycqa/isort 24 | rev: 5.12.0 25 | hooks: 26 | - id: isort 27 | name: isort (python) 28 | args: ["--profile", "black"] 29 | - repo: https://github.com/pre-commit/mirrors-prettier 30 | rev: v2.2.1 31 | hooks: 32 | - id: prettier 33 | - repo: https://github.com/pre-commit/mirrors-mypy 34 | rev: v1.0.1 35 | hooks: 36 | - id: mypy 37 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 3 | "version": "0.2.0", 4 | "configurations": [ 5 | { 6 | // Example of attaching to local debug server 7 | "name": "Python: Attach Local", 8 | "type": "python", 9 | "request": "attach", 10 | "port": 5678, 11 | "host": "localhost", 12 | "pathMappings": [ 13 | { 14 | "localRoot": "${workspaceFolder}", 15 | "remoteRoot": "." 16 | } 17 | ] 18 | }, 19 | { 20 | // Example of attaching to my production server 21 | "name": "Python: Attach Remote", 22 | "type": "python", 23 | "request": "attach", 24 | "port": 5678, 25 | "host": "homeassistant.local", 26 | "pathMappings": [ 27 | { 28 | "localRoot": "${workspaceFolder}", 29 | "remoteRoot": "/usr/src/homeassistant" 30 | } 31 | ] 32 | } 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.linting.pylintEnabled": true, 3 | "python.linting.enabled": true, 4 | "python.pythonPath": "venv/bin/python", 5 | "files.associations": { 6 | "*.yaml": "home-assistant" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "Run Home Assistant on port 9123", 6 | "type": "shell", 7 | "command": "container start", 8 | "problemMatcher": [] 9 | }, 10 | { 11 | "label": "Run Home Assistant configuration against /config", 12 | "type": "shell", 13 | "command": "container check", 14 | "problemMatcher": [] 15 | }, 16 | { 17 | "label": "Upgrade Home Assistant to latest dev", 18 | "type": "shell", 19 | "command": "container install", 20 | "problemMatcher": [] 21 | }, 22 | { 23 | "label": "Install a specific version of Home Assistant", 24 | "type": "shell", 25 | "command": "container set-version", 26 | "problemMatcher": [] 27 | } 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .ONESHELL: 2 | ENV_PREFIX=$(shell python -c "if __import__('pathlib').Path('.venv/bin/pip').exists(): print('.venv/bin/')") 3 | USING_POETRY=$(shell grep "tool.poetry" pyproject.toml && echo "yes") 4 | 5 | .PHONY: help 6 | help: ## Show the help. 7 | @echo "Usage: make " 8 | @echo "" 9 | @echo "Targets:" 10 | @fgrep "##" Makefile | fgrep -v fgrep 11 | 12 | 13 | .PHONY: show 14 | show: ## Show the current environment. 15 | @echo "Current environment:" 16 | @if [ "$(USING_POETRY)" ]; then poetry env info && exit; fi 17 | @echo "Running using $(ENV_PREFIX)" 18 | @$(ENV_PREFIX)python -V 19 | @$(ENV_PREFIX)python -m site 20 | 21 | .PHONY: install 22 | install: ## Install the project in dev mode. 23 | @if [ "$(USING_POETRY)" ]; then poetry install && exit; fi 24 | @echo "Don't forget to run 'make virtualenv' if you got errors." 25 | $(ENV_PREFIX)pip install -e .[test] 26 | 27 | .PHONY: fmt 28 | fmt: ## Format code using black & isort. 29 | $(ENV_PREFIX)isort custom_components/ 30 | $(ENV_PREFIX)black custom_components/ 31 | $(ENV_PREFIX)black tests/ 32 | 33 | .PHONY: fmt lint 34 | lint: ## Run pep8, black, mypy linters. 35 | $(ENV_PREFIX)flake8 custom_components/ 36 | $(ENV_PREFIX)black --check custom_components/ 37 | $(ENV_PREFIX)black --check tests/ 38 | $(ENV_PREFIX)mypy --ignore-missing-imports custom_components/ 39 | 40 | .PHONY: test 41 | test: fmt lint ## Run tests and generate coverage report. 42 | $(ENV_PREFIX)pytest -v --cov-config .coveragerc --cov=custom_components -l --tb=short --maxfail=1 tests/ 43 | $(ENV_PREFIX)coverage xml 44 | $(ENV_PREFIX)coverage html 45 | 46 | .PHONY: watch 47 | watch: ## Run tests on every change. 48 | ls **/**.py | entr $(ENV_PREFIX)pytest -s -vvv -l --tb=long --maxfail=1 tests/ 49 | 50 | .PHONY: clean 51 | clean: ## Clean unused files. 52 | @find ./ -name '*.pyc' -exec rm -f {} \; 53 | @find ./ -name '__pycache__' -exec rm -rf {} \; 54 | @find ./ -name 'Thumbs.db' -exec rm -f {} \; 55 | @find ./ -name '*~' -exec rm -f {} \; 56 | @rm -rf .cache 57 | @rm -rf .pytest_cache 58 | @rm -rf .mypy_cache 59 | @rm -rf build 60 | @rm -rf dist 61 | @rm -rf *.egg-info 62 | @rm -rf htmlcov 63 | @rm -rf .tox/ 64 | @rm -rf docs/_build 65 | 66 | .PHONY: virtualenv 67 | virtualenv: ## Create a virtual environment. 68 | @if [ "$(USING_POETRY)" ]; then poetry install && exit; fi 69 | @echo "creating virtualenv ..." 70 | @rm -rf .venv 71 | @python3 -m venv .venv 72 | @./.venv/bin/pip install -U pip 73 | @./.venv/bin/pip install -e .[test] 74 | @echo 75 | @echo "!!! Please run 'source .venv/bin/activate' to enable the environment !!!" 76 | 77 | .PHONY: release 78 | release: ## Create a new tag for release. 79 | @echo "WARNING: This operation will create s version tag and push to github" 80 | @read -p "Version? (provide the next x.y.z semver) : " TAG 81 | @echo "$${TAG}" > custom_components/VERSION 82 | @$(ENV_PREFIX)gitchangelog > HISTORY.md 83 | @git add custom_components/VERSION HISTORY.md 84 | @git commit -m "release: version $${TAG} 🚀" 85 | @echo "creating git tag : $${TAG}" 86 | @git tag $${TAG} 87 | @git push -u origin HEAD --tags 88 | @echo "Github Actions will detect the new tag and release the new version." 89 | 90 | .PHONY: docs 91 | docs: ## Build the documentation. 92 | @echo "building documentation ..." 93 | @$(ENV_PREFIX)mkdocs build 94 | URL="site/index.html"; open $$URL || xdg-open $$URL || sensible-browser $$URL || x-www-browser $$URL || gnome-open $$URL 95 | 96 | .PHONY: init 97 | init: ## Initialize the project based on an application template. 98 | @./.github/init.sh 99 | 100 | 101 | # This project has been generated from rochacbruno/python-project-template 102 | # __author__ = 'rochacbruno' 103 | # __repo__ = https://github.com/rochacbruno/python-project-template 104 | # __sponsor__ = https://github.com/sponsors/rochacbruno/ 105 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![hacs_badge](https://img.shields.io/badge/HACS-Default-41BDF5.svg?style=for-the-badge)](https://github.com/hacs/integration) 2 | 3 | # Victron Instant Readout Integration 4 | 5 | This integration allows exposing data from Victron devices with Instant Readout enabled in Home Assistant. 6 | 7 | Supported Devices & Entities: 8 | 9 | - SmartShunt 500A/500mv and BMV-712/702 provide the following data: 10 | - Voltage 11 | - Alarm status 12 | - Current 13 | - Remaining time (mins) 14 | - State of charge (%) 15 | - Consumed amp hours 16 | - Auxilary input mode and value (temperature, midpoint voltage, or starter battery voltage) 17 | - Smart Battery Sense 18 | - Voltage 19 | - Temperature (°C) 20 | - Smart Battery Protect 21 | - Input Voltage 22 | - Output Voltage 23 | - Output State 24 | - Device State 25 | - Charger Error 26 | - Alarm Reason 27 | - Warning Reason 28 | - Off Reason 29 | - MPPT/Solar Charger 30 | - Charger State (Off, Bulk, Absorption, Float) 31 | - Battery Voltage (V) 32 | - Battery Charging Current (A) 33 | - Solar Power (W) 34 | - Yield Today (Wh) 35 | - External Device Load (A) 36 | - DC/DC Charger 37 | - Input Voltage 38 | - Output Voltage 39 | - Operation Mode 40 | - Charger Error 41 | - Off Reason 42 | - AC Charger 43 | - Output Voltage 1|2|3 44 | - Output Current 1|2|3 45 | - Operation Mode 46 | - Temperature (°C) 47 | - AC Current 48 | 49 | # Installation 50 | 51 | ## Manual 52 | 53 | 1. Clone the repository to your machine and copy the contents of custom_components/ to your config directory 54 | 2. Restart Home Assistant 55 | 3. Setup integration via the integration page. 56 | 57 | ## HACS 58 | 59 | 1. Add the integration through this link: 60 | [![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=keshavdv&repository=victron-hacs&category=integration) 61 | 2. Restart Home Assistant 62 | 3. Setup integration via the integration page. 63 | -------------------------------------------------------------------------------- /custom_components/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/keshavdv/victron-hacs/582059ba16950ed14e4d80eba47a9d43acd3ff97/custom_components/__init__.py -------------------------------------------------------------------------------- /custom_components/victron_ble/__init__.py: -------------------------------------------------------------------------------- 1 | """The victron_ble integration.""" 2 | from __future__ import annotations 3 | 4 | import logging 5 | 6 | from homeassistant.components.bluetooth import BluetoothScanningMode 7 | from homeassistant.components.bluetooth.passive_update_processor import ( 8 | PassiveBluetoothProcessorCoordinator, 9 | ) 10 | from homeassistant.config_entries import ConfigEntry 11 | from homeassistant.const import Platform 12 | from homeassistant.core import HomeAssistant 13 | 14 | from .const import DOMAIN 15 | from .device import VictronBluetoothDeviceData 16 | 17 | PLATFORMS: list[Platform] = [Platform.SENSOR] 18 | 19 | _LOGGER = logging.getLogger(__name__) 20 | 21 | 22 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 23 | """Set up Victron BLE device from a config entry.""" 24 | address = entry.unique_id 25 | assert address is not None 26 | data = VictronBluetoothDeviceData(entry.data["key"]) 27 | coordinator = hass.data.setdefault(DOMAIN, {})[ 28 | entry.entry_id 29 | ] = PassiveBluetoothProcessorCoordinator( 30 | hass, 31 | _LOGGER, 32 | address=address, 33 | mode=BluetoothScanningMode.ACTIVE, 34 | update_method=data.update, 35 | ) 36 | await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 37 | entry.async_on_unload( 38 | coordinator.async_start() 39 | ) # only start after all platforms have had a chance to subscribe 40 | return True 41 | 42 | 43 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 44 | """Unload a config entry.""" 45 | if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): 46 | hass.data[DOMAIN].pop(entry.entry_id) 47 | 48 | return unload_ok 49 | -------------------------------------------------------------------------------- /custom_components/victron_ble/config_flow.py: -------------------------------------------------------------------------------- 1 | """Config flow for victron_ble integration.""" 2 | from __future__ import annotations 3 | 4 | import logging 5 | from typing import Any 6 | 7 | import voluptuous as vol 8 | from homeassistant import config_entries 9 | from homeassistant.components.bluetooth import BluetoothServiceInfoBleak 10 | from homeassistant.data_entry_flow import FlowResult 11 | from homeassistant.exceptions import HomeAssistantError 12 | 13 | from .const import DOMAIN 14 | 15 | _LOGGER = logging.getLogger(__name__) 16 | 17 | STEP_USER_DATA_SCHEMA = vol.Schema( 18 | { 19 | vol.Required("Title"): str, 20 | vol.Required("key"): str, 21 | } 22 | ) 23 | 24 | 25 | class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # type: ignore[call-arg] 26 | """Handle a config flow for victron_ble.""" 27 | 28 | VERSION = 1 29 | 30 | async def async_step_bluetooth( 31 | self, discovery_info: BluetoothServiceInfoBleak 32 | ) -> FlowResult: 33 | """Handle a flow initialized by bluetooth discovery.""" 34 | _LOGGER.debug(discovery_info) 35 | self.context["discovery_info"] = { 36 | "name": discovery_info.name, 37 | "address": discovery_info.address, 38 | } 39 | await self.async_set_unique_id(discovery_info.address) 40 | self._abort_if_unique_id_configured() 41 | return await self.async_step_user() 42 | 43 | async def async_step_user( 44 | self, user_input: dict[str, Any] | None = None 45 | ) -> FlowResult: 46 | """User setup.""" 47 | if user_input is None: 48 | name = None 49 | address = None 50 | 51 | discovery_info = self.context.get("discovery_info") 52 | if discovery_info: 53 | name = self.context["discovery_info"]["name"] 54 | address = self.context["discovery_info"]["address"] 55 | 56 | return self.async_show_form( 57 | step_id="user", 58 | data_schema=vol.Schema( 59 | { 60 | vol.Required("name", default=name): str, 61 | vol.Required("address", default=address): str, 62 | vol.Required("key"): str, 63 | } 64 | ), 65 | ) 66 | 67 | await self.async_set_unique_id(user_input["address"]) 68 | self._abort_if_unique_id_configured() 69 | return self.async_create_entry(title=user_input["name"], data=user_input) 70 | 71 | async def async_step_unignore(self, user_input): 72 | unique_id = user_input["unique_id"] 73 | await self.async_set_unique_id(unique_id) 74 | self.async_abort(reason="discovery_error") 75 | 76 | 77 | class CannotConnect(HomeAssistantError): 78 | """Error to indicate we cannot connect.""" 79 | 80 | 81 | class InvalidAuth(HomeAssistantError): 82 | """Error to indicate there is invalid auth.""" 83 | -------------------------------------------------------------------------------- /custom_components/victron_ble/const.py: -------------------------------------------------------------------------------- 1 | """Constants for the victron_ble integration.""" 2 | 3 | DOMAIN = "victron_ble" 4 | -------------------------------------------------------------------------------- /custom_components/victron_ble/device.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from bluetooth_sensor_state_data import BluetoothData 4 | from homeassistant.components.sensor import SensorDeviceClass 5 | from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo 6 | from sensor_state_data import SensorLibrary 7 | from sensor_state_data.enum import StrEnum 8 | from sensor_state_data.units import Units 9 | from victron_ble.devices import detect_device_type 10 | from victron_ble.devices.ac_charger import AcChargerData 11 | from victron_ble.devices.battery_monitor import AuxMode, BatteryMonitorData 12 | from victron_ble.devices.battery_sense import BatterySenseData 13 | from victron_ble.devices.dc_energy_meter import DcEnergyMeterData 14 | from victron_ble.devices.dcdc_converter import DcDcConverterData 15 | from victron_ble.devices.smart_battery_protect import SmartBatteryProtectData 16 | from victron_ble.devices.solar_charger import SolarChargerData 17 | 18 | _LOGGER = logging.getLogger(__name__) 19 | 20 | 21 | class VictronSensor(StrEnum): 22 | AUX_MODE = "aux_mode" 23 | OPERATION_MODE = "operation_mode" 24 | EXTERNAL_DEVICE_LOAD = "external_device_load" 25 | YIELD_TODAY = "yield_today" 26 | INPUT_VOLTAGE = "input_voltage" 27 | OUTPUT_VOLTAGE = "output_voltage" 28 | OUTPUT_CURRENT = "output_current" 29 | OUTPUT_POWER = "output_power" 30 | OFF_REASON = "off_reason" 31 | CHARGER_ERROR = "charger_error" 32 | STARTER_BATTERY_VOLTAGE = "starter_battery_voltage" 33 | MIDPOINT_VOLTAGE = "midpoint_voltage" 34 | TIME_REMAINING = "time_remaining" 35 | CONSUMED_ENERGY = "consumed_energy" 36 | ALARM_REASON = "alarm_reason" 37 | WARNING_REASON = "warning_reason" 38 | DEVICE_STATE = "device_state" 39 | OUTPUT_STATE = "output_state" 40 | 41 | 42 | class VictronBluetoothDeviceData(BluetoothData): 43 | """Data for Victron BLE sensors.""" 44 | 45 | def __init__(self, key) -> None: 46 | """Initialize the class.""" 47 | super().__init__() 48 | self.key = key 49 | 50 | def _start_update(self, service_info: BluetoothServiceInfo) -> None: 51 | """Update from BLE advertisement data.""" 52 | _LOGGER.debug( 53 | "Parsing Victron BLE advertisement data: %s", service_info.manufacturer_data 54 | ) 55 | manufacturer_data = service_info.manufacturer_data 56 | service_uuids = service_info.service_uuids 57 | local_name = service_info.name 58 | address = service_info.address 59 | self.set_device_name(local_name) 60 | self.set_device_manufacturer("Victron") 61 | 62 | self.set_precision(2) 63 | 64 | for mfr_id, mfr_data in manufacturer_data.items(): 65 | if mfr_id != 0x02E1 or not mfr_data.startswith(b"\x10"): 66 | continue 67 | self._process_mfr_data(address, local_name, mfr_id, mfr_data, service_uuids) 68 | 69 | def _process_mfr_data( 70 | self, 71 | address: str, 72 | local_name: str, 73 | mfr_id: int, 74 | data: bytes, 75 | service_uuids: list[str], 76 | ) -> None: 77 | """Parser for Victron sensors.""" 78 | device_parser = detect_device_type(data) 79 | if not device_parser: 80 | _LOGGER.error("Could not identify Victron device type") 81 | return 82 | parsed = device_parser(self.key).parse(data) 83 | _LOGGER.debug(f"Handle Victron BLE advertisement data: {parsed._data}") 84 | self.set_device_type(parsed.get_model_name()) 85 | 86 | if isinstance(parsed, DcEnergyMeterData): 87 | # missing metrics that are available in victron_ble: meter_type, alarm, aux_mode, temperature, starter_voltage 88 | self.update_predefined_sensor( 89 | SensorLibrary.VOLTAGE__ELECTRIC_POTENTIAL_VOLT, parsed.get_voltage() 90 | ) 91 | self.update_predefined_sensor( 92 | SensorLibrary.CURRENT__ELECTRIC_CURRENT_AMPERE, parsed.get_current() 93 | ) 94 | elif isinstance(parsed, AcChargerData): 95 | self.update_sensor( 96 | key=VictronSensor.OPERATION_MODE, 97 | native_unit_of_measurement=None, 98 | native_value=parsed.get_charge_state().name.lower(), 99 | device_class=SensorDeviceClass.ENUM, 100 | ) 101 | self.update_sensor( 102 | key=VictronSensor.CHARGER_ERROR, 103 | native_unit_of_measurement=None, 104 | native_value=parsed.get_charger_error().name.lower(), 105 | device_class=SensorDeviceClass.ENUM, 106 | ) 107 | self.update_sensor( 108 | key=VictronSensor.OUTPUT_VOLTAGE, 109 | name="Output Voltage 1", 110 | native_unit_of_measurement=Units.ELECTRIC_POTENTIAL_VOLT, 111 | native_value=parsed.get_output_voltage1(), 112 | device_class=SensorDeviceClass.VOLTAGE, 113 | ) 114 | # self.update_sensor( 115 | # key=VictronSensor.OUTPUT_VOLTAGE, 116 | # name="Output Voltage 2", 117 | # native_unit_of_measurement=Units.ELECTRIC_POTENTIAL_VOLT, 118 | # native_value=parsed.get_output_voltage2(), 119 | # device_class=SensorDeviceClass.VOLTAGE, 120 | # ) 121 | # self.update_sensor( 122 | # key=VictronSensor.OUTPUT_VOLTAGE, 123 | # name="Output Voltage 3", 124 | # native_unit_of_measurement=Units.ELECTRIC_POTENTIAL_VOLT, 125 | # native_value=parsed.get_output_voltage3(), 126 | # device_class=SensorDeviceClass.VOLTAGE, 127 | # ) 128 | self.update_sensor( 129 | key=VictronSensor.OUTPUT_CURRENT, 130 | name="Output Current 1", 131 | native_unit_of_measurement=Units.ELECTRIC_CURRENT_AMPERE, 132 | native_value=parsed.get_output_current1(), 133 | device_class=SensorDeviceClass.CURRENT, 134 | ) 135 | # self.update_sensor( 136 | # key=VictronSensor.OUTPUT_CURRENT, 137 | # name="Output Current 2", 138 | # native_unit_of_measurement=Units.ELECTRIC_CURRENT_AMPERE, 139 | # native_value=parsed.get_output_current2(), 140 | # device_class=SensorDeviceClass.CURRENT, 141 | # ) 142 | # self.update_sensor( 143 | # key=VictronSensor.OUTPUT_CURRENT, 144 | # name="Output Current 3", 145 | # native_unit_of_measurement=Units.ELECTRIC_CURRENT_AMPERE, 146 | # native_value=parsed.get_output_current3(), 147 | # device_class=SensorDeviceClass.CURRENT, 148 | # ) 149 | self.update_predefined_sensor( 150 | base_description=SensorLibrary.TEMPERATURE__CELSIUS, 151 | native_value=parsed.get_temperature(), 152 | name="Temperature", 153 | ) 154 | self.update_predefined_sensor( 155 | base_description=SensorLibrary.CURRENT__ELECTRIC_CURRENT_AMPERE, 156 | native_value=parsed.get_ac_current(), 157 | name="AC Current", 158 | ) 159 | 160 | # Additional Sensor 161 | self.update_sensor( 162 | key=VictronSensor.OUTPUT_POWER, 163 | name="Output Power 1", 164 | native_unit_of_measurement=Units.POWER_WATT, 165 | native_value=parsed.get_output_current1() * parsed.get_output_voltage1(), 166 | device_class=SensorDeviceClass.POWER, 167 | ) 168 | 169 | 170 | elif isinstance(parsed, SmartBatteryProtectData): 171 | self.update_sensor( 172 | key=VictronSensor.DEVICE_STATE, 173 | name="Device State", 174 | native_unit_of_measurement=None, 175 | native_value=parsed.get_device_state().name.lower(), 176 | device_class=SensorDeviceClass.ENUM, 177 | ) 178 | self.update_sensor( 179 | key=VictronSensor.OUTPUT_STATE, 180 | name="Output State", 181 | native_unit_of_measurement=None, 182 | native_value=parsed.get_output_state().name.lower(), 183 | device_class=SensorDeviceClass.ENUM, 184 | ) 185 | self.update_sensor( 186 | key=VictronSensor.CHARGER_ERROR, 187 | native_unit_of_measurement=None, 188 | native_value=parsed.get_error_code().name.lower(), 189 | device_class=SensorDeviceClass.ENUM, 190 | ) 191 | self.update_sensor( 192 | key=VictronSensor.ALARM_REASON, 193 | name="Alarm Reason", 194 | native_unit_of_measurement=None, 195 | native_value=parsed.get_alarm_reason().name.lower(), 196 | device_class=SensorDeviceClass.ENUM, 197 | ) 198 | self.update_sensor( 199 | key=VictronSensor.WARNING_REASON, 200 | name="Warning Reason", 201 | native_unit_of_measurement=None, 202 | native_value=parsed.get_warning_reason().name.lower(), 203 | device_class=SensorDeviceClass.ENUM, 204 | ) 205 | self.update_sensor( 206 | key=VictronSensor.INPUT_VOLTAGE, 207 | name="Input Voltage", 208 | native_unit_of_measurement=Units.ELECTRIC_POTENTIAL_VOLT, 209 | native_value=parsed.get_input_voltage(), 210 | device_class=SensorDeviceClass.VOLTAGE, 211 | ) 212 | self.update_sensor( 213 | key=VictronSensor.OUTPUT_VOLTAGE, 214 | name="Output Voltage", 215 | native_unit_of_measurement=Units.ELECTRIC_POTENTIAL_VOLT, 216 | native_value=parsed.get_output_voltage(), 217 | device_class=SensorDeviceClass.VOLTAGE, 218 | ) 219 | self.update_sensor( 220 | key=VictronSensor.OFF_REASON, 221 | native_unit_of_measurement=None, 222 | native_value=parsed.get_off_reason().name.lower(), 223 | device_class=SensorDeviceClass.ENUM, 224 | ) 225 | elif isinstance(parsed, BatteryMonitorData): 226 | self.update_predefined_sensor( 227 | SensorLibrary.VOLTAGE__ELECTRIC_POTENTIAL_VOLT, parsed.get_voltage() 228 | ) 229 | self.update_predefined_sensor( 230 | SensorLibrary.CURRENT__ELECTRIC_CURRENT_AMPERE, parsed.get_current() 231 | ) 232 | self.update_predefined_sensor( 233 | SensorLibrary.BATTERY__PERCENTAGE, parsed.get_soc() 234 | ) 235 | 236 | self.update_sensor( 237 | key=VictronSensor.ALARM_REASON, 238 | name="Alarm Reason", 239 | native_unit_of_measurement=None, 240 | native_value=parsed.get_alarm().name.lower(), 241 | device_class=SensorDeviceClass.ENUM, 242 | ) 243 | self.update_sensor( 244 | key=VictronSensor.TIME_REMAINING, 245 | name="Time remaining", 246 | native_unit_of_measurement=Units.TIME_MINUTES, 247 | native_value=parsed.get_remaining_mins(), 248 | device_class=SensorDeviceClass.DURATION, 249 | ) 250 | 251 | aux_mode = parsed.get_aux_mode() 252 | self.update_sensor( 253 | key=VictronSensor.AUX_MODE, 254 | name="Auxilliary Input Mode", 255 | native_unit_of_measurement=None, 256 | native_value=aux_mode.name.lower(), 257 | device_class=SensorDeviceClass.ENUM, 258 | ) 259 | if aux_mode == AuxMode.MIDPOINT_VOLTAGE: 260 | self.update_sensor( 261 | key=VictronSensor.MIDPOINT_VOLTAGE, 262 | native_unit_of_measurement=Units.ELECTRIC_POTENTIAL_VOLT, 263 | native_value=parsed.get_midpoint_voltage(), 264 | device_class=SensorDeviceClass.VOLTAGE, 265 | ) 266 | elif aux_mode == AuxMode.STARTER_VOLTAGE: 267 | self.update_sensor( 268 | key=VictronSensor.STARTER_BATTERY_VOLTAGE, 269 | native_unit_of_measurement=Units.ELECTRIC_POTENTIAL_VOLT, 270 | native_value=parsed.get_starter_voltage(), 271 | device_class=SensorDeviceClass.VOLTAGE, 272 | ) 273 | elif aux_mode == AuxMode.TEMPERATURE: 274 | self.update_predefined_sensor( 275 | SensorLibrary.TEMPERATURE__CELSIUS, parsed.get_temperature() 276 | ) 277 | 278 | # Additional Sensor 279 | self.update_sensor( 280 | key=VictronSensor.OUTPUT_POWER, 281 | name="Power", 282 | native_unit_of_measurement=Units.POWER_WATT, 283 | native_value=parsed.get_voltage() * parsed.get_current(), 284 | device_class=SensorDeviceClass.POWER, 285 | ) 286 | self.update_sensor( 287 | key=VictronSensor.CONSUMED_ENERGY, 288 | name="Consumed Energy", 289 | native_unit_of_measurement=Units.ENERGY_WATT_HOUR, 290 | native_value=parsed.get_voltage() * parsed.get_consumed_ah() * -1, 291 | device_class=SensorDeviceClass.ENERGY, 292 | ) 293 | elif isinstance(parsed, BatterySenseData): 294 | self.update_predefined_sensor( 295 | SensorLibrary.TEMPERATURE__CELSIUS, parsed.get_temperature() 296 | ) 297 | self.update_predefined_sensor( 298 | SensorLibrary.VOLTAGE__ELECTRIC_POTENTIAL_VOLT, parsed.get_voltage() 299 | ) 300 | elif isinstance(parsed, SolarChargerData): 301 | self.update_predefined_sensor( 302 | SensorLibrary.POWER__POWER_WATT, parsed.get_solar_power() 303 | ) 304 | self.update_predefined_sensor( 305 | SensorLibrary.VOLTAGE__ELECTRIC_POTENTIAL_VOLT, 306 | parsed.get_battery_voltage(), 307 | ) 308 | self.update_predefined_sensor( 309 | SensorLibrary.CURRENT__ELECTRIC_CURRENT_AMPERE, 310 | parsed.get_battery_charging_current(), 311 | ) 312 | self.update_sensor( 313 | key=VictronSensor.YIELD_TODAY, 314 | native_unit_of_measurement=Units.ENERGY_WATT_HOUR, 315 | native_value=parsed.get_yield_today(), 316 | device_class=SensorDeviceClass.CURRENT, 317 | ) 318 | self.update_sensor( 319 | key=VictronSensor.OPERATION_MODE, 320 | native_unit_of_measurement=None, 321 | native_value=parsed.get_charge_state().name.lower(), 322 | device_class=SensorDeviceClass.ENUM, 323 | ) 324 | self.update_sensor( 325 | key=VictronSensor.CHARGER_ERROR, 326 | native_unit_of_measurement=None, 327 | native_value=parsed.get_charger_error().name.lower(), 328 | device_class=SensorDeviceClass.ENUM, 329 | ) 330 | if parsed.get_external_device_load(): 331 | self.update_sensor( 332 | key=VictronSensor.EXTERNAL_DEVICE_LOAD, 333 | native_unit_of_measurement=Units.ELECTRIC_CURRENT_AMPERE, 334 | native_value=parsed.get_external_device_load(), 335 | device_class=SensorDeviceClass.CURRENT, 336 | ) 337 | elif isinstance(parsed, DcDcConverterData): 338 | self.update_sensor( 339 | key=VictronSensor.OPERATION_MODE, 340 | native_unit_of_measurement=None, 341 | native_value=parsed.get_charge_state().name.lower(), 342 | device_class=SensorDeviceClass.ENUM, 343 | ) 344 | self.update_sensor( 345 | key=VictronSensor.INPUT_VOLTAGE, 346 | native_unit_of_measurement=Units.ELECTRIC_POTENTIAL_VOLT, 347 | native_value=parsed.get_input_voltage(), 348 | device_class=SensorDeviceClass.VOLTAGE, 349 | ) 350 | self.update_sensor( 351 | key=VictronSensor.OUTPUT_VOLTAGE, 352 | native_unit_of_measurement=Units.ELECTRIC_POTENTIAL_VOLT, 353 | native_value=parsed.get_output_voltage(), 354 | device_class=SensorDeviceClass.VOLTAGE, 355 | ) 356 | self.update_sensor( 357 | key=VictronSensor.OFF_REASON, 358 | native_unit_of_measurement=None, 359 | native_value=parsed.get_off_reason().name.lower(), 360 | device_class=SensorDeviceClass.ENUM, 361 | ) 362 | self.update_sensor( 363 | key=VictronSensor.CHARGER_ERROR, 364 | native_unit_of_measurement=None, 365 | native_value=parsed.get_charger_error().name.lower(), 366 | device_class=SensorDeviceClass.ENUM, 367 | ) 368 | 369 | return 370 | -------------------------------------------------------------------------------- /custom_components/victron_ble/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "victron_ble", 3 | "name": "Victron BLE", 4 | "bluetooth": [ 5 | { 6 | "manufacturer_id": 737, 7 | "manufacturer_data_start": [16] 8 | } 9 | ], 10 | "codeowners": ["@keshavdv"], 11 | "config_flow": true, 12 | "dependencies": ["bluetooth"], 13 | "documentation": "https://github.com/keshavdv/victron-hacs/", 14 | "integration_type": "device", 15 | "iot_class": "local_push", 16 | "issue_tracker": "https://github.com/keshavdv/victron-hacs/issues", 17 | "requirements": ["victron_ble==0.9.2"], 18 | "version": "0.1.1" 19 | } 20 | -------------------------------------------------------------------------------- /custom_components/victron_ble/sensor.py: -------------------------------------------------------------------------------- 1 | """Support for Victron ble sensors.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from typing import Any, Dict, Optional, Tuple, Union 7 | 8 | from bluetooth_sensor_state_data import SIGNAL_STRENGTH_KEY 9 | from homeassistant import config_entries 10 | from homeassistant.components.bluetooth.passive_update_processor import ( 11 | PassiveBluetoothDataProcessor, 12 | PassiveBluetoothDataUpdate, 13 | PassiveBluetoothEntityKey, 14 | PassiveBluetoothProcessorCoordinator, 15 | PassiveBluetoothProcessorEntity, 16 | ) 17 | from homeassistant.components.sensor import ( 18 | SensorDeviceClass, 19 | SensorEntity, 20 | SensorEntityDescription, 21 | SensorStateClass, 22 | ) 23 | from homeassistant.core import HomeAssistant 24 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 25 | from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info 26 | from sensor_state_data.data import SensorUpdate 27 | from sensor_state_data.units import Units 28 | from victron_ble.devices.base import AlarmReason, ChargerError, OffReason, OperationMode 29 | from victron_ble.devices.battery_monitor import AuxMode 30 | from victron_ble.devices.smart_battery_protect import OutputState 31 | 32 | from .const import DOMAIN 33 | from .device import VictronSensor 34 | 35 | _LOGGER = logging.getLogger(__name__) 36 | 37 | 38 | SENSOR_DESCRIPTIONS: Dict[Tuple[SensorDeviceClass, Optional[Units]], Any] = { 39 | (SensorDeviceClass.TEMPERATURE, Units.TEMP_CELSIUS): SensorEntityDescription( 40 | key=f"{SensorDeviceClass.TEMPERATURE}_{Units.TEMP_CELSIUS}", 41 | device_class=SensorDeviceClass.TEMPERATURE, 42 | native_unit_of_measurement=Units.TEMP_CELSIUS, 43 | state_class=SensorStateClass.MEASUREMENT, 44 | ), 45 | (SensorDeviceClass.VOLTAGE, Units.ELECTRIC_POTENTIAL_VOLT): SensorEntityDescription( 46 | key=f"{SensorDeviceClass.VOLTAGE}_{Units.ELECTRIC_POTENTIAL_VOLT}", 47 | device_class=SensorDeviceClass.VOLTAGE, 48 | native_unit_of_measurement=Units.ELECTRIC_POTENTIAL_VOLT, 49 | state_class=SensorStateClass.MEASUREMENT, 50 | ), 51 | (SensorDeviceClass.CURRENT, Units.ELECTRIC_CURRENT_AMPERE): SensorEntityDescription( 52 | key=f"{SensorDeviceClass.CURRENT}_{Units.ELECTRIC_CURRENT_AMPERE}", 53 | device_class=SensorDeviceClass.CURRENT, 54 | native_unit_of_measurement=Units.ELECTRIC_CURRENT_AMPERE, 55 | state_class=SensorStateClass.MEASUREMENT, 56 | ), 57 | (SensorDeviceClass.BATTERY, Units.PERCENTAGE): SensorEntityDescription( 58 | key=f"{SensorDeviceClass.BATTERY}_{Units.PERCENTAGE}", 59 | device_class=SensorDeviceClass.BATTERY, 60 | native_unit_of_measurement=Units.PERCENTAGE, 61 | state_class=SensorStateClass.MEASUREMENT, 62 | ), 63 | (VictronSensor.YIELD_TODAY, Units.ENERGY_WATT_HOUR): SensorEntityDescription( 64 | key=VictronSensor.YIELD_TODAY, 65 | device_class=SensorDeviceClass.ENERGY, 66 | native_unit_of_measurement=Units.ENERGY_WATT_HOUR, 67 | state_class=SensorStateClass.TOTAL_INCREASING, 68 | ), 69 | (SensorDeviceClass.POWER, Units.POWER_WATT): SensorEntityDescription( 70 | key=f"{SensorDeviceClass.POWER}_{Units.POWER_WATT}", 71 | device_class=SensorDeviceClass.POWER, 72 | native_unit_of_measurement=Units.POWER_WATT, 73 | state_class=SensorStateClass.MEASUREMENT, 74 | ), 75 | (VictronSensor.AUX_MODE, None): SensorEntityDescription( 76 | key=VictronSensor.AUX_MODE, 77 | device_class=SensorDeviceClass.ENUM, 78 | options=[x.lower() for x in AuxMode._member_names_], 79 | ), 80 | (VictronSensor.OPERATION_MODE, None): SensorEntityDescription( 81 | key=VictronSensor.OPERATION_MODE, 82 | device_class=SensorDeviceClass.ENUM, 83 | options=[x.lower() for x in OperationMode._member_names_], 84 | ), 85 | (VictronSensor.OFF_REASON, None): SensorEntityDescription( 86 | key=VictronSensor.OFF_REASON, 87 | device_class=SensorDeviceClass.ENUM, 88 | options=[x.lower() for x in OffReason._member_names_], 89 | ), 90 | (VictronSensor.CHARGER_ERROR, None): SensorEntityDescription( 91 | key=VictronSensor.CHARGER_ERROR, 92 | device_class=SensorDeviceClass.ENUM, 93 | options=[x.lower() for x in ChargerError._member_names_], 94 | ), 95 | (VictronSensor.EXTERNAL_DEVICE_LOAD, None): SensorEntityDescription( 96 | key=VictronSensor.EXTERNAL_DEVICE_LOAD, 97 | device_class=SensorDeviceClass.CURRENT, 98 | native_unit_of_measurement=Units.ELECTRIC_CURRENT_AMPERE, 99 | state_class=SensorStateClass.MEASUREMENT, 100 | ), 101 | (VictronSensor.EXTERNAL_DEVICE_LOAD, Units.ELECTRIC_CURRENT_AMPERE): SensorEntityDescription( 102 | key=VictronSensor.EXTERNAL_DEVICE_LOAD, 103 | device_class=SensorDeviceClass.CURRENT, 104 | native_unit_of_measurement=Units.ELECTRIC_CURRENT_AMPERE, 105 | state_class=SensorStateClass.MEASUREMENT, 106 | ), 107 | (VictronSensor.TIME_REMAINING, Units.TIME_MINUTES): SensorEntityDescription( 108 | key=VictronSensor.TIME_REMAINING, 109 | device_class=SensorDeviceClass.DURATION, 110 | native_unit_of_measurement=Units.TIME_MINUTES, 111 | state_class=SensorStateClass.MEASUREMENT, 112 | ), 113 | ( 114 | VictronSensor.INPUT_VOLTAGE, 115 | Units.ELECTRIC_POTENTIAL_VOLT, 116 | ): SensorEntityDescription( 117 | key=VictronSensor.INPUT_VOLTAGE, 118 | device_class=SensorDeviceClass.VOLTAGE, 119 | native_unit_of_measurement=Units.ELECTRIC_POTENTIAL_VOLT, 120 | state_class=SensorStateClass.MEASUREMENT, 121 | ), 122 | ( 123 | VictronSensor.OUTPUT_VOLTAGE, 124 | Units.ELECTRIC_POTENTIAL_VOLT, 125 | ): SensorEntityDescription( 126 | key=VictronSensor.OUTPUT_VOLTAGE, 127 | device_class=SensorDeviceClass.VOLTAGE, 128 | native_unit_of_measurement=Units.ELECTRIC_POTENTIAL_VOLT, 129 | state_class=SensorStateClass.MEASUREMENT, 130 | ), 131 | ( 132 | VictronSensor.OUTPUT_CURRENT, 133 | Units.ELECTRIC_CURRENT_AMPERE, 134 | ): SensorEntityDescription( 135 | key=VictronSensor.OUTPUT_CURRENT, 136 | device_class=SensorDeviceClass.CURRENT, 137 | native_unit_of_measurement=Units.ELECTRIC_CURRENT_AMPERE, 138 | state_class=SensorStateClass.MEASUREMENT, 139 | ), 140 | ( 141 | VictronSensor.OUTPUT_POWER, 142 | Units.POWER_WATT, 143 | ): SensorEntityDescription( 144 | key=VictronSensor.OUTPUT_POWER, 145 | device_class=SensorDeviceClass.POWER, 146 | native_unit_of_measurement=Units.POWER_WATT, 147 | state_class=SensorStateClass.MEASUREMENT, 148 | ), 149 | (SensorDeviceClass.BATTERY, Units.PERCENTAGE): SensorEntityDescription( 150 | key=f"{SensorDeviceClass.BATTERY}_{Units.PERCENTAGE}", 151 | device_class=SensorDeviceClass.BATTERY, 152 | native_unit_of_measurement=Units.PERCENTAGE, 153 | state_class=SensorStateClass.MEASUREMENT, 154 | ), 155 | ( 156 | SIGNAL_STRENGTH_KEY, 157 | Units.SIGNAL_STRENGTH_DECIBELS_MILLIWATT, 158 | ): SensorEntityDescription( 159 | key=f"{SensorDeviceClass.SIGNAL_STRENGTH}_{Units.SIGNAL_STRENGTH_DECIBELS_MILLIWATT}", 160 | device_class=SensorDeviceClass.SIGNAL_STRENGTH, 161 | native_unit_of_measurement=Units.SIGNAL_STRENGTH_DECIBELS_MILLIWATT, 162 | state_class=SensorStateClass.MEASUREMENT, 163 | entity_registry_enabled_default=False, 164 | ), 165 | ( 166 | VictronSensor.STARTER_BATTERY_VOLTAGE, 167 | Units.ELECTRIC_POTENTIAL_VOLT, 168 | ): SensorEntityDescription( 169 | key=VictronSensor.STARTER_BATTERY_VOLTAGE, 170 | device_class=SensorDeviceClass.VOLTAGE, 171 | native_unit_of_measurement=Units.ELECTRIC_POTENTIAL_VOLT, 172 | state_class=SensorStateClass.MEASUREMENT, 173 | ), 174 | ( 175 | VictronSensor.MIDPOINT_VOLTAGE, 176 | Units.ELECTRIC_POTENTIAL_VOLT, 177 | ): SensorEntityDescription( 178 | key=VictronSensor.MIDPOINT_VOLTAGE, 179 | device_class=SensorDeviceClass.VOLTAGE, 180 | native_unit_of_measurement=Units.ELECTRIC_POTENTIAL_VOLT, 181 | state_class=SensorStateClass.MEASUREMENT, 182 | ), 183 | (VictronSensor.CONSUMED_ENERGY, Units.ENERGY_WATT_HOUR): SensorEntityDescription( 184 | key=VictronSensor.CONSUMED_ENERGY, 185 | device_class=SensorDeviceClass.ENERGY, 186 | native_unit_of_measurement=Units.ENERGY_WATT_HOUR, 187 | state_class=SensorStateClass.TOTAL_INCREASING, 188 | ), 189 | (VictronSensor.ALARM_REASON, None): SensorEntityDescription( 190 | key=VictronSensor.ALARM_REASON, 191 | device_class=SensorDeviceClass.ENUM, 192 | options=[x.lower() for x in AlarmReason._member_names_], 193 | ), 194 | (VictronSensor.WARNING_REASON, None): SensorEntityDescription( 195 | key=VictronSensor.WARNING_REASON, 196 | device_class=SensorDeviceClass.ENUM, 197 | options=[x.lower() for x in AlarmReason._member_names_], 198 | ), 199 | (VictronSensor.DEVICE_STATE, None): SensorEntityDescription( 200 | key=VictronSensor.DEVICE_STATE, 201 | device_class=SensorDeviceClass.ENUM, 202 | options=[x.lower() for x in OperationMode._member_names_], 203 | ), 204 | (VictronSensor.OUTPUT_STATE, None): SensorEntityDescription( 205 | key=VictronSensor.OUTPUT_STATE, 206 | device_class=SensorDeviceClass.ENUM, 207 | options=[x.lower() for x in OutputState._member_names_], 208 | ), 209 | } 210 | 211 | 212 | def sensor_update_to_bluetooth_data_update( 213 | sensor_update: SensorUpdate, 214 | ) -> PassiveBluetoothDataUpdate: 215 | """Convert a sensor update to a bluetooth data update.""" 216 | data = PassiveBluetoothDataUpdate( 217 | devices={ 218 | device_id: sensor_device_info_to_hass_device_info(device_info) 219 | for device_id, device_info in sensor_update.devices.items() 220 | }, 221 | entity_descriptions={ 222 | PassiveBluetoothEntityKey( 223 | device_key.key, device_key.device_id 224 | ): SENSOR_DESCRIPTIONS[ 225 | (description.device_key.key, description.native_unit_of_measurement) 226 | ] 227 | for device_key, description in sensor_update.entity_descriptions.items() 228 | if description.device_key 229 | }, 230 | entity_data={ 231 | PassiveBluetoothEntityKey( 232 | device_key.key, device_key.device_id 233 | ): sensor_values.native_value 234 | for device_key, sensor_values in sensor_update.entity_values.items() 235 | }, 236 | entity_names={ 237 | PassiveBluetoothEntityKey( 238 | device_key.key, device_key.device_id 239 | ): sensor_values.name 240 | for device_key, sensor_values in sensor_update.entity_values.items() 241 | }, 242 | ) 243 | _LOGGER.debug(f"IN 2here: {data}") 244 | 245 | return data 246 | 247 | 248 | async def async_setup_entry( 249 | hass: HomeAssistant, 250 | entry: config_entries.ConfigEntry, 251 | async_add_entities: AddEntitiesCallback, 252 | ) -> None: 253 | """Set up the Victron BLE sensors.""" 254 | coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ 255 | entry.entry_id 256 | ] 257 | 258 | def update_method(sensor_update: SensorUpdate) -> PassiveBluetoothDataUpdate: 259 | return sensor_update_to_bluetooth_data_update(sensor_update) 260 | 261 | processor = PassiveBluetoothDataProcessor( 262 | update_method=update_method, restore_key=entry.entry_id 263 | ) 264 | 265 | entry.async_on_unload( 266 | processor.async_add_entities_listener( 267 | VictronBluetoothSensorEntity, async_add_entities 268 | ) 269 | ) 270 | entry.async_on_unload(coordinator.async_register_processor(processor)) 271 | 272 | 273 | class VictronBluetoothSensorEntity( 274 | PassiveBluetoothProcessorEntity[ 275 | PassiveBluetoothDataProcessor[Optional[Union[float, int]], SensorUpdate] 276 | ], 277 | SensorEntity, 278 | ): 279 | """Representation of a Victron device that emits Instant Readout advertisements.""" 280 | 281 | @property 282 | def native_value(self) -> int | float | None: 283 | """Return the native value.""" 284 | return self.processor.entity_data.get(self.entity_key) 285 | -------------------------------------------------------------------------------- /custom_components/victron_ble/strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "data": { 6 | "name": "[%key:common::config_flow::data::name%]", 7 | "address": "[%key:common::config_flow::data::address%]", 8 | "key": "[%key:common::config_flow::data::key%]" 9 | } 10 | } 11 | }, 12 | "error": { 13 | "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", 14 | "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", 15 | "unknown": "[%key:common::config_flow::error::unknown%]" 16 | }, 17 | "abort": { 18 | "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" 19 | }, 20 | "entity": {} 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /custom_components/victron_ble/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "Device is already configured" 5 | }, 6 | "error": { 7 | "cannot_connect": "Failed to connect", 8 | "invalid_auth": "Invalid authentication", 9 | "unknown": "Unexpected error" 10 | }, 11 | "step": { 12 | "user": { 13 | "data": { 14 | "name": "Name", 15 | "address": "Address", 16 | "key": "Advertisement Key" 17 | } 18 | } 19 | }, 20 | "entity": { 21 | "sensor": { 22 | "operation_mode": { 23 | "state": { 24 | "cancelling": "Cancelling", 25 | "idle": "Idle", 26 | "paused": "Paused", 27 | "pausing": "Pausing", 28 | "printing": "Printing" 29 | } 30 | } 31 | } 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Victron BLE", 3 | "render_readme": true 4 | } 5 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | python_version = 3.9 3 | follow_imports = silent 4 | ignore_missing_imports = true 5 | warn_incomplete_stub = true 6 | warn_redundant_casts = true 7 | warn_unused_configs = true 8 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | asyncio_mode = auto 3 | -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | homeassistant 2 | pytest 3 | coverage 4 | flake8 5 | black 6 | isort 7 | pytest-cov 8 | codecov 9 | mypy 10 | gitchangelog 11 | mkdocs 12 | -------------------------------------------------------------------------------- /requirements_test.txt: -------------------------------------------------------------------------------- 1 | -r requirements_dev.txt 2 | pytest-homeassistant 3 | pytest-homeassistant-custom-component==0.13.3 4 | victron_ble==0.4.0 5 | bleak_retry_connector 6 | serial 7 | pyserial 8 | bluetooth_adapters 9 | bluetooth_sensor_state_data 10 | -------------------------------------------------------------------------------- /scripts/develop: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | # Start Home Assistant 8 | hass -c . --debug 9 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for the victron_ble integration.""" 2 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Global fixtures for integration.""" 2 | # Fixtures allow you to replace functions with a Mock object. You can perform 3 | # many options via the Mock to reflect a particular behavior from the original 4 | # function that you want to see without going through the function's actual logic. 5 | # Fixtures can either be passed into tests as parameters, or if autouse=True, they 6 | # will automatically be used across all tests. 7 | # 8 | # Fixtures that are defined in conftest.py are available across all tests. You can also 9 | # define fixtures within a particular test file to scope them locally. 10 | # 11 | # pytest_homeassistant_custom_component provides some fixtures that are provided by 12 | # Home Assistant core. You can find those fixture definitions here: 13 | # https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/blob/master/pytest_homeassistant_custom_component/common.py 14 | # 15 | # See here for more info: https://docs.pytest.org/en/latest/fixture.html (note that 16 | # pytest includes fixtures OOB which you can use as defined on this page) 17 | from unittest.mock import patch 18 | 19 | import pytest 20 | 21 | pytest_plugins = "pytest_homeassistant_custom_component" 22 | 23 | 24 | # This fixture enables loading custom integrations in all tests. 25 | # Remove to enable selective use of this fixture 26 | @pytest.fixture(autouse=True) 27 | def auto_enable_custom_integrations(enable_custom_integrations): 28 | yield 29 | 30 | 31 | # This fixture is used to prevent HomeAssistant from attempting to create and dismiss persistent 32 | # notifications. These calls would fail without this fixture since the persistent_notification 33 | # integration is never loaded during a test. 34 | @pytest.fixture(name="skip_notifications", autouse=True) 35 | def skip_notifications_fixture(): 36 | """Skip notification calls.""" 37 | ha_mod = "homeassistant.components.persistent_notification" 38 | with patch(f"{ha_mod}.async_create"), patch(f"{ha_mod}.async_dismiss"): 39 | yield 40 | -------------------------------------------------------------------------------- /tests/test_config_flow.py: -------------------------------------------------------------------------------- 1 | """Test the victron_ble config flow.""" 2 | from unittest.mock import patch 3 | 4 | from homeassistant import config_entries 5 | from homeassistant.core import HomeAssistant 6 | from homeassistant.data_entry_flow import FlowResultType 7 | 8 | from custom_components.victron_ble.const import DOMAIN 9 | 10 | 11 | async def test_form(hass: HomeAssistant) -> None: 12 | """Test we get the form.""" 13 | result = await hass.config_entries.flow.async_init( 14 | DOMAIN, context={"source": config_entries.SOURCE_USER} 15 | ) 16 | assert result["type"] == FlowResultType.FORM 17 | assert result["errors"] is None 18 | 19 | with patch( 20 | "custom_components.victron_ble.async_setup_entry", 21 | return_value=True, 22 | ) as mock_setup_entry: 23 | result2 = await hass.config_entries.flow.async_configure( 24 | result["flow_id"], 25 | { 26 | "name": "test_device", 27 | "address": "test-address", 28 | "key": "test-key", 29 | }, 30 | ) 31 | await hass.async_block_till_done() 32 | 33 | assert result2["type"] == FlowResultType.CREATE_ENTRY 34 | assert result2["title"] == "test_device" 35 | assert result2["data"] == { 36 | "name": "test_device", 37 | "address": "test-address", 38 | "key": "test-key", 39 | } 40 | assert len(mock_setup_entry.mock_calls) == 1 41 | --------------------------------------------------------------------------------