Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
12 changes: 10 additions & 2 deletions custom_components/nodered/binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

from .const import CONF_BINARY_SENSOR, NODERED_DISCOVERY_NEW
from .entity import NodeRedEntity
from .utils import contrib_supports_presence_available

try:
from homeassistant.components.lock.const import LockState
Expand Down Expand Up @@ -71,12 +72,19 @@ class NodeRedBinarySensor(NodeRedEntity, BinarySensorEntity):
def __init__(self, hass: HomeAssistant, config: dict[str, Any]) -> None:
"""Initialize the binary sensor."""
super().__init__(hass, config)
self._attr_is_on = self._evaluate_sensor_state(config.get(CONF_STATE))
if CONF_STATE in config:
self._attr_is_on = self._evaluate_sensor_state(config[CONF_STATE])
else:
self._attr_is_on = None
# No reading yet: unavailable once presence-available applies
if contrib_supports_presence_available(hass):
self._attr_available = False
Comment on lines +75 to +81

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The issue only arises as a bit of an edge-case when "Resend state, available, and attributes" is set on: create entity -> set available (HA state now unknown/available) -> HA restart -> resend happens without state -> HA state now unknown/unavailable.

I agree it's a bit inconsistent, but it's a very edge case. Discovery without state still defaults to unavailable on purpose (avoid available+unknown on first deploy / empty rediscovery). Availability-only updates after the entity exists already honor available and keep the last reading.

I don't think we should change this for this case, but if we were it would be to disallow setting available to false for resend sensors that have never had a value.


def update_entity_state_attributes(self, msg: dict[str, Any]) -> None:
"""Update entity state attributes."""
super().update_entity_state_attributes(msg)
self._attr_is_on = self._evaluate_sensor_state(msg.get(CONF_STATE))
if CONF_STATE in msg:
self._attr_is_on = self._evaluate_sensor_state(msg[CONF_STATE])

def _evaluate_sensor_state(self, value: Any) -> Any:
"""Parse state."""
Expand Down
2 changes: 2 additions & 0 deletions custom_components/nodered/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,13 @@

# Configuration
CONF_ATTRIBUTES = "attributes"
CONF_AVAILABLE = "available"
CONF_BINARY_SENSOR = "binary_sensor"
CONF_BUTTON = "button"
CONF_COMPONENT = "component"
CONF_CONFIG = "config"
CONF_CONNECTION = "connection"
CONF_CONTRIB_VERSION = "contrib_version"
CONF_DATA = "data"
CONF_DEVICE_INFO = "device_info"
CONF_DEVICE_TRIGGER = "device_trigger"
Expand Down
23 changes: 21 additions & 2 deletions custom_components/nodered/entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
CONF_DEVICE_CLASS,
CONF_ENTITY_CATEGORY,
CONF_ICON,
CONF_STATE,
CONF_UNIT_OF_MEASUREMENT,
EntityCategory,
)
Expand All @@ -26,6 +27,7 @@

from .const import (
CONF_ATTRIBUTES,
CONF_AVAILABLE,
CONF_COMPONENT,
CONF_CONFIG,
CONF_DEVICE_INFO,
Expand All @@ -43,6 +45,7 @@
NODERED_ENTITY,
)
from .discovery import ALREADY_DISCOVERED, CHANGE_ENTITY_TYPE
from .utils import contrib_supports_presence_available


class MissingConfigError(TypeError):
Expand Down Expand Up @@ -99,8 +102,24 @@ def handle_entity_update(self, msg: dict[str, Any]) -> None:
self.async_write_ha_state()

def update_entity_state_attributes(self, msg: dict[str, Any]) -> None:
"""Set extra state attributes from incoming message."""
self._attr_extra_state_attributes = msg.get(CONF_ATTRIBUTES, {})
"""Apply attributes and availability from an incoming message.

When presence-available is not supported, inject ``available: true`` if
``state`` is present without ``available``, and clear attributes to ``{}``
when the attributes key is omitted.
"""
if (
not contrib_supports_presence_available(self.hass)
and CONF_STATE in msg
and CONF_AVAILABLE not in msg
):
msg[CONF_AVAILABLE] = True
if CONF_ATTRIBUTES not in msg:
self._attr_extra_state_attributes = {}
if CONF_ATTRIBUTES in msg:
self._attr_extra_state_attributes = msg[CONF_ATTRIBUTES]
if CONF_AVAILABLE in msg:
self._attr_available = msg[CONF_AVAILABLE]

@callback
def handle_lost_connection(self) -> None:
Expand Down
2 changes: 1 addition & 1 deletion custom_components/nodered/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,5 @@
"documentation": "https://zachowj.github.io/node-red-contrib-home-assistant-websocket/guide/custom_integration/",
"iot_class": "local_push",
"issue_tracker": "https://github.com/zachowj/hass-node-red/issues",
"version": "4.2.3"
"version": "4.2.4"
}
4 changes: 4 additions & 0 deletions custom_components/nodered/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
NODERED_DISCOVERY_NEW,
)
from .entity import NodeRedEntity
from .utils import contrib_supports_presence_available

_LOGGER = logging.getLogger(__name__)

