mirror of
https://github.com/home-assistant/core.git
synced 2026-06-11 11:41:42 +02:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bfcbfb8725 | |||
| 675969e36b | |||
| ff43e12449 | |||
| 7dfef5c82a | |||
| b75cd0f6a7 | |||
| 7859aba432 |
@@ -8,8 +8,39 @@ description: Reviews GitHub pull requests and provides feedback comments. This i
|
||||
## Follow these steps:
|
||||
1. Use 'gh pr view' to get the PR details and description.
|
||||
2. Use 'gh pr diff' to see all the changes in the PR.
|
||||
3. Review the changes following the `review` skill. It is VERY IMPORTANT to follow the `review` skill instructions.
|
||||
4. Check if all existing review comments have been addressed.
|
||||
3. Analyze the code changes for:
|
||||
- Code quality and style consistency
|
||||
- Potential bugs or issues
|
||||
- Performance implications
|
||||
- Security concerns
|
||||
- Test coverage
|
||||
- Documentation updates if needed
|
||||
4. Ensure any existing review comments have been addressed.
|
||||
5. Generate constructive review comments in the CONSOLE. DO NOT POST TO GITHUB YOURSELF.
|
||||
|
||||
## Verification:
|
||||
|
||||
- After the review, run parallel subagents for each finding to double check it.
|
||||
- Spawn up to a maximum of 10 parallel subagents at a time.
|
||||
- Gather the results from the subagents and summarize them in the final review comments.
|
||||
|
||||
|
||||
## IMPORTANT:
|
||||
- Just review. DO NOT make any changes
|
||||
- Be constructive and specific in your comments
|
||||
- Suggest improvements where appropriate
|
||||
- Only provide review feedback in the CONSOLE. DO NOT ACT ON GITHUB.
|
||||
- No need to run tests or linters, just review the code changes.
|
||||
- No need to highlight things that are already good.
|
||||
|
||||
## Output format:
|
||||
- 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
|
||||
```
|
||||
- Make sure to include the file and line number when possible in the bullet points.
|
||||
|
||||
@@ -24,7 +24,6 @@ The following platforms have extra guidelines:
|
||||
## Entity platforms
|
||||
|
||||
- Ensure `async_added_to_hass()` and `async_will_remove_from_hass()` have symmetrical behavior. For example, if a subscription is created in `async_added_to_hass()`, it should be unsubscribed in `async_will_remove_from_hass()`. Also, if something is torn down in `async_will_remove_from_hass()`, it should be set up in `async_added_to_hass()`.
|
||||
- Entity base class (e.g. `SensorEntity`, `TrackerEntity`) provide a stable API for child classes to inherit from. Do not suggest redeclaring or duplicating attributes, properties, or methods the base class already provides, and do not add guards against the parent's behavior changing — rely on the base class instead.
|
||||
|
||||
## Integration Quality Scale
|
||||
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
---
|
||||
name: review
|
||||
description: Reviews code changes and provides constructive feedback. Should be used when a review is requested to provide a consistent review behavior and output format. This skill can be used for code reviews in general, not just for GitHub pull requests.
|
||||
---
|
||||
|
||||
# Review Code Changes
|
||||
|
||||
## Analyze the code changes for:
|
||||
- Code quality and style consistency
|
||||
- Potential bugs or issues
|
||||
- Performance implications
|
||||
- Security concerns
|
||||
- Test coverage
|
||||
- Documentation updates if needed
|
||||
|
||||
## Verification:
|
||||
- After the review, run parallel subagents for each finding to double-check it.
|
||||
- Spawn up to a maximum of 10 parallel subagents at a time.
|
||||
- Gather the results from the subagents and summarize them in the final review comments.
|
||||
|
||||
## IMPORTANT:
|
||||
- Just review. DO NOT make any changes.
|
||||
- Be constructive and specific in your comments.
|
||||
- Suggest improvements where appropriate.
|
||||
- No need to run tests or linters, just review the code changes.
|
||||
- No need to highlight things that are already good.
|
||||
|
||||
## Output format:
|
||||
- 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
|
||||
```
|
||||
- Make sure to include the file and line number when possible in the bullet points.
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/github-cli:1": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "ghcr.io/devcontainers/features/github-cli@sha256:d22f50b70ed75339b4eed1ba9ecde3a1791f90e88d37936517e3bace0bbad671",
|
||||
"integrity": "sha256:d22f50b70ed75339b4eed1ba9ecde3a1791f90e88d37936517e3bace0bbad671"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,6 @@ Dockerfile.dev linguist-language=Dockerfile
|
||||
# Generated files
|
||||
CODEOWNERS linguist-generated=true
|
||||
homeassistant/generated/*.py linguist-generated=true
|
||||
pylint/plugins/pylint_home_assistant/generated/*.py linguist-generated=true
|
||||
machine/* linguist-generated=true
|
||||
mypy.ini linguist-generated=true
|
||||
requirements.txt linguist-generated=true
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
name: Cache and install APT packages
|
||||
description: >-
|
||||
Wraps awalsh128/cache-apt-pkgs-action with the workarounds Home Assistant CI
|
||||
needs. Removes the conflicting Microsoft apt source before any apt run, and
|
||||
points the dynamic linker at the host's multiarch lib subdirectories so
|
||||
shared libraries that rely on update-alternatives or postinst-managed paths
|
||||
(eg libblas, liblapack pulled in by ffmpeg) stay reachable since the upstream
|
||||
action does not execute postinst scripts on cache restore.
|
||||
|
||||
inputs:
|
||||
packages:
|
||||
description: Space-delimited list of apt packages to install.
|
||||
required: true
|
||||
version:
|
||||
description: Cache version. Bump to invalidate the cache.
|
||||
required: false
|
||||
default: "1"
|
||||
execute_install_scripts:
|
||||
description: >-
|
||||
Pass-through to awalsh128/cache-apt-pkgs-action. Postinst scripts are not
|
||||
actually cached by the upstream action, so this is largely a no-op today.
|
||||
required: false
|
||||
default: "false"
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Remove conflicting Microsoft apt source
|
||||
shell: bash
|
||||
run: sudo rm -f /etc/apt/sources.list.d/microsoft-prod.list
|
||||
- name: Install apt packages via cache
|
||||
uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.5.3
|
||||
with:
|
||||
packages: ${{ inputs.packages }}
|
||||
version: ${{ inputs.version }}
|
||||
execute_install_scripts: ${{ inputs.execute_install_scripts }}
|
||||
- name: Refresh dynamic linker cache
|
||||
shell: bash
|
||||
run: |
|
||||
# awalsh128/cache-apt-pkgs-action does not run postinst scripts on
|
||||
# cache restore, so update-alternatives symlinks (eg the one libblas
|
||||
# creates at /usr/lib/<multiarch>/libblas.so.3) are never produced.
|
||||
# Add every /usr/lib/<multiarch> subdirectory that holds shared
|
||||
# libraries to the ldconfig search path so the dynamic linker still
|
||||
# finds them. Use dpkg-architecture to derive the host's multiarch
|
||||
# tuple so this works on non-x86_64 runners too.
|
||||
multiarch="$(dpkg-architecture -qDEB_HOST_MULTIARCH)"
|
||||
find "/usr/lib/${multiarch}" -mindepth 2 -maxdepth 2 \
|
||||
-name '*.so.*' -printf '%h\n' \
|
||||
| sort -u \
|
||||
| sudo tee /etc/ld.so.conf.d/zzz-cache-apt-extras.conf > /dev/null
|
||||
sudo ldconfig
|
||||
@@ -25,7 +25,6 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
|
||||
|
||||
- When entering a new environment or worktree, run `script/setup` to set up the virtual environment with all development dependencies (pylint, pre-commit hooks, etc.). This is required before committing.
|
||||
- .vscode/tasks.json contains useful commands used for development.
|
||||
- After finishing a code session, run `uv run prek run --all-files` to check for linting and formatting issues.
|
||||
|
||||
## Python Syntax Notes
|
||||
|
||||
@@ -43,7 +42,6 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
|
||||
- Avoid using conditions/branching in tests. Instead, either split tests or adjust the test parametrization to cover all cases without branching.
|
||||
- If multiple tests share most of their code, use `pytest.mark.parametrize` to merge them into a single parameterized test instead of duplicating the body. Use `pytest.param` with an `id` parameter to name the test cases clearly.
|
||||
- We use Syrupy for snapshot testing. Leverage `.ambr` snapshots instead of repetitive and exhaustive generation of test data within Python code itself.
|
||||
- Hardcoded `entity_id`s in tests are fine. If the same one is repeated, use a constant.
|
||||
|
||||
## Good practices
|
||||
|
||||
|
||||
@@ -27,7 +27,6 @@ The following platforms have extra guidelines:
|
||||
## Entity platforms
|
||||
|
||||
- Ensure `async_added_to_hass()` and `async_will_remove_from_hass()` have symmetrical behavior. For example, if a subscription is created in `async_added_to_hass()`, it should be unsubscribed in `async_will_remove_from_hass()`. Also, if something is torn down in `async_will_remove_from_hass()`, it should be set up in `async_added_to_hass()`.
|
||||
- Entity base class (e.g. `SensorEntity`, `TrackerEntity`) provide a stable API for child classes to inherit from. Do not suggest redeclaring or duplicating attributes, properties, or methods the base class already provides, and do not add guards against the parent's behavior changing — rely on the base class instead.
|
||||
|
||||
## Integration Quality Scale
|
||||
|
||||
|
||||
@@ -344,13 +344,13 @@ jobs:
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: matrix.registry == 'docker.io/homeassistant'
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -380,7 +380,7 @@ jobs:
|
||||
# 2025.12.0.dev202511250240 -> tags: 2025.12.0.dev202511250240, dev
|
||||
- name: Generate Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
with:
|
||||
images: ${{ matrix.registry }}/home-assistant
|
||||
sep-tags: ","
|
||||
@@ -394,7 +394,7 @@ jobs:
|
||||
type=semver,pattern={{major}}.{{minor}},value=${{ needs.init.outputs.version }},enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v3.7.1
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v3.7.1
|
||||
|
||||
- name: Copy architecture images to DockerHub
|
||||
if: matrix.registry == 'docker.io/homeassistant'
|
||||
@@ -523,14 +523,14 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: . # So action will not pull the repository again
|
||||
file: ./script/hassfest/docker/Dockerfile
|
||||
@@ -543,7 +543,7 @@ jobs:
|
||||
- name: Push Docker image
|
||||
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
|
||||
id: push
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: . # So action will not pull the repository again
|
||||
file: ./script/hassfest/docker/Dockerfile
|
||||
|
||||
+7
-7
@@ -36,7 +36,7 @@
|
||||
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
# - actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
# - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
# - github/gh-aw-actions/setup@97f280b14527ca95859c0facba201aeccb2c097f # v0.77.3
|
||||
# - github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
|
||||
#
|
||||
# Container images used:
|
||||
# - ghcr.io/github/gh-aw-firewall/agent:0.25.46
|
||||
@@ -90,7 +90,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@97f280b14527ca95859c0facba201aeccb2c097f # v0.77.3
|
||||
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -352,7 +352,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@97f280b14527ca95859c0facba201aeccb2c097f # v0.77.3
|
||||
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -961,7 +961,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@97f280b14527ca95859c0facba201aeccb2c097f # v0.77.3
|
||||
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -1100,7 +1100,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@97f280b14527ca95859c0facba201aeccb2c097f # v0.77.3
|
||||
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -1325,7 +1325,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@97f280b14527ca95859c0facba201aeccb2c097f # v0.77.3
|
||||
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -1383,7 +1383,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@97f280b14527ca95859c0facba201aeccb2c097f # v0.77.3
|
||||
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
|
||||
+209
-102
@@ -39,7 +39,7 @@ on:
|
||||
env:
|
||||
CACHE_VERSION: 3
|
||||
MYPY_CACHE_VERSION: 1
|
||||
HA_SHORT_VERSION: "2026.7"
|
||||
HA_SHORT_VERSION: "2026.6"
|
||||
ADDITIONAL_PYTHON_VERSIONS: "[]"
|
||||
# 10.3 is the oldest supported version
|
||||
# - 10.3.32 is the version currently shipped with Synology (as of 17 Feb 2022)
|
||||
@@ -60,7 +60,9 @@ env:
|
||||
# - 15.2 is the latest (as of 9 Feb 2023)
|
||||
POSTGRESQL_VERSIONS: "['postgres:12.14','postgres:15.2']"
|
||||
UV_CACHE_DIR: /tmp/uv-cache
|
||||
APT_CACHE_VERSION: 1
|
||||
APT_CACHE_BASE: /home/runner/work/apt
|
||||
APT_CACHE_DIR: /home/runner/work/apt/cache
|
||||
APT_LIST_CACHE_DIR: /home/runner/work/apt/lists
|
||||
SQLALCHEMY_WARN_20: 1
|
||||
PYTHONASYNCIODEBUG: 1
|
||||
HASS_CI: 1
|
||||
@@ -84,6 +86,7 @@ jobs:
|
||||
core: ${{ steps.core.outputs.changes }}
|
||||
integrations_glob: ${{ steps.info.outputs.integrations_glob }}
|
||||
integrations: ${{ steps.integrations.outputs.changes }}
|
||||
apt_cache_key: ${{ steps.generate_apt_cache_key.outputs.key }}
|
||||
python_cache_key: ${{ steps.generate_python_cache_key.outputs.key }}
|
||||
requirements: ${{ steps.core.outputs.requirements }}
|
||||
mariadb_groups: ${{ steps.info.outputs.mariadb_groups }}
|
||||
@@ -113,6 +116,10 @@ jobs:
|
||||
# Include HA_SHORT_VERSION to force the immediate creation
|
||||
# of a new uv cache entry after a version bump.
|
||||
echo "key=venv-${CACHE_VERSION}-${HA_SHORT_VERSION}-${HASH_REQUIREMENTS_TEST}-${HASH_REQUIREMENTS}-${HASH_REQUIREMENTS_ALL}-${HASH_PACKAGE_CONSTRAINTS}-${HASH_GEN_REQUIREMENTS}" >> $GITHUB_OUTPUT
|
||||
- name: Generate partial apt restore key
|
||||
id: generate_apt_cache_key
|
||||
run: |
|
||||
echo "key=$(lsb_release -rs)-apt-${CACHE_VERSION}-${HA_SHORT_VERSION}" >> $GITHUB_OUTPUT
|
||||
- name: Filter for core changes
|
||||
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
id: core
|
||||
@@ -274,7 +281,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@bdca6f102f98e2b4c7029491a53dfd366469e33d # v2.0.4
|
||||
uses: j178/prek-action@6ad80277337ad479fe43bd70701c3f7f8aa74db3 # v2.0.3
|
||||
env:
|
||||
PREK_SKIP: no-commit-to-branch,mypy,pylint,gen_requirements_all,hassfest,hassfest-metadata,hassfest-mypy-config,zizmor
|
||||
RUFF_OUTPUT_FORMAT: github
|
||||
@@ -295,7 +302,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Run zizmor
|
||||
uses: j178/prek-action@bdca6f102f98e2b4c7029491a53dfd366469e33d # v2.0.4
|
||||
uses: j178/prek-action@6ad80277337ad479fe43bd70701c3f7f8aa74db3 # v2.0.3
|
||||
with:
|
||||
extra-args: --all-files zizmor
|
||||
|
||||
@@ -377,36 +384,65 @@ jobs:
|
||||
path: ${{ env.UV_CACHE_DIR }}
|
||||
key: ${{ steps.generate-uv-key.outputs.full_key }}
|
||||
restore-keys: ${{ steps.generate-uv-key.outputs.partial_key }}
|
||||
- name: Check if apt cache exists
|
||||
id: cache-apt-check
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
lookup-only: ${{ steps.cache-venv.outputs.cache-hit == 'true' }}
|
||||
path: |
|
||||
${{ env.APT_CACHE_DIR }}
|
||||
${{ env.APT_LIST_CACHE_DIR }}
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
|
||||
- name: Install additional OS dependencies
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
if: |
|
||||
steps.cache-venv.outputs.cache-hit != 'true'
|
||||
|| steps.cache-apt-check.outputs.cache-hit != 'true'
|
||||
id: install-os-deps
|
||||
timeout-minutes: 10
|
||||
uses: ./.github/actions/cache-apt-packages
|
||||
with:
|
||||
packages: >-
|
||||
bluez
|
||||
ffmpeg
|
||||
libturbojpeg
|
||||
libxml2-utils
|
||||
libavcodec-dev
|
||||
libavdevice-dev
|
||||
libavfilter-dev
|
||||
libavformat-dev
|
||||
libavutil-dev
|
||||
libswresample-dev
|
||||
libswscale-dev
|
||||
libudev-dev
|
||||
version: ${{ env.APT_CACHE_VERSION }}
|
||||
execute_install_scripts: true
|
||||
- name: Read uv version from requirements.txt
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
id: read-uv-version
|
||||
env:
|
||||
APT_CACHE_HIT: ${{ steps.cache-apt-check.outputs.cache-hit }}
|
||||
run: |
|
||||
echo "version=$(grep '^uv==' requirements.txt | cut -d'=' -f3)" >> "$GITHUB_OUTPUT"
|
||||
- name: Set up uv
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
|
||||
if [[ "${APT_CACHE_HIT}" != 'true' ]]; then
|
||||
mkdir -p ${APT_CACHE_DIR}
|
||||
mkdir -p ${APT_LIST_CACHE_DIR}
|
||||
fi
|
||||
|
||||
sudo apt-get update \
|
||||
-o Dir::Cache=${APT_CACHE_DIR} \
|
||||
-o Dir::State::Lists=${APT_LIST_CACHE_DIR}
|
||||
sudo apt-get -y install \
|
||||
-o Dir::Cache=${APT_CACHE_DIR} \
|
||||
-o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
|
||||
bluez \
|
||||
ffmpeg \
|
||||
libturbojpeg \
|
||||
libxml2-utils \
|
||||
libavcodec-dev \
|
||||
libavdevice-dev \
|
||||
libavfilter-dev \
|
||||
libavformat-dev \
|
||||
libavutil-dev \
|
||||
libswresample-dev \
|
||||
libswscale-dev \
|
||||
libudev-dev
|
||||
|
||||
if [[ "${APT_CACHE_HIT}" != 'true' ]]; then
|
||||
sudo chmod -R 755 ${APT_CACHE_BASE}
|
||||
fi
|
||||
- name: Save apt cache
|
||||
if: |
|
||||
always()
|
||||
&& steps.cache-apt-check.outputs.cache-hit != 'true'
|
||||
&& steps.install-os-deps.outcome == 'success'
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
version: ${{ steps.read-uv-version.outputs.version }}
|
||||
path: |
|
||||
${{ env.APT_CACHE_DIR }}
|
||||
${{ env.APT_LIST_CACHE_DIR }}
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
|
||||
- name: Create Python virtual environment
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
id: create-venv
|
||||
@@ -414,6 +450,8 @@ jobs:
|
||||
python -m venv venv
|
||||
. venv/bin/activate
|
||||
python --version
|
||||
pip install "$(grep '^uv' < requirements.txt)"
|
||||
uv pip install -U "pip>=25.2"
|
||||
uv pip install -r requirements.txt
|
||||
uv pip install -r requirements_all.txt -r requirements_test.txt
|
||||
uv pip install -e . --config-settings editable_mode=compat
|
||||
@@ -468,16 +506,30 @@ jobs:
|
||||
&& github.event.inputs.mypy-only != 'true'
|
||||
&& github.event.inputs.audit-licenses-only != 'true'
|
||||
steps:
|
||||
- name: Restore apt cache
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
${{ env.APT_CACHE_DIR }}
|
||||
${{ env.APT_LIST_CACHE_DIR }}
|
||||
fail-on-cache-miss: true
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
|
||||
- name: Install additional OS dependencies
|
||||
timeout-minutes: 10
|
||||
run: |
|
||||
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
|
||||
sudo apt-get update \
|
||||
-o Dir::Cache=${APT_CACHE_DIR} \
|
||||
-o Dir::State::Lists=${APT_LIST_CACHE_DIR}
|
||||
sudo apt-get -y install \
|
||||
-o Dir::Cache=${APT_CACHE_DIR} \
|
||||
-o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
|
||||
libturbojpeg
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install additional OS dependencies
|
||||
timeout-minutes: 10
|
||||
uses: ./.github/actions/cache-apt-packages
|
||||
with:
|
||||
packages: libturbojpeg
|
||||
version: ${{ env.APT_CACHE_VERSION }}
|
||||
- name: Set up Python
|
||||
id: python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
@@ -824,20 +876,32 @@ jobs:
|
||||
- info
|
||||
- base
|
||||
steps:
|
||||
- name: Restore apt cache
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
${{ env.APT_CACHE_DIR }}
|
||||
${{ env.APT_LIST_CACHE_DIR }}
|
||||
fail-on-cache-miss: true
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
|
||||
- name: Install additional OS dependencies
|
||||
timeout-minutes: 10
|
||||
run: |
|
||||
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
|
||||
sudo apt-get update \
|
||||
-o Dir::Cache=${APT_CACHE_DIR} \
|
||||
-o Dir::State::Lists=${APT_LIST_CACHE_DIR}
|
||||
sudo apt-get -y install \
|
||||
-o Dir::Cache=${APT_CACHE_DIR} \
|
||||
-o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
|
||||
bluez \
|
||||
ffmpeg \
|
||||
libturbojpeg
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install additional OS dependencies
|
||||
timeout-minutes: 10
|
||||
uses: ./.github/actions/cache-apt-packages
|
||||
with:
|
||||
packages: >-
|
||||
bluez
|
||||
ffmpeg
|
||||
libturbojpeg
|
||||
version: ${{ env.APT_CACHE_VERSION }}
|
||||
execute_install_scripts: true
|
||||
- name: Set up Python
|
||||
id: python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
@@ -888,21 +952,33 @@ jobs:
|
||||
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
|
||||
group: ${{ fromJson(needs.info.outputs.test_groups) }}
|
||||
steps:
|
||||
- name: Restore apt cache
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
${{ env.APT_CACHE_DIR }}
|
||||
${{ env.APT_LIST_CACHE_DIR }}
|
||||
fail-on-cache-miss: true
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
|
||||
- name: Install additional OS dependencies
|
||||
timeout-minutes: 10
|
||||
run: |
|
||||
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
|
||||
sudo apt-get update \
|
||||
-o Dir::Cache=${APT_CACHE_DIR} \
|
||||
-o Dir::State::Lists=${APT_LIST_CACHE_DIR}
|
||||
sudo apt-get -y install \
|
||||
-o Dir::Cache=${APT_CACHE_DIR} \
|
||||
-o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
|
||||
bluez \
|
||||
ffmpeg \
|
||||
libturbojpeg \
|
||||
libxml2-utils
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install additional OS dependencies
|
||||
timeout-minutes: 10
|
||||
uses: ./.github/actions/cache-apt-packages
|
||||
with:
|
||||
packages: >-
|
||||
bluez
|
||||
ffmpeg
|
||||
libturbojpeg
|
||||
libxml2-utils
|
||||
version: ${{ env.APT_CACHE_VERSION }}
|
||||
execute_install_scripts: true
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
id: python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
@@ -1029,22 +1105,34 @@ jobs:
|
||||
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
|
||||
mariadb-group: ${{ fromJson(needs.info.outputs.mariadb_groups) }}
|
||||
steps:
|
||||
- name: Restore apt cache
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
${{ env.APT_CACHE_DIR }}
|
||||
${{ env.APT_LIST_CACHE_DIR }}
|
||||
fail-on-cache-miss: true
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
|
||||
- name: Install additional OS dependencies
|
||||
timeout-minutes: 10
|
||||
run: |
|
||||
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
|
||||
sudo apt-get update \
|
||||
-o Dir::Cache=${APT_CACHE_DIR} \
|
||||
-o Dir::State::Lists=${APT_LIST_CACHE_DIR}
|
||||
sudo apt-get -y install \
|
||||
-o Dir::Cache=${APT_CACHE_DIR} \
|
||||
-o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
|
||||
bluez \
|
||||
ffmpeg \
|
||||
libturbojpeg \
|
||||
libmariadb-dev-compat \
|
||||
libxml2-utils
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install additional OS dependencies
|
||||
timeout-minutes: 10
|
||||
uses: ./.github/actions/cache-apt-packages
|
||||
with:
|
||||
packages: >-
|
||||
bluez
|
||||
ffmpeg
|
||||
libturbojpeg
|
||||
libmariadb-dev-compat
|
||||
libxml2-utils
|
||||
version: ${{ env.APT_CACHE_VERSION }}
|
||||
execute_install_scripts: true
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
id: python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
@@ -1178,29 +1266,36 @@ jobs:
|
||||
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
|
||||
postgresql-group: ${{ fromJson(needs.info.outputs.postgresql_groups) }}
|
||||
steps:
|
||||
- name: Restore apt cache
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
${{ env.APT_CACHE_DIR }}
|
||||
${{ env.APT_LIST_CACHE_DIR }}
|
||||
fail-on-cache-miss: true
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
|
||||
- name: Install additional OS dependencies
|
||||
timeout-minutes: 10
|
||||
run: |
|
||||
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
|
||||
sudo apt-get update \
|
||||
-o Dir::Cache=${APT_CACHE_DIR} \
|
||||
-o Dir::State::Lists=${APT_LIST_CACHE_DIR}
|
||||
sudo apt-get -y install \
|
||||
-o Dir::Cache=${APT_CACHE_DIR} \
|
||||
-o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
|
||||
bluez \
|
||||
ffmpeg \
|
||||
libturbojpeg \
|
||||
libxml2-utils
|
||||
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y
|
||||
sudo apt-get -y install \
|
||||
postgresql-server-dev-14
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install additional OS dependencies
|
||||
timeout-minutes: 10
|
||||
uses: ./.github/actions/cache-apt-packages
|
||||
with:
|
||||
packages: >-
|
||||
bluez
|
||||
ffmpeg
|
||||
libturbojpeg
|
||||
libxml2-utils
|
||||
version: ${{ env.APT_CACHE_VERSION }}
|
||||
execute_install_scripts: true
|
||||
- name: Set up PostgreSQL apt repository
|
||||
run: sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y
|
||||
- name: Cache PostgreSQL development headers
|
||||
timeout-minutes: 10
|
||||
uses: ./.github/actions/cache-apt-packages
|
||||
with:
|
||||
packages: postgresql-server-dev-14
|
||||
version: ${{ env.APT_CACHE_VERSION }}
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
id: python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
@@ -1326,7 +1421,7 @@ jobs:
|
||||
pattern: coverage-*
|
||||
- name: Upload coverage to Codecov
|
||||
if: needs.info.outputs.test_full_suite == 'true'
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
flags: full-suite
|
||||
@@ -1354,21 +1449,33 @@ jobs:
|
||||
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
|
||||
group: ${{ fromJson(needs.info.outputs.test_groups) }}
|
||||
steps:
|
||||
- name: Restore apt cache
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
${{ env.APT_CACHE_DIR }}
|
||||
${{ env.APT_LIST_CACHE_DIR }}
|
||||
fail-on-cache-miss: true
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
|
||||
- name: Install additional OS dependencies
|
||||
timeout-minutes: 10
|
||||
run: |
|
||||
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
|
||||
sudo apt-get update \
|
||||
-o Dir::Cache=${APT_CACHE_DIR} \
|
||||
-o Dir::State::Lists=${APT_LIST_CACHE_DIR}
|
||||
sudo apt-get -y install \
|
||||
-o Dir::Cache=${APT_CACHE_DIR} \
|
||||
-o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
|
||||
bluez \
|
||||
ffmpeg \
|
||||
libturbojpeg \
|
||||
libxml2-utils
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install additional OS dependencies
|
||||
timeout-minutes: 10
|
||||
uses: ./.github/actions/cache-apt-packages
|
||||
with:
|
||||
packages: >-
|
||||
bluez
|
||||
ffmpeg
|
||||
libturbojpeg
|
||||
libxml2-utils
|
||||
version: ${{ env.APT_CACHE_VERSION }}
|
||||
execute_install_scripts: true
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
id: python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
@@ -1485,7 +1592,7 @@ jobs:
|
||||
pattern: coverage-*
|
||||
- name: Upload coverage to Codecov
|
||||
if: needs.info.outputs.test_full_suite == 'false'
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
token: ${{ secrets.CODECOV_TOKEN }} # zizmor: ignore[secrets-outside-env]
|
||||
@@ -1513,7 +1620,7 @@ jobs:
|
||||
with:
|
||||
pattern: test-results-*
|
||||
- name: Upload test results to Codecov
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
|
||||
with:
|
||||
report_type: test_results
|
||||
fail_ci_if_error: true
|
||||
|
||||
@@ -28,11 +28,11 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
||||
@@ -236,7 +236,7 @@ jobs:
|
||||
- name: Detect duplicates using AI
|
||||
id: ai_detection
|
||||
if: steps.extract.outputs.should_continue == 'true' && steps.fetch_similar.outputs.has_similar == 'true'
|
||||
uses: actions/ai-inference@a7805884c80886efc241e94a5351df715968a0ad # v2.1.1
|
||||
uses: actions/ai-inference@17ff458cb182449bbb2e43701fcd98f6af8f6570 # v2.1.0
|
||||
with:
|
||||
model: openai/gpt-4o
|
||||
system-prompt: |
|
||||
|
||||
@@ -62,7 +62,7 @@ jobs:
|
||||
- name: Detect language using AI
|
||||
id: ai_language_detection
|
||||
if: steps.detect_language.outputs.should_continue == 'true'
|
||||
uses: actions/ai-inference@a7805884c80886efc241e94a5351df715968a0ad # v2.1.1
|
||||
uses: actions/ai-inference@17ff458cb182449bbb2e43701fcd98f6af8f6570 # v2.1.0
|
||||
with:
|
||||
model: openai/gpt-4o-mini
|
||||
system-prompt: |
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
issues: write # To lock issues
|
||||
pull-requests: write # To lock pull requests
|
||||
steps:
|
||||
- uses: dessant/lock-threads@89ae32b08ed1a541efecbab17912962a5e38981c # v6.0.2
|
||||
- uses: dessant/lock-threads@7266a7ce5c1df01b1c6db85bf8cd86c737dadbe7 # v6.0.0
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
issue-inactive-days: "30"
|
||||
|
||||
+68
-26
@@ -20,36 +20,22 @@ jobs:
|
||||
permissions:
|
||||
issues: write # To label and close stale issues
|
||||
pull-requests: write # To label and close stale PRs
|
||||
actions: write # To delete stalebot state
|
||||
steps:
|
||||
# Generate a token for the GitHub App, we use this method to avoid
|
||||
# hitting API limits for our GitHub actions + have a higher rate limit.
|
||||
- name: Generate app token
|
||||
id: token
|
||||
# Pinned to a specific version of the action for security reasons
|
||||
# v3.2.0
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
|
||||
with:
|
||||
client-id: ${{ secrets.ISSUE_TRIAGE_APP_ID }} # zizmor: ignore[secrets-outside-env]
|
||||
private-key: ${{ secrets.ISSUE_TRIAGE_APP_PEM }} # zizmor: ignore[secrets-outside-env]
|
||||
|
||||
# The 60 day stale policy for PRs
|
||||
# Used for:
|
||||
# - PRs
|
||||
# - No PRs marked as no-stale
|
||||
# The 90 day stale policy for issues
|
||||
# Used for:
|
||||
# - Issues
|
||||
# - No issues marked as no-stale or help-wanted
|
||||
- name: 60 days stale PRs policy and 90 days stale issue policy
|
||||
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
# - No issues (-1)
|
||||
- name: 60 days stale PRs policy
|
||||
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||
with:
|
||||
repo-token: ${{ steps.token.outputs.token }}
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
days-before-stale: 60
|
||||
days-before-close: 7
|
||||
days-before-issue-stale: -1
|
||||
days-before-issue-close: -1
|
||||
operations-per-run: 150
|
||||
remove-stale-when-updated: true
|
||||
operations-per-run: 350
|
||||
# pr policy
|
||||
days-before-pr-stale: 60
|
||||
days-before-pr-close: 7
|
||||
stale-pr-label: "stale"
|
||||
exempt-pr-labels: "no-stale"
|
||||
stale-pr-message: >
|
||||
@@ -62,9 +48,65 @@ jobs:
|
||||
branch to ensure that it's up to date with the latest changes.
|
||||
|
||||
Thank you for your contribution!
|
||||
# issue policy
|
||||
days-before-issue-stale: 90
|
||||
days-before-issue-close: 7
|
||||
|
||||
# Generate a token for the GitHub App, we use this method to avoid
|
||||
# hitting API limits for our GitHub actions + have a higher rate limit.
|
||||
# This is only used for issues.
|
||||
- name: Generate app token
|
||||
id: token
|
||||
# Pinned to a specific version of the action for security reasons
|
||||
# v1.7.0
|
||||
uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a
|
||||
with:
|
||||
app_id: ${{ secrets.ISSUE_TRIAGE_APP_ID }} # zizmor: ignore[secrets-outside-env]
|
||||
private_key: ${{ secrets.ISSUE_TRIAGE_APP_PEM }} # zizmor: ignore[secrets-outside-env]
|
||||
|
||||
# The 90 day stale policy for issues
|
||||
# Used for:
|
||||
# - Issues
|
||||
# - No issues marked as no-stale or help-wanted
|
||||
# - No PRs (-1)
|
||||
- name: 90 days stale issues
|
||||
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||
with:
|
||||
repo-token: ${{ steps.token.outputs.token }}
|
||||
days-before-stale: 90
|
||||
days-before-close: 7
|
||||
days-before-pr-stale: -1
|
||||
days-before-pr-close: -1
|
||||
operations-per-run: 250
|
||||
remove-stale-when-updated: true
|
||||
stale-issue-label: "stale"
|
||||
exempt-issue-labels: "no-stale,help-wanted,needs-more-information"
|
||||
stale-issue-message: >
|
||||
There hasn't been any activity on this issue recently. Due to the
|
||||
high number of incoming GitHub notifications, we have to clean some
|
||||
of the old issues, as many of them have already been resolved with
|
||||
the latest updates.
|
||||
|
||||
Please make sure to update to the latest Home Assistant version and
|
||||
check if that solves the issue. Let us know if that works for you by
|
||||
adding a comment 👍
|
||||
|
||||
This issue has now been marked as stale and will be closed if no
|
||||
further activity occurs. Thank you for your contributions.
|
||||
|
||||
# The 30 day stale policy for issues
|
||||
# Used for:
|
||||
# - Issues that are pending more information (incomplete issues)
|
||||
# - No Issues marked as no-stale or help-wanted
|
||||
# - No PRs (-1)
|
||||
- name: Needs more information stale issues policy
|
||||
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||
with:
|
||||
repo-token: ${{ steps.token.outputs.token }}
|
||||
only-labels: "needs-more-information"
|
||||
days-before-stale: 14
|
||||
days-before-close: 7
|
||||
days-before-pr-stale: -1
|
||||
days-before-pr-close: -1
|
||||
operations-per-run: 250
|
||||
remove-stale-when-updated: true
|
||||
stale-issue-label: "stale"
|
||||
exempt-issue-labels: "no-stale,help-wanted"
|
||||
stale-issue-message: >
|
||||
|
||||
@@ -137,7 +137,7 @@ jobs:
|
||||
sed -i "/uv/d" requirements_diff.txt
|
||||
|
||||
- name: Build wheels
|
||||
uses: home-assistant/wheels@34957438948e0b3dcde73c77750643dadae594f5 # 2026.06.0
|
||||
uses: home-assistant/wheels@e5742a69d69f0e274e2689c998900c7d19652c21 # 2025.12.0
|
||||
with:
|
||||
abi: ${{ matrix.abi }}
|
||||
tag: musllinux_1_2
|
||||
@@ -195,7 +195,7 @@ jobs:
|
||||
sed -i "/uv/d" requirements_diff.txt
|
||||
|
||||
- name: Build wheels
|
||||
uses: home-assistant/wheels@34957438948e0b3dcde73c77750643dadae594f5 # 2026.06.0
|
||||
uses: home-assistant/wheels@e5742a69d69f0e274e2689c998900c7d19652c21 # 2025.12.0
|
||||
with:
|
||||
abi: ${{ matrix.abi }}
|
||||
tag: musllinux_1_2
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.15.15
|
||||
rev: v0.15.13
|
||||
hooks:
|
||||
- id: ruff-check
|
||||
args:
|
||||
|
||||
@@ -286,7 +286,6 @@ homeassistant.components.huawei_lte.*
|
||||
homeassistant.components.humidifier.*
|
||||
homeassistant.components.husqvarna_automower.*
|
||||
homeassistant.components.huum.*
|
||||
homeassistant.components.hvv_departures.*
|
||||
homeassistant.components.hydrawise.*
|
||||
homeassistant.components.hyperion.*
|
||||
homeassistant.components.hypontech.*
|
||||
@@ -338,7 +337,6 @@ homeassistant.components.led_ble.*
|
||||
homeassistant.components.lektrico.*
|
||||
homeassistant.components.letpot.*
|
||||
homeassistant.components.lg_infrared.*
|
||||
homeassistant.components.lg_tv_rs232.*
|
||||
homeassistant.components.libre_hardware_monitor.*
|
||||
homeassistant.components.lidarr.*
|
||||
homeassistant.components.liebherr.*
|
||||
@@ -430,7 +428,6 @@ homeassistant.components.otp.*
|
||||
homeassistant.components.ouman_eh_800.*
|
||||
homeassistant.components.overkiz.*
|
||||
homeassistant.components.overseerr.*
|
||||
homeassistant.components.ovhcloud_ai_endpoints.*
|
||||
homeassistant.components.p1_monitor.*
|
||||
homeassistant.components.paj_gps.*
|
||||
homeassistant.components.panel_custom.*
|
||||
@@ -568,7 +565,6 @@ homeassistant.components.technove.*
|
||||
homeassistant.components.tedee.*
|
||||
homeassistant.components.telegram_bot.*
|
||||
homeassistant.components.teleinfo.*
|
||||
homeassistant.components.teltonika.*
|
||||
homeassistant.components.teslemetry.*
|
||||
homeassistant.components.text.*
|
||||
homeassistant.components.thethingsnetwork.*
|
||||
@@ -613,7 +609,6 @@ homeassistant.components.valve.*
|
||||
homeassistant.components.velbus.*
|
||||
homeassistant.components.velux.*
|
||||
homeassistant.components.victron_gx.*
|
||||
homeassistant.components.vistapool.*
|
||||
homeassistant.components.vivotek.*
|
||||
homeassistant.components.vlc_telnet.*
|
||||
homeassistant.components.vodafone_station.*
|
||||
|
||||
@@ -15,7 +15,6 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
|
||||
|
||||
- When entering a new environment or worktree, run `script/setup` to set up the virtual environment with all development dependencies (pylint, pre-commit hooks, etc.). This is required before committing.
|
||||
- .vscode/tasks.json contains useful commands used for development.
|
||||
- After finishing a code session, run `uv run prek run --all-files` to check for linting and formatting issues.
|
||||
|
||||
## Python Syntax Notes
|
||||
|
||||
@@ -33,7 +32,6 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
|
||||
- Avoid using conditions/branching in tests. Instead, either split tests or adjust the test parametrization to cover all cases without branching.
|
||||
- If multiple tests share most of their code, use `pytest.mark.parametrize` to merge them into a single parameterized test instead of duplicating the body. Use `pytest.param` with an `id` parameter to name the test cases clearly.
|
||||
- We use Syrupy for snapshot testing. Leverage `.ambr` snapshots instead of repetitive and exhaustive generation of test data within Python code itself.
|
||||
- Hardcoded `entity_id`s in tests are fine. If the same one is repeated, use a constant.
|
||||
|
||||
## Good practices
|
||||
|
||||
|
||||
Generated
+12
-26
@@ -236,8 +236,8 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/blebox/ @bbx-a @swistakm @bkobus-bbx
|
||||
/homeassistant/components/blink/ @fronzbot
|
||||
/tests/components/blink/ @fronzbot
|
||||
/homeassistant/components/blue_current/ @gleeuwen @jtodorova23
|
||||
/tests/components/blue_current/ @gleeuwen @jtodorova23
|
||||
/homeassistant/components/blue_current/ @gleeuwen @NickKoepr @jtodorova23
|
||||
/tests/components/blue_current/ @gleeuwen @NickKoepr @jtodorova23
|
||||
/homeassistant/components/bluemaestro/ @bdraco
|
||||
/tests/components/bluemaestro/ @bdraco
|
||||
/homeassistant/components/blueprint/ @home-assistant/core
|
||||
@@ -453,8 +453,6 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/ecovacs/ @mib1185 @edenhaus @Augar
|
||||
/homeassistant/components/ecowitt/ @pvizeli
|
||||
/tests/components/ecowitt/ @pvizeli
|
||||
/homeassistant/components/edifier_infrared/ @abmantis
|
||||
/tests/components/edifier_infrared/ @abmantis
|
||||
/homeassistant/components/efergy/ @tkdrob
|
||||
/tests/components/efergy/ @tkdrob
|
||||
/homeassistant/components/egardia/ @jeroenterheerdt
|
||||
@@ -468,6 +466,8 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/electrasmart/ @jafar-atili
|
||||
/homeassistant/components/electric_kiwi/ @mikey0000
|
||||
/tests/components/electric_kiwi/ @mikey0000
|
||||
/homeassistant/components/electrolux/ @electrolux-oss
|
||||
/tests/components/electrolux/ @electrolux-oss
|
||||
/homeassistant/components/elevenlabs/ @sorgfresser
|
||||
/tests/components/elevenlabs/ @sorgfresser
|
||||
/homeassistant/components/elgato/ @frenck
|
||||
@@ -503,8 +503,6 @@ CLAUDE.md @home-assistant/core
|
||||
/homeassistant/components/enphase_envoy/ @bdraco @cgarwood @catsmanac
|
||||
/tests/components/enphase_envoy/ @bdraco @cgarwood @catsmanac
|
||||
/homeassistant/components/entur_public_transport/ @hfurubotten @SanderBlom
|
||||
/homeassistant/components/envertech_evt800/ @daniel-bergmann-00
|
||||
/tests/components/envertech_evt800/ @daniel-bergmann-00
|
||||
/homeassistant/components/environment_canada/ @gwww @michaeldavie
|
||||
/tests/components/environment_canada/ @gwww @michaeldavie
|
||||
/homeassistant/components/ephember/ @ttroy50 @roberty99
|
||||
@@ -627,8 +625,8 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/generic_hygrostat/ @Shulyaka
|
||||
/homeassistant/components/geniushub/ @manzanotti
|
||||
/tests/components/geniushub/ @manzanotti
|
||||
/homeassistant/components/gentex_homelink/ @Gentex-Corporation/Homelink @rjones-gentex
|
||||
/tests/components/gentex_homelink/ @Gentex-Corporation/Homelink @rjones-gentex
|
||||
/homeassistant/components/gentex_homelink/ @niaexa @ryanjones-gentex
|
||||
/tests/components/gentex_homelink/ @niaexa @ryanjones-gentex
|
||||
/homeassistant/components/geo_json_events/ @exxamalte
|
||||
/tests/components/geo_json_events/ @exxamalte
|
||||
/homeassistant/components/geo_location/ @home-assistant/core
|
||||
@@ -722,8 +720,6 @@ CLAUDE.md @home-assistant/core
|
||||
/homeassistant/components/heatmiser/ @andylockran
|
||||
/homeassistant/components/hegel/ @boazca
|
||||
/tests/components/hegel/ @boazca
|
||||
/homeassistant/components/helty/ @ebaschiera
|
||||
/tests/components/helty/ @ebaschiera
|
||||
/homeassistant/components/heos/ @andrewsayre
|
||||
/tests/components/heos/ @andrewsayre
|
||||
/homeassistant/components/here_travel_time/ @eifinger
|
||||
@@ -842,8 +838,6 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/imgw_pib/ @bieniu
|
||||
/homeassistant/components/immich/ @mib1185
|
||||
/tests/components/immich/ @mib1185
|
||||
/homeassistant/components/imou/ @Imou-OpenPlatform
|
||||
/tests/components/imou/ @Imou-OpenPlatform
|
||||
/homeassistant/components/improv_ble/ @emontnemery
|
||||
/tests/components/improv_ble/ @emontnemery
|
||||
/homeassistant/components/incomfort/ @jbouwh
|
||||
@@ -953,6 +947,8 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/knx/ @Julius2342 @farmio @marvin-w
|
||||
/homeassistant/components/kodi/ @OnFreund
|
||||
/tests/components/kodi/ @OnFreund
|
||||
/homeassistant/components/konnected/ @heythisisnate
|
||||
/tests/components/konnected/ @heythisisnate
|
||||
/homeassistant/components/kostal_plenticore/ @stegm
|
||||
/tests/components/kostal_plenticore/ @stegm
|
||||
/homeassistant/components/kraken/ @eifinger
|
||||
@@ -995,8 +991,6 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/lg_netcast/ @Drafteed @splinter98
|
||||
/homeassistant/components/lg_thinq/ @LG-ThinQ-Integration
|
||||
/tests/components/lg_thinq/ @LG-ThinQ-Integration
|
||||
/homeassistant/components/lg_tv_rs232/ @balloob
|
||||
/tests/components/lg_tv_rs232/ @balloob
|
||||
/homeassistant/components/libre_hardware_monitor/ @Sab44
|
||||
/tests/components/libre_hardware_monitor/ @Sab44
|
||||
/homeassistant/components/lichess/ @aryanhasgithub
|
||||
@@ -1300,8 +1294,6 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/openhome/ @bazwilliams
|
||||
/homeassistant/components/openrgb/ @felipecrs
|
||||
/tests/components/openrgb/ @felipecrs
|
||||
/homeassistant/components/opensensemap/ @AlCalzone
|
||||
/tests/components/opensensemap/ @AlCalzone
|
||||
/homeassistant/components/opensky/ @joostlek
|
||||
/tests/components/opensky/ @joostlek
|
||||
/homeassistant/components/opentherm_gw/ @mvn23
|
||||
@@ -1329,8 +1321,6 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/overkiz/ @imicknl
|
||||
/homeassistant/components/overseerr/ @joostlek @AmGarera
|
||||
/tests/components/overseerr/ @joostlek @AmGarera
|
||||
/homeassistant/components/ovhcloud_ai_endpoints/ @Crocmagnon
|
||||
/tests/components/ovhcloud_ai_endpoints/ @Crocmagnon
|
||||
/homeassistant/components/ovo_energy/ @timmo001
|
||||
/tests/components/ovo_energy/ @timmo001
|
||||
/homeassistant/components/p1_monitor/ @klaasnicolaas
|
||||
@@ -1425,8 +1415,8 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/pushover/ @engrbm87
|
||||
/homeassistant/components/pvoutput/ @frenck
|
||||
/tests/components/pvoutput/ @frenck
|
||||
/homeassistant/components/pvpc_hourly_pricing/ @azogue @chiro79
|
||||
/tests/components/pvpc_hourly_pricing/ @azogue @chiro79
|
||||
/homeassistant/components/pvpc_hourly_pricing/ @azogue
|
||||
/tests/components/pvpc_hourly_pricing/ @azogue
|
||||
/homeassistant/components/pyload/ @tr4nt0r
|
||||
/tests/components/pyload/ @tr4nt0r
|
||||
/homeassistant/components/qbittorrent/ @geoffreylagaisse @finder39
|
||||
@@ -1944,8 +1934,6 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/victron_remote_monitoring/ @AndyTempel
|
||||
/homeassistant/components/vilfo/ @ManneW
|
||||
/tests/components/vilfo/ @ManneW
|
||||
/homeassistant/components/vistapool/ @fdebrus
|
||||
/tests/components/vistapool/ @fdebrus
|
||||
/homeassistant/components/vivotek/ @HarlemSquirrel
|
||||
/tests/components/vivotek/ @HarlemSquirrel
|
||||
/homeassistant/components/vizio/ @raman325
|
||||
@@ -2062,16 +2050,14 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/yamaha_musiccast/ @vigonotion @micha91
|
||||
/homeassistant/components/yandex_transport/ @rishatik92 @devbis
|
||||
/tests/components/yandex_transport/ @rishatik92 @devbis
|
||||
/homeassistant/components/yardian/ @aeon-matrix
|
||||
/tests/components/yardian/ @aeon-matrix
|
||||
/homeassistant/components/yardian/ @h3l1o5
|
||||
/tests/components/yardian/ @h3l1o5
|
||||
/homeassistant/components/yeelight/ @zewelor @shenxn @starkillerOG @alexyao2015
|
||||
/tests/components/yeelight/ @zewelor @shenxn @starkillerOG @alexyao2015
|
||||
/homeassistant/components/yeelightsunflower/ @lindsaymarkward
|
||||
/homeassistant/components/yi/ @bachya
|
||||
/homeassistant/components/yolink/ @matrixd2
|
||||
/tests/components/yolink/ @matrixd2
|
||||
/homeassistant/components/yoto/ @cdnninja @piitaya
|
||||
/tests/components/yoto/ @cdnninja @piitaya
|
||||
/homeassistant/components/youless/ @gjong
|
||||
/tests/components/youless/ @gjong
|
||||
/homeassistant/components/youtube/ @joostlek
|
||||
|
||||
@@ -134,7 +134,7 @@ class AuthManagerFlowManager(
|
||||
"""
|
||||
flow = cast(LoginFlow, flow)
|
||||
|
||||
if result["type"] is not FlowResultType.CREATE_ENTRY:
|
||||
if result["type"] != FlowResultType.CREATE_ENTRY:
|
||||
return result
|
||||
|
||||
# we got final result
|
||||
|
||||
@@ -92,7 +92,8 @@ def _extract_backup(
|
||||
):
|
||||
ostf.tar.extractall(
|
||||
path=Path(tempdir, "extracted"),
|
||||
filter="tar",
|
||||
members=securetar.secure_path(ostf.tar),
|
||||
filter="fully_trusted",
|
||||
)
|
||||
backup_meta_file = Path(tempdir, "extracted", "backup.json")
|
||||
backup_meta = json.loads(backup_meta_file.read_text(encoding="utf8"))
|
||||
@@ -118,7 +119,8 @@ def _extract_backup(
|
||||
) as istf:
|
||||
istf.extractall(
|
||||
path=Path(tempdir, "homeassistant"),
|
||||
filter="tar",
|
||||
members=securetar.secure_path(istf),
|
||||
filter="fully_trusted",
|
||||
)
|
||||
if restore_content.restore_homeassistant:
|
||||
keep = list(KEEP_BACKUPS)
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
"lg_netcast",
|
||||
"lg_soundbar",
|
||||
"lg_thinq",
|
||||
"lg_tv_rs232",
|
||||
"webostv"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ from homeassistant.components.sensor import (
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONCENTRATION_PARTS_PER_CUBIC_METER,
|
||||
PERCENTAGE,
|
||||
UV_INDEX,
|
||||
UnitOfIrradiance,
|
||||
@@ -46,8 +47,6 @@ from .coordinator import (
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
PARTS_PER_CUBIC_METER = "p/m³"
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class AccuWeatherSensorDescription(SensorEntityDescription):
|
||||
@@ -82,7 +81,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
AccuWeatherSensorDescription(
|
||||
key="Grass",
|
||||
entity_registry_enabled_default=False,
|
||||
native_unit_of_measurement=PARTS_PER_CUBIC_METER,
|
||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER,
|
||||
value_fn=lambda data: cast(int, data[ATTR_VALUE]),
|
||||
attr_fn=lambda data: {
|
||||
ATTR_LEVEL: POLLEN_CATEGORY_MAP[data[ATTR_CATEGORY_VALUE]]
|
||||
@@ -108,7 +107,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
AccuWeatherSensorDescription(
|
||||
key="Mold",
|
||||
entity_registry_enabled_default=False,
|
||||
native_unit_of_measurement=PARTS_PER_CUBIC_METER,
|
||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER,
|
||||
value_fn=lambda data: cast(int, data[ATTR_VALUE]),
|
||||
attr_fn=lambda data: {
|
||||
ATTR_LEVEL: POLLEN_CATEGORY_MAP[data[ATTR_CATEGORY_VALUE]]
|
||||
@@ -117,7 +116,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
),
|
||||
AccuWeatherSensorDescription(
|
||||
key="Ragweed",
|
||||
native_unit_of_measurement=PARTS_PER_CUBIC_METER,
|
||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda data: cast(int, data[ATTR_VALUE]),
|
||||
attr_fn=lambda data: {
|
||||
@@ -185,7 +184,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
),
|
||||
AccuWeatherSensorDescription(
|
||||
key="Tree",
|
||||
native_unit_of_measurement=PARTS_PER_CUBIC_METER,
|
||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda data: cast(int, data[ATTR_VALUE]),
|
||||
attr_fn=lambda data: {
|
||||
|
||||
@@ -13,5 +13,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["actron-neo-api==0.5.12"]
|
||||
"requirements": ["actron-neo-api==0.5.6"]
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ from homeassistant.components.select import (
|
||||
PLATFORM_SCHEMA as SELECT_PLATFORM_SCHEMA,
|
||||
SelectEntity,
|
||||
)
|
||||
from homeassistant.const import CONF_NAME, CONF_OPTIONS
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
@@ -19,6 +19,9 @@ from .hub import AdsHub
|
||||
|
||||
DEFAULT_NAME = "ADS select"
|
||||
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_OPTIONS = "options"
|
||||
|
||||
PLATFORM_SCHEMA = SELECT_PLATFORM_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(CONF_ADS_VAR): cv.string,
|
||||
|
||||
@@ -72,7 +72,8 @@ async def _resolve_attachments(
|
||||
resolved_attachments.append(
|
||||
conversation.Attachment(
|
||||
media_content_id=media_content_id,
|
||||
mime_type=image_data.content_type,
|
||||
mime_type=attachment.get("media_content_type")
|
||||
or image_data.content_type,
|
||||
path=temp_filename,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.trigger_common_fields:
|
||||
behavior: &trigger_behavior
|
||||
required: true
|
||||
default: each
|
||||
default: any
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["airos==0.6.8"]
|
||||
"requirements": ["airos==0.6.5"]
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ from bleak.backends.device import BLEDevice
|
||||
from bleak_retry_connector import close_stale_connections_by_address
|
||||
|
||||
from homeassistant.components import bluetooth
|
||||
from homeassistant.components.bluetooth import BluetoothReachabilityIntent
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
@@ -64,16 +63,7 @@ class AirthingsBLEDataUpdateCoordinator(DataUpdateCoordinator[AirthingsDevice]):
|
||||
|
||||
if not ble_device:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="device_not_found",
|
||||
translation_placeholders={
|
||||
"address": address,
|
||||
"reason": bluetooth.async_address_reachability_diagnostics(
|
||||
self.hass,
|
||||
address.upper(),
|
||||
BluetoothReachabilityIntent.CONNECTION,
|
||||
),
|
||||
},
|
||||
f"Could not find Airthings device with address {address}"
|
||||
)
|
||||
self.ble_device = ble_device
|
||||
|
||||
|
||||
@@ -54,10 +54,5 @@
|
||||
"name": "Radon longterm level"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"device_not_found": {
|
||||
"message": "Could not find Airthings device with address {address}: {reason}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
"""The AirVisual Pro integration."""
|
||||
|
||||
from homeassistant.helpers.device_registry import (
|
||||
CONNECTION_NETWORK_MAC,
|
||||
DeviceInfo,
|
||||
format_mac,
|
||||
)
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
@@ -29,12 +25,6 @@ class AirVisualProEntity(CoordinatorEntity[AirVisualProCoordinator]):
|
||||
"""Return device registry information for this entity."""
|
||||
return DeviceInfo(
|
||||
identifiers={(DOMAIN, self.coordinator.data["serial_number"])},
|
||||
connections={
|
||||
(
|
||||
CONNECTION_NETWORK_MAC,
|
||||
format_mac(self.coordinator.data["status"]["mac_address"]),
|
||||
)
|
||||
},
|
||||
manufacturer="AirVisual",
|
||||
model=self.coordinator.data["status"]["model"],
|
||||
name=self.coordinator.data["settings"]["node_name"],
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
fields: &trigger_common_fields
|
||||
behavior:
|
||||
required: true
|
||||
default: each
|
||||
default: any
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"service": "mdi:dialpad"
|
||||
},
|
||||
"alarm_toggle_chime": {
|
||||
"service": "mdi:bell-ring"
|
||||
"service": "mdi:abc"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ from homeassistant.const import (
|
||||
ATTR_DEVICE_CLASS,
|
||||
ATTR_SUPPORTED_FEATURES,
|
||||
ATTR_UNIT_OF_MEASUREMENT,
|
||||
CLOUD_NEVER_EXPOSED_ENTITIES,
|
||||
CONF_DESCRIPTION,
|
||||
CONF_NAME,
|
||||
UnitOfTemperature,
|
||||
@@ -372,6 +373,9 @@ def async_get_entities(
|
||||
"""Return all entities that are supported by Alexa."""
|
||||
entities: list[AlexaEntity] = []
|
||||
for state in hass.states.async_all():
|
||||
if state.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
|
||||
continue
|
||||
|
||||
if state.domain not in ENTITY_ADAPTERS:
|
||||
continue
|
||||
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
|
||||
from homeassistant.const import CONF_COUNTRY, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import aiohttp_client, config_validation as cv, httpx_client
|
||||
from homeassistant.helpers import aiohttp_client, config_validation as cv
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util.ssl import SSL_ALPN_HTTP11_HTTP2
|
||||
|
||||
from .const import _LOGGER, CONF_LOGIN_DATA, CONF_SITE, COUNTRY_DOMAINS, DOMAIN
|
||||
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator
|
||||
@@ -13,8 +12,6 @@ from .services import async_setup_services
|
||||
PLATFORMS = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.BUTTON,
|
||||
Platform.EVENT,
|
||||
Platform.MEDIA_PLAYER,
|
||||
Platform.NOTIFY,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
@@ -37,24 +34,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bo
|
||||
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
await coordinator.sync_history_state()
|
||||
await coordinator.sync_media_state()
|
||||
|
||||
async def _on_http2_reauth_required() -> None:
|
||||
entry.async_start_reauth(hass)
|
||||
|
||||
alexa_httpx_client = httpx_client.get_async_client(
|
||||
hass,
|
||||
alpn_protocols=SSL_ALPN_HTTP11_HTTP2,
|
||||
)
|
||||
|
||||
await coordinator.api.start_http2_processing(
|
||||
alexa_httpx_client,
|
||||
on_reauth_required=_on_http2_reauth_required,
|
||||
)
|
||||
|
||||
entry.async_on_unload(coordinator.api.stop_http2_processing)
|
||||
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
@@ -39,8 +39,11 @@ async def async_setup_entry(
|
||||
class AmazonRoutineButton(AmazonServiceEntity, ButtonEntity):
|
||||
"""Button entity for Alexa routine."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(self, coordinator: AmazonDevicesCoordinator, routine: str) -> None:
|
||||
"""Initialize the routine button entity."""
|
||||
self._coordinator = coordinator
|
||||
self._routine = routine
|
||||
super().__init__(
|
||||
coordinator,
|
||||
@@ -49,4 +52,4 @@ class AmazonRoutineButton(AmazonServiceEntity, ButtonEntity):
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Handle button press action."""
|
||||
await self.coordinator.api.call_routine(self._routine)
|
||||
await self._coordinator.api.call_routine(self._routine)
|
||||
|
||||
@@ -8,18 +8,13 @@ from aioamazondevices.exceptions import (
|
||||
CannotConnect,
|
||||
CannotRetrieveData,
|
||||
)
|
||||
from aioamazondevices.structures import (
|
||||
AmazonDevice,
|
||||
AmazonMediaState,
|
||||
AmazonVocalRecord,
|
||||
AmazonVolumeState,
|
||||
)
|
||||
from aioamazondevices.structures import AmazonDevice
|
||||
from aiohttp import ClientSession
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
from homeassistant.helpers.debounce import Debouncer
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
@@ -78,18 +73,6 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
|
||||
if routine.domain == Platform.BUTTON
|
||||
}
|
||||
|
||||
self._vocal_records: dict[str, AmazonVocalRecord] = {}
|
||||
self.api.on_history_event.append(self.history_state_event_handler)
|
||||
self.api.on_history_event.freeze()
|
||||
|
||||
self._volume_states: dict[str, AmazonVolumeState] = {}
|
||||
self.api.on_volume_state_event.append(self.volume_state_event_handler)
|
||||
self.api.on_volume_state_event.freeze()
|
||||
|
||||
self._media_states: dict[str, AmazonMediaState] = {}
|
||||
self.api.on_media_state_event.append(self.media_state_event_handler)
|
||||
self.api.on_media_state_event.freeze()
|
||||
|
||||
async def _async_update_data(self) -> dict[str, AmazonDevice]:
|
||||
"""Update device data."""
|
||||
try:
|
||||
@@ -108,6 +91,7 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
except CannotAuthenticate as err:
|
||||
# pylint: disable-next=home-assistant-exception-translation-key-missing
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_auth",
|
||||
@@ -166,85 +150,3 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
|
||||
)
|
||||
if entity_id:
|
||||
entity_registry.async_remove(entity_id)
|
||||
|
||||
async def sync_history_state(self) -> None:
|
||||
"""Sync history state."""
|
||||
try:
|
||||
self._vocal_records = await self.api.sync_history_state()
|
||||
except CannotAuthenticate as e:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_auth",
|
||||
translation_placeholders={"error": repr(e)},
|
||||
) from e
|
||||
except CannotConnect as e:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect_with_error",
|
||||
translation_placeholders={"error": repr(e)},
|
||||
) from e
|
||||
except BaseException as e:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_retrieve_data_with_error",
|
||||
translation_placeholders={"error": repr(e)},
|
||||
) from e
|
||||
|
||||
async def history_state_event_handler(
|
||||
self, vocal_records: dict[str, AmazonVocalRecord]
|
||||
) -> None:
|
||||
"""Handle pushed vocal record events."""
|
||||
self._vocal_records = {**self._vocal_records, **vocal_records}
|
||||
self.async_update_listeners()
|
||||
|
||||
@property
|
||||
def vocal_records(self) -> dict[str, AmazonVocalRecord]:
|
||||
"""Vocal records of devices."""
|
||||
return self._vocal_records
|
||||
|
||||
async def sync_media_state(self) -> None:
|
||||
"""Sync media state."""
|
||||
try:
|
||||
await self.api.sync_media_state()
|
||||
except CannotAuthenticate as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_auth",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
except (CannotConnect, TimeoutError) as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect_with_error",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
except (CannotRetrieveData, ValueError) as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_retrieve_data_with_error",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
|
||||
async def media_state_event_handler(
|
||||
self, media_state: dict[str, AmazonMediaState]
|
||||
) -> None:
|
||||
"""Handle pushed media state changed events."""
|
||||
self._media_states = media_state
|
||||
self.async_update_listeners()
|
||||
|
||||
@property
|
||||
def media_states(self) -> dict[str, AmazonMediaState]:
|
||||
"""Media state of devices."""
|
||||
return self._media_states
|
||||
|
||||
async def volume_state_event_handler(
|
||||
self, volume_states: dict[str, AmazonVolumeState]
|
||||
) -> None:
|
||||
"""Handle pushed volume change events."""
|
||||
self._volume_states = volume_states
|
||||
self.async_update_listeners()
|
||||
|
||||
@property
|
||||
def volume_states(self) -> dict[str, AmazonVolumeState]:
|
||||
"""Volumes of devices."""
|
||||
return self._volume_states
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
"""Support for events."""
|
||||
|
||||
from typing import Final
|
||||
|
||||
from homeassistant.components.event import EventEntity, EventEntityDescription
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import _LOGGER
|
||||
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator
|
||||
from .entity import AmazonEntity
|
||||
|
||||
# Coordinator is used to centralize the data updates
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
EVENTS: Final = {
|
||||
EventEntityDescription(
|
||||
key="voice_event",
|
||||
translation_key="voice_event",
|
||||
),
|
||||
}
|
||||
|
||||
EVENT_TYPE = "triggered"
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: AmazonConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Alexa Devices events based on a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
known_devices: set[str] = set()
|
||||
|
||||
def _check_device() -> None:
|
||||
current_devices = set(coordinator.data)
|
||||
new_devices = current_devices - known_devices
|
||||
if new_devices:
|
||||
known_devices.update(new_devices)
|
||||
async_add_entities(
|
||||
AlexaVoiceEvent(coordinator, serial_num, event_desc)
|
||||
for event_desc in EVENTS
|
||||
for serial_num in new_devices
|
||||
)
|
||||
|
||||
_check_device()
|
||||
entry.async_on_unload(coordinator.async_add_listener(_check_device))
|
||||
|
||||
|
||||
class AlexaVoiceEvent(AmazonEntity, EventEntity):
|
||||
"""Representation of an Alexa voice event."""
|
||||
|
||||
_attr_event_types = [EVENT_TYPE]
|
||||
coordinator: AmazonDevicesCoordinator
|
||||
_last_seen_timestamp: int = 0 # January 1, 1970 at 12:00:00 AM
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
|
||||
if not (
|
||||
vocal_record := self.coordinator.vocal_records.get(
|
||||
self.device.serial_number
|
||||
)
|
||||
):
|
||||
_LOGGER.debug(
|
||||
"No vocal record found for device %s [%s]",
|
||||
self.device.account_name,
|
||||
self.device.serial_number,
|
||||
)
|
||||
return
|
||||
|
||||
if vocal_record.timestamp <= self._last_seen_timestamp:
|
||||
# Discard old events that have already been processed
|
||||
return
|
||||
|
||||
self._last_seen_timestamp = vocal_record.timestamp
|
||||
self._trigger_event(
|
||||
EVENT_TYPE,
|
||||
{
|
||||
"intent": vocal_record.intent,
|
||||
"voice_command": vocal_record.title,
|
||||
"voice_reply": vocal_record.sub_title,
|
||||
},
|
||||
)
|
||||
self.async_write_ha_state()
|
||||
@@ -1,10 +1,5 @@
|
||||
{
|
||||
"entity": {
|
||||
"event": {
|
||||
"voice_event": {
|
||||
"default": "mdi:chat-processing"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"voc_index": {
|
||||
"default": "mdi:molecule"
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioamazondevices==14.0.0"]
|
||||
"requirements": ["aioamazondevices==13.7.0"]
|
||||
}
|
||||
|
||||
@@ -1,291 +0,0 @@
|
||||
"""Media player platform for Alexa Devices."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from aioamazondevices.structures import (
|
||||
AmazonMediaControls,
|
||||
AmazonMediaState,
|
||||
AmazonVolumeState,
|
||||
)
|
||||
|
||||
from homeassistant.components.media_player import (
|
||||
MediaPlayerDeviceClass,
|
||||
MediaPlayerEnqueue,
|
||||
MediaPlayerEntity,
|
||||
MediaPlayerEntityDescription,
|
||||
MediaPlayerEntityFeature,
|
||||
MediaPlayerState,
|
||||
MediaType,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import _LOGGER
|
||||
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator
|
||||
from .entity import AmazonEntity
|
||||
from .utils import alexa_api_call
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
STANDARD_SUPPORTED_FEATURES = (
|
||||
MediaPlayerEntityFeature.VOLUME_SET
|
||||
| MediaPlayerEntityFeature.VOLUME_STEP
|
||||
| MediaPlayerEntityFeature.VOLUME_MUTE
|
||||
| MediaPlayerEntityFeature.STOP
|
||||
| MediaPlayerEntityFeature.PLAY_MEDIA
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: AmazonConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Alexa Devices media player entities from a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
known_devices: set[str] = set()
|
||||
|
||||
def _check_device() -> None:
|
||||
"""Add entities for newly discovered devices."""
|
||||
new_entities: list[AlexaDevicesMediaPlayer] = []
|
||||
|
||||
for serial_num, device in coordinator.data.items():
|
||||
if serial_num in known_devices or not device.media_player_supported:
|
||||
continue
|
||||
|
||||
known_devices.add(serial_num)
|
||||
new_entities.append(
|
||||
AlexaDevicesMediaPlayer(
|
||||
coordinator, serial_num, MediaPlayerEntityDescription(key="media")
|
||||
)
|
||||
)
|
||||
|
||||
if new_entities:
|
||||
async_add_entities(new_entities)
|
||||
|
||||
remove_listener = coordinator.async_add_listener(_check_device)
|
||||
entry.async_on_unload(remove_listener)
|
||||
_check_device()
|
||||
|
||||
|
||||
class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
|
||||
"""Representation of an Alexa device media player."""
|
||||
|
||||
_attr_name = None # Uses the device name
|
||||
_attr_device_class = MediaPlayerDeviceClass.SPEAKER
|
||||
_attr_volume_step = 0.05
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: AmazonDevicesCoordinator,
|
||||
serial_num: str,
|
||||
description: MediaPlayerEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
self._prev_volume: int | None = None
|
||||
super().__init__(coordinator, serial_num, description)
|
||||
|
||||
@property
|
||||
def media_state(self) -> AmazonMediaState | None:
|
||||
"""Return the media state relating to device."""
|
||||
if not self.coordinator or not self.coordinator.media_states:
|
||||
return None
|
||||
return self.coordinator.media_states.get(self._serial_num)
|
||||
|
||||
@property
|
||||
def volume_state(self) -> AmazonVolumeState | None:
|
||||
"""Volume settings for device."""
|
||||
if not self.coordinator or not self.coordinator.volume_states:
|
||||
return None
|
||||
return self.coordinator.volume_states.get(self._serial_num)
|
||||
|
||||
@property
|
||||
def supported_features(self) -> MediaPlayerEntityFeature:
|
||||
"""Return dynamically supported features based on current media."""
|
||||
features = STANDARD_SUPPORTED_FEATURES
|
||||
|
||||
if self.media_state is None:
|
||||
return features
|
||||
|
||||
if self.media_state.pause_enabled:
|
||||
features |= MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.PAUSE
|
||||
|
||||
if self.media_state.next_enabled:
|
||||
features |= MediaPlayerEntityFeature.NEXT_TRACK
|
||||
|
||||
if self.media_state.previous_enabled:
|
||||
features |= MediaPlayerEntityFeature.PREVIOUS_TRACK
|
||||
|
||||
return features
|
||||
|
||||
@property
|
||||
def state(self) -> MediaPlayerState | None:
|
||||
"""Return the current state of the player."""
|
||||
if not self.media_state:
|
||||
return MediaPlayerState.IDLE
|
||||
if self.media_state.player_state == "PLAYING":
|
||||
return MediaPlayerState.PLAYING
|
||||
if self.media_state.player_state == "PAUSED":
|
||||
return MediaPlayerState.PAUSED
|
||||
|
||||
return MediaPlayerState.IDLE
|
||||
|
||||
@property
|
||||
def volume_level(self) -> float | None:
|
||||
"""Return the volume level (0.0 to 1.0)."""
|
||||
if not self.volume_state or self.volume_state.volume is None:
|
||||
return None
|
||||
return self.volume_state.volume / 100
|
||||
|
||||
@property
|
||||
def is_volume_muted(self) -> bool | None:
|
||||
"""Return True if the volume is muted."""
|
||||
if not self.volume_state or self.volume_state.volume is None:
|
||||
return None
|
||||
# is_muted is True when Alexa has muted the device
|
||||
# volume == 0 is where we have muted by setting volume to 0
|
||||
return self.volume_state.is_muted or self.volume_state.volume == 0
|
||||
|
||||
@property
|
||||
def media_title(self) -> str | None:
|
||||
"""Track title."""
|
||||
if not self.media_state:
|
||||
return None
|
||||
return self.media_state.now_playing_title
|
||||
|
||||
@property
|
||||
def media_artist(self) -> str | None:
|
||||
"""Artist name."""
|
||||
if not self.media_state:
|
||||
return None
|
||||
return self.media_state.now_playing_line1
|
||||
|
||||
@property
|
||||
def media_album_name(self) -> str | None:
|
||||
"""Album name."""
|
||||
if not self.media_state:
|
||||
return None
|
||||
return self.media_state.now_playing_line2
|
||||
|
||||
@property
|
||||
def media_image_url(self) -> str | None:
|
||||
"""Album art URL."""
|
||||
if not self.media_state:
|
||||
return None
|
||||
return self.media_state.now_playing_url
|
||||
|
||||
@property
|
||||
def media_duration(self) -> int | None:
|
||||
"""Duration in seconds."""
|
||||
if not self.media_state:
|
||||
return None
|
||||
return self.media_state.media_length
|
||||
|
||||
@property
|
||||
def media_position(self) -> int | None:
|
||||
"""Current playback position in seconds."""
|
||||
if not self.media_state:
|
||||
return None
|
||||
return self.media_state.media_position
|
||||
|
||||
@property
|
||||
def media_position_updated_at(self) -> datetime | None:
|
||||
"""When media_position was last updated — HA uses this to interpolate the progress bar."""
|
||||
if not self.media_state:
|
||||
return None
|
||||
return self.media_state.media_position_updated_at
|
||||
|
||||
@property
|
||||
def media_content_type(self) -> MediaType | None:
|
||||
"""Content type — tells HA what kind of media is playing."""
|
||||
if self.state in (MediaPlayerState.PLAYING, MediaPlayerState.PAUSED):
|
||||
return MediaType.MUSIC
|
||||
return None
|
||||
|
||||
async def async_play_media(
|
||||
self,
|
||||
media_type: MediaType | str,
|
||||
media_id: str,
|
||||
enqueue: MediaPlayerEnqueue | None = None,
|
||||
announce: bool | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Play a piece of media."""
|
||||
provider = media_type.value if isinstance(media_type, MediaType) else media_type
|
||||
await self.async_call_alexa_music(media_id, provider)
|
||||
|
||||
@alexa_api_call
|
||||
async def async_call_alexa_music(
|
||||
self, search_phrase: str, provider_id: str
|
||||
) -> None:
|
||||
"""Call alexa music."""
|
||||
await self.coordinator.api.call_alexa_music(
|
||||
self.device, search_phrase, provider_id
|
||||
)
|
||||
|
||||
@alexa_api_call
|
||||
async def async_set_device_volume(self, volume: int) -> None:
|
||||
"""Set the device volume."""
|
||||
_LOGGER.debug(
|
||||
"Setting volume for %s to %s%%",
|
||||
self.device.serial_number,
|
||||
volume,
|
||||
)
|
||||
await self.coordinator.api.set_device_volume(self.device, volume)
|
||||
|
||||
async def async_set_volume_level(self, volume: float) -> None:
|
||||
"""Set the volume level (0.0 to 1.0)."""
|
||||
device_volume = round(volume * 100)
|
||||
await self.async_set_device_volume(device_volume)
|
||||
|
||||
async def async_mute_volume(self, mute: bool) -> None:
|
||||
"""Mute or un-mute the volume."""
|
||||
# Whilst you can mute a device by asking it there appears to be
|
||||
# no way to do this programmatically so set volume to 0
|
||||
if not self.volume_state or self.volume_state.volume is None:
|
||||
return
|
||||
if mute:
|
||||
self._prev_volume = self.volume_state.volume
|
||||
await self.async_set_volume_level(0)
|
||||
return
|
||||
|
||||
if self.volume_state.is_muted and self._prev_volume is None:
|
||||
# is muted by Alexa which we can see but not control
|
||||
# when muted this way, volume is still set
|
||||
# changing volume will unmute
|
||||
# if HA set volume to 0 then Alexa muted we just default to 30%
|
||||
self._prev_volume = self.volume_state.volume or 30
|
||||
if self._prev_volume is None:
|
||||
return
|
||||
target_volume = self._prev_volume
|
||||
await self.async_set_volume_level(target_volume / 100)
|
||||
self._prev_volume = None
|
||||
|
||||
@alexa_api_call
|
||||
async def _send_media_command(self, command: AmazonMediaControls) -> None:
|
||||
_LOGGER.debug(
|
||||
"Sending media command '%s' to %s", command, self.device.serial_number
|
||||
)
|
||||
await self.coordinator.api.send_media_command(self.device, command)
|
||||
|
||||
async def async_media_stop(self) -> None:
|
||||
"""Send stop command."""
|
||||
await self._send_media_command(AmazonMediaControls.Stop)
|
||||
|
||||
async def async_media_pause(self) -> None:
|
||||
"""Send pause command."""
|
||||
await self._send_media_command(AmazonMediaControls.Pause)
|
||||
|
||||
async def async_media_play(self) -> None:
|
||||
"""Send play command."""
|
||||
await self._send_media_command(AmazonMediaControls.Play)
|
||||
|
||||
async def async_media_next_track(self) -> None:
|
||||
"""Send next track command."""
|
||||
await self._send_media_command(AmazonMediaControls.Next)
|
||||
|
||||
async def async_media_previous_track(self) -> None:
|
||||
"""Send previous track command."""
|
||||
await self._send_media_command(AmazonMediaControls.Previous)
|
||||
@@ -58,18 +58,6 @@
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"event": {
|
||||
"voice_event": {
|
||||
"name": "Voice event",
|
||||
"state_attributes": {
|
||||
"event_type": {
|
||||
"state": {
|
||||
"triggered": "Triggered"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"notify": {
|
||||
"announce": {
|
||||
"name": "Announce"
|
||||
@@ -114,9 +102,6 @@
|
||||
"entry_not_loaded": {
|
||||
"message": "Entry not loaded: {entry}"
|
||||
},
|
||||
"invalid_auth": {
|
||||
"message": "Invalid authentication credentials: {error}"
|
||||
},
|
||||
"invalid_device_id": {
|
||||
"message": "Invalid device ID specified: {device_id}"
|
||||
},
|
||||
@@ -125,9 +110,6 @@
|
||||
},
|
||||
"invalid_sound_value": {
|
||||
"message": "Invalid sound {sound} specified"
|
||||
},
|
||||
"unknown_exception": {
|
||||
"message": "Unknown error occurred: {error}"
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
|
||||
@@ -7,11 +7,10 @@ from altruistclient import AltruistClient, AltruistDeviceModel, AltruistError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
||||
|
||||
from .const import DOMAIN
|
||||
from .const import CONF_HOST, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
"""Constants for the Altruist integration."""
|
||||
|
||||
DOMAIN = "altruist"
|
||||
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_HOST = "host"
|
||||
|
||||
@@ -10,12 +10,13 @@ import logging
|
||||
from altruistclient import AltruistClient, AltruistError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import CONF_HOST
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
UPDATE_INTERVAL = timedelta(seconds=15)
|
||||
|
||||
@@ -5,12 +5,8 @@ from typing import Any
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import labs, websocket_api
|
||||
from homeassistant.components.hassio import HassioNotReadyError
|
||||
from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import discovery_flow
|
||||
from homeassistant.helpers.start import async_at_started
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
@@ -53,7 +49,6 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
)
|
||||
|
||||
DATA_COMPONENT: HassKey[Analytics] = HassKey(DOMAIN)
|
||||
_DATA_SNAPSHOTS_URL: HassKey[str | None] = HassKey(f"{DOMAIN}_snapshots_url")
|
||||
|
||||
LABS_SNAPSHOT_FEATURE = "snapshots"
|
||||
|
||||
@@ -62,39 +57,18 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the analytics integration."""
|
||||
analytics_config = config.get(DOMAIN, {})
|
||||
|
||||
snapshots_url: str | None = None
|
||||
if CONF_SNAPSHOTS_URL in analytics_config:
|
||||
await labs.async_update_preview_feature(
|
||||
hass, DOMAIN, LABS_SNAPSHOT_FEATURE, enabled=True
|
||||
)
|
||||
snapshots_url = analytics_config[CONF_SNAPSHOTS_URL]
|
||||
else:
|
||||
snapshots_url = None
|
||||
|
||||
hass.data[_DATA_SNAPSHOTS_URL] = snapshots_url
|
||||
|
||||
discovery_flow.async_create_flow(
|
||||
hass, DOMAIN, context={"source": SOURCE_SYSTEM}, data={}
|
||||
)
|
||||
|
||||
websocket_api.async_register_command(hass, websocket_analytics)
|
||||
websocket_api.async_register_command(hass, websocket_analytics_preferences)
|
||||
|
||||
hass.http.register_view(AnalyticsDevicesView)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Analytics from a config entry."""
|
||||
snapshots_url = hass.data[_DATA_SNAPSHOTS_URL]
|
||||
analytics = Analytics(hass, snapshots_url)
|
||||
|
||||
try:
|
||||
await analytics.load()
|
||||
except HassioNotReadyError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="supervisor_not_ready",
|
||||
) from err
|
||||
# Load stored data
|
||||
await analytics.load()
|
||||
|
||||
started = False
|
||||
|
||||
@@ -106,8 +80,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
if started:
|
||||
await analytics.async_schedule()
|
||||
|
||||
async def start_schedule(hass: HomeAssistant) -> None:
|
||||
"""Start the send schedule once Home Assistant has started."""
|
||||
async def start_schedule(_event: Event) -> None:
|
||||
"""Start the send schedule after the started event."""
|
||||
nonlocal started
|
||||
started = True
|
||||
await analytics.async_schedule()
|
||||
@@ -115,7 +89,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
labs.async_subscribe_preview_feature(
|
||||
hass, DOMAIN, LABS_SNAPSHOT_FEATURE, _async_handle_labs_update
|
||||
)
|
||||
async_at_started(hass, start_schedule)
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, start_schedule)
|
||||
|
||||
websocket_api.async_register_command(hass, websocket_analytics)
|
||||
websocket_api.async_register_command(hass, websocket_analytics_preferences)
|
||||
|
||||
hass.http.register_view(AnalyticsDevicesView)
|
||||
|
||||
hass.data[DATA_COMPONENT] = analytics
|
||||
return True
|
||||
@@ -130,9 +109,7 @@ def websocket_analytics(
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Return analytics preferences."""
|
||||
if (analytics := hass.data.get(DATA_COMPONENT)) is None:
|
||||
connection.send_error(msg["id"], websocket_api.ERR_NOT_FOUND, "Not loaded")
|
||||
return
|
||||
analytics = hass.data[DATA_COMPONENT]
|
||||
connection.send_result(
|
||||
msg["id"],
|
||||
{ATTR_PREFERENCES: analytics.preferences, ATTR_ONBOARDED: analytics.onboarded},
|
||||
@@ -153,10 +130,8 @@ async def websocket_analytics_preferences(
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Update analytics preferences."""
|
||||
if (analytics := hass.data.get(DATA_COMPONENT)) is None:
|
||||
connection.send_error(msg["id"], websocket_api.ERR_NOT_FOUND, "Not loaded")
|
||||
return
|
||||
preferences = msg[ATTR_PREFERENCES]
|
||||
analytics = hass.data[DATA_COMPONENT]
|
||||
|
||||
await analytics.save_preferences(preferences)
|
||||
await analytics.async_schedule()
|
||||
|
||||
@@ -299,8 +299,12 @@ class Analytics:
|
||||
self._data = AnalyticsData.from_dict(stored)
|
||||
|
||||
if self.supervisor and not self.onboarded:
|
||||
# This may raise HassioNotReadyError if Supervisor was unreachable.
|
||||
# The caller is responsible for handling this and triggering a retry.
|
||||
# This may raise HassioNotReadyError if Supervisor was unreachable
|
||||
# during setup of the Supervisor integration. That will fail setup
|
||||
# of this integration. However there is no better option at this time
|
||||
# since we need to get the diagnostic setting from Supervisor to correctly
|
||||
# setup this integration and we can't raise ConfigEntryNotReady to
|
||||
# trigger a retry from async_setup.
|
||||
supervisor_info = hassio.get_supervisor_info(self._hass)
|
||||
|
||||
# User have not configured analytics, get this setting from the supervisor
|
||||
@@ -345,10 +349,10 @@ class Analytics:
|
||||
await self._save()
|
||||
|
||||
if self.supervisor:
|
||||
# Try to pull Supervisor information, but don't fail if some or all
|
||||
# of it is unavailable due to setup failures in the hassio integration.
|
||||
with contextlib.suppress(hassio.HassioNotReadyError):
|
||||
supervisor_info = hassio.get_supervisor_info(hass)
|
||||
# get_supervisor_info was called during setup so we can't get here
|
||||
# if it raised. The others may raise HassioNotReadyError if only some
|
||||
# data was successfully fetched from Supervisor
|
||||
supervisor_info = hassio.get_supervisor_info(hass)
|
||||
with contextlib.suppress(hassio.HassioNotReadyError):
|
||||
operating_system_info = hassio.get_os_info(hass)
|
||||
with contextlib.suppress(hassio.HassioNotReadyError):
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
"""Config flow for Analytics integration."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
class AnalyticsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Analytics."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def async_step_system(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
return self.async_create_entry(title="Analytics", data={})
|
||||
@@ -3,7 +3,6 @@
|
||||
"name": "Analytics",
|
||||
"after_dependencies": ["energy", "hassio", "recorder"],
|
||||
"codeowners": ["@home-assistant/core"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["api", "websocket_api", "http"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/analytics",
|
||||
"integration_type": "system",
|
||||
@@ -15,6 +14,5 @@
|
||||
"report_issue_url": "https://github.com/OHF-Device-Database/device-database/issues/new"
|
||||
}
|
||||
},
|
||||
"quality_scale": "internal",
|
||||
"single_config_entry": true
|
||||
"quality_scale": "internal"
|
||||
}
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
{
|
||||
"exceptions": {
|
||||
"supervisor_not_ready": {
|
||||
"message": "Supervisor was not ready during setup, will retry"
|
||||
}
|
||||
},
|
||||
"preview_features": {
|
||||
"snapshots": {
|
||||
"description": "We're creating the [Open Home Foundation Device Database](https://www.home-assistant.io/blog/2026/02/02/about-device-database/): a free, open source community-powered resource to help users find practical information about how smart home devices perform in real installations.\n\nYou can help us build it by opting in to share anonymized data about your devices. This data will only ever include device-specific details (like model or manufacturer) – never personally identifying information (like the names you assign).\n\nFind out how we process your data (should you choose to contribute) in our [Data Use Statement](https://www.openhomefoundation.org/device-database-data-use-statement).",
|
||||
|
||||
@@ -230,13 +230,13 @@ async def async_migrate_entry(hass: HomeAssistant, entry: AnthropicConfigEntry)
|
||||
|
||||
if entry.version == 2 and entry.minor_version == 3:
|
||||
# Remove Temperature parameter
|
||||
temperature_key = "temperature"
|
||||
CONF_TEMPERATURE = "temperature"
|
||||
|
||||
for subentry in entry.subentries.values():
|
||||
data = subentry.data.copy()
|
||||
if temperature_key not in data:
|
||||
if CONF_TEMPERATURE not in data:
|
||||
continue
|
||||
data.pop(temperature_key, None)
|
||||
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)
|
||||
|
||||
@@ -226,7 +226,7 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
|
||||
) -> SubentryFlowResult:
|
||||
"""Set initial options."""
|
||||
# abort if entry is not loaded
|
||||
if self._get_entry().state is not ConfigEntryState.LOADED:
|
||||
if self._get_entry().state != ConfigEntryState.LOADED:
|
||||
return self.async_abort(reason="entry_not_loaded")
|
||||
|
||||
hass_apis: list[SelectOptionDict] = [
|
||||
|
||||
@@ -4,6 +4,7 @@ import base64
|
||||
from collections import deque
|
||||
from collections.abc import AsyncIterator, Callable, Iterable
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
import json
|
||||
from mimetypes import guess_file_type
|
||||
from pathlib import Path
|
||||
@@ -113,7 +114,7 @@ from homeassistant.exceptions import HomeAssistantError
|
||||
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 dt as dt_util, slugify
|
||||
from homeassistant.util import slugify
|
||||
from homeassistant.util.json import JsonArrayType, JsonObjectType
|
||||
|
||||
from .const import (
|
||||
@@ -371,7 +372,7 @@ def _convert_content( # noqa: C901
|
||||
)
|
||||
if (
|
||||
content.native.container is not None
|
||||
and content.native.container.expires_at > dt_util.utcnow()
|
||||
and content.native.container.expires_at > datetime.now(UTC)
|
||||
):
|
||||
container_id = content.native.container.id
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ class AOSmithWaterHeaterEntity(AOSmithStatusEntity, WaterHeaterEntity):
|
||||
def supported_features(self) -> WaterHeaterEntityFeature:
|
||||
"""Return the list of supported features."""
|
||||
supports_vacation_mode = any(
|
||||
supported_mode.mode is AOSmithOperationMode.VACATION
|
||||
supported_mode.mode == AOSmithOperationMode.VACATION
|
||||
for supported_mode in self.device.supported_modes
|
||||
)
|
||||
|
||||
@@ -122,7 +122,7 @@ class AOSmithWaterHeaterEntity(AOSmithStatusEntity, WaterHeaterEntity):
|
||||
@property
|
||||
def is_away_mode_on(self) -> bool:
|
||||
"""Return True if away mode is on."""
|
||||
return self.device.status.current_mode is AOSmithOperationMode.VACATION
|
||||
return self.device.status.current_mode == AOSmithOperationMode.VACATION
|
||||
|
||||
async def async_set_operation_mode(self, operation_mode: str) -> None:
|
||||
"""Set new target operation mode."""
|
||||
|
||||
@@ -7,3 +7,27 @@ CONNECTION_TIMEOUT: int = 10
|
||||
|
||||
# Field name of last self test retrieved from apcupsd.
|
||||
LAST_S_TEST: Final = "laststest"
|
||||
|
||||
# Mapping of deprecated sensor keys (as reported by apcupsd,
|
||||
# lower-cased) to their deprecation
|
||||
# repair issue translation keys.
|
||||
DEPRECATED_SENSORS: Final = {
|
||||
"apc": "apc_deprecated",
|
||||
"end apc": "date_deprecated",
|
||||
"date": "date_deprecated",
|
||||
"apcmodel": "available_via_device_info",
|
||||
"model": "available_via_device_info",
|
||||
"firmware": "available_via_device_info",
|
||||
"version": "available_via_device_info",
|
||||
"upsname": "available_via_device_info",
|
||||
"serialno": "available_via_device_info",
|
||||
}
|
||||
|
||||
AVAILABLE_VIA_DEVICE_ATTR: Final = {
|
||||
"apcmodel": "model",
|
||||
"model": "model",
|
||||
"firmware": "hw_version",
|
||||
"version": "sw_version",
|
||||
"upsname": "name",
|
||||
"serialno": "serial_number",
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"""Support for APCUPSd sensors."""
|
||||
|
||||
import logging
|
||||
from typing import Final
|
||||
|
||||
import dateutil
|
||||
|
||||
from homeassistant.components.automation import automations_with_entity
|
||||
from homeassistant.components.script import scripts_with_entity
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
@@ -23,9 +24,11 @@ from homeassistant.const import (
|
||||
UnitOfTime,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
import homeassistant.helpers.issue_registry as ir
|
||||
|
||||
from .const import LAST_S_TEST
|
||||
from .const import AVAILABLE_VIA_DEVICE_ATTR, DEPRECATED_SENSORS, DOMAIN, LAST_S_TEST
|
||||
from .coordinator import APCUPSdConfigEntry, APCUPSdCoordinator
|
||||
from .entity import APCUPSdEntity
|
||||
|
||||
@@ -33,20 +36,6 @@ PARALLEL_UPDATES = 0
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# List of useless sensors to ignore, since they are either provided in device
|
||||
# information, or not useful at all
|
||||
IGNORED_SENSORS: Final = {
|
||||
"apc",
|
||||
"end apc",
|
||||
"date",
|
||||
"apcmodel",
|
||||
"model",
|
||||
"firmware",
|
||||
"version",
|
||||
"upsname",
|
||||
"serialno",
|
||||
}
|
||||
|
||||
SENSORS: dict[str, SensorEntityDescription] = {
|
||||
"alarmdel": SensorEntityDescription(
|
||||
key="alarmdel",
|
||||
@@ -60,6 +49,18 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
"apc": SensorEntityDescription(
|
||||
key="apc",
|
||||
translation_key="apc_status",
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"apcmodel": SensorEntityDescription(
|
||||
key="apcmodel",
|
||||
translation_key="apc_model",
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"badbatts": SensorEntityDescription(
|
||||
key="badbatts",
|
||||
translation_key="bad_batteries",
|
||||
@@ -99,6 +100,12 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
),
|
||||
"date": SensorEntityDescription(
|
||||
key="date",
|
||||
translation_key="date",
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"dipsw": SensorEntityDescription(
|
||||
key="dipsw",
|
||||
translation_key="dip_switch_settings",
|
||||
@@ -125,11 +132,23 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
||||
translation_key="wake_delay",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"end apc": SensorEntityDescription(
|
||||
key="end apc",
|
||||
translation_key="date_and_time",
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"extbatts": SensorEntityDescription(
|
||||
key="extbatts",
|
||||
translation_key="external_batteries",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"firmware": SensorEntityDescription(
|
||||
key="firmware",
|
||||
translation_key="firmware_version",
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"hitrans": SensorEntityDescription(
|
||||
key="hitrans",
|
||||
translation_key="transfer_high",
|
||||
@@ -245,6 +264,12 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
||||
translation_key="min_time",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"model": SensorEntityDescription(
|
||||
key="model",
|
||||
translation_key="model",
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"nombattv": SensorEntityDescription(
|
||||
key="nombattv",
|
||||
translation_key="battery_nominal_voltage",
|
||||
@@ -333,6 +358,12 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"serialno": SensorEntityDescription(
|
||||
key="serialno",
|
||||
translation_key="serial_number",
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"starttime": SensorEntityDescription(
|
||||
key="starttime",
|
||||
translation_key="startup_time",
|
||||
@@ -373,6 +404,18 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
||||
translation_key="ups_mode",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"upsname": SensorEntityDescription(
|
||||
key="upsname",
|
||||
translation_key="ups_name",
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"version": SensorEntityDescription(
|
||||
key="version",
|
||||
translation_key="version",
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"xoffbat": SensorEntityDescription(
|
||||
key="xoffbat",
|
||||
translation_key="transfer_from_battery",
|
||||
@@ -438,10 +481,9 @@ async def async_setup_entry(
|
||||
# as unknown initially.
|
||||
#
|
||||
# We also sort the resources to ensure the order of entities
|
||||
# created is deterministic
|
||||
# created is deterministic since "APCMODEL" and "MODEL"
|
||||
# resources map to the same "Model" name.
|
||||
for resource in sorted(available_resources | {LAST_S_TEST}):
|
||||
if resource in IGNORED_SENSORS:
|
||||
continue
|
||||
if resource not in SENSORS:
|
||||
_LOGGER.warning("Invalid resource from APCUPSd: %s", resource.upper())
|
||||
continue
|
||||
@@ -519,3 +561,63 @@ class APCUPSdSensor(APCUPSdEntity, SensorEntity):
|
||||
self._attr_native_value, inferred_unit = infer_unit(data)
|
||||
if not self.native_unit_of_measurement:
|
||||
self._attr_native_unit_of_measurement = inferred_unit
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Handle when entity is added to Home Assistant.
|
||||
|
||||
If this is a deprecated sensor entity, create a repair issue to guide
|
||||
the user to disable it.
|
||||
"""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
if not self.enabled:
|
||||
return
|
||||
|
||||
reason = DEPRECATED_SENSORS.get(self.entity_description.key)
|
||||
if not reason:
|
||||
return
|
||||
|
||||
automations = automations_with_entity(self.hass, self.entity_id)
|
||||
scripts = scripts_with_entity(self.hass, self.entity_id)
|
||||
if not automations and not scripts:
|
||||
return
|
||||
|
||||
entity_registry = er.async_get(self.hass)
|
||||
items = [
|
||||
f"- [{entry.name or entry.original_name or entity_id}]"
|
||||
f"(/config/{integration}/edit/"
|
||||
f"{entry.unique_id or entity_id.split('.', 1)[-1]})"
|
||||
for integration, entities in (
|
||||
("automation", automations),
|
||||
("script", scripts),
|
||||
)
|
||||
for entity_id in entities
|
||||
if (entry := entity_registry.async_get(entity_id))
|
||||
]
|
||||
placeholders = {
|
||||
"entity_name": str(self.name or self.entity_id),
|
||||
"entity_id": self.entity_id,
|
||||
"items": "\n".join(items),
|
||||
}
|
||||
if via_attr := AVAILABLE_VIA_DEVICE_ATTR.get(self.entity_description.key):
|
||||
placeholders["available_via_device_attr"] = via_attr
|
||||
if device_entry := self.device_entry:
|
||||
placeholders["device_id"] = device_entry.id
|
||||
|
||||
ir.async_create_issue(
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
f"{reason}_{self.entity_id}",
|
||||
breaks_in_ha_version="2026.6.0",
|
||||
is_fixable=False,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key=reason,
|
||||
translation_placeholders=placeholders,
|
||||
)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Handle when entity will be removed from Home Assistant."""
|
||||
await super().async_will_remove_from_hass()
|
||||
|
||||
if issue_key := DEPRECATED_SENSORS.get(self.entity_description.key):
|
||||
ir.async_delete_issue(self.hass, DOMAIN, f"{issue_key}_{self.entity_id}")
|
||||
|
||||
@@ -241,5 +241,19 @@
|
||||
"cannot_connect": {
|
||||
"message": "Cannot connect to APC UPS Daemon."
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"apc_deprecated": {
|
||||
"description": "The {entity_name} sensor (`{entity_id}`) is deprecated because it exposes internal details of the APC UPS Daemon response.\n\nIt is still referenced in the following automations or scripts:\n{items}\n\nUpdate those automations or scripts to use supported APC UPS entities instead. Reload the APC UPS Daemon integration afterwards to resolve this issue.",
|
||||
"title": "{entity_name} sensor is deprecated"
|
||||
},
|
||||
"available_via_device_info": {
|
||||
"description": "The {entity_name} sensor (`{entity_id}`) is deprecated because the same value is available from the device registry via `device_attr(\"{device_id}\", \"{available_via_device_attr}\")`.\n\nIt is still referenced in the following automations or scripts:\n{items}\n\nUpdate those automations or scripts to use the `device_attr` helper instead of this sensor. Reload the APC UPS Daemon integration afterwards to resolve this issue.",
|
||||
"title": "{entity_name} sensor is deprecated"
|
||||
},
|
||||
"date_deprecated": {
|
||||
"description": "The {entity_name} sensor (`{entity_id}`) is deprecated because the timestamp is already available from other APC UPS sensors via their last updated time.\n\nIt is still referenced in the following automations or scripts:\n{items}\n\nUpdate those automations or scripts to reference any entity's `last_updated` attribute instead (for example, `states.binary_sensor.apcups_online_status.last_updated`). Reload the APC UPS Daemon integration afterwards to resolve this issue.",
|
||||
"title": "{entity_name} sensor is deprecated"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ ATTR_EXTERNAL_URL = "external_url"
|
||||
ATTR_INTERNAL_URL = "internal_url"
|
||||
ATTR_LOCATION_NAME = "location_name"
|
||||
ATTR_INSTALLATION_TYPE = "installation_type"
|
||||
ATTR_REQUIRES_API_PASSWORD = "requires_api_password"
|
||||
ATTR_UUID = "uuid"
|
||||
ATTR_VERSION = "version"
|
||||
|
||||
@@ -221,7 +222,7 @@ class APIStatesView(HomeAssistantView):
|
||||
states = (
|
||||
state.as_dict_json
|
||||
for state in hass.states.async_all()
|
||||
if entity_perm(state.entity_id, POLICY_READ)
|
||||
if entity_perm(state.entity_id, "read")
|
||||
)
|
||||
response = web.Response(
|
||||
body=b"".join((b"[", b",".join(states), b"]")),
|
||||
@@ -293,10 +294,8 @@ class APIEntityStateView(HomeAssistantView):
|
||||
|
||||
# Read the state back for our response
|
||||
status_code = HTTPStatus.CREATED if is_new_state else HTTPStatus.OK
|
||||
if (state := hass.states.get(entity_id)) is None:
|
||||
return self.json_message(
|
||||
"Error storing state.", HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
)
|
||||
state = hass.states.get(entity_id)
|
||||
assert state
|
||||
resp = self.json(state.as_dict(), status_code)
|
||||
|
||||
resp.headers.add("Location", f"/api/states/{entity_id}")
|
||||
|
||||
@@ -369,7 +369,7 @@ class AppleTVManager(DeviceListener):
|
||||
|
||||
attrs[ATTR_MODEL] = (
|
||||
dev_info.raw_model
|
||||
if dev_info.model is DeviceModel.Unknown and dev_info.raw_model
|
||||
if dev_info.model == DeviceModel.Unknown and dev_info.raw_model
|
||||
else model_str(dev_info.model)
|
||||
)
|
||||
attrs[ATTR_SW_VERSION] = dev_info.version
|
||||
|
||||
@@ -63,7 +63,7 @@ class AppleTVKeyboardFocused(AppleTVEntity, BinarySensorEntity, KeyboardListener
|
||||
# Listen to keyboard updates
|
||||
atv.keyboard.listener = self
|
||||
# Set initial state based on current focus state
|
||||
self._update_state(atv.keyboard.text_focus_state is KeyboardFocusState.Focused)
|
||||
self._update_state(atv.keyboard.text_focus_state == KeyboardFocusState.Focused)
|
||||
|
||||
@callback
|
||||
def async_device_disconnected(self) -> None:
|
||||
@@ -78,7 +78,7 @@ class AppleTVKeyboardFocused(AppleTVEntity, BinarySensorEntity, KeyboardListener
|
||||
|
||||
This is a callback function from pyatv.interface.KeyboardListener.
|
||||
"""
|
||||
self._update_state(new_state is KeyboardFocusState.Focused)
|
||||
self._update_state(new_state == KeyboardFocusState.Focused)
|
||||
|
||||
def _update_state(self, new_state: bool) -> None:
|
||||
"""Update and report."""
|
||||
|
||||
@@ -354,7 +354,7 @@ class AppleTVConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"name": self.atv.name,
|
||||
"type": (
|
||||
dev_info.raw_model
|
||||
if dev_info.model is DeviceModel.Unknown and dev_info.raw_model
|
||||
if dev_info.model == DeviceModel.Unknown and dev_info.raw_model
|
||||
else model_str(dev_info.model)
|
||||
),
|
||||
}
|
||||
@@ -441,12 +441,12 @@ class AppleTVConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
return await self.async_step_password()
|
||||
|
||||
# Figure out, depending on protocol, what kind of pairing is needed
|
||||
if service.pairing is PairingRequirement.Unsupported:
|
||||
if service.pairing == PairingRequirement.Unsupported:
|
||||
_LOGGER.debug("%s does not support pairing", self.protocol)
|
||||
return await self.async_pair_next_protocol()
|
||||
if service.pairing is PairingRequirement.Disabled:
|
||||
if service.pairing == PairingRequirement.Disabled:
|
||||
return await self.async_step_protocol_disabled()
|
||||
if service.pairing is PairingRequirement.NotNeeded:
|
||||
if service.pairing == PairingRequirement.NotNeeded:
|
||||
_LOGGER.debug("%s does not require pairing", self.protocol)
|
||||
self.credentials[self.protocol.value] = None
|
||||
return await self.async_pair_next_protocol()
|
||||
@@ -457,7 +457,7 @@ class AppleTVConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
pair_args: dict[str, Any] = {}
|
||||
if self.protocol in {Protocol.AirPlay, Protocol.Companion, Protocol.DMAP}:
|
||||
pair_args["name"] = "Home Assistant"
|
||||
if self.protocol is Protocol.DMAP:
|
||||
if self.protocol == Protocol.DMAP:
|
||||
pair_args["zeroconf"] = await zeroconf.async_get_instance(self.hass)
|
||||
|
||||
# Initiate the pairing process
|
||||
|
||||
@@ -24,7 +24,6 @@ from pyatv.interface import (
|
||||
PushListener,
|
||||
PushUpdater,
|
||||
)
|
||||
from yarl import URL
|
||||
|
||||
from homeassistant.components import media_source
|
||||
from homeassistant.components.media_player import (
|
||||
@@ -38,13 +37,11 @@ from homeassistant.components.media_player import (
|
||||
)
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from . import AppleTvConfigEntry, AppleTVManager
|
||||
from .browse_media import build_app_list
|
||||
from .const import DOMAIN
|
||||
from .entity import AppleTVEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -128,6 +125,7 @@ class AppleTvMediaPlayer(
|
||||
@callback
|
||||
def async_device_connected(self, atv: AppleTV) -> None:
|
||||
"""Handle when connection is made to device."""
|
||||
# NB: Do not use _is_feature_available here as it only works when playing
|
||||
if atv.features.in_state(FeatureState.Available, FeatureName.PushUpdates):
|
||||
atv.push_updater.listener = self
|
||||
atv.push_updater.start()
|
||||
@@ -141,7 +139,7 @@ class AppleTvMediaPlayer(
|
||||
all_features = atv.features.all_features()
|
||||
for feature_name, support_flag in SUPPORT_FEATURE_MAPPING.items():
|
||||
feature_info = all_features.get(feature_name)
|
||||
if feature_info and feature_info.state is not FeatureState.Unsupported:
|
||||
if feature_info and feature_info.state != FeatureState.Unsupported:
|
||||
self._attr_supported_features |= support_flag
|
||||
|
||||
# No need to schedule state update here as that will happen when the first
|
||||
@@ -190,14 +188,14 @@ class AppleTvMediaPlayer(
|
||||
return MediaPlayerState.OFF
|
||||
if (
|
||||
self._is_feature_available(FeatureName.PowerState)
|
||||
and self.atv.power.power_state is PowerState.Off
|
||||
and self.atv.power.power_state == PowerState.Off
|
||||
):
|
||||
return MediaPlayerState.OFF
|
||||
if self._playing:
|
||||
state = self._playing.device_state
|
||||
if state in (DeviceState.Idle, DeviceState.Loading):
|
||||
return MediaPlayerState.IDLE
|
||||
if state is DeviceState.Playing:
|
||||
if state == DeviceState.Playing:
|
||||
return MediaPlayerState.PLAYING
|
||||
if state in (DeviceState.Paused, DeviceState.Seeking, DeviceState.Stopped):
|
||||
return MediaPlayerState.PAUSED
|
||||
@@ -347,47 +345,19 @@ class AppleTvMediaPlayer(
|
||||
play_item = await media_source.async_resolve_media(
|
||||
self.hass, media_id, self.entity_id
|
||||
)
|
||||
if play_item.path and self._is_feature_available(FeatureName.StreamFile):
|
||||
media_id = str(play_item.path)
|
||||
else:
|
||||
media_id = async_process_play_media_url(self.hass, play_item.url)
|
||||
media_id = async_process_play_media_url(self.hass, play_item.url)
|
||||
media_type = MediaType.MUSIC
|
||||
|
||||
use_stream_file = self._is_feature_available(FeatureName.StreamFile) and (
|
||||
if self._is_feature_available(FeatureName.StreamFile) and (
|
||||
media_type == MediaType.MUSIC or await is_streamable(media_id)
|
||||
)
|
||||
|
||||
try:
|
||||
if use_stream_file:
|
||||
_LOGGER.debug("Streaming %s via RAOP", media_id)
|
||||
await self.atv.stream.stream_file(media_id)
|
||||
elif self._is_feature_available(FeatureName.PlayUrl) and (
|
||||
(parsed_url := URL(media_id)).is_absolute() and parsed_url.host
|
||||
):
|
||||
_LOGGER.debug("Playing %s via AirPlay", media_id)
|
||||
await self.atv.stream.play_url(media_id)
|
||||
else:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="streaming_not_supported",
|
||||
)
|
||||
except exceptions.NotSupportedError as ex:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="streaming_not_supported",
|
||||
) from ex
|
||||
except (
|
||||
exceptions.BlockedStateError,
|
||||
exceptions.ConnectionLostError,
|
||||
exceptions.InvalidStateError,
|
||||
exceptions.OperationTimeoutError,
|
||||
exceptions.PlaybackError,
|
||||
exceptions.ProtocolError,
|
||||
) as ex:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="stream_failed",
|
||||
) from ex
|
||||
):
|
||||
_LOGGER.debug("Streaming %s via RAOP", media_id)
|
||||
await self.atv.stream.stream_file(media_id)
|
||||
elif self._is_feature_available(FeatureName.PlayUrl):
|
||||
_LOGGER.debug("Playing %s via AirPlay", media_id)
|
||||
await self.atv.stream.play_url(media_id)
|
||||
else:
|
||||
_LOGGER.error("Media streaming is not possible with current configuration")
|
||||
|
||||
@property
|
||||
def media_image_hash(self) -> str | None:
|
||||
@@ -476,12 +446,12 @@ class AppleTvMediaPlayer(
|
||||
def shuffle(self) -> bool | None:
|
||||
"""Boolean if shuffle is enabled."""
|
||||
if self._playing and self._is_feature_available(FeatureName.Shuffle):
|
||||
return self._playing.shuffle is not ShuffleState.Off
|
||||
return self._playing.shuffle != ShuffleState.Off
|
||||
return None
|
||||
|
||||
def _is_feature_available(self, feature: FeatureName) -> bool:
|
||||
"""Return if a feature is available."""
|
||||
if self.atv:
|
||||
if self.atv and self._playing:
|
||||
return self.atv.features.in_state(FeatureState.Available, feature)
|
||||
return False
|
||||
|
||||
@@ -536,7 +506,7 @@ class AppleTvMediaPlayer(
|
||||
and (self._is_feature_available(FeatureName.TurnOff))
|
||||
and (
|
||||
not self._is_feature_available(FeatureName.PowerState)
|
||||
or self.atv.power.power_state is PowerState.On
|
||||
or self.atv.power.power_state == PowerState.On
|
||||
)
|
||||
):
|
||||
await self.atv.power.turn_off()
|
||||
|
||||
@@ -59,7 +59,7 @@ def _check_keyboard_focus(atv: AppleTVInterface) -> None:
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="keyboard_not_available",
|
||||
) from err
|
||||
if focus_state is not KeyboardFocusState.Focused:
|
||||
if focus_state != KeyboardFocusState.Focused:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="keyboard_not_focused",
|
||||
|
||||
@@ -81,12 +81,6 @@
|
||||
},
|
||||
"not_connected": {
|
||||
"message": "Apple TV is not connected"
|
||||
},
|
||||
"stream_failed": {
|
||||
"message": "Failed to stream media to the Apple TV"
|
||||
},
|
||||
"streaming_not_supported": {
|
||||
"message": "Streaming the requested media is not supported"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["apprise"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["apprise==1.11.0"]
|
||||
"requirements": ["apprise==1.9.1"]
|
||||
}
|
||||
|
||||
@@ -193,11 +193,7 @@ async def async_setup_entry(
|
||||
Aranet4BluetoothSensorEntity, async_add_entities
|
||||
)
|
||||
)
|
||||
entry.async_on_unload(
|
||||
entry.runtime_data.async_register_processor(
|
||||
processor, AranetSensorEntityDescription
|
||||
)
|
||||
)
|
||||
entry.async_on_unload(entry.runtime_data.async_register_processor(processor))
|
||||
|
||||
|
||||
class Aranet4BluetoothSensorEntity(
|
||||
|
||||
@@ -16,13 +16,6 @@ from homeassistant.helpers.service_info.ssdp import ATTR_UPNP_UDN, SsdpServiceIn
|
||||
|
||||
from .const import DEFAULT_NAME, DEFAULT_PORT, DOMAIN
|
||||
|
||||
STEP_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST): str,
|
||||
vol.Required(CONF_PORT, default=DEFAULT_PORT): int,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class ArcamFmjFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle config flow."""
|
||||
@@ -38,22 +31,13 @@ class ArcamFmjFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
await self.async_set_unique_id(uuid)
|
||||
self._abort_if_unique_id_configured({CONF_HOST: host, CONF_PORT: port})
|
||||
|
||||
async def _async_try_connect(self, host: str, port: int) -> dict[str, str]:
|
||||
"""Verify the device is reachable; return errors keyed by reason."""
|
||||
async def _async_try_connect(self, host: str, port: int) -> None:
|
||||
"""Verify the device is reachable."""
|
||||
client = Client(host, port)
|
||||
try:
|
||||
await client.start()
|
||||
except socket.gaierror:
|
||||
return {"base": "invalid_host"}
|
||||
except TimeoutError:
|
||||
return {"base": "timeout_connect"}
|
||||
except ConnectionRefusedError:
|
||||
return {"base": "connection_refused"}
|
||||
except ConnectionFailed, OSError:
|
||||
return {"base": "cannot_connect"}
|
||||
finally:
|
||||
await client.stop()
|
||||
return {}
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -69,10 +53,19 @@ class ArcamFmjFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
user_input[CONF_HOST], user_input[CONF_PORT], uuid
|
||||
)
|
||||
|
||||
errors = await self._async_try_connect(
|
||||
user_input[CONF_HOST], user_input[CONF_PORT]
|
||||
)
|
||||
if not errors:
|
||||
try:
|
||||
await self._async_try_connect(
|
||||
user_input[CONF_HOST], user_input[CONF_PORT]
|
||||
)
|
||||
except socket.gaierror:
|
||||
errors["base"] = "invalid_host"
|
||||
except TimeoutError:
|
||||
errors["base"] = "timeout_connect"
|
||||
except ConnectionRefusedError:
|
||||
errors["base"] = "connection_refused"
|
||||
except ConnectionFailed, OSError:
|
||||
errors["base"] = "cannot_connect"
|
||||
else:
|
||||
return self.async_create_entry(
|
||||
title=f"{DEFAULT_NAME} ({user_input[CONF_HOST]})",
|
||||
data={
|
||||
@@ -81,46 +74,16 @@ class ArcamFmjFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
},
|
||||
)
|
||||
|
||||
schema = STEP_DATA_SCHEMA
|
||||
fields = {
|
||||
vol.Required(CONF_HOST): str,
|
||||
vol.Required(CONF_PORT, default=DEFAULT_PORT): int,
|
||||
}
|
||||
schema = vol.Schema(fields)
|
||||
if user_input is not None:
|
||||
schema = self.add_suggested_values_to_schema(schema, user_input)
|
||||
|
||||
return self.async_show_form(step_id="user", data_schema=schema, errors=errors)
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reconfiguration of an existing entry."""
|
||||
errors: dict[str, str] = {}
|
||||
reconfigure_entry = self._get_reconfigure_entry()
|
||||
if user_input is not None:
|
||||
uuid = await get_uniqueid_from_host(
|
||||
async_get_clientsession(self.hass), user_input[CONF_HOST]
|
||||
)
|
||||
if uuid:
|
||||
await self.async_set_unique_id(uuid)
|
||||
self._abort_if_unique_id_mismatch()
|
||||
|
||||
errors = await self._async_try_connect(
|
||||
user_input[CONF_HOST], user_input[CONF_PORT]
|
||||
)
|
||||
if not errors:
|
||||
return self.async_update_reload_and_abort(
|
||||
reconfigure_entry,
|
||||
data_updates={
|
||||
CONF_HOST: user_input[CONF_HOST],
|
||||
CONF_PORT: user_input[CONF_PORT],
|
||||
},
|
||||
)
|
||||
|
||||
schema = self.add_suggested_values_to_schema(
|
||||
STEP_DATA_SCHEMA, user_input or reconfigure_entry.data
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure", data_schema=schema, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
@@ -150,7 +113,9 @@ class ArcamFmjFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
await self._async_set_unique_id_and_update(host, port, uuid)
|
||||
|
||||
if await self._async_try_connect(host, port):
|
||||
try:
|
||||
await self._async_try_connect(host, port)
|
||||
except ConnectionFailed, OSError:
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
|
||||
self.host = host
|
||||
|
||||
@@ -263,9 +263,9 @@ class ArcamFmj(ArcamFmjEntity, MediaPlayerEntity):
|
||||
def media_channel(self) -> str | None:
|
||||
"""Channel currently playing."""
|
||||
source = self._state.get_source()
|
||||
if source is SourceCodes.DAB:
|
||||
if source == SourceCodes.DAB:
|
||||
value = self._state.get_dab_station()
|
||||
elif source is SourceCodes.FM:
|
||||
elif source == SourceCodes.FM:
|
||||
value = self._state.get_rds_information()
|
||||
else:
|
||||
value = None
|
||||
@@ -274,7 +274,7 @@ class ArcamFmj(ArcamFmjEntity, MediaPlayerEntity):
|
||||
@property
|
||||
def media_artist(self) -> str | None:
|
||||
"""Artist of current playing media, music track only."""
|
||||
if self._state.get_source() is SourceCodes.DAB:
|
||||
if self._state.get_source() == SourceCodes.DAB:
|
||||
value = self._state.get_dls_pdt()
|
||||
else:
|
||||
value = None
|
||||
|
||||
@@ -3,9 +3,7 @@
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||
"unique_id_mismatch": "Please ensure you reconfigure against the same device."
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
@@ -18,13 +16,6 @@
|
||||
"confirm": {
|
||||
"description": "Do you want to add Arcam FMJ on `{host}` to Home Assistant?"
|
||||
},
|
||||
"reconfigure": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"port": "[%key:common::config_flow::data::port%]"
|
||||
},
|
||||
"description": "[%key:component::arcam_fmj::config::step::user::description%]"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
"codeowners": [],
|
||||
"dependencies": ["mqtt"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/arwn",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["arwn-client==0.2.1"]
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "legacy"
|
||||
}
|
||||
|
||||
@@ -3,26 +3,113 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from arwn_client import parse_message
|
||||
|
||||
from homeassistant.components import mqtt
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import DEGREE, UnitOfPrecipitationDepth, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
from homeassistant.util import slugify
|
||||
from homeassistant.util.json import json_loads_object
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DOMAIN = "arwn"
|
||||
|
||||
DATA_ARWN = "arwn"
|
||||
TOPIC = "arwn/#"
|
||||
|
||||
|
||||
def discover_sensors(topic: str, payload: dict[str, Any]) -> list[ArwnSensor] | None:
|
||||
"""Given a topic, dynamically create the right sensor type.
|
||||
|
||||
Async friendly.
|
||||
"""
|
||||
parts = topic.split("/")
|
||||
unit = payload.get("units", "")
|
||||
domain = parts[1]
|
||||
if domain == "temperature":
|
||||
name = parts[2]
|
||||
if unit == "F":
|
||||
unit = UnitOfTemperature.FAHRENHEIT
|
||||
else:
|
||||
unit = UnitOfTemperature.CELSIUS
|
||||
return [
|
||||
ArwnSensor(
|
||||
topic, name, "temp", unit, device_class=SensorDeviceClass.TEMPERATURE
|
||||
)
|
||||
]
|
||||
if domain == "moisture":
|
||||
name = f"{parts[2]} Moisture"
|
||||
return [ArwnSensor(topic, name, "moisture", unit, "mdi:water-percent")]
|
||||
if domain == "rain":
|
||||
if len(parts) >= 3 and parts[2] == "today":
|
||||
return [
|
||||
ArwnSensor(
|
||||
topic,
|
||||
"Rain Since Midnight",
|
||||
"since_midnight",
|
||||
UnitOfPrecipitationDepth.INCHES,
|
||||
device_class=SensorDeviceClass.PRECIPITATION,
|
||||
)
|
||||
]
|
||||
return [
|
||||
ArwnSensor(
|
||||
topic + "/total",
|
||||
"Total Rainfall",
|
||||
"total",
|
||||
unit,
|
||||
device_class=SensorDeviceClass.PRECIPITATION,
|
||||
),
|
||||
ArwnSensor(
|
||||
topic + "/rate",
|
||||
"Rainfall Rate",
|
||||
"rate",
|
||||
unit,
|
||||
device_class=SensorDeviceClass.PRECIPITATION,
|
||||
),
|
||||
]
|
||||
if domain == "barometer":
|
||||
return [
|
||||
ArwnSensor(topic, "Barometer", "pressure", unit, "mdi:thermometer-lines")
|
||||
]
|
||||
if domain == "wind":
|
||||
return [
|
||||
ArwnSensor(
|
||||
topic + "/speed",
|
||||
"Wind Speed",
|
||||
"speed",
|
||||
unit,
|
||||
device_class=SensorDeviceClass.WIND_SPEED,
|
||||
),
|
||||
ArwnSensor(
|
||||
topic + "/gust",
|
||||
"Wind Gust",
|
||||
"gust",
|
||||
unit,
|
||||
device_class=SensorDeviceClass.WIND_SPEED,
|
||||
),
|
||||
ArwnSensor(
|
||||
topic + "/dir",
|
||||
"Wind Direction",
|
||||
"direction",
|
||||
DEGREE,
|
||||
"mdi:compass",
|
||||
device_class=SensorDeviceClass.WIND_DIRECTION,
|
||||
state_class=SensorStateClass.MEASUREMENT_ANGLE,
|
||||
),
|
||||
]
|
||||
return None
|
||||
|
||||
|
||||
def _slug(name: str) -> str:
|
||||
return f"sensor.arwn_{slugify(name)}"
|
||||
|
||||
|
||||
async def async_setup_platform(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
@@ -31,25 +118,28 @@ async def async_setup_platform(
|
||||
) -> None:
|
||||
"""Set up the ARWN platform."""
|
||||
|
||||
# Make sure MQTT integration is enabled and the client is available
|
||||
if not await mqtt.async_wait_for_mqtt_client(hass):
|
||||
_LOGGER.error("MQTT integration is not available")
|
||||
return
|
||||
|
||||
@callback
|
||||
def async_sensor_event_received(msg: mqtt.ReceiveMessage) -> None:
|
||||
"""Process MQTT events as sensors."""
|
||||
try:
|
||||
event = json_loads_object(msg.payload)
|
||||
device = parse_message(msg.topic, event)
|
||||
except Exception: # noqa: BLE001
|
||||
_LOGGER.debug(
|
||||
"Failed to parse ARWN message on topic %s",
|
||||
msg.topic,
|
||||
exc_info=True,
|
||||
)
|
||||
return
|
||||
"""Process events as sensors.
|
||||
|
||||
if device is None:
|
||||
When a new event on our topic (arwn/#) is received we map it
|
||||
into a known kind of sensor based on topic name. If we've
|
||||
never seen this before, we keep this sensor around in a global
|
||||
cache. If we have seen it before, we update the values of the
|
||||
existing sensor. Either way, we push an ha state update at the
|
||||
end for the new event we've seen.
|
||||
|
||||
This lets us dynamically incorporate sensors without any
|
||||
configuration on our side.
|
||||
"""
|
||||
event = json_loads_object(msg.payload)
|
||||
sensors = discover_sensors(msg.topic, event)
|
||||
if not sensors:
|
||||
return
|
||||
|
||||
if (store := hass.data.get(DATA_ARWN)) is None:
|
||||
@@ -58,71 +148,22 @@ async def async_setup_platform(
|
||||
if "timestamp" in event:
|
||||
del event["timestamp"]
|
||||
|
||||
new_sensors: list[ArwnSensor] = []
|
||||
for reading in device.readings:
|
||||
if not reading.expose:
|
||||
continue
|
||||
|
||||
unique_id = (
|
||||
f"{msg.topic}/{reading.sensor_key}"
|
||||
if len(device.readings) > 1
|
||||
else msg.topic
|
||||
)
|
||||
|
||||
try:
|
||||
device_class = (
|
||||
SensorDeviceClass(reading.device_class)
|
||||
if reading.device_class
|
||||
else None
|
||||
)
|
||||
except ValueError:
|
||||
_LOGGER.debug(
|
||||
"Unknown device_class=%s for sensor %s",
|
||||
reading.device_class,
|
||||
reading.sensor_name,
|
||||
)
|
||||
device_class = None
|
||||
|
||||
try:
|
||||
state_class = (
|
||||
SensorStateClass(reading.state_class)
|
||||
if reading.state_class
|
||||
else None
|
||||
)
|
||||
except ValueError:
|
||||
_LOGGER.debug(
|
||||
"Unknown state_class=%s for sensor %s",
|
||||
reading.state_class,
|
||||
reading.sensor_name,
|
||||
)
|
||||
state_class = None
|
||||
|
||||
if unique_id not in store:
|
||||
sensor = ArwnSensor(
|
||||
unique_id=unique_id,
|
||||
name=reading.sensor_name,
|
||||
state_key=reading.sensor_key,
|
||||
units=reading.unit,
|
||||
icon=reading.icon,
|
||||
device_class=device_class,
|
||||
state_class=state_class,
|
||||
event=event,
|
||||
)
|
||||
store[unique_id] = sensor
|
||||
for sensor in sensors:
|
||||
if sensor.name not in store:
|
||||
sensor.hass = hass
|
||||
sensor.set_event(event)
|
||||
store[sensor.name] = sensor
|
||||
_LOGGER.debug(
|
||||
"Registering sensor %(name)s => %(event)s",
|
||||
{"name": reading.sensor_name, "event": event},
|
||||
{"name": sensor.name, "event": event},
|
||||
)
|
||||
new_sensors.append(sensor)
|
||||
async_add_entities((sensor,), True)
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"Recording sensor %(name)s => %(event)s",
|
||||
{"name": reading.sensor_name, "event": event},
|
||||
{"name": sensor.name, "event": event},
|
||||
)
|
||||
store[unique_id].set_event(event)
|
||||
|
||||
if new_sensors:
|
||||
async_add_entities(new_sensors, True)
|
||||
store[sensor.name].set_event(event)
|
||||
|
||||
await mqtt.async_subscribe(hass, TOPIC, async_sensor_event_received, 0)
|
||||
|
||||
@@ -134,29 +175,29 @@ class ArwnSensor(SensorEntity):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
unique_id: str,
|
||||
topic: str,
|
||||
name: str,
|
||||
state_key: str,
|
||||
units: str,
|
||||
icon: str | None = None,
|
||||
device_class: SensorDeviceClass | None = None,
|
||||
state_class: SensorStateClass | None = None,
|
||||
event: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
self.entity_id = _slug(name)
|
||||
self._attr_name = name
|
||||
self._attr_unique_id = unique_id
|
||||
# This mqtt topic for the sensor which is its uid
|
||||
self._attr_unique_id = topic
|
||||
self._state_key = state_key
|
||||
self._attr_native_unit_of_measurement = units
|
||||
self._attr_icon = icon
|
||||
self._attr_device_class = device_class
|
||||
self._attr_state_class = state_class
|
||||
if event is not None:
|
||||
self._attr_extra_state_attributes = dict(event)
|
||||
self._attr_native_value = event.get(state_key)
|
||||
|
||||
def set_event(self, event: dict[str, Any]) -> None:
|
||||
"""Update the sensor with the most recent event."""
|
||||
self._attr_extra_state_attributes = dict(event)
|
||||
self._attr_native_value = event.get(self._state_key)
|
||||
ev: dict[str, Any] = {}
|
||||
ev.update(event)
|
||||
self._attr_extra_state_attributes = ev
|
||||
self._attr_native_value = ev.get(self._state_key)
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -1355,7 +1355,7 @@ class PipelineRun:
|
||||
) -> bool:
|
||||
"""Return true if all targeted entities were in the same area as the device."""
|
||||
if (
|
||||
intent_response.response_type is not intent.IntentResponseType.ACTION_DONE
|
||||
intent_response.response_type != intent.IntentResponseType.ACTION_DONE
|
||||
or not intent_response.matched_states
|
||||
):
|
||||
return False
|
||||
|
||||
@@ -5,8 +5,6 @@ import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from hassil.parse_expression import parse_sentence
|
||||
from hassil.parser import ParseError
|
||||
from hassil.util import (
|
||||
PUNCTUATION_END,
|
||||
PUNCTUATION_END_WORD,
|
||||
@@ -166,7 +164,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
[cv.string],
|
||||
has_one_non_empty_item,
|
||||
has_no_punctuation,
|
||||
is_valid_sentence,
|
||||
),
|
||||
}
|
||||
],
|
||||
@@ -215,17 +212,6 @@ def has_no_punctuation(value: list[str]) -> list[str]:
|
||||
return value
|
||||
|
||||
|
||||
def is_valid_sentence(value: list[str]) -> list[str]:
|
||||
"""Validate result can be parsed by hassil."""
|
||||
for sentence in value:
|
||||
try:
|
||||
parse_sentence(sentence)
|
||||
except ParseError as err:
|
||||
raise vol.Invalid(f"invalid sentence: {err}") from err
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def has_one_non_empty_item(value: list[str]) -> list[str]:
|
||||
"""Validate result has at least one item."""
|
||||
if len(value) < 1:
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/assist_satellite",
|
||||
"integration_type": "entity",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["hassil==3.6.0"]
|
||||
"requirements": ["hassil==3.5.0"]
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
fields:
|
||||
behavior:
|
||||
required: true
|
||||
default: each
|
||||
default: any
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -49,20 +49,6 @@ SENSORS_TYPE_COUNT = "sensors_count"
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_ENTITY_MIGRATION_ID = {
|
||||
"sensor_connected_device": "Devices Connected",
|
||||
"sensor_rx_bytes": "Download",
|
||||
"sensor_tx_bytes": "Upload",
|
||||
"sensor_rx_rates": "Download Speed",
|
||||
"sensor_tx_rates": "Upload Speed",
|
||||
"sensor_load_avg1": "Load Avg (1m)",
|
||||
"sensor_load_avg5": "Load Avg (5m)",
|
||||
"sensor_load_avg15": "Load Avg (15m)",
|
||||
"2.4GHz": "2.4GHz Temperature",
|
||||
"5.0GHz": "5GHz Temperature",
|
||||
"CPU": "CPU Temperature",
|
||||
}
|
||||
|
||||
|
||||
class AsusWrtSensorDataHandler:
|
||||
"""Data handler for AsusWrt sensor."""
|
||||
@@ -201,6 +187,20 @@ class AsusWrtRouter:
|
||||
|
||||
def _migrate_entities_unique_id(self) -> None:
|
||||
"""Migrate router entities to new unique id format."""
|
||||
_ENTITY_MIGRATION_ID = {
|
||||
"sensor_connected_device": "Devices Connected",
|
||||
"sensor_rx_bytes": "Download",
|
||||
"sensor_tx_bytes": "Upload",
|
||||
"sensor_rx_rates": "Download Speed",
|
||||
"sensor_tx_rates": "Upload Speed",
|
||||
"sensor_load_avg1": "Load Avg (1m)",
|
||||
"sensor_load_avg5": "Load Avg (5m)",
|
||||
"sensor_load_avg15": "Load Avg (15m)",
|
||||
"2.4GHz": "2.4GHz Temperature",
|
||||
"5.0GHz": "5GHz Temperature",
|
||||
"CPU": "CPU Temperature",
|
||||
}
|
||||
|
||||
entity_reg = er.async_get(self.hass)
|
||||
router_entries = er.async_entries_for_config_entry(
|
||||
entity_reg, self._entry.entry_id
|
||||
|
||||
@@ -30,5 +30,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["pubnub", "yalexs"],
|
||||
"requirements": ["yalexs==9.2.7", "yalexs-ble==3.3.0"]
|
||||
"requirements": ["yalexs==9.2.0", "yalexs-ble==3.3.0"]
|
||||
}
|
||||
|
||||
@@ -9,11 +9,12 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.components import usb
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import ATTR_MODEL, ATTR_SERIAL_NUMBER, CONF_ADDRESS, CONF_PORT
|
||||
from homeassistant.const import ATTR_SERIAL_NUMBER, CONF_ADDRESS, CONF_PORT
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import (
|
||||
ATTR_FIRMWARE,
|
||||
ATTR_MODEL,
|
||||
DEFAULT_ADDRESS,
|
||||
DEFAULT_INTEGRATION_TITLE,
|
||||
DOMAIN,
|
||||
|
||||
@@ -19,4 +19,8 @@ DEVICES = "devices"
|
||||
MANUFACTURER = "ABB"
|
||||
|
||||
ATTR_DEVICE_NAME = "device_name"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
ATTR_DEVICE_ID = "device_id"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
ATTR_MODEL = "model"
|
||||
ATTR_FIRMWARE = "firmware"
|
||||
|
||||
@@ -13,7 +13,6 @@ from homeassistant.components.sensor import (
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
ATTR_MODEL,
|
||||
ATTR_SERIAL_NUMBER,
|
||||
EntityCategory,
|
||||
UnitOfElectricCurrent,
|
||||
@@ -32,6 +31,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from .const import (
|
||||
ATTR_DEVICE_NAME,
|
||||
ATTR_FIRMWARE,
|
||||
ATTR_MODEL,
|
||||
DEFAULT_DEVICE_NAME,
|
||||
DOMAIN,
|
||||
MANUFACTURER,
|
||||
|
||||
@@ -251,12 +251,12 @@ class AuthProvidersView(HomeAssistantView):
|
||||
|
||||
def _prepare_result_json(result: AuthFlowResult) -> dict[str, Any]:
|
||||
"""Convert result to JSON serializable dict."""
|
||||
if result["type"] is data_entry_flow.FlowResultType.CREATE_ENTRY:
|
||||
if result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY:
|
||||
return {
|
||||
key: val for key, val in result.items() if key not in ("result", "data")
|
||||
}
|
||||
|
||||
if result["type"] is not data_entry_flow.FlowResultType.FORM:
|
||||
if result["type"] != data_entry_flow.FlowResultType.FORM:
|
||||
return result # type: ignore[return-value]
|
||||
|
||||
data = dict(result)
|
||||
@@ -289,11 +289,11 @@ class LoginFlowBaseView(HomeAssistantView):
|
||||
result: AuthFlowResult,
|
||||
) -> web.Response:
|
||||
"""Convert the flow result to a response."""
|
||||
if result["type"] is not data_entry_flow.FlowResultType.CREATE_ENTRY:
|
||||
if result["type"] != data_entry_flow.FlowResultType.CREATE_ENTRY:
|
||||
# @log_invalid_auth does not work here since it returns HTTP 200.
|
||||
# We need to manually log failed login attempts.
|
||||
if (
|
||||
result["type"] is data_entry_flow.FlowResultType.FORM
|
||||
result["type"] == data_entry_flow.FlowResultType.FORM
|
||||
and (errors := result.get("errors"))
|
||||
and errors.get("base")
|
||||
in (
|
||||
|
||||
@@ -142,9 +142,9 @@ def websocket_depose_mfa(
|
||||
|
||||
def _prepare_result_json(result: data_entry_flow.FlowResult) -> dict[str, Any]:
|
||||
"""Convert result to JSON serializable dict."""
|
||||
if result["type"] is data_entry_flow.FlowResultType.CREATE_ENTRY:
|
||||
if result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY:
|
||||
return dict(result)
|
||||
if result["type"] is not data_entry_flow.FlowResultType.FORM:
|
||||
if result["type"] != data_entry_flow.FlowResultType.FORM:
|
||||
return result # type: ignore[return-value]
|
||||
|
||||
data = dict(result)
|
||||
|
||||
@@ -2,18 +2,12 @@
|
||||
|
||||
import avea
|
||||
|
||||
from homeassistant.components.bluetooth import (
|
||||
BluetoothReachabilityIntent,
|
||||
async_address_reachability_diagnostics,
|
||||
async_ble_device_from_address,
|
||||
)
|
||||
from homeassistant.components.bluetooth import async_ble_device_from_address
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_ADDRESS, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
type AveaConfigEntry = ConfigEntry[avea.Bulb]
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.LIGHT]
|
||||
@@ -21,20 +15,12 @@ PLATFORMS: list[Platform] = [Platform.LIGHT]
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: AveaConfigEntry) -> bool:
|
||||
"""Set up Avea from a config entry."""
|
||||
address = entry.data[CONF_ADDRESS]
|
||||
ble_device = async_ble_device_from_address(hass, address, connectable=True)
|
||||
ble_device = async_ble_device_from_address(
|
||||
hass, entry.data[CONF_ADDRESS], connectable=True
|
||||
)
|
||||
if not ble_device:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="device_not_found",
|
||||
translation_placeholders={
|
||||
"address": address,
|
||||
"reason": async_address_reachability_diagnostics(
|
||||
hass,
|
||||
address.upper(),
|
||||
BluetoothReachabilityIntent.CONNECTION,
|
||||
),
|
||||
},
|
||||
f"Could not find Avea device with address {entry.data[CONF_ADDRESS]}"
|
||||
)
|
||||
|
||||
entry.runtime_data = avea.Bulb(ble_device)
|
||||
|
||||
@@ -8,7 +8,6 @@ import avea
|
||||
from bleak.exc import BleakError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import bluetooth
|
||||
from homeassistant.components.bluetooth import (
|
||||
BluetoothServiceInfoBleak,
|
||||
async_discovered_service_info,
|
||||
@@ -67,15 +66,6 @@ def _is_avea_discovery(discovery_info: BluetoothServiceInfoBleak) -> bool:
|
||||
return AVEA_SERVICE_UUID in discovery_info.service_uuids
|
||||
|
||||
|
||||
def _discovery_label(discovery_info: BluetoothServiceInfoBleak) -> str:
|
||||
"""Return a label for a discovered Avea bulb."""
|
||||
if (
|
||||
name := _normalize_name(discovery_info.name)
|
||||
) and name != discovery_info.address:
|
||||
return f"{name} ({discovery_info.address})"
|
||||
return discovery_info.address
|
||||
|
||||
|
||||
class AveaConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Avea."""
|
||||
|
||||
@@ -160,7 +150,6 @@ class AveaConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
if discovery := self._discovery_info:
|
||||
self._discovered_devices[discovery.address] = discovery
|
||||
else:
|
||||
await bluetooth.async_request_active_scan(self.hass)
|
||||
current_addresses = self._async_current_ids(include_ignore=False)
|
||||
for discovery in async_discovered_service_info(self.hass):
|
||||
if (
|
||||
@@ -176,10 +165,11 @@ class AveaConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
if self._discovery_info:
|
||||
disc = self._discovery_info
|
||||
label = f"{disc.name or disc.address} ({disc.address})"
|
||||
data_schema = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ADDRESS, default=disc.address): vol.In(
|
||||
{disc.address: _discovery_label(disc)}
|
||||
{disc.address: label}
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -188,7 +178,10 @@ class AveaConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
{
|
||||
vol.Required(CONF_ADDRESS): vol.In(
|
||||
{
|
||||
service_info.address: _discovery_label(service_info)
|
||||
service_info.address: (
|
||||
f"{service_info.name or service_info.address}"
|
||||
f" ({service_info.address})"
|
||||
)
|
||||
for service_info in self._discovered_devices.values()
|
||||
}
|
||||
),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Light platform for Avea."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from contextlib import suppress
|
||||
import logging
|
||||
from typing import Any
|
||||
@@ -20,7 +19,6 @@ from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
from homeassistant.exceptions import PlatformNotReady
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import (
|
||||
AddConfigEntryEntitiesCallback,
|
||||
AddEntitiesCallback,
|
||||
@@ -29,7 +27,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
from homeassistant.util import color as color_util
|
||||
|
||||
from . import AveaConfigEntry
|
||||
from .const import DOMAIN, INTEGRATION_TITLE, MODEL, UNKNOWN_NAME
|
||||
from .const import DOMAIN, INTEGRATION_TITLE, UNKNOWN_NAME
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
UPDATE_EXCEPTIONS = (BleakError, OSError, RuntimeError)
|
||||
@@ -44,13 +42,6 @@ def _normalize_name(name: str | None) -> str | None:
|
||||
return name
|
||||
|
||||
|
||||
def _read_device_info_value(read: Callable[[], str | None]) -> str | None:
|
||||
"""Read a device information value from an Avea bulb."""
|
||||
with suppress(*UPDATE_EXCEPTIONS):
|
||||
return _normalize_name(read())
|
||||
return None
|
||||
|
||||
|
||||
def _ha_brightness_to_avea(brightness: int) -> int:
|
||||
"""Convert Home Assistant brightness to Avea brightness."""
|
||||
return round((brightness / 255) * AVEA_MAX_BRIGHTNESS)
|
||||
@@ -105,8 +96,7 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up the Avea light platform."""
|
||||
async_add_entities(
|
||||
[AveaLight(entry.runtime_data, entry.data[CONF_ADDRESS])],
|
||||
update_before_add=True,
|
||||
[AveaLight(entry.runtime_data, entry.title)], update_before_add=True
|
||||
)
|
||||
|
||||
|
||||
@@ -190,42 +180,14 @@ class AveaLight(LightEntity):
|
||||
"""Representation of an Avea."""
|
||||
|
||||
_attr_color_mode = ColorMode.HS
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
_attr_supported_color_modes = {ColorMode.HS}
|
||||
|
||||
def __init__(self, light: avea.Bulb, address: str) -> None:
|
||||
def __init__(self, light: avea.Bulb, entry_title: str) -> None:
|
||||
"""Initialize an AveaLight."""
|
||||
self._light = light
|
||||
self._attr_unique_id = address
|
||||
self._attr_name = entry_title
|
||||
self._attr_brightness = light.brightness
|
||||
self._last_brightness = 255
|
||||
self._device_info_updated = False
|
||||
self._attr_device_info = DeviceInfo(
|
||||
connections={(CONNECTION_BLUETOOTH, address)},
|
||||
model=MODEL,
|
||||
)
|
||||
|
||||
def _update_device_info(self) -> None:
|
||||
"""Fetch device information from the Avea bulb."""
|
||||
device_info = self._attr_device_info
|
||||
assert device_info is not None
|
||||
|
||||
manufacturer = _read_device_info_value(self._light.get_manufacturer_name)
|
||||
hardware_revision = _read_device_info_value(self._light.get_hardware_revision)
|
||||
firmware_version = _read_device_info_value(self._light.get_fw_version)
|
||||
serial_number = _read_device_info_value(self._light.get_serial_number)
|
||||
|
||||
if manufacturer:
|
||||
device_info["manufacturer"] = manufacturer
|
||||
if hardware_revision:
|
||||
device_info["hw_version"] = hardware_revision
|
||||
if firmware_version:
|
||||
device_info["sw_version"] = firmware_version
|
||||
if serial_number:
|
||||
device_info["serial_number"] = serial_number
|
||||
|
||||
self._device_info_updated = True
|
||||
|
||||
def turn_on(self, **kwargs: Any) -> None:
|
||||
"""Instruct the light to turn on."""
|
||||
@@ -252,8 +214,6 @@ class AveaLight(LightEntity):
|
||||
connected = self._light.connect()
|
||||
|
||||
try:
|
||||
if not self._device_info_updated:
|
||||
self._update_device_info()
|
||||
brightness = self._light.get_brightness()
|
||||
rgb_color = self._light.get_rgb()
|
||||
finally:
|
||||
|
||||
@@ -22,11 +22,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"device_not_found": {
|
||||
"message": "Could not find Avea device with address {address}: {reason}"
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_yaml": {
|
||||
"description": "[%key:component::homeassistant::issues::deprecated_yaml::description%]",
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
"""Virtual integration: Avosdim."""
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"domain": "avosdim",
|
||||
"name": "Avosdim",
|
||||
"integration_type": "virtual",
|
||||
"supported_by": "motion_blinds"
|
||||
}
|
||||
@@ -51,6 +51,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: S3ConfigEntry) -> bool:
|
||||
translation_key="invalid_bucket_name",
|
||||
) from err
|
||||
except ValueError as err:
|
||||
# pylint: disable-next=home-assistant-exception-translation-key-missing
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_endpoint_url",
|
||||
|
||||
@@ -17,11 +17,10 @@ from homeassistant.components.backup import (
|
||||
OnProgressCallback,
|
||||
suggested_filename,
|
||||
)
|
||||
from homeassistant.const import CONF_PREFIX
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
from . import S3ConfigEntry
|
||||
from .const import CONF_BUCKET, DATA_BACKUP_AGENT_LISTENERS, DOMAIN
|
||||
from .const import CONF_BUCKET, CONF_PREFIX, DATA_BACKUP_AGENT_LISTENERS, DOMAIN
|
||||
from .helpers import async_list_backups_from_s3
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -8,7 +8,6 @@ from botocore.exceptions import ClientError, ConnectionError, ParamValidationErr
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_PREFIX
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.selector import (
|
||||
TextSelector,
|
||||
@@ -21,6 +20,7 @@ from .const import (
|
||||
CONF_ACCESS_KEY_ID,
|
||||
CONF_BUCKET,
|
||||
CONF_ENDPOINT_URL,
|
||||
CONF_PREFIX,
|
||||
CONF_SECRET_ACCESS_KEY,
|
||||
DEFAULT_ENDPOINT_URL,
|
||||
DESCRIPTION_AWS_S3_DOCS_URL,
|
||||
|
||||
@@ -11,6 +11,8 @@ CONF_ACCESS_KEY_ID = "access_key_id"
|
||||
CONF_SECRET_ACCESS_KEY = "secret_access_key"
|
||||
CONF_ENDPOINT_URL = "endpoint_url"
|
||||
CONF_BUCKET = "bucket"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_PREFIX = "prefix"
|
||||
|
||||
AWS_DOMAIN = "amazonaws.com"
|
||||
DEFAULT_ENDPOINT_URL = f"https://s3.eu-central-1.{AWS_DOMAIN}/"
|
||||
|
||||
@@ -8,11 +8,10 @@ from aiobotocore.client import AioBaseClient as S3Client
|
||||
from botocore.exceptions import BotoCoreError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_PREFIX
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import CONF_BUCKET, DOMAIN
|
||||
from .const import CONF_BUCKET, CONF_PREFIX, DOMAIN
|
||||
from .helpers import async_list_backups_from_s3
|
||||
|
||||
SCAN_INTERVAL = timedelta(hours=6)
|
||||
|
||||
@@ -5,10 +5,15 @@ from typing import Any
|
||||
|
||||
from homeassistant.components.backup import DATA_MANAGER as BACKUP_DATA_MANAGER
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.const import CONF_PREFIX
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import CONF_ACCESS_KEY_ID, CONF_BUCKET, CONF_SECRET_ACCESS_KEY, DOMAIN
|
||||
from .const import (
|
||||
CONF_ACCESS_KEY_ID,
|
||||
CONF_BUCKET,
|
||||
CONF_PREFIX,
|
||||
CONF_SECRET_ACCESS_KEY,
|
||||
DOMAIN,
|
||||
)
|
||||
from .coordinator import S3ConfigEntry
|
||||
from .helpers import async_list_backups_from_s3
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"cannot_connect": "[%key:component::aws_s3::exceptions::cannot_connect::message%]",
|
||||
"invalid_bucket_name": "[%key:component::aws_s3::exceptions::invalid_bucket_name::message%]",
|
||||
"invalid_credentials": "[%key:component::aws_s3::exceptions::invalid_credentials::message%]",
|
||||
"invalid_endpoint_url": "[%key:component::aws_s3::exceptions::invalid_endpoint_url::message%]"
|
||||
"invalid_endpoint_url": "Invalid endpoint URL. Please make sure it's a valid AWS S3 endpoint URL."
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
@@ -48,9 +48,6 @@
|
||||
},
|
||||
"invalid_credentials": {
|
||||
"message": "Bucket cannot be accessed using provided combination of access key ID and secret access key."
|
||||
},
|
||||
"invalid_endpoint_url": {
|
||||
"message": "Invalid endpoint URL. Please make sure it's a valid AWS S3 endpoint URL."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
|
||||
from collections.abc import Mapping
|
||||
from ipaddress import ip_address
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_IGNORE,
|
||||
SOURCE_REAUTH,
|
||||
SOURCE_RECONFIGURE,
|
||||
ConfigEntry,
|
||||
@@ -49,9 +50,6 @@ from .const import (
|
||||
from .errors import AuthenticationRequired, CannotConnect
|
||||
from .hub import AxisHub, get_axis_api
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import axis
|
||||
|
||||
AXIS_OUI = {"00:40:8c", "ac:cc:8e", "b8:a4:4f", "e8:27:25"}
|
||||
DEFAULT_PORT = 443
|
||||
DEFAULT_PROTOCOL = "https"
|
||||
@@ -96,8 +94,7 @@ class AxisFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
errors["base"] = "cannot_connect"
|
||||
|
||||
else:
|
||||
if (serial := self._get_serial_number(api)) is None:
|
||||
return self.async_abort(reason="no_serial_number")
|
||||
serial = api.vapix.serial_number
|
||||
config = {
|
||||
CONF_PROTOCOL: user_input[CONF_PROTOCOL],
|
||||
CONF_HOST: user_input[CONF_HOST],
|
||||
@@ -142,15 +139,25 @@ class AxisFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
async def _create_entry(self, serial: str) -> ConfigFlowResult:
|
||||
"""Create entry for device.
|
||||
|
||||
Use the discovered device name when available.
|
||||
Generate a name to be used as a prefix for device entities.
|
||||
"""
|
||||
if (title_placeholders := self.context.get("title_placeholders")) is not None:
|
||||
name = title_placeholders[CONF_NAME]
|
||||
else:
|
||||
name = f"{self.config[CONF_MODEL]} - {serial}"
|
||||
model = self.config[CONF_MODEL]
|
||||
same_model = [
|
||||
entry.data[CONF_NAME]
|
||||
for entry in self.hass.config_entries.async_entries(DOMAIN)
|
||||
if entry.source != SOURCE_IGNORE and entry.data[CONF_MODEL] == model
|
||||
]
|
||||
|
||||
name = model
|
||||
for idx in range(len(same_model) + 1):
|
||||
name = f"{model} {idx}"
|
||||
if name not in same_model:
|
||||
break
|
||||
|
||||
self.config[CONF_NAME] = name
|
||||
|
||||
return self.async_create_entry(title=name, data=self.config)
|
||||
title = f"{model} - {serial}"
|
||||
return self.async_create_entry(title=title, data=self.config)
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -262,19 +269,6 @@ class AxisFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
return await self.async_step_user()
|
||||
|
||||
@staticmethod
|
||||
def _get_serial_number(api: axis.AxisDevice) -> str | None:
|
||||
"""Retrieve the device serial number from the Axis API.
|
||||
|
||||
Tries basic_device_info first, then property_handler. Returns None if not found.
|
||||
"""
|
||||
vapix = api.vapix
|
||||
if vapix.basic_device_info.initialized:
|
||||
return vapix.basic_device_info["0"].serial_number
|
||||
if vapix.params.property_handler.initialized:
|
||||
return vapix.params.property_handler["0"].system_serial_number
|
||||
return None
|
||||
|
||||
|
||||
class AxisOptionsFlowHandler(OptionsFlow):
|
||||
"""Handle Axis device options."""
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import axis
|
||||
from axis.errors import Unauthorized
|
||||
from axis.models.mqtt import ClientState, mqtt_json_to_event
|
||||
from axis.interfaces.mqtt import mqtt_json_to_event
|
||||
from axis.models.mqtt import ClientState
|
||||
from axis.stream_manager import Signal, State
|
||||
|
||||
from homeassistant.components import mqtt
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["axis"],
|
||||
"requirements": ["axis==72"],
|
||||
"requirements": ["axis==71"],
|
||||
"ssdp": [
|
||||
{
|
||||
"manufacturer": "AXIS"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user