Compare commits

..

4 Commits

Author SHA1 Message Date
Allen Porter
27be570b69 Add tools to trace 2024-07-24 02:09:22 +00:00
Allen Porter
4879e02839 Modify ollama tool calls 2024-07-23 05:00:48 +00:00
Allen Porter
eaeca423d4 Update ollama tool calls 2024-07-23 05:00:48 +00:00
Allen Porter
8f688ee079 Ollama tool calling 2024-07-23 05:00:48 +00:00
1041 changed files with 11999 additions and 39271 deletions

View File

@@ -190,7 +190,7 @@ jobs:
echo "${{ github.sha }};${{ github.ref }};${{ github.event_name }};${{ github.actor }}" > rootfs/OFFICIAL_IMAGE
- name: Login to GitHub Container Registry
uses: docker/login-action@v3.3.0
uses: docker/login-action@v3.2.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -256,7 +256,7 @@ jobs:
fi
- name: Login to GitHub Container Registry
uses: docker/login-action@v3.3.0
uses: docker/login-action@v3.2.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -329,14 +329,14 @@ jobs:
- name: Login to DockerHub
if: matrix.registry == 'docker.io/homeassistant'
uses: docker/login-action@v3.3.0
uses: docker/login-action@v3.2.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
if: matrix.registry == 'ghcr.io/home-assistant'
uses: docker/login-action@v3.3.0
uses: docker/login-action@v3.2.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}

View File

@@ -904,7 +904,6 @@ jobs:
cov_params+=(--cov-report=xml)
fi
echo "Test group ${{ matrix.group }}: $(sed -n "${{ matrix.group }},1p" pytest_buckets.txt)"
python3 -b -X dev -m pytest \
-qq \
--timeout=9 \

View File

@@ -24,11 +24,11 @@ jobs:
uses: actions/checkout@v4.1.7
- name: Initialize CodeQL
uses: github/codeql-action/init@v3.25.15
uses: github/codeql-action/init@v3.25.13
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3.25.15
uses: github/codeql-action/analyze@v3.25.13
with:
category: "/language:python"

View File

@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.5.5
rev: v0.5.4
hooks:
- id: ruff
args:

View File

@@ -120,7 +120,6 @@ homeassistant.components.bond.*
homeassistant.components.braviatv.*
homeassistant.components.brother.*
homeassistant.components.browser.*
homeassistant.components.bryant_evolution.*
homeassistant.components.bthome.*
homeassistant.components.button.*
homeassistant.components.calendar.*
@@ -168,7 +167,6 @@ homeassistant.components.ecowitt.*
homeassistant.components.efergy.*
homeassistant.components.electrasmart.*
homeassistant.components.electric_kiwi.*
homeassistant.components.elevenlabs.*
homeassistant.components.elgato.*
homeassistant.components.elkm1.*
homeassistant.components.emulated_hue.*
@@ -282,7 +280,6 @@ homeassistant.components.lidarr.*
homeassistant.components.lifx.*
homeassistant.components.light.*
homeassistant.components.linear_garage_door.*
homeassistant.components.linkplay.*
homeassistant.components.litejet.*
homeassistant.components.litterrobot.*
homeassistant.components.local_ip.*

View File

@@ -197,8 +197,7 @@ build.json @home-assistant/supervisor
/tests/components/bluemaestro/ @bdraco
/homeassistant/components/blueprint/ @home-assistant/core
/tests/components/blueprint/ @home-assistant/core
/homeassistant/components/bluesound/ @thrawnarn @LouisChrist
/tests/components/bluesound/ @thrawnarn @LouisChrist
/homeassistant/components/bluesound/ @thrawnarn
/homeassistant/components/bluetooth/ @bdraco
/tests/components/bluetooth/ @bdraco
/homeassistant/components/bluetooth_adapters/ @bdraco
@@ -221,8 +220,6 @@ build.json @home-assistant/supervisor
/tests/components/brottsplatskartan/ @gjohansson-ST
/homeassistant/components/brunt/ @eavanvalkenburg
/tests/components/brunt/ @eavanvalkenburg
/homeassistant/components/bryant_evolution/ @danielsmyers
/tests/components/bryant_evolution/ @danielsmyers
/homeassistant/components/bsblan/ @liudger
/tests/components/bsblan/ @liudger
/homeassistant/components/bt_smarthub/ @typhoon2099
@@ -376,8 +373,6 @@ build.json @home-assistant/supervisor
/tests/components/electrasmart/ @jafar-atili
/homeassistant/components/electric_kiwi/ @mikey0000
/tests/components/electric_kiwi/ @mikey0000
/homeassistant/components/elevenlabs/ @sorgfresser
/tests/components/elevenlabs/ @sorgfresser
/homeassistant/components/elgato/ @frenck
/tests/components/elgato/ @frenck
/homeassistant/components/elkm1/ @gwww @bdraco
@@ -389,7 +384,6 @@ build.json @home-assistant/supervisor
/tests/components/elvia/ @ludeeus
/homeassistant/components/emby/ @mezz64
/homeassistant/components/emoncms/ @borpin @alexandrecuer
/tests/components/emoncms/ @borpin @alexandrecuer
/homeassistant/components/emonitor/ @bdraco
/tests/components/emonitor/ @bdraco
/homeassistant/components/emulated_hue/ @bdraco @Tho85
@@ -712,8 +706,6 @@ build.json @home-assistant/supervisor
/homeassistant/components/iqvia/ @bachya
/tests/components/iqvia/ @bachya
/homeassistant/components/irish_rail_transport/ @ttroy50
/homeassistant/components/iron_os/ @tr4nt0r
/tests/components/iron_os/ @tr4nt0r
/homeassistant/components/isal/ @bdraco
/tests/components/isal/ @bdraco
/homeassistant/components/islamic_prayer_times/ @engrbm87 @cpfair
@@ -802,8 +794,6 @@ build.json @home-assistant/supervisor
/tests/components/light/ @home-assistant/core
/homeassistant/components/linear_garage_door/ @IceBotYT
/tests/components/linear_garage_door/ @IceBotYT
/homeassistant/components/linkplay/ @Velleman
/tests/components/linkplay/ @Velleman
/homeassistant/components/linux_battery/ @fabaff
/homeassistant/components/litejet/ @joncar
/tests/components/litejet/ @joncar
@@ -846,8 +836,7 @@ build.json @home-assistant/supervisor
/tests/components/lyric/ @timmo001
/homeassistant/components/madvr/ @iloveicedgreentea
/tests/components/madvr/ @iloveicedgreentea
/homeassistant/components/mastodon/ @fabaff @andrew-codechimp
/tests/components/mastodon/ @fabaff @andrew-codechimp
/homeassistant/components/mastodon/ @fabaff
/homeassistant/components/matrix/ @PaarthShah
/tests/components/matrix/ @PaarthShah
/homeassistant/components/matter/ @home-assistant/matter
@@ -1053,8 +1042,8 @@ build.json @home-assistant/supervisor
/tests/components/otbr/ @home-assistant/core
/homeassistant/components/ourgroceries/ @OnFreund
/tests/components/ourgroceries/ @OnFreund
/homeassistant/components/overkiz/ @imicknl @vlebourl @tetienne @nyroDev @tronix117 @alexfp14
/tests/components/overkiz/ @imicknl @vlebourl @tetienne @nyroDev @tronix117 @alexfp14
/homeassistant/components/overkiz/ @imicknl @vlebourl @tetienne @nyroDev @tronix117
/tests/components/overkiz/ @imicknl @vlebourl @tetienne @nyroDev @tronix117
/homeassistant/components/ovo_energy/ @timmo001
/tests/components/ovo_energy/ @timmo001
/homeassistant/components/p1_monitor/ @klaasnicolaas

View File

@@ -18,12 +18,9 @@ from homeassistant.const import (
EVENT_THEMES_UPDATED,
)
from homeassistant.helpers.area_registry import EVENT_AREA_REGISTRY_UPDATED
from homeassistant.helpers.category_registry import EVENT_CATEGORY_REGISTRY_UPDATED
from homeassistant.helpers.device_registry import EVENT_DEVICE_REGISTRY_UPDATED
from homeassistant.helpers.entity_registry import EVENT_ENTITY_REGISTRY_UPDATED
from homeassistant.helpers.floor_registry import EVENT_FLOOR_REGISTRY_UPDATED
from homeassistant.helpers.issue_registry import EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED
from homeassistant.helpers.label_registry import EVENT_LABEL_REGISTRY_UPDATED
from homeassistant.util.event_type import EventType
# These are events that do not contain any sensitive data
@@ -44,7 +41,4 @@ SUBSCRIBE_ALLOWLIST: Final[set[EventType[Any] | str]] = {
EVENT_SHOPPING_LIST_UPDATED,
EVENT_STATE_CHANGED,
EVENT_THEMES_UPDATED,
EVENT_LABEL_REGISTRY_UPDATED,
EVENT_CATEGORY_REGISTRY_UPDATED,
EVENT_FLOOR_REGISTRY_UPDATED,
}

View File

@@ -1,5 +1,5 @@
{
"domain": "logitech",
"name": "Logitech",
"integrations": ["harmony", "squeezebox"]
"integrations": ["harmony", "ue_smart_radio", "squeezebox"]
}

View File

@@ -206,8 +206,7 @@ class AdvantageAirAC(AdvantageAirAcEntity, ClimateEntity):
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set the HVAC Mode and State."""
if hvac_mode == HVACMode.OFF:
await self.async_turn_off()
return
return await self.async_turn_off()
if hvac_mode == HVACMode.HEAT_COOL and self.preset_mode != ADVANTAGE_AIR_MYAUTO:
raise ServiceValidationError("Heat/Cool is not supported in this mode")
await self.async_update_ac(

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/aemet",
"iot_class": "cloud_polling",
"loggers": ["aemet_opendata"],
"requirements": ["AEMET-OpenData==0.5.3"]
"requirements": ["AEMET-OpenData==0.5.2"]
}

View File

@@ -2,13 +2,9 @@
from typing import Any
from airgradient import (
AirGradientClient,
AirGradientError,
AirGradientParseError,
ConfigurationControl,
)
from airgradient import AirGradientClient, AirGradientError, ConfigurationControl
from awesomeversion import AwesomeVersion
from mashumaro import MissingField
import voluptuous as vol
from homeassistant.components import zeroconf
@@ -87,10 +83,10 @@ class AirGradientConfigFlow(ConfigFlow, domain=DOMAIN):
self.client = AirGradientClient(user_input[CONF_HOST], session=session)
try:
current_measures = await self.client.get_current_measures()
except AirGradientParseError:
return self.async_abort(reason="invalid_version")
except AirGradientError:
errors["base"] = "cannot_connect"
except MissingField:
return self.async_abort(reason="invalid_version")
else:
await self.async_set_unique_id(current_measures.serial_number)
self._abort_if_unique_id_configured()

View File

@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/airgradient",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["airgradient==0.8.0"],
"requirements": ["airgradient==0.7.0"],
"zeroconf": ["_airgradient._tcp.local."]
}

View File

@@ -156,8 +156,7 @@ class AirtouchAC(CoordinatorEntity, ClimateEntity):
raise ValueError(f"Unsupported HVAC mode: {hvac_mode}")
if hvac_mode == HVACMode.OFF:
await self.async_turn_off()
return
return await self.async_turn_off()
await self._airtouch.SetCoolingModeForAc(
self._ac_number, HA_STATE_TO_AT[hvac_mode]
)
@@ -263,8 +262,7 @@ class AirtouchGroup(CoordinatorEntity, ClimateEntity):
raise ValueError(f"Unsupported HVAC mode: {hvac_mode}")
if hvac_mode == HVACMode.OFF:
await self.async_turn_off()
return
return await self.async_turn_off()
if self.hvac_mode == HVACMode.OFF:
await self.async_turn_on()
self._unit = self._airtouch.GetGroups()[self._group_number]

View File

@@ -11,7 +11,7 @@ from homeassistant.exceptions import ConfigEntryNotReady
from .const import DOMAIN
PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.COVER]
PLATFORMS: list[Platform] = [Platform.CLIMATE]
type Airtouch5ConfigEntry = ConfigEntry[Airtouch5SimpleClient]

View File

@@ -121,7 +121,6 @@ class Airtouch5ClimateEntity(ClimateEntity, Airtouch5Entity):
"""Base class for Airtouch5 Climate Entities."""
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_translation_key = DOMAIN
_attr_target_temperature_step = 1
_attr_name = None
_enable_turn_on_off_backwards_compatibility = False

View File

@@ -1,134 +0,0 @@
"""Representation of the Damper for AirTouch 5 Devices."""
import logging
from typing import Any
from airtouch5py.airtouch5_simple_client import Airtouch5SimpleClient
from airtouch5py.packets.zone_control import (
ZoneControlZone,
ZoneSettingPower,
ZoneSettingValue,
)
from airtouch5py.packets.zone_name import ZoneName
from airtouch5py.packets.zone_status import ZoneStatusZone
from homeassistant.components.cover import (
ATTR_POSITION,
CoverDeviceClass,
CoverEntity,
CoverEntityFeature,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import Airtouch5ConfigEntry
from .const import DOMAIN
from .entity import Airtouch5Entity
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: Airtouch5ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Airtouch 5 Cover entities."""
client = config_entry.runtime_data
# Each zone has a cover for its open percentage
async_add_entities(
Airtouch5ZoneOpenPercentage(
client, zone, client.latest_zone_status[zone.zone_number].has_sensor
)
for zone in client.zones
)
class Airtouch5ZoneOpenPercentage(CoverEntity, Airtouch5Entity):
"""How open the damper is in each zone."""
_attr_device_class = CoverDeviceClass.DAMPER
_attr_translation_key = "damper"
# Zones with temperature sensors shouldn't be manually controlled.
# We allow it but warn the user in the integration documentation.
_attr_supported_features = (
CoverEntityFeature.SET_POSITION
| CoverEntityFeature.OPEN
| CoverEntityFeature.CLOSE
)
def __init__(
self, client: Airtouch5SimpleClient, zone_name: ZoneName, has_sensor: bool
) -> None:
"""Initialise the Cover Entity."""
super().__init__(client)
self._zone_name = zone_name
self._attr_unique_id = f"zone_{zone_name.zone_number}_open_percentage"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"zone_{zone_name.zone_number}")},
name=zone_name.zone_name,
manufacturer="Polyaire",
model="AirTouch 5",
)
@callback
def _async_update_attrs(self, data: dict[int, ZoneStatusZone]) -> None:
if self._zone_name.zone_number not in data:
return
status = data[self._zone_name.zone_number]
self._attr_current_cover_position = int(status.open_percentage * 100)
if status.open_percentage == 0:
self._attr_is_closed = True
else:
self._attr_is_closed = False
self.async_write_ha_state()
async def async_added_to_hass(self) -> None:
"""Add data updated listener after this object has been initialized."""
await super().async_added_to_hass()
self._client.zone_status_callbacks.append(self._async_update_attrs)
self._async_update_attrs(self._client.latest_zone_status)
async def async_will_remove_from_hass(self) -> None:
"""Remove data updated listener after this object has been initialized."""
await super().async_will_remove_from_hass()
self._client.zone_status_callbacks.remove(self._async_update_attrs)
async def async_open_cover(self, **kwargs: Any) -> None:
"""Open the damper."""
await self._set_cover_position(100)
async def async_close_cover(self, **kwargs: Any) -> None:
"""Close damper."""
await self._set_cover_position(0)
async def async_set_cover_position(self, **kwargs: Any) -> None:
"""Update the damper to a specific position."""
if (position := kwargs.get(ATTR_POSITION)) is None:
_LOGGER.debug("Argument `position` is missing in set_cover_position")
return
await self._set_cover_position(position)
async def _set_cover_position(self, position_percent: float) -> None:
power: ZoneSettingPower
if position_percent == 0:
power = ZoneSettingPower.SET_TO_OFF
else:
power = ZoneSettingPower.SET_TO_ON
zcz = ZoneControlZone(
self._zone_name.zone_number,
ZoneSettingValue.SET_OPEN_PERCENTAGE,
power,
position_percent / 100.0,
)
packet = self._client.data_packet_factory.zone_control([zcz])
await self._client.send_packet(packet)

View File

@@ -6,12 +6,15 @@ from airtouch5py.airtouch5_simple_client import Airtouch5SimpleClient
from homeassistant.core import callback
from homeassistant.helpers.entity import Entity
from .const import DOMAIN
class Airtouch5Entity(Entity):
"""Base class for Airtouch5 entities."""
_attr_should_poll = False
_attr_has_entity_name = True
_attr_translation_key = DOMAIN
def __init__(self, client: Airtouch5SimpleClient) -> None:
"""Initialise the Entity."""

View File

@@ -27,11 +27,6 @@
}
}
}
},
"cover": {
"damper": {
"name": "[%key:component::cover::entity_component::damper::name%]"
}
}
}
}

