core/homeassistant/components/websocket_api/messages.py

203 lines
7.0 KiB
Python
Raw Normal View History

"""Message templates for websocket commands."""
2021-03-18 14:08:35 +00:00
from __future__ import annotations
from functools import lru_cache
import logging
from typing import TYPE_CHECKING, Any, Final, cast
import voluptuous as vol
from homeassistant.const import (
COMPRESSED_STATE_ATTRIBUTES,
COMPRESSED_STATE_CONTEXT,
COMPRESSED_STATE_LAST_CHANGED,
COMPRESSED_STATE_LAST_UPDATED,
COMPRESSED_STATE_STATE,
)
from homeassistant.core import Event, State
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.json import JSON_DUMP, find_paths_unserializable_data
from homeassistant.util.json import format_unserializable_data
from . import const
_LOGGER: Final = logging.getLogger(__name__)
# Minimal requirements of a message
MINIMAL_MESSAGE_SCHEMA: Final = vol.Schema(
2019-07-31 19:25:30 +00:00
{vol.Required("id"): cv.positive_int, vol.Required("type"): cv.string},
extra=vol.ALLOW_EXTRA,
)
# Base schema to extend by message handlers
BASE_COMMAND_MESSAGE_SCHEMA: Final = vol.Schema({vol.Required("id"): cv.positive_int})
IDEN_TEMPLATE: Final = "__IDEN__"
IDEN_JSON_TEMPLATE: Final = '"__IDEN__"'
STATE_DIFF_ADDITIONS = "+"
STATE_DIFF_REMOVALS = "-"
ENTITY_EVENT_ADD = "a"
ENTITY_EVENT_REMOVE = "r"
ENTITY_EVENT_CHANGE = "c"
def result_message(iden: int, result: Any = None) -> dict[str, Any]:
"""Return a success result message."""
2019-07-31 19:25:30 +00:00
return {"id": iden, "type": const.TYPE_RESULT, "success": True, "result": result}
def construct_result_message(iden: int, payload: str) -> str:
"""Construct a success result message JSON."""
iden_str = str(iden)
return f'{{"id":{iden_str},"type":"result","success":true,"result":{payload}}}'
def error_message(iden: int | None, code: str, message: str) -> dict[str, Any]:
"""Return an error result message."""
return {
2019-07-31 19:25:30 +00:00
"id": iden,
"type": const.TYPE_RESULT,
"success": False,
"error": {"code": code, "message": message},
}
def construct_event_message(iden: int, payload: str) -> str:
"""Construct an event message JSON."""
iden_str = str(iden)
return f'{{"id":{iden_str},"type":"event","event":{payload}}}'
def event_message(iden: int, event: Any) -> dict[str, Any]:
"""Return an event message."""
2019-07-31 19:25:30 +00:00
return {"id": iden, "type": "event", "event": event}
def cached_event_message(iden: int, event: Event) -> str:
"""Return an event message.
Serialize to json once per message.
Since we can have many clients connected that are
all getting many of the same events (mostly state changed)
we can avoid serializing the same data for each connection.
"""
return _cached_event_message(event).replace(IDEN_JSON_TEMPLATE, str(iden), 1)
@lru_cache(maxsize=128)
def _cached_event_message(event: Event) -> str:
"""Cache and serialize the event to json.
The IDEN_TEMPLATE is used which will be replaced
with the actual iden in cached_event_message
"""
return message_to_json({"id": IDEN_TEMPLATE, "type": "event", "event": event})
def cached_state_diff_message(iden: int, event: Event) -> str:
"""Return an event message.
Serialize to json once per message.
Since we can have many clients connected that are
all getting many of the same events (mostly state changed)
we can avoid serializing the same data for each connection.
"""
return _cached_state_diff_message(event).replace(IDEN_JSON_TEMPLATE, str(iden), 1)
@lru_cache(maxsize=128)
def _cached_state_diff_message(event: Event) -> str:
"""Cache and serialize the event to json.
The IDEN_TEMPLATE is used which will be replaced
with the actual iden in cached_event_message
"""
return message_to_json(
{"id": IDEN_TEMPLATE, "type": "event", "event": _state_diff_event(event)}
)
def _state_diff_event(event: Event) -> dict:
"""Convert a state_changed event to the minimal version.
State update example
{
"a": {entity_id: compressed_state,}
"c": {entity_id: diff,}
"r": [entity_id,]
}
"""
if (event_new_state := event.data["new_state"]) is None:
return {ENTITY_EVENT_REMOVE: [event.data["entity_id"]]}
if TYPE_CHECKING:
event_new_state = cast(State, event_new_state)
if (event_old_state := event.data["old_state"]) is None:
return {
ENTITY_EVENT_ADD: {
event_new_state.entity_id: event_new_state.as_compressed_state()
}
}
if TYPE_CHECKING:
event_old_state = cast(State, event_old_state)
return _state_diff(event_old_state, event_new_state)
def _state_diff(
old_state: State, new_state: State
) -> dict[str, dict[str, dict[str, dict[str, str | list[str]]]]]:
"""Create a diff dict that can be used to overlay changes."""
additions: dict[str, Any] = {}
diff: dict[str, dict[str, Any]] = {STATE_DIFF_ADDITIONS: additions}
new_state_context = new_state.context
old_state_context = old_state.context
if old_state.state != new_state.state:
additions[COMPRESSED_STATE_STATE] = new_state.state
if old_state.last_changed != new_state.last_changed:
additions[COMPRESSED_STATE_LAST_CHANGED] = new_state.last_changed.timestamp()
elif old_state.last_updated != new_state.last_updated:
additions[COMPRESSED_STATE_LAST_UPDATED] = new_state.last_updated.timestamp()
if old_state_context.parent_id != new_state_context.parent_id:
additions[COMPRESSED_STATE_CONTEXT] = {"parent_id": new_state_context.parent_id}
if old_state_context.user_id != new_state_context.user_id:
if COMPRESSED_STATE_CONTEXT in additions:
additions[COMPRESSED_STATE_CONTEXT]["user_id"] = new_state_context.user_id
else:
additions[COMPRESSED_STATE_CONTEXT] = {"user_id": new_state_context.user_id}
if old_state_context.id != new_state_context.id:
if COMPRESSED_STATE_CONTEXT in additions:
additions[COMPRESSED_STATE_CONTEXT]["id"] = new_state_context.id
else:
additions[COMPRESSED_STATE_CONTEXT] = new_state_context.id
if (old_attributes := old_state.attributes) != (
new_attributes := new_state.attributes
):
for key, value in new_attributes.items():
if old_attributes.get(key) != value:
additions.setdefault(COMPRESSED_STATE_ATTRIBUTES, {})[key] = value
if removed := set(old_attributes).difference(new_attributes):
diff[STATE_DIFF_REMOVALS] = {COMPRESSED_STATE_ATTRIBUTES: removed}
return {ENTITY_EVENT_CHANGE: {new_state.entity_id: diff}}
def message_to_json(message: dict[str, Any]) -> str:
"""Serialize a websocket message to json."""
try:
Initial orjson support take 3 (#73849) * Initial orjson support take 2 Still need to work out problem building wheels -- Redux of #72754 / #32153 Now possible since the following is solved: ijl/orjson#220 (comment) This implements orjson where we use our default encoder. This does not implement orjson where `ExtendedJSONEncoder` is used as these areas tend to be called far less frequently. If its desired, this could be done in a followup, but it seemed like a case of diminishing returns (except maybe for large diagnostics files, or traces, but those are not expected to be downloaded frequently). Areas where this makes a perceptible difference: - Anything that subscribes to entities (Initial subscribe_entities payload) - Initial download of registries on first connection / restore - History queries - Saving states to the database - Large logbook queries - Anything that subscribes to events (appdaemon) Cavets: orjson supports serializing dataclasses natively (and much faster) which eliminates the need to implement `as_dict` in many places when the data is already in a dataclass. This works well as long as all the data in the dataclass can also be serialized. I audited all places where we have an `as_dict` for a dataclass and found only backups needs to be adjusted (support for `Path` needed to be added for backups). I was a little bit worried about `SensorExtraStoredData` with `Decimal` but it all seems to work out from since it converts it before it gets to the json encoding cc @dgomes If it turns out to be a problem we can disable this with option |= [orjson.OPT_PASSTHROUGH_DATACLASS](https://github.com/ijl/orjson#opt_passthrough_dataclass) and it will fallback to `as_dict` Its quite impressive for history queries <img width="1271" alt="Screen_Shot_2022-05-30_at_23_46_30" src="https://user-images.githubusercontent.com/663432/171145699-661ad9db-d91d-4b2d-9c1a-9d7866c03a73.png"> * use for views as well * handle UnicodeEncodeError * tweak * DRY * DRY * not needed * fix tests * Update tests/components/http/test_view.py * Update tests/components/http/test_view.py * black * templates
2022-06-22 19:59:51 +00:00
return JSON_DUMP(message)
except (ValueError, TypeError):
_LOGGER.error(
"Unable to serialize to JSON. Bad data found at %s",
format_unserializable_data(
Initial orjson support take 3 (#73849) * Initial orjson support take 2 Still need to work out problem building wheels -- Redux of #72754 / #32153 Now possible since the following is solved: ijl/orjson#220 (comment) This implements orjson where we use our default encoder. This does not implement orjson where `ExtendedJSONEncoder` is used as these areas tend to be called far less frequently. If its desired, this could be done in a followup, but it seemed like a case of diminishing returns (except maybe for large diagnostics files, or traces, but those are not expected to be downloaded frequently). Areas where this makes a perceptible difference: - Anything that subscribes to entities (Initial subscribe_entities payload) - Initial download of registries on first connection / restore - History queries - Saving states to the database - Large logbook queries - Anything that subscribes to events (appdaemon) Cavets: orjson supports serializing dataclasses natively (and much faster) which eliminates the need to implement `as_dict` in many places when the data is already in a dataclass. This works well as long as all the data in the dataclass can also be serialized. I audited all places where we have an `as_dict` for a dataclass and found only backups needs to be adjusted (support for `Path` needed to be added for backups). I was a little bit worried about `SensorExtraStoredData` with `Decimal` but it all seems to work out from since it converts it before it gets to the json encoding cc @dgomes If it turns out to be a problem we can disable this with option |= [orjson.OPT_PASSTHROUGH_DATACLASS](https://github.com/ijl/orjson#opt_passthrough_dataclass) and it will fallback to `as_dict` Its quite impressive for history queries <img width="1271" alt="Screen_Shot_2022-05-30_at_23_46_30" src="https://user-images.githubusercontent.com/663432/171145699-661ad9db-d91d-4b2d-9c1a-9d7866c03a73.png"> * use for views as well * handle UnicodeEncodeError * tweak * DRY * DRY * not needed * fix tests * Update tests/components/http/test_view.py * Update tests/components/http/test_view.py * black * templates
2022-06-22 19:59:51 +00:00
find_paths_unserializable_data(message, dump=JSON_DUMP)
),
)
Initial orjson support take 3 (#73849) * Initial orjson support take 2 Still need to work out problem building wheels -- Redux of #72754 / #32153 Now possible since the following is solved: ijl/orjson#220 (comment) This implements orjson where we use our default encoder. This does not implement orjson where `ExtendedJSONEncoder` is used as these areas tend to be called far less frequently. If its desired, this could be done in a followup, but it seemed like a case of diminishing returns (except maybe for large diagnostics files, or traces, but those are not expected to be downloaded frequently). Areas where this makes a perceptible difference: - Anything that subscribes to entities (Initial subscribe_entities payload) - Initial download of registries on first connection / restore - History queries - Saving states to the database - Large logbook queries - Anything that subscribes to events (appdaemon) Cavets: orjson supports serializing dataclasses natively (and much faster) which eliminates the need to implement `as_dict` in many places when the data is already in a dataclass. This works well as long as all the data in the dataclass can also be serialized. I audited all places where we have an `as_dict` for a dataclass and found only backups needs to be adjusted (support for `Path` needed to be added for backups). I was a little bit worried about `SensorExtraStoredData` with `Decimal` but it all seems to work out from since it converts it before it gets to the json encoding cc @dgomes If it turns out to be a problem we can disable this with option |= [orjson.OPT_PASSTHROUGH_DATACLASS](https://github.com/ijl/orjson#opt_passthrough_dataclass) and it will fallback to `as_dict` Its quite impressive for history queries <img width="1271" alt="Screen_Shot_2022-05-30_at_23_46_30" src="https://user-images.githubusercontent.com/663432/171145699-661ad9db-d91d-4b2d-9c1a-9d7866c03a73.png"> * use for views as well * handle UnicodeEncodeError * tweak * DRY * DRY * not needed * fix tests * Update tests/components/http/test_view.py * Update tests/components/http/test_view.py * black * templates
2022-06-22 19:59:51 +00:00
return JSON_DUMP(
error_message(
message["id"], const.ERR_UNKNOWN_ERROR, "Invalid JSON in response"
)
)