Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/builder.yml
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ jobs:
sed -i "s|home-assistant-intents==.*|home-assistant-intents==${BASH_REMATCH[1]}|" \
homeassistant/package_constraints.txt

sed -i "s|home-assistant-intents==.*||" requirements_all.txt
sed -i "s|home-assistant-intents==.*||" requirements_all.txt requirements.txt
fi

- name: Download translations
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/conversation/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "entity",
"quality_scale": "internal",
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.1.28"]
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.2.13"]
}
2 changes: 2 additions & 0 deletions homeassistant/components/zwave_js/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@


EVENT_DEVICE_ADDED_TO_REGISTRY = f"{DOMAIN}_device_added_to_registry"
EVENT_VALUE_ADDED = "value added"
EVENT_VALUE_REMOVED = "value removed"
EVENT_VALUE_UPDATED = "value updated"

LOGGER = logging.getLogger(__package__)
Expand Down
43 changes: 38 additions & 5 deletions homeassistant/components/zwave_js/entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,17 @@
from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.typing import UNDEFINED

from .const import DOMAIN, EVENT_VALUE_UPDATED, LOGGER
from .const import (
DOMAIN,
EVENT_VALUE_ADDED,
EVENT_VALUE_REMOVED,
EVENT_VALUE_UPDATED,
LOGGER,
)
from .discovery_data_template import BaseDiscoverySchemaDataTemplate
from .helpers import get_device_id, get_unique_id, get_valueless_base_unique_id
from .models import PlatformZwaveDiscoveryInfo, ZwaveDiscoveryInfo

EVENT_VALUE_REMOVED = "value removed"


