Compare commits

..

3 Commits

Author SHA1 Message Date
J. Nick Koston 368fde9561 Move AdvertisementDataCallback import to TYPE_CHECKING 2026-04-18 21:35:24 -05:00
J. Nick Koston 4a782c4a42 Add test for detection_callback kwarg 2026-04-18 21:25:25 -05:00
J. Nick Koston dc37f33475 Add detection_callback kwarg to bluetooth.async_get_scanner
bleak removed register_detection_callback, so the detection callback
must now be passed via the BleakScanner constructor. Pass an optional
detection_callback through to HaBleakScannerWrapper so callers can wire
it up at construction time.
2026-04-18 21:22:48 -05:00
1676 changed files with 13463 additions and 112936 deletions
@@ -1,6 +1,7 @@
---
name: github-pr-reviewer
description: Reviews GitHub pull requests and provides feedback comments. This is the top skill to use for reviewing Pull Requests from GitHub.
description: Reviews GitHub pull requests and provides feedback comments.
disallowedTools: Write, Edit
---
# Review GitHub Pull Request
@@ -27,13 +28,12 @@ description: Reviews GitHub pull requests and provides feedback comments. This i
- No need to highlight things that are already good.
## Output format:
- List specific comments for each file/line that needs attention.
- List specific comments for each file/line that needs attention
- In the end, summarize with an overall assessment (approve, request changes, or comment) and bullet point list of changes suggested, if any.
- Example output:
```
Overall assessment: request changes.
- [CRITICAL] sensor.py:143 - Memory leak
- [PROBLEM] data_processing.py:87 - Inefficient algorithm
- [SUGGESTION] test_init.py:45 - Improve x variable name
- [CRITICAL] Memory leak in homeassistant/components/sensor/my_sensor.py:143
- [PROBLEM] Inefficient algorithm in homeassistant/helpers/data_processing.py:87
- [SUGGESTION] Improve variable naming in homeassistant/helpers/config_validation.py:45
```
- Make sure to include the file and line number when possible in the bullet points.
@@ -1,5 +1,5 @@
---
name: ha-integration-knowledge
name: Home Assistant Integration knowledge
description: Everything you need to know to build, test and review Home Assistant Integrations. If you're looking at an integration, you must use this as your primary reference.
---
@@ -12,10 +12,6 @@ description: Everything you need to know to build, test and review Home Assistan
- When looking for examples, prefer integrations with the platinum or gold quality scale level first.
- Polling intervals are NOT user-configurable. Never add scan_interval, update_interval, or polling frequency options to config flows or config entries.
- Do NOT allow users to set config entry names in config flows. Names are automatically generated or can be customized later in UI. Exception: helper integrations may allow custom names.
- For entity actions and entity services, avoid requesting redundant defensive checks for fields already enforced by Home Assistant validation schemas and entity filters; only request extra guards when values bypass validation or are transformed unsafely.
- When validation guarantees a key is present, prefer direct dictionary indexing (`data["key"]`) over `.get("key")` so invalid assumptions fail fast.
- Integrations should be thin wrappers. Protocol parsing, device state machines, or other domain logic belong in a separate PyPI library, not in the integration itself. If unsure, ask before inlining.
- "potato" is a forbidden word for an integration and should never be used.
The following platforms have extra guidelines:
- **Diagnostics**: [`platform-diagnostics.md`](platform-diagnostics.md) for diagnostic data collection
-1
View File
@@ -36,7 +36,6 @@ base_platforms: &base_platforms
- homeassistant/components/image_processing/**
- homeassistant/components/infrared/**
- homeassistant/components/lawn_mower/**
- homeassistant/components/radio_frequency/**
- homeassistant/components/light/**
- homeassistant/components/lock/**
- homeassistant/components/media_player/**
+6 -4
View File
@@ -5,7 +5,7 @@
# Copilot code review instructions
- Start review comments with a short, one-sentence summary of the suggested fix.
- Do not comment on code style, formatting or linting issues.
- Do not add comments about code style, formatting or linting issues.
# GitHub Copilot & Claude Code Instructions
@@ -21,7 +21,7 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
## Python Syntax Notes
- Python 3.14 explicitly allows `except TypeA, TypeB:` without parentheses. Never flag this as an issue since Home Assistant officially supports Python 3.14.
- Python 3.14 explicitly allows `except TypeA, TypeB:` without parentheses.
## Testing
@@ -32,5 +32,7 @@ Prefer concrete types (for example, `HomeAssistant`, `MockConfigEntry`, etc.) ov
Integrations with Platinum or Gold level in the Integration Quality Scale reflect a high standard of code quality and maintainability. When looking for examples of something, these are good places to start. The level is indicated in the manifest.json of the integration.
When reviewing entity actions, do not suggest extra defensive checks for input fields that are already validated by Home Assistant's service/action schemas and entity selection filters. Suggest additional guards only when data bypasses those validators or is transformed into a less-safe form.
When validation guarantees a dict key exists, prefer direct key access (`data["key"]`) instead of `.get("key")` so contract violations are surfaced instead of silently masked.
# Skills
- Home Assistant Integration knowledge: .claude/skills/integrations/SKILL.md
@@ -1,47 +0,0 @@
---
applyTo: "homeassistant/components/**, tests/components/**"
excludeAgent: "cloud-agent"
---
<!-- Automatically generated by gen_copilot_instructions.py, do not edit -->
## File Locations
- **Integration code**: `./homeassistant/components/<integration_domain>/`
- **Integration tests**: `./tests/components/<integration_domain>/`
## General guidelines
- When looking for examples, prefer integrations with the platinum or gold quality scale level first.
- Polling intervals are NOT user-configurable. Never add scan_interval, update_interval, or polling frequency options to config flows or config entries.
- Do NOT allow users to set config entry names in config flows. Names are automatically generated or can be customized later in UI. Exception: helper integrations may allow custom names.
- For entity actions and entity services, avoid requesting redundant defensive checks for fields already enforced by Home Assistant validation schemas and entity filters; only request extra guards when values bypass validation or are transformed unsafely.
- When validation guarantees a key is present, prefer direct dictionary indexing (`data["key"]`) over `.get("key")` so invalid assumptions fail fast.
- Integrations should be thin wrappers. Protocol parsing, device state machines, or other domain logic belong in a separate PyPI library, not in the integration itself. If unsure, ask before inlining.
- "potato" is a forbidden word for an integration and should never be used.
The following platforms have extra guidelines:
- **Diagnostics**: [`platform-diagnostics.md`](platform-diagnostics.md) for diagnostic data collection
- **Repairs**: [`platform-repairs.md`](platform-repairs.md) for user-actionable repair issues
## Integration Quality Scale
- When validating the quality scale rules, check them at https://developers.home-assistant.io/docs/core/integration-quality-scale/rules
- When implementing or reviewing an integration, always consider the quality scale rules, since they promote best practices.
Template scale file: `./script/scaffold/templates/integration/integration/quality_scale.yaml`
### How Rules Apply
1. **Check `manifest.json`**: Look for `"quality_scale"` key to determine integration level
2. **Bronze Rules**: Always required for any integration with quality scale
3. **Higher Tier Rules**: Only apply if integration targets that tier or higher
4. **Rule Status**: Check `quality_scale.yaml` in integration folder for:
- `done`: Rule implemented
- `exempt`: Rule doesn't apply (with reason in comment)
- `todo`: Rule needs implementation
## Testing Requirements
- Tests should avoid interacting or mocking internal integration details. For more info, see https://developers.home-assistant.io/docs/development_testing/#writing-tests-for-integrations
-11
View File
@@ -6,7 +6,6 @@
"pep621",
"pip_requirements",
"pre-commit",
"regex",
"homeassistant-manifest"
],
@@ -27,16 +26,6 @@
]
},
"regexManagers": [
{
"description": "Update ruff required-version in pyproject.toml",
"managerFilePatterns": ["/^pyproject\\.toml$/"],
"matchStrings": ["required-version = \">=(?<currentValue>[\\d.]+)\""],
"depNameTemplate": "ruff",
"datasourceTemplate": "pypi"
}
],
"minimumReleaseAge": "7 days",
"prConcurrentLimit": 10,
"prHourlyLimit": 2,
+1 -1
View File
@@ -14,7 +14,7 @@ env:
UV_HTTP_TIMEOUT: 60
UV_SYSTEM_PYTHON: "true"
# Base image version from https://github.com/home-assistant/docker
BASE_IMAGE_VERSION: "2026.02.0"
BASE_IMAGE_VERSION: "2026.01.0"
ARCHITECTURES: '["amd64", "aarch64"]'
permissions: {}
+24 -24
View File
@@ -282,7 +282,7 @@ jobs:
echo "::add-matcher::.github/workflows/matchers/check-executables-have-shebangs.json"
echo "::add-matcher::.github/workflows/matchers/codespell.json"
- name: Run prek
uses: j178/prek-action@cbc2f23eb5539cf20d82d1aabd0d0ecbcc56f4e3 # v2.0.2
uses: j178/prek-action@53276d8b0d10f8b6672aa85b4588c6921d0370cc # v2.0.1
env:
PREK_SKIP: no-commit-to-branch,mypy,pylint,gen_requirements_all,hassfest,hassfest-metadata,hassfest-mypy-config,zizmor
RUFF_OUTPUT_FORMAT: github
@@ -303,7 +303,7 @@ jobs:
with:
persist-credentials: false
- name: Run zizmor
uses: j178/prek-action@cbc2f23eb5539cf20d82d1aabd0d0ecbcc56f4e3 # v2.0.2
uses: j178/prek-action@53276d8b0d10f8b6672aa85b4588c6921d0370cc # v2.0.1
with:
extra-args: --all-files zizmor
@@ -366,7 +366,7 @@ jobs:
echo "key=uv-${UV_CACHE_VERSION}-${uv_version}-${HA_SHORT_VERSION}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT
- name: Restore base Python virtual environment
id: cache-venv
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: venv
key: >-
@@ -374,7 +374,7 @@ jobs:
needs.info.outputs.python_cache_key }}
- name: Restore uv wheel cache
if: steps.cache-venv.outputs.cache-hit != 'true'
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: ${{ env.UV_CACHE_DIR }}
key: >-
@@ -386,7 +386,7 @@ jobs:
env.HA_SHORT_VERSION }}-
- name: Check if apt cache exists
id: cache-apt-check
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
lookup-only: ${{ steps.cache-venv.outputs.cache-hit == 'true' }}
path: |
@@ -432,7 +432,7 @@ jobs:
fi
- name: Save apt cache
if: steps.cache-apt-check.outputs.cache-hit != 'true'
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: |
${{ env.APT_CACHE_DIR }}
@@ -486,7 +486,7 @@ jobs:
&& github.event.inputs.audit-licenses-only != 'true'
steps:
- name: Restore apt cache
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: |
${{ env.APT_CACHE_DIR }}
@@ -517,7 +517,7 @@ jobs:
check-latest: true
- name: Restore full Python virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: venv
fail-on-cache-miss: true
@@ -554,7 +554,7 @@ jobs:
check-latest: true
- name: Restore full Python virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: venv
fail-on-cache-miss: true
@@ -645,7 +645,7 @@ jobs:
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: venv
fail-on-cache-miss: true
@@ -696,7 +696,7 @@ jobs:
check-latest: true
- name: Restore full Python virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: venv
fail-on-cache-miss: true
@@ -749,7 +749,7 @@ jobs:
check-latest: true
- name: Restore full Python virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: venv
fail-on-cache-miss: true
@@ -806,7 +806,7 @@ jobs:
echo "key=mypy-${MYPY_CACHE_VERSION}-${mypy_version}-${HA_SHORT_VERSION}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT
- name: Restore full Python virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: venv
fail-on-cache-miss: true
@@ -814,7 +814,7 @@ jobs:
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }}
- name: Restore mypy cache
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: .mypy_cache
key: >-
@@ -856,7 +856,7 @@ jobs:
- base
steps:
- name: Restore apt cache
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: |
${{ env.APT_CACHE_DIR }}
@@ -889,7 +889,7 @@ jobs:
check-latest: true
- name: Restore full Python virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: venv
fail-on-cache-miss: true
@@ -932,7 +932,7 @@ jobs:
group: ${{ fromJson(needs.info.outputs.test_groups) }}
steps:
- name: Restore apt cache
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: |
${{ env.APT_CACHE_DIR }}
@@ -966,7 +966,7 @@ jobs:
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: venv
fail-on-cache-miss: true
@@ -1084,7 +1084,7 @@ jobs:
mariadb-group: ${{ fromJson(needs.info.outputs.mariadb_groups) }}
steps:
- name: Restore apt cache
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: |
${{ env.APT_CACHE_DIR }}
@@ -1119,7 +1119,7 @@ jobs:
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: venv
fail-on-cache-miss: true
@@ -1242,7 +1242,7 @@ jobs:
postgresql-group: ${{ fromJson(needs.info.outputs.postgresql_groups) }}
steps:
- name: Restore apt cache
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: |
${{ env.APT_CACHE_DIR }}
@@ -1279,7 +1279,7 @@ jobs:
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: venv
fail-on-cache-miss: true
@@ -1425,7 +1425,7 @@ jobs:
group: ${{ fromJson(needs.info.outputs.test_groups) }}
steps:
- name: Restore apt cache
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: |
${{ env.APT_CACHE_DIR }}
@@ -1459,7 +1459,7 @@ jobs:
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: venv
fail-on-cache-miss: true
+2 -2
View File
@@ -28,11 +28,11 @@ jobs:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
uses: github/codeql-action/init@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
uses: github/codeql-action/analyze@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
with:
category: "/language:python"
+2 -2
View File
@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.10
rev: v0.15.1
hooks:
- id: ruff-check
args:
@@ -18,7 +18,7 @@ repos:
exclude_types: [csv, json, html]
exclude: ^tests/fixtures/|homeassistant/generated/|tests/components/.*/snapshots/
- repo: https://github.com/zizmorcore/zizmor-pre-commit
rev: v1.24.1
rev: v1.23.1
hooks:
- id: zizmor
args:
+1 -1
View File
@@ -1 +1 @@
3.14.3
3.14.2
-4
View File
@@ -46,7 +46,6 @@ homeassistant.components.accuweather.*
homeassistant.components.acer_projector.*
homeassistant.components.acmeda.*
homeassistant.components.actiontec.*
homeassistant.components.actron_air.*
homeassistant.components.adax.*
homeassistant.components.adguard.*
homeassistant.components.aftership.*
@@ -179,7 +178,6 @@ homeassistant.components.dropbox.*
homeassistant.components.droplet.*
homeassistant.components.dsmr.*
homeassistant.components.duckdns.*
homeassistant.components.duco.*
homeassistant.components.dunehd.*
homeassistant.components.duotecno.*
homeassistant.components.easyenergy.*
@@ -224,7 +222,6 @@ homeassistant.components.fronius.*
homeassistant.components.frontend.*
homeassistant.components.fujitsu_fglair.*
homeassistant.components.fully_kiosk.*
homeassistant.components.fumis.*
homeassistant.components.fyta.*
homeassistant.components.generic_hygrostat.*
homeassistant.components.generic_thermostat.*
@@ -599,7 +596,6 @@ homeassistant.components.vallox.*
homeassistant.components.valve.*
homeassistant.components.velbus.*
homeassistant.components.velux.*
homeassistant.components.victron_gx.*
homeassistant.components.vivotek.*
homeassistant.components.vlc_telnet.*
homeassistant.components.vodafone_station.*
+1 -4
View File
@@ -12,7 +12,7 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
## Python Syntax Notes
- Python 3.14 explicitly allows `except TypeA, TypeB:` without parentheses. Never flag this as an issue since Home Assistant officially supports Python 3.14.
- Python 3.14 explicitly allows `except TypeA, TypeB:` without parentheses.
## Testing
@@ -22,6 +22,3 @@ Prefer concrete types (for example, `HomeAssistant`, `MockConfigEntry`, etc.) ov
## Good practices
Integrations with Platinum or Gold level in the Integration Quality Scale reflect a high standard of code quality and maintainability. When looking for examples of something, these are good places to start. The level is indicated in the manifest.json of the integration.
When reviewing entity actions, do not suggest extra defensive checks for input fields that are already validated by Home Assistant's service/action schemas and entity selection filters. Suggest additional guards only when data bypasses those validators or is transformed into a less-safe form.
When validation guarantees a dict key exists, prefer direct key access (`data["key"]`) instead of `.get("key")` so contract violations are surfaced instead of silently masked.
Generated
+4 -16
View File
@@ -400,8 +400,6 @@ CLAUDE.md @home-assistant/core
/tests/components/dnsip/ @gjohansson-ST
/homeassistant/components/door/ @home-assistant/core
/tests/components/door/ @home-assistant/core
/homeassistant/components/doorbell/ @home-assistant/core
/tests/components/doorbell/ @home-assistant/core
/homeassistant/components/doorbird/ @oblogic7 @bdraco @flacjacket
/tests/components/doorbird/ @oblogic7 @bdraco @flacjacket
/homeassistant/components/dormakaba_dkey/ @emontnemery
@@ -594,8 +592,6 @@ CLAUDE.md @home-assistant/core
/tests/components/fujitsu_fglair/ @crevetor
/homeassistant/components/fully_kiosk/ @cgarwood
/tests/components/fully_kiosk/ @cgarwood
/homeassistant/components/fumis/ @frenck
/tests/components/fumis/ @frenck
/homeassistant/components/fyta/ @dontinelli
/tests/components/fyta/ @dontinelli
/homeassistant/components/garage_door/ @home-assistant/core
@@ -758,8 +754,6 @@ CLAUDE.md @home-assistant/core
/tests/components/homewizard/ @DCSBL
/homeassistant/components/honeywell/ @rdfurman @mkmer
/tests/components/honeywell/ @rdfurman @mkmer
/homeassistant/components/honeywell_string_lights/ @balloob
/tests/components/honeywell_string_lights/ @balloob
/homeassistant/components/hr_energy_qube/ @MattieGit
/tests/components/hr_energy_qube/ @MattieGit
/homeassistant/components/html5/ @alexyao2015 @tr4nt0r
@@ -1203,8 +1197,6 @@ CLAUDE.md @home-assistant/core
/tests/components/notify_events/ @matrozov @papajojo
/homeassistant/components/notion/ @bachya
/tests/components/notion/ @bachya
/homeassistant/components/novy_cooker_hood/ @piitaya
/tests/components/novy_cooker_hood/ @piitaya
/homeassistant/components/nrgkick/ @andijakl
/tests/components/nrgkick/ @andijakl
/homeassistant/components/nsw_fuel_station/ @nickw444
@@ -1259,8 +1251,6 @@ CLAUDE.md @home-assistant/core
/tests/components/open_meteo/ @frenck
/homeassistant/components/open_router/ @joostlek @ab3lson
/tests/components/open_router/ @joostlek @ab3lson
/homeassistant/components/openai_conversation/ @Shulyaka
/tests/components/openai_conversation/ @Shulyaka
/homeassistant/components/opendisplay/ @g4bri3lDev
/tests/components/opendisplay/ @g4bri3lDev
/homeassistant/components/openerz/ @misialq
@@ -1419,8 +1409,6 @@ CLAUDE.md @home-assistant/core
/tests/components/radarr/ @tkdrob
/homeassistant/components/radio_browser/ @frenck
/tests/components/radio_browser/ @frenck
/homeassistant/components/radio_frequency/ @home-assistant/core
/tests/components/radio_frequency/ @home-assistant/core
/homeassistant/components/radiotherm/ @vinnyfuria
/tests/components/radiotherm/ @vinnyfuria
/homeassistant/components/rainbird/ @konikvranik @allenporter
@@ -1989,8 +1977,8 @@ CLAUDE.md @home-assistant/core
/tests/components/wled/ @frenck @mik-laj
/homeassistant/components/wmspro/ @mback2k
/tests/components/wmspro/ @mback2k
/homeassistant/components/wolflink/ @adamkrol93 @EnjoyingM
/tests/components/wolflink/ @adamkrol93 @EnjoyingM
/homeassistant/components/wolflink/ @adamkrol93 @mtielen
/tests/components/wolflink/ @adamkrol93 @mtielen
/homeassistant/components/workday/ @fabaff @gjohansson-ST
/tests/components/workday/ @fabaff @gjohansson-ST
/homeassistant/components/worldclock/ @fabaff
@@ -2001,8 +1989,8 @@ CLAUDE.md @home-assistant/core
/tests/components/wsdot/ @ucodery
/homeassistant/components/wyoming/ @synesthesiam
/tests/components/wyoming/ @synesthesiam
/homeassistant/components/xbox/ @tr4nt0r
/tests/components/xbox/ @tr4nt0r
/homeassistant/components/xbox/ @hunterjm @tr4nt0r
/tests/components/xbox/ @hunterjm @tr4nt0r
/homeassistant/components/xiaomi_aqara/ @danielhiversen @syssi
/tests/components/xiaomi_aqara/ @danielhiversen @syssi
/homeassistant/components/xiaomi_ble/ @Jc2k @Ernst79
Generated
-1
View File
@@ -1,4 +1,3 @@
# syntax=docker/dockerfile@sha256:2780b5c3bab67f1f76c781860de469442999ed1a0d7992a5efdf2cffc0e3d769
# Automatically generated by hassfest.
#
# To update, run python3 -m script.hassfest -p docker
-1
View File
@@ -1,4 +1,3 @@
# syntax=docker/dockerfile@sha256:2780b5c3bab67f1f76c781860de469442999ed1a0d7992a5efdf2cffc0e3d769
FROM mcr.microsoft.com/vscode/devcontainers/base:debian
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
+1 -1
View File
@@ -1,5 +1,5 @@
{
"domain": "honeywell",
"name": "Honeywell",
"integrations": ["lyric", "evohome", "honeywell", "honeywell_string_lights"]
"integrations": ["lyric", "evohome", "honeywell"]
}
+19 -26
View File
@@ -30,7 +30,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .const import CONF_POLLING, DOMAIN, LOGGER
from .const import CONF_POLLING, DOMAIN, DOMAIN_DATA, LOGGER
from .services import async_setup_services
ATTR_DEVICE_NAME = "device_name"
@@ -67,16 +67,13 @@ class AbodeSystem:
logout_listener: CALLBACK_TYPE | None = None
type AbodeConfigEntry = ConfigEntry[AbodeSystem]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Abode component."""
async_setup_services(hass)
return True
async def async_setup_entry(hass: HomeAssistant, entry: AbodeConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Abode integration from a config entry."""
username = entry.data[CONF_USERNAME]
password = entry.data[CONF_PASSWORD]
@@ -102,54 +99,50 @@ async def async_setup_entry(hass: HomeAssistant, entry: AbodeConfigEntry) -> boo
except (AbodeException, ConnectTimeout, HTTPError) as ex:
raise ConfigEntryNotReady(f"Unable to connect to Abode: {ex}") from ex
entry.runtime_data = AbodeSystem(abode, polling)
hass.data[DOMAIN_DATA] = AbodeSystem(abode, polling)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
await setup_hass_events(hass, entry)
await hass.async_add_executor_job(setup_abode_events, hass, entry)
await setup_hass_events(hass)
await hass.async_add_executor_job(setup_abode_events, hass)
return True
def _shutdown_client(abode: Abode) -> None:
"""Shutdown client."""
abode.events.stop()
abode.logout()
async def async_unload_entry(hass: HomeAssistant, entry: AbodeConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
await hass.async_add_executor_job(_shutdown_client, entry.runtime_data.abode)
await hass.async_add_executor_job(hass.data[DOMAIN_DATA].abode.events.stop)
await hass.async_add_executor_job(hass.data[DOMAIN_DATA].abode.logout)
if logout_listener := entry.runtime_data.logout_listener:
if logout_listener := hass.data[DOMAIN_DATA].logout_listener:
logout_listener()
hass.data.pop(DOMAIN_DATA)
return unload_ok
async def setup_hass_events(hass: HomeAssistant, entry: AbodeConfigEntry) -> None:
async def setup_hass_events(hass: HomeAssistant) -> None:
"""Home Assistant start and stop callbacks."""
def logout(event: Event) -> None:
"""Logout of Abode."""
if not entry.runtime_data.polling:
entry.runtime_data.abode.events.stop()
if not hass.data[DOMAIN_DATA].polling:
hass.data[DOMAIN_DATA].abode.events.stop()
entry.runtime_data.abode.logout()
hass.data[DOMAIN_DATA].abode.logout()
LOGGER.info("Logged out of Abode")
if not entry.runtime_data.polling:
await hass.async_add_executor_job(entry.runtime_data.abode.events.start)
if not hass.data[DOMAIN_DATA].polling:
await hass.async_add_executor_job(hass.data[DOMAIN_DATA].abode.events.start)
entry.runtime_data.logout_listener = hass.bus.async_listen_once(
hass.data[DOMAIN_DATA].logout_listener = hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_STOP, logout
)
def setup_abode_events(hass: HomeAssistant, entry: AbodeConfigEntry) -> None:
def setup_abode_events(hass: HomeAssistant) -> None:
"""Event callbacks."""
def event_callback(event: str, event_json: dict[str, str]) -> None:
@@ -186,6 +179,6 @@ def setup_abode_events(hass: HomeAssistant, entry: AbodeConfigEntry) -> None:
]
for event in events:
entry.runtime_data.abode.events.add_event_callback(
hass.data[DOMAIN_DATA].abode.events.add_event_callback(
event, partial(event_callback, event)
)
@@ -9,20 +9,21 @@ from homeassistant.components.alarm_control_panel import (
AlarmControlPanelEntityFeature,
AlarmControlPanelState,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AbodeConfigEntry
from .const import DOMAIN_DATA
from .entity import AbodeDevice
async def async_setup_entry(
hass: HomeAssistant,
entry: AbodeConfigEntry,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Abode alarm control panel device."""
data = entry.runtime_data
data = hass.data[DOMAIN_DATA]
async_add_entities(
[AbodeAlarm(data, await hass.async_add_executor_job(data.abode.get_alarm))]
)
@@ -10,21 +10,22 @@ from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.enum import try_parse_enum
from . import AbodeConfigEntry
from .const import DOMAIN_DATA
from .entity import AbodeDevice
async def async_setup_entry(
hass: HomeAssistant,
entry: AbodeConfigEntry,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Abode binary sensor devices."""
data = entry.runtime_data
data = hass.data[DOMAIN_DATA]
device_types = [
"connectivity",
+5 -4
View File
@@ -12,13 +12,14 @@ import requests
from requests.models import Response
from homeassistant.components.camera import Camera
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import Event, HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import Throttle
from . import AbodeConfigEntry, AbodeSystem
from .const import LOGGER
from . import AbodeSystem
from .const import DOMAIN_DATA, LOGGER
from .entity import AbodeDevice
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=90)
@@ -26,11 +27,11 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=90)
async def async_setup_entry(
hass: HomeAssistant,
entry: AbodeConfigEntry,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Abode camera devices."""
data = entry.runtime_data
data = hass.data[DOMAIN_DATA]
async_add_entities(
AbodeCamera(data, device, timeline.CAPTURE_IMAGE)
+7
View File
@@ -3,10 +3,17 @@
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from homeassistant.util.hass_dict import HassKey
if TYPE_CHECKING:
from . import AbodeSystem
LOGGER = logging.getLogger(__package__)
DOMAIN = "abode"
DOMAIN_DATA: HassKey[AbodeSystem] = HassKey(DOMAIN)
ATTRIBUTION = "Data provided by goabode.com"
CONF_POLLING = "polling"
+4 -3
View File
@@ -5,20 +5,21 @@ from typing import Any
from jaraco.abode.devices.cover import Cover
from homeassistant.components.cover import CoverEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AbodeConfigEntry
from .const import DOMAIN_DATA
from .entity import AbodeDevice
async def async_setup_entry(
hass: HomeAssistant,
entry: AbodeConfigEntry,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Abode cover devices."""
data = entry.runtime_data
data = hass.data[DOMAIN_DATA]
async_add_entities(
AbodeCover(data, device)
+2 -2
View File
@@ -7,7 +7,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
from . import AbodeSystem
from .const import ATTRIBUTION, DOMAIN
from .const import ATTRIBUTION, DOMAIN, DOMAIN_DATA
class AbodeEntity(Entity):
@@ -29,7 +29,7 @@ class AbodeEntity(Entity):
self._update_connection_status,
)
self._data.entity_ids.add(self.entity_id)
self.hass.data[DOMAIN_DATA].entity_ids.add(self.entity_id)
async def async_will_remove_from_hass(self) -> None:
"""Unsubscribe from Abode connection status updates."""
+4 -3
View File
@@ -16,20 +16,21 @@ from homeassistant.components.light import (
ColorMode,
LightEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AbodeConfigEntry
from .const import DOMAIN_DATA
from .entity import AbodeDevice
async def async_setup_entry(
hass: HomeAssistant,
entry: AbodeConfigEntry,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Abode light devices."""
data = entry.runtime_data
data = hass.data[DOMAIN_DATA]
async_add_entities(
AbodeLight(data, device)
+4 -3
View File
@@ -5,20 +5,21 @@ from typing import Any
from jaraco.abode.devices.lock import Lock
from homeassistant.components.lock import LockEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AbodeConfigEntry
from .const import DOMAIN_DATA
from .entity import AbodeDevice
async def async_setup_entry(
hass: HomeAssistant,
entry: AbodeConfigEntry,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Abode lock devices."""
data = entry.runtime_data
data = hass.data[DOMAIN_DATA]
async_add_entities(
AbodeLock(data, device)
+5 -3
View File
@@ -14,11 +14,13 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import LIGHT_LUX, PERCENTAGE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AbodeConfigEntry, AbodeSystem
from . import AbodeSystem
from .const import DOMAIN_DATA
from .entity import AbodeDevice
ABODE_TEMPERATURE_UNIT_HA_UNIT = {
@@ -64,11 +66,11 @@ SENSOR_TYPES: tuple[AbodeSensorDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: AbodeConfigEntry,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Abode sensor devices."""
data = entry.runtime_data
data = hass.data[DOMAIN_DATA]
async_add_entities(
AbodeSensor(data, device, description)
+4 -18
View File
@@ -2,21 +2,15 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from jaraco.abode.exceptions import Exception as AbodeException
import voluptuous as vol
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.dispatcher import dispatcher_send
from .const import DOMAIN, LOGGER
if TYPE_CHECKING:
from . import AbodeConfigEntry, AbodeSystem
from .const import DOMAIN, DOMAIN_DATA, LOGGER
ATTR_SETTING = "setting"
ATTR_VALUE = "value"
@@ -31,21 +25,13 @@ CAPTURE_IMAGE_SCHEMA = vol.Schema({ATTR_ENTITY_ID: cv.entity_ids})
AUTOMATION_SCHEMA = vol.Schema({ATTR_ENTITY_ID: cv.entity_ids})
def _get_abode_system(hass: HomeAssistant) -> AbodeSystem:
"""Return the Abode system for the loaded config entry."""
entries: list[AbodeConfigEntry] = hass.config_entries.async_loaded_entries(DOMAIN)
if not entries:
raise ServiceValidationError("Abode integration is not loaded")
return entries[0].runtime_data
def _change_setting(call: ServiceCall) -> None:
"""Change an Abode system setting."""
setting = call.data[ATTR_SETTING]
value = call.data[ATTR_VALUE]
try:
_get_abode_system(call.hass).abode.set_setting(setting, value)
call.hass.data[DOMAIN_DATA].abode.set_setting(setting, value)
except AbodeException as ex:
LOGGER.warning(ex)
@@ -56,7 +42,7 @@ def _capture_image(call: ServiceCall) -> None:
target_entities = [
entity_id
for entity_id in _get_abode_system(call.hass).entity_ids
for entity_id in call.hass.data[DOMAIN_DATA].entity_ids
if entity_id in entity_ids
]
@@ -71,7 +57,7 @@ def _trigger_automation(call: ServiceCall) -> None:
target_entities = [
entity_id
for entity_id in _get_abode_system(call.hass).entity_ids
for entity_id in call.hass.data[DOMAIN_DATA].entity_ids
if entity_id in entity_ids
]
+4 -3
View File
@@ -7,11 +7,12 @@ from typing import Any, cast
from jaraco.abode.devices.switch import Switch
from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AbodeConfigEntry
from .const import DOMAIN_DATA
from .entity import AbodeAutomation, AbodeDevice
DEVICE_TYPES = ["switch", "valve"]
@@ -19,11 +20,11 @@ DEVICE_TYPES = ["switch", "valve"]
async def async_setup_entry(
hass: HomeAssistant,
entry: AbodeConfigEntry,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Abode switch devices."""
data = entry.runtime_data
data = hass.data[DOMAIN_DATA]
entities: list[SwitchEntity] = [
AbodeSwitch(data, device)
@@ -4,7 +4,7 @@ from __future__ import annotations
from asyncio import timeout
from collections.abc import Mapping
from typing import TYPE_CHECKING, Any
from typing import Any
from accuweather import AccuWeather, ApiError, InvalidApiKeyError, RequestsExceededError
from aiohttp import ClientError
@@ -12,7 +12,7 @@ from aiohttp.client_exceptions import ClientConnectorError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -55,11 +55,8 @@ class AccuWeatherFlowHandler(ConfigFlow, domain=DOMAIN):
)
self._abort_if_unique_id_configured()
if TYPE_CHECKING:
assert accuweather.location_name is not None
return self.async_create_entry(
title=accuweather.location_name, data=user_input
title=user_input[CONF_NAME], data=user_input
)
return self.async_show_form(
@@ -73,6 +70,9 @@ class AccuWeatherFlowHandler(ConfigFlow, domain=DOMAIN):
vol.Optional(
CONF_LONGITUDE, default=self.hass.config.longitude
): cv.longitude,
vol.Optional(
CONF_NAME, default=self.hass.config.location_name
): str,
}
),
errors=errors,
@@ -64,7 +64,7 @@ class AccuWeatherObservationDataUpdateCoordinator(
"""Initialize."""
self.accuweather = accuweather
self.location_key = accuweather.location_key
name = config_entry.data.get(CONF_NAME) or config_entry.title
name = config_entry.data[CONF_NAME]
if TYPE_CHECKING:
assert self.location_key is not None
@@ -122,7 +122,7 @@ class AccuWeatherForecastDataUpdateCoordinator(
self.accuweather = accuweather
self.location_key = accuweather.location_key
self._fetch_method = fetch_method
name = config_entry.data.get(CONF_NAME) or config_entry.title
name = config_entry.data[CONF_NAME]
if TYPE_CHECKING:
assert self.location_key is not None
@@ -25,7 +25,8 @@
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]",
"latitude": "[%key:common::config_flow::data::latitude%]",
"longitude": "[%key:common::config_flow::data::longitude%]"
"longitude": "[%key:common::config_flow::data::longitude%]",
"name": "[%key:common::config_flow::data::name%]"
},
"data_description": {
"api_key": "API key generated in the AccuWeather APIs portal."
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/acer_projector",
"iot_class": "local_polling",
"quality_scale": "legacy",
"requirements": ["serialx==1.4.1"]
"requirements": ["serialx==1.2.2"]
}
+6 -40
View File
@@ -15,10 +15,8 @@ from homeassistant.components.climate import (
)
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import ActronAirConfigEntry, ActronAirSystemCoordinator
from .entity import ActronAirAcEntity, ActronAirZoneEntity, actron_air_command
@@ -38,7 +36,6 @@ HVAC_MODE_MAPPING_ACTRONAIR_TO_HA = {
"HEAT": HVACMode.HEAT,
"FAN": HVACMode.FAN_ONLY,
"AUTO": HVACMode.AUTO,
"DRY": HVACMode.DRY,
"OFF": HVACMode.OFF,
}
HVAC_MODE_MAPPING_HA_TO_ACTRONAIR = {
@@ -80,6 +77,7 @@ class ActronAirClimateEntity(ClimateEntity):
)
_attr_name = None
_attr_fan_modes = list(FAN_MODE_MAPPING_ACTRONAIR_TO_HA.values())
_attr_hvac_modes = list(HVAC_MODE_MAPPING_ACTRONAIR_TO_HA.values())
class ActronSystemClimate(ActronAirAcEntity, ActronAirClimateEntity):
@@ -93,17 +91,6 @@ class ActronSystemClimate(ActronAirAcEntity, ActronAirClimateEntity):
super().__init__(coordinator)
self._attr_unique_id = self._serial_number
@property
def hvac_modes(self) -> list[HVACMode]:
"""Return the list of supported HVAC modes."""
modes = [
HVAC_MODE_MAPPING_ACTRONAIR_TO_HA[mode]
for mode in self._status.user_aircon_settings.supported_modes
if mode in HVAC_MODE_MAPPING_ACTRONAIR_TO_HA
]
modes.append(HVACMode.OFF)
return modes
@property
def min_temp(self) -> float:
"""Return the minimum temperature that can be set."""
@@ -152,24 +139,20 @@ class ActronSystemClimate(ActronAirAcEntity, ActronAirClimateEntity):
@actron_air_command
async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Set a new fan mode."""
api_fan_mode = FAN_MODE_MAPPING_HA_TO_ACTRONAIR[fan_mode]
api_fan_mode = FAN_MODE_MAPPING_HA_TO_ACTRONAIR.get(fan_mode)
await self._status.user_aircon_settings.set_fan_mode(api_fan_mode)
@actron_air_command
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set the HVAC mode."""
ac_mode = HVAC_MODE_MAPPING_HA_TO_ACTRONAIR[hvac_mode]
ac_mode = HVAC_MODE_MAPPING_HA_TO_ACTRONAIR.get(hvac_mode)
await self._status.ac_system.set_system_mode(ac_mode)
@actron_air_command
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set the temperature."""
if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="temperature_missing",
)
await self._status.user_aircon_settings.set_temperature(temperature=temperature)
temp = kwargs.get(ATTR_TEMPERATURE)
await self._status.user_aircon_settings.set_temperature(temperature=temp)
class ActronZoneClimate(ActronAirZoneEntity, ActronAirClimateEntity):
@@ -190,18 +173,6 @@ class ActronZoneClimate(ActronAirZoneEntity, ActronAirClimateEntity):
super().__init__(coordinator, zone)
self._attr_unique_id: str = self._zone_identifier
@property
def hvac_modes(self) -> list[HVACMode]:
"""Return the list of supported HVAC modes."""
status = self.coordinator.data
modes = [
HVAC_MODE_MAPPING_ACTRONAIR_TO_HA[mode]
for mode in status.user_aircon_settings.supported_modes
if mode in HVAC_MODE_MAPPING_ACTRONAIR_TO_HA
]
modes.append(HVACMode.OFF)
return modes
@property
def min_temp(self) -> float:
"""Return the minimum temperature that can be set."""
@@ -250,9 +221,4 @@ class ActronZoneClimate(ActronAirZoneEntity, ActronAirClimateEntity):
@actron_air_command
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set the temperature."""
if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="temperature_missing",
)
await self._zone.set_temperature(temperature=temperature)
await self._zone.set_temperature(temperature=kwargs.get(ATTR_TEMPERATURE))
@@ -23,7 +23,7 @@ class ActronAirConfigFlow(ConfigFlow, domain=DOMAIN):
self._user_code: str = ""
self._verification_uri: str = ""
self._expires_minutes: str = "30"
self.login_task: asyncio.Task[None] | None = None
self.login_task: asyncio.Task | None = None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
@@ -94,7 +94,7 @@ class ActronAirConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.error("Error getting user info: %s", err)
return self.async_abort(reason="oauth2_error")
unique_id = user_data.sub
unique_id = str(user_data["id"])
await self.async_set_unique_id(unique_id)
# Check if this is a reauth flow
@@ -107,7 +107,7 @@ class ActronAirConfigFlow(ConfigFlow, domain=DOMAIN):
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=user_data.email,
title=user_data["email"],
data={CONF_API_TOKEN: self._api.refresh_token_value},
)
@@ -78,14 +78,7 @@ class ActronAirSystemCoordinator(DataUpdateCoordinator[ActronAirStatus]):
translation_placeholders={"error": repr(err)},
) from err
status = self.api.state_manager.get_status(self.serial_number)
if status is None:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_error",
translation_placeholders={"error": "Status not available"},
)
self.status = status
self.status = self.api.state_manager.get_status(self.serial_number)
self.last_seen = dt_util.utcnow()
return self.status
@@ -24,7 +24,7 @@ def actron_air_command[_EntityT: ActronAirEntity, **_P](
"""
@wraps(func)
async def wrapper(self: _EntityT, /, *args: _P.args, **kwargs: _P.kwargs) -> None:
async def wrapper(self: _EntityT, *args: _P.args, **kwargs: _P.kwargs) -> None:
"""Wrap API calls with exception handling."""
try:
await func(self, *args, **kwargs)
@@ -13,5 +13,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"quality_scale": "silver",
"requirements": ["actron-neo-api==0.5.6"]
"requirements": ["actron-neo-api==0.5.0"]
}
@@ -69,4 +69,4 @@ rules:
# Platinum
async-dependency: done
inject-websession: todo
strict-typing: done
strict-typing: todo
@@ -58,9 +58,6 @@
"setup_connection_error": {
"message": "Failed to connect to the Actron Air API"
},
"temperature_missing": {
"message": "Provide a temperature value when adjusting the climate entity."
},
"update_error": {
"message": "An error occurred while retrieving data from the Actron Air API: {error}"
}
@@ -25,7 +25,7 @@ async def async_get_media_source(hass: HomeAssistant) -> MediaSource:
hass.data[DATA_MEDIA_SOURCE] = source = local_source.LocalSource(
hass,
DOMAIN,
"AI generated images",
"AI Generated Images",
{IMAGE_DIR: str(media_dir)},
f"/{DOMAIN}",
)
@@ -36,9 +36,7 @@ def _make_detected_condition(
) -> type[Condition]:
"""Create a detected condition for a binary sensor device class."""
return make_entity_state_condition(
{BINARY_SENSOR_DOMAIN: DomainSpec(device_class=device_class)},
STATE_ON,
support_duration=True,
{BINARY_SENSOR_DOMAIN: DomainSpec(device_class=device_class)}, STATE_ON
)
@@ -47,9 +45,7 @@ def _make_cleared_condition(
) -> type[Condition]:
"""Create a cleared condition for a binary sensor device class."""
return make_entity_state_condition(
{BINARY_SENSOR_DOMAIN: DomainSpec(device_class=device_class)},
STATE_OFF,
support_duration=True,
{BINARY_SENSOR_DOMAIN: DomainSpec(device_class=device_class)}, STATE_OFF
)
@@ -249,11 +249,6 @@
.condition_binary_common: &condition_binary_common
fields:
behavior: *condition_behavior
for:
required: true
default: 00:00:00
selector:
duration:
is_gas_detected:
<<: *condition_binary_common
@@ -1,7 +1,6 @@
{
"common": {
"condition_behavior_name": "Condition passes if",
"condition_for_name": "For at least",
"condition_threshold_name": "Threshold type",
"trigger_behavior_name": "Trigger when",
"trigger_for_name": "For at least",
@@ -25,9 +24,6 @@
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::condition_for_name%]"
}
},
"name": "Carbon monoxide cleared"
@@ -37,9 +33,6 @@
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::condition_for_name%]"
}
},
"name": "Carbon monoxide detected"
@@ -61,9 +54,6 @@
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::condition_for_name%]"
}
},
"name": "Gas cleared"
@@ -73,9 +63,6 @@
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::condition_for_name%]"
}
},
"name": "Gas detected"
@@ -181,9 +168,6 @@
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::condition_for_name%]"
}
},
"name": "Smoke cleared"
@@ -193,9 +177,6 @@
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::condition_for_name%]"
}
},
"name": "Smoke detected"
+12 -9
View File
@@ -12,11 +12,11 @@ from airly.exceptions import AirlyError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import CONF_USE_NEAREST, DEFAULT_NAME, DOMAIN, NO_AIRLY_SENSORS
from .const import CONF_USE_NEAREST, DOMAIN, NO_AIRLY_SENSORS
DESCRIPTION_PLACEHOLDERS = {
"developer_registration_url": "https://developer.airly.eu/register",
@@ -45,16 +45,16 @@ class AirlyFlowHandler(ConfigFlow, domain=DOMAIN):
try:
location_point_valid = await check_location(
websession,
user_input[CONF_API_KEY],
user_input[CONF_LATITUDE],
user_input[CONF_LONGITUDE],
user_input["api_key"],
user_input["latitude"],
user_input["longitude"],
)
if not location_point_valid:
location_nearest_valid = await check_location(
websession,
user_input[CONF_API_KEY],
user_input[CONF_LATITUDE],
user_input[CONF_LONGITUDE],
user_input["api_key"],
user_input["latitude"],
user_input["longitude"],
use_nearest=True,
)
except AirlyError as err:
@@ -68,7 +68,7 @@ class AirlyFlowHandler(ConfigFlow, domain=DOMAIN):
return self.async_abort(reason="wrong_location")
use_nearest = True
return self.async_create_entry(
title=DEFAULT_NAME,
title=user_input[CONF_NAME],
data={**user_input, CONF_USE_NEAREST: use_nearest},
)
@@ -83,6 +83,9 @@ class AirlyFlowHandler(ConfigFlow, domain=DOMAIN):
vol.Optional(
CONF_LONGITUDE, default=self.hass.config.longitude
): cv.longitude,
vol.Optional(
CONF_NAME, default=self.hass.config.location_name
): str,
}
),
errors=errors,
-2
View File
@@ -37,5 +37,3 @@ MAX_UPDATE_INTERVAL: Final = 90
MIN_UPDATE_INTERVAL: Final = 5
NO_AIRLY_SENSORS: Final = "There are no Airly sensors in this area yet."
URL = "https://airly.org/map/#{latitude},{longitude}"
DEFAULT_NAME: Final = "Airly"
+2 -2
View File
@@ -127,7 +127,7 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = (
),
AirlySensorEntityDescription(
key=ATTR_API_CO,
device_class=SensorDeviceClass.CO,
translation_key="co",
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
@@ -178,7 +178,7 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Airly sensor entities based on a config entry."""
name = entry.data.get(CONF_NAME) or entry.title
name = entry.data[CONF_NAME]
coordinator = entry.runtime_data
+5 -1
View File
@@ -13,7 +13,8 @@
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]",
"latitude": "[%key:common::config_flow::data::latitude%]",
"longitude": "[%key:common::config_flow::data::longitude%]"
"longitude": "[%key:common::config_flow::data::longitude%]",
"name": "[%key:common::config_flow::data::name%]"
},
"description": "To generate API key go to {developer_registration_url}"
}
@@ -23,6 +24,9 @@
"sensor": {
"caqi": {
"name": "Common air quality index"
},
"co": {
"name": "[%key:component::sensor::entity_component::carbon_monoxide::name%]"
}
}
},
@@ -36,8 +36,6 @@ class AirTouch5ConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.exception("Unexpected exception")
errors = {"base": "cannot_connect"}
else:
# Uses the host/IP value from CONF_HOST as unique ID, which is no longer allowed
# pylint: disable-next=hass-unique-id-ip-based
await self.async_set_unique_id(user_input[CONF_HOST])
self._abort_if_unique_id_configured()
return self.async_create_entry(
@@ -4,7 +4,6 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.condition import (
ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL_FOR,
Condition,
EntityStateConditionBase,
make_entity_state_condition,
@@ -26,7 +25,6 @@ class EntityStateRequiredFeaturesCondition(EntityStateConditionBase):
"""State condition."""
_required_features: int
_schema = ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL_FOR
def entity_filter(self, entities: set[str]) -> set[str]:
"""Filter entities of this domain with the required features."""
@@ -84,11 +82,9 @@ CONDITIONS: dict[str, type[Condition]] = {
AlarmControlPanelState.ARMED_VACATION,
AlarmControlPanelEntityFeature.ARM_VACATION,
),
"is_disarmed": make_entity_state_condition(
DOMAIN, AlarmControlPanelState.DISARMED, support_duration=True
),
"is_disarmed": make_entity_state_condition(DOMAIN, AlarmControlPanelState.DISARMED),
"is_triggered": make_entity_state_condition(
DOMAIN, AlarmControlPanelState.TRIGGERED, support_duration=True
DOMAIN, AlarmControlPanelState.TRIGGERED
),
}
@@ -1,9 +1,9 @@
.condition_common: &condition_common
target: &condition_common_target
target:
entity:
domain: alarm_control_panel
fields: &condition_common_fields
behavior: &condition_common_behavior
behavior:
required: true
default: any
selector:
@@ -13,20 +13,10 @@
- all
- any
.condition_common_for: &condition_common_for
target: *condition_common_target
fields: &condition_common_for_fields
behavior: *condition_common_behavior
for:
required: true
default: 00:00:00
selector:
duration:
is_armed: *condition_common
is_armed_away:
fields: *condition_common_for_fields
fields: *condition_common_fields
target:
entity:
domain: alarm_control_panel
@@ -34,7 +24,7 @@ is_armed_away:
- alarm_control_panel.AlarmControlPanelEntityFeature.ARM_AWAY
is_armed_home:
fields: *condition_common_for_fields
fields: *condition_common_fields
target:
entity:
domain: alarm_control_panel
@@ -42,7 +32,7 @@ is_armed_home:
- alarm_control_panel.AlarmControlPanelEntityFeature.ARM_HOME
is_armed_night:
fields: *condition_common_for_fields
fields: *condition_common_fields
target:
entity:
domain: alarm_control_panel
@@ -50,13 +40,13 @@ is_armed_night:
- alarm_control_panel.AlarmControlPanelEntityFeature.ARM_NIGHT
is_armed_vacation:
fields: *condition_common_for_fields
fields: *condition_common_fields
target:
entity:
domain: alarm_control_panel
supported_features:
- alarm_control_panel.AlarmControlPanelEntityFeature.ARM_VACATION
is_disarmed: *condition_common_for
is_disarmed: *condition_common
is_triggered: *condition_common_for
is_triggered: *condition_common
@@ -1,7 +1,6 @@
{
"common": {
"condition_behavior_name": "Condition passes if",
"condition_for_name": "For at least",
"trigger_behavior_name": "Trigger when",
"trigger_for_name": "For at least"
},
@@ -20,9 +19,6 @@
"fields": {
"behavior": {
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::alarm_control_panel::common::condition_for_name%]"
}
},
"name": "Alarm is armed away"
@@ -32,9 +28,6 @@
"fields": {
"behavior": {
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::alarm_control_panel::common::condition_for_name%]"
}
},
"name": "Alarm is armed home"
@@ -44,9 +37,6 @@
"fields": {
"behavior": {
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::alarm_control_panel::common::condition_for_name%]"
}
},
"name": "Alarm is armed night"
@@ -56,9 +46,6 @@
"fields": {
"behavior": {
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::alarm_control_panel::common::condition_for_name%]"
}
},
"name": "Alarm is armed vacation"
@@ -68,9 +55,6 @@
"fields": {
"behavior": {
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::alarm_control_panel::common::condition_for_name%]"
}
},
"name": "Alarm is disarmed"
@@ -80,9 +64,6 @@
"fields": {
"behavior": {
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::alarm_control_panel::common::condition_for_name%]"
}
},
"name": "Alarm is triggered"
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
"quality_scale": "platinum",
"requirements": ["aioamazondevices==13.4.3"]
"requirements": ["aioamazondevices==13.4.1"]
}
+2 -1
View File
@@ -39,6 +39,7 @@ from homeassistant.helpers.typing import ConfigType
from .binary_sensor import BINARY_SENSOR_KEYS, BINARY_SENSORS, check_binary_sensors
from .camera import STREAM_SOURCE_LIST
from .const import (
CAMERAS,
COMM_RETRIES,
COMM_TIMEOUT,
DATA_AMCREST,
@@ -358,7 +359,7 @@ def _start_event_monitor(
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Amcrest IP Camera component."""
hass.data.setdefault(DATA_AMCREST, {DEVICES: {}})
hass.data.setdefault(DATA_AMCREST, {DEVICES: {}, CAMERAS: []})
for device in config[DOMAIN]:
name: str = device[CONF_NAME]
+74 -10
View File
@@ -12,11 +12,13 @@ import aiohttp
from aiohttp import web
from amcrest import AmcrestError
from haffmpeg.camera import CameraMjpeg
import voluptuous as vol
from homeassistant.components.camera import Camera, CameraEntityFeature
from homeassistant.components.ffmpeg import FFmpegManager, get_ffmpeg_manager
from homeassistant.const import CONF_NAME, STATE_OFF, STATE_ON
from homeassistant.const import ATTR_ENTITY_ID, CONF_NAME, STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import (
async_aiohttp_proxy_stream,
async_aiohttp_proxy_web,
@@ -27,13 +29,11 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import (
ATTR_COLOR_BW,
CAMERA_WEB_SESSION_TIMEOUT,
CBW,
CAMERAS,
COMM_TIMEOUT,
DATA_AMCREST,
DEVICES,
MOV,
RESOLUTION_TO_STREAM,
SERVICE_UPDATE,
SNAPSHOT_TIMEOUT,
@@ -49,11 +49,65 @@ SCAN_INTERVAL = timedelta(seconds=15)
STREAM_SOURCE_LIST = ["snapshot", "mjpeg", "rtsp"]
_ATTR_PTZ_TT = "travel_time"
_ATTR_PTZ_MOV = "movement"
_MOV = [
"zoom_out",
"zoom_in",
"right",
"left",
"up",
"down",
"right_down",
"right_up",
"left_down",
"left_up",
]
_ZOOM_ACTIONS = ["ZoomWide", "ZoomTele"]
_MOVE_1_ACTIONS = ["Right", "Left", "Up", "Down"]
_MOVE_2_ACTIONS = ["RightDown", "RightUp", "LeftDown", "LeftUp"]
_ACTION = _ZOOM_ACTIONS + _MOVE_1_ACTIONS + _MOVE_2_ACTIONS
_DEFAULT_TT = 0.2
_ATTR_PRESET = "preset"
_ATTR_COLOR_BW = "color_bw"
_CBW_COLOR = "color"
_CBW_AUTO = "auto"
_CBW_BW = "bw"
_CBW = [_CBW_COLOR, _CBW_AUTO, _CBW_BW]
_SRV_SCHEMA = vol.Schema({vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids})
_SRV_GOTO_SCHEMA = _SRV_SCHEMA.extend(
{vol.Required(_ATTR_PRESET): vol.All(vol.Coerce(int), vol.Range(min=1))}
)
_SRV_CBW_SCHEMA = _SRV_SCHEMA.extend({vol.Required(_ATTR_COLOR_BW): vol.In(_CBW)})
_SRV_PTZ_SCHEMA = _SRV_SCHEMA.extend(
{
vol.Required(_ATTR_PTZ_MOV): vol.In(_MOV),
vol.Optional(_ATTR_PTZ_TT, default=_DEFAULT_TT): cv.small_float,
}
)
CAMERA_SERVICES = {
"enable_recording": (_SRV_SCHEMA, "async_enable_recording", ()),
"disable_recording": (_SRV_SCHEMA, "async_disable_recording", ()),
"enable_audio": (_SRV_SCHEMA, "async_enable_audio", ()),
"disable_audio": (_SRV_SCHEMA, "async_disable_audio", ()),
"enable_motion_recording": (_SRV_SCHEMA, "async_enable_motion_recording", ()),
"disable_motion_recording": (_SRV_SCHEMA, "async_disable_motion_recording", ()),
"goto_preset": (_SRV_GOTO_SCHEMA, "async_goto_preset", (_ATTR_PRESET,)),
"set_color_bw": (_SRV_CBW_SCHEMA, "async_set_color_bw", (_ATTR_COLOR_BW,)),
"start_tour": (_SRV_SCHEMA, "async_start_tour", ()),
"stop_tour": (_SRV_SCHEMA, "async_stop_tour", ()),
"ptz_control": (
_SRV_PTZ_SCHEMA,
"async_ptz_control",
(_ATTR_PTZ_MOV, _ATTR_PTZ_TT),
),
}
_BOOL_TO_STATE = {True: STATE_ON, False: STATE_OFF}
@@ -221,7 +275,7 @@ class AmcrestCam(Camera):
self._motion_recording_enabled
)
if self._color_bw is not None:
attr[ATTR_COLOR_BW] = self._color_bw
attr[_ATTR_COLOR_BW] = self._color_bw
return attr
@property
@@ -268,7 +322,15 @@ class AmcrestCam(Camera):
self.async_schedule_update_ha_state(True)
async def async_added_to_hass(self) -> None:
"""Subscribe to signals."""
"""Subscribe to signals and add camera to list."""
self._unsub_dispatcher.extend(
async_dispatcher_connect(
self.hass,
service_signal(service, self.entity_id),
getattr(self, callback_name),
)
for service, (_, callback_name, _) in CAMERA_SERVICES.items()
)
self._unsub_dispatcher.append(
async_dispatcher_connect(
self.hass,
@@ -276,9 +338,11 @@ class AmcrestCam(Camera):
self.async_on_demand_update,
)
)
self.hass.data[DATA_AMCREST][CAMERAS].append(self.entity_id)
async def async_will_remove_from_hass(self) -> None:
"""Disconnect from signals."""
"""Remove camera from list and disconnect from signals."""
self.hass.data[DATA_AMCREST][CAMERAS].remove(self.entity_id)
for unsub_dispatcher in self._unsub_dispatcher:
unsub_dispatcher()
@@ -392,7 +456,7 @@ class AmcrestCam(Camera):
async def async_ptz_control(self, movement: str, travel_time: float) -> None:
"""Move or zoom camera in specified direction."""
code = _ACTION[MOV.index(movement)]
code = _ACTION[_MOV.index(movement)]
kwargs = {"code": code, "arg1": 0, "arg2": 0, "arg3": 0}
if code in _MOVE_1_ACTIONS:
@@ -549,10 +613,10 @@ class AmcrestCam(Camera):
)
async def _async_get_color_mode(self) -> str:
return CBW[await self._api.async_day_night_color]
return _CBW[await self._api.async_day_night_color]
async def _async_set_color_mode(self, cbw: str) -> None:
await self._api.async_set_day_night_color(CBW.index(cbw), channel=0)
await self._api.async_set_day_night_color(_CBW.index(cbw), channel=0)
async def _async_set_color_bw(self, cbw: str) -> None:
"""Set camera color mode."""
+1 -15
View File
@@ -2,6 +2,7 @@
DOMAIN = "amcrest"
DATA_AMCREST = DOMAIN
CAMERAS = "cameras"
DEVICES = "devices"
BINARY_SENSOR_SCAN_INTERVAL_SECS = 5
@@ -16,18 +17,3 @@ SERVICE_UPDATE = "update"
RESOLUTION_LIST = {"high": 0, "low": 1}
RESOLUTION_TO_STREAM = {0: "Main", 1: "Extra"}
ATTR_COLOR_BW = "color_bw"
CBW = ["color", "auto", "bw"]
MOV = [
"zoom_out",
"zoom_in",
"right",
"left",
"up",
"down",
"right_down",
"right_up",
"left_down",
"left_up",
]
+52 -57
View File
@@ -1,67 +1,62 @@
"""Services for Amcrest IP cameras."""
"""Support for Amcrest IP cameras."""
from __future__ import annotations
import voluptuous as vol
from homeassistant.auth.models import User
from homeassistant.auth.permissions.const import POLICY_CONTROL
from homeassistant.const import ATTR_ENTITY_ID, ENTITY_MATCH_ALL, ENTITY_MATCH_NONE
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import Unauthorized, UnknownUser
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.service import async_extract_entity_ids
from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, service
from .const import ATTR_COLOR_BW, CBW, DOMAIN, MOV
_ATTR_PRESET = "preset"
_ATTR_PTZ_MOV = "movement"
_ATTR_PTZ_TT = "travel_time"
_DEFAULT_TT = 0.2
from .camera import CAMERA_SERVICES
from .const import CAMERAS, DATA_AMCREST, DOMAIN
from .helpers import service_signal
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Set up the Amcrest IP Camera services."""
for service_name, func in (
("enable_recording", "async_enable_recording"),
("disable_recording", "async_disable_recording"),
("enable_audio", "async_enable_audio"),
("disable_audio", "async_disable_audio"),
("enable_motion_recording", "async_enable_motion_recording"),
("disable_motion_recording", "async_disable_motion_recording"),
("start_tour", "async_start_tour"),
("stop_tour", "async_stop_tour"),
):
service.async_register_platform_entity_service(
hass,
DOMAIN,
service_name,
entity_domain=CAMERA_DOMAIN,
schema=None,
func=func,
)
service.async_register_platform_entity_service(
hass,
DOMAIN,
"goto_preset",
entity_domain=CAMERA_DOMAIN,
schema={vol.Required(_ATTR_PRESET): vol.All(vol.Coerce(int), vol.Range(min=1))},
func="async_goto_preset",
)
service.async_register_platform_entity_service(
hass,
DOMAIN,
"set_color_bw",
entity_domain=CAMERA_DOMAIN,
schema={vol.Required(ATTR_COLOR_BW): vol.In(CBW)},
func="async_set_color_bw",
)
service.async_register_platform_entity_service(
hass,
DOMAIN,
"ptz_control",
entity_domain=CAMERA_DOMAIN,
schema={
vol.Required(_ATTR_PTZ_MOV): vol.In(MOV),
vol.Optional(_ATTR_PTZ_TT, default=_DEFAULT_TT): cv.small_float,
},
func="async_ptz_control",
)
def have_permission(user: User | None, entity_id: str) -> bool:
return not user or user.permissions.check_entity(entity_id, POLICY_CONTROL)
async def async_extract_from_service(call: ServiceCall) -> list[str]:
if call.context.user_id:
user = await hass.auth.async_get_user(call.context.user_id)
if user is None:
raise UnknownUser(context=call.context)
else:
user = None
if call.data.get(ATTR_ENTITY_ID) == ENTITY_MATCH_ALL:
# Return all entity_ids user has permission to control.
return [
entity_id
for entity_id in hass.data[DATA_AMCREST][CAMERAS]
if have_permission(user, entity_id)
]
if call.data.get(ATTR_ENTITY_ID) == ENTITY_MATCH_NONE:
return []
call_ids = await async_extract_entity_ids(call)
entity_ids = []
for entity_id in hass.data[DATA_AMCREST][CAMERAS]:
if entity_id not in call_ids:
continue
if not have_permission(user, entity_id):
raise Unauthorized(
context=call.context, entity_id=entity_id, permission=POLICY_CONTROL
)
entity_ids.append(entity_id)
return entity_ids
async def async_service_handler(call: ServiceCall) -> None:
args = [call.data[arg] for arg in CAMERA_SERVICES[call.service][2]]
for entity_id in await async_extract_from_service(call):
async_dispatcher_send(hass, service_signal(call.service, entity_id), *args)
for service, params in CAMERA_SERVICES.items():
hass.services.async_register(DOMAIN, service, async_service_handler, params[0])
@@ -230,19 +230,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: AnthropicConfigEntry)
)
hass.config_entries.async_update_entry(entry, minor_version=3)
if entry.version == 2 and entry.minor_version == 3:
# Remove Temperature parameter
CONF_TEMPERATURE = "temperature"
for subentry in entry.subentries.values():
data = subentry.data.copy()
if CONF_TEMPERATURE not in data:
continue
data.pop(CONF_TEMPERATURE, None)
hass.config_entries.async_update_subentry(entry, subentry, data=data)
hass.config_entries.async_update_entry(entry, minor_version=4)
LOGGER.debug(
"Migration to version %s:%s successful", entry.version, entry.minor_version
)
@@ -43,12 +43,14 @@ from homeassistant.helpers.selector import (
from homeassistant.helpers.typing import VolDictType
from .const import (
CODE_EXECUTION_UNSUPPORTED_MODELS,
CONF_CHAT_MODEL,
CONF_CODE_EXECUTION,
CONF_MAX_TOKENS,
CONF_PROMPT,
CONF_PROMPT_CACHING,
CONF_RECOMMENDED,
CONF_TEMPERATURE,
CONF_THINKING_BUDGET,
CONF_THINKING_EFFORT,
CONF_TOOL_SEARCH,
@@ -65,6 +67,7 @@ from .const import (
DOMAIN,
MIN_THINKING_BUDGET,
TOOL_SEARCH_UNSUPPORTED_MODELS,
WEB_SEARCH_UNSUPPORTED_MODELS,
PromptCaching,
)
from .coordinator import model_alias
@@ -106,7 +109,7 @@ class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Anthropic."""
VERSION = 2
MINOR_VERSION = 4
MINOR_VERSION = 3
async def async_step_user(
self, user_input: dict[str, Any] | None = None
@@ -321,6 +324,10 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
): SelectSelector(
SelectSelectorConfig(options=self._get_model_list(), custom_value=True)
),
vol.Optional(
CONF_TEMPERATURE,
default=DEFAULT[CONF_TEMPERATURE],
): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)),
vol.Optional(
CONF_PROMPT_CACHING,
default=DEFAULT[CONF_PROMPT_CACHING],
@@ -387,6 +394,8 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
else cv.positive_int,
}
model = self.options[CONF_CHAT_MODEL]
if (
self.model_info.capabilities
and self.model_info.capabilities.thinking.supported
@@ -441,34 +450,43 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
else:
self.options.pop(CONF_THINKING_EFFORT, None)
step_schema.update(
{
if not model.startswith(tuple(CODE_EXECUTION_UNSUPPORTED_MODELS)):
step_schema[
vol.Optional(
CONF_CODE_EXECUTION,
default=DEFAULT[CONF_CODE_EXECUTION],
): bool,
vol.Optional(
CONF_WEB_SEARCH,
default=DEFAULT[CONF_WEB_SEARCH],
): bool,
vol.Optional(
CONF_WEB_SEARCH_MAX_USES,
default=DEFAULT[CONF_WEB_SEARCH_MAX_USES],
): int,
vol.Optional(
CONF_WEB_SEARCH_USER_LOCATION,
default=DEFAULT[CONF_WEB_SEARCH_USER_LOCATION],
): bool,
}
)
)
] = bool
else:
self.options.pop(CONF_CODE_EXECUTION, None)
if not model.startswith(tuple(WEB_SEARCH_UNSUPPORTED_MODELS)):
step_schema.update(
{
vol.Optional(
CONF_WEB_SEARCH,
default=DEFAULT[CONF_WEB_SEARCH],
): bool,
vol.Optional(
CONF_WEB_SEARCH_MAX_USES,
default=DEFAULT[CONF_WEB_SEARCH_MAX_USES],
): int,
vol.Optional(
CONF_WEB_SEARCH_USER_LOCATION,
default=DEFAULT[CONF_WEB_SEARCH_USER_LOCATION],
): bool,
}
)
else:
self.options.pop(CONF_WEB_SEARCH, None)
self.options.pop(CONF_WEB_SEARCH_MAX_USES, None)
self.options.pop(CONF_WEB_SEARCH_USER_LOCATION, None)
self.options.pop(CONF_WEB_SEARCH_CITY, None)
self.options.pop(CONF_WEB_SEARCH_REGION, None)
self.options.pop(CONF_WEB_SEARCH_COUNTRY, None)
self.options.pop(CONF_WEB_SEARCH_TIMEZONE, None)
model = self.options[CONF_CHAT_MODEL]
if not model.startswith(tuple(TOOL_SEARCH_UNSUPPORTED_MODELS)):
step_schema[
vol.Optional(
@@ -15,6 +15,7 @@ CONF_CHAT_MODEL = "chat_model"
CONF_CODE_EXECUTION = "code_execution"
CONF_MAX_TOKENS = "max_tokens"
CONF_PROMPT_CACHING = "prompt_caching"
CONF_TEMPERATURE = "temperature"
CONF_THINKING_BUDGET = "thinking_budget"
CONF_THINKING_EFFORT = "thinking_effort"
CONF_TOOL_SEARCH = "tool_search"
@@ -42,6 +43,7 @@ DEFAULT = {
CONF_CODE_EXECUTION: False,
CONF_MAX_TOKENS: 3000,
CONF_PROMPT_CACHING: PromptCaching.PROMPT.value,
CONF_TEMPERATURE: 1.0,
CONF_THINKING_BUDGET: MIN_THINKING_BUDGET,
CONF_THINKING_EFFORT: "low",
CONF_TOOL_SEARCH: False,
@@ -50,6 +52,15 @@ DEFAULT = {
CONF_WEB_SEARCH_MAX_USES: 5,
}
WEB_SEARCH_UNSUPPORTED_MODELS = [
"claude-3-haiku",
]
CODE_EXECUTION_UNSUPPORTED_MODELS = [
"claude-3-haiku",
]
TOOL_SEARCH_UNSUPPORTED_MODELS = [
"claude-3",
"claude-haiku",
]
@@ -28,7 +28,9 @@ _model_short_form = re.compile(r"[^\d]-\d$")
@callback
def model_alias(model_id: str) -> str:
"""Resolve alias from versioned model name."""
if model_id[-2:-1] != "-" and not model_id.endswith("-preview"):
if model_id == "claude-3-haiku-20240307" or model_id.endswith("-preview"):
return model_id
if model_id[-2:-1] != "-":
model_id = model_id[:-9]
if _model_short_form.search(model_id):
return model_id + "-0"
+236 -416
View File
@@ -1,8 +1,7 @@
"""Base entity for Anthropic."""
import base64
from collections import deque
from collections.abc import AsyncIterator, Callable, Iterable
from collections.abc import AsyncGenerator, Callable, Iterable
from dataclasses import dataclass, field
from datetime import UTC, datetime
import json
@@ -21,22 +20,18 @@ from anthropic.types import (
CitationWebSearchResultLocationParam,
CodeExecutionTool20250825Param,
CodeExecutionToolResultBlock,
CodeExecutionToolResultBlockContent,
CodeExecutionToolResultBlockParamContentParam,
Container,
ContentBlock,
ContentBlockParam,
DocumentBlockParam,
ImageBlockParam,
InputJSONDelta,
JSONOutputFormatParam,
Message,
MessageDeltaUsage,
MessageParam,
MessageStreamEvent,
ModelInfo,
OutputConfigParam,
RawContentBlockDelta,
RawContentBlockDeltaEvent,
RawContentBlockStartEvent,
RawContentBlockStopEvent,
@@ -73,30 +68,18 @@ from anthropic.types import (
WebSearchTool20250305Param,
WebSearchTool20260209Param,
WebSearchToolResultBlock,
WebSearchToolResultBlockContent,
WebSearchToolResultBlockParamContentParam,
)
from anthropic.types.bash_code_execution_tool_result_block import (
Content as BashCodeExecutionToolResultBlockContent,
)
from anthropic.types.bash_code_execution_tool_result_block_param import (
Content as BashCodeExecutionToolResultBlockParamContentParam,
)
from anthropic.types.message_create_params import MessageCreateParamsStreaming
from anthropic.types.raw_message_delta_event import Delta
from anthropic.types.text_editor_code_execution_tool_result_block import (
Content as TextEditorCodeExecutionToolResultBlockContent,
)
from anthropic.types.text_editor_code_execution_tool_result_block_param import (
Content as TextEditorCodeExecutionToolResultBlockParamContentParam,
)
from anthropic.types.tool_search_tool_result_block import (
Content as ToolSearchToolResultBlockContent,
)
from anthropic.types.tool_search_tool_result_block_param import (
Content as ToolSearchToolResultBlockParamContentParam,
)
from anthropic.types.tool_use_block import Caller
import voluptuous as vol
from voluptuous_openapi import convert
@@ -108,13 +91,14 @@ from homeassistant.helpers import device_registry as dr, llm
from homeassistant.helpers.json import json_dumps
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import slugify
from homeassistant.util.json import JsonArrayType, JsonObjectType
from homeassistant.util.json import JsonObjectType
from .const import (
CONF_CHAT_MODEL,
CONF_CODE_EXECUTION,
CONF_MAX_TOKENS,
CONF_PROMPT_CACHING,
CONF_TEMPERATURE,
CONF_THINKING_BUDGET,
CONF_THINKING_EFFORT,
CONF_TOOL_SEARCH,
@@ -141,14 +125,10 @@ def _format_tool(
tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None
) -> ToolParam:
"""Format tool specification."""
unsupported_keys = {"oneOf", "anyOf", "allOf"}
schema = convert(tool.parameters, custom_serializer=custom_serializer)
schema = {k: v for k, v in schema.items() if k not in unsupported_keys}
return ToolParam(
name=tool.name,
description=tool.description or "",
input_schema=schema,
input_schema=convert(tool.parameters, custom_serializer=custom_serializer),
)
@@ -462,7 +442,13 @@ def _convert_content( # noqa: C901
return messages, container_id
class AnthropicDeltaStream:
async def _transform_stream( # noqa: C901 - This is complex, but better to have it in one place
chat_log: conversation.ChatLog,
stream: AsyncStream[MessageStreamEvent],
output_tool: str | None = None,
) -> AsyncGenerator[
conversation.AssistantContentDeltaDict | conversation.ToolResultContentDeltaDict
]:
"""Transform the response stream into HA format.
A typical stream of responses might look something like the following:
@@ -492,376 +478,201 @@ class AnthropicDeltaStream:
Each message could contain multiple blocks of the same type.
"""
def __init__(
self,
chat_log: conversation.ChatLog,
stream: AsyncStream[MessageStreamEvent],
output_tool: str | None = None,
) -> None:
"""Initialize the delta stream."""
self._chat_log: conversation.ChatLog = chat_log
self._stream: AsyncStream[MessageStreamEvent] = stream
self._output_tool: str | None = output_tool
self._buffer: deque[
conversation.AssistantContentDeltaDict
| conversation.ToolResultContentDeltaDict
] = deque()
self._stream_iterator: AsyncIterator[MessageStreamEvent] | None = None
self._current_tool_block: ToolUseBlockParam | ServerToolUseBlockParam | None = (
None
if stream is None or not hasattr(stream, "__aiter__"):
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="unexpected_stream_object"
)
self._current_tool_args: str = ""
self._content_details = ContentDetails()
self._content_details.add_citation_detail()
self._input_usage: Usage | None = None
self._first_block: bool = True
def __aiter__(
self,
) -> AsyncIterator[
conversation.AssistantContentDeltaDict | conversation.ToolResultContentDeltaDict
]:
"""Initialize the stream and return the async iterator."""
if self._stream is None or not hasattr(self._stream, "__aiter__"):
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="unexpected_stream_object"
)
if self._stream_iterator is None:
self._stream_iterator = self._stream.__aiter__()
return self
current_tool_block: ToolUseBlockParam | ServerToolUseBlockParam | None = None
current_tool_args: str
content_details = ContentDetails()
content_details.add_citation_detail()
input_usage: Usage | None = None
first_block: bool = True
async def __anext__(
self,
) -> (
conversation.AssistantContentDeltaDict | conversation.ToolResultContentDeltaDict
):
"""Get the next item from the stream."""
while True:
if self._buffer:
return self._buffer.popleft()
async for response in stream:
LOGGER.debug("Received response: %s", response)
response = await self._stream_iterator.__anext__() # type: ignore[union-attr]
LOGGER.debug("Received response: %s", response)
self.on_message_stream_event(response)
def on_message_stream_event(self, event: MessageStreamEvent) -> None:
"""Handle MessageStreamEvent."""
if isinstance(event, RawMessageStartEvent):
self.on_message_start_event(event.message)
return
if isinstance(event, RawContentBlockStartEvent):
self.on_content_block_start_event(event.content_block, event.index)
return
if isinstance(event, RawContentBlockDeltaEvent):
self.on_content_block_delta_event(event.delta)
return
if isinstance(event, RawContentBlockStopEvent):
self.on_content_block_stop_event(event.index)
return
if isinstance(event, RawMessageDeltaEvent):
self.on_message_delta_event(event.delta, event.usage)
return
if isinstance(event, RawMessageStopEvent):
self.on_message_stop_event()
return
LOGGER.debug("Unhandled event type: %s", event.type) # type: ignore[unreachable] # pragma: no cover - All types are handled but we want to verify that
def on_message_start_event(self, message: Message) -> None:
"""Handle RawMessageStartEvent."""
self._input_usage = message.usage
self._first_block = True
def on_content_block_start_event(
self, content_block: ContentBlock, index: int
) -> None:
"""Handle RawContentBlockStartEvent."""
if isinstance(content_block, ToolUseBlock):
self.on_tool_use_block(
content_block.id,
content_block.input,
content_block.name,
content_block.caller,
)
return
if isinstance(content_block, TextBlock):
self.on_text_block(content_block.text, content_block.citations)
return
if isinstance(content_block, ThinkingBlock):
self.on_thinking_block(content_block.thinking, content_block.signature)
return
if isinstance(content_block, RedactedThinkingBlock):
self.on_redacted_thinking_block(content_block.data)
return
if isinstance(content_block, ServerToolUseBlock):
self.on_server_tool_use_block(
content_block.id,
content_block.name,
content_block.input,
content_block.caller,
)
return
if isinstance(
content_block,
(
WebSearchToolResultBlock,
CodeExecutionToolResultBlock,
BashCodeExecutionToolResultBlock,
TextEditorCodeExecutionToolResultBlock,
ToolSearchToolResultBlock,
),
):
self.on_server_tool_result_block(
content_block.tool_use_id,
content_block.type,
content_block.content,
content_block.caller if hasattr(content_block, "caller") else None,
)
return
LOGGER.debug("Unhandled content block type: %s", content_block.type)
def on_tool_use_block(
self, id: str, input: dict[str, Any], name: str, caller: Caller | None
) -> None:
"""Handle ToolUseBlock."""
self._current_tool_block = ToolUseBlockParam(
type="tool_use",
id=id,
name=name,
input=input,
)
self._current_tool_args = ""
if name == self._output_tool:
if self._first_block or self._content_details.has_content():
if self._content_details:
self._content_details.delete_empty()
self._buffer.append({"native": self._content_details})
self._content_details = ContentDetails()
self._content_details.add_citation_detail()
self._buffer.append({"role": "assistant"})
self._first_block = False
def on_text_block(self, text: str, citations: list[TextCitation] | None) -> None:
"""Handle TextBlock."""
if ( # Do not start a new assistant content just for citations, concatenate consecutive blocks with citations instead.
self._first_block
or (
not self._content_details.has_citations()
and citations is None
and self._content_details.has_content()
)
):
if self._content_details:
self._content_details.delete_empty()
self._buffer.append({"native": self._content_details})
self._content_details = ContentDetails()
self._buffer.append({"role": "assistant"})
self._first_block = False
self._content_details.add_citation_detail()
if text:
self._content_details.citation_details[-1].length += len(text)
self._buffer.append({"content": text})
def on_thinking_block(self, thinking: str, signature: str) -> None:
"""Handle ThinkingBlock."""
if self._first_block or self._content_details.thinking_signature:
if self._content_details:
self._content_details.delete_empty()
self._buffer.append({"native": self._content_details})
self._content_details = ContentDetails()
self._content_details.add_citation_detail()
self._buffer.append({"role": "assistant"})
self._first_block = False
def on_redacted_thinking_block(self, data: str) -> None:
"""Handle RedactedThinkingBlock."""
LOGGER.debug(
"Some of Claudes internal reasoning has been automatically "
"encrypted for safety reasons. This doesnt affect the quality of "
"responses"
)
if self._first_block or self._content_details.redacted_thinking:
if self._content_details:
self._content_details.delete_empty()
self._buffer.append({"native": self._content_details})
self._content_details = ContentDetails()
self._content_details.add_citation_detail()
self._buffer.append({"role": "assistant"})
self._first_block = False
self._content_details.redacted_thinking = data
def on_server_tool_use_block(
self,
id: str,
name: Literal[
"web_search",
"web_fetch",
"code_execution",
"bash_code_execution",
"text_editor_code_execution",
"tool_search_tool_regex",
"tool_search_tool_bm25",
],
input: dict[str, Any],
caller: Caller | None,
) -> None:
"""Handle ServerToolUseBlock."""
self._current_tool_block = ServerToolUseBlockParam(
type="server_tool_use",
id=id,
name=name,
input=input,
)
self._current_tool_args = ""
def on_server_tool_result_block(
self,
tool_use_id: str,
tool_name: Literal[
"web_search_tool_result",
"code_execution_tool_result",
"bash_code_execution_tool_result",
"text_editor_code_execution_tool_result",
"tool_search_tool_result",
],
content: WebSearchToolResultBlockContent
| CodeExecutionToolResultBlockContent
| BashCodeExecutionToolResultBlockContent
| TextEditorCodeExecutionToolResultBlockContent
| ToolSearchToolResultBlockContent,
caller: Caller | None,
) -> None:
"""Handle various server tool result blocks."""
if self._content_details:
self._content_details.delete_empty()
self._buffer.append({"native": self._content_details})
self._content_details = ContentDetails()
self._content_details.add_citation_detail()
self._buffer.append(
{
"role": "tool_result",
"tool_call_id": tool_use_id,
"tool_name": tool_name.removesuffix("_tool_result"),
"tool_result": {
"content": cast(JsonArrayType, [x.to_dict() for x in content])
if isinstance(response, RawMessageStartEvent):
input_usage = response.message.usage
first_block = True
elif isinstance(response, RawContentBlockStartEvent):
if isinstance(response.content_block, ToolUseBlock):
current_tool_block = ToolUseBlockParam(
type="tool_use",
id=response.content_block.id,
name=response.content_block.name,
input=response.content_block.input or {},
)
current_tool_args = ""
if response.content_block.name == output_tool:
if first_block or content_details.has_content():
if content_details:
content_details.delete_empty()
yield {"native": content_details}
content_details = ContentDetails()
content_details.add_citation_detail()
yield {"role": "assistant"}
first_block = False
elif isinstance(response.content_block, TextBlock):
if ( # Do not start a new assistant content just for citations, concatenate consecutive blocks with citations instead.
first_block
or (
not content_details.has_citations()
and response.content_block.citations is None
and content_details.has_content()
)
):
if content_details:
content_details.delete_empty()
yield {"native": content_details}
content_details = ContentDetails()
yield {"role": "assistant"}
first_block = False
content_details.add_citation_detail()
if response.content_block.text:
content_details.citation_details[-1].length += len(
response.content_block.text
)
yield {"content": response.content_block.text}
elif isinstance(response.content_block, ThinkingBlock):
if first_block or content_details.thinking_signature:
if content_details:
content_details.delete_empty()
yield {"native": content_details}
content_details = ContentDetails()
content_details.add_citation_detail()
yield {"role": "assistant"}
first_block = False
elif isinstance(response.content_block, RedactedThinkingBlock):
LOGGER.debug(
"Some of Claudes internal reasoning has been automatically "
"encrypted for safety reasons. This doesnt affect the quality of "
"responses"
)
if first_block or content_details.redacted_thinking:
if content_details:
content_details.delete_empty()
yield {"native": content_details}
content_details = ContentDetails()
content_details.add_citation_detail()
yield {"role": "assistant"}
first_block = False
content_details.redacted_thinking = response.content_block.data
elif isinstance(response.content_block, ServerToolUseBlock):
current_tool_block = ServerToolUseBlockParam(
type="server_tool_use",
id=response.content_block.id,
name=response.content_block.name,
input=response.content_block.input or {},
)
current_tool_args = ""
elif isinstance(
response.content_block,
(
WebSearchToolResultBlock,
CodeExecutionToolResultBlock,
BashCodeExecutionToolResultBlock,
TextEditorCodeExecutionToolResultBlock,
ToolSearchToolResultBlock,
),
):
if content_details:
content_details.delete_empty()
yield {"native": content_details}
content_details = ContentDetails()
content_details.add_citation_detail()
yield {
"role": "tool_result",
"tool_call_id": response.content_block.tool_use_id,
"tool_name": response.content_block.type.removesuffix(
"_tool_result"
),
"tool_result": {
"content": cast(
JsonObjectType, response.content_block.to_dict()["content"]
)
}
if isinstance(response.content_block.content, list)
else cast(JsonObjectType, response.content_block.content.to_dict()),
}
if isinstance(content, list)
else cast(JsonObjectType, content.to_dict()),
}
)
self._first_block = True
def on_content_block_delta_event(self, delta: RawContentBlockDelta) -> None:
"""Handle RawContentBlockDeltaEvent."""
if isinstance(delta, InputJSONDelta):
self.on_input_json_delta(delta.partial_json)
return
if isinstance(delta, TextDelta):
self.on_text_delta(delta.text)
return
if isinstance(delta, ThinkingDelta):
self.on_thinking_delta(delta.thinking)
return
if isinstance(delta, SignatureDelta):
self.on_signature_delta(delta.signature)
return
if isinstance(delta, CitationsDelta):
self.on_citations_delta(delta.citation)
return
LOGGER.debug("Unhandled content delta type: %s", delta.type) # type: ignore[unreachable] # pragma: no cover - All types are handled but we want to verify that
def on_input_json_delta(self, partial_json: str) -> None:
"""Handle InputJSONDelta."""
if (
self._current_tool_block is not None
and self._current_tool_block["name"] == self._output_tool
):
self._content_details.citation_details[-1].length += len(partial_json)
self._buffer.append({"content": partial_json})
else:
self._current_tool_args += partial_json
def on_text_delta(self, text: str) -> None:
"""Handle TextDelta."""
if text:
self._content_details.citation_details[-1].length += len(text)
self._buffer.append({"content": text})
def on_thinking_delta(self, thinking: str) -> None:
"""Handle ThinkingDelta."""
if thinking:
self._buffer.append({"thinking_content": thinking})
def on_signature_delta(self, signature: str) -> None:
"""Handle SignatureDelta."""
self._content_details.thinking_signature = signature
def on_citations_delta(self, citation: TextCitation) -> None:
"""Handle CitationsDelta."""
self._content_details.add_citation(citation)
def on_content_block_stop_event(self, index: int) -> None:
"""Handle RawContentBlockStopEvent."""
if self._current_tool_block is not None:
if self._current_tool_block["name"] == self._output_tool:
self._current_tool_block = None
return
tool_args = (
json.loads(self._current_tool_args) if self._current_tool_args else {}
)
self._current_tool_block["input"] |= tool_args
self._buffer.append(
{
first_block = True
elif isinstance(response, RawContentBlockDeltaEvent):
if isinstance(response.delta, InputJSONDelta):
if (
current_tool_block is not None
and current_tool_block["name"] == output_tool
):
content_details.citation_details[-1].length += len(
response.delta.partial_json
)
yield {"content": response.delta.partial_json}
else:
current_tool_args += response.delta.partial_json
elif isinstance(response.delta, TextDelta):
if response.delta.text:
content_details.citation_details[-1].length += len(
response.delta.text
)
yield {"content": response.delta.text}
elif isinstance(response.delta, ThinkingDelta):
if response.delta.thinking:
yield {"thinking_content": response.delta.thinking}
elif isinstance(response.delta, SignatureDelta):
content_details.thinking_signature = response.delta.signature
elif isinstance(response.delta, CitationsDelta):
content_details.add_citation(response.delta.citation)
elif isinstance(response, RawContentBlockStopEvent):
if current_tool_block is not None:
if current_tool_block["name"] == output_tool:
current_tool_block = None
continue
tool_args = json.loads(current_tool_args) if current_tool_args else {}
current_tool_block["input"] |= tool_args
yield {
"tool_calls": [
llm.ToolInput(
id=self._current_tool_block["id"],
tool_name=self._current_tool_block["name"],
tool_args=self._current_tool_block["input"],
external=self._current_tool_block["type"]
== "server_tool_use",
id=current_tool_block["id"],
tool_name=current_tool_block["name"],
tool_args=current_tool_block["input"],
external=current_tool_block["type"] == "server_tool_use",
)
]
}
)
self._current_tool_block = None
current_tool_block = None
elif isinstance(response, RawMessageDeltaEvent):
if (usage := response.usage) is not None:
chat_log.async_trace(_create_token_stats(input_usage, usage))
content_details.container = response.delta.container
if response.delta.stop_reason == "refusal":
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="api_refusal"
)
elif isinstance(response, RawMessageStopEvent):
if content_details:
content_details.delete_empty()
yield {"native": content_details}
content_details = ContentDetails()
content_details.add_citation_detail()
def on_message_delta_event(self, delta: Delta, usage: MessageDeltaUsage) -> None:
"""Handle RawMessageDeltaEvent."""
self._chat_log.async_trace(self._create_token_stats(self._input_usage, usage))
self._content_details.container = delta.container
if delta.stop_reason == "refusal":
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="api_refusal"
)
def on_message_stop_event(self) -> None:
"""Handle RawMessageStopEvent."""
if self._content_details:
self._content_details.delete_empty()
self._buffer.append({"native": self._content_details})
self._content_details = ContentDetails()
self._content_details.add_citation_detail()
def _create_token_stats(
self, input_usage: Usage | None, response_usage: MessageDeltaUsage
) -> dict[str, Any]:
"""Create token stats for conversation agent tracing."""
input_tokens = 0
cached_input_tokens = 0
if input_usage:
input_tokens = input_usage.input_tokens
cached_input_tokens = input_usage.cache_creation_input_tokens or 0
output_tokens = response_usage.output_tokens
return {
"stats": {
"input_tokens": input_tokens,
"cached_input_tokens": cached_input_tokens,
"output_tokens": output_tokens,
}
def _create_token_stats(
input_usage: Usage | None, response_usage: MessageDeltaUsage
) -> dict[str, Any]:
"""Create token stats for conversation agent tracing."""
input_tokens = 0
cached_input_tokens = 0
if input_usage:
input_tokens = input_usage.input_tokens
cached_input_tokens = input_usage.cache_creation_input_tokens or 0
output_tokens = response_usage.output_tokens
return {
"stats": {
"input_tokens": input_tokens,
"cached_input_tokens": cached_input_tokens,
"output_tokens": output_tokens,
}
}
class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
@@ -889,14 +700,15 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
entry_type=dr.DeviceEntryType.SERVICE,
)
async def _get_model_args( # noqa: C901
async def _async_handle_chat_log( # noqa: C901
self,
chat_log: conversation.ChatLog,
structure_name: str | None = None,
structure: vol.Schema | None = None,
) -> tuple[MessageCreateParamsStreaming, str | None]:
"""Get the model arguments."""
options: dict[str, Any] = DEFAULT | self.subentry.data
max_iterations: int = MAX_TOOL_ITERATIONS,
) -> None:
"""Generate an answer for the chat log."""
options = self.subentry.data
preloaded_tools = [
"HassTurnOn",
@@ -914,18 +726,21 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
messages, container_id = _convert_content(chat_log.content[1:])
model = options[CONF_CHAT_MODEL]
model = options.get(CONF_CHAT_MODEL, DEFAULT[CONF_CHAT_MODEL])
model_args = MessageCreateParamsStreaming(
model=model,
messages=messages,
max_tokens=options[CONF_MAX_TOKENS],
max_tokens=options.get(CONF_MAX_TOKENS, DEFAULT[CONF_MAX_TOKENS]),
system=system.content,
stream=True,
container=container_id,
)
if options[CONF_PROMPT_CACHING] == PromptCaching.PROMPT:
if (
options.get(CONF_PROMPT_CACHING, DEFAULT[CONF_PROMPT_CACHING])
== PromptCaching.PROMPT
):
model_args["system"] = [
{
"type": "text",
@@ -933,14 +748,19 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
"cache_control": {"type": "ephemeral"},
}
]
elif options[CONF_PROMPT_CACHING] == PromptCaching.AUTOMATIC:
elif (
options.get(CONF_PROMPT_CACHING, DEFAULT[CONF_PROMPT_CACHING])
== PromptCaching.AUTOMATIC
):
model_args["cache_control"] = {"type": "ephemeral"}
if (
self.model_info.capabilities
and self.model_info.capabilities.thinking.types.adaptive.supported
):
thinking_effort = options[CONF_THINKING_EFFORT]
thinking_effort = options.get(
CONF_THINKING_EFFORT, DEFAULT[CONF_THINKING_EFFORT]
)
if thinking_effort != "none":
model_args["thinking"] = ThinkingConfigAdaptiveParam(
type="adaptive", display="summarized"
@@ -948,8 +768,13 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
model_args["output_config"] = OutputConfigParam(effort=thinking_effort)
else:
model_args["thinking"] = ThinkingConfigDisabledParam(type="disabled")
model_args["temperature"] = options.get(
CONF_TEMPERATURE, DEFAULT[CONF_TEMPERATURE]
)
else:
thinking_budget = options[CONF_THINKING_BUDGET]
thinking_budget = options.get(
CONF_THINKING_BUDGET, DEFAULT[CONF_THINKING_BUDGET]
)
if (
self.model_info.capabilities
and self.model_info.capabilities.thinking.types.enabled.supported
@@ -960,13 +785,18 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
)
else:
model_args["thinking"] = ThinkingConfigDisabledParam(type="disabled")
model_args["temperature"] = options.get(
CONF_TEMPERATURE, DEFAULT[CONF_TEMPERATURE]
)
if (
self.model_info.capabilities
and self.model_info.capabilities.effort.supported
):
model_args["output_config"] = OutputConfigParam(
effort=options[CONF_THINKING_EFFORT]
effort=options.get(
CONF_THINKING_EFFORT, DEFAULT[CONF_THINKING_EFFORT]
)
)
tools: list[ToolUnionParam] = []
@@ -976,12 +806,12 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
for tool in chat_log.llm_api.tools
]
if options[CONF_CODE_EXECUTION]:
if options.get(CONF_CODE_EXECUTION):
# The `web_search_20260209` tool automatically enables `code_execution_20260120` tool
if (
not self.model_info.capabilities
or not self.model_info.capabilities.code_execution.supported
or not options[CONF_WEB_SEARCH]
or not options.get(CONF_WEB_SEARCH)
):
tools.append(
CodeExecutionTool20250825Param(
@@ -990,26 +820,26 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
),
)
if options[CONF_WEB_SEARCH]:
if options.get(CONF_WEB_SEARCH):
if (
not self.model_info.capabilities
or not self.model_info.capabilities.code_execution.supported
or not options[CONF_CODE_EXECUTION]
or not options.get(CONF_CODE_EXECUTION)
):
web_search: WebSearchTool20250305Param | WebSearchTool20260209Param = (
WebSearchTool20250305Param(
name="web_search",
type="web_search_20250305",
max_uses=options[CONF_WEB_SEARCH_MAX_USES],
max_uses=options.get(CONF_WEB_SEARCH_MAX_USES),
)
)
else:
web_search = WebSearchTool20260209Param(
name="web_search",
type="web_search_20260209",
max_uses=options[CONF_WEB_SEARCH_MAX_USES],
max_uses=options.get(CONF_WEB_SEARCH_MAX_USES),
)
if options[CONF_WEB_SEARCH_USER_LOCATION]:
if options.get(CONF_WEB_SEARCH_USER_LOCATION):
web_search["user_location"] = {
"type": "approximate",
"city": options.get(CONF_WEB_SEARCH_CITY, ""),
@@ -1110,7 +940,10 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
preloaded_tools.append(structure_name)
if tools:
if options[CONF_TOOL_SEARCH] and len(tools) > len(preloaded_tools) + 1:
if (
options.get(CONF_TOOL_SEARCH, DEFAULT[CONF_TOOL_SEARCH])
and len(tools) > len(preloaded_tools) + 1
):
for tool in tools:
if not tool["name"].endswith(tuple(preloaded_tools)):
tool["defer_loading"] = True
@@ -1123,19 +956,6 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
model_args["tools"] = tools
return model_args, structure_name
async def _async_handle_chat_log(
self,
chat_log: conversation.ChatLog,
structure_name: str | None = None,
structure: vol.Schema | None = None,
max_iterations: int = MAX_TOOL_ITERATIONS,
) -> None:
"""Generate an answer for the chat log."""
model_args, structure_name = await self._get_model_args(
chat_log, structure_name, structure
)
coordinator = self.entry.runtime_data
client = coordinator.client
@@ -1149,7 +969,7 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
content
async for content in chat_log.async_add_delta_content_stream(
self.entity_id,
AnthropicDeltaStream(
_transform_stream(
chat_log,
stream,
output_tool=structure_name or None,
@@ -1157,7 +977,7 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
)
]
)
cast(list[MessageParam], model_args["messages"]).extend(new_messages)
messages.extend(new_messages)
except anthropic.AuthenticationError as err:
# Trigger coordinator to confirm the auth failure and trigger the reauth flow.
await coordinator.async_request_refresh()
@@ -155,12 +155,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
hass.data[DATA_COMPONENT] = storage_collection
collection.DictStorageCollectionWebsocket(
storage_collection,
DOMAIN,
DOMAIN,
CREATE_FIELDS,
UPDATE_FIELDS,
admin_only=True,
storage_collection, DOMAIN, DOMAIN, CREATE_FIELDS, UPDATE_FIELDS
).async_setup(hass)
websocket_api.async_register_command(hass, handle_integration_list)
@@ -346,7 +341,6 @@ async def handle_integration_list(
vol.Required("config_entry_id"): str,
}
)
@websocket_api.require_admin
@websocket_api.async_response
async def handle_config_entry(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
+1 -1
View File
@@ -28,7 +28,7 @@ class AquacellEntity(CoordinatorEntity[AquacellCoordinator]):
self._attr_unique_id = f"{softener_key}-{entity_key}"
self._attr_device_info = DeviceInfo(
name=self.softener.name,
hw_version=self.softener.diagnostics.fw_version,
hw_version=self.softener.fwVersion,
identifiers={(DOMAIN, str(softener_key))},
manufacturer=self.softener.brand,
model=self.softener.ssn,
@@ -8,5 +8,5 @@
"integration_type": "device",
"iot_class": "cloud_polling",
"loggers": ["aioaquacell"],
"requirements": ["aioaquacell==1.0.0"]
"requirements": ["aioaquacell==0.2.0"]
}
+7 -7
View File
@@ -38,39 +38,39 @@ SENSORS: tuple[SoftenerSensorEntityDescription, ...] = (
translation_key="salt_left_side_percentage",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
value_fn=lambda softener: softener.salt.left_percent,
value_fn=lambda softener: softener.salt.leftPercent,
),
SoftenerSensorEntityDescription(
key="salt_right_side_percentage",
translation_key="salt_right_side_percentage",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
value_fn=lambda softener: softener.salt.right_percent,
value_fn=lambda softener: softener.salt.rightPercent,
),
SoftenerSensorEntityDescription(
key="salt_left_side_time_remaining",
translation_key="salt_left_side_time_remaining",
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.DAYS,
value_fn=lambda softener: softener.salt.left_days,
value_fn=lambda softener: softener.salt.leftDays,
),
SoftenerSensorEntityDescription(
key="salt_right_side_time_remaining",
translation_key="salt_right_side_time_remaining",
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.DAYS,
value_fn=lambda softener: softener.salt.right_days,
value_fn=lambda softener: softener.salt.rightDays,
),
SoftenerSensorEntityDescription(
key="battery",
device_class=SensorDeviceClass.BATTERY,
native_unit_of_measurement=PERCENTAGE,
value_fn=lambda softener: softener.diagnostics.battery,
value_fn=lambda softener: softener.battery,
),
SoftenerSensorEntityDescription(
key="wi_fi_strength",
translation_key="wi_fi_strength",
value_fn=lambda softener: softener.diagnostics.wifi_level,
value_fn=lambda softener: softener.wifiLevel,
device_class=SensorDeviceClass.ENUM,
options=[
"high",
@@ -82,7 +82,7 @@ SENSORS: tuple[SoftenerSensorEntityDescription, ...] = (
key="last_update",
translation_key="last_update",
device_class=SensorDeviceClass.TIMESTAMP,
value_fn=lambda softener: softener.diagnostics.last_update,
value_fn=lambda softener: softener.lastUpdate,
),
)
+18 -29
View File
@@ -4,9 +4,8 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
import logging
from arcam.fmj import IncomingVideoAspectRatio, IncomingVideoColorspace, IntOrTypeEnum
from arcam.fmj import IncomingVideoAspectRatio, IncomingVideoColorspace
from arcam.fmj.state import IncomingAudioConfig, IncomingAudioFormat, State
from homeassistant.components.sensor import (
@@ -22,25 +21,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import ArcamFmjConfigEntry
from .entity import ArcamFmjEntity
_LOGGER = logging.getLogger(__name__)
def _enum_options(value: type[IntOrTypeEnum]) -> list[str]:
return [
member.name.lower() for member in value if not member.name.startswith("CODE_")
]
def _enum_value(value: IntOrTypeEnum | None) -> str | None:
if value is None:
return None
if value.name.startswith("CODE_"):
_LOGGER.debug("Undefined enum value %s ignored", value)
return None
return value.name.lower()
@dataclass(frozen=True, kw_only=True)
class ArcamFmjSensorEntityDescription(SensorEntityDescription):
@@ -95,9 +75,9 @@ SENSORS: tuple[ArcamFmjSensorEntityDescription, ...] = (
translation_key="incoming_video_aspect_ratio",
entity_category=EntityCategory.DIAGNOSTIC,
device_class=SensorDeviceClass.ENUM,
options=_enum_options(IncomingVideoAspectRatio),
options=[member.name.lower() for member in IncomingVideoAspectRatio],
value_fn=lambda state: (
_enum_value(vp.aspect_ratio)
vp.aspect_ratio.name.lower()
if (vp := state.get_incoming_video_parameters()) is not None
else None
),
@@ -107,10 +87,11 @@ SENSORS: tuple[ArcamFmjSensorEntityDescription, ...] = (
translation_key="incoming_video_colorspace",
entity_category=EntityCategory.DIAGNOSTIC,
device_class=SensorDeviceClass.ENUM,
options=_enum_options(IncomingVideoColorspace),
options=[member.name.lower() for member in IncomingVideoColorspace],
value_fn=lambda state: (
_enum_value(vp.colorspace)
vp.colorspace.name.lower()
if (vp := state.get_incoming_video_parameters()) is not None
and vp.colorspace is not None
else None
),
),
@@ -119,16 +100,24 @@ SENSORS: tuple[ArcamFmjSensorEntityDescription, ...] = (
translation_key="incoming_audio_format",
entity_category=EntityCategory.DIAGNOSTIC,
device_class=SensorDeviceClass.ENUM,
options=_enum_options(IncomingAudioFormat),
value_fn=lambda state: _enum_value(state.get_incoming_audio_format()[0]),
options=[member.name.lower() for member in IncomingAudioFormat],
value_fn=lambda state: (
result.name.lower()
if (result := state.get_incoming_audio_format()[0]) is not None
else None
),
),
ArcamFmjSensorEntityDescription(
key="incoming_audio_config",
translation_key="incoming_audio_config",
entity_category=EntityCategory.DIAGNOSTIC,
device_class=SensorDeviceClass.ENUM,
options=_enum_options(IncomingAudioConfig),
value_fn=lambda state: _enum_value(state.get_incoming_audio_format()[1]),
options=[member.name.lower() for member in IncomingAudioConfig],
value_fn=lambda state: (
result.name.lower()
if (result := state.get_incoming_audio_format()[1]) is not None
else None
),
),
ArcamFmjSensorEntityDescription(
key="incoming_audio_sample_rate",
@@ -945,10 +945,7 @@ class PipelineRun:
try:
# Transcribe audio stream
stt_vad: VoiceCommandSegmenter | None = None
if (
self.audio_settings.is_vad_enabled
and self.stt_provider.audio_processing.requires_external_vad
):
if self.audio_settings.is_vad_enabled:
stt_vad = VoiceCommandSegmenter(
silence_seconds=self.audio_settings.silence_seconds
)
@@ -13,12 +13,11 @@ from hassil.util import (
)
import voluptuous as vol
from homeassistant.auth.permissions.const import CAT_ENTITIES, POLICY_CONTROL
from homeassistant.components.http import StaticPathConfig
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse
from homeassistant.exceptions import HomeAssistantError, Unauthorized, UnknownUser
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.typing import ConfigType
@@ -104,22 +103,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def handle_ask_question(call: ServiceCall) -> dict[str, Any]:
"""Handle a Show View service call."""
satellite_entity_id: str = call.data[ATTR_ENTITY_ID]
if call.context.user_id:
user = await hass.auth.async_get_user(call.context.user_id)
if user is None:
raise UnknownUser(
context=call.context,
permission=POLICY_CONTROL,
user_id=call.context.user_id,
)
if not user.permissions.check_entity(satellite_entity_id, POLICY_CONTROL):
raise Unauthorized(
context=call.context,
permission=POLICY_CONTROL,
user_id=call.context.user_id,
perm_category=CAT_ENTITIES,
)
satellite_entity: AssistSatelliteEntity | None = component.get_entity(
satellite_entity_id
)
@@ -7,17 +7,13 @@ from .const import DOMAIN
from .entity import AssistSatelliteState
CONDITIONS: dict[str, type[Condition]] = {
"is_idle": make_entity_state_condition(
DOMAIN, AssistSatelliteState.IDLE, support_duration=True
),
"is_listening": make_entity_state_condition(
DOMAIN, AssistSatelliteState.LISTENING, support_duration=True
),
"is_idle": make_entity_state_condition(DOMAIN, AssistSatelliteState.IDLE),
"is_listening": make_entity_state_condition(DOMAIN, AssistSatelliteState.LISTENING),
"is_processing": make_entity_state_condition(
DOMAIN, AssistSatelliteState.PROCESSING, support_duration=True
DOMAIN, AssistSatelliteState.PROCESSING
),
"is_responding": make_entity_state_condition(
DOMAIN, AssistSatelliteState.RESPONDING, support_duration=True
DOMAIN, AssistSatelliteState.RESPONDING
),
}
@@ -12,11 +12,6 @@
options:
- all
- any
for:
required: true
default: 00:00:00
selector:
duration:
is_idle: *condition_common
is_listening: *condition_common
@@ -1,7 +1,6 @@
{
"common": {
"condition_behavior_name": "Condition passes if",
"condition_for_name": "For at least",
"trigger_behavior_name": "Trigger when",
"trigger_for_name": "For at least"
},
@@ -11,9 +10,6 @@
"fields": {
"behavior": {
"name": "[%key:component::assist_satellite::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::assist_satellite::common::condition_for_name%]"
}
},
"name": "Satellite is idle"
@@ -23,9 +19,6 @@
"fields": {
"behavior": {
"name": "[%key:component::assist_satellite::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::assist_satellite::common::condition_for_name%]"
}
},
"name": "Satellite is listening"
@@ -35,9 +28,6 @@
"fields": {
"behavior": {
"name": "[%key:component::assist_satellite::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::assist_satellite::common::condition_for_name%]"
}
},
"name": "Satellite is processing"
@@ -47,9 +37,6 @@
"fields": {
"behavior": {
"name": "[%key:component::assist_satellite::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::assist_satellite::common::condition_for_name%]"
}
},
"name": "Satellite is responding"
@@ -165,7 +165,6 @@ async def websocket_set_wake_words(
vol.Required("entity_id"): cv.entity_domain(DOMAIN),
}
)
@websocket_api.require_admin
@websocket_api.async_response
async def websocket_test_connection(
hass: HomeAssistant,
+25 -18
View File
@@ -15,6 +15,24 @@ from homeassistant.data_entry_flow import FlowContext
from homeassistant.helpers import config_validation as cv
from homeassistant.util.hass_dict import HassKey
WS_TYPE_SETUP_MFA = "auth/setup_mfa"
SCHEMA_WS_SETUP_MFA = vol.All(
websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend(
{
vol.Required("type"): WS_TYPE_SETUP_MFA,
vol.Exclusive("mfa_module_id", "module_or_flow_id"): str,
vol.Exclusive("flow_id", "module_or_flow_id"): str,
vol.Optional("user_input"): object,
}
),
cv.has_at_least_one_key("mfa_module_id", "flow_id"),
)
WS_TYPE_DEPOSE_MFA = "auth/depose_mfa"
SCHEMA_WS_DEPOSE_MFA = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend(
{vol.Required("type"): WS_TYPE_DEPOSE_MFA, vol.Required("mfa_module_id"): str}
)
DATA_SETUP_FLOW_MGR: HassKey[MfaFlowManager] = HassKey("auth_mfa_setup_flow_manager")
_LOGGER = logging.getLogger(__name__)
@@ -55,24 +73,16 @@ def async_setup(hass: HomeAssistant) -> None:
"""Init mfa setup flow manager."""
hass.data[DATA_SETUP_FLOW_MGR] = MfaFlowManager(hass)
websocket_api.async_register_command(hass, websocket_setup_mfa)
websocket_api.async_register_command(hass, websocket_depose_mfa)
websocket_api.async_register_command(
hass, WS_TYPE_SETUP_MFA, websocket_setup_mfa, SCHEMA_WS_SETUP_MFA
)
websocket_api.async_register_command(
hass, WS_TYPE_DEPOSE_MFA, websocket_depose_mfa, SCHEMA_WS_DEPOSE_MFA
)
@callback
@websocket_api.websocket_command(
vol.All(
vol.Schema(
{
vol.Required("type"): "auth/setup_mfa",
vol.Exclusive("mfa_module_id", "module_or_flow_id"): str,
vol.Exclusive("flow_id", "module_or_flow_id"): str,
vol.Optional("user_input"): object,
}
),
cv.has_at_least_one_key("mfa_module_id", "flow_id"),
)
)
@websocket_api.ws_require_user(allow_system_user=False)
def websocket_setup_mfa(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
@@ -111,9 +121,6 @@ def websocket_setup_mfa(
@callback
@websocket_api.websocket_command(
{vol.Required("type"): "auth/depose_mfa", vol.Required("mfa_module_id"): str}
)
@websocket_api.ws_require_user(allow_system_user=False)
def websocket_depose_mfa(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
@@ -4,10 +4,10 @@ from __future__ import annotations
from abc import ABC, abstractmethod
import asyncio
from collections.abc import Callable
from collections.abc import Callable, Mapping
from dataclasses import dataclass
import logging
from typing import Any, cast
from typing import Any, Protocol, cast
from propcache.api import cached_property
import voluptuous as vol
@@ -169,7 +169,6 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
"cover",
"device_tracker",
"door",
"doorbell",
"event",
"fan",
"garage_door",
@@ -229,11 +228,14 @@ def is_disabled_experimental_trigger(hass: HomeAssistant, platform: str) -> bool
)
class IfAction(condition_helper.ConditionsChecker):
class IfAction(Protocol):
"""Define the format of if_action."""
config: list[ConfigType]
def __call__(self, variables: Mapping[str, Any] | None = None) -> bool:
"""AND all conditions."""
def is_on(hass: HomeAssistant, entity_id: str) -> bool:
"""Return true if specified automation entity_id is on.
@@ -832,7 +834,7 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
if (
not skip_condition
and self._condition is not None
and not self._condition.async_check(variables=variables)
and not self._condition(variables)
):
self._logger.debug(
"Conditions not met, aborting automation. Condition summary: %s",
@@ -901,9 +903,6 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
"""Remove listeners when removing automation from Home Assistant."""
await super().async_will_remove_from_hass()
await self._async_disable()
self.action_script.async_unload()
if self._condition is not None:
self._condition.async_unload()
async def _async_enable_automation(self, event: Event) -> None:
"""Start automation on startup."""
@@ -1276,7 +1275,6 @@ async def _async_process_if(
@websocket_api.websocket_command({"type": "automation/config", "entity_id": str})
@websocket_api.require_admin
def websocket_config(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
+11 -22
View File
@@ -1,12 +1,8 @@
"""Support for Amazon Web Services (AWS)."""
from __future__ import annotations
import asyncio
from collections import OrderedDict
from dataclasses import dataclass
import logging
from typing import Any
from aiobotocore.session import AioSession
import voluptuous as vol
@@ -34,22 +30,14 @@ from .const import (
CONF_REGION,
CONF_SECRET_ACCESS_KEY,
CONF_VALIDATE,
DATA_AWS,
DATA_CONFIG,
DATA_HASS_CONFIG,
DATA_SESSIONS,
DOMAIN,
)
_LOGGER = logging.getLogger(__name__)
@dataclass
class AWSData:
"""Runtime data for the AWS integration."""
hass_config: ConfigType
config: dict[str, Any]
sessions: OrderedDict[str, AioSession]
AWS_CREDENTIAL_SCHEMA = vol.Schema(
{
vol.Required(CONF_NAME): cv.string,
@@ -100,13 +88,14 @@ CONFIG_SCHEMA = vol.Schema(
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up AWS component."""
hass.data[DATA_HASS_CONFIG] = config
if (conf := config.get(DOMAIN)) is None:
# create a default conf using default profile
conf = CONFIG_SCHEMA({ATTR_CREDENTIALS: DEFAULT_CREDENTIAL})
hass.data[DATA_AWS] = AWSData(
hass_config=config, config=conf, sessions=OrderedDict()
)
hass.data[DATA_CONFIG] = conf
hass.data[DATA_SESSIONS] = OrderedDict()
hass.async_create_task(
hass.config_entries.flow.async_init(
@@ -122,8 +111,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
Validate and save sessions per aws credential.
"""
data = hass.data[DATA_AWS]
conf = data.config
config = hass.data[DATA_HASS_CONFIG]
conf = hass.data[DATA_CONFIG]
if entry.source == config_entries.SOURCE_IMPORT:
if conf is None:
@@ -154,14 +143,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
validation = False
else:
data.sessions[name] = result
hass.data[DATA_SESSIONS][name] = result
# set up notify platform, no entry support for notify component yet,
# have to use discovery to load platform.
for notify_config in conf[CONF_NOTIFY]:
hass.async_create_task(
discovery.async_load_platform(
hass, Platform.NOTIFY, DOMAIN, notify_config, data.hass_config
hass, Platform.NOTIFY, DOMAIN, notify_config, config
)
)
+3 -10
View File
@@ -1,17 +1,10 @@
"""Constant for AWS component."""
from __future__ import annotations
from typing import TYPE_CHECKING
from homeassistant.util.hass_dict import HassKey
if TYPE_CHECKING:
from . import AWSData
DOMAIN = "aws"
DATA_AWS: HassKey[AWSData] = HassKey(DOMAIN)
DATA_CONFIG = "aws_config"
DATA_HASS_CONFIG = "aws_hass_config"
DATA_SESSIONS = "aws_sessions"
CONF_ACCESS_KEY_ID = "aws_access_key_id"
CONF_CONTEXT = "context"
+4 -6
View File
@@ -27,7 +27,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.json import JSONEncoder
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import CONF_CONTEXT, CONF_CREDENTIAL_NAME, CONF_REGION, DATA_AWS
from .const import CONF_CONTEXT, CONF_CREDENTIAL_NAME, CONF_REGION, DATA_SESSIONS
_LOGGER = logging.getLogger(__name__)
@@ -76,12 +76,10 @@ async def async_get_service(
if CONF_CONTEXT in aws_config:
del aws_config[CONF_CONTEXT]
sessions = hass.data[DATA_AWS].sessions
if not aws_config:
# no platform config, use the first aws component credential instead
if sessions:
session = next(iter(sessions.values()))
if hass.data[DATA_SESSIONS]:
session = next(iter(hass.data[DATA_SESSIONS].values()))
else:
_LOGGER.error("Missing aws credential for %s", config[CONF_NAME])
return None
@@ -89,7 +87,7 @@ async def async_get_service(
if session is None:
credential_name = aws_config.get(CONF_CREDENTIAL_NAME)
if credential_name is not None:
session = sessions.get(credential_name)
session = hass.data[DATA_SESSIONS].get(credential_name)
if session is None:
_LOGGER.warning("No available aws session for %s", credential_name)
del aws_config[CONF_CREDENTIAL_NAME]
@@ -5,7 +5,10 @@ from __future__ import annotations
import dataclasses
from typing import Any
from homeassistant.components.backup import DATA_MANAGER as BACKUP_DATA_MANAGER
from homeassistant.components.backup import (
DATA_MANAGER as BACKUP_DATA_MANAGER,
BackupManager,
)
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.core import HomeAssistant
@@ -28,7 +31,7 @@ async def async_get_config_entry_diagnostics(
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
coordinator = entry.runtime_data
backup_manager = hass.data[BACKUP_DATA_MANAGER]
backup_manager: BackupManager = hass.data[BACKUP_DATA_MANAGER]
backups = await async_list_backups_from_s3(
coordinator.client,
bucket=entry.data[CONF_BUCKET],
-1
View File
@@ -36,7 +36,6 @@ async def get_axis_api(
username=config[CONF_USERNAME],
password=config[CONF_PASSWORD],
web_proto=config.get(CONF_PROTOCOL, "http"),
websocket_enabled=True,
)
)
+1 -1
View File
@@ -29,7 +29,7 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["axis"],
"requirements": ["axis==69"],
"requirements": ["axis==68"],
"ssdp": [
{
"manufacturer": "AXIS"
+3 -6
View File
@@ -2,7 +2,6 @@
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers.hassio import is_hassio
from homeassistant.helpers.service import async_register_admin_service
from .const import DATA_MANAGER, DOMAIN
@@ -31,9 +30,7 @@ async def _async_handle_create_automatic_service(call: ServiceCall) -> None:
def async_setup_services(hass: HomeAssistant) -> None:
"""Register services."""
if not is_hassio(hass):
async_register_admin_service(
hass, DOMAIN, "create", _async_handle_create_service
)
async_register_admin_service(
hass, DOMAIN, "create_automatic", _async_handle_create_automatic_service
hass.services.async_register(DOMAIN, "create", _async_handle_create_service)
hass.services.async_register(
DOMAIN, "create_automatic", _async_handle_create_automatic_service
)
@@ -21,9 +21,8 @@ from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.ssl import get_default_context
from .const import DOMAIN, MANUFACTURER, BeoModel
from .const import DOMAIN
from .services import async_setup_services
from .util import get_remotes
from .websocket import BeoWebsocket
@@ -59,6 +58,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: BeoConfigEntry) -> bool:
# Remove casts to str
assert entry.unique_id
# Create device now as BeoWebsocket needs a device for debug logging, firing events etc.
device_registry = dr.async_get(hass)
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, entry.unique_id)},
name=entry.title,
model=entry.data[CONF_MODEL],
)
client = MozartClient(host=entry.data[CONF_HOST], ssl_context=get_default_context())
# Check API and WebSocket connection
@@ -75,27 +83,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: BeoConfigEntry) -> bool:
await client.close_api_client()
raise ConfigEntryNotReady(f"Unable to connect to {entry.title}") from error
# Create device now as BeoWebsocket needs a device for debug logging, firing events etc.
device_registry = dr.async_get(hass)
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, entry.unique_id)},
model=entry.data[CONF_MODEL],
)
# Create devices for paired Beoremote One remotes
for remote in await get_remotes(client):
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, f"{remote.serial_number}_{entry.unique_id}")},
name=f"{BeoModel.BEOREMOTE_ONE}-{remote.serial_number}-{entry.unique_id}",
model=BeoModel.BEOREMOTE_ONE,
serial_number=remote.serial_number,
sw_version=remote.app_version,
manufacturer=MANUFACTURER,
via_device=(DOMAIN, entry.unique_id),
)
websocket = BeoWebsocket(hass, entry, client)
# Add the websocket and API client
@@ -52,7 +52,6 @@ class BeoConfigFlowHandler(ConfigFlow, domain=DOMAIN):
_beolink_jid = ""
_client: MozartClient
_friendly_name = ""
_host = ""
_model = ""
_name = ""
@@ -112,7 +111,6 @@ class BeoConfigFlowHandler(ConfigFlow, domain=DOMAIN):
)
self._beolink_jid = beolink_self.jid
self._friendly_name = beolink_self.friendly_name
self._serial_number = get_serial_number_from_jid(beolink_self.jid)
await self.async_set_unique_id(self._serial_number)
@@ -151,7 +149,6 @@ class BeoConfigFlowHandler(ConfigFlow, domain=DOMAIN):
return self.async_abort(reason="invalid_address")
self._model = discovery_info.hostname[:-16].replace("-", " ")
self._friendly_name = discovery_info.properties[ATTR_FRIENDLY_NAME]
self._serial_number = discovery_info.properties[ATTR_SERIAL_NUMBER]
self._beolink_jid = f"{discovery_info.properties[ATTR_TYPE_NUMBER]}.{discovery_info.properties[ATTR_ITEM_NUMBER]}.{self._serial_number}@products.bang-olufsen.com"
@@ -167,13 +164,16 @@ class BeoConfigFlowHandler(ConfigFlow, domain=DOMAIN):
async def _create_entry(self) -> ConfigFlowResult:
"""Create the config entry for a discovered or manually configured Bang & Olufsen device."""
# Ensure that created entities have a unique and easily identifiable id and not a "friendly name"
self._name = f"{self._model}-{self._serial_number}"
return self.async_create_entry(
title=self._friendly_name,
title=self._name,
data=EntryData(
host=self._host,
jid=self._beolink_jid,
model=self._model,
name=self._friendly_name,
name=self._name,
),
)
@@ -20,6 +20,7 @@ from .const import (
CONNECTION_STATUS,
DEVICE_BUTTON_EVENTS,
DOMAIN,
MANUFACTURER,
BeoModel,
WebsocketNotification,
)
@@ -141,6 +142,12 @@ class BeoRemoteKeyEvent(BeoEvent):
self._attr_unique_id = f"{remote.serial_number}_{self._unique_id}_{key_type}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"{remote.serial_number}_{self._unique_id}")},
name=f"{BeoModel.BEOREMOTE_ONE}-{remote.serial_number}-{self._unique_id}",
model=BeoModel.BEOREMOTE_ONE,
serial_number=remote.serial_number,
sw_version=remote.app_version,
manufacturer=MANUFACTURER,
via_device=(DOMAIN, self._unique_id),
)
# Make the native key name Home Assistant compatible
@@ -115,7 +115,7 @@ class BeoSensorRemoteBatteryLevel(BeoSensor):
f"{remote.serial_number}_{self._unique_id}_remote_battery_level"
)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"{remote.serial_number}_{self._unique_id}")},
identifiers={(DOMAIN, f"{remote.serial_number}_{self._unique_id}")}
)
self._attr_native_value = remote.battery_level
self._remote = remote
@@ -34,7 +34,7 @@ def get_device(hass: HomeAssistant, unique_id: str) -> DeviceEntry:
def get_serial_number_from_jid(jid: str) -> str:
"""Get serial number from Beolink JID."""
return jid.split(".")[2].split("@", maxsplit=1)[0]
return jid.split(".")[2].split("@")[0]
async def get_remotes(client: MozartClient) -> list[PairedRemote]:
+4 -10
View File
@@ -29,17 +29,11 @@ BATTERY_PERCENTAGE_DOMAIN_SPECS = {
}
CONDITIONS: dict[str, type[Condition]] = {
"is_low": make_entity_state_condition(
BATTERY_DOMAIN_SPECS, STATE_ON, support_duration=True
),
"is_not_low": make_entity_state_condition(
BATTERY_DOMAIN_SPECS, STATE_OFF, support_duration=True
),
"is_charging": make_entity_state_condition(
BATTERY_CHARGING_DOMAIN_SPECS, STATE_ON, support_duration=True
),
"is_low": make_entity_state_condition(BATTERY_DOMAIN_SPECS, STATE_ON),
"is_not_low": make_entity_state_condition(BATTERY_DOMAIN_SPECS, STATE_OFF),
"is_charging": make_entity_state_condition(BATTERY_CHARGING_DOMAIN_SPECS, STATE_ON),
"is_not_charging": make_entity_state_condition(
BATTERY_CHARGING_DOMAIN_SPECS, STATE_OFF, support_duration=True
BATTERY_CHARGING_DOMAIN_SPECS, STATE_OFF
),
"is_level": make_entity_numerical_condition(
BATTERY_PERCENTAGE_DOMAIN_SPECS, PERCENTAGE
@@ -13,11 +13,6 @@
options:
- all
- any
for: &condition_for
required: true
default: 00:00:00
selector:
duration:
.battery_threshold_entity: &battery_threshold_entity
- domain: input_number
@@ -44,7 +39,6 @@ is_charging:
device_class: battery_charging
fields:
behavior: *condition_behavior
for: *condition_for
is_not_charging:
target:
@@ -53,7 +47,6 @@ is_not_charging:
device_class: battery_charging
fields:
behavior: *condition_behavior
for: *condition_for
is_level:
target:
@@ -1,7 +1,6 @@
{
"common": {
"condition_behavior_name": "Condition passes if",
"condition_for_name": "For at least",
"condition_threshold_name": "Threshold type",
"trigger_behavior_name": "Trigger when",
"trigger_for_name": "For at least",
@@ -13,9 +12,6 @@
"fields": {
"behavior": {
"name": "[%key:component::battery::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::battery::common::condition_for_name%]"
}
},
"name": "Battery is charging"
@@ -37,9 +33,6 @@
"fields": {
"behavior": {
"name": "[%key:component::battery::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::battery::common::condition_for_name%]"
}
},
"name": "Battery is low"
@@ -49,9 +42,6 @@
"fields": {
"behavior": {
"name": "[%key:component::battery::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::battery::common::condition_for_name%]"
}
},
"name": "Battery is not charging"
@@ -61,9 +51,6 @@
"fields": {
"behavior": {
"name": "[%key:component::battery::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::battery::common::condition_for_name%]"
}
},
"name": "Battery is not low"
+6 -14
View File
@@ -32,27 +32,19 @@ BATTERY_PERCENTAGE_DOMAIN_SPECS: dict[str, DomainSpec] = {
}
TRIGGERS: dict[str, type[Trigger]] = {
"low": make_entity_target_state_trigger(
BATTERY_LOW_DOMAIN_SPECS, STATE_ON, primary_entities_only=False
),
"not_low": make_entity_target_state_trigger(
BATTERY_LOW_DOMAIN_SPECS, STATE_OFF, primary_entities_only=False
),
"low": make_entity_target_state_trigger(BATTERY_LOW_DOMAIN_SPECS, STATE_ON),
"not_low": make_entity_target_state_trigger(BATTERY_LOW_DOMAIN_SPECS, STATE_OFF),
"started_charging": make_entity_target_state_trigger(
BATTERY_CHARGING_DOMAIN_SPECS, STATE_ON, primary_entities_only=False
BATTERY_CHARGING_DOMAIN_SPECS, STATE_ON
),
"stopped_charging": make_entity_target_state_trigger(
BATTERY_CHARGING_DOMAIN_SPECS, STATE_OFF, primary_entities_only=False
BATTERY_CHARGING_DOMAIN_SPECS, STATE_OFF
),
"level_changed": make_entity_numerical_state_changed_trigger(
BATTERY_PERCENTAGE_DOMAIN_SPECS,
valid_unit="%",
primary_entities_only=False,
BATTERY_PERCENTAGE_DOMAIN_SPECS, valid_unit="%"
),
"level_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
BATTERY_PERCENTAGE_DOMAIN_SPECS,
valid_unit="%",
primary_entities_only=False,
BATTERY_PERCENTAGE_DOMAIN_SPECS, valid_unit="%"
),
}
@@ -33,19 +33,16 @@
entity:
- domain: binary_sensor
device_class: battery
primary_entities_only: false
.trigger_target_charging: &trigger_target_charging
entity:
- domain: binary_sensor
device_class: battery_charging
primary_entities_only: false
.trigger_target_percentage: &trigger_target_percentage
entity:
- domain: sensor
device_class: battery
primary_entities_only: false
low:
fields:
@@ -7,6 +7,6 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["blebox_uniapi"],
"requirements": ["blebox-uniapi==2.5.1"],
"requirements": ["blebox-uniapi==2.5.0"],
"zeroconf": ["_bbxsrv._tcp.local."]
}
@@ -21,6 +21,9 @@
"save_video": {
"service": "mdi:file-video"
},
"send_pin": {
"service": "mdi:two-factor-authentication"
},
"trigger_camera": {
"service": "mdi:image-refresh"
}
+49 -3
View File
@@ -5,9 +5,15 @@ from __future__ import annotations
import voluptuous as vol
from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN
from homeassistant.const import CONF_FILE_PATH, CONF_FILENAME
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, service
from homeassistant.const import (
ATTR_CONFIG_ENTRY_ID,
CONF_FILE_PATH,
CONF_FILENAME,
CONF_PIN,
)
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, issue_registry as ir, service
from .const import DOMAIN
@@ -17,10 +23,50 @@ SERVICE_SAVE_VIDEO = "save_video"
SERVICE_SAVE_RECENT_CLIPS = "save_recent_clips"
# Deprecated
SERVICE_SEND_PIN = "send_pin"
SERVICE_SEND_PIN_SCHEMA = vol.Schema(
{
vol.Required(ATTR_CONFIG_ENTRY_ID): vol.All(cv.ensure_list, [cv.string]),
vol.Optional(CONF_PIN): cv.string,
}
)
async def _send_pin(call: ServiceCall) -> None:
"""Call blink to send new pin."""
# Create repair issue to inform user about service removal
ir.async_create_issue(
call.hass,
DOMAIN,
"service_send_pin_deprecation",
is_fixable=False,
issue_domain=DOMAIN,
severity=ir.IssueSeverity.ERROR,
breaks_in_ha_version="2026.5.0",
translation_key="service_send_pin_deprecation",
translation_placeholders={"service_name": f"{DOMAIN}.{SERVICE_SEND_PIN}"},
)
# Service has been removed - raise exception
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="service_removed",
translation_placeholders={"service_name": f"{DOMAIN}.{SERVICE_SEND_PIN}"},
)
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Set up the services for the Blink integration."""
hass.services.async_register(
DOMAIN,
SERVICE_SEND_PIN,
_send_pin,
schema=SERVICE_SEND_PIN_SCHEMA,
)
service.async_register_platform_entity_service(
hass,
DOMAIN,
@@ -35,3 +35,15 @@ save_recent_clips:
example: "/tmp"
selector:
text:
send_pin:
fields:
config_entry_id:
required: true
selector:
config_entry:
integration: blink
pin:
example: "abc123"
selector:
text:
@@ -82,6 +82,9 @@
},
"not_loaded": {
"message": "{target} is not loaded."
},
"service_removed": {
"message": "The service {service_name} has been removed and is no longer needed. Home Assistant will automatically prompt for reauthentication when required."
}
},
"issues": {
@@ -95,6 +98,10 @@
}
},
"title": "Blink update service is being removed"
},
"service_send_pin_deprecation": {
"description": "The service {service_name} has been removed and is no longer needed. When a new two-factor authentication code is required, Home Assistant will automatically prompt you to reauthenticate through the integration configuration. Please remove any automations or scripts that call this service.",
"title": "Blink send PIN service has been removed"
}
},
"options": {
@@ -133,6 +140,20 @@
},
"name": "Save video"
},
"send_pin": {
"description": "Sends a new PIN to Blink for 2FA.",
"fields": {
"config_entry_id": {
"description": "The Blink integration ID.",
"name": "Integration ID"
},
"pin": {
"description": "PIN received from Blink. Leave empty if you only received a verification email.",
"name": "PIN"
}
},
"name": "Send PIN"
},
"trigger_camera": {
"description": "Requests camera to take new image.",
"name": "Trigger camera"
+8 -2
View File
@@ -30,6 +30,7 @@ from .models import BluetoothCallback, BluetoothChange, ProcessAdvertisementCall
if TYPE_CHECKING:
from bleak.backends.device import BLEDevice
from bleak.backends.scanner import AdvertisementDataCallback
@singleton(DATA_MANAGER)
@@ -39,7 +40,10 @@ def _get_manager(hass: HomeAssistant) -> HomeAssistantBluetoothManager:
@hass_callback
def async_get_scanner(hass: HomeAssistant) -> BleakScanner:
def async_get_scanner(
hass: HomeAssistant,
detection_callback: AdvertisementDataCallback | None = None,
) -> BleakScanner:
"""Return a HaBleakScannerWrapper cast to BleakScanner.
This is a wrapper around our BleakScanner singleton that allows
@@ -48,7 +52,9 @@ def async_get_scanner(hass: HomeAssistant) -> BleakScanner:
The wrapper is cast to BleakScanner for type compatibility with
libraries expecting a BleakScanner instance.
"""
return cast(BleakScanner, HaBleakScannerWrapper())
return cast(
BleakScanner, HaBleakScannerWrapper(detection_callback=detection_callback)
)
@hass_callback

Some files were not shown because too many files have changed in this diff Show More