Expand Down Expand Up @@ -71,6 +72,9 @@ def __init__(self, hass: HomeAssistant, config: dict[str, Any]) -> None:
self._attr_native_value = self.convert_state(config.get(CONF_STATE))
else:
self._attr_native_value = None
# No reading yet: unavailable once presence-available applies
if contrib_supports_presence_available(hass):
self._attr_available = False
Comment on lines 73 to +77

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See below.

self._attr_native_unit_of_measurement = self._config.get(
CONF_UNIT_OF_MEASUREMENT
)
Expand Down
16 changes: 16 additions & 0 deletions custom_components/nodered/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,24 @@
from datetime import timedelta
from typing import Any

from homeassistant.core import HomeAssistant
from homeassistant.helpers.json import JSONEncoder

from .const import CONF_CONTRIB_VERSION, DOMAIN


def contrib_announced_version(hass: HomeAssistant) -> bool:
"""Return True when contrib has stored a package version on the config entry."""
return any(
entry.data.get(CONF_CONTRIB_VERSION)
for entry in hass.config_entries.async_entries(DOMAIN)
)


def contrib_supports_presence_available(hass: HomeAssistant) -> bool:
"""Whether presence-available entity semantics apply."""
return contrib_announced_version(hass)


class NodeRedJSONEncoder(JSONEncoder):
"""JSONEncoder that supports timedelta objects and falls back to the Home Assistant Encoder."""
Expand Down
14 changes: 12 additions & 2 deletions custom_components/nodered/version.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
"""Version info for Node-RED integration."""
"""Version info for Node-RED integration.

__version__ = "4.2.3"
Single source of truth is ``manifest.json`` (HACS + release-please).
"""

from __future__ import annotations

import json
from pathlib import Path

__version__: str = json.loads(
Path(__file__).with_name("manifest.json").read_text(encoding="utf-8")
)["version"]
48 changes: 44 additions & 4 deletions custom_components/nodered/websocket.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,10 @@

from .const import (
CONF_ATTRIBUTES,
CONF_AVAILABLE,
CONF_COMPONENT,
CONF_CONFIG,
CONF_CONTRIB_VERSION,
CONF_DEVICE_INFO,
CONF_DEVICE_TRIGGER,
CONF_NODE_ID,
Expand Down Expand Up @@ -192,6 +194,7 @@ async def websocket_device_remove(
vol.Optional(CONF_CONFIG, default={}): dict,
vol.Optional(CONF_STATE): vol.Any(bool, str, int, float, None),
vol.Optional(CONF_ATTRIBUTES): dict,
vol.Optional(CONF_AVAILABLE): bool,
vol.Optional(CONF_REMOVE): bool,
vol.Optional(CONF_DEVICE_INFO): dict,
vol.Optional(CONF_DEVICE_TRIGGER): TRIGGER_SCHEMA,
Expand All @@ -214,8 +217,9 @@ def websocket_discovery(
vol.Required(CONF_TYPE): "nodered/entity",
vol.Required(CONF_SERVER_ID): cv.string,
vol.Required(CONF_NODE_ID): cv.string,
vol.Required(CONF_STATE): vol.Any(bool, str, int, float, None),
vol.Optional(CONF_ATTRIBUTES, default={}): dict,
vol.Optional(CONF_STATE): vol.Any(bool, str, int, float, None),
vol.Optional(CONF_ATTRIBUTES): dict,
vol.Optional(CONF_AVAILABLE): bool,
Comment on lines +220 to +222

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The non-sensor entities can't have this yet, they haven't been updated with the new code.

}
)
def websocket_entity(
Expand Down Expand Up @@ -247,12 +251,48 @@ def websocket_config_update(
connection.send_message(result_message(msg[CONF_ID]))


def _store_contrib_version(hass: HomeAssistant, contrib_version: str | None) -> None:
"""Persist or clear contrib package version on the Node-RED config entry."""
entries = hass.config_entries.async_entries(DOMAIN)
if not entries:
return
entry = entries[0]
new_data = dict(entry.data)
if contrib_version:
new_data[CONF_CONTRIB_VERSION] = contrib_version
else:
new_data.pop(CONF_CONTRIB_VERSION, None)
if new_data != entry.data:
hass.config_entries.async_update_entry(entry, data=new_data)


@require_admin
@websocket_command({vol.Required(CONF_TYPE): "nodered/version"})
@websocket_command(
{
vol.Required(CONF_TYPE): "nodered/version",
vol.Optional(CONF_CONTRIB_VERSION): cv.string,
}
)
def websocket_version(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
) -> None:
"""Version command."""
"""Version command.

Optional ``contrib_version`` is stored on the config entry. Only update when
the key is present so a version probe without it does not clear a prior
announce. Empty string clears. On announce, register a disconnect callback
so reconnect from a contrib that does not announce clears the stored value.
"""
if CONF_CONTRIB_VERSION in msg:

def clear_contrib_version() -> None:
_store_contrib_version(hass, None)

contrib_version = msg[CONF_CONTRIB_VERSION]
_store_contrib_version(hass, contrib_version or None)
if contrib_version:
connection.subscriptions[msg[CONF_ID]] = clear_contrib_version

connection.send_message(result_message(msg[CONF_ID], VERSION))


Expand Down
Loading
Loading