@dataclass(kw_only=True)
class NewZwaveDiscoveryInfo(PlatformZwaveDiscoveryInfo):
Expand Down Expand Up @@ -62,6 +66,7 @@ def __init__(
self.config_entry = config_entry
self.driver = driver
self.info = info
self._primary_value_removed = False
# entities requiring additional values, can add extra ids to this list
self.watched_value_ids = {self.info.primary_value.value_id}

Expand Down Expand Up @@ -135,6 +140,7 @@ async def async_added_to_hass(self) -> None:
self.async_on_remove(
self.info.node.on(EVENT_VALUE_UPDATED, self._value_changed)
)
self.async_on_remove(self.info.node.on(EVENT_VALUE_ADDED, self._value_added))
self.async_on_remove(
self.info.node.on(EVENT_VALUE_REMOVED, self._value_removed)
)
Expand Down Expand Up @@ -226,7 +232,11 @@ def generate_name(
@property
def available(self) -> bool:
"""Return entity availability."""
return self.driver.client.connected and bool(self.info.node.ready)
return (
self.driver.client.connected
and bool(self.info.node.ready)
and not self._primary_value_removed
)

@callback
def _value_changed(self, event_data: dict) -> None:
Expand Down Expand Up @@ -269,7 +279,30 @@ def _value_removed(self, event_data: dict) -> None:
value_id,
)

self.hass.async_create_task(self.async_remove())
self._primary_value_removed = True
self.async_write_ha_state()

@callback
def _value_added(self, event_data: dict) -> None:
"""Call when a value associated with our node is added.

Should not be overridden by subclasses.
"""
value = event_data["value"]

if value.value_id != self.info.primary_value.value_id:
return

LOGGER.debug(
"[%s] Primary value %s was added",
self.entity_id,
value.value_id,
)

self.info.primary_value = value
self._primary_value_removed = False
self.on_value_update()
self.async_write_ha_state()

@callback
def get_zwave_value(
Expand Down
4 changes: 2 additions & 2 deletions homeassistant/components/zwave_js/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -795,10 +795,10 @@ def on_value_update(self) -> None:
self._attr_native_unit_of_measurement = data.unit_of_measurement

@property
def native_value(self) -> float:
def native_value(self) -> float | None:
"""Return state of the sensor."""
if self.info.primary_value.value is None:
return 0
return None
return float(self.info.primary_value.value)


Expand Down
2 changes: 1 addition & 1 deletion homeassistant/package_constraints.txt
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ hass-nabucasa==1.13.0
hassil==3.5.0
home-assistant-bluetooth==1.13.1
home-assistant-frontend==20260128.6
home-assistant-intents==2026.1.28
home-assistant-intents==2026.2.13
httpx==0.28.1
ifaddr==0.2.0
Jinja2==3.1.6
Expand Down
2 changes: 1 addition & 1 deletion requirements.txt

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion requirements_all.txt

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion requirements_test_all.txt

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

154 changes: 152 additions & 2 deletions tests/components/zwave_js/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
from homeassistant.components.zwave_js import DOMAIN
from homeassistant.components.zwave_js.helpers import get_device_id, get_device_id_ext
from homeassistant.config_entries import ConfigEntryDisabler, ConfigEntryState
from homeassistant.const import STATE_UNAVAILABLE, Platform
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform
from homeassistant.core import CoreState, HomeAssistant
from homeassistant.helpers import (
area_registry as ar,
Expand Down Expand Up @@ -1917,11 +1917,12 @@ async def test_disabled_node_status_entity_on_node_replaced(
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_remove_entity_on_value_removed(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
zp3111: Node,
client: MagicMock,
integration: MockConfigEntry,
) -> None:
"""Test that when entity primary values are removed the entity is removed."""
"""Test that when entity primary values are removed the entity becomes unavailable."""
idle_cover_status_button_entity = (
"button.4_in_1_sensor_idle_home_security_cover_status"
)
Expand Down Expand Up @@ -2039,6 +2040,155 @@ async def test_remove_entity_on_value_removed(
== new_unavailable_entities
)

# Entities should still be in the entity registry (not fully removed)
assert entity_registry.async_get(battery_level_entity) is not None
assert entity_registry.async_get(binary_cover_entity) is not None
assert entity_registry.async_get(idle_cover_status_button_entity) is not None


@pytest.mark.usefixtures("entity_registry_enabled_by_default")
@pytest.mark.parametrize("platforms", [[Platform.SENSOR]])
async def test_value_removed_and_readded(
hass: HomeAssistant,
zp3111: Node,
client: MagicMock,
integration: MockConfigEntry,
) -> None:
"""Test entity recovers when primary value is removed and re-added."""
battery_level_entity = "sensor.4_in_1_sensor_battery_level"

state = hass.states.get(battery_level_entity)
assert state
assert state.state == "0.0"

# Remove the battery level value
event = Event(
type="value removed",
data={
"source": "node",
"event": "value removed",
"nodeId": zp3111.node_id,
"args": {
"commandClassName": "Battery",
"commandClass": 128,
"endpoint": 0,
"property": "level",
"prevValue": 100,
"propertyName": "level",
},
},
)
client.driver.receive_event(event)
await hass.async_block_till_done()

state = hass.states.get(battery_level_entity)
assert state
assert state.state == STATE_UNAVAILABLE

# Re-add the battery level value with a new reading
event = Event(
type="value added",
data={
"source": "node",
"event": "value added",
"nodeId": zp3111.node_id,
"args": {
"commandClassName": "Battery",
"commandClass": 128,
"endpoint": 0,
"property": "level",
"propertyName": "level",
"metadata": {
"type": "number",
"readable": True,
"writeable": False,
"label": "Battery level",
"min": 0,
"max": 100,
"unit": "%",
},
"value": 80,
},
},
)
client.driver.receive_event(event)
await hass.async_block_till_done()

state = hass.states.get(battery_level_entity)
assert state
assert state.state != STATE_UNAVAILABLE
assert state.state == "80.0"


@pytest.mark.usefixtures("entity_registry_enabled_by_default")
@pytest.mark.parametrize("platforms", [[Platform.SENSOR]])
async def test_value_never_populated_then_added(
hass: HomeAssistant,
zp3111_state: NodeDataType,
client: MagicMock,
integration: MockConfigEntry,
) -> None:
"""Test entity updates when value metadata exists but value is None, then added."""
# Modify the battery level value to have value=None (metadata exists but no data)
node_state = deepcopy(zp3111_state)
for value in node_state["values"]:
if value["commandClass"] == 128 and value["property"] == "level":
value["value"] = None
break

event = Event(
"node added",
{
"source": "controller",
"event": "node added",
"node": node_state,
"result": {},
},
)
client.driver.controller.receive_event(event)
await hass.async_block_till_done()

# The entity should exist but have unknown state (value is None)
battery_level_entity = "sensor.4_in_1_sensor_battery_level"
state = hass.states.get(battery_level_entity)
assert state
assert state.state == STATE_UNKNOWN

node = client.driver.controller.nodes[node_state["nodeId"]]

# Now send "value added" event with actual value
event = Event(
type="value added",
data={
"source": "node",
"event": "value added",
"nodeId": node.node_id,
"args": {
"commandClassName": "Battery",
"commandClass": 128,
"endpoint": 0,
"property": "level",
"propertyName": "level",
"metadata": {
"type": "number",
"readable": True,
"writeable": False,
"label": "Battery level",
"min": 0,
"max": 100,
"unit": "%",
},
"value": 75,
},
},
)
client.driver.receive_event(event)
await hass.async_block_till_done()

state = hass.states.get(battery_level_entity)
assert state
assert state.state == "75.0"


async def test_identify_event(
hass: HomeAssistant,
Expand Down
10 changes: 3 additions & 7 deletions tests/components/zwave_js/test_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,9 +125,7 @@ async def test_battery_sensors(
entity_id = "sensor.keypad_v2_maximum_capacity"
state = hass.states.get(entity_id)
assert state
assert (
state.state == "0"
) # This should be None/unknown but will be fixed in a future PR.
assert state.state == STATE_UNKNOWN
assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == PERCENTAGE
assert ATTR_DEVICE_CLASS not in state.attributes
assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT
Expand All @@ -143,9 +141,7 @@ async def test_battery_sensors(
entity_id = "sensor.keypad_v2_temperature"
state = hass.states.get(entity_id)
assert state
assert (
state.state == "0"
) # This should be None/unknown but will be fixed in a future PR.
assert state.state == STATE_UNKNOWN
assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfTemperature.CELSIUS
assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.TEMPERATURE
assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT
Expand Down Expand Up @@ -225,7 +221,7 @@ async def test_numeric_sensor(
await hass.async_block_till_done()
state = hass.states.get("sensor.hsm200_illuminance")
assert state
assert state.state == "0"
assert state.state == STATE_UNKNOWN


async def test_invalid_multilevel_sensor_scale(
Expand Down
Loading