import { HassEntity } from "home-assistant-js-websocket"; import { BINARY_STATE_OFF, BINARY_STATE_ON, DOMAINS_WITH_DYNAMIC_PICTURE, } from "../common/const"; import { computeDomain } from "../common/entity/compute_domain"; import { computeStateDisplay } from "../common/entity/compute_state_display"; import { computeStateDomain } from "../common/entity/compute_state_domain"; import { LocalizeFunc } from "../common/translations/localize"; import { HaEntityPickerEntityFilterFunc } from "../components/entity/ha-entity-picker"; import { HomeAssistant } from "../types"; import { UNAVAILABLE, UNKNOWN } from "./entity"; const LOGBOOK_LOCALIZE_PATH = "ui.components.logbook.messages"; export const CONTINUOUS_DOMAINS = ["counter", "proximity", "sensor", "zone"]; export interface LogbookStreamMessage { events: LogbookEntry[]; start_time?: number; // Start time of this historical chunk end_time?: number; // End time of this historical chunk partial?: boolean; // Indiciates more historical chunks are coming } export interface LogbookEntry { // Base data when: number; // Python timestamp. Do *1000 to get JS timestamp. name: string; message?: string; entity_id?: string; icon?: string; source?: string; // The trigger source domain?: string; state?: string; // The state of the entity // Context data context_id?: string; context_user_id?: string; context_event_type?: string; context_domain?: string; context_service?: string; // Service calls only context_entity_id?: string; context_entity_id_name?: string; // Legacy, not longer sent context_name?: string; context_state?: string; // The state of the entity context_source?: string; // The trigger source context_message?: string; } // // Localization mapping for all the triggers in core // in homeassistant.components.homeassistant.triggers // const triggerPhrases = { "numeric state of": "triggered_by_numeric_state_of", // number state trigger "state of": "triggered_by_state_of", // state trigger event: "triggered_by_event", // event trigger time: "triggered_by_time", // time trigger "time pattern": "triggered_by_time_pattern", // time trigger "Home Assistant stopping": "triggered_by_homeassistant_stopping", // stop event "Home Assistant starting": "triggered_by_homeassistant_starting", // start event }; const DATA_CACHE: { [cacheKey: string]: { [entityId: string]: Promise }; } = {}; export const getLogbookDataForContext = async ( hass: HomeAssistant, startDate: string, contextId?: string ): Promise => { await hass.loadBackendTranslation("device_class"); return getLogbookDataFromServer( hass, startDate, undefined, undefined, contextId ); }; export const getLogbookData = async ( hass: HomeAssistant, startDate: string, endDate: string, entityIds?: string[], deviceIds?: string[] ): Promise => { await hass.loadBackendTranslation("device_class"); return deviceIds?.length ? getLogbookDataFromServer( hass, startDate, endDate, entityIds, undefined, deviceIds ) : getLogbookDataCache(hass, startDate, endDate, entityIds); }; const getLogbookDataCache = async ( hass: HomeAssistant, startDate: string, endDate: string, entityId?: string[] ) => { const ALL_ENTITIES = "*"; const entityIdKey = entityId ? entityId.toString() : ALL_ENTITIES; const cacheKey = `${startDate}${endDate}`; if (!DATA_CACHE[cacheKey]) { DATA_CACHE[cacheKey] = {}; } if (entityIdKey in DATA_CACHE[cacheKey]) { return DATA_CACHE[cacheKey][entityIdKey]; } if (entityId && DATA_CACHE[cacheKey][ALL_ENTITIES]) { const entities = await DATA_CACHE[cacheKey][ALL_ENTITIES]; return entities.filter( (entity) => entity.entity_id && entityId.includes(entity.entity_id) ); } DATA_CACHE[cacheKey][entityIdKey] = getLogbookDataFromServer( hass, startDate, endDate, entityId ); return DATA_CACHE[cacheKey][entityIdKey]; }; const getLogbookDataFromServer = ( hass: HomeAssistant, startDate: string, endDate?: string, entityIds?: string[], contextId?: string, deviceIds?: string[] ): Promise => { // If all specified filters are empty lists, we can return an empty list. if ( (entityIds || deviceIds) && (!entityIds || entityIds.length === 0) && (!deviceIds || deviceIds.length === 0) ) { return Promise.resolve([]); } const params: any = { type: "logbook/get_events", start_time: startDate, }; if (endDate) { params.end_time = endDate; } if (entityIds?.length) { params.entity_ids = entityIds; } if (deviceIds?.length) { params.device_ids = deviceIds; } if (contextId) { params.context_id = contextId; } return hass.callWS(params); }; export const subscribeLogbook = ( hass: HomeAssistant, callbackFunction: (message: LogbookStreamMessage) => void, startDate: string, endDate: string, entityIds?: string[], deviceIds?: string[] ): Promise<() => Promise> => { // If all specified filters are empty lists, we can return an empty list. if ( (entityIds || deviceIds) && (!entityIds || entityIds.length === 0) && (!deviceIds || deviceIds.length === 0) ) { return Promise.reject("No entities or devices"); } const params: any = { type: "logbook/event_stream", start_time: startDate, end_time: endDate, }; if (entityIds?.length) { params.entity_ids = entityIds; } if (deviceIds?.length) { params.device_ids = deviceIds; } return hass.connection.subscribeMessage( (message) => callbackFunction(message), params ); }; export const clearLogbookCache = (startDate: string, endDate: string) => { DATA_CACHE[`${startDate}${endDate}`] = {}; }; export const createHistoricState = ( currentStateObj: HassEntity, state?: string ): HassEntity => ({ entity_id: currentStateObj.entity_id, state: state, attributes: { // Rebuild the historical state by copying static attributes only device_class: currentStateObj?.attributes.device_class, source_type: currentStateObj?.attributes.source_type, has_date: currentStateObj?.attributes.has_date, has_time: currentStateObj?.attributes.has_time, // We do not want to use dynamic entity pictures (e.g., from media player) for the log book rendering, // as they would present a false state in the log (played media right now vs actual historic data). entity_picture_local: DOMAINS_WITH_DYNAMIC_PICTURE.has( computeDomain(currentStateObj.entity_id) ) ? undefined : currentStateObj?.attributes.entity_picture_local, entity_picture: DOMAINS_WITH_DYNAMIC_PICTURE.has( computeDomain(currentStateObj.entity_id) ) ? undefined : currentStateObj?.attributes.entity_picture, }, }); export const localizeTriggerSource = ( localize: LocalizeFunc, source: string ) => { for (const triggerPhrase in triggerPhrases) { if (source.startsWith(triggerPhrase)) { return source.replace( triggerPhrase, `${localize(`ui.components.logbook.${triggerPhrases[triggerPhrase]}`)}` ); } } return source; }; export const localizeStateMessage = ( hass: HomeAssistant, localize: LocalizeFunc, state: string, stateObj: HassEntity, domain: string ): string => { switch (domain) { case "device_tracker": case "person": if (state === "not_home") { return localize(`${LOGBOOK_LOCALIZE_PATH}.was_away`); } if (state === "home") { return localize(`${LOGBOOK_LOCALIZE_PATH}.was_at_home`); } return localize(`${LOGBOOK_LOCALIZE_PATH}.was_at_state`, "state", state); case "sun": return state === "above_horizon" ? localize(`${LOGBOOK_LOCALIZE_PATH}.rose`) : localize(`${LOGBOOK_LOCALIZE_PATH}.set`); case "binary_sensor": { const isOn = state === BINARY_STATE_ON; const isOff = state === BINARY_STATE_OFF; const device_class = stateObj.attributes.device_class; switch (device_class) { case "battery": if (isOn) { return localize(`${LOGBOOK_LOCALIZE_PATH}.was_low`); } if (isOff) { return localize(`${LOGBOOK_LOCALIZE_PATH}.was_normal`); } break; case "connectivity": if (isOn) { return localize(`${LOGBOOK_LOCALIZE_PATH}.was_connected`); } if (isOff) { return localize(`${LOGBOOK_LOCALIZE_PATH}.was_disconnected`); } break; case "door": case "garage_door": case "opening": case "window": if (isOn) { return localize(`${LOGBOOK_LOCALIZE_PATH}.was_opened`); } if (isOff) { return localize(`${LOGBOOK_LOCALIZE_PATH}.was_closed`); } break; case "lock": if (isOn) { return localize(`${LOGBOOK_LOCALIZE_PATH}.was_unlocked`); } if (isOff) { return localize(`${LOGBOOK_LOCALIZE_PATH}.was_locked`); } break; case "plug": if (isOn) { return localize(`${LOGBOOK_LOCALIZE_PATH}.was_plugged_in`); } if (isOff) { return localize(`${LOGBOOK_LOCALIZE_PATH}.was_unplugged`); } break; case "presence": if (isOn) { return localize(`${LOGBOOK_LOCALIZE_PATH}.was_at_home`); } if (isOff) { return localize(`${LOGBOOK_LOCALIZE_PATH}.was_away`); } break; case "safety": if (isOn) { return localize(`${LOGBOOK_LOCALIZE_PATH}.was_unsafe`); } if (isOff) { return localize(`${LOGBOOK_LOCALIZE_PATH}.was_safe`); } break; case "cold": case "gas": case "heat": case "moisture": case "motion": case "occupancy": case "power": case "problem": case "smoke": case "sound": case "vibration": if (isOn) { return localize(`${LOGBOOK_LOCALIZE_PATH}.detected_device_class`, { device_class: localize( `component.binary_sensor.device_class.${device_class}` ), }); } if (isOff) { return localize(`${LOGBOOK_LOCALIZE_PATH}.cleared_device_class`, { device_class: localize( `component.binary_sensor.device_class.${device_class}` ), }); } break; case "tamper": if (isOn) { return localize(`${LOGBOOK_LOCALIZE_PATH}.detected_tampering`); } if (isOff) { return localize(`${LOGBOOK_LOCALIZE_PATH}.cleared_tampering`); } break; } break; } case "cover": switch (state) { case "open": return localize(`${LOGBOOK_LOCALIZE_PATH}.was_opened`); case "opening": return localize(`${LOGBOOK_LOCALIZE_PATH}.is_opening`); case "closing": return localize(`${LOGBOOK_LOCALIZE_PATH}.is_closing`); case "closed": return localize(`${LOGBOOK_LOCALIZE_PATH}.was_closed`); } break; case "lock": switch (state) { case "unlocked": return localize(`${LOGBOOK_LOCALIZE_PATH}.was_unlocked`); case "locking": return localize(`${LOGBOOK_LOCALIZE_PATH}.is_locking`); case "unlocking": return localize(`${LOGBOOK_LOCALIZE_PATH}.is_unlocking`); case "locked": return localize(`${LOGBOOK_LOCALIZE_PATH}.was_locked`); case "jammed": return localize(`${LOGBOOK_LOCALIZE_PATH}.is_jammed`); } break; } if (state === BINARY_STATE_ON) { return localize(`${LOGBOOK_LOCALIZE_PATH}.turned_on`); } if (state === BINARY_STATE_OFF) { return localize(`${LOGBOOK_LOCALIZE_PATH}.turned_off`); } if (state === UNKNOWN) { return localize(`${LOGBOOK_LOCALIZE_PATH}.became_unknown`); } if (state === UNAVAILABLE) { return localize(`${LOGBOOK_LOCALIZE_PATH}.became_unavailable`); } return hass.localize( `${LOGBOOK_LOCALIZE_PATH}.changed_to_state`, "state", stateObj ? computeStateDisplay(localize, stateObj, hass.locale, state) : state ); }; export const filterLogbookCompatibleEntities: HaEntityPickerEntityFilterFunc = ( entity ) => computeStateDomain(entity) !== "sensor" || (entity.attributes.unit_of_measurement === undefined && entity.attributes.state_class === undefined);