Compare commits

..

74 Commits

Author SHA1 Message Date
epenet
4d4ad900b1 Add Tuya climate tests for US unit_system (#156989) 2025-11-21 17:20:03 +01:00
Joost Lekkerkerker
acc136af19 Add entities for Smartthings flexwash (#156997) 2025-11-21 16:58:50 +01:00
Abílio Costa
0f12a40eb2 Fix typing in websocket_api test (#156964) 2025-11-21 16:29:19 +01:00
Joost Lekkerkerker
bf124daf72 Add SmartThings dustfilter threshold (#153909) 2025-11-21 16:28:35 +01:00
Josef Zweck
1682ced5cc Bump pylamarzocco to 2.2.0 (#156667) 2025-11-21 16:26:38 +01:00
averybiteydinosaur
80b316bc70 Bump version of python_awair to 0.2.5 (#155798)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-11-21 16:25:18 +01:00
karwosts
00d2340d4b Fix usage_prediction incorrectly accessing target fields (#156937) 2025-11-21 15:56:58 +01:00
Timothy
514a329580 Rework CloudhookURL setup for mobile app (#156940) 2025-11-21 15:23:23 +01:00
Petro31
f2b8bb01bf Modernize template cover (#156475) 2025-11-21 15:20:30 +01:00
Manu
30153ab059 Fix spelling mistake in IronOS integration (#156996) 2025-11-21 15:19:33 +01:00
Bram Kragten
2957b15ede Update frontend to 20251105.1 (#156992) 2025-11-21 15:18:23 +01:00
Glenn Vandeuren (aka Iondependent)
12ace95f3e Improve error handling in Niko Home Control config flow (#154565)
Co-authored-by: Joostlek <joostlek@outlook.com>
2025-11-21 14:34:59 +01:00
Joost Lekkerkerker
babe19767d Add diagnostic support to WAQI (#156811) 2025-11-21 14:20:47 +01:00
Robert Resch
d01843e1ab Use unix socket for HA managed go2rtc instance (#156968) 2025-11-21 14:19:03 +01:00
Joost Lekkerkerker
9964cb512a Throttle Decora wifi updates (#156994) 2025-11-21 14:16:03 +01:00
Joost Lekkerkerker
ae38214b7c Bump pySmartThings to 3.3.4 (#156830) 2025-11-21 14:10:38 +01:00
Kamil Breguła
9812286801 Add fixtures for Samsung oven and dishwasher (#156655)
Co-authored-by: mik-laj <12058428+mik-laj@users.noreply.github.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2025-11-21 13:53:10 +01:00
J. Nick Koston
32a40e5919 Bump PySwichBot to 0.74.0 (#156986) 2025-11-21 13:22:55 +01:00
Kamil Breguła
97de944a14 Add Washer Water Temperature to SmartThings (#156980)
Co-authored-by: mik-laj <12058428+mik-laj@users.noreply.github.com>
2025-11-21 13:19:08 +01:00
Artur Pragacz
c9bd87f4b3 Classify identify button as diagnostic in Matter (#156943) 2025-11-21 13:17:26 +01:00
Neal
ac46568996 Add tests to concord232 component (#156070)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-11-21 13:13:08 +01:00
J. Nick Koston
7c1b8ee02c Bump aioshelly to 13.20.0 (#156988) 2025-11-21 06:11:03 -06:00
dependabot[bot]
aa6901265d Bump actions/checkout from 5.0.1 to 6.0.0 (#156973)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-21 12:56:33 +01:00
epenet
b76e9ad1c0 Migrate Tuya light (color_data) to use wrapper class (#156816) 2025-11-21 13:05:12 +02:00
epenet
edb8007c65 Migrate Tuya climate (temperature) to use wrapper class (#156977) 2025-11-21 13:03:58 +02:00
epenet
956a29411f Migrate Tuya fan (oscillate) to use wrapper class (#156946) 2025-11-21 11:56:24 +01:00
Brett Adams
1a2361050b Add update platform to Tesla Fleet (#156908) 2025-11-21 11:54:20 +01:00
Jan Bouwhuis
0c9e92f6f9 Add MQTT text subentry support (#156686) 2025-11-21 11:46:19 +01:00
epenet
bfdff46859 Migrate Tuya fan (speed) to use wrapper class (#156976) 2025-11-21 11:45:42 +01:00
epenet
9a22808499 Migrate Tuya fan (direction) to use wrapper class (#156944) 2025-11-21 11:44:16 +01:00
epenet
88b373af41 Migrate Tuya climate (swing) to use wrapper class (#156938) 2025-11-21 11:41:01 +01:00
epenet
dea2f37e8f Migrate Tuya cover (state) to use wrapper class (#156941) 2025-11-21 11:38:05 +01:00
David Rapan
30cce68e0b Update Shelly's quality scale to platinum 🏆️ (#156982) 2025-11-21 12:36:58 +02:00
Shay Levy
985eff972a Mark Shelly entity translations as done (#155683) 2025-11-21 12:10:46 +02:00
David Rapan
31ca332158 Increase Shelly code coverage for Gen1 EM3 (#156752)
Signed-off-by: David Rapan <david@rapan.cz>
2025-11-21 11:02:53 +01:00
David Rapan
bf76c1601d Align Shelly event naming paradigm (#156774)
Signed-off-by: David Rapan <david@rapan.cz>
2025-11-21 09:15:26 +01:00
J. Nick Koston
e572f8d48f Fix Shelly Bluetooth discovery for Gen3/Gen4 devices without advertised names (#156883) 2025-11-20 15:11:17 -06:00
Franck Nijhof
482b5d49a3 Introduce Home Assistant Labs (#156840) 2025-11-20 21:22:37 +01:00
Robert Resch
126fd217e7 Bump go2rtc to 1.9.12 and go2rtc-client to 0.3.0 (#156948) 2025-11-20 19:41:40 +01:00
Manu
0327b0e1ec Fix next alarm sensor showing wrong time in Sleep as Android (#156939) 2025-11-20 18:56:58 +01:00
epenet
3d5a7b4813 Migrate Tuya vacuum (pause) to use wrapper class (#156947) 2025-11-20 18:55:03 +01:00
epenet
e0bb30f63b Migrate Tuya fan (switch) to use wrapper class (#156936) 2025-11-20 15:04:36 +01:00
epenet
e5ae58c5df Migrate Tuya cover (open/close/stop) to use wrapper class (#156726) 2025-11-20 15:24:58 +02:00
epenet
13e4bb4b93 Migrate Tuya climate (hvac_mode/presets) to use wrapper class (#156933) 2025-11-20 14:23:28 +01:00
epenet
d5fd27d2a2 Add tests for Tuya climate actions (#156935) 2025-11-20 15:23:04 +02:00
bestycame
0a034b9984 Add Hanna integration (#147085)
Co-authored-by: Norbert Rittel <norbert@rittel.de>
Co-authored-by: Olivier d'Otreppe <odotreppe@abbove.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2025-11-20 14:20:14 +01:00
epenet
6a8106c0eb Add tests for Tuya fan actions (#156919) 2025-11-20 14:25:05 +02:00
epenet
2cacfc7413 Migrate Tuya fan (preset) to use wrapper class (#156922) 2025-11-20 13:19:37 +01:00
epenet
388ab5c16c Migrate Tuya climate (fan_mode) to use wrapper class (#156721) 2025-11-20 13:02:33 +01:00
epenet
81ea6f8c25 Migrate Tuya vacuum (status) to use wrapper class (#156744) 2025-11-20 14:01:18 +02:00
Joost Lekkerkerker
4f885994b7 Remove deprecation for SmartThings binary sensor (#156924) 2025-11-20 11:53:50 +01:00
Hessel
25e2c9ee80 Cache token info in Wallbox (#154147)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-11-20 11:12:11 +01:00
epenet
12c04f5571 Use pytest.parametrize in Tuya siren/switch/valve tests (#156920) 2025-11-20 10:46:57 +01:00
epenet
3ad1c6a47a Use pytest.parametrize in Tuya cover tests (#156921) 2025-11-20 11:38:14 +02:00
Åke Strandberg
e7e13ecc74 Refactor miele program id codes part 3(3) (#144196)
Co-authored-by: Erik Montnemery <erik@montnemery.com>
2025-11-20 09:59:28 +01:00
J. Nick Koston
991b8d2040 Bump aioshelly to 13.19.0 (#156902) 2025-11-19 17:52:55 -06:00
J. Nick Koston
43fadbf6b4 Bump aioshelly to 13.18.0 (#156887) 2025-11-19 19:00:10 +02:00
Maciej Bieniek
ca79d37135 Use native_value property instead of _attr_native_value in the Brother integration (#156878) 2025-11-19 16:06:11 +01:00
Paul Bottein
df8ef15535 Add reorder floors and areas websocket command (#156802)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-19 09:58:07 -05:00
Maciej Bieniek
249c1530d0 Address comments for Brother tests (#156877) 2025-11-19 15:06:27 +01:00
Maciej Bieniek
081b769abc Use Brother printer model as model_id (#156876) 2025-11-19 14:44:22 +01:00
Josef Zweck
b8b101d747 Lamarzocco fix websocket reconnect issue (#156786)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2025-11-19 13:06:29 +01:00
Sebastian Schneider
a19be192e0 Bump aiounifi to 88 (#156867) 2025-11-19 13:04:20 +01:00
Josef Zweck
92da82a200 Bump onedrive-personal-sdk to 0.0.17 (#156865) 2025-11-19 13:03:37 +01:00
Paul Bottein
820ba1dfba Add system-level frontend data storage (#155945) 2025-11-19 06:59:34 -05:00
Ludovic BOUÉ
63c8962f09 Add Matter mock lock fixture (#156862) 2025-11-19 12:50:58 +01:00
dependabot[bot]
c1a6996549 Bump github/codeql-action from 4.31.3 to 4.31.4 (#156850)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-19 12:46:32 +01:00
epenet
05253841af Auto-generate fixture list in Tuya tests (#156858) 2025-11-19 12:38:11 +01:00
Heindrich Paul
f2ef0503a0 Adding new sensors to the cat litter box (#156054)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2025-11-19 12:32:54 +01:00
puddly
938da38fc3 Bump universal-silabs-flasher to 0.1.2 (#156849) 2025-11-19 10:46:56 +01:00
Niracler
9311a87bf5 Refactor Sunricher DALI integration to use direct device callbacks (#155315) 2025-11-19 09:47:45 +01:00
Louis
b45294ded3 unifi: Add wired client link speed sensor and related tests (#155086)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Robert Svensson <Kane610@users.noreply.github.com>
2025-11-19 09:26:26 +01:00
Andre Lengwenus
82d3190016 Bump pypck to 0.9.5 (#156847) 2025-11-19 06:52:29 +01:00
omrishiv
d8cbcc1977 Bump pylutron-caseta to 0.26.0 (#156825)
Signed-off-by: omrishiv <327609+omrishiv@users.noreply.github.com>
2025-11-18 23:01:34 +01:00
260 changed files with 14953 additions and 7625 deletions

View File

@@ -27,7 +27,7 @@ jobs:
publish: ${{ steps.version.outputs.publish }}
steps:
- name: Checkout the repository
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
fetch-depth: 0
@@ -94,7 +94,7 @@ jobs:
- arch: i386
steps:
- name: Checkout the repository
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Download nightly wheels of frontend
if: needs.init.outputs.channel == 'dev'
@@ -227,7 +227,7 @@ jobs:
- green
steps:
- name: Checkout the repository
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Set build additional args
run: |
@@ -265,7 +265,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Initialize git
uses: home-assistant/actions/helpers/git-init@master
@@ -309,7 +309,7 @@ jobs:
registry: ["ghcr.io/home-assistant", "docker.io/homeassistant"]
steps:
- name: Checkout the repository
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Install Cosign
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
@@ -418,7 +418,7 @@ jobs:
if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true'
steps:
- name: Checkout the repository
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
@@ -463,7 +463,7 @@ jobs:
HASSFEST_IMAGE_TAG: ghcr.io/home-assistant/hassfest:${{ needs.init.outputs.version }}
steps:
- name: Checkout repository
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Login to GitHub Container Registry
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0

View File

@@ -99,7 +99,7 @@ jobs:
steps:
- &checkout
name: Check out code from GitHub
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Generate partial Python venv restore key
id: generate_python_cache_key
run: |

View File

@@ -21,14 +21,14 @@ jobs:
steps:
- name: Check out code from GitHub
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Initialize CodeQL
uses: github/codeql-action/init@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
uses: github/codeql-action/init@e12f0178983d466f2f6028f5cc7a6d786fd97f4b # v4.31.4
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
uses: github/codeql-action/analyze@e12f0178983d466f2f6028f5cc7a6d786fd97f4b # v4.31.4
with:
category: "/language:python"

View File

@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0

View File

@@ -33,7 +33,7 @@ jobs:
steps:
- &checkout
name: Checkout the repository
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python

4
CODEOWNERS generated
View File

@@ -627,6 +627,8 @@ build.json @home-assistant/supervisor
/tests/components/guardian/ @bachya
/homeassistant/components/habitica/ @tr4nt0r
/tests/components/habitica/ @tr4nt0r
/homeassistant/components/hanna/ @bestycame
/tests/components/hanna/ @bestycame
/homeassistant/components/hardkernel/ @home-assistant/core
/tests/components/hardkernel/ @home-assistant/core
/homeassistant/components/hardware/ @home-assistant/core
@@ -846,6 +848,8 @@ build.json @home-assistant/supervisor
/tests/components/kraken/ @eifinger
/homeassistant/components/kulersky/ @emlove
/tests/components/kulersky/ @emlove
/homeassistant/components/labs/ @home-assistant/core
/tests/components/labs/ @home-assistant/core
/homeassistant/components/lacrosse_view/ @IceBotYT
/tests/components/lacrosse_view/ @IceBotYT
/homeassistant/components/lamarzocco/ @zweckj

2
Dockerfile generated
View File

@@ -25,7 +25,7 @@ RUN \
"armv7") go2rtc_suffix='arm' ;; \
*) go2rtc_suffix=${BUILD_ARCH} ;; \
esac \
&& curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.11/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \
&& curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.12/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \
&& chmod +x /bin/go2rtc \
# Verify go2rtc can be executed
&& go2rtc --version

View File

@@ -176,6 +176,8 @@ FRONTEND_INTEGRATIONS = {
STAGE_0_INTEGRATIONS = (
# Load logging and http deps as soon as possible
("logging, http deps", LOGGING_AND_HTTP_DEPS_INTEGRATIONS, None),
# Setup labs for preview features
("labs", {"labs"}, STAGE_0_SUBSTAGE_TIMEOUT),
# Setup frontend
("frontend", FRONTEND_INTEGRATIONS, None),
# Setup recorder
@@ -212,6 +214,7 @@ DEFAULT_INTEGRATIONS = {
"backup",
"frontend",
"hardware",
"labs",
"logger",
"network",
"system_health",

View File

@@ -36,28 +36,5 @@
"alarm_trigger": {
"service": "mdi:bell-ring"
}
},
"triggers": {
"armed": {
"trigger": "mdi:shield"
},
"armed_away": {
"trigger": "mdi:shield-lock"
},
"armed_home": {
"trigger": "mdi:shield-home"
},
"armed_night": {
"trigger": "mdi:shield-moon"
},
"armed_vacation": {
"trigger": "mdi:shield-airplane"
},
"disarmed": {
"trigger": "mdi:shield-off"
},
"triggered": {
"trigger": "mdi:bell-ring"
}
}
}

View File

@@ -1,8 +1,4 @@
{
"common": {
"trigger_behavior_description": "The behavior of the targeted alarms to trigger on.",
"trigger_behavior_name": "Behavior"
},
"device_automation": {
"action_type": {
"arm_away": "Arm {entity_name} away",
@@ -75,15 +71,6 @@
"message": "Arming requires a code but none was given for {entity_id}."
}
},
"selector": {
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
}
},
"services": {
"alarm_arm_away": {
"description": "Arms the alarm in the away mode.",
@@ -156,84 +143,5 @@
"name": "Trigger"
}
},
"title": "Alarm control panel",
"triggers": {
"armed": {
"description": "Triggers when an alarm is armed.",
"description_configured": "[%key:component::alarm_control_panel::triggers::armed::description%]",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
}
},
"name": "When an alarm is armed"
},
"armed_away": {
"description": "Triggers when an alarm is armed away.",
"description_configured": "[%key:component::alarm_control_panel::triggers::armed_away::description%]",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
}
},
"name": "When an alarm is armed away"
},
"armed_home": {
"description": "Triggers when an alarm is armed home.",
"description_configured": "[%key:component::alarm_control_panel::triggers::armed_home::description%]",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
}
},
"name": "When an alarm is armed home"
},
"armed_night": {
"description": "Triggers when an alarm is armed night.",
"description_configured": "[%key:component::alarm_control_panel::triggers::armed_night::description%]",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
}
},
"name": "When an alarm is armed night"
},
"armed_vacation": {
"description": "Triggers when an alarm is armed vacation.",
"description_configured": "[%key:component::alarm_control_panel::triggers::armed_vacation::description%]",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
}
},
"name": "When an alarm is armed vacation"
},
"disarmed": {
"description": "Triggers when an alarm is disarmed.",
"description_configured": "[%key:component::alarm_control_panel::triggers::disarmed::description%]",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
}
},
"name": "When an alarm is disarmed"
},
"triggered": {
"description": "Triggers when an alarm is triggered.",
"description_configured": "[%key:component::alarm_control_panel::triggers::triggered::description%]",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
}
},
"name": "When an alarm is triggered"
}
}
"title": "Alarm control panel"
}

View File

@@ -1,99 +0,0 @@
"""Provides triggers for alarm control panels."""
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity import get_supported_features
from homeassistant.helpers.trigger import (
EntityStateTriggerBase,
Trigger,
make_conditional_entity_state_trigger,
make_entity_state_trigger,
)
from .const import DOMAIN, AlarmControlPanelEntityFeature, AlarmControlPanelState
def supports_feature(hass: HomeAssistant, entity_id: str, features: int) -> bool:
"""Get the device class of an entity or UNDEFINED if not found."""
try:
return bool(get_supported_features(hass, entity_id) & features)
except HomeAssistantError:
return False
class EntityStateTriggerRequiredFeatures(EntityStateTriggerBase):
"""Trigger for entity state changes."""
_required_features: int
def entity_filter(self, entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
entities = super().entity_filter(entities)
return {
entity_id
for entity_id in entities
if supports_feature(self._hass, entity_id, self._required_features)
}
def make_entity_state_trigger_required_features(
domain: str, to_state: str, required_features: int
) -> type[EntityStateTriggerBase]:
"""Create an entity state trigger class."""
class CustomTrigger(EntityStateTriggerRequiredFeatures):
"""Trigger for entity state changes."""
_domain = domain
_to_state = to_state
_required_features = required_features
return CustomTrigger
TRIGGERS: dict[str, type[Trigger]] = {
"armed": make_conditional_entity_state_trigger(
DOMAIN,
from_states={
AlarmControlPanelState.ARMING,
AlarmControlPanelState.DISARMED,
AlarmControlPanelState.DISARMING,
AlarmControlPanelState.PENDING,
AlarmControlPanelState.TRIGGERED,
},
to_states={
AlarmControlPanelState.ARMED_AWAY,
AlarmControlPanelState.ARMED_CUSTOM_BYPASS,
AlarmControlPanelState.ARMED_HOME,
AlarmControlPanelState.ARMED_NIGHT,
AlarmControlPanelState.ARMED_VACATION,
},
),
"armed_away": make_entity_state_trigger_required_features(
DOMAIN,
AlarmControlPanelState.ARMED_AWAY,
AlarmControlPanelEntityFeature.ARM_AWAY,
),
"armed_home": make_entity_state_trigger_required_features(
DOMAIN,
AlarmControlPanelState.ARMED_HOME,
AlarmControlPanelEntityFeature.ARM_HOME,
),
"armed_night": make_entity_state_trigger_required_features(
DOMAIN,
AlarmControlPanelState.ARMED_NIGHT,
AlarmControlPanelEntityFeature.ARM_NIGHT,
),
"armed_vacation": make_entity_state_trigger_required_features(
DOMAIN,
AlarmControlPanelState.ARMED_VACATION,
AlarmControlPanelEntityFeature.ARM_VACATION,
),
"disarmed": make_entity_state_trigger(DOMAIN, AlarmControlPanelState.DISARMED),
"triggered": make_entity_state_trigger(DOMAIN, AlarmControlPanelState.TRIGGERED),
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for alarm control panels."""
return TRIGGERS

View File

@@ -1,53 +0,0 @@
.trigger_common: &trigger_common
target:
entity:
domain: alarm_control_panel
fields: &trigger_common_fields
behavior:
required: true
default: any
selector:
select:
options:
- first
- last
- any
translation_key: trigger_behavior
armed: *trigger_common
armed_away:
fields: *trigger_common_fields
target:
entity:
domain: alarm_control_panel
supported_features:
- alarm_control_panel.AlarmControlPanelEntityFeature.ARM_AWAY
armed_home:
fields: *trigger_common_fields
target:
entity:
domain: alarm_control_panel
supported_features:
- alarm_control_panel.AlarmControlPanelEntityFeature.ARM_HOME
armed_night:
fields: *trigger_common_fields
target:
entity:
domain: alarm_control_panel
supported_features:
- alarm_control_panel.AlarmControlPanelEntityFeature.ARM_NIGHT
armed_vacation:
fields: *trigger_common_fields
target:
entity:
domain: alarm_control_panel
supported_features:
- alarm_control_panel.AlarmControlPanelEntityFeature.ARM_VACATION
disarmed: *trigger_common
triggered: *trigger_common

View File

@@ -14,19 +14,5 @@
"start_conversation": {
"service": "mdi:forum"
}
},
"triggers": {
"idle": {
"trigger": "mdi:chat-sleep"
},
"listening": {
"trigger": "mdi:chat-question"
},
"processing": {
"trigger": "mdi:chat-processing"
},
"responding": {
"trigger": "mdi:chat-alert"
}
}
}

View File

@@ -1,8 +1,4 @@
{
"common": {
"trigger_behavior_description": "The behavior of the targeted assist satellites to trigger on.",
"trigger_behavior_name": "Behavior"
},
"entity_component": {
"_": {
"name": "Assist satellite",
@@ -20,13 +16,6 @@
"id": "Answer ID",
"sentences": "Sentences"
}
},
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
}
},
"services": {
@@ -109,51 +98,5 @@
"name": "Start conversation"
}
},
"title": "Assist satellite",
"triggers": {
"idle": {
"description": "Triggers when an assist satellite becomes idle.",
"description_configured": "[%key:component::assist_satellite::triggers::idle::description%]",
"fields": {
"behavior": {
"description": "[%key:component::assist_satellite::common::trigger_behavior_description%]",
"name": "[%key:component::assist_satellite::common::trigger_behavior_name%]"
}
},
"name": "When an assist satellite becomes idle"
},
"listening": {
"description": "Triggers when an assist satellite starts listening.",
"description_configured": "[%key:component::assist_satellite::triggers::listening::description%]",
"fields": {
"behavior": {
"description": "[%key:component::assist_satellite::common::trigger_behavior_description%]",
"name": "[%key:component::assist_satellite::common::trigger_behavior_name%]"
}
},
"name": "When an assist satellite starts listening"
},
"processing": {
"description": "Triggers when an assist satellite is processing.",
"description_configured": "[%key:component::assist_satellite::triggers::processing::description%]",
"fields": {
"behavior": {
"description": "[%key:component::assist_satellite::common::trigger_behavior_description%]",
"name": "[%key:component::assist_satellite::common::trigger_behavior_name%]"
}
},
"name": "When an assist satellite is processing"
},
"responding": {
"description": "Triggers when an assist satellite is responding.",
"description_configured": "[%key:component::assist_satellite::triggers::responding::description%]",
"fields": {
"behavior": {
"description": "[%key:component::assist_satellite::common::trigger_behavior_description%]",
"name": "[%key:component::assist_satellite::common::trigger_behavior_name%]"
}
},
"name": "When an assist satellite is responding"
}
}
"title": "Assist satellite"
}

View File

@@ -1,19 +0,0 @@
"""Provides triggers for assist satellites."""
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import Trigger, make_entity_state_trigger
from .const import DOMAIN
from .entity import AssistSatelliteState
TRIGGERS: dict[str, type[Trigger]] = {
"idle": make_entity_state_trigger(DOMAIN, AssistSatelliteState.IDLE),
"listening": make_entity_state_trigger(DOMAIN, AssistSatelliteState.LISTENING),
"processing": make_entity_state_trigger(DOMAIN, AssistSatelliteState.PROCESSING),
"responding": make_entity_state_trigger(DOMAIN, AssistSatelliteState.RESPONDING),
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for assist satellites."""
return TRIGGERS

View File

@@ -1,20 +0,0 @@
.trigger_common: &trigger_common
target:
entity:
domain: assist_satellite
fields:
behavior:
required: true
default: any
selector:
select:
options:
- first
- last
- any
translation_key: trigger_behavior
idle: *trigger_common
listening: *trigger_common
processing: *trigger_common
responding: *trigger_common

View File

@@ -6,7 +6,7 @@
"documentation": "https://www.home-assistant.io/integrations/awair",
"iot_class": "local_polling",
"loggers": ["python_awair"],
"requirements": ["python-awair==0.2.4"],
"requirements": ["python-awair==0.2.5"],
"zeroconf": [
{
"name": "awair*",

View File

@@ -24,7 +24,7 @@ class BrotherPrinterEntity(CoordinatorEntity[BrotherDataUpdateCoordinator]):
connections={(CONNECTION_NETWORK_MAC, coordinator.brother.mac)},
serial_number=coordinator.brother.serial,
manufacturer="Brother",
model=coordinator.brother.model,
model_id=coordinator.brother.model,
name=coordinator.brother.model,
sw_version=coordinator.brother.firmware,
)

View File

@@ -17,7 +17,7 @@ from homeassistant.components.sensor import (
SensorStateClass,
)
from homeassistant.const import PERCENTAGE, EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
@@ -345,12 +345,10 @@ class BrotherPrinterSensor(BrotherPrinterEntity, SensorEntity):
"""Initialize."""
super().__init__(coordinator)
self._attr_native_value = description.value(coordinator.data)
self._attr_unique_id = f"{coordinator.brother.serial.lower()}_{description.key}"
self.entity_description = description
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self._attr_native_value = self.entity_description.value(self.coordinator.data)
self.async_write_ha_state()
@property
def native_value(self) -> StateType | datetime:
"""Return the native value of the sensor."""
return self.entity_description.value(self.coordinator.data)

View File

@@ -96,16 +96,5 @@
"turn_on": {
"service": "mdi:power-on"
}
},
"triggers": {
"started_heating": {
"trigger": "mdi:fire"
},
"turned_off": {
"trigger": "mdi:power-off"
},
"turned_on": {
"trigger": "mdi:power-on"
}
}
}

View File

@@ -1,8 +1,4 @@
{
"common": {
"trigger_behavior_description": "The behavior of the targeted climates to trigger on.",
"trigger_behavior_name": "Behavior"
},
"device_automation": {
"action_type": {
"set_hvac_mode": "Change HVAC mode on {entity_name}",
@@ -191,13 +187,6 @@
"heat_cool": "Heat/cool",
"off": "[%key:common::state::off%]"
}
},
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
}
},
"services": {
@@ -296,40 +285,5 @@
"name": "[%key:common::action::turn_on%]"
}
},
"title": "Climate",
"triggers": {
"started_heating": {
"description": "Triggers when a climate starts to heat.",
"description_configured": "[%key:component::climate::triggers::started_heating::description%]",
"fields": {
"behavior": {
"description": "[%key:component::climate::common::trigger_behavior_description%]",
"name": "[%key:component::climate::common::trigger_behavior_name%]"
}
},
"name": "When a climate starts to heat"
},
"turned_off": {
"description": "Triggers when a climate is turned off.",
"description_configured": "[%key:component::climate::triggers::turned_off::description%]",
"fields": {
"behavior": {
"description": "[%key:component::climate::common::trigger_behavior_description%]",
"name": "[%key:component::climate::common::trigger_behavior_name%]"
}
},
"name": "When a climate is turned off"
},
"turned_on": {
"description": "Triggers when a climate is turned on.",
"description_configured": "[%key:component::climate::triggers::turned_on::description%]",
"fields": {
"behavior": {
"description": "[%key:component::climate::common::trigger_behavior_description%]",
"name": "[%key:component::climate::common::trigger_behavior_name%]"
}
},
"name": "When a climate is turned on"
}
}
"title": "Climate"
}

View File

@@ -1,37 +0,0 @@
"""Provides triggers for climates."""
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import (
Trigger,
make_conditional_entity_state_trigger,
make_entity_state_attribute_trigger,
make_entity_state_trigger,
)
from .const import ATTR_HVAC_ACTION, DOMAIN, HVACAction, HVACMode
TRIGGERS: dict[str, type[Trigger]] = {
"turned_off": make_entity_state_trigger(DOMAIN, HVACMode.OFF),
"turned_on": make_conditional_entity_state_trigger(
DOMAIN,
from_states={
HVACMode.OFF,
},
to_states={
HVACMode.AUTO,
HVACMode.COOL,
HVACMode.DRY,
HVACMode.FAN_ONLY,
HVACMode.HEAT,
HVACMode.HEAT_COOL,
},
),
"started_heating": make_entity_state_attribute_trigger(
DOMAIN, ATTR_HVAC_ACTION, HVACAction.HEATING
),
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for climates."""
return TRIGGERS

View File

@@ -1,19 +0,0 @@
.trigger_common: &trigger_common
target:
entity:
domain: climate
fields:
behavior:
required: true
default: any
selector:
select:
translation_key: trigger_behavior
options:
- first
- last
- any
started_heating: *trigger_common
turned_off: *trigger_common
turned_on: *trigger_common

View File

@@ -7,7 +7,7 @@ from collections.abc import Awaitable, Callable
from datetime import datetime, timedelta
from enum import Enum
import logging
from typing import cast
from typing import Any, cast
from hass_nabucasa import Cloud
import voluptuous as vol
@@ -86,6 +86,10 @@ SIGNAL_CLOUD_CONNECTION_STATE: SignalType[CloudConnectionState] = SignalType(
"CLOUD_CONNECTION_STATE"
)
_SIGNAL_CLOUDHOOKS_UPDATED: SignalType[dict[str, Any]] = SignalType(
"CLOUDHOOKS_UPDATED"
)
STARTUP_REPAIR_DELAY = 1 # 1 hour
ALEXA_ENTITY_SCHEMA = vol.Schema(
@@ -242,6 +246,24 @@ async def async_delete_cloudhook(hass: HomeAssistant, webhook_id: str) -> None:
await hass.data[DATA_CLOUD].cloudhooks.async_delete(webhook_id)
@callback
def async_listen_cloudhook_change(
hass: HomeAssistant,
webhook_id: str,
on_change: Callable[[dict[str, Any] | None], None],
) -> Callable[[], None]:
"""Listen for cloudhook changes for the given webhook and notify when modified or deleted."""
@callback
def _handle_cloudhooks_updated(cloudhooks: dict[str, Any]) -> None:
"""Handle cloudhooks updated signal."""
on_change(cloudhooks.get(webhook_id))
return async_dispatcher_connect(
hass, _SIGNAL_CLOUDHOOKS_UPDATED, _handle_cloudhooks_updated
)
@bind_hass
@callback
def async_remote_ui_url(hass: HomeAssistant) -> str:
@@ -289,7 +311,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown)
_remote_handle_prefs_updated(cloud)
_handle_prefs_updated(hass, cloud)
_setup_services(hass, prefs)
async def async_startup_repairs(_: datetime) -> None:
@@ -373,26 +395,32 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
@callback
def _remote_handle_prefs_updated(cloud: Cloud[CloudClient]) -> None:
"""Handle remote preferences updated."""
cur_pref = cloud.client.prefs.remote_enabled
def _handle_prefs_updated(hass: HomeAssistant, cloud: Cloud[CloudClient]) -> None:
"""Register handler for cloud preferences updates."""
cur_remote_enabled = cloud.client.prefs.remote_enabled
cur_cloudhooks = cloud.client.prefs.cloudhooks
lock = asyncio.Lock()
# Sync remote connection with prefs
async def remote_prefs_updated(prefs: CloudPreferences) -> None:
"""Update remote status."""
nonlocal cur_pref
async def on_prefs_updated(prefs: CloudPreferences) -> None:
"""Handle cloud preferences updates."""
nonlocal cur_remote_enabled
nonlocal cur_cloudhooks
# Lock protects cur_ state variables from concurrent updates
async with lock:
if prefs.remote_enabled == cur_pref:
if cur_cloudhooks != prefs.cloudhooks:
cur_cloudhooks = prefs.cloudhooks
async_dispatcher_send(hass, _SIGNAL_CLOUDHOOKS_UPDATED, cur_cloudhooks)
if prefs.remote_enabled == cur_remote_enabled:
return
if cur_pref := prefs.remote_enabled:
if cur_remote_enabled := prefs.remote_enabled:
await cloud.remote.connect()
else:
await cloud.remote.disconnect()
cloud.client.prefs.async_listen_updates(remote_prefs_updated)
cloud.client.prefs.async_listen_updates(on_prefs_updated)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:

View File

@@ -18,6 +18,7 @@ def async_setup(hass: HomeAssistant) -> bool:
websocket_api.async_register_command(hass, websocket_create_area)
websocket_api.async_register_command(hass, websocket_delete_area)
websocket_api.async_register_command(hass, websocket_update_area)
websocket_api.async_register_command(hass, websocket_reorder_areas)
return True
@@ -145,3 +146,27 @@ def websocket_update_area(
connection.send_error(msg["id"], "invalid_info", str(err))
else:
connection.send_result(msg["id"], entry.json_fragment)
@websocket_api.websocket_command(
{
vol.Required("type"): "config/area_registry/reorder",
vol.Required("area_ids"): [str],
}
)
@websocket_api.require_admin
@callback
def websocket_reorder_areas(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Handle reorder areas websocket command."""
registry = ar.async_get(hass)
try:
registry.async_reorder(msg["area_ids"])
except ValueError as err:
connection.send_error(msg["id"], websocket_api.ERR_INVALID_FORMAT, str(err))
else:
connection.send_result(msg["id"])

View File

@@ -18,6 +18,7 @@ def async_setup(hass: HomeAssistant) -> bool:
websocket_api.async_register_command(hass, websocket_create_floor)
websocket_api.async_register_command(hass, websocket_delete_floor)
websocket_api.async_register_command(hass, websocket_update_floor)
websocket_api.async_register_command(hass, websocket_reorder_floors)
return True
@@ -127,6 +128,28 @@ def websocket_update_floor(
connection.send_result(msg["id"], _entry_dict(entry))
@websocket_api.websocket_command(
{
vol.Required("type"): "config/floor_registry/reorder",
vol.Required("floor_ids"): [str],
}
)
@websocket_api.require_admin
@callback
def websocket_reorder_floors(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
) -> None:
"""Handle reorder floors websocket command."""
registry = fr.async_get(hass)
try:
registry.async_reorder(msg["floor_ids"])
except ValueError as err:
connection.send_error(msg["id"], websocket_api.ERR_INVALID_FORMAT, str(err))
else:
connection.send_result(msg["id"])
@callback
def _entry_dict(entry: FloorEntry) -> dict[str, Any]:
"""Convert entry to API format."""

View File

@@ -108,34 +108,5 @@
"toggle_cover_tilt": {
"service": "mdi:arrow-top-right-bottom-left"
}
},
"triggers": {
"awning_opened": {
"trigger": "mdi:awning-outline"
},
"blind_opened": {
"trigger": "mdi:blinds-horizontal"
},
"curtain_opened": {
"trigger": "mdi:curtains"
},
"door_opened": {
"trigger": "mdi:door-open"
},
"garage_opened": {
"trigger": "mdi:garage-open"
},
"gate_opened": {
"trigger": "mdi:gate-open"
},
"shade_opened": {
"trigger": "mdi:roller-shade"
},
"shutter_opened": {
"trigger": "mdi:window-shutter-open"
},
"window_opened": {
"trigger": "mdi:window-open"
}
}
}

View File

@@ -1,16 +1,4 @@
{
"common": {
"trigger_behavior_description_awning": "The behavior of the targeted awnings to trigger on.",
"trigger_behavior_description_blind": "The behavior of the targeted blinds to trigger on.",
"trigger_behavior_description_curtain": "The behavior of the targeted curtains to trigger on.",
"trigger_behavior_description_door": "The behavior of the targeted doors to trigger on.",
"trigger_behavior_description_garage": "The behavior of the targeted garage doors to trigger on.",
"trigger_behavior_description_gate": "The behavior of the targeted gates to trigger on.",
"trigger_behavior_description_shade": "The behavior of the targeted shades to trigger on.",
"trigger_behavior_description_shutter": "The behavior of the targeted shutters to trigger on.",
"trigger_behavior_description_window": "The behavior of the targeted windows to trigger on.",
"trigger_behavior_name": "Behavior"
},
"device_automation": {
"action_type": {
"close": "Close {entity_name}",
@@ -94,15 +82,6 @@
"name": "Window"
}
},
"selector": {
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
}
},
"services": {
"close_cover": {
"description": "Closes a cover.",
@@ -157,142 +136,5 @@
"name": "Toggle tilt"
}
},
"title": "Cover",
"triggers": {
"awning_opened": {
"description": "Triggers when an awning opens.",
"description_configured": "[%key:component::cover::triggers::awning_opened::description%]",
"fields": {
"behavior": {
"description": "[%key:component::cover::common::trigger_behavior_description_awning%]",
"name": "[%key:component::cover::common::trigger_behavior_name%]"
},
"fully_opened": {
"description": "Require the awnings to be fully opened before triggering.",
"name": "Fully opened"
}
},
"name": "When an awning opens"
},
"blind_opened": {
"description": "Triggers when a blind opens.",
"description_configured": "[%key:component::cover::triggers::blind_opened::description%]",
"fields": {
"behavior": {
"description": "[%key:component::cover::common::trigger_behavior_description_blind%]",
"name": "[%key:component::cover::common::trigger_behavior_name%]"
},
"fully_opened": {
"description": "Require the blinds to be fully opened before triggering.",
"name": "Fully opened"
}
},
"name": "When a blind opens"
},
"curtain_opened": {
"description": "Triggers when a curtain opens.",
"description_configured": "[%key:component::cover::triggers::curtain_opened::description%]",
"fields": {
"behavior": {
"description": "[%key:component::cover::common::trigger_behavior_description_curtain%]",
"name": "[%key:component::cover::common::trigger_behavior_name%]"
},
"fully_opened": {
"description": "Require the curtains to be fully opened before triggering.",
"name": "Fully opened"
}
},
"name": "When a curtain opens"
},
"door_opened": {
"description": "Triggers when a door opens.",
"description_configured": "[%key:component::cover::triggers::door_opened::description%]",
"fields": {
"behavior": {
"description": "[%key:component::cover::common::trigger_behavior_description_door%]",
"name": "[%key:component::cover::common::trigger_behavior_name%]"
},
"fully_opened": {
"description": "Require the doors to be fully opened before triggering.",
"name": "Fully opened"
}
},
"name": "When a door opens"
},
"garage_opened": {
"description": "Triggers when a garage door opens.",
"description_configured": "[%key:component::cover::triggers::garage_opened::description%]",
"fields": {
"behavior": {
"description": "[%key:component::cover::common::trigger_behavior_description_garage%]",
"name": "[%key:component::cover::common::trigger_behavior_name%]"
},
"fully_opened": {
"description": "Require the garage doors to be fully opened before triggering.",
"name": "Fully opened"
}
},
"name": "When a garage door opens"
},
"gate_opened": {
"description": "Triggers when a gate opens.",
"description_configured": "[%key:component::cover::triggers::gate_opened::description%]",
"fields": {
"behavior": {
"description": "[%key:component::cover::common::trigger_behavior_description_gate%]",
"name": "[%key:component::cover::common::trigger_behavior_name%]"
},
"fully_opened": {
"description": "Require the gates to be fully opened before triggering.",
"name": "Fully opened"
}
},
"name": "When a gate opens"
},
"shade_opened": {
"description": "Triggers when a shade opens.",
"description_configured": "[%key:component::cover::triggers::shade_opened::description%]",
"fields": {
"behavior": {
"description": "[%key:component::cover::common::trigger_behavior_description_shade%]",
"name": "[%key:component::cover::common::trigger_behavior_name%]"
},
"fully_opened": {
"description": "Require the shades to be fully opened before triggering.",
"name": "Fully opened"
}
},
"name": "When a shade opens"
},
"shutter_opened": {
"description": "Triggers when a shutter opens.",
"description_configured": "[%key:component::cover::triggers::shutter_opened::description%]",
"fields": {
"behavior": {
"description": "[%key:component::cover::common::trigger_behavior_description_shutter%]",
"name": "[%key:component::cover::common::trigger_behavior_name%]"
},
"fully_opened": {
"description": "Require the shutters to be fully opened before triggering.",
"name": "Fully opened"
}
},
"name": "When a shutter opens"
},
"window_opened": {
"description": "Triggers when a window opens.",
"description_configured": "[%key:component::cover::triggers::window_opened::description%]",
"fields": {
"behavior": {
"description": "[%key:component::cover::common::trigger_behavior_description_window%]",
"name": "[%key:component::cover::common::trigger_behavior_name%]"
},
"fully_opened": {
"description": "Require the windows to be fully opened before triggering.",
"name": "Fully opened"
}
},
"name": "When a window opens"
}
}
"title": "Cover"
}

View File

@@ -1,116 +0,0 @@
"""Provides triggers for covers."""
from typing import Final
import voluptuous as vol
from homeassistant.const import CONF_OPTIONS
from homeassistant.core import HomeAssistant, State
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity import get_device_class
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST,
EntityTriggerBase,
Trigger,
TriggerConfig,
)
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
from . import ATTR_CURRENT_POSITION, CoverDeviceClass, CoverState
from .const import DOMAIN
ATTR_FULLY_OPENED: Final = "fully_opened"
COVER_OPENED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST.extend(
{
vol.Required(CONF_OPTIONS): {
vol.Required(ATTR_FULLY_OPENED, default=False): bool,
},
}
)
def get_device_class_or_undefined(
hass: HomeAssistant, entity_id: str
) -> str | None | UndefinedType:
"""Get the device class of an entity or UNDEFINED if not found."""
try:
return get_device_class(hass, entity_id)
except HomeAssistantError:
return UNDEFINED
class CoverOpenedClosedTrigger(EntityTriggerBase):
"""Class for cover opened and closed triggers."""
_attribute: str = ATTR_CURRENT_POSITION
_attribute_value: int | None = None
_device_class: CoverDeviceClass | None
_domain: str = DOMAIN
_to_states: set[str]
def is_to_state(self, state: State) -> bool:
"""Check if the state matches the target state."""
if state.state not in self._to_states:
return False
if (
self._attribute_value is not None
and (value := state.attributes.get(self._attribute)) is not None
and value != self._attribute_value
):
return False
return True
def entity_filter(self, entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
entities = super().entity_filter(entities)
return {
entity_id
for entity_id in entities
if get_device_class_or_undefined(self._hass, entity_id)
== self._device_class
}
class CoverOpenedTrigger(CoverOpenedClosedTrigger):
"""Class for cover opened triggers."""
_schema = COVER_OPENED_TRIGGER_SCHEMA
_to_states = {CoverState.OPEN, CoverState.OPENING}
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the state trigger."""
super().__init__(hass, config)
if self._options.get(ATTR_FULLY_OPENED):
self._attribute_value = 100
def make_cover_opened_trigger(
device_class: CoverDeviceClass | None,
) -> type[CoverOpenedTrigger]:
"""Create an entity state attribute trigger class."""
class CustomTrigger(CoverOpenedTrigger):
"""Trigger for entity state changes."""
_device_class = device_class
return CustomTrigger
TRIGGERS: dict[str, type[Trigger]] = {
"awning_opened": make_cover_opened_trigger(CoverDeviceClass.AWNING),
"blind_opened": make_cover_opened_trigger(CoverDeviceClass.BLIND),
"curtain_opened": make_cover_opened_trigger(CoverDeviceClass.CURTAIN),
"door_opened": make_cover_opened_trigger(CoverDeviceClass.DOOR),
"garage_opened": make_cover_opened_trigger(CoverDeviceClass.GARAGE),
"gate_opened": make_cover_opened_trigger(CoverDeviceClass.GATE),
"shade_opened": make_cover_opened_trigger(CoverDeviceClass.SHADE),
"shutter_opened": make_cover_opened_trigger(CoverDeviceClass.SHUTTER),
"window_opened": make_cover_opened_trigger(CoverDeviceClass.WINDOW),
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for covers."""
return TRIGGERS

View File

@@ -1,79 +0,0 @@
.trigger_common_fields: &trigger_common_fields
behavior:
required: true
default: any
selector:
select:
translation_key: trigger_behavior
options:
- first
- last
- any
fully_opened:
required: true
default: false
selector:
boolean:
awning_opened:
fields: *trigger_common_fields
target:
entity:
domain: cover
device_class: awning
blind_opened:
fields: *trigger_common_fields
target:
entity:
domain: cover
device_class: blind
curtain_opened:
fields: *trigger_common_fields
target:
entity:
domain: cover
device_class: curtain
door_opened:
fields: *trigger_common_fields
target:
entity:
domain: cover
device_class: door
garage_opened:
fields: *trigger_common_fields
target:
entity:
domain: cover
device_class: garage
gate_opened:
fields: *trigger_common_fields
target:
entity:
domain: cover
device_class: gate
shade_opened:
fields: *trigger_common_fields
target:
entity:
domain: cover
device_class: shade
shutter_opened:
fields: *trigger_common_fields
target:
entity:
domain: cover
device_class: shutter
window_opened:
fields: *trigger_common_fields
target:
entity:
domain: cover
device_class: window

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
from datetime import timedelta
import logging
from typing import Any
@@ -25,6 +26,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import Throttle
_LOGGER = logging.getLogger(__name__)
@@ -167,6 +169,7 @@ class DecoraWifiLight(LightEntity):
except ValueError:
_LOGGER.error("Failed to turn off myLeviton switch")
@Throttle(timedelta(seconds=30))
def update(self) -> None:
"""Fetch new state data for this switch."""
try:

View File

@@ -47,13 +47,5 @@
"turn_on": {
"service": "mdi:fan"
}
},
"triggers": {
"turned_off": {
"trigger": "mdi:fan-off"
},
"turned_on": {
"trigger": "mdi:fan"
}
}
}

View File

@@ -1,8 +1,4 @@
{
"common": {
"trigger_behavior_description": "The behavior of the targeted fans to trigger on.",
"trigger_behavior_name": "Behavior"
},
"device_automation": {
"action_type": {
"toggle": "[%key:common::device_automation::action_type::toggle%]",
@@ -70,13 +66,6 @@
"forward": "Forward",
"reverse": "Reverse"
}
},
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
}
},
"services": {
@@ -163,29 +152,5 @@
"name": "[%key:common::action::turn_on%]"
}
},
"title": "Fan",
"triggers": {
"turned_off": {
"description": "Triggers when a fan is turned off.",
"description_configured": "[%key:component::fan::triggers::turned_off::description%]",
"fields": {
"behavior": {
"description": "[%key:component::fan::common::trigger_behavior_description%]",
"name": "[%key:component::fan::common::trigger_behavior_name%]"
}
},
"name": "When a fan is turned off"
},
"turned_on": {
"description": "Triggers when a fan is turned on.",
"description_configured": "[%key:component::fan::triggers::turned_on::description%]",
"fields": {
"behavior": {
"description": "[%key:component::fan::common::trigger_behavior_description%]",
"name": "[%key:component::fan::common::trigger_behavior_name%]"
}
},
"name": "When a fan is turned on"
}
}
"title": "Fan"
}

View File

@@ -1,17 +0,0 @@
"""Provides triggers for fans."""
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import Trigger, make_entity_state_trigger
from . import DOMAIN
TRIGGERS: dict[str, type[Trigger]] = {
"turned_off": make_entity_state_trigger(DOMAIN, STATE_OFF),
"turned_on": make_entity_state_trigger(DOMAIN, STATE_ON),
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for fans."""
return TRIGGERS

View File

@@ -1,18 +0,0 @@
.trigger_common: &trigger_common
target:
entity:
domain: fan
fields:
behavior:
required: true
default: any
selector:
select:
options:
- first
- last
- any
translation_key: trigger_behavior
turned_on: *trigger_common
turned_off: *trigger_common

View File

@@ -778,7 +778,7 @@ class ManifestJSONView(HomeAssistantView):
{
"type": "frontend/get_icons",
vol.Required("category"): vol.In(
{"conditions", "entity", "entity_component", "services", "triggers"}
{"entity", "entity_component", "services", "triggers", "conditions"}
),
vol.Optional("integration"): vol.All(cv.ensure_list, [str]),
}

View File

@@ -20,5 +20,5 @@
"documentation": "https://www.home-assistant.io/integrations/frontend",
"integration_type": "system",
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20251105.0"]
"requirements": ["home-assistant-frontend==20251105.1"]
}

View File

@@ -11,11 +11,14 @@ import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.components.websocket_api import ActiveConnection
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import singleton
from homeassistant.helpers.storage import Store
from homeassistant.util.hass_dict import HassKey
DATA_STORAGE: HassKey[dict[str, UserStore]] = HassKey("frontend_storage")
DATA_SYSTEM_STORAGE: HassKey[SystemStore] = HassKey("frontend_system_storage")
STORAGE_VERSION_USER_DATA = 1
STORAGE_VERSION_SYSTEM_DATA = 1
async def async_setup_frontend_storage(hass: HomeAssistant) -> None:
@@ -23,6 +26,9 @@ async def async_setup_frontend_storage(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, websocket_set_user_data)
websocket_api.async_register_command(hass, websocket_get_user_data)
websocket_api.async_register_command(hass, websocket_subscribe_user_data)
websocket_api.async_register_command(hass, websocket_set_system_data)
websocket_api.async_register_command(hass, websocket_get_system_data)
websocket_api.async_register_command(hass, websocket_subscribe_system_data)
async def async_user_store(hass: HomeAssistant, user_id: str) -> UserStore:
@@ -83,6 +89,52 @@ class _UserStore(Store[dict[str, Any]]):
)
@singleton.singleton(DATA_SYSTEM_STORAGE, async_=True)
async def async_system_store(hass: HomeAssistant) -> SystemStore:
"""Access the system store."""
store = SystemStore(hass)
await store.async_load()
return store
class SystemStore:
"""System store for frontend data."""
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the system store."""
self._store: Store[dict[str, Any]] = Store(
hass,
STORAGE_VERSION_SYSTEM_DATA,
"frontend.system_data",
)
self.data: dict[str, Any] = {}
self.subscriptions: dict[str, list[Callable[[], None]]] = {}
async def async_load(self) -> None:
"""Load the data from the store."""
self.data = await self._store.async_load() or {}
async def async_set_item(self, key: str, value: Any) -> None:
"""Set an item and save the store."""
self.data[key] = value
self._store.async_delay_save(lambda: self.data, 1.0)
for cb in self.subscriptions.get(key, []):
cb()
@callback
def async_subscribe(
self, key: str, on_update_callback: Callable[[], None]
) -> Callable[[], None]:
"""Subscribe to store updates."""
self.subscriptions.setdefault(key, []).append(on_update_callback)
def unsubscribe() -> None:
"""Unsubscribe from the store."""
self.subscriptions[key].remove(on_update_callback)
return unsubscribe
def with_user_store(
orig_func: Callable[
[HomeAssistant, ActiveConnection, dict[str, Any], UserStore],
@@ -107,6 +159,28 @@ def with_user_store(
return with_user_store_func
def with_system_store(
orig_func: Callable[
[HomeAssistant, ActiveConnection, dict[str, Any], SystemStore],
Coroutine[Any, Any, None],
],
) -> Callable[
[HomeAssistant, ActiveConnection, dict[str, Any]], Coroutine[Any, Any, None]
]:
"""Decorate function to provide system store."""
@wraps(orig_func)
async def with_system_store_func(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
) -> None:
"""Provide system store to function."""
store = await async_system_store(hass)
await orig_func(hass, connection, msg, store)
return with_system_store_func
@websocket_api.websocket_command(
{
vol.Required("type"): "frontend/set_user_data",
@@ -169,3 +243,65 @@ async def websocket_subscribe_user_data(
connection.subscriptions[msg["id"]] = store.async_subscribe(key, on_data_update)
on_data_update()
connection.send_result(msg["id"])
@websocket_api.websocket_command(
{
vol.Required("type"): "frontend/set_system_data",
vol.Required("key"): str,
vol.Required("value"): vol.Any(bool, str, int, float, dict, list, None),
}
)
@websocket_api.require_admin
@websocket_api.async_response
@with_system_store
async def websocket_set_system_data(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
store: SystemStore,
) -> None:
"""Handle set system data command."""
await store.async_set_item(msg["key"], msg["value"])
connection.send_result(msg["id"])
@websocket_api.websocket_command(
{vol.Required("type"): "frontend/get_system_data", vol.Required("key"): str}
)
@websocket_api.async_response
@with_system_store
async def websocket_get_system_data(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
store: SystemStore,
) -> None:
"""Handle get system data command."""
connection.send_result(msg["id"], {"value": store.data.get(msg["key"])})
@websocket_api.websocket_command(
{
vol.Required("type"): "frontend/subscribe_system_data",
vol.Required("key"): str,
}
)
@websocket_api.async_response
@with_system_store
async def websocket_subscribe_system_data(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
store: SystemStore,
) -> None:
"""Handle subscribe to system data command."""
key: str = msg["key"]
def on_data_update() -> None:
"""Handle system data update."""
connection.send_event(msg["id"], {"value": store.data.get(key)})
connection.subscriptions[msg["id"]] = store.async_subscribe(key, on_data_update)
on_data_update()
connection.send_result(msg["id"])

View File

@@ -2,10 +2,11 @@
from __future__ import annotations
from dataclasses import dataclass
import logging
import shutil
from aiohttp import ClientSession
from aiohttp import ClientSession, UnixConnector
from aiohttp.client_exceptions import ClientConnectionError, ServerConnectionError
from awesomeversion import AwesomeVersion
from go2rtc_client import Go2RtcRestClient
@@ -52,6 +53,7 @@ from .const import (
CONF_DEBUG_UI,
DEBUG_UI_URL_MESSAGE,
DOMAIN,
HA_MANAGED_UNIX_SOCKET,
HA_MANAGED_URL,
RECOMMENDED_VERSION,
)
@@ -60,35 +62,6 @@ from .server import Server
_LOGGER = logging.getLogger(__name__)
_FFMPEG = "ffmpeg"
_SUPPORTED_STREAMS = frozenset(
(
"bubble",
"dvrip",
"expr",
_FFMPEG,
"gopro",
"homekit",
"http",
"https",
"httpx",
"isapi",
"ivideon",
"kasa",
"nest",
"onvif",
"roborock",
"rtmp",
"rtmps",
"rtmpx",
"rtsp",
"rtsps",
"rtspx",
"tapo",
"tcp",
"webrtc",
"webtorrent",
)
)
CONFIG_SCHEMA = vol.Schema(
{
@@ -102,7 +75,7 @@ CONFIG_SCHEMA = vol.Schema(
extra=vol.ALLOW_EXTRA,
)
_DATA_GO2RTC: HassKey[str] = HassKey(DOMAIN)
_DATA_GO2RTC: HassKey[Go2RtcConfig] = HassKey(DOMAIN)
_RETRYABLE_ERRORS = (ClientConnectionError, ServerConnectionError)
type Go2RtcConfigEntry = ConfigEntry[WebRTCProvider]
@@ -129,8 +102,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
return False
# HA will manage the binary
session = ClientSession(connector=UnixConnector(path=HA_MANAGED_UNIX_SOCKET))
server = Server(
hass, binary, enable_ui=config.get(DOMAIN, {}).get(CONF_DEBUG_UI, False)
hass,
binary,
session,
enable_ui=config.get(DOMAIN, {}).get(CONF_DEBUG_UI, False),
)
try:
await server.start()
@@ -140,12 +117,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def on_stop(event: Event) -> None:
await server.stop()
await session.close()
hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, on_stop)
url = HA_MANAGED_URL
else:
session = async_get_clientsession(hass)
hass.data[_DATA_GO2RTC] = url
hass.data[_DATA_GO2RTC] = Go2RtcConfig(url, session)
discovery_flow.async_create_flow(
hass, DOMAIN, context={"source": SOURCE_SYSTEM}, data={}
)
@@ -161,8 +141,9 @@ async def _remove_go2rtc_entries(hass: HomeAssistant) -> None:
async def async_setup_entry(hass: HomeAssistant, entry: Go2RtcConfigEntry) -> bool:
"""Set up go2rtc from a config entry."""
url = hass.data[_DATA_GO2RTC]
session = async_get_clientsession(hass)
config = hass.data[_DATA_GO2RTC]
url = config.url
session = config.session
client = Go2RtcRestClient(session, url)
# Validate the server URL
try:
@@ -197,6 +178,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: Go2RtcConfigEntry) -> bo
return False
provider = entry.runtime_data = WebRTCProvider(hass, url, session, client)
await provider.initialize()
entry.async_on_unload(async_register_webrtc_provider(hass, provider))
return True
@@ -228,16 +210,21 @@ class WebRTCProvider(CameraWebRTCProvider):
self._session = session
self._rest_client = rest_client
self._sessions: dict[str, Go2RtcWsClient] = {}
self._supported_schemes: set[str] = set()
@property
def domain(self) -> str:
"""Return the integration domain of the provider."""
return DOMAIN
async def initialize(self) -> None:
"""Initialize the provider."""
self._supported_schemes = await self._rest_client.schemes.list()
@callback
def async_is_supported(self, stream_source: str) -> bool:
"""Return if this provider is supports the Camera as source."""
return stream_source.partition(":")[0] in _SUPPORTED_STREAMS
return stream_source.partition(":")[0] in self._supported_schemes
async def async_handle_async_webrtc_offer(
self,
@@ -365,3 +352,11 @@ class WebRTCProvider(CameraWebRTCProvider):
for ws_client in self._sessions.values():
await ws_client.close()
self._sessions.clear()
@dataclass
class Go2RtcConfig:
"""Go2rtc configuration."""
url: str
session: ClientSession

View File

@@ -6,4 +6,5 @@ CONF_DEBUG_UI = "debug_ui"
DEBUG_UI_URL_MESSAGE = "Url and debug_ui cannot be set at the same time."
HA_MANAGED_API_PORT = 11984
HA_MANAGED_URL = f"http://localhost:{HA_MANAGED_API_PORT}/"
RECOMMENDED_VERSION = "1.9.11"
HA_MANAGED_UNIX_SOCKET = "/run/go2rtc.sock"
RECOMMENDED_VERSION = "1.9.12"

View File

@@ -8,6 +8,6 @@
"integration_type": "system",
"iot_class": "local_polling",
"quality_scale": "internal",
"requirements": ["go2rtc-client==0.2.1"],
"requirements": ["go2rtc-client==0.3.0"],
"single_config_entry": true
}

View File

@@ -6,13 +6,13 @@ from contextlib import suppress
import logging
from tempfile import NamedTemporaryFile
from aiohttp import ClientSession
from go2rtc_client import Go2RtcRestClient
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import HA_MANAGED_API_PORT, HA_MANAGED_URL
from .const import HA_MANAGED_API_PORT, HA_MANAGED_UNIX_SOCKET, HA_MANAGED_URL
_LOGGER = logging.getLogger(__name__)
_TERMINATE_TIMEOUT = 5
@@ -23,14 +23,26 @@ _LOG_BUFFER_SIZE = 512
_RESPAWN_COOLDOWN = 1
# Default configuration for HA
# - Api is listening only on localhost
# - Unix socket for secure local communication
# - HTTP API only enabled when UI is enabled
# - Enable rtsp for localhost only as ffmpeg needs it
# - Clear default ice servers
_GO2RTC_CONFIG_FORMAT = r"""# This file is managed by Home Assistant
# Do not edit it manually
app:
modules: {app_modules}
api:
listen: "{api_ip}:{api_port}"
listen: "{listen_config}"
unix_listen: "{unix_socket}"
allow_paths: {api_allow_paths}
# ffmpeg needs the exec module
# Restrict execution to only ffmpeg binary
exec:
allow_paths:
- ffmpeg
rtsp:
listen: "127.0.0.1:18554"
@@ -40,6 +52,43 @@ webrtc:
ice_servers: []
"""
_APP_MODULES = (
"api",
"exec", # Execution module for ffmpeg
"ffmpeg",
"http",
"mjpeg",
"onvif",
"rtmp",
"rtsp",
"srtp",
"webrtc",
"ws",
)
_API_ALLOW_PATHS = (
"/", # UI static page and version control
"/api", # Main API path
"/api/frame.jpeg", # Snapshot functionality
"/api/schemes", # Supported stream schemes
"/api/streams", # Stream management
"/api/webrtc", # Webrtc functionality
"/api/ws", # Websocket functionality (e.g. webrtc candidates)
)
# Additional modules when UI is enabled
_UI_APP_MODULES = (
*_APP_MODULES,
"debug",
)
# Additional api paths when UI is enabled
_UI_API_ALLOW_PATHS = (
*_API_ALLOW_PATHS,
"/api/config", # UI config view
"/api/log", # UI log view
"/api/streams.dot", # UI network view
)
_LOG_LEVEL_MAP = {
"TRC": logging.DEBUG,
"DBG": logging.DEBUG,
@@ -61,14 +110,38 @@ class Go2RTCWatchdogError(HomeAssistantError):
"""Raised on watchdog error."""
def _create_temp_file(api_ip: str) -> str:
def _format_list_for_yaml(items: tuple[str, ...]) -> str:
"""Format a list of strings for yaml config."""
if not items:
return "[]"
formatted_items = ",".join(f'"{item}"' for item in items)
return f"[{formatted_items}]"
def _create_temp_file(enable_ui: bool) -> str:
"""Create temporary config file."""
app_modules: tuple[str, ...] = _APP_MODULES
api_paths: tuple[str, ...] = _API_ALLOW_PATHS
if enable_ui:
app_modules = _UI_APP_MODULES
api_paths = _UI_API_ALLOW_PATHS
# Listen on all interfaces for allowing access from all ips
listen_config = f":{HA_MANAGED_API_PORT}"
else:
# Disable HTTP listening when UI is not enabled
# as HA does not use it.
listen_config = ""
# Set delete=False to prevent the file from being deleted when the file is closed
# Linux is clearing tmp folder on reboot, so no need to delete it manually
with NamedTemporaryFile(prefix="go2rtc_", suffix=".yaml", delete=False) as file:
file.write(
_GO2RTC_CONFIG_FORMAT.format(
api_ip=api_ip, api_port=HA_MANAGED_API_PORT
listen_config=listen_config,
unix_socket=HA_MANAGED_UNIX_SOCKET,
app_modules=_format_list_for_yaml(app_modules),
api_allow_paths=_format_list_for_yaml(api_paths),
).encode()
)
return file.name
@@ -78,18 +151,21 @@ class Server:
"""Go2rtc server."""
def __init__(
self, hass: HomeAssistant, binary: str, *, enable_ui: bool = False
self,
hass: HomeAssistant,
binary: str,
session: ClientSession,
*,
enable_ui: bool = False,
) -> None:
"""Initialize the server."""
self._hass = hass
self._binary = binary
self._session = session
self._log_buffer: deque[str] = deque(maxlen=_LOG_BUFFER_SIZE)
self._process: asyncio.subprocess.Process | None = None
self._startup_complete = asyncio.Event()
self._api_ip = _LOCALHOST_IP
if enable_ui:
# Listen on all interfaces for allowing access from all ips
self._api_ip = ""
self._enable_ui = enable_ui
self._watchdog_task: asyncio.Task | None = None
self._watchdog_tasks: list[asyncio.Task] = []
@@ -104,7 +180,7 @@ class Server:
"""Start the server."""
_LOGGER.debug("Starting go2rtc server")
config_file = await self._hass.async_add_executor_job(
_create_temp_file, self._api_ip
_create_temp_file, self._enable_ui
)
self._startup_complete.clear()
@@ -133,7 +209,7 @@ class Server:
raise Go2RTCServerStartError from err
# Check the server version
client = Go2RtcRestClient(async_get_clientsession(self._hass), HA_MANAGED_URL)
client = Go2RtcRestClient(self._session, HA_MANAGED_URL)
await client.validate_server_version()
async def _log_output(self, process: asyncio.subprocess.Process) -> None:
@@ -205,7 +281,7 @@ class Server:
async def _monitor_api(self) -> None:
"""Raise if the go2rtc process terminates."""
client = Go2RtcRestClient(async_get_clientsession(self._hass), HA_MANAGED_URL)
client = Go2RtcRestClient(self._session, HA_MANAGED_URL)
_LOGGER.debug("Monitoring go2rtc API")
try:

View File

@@ -0,0 +1,54 @@
"""The Hanna Instruments integration."""
from __future__ import annotations
from typing import Any
from hanna_cloud import HannaCloudClient
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform
from homeassistant.core import HomeAssistant
from .coordinator import HannaConfigEntry, HannaDataCoordinator
PLATFORMS = [Platform.SENSOR]
def _authenticate_and_get_devices(
api_client: HannaCloudClient,
email: str,
password: str,
) -> list[dict[str, Any]]:
"""Authenticate and get devices in a single executor job."""
api_client.authenticate(email, password)
return api_client.get_devices()
async def async_setup_entry(hass: HomeAssistant, entry: HannaConfigEntry) -> bool:
"""Set up Hanna Instruments from a config entry."""
api_client = HannaCloudClient()
devices = await hass.async_add_executor_job(
_authenticate_and_get_devices,
api_client,
entry.data[CONF_EMAIL],
entry.data[CONF_PASSWORD],
)
# Create device coordinators
device_coordinators = {}
for device in devices:
coordinator = HannaDataCoordinator(hass, entry, device, api_client)
await coordinator.async_config_entry_first_refresh()
device_coordinators[coordinator.device_identifier] = coordinator
# Set runtime data
entry.runtime_data = device_coordinators
# Forward the setup to the platforms
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: HannaConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -0,0 +1,62 @@
"""Config flow for Hanna Instruments integration."""
from __future__ import annotations
import logging
from typing import Any
from hanna_cloud import AuthenticationError, HannaCloudClient
from requests.exceptions import ConnectionError as RequestsConnectionError, Timeout
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
class HannaConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Hanna Instruments."""
VERSION = 1
data_schema = vol.Schema(
{vol.Required(CONF_EMAIL): str, vol.Required(CONF_PASSWORD): str}
)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the setup flow."""
errors: dict[str, str] = {}
if user_input is not None:
await self.async_set_unique_id(user_input[CONF_EMAIL])
self._abort_if_unique_id_configured()
client = HannaCloudClient()
try:
await self.hass.async_add_executor_job(
client.authenticate,
user_input[CONF_EMAIL],
user_input[CONF_PASSWORD],
)
except (Timeout, RequestsConnectionError):
errors["base"] = "cannot_connect"
except AuthenticationError:
errors["base"] = "invalid_auth"
if not errors:
return self.async_create_entry(
title=user_input[CONF_EMAIL],
data=user_input,
)
return self.async_show_form(
step_id="user",
data_schema=self.add_suggested_values_to_schema(
self.data_schema, user_input
),
errors=errors,
)

View File

@@ -0,0 +1,3 @@
"""Constants for the Hanna integration."""
DOMAIN = "hanna"

View File

@@ -0,0 +1,72 @@
"""Hanna Instruments data coordinator for Home Assistant.
This module provides the data coordinator for fetching and managing Hanna Instruments
sensor data.
"""
from datetime import timedelta
import logging
from typing import Any
from hanna_cloud import HannaCloudClient
from requests.exceptions import RequestException
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
type HannaConfigEntry = ConfigEntry[dict[str, HannaDataCoordinator]]
_LOGGER = logging.getLogger(__name__)
class HannaDataCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Coordinator for fetching Hanna sensor data."""
def __init__(
self,
hass: HomeAssistant,
config_entry: HannaConfigEntry,
device: dict[str, Any],
api_client: HannaCloudClient,
) -> None:
"""Initialize the Hanna data coordinator."""
self.api_client = api_client
self.device_data = device
super().__init__(
hass,
_LOGGER,
name=f"{DOMAIN}_{self.device_identifier}",
config_entry=config_entry,
update_interval=timedelta(seconds=30),
)
@property
def device_identifier(self) -> str:
"""Return the device identifier."""
return self.device_data["DID"]
def get_parameters(self) -> list[dict[str, Any]]:
"""Get all parameters from the sensor data."""
return self.api_client.parameters
def get_parameter_value(self, key: str) -> Any:
"""Get the value for a specific parameter."""
for parameter in self.get_parameters():
if parameter["name"] == key:
return parameter["value"]
return None
async def _async_update_data(self) -> dict[str, Any]:
"""Fetch latest sensor data from the Hanna API."""
try:
readings = await self.hass.async_add_executor_job(
self.api_client.get_last_device_reading, self.device_identifier
)
except RequestException as e:
raise UpdateFailed(f"Error communicating with Hanna API: {e}") from e
except (KeyError, IndexError) as e:
raise UpdateFailed(f"Error parsing Hanna API response: {e}") from e
return readings

View File

@@ -0,0 +1,28 @@
"""Hanna Instruments entity base class for Home Assistant.
This module provides the base entity class for Hanna Instruments entities.
"""
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import HannaDataCoordinator
class HannaEntity(CoordinatorEntity[HannaDataCoordinator]):
"""Base class for Hanna entities."""
_attr_has_entity_name = True
def __init__(self, coordinator: HannaDataCoordinator) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, coordinator.device_identifier)},
manufacturer=coordinator.device_data.get("manufacturer"),
model=coordinator.device_data.get("DM"),
name=coordinator.device_data.get("name"),
serial_number=coordinator.device_data.get("serial_number"),
sw_version=coordinator.device_data.get("sw_version"),
)

View File

@@ -0,0 +1,10 @@
{
"domain": "hanna",
"name": "Hanna",
"codeowners": ["@bestycame"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/hanna",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["hanna-cloud==0.0.6"]
}

View File

@@ -0,0 +1,70 @@
rules:
# Bronze
action-setup:
status: exempt
comment: |
This integration doesn't add actions.
appropriate-polling:
status: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions: done
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: |
Entities of this integration does not explicitly subscribe to events.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: todo
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: |
This integration does not have any configuration parameters.
docs-installation-parameters: done
entity-unavailable: todo
integration-owner: done
log-when-unavailable: todo
parallel-updates: todo
reauthentication-flow: todo
test-coverage: todo
# Gold
devices: done
diagnostics: todo
discovery-update-info: todo
discovery: todo
docs-data-update: done
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category: todo
entity-device-class: done
entity-disabled-by-default: todo
entity-translations: done
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo
# Platinum
async-dependency: todo
inject-websession: todo
strict-typing: todo

View File

@@ -0,0 +1,106 @@
"""Hanna Instruments sensor integration for Home Assistant.
This module provides sensor entities for various Hanna Instruments devices,
including pH, ORP, temperature, and chemical sensors. It uses the Hanna API
to fetch readings and updates them periodically.
"""
from __future__ import annotations
import logging
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import UnitOfElectricPotential, UnitOfTemperature, UnitOfVolume
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .coordinator import HannaConfigEntry, HannaDataCoordinator
from .entity import HannaEntity
_LOGGER = logging.getLogger(__name__)
SENSOR_DESCRIPTIONS = [
SensorEntityDescription(
key="ph",
translation_key="ph_value",
device_class=SensorDeviceClass.PH,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="orp",
translation_key="chlorine_orp_value",
device_class=SensorDeviceClass.VOLTAGE,
native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="temp",
translation_key="water_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="airTemp",
translation_key="air_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="acidBase",
translation_key="ph_acid_base_flow_rate",
icon="mdi:chemical-weapon",
device_class=SensorDeviceClass.VOLUME,
native_unit_of_measurement=UnitOfVolume.MILLILITERS,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="cl",
translation_key="chlorine_flow_rate",
icon="mdi:chemical-weapon",
device_class=SensorDeviceClass.VOLUME,
native_unit_of_measurement=UnitOfVolume.MILLILITERS,
state_class=SensorStateClass.MEASUREMENT,
),
]
async def async_setup_entry(
hass: HomeAssistant,
entry: HannaConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Hanna sensors from a config entry."""
device_coordinators = entry.runtime_data
async_add_entities(
HannaSensor(coordinator, description)
for description in SENSOR_DESCRIPTIONS
for coordinator in device_coordinators.values()
)
class HannaSensor(HannaEntity, SensorEntity):
"""Representation of a Hanna sensor."""
def __init__(
self,
coordinator: HannaDataCoordinator,
description: SensorEntityDescription,
) -> None:
"""Initialize a Hanna sensor."""
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.device_identifier}_{description.key}"
self.entity_description = description
@property
def native_value(self) -> StateType:
"""Return the value reported by the sensor."""
return self.coordinator.get_parameter_value(self.entity_description.key)

View File

@@ -0,0 +1,44 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"user": {
"data": {
"email": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"email": "Email address for your Hanna Cloud account",
"password": "Password for your Hanna Cloud account"
},
"description": "Enter your Hanna Cloud credentials"
}
}
},
"entity": {
"sensor": {
"air_temperature": {
"name": "Air temperature"
},
"chlorine_flow_rate": {
"name": "Chlorine flow rate"
},
"chlorine_orp_value": {
"name": "Chlorine ORP value"
},
"ph_acid_base_flow_rate": {
"name": "pH Acid/Base flow rate"
},
"water_temperature": {
"name": "Water temperature"
}
}
}
}

View File

@@ -7,7 +7,7 @@
"documentation": "https://www.home-assistant.io/integrations/homeassistant_hardware",
"integration_type": "system",
"requirements": [
"universal-silabs-flasher==0.1.0",
"universal-silabs-flasher==0.1.2",
"ha-silabs-firmware-client==0.3.0"
]
}

View File

@@ -145,10 +145,10 @@
"loop": "Loop",
"off": "[%key:common::state::off%]",
"seconds_1": "1 second",
"seconds_2": "2 second",
"seconds_3": "3 second",
"seconds_4": "4 second",
"seconds_5": "5 second"
"seconds_2": "2 seconds",
"seconds_3": "3 seconds",
"seconds_4": "4 seconds",
"seconds_5": "5 seconds"
}
},
"min_dc_voltage_cells": {

View File

@@ -11,6 +11,11 @@ from random import random
import voluptuous as vol
from homeassistant.components.labs import (
EVENT_LABS_UPDATED,
EventLabsUpdatedData,
async_is_preview_feature_enabled,
)
from homeassistant.components.recorder import DOMAIN as RECORDER_DOMAIN, get_instance
from homeassistant.components.recorder.models import (
StatisticData,
@@ -30,10 +35,14 @@ from homeassistant.const import (
UnitOfTemperature,
UnitOfVolume,
)
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.core import Event, HomeAssistant, ServiceCall, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.issue_registry import (
IssueSeverity,
async_create_issue,
async_delete_issue,
)
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import dt as dt_util
from homeassistant.util.unit_conversion import (
@@ -110,6 +119,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# Notify backup listeners
hass.async_create_task(_notify_backup_listeners(hass), eager_start=False)
# Subscribe to labs feature updates for kitchen_sink preview repair
@callback
def _async_labs_updated(event: Event[EventLabsUpdatedData]) -> None:
"""Handle labs feature update event."""
if (
event.data["domain"] == "kitchen_sink"
and event.data["preview_feature"] == "special_repair"
):
_async_update_special_repair(hass)
entry.async_on_unload(
hass.bus.async_listen(EVENT_LABS_UPDATED, _async_labs_updated)
)
# Check if lab feature is currently enabled and create repair if so
_async_update_special_repair(hass)
return True
@@ -137,6 +163,27 @@ async def async_remove_config_entry_device(
return True
@callback
def _async_update_special_repair(hass: HomeAssistant) -> None:
"""Create or delete the special repair issue.
Creates a repair issue when the special_repair lab feature is enabled,
and deletes it when disabled. This demonstrates how lab features can interact
with Home Assistant's repair system.
"""
if async_is_preview_feature_enabled(hass, DOMAIN, "special_repair"):
async_create_issue(
hass,
DOMAIN,
"kitchen_sink_special_repair_issue",
is_fixable=False,
severity=IssueSeverity.WARNING,
translation_key="special_repair",
)
else:
async_delete_issue(hass, DOMAIN, "kitchen_sink_special_repair_issue")
async def _notify_backup_listeners(hass: HomeAssistant) -> None:
for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []):
listener()

View File

@@ -5,6 +5,13 @@
"codeowners": ["@home-assistant/core"],
"documentation": "https://www.home-assistant.io/integrations/kitchen_sink",
"iot_class": "calculated",
"preview_features": {
"special_repair": {
"feedback_url": "https://community.home-assistant.io",
"learn_more_url": "https://www.home-assistant.io/integrations/kitchen_sink",
"report_issue_url": "https://github.com/home-assistant/core/issues/new?template=bug_report.yml&integration_link=https://www.home-assistant.io/integrations/kitchen_sink&integration_name=Kitchen%20Sink"
}
},
"quality_scale": "internal",
"single_config_entry": true
}

View File

@@ -71,6 +71,10 @@
},
"title": "The blinker fluid is empty and needs to be refilled"
},
"special_repair": {
"description": "This is a special repair created by a preview feature! This demonstrates how lab features can interact with the Home Assistant repair system. You can disable this by turning off the kitchen sink special repair feature in Settings > System > Labs.",
"title": "Special repair feature preview"
},
"transmogrifier_deprecated": {
"description": "The transmogrifier component is now deprecated due to the lack of local control available in the new API",
"title": "The transmogrifier component is deprecated"
@@ -103,6 +107,14 @@
}
}
},
"preview_features": {
"special_repair": {
"description": "Creates a **special repair issue** when enabled.\n\nThis demonstrates how lab features can interact with other Home Assistant integrations.",
"disable_confirmation": "This will remove the special repair issue. Don't worry, this is just a demonstration feature.",
"enable_confirmation": "This will create a special repair issue to demonstrate Labs preview features. This is just an example and won't affect your actual system.",
"name": "Special repair"
}
},
"services": {
"test_service_1": {
"description": "Fake action for testing",

View File

@@ -0,0 +1,310 @@
"""The Home Assistant Labs integration.
This integration provides preview features that can be toggled on/off by users.
Integrations can register lab preview features in their manifest.json which will appear
in the Home Assistant Labs UI for users to enable or disable.
"""
from __future__ import annotations
import logging
from typing import Any
import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.components.backup import async_get_manager
from homeassistant.core import HomeAssistant, callback
from homeassistant.generated.labs import LABS_PREVIEW_FEATURES
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.storage import Store
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import async_get_custom_components
from .const import (
DOMAIN,
EVENT_LABS_UPDATED,
LABS_DATA,
STORAGE_KEY,
STORAGE_VERSION,
EventLabsUpdatedData,
LabPreviewFeature,
LabsData,
LabsStoreData,
)
_LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
__all__ = [
"EVENT_LABS_UPDATED",
"EventLabsUpdatedData",
"async_is_preview_feature_enabled",
]
class LabsStorage(Store[LabsStoreData]):
"""Custom Store for Labs that converts between runtime and storage formats.
Runtime format: {"preview_feature_status": {(domain, preview_feature)}}
Storage format: {"preview_feature_status": [{"domain": str, "preview_feature": str}]}
Only enabled features are saved to storage - if stored, it's enabled.
"""
async def _async_load_data(self) -> LabsStoreData | None:
"""Load data and convert from storage format to runtime format."""
raw_data = await super()._async_load_data()
if raw_data is None:
return None
status_list = raw_data.get("preview_feature_status", [])
# Convert list of objects to runtime set - if stored, it's enabled
return {
"preview_feature_status": {
(item["domain"], item["preview_feature"]) for item in status_list
}
}
def _write_data(self, path: str, data: dict) -> None:
"""Convert from runtime format to storage format and write.
Only saves enabled features - disabled is the default.
"""
# Extract the actual data (has version/key wrapper)
actual_data = data.get("data", data)
# Check if this is Labs data (has preview_feature_status key)
if "preview_feature_status" not in actual_data:
# Not Labs data, write as-is
super()._write_data(path, data)
return
preview_status = actual_data["preview_feature_status"]
# Convert from runtime format (set of tuples) to storage format (list of dicts)
status_list = [
{"domain": domain, "preview_feature": preview_feature}
for domain, preview_feature in preview_status
]
# Build the final data structure with converted format
data_copy = data.copy()
data_copy["data"] = {"preview_feature_status": status_list}
super()._write_data(path, data_copy)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Labs component."""
store = LabsStorage(hass, STORAGE_VERSION, STORAGE_KEY, private=True)
data = await store.async_load()
if data is None:
data = {"preview_feature_status": set()}
# Scan ALL integrations for lab preview features (loaded or not)
lab_preview_features = await _async_scan_all_preview_features(hass)
# Clean up preview features that no longer exist
if lab_preview_features:
valid_keys = {
(pf.domain, pf.preview_feature) for pf in lab_preview_features.values()
}
stale_keys = data["preview_feature_status"] - valid_keys
if stale_keys:
_LOGGER.debug(
"Removing %d stale preview features: %s",
len(stale_keys),
stale_keys,
)
data["preview_feature_status"] -= stale_keys
await store.async_save(data)
hass.data[LABS_DATA] = LabsData(
store=store,
data=data,
preview_features=lab_preview_features,
)
websocket_api.async_register_command(hass, websocket_list_preview_features)
websocket_api.async_register_command(hass, websocket_update_preview_feature)
return True
def _populate_preview_features(
preview_features: dict[str, LabPreviewFeature],
domain: str,
labs_preview_features: dict[str, dict[str, str]],
is_built_in: bool = True,
) -> None:
"""Populate preview features dictionary from integration preview_features.
Args:
preview_features: Dictionary to populate
domain: Integration domain
labs_preview_features: Dictionary of preview feature definitions from manifest
is_built_in: Whether this is a built-in integration
"""
for preview_feature_key, preview_feature_data in labs_preview_features.items():
preview_feature = LabPreviewFeature(
domain=domain,
preview_feature=preview_feature_key,
is_built_in=is_built_in,
feedback_url=preview_feature_data.get("feedback_url"),
learn_more_url=preview_feature_data.get("learn_more_url"),
report_issue_url=preview_feature_data.get("report_issue_url"),
)
preview_features[preview_feature.full_key] = preview_feature
async def _async_scan_all_preview_features(
hass: HomeAssistant,
) -> dict[str, LabPreviewFeature]:
"""Scan ALL available integrations for lab preview features (loaded or not)."""
preview_features: dict[str, LabPreviewFeature] = {}
# Load pre-generated built-in lab preview features (already includes all data)
for domain, domain_preview_features in LABS_PREVIEW_FEATURES.items():
_populate_preview_features(
preview_features, domain, domain_preview_features, is_built_in=True
)
# Scan custom components
custom_integrations = await async_get_custom_components(hass)
_LOGGER.debug(
"Loaded %d built-in + scanning %d custom integrations for lab preview features",
len(preview_features),
len(custom_integrations),
)
for integration in custom_integrations.values():
if labs_preview_features := integration.preview_features:
_populate_preview_features(
preview_features,
integration.domain,
labs_preview_features,
is_built_in=False,
)
_LOGGER.debug("Loaded %d total lab preview features", len(preview_features))
return preview_features
@callback
def async_is_preview_feature_enabled(
hass: HomeAssistant, domain: str, preview_feature: str
) -> bool:
"""Check if a lab preview feature is enabled.
Args:
hass: HomeAssistant instance
domain: Integration domain
preview_feature: Preview feature name
Returns:
True if the preview feature is enabled, False otherwise
"""
if LABS_DATA not in hass.data:
return False
labs_data = hass.data[LABS_DATA]
return (domain, preview_feature) in labs_data.data["preview_feature_status"]
@callback
@websocket_api.require_admin
@websocket_api.websocket_command({vol.Required("type"): "labs/list"})
def websocket_list_preview_features(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""List all lab preview features filtered by loaded integrations."""
labs_data = hass.data[LABS_DATA]
loaded_components = hass.config.components
preview_features: list[dict[str, Any]] = [
preview_feature.to_dict(
(preview_feature.domain, preview_feature.preview_feature)
in labs_data.data["preview_feature_status"]
)
for preview_feature_key, preview_feature in labs_data.preview_features.items()
if preview_feature.domain in loaded_components
]
connection.send_result(msg["id"], {"features": preview_features})
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required("type"): "labs/update",
vol.Required("domain"): str,
vol.Required("preview_feature"): str,
vol.Required("enabled"): bool,
vol.Optional("create_backup", default=False): bool,
}
)
@websocket_api.async_response
async def websocket_update_preview_feature(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Update a lab preview feature state."""
domain = msg["domain"]
preview_feature = msg["preview_feature"]
enabled = msg["enabled"]
create_backup = msg["create_backup"]
labs_data = hass.data[LABS_DATA]
# Build preview_feature_id for lookup
preview_feature_id = f"{domain}.{preview_feature}"
# Validate preview feature exists
if preview_feature_id not in labs_data.preview_features:
connection.send_error(
msg["id"],
websocket_api.ERR_NOT_FOUND,
f"Preview feature {preview_feature_id} not found",
)
return
# Create backup if requested and enabling
if create_backup and enabled:
try:
backup_manager = async_get_manager(hass)
await backup_manager.async_create_automatic_backup()
except Exception as err: # noqa: BLE001 - websocket handlers can catch broad exceptions
connection.send_error(
msg["id"],
websocket_api.ERR_UNKNOWN_ERROR,
f"Error creating backup: {err}",
)
return
# Update storage (only store enabled features, remove if disabled)
if enabled:
labs_data.data["preview_feature_status"].add((domain, preview_feature))
else:
labs_data.data["preview_feature_status"].discard((domain, preview_feature))
# Save changes immediately
await labs_data.store.async_save(labs_data.data)
# Fire event
event_data: EventLabsUpdatedData = {
"domain": domain,
"preview_feature": preview_feature,
"enabled": enabled,
}
hass.bus.async_fire(EVENT_LABS_UPDATED, event_data)
connection.send_result(msg["id"])

View File

@@ -0,0 +1,77 @@
"""Constants for the Home Assistant Labs integration."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, TypedDict
from homeassistant.util.hass_dict import HassKey
if TYPE_CHECKING:
from homeassistant.helpers.storage import Store
DOMAIN = "labs"
STORAGE_KEY = "core.labs"
STORAGE_VERSION = 1
EVENT_LABS_UPDATED = "labs_updated"
class EventLabsUpdatedData(TypedDict):
"""Event data for labs_updated event."""
domain: str
preview_feature: str
enabled: bool
@dataclass(frozen=True, kw_only=True, slots=True)
class LabPreviewFeature:
"""Lab preview feature definition."""
domain: str
preview_feature: str
is_built_in: bool = True
feedback_url: str | None = None
learn_more_url: str | None = None
report_issue_url: str | None = None
@property
def full_key(self) -> str:
"""Return the full key for the preview feature (domain.preview_feature)."""
return f"{self.domain}.{self.preview_feature}"
def to_dict(self, enabled: bool) -> dict[str, str | bool | None]:
"""Return a serialized version of the preview feature.
Args:
enabled: Whether the preview feature is currently enabled
Returns:
Dictionary with preview feature data including enabled status
"""
return {
"preview_feature": self.preview_feature,
"domain": self.domain,
"enabled": enabled,
"is_built_in": self.is_built_in,
"feedback_url": self.feedback_url,
"learn_more_url": self.learn_more_url,
"report_issue_url": self.report_issue_url,
}
type LabsStoreData = dict[str, set[tuple[str, str]]]
@dataclass
class LabsData:
"""Storage class for Labs global data."""
store: Store[LabsStoreData]
data: LabsStoreData
preview_features: dict[str, LabPreviewFeature] = field(default_factory=dict)
LABS_DATA: HassKey[LabsData] = HassKey(DOMAIN)

View File

@@ -0,0 +1,9 @@
{
"domain": "labs",
"name": "Home Assistant Labs",
"codeowners": ["@home-assistant/core"],
"documentation": "https://www.home-assistant.io/integrations/labs",
"integration_type": "system",
"iot_class": "calculated",
"quality_scale": "internal"
}

View File

@@ -0,0 +1,3 @@
{
"title": "Home Assistant Labs"
}

View File

@@ -15,16 +15,20 @@ from pylamarzocco.const import FirmwareType
from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful
from pylamarzocco.util import InstallationKey, generate_installation_key
from homeassistant.components.bluetooth import async_discovered_service_info
from homeassistant.components.bluetooth import (
async_ble_device_from_address,
async_discovered_service_info,
)
from homeassistant.const import (
CONF_MAC,
CONF_PASSWORD,
CONF_TOKEN,
CONF_USERNAME,
EVENT_HOMEASSISTANT_STOP,
Platform,
__version__,
)
from homeassistant.core import HomeAssistant
from homeassistant.core import Event, HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.aiohttp_client import async_create_clientsession
@@ -99,7 +103,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) -
# initialize Bluetooth
bluetooth_client: LaMarzoccoBluetoothClient | None = None
if entry.options.get(CONF_USE_BLUETOOTH, True) and (
token := settings.ble_auth_token
token := (entry.data.get(CONF_TOKEN) or settings.ble_auth_token)
):
if CONF_MAC not in entry.data:
for discovery_info in async_discovered_service_info(hass):
@@ -108,7 +112,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) -
and name.startswith(BT_MODEL_PREFIXES)
and name.split("_")[1] == serial
):
_LOGGER.debug("Found Bluetooth device, configuring with Bluetooth")
_LOGGER.info("Found lamarzocco Bluetooth device, adding to entry")
# found a device, add MAC address to config entry
hass.config_entries.async_update_entry(
entry,
@@ -118,22 +122,29 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) -
},
)
if not entry.data[CONF_TOKEN]:
# update the token in the config entry
hass.config_entries.async_update_entry(
entry,
data={
**entry.data,
CONF_TOKEN: token,
},
)
if CONF_MAC in entry.data:
_LOGGER.debug("Initializing Bluetooth device")
bluetooth_client = LaMarzoccoBluetoothClient(
address_or_ble_device=entry.data[CONF_MAC],
ble_token=token,
)
ble_device = async_ble_device_from_address(hass, entry.data[CONF_MAC])
if ble_device:
_LOGGER.info("Setting up lamarzocco with Bluetooth")
bluetooth_client = LaMarzoccoBluetoothClient(
ble_device=ble_device,
ble_token=token,
)
async def disconnect_bluetooth(_: Event) -> None:
"""Stop push updates when hass stops."""
await bluetooth_client.disconnect()
entry.async_on_unload(
hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_STOP, disconnect_bluetooth
)
)
entry.async_on_unload(bluetooth_client.disconnect)
else:
_LOGGER.info(
"Bluetooth device not found during lamarzocco setup, continuing with cloud only"
)
device = LaMarzoccoMachine(
serial_number=entry.unique_id,

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
from abc import abstractmethod
from asyncio import Task
from dataclasses import dataclass
from datetime import timedelta
import logging
@@ -44,7 +45,7 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]):
_default_update_interval = SCAN_INTERVAL
config_entry: LaMarzoccoConfigEntry
websocket_terminated = True
_websocket_task: Task | None = None
def __init__(
self,
@@ -64,6 +65,13 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]):
self.device = device
self.cloud_client = cloud_client
@property
def websocket_terminated(self) -> bool:
"""Return True if the websocket task is terminated or not running."""
if self._websocket_task is None:
return True
return self._websocket_task.done()
async def _async_update_data(self) -> None:
"""Do the data update."""
try:
@@ -95,13 +103,14 @@ class LaMarzoccoConfigUpdateCoordinator(LaMarzoccoUpdateCoordinator):
# ensure token stays valid; does nothing if token is still valid
await self.cloud_client.async_get_access_token()
if self.device.websocket.connected:
# Only skip websocket reconnection if it's currently connected and the task is still running
if self.device.websocket.connected and not self.websocket_terminated:
return
await self.device.get_dashboard()
_LOGGER.debug("Current status: %s", self.device.dashboard.to_dict())
self.config_entry.async_create_background_task(
self._websocket_task = self.config_entry.async_create_background_task(
hass=self.hass,
target=self.connect_websocket(),
name="lm_websocket_task",
@@ -120,7 +129,6 @@ class LaMarzoccoConfigUpdateCoordinator(LaMarzoccoUpdateCoordinator):
_LOGGER.debug("Init WebSocket in background task")
self.websocket_terminated = False
self.async_update_listeners()
await self.device.connect_dashboard_websocket(
@@ -129,7 +137,6 @@ class LaMarzoccoConfigUpdateCoordinator(LaMarzoccoUpdateCoordinator):
disconnect_callback=self.async_update_listeners,
)
self.websocket_terminated = True
self.async_update_listeners()

View File

@@ -37,5 +37,5 @@
"iot_class": "cloud_push",
"loggers": ["pylamarzocco"],
"quality_scale": "platinum",
"requirements": ["pylamarzocco==2.1.3"]
"requirements": ["pylamarzocco==2.2.0"]
}

View File

@@ -14,19 +14,5 @@
"start_mowing": {
"service": "mdi:play"
}
},
"triggers": {
"docked": {
"trigger": "mdi:home-import-outline"
},
"errored": {
"trigger": "mdi:alert-circle-outline"
},
"paused_mowing": {
"trigger": "mdi:pause"
},
"started_mowing": {
"trigger": "mdi:play"
}
}
}

View File

@@ -1,8 +1,4 @@
{
"common": {
"trigger_behavior_description": "The behavior of the targeted lawn mowers to trigger on.",
"trigger_behavior_name": "Behavior"
},
"entity_component": {
"_": {
"name": "[%key:component::lawn_mower::title%]",
@@ -15,15 +11,6 @@
}
}
},
"selector": {
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
}
},
"services": {
"dock": {
"description": "Stops the mowing task and returns to the dock.",
@@ -38,51 +25,5 @@
"name": "Start mowing"
}
},
"title": "Lawn mower",
"triggers": {
"docked": {
"description": "Triggers when a lawn mower has docked.",
"description_configured": "[%key:component::lawn_mower::triggers::docked::description%]",
"fields": {
"behavior": {
"description": "[%key:component::lawn_mower::common::trigger_behavior_description%]",
"name": "[%key:component::lawn_mower::common::trigger_behavior_name%]"
}
},
"name": "When a lawn mower has docked"
},
"errored": {
"description": "Triggers when a lawn mower has errored.",
"description_configured": "[%key:component::lawn_mower::triggers::errored::description%]",
"fields": {
"behavior": {
"description": "[%key:component::lawn_mower::common::trigger_behavior_description%]",
"name": "[%key:component::lawn_mower::common::trigger_behavior_name%]"
}
},
"name": "When a lawn mower has errored"
},
"paused_mowing": {
"description": "Triggers when a lawn mower has paused mowing.",
"description_configured": "[%key:component::lawn_mower::triggers::paused_mowing::description%]",
"fields": {
"behavior": {
"description": "[%key:component::lawn_mower::common::trigger_behavior_description%]",
"name": "[%key:component::lawn_mower::common::trigger_behavior_name%]"
}
},
"name": "When a lawn mower has paused mowing"
},
"started_mowing": {
"description": "Triggers when a lawn mower has started mowing.",
"description_configured": "[%key:component::lawn_mower::triggers::started_mowing::description%]",
"fields": {
"behavior": {
"description": "[%key:component::lawn_mower::common::trigger_behavior_description%]",
"name": "[%key:component::lawn_mower::common::trigger_behavior_name%]"
}
},
"name": "When a lawn mower has started mowing"
}
}
"title": "Lawn mower"
}

View File

@@ -1,18 +0,0 @@
"""Provides triggers for lawn mowers."""
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import Trigger, make_entity_state_trigger
from .const import DOMAIN, LawnMowerActivity
TRIGGERS: dict[str, type[Trigger]] = {
"docked": make_entity_state_trigger(DOMAIN, LawnMowerActivity.DOCKED),
"errored": make_entity_state_trigger(DOMAIN, LawnMowerActivity.ERROR),
"paused_mowing": make_entity_state_trigger(DOMAIN, LawnMowerActivity.PAUSED),
"started_mowing": make_entity_state_trigger(DOMAIN, LawnMowerActivity.MOWING),
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for lawn mowers."""
return TRIGGERS

View File

@@ -1,20 +0,0 @@
.trigger_common: &trigger_common
target:
entity:
domain: lawn_mower
fields:
behavior:
required: true
default: any
selector:
select:
options:
- first
- last
- any
translation_key: trigger_behavior
docked: *trigger_common
errored: *trigger_common
paused_mowing: *trigger_common
started_mowing: *trigger_common

View File

@@ -9,5 +9,5 @@
"iot_class": "local_polling",
"loggers": ["pypck"],
"quality_scale": "bronze",
"requirements": ["pypck==0.9.4", "lcn-frontend==0.2.7"]
"requirements": ["pypck==0.9.5", "lcn-frontend==0.2.7"]
}

View File

@@ -1,131 +0,0 @@
"""Provides conditions for lights."""
from collections.abc import Callable
from typing import TYPE_CHECKING, Any, Final, override
import voluptuous as vol
from homeassistant.const import CONF_OPTIONS, CONF_TARGET, STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant, split_entity_id
from homeassistant.helpers import config_validation as cv, target
from homeassistant.helpers.condition import (
Condition,
ConditionCheckerType,
ConditionConfig,
trace_condition_function,
)
from homeassistant.helpers.typing import ConfigType, TemplateVarsType
from .const import DOMAIN
ATTR_BEHAVIOR: Final = "behavior"
BEHAVIOR_ANY: Final = "any"
BEHAVIOR_ALL: Final = "all"
STATE_CONDITION_VALID_STATES: Final = [STATE_ON, STATE_OFF]
STATE_CONDITION_OPTIONS_SCHEMA: dict[vol.Marker, Any] = {
vol.Required(ATTR_BEHAVIOR, default=BEHAVIOR_ANY): vol.In(
[BEHAVIOR_ANY, BEHAVIOR_ALL]
),
}
STATE_CONDITION_SCHEMA = vol.Schema(
{
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
vol.Required(CONF_OPTIONS): STATE_CONDITION_OPTIONS_SCHEMA,
}
)
class StateConditionBase(Condition):
"""State condition."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return STATE_CONDITION_SCHEMA(config) # type: ignore[no-any-return]
def __init__(
self, hass: HomeAssistant, config: ConditionConfig, state: str
) -> None:
"""Initialize condition."""
self._hass = hass
if TYPE_CHECKING:
assert config.target
assert config.options
self._target = config.target
self._behavior = config.options[ATTR_BEHAVIOR]
self._state = state
@override
async def async_get_checker(self) -> ConditionCheckerType:
"""Get the condition checker."""
def check_any_match_state(states: list[str]) -> bool:
"""Test if any entity match the state."""
return any(state == self._state for state in states)
def check_all_match_state(states: list[str]) -> bool:
"""Test if all entities match the state."""
return all(state == self._state for state in states)
matcher: Callable[[list[str]], bool]
if self._behavior == BEHAVIOR_ANY:
matcher = check_any_match_state
elif self._behavior == BEHAVIOR_ALL:
matcher = check_all_match_state
@trace_condition_function
def test_state(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool:
"""Test state condition."""
selector_data = target.TargetSelectorData(self._target)
targeted_entities = target.async_extract_referenced_entity_ids(
hass, selector_data, expand_group=False
)
referenced_entity_ids = targeted_entities.referenced.union(
targeted_entities.indirectly_referenced
)
light_entity_ids = {
entity_id
for entity_id in referenced_entity_ids
if split_entity_id(entity_id)[0] == DOMAIN
}
light_entity_states = [
state.state
for entity_id in light_entity_ids
if (state := hass.states.get(entity_id))
and state.state in STATE_CONDITION_VALID_STATES
]
return matcher(light_entity_states)
return test_state
class IsOnCondition(StateConditionBase):
"""Is on condition."""
def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None:
"""Initialize condition."""
super().__init__(hass, config, STATE_ON)
class IsOffCondition(StateConditionBase):
"""Is off condition."""
def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None:
"""Initialize condition."""
super().__init__(hass, config, STATE_OFF)
CONDITIONS: dict[str, type[Condition]] = {
"is_off": IsOffCondition,
"is_on": IsOnCondition,
}
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
"""Return the light conditions."""
return CONDITIONS

View File

@@ -1,28 +0,0 @@
is_off:
target:
entity:
domain: light
fields:
behavior:
required: true
default: any
selector:
select:
translation_key: condition_behavior
options:
- all
- any
is_on:
target:
entity:
domain: light
fields:
behavior:
required: true
default: any
selector:
select:
translation_key: condition_behavior
options:
- all
- any

View File

@@ -1,12 +1,4 @@
{
"conditions": {
"is_off": {
"condition": "mdi:lightbulb-off"
},
"is_on": {
"condition": "mdi:lightbulb-on"
}
},
"entity_component": {
"_": {
"default": "mdi:lightbulb",
@@ -33,13 +25,5 @@
"turn_on": {
"service": "mdi:lightbulb-on"
}
},
"triggers": {
"turned_off": {
"trigger": "mdi:lightbulb-off"
},
"turned_on": {
"trigger": "mdi:lightbulb-on"
}
}
}

View File

@@ -1,7 +1,5 @@
{
"common": {
"condition_behavior_description": "How the state should match on the targeted lights.",
"condition_behavior_name": "Behavior",
"field_brightness_description": "Number indicating brightness, where 0 turns the light off, 1 is the minimum brightness, and 255 is the maximum brightness.",
"field_brightness_name": "Brightness value",
"field_brightness_pct_description": "Number indicating the percentage of full brightness, where 0 turns the light off, 1 is the minimum brightness, and 100 is the maximum brightness.",
@@ -36,33 +34,7 @@
"field_white_name": "White",
"field_xy_color_description": "Color in XY-format. A list of two decimal numbers between 0 and 1.",
"field_xy_color_name": "XY-color",
"section_advanced_fields_name": "Advanced options",
"trigger_behavior_description": "The behavior of the targeted lights to trigger on.",
"trigger_behavior_name": "Behavior"
},
"conditions": {
"is_off": {
"description": "Test if a light is off.",
"description_configured": "[%key:component::light::conditions::is_off::description%]",
"fields": {
"behavior": {
"description": "[%key:component::light::common::condition_behavior_description%]",
"name": "[%key:component::light::common::condition_behavior_name%]"
}
},
"name": "If a light is off"
},
"is_on": {
"description": "Test if a light is on.",
"description_configured": "[%key:component::light::conditions::is_on::description%]",
"fields": {
"behavior": {
"description": "[%key:component::light::common::condition_behavior_description%]",
"name": "[%key:component::light::common::condition_behavior_name%]"
}
},
"name": "If a light is on"
}
"section_advanced_fields_name": "Advanced options"
},
"device_automation": {
"action_type": {
@@ -312,30 +284,11 @@
"yellowgreen": "Yellow green"
}
},
"condition_behavior": {
"options": {
"all": "All",
"any": "Any"
}
},
"flash": {
"options": {
"long": "Long",
"short": "Short"
}
},
"state": {
"options": {
"off": "[%key:common::state::off%]",
"on": "[%key:common::state::on%]"
}
},
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
}
},
"services": {
@@ -509,29 +462,5 @@
}
}
},
"title": "Light",
"triggers": {
"turned_off": {
"description": "Triggers when a light is turned off.",
"description_configured": "[%key:component::light::triggers::turned_off::description%]",
"fields": {
"behavior": {
"description": "[%key:component::light::common::trigger_behavior_description%]",
"name": "[%key:component::light::common::trigger_behavior_name%]"
}
},
"name": "When a light is turned off"
},
"turned_on": {
"description": "Triggers when a light is turned on.",
"description_configured": "[%key:component::light::triggers::turned_on::description%]",
"fields": {
"behavior": {
"description": "[%key:component::light::common::trigger_behavior_description%]",
"name": "[%key:component::light::common::trigger_behavior_name%]"
}
},
"name": "When a light is turned on"
}
}
"title": "Light"
}

View File

@@ -1,17 +0,0 @@
"""Provides triggers for lights."""
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import Trigger, make_entity_state_trigger
from .const import DOMAIN
TRIGGERS: dict[str, type[Trigger]] = {
"turned_off": make_entity_state_trigger(DOMAIN, STATE_OFF),
"turned_on": make_entity_state_trigger(DOMAIN, STATE_ON),
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for lights."""
return TRIGGERS

View File

@@ -1,18 +0,0 @@
.trigger_common: &trigger_common
target:
entity:
domain: light
fields:
behavior:
required: true
default: any
selector:
select:
options:
- first
- last
- any
translation_key: trigger_behavior
turned_on: *trigger_common
turned_off: *trigger_common

View File

@@ -9,7 +9,7 @@
},
"iot_class": "local_push",
"loggers": ["pylutron_caseta"],
"requirements": ["pylutron-caseta==0.25.0"],
"requirements": ["pylutron-caseta==0.26.0"],
"zeroconf": [
{
"properties": {

View File

@@ -58,7 +58,7 @@ DISCOVERY_SCHEMAS = [
platform=Platform.BUTTON,
entity_description=MatterButtonEntityDescription(
key="IdentifyButton",
entity_category=EntityCategory.CONFIG,
entity_category=EntityCategory.DIAGNOSTIC,
device_class=ButtonDeviceClass.IDENTIFY,
command=lambda: clusters.Identify.Commands.Identify(identifyTime=15),
),

View File

@@ -104,10 +104,5 @@
"volume_up": {
"service": "mdi:volume-plus"
}
},
"triggers": {
"stopped_playing": {
"trigger": "mdi:stop"
}
}
}

View File

@@ -1,8 +1,4 @@
{
"common": {
"trigger_behavior_description": "The behavior of the targeted media players to trigger on.",
"trigger_behavior_name": "Behavior"
},
"device_automation": {
"condition_type": {
"is_buffering": "{entity_name} is buffering",
@@ -181,13 +177,6 @@
"off": "[%key:common::state::off%]",
"one": "Repeat one"
}
},
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
}
},
"services": {
@@ -378,18 +367,5 @@
"name": "Turn up volume"
}
},
"title": "Media player",
"triggers": {
"stopped_playing": {
"description": "Triggers when a media player stops playing.",
"description_configured": "[%key:component::media_player::triggers::stopped_playing::description%]",
"fields": {
"behavior": {
"description": "[%key:component::media_player::common::trigger_behavior_description%]",
"name": "[%key:component::media_player::common::trigger_behavior_name%]"
}
},
"name": "When a media player stops playing"
}
}
"title": "Media player"
}

View File

@@ -1,28 +0,0 @@
"""Provides triggers for media players."""
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import Trigger, make_conditional_entity_state_trigger
from . import MediaPlayerState
from .const import DOMAIN
TRIGGERS: dict[str, type[Trigger]] = {
"stopped_playing": make_conditional_entity_state_trigger(
DOMAIN,
from_states={
MediaPlayerState.BUFFERING,
MediaPlayerState.PAUSED,
MediaPlayerState.PLAYING,
},
to_states={
MediaPlayerState.IDLE,
MediaPlayerState.OFF,
MediaPlayerState.ON,
},
),
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for media players."""
return TRIGGERS

View File

@@ -1,15 +0,0 @@
stopped_playing:
target:
entity:
domain: media_player
fields:
behavior:
required: true
default: any
selector:
select:
translation_key: trigger_behavior
options:
- first
- last
- any

File diff suppressed because it is too large Load Diff

View File

@@ -36,8 +36,8 @@ from .const import (
COFFEE_SYSTEM_PROFILE,
DISABLED_TEMP_ENTITIES,
DOMAIN,
PROGRAM_IDS,
PROGRAM_PHASE,
STATE_PROGRAM_ID,
STATE_STATUS_TAGS,
MieleAppliance,
PlatePowerStep,
@@ -979,21 +979,16 @@ class MieleProgramIdSensor(MieleSensor):
@property
def native_value(self) -> StateType:
"""Return the state of the sensor."""
ret_val = STATE_PROGRAM_ID.get(self.device.device_type, {}).get(
self.device.state_program_id
return (
PROGRAM_IDS[self.device.device_type](self.device.state_program_id).name
if self.device.device_type in PROGRAM_IDS
else None
)
if ret_val is None:
_LOGGER.debug(
"Unknown program id: %s on device type: %s",
self.device.state_program_id,
self.device.device_type,
)
return ret_val
@property
def options(self) -> list[str]:
"""Return the options list for the actual device type."""
return sorted(set(STATE_PROGRAM_ID.get(self.device.device_type, {}).values()))
return sorted(PROGRAM_IDS.get(self.device.device_type, {}).keys())
class MieleTimeSensor(MieleRestorableSensor):

View File

@@ -430,7 +430,7 @@
"custom_program_9": "Custom program 9",
"dark_garments": "Dark garments",
"dark_mixed_grain_bread": "Dark mixed grain bread",
"decrystallise_honey": "Decrystallise honey",
"decrystallise_honey": "Decrystallize honey",
"defrost": "Defrost",
"defrosting_with_microwave": "Defrosting with microwave",
"defrosting_with_steam": "Defrosting with steam",

View File

@@ -131,12 +131,41 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
registration_name = f"Mobile App: {registration[ATTR_DEVICE_NAME]}"
webhook_register(hass, DOMAIN, registration_name, webhook_id, handle_webhook)
def clean_cloudhook() -> None:
"""Clean up cloudhook from config entry."""
if CONF_CLOUDHOOK_URL in entry.data:
data = dict(entry.data)
data.pop(CONF_CLOUDHOOK_URL)
hass.config_entries.async_update_entry(entry, data=data)
def on_cloudhook_change(cloudhook: dict[str, Any] | None) -> None:
"""Handle cloudhook changes."""
if cloudhook:
if entry.data.get(CONF_CLOUDHOOK_URL) == cloudhook[CONF_CLOUDHOOK_URL]:
return
hass.config_entries.async_update_entry(
entry,
data={**entry.data, CONF_CLOUDHOOK_URL: cloudhook[CONF_CLOUDHOOK_URL]},
)
else:
clean_cloudhook()
async def manage_cloudhook(state: cloud.CloudConnectionState) -> None:
if (
state is cloud.CloudConnectionState.CLOUD_CONNECTED
and CONF_CLOUDHOOK_URL not in entry.data
):
await async_create_cloud_hook(hass, webhook_id, entry)
elif (
state is cloud.CloudConnectionState.CLOUD_DISCONNECTED
and not cloud.async_is_logged_in(hass)
):
clean_cloudhook()
entry.async_on_unload(
cloud.async_listen_cloudhook_change(hass, webhook_id, on_cloudhook_change)
)
if cloud.async_is_logged_in(hass):
if (
@@ -147,9 +176,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await async_create_cloud_hook(hass, webhook_id, entry)
elif CONF_CLOUDHOOK_URL in entry.data:
# If we have a cloudhook but no longer logged in to the cloud, remove it from the entry
data = dict(entry.data)
data.pop(CONF_CLOUDHOOK_URL)
hass.config_entries.async_update_entry(entry, data=data)
clean_cloudhook()
entry.async_on_unload(cloud.async_listen_connection_change(hass, manage_cloudhook))

View File

@@ -756,10 +756,9 @@ async def webhook_get_config(
"theme_color": MANIFEST_JSON["theme_color"],
}
if CONF_CLOUDHOOK_URL in config_entry.data:
resp[CONF_CLOUDHOOK_URL] = config_entry.data[CONF_CLOUDHOOK_URL]
if cloud.async_active_subscription(hass):
if CONF_CLOUDHOOK_URL in config_entry.data:
resp[CONF_CLOUDHOOK_URL] = config_entry.data[CONF_CLOUDHOOK_URL]
with suppress(cloud.CloudNotAvailable):
resp[CONF_REMOTE_UI_URL] = cloud.async_remote_ui_url(hass)

View File

@@ -239,6 +239,7 @@ from .const import (
CONF_OSCILLATION_COMMAND_TOPIC,
CONF_OSCILLATION_STATE_TOPIC,
CONF_OSCILLATION_VALUE_TEMPLATE,
CONF_PATTERN,
CONF_PAYLOAD_ARM_AWAY,
CONF_PAYLOAD_ARM_CUSTOM_BYPASS,
CONF_PAYLOAD_ARM_HOME,
@@ -465,6 +466,7 @@ SUBENTRY_PLATFORMS = [
Platform.SENSOR,
Platform.SIREN,
Platform.SWITCH,
Platform.TEXT,
]
_CODE_VALIDATION_MODE = {
@@ -819,6 +821,16 @@ TEMPERATURE_UNIT_SELECTOR = SelectSelector(
mode=SelectSelectorMode.DROPDOWN,
)
)
TEXT_MODE_SELECTOR = SelectSelector(
SelectSelectorConfig(
options=[TextSelectorType.TEXT.value, TextSelectorType.PASSWORD.value],
mode=SelectSelectorMode.DROPDOWN,
translation_key="text_mode",
)
)
TEXT_SIZE_SELECTOR = NumberSelector(
NumberSelectorConfig(min=0, max=255, step=1, mode=NumberSelectorMode.BOX)
)
@callback
@@ -1151,6 +1163,22 @@ def validate_sensor_platform_config(
return errors
@callback
def validate_text_platform_config(
config: dict[str, Any],
) -> dict[str, str]:
"""Validate the text entity options."""
errors: dict[str, str] = {}
if (
CONF_MIN in config
and CONF_MAX in config
and config[CONF_MIN] > config[CONF_MAX]
):
errors["text_advanced_settings"] = "max_below_min"
return errors
ENTITY_CONFIG_VALIDATOR: dict[
str,
Callable[[dict[str, Any]], dict[str, str]] | None,
@@ -1170,6 +1198,7 @@ ENTITY_CONFIG_VALIDATOR: dict[
Platform.SENSOR: validate_sensor_platform_config,
Platform.SIREN: None,
Platform.SWITCH: None,
Platform.TEXT: validate_text_platform_config,
}
@@ -1430,6 +1459,7 @@ PLATFORM_ENTITY_FIELDS: dict[Platform, dict[str, PlatformField]] = {
selector=SWITCH_DEVICE_CLASS_SELECTOR, required=False
),
},
Platform.TEXT: {},
}
PLATFORM_MQTT_FIELDS: dict[Platform, dict[str, PlatformField]] = {
Platform.ALARM_CONTROL_PANEL: {
@@ -3298,6 +3328,58 @@ PLATFORM_MQTT_FIELDS: dict[Platform, dict[str, PlatformField]] = {
CONF_RETAIN: PlatformField(selector=BOOLEAN_SELECTOR, required=False),
CONF_OPTIMISTIC: PlatformField(selector=BOOLEAN_SELECTOR, required=False),
},
Platform.TEXT: {
CONF_COMMAND_TOPIC: PlatformField(
selector=TEXT_SELECTOR,
required=True,
validator=valid_publish_topic,
error="invalid_publish_topic",
),
CONF_COMMAND_TEMPLATE: PlatformField(
selector=TEMPLATE_SELECTOR,
required=False,
validator=validate(cv.template),
error="invalid_template",
),
CONF_STATE_TOPIC: PlatformField(
selector=TEXT_SELECTOR,
required=False,
validator=valid_subscribe_topic,
error="invalid_subscribe_topic",
),
CONF_VALUE_TEMPLATE: PlatformField(
selector=TEMPLATE_SELECTOR,
required=False,
validator=validate(cv.template),
error="invalid_template",
),
CONF_RETAIN: PlatformField(selector=BOOLEAN_SELECTOR, required=False),
CONF_MIN: PlatformField(
selector=TEXT_SIZE_SELECTOR,
required=True,
default=0,
section="text_advanced_settings",
),
CONF_MAX: PlatformField(
selector=TEXT_SIZE_SELECTOR,
required=True,
default=255,
section="text_advanced_settings",
),
CONF_MODE: PlatformField(
selector=TEXT_MODE_SELECTOR,
required=True,
default=TextSelectorType.TEXT.value,
section="text_advanced_settings",
),
CONF_PATTERN: PlatformField(
selector=TEXT_SELECTOR,
required=False,
validator=validate(cv.is_regex),
error="invalid_regular_expression",
section="text_advanced_settings",
),
},
}
MQTT_DEVICE_PLATFORM_FIELDS = {
ATTR_NAME: PlatformField(selector=TEXT_SELECTOR, required=True),

View File

@@ -138,6 +138,7 @@ CONF_OSCILLATION_COMMAND_TOPIC = "oscillation_command_topic"
CONF_OSCILLATION_COMMAND_TEMPLATE = "oscillation_command_template"
CONF_OSCILLATION_STATE_TOPIC = "oscillation_state_topic"
CONF_OSCILLATION_VALUE_TEMPLATE = "oscillation_value_template"
CONF_PATTERN = "pattern"
CONF_PAYLOAD_ARM_AWAY = "payload_arm_away"
CONF_PAYLOAD_ARM_CUSTOM_BYPASS = "payload_arm_custom_bypass"
CONF_PAYLOAD_ARM_HOME = "payload_arm_home"

View File

@@ -970,6 +970,21 @@
"temperature_state_topic": "The MQTT topic to subscribe for changes of the target temperature. [Learn more.]({url}#temperature_state_topic)"
},
"name": "Target temperature settings"
},
"text_advanced_settings": {
"data": {
"max": "Maximum length",
"min": "Mininum length",
"mode": "Mode",
"pattern": "Pattern"
},
"data_description": {
"max": "Maximum length of the text input",
"min": "Mininum length of the text input",
"mode": "Mode of the text input",
"pattern": "A valid regex pattern"
},
"name": "Advanced text settings"
}
},
"title": "Configure MQTT device \"{mqtt_device}\""
@@ -1387,7 +1402,8 @@
"select": "[%key:component::select::title%]",
"sensor": "[%key:component::sensor::title%]",
"siren": "[%key:component::siren::title%]",
"switch": "[%key:component::switch::title%]"
"switch": "[%key:component::switch::title%]",
"text": "[%key:component::text::title%]"
}
},
"set_ca_cert": {
@@ -1424,6 +1440,12 @@
"none": "No target temperature",
"single": "Single target temperature"
}
},
"text_mode": {
"options": {
"password": "[%key:common::config_flow::data::password%]",
"text": "[%key:component::text::entity_component::_::state_attributes::mode::state::text%]"
}
}
},
"services": {

View File

@@ -27,7 +27,14 @@ from homeassistant.helpers.typing import ConfigType, VolSchemaType
from . import subscription
from .config import MQTT_RW_SCHEMA
from .const import CONF_COMMAND_TEMPLATE, CONF_COMMAND_TOPIC, CONF_STATE_TOPIC
from .const import (
CONF_COMMAND_TEMPLATE,
CONF_COMMAND_TOPIC,
CONF_MAX,
CONF_MIN,
CONF_PATTERN,
CONF_STATE_TOPIC,
)
from .entity import MqttEntity, async_setup_entity_entry_helper
from .models import (
MqttCommandTemplate,
@@ -42,12 +49,7 @@ _LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
CONF_MAX = "max"
CONF_MIN = "min"
CONF_PATTERN = "pattern"
DEFAULT_NAME = "MQTT Text"
DEFAULT_PAYLOAD_RESET = "None"
MQTT_TEXT_ATTRIBUTES_BLOCKED = frozenset(
{

View File

@@ -28,9 +28,13 @@ async def test_connection(host: str) -> str | None:
controller = NHCController(host, 8000)
try:
await controller.connect()
except Exception:
_LOGGER.exception("Unexpected exception")
except TimeoutError:
return "timeout_connect"
except OSError:
return "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception during connection")
return "unknown"
return None

View File

@@ -5,7 +5,9 @@
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"timeout_connect": "[%key:common::config_flow::error::timeout_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"reconfigure": {

View File

@@ -10,5 +10,5 @@
"iot_class": "cloud_polling",
"loggers": ["onedrive_personal_sdk"],
"quality_scale": "platinum",
"requirements": ["onedrive-personal-sdk==0.0.16"]
"requirements": ["onedrive-personal-sdk==0.0.17"]
}

View File

@@ -7,7 +7,11 @@ from collections.abc import AsyncIterator, Mapping
from contextlib import asynccontextmanager
from typing import TYPE_CHECKING, Any, Final
from aioshelly.ble.manufacturer_data import has_rpc_over_ble
from aioshelly.ble import get_name_from_model_id
from aioshelly.ble.manufacturer_data import (
has_rpc_over_ble,
parse_shelly_manufacturer_data,
)
from aioshelly.ble.provisioning import async_provision_wifi, async_scan_wifi_networks
from aioshelly.block_device import BlockDevice
from aioshelly.common import ConnectionOptions, get_info
@@ -358,8 +362,35 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
self, discovery_info: BluetoothServiceInfoBleak
) -> ConfigFlowResult:
"""Handle bluetooth discovery."""
# Parse MAC address from the Bluetooth device name
if not (mac := mac_address_from_name(discovery_info.name)):
# Try to parse MAC address from the Bluetooth device name
# If not found, try to get it from manufacturer data
device_name = discovery_info.name
if (
not (mac := mac_address_from_name(device_name))
and (
parsed := parse_shelly_manufacturer_data(
discovery_info.manufacturer_data
)
)
and (mac_with_colons := parsed.get("mac"))
and isinstance(mac_with_colons, str)
):
# parse_shelly_manufacturer_data returns MAC with colons (e.g., "CC:BA:97:C2:D6:72")
# Convert to format without colons to match mac_address_from_name output
mac = mac_with_colons.replace(":", "")
# For devices without a Shelly name, use model name from model ID if available
# Gen3/4 devices advertise MAC address as name instead of "ShellyXXX-MACADDR"
if (
(model_id := parsed.get("model_id"))
and isinstance(model_id, int)
and (model_name := get_name_from_model_id(model_id))
):
# Remove spaces from model name (e.g., "Shelly 1 Mini Gen4" -> "Shelly1MiniGen4")
device_name = f"{model_name.replace(' ', '')}-{mac}"
else:
device_name = f"Shelly-{mac}"
if not mac:
return self.async_abort(reason="invalid_discovery_info")
# Check if RPC-over-BLE is enabled - required for WiFi provisioning
@@ -381,10 +412,10 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
if not self.ble_device:
return self.async_abort(reason="cannot_connect")
self.device_name = discovery_info.name
self.device_name = device_name
self.context.update(
{
"title_placeholders": {"name": discovery_info.name},
"title_placeholders": {"name": device_name},
}
)

View File

@@ -32,11 +32,16 @@ from .utils import (
async_remove_shelly_entity,
get_block_channel,
get_block_custom_name,
get_block_number_of_channels,
get_device_entry_gen,
get_rpc_component_name,
get_rpc_custom_name,
get_rpc_entity_name,
get_rpc_key,
get_rpc_key_id,
get_rpc_key_instances,
get_rpc_number_of_channels,
is_block_momentary_input,
is_block_single_device,
is_rpc_momentary_input,
)
@@ -158,8 +163,7 @@ def _async_setup_rpc_entry(
if script_name == BLE_SCRIPT_NAME:
continue
script_id = int(script.split(":")[-1])
if script_events and (event_types := script_events[script_id]):
if script_events and (event_types := script_events[get_rpc_key_id(script)]):
entities.append(ShellyRpcScriptEvent(coordinator, script, event_types))
# If a script is removed, from the device configuration, we need to remove orphaned entities
@@ -197,13 +201,15 @@ class ShellyBlockEvent(ShellyBlockEntity, EventEntity):
self._attr_event_types = list(BASIC_INPUTS_EVENTS_TYPES)
self.entity_description = description
if (
hasattr(self, "_attr_name")
and self._attr_name
and not get_block_custom_name(coordinator.device, block)
if hasattr(self, "_attr_name") and not (
(single := is_block_single_device(coordinator.device, block))
and get_block_custom_name(coordinator.device, block)
):
self._attr_translation_placeholders = {
"input_number": get_block_channel(block)
if single
and get_block_number_of_channels(coordinator.device, block) > 1
else ""
}
delattr(self, "_attr_name")
@@ -237,22 +243,24 @@ class ShellyRpcEvent(CoordinatorEntity[ShellyRpcCoordinator], EventEntity):
) -> None:
"""Initialize Shelly entity."""
super().__init__(coordinator)
self.event_id = int(key.split(":")[-1])
self._attr_device_info = get_entity_rpc_device_info(coordinator, key)
self._attr_unique_id = f"{coordinator.mac}-{key}"
self.entity_description = description
if description.key == "input":
component = key.split(":")[0]
component_id = key.split(":")[-1]
if not get_rpc_component_name(coordinator.device, key) and (
component.lower() == "input" and component_id.isnumeric()
):
self._attr_translation_placeholders = {"input_number": component_id}
_, component, component_id = get_rpc_key(key)
if not get_rpc_custom_name(coordinator.device, key):
self._attr_translation_placeholders = {
"input_number": component_id
if get_rpc_number_of_channels(coordinator.device, component) > 1
else ""
}
else:
self._attr_name = get_rpc_entity_name(coordinator.device, key)
self.event_id = int(component_id)
elif description.key == "script":
self._attr_name = get_rpc_entity_name(coordinator.device, key)
self.event_id = get_rpc_key_id(key)
async def async_added_to_hass(self) -> None:
"""When entity is added to hass."""

View File

@@ -4,6 +4,9 @@
"bluetooth": [
{
"local_name": "Shelly*"
},
{
"manufacturer_id": 2985
}
],
"codeowners": ["@bieniu", "@thecode", "@chemelli74", "@bdraco"],
@@ -13,8 +16,8 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["aioshelly"],
"quality_scale": "silver",
"requirements": ["aioshelly==13.17.0"],
"quality_scale": "platinum",
"requirements": ["aioshelly==13.20.0"],
"zeroconf": [
{
"name": "shelly*",

View File

@@ -55,7 +55,7 @@ rules:
entity-category: done
entity-device-class: done
entity-disabled-by-default: done
entity-translations: todo
entity-translations: done
exception-translations: done
icon-translations: done
reconfiguration-flow: done

View File

@@ -93,8 +93,8 @@ def async_remove_shelly_entity(
entity_reg.async_remove(entity_id)
def get_number_of_channels(device: BlockDevice, block: Block) -> int:
"""Get number of channels for block type."""
def get_block_number_of_channels(device: BlockDevice, block: Block) -> int:
"""Get number of channels."""
channels = None
if block.type == "input":
@@ -154,7 +154,7 @@ def get_block_channel_name(device: BlockDevice, block: Block | None) -> str | No
if (
not block
or block.type in ("device", "light", "relay", "emeter")
or get_number_of_channels(device, block) == 1
or get_block_number_of_channels(device, block) == 1
):
return None
@@ -253,7 +253,7 @@ def get_block_input_triggers(
if not is_block_momentary_input(device.settings, block, True):
return []
if block.type == "device" or get_number_of_channels(device, block) == 1:
if block.type == "device" or get_block_number_of_channels(device, block) == 1:
subtype = "button"
else:
assert block.channel
@@ -397,8 +397,13 @@ def get_rpc_key(value: str) -> tuple[bool, str, str]:
return len(parts) > 1, parts[0], parts[-1]
def get_rpc_key_id(value: str) -> int:
"""Get id from device key."""
return int(get_rpc_key(value)[-1])
def get_rpc_custom_name(device: RpcDevice, key: str) -> str | None:
"""Get component name from device config."""
"""Get custom name from device config."""
if (
key in device.config
and key != "em:0" # workaround for Pro 3EM, we don't want to get name for em:0
@@ -409,9 +414,9 @@ def get_rpc_custom_name(device: RpcDevice, key: str) -> str | None:
return None
def get_rpc_component_name(device: RpcDevice, key: str) -> str | None:
"""Get component name from device config."""
return get_rpc_custom_name(device, key)
def get_rpc_number_of_channels(device: RpcDevice, component: str) -> int:
"""Get number of channels."""
return len(get_rpc_key_instances(device.status, component, all_lights=True))
def get_rpc_channel_name(device: RpcDevice, key: str) -> str | None:
@@ -419,17 +424,15 @@ def get_rpc_channel_name(device: RpcDevice, key: str) -> str | None:
if BLU_TRV_IDENTIFIER in key:
return None
instances = len(
get_rpc_key_instances(device.status, key.split(":")[0], all_lights=True)
)
component = key.split(":")[0]
component_id = key.split(":")[-1]
_, component, component_id = get_rpc_key(key)
if custom_name := get_rpc_custom_name(device, key):
if component in (*VIRTUAL_COMPONENTS, "input", "presencezone", "script"):
return custom_name
return custom_name if instances == 1 else None
return (
custom_name if get_rpc_number_of_channels(device, component) == 1 else None
)
if component in (*VIRTUAL_COMPONENTS, "input"):
return f"{component.title()} {component_id}"
@@ -437,6 +440,14 @@ def get_rpc_channel_name(device: RpcDevice, key: str) -> str | None:
return None
def get_rpc_key_normalized(key: str) -> str:
"""Get normalized key. Workaround for Pro EM50 and Pro 3EM."""
# workaround for Pro EM50
key = key.replace("em1data", "em1")
# workaround for Pro 3EM
return key.replace("emdata", "em")
def get_rpc_sub_device_name(
device: RpcDevice, key: str, emeter_phase: str | None = None
) -> str:
@@ -451,11 +462,7 @@ def get_rpc_sub_device_name(
if entity_name := device.config[key].get("name"):
return cast(str, entity_name)
key = key.replace("emdata", "em")
key = key.replace("em1data", "em1")
component = key.split(":")[0]
component_id = key.split(":")[-1]
_, component, component_id = get_rpc_key(get_rpc_key_normalized(key))
if component in ("cct", "rgb", "rgbw"):
return f"{device.name} {component.upper()} light {component_id}"
@@ -524,7 +531,7 @@ def get_rpc_key_instances(
def get_rpc_key_ids(keys_dict: dict[str, Any], key: str) -> list[int]:
"""Return list of key ids for RPC device from a dict."""
return [int(k.split(":")[1]) for k in keys_dict if k.startswith(f"{key}:")]
return [get_rpc_key_id(k) for k in keys_dict if k.startswith(f"{key}:")]
def get_rpc_key_by_role(keys_dict: dict[str, Any], role: str) -> str | None:
@@ -806,11 +813,10 @@ def is_rpc_exclude_from_relay(
settings: dict[str, Any], status: dict[str, Any], channel: str
) -> bool:
"""Return true if rpc channel should be excludeed from switch platform."""
ch = int(channel.split(":")[1])
if is_rpc_thermostat_internal_actuator(status):
return True
return is_rpc_channel_type_light(settings, ch)
return is_rpc_channel_type_light(settings, get_rpc_key_id(channel))
def get_shelly_air_lamp_life(lamp_seconds: int) -> float:
@@ -832,7 +838,7 @@ async def get_rpc_scripts_event_types(
if script_name in ignore_scripts:
continue
script_id = int(script.split(":")[-1])
script_id = get_rpc_key_id(script)
script_events[script_id] = await get_rpc_script_event_types(device, script_id)
return script_events
@@ -863,14 +869,8 @@ def get_rpc_device_info(
if key is None:
return DeviceInfo(connections={(CONNECTION_NETWORK_MAC, mac)})
# workaround for Pro EM50
key = key.replace("em1data", "em1")
# workaround for Pro 3EM
key = key.replace("emdata", "em")
key_parts = key.split(":")
component = key_parts[0]
idx = key_parts[1] if len(key_parts) > 1 else None
key = get_rpc_key_normalized(key)
has_id, component, _ = get_rpc_key(key)
if emeter_phase is not None:
return DeviceInfo(
@@ -889,8 +889,8 @@ def get_rpc_device_info(
component not in (*All_LIGHT_TYPES, "cover", "em1", "switch")
and get_irrigation_zone_id(device, key) is None
)
or idx is None
or len(get_rpc_key_instances(device.status, component, all_lights=True)) < 2
or not has_id
or get_rpc_number_of_channels(device, component) < 2
):
return DeviceInfo(connections={(CONNECTION_NETWORK_MAC, mac)})
@@ -923,6 +923,15 @@ def get_blu_trv_device_info(
)
def is_block_single_device(device: BlockDevice, block: Block | None = None) -> bool:
"""Return true if block is single device."""
return (
block is None
or block.type not in ("light", "relay", "emeter")
or device.settings.get("mode") == "roller"
)
def get_block_device_info(
device: BlockDevice,
mac: str,
@@ -933,14 +942,14 @@ def get_block_device_info(
suggested_area: str | None = None,
) -> DeviceInfo:
"""Return device info for Block device."""
if (
block is None
or block.type not in ("light", "relay", "emeter")
or device.settings.get("mode") == "roller"
or get_number_of_channels(device, block) < 2
if is_block_single_device(device, block) or (
block is not None and get_block_number_of_channels(device, block) < 2
):
return DeviceInfo(connections={(CONNECTION_NETWORK_MAC, mac)})
if TYPE_CHECKING:
assert block
return DeviceInfo(
identifiers={(DOMAIN, f"{mac}-{block.description}")},
name=get_block_sub_device_name(device, block),

View File

@@ -66,8 +66,6 @@ class SleepAsAndroidSensorEntity(SleepAsAndroidEntity, RestoreSensor):
if webhook_id == self.webhook_id and data[ATTR_EVENT] in (
"alarm_snooze_clicked",
"alarm_snooze_canceled",
"alarm_alert_start",
"alarm_alert_dismiss",
"alarm_skip_next",
"show_skip_next_alarm",
"alarm_rescheduled",

View File

@@ -8,20 +8,17 @@ from dataclasses import dataclass
from pysmartthings import Attribute, Capability, Category, SmartThings, Status
from homeassistant.components.binary_sensor import (
DOMAIN as BINARY_SENSOR_DOMAIN,
BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import FullDevice, SmartThingsConfigEntry
from .const import INVALID_SWITCH_CATEGORIES, MAIN
from .entity import SmartThingsEntity
from .util import deprecate_entity
@dataclass(frozen=True, kw_only=True)
@@ -31,11 +28,14 @@ class SmartThingsBinarySensorEntityDescription(BinarySensorEntityDescription):
is_on_key: str
category_device_class: dict[Category | str, BinarySensorDeviceClass] | None = None
category: set[Category] | None = None
exists_fn: Callable[[str], bool] | None = None
exists_fn: (
Callable[
[str, dict[str, dict[Capability | str, dict[Attribute | str, Status]]]],
bool,
]
| None
) = None
component_translation_key: dict[str, str] | None = None
deprecated_fn: Callable[
[dict[str, dict[Capability | str, dict[Attribute | str, Status]]]], str | None
] = lambda _: None
CAPABILITY_TO_SENSORS: dict[
@@ -59,17 +59,16 @@ CAPABILITY_TO_SENSORS: dict[
Category.DOOR: BinarySensorDeviceClass.DOOR,
Category.WINDOW: BinarySensorDeviceClass.WINDOW,
},
exists_fn=lambda key: key in {"freezer", "cooler", "cvroom"},
exists_fn=lambda component, status: (
not ("freezer" in status and "cooler" in status)
if component == MAIN
else True
),
component_translation_key={
"freezer": "freezer_door",
"cooler": "cooler_door",
"cvroom": "cool_select_plus_door",
},
deprecated_fn=(
lambda status: "fridge_door"
if "freezer" in status and "cooler" in status
else None
),
)
},
Capability.CUSTOM_DRYER_WRINKLE_PREVENT: {
@@ -130,6 +129,9 @@ CAPABILITY_TO_SENSORS: dict[
key=Attribute.REMOTE_CONTROL_ENABLED,
translation_key="remote_control",
is_on_key="true",
component_translation_key={
"sub": "sub_remote_control",
},
)
},
Capability.SOUND_SENSOR: {
@@ -155,15 +157,6 @@ CAPABILITY_TO_SENSORS: dict[
entity_category=EntityCategory.DIAGNOSTIC,
)
},
Capability.VALVE: {
Attribute.VALVE: SmartThingsBinarySensorEntityDescription(
key=Attribute.VALVE,
translation_key="valve",
device_class=BinarySensorDeviceClass.OPENING,
is_on_key="open",
deprecated_fn=lambda _: "valve",
)
},
Capability.WATER_SENSOR: {
Attribute.WATER: SmartThingsBinarySensorEntityDescription(
key=Attribute.WATER,
@@ -204,64 +197,39 @@ async def async_setup_entry(
) -> None:
"""Add binary sensors for a config entry."""
entry_data = entry.runtime_data
entities = []
entity_registry = er.async_get(hass)
for device in entry_data.devices.values(): # pylint: disable=too-many-nested-blocks
for capability, attribute_map in CAPABILITY_TO_SENSORS.items():
for attribute, description in attribute_map.items():
for component in device.status:
if (
capability in device.status[component]
and (
component == MAIN
or (
description.exists_fn is not None
and description.exists_fn(component)
)
)
and (
not description.category
or get_main_component_category(device)
in description.category
)
):
if (
component == MAIN
and (issue := description.deprecated_fn(device.status))
is not None
):
if deprecate_entity(
hass,
entity_registry,
BINARY_SENSOR_DOMAIN,
f"{device.device.device_id}_{component}_{capability}_{attribute}_{attribute}",
f"deprecated_binary_{issue}",
):
entities.append(
SmartThingsBinarySensor(
entry_data.client,
device,
description,
capability,
attribute,
component,
)
)
continue
entities.append(
SmartThingsBinarySensor(
entry_data.client,
device,
description,
capability,
attribute,
component,
)
)
async_add_entities(entities)
async_add_entities(
SmartThingsBinarySensor(
entry_data.client,
device,
description,
capability,
attribute,
component,
)
for device in entry_data.devices.values()
for capability, attribute_map in CAPABILITY_TO_SENSORS.items()
for attribute, description in attribute_map.items()
for component in device.status
if (
capability in device.status[component]
and (
component == MAIN
or (
description.component_translation_key is not None
and component in description.component_translation_key
)
)
and (
description.exists_fn is None
or description.exists_fn(component, device.status)
)
and (
not description.category
or get_main_component_category(device) in description.category
)
)
)
class SmartThingsBinarySensor(SmartThingsEntity, BinarySensorEntity):

View File

@@ -82,8 +82,14 @@
"stop": "mdi:stop"
}
},
"soil_level": {
"default": "mdi:liquid-spot"
},
"spin_level": {
"default": "mdi:rotate-right"
},
"water_temperature": {
"default": "mdi:water-thermometer"
}
},
"sensor": {

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