View File

@@ -11,5 +11,5 @@
"documentation": "https://www.home-assistant.io/integrations/airzone",
"iot_class": "local_polling",
"loggers": ["aioairzone"],
"requirements": ["aioairzone==0.8.1"]
"requirements": ["aioairzone==0.8.0"]
}

View File

@@ -14,7 +14,6 @@ from aioairzone_cloud.const import (
AZD_FLOOR_DEMAND,
AZD_PROBLEMS,
AZD_SYSTEMS,
AZD_THERMOSTAT_BATTERY_LOW,
AZD_WARNINGS,
AZD_ZONES,
)
@@ -89,10 +88,6 @@ ZONE_BINARY_SENSOR_TYPES: Final[tuple[AirzoneBinarySensorEntityDescription, ...]
key=AZD_AQ_ACTIVE,
translation_key="air_quality_active",
),
AirzoneBinarySensorEntityDescription(
device_class=BinarySensorDeviceClass.BATTERY,
key=AZD_THERMOSTAT_BATTERY_LOW,
),
AirzoneBinarySensorEntityDescription(
device_class=BinarySensorDeviceClass.RUNNING,
key=AZD_FLOOR_DEMAND,

View File

@@ -13,12 +13,9 @@ from aioairzone_cloud.const import (
AZD_GROUPS,
AZD_HOT_WATERS,
AZD_INSTALLATIONS,
AZD_MODEL,
AZD_NAME,
AZD_SYSTEM_ID,
AZD_SYSTEMS,
AZD_THERMOSTAT_FW,
AZD_THERMOSTAT_MODEL,
AZD_WEBSERVER,
AZD_WEBSERVERS,
AZD_ZONES,
@@ -72,7 +69,6 @@ class AirzoneAidooEntity(AirzoneEntity):
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, aidoo_id)},
manufacturer=MANUFACTURER,
model=aidoo_data[AZD_MODEL],
name=aidoo_data[AZD_NAME],
via_device=(DOMAIN, aidoo_data[AZD_WEBSERVER]),
)
@@ -115,7 +111,6 @@ class AirzoneGroupEntity(AirzoneEntity):
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, group_id)},
model="Group",
manufacturer=MANUFACTURER,
name=group_data[AZD_NAME],
)
@@ -159,7 +154,6 @@ class AirzoneHotWaterEntity(AirzoneEntity):
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, dhw_id)},
manufacturer=MANUFACTURER,
model="Hot Water",
name=dhw_data[AZD_NAME],
via_device=(DOMAIN, dhw_data[AZD_WEBSERVER]),
)
@@ -201,7 +195,6 @@ class AirzoneInstallationEntity(AirzoneEntity):
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, inst_id)},
manufacturer=MANUFACTURER,
model="Installation",
name=inst_data[AZD_NAME],
)
@@ -247,11 +240,9 @@ class AirzoneSystemEntity(AirzoneEntity):
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, system_id)},
model=system_data.get(AZD_MODEL),
manufacturer=MANUFACTURER,
name=system_data[AZD_NAME],
via_device=(DOMAIN, system_data[AZD_WEBSERVER]),
sw_version=system_data.get(AZD_FIRMWARE),
)
def get_airzone_value(self, key: str) -> Any:
@@ -279,7 +270,6 @@ class AirzoneWebServerEntity(AirzoneEntity):
self._attr_device_info = DeviceInfo(
connections={(dr.CONNECTION_NETWORK_MAC, ws_id)},
identifiers={(DOMAIN, ws_id)},
model="WebServer",
manufacturer=MANUFACTURER,
name=ws_data[AZD_NAME],
sw_version=ws_data[AZD_FIRMWARE],
@@ -310,11 +300,9 @@ class AirzoneZoneEntity(AirzoneEntity):
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, zone_id)},
model=zone_data.get(AZD_THERMOSTAT_MODEL),
manufacturer=MANUFACTURER,
name=zone_data[AZD_NAME],
via_device=(DOMAIN, self.system_id),
sw_version=zone_data.get(AZD_THERMOSTAT_FW),
)
def get_airzone_value(self, key: str) -> Any:

View File

@@ -1,15 +0,0 @@
{
"entity": {
"sensor": {
"cpu_usage": {
"default": "mdi:cpu-32-bit"
},
"free_memory": {
"default": "mdi:memory"
},
"thermostat_coverage": {
"default": "mdi:signal"
}
}
}
}

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/airzone_cloud",
"iot_class": "cloud_push",
"loggers": ["aioairzone_cloud"],
"requirements": ["aioairzone-cloud==0.6.1"]
"requirements": ["aioairzone-cloud==0.5.4"]
}

View File

@@ -10,12 +10,8 @@ from aioairzone_cloud.const import (
AZD_AQ_PM_1,
AZD_AQ_PM_2P5,
AZD_AQ_PM_10,
AZD_CPU_USAGE,
AZD_HUMIDITY,
AZD_MEMORY_FREE,
AZD_TEMP,
AZD_THERMOSTAT_BATTERY,
AZD_THERMOSTAT_COVERAGE,
AZD_WEBSERVERS,
AZD_WIFI_RSSI,
AZD_ZONES,
@@ -32,7 +28,6 @@ from homeassistant.const import (
PERCENTAGE,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
EntityCategory,
UnitOfInformation,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, callback
@@ -57,22 +52,6 @@ AIDOO_SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = (
)
WEBSERVER_SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = (
SensorEntityDescription(
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
key=AZD_CPU_USAGE,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
translation_key="cpu_usage",
),
SensorEntityDescription(
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
key=AZD_MEMORY_FREE,
native_unit_of_measurement=UnitOfInformation.BYTES,
state_class=SensorStateClass.MEASUREMENT,
translation_key="free_memory",
),
SensorEntityDescription(
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
entity_category=EntityCategory.DIAGNOSTIC,
@@ -119,20 +98,6 @@ ZONE_SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = (
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
device_class=SensorDeviceClass.BATTERY,
key=AZD_THERMOSTAT_BATTERY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
key=AZD_THERMOSTAT_COVERAGE,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
translation_key="thermostat_coverage",
),
)

View File

@@ -37,17 +37,6 @@
"auto": "Auto"
}
}
},
"sensor": {
"cpu_usage": {
"name": "CPU usage"
},
"free_memory": {
"name": "Free memory"
},
"thermostat_coverage": {
"name": "Signal percentage"
}
}
}
}

View File

@@ -5,6 +5,5 @@
"codeowners": ["@home-assistant/cloud", "@ochlocracy", "@jbouwh"],
"dependencies": ["http"],
"documentation": "https://www.home-assistant.io/integrations/alexa",
"integration_type": "system",
"iot_class": "cloud_push"
}

View File

@@ -8,7 +8,6 @@ from datetime import timedelta
import logging
from typing import TYPE_CHECKING, Any
import aiohttp
from aiohttp import web
from amcrest import AmcrestError
from haffmpeg.camera import CameraMjpeg
@@ -245,9 +244,7 @@ class AmcrestCam(Camera):
websession = async_get_clientsession(self.hass)
streaming_url = self._api.mjpeg_url(typeno=self._resolution)
stream_coro = websession.get(
streaming_url,
auth=self._token,
timeout=aiohttp.ClientTimeout(total=CAMERA_WEB_SESSION_TIMEOUT),
streaming_url, auth=self._token, timeout=CAMERA_WEB_SESSION_TIMEOUT
)
return await async_aiohttp_proxy_web(self.hass, request, stream_coro)

View File

@@ -4,6 +4,3 @@ from typing import Final
DOMAIN: Final = "apcupsd"
CONNECTION_TIMEOUT: int = 10
# Field name of last self test retrieved from apcupsd.
LASTSTEST: Final = "laststest"

View File

