├── .devcontainer.json ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ └── config.yml ├── dependabot.yml ├── release-drafter.yml ├── stale.yaml └── workflows │ ├── draft-release.yaml │ ├── lint.yml │ ├── pytest.yml │ └── validation.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── .ruff.toml ├── .vscode ├── launch.json └── tasks.json ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── assets └── images │ ├── family-room-device.png │ ├── readme_combined_presence_sources.gif │ ├── temperature-aggregate.png │ └── upstairs-controls.png ├── bandit.yaml ├── config └── configuration.yaml ├── custom_components └── magic_areas │ ├── __init__.py │ ├── base │ ├── __init__.py │ ├── entities.py │ └── magic.py │ ├── binary_sensor │ ├── __init__.py │ ├── base.py │ ├── ble_tracker.py │ ├── presence.py │ └── wasp_in_a_box.py │ ├── config_flow.py │ ├── const.py │ ├── cover.py │ ├── fan.py │ ├── helpers │ ├── __init__.py │ └── area.py │ ├── light.py │ ├── manifest.json │ ├── media_player │ ├── __init__.py │ └── area_aware_media_player.py │ ├── sensor │ ├── __init__.py │ └── base.py │ ├── switch │ ├── __init__.py │ ├── base.py │ ├── climate_control.py │ ├── fan_control.py │ ├── media_player_control.py │ └── presence_hold.py │ ├── threshold.py │ ├── translations │ ├── de.json │ ├── en.json │ ├── es.json │ ├── fr.json │ ├── nl-NL.json │ ├── pt-BR.json │ ├── sv.json │ └── ta.json │ └── util.py ├── hacs.json ├── info.md ├── pylintrc ├── pyproject.toml ├── requirements-dev.txt ├── requirements-test.txt ├── requirements.txt ├── scripts ├── develop ├── lint ├── setup └── test ├── setup.cfg └── tests ├── __init__.py ├── common.py ├── conftest.py ├── const.py ├── mocks.py ├── test_aggregates.py ├── test_area_state.py ├── test_ble_tracker_monitor.py ├── test_climate_control.py ├── test_cover.py ├── test_fan.py ├── test_init.py ├── test_light.py ├── test_media_player.py ├── test_meta_aggregates.py ├── test_meta_area_state.py ├── test_threshold.py └── test_wasp_in_a_box.py /.devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jseidl/hass-magic_areas", 3 | "image": "mcr.microsoft.com/devcontainers/python:3.12", 4 | "postCreateCommand": "scripts/setup", 5 | "forwardPorts": [ 6 | 8123 7 | ], 8 | "portsAttributes": { 9 | "8123": { 10 | "label": "Home Assistant", 11 | "onAutoForward": "notify" 12 | } 13 | }, 14 | "customizations": { 15 | "vscode": { 16 | "extensions": [ 17 | "ms-python.python", 18 | "github.vscode-pull-request-github", 19 | "ryanluker.vscode-coverage-gutters", 20 | "ms-python.vscode-pylance", 21 | "charliermarsh.ruff", 22 | ], 23 | "settings": { 24 | "files.eol": "\n", 25 | "editor.tabSize": 4, 26 | "editor.formatOnPaste": true, 27 | "editor.formatOnSave": true, 28 | "editor.formatOnType": true, 29 | "files.trimTrailingWhitespace": true, 30 | "python.analysis.typeCheckingMode": "basic", 31 | "python.analysis.autoImportCompletions": true, 32 | "python.defaultInterpreterPath": "/usr/local/bin/python", 33 | "python.testing.pytestArgs": [ 34 | "--rootdir", 35 | "${workspaceFolder}/tests" 36 | ], 37 | "python.testing.autoTestDiscoverOnSaveEnabled": true, 38 | "[python]": { 39 | "editor.defaultFormatter": "charliermarsh.ruff" 40 | } 41 | } 42 | } 43 | }, 44 | "remoteUser": "vscode", 45 | "features": {} 46 | } 47 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: 'Bug Report' 2 | description: 'File a bug report.' 3 | title: "[Bug]: " 4 | labels: ["bug", "triage"] 5 | assignees: 6 | - jseidl 7 | body: 8 | - type: markdown 9 | attributes: 10 | value: "Before you open a new issue, search through the existing issues to see if others have had the same problem." 11 | - type: textarea 12 | attributes: 13 | label: "System Health details" 14 | description: "Paste the data from the System Health card in Home Assistant (https://www.home-assistant.io/more-info/system-health#github-issues)" 15 | validations: 16 | required: true 17 | - type: checkboxes 18 | attributes: 19 | label: Checklist 20 | options: 21 | - label: I have enabled debug logging for my installation. 22 | required: true 23 | - label: I have filled out the issue template to the best of my ability. 24 | required: true 25 | - label: This issue only contains 1 issue (if you have multiple issues, open one issue for each issue). 26 | required: true 27 | - label: This issue is not a duplicate issue of any [previous issues](https://github.com/jseidl/hass-magic_areas/issues?q=is%3Aissue+label%3A%22Bug%22+).. 28 | required: true 29 | - type: textarea 30 | attributes: 31 | label: "Describe the issue" 32 | description: "A clear and concise description of what the issue is." 33 | validations: 34 | required: true 35 | - type: textarea 36 | attributes: 37 | label: Reproduction steps 38 | description: "Without steps to reproduce, it will be hard to fix. It is very important that you fill out this part. Issues without it will be closed." 39 | value: | 40 | 1. 41 | 2. 42 | 3. 43 | ... 44 | validations: 45 | required: true 46 | - type: textarea 47 | attributes: 48 | label: "Debug logs" 49 | description: "To enable debug logs check this https://www.home-assistant.io/integrations/logger/, this **needs** to include _everything_ from startup of Home Assistant to the point where you encounter the issue." 50 | render: text 51 | validations: 52 | required: true 53 | - type: textarea 54 | attributes: 55 | label: "Diagnostics dump" 56 | description: "Drag the diagnostics dump file here. (see https://www.home-assistant.io/integrations/diagnostics/ for info)" 57 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: I have a question or need support 4 | url: https://github.com/jseidl/hass-magic_areas/discussions/categories/q-a 5 | about: We use GitHub for tracking bugs, check our Discussions' Q&A area questions and help. 6 | - name: Feature Request 7 | url: https://github.com/jseidl/hass-magic_areas/discussions/categories/ideas-feature-requests 8 | about: Please use our Discussions' Feature Request area for making feature requests. 9 | - name: I'm unsure where to go 10 | url: https://discord.gg/8vxJpJ2vP4 11 | about: If you are unsure where to go, then joining our discord is recommended; Just ask! 12 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 2 | version: 2 3 | updates: 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | 9 | - package-ecosystem: "pip" 10 | directory: "/" 11 | schedule: 12 | interval: "weekly" 13 | ignore: 14 | # Dependabot should not update Home Assistant as that should match the homeassistant key in hacs.json 15 | - dependency-name: "homeassistant" 16 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name-template: $NEXT_PATCH_VERSION 2 | tag-template: $NEXT_PATCH_VERSION 3 | categories: 4 | - title: 🚀 Features 5 | labels: 6 | - feature 7 | - enhancement 8 | - title: 🐛 Bug Fixes 9 | labels: 10 | - fix 11 | - hotfix 12 | - bug 13 | - title: 🧰 Maintenance 14 | labels: 15 | - chore 16 | - ci 17 | - workflow 18 | - tests 19 | - maintenance 20 | change-template: "- $TITLE @$AUTHOR (#$NUMBER)" 21 | template: | 22 | ## Changes 23 | 24 | $CHANGES 25 | -------------------------------------------------------------------------------- /.github/stale.yaml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 60 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 7 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - pinned 8 | - security 9 | # Label to use when marking an issue as stale 10 | staleLabel: stale 11 | # Comment to post when marking an issue as stale. Set to `false` to disable 12 | markComment: > 13 | This issue has been automatically marked as stale because it has not had 14 | recent activity. It will be closed if no further activity occurs. Thank you 15 | for your contributions. 16 | # Comment to post when closing a stale issue. Set to `false` to disable 17 | closeComment: false 18 | -------------------------------------------------------------------------------- /.github/workflows/draft-release.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Draft Release" 3 | 4 | on: 5 | push: 6 | branches: 7 | - "main" 8 | 9 | jobs: 10 | update_release_draft: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: release-drafter/release-drafter@v6 14 | env: 15 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 16 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: "Lint" 2 | 3 | on: 4 | push: 5 | branches: 6 | - "main" 7 | pull_request: 8 | branches: 9 | - "main" 10 | 11 | jobs: 12 | ruff: 13 | name: "Ruff" 14 | runs-on: "ubuntu-latest" 15 | steps: 16 | - name: "Checkout the repository" 17 | uses: "actions/checkout@v4.2.2" 18 | 19 | - name: "Set up Python" 20 | uses: actions/setup-python@v5.5.0 21 | with: 22 | python-version: "3.12" 23 | cache: "pip" 24 | 25 | - name: "Install requirements" 26 | run: python3 -m pip install -r requirements-test.txt 27 | 28 | - name: "Run" 29 | run: python3 -m ruff check . 30 | -------------------------------------------------------------------------------- /.github/workflows/pytest.yml: -------------------------------------------------------------------------------- 1 | name: "Test" 2 | 3 | on: 4 | push: 5 | branches: 6 | - "main" 7 | pull_request: 8 | branches: 9 | - "main" 10 | 11 | jobs: 12 | ruff: 13 | name: "PyTest" 14 | runs-on: "ubuntu-latest" 15 | steps: 16 | - name: "Checkout the repository" 17 | uses: "actions/checkout@v4.2.2" 18 | 19 | - name: "Set up Python" 20 | uses: actions/setup-python@v5.5.0 21 | with: 22 | python-version: "3.12" 23 | cache: "pip" 24 | 25 | - name: "Install requirements" 26 | run: python3 -m pip install -r requirements-test.txt 27 | 28 | - name: "Run" 29 | run: pytest 30 | -------------------------------------------------------------------------------- /.github/workflows/validation.yaml: -------------------------------------------------------------------------------- 1 | name: "Validate" 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: "0 0 * * *" 7 | push: 8 | branches: 9 | - "main" 10 | pull_request: 11 | branches: 12 | - "main" 13 | 14 | jobs: 15 | hassfest: # https://developers.home-assistant.io/blog/2020/04/16/hassfest 16 | name: "Hassfest Validation" 17 | runs-on: "ubuntu-latest" 18 | steps: 19 | - name: "Checkout the repository" 20 | uses: "actions/checkout@v4.2.2" 21 | 22 | - name: "Run hassfest validation" 23 | uses: "home-assistant/actions/hassfest@master" 24 | 25 | hacs: # https://github.com/hacs/action 26 | name: "HACS Validation" 27 | runs-on: "ubuntu-latest" 28 | steps: 29 | - name: "Checkout the repository" 30 | uses: "actions/checkout@v4.2.2" 31 | 32 | - name: "Run HACS validation" 33 | uses: "hacs/action@main" 34 | with: 35 | github_token: ${{ secrets.GITHUB_TOKEN }} 36 | category: integration 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # artifacts 2 | __pycache__ 3 | .pytest* 4 | *.egg-info 5 | */build/* 6 | */dist/* 7 | 8 | 9 | # misc 10 | .coverage 11 | .vscode 12 | coverage.xml 13 | *.swp 14 | 15 | 16 | # Home Assistant configuration 17 | config/* 18 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.5.0 4 | hooks: 5 | - id: check-added-large-files 6 | - id: trailing-whitespace 7 | - id: end-of-file-fixer 8 | - id: mixed-line-ending 9 | args: ["--fix=lf"] 10 | - id: check-json 11 | files: ^(custom_components|tests)/.+\.json$ 12 | - repo: https://github.com/astral-sh/ruff-pre-commit 13 | rev: v0.3.5 14 | hooks: 15 | - id: ruff 16 | args: ["--fix"] 17 | - repo: https://github.com/psf/black 18 | rev: 24.3.0 19 | hooks: 20 | - id: black 21 | args: 22 | - --safe 23 | - --quiet 24 | files: ^(custom_components|tests)/.+\.py$ 25 | - repo: https://github.com/pycqa/flake8.git 26 | rev: 6.0.0 27 | hooks: 28 | - id: flake8 29 | additional_dependencies: 30 | - flake8-docstrings>=1.5.0 31 | - pydocstyle>=5.0.2 32 | files: ^(custom_components|bin|tests)/.+\.py$ 33 | - repo: https://github.com/asottile/pyupgrade 34 | rev: v3.15.2 35 | hooks: 36 | - id: pyupgrade 37 | - repo: https://github.com/PyCQA/bandit 38 | rev: 1.7.8 39 | hooks: 40 | - id: bandit 41 | args: 42 | - --quiet 43 | - --format=custom 44 | - --configfile=bandit.yaml 45 | files: ^(custom_components|tests)/.+\.py$ 46 | - repo: https://github.com/pre-commit/mirrors-isort 47 | rev: v5.10.1 48 | hooks: 49 | - id: isort 50 | - repo: local 51 | hooks: 52 | - id: pylint 53 | name: pylint 54 | entry: pylint 55 | language: system 56 | types: [python] 57 | args: 58 | [ 59 | "-rn", # Only display messages 60 | "-sn", # Don't display the score 61 | "--rcfile=pylintrc", # Link to your config file 62 | ] 63 | - repo: https://github.com/pre-commit/mirrors-mypy 64 | rev: 'v1.9.0' 65 | hooks: 66 | - id: mypy 67 | args: 68 | - --explicit-package-bases 69 | - --strict 70 | types: [python] 71 | additional_dependencies: 72 | - typed 73 | - pydantic 74 | - returns 75 | -------------------------------------------------------------------------------- /.ruff.toml: -------------------------------------------------------------------------------- 1 | # The contents of this file is based on https://github.com/home-assistant/core/blob/dev/pyproject.toml 2 | 3 | target-version = "py310" 4 | 5 | lint.select = [ 6 | "B007", # Loop control variable {name} not used within loop body 7 | "B014", # Exception handler with duplicate exception 8 | "C", # complexity 9 | "D", # docstrings 10 | "E", # pycodestyle 11 | "F", # pyflakes/autoflake 12 | "ICN001", # import concentions; {name} should be imported as {asname} 13 | "PGH004", # Use specific rule codes when using noqa 14 | "PLC0414", # Useless import alias. Import alias does not rename original package. 15 | "SIM105", # Use contextlib.suppress({exception}) instead of try-except-pass 16 | "SIM117", # Merge with-statements that use the same scope 17 | "SIM118", # Use {key} in {dict} instead of {key} in {dict}.keys() 18 | "SIM201", # Use {left} != {right} instead of not {left} == {right} 19 | "SIM212", # Use {a} if {a} else {b} instead of {b} if not {a} else {a} 20 | "SIM300", # Yoda conditions. Use 'age == 42' instead of '42 == age'. 21 | "SIM401", # Use get from dict with default instead of an if block 22 | "T20", # flake8-print 23 | "TRY004", # Prefer TypeError exception for invalid type 24 | "RUF006", # Store a reference to the return value of asyncio.create_task 25 | "UP", # pyupgrade 26 | "W", # pycodestyle 27 | ] 28 | 29 | lint.ignore = [ 30 | "D202", # No blank lines allowed after function docstring 31 | "D203", # 1 blank line required before class docstring 32 | "D213", # Multi-line docstring summary should start at the second line 33 | "D404", # First word of the docstring should not be This 34 | "D406", # Section name should end with a newline 35 | "D407", # Section name underlining 36 | "D411", # Missing blank line before section 37 | "E501", # line too long 38 | "E731", # do not assign a lambda expression, use a def 39 | ] 40 | 41 | [lint.flake8-pytest-style] 42 | fixture-parentheses = false 43 | 44 | [lint.pyupgrade] 45 | keep-runtime-typing = true 46 | 47 | [lint.mccabe] 48 | max-complexity = 25 49 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Home Assistant", 9 | "type": "debugpy", 10 | "request": "launch", 11 | "module": "homeassistant", 12 | "justMyCode": false, 13 | "args": [ 14 | "--debug", 15 | "-c", 16 | "./config" 17 | ], 18 | }, 19 | { 20 | "name": "Home Assistant (skip pip)", 21 | "type": "debugpy", 22 | "request": "launch", 23 | "module": "homeassistant", 24 | "justMyCode": false, 25 | "args": [ 26 | "--debug", 27 | "-c", 28 | "./config", 29 | "--skip-pip" 30 | ] 31 | }, 32 | { 33 | "name": "Home Assistant: Changed tests", 34 | "type": "debugpy", 35 | "request": "launch", 36 | "module": "pytest", 37 | "justMyCode": false, 38 | "args": [ 39 | "--timeout=10", 40 | "--picked" 41 | ], 42 | }, 43 | { 44 | // Debug by attaching to local Home Assistant server using Remote Python Debugger. 45 | // See https://www.home-assistant.io/integrations/debugpy/ 46 | "name": "Home Assistant: Attach Local", 47 | "type": "debugpy", 48 | "request": "attach", 49 | "connect": { 50 | "port": 5678, 51 | "host": "localhost" 52 | }, 53 | "pathMappings": [ 54 | { 55 | "localRoot": "${workspaceFolder}", 56 | "remoteRoot": "." 57 | } 58 | ] 59 | }, 60 | { 61 | // Debug by attaching to remote Home Assistant server using Remote Python Debugger. 62 | // See https://www.home-assistant.io/integrations/debugpy/ 63 | "name": "Home Assistant: Attach Remote", 64 | "type": "debugpy", 65 | "request": "attach", 66 | "connect": { 67 | "port": 5678, 68 | "host": "homeassistant.local" 69 | }, 70 | "pathMappings": [ 71 | { 72 | "localRoot": "${workspaceFolder}", 73 | "remoteRoot": "/usr/src/homeassistant" 74 | } 75 | ] 76 | } 77 | ] 78 | } 79 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "Run Home Assistant on port 8123", 6 | "type": "shell", 7 | "command": "scripts/develop", 8 | "problemMatcher": [] 9 | }, 10 | { 11 | "label": "Lint (run pre-commit hooks)", 12 | "type": "shell", 13 | "command": "scripts/lint", 14 | "problemMatcher": [] 15 | }, 16 | { 17 | "label": "Test (run pytest)", 18 | "type": "shell", 19 | "command": "scripts/test", 20 | "problemMatcher": [] 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | our Discord server. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution guidelines 2 | 3 | Contributing to this project should be as easy and transparent as possible, whether it's: 4 | 5 | - Reporting a bug 6 | - Discussing the current state of the code 7 | - Submitting a fix 8 | - Proposing new features 9 | - Helping with translation 10 | 11 | ## Github is used for (almost) everything 12 | 13 | Github is used to host code, to track issues and feature requests, as well as accept pull requests. We use [Hosted Weblate](https://hosted.weblate.org/engage/magic-areas/) for translations but old-school pull requests for those are accepted as well. 14 | 15 | Pull requests are the best way to propose changes to the codebase. 16 | 17 | 1. Fork the repo and create your branch from `main`. 18 | 2. If you've changed (or added) something, update the documentation. 19 | 3. Make sure your code lints (using `scripts/lint`). 20 | 4. Test you contribution (using `scripts/test`). Contributions that don't provide tests may take longer to be incorporated. 21 | 5. Issue that pull request! 22 | 23 | ## Any contributions you make will be under the MIT Software License 24 | 25 | In short, when you submit code changes, your submissions are understood to be under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the project. Feel free to contact the maintainers if that's a concern. 26 | 27 | ## Report bugs using Github's [issues](https://github.com/jseidl/hass-magic_areas/issues) 28 | 29 | GitHub issues are used to track public bugs. 30 | Report a bug by [opening a new issue](https://github.com/jseidl/hass-magic_areas/issues/new/choose); it's that easy! 31 | 32 | ## Write bug reports with detail, background, and sample code 33 | 34 | **Great Bug Reports** tend to have: 35 | 36 | - A quick summary and/or background 37 | - Steps to reproduce 38 | - Be specific! 39 | - Give sample code if you can. 40 | - What you expected would happen 41 | - What actually happens 42 | - Notes (possibly including why you think this might be happening, or stuff you tried that didn't work) 43 | 44 | People *love* thorough bug reports. I'm not even kidding. 45 | 46 | ## Use a Consistent Coding Style 47 | 48 | Use [black](https://github.com/ambv/black) to make sure the code follows the style. (Running `scripts/lint` will run `black`) 49 | 50 | ## Test your code modification 51 | 52 | This custom component is based on [integration_blueprint template](https://github.com/ludeeus/integration_blueprint). 53 | 54 | It comes with development environment in a container, easy to launch if you use Visual Studio Code. With this container you will have a stand alone Home Assistant instance running and already configured with the included [`configuration.yaml`](./config/configuration.yaml) file. 55 | 56 | If you need help with your environment or understanding the code, join us at our [Discord #developers channel](https://discord.com/channels/928386239789400065/928386308324335666). 57 | 58 | ## License 59 | 60 | By contributing, you agree that your contributions will be licensed under its MIT License. 61 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Jan Seidl 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /assets/images/family-room-device.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jseidl/hass-magic_areas/d66f5e01ac5504cfe0d8842f07d8dae101356373/assets/images/family-room-device.png -------------------------------------------------------------------------------- /assets/images/readme_combined_presence_sources.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jseidl/hass-magic_areas/d66f5e01ac5504cfe0d8842f07d8dae101356373/assets/images/readme_combined_presence_sources.gif -------------------------------------------------------------------------------- /assets/images/temperature-aggregate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jseidl/hass-magic_areas/d66f5e01ac5504cfe0d8842f07d8dae101356373/assets/images/temperature-aggregate.png -------------------------------------------------------------------------------- /assets/images/upstairs-controls.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jseidl/hass-magic_areas/d66f5e01ac5504cfe0d8842f07d8dae101356373/assets/images/upstairs-controls.png -------------------------------------------------------------------------------- /bandit.yaml: -------------------------------------------------------------------------------- 1 | # https://bandit.readthedocs.io/en/latest/config.html 2 | 3 | tests: 4 | - B108 5 | - B306 6 | - B307 7 | - B313 8 | - B314 9 | - B315 10 | - B316 11 | - B317 12 | - B318 13 | - B319 14 | - B320 15 | - B602 16 | - B604 17 | -------------------------------------------------------------------------------- /config/configuration.yaml: -------------------------------------------------------------------------------- 1 | # https://www.home-assistant.io/integrations/default_config/ 2 | default_config: 3 | 4 | # https://www.home-assistant.io/integrations/logger/ 5 | logger: 6 | default: info 7 | logs: 8 | custom_components.magic_areas: debug 9 | 10 | # Example configuration.yaml entry 11 | input_boolean: 12 | lr_main_light: 13 | name: LR Main Light 14 | lr_task_light: 15 | name: LR Task Light 16 | lr_sleep_light: 17 | name: LR Sleep Light 18 | lr_accent_light: 19 | name: LR Accent Light 20 | fake_fan_for_climate: 21 | name: Fake Fan for Climate 22 | fake_door: 23 | name: Fake Door 24 | fake_motion: 25 | name: Fake Motion 26 | fake_cover: 27 | name: Fake Cover 28 | sleep_mode: 29 | name: Sleep Mode 30 | input_number: 31 | lr_temp_c: 32 | name: "Living Room Fake Temperature C" 33 | min: 0 34 | max: 50 35 | lr_temp: 36 | name: "Living Room Fake Temperature" 37 | min: 34 38 | max: 100 39 | lr_ilum: 40 | name: "Living Room Fake Illuminance" 41 | min: 0 42 | max: 1000 43 | 44 | cover: 45 | - platform: template 46 | covers: 47 | fake_cover: 48 | device_class: shade 49 | friendly_name: "Fake Shade" 50 | value_template: "{{ is_state('input_boolean.fake_cover','on') }}" 51 | unique_id: cover_fake_cover 52 | open_cover: 53 | service: input_boolean.turn_on 54 | data: 55 | entity_id: input_boolean.fake_cover 56 | close_cover: 57 | service: input_boolean.turn_off 58 | data: 59 | entity_id: input_boolean.fake_cover 60 | 61 | template: 62 | - binary_sensor: 63 | - name: "LR Fake Door" 64 | device_class: 'door' 65 | unique_id: binary_sensor_lr_door 66 | state: "{{ is_state('input_boolean.fake_door','on') }}" 67 | - name: "LR Fake Motion" 68 | device_class: 'motion' 69 | unique_id: binary_sensor_lr_motion 70 | state: "{{ is_state('input_boolean.fake_motion','on') }}" 71 | - sensor: 72 | - name: "LR Fake temperature C" 73 | device_class: 'temperature' 74 | state: "{{ states('input_number.lr_temp_c')|int(0) }}" 75 | unique_id: sensor_lr_temp_c 76 | unit_of_measurement: '°C' 77 | - name: "LR Fake temperature" 78 | device_class: 'temperature' 79 | state: "{{ states('input_number.lr_temp')|int(0) }}" 80 | unique_id: sensor_lr_temp 81 | unit_of_measurement: '°F' 82 | - name: "LR Fake illuminance" 83 | device_class: 'illuminance' 84 | state: "{{ states('input_number.lr_ilum')|int(0) }}" 85 | unique_id: sensor_lr_ilum 86 | unit_of_measurement: 'lx' 87 | 88 | climate: 89 | - platform: generic_thermostat 90 | name: Fake Climate 91 | heater: input_boolean.fake_fan_for_climate 92 | ac_mode: true 93 | target_sensor: sensor.lr_temperature 94 | unique_id: climate_fake 95 | home_temp: 68 96 | sleep_temp: 66 97 | away_temp: 64 98 | comfort_temp: 70 99 | 100 | 101 | light: 102 | - platform: template 103 | lights: 104 | lr_main_light: 105 | friendly_name: "LR Main Light" 106 | value_template: "{{ is_state('input_boolean.lr_main_light', 'on') }}" 107 | unique_id: lr_main_light 108 | turn_on: 109 | service: input_boolean.turn_on 110 | data: 111 | entity_id: input_boolean.lr_main_light 112 | turn_off: 113 | service: input_boolean.turn_off 114 | data: 115 | entity_id: input_boolean.lr_main_light 116 | lr_task_light: 117 | friendly_name: "LR Task Light" 118 | value_template: "{{ is_state('input_boolean.lr_task_light', 'on') }}" 119 | unique_id: lr_task_light 120 | turn_on: 121 | service: input_boolean.turn_on 122 | data: 123 | entity_id: input_boolean.lr_task_light 124 | turn_off: 125 | service: input_boolean.turn_off 126 | data: 127 | entity_id: input_boolean.lr_task_light 128 | lr_sleep_light: 129 | friendly_name: "LR Sleep Light" 130 | value_template: "{{ is_state('input_boolean.lr_sleep_light', 'on') }}" 131 | unique_id: lr_sleep_light 132 | turn_on: 133 | service: input_boolean.turn_on 134 | data: 135 | entity_id: input_boolean.lr_sleep_light 136 | turn_off: 137 | service: input_boolean.turn_off 138 | data: 139 | entity_id: input_boolean.lr_sleep_light 140 | lr_accent_light: 141 | friendly_name: "LR Accent Light" 142 | value_template: "{{ is_state('input_boolean.lr_accent_light', 'on') }}" 143 | unique_id: lr_accent_light 144 | turn_on: 145 | service: input_boolean.turn_on 146 | data: 147 | entity_id: input_boolean.lr_accent_light 148 | turn_off: 149 | service: input_boolean.turn_off 150 | data: 151 | entity_id: input_boolean.lr_accent_light 152 | -------------------------------------------------------------------------------- /custom_components/magic_areas/__init__.py: -------------------------------------------------------------------------------- 1 | """Magic Areas component for Home Assistant.""" 2 | 3 | from collections.abc import Callable 4 | from datetime import UTC, datetime 5 | import logging 6 | from typing import Any 7 | 8 | from homeassistant.config_entries import ConfigEntry 9 | from homeassistant.const import ATTR_NAME, EVENT_HOMEASSISTANT_STARTED 10 | from homeassistant.core import Event, HomeAssistant, callback 11 | from homeassistant.helpers.device_registry import ( 12 | EVENT_DEVICE_REGISTRY_UPDATED, 13 | EventDeviceRegistryUpdatedData, 14 | ) 15 | from homeassistant.helpers.entity_registry import ( 16 | EVENT_ENTITY_REGISTRY_UPDATED, 17 | EventEntityRegistryUpdatedData, 18 | ) 19 | 20 | from custom_components.magic_areas.base.magic import MagicArea 21 | from custom_components.magic_areas.const import ( 22 | CONF_RELOAD_ON_REGISTRY_CHANGE, 23 | DATA_AREA_OBJECT, 24 | DATA_TRACKED_LISTENERS, 25 | DEFAULT_RELOAD_ON_REGISTRY_CHANGE, 26 | MODULE_DATA, 27 | MagicConfigEntryVersion, 28 | ) 29 | from custom_components.magic_areas.helpers.area import get_magic_area_for_config_entry 30 | 31 | _LOGGER = logging.getLogger(__name__) 32 | 33 | 34 | async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry): 35 | """Set up the component.""" 36 | 37 | @callback 38 | def _async_registry_updated( 39 | event: ( 40 | Event[EventEntityRegistryUpdatedData] 41 | | Event[EventDeviceRegistryUpdatedData] 42 | ), 43 | ) -> None: 44 | """Reload integration when entity registry is updated.""" 45 | 46 | area_data: dict[str, Any] = dict(config_entry.data) 47 | if config_entry.options: 48 | area_data.update(config_entry.options) 49 | 50 | # Check if disabled 51 | if not area_data.get( 52 | CONF_RELOAD_ON_REGISTRY_CHANGE, DEFAULT_RELOAD_ON_REGISTRY_CHANGE 53 | ): 54 | _LOGGER.debug( 55 | "%s: Auto-Reloading disabled for this area skipping...", 56 | config_entry.data[ATTR_NAME], 57 | ) 58 | return 59 | 60 | _LOGGER.debug( 61 | "%s: Reloading entry due entity registry change", 62 | config_entry.data[ATTR_NAME], 63 | ) 64 | hass.config_entries.async_update_entry( 65 | config_entry, 66 | data={**config_entry.data, "entity_ts": datetime.now(UTC)}, 67 | ) 68 | 69 | async def _async_setup_integration(*args, **kwargs) -> None: 70 | """Load integration when Hass has finished starting.""" 71 | _LOGGER.debug("Setting up entry for %s", config_entry.data[ATTR_NAME]) 72 | 73 | magic_area: MagicArea | None = get_magic_area_for_config_entry( 74 | hass, config_entry 75 | ) 76 | assert magic_area is not None 77 | await magic_area.initialize() 78 | 79 | _LOGGER.debug( 80 | "%s: Magic Area (%s) created: %s", 81 | magic_area.name, 82 | magic_area.id, 83 | str(magic_area.config), 84 | ) 85 | 86 | # Setup config uptate listener 87 | tracked_listeners: list[Callable] = [] 88 | tracked_listeners.append(config_entry.add_update_listener(async_update_options)) 89 | 90 | # Watch for area changes. 91 | tracked_listeners.append( 92 | hass.bus.async_listen( 93 | EVENT_ENTITY_REGISTRY_UPDATED, 94 | _async_registry_updated, 95 | _entity_registry_filter, 96 | ) 97 | ) 98 | tracked_listeners.append( 99 | hass.bus.async_listen( 100 | EVENT_DEVICE_REGISTRY_UPDATED, 101 | _async_registry_updated, 102 | _device_registry_filter, 103 | ) 104 | ) 105 | 106 | hass.data[MODULE_DATA][config_entry.entry_id] = { 107 | DATA_AREA_OBJECT: magic_area, 108 | DATA_TRACKED_LISTENERS: tracked_listeners, 109 | } 110 | 111 | # Setup platforms 112 | await hass.config_entries.async_forward_entry_setups( 113 | config_entry, magic_area.available_platforms() 114 | ) 115 | 116 | hass.data.setdefault(MODULE_DATA, {}) 117 | 118 | # Wait for Hass to have started before setting up. 119 | if hass.is_running: 120 | hass.create_task(_async_setup_integration()) 121 | else: 122 | hass.bus.async_listen_once( 123 | EVENT_HOMEASSISTANT_STARTED, _async_setup_integration 124 | ) 125 | 126 | return True 127 | 128 | 129 | async def async_update_options(hass: HomeAssistant, config_entry: ConfigEntry) -> None: 130 | """Update options.""" 131 | _LOGGER.debug( 132 | "Detected options change for entry %s, reloading", config_entry.entry_id 133 | ) 134 | await hass.config_entries.async_reload(config_entry.entry_id) 135 | 136 | # @TODO Reload corresponding meta areas (floor+interior/exterior+global) 137 | 138 | 139 | async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: 140 | """Unload a config entry.""" 141 | 142 | platforms_unloaded = [] 143 | if MODULE_DATA not in hass.data: 144 | _LOGGER.warning( 145 | "Module data object for Magic Areas not found, possibly already removed." 146 | ) 147 | return False 148 | 149 | data = hass.data[MODULE_DATA] 150 | 151 | if config_entry.entry_id not in data: 152 | _LOGGER.debug( 153 | "Config entry '%s' not on data dictionary, probably already unloaded. Skipping.", 154 | config_entry.entry_id, 155 | ) 156 | return True 157 | 158 | area_data = data[config_entry.entry_id] 159 | area = area_data[DATA_AREA_OBJECT] 160 | 161 | for platform in area.available_platforms(): 162 | unload_ok = await hass.config_entries.async_forward_entry_unload( 163 | config_entry, platform 164 | ) 165 | platforms_unloaded.append(unload_ok) 166 | 167 | for tracked_listener in area_data[DATA_TRACKED_LISTENERS]: 168 | tracked_listener() 169 | 170 | all_unloaded = all(platforms_unloaded) 171 | 172 | if all_unloaded: 173 | data.pop(config_entry.entry_id) 174 | 175 | if not data: 176 | hass.data.pop(MODULE_DATA) 177 | 178 | return True 179 | 180 | 181 | # Update config version 182 | async def async_migrate_entry(hass, config_entry: ConfigEntry): 183 | """Migrate old entry.""" 184 | _LOGGER.info( 185 | "%s: Migrating configuration from version %s.%s, current config: %s", 186 | config_entry.data[ATTR_NAME], 187 | config_entry.version, 188 | config_entry.minor_version, 189 | str(config_entry.data), 190 | ) 191 | 192 | if config_entry.version > MagicConfigEntryVersion.MAJOR: 193 | # This means the user has downgraded from a future version 194 | _LOGGER.warning( 195 | "%s: Major version downgrade detection, skipping migration.", 196 | config_entry.data[ATTR_NAME], 197 | ) 198 | 199 | return False 200 | 201 | hass.config_entries.async_update_entry( 202 | config_entry, 203 | minor_version=MagicConfigEntryVersion.MINOR, 204 | version=MagicConfigEntryVersion.MAJOR, 205 | ) 206 | 207 | _LOGGER.info( 208 | "Migration to configuration version %s.%s successful: %s", 209 | config_entry.version, 210 | config_entry.minor_version, 211 | str(config_entry.data), 212 | ) 213 | 214 | return True 215 | 216 | 217 | @callback 218 | def _entity_registry_filter(event_data: EventEntityRegistryUpdatedData) -> bool: 219 | """Filter entity registry events.""" 220 | return event_data["action"] == "update" and "area_id" in event_data["changes"] 221 | 222 | 223 | @callback 224 | def _device_registry_filter(event_data: EventDeviceRegistryUpdatedData) -> bool: 225 | """Filter device registry events.""" 226 | return event_data["action"] == "update" and "area_id" in event_data["changes"] 227 | -------------------------------------------------------------------------------- /custom_components/magic_areas/base/__init__.py: -------------------------------------------------------------------------------- 1 | """Provides base classes for Magic Areas entities.""" 2 | -------------------------------------------------------------------------------- /custom_components/magic_areas/base/entities.py: -------------------------------------------------------------------------------- 1 | """The basic entities for magic areas.""" 2 | 3 | import logging 4 | 5 | from homeassistant.const import STATE_OFF, STATE_ON 6 | from homeassistant.helpers.device_registry import DeviceInfo 7 | from homeassistant.helpers.restore_state import RestoreEntity 8 | 9 | from custom_components.magic_areas.base.magic import MagicArea 10 | from custom_components.magic_areas.const import ( 11 | DOMAIN, 12 | MAGIC_DEVICE_ID_PREFIX, 13 | MAGICAREAS_UNIQUEID_PREFIX, 14 | META_AREAS, 15 | MagicAreasFeatureInfo, 16 | ) 17 | 18 | _LOGGER = logging.getLogger(__name__) 19 | 20 | 21 | class MagicEntity(RestoreEntity): 22 | """MagicEntity is the base entity for use with all the magic classes.""" 23 | 24 | area: MagicArea 25 | feature_info: MagicAreasFeatureInfo | None = None 26 | _extra_identifiers: list[str] | None = None 27 | _attr_has_entity_name = True 28 | 29 | def __init__( 30 | self, 31 | area: MagicArea, 32 | domain: str, 33 | translation_key: str | None = None, 34 | extra_identifiers: list[str] | None = None, 35 | ) -> None: 36 | """Initialize the magic area.""" 37 | # Avoiding using super() due multiple inheritance issues 38 | RestoreEntity.__init__(self) 39 | 40 | if not self.feature_info: 41 | raise NotImplementedError(f"{self.name}: Feature info not set.") 42 | 43 | self.logger = logging.getLogger(type(self).__module__) 44 | self.area = area 45 | self._extra_identifiers = [] 46 | 47 | if extra_identifiers: 48 | self._extra_identifiers.extend(extra_identifiers) 49 | 50 | # Allow supplying of additional translation key parts 51 | # for dealing with device_classes 52 | translation_key_parts = [] 53 | feature_translation_key = self.feature_info.translation_keys[domain] 54 | if feature_translation_key: 55 | translation_key_parts.append(feature_translation_key) 56 | if translation_key: 57 | translation_key_parts.append(translation_key) 58 | self._attr_translation_key = "_".join(translation_key_parts) 59 | self._attr_translation_placeholders = {} 60 | 61 | # Resolve icon 62 | self._attr_icon = self.feature_info.icons.get(domain, None) 63 | 64 | # Resolve entity id & unique id 65 | self.entity_id = self._generate_entity_id(domain) 66 | self._attr_unique_id = self._generaete_unique_id(domain) 67 | 68 | _LOGGER.debug( 69 | "%s: Initializing entity. (entity_id: %s, unique id: %s, translation_key: %s)", 70 | self.area.name, 71 | self.entity_id, 72 | self._attr_unique_id, 73 | self._attr_translation_key, 74 | ) 75 | 76 | def _generate_entity_id(self, domain: str) -> str: 77 | if not self.feature_info: 78 | raise NotImplementedError(f"{self.name}: Feature info not set.") 79 | 80 | entity_id_parts = [ 81 | MAGICAREAS_UNIQUEID_PREFIX, 82 | self.feature_info.id, 83 | self.area.slug, 84 | ] 85 | 86 | if ( 87 | self._attr_translation_key 88 | and self._attr_translation_key != self.feature_info.id 89 | ): 90 | entity_id_parts.append(self._attr_translation_key) 91 | 92 | if self._extra_identifiers: 93 | entity_id_parts.extend(self._extra_identifiers) 94 | 95 | entity_id = "_".join(entity_id_parts) 96 | 97 | return f"{domain}.{entity_id}" 98 | 99 | def _generaete_unique_id(self, domain: str, extra_parts: list | None = None): 100 | # Format: magicareas_feature_domain_areaname_name 101 | if not self.feature_info: 102 | raise NotImplementedError(f"{self.name}: Feature info not set.") 103 | 104 | unique_id_parts = [ 105 | MAGICAREAS_UNIQUEID_PREFIX, 106 | self.feature_info.id, 107 | domain, 108 | self.area.slug, 109 | ] 110 | 111 | if self._attr_translation_key: 112 | unique_id_parts.append(self._attr_translation_key) 113 | 114 | if self._extra_identifiers: 115 | unique_id_parts.extend(self._extra_identifiers) 116 | 117 | return "_".join(unique_id_parts) 118 | 119 | @property 120 | def should_poll(self) -> bool: 121 | """If entity should be polled.""" 122 | return False 123 | 124 | @property 125 | def device_info(self) -> DeviceInfo: 126 | """Return the device info.""" 127 | return DeviceInfo( 128 | identifiers={ 129 | # Serial numbers are unique identifiers within a specific domain 130 | (DOMAIN, f"{MAGIC_DEVICE_ID_PREFIX}{self.area.id}") 131 | }, 132 | name=self.area.name, 133 | manufacturer="Magic Areas", 134 | model="Magic Area", 135 | translation_key=( 136 | self.area.slug 137 | if (self.area.is_meta() and self.area.name in META_AREAS) 138 | else None 139 | ), 140 | ) 141 | 142 | async def restore_state(self) -> None: 143 | """Restore the state of the entity.""" 144 | last_state = await self.async_get_last_state() 145 | 146 | if last_state is None: 147 | _LOGGER.debug("%s: New entity created", self.name) 148 | self._attr_state = STATE_OFF 149 | else: 150 | _LOGGER.debug( 151 | "%s: entity restored [state=%s]", 152 | self.name, 153 | last_state.state, 154 | ) 155 | self._attr_state = last_state.state 156 | self._attr_extra_state_attributes = dict(last_state.attributes) 157 | 158 | self.schedule_update_ha_state() 159 | 160 | 161 | class BinaryMagicEntity(MagicEntity): 162 | """Class for Binary-based magic entities.""" 163 | 164 | _attr_is_on: bool 165 | 166 | async def restore_state(self) -> None: 167 | """Restore the state of the entity.""" 168 | last_state = await self.async_get_last_state() 169 | 170 | if last_state is None: 171 | _LOGGER.debug("%s: New entity created", self.name) 172 | self._attr_is_on = False 173 | else: 174 | _LOGGER.debug( 175 | "%s: entity restored [state=%s]", 176 | self.name, 177 | last_state.state, 178 | ) 179 | self._attr_is_on = last_state.state == STATE_ON 180 | self._attr_extra_state_attributes = dict(last_state.attributes) 181 | 182 | self.schedule_update_ha_state() 183 | -------------------------------------------------------------------------------- /custom_components/magic_areas/binary_sensor/__init__.py: -------------------------------------------------------------------------------- 1 | """Binary sensor control for magic areas.""" 2 | 3 | import logging 4 | 5 | from homeassistant.components.binary_sensor import ( 6 | DOMAIN as BINARY_SENSOR_DOMAIN, 7 | BinarySensorDeviceClass, 8 | ) 9 | from homeassistant.config_entries import ConfigEntry 10 | from homeassistant.const import ATTR_DEVICE_CLASS, ATTR_ENTITY_ID 11 | from homeassistant.core import HomeAssistant 12 | from homeassistant.helpers.entity import Entity 13 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 14 | 15 | from custom_components.magic_areas.base.magic import MagicArea, MagicMetaArea 16 | from custom_components.magic_areas.binary_sensor.base import AreaSensorGroupBinarySensor 17 | from custom_components.magic_areas.binary_sensor.ble_tracker import ( 18 | AreaBLETrackerBinarySensor, 19 | ) 20 | from custom_components.magic_areas.binary_sensor.presence import ( 21 | AreaStateBinarySensor, 22 | MetaAreaStateBinarySensor, 23 | ) 24 | from custom_components.magic_areas.binary_sensor.wasp_in_a_box import ( 25 | AreaWaspInABoxBinarySensor, 26 | ) 27 | from custom_components.magic_areas.const import ( 28 | CONF_AGGREGATES_BINARY_SENSOR_DEVICE_CLASSES, 29 | CONF_AGGREGATES_MIN_ENTITIES, 30 | CONF_BLE_TRACKER_ENTITIES, 31 | CONF_FEATURE_AGGREGATION, 32 | CONF_FEATURE_BLE_TRACKERS, 33 | CONF_FEATURE_HEALTH, 34 | CONF_FEATURE_WASP_IN_A_BOX, 35 | CONF_HEALTH_SENSOR_DEVICE_CLASSES, 36 | DEFAULT_AGGREGATES_BINARY_SENSOR_DEVICE_CLASSES, 37 | DEFAULT_HEALTH_SENSOR_DEVICE_CLASSES, 38 | MagicAreasFeatureInfoAggregates, 39 | MagicAreasFeatureInfoHealth, 40 | ) 41 | from custom_components.magic_areas.helpers.area import get_area_from_config_entry 42 | from custom_components.magic_areas.threshold import create_illuminance_threshold 43 | from custom_components.magic_areas.util import cleanup_removed_entries 44 | 45 | _LOGGER = logging.getLogger(__name__) 46 | 47 | # Classes 48 | 49 | 50 | class AreaAggregateBinarySensor(AreaSensorGroupBinarySensor): 51 | """Aggregate sensor for the area.""" 52 | 53 | feature_info = MagicAreasFeatureInfoAggregates() 54 | 55 | 56 | class AreaHealthBinarySensor(AreaSensorGroupBinarySensor): 57 | """Aggregate sensor for the area.""" 58 | 59 | feature_info = MagicAreasFeatureInfoHealth() 60 | 61 | 62 | # Setup 63 | 64 | 65 | async def async_setup_entry( 66 | hass: HomeAssistant, 67 | config_entry: ConfigEntry, 68 | async_add_entities: AddEntitiesCallback, 69 | ) -> None: 70 | """Set up the area binary sensor config entry.""" 71 | 72 | area: MagicArea | MagicMetaArea | None = get_area_from_config_entry( 73 | hass, config_entry 74 | ) 75 | assert area is not None 76 | 77 | entities = [] 78 | 79 | # Create main presence sensor 80 | if area.is_meta() and isinstance(area, MagicMetaArea): 81 | entities.append(MetaAreaStateBinarySensor(area)) 82 | else: 83 | entities.append(AreaStateBinarySensor(area)) 84 | 85 | # Create extra sensors 86 | if area.has_feature(CONF_FEATURE_AGGREGATION): 87 | entities.extend(create_aggregate_sensors(area)) 88 | illuminance_threshold_sensor = create_illuminance_threshold(area) 89 | if illuminance_threshold_sensor: 90 | entities.append(illuminance_threshold_sensor) 91 | 92 | # Wasp in a box 93 | if area.has_feature(CONF_FEATURE_WASP_IN_A_BOX) and not area.is_meta(): 94 | entities.extend(create_wasp_in_a_box_sensor(area)) 95 | 96 | if area.has_feature(CONF_FEATURE_HEALTH): 97 | entities.extend(create_health_sensors(area)) 98 | 99 | if area.has_feature(CONF_FEATURE_BLE_TRACKERS): 100 | entities.extend(create_ble_tracker_sensor(area)) 101 | 102 | # Add all entities 103 | async_add_entities(entities) 104 | 105 | # Cleanup 106 | if BINARY_SENSOR_DOMAIN in area.magic_entities: 107 | cleanup_removed_entries( 108 | area.hass, entities, area.magic_entities[BINARY_SENSOR_DOMAIN] 109 | ) 110 | 111 | 112 | def create_wasp_in_a_box_sensor( 113 | area: MagicArea, 114 | ) -> list[AreaWaspInABoxBinarySensor]: 115 | """Add the Wasp in a box sensor for the area.""" 116 | 117 | if not area.has_feature(CONF_FEATURE_WASP_IN_A_BOX) or not area.has_feature( 118 | CONF_FEATURE_AGGREGATION 119 | ): 120 | return [] 121 | 122 | try: 123 | return [AreaWaspInABoxBinarySensor(area)] 124 | except Exception as e: # pylint: disable=broad-exception-caught 125 | _LOGGER.error( 126 | "%s: Error creating wasp in a box sensor: %s", 127 | area.slug, 128 | str(e), 129 | ) 130 | return [] 131 | 132 | 133 | def create_ble_tracker_sensor(area: MagicArea) -> list[AreaBLETrackerBinarySensor]: 134 | """Add the BLE tracker sensor for the area.""" 135 | if not area.has_feature(CONF_FEATURE_BLE_TRACKERS): 136 | return [] 137 | 138 | if not area.feature_config(CONF_FEATURE_BLE_TRACKERS).get( 139 | CONF_BLE_TRACKER_ENTITIES, [] 140 | ): 141 | return [] 142 | 143 | try: 144 | return [ 145 | AreaBLETrackerBinarySensor( 146 | area, 147 | ) 148 | ] 149 | except Exception as e: # pylint: disable=broad-exception-caught 150 | _LOGGER.error( 151 | "%s: Error creating BLE tracker sensor: %s", 152 | area.slug, 153 | str(e), 154 | ) 155 | return [] 156 | 157 | 158 | def create_health_sensors(area: MagicArea) -> list[AreaHealthBinarySensor]: 159 | """Add the health sensors for the area.""" 160 | if not area.has_feature(CONF_FEATURE_HEALTH): 161 | return [] 162 | 163 | if BINARY_SENSOR_DOMAIN not in area.entities: 164 | return [] 165 | 166 | distress_entities: list[str] = [] 167 | 168 | health_sensor_device_classes = area.feature_config(CONF_FEATURE_HEALTH).get( 169 | CONF_HEALTH_SENSOR_DEVICE_CLASSES, DEFAULT_HEALTH_SENSOR_DEVICE_CLASSES 170 | ) 171 | 172 | for entity in area.entities[BINARY_SENSOR_DOMAIN]: 173 | if ATTR_DEVICE_CLASS not in entity: 174 | continue 175 | 176 | if entity[ATTR_DEVICE_CLASS] not in health_sensor_device_classes: 177 | continue 178 | 179 | distress_entities.append(entity[ATTR_ENTITY_ID]) 180 | 181 | if not distress_entities: 182 | _LOGGER.debug( 183 | "%s: No binary sensor found for configured device classes: %s.", 184 | area.name, 185 | str(health_sensor_device_classes), 186 | ) 187 | return [] 188 | 189 | _LOGGER.debug( 190 | "%s: Creating health sensor with the following entities: %s", 191 | area.slug, 192 | str(distress_entities), 193 | ) 194 | 195 | try: 196 | return [ 197 | AreaHealthBinarySensor( 198 | area, 199 | device_class=BinarySensorDeviceClass.PROBLEM, 200 | entity_ids=distress_entities, 201 | ) 202 | ] 203 | except Exception as e: # pylint: disable=broad-exception-caught 204 | _LOGGER.error( 205 | "%s: Error creating area health sensor: %s", 206 | area.slug, 207 | str(e), 208 | ) 209 | return [] 210 | 211 | 212 | def create_aggregate_sensors(area: MagicArea) -> list[Entity]: 213 | """Create the aggregate sensors for the area.""" 214 | # Create aggregates 215 | if not area.has_feature(CONF_FEATURE_AGGREGATION): 216 | return [] 217 | 218 | aggregates: list[Entity] = [] 219 | 220 | # Check BINARY_SENSOR_DOMAIN entities, count by device_class 221 | if BINARY_SENSOR_DOMAIN not in area.entities: 222 | return [] 223 | 224 | device_class_entities: dict[str, list[str]] = {} 225 | 226 | for entity in area.entities[BINARY_SENSOR_DOMAIN]: 227 | if ATTR_DEVICE_CLASS not in entity: 228 | continue 229 | 230 | if entity[ATTR_DEVICE_CLASS] not in device_class_entities: 231 | device_class_entities[entity[ATTR_DEVICE_CLASS]] = [] 232 | 233 | device_class_entities[entity[ATTR_DEVICE_CLASS]].append(entity["entity_id"]) 234 | 235 | for device_class, entity_list in device_class_entities.items(): 236 | if len(entity_list) < area.feature_config(CONF_FEATURE_AGGREGATION).get( 237 | CONF_AGGREGATES_MIN_ENTITIES, 0 238 | ): 239 | continue 240 | 241 | if device_class not in area.feature_config(CONF_FEATURE_AGGREGATION).get( 242 | CONF_AGGREGATES_BINARY_SENSOR_DEVICE_CLASSES, 243 | DEFAULT_AGGREGATES_BINARY_SENSOR_DEVICE_CLASSES, 244 | ): 245 | continue 246 | 247 | _LOGGER.debug( 248 | "Creating aggregate sensor for device_class '%s' with %s entities (%s)", 249 | device_class, 250 | len(entity_list), 251 | area.slug, 252 | ) 253 | try: 254 | aggregates.append( 255 | AreaAggregateBinarySensor(area, device_class, entity_list) 256 | ) 257 | except Exception as e: # pylint: disable=broad-exception-caught 258 | _LOGGER.error( 259 | "%s: Error creating '%s' aggregate sensor: %s", 260 | area.slug, 261 | device_class, 262 | str(e), 263 | ) 264 | 265 | return aggregates 266 | -------------------------------------------------------------------------------- /custom_components/magic_areas/binary_sensor/base.py: -------------------------------------------------------------------------------- 1 | """Base classes for binary sensor component.""" 2 | 3 | from homeassistant.components.binary_sensor import ( 4 | DOMAIN as BINARY_SENSOR_DOMAIN, 5 | BinarySensorDeviceClass, 6 | ) 7 | from homeassistant.components.group.binary_sensor import BinarySensorGroup 8 | 9 | from custom_components.magic_areas.base.entities import MagicEntity 10 | from custom_components.magic_areas.base.magic import MagicArea 11 | from custom_components.magic_areas.const import AGGREGATE_MODE_ALL, EMPTY_STRING 12 | 13 | 14 | class AreaSensorGroupBinarySensor(MagicEntity, BinarySensorGroup): 15 | """Group binary sensor for the area.""" 16 | 17 | def __init__( 18 | self, 19 | area: MagicArea, 20 | device_class: str, 21 | entity_ids: list[str], 22 | ) -> None: 23 | """Initialize an area sensor group binary sensor.""" 24 | 25 | MagicEntity.__init__( 26 | self, area, domain=BINARY_SENSOR_DOMAIN, translation_key=device_class 27 | ) 28 | BinarySensorGroup.__init__( 29 | self, 30 | device_class=( 31 | BinarySensorDeviceClass(device_class) if device_class else None 32 | ), 33 | name=EMPTY_STRING, 34 | unique_id=self._attr_unique_id, 35 | entity_ids=entity_ids, 36 | mode=device_class in AGGREGATE_MODE_ALL, 37 | ) 38 | delattr(self, "_attr_name") 39 | -------------------------------------------------------------------------------- /custom_components/magic_areas/binary_sensor/ble_tracker.py: -------------------------------------------------------------------------------- 1 | """BLE Tracker binary sensor component.""" 2 | 3 | from datetime import UTC, datetime 4 | import logging 5 | 6 | from homeassistant.components.binary_sensor import ( 7 | DOMAIN as BINARY_SENSOR_DOMAIN, 8 | BinarySensorDeviceClass, 9 | BinarySensorEntity, 10 | ) 11 | from homeassistant.const import ATTR_ENTITY_ID, STATE_ON 12 | from homeassistant.core import Event, EventStateChangedData, callback 13 | from homeassistant.helpers.event import async_track_state_change_event 14 | 15 | from custom_components.magic_areas.base.entities import MagicEntity 16 | from custom_components.magic_areas.base.magic import MagicArea 17 | from custom_components.magic_areas.const import ( 18 | ATTR_ACTIVE_SENSORS, 19 | CONF_BLE_TRACKER_ENTITIES, 20 | MagicAreasFeatureInfoBLETrackers, 21 | MagicAreasFeatures, 22 | ) 23 | 24 | _LOGGER = logging.getLogger(__name__) 25 | 26 | 27 | class AreaBLETrackerBinarySensor(MagicEntity, BinarySensorEntity): 28 | """BLE Tracker monitoring sensor for the area.""" 29 | 30 | feature_info = MagicAreasFeatureInfoBLETrackers() 31 | _sensors: list[str] 32 | 33 | def __init__(self, area: MagicArea) -> None: 34 | """Initialize the area presence binary sensor.""" 35 | 36 | MagicEntity.__init__(self, area, domain=BINARY_SENSOR_DOMAIN) 37 | BinarySensorEntity.__init__(self) 38 | 39 | self._sensors = self.area.feature_config(MagicAreasFeatures.BLE_TRACKER).get( 40 | CONF_BLE_TRACKER_ENTITIES, [] 41 | ) 42 | 43 | self._attr_device_class = BinarySensorDeviceClass.OCCUPANCY 44 | self._attr_extra_state_attributes = { 45 | ATTR_ENTITY_ID: self._sensors, 46 | ATTR_ACTIVE_SENSORS: [], 47 | } 48 | self._attr_is_on: bool = False 49 | 50 | async def _restore_state(self) -> None: 51 | """Restore the state of the BLE Tracker monitor sensor entity on initialize.""" 52 | last_state = await self.async_get_last_state() 53 | 54 | if last_state is None: 55 | _LOGGER.debug("%s: New BLE Tracker monitor sensor created", self.area.name) 56 | self._attr_is_on = False 57 | else: 58 | _LOGGER.debug( 59 | "%s: BLE Tracker monitor sensor restored [state=%s]", 60 | self.area.name, 61 | last_state.state, 62 | ) 63 | self._attr_is_on = last_state.state == STATE_ON 64 | self._attr_extra_state_attributes = dict(last_state.attributes) 65 | 66 | self.schedule_update_ha_state() 67 | 68 | async def async_added_to_hass(self) -> None: 69 | """Call to add the system to hass.""" 70 | await super().async_added_to_hass() 71 | await self._restore_state() 72 | 73 | # Setup the listeners 74 | await self._setup_listeners() 75 | 76 | self.hass.loop.call_soon_threadsafe(self._update_state, datetime.now(UTC)) 77 | 78 | _LOGGER.debug("%s: BLE Tracker monitor sensor initialized", self.area.name) 79 | 80 | async def _setup_listeners(self) -> None: 81 | """Attach state chagne listeners.""" 82 | self.async_on_remove( 83 | async_track_state_change_event( 84 | self.hass, self._sensors, self._sensor_state_change 85 | ) 86 | ) 87 | 88 | def _sensor_state_change(self, event: Event[EventStateChangedData]) -> None: 89 | """Call update state from track state change event.""" 90 | 91 | self._update_state() 92 | 93 | @callback 94 | def _update_state(self, extra: datetime | None = None) -> None: 95 | """Calculate state based off BLE tracker sensors.""" 96 | 97 | calculated_state: bool = False 98 | active_sensors: list[str] = [] 99 | 100 | for sensor in self._sensors: 101 | sensor_state = self.hass.states.get(sensor) 102 | 103 | if not sensor_state: 104 | continue 105 | 106 | normalized_state = sensor_state.state.lower() 107 | 108 | if ( 109 | normalized_state == self.area.slug 110 | or normalized_state == self.area.id 111 | or normalized_state == self.area.name.lower() 112 | ): 113 | calculated_state = True 114 | active_sensors.append(sensor) 115 | 116 | _LOGGER.debug( 117 | "%s: BLE Tracker monitor sensor state change: %s -> %s", 118 | self.area.name, 119 | self._attr_is_on, 120 | calculated_state, 121 | ) 122 | 123 | self._attr_is_on = calculated_state 124 | self._attr_extra_state_attributes[ATTR_ACTIVE_SENSORS] = active_sensors 125 | self.schedule_update_ha_state() 126 | -------------------------------------------------------------------------------- /custom_components/magic_areas/binary_sensor/wasp_in_a_box.py: -------------------------------------------------------------------------------- 1 | """Wasp in a box binary sensor component.""" 2 | 3 | import logging 4 | 5 | from homeassistant.components.binary_sensor import ( 6 | DOMAIN as BINARY_SENSOR_DOMAIN, 7 | BinarySensorDeviceClass, 8 | BinarySensorEntity, 9 | ) 10 | from homeassistant.const import STATE_OFF, STATE_ON 11 | from homeassistant.core import Event, EventStateChangedData, callback 12 | from homeassistant.helpers.event import async_track_state_change_event 13 | 14 | from custom_components.magic_areas.base.entities import MagicEntity 15 | from custom_components.magic_areas.base.magic import MagicArea 16 | from custom_components.magic_areas.const import ( 17 | CONF_WASP_IN_A_BOX_DELAY, 18 | CONF_WASP_IN_A_BOX_WASP_DEVICE_CLASSES, 19 | DEFAULT_WASP_IN_A_BOX_DELAY, 20 | DEFAULT_WASP_IN_A_BOX_WASP_DEVICE_CLASSES, 21 | WASP_IN_A_BOX_BOX_DEVICE_CLASSES, 22 | MagicAreasFeatureInfoWaspInABox, 23 | MagicAreasFeatures, 24 | ) 25 | 26 | _LOGGER = logging.getLogger(__name__) 27 | 28 | 29 | ATTR_BOX = "box" 30 | ATTR_WASP = "wasp" 31 | 32 | 33 | class AreaWaspInABoxBinarySensor(MagicEntity, BinarySensorEntity): 34 | """Wasp In The Box logic tracking sensor for the area.""" 35 | 36 | feature_info = MagicAreasFeatureInfoWaspInABox() 37 | _wasp_sensors: list[str] 38 | _box_sensors: list[str] 39 | delay: int 40 | wasp: bool 41 | 42 | def __init__(self, area: MagicArea) -> None: 43 | """Initialize the area presence binary sensor.""" 44 | 45 | MagicEntity.__init__(self, area, domain=BINARY_SENSOR_DOMAIN) 46 | BinarySensorEntity.__init__(self) 47 | 48 | self.delay = self.area.feature_config(MagicAreasFeatures.WASP_IN_A_BOX).get( 49 | CONF_WASP_IN_A_BOX_DELAY, DEFAULT_WASP_IN_A_BOX_DELAY 50 | ) 51 | 52 | self._attr_device_class = BinarySensorDeviceClass.PRESENCE 53 | self._attr_extra_state_attributes = { 54 | ATTR_BOX: STATE_OFF, 55 | ATTR_WASP: STATE_OFF, 56 | } 57 | 58 | self.wasp = False 59 | self._attr_is_on: bool = False 60 | 61 | self._wasp_sensors = [] 62 | self._box_sensors = [] 63 | 64 | async def async_added_to_hass(self) -> None: 65 | """Call to add the system to hass.""" 66 | await super().async_added_to_hass() 67 | 68 | # Check entities exist 69 | wasp_device_classes = self.area.feature_config( 70 | MagicAreasFeatures.WASP_IN_A_BOX 71 | ).get( 72 | CONF_WASP_IN_A_BOX_WASP_DEVICE_CLASSES, 73 | DEFAULT_WASP_IN_A_BOX_WASP_DEVICE_CLASSES, 74 | ) 75 | 76 | for device_class in wasp_device_classes: 77 | dc_entity_id = f"{BINARY_SENSOR_DOMAIN}.magic_areas_aggregates_{self.area.slug}_aggregate_{device_class}" 78 | dc_state = self.hass.states.get(dc_entity_id) 79 | if not dc_state: 80 | continue 81 | self._wasp_sensors.append(dc_entity_id) 82 | 83 | if not self._wasp_sensors: 84 | raise RuntimeError(f"{self.area.name}: No valid wasp sensors defined.") 85 | 86 | for device_class in WASP_IN_A_BOX_BOX_DEVICE_CLASSES: 87 | dc_entity_id = f"{BINARY_SENSOR_DOMAIN}.magic_areas_aggregates_{self.area.slug}_aggregate_{device_class}" 88 | dc_state = self.hass.states.get(dc_entity_id) 89 | if not dc_state: 90 | continue 91 | self._box_sensors.append(dc_entity_id) 92 | 93 | if not self._box_sensors: 94 | raise RuntimeError(f"{self.area.name}: No valid wasp sensors defined.") 95 | 96 | # Add listeners 97 | 98 | self.async_on_remove( 99 | async_track_state_change_event( 100 | self.hass, self._wasp_sensors, self._wasp_sensor_state_change 101 | ) 102 | ) 103 | self.async_on_remove( 104 | async_track_state_change_event( 105 | self.hass, self._box_sensors, self._box_sensor_state_change 106 | ) 107 | ) 108 | 109 | def _wasp_sensor_state_change(self, event: Event[EventStateChangedData]) -> None: 110 | """Register wasp sensor state change event.""" 111 | 112 | # Ignore state reports that aren't really a state change 113 | if not event.data["new_state"] or not event.data["old_state"]: 114 | return 115 | if event.data["new_state"].state == event.data["old_state"].state: 116 | return 117 | 118 | self.wasp_in_a_box(wasp_state=event.data["new_state"].state) 119 | 120 | def _box_sensor_state_change(self, event: Event[EventStateChangedData]) -> None: 121 | """Register box sensor state change event.""" 122 | 123 | # Ignore state reports that aren't really a state change 124 | if not event.data["new_state"] or not event.data["old_state"]: 125 | return 126 | if event.data["new_state"].state == event.data["old_state"].state: 127 | return 128 | 129 | if self.delay: 130 | self.wasp = False 131 | self._attr_is_on = self.wasp 132 | self._attr_extra_state_attributes[ATTR_BOX] = event.data["new_state"].state 133 | self._attr_extra_state_attributes[ATTR_WASP] = STATE_OFF 134 | self.schedule_update_ha_state() 135 | self.hass.loop.call_soon_threadsafe( 136 | self.wasp_in_a_box_delayed, 137 | None, 138 | event.data["new_state"].state, 139 | ) 140 | else: 141 | self.wasp_in_a_box(box_state=event.data["new_state"].state) 142 | 143 | @callback 144 | def wasp_in_a_box_delayed( 145 | self, 146 | wasp_state: str | None = None, 147 | box_state: str | None = None, 148 | ) -> None: 149 | """Call Wasp In A Box Logic function after a delay.""" 150 | self.hass.loop.call_later(self.delay, self.wasp_in_a_box, wasp_state, box_state) 151 | 152 | def wasp_in_a_box( 153 | self, 154 | wasp_state: str | None = None, 155 | box_state: str | None = None, 156 | ) -> None: 157 | """Perform Wasp In A Box Logic.""" 158 | 159 | if not wasp_state: 160 | # Get Wasp State 161 | wasp_state = STATE_OFF 162 | for wasp_sensor in self._wasp_sensors: 163 | wasp_sensor_state = self.hass.states.get(wasp_sensor) 164 | if not wasp_sensor_state: 165 | continue 166 | if wasp_sensor_state.state == STATE_ON: 167 | wasp_state = STATE_ON 168 | break 169 | 170 | if not box_state: 171 | # Get Box State 172 | box_state = STATE_OFF 173 | for box_sensor in self._box_sensors: 174 | box_sensor_state = self.hass.states.get(box_sensor) 175 | if not box_sensor_state: 176 | continue 177 | if box_sensor_state.state == STATE_ON: 178 | box_state = STATE_ON 179 | break 180 | 181 | # Main Logic 182 | if wasp_state == STATE_ON: 183 | self.wasp = True 184 | elif box_state == STATE_ON: 185 | self.wasp = False 186 | 187 | self._attr_extra_state_attributes[ATTR_BOX] = box_state 188 | self._attr_extra_state_attributes[ATTR_WASP] = wasp_state 189 | 190 | self._attr_is_on = self.wasp 191 | self.schedule_update_ha_state() 192 | -------------------------------------------------------------------------------- /custom_components/magic_areas/cover.py: -------------------------------------------------------------------------------- 1 | """Cover controls for magic areas.""" 2 | 3 | import logging 4 | 5 | from homeassistant.components.cover import ( 6 | DEVICE_CLASSES as COVER_DEVICE_CLASSES, 7 | CoverDeviceClass, 8 | ) 9 | from homeassistant.components.cover.const import DOMAIN as COVER_DOMAIN 10 | from homeassistant.components.group.cover import CoverGroup 11 | from homeassistant.config_entries import ConfigEntry 12 | from homeassistant.core import HomeAssistant 13 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 14 | 15 | from custom_components.magic_areas.base.entities import MagicEntity 16 | from custom_components.magic_areas.base.magic import MagicArea 17 | from custom_components.magic_areas.const import ( 18 | CONF_FEATURE_COVER_GROUPS, 19 | EMPTY_STRING, 20 | MagicAreasFeatureInfoCoverGroups, 21 | ) 22 | from custom_components.magic_areas.helpers.area import get_area_from_config_entry 23 | from custom_components.magic_areas.util import cleanup_removed_entries 24 | 25 | _LOGGER = logging.getLogger(__name__) 26 | DEPENDENCIES = ["magic_areas"] 27 | 28 | 29 | async def async_setup_entry( 30 | hass: HomeAssistant, 31 | config_entry: ConfigEntry, 32 | async_add_entities: AddEntitiesCallback, 33 | ): 34 | """Set up the area cover config entry.""" 35 | 36 | area: MagicArea | None = get_area_from_config_entry(hass, config_entry) 37 | assert area is not None 38 | 39 | # Check feature availability 40 | if not area.has_feature(CONF_FEATURE_COVER_GROUPS): 41 | return 42 | 43 | # Check if there are any covers 44 | if not area.has_entities(COVER_DOMAIN): 45 | _LOGGER.debug("No %s entities for area %s", COVER_DOMAIN, area.name) 46 | return 47 | 48 | entities_to_add = [] 49 | 50 | # Append None to the list of device classes to catch those covers that 51 | # don't have a device class assigned (and put them in their own group) 52 | for device_class in [*COVER_DEVICE_CLASSES, None]: 53 | covers_in_device_class = [ 54 | e["entity_id"] 55 | for e in area.entities[COVER_DOMAIN] 56 | if e.get("device_class") == device_class 57 | ] 58 | 59 | if any(covers_in_device_class): 60 | _LOGGER.debug( 61 | "Creating %s cover group for %s with covers: %s", 62 | device_class, 63 | area.name, 64 | covers_in_device_class, 65 | ) 66 | entities_to_add.append(AreaCoverGroup(area, device_class)) 67 | 68 | if entities_to_add: 69 | async_add_entities(entities_to_add) 70 | 71 | if COVER_DOMAIN in area.magic_entities: 72 | cleanup_removed_entries( 73 | area.hass, entities_to_add, area.magic_entities[COVER_DOMAIN] 74 | ) 75 | 76 | 77 | class AreaCoverGroup(MagicEntity, CoverGroup): 78 | """Cover group for handling all the covers in the area.""" 79 | 80 | feature_info = MagicAreasFeatureInfoCoverGroups() 81 | 82 | def __init__(self, area: MagicArea, device_class: str) -> None: 83 | """Initialize the cover group.""" 84 | MagicEntity.__init__( 85 | self, area, domain=COVER_DOMAIN, translation_key=device_class 86 | ) 87 | sensor_device_class: CoverDeviceClass | None = ( 88 | CoverDeviceClass(device_class) if device_class else None 89 | ) 90 | self._attr_device_class = sensor_device_class 91 | self._entities = [ 92 | e 93 | for e in area.entities[COVER_DOMAIN] 94 | if e.get("device_class") == device_class 95 | ] 96 | CoverGroup.__init__( 97 | self, 98 | entities=[e["entity_id"] for e in self._entities], 99 | name=EMPTY_STRING, 100 | unique_id=self._attr_unique_id, 101 | ) 102 | delattr(self, "_attr_name") 103 | -------------------------------------------------------------------------------- /custom_components/magic_areas/fan.py: -------------------------------------------------------------------------------- 1 | """Fan groups & control for Magic Areas.""" 2 | 3 | import logging 4 | 5 | from homeassistant.components.fan import DOMAIN as FAN_DOMAIN 6 | from homeassistant.components.group.fan import FanGroup 7 | from homeassistant.config_entries import ConfigEntry 8 | from homeassistant.core import HomeAssistant 9 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 10 | 11 | from custom_components.magic_areas.base.entities import MagicEntity 12 | from custom_components.magic_areas.base.magic import MagicArea 13 | from custom_components.magic_areas.const import ( 14 | CONF_FEATURE_FAN_GROUPS, 15 | EMPTY_STRING, 16 | MagicAreasFeatureInfoFanGroups, 17 | ) 18 | from custom_components.magic_areas.helpers.area import get_area_from_config_entry 19 | from custom_components.magic_areas.util import cleanup_removed_entries 20 | 21 | _LOGGER = logging.getLogger(__name__) 22 | 23 | 24 | async def async_setup_entry( 25 | hass: HomeAssistant, 26 | config_entry: ConfigEntry, 27 | async_add_entities: AddEntitiesCallback, 28 | ): 29 | """Set up the Area config entry.""" 30 | 31 | area: MagicArea | None = get_area_from_config_entry(hass, config_entry) 32 | assert area is not None 33 | 34 | # Check feature availability 35 | if not area.has_feature(CONF_FEATURE_FAN_GROUPS): 36 | return [] 37 | 38 | # Check if there are any fan entities 39 | if not area.has_entities(FAN_DOMAIN): 40 | _LOGGER.debug("%s: No %s entities for area.", area.name, FAN_DOMAIN) 41 | return 42 | 43 | fan_entities: list[str] = [e["entity_id"] for e in area.entities[FAN_DOMAIN]] 44 | 45 | try: 46 | fan_groups: list[AreaFanGroup] = [AreaFanGroup(area, fan_entities)] 47 | if fan_groups: 48 | async_add_entities(fan_groups) 49 | except Exception as e: # pylint: disable=broad-exception-caught 50 | _LOGGER.error( 51 | "%s: Error creating fan group: %s", 52 | area.slug, 53 | str(e), 54 | ) 55 | 56 | if FAN_DOMAIN in area.magic_entities: 57 | cleanup_removed_entries(area.hass, fan_groups, area.magic_entities[FAN_DOMAIN]) 58 | 59 | 60 | class AreaFanGroup(MagicEntity, FanGroup): 61 | """Fan Group.""" 62 | 63 | feature_info = MagicAreasFeatureInfoFanGroups() 64 | 65 | def __init__(self, area: MagicArea, entities: list[str]) -> None: 66 | """Init the fan group for the area.""" 67 | MagicEntity.__init__(self, area=area, domain=FAN_DOMAIN) 68 | FanGroup.__init__( 69 | self, 70 | entities=entities, 71 | name=EMPTY_STRING, 72 | unique_id=self.unique_id, 73 | ) 74 | 75 | delattr(self, "_attr_name") 76 | -------------------------------------------------------------------------------- /custom_components/magic_areas/helpers/__init__.py: -------------------------------------------------------------------------------- 1 | """Provides helper functions for Magic Areas.""" 2 | -------------------------------------------------------------------------------- /custom_components/magic_areas/helpers/area.py: -------------------------------------------------------------------------------- 1 | """Magic Areas Area Helper Functions. 2 | 3 | Small helper functions for area and Magic Area objects. 4 | """ 5 | 6 | import logging 7 | 8 | from homeassistant.config_entries import ConfigEntry 9 | from homeassistant.const import ATTR_ID, ATTR_NAME 10 | from homeassistant.core import HomeAssistant 11 | from homeassistant.helpers.area_registry import ( 12 | AreaEntry, 13 | async_get as areareg_async_get, 14 | ) 15 | from homeassistant.helpers.floor_registry import ( 16 | FloorEntry, 17 | async_get as floorreg_async_get, 18 | ) 19 | 20 | from custom_components.magic_areas.base.magic import BasicArea, MagicArea, MagicMetaArea 21 | from custom_components.magic_areas.const import ( 22 | DATA_AREA_OBJECT, 23 | MODULE_DATA, 24 | MetaAreaIcons, 25 | MetaAreaType, 26 | ) 27 | 28 | _LOGGER = logging.getLogger(__name__) 29 | 30 | 31 | def basic_area_from_meta(area_id: str, name: str | None = None) -> BasicArea: 32 | """Create a BasicArea from a name.""" 33 | 34 | basic_area = BasicArea() 35 | if not name: 36 | basic_area.name = area_id.capitalize() 37 | basic_area.id = area_id 38 | basic_area.is_meta = True 39 | 40 | meta_area_icon_map: dict[str, str] = {} 41 | meta_area_icon_map = { 42 | MetaAreaType.EXTERIOR.value: MetaAreaIcons.EXTERIOR.value, 43 | MetaAreaType.INTERIOR.value: MetaAreaIcons.INTERIOR.value, 44 | MetaAreaType.GLOBAL.value: MetaAreaIcons.GLOBAL.value, 45 | } 46 | 47 | basic_area.icon = meta_area_icon_map.get(area_id, None) 48 | 49 | return basic_area 50 | 51 | 52 | def basic_area_from_object(area: AreaEntry) -> BasicArea: 53 | """Create a BasicArea from an AreaEntry object.""" 54 | 55 | basic_area = BasicArea() 56 | basic_area.name = area.name 57 | basic_area.id = area.id 58 | basic_area.icon = area.icon 59 | basic_area.floor_id = area.floor_id 60 | 61 | return basic_area 62 | 63 | 64 | def basic_area_from_floor(floor: FloorEntry) -> BasicArea: 65 | """Create a BasicArea from an AreaEntry object.""" 66 | 67 | basic_area = BasicArea() 68 | basic_area.name = floor.name 69 | basic_area.id = floor.floor_id 70 | default_icon = ( 71 | f"mdi:home-floor-{floor.level}" # noqa: E231 72 | if floor.level is not None 73 | else "mdi:home" # noqa: E231 74 | ) 75 | basic_area.icon = floor.icon or default_icon 76 | basic_area.floor_id = floor.floor_id 77 | basic_area.is_meta = True 78 | 79 | return basic_area 80 | 81 | 82 | def get_magic_area_for_config_entry( 83 | hass: HomeAssistant, config_entry: ConfigEntry 84 | ) -> MagicArea | None: 85 | """Return magic area object for given config entry.""" 86 | 87 | area_id = config_entry.data[ATTR_ID] 88 | area_name = config_entry.data[ATTR_NAME] 89 | 90 | magic_area: MagicArea | None = None 91 | 92 | _LOGGER.debug("%s: Setting up entry.", area_name) 93 | 94 | # Load floors 95 | floor_registry = floorreg_async_get(hass) 96 | floors = floor_registry.async_list_floors() 97 | 98 | non_floor_meta_ids = [ 99 | meta_area_type 100 | for meta_area_type in MetaAreaType 101 | if meta_area_type != MetaAreaType.FLOOR 102 | ] 103 | floor_ids = [f.floor_id for f in floors] 104 | 105 | if area_id in non_floor_meta_ids: 106 | # Non-floor Meta-Area (Global/Interior/Exterior) 107 | meta_area = basic_area_from_meta(area_id) 108 | magic_area = MagicMetaArea(hass, meta_area, config_entry) 109 | elif area_id in floor_ids: 110 | # Floor Meta-Area 111 | floor_entry: FloorEntry | None = floor_registry.async_get_floor(area_id) 112 | assert floor_entry is not None 113 | meta_area = basic_area_from_floor(floor_entry) 114 | magic_area = MagicMetaArea(hass, meta_area, config_entry) 115 | else: 116 | # Regular Area 117 | area_registry = areareg_async_get(hass) 118 | area = area_registry.async_get_area(area_id) 119 | 120 | if not area: 121 | _LOGGER.warning("%s: ID '%s' not found on registry", area_name, area_id) 122 | return None 123 | 124 | _LOGGER.debug("%s: Got area from registry: %s", area_name, str(area)) 125 | 126 | magic_area = MagicArea( 127 | hass, 128 | basic_area_from_object(area), 129 | config_entry, 130 | ) 131 | 132 | return magic_area 133 | 134 | 135 | def get_area_from_config_entry( 136 | hass: HomeAssistant, config_entry: ConfigEntry 137 | ) -> MagicArea | MagicMetaArea | None: 138 | """Return area object for given config entry.""" 139 | 140 | if config_entry.entry_id not in hass.data[MODULE_DATA]: 141 | return None 142 | 143 | return hass.data[MODULE_DATA][config_entry.entry_id][DATA_AREA_OBJECT] 144 | -------------------------------------------------------------------------------- /custom_components/magic_areas/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "magic_areas", 3 | "name": "Magic Areas", 4 | "after_dependencies": [ 5 | "template", 6 | "group", 7 | "fan", 8 | "climate", 9 | "binary_sensor", 10 | "sensor", 11 | "light", 12 | "media_player", 13 | "device_tracker", 14 | "discovery" 15 | ], 16 | "codeowners": [ 17 | "@jseidl" 18 | ], 19 | "config_flow": true, 20 | "dependencies": [], 21 | "documentation": "https://github.com/jseidl/hass-magic_areas/wiki", 22 | "iot_class": "calculated", 23 | "issue_tracker": "https://github.com/jseidl/hass-magic_areas/issues", 24 | "requirements": [], 25 | "version": "4.3.0" 26 | } 27 | -------------------------------------------------------------------------------- /custom_components/magic_areas/media_player/__init__.py: -------------------------------------------------------------------------------- 1 | """Platform file for Magic Area's media_player entities.""" 2 | 3 | import logging 4 | 5 | from homeassistant.components.group.media_player import MediaPlayerGroup 6 | from homeassistant.components.media_player.const import DOMAIN as MEDIA_PLAYER_DOMAIN 7 | 8 | from custom_components.magic_areas.base.entities import MagicEntity 9 | from custom_components.magic_areas.base.magic import MagicArea 10 | from custom_components.magic_areas.const import ( 11 | CONF_FEATURE_AREA_AWARE_MEDIA_PLAYER, 12 | CONF_FEATURE_MEDIA_PLAYER_GROUPS, 13 | CONF_NOTIFICATION_DEVICES, 14 | DATA_AREA_OBJECT, 15 | EMPTY_STRING, 16 | META_AREA_GLOBAL, 17 | MODULE_DATA, 18 | MagicAreasFeatureInfoMediaPlayerGroups, 19 | ) 20 | from custom_components.magic_areas.helpers.area import get_area_from_config_entry 21 | from custom_components.magic_areas.media_player.area_aware_media_player import ( 22 | AreaAwareMediaPlayer, 23 | ) 24 | from custom_components.magic_areas.util import cleanup_removed_entries 25 | 26 | _LOGGER = logging.getLogger(__name__) 27 | 28 | 29 | async def async_setup_entry(hass, config_entry, async_add_entities): 30 | """Set up the area media player config entry.""" 31 | 32 | area: MagicArea | None = get_area_from_config_entry(hass, config_entry) 33 | assert area is not None 34 | 35 | entities_to_add: list[AreaAwareMediaPlayer | AreaMediaPlayerGroup] = [] 36 | 37 | # Media Player Groups 38 | if area.has_feature(CONF_FEATURE_MEDIA_PLAYER_GROUPS): 39 | _LOGGER.debug("%s: Setting up media player groups.", area.name) 40 | entities_to_add.extend(setup_media_player_group(area)) 41 | 42 | # Check if we are the Global Meta Area 43 | if area.is_meta() and area.id == META_AREA_GLOBAL.lower(): 44 | # Try to setup AAMP 45 | _LOGGER.debug("%s: Setting up Area-Aware media player", area.name) 46 | entities_to_add.extend(setup_area_aware_media_player(area)) 47 | 48 | if entities_to_add: 49 | async_add_entities(entities_to_add) 50 | 51 | if MEDIA_PLAYER_DOMAIN in area.magic_entities: 52 | cleanup_removed_entries( 53 | area.hass, entities_to_add, area.magic_entities[MEDIA_PLAYER_DOMAIN] 54 | ) 55 | 56 | 57 | def setup_media_player_group(area): 58 | """Create the media player groups.""" 59 | # Check if there are any media player devices 60 | if not area.has_entities(MEDIA_PLAYER_DOMAIN): 61 | _LOGGER.debug("%s: No %s entities.", area.name, MEDIA_PLAYER_DOMAIN) 62 | return [] 63 | 64 | media_player_entities = [e["entity_id"] for e in area.entities[MEDIA_PLAYER_DOMAIN]] 65 | 66 | return [AreaMediaPlayerGroup(area, media_player_entities)] 67 | 68 | 69 | def setup_area_aware_media_player(area): 70 | """Create Area-aware media player.""" 71 | ma_data = area.hass.data[MODULE_DATA] 72 | 73 | # Check if we have areas with MEDIA_PLAYER_DOMAIN entities 74 | areas_with_media_players = [] 75 | 76 | for entry in ma_data.values(): 77 | current_area = entry[DATA_AREA_OBJECT] 78 | 79 | # Skip meta areas 80 | if current_area.is_meta(): 81 | _LOGGER.debug("%s: Is meta-area, skipping.", current_area.name) 82 | continue 83 | 84 | # Skip areas with feature not enabled 85 | if not current_area.has_feature(CONF_FEATURE_AREA_AWARE_MEDIA_PLAYER): 86 | _LOGGER.debug( 87 | "%s: Does not have Area-aware media player feature enabled, skipping.", 88 | current_area.name, 89 | ) 90 | continue 91 | 92 | # Skip areas without media player entities 93 | if not current_area.has_entities(MEDIA_PLAYER_DOMAIN): 94 | _LOGGER.debug( 95 | "%s: Has no media player entities, skipping.", current_area.name 96 | ) 97 | continue 98 | 99 | # Skip areas without notification devices set 100 | notification_devices = current_area.feature_config( 101 | CONF_FEATURE_AREA_AWARE_MEDIA_PLAYER 102 | ).get(CONF_NOTIFICATION_DEVICES) 103 | 104 | if not notification_devices: 105 | _LOGGER.debug( 106 | "%s: Has no notification devices, skipping.", current_area.name 107 | ) 108 | continue 109 | 110 | # If all passes, we add this valid area to the list 111 | areas_with_media_players.append(current_area) 112 | 113 | if not areas_with_media_players: 114 | _LOGGER.debug( 115 | "No areas with %s entities. Skipping creation of area-aware-media-player", 116 | MEDIA_PLAYER_DOMAIN, 117 | ) 118 | return [] 119 | 120 | area_names = [i.name for i in areas_with_media_players] 121 | 122 | _LOGGER.debug( 123 | "%s: Setting up area-aware media player with areas: %s", area.name, area_names 124 | ) 125 | 126 | return [AreaAwareMediaPlayer(area, areas_with_media_players)] 127 | 128 | 129 | class AreaMediaPlayerGroup(MagicEntity, MediaPlayerGroup): 130 | """Media player group.""" 131 | 132 | feature_info = MagicAreasFeatureInfoMediaPlayerGroups() 133 | 134 | def __init__(self, area, entities): 135 | """Initialize media player group.""" 136 | MagicEntity.__init__(self, area, domain=MEDIA_PLAYER_DOMAIN) 137 | MediaPlayerGroup.__init__( 138 | self, 139 | name=EMPTY_STRING, 140 | unique_id=self._attr_unique_id, 141 | entities=entities, 142 | ) 143 | -------------------------------------------------------------------------------- /custom_components/magic_areas/media_player/area_aware_media_player.py: -------------------------------------------------------------------------------- 1 | """Area aware media player, media player component.""" 2 | 3 | import logging 4 | 5 | from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN 6 | from homeassistant.components.media_player import MediaPlayerEntity 7 | from homeassistant.components.media_player.const import ( 8 | ATTR_MEDIA_CONTENT_ID, 9 | ATTR_MEDIA_CONTENT_TYPE, 10 | DOMAIN as MEDIA_PLAYER_DOMAIN, 11 | SERVICE_PLAY_MEDIA, 12 | MediaPlayerEntityFeature, 13 | ) 14 | from homeassistant.const import ATTR_ENTITY_ID, STATE_IDLE, STATE_ON 15 | 16 | from custom_components.magic_areas.base.entities import MagicEntity 17 | from custom_components.magic_areas.const import ( 18 | CONF_NOTIFICATION_DEVICES, 19 | CONF_NOTIFY_STATES, 20 | DEFAULT_NOTIFICATION_DEVICES, 21 | DEFAULT_NOTIFY_STATES, 22 | AreaStates, 23 | MagicAreasFeatureInfoAreaAwareMediaPlayer, 24 | MagicAreasFeatures, 25 | ) 26 | 27 | _LOGGER = logging.getLogger(__name__) 28 | 29 | 30 | class AreaAwareMediaPlayer(MagicEntity, MediaPlayerEntity): 31 | """Area-aware media player.""" 32 | 33 | feature_info = MagicAreasFeatureInfoAreaAwareMediaPlayer() 34 | 35 | def __init__(self, area, areas): 36 | """Initialize area-aware media player.""" 37 | MagicEntity.__init__(self, area, domain=MEDIA_PLAYER_DOMAIN) 38 | MediaPlayerEntity.__init__(self) 39 | 40 | self._attr_extra_state_attributes = {} 41 | self._state = STATE_IDLE 42 | 43 | self.areas = areas 44 | self.area = area 45 | self._tracked_entities = [] 46 | 47 | for area_obj in self.areas: 48 | entity_list = self.get_media_players_for_area(area_obj) 49 | if entity_list: 50 | self._tracked_entities.extend(entity_list) 51 | 52 | _LOGGER.info("AreaAwareMediaPlayer loaded.") 53 | 54 | def update_attributes(self): 55 | """Update entity attributes.""" 56 | self._attr_extra_state_attributes["areas"] = [ 57 | f"{BINARY_SENSOR_DOMAIN}.magic_areas_presence_tracking_{area.slug}_area_state" 58 | for area in self.areas 59 | ] 60 | self._attr_extra_state_attributes["entity_id"] = self._tracked_entities 61 | 62 | def get_media_players_for_area(self, area): 63 | """Return media players for a given area.""" 64 | entity_ids = [] 65 | 66 | notification_devices = area.feature_config( 67 | MagicAreasFeatures.AREA_AWARE_MEDIA_PLAYER 68 | ).get(CONF_NOTIFICATION_DEVICES, DEFAULT_NOTIFICATION_DEVICES) 69 | 70 | _LOGGER.debug("%s: Notification devices: %s", area.name, notification_devices) 71 | 72 | area_media_players = [ 73 | entity["entity_id"] for entity in area.entities[MEDIA_PLAYER_DOMAIN] 74 | ] 75 | 76 | # Check if media_player entities are notification devices 77 | for mp in area_media_players: 78 | if mp in notification_devices: 79 | entity_ids.append(mp) 80 | 81 | return set(entity_ids) 82 | 83 | async def async_added_to_hass(self): 84 | """Call when entity about to be added to hass.""" 85 | 86 | last_state = await self.async_get_last_state() 87 | 88 | if last_state: 89 | _LOGGER.debug( 90 | "%s: Nedia Player restored [state=%s]", self.name, last_state.state 91 | ) 92 | self._state = last_state.state 93 | else: 94 | self._state = STATE_IDLE 95 | 96 | self.set_state() 97 | 98 | @property 99 | def state(self): 100 | """Return the state of the media player.""" 101 | return self._state 102 | 103 | @property 104 | def supported_features(self): 105 | """Flag media player features that are supported.""" 106 | return ( 107 | MediaPlayerEntityFeature.PLAY_MEDIA 108 | | MediaPlayerEntityFeature.MEDIA_ANNOUNCE 109 | ) 110 | 111 | def get_active_areas(self): 112 | """Return areas that are occupied.""" 113 | active_areas = [] 114 | 115 | for area in self.areas: 116 | area_binary_sensor_name = f"{BINARY_SENSOR_DOMAIN}.magic_areas_presence_tracking_{area.slug}_area_state" 117 | area_binary_sensor_state = self.hass.states.get(area_binary_sensor_name) 118 | 119 | if not area_binary_sensor_state: 120 | _LOGGER.debug( 121 | "%s: No state found for entity '%s'", 122 | self.name, 123 | area_binary_sensor_name, 124 | ) 125 | continue 126 | 127 | # Ignore not occupied areas 128 | if area_binary_sensor_state.state != STATE_ON: 129 | continue 130 | 131 | # Check notification states 132 | notification_states = area.feature_config( 133 | MagicAreasFeatures.AREA_AWARE_MEDIA_PLAYER 134 | ).get(CONF_NOTIFY_STATES, DEFAULT_NOTIFY_STATES) 135 | 136 | # Check sleep 137 | if area.has_state(AreaStates.SLEEP) and ( 138 | AreaStates.SLEEP not in notification_states 139 | ): 140 | continue 141 | 142 | # Check other states 143 | has_valid_state = False 144 | for notification_state in notification_states: 145 | if area.has_state(notification_state): 146 | has_valid_state = True 147 | 148 | # Append area 149 | if has_valid_state: 150 | active_areas.append(area) 151 | 152 | return active_areas 153 | 154 | def update_state(self): 155 | """Update entity state and attributes.""" 156 | self.update_attributes() 157 | self.schedule_update_ha_state() 158 | 159 | def set_state(self, state=None): 160 | """Set the entity state.""" 161 | if state: 162 | self._state = state 163 | self.update_state() 164 | 165 | async def async_play_media(self, media_type, media_id, **kwargs) -> None: 166 | """Forward a piece of media to media players in active areas.""" 167 | 168 | # Read active areas 169 | active_areas = self.get_active_areas() 170 | 171 | # Fail early 172 | if not active_areas: 173 | _LOGGER.info("No areas active. Ignoring.") 174 | return 175 | 176 | # Gather media_player entities 177 | media_players = [] 178 | for area in active_areas: 179 | media_players.extend(self.get_media_players_for_area(area)) 180 | 181 | if not media_players: 182 | _LOGGER.info( 183 | "%s: No media_player entities to forward. Ignoring.", self.name 184 | ) 185 | return 186 | 187 | data = { 188 | ATTR_MEDIA_CONTENT_ID: media_id, 189 | ATTR_MEDIA_CONTENT_TYPE: media_type, 190 | ATTR_ENTITY_ID: media_players, 191 | } 192 | if kwargs: 193 | data.update(kwargs) 194 | 195 | await self.hass.services.async_call( 196 | MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, data 197 | ) 198 | -------------------------------------------------------------------------------- /custom_components/magic_areas/sensor/__init__.py: -------------------------------------------------------------------------------- 1 | """Sensor controls for magic areas.""" 2 | 3 | from collections import Counter 4 | import logging 5 | 6 | from homeassistant.components.sensor.const import DOMAIN as SENSOR_DOMAIN 7 | from homeassistant.config_entries import ConfigEntry 8 | from homeassistant.const import ( 9 | ATTR_DEVICE_CLASS, 10 | ATTR_ENTITY_ID, 11 | ATTR_UNIT_OF_MEASUREMENT, 12 | ) 13 | from homeassistant.core import HomeAssistant 14 | from homeassistant.helpers.entity import Entity 15 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 16 | 17 | from custom_components.magic_areas.base.magic import MagicArea 18 | from custom_components.magic_areas.const import ( 19 | CONF_AGGREGATES_MIN_ENTITIES, 20 | CONF_AGGREGATES_SENSOR_DEVICE_CLASSES, 21 | CONF_FEATURE_AGGREGATION, 22 | DEFAULT_AGGREGATES_MIN_ENTITIES, 23 | DEFAULT_AGGREGATES_SENSOR_DEVICE_CLASSES, 24 | MagicAreasFeatureInfoAggregates, 25 | ) 26 | from custom_components.magic_areas.helpers.area import get_area_from_config_entry 27 | from custom_components.magic_areas.sensor.base import AreaSensorGroupSensor 28 | from custom_components.magic_areas.util import cleanup_removed_entries 29 | 30 | _LOGGER = logging.getLogger(__name__) 31 | 32 | 33 | async def async_setup_entry( 34 | hass: HomeAssistant, 35 | config_entry: ConfigEntry, 36 | async_add_entities: AddEntitiesCallback, 37 | ): 38 | """Set up the area sensor config entry.""" 39 | 40 | area: MagicArea | None = get_area_from_config_entry(hass, config_entry) 41 | assert area is not None 42 | 43 | entities_to_add = [] 44 | 45 | if area.has_feature(CONF_FEATURE_AGGREGATION): 46 | entities_to_add.extend(create_aggregate_sensors(area)) 47 | 48 | if entities_to_add: 49 | async_add_entities(entities_to_add) 50 | 51 | if SENSOR_DOMAIN in area.magic_entities: 52 | cleanup_removed_entries( 53 | area.hass, entities_to_add, area.magic_entities[SENSOR_DOMAIN] 54 | ) 55 | 56 | 57 | def create_aggregate_sensors(area: MagicArea) -> list[Entity]: 58 | """Create the aggregate sensors for the area.""" 59 | 60 | eligible_entities: dict[str, list[str]] = {} 61 | unit_of_measurement_map: dict[str, list[str]] = {} 62 | 63 | aggregates = [] 64 | 65 | if SENSOR_DOMAIN not in area.entities: 66 | return [] 67 | 68 | if not area.has_feature(CONF_FEATURE_AGGREGATION): 69 | return [] 70 | 71 | for entity in area.entities[SENSOR_DOMAIN]: 72 | entity_state = area.hass.states.get(entity[ATTR_ENTITY_ID]) 73 | if not entity_state: 74 | continue 75 | 76 | if ( 77 | ATTR_DEVICE_CLASS not in entity_state.attributes 78 | or not entity_state.attributes[ATTR_DEVICE_CLASS] 79 | ): 80 | _LOGGER.debug( 81 | "Entity %s does not have device_class defined", 82 | entity[ATTR_ENTITY_ID], 83 | ) 84 | continue 85 | 86 | if ( 87 | ATTR_UNIT_OF_MEASUREMENT not in entity_state.attributes 88 | or not entity_state.attributes[ATTR_UNIT_OF_MEASUREMENT] 89 | ): 90 | _LOGGER.debug( 91 | "Entity %s does not have unit_of_measurement defined", 92 | entity[ATTR_ENTITY_ID], 93 | ) 94 | continue 95 | 96 | device_class = entity_state.attributes[ATTR_DEVICE_CLASS] 97 | 98 | # Dictionary of sensors by device class. 99 | if device_class not in eligible_entities: 100 | eligible_entities[device_class] = [] 101 | 102 | # Dictionary of seen unit of measurements by device class. 103 | if device_class not in unit_of_measurement_map: 104 | unit_of_measurement_map[device_class] = [] 105 | 106 | unit_of_measurement_map[device_class].append( 107 | entity_state.attributes[ATTR_UNIT_OF_MEASUREMENT] 108 | ) 109 | eligible_entities[device_class].append(entity[ATTR_ENTITY_ID]) 110 | 111 | # Create aggregates 112 | for device_class, entities in eligible_entities.items(): 113 | if len(entities) < area.feature_config(CONF_FEATURE_AGGREGATION).get( 114 | CONF_AGGREGATES_MIN_ENTITIES, DEFAULT_AGGREGATES_MIN_ENTITIES 115 | ): 116 | continue 117 | 118 | if device_class not in area.feature_config(CONF_FEATURE_AGGREGATION).get( 119 | CONF_AGGREGATES_SENSOR_DEVICE_CLASSES, 120 | DEFAULT_AGGREGATES_SENSOR_DEVICE_CLASSES, 121 | ): 122 | continue 123 | 124 | _LOGGER.debug( 125 | "%s: Creating aggregate sensor for device_class '%s' with %d entities", 126 | area.slug, 127 | device_class, 128 | len(entities), 129 | ) 130 | 131 | try: 132 | # Infer most-popular unit of measurement 133 | unit_of_measurements = Counter(unit_of_measurement_map[device_class]) 134 | most_common_unit_of_measurement = unit_of_measurements.most_common(1)[0][0] 135 | 136 | aggregates.append( 137 | AreaAggregateSensor( 138 | area=area, 139 | device_class=device_class, 140 | entity_ids=entities, 141 | unit_of_measurement=most_common_unit_of_measurement, 142 | ) 143 | ) 144 | except Exception as e: # pylint: disable=broad-exception-caught 145 | _LOGGER.error( 146 | "%s: Error creating '%s' aggregate sensor: %s", 147 | area.slug, 148 | device_class, 149 | str(e), 150 | ) 151 | 152 | return aggregates 153 | 154 | 155 | class AreaAggregateSensor(AreaSensorGroupSensor): 156 | """Aggregate sensor for the area.""" 157 | 158 | feature_info = MagicAreasFeatureInfoAggregates() 159 | -------------------------------------------------------------------------------- /custom_components/magic_areas/sensor/base.py: -------------------------------------------------------------------------------- 1 | """Base classes for sensor component.""" 2 | 3 | import logging 4 | 5 | from homeassistant.components.group.sensor import ATTR_MEAN, ATTR_SUM, SensorGroup 6 | from homeassistant.components.sensor.const import ( 7 | DOMAIN as SENSOR_DOMAIN, 8 | SensorDeviceClass, 9 | SensorStateClass, 10 | ) 11 | 12 | from custom_components.magic_areas.base.entities import MagicEntity 13 | from custom_components.magic_areas.base.magic import MagicArea 14 | from custom_components.magic_areas.const import ( 15 | AGGREGATE_MODE_SUM, 16 | AGGREGATE_MODE_TOTAL_INCREASING_SENSOR, 17 | AGGREGATE_MODE_TOTAL_SENSOR, 18 | DEFAULT_SENSOR_PRECISION, 19 | EMPTY_STRING, 20 | ) 21 | 22 | _LOGGER = logging.getLogger(__name__) 23 | 24 | 25 | class AreaSensorGroupSensor(MagicEntity, SensorGroup): 26 | """Sensor for the magic area, group sensor with all the stuff in it.""" 27 | 28 | def __init__( 29 | self, 30 | area: MagicArea, 31 | device_class: str, 32 | entity_ids: list[str], 33 | unit_of_measurement: str, 34 | ) -> None: 35 | """Initialize an area sensor group sensor.""" 36 | 37 | MagicEntity.__init__( 38 | self, area=area, domain=SENSOR_DOMAIN, translation_key=device_class 39 | ) 40 | 41 | final_unit_of_measurement = None 42 | 43 | # Resolve unit of measurement 44 | unit_attr_name = f"{device_class}_unit" 45 | if hasattr(area.hass.config.units, unit_attr_name): 46 | final_unit_of_measurement = getattr(area.hass.config.units, unit_attr_name) 47 | else: 48 | final_unit_of_measurement = unit_of_measurement 49 | 50 | self._attr_suggested_display_precision = DEFAULT_SENSOR_PRECISION 51 | 52 | sensor_device_class: SensorDeviceClass | None = ( 53 | SensorDeviceClass(device_class) if device_class else None 54 | ) 55 | self.device_class = sensor_device_class 56 | 57 | state_class = SensorStateClass.MEASUREMENT 58 | 59 | if device_class in AGGREGATE_MODE_TOTAL_INCREASING_SENSOR: 60 | state_class = SensorStateClass.TOTAL_INCREASING 61 | elif device_class in AGGREGATE_MODE_TOTAL_SENSOR: 62 | state_class = SensorStateClass.TOTAL 63 | 64 | SensorGroup.__init__( 65 | self, 66 | hass=area.hass, 67 | device_class=sensor_device_class, 68 | entity_ids=entity_ids, 69 | ignore_non_numeric=True, 70 | sensor_type=ATTR_SUM if device_class in AGGREGATE_MODE_SUM else ATTR_MEAN, 71 | state_class=state_class, 72 | unit_of_measurement=final_unit_of_measurement, 73 | name=EMPTY_STRING, 74 | unique_id=self._attr_unique_id, 75 | ) 76 | delattr(self, "_attr_name") 77 | -------------------------------------------------------------------------------- /custom_components/magic_areas/switch/__init__.py: -------------------------------------------------------------------------------- 1 | """Platform file for Magic Area's switch entities.""" 2 | 3 | import logging 4 | 5 | from homeassistant.components.switch.const import DOMAIN as SWITCH_DOMAIN 6 | from homeassistant.config_entries import ConfigEntry 7 | from homeassistant.const import EntityCategory 8 | from homeassistant.core import HomeAssistant 9 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 10 | 11 | from custom_components.magic_areas.base.magic import MagicArea 12 | from custom_components.magic_areas.const import ( 13 | MagicAreasFeatureInfoLightGroups, 14 | MagicAreasFeatures, 15 | ) 16 | from custom_components.magic_areas.helpers.area import get_area_from_config_entry 17 | from custom_components.magic_areas.switch.base import SwitchBase 18 | from custom_components.magic_areas.switch.climate_control import ClimateControlSwitch 19 | from custom_components.magic_areas.switch.fan_control import FanControlSwitch 20 | from custom_components.magic_areas.switch.media_player_control import ( 21 | MediaPlayerControlSwitch, 22 | ) 23 | from custom_components.magic_areas.switch.presence_hold import PresenceHoldSwitch 24 | from custom_components.magic_areas.util import cleanup_removed_entries 25 | 26 | _LOGGER = logging.getLogger(__name__) 27 | 28 | 29 | async def async_setup_entry( 30 | hass: HomeAssistant, 31 | config_entry: ConfigEntry, 32 | async_add_entities: AddEntitiesCallback, 33 | ): 34 | """Set up the area switch config entry.""" 35 | 36 | area: MagicArea | None = get_area_from_config_entry(hass, config_entry) 37 | assert area is not None 38 | 39 | switch_entities = [] 40 | 41 | if area.has_feature(MagicAreasFeatures.PRESENCE_HOLD) and not area.is_meta(): 42 | try: 43 | switch_entities.append(PresenceHoldSwitch(area)) 44 | except Exception as e: # pylint: disable=broad-exception-caught 45 | _LOGGER.error( 46 | "%s: Error loading presence hold switch: %s", area.name, str(e) 47 | ) 48 | 49 | if area.has_feature(MagicAreasFeatures.LIGHT_GROUPS) and not area.is_meta(): 50 | try: 51 | switch_entities.append(LightControlSwitch(area)) 52 | except Exception as e: # pylint: disable=broad-exception-caught 53 | _LOGGER.error( 54 | "%s: Error loading light control switch: %s", area.name, str(e) 55 | ) 56 | 57 | if area.has_feature(MagicAreasFeatures.MEDIA_PLAYER_GROUPS) and not area.is_meta(): 58 | try: 59 | switch_entities.append(MediaPlayerControlSwitch(area)) 60 | except Exception as e: # pylint: disable=broad-exception-caught 61 | _LOGGER.error( 62 | "%s: Error loading media player control switch: %s", area.name, str(e) 63 | ) 64 | 65 | if area.has_feature(MagicAreasFeatures.FAN_GROUPS) and not area.is_meta(): 66 | try: 67 | switch_entities.append(FanControlSwitch(area)) 68 | except Exception as e: # pylint: disable=broad-exception-caught 69 | _LOGGER.error("%s: Error loading fan control switch: %s", area.name, str(e)) 70 | 71 | if area.has_feature(MagicAreasFeatures.CLIMATE_CONTROL): 72 | try: 73 | switch_entities.append(ClimateControlSwitch(area)) 74 | except Exception as e: # pylint: disable=broad-exception-caught 75 | _LOGGER.error( 76 | "%s: Error loading climate control switch: %s", area.name, str(e) 77 | ) 78 | 79 | if switch_entities: 80 | async_add_entities(switch_entities) 81 | 82 | if SWITCH_DOMAIN in area.magic_entities: 83 | cleanup_removed_entries( 84 | area.hass, switch_entities, area.magic_entities[SWITCH_DOMAIN] 85 | ) 86 | 87 | 88 | class LightControlSwitch(SwitchBase): 89 | """Switch to enable/disable light control.""" 90 | 91 | feature_info = MagicAreasFeatureInfoLightGroups() 92 | _attr_entity_category = EntityCategory.CONFIG 93 | -------------------------------------------------------------------------------- /custom_components/magic_areas/switch/base.py: -------------------------------------------------------------------------------- 1 | """Base classes for switch.""" 2 | 3 | from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity 4 | from homeassistant.components.switch.const import DOMAIN as SWITCH_DOMAIN 5 | from homeassistant.const import STATE_OFF, STATE_ON 6 | from homeassistant.helpers.event import async_call_later 7 | 8 | from custom_components.magic_areas.base.entities import MagicEntity 9 | from custom_components.magic_areas.base.magic import MagicArea 10 | from custom_components.magic_areas.const import ONE_MINUTE 11 | 12 | 13 | class SwitchBase(MagicEntity, SwitchEntity): 14 | """The base class for all the switches.""" 15 | 16 | _attr_state: str 17 | 18 | def __init__(self, area: MagicArea) -> None: 19 | """Initialize the base switch bits, basic just a mixin for the two types.""" 20 | MagicEntity.__init__(self, area, domain=SWITCH_DOMAIN) 21 | SwitchEntity.__init__(self) 22 | self._attr_device_class = SwitchDeviceClass.SWITCH 23 | self._attr_should_poll = False 24 | self._attr_is_on = False 25 | 26 | async def async_added_to_hass(self) -> None: 27 | """Call when entity about to be added to hass.""" 28 | await super().async_added_to_hass() 29 | 30 | # Restore state 31 | last_state = await self.async_get_last_state() 32 | if last_state: 33 | self._attr_is_on = last_state.state == STATE_ON 34 | self._attr_extra_state_attributes = dict(last_state.attributes) 35 | 36 | self.async_write_ha_state() 37 | self.schedule_update_ha_state() 38 | 39 | async def async_turn_on(self, **kwargs) -> None: 40 | """Turn on presence hold.""" 41 | self._attr_state = STATE_ON 42 | self._attr_is_on = True 43 | self.schedule_update_ha_state() 44 | 45 | async def async_turn_off(self, **kwargs) -> None: 46 | """Turn off presence hold.""" 47 | self._attr_state = STATE_OFF 48 | self._attr_is_on = False 49 | self.schedule_update_ha_state() 50 | 51 | 52 | class ResettableSwitchBase(SwitchBase): 53 | """Control the presense/state from being changed for the device.""" 54 | 55 | timeout: int 56 | 57 | def __init__(self, area: MagicArea, timeout: int = 0) -> None: 58 | """Initialize the switch.""" 59 | super().__init__(area) 60 | 61 | self.timeout = timeout 62 | self._timeout_callback = None 63 | 64 | self.async_on_remove(self._clear_timers) 65 | 66 | def _clear_timers(self) -> None: 67 | """Remove the timer on entity removal.""" 68 | if self._timeout_callback: 69 | self._timeout_callback() 70 | 71 | async def _timeout_turn_off(self, next_interval): 72 | """Turn off the presence hold after the timeout.""" 73 | if self._attr_state == STATE_ON: 74 | await self.async_turn_off() 75 | 76 | async def async_turn_on(self, **kwargs): 77 | """Turn on presence hold.""" 78 | self._attr_state = STATE_ON 79 | self._attr_is_on = True 80 | self.schedule_update_ha_state() 81 | 82 | if self.timeout and not self._timeout_callback: 83 | self._timeout_callback = async_call_later( 84 | self.hass, self.timeout * ONE_MINUTE, self._timeout_turn_off 85 | ) 86 | 87 | async def async_turn_off(self, **kwargs): 88 | """Turn off presence hold.""" 89 | self._attr_state = STATE_OFF 90 | self._attr_is_on = False 91 | self.schedule_update_ha_state() 92 | 93 | if self._timeout_callback: 94 | self._timeout_callback() 95 | self._timeout_callback = None 96 | -------------------------------------------------------------------------------- /custom_components/magic_areas/switch/climate_control.py: -------------------------------------------------------------------------------- 1 | """Climate control feature switch.""" 2 | 3 | import logging 4 | 5 | from homeassistant.components.climate.const import ( 6 | ATTR_PRESET_MODE, 7 | DOMAIN as CLIMATE_DOMAIN, 8 | SERVICE_SET_PRESET_MODE, 9 | ) 10 | from homeassistant.const import ATTR_ENTITY_ID, EntityCategory 11 | from homeassistant.helpers.dispatcher import async_dispatcher_connect 12 | 13 | from custom_components.magic_areas.base.magic import MagicArea 14 | from custom_components.magic_areas.const import ( 15 | CONF_CLIMATE_CONTROL_ENTITY_ID, 16 | CONF_CLIMATE_CONTROL_PRESET_CLEAR, 17 | CONF_CLIMATE_CONTROL_PRESET_EXTENDED, 18 | CONF_CLIMATE_CONTROL_PRESET_OCCUPIED, 19 | CONF_CLIMATE_CONTROL_PRESET_SLEEP, 20 | DEFAULT_CLIMATE_CONTROL_PRESET_CLEAR, 21 | DEFAULT_CLIMATE_CONTROL_PRESET_EXTENDED, 22 | DEFAULT_CLIMATE_CONTROL_PRESET_OCCUPIED, 23 | DEFAULT_CLIMATE_CONTROL_PRESET_SLEEP, 24 | AreaStates, 25 | MagicAreasEvents, 26 | MagicAreasFeatureInfoClimateControl, 27 | MagicAreasFeatures, 28 | ) 29 | from custom_components.magic_areas.switch.base import SwitchBase 30 | 31 | _LOGGER = logging.getLogger(__name__) 32 | 33 | 34 | class ClimateControlSwitch(SwitchBase): 35 | """Switch to enable/disable climate control.""" 36 | 37 | feature_info = MagicAreasFeatureInfoClimateControl() 38 | _attr_entity_category = EntityCategory.CONFIG 39 | 40 | preset_map: dict[str, str] 41 | climate_entity_id: str | None 42 | 43 | def __init__(self, area: MagicArea) -> None: 44 | """Initialize the Climate control switch.""" 45 | 46 | SwitchBase.__init__(self, area) 47 | 48 | self.climate_entity_id = self.area.feature_config( 49 | MagicAreasFeatures.CLIMATE_CONTROL 50 | ).get(CONF_CLIMATE_CONTROL_ENTITY_ID, None) 51 | 52 | if not self.climate_entity_id: 53 | raise ValueError("Climate entity not set") 54 | 55 | self.preset_map = { 56 | AreaStates.CLEAR: self.area.feature_config( 57 | MagicAreasFeatures.CLIMATE_CONTROL 58 | ).get( 59 | CONF_CLIMATE_CONTROL_PRESET_CLEAR, DEFAULT_CLIMATE_CONTROL_PRESET_CLEAR 60 | ), 61 | AreaStates.OCCUPIED: self.area.feature_config( 62 | MagicAreasFeatures.CLIMATE_CONTROL 63 | ).get( 64 | CONF_CLIMATE_CONTROL_PRESET_OCCUPIED, 65 | DEFAULT_CLIMATE_CONTROL_PRESET_OCCUPIED, 66 | ), 67 | AreaStates.SLEEP: self.area.feature_config( 68 | MagicAreasFeatures.CLIMATE_CONTROL 69 | ).get( 70 | CONF_CLIMATE_CONTROL_PRESET_SLEEP, DEFAULT_CLIMATE_CONTROL_PRESET_SLEEP 71 | ), 72 | AreaStates.EXTENDED: self.area.feature_config( 73 | MagicAreasFeatures.CLIMATE_CONTROL 74 | ).get( 75 | CONF_CLIMATE_CONTROL_PRESET_EXTENDED, 76 | DEFAULT_CLIMATE_CONTROL_PRESET_EXTENDED, 77 | ), 78 | } 79 | 80 | async def async_added_to_hass(self) -> None: 81 | """Call when entity about to be added to hass.""" 82 | await super().async_added_to_hass() 83 | 84 | self.async_on_remove( 85 | async_dispatcher_connect( 86 | self.hass, MagicAreasEvents.AREA_STATE_CHANGED, self.area_state_changed 87 | ) 88 | ) 89 | 90 | async def area_state_changed(self, area_id, states_tuple): 91 | """Handle area state change event.""" 92 | 93 | if not self.is_on: 94 | self.logger.debug("%s: Control disabled. Skipping.", self.name) 95 | return 96 | 97 | if area_id != self.area.id: 98 | _LOGGER.debug( 99 | "%s: Area state change event not for us. Skipping. (event: %s/self: %s)", 100 | self.name, 101 | area_id, 102 | self.area.id, 103 | ) 104 | return 105 | 106 | priority_states: list[str] = [ 107 | AreaStates.SLEEP, 108 | AreaStates.EXTENDED, 109 | AreaStates.OCCUPIED, 110 | ] 111 | 112 | # Handle area clear because the other states doesn't matter 113 | if self.area.has_state(AreaStates.CLEAR): 114 | if self.preset_map[AreaStates.CLEAR]: 115 | await self.apply_preset(AreaStates.CLEAR) 116 | return 117 | 118 | # Handle each state top priority to last, returning early 119 | for p_state in priority_states: 120 | if self.area.has_state(p_state) and self.preset_map[p_state]: 121 | return await self.apply_preset(p_state) 122 | 123 | async def apply_preset(self, state_name: str): 124 | """Set climate entity to given preset.""" 125 | 126 | selected_preset: str = self.preset_map[state_name] 127 | 128 | try: 129 | await self.hass.services.async_call( 130 | CLIMATE_DOMAIN, 131 | SERVICE_SET_PRESET_MODE, 132 | { 133 | ATTR_ENTITY_ID: self.climate_entity_id, 134 | ATTR_PRESET_MODE: selected_preset, 135 | }, 136 | ) 137 | # pylint: disable-next=broad-exception-caught 138 | except Exception as e: 139 | self.logger.error("%s: Error applying preset: %s", self.name, str(e)) 140 | -------------------------------------------------------------------------------- /custom_components/magic_areas/switch/fan_control.py: -------------------------------------------------------------------------------- 1 | """Fan Control switch.""" 2 | 3 | import logging 4 | 5 | from homeassistant.components.fan import DOMAIN as FAN_DOMAIN 6 | from homeassistant.components.sensor.const import DOMAIN as SENSOR_DOMAIN 7 | from homeassistant.const import ( 8 | ATTR_ENTITY_ID, 9 | SERVICE_TURN_OFF, 10 | SERVICE_TURN_ON, 11 | STATE_ON, 12 | EntityCategory, 13 | ) 14 | from homeassistant.core import Event, EventStateChangedData 15 | from homeassistant.helpers.dispatcher import async_dispatcher_connect 16 | from homeassistant.helpers.event import async_track_state_change_event 17 | 18 | from custom_components.magic_areas.base.magic import MagicArea 19 | from custom_components.magic_areas.const import ( 20 | CONF_FAN_GROUPS_REQUIRED_STATE, 21 | CONF_FAN_GROUPS_SETPOINT, 22 | CONF_FAN_GROUPS_TRACKED_DEVICE_CLASS, 23 | DEFAULT_FAN_GROUPS_REQUIRED_STATE, 24 | DEFAULT_FAN_GROUPS_SETPOINT, 25 | DEFAULT_FAN_GROUPS_TRACKED_DEVICE_CLASS, 26 | AreaStates, 27 | MagicAreasEvents, 28 | MagicAreasFeatureInfoFanGroups, 29 | MagicAreasFeatures, 30 | ) 31 | from custom_components.magic_areas.switch.base import SwitchBase 32 | 33 | _LOGGER = logging.getLogger(__name__) 34 | 35 | 36 | class FanControlSwitch(SwitchBase): 37 | """Switch to enable/disable fan control.""" 38 | 39 | feature_info = MagicAreasFeatureInfoFanGroups() 40 | _attr_entity_category = EntityCategory.CONFIG 41 | 42 | setpoint: float = 0.0 43 | tracked_entity_id: str 44 | 45 | def __init__(self, area: MagicArea) -> None: 46 | """Initialize the Fan control switch.""" 47 | 48 | SwitchBase.__init__(self, area) 49 | 50 | tracked_device_class = self.area.feature_config( 51 | MagicAreasFeatures.FAN_GROUPS 52 | ).get( 53 | CONF_FAN_GROUPS_TRACKED_DEVICE_CLASS, 54 | DEFAULT_FAN_GROUPS_TRACKED_DEVICE_CLASS, 55 | ) 56 | self.tracked_entity_id = f"{SENSOR_DOMAIN}.magic_areas_aggregates_{self.area.slug}_aggregate_{tracked_device_class}" 57 | 58 | self.setpoint = float( 59 | self.area.feature_config(MagicAreasFeatures.FAN_GROUPS).get( 60 | CONF_FAN_GROUPS_SETPOINT, DEFAULT_FAN_GROUPS_SETPOINT 61 | ) 62 | ) 63 | 64 | async def async_added_to_hass(self) -> None: 65 | """Call when entity about to be added to hass.""" 66 | await super().async_added_to_hass() 67 | 68 | self.async_on_remove( 69 | async_dispatcher_connect( 70 | self.hass, MagicAreasEvents.AREA_STATE_CHANGED, self.area_state_changed 71 | ) 72 | ) 73 | self.async_on_remove( 74 | async_track_state_change_event( 75 | self.hass, 76 | [self.tracked_entity_id], 77 | self.aggregate_sensor_state_changed, 78 | ) 79 | ) 80 | 81 | async def aggregate_sensor_state_changed( 82 | self, event: Event[EventStateChangedData] 83 | ) -> None: 84 | """Call update state from track state change event.""" 85 | 86 | await self.run_logic(self.area.states) 87 | 88 | async def area_state_changed(self, area_id, states_tuple): 89 | """Handle area state change event.""" 90 | 91 | if area_id != self.area.id: 92 | _LOGGER.debug( 93 | "%s: Area state change event not for us. Skipping. (event: %s/self: %s)", 94 | self.name, 95 | area_id, 96 | self.area.id, 97 | ) 98 | return 99 | 100 | # pylint: disable-next=unused-variable 101 | new_states, lost_states = states_tuple 102 | await self.run_logic(states=new_states) 103 | 104 | async def run_logic(self, states: list[str]) -> None: 105 | """Run fan control logic.""" 106 | 107 | if not self.is_on: 108 | _LOGGER.debug("%s: Control disabled, skipping.", self.name) 109 | return 110 | 111 | fan_group_entity_id = ( 112 | f"{FAN_DOMAIN}.magic_areas_fan_groups_{self.area.slug}_fan_group" 113 | ) 114 | 115 | if AreaStates.CLEAR in states: 116 | _LOGGER.debug("%s: Area clear, turning off fans", self.name) 117 | await self.hass.services.async_call( 118 | FAN_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: fan_group_entity_id} 119 | ) 120 | return 121 | 122 | required_state = self.area.feature_config(MagicAreasFeatures.FAN_GROUPS).get( 123 | CONF_FAN_GROUPS_REQUIRED_STATE, DEFAULT_FAN_GROUPS_REQUIRED_STATE 124 | ) 125 | 126 | if required_state not in states: 127 | _LOGGER.debug( 128 | "%s: Area not in required state '%s' (states: %s)", 129 | self.name, 130 | required_state, 131 | str(states), 132 | ) 133 | return 134 | 135 | _LOGGER.debug( 136 | "%s: Area in required state '%s', checking tracked aggregate and setpoint.", 137 | self.name, 138 | required_state, 139 | ) 140 | if self.is_setpoint_reached(): 141 | _LOGGER.debug("%s: Setpoint reached, turning on fans", self.name) 142 | await self.hass.services.async_call( 143 | FAN_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: fan_group_entity_id} 144 | ) 145 | else: 146 | fan_group_state = self.hass.states.get(fan_group_entity_id) 147 | if fan_group_state and fan_group_state.state == STATE_ON: 148 | _LOGGER.debug("%s: Setpoint not reached, turning off fans", self.name) 149 | await self.hass.services.async_call( 150 | FAN_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: fan_group_entity_id} 151 | ) 152 | return 153 | 154 | def is_setpoint_reached(self) -> bool: 155 | """Check wether the setpoint is reached.""" 156 | 157 | tracked_sensor_state = self.hass.states.get(self.tracked_entity_id) 158 | 159 | if not tracked_sensor_state: 160 | _LOGGER.warning( 161 | "%s: Tracked sensor entity '%s' is not found. Please ensure aggregates are enabled and the selected device class is configured.", 162 | self.name, 163 | self.tracked_entity_id, 164 | ) 165 | return False 166 | 167 | tracked_sensor_value = float(tracked_sensor_state.state) 168 | _LOGGER.debug( 169 | "%s: Setpoint value: %.2f, Sensor value: %.2f", 170 | self.name, 171 | self.setpoint, 172 | tracked_sensor_value, 173 | ) 174 | return tracked_sensor_value >= self.setpoint 175 | -------------------------------------------------------------------------------- /custom_components/magic_areas/switch/media_player_control.py: -------------------------------------------------------------------------------- 1 | """Media player control feature switch.""" 2 | 3 | import logging 4 | 5 | from homeassistant.components.media_player.const import DOMAIN as MEDIA_PLAYER_DOMAIN 6 | from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, EntityCategory 7 | from homeassistant.helpers.dispatcher import async_dispatcher_connect 8 | 9 | from custom_components.magic_areas.base.magic import MagicArea 10 | from custom_components.magic_areas.const import ( 11 | AreaStates, 12 | MagicAreasEvents, 13 | MagicAreasFeatureInfoMediaPlayerGroups, 14 | ) 15 | from custom_components.magic_areas.switch.base import SwitchBase 16 | 17 | _LOGGER = logging.getLogger(__name__) 18 | 19 | 20 | class MediaPlayerControlSwitch(SwitchBase): 21 | """Switch to enable/disable climate control.""" 22 | 23 | feature_info = MagicAreasFeatureInfoMediaPlayerGroups() 24 | _attr_entity_category = EntityCategory.CONFIG 25 | 26 | media_player_group_id: str 27 | 28 | def __init__(self, area: MagicArea) -> None: 29 | """Initialize the Climate control switch.""" 30 | 31 | SwitchBase.__init__(self, area) 32 | 33 | self.media_player_group_id = f"{MEDIA_PLAYER_DOMAIN}.magic_areas_media_player_groups_{self.area.slug}_media_player_group" 34 | 35 | async def async_added_to_hass(self) -> None: 36 | """Call when entity about to be added to hass.""" 37 | await super().async_added_to_hass() 38 | 39 | self.async_on_remove( 40 | async_dispatcher_connect( 41 | self.hass, MagicAreasEvents.AREA_STATE_CHANGED, self.area_state_changed 42 | ) 43 | ) 44 | 45 | async def area_state_changed(self, area_id, states_tuple): 46 | """Handle area state change event.""" 47 | 48 | if not self.is_on: 49 | self.logger.debug("%s: Control disabled. Skipping.", self.name) 50 | return 51 | 52 | if area_id != self.area.id: 53 | _LOGGER.debug( 54 | "%s: Area state change event not for us. Skipping. (event: %s/self: %s)", 55 | self.name, 56 | area_id, 57 | self.area.id, 58 | ) 59 | return 60 | 61 | # pylint: disable-next=unused-variable 62 | new_states, lost_states = states_tuple 63 | 64 | if AreaStates.CLEAR in new_states: 65 | _LOGGER.debug("%s: Area clear, turning off media players.", self.name) 66 | await self.hass.services.async_call( 67 | MEDIA_PLAYER_DOMAIN, 68 | SERVICE_TURN_OFF, 69 | {ATTR_ENTITY_ID: self.media_player_group_id}, 70 | ) 71 | return 72 | -------------------------------------------------------------------------------- /custom_components/magic_areas/switch/presence_hold.py: -------------------------------------------------------------------------------- 1 | """Presence hold switch.""" 2 | 3 | from homeassistant.const import EntityCategory 4 | 5 | from custom_components.magic_areas.base.magic import MagicArea 6 | from custom_components.magic_areas.const import ( 7 | CONF_PRESENCE_HOLD_TIMEOUT, 8 | DEFAULT_PRESENCE_HOLD_TIMEOUT, 9 | MagicAreasFeatureInfoPresenceHold, 10 | MagicAreasFeatures, 11 | ) 12 | from custom_components.magic_areas.switch.base import ResettableSwitchBase 13 | 14 | 15 | class PresenceHoldSwitch(ResettableSwitchBase): 16 | """Switch to enable/disable presence hold.""" 17 | 18 | feature_info = MagicAreasFeatureInfoPresenceHold() 19 | _attr_entity_category = EntityCategory.CONFIG 20 | 21 | def __init__(self, area: MagicArea) -> None: 22 | """Initialize the switch.""" 23 | 24 | timeout = area.feature_config(MagicAreasFeatures.PRESENCE_HOLD).get( 25 | CONF_PRESENCE_HOLD_TIMEOUT, DEFAULT_PRESENCE_HOLD_TIMEOUT 26 | ) 27 | 28 | ResettableSwitchBase.__init__(self, area, timeout=timeout) 29 | -------------------------------------------------------------------------------- /custom_components/magic_areas/threshold.py: -------------------------------------------------------------------------------- 1 | """Platform file for Magic Areas threhsold sensors.""" 2 | 3 | import logging 4 | 5 | from homeassistant.components.binary_sensor import ( 6 | DOMAIN as BINARY_SENSOR_DOMAIN, 7 | BinarySensorDeviceClass, 8 | ) 9 | from homeassistant.components.sensor.const import ( 10 | DOMAIN as SENSOR_DOMAIN, 11 | SensorDeviceClass, 12 | ) 13 | from homeassistant.components.threshold.binary_sensor import ThresholdSensor 14 | from homeassistant.const import ATTR_DEVICE_CLASS 15 | from homeassistant.helpers.entity import Entity 16 | 17 | from custom_components.magic_areas.base.entities import MagicEntity 18 | from custom_components.magic_areas.base.magic import MagicArea 19 | from custom_components.magic_areas.const import ( 20 | CONF_AGGREGATES_ILLUMINANCE_THRESHOLD, 21 | CONF_AGGREGATES_ILLUMINANCE_THRESHOLD_HYSTERESIS, 22 | CONF_AGGREGATES_SENSOR_DEVICE_CLASSES, 23 | CONF_FEATURE_AGGREGATION, 24 | DEFAULT_AGGREGATES_ILLUMINANCE_THRESHOLD, 25 | DEFAULT_AGGREGATES_ILLUMINANCE_THRESHOLD_HYSTERESIS, 26 | DEFAULT_AGGREGATES_SENSOR_DEVICE_CLASSES, 27 | EMPTY_STRING, 28 | MagicAreasFeatureInfoThrehsold, 29 | ) 30 | 31 | _LOGGER = logging.getLogger(__name__) 32 | 33 | 34 | def create_illuminance_threshold(area: MagicArea) -> Entity | None: 35 | """Create threhsold light binary sensor based off illuminance aggregate.""" 36 | 37 | if not area.has_feature(CONF_FEATURE_AGGREGATION): 38 | return None 39 | 40 | illuminance_threshold = area.feature_config(CONF_FEATURE_AGGREGATION).get( 41 | CONF_AGGREGATES_ILLUMINANCE_THRESHOLD, DEFAULT_AGGREGATES_ILLUMINANCE_THRESHOLD 42 | ) 43 | 44 | if illuminance_threshold == 0: 45 | return None 46 | 47 | if SensorDeviceClass.ILLUMINANCE not in area.feature_config( 48 | CONF_FEATURE_AGGREGATION 49 | ).get( 50 | CONF_AGGREGATES_SENSOR_DEVICE_CLASSES, DEFAULT_AGGREGATES_SENSOR_DEVICE_CLASSES 51 | ): 52 | return None 53 | 54 | if SENSOR_DOMAIN not in area.entities: 55 | return None 56 | 57 | illuminance_sensors = [ 58 | sensor 59 | for sensor in area.entities[SENSOR_DOMAIN] 60 | if ATTR_DEVICE_CLASS in sensor 61 | and sensor[ATTR_DEVICE_CLASS] == SensorDeviceClass.ILLUMINANCE 62 | ] 63 | 64 | if not illuminance_sensors: 65 | return None 66 | 67 | illuminance_threshold_hysteresis_percentage = area.feature_config( 68 | CONF_FEATURE_AGGREGATION 69 | ).get( 70 | CONF_AGGREGATES_ILLUMINANCE_THRESHOLD_HYSTERESIS, 71 | DEFAULT_AGGREGATES_ILLUMINANCE_THRESHOLD_HYSTERESIS, 72 | ) 73 | illuminance_threshold_hysteresis = 0 74 | 75 | if illuminance_threshold_hysteresis_percentage > 0: 76 | illuminance_threshold_hysteresis = illuminance_threshold * ( 77 | illuminance_threshold_hysteresis_percentage / 100 78 | ) 79 | 80 | illuminance_aggregate_entity_id = ( 81 | f"{SENSOR_DOMAIN}.magic_areas_aggregates_{area.slug}_aggregate_illuminance" 82 | ) 83 | 84 | _LOGGER.debug( 85 | "Creating illuminance threhsold sensor for area '%s': Threhsold: %d, Hysteresis: %d (%d%%)", 86 | area.slug, 87 | illuminance_threshold, 88 | illuminance_threshold_hysteresis, 89 | illuminance_threshold_hysteresis_percentage, 90 | ) 91 | 92 | try: 93 | return AreaThresholdSensor( 94 | area=area, 95 | device_class=BinarySensorDeviceClass.LIGHT, 96 | entity_id=illuminance_aggregate_entity_id, 97 | upper=illuminance_threshold, 98 | hysteresis=illuminance_threshold_hysteresis, 99 | ) 100 | except Exception as e: # pylint: disable=broad-exception-caught 101 | _LOGGER.error( 102 | "%s: Error creating calculated light sensor: %s", 103 | area.slug, 104 | str(e), 105 | ) 106 | return None 107 | 108 | 109 | class AreaThresholdSensor(MagicEntity, ThresholdSensor): 110 | """Threshold sensor based off aggregates.""" 111 | 112 | feature_info = MagicAreasFeatureInfoThrehsold() 113 | 114 | def __init__( 115 | self, 116 | *, 117 | area: MagicArea, 118 | device_class: BinarySensorDeviceClass, 119 | entity_id: str, 120 | upper: int | None = None, 121 | lower: int | None = None, 122 | hysteresis: int = 0, 123 | ) -> None: 124 | """Initialize an area sensor group binary sensor.""" 125 | 126 | MagicEntity.__init__( 127 | self, area, domain=BINARY_SENSOR_DOMAIN, translation_key=device_class 128 | ) 129 | ThresholdSensor.__init__( 130 | self, 131 | entity_id=entity_id, 132 | name=EMPTY_STRING, 133 | unique_id=self.unique_id, 134 | lower=lower, 135 | upper=upper, 136 | hysteresis=hysteresis, 137 | device_class=device_class, 138 | ) 139 | delattr(self, "_attr_name") 140 | -------------------------------------------------------------------------------- /custom_components/magic_areas/translations/sv.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /custom_components/magic_areas/util.py: -------------------------------------------------------------------------------- 1 | """Magic Areas Util Functions. 2 | 3 | Small helper functions that are used more than once. 4 | """ 5 | 6 | from collections.abc import Sequence 7 | import logging 8 | 9 | from homeassistant.const import ATTR_ENTITY_ID 10 | from homeassistant.core import HomeAssistant 11 | from homeassistant.helpers.entity import Entity 12 | from homeassistant.helpers.entity_registry import async_get as entityreg_async_get 13 | 14 | _LOGGER = logging.getLogger(__name__) 15 | 16 | 17 | def cleanup_removed_entries( 18 | hass: HomeAssistant, entity_list: Sequence[Entity], old_ids: list[dict[str, str]] 19 | ) -> None: 20 | """Clean up old magic entities.""" 21 | new_ids = [entity.entity_id for entity in entity_list] 22 | _LOGGER.debug( 23 | "Checking for cleanup. Old entity list: %s, New entity list: %s", 24 | old_ids, 25 | new_ids, 26 | ) 27 | entity_registry = entityreg_async_get(hass) 28 | for entity_dict in old_ids: 29 | entity_id = entity_dict[ATTR_ENTITY_ID] 30 | if entity_id in new_ids: 31 | continue 32 | _LOGGER.info("Cleaning up old entity %s", entity_id) 33 | entity_registry.async_remove(entity_id) 34 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Magic Areas", 3 | "homeassistant": "2025.2.0", 4 | "render_readme": true 5 | } 6 | -------------------------------------------------------------------------------- /info.md: -------------------------------------------------------------------------------- 1 | # Magic Areas for Home Assistant 2 | ![Magic Areas](https://raw.githubusercontent.com/home-assistant/brands/master/custom_integrations/magic_areas/icon.png) 3 | 4 | ![Build Status](https://github.com/jseidl/hass-magic_areas/actions/workflows/validation.yaml/badge.svg) [![Discord](https://img.shields.io/discord/928386239789400065.svg?color=768AD4&label=Discord)](https://discord.gg/8vxJpJ2vP4) [![Latest release](https://img.shields.io/github/v/release/jseidl/hass-magic_areas.svg)](https://github.com/jseidl/hass-magic_areas/releases) [![GitHub latest commit](https://badgen.net/github/last-commit/jseidl/hass-magic_areas)](https://GitHub.com/jseidl/hass-magic_areas/commit/) [![GitHub contributors](https://badgen.net/github/contributors/jseidl/hass-magic_areas)](https://GitHub.com/jseidl/hass-magic_areas/graphs/contributors/) 5 | 6 | Tired of writing the same automations, over and over, for each of your rooms? You wish Home Assistant just figured out all entities you have in an area and **magically** started being smarter about them? 7 | 8 | Magic Areas is the batteries that were missing! Would like to have a single `motion` sensor that is grouped to all your other motion sensors in that area? What if all (most) of your `sensor` and `binary_sensor` entities had aggregates (grouping/average), PER AREA? Would you like for lights and climate devices to turn on and off **MAGICALLY** whenever an area is occupied/clear? 9 | 10 | If you think all of the above features are freaking awesome, **Magic Areas** is here for you! 11 | -------------------------------------------------------------------------------- /pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | ignore=tests 3 | # Use a conservative default here; 2 should speed up most setups and not hurt 4 | # any too bad. Override on command line as appropriate. 5 | jobs=2 6 | load-plugins=pylint_strict_informational 7 | persistent=no 8 | extension-pkg-whitelist=ciso8601 9 | 10 | [BASIC] 11 | good-names=id,i,j,k,ex,Run,_,fp,T 12 | 13 | [MESSAGES CONTROL] 14 | # Reasons disabled: 15 | # format - handled by black 16 | # locally-disabled - it spams too much 17 | # duplicate-code - unavoidable 18 | # cyclic-import - doesn't test if both import on load 19 | # unused-argument - generic callbacks and setup methods create a lot of warnings 20 | # too-many-* - are not enforced for the sake of readability 21 | # too-few-* - same as too-many-* 22 | # abstract-method - with intro of async there are always methods missing 23 | # inconsistent-return-statements - doesn't handle raise 24 | # too-many-ancestors - it's too strict. 25 | # wrong-import-order - isort guards this 26 | disable= 27 | format, 28 | abstract-method, 29 | cyclic-import, 30 | duplicate-code, 31 | inconsistent-return-statements, 32 | locally-disabled, 33 | not-context-manager, 34 | too-few-public-methods, 35 | too-many-ancestors, 36 | too-many-arguments, 37 | too-many-branches, 38 | too-many-instance-attributes, 39 | too-many-lines, 40 | too-many-locals, 41 | too-many-public-methods, 42 | too-many-return-statements, 43 | too-many-statements, 44 | too-many-boolean-expressions, 45 | unused-argument, 46 | wrong-import-order 47 | enable= 48 | use-symbolic-message-instead 49 | 50 | [REPORTS] 51 | score=no 52 | 53 | [TYPECHECK] 54 | # For attrs 55 | ignored-classes=_CountingAttr 56 | 57 | [FORMAT] 58 | expected-line-ending-format=LF 59 | 60 | [EXCEPTIONS] 61 | overgeneral-exceptions=builtins.BaseException,builtins.Exception,HomeAssistantError.HomeAssistantError 62 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | target-version = ["py312"] 3 | extend-exclude = "/generated/" 4 | 5 | [tool.isort] 6 | # https://github.com/PyCQA/isort/wiki/isort-Settings 7 | profile = "black" 8 | # will group `import x` and `from x import` of the same module. 9 | force_sort_within_sections = true 10 | known_first_party = ["homeassistant", "tests"] 11 | known_local_folder = ["custom_components.magic_areas"] 12 | forced_separate = ["tests"] 13 | combine_as_imports = true 14 | 15 | [tool.pylint.MAIN] 16 | py-version = "3.12" 17 | ignore = ["tests"] 18 | # Use a conservative default here; 2 should speed up most setups and not hurt 19 | # any too bad. Override on command line as appropriate. 20 | jobs = 2 21 | init-hook = """\ 22 | from pathlib import Path; \ 23 | import sys; \ 24 | 25 | from pylint.config import find_default_config_files; \ 26 | 27 | sys.path.append( \ 28 | str(Path(next(find_default_config_files())).parent.joinpath('pylint/plugins')) 29 | ) \ 30 | """ 31 | load-plugins = [ 32 | "pylint.extensions.code_style", 33 | "pylint.extensions.typing", 34 | "hass_enforce_type_hints", 35 | "hass_imports", 36 | "hass_logger", 37 | "pylint_per_file_ignores", 38 | ] 39 | persistent = false 40 | extension-pkg-allow-list = [ 41 | "av.audio.stream", 42 | "av.stream", 43 | "ciso8601", 44 | "orjson", 45 | "cv2", 46 | ] 47 | fail-on = ["I"] 48 | 49 | [tool.pylint.BASIC] 50 | class-const-naming-style = "any" 51 | good-names = ["_", "ev", "ex", "fp", "i", "id", "j", "k", "Run", "ip"] 52 | 53 | [tool.pylint."MESSAGES CONTROL"] 54 | # Reasons disabled: 55 | # format - handled by black 56 | # locally-disabled - it spams too much 57 | # duplicate-code - unavoidable 58 | # cyclic-import - doesn't test if both import on load 59 | # abstract-class-little-used - prevents from setting right foundation 60 | # unused-argument - generic callbacks and setup methods create a lot of warnings 61 | # too-many-* - are not enforced for the sake of readability 62 | # too-few-* - same as too-many-* 63 | # abstract-method - with intro of async there are always methods missing 64 | # inconsistent-return-statements - doesn't handle raise 65 | # too-many-ancestors - it's too strict. 66 | # wrong-import-order - isort guards this 67 | # consider-using-f-string - str.format sometimes more readable 68 | # --- 69 | # Pylint CodeStyle plugin 70 | # consider-using-namedtuple-or-dataclass - too opinionated 71 | # consider-using-assignment-expr - decision to use := better left to devs 72 | disable = [ 73 | "format", 74 | "abstract-method", 75 | "cyclic-import", 76 | "duplicate-code", 77 | "inconsistent-return-statements", 78 | "locally-disabled", 79 | "not-context-manager", 80 | "too-few-public-methods", 81 | "too-many-ancestors", 82 | "too-many-arguments", 83 | "too-many-branches", 84 | "too-many-instance-attributes", 85 | "too-many-lines", 86 | "too-many-locals", 87 | "too-many-public-methods", 88 | "too-many-return-statements", 89 | "too-many-statements", 90 | "too-many-boolean-expressions", 91 | "unused-argument", 92 | "wrong-import-order", 93 | "consider-using-f-string", 94 | "consider-using-namedtuple-or-dataclass", 95 | "consider-using-assignment-expr", 96 | ] 97 | enable = [ 98 | #"useless-suppression", # temporarily every now and then to clean them up 99 | "use-symbolic-message-instead", 100 | ] 101 | 102 | [tool.pylint.REPORTS] 103 | score = false 104 | 105 | [tool.pylint.TYPECHECK] 106 | ignored-classes = [ 107 | "_CountingAttr", # for attrs 108 | ] 109 | mixin-class-rgx = ".*[Mm]ix[Ii]n" 110 | 111 | [tool.pylint.FORMAT] 112 | expected-line-ending-format = "LF" 113 | 114 | [tool.pylint.EXCEPTIONS] 115 | overgeneral-exceptions = [ 116 | "builtins.BaseException", 117 | "builtins.Exception", 118 | # "homeassistant.exceptions.HomeAssistantError", # too many issues 119 | ] 120 | 121 | [tool.pylint.TYPING] 122 | runtime-typing = false 123 | 124 | [tool.pylint.CODE_STYLE] 125 | max-line-length-suggestions = 72 126 | 127 | [tool.pylint-per-file-ignores] 128 | # hass-component-root-import: Tests test non-public APIs 129 | # protected-access: Tests do often test internals a lot 130 | # redefined-outer-name: Tests reference fixtures in the test function 131 | "/tests/" = "hass-component-root-import,protected-access,redefined-outer-name" 132 | 133 | [tool.pytest.ini_options] 134 | testpaths = ["tests"] 135 | norecursedirs = [".git", "testing_config"] 136 | log_format = "%(asctime)s.%(msecs)03d %(levelname)-8s %(threadName)s %(name)s:%(filename)s:%(lineno)s %(message)s" 137 | log_date_format = "%Y-%m-%d %H:%M:%S" 138 | log_cli = false 139 | asyncio_mode = "auto" 140 | 141 | [tool.ruff] 142 | target-version = "py312" 143 | 144 | select = [ 145 | "C", # complexity 146 | "D", # docstrings 147 | "E", # pycodestyle 148 | "F", # pyflakes/autoflake 149 | "PGH004", # Use specific rule codes when using noqa 150 | "PLC0414", # Useless import alias. Import alias does not rename original package. 151 | "SIM105", # Use contextlib.suppress({exception}) instead of try-except-pass 152 | "SIM117", # Merge with-statements that use the same scope 153 | "SIM300", # Yoda conditions. Use 'age == 42' instead of '42 == age'. 154 | "SIM401", # Use get from dict with default instead of an if block 155 | "T20", # flake8-print 156 | "TRY004", # Prefer TypeError exception for invalid type 157 | "UP", # pyupgrade 158 | "W", # pycodestyle 159 | ] 160 | 161 | ignore = [ 162 | "D202", # No blank lines allowed after function docstring 163 | "D203", # 1 blank line required before class docstring 164 | "D213", # Multi-line docstring summary should start at the second line 165 | "D404", # First word of the docstring should not be This 166 | "D406", # Section name should end with a newline 167 | "D407", # Section name underlining 168 | "D411", # Missing blank line before section 169 | "E501", # line too long 170 | "E731", # do not assign a lambda expression, use a def 171 | ] 172 | 173 | [tool.ruff.flake8-pytest-style] 174 | fixture-parentheses = false 175 | 176 | [tool.ruff.pyupgrade] 177 | keep-runtime-typing = true 178 | 179 | [tool.ruff.per-file-ignores] 180 | 181 | # TODO: these files have functions that are too complex, but flake8's and ruff's 182 | # complexity (and/or nested-function) handling differs; trying to add a noqa doesn't work 183 | # because the flake8-noqa plugin then disagrees on whether there should be a C901 noqa 184 | # on that line. So, for now, we just ignore C901s on these files as far as ruff is concerned. 185 | 186 | "homeassistant/components/light/__init__.py" = ["C901"] 187 | "homeassistant/components/mqtt/discovery.py" = ["C901"] 188 | "homeassistant/components/websocket_api/http.py" = ["C901"] 189 | 190 | # Allow for main entry & scripts to write to stdout 191 | "homeassistant/__main__.py" = ["T201"] 192 | "homeassistant/scripts/*" = ["T201"] 193 | "script/*" = ["T20"] 194 | 195 | [tool.ruff.mccabe] 196 | max-complexity = 25 197 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | -r requirements-test.txt 2 | black~=25.1 3 | packaging~=24.2 4 | pre-commit~=4.2 5 | PyGithub~=2.6 6 | pyupgrade~=3.19 7 | yamllint~=1.37 8 | -------------------------------------------------------------------------------- /requirements-test.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | colorlog~=6.9 3 | flake8~=7.2 4 | flake8-docstrings~=1.7 5 | fnv-hash-fast 6 | mypy~=1.15 7 | psutil-home-assistant==0.0.1 8 | pylint~=3.3 9 | pylint-strict-informational==0.1 10 | pytest>=7.2 11 | pytest-cov 12 | pytest-homeassistant-custom-component 13 | pytest-asyncio>=0.20 14 | tzdata 15 | ruff~=0.11 16 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | homeassistant>=2024.12.0 2 | pip 3 | -------------------------------------------------------------------------------- /scripts/develop: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | # Create config dir if not present 8 | if [[ ! -d "${PWD}/config" ]]; then 9 | mkdir -p "${PWD}/config" 10 | hass --config "${PWD}/config" --script ensure_config 11 | fi 12 | 13 | # Set the path to custom_components 14 | ## This let's us have the structure we want /custom_components/magic_areas 15 | ## while at the same time have Home Assistant configuration inside /config 16 | ## without resulting to symlinks. 17 | export PYTHONPATH="${PYTHONPATH}:${PWD}/custom_components" 18 | 19 | # Start Home Assistant 20 | hass --config "${PWD}/config" --debug 21 | -------------------------------------------------------------------------------- /scripts/lint: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | pre-commit run --all-files 8 | -------------------------------------------------------------------------------- /scripts/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | python3 -m pip install --requirement requirements.txt 8 | python3 -m pip install --requirement requirements-test.txt 9 | -------------------------------------------------------------------------------- /scripts/test: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | coverage run -m pytest 8 | coverage report 9 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [coverage:run] 2 | source = 3 | custom_components 4 | 5 | [coverage:report] 6 | exclude_lines = 7 | pragma: no cover 8 | raise NotImplemented() 9 | if __name__ == '__main__': 10 | main() 11 | ; todo: restore threshold 12 | ;fail_under = 93 13 | show_missing = true 14 | 15 | [tool:pytest] 16 | testpaths = tests 17 | norecursedirs = 18 | .git 19 | addopts = 20 | --strict-markers 21 | --cov=custom_components 22 | filterwarnings = 23 | ignore::DeprecationWarning:asynctest.*: 24 | 25 | [flake8] 26 | exclude = .venv,.git,.tox,docs,venv,bin,lib,deps,build,__pycache__ 27 | doctests = True 28 | # To work with Black 29 | # https://github.com/ambv/black#line-length 30 | max-line-length = 88 31 | # E501: Line too long 32 | # W503: Line break occurred before a binary operator 33 | # E203: Whitespace before ':' 34 | # D202: No blank lines allowed after function docstring 35 | # W504: Line break after binary operator 36 | ignore = 37 | E501, 38 | W503, 39 | E203, 40 | D202, 41 | W504 42 | 43 | [isort] 44 | # https://github.com/timothycrosley/isort 45 | # https://github.com/timothycrosley/isort/wiki/isort-Settings 46 | # splits long import on multiple lines indented by 4 spaces 47 | multi_line_output = 3 48 | include_trailing_comma=True 49 | force_grid_wrap=0 50 | use_parentheses=True 51 | line_length=88 52 | indent = " " 53 | sections = FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,LOCALFOLDER 54 | known_first_party = custom_components,tests 55 | forced_separate = tests 56 | 57 | [mypy] 58 | ignore_errors = true 59 | follow_imports = silent 60 | ignore_missing_imports = true 61 | warn_incomplete_stub = true 62 | warn_redundant_casts = true 63 | warn_unused_configs = true 64 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for Magic Areas.""" 2 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Fixtures for tests.""" 2 | 3 | from collections.abc import AsyncGenerator, Generator 4 | import logging 5 | from typing import Any 6 | 7 | import pytest 8 | from pytest_homeassistant_custom_component.common import MockConfigEntry 9 | 10 | from homeassistant.components.binary_sensor import ( 11 | DOMAIN as BINARY_SENSOR_DOMAIN, 12 | BinarySensorDeviceClass, 13 | ) 14 | from homeassistant.core import HomeAssistant 15 | 16 | from custom_components.magic_areas.const import ( 17 | CONF_AGGREGATES_MIN_ENTITIES, 18 | CONF_ENABLED_FEATURES, 19 | CONF_FEATURE_AGGREGATION, 20 | CONF_TYPE, 21 | DOMAIN, 22 | AreaType, 23 | ) 24 | 25 | from tests.common import ( 26 | get_basic_config_entry_data, 27 | init_integration, 28 | setup_mock_entities, 29 | shutdown_integration, 30 | ) 31 | from tests.const import DEFAULT_MOCK_AREA, MOCK_AREAS, MockAreaIds 32 | from tests.mocks import MockBinarySensor 33 | 34 | _LOGGER = logging.getLogger(__name__) 35 | 36 | # Fixtures 37 | 38 | 39 | @pytest.fixture(autouse=True) 40 | def auto_enable_custom_integrations( 41 | enable_custom_integrations: None, 42 | ) -> Generator[None, None, None]: 43 | """Enable custom integration.""" 44 | _ = enable_custom_integrations # unused 45 | yield 46 | 47 | 48 | # Config entries 49 | 50 | 51 | @pytest.fixture(name="basic_config_entry") 52 | def mock_config_entry() -> MockConfigEntry: 53 | """Fixture for mock configuration entry.""" 54 | data = get_basic_config_entry_data(DEFAULT_MOCK_AREA) 55 | return MockConfigEntry(domain=DOMAIN, data=data) 56 | 57 | 58 | @pytest.fixture(name="all_areas_with_meta_config_entry") 59 | def mock_config_entry_all_areas_with_meta_config_entry() -> list[MockConfigEntry]: 60 | """Fixture for mock configuration entry.""" 61 | 62 | config_entries: list[MockConfigEntry] = [] 63 | for area_entry in MockAreaIds: 64 | data = get_basic_config_entry_data(area_entry) 65 | data.update( 66 | { 67 | CONF_ENABLED_FEATURES: { 68 | CONF_FEATURE_AGGREGATION: {CONF_AGGREGATES_MIN_ENTITIES: 1} 69 | } 70 | } 71 | ) 72 | config_entries.append(MockConfigEntry(domain=DOMAIN, data=data)) 73 | 74 | return config_entries 75 | 76 | 77 | # Entities 78 | 79 | 80 | @pytest.fixture(name="entities_binary_sensor_motion_one") 81 | async def setup_entities_binary_sensor_motion_one( 82 | hass: HomeAssistant, 83 | ) -> list[MockBinarySensor]: 84 | """Create one mock sensor and setup the system with it.""" 85 | mock_binary_sensor_entities = [ 86 | MockBinarySensor( 87 | name="motion_sensor", 88 | unique_id="unique_motion", 89 | device_class=BinarySensorDeviceClass.MOTION, 90 | ) 91 | ] 92 | await setup_mock_entities( 93 | hass, BINARY_SENSOR_DOMAIN, {DEFAULT_MOCK_AREA: mock_binary_sensor_entities} 94 | ) 95 | return mock_binary_sensor_entities 96 | 97 | 98 | @pytest.fixture(name="entities_binary_sensor_motion_multiple") 99 | async def setup_entities_binary_sensor_motion_multiple( 100 | hass: HomeAssistant, 101 | ) -> list[MockBinarySensor]: 102 | """Create multiple mock sensor and setup the system with it.""" 103 | nr_entities = 3 104 | mock_binary_sensor_entities = [] 105 | for i in range(nr_entities): 106 | mock_binary_sensor_entities.append( 107 | MockBinarySensor( 108 | name=f"motion_sensor_{i}", 109 | unique_id=f"motion_sensor_{i}", 110 | device_class=BinarySensorDeviceClass.MOTION, 111 | ) 112 | ) 113 | await setup_mock_entities( 114 | hass, BINARY_SENSOR_DOMAIN, {DEFAULT_MOCK_AREA: mock_binary_sensor_entities} 115 | ) 116 | return mock_binary_sensor_entities 117 | 118 | 119 | @pytest.fixture(name="entities_binary_sensor_motion_all_areas_with_meta") 120 | async def setup_entities_binary_sensor_motion_all_areas_with_meta( 121 | hass: HomeAssistant, 122 | ) -> dict[MockAreaIds, list[MockBinarySensor]]: 123 | """Create multiple mock sensor and setup the system with it.""" 124 | 125 | mock_binary_sensor_entities: dict[MockAreaIds, list[MockBinarySensor]] = {} 126 | 127 | for area in MockAreaIds: 128 | assert area is not None 129 | area_object = MOCK_AREAS[area] 130 | assert area_object is not None 131 | if area_object[CONF_TYPE] == AreaType.META: 132 | continue 133 | 134 | mock_sensor = MockBinarySensor( 135 | name=f"motion_sensor_{area.value}", 136 | unique_id=f"motion_sensor_{area.value}", 137 | device_class=BinarySensorDeviceClass.MOTION, 138 | ) 139 | 140 | mock_binary_sensor_entities[area] = [ 141 | mock_sensor, 142 | ] 143 | 144 | await setup_mock_entities(hass, BINARY_SENSOR_DOMAIN, mock_binary_sensor_entities) 145 | 146 | return mock_binary_sensor_entities 147 | 148 | 149 | # Integration setups 150 | 151 | 152 | @pytest.fixture(name="_setup_integration_basic") 153 | async def setup_integration( 154 | hass: HomeAssistant, 155 | basic_config_entry: MockConfigEntry, 156 | ) -> AsyncGenerator[Any]: 157 | """Set up integration with basic config.""" 158 | 159 | await init_integration(hass, [basic_config_entry]) 160 | yield 161 | await shutdown_integration(hass, [basic_config_entry]) 162 | 163 | 164 | @pytest.fixture(name="_setup_integration_all_areas_with_meta") 165 | async def setup_integration_all_areas_with_meta( 166 | hass: HomeAssistant, 167 | all_areas_with_meta_config_entry: list[MockConfigEntry], 168 | ) -> AsyncGenerator[Any]: 169 | """Set up integration with all areas and meta-areas.""" 170 | 171 | non_meta_areas: list[MockAreaIds] = [] 172 | 173 | for area in MockAreaIds: 174 | area_object = MOCK_AREAS[area] 175 | assert area_object is not None 176 | if area_object[CONF_TYPE] == AreaType.META: 177 | continue 178 | non_meta_areas.append(area) 179 | 180 | assert len(non_meta_areas) > 0 181 | 182 | await init_integration( 183 | hass, 184 | all_areas_with_meta_config_entry, 185 | areas=non_meta_areas, 186 | ) 187 | yield 188 | await shutdown_integration( 189 | hass, 190 | all_areas_with_meta_config_entry, 191 | ) 192 | -------------------------------------------------------------------------------- /tests/const.py: -------------------------------------------------------------------------------- 1 | """Constants for Magic Areas tests.""" 2 | 3 | from enum import StrEnum, auto 4 | 5 | from homeassistant.const import ATTR_FLOOR_ID 6 | 7 | from custom_components.magic_areas.const import CONF_TYPE, AreaType 8 | 9 | 10 | class MockAreaIds(StrEnum): 11 | """StrEnum with ids of Mock Areas.""" 12 | 13 | KITCHEN = auto() 14 | LIVING_ROOM = auto() 15 | DINING_ROOM = auto() 16 | MASTER_BEDROOM = auto() 17 | GUEST_BEDROOM = auto() 18 | GARAGE = auto() 19 | BACKYARD = auto() 20 | FRONT_YARD = auto() 21 | INTERIOR = auto() 22 | EXTERIOR = auto() 23 | GLOBAL = auto() 24 | GROUND_LEVEL = auto() 25 | FIRST_FLOOR = auto() 26 | SECOND_FLOOR = auto() 27 | 28 | 29 | class MockFloorIds(StrEnum): 30 | """StrEnum with ids of Mock Floors.""" 31 | 32 | GROUND_LEVEL = auto() 33 | FIRST_FLOOR = auto() 34 | SECOND_FLOOR = auto() 35 | 36 | 37 | FLOOR_LEVEL_MAP: dict[MockFloorIds, int] = { 38 | MockFloorIds.GROUND_LEVEL: 0, 39 | MockFloorIds.FIRST_FLOOR: 1, 40 | MockFloorIds.SECOND_FLOOR: 2, 41 | } 42 | 43 | MOCK_AREAS: dict[MockAreaIds, dict[str, str | None]] = { 44 | MockAreaIds.KITCHEN: { 45 | CONF_TYPE: AreaType.INTERIOR, 46 | ATTR_FLOOR_ID: MockFloorIds.FIRST_FLOOR, 47 | }, 48 | MockAreaIds.LIVING_ROOM: { 49 | CONF_TYPE: AreaType.INTERIOR, 50 | ATTR_FLOOR_ID: MockFloorIds.FIRST_FLOOR, 51 | }, 52 | MockAreaIds.DINING_ROOM: { 53 | CONF_TYPE: AreaType.INTERIOR, 54 | ATTR_FLOOR_ID: MockFloorIds.FIRST_FLOOR, 55 | }, 56 | MockAreaIds.MASTER_BEDROOM: { 57 | CONF_TYPE: AreaType.INTERIOR, 58 | ATTR_FLOOR_ID: MockFloorIds.SECOND_FLOOR, 59 | }, 60 | MockAreaIds.GUEST_BEDROOM: { 61 | CONF_TYPE: AreaType.INTERIOR, 62 | ATTR_FLOOR_ID: MockFloorIds.SECOND_FLOOR, 63 | }, 64 | MockAreaIds.GARAGE: { 65 | CONF_TYPE: AreaType.INTERIOR, 66 | ATTR_FLOOR_ID: MockFloorIds.GROUND_LEVEL, 67 | }, 68 | MockAreaIds.BACKYARD: { 69 | CONF_TYPE: AreaType.EXTERIOR, 70 | ATTR_FLOOR_ID: MockFloorIds.GROUND_LEVEL, 71 | }, 72 | MockAreaIds.FRONT_YARD: { 73 | CONF_TYPE: AreaType.EXTERIOR, 74 | ATTR_FLOOR_ID: MockFloorIds.GROUND_LEVEL, 75 | }, 76 | MockAreaIds.INTERIOR: { 77 | CONF_TYPE: AreaType.META, 78 | ATTR_FLOOR_ID: None, 79 | }, 80 | MockAreaIds.EXTERIOR: { 81 | CONF_TYPE: AreaType.META, 82 | ATTR_FLOOR_ID: None, 83 | }, 84 | MockAreaIds.GLOBAL: { 85 | CONF_TYPE: AreaType.META, 86 | ATTR_FLOOR_ID: None, 87 | }, 88 | MockAreaIds.GROUND_LEVEL: { 89 | CONF_TYPE: AreaType.META, 90 | ATTR_FLOOR_ID: MockFloorIds.GROUND_LEVEL, 91 | }, 92 | MockAreaIds.FIRST_FLOOR: { 93 | CONF_TYPE: AreaType.META, 94 | ATTR_FLOOR_ID: MockFloorIds.FIRST_FLOOR, 95 | }, 96 | MockAreaIds.SECOND_FLOOR: { 97 | CONF_TYPE: AreaType.META, 98 | ATTR_FLOOR_ID: MockFloorIds.SECOND_FLOOR, 99 | }, 100 | } 101 | 102 | DEFAULT_MOCK_AREA: MockAreaIds = MockAreaIds.KITCHEN 103 | -------------------------------------------------------------------------------- /tests/test_ble_tracker_monitor.py: -------------------------------------------------------------------------------- 1 | """Tests for the BLE Tracker feature.""" 2 | 3 | from collections.abc import AsyncGenerator 4 | from typing import Any 5 | 6 | import pytest 7 | from pytest_homeassistant_custom_component.common import MockConfigEntry 8 | 9 | from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN 10 | from homeassistant.components.sensor.const import DOMAIN as SENSOR_DOMAIN 11 | from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, STATE_UNKNOWN 12 | from homeassistant.core import HomeAssistant 13 | 14 | from custom_components.magic_areas.const import ( 15 | ATTR_ACTIVE_SENSORS, 16 | ATTR_PRESENCE_SENSORS, 17 | CONF_BLE_TRACKER_ENTITIES, 18 | CONF_ENABLED_FEATURES, 19 | CONF_FEATURE_BLE_TRACKERS, 20 | DOMAIN, 21 | ) 22 | 23 | from tests.common import ( 24 | assert_in_attribute, 25 | assert_state, 26 | get_basic_config_entry_data, 27 | init_integration, 28 | setup_mock_entities, 29 | shutdown_integration, 30 | ) 31 | from tests.const import DEFAULT_MOCK_AREA 32 | from tests.mocks import MockSensor 33 | 34 | # Fixtures 35 | 36 | 37 | @pytest.fixture(name="ble_tracker_config_entry") 38 | def mock_config_entry_ble_tracker() -> MockConfigEntry: 39 | """Fixture for mock configuration entry.""" 40 | data = get_basic_config_entry_data(DEFAULT_MOCK_AREA) 41 | data.update( 42 | { 43 | CONF_ENABLED_FEATURES: { 44 | CONF_FEATURE_BLE_TRACKERS: { 45 | CONF_BLE_TRACKER_ENTITIES: ["sensor.ble_tracker_1"], 46 | } 47 | } 48 | } 49 | ) 50 | return MockConfigEntry(domain=DOMAIN, data=data) 51 | 52 | 53 | @pytest.fixture(name="_setup_integration_ble_tracker") 54 | async def setup_integration_ble_tracker( 55 | hass: HomeAssistant, 56 | ble_tracker_config_entry: MockConfigEntry, 57 | ) -> AsyncGenerator[Any]: 58 | """Set up integration with BLE tracker config.""" 59 | 60 | await init_integration(hass, [ble_tracker_config_entry]) 61 | yield 62 | await shutdown_integration(hass, [ble_tracker_config_entry]) 63 | 64 | 65 | # Entities 66 | 67 | 68 | @pytest.fixture(name="entities_ble_sensor_one") 69 | async def setup_entities_ble_sensor_one( 70 | hass: HomeAssistant, 71 | ) -> list[MockSensor]: 72 | """Create one mock sensor and setup the system with it.""" 73 | mock_ble_sensor_entities = [ 74 | MockSensor( 75 | name="ble_sensor_1", 76 | unique_id="unique_ble_sensor", 77 | device_class=None, 78 | ) 79 | ] 80 | await setup_mock_entities( 81 | hass, SENSOR_DOMAIN, {DEFAULT_MOCK_AREA: mock_ble_sensor_entities} 82 | ) 83 | return mock_ble_sensor_entities 84 | 85 | 86 | # Tests 87 | 88 | 89 | async def test_ble_tracker_presence_sensor( 90 | hass: HomeAssistant, 91 | entities_ble_sensor_one: list[MockSensor], 92 | _setup_integration_ble_tracker, 93 | ) -> None: 94 | """Test BLE tracker monitor functionality.""" 95 | 96 | ble_sensor_entity_id = "sensor.ble_tracker_1" 97 | ble_tracker_entity_id = f"{BINARY_SENSOR_DOMAIN}.magic_areas_ble_trackers_{DEFAULT_MOCK_AREA.value}_ble_tracker_monitor" 98 | area_sensor_entity_id = f"{BINARY_SENSOR_DOMAIN}.magic_areas_presence_tracking_{DEFAULT_MOCK_AREA.value}_area_state" 99 | 100 | hass.states.async_set(ble_sensor_entity_id, STATE_UNKNOWN) 101 | await hass.async_block_till_done() 102 | 103 | ble_sensor_state = hass.states.get(ble_sensor_entity_id) 104 | 105 | assert ble_sensor_state is not None 106 | assert ble_sensor_state.state is not None 107 | 108 | ble_tracker_state = hass.states.get(ble_tracker_entity_id) 109 | assert ble_tracker_state is not None 110 | assert ble_tracker_state.state == STATE_OFF 111 | assert ble_sensor_entity_id in ble_tracker_state.attributes[ATTR_ENTITY_ID] 112 | 113 | area_sensor_state = hass.states.get(area_sensor_entity_id) 114 | assert area_sensor_state is not None 115 | assert area_sensor_state.state == STATE_OFF 116 | assert ble_tracker_entity_id in area_sensor_state.attributes[ATTR_PRESENCE_SENSORS] 117 | 118 | # Set BLE sensor to DEFAULT_MOCK_AREA 119 | hass.states.async_set(ble_sensor_entity_id, DEFAULT_MOCK_AREA.value) 120 | await hass.async_block_till_done() 121 | 122 | ble_sensor_state = hass.states.get(ble_sensor_entity_id) 123 | assert_state(ble_sensor_state, DEFAULT_MOCK_AREA.value) 124 | 125 | ble_tracker_state = hass.states.get(ble_tracker_entity_id) 126 | assert_state(ble_tracker_state, STATE_ON) 127 | 128 | area_sensor_state = hass.states.get(area_sensor_entity_id) 129 | assert_state(area_sensor_state, STATE_ON) 130 | assert_in_attribute(area_sensor_state, ATTR_ACTIVE_SENSORS, ble_tracker_entity_id) 131 | 132 | # Set BLE sensor to something else 133 | hass.states.async_set(ble_sensor_entity_id, STATE_UNKNOWN) 134 | await hass.async_block_till_done() 135 | 136 | ble_sensor_state = hass.states.get(ble_sensor_entity_id) 137 | assert_state(ble_sensor_state, STATE_UNKNOWN) 138 | 139 | ble_tracker_state = hass.states.get(ble_tracker_entity_id) 140 | assert_state(ble_tracker_state, STATE_OFF) 141 | 142 | area_sensor_state = hass.states.get(area_sensor_entity_id) 143 | assert_state(area_sensor_state, STATE_OFF) 144 | -------------------------------------------------------------------------------- /tests/test_climate_control.py: -------------------------------------------------------------------------------- 1 | """Tests for the BLE Tracker feature.""" 2 | 3 | from collections.abc import AsyncGenerator 4 | import logging 5 | from typing import Any 6 | 7 | import pytest 8 | from pytest_homeassistant_custom_component.common import MockConfigEntry 9 | 10 | from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN 11 | from homeassistant.components.climate.const import ( 12 | ATTR_PRESET_MODE, 13 | DOMAIN as CLIMATE_DOMAIN, 14 | PRESET_AWAY, 15 | PRESET_ECO, 16 | PRESET_NONE, 17 | SERVICE_SET_PRESET_MODE, 18 | HVACMode, 19 | ) 20 | from homeassistant.components.switch.const import DOMAIN as SWITCH_DOMAIN 21 | from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_ON, STATE_OFF, STATE_ON 22 | from homeassistant.core import HomeAssistant 23 | 24 | from custom_components.magic_areas.const import ( 25 | CONF_CLIMATE_CONTROL_ENTITY_ID, 26 | CONF_CLIMATE_CONTROL_PRESET_CLEAR, 27 | CONF_CLIMATE_CONTROL_PRESET_OCCUPIED, 28 | CONF_ENABLED_FEATURES, 29 | DOMAIN, 30 | MagicAreasFeatures, 31 | ) 32 | 33 | from tests.common import ( 34 | assert_attribute, 35 | assert_state, 36 | get_basic_config_entry_data, 37 | init_integration, 38 | setup_mock_entities, 39 | shutdown_integration, 40 | ) 41 | from tests.const import DEFAULT_MOCK_AREA 42 | from tests.mocks import MockBinarySensor, MockClimate 43 | 44 | _LOGGER = logging.getLogger(__name__) 45 | 46 | 47 | # Constants 48 | 49 | MOCK_CLIMATE_ENTITY_ID = f"{CLIMATE_DOMAIN}.mock_climate" 50 | CLIMATE_CONTROL_SWITCH_ENTITY_ID = ( 51 | f"{SWITCH_DOMAIN}.magic_areas_climate_control_{DEFAULT_MOCK_AREA}" 52 | ) 53 | AREA_SENSOR_ENTITY_ID = f"{BINARY_SENSOR_DOMAIN}.magic_areas_presence_tracking_{DEFAULT_MOCK_AREA}_area_state" 54 | 55 | 56 | # Fixtures 57 | 58 | 59 | @pytest.fixture(name="climate_control_config_entry") 60 | def mock_config_entry_climate_control() -> MockConfigEntry: 61 | """Fixture for mock configuration entry.""" 62 | data = get_basic_config_entry_data(DEFAULT_MOCK_AREA) 63 | data.update( 64 | { 65 | CONF_ENABLED_FEATURES: { 66 | MagicAreasFeatures.CLIMATE_CONTROL: { 67 | CONF_CLIMATE_CONTROL_ENTITY_ID: MOCK_CLIMATE_ENTITY_ID, 68 | CONF_CLIMATE_CONTROL_PRESET_OCCUPIED: PRESET_NONE, 69 | CONF_CLIMATE_CONTROL_PRESET_CLEAR: PRESET_AWAY, 70 | }, 71 | } 72 | } 73 | ) 74 | return MockConfigEntry(domain=DOMAIN, data=data) 75 | 76 | 77 | @pytest.fixture(name="_setup_integration_climate_control") 78 | async def setup_integration_climate_control( 79 | hass: HomeAssistant, 80 | climate_control_config_entry: MockConfigEntry, 81 | ) -> AsyncGenerator[Any]: 82 | """Set up integration with BLE tracker config.""" 83 | 84 | await init_integration(hass, [climate_control_config_entry]) 85 | yield 86 | await shutdown_integration(hass, [climate_control_config_entry]) 87 | 88 | 89 | # Entities 90 | 91 | 92 | @pytest.fixture(name="entities_climate_one") 93 | async def setup_entities_climate_one( 94 | hass: HomeAssistant, 95 | ) -> list[MockClimate]: 96 | """Create one mock climate and setup the system with it.""" 97 | mock_climate_entities = [ 98 | MockClimate( 99 | name="mock_climate", 100 | unique_id="unique_mock_climate", 101 | ) 102 | ] 103 | await setup_mock_entities( 104 | hass, CLIMATE_DOMAIN, {DEFAULT_MOCK_AREA: mock_climate_entities} 105 | ) 106 | return mock_climate_entities 107 | 108 | 109 | # Tests 110 | 111 | 112 | async def test_climate_control_init( 113 | hass: HomeAssistant, 114 | entities_climate_one: list[MockClimate], 115 | _setup_integration_climate_control, 116 | ) -> None: 117 | """Test climate control.""" 118 | 119 | area_sensor_state = hass.states.get(AREA_SENSOR_ENTITY_ID) 120 | assert_state(area_sensor_state, STATE_OFF) 121 | 122 | climate_control_switch_state = hass.states.get(CLIMATE_CONTROL_SWITCH_ENTITY_ID) 123 | assert_state(climate_control_switch_state, STATE_OFF) 124 | 125 | climate_state = hass.states.get(MOCK_CLIMATE_ENTITY_ID) 126 | assert_state(climate_state, STATE_OFF) 127 | 128 | # Turn on the climate device 129 | await hass.services.async_call( 130 | CLIMATE_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: MOCK_CLIMATE_ENTITY_ID} 131 | ) 132 | await hass.async_block_till_done() 133 | 134 | climate_state = hass.states.get(MOCK_CLIMATE_ENTITY_ID) 135 | assert_state(climate_state, HVACMode.AUTO) 136 | 137 | # Reset preset mode 138 | await hass.services.async_call( 139 | CLIMATE_DOMAIN, 140 | SERVICE_SET_PRESET_MODE, 141 | {ATTR_ENTITY_ID: MOCK_CLIMATE_ENTITY_ID, ATTR_PRESET_MODE: PRESET_ECO}, 142 | ) 143 | await hass.async_block_till_done() 144 | 145 | climate_state = hass.states.get(MOCK_CLIMATE_ENTITY_ID) 146 | assert_attribute(climate_state, ATTR_PRESET_MODE, PRESET_ECO) 147 | 148 | 149 | async def test_climate_control_logic( 150 | hass: HomeAssistant, 151 | entities_climate_one: list[MockClimate], 152 | entities_binary_sensor_motion_one: list[MockBinarySensor], 153 | _setup_integration_climate_control, 154 | ) -> None: 155 | """Test climate control logic.""" 156 | 157 | motion_sensor_entity_id = entities_binary_sensor_motion_one[0].entity_id 158 | motion_sensor_state = hass.states.get(motion_sensor_entity_id) 159 | assert_state(motion_sensor_state, STATE_OFF) 160 | 161 | # Turn on the climate device 162 | await hass.services.async_call( 163 | CLIMATE_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: MOCK_CLIMATE_ENTITY_ID} 164 | ) 165 | await hass.async_block_till_done() 166 | 167 | climate_state = hass.states.get(MOCK_CLIMATE_ENTITY_ID) 168 | assert_state(climate_state, HVACMode.AUTO) 169 | 170 | # Set initial preset to something we don't use, so we know we changed from it 171 | await hass.services.async_call( 172 | CLIMATE_DOMAIN, 173 | SERVICE_SET_PRESET_MODE, 174 | {ATTR_ENTITY_ID: MOCK_CLIMATE_ENTITY_ID, ATTR_PRESET_MODE: PRESET_ECO}, 175 | ) 176 | await hass.async_block_till_done() 177 | 178 | climate_state = hass.states.get(MOCK_CLIMATE_ENTITY_ID) 179 | assert_attribute(climate_state, ATTR_PRESET_MODE, PRESET_ECO) 180 | 181 | # @TODO test control off, ensure nothing happens 182 | 183 | # Turn on climate control 184 | await hass.services.async_call( 185 | SWITCH_DOMAIN, 186 | SERVICE_TURN_ON, 187 | {ATTR_ENTITY_ID: CLIMATE_CONTROL_SWITCH_ENTITY_ID}, 188 | ) 189 | await hass.async_block_till_done() 190 | 191 | # Area occupied, preset should be PRESET_NONE 192 | hass.states.async_set(motion_sensor_entity_id, STATE_ON) 193 | await hass.async_block_till_done() 194 | 195 | motion_sensor_state = hass.states.get(motion_sensor_entity_id) 196 | assert_state(motion_sensor_state, STATE_ON) 197 | 198 | area_sensor_state = hass.states.get(AREA_SENSOR_ENTITY_ID) 199 | assert_state(area_sensor_state, STATE_ON) 200 | 201 | climate_state = hass.states.get(MOCK_CLIMATE_ENTITY_ID) 202 | assert_attribute(climate_state, ATTR_PRESET_MODE, PRESET_NONE) 203 | 204 | # Area clear, preset should be PRESET_AWAY 205 | hass.states.async_set(motion_sensor_entity_id, STATE_OFF) 206 | await hass.async_block_till_done() 207 | 208 | motion_sensor_state = hass.states.get(motion_sensor_entity_id) 209 | assert_state(motion_sensor_state, STATE_OFF) 210 | 211 | area_sensor_state = hass.states.get(AREA_SENSOR_ENTITY_ID) 212 | assert_state(area_sensor_state, STATE_OFF) 213 | 214 | climate_state = hass.states.get(MOCK_CLIMATE_ENTITY_ID) 215 | assert_attribute(climate_state, ATTR_PRESET_MODE, PRESET_AWAY) 216 | -------------------------------------------------------------------------------- /tests/test_cover.py: -------------------------------------------------------------------------------- 1 | """Test for cover groups.""" 2 | 3 | from collections import defaultdict 4 | from collections.abc import AsyncGenerator 5 | import logging 6 | from typing import Any 7 | 8 | import pytest 9 | from pytest_homeassistant_custom_component.common import MockConfigEntry 10 | 11 | from homeassistant.components.cover import CoverDeviceClass 12 | from homeassistant.components.cover.const import DOMAIN as COVER_DOMAIN 13 | from homeassistant.const import ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, STATE_OPEN 14 | from homeassistant.core import HomeAssistant 15 | 16 | from custom_components.magic_areas.const import ( 17 | CONF_ENABLED_FEATURES, 18 | CONF_FEATURE_COVER_GROUPS, 19 | DOMAIN, 20 | ) 21 | 22 | from tests.common import ( 23 | get_basic_config_entry_data, 24 | init_integration, 25 | setup_mock_entities, 26 | shutdown_integration, 27 | ) 28 | from tests.const import DEFAULT_MOCK_AREA 29 | from tests.mocks import MockCover 30 | 31 | _LOGGER = logging.getLogger(__name__) 32 | 33 | 34 | # Fixtures 35 | 36 | 37 | @pytest.fixture(name="cover_groups_config_entry") 38 | def mock_config_entry_cover_groups() -> MockConfigEntry: 39 | """Fixture for mock configuration entry.""" 40 | data = get_basic_config_entry_data(DEFAULT_MOCK_AREA) 41 | data.update({CONF_ENABLED_FEATURES: {CONF_FEATURE_COVER_GROUPS: {}}}) 42 | return MockConfigEntry(domain=DOMAIN, data=data) 43 | 44 | 45 | @pytest.fixture(name="_setup_integration_cover_group") 46 | async def setup_integration_cover_group( 47 | hass: HomeAssistant, 48 | cover_groups_config_entry: MockConfigEntry, 49 | ) -> AsyncGenerator[Any]: 50 | """Set up integration with secondary states config.""" 51 | 52 | await init_integration(hass, [cover_groups_config_entry]) 53 | yield 54 | await shutdown_integration(hass, [cover_groups_config_entry]) 55 | 56 | 57 | # Entities 58 | 59 | 60 | @pytest.fixture(name="entities_sensor_cover_all_classes_multiple") 61 | async def setup_entities_sensor_cover_all_classes_multiple( 62 | hass: HomeAssistant, 63 | ) -> list[MockCover]: 64 | """Create multiple mock sensor and setup the system with it.""" 65 | 66 | nr_entities = 3 67 | mock_cover_entities = [] 68 | 69 | for dc in CoverDeviceClass: 70 | for i in range(nr_entities): 71 | mock_cover_entities.append( 72 | MockCover( 73 | name=f"cover_{dc.value}_{i}", 74 | unique_id=f"cover_{dc.value}_{i}", 75 | device_class=dc.value, 76 | ) 77 | ) 78 | await setup_mock_entities( 79 | hass, COVER_DOMAIN, {DEFAULT_MOCK_AREA: mock_cover_entities} 80 | ) 81 | return mock_cover_entities 82 | 83 | 84 | # Tests 85 | 86 | 87 | async def test_cover_group_basic( 88 | hass: HomeAssistant, 89 | entities_sensor_cover_all_classes_multiple: list[MockCover], 90 | _setup_integration_cover_group, 91 | ) -> None: 92 | """Test cover group.""" 93 | 94 | cover_group_entity_id_base = ( 95 | f"{COVER_DOMAIN}.magic_areas_cover_groups_kitchen_cover_group_" 96 | ) 97 | entity_map = defaultdict(list) 98 | 99 | # Ensure all mock entities exist and map 100 | for cover in entities_sensor_cover_all_classes_multiple: 101 | cover_state = hass.states.get(cover.entity_id) 102 | assert cover_state is not None 103 | assert cover_state.state == STATE_OPEN 104 | assert hasattr(cover_state, "attributes") 105 | assert ATTR_DEVICE_CLASS in cover_state.attributes 106 | entity_map[cover_state.attributes[ATTR_DEVICE_CLASS]].append(cover) 107 | 108 | for dc in CoverDeviceClass: 109 | group_entity_id = f"{cover_group_entity_id_base}{dc.value}" 110 | 111 | # Ensure cover group exists and has its children 112 | group_entity_state = hass.states.get(group_entity_id) 113 | assert group_entity_state is not None 114 | assert group_entity_state.state == STATE_OPEN 115 | assert hasattr(group_entity_state, "attributes") 116 | assert ATTR_ENTITY_ID in group_entity_state.attributes 117 | for child_cover in entity_map[dc.value]: 118 | assert ( 119 | child_cover.entity_id in group_entity_state.attributes[ATTR_ENTITY_ID] 120 | ) 121 | -------------------------------------------------------------------------------- /tests/test_init.py: -------------------------------------------------------------------------------- 1 | """Test initializing the system.""" 2 | 3 | import logging 4 | 5 | from pytest_homeassistant_custom_component.common import MockConfigEntry 6 | 7 | from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN 8 | from homeassistant.const import STATE_OFF 9 | from homeassistant.core import HomeAssistant 10 | 11 | from tests.common import assert_state 12 | 13 | _LOGGER = logging.getLogger(__name__) 14 | 15 | 16 | async def test_init_default_config( 17 | hass: HomeAssistant, basic_config_entry: MockConfigEntry, _setup_integration_basic 18 | ) -> None: 19 | """Test loading the integration.""" 20 | 21 | # Validate the right enties were created. 22 | area_binary_sensor = hass.states.get( 23 | f"{BINARY_SENSOR_DOMAIN}.magic_areas_presence_tracking_kitchen_area_state" 24 | ) 25 | 26 | assert_state(area_binary_sensor, STATE_OFF) 27 | -------------------------------------------------------------------------------- /tests/test_light.py: -------------------------------------------------------------------------------- 1 | """Test for light groups.""" 2 | 3 | import asyncio 4 | from collections.abc import AsyncGenerator 5 | import logging 6 | from typing import Any 7 | 8 | import pytest 9 | from pytest_homeassistant_custom_component.common import MockConfigEntry 10 | 11 | from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN 12 | from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN 13 | from homeassistant.components.switch.const import DOMAIN as SWITCH_DOMAIN 14 | from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_ON, STATE_OFF, STATE_ON 15 | from homeassistant.core import HomeAssistant 16 | 17 | from custom_components.magic_areas.const import ( 18 | CONF_ENABLED_FEATURES, 19 | CONF_FEATURE_LIGHT_GROUPS, 20 | CONF_OVERHEAD_LIGHTS, 21 | CONF_OVERHEAD_LIGHTS_ACT_ON, 22 | CONF_OVERHEAD_LIGHTS_STATES, 23 | DOMAIN, 24 | LIGHT_GROUP_ACT_ON_OCCUPANCY_CHANGE, 25 | AreaStates, 26 | ) 27 | 28 | from tests.common import ( 29 | assert_in_attribute, 30 | assert_state, 31 | get_basic_config_entry_data, 32 | init_integration, 33 | setup_mock_entities, 34 | shutdown_integration, 35 | ) 36 | from tests.const import DEFAULT_MOCK_AREA 37 | from tests.mocks import MockBinarySensor, MockLight 38 | 39 | _LOGGER = logging.getLogger(__name__) 40 | 41 | 42 | # Fixtures 43 | 44 | 45 | @pytest.fixture(name="light_groups_config_entry") 46 | def mock_config_entry_light_groups() -> MockConfigEntry: 47 | """Fixture for mock configuration entry.""" 48 | data = get_basic_config_entry_data(DEFAULT_MOCK_AREA) 49 | data.update( 50 | { 51 | CONF_ENABLED_FEATURES: { 52 | CONF_FEATURE_LIGHT_GROUPS: { 53 | CONF_OVERHEAD_LIGHTS: ["light.mock_light_1"], 54 | CONF_OVERHEAD_LIGHTS_ACT_ON: [LIGHT_GROUP_ACT_ON_OCCUPANCY_CHANGE], 55 | CONF_OVERHEAD_LIGHTS_STATES: [AreaStates.OCCUPIED], 56 | }, 57 | } 58 | } 59 | ) 60 | return MockConfigEntry(domain=DOMAIN, data=data) 61 | 62 | 63 | @pytest.fixture(name="_setup_integration_light_groups") 64 | async def setup_integration_light_groups( 65 | hass: HomeAssistant, 66 | light_groups_config_entry: MockConfigEntry, 67 | ) -> AsyncGenerator[Any]: 68 | """Set up integration with BLE tracker config.""" 69 | 70 | await init_integration(hass, [light_groups_config_entry]) 71 | yield 72 | await shutdown_integration(hass, [light_groups_config_entry]) 73 | 74 | 75 | # Entities 76 | 77 | 78 | @pytest.fixture(name="entities_light_one") 79 | async def setup_entities_light_one( 80 | hass: HomeAssistant, 81 | ) -> list[MockLight]: 82 | """Create one mock light and setup the system with it.""" 83 | mock_light_entities = [ 84 | MockLight( 85 | name="mock_light_1", 86 | state="off", 87 | unique_id="unique_light", 88 | ) 89 | ] 90 | await setup_mock_entities( 91 | hass, LIGHT_DOMAIN, {DEFAULT_MOCK_AREA: mock_light_entities} 92 | ) 93 | return mock_light_entities 94 | 95 | 96 | # Tests 97 | 98 | 99 | async def test_light_group_basic( 100 | hass: HomeAssistant, 101 | entities_light_one: list[MockLight], 102 | entities_binary_sensor_motion_one: list[MockBinarySensor], 103 | _setup_integration_light_groups, 104 | ) -> None: 105 | """Test light group.""" 106 | 107 | mock_light_entity_id = entities_light_one[0].entity_id 108 | mock_motion_sensor_entity_id = entities_binary_sensor_motion_one[0].entity_id 109 | light_group_entity_id = ( 110 | f"{LIGHT_DOMAIN}.magic_areas_light_groups_{DEFAULT_MOCK_AREA}_overhead_lights" 111 | ) 112 | light_control_entity_id = ( 113 | f"{SWITCH_DOMAIN}.magic_areas_light_groups_{DEFAULT_MOCK_AREA}_light_control" 114 | ) 115 | area_sensor_entity_id = f"{BINARY_SENSOR_DOMAIN}.magic_areas_presence_tracking_{DEFAULT_MOCK_AREA}_area_state" 116 | 117 | # Test mock entity created 118 | mock_light_state = hass.states.get(mock_light_entity_id) 119 | assert_state(mock_light_state, STATE_OFF) 120 | 121 | # Test light group created 122 | light_group_state = hass.states.get(light_group_entity_id) 123 | assert_state(light_group_state, STATE_OFF) 124 | assert_in_attribute(light_group_state, ATTR_ENTITY_ID, mock_light_entity_id) 125 | 126 | # Test light control switch created 127 | light_control_state = hass.states.get(light_control_entity_id) 128 | assert_state(light_control_state, STATE_OFF) 129 | 130 | # Test motion sensor created 131 | motion_sensor_state = hass.states.get(mock_motion_sensor_entity_id) 132 | assert_state(motion_sensor_state, STATE_OFF) 133 | 134 | # Test area state 135 | area_state = hass.states.get(area_sensor_entity_id) 136 | assert_state(area_state, STATE_OFF) 137 | 138 | # Turn on light control 139 | hass.states.async_set(light_control_entity_id, STATE_ON) 140 | await hass.services.async_call( 141 | SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: light_control_entity_id} 142 | ) 143 | await hass.async_block_till_done() 144 | 145 | # Test light control switch state turned on 146 | light_control_state = hass.states.get(light_control_entity_id) 147 | assert_state(light_control_state, STATE_ON) 148 | 149 | # Turn motion sensor on 150 | hass.states.async_set(mock_motion_sensor_entity_id, STATE_ON) 151 | await hass.async_block_till_done() 152 | 153 | motion_sensor_state = hass.states.get(mock_motion_sensor_entity_id) 154 | assert_state(motion_sensor_state, STATE_ON) 155 | 156 | # Test area state is STATE_ON 157 | area_state = hass.states.get(area_sensor_entity_id) 158 | assert_state(area_state, STATE_ON) 159 | 160 | await asyncio.sleep(1) 161 | 162 | # Check light group is on 163 | light_group_state = hass.states.get(light_group_entity_id) 164 | assert_state(light_group_state, STATE_ON) 165 | 166 | # Turn motion sensor off 167 | hass.states.async_set(mock_motion_sensor_entity_id, STATE_OFF) 168 | await hass.async_block_till_done() 169 | 170 | # Test area state is STATE_OFF 171 | area_state = hass.states.get(area_sensor_entity_id) 172 | assert_state(area_state, STATE_OFF) 173 | 174 | # Check light group is off 175 | light_group_state = hass.states.get(light_group_entity_id) 176 | assert_state(light_group_state, STATE_OFF) 177 | -------------------------------------------------------------------------------- /tests/test_media_player.py: -------------------------------------------------------------------------------- 1 | """Test for aggregate (group) sensor behavior.""" 2 | 3 | from collections.abc import AsyncGenerator 4 | import logging 5 | from typing import Any 6 | 7 | import pytest 8 | from pytest_homeassistant_custom_component.common import MockConfigEntry 9 | 10 | from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN 11 | from homeassistant.components.media_player.const import ( 12 | ATTR_MEDIA_CONTENT_ID, 13 | ATTR_MEDIA_CONTENT_TYPE, 14 | DOMAIN as MEDIA_PLAYER_DOMAIN, 15 | SERVICE_PLAY_MEDIA, 16 | MediaType, 17 | ) 18 | from homeassistant.const import ( 19 | ATTR_ENTITY_ID, 20 | SERVICE_MEDIA_STOP, 21 | STATE_IDLE, 22 | STATE_OFF, 23 | STATE_ON, 24 | STATE_PLAYING, 25 | ) 26 | from homeassistant.core import HomeAssistant 27 | 28 | from custom_components.magic_areas.const import ( 29 | ATTR_STATES, 30 | CONF_ENABLED_FEATURES, 31 | CONF_FEATURE_AREA_AWARE_MEDIA_PLAYER, 32 | CONF_NOTIFICATION_DEVICES, 33 | CONF_NOTIFY_STATES, 34 | DOMAIN, 35 | AreaStates, 36 | ) 37 | 38 | from tests.common import ( 39 | assert_in_attribute, 40 | assert_state, 41 | get_basic_config_entry_data, 42 | init_integration, 43 | setup_mock_entities, 44 | shutdown_integration, 45 | ) 46 | from tests.const import DEFAULT_MOCK_AREA, MockAreaIds 47 | from tests.mocks import MockBinarySensor, MockMediaPlayer 48 | 49 | _LOGGER = logging.getLogger(__name__) 50 | 51 | 52 | # Fixtures 53 | 54 | 55 | @pytest.fixture(name="area_aware_media_player_global_config_entry") 56 | def mock_config_entry_area_aware_media_player_global() -> MockConfigEntry: 57 | """Fixture for mock configuration entry.""" 58 | data = get_basic_config_entry_data(MockAreaIds.GLOBAL) 59 | return MockConfigEntry(domain=DOMAIN, data=data) 60 | 61 | 62 | @pytest.fixture(name="area_aware_media_player_area_config_entry") 63 | def mock_config_entry_area_aware_media_player_area() -> MockConfigEntry: 64 | """Fixture for mock configuration entry.""" 65 | data = get_basic_config_entry_data(DEFAULT_MOCK_AREA) 66 | data.update( 67 | { 68 | CONF_ENABLED_FEATURES: { 69 | CONF_FEATURE_AREA_AWARE_MEDIA_PLAYER: { 70 | CONF_NOTIFICATION_DEVICES: ["media_player.media_player_1"], 71 | CONF_NOTIFY_STATES: [AreaStates.OCCUPIED], 72 | } 73 | } 74 | } 75 | ) 76 | return MockConfigEntry(domain=DOMAIN, data=data) 77 | 78 | 79 | @pytest.fixture(name="_setup_integration_area_aware_media_player") 80 | async def setup_integration_area_aware_media_player( 81 | hass: HomeAssistant, 82 | area_aware_media_player_global_config_entry: MockConfigEntry, 83 | area_aware_media_player_area_config_entry: MockConfigEntry, 84 | ) -> AsyncGenerator[Any]: 85 | """Set up integration with secondary states config.""" 86 | 87 | await init_integration( 88 | hass, 89 | [ 90 | area_aware_media_player_area_config_entry, 91 | area_aware_media_player_global_config_entry, 92 | ], 93 | ) 94 | yield 95 | await shutdown_integration( 96 | hass, 97 | [ 98 | area_aware_media_player_area_config_entry, 99 | area_aware_media_player_global_config_entry, 100 | ], 101 | ) 102 | 103 | 104 | # Entities 105 | 106 | 107 | @pytest.fixture(name="entities_media_player_single") 108 | async def setup_entities_media_player_single( 109 | hass: HomeAssistant, 110 | ) -> list[MockMediaPlayer]: 111 | """Create multiple mock sensor and setup the system with it.""" 112 | 113 | mock_media_player_entities = [] 114 | 115 | mock_media_player_entities.append( 116 | MockMediaPlayer(name="media_player_1", unique_id="media_player_1") 117 | ) 118 | await setup_mock_entities( 119 | hass, MEDIA_PLAYER_DOMAIN, {DEFAULT_MOCK_AREA: mock_media_player_entities} 120 | ) 121 | return mock_media_player_entities 122 | 123 | 124 | # Tests 125 | 126 | 127 | async def test_area_aware_media_player( 128 | hass: HomeAssistant, 129 | entities_media_player_single: list[MockMediaPlayer], 130 | entities_binary_sensor_motion_one: list[MockBinarySensor], 131 | _setup_integration_area_aware_media_player: AsyncGenerator[Any, None], 132 | ) -> None: 133 | """Test the area aware media player.""" 134 | 135 | area_aware_media_player_id = ( 136 | f"{MEDIA_PLAYER_DOMAIN}.magic_areas_area_aware_media_player_global" 137 | ) 138 | 139 | area_sensor_entity_id = ( 140 | f"{BINARY_SENSOR_DOMAIN}.magic_areas_presence_tracking_kitchen_area_state" 141 | ) 142 | motion_sensor_entity_id = entities_binary_sensor_motion_one[0].entity_id 143 | 144 | # Initialization tests 145 | 146 | # Mock media player 147 | media_player_state = hass.states.get(entities_media_player_single[0].entity_id) 148 | assert_state(media_player_state, STATE_OFF) 149 | 150 | # Area-Aware media player 151 | area_aware_media_player_state = hass.states.get(area_aware_media_player_id) 152 | assert_state(area_aware_media_player_state, STATE_IDLE) 153 | 154 | # Test area clear 155 | 156 | # Ensure area is clear 157 | area_state = hass.states.get(area_sensor_entity_id) 158 | assert_state(area_state, STATE_OFF) 159 | 160 | # Send play to AAMP 161 | service_data = { 162 | ATTR_ENTITY_ID: area_aware_media_player_id, 163 | ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, 164 | ATTR_MEDIA_CONTENT_ID: 42, 165 | } 166 | await hass.services.async_call( 167 | MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, service_data 168 | ) 169 | await hass.async_block_till_done() 170 | 171 | # Ensure area MP & AAMP is NOT playing 172 | media_player_state = hass.states.get(entities_media_player_single[0].entity_id) 173 | assert_state(media_player_state, STATE_OFF) 174 | 175 | area_aware_media_player_state = hass.states.get(area_aware_media_player_id) 176 | assert_state(area_aware_media_player_state, STATE_IDLE) 177 | 178 | # Test area occupied 179 | 180 | # Ensure area is occupied 181 | # Turn on motion sensor 182 | hass.states.async_set(motion_sensor_entity_id, STATE_ON) 183 | await hass.async_block_till_done() 184 | 185 | area_binary_sensor = hass.states.get(area_sensor_entity_id) 186 | motion_sensor = hass.states.get(motion_sensor_entity_id) 187 | 188 | assert_state(motion_sensor, STATE_ON) 189 | assert_state(area_binary_sensor, STATE_ON) 190 | assert_in_attribute(area_binary_sensor, ATTR_STATES, AreaStates.OCCUPIED) 191 | 192 | # Send play to AAMP 193 | service_data = { 194 | ATTR_ENTITY_ID: area_aware_media_player_id, 195 | ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, 196 | ATTR_MEDIA_CONTENT_ID: 42, 197 | } 198 | await hass.services.async_call( 199 | MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, service_data 200 | ) 201 | await hass.async_block_till_done() 202 | 203 | # Ensure area MP is playing 204 | media_player_state = hass.states.get(entities_media_player_single[0].entity_id) 205 | assert_state(media_player_state, STATE_PLAYING) 206 | 207 | area_aware_media_player_state = hass.states.get(area_aware_media_player_id) 208 | assert_state(area_aware_media_player_state, STATE_IDLE) 209 | 210 | # Turn off area MP 211 | await hass.services.async_call( 212 | MEDIA_PLAYER_DOMAIN, 213 | SERVICE_MEDIA_STOP, 214 | {ATTR_ENTITY_ID: entities_media_player_single[0].entity_id}, 215 | ) 216 | await hass.async_block_till_done() 217 | 218 | # Ensure area MP is stopped 219 | media_player_state = hass.states.get(entities_media_player_single[0].entity_id) 220 | assert_state(media_player_state, STATE_IDLE) 221 | 222 | # Test area occupied + sleep 223 | 224 | # Ensure area is occupied + sleep 225 | 226 | # Send play to AAMP 227 | 228 | # Ensure area MP is NOT playing 229 | 230 | # Turn off AAMP 231 | -------------------------------------------------------------------------------- /tests/test_meta_aggregates.py: -------------------------------------------------------------------------------- 1 | """Test for aggregates on meta areas.""" 2 | 3 | import logging 4 | 5 | from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN 6 | from homeassistant.const import STATE_OFF, STATE_ON 7 | from homeassistant.core import HomeAssistant 8 | 9 | from tests.common import assert_state 10 | from tests.const import MockAreaIds 11 | from tests.mocks import MockBinarySensor 12 | 13 | _LOGGER = logging.getLogger(__name__) 14 | 15 | 16 | # Tests 17 | async def test_meta_aggregates_binary_sensor( 18 | hass: HomeAssistant, 19 | entities_binary_sensor_motion_all_areas_with_meta: dict[ 20 | MockAreaIds, list[MockBinarySensor] 21 | ], 22 | _setup_integration_all_areas_with_meta, 23 | ) -> None: 24 | """Test aggregation of binary sensor states.""" 25 | 26 | # Entity Ids 27 | interior_aggregate_sensor_entity_id = f"{BINARY_SENSOR_DOMAIN}.magic_areas_aggregates_{MockAreaIds.INTERIOR.value}_aggregate_motion" 28 | exterior_aggregate_sensor_entity_id = f"{BINARY_SENSOR_DOMAIN}.magic_areas_aggregates_{MockAreaIds.EXTERIOR.value}_aggregate_motion" 29 | global_aggregate_sensor_entity_id = f"{BINARY_SENSOR_DOMAIN}.magic_areas_aggregates_{MockAreaIds.GLOBAL.value}_aggregate_motion" 30 | ground_level_aggregate_sensor_entity_id = f"{BINARY_SENSOR_DOMAIN}.magic_areas_aggregates_{MockAreaIds.GROUND_LEVEL.value}_aggregate_motion" 31 | first_floor_aggregate_sensor_entity_id = f"{BINARY_SENSOR_DOMAIN}.magic_areas_aggregates_{MockAreaIds.FIRST_FLOOR.value}_aggregate_motion" 32 | second_floor_aggregate_sensor_entity_id = f"{BINARY_SENSOR_DOMAIN}.magic_areas_aggregates_{MockAreaIds.SECOND_FLOOR.value}_aggregate_motion" 33 | 34 | # First Floor + Interior + Global 35 | kitchen_motion_sensor_id = entities_binary_sensor_motion_all_areas_with_meta[ 36 | MockAreaIds.KITCHEN 37 | ][0].entity_id 38 | 39 | # > On 40 | 41 | hass.states.async_set(kitchen_motion_sensor_id, STATE_ON) 42 | await hass.async_block_till_done() 43 | 44 | kitchen_motion_sensor_state = hass.states.get(kitchen_motion_sensor_id) 45 | assert_state(kitchen_motion_sensor_state, STATE_ON) 46 | 47 | first_floor_motion_sensor_state = hass.states.get( 48 | first_floor_aggregate_sensor_entity_id 49 | ) 50 | assert_state(first_floor_motion_sensor_state, STATE_ON) 51 | 52 | interior_motion_sensor_state = hass.states.get(interior_aggregate_sensor_entity_id) 53 | assert_state(interior_motion_sensor_state, STATE_ON) 54 | 55 | global_motion_sensor_state = hass.states.get(global_aggregate_sensor_entity_id) 56 | assert_state(global_motion_sensor_state, STATE_ON) 57 | 58 | # > Off 59 | 60 | hass.states.async_set(kitchen_motion_sensor_id, STATE_OFF) 61 | await hass.async_block_till_done() 62 | 63 | kitchen_motion_sensor_state = hass.states.get(kitchen_motion_sensor_id) 64 | assert_state(kitchen_motion_sensor_state, STATE_OFF) 65 | 66 | first_floor_motion_sensor_state = hass.states.get( 67 | first_floor_aggregate_sensor_entity_id 68 | ) 69 | assert_state(first_floor_motion_sensor_state, STATE_OFF) 70 | 71 | interior_motion_sensor_state = hass.states.get(interior_aggregate_sensor_entity_id) 72 | assert_state(interior_motion_sensor_state, STATE_OFF) 73 | 74 | global_motion_sensor_state = hass.states.get(global_aggregate_sensor_entity_id) 75 | assert_state(global_motion_sensor_state, STATE_OFF) 76 | 77 | # Second Floor 78 | master_bedroom_motion_sensor_id = entities_binary_sensor_motion_all_areas_with_meta[ 79 | MockAreaIds.MASTER_BEDROOM 80 | ][0].entity_id 81 | 82 | # > On 83 | 84 | hass.states.async_set(master_bedroom_motion_sensor_id, STATE_ON) 85 | await hass.async_block_till_done() 86 | 87 | master_bedroom_motion_sensor_state = hass.states.get( 88 | master_bedroom_motion_sensor_id 89 | ) 90 | assert_state(master_bedroom_motion_sensor_state, STATE_ON) 91 | 92 | second_floor_motion_sensor_state = hass.states.get( 93 | second_floor_aggregate_sensor_entity_id 94 | ) 95 | assert_state(second_floor_motion_sensor_state, STATE_ON) 96 | 97 | # Off 98 | 99 | hass.states.async_set(master_bedroom_motion_sensor_id, STATE_OFF) 100 | await hass.async_block_till_done() 101 | 102 | master_bedroom_motion_sensor_state = hass.states.get( 103 | master_bedroom_motion_sensor_id 104 | ) 105 | assert_state(master_bedroom_motion_sensor_state, STATE_OFF) 106 | 107 | second_floor_motion_sensor_state = hass.states.get( 108 | second_floor_aggregate_sensor_entity_id 109 | ) 110 | assert_state(second_floor_motion_sensor_state, STATE_OFF) 111 | 112 | # Exterior + Ground Level 113 | backyard_motion_sensor_id = entities_binary_sensor_motion_all_areas_with_meta[ 114 | MockAreaIds.BACKYARD 115 | ][0].entity_id 116 | 117 | # > On 118 | 119 | hass.states.async_set(backyard_motion_sensor_id, STATE_ON) 120 | await hass.async_block_till_done() 121 | 122 | backyard_motion_sensor_state = hass.states.get(backyard_motion_sensor_id) 123 | assert_state(backyard_motion_sensor_state, STATE_ON) 124 | 125 | ground_level_motion_sensor_state = hass.states.get( 126 | ground_level_aggregate_sensor_entity_id 127 | ) 128 | assert_state(ground_level_motion_sensor_state, STATE_ON) 129 | 130 | exterior_motion_sensor_state = hass.states.get(exterior_aggregate_sensor_entity_id) 131 | assert_state(exterior_motion_sensor_state, STATE_ON) 132 | 133 | # > Off 134 | 135 | hass.states.async_set(backyard_motion_sensor_id, STATE_OFF) 136 | await hass.async_block_till_done() 137 | 138 | backyard_motion_sensor_state = hass.states.get(backyard_motion_sensor_id) 139 | assert_state(backyard_motion_sensor_state, STATE_OFF) 140 | 141 | ground_level_motion_sensor_state = hass.states.get( 142 | ground_level_aggregate_sensor_entity_id 143 | ) 144 | assert_state(ground_level_motion_sensor_state, STATE_OFF) 145 | 146 | exterior_motion_sensor_state = hass.states.get(exterior_aggregate_sensor_entity_id) 147 | assert_state(exterior_motion_sensor_state, STATE_OFF) 148 | -------------------------------------------------------------------------------- /tests/test_meta_area_state.py: -------------------------------------------------------------------------------- 1 | """Test for meta area changes and how the system handles it.""" 2 | 3 | import logging 4 | 5 | from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN 6 | from homeassistant.const import STATE_OFF, STATE_ON 7 | from homeassistant.core import HomeAssistant 8 | 9 | from custom_components.magic_areas.const import AreaStates 10 | 11 | from tests.common import assert_state 12 | from tests.const import MockAreaIds 13 | from tests.mocks import MockBinarySensor 14 | 15 | _LOGGER = logging.getLogger(__name__) 16 | 17 | 18 | # Tests 19 | 20 | 21 | async def test_meta_area_primary_state_change( 22 | hass: HomeAssistant, 23 | entities_binary_sensor_motion_all_areas_with_meta: dict[ 24 | MockAreaIds, list[MockBinarySensor] 25 | ], 26 | _setup_integration_all_areas_with_meta, 27 | ) -> None: 28 | """Test primary state changes between meta areas.""" 29 | 30 | # Test initialization 31 | for ( 32 | area_id, 33 | entity_list, 34 | ) in entities_binary_sensor_motion_all_areas_with_meta.items(): 35 | assert entity_list is not None 36 | assert len(entity_list) == 1 37 | 38 | entity_ids = [entity.entity_id for entity in entity_list] 39 | 40 | # Check area sensor is created 41 | area_sensor_entity_id = ( 42 | f"{BINARY_SENSOR_DOMAIN}.magic_areas_presence_tracking_{area_id}_area_state" 43 | ) 44 | area_binary_sensor = hass.states.get(area_sensor_entity_id) 45 | assert area_binary_sensor is not None 46 | assert area_binary_sensor.state == STATE_OFF 47 | assert set(entity_ids).issubset( 48 | set(area_binary_sensor.attributes["presence_sensors"]) 49 | ) 50 | assert AreaStates.CLEAR in area_binary_sensor.attributes["states"] 51 | 52 | # Entity Ids 53 | kitchen_area_sensor_entity_id = f"{BINARY_SENSOR_DOMAIN}.magic_areas_presence_tracking_{MockAreaIds.KITCHEN.value}_area_state" 54 | backyard_area_sensor_entity_id = f"{BINARY_SENSOR_DOMAIN}.magic_areas_presence_tracking_{MockAreaIds.BACKYARD.value}_area_state" 55 | master_bedroom_area_sensor_entity_id = f"{BINARY_SENSOR_DOMAIN}.magic_areas_presence_tracking_{MockAreaIds.MASTER_BEDROOM.value}_area_state" 56 | interior_area_sensor_entity_id = f"{BINARY_SENSOR_DOMAIN}.magic_areas_presence_tracking_{MockAreaIds.INTERIOR.value}_area_state" 57 | exterior_area_sensor_entity_id = f"{BINARY_SENSOR_DOMAIN}.magic_areas_presence_tracking_{MockAreaIds.EXTERIOR.value}_area_state" 58 | global_area_sensor_entity_id = f"{BINARY_SENSOR_DOMAIN}.magic_areas_presence_tracking_{MockAreaIds.GLOBAL.value}_area_state" 59 | ground_level_area_sensor_entity_id = f"{BINARY_SENSOR_DOMAIN}.magic_areas_presence_tracking_{MockAreaIds.GROUND_LEVEL.value}_area_state" 60 | first_floor_area_sensor_entity_id = f"{BINARY_SENSOR_DOMAIN}.magic_areas_presence_tracking_{MockAreaIds.FIRST_FLOOR.value}_area_state" 61 | second_floor_area_sensor_entity_id = f"{BINARY_SENSOR_DOMAIN}.magic_areas_presence_tracking_{MockAreaIds.SECOND_FLOOR.value}_area_state" 62 | 63 | # Toggle interior area and check interior meta area 64 | kitchen_motion_sensor_id = entities_binary_sensor_motion_all_areas_with_meta[ 65 | MockAreaIds.KITCHEN 66 | ][0].entity_id 67 | hass.states.async_set(kitchen_motion_sensor_id, STATE_ON) 68 | await hass.async_block_till_done() 69 | 70 | kitchen_motion_sensor_state = hass.states.get(kitchen_motion_sensor_id) 71 | assert_state(kitchen_motion_sensor_state, STATE_ON) 72 | 73 | kitchen_area_sensor_state = hass.states.get(kitchen_area_sensor_entity_id) 74 | assert_state(kitchen_area_sensor_state, STATE_ON) 75 | 76 | interior_area_sensor_state = hass.states.get(interior_area_sensor_entity_id) 77 | assert_state(interior_area_sensor_state, STATE_ON) 78 | 79 | exterior_area_sensor_state = hass.states.get(exterior_area_sensor_entity_id) 80 | assert_state(exterior_area_sensor_state, STATE_OFF) 81 | 82 | global_area_sensor_state = hass.states.get(global_area_sensor_entity_id) 83 | assert_state(global_area_sensor_state, STATE_ON) 84 | 85 | hass.states.async_set(kitchen_motion_sensor_id, STATE_OFF) 86 | await hass.async_block_till_done() 87 | 88 | kitchen_motion_sensor_state = hass.states.get(kitchen_motion_sensor_id) 89 | assert_state(kitchen_motion_sensor_state, STATE_OFF) 90 | 91 | kitchen_area_sensor_state = hass.states.get(kitchen_area_sensor_entity_id) 92 | assert_state(kitchen_area_sensor_state, STATE_OFF) 93 | 94 | interior_area_sensor_state = hass.states.get(interior_area_sensor_entity_id) 95 | assert_state(interior_area_sensor_state, STATE_OFF) 96 | 97 | exterior_area_sensor_state = hass.states.get(exterior_area_sensor_entity_id) 98 | assert_state(exterior_area_sensor_state, STATE_OFF) 99 | 100 | global_area_sensor_state = hass.states.get(global_area_sensor_entity_id) 101 | assert_state(global_area_sensor_state, STATE_OFF) 102 | 103 | # Toggle exterior area 104 | backyard_motion_sensor_id = entities_binary_sensor_motion_all_areas_with_meta[ 105 | MockAreaIds.BACKYARD 106 | ][0].entity_id 107 | hass.states.async_set(backyard_motion_sensor_id, STATE_ON) 108 | await hass.async_block_till_done() 109 | 110 | backyard_motion_sensor_state = hass.states.get(backyard_motion_sensor_id) 111 | assert_state(backyard_motion_sensor_state, STATE_ON) 112 | 113 | backyard_area_sensor_state = hass.states.get(backyard_area_sensor_entity_id) 114 | assert_state(backyard_area_sensor_state, STATE_ON) 115 | 116 | interior_area_sensor_state = hass.states.get(interior_area_sensor_entity_id) 117 | assert_state(interior_area_sensor_state, STATE_OFF) 118 | 119 | exterior_area_sensor_state = hass.states.get(exterior_area_sensor_entity_id) 120 | assert_state(exterior_area_sensor_state, STATE_ON) 121 | 122 | global_area_sensor_state = hass.states.get(global_area_sensor_entity_id) 123 | assert_state(global_area_sensor_state, STATE_ON) 124 | 125 | hass.states.async_set(backyard_motion_sensor_id, STATE_OFF) 126 | await hass.async_block_till_done() 127 | 128 | backyard_motion_sensor_state = hass.states.get(kitchen_motion_sensor_id) 129 | assert_state(backyard_motion_sensor_state, STATE_OFF) 130 | 131 | backyard_area_sensor_state = hass.states.get(backyard_area_sensor_entity_id) 132 | assert_state(backyard_area_sensor_state, STATE_OFF) 133 | 134 | interior_area_sensor_state = hass.states.get(interior_area_sensor_entity_id) 135 | assert_state(interior_area_sensor_state, STATE_OFF) 136 | 137 | exterior_area_sensor_state = hass.states.get(exterior_area_sensor_entity_id) 138 | assert_state(exterior_area_sensor_state, STATE_OFF) 139 | 140 | global_area_sensor_state = hass.states.get(global_area_sensor_entity_id) 141 | assert_state(global_area_sensor_state, STATE_OFF) 142 | 143 | # Floors 144 | ground_level_area_sensor_state = hass.states.get(ground_level_area_sensor_entity_id) 145 | assert_state(ground_level_area_sensor_state, STATE_OFF) 146 | 147 | hass.states.async_set(backyard_motion_sensor_id, STATE_ON) 148 | await hass.async_block_till_done() 149 | 150 | ground_level_area_sensor_state = hass.states.get(ground_level_area_sensor_entity_id) 151 | assert_state(ground_level_area_sensor_state, STATE_ON) 152 | 153 | hass.states.async_set(backyard_motion_sensor_id, STATE_OFF) 154 | await hass.async_block_till_done() 155 | 156 | ground_level_area_sensor_state = hass.states.get(ground_level_area_sensor_entity_id) 157 | assert_state(ground_level_area_sensor_state, STATE_OFF) 158 | 159 | first_floor_area_sensor_state = hass.states.get(first_floor_area_sensor_entity_id) 160 | assert_state(first_floor_area_sensor_state, STATE_OFF) 161 | 162 | hass.states.async_set(kitchen_motion_sensor_id, STATE_ON) 163 | await hass.async_block_till_done() 164 | 165 | first_floor_area_sensor_state = hass.states.get(first_floor_area_sensor_entity_id) 166 | assert_state(first_floor_area_sensor_state, STATE_ON) 167 | 168 | hass.states.async_set(kitchen_motion_sensor_id, STATE_OFF) 169 | await hass.async_block_till_done() 170 | 171 | first_floor_area_sensor_state = hass.states.get(first_floor_area_sensor_entity_id) 172 | assert_state(first_floor_area_sensor_state, STATE_OFF) 173 | 174 | second_floor_area_sensor_state = hass.states.get(second_floor_area_sensor_entity_id) 175 | assert_state(second_floor_area_sensor_state, STATE_OFF) 176 | 177 | hass.states.async_set(master_bedroom_area_sensor_entity_id, STATE_ON) 178 | await hass.async_block_till_done() 179 | 180 | second_floor_area_sensor_state = hass.states.get(second_floor_area_sensor_entity_id) 181 | assert_state(second_floor_area_sensor_state, STATE_ON) 182 | 183 | hass.states.async_set(master_bedroom_area_sensor_entity_id, STATE_OFF) 184 | await hass.async_block_till_done() 185 | 186 | second_floor_area_sensor_state = hass.states.get(second_floor_area_sensor_entity_id) 187 | assert_state(second_floor_area_sensor_state, STATE_OFF) 188 | -------------------------------------------------------------------------------- /tests/test_threshold.py: -------------------------------------------------------------------------------- 1 | """Test for aggregate (group) sensor behavior.""" 2 | 3 | import asyncio 4 | from collections.abc import AsyncGenerator 5 | import logging 6 | from typing import Any 7 | 8 | import pytest 9 | from pytest_homeassistant_custom_component.common import MockConfigEntry 10 | 11 | from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN 12 | from homeassistant.components.sensor.const import ( 13 | DOMAIN as SENSOR_DOMAIN, 14 | SensorDeviceClass, 15 | ) 16 | from homeassistant.components.threshold.const import ATTR_HYSTERESIS, ATTR_UPPER 17 | from homeassistant.const import LIGHT_LUX, STATE_OFF, STATE_ON 18 | from homeassistant.core import HomeAssistant 19 | 20 | from custom_components.magic_areas.const import ( 21 | CONF_AGGREGATES_ILLUMINANCE_THRESHOLD, 22 | CONF_AGGREGATES_ILLUMINANCE_THRESHOLD_HYSTERESIS, 23 | CONF_AGGREGATES_MIN_ENTITIES, 24 | CONF_ENABLED_FEATURES, 25 | CONF_FEATURE_AGGREGATION, 26 | DOMAIN, 27 | ) 28 | 29 | from tests.common import ( 30 | assert_state, 31 | get_basic_config_entry_data, 32 | init_integration, 33 | setup_mock_entities, 34 | shutdown_integration, 35 | ) 36 | from tests.const import DEFAULT_MOCK_AREA 37 | from tests.mocks import MockBinarySensor, MockSensor 38 | 39 | _LOGGER = logging.getLogger(__name__) 40 | 41 | 42 | # Fixtures 43 | 44 | 45 | @pytest.fixture(name="threshold_config_entry") 46 | def mock_config_entry_threshold() -> MockConfigEntry: 47 | """Fixture for mock configuration entry.""" 48 | data = get_basic_config_entry_data(DEFAULT_MOCK_AREA) 49 | data.update( 50 | { 51 | CONF_ENABLED_FEATURES: { 52 | CONF_FEATURE_AGGREGATION: { 53 | CONF_AGGREGATES_MIN_ENTITIES: 1, 54 | CONF_AGGREGATES_ILLUMINANCE_THRESHOLD: 600, 55 | CONF_AGGREGATES_ILLUMINANCE_THRESHOLD_HYSTERESIS: 10, 56 | }, 57 | } 58 | } 59 | ) 60 | return MockConfigEntry(domain=DOMAIN, data=data) 61 | 62 | 63 | @pytest.fixture(name="_setup_integration_threshold") 64 | async def setup_integration_threshold( 65 | hass: HomeAssistant, 66 | threshold_config_entry: MockConfigEntry, 67 | ) -> AsyncGenerator[Any]: 68 | """Set up integration with secondary states config.""" 69 | 70 | await init_integration(hass, [threshold_config_entry]) 71 | yield 72 | await shutdown_integration(hass, [threshold_config_entry]) 73 | 74 | 75 | # Entities 76 | 77 | 78 | @pytest.fixture(name="entities_sensor_illuminance_multiple") 79 | async def setup_entities_sensor_illuminance_multiple( 80 | hass: HomeAssistant, 81 | ) -> list[MockSensor]: 82 | """Create multiple mock sensor and setup the system with it.""" 83 | nr_entities = 3 84 | mock_sensor_entities = [] 85 | for i in range(nr_entities): 86 | mock_sensor_entities.append( 87 | MockSensor( 88 | name=f"illuminance_sensor_{i}", 89 | unique_id=f"illuminance_sensor_{i}", 90 | native_value=0.0, 91 | device_class=SensorDeviceClass.ILLUMINANCE, 92 | native_unit_of_measurement=LIGHT_LUX, 93 | unit_of_measurement=LIGHT_LUX, 94 | extra_state_attributes={ 95 | "unit_of_measurement": LIGHT_LUX, 96 | }, 97 | ) 98 | ) 99 | await setup_mock_entities( 100 | hass, SENSOR_DOMAIN, {DEFAULT_MOCK_AREA: mock_sensor_entities} 101 | ) 102 | return mock_sensor_entities 103 | 104 | 105 | # Tests 106 | 107 | 108 | async def test_threshold_sensor_light( 109 | hass: HomeAssistant, 110 | entities_sensor_illuminance_multiple: list[MockBinarySensor], 111 | _setup_integration_threshold, 112 | ) -> None: 113 | """Test the light from illuminance threshold sensor.""" 114 | 115 | threshold_sensor_id = ( 116 | f"{BINARY_SENSOR_DOMAIN}.magic_areas_threshold_kitchen_threshold_light" 117 | ) 118 | 119 | aggregate_sensor_id = ( 120 | f"{SENSOR_DOMAIN}.magic_areas_aggregates_kitchen_aggregate_illuminance" 121 | ) 122 | 123 | # Ensure aggregate sensor was created 124 | aggregate_sensor_state = hass.states.get(aggregate_sensor_id) 125 | assert aggregate_sensor_state is not None 126 | assert float(aggregate_sensor_state.state) == 0.0 127 | 128 | # Ensure threhsold sensor was created 129 | threshold_sensor_state = hass.states.get(threshold_sensor_id) 130 | assert threshold_sensor_state is not None 131 | assert threshold_sensor_state.state == STATE_OFF 132 | assert hasattr(threshold_sensor_state, "attributes") 133 | assert ATTR_UPPER in threshold_sensor_state.attributes 134 | assert ATTR_HYSTERESIS in threshold_sensor_state.attributes 135 | 136 | sensor_threhsold_upper = int(threshold_sensor_state.attributes[ATTR_UPPER]) 137 | sensor_hysteresis = int(threshold_sensor_state.attributes[ATTR_HYSTERESIS]) 138 | 139 | assert sensor_threhsold_upper == 600 140 | assert sensor_hysteresis == 600 / 10 # 10%, configured value 141 | 142 | # Set illuminance sensor values to over the threhsold upper value (incl. hysteresis) 143 | for mock_entity in entities_sensor_illuminance_multiple: 144 | hass.states.async_set( 145 | mock_entity.entity_id, 146 | str(sensor_threhsold_upper + sensor_hysteresis + 1), 147 | attributes={"unit_of_measurement": LIGHT_LUX}, 148 | ) 149 | await hass.async_block_till_done() 150 | 151 | # Wait a bit for threshold sensor to trigger 152 | await asyncio.sleep(1) 153 | await hass.async_block_till_done() 154 | 155 | # Ensure threhsold sensor is triggered 156 | threshold_sensor_state = hass.states.get(threshold_sensor_id) 157 | assert_state(threshold_sensor_state, STATE_ON) 158 | 159 | # Reset illuminance sensor values to 0 160 | for mock_entity in entities_sensor_illuminance_multiple: 161 | hass.states.async_set( 162 | mock_entity.entity_id, 163 | str(0.0), 164 | attributes={"unit_of_measurement": LIGHT_LUX}, 165 | ) 166 | await hass.async_block_till_done() 167 | 168 | # Wait a bit for threshold sensor to trigger 169 | await asyncio.sleep(1) 170 | await hass.async_block_till_done() 171 | 172 | # Ensure threhsold sensor is cleared 173 | threshold_sensor_state = hass.states.get(threshold_sensor_id) 174 | assert_state(threshold_sensor_state, STATE_OFF) 175 | -------------------------------------------------------------------------------- /tests/test_wasp_in_a_box.py: -------------------------------------------------------------------------------- 1 | """Test for Wasp in a box sensor behavior.""" 2 | 3 | import asyncio 4 | from collections.abc import AsyncGenerator 5 | import logging 6 | from typing import Any 7 | 8 | import pytest 9 | from pytest_homeassistant_custom_component.common import MockConfigEntry 10 | 11 | from homeassistant.components.binary_sensor import ( 12 | DOMAIN as BINARY_SENSOR_DOMAIN, 13 | BinarySensorDeviceClass, 14 | ) 15 | from homeassistant.const import STATE_OFF, STATE_ON 16 | from homeassistant.core import HomeAssistant 17 | 18 | from .conftest import ( 19 | DEFAULT_MOCK_AREA, 20 | get_basic_config_entry_data, 21 | init_integration, 22 | setup_mock_entities, 23 | shutdown_integration, 24 | ) 25 | from .mocks import MockBinarySensor 26 | from custom_components.magic_areas.binary_sensor.wasp_in_a_box import ( 27 | ATTR_BOX, 28 | ATTR_WASP, 29 | ) 30 | from custom_components.magic_areas.const import ( 31 | ATTR_ACTIVE_SENSORS, 32 | ATTR_PRESENCE_SENSORS, 33 | CONF_AGGREGATES_MIN_ENTITIES, 34 | CONF_ENABLED_FEATURES, 35 | CONF_FEATURE_AGGREGATION, 36 | CONF_FEATURE_WASP_IN_A_BOX, 37 | CONF_WASP_IN_A_BOX_DELAY, 38 | DOMAIN, 39 | ) 40 | 41 | from tests.common import assert_attribute, assert_in_attribute, assert_state 42 | 43 | _LOGGER = logging.getLogger(__name__) 44 | 45 | # Fixtures 46 | 47 | 48 | @pytest.fixture(name="wasp_in_a_box_config_entry") 49 | def mock_config_entry_wasp_in_a_box() -> MockConfigEntry: 50 | """Fixture for mock configuration entry.""" 51 | data = get_basic_config_entry_data(DEFAULT_MOCK_AREA) 52 | data.update( 53 | { 54 | CONF_ENABLED_FEATURES: { 55 | CONF_FEATURE_WASP_IN_A_BOX: {CONF_WASP_IN_A_BOX_DELAY: 0}, 56 | CONF_FEATURE_AGGREGATION: {CONF_AGGREGATES_MIN_ENTITIES: 1}, 57 | }, 58 | } 59 | ) 60 | return MockConfigEntry(domain=DOMAIN, data=data) 61 | 62 | 63 | @pytest.fixture(name="_setup_integration_wasp_in_a_box") 64 | async def setup_integration_wasp_in_a_box( 65 | hass: HomeAssistant, 66 | wasp_in_a_box_config_entry: MockConfigEntry, 67 | ) -> AsyncGenerator[Any]: 68 | """Set up integration with Wasp in a box (and aggregates) config.""" 69 | 70 | await init_integration(hass, [wasp_in_a_box_config_entry]) 71 | yield 72 | await shutdown_integration(hass, [wasp_in_a_box_config_entry]) 73 | 74 | 75 | # Entities 76 | 77 | 78 | @pytest.fixture(name="entities_wasp_in_a_box") 79 | async def setup_entities_wasp_in_a_box( 80 | hass: HomeAssistant, 81 | ) -> list[MockBinarySensor]: 82 | """Create motion and door sensors.""" 83 | mock_binary_sensor_entities = [ 84 | MockBinarySensor( 85 | name="motion_sensor", 86 | unique_id="unique_motion", 87 | device_class=BinarySensorDeviceClass.MOTION, 88 | ), 89 | MockBinarySensor( 90 | name="door_sensor", 91 | unique_id="unique_door", 92 | device_class=BinarySensorDeviceClass.DOOR, 93 | ), 94 | ] 95 | await setup_mock_entities( 96 | hass, BINARY_SENSOR_DOMAIN, {DEFAULT_MOCK_AREA: mock_binary_sensor_entities} 97 | ) 98 | return mock_binary_sensor_entities 99 | 100 | 101 | # Tests 102 | 103 | 104 | async def test_wasp_in_a_box_logic( 105 | hass: HomeAssistant, 106 | entities_wasp_in_a_box: list[MockBinarySensor], 107 | _setup_integration_wasp_in_a_box, 108 | ) -> None: 109 | """Test the Wasp in a box sensor logic.""" 110 | 111 | motion_sensor_entity_id = entities_wasp_in_a_box[0].entity_id 112 | door_sensor_entity_id = entities_wasp_in_a_box[1].entity_id 113 | 114 | wasp_in_a_box_entity_id = ( 115 | f"{BINARY_SENSOR_DOMAIN}.magic_areas_wasp_in_a_box_{DEFAULT_MOCK_AREA}" 116 | ) 117 | motion_aggregate_entity_id = f"{BINARY_SENSOR_DOMAIN}.magic_areas_aggregates_{DEFAULT_MOCK_AREA}_aggregate_motion" 118 | door_aggregate_entity_id = f"{BINARY_SENSOR_DOMAIN}.magic_areas_aggregates_{DEFAULT_MOCK_AREA}_aggregate_door" 119 | 120 | # Ensure source entities are loaded 121 | motion_sensor_state = hass.states.get(motion_sensor_entity_id) 122 | assert_state(motion_sensor_state, STATE_OFF) 123 | 124 | door_sensor_state = hass.states.get(door_sensor_entity_id) 125 | assert_state(door_sensor_state, STATE_OFF) 126 | 127 | # Ensure aggregates are loaded 128 | motion_aggregate_state = hass.states.get(motion_aggregate_entity_id) 129 | assert_state(motion_aggregate_state, STATE_OFF) 130 | 131 | door_aggregate_state = hass.states.get(door_aggregate_entity_id) 132 | assert_state(door_aggregate_state, STATE_OFF) 133 | 134 | # Ensure Wasp in a box sensor is loaded 135 | wasp_in_a_box_state = hass.states.get(wasp_in_a_box_entity_id) 136 | assert_state(wasp_in_a_box_state, STATE_OFF) 137 | assert_attribute(wasp_in_a_box_state, ATTR_WASP, STATE_OFF) 138 | assert_attribute(wasp_in_a_box_state, ATTR_BOX, STATE_OFF) 139 | 140 | # Test motion door open behavior 141 | hass.states.async_set(door_sensor_entity_id, STATE_ON) 142 | await asyncio.sleep(1) 143 | await hass.async_block_till_done() 144 | 145 | wasp_in_a_box_state = hass.states.get(wasp_in_a_box_entity_id) 146 | assert_state(wasp_in_a_box_state, STATE_OFF) 147 | assert_attribute(wasp_in_a_box_state, ATTR_WASP, STATE_OFF) 148 | assert_attribute(wasp_in_a_box_state, ATTR_BOX, STATE_ON) 149 | 150 | hass.states.async_set(motion_sensor_entity_id, STATE_ON) 151 | await hass.async_block_till_done() 152 | 153 | wasp_in_a_box_state = hass.states.get(wasp_in_a_box_entity_id) 154 | assert_state(wasp_in_a_box_state, STATE_ON) 155 | assert_attribute(wasp_in_a_box_state, ATTR_WASP, STATE_ON) 156 | assert_attribute(wasp_in_a_box_state, ATTR_BOX, STATE_ON) 157 | 158 | hass.states.async_set(motion_sensor_entity_id, STATE_OFF) 159 | await hass.async_block_till_done() 160 | 161 | wasp_in_a_box_state = hass.states.get(wasp_in_a_box_entity_id) 162 | assert_state(wasp_in_a_box_state, STATE_OFF) 163 | assert_attribute(wasp_in_a_box_state, ATTR_WASP, STATE_OFF) 164 | assert_attribute(wasp_in_a_box_state, ATTR_BOX, STATE_ON) 165 | 166 | # Test motion on door closed behavior 167 | hass.states.async_set(door_sensor_entity_id, STATE_OFF) 168 | await asyncio.sleep(1) 169 | await hass.async_block_till_done() 170 | 171 | wasp_in_a_box_state = hass.states.get(wasp_in_a_box_entity_id) 172 | assert_state(wasp_in_a_box_state, STATE_OFF) 173 | assert_attribute(wasp_in_a_box_state, ATTR_WASP, STATE_OFF) 174 | assert_attribute(wasp_in_a_box_state, ATTR_BOX, STATE_OFF) 175 | 176 | hass.states.async_set(motion_sensor_entity_id, STATE_ON) 177 | await hass.async_block_till_done() 178 | 179 | wasp_in_a_box_state = hass.states.get(wasp_in_a_box_entity_id) 180 | assert_state(wasp_in_a_box_state, STATE_ON) 181 | assert_attribute(wasp_in_a_box_state, ATTR_WASP, STATE_ON) 182 | assert_attribute(wasp_in_a_box_state, ATTR_BOX, STATE_OFF) 183 | 184 | hass.states.async_set(motion_sensor_entity_id, STATE_OFF) 185 | await hass.async_block_till_done() 186 | 187 | wasp_in_a_box_state = hass.states.get(wasp_in_a_box_entity_id) 188 | assert_state(wasp_in_a_box_state, STATE_ON) 189 | assert_attribute(wasp_in_a_box_state, ATTR_WASP, STATE_OFF) 190 | assert_attribute(wasp_in_a_box_state, ATTR_BOX, STATE_OFF) 191 | 192 | # Test door open releases wasp 193 | hass.states.async_set(door_sensor_entity_id, STATE_ON) 194 | await hass.async_block_till_done() 195 | 196 | # Wait a bit for wasp sensor to trigger 197 | await asyncio.sleep(1) 198 | await hass.async_block_till_done() 199 | 200 | wasp_in_a_box_state = hass.states.get(wasp_in_a_box_entity_id) 201 | assert_state(wasp_in_a_box_state, STATE_OFF) 202 | assert_attribute(wasp_in_a_box_state, ATTR_WASP, STATE_OFF) 203 | assert_attribute(wasp_in_a_box_state, ATTR_BOX, STATE_ON) 204 | 205 | 206 | async def test_wasp_in_a_box_as_presence( 207 | hass: HomeAssistant, 208 | entities_wasp_in_a_box: list[MockBinarySensor], 209 | _setup_integration_wasp_in_a_box, 210 | ) -> None: 211 | """Test the Wasp in a box sensor triggers area presence.""" 212 | 213 | motion_sensor_entity_id = entities_wasp_in_a_box[0].entity_id 214 | door_sensor_entity_id = entities_wasp_in_a_box[1].entity_id 215 | wasp_in_a_box_entity_id = ( 216 | f"{BINARY_SENSOR_DOMAIN}.magic_areas_wasp_in_a_box_{DEFAULT_MOCK_AREA}" 217 | ) 218 | area_state_entity_id = f"{BINARY_SENSOR_DOMAIN}.magic_areas_presence_tracking_{DEFAULT_MOCK_AREA}_area_state" 219 | 220 | # Set initial values 221 | hass.states.async_set(motion_sensor_entity_id, STATE_OFF) 222 | hass.states.async_set(door_sensor_entity_id, STATE_ON) 223 | await hass.async_block_till_done() 224 | 225 | # Ensure initial values are set 226 | door_sensor_state = hass.states.get(door_sensor_entity_id) 227 | assert_state(door_sensor_state, STATE_ON) 228 | 229 | motion_sensor_state = hass.states.get(motion_sensor_entity_id) 230 | assert_state(motion_sensor_state, STATE_OFF) 231 | 232 | wasp_in_a_box_state = hass.states.get(wasp_in_a_box_entity_id) 233 | assert_state(wasp_in_a_box_state, STATE_OFF) 234 | 235 | area_sensor_state = hass.states.get(area_state_entity_id) 236 | assert_state(area_sensor_state, STATE_OFF) 237 | assert_in_attribute( 238 | area_sensor_state, ATTR_PRESENCE_SENSORS, wasp_in_a_box_entity_id 239 | ) 240 | 241 | # Test presence tracking 242 | hass.states.async_set(motion_sensor_entity_id, STATE_ON) 243 | await hass.async_block_till_done() 244 | 245 | wasp_in_a_box_state = hass.states.get(wasp_in_a_box_entity_id) 246 | assert_state(wasp_in_a_box_state, STATE_ON) 247 | 248 | area_sensor_state = hass.states.get(area_state_entity_id) 249 | assert_state(area_sensor_state, STATE_ON) 250 | assert_in_attribute(area_sensor_state, ATTR_ACTIVE_SENSORS, wasp_in_a_box_entity_id) 251 | 252 | hass.states.async_set(motion_sensor_entity_id, STATE_OFF) 253 | await hass.async_block_till_done() 254 | 255 | wasp_in_a_box_state = hass.states.get(wasp_in_a_box_entity_id) 256 | assert_state(wasp_in_a_box_state, STATE_OFF) 257 | 258 | area_sensor_state = hass.states.get(area_state_entity_id) 259 | assert_state(area_sensor_state, STATE_OFF) 260 | --------------------------------------------------------------------------------