@@ -13,7 +13,6 @@ from homeassistant.components.sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
PERCENTAGE,
STATE_UNKNOWN,
UnitOfApparentPower,
UnitOfElectricCurrent,
UnitOfElectricPotential,
@@ -26,7 +25,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, LASTSTEST
from .const import DOMAIN
from .coordinator import APCUPSdCoordinator
PARALLEL_UPDATES = 0
@@ -157,8 +156,8 @@ SENSORS: dict[str, SensorEntityDescription] = {
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
LASTSTEST: SensorEntityDescription(
key=LASTSTEST,
"laststest": SensorEntityDescription(
key="laststest",
translation_key="last_self_test",
),
"lastxfer": SensorEntityDescription(
@@ -418,12 +417,7 @@ async def async_setup_entry(
available_resources: set[str] = {k.lower() for k, _ in coordinator.data.items()}
entities = []
# "laststest" is a special sensor that only appears when the APC UPS daemon has done a
# periodical (or manual) self test since last daemon restart. It might not be available
# when we set up the integration, and we do not know if it would ever be available. Here we
# add it anyway and mark it as unknown initially.
for resource in available_resources | {LASTSTEST}:
for resource in available_resources:
if resource not in SENSORS:
_LOGGER.warning("Invalid resource from APCUPSd: %s", resource.upper())
continue
@@ -479,14 +473,6 @@ class APCUPSdSensor(CoordinatorEntity[APCUPSdCoordinator], SensorEntity):
def _update_attrs(self) -> None:
"""Update sensor attributes based on coordinator data."""
key = self.entity_description.key.upper()
# For most sensors the key will always be available for each refresh. However, some sensors
# (e.g., "laststest") will only appear after certain event occurs (e.g., a self test is
# performed) and may disappear again after certain event. So we mark the state as "unknown"
# when it becomes unknown after such events.
if key not in self.coordinator.data:
self._attr_native_value = STATE_UNKNOWN
return
self._attr_native_value, inferred_unit = infer_unit(self.coordinator.data[key])
if not self.native_unit_of_measurement:
self._attr_native_unit_of_measurement = inferred_unit

View File

@@ -45,7 +45,7 @@ from homeassistant.exceptions import (
TemplateError,
Unauthorized,
)
from homeassistant.helpers import config_validation as cv, recorder, template
from homeassistant.helpers import config_validation as cv, template
from homeassistant.helpers.json import json_dumps, json_fragment
from homeassistant.helpers.service import async_get_all_descriptions
from homeassistant.helpers.typing import ConfigType
@@ -119,10 +119,7 @@ class APICoreStateView(HomeAssistantView):
to check if Home Assistant is running.
"""
hass = request.app[KEY_HASS]
migration = recorder.async_migration_in_progress(hass)
live = recorder.async_migration_is_live(hass)
recorder_state = {"migration_in_progress": migration, "migration_is_live": live}
return self.json({"state": hass.state.value, "recorder_state": recorder_state})
return self.json({"state": hass.state.value})
class APIEventStream(HomeAssistantView):
@@ -390,27 +387,6 @@ class APIDomainServicesView(HomeAssistantView):
)
context = self.context(request)
if not hass.services.has_service(domain, service):
raise HTTPBadRequest from ServiceNotFound(domain, service)
if response_requested := "return_response" in request.query:
if (
hass.services.supports_response(domain, service)
is ha.SupportsResponse.NONE
):
return self.json_message(
"Service does not support responses. Remove return_response from request.",
HTTPStatus.BAD_REQUEST,
)
elif (
hass.services.supports_response(domain, service) is ha.SupportsResponse.ONLY
):
return self.json_message(
"Service call requires responses but caller did not ask for responses. "
"Add ?return_response to query parameters.",
HTTPStatus.BAD_REQUEST,
)
changed_states: list[json_fragment] = []
@ha.callback
@@ -427,14 +403,13 @@ class APIDomainServicesView(HomeAssistantView):
try:
# shield the service call from cancellation on connection drop
response = await shield(
await shield(
hass.services.async_call(
domain,
service,
data, # type: ignore[arg-type]
blocking=True,
context=context,
return_response=response_requested,
)
)
except (vol.Invalid, ServiceNotFound) as ex:
@@ -442,11 +417,6 @@ class APIDomainServicesView(HomeAssistantView):
finally:
cancel_listen()
if response_requested:
return self.json(
{"changed_states": changed_states, "service_response": response}
)
return self.json(changed_states)

View File

@@ -60,7 +60,6 @@ AUTH_EXCEPTIONS = (
exceptions.NoCredentialsError,
)
CONNECTION_TIMEOUT_EXCEPTIONS = (
OSError,
asyncio.CancelledError,
TimeoutError,
exceptions.ConnectionLostError,

View File

@@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant
from .const import DEFAULT_PORT
from .coordinator import ApSystemsDataCoordinator
PLATFORMS: list[Platform] = [Platform.NUMBER, Platform.SENSOR, Platform.SWITCH]
PLATFORMS: list[Platform] = [Platform.NUMBER, Platform.SENSOR]
@dataclass

View File

@@ -20,43 +20,18 @@
},
"entity": {
"sensor": {
"total_power": {
"name": "Total power"
},
"total_power_p1": {
"name": "Power of P1"
},
"total_power_p2": {
"name": "Power of P2"
},
"lifetime_production": {
"name": "Total lifetime production"
},
"lifetime_production_p1": {
"name": "Lifetime production of P1"
},
"lifetime_production_p2": {
"name": "Lifetime production of P2"
},
"today_production": {
"name": "Production of today"
},
"today_production_p1": {
"name": "Production of today from P1"
},
"today_production_p2": {
"name": "Production of today from P2"
}
"total_power": { "name": "Total power" },
"total_power_p1": { "name": "Power of P1" },
"total_power_p2": { "name": "Power of P2" },
"lifetime_production": { "name": "Total lifetime production" },
"lifetime_production_p1": { "name": "Lifetime production of P1" },
"lifetime_production_p2": { "name": "Lifetime production of P2" },
"today_production": { "name": "Production of today" },
"today_production_p1": { "name": "Production of today from P1" },
"today_production_p2": { "name": "Production of today from P2" }
},
"number": {
"max_output": {
"name": "Max output"
}
},
"switch": {
"inverter_status": {
"name": "Inverter status"
}
"max_output": { "name": "Max output" }
}
}
}

View File

@@ -1,56 +0,0 @@
"""The power switch which can be toggled via the APsystems local API integration."""
from __future__ import annotations
from typing import Any
from aiohttp.client_exceptions import ClientConnectionError
from APsystemsEZ1 import Status
from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import ApSystemsConfigEntry, ApSystemsData
from .entity import ApSystemsEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ApSystemsConfigEntry,
add_entities: AddEntitiesCallback,
) -> None:
"""Set up the switch platform."""
add_entities([ApSystemsInverterSwitch(config_entry.runtime_data)], True)
class ApSystemsInverterSwitch(ApSystemsEntity, SwitchEntity):
"""The switch class for APSystems switches."""
_attr_device_class = SwitchDeviceClass.SWITCH
_attr_translation_key = "inverter_status"
def __init__(self, data: ApSystemsData) -> None:
"""Initialize the switch."""
super().__init__(data)
self._api = data.coordinator.api
self._attr_unique_id = f"{data.device_id}_inverter_status"
async def async_update(self) -> None:
"""Update switch status and availability."""
try:
status = await self._api.get_device_power_status()
except (TimeoutError, ClientConnectionError):
self._attr_available = False
else:
self._attr_available = True
self._attr_is_on = status == Status.normal
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""
await self._api.set_device_power_status(0)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the switch off."""
await self._api.set_device_power_status(1)

View File

@@ -19,10 +19,7 @@ class AsekoEntity(CoordinatorEntity[AsekoDataUpdateCoordinator]):
super().__init__(coordinator)
self._unit = unit
if self._unit.type == "Remote":
self._device_model = "ASIN Pool"
else:
self._device_model = f"ASIN AQUA {self._unit.type}"
self._device_model = f"ASIN AQUA {self._unit.type}"
self._device_name = self._unit.name if self._unit.name else self._device_model
self._attr_device_info = DeviceInfo(

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/aseko_pool_live",
"iot_class": "cloud_polling",
"loggers": ["aioaseko"],
"requirements": ["aioaseko==0.2.0"]
"requirements": ["aioaseko==0.1.1"]
}

View File

@@ -16,10 +16,6 @@ from .const import (
DATA_LAST_WAKE_UP,
DOMAIN,
EVENT_RECORDING,
SAMPLE_CHANNELS,
SAMPLE_RATE,
SAMPLE_WIDTH,
SAMPLES_PER_CHUNK,
)
from .error import PipelineNotFound
from .pipeline import (
@@ -57,10 +53,6 @@ __all__ = (
"PipelineNotFound",
"WakeWordSettings",
"EVENT_RECORDING",
"SAMPLES_PER_CHUNK",
"SAMPLE_RATE",
"SAMPLE_WIDTH",
"SAMPLE_CHANNELS",
)
CONFIG_SCHEMA = vol.Schema(

View File

@@ -1,72 +0,0 @@
"""Audio enhancement for Assist."""
from abc import ABC, abstractmethod
from dataclasses import dataclass
import logging
from pymicro_vad import MicroVad
from .const import BYTES_PER_CHUNK
_LOGGER = logging.getLogger(__name__)
@dataclass(frozen=True, slots=True)
class EnhancedAudioChunk:
"""Enhanced audio chunk and metadata."""
audio: bytes
"""Raw PCM audio @ 16Khz with 16-bit mono samples"""
timestamp_ms: int
"""Timestamp relative to start of audio stream (milliseconds)"""
is_speech: bool | None
"""True if audio chunk likely contains speech, False if not, None if unknown"""
class AudioEnhancer(ABC):
"""Base class for audio enhancement."""
def __init__(
self, auto_gain: int, noise_suppression: int, is_vad_enabled: bool
) -> None:
"""Initialize audio enhancer."""
self.auto_gain = auto_gain
self.noise_suppression = noise_suppression
self.is_vad_enabled = is_vad_enabled
@abstractmethod
def enhance_chunk(self, audio: bytes, timestamp_ms: int) -> EnhancedAudioChunk:
"""Enhance chunk of PCM audio @ 16Khz with 16-bit mono samples."""
class MicroVadEnhancer(AudioEnhancer):
"""Audio enhancer that just runs microVAD."""
def __init__(
self, auto_gain: int, noise_suppression: int, is_vad_enabled: bool
) -> None:
"""Initialize audio enhancer."""
super().__init__(auto_gain, noise_suppression, is_vad_enabled)
self.vad: MicroVad | None = None
self.threshold = 0.5
if self.is_vad_enabled:
self.vad = MicroVad()
_LOGGER.debug("Initialized microVAD with threshold=%s", self.threshold)
def enhance_chunk(self, audio: bytes, timestamp_ms: int) -> EnhancedAudioChunk:
"""Enhance 10ms chunk of PCM audio @ 16Khz with 16-bit mono samples."""
is_speech: bool | None = None
if self.vad is not None:
# Run VAD
assert len(audio) == BYTES_PER_CHUNK
speech_prob = self.vad.Process10ms(audio)
is_speech = speech_prob > self.threshold
return EnhancedAudioChunk(
audio=audio, timestamp_ms=timestamp_ms, is_speech=is_speech
)

View File

@@ -15,10 +15,3 @@ DATA_LAST_WAKE_UP = f"{DOMAIN}.last_wake_up"
WAKE_WORD_COOLDOWN = 2 # seconds
EVENT_RECORDING = f"{DOMAIN}_recording"
SAMPLE_RATE = 16000 # hertz
SAMPLE_WIDTH = 2 # bytes
SAMPLE_CHANNELS = 1 # mono
MS_PER_CHUNK = 10
SAMPLES_PER_CHUNK = SAMPLE_RATE // (1000 // MS_PER_CHUNK) # 10 ms @ 16Khz
BYTES_PER_CHUNK = SAMPLES_PER_CHUNK * SAMPLE_WIDTH * SAMPLE_CHANNELS # 16-bit

View File

@@ -4,8 +4,7 @@
"codeowners": ["@balloob", "@synesthesiam"],
"dependencies": ["conversation", "stt", "tts", "wake_word"],
"documentation": "https://www.home-assistant.io/integrations/assist_pipeline",
"integration_type": "system",
"iot_class": "local_push",
"quality_scale": "internal",
"requirements": ["pymicro-vad==1.0.1"]
"requirements": ["webrtc-noise-gain==1.2.3"]
}

View File

@@ -13,11 +13,14 @@ from pathlib import Path
from queue import Empty, Queue
from threading import Thread
import time
from typing import Any, Literal, cast
from typing import TYPE_CHECKING, Any, Final, Literal, cast
import wave
import voluptuous as vol
if TYPE_CHECKING:
from webrtc_noise_gain import AudioProcessor
from homeassistant.components import (
conversation,
media_source,
@@ -49,19 +52,12 @@ from homeassistant.util import (
)
from homeassistant.util.limited_size_dict import LimitedSizeDict
from .audio_enhancer import AudioEnhancer, EnhancedAudioChunk, MicroVadEnhancer
from .const import (
BYTES_PER_CHUNK,
CONF_DEBUG_RECORDING_DIR,
DATA_CONFIG,
DATA_LAST_WAKE_UP,
DATA_MIGRATIONS,
DOMAIN,
MS_PER_CHUNK,
SAMPLE_CHANNELS,
SAMPLE_RATE,
SAMPLE_WIDTH,
SAMPLES_PER_CHUNK,
WAKE_WORD_COOLDOWN,
)
from .error import (
@@ -115,6 +111,9 @@ STORED_PIPELINE_RUNS = 10
SAVE_DELAY = 10
AUDIO_PROCESSOR_SAMPLES: Final = 160 # 10 ms @ 16 Khz
AUDIO_PROCESSOR_BYTES: Final = AUDIO_PROCESSOR_SAMPLES * 2 # 16-bit samples
@callback
def _async_resolve_default_pipeline_settings(
@@ -504,8 +503,8 @@ class AudioSettings:
is_vad_enabled: bool = True
"""True if VAD is used to determine the end of the voice command."""
silence_seconds: float = 0.5
"""Seconds of silence after voice command has ended."""
is_chunking_enabled: bool = True
"""True if audio is automatically split into 10 ms chunks (required for VAD, etc.)"""
def __post_init__(self) -> None:
"""Verify settings post-initialization."""
@@ -515,6 +514,9 @@ class AudioSettings:
if (self.auto_gain_dbfs < 0) or (self.auto_gain_dbfs > 31):
raise ValueError("auto_gain_dbfs must be in [0, 31]")
if self.needs_processor and (not self.is_chunking_enabled):
raise ValueError("Chunking must be enabled for audio processing")
@property
def needs_processor(self) -> bool:
"""True if an audio processor is needed."""
@@ -525,6 +527,20 @@ class AudioSettings:
)
@dataclass(frozen=True, slots=True)
class ProcessedAudioChunk:
"""Processed audio chunk and metadata."""
audio: bytes
"""Raw PCM audio @ 16Khz with 16-bit mono samples"""
timestamp_ms: int
"""Timestamp relative to start of audio stream (milliseconds)"""
is_speech: bool | None
"""True if audio chunk likely contains speech, False if not, None if unknown"""
@dataclass
class PipelineRun:
"""Running context for a pipeline."""
@@ -557,12 +573,10 @@ class PipelineRun:
debug_recording_queue: Queue[str | bytes | None] | None = None
"""Queue to communicate with debug recording thread"""
audio_enhancer: AudioEnhancer | None = None
audio_processor: AudioProcessor | None = None
"""VAD/noise suppression/auto gain"""
audio_chunking_buffer: AudioBuffer = field(
default_factory=lambda: AudioBuffer(BYTES_PER_CHUNK)
)
audio_processor_buffer: AudioBuffer = field(init=False, repr=False)
"""Buffer used when splitting audio into chunks for audio processing"""
_device_id: str | None = None
@@ -587,12 +601,17 @@ class PipelineRun:
pipeline_data.pipeline_runs.add_run(self)
# Initialize with audio settings
if self.audio_settings.needs_processor and (self.audio_enhancer is None):
# Default audio enhancer
self.audio_enhancer = MicroVadEnhancer(
self.audio_processor_buffer = AudioBuffer(AUDIO_PROCESSOR_BYTES)
if self.audio_settings.needs_processor:
# Delay import of webrtc so HA start up is not crashing
# on older architectures (armhf).
#
# pylint: disable=import-outside-toplevel
from webrtc_noise_gain import AudioProcessor
self.audio_processor = AudioProcessor(
self.audio_settings.auto_gain_dbfs,
self.audio_settings.noise_suppression_level,
self.audio_settings.is_vad_enabled,
)
def __eq__(self, other: object) -> bool:
@@ -669,8 +688,8 @@ class PipelineRun:
async def wake_word_detection(
self,
stream: AsyncIterable[EnhancedAudioChunk],
audio_chunks_for_stt: list[EnhancedAudioChunk],
stream: AsyncIterable[ProcessedAudioChunk],
audio_chunks_for_stt: list[ProcessedAudioChunk],
) -> wake_word.DetectionResult | None:
"""Run wake-word-detection portion of pipeline. Returns detection result."""
metadata_dict = asdict(
@@ -713,11 +732,10 @@ class PipelineRun:
# Audio chunk buffer. This audio will be forwarded to speech-to-text
# after wake-word-detection.
num_audio_chunks_to_buffer = int(
(wake_word_settings.audio_seconds_to_buffer * SAMPLE_RATE)
/ SAMPLES_PER_CHUNK
(wake_word_settings.audio_seconds_to_buffer * 16000)
/ AUDIO_PROCESSOR_SAMPLES
)
stt_audio_buffer: deque[EnhancedAudioChunk] | None = None
stt_audio_buffer: deque[ProcessedAudioChunk] | None = None
if num_audio_chunks_to_buffer > 0:
stt_audio_buffer = deque(maxlen=num_audio_chunks_to_buffer)
@@ -779,7 +797,7 @@ class PipelineRun:
# speech-to-text so the user does not have to pause before
# speaking the voice command.
audio_chunks_for_stt.extend(
EnhancedAudioChunk(
ProcessedAudioChunk(
audio=chunk_ts[0], timestamp_ms=chunk_ts[1], is_speech=False
)
for chunk_ts in result.queued_audio
@@ -801,17 +819,18 @@ class PipelineRun:
async def _wake_word_audio_stream(
self,
audio_stream: AsyncIterable[EnhancedAudioChunk],
stt_audio_buffer: deque[EnhancedAudioChunk] | None,
audio_stream: AsyncIterable[ProcessedAudioChunk],
stt_audio_buffer: deque[ProcessedAudioChunk] | None,
wake_word_vad: VoiceActivityTimeout | None,
sample_rate: int = SAMPLE_RATE,
sample_width: int = SAMPLE_WIDTH,
sample_rate: int = 16000,
sample_width: int = 2,
) -> AsyncIterable[tuple[bytes, int]]:
"""Yield audio chunks with timestamps (milliseconds since start of stream).
Adds audio to a ring buffer that will be forwarded to speech-to-text after
detection. Times out if VAD detects enough silence.
"""
chunk_seconds = AUDIO_PROCESSOR_SAMPLES / sample_rate
async for chunk in audio_stream:
if self.abort_wake_word_detection:
raise WakeWordDetectionAborted
@@ -826,7 +845,6 @@ class PipelineRun:
stt_audio_buffer.append(chunk)
if wake_word_vad is not None:
chunk_seconds = (len(chunk.audio) // sample_width) / sample_rate
if not wake_word_vad.process(chunk_seconds, chunk.is_speech):
raise WakeWordTimeoutError(
code="wake-word-timeout", message="Wake word was not detected"
@@ -863,7 +881,7 @@ class PipelineRun:
async def speech_to_text(
self,
metadata: stt.SpeechMetadata,
stream: AsyncIterable[EnhancedAudioChunk],
stream: AsyncIterable[ProcessedAudioChunk],
) -> str:
"""Run speech-to-text portion of pipeline. Returns the spoken text."""
# Create a background task to prepare the conversation agent
@@ -898,9 +916,7 @@ class PipelineRun:
# Transcribe audio stream
stt_vad: VoiceCommandSegmenter | None = None
if self.audio_settings.is_vad_enabled:
stt_vad = VoiceCommandSegmenter(
silence_seconds=self.audio_settings.silence_seconds
)
stt_vad = VoiceCommandSegmenter()
result = await self.stt_provider.async_process_audio_stream(
metadata,
@@ -941,18 +957,18 @@ class PipelineRun:
async def _speech_to_text_stream(
self,
audio_stream: AsyncIterable[EnhancedAudioChunk],
audio_stream: AsyncIterable[ProcessedAudioChunk],
stt_vad: VoiceCommandSegmenter | None,
sample_rate: int = SAMPLE_RATE,
sample_width: int = SAMPLE_WIDTH,
sample_rate: int = 16000,
sample_width: int = 2,
) -> AsyncGenerator[bytes]:
"""Yield audio chunks until VAD detects silence or speech-to-text completes."""
chunk_seconds = AUDIO_PROCESSOR_SAMPLES / sample_rate
sent_vad_start = False
async for chunk in audio_stream:
self._capture_chunk(chunk.audio)
if stt_vad is not None:
chunk_seconds = (len(chunk.audio) // sample_width) / sample_rate
if not stt_vad.process(chunk_seconds, chunk.is_speech):
# Silence detected at the end of voice command
self.process_event(
@@ -1056,8 +1072,8 @@ class PipelineRun:
tts_options[tts.ATTR_PREFERRED_FORMAT] = self.tts_audio_output
if self.tts_audio_output == "wav":
# 16 Khz, 16-bit mono
tts_options[tts.ATTR_PREFERRED_SAMPLE_RATE] = SAMPLE_RATE
tts_options[tts.ATTR_PREFERRED_SAMPLE_CHANNELS] = SAMPLE_CHANNELS
tts_options[tts.ATTR_PREFERRED_SAMPLE_RATE] = 16000
tts_options[tts.ATTR_PREFERRED_SAMPLE_CHANNELS] = 1
try:
options_supported = await tts.async_support_options(
@@ -1202,31 +1218,53 @@ class PipelineRun:
self.debug_recording_thread = None
async def process_volume_only(
self, audio_stream: AsyncIterable[bytes]
) -> AsyncGenerator[EnhancedAudioChunk]:
self,
audio_stream: AsyncIterable[bytes],
sample_rate: int = 16000,
sample_width: int = 2,
) -> AsyncGenerator[ProcessedAudioChunk]:
"""Apply volume transformation only (no VAD/audio enhancements) with optional chunking."""
ms_per_sample = sample_rate // 1000
ms_per_chunk = (AUDIO_PROCESSOR_SAMPLES // sample_width) // ms_per_sample
timestamp_ms = 0
async for chunk in audio_stream:
if self.audio_settings.volume_multiplier != 1.0:
chunk = _multiply_volume(chunk, self.audio_settings.volume_multiplier)
for sub_chunk in chunk_samples(
chunk, BYTES_PER_CHUNK, self.audio_chunking_buffer
):
yield EnhancedAudioChunk(
audio=sub_chunk,
if self.audio_settings.is_chunking_enabled:
# 10 ms chunking
for chunk_10ms in chunk_samples(
chunk, AUDIO_PROCESSOR_BYTES, self.audio_processor_buffer
):
yield ProcessedAudioChunk(
audio=chunk_10ms,
timestamp_ms=timestamp_ms,
is_speech=None, # no VAD
)
timestamp_ms += ms_per_chunk
else:
# No chunking
yield ProcessedAudioChunk(
audio=chunk,
timestamp_ms=timestamp_ms,
is_speech=None, # no VAD
)
timestamp_ms += MS_PER_CHUNK
timestamp_ms += (len(chunk) // sample_width) // ms_per_sample
async def process_enhance_audio(
self, audio_stream: AsyncIterable[bytes]
) -> AsyncGenerator[EnhancedAudioChunk]:
"""Split audio into chunks and apply VAD/noise suppression/auto gain/volume transformation."""
assert self.audio_enhancer is not None
self,
audio_stream: AsyncIterable[bytes],
sample_rate: int = 16000,
sample_width: int = 2,
) -> AsyncGenerator[ProcessedAudioChunk]:
"""Split audio into 10 ms chunks and apply VAD/noise suppression/auto gain/volume transformation."""
assert self.audio_processor is not None
ms_per_sample = sample_rate // 1000
ms_per_chunk = (AUDIO_PROCESSOR_SAMPLES // sample_width) // ms_per_sample
timestamp_ms = 0
async for dirty_samples in audio_stream:
if self.audio_settings.volume_multiplier != 1.0:
# Static gain
@@ -1234,12 +1272,18 @@ class PipelineRun:
dirty_samples, self.audio_settings.volume_multiplier
)
# Split into chunks for audio enhancements/VAD
for dirty_chunk in chunk_samples(
dirty_samples, BYTES_PER_CHUNK, self.audio_chunking_buffer
# Split into 10ms chunks for audio enhancements/VAD
for dirty_10ms_chunk in chunk_samples(
dirty_samples, AUDIO_PROCESSOR_BYTES, self.audio_processor_buffer
):
yield self.audio_enhancer.enhance_chunk(dirty_chunk, timestamp_ms)
timestamp_ms += MS_PER_CHUNK
ap_result = self.audio_processor.Process10ms(dirty_10ms_chunk)
yield ProcessedAudioChunk(
audio=ap_result.audio,
timestamp_ms=timestamp_ms,
is_speech=ap_result.is_speech,
)
timestamp_ms += ms_per_chunk
def _multiply_volume(chunk: bytes, volume_multiplier: float) -> bytes:
@@ -1279,9 +1323,9 @@ def _pipeline_debug_recording_thread_proc(
wav_path = run_recording_dir / f"{message}.wav"
wav_writer = wave.open(str(wav_path), "wb")
wav_writer.setframerate(SAMPLE_RATE)
wav_writer.setsampwidth(SAMPLE_WIDTH)
wav_writer.setnchannels(SAMPLE_CHANNELS)
wav_writer.setframerate(16000)
wav_writer.setsampwidth(2)
wav_writer.setnchannels(1)
elif isinstance(message, bytes):
# Chunk of 16-bit mono audio at 16Khz
if wav_writer is not None:
@@ -1324,8 +1368,8 @@ class PipelineInput:
"""Run pipeline."""
self.run.start(device_id=self.device_id)
current_stage: PipelineStage | None = self.run.start_stage
stt_audio_buffer: list[EnhancedAudioChunk] = []
stt_processed_stream: AsyncIterable[EnhancedAudioChunk] | None = None
stt_audio_buffer: list[ProcessedAudioChunk] = []
stt_processed_stream: AsyncIterable[ProcessedAudioChunk] | None = None
if self.stt_stream is not None:
if self.run.audio_settings.needs_processor:
@@ -1379,7 +1423,7 @@ class PipelineInput:
# Send audio in the buffer first to speech-to-text, then move on to stt_stream.
# This is basically an async itertools.chain.
async def buffer_then_audio_stream() -> (
AsyncGenerator[EnhancedAudioChunk]
AsyncGenerator[ProcessedAudioChunk]
):
# Buffered audio
for chunk in stt_audio_buffer:

View File

@@ -2,11 +2,12 @@
from __future__ import annotations
from collections.abc import Callable, Iterable
from abc import ABC, abstractmethod
from collections.abc import Iterable
from dataclasses import dataclass
from enum import StrEnum
import logging
from typing import Final
from typing import Final, cast
_LOGGER = logging.getLogger(__name__)
@@ -34,6 +35,44 @@ class VadSensitivity(StrEnum):
return 1.0
class VoiceActivityDetector(ABC):
"""Base class for voice activity detectors (VAD)."""
@abstractmethod
def is_speech(self, chunk: bytes) -> bool:
"""Return True if audio chunk contains speech."""
@property
@abstractmethod
def samples_per_chunk(self) -> int | None:
"""Return number of samples per chunk or None if chunking is not required."""
class WebRtcVad(VoiceActivityDetector):
"""Voice activity detector based on webrtc."""
def __init__(self) -> None:
"""Initialize webrtcvad."""
# Delay import of webrtc so HA start up is not crashing
# on older architectures (armhf).
#
# pylint: disable=import-outside-toplevel
from webrtc_noise_gain import AudioProcessor
# Just VAD: no noise suppression or auto gain
self._audio_processor = AudioProcessor(0, 0)
def is_speech(self, chunk: bytes) -> bool:
"""Return True if audio chunk contains speech."""
result = self._audio_processor.Process10ms(chunk)
return cast(bool, result.is_speech)
@property
def samples_per_chunk(self) -> int | None:
"""Return 10 ms."""
return int(0.01 * _SAMPLE_RATE) # 10 ms
class AudioBuffer:
"""Fixed-sized audio buffer with variable internal length."""
@@ -80,7 +119,7 @@ class VoiceCommandSegmenter:
speech_seconds: float = 0.3
"""Seconds of speech before voice command has started."""
silence_seconds: float = 1.0
silence_seconds: float = 0.5
"""Seconds of silence after voice command has ended."""
timeout_seconds: float = 15.0
@@ -137,38 +176,29 @@ class VoiceCommandSegmenter:
if self._speech_seconds_left <= 0:
# Inside voice command
self.in_command = True
self._silence_seconds_left = self.silence_seconds
_LOGGER.debug("Voice command started")
else:
# Reset if enough silence
self._reset_seconds_left -= chunk_seconds
if self._reset_seconds_left <= 0:
self._speech_seconds_left = self.speech_seconds
self._reset_seconds_left = self.reset_seconds
elif not is_speech:
# Silence in command
self._reset_seconds_left = self.reset_seconds
self._silence_seconds_left -= chunk_seconds
if self._silence_seconds_left <= 0:
# Command finished successfully
self.reset()
_LOGGER.debug("Voice command finished")
return False
else:
# Speech in command.
# Reset silence counter if enough speech.
# Reset if enough speech
self._reset_seconds_left -= chunk_seconds
if self._reset_seconds_left <= 0:
self._silence_seconds_left = self.silence_seconds
self._reset_seconds_left = self.reset_seconds
return True
def process_with_vad(
self,
chunk: bytes,
vad_samples_per_chunk: int | None,
vad_is_speech: Callable[[bytes], bool],
vad: VoiceActivityDetector,
leftover_chunk_buffer: AudioBuffer | None,
) -> bool:
"""Process an audio chunk using an external VAD.
@@ -177,20 +207,20 @@ class VoiceCommandSegmenter:
Returns False when voice command is finished.
"""
if vad_samples_per_chunk is None:
if vad.samples_per_chunk is None:
# No chunking
chunk_seconds = (len(chunk) // _SAMPLE_WIDTH) / _SAMPLE_RATE
is_speech = vad_is_speech(chunk)
is_speech = vad.is_speech(chunk)
return self.process(chunk_seconds, is_speech)
if leftover_chunk_buffer is None:
raise ValueError("leftover_chunk_buffer is required when vad uses chunking")
# With chunking
seconds_per_chunk = vad_samples_per_chunk / _SAMPLE_RATE
bytes_per_chunk = vad_samples_per_chunk * _SAMPLE_WIDTH
seconds_per_chunk = vad.samples_per_chunk / _SAMPLE_RATE
bytes_per_chunk = vad.samples_per_chunk * _SAMPLE_WIDTH
for vad_chunk in chunk_samples(chunk, bytes_per_chunk, leftover_chunk_buffer):
is_speech = vad_is_speech(vad_chunk)
is_speech = vad.is_speech(vad_chunk)
if not self.process(seconds_per_chunk, is_speech):
return False

View File

@@ -24,9 +24,6 @@ from .const import (
DEFAULT_WAKE_WORD_TIMEOUT,
DOMAIN,
EVENT_RECORDING,
SAMPLE_CHANNELS,
SAMPLE_RATE,
SAMPLE_WIDTH,
)
from .error import PipelineNotFound
from .pipeline import (
@@ -95,6 +92,7 @@ def async_register_websocket_api(hass: HomeAssistant) -> None:
vol.Optional("volume_multiplier"): float,
# Advanced use cases/testing
vol.Optional("no_vad"): bool,
vol.Optional("no_chunking"): bool,
}
},
extra=vol.ALLOW_EXTRA,
@@ -172,14 +170,9 @@ async def websocket_run(
# Yield until we receive an empty chunk
while chunk := await audio_queue.get():
if incoming_sample_rate != SAMPLE_RATE:
if incoming_sample_rate != 16000:
chunk, state = audioop.ratecv(
chunk,
SAMPLE_WIDTH,
SAMPLE_CHANNELS,
incoming_sample_rate,
SAMPLE_RATE,
state,
chunk, 2, 1, incoming_sample_rate, 16000, state
)
yield chunk
@@ -213,6 +206,7 @@ async def websocket_run(
auto_gain_dbfs=msg_input.get("auto_gain_dbfs", 0),
volume_multiplier=msg_input.get("volume_multiplier", 1.0),
is_vad_enabled=not msg_input.get("no_vad", False),
is_chunking_enabled=not msg_input.get("no_chunking", False),
)
elif start_stage == PipelineStage.INTENT:
# Input to conversation agent
@@ -430,9 +424,9 @@ def websocket_list_languages(
connection.send_result(
msg["id"],
{
"languages": (
sorted(pipeline_languages) if pipeline_languages else pipeline_languages
)
"languages": sorted(pipeline_languages)
if pipeline_languages
else pipeline_languages
},
)

View File

@@ -16,6 +16,8 @@ NOTIFICATION_TITLE = "August"
MANUFACTURER = "August Home Inc."
DEFAULT_AUGUST_CONFIG_FILE = ".august.conf"
DEFAULT_NAME = "August"
DOMAIN = "august"

View File

@@ -94,7 +94,7 @@ async def fetch_redirect_uris(hass: HomeAssistant, url: str) -> list[str]:
try:
async with (
aiohttp.ClientSession() as session,
session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp,
session.get(url, timeout=5) as resp,
):
async for data in resp.content.iter_chunked(1024):
parser.feed(data.decode())

View File

@@ -7,7 +7,7 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from .const import PLATFORMS
from .const import DOMAIN as AXIS_DOMAIN, PLATFORMS
from .errors import AuthenticationRequired, CannotConnect
from .hub import AxisHub, get_axis_api
@@ -18,6 +18,8 @@ type AxisConfigEntry = ConfigEntry[AxisHub]
async def async_setup_entry(hass: HomeAssistant, config_entry: AxisConfigEntry) -> bool:
"""Set up the Axis integration."""
hass.data.setdefault(AXIS_DOMAIN, {})
try:
api = await get_axis_api(hass, config_entry.data)
except CannotConnect as err:

View File

@@ -135,15 +135,6 @@ class BangOlufsenConfigFlowHandler(ConfigFlow, domain=DOMAIN):
except AddressValueError:
return self.async_abort(reason="ipv6_address")
# Check connection to ensure valid address is received
self._client = MozartClient(self._host)
async with self._client:
try:
await self._client.get_beolink_self(_request_timeout=3)
except (ClientConnectorError, TimeoutError):
return self.async_abort(reason="invalid_address")
self._model = discovery_info.hostname[:-16].replace("-", " ")
self._serial_number = discovery_info.properties[ATTR_SERIAL_NUMBER]
self._beolink_jid = f"{discovery_info.properties[ATTR_TYPE_NUMBER]}.{discovery_info.properties[ATTR_ITEM_NUMBER]}.{self._serial_number}@products.bang-olufsen.com"

View File

@@ -245,36 +245,14 @@ async def fetch_blueprint_from_website_url(
return ImportedBlueprint(suggested_filename, raw_yaml, blueprint)
async def fetch_blueprint_from_generic_url(
hass: HomeAssistant, url: str
) -> ImportedBlueprint:
"""Get a blueprint from a generic website."""
session = aiohttp_client.async_get_clientsession(hass)
resp = await session.get(url, raise_for_status=True)
raw_yaml = await resp.text()
data = yaml.parse_yaml(raw_yaml)
assert isinstance(data, dict)
blueprint = Blueprint(data)
parsed_import_url = yarl.URL(url)
suggested_filename = f"{parsed_import_url.host}/{parsed_import_url.parts[-1][:-5]}"
return ImportedBlueprint(suggested_filename, raw_yaml, blueprint)
FETCH_FUNCTIONS = (
fetch_blueprint_from_community_post,
fetch_blueprint_from_github_url,
fetch_blueprint_from_github_gist_url,
fetch_blueprint_from_website_url,
fetch_blueprint_from_generic_url,
)
async def fetch_blueprint_from_url(hass: HomeAssistant, url: str) -> ImportedBlueprint:
"""Get a blueprint from a url."""
for func in FETCH_FUNCTIONS:
for func in (
fetch_blueprint_from_community_post,
fetch_blueprint_from_github_url,
fetch_blueprint_from_github_gist_url,
fetch_blueprint_from_website_url,
):
with suppress(UnsupportedUrl):
imported_bp = await func(hass, url)
imported_bp.blueprint.update_metadata(source_url=url)

View File

@@ -21,7 +21,7 @@ from homeassistant.const import (
CONF_PATH,
__version__,
)
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback
from homeassistant.core import DOMAIN as HA_DOMAIN, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.util import yaml
@@ -372,7 +372,7 @@ class DomainBlueprints:
shutil.copytree(
integration.file_path / BLUEPRINT_FOLDER,
self.blueprint_folder / HOMEASSISTANT_DOMAIN,
self.blueprint_folder / HA_DOMAIN,
)
await self.hass.async_add_executor_job(populate)

View File

@@ -1,70 +1 @@
"""The bluesound component."""
from dataclasses import dataclass
import aiohttp
from pyblu import Player, SyncStatus
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PORT, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN
from .media_player import setup_services
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS = [Platform.MEDIA_PLAYER]
@dataclass
class BluesoundData:
"""Bluesound data class."""
player: Player
sync_status: SyncStatus
type BluesoundConfigEntry = ConfigEntry[BluesoundData]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Bluesound."""
if DOMAIN not in hass.data:
hass.data[DOMAIN] = []
setup_services(hass)
return True
async def async_setup_entry(
hass: HomeAssistant, config_entry: BluesoundConfigEntry
) -> bool:
"""Set up the Bluesound entry."""
host = config_entry.data[CONF_HOST]
port = config_entry.data[CONF_PORT]
session = async_get_clientsession(hass)
async with Player(host, port, session=session, default_timeout=10) as player:
try:
sync_status = await player.sync_status(timeout=1)
except TimeoutError as ex:
raise ConfigEntryNotReady(
f"Timeout while connecting to {host}:{port}"
) from ex
except aiohttp.ClientError as ex:
raise ConfigEntryNotReady(f"Error connecting to {host}:{port}") from ex
config_entry.runtime_data = BluesoundData(player, sync_status)
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)

View File

@@ -1,150 +0,0 @@
"""Config flow for bluesound."""
import logging
from typing import Any
import aiohttp
from pyblu import Player, SyncStatus
import voluptuous as vol
from homeassistant.components import zeroconf
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
from .media_player import DEFAULT_PORT
from .utils import format_unique_id
_LOGGER = logging.getLogger(__name__)
class BluesoundConfigFlow(ConfigFlow, domain=DOMAIN):
"""Bluesound config flow."""
VERSION = 1
MINOR_VERSION = 1
def __init__(self) -> None:
"""Initialize the config flow."""
self._host: str | None = None
self._port = DEFAULT_PORT
self._sync_status: SyncStatus | None = None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a flow initiated by the user."""
errors: dict[str, str] = {}
if user_input is not None:
session = async_get_clientsession(self.hass)
async with Player(
user_input[CONF_HOST], user_input[CONF_PORT], session=session
) as player:
try:
sync_status = await player.sync_status(timeout=1)
except (TimeoutError, aiohttp.ClientError):
errors["base"] = "cannot_connect"
else:
await self.async_set_unique_id(
format_unique_id(sync_status.mac, user_input[CONF_PORT])
)
self._abort_if_unique_id_configured(
updates={
CONF_HOST: user_input[CONF_HOST],
}
)
return self.async_create_entry(
title=sync_status.name,
data=user_input,
)
return self.async_show_form(
step_id="user",
errors=errors,
data_schema=vol.Schema(
{
vol.Required(CONF_HOST): str,
vol.Optional(CONF_PORT, default=11000): int,
}
),
)
async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
"""Import bluesound config entry from configuration.yaml."""
session = async_get_clientsession(self.hass)
async with Player(
import_data[CONF_HOST], import_data[CONF_PORT], session=session
) as player:
try:
sync_status = await player.sync_status(timeout=1)
except (TimeoutError, aiohttp.ClientError):
return self.async_abort(reason="cannot_connect")
await self.async_set_unique_id(
format_unique_id(sync_status.mac, import_data[CONF_PORT])
)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=sync_status.name,
data=import_data,
)
async def async_step_zeroconf(
self, discovery_info: zeroconf.ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle a flow initialized by zeroconf discovery."""
if discovery_info.port is not None:
self._port = discovery_info.port
session = async_get_clientsession(self.hass)
try:
async with Player(
discovery_info.host, self._port, session=session
) as player:
sync_status = await player.sync_status(timeout=1)
except (TimeoutError, aiohttp.ClientError):
return self.async_abort(reason="cannot_connect")
await self.async_set_unique_id(format_unique_id(sync_status.mac, self._port))
self._host = discovery_info.host
self._sync_status = sync_status
self._abort_if_unique_id_configured(
updates={
CONF_HOST: self._host,
}
)
self.context.update(
{
"title_placeholders": {"name": sync_status.name},
"configuration_url": f"http://{discovery_info.host}",
}
)
return await self.async_step_confirm()
async def async_step_confirm(self, user_input=None) -> ConfigFlowResult:
"""Confirm the zeroconf setup."""
assert self._sync_status is not None
assert self._host is not None
if user_input is not None:
return self.async_create_entry(
title=self._sync_status.name,
data={
CONF_HOST: self._host,
CONF_PORT: self._port,
},
)
return self.async_show_form(
step_id="confirm",
description_placeholders={
"name": self._sync_status.name,
"host": self._host,
},
)

View File

@@ -1,10 +1,7 @@
"""Constants for the Bluesound HiFi wireless speakers and audio integrations component."""
DOMAIN = "bluesound"
INTEGRATION_TITLE = "Bluesound"
SERVICE_CLEAR_TIMER = "clear_sleep_timer"
SERVICE_JOIN = "join"
SERVICE_SET_TIMER = "set_sleep_timer"
SERVICE_UNJOIN = "unjoin"
ATTR_BLUESOUND_GROUP = "bluesound_group"
ATTR_MASTER = "master"

View File

@@ -1,15 +1,8 @@
{
"domain": "bluesound",
"name": "Bluesound",
"after_dependencies": ["zeroconf"],
"codeowners": ["@thrawnarn", "@LouisChrist"],
"config_flow": true,
"codeowners": ["@thrawnarn"],
"documentation": "https://www.home-assistant.io/integrations/bluesound",
"iot_class": "local_polling",
"requirements": ["pyblu==0.4.0"],
"zeroconf": [
{
"type": "_musc._tcp.local."
}
]
"requirements": ["pyblu==0.4.0"]
}

View File

@@ -3,11 +3,11 @@
from __future__ import annotations
import asyncio
from asyncio import CancelledError, Task
from asyncio import CancelledError
from contextlib import suppress
from datetime import datetime, timedelta
import logging
from typing import TYPE_CHECKING, Any, NamedTuple
from typing import Any, NamedTuple
from aiohttp.client_exceptions import ClientError
from pyblu import Input, Player, Preset, Status, SyncStatus
@@ -23,50 +23,40 @@ from homeassistant.components.media_player import (
MediaType,
async_process_play_media_url,
)
from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import (
ATTR_ENTITY_ID,
CONF_HOST,
CONF_HOSTS,
CONF_NAME,
CONF_PORT,
EVENT_HOMEASSISTANT_START,
EVENT_HOMEASSISTANT_STOP,
)
from homeassistant.core import (
DOMAIN as HOMEASSISTANT_DOMAIN,
HomeAssistant,
ServiceCall,
)
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv, issue_registry as ir
from homeassistant.helpers.device_registry import (
CONNECTION_NETWORK_MAC,
DeviceInfo,
format_mac,
)
from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import Throttle
import homeassistant.util.dt as dt_util
from .const import (
ATTR_BLUESOUND_GROUP,
ATTR_MASTER,
DOMAIN,
INTEGRATION_TITLE,
SERVICE_CLEAR_TIMER,
SERVICE_JOIN,
SERVICE_SET_TIMER,
SERVICE_UNJOIN,
)
from .utils import format_unique_id
if TYPE_CHECKING:
from . import BluesoundConfigEntry
_LOGGER = logging.getLogger(__name__)
DATA_BLUESOUND = DOMAIN
ATTR_BLUESOUND_GROUP = "bluesound_group"
ATTR_MASTER = "master"
DATA_BLUESOUND = "bluesound"
DEFAULT_PORT = 11000
NODE_OFFLINE_CHECK_TIMEOUT = 180
@@ -93,10 +83,6 @@ PLATFORM_SCHEMA = MEDIA_PLAYER_PLATFORM_SCHEMA.extend(
}
)
BS_SCHEMA = vol.Schema({vol.Optional(ATTR_ENTITY_ID): cv.entity_ids})
BS_JOIN_SCHEMA = BS_SCHEMA.extend({vol.Required(ATTR_MASTER): cv.entity_id})
class ServiceMethodDetails(NamedTuple):
"""Details for SERVICE_TO_METHOD mapping."""
@@ -105,6 +91,10 @@ class ServiceMethodDetails(NamedTuple):
schema: vol.Schema
BS_SCHEMA = vol.Schema({vol.Optional(ATTR_ENTITY_ID): cv.entity_ids})
BS_JOIN_SCHEMA = BS_SCHEMA.extend({vol.Required(ATTR_MASTER): cv.entity_id})
SERVICE_TO_METHOD = {
SERVICE_JOIN: ServiceMethodDetails(method="async_join", schema=BS_JOIN_SCHEMA),
SERVICE_UNJOIN: ServiceMethodDetails(method="async_unjoin", schema=BS_SCHEMA),
@@ -117,51 +107,78 @@ SERVICE_TO_METHOD = {
}
async def _async_import(hass: HomeAssistant, config: ConfigType) -> None:
"""Import config entry from configuration.yaml."""
if not hass.config_entries.async_entries(DOMAIN):
# Start import flow
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=config
)
if (
result["type"] == FlowResultType.ABORT
and result["reason"] == "cannot_connect"
):
ir.async_create_issue(
hass,
DOMAIN,
f"deprecated_yaml_import_issue_{result['reason']}",
breaks_in_ha_version="2025.2.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=ir.IssueSeverity.WARNING,
translation_key=f"deprecated_yaml_import_issue_{result['reason']}",
translation_placeholders={
"domain": DOMAIN,
"integration_title": INTEGRATION_TITLE,
},
)
def _add_player(hass: HomeAssistant, async_add_entities, host, port=None, name=None):
"""Add Bluesound players."""
@callback
def _init_player(event=None):
"""Start polling."""
hass.async_create_task(player.async_init())
@callback
def _start_polling(event=None):
"""Start polling."""
player.start_polling()
@callback
def _stop_polling(event=None):
"""Stop polling."""
player.stop_polling()
@callback
def _add_player_cb():
"""Add player after first sync fetch."""
if player.id in [x.id for x in hass.data[DATA_BLUESOUND]]:
_LOGGER.warning("Player already added %s", player.id)
return
ir.async_create_issue(
hass,
HOMEASSISTANT_DOMAIN,
f"deprecated_yaml_{DOMAIN}",
breaks_in_ha_version="2025.2.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=ir.IssueSeverity.WARNING,
translation_key="deprecated_yaml",
translation_placeholders={
"domain": DOMAIN,
"integration_title": INTEGRATION_TITLE,
},
)
hass.data[DATA_BLUESOUND].append(player)
async_add_entities([player])
_LOGGER.info("Added device with name: %s", player.name)
if hass.is_running:
_start_polling()
else:
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _start_polling)
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_polling)
player = BluesoundPlayer(hass, host, port, name, _add_player_cb)
if hass.is_running:
_init_player()
else:
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _init_player)
def setup_services(hass: HomeAssistant) -> None:
"""Set up services for Bluesound component."""
async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the Bluesound platforms."""
if DATA_BLUESOUND not in hass.data:
hass.data[DATA_BLUESOUND] = []
if discovery_info:
_add_player(
hass,
async_add_entities,
discovery_info.get(CONF_HOST),
discovery_info.get(CONF_PORT),
)
return
if hosts := config.get(CONF_HOSTS):
for host in hosts:
_add_player(
hass,
async_add_entities,
host.get(CONF_HOST),
host.get(CONF_PORT),
host.get(CONF_NAME),
)
async def async_service_handler(service: ServiceCall) -> None:
"""Map services to method of Bluesound devices."""
@@ -173,10 +190,12 @@ def setup_services(hass: HomeAssistant) -> None:
}
if entity_ids := service.data.get(ATTR_ENTITY_ID):
target_players = [
player for player in hass.data[DOMAIN] if player.entity_id in entity_ids
player
for player in hass.data[DATA_BLUESOUND]
if player.entity_id in entity_ids
]
else:
target_players = hass.data[DOMAIN]
target_players = hass.data[DATA_BLUESOUND]
for player in target_players:
await getattr(player, method.method)(**params)
@@ -187,92 +206,42 @@ def setup_services(hass: HomeAssistant) -> None:
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: BluesoundConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Bluesound entry."""
bluesound_player = BluesoundPlayer(
config_entry.data[CONF_HOST],
config_entry.data[CONF_PORT],
config_entry.runtime_data.player,
config_entry.runtime_data.sync_status,
)
hass.data[DATA_BLUESOUND].append(bluesound_player)
async_add_entities([bluesound_player])
async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None,
) -> None:
"""Trigger import flows."""
hosts = config.get(CONF_HOSTS, [])
for host in hosts:
import_data = {
CONF_HOST: host[CONF_HOST],
CONF_PORT: host.get(CONF_PORT, 11000),
}
hass.async_create_task(_async_import(hass, import_data))
class BluesoundPlayer(MediaPlayerEntity):
"""Representation of a Bluesound Player."""
_attr_media_content_type = MediaType.MUSIC
_attr_has_entity_name = True
_attr_name = None
def __init__(
self,
host: str,
port: int,
player: Player,
sync_status: SyncStatus,
self, hass: HomeAssistant, host, port=None, name=None, init_callback=None
) -> None:
"""Initialize the media player."""
self.host = host
self._hass = hass
self.port = port
self._polling_task: Task[None] | None = None # The actual polling task.
self._id = sync_status.id
self._polling_task = None # The actual polling task.
self._name = name
self._id = None
self._last_status_update = None
self._sync_status = sync_status
self._sync_status: SyncStatus | None = None
self._status: Status | None = None
self._inputs: list[Input] = []
self._presets: list[Preset] = []
self._is_online = False
self._retry_remove = None
self._muted = False
self._master: BluesoundPlayer | None = None
self._is_master = False
self._group_name = None
self._group_list: list[str] = []
self._bluesound_device_name = sync_status.name
self._player = player
self._bluesound_device_name = None
self._player = Player(
host, port, async_get_clientsession(hass), default_timeout=10
)
self._attr_unique_id = format_unique_id(sync_status.mac, port)
# there should always be one player with the default port per mac
if port == DEFAULT_PORT:
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, format_mac(sync_status.mac))},
connections={(CONNECTION_NETWORK_MAC, format_mac(sync_status.mac))},
name=sync_status.name,
manufacturer=sync_status.brand,
model=sync_status.model_name,
model_id=sync_status.model,
)
else:
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, format_unique_id(sync_status.mac, port))},
name=sync_status.name,
manufacturer=sync_status.brand,
model=sync_status.model_name,
model_id=sync_status.model,
via_device=(DOMAIN, format_mac(sync_status.mac)),
)
self._init_callback = init_callback
if self.port is None:
self.port = DEFAULT_PORT
@staticmethod
def _try_get_index(string, search_string):
@@ -282,18 +251,25 @@ class BluesoundPlayer(MediaPlayerEntity):
except ValueError:
return -1
async def force_update_sync_status(self) -> bool:
async def force_update_sync_status(self, on_updated_cb=None) -> bool:
"""Update the internal status."""
sync_status = await self._player.sync_status()
self._sync_status = sync_status
if not self._name:
self._name = sync_status.name if sync_status.name else self.host
if not self._id:
self._id = sync_status.id
if not self._bluesound_device_name:
self._bluesound_device_name = self._name
if sync_status.master is not None:
self._is_master = False
master_id = f"{sync_status.master.ip}:{sync_status.master.port}"
master_device = [
device
for device in self.hass.data[DATA_BLUESOUND]
for device in self._hass.data[DATA_BLUESOUND]
if device.id == master_id
]
@@ -308,6 +284,8 @@ class BluesoundPlayer(MediaPlayerEntity):
slaves = self._sync_status.slaves
self._is_master = slaves is not None
if on_updated_cb:
on_updated_cb()
return True
async def _start_poll_command(self):
@@ -317,34 +295,42 @@ class BluesoundPlayer(MediaPlayerEntity):
await self.async_update_status()
except (TimeoutError, ClientError):
_LOGGER.error("Node %s:%s is offline, retrying later", self.host, self.port)
_LOGGER.info("Node %s:%s is offline, retrying later", self.name, self.port)
await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT)
self.start_polling()
except CancelledError:
_LOGGER.debug("Stopping the polling of node %s:%s", self.host, self.port)
_LOGGER.debug("Stopping the polling of node %s:%s", self.name, self.port)
except Exception:
_LOGGER.exception("Unexpected error in %s:%s", self.host, self.port)
_LOGGER.exception("Unexpected error in %s:%s", self.name, self.port)
raise
async def async_added_to_hass(self) -> None:
def start_polling(self):
"""Start the polling task."""
await super().async_added_to_hass()
self._polling_task = self._hass.async_create_task(self._start_poll_command())
self._polling_task = self.hass.async_create_background_task(
self._start_poll_command(),
name=f"bluesound.polling_{self.host}:{self.port}",
)
async def async_will_remove_from_hass(self) -> None:
def stop_polling(self):
"""Stop the polling task."""
await super().async_will_remove_from_hass()
self._polling_task.cancel()
assert self._polling_task is not None
if self._polling_task.cancel():
await self._polling_task
async def async_init(self, triggered=None):
"""Initialize the player async."""
try:
if self._retry_remove is not None:
self._retry_remove()
self._retry_remove = None
self.hass.data[DATA_BLUESOUND].remove(self)
await self.force_update_sync_status(self._init_callback)
except (TimeoutError, ClientError):
_LOGGER.info("Node %s:%s is offline, retrying later", self.host, self.port)
self._retry_remove = async_track_time_interval(
self._hass, self.async_init, NODE_RETRY_INITIATION
)
except Exception:
_LOGGER.exception(
"Unexpected when initiating error in %s:%s", self.host, self.port
)
raise
async def async_update(self) -> None:
"""Update internal status of the entity."""
@@ -398,23 +384,26 @@ class BluesoundPlayer(MediaPlayerEntity):
self._last_status_update = None
self._status = None
self.async_write_ha_state()
_LOGGER.error(
"Client connection error, marking %s as offline",
self._bluesound_device_name,
)
_LOGGER.info("Client connection error, marking %s as offline", self._name)
raise
@property
def unique_id(self) -> str | None:
"""Return an unique ID."""
assert self._sync_status is not None
return f"{format_mac(self._sync_status.mac)}-{self.port}"
async def async_trigger_sync_on_all(self):
"""Trigger sync status update on all devices."""
_LOGGER.debug("Trigger sync status on all devices")
for player in self.hass.data[DATA_BLUESOUND]:
for player in self._hass.data[DATA_BLUESOUND]:
await player.force_update_sync_status()
@Throttle(SYNC_STATUS_INTERVAL)
async def async_update_sync_status(self):
async def async_update_sync_status(self, on_updated_cb=None):
"""Update sync status."""
await self.force_update_sync_status()
await self.force_update_sync_status(on_updated_cb)
@Throttle(UPDATE_CAPTURE_INTERVAL)
async def async_update_captures(self) -> list[Input] | None:
@@ -533,7 +522,7 @@ class BluesoundPlayer(MediaPlayerEntity):
if self._status is not None:
volume = self._status.volume
if self.is_grouped:
if self.is_grouped and self._sync_status is not None:
volume = self._sync_status.volume
if volume is None:
@@ -548,7 +537,7 @@ class BluesoundPlayer(MediaPlayerEntity):
if self._status is not None:
mute = self._status.mute
if self.is_grouped:
if self.is_grouped and self._sync_status is not None:
mute = self._sync_status.mute_volume is not None
return mute
@@ -558,6 +547,11 @@ class BluesoundPlayer(MediaPlayerEntity):
"""Get id of device."""
return self._id
@property
def name(self) -> str | None:
"""Return the name of the device."""
return self._name
@property
def bluesound_device_name(self) -> str | None:
"""Return the device name as returned by the device."""
@@ -696,7 +690,7 @@ class BluesoundPlayer(MediaPlayerEntity):
device_group = self._group_name.split("+")
sorted_entities = sorted(
self.hass.data[DATA_BLUESOUND],
self._hass.data[DATA_BLUESOUND],
key=lambda entity: entity.is_master,
reverse=True,
)

View File

@@ -1,30 +1,4 @@
{
"config": {
"step": {
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
"port": "[%key:common::config_flow::data::port%]"
},
"data_description": {
"host": "Hostname or IP address of your Bluesound player",
"port": "Port of your Bluesound player. This is usually 11000."
}
},
"confirm": {
"title": "Discover Bluesound player",
"description": "[%key:common::config_flow::description::confirm_setup%]"
}
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
}
},
"services": {
"join": {
"name": "Join",

View File

@@ -1,8 +0,0 @@
"""Utility functions for the Bluesound component."""
from homeassistant.helpers.device_registry import format_mac
def format_unique_id(mac: str, port: int) -> str:
"""Generate a unique ID based on the MAC address and port number."""
return f"{format_mac(mac)}-{port}"

View File

@@ -18,7 +18,7 @@
"bleak-retry-connector==3.5.0",
"bluetooth-adapters==0.19.3",
"bluetooth-auto-recovery==1.4.2",
"bluetooth-data-tools==1.19.4",
"bluetooth-data-tools==1.19.3",
"dbus-fast==2.22.1",
"habluetooth==3.1.3"
]

View File

@@ -2,7 +2,6 @@
from __future__ import annotations
from collections import defaultdict
from dataclasses import dataclass
from fnmatch import translate
from functools import lru_cache
@@ -174,10 +173,10 @@ class BluetoothMatcherIndexBase[
def __init__(self) -> None:
"""Initialize the matcher index."""
self.local_name: defaultdict[str, list[_T]] = defaultdict(list)
self.service_uuid: defaultdict[str, list[_T]] = defaultdict(list)
self.service_data_uuid: defaultdict[str, list[_T]] = defaultdict(list)
self.manufacturer_id: defaultdict[int, list[_T]] = defaultdict(list)
self.local_name: dict[str, list[_T]] = {}
self.service_uuid: dict[str, list[_T]] = {}
self.service_data_uuid: dict[str, list[_T]] = {}
self.manufacturer_id: dict[int, list[_T]] = {}
self.service_uuid_set: set[str] = set()
self.service_data_uuid_set: set[str] = set()
self.manufacturer_id_set: set[int] = set()
@@ -191,22 +190,26 @@ class BluetoothMatcherIndexBase[
"""
# Local name is the cheapest to match since its just a dict lookup
if LOCAL_NAME in matcher:
self.local_name[_local_name_to_index_key(matcher[LOCAL_NAME])].append(
matcher
)
self.local_name.setdefault(
_local_name_to_index_key(matcher[LOCAL_NAME]), []
).append(matcher)
return True
# Manufacturer data is 2nd cheapest since its all ints
if MANUFACTURER_ID in matcher:
self.manufacturer_id[matcher[MANUFACTURER_ID]].append(matcher)
self.manufacturer_id.setdefault(matcher[MANUFACTURER_ID], []).append(
matcher
)
return True
if SERVICE_UUID in matcher:
self.service_uuid[matcher[SERVICE_UUID]].append(matcher)
self.service_uuid.setdefault(matcher[SERVICE_UUID], []).append(matcher)
return True
if SERVICE_DATA_UUID in matcher:
self.service_data_uuid[matcher[SERVICE_DATA_UUID]].append(matcher)
self.service_data_uuid.setdefault(matcher[SERVICE_DATA_UUID], []).append(
matcher
)
return True
return False
@@ -257,38 +260,32 @@ class BluetoothMatcherIndexBase[
if ble_device_matches(matcher, service_info)
)
if (
(service_data_uuid_set := self.service_data_uuid_set)
and (service_data := service_info.service_data)
and (matched_uuids := service_data_uuid_set.intersection(service_data))
):
if self.service_data_uuid_set and service_info.service_data:
matches.extend(
matcher
for service_data_uuid in matched_uuids
for service_data_uuid in self.service_data_uuid_set.intersection(
service_info.service_data
)
for matcher in self.service_data_uuid[service_data_uuid]
if ble_device_matches(matcher, service_info)
)
if (
(manufacturer_id_set := self.manufacturer_id_set)
and (manufacturer_data := service_info.manufacturer_data)
and (matched_ids := manufacturer_id_set.intersection(manufacturer_data))
):
if self.manufacturer_id_set and service_info.manufacturer_data:
matches.extend(
matcher
for manufacturer_id in matched_ids
for manufacturer_id in self.manufacturer_id_set.intersection(
service_info.manufacturer_data
)
for matcher in self.manufacturer_id[manufacturer_id]
if ble_device_matches(matcher, service_info)
)
if (
(service_uuid_set := self.service_uuid_set)
and (service_uuids := service_info.service_uuids)
and (matched_uuids := service_uuid_set.intersection(service_uuids))
):
if self.service_uuid_set and service_info.service_uuids:
matches.extend(
matcher
for service_uuid in matched_uuids
for service_uuid in self.service_uuid_set.intersection(
service_info.service_uuids
)
for matcher in self.service_uuid[service_uuid]
if ble_device_matches(matcher, service_info)
)
@@ -313,9 +310,7 @@ class BluetoothCallbackMatcherIndex(
def __init__(self) -> None:
"""Initialize the matcher index."""
super().__init__()
self.address: defaultdict[str, list[BluetoothCallbackMatcherWithCallback]] = (
defaultdict(list)
)
self.address: dict[str, list[BluetoothCallbackMatcherWithCallback]] = {}
self.connectable: list[BluetoothCallbackMatcherWithCallback] = []
def add_callback_matcher(
@@ -328,7 +323,7 @@ class BluetoothCallbackMatcherIndex(
We put them in the bucket that they are most likely to match.
"""
if ADDRESS in matcher:
self.address[matcher[ADDRESS]].append(matcher)
self.address.setdefault(matcher[ADDRESS], []).append(matcher)
return
if super().add(matcher):

View File

@@ -7,5 +7,5 @@
"iot_class": "cloud_polling",
"loggers": ["bimmer_connected"],
"quality_scale": "platinum",
"requirements": ["bimmer-connected[china]==0.16.1"]
"requirements": ["bimmer-connected[china]==0.15.3"]
}

View File

@@ -86,8 +86,7 @@
"name": "Charging Mode",
"state": {
"immediate_charging": "Immediate charging",
"delayed_charging": "Delayed charging",
"no_action": "No action"
"delayed_charging": "Delayed charging"
}
}
},

View File

@@ -4,8 +4,8 @@ from __future__ import annotations
import logging
from bring_api import (
Bring,
from bring_api.bring import Bring
from bring_api.exceptions import (
BringAuthException,
BringParseException,
BringRequestException,

View File

@@ -6,12 +6,9 @@ from collections.abc import Mapping
import logging
from typing import Any
from bring_api import (
Bring,
BringAuthException,
BringAuthResponse,
BringRequestException,
)
from bring_api.bring import Bring
from bring_api.exceptions import BringAuthException, BringRequestException
from bring_api.types import BringAuthResponse
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult

View File

@@ -5,8 +5,8 @@ from __future__ import annotations
from datetime import timedelta
import logging
from bring_api import (
Bring,
from bring_api.bring import Bring
from bring_api.exceptions import (
BringAuthException,
BringParseException,
BringRequestException,

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/bring",
"integration_type": "service",
"iot_class": "cloud_polling",
"requirements": ["bring-api==0.8.1"]
"requirements": ["bring-api==0.7.1"]
}

View File

@@ -5,12 +5,8 @@ from __future__ import annotations
from typing import TYPE_CHECKING
import uuid
from bring_api import (
BringItem,
BringItemOperation,
BringNotificationType,
BringRequestException,
)
from bring_api.exceptions import BringRequestException
from bring_api.types import BringItem, BringItemOperation, BringNotificationType
import voluptuous as vol
from homeassistant.components.todo import (

View File

@@ -1,6 +1,5 @@
"""Support for Broadlink climate devices."""
from enum import IntEnum
from typing import Any
from homeassistant.components.climate import (
@@ -20,14 +19,6 @@ from .device import BroadlinkDevice
from .entity import BroadlinkEntity
class SensorMode(IntEnum):
"""Thermostat sensor modes."""
INNER_SENSOR_CONTROL = 0
OUTER_SENSOR_CONTROL = 1
INNER_SENSOR_CONTROL_OUTER_LIMIT = 2
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
@@ -59,7 +50,6 @@ class BroadlinkThermostat(BroadlinkEntity, ClimateEntity):
super().__init__(device)
self._attr_unique_id = device.unique_id
self._attr_hvac_mode = None
self.sensor_mode = SensorMode.INNER_SENSOR_CONTROL
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
@@ -71,8 +61,6 @@ class BroadlinkThermostat(BroadlinkEntity, ClimateEntity):
@callback
def _update_state(self, data: dict[str, Any]) -> None:
"""Update data."""
if (sensor := data.get("sensor")) is not None:
self.sensor_mode = SensorMode(sensor)
if data.get("power"):
if data.get("auto_mode"):
self._attr_hvac_mode = HVACMode.AUTO
@@ -86,10 +74,8 @@ class BroadlinkThermostat(BroadlinkEntity, ClimateEntity):
else:
self._attr_hvac_mode = HVACMode.OFF
self._attr_hvac_action = HVACAction.OFF
if self.sensor_mode is SensorMode.OUTER_SENSOR_CONTROL:
self._attr_current_temperature = data.get("external_temp")
else:
self._attr_current_temperature = data.get("room_temp")
self._attr_current_temperature = data.get("room_temp")
self._attr_target_temperature = data.get("thermostat_temp")
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
@@ -99,9 +85,7 @@ class BroadlinkThermostat(BroadlinkEntity, ClimateEntity):
else:
await self._device.async_request(self._device.api.set_power, 1)
mode = 0 if hvac_mode == HVACMode.HEAT else 1
await self._device.async_request(
self._device.api.set_mode, mode, 0, self.sensor_mode.value
)
await self._device.async_request(self._device.api.set_mode, mode, 0)
self._attr_hvac_mode = hvac_mode
self.async_write_ha_state()

View File

@@ -1,84 +0,0 @@
"""The Bryant Evolution integration."""
from __future__ import annotations
import logging
from evolutionhttp import BryantEvolutionLocalClient
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_FILENAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from . import names
from .const import CONF_SYSTEM_ZONE, DOMAIN
PLATFORMS: list[Platform] = [Platform.CLIMATE]
type BryantEvolutionLocalClients = dict[tuple[int, int], BryantEvolutionLocalClient]
type BryantEvolutionConfigEntry = ConfigEntry[BryantEvolutionLocalClients]
_LOGGER = logging.getLogger(__name__)
async def _can_reach_device(client: BryantEvolutionLocalClient) -> bool:
"""Return whether we can reach the device at the given filename."""
# Verify that we can read current temperature to check that the
# (filename, system, zone) is valid.
return await client.read_current_temperature() is not None
async def async_setup_entry(
hass: HomeAssistant, entry: BryantEvolutionConfigEntry
) -> bool:
"""Set up Bryant Evolution from a config entry."""
# Add a device for the SAM itself.
sam_uid = names.sam_device_uid(entry)
device_registry = dr.async_get(hass)
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, sam_uid)},
manufacturer="Bryant",
name="System Access Module",
)
# Add a device for each system.
for sys_id in (1, 2):
if not any(sz[0] == sys_id for sz in entry.data[CONF_SYSTEM_ZONE]):
_LOGGER.debug(
"Skipping system %s because it is not configured for this integration: %s",
sys_id,
entry.data[CONF_SYSTEM_ZONE],
)
continue
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, names.system_device_uid(sam_uid, sys_id))},
via_device=(DOMAIN, names.sam_device_uid(entry)),
manufacturer="Bryant",
name=f"System {sys_id}",
)
# Create a client for every zone.
entry.runtime_data = {}
for sz in entry.data[CONF_SYSTEM_ZONE]:
try:
client = await BryantEvolutionLocalClient.get_client(
sz[0], sz[1], entry.data[CONF_FILENAME]
)
if not await _can_reach_device(client):
raise ConfigEntryNotReady
entry.runtime_data[tuple(sz)] = client
except FileNotFoundError as f:
raise ConfigEntryNotReady from f
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(
hass: HomeAssistant, entry: BryantEvolutionConfigEntry
) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -1,252 +0,0 @@
"""Support for Bryant Evolution HVAC systems."""
from datetime import timedelta
import logging
from typing import Any
from evolutionhttp import BryantEvolutionLocalClient
from homeassistant.components.climate import (
ClimateEntity,
ClimateEntityFeature,
HVACAction,
HVACMode,
)
from homeassistant.const import UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import BryantEvolutionConfigEntry, names
from .const import CONF_SYSTEM_ZONE, DOMAIN
_LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = timedelta(seconds=60)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: BryantEvolutionConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up a config entry."""
# Add a climate entity for each system/zone.
sam_uid = names.sam_device_uid(config_entry)
entities: list[Entity] = []
for sz in config_entry.data[CONF_SYSTEM_ZONE]:
system_id = sz[0]
zone_id = sz[1]
client = config_entry.runtime_data.get(tuple(sz))
climate = BryantEvolutionClimate(
client,
system_id,
zone_id,
sam_uid,
)
entities.append(climate)
async_add_entities(entities, update_before_add=True)
class BryantEvolutionClimate(ClimateEntity):
"""ClimateEntity for Bryant Evolution HVAC systems.
Design note: this class updates using polling. However, polling
is very slow (~1500 ms / parameter). To improve the user
experience on updates, we also locally update this instance and
call async_write_ha_state as well.
"""
_attr_has_entity_name = True
_attr_temperature_unit = UnitOfTemperature.FAHRENHEIT
_attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
| ClimateEntityFeature.FAN_MODE
| ClimateEntityFeature.TURN_ON
| ClimateEntityFeature.TURN_OFF
)
_attr_hvac_modes = [
HVACMode.HEAT,
HVACMode.COOL,
HVACMode.HEAT_COOL,
HVACMode.OFF,
]
_attr_fan_modes = ["auto", "low", "med", "high"]
_enable_turn_on_off_backwards_compatibility = False
def __init__(
self,
client: BryantEvolutionLocalClient,
system_id: int,
zone_id: int,
sam_uid: str,
) -> None:
"""Initialize an entity from parts."""
self._client = client
self._attr_name = None
self._attr_unique_id = names.zone_entity_uid(sam_uid, system_id, zone_id)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._attr_unique_id)},
manufacturer="Bryant",
via_device=(DOMAIN, names.system_device_uid(sam_uid, system_id)),
name=f"System {system_id} Zone {zone_id}",
)
async def async_update(self) -> None:
"""Update the entity state."""
self._attr_current_temperature = await self._client.read_current_temperature()
if (fan_mode := await self._client.read_fan_mode()) is not None:
self._attr_fan_mode = fan_mode.lower()
else:
self._attr_fan_mode = None
self._attr_target_temperature = None
self._attr_target_temperature_high = None
self._attr_target_temperature_low = None
self._attr_hvac_mode = await self._read_hvac_mode()
# Set target_temperature or target_temperature_{high, low} based on mode.
match self._attr_hvac_mode:
case HVACMode.HEAT:
self._attr_target_temperature = (
await self._client.read_heating_setpoint()
)
case HVACMode.COOL:
self._attr_target_temperature = (
await self._client.read_cooling_setpoint()
)
case HVACMode.HEAT_COOL:
self._attr_target_temperature_high = (
await self._client.read_cooling_setpoint()
)
self._attr_target_temperature_low = (
await self._client.read_heating_setpoint()
)
case HVACMode.OFF:
pass
case _:
_LOGGER.error("Unknown HVAC mode %s", self._attr_hvac_mode)
# Note: depends on current temperature and target temperature low read
# above.
self._attr_hvac_action = await self._read_hvac_action()
async def _read_hvac_mode(self) -> HVACMode:
mode_and_active = await self._client.read_hvac_mode()
if not mode_and_active:
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="failed_to_read_hvac_mode"
)
mode = mode_and_active[0]
mode_enum = {
"HEAT": HVACMode.HEAT,
"COOL": HVACMode.COOL,
"AUTO": HVACMode.HEAT_COOL,
"OFF": HVACMode.OFF,
}.get(mode.upper())
if mode_enum is None:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="failed_to_parse_hvac_mode",
translation_placeholders={"mode": mode},
)
return mode_enum
async def _read_hvac_action(self) -> HVACAction:
"""Return the current running hvac operation."""
mode_and_active = await self._client.read_hvac_mode()
if not mode_and_active:
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="failed_to_read_hvac_action"
)
mode, is_active = mode_and_active
if not is_active:
return HVACAction.OFF
match mode.upper():
case "HEAT":
return HVACAction.HEATING
case "COOL":
return HVACAction.COOLING
case "OFF":
return HVACAction.OFF
case "AUTO":
# In AUTO, we need to figure out what the actual action is
# based on the setpoints.
if (
self.current_temperature is not None
and self.target_temperature_low is not None
):
if self.current_temperature > self.target_temperature_low:
# If the system is on and the current temperature is
# higher than the point at which heating would activate,
# then we must be cooling.
return HVACAction.COOLING
return HVACAction.HEATING
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="failed_to_parse_hvac_mode",
translation_placeholders={
"mode_and_active": mode_and_active,
"current_temperature": str(self.current_temperature),
"target_temperature_low": str(self.target_temperature_low),
},
)
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target hvac mode."""
if hvac_mode == HVACMode.HEAT_COOL:
hvac_mode = HVACMode.AUTO
if not await self._client.set_hvac_mode(hvac_mode):
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="failed_to_set_hvac_mode"
)
self._attr_hvac_mode = hvac_mode
self._async_write_ha_state()
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
if kwargs.get("target_temp_high"):
temp = int(kwargs["target_temp_high"])
if not await self._client.set_cooling_setpoint(temp):
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="failed_to_set_clsp"
)
self._attr_target_temperature_high = temp
if kwargs.get("target_temp_low"):
temp = int(kwargs["target_temp_low"])
if not await self._client.set_heating_setpoint(temp):
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="failed_to_set_htsp"
)
self._attr_target_temperature_low = temp
if kwargs.get("temperature"):
temp = int(kwargs["temperature"])
fn = (
self._client.set_heating_setpoint
if self.hvac_mode == HVACMode.HEAT
else self._client.set_cooling_setpoint
)
if not await fn(temp):
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="failed_to_set_temp"
)
self._attr_target_temperature = temp
# If we get here, we must have changed something unless HA allowed an
# invalid service call (without any recognized kwarg).
self._async_write_ha_state()
async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Set new target fan mode."""
if not await self._client.set_fan_mode(fan_mode):
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="failed_to_set_fan_mode"
)
self._attr_fan_mode = fan_mode.lower()
self.async_write_ha_state()

View File

@@ -1,87 +0,0 @@
"""Config flow for Bryant Evolution integration."""
from __future__ import annotations
import logging
from typing import Any
from evolutionhttp import BryantEvolutionLocalClient
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_FILENAME
from homeassistant.helpers.typing import UNDEFINED
from .const import CONF_SYSTEM_ZONE, DOMAIN
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_FILENAME, default="/dev/ttyUSB0"): str,
}
)
async def _enumerate_sz(tty: str) -> list[tuple[int, int]]:
"""Return (system, zone) tuples for each system+zone accessible through tty."""
return [
(system_id, zone.zone_id)
for system_id in (1, 2)
for zone in await BryantEvolutionLocalClient.enumerate_zones(system_id, tty)
]
class BryantConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Bryant Evolution."""
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
try:
system_zone = await _enumerate_sz(user_input[CONF_FILENAME])
except FileNotFoundError:
_LOGGER.error("Could not open %s: not found", user_input[CONF_FILENAME])
errors["base"] = "cannot_connect"
else:
if len(system_zone) != 0:
return self.async_create_entry(
title=f"SAM at {user_input[CONF_FILENAME]}",
data={
CONF_FILENAME: user_input[CONF_FILENAME],
CONF_SYSTEM_ZONE: system_zone,
},
)
errors["base"] = "cannot_connect"
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle integration reconfiguration."""
errors: dict[str, str] = {}
if user_input is not None:
system_zone = await _enumerate_sz(user_input[CONF_FILENAME])
if len(system_zone) != 0:
our_entry = self.hass.config_entries.async_get_entry(
self.context["entry_id"]
)
assert our_entry is not None, "Could not find own entry"
return self.async_update_reload_and_abort(
entry=our_entry,
data={
CONF_FILENAME: user_input[CONF_FILENAME],
CONF_SYSTEM_ZONE: system_zone,
},
unique_id=UNDEFINED,
reason="reconfigured",
)
errors["base"] = "cannot_connect"
return self.async_show_form(
step_id="reconfigure", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)

View File

@@ -1,4 +0,0 @@
"""Constants for the Bryant Evolution integration."""
DOMAIN = "bryant_evolution"
CONF_SYSTEM_ZONE = "system_zone"

View File

@@ -1,10 +0,0 @@
{
"domain": "bryant_evolution",
"name": "Bryant Evolution",
"codeowners": ["@danielsmyers"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/bryant_evolution",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["evolutionhttp==0.0.18"]
}

View File

@@ -1,18 +0,0 @@
"""Functions to generate names for devices and entities."""
from homeassistant.config_entries import ConfigEntry
def sam_device_uid(entry: ConfigEntry) -> str:
"""Return the UID for the SAM device."""
return entry.entry_id
def system_device_uid(sam_uid: str, system_id: int) -> str:
"""Return the UID for a given system (e.g., 1) under a SAM."""
return f"{sam_uid}-S{system_id}"
def zone_entity_uid(sam_uid: str, system_id: int, zone_id: int) -> str:
"""Return the UID for a given system and zone (e.g., 1 and 2) under a SAM."""
return f"{sam_uid}-S{system_id}-Z{zone_id}"

View File

@@ -1,48 +0,0 @@
{
"config": {
"step": {
"user": {
"data": {
"filename": "Serial port filename"
}
}
},
"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%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
},
"exceptions": {
"failed_to_read_hvac_mode": {
"message": "Failed to read current HVAC mode"
},
"failed_to_parse_hvac_mode": {
"message": "Cannot parse response to HVACMode: {mode}"
},
"failed_to_read_hvac_action": {
"message": "Failed to read current HVAC action"
},
"failed_to_parse_hvac_action": {
"message": "Could not determine HVAC action: {mode_and_active}, {self.current_temperature}, {self.target_temperature_low}"
},
"failed_to_set_hvac_mode": {
"message": "Failed to set HVAC mode"
},
"failed_to_set_clsp": {
"message": "Failed to set cooling setpoint"
},
"failed_to_set_htsp": {
"message": "Failed to set heating setpoint"
},
"failed_to_set_temp": {
"message": "Failed to set temperature"
},
"failed_to_set_fan_mode": {
"message": "Failed to set fan mode"
}
}
}

View File

@@ -115,9 +115,7 @@ class BuienradarCam(Camera):
headers = {}
try:
async with session.get(
url, timeout=aiohttp.ClientTimeout(total=5), headers=headers
) as res:
async with session.get(url, timeout=5, headers=headers) as res:
res.raise_for_status()
if res.status == 304:

View File

@@ -248,7 +248,7 @@ async def _fetch_playlist(hass, url, supported_content_types):
"""Fetch a playlist from the given url."""
try:
session = aiohttp_client.async_get_clientsession(hass, verify_ssl=False)
async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp:
async with session.get(url, timeout=5) as resp:
charset = resp.charset or "utf-8"
if resp.content_type in supported_content_types:
raise PlaylistSupported

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/chacon_dio",
"iot_class": "cloud_push",
"loggers": ["dio_chacon_api"],
"requirements": ["dio-chacon-wifi-api==1.2.0"]
"requirements": ["dio-chacon-wifi-api==1.1.0"]
}

View File

@@ -914,37 +914,12 @@ async def async_service_temperature_set(
"""Handle set temperature service."""
hass = entity.hass
kwargs = {}
min_temp = entity.min_temp
max_temp = entity.max_temp
temp_unit = entity.temperature_unit
for value, temp in service_call.data.items():
if value in CONVERTIBLE_ATTRIBUTE:
kwargs[value] = check_temp = TemperatureConverter.convert(
temp, hass.config.units.temperature_unit, temp_unit
kwargs[value] = TemperatureConverter.convert(
temp, hass.config.units.temperature_unit, entity.temperature_unit
)
_LOGGER.debug(
"Check valid temperature %d %s (%d %s) in range %d %s - %d %s",
check_temp,
entity.temperature_unit,
temp,
hass.config.units.temperature_unit,
min_temp,
temp_unit,
max_temp,
temp_unit,
)
if check_temp < min_temp or check_temp > max_temp:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="temp_out_of_range",
translation_placeholders={
"check_temp": str(check_temp),
"min_temp": str(min_temp),
"max_temp": str(max_temp),
},
)
else:
kwargs[value] = temp

View File

@@ -266,9 +266,6 @@
},
"not_valid_fan_mode": {
"message": "Fan mode {mode} is not valid. Valid fan modes are: {modes}."
},
"temp_out_of_range": {
"message": "Provided temperature {check_temp} is not valid. Accepted range is {min_temp} to {max_temp}."
}
}
}

View File

@@ -6,7 +6,7 @@ from collections.abc import Callable, Coroutine
from typing import Any
import uuid
from hass_nabucasa.voice import MAP_VOICE, Gender
from hass_nabucasa.voice import MAP_VOICE
from homeassistant.auth.const import GROUP_ID_ADMIN
from homeassistant.auth.models import User
@@ -91,8 +91,8 @@ class CloudPreferencesStore(Store):
# The new second item is the voice name.
default_tts_voice = old_data.get(PREF_TTS_DEFAULT_VOICE)
if default_tts_voice and (voice_item_two := default_tts_voice[1]) in (
Gender.FEMALE,
Gender.MALE,
"female",
"male",
):
language: str = default_tts_voice[0]
if voice := MAP_VOICE.get((language, voice_item_two)):

View File

@@ -5,9 +5,7 @@ from __future__ import annotations
from datetime import timedelta
import logging
from coinbase.rest import RESTClient
from coinbase.rest.rest_base import HTTPError
from coinbase.wallet.client import Client as LegacyClient
from coinbase.wallet.client import Client
from coinbase.wallet.error import AuthenticationError
from homeassistant.config_entries import ConfigEntry
@@ -17,23 +15,8 @@ from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.util import Throttle
from .const import (
ACCOUNT_IS_VAULT,
API_ACCOUNT_AMOUNT,
API_ACCOUNT_AVALIABLE,
API_ACCOUNT_BALANCE,
API_ACCOUNT_CURRENCY,
API_ACCOUNT_CURRENCY_CODE,
API_ACCOUNT_HOLD,
API_ACCOUNT_ID,
API_ACCOUNT_NAME,
API_ACCOUNT_VALUE,
API_ACCOUNTS,
API_DATA,
API_RATES_CURRENCY,
API_RESOURCE_TYPE,
API_TYPE_VAULT,
API_V3_ACCOUNT_ID,
API_V3_TYPE_VAULT,
API_ACCOUNTS_DATA,
CONF_CURRENCIES,
CONF_EXCHANGE_BASE,
CONF_EXCHANGE_RATES,
@@ -76,16 +59,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
def create_and_update_instance(entry: ConfigEntry) -> CoinbaseData:
"""Create and update a Coinbase Data instance."""
if "organizations" not in entry.data[CONF_API_KEY]:
client = LegacyClient(entry.data[CONF_API_KEY], entry.data[CONF_API_TOKEN])
version = "v2"
else:
client = RESTClient(
api_key=entry.data[CONF_API_KEY], api_secret=entry.data[CONF_API_TOKEN]
)
version = "v3"
client = Client(entry.data[CONF_API_KEY], entry.data[CONF_API_TOKEN])
base_rate = entry.options.get(CONF_EXCHANGE_BASE, "USD")
instance = CoinbaseData(client, base_rate, version)
instance = CoinbaseData(client, base_rate)
instance.update()
return instance
@@ -110,83 +86,42 @@ async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> Non
registry.async_remove(entity.entity_id)
def get_accounts(client, version):
def get_accounts(client):
"""Handle paginated accounts."""
response = client.get_accounts()
if version == "v2":
accounts = response[API_DATA]
accounts = response[API_ACCOUNTS_DATA]
next_starting_after = response.pagination.next_starting_after
while next_starting_after:
response = client.get_accounts(starting_after=next_starting_after)
accounts += response[API_ACCOUNTS_DATA]
next_starting_after = response.pagination.next_starting_after
while next_starting_after:
response = client.get_accounts(starting_after=next_starting_after)
accounts += response[API_DATA]
next_starting_after = response.pagination.next_starting_after
return [
{
API_ACCOUNT_ID: account[API_ACCOUNT_ID],
API_ACCOUNT_NAME: account[API_ACCOUNT_NAME],
API_ACCOUNT_CURRENCY: account[API_ACCOUNT_CURRENCY][
API_ACCOUNT_CURRENCY_CODE
],
API_ACCOUNT_AMOUNT: account[API_ACCOUNT_BALANCE][API_ACCOUNT_AMOUNT],
ACCOUNT_IS_VAULT: account[API_RESOURCE_TYPE] == API_TYPE_VAULT,
}
for account in accounts
]
accounts = response[API_ACCOUNTS]
while response["has_next"]:
response = client.get_accounts(cursor=response["cursor"])
accounts += response["accounts"]
return [
{
API_ACCOUNT_ID: account[API_V3_ACCOUNT_ID],
API_ACCOUNT_NAME: account[API_ACCOUNT_NAME],
API_ACCOUNT_CURRENCY: account[API_ACCOUNT_CURRENCY],
API_ACCOUNT_AMOUNT: account[API_ACCOUNT_AVALIABLE][API_ACCOUNT_VALUE]
+ account[API_ACCOUNT_HOLD][API_ACCOUNT_VALUE],
ACCOUNT_IS_VAULT: account[API_RESOURCE_TYPE] == API_V3_TYPE_VAULT,
}
for account in accounts
]
return accounts
class CoinbaseData:
"""Get the latest data and update the states."""
def __init__(self, client, exchange_base, version):
def __init__(self, client, exchange_base):
"""Init the coinbase data object."""
self.client = client
self.accounts = None
self.exchange_base = exchange_base
self.exchange_rates = None
if version == "v2":
self.user_id = self.client.get_current_user()[API_ACCOUNT_ID]
else:
self.user_id = (
"v3_" + client.get_portfolios()["portfolios"][0][API_V3_ACCOUNT_ID]
)
self.api_version = version
self.user_id = self.client.get_current_user()[API_ACCOUNT_ID]
@Throttle(MIN_TIME_BETWEEN_UPDATES)
def update(self):
"""Get the latest data from coinbase."""
try:
self.accounts = get_accounts(self.client, self.api_version)
if self.api_version == "v2":
self.exchange_rates = self.client.get_exchange_rates(
currency=self.exchange_base
)
else:
self.exchange_rates = self.client.get(
"/v2/exchange-rates",
params={API_RATES_CURRENCY: self.exchange_base},
)[API_DATA]
except (AuthenticationError, HTTPError) as coinbase_error:
self.accounts = get_accounts(self.client)
self.exchange_rates = self.client.get_exchange_rates(
currency=self.exchange_base
)
except AuthenticationError as coinbase_error:
_LOGGER.error(
"Authentication error connecting to coinbase: %s", coinbase_error
)

View File

@@ -5,9 +5,7 @@ from __future__ import annotations
import logging
from typing import Any
from coinbase.rest import RESTClient
from coinbase.rest.rest_base import HTTPError
from coinbase.wallet.client import Client as LegacyClient
from coinbase.wallet.client import Client
from coinbase.wallet.error import AuthenticationError
import voluptuous as vol
@@ -17,17 +15,18 @@ from homeassistant.config_entries import (
ConfigFlowResult,
OptionsFlow,
)
from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN, CONF_API_VERSION
from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
import homeassistant.helpers.config_validation as cv
from . import get_accounts
from .const import (
ACCOUNT_IS_VAULT,
API_ACCOUNT_CURRENCY,
API_DATA,
API_ACCOUNT_CURRENCY_CODE,
API_RATES,
API_RESOURCE_TYPE,
API_TYPE_VAULT,
CONF_CURRENCIES,
CONF_EXCHANGE_BASE,
CONF_EXCHANGE_PRECISION,
@@ -50,11 +49,8 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
def get_user_from_client(api_key, api_token):
"""Get the user name from Coinbase API credentials."""
if "organizations" not in api_key:
client = LegacyClient(api_key, api_token)
return client.get_current_user()["name"]
client = RESTClient(api_key=api_key, api_secret=api_token)
return client.get_portfolios()["portfolios"][0]["name"]
client = Client(api_key, api_token)
return client.get_current_user()
async def validate_api(hass: HomeAssistant, data):
@@ -64,13 +60,11 @@ async def validate_api(hass: HomeAssistant, data):
user = await hass.async_add_executor_job(
get_user_from_client, data[CONF_API_KEY], data[CONF_API_TOKEN]
)
except (AuthenticationError, HTTPError) as error:
if "api key" in str(error) or " 401 Client Error" in str(error):
except AuthenticationError as error:
if "api key" in str(error):
_LOGGER.debug("Coinbase rejected API credentials due to an invalid API key")
raise InvalidKey from error
if "invalid signature" in str(
error
) or "'Could not deserialize key data" in str(error):
if "invalid signature" in str(error):
_LOGGER.debug(
"Coinbase rejected API credentials due to an invalid API secret"
)
@@ -79,8 +73,8 @@ async def validate_api(hass: HomeAssistant, data):
raise InvalidAuth from error
except ConnectionError as error:
raise CannotConnect from error
api_version = "v3" if "organizations" in data[CONF_API_KEY] else "v2"
return {"title": user, "api_version": api_version}
return {"title": user["name"]}
async def validate_options(hass: HomeAssistant, config_entry: ConfigEntry, options):
@@ -88,20 +82,14 @@ async def validate_options(hass: HomeAssistant, config_entry: ConfigEntry, optio
client = hass.data[DOMAIN][config_entry.entry_id].client
accounts = await hass.async_add_executor_job(
get_accounts, client, config_entry.data.get("api_version", "v2")
)
accounts = await hass.async_add_executor_job(get_accounts, client)
accounts_currencies = [
account[API_ACCOUNT_CURRENCY]
account[API_ACCOUNT_CURRENCY][API_ACCOUNT_CURRENCY_CODE]
for account in accounts
if not account[ACCOUNT_IS_VAULT]
if account[API_RESOURCE_TYPE] != API_TYPE_VAULT
]
if config_entry.data.get("api_version", "v2") == "v2":
available_rates = await hass.async_add_executor_job(client.get_exchange_rates)
else:
resp = await hass.async_add_executor_job(client.get, "/v2/exchange-rates")
available_rates = resp[API_DATA]
available_rates = await hass.async_add_executor_job(client.get_exchange_rates)
if CONF_CURRENCIES in options:
for currency in options[CONF_CURRENCIES]:
if currency not in accounts_currencies:
@@ -146,7 +134,6 @@ class CoinbaseConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
user_input[CONF_API_VERSION] = info["api_version"]
return self.async_create_entry(title=info["title"], data=user_input)
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors

View File

@@ -1,7 +1,5 @@
"""Constants used for Coinbase."""
ACCOUNT_IS_VAULT = "is_vault"
CONF_CURRENCIES = "account_balance_currencies"
CONF_EXCHANGE_BASE = "exchange_base"
CONF_EXCHANGE_RATES = "exchange_rate_currencies"
@@ -12,25 +10,18 @@ DOMAIN = "coinbase"
# Constants for data returned by Coinbase API
API_ACCOUNT_AMOUNT = "amount"
API_ACCOUNT_AVALIABLE = "available_balance"
API_ACCOUNT_BALANCE = "balance"
API_ACCOUNT_CURRENCY = "currency"
API_ACCOUNT_CURRENCY_CODE = "code"
API_ACCOUNT_HOLD = "hold"
API_ACCOUNT_ID = "id"
API_ACCOUNT_NATIVE_BALANCE = "balance"
API_ACCOUNT_NAME = "name"
API_ACCOUNT_VALUE = "value"
API_ACCOUNTS = "accounts"
API_DATA = "data"
API_ACCOUNTS_DATA = "data"
API_RATES = "rates"
API_RATES_CURRENCY = "currency"
API_RESOURCE_PATH = "resource_path"
API_RESOURCE_TYPE = "type"
API_TYPE_VAULT = "vault"
API_USD = "USD"
API_V3_ACCOUNT_ID = "uuid"
API_V3_TYPE_VAULT = "ACCOUNT_TYPE_VAULT"
WALLETS = {
"1INCH": "1INCH",

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/coinbase",
"iot_class": "cloud_polling",
"loggers": ["coinbase"],
"requirements": ["coinbase==2.1.0", "coinbase-advanced-py==1.2.2"]
"requirements": ["coinbase==2.1.0"]
}

View File

@@ -12,12 +12,15 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import CoinbaseData
from .const import (
ACCOUNT_IS_VAULT,
API_ACCOUNT_AMOUNT,
API_ACCOUNT_BALANCE,
API_ACCOUNT_CURRENCY,
API_ACCOUNT_CURRENCY_CODE,
API_ACCOUNT_ID,
API_ACCOUNT_NAME,
API_RATES,
API_RESOURCE_TYPE,
API_TYPE_VAULT,
CONF_CURRENCIES,
CONF_EXCHANGE_PRECISION,
CONF_EXCHANGE_PRECISION_DEFAULT,
@@ -28,7 +31,6 @@ from .const import (
_LOGGER = logging.getLogger(__name__)
ATTR_NATIVE_BALANCE = "Balance in native currency"
ATTR_API_VERSION = "API Version"
CURRENCY_ICONS = {
"BTC": "mdi:currency-btc",
@@ -54,9 +56,9 @@ async def async_setup_entry(
entities: list[SensorEntity] = []
provided_currencies: list[str] = [
account[API_ACCOUNT_CURRENCY]
account[API_ACCOUNT_CURRENCY][API_ACCOUNT_CURRENCY_CODE]
for account in instance.accounts
if not account[ACCOUNT_IS_VAULT]
if account[API_RESOURCE_TYPE] != API_TYPE_VAULT
]
desired_currencies: list[str] = []
@@ -71,11 +73,6 @@ async def async_setup_entry(
)
for currency in desired_currencies:
_LOGGER.debug(
"Attempting to set up %s account sensor with %s API",
currency,
instance.api_version,
)
if currency not in provided_currencies:
_LOGGER.warning(
(
@@ -88,17 +85,12 @@ async def async_setup_entry(
entities.append(AccountSensor(instance, currency))
if CONF_EXCHANGE_RATES in config_entry.options:
for rate in config_entry.options[CONF_EXCHANGE_RATES]:
_LOGGER.debug(
"Attempting to set up %s account sensor with %s API",
rate,
instance.api_version,
)
entities.append(
ExchangeRateSensor(
instance, rate, exchange_base_currency, exchange_precision
)
entities.extend(
ExchangeRateSensor(
instance, rate, exchange_base_currency, exchange_precision
)
for rate in config_entry.options[CONF_EXCHANGE_RATES]
)
async_add_entities(entities)
@@ -113,21 +105,26 @@ class AccountSensor(SensorEntity):
self._coinbase_data = coinbase_data
self._currency = currency
for account in coinbase_data.accounts:
if account[API_ACCOUNT_CURRENCY] != currency or account[ACCOUNT_IS_VAULT]:
if (
account[API_ACCOUNT_CURRENCY][API_ACCOUNT_CURRENCY_CODE] != currency
or account[API_RESOURCE_TYPE] == API_TYPE_VAULT
):
continue
self._attr_name = f"Coinbase {account[API_ACCOUNT_NAME]}"
self._attr_unique_id = (
f"coinbase-{account[API_ACCOUNT_ID]}-wallet-"
f"{account[API_ACCOUNT_CURRENCY]}"
f"{account[API_ACCOUNT_CURRENCY][API_ACCOUNT_CURRENCY_CODE]}"
)
self._attr_native_value = account[API_ACCOUNT_AMOUNT]
self._attr_native_unit_of_measurement = account[API_ACCOUNT_CURRENCY]
self._attr_native_value = account[API_ACCOUNT_BALANCE][API_ACCOUNT_AMOUNT]
self._attr_native_unit_of_measurement = account[API_ACCOUNT_CURRENCY][
API_ACCOUNT_CURRENCY_CODE
]
self._attr_icon = CURRENCY_ICONS.get(
account[API_ACCOUNT_CURRENCY],
account[API_ACCOUNT_CURRENCY][API_ACCOUNT_CURRENCY_CODE],
DEFAULT_COIN_ICON,
)
self._native_balance = round(
float(account[API_ACCOUNT_AMOUNT])
float(account[API_ACCOUNT_BALANCE][API_ACCOUNT_AMOUNT])
/ float(coinbase_data.exchange_rates[API_RATES][currency]),
2,
)
@@ -147,26 +144,21 @@ class AccountSensor(SensorEntity):
"""Return the state attributes of the sensor."""
return {
ATTR_NATIVE_BALANCE: f"{self._native_balance} {self._coinbase_data.exchange_base}",
ATTR_API_VERSION: self._coinbase_data.api_version,
}
def update(self) -> None:
"""Get the latest state of the sensor."""
_LOGGER.debug(
"Updating %s account sensor with %s API",
self._currency,
self._coinbase_data.api_version,
)
self._coinbase_data.update()
for account in self._coinbase_data.accounts:
if (
account[API_ACCOUNT_CURRENCY] != self._currency
or account[ACCOUNT_IS_VAULT]
account[API_ACCOUNT_CURRENCY][API_ACCOUNT_CURRENCY_CODE]
!= self._currency
or account[API_RESOURCE_TYPE] == API_TYPE_VAULT
):
continue
self._attr_native_value = account[API_ACCOUNT_AMOUNT]
self._attr_native_value = account[API_ACCOUNT_BALANCE][API_ACCOUNT_AMOUNT]
self._native_balance = round(
float(account[API_ACCOUNT_AMOUNT])
float(account[API_ACCOUNT_BALANCE][API_ACCOUNT_AMOUNT])
/ float(self._coinbase_data.exchange_rates[API_RATES][self._currency]),
2,
)
@@ -210,13 +202,8 @@ class ExchangeRateSensor(SensorEntity):
def update(self) -> None:
"""Get the latest state of the sensor."""
_LOGGER.debug(
"Updating %s rate sensor with %s API",
self._currency,
self._coinbase_data.api_version,
)
self._coinbase_data.update()
self._attr_native_value = round(
1 / float(self._coinbase_data.exchange_rates[API_RATES][self._currency]),
1 / float(self._coinbase_data.exchange_rates.rates[self._currency]),
self._precision,
)

View File

@@ -13,7 +13,7 @@ from homeassistant.const import (
EVENT_HOMEASSISTANT_STOP,
Platform,
)
from homeassistant.core import Event, HomeAssistant
from homeassistant.core import HomeAssistant
from homeassistant.helpers import discovery
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.dispatcher import dispatcher_send
@@ -76,7 +76,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
ccb.connect()
# Schedule disconnect on shutdown
def _shutdown(_event: Event) -> None:
def _shutdown(_event):
ccb.disconnect()
hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown)
@@ -90,15 +90,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
class ComfoConnectBridge:
"""Representation of a ComfoConnect bridge."""
def __init__(
self,
hass: HomeAssistant,
bridge: Bridge,
name: str,
token: str,
friendly_name: str,
pin: int,
) -> None:
def __init__(self, hass, bridge, name, token, friendly_name, pin):
"""Initialize the ComfoConnect bridge."""
self.name = name
self.hass = hass
@@ -112,17 +104,17 @@ class ComfoConnectBridge:
)
self.comfoconnect.callback_sensor = self.sensor_callback
def connect(self) -> None:
def connect(self):
"""Connect with the bridge."""
_LOGGER.debug("Connecting with bridge")
self.comfoconnect.connect(True)
def disconnect(self) -> None:
def disconnect(self):
"""Disconnect from the bridge."""
_LOGGER.debug("Disconnecting from bridge")
self.comfoconnect.disconnect()
def sensor_callback(self, var: str, value: str) -> None:
def sensor_callback(self, var, value):
"""Notify listeners that we have received an update."""
_LOGGER.debug("Received update for %s: %s", var, value)
dispatcher_send(

View File

@@ -327,7 +327,7 @@ class ComfoConnectSensor(SensorEntity):
self._ccb.comfoconnect.register_sensor, self.entity_description.sensor_id
)
def _handle_update(self, value: float) -> None:
def _handle_update(self, value):
"""Handle update callbacks."""
_LOGGER.debug(
"Handle update for sensor %s (%d): %s",

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
import asyncio
from datetime import datetime, timedelta
from typing import Any, cast
from typing import TYPE_CHECKING, Any, cast
from homeassistant.components.cover import CoverEntity
from homeassistant.const import (
@@ -145,7 +145,8 @@ class CommandCover(ManualTriggerEntity, CoverEntity):
if self._command_state:
LOGGER.info("Running state value command: %s", self._command_state)
return await async_check_output_or_log(self._command_state, self._timeout)
return None
if TYPE_CHECKING:
return None
async def _update_entity_state(self, now: datetime | None = None) -> None:
"""Update the state of the entity."""

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
import asyncio
from datetime import datetime, timedelta
from typing import Any, cast
from typing import TYPE_CHECKING, Any, cast
from homeassistant.components.switch import ENTITY_ID_FORMAT, SwitchEntity
from homeassistant.const import (
@@ -147,7 +147,8 @@ class CommandSwitch(ManualTriggerEntity, SwitchEntity):
if self._value_template:
return await self._async_query_state_value(self._command_state)
return await self._async_query_state_code(self._command_state)
return None
if TYPE_CHECKING:
return None
async def _update_entity_state(self, now: datetime | None = None) -> None:
"""Update the state of the entity."""

View File

@@ -130,8 +130,6 @@ def _entry_dict(entry: cr.CategoryEntry) -> dict[str, Any]:
"""Convert entry to API format."""
return {
"category_id": entry.category_id,
"created_at": entry.created_at.timestamp(),
"icon": entry.icon,
"modified_at": entry.modified_at.timestamp(),
"name": entry.name,
}

View File

@@ -11,7 +11,7 @@ from homeassistant.components.scene import (
)
from homeassistant.config import SCENE_CONFIG_PATH
from homeassistant.const import CONF_ID, SERVICE_RELOAD
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback
from homeassistant.core import DOMAIN as HA_DOMAIN, HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, entity_registry as er
from .const import ACTION_DELETE
@@ -32,9 +32,7 @@ def async_setup(hass: HomeAssistant) -> bool:
ent_reg = er.async_get(hass)
entity_id = ent_reg.async_get_entity_id(
DOMAIN, HOMEASSISTANT_DOMAIN, config_key
)
entity_id = ent_reg.async_get_entity_id(DOMAIN, HA_DOMAIN, config_key)
if entity_id is None:
return

View File

@@ -47,7 +47,6 @@ from homeassistant.util.json import JsonObjectType, json_loads_object
from .const import DEFAULT_EXPOSED_ATTRIBUTES, DOMAIN, ConversationEntityFeature
from .entity import ConversationEntity
from .models import ConversationInput, ConversationResult
from .trace import ConversationTraceEventType, async_conversation_trace_append
_LOGGER = logging.getLogger(__name__)
_DEFAULT_ERROR_TEXT = "Sorry, I couldn't understand that"
@@ -349,16 +348,6 @@ class DefaultAgent(ConversationEntity):
}
for entity in result.entities_list
}
async_conversation_trace_append(
ConversationTraceEventType.TOOL_CALL,
{
"intent_name": result.intent.name,
"slots": {
entity.name: entity.value or entity.text
for entity in result.entities_list
},
},
)
try:
intent_response = await intent.async_handle(

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "system",
"quality_scale": "internal",
"requirements": ["hassil==1.7.4", "home-assistant-intents==2024.8.7"]
"requirements": ["hassil==1.7.4", "home-assistant-intents==2024.7.10"]
}

View File

@@ -22,8 +22,8 @@ class ConversationTraceEventType(enum.StrEnum):
AGENT_DETAIL = "agent_detail"
"""Event detail added by a conversation agent."""
TOOL_CALL = "tool_call"
"""A conversation agent Tool call or default agent intent call."""
LLM_TOOL_CALL = "llm_tool_call"
"""An LLM Tool call"""
@dataclass(frozen=True)

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/coolmaster",
"iot_class": "local_polling",
"loggers": ["pycoolmasternet_async"],
"requirements": ["pycoolmasternet-async==0.1.5"]
"requirements": ["pycoolmasternet-async==0.2.0"]
}

View File

@@ -4,7 +4,7 @@ from homeassistant.const import SERVICE_CLOSE_COVER, SERVICE_OPEN_COVER
from homeassistant.core import HomeAssistant
from homeassistant.helpers import intent
from . import DOMAIN, CoverDeviceClass
from . import DOMAIN
INTENT_OPEN_COVER = "HassOpenCover"
INTENT_CLOSE_COVER = "HassCloseCover"
@@ -21,7 +21,6 @@ async def async_setup_intents(hass: HomeAssistant) -> None:
"Opening {}",
description="Opens a cover",
platforms={DOMAIN},
device_classes={CoverDeviceClass},
),
)
intent.async_register(
@@ -33,6 +32,5 @@ async def async_setup_intents(hass: HomeAssistant) -> None:
"Closing {}",
description="Closes a cover",
platforms={DOMAIN},
device_classes={CoverDeviceClass},
),
)

View File

@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/daikin",
"iot_class": "local_polling",
"loggers": ["pydaikin"],
"requirements": ["pydaikin==2.13.2"],
"requirements": ["pydaikin==2.13.1"],
"zeroconf": ["_dkapi._tcp.local."]
}

View File

@@ -162,7 +162,6 @@ class DdWrtDeviceScanner(DeviceScanner):
)
return None
_LOGGER.error("Invalid response from DD-WRT: %s", response)
return None
def _parse_ddwrt_response(data_str):

View File

@@ -66,6 +66,7 @@ class DeconzFan(DeconzDevice[Light], FanEntity):
def __init__(self, device: Light, hub: DeconzHub) -> None:
"""Set up fan."""
super().__init__(device, hub)
_attr_speed_count = len(ORDERED_NAMED_FAN_SPEEDS)
if device.fan_speed in ORDERED_NAMED_FAN_SPEEDS:
self._default_on_speed = device.fan_speed
@@ -95,8 +96,7 @@ class DeconzFan(DeconzDevice[Light], FanEntity):
async def async_set_percentage(self, percentage: int) -> None:
"""Set the speed percentage of the fan."""
if percentage == 0:
await self.async_turn_off()
return
return await self.async_turn_off()
await self.hub.api.lights.lights.set_state(
id=self._device.resource_id,
fan_speed=percentage_to_ordered_list_item(

View File

@@ -253,12 +253,11 @@ class DenonDevice(MediaPlayerEntity):
return SUPPORT_DENON
@property
def source(self) -> str | None:
def source(self):
"""Return the current input source."""
for pretty_name, name in self._source_list.items():
if self._mediasource == name:
return pretty_name
return None
def turn_off(self) -> None:
"""Turn off media player."""

View File

@@ -21,8 +21,6 @@ from . import DevoloHomeNetworkConfigEntry
from .const import CONNECTED_PLC_DEVICES, CONNECTED_TO_ROUTER
from .entity import DevoloCoordinatorEntity
PARALLEL_UPDATES = 1
def _is_connected_to_router(entity: DevoloBinarySensorEntity) -> bool:
"""Check, if device is attached to the router."""

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